feat(ws): realtime Baidu quote via tick/snapshot WebSocket

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dongzp 2026-07-22 09:56:39 +08:00
parent d55877e8c7
commit 497236b8b4
7 changed files with 1119 additions and 3 deletions

View File

@ -0,0 +1,624 @@
# 百度行情 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) |

167
src/api/baidu/mapWsQuote.ts Normal file
View File

@ -0,0 +1,167 @@
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}`
}
/**
* Parse display amount such as 「103.32亿」; prefer originValue (raw yuan) when present.
*/
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)
}
/** Extract HH:mm from 「07-15 10:59」 or 「10:59」. */
function hhmmFromPointTime(time: string): string {
if (time.includes(' ')) return time.split(' ')[1]!.slice(0, 5)
return time.slice(0, 5)
}
/**
* Upsert a minute bar. WS only provides cumulative totalVolume;
* minute volume = totalVolume − sum of other minutes' volumes.
*/
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
}
/** 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 (c.status) next.status = c.status
}
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
}

View File

@ -19,7 +19,7 @@ export interface GetStockKlineParams {
/** /**
* 百度财经 — 期货盘口 / 分时 / 行情快照 * 百度财经 — 期货盘口 / 分时 / 行情快照
* 仅页面打开时拉取一次;实时更新后续对接 WebSocket。 * 页面打开时拉取全量;实时增量由 BaiduQuoteWs(tick / snapshot)负责。
*/ */
export async function getStockQuotation( export async function getStockQuotation(
params: GetStockQuotationParams, params: GetStockQuotationParams,

167
src/api/baidu/ws.ts Normal file
View File

@ -0,0 +1,167 @@
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',
}
}
/**
* Baidu finance quote WebSocket client.
* Handles subscribe (tick + snapshot), ping every 6s, patch every 60s, and reconnect.
*/
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 {
if (this.intentionalClose) return
const ws = new WebSocket(WS_URL)
this.ws = ws
ws.onopen = () => {
if (this.intentionalClose) {
try {
ws.close()
} catch {
/* ignore */
}
return
}
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.reconnectTimer = null
if (this.intentionalClose) return
this.openSocket()
}, delay)
}
private clearReconnect(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
}
}

96
src/api/baidu/wsTypes.ts Normal file
View File

@ -0,0 +1,96 @@
/** 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
}

View File

@ -1,12 +1,13 @@
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { onMounted } from 'vue' import { onMounted, onUnmounted } from 'vue'
import { useQuotaStore, type KlinePeriod } from '../stores/quota' import { useQuotaStore, type KlinePeriod } from '../stores/quota'
export type { KlinePeriod } export type { KlinePeriod }
/** /**
* 行情 composable:状态统一落在 quota store,便于 AI 分析读取。 * 行情 composable:状态统一落在 quota store,便于 AI 分析读取。
* 页面打开时只拉分时/盘口;K 线点击 Tab 再拉。 * 页面打开时拉分时/盘口并建立 WebSocket;卸载时断开。
* K 线点击 Tab 再拉。
*/ */
export function useQuote() { export function useQuote() {
const store = useQuotaStore() const store = useQuotaStore()
@ -14,9 +15,14 @@ export function useQuote() {
storeToRefs(store) storeToRefs(store)
onMounted(() => { onMounted(() => {
store.enableWs()
void store.fetchQuote() void store.fetchQuote()
}) })
onUnmounted(() => {
store.disconnectWs()
})
return { return {
data, data,
loading, loading,

View File

@ -7,7 +7,14 @@ import { getFuturesNews } from '../api/baidu/news'
import { mapBaiduQuotationToQuote } from '../api/baidu/mapQuote' import { mapBaiduQuotationToQuote } from '../api/baidu/mapQuote'
import { mapBaiduKlineToCandles } from '../api/baidu/mapKline' import { mapBaiduKlineToCandles } from '../api/baidu/mapKline'
import { mapBaiduNewsToItems } from '../api/baidu/mapNews' import { mapBaiduNewsToItems } from '../api/baidu/mapNews'
import { applyWsSnapshot, applyWsTick } from '../api/baidu/mapWsQuote'
import { BaiduQuoteWs } from '../api/baidu/ws'
import type { BaiduKlineType } from '../api/baidu/types' import type { BaiduKlineType } from '../api/baidu/types'
import type {
BaiduWsMessage,
BaiduWsSnapshotData,
BaiduWsTickData,
} from '../api/baidu/wsTypes'
import { getDealPosition, getPositionProfitRank } from '../api/jqka/position' import { getDealPosition, getPositionProfitRank } from '../api/jqka/position'
import { mapJqkaDealPosition, mapJqkaPositionProfit } from '../api/jqka/mapPosition' import { mapJqkaDealPosition, mapJqkaPositionProfit } from '../api/jqka/mapPosition'
import { import {
@ -100,6 +107,50 @@ export const useQuotaStore = defineStore('quota', () => {
month: false, month: false,
}) })
/** Module-level WS client; not reactive state */
let quoteWs: BaiduQuoteWs | null = null
/** When false, fetchQuote must not open WS (page unmounted). */
let wsDesired = false
function handleWsMessage(msg: BaiduWsMessage) {
if (msg.resultCode !== '0' || !msg.data) {
if (msg.resultCode && msg.resultCode !== '0') {
console.warn('[quota] ws resultCode', msg.resultCode)
}
return
}
if (msg.data.code && msg.data.code !== contractConfig.code) 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)
}
}
/** Allow WS after HTTP success (call on page mount before fetchQuote). */
function enableWs() {
wsDesired = true
}
/** (Re)connect only while WS is still desired. */
function connectWs() {
if (!wsDesired) return
quoteWs?.disconnect()
quoteWs = new BaiduQuoteWs({
onMessage: handleWsMessage,
onError: (e) => console.error('[quota] ws error', e),
})
quoteWs.connect()
}
/** Stop WS and clear desire so in-flight fetchQuote cannot reopen it. */
function disconnectWs() {
wsDesired = false
quoteWs?.disconnect()
quoteWs = null
}
// ─── 新闻 ─────────────────────────────────────────────── // ─── 新闻 ───────────────────────────────────────────────
const news = ref<NewsItem[]>([]) const news = ref<NewsItem[]>([])
const newsLoading = ref(false) const newsLoading = ref(false)
@ -132,6 +183,8 @@ export const useQuotaStore = defineStore('quota', () => {
mapped.candles = emptyCandles() mapped.candles = emptyCandles()
loadedKlines.value = { day: false, week: false, month: false } loadedKlines.value = { day: false, week: false, month: false }
quote.value = mapped quote.value = mapped
// Only (re)connect when page still wants live quotes
if (wsDesired) connectWs()
} catch (e) { } catch (e) {
quoteError.value = e quoteError.value = e
console.error('[quota] 拉取行情失败', e) console.error('[quota] 拉取行情失败', e)
@ -299,5 +352,8 @@ export const useQuotaStore = defineStore('quota', () => {
fetchAll, fetchAll,
fetchForAnalysis, fetchForAnalysis,
getAnalysisSnapshot, getAnalysisSnapshot,
enableWs,
connectWs,
disconnectWs,
} }
}) })