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 = { 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, 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(emptyQuote()) const quoteLoading = ref(false) const klineLoading = ref(false) const quoteError = ref(null) const loadedKlines = ref>({ 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 | null = null const wsStatus = ref('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([]) const newsLoading = ref(false) const newsError = ref(null) // ─── 机构持仓 ─────────────────────────────────────────── const positions = ref(emptyPositions()) const positionsLoading = ref(false) const positionsError = ref(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 { 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[] = [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(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, } })