mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
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:
@@ -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':
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'] },
|
||||
|
||||
Reference in New Issue
Block a user