mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
- 数据页提供股票、ETF、指数历史日K自检与报告,区分已确认异常和待核实缺口 - 按异常股票和日期暂存校验、备份并修补,使用完整原始历史重算受影响指标 - 跨重启保留异常与待重算状态,失败或取消后可继续处理 - 修补完成后更新行情、指标、矩阵、策略和前端查询缓存,重算市场环境与主线 - 对已知异常阻止相关计算,提示历史回测和优化结果的数据版本过期 - 复用插件日K取数,修正 ETF 历史回补起点及任务阶段进度重置 补充自检 API、精准修补、失败恢复、资产隔离、缓存失效及前端交互测试。 前端全量 389 项及构建通过,后端执行完整回归。 Signed-off-by: shy3130 <shy3130@qq.com>
1065 lines
39 KiB
Python
1065 lines
39 KiB
Python
"""数据画像 API —— 让前端知道"我们本地有什么数据"。"""
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import logging
|
|
import os
|
|
import threading
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Literal
|
|
|
|
from fastapi import APIRouter, HTTPException, Request
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app.enriched_generation import EnrichedPublication
|
|
from app.indicators.pipeline import ENRICHED_COLUMNS
|
|
from app.services.pipeline_jobs import job_store
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api/data", tags=["data"])
|
|
|
|
|
|
class IntegrityScanIn(BaseModel):
|
|
lookback_days: int = Field(default=30, ge=1, le=366)
|
|
asset_types: list[Literal["stock", "etf", "index"]] | None = Field(default=None, min_length=1, max_length=3)
|
|
|
|
|
|
class IntegrityRepairIn(BaseModel):
|
|
report_id: str = Field(pattern=r"^[a-f0-9]{32}$")
|
|
|
|
|
|
def _start_integrity_job(request: Request, operation: Callable, *, repair: bool) -> dict:
|
|
from app.services.pipeline_jobs import (
|
|
JobCancelledError,
|
|
release_run_slot,
|
|
run_with_capacity,
|
|
try_acquire_run_slot,
|
|
)
|
|
|
|
job_id, is_new = job_store.create()
|
|
if not is_new:
|
|
raise HTTPException(status_code=409, detail="已有数据任务正在运行, 请等待完成后再自检或修补")
|
|
|
|
def progress(stage, pct, message, **kwargs):
|
|
job_store.progress(job_id, stage, pct, message, **kwargs)
|
|
|
|
def run():
|
|
qs = getattr(request.app.state, "quote_service", None)
|
|
with qs.paused() if repair and qs else contextlib.nullcontext():
|
|
return operation(progress)
|
|
|
|
def execute():
|
|
try:
|
|
if not try_acquire_run_slot(job_id):
|
|
job_store.fail(job_id, "已有数据任务尚未结束, 请稍后重试")
|
|
return
|
|
report = run_with_capacity(job_id, run)
|
|
job_store.succeed(job_id, {"integrity_report": report})
|
|
except JobCancelledError:
|
|
pass
|
|
except Exception as exc:
|
|
logger.exception("日K自检/修补失败: %s", job_id)
|
|
job_store.fail(job_id, str(exc))
|
|
finally:
|
|
release_run_slot(job_id)
|
|
invalidate_data_cache()
|
|
qs = getattr(request.app.state, "quote_service", None)
|
|
notify = getattr(qs, "notify_daily_integrity_updated", None)
|
|
if callable(notify):
|
|
from app.services.integrity_check import latest_report
|
|
report = latest_report(request.app.state.repo.store.data_dir)
|
|
notify(report["asset_types"] if report else [], data_changed=repair)
|
|
|
|
try:
|
|
threading.Thread(target=execute, daemon=True, name=f"integrity-{job_id}").start()
|
|
except RuntimeError:
|
|
job_store.fail(job_id, "无法启动数据自检任务")
|
|
raise
|
|
return {"job_id": job_id}
|
|
|
|
|
|
@router.get("/integrity")
|
|
def get_integrity_report(request: Request) -> dict:
|
|
from app.services import daily_health
|
|
from app.services.integrity_check import latest_report
|
|
|
|
root = request.app.state.repo.store.data_dir
|
|
return {"report": latest_report(root), "blocking_assets": sorted({
|
|
e["asset_type"] for e in daily_health.snapshot(root)["entries"]
|
|
}), "data_revisions": {
|
|
asset: daily_health.revision(root, asset) for asset in daily_health.ASSET_LABELS
|
|
}}
|
|
|
|
|
|
@router.post("/integrity/scan")
|
|
def scan_integrity(body: IntegrityScanIn, request: Request) -> dict:
|
|
from app.services.integrity_check import run_check
|
|
|
|
root = request.app.state.repo.store.data_dir
|
|
return _start_integrity_job(request, lambda progress: run_check(
|
|
root, lookback_days=body.lookback_days, asset_types=body.asset_types, on_progress=progress,
|
|
), repair=False)
|
|
|
|
|
|
@router.post("/integrity/repair")
|
|
def repair_integrity(body: IntegrityRepairIn, request: Request) -> dict:
|
|
from app.data_providers import custom
|
|
from app.services import preferences
|
|
from app.services.integrity_check import latest_report, run_repair
|
|
from app.tickflow.capabilities import Cap
|
|
|
|
state = request.app.state
|
|
report = latest_report(state.repo.store.data_dir)
|
|
if report is None or report.get("id") != body.report_id:
|
|
raise HTTPException(status_code=409, detail="自检报告已变化, 请重新自检后再修补")
|
|
if not report.get("repairable_count"):
|
|
raise HTTPException(status_code=400, detail="当前没有可自动修补的问题")
|
|
provider = preferences.get_daily_data_provider()
|
|
custom_daily = provider != "tickflow" and custom.provider_has_dataset(provider, "daily")
|
|
if not custom_daily and not state.capabilities.has(Cap.KLINE_DAILY_BATCH):
|
|
raise HTTPException(status_code=403, detail="当前日K数据源不可用, 请先检查数据源配置")
|
|
return _start_integrity_job(request, lambda progress: run_repair(
|
|
state.repo, state.capabilities, body.report_id, on_progress=progress,
|
|
), repair=True)
|
|
|
|
# ===== 缓存:storage(文件扫描) + 每张表 aggregate 各自缓存 =====
|
|
# 同步期间前端 2s 轮一次 status,每张表 aggregate 全表 count + min/max + distinct
|
|
# 太重,加 TTL + 事件失效。stage 写完只清对应那张表的缓存。
|
|
|
|
_TABLE_TTL = 30.0 # 兜底 TTL,即使没人调 invalidate 也会过期
|
|
_TABLE_TTL_LARGE = 120.0 # 大表(分钟K等)单独 TTL,避免多分区聚合反复重算
|
|
_STORAGE_TTL = 60.0 # storage 文件扫描独立 TTL,stage 写完不触发重算
|
|
|
|
# 聚合慢的大表(分区数多、行数多),使用更长的 TTL
|
|
_LARGE_TABLES = {"minute"}
|
|
|
|
_storage_cache: dict[str, Any] | None = None
|
|
_storage_cache_ts: float = 0.0
|
|
_storage_cache_stale: dict[str, Any] | None = None
|
|
_storage_lock = threading.Lock()
|
|
|
|
_table_cache: dict[str, dict | None] = {
|
|
"daily": None,
|
|
"enriched": None,
|
|
"index_daily": None,
|
|
"index_enriched": None,
|
|
"index_instruments": None,
|
|
"etf_daily": None,
|
|
"etf_enriched": None,
|
|
"etf_instruments": None,
|
|
"minute": None,
|
|
"adj_factor": None,
|
|
"instruments": None,
|
|
"financials": None,
|
|
}
|
|
_table_cache_ts: dict[str, float] = {k: 0.0 for k in _table_cache}
|
|
# 最后一次非空统计 (失效事件不清空): 同步任务期间 /status 的兜底展示 —
|
|
# 宁可数字旧一点, 也不能让重聚合在 DuckDB 锁上排队到前端超时
|
|
_table_cache_stale: dict[str, dict | None] = {}
|
|
_table_cache_lock = threading.Lock()
|
|
|
|
_last_finished_cache: dict[str, str | None] | None = None
|
|
_last_finished_lock = threading.Lock()
|
|
|
|
|
|
def invalidate_data_cache(table: str | None = None) -> None:
|
|
"""数据写入/清除后调用。
|
|
|
|
table=None 时清所有表 cache + storage(粗粒度,用于 pipeline 完成/clear);
|
|
指定 table 时只清那张表,不影响 storage(细粒度,用于单 stage 写完)。
|
|
"""
|
|
with _table_cache_lock:
|
|
if table is None:
|
|
global _storage_cache, _storage_cache_ts, _last_finished_cache
|
|
_storage_cache = None
|
|
_storage_cache_ts = 0.0
|
|
_last_finished_cache = None
|
|
for k in _table_cache:
|
|
_table_cache[k] = None
|
|
_table_cache_ts[k] = 0.0
|
|
elif table in _table_cache:
|
|
_table_cache[table] = None
|
|
_table_cache_ts[table] = 0.0
|
|
|
|
|
|
def invalidate_storage_cache() -> None:
|
|
"""向后兼容入口 — 清全部缓存。新代码请用 invalidate_data_cache(table)。"""
|
|
invalidate_data_cache(None)
|
|
|
|
|
|
def _get_table_stats(
|
|
name: str, fetch: Callable[[], dict | None], *, busy: bool = False
|
|
) -> dict | None:
|
|
"""走 TTL+事件 双重缓存。fetch 在锁外执行避免阻塞别的请求。
|
|
|
|
busy (同步任务运行中 / 指标预热未完成) 时完全不重算, 直接回最后一次非空值
|
|
(可能为 None): 这些聚合夹着 DuckDB 查询, 与同步管道共用 repository 的
|
|
DuckDB 单连接全局锁 — 管道持锁跑长查询时重算会在锁上排队, 2s 的 /status
|
|
轮询堆积后触发前端 30s 超时 (2026-10-28 用户反馈)。任务结束的失效事件
|
|
会清 TTL 缓存, 结束后第一次调用即恢复正常重算。
|
|
"""
|
|
ttl = _TABLE_TTL_LARGE if name in _LARGE_TABLES else _TABLE_TTL
|
|
now = time.time()
|
|
with _table_cache_lock:
|
|
if busy:
|
|
return _table_cache_stale.get(name)
|
|
cached = _table_cache.get(name)
|
|
cached_ts = _table_cache_ts.get(name, 0.0)
|
|
if cached is not None and (now - cached_ts) < ttl:
|
|
return cached
|
|
|
|
fresh = fetch()
|
|
|
|
with _table_cache_lock:
|
|
_table_cache[name] = fresh
|
|
_table_cache_ts[name] = now
|
|
if fresh is not None:
|
|
_table_cache_stale[name] = fresh
|
|
return fresh
|
|
|
|
|
|
def _safe_aggregate(repo, view: str) -> dict | None:
|
|
"""聚合视图基础统计;视图不存在或为空时返 None。"""
|
|
try:
|
|
row = repo.execute_one(
|
|
f"""SELECT count(*) AS rows,
|
|
min(date) AS earliest,
|
|
max(date) AS latest,
|
|
count(DISTINCT symbol) AS symbols,
|
|
count(DISTINCT date) AS trading_days
|
|
FROM {view}"""
|
|
)
|
|
except Exception as e: # noqa: BLE001
|
|
logger.debug("aggregate %s failed: %s", view, e)
|
|
return None
|
|
if not row or not row[0]:
|
|
return None
|
|
return {
|
|
"rows": int(row[0]),
|
|
"earliest_date": str(row[1]) if row[1] else None,
|
|
"latest_date": str(row[2]) if row[2] else None,
|
|
"symbols_covered": int(row[3] or 0),
|
|
"trading_days": int(row[4] or 0),
|
|
}
|
|
|
|
|
|
def _safe_aggregate_daily(repo, view: str = "kline_daily") -> dict | None:
|
|
"""日K轻量统计 — 零数据扫描。
|
|
|
|
从分区目录名获取日期范围和交易日数,不读任何 parquet。
|
|
标的数从 instruments 小表获取(~5000行,毫秒级)。
|
|
"""
|
|
daily_dir = repo.store.data_dir / "kline_daily"
|
|
if not daily_dir.exists():
|
|
return None
|
|
dates: list[str] = []
|
|
for d in daily_dir.iterdir():
|
|
if d.is_dir() and d.name.startswith("date="):
|
|
dates.append(d.name[5:])
|
|
if not dates:
|
|
return None
|
|
dates.sort()
|
|
|
|
symbols = _count_instruments_symbols(repo)
|
|
|
|
return {
|
|
"rows": 0,
|
|
"earliest_date": dates[0],
|
|
"latest_date": dates[-1],
|
|
"symbols_covered": symbols,
|
|
"trading_days": len(dates),
|
|
}
|
|
|
|
|
|
def _safe_aggregate_enriched(repo) -> dict | None:
|
|
"""Enriched 轻量统计 — 零数据扫描。
|
|
|
|
字段数从 DESCRIBE 读 schema(不碰数据),毫秒级。
|
|
日期范围从分区目录名获取(同 minute 策略),不读任何 parquet。
|
|
标的数从 instruments 小表取。
|
|
"""
|
|
# 字段数:读 schema,不碰数据
|
|
fields = 0
|
|
try:
|
|
cols = repo.execute_all("DESCRIBE kline_enriched")
|
|
fields = len(cols)
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
|
|
# 日期范围:从分区目录名获取,不扫数据
|
|
enriched_dir = repo.store.data_dir / "kline_daily_enriched"
|
|
if not enriched_dir.exists():
|
|
return None
|
|
dates: list[str] = []
|
|
for d in enriched_dir.iterdir():
|
|
if d.is_dir() and d.name.startswith("date="):
|
|
dates.append(d.name[5:])
|
|
if not dates:
|
|
return None
|
|
dates.sort()
|
|
|
|
symbols = _count_instruments_symbols(repo)
|
|
|
|
return {
|
|
"rows": 0,
|
|
"fields": fields,
|
|
"earliest_date": dates[0],
|
|
"latest_date": dates[-1],
|
|
"symbols_covered": symbols,
|
|
"trading_days": len(dates),
|
|
}
|
|
|
|
|
|
def _count_instruments_symbols(repo) -> int:
|
|
"""从 instruments 小表取标的数(~5000行,毫秒级)。"""
|
|
try:
|
|
sym_row = repo.execute_one(
|
|
"SELECT count(DISTINCT symbol) FROM instruments"
|
|
)
|
|
if sym_row and sym_row[0]:
|
|
return int(sym_row[0])
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
return 0
|
|
|
|
|
|
def _safe_aggregate_instruments(repo) -> dict | None:
|
|
"""instruments 视图统计(无 date 列,用 as_of)。"""
|
|
try:
|
|
row = repo.execute_one(
|
|
"""SELECT count(*) AS rows,
|
|
count(DISTINCT symbol) AS symbols,
|
|
max(as_of) AS latest_as_of,
|
|
count_if(name IS NOT NULL AND name != '') AS named
|
|
FROM instruments"""
|
|
)
|
|
except Exception as e: # noqa: BLE001
|
|
logger.debug("aggregate instruments failed: %s", e)
|
|
return None
|
|
if not row or not row[0]:
|
|
return None
|
|
return {
|
|
"rows": int(row[0]),
|
|
"symbols_covered": int(row[1] or 0),
|
|
"latest_as_of": str(row[2]) if row[2] else None,
|
|
"named": int(row[3] or 0),
|
|
}
|
|
|
|
|
|
def _safe_aggregate_index_daily(repo) -> dict | None:
|
|
"""指数日K统计。指数数据量较小,直接读取 parquet 元数据统计真实行数。"""
|
|
return _safe_aggregate(repo, "kline_index_daily")
|
|
|
|
|
|
def _safe_aggregate_index_enriched(repo) -> dict | None:
|
|
"""指数 enriched 统计。指数数据量较小,直接读取 parquet 元数据统计真实行数。"""
|
|
fields = 0
|
|
try:
|
|
cols = repo.execute_all("DESCRIBE kline_index_enriched")
|
|
fields = len(cols)
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
stats = _safe_aggregate(repo, "kline_index_enriched")
|
|
if not stats:
|
|
return None
|
|
return {**stats, "fields": fields}
|
|
|
|
|
|
def _safe_aggregate_index_instruments(repo) -> dict | None:
|
|
"""指数 instruments 视图统计。"""
|
|
try:
|
|
row = repo.execute_one(
|
|
"""SELECT count(*) AS rows,
|
|
count(DISTINCT symbol) AS symbols,
|
|
count_if(name IS NOT NULL AND name != '') AS named
|
|
FROM instruments_index"""
|
|
)
|
|
except Exception as e: # noqa: BLE001
|
|
logger.debug("aggregate instruments_index failed: %s", e)
|
|
return None
|
|
if not row or not row[0]:
|
|
return None
|
|
return {
|
|
"rows": int(row[0]),
|
|
"symbols_covered": int(row[1] or 0),
|
|
"latest_as_of": None,
|
|
"named": int(row[2] or 0),
|
|
}
|
|
|
|
|
|
def _safe_aggregate_etf_instruments(repo) -> dict | None:
|
|
"""ETF instruments 统计 — 优先独立 instruments_etf,兼容旧 instruments_index。"""
|
|
queries = [
|
|
"""SELECT count(*) AS rows,
|
|
count(DISTINCT symbol) AS symbols,
|
|
count_if(name IS NOT NULL AND name != '') AS named
|
|
FROM instruments_etf""",
|
|
"""SELECT count(*) AS rows,
|
|
count(DISTINCT symbol) AS symbols,
|
|
count_if(name IS NOT NULL AND name != '') AS named
|
|
FROM instruments_index
|
|
WHERE asset_type = 'etf'""",
|
|
]
|
|
for sql in queries:
|
|
try:
|
|
row = repo.execute_one(sql)
|
|
except Exception as e: # noqa: BLE001
|
|
logger.debug("aggregate etf instruments fallback failed: %s", e)
|
|
continue
|
|
if row and row[0]:
|
|
return {
|
|
"rows": int(row[0]),
|
|
"symbols_covered": int(row[1] or 0),
|
|
"latest_as_of": None,
|
|
"named": int(row[2] or 0),
|
|
}
|
|
return None
|
|
|
|
|
|
def _safe_aggregate_etf_enriched(repo) -> dict | None:
|
|
"""ETF enriched 统计 — 独立 kline_etf_enriched。"""
|
|
fields = 0
|
|
try:
|
|
cols = repo.execute_all("DESCRIBE kline_etf_enriched")
|
|
fields = len(cols)
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
stats = _safe_aggregate(repo, "kline_etf_enriched")
|
|
if not stats:
|
|
return None
|
|
return {**stats, "fields": fields}
|
|
|
|
|
|
def _safe_aggregate_etf_daily(repo) -> dict | None:
|
|
"""ETF 日K统计 — 优先独立 kline_etf_daily,兼容旧 index 存储。"""
|
|
queries = [
|
|
"""SELECT count(*) AS rows,
|
|
min(date) AS earliest,
|
|
max(date) AS latest,
|
|
count(DISTINCT symbol) AS symbols,
|
|
count(DISTINCT date) AS trading_days
|
|
FROM kline_etf_daily""",
|
|
"""SELECT count(*) AS rows,
|
|
min(date) AS earliest,
|
|
max(date) AS latest,
|
|
count(DISTINCT symbol) AS symbols,
|
|
count(DISTINCT date) AS trading_days
|
|
FROM kline_index_daily
|
|
WHERE symbol IN (
|
|
SELECT DISTINCT symbol FROM instruments_index WHERE asset_type = 'etf'
|
|
)""",
|
|
]
|
|
for sql in queries:
|
|
try:
|
|
row = repo.execute_one(sql)
|
|
except Exception as e: # noqa: BLE001
|
|
logger.debug("aggregate etf daily fallback failed: %s", e)
|
|
continue
|
|
if row and row[0]:
|
|
return {
|
|
"rows": int(row[0]),
|
|
"earliest_date": str(row[1]) if row[1] else None,
|
|
"latest_date": str(row[2]) if row[2] else None,
|
|
"symbols_covered": int(row[3] or 0),
|
|
"trading_days": int(row[4] or 0),
|
|
}
|
|
return None
|
|
|
|
|
|
def _safe_aggregate_adj_factor(repo) -> dict | None:
|
|
"""adj_factor 视图统计,日期范围对齐日 K 覆盖区间。"""
|
|
try:
|
|
# 取日 K 的日期范围作为过滤条件
|
|
dr = repo.execute_one(
|
|
"SELECT min(date), max(date) FROM kline_daily"
|
|
)
|
|
if not dr or not dr[0]:
|
|
return None
|
|
d_min, d_max = dr[0], dr[1]
|
|
row = repo.execute_one(
|
|
"""SELECT count(*) AS rows,
|
|
count(DISTINCT symbol) AS symbols,
|
|
count(DISTINCT trade_date) AS trading_days
|
|
FROM adj_factor
|
|
WHERE trade_date BETWEEN ? AND ?""",
|
|
[str(d_min), str(d_max)],
|
|
)
|
|
if not row or not row[0]:
|
|
return None
|
|
return {
|
|
"rows": int(row[0]),
|
|
"symbols_covered": int(row[1]) if isinstance(row[1], (int, float)) else 0,
|
|
"earliest_date": str(d_min),
|
|
"latest_date": str(d_max),
|
|
"trading_days": int(row[2] or 0),
|
|
}
|
|
except Exception as e: # noqa: BLE001
|
|
logger.debug("aggregate adj_factor failed: %s", e)
|
|
return None
|
|
|
|
|
|
def _safe_aggregate_minute(repo) -> dict | None:
|
|
"""kline_minute 统计 — 从分区目录名获取交易日数,跳过全表扫描。
|
|
|
|
分钟 K 按 date=YYYY-MM-DD 分区存储,直接数目录即可,
|
|
无需 count(*) / count(DISTINCT ...) 等昂贵查询。
|
|
"""
|
|
minute_dir = repo.store.data_dir / "kline_minute"
|
|
if not minute_dir.exists():
|
|
return None
|
|
|
|
# 从 date=YYYY-MM-DD 目录名提取交易日
|
|
dates: list[str] = []
|
|
for d in minute_dir.iterdir():
|
|
if d.is_dir() and d.name.startswith("date="):
|
|
dates.append(d.name[5:])
|
|
|
|
if not dates:
|
|
return None
|
|
|
|
dates.sort()
|
|
return {
|
|
"rows": 0, # 不再查询行数
|
|
"earliest_date": dates[0],
|
|
"latest_date": dates[-1],
|
|
"symbols_covered": 0, # 不再查询标的数
|
|
"trading_days": len(dates),
|
|
}
|
|
|
|
|
|
def _safe_aggregate_financials(repo) -> dict | None:
|
|
"""财务数据统计 — 检查各表文件是否存在及行数。"""
|
|
data_dir = repo.store.data_dir
|
|
tables_info: dict[str, dict] = {}
|
|
total_rows = 0
|
|
|
|
for table in ("metrics", "income", "balance_sheet", "cash_flow", "shares"):
|
|
path = data_dir / "financials" / table / "part.parquet"
|
|
if path.exists():
|
|
try:
|
|
import polars as pl
|
|
df = pl.read_parquet(path, columns=["symbol"])
|
|
rows = len(df)
|
|
symbols = df["symbol"].n_unique() if not df.is_empty() else 0
|
|
tables_info[table] = {"rows": rows, "symbols": symbols}
|
|
total_rows += rows
|
|
except Exception:
|
|
tables_info[table] = {"rows": 0, "symbols": 0}
|
|
else:
|
|
tables_info[table] = {"rows": 0, "symbols": 0}
|
|
|
|
if total_rows == 0:
|
|
return None
|
|
|
|
return {
|
|
"rows": total_rows,
|
|
"tables": tables_info,
|
|
}
|
|
|
|
|
|
def _scan_dir_stats(dirpath: Path) -> tuple[int, float]:
|
|
"""单次遍历统计目录下文件数和总大小(MB)。比 rglob+stat 快很多。"""
|
|
if not dirpath.exists():
|
|
return 0, 0.0
|
|
count = 0
|
|
total = 0
|
|
for entry in os.scandir(dirpath):
|
|
if entry.is_dir(follow_symlinks=False):
|
|
c, s = _scan_dir_recursive(entry)
|
|
count += c
|
|
total += s
|
|
elif entry.is_file(follow_symlinks=False):
|
|
try:
|
|
total += entry.stat().st_size
|
|
except OSError:
|
|
pass
|
|
count += 1
|
|
return count, round(total / 1048576, 2)
|
|
|
|
|
|
def _scan_dir_recursive(entry: os.DirEntry) -> tuple[int, int]:
|
|
"""递归统计一个 DirEntry 下的文件数和总字节数。"""
|
|
count = 0
|
|
total = 0
|
|
try:
|
|
for sub in os.scandir(entry.path):
|
|
if sub.is_dir(follow_symlinks=False):
|
|
c, s = _scan_dir_recursive(sub)
|
|
count += c
|
|
total += s
|
|
elif sub.is_file(follow_symlinks=False):
|
|
try:
|
|
total += sub.stat().st_size
|
|
except OSError:
|
|
pass
|
|
count += 1
|
|
except PermissionError:
|
|
pass
|
|
return count, total
|
|
|
|
|
|
def _compute_storage(data_dir: Path) -> dict:
|
|
"""单次遍历计算 storage 统计,避免多次 rglob。"""
|
|
import os
|
|
|
|
# 只统计关心的子目录
|
|
subdirs = {
|
|
"daily": data_dir / "kline_daily",
|
|
"enriched": data_dir / "kline_daily_enriched",
|
|
"index_daily": data_dir / "kline_index_daily",
|
|
"index_enriched": data_dir / "kline_index_enriched",
|
|
"index_instruments": data_dir / "instruments_index",
|
|
"etf_daily": data_dir / "kline_etf_daily",
|
|
"etf_enriched": data_dir / "kline_etf_enriched",
|
|
"etf_instruments": data_dir / "instruments_etf",
|
|
"etf_adj_factor": data_dir / "adj_factor_etf",
|
|
"minute": data_dir / "kline_minute",
|
|
"adj_factor": data_dir / "adj_factor",
|
|
"instruments": data_dir / "instruments",
|
|
"ext_data": data_dir / "ext_data",
|
|
}
|
|
stats = {}
|
|
total_size = 0
|
|
for key, d in subdirs.items():
|
|
fc, sz = _scan_dir_stats(d)
|
|
total_size += sz
|
|
stats[f"{key}_files"] = fc
|
|
stats[f"{key}_size_mb"] = sz
|
|
|
|
# total: 再加上其他零散目录 (financials 有下方专属明细统计, 不在此列)
|
|
other_dirs = ["pools", "backtest_results", "screener_results", "ai_cache"]
|
|
for name in other_dirs:
|
|
d = data_dir / name
|
|
if d.exists():
|
|
_, s = _scan_dir_stats(d)
|
|
total_size += s
|
|
|
|
# financials 单独统计 (明细与 total 各计入一次, 不得与其他目录循环重复累加)
|
|
fin_dir = data_dir / "financials"
|
|
if fin_dir.exists():
|
|
fc, sz = _scan_dir_stats(fin_dir)
|
|
stats["financials_files"] = fc
|
|
stats["financials_size_mb"] = sz
|
|
total_size += sz
|
|
# 根目录散文件
|
|
for entry in os.scandir(data_dir):
|
|
if entry.is_file(follow_symlinks=False):
|
|
try:
|
|
total_size += entry.stat().st_size / 1048576
|
|
except OSError:
|
|
pass
|
|
stats["total_size_mb"] = round(total_size, 2)
|
|
return stats
|
|
|
|
|
|
def _next_cron_run(scheduler, job_id: str) -> str | None:
|
|
"""读 APScheduler 下次执行时间。"""
|
|
if not scheduler:
|
|
return None
|
|
try:
|
|
job = scheduler.get_job(job_id)
|
|
if job and job.next_run_time:
|
|
return job.next_run_time.isoformat(timespec="seconds")
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
return None
|
|
|
|
|
|
def _get_storage(data_dir: Path, *, busy: bool = False) -> dict | None:
|
|
"""返回缓存的 storage 统计;走独立 TTL,stage 写完不触发重算。
|
|
|
|
busy 时同样不重算 (目录 du 在同步写盘期间会显著变慢), 回最后一次值;
|
|
首次调用 (尚无缓存) 返回 None — 契约测试断言 is None, 注解勿再写 dict。"""
|
|
global _storage_cache, _storage_cache_ts, _storage_cache_stale
|
|
now = time.time()
|
|
with _storage_lock:
|
|
if busy:
|
|
return _storage_cache_stale
|
|
if _storage_cache is not None and (now - _storage_cache_ts) < _STORAGE_TTL:
|
|
return _storage_cache
|
|
fresh = _compute_storage(data_dir)
|
|
with _storage_lock:
|
|
_storage_cache = fresh
|
|
_storage_cache_ts = now
|
|
_storage_cache_stale = fresh
|
|
return fresh
|
|
|
|
|
|
def _last_finished(job_label: str) -> str | None:
|
|
"""从 JobStore 读最近一次该类型任务的完成时间(缓存到 pipeline 终态失效)。"""
|
|
global _last_finished_cache
|
|
with _last_finished_lock:
|
|
if _last_finished_cache is not None:
|
|
return _last_finished_cache.get(job_label)
|
|
|
|
from app.services.pipeline_jobs import job_store
|
|
jobs = job_store.list_recent(limit=50)
|
|
cache: dict[str, str | None] = {}
|
|
for j in jobs:
|
|
if j["status"] not in ("succeeded", "failed"):
|
|
continue
|
|
if "instruments_rows" in (j.get("result") or {}) and "instruments" not in cache:
|
|
cache["instruments"] = j["finished_at"]
|
|
if "daily_days" in (j.get("result") or {}) and "pipeline" not in cache:
|
|
cache["pipeline"] = j["finished_at"]
|
|
with _last_finished_lock:
|
|
_last_finished_cache = cache
|
|
return cache.get(job_label)
|
|
|
|
|
|
def _data_pipeline_busy(request: Request) -> bool:
|
|
"""同步任务运行中或指标预热未完成 — /status 的重聚合应退化为纯缓存读。"""
|
|
if not getattr(request.app.state, "indicators_ready", True):
|
|
return True
|
|
active_id = job_store.active_id()
|
|
if not active_id:
|
|
return False
|
|
active = job_store.get(active_id) or {}
|
|
return active.get("status") in ("pending", "running")
|
|
|
|
|
|
@router.get("/status")
|
|
def status(request: Request) -> dict:
|
|
repo = request.app.state.repo
|
|
scheduler = getattr(request.app.state, "scheduler", None)
|
|
data_dir = repo.store.data_dir
|
|
busy = _data_pipeline_busy(request)
|
|
|
|
return {
|
|
"daily": _get_table_stats("daily", lambda: _safe_aggregate_daily(repo), busy=busy),
|
|
"enriched": _get_table_stats("enriched", lambda: _safe_aggregate_enriched(repo), busy=busy),
|
|
"index_daily": _get_table_stats("index_daily", lambda: _safe_aggregate_index_daily(repo), busy=busy),
|
|
"index_enriched": _get_table_stats("index_enriched", lambda: _safe_aggregate_index_enriched(repo), busy=busy),
|
|
"index_instruments": _get_table_stats("index_instruments", lambda: _safe_aggregate_index_instruments(repo), busy=busy),
|
|
"etf_daily": _get_table_stats("etf_daily", lambda: _safe_aggregate_etf_daily(repo), busy=busy),
|
|
"etf_enriched": _get_table_stats("etf_enriched", lambda: _safe_aggregate_etf_enriched(repo), busy=busy),
|
|
"etf_instruments": _get_table_stats("etf_instruments", lambda: _safe_aggregate_etf_instruments(repo), busy=busy),
|
|
"minute": _get_table_stats("minute", lambda: _safe_aggregate_minute(repo), busy=busy),
|
|
"adj_factor": _get_table_stats("adj_factor", lambda: _safe_aggregate_adj_factor(repo), busy=busy),
|
|
"instruments": _get_table_stats("instruments", lambda: _safe_aggregate_instruments(repo), busy=busy),
|
|
"financials": _get_table_stats("financials", lambda: _safe_aggregate_financials(repo), busy=busy),
|
|
|
|
# 文件层面信息(缓存)
|
|
"storage": _get_storage(data_dir, busy=busy),
|
|
|
|
# 调度
|
|
"next_instruments_run": _next_cron_run(scheduler, "pre_market_instruments"),
|
|
"next_pipeline_run": _next_cron_run(scheduler, "daily_pipeline"),
|
|
"last_instruments_run": _last_finished("instruments"),
|
|
"last_pipeline_run": _last_finished("pipeline"),
|
|
"checked_at": datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"),
|
|
# 同步/修复期间同样可能返回 null 统计, 不可把它当成无本地数据。
|
|
"stats_pending": busy,
|
|
# 指标缓存就绪标志 (启动时 enriched 异步预热, 完成前为 false)
|
|
"indicators_ready": getattr(request.app.state, "indicators_ready", True),
|
|
}
|
|
|
|
|
|
@router.post("/clear")
|
|
def clear_data(request: Request):
|
|
"""清除所有本地 Parquet 数据(保留 capabilities.json 和目录结构)。
|
|
|
|
互斥保护 (与其他破坏性路径 pipeline/repair/rebuild 一致):
|
|
- 重任务执行槽: 有数据任务在运行 (或上次任务卡死未结束) 时 fail-closed
|
|
拒绝清空 — 一边删除一边写入会让终态既非"已清空"也非"完整同步";
|
|
- 清空期间暂停实时行情取数, 防止轮询线程覆写同一批 parquet。
|
|
"""
|
|
from app.services.pipeline_jobs import release_run_slot, try_acquire_run_slot
|
|
|
|
if not try_acquire_run_slot("data_clear"):
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="有数据任务在运行(或上一次任务卡死未结束),请先停止任务或稍后再试",
|
|
)
|
|
qs = getattr(request.app.state, "quote_service", None)
|
|
# 全量分钟增量线程独立写 kline_minute/adj_factor 分区, 不占 run slot —
|
|
# 清空期间必须一并暂停, 否则"清完又被写回部分今日数据"(终态不完整)。
|
|
mrs = getattr(request.app.state, "minute_refresh", None)
|
|
mrs_was_running = False
|
|
try:
|
|
if mrs is not None:
|
|
with contextlib.suppress(Exception):
|
|
mrs_was_running = bool(mrs.status().get("running"))
|
|
mrs.stop()
|
|
if qs is not None:
|
|
with qs.paused():
|
|
return _clear_data_impl(request)
|
|
return _clear_data_impl(request)
|
|
finally:
|
|
if mrs is not None and mrs_was_running:
|
|
with contextlib.suppress(Exception):
|
|
mrs.start()
|
|
release_run_slot("data_clear")
|
|
|
|
|
|
def _clear_data_impl(request: Request):
|
|
import shutil
|
|
|
|
repo = request.app.state.repo
|
|
data_dir = repo.store.data_dir
|
|
from app.services.daily_health import clear_assets
|
|
deleted = 0
|
|
# recover=True: 清空语义就是接管一切 — 外部进程(崩掉的脚本/中断的管道)
|
|
# 残留的 publishing 标记(owner pid 已死)不应永久阻塞清空; 活进程的发布
|
|
# 仍会被拦(another publication is active), 写盘竞态保护不受影响。
|
|
publications = {
|
|
"kline_daily_enriched": EnrichedPublication(data_dir, "stock", recover=True),
|
|
"kline_etf_enriched": EnrichedPublication(data_dir, "etf", recover=True),
|
|
}
|
|
|
|
for sub in (
|
|
"kline_daily", "kline_daily_enriched", "kline_index_daily", "kline_index_enriched",
|
|
"kline_etf_daily", "kline_etf_enriched", "kline_etf_minute", "kline_minute",
|
|
"adj_factor", "adj_factor_etf", "instruments", "instruments_index", "instruments_etf", "pools", "financials",
|
|
"backtest_results", "screener_results", "ai_cache",
|
|
):
|
|
d = data_dir / sub
|
|
if not d.exists():
|
|
continue
|
|
publication = publications.get(sub)
|
|
parquet_files = list(d.rglob("*.parquet"))
|
|
if publication is not None and parquet_files:
|
|
publication.begin()
|
|
try:
|
|
for f in parquet_files:
|
|
f.unlink()
|
|
deleted += 1
|
|
if publication is not None:
|
|
publication.mark_changed()
|
|
for child in list(d.iterdir()):
|
|
if child.is_dir():
|
|
shutil.rmtree(child, ignore_errors=True)
|
|
if publication is not None:
|
|
publication.commit()
|
|
except BaseException:
|
|
if publication is not None:
|
|
publication.abandon()
|
|
raise
|
|
|
|
clear_assets(data_dir, {"stock", "etf", "index"})
|
|
(data_dir / "user_data" / "integrity_report.json").unlink(missing_ok=True)
|
|
# 清除同步历史(内存 + 磁盘 job_store/ 文件夹)
|
|
from app.services.pipeline_jobs import job_store
|
|
job_store.clear()
|
|
|
|
# 清除财务数据
|
|
fin_dir = data_dir / "financials"
|
|
for sub in ("metrics", "income", "balance_sheet", "cash_flow"):
|
|
fp = fin_dir / sub / "part.parquet"
|
|
if fp.exists():
|
|
fp.unlink()
|
|
deleted += 1
|
|
|
|
# 清除监控运行数据 (user_data 下仅清运行产物, 不动 monitor_rules/preferences/secrets 等用户配置)
|
|
# - 触发记录 alerts.jsonl
|
|
from app.services import alert_store
|
|
alert_store.clear(data_dir)
|
|
# - 待推送的实时通知队列 (进程内存)
|
|
qs = getattr(request.app.state, "quote_service", None)
|
|
if qs is not None:
|
|
qs.clear_pending_alerts()
|
|
|
|
# 清除 Polars 缓存
|
|
# 先 clear_cache 无条件清空内存 (refresh_cache 在磁盘无数据时会提前 return,
|
|
# 导致 _enriched_cache 等旧数据残留 —— 清数据后看板仍显示旧数据的根因),
|
|
# 再 refresh_cache 尝试重载 (磁盘有数据则重建缓存)。
|
|
repo.clear_cache()
|
|
repo.refresh_cache()
|
|
|
|
# 清除 Screener 进程级 _history_cache (TTL 缓存)
|
|
from app.services.screener import ScreenerService
|
|
ScreenerService.clear_history_cache()
|
|
|
|
# 清除 Overview 总览聚合结果缓存 (5s TTL)
|
|
from app.api.overview import invalidate_overview_cache
|
|
invalidate_overview_cache()
|
|
|
|
# 刷新 DuckDB 视图(空 parquet 目录也需要重新挂载)——
|
|
# 委托给 repository 的唯一权威实现, 覆盖全部视图 (此前这里内联的副本漏了几张)。
|
|
repo.rebuild_views()
|
|
|
|
logger.info("数据已清除: 删除 %d 个 parquet 文件", deleted)
|
|
invalidate_data_cache(None)
|
|
return {"deleted_files": deleted}
|
|
|
|
|
|
# 各表字段说明
|
|
_TABLE_FIELD_DESC: dict[str, dict[str, str]] = {
|
|
"kline_daily": {
|
|
"symbol": "股票代码",
|
|
"date": "交易日期",
|
|
"open": "开盘价",
|
|
"high": "最高价",
|
|
"low": "最低价",
|
|
"close": "收盘价",
|
|
"volume": "成交量",
|
|
"amount": "成交额",
|
|
},
|
|
"kline_enriched": ENRICHED_COLUMNS,
|
|
"kline_index_daily": {
|
|
"symbol": "指数代码",
|
|
"date": "交易日期",
|
|
"open": "开盘点位",
|
|
"high": "最高点位",
|
|
"low": "最低点位",
|
|
"close": "收盘点位",
|
|
"volume": "成交量",
|
|
"amount": "成交额",
|
|
},
|
|
"kline_index_enriched": ENRICHED_COLUMNS,
|
|
"kline_etf_daily": {
|
|
"symbol": "ETF代码",
|
|
"date": "交易日期",
|
|
"open": "开盘价",
|
|
"high": "最高价",
|
|
"low": "最低价",
|
|
"close": "收盘价",
|
|
"volume": "成交量",
|
|
"amount": "成交额",
|
|
},
|
|
"kline_etf_enriched": ENRICHED_COLUMNS,
|
|
"kline_minute": {
|
|
"symbol": "股票代码",
|
|
"datetime": "分钟时间戳",
|
|
"open": "开盘价",
|
|
"high": "最高价",
|
|
"low": "最低价",
|
|
"close": "收盘价",
|
|
"volume": "成交量",
|
|
"amount": "成交额",
|
|
},
|
|
"adj_factor": {
|
|
"symbol": "股票代码",
|
|
"trade_date": "除权除息日",
|
|
"ex_factor": "单事件等比因子 = 除权前收盘价 / 除权参考价, 按日期连乘得累计复权因子",
|
|
# 事件明细列: 表结构固定 8 列, 明细由提供事件的数据源(扶摇)回填, TickFlow 源为 null
|
|
"dividend": "每股现金分红(税前, 元)。可空, 仅事件明细数据源回填",
|
|
"bonus": "每股送转比例(10送3 = 0.3)。可空, 仅事件明细数据源回填",
|
|
"allot": "每股配股比例。可空, 仅事件明细数据源回填",
|
|
"allot_price": "配股价(元/股)。可空, 仅事件明细数据源回填",
|
|
"prev_close": "除权前一日收盘价(元), 因子推导基准。可空, 仅事件明细数据源回填",
|
|
},
|
|
"instruments": {
|
|
"symbol": "股票代码",
|
|
"name": "股票名称",
|
|
"code": "股票编码(纯数字)",
|
|
"exchange": "交易所(SH/SZ/BJ)",
|
|
"region": "地区",
|
|
"type": "证券类型",
|
|
"listing_date": "上市日期",
|
|
"total_shares": "总股本",
|
|
"float_shares": "流通股本",
|
|
"tick_size": "最小价格变动单位",
|
|
"limit_up": "涨停限制(%)",
|
|
"limit_down": "跌停限制(%)",
|
|
"as_of": "快照日期",
|
|
},
|
|
"instruments_index": {
|
|
"symbol": "指数代码",
|
|
"name": "指数名称",
|
|
"code": "指数编码(纯数字)",
|
|
"asset_type": "资产类型(index)",
|
|
},
|
|
"instruments_etf": {
|
|
"symbol": "ETF代码",
|
|
"name": "ETF名称",
|
|
"code": "ETF编码(纯数字)",
|
|
"asset_type": "资产类型(etf)",
|
|
"source": "数据源",
|
|
},
|
|
}
|
|
|
|
# view 名 → DuckDB 视图名
|
|
_SCHEMA_VIEWS: dict[str, str] = {
|
|
"daily": "kline_daily",
|
|
"enriched": "kline_enriched",
|
|
"index_daily": "kline_index_daily",
|
|
"index_enriched": "kline_index_enriched",
|
|
"index_instruments": "instruments_index",
|
|
"etf_daily": "kline_etf_daily",
|
|
"etf_enriched": "kline_etf_enriched",
|
|
"etf_instruments": "instruments_etf",
|
|
"minute": "kline_minute",
|
|
"adj_factor": "adj_factor",
|
|
"instruments": "instruments",
|
|
}
|
|
|
|
|
|
@router.get("/schema/{table}")
|
|
def table_schema(request: Request, table: str) -> list[dict]:
|
|
"""返回指定表的字段名、类型和中文说明。
|
|
|
|
优先从 DuckDB DESCRIBE 读取(有数据时含精确类型);
|
|
视图不存在(无数据)时回退到 _TABLE_FIELD_DESC 静态定义。
|
|
"""
|
|
view = _SCHEMA_VIEWS.get(table)
|
|
if not view:
|
|
return []
|
|
desc_map = _TABLE_FIELD_DESC.get(view, {})
|
|
repo = request.app.state.repo
|
|
fields: list[dict] = []
|
|
try:
|
|
cols = repo.execute_all(f"DESCRIBE {view}")
|
|
for col in cols:
|
|
name = col[0]
|
|
dtype = col[1]
|
|
fields.append({
|
|
"name": name,
|
|
"type": dtype,
|
|
"desc": desc_map.get(name, ""),
|
|
})
|
|
except Exception: # noqa: BLE001
|
|
# 视图不存在(本地无数据),用静态字段定义兜底
|
|
if desc_map:
|
|
for name, desc in desc_map.items():
|
|
fields.append({"name": name, "type": "—", "desc": desc})
|
|
return fields
|
|
|
|
|
|
@router.get("/version")
|
|
def get_version(request: Request) -> dict:
|
|
"""返回当前项目版本号。
|
|
|
|
优先读 app.__version__ (与 /health 接口同源, 唯一权威版本),
|
|
回退到项目根 VERSION 文件, 最后兜底 v0.0.0。
|
|
"""
|
|
from app import __version__
|
|
|
|
# 1. 优先用 app.__version__ (唯一权威版本, 运行时从 frontend/package.json 加载)
|
|
if __version__:
|
|
v = __version__.strip()
|
|
return {"version": v if v.startswith("v") else f"v{v}"}
|
|
|
|
# 2. 回退到项目根 VERSION 文件
|
|
from app.config import settings
|
|
project_root = Path(settings.data_dir).parent
|
|
version_file = project_root / "VERSION"
|
|
if version_file.exists():
|
|
v = version_file.read_text(encoding="utf-8").strip()
|
|
if v:
|
|
return {"version": v}
|
|
|
|
return {"version": "v0.0.0"}
|
|
|
|
|
|
@router.post("/refresh-cache")
|
|
def refresh_cache(request: Request) -> dict:
|
|
"""重建 Polars 内存缓存 (clear + refresh), 不清数据、不清 alerts。
|
|
|
|
用于跨天后缓存残留、或维表更新后手动刷新场景。
|
|
与设置页「清理并刷新」的区别: 那个清前端 react-query, 这个重建后端 enriched 缓存。
|
|
"""
|
|
repo = request.app.state.repo
|
|
repo.clear_cache()
|
|
repo.refresh_cache()
|
|
# 清除 Overview 总览聚合结果缓存 + Screener 历史 TTL 缓存
|
|
from app.api.overview import invalidate_overview_cache
|
|
invalidate_overview_cache()
|
|
from app.services.screener import ScreenerService
|
|
ScreenerService.clear_history_cache()
|
|
logger.info("refresh-cache: Polars 缓存已重建")
|
|
return {"ok": True}
|