Files
tick-stock-panel/backend/tests/test_backtest_stream_force.py
T
shy3130 2d629197c1 fix(backtest,services,frontend): 回测成交口径/任务注册表并发/查询键一批正确性修复
回测成交口径:
- close_t 按收盘价成交的建仓日: 当日 high 发生在建仓之前, 不得进入移动止损
  峰值 (峰值虚高会把风控线上移、退出提前) — 矩阵独立/矩阵组合/legacy 三条
  路径同口径; 新增回归测试锁定 open_t+1 对照组不被误伤
- 矩阵路径补收盘封死跌停拦截 (含一字), 经 pending_exit 递延到下一可卖日,
  与独立/legacy 路径同口径 (此前尾盘封板股按跌停价"卖出"低估亏损)
- #388 当日指标纪律同口径: 盘中成交不可知当日收盘, 代数剔除当日 enriched
- 分钟回放: 入场价用前复权口径 (与日线出场同尺度); 除权日涨停参考价按
  ex_factor 还原 (直接用 raw_prev 会把涨停价算高, 除权日封板拒买漏检)
- 指标管线: 涨停参考价除权口径修正 + 夏普用样本标准差 (ddof=1, 与 engine 一致)
- 回测任务: _acquire_or_evict_job 收口同 key 复用/强制重建 (参数不同先取消
  旧任务, 避免旧任务占着重任务槽跑完)

服务与并发:
- task_registry 读-改-写加锁: 两个前端 fire-and-forget 并发上报时后写者
  覆盖先写者 (丢终态翻转或整条记录); 文件写入本身原子, 锁只护 RMW 段
- quote_service 生命周期锁串行化 start/stop/enable 的 check-then-act (双标签页
  并发开关会起两条轮询线程 → 双倍拉取配额 + 重复 SSE 广播)
- 系统通知投递移入专用单线程池: notify_adapter 底层 subprocess 逐条最长 5s,
  在 _poll_loop 同步执行会让告警风暴阻塞一轮监控评估, 拖慢行情拉取与推送
- sector_monitor / minute_refresh / wecom_bot: 同类单实例与停止时序加固
- ai_provider: 401 识别加词边界 (避免模型正文含 401 数字串误判鉴权失败并
  附出不相关指引); 自定义可执行路径校验可执行位
