perf(kline): 分钟批量补拉取到即落盘 + 尾部增量窗口

- 补拉结果经 _write_minute_partition upsert 落盘 (持仓库写锁, 与全量分钟
  服务/盘后同步同一纪律), 下一轮命中本地, 大自选从每轮全天重拉降为增量
- 完整性判定由 0.9 根数比例改为 期望-2 根: 比例阈值会让持久化数据在 90%
  处冻结尾巴
- 分类三态: fresh 直读本地零请求 / 尾部落后从最后一根+1min 增量拉 /
  中间有洞 (间距非 1min/午休 91min) 退回全天重拉回填, 防增量窗口漏数据
- 落盘失败降级为仅返回本轮数据 (持久化是优化而非正确性前提)
This commit is contained in:
shy3130
2026-08-31 20:53:23 +08:00
parent bfe01e06d9
commit b78ba9940d
2 changed files with 192 additions and 62 deletions
+95 -62
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import logging import logging
import math import math
from datetime import date, timedelta from datetime import date, timedelta
from pathlib import Path
from functools import lru_cache from functools import lru_cache
from typing import Optional from typing import Optional
@@ -599,11 +600,9 @@ def get_minute_batch(request: Request, body: dict):
trade_date = date.fromisoformat(trade_date_str) if trade_date_str else cn_today() trade_date = date.fromisoformat(trade_date_str) if trade_date_str else cn_today()
# 非交易日(周末/节假日)才回退到最近有数据的交易日; 否则盘中会显示昨天而非今天。 # 非交易日(周末/节假日)才回退到最近有数据的交易日; 否则盘中会显示昨天而非今天。
# 注意: 不能用 latest_minute_date_global() 判断盘中是否为交易日 —— 批量实时补拉 # 判据: 周末必回退; 工作日收盘后(>=15:30)仍无今日日K → 节假日, 回退。
# 不落库 (见下方 sync_minute_batch 无 on_segment), 盘中它恒返回上次全量同步日, # (下方补拉已改为取到即落盘, 但只有真实交易时段才会写入当日分区,
# 用它做判据会导致 trade_date 永久回退到昨天, 再因 expected=240 判定昨日"完整" # 节假日当日分区恒为空, 不影响该回退判据。)
# 而不再补拉今天, 形成永远显示昨日的死循环。
# 判据改为: 周末必回退; 工作日收盘后(>=15:30)仍无今日日K → 节假日, 回退。
if not trade_date_str: if not trade_date_str:
today = cn_today() today = cn_today()
need_fallback = today.weekday() >= 5 # 周六/周日必非交易日 need_fallback = today.weekday() >= 5 # 周六/周日必非交易日
@@ -649,19 +648,36 @@ def get_minute_batch(request: Request, body: dict):
else: else:
expected = 240 expected = 240
# 按 symbol 分组, 判定哪些不完整需要补拉 (partition_by 一次切分, 同 daily-batch) # 本地状态分类 (补拉已改为取到即落盘, 完整性判定随之收紧):
# - fresh: 根数 >= 期望-2 (时间边界容差), 直接用本地。原 0.9 比例阈值会让
# 持久化数据在 90% 处冻结尾巴, 必须按根数差判。
# - holes: 中间缺K (相邻间距非 1 分钟 / 非午休 91 分钟) → 全天重拉回填,
# 否则"最后一根+1min"的增量窗口永远不会回看中间的洞。
# - stale: 仅尾部落后 → 增量拉, 请求量从"每轮全天"降为"每轮一根"量级。
_LUNCH_GAP_MIN = 91 # 11:30 → 13:01
def _has_holes(sub: pl.DataFrame) -> bool:
gaps = sub["datetime"].diff().dt.total_minutes().drop_nulls()
return gaps.filter((gaps != 1) & (gaps != _LUNCH_GAP_MIN)).len() > 0
result: dict[str, list[dict]] = {} result: dict[str, list[dict]] = {}
incomplete: list[str] = [] full_pull: list[str] = [] # 无数据或中间有洞 → 全天拉
stale_last: dict[str, datetime] = {} # 尾部落后 → symbol → 最后一根时间
local_parts: dict[str, pl.DataFrame] = {} local_parts: dict[str, pl.DataFrame] = {}
if not df_local.is_empty(): if not df_local.is_empty():
for part in df_local.partition_by("symbol", maintain_order=True): for part in df_local.partition_by("symbol", maintain_order=True):
local_parts[part["symbol"][0]] = part.sort("datetime") local_parts[part["symbol"][0]] = part.sort("datetime")
fresh_floor = max(0, expected - 2)
for sym in symbols: for sym in symbols:
sub = local_parts.get(sym, pl.DataFrame()) sub = local_parts.get(sym, pl.DataFrame())
if expected > 0 and (sub.is_empty() or len(sub) < expected * 0.9): if expected == 0 or sub.height >= fresh_floor:
incomplete.append(sym) if not sub.is_empty():
elif not sub.is_empty(): result[sym] = sub.to_dicts()
result[sym] = sub.to_dicts() continue
if sub.is_empty() or _has_holes(sub):
full_pull.append(sym)
else:
stale_last[sym] = sub["datetime"][-1]
# prefer_local 生效判定: 仅当全量分钟服务健康 (freshness 契约, 见 minute_refresh.is_healthy) # prefer_local 生效判定: 仅当全量分钟服务健康 (freshness 契约, 见 minute_refresh.is_healthy)
full_minute_healthy = False full_minute_healthy = False
@@ -669,59 +685,76 @@ def get_minute_batch(request: Request, body: dict):
svc = getattr(request.app.state, "minute_refresh", None) svc = getattr(request.app.state, "minute_refresh", None)
full_minute_healthy = bool(svc is not None and svc.is_healthy()) full_minute_healthy = bool(svc is not None and svc.is_healthy())
if full_minute_healthy: if full_minute_healthy:
# 股票 incomplete 不补拉, 本地有多少给多少 (服务下一轮写入补全); # 股票缺口不补拉, 本地有多少给多少 (服务下一轮写入补全);
# ETF 维持 incomplete 走下方补拉 # ETF 不在 universe 内, 维持补拉
for sym in incomplete: for sym in [*full_pull, *stale_last]:
sub = local_parts.get(sym) if sym not in etf_set:
if sym not in etf_set and sub is not None and not sub.is_empty(): sub = local_parts.get(sym)
result[sym] = sub.to_dicts()
incomplete = [s for s in incomplete if s in etf_set]
# Step 2: 缺失的 symbol 批量实时拉取 (不落库)
if incomplete:
start_time = datetime(trade_date.year, trade_date.month, trade_date.day, 9, 25, 0)
end_time = datetime(trade_date.year, trade_date.month, trade_date.day, 15, 5, 0)
lim = capset.limits(Cap.KLINE_MINUTE_BATCH)
# etf_set 已在上方获取, 直接复用 — 按 asset_type 拆分调用 sync_minute_batch
# (自定义源 / TickFlow 路由均依赖 asset_type 正确传递)
# 契约: 本端点只接受 stock/ETF (指数分钟K走 /api/index/minute 独立路径),
# 故两分支已覆盖全部 incomplete。若未来放开指数支持, 需额外加 index 分支
# 以避免被误路由为 stock。
stock_incomplete = [s for s in incomplete if s not in etf_set]
etf_incomplete = [s for s in incomplete if s in etf_set]
live_parts: list[pl.DataFrame] = []
if stock_incomplete:
df_s = kline_sync.sync_minute_batch(
stock_incomplete,
start_time=start_time,
end_time=end_time,
batch_size=lim.batch if lim else None,
rpm=lim.rpm if lim else None,
asset_type="stock",
)
if not df_s.is_empty():
live_parts.append(df_s)
if etf_incomplete:
df_e = kline_sync.sync_minute_batch(
etf_incomplete,
start_time=start_time,
end_time=end_time,
batch_size=lim.batch if lim else None,
rpm=lim.rpm if lim else None,
asset_type="etf",
)
if not df_e.is_empty():
live_parts.append(df_e)
if live_parts:
live_df = pl.concat(live_parts, how="diagonal_relaxed")
live_map: dict[str, pl.DataFrame] = {
part["symbol"][0]: part.sort("datetime")
for part in live_df.partition_by("symbol", maintain_order=True)
}
for sym in incomplete:
sub = live_map.get(sym)
if sub is not None and not sub.is_empty(): if sub is not None and not sub.is_empty():
result[sym] = sub.to_dicts() result[sym] = sub.to_dicts()
full_pull = [s for s in full_pull if s in etf_set]
stale_last = {s: t for s, t in stale_last.items() if s in etf_set}
# Step 2: 补拉并落盘 (取到即写, upsert 语义; 下一轮命中本地, 请求量骤降)。
# 落盘失败只降级 (log 后继续返回本轮数据), 不影响响应 —— 持久化是优化而非正确性前提。
# 契约: 本端点只接受 stock/ETF (指数分钟K走 /api/index/minute 独立路径),
# 按 asset_type 拆分调用 (自定义源 / TickFlow 路由均依赖 asset_type 正确传递)。
day_start = datetime(trade_date.year, trade_date.month, trade_date.day, 9, 25, 0)
session_end = datetime(trade_date.year, trade_date.month, trade_date.day, 15, 5, 0)
lim = capset.limits(Cap.KLINE_MINUTE_BATCH)
minute_dirs = {
"stock": repo.store.data_dir / "kline_minute",
"etf": repo.store.data_dir / "kline_etf_minute",
}
live_map: dict[str, pl.DataFrame] = {}
def _pull(asset: str, sym_list: list[str], start: datetime) -> None:
if not sym_list:
return
df_live = kline_sync.sync_minute_batch(
sym_list,
start_time=start,
end_time=session_end,
batch_size=lim.batch if lim else None,
rpm=lim.rpm if lim else None,
asset_type=asset,
)
if df_live.is_empty():
return
try:
# 读-改-写必须持仓库写锁 (与全量分钟服务/盘后同步同一纪律, Windows 临时文件占用)。
# 仅在拿到真实目录时落盘: data_dir 异常 (非 Path) 时跳过, 只返回本轮数据。
minute_dir = minute_dirs[asset]
if isinstance(minute_dir, Path):
with repo._write_lock:
kline_sync._write_minute_partition(df_live, minute_dir)
except Exception as e: # noqa: BLE001
logger.warning("minute-batch 补拉落盘失败 (降级为仅返回): %s", e)
for part in df_live.partition_by("symbol", maintain_order=True):
live_map[part["symbol"][0]] = part.sort("datetime")
_pull("stock", [s for s in full_pull if s not in etf_set], day_start)
_pull("etf", [s for s in full_pull if s in etf_set], day_start)
if stale_last:
# 增量公共起点 = 最旧尾部 + 1min; 起点更早的重叠由落盘/合并去重吸收
inc_start = min(stale_last.values()) + timedelta(minutes=1)
if inc_start < session_end:
_pull("stock", [s for s in stale_last if s not in etf_set], inc_start)
_pull("etf", [s for s in stale_last if s in etf_set], inc_start)
# 合并: 有增量/回填的 symbol = 本地 + 拉取 upsert; 仅拉到的 (missing) 直接进结果
for sym, sub in local_parts.items():
live = live_map.get(sym)
if live is not None:
merged = (
pl.concat([sub, live])
.unique(subset=["symbol", "datetime"], keep="last")
.sort("datetime")
)
result[sym] = merged.to_dicts()
for sym, live in live_map.items():
if sym not in result:
result[sym] = live.to_dicts()
# full_minute_local: 本轮 prefer_local 生效 (本地分区由全量分钟服务供给, 股票未做补拉) # full_minute_local: 本轮 prefer_local 生效 (本地分区由全量分钟服务供给, 股票未做补拉)
return {"data": result, "full_minute_local": full_minute_healthy} return {"data": result, "full_minute_local": full_minute_healthy}
+97
View File
@@ -12,6 +12,7 @@ mock 范式沿用 test_stocksdk_provider.py (monkeypatch 模块属性)。
from __future__ import annotations from __future__ import annotations
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from pathlib import Path
from threading import Lock from threading import Lock
from unittest.mock import MagicMock from unittest.mock import MagicMock
@@ -316,6 +317,102 @@ def test_get_minute_batch_splits_stock_and_etf(monkeypatch):
assert "510300.SH" in result["data"] assert "510300.SH" in result["data"]
# ---------- 测试 9b: 取到即落盘 + 增量拉取 (尾部落后 / 中间洞 / fresh) ----------
def _bars(symbol: str, dts: list) -> pl.DataFrame:
"""构造 canonical 8 列分钟K帧 (dts 为 datetime 列表)。"""
n = len(dts)
return pl.DataFrame({
"symbol": [symbol] * n,
"datetime": dts,
"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 _endpoint_mocks(monkeypatch, local_df: pl.DataFrame, sync_ret: pl.DataFrame | None):
"""get_minute_batch 的最小 mock: repo/capset + sync/落盘 spy。返回 (捕获, 落盘, request)。"""
from app.api import kline as kline_api
captured: list[dict] = []
def fake_sync(symbols, *, start_time, end_time, batch_size, rpm, asset_type):
captured.append({"symbols": list(symbols), "start": start_time, "asset": asset_type})
return sync_ret if sync_ret is not None else pl.DataFrame()
monkeypatch.setattr(kline_api.kline_sync, "sync_minute_batch", fake_sync)
writes: list[pl.DataFrame] = []
monkeypatch.setattr(kline_api.kline_sync, "_write_minute_partition",
lambda df, d: writes.append(df))
mock_repo = MagicMock()
mock_repo.get_etf_symbol_set.return_value = set()
mock_repo.get_minute_batch.return_value = local_df
mock_repo._write_lock = Lock()
# 真实 Path 才会触发落盘分支 (kline.py 的 isinstance 守卫)
mock_repo.store.data_dir = Path("data")
mock_capset = MagicMock()
mock_capset.has.return_value = True
mock_capset.limits.return_value = None
mock_request = MagicMock()
mock_request.app.state.repo = mock_repo
mock_request.app.state.capabilities = mock_capset
return captured, writes, mock_request
def test_minute_batch_tail_stale_pulls_incremental_and_persists(monkeypatch):
"""尾部落后 (本地连续但根数不足) → 从最后一根+1min 增量拉;
拉取结果落盘, 响应为本地+增量合并去重。"""
from app.api import kline as kline_api
local = _bars("600519.SH", [
datetime(2026, 1, 15, 9, 31), datetime(2026, 1, 15, 9, 32), datetime(2026, 1, 15, 9, 33),
])
inc = _bars("600519.SH", [datetime(2026, 1, 15, 9, 34), datetime(2026, 1, 15, 9, 35)])
captured, writes, req = _endpoint_mocks(monkeypatch, local, sync_ret=inc)
result = kline_api.get_minute_batch(req, {"symbols": ["600519.SH"], "date": "2026-01-15"})
assert len(captured) == 1
assert captured[0]["start"] == datetime(2026, 1, 15, 9, 34) # 最后一根 + 1min
assert captured[0]["asset"] == "stock"
assert writes and writes[0].height == 2 # 取到即落盘
rows = result["data"]["600519.SH"]
assert len(rows) == 5 # 3 本地 + 2 增量
assert rows[-1]["datetime"] == datetime(2026, 1, 15, 9, 35)
def test_minute_batch_middle_hole_falls_back_to_full_day(monkeypatch):
"""中间缺K (间距 2min) → 增量窗口永远回看不到洞, 必须退回全天重拉。"""
from app.api import kline as kline_api
local = _bars("600519.SH", [datetime(2026, 1, 15, 9, 31), datetime(2026, 1, 15, 9, 33)])
captured, _, req = _endpoint_mocks(monkeypatch, local, sync_ret=pl.DataFrame())
kline_api.get_minute_batch(req, {"symbols": ["600519.SH"], "date": "2026-01-15"})
assert len(captured) == 1
assert captured[0]["start"] == datetime(2026, 1, 15, 9, 25) # 全天窗口
def test_minute_batch_fresh_local_skips_pull(monkeypatch):
"""本地完整 (240 根, 含午休 91min 间距) → 不发任何拉取请求, 直读本地。"""
from app.api import kline as kline_api
dts = ([datetime(2026, 1, 15, 9, 31) + timedelta(minutes=i) for i in range(120)]
+ [datetime(2026, 1, 15, 13, 1) + timedelta(minutes=i) for i in range(120)])
local = _bars("600519.SH", dts)
captured, writes, req = _endpoint_mocks(monkeypatch, local, sync_ret=pl.DataFrame())
result = kline_api.get_minute_batch(req, {"symbols": ["600519.SH"], "date": "2026-01-15"})
assert captured == [] # 零请求
assert writes == []
assert len(result["data"]["600519.SH"]) == 240
# ---------- 测试 10: sync_minute_batch 自定义源成功时调 on_segment (Issue 1) ---------- # ---------- 测试 10: sync_minute_batch 自定义源成功时调 on_segment (Issue 1) ----------
def test_sync_minute_batch_custom_calls_on_segment(monkeypatch): def test_sync_minute_batch_custom_calls_on_segment(monkeypatch):