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:
@@ -1,76 +1,60 @@
|
||||
"""UpCap+MomGap factor combo, ported from quant-trading `strategies/factor_combo.py`
|
||||
(`fc_up_cap_mom_gap_daily`).
|
||||
"""overnight_recovery_top10, ported from quant-trading `strategies/robust_momentum.py`
|
||||
(`RobustMomentumStrategy("overnight_recovery", top_n=10, rebal_freq=10, buffer=2.0)`).
|
||||
|
||||
The source runs on pandas 2, whose `pct_change` forward-fills gaps first;
|
||||
`_pct_change` keeps that behaviour under pandas 3.
|
||||
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: 12-1 momentum needs 21 + 231 bars.
|
||||
# Rows the source zeroes before trading; overnight momentum needs 180 + 21 of them.
|
||||
WARMUP = 252
|
||||
|
||||
|
||||
def _pct_change(p: pd.DataFrame, periods: int = 1) -> pd.DataFrame:
|
||||
return p.ffill().pct_change(periods)
|
||||
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 _rank(df: pd.DataFrame) -> pd.DataFrame:
|
||||
return df.rank(axis=1, pct=True, na_option="keep")
|
||||
def recovery(closes: pd.DataFrame, window: int = 126) -> pd.DataFrame:
|
||||
c = closes.ffill()
|
||||
return c / c.rolling(window, min_periods=window).min() - 1
|
||||
|
||||
|
||||
def _mom_12_1(p: pd.DataFrame) -> pd.DataFrame:
|
||||
return _pct_change(p.shift(21), 231)
|
||||
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 _gap_up_freq(p: pd.DataFrame) -> pd.DataFrame:
|
||||
ret = _pct_change(p)
|
||||
return (ret > 0.01).astype(float).rolling(60, min_periods=40).mean()
|
||||
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 _members(df: pd.DataFrame, member: pd.DataFrame | None) -> pd.DataFrame:
|
||||
return df if member is None else df.where(member)
|
||||
|
||||
|
||||
def _up_capture(p: pd.DataFrame, member: pd.DataFrame | None = None) -> pd.DataFrame:
|
||||
ret = _pct_change(p)
|
||||
mkt = _members(ret, member).mean(axis=1)
|
||||
up_mkt = mkt > 0
|
||||
arr = ret.values.copy()
|
||||
arr[~up_mkt.values, :] = np.nan
|
||||
stock_up = pd.DataFrame(arr, index=ret.index, columns=ret.columns)
|
||||
mkt_up_vals = mkt.where(up_mkt, np.nan)
|
||||
stock_avg = stock_up.rolling(60, min_periods=20).mean()
|
||||
mkt_avg = mkt_up_vals.rolling(60, min_periods=20).mean()
|
||||
return stock_avg.div(mkt_avg, axis=0)
|
||||
|
||||
|
||||
def _mom_x_gap(p: pd.DataFrame, member: pd.DataFrame | None = None) -> pd.DataFrame:
|
||||
return _rank(_members(_mom_12_1(p), member)) * _rank(_members(_gap_up_freq(p), member))
|
||||
|
||||
|
||||
def signal_up_cap_mom_gap(p: pd.DataFrame, member: pd.DataFrame | None = None) -> pd.DataFrame:
|
||||
"""`member` (dates x symbols) limits the cross-section (ranks, market return) to each
|
||||
day's index members; factors still use each name's full price history. Live, every
|
||||
column is a current member."""
|
||||
return 0.5 * _rank(_members(_up_capture(p, member), member)) + 0.5 * _rank(_mom_x_gap(p, member))
|
||||
|
||||
|
||||
def up_cap_mom_gap_weights(opens: pd.DataFrame, top_n: int) -> dict[str, float]:
|
||||
"""Equal-weight top_n names for trading at the close of the last row's session.
|
||||
|
||||
`opens` holds opening prices (dates x symbols) up to and including today's open,
|
||||
matching the source's live timing: signal from opens, fills at the same day's close.
|
||||
"""
|
||||
if len(opens) <= WARMUP:
|
||||
raise ValueError(f"need more than {WARMUP} sessions of opens, have {len(opens)}")
|
||||
return select_top_n(signal_up_cap_mom_gap(opens).iloc[-1], top_n)
|
||||
|
||||
|
||||
def select_top_n(sig: pd.Series, top_n: int) -> dict[str, float]:
|
||||
if sig.notna().sum() < top_n:
|
||||
return {}
|
||||
rank = sig.rank(ascending=False, na_option="bottom")
|
||||
winners = rank.index[rank <= top_n]
|
||||
return {symbol: 1.0 / len(winners) for symbol in winners}
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user