mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 14:24:15 +08:00
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:
@@ -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))
|
||||
Reference in New Issue
Block a user