16 KiB
百度行情 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: 创建类型文件
/** 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
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 合并函数
关键逻辑:
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<string>()
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
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
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<typeof setInterval> | null = null
private patchTimer: ReturnType<typeof setInterval> | null = null
private reconnectTimer: ReturnType<typeof setTimeout> | 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
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:
import { BaiduQuoteWs } from '../api/baidu/ws'
import { applyWsSnapshot, applyWsTick } from '../api/baidu/mapWsQuote'
import type { BaiduWsMessage, BaiduWsSnapshotData, BaiduWsTickData } from '../api/baidu/wsTypes'
在 store 内增加:
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 卸载断开
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
npm run typecheck
Expected: 无错误。
- Step 4: Commit
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
npm run dev
- Step 2: 浏览器验证清单
- Network → WS 连上
finance-ws.pae.baidu.com - 发送 subscribe tick + snapshot
- 约 6s 见 ping;约 60s 见 patch
- 成交明细 / 大单分析随 tick 动
- 现价、盘口、分时随 snapshot 动
- 刷新页面后无残留连接(旧 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) |