411 lines
12 KiB
TypeScript
411 lines
12 KiB
TypeScript
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,
|
||
}
|
||
})
|