research: 10-day momentum in a $10k/$20k IBKR account with whole shares and low-frequency risk controls vs QQQ (none passes)

Preregistered: rb20, SPY de-risk vol target, fundamental re-sort and half QQQ,
simulated with execution.compute_trades sizing, cash-limited buys, IBKR Tiered/
Fixed fees and constant account size. All fail the 2011-16 Sharpe gate;
rb20_fundmom beats QQQ on 2011-26 CAGR and MaxDD but has no 2008 test.
Also found: wrong-security series in the research panel (filtered by dollar
volume) and the lab engine's missing delisting settlement (~2.8 pts/yr).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 00:47:37 -07:00
parent 7e9e49c71f
commit 50b4cb7678
2 changed files with 497 additions and 0 deletions

View File

@@ -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)

View File

@@ -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()