perf(kline): update detail chart with latest row

This commit is contained in:
0112020179
2026-09-03 17:35:16 +08:00
parent 4cb30e48aa
commit 8721bd2e83
9 changed files with 263 additions and 44 deletions
+50 -24
View File
@@ -459,51 +459,54 @@ def _attach_ext(resp: dict, repo, symbol: str, ext_columns: Optional[str]) -> di
return resp
def _maybe_inject_live_candle(request: Request, symbol: str, rows: list[dict], asset_type: str = "stock") -> list[dict]:
"""如果有当日实时 enriched 数据, 用实时数据生成今日蜡烛并追加/覆盖。
def _latest_live_candle(
request: Request,
symbol: str,
asset_type: str = "stock",
*,
refresh_asset: bool = True,
) -> dict | None:
"""从内存缓存读取单只标的的当日实时 enriched 行。"""
stock 走 QuoteService 的股票实时缓存; etf 走 ETF enriched 缓存 (开启实时 ETF
拉取时为盘中数据, 否则为磁盘最新日, 由下方"非今日不注入"守卫自然跳过)。
"""
if asset_type == "stock":
qs = getattr(request.app.state, "quote_service", None)
if not qs:
return rows
return None
df_today, enriched_date = qs.get_enriched_today()
elif asset_type == "etf":
df_today, enriched_date = request.app.state.repo.get_enriched_latest_asset("etf")
df_today, enriched_date = request.app.state.repo.get_enriched_latest_asset(
"etf", refresh=refresh_asset,
)
else:
return rows
return None
if df_today.is_empty():
return rows
return None
# 非交易日周末/假日缓存的行情日期 != 今天跳过注入避免产生重复蜡烛
# 非交易日(周末/假日)缓存日期 != 今天, 跳过注入避免产生重复蜡烛
if not enriched_date or enriched_date != date.today():
return rows
return None
# 查找该 symbol 的实时 enriched 行
import polars as pl
try:
q = df_today.filter(pl.col("symbol") == symbol).to_dicts()
if not q:
return rows
return None
q = q[0]
except Exception: # noqa: BLE001
return rows
except Exception:
return None
close_price = q.get("close")
if not close_price or close_price <= 0:
return rows
return None
today_str = str(enriched_date)
# enriched 行已包含 OHLCV + 全套指标, 直接用它
# 修复: API 在非交易时段可能返回 open/high/low=0, 用 close 填充避免异常蜡烛
# 沿用完整日K接口原有的实时行投影, 避免增量接口形成第二套字段契约。
# API 在非交易时段可能返回 open/high/low=0, 用 close 填充避免异常蜡烛。
raw_open = q.get("open")
raw_high = q.get("high")
raw_low = q.get("low")
live_row: dict = {
"date": today_str,
live_row = {
"date": str(enriched_date),
"symbol": symbol,
"open": raw_open if raw_open and raw_open > 0 else close_price,
"high": raw_high if raw_high and raw_high > 0 else close_price,
@@ -514,7 +517,6 @@ def _maybe_inject_live_candle(request: Request, symbol: str, rows: list[dict], a
"change_pct": q.get("change_pct"),
"is_live": True,
}
# 补上 enriched 的技术指标字段
for key in ("ma5", "ma10", "ma20", "ma30", "ma60",
"macd_dif", "macd_dea", "macd_hist",
"kdj_k", "kdj_d", "kdj_j",
@@ -523,11 +525,19 @@ def _maybe_inject_live_candle(request: Request, symbol: str, rows: list[dict], a
"atr_14", "vol_ratio_5d"):
if key in q and q[key] is not None:
live_row[key] = q[key]
return live_row
def _maybe_inject_live_candle(request: Request, symbol: str, rows: list[dict], asset_type: str = "stock") -> list[dict]:
"""如果有当日实时 enriched 数据, 用实时数据生成今日蜡烛并追加/覆盖。"""
live_row = _latest_live_candle(request, symbol, asset_type)
if live_row is None:
return rows
# 如果已有今天的 enriched 行, 覆盖; 否则追加
found = False
for i, r in enumerate(rows):
if str(r.get("date")) == today_str:
for r in rows:
if str(r.get("date")) == live_row["date"]:
r.update(live_row)
found = True
break
@@ -538,6 +548,22 @@ def _maybe_inject_live_candle(request: Request, symbol: str, rows: list[dict], a
return rows
@router.get("/daily/latest")
def get_daily_latest(
request: Request,
symbol: str = Query(..., description="标的代码,如 000001.SZ"),
):
"""返回内存中的当日单行 K 线, 供详情页实时增量更新。"""
repo = request.app.state.repo
asset_type = repo.resolve_asset_type(symbol)
row = _latest_live_candle(request, symbol, asset_type, refresh_asset=False)
return {
"symbol": symbol,
"row": row,
"source": "live" if row is not None else "none",
}
class DailyBatchRequest:
"""批量日K请求。"""
symbols: list[str]
+140
View File
@@ -0,0 +1,140 @@
"""个股详情日 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() -> None:
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() -> None:
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() -> None:
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() -> None:
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