From 3820b359212ffa299c522a2b091fc5f2100b7c65 Mon Sep 17 00:00:00 2001 From: wshy Date: Wed, 8 Jul 2026 11:06:56 +0800 Subject: [PATCH] =?UTF-8?q?fix(watchlist):=20=E8=87=AA=E9=80=89=E9=A1=B5?= =?UTF-8?q?=20enriched=20=E6=94=B9=E4=B8=BA=20LEFT=20JOIN,=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E4=B8=8D=E5=9C=A8=E7=BC=93=E5=AD=98=20universe=20?= =?UTF-8?q?=E7=9A=84=E8=87=AA=E9=80=89=E8=82=A1=E8=A2=AB=E9=9D=99=E9=BB=98?= =?UTF-8?q?=E4=B8=A2=E5=BC=83=20(#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端 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 个回归测试覆盖核心契约. --- backend/app/api/watchlist.py | 17 +- backend/tests/test_watchlist_enriched_join.py | 149 ++++++++++++++++++ frontend/src/pages/Watchlist.tsx | 27 +++- 3 files changed, 187 insertions(+), 6 deletions(-) create mode 100644 backend/tests/test_watchlist_enriched_join.py diff --git a/backend/app/api/watchlist.py b/backend/app/api/watchlist.py index 213a530..4e616d0 100644 --- a/backend/app/api/watchlist.py +++ b/backend/app/api/watchlist.py @@ -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") diff --git a/backend/tests/test_watchlist_enriched_join.py b/backend/tests/test_watchlist_enriched_join.py new file mode 100644 index 0000000..80597fa --- /dev/null +++ b/backend/tests/test_watchlist_enriched_join.py @@ -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 diff --git a/frontend/src/pages/Watchlist.tsx b/frontend/src/pages/Watchlist.tsx index b432601..62be399 100644 --- a/frontend/src/pages/Watchlist.tsx +++ b/frontend/src/pages/Watchlist.tsx @@ -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 (
@@ -826,7 +835,17 @@ export function Watchlist() { {allSymbols.length} - {/* 过滤提示: 仅在有隐藏时出现, 柔和橙色融入整体 */} + {/* 数据未就绪提示: 自选了但 enriched 缓存未覆盖 (新股/冷门/新用户未同步), 指标全为 null */} + {pendingCount > 0 && ( + + + 待数据 {pendingCount} + + )} + {/* 过滤提示: 仅在有筛选隐藏时出现, 柔和橙色融入整体 */} {hiddenCount > 0 && (