2026-07-22 11:19:10 +08:00

411 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { defineStore } from 'pinia'
import { computed, ref, toRaw } from 'vue'
import type { Candle, NewsItem, PositionsData, QuoteData } from '../types'
import { contractConfig } from '../config/contract'
import { getStockKline, getStockQuotation } from '../api/baidu/quotation'
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 {
extractVariety,
getPositionQueryDate,
getPreviousTradingDate,
isWsTradingSession,
} from '../utils/tradingDate'
import type { WsConnectionStatus } from '../api/baidu/ws'
/** K 线周期(日/周/月) */
export type KlinePeriod = 'day' | 'week' | 'month'
const KTYPE_MAP: Record<KlinePeriod, BaiduKlineType> = {
day: 1,
week: 2,
month: 3,
}
/** 供 AI 分析消费的数据快照(可序列化) */
export interface AnalysisSnapshot {
contract: {
code: string
name: string
exchange: string
}
quote: QuoteData | null
news: NewsItem[]
positions: PositionsData | null
fetchedAt: string
}
function emptyCandles(): QuoteData['candles'] {
return { day: [], week: [], month: [] }
}
function emptyQuote(): QuoteData {
return {
name: contractConfig.name,
code: contractConfig.code,
exchange: contractConfig.exchange,
status: '',
last: 0,
change: 0,
changePercent: 0,
open: 0,
high: 0,
low: 0,
prevClose: 0,
settlement: 0,
prevSettlement: 0,
avg: 0,
volume: 0,
amount: 0,
openInterest: 0,
amountDelta: 0,
amplitude: 0,
outerVol: 0,
innerVol: 0,
updatedAt: '',
buyRatio: 50,
sellRatio: 50,
intraday: [],
candles: emptyCandles(),
orderBook: { asks: [], bids: [] },
trades: [],
}
}
function emptyPositions(): PositionsData {
return {
long: [],
short: [],
volume: [],
netLong: [],
netShort: [],
profitGain: [],
profitLoss: [],
topTwentySum: [],
updatedAt: '',
}
}
export const useQuotaStore = defineStore('quota', () => {
// ─── 行情 ───────────────────────────────────────────────
const quote = ref<QuoteData>(emptyQuote())
const quoteLoading = ref(false)
const klineLoading = ref(false)
const quoteError = ref<unknown>(null)
const loadedKlines = ref<Record<KlinePeriod, boolean>>({
day: false,
week: 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
/** 30s session gate: connect in trading hours, disconnect outside. */
let sessionTimer: ReturnType<typeof setInterval> | null = null
const wsStatus = ref<WsConnectionStatus>('disconnected')
const wsInSession = ref(false)
const SESSION_CHECK_MS = 30_000
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)
}
}
function stopSessionWatch() {
if (sessionTimer) {
clearInterval(sessionTimer)
sessionTimer = null
}
}
/**
* Align WS with trading session:
* - in session + not running → connect
* - out of session + running → disconnect (keep wsDesired)
*/
function syncWsWithSession() {
if (!wsDesired) return
const inSession = isWsTradingSession()
wsInSession.value = inSession
if (inSession) {
if (!quoteWs?.isRunning()) connectWs()
} else if (quoteWs?.isRunning()) {
quoteWs.disconnect()
quoteWs = null
wsStatus.value = 'disconnected'
}
}
function startSessionWatch() {
stopSessionWatch()
syncWsWithSession()
sessionTimer = setInterval(syncWsWithSession, SESSION_CHECK_MS)
}
/** Allow WS after HTTP success (call on page mount before fetchQuote). */
function enableWs() {
wsDesired = true
startSessionWatch()
}
/** (Re)connect only while page wants WS and within trading session. */
function connectWs() {
if (!wsDesired || !isWsTradingSession()) return
wsInSession.value = true
quoteWs?.disconnect()
quoteWs = new BaiduQuoteWs({
onMessage: handleWsMessage,
onError: (e) => console.error('[quota] ws error', e),
onStatus: (s) => {
wsStatus.value = s
},
})
quoteWs.connect()
}
/** Stop WS and clear desire so in-flight fetchQuote cannot reopen it. */
function disconnectWs() {
wsDesired = false
stopSessionWatch()
quoteWs?.disconnect()
quoteWs = null
wsStatus.value = 'disconnected'
wsInSession.value = false
}
// ─── 新闻 ───────────────────────────────────────────────
const news = ref<NewsItem[]>([])
const newsLoading = ref(false)
const newsError = ref<unknown>(null)
// ─── 机构持仓 ───────────────────────────────────────────
const positions = ref<PositionsData>(emptyPositions())
const positionsLoading = ref(false)
const positionsError = ref<unknown>(null)
/** 是否已有至少一次成功的行情拉取(可用于 AI 门禁) */
const hasQuote = computed(() => quote.value.last > 0 && quote.value.intraday.length > 0)
const hasNews = computed(() => news.value.length > 0)
/** 新闻 / 持仓是否已成功拉取过真实接口(有则 AI 刷新可跳过) */
const newsLoaded = ref(false)
const positionsLoaded = ref(false)
const hasPositions = computed(
() =>
positions.value.long.length > 0 ||
positions.value.short.length > 0 ||
positions.value.volume.length > 0,
)
async function fetchQuote() {
quoteLoading.value = true
quoteError.value = null
try {
const result = await getStockQuotation({ code: contractConfig.code })
const mapped = mapBaiduQuotationToQuote(result)
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)
} finally {
quoteLoading.value = false
}
}
/** 按需加载日/周/月 K 线;已加载则直接返回缓存 */
async function loadKline(period: KlinePeriod): Promise<Candle[]> {
if (loadedKlines.value[period]) {
return quote.value.candles[period]
}
klineLoading.value = true
quoteError.value = null
try {
const result = await getStockKline({
code: contractConfig.code,
ktype: KTYPE_MAP[period],
})
const candles = mapBaiduKlineToCandles(result)
quote.value.candles[period] = candles
loadedKlines.value[period] = true
return candles
} catch (e) {
quoteError.value = e
console.error(`[quota] 拉取${period}K线失败`, e)
throw e
} finally {
klineLoading.value = false
}
}
async function fetchNews() {
newsLoading.value = true
newsError.value = null
try {
const list = await getFuturesNews({ code: contractConfig.code })
news.value = mapBaiduNewsToItems(list)
newsLoaded.value = news.value.length > 0
} catch (e) {
newsError.value = e
console.error('[quota] 拉取新闻失败', e)
} finally {
newsLoading.value = false
}
}
/**
* 拉取会员持仓 + 机构盈利。
* 持仓日期:交易日 16:00 后查当天,否则查上一交易日;
* 若遇节假日空数据则再往前最多试 5 个交易日。
* 盈利默认最近一个月、按当前品种查询。
*/
async function fetchPositions() {
positionsLoading.value = true
positionsError.value = null
try {
const contract = contractConfig.code
const variety = contractConfig.variety || extractVariety(contract)
let date = getPositionQueryDate()
let mapped: PositionsData | null = null
for (let i = 0; i < 5; i++) {
const raw = await getDealPosition({ contract, variety, date })
if (raw.positionList?.length) {
mapped = mapJqkaDealPosition(raw)
break
}
// 空列表:再往前一个交易日(覆盖长假)
const d = new Date(`${date}T12:00:00`)
date = getPreviousTradingDate(d)
}
if (!mapped) {
throw new Error(`持仓无数据(已回溯至 ${date})`)
}
try {
const profitRaw = await getPositionProfitRank({ variety, contract })
const profit = mapJqkaPositionProfit(profitRaw)
mapped.profitGain = profit.profitGain
mapped.profitLoss = profit.profitLoss
} catch (e) {
console.error('[quota] 拉取机构盈利失败', e)
}
positions.value = mapped
positionsLoaded.value = true
} catch (e) {
positionsError.value = e
console.error('[quota] 拉取持仓失败', e)
} finally {
positionsLoading.value = false
}
}
/** 并行刷新行情 + 新闻 + 持仓(K 线仍按需) */
async function fetchAll() {
await Promise.all([fetchQuote(), fetchNews(), fetchPositions()])
}
/**
* AI 分析前取数:行情每次重新拉取;
* 新闻 / 机构持仓若已有成功数据则跳过,避免重复请求。
*/
async function fetchForAnalysis() {
const tasks: Promise<void>[] = [fetchQuote()]
if (!newsLoaded.value) tasks.push(fetchNews())
if (!positionsLoaded.value) tasks.push(fetchPositions())
await Promise.all(tasks)
}
/**
* 深拷贝可序列化数据。Pinia/ref 内是 Proxy,structuredClone 直接克隆会 DataCloneError,
* 需先 toRaw 再 JSON 往返,得到脱离响应式的纯对象。
*/
function clonePlain<T>(value: T): T {
return JSON.parse(JSON.stringify(toRaw(value))) as T
}
/**
* 导出当前全部接口数据快照,供下一步 AI 分析拼接 Prompt。
* 返回深拷贝,避免分析过程中被行情刷新污染。
*/
function getAnalysisSnapshot(): AnalysisSnapshot {
return {
contract: {
code: quote.value.code || contractConfig.code,
name: quote.value.name || contractConfig.name,
exchange: quote.value.exchange || contractConfig.exchange,
},
quote: quote.value ? clonePlain(quote.value) : null,
news: clonePlain(news.value),
positions: positions.value ? clonePlain(positions.value) : null,
fetchedAt: new Date().toISOString(),
}
}
return {
// state
quote,
quoteLoading,
klineLoading,
quoteError,
loadedKlines,
news,
newsLoading,
newsError,
positions,
positionsLoading,
positionsError,
wsStatus,
wsInSession,
// computed
hasQuote,
hasNews,
hasPositions,
newsLoaded,
positionsLoaded,
// actions
fetchQuote,
loadKline,
fetchNews,
fetchPositions,
fetchAll,
fetchForAnalysis,
getAnalysisSnapshot,
enableWs,
connectWs,
disconnectWs,
}
})