# 百度行情 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) |