Files
tick-stock-panel/backend/tests/test_kline_batch_symbol_cap.py
T
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

104 lines
4.0 KiB
Python

"""批量 K 线端点 symbols 单次上限: 挡住旧客户端/异常调用的一次性全量请求。
大自选 (千只级) 整表直发会在服务端构造百万行级 Python dict 峰值, 是内存暴涨
主因之一。前端已按视口/分块收敛; 上限是后端侧的兜底防线, 值现在由用户偏好
配置 (默认 400, 见 test_kline_batch_limits_config.py), 前端分块读同一偏好。
"""
import json
import datetime as _dt
import polars as pl
import pytest
from fastapi import HTTPException
from app.services import preferences
from app.tickflow.capabilities import Cap, CapabilitySet
from app.tickflow.repository import DataStore, KlineRepository
_BJ_DATE = _dt.date(2026, 10, 23) # 周五交易日, 钉死不随跑测试当天漂移
_BJ_NOW = _dt.datetime(2026, 10, 23, 11, 0)
@pytest.fixture(autouse=True)
def _isolated_prefs(tmp_path, monkeypatch):
"""上限来自用户偏好: 固定到临时文件, 不读真实 preferences.json。"""
path = tmp_path / "preferences.json"
monkeypatch.setattr(preferences, "_path", lambda: path)
preferences._invalidate_cache()
yield
preferences._invalidate_cache()
@pytest.fixture()
def repo(tmp_path):
return KlineRepository(DataStore(tmp_path))
def _fake_req(repo, *, with_minute_cap=False):
state_attrs = {"repo": repo}
if with_minute_cap:
capset = CapabilitySet()
capset.grant(Cap.KLINE_MINUTE_BATCH)
state_attrs["capabilities"] = capset
state = type("S", (), state_attrs)()
return type("R", (), {"app": type("A", (), {"state": state})()})()
# ---------- daily-batch ----------
def test_daily_batch_rejects_over_cap(repo):
from app.api import kline as kline_api
req = _fake_req(repo)
body = {"symbols": [f"{600000 + i}.SH" for i in range(401)], "days": 12}
with pytest.raises(HTTPException) as exc:
kline_api.get_daily_batch(req, body)
assert exc.value.status_code == 400
assert "400" in exc.value.detail
def test_daily_batch_accepts_cap_boundary(repo, monkeypatch):
from app.api import kline as kline_api
monkeypatch.setattr(repo, "get_daily_batch", lambda *a, **kw: pl.DataFrame())
monkeypatch.setattr(repo, "resolve_asset_type", lambda s: "stock")
req = _fake_req(repo)
body = {"symbols": [f"{600000 + i}.SH" for i in range(400)], "days": 12}
out = json.loads(kline_api.get_daily_batch(req, body).body)
assert out["data"] == {}
# ---------- minute-batch ----------
def test_minute_batch_rejects_over_cap(repo, monkeypatch):
from app.api import kline as kline_api
monkeypatch.setattr(kline_api, "cn_today", lambda: _BJ_DATE)
monkeypatch.setattr(kline_api, "cn_now", lambda: _BJ_NOW)
monkeypatch.setattr(kline_api.trading_day, "is_trading_day", lambda: True)
monkeypatch.setattr(repo, "get_minute_batch", lambda *a, **kw: pl.DataFrame())
monkeypatch.setattr(repo, "get_etf_symbol_set", lambda: set())
monkeypatch.setattr(kline_api.kline_sync, "sync_minute_batch", lambda *a, **kw: pl.DataFrame())
req = _fake_req(repo, with_minute_cap=True)
body = {"symbols": [f"{600000 + i}.SH" for i in range(401)]}
with pytest.raises(HTTPException) as exc:
kline_api.get_minute_batch(req, body)
assert exc.value.status_code == 400
def test_minute_batch_accepts_cap_boundary(repo, monkeypatch):
from app.api import kline as kline_api
monkeypatch.setattr(kline_api, "cn_today", lambda: _BJ_DATE)
monkeypatch.setattr(kline_api, "cn_now", lambda: _BJ_NOW)
monkeypatch.setattr(kline_api.trading_day, "is_trading_day", lambda: True)
monkeypatch.setattr(repo, "get_minute_batch", lambda *a, **kw: pl.DataFrame())
monkeypatch.setattr(repo, "get_etf_symbol_set", lambda: set())
monkeypatch.setattr(kline_api.kline_sync, "sync_minute_batch", lambda *a, **kw: pl.DataFrame())
req = _fake_req(repo, with_minute_cap=True)
body = {"symbols": [f"{600000 + i}.SH" for i in range(400)]}
out = json.loads(kline_api.get_minute_batch(req, body).body)
assert out["data"] == {}