feat: signal abstraction layer + sizer + HS300 universe + PnL/IC reports
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user