feat: 自选列表新增「近3日/近1周/近2周」涨跌幅三列(issue #7)

交易日偏移口径:T = 上证指数日线(交易日历)中 <= 今天的最后一天,
D_n = T 往前 n 个交易日,锚点 = 个股日线(/bars 同款 QFQ)中
date <= D_n 的最后一根 bar。后端只回锚点收盘价,涨跌幅由前端用
SSE 实时价现算,盘中三列随报价免费跳动、无需轮询本接口。

- 新增 GET /watchlist/returns + 纯计算模块 web/returns.py(零 IO,单测覆盖
  停牌回退/次新 null/除权日 QFQ/非交易日回退等口径)
- 个股日线与交易日历均进程内缓存(当日不变、次日失效),重复刷新零行情请求;
  日历缺今天(serve 盘前启动)时按 60s 间隔重取,避免 T 整体前移
- 取数并发 ≤ 4(TDX 防封红线);单只失败只落 error,不影响整表
- 前端 +3 列(着色复用 dirClass/fmtPctSigned),e2e 同步断言

与 issue 定稿的两处偏差(/simplify 收敛,"减少不必要的改动"):
- 删除 windows 查询参数:列名与窗口一一对应(issue 亦将"用户自定义窗口"
  列为 out of scope),固定 3/5/10
- 个股缓存由磁盘 JSON 改为进程内 dict:可观测行为不变(当日不重复请求),
  但 serve 重启后当天首次请求会重取一次

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
awayings
2026-09-11 11:39:10 +08:00
co-authored by Claude Code
parent 57b1a7b32b
commit fb35677802
9 changed files with 1406 additions and 7 deletions
+30
View File
@@ -1,4 +1,6 @@
// 自选页 E2E:加入自选(行情校验 + 名称补全走 mock)→ 表格出现 → 删除 → 消失。
// 另覆盖 issue #7 的「近3日 / 近1周 / 近2周」三列(交易日偏移口径,锚点走后端
// /watchlist/returns,涨跌幅由前端用实时价现算)。
//
// 每轮 E2E 用独立的临时 EASY_TDX_CONFIG_DIR,自选从空开始,断言可写死。
@@ -9,6 +11,8 @@ test('自选页增删自选', async ({ page }) => {
// 初始为空(临时配置目录)
await expect(page.locator('.empty-row')).toBeVisible()
// 空行 colspan 与表头列数一致(新增 3 列后 = 15)
await expect(page.locator('.empty-row td')).toHaveAttribute('colspan', '15')
// 加入 600519(市场自动识别 SH;名称走 mock /mac/symbol-info → 贵州茅台)
await page.fill('.code-input', '600519')
@@ -22,3 +26,29 @@ test('自选页增删自选', async ({ page }) => {
await expect(page.locator('.data-row')).toHaveCount(0)
await expect(page.locator('.empty-row')).toBeVisible()
})
test('自选页近3日/近1周/近2周涨跌幅三列', async ({ page }) => {
await page.goto('/watchlist')
await page.fill('.code-input', '600519')
await page.getByRole('button', { name: '加入自选' }).click()
await expect(page.locator('.data-row')).toHaveCount(1, { timeout: 30_000 })
// 表头:现价/涨跌幅之后依次是 近3日、近1周、近2周(共 15 列 = 12 + 3
const headers = page.locator('.qtable thead th')
await expect(headers).toHaveCount(15)
await expect(headers.nth(2)).toHaveText('涨跌幅')
await expect(headers.nth(3)).toHaveText('近3日')
await expect(headers.nth(4)).toHaveText('近1周')
await expect(headers.nth(5)).toHaveText('近2周')
// 数据格:与表头列数一致,三列都是带符号百分比(合成行情锚点 → 一定会算出数)
const cells = page.locator('.data-row td')
await expect(cells).toHaveCount(15)
for (const i of [3, 4, 5]) {
await expect(cells.nth(i)).toHaveText(/^[+-]?\d+\.\d+%$/)
}
await page.locator('.data-row .del').first().click()
await expect(page.locator('.data-row')).toHaveCount(0)
})
+8
View File
@@ -48,6 +48,7 @@ import type {
TaskState,
TaskSubmitResponse,
WatchlistResponse,
WatchlistReturnsResponse,
} from './types'
const BASE = '/api/v1'
@@ -703,6 +704,13 @@ export async function fetchWatchlist(): Promise<WatchlistResponse> {
return (await resp.json()) as WatchlistResponse
}
/** 近 3/5/10 交易日涨跌幅的锚点收盘价(前端用实时价现算涨跌幅,后端只给锚点)。 */
export async function fetchWatchlistReturns(): Promise<WatchlistReturnsResponse> {
const resp = await fetch(`${BASE}/watchlist/returns`)
if (!resp.ok) await throwError(resp)
return (await resp.json()) as WatchlistReturnsResponse
}
/** 加入自选(幂等)。 */
export async function addWatchItem(market: string, code: string, name = ''): Promise<void> {
const resp = await fetch(`${BASE}/watchlist`, {
+22
View File
@@ -555,6 +555,28 @@ export interface WatchlistResponse {
count: number
}
/** 单个交易日窗口的锚点(close 为 null = 数据不足,前端显示 '-')。 */
export interface WatchReturnAnchor {
days: number
close: number | null
date: string | null
}
/** 一只自选的锚点结果;取数失败时只有 error(后端不下发 null 字段)。 */
export interface WatchReturnItem {
last_close?: number
last_date?: string
stale_days?: number
anchors?: WatchReturnAnchor[]
error?: string
}
/** GET /api/v1/watchlist/returnsanchor 收盘价 + T(涨跌幅由前端用实时价现算)。 */
export interface WatchlistReturnsResponse {
trade_date: string | null
items: Record<string, WatchReturnItem>
}
// ── 行情终端:板块列表(GET /api/v1/board-mac/listMAC 协议,防御式取列) ────
/** 板块行(MAC 协议字段随版本浮动,全部可选,渲染端容错)。 */
+66 -2
View File
@@ -11,6 +11,7 @@ import {
fetchQuotes,
fetchSymbolName,
fetchWatchlist,
fetchWatchlistReturns,
formatError,
removeWatchItem,
} from '../api'
@@ -20,10 +21,18 @@ import Sparkline from '../components/Sparkline.vue'
import { dirClass, fmt2, fmtAmount, fmtPctSigned, fmtVol } from '../format'
import { detectMarket } from '../market'
import { useQuoteStore } from '../stores/quotes'
import type { WatchItem } from '../types'
import type { WatchItem, WatchReturnItem } from '../types'
const quoteStore = useQuoteStore()
// 近 N 交易日涨跌幅(交易日偏移口径,见后端 /watchlist/returns):列名与窗口一一对应,
// 窗口本身由后端 returns.DEFAULT_WINDOWS 固定,这里只负责标签与取值顺序。
const WINDOWS: ReadonlyArray<{ days: number; label: string }> = [
{ days: 3, label: '近3日' },
{ days: 5, label: '近1周' },
{ days: 10, label: '近2周' },
]
/** 板块指数(881/885/880 开头)走板块弹窗,其余走个股弹窗。 */
function isBoardCode(code: string): boolean {
return /^88\d/.test(code)
@@ -43,6 +52,7 @@ async function loadList() {
const resp = await fetchWatchlist()
items.value = resp.items
loadSparks()
loadReturns()
restFallback()
fillMissingNames()
} catch (e) {
@@ -95,6 +105,50 @@ function pct(item: WatchItem): number | null {
return (qq.price / qq.pre_close - 1) * 100
}
// ── 近 N 交易日涨跌幅(锚点收盘价来自后端,涨跌幅在这里用实时价现算) ──────────
const returns = ref(new Map<string, WatchReturnItem>())
/** 拉一次锚点(后端按天缓存,盘中/重复刷新不重复请求行情)。 */
async function loadReturns() {
if (items.value.length === 0) {
returns.value = new Map()
return
}
try {
const resp = await fetchWatchlistReturns()
returns.value = new Map(Object.entries(resp.items))
} catch {
// 单只失败/整体失败都不影响其余列,静默(与 loadSparks 同语义)
}
}
function retItem(item: WatchItem): WatchReturnItem | undefined {
return returns.value.get(item.symbol)
}
/** 锚点日期(悬停提示用):近N日涨跌幅的基准 bar 实际日期。 */
function anchorDate(item: WatchItem, days: number): string {
const a = retItem(item)?.anchors?.find((x) => x.days === days)
return a?.date ? `锚点 ${a.date}` : '锚点不可用'
}
/** 近 N 交易日涨跌幅:优先 SSE 实时价,无报价时用后端 last_close 兜底。 */
function pctVs(item: WatchItem, days: number): number | null {
const r = retItem(item)
const anchor = r?.anchors?.find((a) => a.days === days)?.close
if (anchor == null || !(anchor > 0)) return null
const price = q(item)?.price ?? r?.last_close
if (price == null || !Number.isFinite(price) || price <= 0) return null
return (price / anchor - 1) * 100
}
/** 长期停牌(最后一根 bar 不在 T):标灰,避免误读成当日行情。 */
function isStale(item: WatchItem): boolean {
const r = retItem(item)
return !!r && !r.error && (r.stale_days ?? 0) > 0
}
// ── 迷你分时 ────────────────────────────────────────────────────────────────
const sparks = ref(new Map<string, number[]>())
@@ -168,6 +222,7 @@ async function remove(item: WatchItem) {
await removeWatchItem(item.market, item.code)
items.value = items.value.filter((i) => i.symbol !== item.symbol)
sparks.value.delete(item.symbol)
returns.value.delete(item.symbol)
} catch (e) {
listError.value = formatError(e)
}
@@ -211,6 +266,7 @@ const emptyHint = computed(() =>
<th>名称</th>
<th>现价</th>
<th>涨跌幅</th>
<th v-for="w in WINDOWS" :key="w.days">{{ w.label }}</th>
<th>涨跌额</th>
<th>成交量</th>
<th>成交额</th>
@@ -224,7 +280,7 @@ const emptyHint = computed(() =>
</thead>
<tbody>
<tr v-if="emptyHint" class="empty-row">
<td colspan="12">{{ emptyHint }}</td>
<td colspan="15">{{ emptyHint }}</td>
</tr>
<tr v-for="item in items" :key="item.symbol" class="data-row" @click="openItem(item)">
<td>
@@ -233,6 +289,14 @@ const emptyHint = computed(() =>
</td>
<td class="big" :class="dirClass(pct(item))">{{ fmt2(q(item)?.price) }}</td>
<td :class="dirClass(pct(item))">{{ fmtPctSigned(pct(item)) }}</td>
<td
v-for="w in WINDOWS"
:key="w.days"
:class="[dirClass(pctVs(item, w.days)), { dim: isStale(item) }]"
:title="anchorDate(item, w.days)"
>
{{ fmtPctSigned(pctVs(item, w.days)) }}
</td>
<td :class="dirClass(pct(item))">
{{ q(item)?.price && q(item)?.pre_close ? fmt2(q(item)!.price! - q(item)!.pre_close!) : '-' }}
</td>