对接到deepseek分析

This commit is contained in:
tony 2026-07-21 23:39:28 +08:00
parent 0f04b1370a
commit 398dfdf487
8 changed files with 295 additions and 31 deletions

102
src/api/deepseek/analyze.ts Normal file
View File

@ -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<AiAdvice> {
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<DeepSeekChatResponse>(
'/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 '观望'
}

View File

@ -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),
}
}

52
src/api/deepseek/types.ts Normal file
View File

@ -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[]
}

View File

@ -11,3 +11,9 @@ export const jqkaHttp = axios.create({
baseURL: '/jqka', baseURL: '/jqka',
timeout: 15000, timeout: 15000,
}) })
/** 开发环境走 Vite 代理 /deepseek → https://api.deepseek.com */
export const deepseekHttp = axios.create({
baseURL: '/deepseek',
timeout: 90000,
})

View File

@ -15,7 +15,7 @@
</div> </div>
</div> </div>
<div v-show="expanded" class="body"> <div v-show="expanded" class="body">
<div class="hint">基于行情、盘口、新闻与持仓的综合分析(当前为 Mock)</div> <div class="hint">基于行情、盘口、新闻与持仓,由 DeepSeek 综合分析</div>
<ul> <ul>
<li v-for="(r, i) in data.reasons" :key="i">{{ r }}</li> <li v-for="(r, i) in data.reasons" :key="i">{{ r }}</li>
</ul> </ul>
@ -25,9 +25,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import type { AiAdvice } from '../../types' import type { AiAdvice } from '../../types'
import { useSettingsStore } from '../../stores/settings'
withDefaults( withDefaults(
defineProps<{ defineProps<{
@ -40,13 +38,9 @@ withDefaults(
const emit = defineEmits<{ const emit = defineEmits<{
refresh: [] refresh: []
}>() }>()
const settings = useSettingsStore()
const expanded = ref(false) const expanded = ref(false)
function onRefresh() { function onRefresh() {
if (!settings.apiKey) {
ElMessage.warning('请先在设置中配置 DeepSeek API Key(本阶段仍返回 Mock 建议)')
}
emit('refresh') emit('refresh')
} }
</script> </script>

View File

@ -1,17 +1,12 @@
import { ref } from 'vue' import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import type { AiAdvice } from '../types' import type { AiAdvice } from '../types'
import { aiAdviceMock } from '../mocks/aiAdvice' 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' import { useSettingsStore } from '../stores/settings'
/** 提交给 AI 的完整载荷(格式化后的规则数据 + 关键词) */
export interface AiAnalysisPayload {
/** 设置中的角色/分析关键词 */
keywords: string
/** 行情 + 新闻 + 机构持仓等接口快照 */
snapshot: AnalysisSnapshot
}
const KLINE_PERIODS: KlinePeriod[] = ['day', 'week', 'month'] const KLINE_PERIODS: KlinePeriod[] = ['day', 'week', 'month']
export function useAiAdvice() { export function useAiAdvice() {
@ -35,14 +30,19 @@ export function useAiAdvice() {
} }
/** /**
* 一键获取建议:先收集行情 / 新闻 / 持仓(含 K 线), * 一键获取建议:收集行情 / 新闻 / 持仓后提交 DeepSeek,返回真实 AI 建议。
* 再拼装提交给 AI 的关键词与格式化快照;当前阶段仅 console.log,仍返回 Mock 建议。
*/ */
async function refresh() { async function refresh() {
const apiKey = settings.apiKey.trim()
if (!apiKey) {
ElMessage.warning('请先在设置中配置 DeepSeek API Key')
return
}
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
await quota.fetchAll() await quota.fetchForAnalysis()
await ensureKlines() await ensureKlines()
const payload: AiAnalysisPayload = { const payload: AiAnalysisPayload = {
@ -50,18 +50,16 @@ export function useAiAdvice() {
snapshot: quota.getAnalysisSnapshot(), snapshot: quota.getAnalysisSnapshot(),
} }
// 本阶段:打印将提交给 AI 的关键词与全部接口规则数据 console.log('[AI] 提交给 DeepSeek 的关键词', payload.keywords)
console.log('[AI] 提交给 AI 的关键词', payload.keywords) console.log('[AI] 提交给 DeepSeek 的数据快照', payload.snapshot)
console.log('[AI] 提交给 AI 的数据(格式化规则快照)', payload.snapshot)
console.log('[AI] 完整载荷', payload)
data.value = { data.value = await fetchDeepSeekAdvice(apiKey, payload)
...structuredClone(aiAdviceMock), ElMessage.success('AI 建议已更新')
updatedAt: new Date().toLocaleString('zh-CN', { hour12: false }),
}
} catch (e) { } catch (e) {
error.value = e error.value = e
const msg = formatError(e)
console.error('[AI] 一键获取建议失败', e) console.error('[AI] 一键获取建议失败', e)
ElMessage.error(msg)
} finally { } finally {
loading.value = false loading.value = false
} }
@ -69,3 +67,15 @@ export function useAiAdvice() {
return { data, loading, error, refresh } 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 建议失败'
}

View File

@ -1,5 +1,5 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { computed, ref } from 'vue' import { computed, ref, toRaw } from 'vue'
import type { Candle, NewsItem, PositionsData, QuoteData } from '../types' import type { Candle, NewsItem, PositionsData, QuoteData } from '../types'
import { quoteMock } from '../mocks/quote' import { quoteMock } from '../mocks/quote'
import { newsMock } from '../mocks/news' import { newsMock } from '../mocks/news'
@ -70,6 +70,15 @@ export const useQuotaStore = defineStore('quota', () => {
/** 是否已有至少一次成功的行情拉取(可用于 AI 门禁) */ /** 是否已有至少一次成功的行情拉取(可用于 AI 门禁) */
const hasQuote = computed(() => quote.value.last > 0 && quote.value.intraday.length > 0) const hasQuote = computed(() => quote.value.last > 0 && quote.value.intraday.length > 0)
const hasNews = computed(() => news.value.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() { async function fetchQuote() {
quoteLoading.value = true quoteLoading.value = true
@ -120,6 +129,7 @@ export const useQuotaStore = defineStore('quota', () => {
try { try {
const list = await getFuturesNews({ code: contractConfig.code }) const list = await getFuturesNews({ code: contractConfig.code })
news.value = mapBaiduNewsToItems(list) news.value = mapBaiduNewsToItems(list)
newsLoaded.value = news.value.length > 0
} catch (e) { } catch (e) {
newsError.value = e newsError.value = e
console.error('[quota] 拉取新闻失败', e) console.error('[quota] 拉取新闻失败', e)
@ -168,6 +178,7 @@ export const useQuotaStore = defineStore('quota', () => {
} }
positions.value = mapped positions.value = mapped
positionsLoaded.value = true
} catch (e) { } catch (e) {
positionsError.value = e positionsError.value = e
console.error('[quota] 拉取持仓失败', e) console.error('[quota] 拉取持仓失败', e)
@ -181,6 +192,25 @@ export const useQuotaStore = defineStore('quota', () => {
await Promise.all([fetchQuote(), fetchNews(), fetchPositions()]) 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。 * 导出当前全部接口数据快照,供下一步 AI 分析拼接 Prompt。
* 返回深拷贝,避免分析过程中被行情刷新污染。 * 返回深拷贝,避免分析过程中被行情刷新污染。
@ -192,9 +222,9 @@ export const useQuotaStore = defineStore('quota', () => {
name: quote.value.name || contractConfig.name, name: quote.value.name || contractConfig.name,
exchange: quote.value.exchange || contractConfig.exchange, exchange: quote.value.exchange || contractConfig.exchange,
}, },
quote: quote.value ? structuredClone(quote.value) : null, quote: quote.value ? clonePlain(quote.value) : null,
news: structuredClone(news.value), news: clonePlain(news.value),
positions: positions.value ? structuredClone(positions.value) : null, positions: positions.value ? clonePlain(positions.value) : null,
fetchedAt: new Date().toISOString(), fetchedAt: new Date().toISOString(),
} }
} }
@ -215,12 +245,16 @@ export const useQuotaStore = defineStore('quota', () => {
// computed // computed
hasQuote, hasQuote,
hasNews, hasNews,
hasPositions,
newsLoaded,
positionsLoaded,
// actions // actions
fetchQuote, fetchQuote,
loadKline, loadKline,
fetchNews, fetchNews,
fetchPositions, fetchPositions,
fetchAll, fetchAll,
fetchForAnalysis,
getAnalysisSnapshot, getAnalysisSnapshot,
} }
}) })

View File

@ -26,6 +26,11 @@ export default defineConfig({
changeOrigin: true, changeOrigin: true,
rewrite: (path) => path.replace(/^\/jqka/, ''), rewrite: (path) => path.replace(/^\/jqka/, ''),
}, },
'/deepseek': {
target: 'https://api.deepseek.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/deepseek/, ''),
},
}, },
}, },
}) })