Files
tick-stock-panel/backend/app/backtest/stats_v2.py
T
shy3130 e0cd625ef4 feat(platform): 因子平台与因子↔策略双向联动 v0.2.3
- 因子平台: /factors 一级页(检验/因子库/编辑器/组合/挖掘), DSL 公式因子(25 算子点选、双语字段、我的因子模板、脏公式守卫), 版本与生命周期, 自动挖掘 L1 统计筛选
- 因子↔策略四条桥: 触发器 Zap 快建因子条件信号、因子一键生成排名策略、自定义信号 AI 提示词接入因子分组、策略回测因子归因(胜/败单入场信号日因子均值, 独立 tab, 双语因子名)
- 回测: 统计卡新增盈亏比(≥1 红/<1 绿), 蒙卡回撤合并为中位/95% 双值卡(自适应字号), 高级设置基础过滤与策略编辑器参数对齐(5 组区间)
- 信号库独立页 /signals(原设置 tab 迁出), 持仓提醒入导航; 挖掘并入因子页第 5 tab, /mining 旧链接重定向
- 研究线配套: 因子目录 61→77(评分/矩阵双内核), stats_v2(Newey-West/BH-FDR/DSR), enriched 管道与异动/报价服务配套调整
- 文档: README 导航与特性表、features.md 因子平台章节、操作说明书 9.2、factor-platform-plan 执行状态与 §5、二开文档桥接说明; 交流与支持节改版
- 版本 0.2.2 → 0.2.3; 后端全量 1625 passed(1 例环境性跳过), 前端 build 通过
2026-09-05 15:41:15 +08:00

148 lines
6.0 KiB
Python

