fix: realistic transaction costs, colorbar layout, equity curve clipping
- Costs updated to evidence-based values (SEC small-cap liquidity study 2013, Nasdaq spread data 2021, AQR Trading Costs paper 2018): large ~0.2% RT, mid ~0.5%, small ~1.5%, micro ~5% - Micro-cap note: Alpaca does not allow new OTC/Pink Sheet positions; most micro-cap signals are untradeable; at realistic 5% RT, micro-cap destroys capital (-36% to -81% excess return) - db.py: get_cached_market_caps returns already_fetched set including null rows, preventing repeated yfinance re-queries for known-missing tickers - plot_hp_heatmap: colorbar in dedicated axes (right margin), no overlap - plot_equity_curves: two-pass approach clips all curves to min end date - README: updated cost table, shortened insidercopytrading.com section Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+45
-30
@@ -45,14 +45,17 @@ def plot_hp_heatmap(prices: dict, out_dir: str = PLOTS_DIR, signals=None, market
|
||||
buy_delays = [0, 1, 2, 3]
|
||||
|
||||
# Cap tier definitions: (label, cap_tier, spread, slippage)
|
||||
# Costs match README results table. commission=0 (Alpaca).
|
||||
# Costs based on SEC small-cap liquidity study (2013), Nasdaq spread data (2021),
|
||||
# and Frazzini/Israel/Moskowitz "Trading Costs" (AQR, 2018).
|
||||
# Alpaca charges zero commission. OTC/Pink Sheet stocks cannot be opened on Alpaca
|
||||
# (close-only), so micro-cap signals overlap heavily with untradeable names.
|
||||
tiers = [
|
||||
("Theoretical (0% RT, all)", None, 0.000, 0.000),
|
||||
("All cap (~0.7% RT)", None, 0.0025, 0.002),
|
||||
("Large cap (~0.2% RT)", "large", 0.001, 0.001),
|
||||
("Mid cap (~0.5% RT)", "mid", 0.0015, 0.0015),
|
||||
("Small cap (~0.8% RT)", "small", 0.003, 0.002),
|
||||
("Micro cap (~1.6% RT)", "micro", 0.005, 0.003),
|
||||
("Theoretical (0% RT, all)", None, 0.000, 0.000),
|
||||
("All cap (~1% RT)", None, 0.003, 0.004),
|
||||
("Large cap (~0.2% RT)", "large", 0.0005, 0.001),
|
||||
("Mid cap (~0.5% RT)", "mid", 0.0015, 0.002),
|
||||
("Small cap (~1.5% RT)", "small", 0.005, 0.005),
|
||||
("Micro cap (~5% RT, if listed)", "micro", 0.015, 0.020),
|
||||
]
|
||||
|
||||
total = len(tiers) * len(hold_days) * len(buy_delays)
|
||||
@@ -104,17 +107,18 @@ def plot_hp_heatmap(prices: dict, out_dir: str = PLOTS_DIR, signals=None, market
|
||||
ax.text(j, i, f"{val:+.1f}", ha="center", va="center",
|
||||
fontsize=8, color=color)
|
||||
|
||||
# Shared colorbar
|
||||
fig.colorbar(
|
||||
plt.cm.ScalarMappable(norm=norm, cmap="RdYlGn"),
|
||||
ax=axes_flat, label="Annualised excess return vs SPY (%)", shrink=0.6,
|
||||
)
|
||||
|
||||
fig.suptitle(
|
||||
"HP sweep: holding period x entry delay, by cap tier (Alpaca, zero commission)",
|
||||
fontsize=13,
|
||||
)
|
||||
plt.tight_layout()
|
||||
plt.tight_layout(rect=[0, 0, 0.88, 1])
|
||||
|
||||
# Shared colorbar in reserved right margin — avoids overlapping panels
|
||||
cbar_ax = fig.add_axes([0.905, 0.15, 0.018, 0.65])
|
||||
fig.colorbar(
|
||||
plt.cm.ScalarMappable(norm=norm, cmap="RdYlGn"),
|
||||
cax=cbar_ax, label="Annualised excess return vs SPY (%)",
|
||||
)
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
out = os.path.join(out_dir, "hp_sweep.png")
|
||||
@@ -130,21 +134,26 @@ def plot_equity_curves(prices: dict, out_dir: str = PLOTS_DIR, signals=None, mar
|
||||
"""
|
||||
matplotlib, plt, mdates, np = _get_matplotlib()
|
||||
|
||||
# Alpaca zero-commission costs by cap tier (spread + slippage only)
|
||||
# Costs match the values used in the README results table
|
||||
# Realistic Alpaca costs by cap tier (zero commission, spread + slippage only).
|
||||
# Sources: SEC small-cap liquidity study (2013); Nasdaq spread data (2021);
|
||||
# Frazzini/Israel/Moskowitz "Trading Costs" AQR (2018).
|
||||
# Micro-cap: Alpaca does not allow new positions in OTC/Pink Sheet stocks — most
|
||||
# micro-cap names fall in this category and are simply not tradeable.
|
||||
scenarios = [
|
||||
{"label": "Large cap (~0.2% RT)", "cap_tier": "large", "spread": 0.001, "slippage": 0.001},
|
||||
{"label": "Mid cap (~0.5% RT)", "cap_tier": "mid", "spread": 0.0015, "slippage": 0.0015},
|
||||
{"label": "Small cap (~0.8% RT)", "cap_tier": "small", "spread": 0.003, "slippage": 0.002},
|
||||
{"label": "Micro cap (~1.6% RT)", "cap_tier": "micro", "spread": 0.005, "slippage": 0.003},
|
||||
{"label": "Large cap (~0.2% RT)", "cap_tier": "large", "spread": 0.0005, "slippage": 0.001},
|
||||
{"label": "Mid cap (~0.5% RT)", "cap_tier": "mid", "spread": 0.0015, "slippage": 0.002},
|
||||
{"label": "Small cap (~1.5% RT)", "cap_tier": "small", "spread": 0.005, "slippage": 0.005},
|
||||
{"label": "Micro cap (~5% RT, if listed)", "cap_tier": "micro", "spread": 0.015, "slippage": 0.020},
|
||||
]
|
||||
|
||||
fig, ax = plt.subplots(figsize=(13, 7))
|
||||
|
||||
colors = ["#2ecc71", "#3498db", "#e67e22", "#e74c3c"]
|
||||
sim_start = None
|
||||
last_curve_date = None # earliest end across all scenarios — SPY clipped here
|
||||
last_curve_date = None # earliest end across all scenarios — all curves clipped here
|
||||
|
||||
# First pass: simulate and find common end date
|
||||
raw_curves = []
|
||||
for sc, color in zip(scenarios, colors):
|
||||
s = Strategy(
|
||||
holding_days=7, buy_delay=1,
|
||||
@@ -154,13 +163,19 @@ def plot_equity_curves(prices: dict, out_dir: str = PLOTS_DIR, signals=None, mar
|
||||
print(f" equity curve: {sc['label']}...", flush=True)
|
||||
r = simulate(s, prices=prices, _signals=signals, _market_caps=market_caps)
|
||||
curve = r.get("equity_curve", [])
|
||||
raw_curves.append((sc, color, curve, r))
|
||||
if curve:
|
||||
sim_start = sim_start or r["period"]["start"]
|
||||
end = curve[-1][0]
|
||||
last_curve_date = min(last_curve_date, end) if last_curve_date else end
|
||||
|
||||
# Second pass: plot all curves clipped to the minimum end date
|
||||
for sc, color, curve, r in raw_curves:
|
||||
if not curve:
|
||||
continue
|
||||
curve = [(d, v) for d, v in curve if d <= last_curve_date]
|
||||
if not curve:
|
||||
continue
|
||||
|
||||
sim_start = sim_start or r["period"]["start"]
|
||||
end = curve[-1][0]
|
||||
last_curve_date = min(last_curve_date, end) if last_curve_date else end
|
||||
|
||||
dates = [datetime.strptime(d, "%Y-%m-%d") for d, _ in curve]
|
||||
values = [v for _, v in curve]
|
||||
base = values[0]
|
||||
@@ -213,10 +228,10 @@ def plot_position_size(prices: dict, out_dir: str = PLOTS_DIR, signals=None, mar
|
||||
pos_sizes = [0.03, 0.05, 0.07, 0.10, 0.15, 0.20, 0.25]
|
||||
|
||||
tiers = [
|
||||
("Large (~0.2% RT)", "large", 0.001, 0.001),
|
||||
("Mid (~0.5% RT)", "mid", 0.0015, 0.0015),
|
||||
("Small (~0.8% RT)", "small", 0.003, 0.002),
|
||||
("Micro (~1.6% RT)", "micro", 0.005, 0.003),
|
||||
("Large (~0.2% RT)", "large", 0.0005, 0.001),
|
||||
("Mid (~0.5% RT)", "mid", 0.0015, 0.002),
|
||||
("Small (~1.5% RT)", "small", 0.005, 0.005),
|
||||
("Micro (~5% RT, if lsted)","micro", 0.015, 0.020),
|
||||
]
|
||||
|
||||
colors = ["#2ecc71", "#3498db", "#e67e22", "#e74c3c"]
|
||||
|
||||
@@ -47,9 +47,9 @@ def _fetch_market_caps(tickers: list[str]) -> dict[str, float]:
|
||||
import yfinance as yf
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
cached = get_cached_market_caps(tickers)
|
||||
# Skip tickers with special chars that yfinance can't handle
|
||||
missing = [t for t in tickers if t not in cached and "/" not in t]
|
||||
cached, already_fetched = get_cached_market_caps(tickers)
|
||||
# Skip tickers already tried (even if null) and those with special chars
|
||||
missing = [t for t in tickers if t not in already_fetched and "/" not in t]
|
||||
|
||||
if missing:
|
||||
logger.info(f"Fetching market caps for {len(missing)} tickers via yfinance (parallel)...")
|
||||
|
||||
Reference in New Issue
Block a user