fix: IC chart cumulative mean + forward return horizon matching + multi-horizon eval

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Yuxuan Yan
2026-06-07 09:54:31 +08:00
parent bd0605072f
commit aedc019d23
5 changed files with 51 additions and 30 deletions
+3 -1
View File
@@ -107,7 +107,9 @@ def plot_ic(signal_eval: dict, output_path: str = "reports/ic.png") -> str:
fig, ax = plt.subplots(figsize=(10, 5)) fig, ax = plt.subplots(figsize=(10, 5))
if len(rank_ic): if len(rank_ic):
ax.bar(rank_ic.index, rank_ic.values, width=1.0, color="C1", alpha=0.6, label="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.legend()
ax.set_title("Cross-Sectional Rank IC") ax.set_title("Cross-Sectional Rank IC")
ax.set_xlabel("Date") ax.set_xlabel("Date")
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 43 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 56 KiB

+16 -16
View File
@@ -1,21 +1,21 @@
BACKTEST SUMMARY BACKTEST SUMMARY
======================================== ========================================
sharpe: -0.18318054011826762 sharpe: -0.578725244460369
max_drawdown: 31.155770933507835 max_drawdown: 27.593465928996675
max_drawdown_len: 403 max_drawdown_len: 445
total_return: -0.01938978410684507 total_return: -0.16317310010638986
avg_return: -4.006153741083692e-05 avg_return: -0.00033713450435204515
total_trades: 470 total_trades: 504
won_trades: 172 won_trades: 258
lost_trades: 293 lost_trades: 242
SIGNAL IC SIGNAL IC
======================================== ========================================
ic_mean: 0.006912277738865651 ic_mean: -0.020299172809112382
ic_std: 0.3332983458971776 ic_std: 0.3285779534751247
ir: 0.020739010030965125 ir: -0.06177886432861099
rank_ic_mean: 0.006980297831283274 rank_ic_mean: -0.016494730906385646
rank_ic_std: 0.32283972442680237 rank_ic_std: 0.3261781536343117
rank_ir: 0.02162155801513181 rank_ir: -0.05056969856073928
hit_rate: 0.5188284518828452 hit_rate: 0.459915611814346
n_periods: 478 n_periods: 474
+32 -13
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/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.""" -> multi-stock backtest (AlphaStrategy + RankEqualWeightBuilder) -> reports."""
import logging import logging
@@ -12,14 +12,24 @@ from data.downloader import download_batch
from data.universe import SYMBOLS from data.universe import SYMBOLS
from eval.metrics import evaluate_cross_sectional from eval.metrics import evaluate_cross_sectional
from portfolio.builder import RankEqualWeightBuilder from portfolio.builder import RankEqualWeightBuilder
from signals.momentum import MomentumSignal from signals.reversal import ReversalSignal
from strategies.alpha_strategy import AlphaStrategy from strategies.alpha_strategy import AlphaStrategy
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__) 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] symbols = SYMBOLS[:30]
start, end = "2023-01-01", "2024-12-31" start, end = "2023-01-01", "2024-12-31"
initial_cash = 1_000_000 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} 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") logger.info(f"Downloaded {len(data)}/{len(symbols)} symbols")
# 3. Compute the momentum signal per stock. # 3. Compute the reversal signal per stock.
signal = MomentumSignal(lookback=5) signal = ReversalSignal(lookback=5)
signal_series: dict[str, pd.Series] = {} signal_series: dict[str, pd.Series] = {}
forward_returns: dict[str, pd.Series] = {}
for sym, df in data.items(): for sym, df in data.items():
idx = pd.to_datetime(df["date"])
sig = signal.compute(df) sig = signal.compute(df)
sig.index = idx sig.index = pd.to_datetime(df["date"])
signal_series[sym] = sig 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) 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) 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. # 5. Attach the signal column to each DataFrame and build feeds.
config = BacktestConfig( config = BacktestConfig(
symbols=list(data.keys()), symbols=list(data.keys()),
@@ -79,6 +90,14 @@ def main():
f"{signal_eval['rank_ic_std']:.4f} / {signal_eval['rank_ir']:.4f}") f"{signal_eval['rank_ic_std']:.4f} / {signal_eval['rank_ir']:.4f}")
print(f"Hit rate: {signal_eval['hit_rate']:.2%}") print(f"Hit rate: {signal_eval['hit_rate']:.2%}")
print(f"Periods: {signal_eval['n_periods']}") 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}") print(f"\nReports written to: {artifacts}")