Switch to overnight_recovery_top10, rebalanced every 10 sessions
Under quant-trading's PIT research engine (S&P 500 members 2006-2026, 10 bp), overnight_recovery_top10 filled at the next close beat the daily UpCap+MomGap by 10.5%/yr (paired Newey-West t 2.44) at 3x instead of 29x yearly turnover. UpCap+MomGap was a CN champion there, and its daily variant was never picked for the US book. - strategy: overnight-return momentum (12m skip 1m) + distance from the 126-day low, ported from strategies/robust_momentum.py; top 10 equal weight, a held name stays while ranked within 30. Scores and picks match RobustMomentumStrategy on the IB bars (25 rebalances, 0 diffs). - rebalance: every 10 sessions from strategy.anchor (2026-09-29), on completed sessions through yesterday's close, MOC at today's close; other sessions do nothing and weights drift. - backtest trades only on rebalance sessions, like the live loop. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,7 @@ from .broker import NEW_YORK, Broker
|
||||
from .config import Config, load_config
|
||||
from .rebalance import plan_orders
|
||||
from . import backtest
|
||||
from .strategy import signal_up_cap_mom_gap, up_cap_mom_gap_weights
|
||||
from .strategy import WARMUP, select_with_buffer, sessions_until_rebalance, signal_overnight_recovery
|
||||
from .universe import fetch_sp500, load_universe
|
||||
|
||||
# MOC orders must reach NYSE by 15:50 ET; keep a margin.
|
||||
@@ -71,20 +71,30 @@ async def update_bars(cfg: Config) -> None:
|
||||
print(f"no bars ({len(failed)}): {failed}")
|
||||
|
||||
|
||||
def load_opens(path: Path, today: dt.date) -> pd.DataFrame:
|
||||
def load_bars(path: Path, today: dt.date) -> tuple[pd.DataFrame, pd.DataFrame]:
|
||||
bars = pd.read_csv(path, parse_dates=["date"])
|
||||
opens = bars.pivot(index="date", columns="symbol", values="open").sort_index()
|
||||
opens, closes = (bars.pivot(index="date", columns="symbol", values=v).sort_index() for v in ("open", "close"))
|
||||
if opens.index[-1].date() != today:
|
||||
raise SystemExit(f"bars end {opens.index[-1].date()}, need today's open ({today}); run `make bars` after 09:30 ET")
|
||||
return opens
|
||||
return opens, closes
|
||||
|
||||
|
||||
async def rebalance(cfg: Config, submit: bool) -> None:
|
||||
ex = cfg.execution
|
||||
ex, st = cfg.execution, cfg.strategy
|
||||
now = dt.datetime.now(NEW_YORK)
|
||||
opens = load_opens(cfg.data.bars, now.date())
|
||||
weights = up_cap_mom_gap_weights(opens, cfg.strategy.top_n)
|
||||
universe = set(load_universe(cfg.data.universe))
|
||||
opens, closes = load_bars(cfg.data.bars, now.date())
|
||||
wait = sessions_until_rebalance(opens.index, st.anchor, st.rebalance_every)
|
||||
if wait != 0:
|
||||
after = f"in {wait} sessions" if wait else f"on the first session from {st.anchor}"
|
||||
print(f"not a rebalance session; holdings drift until the next one {after}")
|
||||
return
|
||||
# Today's bar is still forming, so the signal uses completed sessions only.
|
||||
history = opens.index[:-1]
|
||||
if len(history) <= WARMUP:
|
||||
raise SystemExit(f"need more than {WARMUP} completed sessions of bars, have {len(history)}")
|
||||
members = opens.columns.intersection(sorted(universe))
|
||||
score = signal_overnight_recovery(opens.loc[history, members], closes.loc[history, members]).iloc[-1]
|
||||
run_dir = cfg.root / "runs" / now.strftime("%Y%m%d-%H%M%S")
|
||||
|
||||
async with Broker(cfg.ib) as broker:
|
||||
@@ -92,7 +102,8 @@ async def rebalance(cfg: Config, submit: bool) -> None:
|
||||
managed = [h for h in state.holdings if h.sec_type == "STK" and h.currency == "USD" and h.symbol in universe]
|
||||
unmanaged_value = sum(h.market_value_usd for h in state.holdings if h not in managed)
|
||||
investable = state.nav_usd * (1 - ex.cash_buffer) - unmanaged_value
|
||||
# Sized at today's open, as the source strategy does; filled at the close.
|
||||
weights = select_with_buffer(score, {h.symbol for h in managed}, st.top_n, st.buffer)
|
||||
# Sized at today's open, the latest price in the bars; filled at the close.
|
||||
orders = plan_orders(
|
||||
weights,
|
||||
{h.symbol: h.quantity for h in managed},
|
||||
@@ -107,7 +118,7 @@ async def rebalance(cfg: Config, submit: bool) -> None:
|
||||
plan["weight"] = plan["symbol"].map(weights).fillna(0.0)
|
||||
plan.to_csv(run_dir / "plan.csv", index=False)
|
||||
(run_dir / "account.json").write_text(json.dumps(dataclasses.asdict(state), indent=2))
|
||||
print(f"account {state.account} NAV ${state.nav_usd:,.2f} investable ${investable:,.2f} signal on opens through {opens.index[-1].date()}")
|
||||
print(f"account {state.account} NAV ${state.nav_usd:,.2f} investable ${investable:,.2f} signal on sessions through {history[-1].date()}")
|
||||
print(plan.to_string(index=False) if len(plan) else "no orders")
|
||||
if not submit or not orders:
|
||||
print(f"dry run -> {run_dir}")
|
||||
@@ -157,6 +168,7 @@ async def rebalance(cfg: Config, submit: bool) -> None:
|
||||
|
||||
|
||||
def run_backtest(cfg: Config, top_ns: list[int], everies: list[int], capitals: list[float], fee: str) -> None:
|
||||
st = cfg.strategy
|
||||
bars = pd.read_csv(cfg.data.bars, parse_dates=["date"])
|
||||
opens = bars.pivot(index="date", columns="symbol", values="open").sort_index()
|
||||
closes = bars.pivot(index="date", columns="symbol", values="close").sort_index()
|
||||
@@ -166,17 +178,18 @@ def run_backtest(cfg: Config, top_ns: list[int], everies: list[int], capitals: l
|
||||
added = pd.read_csv(cfg.data.universe, parse_dates=["added"]).set_index("symbol")["added"]
|
||||
member = pd.DataFrame(opens.index.values[:, None] >= added.reindex(opens.columns).values[None, :],
|
||||
index=opens.index, columns=opens.columns)
|
||||
signal = signal_up_cap_mom_gap(opens, member)
|
||||
# Row d holds the signal from sessions through d - 1, known at d's open.
|
||||
signal = signal_overnight_recovery(opens, closes, member).shift(1)
|
||||
fee_model = backtest.ibkr_fixed_fee if fee == "ibkr-fixed" else backtest.flat_fee(2.0)
|
||||
rows = []
|
||||
for capital in capitals:
|
||||
for every in everies:
|
||||
for top_n in top_ns:
|
||||
run_cfg = backtest.RunConfig(top_n, every, capital, fee_model)
|
||||
run_cfg = backtest.RunConfig(top_n, every, st.buffer, capital, fee_model)
|
||||
result = backtest.run(signal, opens, closes, run_cfg)
|
||||
rows.append({"capital": capital, "every": every, "top_n": top_n} | backtest.metrics(result, capital))
|
||||
days = signal.index[backtest.WARMUP:]
|
||||
# Same-universe baseline: equal weight, rebalanced at every close, no costs.
|
||||
# Same-universe baseline: equal weight, re-weighted at every close, no costs.
|
||||
ew = closes.ffill().pct_change().shift(-1).loc[days[:-1]].mean(axis=1)
|
||||
table = pd.DataFrame(rows)
|
||||
path = cfg.root / "runs" / f"backtest-{dt.datetime.now(NEW_YORK):%Y%m%d-%H%M%S}.csv"
|
||||
@@ -214,8 +227,8 @@ def main() -> None:
|
||||
rebalance_parser.add_argument("--submit", action="store_true")
|
||||
commands.add_parser("fills", help="show today's executions")
|
||||
backtest_parser = commands.add_parser("backtest", help="replay the strategy on the downloaded bars")
|
||||
backtest_parser.add_argument("--top-n", type=int, nargs="+", default=[10])
|
||||
backtest_parser.add_argument("--every", type=int, nargs="+", default=[1], help="rebalance interval in sessions")
|
||||
backtest_parser.add_argument("--top-n", type=int, nargs="+", help="default: strategy.top_n")
|
||||
backtest_parser.add_argument("--every", type=int, nargs="+", help="rebalance interval in sessions; default: strategy.rebalance_every")
|
||||
backtest_parser.add_argument("--capital", type=float, nargs="+", default=[1_000_000])
|
||||
backtest_parser.add_argument("--fee", choices=["ibkr-fixed", "flat2"], default="ibkr-fixed")
|
||||
args = parser.parse_args()
|
||||
@@ -236,4 +249,4 @@ def main() -> None:
|
||||
case "fills":
|
||||
asyncio.run(fills(cfg))
|
||||
case "backtest":
|
||||
run_backtest(cfg, args.top_n, args.every, args.capital, args.fee)
|
||||
run_backtest(cfg, args.top_n or [cfg.strategy.top_n], args.every or [cfg.strategy.rebalance_every], args.capital, args.fee)
|
||||
|
||||
Reference in New Issue
Block a user