Files
claude_dev 2ff8ecaf52
CI/CD / test (push) Successful in 10s
CI/CD / nas-deploy (push) Successful in 29s
CI/CD / nas-verify (push) Successful in 5s
perf(metrics): rolling alpha/beta 向量化(O(n²)→O(n),治分钟级卡死)
compute_metrics rolling alpha/beta 原 for 循环每点 iloc[:i+1].cov/var = O(n²)。日线 111 天没事,分钟级 daily_df(万行)卡死(cta_c287f4b5 卡 5min CPU88%)。改 pandas expanding 向量化(cov/var/mean O(n)),数值与原循环一致(beta/alpha diff<1e-15,NaN 位置匹配)。pytest 6 绿。触发:b2c41c7 降级让 compute_metrics 首次在 benchmark 有数据时真跑(此前 benchmark 空被 skip),暴露 rolling O(n²) 性能 bug。
2026-08-02 20:54:50 +08:00

102 lines
4.9 KiB
Python
Raw Permalink 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 保证)
# 向量化 expanding (O(n)) 替代原 for 循环 (O(n²))——分钟级 daily_df(万行)原循环卡死
# (cta_c287f4b5 卡 5min CPU88% 根因)。expanding.cov/.var/.mean 数值与原循环一致
# (ddof=1)var_b<=0(含单点 NaN)处 beta=NaN,与原 len>=2 且 var>0 守卫等价。
cov_sb = aligned["s"].expanding().cov(aligned["b"])
var_b = aligned["b"].expanding().var()
roll_beta = cov_sb / var_b.where(var_b > 0)
roll_alpha = (aligned["s"].expanding().mean() - roll_beta * aligned["b"].expanding().mean()) * 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)