Add 32 factor-combo strategies with configurable rebalancing frequency

New FactorComboStrategy class (strategies/factor_combo.py) implements
8 champion factor signals (4 US, 4 CN) discovered through iterative
factor research, each at 4 rebalancing frequencies (daily/weekly/
biweekly/monthly). Registered in trader.py as fc_{signal}_{freq}.

Existing strategies and state files are untouched — safe to git pull
and restart monitor on server.

Also includes factor research scripts (factor_loop.py, factor_research.py,
etc.) used to discover and validate these factors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-08 10:41:34 +08:00
parent a66b039d2d
commit ae25f2f6b5
13 changed files with 3402 additions and 1 deletions

218
strategies/factor_combo.py Normal file
View File

@@ -0,0 +1,218 @@
"""
Factor combination strategies discovered through iterative factor research.
US champions:
- rec_mfilt+deep×upvol: Recovery (momentum-filtered) + deep recovery × up-volume
- ma200+mom7m+rec126: Above MA200 + intermediate momentum + deep recovery
- rec_mfilt+ma200: Recovery (momentum-filtered) + above MA200
- mom7m+rec126: Intermediate momentum + deep recovery
CN champions:
- up_cap+quality_mom: Up-capture ratio + quality momentum composite
- down_resil+qual_mom: Down-resilience + quality momentum composite
- rec63+mom×gap: Recovery 63d + momentum × gap-up frequency
- up_cap+mom×gap: Up-capture + momentum × gap-up frequency
Each can run at daily/weekly/biweekly/monthly rebalancing frequency.
"""
import numpy as np
import pandas as pd
from strategies.base import Strategy
# ---------------------------------------------------------------------------
# Factor building blocks
# ---------------------------------------------------------------------------
def _mom_12_1(p):
return p.shift(21).pct_change(231)
def _mom_intermediate(p):
return p.shift(21).pct_change(147)
def _rec_63(p):
return p / p.rolling(63, min_periods=63).min() - 1
def _rec_126(p):
return p / p.rolling(126, min_periods=126).min() - 1
def _above_ma200(p):
return p / p.rolling(200, min_periods=200).mean() - 1
def _up_volume_proxy(p):
ret = p.pct_change()
return ret.where(ret > 0, 0).rolling(20, min_periods=15).sum()
def _gap_up_freq(p):
ret = p.pct_change()
return (ret > 0.01).astype(float).rolling(60, min_periods=40).mean()
def _consistent_returns(p):
ret = p.pct_change()
return (ret > 0).astype(float).rolling(252, min_periods=126).mean()
def _rec_mom_filtered(p):
rec = p / p.rolling(126, min_periods=126).min() - 1
mom = p.shift(21).pct_change(105)
return rec.where(mom > 0, np.nan)
def _up_capture(p):
ret = p.pct_change()
mkt = ret.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 _down_resilience(p):
ret = p.pct_change()
mkt = ret.mean(axis=1)
down_mkt = mkt < 0
arr = ret.values.copy()
arr[~down_mkt.values, :] = np.nan
down_ret = pd.DataFrame(arr, index=ret.index, columns=ret.columns)
return -down_ret.rolling(120, min_periods=30).mean()
def _quality_mom(p):
mom_r = _mom_12_1(p).rank(axis=1, pct=True, na_option="keep")
con_r = _consistent_returns(p).rank(axis=1, pct=True, na_option="keep")
up_r = _up_volume_proxy(p).rank(axis=1, pct=True, na_option="keep")
return 0.4 * mom_r + 0.3 * con_r + 0.3 * up_r
def _mom_x_gap(p):
mom_r = _mom_12_1(p).rank(axis=1, pct=True, na_option="keep")
gap_r = _gap_up_freq(p).rank(axis=1, pct=True, na_option="keep")
return mom_r * gap_r
# ---------------------------------------------------------------------------
# Combo signal constructors (weighted rank sums)
# ---------------------------------------------------------------------------
def _rank(df):
return df.rank(axis=1, pct=True, na_option="keep")
# US combos
def signal_rec_mfilt_deep_upvol(p):
rec_mfilt_r = _rank(_rec_mom_filtered(p))
deep_upvol_r = _rank(_rec_126(p)) * _rank(_up_volume_proxy(p))
deep_upvol_rr = _rank(deep_upvol_r)
return 0.5 * rec_mfilt_r + 0.5 * deep_upvol_rr
def signal_ma200_mom7m_rec126(p):
return (0.33 * _rank(_above_ma200(p))
+ 0.33 * _rank(_mom_intermediate(p))
+ 0.34 * _rank(_rec_126(p)))
def signal_rec_mfilt_ma200(p):
return 0.5 * _rank(_rec_mom_filtered(p)) + 0.5 * _rank(_above_ma200(p))
def signal_mom7m_rec126(p):
return 0.5 * _rank(_mom_intermediate(p)) + 0.5 * _rank(_rec_126(p))
# CN combos
def signal_up_cap_quality_mom(p):
return 0.5 * _rank(_up_capture(p)) + 0.5 * _rank(_quality_mom(p))
def signal_down_resil_qual_mom(p):
return 0.5 * _rank(_down_resilience(p)) + 0.5 * _rank(_quality_mom(p))
def signal_rec63_mom_gap(p):
return 0.5 * _rank(_rec_63(p)) + 0.5 * _rank(_mom_x_gap(p))
def signal_up_cap_mom_gap(p):
return 0.5 * _rank(_up_capture(p)) + 0.5 * _rank(_mom_x_gap(p))
# ---------------------------------------------------------------------------
# Signal registry: name -> callable(prices) -> DataFrame
# ---------------------------------------------------------------------------
SIGNAL_REGISTRY = {
# US
"rec_mfilt+deep_upvol": signal_rec_mfilt_deep_upvol,
"ma200+mom7m+rec126": signal_ma200_mom7m_rec126,
"rec_mfilt+ma200": signal_rec_mfilt_ma200,
"mom7m+rec126": signal_mom7m_rec126,
# CN
"up_cap+quality_mom": signal_up_cap_quality_mom,
"down_resil+qual_mom": signal_down_resil_qual_mom,
"rec63+mom_gap": signal_rec63_mom_gap,
"up_cap+mom_gap": signal_up_cap_mom_gap,
}
# ---------------------------------------------------------------------------
# Strategy class
# ---------------------------------------------------------------------------
class FactorComboStrategy(Strategy):
"""
Generic factor-combination strategy with configurable rebalancing frequency.
Parameters:
signal_name: key into SIGNAL_REGISTRY
rebal_freq: rebalancing interval in trading days (1=daily, 5=weekly, 10=biweekly, 21=monthly)
top_n: number of stocks to hold
"""
REBAL_LABELS = {1: "daily", 5: "weekly", 10: "biweekly", 21: "monthly"}
def __init__(self, signal_name: str, rebal_freq: int = 21, top_n: int = 10):
if signal_name not in SIGNAL_REGISTRY:
raise ValueError(f"Unknown signal: {signal_name}. "
f"Available: {list(SIGNAL_REGISTRY.keys())}")
self.signal_name = signal_name
self.signal_func = SIGNAL_REGISTRY[signal_name]
self.rebal_freq = rebal_freq
self.top_n = top_n
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
sig = self.signal_func(data)
# Select top_n by signal rank
rank = sig.rank(axis=1, ascending=False, na_option="bottom")
n_valid = sig.notna().sum(axis=1)
enough = n_valid >= self.top_n
top_mask = (rank <= self.top_n) & enough.values.reshape(-1, 1)
raw = top_mask.astype(float)
row_sums = raw.sum(axis=1).replace(0, np.nan)
signals = raw.div(row_sums, axis=0).fillna(0.0)
# Rebalance at configured frequency
warmup = 252
rebal_mask = pd.Series(False, index=data.index)
rebal_indices = list(range(warmup, len(data), self.rebal_freq))
rebal_mask.iloc[rebal_indices] = True
signals[~rebal_mask] = np.nan
signals = signals.ffill().fillna(0.0)
signals.iloc[:warmup] = 0.0
return signals.shift(1).fillna(0.0)