mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 16:44:15 +08:00
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:
@@ -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
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user