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

323
factor_robustness.py Normal file
View File

@@ -0,0 +1,323 @@
"""
Robustness checks for winning factor strategies.
Tests:
1. Rolling 2-year window performance (stability)
2. Top-N sensitivity (5, 10, 15, 20)
3. Rebalance frequency sensitivity (5d, 10d, 21d, 42d)
4. Transaction cost sensitivity (0, 10bps, 20bps, 50bps)
5. Drawdown analysis
"""
from __future__ import annotations
import argparse
import warnings
import numpy as np
import pandas as pd
import data_manager
from universe import UNIVERSES
from factor_real_backtest import (
f_recovery_mom,
f_momentum_12_1,
f_recovery,
f_recovery_deep,
f_up_volume_proxy,
f_gap_up_freq,
f_earnings_drift_proxy,
f_reversal_vol_cn,
f_consistent_winner,
combo_signal,
make_strategy,
run_backtest,
compute_stats,
)
warnings.filterwarnings("ignore")
def rolling_window_performance(equity: pd.Series, window_years: int = 2):
"""Compute rolling window returns."""
daily_ret = equity.pct_change().dropna()
window = 252 * window_years
results = []
for end_idx in range(window, len(daily_ret), 63): # step 3 months
start_idx = end_idx - window
chunk = daily_ret.iloc[start_idx:end_idx]
total = (1 + chunk).prod() - 1
ann = (1 + total) ** (252 / len(chunk)) - 1
sharpe = chunk.mean() / chunk.std() * np.sqrt(252) if chunk.std() > 0 else 0
results.append({
"end_date": chunk.index[-1].date(),
"ann_return": ann,
"sharpe": sharpe,
})
return pd.DataFrame(results)
def drawdown_analysis(equity: pd.Series) -> pd.DataFrame:
"""Find top 5 drawdown episodes."""
running_max = equity.cummax()
drawdown = (equity - running_max) / running_max
# Find drawdown episodes
episodes = []
in_dd = False
start = None
for i in range(len(drawdown)):
if drawdown.iloc[i] < -0.05 and not in_dd:
in_dd = True
start = i
elif drawdown.iloc[i] >= 0 and in_dd:
in_dd = False
trough_idx = drawdown.iloc[start:i].idxmin()
episodes.append({
"start": drawdown.index[start].date(),
"trough": trough_idx.date(),
"end": drawdown.index[i].date(),
"depth": drawdown.loc[trough_idx],
"duration_days": i - start,
})
# Handle ongoing drawdown
if in_dd:
trough_idx = drawdown.iloc[start:].idxmin()
episodes.append({
"start": drawdown.index[start].date(),
"trough": trough_idx.date(),
"end": "ongoing",
"depth": drawdown.loc[trough_idx],
"duration_days": len(drawdown) - start,
})
df = pd.DataFrame(episodes)
if df.empty:
return df
return df.nsmallest(5, "depth")
def run_us(stocks: pd.DataFrame):
print("=" * 100)
print(" US ROBUSTNESS — Winner: momentum_12_1 + up_volume_proxy")
print("=" * 100)
winner_func = combo_signal([(f_momentum_12_1, 0.5), (f_up_volume_proxy, 0.5)])
baseline_func = f_recovery_mom
# 1. Rolling 2-year performance
print("\n--- 1. Rolling 2-Year Performance ---\n")
for label, func in [("Winner: mom+upvol", winner_func),
("Baseline: rec+mom", baseline_func)]:
w = make_strategy(stocks, func, top_n=10)
eq = run_backtest(w, stocks)
roll = rolling_window_performance(eq)
if roll.empty:
continue
win_pct = (roll["ann_return"] > 0).mean()
print(f" {label}:")
print(f" Mean 2yr ann return: {roll['ann_return'].mean():+.1%}")
print(f" Min 2yr ann return: {roll['ann_return'].min():+.1%}")
print(f" Max 2yr ann return: {roll['ann_return'].max():+.1%}")
print(f" % positive 2yr: {win_pct:.0%}")
print(f" Mean 2yr Sharpe: {roll['sharpe'].mean():.2f}")
print()
# 2. Top-N sensitivity
print("--- 2. Top-N Sensitivity ---\n")
header = f" {'Top-N':<8}"
for label in ["Winner: mom+upvol", "Baseline: rec+mom"]:
header += f" | {'CAGR':>8} {'Sharpe':>8} {'MaxDD':>8}"
print(header)
print(" " + "-" * 70)
for top_n in [5, 10, 15, 20, 30]:
line = f" {top_n:<8}"
for func in [winner_func, baseline_func]:
w = make_strategy(stocks, func, top_n=top_n)
eq = run_backtest(w, stocks)
s = compute_stats(eq, "")
line += f" | {s['cagr']:>+7.1%} {s['sharpe']:>8.2f} {s['maxdd']:>+7.1%}"
print(line)
# 3. Rebalance frequency sensitivity
print("\n--- 3. Rebalance Frequency Sensitivity ---\n")
header = f" {'Rebal':<8}"
for label in ["Winner: mom+upvol", "Baseline: rec+mom"]:
header += f" | {'CAGR':>8} {'Sharpe':>8} {'MaxDD':>8}"
print(header)
print(" " + "-" * 70)
for rebal in [5, 10, 21, 42, 63]:
line = f" {rebal}d{'':<5}"
for func in [winner_func, baseline_func]:
w = make_strategy(stocks, func, top_n=10, rebal_freq=rebal)
eq = run_backtest(w, stocks)
s = compute_stats(eq, "")
line += f" | {s['cagr']:>+7.1%} {s['sharpe']:>8.2f} {s['maxdd']:>+7.1%}"
print(line)
# 4. Transaction cost sensitivity
print("\n--- 4. Transaction Cost Sensitivity ---\n")
header = f" {'Cost':<8}"
for label in ["Winner: mom+upvol", "Baseline: rec+mom"]:
header += f" | {'CAGR':>8} {'Sharpe':>8}"
print(header)
print(" " + "-" * 50)
for cost in [0, 0.001, 0.002, 0.005]:
line = f" {cost*10000:.0f}bps{'':<4}"
for func in [winner_func, baseline_func]:
w = make_strategy(stocks, func, top_n=10)
eq = run_backtest(w, stocks, cost=cost)
s = compute_stats(eq, "")
line += f" | {s['cagr']:>+7.1%} {s['sharpe']:>8.2f}"
print(line)
# 5. Drawdown analysis
print("\n--- 5. Drawdown Episodes ---\n")
for label, func in [("Winner: mom+upvol", winner_func),
("Baseline: rec+mom", baseline_func)]:
w = make_strategy(stocks, func, top_n=10)
eq = run_backtest(w, stocks)
dd = drawdown_analysis(eq)
print(f" {label}:")
if dd.empty:
print(" No significant drawdowns")
else:
for _, row in dd.iterrows():
print(f" {row['start']}{row['trough']}{row['end']}: "
f"{row['depth']:+.1%} ({row['duration_days']}d)")
print()
# 6. Also test the runner-up combos
print("--- 6. Other Strong Combos (Top-10, 21d rebal, 10bps) ---\n")
other_combos = [
("rec_deep+upvol", combo_signal([(f_recovery_deep, 0.5), (f_up_volume_proxy, 0.5)])),
("rec_deep+mom", combo_signal([(f_recovery_deep, 0.5), (f_momentum_12_1, 0.5)])),
("mom+gap_up", combo_signal([(f_momentum_12_1, 0.5), (f_gap_up_freq, 0.5)])),
("rec_deep+upvol+mom", combo_signal([(f_recovery_deep, 0.33), (f_up_volume_proxy, 0.33), (f_momentum_12_1, 0.34)])),
("mom+upvol+gap", combo_signal([(f_momentum_12_1, 0.33), (f_up_volume_proxy, 0.33), (f_gap_up_freq, 0.34)])),
]
for label, func in other_combos:
w = make_strategy(stocks, func, top_n=10)
eq = run_backtest(w, stocks)
s = compute_stats(eq, "")
print(f" {label:<25} CAGR: {s['cagr']:>+7.1%} Sharpe: {s['sharpe']:.2f} MaxDD: {s['maxdd']:>+7.1%} Calmar: {s['calmar']:.2f}")
def run_cn(stocks: pd.DataFrame):
print("\n" + "=" * 100)
print(" CN ROBUSTNESS — Winners: reversal_vol + gap_up, earn_drift + reversal_vol")
print("=" * 100)
winner1_func = combo_signal([(f_reversal_vol_cn, 0.5), (f_gap_up_freq, 0.5)])
winner2_func = combo_signal([(f_earnings_drift_proxy, 0.5), (f_reversal_vol_cn, 0.5)])
baseline_func = f_recovery_mom
# 1. Rolling 2-year performance
print("\n--- 1. Rolling 2-Year Performance ---\n")
for label, func in [("W1: rev_vol+gap_up", winner1_func),
("W2: earn_drift+rev_vol", winner2_func),
("Baseline: rec+mom", baseline_func)]:
w = make_strategy(stocks, func, top_n=10)
eq = run_backtest(w, stocks)
roll = rolling_window_performance(eq)
if roll.empty:
continue
win_pct = (roll["ann_return"] > 0).mean()
print(f" {label}:")
print(f" Mean 2yr ann return: {roll['ann_return'].mean():+.1%}")
print(f" Min 2yr ann return: {roll['ann_return'].min():+.1%}")
print(f" Max 2yr ann return: {roll['ann_return'].max():+.1%}")
print(f" % positive 2yr: {win_pct:.0%}")
print(f" Mean 2yr Sharpe: {roll['sharpe'].mean():.2f}")
print()
# 2. Top-N sensitivity
print("--- 2. Top-N Sensitivity ---\n")
header = f" {'Top-N':<8}"
for label in ["W1: rev+gap", "W2: earn+rev", "Baseline"]:
header += f" | {'CAGR':>8} {'Sharpe':>8} {'MaxDD':>8}"
print(header)
print(" " + "-" * 100)
for top_n in [5, 10, 15, 20]:
line = f" {top_n:<8}"
for func in [winner1_func, winner2_func, baseline_func]:
w = make_strategy(stocks, func, top_n=top_n)
eq = run_backtest(w, stocks)
s = compute_stats(eq, "")
line += f" | {s['cagr']:>+7.1%} {s['sharpe']:>8.2f} {s['maxdd']:>+7.1%}"
print(line)
# 3. Rebalance frequency
print("\n--- 3. Rebalance Frequency ---\n")
header = f" {'Rebal':<8}"
for label in ["W1: rev+gap", "W2: earn+rev", "Baseline"]:
header += f" | {'CAGR':>8} {'Sharpe':>8}"
print(header)
print(" " + "-" * 75)
for rebal in [5, 10, 21, 42]:
line = f" {rebal}d{'':<5}"
for func in [winner1_func, winner2_func, baseline_func]:
w = make_strategy(stocks, func, top_n=10, rebal_freq=rebal)
eq = run_backtest(w, stocks)
s = compute_stats(eq, "")
line += f" | {s['cagr']:>+7.1%} {s['sharpe']:>8.2f}"
print(line)
# 4. Transaction cost sensitivity
print("\n--- 4. Transaction Cost Sensitivity ---\n")
header = f" {'Cost':<8}"
for label in ["W1: rev+gap", "W2: earn+rev", "Baseline"]:
header += f" | {'CAGR':>8} {'Sharpe':>8}"
print(header)
print(" " + "-" * 75)
for cost in [0, 0.001, 0.002, 0.005]:
line = f" {cost*10000:.0f}bps{'':<4}"
for func in [winner1_func, winner2_func, baseline_func]:
w = make_strategy(stocks, func, top_n=10)
eq = run_backtest(w, stocks, cost=cost)
s = compute_stats(eq, "")
line += f" | {s['cagr']:>+7.1%} {s['sharpe']:>8.2f}"
print(line)
# 5. Drawdown analysis
print("\n--- 5. Drawdown Episodes ---\n")
for label, func in [("W1: rev_vol+gap_up", winner1_func),
("W2: earn_drift+rev_vol", winner2_func),
("Baseline: rec+mom", baseline_func)]:
w = make_strategy(stocks, func, top_n=10)
eq = run_backtest(w, stocks)
dd = drawdown_analysis(eq)
print(f" {label}:")
if dd.empty:
print(" No significant drawdowns")
else:
for _, row in dd.iterrows():
print(f" {row['start']}{row['trough']}{row['end']}: "
f"{row['depth']:+.1%} ({row['duration_days']}d)")
print()
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--market", default="both", choices=["us", "cn", "both"])
args = parser.parse_args()
if args.market in ("us", "both"):
prices = data_manager.load("us")
stocks = prices.drop(columns=["SPY"], errors="ignore")
run_us(stocks)
if args.market in ("cn", "both"):
prices = data_manager.load("cn")
stocks = prices.drop(columns=["000300.SS"], errors="ignore")
run_cn(stocks)
if __name__ == "__main__":
main()