Compare commits
No commits in common. "497236b8b4fa35260bccd4e3d4aca51a61439717" and "7366bf66fa206a136994890a3d5678b4f1c83ad3" have entirely different histories.
497236b8b4
...
7366bf66fa
@ -1,624 +0,0 @@
|
|||||||
# 百度行情 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) |
|
|
||||||
@ -1,146 +0,0 @@
|
|||||||
# 百度财经 WebSocket 行情实时刷新 — 设计
|
|
||||||
|
|
||||||
**日期:** 2026-07-22
|
|
||||||
**状态:** 已口头确认,待书面复核
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 背景与目标
|
|
||||||
|
|
||||||
当前行情通过 HTTP `getStockQuotation` 在页面打开时拉取一次全量(分时、盘口、成交明细)。`quotation.ts` 已预留「实时更新后续对接 WebSocket」。
|
|
||||||
|
|
||||||
本阶段目标:接入百度财经 WebSocket,在 HTTP 全量底之上做增量刷新,使报价、盘口、分时图、分时成交与大单分析实时更新。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 已确认决策
|
|
||||||
|
|
||||||
| 项 | 选择 |
|
|
||||||
|---|---|
|
|
||||||
| 初始全量 | 现有 HTTP `getStockQuotation`,成功后再连 WS |
|
|
||||||
| 数据映射 | `tick` → 成交明细(TradeTape)+ 大单分析;`snapshot` → 报价 / 盘口 / 分时点 |
|
|
||||||
| 架构 | 独立 `BaiduQuoteWs` 客户端 + mapper;状态仍落 `quota` store |
|
|
||||||
| 合约 | 仍读 `contractConfig` 单合约,不做多合约订阅 |
|
|
||||||
| WS 库 | 原生 `WebSocket`,不引入第三方 |
|
|
||||||
| 连接 UI | 本期不做连接指示灯 / 手动重连按钮 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 架构与数据流
|
|
||||||
|
|
||||||
```
|
|
||||||
页面挂载
|
|
||||||
→ quota.fetchQuote() // HTTP 全量
|
|
||||||
→ BaiduQuoteWs.connect() // wss://finance-ws.pae.baidu.com/
|
|
||||||
→ subscribe tick + snapshot
|
|
||||||
→ 每 6s ping
|
|
||||||
→ 每 60s patch snapshot
|
|
||||||
→ onMessage
|
|
||||||
→ product=tick → 合并 trades(最新在前)
|
|
||||||
→ product=snapshot → 更新报价 / 盘口 / upsert intraday
|
|
||||||
页面卸载 / 主动 refresh
|
|
||||||
→ BaiduQuoteWs.disconnect()(refresh 成功后再 connect)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 模块划分
|
|
||||||
|
|
||||||
| 文件 | 职责 |
|
|
||||||
|------|------|
|
|
||||||
| `src/api/baidu/ws.ts` | 连接、心跳、订阅、重连、回调 |
|
|
||||||
| `src/api/baidu/wsTypes.ts` | tick / snapshot 消息 TypeScript 类型 |
|
|
||||||
| `src/api/baidu/mapWsQuote.ts` | 增量消息映射并合并进 `QuoteData` |
|
|
||||||
| `src/stores/quota.ts` | HTTP 成功后启 WS;应用增量;卸载断开 |
|
|
||||||
| `src/composables/useQuote.ts` | `onMounted` 拉行情;`onUnmounted` 断开 WS |
|
|
||||||
|
|
||||||
大单分析已基于 `quote.trades` 计算,只需更新 `trades`,不改组件。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 协议细节
|
|
||||||
|
|
||||||
**端点:** `wss://finance-ws.pae.baidu.com/`
|
|
||||||
|
|
||||||
**订阅(open 后立即发送两条):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"method":"subscribe","source":"pc-web","product":"tick","items":[{"code":"FG609","name":"玻璃2609","market":"ab","financeType":"futures"}]}
|
|
||||||
{"method":"subscribe","source":"pc-web","product":"snapshot","items":[{"code":"FG609","name":"玻璃2609","market":"ab","financeType":"futures"}]}
|
|
||||||
```
|
|
||||||
|
|
||||||
`code` / `name` 来自 `contractConfig`;`market` 固定 `ab`;`financeType` 固定 `futures`。
|
|
||||||
|
|
||||||
**保活:**
|
|
||||||
- 每 6s:`{"method":"ping","source":"pc-web"}`
|
|
||||||
- 每 60s:snapshot 的 `patch`(items 同订阅)
|
|
||||||
|
|
||||||
**重连:** 断线后指数退避(1s → 2s → 4s … 上限 30s);重连成功后重新 subscribe;不自动重拉 HTTP。
|
|
||||||
|
|
||||||
**disconnect:** 清 ping/patch 定时器、关闭 socket、停止重连调度。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 合并规则
|
|
||||||
|
|
||||||
### 5.1 tick → trades
|
|
||||||
|
|
||||||
- 解析 `data.detailinfos` → `TradeTick[]`(字段映射与现有 HTTP `parseTrades` 一致:`formatTime` / `price` / `volume` / `bsFlag`;最新在前)
|
|
||||||
- 前置合并到 `quote.trades`
|
|
||||||
- 去重键:`time + price + volume + side`
|
|
||||||
- 上限:最近 **200** 条
|
|
||||||
|
|
||||||
### 5.2 snapshot → 报价 / 盘口 / 分时
|
|
||||||
|
|
||||||
- `cur`:更新 `last` / `change` / `changePercent` / `avg`;状态可从 `cur.status` 或 `update` 取
|
|
||||||
- `pankouinfos`(数组,按 `ename`):更新 open / high / low / volume / amount / openInterest / amplitude / settlement / prevSettlement / outerVol / innerVol 等
|
|
||||||
- `askinfos` / `buyinfos`:刷新五档;过滤无效价;重算 `buyRatio` / `sellRatio`
|
|
||||||
- `update`:刷新 `updatedAt` / `status`
|
|
||||||
- `point`:按分钟时间(从 `point.time` 取 `HH:mm`)upsert 到 `intraday`
|
|
||||||
- 覆盖 / 写入:`price` ← `point.price`,`avg` ← `point.avgPrice`
|
|
||||||
- `volume`:WS 只给 `totalVolume`(全日累计)。分钟成交量 = `max(0, totalVolume - 此前各分钟 volume 之和)`;同分钟再次推送时用同一公式重算并覆盖
|
|
||||||
- 新分钟:append;同分钟:覆盖该点
|
|
||||||
|
|
||||||
### 5.3 错误处理
|
|
||||||
|
|
||||||
- `resultCode !== "0"`:打日志,忽略本条
|
|
||||||
- JSON / 字段解析失败:打日志,不中断连接
|
|
||||||
- 组件层无感;不暴露 WS 连接状态到 UI
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 生命周期
|
|
||||||
|
|
||||||
1. `useQuote` `onMounted` → `store.fetchQuote()`
|
|
||||||
2. `fetchQuote` 成功 → `store.connectWs()`(若已连接则先 disconnect 再 connect)
|
|
||||||
3. `useQuote` `onUnmounted` → `store.disconnectWs()`
|
|
||||||
4. `refresh()` / `fetchForAnalysis()`:仍走 HTTP 全量;成功后重连 WS,用最新全量覆盖后再接增量
|
|
||||||
5. K 线逻辑不变;WS 不修改 `candles`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 明确不做(本期)
|
|
||||||
|
|
||||||
- 连接状态 UI / 手动重连按钮
|
|
||||||
- 多合约同时订阅
|
|
||||||
- 修改 TradeTape / LargeOrderAnalysis / ChartPanel(仅消费响应式 `quote`)
|
|
||||||
- 引入第三方 WS 库
|
|
||||||
- WS 失败时回退 HTTP 轮询
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 验证要点
|
|
||||||
|
|
||||||
- HTTP 成功后会发送 tick + snapshot 两条 subscribe
|
|
||||||
- 约每 6s 有 ping,约每 60s 有 snapshot patch
|
|
||||||
- tick 合并 trades:去重生效、长度 ≤ 200;TradeTape / 大单分析随之刷新
|
|
||||||
- snapshot.point:同分钟覆盖、新分钟追加;分时图更新
|
|
||||||
- snapshot 盘口 / 现价字段正确刷新
|
|
||||||
- 页面卸载后无残留 timer / 未关闭的 socket
|
|
||||||
- 手动 refresh 后 WS 会断开并重新订阅
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 风险与注意
|
|
||||||
|
|
||||||
- WS 与 HTTP 的 snapshot 字段结构不完全一致(HTTP 用 `origin_pankou`,WS 用 `pankouinfos` 数组);mapper 需独立,勿强行复用 HTTP mapper 全文
|
|
||||||
- 去重键不含服务端唯一 id(协议未提供);极端情况下同秒同价同量同向两笔可能被误去重,可接受
|
|
||||||
- 跨域 / 鉴权:浏览器直连百度 WS;若环境拦截再评估代理,本期先直连
|
|
||||||
@ -1,167 +0,0 @@
|
|||||||
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 {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 百度财经 — 期货盘口 / 分时 / 行情快照
|
* 百度财经 — 期货盘口 / 分时 / 行情快照
|
||||||
* 页面打开时拉取全量;实时增量由 BaiduQuoteWs(tick / snapshot)负责。
|
* 仅页面打开时拉取一次;实时更新后续对接 WebSocket。
|
||||||
*/
|
*/
|
||||||
export async function getStockQuotation(
|
export async function getStockQuotation(
|
||||||
params: GetStockQuotationParams,
|
params: GetStockQuotationParams,
|
||||||
|
|||||||
@ -1,167 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,96 +0,0 @@
|
|||||||
/** 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,13 +1,12 @@
|
|||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { onMounted, onUnmounted } from 'vue'
|
import { onMounted } 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 分析读取。
|
||||||
* 页面打开时拉分时/盘口并建立 WebSocket;卸载时断开。
|
* 页面打开时只拉分时/盘口;K 线点击 Tab 再拉。
|
||||||
* K 线点击 Tab 再拉。
|
|
||||||
*/
|
*/
|
||||||
export function useQuote() {
|
export function useQuote() {
|
||||||
const store = useQuotaStore()
|
const store = useQuotaStore()
|
||||||
@ -15,14 +14,9 @@ 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,
|
||||||
|
|||||||
@ -7,14 +7,7 @@ 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 {
|
||||||
@ -107,50 +100,6 @@ 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)
|
||||||
@ -183,8 +132,6 @@ 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)
|
||||||
@ -352,8 +299,5 @@ export const useQuotaStore = defineStore('quota', () => {
|
|||||||
fetchAll,
|
fetchAll,
|
||||||
fetchForAnalysis,
|
fetchForAnalysis,
|
||||||
getAnalysisSnapshot,
|
getAnalysisSnapshot,
|
||||||
enableWs,
|
|
||||||
connectWs,
|
|
||||||
disconnectWs,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user