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:
219
factor_yearly_report.py
Normal file
219
factor_yearly_report.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""
|
||||
Yearly ROI report for champion strategies vs SPY, starting from $10,000.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import warnings
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import data_manager
|
||||
from universe import UNIVERSES
|
||||
from factor_loop import (
|
||||
strat, bt, stats, combo,
|
||||
f_rec_mom, f_rec_126, f_rec_63,
|
||||
f_mom_12_1, f_mom_6_1, f_mom_intermediate,
|
||||
f_above_ma200, f_golden_cross,
|
||||
f_up_volume_proxy, f_gap_up_freq,
|
||||
f_rec_mom_filtered, f_down_resilience,
|
||||
f_up_capture, f_52w_high, f_str_10d,
|
||||
f_earnings_drift, f_reversal_vol,
|
||||
)
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
INITIAL = 10_000
|
||||
|
||||
|
||||
def f_quality_mom(p):
|
||||
mom = f_mom_12_1(p)
|
||||
consist = (p.pct_change() > 0).astype(float).rolling(252, min_periods=126).mean()
|
||||
mom_r = mom.rank(axis=1, pct=True, na_option="keep")
|
||||
con_r = consist.rank(axis=1, pct=True, na_option="keep")
|
||||
up_r = f_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 f_mom_x_gap(p):
|
||||
return (f_mom_12_1(p).rank(axis=1, pct=True, na_option="keep") *
|
||||
f_gap_up_freq(p).rank(axis=1, pct=True, na_option="keep"))
|
||||
|
||||
|
||||
def run_equity(func, prices, cost=0.001):
|
||||
w = strat(prices, func, top_n=10)
|
||||
eq = bt(w, prices, cost=cost)
|
||||
return eq / eq.iloc[0] * INITIAL
|
||||
|
||||
|
||||
def yearly_table(equities: dict[str, pd.Series], title: str):
|
||||
print(f"\n{'='*130}")
|
||||
print(f" {title}")
|
||||
print(f" Starting capital: ${INITIAL:,.0f}")
|
||||
print(f"{'='*130}")
|
||||
|
||||
names = list(equities.keys())
|
||||
all_years = sorted(set(y for eq in equities.values() for y in eq.index.year.unique()))
|
||||
|
||||
# Header
|
||||
print(f"\n {'Year':<6}", end="")
|
||||
for n in names:
|
||||
print(f" | {n[:24]:>24}", end="")
|
||||
print()
|
||||
print(f" {'-'*6}", end="")
|
||||
for _ in names:
|
||||
print(f"-+-{'-'*24}", end="")
|
||||
print()
|
||||
|
||||
# Track portfolio values
|
||||
year_end_vals = {n: INITIAL for n in names}
|
||||
|
||||
for year in all_years:
|
||||
print(f" {year:<6}", end="")
|
||||
for n in names:
|
||||
eq = equities[n]
|
||||
yr_data = eq[eq.index.year == year]
|
||||
if len(yr_data) < 2:
|
||||
print(f" | {'—':>24}", end="")
|
||||
continue
|
||||
start_val = yr_data.iloc[0]
|
||||
end_val = yr_data.iloc[-1]
|
||||
ret = end_val / start_val - 1
|
||||
year_end_vals[n] = end_val
|
||||
# Show both return % and portfolio value
|
||||
print(f" | {ret:>+7.1%} ${end_val:>12,.0f}", end="")
|
||||
print()
|
||||
|
||||
# Summary rows
|
||||
print(f" {'-'*6}", end="")
|
||||
for _ in names:
|
||||
print(f"-+-{'-'*24}", end="")
|
||||
print()
|
||||
|
||||
# Total return
|
||||
print(f" {'Total':<6}", end="")
|
||||
for n in names:
|
||||
eq = equities[n]
|
||||
total = eq.iloc[-1] / INITIAL - 1
|
||||
print(f" | {total:>+7.0%} ${eq.iloc[-1]:>12,.0f}", end="")
|
||||
print()
|
||||
|
||||
# CAGR
|
||||
print(f" {'CAGR':<6}", end="")
|
||||
for n in names:
|
||||
eq = equities[n]
|
||||
ny = len(eq) / 252
|
||||
total = eq.iloc[-1] / INITIAL - 1
|
||||
cagr = (1 + total) ** (1 / ny) - 1
|
||||
print(f" | {cagr:>+7.1%} {'':>12}", end="")
|
||||
print()
|
||||
|
||||
# Sharpe
|
||||
print(f" {'Sharpe':<6}", end="")
|
||||
for n in names:
|
||||
eq = equities[n]
|
||||
dr = eq.pct_change().dropna()
|
||||
sh = dr.mean() / dr.std() * np.sqrt(252) if dr.std() > 0 else 0
|
||||
print(f" | {sh:>7.2f} {'':>12}", end="")
|
||||
print()
|
||||
|
||||
# Max DD
|
||||
print(f" {'MaxDD':<6}", end="")
|
||||
for n in names:
|
||||
eq = equities[n]
|
||||
rm = eq.cummax()
|
||||
dd = ((eq - rm) / rm).min()
|
||||
print(f" | {dd:>+7.1%} {'':>12}", end="")
|
||||
print()
|
||||
|
||||
# Best/Worst year
|
||||
print(f" {'Best':<6}", end="")
|
||||
for n in names:
|
||||
eq = equities[n]
|
||||
dr = eq.pct_change().fillna(0)
|
||||
yr_rets = {y: float((1 + dr[dr.index.year == y]).prod() - 1) for y in all_years}
|
||||
# skip warmup year
|
||||
active = {y: r for y, r in yr_rets.items() if abs(r) > 0.001}
|
||||
if active:
|
||||
best_y = max(active, key=active.get)
|
||||
print(f" | {active[best_y]:>+7.1%} ({best_y}) ", end="")
|
||||
else:
|
||||
print(f" | {'—':>24}", end="")
|
||||
print()
|
||||
|
||||
print(f" {'Worst':<6}", end="")
|
||||
for n in names:
|
||||
eq = equities[n]
|
||||
dr = eq.pct_change().fillna(0)
|
||||
yr_rets = {y: float((1 + dr[dr.index.year == y]).prod() - 1) for y in all_years}
|
||||
active = {y: r for y, r in yr_rets.items() if abs(r) > 0.001}
|
||||
if active:
|
||||
worst_y = min(active, key=active.get)
|
||||
print(f" | {active[worst_y]:>+7.1%} ({worst_y}) ", end="")
|
||||
else:
|
||||
print(f" | {'—':>24}", end="")
|
||||
print()
|
||||
|
||||
|
||||
def main():
|
||||
# ===== US =====
|
||||
prices_us = data_manager.load("us")
|
||||
bench_us = prices_us["SPY"].dropna()
|
||||
stocks_us = prices_us.drop(columns=["SPY"], errors="ignore")
|
||||
|
||||
eq_spy = bench_us / bench_us.iloc[0] * INITIAL
|
||||
|
||||
us_strats = {
|
||||
"rec_mfilt+deep×upvol": combo([
|
||||
(f_rec_mom_filtered, 0.5),
|
||||
(combo([(f_rec_126, 0.5), (f_up_volume_proxy, 0.5)]), 0.5),
|
||||
]),
|
||||
"ma200+mom7m+rec126": combo([
|
||||
(f_above_ma200, 0.33), (f_mom_intermediate, 0.33), (f_rec_126, 0.34)
|
||||
]),
|
||||
"rec_mfilt+ma200": combo([
|
||||
(f_rec_mom_filtered, 0.5), (f_above_ma200, 0.5)
|
||||
]),
|
||||
"mom7m+rec126": combo([
|
||||
(f_mom_intermediate, 0.5), (f_rec_126, 0.5)
|
||||
]),
|
||||
"BASELINE:rec+mom": f_rec_mom,
|
||||
}
|
||||
|
||||
us_equities = {}
|
||||
for name, func in us_strats.items():
|
||||
us_equities[name] = run_equity(func, stocks_us)
|
||||
us_equities["SPY (Benchmark)"] = eq_spy
|
||||
|
||||
yearly_table(us_equities, "US MARKET — Champion Strategies vs SPY — $10,000 Starting Capital")
|
||||
|
||||
# ===== CN =====
|
||||
prices_cn = data_manager.load("cn")
|
||||
bench_cn = prices_cn["000300.SS"].dropna() if "000300.SS" in prices_cn.columns else None
|
||||
stocks_cn = prices_cn.drop(columns=["000300.SS"], errors="ignore")
|
||||
|
||||
cn_strats = {
|
||||
"up_cap+quality_mom": combo([
|
||||
(f_up_capture, 0.5), (f_quality_mom, 0.5)
|
||||
]),
|
||||
"down_resil+qual_mom": combo([
|
||||
(f_down_resilience, 0.5), (f_quality_mom, 0.5)
|
||||
]),
|
||||
"rec63+mom×gap": combo([
|
||||
(f_rec_63, 0.5), (f_mom_x_gap, 0.5)
|
||||
]),
|
||||
"up_cap+mom×gap": combo([
|
||||
(f_up_capture, 0.5), (f_mom_x_gap, 0.5)
|
||||
]),
|
||||
"BASELINE:rec+mom": f_rec_mom,
|
||||
}
|
||||
|
||||
cn_equities = {}
|
||||
for name, func in cn_strats.items():
|
||||
cn_equities[name] = run_equity(func, stocks_cn)
|
||||
if bench_cn is not None:
|
||||
cn_equities["CSI300 (Benchmark)"] = bench_cn / bench_cn.iloc[0] * INITIAL
|
||||
|
||||
yearly_table(cn_equities, "CN MARKET — Champion Strategies vs CSI 300 — $10,000 Starting Capital")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user