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

625 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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