diff --git a/CLAUDE.md b/CLAUDE.md index 22486e3..3f5c9c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,6 +123,7 @@ PYTHONPATH=. uv run python -m research.lab.fetch_options backfill # OPRA PYTHONPATH=. uv run python -m research.lab.fetch_options volume-panel # date x underlying option volume (all 0-100 DTE strikes and the 5-100 DTE / 0.7-1.3 band), os_volume, pc_volume PYTHONPATH=. uv run python -m research.lab.auction_audit [fetch] # overnight sleeve: Yahoo open/close vs the primary exchange's opening/closing cross prints (Alpaca SIP trades), 800 nights 2016-26 (preregistered; PASS 2026-10-12: +0.20 bp a side incl. SEC fee, the 1 bp backtest cost is conservative) PYTHONPATH=. uv run python -m research.lab.no_letf_book # books without leveraged ETFs for the $10k IBKR account: QQQ trend held in QQQ (+ Reg T margin), momentum blends, vs QQQ at matched risk (preregistered; NONE BEATS QQQ 2026-10-13: Sharpe gain < 0.10; trend's gain is crashes, MaxDD -28% vs -83% since 1990; x2 margin 17.0% vs 14.9%) +PYTHONPATH=. uv run python -m research.lab.small_account_momentum # 10-day momentum in a $10k/$20k IBKR account: whole shares, Tiered/Fixed fees, rb20 / SPY de-risk / fundamental re-sort / half QQQ vs QQQ (preregistered; NONE PASSES 2026-10-13: all fail 2011-16 Sharpe; rb20_fundmom 26.3%/0.96/-32% vs QQQ 19.1%/0.87/-35% 2011-26 but no 2008 test) PYTHONPATH=. uv run python -m research.lab.option_overlay [fetch] # leverage routes (Reg T margin, box, MES futures, calls) and S&P 500 puts (Cboe PPUT3M/PPUT held to expiry) for $10k-$100k accounts (preregistered; 2026-10-12: no route reaches 50% under Reg T, cap L 1.71 -> 36%/yr; puts B FAIL +1.0pp) PYTHONPATH=. uv run --with numba python -m research.lab.option_volume # Johnson-So O/S and put/call volume on OPRA bars 2024-03..2026-09 (preregistered; STOP 2026-10-12: O/S sign reversed, t -1.6, corr -0.65 with momentum; put/call t 0.4) uv run python -m research.lab.fetch_us_fundamentals --universe mid # EDGAR PIT facts for S&P 400 ever-members (name-checked CIKs) diff --git a/research/lab/small_account_momentum.py b/research/lab/small_account_momentum.py new file mode 100644 index 0000000..9aa3ab1 --- /dev/null +++ b/research/lab/small_account_momentum.py @@ -0,0 +1,496 @@ +""" +10-day momentum in a $10k-$20k IBKR account: whole shares, real commissions +and low-frequency risk controls, against holding QQQ (preregistered). + + PYTHONPATH=. uv run python -m research.lab.small_account_momentum + +Question (2026-10-13). The $10k IBKR account has no leveraged-ETF permission. +Nothing in no_letf_book beat QQQ, and the overnight sleeve needs near-free +orders. The 10-day momentum book (overnight_recovery_top10, what ../ibkr-quant +runs on IBKR paper) sends about 140 orders a year, so commissions are not its +problem; its problem is risk (MaxDD -69% since 2006, Sharpe below QQQ). This +study asks whether three low-frequency changes, already studied one at a time +on fractional books, make it beat QQQ once whole shares, IBKR commissions and +a small account are modelled: + + rb20 resize band 20% instead of 10% (fewer resizing orders) + derisk de-risk-only vol target: exposure = min(1, expanding median / EWMA + vol (hl 20) of SPY), read at the signal date, applied at each + rebalance (us_sweep 2026-09-29: MaxDD halved, Sharpe +0.03) + fundmom momentum top 30 re-sorted by fundamental momentum (mean percentile + of SUE, revenue SUE, gross-margin change, ROA change; EDGAR first + filings), top 10, buffer 1 (momentum_fundamentals 2026-09-30: + holdout Sharpe 0.97 -> 1.06, MaxDD -42% -> -35%) + half_qqq the momentum book on half the account, QQQ on the other half + +Simulation (fixed before running) + * PIT S&P 500 research panel with the Tiingo overlay. Signal on bars through + day d-1, MOC fill at day d's close (the live convention). Top 10, every 10 + sessions, rank buffer 2 (fundmom: 1), equal weight, 2% cash buffer. + * Whole shares at the price of the day: split-adjusted, dividend-unadjusted + Yahoo closes x later split ratios, else Tiingo's raw close, else the + adjusted close. Orders are sized by execution.compute_trades, the live + ledger's rule (round toward zero, leftover cash buys one more share for + the names furthest below target, $50 minimum order, resize band) and + filled like execution.execute_trades (sells first, then each buy shrunk + to the whole shares the remaining cash pays for: no margin). + * Constant account size: at every rebalance the account is valued at the + stated capital ($10k or $20k) for share counts and fees, so the friction + of a small account does not fade as the backtest compounds. + * IBKR Pro Tiered: max(0.35, 0.0035/share) capped at 1% of value, +0.0002/share + clearing, +0.0012/share closing-auction fee (approximate), SEC 0.0000206 x + sales, CAT 0.000003/share. Fixed: max(1, 0.005/share) capped at 1%, + SEC/CAT. + Plus 2 bp a side against the official close (auction_audit measured 0.2). + * Idle cash earns nothing (IBKR pays no interest on the first $10k). + Dividends are reinvested in the name (adjusted prices). A held name with + no print for 5 sessions that has left the index is settled at its last print. + * Phase averaging: 10 rebalance phases; statistics are the mean over phases. + * Data filter: a member-day whose 63-day median dollar volume is under $2M is + neither selectable nor tradable. Found while building this study: EP, CPWR, + SW, GR, SII, MIL, GENZ, KG, LSI carry another security's (or stale) prices + in the research panel; ~13.8k of 2.6M member-days. + +Windows: 2011-01-03..2026-09-24 for every variant (fundamentals cover ~90% of +members from 2011), design 2011-2016, holdout 2017-2026. Price-only variants +are also run from 2006-10-02 to include 2008. + +Gates (each at $10k and at $20k, Tiered), against QQQ total return: + G1 CAGR >= QQQ in design and holdout + G2 MaxDD 2011-2026 no deeper than QQQ's; price-only variants also 2006-2026 + G3 excess-return Sharpe >= QQQ in design and holdout +PASS = G1 and G2 and G3. Among passing variants the highest full-span Sharpe +is preferred. Data status: the momentum signal, rb20, the de-risk overlay and +fund_momentum were each examined on these years before; the holdout is not +independent out-of-sample data. What is new here is the combination, whole +shares, commissions at $10k/$20k and the gates against QQQ. + +Results (2026-10-13): NONE PASSES. Every variant fails G3 in 2011-2016, when +QQQ itself ran at Sharpe 0.92 with a -16% drawdown. + + mean of 10 phases, $10k Tiered CAGR des/hold Sharpe des/hold MaxDD 11-26 orders/yr + QQQ 15.2% / 21.5% 0.92 / 0.86 -35% + base (ibkr-quant) 17.2% / 29.0% 0.78 / 0.87 -38% 82 + rb20 17.3% / 29.5% 0.79 / 0.88 -37% 67 + rb20_derisk 16.3% / 21.7% 0.82 / 0.74 -36% 79 + rb20_fundmom 16.5% / 32.7% 0.80 / 1.04 -32% G1 G2 92 + rb20_fundmom_derisk 15.1% / 25.7% 0.82 / 0.93 -31% G2 103 + rb20_half_qqq 16.2% / 24.2% 0.87 / 0.91 -29% 58 + ($20k: rb20_fundmom 16.7% / 34.9%, 0.80 / 1.05, -35%; 2006-26 price-only: + base 21.5% / -73%, rb20_derisk 18.2% / -38%, half_qqq 18.5% / -62%, QQQ 16.5% / -53%) + + * Friction is small: Tiered fees 0.3-0.5%/yr, Fixed 0.6-1.2%. At $10k the + larger cost is whole shares: 5-8% of picks get no shares (cash left after + in-band winners, or one share above the name's budget) vs ~0.5% at $20k; + rb20_fundmom holdout 32.7% at $10k, 34.9% at $20k, 35.3% frictionless. + * Plain momentum's holdout edge is 2026: 2017-2025 rb20 21.7% / 0.75 vs QQQ + 20.9% / 0.84; it beat QQQ in 5 of 16 years. rb20_fundmom 2017-2025 28.6% / + 1.02 / -31%, beat QQQ in 10 of 16 years ($20k: 11). + * The de-risk overlay pays only in 2008-type crashes (2006-26 MaxDD -73% -> + -38%); in 2011-26 it costs 8 points of holdout CAGR and lowers Sharpe. + * rb20_fundmom meets the user's two stated criteria (return >= QQQ, drawdown + no deeper) in 2011-26 but is untested in 2008 (EDGAR coverage starts 2011) + and was picked in momentum_fundamentals after seeing these years. + * Simulator check (--smoke): with no resize band, no costs and no delisting + settlement it reproduces research/lab/engine.py (21.7% vs 22.0%, daily + corr 0.99). The lab engine never settles delisted names: capital in + acquired names stays frozen, which costs momentum ~2.8 points a year in + 2011-26 (24.5% with settlement). +""" + +from __future__ import annotations + +import dataclasses +import json +import os +import pickle +import sys +import warnings +from concurrent.futures import ProcessPoolExecutor + +import numpy as np +import pandas as pd + +from execution import compute_trades +from research.lab import fundamentals as fu +from research.lab import overlays as ov +from research.lab.alpha import alpha_stats, bench_returns +from research.lab.engine import Costs, select_top +from research.lab.factor_scan import RESULTS, load +from research.lab.levered_book import stats, tbill +from research.lab.rotation import factor_ranks +from research.lab.us_sweep import _blend + +warnings.filterwarnings("ignore") +OUT = os.path.join(RESULTS, "small_account_momentum") +SPAN = ("2011-01-03", "2026-09-24") +LONG = ("2006-10-02", "2026-09-24") +DESIGN_END, HOLDOUT = "2016-12-31", "2017-01-01" +TOP_N, EVERY = 10, 10 +CASH_BUFFER, MIN_ORDER, MARKET_BP = 0.02, 50.0, 2.0 +SETTLE_AFTER = 5 +MIN_DOLLAR_VOLUME = 2e6 # 63-day median; below it the series is not the member's (data filter) +TIINGO = os.path.join("data", "research", "tiingo") + +VARIANTS = { + "base": dict(score="mom", band=0.10, derisk=False, qqq=0.0), # what ibkr-quant runs + "rb20": dict(score="mom", band=0.20, derisk=False, qqq=0.0), + "rb20_derisk": dict(score="mom", band=0.20, derisk=True, qqq=0.0), + "rb20_fundmom": dict(score="fundmom", band=0.20, derisk=False, qqq=0.0), + "rb20_fundmom_derisk": dict(score="fundmom", band=0.20, derisk=True, qqq=0.0), + "rb20_half_qqq": dict(score="mom", band=0.20, derisk=False, qqq=0.5), +} +# (label, capital, pricing, market bp a side, cash earns T-bill) +CONFIGS = { + "10k_tiered": (10_000.0, "tiered", MARKET_BP, False), + "20k_tiered": (20_000.0, "tiered", MARKET_BP, False), + "10k_fixed": (10_000.0, "fixed", MARKET_BP, False), + "20k_fixed": (20_000.0, "fixed", MARKET_BP, False), + "10k_tiered_10bp": (10_000.0, "tiered", 10.0, False), + "10k_tiered_tbill_cash": (10_000.0, "tiered", MARKET_BP, True), + "frictionless": (1e9, "none", 0.0, False), +} +GATE_CONFIGS = ("10k_tiered", "20k_tiered") +LONG_CONFIGS = ("10k_tiered", "20k_tiered", "frictionless") +PREREG = { + "question": "does 10-day momentum with low-frequency risk controls beat QQQ in a $10k/$20k IBKR account", + "variants": {k: v for k, v in VARIANTS.items()}, + "simulation": "PIT S&P 500 + Tiingo; signal through d-1, MOC at d's close; top 10 every 10 sessions; " + "whole shares via execution.compute_trades at raw prices; constant account size; " + "10 phases averaged; idle cash earns 0; 2% cash buffer", + "costs": "IBKR Pro Tiered max(0.35, 0.0035/sh) cap 1% + 0.0002/sh clearing + 0.0012/sh auction + SEC/CAT; " + "Fixed max(1, 0.005/sh) cap 1% + SEC/CAT; 2 bp a side market cost", + "configs": {k: list(v) for k, v in CONFIGS.items()}, + "windows": {"span": SPAN, "design_end": DESIGN_END, "holdout_start": HOLDOUT, "long_price_only": LONG}, + "gates": {"G1": "mean-phase CAGR >= QQQ in design and holdout", + "G2": "mean-phase MaxDD 2011-26 no deeper than QQQ; price-only also 2006-26", + "G3": "mean-phase excess Sharpe >= QQQ in design and holdout"}, + "evaluated_at": GATE_CONFIGS, + "decision": "PASS = G1 & G2 & G3; prefer the highest full-span Sharpe among passing variants; " + "none passing -> report the gate margins and keep QQQ as the default", + "data_filter": "member-days with 63-day median dollar volume < $2M excluded (wrong-security series)", + "data_status": "signal, rb20, de-risk overlay and fund_momentum examined on these years before; " + "holdout is not independent OOS", +} + + +# --- prices --------------------------------------------------------------- + +def raw_prices(p) -> tuple[pd.DataFrame, dict]: + """Close as it printed that day: Yahoo split-adjusted (not dividend-adjusted) + close x the split ratios after the day; Tiingo's raw close for names Yahoo + lacks; the adjusted close as the last resort.""" + with open(os.path.join("data", "research", "us_fund_raw.pkl"), "rb") as fh: + raw = pickle.load(fh) + sa = raw["close_sa"].reindex(p.dates) + ratio = raw["splits"].reindex(p.dates).fillna(0.0).replace(0.0, 1.0) + after = ratio[::-1].cumprod()[::-1] / ratio + R = (sa * after.reindex(columns=sa.columns)).reindex(columns=p.close.columns) + src = {"yahoo": 0, "tiingo": 0, "adjusted": 0} + for t in p.close.columns: + if R[t].notna().any(): + src["yahoo"] += 1 + continue + path = os.path.join(TIINGO, f"{t}.csv") + if os.path.exists(path): + h = pd.read_csv(path, index_col=0, parse_dates=True) + if "close" in h: + h.index = h.index.tz_localize(None) if h.index.tz is not None else h.index + R[t] = h["close"].reindex(p.dates) + src["tiingo"] += 1 + continue + src["adjusted"] += 1 + C = p.close + # gaps (dates a source lacks) take the adjusted close scaled by the nearest known raw/adjusted ratio + k = (R / C).replace([np.inf, -np.inf], np.nan) + k = k.ffill().bfill().fillna(1.0) + R = R.where(R.notna(), C * k) + return R, src + + +def commission(shares: float, price: float, sell: bool, plan: str) -> float: + shares = abs(shares) + value = shares * price + if plan == "none" or shares == 0: + return 0.0 + reg = (0.0000206 * value if sell else 0.0) + 0.000003 * shares + if plan == "tiered": + return min(max(0.0035 * shares, 0.35), 0.01 * value) + 0.0014 * shares + reg + return min(max(0.005 * shares, 1.0), 0.01 * value) + reg + + +# --- one account ------------------------------------------------------------ + +_G: dict = {} + + +def _init(data: dict) -> None: + _G.update(data) + + +def run_account(job: tuple) -> dict: + """One account over [start, end] at one rebalance phase.""" + variant, config, phase, start, end = job + v, (capital, plan, market_bp, tbill_cash) = VARIANTS[variant], CONFIGS[config] + G = _G + dates, cols = G["dates"], G["cols"] + C, R, ok, member = G["C"], G["R"], G["tradable"], G["member"] + S = G["score"][v["score"]] + buffer = 1.0 if v["score"] == "fundmom" else 2.0 + expo = G["derisk"] if v["derisk"] else None + rf = G["rf"] + q = G["qqq_col"] + i0 = int(dates.searchsorted(pd.Timestamp(start))) + i1 = int(dates.searchsorted(pd.Timestamp(end), side="right")) - 1 + first = i0 - EVERY + phase # invested by the window's first day in every phase + a = np.zeros(len(cols)) # positions in adjusted-price units + cash = capital + last_print = np.full(len(cols), -1) + eq = np.empty(i1 - first + 1) + orders = fees = 0.0 + misses = picks = 0 + cash_after = [] + for j, d in enumerate(range(first, i1 + 1)): + last_print[ok[d]] = d + if tbill_cash and cash > 0: + cash *= 1 + rf[d] / 252 + held = np.where(a > 0)[0] + stale = held[(d - last_print[held] >= SETTLE_AFTER) & ~member[d, held] & (held != q)] + for i in stale: # delisted: settled at the last print + cash += a[i] * C[d, i] + a[i] = 0.0 + if (d - first) % EVERY == 0: + row = np.where(member[d - 1], S[d - 1], np.nan) + row[q] = np.nan + chosen = select_top(row, TOP_N, a > 0, buffer) + if len(chosen) >= TOP_N // 2: + V = cash + float((a * C[d]).sum()) + m = V / capital # account-dollar scale + e = 1.0 if expo is None else float(np.clip(expo[d - 1], 0.0, 1.0)) + stock_w = (1 - CASH_BUFFER) * (1 - v["qqq"]) * e / len(chosen) + target = {cols[i]: stock_w for i in chosen} + if v["qqq"] > 0: + target[cols[q]] = (1 - CASH_BUFFER) * v["qqq"] + idx = {cols[i]: i for i in set(chosen) | set(np.where(a > 0)[0]) | ({q} if v["qqq"] else set())} + prices = {t: float(R[d, i]) for t, i in idx.items()} + holdings = {t: a[i] * C[d, i] / R[d, i] / m for t, i in idx.items() if a[i] > 0} + frozen = {t for t, i in idx.items() if not ok[d, i] or not np.isfinite(R[d, i])} + trades = compute_trades(holdings, cash / m, target, prices, min_trade_value=MIN_ORDER, + integer_shares=True, resize_band=v["band"], frozen=frozen) + # execution.execute_trades: sells first, then each buy (ticker order) + # shrunk to the whole shares the cash left after its fees can pay for + acct = cash / m + sells = [tr for tr in trades if tr["shares_delta"] < 0] + buys = [tr for tr in trades if tr["shares_delta"] > 0] + for tr in sells + buys: + t = tr["ticker"] + i, P = idx[t], prices[t] + if tr["shares_delta"] < 0: + k = -holdings.get(t, 0.0) if tr["target_shares"] == 0 else \ + max(float(tr["shares_delta"]), -holdings.get(t, 0.0)) + else: + k = float(tr["shares_delta"]) + cost = lambda n: n * P + commission(n, P, False, plan) + n * P * market_bp / 1e4 # noqa: E731 + if cost(k) > acct: + k = float(max(0, int(acct / (P * (1 + market_bp / 1e4) + 0.005)))) + while k > 0 and cost(k) > acct: + k -= 1 + if k <= 0: + continue + fee = commission(k, P, k < 0, plan) + abs(k * P) * market_bp / 1e4 + acct -= k * P + fee + a[i] = 0.0 if (k < 0 and tr["target_shares"] == 0) else a[i] + k * P * m / C[d, i] + orders += 1 + fees += fee * m / V + cash = acct * m + for i in chosen: + picks += 1 + misses += a[i] <= 0 and ok[d, i] + cash_after.append(cash / (cash + float((a * C[d]).sum()))) + eq[j] = cash + float((a * C[d]).sum()) + E = pd.Series(eq, index=dates[first:i1 + 1]) + r = E.pct_change().fillna(0.0).loc[start:end] + return {"variant": variant, "config": config, "phase": phase, "start": start, "returns": r, + "orders_yr": orders / (len(E) / 252), + "fees_yr": fees / (len(E) / 252), "miss_rate": misses / max(picks, 1), + "cash_avg": float(np.mean(cash_after)) if cash_after else 0.0} + + +# --- study ------------------------------------------------------------------- + +def build(p) -> dict: + pr = factor_ranks(p, "us") + mom = _blend(pr["overnight_mom"], pr["rec_126"]).where(p.member) + F = fu.load(p) + pct = lambda x: x.where(p.member).rank(axis=1, pct=True) # noqa: E731 + fmom = _blend(*(pct(F[k]) for k in ("sue", "sue_rev", "gm_change", "roa_change"))) + fundmom = fmom.fillna(0.5).where(mom.rank(axis=1, ascending=False) <= 30) + R, src = raw_prices(p) + cols = list(p.close.columns) + ["QQQ"] + qqq_px = (1 + bench_returns(p.dates, "QQQ").fillna(0.0)).cumprod() * 100.0 + C = p.close.ffill().fillna(0.0) # 0 before a name's first print (never held) + C["QQQ"] = qqq_px + R = R.ffill() # last print values a name the day it has none + R["QQQ"] = qqq_px # adjusted QQQ stands in for its raw price + # A few delisted tickers carry another security's prices (EP = Empire Petroleum + # pennies, CPWR, SW, GR, ...): an S&P 500 member never trades < $2M a day. + dv = (p.close * p.volume).where(p.member).rolling(63, min_periods=20).median() + junk = p.member & (dv < MIN_DOLLAR_VOLUME) + ok = (p.close.notna() & (p.volume.fillna(0) > 0)) & ~junk + ok["QQQ"] = True + member = p.member & ~junk + member["QQQ"] = False + pad = lambda x: x.reindex(columns=cols).to_numpy(float) # noqa: E731 + return {"dates": p.dates, "cols": cols, "C": C[cols].to_numpy(float), "R": R[cols].to_numpy(float), + "tradable": ok[cols].to_numpy(bool), "member": member[cols].fillna(False).to_numpy(bool), + "score": {"mom": pad(mom), "fundmom": pad(fundmom)}, + "derisk": ov.derisk_vol(p.benchmark.ffill()).reindex(p.dates).fillna(1.0).to_numpy(float), + "rf": tbill(p.dates).fillna(0.0).to_numpy(float), "qqq_col": len(cols) - 1, + "_src": {**src, "junk_member_days": int(junk.sum().sum()), "junk_names": int(junk.any().sum())}} + + +def summarize(runs: list[dict], rf: pd.Series, qqq: pd.Series, a: str, b: str) -> dict: + """Mean over phases of each window's statistics, worst phase, and the tranche book's alpha.""" + out = {} + windows = {"design": (a, DESIGN_END), "holdout": (HOLDOUT, b), "full": (a, b)} + for w, (x, y) in windows.items(): + S = [stats(r["returns"].loc[x:y], rf) for r in runs] + out[w] = {k: float(np.mean([s[k] for s in S])) for k in ("cagr", "sharpe", "mdd", "vol", "worst_yr")} + out[w]["worst_phase"] = {k: float(min(s[k] for s in S)) for k in ("cagr", "sharpe", "mdd")} + tranche = pd.concat([r["returns"].loc[x:y] for r in runs], axis=1).mean(axis=1) + al = alpha_stats(tranche, qqq.loc[x:y], rf.loc[x:y]) + out[w]["alpha"], out[w]["alpha_t"], out[w]["beta"] = float(al["alpha"]), float(al["t"]), float(al["beta"]) + for k in ("orders_yr", "fees_yr", "miss_rate", "cash_avg"): + out[k] = float(np.mean([r[k] for r in runs])) + tranche = pd.concat([r["returns"] for r in runs], axis=1).mean(axis=1) + out["years"] = {int(yr): float(v) for yr, v in ((1 + tranche).groupby(tranche.index.year).prod() - 1).items()} + out["_tranche"] = tranche + return out + + +def smoke() -> None: + """Simulator check, no study results: the frictionless account with no resize + band against the lab engine (research/lab/engine.py) on the same phase.""" + from research.lab.engine import simulate + p, _ = load("us") + data = build(p) + print(f"raw price sources (columns): {data.pop('_src')}", flush=True) + _init(data) + VARIANTS["_band0"] = dict(score="mom", band=0.0, derisk=False, qqq=0.0) + rf = tbill(p.dates).fillna(0.0) + score = pd.DataFrame(data["score"]["mom"][:, :-1], index=p.dates, columns=p.close.columns) + zero = dataclasses.replace(p, costs=Costs(buy=0.0, sell=0.0)) + a, b = SPAN + i0 = p.dates.searchsorted(pd.Timestamp(a)) + for ph in (0, 5): + mine = run_account(("_band0", "frictionless", ph, a, b)) + # lab: score at s's close, traded at s+1's close; first signal row = this account's first rebalance - 1 + sp = zero.slice(p.dates[i0 - EVERY + ph - 1], b) + lab = simulate(sp, score, top_n=TOP_N, every=EVERY, buffer=2.0, exec_at="close", min_trade=0.0).returns.loc[a:b] + s1, s2 = stats(mine["returns"], rf), stats(lab, rf) + corr = float(np.corrcoef(mine["returns"], lab.reindex(mine["returns"].index).fillna(0))[0, 1]) + print(f"phase {ph}: account cagr {s1['cagr']:.3f} sh {s1['sharpe']:.2f} mdd {s1['mdd']:.3f} | " + f"lab cagr {s2['cagr']:.3f} sh {s2['sharpe']:.2f} mdd {s2['mdd']:.3f} | daily corr {corr:.4f}") + for cn in ("10k_tiered", "10k_fixed"): + r = run_account(("base", cn, 0, a, b)) + print(f"base {cn} phase 0: orders/yr {r['orders_yr']:.0f} fees/yr {r['fees_yr']*100:.2f}% " + f"miss {r['miss_rate']:.3f} cash after rebalance {r['cash_avg']:.3f}") + + +def main() -> None: + os.makedirs(OUT, exist_ok=True) + res_path = os.path.join(OUT, "results.json") + if "--smoke" in sys.argv: + return smoke() + if os.path.exists(res_path): + raise SystemExit(f"{res_path} exists; this study does not overwrite results") + with open(os.path.join(OUT, "prereg.json"), "w") as fh: + json.dump(PREREG, fh, indent=2, default=list) + + p, _ = load("us") + data = build(p) + print(f"raw price sources (columns): {data.pop('_src')}", flush=True) + jobs = [(vn, cn, ph, *SPAN) for vn in VARIANTS for cn in CONFIGS for ph in range(EVERY)] + jobs += [(vn, cn, ph, *LONG) for vn, v in VARIANTS.items() if v["score"] == "mom" + for cn in LONG_CONFIGS for ph in range(EVERY)] + _init(data) # forked workers inherit the arrays + with ProcessPoolExecutor(max_workers=min(16, os.cpu_count() or 4), + mp_context=__import__("multiprocessing").get_context("fork")) as pool: + runs = list(pool.map(run_account, jobs, chunksize=4)) + rf = tbill(p.dates).fillna(0.0) + qqq = bench_returns(p.dates, "QQQ").fillna(0.0) + + res = {"qqq": {}, "variants": {}, "verdict": {}} + for label, (a, b) in (("span", SPAN), ("long", LONG)): + res["qqq"][label] = {w: {k: float(v) for k, v in stats(qqq.loc[x:y], rf).items()} + for w, (x, y) in {"design": (a, DESIGN_END), "holdout": (HOLDOUT, b), + "full": (a, b)}.items()} + tranches = {} + for vn in VARIANTS: + res["variants"][vn] = {} + for label, (a, b) in (("span", SPAN), ("long", LONG)): + for cn in CONFIGS: + group = [r for r in runs if r["variant"] == vn and r["config"] == cn and r["start"] == a] + if not group: + continue + s = summarize(group, rf, qqq, a, b) + tranches[f"{vn}|{cn}|{label}"] = s.pop("_tranche") + res["variants"][vn][f"{cn}|{label}"] = s + + Q, QL = res["qqq"]["span"], res["qqq"]["long"] + rows = [] + for vn, v in VARIANTS.items(): + for cn in GATE_CONFIGS: + s = res["variants"][vn][f"{cn}|span"] + g1 = s["design"]["cagr"] >= Q["design"]["cagr"] and s["holdout"]["cagr"] >= Q["holdout"]["cagr"] + g2 = s["full"]["mdd"] >= Q["full"]["mdd"] + if v["score"] == "mom": + g2 = g2 and res["variants"][vn][f"{cn}|long"]["full"]["mdd"] >= QL["full"]["mdd"] + g3 = s["design"]["sharpe"] >= Q["design"]["sharpe"] and s["holdout"]["sharpe"] >= Q["holdout"]["sharpe"] + res["variants"][vn][f"{cn}|gates"] = {"G1": g1, "G2": g2, "G3": g3, "pass": g1 and g2 and g3} + pd.set_option("display.width", 250) + for cn in CONFIGS: + print(f"\n== {cn}, {SPAN[0][:4]}-{SPAN[1][:4]} (mean of {EVERY} phases) ==") + T = [] + for vn in VARIANTS: + s = res["variants"][vn][f"{cn}|span"] + g = res["variants"][vn].get(f"{cn}|gates", {}) + T.append({"variant": vn, "cagr_des": s["design"]["cagr"], "cagr_hold": s["holdout"]["cagr"], + "sh_des": s["design"]["sharpe"], "sh_hold": s["holdout"]["sharpe"], + "mdd": s["full"]["mdd"], "mdd_worst_ph": s["full"]["worst_phase"]["mdd"], + "alpha_hold": s["holdout"]["alpha"], "t_hold": s["holdout"]["alpha_t"], + "orders_yr": s["orders_yr"], "fees_yr": s["fees_yr"], "miss": s["miss_rate"], + "cash": s["cash_avg"], **{k: g.get(k, "") for k in ("G1", "G2", "G3", "pass")}}) + T.append({"variant": "QQQ", "cagr_des": Q["design"]["cagr"], "cagr_hold": Q["holdout"]["cagr"], + "sh_des": Q["design"]["sharpe"], "sh_hold": Q["holdout"]["sharpe"], "mdd": Q["full"]["mdd"]}) + print(pd.DataFrame(T).to_string(index=False, float_format=lambda x: f"{x:.3f}")) + print(f"\n== price-only variants {LONG[0][:4]}-{LONG[1][:4]} ==") + for vn, v in VARIANTS.items(): + if v["score"] != "mom": + continue + for cn in LONG_CONFIGS: + s = res["variants"][vn][f"{cn}|long"]["full"] + print(f"{vn:22s} {cn:14s} cagr {s['cagr']*100:5.1f}% sharpe {s['sharpe']:.2f} mdd {s['mdd']*100:5.1f}% " + f"worst yr {s['worst_yr']*100:5.1f}%") + print(f"{'QQQ':22s} {'':14s} cagr {QL['full']['cagr']*100:5.1f}% sharpe {QL['full']['sharpe']:.2f} " + f"mdd {QL['full']['mdd']*100:5.1f}%") + + verdict = {} + for cn in GATE_CONFIGS: + passing = [vn for vn in VARIANTS if res["variants"][vn][f"{cn}|gates"]["pass"]] + best = max(passing, key=lambda vn: res["variants"][vn][f"{cn}|span"]["full"]["sharpe"]) if passing else None + verdict[cn] = {"passing": passing, "preferred": best} + res["verdict"] = verdict + print(f"\n-> {verdict}") + with open(res_path, "w") as fh: + json.dump(res, fh, indent=1, default=float) + pd.DataFrame(tranches).to_pickle(os.path.join(OUT, "daily.pkl")) + from research.lab.trials import append_trials + append_trials(os.path.join(RESULTS, "trials_us.jsonl"), + [{"name": f"small_account_momentum:{vn}:{cn}", + **{k: v for k, v in res["variants"][vn][f"{cn}|span"]["full"].items() if k != "worst_phase"}} + for vn in VARIANTS for cn in GATE_CONFIGS], + {"study": "small_account_momentum", "role": "preregistered"}) + + +if __name__ == "__main__": + main()