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:
@@ -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)
|
||||
|
||||
496
research/lab/small_account_momentum.py
Normal file
496
research/lab/small_account_momentum.py
Normal 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()
|
||||
Reference in New Issue
Block a user