mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 14:34:18 +08:00
交易日偏移口径: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>
417 lines
13 KiB
Vue
417 lines
13 KiB
Vue
<script setup lang="ts">
|
|
// 自选行情:SSE 实时表格 + 行内迷你分时 + 一键加删 + 点击开个股对话框。
|
|
// 迷你分时按需懒加载(行可见时才拉 /minute,60s 重拉)——这里简化为
|
|
// 打开页面时批量拉前 N 只(80/批上限内),足够 MVP。
|
|
|
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
|
|
import {
|
|
addWatchItem,
|
|
fetchMinute,
|
|
fetchQuotes,
|
|
fetchSymbolName,
|
|
fetchWatchlist,
|
|
fetchWatchlistReturns,
|
|
formatError,
|
|
removeWatchItem,
|
|
} from '../api'
|
|
import StockDialog from '../components/StockDialog.vue'
|
|
import BoardDialog from '../components/BoardDialog.vue'
|
|
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, 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)
|
|
}
|
|
|
|
const items = ref<WatchItem[]>([])
|
|
const listError = ref('')
|
|
const adding = ref(false)
|
|
const addCode = ref('')
|
|
const addName = ref('')
|
|
|
|
// ── 列表加载 ────────────────────────────────────────────────────────────────
|
|
|
|
async function loadList() {
|
|
listError.value = ''
|
|
try {
|
|
const resp = await fetchWatchlist()
|
|
items.value = resp.items
|
|
loadSparks()
|
|
loadReturns()
|
|
restFallback()
|
|
fillMissingNames()
|
|
} catch (e) {
|
|
listError.value = formatError(e)
|
|
}
|
|
}
|
|
|
|
/** 给历史遗留的"只有代码没有名称"的自选项补中文名(幂等写回存储)。 */
|
|
async function fillMissingNames() {
|
|
const missing = items.value.filter((i) => !i.name)
|
|
for (const it of missing) {
|
|
const name = await fetchSymbolName(it.market, it.code)
|
|
if (name) {
|
|
it.name = name
|
|
try {
|
|
await addWatchItem(it.market, it.code, name)
|
|
} catch {
|
|
// 写回失败仅影响下次加载,静默
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 行情(SSE 快照 + REST 首次兜底) ────────────────────────────────────────
|
|
|
|
/** SSE 未覆盖时(自选刚加、服务重启间隙)用 REST 主动拉一次。
|
|
* 后端 /quotes 单次最多 80 只(通达信协议上限),超量需分批。 */
|
|
const QUOTE_BATCH = 80
|
|
|
|
async function restFallback() {
|
|
if (items.value.length === 0) return
|
|
const missing = items.value.filter((i) => !quoteStore.getQuote(i.symbol))
|
|
if (missing.length === 0) return
|
|
for (let i = 0; i < missing.length; i += QUOTE_BATCH) {
|
|
try {
|
|
await fetchQuotes(missing.slice(i, i + QUOTE_BATCH).map((it) => ({ market: it.market, code: it.code })))
|
|
} catch {
|
|
// SSE 会补上,静默(单批失败不阻断后续批次)
|
|
}
|
|
}
|
|
}
|
|
|
|
function q(item: WatchItem) {
|
|
return quoteStore.getQuote(item.symbol)
|
|
}
|
|
|
|
function pct(item: WatchItem): number | null {
|
|
const qq = q(item)
|
|
if (!qq?.price || !qq.pre_close) return 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[]>())
|
|
const sparkBase = ref(new Map<string, number>())
|
|
|
|
async function loadSparks() {
|
|
const targets = items.value.slice(0, 80)
|
|
for (const it of targets) {
|
|
if (sparks.value.has(it.symbol)) continue
|
|
try {
|
|
const pts = await fetchMinute(it.market, it.code)
|
|
if (pts.length > 0) {
|
|
sparks.value.set(it.symbol, pts.map((p) => p.price))
|
|
const qq = q(it)
|
|
sparkBase.value.set(it.symbol, qq?.pre_close ?? pts[0].price)
|
|
}
|
|
} catch {
|
|
// 单只失败不影响整表
|
|
}
|
|
}
|
|
}
|
|
|
|
let sparkTimer = 0
|
|
onMounted(() => {
|
|
loadList()
|
|
sparkTimer = window.setInterval(loadSparks, 60_000)
|
|
})
|
|
onBeforeUnmount(() => window.clearInterval(sparkTimer))
|
|
|
|
// ── 加/删自选 ───────────────────────────────────────────────────────────────
|
|
|
|
async function add() {
|
|
const code = addCode.value.trim()
|
|
if (!/^\d{6}$/.test(code)) {
|
|
listError.value = '请输入 6 位数字代码'
|
|
return
|
|
}
|
|
const market = detectMarket(code)
|
|
adding.value = true
|
|
listError.value = ''
|
|
try {
|
|
// 名称优先级:用户备注 > 证券名称接口(五档行情协议本身不带名称)。
|
|
// 名称取不到不阻断(BJ 等市场 MAC 协议可能不支持),退回显示代码。
|
|
let name = addName.value.trim()
|
|
if (!name) name = await fetchSymbolName(market, code)
|
|
try {
|
|
const quotes = await fetchQuotes([{ market, code }])
|
|
const qq = quotes[0]
|
|
if (qq && qq.price == null) {
|
|
listError.value = `提示:${market}${code} 暂无行情返回,仍已加入自选`
|
|
} else if (qq && Number.isFinite(qq.vol) && qq.vol === 0 && qq.amount === 0) {
|
|
listError.value = `提示:${market}${code} 今日无成交(停牌或非交易日),仍已加入自选`
|
|
}
|
|
} catch {
|
|
listError.value = `提示:行情校验不可用,${market}${code} 仍已加入自选`
|
|
}
|
|
await addWatchItem(market, code, name)
|
|
addCode.value = ''
|
|
addName.value = ''
|
|
await loadList()
|
|
} catch (e) {
|
|
listError.value = formatError(e)
|
|
} finally {
|
|
adding.value = false
|
|
}
|
|
}
|
|
|
|
async function remove(item: WatchItem) {
|
|
listError.value = ''
|
|
try {
|
|
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)
|
|
}
|
|
}
|
|
|
|
// ── 弹窗(个股 / 板块分流) ─────────────────────────────────────────────────
|
|
|
|
const dialog = ref<WatchItem | null>(null)
|
|
const boardDlg = ref<WatchItem | null>(null)
|
|
|
|
function openItem(item: WatchItem) {
|
|
if (isBoardCode(item.code)) boardDlg.value = item
|
|
else dialog.value = item
|
|
}
|
|
|
|
const emptyHint = computed(() =>
|
|
items.value.length === 0 && !listError.value
|
|
? '自选为空:输入 6 位代码加入(如 600519)。加入后行情实时推送。'
|
|
: '',
|
|
)
|
|
</script>
|
|
|
|
<template>
|
|
<div class="wl">
|
|
<div class="toolbar">
|
|
<h2>自选行情</h2>
|
|
<div class="add-row">
|
|
<input v-model="addCode" class="code-input" placeholder="6 位代码" maxlength="6" @keyup.enter="add" />
|
|
<input v-model="addName" class="name-input" placeholder="备注名(可空)" maxlength="16" @keyup.enter="add" />
|
|
<button class="primary" :disabled="adding" @click="add">{{ adding ? '加入中…' : '加入自选' }}</button>
|
|
</div>
|
|
<span class="hint">共 {{ items.length }} 只 · 行情实时推送</span>
|
|
</div>
|
|
|
|
<div v-if="listError" class="err">{{ listError }}</div>
|
|
|
|
<div class="table-wrap">
|
|
<table class="qtable">
|
|
<thead>
|
|
<tr>
|
|
<th>名称</th>
|
|
<th>现价</th>
|
|
<th>涨跌幅</th>
|
|
<th v-for="w in WINDOWS" :key="w.days">{{ w.label }}</th>
|
|
<th>涨跌额</th>
|
|
<th>成交量</th>
|
|
<th>成交额</th>
|
|
<th>最高</th>
|
|
<th>最低</th>
|
|
<th>今开</th>
|
|
<th>昨收</th>
|
|
<th>分时</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-if="emptyHint" class="empty-row">
|
|
<td colspan="15">{{ emptyHint }}</td>
|
|
</tr>
|
|
<tr v-for="item in items" :key="item.symbol" class="data-row" @click="openItem(item)">
|
|
<td>
|
|
<div class="cell-name">{{ item.name || item.code }}</div>
|
|
<div class="cell-code mono dim">{{ item.symbol }}</div>
|
|
</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>
|
|
<td>{{ fmtVol(q(item)?.vol) }}</td>
|
|
<td>{{ fmtAmount(q(item)?.amount) }}</td>
|
|
<td>{{ fmt2(q(item)?.high) }}</td>
|
|
<td>{{ fmt2(q(item)?.low) }}</td>
|
|
<td>{{ fmt2(q(item)?.open) }}</td>
|
|
<td class="dim">{{ fmt2(q(item)?.pre_close) }}</td>
|
|
<td>
|
|
<Sparkline :prices="sparks.get(item.symbol) ?? []" :base="sparkBase.get(item.symbol) ?? null" />
|
|
</td>
|
|
<td>
|
|
<button class="del" @click.stop="remove(item)">✕</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<StockDialog
|
|
v-if="dialog"
|
|
:market="dialog.market"
|
|
:code="dialog.code"
|
|
:name="dialog.name"
|
|
@close="dialog = null"
|
|
@watchlist-changed="loadList"
|
|
/>
|
|
<BoardDialog
|
|
v-if="boardDlg"
|
|
:code="boardDlg.code"
|
|
:name="boardDlg.name || boardDlg.code"
|
|
@close="boardDlg = null"
|
|
@watchlist-changed="loadList"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.wl {
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: 14px 16px;
|
|
gap: 10px;
|
|
}
|
|
.toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.toolbar h2 {
|
|
font-size: 16px;
|
|
}
|
|
.add-row {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
.code-input {
|
|
width: 110px;
|
|
}
|
|
.name-input {
|
|
width: 140px;
|
|
}
|
|
.hint {
|
|
font-size: 12px;
|
|
color: var(--text-dim);
|
|
margin-left: auto;
|
|
}
|
|
.err {
|
|
color: var(--up);
|
|
font-size: 12px;
|
|
}
|
|
.table-wrap {
|
|
flex: 1;
|
|
overflow: auto;
|
|
background: var(--bg-panel);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
}
|
|
.data-row {
|
|
cursor: pointer;
|
|
}
|
|
.data-row:hover {
|
|
background: var(--bg-elevated);
|
|
}
|
|
.cell-name {
|
|
font-weight: 600;
|
|
}
|
|
.cell-code {
|
|
font-size: 11px;
|
|
}
|
|
.big {
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
}
|
|
.empty-row td {
|
|
text-align: center;
|
|
color: var(--text-dim);
|
|
padding: 40px 0;
|
|
font-size: 13px;
|
|
}
|
|
.del {
|
|
padding: 1px 7px;
|
|
font-size: 11px;
|
|
color: var(--text-dim);
|
|
border: none;
|
|
background: transparent;
|
|
}
|
|
.del:hover {
|
|
color: var(--up);
|
|
}
|
|
.dim {
|
|
color: var(--text-dim);
|
|
}
|
|
</style>
|