mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
拆分同步获取和数据体检页签,固定弹窗头尾并改善窄屏布局。 复用现有同步任务定向批量补拉缺口,刷新跨页面分钟缓存。 结合日线单独统计整日零成交记录,避免停牌残留反复报缺口。 验证:独立暂存快照中后端 185 项、前端 16 项测试通过; 前端构建成功,Ruff 无新增问题,git diff --check 通过。 Signed-off-by: shy3130 <shy3130@qq.com>
1929 lines
80 KiB
Python
1929 lines
80 KiB
Python
"""K 线 / 同步 API。"""
|
||
from __future__ import annotations
|
||
|
||
import gzip
|
||
import json
|
||
import logging
|
||
import math
|
||
from datetime import date, timedelta
|
||
from functools import lru_cache
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
from zoneinfo import ZoneInfo
|
||
|
||
from fastapi import APIRouter, HTTPException, Query, Request, Response
|
||
|
||
from app.db_safe import is_valid_ext_ident
|
||
from app.indicators.pipeline import compute_enriched, compute_enriched_single
|
||
from app.market_time import cn_now, cn_today, in_continuous_session
|
||
from app.price_limits import (
|
||
is_no_limit_day,
|
||
is_risk_warning_name,
|
||
parse_listing_date,
|
||
price_limit_pct,
|
||
)
|
||
from app.services import kline_sync, minute_adjust, trading_day
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter(prefix="/api/kline", tags=["kline"])
|
||
|
||
# 批量端点 (daily-batch / minute-batch) 单次 symbols 上限: 千只级自选整表直发
|
||
# 会在服务端构造百万行级的 Python dict 峰值 (内存暴涨主因之一)。上限值是可配置
|
||
# 偏好 (见 services/preferences.py), 前端分块大小读同一配置 —— 不再各处硬编码,
|
||
# 避免「后端调小、前端仍按旧值分块」导致的全站 400。
|
||
# 兜底留给旧客户端/异常调用: 超限明确报错, 文案回传当前值便于客户端自愈。
|
||
def _batch_symbols_limit(key: str) -> int:
|
||
from app.services import preferences
|
||
|
||
return (
|
||
preferences.get_daily_batch_symbols()
|
||
if key == "daily"
|
||
else preferences.get_minute_batch_symbols()
|
||
)
|
||
|
||
|
||
def _reject_over_limit(symbols: list, kind: str) -> None:
|
||
limit = _batch_symbols_limit(kind)
|
||
if len(symbols) <= limit:
|
||
return
|
||
label = "日K" if kind == "daily" else "分时"
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=(
|
||
f"{label}批量单次上限 {limit} 只 (当前 {len(symbols)} 只), 请分批请求; "
|
||
f"可在 系统设置 → 批量请求 调整上限"
|
||
),
|
||
)
|
||
|
||
|
||
def _json_safe(obj):
|
||
"""把 nan/inf 换成 None, 保证 JSON 合法。
|
||
|
||
gzip 路径原先 allow_nan=True, 会写出前端 JSON.parse 不能吃的 NaN/Infinity;
|
||
未压缩路径走 Starlette allow_nan=False, 遇到非有限浮点整段 500。
|
||
"""
|
||
if isinstance(obj, float):
|
||
return obj if math.isfinite(obj) else None
|
||
if isinstance(obj, dict):
|
||
return {k: _json_safe(v) for k, v in obj.items()}
|
||
if isinstance(obj, (list, tuple)):
|
||
return [_json_safe(v) for v in obj]
|
||
return obj
|
||
|
||
|
||
def _compress_on(pref_key: str) -> bool:
|
||
"""读取传输压缩偏好; 未知键/读取异常一律按关闭。"""
|
||
from app.services import preferences as _prefs
|
||
_getters = {
|
||
"minute_batch_compress": _prefs.get_minute_batch_compress,
|
||
"daily_batch_compress": _prefs.get_daily_batch_compress,
|
||
}
|
||
getter = _getters.get(pref_key)
|
||
if getter is None:
|
||
return False
|
||
try:
|
||
return bool(getter())
|
||
except Exception: # 偏好读取异常按不压缩返回原样
|
||
return False
|
||
|
||
|
||
def _client_accepts_gzip(request: Request) -> bool:
|
||
headers = getattr(request, "headers", None) or {}
|
||
return "gzip" in (headers.get("accept-encoding") or "")
|
||
|
||
|
||
def _gzip_payload(request: Request, payload: dict, *, pref_key: str) -> dict | Response:
|
||
"""大 JSON 响应的传输压缩: 偏好开启 + 客户端接受 gzip + 响应超阈值才压。
|
||
|
||
分时/日K各自使用独立偏好键 (沿用已有 *_batch_compress 存储键保证兼容)。
|
||
level 6 实测 13MB ≈ 290ms CPU 压掉 87%; level 9 要 2.5s 不可用。
|
||
datetime → isoformat, 与 FastAPI jsonable_encoder 输出一致
|
||
(前端 since 增量按字符串字典序比较, 格式必须与非压缩路径相同)。
|
||
"""
|
||
if _compress_on(pref_key) and _client_accepts_gzip(request):
|
||
# NaN/inf 只在停牌/坏源时出现, 常态直接序列化, 仅在 allow_nan=False
|
||
# 抛 ValueError 时才回退 _json_safe 整树深拷贝 —— 深拷贝是批量响应
|
||
# 峰值最大的一份瞬态分配 (与原 payload 同量级), 常态跳过后峰值约减半。
|
||
def _dump(obj):
|
||
return json.dumps(
|
||
obj, ensure_ascii=False, separators=(",", ":"), allow_nan=False,
|
||
default=lambda o: o.isoformat() if hasattr(o, "isoformat") else str(o),
|
||
).encode()
|
||
|
||
try:
|
||
raw = _dump(payload)
|
||
except ValueError:
|
||
payload = _json_safe(payload)
|
||
raw = _dump(payload)
|
||
if len(raw) > 1024:
|
||
return Response(
|
||
content=gzip.compress(raw, 6),
|
||
media_type="application/json",
|
||
headers={"Content-Encoding": "gzip", "Vary": "Accept-Encoding"},
|
||
)
|
||
# 未达压缩阈值: dumps 成功则 payload 必无非有限浮点, 回退分支则已净化
|
||
return payload
|
||
return _json_safe(payload)
|
||
|
||
|
||
def _gzip_raw(request: Request, raw: bytes, *, pref_key: str) -> Response:
|
||
"""已序列化 JSON bytes 的传输压缩: 规则与 _gzip_payload 一致 (偏好/阈值/level)。"""
|
||
if _compress_on(pref_key) and _client_accepts_gzip(request) and len(raw) > 1024:
|
||
return Response(
|
||
content=gzip.compress(raw, 6),
|
||
media_type="application/json",
|
||
headers={"Content-Encoding": "gzip", "Vary": "Accept-Encoding"},
|
||
)
|
||
return Response(content=raw, media_type="application/json")
|
||
|
||
|
||
def _batch_frames_json(parts, dt_col: str, dt_fmt: str, extra=None) -> bytes:
|
||
"""批量 K 响应直出 JSON: 每只 symbol 的帧由 Polars Rust 侧序列化为行式片段。
|
||
|
||
与 to_dicts()+json.dumps 输出字节级等价 (tests/test_kline_batch_frames_json 锁定),
|
||
但完全不构建 Python dict —— 对象图与序列化耗时一起消失 (实测 96k 根:
|
||
1222ms / 峰值 69MB → 26ms / 峰值 18MB)。列集合与顺序沿用各帧自身 (含 symbol 列),
|
||
与 to_dicts 的键序完全一致。
|
||
datetime/date 列由 strftime 预格式化: write_json 原生输出空格分隔, 而前端
|
||
since 增量按 ISO (T 分隔) 字符串字典序比较, 格式必须与既有响应一致。
|
||
NaN/inf → null 为 write_json 原生行为, 与 _json_safe 净化语义一致。
|
||
"""
|
||
import polars as pl
|
||
|
||
frags = [
|
||
json.dumps(sym) + ":" + sub.with_columns(pl.col(dt_col).dt.strftime(dt_fmt)).write_json()
|
||
for sym, sub in parts.items()
|
||
]
|
||
raw = '{"data":{' + ",".join(frags) + "}"
|
||
if extra:
|
||
# 去掉 dumps 的最外层花括号, 键值并入根对象 (与旧 dict 路径的 payload 布局一致)
|
||
raw += "," + json.dumps(extra, ensure_ascii=False, separators=(",", ":"))[1:-1]
|
||
return (raw + "}").encode()
|
||
|
||
|
||
def _minute_allowed(capset) -> bool:
|
||
"""仅当前分钟数据源确实可用时开放分钟 K 接口。"""
|
||
from app.tickflow.capabilities import Cap
|
||
provider = kline_sync._selected_minute_provider_name()
|
||
if provider == "tickflow":
|
||
return capset.has(Cap.KLINE_MINUTE_BATCH)
|
||
_, fallback, error = kline_sync._resolve_minute_provider(provider)
|
||
if error is not None:
|
||
logger.warning("minute provider resolution failed while checking access: %s", error)
|
||
return error is None and not fallback
|
||
|
||
|
||
@lru_cache(maxsize=8192)
|
||
def _name_pinyin_keys(name: str) -> tuple[str, ...]:
|
||
"""返回中文名称所有可能的拼音首字母串 (多音字展开为笛卡尔积)。
|
||
|
||
'平安银行' -> ('PAYH',); '重庆百货' -> ('CQBH', 'CQMH', 'ZQBH', 'ZQMH')。
|
||
非汉字字符原样保留: '万科A' -> ('WKA',)。
|
||
股票名总量有限且不变, lru_cache 命中后单次查询 ≈ dict 查找, 全市场遍历 < 1ms。
|
||
"""
|
||
from pypinyin import Style, pinyin
|
||
if not name:
|
||
return ()
|
||
keys = [""]
|
||
for group in pinyin(name, style=Style.FIRST_LETTER, heteronym=True):
|
||
keys = [k + g.upper() for k in keys for g in group]
|
||
return tuple(keys)
|
||
|
||
|
||
def _init_pinyin_dict() -> None:
|
||
"""加载 A 股高频多音字地名/词组词典, 使常见误读也能命中。
|
||
|
||
pypinyin 默认词典对部分地名取常见读音 (如「重」→ chóng), 补充后「重庆」
|
||
同时接受 zhòng/qìng (zq) 与 chóng/qīng (cq) 两种首字母, 与同花顺行为一致。
|
||
幂等: 多次调用安全。
|
||
"""
|
||
try:
|
||
from pypinyin import load_phrases_dict
|
||
# value 用二维 list: 每个字给一个或多个读音
|
||
load_phrases_dict({
|
||
"重庆": [["zhòng", "chóng"], ["qīng"]],
|
||
"长安": [["cháng", "zhǎng"], ["ān"]],
|
||
"长春": [["cháng", "zhǎng"], ["chūn"]],
|
||
"长沙": [["cháng", "zhǎng"], ["shā"]],
|
||
"长城": [["cháng", "zhǎng"], ["chéng"]],
|
||
"长江": [["cháng", "zhǎng"], ["jiāng"]],
|
||
})
|
||
except Exception as exc: # noqa: BLE001
|
||
logger.warning("pypinyin phrases dict load failed (polyphone coverage may degrade): %s", exc)
|
||
|
||
|
||
_init_pinyin_dict()
|
||
|
||
|
||
def _match_pinyin(name: str, keyword: str) -> bool:
|
||
"""keyword 是否匹配 name 任一拼音首字母串的前缀 (支持多音字)。"""
|
||
return any(k.startswith(keyword) for k in _name_pinyin_keys(name))
|
||
|
||
|
||
@router.get("/instruments/search")
|
||
def search_instruments(
|
||
request: Request,
|
||
q: str = Query("", min_length=0, max_length=50, description="搜索关键词"),
|
||
limit: int = Query(20, ge=1, le=50),
|
||
asset_types: str = Query("stock", description="逗号分隔的资产类型: stock,etf"),
|
||
):
|
||
"""模糊搜索标的 (代码 / 名称)。从内存 instruments 缓存中查。
|
||
|
||
默认只搜股票, 保持既有调用方行为不变; 自选等场景传 asset_types=stock,etf
|
||
可一并搜出 ETF, 结果附带 asset_type 字段供前端区分。
|
||
"""
|
||
if not q.strip():
|
||
return {"results": []}
|
||
|
||
repo = request.app.state.repo
|
||
import polars as pl
|
||
|
||
types = [t.strip() for t in asset_types.split(",") if t.strip()]
|
||
parts: list[pl.DataFrame] = []
|
||
for t in types:
|
||
df_t = repo.get_instruments_asset(t)
|
||
if df_t.is_empty() or "symbol" not in df_t.columns:
|
||
continue
|
||
# dtype 全部归一到 Utf8: 股票/ETF 两份缓存来源不同 (ETF 含 legacy 合并), 防 concat SchemaError
|
||
parts.append(df_t.with_columns([
|
||
pl.col("symbol").cast(pl.Utf8).alias("symbol"),
|
||
(pl.col("name").cast(pl.Utf8) if "name" in df_t.columns else pl.lit("")).alias("name"),
|
||
(pl.col("code").cast(pl.Utf8) if "code" in df_t.columns else pl.lit("")).alias("code"),
|
||
pl.lit(t).alias("asset_type"),
|
||
]).select(["symbol", "name", "code", "asset_type"]))
|
||
if not parts:
|
||
return {"results": []}
|
||
df = pl.concat(parts, how="vertical")
|
||
|
||
keyword = q.strip().upper()
|
||
is_pinyin_query = keyword.isalpha() and keyword.isascii()
|
||
|
||
# code/symbol 前缀优先,再 name 包含匹配
|
||
prefix_mask = (
|
||
pl.col("code").str.starts_with(keyword)
|
||
| pl.col("symbol").str.to_uppercase().str.starts_with(keyword)
|
||
)
|
||
contains_mask = (
|
||
pl.col("code").str.contains(keyword, literal=True)
|
||
| pl.col("symbol").str.to_uppercase().str.contains(keyword, literal=True)
|
||
| pl.col("name").str.contains(keyword, literal=True)
|
||
)
|
||
|
||
# 分层匹配: ① code/symbol 前缀 → ② 拼音首字母前缀(纯字母输入) → ③ 包含匹配
|
||
prefix_hits = df.filter(prefix_mask).head(limit)
|
||
if prefix_hits.height >= limit:
|
||
matched = prefix_hits
|
||
else:
|
||
collected = [prefix_hits] if prefix_hits.height else []
|
||
seen = set(prefix_hits["symbol"].to_list()) if prefix_hits.height else set()
|
||
remaining = limit - prefix_hits.height
|
||
|
||
# ② 拼音首字母前缀: 仅纯字母输入触发 (如 payh → 平安银行); 中文/代码输入零开销跳过
|
||
if is_pinyin_query and remaining > 0:
|
||
pinyin_rows = []
|
||
for row in df.filter(~pl.col("symbol").is_in(seen)).iter_rows(named=True):
|
||
if _match_pinyin(row["name"], keyword):
|
||
pinyin_rows.append(row)
|
||
if len(pinyin_rows) >= remaining:
|
||
break
|
||
if pinyin_rows:
|
||
collected.append(pl.DataFrame(pinyin_rows))
|
||
seen.update(r["symbol"] for r in pinyin_rows)
|
||
remaining -= len(pinyin_rows)
|
||
|
||
# ③ 包含匹配补充
|
||
if remaining > 0:
|
||
contain_hits = df.filter(contains_mask & ~pl.col("symbol").is_in(seen)).head(remaining)
|
||
if contain_hits.height:
|
||
collected.append(contain_hits)
|
||
|
||
matched = (
|
||
pl.concat(collected, how="vertical") if len(collected) > 1
|
||
else (collected[0] if collected else df.head(0))
|
||
)
|
||
rows = matched.select(["symbol", "name", "code", "asset_type"]).to_dicts()
|
||
return {"results": rows}
|
||
|
||
|
||
@router.post("/instruments/names")
|
||
def instruments_names(request: Request, symbols: list[str]):
|
||
"""批量查标的名称 (股票 + ETF + 指数)。传入 symbol 列表, 返回 {symbol: name}。"""
|
||
if not symbols:
|
||
return {"names": {}}
|
||
repo = request.app.state.repo
|
||
return {"names": repo.get_name_map(symbols)}
|
||
|
||
|
||
def _get_stock_info(repo, symbol: str) -> dict:
|
||
"""从 instruments 内存缓存查标的名称 + 股本。
|
||
|
||
该接口在个股弹窗打开时每秒被调用 (SSE invalidate 触发重拉), 走
|
||
repo.get_instruments() 的 Polars 内存缓存按 symbol 过滤, 不再每请求
|
||
DuckDB 扫 instruments parquet。列缺失时返回空 dict, 与旧 SQL 报错路径一致。
|
||
"""
|
||
import polars as pl
|
||
try:
|
||
df = repo.get_instruments()
|
||
needed = ("symbol", "name", "total_shares", "float_shares")
|
||
if df.is_empty() or not all(c in df.columns for c in needed):
|
||
return {}
|
||
hit = df.filter(pl.col("symbol") == symbol).head(1)
|
||
if hit.is_empty():
|
||
return {}
|
||
return {
|
||
"name": hit["name"][0],
|
||
"total_shares": hit["total_shares"][0],
|
||
"float_shares": hit["float_shares"][0],
|
||
}
|
||
except Exception: # noqa: BLE001
|
||
return {}
|
||
|
||
|
||
def _get_asset_info(repo, symbol: str, asset_type: str) -> dict:
|
||
"""非股票标的 (ETF / 指数) 的名称信息 — 从对应 instruments 缓存查, 无股本概念。"""
|
||
import polars as pl
|
||
try:
|
||
df = repo.get_instruments_asset(asset_type)
|
||
if df.is_empty() or "symbol" not in df.columns or "name" not in df.columns:
|
||
return {}
|
||
hit = df.filter(pl.col("symbol") == symbol).head(1)
|
||
if hit.is_empty():
|
||
return {}
|
||
return {"name": hit["name"][0]}
|
||
except Exception:
|
||
return {}
|
||
|
||
|
||
def _get_price_limit_info(
|
||
repo,
|
||
symbol: str,
|
||
trade_date: date,
|
||
asset_type: str,
|
||
instrument_name: str | None,
|
||
) -> dict | None:
|
||
"""Return the date-aware limit rule and today's authoritative prices."""
|
||
if asset_type == "index":
|
||
return None
|
||
|
||
info: dict = {
|
||
"rate": price_limit_pct(
|
||
symbol,
|
||
trade_date,
|
||
is_risk_warning=(
|
||
asset_type == "stock" and is_risk_warning_name(instrument_name)
|
||
),
|
||
),
|
||
"limit_up": None,
|
||
"limit_down": None,
|
||
"no_limit": False,
|
||
"source": "rule",
|
||
}
|
||
|
||
# instrument 行一次取出: 今日权威涨跌停价 + listing_date 窗口判定共用
|
||
row: dict | None = None
|
||
try:
|
||
import polars as pl
|
||
|
||
instruments = repo.get_instruments_asset(asset_type)
|
||
available = [
|
||
column
|
||
for column in ("symbol", "limit_up", "limit_down", "listing_date")
|
||
if column in instruments.columns
|
||
]
|
||
if "symbol" in available and len(available) > 1:
|
||
hit = instruments.filter(pl.col("symbol") == symbol).select(available).head(1)
|
||
if not hit.is_empty():
|
||
row = hit.to_dicts()[0]
|
||
except Exception:
|
||
row = None
|
||
|
||
# 注册制新股上市初期无涨跌幅: listing_date 命中窗口时 no_limit=True,
|
||
# 压过 rate 与维表值 (前端不再画涨跌停带, y 轴按实际数据自适应)
|
||
if row is not None:
|
||
listing = parse_listing_date(row.get("listing_date"))
|
||
if listing is not None and is_no_limit_day(symbol, listing, trade_date):
|
||
info["no_limit"] = True
|
||
return info
|
||
|
||
if trade_date != cn_today():
|
||
return info
|
||
|
||
if row is None:
|
||
return info
|
||
|
||
has_authoritative_price = False
|
||
for field in ("limit_up", "limit_down"):
|
||
value = row.get(field)
|
||
if value is None:
|
||
continue
|
||
try:
|
||
numeric = float(value)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if math.isfinite(numeric) and 0 < numeric < 10_000:
|
||
info[field] = numeric
|
||
has_authoritative_price = True
|
||
if has_authoritative_price:
|
||
info["source"] = "instrument"
|
||
return info
|
||
|
||
|
||
def _get_previous_closes(
|
||
repo,
|
||
symbol: str,
|
||
trade_dates: list[date],
|
||
asset_type: str,
|
||
) -> dict[date, float | None]:
|
||
"""Return the previous trading day's adjusted close for each session."""
|
||
if not trade_dates:
|
||
return {}
|
||
start = min(trade_dates) - timedelta(days=45)
|
||
end = max(trade_dates)
|
||
try:
|
||
daily = repo.get_daily_asset(
|
||
asset_type,
|
||
symbol,
|
||
start,
|
||
end,
|
||
columns=["date", "close"],
|
||
).sort("date")
|
||
except Exception:
|
||
daily = None
|
||
if daily is None or daily.is_empty():
|
||
return {trade_date: None for trade_date in trade_dates}
|
||
|
||
closes: list[tuple[date, float]] = []
|
||
for daily_date, close in daily.select(["date", "close"]).iter_rows():
|
||
if close is None:
|
||
continue
|
||
numeric = float(close)
|
||
if math.isfinite(numeric) and numeric > 0:
|
||
closes.append((daily_date, numeric))
|
||
|
||
result: dict[date, float | None] = {}
|
||
for trade_date in trade_dates:
|
||
result[trade_date] = next(
|
||
(close for daily_date, close in reversed(closes) if daily_date < trade_date),
|
||
None,
|
||
)
|
||
return result
|
||
|
||
|
||
@router.get("/daily")
|
||
def get_daily(
|
||
request: Request,
|
||
symbol: str = Query(..., description="标的代码,如 000001.SZ"),
|
||
days: int = Query(120, ge=10, le=2000),
|
||
start_date: Optional[str] = Query(None, description="起始日期 YYYY-MM-DD, 优先于 days"),
|
||
end_date: Optional[str] = Query(None, description="截止日期 YYYY-MM-DD, 默认今天"),
|
||
ext_columns: Optional[str] = Query(None, description="逗号分隔的 ext 列: config_id.field_name"),
|
||
):
|
||
"""读取本地 enriched 表中某只股票的日 K。
|
||
|
||
- 若 QuoteService 有实时行情, 追加/覆盖今日实时蜡烛
|
||
- Free 用户: 若 enriched 表里没有该股票, 实时拉取 + 本地算 enriched 返回
|
||
- ext_columns: 可选,动态 LEFT JOIN 扩展数据表,结果平铺到 stock_info.ext 下
|
||
(key 为 "{config_id}__{field_name}"),供日K信息条等场景展示自定义字段
|
||
"""
|
||
import polars as pl
|
||
|
||
repo = request.app.state.repo
|
||
# 未传 end_date 时用北京今天: 实时注入只在内存缓存命中时补当日 K,
|
||
# 缓存冷时 parquet 当日行能否进结果取决于这个窗口右端。
|
||
end = date.fromisoformat(end_date) if end_date else cn_today()
|
||
if start_date:
|
||
start = date.fromisoformat(start_date)
|
||
else:
|
||
start = end - timedelta(days=days)
|
||
|
||
asset_type = repo.resolve_asset_type(symbol)
|
||
stock_info = _get_stock_info(repo, symbol) if asset_type == "stock" else _get_asset_info(repo, symbol, asset_type)
|
||
stock_name = stock_info.get("name")
|
||
|
||
# 从 enriched 表读取 (已含前复权 OHLCV + 技术指标 + 信号); ETF/指数走独立存储
|
||
df = repo.get_daily_asset(asset_type, symbol, start, end)
|
||
|
||
if df.is_empty():
|
||
try:
|
||
raw = kline_sync.sync_daily_batch([symbol], count=days + 30)
|
||
except Exception as e:
|
||
raise HTTPException(status_code=502, detail=f"TickFlow fetch failed: {e}") from e
|
||
if raw.is_empty():
|
||
return _gzip_payload(
|
||
request,
|
||
{"symbol": symbol, "name": stock_name, "stock_info": stock_info, "rows": []},
|
||
pref_key="daily_batch_compress",
|
||
)
|
||
# 拉除权因子做前复权 (Starter+ 有权限), 否则空 df → compute_enriched 退回未复权
|
||
factors = pl.DataFrame()
|
||
capset = getattr(request.app.state, "capabilities", None)
|
||
try:
|
||
from app.tickflow.capabilities import Cap
|
||
if capset and capset.has(Cap.ADJ_FACTOR):
|
||
factors = kline_sync.fetch_adj_factor_single(symbol)
|
||
except Exception as e: # noqa: BLE001
|
||
logger.debug("单股除权因子拉取失败 %s: %s", symbol, e)
|
||
enriched = compute_enriched(raw, factors=factors)
|
||
rows = enriched.tail(days).to_dicts()
|
||
# 即使 live 模式也尝试追加实时蜡烛
|
||
rows = _maybe_inject_live_candle(request, symbol, rows, asset_type)
|
||
resp = {"symbol": symbol, "name": stock_name, "stock_info": stock_info, "rows": rows, "source": "live"}
|
||
return _gzip_payload(
|
||
request,
|
||
_attach_ext(resp, repo, symbol, ext_columns),
|
||
pref_key="daily_batch_compress",
|
||
)
|
||
|
||
rows = df.to_dicts()
|
||
|
||
# 追加/覆盖今日实时蜡烛
|
||
rows = _maybe_inject_live_candle(request, symbol, rows, asset_type)
|
||
|
||
resp = {"symbol": symbol, "name": stock_name, "stock_info": stock_info, "rows": rows, "source": "enriched"}
|
||
return _gzip_payload(
|
||
request,
|
||
_attach_ext(resp, repo, symbol, ext_columns),
|
||
pref_key="daily_batch_compress",
|
||
)
|
||
|
||
|
||
def _attach_ext(resp: dict, repo, symbol: str, ext_columns: Optional[str]) -> dict:
|
||
"""按 ext_columns 规格为单只股票 LEFT JOIN 扩展数据,平铺到 stock_info['ext']。
|
||
|
||
key 形如 "{config_id}__{field_name}",与自选列表 enriched 接口保持一致。
|
||
委托 screener._load_ext_value_maps 取值: 复用其 (路径,mtime) 签名缓存,
|
||
个股弹窗每秒重拉时不再重复读 ext parquet; 任何 ext 表/字段缺失都静默跳过。
|
||
"""
|
||
if not ext_columns or not ext_columns.strip():
|
||
return resp
|
||
|
||
specs: list[tuple[str, str]] = []
|
||
for part in ext_columns.split(","):
|
||
part = part.strip()
|
||
if "." not in part:
|
||
continue
|
||
config_id, field_name = part.split(".", 1)
|
||
config_id, field_name = config_id.strip(), field_name.strip()
|
||
if config_id and field_name and is_valid_ext_ident(config_id):
|
||
specs.append((config_id, field_name))
|
||
if not specs:
|
||
return resp
|
||
|
||
try:
|
||
from app.api.screener import _load_ext_value_maps
|
||
value_maps = _load_ext_value_maps(repo, ext_columns)
|
||
except Exception: # noqa: BLE001
|
||
value_maps = {}
|
||
|
||
ext_values: dict = {}
|
||
for config_id, field_name in specs:
|
||
ext_col_name = f"{config_id}__{field_name}"
|
||
vmap = value_maps.get(ext_col_name) or {}
|
||
ext_values[ext_col_name] = vmap.get(symbol)
|
||
|
||
stock_info = dict(resp.get("stock_info") or {})
|
||
stock_info["ext"] = ext_values
|
||
resp["stock_info"] = stock_info
|
||
return resp
|
||
|
||
|
||
def _latest_live_candle(
|
||
request: Request,
|
||
symbol: str,
|
||
asset_type: str = "stock",
|
||
*,
|
||
refresh_asset: bool = True,
|
||
) -> dict | None:
|
||
"""从内存缓存读取单只标的的当日实时 enriched 行。"""
|
||
|
||
if asset_type == "stock":
|
||
qs = getattr(request.app.state, "quote_service", None)
|
||
if not qs:
|
||
return None
|
||
df_today, enriched_date = qs.get_enriched_today()
|
||
elif asset_type in {"etf", "index"}:
|
||
df_today, enriched_date = request.app.state.repo.get_enriched_latest_asset(
|
||
asset_type, refresh=refresh_asset,
|
||
)
|
||
else:
|
||
return None
|
||
if df_today.is_empty():
|
||
return None
|
||
|
||
# 非交易日(周末/假日)缓存日期 != 北京今天, 跳过注入避免产生重复蜡烛。
|
||
# 必须用 cn_today(): 美洲时区主机整个 A 股交易时段本地日期落后北京一天,
|
||
# 旧代码盘中直接丢K。UTC 主机盘中(UTC 1:30-7:00)本地日期与北京相同, 并不丢K;
|
||
# UTC 的旧症状是北京 00:00-08:00 把昨日残留快照误当实时K注入。
|
||
if not enriched_date or enriched_date != cn_today():
|
||
return None
|
||
|
||
# 查找该 symbol 的实时 enriched 行
|
||
import polars as pl
|
||
try:
|
||
q = df_today.filter(pl.col("symbol") == symbol).to_dicts()
|
||
if not q:
|
||
return None
|
||
q = q[0]
|
||
except Exception:
|
||
return None
|
||
|
||
close_price = q.get("close")
|
||
if not close_price or close_price <= 0:
|
||
return None
|
||
|
||
# 沿用完整日K接口原有的实时行投影, 避免增量接口形成第二套字段契约。
|
||
# API 在非交易时段可能返回 open/high/low=0, 用 close 填充避免异常蜡烛。
|
||
raw_open = q.get("open")
|
||
raw_high = q.get("high")
|
||
raw_low = q.get("low")
|
||
live_row = {
|
||
"date": str(enriched_date),
|
||
"symbol": symbol,
|
||
"open": raw_open if raw_open and raw_open > 0 else close_price,
|
||
"high": raw_high if raw_high and raw_high > 0 else close_price,
|
||
"low": raw_low if raw_low and raw_low > 0 else close_price,
|
||
"close": close_price,
|
||
"volume": q.get("volume"),
|
||
"amount": q.get("amount"),
|
||
"change_pct": q.get("change_pct"),
|
||
"is_live": True,
|
||
}
|
||
for key in ("ma5", "ma10", "ma20", "ma30", "ma60",
|
||
"macd_dif", "macd_dea", "macd_hist",
|
||
"kdj_k", "kdj_d", "kdj_j",
|
||
"boll_upper", "boll_lower",
|
||
"rsi_6", "rsi_14", "rsi_24",
|
||
"atr_14", "vol_ratio_5d"):
|
||
if key in q and q[key] is not None:
|
||
live_row[key] = q[key]
|
||
return live_row
|
||
|
||
|
||
def _maybe_inject_live_candle(request: Request, symbol: str, rows: list[dict], asset_type: str = "stock") -> list[dict]:
|
||
"""如果有当日实时 enriched 数据, 用实时数据生成今日蜡烛并追加/覆盖。"""
|
||
live_row = _latest_live_candle(request, symbol, asset_type)
|
||
if live_row is None:
|
||
return rows
|
||
|
||
# 如果已有今天的 enriched 行, 覆盖; 否则追加
|
||
found = False
|
||
for r in rows:
|
||
if str(r.get("date")) == live_row["date"]:
|
||
r.update(live_row)
|
||
found = True
|
||
break
|
||
|
||
if not found:
|
||
rows.append(live_row)
|
||
|
||
return rows
|
||
|
||
|
||
@router.get("/daily/latest")
|
||
def get_daily_latest(
|
||
request: Request,
|
||
symbol: str = Query(..., description="标的代码,如 000001.SZ"),
|
||
):
|
||
"""返回内存中的当日单行 K 线, 供详情页实时增量更新。"""
|
||
repo = request.app.state.repo
|
||
asset_type = repo.resolve_asset_type(symbol)
|
||
row = _latest_live_candle(request, symbol, asset_type, refresh_asset=False)
|
||
return {
|
||
"symbol": symbol,
|
||
"row": row,
|
||
"source": "live" if row is not None else "none",
|
||
}
|
||
|
||
|
||
class DailyBatchRequest:
|
||
"""批量日K请求。"""
|
||
symbols: list[str]
|
||
days: int = 12
|
||
|
||
|
||
@router.post("/daily-batch")
|
||
def get_daily_batch(request: Request, body: dict):
|
||
"""批量获取多只股票最近 N 天日K (OHLCV)。
|
||
|
||
用于自选列表迷你蜡烛图等场景,只返回基础列,不返回全部 enriched 指标。
|
||
按交易日语义取「最近 N 根」: 停牌/断档股在窗口内凑不满时向更早窗口回补,
|
||
跳过停牌空档取复牌前的 K, 而不是把迷你K截成只剩复牌后几根。
|
||
"""
|
||
symbols = body.get("symbols", [])
|
||
days = body.get("days", 12)
|
||
if not symbols:
|
||
return {"data": {}}
|
||
# 大自选整表直发会构造千只级响应 (百万行 dict 峰值); 前端已按视口/分块
|
||
# 收敛, 上限挡住旧客户端或异常调用的一次性全量请求 (值可配置)。
|
||
_reject_over_limit(symbols, "daily")
|
||
days = max(5, min(60, days))
|
||
|
||
repo = request.app.state.repo
|
||
from datetime import timedelta
|
||
|
||
import polars as pl
|
||
|
||
# 窗口右端必须是北京今天: QuoteService 当日 flush 的分区日期是北京交易日。
|
||
# 美西主机整个 A 股交易时段、UTC 主机北京 00:00-08:00, date.today() 比北京早一天,
|
||
# 迷你蜡烛会把当日实时 K 排除在窗口外。
|
||
end = cn_today()
|
||
start = end - timedelta(days=days * 2) # 多取一些确保交易日够
|
||
|
||
cols = ["symbol", "date", "open", "high", "low", "close", "volume"]
|
||
|
||
# 资产类型解析一次, 回补轮次复用: stock 走批量缓存; etf/index 逐只查独立存储
|
||
# (数量少, 成本可忽略)
|
||
sym_type = {s: repo.resolve_asset_type(s) for s in symbols}
|
||
|
||
def _read_range(syms: list[str], s) -> pl.DataFrame:
|
||
stock_syms = [x for x in syms if sym_type[x] not in ("etf", "index")]
|
||
frames: list[pl.DataFrame] = []
|
||
if stock_syms:
|
||
df_stock = repo.get_daily_batch(stock_syms, s, end, columns=cols)
|
||
if not df_stock.is_empty():
|
||
frames.append(df_stock)
|
||
for x in syms:
|
||
if sym_type[x] == "etf":
|
||
sub = repo.get_etf_daily(x, s, end, columns=cols)
|
||
if not sub.is_empty():
|
||
frames.append(sub)
|
||
elif sym_type[x] == "index":
|
||
sub = repo.get_index_daily(x, s, end, columns=cols)
|
||
if not sub.is_empty():
|
||
frames.append(sub)
|
||
if not frames:
|
||
return pl.DataFrame()
|
||
return pl.concat(frames, how="diagonal_relaxed")
|
||
|
||
def _tail_frames(df: pl.DataFrame) -> dict[str, pl.DataFrame]:
|
||
# 按 symbol 分组, 每只取最近 N 条。
|
||
# partition_by 一次切分, 避免 N 只自选时对同一批数据做 N 次全帧过滤。
|
||
# 全程持有帧不转 dict: 响应由 _batch_frames_json 直出 (见其 docstring)。
|
||
out: dict[str, pl.DataFrame] = {}
|
||
if df.is_empty():
|
||
return out
|
||
for part in df.partition_by("symbol", maintain_order=True):
|
||
sub = part.sort("date").tail(days)
|
||
if not sub.is_empty():
|
||
out[sub["symbol"][0]] = sub
|
||
return out
|
||
|
||
result = _tail_frames(_read_range(list(symbols), start))
|
||
|
||
# 停牌回补: 首段窗口是「自然日×2 ≈ 交易日」启发式, 默认窗口内每个交易日都有行情;
|
||
# 停牌股在窗口内交易日不足, tail(days) 只封顶不回补, 迷你K会只剩复牌后几根。
|
||
# 只对凑不满 days 根的股票 (含整窗无数据的长期停牌股) 分轮向更早窗口重取:
|
||
# 第一轮 +120 自然日通常落在 repo 的 enriched 300 天内存缓存内 (近零成本),
|
||
# 第二轮 +480 自然日 (约回看一年) 兜更长停牌, 兜不着的极端停牌按可得分量返回。
|
||
backlog: set[str] = {s for s, sub in result.items() if sub.height < days}
|
||
backlog |= {s for s in symbols if s not in result}
|
||
for pad in (120, 480):
|
||
if not backlog:
|
||
break
|
||
wider = _tail_frames(_read_range(sorted(backlog), start - timedelta(days=pad)))
|
||
still: set[str] = set()
|
||
for sym in backlog:
|
||
w = wider.get(sym)
|
||
if w is not None and w.height: # 宽窗读到的必然 ⊇ 窄窗行, 直接覆盖
|
||
result[sym] = w
|
||
if w is None or w.height < days:
|
||
still.add(sym)
|
||
backlog = still
|
||
|
||
# 日K批量同为大响应端点 (千只自选 MB 级), 与分时各自独立压缩开关
|
||
return _gzip_raw(
|
||
request,
|
||
_batch_frames_json(result, "date", "%Y-%m-%d"),
|
||
pref_key="daily_batch_compress",
|
||
)
|
||
|
||
|
||
@router.post("/minute-batch")
|
||
def get_minute_batch(request: Request, body: dict):
|
||
"""批量获取多只股票某天的分钟K (分时图用)。
|
||
|
||
- 本地优先: 先从 kline_minute parquet 读, 完整的直接用
|
||
- 缺失补拉: 本地不完整的 symbol 用 sync_minute_batch 批量实时拉 (不落库)
|
||
- 需 Pro+ 权限 (kline.minute.batch)
|
||
"""
|
||
from datetime import datetime
|
||
|
||
import polars as pl
|
||
|
||
from app.tickflow.capabilities import Cap
|
||
|
||
symbols: list[str] = body.get("symbols", [])
|
||
trade_date_str: str | None = body.get("date")
|
||
# 增量响应: since (ISO datetime) 之前的K不回传, 客户端本地缓存合并。
|
||
# since 应传客户端已持有的最后一根时间 — 形成中的动态K >= since, 每轮覆盖。
|
||
since_str = body.get("since")
|
||
since_dt: datetime | None = None
|
||
if since_str:
|
||
try:
|
||
since_dt = datetime.fromisoformat(str(since_str))
|
||
# 防御: 带 Z/偏移的 aware 输入 (如 toISOString) → 转北京墙钟再去 tz,
|
||
# 否则与行里的 naive 北京时间比较会 TypeError 且差 8 小时
|
||
if since_dt.tzinfo is not None:
|
||
since_dt = since_dt.astimezone(ZoneInfo("Asia/Shanghai")).replace(tzinfo=None)
|
||
except ValueError:
|
||
since_dt = None
|
||
# 自选分时本地优先标志: 全量分钟服务健康时, 股票缺口不再批量补拉
|
||
# (本地分区由服务按间隔持续写入, 下一轮自然补全; 停牌/临停票补拉也是空, 无损)。
|
||
# ETF 不在全量分钟 universe 内, 恒走补拉。服务不健康时回落现状补拉兜底。
|
||
prefer_local = bool(body.get("prefer_local", False))
|
||
if not symbols:
|
||
return {"data": {}}
|
||
# 同 daily-batch: 前端分块/视口化后单请求远低于此, 上限只挡异常规模 (可配置)
|
||
_reject_over_limit(symbols, "minute")
|
||
|
||
repo = request.app.state.repo
|
||
capset = request.app.state.capabilities
|
||
|
||
# 权限守卫: 分钟K批量补拉是 Pro+ 能力; 但本地读不消耗 provider 配额 ——
|
||
# 全量分钟服务健康 (正持续写本地分区) 时放行, 避免"数据在盘上却 403"。
|
||
# is_healthy() 纯内存 O(1), 不加热路径成本。
|
||
if not capset.has(Cap.KLINE_MINUTE_BATCH):
|
||
svc = getattr(request.app.state, "minute_refresh", None)
|
||
if svc is None or not svc.is_healthy():
|
||
raise HTTPException(status_code=403, detail="需要 Pro+ 权限 (kline.minute.batch)")
|
||
|
||
trade_date = date.fromisoformat(trade_date_str) if trade_date_str else cn_today()
|
||
|
||
# 非交易日(周末/节假日)才回退到最近有数据的交易日; 否则盘中会显示昨天而非今天。
|
||
# 判据: 周末必回退; 工作日收盘后(>=15:30)仍无今日日K → 节假日, 回退。
|
||
# (下方补拉已改为取到即落盘, 但只有真实交易时段才会写入当日分区,
|
||
# 节假日当日分区恒为空, 不影响该回退判据。)
|
||
if not trade_date_str:
|
||
today = cn_today()
|
||
# 周六/周日必非交易日; 工作日休市 (国庆等) 以交易日探针的「确定休市」为准,
|
||
# 与 /api/index/minute 同口径 — 未知 (None) 维持下方收盘后判据
|
||
need_fallback = today.weekday() >= 5 or trading_day.is_trading_day() is False
|
||
if not need_fallback:
|
||
now_cn = cn_now()
|
||
after_close = now_cn.hour > 15 or (now_cn.hour == 15 and now_cn.minute >= 30)
|
||
if after_close:
|
||
latest_daily = repo.latest_daily_date()
|
||
if latest_daily is None or latest_daily < today:
|
||
need_fallback = True
|
||
if need_fallback:
|
||
recent_date = repo.latest_minute_date_global()
|
||
if recent_date is None:
|
||
recent_date = repo.latest_daily_date()
|
||
if recent_date is not None:
|
||
trade_date = recent_date
|
||
|
||
# Step 1: 本地优先 — 一次 scan 读全部 symbol 当日分钟K (股票 / ETF 分钟数据分开存储)
|
||
etf_set = repo.get_etf_symbol_set()
|
||
stock_syms = [s for s in symbols if s not in etf_set]
|
||
etf_syms = [s for s in symbols if s in etf_set]
|
||
df_local = repo.get_minute_batch(stock_syms, trade_date)
|
||
if etf_syms:
|
||
df_etf = repo.get_minute_batch(etf_syms, trade_date, asset_type="etf")
|
||
if df_local.is_empty():
|
||
df_local = df_etf
|
||
elif not df_etf.is_empty():
|
||
df_local = pl.concat([df_local, df_etf], how="diagonal_relaxed")
|
||
|
||
# 期望条数 (盘中按当前时刻估算, 盘后 240)
|
||
now = cn_now()
|
||
h, m = now.hour, now.minute
|
||
if trade_date != cn_today():
|
||
expected = 240
|
||
elif h < 9 or (h == 9 and m < 30):
|
||
expected = 0
|
||
elif h < 11 or (h == 11 and m <= 30):
|
||
expected = (h - 9) * 60 + m - 30
|
||
elif h < 13:
|
||
expected = 120
|
||
elif h < 15:
|
||
expected = 120 + (h - 13) * 60 + m
|
||
else:
|
||
expected = 240
|
||
|
||
# 本地状态分类 (补拉已改为取到即落盘, 完整性判定随之收紧):
|
||
# - fresh: 根数 >= 期望-2 (时间边界容差), 直接用本地。原 0.9 比例阈值会让
|
||
# 持久化数据在 90% 处冻结尾巴, 必须按根数差判。
|
||
# - holes: 缺K → 全天重拉回填, 否则"最后一根+1min"的增量窗口永远不会
|
||
# 回看洞。含两种: 中间的洞 (相邻间距非 1 分钟 / 非午休 91 分钟)
|
||
# 与前部的洞 (首根显著晚于开盘 — 盘中重启/停机跨开盘的残留,
|
||
# 连续的尾部K会被增量锚定锁死, 同样必须全天重拉)。
|
||
# - stale: 仅尾部落后 → 增量拉, 请求量从"每轮全天"降为"每轮一根"量级。
|
||
_LUNCH_GAP_MIN = 91 # 11:30 → 13:01
|
||
# 前部洞基准: 开盘后 6 分钟 (容许无集合竞价K的数据源)。晚开/停牌复牌的票
|
||
# 也会命中 → 全天拉幂等, 至多多一次批量请求, 与中间洞同一代价模型。
|
||
day_open_floor = datetime(trade_date.year, trade_date.month, trade_date.day, 9, 36, 0)
|
||
|
||
def _has_holes(sub: pl.DataFrame) -> bool:
|
||
if not sub.is_empty() and sub["datetime"][0] > day_open_floor:
|
||
return True
|
||
gaps = sub["datetime"].diff().dt.total_minutes().drop_nulls()
|
||
return gaps.filter((gaps != 1) & (gaps != _LUNCH_GAP_MIN)).len() > 0
|
||
|
||
result: dict[str, pl.DataFrame] = {}
|
||
full_pull: list[str] = [] # 无数据或中间有洞 → 全天拉
|
||
stale_last: dict[str, datetime] = {} # 尾部落后 → symbol → 最后一根时间
|
||
local_parts: dict[str, pl.DataFrame] = {}
|
||
if not df_local.is_empty():
|
||
for part in df_local.partition_by("symbol", maintain_order=True):
|
||
local_parts[part["symbol"][0]] = part.sort("datetime")
|
||
fresh_floor = max(0, expected - 2)
|
||
for sym in symbols:
|
||
sub = local_parts.get(sym, pl.DataFrame())
|
||
if expected == 0 or sub.height >= fresh_floor:
|
||
if not sub.is_empty():
|
||
result[sym] = sub
|
||
continue
|
||
if sub.is_empty() or _has_holes(sub):
|
||
full_pull.append(sym)
|
||
else:
|
||
stale_last[sym] = sub["datetime"][-1]
|
||
|
||
# prefer_local 生效判定: 仅当全量分钟服务健康 (freshness 契约, 见 minute_refresh.is_healthy)
|
||
full_minute_healthy = False
|
||
if prefer_local:
|
||
svc = getattr(request.app.state, "minute_refresh", None)
|
||
full_minute_healthy = bool(svc is not None and svc.is_healthy())
|
||
if full_minute_healthy:
|
||
# 纯尾部落后 (stale_last): 服务的增量轮下一轮就会补上, 股票不补拉省请求。
|
||
# 空洞 (full_pull: 空分区 / 中间洞 / 前部洞): 服务增量锚定本地最新时间,
|
||
# 永远不会回看洞 → 不压制, 由端点全天拉取并落盘修复。
|
||
# ETF 不在服务 universe 内, 两类均维持补拉。
|
||
for sym in stale_last:
|
||
if sym not in etf_set:
|
||
sub = local_parts.get(sym)
|
||
if sub is not None and not sub.is_empty():
|
||
result[sym] = sub
|
||
stale_last = {s: t for s, t in stale_last.items() if s in etf_set}
|
||
|
||
# Step 2: 补拉并落盘 (取到即写, upsert 语义; 下一轮命中本地, 请求量骤降)。
|
||
# 落盘失败只降级 (log 后继续返回本轮数据), 不影响响应 —— 持久化是优化而非正确性前提。
|
||
# 契约: 本端点只接受 stock/ETF (指数分钟K走 /api/index/minute 独立路径),
|
||
# 按 asset_type 拆分调用 (自定义源 / TickFlow 路由均依赖 asset_type 正确传递)。
|
||
day_start = datetime(trade_date.year, trade_date.month, trade_date.day, 9, 25, 0)
|
||
session_end = datetime(trade_date.year, trade_date.month, trade_date.day, 15, 5, 0)
|
||
from app.tickflow.rate_limits import resolve_limit
|
||
|
||
# 与盘后同步 (kline_sync.sync_and_persist_minute) 同用 resolve_limit (×0.8 安全
|
||
# 系数): 此前裸 limits().rpm=60 与盘后解析出的 48 分属两个互不感知的 rpm 桶,
|
||
# 同一服务端池的实际合并上限是 108rpm。
|
||
lim = resolve_limit(capset, Cap.KLINE_MINUTE_BATCH)
|
||
minute_dirs = {
|
||
"stock": repo.store.data_dir / "kline_minute",
|
||
"etf": repo.store.data_dir / "kline_etf_minute",
|
||
}
|
||
live_map: dict[str, pl.DataFrame] = {}
|
||
|
||
def _pull(asset: str, sym_list: list[str], start: datetime) -> None:
|
||
if not sym_list:
|
||
return
|
||
raw_basis = minute_adjust.minute_basis_is_raw(repo.store.data_dir)
|
||
df_live = kline_sync.sync_minute_batch(
|
||
sym_list,
|
||
start_time=start,
|
||
end_time=session_end,
|
||
batch_size=lim.batch,
|
||
rpm=lim.rpm,
|
||
asset_type=asset,
|
||
raw_basis=raw_basis,
|
||
)
|
||
if df_live.is_empty():
|
||
return
|
||
try:
|
||
# 读-改-写必须持仓库写锁 (与全量分钟服务/盘后同步同一纪律, Windows 临时文件占用)。
|
||
# 仅在拿到真实目录时落盘: data_dir 异常 (非 Path) 时跳过, 只返回本轮数据。
|
||
# 落盘必须是原始口径 (raw_basis=True 时 sync 已按 adjust='none' 取回);
|
||
# 对外响应再统一复权投影。
|
||
minute_dir = minute_dirs[asset]
|
||
if isinstance(minute_dir, Path):
|
||
with repo._write_lock:
|
||
kline_sync._write_minute_partition(df_live, minute_dir)
|
||
except Exception as e: # noqa: BLE001
|
||
logger.warning("minute-batch 补拉落盘失败 (降级为仅返回): %s", e)
|
||
if raw_basis:
|
||
df_live = minute_adjust.apply_minute_adjustment(
|
||
df_live, repo.store.data_dir, asset,
|
||
)
|
||
for part in df_live.partition_by("symbol", maintain_order=True):
|
||
live_map[part["symbol"][0]] = part.sort("datetime")
|
||
|
||
_pull("stock", [s for s in full_pull if s not in etf_set], day_start)
|
||
_pull("etf", [s for s in full_pull if s in etf_set], day_start)
|
||
if stale_last:
|
||
# 增量公共起点 = 最旧的最后一根本身: 最后一根是形成中的动态K (分钟内
|
||
# 收盘/量/额持续变化), 必须重拉并以定版值覆盖; 重叠由 upsert/合并去重吸收
|
||
inc_start = min(stale_last.values())
|
||
if inc_start < session_end:
|
||
_pull("stock", [s for s in stale_last if s not in etf_set], inc_start)
|
||
_pull("etf", [s for s in stale_last if s in etf_set], inc_start)
|
||
|
||
# 合并: 有增量/回填的 symbol = 本地 + 拉取 upsert; 仅拉到的 (missing) 直接进结果
|
||
# 补拉落空兜底 (truncated): 本地已有部分K但本轮补拉失败/为空 (上游断网窗口) 时,
|
||
# 返回本地已落盘的行 — 图冻结在最后落盘时刻, 而不是整只从结果里消失
|
||
# (2026-10-23 实测: 断网 40 分钟, 分时列全部退化为只剩竞价栏)。
|
||
# truncated 标记这些 symbol 是残缺快照, 前端可据此提示; 严格只回本地行, 不外推。
|
||
truncated: set[str] = set()
|
||
for sym, sub in local_parts.items():
|
||
live = live_map.get(sym)
|
||
if live is not None:
|
||
merged = (
|
||
pl.concat([sub, live])
|
||
.unique(subset=["symbol", "datetime"], keep="last")
|
||
.sort("datetime")
|
||
)
|
||
result[sym] = merged
|
||
elif sym not in result and not sub.is_empty():
|
||
result[sym] = sub
|
||
truncated.add(sym)
|
||
for sym, live in live_map.items():
|
||
if sym not in result:
|
||
result[sym] = live
|
||
|
||
# since 增量过滤: 只回 >= since 的K (含动态最后一根), 无新增的 symbol 不回
|
||
if since_dt is not None:
|
||
result = {
|
||
sym: sub.filter(pl.col("datetime") >= since_dt)
|
||
for sym, sub in result.items()
|
||
}
|
||
result = {sym: sub for sym, sub in result.items() if sub.height}
|
||
# since 过滤后已无行的 symbol 不再标记 truncated (标记与 data 键一致)
|
||
truncated &= result.keys()
|
||
|
||
extra = {
|
||
"full_minute_local": full_minute_healthy,
|
||
"incremental": since_dt is not None,
|
||
}
|
||
if truncated:
|
||
extra["truncated"] = sorted(truncated)
|
||
return _gzip_raw(
|
||
request,
|
||
_batch_frames_json(result, "datetime", "%Y-%m-%dT%H:%M:%S", extra),
|
||
pref_key="minute_batch_compress",
|
||
)
|
||
|
||
|
||
@router.get("/minute-range")
|
||
def get_minute_range(
|
||
request: Request,
|
||
symbol: str = Query(..., description="标的代码"),
|
||
days: int = Query(10, ge=1, le=20, description="最近交易日数量"),
|
||
):
|
||
"""读取单只标的最近 N 个已落库交易日的分钟 K。"""
|
||
import polars as pl
|
||
|
||
repo = request.app.state.repo
|
||
asset_type = repo.resolve_asset_type(symbol)
|
||
stock_info = (
|
||
_get_stock_info(repo, symbol)
|
||
if asset_type == "stock"
|
||
else _get_asset_info(repo, symbol, asset_type)
|
||
)
|
||
base_response = {
|
||
"symbol": symbol,
|
||
"name": stock_info.get("name"),
|
||
"asset_type": asset_type,
|
||
"requested_days": days,
|
||
}
|
||
|
||
# 指数分钟 K 不落本地仓库, 最新分时仍由 /api/index/minute 实时读取。
|
||
if asset_type == "index":
|
||
return _gzip_payload(
|
||
request,
|
||
{**base_response, "sessions": [], "source": "none"},
|
||
pref_key="minute_batch_compress",
|
||
)
|
||
|
||
end = cn_today()
|
||
start = end - timedelta(days=days * 3 + 20)
|
||
minute = repo.get_minute_range([symbol], start, end, asset_type=asset_type)
|
||
if minute.is_empty() or "datetime" not in minute.columns:
|
||
return _gzip_payload(
|
||
request,
|
||
{**base_response, "sessions": [], "source": "none"},
|
||
pref_key="minute_batch_compress",
|
||
)
|
||
|
||
minute = minute.with_columns(
|
||
pl.col("datetime").dt.date().alias("_trade_date"),
|
||
)
|
||
trade_dates = sorted(minute["_trade_date"].unique().to_list())[-days:]
|
||
previous_closes = _get_previous_closes(repo, symbol, trade_dates, asset_type)
|
||
row_columns = [
|
||
column
|
||
for column in (
|
||
"datetime", "open", "high", "low", "close", "volume", "amount"
|
||
)
|
||
if column in minute.columns
|
||
]
|
||
sessions = []
|
||
for trade_date in trade_dates:
|
||
rows = (
|
||
minute.filter(pl.col("_trade_date") == trade_date)
|
||
.sort("datetime")
|
||
.select(row_columns)
|
||
.to_dicts()
|
||
)
|
||
if rows:
|
||
sessions.append({
|
||
"date": trade_date.isoformat(),
|
||
"prev_close": previous_closes.get(trade_date),
|
||
"rows": rows,
|
||
})
|
||
|
||
return _gzip_payload(
|
||
request,
|
||
{
|
||
**base_response,
|
||
"sessions": sessions,
|
||
"source": "local" if sessions else "none",
|
||
},
|
||
pref_key="minute_batch_compress",
|
||
)
|
||
|
||
|
||
@router.get("/minute")
|
||
def get_minute(
|
||
request: Request,
|
||
symbol: str = Query(..., description="标的代码"),
|
||
trade_date: date | None = Query(None, alias="date", description="交易日期, 默认最新"),
|
||
live: bool = Query(False, description="当日盘中跳过本地优先, 直接实时拉取(个股详情分时轮询用)"),
|
||
):
|
||
"""读取某只股票某天的分钟 K 线。
|
||
|
||
- 本地有完整数据(240条) → 直接返回
|
||
- 本地无数据或不完整 → 从有效分钟数据源实时拉取返回(不写入)
|
||
- 自定义源失败时, 仅具备 TickFlow 单股分钟能力才回退 TickFlow
|
||
- live=true 且当日连续竞价时段 → 跳过本地优先直接实时拉取:
|
||
盘中分钟增量落盘的本地分区按 ≥60s 轮次更新, 90% 完整度启发式会让
|
||
详情分时图停在上一增量轮, 与行情列表的节奏脱节
|
||
"""
|
||
repo = request.app.state.repo
|
||
capset = request.app.state.capabilities
|
||
raw_basis = minute_adjust.minute_basis_is_raw(repo.store.data_dir)
|
||
asset_type = repo.resolve_asset_type(symbol)
|
||
stock_info = _get_stock_info(repo, symbol) if asset_type == "stock" else _get_asset_info(repo, symbol, asset_type)
|
||
stock_name = stock_info.get("name")
|
||
|
||
if trade_date is None:
|
||
# 默认看今天, 而不是本地落盘的最近日 (盘中后者是昨天)。
|
||
# 非交易日(周末/节假日)才回退到本地最近有数据的交易日。
|
||
today = cn_today()
|
||
# 同 /minute-batch: 周末必回退, 工作日休市以交易日探针「确定休市」为准
|
||
need_fallback = today.weekday() >= 5 or trading_day.is_trading_day() is False
|
||
if not need_fallback:
|
||
now_cn = cn_now()
|
||
after_close = now_cn.hour > 15 or (now_cn.hour == 15 and now_cn.minute >= 30)
|
||
if after_close:
|
||
latest_daily = repo.latest_daily_date()
|
||
if latest_daily is None or latest_daily < today:
|
||
need_fallback = True
|
||
if need_fallback:
|
||
recent = repo.latest_minute_date(symbol, asset_type=asset_type)
|
||
if recent is None:
|
||
recent = repo.latest_daily_date()
|
||
trade_date = recent if recent is not None else today
|
||
else:
|
||
trade_date = today
|
||
if trade_date is None:
|
||
# 本地无任何分钟K, 尝试从当前有效分钟源拉取当天
|
||
trade_date = cn_today()
|
||
df = kline_sync.fetch_minute_single(
|
||
symbol, trade_date, asset_type=asset_type, capset=capset,
|
||
raw_basis=raw_basis,
|
||
)
|
||
if raw_basis:
|
||
df = minute_adjust.apply_minute_adjustment(df, repo.store.data_dir, asset_type)
|
||
price_limit = _get_price_limit_info(
|
||
repo, symbol, trade_date, asset_type, stock_name,
|
||
)
|
||
prev_close = _get_previous_closes(
|
||
repo, symbol, [trade_date], asset_type,
|
||
).get(trade_date)
|
||
return _gzip_payload(
|
||
request,
|
||
{
|
||
"symbol": symbol, "name": stock_name, "stock_info": stock_info,
|
||
"date": str(trade_date), "rows": df.to_dicts(),
|
||
"source": "live" if not df.is_empty() else "none",
|
||
"asset_type": asset_type,
|
||
"price_limit": price_limit,
|
||
"prev_close": prev_close,
|
||
},
|
||
pref_key="minute_batch_compress",
|
||
)
|
||
|
||
prev_close = _get_previous_closes(
|
||
repo, symbol, [trade_date], asset_type,
|
||
).get(trade_date)
|
||
price_limit = _get_price_limit_info(
|
||
repo, symbol, trade_date, asset_type, stock_name,
|
||
)
|
||
|
||
if live and trade_date == cn_today() and in_continuous_session():
|
||
# 详情分时轮询: 当日盘中实时拉取最新一根K, 不落盘; 拉空(源侧延迟/
|
||
# 时段边界)则落回下方本地优先路径。
|
||
live_df = kline_sync.fetch_minute_single(
|
||
symbol, trade_date, asset_type=asset_type, capset=capset,
|
||
raw_basis=raw_basis,
|
||
)
|
||
if not live_df.is_empty():
|
||
if raw_basis:
|
||
live_df = minute_adjust.apply_minute_adjustment(
|
||
live_df, repo.store.data_dir, asset_type,
|
||
)
|
||
return _gzip_payload(
|
||
request,
|
||
{
|
||
"symbol": symbol, "name": stock_name, "stock_info": stock_info,
|
||
"date": str(trade_date), "rows": live_df.to_dicts(),
|
||
"source": "live", "asset_type": asset_type,
|
||
"price_limit": price_limit, "prev_close": prev_close,
|
||
},
|
||
pref_key="minute_batch_compress",
|
||
)
|
||
|
||
df = repo.get_minute(symbol, trade_date, asset_type=asset_type)
|
||
|
||
# 完整交易日应有 240 条分钟K;如果是今天(盘中),期望条数按已交易分钟估算
|
||
expected = 240
|
||
today = cn_today()
|
||
if trade_date == today:
|
||
now = cn_now()
|
||
h, m = now.hour, now.minute
|
||
if h < 9 or (h == 9 and m < 30):
|
||
expected = 0 # 还没开盘
|
||
elif h < 11 or (h == 11 and m <= 30):
|
||
expected = (h - 9) * 60 + m - 30 # 9:30 起
|
||
elif h < 13:
|
||
expected = 120 # 午休
|
||
elif h < 15:
|
||
expected = 120 + (h - 13) * 60 + m
|
||
else:
|
||
expected = 240
|
||
|
||
is_complete = not df.is_empty() and len(df) >= expected * 0.9 # 允许 10% 容差
|
||
|
||
if is_complete:
|
||
return _gzip_payload(
|
||
request,
|
||
{
|
||
"symbol": symbol, "name": stock_name, "stock_info": stock_info,
|
||
"date": str(trade_date), "rows": df.to_dicts(), "source": "local",
|
||
"asset_type": asset_type,
|
||
"price_limit": price_limit,
|
||
"prev_close": prev_close,
|
||
},
|
||
pref_key="minute_batch_compress",
|
||
)
|
||
|
||
# 历史日期本地有数据但不完整: 优先返回已有行, 不再丢弃去问一个给不出
|
||
# 历史分钟的源。历史上这里把 171 根当「无数据」→ 补拉历史日期恒为空 →
|
||
# 前端白屏, 用户看到「同步完成却显示不出来」。
|
||
# 当日盘中不适用: 盘中本地落后于实时源是常态, 仍应走下方补拉 (见
|
||
# test_minute_lunch_break_still_pulls_when_morning_incomplete)。
|
||
if not df.is_empty() and trade_date != cn_today():
|
||
return _gzip_payload(
|
||
request,
|
||
{
|
||
"symbol": symbol, "name": stock_name, "stock_info": stock_info,
|
||
"date": str(trade_date), "rows": df.to_dicts(),
|
||
"source": "local_partial",
|
||
"expected": expected, "actual": len(df),
|
||
"asset_type": asset_type,
|
||
"price_limit": price_limit,
|
||
"prev_close": prev_close,
|
||
},
|
||
pref_key="minute_batch_compress",
|
||
)
|
||
|
||
# 本地无数据 (或当日盘中数据落后) → 从当前有效分钟源实时拉取
|
||
live_df = kline_sync.fetch_minute_single(
|
||
symbol, trade_date, asset_type=asset_type, capset=capset,
|
||
raw_basis=raw_basis,
|
||
)
|
||
if raw_basis and not live_df.is_empty():
|
||
live_df = minute_adjust.apply_minute_adjustment(
|
||
live_df, repo.store.data_dir, asset_type,
|
||
)
|
||
return _gzip_payload(
|
||
request,
|
||
{
|
||
"symbol": symbol, "name": stock_name, "stock_info": stock_info,
|
||
"date": str(trade_date), "rows": live_df.to_dicts(),
|
||
"source": "live" if not live_df.is_empty() else "none",
|
||
"asset_type": asset_type,
|
||
"price_limit": price_limit,
|
||
"prev_close": prev_close,
|
||
},
|
||
pref_key="minute_batch_compress",
|
||
)
|
||
|
||
|
||
@router.post("/sync")
|
||
def sync_symbol(
|
||
request: Request,
|
||
symbol: str = Query(...),
|
||
days: int = Query(250, ge=10, le=2000),
|
||
):
|
||
"""手动触发单股同步(Free 用户在 K 线页用)。"""
|
||
repo = request.app.state.repo
|
||
capset = request.app.state.capabilities
|
||
zero: list[str] = []
|
||
n = kline_sync.sync_and_persist_daily_batch([symbol], repo, capset, count=days, zero_row_out=zero)
|
||
resp = {"symbol": symbol, "rows_written": n}
|
||
if zero:
|
||
resp["zero_row_symbols"] = zero
|
||
return resp
|
||
|
||
|
||
@router.post("/sync_batch")
|
||
def sync_batch(
|
||
request: Request,
|
||
symbols: list[str],
|
||
days: int = Query(250, ge=10, le=2000),
|
||
):
|
||
repo = request.app.state.repo
|
||
capset = request.app.state.capabilities
|
||
zero: list[str] = []
|
||
n = kline_sync.sync_and_persist_daily_batch(symbols, repo, capset, count=days, zero_row_out=zero)
|
||
resp = {"symbols": symbols, "rows_written": n}
|
||
if zero:
|
||
# fail-loud (#302): 裸符号被跳过/上游 200 空数据的标的显式列出,
|
||
# 不再"回填显示成功、实际全库 0 行"
|
||
resp["zero_row_symbols"] = zero
|
||
return resp
|
||
|
||
|
||
@router.post("/refresh_views")
|
||
def refresh_views(request: Request):
|
||
"""刷新所有 DuckDB 视图(解决视图状态不一致问题)。"""
|
||
from app.jobs.daily_pipeline import _refresh_views
|
||
repo = request.app.state.repo
|
||
_refresh_views(repo)
|
||
return {"status": "ok"}
|
||
|
||
|
||
@router.post("/sync_minute")
|
||
async def sync_minute(request: Request):
|
||
"""手动触发分钟 K 同步(全市场)。返回 pipeline job_id 可轮询进度。
|
||
|
||
body 可选: { "days": int } — 指定拉取天数 (不传则用偏好设置)。
|
||
"""
|
||
# 可选 body: { "days": int, "extend": bool }
|
||
# days: 拉取天数; extend: 向前扩展模式 (从最早数据往前补)
|
||
body = {}
|
||
try:
|
||
body = await request.json()
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
return await trigger_minute_sync(
|
||
request.app.state.repo,
|
||
request.app.state.capabilities,
|
||
override_days=body.get("days"),
|
||
extend_flag=body.get("extend"),
|
||
)
|
||
|
||
|
||
@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
|
||
from app.services.pipeline_jobs import (
|
||
JobCancelledError,
|
||
job_store,
|
||
release_run_slot,
|
||
run_with_capacity,
|
||
try_acquire_run_slot,
|
||
)
|
||
from app.services.preferences import get_minute_sync_days
|
||
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:
|
||
if not try_acquire_run_slot(job_id):
|
||
job_store.fail(job_id, "已有数据任务在运行(或上一次任务卡死未结束),请稍后再试")
|
||
return
|
||
loop = asyncio.get_event_loop()
|
||
|
||
def progress(stage: str, pct: int, msg: str) -> None:
|
||
job_store.progress(job_id, stage, pct, msg)
|
||
|
||
try:
|
||
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()
|
||
# extend=1 → 向前扩展; days>=365 也自动向前扩展
|
||
extend_backward = bool(extend_flag) or days >= 365
|
||
|
||
def _on_chunk(done: int, total: int, seg_label: str) -> None:
|
||
# 进度映射: 10% (标的池解析完) → 95%, 留 5% 给写入+刷新
|
||
pct = 10 + int((done / max(total, 1)) * 85)
|
||
action = "补拉缺口分钟K" if repair_symbols is not None else "拉取分钟K"
|
||
progress("sync_minute", pct, f"{action}… {done}/{total} 批 [{seg_label}]")
|
||
|
||
def _run():
|
||
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
|
||
|
||
_refresh_single_view(repo, "kline_minute")
|
||
|
||
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:
|
||
# 已由 terminate() 标记失败, 拉取线程在分块回调处自行退出
|
||
invalidate_storage_cache()
|
||
except Exception as e: # noqa: BLE001
|
||
job_store.fail(job_id, str(e))
|
||
invalidate_storage_cache()
|
||
finally:
|
||
release_run_slot(job_id)
|
||
|
||
asyncio.create_task(task())
|
||
return {"status": "started", "job_id": job_id}
|
||
|
||
|
||
@router.post("/sync_minute_single")
|
||
async def sync_minute_single(request: Request, body: dict):
|
||
"""手动拉取单只股票的分钟K并落库 (口径随基准标记: 未迁移=前复权, 已迁移=原始)。
|
||
|
||
body: { "symbol": "000001.SZ" }
|
||
用于个股分时图"获取数据"按钮: 本地无数据时单独拉取并持久化。
|
||
"""
|
||
import asyncio
|
||
|
||
from app.services.preferences import get_minute_sync_days
|
||
|
||
symbol = body.get("symbol", "").strip()
|
||
if not symbol:
|
||
raise HTTPException(status_code=400, detail="symbol 不能为空")
|
||
|
||
requested_days = body.get("days")
|
||
if requested_days is not None:
|
||
if isinstance(requested_days, bool) or not isinstance(requested_days, int):
|
||
raise HTTPException(status_code=400, detail="days 必须是整数")
|
||
if requested_days < 1 or requested_days > 30:
|
||
raise HTTPException(status_code=400, detail="days 必须在 1 到 30 之间")
|
||
|
||
repo = request.app.state.repo
|
||
capset = request.app.state.capabilities
|
||
|
||
# 指数分钟K无本地存储, 落库会污染股票分钟表 kline_minute;
|
||
# 指数分钟数据走 /api/index/minute 实时读取, 此端点显式拒绝。
|
||
if repo.resolve_asset_type(symbol) == "index":
|
||
raise HTTPException(status_code=400, detail="指数分钟K不支持落库同步 (指数分钟数据走 /api/index/minute 实时读取)")
|
||
|
||
if not _minute_allowed(capset):
|
||
raise HTTPException(status_code=403, detail="需要 Pro+ 权限")
|
||
|
||
days = requested_days if requested_days is not None else get_minute_sync_days()
|
||
loop = asyncio.get_event_loop()
|
||
|
||
def _run():
|
||
return kline_sync.sync_and_persist_minute([symbol], repo, capset, days=days, force_full_days=True)
|
||
|
||
written = await loop.run_in_executor(_long_task_executor, _run)
|
||
|
||
# 刷新视图
|
||
from app.jobs.daily_pipeline import _refresh_single_view
|
||
_refresh_single_view(repo, "kline_minute")
|
||
|
||
return {"status": "ok", "symbol": symbol, "rows": written}
|
||
|
||
|
||
@router.get("/minute-gaps")
|
||
def get_minute_gaps(
|
||
request: Request,
|
||
symbols: str | None = Query(None, description="逗号分隔, 只看这些标的"),
|
||
dates: str | None = Query(None, description="逗号分隔 YYYY-MM-DD, 只看这些交易日"),
|
||
limit: int = Query(500, ge=1, le=2000, description="最多返回的缺口行数"),
|
||
):
|
||
"""分钟数据缺口体检: 列出本地分区中分钟 K 不完整的 (标的, 交易日)。
|
||
|
||
只做**发现**, 不做补齐 —— 上游分钟源通常只提供最近 N 天, 超出窗口的历史
|
||
缺口补不回来, 盲目补拉会白等且返回空。前端据此展示残缺清单并标注
|
||
「可补 / 超出源窗口」, 避免用户误以为「同步完成 = 数据完整」。
|
||
"""
|
||
from app.services.minute_gaps import scan_minute_gaps
|
||
|
||
repo = request.app.state.repo
|
||
now = cn_now()
|
||
return scan_minute_gaps(
|
||
repo.store.data_dir / "kline_minute",
|
||
today=cn_today(),
|
||
now_hm=(now.hour, now.minute),
|
||
symbols=[s.strip() for s in symbols.split(",") if s.strip()] if symbols else None,
|
||
dates=[d.strip() for d in dates.split(",") if d.strip()] if dates else None,
|
||
limit=limit,
|
||
)
|
||
|
||
|
||
@router.post("/minute-migrate")
|
||
async def minute_migrate(request: Request):
|
||
"""存量分钟K迁移为原始口径并启用读取时复权 (幂等, 见 services/minute_adjust)。
|
||
|
||
- 用日K原始收盘价做锚点, 把历史"拉取时前复权"的分区换算回原始价;
|
||
已是原始价的行 (全量分钟落盘) 锚点 k≈1 自动跳过;
|
||
- 全部分区成功后创建 .raw_basis 标记: 此后读取自动复权投影、拉取改取原始价;
|
||
- 失败不标记, 可重复调用续跑 (已换算分区二次运行为 no-op)。
|
||
返回 { partitions, converted_symbols, skipped_no_daily, failed, marked }。
|
||
"""
|
||
import asyncio
|
||
|
||
repo = request.app.state.repo
|
||
loop = asyncio.get_event_loop()
|
||
stats = await loop.run_in_executor(
|
||
_long_task_executor,
|
||
lambda: minute_adjust.migrate_minute_to_raw(
|
||
repo.store.data_dir, write_lock=repo._write_lock,
|
||
),
|
||
)
|
||
if stats.get("marked"):
|
||
from app.jobs.daily_pipeline import _refresh_single_view
|
||
_refresh_single_view(repo, "kline_minute")
|
||
_refresh_single_view(repo, "kline_etf_minute")
|
||
return {"status": "ok", **stats}
|
||
|
||
|
||
@router.post("/clear_minute")
|
||
async def clear_minute(request: Request):
|
||
"""清空全部分钟K数据 (仅 kline_minute, 不影响其他数据)。
|
||
|
||
删除 data/kline_minute/ 下所有分区 parquet, 刷新视图。
|
||
需二次确认: body { "confirm": true }。
|
||
"""
|
||
import shutil
|
||
|
||
body = await request.json() if request.method == "POST" else {}
|
||
if not body.get("confirm"):
|
||
raise HTTPException(status_code=400, detail="需传 confirm: true 以确认清空")
|
||
|
||
repo = request.app.state.repo
|
||
minute_dir = repo.store.data_dir / "kline_minute"
|
||
|
||
# 统计待删除行数 (用于返回)
|
||
removed = 0
|
||
if minute_dir.exists():
|
||
try:
|
||
# execute_one (cursor+close): 直连 db.execute 的未消费结果集会在 Windows 上
|
||
# 钉住分区句柄, 导致下方 rmtree 静默删不掉被钉文件
|
||
result = repo.execute_one("SELECT COUNT(*) AS cnt FROM kline_minute")
|
||
removed = result[0] if result else 0
|
||
except Exception:
|
||
pass
|
||
# 仅删 kline_minute 目录, 绝不触碰其他目录
|
||
shutil.rmtree(minute_dir, ignore_errors=True)
|
||
|
||
# 刷新视图 (重建空视图)
|
||
from app.jobs.daily_pipeline import _refresh_single_view
|
||
_refresh_single_view(repo, "kline_minute")
|
||
|
||
from app.api.data import invalidate_storage_cache
|
||
invalidate_storage_cache()
|
||
|
||
logger.info("minute K cleared: %d rows removed", removed)
|
||
return {"status": "ok", "removed": removed}
|
||
|
||
|
||
@router.post("/extend_history")
|
||
async def extend_history(request: Request):
|
||
"""向前扩展历史日K数据 — 独立于盘后管道。
|
||
|
||
body: { "value": int, "unit": "day"|"month"|"year" }
|
||
返回 job_id,可轮询 /api/pipeline/jobs 查看进度。
|
||
"""
|
||
import asyncio
|
||
import traceback as _tb
|
||
try:
|
||
body = await request.json()
|
||
value = body.get("value")
|
||
unit = body.get("unit", "month")
|
||
if not value or value <= 0:
|
||
raise HTTPException(status_code=400, detail="value 必须为正整数")
|
||
if unit not in ("day", "month", "year"):
|
||
raise HTTPException(status_code=400, detail="unit 只支持 day/month/year")
|
||
|
||
repo = request.app.state.repo
|
||
capset = request.app.state.capabilities
|
||
|
||
from app.tickflow.capabilities import Cap
|
||
if not capset.has(Cap.KLINE_DAILY_BATCH):
|
||
raise HTTPException(status_code=403, detail="需要 Pro+ 权限 (batch K-line)")
|
||
|
||
from app.api.data import invalidate_storage_cache
|
||
from app.services.extend_history import run_extend_history
|
||
from app.services.pipeline_jobs import (
|
||
JobCancelledError,
|
||
job_store,
|
||
release_run_slot,
|
||
run_with_capacity,
|
||
try_acquire_run_slot,
|
||
)
|
||
|
||
job_id, is_new = job_store.create()
|
||
if not is_new:
|
||
return {"status": "reused", "job_id": job_id}
|
||
|
||
async def task() -> None:
|
||
if not try_acquire_run_slot(job_id):
|
||
job_store.fail(job_id, "已有数据任务在运行(或上一次任务卡死未结束),请稍后再试")
|
||
return
|
||
loop = asyncio.get_event_loop()
|
||
|
||
def progress(stage: str, pct: int, msg: str,
|
||
stage_pct: int | None = None, skip_log: bool = False) -> None:
|
||
job_store.progress(job_id, stage, pct, msg,
|
||
stage_pct=stage_pct, skip_log=skip_log)
|
||
|
||
try:
|
||
result = await loop.run_in_executor(
|
||
_long_task_executor, run_with_capacity, job_id,
|
||
lambda: run_extend_history(repo, capset, value, unit, on_progress=progress),
|
||
)
|
||
if "error" in result:
|
||
job_store.fail(job_id, result["error"])
|
||
else:
|
||
job_store.succeed(job_id, result)
|
||
invalidate_storage_cache()
|
||
except JobCancelledError:
|
||
# 已由 terminate() 标记失败, 拉取线程在分块回调处自行退出
|
||
invalidate_storage_cache()
|
||
except Exception as e:
|
||
logger.exception("extend_history failed: job_id=%s", job_id)
|
||
job_store.fail(job_id, str(e))
|
||
invalidate_storage_cache()
|
||
finally:
|
||
release_run_slot(job_id)
|
||
|
||
asyncio.create_task(task())
|
||
return {"status": "started", "job_id": job_id}
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error("extend_history error: %s\n%s", e, _tb.format_exc())
|
||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||
|
||
|
||
@router.post("/repair_daily")
|
||
async def repair_daily(request: Request):
|
||
"""修正 / 补全日K数据 — 从指定起始日期重拉到今天。
|
||
|
||
典型场景: 昨天没看盘 / 服务挂了,本地日K缺了若干天。
|
||
用户选起始日期,复用盘后管道全流程重拉 [start_date ~ 今天]。
|
||
|
||
body: { "start_date": "YYYY-MM-DD" }
|
||
返回 job_id,可轮询 /api/pipeline/jobs 查看进度。
|
||
"""
|
||
import asyncio
|
||
import traceback as _tb
|
||
from datetime import date as _date
|
||
try:
|
||
body = await request.json()
|
||
raw = body.get("start_date")
|
||
if not raw:
|
||
raise HTTPException(status_code=400, detail="start_date 必填 (YYYY-MM-DD)")
|
||
try:
|
||
start_date = _date.fromisoformat(str(raw))
|
||
except ValueError:
|
||
raise HTTPException(status_code=400, detail="start_date 格式错误 (应为 YYYY-MM-DD)")
|
||
|
||
if start_date > cn_today():
|
||
raise HTTPException(status_code=400, detail="起始日期不能晚于今天")
|
||
|
||
repo = request.app.state.repo
|
||
capset = request.app.state.capabilities
|
||
|
||
from app.tickflow.capabilities import Cap
|
||
if not capset.has(Cap.KLINE_DAILY_BATCH):
|
||
raise HTTPException(status_code=403, detail="需要 Pro+ 权限 (batch K-line)")
|
||
|
||
from app.api.data import invalidate_storage_cache
|
||
from app.services.pipeline_jobs import (
|
||
JobCancelledError,
|
||
job_store,
|
||
release_run_slot,
|
||
run_with_capacity,
|
||
try_acquire_run_slot,
|
||
)
|
||
from app.services.repair_daily import run_repair_daily
|
||
|
||
job_id, is_new = job_store.create()
|
||
if not is_new:
|
||
return {"status": "reused", "job_id": job_id}
|
||
|
||
async def task() -> None:
|
||
if not try_acquire_run_slot(job_id):
|
||
job_store.fail(job_id, "已有数据任务在运行(或上一次任务卡死未结束),请稍后再试")
|
||
return
|
||
loop = asyncio.get_event_loop()
|
||
qs = getattr(request.app.state, "quote_service", None)
|
||
|
||
def progress(stage: str, pct: int, msg: str,
|
||
stage_pct: int | None = None, skip_log: bool = False) -> None:
|
||
job_store.progress(job_id, stage, pct, msg,
|
||
stage_pct=stage_pct, skip_log=skip_log)
|
||
|
||
def _run() -> dict:
|
||
# 修正运行期间暂停实时行情, 防止覆写同一批 parquet 竞态
|
||
if qs:
|
||
with qs.paused():
|
||
return run_repair_daily(repo, capset, start_date, on_progress=progress)
|
||
return run_repair_daily(repo, capset, start_date, on_progress=progress)
|
||
|
||
try:
|
||
result = await loop.run_in_executor(_long_task_executor, run_with_capacity, job_id, _run)
|
||
if "error" in result:
|
||
job_store.fail(job_id, result["error"])
|
||
else:
|
||
job_store.succeed(job_id, result)
|
||
invalidate_storage_cache()
|
||
except JobCancelledError:
|
||
# 已由 terminate() 标记失败, 拉取线程在分块回调处自行退出
|
||
invalidate_storage_cache()
|
||
except Exception as e:
|
||
logger.exception("repair_daily failed: job_id=%s", job_id)
|
||
job_store.fail(job_id, str(e))
|
||
invalidate_storage_cache()
|
||
finally:
|
||
release_run_slot(job_id)
|
||
|
||
asyncio.create_task(task())
|
||
return {"status": "started", "job_id": job_id}
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error("repair_daily error: %s\n%s", e, _tb.format_exc())
|
||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||
|
||
|
||
@router.post("/rebuild_enriched")
|
||
async def rebuild_enriched(request: Request):
|
||
"""全量重算 enriched 表 — 不获取任何数据,仅基于已有 kline_daily + adj_factor 重算复权+指标。
|
||
|
||
返回 job_id,可轮询 /api/pipeline/jobs 查看进度。
|
||
"""
|
||
import asyncio
|
||
try:
|
||
repo = request.app.state.repo
|
||
|
||
from app.api.data import invalidate_storage_cache
|
||
from app.services.pipeline_jobs import (
|
||
JobCancelledError,
|
||
job_store,
|
||
release_run_slot,
|
||
run_with_capacity,
|
||
try_acquire_run_slot,
|
||
)
|
||
|
||
job_id, is_new = job_store.create()
|
||
if not is_new:
|
||
return {"status": "reused", "job_id": job_id}
|
||
|
||
async def task() -> None:
|
||
if not try_acquire_run_slot(job_id):
|
||
job_store.fail(job_id, "已有数据任务在运行(或上一次任务卡死未结束),请稍后再试")
|
||
return
|
||
loop = asyncio.get_event_loop()
|
||
|
||
def progress(stage: str, pct: int, msg: str,
|
||
stage_pct: int | None = None, skip_log: bool = False) -> None:
|
||
job_store.progress(job_id, stage, pct, msg,
|
||
stage_pct=stage_pct, skip_log=skip_log)
|
||
|
||
try:
|
||
progress("rebuild_enriched", 10, "全量计算 enriched…")
|
||
from app.indicators.pipeline import run_pipeline
|
||
|
||
def _batch_progress(cur: int, tot: int) -> None:
|
||
pct = 10 + int(85 * cur / tot)
|
||
progress("rebuild_enriched", pct,
|
||
f"计算指标 批次 {cur}/{tot}",
|
||
stage_pct=int(100 * cur / tot), skip_log=True)
|
||
|
||
written = await loop.run_in_executor(
|
||
_long_task_executor, run_with_capacity, job_id,
|
||
lambda: run_pipeline(on_batch_done=_batch_progress),
|
||
)
|
||
|
||
enriched_dir = repo.store.data_dir / "kline_daily_enriched"
|
||
enriched_days = len(list(enriched_dir.glob("date=*"))) if enriched_dir.exists() else 0
|
||
|
||
# 刷新视图
|
||
d = repo.store.data_dir.as_posix()
|
||
for view_name, glob in [
|
||
("kline_enriched", f"{d}/kline_daily_enriched/**/*.parquet"),
|
||
]:
|
||
repo.refresh_view(view_name, glob)
|
||
|
||
# 失效内存 enriched 快照: get_enriched_latest 对非空缓存不校验
|
||
# generation, 只刷 DuckDB 视图不重载内存缓存的话, 重建结果对
|
||
# 自选/筛选/监控等页面不可见, 直到进程重启 (对照 /api/data/clear
|
||
# 的同类修复)。clear 无条件清空 (refresh 在磁盘无数据时会提前
|
||
# return 留下旧缓存), refresh 重建; 在 executor 执行避免阻塞事件循环。
|
||
def _reload_memory_cache() -> None:
|
||
repo.clear_cache()
|
||
repo.refresh_cache()
|
||
|
||
await loop.run_in_executor(_long_task_executor, _reload_memory_cache)
|
||
|
||
progress("rebuild_enriched", 100, f"完成,覆盖 {enriched_days} 天")
|
||
job_store.succeed(job_id, {
|
||
"enriched_days": enriched_days,
|
||
"enriched_rows": written,
|
||
})
|
||
invalidate_storage_cache()
|
||
except JobCancelledError:
|
||
# 已由 terminate() 标记失败, 拉取线程在分块回调处自行退出
|
||
invalidate_storage_cache()
|
||
except Exception as e:
|
||
logger.exception("rebuild_enriched failed: job_id=%s", job_id)
|
||
job_store.fail(job_id, str(e))
|
||
invalidate_storage_cache()
|
||
finally:
|
||
release_run_slot(job_id)
|
||
|
||
asyncio.create_task(task())
|
||
return {"status": "started", "job_id": job_id}
|
||
except Exception as e:
|
||
import traceback as _tb
|
||
logger.error("rebuild_enriched error: %s\n%s", e, _tb.format_exc())
|
||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||
|
||
|
||
# 长时间任务专用线程池(隔离于 FastAPI 默认线程池,防止阻塞请求处理)
|
||
import concurrent.futures as _cf
|
||
|
||
_long_task_executor = _cf.ThreadPoolExecutor(max_workers=2, thread_name_prefix="long-task")
|