Files
tick-stock-panel/backend/tests/test_screener_builtin_params.py
T
shy3130 f03bc38a16 feat(screener): run_all 渐进式返回, 快策略先出、慢策略后台补算
策略页全量 run_all 需 ~2 分钟, 期间卡片全空。现在按历史耗时升序执行,
首返时限 (strategy_run_all_first_return_s, 默认 15s) 内算完的策略随
响应返回, 慢策略转后台继续算并逐个写入策略缓存, 前端轮询
cached-summary 逐个点亮卡片数字 (未出的显示脉冲占位)。

后端:
- services/strategy_run_queue: 单飞 daemon 工作线程 + handle 状态。
  相同 key (资产/周期/日期/策略集) 且未完成的请求搭车现有执行不重算;
  已完成的重跑即新执行。后端全局同时只跑一个 run_all, 补上前端
  防重入之外的第二道 Numba 并发防线
- run_all 渐进分支 (仅日线 + summary_only): 逐策略增量写缓存 (同日
  按 sid 合并), 收尾整体重写保持旧口径; 结果带 computed_at 时间戳;
  分钟周期与明细请求保持整段阻塞不变
- 历史耗时落盘 user_data/strategy_run_timings.json, 次日起快策略自动
  排前; cached-summary 透传 computed_at

前端:
- 请求通用 30s 超时 + 慢接口豁免清单 (run_all/run/backtest/factor 等
  300s), 避免一个挂起请求占满 HTTP/1.1 连接拖死全站
- Screener: 收到 pending 后 summaryQuery 每 2s 轮询, 以
  computed_at >= started_at 判新 (防同日旧缓存冒充), 8 分钟兜底;
  StrategyCard 三种尺寸新增 computing 脉冲占位

验证: 新增 13 测试 (排序/落盘/搭车/串行化/端点行为/旧路径兼容),
全量套件 1762 passed; pnpm build 通过。
2026-09-07 15:25:54 +08:00

166 lines
5.7 KiB
Python

from __future__ import annotations
import types
from datetime import date
from typing import ClassVar
from app.api import screener as screener_api
from app.services.screener import ScreenerResult
class _CapturingScreenerService:
calls: ClassVar[list[dict]] = []
def __init__(self, repo, asset_type="stock"):
self.repo = repo
self.asset_type = asset_type
def latest_date(self):
return date(2026, 7, 15)
def build_strategy_context(
self,
engine,
as_of,
strategy_ids,
*,
timeframe="1d",
params_map=None,
overrides_map=None,
):
self.calls.append({
"kind": "context",
"strategy_ids": strategy_ids,
"timeframe": timeframe,
"params_map": params_map,
"overrides_map": overrides_map,
})
return types.SimpleNamespace(as_of=as_of)
class _CapturingStrategyEngine:
calls: ClassVar[list[dict]] = []
def has(self, strategy_id):
return strategy_id == "builtin_strategy"
def get(self, strategy_id):
if not self.has(strategy_id):
raise ValueError(f"unknown strategy: {strategy_id}")
return types.SimpleNamespace(meta={"id": strategy_id})
def run(self, strategy_id, context, *, pool=None, params=None, overrides=None):
self.calls.append({
"kind": "run",
"strategy_id": strategy_id,
"pool": pool,
"params": params,
"overrides": overrides,
})
return ScreenerResult(as_of=context.as_of, strategy=strategy_id)
def run_all(self, context, *, params_map=None, overrides_map=None, strategy_ids=None, parallel=True):
self.calls.append({
"kind": "run_all",
"params_map": params_map,
"overrides_map": overrides_map,
"strategy_ids": strategy_ids,
})
return {
strategy_id: ScreenerResult(as_of=context.as_of, strategy=strategy_id)
for strategy_id in strategy_ids or []
}
def _api_request(tmp_path, engine):
repo = types.SimpleNamespace(store=types.SimpleNamespace(data_dir=tmp_path))
state = types.SimpleNamespace(repo=repo, strategy_engine=engine)
return types.SimpleNamespace(app=types.SimpleNamespace(state=state))
def _install_api_fakes(monkeypatch):
_CapturingScreenerService.calls = []
_CapturingStrategyEngine.calls = []
monkeypatch.setattr(screener_api, "ScreenerService", _CapturingScreenerService)
monkeypatch.setattr(screener_api, "_load_ext_value_maps", lambda *_args: {})
monkeypatch.setattr(screener_api, "_update_cache_strategy", lambda *_args: None)
monkeypatch.setattr(screener_api.strategy_cache, "write_cache", lambda *_args: None)
def test_single_run_passes_saved_params_to_strategy_engine(monkeypatch, tmp_path):
engine = _CapturingStrategyEngine()
request = _api_request(tmp_path, engine)
_install_api_fakes(monkeypatch)
saved = {"params": {"threshold": 3.0, "enabled": False}}
monkeypatch.setattr(screener_api.strategy_config, "load_override", lambda *_args: saved)
screener_api.run_preset(
screener_api.PresetRequest(
strategy_id="builtin_strategy",
as_of=date(2026, 7, 15),
),
request,
)
context_call = _CapturingScreenerService.calls[0]
run_call = _CapturingStrategyEngine.calls[0]
assert context_call["params_map"] == {"builtin_strategy": saved["params"]}
assert context_call["overrides_map"] == {"builtin_strategy": saved}
assert run_call["params"] == saved["params"]
assert run_call["overrides"] == saved
def test_batch_run_passes_saved_params_to_strategy_engine(monkeypatch, tmp_path):
engine = _CapturingStrategyEngine()
request = _api_request(tmp_path, engine)
_install_api_fakes(monkeypatch)
saved = {"params": {"threshold": 3.0, "enabled": False}}
monkeypatch.setattr(
screener_api.strategy_config,
"list_overrides",
lambda *_args: {"builtin_strategy": saved},
)
screener_api.run_all(
request,
body={"as_of": "2026-07-15", "strategy_ids": ["builtin_strategy"]},
)
context_call = _CapturingScreenerService.calls[0]
run_all_call = _CapturingStrategyEngine.calls[0]
expected_params = {"builtin_strategy": saved["params"]}
expected_overrides = {"builtin_strategy": saved}
assert context_call["params_map"] == expected_params
assert context_call["overrides_map"] == expected_overrides
assert run_all_call["params_map"] == expected_params
assert run_all_call["overrides_map"] == expected_overrides
def test_batch_summary_response_still_writes_full_cache(monkeypatch, tmp_path):
engine = _CapturingStrategyEngine()
request = _api_request(tmp_path, engine)
_install_api_fakes(monkeypatch)
written = []
monkeypatch.setattr(screener_api.strategy_config, "list_overrides", lambda *_args: {})
monkeypatch.setattr(screener_api.strategy_cache, "write_cache", lambda *args: written.append(args))
payload = screener_api.run_all(
request,
body={
"as_of": "2026-07-15",
"strategy_ids": ["builtin_strategy"],
"summary_only": True,
},
)
assert payload["as_of"] == "2026-07-15"
assert payload["results"]["builtin_strategy"]["total"] == 0
assert payload["results"]["builtin_strategy"]["as_of"] == "2026-07-15"
# 渐进式路径: 全部算完 → complete 且无 pending
assert payload["pending"] == []
assert payload["complete"] is True
assert payload["error"] is None
# 渐进式增量写 + 收尾全量写都带 rows, 缓存口径不变
assert written[0][2]["builtin_strategy"]["rows"] == []
assert written[-1][2]["builtin_strategy"]["rows"] == []