diff --git a/src/api/deepseek/prompt.ts b/src/api/deepseek/prompt.ts index 94b161b..d654f66 100644 --- a/src/api/deepseek/prompt.ts +++ b/src/api/deepseek/prompt.ts @@ -1,4 +1,16 @@ import type { AnalysisSnapshot } from '../../stores/quota' +import type { + ChangeRiskItem, + MainForceTrendItem, + NewsItem, + PositionAnomalyItem, + PositionRow, + PositionsData, + ProfitRow, + QuoteData, + TradeTick, +} from '../../types' +import { toAnomalySections } from '../jqka/mapAnomaly' import type { AiAnalysisPayload } from './types' const OUTPUT_SCHEMA = `{ @@ -9,9 +21,18 @@ const OUTPUT_SCHEMA = `{ "reasons": ["理由1", "理由2", "理由3", "理由4"] }` +const POSITION_TOP_N = 10 +const NEWS_TOP_N = 20 +const INTRADAY_TAIL = 120 +const CANDLE_DAY_TAIL = 60 +/** 主力全市场列表:当前品种优先后截断 */ +const TRACE_RISK_LIMIT = 12 +const TRACE_TREND_LIMIT = 16 +const ANOMALY_PER_SECTION = 5 + /** * 将行情 / 新闻 / 持仓格式化规则数据拼成 DeepSeek messages。 - * keywords 作为 system 角色设定;snapshot 作为 user 侧事实数据。 + * keywords 作为 system 角色设定;其余按 UI 关键字文案提交,分时/K 线保留 JSON。 */ export function buildAnalysisMessages(payload: AiAnalysisPayload): Array<{ role: 'system' | 'user' @@ -19,44 +40,372 @@ export function buildAnalysisMessages(payload: AiAnalysisPayload): Array<{ }> { const system = [ payload.keywords.trim() || '你是一名专业的国内期货交易员和分析员。', - '请仅基于用户提供的行情、盘口、新闻、机构持仓、主力追踪(变盘预警、主力趋势机会)与主力异动数据给出交易建议。', - '主力追踪与主力异动为全市场数据,请优先关注与当前合约品种相关的条目,并结合整体主力动向综合判断。', - '不要编造未提供的数据;信息不足时偏向观望并说明原因。', + '请仅基于用户提供的行情、盘口、大单分析、新闻、机构持仓、主力追踪(变盘预警、主力趋势机会)与主力异动数据给出交易建议。', + '主力追踪与主力异动为全市场数据,请优先关注标注为「当前品种」的条目,并结合整体主力动向综合判断。', + '文案中的单位与页面一致(手、万手、万、亿、百分比);不要编造未提供的数据;信息不足时偏向观望并说明原因。', '必须只输出一个 JSON 对象,不要 Markdown 代码块,不要其它说明文字。', `JSON 格式:${OUTPUT_SCHEMA}`, ].join('\n') - const user = [ - '以下为当前合约的格式化分析数据(JSON),请据此给出建议:', - JSON.stringify(slimSnapshot(payload.snapshot), null, 2), - ].join('\n\n') - return [ { role: 'system', content: system }, - { role: 'user', content: user }, + { role: 'user', content: buildAnalysisUserContent(payload) }, ] } -/** - * 压缩超长数组,保留分析关键字段,避免 prompt 过大。 - * 盘口、统计、持仓、新闻、主力追踪、主力异动完整保留;分时/成交/K 线做尾部截断。 - */ -function slimSnapshot(snapshot: AnalysisSnapshot): AnalysisSnapshot { - const quote = snapshot.quote - if (!quote) return snapshot +/** 供调试:与提交给模型的 user 内容一致 */ +export function buildAnalysisUserContent(payload: AiAnalysisPayload): string { + const { snapshot, largeOrderLots, variety } = payload + const sections = [ + formatContract(snapshot), + formatQuoteStats(snapshot.quote), + formatOrderBook(snapshot.quote), + formatLargeOrder(snapshot.quote?.trades ?? [], largeOrderLots), + formatIntradayJson(snapshot.quote), + formatCandlesJson(snapshot.quote), + formatPositions(snapshot.positions), + formatMainForceTrace(snapshot, variety), + formatPositionAnomaly(snapshot, variety), + formatNews(snapshot.news), + ].filter(Boolean) - return { - ...snapshot, - quote: { - ...quote, - intraday: quote.intraday.slice(-120), - trades: quote.trades.slice(-80), - candles: { - day: quote.candles.day.slice(-60), - week: quote.candles.week.slice(-40), - month: quote.candles.month.slice(-36), - }, - }, - news: snapshot.news.slice(0, 30), - } + return [ + `抓取时间:${snapshot.fetchedAt || '—'}`, + '以下为当前合约的分析数据(与页面展示一致的关键字文案;分时/K 线为 JSON),请据此给出建议:', + ...sections, + ].join('\n\n') +} + +function formatContract(snapshot: AnalysisSnapshot): string { + const c = snapshot.contract + const q = snapshot.quote + const lines = [ + '【合约】', + `名称:${q?.name || c.name || '—'}`, + `代码:${q?.code || c.code || '—'}`, + `交易所:${q?.exchange || c.exchange || '—'}`, + ] + if (q) { + lines.push( + `最新价:${fmtPrice(q.last)}`, + `涨跌:${fmtSigned(q.change)}(${fmtSigned(q.changePercent)}%)`, + `状态:${q.status || '—'}`, + `更新:${q.updatedAt || '—'}`, + ) + } + return lines.join('\n') +} + +function formatQuoteStats(quote: QuoteData | null): string { + if (!quote) return '【行情统计】\n暂无数据' + return [ + '【行情统计】', + `开盘:${fmtPrice(quote.open)}`, + `最高:${fmtPrice(quote.high)}`, + `最低:${fmtPrice(quote.low)}`, + `昨收:${fmtPrice(quote.prevClose)}`, + `结算:${fmtPrice(quote.settlement)}`, + `昨结:${fmtPrice(quote.prevSettlement)}`, + `均价:${fmtPrice(quote.avg)}`, + `涨跌:${fmtSigned(quote.change)}`, + `持仓量:${fmtWan(quote.openInterest)}万`, + `成交量:${fmtWan(quote.volume)}万手`, + `成交额:${quote.amount}亿`, + `振幅:${quote.amplitude.toFixed(2)}%`, + `外盘:${fmtWan(quote.outerVol)}万`, + `内盘:${fmtWan(quote.innerVol)}万`, + `日增:${formatWanSigned(quote.amountDelta)}`, + ].join('\n') +} + +function formatOrderBook(quote: QuoteData | null): string { + if (!quote) return '【盘口】\n暂无数据' + const lines = [ + '【盘口】', + `买盘占比:${quote.buyRatio}%`, + `卖盘占比:${quote.sellRatio}%`, + ] + for (const ask of quote.orderBook.asks) { + lines.push(`卖${ask.level}:价格 ${fmtPrice(ask.price)},量 ${ask.volume}`) + } + for (const bid of quote.orderBook.bids) { + lines.push(`买${bid.level}:价格 ${fmtPrice(bid.price)},量 ${bid.volume}`) + } + return lines.join('\n') +} + +function formatLargeOrder(trades: TradeTick[], lots: number): string { + const threshold = Math.max(0, Number(lots) || 0) + let largeBuy = 0 + let largeSell = 0 + let retailBuy = 0 + let retailSell = 0 + + for (const t of trades) { + const isLarge = t.volume > threshold + if (t.side === 'B') { + if (isLarge) largeBuy += t.volume + else retailBuy += t.volume + } else if (isLarge) largeSell += t.volume + else retailSell += t.volume + } + + const total = largeBuy + largeSell + retailBuy + retailSell + const pct = (v: number) => (total > 0 ? ((v / total) * 100).toFixed(0) : '0') + + return [ + '【大单分析】(分时成交汇总,非逐笔)', + `大单手数阈值:现手大于 ${threshold} 手计为大单`, + `大单主买:${largeBuy}手(${pct(largeBuy)}%)`, + `散单主买:${retailBuy}手(${pct(retailBuy)}%)`, + `大单主卖:${largeSell}手(${pct(largeSell)}%)`, + `散单主卖:${retailSell}手(${pct(retailSell)}%)`, + `合计:${total}手`, + ].join('\n') +} + +function formatIntradayJson(quote: QuoteData | null): string { + if (!quote) return '【分时图 JSON】\n[]' + const points = quote.intraday.slice(-INTRADAY_TAIL) + return ['【分时图 JSON】', JSON.stringify(points)].join('\n') +} + +function formatCandlesJson(quote: QuoteData | null): string { + const day = quote?.candles.day.slice(-CANDLE_DAY_TAIL) ?? [] + return ['【日K线 JSON】', JSON.stringify(day)].join('\n') +} + +function formatPositions(data: PositionsData | null): string { + if (!data) return '【机构持仓】\n暂无数据' + const lines = [ + '【机构持仓】', + `数据日期:${data.updatedAt || '—'}`, + '', + `成交量前${POSITION_TOP_N}名:`, + ...formatPositionRows(data.volume, '成交量'), + '', + `净多持仓前${POSITION_TOP_N}名:`, + ...formatPositionRows(data.netLong, '净持仓'), + '', + `净空持仓前${POSITION_TOP_N}名:`, + ...formatPositionRows(data.netShort, '净持仓'), + '', + `盈利机构前${POSITION_TOP_N}名:`, + ...formatProfitRows(data.profitGain), + '', + `亏损机构前${POSITION_TOP_N}名:`, + ...formatProfitRows(data.profitLoss), + ] + return lines.join('\n') +} + +function formatPositionRows(list: PositionRow[], qtyLabel: string): string[] { + const top = list.slice(0, POSITION_TOP_N) + if (!top.length) return ['(无)'] + return top.map( + (row) => + `${row.rank}. ${row.name}|${qtyLabel} ${row.qty}|增减 ${fmtSignedInt(row.change)}`, + ) +} + +function formatProfitRows(list: ProfitRow[]): string[] { + const top = list.slice(0, POSITION_TOP_N) + if (!top.length) return ['(无)'] + return top.map( + (row) => + `${row.rank}. ${row.name}|盈利金额 ${formatProfit(row.profit)}|净持仓 ${row.netQty}|增减 ${fmtSignedInt(row.change)}`, + ) +} + +function formatMainForceTrace(snapshot: AnalysisSnapshot, variety: string): string { + const data = snapshot.mainForceTrace + if (!data) return '【主力追踪】\n暂无数据' + + const riskList = takeVarietyFirst(data.changeRiskList, variety, TRACE_RISK_LIMIT) + const trendList = takeVarietyFirst(data.mainForceTrendList, variety, TRACE_TREND_LIMIT) + + const lines = [ + '【主力追踪】', + `数据日期:${data.date || '—'}`, + '', + '变盘预警:', + ] + + if (!riskList.length) lines.push('(无)') + else { + for (const row of riskList) { + lines.push(formatChangeRiskLine(row, variety)) + } + } + + lines.push('', '主力趋势机会:') + if (!trendList.length) lines.push('(无)') + else { + for (const row of trendList) { + lines.push(formatTrendLine(row, variety)) + } + } + + return lines.join('\n') +} + +function formatChangeRiskLine(row: ChangeRiskItem, variety: string): string { + const tag = isCurrentVariety(row.variety, variety) ? '【当前品种】' : '' + return [ + `${tag}${row.varietyName}(${row.variety})`, + `${row.actionLabel}${row.actionLots}手`, + `价格 ${formatTracePrice(row.linkClosePrice)}`, + `涨跌幅 ${formatSignedPct(row.percent)}`, + `变盘风险值 ${formatPct(row.changeRisk)}`, + ].join('|') +} + +function formatTrendLine(row: MainForceTrendItem, variety: string): string { + const tag = isCurrentVariety(row.variety, variety) ? '【当前品种】' : '' + const side = row.side === 'long' ? '多' : '空' + return [ + `${tag}${row.varietyName}(${row.variety})`, + `机构 ${row.company}`, + `涨跌幅 ${formatSignedPct(row.percent)}`, + `单日${formatPct(row.mood)}流${side}`, + `单日加${side}${Math.abs(row.netChange)}手`, + `流${side}${formatFlowAbs(row.pupilFlow)}`, + `近一周胜率 ${formatPct(row.weekRate)}`, + `近一年盈利 ${formatProfitAbs(row.yearProfit)}`, + ].join('|') +} + +function formatPositionAnomaly(snapshot: AnalysisSnapshot, variety: string): string { + const data = snapshot.positionAnomaly + if (!data) return '【主力异动】\n暂无数据' + + const lines = ['【主力异动】', `数据日期:${data.date || '—'}`] + for (const section of toAnomalySections(data)) { + lines.push('', `${section.title}:`) + const items = takeVarietyFirst(section.items, variety, ANOMALY_PER_SECTION) + if (!items.length) { + lines.push('(无)') + continue + } + items.forEach((row, idx) => { + lines.push(formatAnomalyLine(row, variety, idx + 1)) + }) + } + return lines.join('\n') +} + +function formatAnomalyLine( + row: PositionAnomalyItem, + variety: string, + rank: number, +): string { + const tag = isCurrentVariety(row.variety, variety) ? '【当前品种】' : '' + return [ + `${rank}. ${tag}${row.varietyName}(${row.variety})`, + `主导 ${row.maxCompanyName}`, + `增减 ${fmtSignedInt(row.netPositionChange)}`, + `增幅 ${fmtSigned(row.netPositionChangeRate)}%`, + `净持仓 ${fmtSignedInt(row.netPosition)}`, + ].join('|') +} + +function formatNews(news: NewsItem[]): string { + const top = news.slice(0, NEWS_TOP_N) + if (!top.length) return '【新闻】\n暂无数据' + const lines = ['【新闻】'] + for (const item of top) { + const summary = item.summary?.trim() + lines.push( + summary + ? `[${item.source} ${item.time}] ${item.title}|${summary}` + : `[${item.source} ${item.time}] ${item.title}`, + ) + } + return lines.join('\n') +} + +function takeVarietyFirst( + list: T[], + variety: string, + limit: number, +): T[] { + const key = variety.trim().toUpperCase() + if (!list.length) return [] + if (!key) return list.slice(0, limit) + + const matched: T[] = [] + const rest: T[] = [] + for (const item of list) { + if (item.variety.toUpperCase() === key) matched.push(item) + else rest.push(item) + } + const merged = [...matched, ...rest] + return merged.slice(0, Math.max(limit, matched.length)) +} + +function isCurrentVariety(itemVariety: string, current: string): boolean { + const key = current.trim().toUpperCase() + return Boolean(key) && itemVariety.toUpperCase() === key +} + +function fmtPrice(n: number): string { + return Number.isFinite(n) ? n.toFixed(2) : '—' +} + +function fmtSigned(n: number): string { + if (!Number.isFinite(n)) return '—' + return `${n > 0 ? '+' : ''}${n}` +} + +function fmtSignedInt(n: number): string { + if (!Number.isFinite(n)) return '—' + return `${n > 0 ? '+' : ''}${n}` +} + +function fmtWan(hands: number): string { + return (hands / 10000).toFixed(2) +} + +function formatWanSigned(hands: number): string { + const wan = hands / 10000 + const sign = wan > 0 ? '+' : '' + return `${sign}${wan.toFixed(2)}万手` +} + +function formatProfit(n: number): string { + const sign = n > 0 ? '+' : n < 0 ? '-' : '' + const abs = Math.abs(n) + if (abs >= 1e8) return `${sign}${(abs / 1e8).toFixed(2)}亿` + if (abs >= 1e4) return `${sign}${(abs / 1e4).toFixed(2)}万` + return `${sign}${abs.toFixed(0)}` +} + +function formatPct(n: number): string { + return `${(n * 100).toFixed(1)}%` +} + +function formatSignedPct(n: number): string { + const pct = n * 100 + if (Math.abs(pct) < 0.005) return '0.00%' + const sign = pct > 0 ? '+' : '' + return `${sign}${pct.toFixed(2)}%` +} + +function formatTracePrice(n: number): string { + if (!Number.isFinite(n) || n === 0) return '—' + return n >= 100 ? n.toFixed(0) : n.toFixed(2) +} + +function formatFlowAbs(n: number): string { + const abs = Math.abs(n) + if (abs >= 1e8) return `${(abs / 1e8).toFixed(1)}亿` + if (abs >= 1e4) return `${(abs / 1e4).toFixed(0)}万` + return abs.toFixed(0) +} + +function formatProfitAbs(n: number): string { + const abs = Math.abs(n) + if (abs >= 1e8) return `${(abs / 1e8).toFixed(2)}亿元` + if (abs >= 1e4) return `${(abs / 1e4).toFixed(2)}万元` + return `${abs.toFixed(0)}元` } diff --git a/src/api/deepseek/types.ts b/src/api/deepseek/types.ts index 38dd256..b2f0696 100644 --- a/src/api/deepseek/types.ts +++ b/src/api/deepseek/types.ts @@ -6,6 +6,10 @@ export interface AiAnalysisPayload { keywords: string /** 行情 + 新闻 + 机构持仓 + 主力追踪 + 主力异动等接口快照 */ snapshot: AnalysisSnapshot + /** 大单手数阈值,与 UI 大单分析一致 */ + largeOrderLots: number + /** 品种代码(如 FG),主力数据优先标注当前品种 */ + variety: string } /** DeepSeek Chat Completions(OpenAI 兼容)请求体 */ diff --git a/src/composables/useAiAdvice.ts b/src/composables/useAiAdvice.ts index 3b37344..f2989b1 100644 --- a/src/composables/useAiAdvice.ts +++ b/src/composables/useAiAdvice.ts @@ -2,6 +2,7 @@ import { onUnmounted, ref, watch } from 'vue' import { ElMessage } from 'element-plus' import type { AiAdvice } from '../types' import { fetchDeepSeekAdvice } from '../api/deepseek/analyze' +import { buildAnalysisUserContent } from '../api/deepseek/prompt' import type { AiAnalysisPayload } from '../api/deepseek/types' import { getSharedData, putSharedData } from '../api/uuquant/sharedData' import { adviceSharedKey } from '../api/uuquant/keys' @@ -9,7 +10,11 @@ import type { AdvicePayload } from '../api/uuquant/models' import { useQuotaStore, type KlinePeriod } from '../stores/quota' import { useContractStore } from '../stores/contract' import { useSettingsStore } from '../stores/settings' -import { isTradingDay, isWsTradingSession } from '../utils/tradingDate' +import { + extractVariety, + isTradingDay, + isWsTradingSession, +} from '../utils/tradingDate' import { useTitleBlink } from './useTitleBlink' /** 全局额外关键字 + 当前合约关键字(合约段追加在后) */ @@ -138,26 +143,22 @@ export function useAiAdvice() { /** * 一键获取建议:收集行情 / 新闻 / 持仓后提交 DeepSeek,返回真实 AI 建议。 * @param opts.silent 定时任务时静默:无 Key / 周末 / 非开盘 / 进行中则跳过,成功不弹 toast + * 手动点击会先组装并 console.log 分析关键字(不受周末/开盘限制),再决定是否调用 API。 */ async function refresh(opts?: { silent?: boolean }) { const silent = Boolean(opts?.silent) - // 周末不分析(定时静默跳过;手动点击提示) - if (!isTradingDay()) { - if (!silent) ElMessage.warning('周末休市,暂不进行 AI 分析') - return - } - // 定时分析仅在开盘时段执行(与行情 WS 时段一致) - if (silent && !isWsTradingSession()) return - const apiKey = settings.apiKey.trim() - if (!apiKey) { - if (!silent) ElMessage.warning('请先在设置中配置 DeepSeek API Key') - return + // 定时:周末 / 非开盘 / 无 Key 直接跳过 + if (silent) { + if (!isTradingDay()) return + if (!isWsTradingSession()) return + if (!settings.apiKey.trim()) return } if (loading.value) return const epoch = analysisEpoch - const contractCode = contractStore.current?.code || '' + const contract = contractStore.current + const contractCode = contract?.code || '' loading.value = true error.value = null try { @@ -167,13 +168,30 @@ export function useAiAdvice() { if (epoch !== analysisEpoch) return const payload: AiAnalysisPayload = { - keywords: mergeKeywords(settings.keywords, contractStore.current.keywords), + keywords: mergeKeywords(settings.keywords, contract?.keywords || ''), snapshot: quota.getAnalysisSnapshot(), + largeOrderLots: contract?.largeOrderLots ?? 0, + variety: + (contract?.variety || extractVariety(contractCode)).toUpperCase(), } - // 勿 console.log 完整 snapshot:DevTools 会长期持有大对象导致内存暴涨 - if (import.meta.env.DEV) { - console.log('[AI] 分析', payload.snapshot.contract.code, payload.keywords.slice(0, 80)) + // 手动点击:始终打印关键字(周末/休市也可核对 prompt) + if (!silent) { + const userContent = buildAnalysisUserContent(payload) + console.log('[AI] 角色关键字', payload.keywords) + console.log('[AI] 分析数据关键字', userContent) + } + + // 周末:手动可看关键字,但不调 API + if (!isTradingDay()) { + if (!silent) ElMessage.warning('周末休市,暂不进行 AI 分析(已输出分析关键字到控制台)') + return + } + + const apiKey = settings.apiKey.trim() + if (!apiKey) { + if (!silent) ElMessage.warning('请先在设置中配置 DeepSeek API Key') + return } const advice = await fetchDeepSeekAdvice(apiKey, payload)