fix(dragon-tiger): thscode 为 null 的退市/B股标的归一化, 修复龙虎榜崩溃

用户反馈: 展开复盘页龙虎榜 (2026-10-29 榜单 72 条) 前端崩溃。
根因: fuyao 对退市整理期股/B股返回 thscode=null (ticker 有值) — 今天
首次上榜的萃华退(002731)/宁通信B(200468)/动力B股(900920) 3 条 null
thscode 被后端原样透传, 违反前端 DragonTigerStockItem.thscode: string
契约 (下游按 string 使用, 收到 null 即崩)。

- dragon_tiger 服务新增归一化 (新拉取 + 历史缓存读回共用):
  thscode 缺失时由 ticker 推导市场后缀 (6xx/900→SH, 0xx/3xx/200→SZ,
  4xx/8xx→BJ), 两者都不可用则丢弃并告警; 游资席位嵌套 rows 同样处理
- 前端: 类型放宽为 string | null (如实反映数据源现实), 榜单卡片统一走
  _dtCode/_dtLabel 兜底 (旧缓存/异常数据源也不再崩), 无代码行不触发
  onOpenStock
- 实测真实数据: 72 条归一后 0 条 null; 三条退市/B股 →
  002731.SZ / 200468.SZ / 900920.SH; 席位嵌套 rows 同样干净

验证: 后端新增 6 条归一化用例 (ticker 推导/不可识别丢弃/嵌套 rows/
缓存读回/端到端); 前端新增龙虎榜卡片回归 2 条 (展开 72 条不崩 + 点击
null 行以 ticker 兜底回调); 后端全量 3546 passed; 前端 49 文件 348
用例 + build 通过; ruff 零新增。

