diff --git a/backend/app/api/kline.py b/backend/app/api/kline.py index 279fd0b..666f8c2 100644 --- a/backend/app/api/kline.py +++ b/backend/app/api/kline.py @@ -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;如果是今天(盘中),期望条数按已交易分钟估算 diff --git a/backend/app/market_time.py b/backend/app/market_time.py index f98835a..d25e212 100644 --- a/backend/app/market_time.py +++ b/backend/app/market_time.py @@ -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 计算当日已交易分钟数。 diff --git a/backend/app/services/minute_refresh.py b/backend/app/services/minute_refresh.py index 43035de..5dd621c 100644 --- a/backend/app/services/minute_refresh.py +++ b/backend/app/services/minute_refresh.py @@ -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 diff --git a/backend/tests/test_kline_minute_live.py b/backend/tests/test_kline_minute_live.py new file mode 100644 index 0000000..7764024 --- /dev/null +++ b/backend/tests/test_kline_minute_live.py @@ -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 diff --git a/frontend/src/components/StockMultiDayIntradayChart.tsx b/frontend/src/components/StockMultiDayIntradayChart.tsx index 852fb37..3024129 100644 --- a/frontend/src/components/StockMultiDayIntradayChart.tsx +++ b/frontend/src/components/StockMultiDayIntradayChart.tsx @@ -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, }) diff --git a/frontend/src/components/StockPreviewDialog.tsx b/frontend/src/components/StockPreviewDialog.tsx index 968d915..3ca187a 100644 --- a/frontend/src/components/StockPreviewDialog.tsx +++ b/frontend/src/components/StockPreviewDialog.tsx @@ -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 diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ef5976d..33b504d 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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<{