Daily UpCap+MomGap trading on an IBKR paper account

Connects to IB Gateway through ib_async and trades the S&P 500 with the
UpCap+MomGap factor combo ported from quant-trading's
fc_up_cap_mom_gap_daily: signal on opens through today's open, equal
weight top 10, MOC orders filled at today's close, 10% resize band.

- make universe/bars/check/plan/submit/fills for the daily loop; refuses
  non-paper accounts unless allow_live, open orders, stale bars, and
  orders outside 09:31-15:45 ET
- make backtest replays the live loop day by day with plan_orders and
  IBKR Fixed commissions; selection uses each day's index members
- make bars fails fast when the Gateway's historical farm is down

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 21:53:31 +08:00
commit dc94f63839
16 changed files with 1286 additions and 0 deletions

View File

@@ -0,0 +1,76 @@
"""UpCap+MomGap factor combo, ported from quant-trading `strategies/factor_combo.py`
(`fc_up_cap_mom_gap_daily`).
The source runs on pandas 2, whose `pct_change` forward-fills gaps first;
`_pct_change` keeps that behaviour under pandas 3.
"""
import numpy as np
import pandas as pd
# Rows the source zeroes before trading: 12-1 momentum needs 21 + 231 bars.
WARMUP = 252
def _pct_change(p: pd.DataFrame, periods: int = 1) -> pd.DataFrame:
return p.ffill().pct_change(periods)
def _rank(df: pd.DataFrame) -> pd.DataFrame:
return df.rank(axis=1, pct=True, na_option="keep")
def _mom_12_1(p: pd.DataFrame) -> pd.DataFrame:
return _pct_change(p.shift(21), 231)
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 _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}