mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
fix(assistant): 行情/自选/持仓工具按资产类型取快照, ETF 与指数不再「未匹配到行情」
get_stock_quote 与 _quotes_map (get_watchlist / get_lots 共用) 只调
quote_service.get_quotes_compat(), 它读的是 repo.get_enriched_latest(), 只有股票。
ETF 与指数是独立的 enriched 缓存, 于是单查 510300.SH 返回「未匹配到行情」,
自选和持仓提醒里的 ETF、指数行 close / change_pct 全为 null。
新增 _asset_quote_frame, 口径同自选页 /api/watchlist/enriched: 股票照旧取
get_quotes_compat(); 股票缓存里没有的代码再按 resolve_asset_type 分流, 从
get_enriched_latest_asset("etf" / "index") 取同一组行情列。全是股票时不加载
ETF / 指数缓存。
Closes #352
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5eafc0ad0e
commit
b751b83f10
@@ -144,12 +144,48 @@ def _rows(
|
||||
return rows
|
||||
|
||||
|
||||
_QUOTE_COLUMNS = (
|
||||
"symbol", "close", "open", "high", "low", "volume", "amount",
|
||||
"prev_close", "change_pct", "change_amount", "amplitude", "turnover_rate",
|
||||
)
|
||||
|
||||
|
||||
def _asset_quote_frame(ctx: ToolContext, symbols: list[str]) -> pl.DataFrame:
|
||||
"""symbols 的行情快照: 股票取 get_quotes_compat, ETF / 指数取各自的 enriched 缓存。
|
||||
|
||||
get_quotes_compat 只含股票 enriched 缓存; ETF 与指数是独立缓存, 与自选页
|
||||
/api/watchlist/enriched 一样须按资产类型分流, 否则 ETF / 指数永远查不到价格。
|
||||
只对股票缓存里没有的代码判定资产类型, 全是股票时不加载 ETF / 指数缓存。
|
||||
"""
|
||||
frames: list[pl.DataFrame] = []
|
||||
df = ctx.quote_service.get_quotes_compat()
|
||||
if df is not None and not df.is_empty() and "symbol" in df.columns:
|
||||
frames.append(df.filter(pl.col("symbol").is_in(symbols)))
|
||||
found = set(frames[0]["symbol"].to_list()) if frames else set()
|
||||
missing = [s for s in symbols if s not in found]
|
||||
if missing and ctx.repo is not None:
|
||||
by_asset: dict[str, list[str]] = {}
|
||||
for symbol in missing:
|
||||
asset_type = ctx.repo.resolve_asset_type(symbol)
|
||||
if asset_type != "stock":
|
||||
by_asset.setdefault(asset_type, []).append(symbol)
|
||||
for asset_type, members in by_asset.items():
|
||||
asset_df, _ = ctx.repo.get_enriched_latest_asset(asset_type)
|
||||
if asset_df is None or asset_df.is_empty() or "symbol" not in asset_df.columns:
|
||||
continue
|
||||
keep = [c for c in _QUOTE_COLUMNS if c in asset_df.columns]
|
||||
frames.append(asset_df.filter(pl.col("symbol").is_in(members)).select(keep))
|
||||
if not frames:
|
||||
return pl.DataFrame()
|
||||
return pl.concat(frames, how="diagonal_relaxed")
|
||||
|
||||
|
||||
def _quotes_map(ctx: ToolContext, symbols: list[str]) -> dict[str, dict[str, Any]]:
|
||||
"""symbol → {close, change_pct} 轻量行情映射(自选/持仓等合并展示用)。"""
|
||||
if ctx.quote_service is None or not symbols:
|
||||
return {}
|
||||
try:
|
||||
df = ctx.quote_service.get_quotes_compat()
|
||||
df = _asset_quote_frame(ctx, symbols)
|
||||
except Exception: # 行情服务未就绪时静默降级为无价格列
|
||||
return {}
|
||||
if df is None or df.is_empty() or "symbol" not in df.columns:
|
||||
@@ -184,8 +220,7 @@ def _get_stock_quote(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
|
||||
if not isinstance(raw, list) or not raw:
|
||||
raise ValueError("symbols 不能为空, 请传入证券代码数组 (如 ['600519.SH'])。")
|
||||
symbols = list(dict.fromkeys(_validate_symbol(s) for s in raw[:50]))
|
||||
df = ctx.quote_service.get_quotes_compat()
|
||||
sub = df.filter(pl.col("symbol").is_in(symbols)) if df is not None and not df.is_empty() else pl.DataFrame()
|
||||
sub = _asset_quote_frame(ctx, symbols)
|
||||
names = ctx.repo.get_name_map(symbols) if ctx.repo is not None else {}
|
||||
rows = _rows(sub, 50)
|
||||
for row in rows:
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""AI 助手行情类工具按资产类型取快照: ETF / 指数不能永远「未匹配到行情」。
|
||||
|
||||
quote_service.get_quotes_compat 只读股票 enriched 缓存; ETF 与指数各有独立缓存
|
||||
(repo.get_enriched_latest_asset, 自选页 /api/watchlist/enriched 即按此分流)。
|
||||
get_stock_quote / get_watchlist / get_lots 只查股票缓存 → ETF、指数查不到价格:
|
||||
单查 510300.SH 返回「未匹配到行情」, 自选/持仓里的 ETF 行 close 与 change_pct 全是 null。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
import polars as pl
|
||||
import pytest
|
||||
|
||||
from app.custom.assistant import tools as assistant_tools
|
||||
|
||||
_ASSET_TYPES = {"510300.SH": "etf", "000300.SH": "index"}
|
||||
|
||||
|
||||
class _AssetRepo:
|
||||
"""repo 桩: 股票之外的 ETF / 指数各有独立 enriched 缓存 (与 KlineRepository 同接口)。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.loaded: list[str] = []
|
||||
|
||||
def get_name_map(self, symbols=None) -> dict[str, str]:
|
||||
return {"600519.SH": "贵州茅台", "510300.SH": "沪深300ETF", "000300.SH": "沪深300"}
|
||||
|
||||
def resolve_asset_type(self, symbol: str) -> str:
|
||||
return _ASSET_TYPES.get(symbol, "stock")
|
||||
|
||||
def get_enriched_latest_asset(self, asset_type: str, refresh: bool = True) -> tuple[pl.DataFrame, date | None]:
|
||||
self.loaded.append(asset_type)
|
||||
if asset_type == "etf":
|
||||
return pl.DataFrame({
|
||||
"symbol": ["159915.SZ", "510300.SH"],
|
||||
"close": [2.301, 4.120],
|
||||
"prev_close": [2.280, 4.080],
|
||||
"change_pct": [0.0092, 0.0098],
|
||||
"amount": [1.2e9, 3.4e9],
|
||||
"ma5": [2.29, 4.10],
|
||||
}), date(2026, 9, 18)
|
||||
if asset_type == "index":
|
||||
return pl.DataFrame({
|
||||
"symbol": ["000300.SH"],
|
||||
"close": [4012.5],
|
||||
"prev_close": [3990.1],
|
||||
"change_pct": [0.0056],
|
||||
"amount": [3.1e11],
|
||||
}), date(2026, 9, 18)
|
||||
return pl.DataFrame(), None
|
||||
|
||||
def get_minute(self, symbol, trade_date, asset_type="stock") -> pl.DataFrame:
|
||||
return pl.DataFrame()
|
||||
|
||||
|
||||
class _StockOnlyQuoteService:
|
||||
"""get_quotes_compat 与真实实现一致: 只含股票 enriched 缓存。"""
|
||||
|
||||
def get_quotes_compat(self) -> pl.DataFrame:
|
||||
return pl.DataFrame({
|
||||
"symbol": ["600519.SH", "300750.SZ"],
|
||||
"close": [1500.0, 200.5],
|
||||
"prev_close": [1481.8, 210.0],
|
||||
"change_pct": [0.0123, -0.0452],
|
||||
"amount": [3.0e9, 5.0e9],
|
||||
})
|
||||
|
||||
|
||||
def _ctx(repo: _AssetRepo, tmp_path=None) -> assistant_tools.ToolContext:
|
||||
return assistant_tools.ToolContext.build(
|
||||
repo=repo, quote_service=_StockOnlyQuoteService(), data_dir=tmp_path,
|
||||
)
|
||||
|
||||
|
||||
async def test_get_stock_quote_finds_etf_snapshot() -> None:
|
||||
payload = await assistant_tools.execute_assistant_tool(
|
||||
"get_stock_quote", {"symbols": ["510300.SH"]}, _ctx(_AssetRepo()),
|
||||
)
|
||||
|
||||
assert payload["ok"] is True
|
||||
result = payload["result"]
|
||||
assert result["count"] == 1
|
||||
row = result["rows"][0]
|
||||
assert row["symbol"] == "510300.SH"
|
||||
assert row["name"] == "沪深300ETF"
|
||||
assert row["close"] == 4.12
|
||||
assert row["change_pct"] == 0.0098
|
||||
|
||||
|
||||
async def test_get_stock_quote_mixes_stock_etf_and_index() -> None:
|
||||
payload = await assistant_tools.execute_assistant_tool(
|
||||
"get_stock_quote", {"symbols": ["600519.SH", "510300.SH", "000300.SH"]}, _ctx(_AssetRepo()),
|
||||
)
|
||||
|
||||
assert payload["ok"] is True
|
||||
rows = {r["symbol"]: r for r in payload["result"]["rows"]}
|
||||
assert set(rows) == {"600519.SH", "510300.SH", "000300.SH"}
|
||||
assert rows["600519.SH"]["close"] == 1500.0
|
||||
assert rows["510300.SH"]["change_pct"] == 0.0098
|
||||
assert rows["000300.SH"]["close"] == 4012.5
|
||||
|
||||
|
||||
async def test_stock_only_query_does_not_load_etf_or_index_cache() -> None:
|
||||
repo = _AssetRepo()
|
||||
payload = await assistant_tools.execute_assistant_tool(
|
||||
"get_stock_quote", {"symbols": ["600519.SH", "300750.SZ"]}, _ctx(repo),
|
||||
)
|
||||
|
||||
assert payload["result"]["count"] == 2
|
||||
assert repo.loaded == []
|
||||
|
||||
|
||||
class _ColdRepo(_AssetRepo):
|
||||
"""ETF / 指数缓存未焐热 (空表): 应安静降级为「未匹配到行情」, 不报错。"""
|
||||
|
||||
def get_enriched_latest_asset(self, asset_type: str, refresh: bool = True) -> tuple[pl.DataFrame, date | None]:
|
||||
self.loaded.append(asset_type)
|
||||
return pl.DataFrame(), None
|
||||
|
||||
|
||||
class _EmptyQuoteService:
|
||||
def get_quotes_compat(self) -> pl.DataFrame:
|
||||
return pl.DataFrame()
|
||||
|
||||
|
||||
async def test_cold_caches_degrade_to_no_match() -> None:
|
||||
repo = _ColdRepo()
|
||||
ctx = assistant_tools.ToolContext.build(repo=repo, quote_service=_EmptyQuoteService())
|
||||
payload = await assistant_tools.execute_assistant_tool(
|
||||
"get_stock_quote", {"symbols": ["510300.SH", "000300.SH"]}, ctx,
|
||||
)
|
||||
|
||||
assert payload["ok"] is True
|
||||
assert payload["result"]["count"] == 0
|
||||
assert sorted(repo.loaded) == ["etf", "index"]
|
||||
|
||||
|
||||
async def test_get_watchlist_merges_etf_and_index_prices(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from app.services import watchlist as watchlist_service
|
||||
|
||||
monkeypatch.setattr(watchlist_service, "list_symbols", lambda: [
|
||||
{"symbol": "600519.SH", "note": ""},
|
||||
{"symbol": "510300.SH", "note": "宽基"},
|
||||
{"symbol": "000300.SH", "note": ""},
|
||||
])
|
||||
payload = await assistant_tools.execute_assistant_tool("get_watchlist", {}, _ctx(_AssetRepo()))
|
||||
|
||||
assert payload["ok"] is True
|
||||
rows = {r["symbol"]: r for r in payload["result"]["rows"]}
|
||||
assert rows["600519.SH"]["change_pct"] == 0.0123
|
||||
assert rows["510300.SH"]["close"] == 4.12
|
||||
assert rows["510300.SH"]["change_pct"] == 0.0098
|
||||
assert rows["000300.SH"]["change_pct"] == 0.0056
|
||||
|
||||
|
||||
async def test_get_lots_merges_etf_price(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
|
||||
from app.strategy import lots as lots_module
|
||||
|
||||
def fake_load_all(data_dir) -> list[dict[str, Any]]:
|
||||
return [{"symbol": "510300.SH", "qty": 1000, "cost_price": 3.95, "buy_date": "2026-09-01"}]
|
||||
|
||||
monkeypatch.setattr(lots_module, "load_all", fake_load_all)
|
||||
payload = await assistant_tools.execute_assistant_tool("get_lots", {}, _ctx(_AssetRepo(), tmp_path))
|
||||
|
||||
assert payload["ok"] is True
|
||||
row = payload["result"]["rows"][0]
|
||||
assert row["symbol"] == "510300.SH"
|
||||
assert row["close"] == 4.12
|
||||
assert row["change_pct"] == 0.0098
|
||||
Reference in New Issue
Block a user