feat(watchlist): 自选页新增 今开/最高/最低/昨收/成交量/涨停价/跌停价 列 (#328)

- open/high/low 后端本就返回, 纯前端加列; prev_close/volume 在 enriched
  缓存已有, _WATCHLIST_COLS 白名单补 2 字符串即透传, 零新增 IO
- 涨跌停价在端点内对自选行(几十~几百行)用 polars_price_limit_pct +
  polars_limit_price 向量化现算(交易所整数分半进位口径, 含主板 ST
  2026-07-06 新老划断), 亚毫秒级; ETF/指数涨跌幅规则不在覆盖范围内,
  置 null 由前端渲染 "—"
- 前端 7 列默认隐藏(未开启用户零渲染/排序成本), mergeColumns 对老用户
  持久化配置自动补齐新列, renderBuiltinDataCell/getSortValue 各加 7 case,
  支持排序与数值筛选; 涨停价红色/跌停价绿色, 成交量按万手/亿手换算
- 测试: watchlist_enriched 契约(含 ST 划断前后两种数据日) + mergeColumns
  老配置兼容(vitest)
This commit is contained in:
shy3130
2026-09-17 16:04:25 +08:00
parent 77603a5be1
commit 51be43e951
6 changed files with 205 additions and 4 deletions
+34 -1
View File
@@ -13,6 +13,11 @@ from fastapi import APIRouter, File, HTTPException, Query, Request, UploadFile
from pydantic import BaseModel
from app.db_safe import is_valid_ext_ident, quote_ident
from app.price_limits import (
polars_is_risk_warning_name,
polars_limit_price,
polars_price_limit_pct,
)
from app.services import watchlist
from app.services.watchlist_csv import import_watchlist_codes, import_watchlist_csv
from app.services.watchlist_ocr import import_watchlist_image
@@ -352,7 +357,8 @@ def clear_all():
# 自选页需要的列
_WATCHLIST_COLS = [
"symbol", "close", "open", "high", "low", "change_pct", "change_amount", "amount",
"symbol", "close", "open", "high", "low", "prev_close", "volume",
"change_pct", "change_amount", "amount",
"turnover_rate",
"amplitude", "annual_vol_20d",
"vol_ratio_5d",
@@ -463,6 +469,33 @@ def watchlist_enriched(
keep = [c for c in _WATCHLIST_COLS + ["name", "float_shares", "asset_type"] if c in df.columns]
df = df.select(keep)
# 涨跌停价 (交易所整数分半进位口径, 仅股票): prev_close/名称已在行上, 对自选的
# 几十~几百行向量化现算为亚毫秒级, 不写回 enriched 存储。ETF (跨境/债券 5% 等)
# 与指数的涨跌幅规则不在 price_limits 覆盖内, 置 null 由前端渲染 "—"。
as_of_date = as_of if isinstance(as_of, date) else None
if as_of_date is None and as_of:
try:
as_of_date = date.fromisoformat(str(as_of)[:10])
except ValueError:
as_of_date = None
if {"symbol", "prev_close", "name", "asset_type"}.issubset(df.columns) and as_of_date is not None:
pct = polars_price_limit_pct(
pl.col("symbol"),
pl.lit(as_of_date),
polars_is_risk_warning_name(pl.col("name")),
)
stock_with_prev = (pl.col("asset_type") == "stock") & pl.col("prev_close").is_not_null()
df = df.with_columns(
pl.when(stock_with_prev)
.then(polars_limit_price(pl.col("prev_close"), pct, up=True))
.otherwise(None)
.alias("limit_up_price"),
pl.when(stock_with_prev)
.then(polars_limit_price(pl.col("prev_close"), pct, up=False))
.otherwise(None)
.alias("limit_down_price"),
)
# 动态 JOIN 扩展数据表
ext_specs = _parse_ext_columns(ext_columns) if ext_columns else []
if ext_specs:
@@ -264,3 +264,88 @@ def test_watchlist_enriched_index_branch(monkeypatch):
assert rows["510300.SH"]["asset_type"] == "etf"
# as_of == min(三类缓存日期)
assert res["as_of"] == "2026-07-23"
def _quote_df(rows):
"""rows: [(symbol, close, prev_close, volume, open, high, low), ...]"""
return pl.DataFrame(
[{"symbol": s, "close": c, "prev_close": pc, "volume": v,
"open": o, "high": h, "low": lo, "amount": 1e9, "change_pct": 1.0}
for s, c, pc, v, o, h, lo in rows],
schema_overrides={k: pl.Float64 for k in
("close", "prev_close", "volume", "open", "high", "low",
"amount", "change_pct")},
)
def test_watchlist_enriched_limit_prices_and_quote_cols(monkeypatch):
"""昨收/成交量透传; 股票行涨跌停价按板块现算 (划断后 ST=10%), ETF 行置 null。
涨停价口径: prev_close x (1+pct) 整数分半进位 (polars_limit_price)。
- 600519.SH 主板 10%: 1800.0 → 涨停 1980.00 / 跌停 1620.00
- 300001.SZ 创业 20%: 10.0 → 12.00 / 8.00
- 600029.SH 主板 ST (2026-07-08 ≥ 划断日) 10%: 10.0 → 11.00 / 9.00
"""
monkeypatch.setattr(wl_api.watchlist, "list_symbols",
lambda: [{"symbol": s} for s in
("600519.SH", "300001.SZ", "600029.SH", "510300.SH")])
repo = _FakeRepo(
enriched_df=_quote_df([
("600519.SH", 1900.0, 1800.0, 123456.0, 1810.0, 1950.0, 1799.0),
("300001.SZ", 10.5, 10.0, 88_000.0, 10.1, 10.8, 9.9),
("600029.SH", 10.6, 10.0, 55_000.0, 10.0, 10.9, 9.7),
]),
enriched_date="2026-07-08",
etf_df=_quote_df([("510300.SH", 4.1, 4.0, 900_000.0, 4.0, 4.2, 3.9)]),
etf_date="2026-07-08",
etf_set={"510300.SH"},
name_map={
"600519.SH": "贵州茅台", "300001.SZ": "特锐德", "600029.SH": "ST 南航",
"510300.SH": "沪深300ETF",
},
)
res = wl_api.watchlist_enriched(_make_request(repo), ext_columns=None)
rows = {r["symbol"]: r for r in res["rows"]}
# 昨收/成交量 透传 (此前被 _WATCHLIST_COLS 裁掉)
row = rows["600519.SH"]
assert row["prev_close"] == 1800.0
assert row["volume"] == 123456.0
assert row["open"] == 1810.0 and row["high"] == 1950.0 and row["low"] == 1799.0
# 股票行涨跌停价
assert rows["600519.SH"]["limit_up_price"] == 1980.0
assert rows["600519.SH"]["limit_down_price"] == 1620.0
assert rows["300001.SZ"]["limit_up_price"] == 12.0
assert rows["300001.SZ"]["limit_down_price"] == 8.0
# 主板 ST 在 2026-07-06 划断后恢复 10%
assert rows["600029.SH"]["limit_up_price"] == 11.0
assert rows["600029.SH"]["limit_down_price"] == 9.0
# ETF 行: prev_close/volume 正常透传, 涨跌停价置 null
etf = rows["510300.SH"]
assert etf["prev_close"] == 4.0 and etf["volume"] == 900_000.0
assert etf["limit_up_price"] is None
assert etf["limit_down_price"] is None
def test_watchlist_enriched_limit_prices_legacy_st(monkeypatch):
"""数据日在 2026-07-06 划断之前: 主板 ST 涨跌幅为 5% 老规则, 非 ST 不受影响。"""
monkeypatch.setattr(wl_api.watchlist, "list_symbols",
lambda: [{"symbol": s} for s in ("600029.SH", "600519.SH")])
repo = _FakeRepo(
enriched_df=_quote_df([
("600029.SH", 10.2, 10.0, 66_000.0, 10.0, 10.4, 9.8),
("600519.SH", 1900.0, 1800.0, 123456.0, 1810.0, 1950.0, 1799.0),
]),
enriched_date="2026-07-01",
name_map={"600029.SH": "ST 南航", "600519.SH": "贵州茅台"},
)
res = wl_api.watchlist_enriched(_make_request(repo), ext_columns=None)
rows = {r["symbol"]: r for r in res["rows"]}
assert rows["600029.SH"]["limit_up_price"] == 10.5
assert rows["600029.SH"]["limit_down_price"] == 9.5
assert rows["600519.SH"]["limit_up_price"] == 1980.0
@@ -9,7 +9,7 @@
* 财务率类用 fmtPct、kdj 用 toFixed(1)、vol_ma 用 fmtBigNum 等。
*/
import type { ReactNode } from 'react'
import { fmtPrice, fmtPct, fmtBigNum, priceColorClass } from '@/lib/format'
import { fmtPrice, fmtPct, fmtBigNum, fmtVolume, priceColorClass } from '@/lib/format'
import type { ColumnConfig } from '@/lib/list-columns'
import { NUM_CELL_CLASS } from '@/lib/stock-table'
@@ -65,9 +65,20 @@ export function renderBuiltinDataCell(r: any, col: ColumnConfig): ReactNode | nu
return <td key={col.id} className={`${numCls} ${priceColorClass(r.change_amount)}`}>{r.change_amount != null ? fmtPrice(r.change_amount) : '—'}</td>
case 'amplitude':
return <td key={col.id} className={numCls}>{r.amplitude != null ? `${(r.amplitude * 100).toFixed(2)}%` : '—'}</td>
case 'open': return <td key={col.id} className={numCls}>{fmtMaybePrice(r.open)}</td>
case 'high': return <td key={col.id} className={numCls}>{fmtMaybePrice(r.high)}</td>
case 'low': return <td key={col.id} className={numCls}>{fmtMaybePrice(r.low)}</td>
case 'prev_close': return <td key={col.id} className={numCls}>{fmtMaybePrice(r.prev_close)}</td>
// 涨跌停价 (仅股票有值, ETF/指数后端置 null); A 股口径红涨绿跌
case 'limit_up_price':
return <td key={col.id} className={`${numCls} ${r.limit_up_price != null ? 'text-danger' : ''}`}>{r.limit_up_price != null ? fmtPrice(r.limit_up_price) : '—'}</td>
case 'limit_down_price':
return <td key={col.id} className={`${numCls} ${r.limit_down_price != null ? 'text-bear' : ''}`}>{r.limit_down_price != null ? fmtPrice(r.limit_down_price) : '—'}</td>
// 成交
case 'turnover':
return <td key={col.id} className={numCls}>{r.turnover_rate != null ? `${Number(r.turnover_rate).toFixed(2)}%` : '—'}</td>
case 'volume':
return <td key={col.id} className={`${numCls} text-secondary`}>{r.volume != null ? fmtVolume(r.volume) : '—'}</td>
case 'amount':
return <td key={col.id} className={`${numCls} text-secondary`}>{fmtBigNum(r.amount)}</td>
case 'float_val':
+7
View File
@@ -66,6 +66,13 @@ export function getSortValue(r: any, col: ColumnConfig): any {
case 'pct': return r.rt_pct ?? r.change_pct
case 'change_amount': return r.change_amount
case 'amplitude': return r.amplitude
case 'open': return r.open
case 'high': return r.high
case 'low': return r.low
case 'prev_close': return r.prev_close
case 'limit_up_price': return r.limit_up_price
case 'limit_down_price': return r.limit_down_price
case 'volume': return r.volume
case 'turnover': return r.turnover_rate
case 'amount': return r.rt_amount ?? r.amount
case 'float_val': return r.float_shares && (r.rt_price ?? r.close) ? r.float_shares * (r.rt_price ?? r.close) : null
@@ -0,0 +1,58 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { mergeColumns } from './list-columns'
import { BUILTIN_COLUMNS, COLUMN_GROUPS } from './watchlist-columns'
// #328: 自选页新增 今开/最高/最低/昨收/成交量/涨停价/跌停价 七列。
// 关键兼容契约: 老用户的持久化配置不含这些列 id, mergeColumns 必须把它们
// 补进结果且保持默认隐藏 (visible:false), 已有列的用户显隐不受影响。
describe('watchlist columns: 新增内置列向后兼容', () => {
const NEW_KEYS = ['open', 'high', 'low', 'prev_close', 'volume', 'limit_up_price', 'limit_down_price']
it('新增列已注册且默认隐藏, 并归入价格/成交分组', () => {
for (const key of NEW_KEYS) {
const col = BUILTIN_COLUMNS.find(c => c.source.type === 'builtin' && c.source.key === key)
expect(col, `内置列 ${key} 应存在`).toBeTruthy()
expect(col!.visible).toBe(false)
}
const groupKeys = new Set(COLUMN_GROUPS.flatMap(g => g.keys))
for (const key of NEW_KEYS) {
expect(groupKeys.has(key), `列 ${key} 应出现在 COLUMN_GROUPS 中`).toBe(true)
}
})
it('老用户配置 (不含新列 id) 合并后自动补齐新列且默认隐藏', () => {
// 模拟新列上线前保存的配置: 只有部分老列
const saved = BUILTIN_COLUMNS
.filter(c => ['builtin:symbol', 'builtin:price', 'builtin:pct', 'builtin:turnover'].includes(c.id))
.map(c => ({ ...c }))
const merged = mergeColumns(saved, BUILTIN_COLUMNS)
const byId = new Map(merged.map(c => [c.id, c]))
for (const key of NEW_KEYS) {
const col = byId.get(`builtin:${key}`)
expect(col, `合并后应包含新列 builtin:${key}`).toBeTruthy()
expect(col!.visible).toBe(false)
}
// 用户可见性保留: 老列的 visible 不被新默认值覆盖
expect(byId.get('builtin:price')!.visible).toBe(true)
expect(byId.get('builtin:symbol')!.visible).toBe(true)
// 用户顺序在前, 新列追加在后
expect(merged[0].id).toBe('builtin:symbol')
expect(merged.slice(0, 4).map(c => c.id)).toEqual(
['builtin:symbol', 'builtin:price', 'builtin:pct', 'builtin:turnover'],
)
})
it('用户开启过的新列 (版本间来回) 显隐被保留', () => {
const saved = BUILTIN_COLUMNS
.filter(c => c.id === 'builtin:symbol' || c.id === 'builtin:volume')
.map(c => ({ ...c, visible: c.id === 'builtin:volume' }))
const merged = mergeColumns(saved, BUILTIN_COLUMNS)
const byId = new Map(merged.map(c => [c.id, c]))
expect(byId.get('builtin:volume')!.visible).toBe(true)
expect(byId.get('builtin:high')!.visible).toBe(false)
})
})
+9 -2
View File
@@ -30,8 +30,15 @@ export const BUILTIN_COLUMNS: ColumnConfig[] = [
{ id: 'builtin:pct', source: { type: 'builtin', key: 'pct' }, label: '涨跌幅', visible: true, align: 'center' },
{ id: 'builtin:change_amount', source: { type: 'builtin', key: 'change_amount' }, label: '涨跌额', visible: false, align: 'center' },
{ id: 'builtin:amplitude', source: { type: 'builtin', key: 'amplitude' }, label: '振幅', visible: false, align: 'center' },
{ id: 'builtin:open', source: { type: 'builtin', key: 'open' }, label: '今开', visible: false, align: 'center' },
{ id: 'builtin:high', source: { type: 'builtin', key: 'high' }, label: '最高', visible: false, align: 'center' },
{ id: 'builtin:low', source: { type: 'builtin', key: 'low' }, label: '最低', visible: false, align: 'center' },
{ id: 'builtin:prev_close', source: { type: 'builtin', key: 'prev_close' }, label: '昨收', visible: false, align: 'center' },
{ id: 'builtin:limit_up_price', source: { type: 'builtin', key: 'limit_up_price' }, label: '涨停价', visible: false, align: 'center' },
{ id: 'builtin:limit_down_price', source: { type: 'builtin', key: 'limit_down_price' }, label: '跌停价', visible: false, align: 'center' },
// 成交
{ id: 'builtin:turnover', source: { type: 'builtin', key: 'turnover' }, label: '换手率', visible: true, align: 'center' },
{ id: 'builtin:volume', source: { type: 'builtin', key: 'volume' }, label: '成交量', visible: false, align: 'center' },
{ id: 'builtin:amount', source: { type: 'builtin', key: 'amount' }, label: '成交额', visible: false, align: 'center' },
{ id: 'builtin:float_val', source: { type: 'builtin', key: 'float_val' }, label: '流通值', visible: false, align: 'center' },
{ id: 'builtin:vol_ratio', source: { type: 'builtin', key: 'vol_ratio' }, label: '量比', visible: true, align: 'center' },
@@ -86,8 +93,8 @@ export const BUILTIN_COLUMNS: ColumnConfig[] = [
]
export const COLUMN_GROUPS: ColumnGroup[] = [
{ id: 'price', label: '价格', icon: '💰', keys: ['price', 'pct', 'change_amount', 'amplitude'] },
{ id: 'volume', label: '成交', icon: '📊', keys: ['turnover', 'amount', 'float_val', 'vol_ratio', 'annual_vol'] },
{ id: 'price', label: '价格', icon: '💰', keys: ['price', 'pct', 'change_amount', 'amplitude', 'open', 'high', 'low', 'prev_close', 'limit_up_price', 'limit_down_price'] },
{ id: 'volume', label: '成交', icon: '📊', keys: ['turnover', 'volume', 'amount', 'float_val', 'vol_ratio', 'annual_vol'] },
{ id: 'ma', label: '均线', icon: '📈', keys: ['ma5', 'ma10', 'ma20', 'ma60'] },
{ id: 'range', label: '区间', icon: '📏', keys: ['high_60d', 'low_60d'] },
{ id: 'tech', label: '技术指标', icon: '🔬', keys: ['rsi6', 'rsi14', 'rsi24', 'macd_dif', 'macd_dea', 'macd_hist', 'kdj_k', 'kdj_d', 'kdj_j', 'boll_upper', 'boll_lower', 'atr14', 'vol_ma5', 'vol_ma10'] },