"""metrics_v2 统计函数 (P3) — Newey-West HAC t 值 / BH-FDR q 值 / DSR。
运行时零新增第三方依赖 (后端无 scipy/statsmodels), 全部 numpy 手写;
数值测试用固定黄金参考向量锁定 (tests/test_stats_v2.py)。
口径 (设计文档 factor-system-design.md §6):
- IC 序列因 h 日前瞻收益存在 h-1 阶移动平均自相关, 主口径 t 值取 NW HAC, 滞后 L=h。
- 多因子批量检验按 Benjamini-Hochberg 步进法控制 FDR。
- DSR (Deflated Sharpe Ratio, Bailey & Lopez de Prado 2014) 用于多重试验校正后的
夏普显著性; 期望最大夏普 EM = sqrt(V[SR]) * ((1-gamma)Φ^-1(1-1/N) + gammaΦ^-1(1-1/(Ne)))
其中 gamma 为欧拉-马歇罗尼常数。
"""
from __future__ import annotations
import math
import numpy as np
EULER_GAMMA = 0.5772156649015329
def _clean_values(values) -> np.ndarray:
array = np.asarray([value for value in values if value is not None and np.isfinite(value)], dtype=float)
return array
def newey_west_t(values, lag: int) -> tuple[float, float, float] | None:
"""Newey-West HAC 稳健 t 统计量 (Bartlett 核)。
返回 (t值, 均值, NW标准误); 样本不足 (n <= lag+2) 或方差为零返回 None。
"""
array = _clean_values(values)
n = array.size
if n <= lag + 2 or n < 3:
return None
mean = float(array.mean())
centered = array - mean
# 长方差 S = gamma0 + 2 Σ_l w_l gamma_l, w_l = 1 - l/(lag+1) (Bartlett)
gamma = [float(np.dot(centered[: n - lag_i], centered[lag_i:]) / n) for lag_i in range(lag + 1)]
long_variance = gamma[0]
for lag_i in range(1, lag + 1):
weight = 1.0 - lag_i / (lag + 1)
long_variance += 2.0 * weight * gamma[lag_i]
long_variance = max(long_variance, 0.0)
nw_se = math.sqrt(long_variance / n)
if nw_se == 0:
return None
return (mean - 0.0) / nw_se, mean, nw_se
def naive_t(values) -> float | None:
array = _clean_values(values)
n = array.size
if n < 3:
return None
std = float(array.std(ddof=1))
if std == 0:
return None
return float(array.mean()) / (std / math.sqrt(n))
def normal_two_sided_p(t_stat: float) -> float:
"""标准正态双侧 p 值: erfc(|t|/sqrt(2))。"""
return math.erfc(abs(t_stat) / math.sqrt(2.0))
def bh_fdr_qvalues(pvalues: list[float | None]) -> list[float | None]:
"""Benjamini-Hochberg 步进法 q 值 (与输入等长, None 透传)。
m 取可检验假设数 (None 不计入); q_i = min over j>=rank_i { p_j * m / rank_j },
从大到小单调回填保证递增约束。
"""
indexed = [
(index, p) for index, p in enumerate(pvalues)
if p is not None and np.isfinite(p)
]
qvalues: list[float | None] = [None] * len(pvalues)
if not indexed:
return qvalues
m = len(indexed)
indexed.sort(key=lambda pair: pair[1])
running_min = float("inf")
for reverse_rank in range(len(indexed) - 1, -1, -1):
index, p = indexed[reverse_rank]
rank = reverse_rank + 1
candidate = p * m / rank
running_min = min(running_min, candidate)
qvalues[index] = min(1.0, running_min)
return qvalues
def _normal_ppf(probability: float) -> float:
"""标准正态分位数 Acklam 逆逼近 (相对误差 < 1.15e-9), 零依赖替代 scipy.stats.norm.ppf。"""
if not (0.0 < probability < 1.0):
raise ValueError("probability 必须在 (0,1) 开区间")
a = (-3.969683028665376e+01, 2.209460984245205e+02, -2.759285104469687e+02,
1.383577518672690e+02, -3.066479806614716e+01, 2.506628277459239e+00)
b = (-5.447609879822406e+01, 1.615858368580409e+02, -1.556989798598866e+02,
6.680131188771972e+01, -1.328068155288572e+01)
c = (-7.784894002430293e-03, -3.223964580411365e-01, -2.400758277161838e+00,
-2.549732539343734e+00, 4.374664141464968e+00, 2.938163982698783e+00)
d = (7.784695709041462e-03, 3.224671290700398e-01, 2.445134137142996e+00,
3.754408661907416e+00)
p_low, p_high = 0.02425, 1 - 0.02425
if probability < p_low:
q_value = math.sqrt(-2 * math.log(probability))
return (((((c[0] * q_value + c[1]) * q_value + c[2]) * q_value + c[3]) * q_value + c[4]) * q_value + c[5]) / \
((((d[0] * q_value + d[1]) * q_value + d[2]) * q_value + d[3]) * q_value + 1)
if probability <= p_high:
q_value = probability - 0.5
r = q_value * q_value
return (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q_value / \
(((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1)
q_value = math.sqrt(-2 * math.log(1 - probability))
return -(((((c[0] * q_value + c[1]) * q_value + c[2]) * q_value + c[3]) * q_value + c[4]) * q_value + c[5]) / \
((((d[0] * q_value + d[1]) * q_value + d[2]) * q_value + d[3]) * q_value + 1)
def expected_max_sharpe(n_trials: int, variance_sharpes: float) -> float:
"""N 次独立试验的期望最大夏普 EM (方差>0 时); 单次试验不校正。"""
if n_trials <= 1 or variance_sharpes <= 0:
return 0.0
z1 = _normal_ppf(1.0 - 1.0 / n_trials)
z2 = _normal_ppf(1.0 - 1.0 / (n_trials * math.e))
return math.sqrt(variance_sharpes) * ((1.0 - EULER_GAMMA) * z1 + EULER_GAMMA * z2)
def deflated_sharpe_psr(
sharpe: float,
n_obs: int,
skewness: float | None = None,
kurtosis: float | None = None,
expected_max_sharpe: float = 0.0,
) -> float | None:
"""Deflated Sharpe (PSR 对 EM 校正) 概率; 参数不足或退化返回 None。
PSR = Φ( (SR - SR*) * sqrt(n-1) / sqrt(1 - gamma3 SR + (gamma4-1)/4 SR^2) )
"""
if n_obs < 5 or not np.isfinite(sharpe):
return None
skewness = 0.0 if skewness is None else skewness
kurtosis = 3.0 if kurtosis is None else kurtosis
denominator = 1.0 - skewness * sharpe + (kurtosis - 1.0) / 4.0 * sharpe * sharpe
if denominator <= 0:
return None
statistic = (sharpe - expected_max_sharpe) * math.sqrt(n_obs - 1) / math.sqrt(denominator)
return 0.5 * (1.0 + math.erf(statistic / math.sqrt(2.0)))