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>
220 lines
6.6 KiB
Python
220 lines
6.6 KiB
Python
"""
|
||
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()
|