Files
tick-stock-panel/backend/tests/test_kline_daily_latest.py
T

269 lines
8.9 KiB
Python

"""个股详情日 K 最新行接口测试。"""
from __future__ import annotations
from datetime import date, timedelta
import polars as pl
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.kline import router
class _FakeQuoteService:
def __init__(self, frame: pl.DataFrame, trade_date: date | None) -> None:
self._frame = frame
self._trade_date = trade_date
def get_enriched_today(self):
return self._frame, self._trade_date
class _FakeRepo:
def __init__(
self,
asset_type: str = "stock",
latest_asset: tuple[pl.DataFrame, date | None] | None = None,
) -> None:
self.asset_type = asset_type
self.latest_asset = latest_asset
self.latest_asset_calls = 0
self.latest_asset_refresh: bool | None = None
def resolve_asset_type(self, symbol: str) -> str:
return self.asset_type
def get_enriched_latest_asset(self, asset_type: str, refresh: bool = True):
self.latest_asset_calls += 1
self.latest_asset_refresh = refresh
if self.latest_asset is not None:
return self.latest_asset
raise AssertionError("stock latest row must use QuoteService's memory cache")
def _client(
frame: pl.DataFrame,
trade_date: date | None,
*,
asset_type: str = "stock",
latest_asset: tuple[pl.DataFrame, date | None] | None = None,
) -> tuple[TestClient, _FakeRepo]:
repo = _FakeRepo(asset_type, latest_asset)
app = FastAPI()
app.include_router(router)
app.state.repo = repo
app.state.quote_service = _FakeQuoteService(frame, trade_date)
return TestClient(app), repo
def _live_frame() -> pl.DataFrame:
return pl.DataFrame({
"symbol": ["600000.SH"],
"date": [date.today()],
"open": [10.0],
"high": [10.8],
"low": [9.9],
"close": [10.6],
"volume": [123_456.0],
"amount": [1_234_560.0],
"ma5": [10.2],
"signal_limit_up": [False],
})
def test_daily_latest_returns_only_current_memory_row(monkeypatch) -> None:
from app.api import kline as kline_api
monkeypatch.setattr(kline_api, "cn_today", lambda: date.today())
client, repo = _client(_live_frame(), date.today())
response = client.get("/api/kline/daily/latest", params={"symbol": "600000.SH"})
assert response.status_code == 200
body = response.json()
assert body["symbol"] == "600000.SH"
assert body["source"] == "live"
assert body["row"] == {
"symbol": "600000.SH",
"date": date.today().isoformat(),
"open": 10.0,
"high": 10.8,
"low": 9.9,
"close": 10.6,
"volume": 123_456.0,
"amount": 1_234_560.0,
"change_pct": None,
"ma5": 10.2,
"is_live": True,
}
assert repo.latest_asset_calls == 0
def test_daily_latest_returns_none_for_stale_cache(monkeypatch) -> None:
from app.api import kline as kline_api
monkeypatch.setattr(kline_api, "cn_today", lambda: date.today())
client, _ = _client(_live_frame(), date.today() - timedelta(days=1))
response = client.get("/api/kline/daily/latest", params={"symbol": "600000.SH"})
assert response.status_code == 200
assert response.json() == {
"symbol": "600000.SH",
"row": None,
"source": "none",
}
def test_daily_latest_returns_none_when_symbol_is_missing(monkeypatch) -> None:
from app.api import kline as kline_api
monkeypatch.setattr(kline_api, "cn_today", lambda: date.today())
client, _ = _client(_live_frame(), date.today())
response = client.get("/api/kline/daily/latest", params={"symbol": "600001.SH"})
assert response.status_code == 200
assert response.json() == {
"symbol": "600001.SH",
"row": None,
"source": "none",
}
def test_daily_latest_uses_etf_enriched_cache(monkeypatch) -> None:
from app.api import kline as kline_api
monkeypatch.setattr(kline_api, "cn_today", lambda: date.today())
etf = _live_frame().with_columns(pl.lit("510300.SH").alias("symbol"))
client, repo = _client(
pl.DataFrame(),
None,
asset_type="etf",
latest_asset=(etf, date.today()),
)
response = client.get("/api/kline/daily/latest", params={"symbol": "510300.SH"})
assert response.status_code == 200
assert response.json()["row"]["close"] == 10.6
assert response.json()["source"] == "live"
assert repo.latest_asset_calls == 1
assert repo.latest_asset_refresh is False
def test_daily_latest_uses_index_enriched_cache(monkeypatch) -> None:
"""指数日K的当日实时行走独立 index enriched 缓存, 不能直接 return None。"""
from app.api import kline as kline_api
monkeypatch.setattr(kline_api, "cn_today", lambda: date.today())
idx = _live_frame().with_columns(pl.lit("000001.SH").alias("symbol"))
client, repo = _client(
pl.DataFrame(),
None,
asset_type="index",
latest_asset=(idx, date.today()),
)
response = client.get("/api/kline/daily/latest", params={"symbol": "000001.SH"})
assert response.status_code == 200
body = response.json()
assert body["row"] is not None
assert body["row"]["close"] == 10.6
assert body["source"] == "live"
assert repo.latest_asset_calls == 1
assert repo.latest_asset_refresh is False
BJ = date(2026, 3, 2) # 钉死的北京日期, 不会碰巧等于跑测试那天的 date.today()
def _frame_on(day: date, symbol: str = "600000.SH") -> pl.DataFrame:
return pl.DataFrame({
"symbol": [symbol],
"date": [day],
"open": [10.0],
"high": [10.8],
"low": [9.9],
"close": [10.6],
"volume": [123_456.0],
"amount": [1_234_560.0],
"ma5": [10.2],
"signal_limit_up": [False],
})
def _request(frame: pl.DataFrame, trade_date: date):
from types import SimpleNamespace
repo = _FakeRepo()
qs = _FakeQuoteService(frame, trade_date)
return SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(repo=repo, quote_service=qs)))
def test_live_candle_uses_beijing_today_not_server_local(monkeypatch) -> None:
"""缓存日期是北京今天时必须注入; 不能拿服务器本地 date.today() 去比较。
raising=False: 未修复代码没有在这条路径调用 cn_today, 钉了也不会被用到,
比较仍走 date.today() → 与 2026-03-02 不等 → 返回 None。
"""
from app.api import kline as kline_api
monkeypatch.setattr(kline_api, "cn_today", lambda: BJ, raising=False)
row = kline_api._latest_live_candle(_request(_frame_on(BJ), BJ), "600000.SH", "stock")
assert row is not None
assert row["close"] == 10.6
assert row["date"] == str(BJ)
def test_live_candle_stale_vs_beijing_today(monkeypatch) -> None:
"""缓存停在北京昨天时不注入。"""
from app.api import kline as kline_api
monkeypatch.setattr(kline_api, "cn_today", lambda: BJ, raising=False)
row = kline_api._latest_live_candle(
_request(_frame_on(date(2026, 3, 1)), date(2026, 3, 1)), "600000.SH", "stock",
)
assert row is None
def test_get_daily_default_end_is_beijing_today(monkeypatch) -> None:
"""/api/kline/daily 未传 end_date 时窗口右端必须是北京今天。
实时注入只在内存缓存命中时补当日 K。缓存冷 (进程刚起 / 盘后重启) 时,
parquet 里的当日行能否进结果完全取决于查询窗口。未修复代码用
date.today(): 美西盘中、UTC 北京 00:00-08:00 会把当日官方 K 排除。
raising=False: 未修复代码没有调用 cn_today, 钉了也不会被用到。
"""
from types import SimpleNamespace
from app.api import kline as kline_api
captured: list[tuple[date, date]] = []
class _Repo:
def resolve_asset_type(self, symbol: str) -> str:
return "stock"
def get_instruments(self) -> pl.DataFrame:
return pl.DataFrame({
"symbol": ["600000.SH"], "name": ["浦发银行"],
"total_shares": [1.0], "float_shares": [1.0],
})
def get_daily_asset(self, asset_type, symbol, start, end, columns=None):
captured.append((start, end))
return pl.DataFrame({
"symbol": ["600000.SH"], "date": [BJ],
"open": [10.0], "high": [10.6], "low": [9.9], "close": [10.6],
"volume": [1.0], "amount": [1.0],
})
monkeypatch.setattr(kline_api, "cn_today", lambda: BJ, raising=False)
req = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(
repo=_Repo(), quote_service=None, capabilities=None,
)))
kline_api.get_daily(req, symbol="600000.SH", days=120, start_date=None, end_date=None, ext_columns=None)
assert captured, "应查询日K"
_start, end = captured[0]
assert end == BJ, f"窗口右端必须是北京日期 {BJ}, 实际 {end} (服务器本地 {date.today()})"