diff --git a/analysis/report.py b/analysis/report.py index 368d7a9..125cac2 100644 --- a/analysis/report.py +++ b/analysis/report.py @@ -107,7 +107,9 @@ def plot_ic(signal_eval: dict, output_path: str = "reports/ic.png") -> str: fig, ax = plt.subplots(figsize=(10, 5)) if len(rank_ic): ax.bar(rank_ic.index, rank_ic.values, width=1.0, color="C1", alpha=0.6, label="Rank IC") - ax.axhline(rank_ic.mean(), color="C3", linestyle="--", label=f"mean={rank_ic.mean():.3f}") + ax.axhline(rank_ic.mean(), color="C7", linestyle="--", label=f"mean={rank_ic.mean():.3f}") + cum_mean = rank_ic.expanding().mean() + ax.plot(cum_mean.index, cum_mean.values, color="red", linewidth=1.5, label="Cumulative mean IC") ax.legend() ax.set_title("Cross-Sectional Rank IC") ax.set_xlabel("Date") diff --git a/reports/ic.png b/reports/ic.png index 77032bf..7a7f90d 100644 Binary files a/reports/ic.png and b/reports/ic.png differ diff --git a/reports/pnl.png b/reports/pnl.png index e6cf911..33e6ebd 100644 Binary files a/reports/pnl.png and b/reports/pnl.png differ diff --git a/reports/summary.txt b/reports/summary.txt index c0d3f67..fc216fb 100644 --- a/reports/summary.txt +++ b/reports/summary.txt @@ -1,21 +1,21 @@ BACKTEST SUMMARY ======================================== -sharpe: -0.18318054011826762 -max_drawdown: 31.155770933507835 -max_drawdown_len: 403 -total_return: -0.01938978410684507 -avg_return: -4.006153741083692e-05 -total_trades: 470 -won_trades: 172 -lost_trades: 293 +sharpe: -0.578725244460369 +max_drawdown: 27.593465928996675 +max_drawdown_len: 445 +total_return: -0.16317310010638986 +avg_return: -0.00033713450435204515 +total_trades: 504 +won_trades: 258 +lost_trades: 242 SIGNAL IC ======================================== -ic_mean: 0.006912277738865651 -ic_std: 0.3332983458971776 -ir: 0.020739010030965125 -rank_ic_mean: 0.006980297831283274 -rank_ic_std: 0.32283972442680237 -rank_ir: 0.02162155801513181 -hit_rate: 0.5188284518828452 -n_periods: 478 +ic_mean: -0.020299172809112382 +ic_std: 0.3285779534751247 +ir: -0.06177886432861099 +rank_ic_mean: -0.016494730906385646 +rank_ic_std: 0.3261781536343117 +rank_ir: -0.05056969856073928 +hit_rate: 0.459915611814346 +n_periods: 474 diff --git a/run_example.py b/run_example.py index 8411dd0..165dd46 100644 --- a/run_example.py +++ b/run_example.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""End-to-end pipeline: HS300 universe -> momentum signal -> cross-sectional IC +"""End-to-end pipeline: HS300 universe -> reversal signal -> cross-sectional IC -> multi-stock backtest (AlphaStrategy + RankEqualWeightBuilder) -> reports.""" import logging @@ -12,14 +12,24 @@ from data.downloader import download_batch from data.universe import SYMBOLS from eval.metrics import evaluate_cross_sectional from portfolio.builder import RankEqualWeightBuilder -from signals.momentum import MomentumSignal +from signals.reversal import ReversalSignal from strategies.alpha_strategy import AlphaStrategy logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") logger = logging.getLogger(__name__) -def main(): +def _forward_returns(data: dict[str, pd.DataFrame], horizon: int) -> pd.DataFrame: + """Build a date-indexed DataFrame of ``horizon``-day forward returns per stock.""" + forward_returns: dict[str, pd.Series] = {} + for sym, df in data.items(): + fwd = df["close"].pct_change(horizon).shift(-horizon) + fwd.index = pd.to_datetime(df["date"]) + forward_returns[sym] = fwd + return pd.DataFrame(forward_returns) + + +def main(forward_horizon: int = 5): symbols = SYMBOLS[:30] start, end = "2023-01-01", "2024-12-31" initial_cash = 1_000_000 @@ -29,24 +39,25 @@ def main(): data = {s: df for s, df in data.items() if df is not None and not df.empty} logger.info(f"Downloaded {len(data)}/{len(symbols)} symbols") - # 3. Compute the momentum signal per stock. - signal = MomentumSignal(lookback=5) + # 3. Compute the reversal signal per stock. + signal = ReversalSignal(lookback=5) signal_series: dict[str, pd.Series] = {} - forward_returns: dict[str, pd.Series] = {} for sym, df in data.items(): - idx = pd.to_datetime(df["date"]) sig = signal.compute(df) - sig.index = idx + sig.index = pd.to_datetime(df["date"]) signal_series[sym] = sig - fwd = df["close"].pct_change().shift(-1) # next-day return - fwd.index = idx - forward_returns[sym] = fwd - # 4. Cross-sectional IC evaluation. + # 4. Cross-sectional IC at the matching forward horizon. signals_df = pd.DataFrame(signal_series) - returns_df = pd.DataFrame(forward_returns) + returns_df = _forward_returns(data, forward_horizon) signal_eval = evaluate_cross_sectional(signals_df, returns_df) + # 4b. Multi-horizon IC to show which horizon the signal works at. + horizon_evals = { + h: evaluate_cross_sectional(signals_df, _forward_returns(data, h)) + for h in (1, 5, 20) + } + # 5. Attach the signal column to each DataFrame and build feeds. config = BacktestConfig( symbols=list(data.keys()), @@ -79,6 +90,14 @@ def main(): f"{signal_eval['rank_ic_std']:.4f} / {signal_eval['rank_ir']:.4f}") print(f"Hit rate: {signal_eval['hit_rate']:.2%}") print(f"Periods: {signal_eval['n_periods']}") + + print("\nMULTI-HORIZON IC") + print("=" * 50) + print(f"{'Horizon':>8} {'Rank IC':>9} {'Rank IR':>9} {'Hit rate':>9} {'Periods':>8}") + for h, ev in horizon_evals.items(): + print(f"{f'{h}d':>8} {ev['rank_ic_mean']:>9.4f} {ev['rank_ir']:>9.4f} " + f"{ev['hit_rate']:>8.2%} {ev['n_periods']:>8}") + print(f"\nReports written to: {artifacts}")