Files

150 lines
5.4 KiB
Python
Raw Permalink Normal View History

"""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