feat(data): 全量分钟健康时策略页与监控注入切读本地分时

- 策略页: healthy 时解除分时列 100 只截断并 prefer_local 读本地分区
- 监控信号注入: 股票 healthy 时直接读本地当日分区, 免每分钟 bucket
  一次的全量 API 拉取; ETF 不在服务 universe, 不健康/读失败回落原路径
- minute-refresh status 增加 healthy 读侧新鲜度字段 (前端共享判断)
- 测试: 注入本地读 3 用例 (健康读本地/不健康回落/ETF 恒走 API)
This commit is contained in:
shy3130
2026-09-01 13:03:54 +08:00
parent dab8a2c676
commit f84cb671ef
5 changed files with 108 additions and 9 deletions
+2 -1
View File
@@ -298,7 +298,7 @@ class MinuteRefreshService:
def is_healthy(self) -> bool: def is_healthy(self) -> bool:
"""读侧 freshness 判定: 本地 kline_minute 分区是否正被服务持续写入。 """读侧 freshness 判定: 本地 kline_minute 分区是否正被服务持续写入。
条件: 偏好开启 + 轮询线程存活 + 最近一轮距现在 ≤ max(2×间隔, 30s)。 条件: 偏好开启 + 轮询线程存活 + 最近一轮距现在 ≤ max(2*间隔, 30s)。
健康时消费方 (自选分时等) 可信任本地分区、跳过批量补拉; 健康时消费方 (自选分时等) 可信任本地分区、跳过批量补拉;
连续失败轮不更新 last_round_at → 自动超时判不健康, 无需单独盯 last_error。 连续失败轮不更新 last_round_at → 自动超时判不健康, 无需单独盯 last_error。
""" """
@@ -325,6 +325,7 @@ class MinuteRefreshService:
return { return {
"enabled": enabled, "enabled": enabled,
"running": running, "running": running,
"healthy": self.is_healthy(),
"interval_seconds": preferences.get_minute_refresh_interval(), "interval_seconds": preferences.get_minute_refresh_interval(),
"capability_ok": self.capability_ok(), "capability_ok": self.capability_ok(),
"custom_provider_active": self.custom_provider_active(), "custom_provider_active": self.custom_provider_active(),
+17 -4
View File
@@ -1241,11 +1241,24 @@ class QuoteService:
) )
capset = getattr(self._app_state, "capabilities", None) capset = getattr(self._app_state, "capabilities", None)
support = intraday_monitor_support(capset)
if not support["available"] or len(symbols) > int(support["max_symbols"]):
return self._intraday_signal_evaluator.inject(enriched, [])
minute_df = fetch_intraday_monitor_batch(sorted(symbols), capset, now=now) # 全量分钟健康时股票读本地分区 (服务按间隔持续落盘, 与 API 同一列契约),
# 免去每分钟 bucket 一次的全量 API 拉取; ETF 不在服务 universe 内,
# 本地读空/异常回落原 API 路径 (含能力与上限检查)
minute_df = pl.DataFrame()
if asset_type == "stock":
svc = getattr(self._app_state, "minute_refresh", None) if self._app_state else None
if svc is not None and svc.is_healthy() and self._repo is not None:
try:
minute_df = self._repo.get_minute_batch(sorted(symbols), cn_today())
except Exception as e: # 本地读异常回落 API
logger.warning("分时信号本地读失败, 回退 API 路径: %s", e)
minute_df = pl.DataFrame()
if minute_df.is_empty():
support = intraday_monitor_support(capset)
if not support["available"] or len(symbols) > int(support["max_symbols"]):
return self._intraday_signal_evaluator.inject(enriched, [])
minute_df = fetch_intraday_monitor_batch(sorted(symbols), capset, now=now)
prev_close: dict[str, float] = {} prev_close: dict[str, float] = {}
available_cols = set(enriched.columns) available_cols = set(enriched.columns)
for row in enriched.filter(pl.col("symbol").is_in(sorted(symbols))).iter_rows(named=True): for row in enriched.filter(pl.col("symbol").is_in(sorted(symbols))).iter_rows(named=True):
@@ -11,6 +11,8 @@ from __future__ import annotations
from types import SimpleNamespace from types import SimpleNamespace
from typing import ClassVar from typing import ClassVar
import polars as pl
from app.services import quote_service as qs from app.services import quote_service as qs
from app.services.index_const import CORE_INDEX_SYMBOLS from app.services.index_const import CORE_INDEX_SYMBOLS
@@ -114,3 +116,76 @@ def test_custom_provider_index_fetch_error_is_soft(monkeypatch):
service, captured = _service_with_provider(monkeypatch, _Boom()) service, captured = _service_with_provider(monkeypatch, _Boom())
service._fetch_full_market_quotes() service._fetch_full_market_quotes()
assert len(captured) == 1 and captured[0][0]["symbol"] == "600519.SH" assert len(captured) == 1 and captured[0][0]["symbol"] == "600519.SH"
# ---- 监控分时注入: 全量分钟健康时股票读本地分区 ----
def _injection_env(monkeypatch, *, healthy, local_df, asset_type="stock", symbols=None):
"""构造 _inject_intraday_signals 最小环境, 捕获传入 evaluator 的 minute_df。"""
symbols = symbols or {"600519.SH"}
service = qs.QuoteService()
service._repo = SimpleNamespace(
get_minute_batch=lambda syms, d: local_df,
)
engine = SimpleNamespace(
intraday_signal_symbols=lambda at: set(symbols) if at == asset_type else set(),
)
minute_svc = SimpleNamespace(is_healthy=lambda: healthy)
service._app_state = SimpleNamespace(minute_refresh=minute_svc)
import app.services.quote_service as qsm
api_calls: list[list[str]] = []
monkeypatch.setattr(
qsm, "_noop", qsm.__dict__.get("_noop", None), raising=False) # 占位无操作
from app.services.kline_sync import intraday_monitor_support
monkeypatch.setattr(
"app.services.quote_service.logger", qsm.logger, raising=False)
# 打桩 API 拉取路径 (健康时不应被调)
import app.services.kline_sync as ks
monkeypatch.setattr(
ks, "fetch_intraday_monitor_batch",
lambda symbols, capset, *, now=None: (api_calls.append(list(symbols)), local_df)[1])
monkeypatch.setattr(
ks, "intraday_monitor_support",
lambda capset: {"available": True, "max_symbols": 200, "source": "minute_batch"})
evaluator = SimpleNamespace(
evaluate=lambda minute_df, **kw: (captured.append(minute_df), [])[1],
inject=lambda enriched, signals: enriched,
)
captured: list = []
service._intraday_signal_evaluator = evaluator
service._intraday_signal_bucket = {}
return service, engine, api_calls, captured
def test_intraday_signals_read_local_when_healthy(monkeypatch):
"""健康时股票读本地分区, 不触发 API 拉取。"""
local = pl.DataFrame({
"symbol": ["600519.SH"], "datetime": ["2026-01-15 09:31:00"],
"open": [100.0], "high": [101.0], "low": [99.0], "close": [100.5],
"volume": [1000.0], "amount": [100500.0],
})
service, engine, api_calls, captured = _injection_env(monkeypatch, healthy=True, local_df=local)
enriched = pl.DataFrame({"symbol": ["600519.SH"], "close": [100.5]})
service._inject_intraday_signals(enriched, engine, asset_type="stock")
assert api_calls == [] # 未走 API
assert captured and captured[0].height == 1 # evaluator 拿到本地数据
def test_intraday_signals_fall_back_to_api_when_unhealthy(monkeypatch):
"""不健康 (服务关/挂) 时回落原 API 拉取路径。"""
service, engine, api_calls, captured = _injection_env(monkeypatch, healthy=False, local_df=pl.DataFrame())
enriched = pl.DataFrame({"symbol": ["600519.SH"], "close": [100.5]})
service._inject_intraday_signals(enriched, engine, asset_type="stock")
assert api_calls == [["600519.SH"]] # 走了 API
def test_intraday_signals_etf_never_reads_local(monkeypatch):
"""ETF 不在全量分钟 universe: 即使健康也走 API 路径。"""
service, engine, api_calls, captured = _injection_env(
monkeypatch, healthy=True, local_df=pl.DataFrame(), asset_type="etf", symbols={"510300.SH"})
enriched = pl.DataFrame({"symbol": ["510300.SH"], "close": [4.0]})
service._inject_intraday_signals(enriched, engine, asset_type="etf")
assert api_calls == [["510300.SH"]]
+2
View File
@@ -1803,6 +1803,8 @@ export const api = {
available: boolean available: boolean
enabled?: boolean enabled?: boolean
running?: boolean running?: boolean
/** 读侧 freshness: 本地分区正被服务持续写入 (前端据此切本地读/解除截断) */
healthy?: boolean
interval_seconds?: number interval_seconds?: number
capability_ok?: boolean capability_ok?: boolean
custom_provider_active?: boolean custom_provider_active?: boolean
+12 -4
View File
@@ -438,6 +438,14 @@ export function Screener() {
) )
// 分时图依赖分钟K批量数据 (kline.minute.batch), 无数据时开了列也不拉 // 分时图依赖分钟K批量数据 (kline.minute.batch), 无数据时开了列也不拉
const caps = useCapabilities() const caps = useCapabilities()
// 全量分钟服务健康 (freshness 契约): 健康时本地分区按配置间隔持续落盘,
// 分时读本地不受批量上限约束 → 不截断 + prefer_local; 与监控设置页共享缓存
const refreshStatus = useQuery({
queryKey: ['minute-refresh-status'],
queryFn: api.minuteRefreshStatus,
refetchInterval: 15000,
})
const fullMinuteHealthy = !!refreshStatus.data?.healthy
const hasMinuteBatch = !!caps.data?.capabilities?.['kline.minute.batch'] const hasMinuteBatch = !!caps.data?.capabilities?.['kline.minute.batch']
const intradayVisible = !!intradayColumn && hasMinuteBatch && intradayChartVisible const intradayVisible = !!intradayColumn && hasMinuteBatch && intradayChartVisible
@@ -456,11 +464,11 @@ export function Screener() {
() => displayRows.map((r: any) => r.symbol), () => displayRows.map((r: any) => r.symbol),
[displayRows], [displayRows],
) )
const intradayTruncated = intradayVisible && allIntradaySymbols.length > minuteBatchCap // 拉模型 (走批量接口) 才截断到 batch 上限; 全量分钟健康时读本地分区无上限
// 截断到 batch 上限, 一次请求 = 一次数据源调用 const intradayTruncated = intradayVisible && !fullMinuteHealthy && allIntradaySymbols.length > minuteBatchCap
const intradaySymbols = useMemo( const intradaySymbols = useMemo(
() => intradayTruncated ? allIntradaySymbols.slice(0, minuteBatchCap) : allIntradaySymbols, () => intradayTruncated ? allIntradaySymbols.slice(0, minuteBatchCap) : allIntradaySymbols,
[allIntradaySymbols, intradayTruncated, minuteBatchCap], [allIntradaySymbols, intradayTruncated, minuteBatchCap, fullMinuteHealthy],
) )
const intradayRequestSymbols = useMemo( const intradayRequestSymbols = useMemo(
() => [...new Set(intradaySymbols)].sort(), () => [...new Set(intradaySymbols)].sort(),
@@ -471,7 +479,7 @@ export function Screener() {
const minuteBatch = useQuery({ const minuteBatch = useQuery({
queryKey: QK.minuteBatch(intradaySymbolsKey), queryKey: QK.minuteBatch(intradaySymbolsKey),
// 增量轮询: 读缓存以最后一根为 since 只拉新增, 本地合并为完整序列 // 增量轮询: 读缓存以最后一根为 since 只拉新增, 本地合并为完整序列
queryFn: () => fetchMinuteBatchIncremental(qc, QK.minuteBatch(intradaySymbolsKey), intradayRequestSymbols), queryFn: () => fetchMinuteBatchIncremental(qc, QK.minuteBatch(intradaySymbolsKey), intradayRequestSymbols, fullMinuteHealthy),
enabled: intradayVisible && intradayRequestSymbols.length > 0, enabled: intradayVisible && intradayRequestSymbols.length > 0,
staleTime: 10_000, staleTime: 10_000,
placeholderData: previousData => previousData, placeholderData: previousData => previousData,