ai_trade_assistance/docs/superpowers/plans/2026-07-22-baidu-quote-ws.md
dongzp 497236b8b4 feat(ws): realtime Baidu quote via tick/snapshot WebSocket
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 09:56:39 +08:00

16 KiB
Raw Permalink Blame History

百度行情 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: 浏览器验证清单
  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)