mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
fix(strategy): 渐进式 run_all 逐策略隔离失败
单个策略执行崩溃 (如自定义策略 filter_history 的数据类型错误) 会杀掉 整批剩余策略: handle 整体 fail, 后台线程结束, 页面剩余卡片永远不亮。 线上实证: 70 策略池跑到第 35 个 (custom_1782999589 pl.DataFrame 构造 schema 混杂) 崩溃, 后 36 个全部没算。 改为单策略 try/except: 失败记入 handle.errors 并移出待算队列, 其余 策略照常算完落缓存; 响应新增 errors 字段 (前端可忽略)。job 级失败 (context 构建崩溃) 仍走整体 500 语义不变。
This commit is contained in:
@@ -554,6 +554,9 @@ def _run_all_progressive(
|
||||
elapsed_map: dict[str, float] = {}
|
||||
for sid in ordered_ids:
|
||||
t0 = time.perf_counter()
|
||||
# 逐策略隔离: 单个策略崩溃 (如自定义代码的数据类型错误) 只记
|
||||
# 错误跳过, 不让整批剩余策略陪葬 — 其余策略照常算完落缓存。
|
||||
try:
|
||||
single = engine.run_all(
|
||||
context,
|
||||
params_map=params_map,
|
||||
@@ -562,6 +565,10 @@ def _run_all_progressive(
|
||||
parallel=False,
|
||||
)
|
||||
result = single[sid]
|
||||
except Exception as e:
|
||||
logger.warning("run_all: 策略 %s 执行失败, 跳过: %s", sid, e, exc_info=True)
|
||||
handle.fail_one(sid, str(e))
|
||||
continue
|
||||
payload = {
|
||||
"total": result.total,
|
||||
"as_of": str(as_of),
|
||||
@@ -602,6 +609,7 @@ def _run_all_progressive(
|
||||
"as_of": str(as_of),
|
||||
"results": done_results,
|
||||
"pending": snap["pending"],
|
||||
"errors": snap["errors"],
|
||||
"complete": snap["done"] and not snap["error"],
|
||||
"error": snap["error"],
|
||||
"started_at": snap["started_at_ms"],
|
||||
|
||||
@@ -83,6 +83,7 @@ class StrategyRunHandle:
|
||||
self._lock = threading.Lock()
|
||||
self._results: dict[str, dict] = {}
|
||||
self._remaining: list[str] = list(ordered_ids)
|
||||
self._errors: dict[str, str] = {}
|
||||
self._error: str | None = None
|
||||
self._done = False
|
||||
|
||||
@@ -92,6 +93,13 @@ class StrategyRunHandle:
|
||||
if sid in self._remaining:
|
||||
self._remaining.remove(sid)
|
||||
|
||||
def fail_one(self, sid: str, message: str) -> None:
|
||||
"""单个策略失败: 记错误并移出待算队列, 不影响其余策略继续。"""
|
||||
with self._lock:
|
||||
self._errors[sid] = message
|
||||
if sid in self._remaining:
|
||||
self._remaining.remove(sid)
|
||||
|
||||
def fail(self, message: str) -> None:
|
||||
with self._lock:
|
||||
self._error = message
|
||||
@@ -102,11 +110,12 @@ class StrategyRunHandle:
|
||||
self._done = True
|
||||
|
||||
def snapshot(self) -> dict:
|
||||
"""线程安全快照: 结果拷贝 + 剩余/错误/完成状态。"""
|
||||
"""线程安全快照: 结果拷贝 + 剩余/逐策略错误/整体错误/完成状态。"""
|
||||
with self._lock:
|
||||
return {
|
||||
"results": dict(self._results),
|
||||
"pending": list(self._remaining),
|
||||
"errors": dict(self._errors),
|
||||
"error": self._error,
|
||||
"done": self._done,
|
||||
"started_at_ms": self.started_at_ms,
|
||||
|
||||
@@ -177,17 +177,23 @@ def test_run_all_same_key_piggybacks_running_execution(
|
||||
assert engine.executed.count("fast_a") == 1
|
||||
|
||||
|
||||
def test_run_all_background_error_without_results_is_500(
|
||||
def test_run_all_job_level_error_without_results_is_500(
|
||||
monkeypatch, tmp_path, fast_first_return
|
||||
):
|
||||
class _BoomEngine(_FakeEngine):
|
||||
def run_all(self, context, params_map=None, overrides_map=None, *, strategy_ids=None, parallel=True):
|
||||
"""job 级失败 (如 context 构建崩溃) 且无任何结果 → 500, 语义不变。
|
||||
|
||||
策略级失败 (engine.run_all 对单个 sid 抛错) 已改为逐策略隔离, 见
|
||||
test_run_all_isolates_single_strategy_failure。
|
||||
"""
|
||||
|
||||
class _BoomCtxService(_FakeService):
|
||||
def build_strategy_context(self, *args, **kwargs):
|
||||
raise ValueError("缺少列: volume")
|
||||
|
||||
monkeypatch.setattr(screener_api, "ScreenerService", _FakeService)
|
||||
monkeypatch.setattr(screener_api, "ScreenerService", _BoomCtxService)
|
||||
with pytest.raises(HTTPException) as excinfo:
|
||||
screener_api.run_all(
|
||||
_request(tmp_path, _BoomEngine({"bad_a": 0.01})),
|
||||
_request(tmp_path, _FakeEngine({"bad_a": 0.01})),
|
||||
{
|
||||
"as_of": AS_OF,
|
||||
"strategy_ids": ["bad_a"],
|
||||
@@ -291,3 +297,38 @@ def test_run_all_progressive_builds_matrix_once_and_shares_it(
|
||||
assert engine.matrix_builds == 1
|
||||
assert engine.seen_markets and all(m == {"fields": 3} for m in engine.seen_markets)
|
||||
assert len(engine.seen_markets) == 3
|
||||
|
||||
|
||||
def test_run_all_isolates_single_strategy_failure(
|
||||
monkeypatch, tmp_path, fast_first_return
|
||||
):
|
||||
"""单个策略执行崩溃只跳过它自己: 其余策略照常算完落缓存, 整批不失败。"""
|
||||
|
||||
class _FlakyEngine(_FakeEngine):
|
||||
def run_all(self, context, params_map=None, overrides_map=None, *, strategy_ids=None, parallel=True):
|
||||
for sid in strategy_ids or []:
|
||||
if sid == "broken":
|
||||
raise ValueError("boom: schema mismatch")
|
||||
return super().run_all(
|
||||
context, params_map=params_map, overrides_map=overrides_map,
|
||||
strategy_ids=strategy_ids, parallel=parallel,
|
||||
)
|
||||
|
||||
engine = _FlakyEngine({"ok_a": 0.01, "broken": 0.01, "ok_b": 0.5})
|
||||
monkeypatch.setattr(screener_api, "ScreenerService", _FakeService)
|
||||
|
||||
resp = screener_api.run_all(
|
||||
_request(tmp_path, engine),
|
||||
{
|
||||
"as_of": AS_OF,
|
||||
"strategy_ids": ["ok_a", "broken", "ok_b"],
|
||||
"asset_type": "stock",
|
||||
"timeframe": "1d",
|
||||
"summary_only": True,
|
||||
},
|
||||
)
|
||||
# 后台继续: 好策略都落缓存; broken 不在结果也不在 pending, 而是进 errors
|
||||
results = _wait_cache_results(tmp_path, ["ok_a", "ok_b"])
|
||||
assert set(results) == {"ok_a", "ok_b"}
|
||||
assert "broken" not in results
|
||||
assert "boom: schema mismatch" in (resp["errors"] or {}).get("broken", "")
|
||||
|
||||
Reference in New Issue
Block a user