mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 16:44:15 +08:00
236 lines
8.6 KiB
Python
236 lines
8.6 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()
|
|
|
|
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.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.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_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()
|