Files
tick-stock-panel/backend/tests/test_minute_integrity_fixes.py
T
shy3130 ddaab1fbf1 fix(minute): 全量分钟服务落盘持仓库写锁 + 原子写临时名唯一化 + 基准状态暴露
- minute_refresh 落盘改持 repo._write_lock: 与 minute-batch 补拉/盘后同步
  同一纪律。此前服务是唯一不持锁的写者, 并发替换同一分区时后写者以过期
  基底覆盖丢整批行, 且共用固定名 tmp 会交错损坏且无自愈。实测增量轮落盘
  ~10ms, 锁占用可忽略
- _atomic_write_parquet 临时名加 uuid 后缀: 消除两写者共用同一 tmp 的
  交错损坏窗口; 崩溃孤儿 tmp 不匹配 *.parquet glob
- 服务每轮记录本地库 raw 基准状态 (status 新增 minute_basis_raw 字段),
  未迁移库一次性警告不刷屏; 插件契约文档补"full_minute 必须返回原始价"条款
- 修正头注释 clamp 漂移 ([3,300]→[3,120])

Signed-off-by: shy3130 <shy3130@qq.com>
2026-10-25 17:31:05 +08:00

234 lines
8.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""分钟数据完整性修复的回归锁定。
四项: ① 全量分钟服务落盘持仓库写锁 + 原子写临时名唯一化; ② 服务暴露 raw 基准
状态; ③ minute-batch 门控在全量分钟服务健康时放行本地读; ④ 补拉限速与盘后
同步统一走 resolve_limit (×0.8 安全系数, 同 rpm 桶)。
"""
from __future__ import annotations
import json
import threading
from datetime import date, datetime, timedelta
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
import polars as pl
import pytest
from fastapi import HTTPException
from app.services import kline_sync
from app.services.minute_refresh import MinuteRefreshService
from app.tickflow.capabilities import Cap, CapabilityLimits, CapabilitySet
_BJ = date(2026, 10, 23)
_SYM = "600519.SH"
# ─────────────────────────── ① 服务落盘持写锁 ───────────────────────────
class _RecordingLock:
"""记录获取次数的锁包装; spy 在写分区时检查它是否被持有。"""
def __init__(self) -> None:
self._lock = threading.Lock()
def __enter__(self):
self._lock.acquire()
return self
def __exit__(self, *exc):
self._lock.release()
return None
def locked(self) -> bool:
return self._lock.locked()
def _round_df() -> pl.DataFrame:
start = datetime(_BJ.year, _BJ.month, _BJ.day, 10, 0)
return pl.DataFrame({
"symbol": [_SYM] * 3,
"datetime": [start + timedelta(minutes=i) for i in range(3)],
"close": [10.0, 10.1, 10.2],
})
def test_service_write_holds_repo_write_lock(tmp_path, monkeypatch):
"""服务增量轮落盘必须持 repo._write_lock (与端点补拉/盘后同步同一纪律)。"""
lock = _RecordingLock()
repo = SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path), _write_lock=lock)
svc = MinuteRefreshService(repo)
monkeypatch.setattr(svc, "_resolve_custom", lambda: (None, "tickflow"))
monkeypatch.setattr(svc, "_select_mode", lambda: "increment")
df = _round_df()
monkeypatch.setattr(
kline_sync, "fetch_intraday_universe_increment", lambda: (df, 1)
)
seen: dict[str, object] = {}
def spy_write(frame, minute_dir):
seen["locked"] = lock.locked()
return frame.height
monkeypatch.setattr(kline_sync, "_write_minute_partition", spy_write)
svc._run_round()
assert seen.get("locked") is True, "落盘时必须持有仓库写锁"
def test_service_records_minute_basis_state(tmp_path, monkeypatch):
"""② 每轮记录本地库 raw 基准状态; 未迁移 (无 .raw_basis) 时只警告一次。"""
lock = _RecordingLock()
repo = SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path), _write_lock=lock)
svc = MinuteRefreshService(repo)
monkeypatch.setattr(svc, "_resolve_custom", lambda: (None, "tickflow"))
monkeypatch.setattr(svc, "_select_mode", lambda: "increment")
monkeypatch.setattr(
kline_sync, "fetch_intraday_universe_increment", lambda: (_round_df(), 1)
)
monkeypatch.setattr(kline_sync, "_write_minute_partition", lambda f, d: f.height)
warnings: list[str] = []
from app.services import minute_refresh as mr
monkeypatch.setattr(mr.logger, "warning", lambda msg, *a: warnings.append(msg))
svc._run_round()
svc._run_round()
assert svc._state.minute_basis_raw is False # tmp_path 无 .raw_basis 标记
assert len(warnings) == 1 # 只警告一次
assert "raw 基准" in warnings[0]
def test_atomic_write_tmp_names_unique(tmp_path, monkeypatch):
"""① 原子写的临时文件名必须每次唯一 (防两写者共用同一 tmp 交错损坏)。"""
df = pl.DataFrame({"a": [1, 2, 3]})
out = tmp_path / "part.parquet"
captured: list[Path] = []
monkeypatch.setattr(kline_sync, "replace_with_retry",
lambda tmp, dst: captured.append(tmp))
kline_sync._atomic_write_parquet(df, out)
kline_sync._atomic_write_parquet(df, out)
assert len(captured) == 2
assert captured[0] != captured[1]
assert all(p.name.endswith(".tmp") for p in captured) # 不匹配 *.parquet glob
assert not list(tmp_path.glob("*.parquet")) # 正常路径无残留
# ─────────────────────── ③ 门控: 服务健康放行本地读 ───────────────────────
def _bars(sym: str, n: int) -> pl.DataFrame:
start = datetime(_BJ.year, _BJ.month, _BJ.day, 9, 31)
return pl.DataFrame({
"symbol": [sym] * n,
"datetime": [start + timedelta(minutes=i) for i in range(n)],
"open": [10.0] * n, "high": [10.1] * n, "low": [9.9] * n, "close": [10.0] * n,
"volume": [100.0] * n, "amount": [1000.0] * n,
})
def _gate_env(monkeypatch, *, healthy: bool | None):
from app.api import kline as kline_api
monkeypatch.setattr(kline_api, "cn_today", lambda: _BJ)
monkeypatch.setattr(kline_api, "cn_now", lambda: datetime(_BJ.year, _BJ.month, _BJ.day, 15, 0))
monkeypatch.setattr(kline_api, "in_continuous_session", lambda: True)
repo = MagicMock()
repo.get_etf_symbol_set.return_value = set()
repo.get_minute_batch.return_value = _bars(_SYM, 240) # 240 根 = 盘后完整, 不触发补拉
capset = MagicMock()
capset.has.return_value = False # 无 Pro+ BATCH 能力
capset.limits.return_value = None
svc = None
if healthy is not None:
svc = MagicMock()
svc.is_healthy.return_value = healthy
request = MagicMock()
request.headers = {}
request.app.state.repo = repo
request.app.state.capabilities = capset
# MagicMock 的属性访问恒返回 truthy mock, 服务缺席场景必须显式置 None
request.app.state.minute_refresh = svc
return kline_api, request
def test_gate_allows_local_read_when_service_healthy(monkeypatch):
"""③ 无 Pro+ 但全量分钟服务健康 (正写本地分区) → 本地读放行。"""
kline_api, request = _gate_env(monkeypatch, healthy=True)
resp = kline_api.get_minute_batch(request, {"symbols": [_SYM], "date": str(_BJ)})
payload = json.loads(resp.body)
assert len(payload["data"][_SYM]) == 240
def test_gate_rejects_when_service_absent_or_unhealthy(monkeypatch):
"""③ 服务缺席或不健康且无能力 → 维持 403 (回落现状, 拉取仍需 Pro+)。"""
kline_api, request = _gate_env(monkeypatch, healthy=None)
with pytest.raises(HTTPException) as exc:
kline_api.get_minute_batch(request, {"symbols": [_SYM], "date": str(_BJ)})
assert exc.value.status_code == 403
kline_api, request = _gate_env(monkeypatch, healthy=False)
with pytest.raises(HTTPException) as exc:
kline_api.get_minute_batch(request, {"symbols": [_SYM], "date": str(_BJ)})
assert exc.value.status_code == 403
# ─────────────── ④ 补拉限速与盘后同步统一 (×0.8, 同 rpm 桶) ───────────────
def test_pull_uses_resolve_limit_safety_rpm(monkeypatch):
"""④ 补拉 rpm 必须经 resolve_limit (60 → 48), 与盘后同步落同一共享桶。"""
from app.api import kline as kline_api
monkeypatch.setattr(kline_api, "cn_today", lambda: _BJ)
monkeypatch.setattr(kline_api, "cn_now", lambda: datetime(_BJ.year, _BJ.month, _BJ.day, 10, 30))
monkeypatch.setattr(kline_api, "in_continuous_session", lambda: True)
# 中间有洞 (间距 2min) → 全天补拉, 捕获传给 sync_minute_batch 的限速参数
hole = pl.DataFrame({
"symbol": [_SYM] * 2,
"datetime": [datetime(_BJ.year, _BJ.month, _BJ.day, 9, 31),
datetime(_BJ.year, _BJ.month, _BJ.day, 9, 33)],
"open": [10.0] * 2, "high": [10.1] * 2, "low": [9.9] * 2, "close": [10.0] * 2,
"volume": [100.0] * 2, "amount": [1000.0] * 2,
})
repo = MagicMock()
repo.get_etf_symbol_set.return_value = set()
repo.get_minute_batch.return_value = hole
repo._write_lock = threading.Lock()
capset = CapabilitySet({Cap.KLINE_MINUTE_BATCH: CapabilityLimits(batch=200, rpm=60)})
captured: dict[str, object] = {}
def spy_sync(symbols, *, start_time, end_time, batch_size=None, rpm=None,
asset_type="stock", **kw):
captured["batch"] = batch_size
captured["rpm"] = rpm
return pl.DataFrame()
monkeypatch.setattr(kline_sync, "sync_minute_batch", spy_sync)
request = MagicMock()
request.headers = {}
request.app.state.repo = repo
request.app.state.capabilities = capset
kline_api.get_minute_batch(request, {"symbols": [_SYM], "date": str(_BJ)})
assert captured["batch"] == 200 # batch 原样透传
assert captured["rpm"] == 48 # 60 × 0.8 安全系数, 与盘后同步同桶