fix(backtest): 信号回测统计里的 inf/NaN 不再让接口返回 500

_json_safe 先命中 `isinstance(v, (int, float, str, bool))` 分支, 原生 float 的
inf/NaN 被原样放行; numpy 分支也只挡 NaN 不挡 inf。pf.stats() 经 pandas
Series.to_dict() 出来时 numpy 标量已装箱成原生 float, 所以清洗实际全部落空。

Starlette 的 JSONResponse 用 json.dumps(allow_nan=False) 渲染, 响应体里出现
一个 inf/NaN 就是整个 POST /api/backtest/run 500
(ValueError: Out of range float values are not JSON compliant)。全部交易都盈利时
Profit Factor = inf、零波动时 Sharpe = NaN 都会触发。

把非有限判定提到原生标量分支之前, 与 app/api/backtest._json_safe 同口径。
This commit is contained in:
kevin9327
2026-09-10 19:38:33 +09:00
parent bb94cd7b29
commit e5f7f625fb
2 changed files with 92 additions and 1 deletions
+8 -1
View File
@@ -5,6 +5,7 @@
from __future__ import annotations
import logging
import math
import uuid
from dataclasses import dataclass, field
from datetime import date, timedelta
@@ -424,10 +425,16 @@ def _config_to_dict(c: BacktestConfig) -> dict:
def _json_safe(v):
# 非有限浮点 (inf / NaN) 必须先于原生标量分支拦下: Starlette 的 JSONResponse 用
# json.dumps(allow_nan=False) 渲染, 漏一个就是整个响应 500。pf.stats() 经
# pandas Series.to_dict() 出来时 numpy 标量已被装箱成原生 float (全胜时
# Profit Factor = inf, 零波动时 Sharpe = NaN), 两条分支都要覆盖。
if isinstance(v, (float, np.floating)) and not math.isfinite(float(v)):
return None
if isinstance(v, (int, float, str, bool)) or v is None:
return v
if isinstance(v, (np.floating, np.integer)):
return float(v) if not np.isnan(float(v)) else None
return float(v)
if hasattr(v, "isoformat"):
return v.isoformat()
return str(v)
@@ -0,0 +1,84 @@
"""信号回测统计里的 inf/NaN 必须在出接口前清成 null。
Starlette 的 JSONResponse 用 json.dumps(..., allow_nan=False) 渲染, 响应体里
只要出现一个 inf/NaN, 整个 POST /api/backtest/run 直接 500
(ValueError: Out of range float values are not JSON compliant)。
vectorbt 的 pf.stats() 常态产出这类值: 全部交易都盈利时 Profit Factor = inf,
零波动/无交易时 Sharpe 等为 NaN; pandas 的 Series.to_dict() 会把 numpy 标量
装箱成原生 float, 所以 _json_safe 的 (int, float, str, bool) 分支会原样放行。
"""
from __future__ import annotations
import json
from datetime import date
import numpy as np
import pytest
from app.services.backtest import _config_to_dict, _json_safe
def _render_like_starlette(payload) -> str:
"""与 starlette.responses.JSONResponse.render 同参数。"""
return json.dumps(payload, ensure_ascii=False, allow_nan=False, separators=(",", ":"))
@pytest.mark.parametrize(
"value",
[
float("inf"), # Profit Factor: 没有亏损单
float("-inf"),
float("nan"), # Sharpe / Sortino: 零波动
np.float64("inf"),
np.float64("nan"),
],
)
def test_non_finite_values_become_null(value):
assert _json_safe(value) is None
def test_stats_dict_survives_starlette_json_render():
"""pf.stats() 形态的统计字典清洗后可被 allow_nan=False 渲染。"""
stats_dict = {
"Total Return [%]": 12.5,
"Profit Factor": float("inf"), # 全胜 → gross_loss = 0
"Sharpe Ratio": float("nan"),
"Max Drawdown [%]": np.float64(3.25),
"Start": date(2026, 1, 2),
}
cleaned = {k: _json_safe(v) for k, v in stats_dict.items()}
rendered = json.loads(_render_like_starlette(cleaned))
assert rendered["Profit Factor"] is None
assert rendered["Sharpe Ratio"] is None
assert rendered["Total Return [%]"] == 12.5
assert rendered["Max Drawdown [%]"] == 3.25
assert rendered["Start"] == "2026-01-02"
def test_finite_and_non_float_values_pass_through():
"""回归保护: 有限数值/字符串/bool/None/日期的既有行为不变。"""
assert _json_safe(1.5) == 1.5
assert _json_safe(np.float64(2.5)) == 2.5
assert _json_safe(3) == 3
assert _json_safe(np.int64(4)) == 4
assert _json_safe("abc") == "abc"
assert _json_safe(True) is True
assert _json_safe(None) is None
assert _json_safe(date(2026, 1, 2)) == "2026-01-02"
def test_config_payload_is_json_renderable():
"""/api/backtest/run 的 config 段本身无非法浮点 (对照组)。"""
from app.services.backtest import BacktestConfig
cfg = BacktestConfig(
symbols=["600000.SH"],
start=date(2026, 1, 2),
end=date(2026, 2, 2),
entries=["signal_ma_golden_5_20"],
exits=[],
)
_render_like_starlette(_config_to_dict(cfg))