diff --git a/src/api/deepseek/analyze.ts b/src/api/deepseek/analyze.ts new file mode 100644 index 0000000..bdb946f --- /dev/null +++ b/src/api/deepseek/analyze.ts @@ -0,0 +1,102 @@ +import type { AiAdvice, AdviceDirection } from '../../types' +import { deepseekHttp } from '../http' +import type { AiAnalysisPayload } from './types' +import { buildAnalysisMessages } from './prompt' +import type { + DeepSeekAdviceJson, + DeepSeekChatRequest, + DeepSeekChatResponse, +} from './types' + +/** 当前推荐模型(deepseek-chat 将于 2026-07-24 弃用) */ +export const DEEPSEEK_MODEL = 'deepseek-v4-flash' + +/** + * 调用 DeepSeek,用关键词 + 格式化行情/新闻/持仓数据生成交易建议。 + */ +export async function fetchDeepSeekAdvice( + apiKey: string, + payload: AiAnalysisPayload, +): Promise { + const body: DeepSeekChatRequest = { + model: DEEPSEEK_MODEL, + messages: buildAnalysisMessages(payload), + temperature: 0.3, + response_format: { type: 'json_object' }, + thinking: { type: 'disabled' }, + stream: false, + } + + const { data } = await deepseekHttp.post( + '/chat/completions', + body, + { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + timeout: 90000, + }, + ) + + const content = data.choices?.[0]?.message?.content + if (!content?.trim()) { + throw new Error('DeepSeek 返回空内容') + } + + return parseAdviceContent(content) +} + +function parseAdviceContent(content: string): AiAdvice { + const jsonText = extractJsonObject(content) + let raw: DeepSeekAdviceJson + try { + raw = JSON.parse(jsonText) as DeepSeekAdviceJson + } catch { + throw new Error(`DeepSeek 返回非 JSON:${content.slice(0, 200)}`) + } + + const direction = normalizeDirection(raw.direction) + const confidence = clampConfidence(raw.confidence) + const reasons = Array.isArray(raw.reasons) + ? raw.reasons.map(String).filter(Boolean).slice(0, 8) + : [] + + return { + action: String(raw.action || directionToAction(direction)).trim() || '观望', + direction, + confidence, + summary: String(raw.summary || '').trim() || '暂无摘要', + reasons: reasons.length ? reasons : ['模型未给出具体理由'], + updatedAt: new Date().toLocaleString('zh-CN', { hour12: false }), + } +} + +function extractJsonObject(text: string): string { + const trimmed = text.trim() + const fence = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i) + if (fence?.[1]) return fence[1].trim() + + const start = trimmed.indexOf('{') + const end = trimmed.lastIndexOf('}') + if (start >= 0 && end > start) return trimmed.slice(start, end + 1) + return trimmed +} + +function normalizeDirection(value: unknown): AdviceDirection { + const v = String(value || '').toLowerCase() + if (v === 'long' || v === 'buy' || v === '多' || v === '买入') return 'long' + if (v === 'short' || v === 'sell' || v === '空' || v === '卖出') return 'short' + return 'neutral' +} + +function clampConfidence(value: unknown): number { + const n = Number(value) + if (!Number.isFinite(n)) return 50 + return Math.max(0, Math.min(100, Math.round(n))) +} + +function directionToAction(direction: AdviceDirection): string { + if (direction === 'long') return '买入' + if (direction === 'short') return '卖出' + return '观望' +} diff --git a/src/api/deepseek/prompt.ts b/src/api/deepseek/prompt.ts new file mode 100644 index 0000000..05754d4 --- /dev/null +++ b/src/api/deepseek/prompt.ts @@ -0,0 +1,61 @@ +import type { AnalysisSnapshot } from '../../stores/quota' +import type { AiAnalysisPayload } from './types' + +const OUTPUT_SCHEMA = `{ + "action": "买入 | 卖出 | 观望(或更具体的操作建议,如「轻仓试多」)", + "direction": "long | short | neutral", + "confidence": 0-100 的整数, + "summary": "一句话结论,面向交易员", + "reasons": ["理由1", "理由2", "理由3", "理由4"] +}` + +/** + * 将行情 / 新闻 / 持仓格式化规则数据拼成 DeepSeek messages。 + * keywords 作为 system 角色设定;snapshot 作为 user 侧事实数据。 + */ +export function buildAnalysisMessages(payload: AiAnalysisPayload): Array<{ + role: 'system' | 'user' + content: string +}> { + 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 }, + ] +} + +/** + * 压缩超长数组,保留分析关键字段,避免 prompt 过大。 + * 盘口、统计、持仓、新闻完整保留;分时/成交/K 线做尾部截断。 + */ +function slimSnapshot(snapshot: AnalysisSnapshot): AnalysisSnapshot { + const quote = snapshot.quote + if (!quote) return snapshot + + 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), + } +} diff --git a/src/api/deepseek/types.ts b/src/api/deepseek/types.ts new file mode 100644 index 0000000..d87b2ee --- /dev/null +++ b/src/api/deepseek/types.ts @@ -0,0 +1,52 @@ +import type { AnalysisSnapshot } from '../../stores/quota' + +/** 提交给 AI 的完整载荷(格式化后的规则数据 + 关键词) */ +export interface AiAnalysisPayload { + /** 设置中的角色/分析关键词 */ + keywords: string + /** 行情 + 新闻 + 机构持仓等接口快照 */ + snapshot: AnalysisSnapshot +} + +/** DeepSeek Chat Completions(OpenAI 兼容)请求体 */ +export interface DeepSeekChatRequest { + model: string + messages: Array<{ + role: 'system' | 'user' | 'assistant' + content: string + }> + temperature?: number + response_format?: { type: 'json_object' | 'text' } + stream?: boolean + /** V4 默认开启 thinking;分析建议关闭以加快 JSON 输出 */ + thinking?: { type: 'enabled' | 'disabled' } +} + +export interface DeepSeekChatChoice { + index: number + message: { + role: string + content: string | null + } + finish_reason: string | null +} + +export interface DeepSeekChatResponse { + id: string + model: string + choices: DeepSeekChatChoice[] + usage?: { + prompt_tokens: number + completion_tokens: number + total_tokens: number + } +} + +/** 要求模型严格返回的 JSON 结构(与 AiAdvice 对齐) */ +export interface DeepSeekAdviceJson { + action: string + direction: 'long' | 'short' | 'neutral' + confidence: number + summary: string + reasons: string[] +} diff --git a/src/api/http.ts b/src/api/http.ts index 9ba55d0..07d4d7d 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -11,3 +11,9 @@ export const jqkaHttp = axios.create({ baseURL: '/jqka', timeout: 15000, }) + +/** 开发环境走 Vite 代理 /deepseek → https://api.deepseek.com */ +export const deepseekHttp = axios.create({ + baseURL: '/deepseek', + timeout: 90000, +}) diff --git a/src/components/ai/AiAdviceDrawer.vue b/src/components/ai/AiAdviceDrawer.vue index 8d22d85..e70500f 100644 --- a/src/components/ai/AiAdviceDrawer.vue +++ b/src/components/ai/AiAdviceDrawer.vue @@ -15,7 +15,7 @@
-
基于行情、盘口、新闻与持仓的综合分析(当前为 Mock)
+
基于行情、盘口、新闻与持仓,由 DeepSeek 综合分析
  • {{ r }}