- api/data: 清空数据期间暂停全量分钟增量线程 (否则"清完又被写回部分今日
  数据"); 状态接口偏好/能力探测异常降级而不是 500
- api/kline: 失效内存 enriched 快照 (非空缓存不校验 generation, 只刷 DuckDB
  视图会让重建结果对自选/筛选/监控不可见直到进程重启)
- api/position_policies: 非法 ID 纵深防御按不存在处理

前端:
- assistant store: 生成终态 (中断提示/流式收敛) 钉在发起会话, 切换或删除会话
  不再把"已中断"写进无关新会话
- backtestTask: 键上挂着旧任务且新参数不同 (不同 job_key) 时先取消旧任务,
  避免其占着重任务槽跑到自然结束; 同参重跑跳过取消 (后端按同 key 复用 /
  force 重建已完成任务)
- 查询键集中与正确性: QK.instrumentNames (排序去重, 同一集合不同来源顺序
  复用缓存)、QK.kline 滚动窗口空串边界 (useQuoteStream 焦点股增量合并可
  修补)、regimeHistory 用实际请求三元组 + 裸前缀失效全部变体
- Data 页 / Regime / SectorRotationCard / RuleEditor / monitor / lots /
  stock-analysis 接入集中键, useQuoteStream 对齐

验证: 后端全量 3546 passed (含 close_t 峰值/分钟回测/跌停递延/并发上报等
新增用例); 前端 49 文件 348 用例 + pnpm build 通过; ruff 改动文件零新增
(141 vs 基线 142)。

Signed-off-by: shy3130 <shy3130@qq.com>
2026-10-30 00:20:52 +08:00

167 lines
7.0 KiB
Python

"""strategy/stream 的 force 参数 — 绕过同参数任务结果缓存。
SSE 回测按全参数 md5 作 job_key, 已完成任务保留 5 分钟内同 key 连接直接复用
结果 (缓存)。force=1 掺入 job_key 使其不命中缓存必然重算; cancel 从 qs 重算
同一 key, 必须同步解析 force, 否则强制重跑的任务取消不到。
"""
from __future__ import annotations
import threading
import time
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api import backtest as bt
from app.api.backtest import router
def _client() -> TestClient:
app = FastAPI()
app.include_router(router)
return TestClient(app)
def test_force_changes_job_key_and_is_stable():
args = ("ma_cross", None, "2024-01-01", "2024-12-31", "open_t+1", None, None,
0.0002, 5.0, 10, 1.0, 1_000_000.0, "equal", None, None)
cached = bt._make_job_key(*args)
forced = bt._make_job_key(*args, force=True)
assert cached != forced
# 各自幂等: 同参数重复计算 key 稳定 (重连/取消依赖)
assert bt._make_job_key(*args) == cached
assert bt._make_job_key(*args, force=True) == forced
# force=False 是默认值: 不显式传参与显式 False 同 key
assert bt._make_job_key(*args, force=False) == cached
def test_cancel_resolves_force_key_from_qs(monkeypatch):
"""带 force=1 的任务, cancel 用带 force=1 的 qs 能取消到; 不带 force 的 qs 取消不到。
手工往任务表塞一个未完成 job (key 按 force 计算), 走真实 cancel 端点的
qs→key 解析路径, 不启动任何回测线程。
"""
args = ("ma_cross", None, None, None, "open_t+1", None, None,
0.0002, 5.0, 10, 1.0, 1_000_000.0, "equal", None, None)
forced_key = bt._make_job_key(*args, force=True)
job = bt._BacktestJob(forced_key)
job.cancel_event = threading.Event()
monkeypatch.setitem(bt._running_jobs, forced_key, job)
qs_force = "strategy_id=ma_cross&force=1"
resp = _client().post("/api/backtest/strategy/cancel", json={"qs": qs_force})
assert resp.status_code == 200
assert resp.json() == {"ok": True}
assert job.cancel_event.is_set()
# 不带 force 的 qs 算出的是缓存任务的 key, 取消不到 force 任务
job2 = bt._BacktestJob(forced_key)
job2.cancel_event = threading.Event()
monkeypatch.setitem(bt._running_jobs, forced_key, job2)
qs_plain = "strategy_id=ma_cross"
resp2 = _client().post("/api/backtest/strategy/cancel", json={"qs": qs_plain})
assert resp2.status_code == 200
assert resp2.json()["ok"] is False
assert not job2.cancel_event.is_set()
def test_cancel_resolves_force_true_alias_from_qs(monkeypatch):
"""force=true (FastAPI bool 别名) 与 force=1 必须算出同一 key, 否则取消不到。
曾为预存 bug: stream 侧 FastAPI 解析 bool 接受 1/true, cancel 侧只认 "1"。
"""
args = ("ma_cross", None, None, None, "open_t+1", None, None,
0.0002, 5.0, 10, 1.0, 1_000_000.0, "equal", None, None)
forced_key = bt._make_job_key(*args, force=True)
job = bt._BacktestJob(forced_key)
job.cancel_event = threading.Event()
monkeypatch.setitem(bt._running_jobs, forced_key, job)
resp = _client().post("/api/backtest/strategy/cancel", json={"qs": "strategy_id=ma_cross&force=true"})
assert resp.status_code == 200
assert resp.json()["ok"] is True
assert job.cancel_event.is_set()
def test_force_evicts_done_job_within_ttl(monkeypatch):
"""force 连接遇同 key 已完成任务: 丢弃重建 (真正重算), 不回放旧结果。"""
args = ("ma_cross", None, None, None, "open_t+1", None, None,
0.0002, 5.0, 10, 1.0, 1_000_000.0, "equal", None, None)
forced_key = bt._make_job_key(*args, force=True)
old_job = bt._BacktestJob(forced_key)
old_job.done = True
old_job.finish_ts = time.time() # TTL 内 (刚完成)
old_job.result = {"stats": {"stale": True}}
monkeypatch.setitem(bt._running_jobs, forced_key, old_job)
new_job, is_new = bt._acquire_or_evict_job(forced_key, force=True)
assert is_new is True
assert new_job is not old_job
assert not new_job.done
assert bt._running_jobs[forced_key] is new_job
# 运行中的任务仍允许订阅 (重连语义不变)
running = bt._BacktestJob(forced_key)
monkeypatch.setitem(bt._running_jobs, forced_key, running)
job, is_new = bt._acquire_or_evict_job(forced_key, force=True)
assert is_new is False
assert job is running
# 非 force 连接保持缓存复用: 已完成任务直接回放
done = bt._BacktestJob(forced_key)
done.done = True
monkeypatch.setitem(bt._running_jobs, forced_key, done)
job, is_new = bt._acquire_or_evict_job(forced_key, force=False)
assert is_new is False
assert job is done
def test_cancel_resolves_minute_fill_and_regime_filter(monkeypatch):
"""minute_fill / regime_filter 掺在 job_key 里, cancel 必须同步解析, 否则取消不到。
曾为预存 bug: stream 侧 key 含这两个参数, cancel 重算漏掉 —— 开了分钟K
精确回测或市场环境过滤的任务停止按钮失效。
"""
from urllib.parse import quote
args = ("ma_cross", None, None, None, "open_t+1", None, None,
0.0002, 5.0, 10, 1.0, 1_000_000.0, "equal", None, None)
regime_json = '{"states":["bull","shake"]}'
key = bt._make_job_key(*args, minute_fill=True, regime_filter=regime_json)
job = bt._BacktestJob(key)
job.cancel_event = threading.Event()
monkeypatch.setitem(bt._running_jobs, key, job)
qs = f"strategy_id=ma_cross&minute_fill=true&regime_filter={quote(regime_json)}"
resp = _client().post("/api/backtest/strategy/cancel", json={"qs": qs})
assert resp.status_code == 200
assert resp.json() == {"ok": True}
assert job.cancel_event.is_set()
# 缺这两个参数的 qs 算出不同 key, 取消不到
job2 = bt._BacktestJob(key)
job2.cancel_event = threading.Event()
monkeypatch.setitem(bt._running_jobs, key, job2)
resp2 = _client().post("/api/backtest/strategy/cancel", json={"qs": "strategy_id=ma_cross"})
assert resp2.status_code == 200
assert resp2.json()["ok"] is False
assert not job2.cancel_event.is_set()
def test_cancel_resolves_all_py_bool_aliases(monkeypatch):
"""force=on/yes 等别名与 force=1 必须算出同一 key (对齐 pydantic v2)。"""
args = ("ma_cross", None, None, None, "open_t+1", None, None,
0.0002, 5.0, 10, 1.0, 1_000_000.0, "equal", None, None)
forced_key = bt._make_job_key(*args, force=True)
for alias in ("force=on", "force=yes", "force=ON"):
job = bt._BacktestJob(forced_key)
job.cancel_event = threading.Event()
monkeypatch.setitem(bt._running_jobs, forced_key, job)
resp = _client().post(
"/api/backtest/strategy/cancel",
json={"qs": f"strategy_id=ma_cross&{alias}"},
)
assert resp.status_code == 200
assert resp.json()["ok"] is True, alias
assert job.cancel_event.is_set(), alias