Files
ibkr-quant/src/ibkr_quant/strategy.py
Gahow Wang d945624601 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>
2026-09-29 18:06:14 +08:00

61 lines
2.9 KiB
Python

"""overnight_recovery_top10, ported from quant-trading `strategies/robust_momentum.py`
(`RobustMomentumStrategy("overnight_recovery", top_n=10, rebal_freq=10, buffer=2.0)`).
Score: mean cross-sectional percentile rank of overnight-return momentum (12 months,
skipping the last one) and distance from the 126-day low. Every `rebalance_every`
sessions the book holds the top_n names equal-weighted, keeping a held name while its
rank is within top_n * (1 + buffer); in between nothing trades and weights drift.
"""
import datetime as dt
import numpy as np
import pandas as pd
# Rows the source zeroes before trading; overnight momentum needs 180 + 21 of them.
WARMUP = 252
def overnight_momentum(opens: pd.DataFrame, closes: pd.DataFrame, window: int = 252, skip: int = 21) -> pd.DataFrame:
"""Sum of log overnight returns (open / previous close), Lou, Polk & Skouras (2019)."""
overnight = np.log(opens / closes.ffill().shift(1))
return overnight.rolling(window, min_periods=180).sum().shift(skip)
def recovery(closes: pd.DataFrame, window: int = 126) -> pd.DataFrame:
c = closes.ffill()
return c / c.rolling(window, min_periods=window).min() - 1
def signal_overnight_recovery(
opens: pd.DataFrame, closes: pd.DataFrame, member: pd.DataFrame | None = None
) -> pd.DataFrame:
"""Row t uses bars through t's close. `member` (dates x symbols) limits the ranks to
each day's index members; factors still use each name's full price history. Live,
every column is a current member. A name without a close on a row is not eligible."""
factors = [overnight_momentum(opens, closes), recovery(closes)]
if member is not None:
factors = [f.where(member) for f in factors]
score = sum(f.rank(axis=1, pct=True) for f in factors) / len(factors)
return score.where(closes.notna())
def select_with_buffer(score: pd.Series, held: set[str], top_n: int, buffer: float) -> dict[str, float]:
"""Equal weights on the top_n names of `score`, keeping held names while ranked
within top_n * (1 + buffer). Ties keep column order, as in the source."""
valid = score.dropna()
if len(valid) < top_n:
raise ValueError(f"only {len(valid)} names have a score, need {top_n}")
order = valid.index[np.argsort(-valid.to_numpy(), kind="stable")]
rank = {symbol: i for i, symbol in enumerate(order)}
chosen = sorted((s for s in held if rank.get(s, np.inf) < top_n * (1 + buffer)), key=rank.__getitem__)[:top_n]
chosen += [s for s in order if s not in chosen][: top_n - len(chosen)]
return {symbol: 1.0 / top_n for symbol in chosen}
def sessions_until_rebalance(sessions: pd.DatetimeIndex, anchor: dt.date, every: int) -> int | None:
"""0 when the last of `sessions` is a rebalance session: the first session on or
after `anchor` and every `every` sessions after it. None before the anchor."""
n = int((sessions.date >= anchor).sum()) - 1
return None if n < 0 else -n % every