@@ -25,9 +25,7 @@ diff --git a/src/composables/useAiAdvice.ts b/src/composables/useAiAdvice.ts index 58ad4a8..6658723 100644 --- a/src/composables/useAiAdvice.ts +++ b/src/composables/useAiAdvice.ts @@ -1,17 +1,12 @@ import { ref } from 'vue' +import { ElMessage } from 'element-plus' import type { AiAdvice } from '../types' import { aiAdviceMock } from '../mocks/aiAdvice' -import { useQuotaStore, type AnalysisSnapshot, type KlinePeriod } from '../stores/quota' +import { fetchDeepSeekAdvice } from '../api/deepseek/analyze' +import type { AiAnalysisPayload } from '../api/deepseek/types' +import { useQuotaStore, type KlinePeriod } from '../stores/quota' import { useSettingsStore } from '../stores/settings' -/** 提交给 AI 的完整载荷(格式化后的规则数据 + 关键词) */ -export interface AiAnalysisPayload { - /** 设置中的角色/分析关键词 */ - keywords: string - /** 行情 + 新闻 + 机构持仓等接口快照 */ - snapshot: AnalysisSnapshot -} - const KLINE_PERIODS: KlinePeriod[] = ['day', 'week', 'month'] export function useAiAdvice() { @@ -35,14 +30,19 @@ export function useAiAdvice() { } /** - * 一键获取建议:先收集行情 / 新闻 / 持仓(含 K 线), - * 再拼装提交给 AI 的关键词与格式化快照;当前阶段仅 console.log,仍返回 Mock 建议。 + * 一键获取建议:收集行情 / 新闻 / 持仓后提交 DeepSeek,返回真实 AI 建议。 */ async function refresh() { + const apiKey = settings.apiKey.trim() + if (!apiKey) { + ElMessage.warning('请先在设置中配置 DeepSeek API Key') + return + } + loading.value = true error.value = null try { - await quota.fetchAll() + await quota.fetchForAnalysis() await ensureKlines() const payload: AiAnalysisPayload = { @@ -50,18 +50,16 @@ export function useAiAdvice() { snapshot: quota.getAnalysisSnapshot(), } - // 本阶段:打印将提交给 AI 的关键词与全部接口规则数据 - console.log('[AI] 提交给 AI 的关键词', payload.keywords) - console.log('[AI] 提交给 AI 的数据(格式化规则快照)', payload.snapshot) - console.log('[AI] 完整载荷', payload) + console.log('[AI] 提交给 DeepSeek 的关键词', payload.keywords) + console.log('[AI] 提交给 DeepSeek 的数据快照', payload.snapshot) - data.value = { - ...structuredClone(aiAdviceMock), - updatedAt: new Date().toLocaleString('zh-CN', { hour12: false }), - } + data.value = await fetchDeepSeekAdvice(apiKey, payload) + ElMessage.success('AI 建议已更新') } catch (e) { error.value = e + const msg = formatError(e) console.error('[AI] 一键获取建议失败', e) + ElMessage.error(msg) } finally { loading.value = false } @@ -69,3 +67,15 @@ export function useAiAdvice() { return { data, loading, error, refresh } } + +function formatError(e: unknown): string { + if (typeof e === 'object' && e && 'response' in e) { + const resp = (e as { response?: { status?: number; data?: { error?: { message?: string } } } }) + .response + const apiMsg = resp?.data?.error?.message + if (apiMsg) return `DeepSeek:${apiMsg}` + if (resp?.status) return `DeepSeek 请求失败(HTTP ${resp.status})` + } + if (e instanceof Error) return e.message + return '获取 AI 建议失败' +} diff --git a/src/stores/quota.ts b/src/stores/quota.ts index eba0838..0c735ce 100644 --- a/src/stores/quota.ts +++ b/src/stores/quota.ts @@ -1,5 +1,5 @@ import { defineStore } from 'pinia' -import { computed, ref } from 'vue' +import { computed, ref, toRaw } from 'vue' import type { Candle, NewsItem, PositionsData, QuoteData } from '../types' import { quoteMock } from '../mocks/quote' import { newsMock } from '../mocks/news' @@ -70,6 +70,15 @@ export const useQuotaStore = defineStore('quota', () => { /** 是否已有至少一次成功的行情拉取(可用于 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 @@ -120,6 +129,7 @@ export const useQuotaStore = defineStore('quota', () => { 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) @@ -168,6 +178,7 @@ export const useQuotaStore = defineStore('quota', () => { } positions.value = mapped + positionsLoaded.value = true } catch (e) { positionsError.value = e console.error('[quota] 拉取持仓失败', e) @@ -181,6 +192,25 @@ export const useQuotaStore = defineStore('quota', () => { 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。 * 返回深拷贝,避免分析过程中被行情刷新污染。 @@ -192,9 +222,9 @@ export const useQuotaStore = defineStore('quota', () => { name: quote.value.name || contractConfig.name, exchange: quote.value.exchange || contractConfig.exchange, }, - quote: quote.value ? structuredClone(quote.value) : null, - news: structuredClone(news.value), - positions: positions.value ? structuredClone(positions.value) : null, + quote: quote.value ? clonePlain(quote.value) : null, + news: clonePlain(news.value), + positions: positions.value ? clonePlain(positions.value) : null, fetchedAt: new Date().toISOString(), } } @@ -215,12 +245,16 @@ export const useQuotaStore = defineStore('quota', () => { // computed hasQuote, hasNews, + hasPositions, + newsLoaded, + positionsLoaded, // actions fetchQuote, loadKline, fetchNews, fetchPositions, fetchAll, + fetchForAnalysis, getAnalysisSnapshot, } }) diff --git a/vite.config.ts b/vite.config.ts index 6dc6607..9f38d3a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -26,6 +26,11 @@ export default defineConfig({ changeOrigin: true, rewrite: (path) => path.replace(/^\/jqka/, ''), }, + '/deepseek': { + target: 'https://api.deepseek.com', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/deepseek/, ''), + }, }, }, })