fix(watchlist): 自选页 enriched 改为 LEFT JOIN, 修复不在缓存 universe 的自选股被静默丢弃 (#70)

后端 watchlist_enriched 原以 enriched 缓存为主表执行 inner filter
(df_e.filter(is_in(stock_symbols))), 方向反了: 不在缓存 universe 里的自选
标的 (新股/冷门股/新用户未同步) 被整行丢弃. 改为以自选列表为主表 LEFT JOIN
enriched, 缺失标的指标为 null, 前端已有 '—' 占位渲染兜底. ETF 分支同理.

前端 Watchlist 顶部胶囊区原 hiddenCount = allSymbols - sortedRows, 把 '数据
未返回' 误算为 '被筛选隐藏'. 拆分为两个口径:
- hiddenCount: rows.length - sortedRows.length (真正被筛选条件隐藏)
- pendingCount: sortedRows 中 close 为 null 的行数 (指标未就绪)
并新增 '待数据 N' 灰色提示与原 '已过滤 N' 区分.

新增 4 个回归测试覆盖核心契约.
This commit is contained in:
wshy
2026-07-08 11:06:56 +08:00
committed by GitHub
parent 5b2282f8cb
commit 3820b35921
3 changed files with 187 additions and 6 deletions
+15 -2
View File
@@ -130,14 +130,27 @@ def watchlist_enriched(
if stock_symbols and df_e.is_empty():
return {"rows": [], "as_of": None, "elapsed_ms": 0}
df = df_e.filter(pl.col("symbol").is_in(stock_symbols)) if stock_symbols else pl.DataFrame()
# 以自选列表为主表 LEFT JOIN enriched, 保证自选的每一只都返回一行;
# 不在 enriched 缓存里的标的 (新股/冷门股/新用户未同步) 指标为 null, 前端渲染为 "—".
# 旧实现是 df_e.filter(is_in(stock_symbols)), 方向反了 (以 enriched 为主),
# 会把不在缓存 universe 里的自选股静默丢弃.
if stock_symbols:
watchlist_df = pl.DataFrame({"symbol": stock_symbols})
if df_e.is_empty():
df = watchlist_df
else:
df = watchlist_df.join(df_e, on="symbol", how="left")
else:
df = pl.DataFrame()
# ETF 行合并; 缺失列 (换手率/涨跌停信号等) 为 null
etf_date = None
if etf_symbols:
df_etf_all, etf_date = repo.get_enriched_latest_asset("etf")
if not df_etf_all.is_empty():
df_etf = df_etf_all.filter(pl.col("symbol").is_in(etf_symbols))
# ETF 同样以自选为主表 LEFT JOIN, 缺失标的指标为 null
etf_watchlist_df = pl.DataFrame({"symbol": etf_symbols})
df_etf = etf_watchlist_df.join(df_etf_all, on="symbol", how="left")
if not df_etf.is_empty():
df = df_etf if df.is_empty() else pl.concat([df, df_etf], how="diagonal_relaxed")
@@ -0,0 +1,149 @@
"""自选页 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):
self._enriched = enriched_df
self._enriched_date = enriched_date
self._etf = etf_df
self._etf_date = etf_date
self._instruments = instruments_df or pl.DataFrame()
self._name_map = name_map or {}
self._etf_set = etf_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
return pl.DataFrame(), None
def get_etf_symbol_set(self):
return self._etf_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 没覆盖的 (新用户冷启动场景)."""
monkeypatch.setattr(wl_api.watchlist, "list_symbols",
lambda: [{"symbol": "000001"}, {"symbol": "000002"}])
repo = _FakeRepo(
enriched_df=pl.DataFrame(schema={"symbol": pl.Utf8}), # 空 schema, 模拟未就绪
enriched_date=None,
)
# 注: 原契约 stock_symbols 非空且 enriched 空 → 返回未就绪. 这是设计, 不变.
res = wl_api.watchlist_enriched(_make_request(repo), ext_columns=None)
assert 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
+23 -4
View File
@@ -1,7 +1,7 @@
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { Trash2, RefreshCw, Star, X, Search, LayoutGrid, List, Settings2, Plus, Check, Filter, Eye, EyeOff, Minus, ChevronsUp } from 'lucide-react'
import { Trash2, RefreshCw, Star, X, Search, LayoutGrid, List, Settings2, Plus, Check, Filter, Eye, EyeOff, Minus, ChevronsUp, Clock } from 'lucide-react'
import { api, type KlineRow, type MinuteKlineRow } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { storage } from '@/lib/storage'
@@ -810,8 +810,17 @@ export function Watchlist() {
[visibleColumns]
)
// 被过滤掉的个股数 (筛选/板块过滤导致的隐藏)
const hiddenCount = Math.max(0, allSymbols.length - sortedRows.length)
// "数据未就绪" 的个股数: 后端 LEFT JOIN 保证返回所有自选行,
// 指标全为 null 的行属于 enriched 缓存未覆盖 (新股/冷门/新用户未同步), 非筛选导致.
// 用 close 是否为 null/undefined 判断 "整行指标缺失" (close 是 enriched 最基础字段).
const pendingCount = useMemo(
() => sortedRows.filter((r: any) => r.close == null).length,
[sortedRows],
)
// "被筛选条件隐藏" 的个股数: 后端返回的行数 vs 经过前端筛选后的行数.
// rows.length 是后端实际返回 (含 pending 行), 减去 sortedRows (筛选后) 才是真正的筛选隐藏.
const hiddenCount = Math.max(0, rows.length - sortedRows.length)
return (
<div className="flex flex-col h-full">
@@ -826,7 +835,17 @@ export function Watchlist() {
<span className="font-mono text-muted tabular-nums">{allSymbols.length}</span>
<span className="text-muted/60 ml-0.5"></span>
</span>
{/* 过滤提示: 仅在有隐藏时出现, 柔和橙色融入整体 */}
{/* 数据未就绪提示: 自选了但 enriched 缓存未覆盖 (新股/冷门/新用户未同步), 指标全为 null */}
{pendingCount > 0 && (
<span
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md text-[10px] font-medium bg-muted/15 text-muted border border-border/50 whitespace-nowrap"
title={`当前有 ${pendingCount} 只指标暂未就绪 (新股/冷门股或数据尚未同步), 等待每日数据更新后自动补全`}
>
<Clock className="h-2.5 w-2.5" />
{pendingCount}
</span>
)}
{/* 过滤提示: 仅在有筛选隐藏时出现, 柔和橙色融入整体 */}
{hiddenCount > 0 && (
<span
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md text-[10px] font-medium bg-warning/12 text-warning/90 border border-warning/25 whitespace-nowrap"