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

356 lines
13 KiB
Python

"""个股详情 K 线传输压缩与分钟源能力门控回归测试。"""
from __future__ import annotations
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 indices, kline
from app.tickflow.capabilities import Cap, CapabilityLimits, CapabilitySet
_SYMBOL = "600000.SH"
_TRADE_DATE = date(2026, 1, 15)
@pytest.fixture(autouse=True)
def _isolated_settings_and_clock(monkeypatch):
monkeypatch.setattr("app.services.preferences.get_daily_batch_compress", lambda: True)
monkeypatch.setattr("app.services.preferences.get_minute_batch_compress", lambda: True)
monkeypatch.setattr(kline, "cn_today", lambda: _TRADE_DATE)
monkeypatch.setattr(kline, "cn_now", lambda: datetime(2026, 1, 15, 10, 30))
monkeypatch.setattr(kline, "in_continuous_session", lambda: True)
def _minute_rows(count: int = 240) -> pl.DataFrame:
start = datetime(2026, 1, 15, 9, 30)
return pl.DataFrame(
{
"symbol": [_SYMBOL] * count,
"datetime": [start + timedelta(minutes=i) for i in range(count)],
"open": [10.0] * count,
"high": [10.1] * count,
"low": [9.9] * count,
"close": [10.05] * count,
"volume": [1_000.0] * count,
"amount": [10_050.0] * count,
}
)
class _DetailRepo:
def __init__(self, minute: pl.DataFrame | None = None) -> None:
self.minute = minute if minute is not None else _minute_rows()
# /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": [_SYMBOL],
"name": ["浦发银行"],
"total_shares": [1.0],
"float_shares": [1.0],
}
)
def get_daily_asset(self, asset_type, symbol, start, end, columns=None):
frame = pl.DataFrame(
{
"symbol": [_SYMBOL] * 30,
"date": [date(2025, 12, 15) + timedelta(days=i) for i in range(30)],
"open": [10.0] * 30,
"high": [10.2] * 30,
"low": [9.8] * 30,
"close": [10.1] * 30,
"volume": [1_000.0] * 30,
"amount": [10_100.0] * 30,
"ma5": [10.0] * 30,
"ma10": [10.0] * 30,
"ma20": [10.0] * 30,
}
)
return frame.select(columns) if columns else frame
def get_minute(self, symbol, trade_date, asset_type="stock") -> pl.DataFrame:
return self.minute
def get_minute_range(self, symbols, start, end, asset_type="stock") -> pl.DataFrame:
return self.minute
class _IndexRepo:
def get_index_instruments(self) -> pl.DataFrame:
return pl.DataFrame({"symbol": ["000001.SH"], "name": ["上证指数"]})
def _client(repo, capset: CapabilitySet | None = None) -> TestClient:
app = FastAPI()
app.include_router(kline.router)
app.include_router(indices.router)
app.state.repo = repo
app.state.capabilities = capset or CapabilitySet()
app.state.quote_service = None
return TestClient(app)
@pytest.mark.parametrize(
("path", "daily_compress", "minute_compress"),
[
(f"/api/kline/daily?symbol={_SYMBOL}&days=120", True, False),
(f"/api/kline/minute?symbol={_SYMBOL}&date={_TRADE_DATE}", False, True),
(f"/api/kline/minute-range?symbol={_SYMBOL}&days=10", False, True),
],
)
def test_detail_kline_responses_use_configured_gzip(
monkeypatch,
path,
daily_compress,
minute_compress,
):
monkeypatch.setattr(
"app.services.preferences.get_daily_batch_compress",
lambda: daily_compress,
)
monkeypatch.setattr(
"app.services.preferences.get_minute_batch_compress",
lambda: minute_compress,
)
response = _client(_DetailRepo()).get(
path,
headers={"Accept-Encoding": "gzip"},
)
assert response.status_code == 200
assert response.headers["content-encoding"] == "gzip"
assert response.json()["symbol"] == _SYMBOL
plain = _client(_DetailRepo()).get(path, headers={"Accept-Encoding": "identity"})
assert "content-encoding" not in plain.headers
assert response.json() == plain.json()
assert int(response.headers["content-length"]) < len(plain.content)
monkeypatch.setattr("app.services.preferences.get_daily_batch_compress", lambda: False)
monkeypatch.setattr("app.services.preferences.get_minute_batch_compress", lambda: False)
disabled = _client(_DetailRepo()).get(path, headers={"Accept-Encoding": "gzip"})
assert "content-encoding" not in disabled.headers
assert disabled.json() == plain.json()
@pytest.mark.parametrize("live", [False, True])
def test_free_tier_skips_tickflow_minute_fallback(monkeypatch, live):
get_client = MagicMock(side_effect=AssertionError("must not call TickFlow"))
monkeypatch.setattr("app.services.preferences.get_minute_data_provider", lambda: "tickflow")
monkeypatch.setattr("app.services.kline_sync.get_client", get_client)
response = _client(_DetailRepo(pl.DataFrame())).get(
"/api/kline/minute",
params={"symbol": _SYMBOL, "date": str(_TRADE_DATE), "live": live},
)
assert response.status_code == 200
assert response.json()["source"] == "none"
get_client.assert_not_called()
def test_custom_minute_source_remains_available_without_tickflow_capability(monkeypatch):
provider = MagicMock()
provider.config.datasets = {"minute": object()}
provider.get_minute.return_value = _minute_rows(1)
monkeypatch.setattr("app.services.preferences.get_minute_data_provider", lambda: "custom")
monkeypatch.setattr(
"app.data_providers.custom.provider_has_dataset", lambda name, dataset: True
)
monkeypatch.setattr("app.data_providers.custom.get_provider", lambda name: provider)
get_client = MagicMock(side_effect=AssertionError("must not call TickFlow"))
monkeypatch.setattr("app.services.kline_sync.get_client", get_client)
response = _client(_DetailRepo(pl.DataFrame())).get(
"/api/kline/minute",
params={"symbol": _SYMBOL, "date": str(_TRADE_DATE)},
)
assert response.status_code == 200
assert response.json()["source"] == "live"
provider.get_minute.assert_called_once()
get_client.assert_not_called()
def test_failed_custom_source_does_not_fall_back_to_unsupported_tickflow(monkeypatch):
provider = MagicMock()
provider.config.datasets = {"minute": object()}
provider.get_minute.side_effect = RuntimeError("custom source unavailable")
monkeypatch.setattr("app.services.preferences.get_minute_data_provider", lambda: "custom")
monkeypatch.setattr(
"app.data_providers.custom.provider_has_dataset", lambda name, dataset: True
)
monkeypatch.setattr("app.data_providers.custom.get_provider", lambda name: provider)
get_client = MagicMock(side_effect=AssertionError("must not call TickFlow"))
monkeypatch.setattr("app.services.kline_sync.get_client", get_client)
capset = CapabilitySet({Cap.KLINE_MINUTE_BATCH: CapabilityLimits()})
response = _client(_DetailRepo(pl.DataFrame()), capset).get(
"/api/kline/minute",
params={"symbol": _SYMBOL, "date": str(_TRADE_DATE)},
)
assert response.status_code == 200
assert response.json()["source"] == "none"
get_client.assert_not_called()
def test_gzip_payload_strips_nonfinite_floats() -> None:
"""gzip 路径不得写出 NaN/Infinity: 前端 JSON.parse 会直接炸掉。
停牌/坏源/指标暖机窗口会出现 nan 或 inf。Python json.dumps 默认 allow_nan=True,
压缩路径会把非法 JSON 词写进 gzip 体; 未压缩路径走 Starlette allow_nan=False, 整段 500。
助手分时小图已经按同样口径清洗 (test_assistant_intraday_chart_finite)。
"""
import gzip as gz
import json
from types import SimpleNamespace
from fastapi.responses import Response
req = SimpleNamespace(headers={"accept-encoding": "gzip"})
payload = {
"symbol": _SYMBOL,
# 压缩路径只在 JSON 超过 1024 字节时启用
"pad": "x" * 1200,
"rows": [
{"close": float("nan")},
{"close": float("inf")},
{"close": 10.5},
],
}
result = kline._gzip_payload(req, payload, pref_key="minute_batch_compress")
assert isinstance(result, Response)
text = gz.decompress(result.body).decode()
json.dumps(json.loads(text), allow_nan=False)
data = json.loads(text)
assert data["rows"][0]["close"] is None
assert data["rows"][1]["close"] is None
assert data["rows"][2]["close"] == 10.5
def test_uncompressed_payload_strips_nonfinite_floats() -> None:
"""未压缩路径同样清洗, 避免 Starlette JSONResponse allow_nan=False 整段 500。"""
import json
from types import SimpleNamespace
req = SimpleNamespace(headers={})
payload = {"rows": [{"close": float("nan")}, {"close": float("-inf")}]}
result = kline._gzip_payload(req, payload, pref_key="daily_batch_compress")
assert isinstance(result, dict)
json.dumps(result, allow_nan=False)
assert result["rows"][0]["close"] is None
assert result["rows"][1]["close"] is None
def test_pro_tier_keeps_tickflow_minute_fallback(monkeypatch):
capset = CapabilitySet({Cap.KLINE_MINUTE_BY_SYMBOL: CapabilityLimits()})
tickflow = MagicMock()
tickflow.klines.batch.side_effect = RuntimeError("upstream unavailable")
get_client = MagicMock(return_value=tickflow)
monkeypatch.setattr("app.services.preferences.get_minute_data_provider", lambda: "tickflow")
monkeypatch.setattr("app.services.kline_sync.get_client", get_client)
response = _client(_DetailRepo(pl.DataFrame()), capset).get(
"/api/kline/minute",
params={"symbol": _SYMBOL, "date": str(_TRADE_DATE)},
)
assert response.status_code == 200
assert response.json()["source"] == "none"
get_client.assert_called_once()
def test_free_tier_skips_tickflow_index_minute_fallback(monkeypatch):
get_client = MagicMock(side_effect=AssertionError("must not call TickFlow"))
monkeypatch.setattr("app.services.preferences.get_minute_data_provider", lambda: "tickflow")
monkeypatch.setattr("app.services.kline_sync.get_client", get_client)
response = _client(_IndexRepo()).get(
"/api/index/minute",
params={"symbol": "000001.SH", "date": str(_TRADE_DATE)},
)
assert response.status_code == 200
assert response.json()["source"] == "none"
get_client.assert_not_called()
def test_gzip_payload_fast_path_skips_deep_copy() -> None:
"""常态 (无非有限浮点) 不做 _json_safe 整树深拷贝。
深拷贝是批量响应峰值最大的一份瞬态分配 (与原 payload 同量级),
常态跳过后峰值约减半; 本测试锁定"只有遇到 NaN/inf 才允许净化"的契约。
"""
import gzip as gz
import json
from types import SimpleNamespace
from unittest.mock import patch
from fastapi.responses import Response
req = SimpleNamespace(headers={"accept-encoding": "gzip"})
payload = {"pad": "x" * 1200, "rows": [{"close": 10.5}, {"close": 11.0}]}
with patch.object(kline, "_json_safe", side_effect=AssertionError("常态不应触发深拷贝")):
result = kline._gzip_payload(req, payload, pref_key="minute_batch_compress")
assert isinstance(result, Response)
data = json.loads(gz.decompress(result.body))
assert data["rows"] == [{"close": 10.5}, {"close": 11.0}]
def test_gzip_payload_sanitizes_exactly_once_on_nonfinite() -> None:
"""遇 NaN/inf 时回退净化且只净化一次 (dumps 重试不再走 _json_safe)。"""
import gzip as gz
import json
from types import SimpleNamespace
from unittest.mock import patch
from fastapi.responses import Response
req = SimpleNamespace(headers={"accept-encoding": "gzip"})
payload = {"pad": "x" * 1200, "rows": [{"close": float("nan")}, {"close": 10.5}]}
# _json_safe 递归自调用也走模块属性, 只统计根调用 (obj is payload)
calls: list[int] = []
real = kline._json_safe
def counting(obj):
if obj is payload:
calls.append(1)
return real(obj)
with patch.object(kline, "_json_safe", side_effect=counting):
result = kline._gzip_payload(req, payload, pref_key="daily_batch_compress")
assert isinstance(result, Response)
assert calls == [1]
data = json.loads(gz.decompress(result.body))
assert data["rows"][0]["close"] is None
assert data["rows"][1]["close"] == 10.5
def test_gzip_payload_tiny_nonfinite_returns_sanitized_dict() -> None:
"""未达压缩阈值 (<1KB) 且含 NaN: 返回净化后的 dict, 不触发 Starlette 500。"""
import json
from types import SimpleNamespace
req = SimpleNamespace(headers={"accept-encoding": "gzip"})
payload = {"rows": [{"close": float("nan")}]}
result = kline._gzip_payload(req, payload, pref_key="minute_batch_compress")
assert isinstance(result, dict)
json.dumps(result, allow_nan=False)
assert result["rows"][0]["close"] is None