160 lines
5.5 KiB
Python
160 lines
5.5 KiB
Python
"""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."""
|
|
if not results:
|
|
print("No results to report.")
|
|
return {}
|
|
|
|
result = results[0]
|
|
report = {}
|
|
|
|
# Sharpe ratio
|
|
sharpe = result.analyzers.sharpe.get_analysis()
|
|
report["sharpe"] = sharpe.get("sharperatio", "N/A")
|
|
|
|
# Drawdown
|
|
dd = result.analyzers.drawdown.get_analysis()
|
|
report["max_drawdown"] = dd.get("max", {}).get("drawdown", "N/A")
|
|
report["max_drawdown_len"] = dd.get("max", {}).get("len", "N/A")
|
|
|
|
# Returns
|
|
rets = result.analyzers.returns.get_analysis()
|
|
report["total_return"] = rets.get("rtot", "N/A")
|
|
report["avg_return"] = rets.get("ravg", "N/A")
|
|
|
|
# Trades
|
|
trades = result.analyzers.trades.get_analysis()
|
|
report["total_trades"] = trades.get("total", {}).get("total", 0)
|
|
report["won_trades"] = trades.get("won", {}).get("total", 0)
|
|
report["lost_trades"] = trades.get("lost", {}).get("total", 0)
|
|
|
|
# Print
|
|
print("=" * 50)
|
|
print("BACKTEST RESULTS")
|
|
print("=" * 50)
|
|
print(f"Sharpe Ratio: {report['sharpe']}")
|
|
print(f"Total Return: {report['total_return']:.4%}" if isinstance(report['total_return'], float) else f"Total Return: {report['total_return']}")
|
|
print(f"Max Drawdown: {report['max_drawdown']:.2%}" if isinstance(report['max_drawdown'], float) else f"Max Drawdown: {report['max_drawdown']}")
|
|
print(f"Max DD Length: {report['max_drawdown_len']}")
|
|
print(f"Total Trades: {report['total_trades']}")
|
|
print(f"Won/Lost: {report['won_trades']}/{report['lost_trades']}")
|
|
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}
|