mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
fix(screener): run_all 的 as_of 与 /custom、/preset 同口径校验
/api/screener/run_all 收的是无类型 dict,as_of 只在 isinstance(str) 时才
解析,其余类型原样透传,造成两个问题:
1. 非法字符串("not-a-date"、"2026-13-01")抛未捕获 ValueError → 500;
2. JSON 数字({"as_of": 2026})不进解析分支,直接被当作日期用下去。
实测:{"as_of": 2026} 返回 200,响应 as_of="2026",
strategy_cache.json 也写入 as_of="2026"({"as_of": 20260904} → "20260904")。
其它入口写进同一份缓存的是 "2026-09-04",cached-summary /
cached-result 都按 as_of 字符串比对,格式不一致的缓存条目永远失配。
同一份 as_of 在 /api/screener/custom 和 /api/screener/preset 上走
Pydantic 模型(CustomRequest / PresetRequest 的 `as_of: Optional[date]`),
非法字符串和数字都会被 422 拦下;只有 run_all 这一处漏了。本次把 run_all
补齐到同一口径:非字符串直接 400,非法字符串 400「日期格式错误」。
补 tests/test_screener_run_all_as_of.py,同时锁住合法 as_of 仍然照常执行、
缓存仍写 ISO 日期,以及不传 as_of 时回退到 latest_date()。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9a4bdcd07d
commit
fd5d883c8f
@@ -613,7 +613,15 @@ def run_all(request: Request, body: Optional[dict] = None):
|
||||
# 解析日期
|
||||
raw_date = body.get("as_of")
|
||||
if raw_date:
|
||||
as_of = date_type.fromisoformat(str(raw_date)) if isinstance(raw_date, str) else raw_date
|
||||
# 与 /custom、/preset 的 `as_of: date` 同口径: 只收 ISO 日期字符串。
|
||||
# 非字符串原样透传会让 str(as_of) 把 "20260904" 之类写进 strategy_cache.json,
|
||||
# 与其它入口写的 "2026-09-04" 不是同一格式, 后续按 as_of 比对缓存永远失配。
|
||||
if not isinstance(raw_date, str):
|
||||
raise HTTPException(status_code=400, detail="as_of 必须是 YYYY-MM-DD 日期字符串")
|
||||
try:
|
||||
as_of = date_type.fromisoformat(raw_date)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=f"日期格式错误: {e}") from e
|
||||
else:
|
||||
as_of = svc.latest_date()
|
||||
if not as_of:
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""run_all 的 as_of 入参口径 — 与 /custom、/preset 的 `as_of: date` 契约对齐。
|
||||
|
||||
/api/screener/custom 与 /api/screener/preset 走 Pydantic 模型 (`as_of: Optional[date]`),
|
||||
非法日期在入口被拦下; run_all 收的是无类型 dict, 同样的入参会漏进业务层。
|
||||
本文件锁住三条: 非法字符串被拦、非日期类型被拦、合法日期照常放行。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.api import screener as screener_api
|
||||
from app.services import strategy_cache
|
||||
|
||||
AS_OF = "2026-09-04"
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeResult:
|
||||
total: int = 1
|
||||
rows: list = field(default_factory=lambda: [{"symbol": "000001.SZ", "close": 1.0}])
|
||||
as_of: str = AS_OF
|
||||
|
||||
|
||||
class _FakeEngine:
|
||||
def has(self, sid: str) -> bool:
|
||||
return sid == "s1"
|
||||
|
||||
def get(self, sid: str):
|
||||
return SimpleNamespace(meta={})
|
||||
|
||||
def run_all(self, context, params_map=None, overrides_map=None, *, strategy_ids=None, parallel=True):
|
||||
return {sid: _FakeResult() for sid in (strategy_ids or [])}
|
||||
|
||||
|
||||
class _FakeService:
|
||||
def __init__(self, repo, asset_type="stock"):
|
||||
pass
|
||||
|
||||
def latest_date(self):
|
||||
return date.fromisoformat(AS_OF)
|
||||
|
||||
def build_strategy_context(self, *args, **kwargs):
|
||||
return SimpleNamespace()
|
||||
|
||||
|
||||
def _request(tmp_path):
|
||||
repo = SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path))
|
||||
state = SimpleNamespace(repo=repo, strategy_engine=_FakeEngine(), monitor_engine=None)
|
||||
return SimpleNamespace(app=SimpleNamespace(state=state))
|
||||
|
||||
|
||||
def _body(as_of):
|
||||
return {
|
||||
"as_of": as_of,
|
||||
"strategy_ids": ["s1"],
|
||||
"asset_type": "stock",
|
||||
"timeframe": "1d",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fake_service(monkeypatch):
|
||||
monkeypatch.setattr(screener_api, "ScreenerService", _FakeService)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["not-a-date", "2026-13-01", "2026/09/04"])
|
||||
def test_malformed_as_of_string_is_rejected(tmp_path, bad):
|
||||
with pytest.raises(HTTPException) as excinfo:
|
||||
screener_api.run_all(_request(tmp_path), _body(bad))
|
||||
assert excinfo.value.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [20260904, 2026, 1.5, True, ["2026-09-04"]])
|
||||
def test_non_string_as_of_is_rejected_and_never_reaches_cache(tmp_path, bad):
|
||||
"""JSON 数字等非字符串 as_of 不得被当成日期用作缓存键。
|
||||
|
||||
/preset 的 `as_of: date` 对 int 报 422 (date_from_datetime_inexact),
|
||||
run_all 必须同样拦下, 否则 str(as_of) 会把 "20260904" 之类写进 strategy_cache.json,
|
||||
与其它入口写的 "2026-09-04" 不是同一口径, 缓存按 as_of 比对时永远失配。
|
||||
"""
|
||||
with pytest.raises(HTTPException) as excinfo:
|
||||
screener_api.run_all(_request(tmp_path), _body(bad))
|
||||
assert excinfo.value.status_code == 400
|
||||
assert strategy_cache.read_cache(tmp_path) in (None, {}) or not (
|
||||
strategy_cache.read_cache(tmp_path) or {}
|
||||
).get("results")
|
||||
|
||||
|
||||
def test_valid_as_of_still_runs_and_writes_iso_cache(tmp_path):
|
||||
resp = screener_api.run_all(_request(tmp_path), _body(AS_OF))
|
||||
assert resp["as_of"] == AS_OF
|
||||
assert resp["results"]["s1"]["total"] == 1
|
||||
assert (strategy_cache.read_cache(tmp_path) or {}).get("as_of") == AS_OF
|
||||
|
||||
|
||||
def test_missing_as_of_falls_back_to_latest_date(tmp_path):
|
||||
resp = screener_api.run_all(_request(tmp_path), _body(None))
|
||||
assert resp["as_of"] == AS_OF
|
||||
Reference in New Issue
Block a user