fix(kline): 个股详情分时图独立轮询, 盘中直接实时拉取最新K

- /api/kline/minute 新增 live 参数: 当日连续竞价时段跳过本地优先直接
  实时拉取, 避免盘中分钟增量落盘后 90% 完整度启发式让详情分时图停在
  上一增量轮 (>=60s 滞后), 与行情列表节奏脱节
- StockPreviewDialog 打开即独立轮询 (间隔沿用偏好, 默认 6s), 不再要求
  「分时刷新开关 + 实时行情运行」双条件 — PR #114 引入的门槛默认冻结详情图
- market_time 抽出公共 in_continuous_session, minute_refresh 复用同一实现
- 新增 12 个单测覆盖 live 路径, 本地回退与连续竞价时段边界
This commit is contained in:
shy3130
2026-08-30 19:05:14 +08:00
parent 7ceddc0669
commit 08701f8886
7 changed files with 174 additions and 19 deletions
+18 -1
View File
@@ -10,7 +10,7 @@ from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Request
from app.indicators.pipeline import compute_enriched, compute_enriched_single
from app.market_time import cn_now, cn_today
from app.market_time import cn_now, cn_today, in_continuous_session
from app.price_limits import is_risk_warning_name, price_limit_pct
from app.db_safe import is_valid_ext_ident
from app.services import kline_sync
@@ -780,11 +780,15 @@ 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 实时拉取返回(不写入)
- live=true 且当日连续竞价时段 → 跳过本地优先直接实时拉取:
盘中分钟增量落盘的本地分区按 ≥60s 轮次更新, 90% 完整度启发式会让
详情分时图停在上一增量轮, 与行情列表的节奏脱节
"""
repo = request.app.state.repo
asset_type = repo.resolve_asset_type(symbol)
@@ -834,6 +838,19 @@ def get_minute(
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)
if not live_df.is_empty():
return {
"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,
}
df = repo.get_minute(symbol, trade_date, asset_type=asset_type)
# 完整交易日应有 240 条分钟K;如果是今天(盘中),期望条数按已交易分钟估算
+9
View File
@@ -28,6 +28,15 @@ def cn_today() -> date:
return datetime.now(CN_TZ).date()
def in_continuous_session(now: datetime | None = None) -> bool:
"""A股连续竞价时段 (北京时间): 9:30-11:30 / 13:00-15:00, 仅工作日。"""
now = now or cn_now()
return now.weekday() < 5 and (
_MORNING_START <= now.time() <= _MORNING_END
or _AFTERNOON_START <= now.time() <= _AFTERNOON_END
)
def trading_minutes_elapsed_from_dt(dt: datetime) -> float:
"""根据北京时间 datetime 计算当日已交易分钟数。
+2 -7
View File
@@ -24,12 +24,11 @@ from __future__ import annotations
import threading
import time
from dataclasses import dataclass, field
from datetime import time as dt_time
from typing import Any
import polars as pl
from app.market_time import cn_now
from app.market_time import in_continuous_session
from app.services import preferences
# 轮询间隔允许范围 (秒): 下限 60s 保证任何滑动窗口 ≤1 个脉冲, 上限防误配。
@@ -41,11 +40,7 @@ _LOOP_STEP_S = 2.0
def _in_continuous_session(now=None) -> bool:
"""A股连续竞价时段 (北京时间): 9:30-11:30 / 13:00-15:00, 仅工作日。"""
now = now or cn_now()
t = now.time()
morning = dt_time(9, 30) <= t <= dt_time(11, 30)
afternoon = dt_time(13, 0) <= t <= dt_time(15, 0)
return now.weekday() < 5 and (morning or afternoon)
return in_continuous_session(now)
@dataclass
+137
View File
@@ -0,0 +1,137 @@
"""个股详情分时轮询的 live 直拉路径测试。
背景: 盘中分钟增量落盘后, 当日本地分区很快达到 90% 完整度,
/api/kline/minute 的本地优先启发式会拦截实时补拉, 详情分时图停在
上一增量轮 (≥60s 滞后)。live=1 让详情轮询在连续竞价时段绕过本地优先。
"""
from __future__ import annotations
from datetime import date, datetime
import polars as pl
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.kline import router
from app.market_time import CN_TZ, in_continuous_session
# 2026-08-26 是周三; 10:00 处于上午连续竞价, expected(已交易分钟) = 30
_NOW = datetime(2026, 8, 26, 10, 0, tzinfo=CN_TZ)
_TODAY = date(2026, 8, 26)
_LOCAL_ROWS = 30
class _FakeRepo:
def resolve_asset_type(self, symbol: str) -> str:
return "stock"
def get_instruments(self) -> pl.DataFrame:
return pl.DataFrame(
{"symbol": [], "name": [], "total_shares": [], "float_shares": []}
)
def get_daily_asset(self, asset_type, symbol, start, end, columns=None):
return pl.DataFrame({"date": [], "close": []})
def get_minute(self, symbol, trade_date, asset_type="stock") -> pl.DataFrame:
return pl.DataFrame({
"datetime": [
datetime(2026, 8, 26, 9, 30 + offset // 60, offset % 60)
for offset in range(_LOCAL_ROWS)
],
"close": [10.0] * _LOCAL_ROWS,
})
def _client() -> TestClient:
app = FastAPI()
app.include_router(router)
app.state.repo = _FakeRepo()
return TestClient(app)
def _patch_market(monkeypatch, *, in_session: bool) -> None:
import app.api.kline as kline_api
monkeypatch.setattr(kline_api, "cn_now", lambda: _NOW)
monkeypatch.setattr(kline_api, "cn_today", lambda: _TODAY)
monkeypatch.setattr(kline_api, "in_continuous_session", lambda: in_session)
def _patch_live_fetch(monkeypatch) -> None:
import app.api.kline as kline_api
def _fake_fetch(symbol, trade_date, asset_type="stock"):
return pl.DataFrame({
"datetime": [datetime(2026, 8, 26, 9, 59)],
"close": [11.11],
})
monkeypatch.setattr(
kline_api.kline_sync, "fetch_minute_single", _fake_fetch
)
def test_minute_live_param_bypasses_local_first_during_session(monkeypatch):
_patch_market(monkeypatch, in_session=True)
_patch_live_fetch(monkeypatch)
resp = _client().get(
"/api/kline/minute", params={"symbol": "600000.SH", "live": 1}
)
assert resp.status_code == 200
body = resp.json()
assert body["source"] == "live"
assert body["rows"][0]["close"] == 11.11
def test_minute_without_live_keeps_local_first(monkeypatch):
_patch_market(monkeypatch, in_session=True)
_patch_live_fetch(monkeypatch)
resp = _client().get(
"/api/kline/minute", params={"symbol": "600000.SH"}
)
assert resp.status_code == 200
body = resp.json()
# 本地 30 根 >= expected(30)*0.9 → 完整, 走本地
assert body["source"] == "local"
assert body["rows"][0]["close"] == 10.0
def test_minute_live_param_falls_back_to_local_off_session(monkeypatch):
_patch_market(monkeypatch, in_session=False)
_patch_live_fetch(monkeypatch)
resp = _client().get(
"/api/kline/minute", params={"symbol": "600000.SH", "live": 1}
)
assert resp.status_code == 200
body = resp.json()
assert body["source"] == "local"
@pytest.mark.parametrize(
("hour", "minute", "expected"),
[
(9, 29, False),
(9, 30, True),
(11, 30, True),
(11, 31, False),
(12, 30, False),
(13, 0, True),
(15, 0, True),
(15, 1, False),
],
)
def test_in_continuous_session_boundaries(hour: int, minute: int, expected: bool):
now = datetime(2026, 8, 26, hour, minute, tzinfo=CN_TZ) # 周三
assert in_continuous_session(now) is expected
def test_in_continuous_session_rejects_weekend():
assert in_continuous_session(datetime(2026, 8, 29, 10, 0, tzinfo=CN_TZ)) is False
@@ -37,7 +37,8 @@ export function StockMultiDayIntradayChart({
})
const latest = useQuery({
queryKey: QK.klineMinute(symbol, ''),
queryFn: () => api.klineMinute(symbol),
// live: 当日盘中直接实时拉取, 不被分钟增量落盘的本地分区(≥60s一轮)拖慢
queryFn: () => api.klineMinute(symbol, undefined, true),
enabled: !!symbol,
refetchInterval: refetchIntervalMs,
})
@@ -161,15 +161,11 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
return () => clearFocusSymbol()
}, [symbol])
// 分时图实时轮询: 复用自选列表的「分时刷新开关 + 间隔」偏好。
// 实时行情运行 且 用户开启分时刷新时才轮询; 否则 undefined (定格)
// 分时图实时轮询: 详情打开即独立轮询, 不再依赖自选列表的「分时刷新开关
// 实时行情运行状态 (打开详情就是要看实时分时); 间隔沿用偏好, 默认 6s
// 最新一根K由后端 live 参数直接实时拉取, 与行情列表节奏一致。
const { data: prefs } = usePreferences()
const { data: quoteStatus } = useQuoteStatus()
const realtimeRunning = quoteStatus?.running ?? false
const intradayRefreshOn = prefs?.minute_intraday_refresh ?? false
const intradayRefetchMs = (intradayRefreshOn && realtimeRunning)
? (prefs?.minute_intraday_refresh_interval ?? 6) * 1000
: undefined
const intradayRefetchMs = (prefs?.minute_intraday_refresh_interval ?? 6) * 1000
const handleRefresh = () => {
if (!symbol) return
+2 -2
View File
@@ -1936,7 +1936,7 @@ export const api = {
method: 'POST',
body: JSON.stringify(symbols),
}),
klineMinute: (symbol: string, date?: string) =>
klineMinute: (symbol: string, date?: string, live?: boolean) =>
request<{
symbol: string
name?: string
@@ -1948,7 +1948,7 @@ export const api = {
price_limit?: PriceLimitInfo | null
prev_close?: number | null
}>(
`/api/kline/minute?symbol=${encodeURIComponent(symbol)}${date ? `&date=${date}` : ''}`,
`/api/kline/minute?symbol=${encodeURIComponent(symbol)}${date ? `&date=${date}` : ''}${live ? '&live=1' : ''}`,
),
klineMinuteRange: (symbol: string, days = 10) =>
request<{