feat: signal abstraction layer + sizer + HS300 universe + PnL/IC reports

This commit is contained in:
Yuxuan Yan
2026-06-07 09:44:33 +08:00
parent 085e51abf1
commit da01312292
20 changed files with 610 additions and 23 deletions
+113
View File
@@ -1,6 +1,13 @@
"""Performance analysis and reporting for backtest results."""
import os
from typing import Any
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
import pandas as pd # noqa: E402
def print_results(results: list, initial_cash: float = 1_000_000.0) -> dict[str, Any]:
"""Print and return key performance metrics from a backtrader run result."""
@@ -44,3 +51,109 @@ def print_results(results: list, initial_cash: float = 1_000_000.0) -> dict[str,
print("=" * 50)
return report
def plot_accumulated_pnl(
results: list, output_path: str = "reports/pnl.png", initial_cash: float = 1_000_000.0
) -> str:
"""Plot accumulated portfolio value from a backtest run.
Reads the per-day TimeReturn analyzer attached by ``BacktestRunner`` and
compounds it into an equity curve.
Args:
results: The list returned by ``cerebro.run()``.
output_path: Destination PNG path.
initial_cash: Starting portfolio value for scaling the curve.
Returns:
The path the chart was written to.
"""
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
series = pd.Series(dtype=float)
if results:
tr = results[0].analyzers.timereturn.get_analysis()
series = pd.Series(tr).sort_index()
fig, ax = plt.subplots(figsize=(10, 5))
if len(series):
equity = (1.0 + series).cumprod() * initial_cash
ax.plot(equity.index, equity.values, color="C0")
ax.set_title("Accumulated Portfolio Value")
ax.set_xlabel("Date")
ax.set_ylabel("Value")
ax.grid(True, alpha=0.3)
fig.autofmt_xdate()
fig.tight_layout()
fig.savefig(output_path, dpi=100)
plt.close(fig)
return output_path
def plot_ic(signal_eval: dict, output_path: str = "reports/ic.png") -> str:
"""Plot the per-period rank IC time series from a signal evaluation.
Args:
signal_eval: Dict returned by ``evaluate_cross_sectional`` (expects a
``rank_ic_series`` pandas Series).
output_path: Destination PNG path.
Returns:
The path the chart was written to.
"""
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
rank_ic = signal_eval.get("rank_ic_series", pd.Series(dtype=float))
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.legend()
ax.set_title("Cross-Sectional Rank IC")
ax.set_xlabel("Date")
ax.set_ylabel("Rank IC")
ax.grid(True, alpha=0.3)
fig.autofmt_xdate()
fig.tight_layout()
fig.savefig(output_path, dpi=100)
plt.close(fig)
return output_path
def generate_report(
results: list,
signal_eval: dict,
output_dir: str = "reports/",
initial_cash: float = 1_000_000.0,
) -> dict[str, str]:
"""Generate the full report: PnL chart, IC chart, and a summary text file.
Args:
results: The list returned by ``cerebro.run()``.
signal_eval: Dict returned by ``evaluate_cross_sectional``.
output_dir: Directory to write artifacts into.
initial_cash: Starting portfolio value.
Returns:
Mapping of artifact name to file path.
"""
os.makedirs(output_dir, exist_ok=True)
pnl_path = plot_accumulated_pnl(
results, os.path.join(output_dir, "pnl.png"), initial_cash
)
ic_path = plot_ic(signal_eval, os.path.join(output_dir, "ic.png"))
metrics = print_results(results, initial_cash)
summary_path = os.path.join(output_dir, "summary.txt")
with open(summary_path, "w") as f:
f.write("BACKTEST SUMMARY\n")
f.write("=" * 40 + "\n")
for k, v in metrics.items():
f.write(f"{k}: {v}\n")
f.write("\nSIGNAL IC\n")
f.write("=" * 40 + "\n")
for k in ("ic_mean", "ic_std", "ir", "rank_ic_mean", "rank_ic_std", "rank_ir", "hit_rate", "n_periods"):
if k in signal_eval:
f.write(f"{k}: {signal_eval[k]}\n")
return {"pnl": pnl_path, "ic": ic_path, "summary": summary_path}