mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
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>
This commit is contained in:
@@ -96,7 +96,9 @@ def _atomic_write_parquet(df: pl.DataFrame, out) -> None:
|
||||
*.parquet glob, 不会被扫描误读。Windows 下目标正被并发读取时由
|
||||
replace_with_retry 短退避穿过。
|
||||
"""
|
||||
tmp = out.with_name(out.name + ".tmp")
|
||||
# 唯一后缀: 两个写入者共用固定名 tmp 并发写同一文件会交错损坏 (被 rename
|
||||
# 定版后分区报废)。崩溃残留的孤儿 tmp 不匹配 *.parquet glob, 读侧无影响。
|
||||
tmp = out.with_name(f"{out.name}.{uuid.uuid4().hex[:8]}.tmp")
|
||||
df.write_parquet(tmp)
|
||||
replace_with_retry(tmp, out)
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
由 provider 自管批量与限速 (rpm 配置/内部并发), 服务不代限。
|
||||
- 稳态轮单请求: 无脉冲并发, 间隔可低至 3s; 实际节奏 = max(间隔, 单轮完成),
|
||||
服务端响应 ~5s 时自动退化为响应节奏, 不会重叠请求。
|
||||
- 固定节奏: 默认 6s 一轮 (clamp [3, 300]), 不补跑 (missed 轮次直接跳过);
|
||||
- 固定节奏: 默认 6s 一轮 (clamp [3, 120]), 不补跑 (missed 轮次直接跳过);
|
||||
仅修复轮的自定义源下限抬到 60s (全天批量打不住 6s 节奏)。
|
||||
- 仅连续竞价时段运行 (9:30-11:30 / 13:00-15:00), 午休/收盘自动暂停与恢复;
|
||||
午休后恢复因覆盖滞后会多跑一次修复轮, 幂等无害。
|
||||
@@ -50,7 +50,7 @@ from typing import Any
|
||||
import polars as pl
|
||||
|
||||
from app.market_time import cn_now, cn_today, in_continuous_session
|
||||
from app.services import preferences
|
||||
from app.services import minute_adjust, preferences
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -89,6 +89,8 @@ class _RefreshState:
|
||||
last_error: str | None = None
|
||||
next_round_at: float | None = None # epoch 秒
|
||||
extra: dict[str, Any] = field(default_factory=dict)
|
||||
minute_basis_raw: bool | None = None # 上轮落盘时本地库是否 raw 基准
|
||||
basis_warned: bool = False # 混基准风险只警告一次
|
||||
|
||||
|
||||
class MinuteRefreshService:
|
||||
@@ -338,10 +340,25 @@ class MinuteRefreshService:
|
||||
)
|
||||
return
|
||||
self._empty_rounds = 0
|
||||
# 基准可见性: intraday 端点的价格基准未签约 (SDK 无 adjust 参数),
|
||||
# 本地库未迁移 raw (.raw_basis 缺失) 时服务写入与端点补拉 (forward)
|
||||
# 可能混基准 —— 只暴露状态与一次性警告, 不阻断 (阻断属功能变更, 另行决策)。
|
||||
basis_raw = minute_adjust.minute_basis_is_raw(self._repo.store.data_dir)
|
||||
if not basis_raw and not self._state.basis_warned:
|
||||
logger.warning(
|
||||
"全量分钟: 本地分钟库未迁移 raw 基准 (.raw_basis 缺失), intraday "
|
||||
"价格基准未签约, 服务写入可能与端点补拉混基准; 建议先运行分钟基准迁移"
|
||||
)
|
||||
self._state.basis_warned = True
|
||||
self._state.minute_basis_raw = basis_raw
|
||||
write_started = time.perf_counter()
|
||||
written = kline_sync._write_minute_partition(
|
||||
df, self._repo.store.data_dir / "kline_minute",
|
||||
)
|
||||
# 读-改-写必须持仓库写锁 (与 minute-batch 补拉 / 盘后同步同一纪律):
|
||||
# 无互斥的两个写者并发替换同一分区时, 后写者以过期基底覆盖丢整批行,
|
||||
# 且会共用同一 tmp 文件交错损坏。实测增量轮落盘 ~10ms, 锁占用可忽略。
|
||||
with self._repo._write_lock:
|
||||
written = kline_sync._write_minute_partition(
|
||||
df, self._repo.store.data_dir / "kline_minute",
|
||||
)
|
||||
write_ms = (time.perf_counter() - write_started) * 1000
|
||||
|
||||
self._state.rounds += 1
|
||||
@@ -413,6 +430,7 @@ class MinuteRefreshService:
|
||||
"last_symbols": self._state.last_symbols,
|
||||
"last_requests": self._state.last_requests,
|
||||
"last_mode": self._state.last_mode,
|
||||
"minute_basis_raw": self._state.minute_basis_raw,
|
||||
"next_round_at": self._state.next_round_at,
|
||||
"last_error": self._state.last_error,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
"""分钟数据完整性修复的回归锁定。
|
||||
|
||||
四项: ① 全量分钟服务落盘持仓库写锁 + 原子写临时名唯一化; ② 服务暴露 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 安全系数, 与盘后同步同桶
|
||||
@@ -47,9 +47,12 @@ class _FakeAppState:
|
||||
|
||||
class _FakeRepo:
|
||||
def __init__(self, symbols: list[str]):
|
||||
import threading
|
||||
from pathlib import Path
|
||||
self._inst = pl.DataFrame({"symbol": symbols})
|
||||
self.store = type("S", (), {"data_dir": Path(".")})()
|
||||
# 与真实仓库同契约: 分区读-改-写必须持仓库写锁
|
||||
self._write_lock = threading.Lock()
|
||||
|
||||
def get_instruments(self) -> pl.DataFrame:
|
||||
return self._inst
|
||||
|
||||
@@ -234,6 +234,10 @@ provider 不应自行切换或回退到其他数据源。
|
||||
> - `get_intraday_latest(symbols=None, count=3) -> pl.DataFrame` — **可选**,
|
||||
> 稳态增量轮专用:尽量单请求返回全市场每只标的最新 `count` 根。未实现时
|
||||
> 服务自动降级为仅修复轮,节奏下限抬到 60s(全天批量打不住 6s 节奏)。
|
||||
> - **价格基准契约**:两个方法都必须返回**原始价(不复权)**。本地分钟库以 raw
|
||||
> 基准落盘 (`.raw_basis` 标记),读取侧统一做前复权投影;返回已复权价格会与
|
||||
> 本地存储混基准,产生静默的错误价格。服务状态面板的 `minute_basis_raw`
|
||||
> 字段会暴露本地库的基准迁移状态。
|
||||
>
|
||||
> 两个方法的返回帧都过 `_enforce_minute_beijing_wallclock` 时区守卫(与
|
||||
> `get_minute` 同纪律);失败抛异常或返回空 df 均按空轮处理,连续空轮触发
|
||||
|
||||
Reference in New Issue
Block a user