Files
tick-stock-panel/backend/tests/test_final_sync_confirmation.py
T
shy3130 e0cd625ef4 feat(platform): 因子平台与因子↔策略双向联动 v0.2.3
- 因子平台: /factors 一级页(检验/因子库/编辑器/组合/挖掘), DSL 公式因子(25 算子点选、双语字段、我的因子模板、脏公式守卫), 版本与生命周期, 自动挖掘 L1 统计筛选
- 因子↔策略四条桥: 触发器 Zap 快建因子条件信号、因子一键生成排名策略、自定义信号 AI 提示词接入因子分组、策略回测因子归因(胜/败单入场信号日因子均值, 独立 tab, 双语因子名)
- 回测: 统计卡新增盈亏比(≥1 红/<1 绿), 蒙卡回撤合并为中位/95% 双值卡(自适应字号), 高级设置基础过滤与策略编辑器参数对齐(5 组区间)
- 信号库独立页 /signals(原设置 tab 迁出), 持仓提醒入导航; 挖掘并入因子页第 5 tab, /mining 旧链接重定向
- 研究线配套: 因子目录 61→77(评分/矩阵双内核), stats_v2(Newey-West/BH-FDR/DSR), enriched 管道与异动/报价服务配套调整
- 文档: README 导航与特性表、features.md 因子平台章节、操作说明书 9.2、factor-platform-plan 执行状态与 §5、二开文档桥接说明; 交流与支持节改版
- 版本 0.2.2 → 0.2.3; 后端全量 1625 passed(1 例环境性跳过), 前端 build 通过
2026-09-05 15:41:15 +08:00

150 lines
5.4 KiB
Python

"""final 定版确认回归: 午休/收盘定版必须校验快照时间戳, 未达边界不落盘。
实测 (2026-09-04): 收盘定版在 15:00:02 仅凭"拉取成功"即标记完成并落盘, 但
实时源当时仍返回 14:59:5x 的竞价前快照 (海鸥住工 7.07 而非官方收盘 7.10),
旧价被永久固化到当日分区; 且重启后的盘后手动刷新会再次写回旧价。修复后:
- _process_full_market_records 收到 final_boundary_ms 时, 快照最大时间戳
达到边界 (含容差) 才落盘/评估监控, 否则只更新展示缓存;
- _final_boundary_ms/_past_final_deadline 提供边界与重试窗口 (收盘 15:00/15:30)。
"""
from __future__ import annotations
from datetime import datetime, time as dt_time
import pytest
import app.services.quote_service as qs_module
from app.market_time import CN_TZ, cn_today
from app.services.quote_service import QuoteService
def _beijing_ms(h: int, m: int, s: int = 0) -> int:
return int(datetime.combine(cn_today(), dt_time(h, m, s), tzinfo=CN_TZ).timestamp() * 1000)
def _record(ts_ms: int) -> dict:
return {
"symbol": "002084.SZ",
"last_price": 7.07,
"open": 7.00, "high": 7.10, "low": 6.95,
"volume": 100_000, "amount": 707_000.0,
"timestamp": ts_ms,
}
class _StubRepo:
"""记录写盘调用的最小仓库桩。"""
def __init__(self) -> None:
self.calls: list[str] = []
def get_index_symbol_set(self) -> set:
return set()
def get_etf_instruments(self):
import polars as pl
return pl.DataFrame()
def flush_live_daily(self, df) -> None:
self.calls.append("daily")
def flush_live_daily_asset(self, asset_type: str, df) -> None:
self.calls.append(f"daily:{asset_type}")
@pytest.fixture
def service(monkeypatch) -> tuple[QuoteService, _StubRepo, dict]:
qs = QuoteService()
repo = _StubRepo()
qs._repo = repo
events: dict[str, int] = {"broadcast": 0, "enriched": 0}
monkeypatch.setattr(qs_module, "_persist_last_fetch", lambda ms: None)
monkeypatch.setattr(qs, "_update_volume_delta", lambda *a, **k: None)
monkeypatch.setattr(qs, "_evaluate_monitors", lambda *a, **k: None)
monkeypatch.setattr(qs, "_broadcast_quote_updated", lambda: events.__setitem__("broadcast", events["broadcast"] + 1))
monkeypatch.setattr(qs, "_flush_live_enriched", lambda *a, **k: events.__setitem__("enriched", events["enriched"] + 1))
return qs, repo, events
def test_final_snapshot_before_boundary_skips_disk(service) -> None:
"""竞价前快照 (时间戳 < 15:00): 只更新展示缓存, 不写 daily/enriched。"""
qs, repo, events = service
boundary = _beijing_ms(15, 0)
qs._process_full_market_records(
[_record(boundary - 60_000)], t0=0.0, now_ts=0.0,
final_boundary_ms=boundary,
)
assert qs._last_final_confirmed is False
assert repo.calls == [] # 未写 kline_daily
assert events["enriched"] == 0 # 未写 enriched
assert events["broadcast"] == 1 # 展示缓存路径仍走通
def test_final_snapshot_after_boundary_writes(service) -> None:
"""边界后快照 (时间戳 ≥ 15:00): 定版落盘。"""
qs, repo, events = service
boundary = _beijing_ms(15, 0)
qs._process_full_market_records(
[_record(boundary + 30_000)], t0=0.0, now_ts=0.0,
final_boundary_ms=boundary,
)
assert qs._last_final_confirmed is True
assert repo.calls == ["daily"]
assert events["enriched"] == 1
assert events["broadcast"] == 1
def test_snapshot_without_timestamp_never_confirmed(service) -> None:
"""无时间戳的快照无法确认定版 → 不落盘 (交由盘后管道兜底)。"""
qs, repo, events = service
rec = _record(0)
rec.pop("timestamp")
qs._process_full_market_records(
[rec], t0=0.0, now_ts=0.0, final_boundary_ms=_beijing_ms(15, 0),
)
assert qs._last_final_confirmed is False
assert repo.calls == []
assert events["enriched"] == 0
def test_normal_poll_ignores_boundary(service) -> None:
"""普通轮询 (无 final_boundary_ms): 时间戳在边界前也照常落盘。"""
qs, repo, events = service
qs._process_full_market_records(
[_record(_beijing_ms(14, 59))], t0=0.0, now_ts=0.0,
)
assert qs._last_final_confirmed is None
assert repo.calls == ["daily"]
assert events["enriched"] == 1
def test_final_boundary_ms_matches_beijing_close(monkeypatch) -> None:
fake_now = datetime.combine(cn_today(), dt_time(15, 10), tzinfo=CN_TZ)
monkeypatch.setattr(qs_module, "cn_now", lambda: fake_now)
assert QuoteService._final_boundary_ms("close_final") == _beijing_ms(15, 0)
assert QuoteService._final_boundary_ms("morning_final") == _beijing_ms(11, 30)
assert QuoteService._final_boundary_ms("afternoon") is None
def test_past_final_deadline(monkeypatch) -> None:
def _at(h: int, m: int):
return datetime.combine(cn_today(), dt_time(h, m), tzinfo=CN_TZ)
monkeypatch.setattr(qs_module, "cn_now", lambda: _at(15, 29))
assert QuoteService._past_final_deadline("close_final") is False
monkeypatch.setattr(qs_module, "cn_now", lambda: _at(15, 30))
assert QuoteService._past_final_deadline("close_final") is True
monkeypatch.setattr(qs_module, "cn_now", lambda: _at(12, 9))
assert QuoteService._past_final_deadline("morning_final") is False
monkeypatch.setattr(qs_module, "cn_now", lambda: _at(12, 11))
assert QuoteService._past_final_deadline("morning_final") is True