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>
268 lines
9.6 KiB
Python
268 lines
9.6 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from datetime import date
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import polars as pl
|
|
import pytest
|
|
|
|
from app.api import data as data_api
|
|
from app.backtest.engine import PanelCache
|
|
from app.enriched_generation import (
|
|
EnrichedGenerationUnavailableError,
|
|
get_enriched_generation,
|
|
)
|
|
|
|
|
|
class _RepoStub:
|
|
def __init__(self, data_dir: Path) -> None:
|
|
self.store = SimpleNamespace(data_dir=data_dir)
|
|
self.calls: list[str] = []
|
|
|
|
def clear_cache(self) -> None:
|
|
self.calls.append("clear_cache")
|
|
|
|
def refresh_cache(self) -> None:
|
|
self.calls.append("refresh_cache")
|
|
|
|
def rebuild_views(self) -> None:
|
|
self.calls.append("rebuild_views")
|
|
|
|
|
|
def _request(repo: _RepoStub) -> SimpleNamespace:
|
|
return SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(repo=repo)))
|
|
|
|
|
|
def _write_parquet_placeholder(path: Path) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(b"parquet-placeholder")
|
|
|
|
|
|
def _stub_clear_side_effects(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
from app.api import overview
|
|
from app.services import alert_store
|
|
from app.services import pipeline_jobs
|
|
from app.services.pipeline_jobs import job_store
|
|
from app.services.screener import ScreenerService
|
|
|
|
monkeypatch.setattr(job_store, "clear", lambda: None)
|
|
monkeypatch.setattr(alert_store, "clear", lambda _data_dir: None)
|
|
monkeypatch.setattr(ScreenerService, "clear_history_cache", lambda: None)
|
|
monkeypatch.setattr(overview, "invalidate_overview_cache", lambda: None)
|
|
monkeypatch.setattr(data_api, "invalidate_data_cache", lambda _table=None: None)
|
|
# 重置重任务执行槽, 隔离其他测试可能残留的持有者
|
|
monkeypatch.setattr(pipeline_jobs, "_run_slot_owner", None)
|
|
|
|
|
|
def test_clear_data_bumps_enriched_generations_and_invalidates_panel_cache(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
_stub_clear_side_effects(monkeypatch)
|
|
repo = _RepoStub(tmp_path)
|
|
stock_file = tmp_path / "kline_daily_enriched" / "date=2026-08-14" / "part.parquet"
|
|
etf_file = tmp_path / "kline_etf_enriched" / "date=2026-08-14" / "part.parquet"
|
|
_write_parquet_placeholder(stock_file)
|
|
_write_parquet_placeholder(etf_file)
|
|
stock_before = get_enriched_generation(tmp_path, "stock")
|
|
etf_before = get_enriched_generation(tmp_path, "etf")
|
|
|
|
cache = PanelCache()
|
|
cache_args = (["000001.SZ"], date(2026, 8, 14), date(2026, 8, 14), None)
|
|
computes: list[int] = []
|
|
|
|
def compute(*_args) -> pl.DataFrame:
|
|
computes.append(len(computes) + 1)
|
|
return pl.DataFrame({"value": [computes[-1]]})
|
|
|
|
cache.get_or_compute(*cache_args, compute, "stock", stock_before)
|
|
result = data_api.clear_data(_request(repo))
|
|
stock_after = get_enriched_generation(tmp_path, "stock")
|
|
etf_after = get_enriched_generation(tmp_path, "etf")
|
|
cached_after = cache.get_or_compute(*cache_args, compute, "stock", stock_after)
|
|
|
|
assert result == {"deleted_files": 2}
|
|
assert not stock_file.exists()
|
|
assert not etf_file.exists()
|
|
assert stock_after != stock_before
|
|
assert etf_after != etf_before
|
|
assert cached_after["value"].item() == 2
|
|
assert cache.stats()["compute_count"] == 2
|
|
assert repo.calls == ["clear_cache", "refresh_cache", "rebuild_views"]
|
|
|
|
|
|
def test_clear_data_restores_ready_generation_when_first_delete_fails(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
repo = _RepoStub(tmp_path)
|
|
target = tmp_path / "kline_daily_enriched" / "date=2026-08-14" / "part.parquet"
|
|
_write_parquet_placeholder(target)
|
|
generation_before = get_enriched_generation(tmp_path, "stock")
|
|
original_unlink = Path.unlink
|
|
|
|
def fail_target(path: Path, *args, **kwargs) -> None:
|
|
if path == target:
|
|
raise PermissionError("injected delete failure")
|
|
original_unlink(path, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(Path, "unlink", fail_target)
|
|
|
|
with pytest.raises(PermissionError, match="injected delete failure"):
|
|
data_api.clear_data(_request(repo))
|
|
|
|
assert target.is_file()
|
|
assert get_enriched_generation(tmp_path, "stock") == generation_before
|
|
marker = json.loads(
|
|
(tmp_path / ".matrix_generation_stock.json").read_text(encoding="utf-8")
|
|
)
|
|
assert marker["state"] == "ready"
|
|
|
|
|
|
def test_clear_data_keeps_generation_publishing_after_partial_delete(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
repo = _RepoStub(tmp_path)
|
|
enriched_dir = tmp_path / "kline_daily_enriched"
|
|
first = enriched_dir / "date=2026-08-13" / "part.parquet"
|
|
second = enriched_dir / "date=2026-08-14" / "part.parquet"
|
|
_write_parquet_placeholder(first)
|
|
_write_parquet_placeholder(second)
|
|
get_enriched_generation(tmp_path, "stock")
|
|
original_unlink = Path.unlink
|
|
parquet_unlinks = 0
|
|
|
|
def fail_second_parquet(path: Path, *args, **kwargs) -> None:
|
|
nonlocal parquet_unlinks
|
|
if path.suffix == ".parquet" and enriched_dir in path.parents:
|
|
parquet_unlinks += 1
|
|
if parquet_unlinks == 2:
|
|
raise PermissionError("injected partial delete failure")
|
|
original_unlink(path, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(Path, "unlink", fail_second_parquet)
|
|
|
|
with pytest.raises(PermissionError, match="injected partial delete failure"):
|
|
data_api.clear_data(_request(repo))
|
|
|
|
assert sum(path.exists() for path in (first, second)) == 1
|
|
marker = json.loads(
|
|
(tmp_path / ".matrix_generation_stock.json").read_text(encoding="utf-8")
|
|
)
|
|
assert marker["state"] == "publishing"
|
|
with pytest.raises(EnrichedGenerationUnavailableError, match="being published"):
|
|
get_enriched_generation(tmp_path, "stock")
|
|
|
|
|
|
def test_clear_data_recovers_stale_publishing_marker_from_dead_process(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""外部进程(崩掉的脚本/中断的管道)残留 publishing 标记且 owner pid 已死时,
|
|
清空不应 500 — clear 语义就是接管一切 (用户实测场景: POST /api/data/clear
|
|
报 another enriched publication is incomplete)。"""
|
|
_stub_clear_side_effects(monkeypatch)
|
|
repo = _RepoStub(tmp_path)
|
|
target = tmp_path / "kline_daily_enriched" / "date=2026-08-14" / "part.parquet"
|
|
_write_parquet_placeholder(target)
|
|
(tmp_path / ".matrix_generation_stock.json").write_text(
|
|
json.dumps({
|
|
"state": "publishing",
|
|
"generation": "stale-generation",
|
|
"publication_id": "stale-publication",
|
|
"owner_pid": 999999999, # 不存在的 pid → 探测判定已死
|
|
"updated_at_ns": 0,
|
|
}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = data_api.clear_data(_request(repo))
|
|
|
|
assert result == {"deleted_files": 1}
|
|
assert not target.exists()
|
|
marker = json.loads(
|
|
(tmp_path / ".matrix_generation_stock.json").read_text(encoding="utf-8")
|
|
)
|
|
assert marker["state"] == "ready"
|
|
assert get_enriched_generation(tmp_path, "stock") == marker["generation"]
|
|
|
|
|
|
def test_clear_data_rejected_while_data_task_running(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""有数据任务运行时清空必须 fail-closed: 409 拒绝且不删除任何文件。
|
|
|
|
互斥缺失的旧风险: 一边删除一边由管道线程继续写入 parquet, 终态既非
|
|
"已清空"也非"完整同步"; 且 job_store.clear() 清掉取消标志后, 僵尸线程
|
|
失去协作式取消信号继续写盘。
|
|
"""
|
|
from fastapi import HTTPException
|
|
|
|
from app.services import pipeline_jobs
|
|
|
|
_stub_clear_side_effects(monkeypatch)
|
|
repo = _RepoStub(tmp_path)
|
|
stock_file = tmp_path / "kline_daily_enriched" / "date=2026-08-14" / "part.parquet"
|
|
_write_parquet_placeholder(stock_file)
|
|
|
|
# 模拟一个运行中的数据任务持有重任务执行槽
|
|
monkeypatch.setattr(pipeline_jobs, "_run_slot_owner", "pipeline-job-1")
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
data_api.clear_data(_request(repo))
|
|
assert exc_info.value.status_code == 409
|
|
|
|
# fail-closed: 文件原样保留, 槽位持有者不变
|
|
assert stock_file.exists()
|
|
assert pipeline_jobs._run_slot_owner == "pipeline-job-1"
|
|
assert repo.calls == []
|
|
|
|
# 槽释放后可正常清空, 且清空结束时槽位被释放 (不阻塞后续任务)
|
|
monkeypatch.setattr(pipeline_jobs, "_run_slot_owner", None)
|
|
result = data_api.clear_data(_request(repo))
|
|
assert result == {"deleted_files": 1}
|
|
assert not stock_file.exists()
|
|
assert pipeline_jobs._run_slot_owner is None
|
|
|
|
|
|
def test_clear_data_pauses_and_restores_minute_refresh(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""清空期间必须暂停全量分钟增量线程 (独立写 kline_minute, 不占 run slot),
|
|
结束后按原运行状态恢复 — 否则"清完又被写回部分今日数据"。"""
|
|
from app.services import pipeline_jobs
|
|
|
|
_stub_clear_side_effects(monkeypatch)
|
|
repo = _RepoStub(tmp_path)
|
|
calls: list[str] = []
|
|
|
|
class _MrsStub:
|
|
def status(self):
|
|
return {"running": True}
|
|
|
|
def stop(self):
|
|
calls.append("stop")
|
|
|
|
def start(self):
|
|
calls.append("start")
|
|
|
|
request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(repo=repo)))
|
|
request.app.state.minute_refresh = _MrsStub()
|
|
data_api.clear_data(request)
|
|
assert calls == ["stop", "start"]
|
|
|
|
# 原本未运行则不重启
|
|
calls.clear()
|
|
|
|
class _MrsIdle(_MrsStub):
|
|
def status(self):
|
|
return {"running": False}
|
|
|
|
request.app.state.minute_refresh = _MrsIdle()
|
|
data_api.clear_data(request)
|
|
assert calls == ["stop"]
|