625 lines
16 KiB
Markdown
625 lines
16 KiB
Markdown
# 百度行情 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) |
|