mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 18:04:20 +08:00
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user