- 新增 finance/tests/ 6 个测试套件(agents/backtest/dao_upsert/factors/features/fundamental_lookahead) - 数据层: data_manager / dao 优化,新增 upsert 逻辑 - 因子层: 基本面因子抽象定位 _mapping、ROE/PE/PB 重构 - 回测层: vectorbt/engine 大改动(251 行),report 增强 - ML 层: features/backtest_integration 特征工程与回测优化 - CLI: agent_cli 重构 - config/settings 扩充配置项
149 lines
5.1 KiB
Python
149 lines
5.1 KiB
Python
"""
|
||
标准化回测报告。
|
||
|
||
与回测引擎解耦,后续换引擎只需改构造函数。
|
||
"""
|
||
|
||
from dataclasses import dataclass, field
|
||
|
||
import numpy as np
|
||
import pandas as pd
|
||
|
||
|
||
@dataclass
|
||
class BacktestReport:
|
||
"""回测报告"""
|
||
|
||
# 核心收益指标
|
||
total_return: float = 0.0
|
||
cagr: float = 0.0
|
||
max_drawdown: float = 0.0
|
||
sharpe_ratio: float = 0.0
|
||
calmar_ratio: float = 0.0
|
||
annual_volatility: float = 0.0
|
||
|
||
# 交易统计
|
||
win_rate: float = 0.0
|
||
profit_factor: float = 0.0
|
||
total_trades: int = 0
|
||
avg_hold_days: float = 0.0
|
||
best_trade_pct: float = 0.0
|
||
worst_trade_pct: float = 0.0
|
||
|
||
# 序列数据
|
||
equity_curve: pd.Series = field(default_factory=pd.Series)
|
||
drawdown_curve: pd.Series = field(default_factory=pd.Series)
|
||
monthly_returns: pd.Series = field(default_factory=pd.Series)
|
||
trades_df: pd.DataFrame = field(default_factory=pd.DataFrame)
|
||
|
||
# 原始 stats
|
||
stats_dict: dict = field(default_factory=dict)
|
||
|
||
@classmethod
|
||
def from_vbt_result(cls, pf, close: pd.Series) -> "BacktestReport":
|
||
"""从 VectorBT Portfolio 结果构建报告。"""
|
||
stats = pf.stats()
|
||
|
||
def _pct(v):
|
||
"""VectorBT stats 值已为百分比 float,直接返回。"""
|
||
if v is None:
|
||
return 0.0
|
||
try:
|
||
return float(v)
|
||
except (ValueError, TypeError):
|
||
return 0.0
|
||
|
||
def _duration_days(v):
|
||
"""Timedelta → 天数。"""
|
||
if v is None:
|
||
return 0.0
|
||
try:
|
||
return v.total_seconds() / 86400
|
||
except AttributeError:
|
||
return float(v) if v is not None else 0.0
|
||
|
||
# VectorBT 1.1.0 的 pf.value() 可能返回数字位置 index(0..n-1)。
|
||
# 统一用它对应的交易日 index(close 已是规范化后的 DatetimeIndex),
|
||
# 长度相等时按位置对齐,保证 equity/drawdown 的日期语义正确。
|
||
equity = pf.value()
|
||
if not isinstance(equity.index, pd.DatetimeIndex) and len(equity) == len(close):
|
||
equity.index = close.index
|
||
elif not isinstance(equity.index, pd.DatetimeIndex):
|
||
# 长度不等时尽力用 close 前缀对齐,避免异常
|
||
equity.index = close.index[: len(equity)]
|
||
|
||
dd = equity / equity.cummax() - 1
|
||
daily_ret = equity.pct_change().dropna()
|
||
|
||
years = len(daily_ret) / 252 if len(daily_ret) > 0 else 1
|
||
total_ret = (equity.iloc[-1] / equity.iloc[0] - 1) * 100 if len(equity) > 1 else 0
|
||
cagr = ((total_ret / 100 + 1) ** (1 / years) - 1) * 100 if years > 0 else 0
|
||
mdd = dd.min() * 100
|
||
ann_vol = daily_ret.std() * np.sqrt(252) * 100 if len(daily_ret) > 0 else 0
|
||
|
||
mean_ret = daily_ret.mean() * 252
|
||
std_ret = daily_ret.std() * np.sqrt(252)
|
||
sharpe = mean_ret / std_ret if std_ret > 0 else 0
|
||
calmar = cagr / abs(mdd) if mdd != 0 else 0
|
||
|
||
trades = pf.trades.records_readable if hasattr(pf, "trades") else pd.DataFrame()
|
||
|
||
try:
|
||
monthly = equity.resample("ME").last().pct_change()
|
||
except Exception:
|
||
monthly = pd.Series(dtype=float)
|
||
|
||
pf_factor = stats.get("Profit Factor", 0)
|
||
if pf_factor is None or np.isinf(float(pf_factor)):
|
||
pf_factor = 0.0
|
||
else:
|
||
pf_factor = float(pf_factor)
|
||
|
||
return cls(
|
||
total_return=round(total_ret, 2),
|
||
cagr=round(cagr, 2),
|
||
max_drawdown=round(mdd, 2),
|
||
sharpe_ratio=round(sharpe, 2),
|
||
calmar_ratio=round(calmar, 2),
|
||
annual_volatility=round(ann_vol, 2),
|
||
win_rate=_pct(stats.get("Win Rate [%]", 0)),
|
||
profit_factor=pf_factor,
|
||
total_trades=int(stats.get("Total Trades", 0)),
|
||
avg_hold_days=_duration_days(stats.get("Avg Winning Trade Duration", None)),
|
||
best_trade_pct=_pct(stats.get("Best Trade [%]", 0)),
|
||
worst_trade_pct=_pct(stats.get("Worst Trade [%]", 0)),
|
||
equity_curve=equity,
|
||
drawdown_curve=dd,
|
||
monthly_returns=monthly,
|
||
trades_df=trades,
|
||
stats_dict={k: str(v) for k, v in stats.items()},
|
||
)
|
||
|
||
def to_dict(self) -> dict:
|
||
"""核心指标转字典。"""
|
||
return {
|
||
"total_return": self.total_return,
|
||
"cagr": self.cagr,
|
||
"max_drawdown": self.max_drawdown,
|
||
"sharpe_ratio": self.sharpe_ratio,
|
||
"calmar_ratio": self.calmar_ratio,
|
||
"annual_volatility": self.annual_volatility,
|
||
"win_rate": self.win_rate,
|
||
"profit_factor": self.profit_factor,
|
||
"total_trades": self.total_trades,
|
||
}
|
||
|
||
def summary(self) -> str:
|
||
"""一行摘要。"""
|
||
return (
|
||
f"收益={self.total_return:.1f}% "
|
||
f"年化={self.cagr:.1f}% "
|
||
f"回撤={self.max_drawdown:.1f}% "
|
||
f"夏普={self.sharpe_ratio:.2f} "
|
||
f"胜率={self.win_rate:.1f}% "
|
||
f"交易={self.total_trades}笔"
|
||
)
|
||
|
||
def __repr__(self) -> str:
|
||
return self.summary()
|