From 497236b8b4fa35260bccd4e3d4aca51a61439717 Mon Sep 17 00:00:00 2001 From: dongzp <975303544@qq.com> Date: Wed, 22 Jul 2026 09:56:39 +0800 Subject: [PATCH] feat(ws): realtime Baidu quote via tick/snapshot WebSocket Co-authored-by: Cursor --- .../plans/2026-07-22-baidu-quote-ws.md | 624 ++++++++++++++++++ src/api/baidu/mapWsQuote.ts | 167 +++++ src/api/baidu/quotation.ts | 2 +- src/api/baidu/ws.ts | 167 +++++ src/api/baidu/wsTypes.ts | 96 +++ src/composables/useQuote.ts | 10 +- src/stores/quota.ts | 56 ++ 7 files changed, 1119 insertions(+), 3 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-22-baidu-quote-ws.md create mode 100644 src/api/baidu/mapWsQuote.ts create mode 100644 src/api/baidu/ws.ts create mode 100644 src/api/baidu/wsTypes.ts diff --git a/docs/superpowers/plans/2026-07-22-baidu-quote-ws.md b/docs/superpowers/plans/2026-07-22-baidu-quote-ws.md new file mode 100644 index 0000000..8c2a8d7 --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-baidu-quote-ws.md @@ -0,0 +1,624 @@ +# 百度行情 WebSocket 实时刷新 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在 HTTP 全量行情之上接入百度财经 WebSocket,实时刷新成交明细、报价、盘口与分时图。 + +**Architecture:** 独立 `BaiduQuoteWs` 客户端负责连接/心跳/重连;`mapWsQuote` 将 tick/snapshot 增量合并进 `QuoteData`;`quota` store 在 HTTP 成功后启 WS,卸载时断开。 + +**Tech Stack:** Vue 3、Pinia、原生 WebSocket、TypeScript + +**Spec:** `docs/superpowers/specs/2026-07-22-baidu-quote-ws-design.md` + +**验证:** 项目无单测框架;以 `npm run typecheck` + 浏览器 Network/WS 手工验证为主。 + +--- + +## File Structure + +``` +Create: + src/api/baidu/wsTypes.ts — WS 消息类型 + src/api/baidu/ws.ts — BaiduQuoteWs 客户端 + src/api/baidu/mapWsQuote.ts — 增量合并 QuoteData + +Modify: + src/stores/quota.ts — connectWs / disconnectWs / apply 增量 + src/composables/useQuote.ts — onUnmounted disconnect + src/api/baidu/quotation.ts — 更新注释(可选) +``` + +--- + +### Task 1: WS 消息类型 `wsTypes.ts` + +**Files:** +- Create: `src/api/baidu/wsTypes.ts` + +- [ ] **Step 1: 创建类型文件** + +```ts +/** Baidu finance WebSocket subscribe / patch item */ +export interface BaiduWsItem { + code: string + name: string + market: 'ab' + financeType: 'futures' +} + +export interface BaiduWsOutbound { + method: 'subscribe' | 'patch' | 'ping' + source: 'pc-web' + product?: 'tick' | 'snapshot' + items?: BaiduWsItem[] +} + +export interface BaiduWsDetailInfo { + time: string + volume: string + price: string + type: string + formatTime: string + bsFlag: string +} + +export interface BaiduWsTickData { + financeType: string + code: string + market: string + product: 'tick' + detailinfos: BaiduWsDetailInfo[] + updatetime?: string +} + +export interface BaiduWsPankouItem { + ename: string + name: string + value: string + originValue?: number + status?: string +} + +export interface BaiduWsCur { + avgPrice?: string + ratio?: string + increase?: string + price?: string + status?: string + unit?: string +} + +export interface BaiduWsPoint { + price: string + avgPrice: string + range?: string + ratio?: string + totalVolume: string + totalAmount?: string + time: string + timestamp?: string + realTimeStampMs?: string +} + +export interface BaiduWsAskInfo { + askprice: string + askvolume: string +} + +export interface BaiduWsBuyInfo { + bidprice: string + bidvolume: string +} + +export interface BaiduWsSnapshotData { + financeType: string + code: string + market: string + product: 'snapshot' + method?: string + cur?: BaiduWsCur + pankouinfos?: BaiduWsPankouItem[] + update?: { + timezone?: string + text?: string + stockStatus?: string + tradeStatusCN?: string + } + point?: BaiduWsPoint + askinfos?: BaiduWsAskInfo[] + buyinfos?: BaiduWsBuyInfo[] +} + +export interface BaiduWsMessage { + queryId?: string + resultCode: string + data?: BaiduWsTickData | BaiduWsSnapshotData +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/api/baidu/wsTypes.ts +git commit -m "feat(ws): add Baidu quote WebSocket message types" +``` + +--- + +### Task 2: 增量 mapper `mapWsQuote.ts` + +**Files:** +- Create: `src/api/baidu/mapWsQuote.ts` + +- [ ] **Step 1: 实现 tick / snapshot 合并函数** + +关键逻辑: + +```ts +import type { IntradayPoint, QuoteData, TradeTick } from '../../types' +import type { BaiduWsSnapshotData, BaiduWsTickData } from './wsTypes' + +const MAX_TRADES = 200 + +function toNum(value: string | number | undefined | null, fallback = 0): number { + if (value == null || value === '' || value === '--') return fallback + if (typeof value === 'number') return Number.isFinite(value) ? value : fallback + const cleaned = String(value).replace(/[+,%]/g, '').trim() + const n = Number(cleaned) + return Number.isFinite(n) ? n : fallback +} + +function tradeKey(t: TradeTick): string { + return `${t.time}|${t.price}|${t.volume}|${t.side}` +} + +function parseAmountYi(value: string | undefined, originValue?: number): number { + if (originValue != null && Number.isFinite(originValue) && originValue > 0) { + return Number((originValue / 1e8).toFixed(2)) + } + if (!value || value === '--') return 0 + if (value.includes('亿')) return toNum(value.replace('亿', '')) + if (value.includes('万')) return Number((toNum(value.replace('万', '')) / 1e4).toFixed(4)) + return toNum(value) +} + +function hhmmFromPointTime(time: string): string { + // "07-15 10:59" or "10:59" + if (time.includes(' ')) return time.split(' ')[1]!.slice(0, 5) + return time.slice(0, 5) +} + +/** Merge tick detailinfos into quote.trades (newest first, dedupe, cap 200). */ +export function applyWsTick(quote: QuoteData, data: BaiduWsTickData): QuoteData { + const incoming: TradeTick[] = [...(data.detailinfos ?? [])] + .reverse() + .map((t) => ({ + time: t.formatTime, + price: toNum(t.price), + volume: toNum(t.volume), + side: t.bsFlag === 'B' ? 'B' : 'S', + })) + + const seen = new Set() + const merged: TradeTick[] = [] + for (const t of [...incoming, ...quote.trades]) { + const k = tradeKey(t) + if (seen.has(k)) continue + seen.add(k) + merged.push(t) + if (merged.length >= MAX_TRADES) break + } + return { ...quote, trades: merged } +} + +/** Apply snapshot fields onto quote (price, book, pankou, intraday point). */ +export function applyWsSnapshot(quote: QuoteData, data: BaiduWsSnapshotData): QuoteData { + const next: QuoteData = { ...quote } + + if (data.cur) { + const c = data.cur + if (c.price != null) next.last = toNum(c.price, next.last) + if (c.increase != null) next.change = toNum(c.increase, next.change) + if (c.ratio != null) next.changePercent = toNum(c.ratio, next.changePercent) + if (c.avgPrice != null) next.avg = toNum(c.avgPrice, next.avg) + } + + if (data.update) { + if (data.update.text) next.updatedAt = data.update.text + if (data.update.stockStatus) next.status = data.update.stockStatus + else if (data.update.tradeStatusCN) next.status = data.update.tradeStatusCN + } + + if (data.pankouinfos?.length) { + const by = Object.fromEntries(data.pankouinfos.map((i) => [i.ename, i])) + const num = (ename: string, fallback: number) => + by[ename] ? toNum(by[ename]!.originValue ?? by[ename]!.value, fallback) : fallback + + next.open = num('open', next.open) + next.high = num('high', next.high) + next.low = num('low', next.low) + next.prevClose = num('preClose', next.prevClose) + next.volume = num('volume', next.volume) + next.openInterest = num('holdingAmount', next.openInterest) + next.amplitude = num('amplitudeRatio', next.amplitude) + next.settlement = num('settlement', next.settlement) + next.prevSettlement = num('prevSettlement', next.prevSettlement) + next.outerVol = num('outside', next.outerVol) + next.innerVol = num('inside', next.innerVol) + if (by.avgPrice) next.avg = num('avgPrice', next.avg) + if (by.amount) { + next.amount = parseAmountYi(by.amount.value, by.amount.originValue) + } + } + + if (data.askinfos || data.buyinfos) { + const asksRaw = data.askinfos ?? [] + const bidsRaw = data.buyinfos ?? [] + const asks = asksRaw + .map((a, i) => ({ + level: asksRaw.length - i, + price: toNum(a.askprice), + volume: toNum(a.askvolume), + })) + .filter((a) => a.price > 0) + const bids = bidsRaw + .map((b, i) => ({ + level: i + 1, + price: toNum(b.bidprice), + volume: toNum(b.bidvolume), + })) + .filter((b) => b.price > 0) + const bidVol = bids.reduce((s, b) => s + b.volume, 0) + const askVol = asks.reduce((s, a) => s + a.volume, 0) + const total = bidVol + askVol + next.orderBook = { asks, bids } + next.buyRatio = total > 0 ? Math.round((bidVol / total) * 100) : 50 + next.sellRatio = 100 - next.buyRatio + } + + if (data.point) { + next.intraday = upsertIntraday(next.intraday, data.point.price, data.point.avgPrice, data.point.time, data.point.totalVolume) + } + + return next +} + +function upsertIntraday( + points: IntradayPoint[], + priceRaw: string, + avgRaw: string, + timeRaw: string, + totalVolumeRaw: string, +): IntradayPoint[] { + const time = hhmmFromPointTime(timeRaw) + const price = toNum(priceRaw) + const avg = toNum(avgRaw) + const totalVolume = toNum(totalVolumeRaw) + const list = [...points] + const idx = list.findIndex((p) => p.time === time) + + const othersSum = list.reduce((s, p, i) => (i === idx ? s : s + p.volume), 0) + const volume = Math.max(0, totalVolume - othersSum) + const point: IntradayPoint = { time, price, avg, volume } + + if (idx >= 0) { + list[idx] = point + return list + } + list.push(point) + return list +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/api/baidu/mapWsQuote.ts +git commit -m "feat(ws): add tick/snapshot merge mappers for QuoteData" +``` + +--- + +### Task 3: WebSocket 客户端 `ws.ts` + +**Files:** +- Create: `src/api/baidu/ws.ts` + +- [ ] **Step 1: 实现 BaiduQuoteWs** + +```ts +import { contractConfig } from '../../config/contract' +import type { BaiduWsItem, BaiduWsMessage, BaiduWsOutbound } from './wsTypes' + +const WS_URL = 'wss://finance-ws.pae.baidu.com/' +const PING_MS = 6_000 +const PATCH_MS = 60_000 +const RECONNECT_BASE_MS = 1_000 +const RECONNECT_MAX_MS = 30_000 + +export type BaiduQuoteWsHandlers = { + onMessage: (msg: BaiduWsMessage) => void + onError?: (err: unknown) => void +} + +function buildItem(): BaiduWsItem { + return { + code: contractConfig.code, + name: contractConfig.name, + market: 'ab', + financeType: 'futures', + } +} + +export class BaiduQuoteWs { + private ws: WebSocket | null = null + private pingTimer: ReturnType | null = null + private patchTimer: ReturnType | null = null + private reconnectTimer: ReturnType | null = null + private reconnectAttempt = 0 + private intentionalClose = false + private handlers: BaiduQuoteWsHandlers + + constructor(handlers: BaiduQuoteWsHandlers) { + this.handlers = handlers + } + + connect(): void { + this.intentionalClose = false + this.clearReconnect() + if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) { + return + } + this.openSocket() + } + + disconnect(): void { + this.intentionalClose = true + this.clearTimers() + this.clearReconnect() + if (this.ws) { + this.ws.onopen = null + this.ws.onmessage = null + this.ws.onerror = null + this.ws.onclose = null + try { + this.ws.close() + } catch { + /* ignore */ + } + this.ws = null + } + } + + private openSocket(): void { + const ws = new WebSocket(WS_URL) + this.ws = ws + + ws.onopen = () => { + this.reconnectAttempt = 0 + this.sendSubscribe() + this.startTimers() + } + + ws.onmessage = (ev) => { + try { + const msg = JSON.parse(String(ev.data)) as BaiduWsMessage + this.handlers.onMessage(msg) + } catch (e) { + console.error('[baidu-ws] parse failed', e) + this.handlers.onError?.(e) + } + } + + ws.onerror = (ev) => { + console.error('[baidu-ws] error', ev) + this.handlers.onError?.(ev) + } + + ws.onclose = () => { + this.clearTimers() + this.ws = null + if (!this.intentionalClose) this.scheduleReconnect() + } + } + + private send(payload: BaiduWsOutbound): void { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return + this.ws.send(JSON.stringify(payload)) + } + + private sendSubscribe(): void { + const item = buildItem() + this.send({ method: 'subscribe', source: 'pc-web', product: 'tick', items: [item] }) + this.send({ method: 'subscribe', source: 'pc-web', product: 'snapshot', items: [item] }) + } + + private sendPing(): void { + this.send({ method: 'ping', source: 'pc-web' }) + } + + private sendPatch(): void { + const item = buildItem() + this.send({ method: 'patch', source: 'pc-web', product: 'snapshot', items: [item] }) + } + + private startTimers(): void { + this.clearTimers() + this.pingTimer = setInterval(() => this.sendPing(), PING_MS) + this.patchTimer = setInterval(() => this.sendPatch(), PATCH_MS) + } + + private clearTimers(): void { + if (this.pingTimer) { + clearInterval(this.pingTimer) + this.pingTimer = null + } + if (this.patchTimer) { + clearInterval(this.patchTimer) + this.patchTimer = null + } + } + + private scheduleReconnect(): void { + this.clearReconnect() + const delay = Math.min( + RECONNECT_BASE_MS * 2 ** this.reconnectAttempt, + RECONNECT_MAX_MS, + ) + this.reconnectAttempt += 1 + this.reconnectTimer = setTimeout(() => this.openSocket(), delay) + } + + private clearReconnect(): void { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/api/baidu/ws.ts +git commit -m "feat(ws): add BaiduQuoteWs client with ping/patch/reconnect" +``` + +--- + +### Task 4: 接入 quota store + useQuote + +**Files:** +- Modify: `src/stores/quota.ts` +- Modify: `src/composables/useQuote.ts` + +- [ ] **Step 1: quota store 增加 WS 生命周期** + +在 `quota.ts` 顶部增加 import: + +```ts +import { BaiduQuoteWs } from '../api/baidu/ws' +import { applyWsSnapshot, applyWsTick } from '../api/baidu/mapWsQuote' +import type { BaiduWsMessage, BaiduWsSnapshotData, BaiduWsTickData } from '../api/baidu/wsTypes' +``` + +在 store 内增加: + +```ts +let quoteWs: BaiduQuoteWs | null = null + +function handleWsMessage(msg: BaiduWsMessage) { + if (msg.resultCode !== '0' || !msg.data) { + if (msg.resultCode && msg.resultCode !== '0') { + console.warn('[quota] ws resultCode', msg.resultCode) + } + return + } + const product = msg.data.product + if (product === 'tick') { + quote.value = applyWsTick(quote.value, msg.data as BaiduWsTickData) + } else if (product === 'snapshot') { + quote.value = applyWsSnapshot(quote.value, msg.data as BaiduWsSnapshotData) + } +} + +function connectWs() { + disconnectWs() + quoteWs = new BaiduQuoteWs({ + onMessage: handleWsMessage, + onError: (e) => console.error('[quota] ws error', e), + }) + quoteWs.connect() +} + +function disconnectWs() { + quoteWs?.disconnect() + quoteWs = null +} +``` + +修改 `fetchQuote`:成功赋值后调用 `connectWs()`。 + +在 return 中导出 `connectWs`、`disconnectWs`。 + +- [ ] **Step 2: useQuote 卸载断开** + +```ts +import { storeToRefs } from 'pinia' +import { onMounted, onUnmounted } from 'vue' +import { useQuotaStore, type KlinePeriod } from '../stores/quota' + +export type { KlinePeriod } + +export function useQuote() { + const store = useQuotaStore() + const { quote: data, quoteLoading: loading, klineLoading, quoteError: error } = + storeToRefs(store) + + onMounted(() => { + void store.fetchQuote() + }) + + onUnmounted(() => { + store.disconnectWs() + }) + + return { + data, + loading, + klineLoading, + error, + refresh: () => store.fetchQuote(), + loadKline: (period: KlinePeriod) => store.loadKline(period), + } +} +``` + +- [ ] **Step 3: typecheck** + +```bash +npm run typecheck +``` + +Expected: 无错误。 + +- [ ] **Step 4: Commit** + +```bash +git add src/stores/quota.ts src/composables/useQuote.ts +git commit -m "feat(ws): wire BaiduQuoteWs into quota store lifecycle" +``` + +--- + +### Task 5: 手工验证 + +- [ ] **Step 1: 启动 dev** + +```bash +npm run dev +``` + +- [ ] **Step 2: 浏览器验证清单** + +1. Network → WS 连上 `finance-ws.pae.baidu.com` +2. 发送 subscribe tick + snapshot +3. 约 6s 见 ping;约 60s 见 patch +4. 成交明细 / 大单分析随 tick 动 +5. 现价、盘口、分时随 snapshot 动 +6. 刷新页面后无残留连接(旧 WS 关闭) + +--- + +## Spec coverage checklist + +| Spec 项 | Task | +|---------|------| +| HTTP 后连 WS | Task 4 | +| tick → trades | Task 2 + 4 | +| snapshot → 报价/盘口/分时 | Task 2 + 4 | +| ping 6s / patch 60s | Task 3 | +| 重连退避 | Task 3 | +| 卸载 disconnect | Task 4 | +| 不改 UI 组件 | —(无 Task) | diff --git a/src/api/baidu/mapWsQuote.ts b/src/api/baidu/mapWsQuote.ts new file mode 100644 index 0000000..e64fea5 --- /dev/null +++ b/src/api/baidu/mapWsQuote.ts @@ -0,0 +1,167 @@ +import type { IntradayPoint, QuoteData, TradeTick } from '../../types' +import type { BaiduWsSnapshotData, BaiduWsTickData } from './wsTypes' + +const MAX_TRADES = 200 + +function toNum(value: string | number | undefined | null, fallback = 0): number { + if (value == null || value === '' || value === '--') return fallback + if (typeof value === 'number') return Number.isFinite(value) ? value : fallback + const cleaned = String(value).replace(/[+,%]/g, '').trim() + const n = Number(cleaned) + return Number.isFinite(n) ? n : fallback +} + +function tradeKey(t: TradeTick): string { + return `${t.time}|${t.price}|${t.volume}|${t.side}` +} + +/** + * Parse display amount such as 「103.32亿」; prefer originValue (raw yuan) when present. + */ +function parseAmountYi(value: string | undefined, originValue?: number): number { + if (originValue != null && Number.isFinite(originValue) && originValue > 0) { + return Number((originValue / 1e8).toFixed(2)) + } + if (!value || value === '--') return 0 + if (value.includes('亿')) return toNum(value.replace('亿', '')) + if (value.includes('万')) return Number((toNum(value.replace('万', '')) / 1e4).toFixed(4)) + return toNum(value) +} + +/** Extract HH:mm from 「07-15 10:59」 or 「10:59」. */ +function hhmmFromPointTime(time: string): string { + if (time.includes(' ')) return time.split(' ')[1]!.slice(0, 5) + return time.slice(0, 5) +} + +/** + * Upsert a minute bar. WS only provides cumulative totalVolume; + * minute volume = totalVolume − sum of other minutes' volumes. + */ +function upsertIntraday( + points: IntradayPoint[], + priceRaw: string, + avgRaw: string, + timeRaw: string, + totalVolumeRaw: string, +): IntradayPoint[] { + const time = hhmmFromPointTime(timeRaw) + const price = toNum(priceRaw) + const avg = toNum(avgRaw) + const totalVolume = toNum(totalVolumeRaw) + const list = [...points] + const idx = list.findIndex((p) => p.time === time) + + const othersSum = list.reduce((s, p, i) => (i === idx ? s : s + p.volume), 0) + const volume = Math.max(0, totalVolume - othersSum) + const point: IntradayPoint = { time, price, avg, volume } + + if (idx >= 0) { + list[idx] = point + return list + } + list.push(point) + return list +} + +/** Merge tick detailinfos into quote.trades (newest first, dedupe, cap 200). */ +export function applyWsTick(quote: QuoteData, data: BaiduWsTickData): QuoteData { + const incoming: TradeTick[] = [...(data.detailinfos ?? [])] + .reverse() + .map((t) => ({ + time: t.formatTime, + price: toNum(t.price), + volume: toNum(t.volume), + side: t.bsFlag === 'B' ? 'B' : 'S', + })) + + const seen = new Set() + const merged: TradeTick[] = [] + for (const t of [...incoming, ...quote.trades]) { + const k = tradeKey(t) + if (seen.has(k)) continue + seen.add(k) + merged.push(t) + if (merged.length >= MAX_TRADES) break + } + return { ...quote, trades: merged } +} + +/** Apply snapshot fields onto quote (price, book, pankou, intraday point). */ +export function applyWsSnapshot(quote: QuoteData, data: BaiduWsSnapshotData): QuoteData { + const next: QuoteData = { ...quote } + + if (data.cur) { + const c = data.cur + if (c.price != null) next.last = toNum(c.price, next.last) + if (c.increase != null) next.change = toNum(c.increase, next.change) + if (c.ratio != null) next.changePercent = toNum(c.ratio, next.changePercent) + if (c.avgPrice != null) next.avg = toNum(c.avgPrice, next.avg) + if (c.status) next.status = c.status + } + + if (data.update) { + if (data.update.text) next.updatedAt = data.update.text + if (data.update.stockStatus) next.status = data.update.stockStatus + else if (data.update.tradeStatusCN) next.status = data.update.tradeStatusCN + } + + if (data.pankouinfos?.length) { + const by = Object.fromEntries(data.pankouinfos.map((i) => [i.ename, i])) + const num = (ename: string, fallback: number) => + by[ename] ? toNum(by[ename]!.originValue ?? by[ename]!.value, fallback) : fallback + + next.open = num('open', next.open) + next.high = num('high', next.high) + next.low = num('low', next.low) + next.prevClose = num('preClose', next.prevClose) + next.volume = num('volume', next.volume) + next.openInterest = num('holdingAmount', next.openInterest) + next.amplitude = num('amplitudeRatio', next.amplitude) + next.settlement = num('settlement', next.settlement) + next.prevSettlement = num('prevSettlement', next.prevSettlement) + next.outerVol = num('outside', next.outerVol) + next.innerVol = num('inside', next.innerVol) + if (by.avgPrice) next.avg = num('avgPrice', next.avg) + if (by.amount) { + next.amount = parseAmountYi(by.amount.value, by.amount.originValue) + } + } + + if (data.askinfos || data.buyinfos) { + const asksRaw = data.askinfos ?? [] + const bidsRaw = data.buyinfos ?? [] + const asks = asksRaw + .map((a, i) => ({ + level: asksRaw.length - i, + price: toNum(a.askprice), + volume: toNum(a.askvolume), + })) + .filter((a) => a.price > 0) + const bids = bidsRaw + .map((b, i) => ({ + level: i + 1, + price: toNum(b.bidprice), + volume: toNum(b.bidvolume), + })) + .filter((b) => b.price > 0) + const bidVol = bids.reduce((s, b) => s + b.volume, 0) + const askVol = asks.reduce((s, a) => s + a.volume, 0) + const total = bidVol + askVol + next.orderBook = { asks, bids } + next.buyRatio = total > 0 ? Math.round((bidVol / total) * 100) : 50 + next.sellRatio = 100 - next.buyRatio + } + + if (data.point) { + next.intraday = upsertIntraday( + next.intraday, + data.point.price, + data.point.avgPrice, + data.point.time, + data.point.totalVolume, + ) + } + + return next +} diff --git a/src/api/baidu/quotation.ts b/src/api/baidu/quotation.ts index 31b8e6c..3626e7e 100644 --- a/src/api/baidu/quotation.ts +++ b/src/api/baidu/quotation.ts @@ -19,7 +19,7 @@ export interface GetStockKlineParams { /** * 百度财经 — 期货盘口 / 分时 / 行情快照 - * 仅页面打开时拉取一次;实时更新后续对接 WebSocket。 + * 页面打开时拉取全量;实时增量由 BaiduQuoteWs(tick / snapshot)负责。 */ export async function getStockQuotation( params: GetStockQuotationParams, diff --git a/src/api/baidu/ws.ts b/src/api/baidu/ws.ts new file mode 100644 index 0000000..dec11c5 --- /dev/null +++ b/src/api/baidu/ws.ts @@ -0,0 +1,167 @@ +import { contractConfig } from '../../config/contract' +import type { BaiduWsItem, BaiduWsMessage, BaiduWsOutbound } from './wsTypes' + +const WS_URL = 'wss://finance-ws.pae.baidu.com/' +const PING_MS = 6_000 +const PATCH_MS = 60_000 +const RECONNECT_BASE_MS = 1_000 +const RECONNECT_MAX_MS = 30_000 + +export type BaiduQuoteWsHandlers = { + onMessage: (msg: BaiduWsMessage) => void + onError?: (err: unknown) => void +} + +function buildItem(): BaiduWsItem { + return { + code: contractConfig.code, + name: contractConfig.name, + market: 'ab', + financeType: 'futures', + } +} + +/** + * Baidu finance quote WebSocket client. + * Handles subscribe (tick + snapshot), ping every 6s, patch every 60s, and reconnect. + */ +export class BaiduQuoteWs { + private ws: WebSocket | null = null + private pingTimer: ReturnType | null = null + private patchTimer: ReturnType | null = null + private reconnectTimer: ReturnType | null = null + private reconnectAttempt = 0 + private intentionalClose = false + private handlers: BaiduQuoteWsHandlers + + constructor(handlers: BaiduQuoteWsHandlers) { + this.handlers = handlers + } + + connect(): void { + this.intentionalClose = false + this.clearReconnect() + if ( + this.ws && + (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) + ) { + return + } + this.openSocket() + } + + disconnect(): void { + this.intentionalClose = true + this.clearTimers() + this.clearReconnect() + if (this.ws) { + this.ws.onopen = null + this.ws.onmessage = null + this.ws.onerror = null + this.ws.onclose = null + try { + this.ws.close() + } catch { + /* ignore */ + } + this.ws = null + } + } + + private openSocket(): void { + if (this.intentionalClose) return + + const ws = new WebSocket(WS_URL) + this.ws = ws + + ws.onopen = () => { + if (this.intentionalClose) { + try { + ws.close() + } catch { + /* ignore */ + } + return + } + this.reconnectAttempt = 0 + this.sendSubscribe() + this.startTimers() + } + + ws.onmessage = (ev) => { + try { + const msg = JSON.parse(String(ev.data)) as BaiduWsMessage + this.handlers.onMessage(msg) + } catch (e) { + console.error('[baidu-ws] parse failed', e) + this.handlers.onError?.(e) + } + } + + ws.onerror = (ev) => { + console.error('[baidu-ws] error', ev) + this.handlers.onError?.(ev) + } + + ws.onclose = () => { + this.clearTimers() + this.ws = null + if (!this.intentionalClose) this.scheduleReconnect() + } + } + + private send(payload: BaiduWsOutbound): void { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return + this.ws.send(JSON.stringify(payload)) + } + + private sendSubscribe(): void { + const item = buildItem() + this.send({ method: 'subscribe', source: 'pc-web', product: 'tick', items: [item] }) + this.send({ method: 'subscribe', source: 'pc-web', product: 'snapshot', items: [item] }) + } + + private sendPing(): void { + this.send({ method: 'ping', source: 'pc-web' }) + } + + private sendPatch(): void { + const item = buildItem() + this.send({ method: 'patch', source: 'pc-web', product: 'snapshot', items: [item] }) + } + + private startTimers(): void { + this.clearTimers() + this.pingTimer = setInterval(() => this.sendPing(), PING_MS) + this.patchTimer = setInterval(() => this.sendPatch(), PATCH_MS) + } + + private clearTimers(): void { + if (this.pingTimer) { + clearInterval(this.pingTimer) + this.pingTimer = null + } + if (this.patchTimer) { + clearInterval(this.patchTimer) + this.patchTimer = null + } + } + + private scheduleReconnect(): void { + this.clearReconnect() + const delay = Math.min(RECONNECT_BASE_MS * 2 ** this.reconnectAttempt, RECONNECT_MAX_MS) + this.reconnectAttempt += 1 + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null + if (this.intentionalClose) return + this.openSocket() + }, delay) + } + + private clearReconnect(): void { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + } +} diff --git a/src/api/baidu/wsTypes.ts b/src/api/baidu/wsTypes.ts new file mode 100644 index 0000000..742bf7b --- /dev/null +++ b/src/api/baidu/wsTypes.ts @@ -0,0 +1,96 @@ +/** Baidu finance WebSocket subscribe / patch item */ +export interface BaiduWsItem { + code: string + name: string + market: 'ab' + financeType: 'futures' +} + +export interface BaiduWsOutbound { + method: 'subscribe' | 'patch' | 'ping' + source: 'pc-web' + product?: 'tick' | 'snapshot' + items?: BaiduWsItem[] +} + +export interface BaiduWsDetailInfo { + time: string + volume: string + price: string + type: string + formatTime: string + bsFlag: string +} + +export interface BaiduWsTickData { + financeType: string + code: string + market: string + product: 'tick' + detailinfos: BaiduWsDetailInfo[] + updatetime?: string +} + +export interface BaiduWsPankouItem { + ename: string + name: string + value: string + originValue?: number + status?: string +} + +export interface BaiduWsCur { + avgPrice?: string + ratio?: string + increase?: string + price?: string + status?: string + unit?: string +} + +export interface BaiduWsPoint { + price: string + avgPrice: string + range?: string + ratio?: string + totalVolume: string + totalAmount?: string + time: string + timestamp?: string + realTimeStampMs?: string +} + +export interface BaiduWsAskInfo { + askprice: string + askvolume: string +} + +export interface BaiduWsBuyInfo { + bidprice: string + bidvolume: string +} + +export interface BaiduWsSnapshotData { + financeType: string + code: string + market: string + product: 'snapshot' + method?: string + cur?: BaiduWsCur + pankouinfos?: BaiduWsPankouItem[] + update?: { + timezone?: string + text?: string + stockStatus?: string + tradeStatusCN?: string + } + point?: BaiduWsPoint + askinfos?: BaiduWsAskInfo[] + buyinfos?: BaiduWsBuyInfo[] +} + +export interface BaiduWsMessage { + queryId?: string + resultCode: string + data?: BaiduWsTickData | BaiduWsSnapshotData +} diff --git a/src/composables/useQuote.ts b/src/composables/useQuote.ts index 936f941..1e91b20 100644 --- a/src/composables/useQuote.ts +++ b/src/composables/useQuote.ts @@ -1,12 +1,13 @@ import { storeToRefs } from 'pinia' -import { onMounted } from 'vue' +import { onMounted, onUnmounted } from 'vue' import { useQuotaStore, type KlinePeriod } from '../stores/quota' export type { KlinePeriod } /** * 行情 composable:状态统一落在 quota store,便于 AI 分析读取。 - * 页面打开时只拉分时/盘口;K 线点击 Tab 再拉。 + * 页面打开时拉分时/盘口并建立 WebSocket;卸载时断开。 + * K 线点击 Tab 再拉。 */ export function useQuote() { const store = useQuotaStore() @@ -14,9 +15,14 @@ export function useQuote() { storeToRefs(store) onMounted(() => { + store.enableWs() void store.fetchQuote() }) + onUnmounted(() => { + store.disconnectWs() + }) + return { data, loading, diff --git a/src/stores/quota.ts b/src/stores/quota.ts index 978924e..df34b21 100644 --- a/src/stores/quota.ts +++ b/src/stores/quota.ts @@ -7,7 +7,14 @@ import { getFuturesNews } from '../api/baidu/news' import { mapBaiduQuotationToQuote } from '../api/baidu/mapQuote' import { mapBaiduKlineToCandles } from '../api/baidu/mapKline' import { mapBaiduNewsToItems } from '../api/baidu/mapNews' +import { applyWsSnapshot, applyWsTick } from '../api/baidu/mapWsQuote' +import { BaiduQuoteWs } from '../api/baidu/ws' import type { BaiduKlineType } from '../api/baidu/types' +import type { + BaiduWsMessage, + BaiduWsSnapshotData, + BaiduWsTickData, +} from '../api/baidu/wsTypes' import { getDealPosition, getPositionProfitRank } from '../api/jqka/position' import { mapJqkaDealPosition, mapJqkaPositionProfit } from '../api/jqka/mapPosition' import { @@ -100,6 +107,50 @@ export const useQuotaStore = defineStore('quota', () => { month: false, }) + /** Module-level WS client; not reactive state */ + let quoteWs: BaiduQuoteWs | null = null + /** When false, fetchQuote must not open WS (page unmounted). */ + let wsDesired = false + + function handleWsMessage(msg: BaiduWsMessage) { + if (msg.resultCode !== '0' || !msg.data) { + if (msg.resultCode && msg.resultCode !== '0') { + console.warn('[quota] ws resultCode', msg.resultCode) + } + return + } + if (msg.data.code && msg.data.code !== contractConfig.code) return + const product = msg.data.product + if (product === 'tick') { + quote.value = applyWsTick(quote.value, msg.data as BaiduWsTickData) + } else if (product === 'snapshot') { + quote.value = applyWsSnapshot(quote.value, msg.data as BaiduWsSnapshotData) + } + } + + /** Allow WS after HTTP success (call on page mount before fetchQuote). */ + function enableWs() { + wsDesired = true + } + + /** (Re)connect only while WS is still desired. */ + function connectWs() { + if (!wsDesired) return + quoteWs?.disconnect() + quoteWs = new BaiduQuoteWs({ + onMessage: handleWsMessage, + onError: (e) => console.error('[quota] ws error', e), + }) + quoteWs.connect() + } + + /** Stop WS and clear desire so in-flight fetchQuote cannot reopen it. */ + function disconnectWs() { + wsDesired = false + quoteWs?.disconnect() + quoteWs = null + } + // ─── 新闻 ─────────────────────────────────────────────── const news = ref([]) const newsLoading = ref(false) @@ -132,6 +183,8 @@ export const useQuotaStore = defineStore('quota', () => { mapped.candles = emptyCandles() loadedKlines.value = { day: false, week: false, month: false } quote.value = mapped + // Only (re)connect when page still wants live quotes + if (wsDesired) connectWs() } catch (e) { quoteError.value = e console.error('[quota] 拉取行情失败', e) @@ -299,5 +352,8 @@ export const useQuotaStore = defineStore('quota', () => { fetchAll, fetchForAnalysis, getAnalysisSnapshot, + enableWs, + connectWs, + disconnectWs, } })