Files
sanguo_vnpy_v2/sanguo_backtest/metrics.py
T
claude_dev 7fe3fb0844 fix(backtest): 结果页垃圾值/无图表端到端修复(empyrical×numpy2.0根因)
根因: empyrical 0.5.5 引用 numpy2.0 已移除的 np.NINF → compute_metrics 静默崩 → _metrics.json 不生成 → 结果页回退 vnpy 原始字段(单位混乱: total_return当百分数、max_drawdown当元 → 前端×100显 3305%/-50M%)。
- metrics.py: 导入 empyrical 前补回 np 别名(NINF/Inf/PINF/NaN/NAN/infty)
- routes.py: benchmark-curve/risk-series 缺 metrics 文件时返空200(不再404拖垮整页); get_result 从 statistics 抽 relative_metrics
- cta_engine.py: bench_df 日期 strip tz 防 pct_change 崩; metrics 块加 traceback 日志
- Result.vue: onMounted 用 Promise.allSettled 隔离7端点, 单接口失败不拖垮整页
- result_store.py: _safe_read_json 容错迁移后残留 NAS 绝对路径, stale path 不崩 list_results
- datareader.py: read_index_daily 改从 vnpy DB 读 + 前缀解析交易所(sh→SSE, 避免 000300 被 guess_exchange 误判 SZSE)
2026-07-17 08:24:01 +08:00

104 lines
4.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""回测相对/绝对指标计算(empyrical,聚宽同源口径)。纯函数。"""
from dataclasses import dataclass, field
from typing import Dict, Literal
import math
import numpy as np
# empyrical 0.5.5 引用了 NumPy 2.0 已移除的别名(np.NINF / np.NaN / np.Inf / np.PINF),
# 不补回会在 sortino_ratio/downside_risk 等函数里抛 AttributeError,导致整块相对指标计算
# 失败、_metrics.json 不生成、结果页回退到 vnpy 原始字段(单位混乱)。
for _alias, _val in (("NINF", -np.inf), ("Inf", np.inf), ("PINF", np.inf),
("NaN", np.nan), ("NAN", np.nan), ("infty", np.inf)):
if not hasattr(np, _alias):
setattr(np, _alias, _val)
import pandas as pd
import empyrical
BenchmarkCode = Literal["hs300", "zz500"]
BENCHMARK_SYMBOL: Dict[str, str] = {"hs300": "sh000300", "zz500": "sz000905"}
@dataclass
class MetricsResult:
scalars: Dict[str, float] = field(default_factory=dict)
series: Dict[str, pd.Series] = field(default_factory=dict)
def compute_metrics(
daily_df: pd.DataFrame,
benchmark_returns: pd.Series,
period: int = 252,
) -> MetricsResult:
"""对 vnpy daily_df + 基准日收益计算聚宽级指标。
H4: 从 daily_df["balance"] 自算 simple returnvnpy df["return"] 是 log return
empyrical 期望 simple return,直接用会失真)。
H5: period='daily' → empyrical 内部 252 交易日年化,口径统一。
benchmark 对齐:reindex 到策略交易日 + ffill,不丢策略日期(原 dropna 会丢停牌日)。
daily_df: vnpy calculate_result() 产出,须含 "balance" 列(账户余额),index 为日期。
benchmark_returns: 基准日收益率 Seriesindex 为日期(不必与 daily_df 对齐)。
period: 年化周期(整数,默认252交易日),empyrical 内部使用 'daily'
"""
# H4: simple return from balancepct_change 首项 NaN → 0
if "balance" not in daily_df.columns:
raise ValueError("daily_df 缺少 balance 列,无法计算 simple return")
strat = daily_df["balance"].astype(float).pct_change().fillna(0)
# benchmark ffill 对齐:reindex 到策略交易日,前向填充,不丢策略日期
b = benchmark_returns.reindex(daily_df.index).ffill().fillna(0)
aligned = pd.concat([strat.rename("s"), b.rename("b")], axis=1)
s, b = aligned["s"], aligned["b"]
scalars = {
"total_return": float(empyrical.cum_returns_final(s)),
"annual_return": float(empyrical.annual_return(s, period='daily')),
"alpha": float(empyrical.alpha(s, b, period='daily')),
"beta": float(empyrical.beta(s, b)),
"sharpe_ratio": float(empyrical.sharpe_ratio(s, period='daily')),
"sortino_ratio": float(empyrical.sortino_ratio(s, period='daily')),
"information_ratio": float(empyrical.excess_sharpe(s, b)),
"annual_volatility": float(empyrical.annual_volatility(s, period='daily')),
"max_drawdown": float(empyrical.max_drawdown(s)),
"benchmark_return": float(empyrical.cum_returns_final(b)),
"benchmark_volatility": float(empyrical.annual_volatility(b, period='daily')),
}
# Sanitize non-finite floats (NaN/Inf from degenerate inputs) → None for JSON safety
scalars = {k: (None if isinstance(v, float) and not math.isfinite(v) else v) for k, v in scalars.items()}
equity = empyrical.cum_returns(s)
bench_curve = empyrical.cum_returns(b)
# rolling alpha/beta (expanding window 用于画图,口径由 scalars 保证)
roll_beta = pd.Series(index=s.index, dtype=float)
roll_alpha = pd.Series(index=s.index, dtype=float)
for i in range(len(s)):
sub = aligned.iloc[: i + 1]
if len(sub) >= 2 and sub["b"].var() > 0:
beta = sub["s"].cov(sub["b"]) / sub["b"].var()
alpha = sub["s"].mean() - beta * sub["b"].mean()
roll_beta.iloc[i] = beta
roll_alpha.iloc[i] = alpha * period
# drawdown: 从峰值回落 (值 <= 0)
cummax = equity.cummax()
drawdown = (equity - cummax) / cummax
# Rolling annualized volatility (quarterly window) for the volatility chart
_vol_window = min(63, len(s))
if _vol_window >= 2:
vol_strategy = s.rolling(_vol_window, min_periods=2).std() * np.sqrt(period)
vol_benchmark = b.rolling(_vol_window, min_periods=2).std() * np.sqrt(period)
else:
vol_strategy = pd.Series([np.nan] * len(s), index=s.index)
vol_benchmark = pd.Series([np.nan] * len(s), index=s.index)
series = {
"equity_curve": equity,
"benchmark_curve": bench_curve,
"alpha": roll_alpha,
"beta": roll_beta,
"drawdown": drawdown,
"volatility_strategy": vol_strategy,
"volatility_benchmark": vol_benchmark,
}
return MetricsResult(scalars=scalars, series=series)