mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
- 日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>
188 lines
6.7 KiB
Python
188 lines
6.7 KiB
Python
"""个股分时 /minute 与自选分时 /minute_batch 的缺省日期 — 工作日休市按交易日探针回退。
|
|
|
|
两个端点不传 date 时默认看今天, 休市才回退到本地最近交易日。判据是:
|
|
周六/周日必回退; 工作日要到 15:30 之后且仍无今日日K才判为节假日。
|
|
于是国庆 (2026-10-01 周四) 这类工作日休市, 15:30 之前每一轮都去取当天的
|
|
空分区 → 本地为空 → 实时补拉 (休市返回空) → 分时图整个上午到下午空白。
|
|
|
|
指数分时 /api/index/minute (#308) 已改用 trading_day.is_trading_day() 的三态口径:
|
|
False = 确定休市才回退, None = 未知维持原判据。这里对齐同一口径。
|
|
"""
|
|
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
|
|
|
|
HOLIDAY = date(2026, 10, 1) # 周四, 国庆休市
|
|
LAST_SESSION = date(2026, 9, 30) # 节前最后一个交易日
|
|
MORNING = datetime(2026, 10, 1, 10, 0, tzinfo=CN_TZ)
|
|
SYMBOL = "600519.SH"
|
|
|
|
|
|
def _session(day: date, symbol: str = SYMBOL) -> pl.DataFrame:
|
|
start = datetime(day.year, day.month, day.day, 9, 31)
|
|
return pl.DataFrame({
|
|
"symbol": [symbol] * 240,
|
|
"datetime": [start + timedelta(minutes=i) for i in range(240)],
|
|
"close": [100.0] * 240,
|
|
})
|
|
|
|
|
|
@pytest.fixture
|
|
def market(monkeypatch: pytest.MonkeyPatch):
|
|
"""北京时间钉在国庆当天上午 10:00; 返回设置探针结论的函数。"""
|
|
monkeypatch.setattr(kline_api, "cn_now", lambda: MORNING)
|
|
monkeypatch.setattr(kline_api, "cn_today", lambda: HOLIDAY)
|
|
monkeypatch.setattr(kline_api, "in_continuous_session", lambda: 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_verdict(verdict: bool | None) -> None:
|
|
monkeypatch.setattr(trading_day, "is_trading_day", lambda now=None: verdict)
|
|
|
|
return set_verdict, fetch, sync
|
|
|
|
|
|
# ---------- /minute_batch (自选列表分时小图) ----------
|
|
|
|
|
|
def _batch_request() -> MagicMock:
|
|
repo = MagicMock()
|
|
repo.get_etf_symbol_set.return_value = set()
|
|
sessions = {LAST_SESSION: _session(LAST_SESSION)}
|
|
repo.get_minute_batch.side_effect = (
|
|
lambda syms, day, asset_type="stock": sessions.get(day, pl.DataFrame())
|
|
)
|
|
repo.latest_minute_date_global.return_value = LAST_SESSION
|
|
repo.latest_daily_date.return_value = LAST_SESSION
|
|
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
|
|
|
|
|
|
def test_minute_batch_on_weekday_holiday_falls_back_to_last_session(market) -> None:
|
|
set_verdict, _, sync = market
|
|
set_verdict(False)
|
|
request = _batch_request()
|
|
|
|
payload = json.loads(kline_api.get_minute_batch(request, {"symbols": [SYMBOL]}).body)
|
|
|
|
assert set(payload["data"]) == {SYMBOL}
|
|
rows = payload["data"][SYMBOL]
|
|
assert len(rows) == 240
|
|
assert rows[0]["datetime"][:10] == LAST_SESSION.isoformat()
|
|
assert request.app.state.repo.get_minute_batch.call_args.args[1] == LAST_SESSION
|
|
sync.assert_not_called() # 不再对休市当天发起补拉
|
|
|
|
|
|
@pytest.mark.parametrize("verdict", [True, None])
|
|
def test_minute_batch_trading_or_unknown_day_keeps_today(market, verdict) -> None:
|
|
set_verdict, _, _ = market
|
|
set_verdict(verdict)
|
|
request = _batch_request()
|
|
|
|
kline_api.get_minute_batch(request, {"symbols": [SYMBOL]})
|
|
|
|
assert request.app.state.repo.get_minute_batch.call_args.args[1] == HOLIDAY
|
|
|
|
|
|
def test_minute_batch_explicit_date_is_not_replaced(market) -> None:
|
|
set_verdict, _, _ = market
|
|
set_verdict(False)
|
|
request = _batch_request()
|
|
|
|
kline_api.get_minute_batch(request, {"symbols": [SYMBOL], "date": "2026-10-01"})
|
|
|
|
assert request.app.state.repo.get_minute_batch.call_args.args[1] == HOLIDAY
|
|
|
|
|
|
# ---------- /minute (个股详情分时) ----------
|
|
|
|
|
|
class _FakeRepo:
|
|
def __init__(self) -> None:
|
|
# /minute 现读取 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 == LAST_SESSION:
|
|
return _session(LAST_SESSION).drop("symbol")
|
|
return pl.DataFrame()
|
|
|
|
def latest_minute_date(self, symbol, asset_type="stock") -> date:
|
|
return LAST_SESSION
|
|
|
|
def latest_daily_date(self) -> date:
|
|
return LAST_SESSION
|
|
|
|
|
|
def _client() -> TestClient:
|
|
app = FastAPI()
|
|
app.include_router(kline_api.router)
|
|
app.state.repo = _FakeRepo()
|
|
app.state.capabilities = CapabilitySet({Cap.KLINE_MINUTE_BY_SYMBOL: CapabilityLimits()})
|
|
return TestClient(app)
|
|
|
|
|
|
@pytest.mark.parametrize("live", [0, 1])
|
|
def test_minute_on_weekday_holiday_falls_back_to_last_session(market, live) -> None:
|
|
set_verdict, fetch, _ = market
|
|
set_verdict(False)
|
|
|
|
body = _client().get("/api/kline/minute", params={"symbol": SYMBOL, "live": live}).json()
|
|
|
|
assert body["date"] == "2026-09-30"
|
|
assert body["source"] == "local"
|
|
assert len(body["rows"]) == 240
|
|
fetch.assert_not_called()
|
|
|
|
|
|
@pytest.mark.parametrize("verdict", [True, None])
|
|
def test_minute_trading_or_unknown_day_keeps_today(market, verdict) -> None:
|
|
set_verdict, _, _ = market
|
|
set_verdict(verdict)
|
|
|
|
body = _client().get("/api/kline/minute", params={"symbol": SYMBOL}).json()
|
|
|
|
assert body["date"] == "2026-10-01"
|
|
|
|
|
|
def test_minute_explicit_date_is_not_replaced(market) -> None:
|
|
set_verdict, _, _ = market
|
|
set_verdict(False)
|
|
|
|
body = _client().get("/api/kline/minute", params={"symbol": SYMBOL, "date": "2026-10-01"}).json()
|
|
|
|
assert body["date"] == "2026-10-01"
|