fix(minute): 完善弹窗适配、批量补齐及停牌体检

拆分同步获取和数据体检页签,固定弹窗头尾并改善窄屏布局。
复用现有同步任务定向批量补拉缺口,刷新跨页面分钟缓存。
结合日线单独统计整日零成交记录,避免停牌残留反复报缺口。

验证:独立暂存快照中后端 185 项、前端 16 项测试通过;
前端构建成功,Ruff 无新增问题,git diff --check 通过。

Signed-off-by: shy3130 <shy3130@qq.com>
This commit is contained in:
shy3130
2026-10-31 21:29:17 +08:00
parent 39c9ab7319
commit 864bbc49d8
15 changed files with 964 additions and 144 deletions
+71 -27
View File
@@ -1384,8 +1384,31 @@ async def sync_minute(request: Request):
)
async def trigger_minute_sync(repo, capset, *, override_days=None, extend_flag=None) -> dict:
"""触发分钟K同步/向前扩展后台任务(HTTP 端点与 AI 助手共用同一条触发路径)。"""
@router.post("/repair_minute_gaps")
async def repair_minute_gaps(request: Request, body: dict):
"""定向批量补拉缺口标的, 返回现有管道任务 ID; 不承诺源窗口外的缺口可补齐。"""
from app.services.minute_gaps import MAX_GAP_ROWS
symbols = body.get("symbols")
if not isinstance(symbols, list) or not symbols:
raise HTTPException(status_code=400, detail="symbols 必须是非空标的列表")
if len(symbols) > MAX_GAP_ROWS:
raise HTTPException(status_code=400, detail=f"一次最多补拉 {MAX_GAP_ROWS} 只标的")
if any(not isinstance(symbol, str) or not symbol.strip() for symbol in symbols):
raise HTTPException(status_code=400, detail="标的代码必须是非空字符串")
symbols = list(dict.fromkeys(symbol.strip() for symbol in symbols))
repo = request.app.state.repo
if any(repo.resolve_asset_type(symbol) != "stock" for symbol in symbols):
raise HTTPException(status_code=400, detail="分钟缺口补拉仅支持股票, ETF 和指数请使用各自的数据入口")
return await trigger_minute_sync(
repo, request.app.state.capabilities, override_days=10, repair_symbols=symbols,
)
async def trigger_minute_sync(
repo, capset, *, override_days=None, extend_flag=None, repair_symbols: list[str] | None = None,
) -> dict:
"""复用分钟同步的分块落盘、容量限制和取消机制; 缺口补拉限定标的且强制回溯。"""
import asyncio
from app.api.data import invalidate_storage_cache
@@ -1397,14 +1420,17 @@ async def trigger_minute_sync(repo, capset, *, override_days=None, extend_flag=N
try_acquire_run_slot,
)
from app.services.preferences import get_minute_sync_days
from app.tickflow.capabilities import Cap
from app.tickflow.pools import get_pool
if not _minute_allowed(capset):
if repair_symbols is not None:
raise HTTPException(status_code=403, detail="当前分钟数据源不可用, 请先在设置中配置可用数据源")
raise HTTPException(status_code=403, detail="需要 Pro+ 权限")
# 分钟K全市场同步是长任务(数据量是日K的 ~240 倍),用更宽松的卡死阈值
job_id, is_new = job_store.create(long_running=True)
if not is_new:
if repair_symbols is not None:
raise HTTPException(status_code=409, detail="已有数据任务正在运行, 请等待完成后再批量补拉")
return {"status": "reused", "job_id": job_id}
async def task() -> None:
@@ -1417,20 +1443,23 @@ async def trigger_minute_sync(repo, capset, *, override_days=None, extend_flag=N
job_store.progress(job_id, stage, pct, msg)
try:
progress("sync_minute", 5, "解析标的池…")
# 标的池 = instruments 维表 + 自选 (单一来源, 不拉 CN_Equity_A/SW1 池)
universe: set[str] = set(get_pool("watchlist"))
inst_path = repo.store.data_dir / "instruments" / "instruments.parquet"
if inst_path.exists():
try:
import polars as pl
inst = pl.read_parquet(inst_path, columns=["symbol"])
universe.update(inst["symbol"].to_list())
except Exception: # noqa: BLE001
pass
# 剔除指数 symbol: 指数分钟K无本地存储, 落库会污染 kline_minute
index_set = repo.get_index_symbol_set()
universe = sorted({s for s in universe if s not in index_set})
progress("sync_minute", 5, "准备补拉缺口标的…" if repair_symbols is not None else "解析标的池…")
if repair_symbols is not None:
universe = repair_symbols
else:
# 标的池 = instruments 维表 + 自选 (单一来源, 不拉 CN_Equity_A/SW1 池)
all_symbols = set(get_pool("watchlist"))
inst_path = repo.store.data_dir / "instruments" / "instruments.parquet"
if inst_path.exists():
try:
import polars as pl
inst = pl.read_parquet(inst_path, columns=["symbol"])
all_symbols.update(inst["symbol"].to_list())
except Exception: # noqa: BLE001
pass
# 剔除指数 symbol: 指数分钟K无本地存储, 落库会污染 kline_minute
index_set = repo.get_index_symbol_set()
universe = sorted(s for s in all_symbols if s not in index_set)
progress("sync_minute", 10, f"标的池 {len(universe)} 只")
days = override_days if override_days else get_minute_sync_days()
@@ -1440,22 +1469,37 @@ async def trigger_minute_sync(repo, capset, *, override_days=None, extend_flag=N
def _on_chunk(done: int, total: int, seg_label: str) -> None:
# 进度映射: 10% (标的池解析完) → 95%, 留 5% 给写入+刷新
pct = 10 + int((done / max(total, 1)) * 85)
progress("sync_minute", pct, f"拉取分钟K… {done}/{total} 批 [{seg_label}]")
action = "补拉缺口分钟K" if repair_symbols is not None else "拉取分钟K"
progress("sync_minute", pct, f"{action}… {done}/{total} 批 [{seg_label}]")
def _run():
return kline_sync.sync_and_persist_minute(
universe, repo, capset, days=days,
extend_backward=extend_backward,
on_chunk_done=_on_chunk,
)
from app.jobs.daily_pipeline import _refresh_single_view
try:
if repair_symbols is not None:
progress("sync_minute", 10, f"补拉 {len(universe)} 只缺口标的的分钟K…")
return kline_sync.sync_and_persist_minute(
universe, repo, capset, days=days,
extend_backward=extend_backward,
force_full_days=repair_symbols is not None,
on_chunk_done=_on_chunk,
)
finally:
if repair_symbols is not None:
# 补拉分块已写入的数据在失败/取消后也要可见, 刷新仍占用原执行槽。
_refresh_single_view(repo, "kline_minute")
written = await loop.run_in_executor(_long_task_executor, run_with_capacity, job_id, _run)
if repair_symbols is None:
from app.jobs.daily_pipeline import _refresh_single_view
# 刷新视图
from app.jobs.daily_pipeline import _refresh_single_view
_refresh_single_view(repo, "kline_minute")
_refresh_single_view(repo, "kline_minute")
progress("done", 100, f"分钟 K 同步完成,{written} 行")
message = (
f"缺口分钟补拉完成, 写入 {written} 行; 是否补齐以体检结果为准"
if repair_symbols is not None else f"分钟 K 同步完成,{written} 行"
)
progress("done", 100, message)
job_store.succeed(job_id, {"minute_rows": written, "universe_size": len(universe)})
invalidate_storage_cache()
except JobCancelledError:
+50 -3
View File
@@ -10,6 +10,8 @@
- 实盘落盘常见 241 根 (含 09:30 与 15:00 两个边界) 或 238~241 浮动的少量差异,
因此与 /api/kline/minute 的 is_complete 同口径: >= expected * 0.9 视为完整。
- 当日 (北京今天) 按已开盘分钟数折算 expected, 避免把盘中正常进度误报为缺口。
- 整日成交量/额均为零且本地日线没有成交的记录单独计数, 不把停牌残留当作缺口。
日线缺失、读失败或成交字段未知时保留原判定, 避免掩盖真实缺失。
"""
from __future__ import annotations
@@ -73,6 +75,28 @@ def _expected_bars_for(trade_date: _date, today: _date, now_hm: tuple[int, int])
return FULL_DAY_BARS
def _daily_active_symbols(daily_dir: Path, date_str: str) -> set[str] | None:
"""用同日股票日线核对成交; None 表示无法核对, 不能排除零成交分钟记录。"""
part = daily_dir / f"date={date_str}" / "part.parquet"
if not part.exists():
return None
try:
if not {"symbol", "volume", "amount"}.issubset(pl.read_parquet_schema(part)):
return None
daily = pl.read_parquet(part, columns=["symbol", "volume", "amount"])
if daily.is_empty():
return None
daily = daily.with_columns(pl.col(c).cast(pl.Float64, strict=False) for c in ("volume", "amount"))
# 非零或未知字段都不能作为「无成交」依据。零成交分钟在正常交易日也会出现,
# 因而只在标的整日零成交、且日线没有相反证据时排除该标的日。
return set(daily.filter(
(pl.col("volume") != 0).fill_null(True) | (pl.col("amount") != 0).fill_null(True)
)["symbol"].to_list())
except Exception as e: # noqa: BLE001
logger.warning("分钟体检日线核对失败 %s: %s", part, e)
return None
def scan_minute_gaps(
minute_dir: Path,
*,
@@ -90,6 +114,7 @@ def scan_minute_gaps(
return {
"status": "ok", "scanned_dates": 0, "scanned_symbols": 0,
"complete_dates": [], "gaps": [], "summary_by_date": [], "truncated": False,
"inactive_symbol_days": 0,
}
symbol_set = set(symbols) if symbols else None
@@ -99,6 +124,7 @@ def scan_minute_gaps(
scanned_dates = 0
scanned_symbols: set[str] = set()
complete_dates: list[str] = []
inactive_symbol_days = 0
truncated = False
for part in sorted(minute_dir.glob("date=*/part.parquet")):
@@ -113,7 +139,9 @@ def scan_minute_gaps(
if expected <= 0:
continue # 未开盘的当日分区不判定
try:
df = pl.read_parquet(part, columns=["symbol", "datetime"])
schema = pl.read_parquet_schema(part)
activity_columns = [c for c in ("volume", "amount") if c in schema]
df = pl.read_parquet(part, columns=["symbol", "datetime", *activity_columns])
except Exception as e: # noqa: BLE001
logger.warning("分钟分区读取失败 %s: %s", part, e)
continue
@@ -127,11 +155,29 @@ def scan_minute_gaps(
continue
scanned_symbols.update(df["symbol"].unique().to_list())
agg = df.group_by("symbol").agg(
aggregations = [
pl.len().alias("n"),
pl.col("datetime").min().alias("first"),
pl.col("datetime").max().alias("last"),
)
]
if len(activity_columns) == 2:
aggregations.append(
pl.all_horizontal(
(pl.col(c).cast(pl.Float64, strict=False) == 0).fill_null(False)
for c in activity_columns
).all().alias("zero_activity")
)
agg = df.group_by("symbol").agg(aggregations)
if "zero_activity" in agg.columns and agg["zero_activity"].any():
daily_active = _daily_active_symbols(minute_dir.parent / "kline_daily", date_str)
if daily_active is not None:
inactive = agg.filter(
pl.col("zero_activity") & ~pl.col("symbol").is_in(list(daily_active))
)["symbol"].to_list()
inactive_symbol_days += len(inactive)
agg = agg.filter(~pl.col("symbol").is_in(inactive))
if agg.is_empty():
continue # 无成交记录不标记为完整交易日, 也没有可补齐的分钟缺口
threshold = expected * COMPLETE_RATIO
bad = agg.filter(pl.col("n") < threshold)
if bad.is_empty():
@@ -165,6 +211,7 @@ def scan_minute_gaps(
"status": "ok",
"scanned_dates": scanned_dates,
"scanned_symbols": len(scanned_symbols),
"inactive_symbol_days": inactive_symbol_days,
"complete_dates": sorted(complete_dates),
"gaps": [g.to_dict() for g in gaps],
"summary_by_date": sorted(by_date.values(), key=lambda c: c["date"]),
+155
View File
@@ -0,0 +1,155 @@
"""分钟缺口一键补拉: 定向批量、任务互斥、失败与取消后的可见性。"""
from __future__ import annotations
import threading
import time
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api import kline
from app.services import heavy_job_limiter, pipeline_jobs, preferences
from app.services.heavy_job_limiter import HeavyJobLimiter
from app.services.pipeline_jobs import JobStore
def wait_for(predicate):
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
if predicate():
return
time.sleep(0.005)
raise AssertionError("分钟补拉任务未在测试期限内完成")
@pytest.fixture
def repair_env(monkeypatch, tmp_path):
monkeypatch.setattr(preferences, "load", lambda: {})
monkeypatch.setattr(pipeline_jobs, "_CANCEL_FLAGS", {})
monkeypatch.setattr(pipeline_jobs, "_run_slot_owner", None)
store = JobStore(store_dir=tmp_path / "jobs")
limiter = HeavyJobLimiter(capacity=2, cancel_poll_interval=0.005)
monkeypatch.setattr(pipeline_jobs, "job_store", store)
monkeypatch.setattr(heavy_job_limiter, "shared_heavy_job_limiter", limiter)
monkeypatch.setattr(kline, "_minute_allowed", lambda _: True)
sync = MagicMock(return_value=480)
refresh = MagicMock()
invalidate = MagicMock()
pool = MagicMock(return_value=["600519.SH"])
monkeypatch.setattr(kline.kline_sync, "sync_and_persist_minute", sync)
monkeypatch.setattr("app.jobs.daily_pipeline._refresh_single_view", refresh)
monkeypatch.setattr("app.api.data.invalidate_storage_cache", invalidate)
monkeypatch.setattr("app.tickflow.pools.get_pool", pool)
app = FastAPI()
app.include_router(kline.router)
app.state.repo = SimpleNamespace(
store=SimpleNamespace(data_dir=tmp_path),
resolve_asset_type=lambda symbol: {"000001.SH": "index", "510300.SH": "etf"}.get(symbol, "stock"),
get_index_symbol_set=lambda: {"000001.SH"},
)
app.state.capabilities = object()
with TestClient(app) as client:
yield SimpleNamespace(
client=client, store=store, limiter=limiter, repo=app.state.repo,
sync=sync, refresh=refresh, invalidate=invalidate, pool=pool,
)
def test_repair_deduplicates_symbols_and_forces_historical_window(repair_env):
env = repair_env
response = env.client.post("/api/kline/repair_minute_gaps", json={
"symbols": ["600000.SH", " 000001.SZ ", "600000.SH"],
})
assert response.status_code == 200, response.text
result = response.json()
wait_for(lambda: env.store.get(result["job_id"])["status"] == "succeeded")
args, kwargs = env.sync.call_args
assert args[0] == ["600000.SH", "000001.SZ"]
assert kwargs["days"] == 10
assert kwargs["force_full_days"] is True
assert kwargs["extend_backward"] is False
assert env.store.get(result["job_id"])["result"] == {"minute_rows": 480, "universe_size": 2}
env.pool.assert_not_called()
env.refresh.assert_called_once_with(env.repo, "kline_minute")
env.invalidate.assert_called()
@pytest.mark.parametrize("symbols", [None, [], "600000.SH", [None], [""], [" "], [123],
["600000.SH"] * 2001, ["000001.SH"], ["510300.SH"]])
def test_repair_rejects_invalid_scope_before_creating_job(repair_env, symbols):
env = repair_env
response = env.client.post("/api/kline/repair_minute_gaps", json={"symbols": symbols})
assert response.status_code == 400
env.sync.assert_not_called()
assert env.store.active_id() is None
def test_repair_requires_usable_minute_provider(repair_env, monkeypatch):
monkeypatch.setattr(kline, "_minute_allowed", lambda _: False)
response = repair_env.client.post("/api/kline/repair_minute_gaps", json={"symbols": ["600000.SH"]})
assert response.status_code == 403
assert "数据源" in response.json()["detail"]
repair_env.sync.assert_not_called()
def test_repair_does_not_reuse_unrelated_pending_job(repair_env):
env = repair_env
existing_id, _ = env.store.create()
response = env.client.post("/api/kline/repair_minute_gaps", json={"symbols": ["600000.SH"]})
assert response.status_code == 409
assert env.store.active_id() == existing_id
env.sync.assert_not_called()
def test_failed_repair_refreshes_partial_data_and_reports_error(repair_env):
env = repair_env
env.sync.side_effect = ValueError("分钟数据源连接中断")
response = env.client.post("/api/kline/repair_minute_gaps", json={"symbols": ["600000.SH"]})
assert response.status_code == 200
job_id = response.json()["job_id"]
wait_for(lambda: env.store.get(job_id)["status"] == "failed")
assert "分钟数据源连接中断" in env.store.get(job_id)["error"]
env.refresh.assert_called_once_with(env.repo, "kline_minute")
env.invalidate.assert_called()
wait_for(lambda: env.limiter.in_use == 0)
def test_zero_rows_are_reported_without_claiming_gaps_fixed(repair_env):
env = repair_env
env.sync.return_value = 0
response = env.client.post("/api/kline/repair_minute_gaps", json={"symbols": ["600000.SH"]})
assert response.status_code == 200
job_id = response.json()["job_id"]
wait_for(lambda: env.store.get(job_id)["status"] == "succeeded")
job = env.store.get(job_id)
assert job["result"]["minute_rows"] == 0
assert "补齐成功" not in str(job["log"])
def test_cancelled_repair_refreshes_partial_data_and_releases_capacity(repair_env):
env = repair_env
entered, finish = threading.Event(), threading.Event()
def sync(*args, **kwargs):
entered.set()
assert finish.wait(3)
kwargs["on_chunk_done"](1, 2, "最近窗口")
raise AssertionError("取消后不得继续拉取")
env.sync.side_effect = sync
response = env.client.post("/api/kline/repair_minute_gaps", json={"symbols": ["600000.SH"]})
assert response.status_code == 200
job_id = response.json()["job_id"]
try:
assert entered.wait(1)
env.store.terminate(job_id, "用户停止分钟补拉")
finally:
finish.set()
wait_for(lambda: env.limiter.in_use == 0)
assert env.store.get(job_id)["status"] == "failed"
assert "用户停止" in env.store.get(job_id)["error"]
assert env.store.get(job_id)["stage"] == "sync_minute"
env.refresh.assert_called_once_with(env.repo, "kline_minute")
+107
View File
@@ -115,6 +115,89 @@ def test_missing_dir_returns_empty(tmp_path):
assert r["scanned_dates"] == 0
def _zero_activity(minute_dir: Path, trade_date: str, symbol: str) -> None:
part = minute_dir / f"date={trade_date}" / "part.parquet"
df = pl.read_parquet(part).with_columns(
pl.when(pl.col("symbol") == symbol).then(0.0).otherwise(pl.col(c)).alias(c)
for c in ("volume", "amount")
)
df.write_parquet(part)
def _write_daily(daily_dir: Path, trade_date: str, symbol: str, volume: float) -> None:
part = daily_dir / f"date={trade_date}" / "part.parquet"
part.parent.mkdir(parents=True, exist_ok=True)
pl.DataFrame({"symbol": [symbol], "volume": [volume], "amount": [volume * 10]}).write_parquet(part)
@pytest.mark.parametrize("count", [171, 23])
def test_zero_activity_residue_is_separate_from_real_gaps(tmp_path, count):
"""停牌残留不会被合并补拉清除, 不能把它反复列为可补齐缺口。"""
minute_dir, daily_dir = tmp_path / "kline_minute", tmp_path / "kline_daily"
day = "2026-10-20"
_write_partition(minute_dir, day, _full_day("002731.SZ")[:count] + _full_day("600519.SH")[:100])
_zero_activity(minute_dir, day, "002731.SZ")
_write_daily(daily_dir, day, "600519.SH", 1000)
r = scan_minute_gaps(minute_dir, today=_BJ, now_hm=(20, 0))
assert [g["symbol"] for g in r["gaps"]] == ["600519.SH"]
assert r["gaps"][0]["actual_bars"] == 100
assert r["inactive_symbol_days"] == 1
assert r["summary_by_date"][0]["gap_symbols"] == 1
@pytest.mark.parametrize("daily_state", ["traded", "missing", "unknown", "corrupt", "empty"])
def test_zero_activity_is_still_a_gap_without_daily_support(tmp_path, daily_state):
"""日线有成交或无法核对时保留告警, 不用零成交量掩盖真实数据缺失。"""
minute_dir, daily_dir = tmp_path / "kline_minute", tmp_path / "kline_daily"
day = "2026-10-20"
_write_partition(minute_dir, day, _full_day("002731.SZ")[:171])
_zero_activity(minute_dir, day, "002731.SZ")
if daily_state != "missing":
_write_daily(daily_dir, day, "002731.SZ", 1000)
part = daily_dir / f"date={day}" / "part.parquet"
if daily_state == "unknown":
pl.read_parquet(part).with_columns(
pl.lit(None).cast(pl.Float64).alias("volume"), pl.lit(0.0).alias("amount"),
).write_parquet(part)
elif daily_state == "corrupt":
part.write_bytes(b"not parquet")
elif daily_state == "empty":
pl.read_parquet(part).head(0).write_parquet(part)
r = scan_minute_gaps(minute_dir, today=_BJ, now_hm=(20, 0))
assert len(r["gaps"]) == 1
assert r["inactive_symbol_days"] == 0
@pytest.mark.parametrize("activity", ["volume", "amount", "unknown_volume", "missing_amount"])
def test_partial_day_with_activity_or_unknown_fields_keeps_gap(tmp_path, activity):
minute_dir, daily_dir = tmp_path / "kline_minute", tmp_path / "kline_daily"
day = "2026-10-20"
_write_partition(minute_dir, day, _full_day("002731.SZ")[:171])
_zero_activity(minute_dir, day, "002731.SZ")
part = minute_dir / f"date={day}" / "part.parquet"
df = pl.read_parquet(part)
if activity == "missing_amount":
df = df.drop("amount")
else:
column = "volume" if activity == "unknown_volume" else activity
value = None if activity == "unknown_volume" else 1.0
df = df.with_columns(
pl.when(pl.col("datetime") == df["datetime"][0]).then(value).otherwise(pl.col(column)).alias(column)
)
df.write_parquet(part)
_write_daily(daily_dir, day, "600519.SH", 1000)
r = scan_minute_gaps(minute_dir, today=_BJ, now_hm=(20, 0))
assert len(r["gaps"]) == 1
assert r["gaps"][0]["actual_bars"] == 171
assert r["inactive_symbol_days"] == 0
# ---------- /api/kline/minute 的 local_partial 返回 ----------
def _fake_req(repo):
@@ -122,6 +205,30 @@ def _fake_req(repo):
return type("R", (), {"app": type("A", (), {"state": state})()})()
def test_gap_api_exposes_inactive_days_without_changing_source_data(tmp_path, monkeypatch):
from types import SimpleNamespace
from app.api import kline as kline_api
minute_dir, daily_dir = tmp_path / "kline_minute", tmp_path / "kline_daily"
day = "2026-10-20"
_write_partition(minute_dir, day, _full_day("002731.SZ")[:171])
_zero_activity(minute_dir, day, "002731.SZ")
_write_daily(daily_dir, day, "600519.SH", 1000)
part = minute_dir / f"date={day}" / "part.parquet"
before = part.read_bytes()
monkeypatch.setattr(kline_api, "cn_now", lambda: _dt.datetime(2026, 10, 23, 20))
monkeypatch.setattr(kline_api, "cn_today", lambda: _BJ)
repo = SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path))
result = kline_api.get_minute_gaps(_fake_req(repo), symbols="002731.SZ", dates=day, limit=500)
assert result["gaps"] == []
assert result["complete_dates"] == []
assert result["inactive_symbol_days"] == 1
assert part.read_bytes() == before
def test_get_minute_returns_partial_instead_of_dropping(tmp_path, monkeypatch):
"""本地 171 根必须原样返回 (source=local_partial), 不能丢弃去问源。
+3
View File
@@ -174,6 +174,8 @@ def test_in_process_backtests_share_capacity(capacity, monkeypatch, kind):
("/api/kline/rebuild_enriched", {}, "app.indicators.pipeline", "run_pipeline", 3),
("/api/kline/sync_minute", {"days": 1},
"app.services.kline_sync", "sync_and_persist_minute", 3),
("/api/kline/repair_minute_gaps", {"symbols": ["600000.SH"]},
"app.services.kline_sync", "sync_and_persist_minute", 3),
])
def test_api_jobs_wait_before_computing(
capacity, monkeypatch, tmp_path, endpoint, body, module_name, function_name, result,
@@ -205,6 +207,7 @@ def test_api_jobs_wait_before_computing(
clear_cache=lambda: None,
refresh_cache=lambda: None,
get_index_symbol_set=lambda: set(),
resolve_asset_type=lambda _: "stock",
# rebuild_enriched 收尾经 repo.refresh_view 重建视图 (不再直连 db.execute)
refresh_view=lambda *args, **kwargs: True,
)
@@ -19,6 +19,7 @@ vi.mock('@/lib/api', () => ({ api: {
watchlistList: vi.fn(async () => ({ symbols: [] })),
watchlistEnriched: vi.fn(async () => ({ rows: [] })),
pipelineJobs: vi.fn(async () => ({ active_id: null, items: [] })),
dataIntegrity: vi.fn(async () => ({ report: null, blocking_assets: [], data_revisions: {} })),
alertsList: vi.fn(async () => ({ total: 0 })),
quoteStatus: vi.fn(),
updateRealtimeQuotes: vi.fn(async (enabled: boolean) => ({
@@ -100,3 +101,22 @@ it('当前源已不可用时不复用旧可用状态开启', async () => {
expect(api.updateRealtimeQuotes).not.toHaveBeenCalled()
expect(toast).toHaveBeenCalledWith(expect.stringContaining('数据源'), 'error')
})
it.each(['succeeded', 'failed'] as const)('离开数据页后, 分钟补拉 %s 仍刷新体检和分钟缓存', async status => {
vi.mocked(api.pipelineJobs).mockResolvedValue({ active_id: null, jobs: [{
id: 'repair-job', status, stage: status === 'succeeded' ? 'done' : 'sync_minute', progress: 100,
stage_pct: 100, started_at: null, finished_at: null, duration_s: 1,
result: status === 'succeeded' ? { minute_rows: 480, universe_size: 2 } : null,
error: status === 'failed' ? '部分补拉失败' : null,
}] })
const keys = [QK.minuteGaps, QK.minuteBatch(), QK.klineMinute('600000.SH', '2026-10-20'), QK.klineMinuteRange('600000.SH', 10)]
keys.forEach(key => client.setQueryData(key, {}))
const unaffected = QK.indexMinute('000001.SH', '2026-10-20')
client.setQueryData(unaffected, {})
await act(async () => root.render(
<MemoryRouter><QueryClientProvider client={client}><Layout /></QueryClientProvider></MemoryRouter>,
))
await act(async () => { await new Promise(r => setTimeout(r, 20)) })
keys.forEach(key => expect(client.getQueryState(key)?.isInvalidated).toBe(true))
expect(client.getQueryState(unaffected)?.isInvalidated).toBe(false)
})
+12 -1
View File
@@ -21,7 +21,7 @@ import { useUpdateCheck } from '@/lib/updateCheck'
import {
useToggleRealtimeQuotes,
} from '@/lib/useSharedMutations'
import { QK } from '@/lib/queryKeys'
import { QK, MINUTE_DATA_QUERY_PREFIXES } from '@/lib/queryKeys'
import { setUiPref } from '@/lib/uiPrefs'
import { getPageScale } from '@/lib/pageZoom'
import { setKlineBatchLimits } from '@/lib/klineBatchLimits'
@@ -493,6 +493,17 @@ export function Layout() {
}, [isDataSyncing])
const qc = useQueryClient()
const latestDataJob = pipelineJobs?.jobs?.[0]
const refreshedMinuteJob = useRef<string | null>(null)
useEffect(() => {
if (!latestDataJob || !['succeeded', 'failed'].includes(latestDataJob.status)) return
if (latestDataJob.result?.minute_rows == null && !['sync_minute', 'extend_minute'].includes(latestDataJob.stage)) return
if (refreshedMinuteJob.current === latestDataJob.id) return
refreshedMinuteJob.current = latestDataJob.id
// 复用常驻的任务轮询, 离开数据页后补拉结束也会刷新; 取消/失败可能已写入部分数据。
qc.invalidateQueries({ predicate: q => MINUTE_DATA_QUERY_PREFIXES.has(String(q.queryKey[0])) })
qc.invalidateQueries({ queryKey: QK.dataStatus })
}, [latestDataJob?.id, latestDataJob?.status, qc])
const navigate = useNavigate()
const version = versionData?.version
const realtimeEnabled = prefs?.realtime_quotes_enabled ?? false
@@ -0,0 +1,82 @@
/* 按面板实际可用宽度切换, 同时兼容侧栏、窄窗口和应用内界面缩放。 */
.minute-gap-list {
container-type: inline-size;
}
.minute-gap-columns {
display: none;
}
.minute-gap-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 0.75rem;
}
.minute-gap-identity {
display: flex;
min-width: 0;
flex-direction: column;
gap: 0.25rem;
}
.minute-gap-metrics {
display: flex;
grid-column: 1 / -1;
grid-row: 2;
flex-wrap: wrap;
gap: 0.5rem 1.5rem;
}
.minute-gap-metric {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.minute-gap-repair {
grid-column: 2;
grid-row: 1;
}
@container (min-width: 42rem) {
.minute-gap-columns,
.minute-gap-row {
display: grid;
grid-template-columns: minmax(6.5rem, 1fr) 6.25rem 5.5rem 4rem 7rem 5rem;
align-items: center;
column-gap: 0.75rem;
}
.minute-gap-identity,
.minute-gap-metrics {
display: contents;
}
.minute-gap-label {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
.minute-gap-metric {
align-items: flex-end;
}
.minute-gap-range {
align-items: flex-start;
}
.minute-gap-repair {
grid-column: auto;
grid-row: auto;
justify-self: end;
}
}
+146 -81
View File
@@ -1,21 +1,22 @@
/**
* 分钟数据缺口体检面板 (数据页 · 分钟 K 卡片 → 设置弹窗内)。
*
* 只做「发现」和「按需补拉单只」, 不批量承诺补齐:
* 发现缺口后可按单只或当前筛选结果批量补拉, 不承诺所有缺口均可补齐:
* 上游分钟源通常只提供最近 N 天, 超出窗口的历史缺口补不回来 —— 盲目重试会
* 白等几十秒且返回空。因此这里把缺口如实列出、明确标注可尝试补齐, 让用户
* 知道「同步完成」不等于「数据完整」。
*/
import { useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { AlertTriangle, CheckCircle2, Loader2, RefreshCw, Search } from 'lucide-react'
import { AlertTriangle, CheckCircle2, Download, Loader2, RefreshCw, Search } from 'lucide-react'
import { api, type MinuteGapRow } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { QK, MINUTE_DATA_QUERY_PREFIXES } from '@/lib/queryKeys'
import { toast } from '@/components/Toast'
import './MinuteGapPanel.css'
const PAGE = 50
const PAGE = 10
export function MinuteGapPanel() {
export function MinuteGapPanel({ hasCap = true, onJobStart }: { hasCap?: boolean; onJobStart?: (jobId: string) => void }) {
const qc = useQueryClient()
const [filter, setFilter] = useState('')
const [page, setPage] = useState(0)
@@ -32,8 +33,8 @@ export function MinuteGapPanel() {
onSuccess: (r, symbol) => {
const rows = (r as { rows?: number })?.rows ?? 0
toast(rows > 0 ? `${symbol} 已拉取 ${rows} 行分钟数据` : `${symbol} 未拉到新数据(源可能不提供该历史窗口)`, rows > 0 ? 'success' : 'error')
qc.invalidateQueries({ queryKey: QK.minuteGaps })
qc.invalidateQueries({ queryKey: ['kline-minute'] })
qc.invalidateQueries({ predicate: q => MINUTE_DATA_QUERY_PREFIXES.has(String(q.queryKey[0])) })
qc.invalidateQueries({ queryKey: QK.dataStatus })
},
onError: (e: unknown) => toast(`补齐失败: ${String((e as Error)?.message ?? e)}`, 'error'),
onSettled: () => setBusySymbol(null),
@@ -44,8 +45,18 @@ export function MinuteGapPanel() {
const kw = filter.trim().toUpperCase()
return kw ? all.filter(g => g.symbol.toUpperCase().includes(kw) || g.date.includes(kw)) : all
}, [all, filter])
const pageRows = filtered.slice(page * PAGE, page * PAGE + PAGE)
const repairSymbols = useMemo(() => [...new Set(filtered.map(g => g.symbol))], [filtered])
const batchRepair = useMutation({
mutationFn: (symbols: string[]) => api.repairMinuteGaps(symbols),
onSuccess: (result, symbols) => {
qc.invalidateQueries({ queryKey: QK.pipelineJobs })
toast(`已启动 ${symbols.length} 只标的的缺口补拉,可在数据页查看进度`, 'success')
onJobStart?.(result.job_id)
},
})
const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE))
const currentPage = Math.min(page, pageCount - 1)
const pageRows = filtered.slice(currentPage * PAGE, (currentPage + 1) * PAGE)
if (gaps.isLoading) {
return (
@@ -67,33 +78,66 @@ export function MinuteGapPanel() {
const totalMissing = summary.reduce((n, c) => n + c.missing_bars, 0)
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px]">
<span className="text-muted">
扫描 {gaps.data?.scanned_dates ?? 0} 个交易日 · {gaps.data?.scanned_symbols ?? 0} 只标的
</span>
{filtered.length === 0 ? (
<span className="inline-flex items-center gap-1 text-emerald-400">
<CheckCircle2 className="h-3.5 w-3.5" />
未发现分钟数据缺口
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3 text-[11px]">
<div className="space-y-1.5">
<span className="text-muted">
扫描 {gaps.data?.scanned_dates ?? 0} 个交易日 · {gaps.data?.scanned_symbols ?? 0} 只标的
</span>
) : (
<span className="inline-flex items-center gap-1 text-warning">
<AlertTriangle className="h-3.5 w-3.5" />
{filtered.length} 条缺口 · 合计缺 {totalMissing.toLocaleString()} 根
</span>
)}
<button
type="button"
onClick={() => gaps.refetch()}
disabled={gaps.isFetching}
className="ml-auto inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-[11px] text-secondary hover:text-accent disabled:opacity-50"
>
<RefreshCw className={`h-3 w-3 ${gaps.isFetching ? 'animate-spin' : ''}`} />
重新体检
</button>
{all.length === 0 ? (
<span className="flex items-center gap-1 text-bear">
<CheckCircle2 className="h-3.5 w-3.5" />
未发现分钟数据缺口
</span>
) : (
<span className="flex items-center gap-1 text-warning">
<AlertTriangle className="h-3.5 w-3.5" />
{all.length} 条缺口 · 合计缺 {totalMissing.toLocaleString()} 根
</span>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
{all.length > 0 && (
<button
type="button"
onClick={() => batchRepair.mutate(repairSymbols)}
disabled={!hasCap || repairSymbols.length === 0 || busySymbol !== null || batchRepair.isPending || gaps.isFetching}
className="inline-flex shrink-0 items-center gap-1.5 rounded-btn bg-accent/90 px-3 py-1.5 text-[11px] font-medium text-white hover:bg-accent disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
>
{batchRepair.isPending ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
{batchRepair.isPending ? '正在启动…' : `一键补齐(${repairSymbols.length} 只)`}
</button>
)}
<button
type="button"
onClick={() => gaps.refetch()}
disabled={gaps.isFetching || batchRepair.isPending || busySymbol !== null}
className="inline-flex shrink-0 items-center gap-1 rounded-btn border border-border px-2.5 py-1.5 text-[11px] text-secondary hover:text-accent disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
>
<RefreshCw className={`h-3 w-3 ${gaps.isFetching ? 'animate-spin' : ''}`} />
重新体检
</button>
</div>
</div>
{(gaps.data?.inactive_symbol_days ?? 0) > 0 && (
<p className="rounded-btn border border-border/60 bg-base/30 px-3 py-2 text-[11px] leading-relaxed text-muted">
另有 {gaps.data!.inactive_symbol_days} 组整日零成交记录(日线核对无成交,可能停牌),未计入缺口,无需补拉。
</p>
)}
{batchRepair.isError && (
<div role="alert" className="rounded-btn border border-danger/30 bg-danger/5 px-3 py-2 text-xs text-danger">
批量补拉启动失败:{String((batchRepair.error as Error)?.message ?? batchRepair.error)}
</div>
)}
{all.length > 0 && (
<p className="text-[11px] leading-relaxed text-muted">
一键补齐处理{filter.trim() ? '当前筛选的' : '已列出的全部'} {filtered.length} 条缺口,跨页按 {repairSymbols.length} 只标的去重补拉;任务结束后更新体检结果。
{gaps.data?.truncated && <span className="text-warning"> 本次体检列表已截断,仅处理已列出的记录。</span>}
</p>
)}
{summary.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{summary.map(c => (
@@ -104,81 +148,102 @@ export function MinuteGapPanel() {
</div>
)}
{filtered.length > 0 && (
{all.length > 0 && (
<>
<div className="flex items-center gap-1.5">
<Search className="h-3.5 w-3.5 text-muted" />
<div className="flex items-center gap-2 rounded-btn border border-border bg-base/30 px-3 focus-within:border-accent">
<Search className="h-3.5 w-3.5 shrink-0 text-muted" />
<input
value={filter}
onChange={e => { setFilter(e.target.value); setPage(0) }}
placeholder="按代码 / 日期过滤"
className="h-7 flex-1 rounded border border-border bg-surface px-2 text-[11px] text-foreground outline-none focus:border-accent"
aria-label="按代码或日期过滤缺口"
className="h-9 min-w-0 flex-1 bg-transparent text-xs text-foreground outline-none"
/>
</div>
<div className="overflow-hidden rounded border border-border/60">
<div className="grid grid-cols-[minmax(0,1.2fr)_90px_78px_78px_minmax(0,1fr)_74px] border-b border-border bg-base/50 px-2 py-1.5 text-[10px] font-medium text-muted">
<span>代码</span>
<span>日期</span>
<span className="text-right">实有/应有</span>
<span className="text-right">缺</span>
<span>数据范围</span>
<span className="text-right">操作</span>
{filtered.length === 0 ? (
<div role="status" className="rounded-card border border-dashed border-border px-4 py-8 text-center text-xs text-muted">
没有匹配的缺口记录,请尝试其他代码或日期。
</div>
<div className="max-h-72 overflow-y-auto">
) : (
<div className="minute-gap-list rounded-card border border-border/60">
<div aria-hidden="true" className="minute-gap-columns rounded-t-card border-b border-border bg-base/50 px-3 py-2 text-[10px] font-medium text-muted">
<span>代码</span><span>日期</span>
<span className="text-right">实有 / 应有</span><span className="text-right">缺失</span>
<span>数据范围</span><span className="text-right">操作</span>
</div>
<ul aria-label="分钟数据缺口" className="divide-y divide-border/40">
{pageRows.map((g: MinuteGapRow) => (
<div
<li
key={`${g.symbol}-${g.date}`}
className="grid grid-cols-[minmax(0,1.2fr)_90px_78px_78px_minmax(0,1fr)_74px] items-center border-b border-border/40 px-2 py-1 text-[11px] last:border-b-0 hover:bg-elevated/40"
className="minute-gap-row px-3 py-2.5 text-[11px] hover:bg-elevated/40"
>
<span className="truncate font-mono text-foreground" title={g.symbol}>{g.symbol}</span>
<span className="font-mono text-secondary">{g.date}</span>
<span className="text-right font-mono text-warning">{g.actual_bars}/{g.expected_bars}</span>
<span className="text-right font-mono text-muted">-{g.missing_bars}</span>
<span className="truncate font-mono text-[10px] text-muted">
{g.first_time ?? '—'} ~ {g.last_time ?? '—'}
</span>
<span className="text-right">
<div className="minute-gap-identity">
<span className="break-all font-mono text-xs text-foreground">{g.symbol}</span>
<span className="whitespace-nowrap font-mono text-secondary">{g.date}</span>
</div>
<dl className="minute-gap-metrics">
<div className="minute-gap-metric">
<dt className="minute-gap-label text-[10px] text-muted">实有 / 应有</dt>
<dd className="whitespace-nowrap font-mono text-warning">{g.actual_bars}/{g.expected_bars}</dd>
</div>
<div className="minute-gap-metric">
<dt className="minute-gap-label text-[10px] text-muted">缺失</dt>
<dd className="font-mono text-warning">{g.missing_bars} 根</dd>
</div>
<div className="minute-gap-metric minute-gap-range">
<dt className="minute-gap-label text-[10px] text-muted">数据范围</dt>
<dd className="whitespace-nowrap font-mono text-secondary">{g.first_time ?? '—'} ~ {g.last_time ?? '—'}</dd>
</div>
</dl>
<div className="minute-gap-repair">
<button
type="button"
disabled={busySymbol !== null}
disabled={!hasCap || busySymbol !== null || batchRepair.isPending}
onClick={() => { setBusySymbol(g.symbol); repair.mutate(g.symbol) }}
className="rounded border border-border px-1.5 py-0.5 text-[10px] text-secondary hover:border-accent hover:text-accent disabled:opacity-50"
className="whitespace-nowrap rounded-btn border border-border px-2 py-1.5 text-[10px] text-secondary hover:border-accent hover:text-accent disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
>
{busySymbol === g.symbol ? '补齐中…' : '尝试补齐'}
</button>
</span>
</div>
</div>
</li>
))}
</div>
</ul>
</div>
)}
{pageCount > 1 && (
<div className="flex items-center justify-center gap-2 text-[11px] text-muted">
<button
type="button"
onClick={() => setPage(p => Math.max(0, p - 1))}
disabled={page === 0}
className="rounded border border-border px-2 py-0.5 disabled:opacity-40"
>
上一页
</button>
<span>{page + 1} / {pageCount}</span>
<button
type="button"
onClick={() => setPage(p => Math.min(pageCount - 1, p + 1))}
disabled={page >= pageCount - 1}
className="rounded border border-border px-2 py-0.5 disabled:opacity-40"
>
下一页
</button>
{filtered.length > 0 && (
<div className="flex flex-wrap items-center justify-between gap-3 text-[11px] text-muted">
<span>第 {currentPage * PAGE + 1}–{Math.min((currentPage + 1) * PAGE, filtered.length)} 条,共 {filtered.length} 条</span>
{pageCount > 1 && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setPage(Math.max(0, currentPage - 1))}
disabled={currentPage === 0}
className="rounded-btn border border-border px-2 py-1 disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
>
上一页
</button>
<span>{currentPage + 1} / {pageCount}</span>
<button
type="button"
onClick={() => setPage(Math.min(pageCount - 1, currentPage + 1))}
disabled={currentPage >= pageCount - 1}
className="rounded-btn border border-border px-2 py-1 disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
>
下一页
</button>
</div>
)}
</div>
)}
</>
)}
<p className="text-[10px] leading-relaxed text-muted">
「尝试补齐」会向当前分钟数据源重新拉取该标的最近 10 个交易日的分钟 K。
{!hasCap && <p className="text-[11px] text-muted">当前分钟数据源不可用,可查看本地缺口;配置可用数据源后可尝试补齐。</p>}
<p className="border-t border-border/60 pt-3 text-[11px] leading-relaxed text-muted">
补齐会向当前分钟数据源重新拉取标的最近 10 个交易日窗口的分钟 K,批量操作沿用同一规则。
分钟源通常只提供最近一段时间的分钟数据,<span className="text-warning">超出窗口的历史缺口无法补齐</span>,
此时应视为永久残缺 —— 不影响已有数据的查看。
</p>
@@ -0,0 +1,217 @@
// @vitest-environment jsdom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter } from 'react-router-dom'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { api, type MinuteGapRow, type Preferences } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { MinuteSyncConfig } from './MinuteSyncConfig'
vi.mock('@/lib/api', () => ({ api: {
preferences: vi.fn(), updateMinuteSync: vi.fn(), syncMinute: vi.fn(),
clearMinute: vi.fn(), minuteGaps: vi.fn(), syncMinuteSingle: vi.fn(), repairMinuteGaps: vi.fn(),
} }))
vi.mock('@/components/Toast', () => ({ toast: vi.fn() }))
let host: HTMLDivElement
let root: Root
let client: QueryClient
const onJobStart = vi.fn()
const rows: MinuteGapRow[] = Array.from({ length: 24 }, (_, i) => ({
symbol: `${String(i + 1).padStart(6, '0')}.SZ`, date: '2026-10-20',
actual_bars: 171, expected_bars: 240, missing_bars: 69,
first_time: '09:30', last_time: '13:49',
}))
const report = {
status: 'ok', scanned_dates: 10, scanned_symbols: 5580, complete_dates: [],
inactive_symbol_days: 0,
gaps: rows, summary_by_date: [{ date: '2026-10-20', gap_symbols: 24, missing_bars: 1656, expected_bars: 240 }],
truncated: false,
}
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
vi.resetAllMocks()
host = document.createElement('div')
document.body.append(host)
root = createRoot(host)
client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
const prefs = { minute_sync_enabled: false, minute_sync_days: 5, minute_sync_segment_days: 20 } as Preferences
vi.mocked(api.preferences).mockResolvedValue(prefs)
vi.mocked(api.updateMinuteSync).mockResolvedValue(prefs)
vi.mocked(api.minuteGaps).mockResolvedValue(report)
vi.mocked(api.syncMinute).mockResolvedValue({ job_id: 'minute-job', status: 'started' })
vi.mocked(api.repairMinuteGaps).mockResolvedValue({ job_id: 'repair-job', status: 'started' })
})
it('零成交残留单独说明, 不提供无效补拉按钮', async () => {
vi.mocked(api.minuteGaps).mockResolvedValue({ ...report, gaps: [], summary_by_date: [], inactive_symbol_days: 2 })
await render()
await click('数据体检')
expect(host.textContent).toContain('未发现分钟数据缺口')
expect(host.textContent).toContain('另有 2 组整日零成交记录')
expect(host.textContent).not.toContain('一键补齐')
expect(api.repairMinuteGaps).not.toHaveBeenCalled()
})
afterEach(async () => {
await act(async () => root.unmount())
client.clear()
host.remove()
})
async function render(hasCap = true) {
await act(async () => root.render(<MemoryRouter><QueryClientProvider client={client}>
<MinuteSyncConfig hasCap={hasCap} onJobStart={onJobStart} />
</QueryClientProvider></MemoryRouter>))
await settle()
}
async function settle() {
await act(async () => { await new Promise(r => setTimeout(r, 20)) })
}
async function click(text: string) {
const button = [...host.querySelectorAll('button')].find(b => b.textContent?.includes(text))!
expect(button).toBeTruthy()
await act(async () => button.click())
await settle()
}
async function filter(value: string) {
const input = host.querySelector<HTMLInputElement>('input[placeholder="按代码 / 日期过滤"]')!
expect(input).toBeTruthy()
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, value)
input.dispatchEvent(new Event('input', { bubbles: true }))
})
}
it('同步设置和体检分开展示, 切换页签不丢失筛选条件', async () => {
await render()
const panels = host.querySelectorAll<HTMLElement>('[role="tabpanel"]')
expect(panels).toHaveLength(2)
expect(panels[0].hidden).toBe(false)
expect(panels[1].hidden).toBe(true)
await click('数据体检')
expect(panels[0].hidden).toBe(true)
expect(panels[1].hidden).toBe(false)
expect(panels[1].textContent).toContain('000001.SZ')
await filter('000001')
await click('同步与获取')
await click('数据体检')
expect(host.querySelector<HTMLInputElement>('input')?.value).toBe('000001')
})
it('页签支持方向键切换并把键盘焦点移到选中项', async () => {
await render()
const tabs = host.querySelectorAll<HTMLButtonElement>('[role="tab"]')
await act(async () => {
tabs[0].focus()
tabs[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }))
})
expect(tabs[1].getAttribute('aria-selected')).toBe('true')
expect(document.activeElement).toBe(tabs[1])
await act(async () => tabs[1].dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true })))
expect(tabs[0].getAttribute('aria-selected')).toBe('true')
expect(document.activeElement).toBe(tabs[0])
})
it('无匹配记录时保留搜索框和真实缺口状态, 可清除筛选', async () => {
await render()
await click('数据体检')
await filter('不存在')
expect(host.querySelector('input')).not.toBeNull()
expect(host.textContent).toContain('没有匹配的缺口记录')
expect(host.textContent).toContain('24 条缺口')
expect(host.textContent).not.toContain('未发现分钟数据缺口')
await filter('')
expect(host.textContent).toContain('000001.SZ')
})
it('分页限制单页记录, 刷新后记录减少仍能显示有效页', async () => {
await render()
await click('数据体检')
expect(host.textContent).toContain('000010.SZ')
expect(host.textContent).not.toContain('000011.SZ')
await click('下一页')
await click('下一页')
expect(host.textContent).toContain('000024.SZ')
await act(async () => { client.setQueryData(QK.minuteGaps, { ...report, gaps: rows.slice(0, 1) }) })
await settle()
expect(host.textContent).toContain('000001.SZ')
})
it('手动获取仍使用分段天数并通知任务, 清空必须经过二次确认', async () => {
await render()
await click('单次获取 20 天')
expect(api.syncMinute).toHaveBeenCalledWith(20, true)
expect(onJobStart).toHaveBeenCalledWith('minute-job')
await click('清空分钟K数据')
expect(api.clearMinute).not.toHaveBeenCalled()
expect(host.textContent).toContain('确认清空分钟K数据?')
await click('取消')
expect(api.clearMinute).not.toHaveBeenCalled()
})
it('无分钟能力时禁用拉取和补齐, 仍能查看本地体检结果', async () => {
await render(false)
expect([...host.querySelectorAll('button')].find(b => b.textContent?.includes('单次获取'))?.disabled).toBe(true)
await click('数据体检')
const repairs = [...host.querySelectorAll('button')].filter(b => b.textContent?.includes('尝试补齐'))
expect(repairs.length).toBeGreaterThan(0)
expect(repairs.every(b => b.disabled)).toBe(true)
expect([...host.querySelectorAll('button')].find(b => b.textContent?.includes('一键补齐'))?.disabled).toBe(true)
})
it('一键补齐覆盖当前筛选的全部分页, 同一标的只提交一次', async () => {
const repeated = rows.map((r, i) => ({ ...r, symbol: rows[i % 12].symbol, date: i < 12 ? '2026-10-20' : '2026-10-21' }))
vi.mocked(api.minuteGaps).mockResolvedValue({ ...report, gaps: repeated })
await render()
await click('数据体检')
await click('一键补齐')
expect(api.repairMinuteGaps).toHaveBeenCalledTimes(1)
expect(api.repairMinuteGaps).toHaveBeenCalledWith(rows.slice(0, 12).map(r => r.symbol))
expect(api.syncMinuteSingle).not.toHaveBeenCalled()
expect(onJobStart).toHaveBeenCalledWith('repair-job')
})
it('一键补齐遵守代码筛选, 无匹配项时不能发起任务', async () => {
await render()
await click('数据体检')
await filter('不存在')
expect([...host.querySelectorAll('button')].find(b => b.textContent?.includes('一键补齐'))?.disabled).toBe(true)
await filter('000001')
await click('一键补齐')
expect(api.repairMinuteGaps).toHaveBeenCalledWith(['000001.SZ'])
})
it('批量启动期间禁用重复操作, 失败后显示原因并恢复按钮', async () => {
let reject!: (error: Error) => void
vi.mocked(api.repairMinuteGaps).mockImplementation(() => new Promise((_, fail) => { reject = fail }))
await render()
await click('数据体检')
await click('一键补齐')
const repairs = [...host.querySelectorAll('button')].filter(b => b.textContent?.includes('尝试补齐'))
expect(repairs.every(b => b.disabled)).toBe(true)
await act(async () => reject(new Error('已有数据任务正在运行')))
await settle()
expect(host.querySelector('[role="alert"]')?.textContent).toContain('已有数据任务正在运行')
expect([...host.querySelectorAll('button')].find(b => b.textContent?.includes('一键补齐'))?.disabled).toBe(false)
expect(onJobStart).not.toHaveBeenCalled()
})
it('体检加载时显示扫描进度', async () => {
vi.mocked(api.minuteGaps).mockImplementationOnce(() => new Promise(() => {}))
await render()
await click('数据体检')
expect(host.textContent).toContain('正在扫描分钟分区')
})
it('体检覆盖失败和空结果状态', async () => {
vi.mocked(api.minuteGaps).mockResolvedValueOnce({ ...report, gaps: [], summary_by_date: [] })
await render()
await click('数据体检')
expect(host.textContent).toContain('未发现分钟数据缺口')
vi.mocked(api.minuteGaps).mockRejectedValueOnce(new Error('无法读取分钟分区'))
await click('重新体检')
expect(host.textContent).toContain('体检失败')
expect(host.textContent).toContain('无法读取分钟分区')
})
@@ -1,13 +1,17 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useId, useRef } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Loader2, Trash2, Download, Calendar } from 'lucide-react'
import { Loader2, Trash2, Download, Calendar, SlidersHorizontal, Activity } from 'lucide-react'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { MissingCapChip } from '@/lib/capability-labels'
import { MinuteGapPanel } from '@/components/data/MinuteGapPanel'
// hasCap: 分钟K能力当前是否可用 (路由矩阵判定, 生效源含插件/自定义源)
export function MinuteSyncConfig({ hasCap, onJobStart }: { hasCap: boolean; onJobStart?: (jobId: string) => void }) {
export function MinuteSyncConfig({ hasCap, onJobStart, onClose }: { hasCap: boolean; onJobStart?: (jobId: string) => void; onClose?: () => void }) {
const [tab, setTab] = useState<'sync' | 'health'>('sync')
const tabId = useId()
const syncTabRef = useRef<HTMLButtonElement>(null)
const healthTabRef = useRef<HTMLButtonElement>(null)
const qc = useQueryClient()
const prefs = useQuery({
queryKey: QK.preferences,
@@ -76,12 +80,54 @@ export function MinuteSyncConfig({ hasCap, onJobStart }: { hasCap: boolean; onJo
}
return (
<div className="px-4 pb-4 pt-3 border-t border-accent/20 space-y-3">
<div className="flex min-h-0 flex-1 flex-col">
<div
role="tablist"
aria-label="分钟数据管理"
className="flex shrink-0 gap-5 border-b border-border px-4 sm:px-5"
onKeyDown={e => {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) return
e.preventDefault()
const next = e.key === 'Home' ? 'sync' : e.key === 'End' ? 'health' : tab === 'sync' ? 'health' : 'sync'
setTab(next)
;(next === 'sync' ? syncTabRef : healthTabRef).current?.focus()
}}
>
{([
{ key: 'sync', label: '同步与获取', icon: SlidersHorizontal, ref: syncTabRef },
{ key: 'health', label: '数据体检', icon: Activity, ref: healthTabRef },
] as const).map(({ key, label, icon: Icon, ref }) => (
<button
key={key}
ref={ref}
role="tab"
id={`${tabId}-${key}-tab`}
aria-controls={`${tabId}-${key}-panel`}
aria-selected={tab === key}
tabIndex={tab === key ? 0 : -1}
onClick={() => setTab(key)}
className={`inline-flex items-center gap-1.5 border-b-2 py-3 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent/60 ${tab === key ? 'border-accent text-accent' : 'border-transparent text-muted hover:text-foreground'}`}
>
<Icon className="h-3.5 w-3.5" />{label}
</button>
))}
</div>
<div
role="tabpanel"
id={`${tabId}-sync-panel`}
aria-labelledby={`${tabId}-sync-tab`}
hidden={tab !== 'sync'}
tabIndex={0}
className="min-h-0 flex-1 space-y-5 overflow-y-auto overscroll-contain p-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent/60 sm:p-5"
>
{/* 区块 A: 自动同步 (盘后定时拉取的偏好设置) */}
<div className="space-y-2.5">
<div className="flex items-center justify-between">
<div className="space-y-4 rounded-card border border-border bg-base/30 p-3 sm:p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2.5">
<button
role="switch"
aria-label="自动同步"
aria-checked={enabled}
onClick={handleToggle}
disabled={!hasMinuteCap}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-200 shrink-0 ${
@@ -102,6 +148,7 @@ export function MinuteSyncConfig({ hasCap, onJobStart }: { hasCap: boolean; onJo
<div className="flex items-center">
<button
onClick={() => setDays(localDays - 1)}
aria-label="减少自动同步天数"
disabled={!hasMinuteCap || !enabled || localDays <= 1}
className="h-6 w-6 flex items-center justify-center rounded-l-btn bg-elevated border border-border text-secondary hover:bg-border/50 disabled:opacity-30 transition-colors text-xs"
>−</button>
@@ -110,6 +157,7 @@ export function MinuteSyncConfig({ hasCap, onJobStart }: { hasCap: boolean; onJo
</div>
<button
onClick={() => setDays(localDays + 1)}
aria-label="增加自动同步天数"
disabled={!hasMinuteCap || !enabled || localDays >= 30}
className="h-6 w-6 flex items-center justify-center rounded-r-btn bg-elevated border border-border text-secondary hover:bg-border/50 disabled:opacity-30 transition-colors text-xs"
>+</button>
@@ -123,7 +171,7 @@ export function MinuteSyncConfig({ hasCap, onJobStart }: { hasCap: boolean; onJo
{/* 分段大小: 控制单次 SDK 请求覆盖的交易日数。每段拉完即落盘,避免全量攒内存 OOM。
「往前获取」与「获取 1 年」共用此设置 (两者都经过 sync_and_persist_minute)。 */}
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border/60 pt-4">
<div className="flex items-center gap-1.5">
<span className="text-xs text-foreground font-medium">分段大小</span>
<span className="text-[10px] text-muted px-1 py-px rounded bg-warning/8 text-warning/80">内存优化</span>
@@ -132,6 +180,7 @@ export function MinuteSyncConfig({ hasCap, onJobStart }: { hasCap: boolean; onJo
<div className="flex items-center">
<button
onClick={() => setSegment(localSegment - 5)}
aria-label="减小分段大小"
disabled={!hasMinuteCap || localSegment <= 5}
className="h-6 w-6 flex items-center justify-center rounded-l-btn bg-elevated border border-border text-secondary hover:bg-border/50 disabled:opacity-30 transition-colors text-xs"
>−</button>
@@ -140,6 +189,7 @@ export function MinuteSyncConfig({ hasCap, onJobStart }: { hasCap: boolean; onJo
</div>
<button
onClick={() => setSegment(localSegment + 5)}
aria-label="增大分段大小"
disabled={!hasMinuteCap || localSegment >= 30}
className="h-6 w-6 flex items-center justify-center rounded-r-btn bg-elevated border border-border text-secondary hover:bg-border/50 disabled:opacity-30 transition-colors text-xs"
>+</button>
@@ -147,14 +197,14 @@ export function MinuteSyncConfig({ hasCap, onJobStart }: { hasCap: boolean; onJo
<span className="text-[10px] text-muted">交易日/段</span>
</div>
</div>
<div className="text-[10px] text-muted leading-relaxed -mt-1">
每段拉完即写盘,避免内存堆积。越小越省内存但越慢,默认 20 平衡。
<div className="text-[11px] text-muted leading-relaxed">
每段拉完即写盘,避免内存堆积。分段越小越省内存,获取耗时越长;默认 20 个交易日。设置自动保存。
</div>
</div>
{/* 区块 B: 手动获取 (一次性操作, 独立于上方自动同步开关) */}
<div className="pt-3 border-t border-border space-y-2">
<div className="flex items-center gap-1.5">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<Download className="h-3 w-3 text-secondary" />
<span className="text-[11px] text-secondary font-medium">手动获取</span>
<span className="text-[10px] text-muted">不受自动同步开关影响</span>
@@ -163,7 +213,7 @@ export function MinuteSyncConfig({ hasCap, onJobStart }: { hasCap: boolean; onJo
<button
onClick={() => handleFetch('40d')}
disabled={!hasMinuteCap || fetchingMode !== ''}
className="inline-flex flex-col items-center justify-center gap-0.5 px-2 py-2 rounded-btn bg-accent/90 text-foreground text-xs font-medium hover:bg-accent disabled:opacity-40 transition-colors duration-150"
className="inline-flex min-h-20 flex-col items-center justify-center gap-1 px-2 py-3 rounded-btn bg-accent/90 text-white text-xs font-medium hover:bg-accent disabled:opacity-40 transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
>
{fetchingMode === '40d' ? (
<><Loader2 className="h-3.5 w-3.5 animate-spin" /><span>获取中…</span></>
@@ -174,7 +224,7 @@ export function MinuteSyncConfig({ hasCap, onJobStart }: { hasCap: boolean; onJo
<button
onClick={() => handleFetch('1y')}
disabled={!hasMinuteCap || fetchingMode !== ''}
className="inline-flex flex-col items-center justify-center gap-0.5 px-2 py-2 rounded-btn border border-amber-400/40 bg-amber-400/10 text-amber-400 text-xs font-medium hover:bg-amber-400/20 disabled:opacity-40 transition-colors duration-150"
className="inline-flex min-h-20 flex-col items-center justify-center gap-1 px-2 py-3 rounded-btn border border-accent/30 bg-accent/5 text-accent text-xs font-medium hover:bg-accent/10 disabled:opacity-40 transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
>
{fetchingMode === '1y' ? (
<><Loader2 className="h-3.5 w-3.5 animate-spin" /><span>分段获取中…</span></>
@@ -183,33 +233,35 @@ export function MinuteSyncConfig({ hasCap, onJobStart }: { hasCap: boolean; onJo
)}
</button>
</div>
<div className="text-[10px] text-muted leading-relaxed">
<div className="text-[11px] text-muted leading-relaxed">
A股标的 · 前复权价格 · 从本地最早数据向前叠加 ·{' '}
均按上方「分段大小」分段拉取、每段即落盘
</div>
</div>
{/* 区块 D: 缺口体检 (发现本地分区中不完整的 标的×交易日) */}
<div className="space-y-2 border-t border-border/40 pt-2.5">
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium text-foreground">数据体检</span>
<span className="rounded bg-warning/10 px-1 py-px text-[10px] text-warning/90">
找出残缺的交易日照
</span>
</div>
<MinuteGapPanel />
</div>
<div
role="tabpanel"
id={`${tabId}-health-panel`}
aria-labelledby={`${tabId}-health-tab`}
hidden={tab !== 'health'}
tabIndex={0}
className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent/60 sm:p-5"
>
<MinuteGapPanel hasCap={hasMinuteCap} onJobStart={onJobStart} />
</div>
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-border bg-base/20 px-4 py-3 sm:px-5">
{/* 区块 C: 清空 (危险操作, 独立分隔) */}
<button
onClick={() => setConfirmClear(true)}
disabled={clearMutation.isPending}
title="清空分钟K数据"
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-btn border border-danger/30 text-danger/80 text-xs font-medium hover:bg-danger/10 disabled:opacity-40 transition-colors duration-150"
className="inline-flex items-center justify-center gap-1.5 px-2.5 py-2 rounded-btn text-danger/80 text-[11px] font-medium hover:bg-danger/10 disabled:opacity-40 transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-danger/60"
>
<Trash2 className="h-3 w-3" />
清空分钟K数据
</button>
{onClose && <button onClick={onClose} className="rounded-btn border border-border bg-elevated px-4 py-1.5 text-xs text-secondary hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60">关闭</button>}
</div>
{/* 清空确认弹窗 */}
{confirmClear && (
+10 -4
View File
@@ -2,12 +2,14 @@ import { motion } from 'framer-motion'
import { X } from 'lucide-react'
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
export function SettingsModal({ title, onClose, children, width = 'max-w-md' }: {
export function SettingsModal({ title, onClose, children, width = 'max-w-md', contentClassName = 'min-h-0 flex-1 overflow-y-auto p-4 sm:p-5' }: {
title: string
onClose: () => void
children: React.ReactNode
/** 弹窗最大宽度 (Tailwind max-w-* 类), 默认 max-w-md */
width?: string
/** 有固定页签/操作栏时, 由内容组件管理唯一的滚动区。 */
contentClassName?: string
}) {
const backdrop = useDialogBackdrop(onClose)
return (
@@ -18,15 +20,19 @@ export function SettingsModal({ title, onClose, children, width = 'max-w-md' }:
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97, y: 8 }}
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
className={`relative rounded-card border border-border bg-surface shadow-2xl mx-4 w-full ${width} max-h-[88vh] flex flex-col overflow-hidden`}
role="dialog"
aria-modal="true"
aria-label={title}
// 百分比跟随 #root 的缩放补偿高度, vh 会在界面放大后把底部推出视口。
className={`relative rounded-dialog border border-border bg-surface shadow-2xl mx-4 w-full ${width} max-h-[92%] flex flex-col overflow-hidden`}
>
<div className="flex items-center justify-between px-5 py-3 border-b border-border shrink-0">
<h3 className="text-sm font-medium text-foreground">{title}</h3>
<button onClick={onClose} className="p-0.5 rounded hover:bg-elevated text-secondary">
<button onClick={onClose} aria-label="关闭" className="p-1 rounded hover:bg-elevated text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60">
<X className="h-4 w-4" />
</button>
</div>
<div className="p-5 flex-1 overflow-y-auto">
<div className={contentClassName}>
{children}
</div>
</motion.div>
+6
View File
@@ -2832,6 +2832,7 @@ export const api = {
scanned_dates: number
scanned_symbols: number
complete_dates: string[]
inactive_symbol_days?: number
gaps: MinuteGapRow[]
summary_by_date: { date: string; gap_symbols: number; missing_bars: number; expected_bars: number }[]
truncated: boolean
@@ -3231,6 +3232,11 @@ export const api = {
method: 'POST',
body: JSON.stringify({ symbol, ...(days != null ? { days } : {}) }),
}),
repairMinuteGaps: (symbols: string[]) =>
request<{ status: string; job_id: string }>('/api/kline/repair_minute_gaps', {
method: 'POST',
body: JSON.stringify({ symbols }),
}),
clearMinute: () =>
request<{ status: string; removed: number }>('/api/kline/clear_minute', {
method: 'POST',
+5
View File
@@ -14,6 +14,11 @@ export const DAILY_DATA_QUERY_PREFIXES = new Set([
'regime-history', 'regime-latest', 'regime-states', 'regime-coverage', 'regime-phases', 'regime-mainline',
])
/** 分钟落盘补拉后的体检与直接消费查询, 不影响日K/财务或独立的指数分钟数据。 */
export const MINUTE_DATA_QUERY_PREFIXES = new Set([
'minute-gaps', 'minute-batch', 'kline-minute', 'kline-minute-range',
])
export const QK = {
// 全局 / 共享 (Layout 预取)
capabilities: ['capabilities'] as const,
+2 -2
View File
@@ -1217,8 +1217,8 @@ export function Data() {
<AnimatePresence>
{openSettings === 'minute' && (
<SettingsModal title="分钟 K · 同步设置" onClose={() => setOpenSettings(null)}>
<MinuteSyncConfig hasCap={hasMinuteCap} onJobStart={(jobId) => { setActiveJobId(jobId); setOpenSettings(null) }} />
<SettingsModal title="分钟 K · 同步设置" width="max-w-4xl" contentClassName="flex min-h-0 flex-1 flex-col overflow-hidden" onClose={() => setOpenSettings(null)}>
<MinuteSyncConfig hasCap={hasMinuteCap} onClose={() => setOpenSettings(null)} onJobStart={(jobId) => { setActiveJobId(jobId); setOpenSettings(null) }} />
</SettingsModal>
)}
</AnimatePresence>