fix(capabilities): redetect 同步刷新 app.state 快照并升级 tickflow SDK 0.1.25

- /api/capabilities/redetect 重新探测后同步写回 app.state.capabilities,
  minute_refresh 等门控服务即时读到新档位, 不再需要重启
- tickflow 0.1.24 缺 intraday_universe 方法导致 Expert 档全量分钟探测失败,
  升级到 0.1.25
- 全量分钟轮次异常与模式补充 warning 日志, 便于线上诊断
This commit is contained in:
shy3130
2026-08-31 16:50:12 +08:00
parent 4d27f3139e
commit e287a55e82
3 changed files with 1869 additions and 1842 deletions
+7 -2
View File
@@ -1,7 +1,7 @@
"""API 路由 — Phase 0 仅 /health 与 /api/capabilities。""" """API 路由 — Phase 0 仅 /health 与 /api/capabilities。"""
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter from fastapi import APIRouter, Request
from app import __version__ from app import __version__
from app.tickflow import client as tf_client from app.tickflow import client as tf_client
@@ -31,9 +31,14 @@ def capabilities() -> dict:
@router.post("/api/capabilities/redetect") @router.post("/api/capabilities/redetect")
def redetect() -> dict: def redetect(request: Request) -> dict:
"""用户在设置页"重新检测"按钮。""" """用户在设置页"重新检测"按钮。"""
capset = detect_capabilities(force=True) capset = detect_capabilities(force=True)
# 同步刷新 app.state 快照 (minute_refresh 等服务的门控读这里) 与财务调度器,
# 与 settings.py 各探测路径一致 — 否则重检测后服务侧仍读旧 capset 被错误门控
request.app.state.capabilities = capset
from app.api.settings import _sync_financial_scheduler_caps
_sync_financial_scheduler_caps(request.app.state, capset)
return { return {
"label": tier_label(), "label": tier_label(),
"capabilities": capset.to_dict(), "capabilities": capset.to_dict(),
+22
View File
@@ -34,6 +34,7 @@
from __future__ import annotations from __future__ import annotations
import contextlib import contextlib
import logging
import threading import threading
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -44,6 +45,8 @@ import polars as pl
from app.market_time import cn_now, cn_today, in_continuous_session from app.market_time import cn_now, cn_today, in_continuous_session
from app.services import preferences from app.services import preferences
logger = logging.getLogger(__name__)
# 轮询间隔允许范围 (秒): 稳态轮单请求无并发脉冲, 下限 3s; # 轮询间隔允许范围 (秒): 稳态轮单请求无并发脉冲, 下限 3s;
# 上限 120s — universe 端点每标的只回最新 3 根, 间隔超过 3 分钟必留缺口, # 上限 120s — universe 端点每标的只回最新 3 根, 间隔超过 3 分钟必留缺口,
# 每轮都会触发修复轮, 稳态设计失效, 故不允许配到 120s 以上。 # 每轮都会触发修复轮, 稳态设计失效, 故不允许配到 120s 以上。
@@ -187,6 +190,7 @@ class MinuteRefreshService:
continue continue
except Exception as e: except Exception as e:
self._state.last_error = f"round failed: {e}" self._state.last_error = f"round failed: {e}"
logger.warning("全量分钟轮次异常: %s", e)
self._stop.wait(_LOOP_STEP_S) self._stop.wait(_LOOP_STEP_S)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -231,8 +235,11 @@ class MinuteRefreshService:
t0 = time.perf_counter() t0 = time.perf_counter()
mode = self._select_mode() mode = self._select_mode()
mode_label = "增量" if mode == "increment" else "全天修复"
with self._round_lock: with self._round_lock:
if mode == "increment": if mode == "increment":
# fetch 计时只覆盖网络取数; full 分支的 universe 维表读取不计入
fetch_started = time.perf_counter()
df, requests = kline_sync.fetch_intraday_universe_increment() df, requests = kline_sync.fetch_intraday_universe_increment()
self._state.last_symbols = ( self._state.last_symbols = (
df["symbol"].n_unique() if not df.is_empty() else 0 df["symbol"].n_unique() if not df.is_empty() else 0
@@ -242,18 +249,27 @@ class MinuteRefreshService:
self._state.last_symbols = len(symbols) self._state.last_symbols = len(symbols)
if not symbols: if not symbols:
self._state.last_error = "empty universe (instruments 未加载)" self._state.last_error = "empty universe (instruments 未加载)"
logger.warning("全量分钟[%s] 本轮中止: 标的池为空 (instruments 未加载)", mode_label)
return return
capset = getattr(self._app_state, "capabilities", None) if self._app_state else None capset = getattr(self._app_state, "capabilities", None) if self._app_state else None
fetch_started = time.perf_counter()
df, requests = kline_sync.fetch_intraday_full_market_burst(symbols, capset) df, requests = kline_sync.fetch_intraday_full_market_burst(symbols, capset)
fetch_ms = (time.perf_counter() - fetch_started) * 1000
self._state.last_requests = requests self._state.last_requests = requests
if df.is_empty(): if df.is_empty():
self._empty_rounds += 1 self._empty_rounds += 1
self._state.last_error = f"intraday {mode} returned no data" self._state.last_error = f"intraday {mode} returned no data"
logger.warning(
"全量分钟[%s] 本轮返回空数据: %d 请求, 取数 %.0fms",
mode_label, requests, fetch_ms,
)
return return
self._empty_rounds = 0 self._empty_rounds = 0
write_started = time.perf_counter()
written = kline_sync._write_minute_partition( written = kline_sync._write_minute_partition(
df, self._repo.store.data_dir / "kline_minute", df, self._repo.store.data_dir / "kline_minute",
) )
write_ms = (time.perf_counter() - write_started) * 1000
self._state.rounds += 1 self._state.rounds += 1
self._state.last_round_at = time.time() self._state.last_round_at = time.time()
@@ -261,6 +277,12 @@ class MinuteRefreshService:
self._state.last_rows = written self._state.last_rows = written
self._state.last_mode = mode self._state.last_mode = mode
self._state.last_error = None self._state.last_error = None
logger.info(
"全量分钟[%s] 第 %d 轮: 取数 %.0fms (%d 请求, %d 标的), "
"落盘 %.0fms (%d 行), 总计 %.0fms",
mode_label, self._state.rounds, fetch_ms, self._state.last_requests,
self._state.last_symbols, write_ms, written, self._state.last_round_ms,
)
def _universe(self) -> list[str]: def _universe(self) -> list[str]:
"""全市场 A 股标的 (instruments 维表, 与盘后分钟同步同一来源)。""" """全市场 A 股标的 (instruments 维表, 与盘后分钟同步同一来源)。"""
+1840 -1840
View File
File diff suppressed because it is too large Load Diff