Add 28 research scripts covering DCA simulation, momentum evaluation, Sharpe optimization, trend rider analysis, and US fundamentals exploration.
202 lines
6.2 KiB
Python
202 lines
6.2 KiB
Python
"""
|
|
Round 2: Strategy improvement iteration.
|
|
|
|
Tests Hybrid Alpha variants that combine FactorCombo signal with inv-vol weighting,
|
|
and RecoveryQualityBlend that uses all strong factors without restrictive gates.
|
|
"""
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
import data_manager
|
|
from universe import UNIVERSES
|
|
from main import backtest
|
|
|
|
# Top performers from round 1
|
|
from strategies.recovery_momentum import RecoveryMomentumStrategy
|
|
from strategies.factor_combo import FactorComboStrategy
|
|
from strategies.improved_momentum_quality import ImprovedMomentumQualityStrategy
|
|
|
|
# Round 2 strategies
|
|
from strategies.hybrid_alpha import HybridAlphaStrategy, RecoveryQualityBlendStrategy
|
|
|
|
|
|
def annual_return(eq: pd.Series) -> float:
|
|
return eq.iloc[-1] / eq.iloc[0] - 1
|
|
|
|
def max_dd(eq: pd.Series) -> float:
|
|
return ((eq / eq.cummax()) - 1).min()
|
|
|
|
def sharpe(eq: pd.Series) -> float:
|
|
daily = eq.pct_change().dropna()
|
|
if daily.std() == 0:
|
|
return 0.0
|
|
return (daily.mean() * 252) / (daily.std() * np.sqrt(252))
|
|
|
|
def sortino(eq: pd.Series) -> float:
|
|
daily = eq.pct_change().dropna()
|
|
downside = daily[daily < 0].std() * np.sqrt(252)
|
|
if downside == 0:
|
|
return 0.0
|
|
return (daily.mean() * 252) / downside
|
|
|
|
def cagr(eq: pd.Series) -> float:
|
|
yrs = (eq.index[-1] - eq.index[0]).days / 365.25
|
|
if yrs <= 0:
|
|
return 0.0
|
|
return (eq.iloc[-1] / eq.iloc[0]) ** (1 / yrs) - 1
|
|
|
|
def calmar(eq: pd.Series) -> float:
|
|
dd = max_dd(eq)
|
|
if dd >= 0:
|
|
return 0.0
|
|
return cagr(eq) / abs(dd)
|
|
|
|
|
|
def main():
|
|
universe = UNIVERSES["us"]
|
|
tickers = universe["fetch"]()
|
|
benchmark = universe["benchmark"]
|
|
all_tickers = sorted(set(tickers + [benchmark]))
|
|
|
|
data = data_manager.update("us", all_tickers, with_open=False)
|
|
tickers = [t for t in tickers if t in data.columns]
|
|
top_n = max(5, len(tickers) // 10)
|
|
|
|
print(f"Universe: {len(tickers)} stocks + {benchmark}. top_n={top_n}")
|
|
print(f"Data range: {data.index[0].date()} to {data.index[-1].date()}")
|
|
|
|
strategies = {
|
|
# === BASELINES (top 3 from round 1) ===
|
|
"Recovery+Mom Top20 (base)": (
|
|
RecoveryMomentumStrategy(top_n=20),
|
|
data[tickers]
|
|
),
|
|
"FactorCombo rec+deep (base)": (
|
|
FactorComboStrategy(signal_name="rec_mfilt+deep_upvol", rebal_freq=21, top_n=20),
|
|
data[tickers]
|
|
),
|
|
"Improved MomQuality (base)": (
|
|
ImprovedMomentumQualityStrategy(top_n=20),
|
|
data[tickers]
|
|
),
|
|
|
|
# === ROUND 2: HYBRID ALPHA ===
|
|
"Hybrid InvVol Top20": (
|
|
HybridAlphaStrategy(rebal_freq=21, top_n=20, use_invvol=True, regime_dampen=1.0),
|
|
data[tickers]
|
|
),
|
|
"Hybrid InvVol Top30": (
|
|
HybridAlphaStrategy(rebal_freq=21, top_n=30, use_invvol=True, regime_dampen=1.0),
|
|
data[tickers]
|
|
),
|
|
"Hybrid EW Top20": (
|
|
HybridAlphaStrategy(rebal_freq=21, top_n=20, use_invvol=False, regime_dampen=1.0),
|
|
data[tickers]
|
|
),
|
|
"Hybrid InvVol Dampen": (
|
|
HybridAlphaStrategy(rebal_freq=21, top_n=20, use_invvol=True, regime_dampen=0.5),
|
|
data[tickers]
|
|
),
|
|
"Hybrid Biweekly": (
|
|
HybridAlphaStrategy(rebal_freq=10, top_n=20, use_invvol=True, regime_dampen=1.0),
|
|
data[tickers]
|
|
),
|
|
|
|
# === ROUND 2: RECOVERY QUALITY BLEND ===
|
|
"RecQuality Blend Top20": (
|
|
RecoveryQualityBlendStrategy(top_n=20, rebal_freq=21),
|
|
data[tickers]
|
|
),
|
|
"RecQuality Blend Top30": (
|
|
RecoveryQualityBlendStrategy(top_n=30, rebal_freq=21),
|
|
data[tickers]
|
|
),
|
|
"RecQuality Blend Biweekly": (
|
|
RecoveryQualityBlendStrategy(top_n=20, rebal_freq=10),
|
|
data[tickers]
|
|
),
|
|
}
|
|
|
|
# Run backtests
|
|
equity = {}
|
|
for name, (strat, strat_data) in strategies.items():
|
|
print(f"Running {name}...")
|
|
equity[name] = backtest(strat, strat_data, initial_capital=10_000)
|
|
|
|
# SPY benchmark
|
|
bench = data[benchmark].dropna()
|
|
equity["SPY"] = (bench / bench.iloc[0]) * 10_000
|
|
|
|
eq_df = pd.DataFrame(equity).sort_index()
|
|
|
|
# Yearly returns
|
|
years = list(range(2016, 2027))
|
|
rows = []
|
|
for yr in years:
|
|
start = pd.Timestamp(f"{yr}-01-01")
|
|
end = pd.Timestamp(f"{yr}-12-31")
|
|
window = eq_df.loc[(eq_df.index >= start) & (eq_df.index <= end)].dropna(how="all")
|
|
if window.empty:
|
|
continue
|
|
row = {"Year": yr}
|
|
for col in eq_df.columns:
|
|
s = window[col].dropna()
|
|
if len(s) < 2:
|
|
row[col] = np.nan
|
|
else:
|
|
row[col] = annual_return(s)
|
|
rows.append(row)
|
|
|
|
yr_df = pd.DataFrame(rows).set_index("Year")
|
|
|
|
print("\n" + "=" * 80)
|
|
print("YEARLY TOTAL RETURN (%)")
|
|
print("=" * 80)
|
|
print((yr_df * 100).round(2).to_string())
|
|
|
|
# Excess over SPY
|
|
excess = yr_df.sub(yr_df["SPY"], axis=0).drop(columns=["SPY"])
|
|
print("\n" + "=" * 80)
|
|
print("EXCESS vs SPY (pp)")
|
|
print("=" * 80)
|
|
print((excess * 100).round(2).to_string())
|
|
|
|
# Full-period summary
|
|
print("\n" + "=" * 80)
|
|
print("FULL-PERIOD METRICS (sorted by Calmar)")
|
|
print("=" * 80)
|
|
|
|
summary_rows = []
|
|
for col in eq_df.columns:
|
|
eq = eq_df[col].dropna()
|
|
if len(eq) < 252:
|
|
continue
|
|
summary_rows.append({
|
|
"Strategy": col,
|
|
"CAGR %": cagr(eq) * 100,
|
|
"Sharpe": sharpe(eq),
|
|
"Sortino": sortino(eq),
|
|
"Max DD %": max_dd(eq) * 100,
|
|
"Calmar": calmar(eq),
|
|
"Win vs SPY": f"{(excess[col] > 0).sum()}/{len(excess)}" if col in excess.columns else "-",
|
|
})
|
|
|
|
summary = pd.DataFrame(summary_rows).sort_values("Calmar", ascending=False)
|
|
pd.set_option('display.max_columns', None)
|
|
pd.set_option('display.width', 200)
|
|
print(summary.to_string(index=False))
|
|
|
|
# Turnover analysis
|
|
print("\n" + "=" * 80)
|
|
print("TURNOVER ANALYSIS")
|
|
print("=" * 80)
|
|
for name, (strat, strat_data) in strategies.items():
|
|
w = strat.generate_signals(strat_data)
|
|
avg_turn = w.diff().abs().sum(axis=1).mean()
|
|
print(f" {name:<35s} avg daily turnover: {avg_turn:.4f}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|