mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 21:24:16 +08:00
- 数据路由: get_name_map 合并指数维表; get_enriched_latest_asset("index") 缓存+flush/merge 分支; daily-batch 按资产分组
- 自选: watchlist_enriched 指数分支 + 行级 asset_type 标注
- 监控: MonitorRuleEngine 第三轮指数评估 (signal/price); 指数实时焐热复刻 ETF flush; Free档自选实时资产分流; 规则校验 (禁 strategy/market/ladder/分时信号)
- 隔离: _resolve_universe 过滤指数防污染股票日K/分钟K; 指数轮 reset_strategy_results=False; 策略/回测/screener 零改动
- AI 分析: prompt 指数无财务文案
267 lines
10 KiB
Python
267 lines
10 KiB
Python
"""自选页 enriched 端点的 LEFT JOIN 回归测试.
|
|
|
|
核心契约 (修复 inner-filter bug 后):
|
|
自选列表里的每一只标的都必须出现在返回结果中, 即使它不在 enriched 缓存里
|
|
(新股 / 冷门股 / 新用户未同步). 缺失标的的指标字段为 null, 前端渲染为 "—".
|
|
|
|
旧 bug: `df_e.filter(is_in(stock_symbols))` 以 enriched 为主表, 会把不在缓存
|
|
universe 里的自选股静默丢弃.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
|
|
import polars as pl
|
|
|
|
from app.api import watchlist as wl_api
|
|
|
|
|
|
class _FakeRepo:
|
|
"""最小化 repo mock: 只实现 watchlist_enriched 调用到的方法."""
|
|
|
|
def __init__(self, enriched_df, enriched_date, etf_df=None, etf_date=None,
|
|
instruments_df=None, name_map=None, etf_set=None,
|
|
index_df=None, index_date=None, index_set=None):
|
|
self._enriched = enriched_df
|
|
self._enriched_date = enriched_date
|
|
self._etf = etf_df
|
|
self._etf_date = etf_date
|
|
self._index = index_df
|
|
self._index_date = index_date
|
|
self._instruments = instruments_df or pl.DataFrame()
|
|
self._name_map = name_map or {}
|
|
self._etf_set = etf_set or set()
|
|
self._index_set = index_set or set()
|
|
|
|
def get_enriched_latest(self):
|
|
return self._enriched, self._enriched_date
|
|
|
|
def get_enriched_latest_asset(self, asset):
|
|
if asset == "etf":
|
|
etf = self._etf if self._etf is not None else pl.DataFrame()
|
|
return etf, self._etf_date
|
|
if asset == "index":
|
|
idx = self._index if self._index is not None else pl.DataFrame()
|
|
return idx, self._index_date
|
|
return pl.DataFrame(), None
|
|
|
|
def get_etf_symbol_set(self):
|
|
return self._etf_set
|
|
|
|
def get_index_symbol_set(self):
|
|
return self._index_set
|
|
|
|
def get_instruments(self):
|
|
return self._instruments
|
|
|
|
def get_name_map(self, symbols):
|
|
return {s: n for s, n in self._name_map.items() if s in (symbols or [])}
|
|
|
|
|
|
def _make_request(repo):
|
|
return SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(repo=repo)))
|
|
|
|
|
|
def _enriched_df(symbols_data):
|
|
"""symbols_data: [(symbol, close, change_pct, amount), ...]"""
|
|
return pl.DataFrame(
|
|
[{"symbol": s, "close": c, "change_pct": p, "amount": a, "turnover_rate": 1.0}
|
|
for s, c, p, a in symbols_data],
|
|
schema_overrides={
|
|
"close": pl.Float64, "change_pct": pl.Float64,
|
|
"amount": pl.Float64, "turnover_rate": pl.Float64,
|
|
},
|
|
)
|
|
|
|
|
|
def test_watchlist_symbol_not_in_enriched_still_returned(monkeypatch):
|
|
"""核心回归: 自选里有但 enriched 缓存里没有的标的, 必须仍返回一行 (指标 null)."""
|
|
# enriched 缓存只覆盖 600519, 不覆盖 999999 (新加的冷门股)
|
|
monkeypatch.setattr(wl_api.watchlist, "list_symbols",
|
|
lambda: [{"symbol": "600519"}, {"symbol": "999999"}])
|
|
repo = _FakeRepo(
|
|
enriched_df=_enriched_df([("600519", 1800.0, 1.2, 1e9)]),
|
|
enriched_date="2026-07-08",
|
|
name_map={"600519": "贵州茅台", "999999": "未知股"},
|
|
)
|
|
|
|
# ext_columns 显式传 None 绕过 FastAPI Query 默认值
|
|
res = wl_api.watchlist_enriched(_make_request(repo), ext_columns=None)
|
|
|
|
syms = [r["symbol"] for r in res["rows"]]
|
|
assert "600519" in syms, "缓存里有的标的必须返回"
|
|
assert "999999" in syms, "缓存里没有的自选标的也必须返回 (修复的核心)"
|
|
|
|
# 缺失标的指标应为 null
|
|
row_999 = next(r for r in res["rows"] if r["symbol"] == "999999")
|
|
assert row_999["close"] is None, f"缺失指标应为 null, 实际: {row_999['close']}"
|
|
assert row_999["name"] == "未知股", "name 走 get_name_map, 应正常返回"
|
|
|
|
# 命中标的指标正常
|
|
row_519 = next(r for r in res["rows"] if r["symbol"] == "600519")
|
|
assert row_519["close"] == 1800.0
|
|
|
|
|
|
def test_all_watchlist_missing_from_enriched(monkeypatch):
|
|
"""股票 enriched 缓存未就绪时, 自选仍返回占位行."""
|
|
syms = ["000001", "000002"]
|
|
monkeypatch.setattr(wl_api.watchlist, "list_symbols",
|
|
lambda: [{"symbol": s} for s in syms])
|
|
repo = _FakeRepo(
|
|
enriched_df=pl.DataFrame(schema={"symbol": pl.Utf8}),
|
|
enriched_date=None,
|
|
)
|
|
|
|
res = wl_api.watchlist_enriched(_make_request(repo), ext_columns=None)
|
|
|
|
assert [r["symbol"] for r in res["rows"]] == syms
|
|
assert all(r.get("close") is None for r in res["rows"])
|
|
assert res["as_of"] is None
|
|
|
|
|
|
def test_partial_coverage_preserves_count(monkeypatch):
|
|
"""多只自选, 部分覆盖: 返回行数必须 == 自选股票数."""
|
|
syms = ["600519", "000001", "999888", "888999"]
|
|
monkeypatch.setattr(wl_api.watchlist, "list_symbols",
|
|
lambda: [{"symbol": s} for s in syms])
|
|
repo = _FakeRepo(
|
|
enriched_df=_enriched_df([
|
|
("600519", 1800.0, 1.2, 1e9),
|
|
("000001", 15.0, 0.3, 2e9),
|
|
]),
|
|
enriched_date="2026-07-08",
|
|
)
|
|
|
|
res = wl_api.watchlist_enriched(_make_request(repo), ext_columns=None)
|
|
assert len(res["rows"]) == len(syms), \
|
|
f"返回行数应等于自选数 {len(syms)}, 实际 {len(res['rows'])}"
|
|
|
|
returned = {r["symbol"] for r in res["rows"]}
|
|
assert returned == set(syms)
|
|
|
|
|
|
def test_etf_not_in_enriched_still_returned(monkeypatch):
|
|
"""ETF 同样: 自选了但 ETF enriched 缓存没有的, 也应返回 (指标 null)."""
|
|
monkeypatch.setattr(wl_api.watchlist, "list_symbols",
|
|
lambda: [{"symbol": "510300"}, {"symbol": "599999"}])
|
|
repo = _FakeRepo(
|
|
enriched_df=pl.DataFrame(schema={"symbol": pl.Utf8}), # 无股票自选
|
|
enriched_date=None,
|
|
etf_df=_enriched_df([("510300", 4.0, 0.5, 1e8)]),
|
|
etf_date="2026-07-08",
|
|
etf_set={"510300", "599999"},
|
|
)
|
|
|
|
res = wl_api.watchlist_enriched(_make_request(repo), ext_columns=None)
|
|
syms = [r["symbol"] for r in res["rows"]]
|
|
assert "510300" in syms
|
|
assert "599999" in syms, "ETF enriched 缺失的自选标的也必须返回"
|
|
|
|
row_missing = next(r for r in res["rows"] if r["symbol"] == "599999")
|
|
assert row_missing["close"] is None
|
|
|
|
|
|
def test_all_etf_watchlist_missing_from_enriched(monkeypatch):
|
|
"""ETF enriched 缓存未就绪时, 自选仍返回占位行."""
|
|
syms = ["510300", "599999"]
|
|
monkeypatch.setattr(wl_api.watchlist, "list_symbols",
|
|
lambda: [{"symbol": s} for s in syms])
|
|
repo = _FakeRepo(
|
|
enriched_df=pl.DataFrame(schema={"symbol": pl.Utf8}),
|
|
enriched_date=None,
|
|
etf_df=pl.DataFrame(schema={"symbol": pl.Utf8}),
|
|
etf_date=None,
|
|
etf_set=set(syms),
|
|
)
|
|
|
|
res = wl_api.watchlist_enriched(_make_request(repo), ext_columns=None)
|
|
|
|
assert [r["symbol"] for r in res["rows"]] == syms
|
|
assert all(r.get("close") is None for r in res["rows"])
|
|
assert res["as_of"] is None
|
|
|
|
|
|
def test_mixed_watchlist_keeps_pending_stock_rows(monkeypatch):
|
|
"""股票缓存未就绪不应影响 ETF 行, 且保持自选原始顺序."""
|
|
syms = ["510300", "000001", "510500"]
|
|
monkeypatch.setattr(wl_api.watchlist, "list_symbols",
|
|
lambda: [{"symbol": s} for s in syms])
|
|
repo = _FakeRepo(
|
|
enriched_df=pl.DataFrame(schema={"symbol": pl.Utf8}),
|
|
enriched_date=None,
|
|
etf_df=_enriched_df([("510300", 4.0, 0.5, 1e8), ("510500", 6.0, -0.2, 2e8)]),
|
|
etf_date="2026-07-08",
|
|
etf_set={"510300", "510500"},
|
|
)
|
|
|
|
res = wl_api.watchlist_enriched(_make_request(repo), ext_columns=None)
|
|
|
|
assert [r["symbol"] for r in res["rows"]] == syms
|
|
assert next(r for r in res["rows"] if r["symbol"] == "000001").get("close") is None
|
|
assert next(r for r in res["rows"] if r["symbol"] == "510300")["close"] == 4.0
|
|
assert res["as_of"] == "2026-07-08"
|
|
|
|
|
|
def test_mixed_watchlist_keeps_pending_etf_rows(monkeypatch):
|
|
"""ETF 缓存未就绪不应影响股票行, 且保持自选原始顺序."""
|
|
syms = ["510300", "000001", "510500"]
|
|
monkeypatch.setattr(wl_api.watchlist, "list_symbols",
|
|
lambda: [{"symbol": s} for s in syms])
|
|
repo = _FakeRepo(
|
|
enriched_df=_enriched_df([("000001", 15.0, 0.3, 2e9)]),
|
|
enriched_date="2026-07-08",
|
|
etf_df=pl.DataFrame(schema={"symbol": pl.Utf8}),
|
|
etf_date=None,
|
|
etf_set={"510300", "510500"},
|
|
)
|
|
|
|
res = wl_api.watchlist_enriched(_make_request(repo), ext_columns=None)
|
|
|
|
assert [r["symbol"] for r in res["rows"]] == syms
|
|
assert next(r for r in res["rows"] if r["symbol"] == "000001")["close"] == 15.0
|
|
assert all(next(r for r in res["rows"] if r["symbol"] == symbol).get("close") is None
|
|
for symbol in ("510300", "510500"))
|
|
assert res["as_of"] == "2026-07-08"
|
|
|
|
|
|
def test_watchlist_enriched_index_branch(monkeypatch):
|
|
"""自选含指数: 行走 index enriched, asset_type=index, 名称回填, 股票/ETF 行不受影响。
|
|
|
|
断言 (计划 Task 4 Step 1):
|
|
- 指数行存在, close == 3000.0, asset_type == "index", name == "上证指数"
|
|
- 指数行 turnover_rate 为 None (列不存在或 null, 不报错)
|
|
- 股票行 asset_type == "stock"; ETF 行 == "etf"
|
|
- as_of == min(股票日期, etf日期, index日期)
|
|
"""
|
|
monkeypatch.setattr(wl_api.watchlist, "list_symbols",
|
|
lambda: [{"symbol": "600000.SH"}, {"symbol": "510300.SH"},
|
|
{"symbol": "000001.SH"}])
|
|
repo = _FakeRepo(
|
|
enriched_df=_enriched_df([("600000.SH", 10.0, 0.3, 2e9)]),
|
|
enriched_date="2026-07-23",
|
|
etf_df=_enriched_df([("510300.SH", 4.0, 0.5, 1e8)]),
|
|
etf_date="2026-07-24",
|
|
etf_set={"510300.SH"},
|
|
index_df=pl.DataFrame([{"symbol": "000001.SH", "close": 3000.0, "change_pct": 0.01,
|
|
"amount": 1e9, "ma5": 2990.0}]),
|
|
index_date="2026-07-24",
|
|
index_set={"000001.SH"},
|
|
name_map={"600000.SH": "浦发银行", "510300.SH": "沪深300ETF", "000001.SH": "上证指数"},
|
|
)
|
|
|
|
res = wl_api.watchlist_enriched(_make_request(repo), ext_columns=None)
|
|
|
|
rows = {r["symbol"]: r for r in res["rows"]}
|
|
# 指数行
|
|
idx = rows["000001.SH"]
|
|
assert idx["close"] == 3000.0
|
|
assert idx["asset_type"] == "index"
|
|
assert idx["name"] == "上证指数"
|
|
# 指数无换手率: 列缺失或 null, 不报错
|
|
assert idx.get("turnover_rate") is None
|
|
# 股票行 / ETF 行 asset_type
|
|
assert rows["600000.SH"]["asset_type"] == "stock"
|
|
assert rows["510300.SH"]["asset_type"] == "etf"
|
|
# as_of == min(三类缓存日期)
|
|
assert res["as_of"] == "2026-07-23"
|