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>
61 lines
2.9 KiB
Python
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
|