mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
回测成交口径: - 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>
167 lines
7.0 KiB
Python
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®ime_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
|