From 4738c1c57a8aeb43b2e6e97aee685e1297ec365b Mon Sep 17 00:00:00 2001 From: dongzp <975303544@qq.com> Date: Mon, 27 Jul 2026 13:59:37 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9EAI=E8=AF=84=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.vue | 2 + src/api/cache/localStorageCache.ts | 127 +++++++++++++++++++++ src/api/deepseek/prompt.ts | 30 ++++- src/api/deepseek/types.ts | 2 +- src/api/http.ts | 5 + src/api/jqka/aiExplain.ts | 84 ++++++++++++++ src/api/jqka/aiExplainTypes.ts | 44 ++++++++ src/components/ai/AiAdviceDrawer.vue | 160 ++++++++++++++++++++++++++- src/stores/quota.ts | 88 ++++++++++++++- src/types/index.ts | 16 +++ 10 files changed, 546 insertions(+), 12 deletions(-) create mode 100644 src/api/cache/localStorageCache.ts create mode 100644 src/api/jqka/aiExplain.ts create mode 100644 src/api/jqka/aiExplainTypes.ts diff --git a/src/App.vue b/src/App.vue index e6a1007..6c27c51 100644 --- a/src/App.vue +++ b/src/App.vue @@ -73,6 +73,7 @@ v-if="advice" :data="advice" :loading="adviceLoading" + :ai-score="aiVarietyScore" @refresh="refreshAdvice" /> @@ -111,6 +112,7 @@ const { positionAnomaly, positionAnomalyLoading, positionAnomalyError, + aiVarietyScore, } = storeToRefs(useQuotaStore()) const { data: news, loading: newsLoading, error: newsError, refresh: refreshNews } = useNews() const { data: positions } = usePositions() diff --git a/src/api/cache/localStorageCache.ts b/src/api/cache/localStorageCache.ts new file mode 100644 index 0000000..390fde6 --- /dev/null +++ b/src/api/cache/localStorageCache.ts @@ -0,0 +1,127 @@ +import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios' +import { getPositionQueryDate } from '../../utils/tradingDate' + +const CACHE_PREFIX = 'api-cache:v1:' + +type CacheEntry = { + data: unknown + savedAt: number + /** 与持仓查询日对齐,换日自动失效 */ + dayKey: string +} + +function stableSerialize(value: unknown): string { + if (value == null) return '' + if (typeof value !== 'object') return String(value) + if (Array.isArray(value)) { + return `[${value.map((v) => stableSerialize(v)).join(',')}]` + } + const obj = value as Record + const keys = Object.keys(obj).sort() + return `{${keys.map((k) => `${k}:${stableSerialize(obj[k])}`).join(',')}}` +} + +/** + * axios 在发 POST 前会把 data 转成 JSON 字符串; + * 请求拦截器看到的是 object,响应拦截器看到的常是 string, + * 必须归一化后再算缓存键,否则 position_profit_rank 等 POST 永远 miss。 + */ +function normalizeBody(data: unknown): unknown { + if (typeof data !== 'string') return data + const trimmed = data.trim() + if (!trimmed) return '' + try { + return JSON.parse(trimmed) as unknown + } catch { + return data + } +} + +/** + * 缓存键:baseURL + path + 查询参数 + 请求体(POST)。 + * 例:/jqka|/futgwapi/api/.../variety_list|params:{}|body: + */ +export function buildApiCacheKey(config: InternalAxiosRequestConfig): string { + const base = (config.baseURL || '').replace(/\/$/, '') + const url = (config.url || '').split('?')[0] + const method = (config.method || 'get').toLowerCase() + const params = stableSerialize(config.params) + const body = + method === 'get' || method === 'head' + ? '' + : stableSerialize(normalizeBody(config.data)) + return `${CACHE_PREFIX}${method}|${base}|${url}|params:${params}|body:${body}` +} + +function readCache(key: string): unknown | null { + try { + const raw = localStorage.getItem(key) + if (!raw) return null + const entry = JSON.parse(raw) as CacheEntry + if (!entry || entry.dayKey !== getPositionQueryDate()) { + localStorage.removeItem(key) + return null + } + return entry.data ?? null + } catch { + return null + } +} + +function writeCache(key: string, data: unknown) { + try { + const entry: CacheEntry = { + data, + savedAt: Date.now(), + dayKey: getPositionQueryDate(), + } + localStorage.setItem(key, JSON.stringify(entry)) + } catch (e) { + // quota exceeded 等:忽略,不影响主流程 + console.warn('[api-cache] 写入 localStorage 失败', e) + } +} + +/** + * 为 axios 实例挂载 localStorage 缓存: + * - 请求前命中则直接返回缓存(自定义 adapter) + * - 成功响应后按 path + 参数写入 + * - 按持仓查询日换日失效 + */ +export function attachLocalStorageCache(http: AxiosInstance) { + http.interceptors.request.use((config) => { + // 允许单次请求跳过缓存:config.headers['X-Skip-Cache'] = '1' + const skip = + config.headers?.['X-Skip-Cache'] === '1' || + config.headers?.['x-skip-cache'] === '1' + if (skip) return config + + const key = buildApiCacheKey(config) + const cached = readCache(key) + if (cached == null) return config + + config.adapter = async () => ({ + data: cached, + status: 200, + statusText: 'OK (localStorage cache)', + headers: { 'x-cache': 'HIT' }, + config, + request: {}, + }) + // 标记已走缓存,避免响应拦截器重复写入 + ;(config as InternalAxiosRequestConfig & { __fromCache?: boolean }).__fromCache = + true + return config + }) + + http.interceptors.response.use((response) => { + const cfg = response.config as InternalAxiosRequestConfig & { + __fromCache?: boolean + } + if (cfg.__fromCache) return response + + const key = buildApiCacheKey(cfg) + writeCache(key, response.data) + return response + }) +} diff --git a/src/api/deepseek/prompt.ts b/src/api/deepseek/prompt.ts index 4f97037..f14324c 100644 --- a/src/api/deepseek/prompt.ts +++ b/src/api/deepseek/prompt.ts @@ -42,8 +42,9 @@ export function buildAnalysisMessages(payload: AiAnalysisPayload): Array<{ }> { const system = [ payload.keywords.trim() || '你是一名专业的国内期货交易员和分析员。', - '请仅基于用户提供的行情、盘口、大单分析、新闻、机构持仓、主力分析(盈利席位榜)、主力追踪(变盘预警、主力趋势机会)与主力异动数据给出交易建议。', + '请仅基于用户提供的行情、盘口、大单分析、新闻、机构持仓、主力分析(盈利席位榜)、主力追踪(变盘预警、主力趋势机会)、主力异动与同花顺 AI 品种评分数据给出交易建议。', '主力分析为当前品种盈利席位榜;主力追踪与主力异动为全市场数据,请优先关注标注为「当前品种」的条目,并结合整体主力动向综合判断。', + '同花顺 AI 评分为外部量化/基本面因子评分,正分偏多、负分偏空;若标注「最佳推荐」则表示该品种在推荐列表中,权重可适当提高,但仍需结合盘面综合判断。', '文案中的单位与页面一致(手、万手、万、亿、百分比);不要编造未提供的数据;信息不足时偏向观望并说明原因。', '必须只输出一个 JSON 对象,不要 Markdown 代码块,不要其它说明文字。', `JSON 格式:${OUTPUT_SCHEMA}`, @@ -60,6 +61,7 @@ export function buildAnalysisUserContent(payload: AiAnalysisPayload): string { const { snapshot, largeOrderLots, variety } = payload const sections = [ formatContract(snapshot), + formatAiVarietyScore(snapshot), formatQuoteStats(snapshot.quote), formatOrderBook(snapshot.quote), formatLargeOrder(snapshot.quote?.trades ?? [], largeOrderLots), @@ -98,6 +100,32 @@ function formatContract(snapshot: AnalysisSnapshot): string { return lines.join('\n') } +function formatAiVarietyScore(snapshot: AnalysisSnapshot): string { + const score = snapshot.aiVarietyScore + if (!score) return '【同花顺 AI 评分】\n暂无数据' + + const lines = [ + '【同花顺 AI 评分】', + `品种:${score.varietyName}(${score.variety})`, + `主力合约:${score.mainContract}`, + `综合评分:${fmtSigned(score.score)}${score.isBest ? '【最佳推荐】' : ''}`, + `涨跌幅:${fmtSigned(score.percent)}%`, + '影响因素:', + ] + + if (!score.factors.length) { + lines.push('(无)') + } else { + for (const f of score.factors) { + lines.push( + `- ${f.factor}|${f.judge}|评分 ${fmtSigned(f.score)}`, + ) + } + } + + return lines.join('\n') +} + function formatQuoteStats(quote: QuoteData | null): string { if (!quote) return '【行情统计】\n暂无数据' return [ diff --git a/src/api/deepseek/types.ts b/src/api/deepseek/types.ts index c375637..1f6ba1e 100644 --- a/src/api/deepseek/types.ts +++ b/src/api/deepseek/types.ts @@ -4,7 +4,7 @@ import type { AnalysisSnapshot } from '../../stores/quota' export interface AiAnalysisPayload { /** 设置中的角色/分析关键词 */ keywords: string - /** 行情 + 新闻 + 机构持仓(含主力分析)+ 主力追踪 + 主力异动等接口快照 */ + /** 行情 + 新闻 + 机构持仓(含主力分析)+ 主力追踪 + 主力异动 + AI 评分等接口快照 */ snapshot: AnalysisSnapshot /** 大单手数阈值,与 UI 大单分析一致 */ largeOrderLots: number diff --git a/src/api/http.ts b/src/api/http.ts index 9f9aef1..e87bc0b 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -1,4 +1,5 @@ import axios from 'axios' +import { attachLocalStorageCache } from './cache/localStorageCache' /** 开发环境走 Vite 代理 /baidu → https://finance.pae.baidu.com */ export const baiduHttp = axios.create({ @@ -18,6 +19,10 @@ export const jqkaDqHttp = axios.create({ timeout: 15000, }) +/** /jqka、/dq 响应按 path + 参数缓存到 localStorage */ +attachLocalStorageCache(jqkaHttp) +attachLocalStorageCache(jqkaDqHttp) + /** 开发环境走 Vite 代理 /deepseek → https://api.deepseek.com */ export const deepseekHttp = axios.create({ baseURL: '/deepseek', diff --git a/src/api/jqka/aiExplain.ts b/src/api/jqka/aiExplain.ts new file mode 100644 index 0000000..15a6576 --- /dev/null +++ b/src/api/jqka/aiExplain.ts @@ -0,0 +1,84 @@ +import { jqkaHttp } from '../http' +import { extractVariety } from '../../utils/tradingDate' +import type { + AiExplainVariety, + AiExplainVarietyListResponse, + AiVarietyScore, +} from './aiExplainTypes' + +function mapVariety(raw: AiExplainVariety, isBest: boolean): AiVarietyScore { + return { + market: raw.market, + score: Number(raw.score) || 0, + percent: Number(raw.percent) || 0, + varietyName: raw.variety_name, + mainContract: raw.main_contract, + variety: extractVariety(raw.main_contract).toUpperCase(), + factors: (raw.factor_list || []).map((f) => ({ + factor: f.factor, + judge: f.judge, + score: Number(f.score) || 0, + })), + isBest, + } +} + +/** + * 同花顺 — 全品种 AI 评分 + * GET /futgwapi/api/f10/ai_explain/v1/variety_list + */ +export async function getAiVarietyList(): Promise { + const { data } = await jqkaHttp.get( + '/futgwapi/api/f10/ai_explain/v1/variety_list', + ) + + if (data.code !== 0) { + throw new Error(`AI 评分接口失败: code=${data.code}, msg=${data.msg}`) + } + + return data.data ?? [] +} + +/** + * 同花顺 — 最佳/推荐品种 AI 评分 + * GET /futgwapi/api/f10/ai_explain/v1/recommend_variety_list + */ +export async function getAiRecommendVarietyList(): Promise { + const { data } = await jqkaHttp.get( + '/futgwapi/api/f10/ai_explain/v1/recommend_variety_list', + ) + + if (data.code !== 0) { + throw new Error(`AI 推荐评分接口失败: code=${data.code}, msg=${data.msg}`) + } + + return data.data ?? [] +} + +/** 按品种代码匹配(忽略大小写) */ +export function findVarietyScore( + list: AiVarietyScore[], + variety: string, +): AiVarietyScore | null { + const key = variety.trim().toUpperCase() + if (!key) return null + return list.find((item) => item.variety === key) ?? null +} + +/** + * 拉取全品种评分 + 推荐列表,合并 isBest 标记。 + */ +export async function fetchAiVarietyScores(): Promise { + const [all, recommend] = await Promise.all([ + getAiVarietyList(), + getAiRecommendVarietyList(), + ]) + + const bestKeys = new Set( + recommend.map((r) => extractVariety(r.main_contract).toUpperCase()), + ) + + return all.map((raw) => + mapVariety(raw, bestKeys.has(extractVariety(raw.main_contract).toUpperCase())), + ) +} diff --git a/src/api/jqka/aiExplainTypes.ts b/src/api/jqka/aiExplainTypes.ts new file mode 100644 index 0000000..43396ce --- /dev/null +++ b/src/api/jqka/aiExplainTypes.ts @@ -0,0 +1,44 @@ +/** 同花顺 AI 解读 — 单条影响因素 */ +export interface AiExplainFactor { + factor: string + /** 利多 | 利空 */ + judge: string + score: string +} + +/** 同花顺 AI 解读 — 品种评分 */ +export interface AiExplainVariety { + market: string + /** 综合评分(字符串数字,可正可负) */ + score: string + /** 涨跌幅等百分比字段 */ + percent: string + variety_name: string + /** 主力合约,如 UR2609、y2609 */ + main_contract: string + factor_list: AiExplainFactor[] +} + +export interface AiExplainVarietyListResponse { + code: number + msg: string + data: AiExplainVariety[] +} + +/** 映射到应用内使用的评分视图 */ +export interface AiVarietyScore { + market: string + score: number + percent: number + varietyName: string + mainContract: string + /** 品种代码(从 main_contract 字母前缀推导,大写) */ + variety: string + factors: Array<{ + factor: string + judge: string + score: number + }> + /** 是否出现在 recommend_variety_list(最佳推荐) */ + isBest: boolean +} diff --git a/src/components/ai/AiAdviceDrawer.vue b/src/components/ai/AiAdviceDrawer.vue index a57561b..0e96e66 100644 --- a/src/components/ai/AiAdviceDrawer.vue +++ b/src/components/ai/AiAdviceDrawer.vue @@ -4,6 +4,15 @@
AI {{ data.action }} + + 评分 {{ formatSigned(aiScore.score) }} + 最佳 + {{ data.summary }} 置信度 {{ data.confidence }}% @@ -27,7 +36,32 @@
-
基于行情、盘口、新闻与持仓,由 DeepSeek 综合分析
+
基于行情、盘口、新闻、持仓与同花顺 AI 评分,由 DeepSeek 综合分析
+
+
+ {{ aiScore.varietyName }} AI 评分 + {{ + formatSigned(aiScore.score) + }} + 最佳 + 涨跌幅 {{ formatSigned(aiScore.percent) }}% +
+
    +
  • + {{ + f.judge + }} + {{ f.factor }} + {{ formatSigned(f.score) }} +
  • +
+
  • {{ r }}
@@ -36,15 +70,16 @@