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:
76
src/ibkr_quant/strategy.py
Normal file
76
src/ibkr_quant/strategy.py
Normal 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}
|
||||
Reference in New Issue
Block a user