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
+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>