Signed-off-by: shy3130 <shy3130@qq.com>
This commit is contained in:
shy3130
2026-10-29 22:35:30 +08:00
parent b8f1ebaaf0
commit e2c84dbeca
5 changed files with 318 additions and 23 deletions
+92 -3
View File
@@ -88,20 +88,109 @@ def _boards_of(provider, board_type: str, iso: str | None) -> dict:
"trade_date": data.get("trade_date"),
"stock_count": data.get("stock_count"),
"count": data.get("count"),
"stock_items": data.get("stock_items") or [],
"hot_money_items": data.get("hot_money_items") or [],
"stock_items": normalize_stock_items(data.get("stock_items") or []),
"hot_money_items": normalize_hot_money_items(data.get("hot_money_items") or []),
}
_TICKER_RE = re.compile(r"^(\d{6})(?:\.[A-Za-z]{2})?$")
def derive_thscode(ticker: object) -> str | None:
"""由 ticker 推导带市场后缀的 thscode (如 002731 → 002731.SZ)。
fuyao 对退市整理期股 / B 股返回的 thscode 可能为 null (ticker 有值),
不能把 null 透传给前端 (各消费方按 string 契约使用)。规则:
6xxxxx / 900xxx (沪B) → .SH
0xxxxx / 3xxxxx / 200xxx(深B) → .SZ
4xxxxx / 8xxxxx (北交所) → .BJ
"""
if not isinstance(ticker, str):
return None
raw = ticker.strip()
m = _TICKER_RE.match(raw)
if not m:
return None
if "." in raw:
return raw
code = m.group(1)
if code.startswith("900") or code[0] == "6":
return f"{code}.SH"
if code.startswith("200") or code[0] in ("0", "3"):
return f"{code}.SZ"
if code[0] in ("4", "8"):
return f"{code}.BJ"
return None
def normalize_stock_items(items: object) -> list[dict]:
"""榜单个股行规范化: thscode 缺失时由 ticker 推导, 两者都不可用则丢弃并告警。"""
if not isinstance(items, list):
return []
out: list[dict] = []
dropped: list[str] = []
for item in items:
if not isinstance(item, dict):
continue
code = item.get("thscode")
if not isinstance(code, str) or not code.strip():
derived = derive_thscode(item.get("ticker"))
if derived is None:
dropped.append(str(item.get("name") or item.get("ticker") or "?"))
continue
item = {**item, "thscode": derived}
out.append(item)
if dropped:
logger.warning(
"龙虎榜 %d 条标的缺少可识别代码 (thscode/ticker 均不可用), 已跳过: %s",
len(dropped), dropped[:5])
return out
def normalize_hot_money_items(items: object) -> list[dict]:
"""游资席位行: 席位本身无代码, 但关联股票 rows 必须带可用 thscode。"""
if not isinstance(items, list):
return []
out: list[dict] = []
for seat in items:
if not isinstance(seat, dict):
continue
rows = seat.get("rows")
if isinstance(rows, list):
seat = {**seat, "rows": normalize_stock_items(rows)}
out.append(seat)
return out
def normalize_payload(payload: dict) -> dict:
"""规范化整个龙虎榜容器 (新拉取与历史缓存读回共用)。"""
for board in ("all", "org"):
b = payload.get(board)
if isinstance(b, dict):
payload[board] = {
**b,
"stock_items": normalize_stock_items(b.get("stock_items") or []),
}
hot = payload.get("hot_money")
if isinstance(hot, dict):
payload["hot_money"] = {
**hot,
"hot_money_items": normalize_hot_money_items(hot.get("hot_money_items") or []),
}
return payload
def _cache_path(data_dir: Path, d: date_cls) -> Path:
return data_dir / "dragon_tiger" / f"date={d.isoformat()}.json"
def _load_cache(path: Path) -> dict | None:
try:
return json.loads(path.read_text(encoding="utf-8"))
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
# 旧版本写入的缓存可能已含 thscode=null (fuyao 退市/B股数据), 读回时同样规范化
return normalize_payload(data) if isinstance(data, dict) else None
def _store_cache(path: Path, payload: dict) -> None:
+88
View File
@@ -155,3 +155,91 @@ def test_build_recap_context_contains_summary(data_dir, monkeypatch):
def test_build_recap_context_empty_without_source(data_dir, monkeypatch):
monkeypatch.setattr(dt, "_provider", lambda: None)
assert dt.build_recap_context(data_dir) == ""
# ---- thscode 归一化 (2026-10-29 用户反馈: 退市整理期股/B股 thscode=null 进前端) ----
def test_derive_thscode_market_suffix():
assert dt.derive_thscode("002731") == "002731.SZ"
assert dt.derive_thscode("300750") == "300750.SZ"
assert dt.derive_thscode("600519") == "600519.SH"
assert dt.derive_thscode("688111") == "688111.SH"
assert dt.derive_thscode("830799") == "830799.BJ"
assert dt.derive_thscode("900920") == "900920.SH" # 沪B
assert dt.derive_thscode("200468") == "200468.SZ" # 深B
assert dt.derive_thscode("600519.SH") == "600519.SH" # 已带后缀原样返回
assert dt.derive_thscode(None) is None
assert dt.derive_thscode("ABCDEF") is None
def test_normalize_stock_items_fills_null_thscode_from_ticker():
items = [
{"thscode": None, "ticker": "002731", "name": "萃华退", "net_value": -525558.3},
{"thscode": None, "ticker": "200468", "name": "宁通信B"},
{"thscode": "600519.SH", "ticker": "600519", "name": "贵州茅台"},
]
out = dt.normalize_stock_items(items)
assert [i["thscode"] for i in out] == ["002731.SZ", "200468.SZ", "600519.SH"]
# 其它字段原样保留
assert out[0]["name"] == "萃华退" and out[0]["net_value"] == -525558.3
def test_normalize_stock_items_drops_unidentifiable():
out = dt.normalize_stock_items([
{"thscode": None, "ticker": None, "name": "无代码"},
{"thscode": " ", "ticker": "nope", "name": "坏ticker"},
"junk",
])
assert out == []
def test_boards_of_normalizes_nested_seat_rows():
class _NullProvider:
def dragon_tiger(self, board_type, date):
return {
"trade_date": date,
"stock_items": [{"thscode": None, "ticker": "900920", "name": "动力B股"}],
"hot_money_items": [{"name": "T王", "buying": 1.0,
"rows": [{"thscode": None, "ticker": "002731", "name": "萃华退"}]}],
}
got = dt._boards_of(_NullProvider(), "all", "2026-10-29")
assert got["stock_items"][0]["thscode"] == "900920.SH"
assert got["hot_money_items"][0]["rows"][0]["thscode"] == "002731.SZ"
def test_load_cache_normalizes_legacy_null_thscode(tmp_path):
"""旧版本写入的历史缓存含 null thscode → 读回即归一, 不把脏数据透给前端。"""
path = tmp_path / "date=2026-10-29.json"
path.write_text(json.dumps({
"state": "ok",
"trade_date": "2026-10-29",
"all": {"stock_items": [{"thscode": None, "ticker": "002731", "name": "萃华退"}]},
"org": {"stock_items": [{"thscode": "600519.SH"}]},
"hot_money": {"hot_money_items": [
{"name": "T王", "rows": [{"thscode": None, "ticker": "200468"}]},
]},
}, ensure_ascii=False), encoding="utf-8")
payload = dt._load_cache(path)
assert payload["all"]["stock_items"][0]["thscode"] == "002731.SZ"
assert payload["org"]["stock_items"][0]["thscode"] == "600519.SH"
assert payload["hot_money"]["hot_money_items"][0]["rows"][0]["thscode"] == "200468.SZ"
def test_get_dragon_tiger_normalizes_provider_null_thscode(data_dir, monkeypatch):
class _NullProvider:
def dragon_tiger(self, board_type, date):
return {
"trade_date": "2026-08-28",
"count": 1,
"stock_count": 1,
"stock_items": [{"thscode": None, "ticker": "002731", "name": "萃华退",
"net_value": -525558.3}],
"hot_money_items": [],
}
_use_provider(monkeypatch, _NullProvider())
payload = dt.get_dragon_tiger(data_dir, date(2026, 8, 28))
assert payload["state"] == "ok"
assert payload["all"]["stock_items"][0]["thscode"] == "002731.SZ"
@@ -0,0 +1,98 @@
// @vitest-environment jsdom
// 回归: fuyao 龙虎榜对退市整理期股/B股返回 thscode=null (ticker 有值),
// 复盘页龙虎榜卡片渲染/点击该行不得崩溃 (用户反馈: 展开龙虎榜即崩)。
// 真实数据样本 (2026-10-29 榜单): 萃华退 002731 / 宁通信B 200468 / 动力B股 900920。
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { MemoryRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { DragonTigerCard } from './extension'
import type { DragonTigerStockItem } from '@/lib/api'
const _items: DragonTigerStockItem[] = [
{ thscode: null, ticker: '002731', name: '萃华退', change: -0.107143, net_value: -525558.3, net_rate: -0.0131, buy_value: 4489000, sell_value: 5014558.3, range_days: 1 },
{ thscode: null, ticker: '200468', name: '宁通信B', change: -0.08011, net_value: -54180.43, net_rate: -0.0102, buy_value: 2617000, sell_value: 2671180.43, range_days: 1 },
{ thscode: null, ticker: '900920', name: '动力B股', change: 0.099602, net_value: 447119.44, net_rate: 0.0521, buy_value: 12030000, sell_value: 11582880.56, range_days: 1 },
{ thscode: '600519.SH', ticker: '600519', name: '贵州茅台', change: 0.012, net_value: 93000000, net_rate: 0.04, buy_value: 200000000, sell_value: 107000000, range_days: 1 },
]
const _payload = {
state: 'ok' as const,
trade_date: '2026-10-29',
all: { trade_date: '2026-10-29', stock_count: _items.length, count: _items.length, stock_items: _items },
org: { trade_date: '2026-10-29', stock_count: 1, count: 1, stock_items: [_items[3]] },
hot_money: { trade_date: '2026-10-29', count: 1, hot_money_items: [
{ name: 'T王', buying: 5000000, rows: [_items[0], _items[1]] },
] },
}
vi.mock('@/lib/api', () => ({
api: { dragonTiger: vi.fn(async () => _payload) },
}))
let host: HTMLDivElement
let root: Root
let client: QueryClient
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
host = document.createElement('div')
document.body.append(host)
root = createRoot(host)
client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
})
afterEach(async () => {
await act(async () => root.unmount())
client.clear()
host.remove()
vi.restoreAllMocks()
})
async function renderCard(onOpenStock = vi.fn()) {
const errors: unknown[] = []
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { errors.push(args) })
await act(async () => root.render(
<MemoryRouter>
<QueryClientProvider client={client}>
<DragonTigerCard date="2026-10-29" onOpenStock={onOpenStock} />
</QueryClientProvider>
</MemoryRouter>,
))
await act(async () => { await new Promise(r => setTimeout(r, 80)) })
spy.mockRestore()
return { errors, onOpenStock }
}
async function expand() {
const btn = [...host.querySelectorAll('button')].find(b => b.textContent?.includes('查看三榜明细'))
expect(btn).toBeDefined()
await act(async () => { btn!.click() })
await act(async () => { await new Promise(r => setTimeout(r, 60)) })
}
it('含 thscode=null 的标的: 展开三榜明细不崩溃, 以名称/ticker 兜底展示', async () => {
const { errors } = await renderCard()
expect(errors.some(e => String(e).includes('TypeError'))).toBe(false)
await expand()
expect(errors.some(e => String(e).includes('TypeError'))).toBe(false)
// 三条 null thscode 标的都以名称 + ticker 展示
expect(host.textContent).toContain('萃华退')
expect(host.textContent).toContain('宁通信B')
expect(host.textContent).toContain('动力B股')
expect(host.textContent).toContain('002731')
})
it('点击 thscode=null 的行用 ticker 兜底回调, 不崩溃', async () => {
const { errors, onOpenStock } = await renderCard()
await expand()
const row = [...host.querySelectorAll('button')].find(b => b.textContent?.includes('萃华退'))
expect(row).toBeDefined()
await act(async () => { row!.click() })
expect(errors.some(e => String(e).includes('TypeError'))).toBe(false)
expect(onOpenStock).toHaveBeenCalledWith('002731')
})
+38 -19
View File
@@ -1072,6 +1072,18 @@ function _dtSorted(items: DragonTigerStockItem[], key: 'net_value' | 'org_net_va
return asc ? s.reverse() : s
}
/** 榜单标的代码: 后端已归一非空, 此处兜底旧缓存/异常数据源的 null thscode。 */
function _dtCode(i: DragonTigerStockItem): string {
const code = i.thscode
if (typeof code === 'string' && code.trim()) return code
return i.ticker ?? ''
}
/** 榜单标的展示名: 名称优先, 无名称时退代码/ticker。 */
function _dtLabel(i: DragonTigerStockItem): string {
return i.name ?? (_dtCode(i) || '—')
}
/** 排名序号色: 前三用暖色系奖牌感, 其余弱化 */
function _rankCls(idx: number): string {
if (idx === 0) return 'bg-amber-500/20 text-amber-400'
@@ -1086,20 +1098,22 @@ function _DtPill({ item, idx, value, onOpenStock }: {
value: number | null | undefined
onOpenStock: (s: string) => void
}) {
const code = _dtCode(item)
const label = _dtLabel(item)
return (
<button
type="button"
onClick={() => onOpenStock(item.thscode)}
onClick={() => code && onOpenStock(code)}
className="group inline-flex items-center gap-1.5 rounded-full border border-border/60 bg-elevated/40 py-0.5 pl-0.5 pr-2.5 transition-all hover:border-accent/40 hover:bg-elevated/70"
title={`查看 ${item.name ?? item.thscode} · 净买 ${fmtVolume(value ?? null)}`}
title={`查看 ${label} · 净买 ${fmtVolume(value ?? null)}`}
>
<span className={cn('grid h-[18px] w-[18px] min-w-[18px] place-items-center rounded-full font-mono text-[9px] font-bold leading-none', _rankCls(idx))}>
{idx + 1}
</span>
<span className="text-[11px] text-foreground/90 transition-colors group-hover:text-accent">
{item.name ?? item.thscode}
{label}
</span>
{(() => { const b = boardTag(item.thscode); return b && (
{(() => { const b = boardTag(code); return b && (
<span className={`inline-flex items-center rounded border px-1 text-[8px] font-bold leading-tight ${b.color}`}>
{b.label}
</span>
@@ -1129,7 +1143,7 @@ function _DtSummaryRow({ label, items, pick, onOpenStock }: {
)}
</span>
{items.map((i, idx) => (
<_DtPill key={`${i.thscode}-${idx}`} item={i} idx={idx} value={pick(i)} onOpenStock={onOpenStock} />
<_DtPill key={`${_dtCode(i)}-${idx}`} item={i} idx={idx} value={pick(i)} onOpenStock={onOpenStock} />
))}
</div>
)
@@ -1202,13 +1216,15 @@ function _DtStockTable({ items, tab, onOpenStock }: {
{sorted.map((i, idx) => {
const barPct = Math.max(2, Math.min(100, (Math.abs(i.net_value ?? 0) / maxAbs) * 100))
const positive = (i.net_value ?? 0) >= 0
const code = _dtCode(i)
const label = _dtLabel(i)
return (
<button
key={`${i.thscode}-${i.range_days ?? 1}`}
key={`${code}-${i.range_days ?? 1}`}
type="button"
onClick={() => onOpenStock(i.thscode)}
onClick={() => code && onOpenStock(code)}
className="flex w-full items-center gap-2 border-t border-border/30 px-2.5 py-2 text-left text-[11px] transition-colors hover:bg-accent/[0.05]"
title={`查看 ${i.name ?? i.thscode} 详情`}
title={`查看 ${label} 详情`}
>
<span className={cn('w-5 shrink-0 text-center font-mono text-[9px] tabular-nums', idx < 3 ? 'text-amber-400/90' : 'text-muted/50')}>
{idx + 1}
@@ -1217,9 +1233,9 @@ function _DtStockTable({ items, tab, onOpenStock }: {
{fmtPct(i.change ?? null, 1)}
</span>
<span className="min-w-0 flex-1">
<span className="text-foreground">{i.name ?? '—'}</span>
<span className="ml-1.5 font-mono text-[9px] text-muted">{i.ticker ?? i.thscode}</span>
{(() => { const b = boardTag(i.thscode); return b && (
<span className="text-foreground">{label}</span>
<span className="ml-1.5 font-mono text-[9px] text-muted">{i.ticker ?? code}</span>
{(() => { const b = boardTag(code); return b && (
<span className={`ml-1 inline-flex items-center rounded border px-1 text-[8px] font-bold leading-tight ${b.color}`}>
{b.label}
</span>
@@ -1304,16 +1320,18 @@ function _DtSeatList({ seats, onOpenStock }: {
</span>
</div>
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-1.5">
{(s.rows ?? []).map(r => (
{(s.rows ?? []).map((r, ri) => {
const code = _dtCode(r)
return (
<button
key={`${s.name}-${r.thscode}`}
key={`${s.name}-${code}-${ri}`}
type="button"
onClick={() => onOpenStock(r.thscode)}
onClick={() => code && onOpenStock(code)}
className="inline-flex items-baseline gap-1.5 rounded-full border border-border/60 bg-surface/60 px-2 py-0.5 text-[10px] transition-all hover:border-accent/40 hover:text-accent"
title={`查看 ${r.name ?? r.thscode} 详情`}
title={`查看 ${_dtLabel(r)} 详情`}
>
<span>{r.name ?? r.thscode}</span>
{(() => { const b = boardTag(r.thscode); return b && (
<span>{_dtLabel(r)}</span>
{(() => { const b = boardTag(code); return b && (
<span className={`inline-flex items-center self-center rounded border px-1 text-[8px] font-bold leading-tight ${b.color}`}>
{b.label}
</span>
@@ -1322,7 +1340,8 @@ function _DtSeatList({ seats, onOpenStock }: {
{fmtVolume(r.hot_money_item_net_value ?? r.net_value ?? null)}
</span>
</button>
))}
)
})}
</div>
</div>
</div>
@@ -1333,7 +1352,7 @@ function _DtSeatList({ seats, onOpenStock }: {
/** 追高风险阈值: 60日回测高开≥5%子集当日开盘买 -1.97% (温和高开才是名单 alpha 来源) */
function DragonTigerCard({ date, onOpenStock }: {
export function DragonTigerCard({ date, onOpenStock }: {
date?: string
onOpenStock: (symbol: string) => void
}) {
+2 -1
View File
@@ -749,7 +749,8 @@ export interface AiReviewReport {
// ===== 龙虎榜 (fuyao 专有, 复盘页) =====
export interface DragonTigerStockItem {
thscode: string
/** 后端已归一 (缺失时由 ticker 推导); 旧缓存/异常数据源可能为 null, 消费方需兜底 */
thscode: string | null
ticker?: string | null
name?: string | null
change?: number | null // 当日涨跌幅 (小数制)