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(
|
||||
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 { onMounted, onUnmounted } from 'vue'
|
||||
import { onMounted } from 'vue'
|
||||
import { useQuotaStore, type KlinePeriod } from '../stores/quota'
|
||||
|
||||
export type { KlinePeriod }
|
||||
|
||||
/**
|
||||
* 行情 composable:状态统一落在 quota store,便于 AI 分析读取。
|
||||
* 页面打开时拉分时/盘口并建立 WebSocket;卸载时断开。
|
||||
* K 线点击 Tab 再拉。
|
||||
* 页面打开时只拉分时/盘口;K 线点击 Tab 再拉。
|
||||
*/
|
||||
export function useQuote() {
|
||||
const store = useQuotaStore()
|
||||
@ -15,14 +14,9 @@ export function useQuote() {
|
||||
storeToRefs(store)
|
||||
|
||||
onMounted(() => {
|
||||
store.enableWs()
|
||||
void store.fetchQuote()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
store.disconnectWs()
|
||||
})
|
||||
|
||||
return {
|
||||
data,
|
||||
loading,
|
||||
|
||||
@ -7,14 +7,7 @@ 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 {
|
||||
@ -107,50 +100,6 @@ 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)
|
||||
@ -183,8 +132,6 @@ 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)
|
||||
@ -352,8 +299,5 @@ export const useQuotaStore = defineStore('quota', () => {
|
||||
fetchAll,
|
||||
fetchForAnalysis,
|
||||
getAnalysisSnapshot,
|
||||
enableWs,
|
||||
connectWs,
|
||||
disconnectWs,
|
||||
}
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user