Files
tick-stock-panel/backend/tests/test_kline_minute_lunch_expected.py
shy3130 d43a3021da feat(kline): 批量上限可配置(前后端同源) + 响应懒净化与 Polars 直出
- 日K/分时批量单请求上限改为系统设置可配 (默认 400), 后端守卫与前端分块
  读同一偏好值, 不再发出必然被拒的请求; 两档独立 (分时成本约为日K 20 倍)
- _gzip_payload 懒净化: 无 NaN/inf 常态跳过 _json_safe 整树深拷贝,
  峰值 -65% (40→14MB/块), CPU -25%
- minute-batch/daily-batch 全程持有 Polars 帧直出 JSON (write_json 行式),
  不再构建 Python dict: 96k 根序列化 1222ms→26ms, 峰值 69→18MB;
  字节级等价由 tests/test_kline_batch_frames_json.py 锁定 (含 inf/NaN/None)
- 白盒测试改为解析 Response body (HTTP 真实契约)
- 随 kline.py 同文件: minute-batch 门控在全量分钟服务健康时放行本地读,
  补拉限速改走 resolve_limit (×0.8, 与盘后同步同 rpm 桶)

实测: 300 只分时(7.2万根)整请求 191ms; 20 并发尖峰 +728MB→+60MB

Signed-off-by: shy3130 <shy3130@qq.com>
2026-10-25 17:31:01 +08:00

185 lines
6.9 KiB
Python

