feat(ws): realtime Baidu quote via tick/snapshot WebSocket
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
d55877e8c7
commit
497236b8b4
624
docs/superpowers/plans/2026-07-22-baidu-quote-ws.md
Normal file
624
docs/superpowers/plans/2026-07-22-baidu-quote-ws.md
Normal 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
167
src/api/baidu/mapWsQuote.ts
Normal 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
|
||||
}
|
||||
@ -19,7 +19,7 @@ export interface GetStockKlineParams {
|
||||
|
||||
/**
|
||||
* 百度财经 — 期货盘口 / 分时 / 行情快照
|
||||
* 仅页面打开时拉取一次;实时更新后续对接 WebSocket。
|
||||
* 页面打开时拉取全量;实时增量由 BaiduQuoteWs(tick / snapshot)负责。
|
||||
*/
|
||||
export async function getStockQuotation(
|
||||
params: GetStockQuotationParams,
|
||||
|
||||
167
src/api/baidu/ws.ts
Normal file
167
src/api/baidu/ws.ts
Normal 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
96
src/api/baidu/wsTypes.ts
Normal 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
|
||||
}
|
||||
@ -1,12 +1,13 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { onMounted } from 'vue'
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { useQuotaStore, type KlinePeriod } from '../stores/quota'
|
||||
|
||||
export type { KlinePeriod }
|
||||
|
||||
/**
|
||||
* 行情 composable:状态统一落在 quota store,便于 AI 分析读取。
|
||||
* 页面打开时只拉分时/盘口;K 线点击 Tab 再拉。
|
||||
* 页面打开时拉分时/盘口并建立 WebSocket;卸载时断开。
|
||||
* K 线点击 Tab 再拉。
|
||||
*/
|
||||
export function useQuote() {
|
||||
const store = useQuotaStore()
|
||||
@ -14,9 +15,14 @@ export function useQuote() {
|
||||
storeToRefs(store)
|
||||
|
||||
onMounted(() => {
|
||||
store.enableWs()
|
||||
void store.fetchQuote()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
store.disconnectWs()
|
||||
})
|
||||
|
||||
return {
|
||||
data,
|
||||
loading,
|
||||
|
||||
@ -7,7 +7,14 @@ import { getFuturesNews } from '../api/baidu/news'
|
||||
import { mapBaiduQuotationToQuote } from '../api/baidu/mapQuote'
|
||||
import { mapBaiduKlineToCandles } from '../api/baidu/mapKline'
|
||||
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 {
|
||||
BaiduWsMessage,
|
||||
BaiduWsSnapshotData,
|
||||
BaiduWsTickData,
|
||||
} from '../api/baidu/wsTypes'
|
||||
import { getDealPosition, getPositionProfitRank } from '../api/jqka/position'
|
||||
import { mapJqkaDealPosition, mapJqkaPositionProfit } from '../api/jqka/mapPosition'
|
||||
import {
|
||||
@ -100,6 +107,50 @@ export const useQuotaStore = defineStore('quota', () => {
|
||||
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 newsLoading = ref(false)
|
||||
@ -132,6 +183,8 @@ export const useQuotaStore = defineStore('quota', () => {
|
||||
mapped.candles = emptyCandles()
|
||||
loadedKlines.value = { day: false, week: false, month: false }
|
||||
quote.value = mapped
|
||||
// Only (re)connect when page still wants live quotes
|
||||
if (wsDesired) connectWs()
|
||||
} catch (e) {
|
||||
quoteError.value = e
|
||||
console.error('[quota] 拉取行情失败', e)
|
||||
@ -299,5 +352,8 @@ export const useQuotaStore = defineStore('quota', () => {
|
||||
fetchAll,
|
||||
fetchForAnalysis,
|
||||
getAnalysisSnapshot,
|
||||
enableWs,
|
||||
connectWs,
|
||||
disconnectWs,
|
||||
}
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user