mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 02:16:13 +00:00
fix(minute): 完善弹窗适配、批量补齐及停牌体检
拆分同步获取和数据体检页签,固定弹窗头尾并改善窄屏布局。 复用现有同步任务定向批量补拉缺口,刷新跨页面分钟缓存。 结合日线单独统计整日零成交记录,避免停牌残留反复报缺口。 验证:独立暂存快照中后端 185 项、前端 16 项测试通过; 前端构建成功,Ruff 无新增问题,git diff --check 通过。 Signed-off-by: shy3130 <shy3130@qq.com>
This commit is contained in:
+71
-27
@@ -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:
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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")
|
||||
@@ -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), 不能丢弃去问源。
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user