"""个股分时 /minute 与自选分时 /minute-batch 在午休 11:31-12:00 的期望根数。
两个端点按北京时刻估算当日「应有」的分钟K根数, 本地根数够了就直接用本地,
不够才去数据源实时拉取。上午分支写成了 `h < 12 or (h == 12 and m == 0)`,
于是 11:31-12:00 仍按 `(h - 9) * 60 + m - 30` 继续增长 (121..150), 而上午
只有 120 根 (09:31-11:30)。本地完整的上午数据在这段时间被判为「不完整」:
- /minute: 走 fetch_minute_single 实时拉取; 拉空时响应 rows=[]、source="none",
前端 minuteRefetchInterval 见 source="none" 即停止轮询, 分时图空白到重新打开。
- /minute-batch: 被归为「尾部落后」, 每轮对所有自选标的发起增量补拉; 补拉为空时
该标的不出现在响应里。
午休应与 market_time.trading_minutes_elapsed_from_dt 同口径: 保持上午累计 120。
"""
from __future__ import annotations
import json
from datetime import date, datetime, timedelta
from unittest.mock import MagicMock
import polars as pl
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api import kline as kline_api
from app.market_time import CN_TZ
from app.services import preferences, trading_day
from app.tickflow.capabilities import Cap, CapabilityLimits, CapabilitySet
TODAY = date(2026, 9, 15) # 周二, 交易日
SYMBOL = "600519.SH"
def _morning(symbol: str = SYMBOL, bars: int = 120) -> pl.DataFrame:
"""当日上午从 09:31 起连续 bars 根分钟K (120 根 = 完整上午, 到 11:30)。"""
start = datetime(TODAY.year, TODAY.month, TODAY.day, 9, 31)
return pl.DataFrame({
"symbol": [symbol] * bars,
"datetime": [start + timedelta(minutes=i) for i in range(bars)],
"close": [100.0] * bars,
})
@pytest.fixture
def clock(monkeypatch: pytest.MonkeyPatch):
"""钉住交易日与探针, 数据源补拉一律返回空; 返回 (设置北京时刻的函数, 单股拉取 mock, 批量拉取 mock)。"""
monkeypatch.setattr(kline_api, "cn_today", lambda: TODAY)
monkeypatch.setattr(kline_api, "in_continuous_session", lambda: False)
monkeypatch.setattr(trading_day, "is_trading_day", lambda now=None: True)
monkeypatch.setattr(preferences, "get_minute_batch_compress", lambda: False)
fetch = MagicMock(return_value=pl.DataFrame())
monkeypatch.setattr(kline_api.kline_sync, "fetch_minute_single", fetch)
sync = MagicMock(return_value=pl.DataFrame())
monkeypatch.setattr(kline_api.kline_sync, "sync_minute_batch", sync)
def set_time(hour: int, minute: int) -> None:
now = datetime(TODAY.year, TODAY.month, TODAY.day, hour, minute, tzinfo=CN_TZ)
monkeypatch.setattr(kline_api, "cn_now", lambda: now)
return set_time, fetch, sync
# 午休时段: 上午 120 根已是完整数据
LUNCH_TIMES = [(11, 35), (11, 50), (12, 0)]
# ---------- /minute (个股详情分时) ----------
class _FakeRepo:
def __init__(self, bars: int = 120) -> None:
self._bars = bars
# /minute 与 /minute-batch 读取 repo.store.data_dir 判断分钟基准标记
import tempfile
from types import SimpleNamespace
from pathlib import Path
self.store = SimpleNamespace(data_dir=Path(tempfile.mkdtemp()))
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) -> pl.DataFrame:
return pl.DataFrame({"date": [], "close": []})
def get_minute(self, symbol, trade_date, asset_type="stock") -> pl.DataFrame:
if trade_date == TODAY:
return _morning(bars=self._bars).drop("symbol")
return pl.DataFrame()
def _client(bars: int = 120) -> TestClient:
app = FastAPI()
app.include_router(kline_api.router)
app.state.repo = _FakeRepo(bars)
app.state.capabilities = CapabilitySet({Cap.KLINE_MINUTE_BY_SYMBOL: CapabilityLimits()})
return TestClient(app)
@pytest.mark.parametrize(("hour", "minute"), LUNCH_TIMES)
@pytest.mark.parametrize("live", [0, 1])
def test_minute_lunch_break_serves_complete_morning_from_local(clock, hour, minute, live) -> None:
set_time, fetch, _ = clock
set_time(hour, minute)
body = _client().get("/api/kline/minute", params={"symbol": SYMBOL, "live": live}).json()
assert (body["source"], len(body["rows"])) == ("local", 120)
fetch.assert_not_called()
@pytest.mark.parametrize(("hour", "minute", "bars"), [(10, 30, 60), (13, 30, 150)])
def test_minute_trading_session_expected_unchanged(clock, hour, minute, bars) -> None:
"""连续竞价时段的估算不变: 本地根数跟上时刻即用本地。"""
set_time, fetch, _ = clock
set_time(hour, minute)
body = _client(bars).get("/api/kline/minute", params={"symbol": SYMBOL}).json()
assert (body["source"], len(body["rows"])) == ("local", bars)
fetch.assert_not_called()
def test_minute_lunch_break_still_pulls_when_morning_incomplete(clock) -> None:
"""午休时本地上午明显不全 (只有 60 根) 仍走实时拉取。"""
set_time, fetch, _ = clock
set_time(11, 45)
_client(60).get("/api/kline/minute", params={"symbol": SYMBOL})
fetch.assert_called_once()
# ---------- /minute-batch (自选列表分时小图) ----------
def _batch_request(bars: int = 120) -> MagicMock:
repo = MagicMock()
repo.get_etf_symbol_set.return_value = set()
repo.get_minute_batch.side_effect = (
lambda syms, day, asset_type="stock": _morning(bars=bars) if day == TODAY else pl.DataFrame()
)
capset = MagicMock()
capset.has.return_value = True
capset.limits.return_value = None
request = MagicMock()
request.app.state.repo = repo
request.app.state.capabilities = capset
request.headers = {}
return request
@pytest.mark.parametrize(("hour", "minute"), LUNCH_TIMES)
def test_minute_batch_lunch_break_serves_complete_morning_from_local(clock, hour, minute) -> None:
set_time, _, sync = clock
set_time(hour, minute)
payload = json.loads(kline_api.get_minute_batch(_batch_request(), {"symbols": [SYMBOL]}).body)
assert len(payload["data"].get(SYMBOL, [])) == 120
sync.assert_not_called()
@pytest.mark.parametrize(("hour", "minute", "bars"), [(10, 30, 60), (13, 30, 150)])
def test_minute_batch_trading_session_expected_unchanged(clock, hour, minute, bars) -> None:
set_time, _, sync = clock
set_time(hour, minute)
payload = json.loads(kline_api.get_minute_batch(_batch_request(bars), {"symbols": [SYMBOL]}).body)
assert len(payload["data"].get(SYMBOL, [])) == bars
sync.assert_not_called()
def test_minute_batch_lunch_break_still_pulls_when_morning_incomplete(clock) -> None:
set_time, _, sync = clock
set_time(11, 45)
kline_api.get_minute_batch(_batch_request(60), {"symbols": [SYMBOL]})
sync.assert_called_once()