mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
用户反馈自选/策略列表分时列「今天能看、昨天不能看、前天又能看」。 根因有两处: 1. /api/kline/minute 把「不完整」等同于「无数据」直接丢弃, 转而请求 分钟源; 而分钟源通常只提供最近 N 天历史, 补拉历史残缺日期恒为空, 前端白屏 —— 用户看到「同步完成却显示不出来」。 修复: 历史日期本地有行时优先返回已有行 (source=local_partial), 不再 为补不回来的缺口丢弃可用数据; 分时图顶部提示「不完整 x/240 根」并 提供「尝试补齐」。当日盘中保持原行为: 本地落后于实时源是常态, 仍走 补拉 (见 test_minute_lunch_break_still_pulls_when_morning_incomplete)。 2. 「同步完成」不等于「数据完整」, 缺口只能靠用户撞见白屏才被发现。 新增 GET /api/kline/minute-gaps 体检: 扫描本地 kline_minute 分区, 列出不完整的 (symbol, 交易日), 判定口径与 /api/kline/minute 的 is_complete 一致 (>= expected*0.9 视为完整; 当日按已开盘分钟折算, 不误报盘中进度)。只做发现不做批量补齐 —— 超出上游窗口的缺口补不 回来, 盲目重试只是白等, 面板如实标注可补/超窗。 前端: 数据页分钟K卡片新增「数据体检」区块 (MinuteGapPanel), 按交易日 汇总缺口并支持按需补拉单只。 验证: 后端 65 passed (含新增缺口扫描/当日折算/窗口外标注用例); 前端 pnpm build 通过。 Signed-off-by: shy3130 <shy3130@qq.com>
168 lines
6.7 KiB
Python
168 lines
6.7 KiB
Python
"""分钟数据缺口体检 (services/minute_gaps) 与 /api/kline/minute 的 partial 返回。"""
|
|
from __future__ import annotations
|
|
|
|
import datetime as _dt
|
|
from pathlib import Path
|
|
|
|
import polars as pl
|
|
import pytest
|
|
|
|
from app.services.minute_gaps import FULL_DAY_BARS, scan_minute_gaps
|
|
|
|
_BJ = _dt.date(2026, 10, 23)
|
|
|
|
|
|
def _write_partition(minute_dir: Path, trade_date: str, rows: list[tuple[str, str]]) -> None:
|
|
"""写一个 date=YYYY-MM-DD/part.parquet, rows 为 (symbol, 'HH:MM')。"""
|
|
d = minute_dir / f"date={trade_date}"
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
pl.DataFrame({
|
|
"symbol": [r[0] for r in rows],
|
|
"datetime": [_dt.datetime.fromisoformat(f"{trade_date}T{t}:00") for _, t in rows],
|
|
"open": [10.0] * len(rows),
|
|
"high": [10.0] * len(rows),
|
|
"low": [10.0] * len(rows),
|
|
"close": [10.0] * len(rows),
|
|
"volume": [1.0] * len(rows),
|
|
"amount": [1.0] * len(rows),
|
|
}).write_parquet(d / "part.parquet")
|
|
|
|
|
|
def _full_day(symbol: str) -> list[tuple[str, str]]:
|
|
"""完整交易日分钟序列 (与实盘落盘同口径: 09:30~11:30 + 13:00~15:00, 241 根)。
|
|
|
|
注意: 实盘分区常见 241 根 (两个端点都含), 完整性阈值是 expected*0.9,
|
|
所以 241 根也判为完整 —— 与本服务的容差口径一致。
|
|
"""
|
|
times: list[str] = []
|
|
t = _dt.datetime(2026, 1, 1, 9, 30)
|
|
while t <= _dt.datetime(2026, 1, 1, 11, 30):
|
|
times.append(t.strftime("%H:%M"))
|
|
t += _dt.timedelta(minutes=1)
|
|
t = _dt.datetime(2026, 1, 1, 13, 0)
|
|
while t <= _dt.datetime(2026, 1, 1, 15, 0):
|
|
times.append(t.strftime("%H:%M"))
|
|
t += _dt.timedelta(minutes=1)
|
|
return [(symbol, hm) for hm in times]
|
|
|
|
|
|
def test_complete_day_reports_no_gap(tmp_path):
|
|
minute_dir = tmp_path / "kline_minute"
|
|
_write_partition(minute_dir, "2026-10-22", _full_day("600519.SH"))
|
|
r = scan_minute_gaps(minute_dir, today=_BJ, now_hm=(20, 0))
|
|
assert r["gaps"] == []
|
|
assert r["complete_dates"] == ["2026-10-22"]
|
|
|
|
|
|
def test_partial_day_reported_with_time_range(tmp_path):
|
|
minute_dir = tmp_path / "kline_minute"
|
|
# 171 根, 截到 13:49 (复现 2026-10-20 的真实缺口形态: 午后中断)
|
|
rows = _full_day("600519.SH")[:171]
|
|
_write_partition(minute_dir, "2026-10-20", rows)
|
|
r = scan_minute_gaps(minute_dir, today=_BJ, now_hm=(20, 0))
|
|
assert len(r["gaps"]) == 1
|
|
g = r["gaps"][0]
|
|
assert g["symbol"] == "600519.SH"
|
|
assert g["date"] == "2026-10-20"
|
|
assert g["actual_bars"] == 171
|
|
assert g["expected_bars"] == FULL_DAY_BARS
|
|
assert g["missing_bars"] == 69
|
|
assert g["last_time"] == "13:49"
|
|
|
|
|
|
def test_tail_only_sequence_also_reported(tmp_path):
|
|
"""只有尾盘一段 (复现 2026-10-21 的尾部残序列) 同样算缺口。"""
|
|
minute_dir = tmp_path / "kline_minute"
|
|
full = _full_day("002388.SZ")
|
|
_write_partition(minute_dir, "2026-10-21", full[-23:])
|
|
r = scan_minute_gaps(minute_dir, today=_BJ, now_hm=(20, 0))
|
|
assert len(r["gaps"]) == 1
|
|
assert r["gaps"][0]["first_time"] == "14:38"
|
|
assert r["gaps"][0]["last_time"] == "15:00"
|
|
|
|
|
|
def test_symbol_filter_narrows_scan(tmp_path):
|
|
minute_dir = tmp_path / "kline_minute"
|
|
rows = _full_day("A.SZ")[:100] + _full_day("B.SZ")[:100]
|
|
_write_partition(minute_dir, "2026-10-20", rows)
|
|
r = scan_minute_gaps(minute_dir, today=_BJ, now_hm=(20, 0), symbols=["A.SZ"])
|
|
assert {g["symbol"] for g in r["gaps"]} == {"A.SZ"}
|
|
|
|
|
|
def test_summary_by_date_aggregates_missing_bars(tmp_path):
|
|
minute_dir = tmp_path / "kline_minute"
|
|
rows = _full_day("A.SZ")[:171] + _full_day("B.SZ")[:120]
|
|
_write_partition(minute_dir, "2026-10-20", rows)
|
|
r = scan_minute_gaps(minute_dir, today=_BJ, now_hm=(20, 0))
|
|
cell = r["summary_by_date"][0]
|
|
assert cell["gap_symbols"] == 2
|
|
assert cell["missing_bars"] == (240 - 171) + (240 - 120)
|
|
|
|
|
|
def test_intraday_expected_scales_with_clock(tmp_path):
|
|
"""当日盘中: expected 按已开盘分钟折算, 正常进度不应误报缺口。"""
|
|
minute_dir = tmp_path / "kline_minute"
|
|
today_str = "2026-10-23"
|
|
rows = _full_day("600519.SH")[:90] # 11:00 应有 90 根
|
|
_write_partition(minute_dir, today_str, rows)
|
|
r = scan_minute_gaps(minute_dir, today=_BJ, now_hm=(11, 0))
|
|
assert r["gaps"] == [] # 90 >= 90*0.9, 属正常进度
|
|
|
|
|
|
def test_missing_dir_returns_empty(tmp_path):
|
|
r = scan_minute_gaps(tmp_path / "nope", today=_BJ, now_hm=(20, 0))
|
|
assert r["gaps"] == []
|
|
assert r["scanned_dates"] == 0
|
|
|
|
|
|
# ---------- /api/kline/minute 的 local_partial 返回 ----------
|
|
|
|
def _fake_req(repo):
|
|
state = type("S", (), {"repo": repo})()
|
|
return type("R", (), {"app": type("A", (), {"state": state})()})()
|
|
|
|
|
|
def test_get_minute_returns_partial_instead_of_dropping(tmp_path, monkeypatch):
|
|
"""本地 171 根必须原样返回 (source=local_partial), 不能丢弃去问源。
|
|
|
|
回归: 此前把不完整当无数据 → 补拉历史日期恒空 → 前端白屏。
|
|
"""
|
|
from app.api import kline as kline_api
|
|
from app.tickflow.capabilities import CapabilitySet
|
|
from app.tickflow.repository import DataStore, KlineRepository
|
|
|
|
repo = KlineRepository(DataStore(tmp_path))
|
|
rows = _full_day("600519.SH")[:171]
|
|
day_df = pl.DataFrame({
|
|
"symbol": [r[0] for r in rows],
|
|
"datetime": [_dt.datetime.fromisoformat(f"2026-10-20T{t}:00") for _, t in rows],
|
|
"open": [10.0] * len(rows), "high": [10.0] * len(rows),
|
|
"low": [10.0] * len(rows), "close": [10.0] * len(rows),
|
|
"volume": [1.0] * len(rows), "amount": [1.0] * len(rows),
|
|
})
|
|
monkeypatch.setattr(repo, "get_minute", lambda *a, **kw: day_df)
|
|
monkeypatch.setattr(repo, "resolve_asset_type", lambda s: "stock")
|
|
monkeypatch.setattr(kline_api, "_get_stock_info", lambda *a, **kw: {"name": "测试"})
|
|
monkeypatch.setattr(kline_api, "_get_previous_closes", lambda *a, **kw: {})
|
|
monkeypatch.setattr(kline_api, "_get_price_limit_info", lambda *a, **kw: None)
|
|
monkeypatch.setattr(kline_api, "cn_today", lambda: _BJ)
|
|
monkeypatch.setattr(kline_api.minute_adjust, "minute_basis_is_raw", lambda d: False)
|
|
|
|
# 补拉绝不能被调用 (否则等于又走回丢弃路径)
|
|
called = {"n": 0}
|
|
def _no_pull(*a, **kw):
|
|
called["n"] += 1
|
|
return pl.DataFrame()
|
|
monkeypatch.setattr(kline_api.kline_sync, "fetch_minute_single", _no_pull)
|
|
|
|
state = type("S", (), {"repo": repo, "capabilities": CapabilitySet()})()
|
|
req = type("R", (), {"app": type("A", (), {"state": state}), "headers": {}})()
|
|
|
|
out = kline_api.get_minute(req, symbol="600519.SH", trade_date=_dt.date(2026, 10, 20), live=False)
|
|
|
|
assert out["source"] == "local_partial"
|
|
assert len(out["rows"]) == 171
|
|
assert out["expected"] == 240
|
|
assert out["actual"] == 171
|
|
assert called["n"] == 0, "有本地残缺数据时不应再向源补拉"
|