新增AI评分
This commit is contained in:
parent
d88fc616f3
commit
4738c1c57a
@ -73,6 +73,7 @@
|
|||||||
v-if="advice"
|
v-if="advice"
|
||||||
:data="advice"
|
:data="advice"
|
||||||
:loading="adviceLoading"
|
:loading="adviceLoading"
|
||||||
|
:ai-score="aiVarietyScore"
|
||||||
@refresh="refreshAdvice"
|
@refresh="refreshAdvice"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@ -111,6 +112,7 @@ const {
|
|||||||
positionAnomaly,
|
positionAnomaly,
|
||||||
positionAnomalyLoading,
|
positionAnomalyLoading,
|
||||||
positionAnomalyError,
|
positionAnomalyError,
|
||||||
|
aiVarietyScore,
|
||||||
} = storeToRefs(useQuotaStore())
|
} = storeToRefs(useQuotaStore())
|
||||||
const { data: news, loading: newsLoading, error: newsError, refresh: refreshNews } = useNews()
|
const { data: news, loading: newsLoading, error: newsError, refresh: refreshNews } = useNews()
|
||||||
const { data: positions } = usePositions()
|
const { data: positions } = usePositions()
|
||||||
|
|||||||
127
src/api/cache/localStorageCache.ts
vendored
Normal file
127
src/api/cache/localStorageCache.ts
vendored
Normal file
@ -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<string, unknown>
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -42,8 +42,9 @@ export function buildAnalysisMessages(payload: AiAnalysisPayload): Array<{
|
|||||||
}> {
|
}> {
|
||||||
const system = [
|
const system = [
|
||||||
payload.keywords.trim() || '你是一名专业的国内期货交易员和分析员。',
|
payload.keywords.trim() || '你是一名专业的国内期货交易员和分析员。',
|
||||||
'请仅基于用户提供的行情、盘口、大单分析、新闻、机构持仓、主力分析(盈利席位榜)、主力追踪(变盘预警、主力趋势机会)与主力异动数据给出交易建议。',
|
'请仅基于用户提供的行情、盘口、大单分析、新闻、机构持仓、主力分析(盈利席位榜)、主力追踪(变盘预警、主力趋势机会)、主力异动与同花顺 AI 品种评分数据给出交易建议。',
|
||||||
'主力分析为当前品种盈利席位榜;主力追踪与主力异动为全市场数据,请优先关注标注为「当前品种」的条目,并结合整体主力动向综合判断。',
|
'主力分析为当前品种盈利席位榜;主力追踪与主力异动为全市场数据,请优先关注标注为「当前品种」的条目,并结合整体主力动向综合判断。',
|
||||||
|
'同花顺 AI 评分为外部量化/基本面因子评分,正分偏多、负分偏空;若标注「最佳推荐」则表示该品种在推荐列表中,权重可适当提高,但仍需结合盘面综合判断。',
|
||||||
'文案中的单位与页面一致(手、万手、万、亿、百分比);不要编造未提供的数据;信息不足时偏向观望并说明原因。',
|
'文案中的单位与页面一致(手、万手、万、亿、百分比);不要编造未提供的数据;信息不足时偏向观望并说明原因。',
|
||||||
'必须只输出一个 JSON 对象,不要 Markdown 代码块,不要其它说明文字。',
|
'必须只输出一个 JSON 对象,不要 Markdown 代码块,不要其它说明文字。',
|
||||||
`JSON 格式:${OUTPUT_SCHEMA}`,
|
`JSON 格式:${OUTPUT_SCHEMA}`,
|
||||||
@ -60,6 +61,7 @@ export function buildAnalysisUserContent(payload: AiAnalysisPayload): string {
|
|||||||
const { snapshot, largeOrderLots, variety } = payload
|
const { snapshot, largeOrderLots, variety } = payload
|
||||||
const sections = [
|
const sections = [
|
||||||
formatContract(snapshot),
|
formatContract(snapshot),
|
||||||
|
formatAiVarietyScore(snapshot),
|
||||||
formatQuoteStats(snapshot.quote),
|
formatQuoteStats(snapshot.quote),
|
||||||
formatOrderBook(snapshot.quote),
|
formatOrderBook(snapshot.quote),
|
||||||
formatLargeOrder(snapshot.quote?.trades ?? [], largeOrderLots),
|
formatLargeOrder(snapshot.quote?.trades ?? [], largeOrderLots),
|
||||||
@ -98,6 +100,32 @@ function formatContract(snapshot: AnalysisSnapshot): string {
|
|||||||
return lines.join('\n')
|
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 {
|
function formatQuoteStats(quote: QuoteData | null): string {
|
||||||
if (!quote) return '【行情统计】\n暂无数据'
|
if (!quote) return '【行情统计】\n暂无数据'
|
||||||
return [
|
return [
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import type { AnalysisSnapshot } from '../../stores/quota'
|
|||||||
export interface AiAnalysisPayload {
|
export interface AiAnalysisPayload {
|
||||||
/** 设置中的角色/分析关键词 */
|
/** 设置中的角色/分析关键词 */
|
||||||
keywords: string
|
keywords: string
|
||||||
/** 行情 + 新闻 + 机构持仓(含主力分析)+ 主力追踪 + 主力异动等接口快照 */
|
/** 行情 + 新闻 + 机构持仓(含主力分析)+ 主力追踪 + 主力异动 + AI 评分等接口快照 */
|
||||||
snapshot: AnalysisSnapshot
|
snapshot: AnalysisSnapshot
|
||||||
/** 大单手数阈值,与 UI 大单分析一致 */
|
/** 大单手数阈值,与 UI 大单分析一致 */
|
||||||
largeOrderLots: number
|
largeOrderLots: number
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
import { attachLocalStorageCache } from './cache/localStorageCache'
|
||||||
|
|
||||||
/** 开发环境走 Vite 代理 /baidu → https://finance.pae.baidu.com */
|
/** 开发环境走 Vite 代理 /baidu → https://finance.pae.baidu.com */
|
||||||
export const baiduHttp = axios.create({
|
export const baiduHttp = axios.create({
|
||||||
@ -18,6 +19,10 @@ export const jqkaDqHttp = axios.create({
|
|||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** /jqka、/dq 响应按 path + 参数缓存到 localStorage */
|
||||||
|
attachLocalStorageCache(jqkaHttp)
|
||||||
|
attachLocalStorageCache(jqkaDqHttp)
|
||||||
|
|
||||||
/** 开发环境走 Vite 代理 /deepseek → https://api.deepseek.com */
|
/** 开发环境走 Vite 代理 /deepseek → https://api.deepseek.com */
|
||||||
export const deepseekHttp = axios.create({
|
export const deepseekHttp = axios.create({
|
||||||
baseURL: '/deepseek',
|
baseURL: '/deepseek',
|
||||||
|
|||||||
84
src/api/jqka/aiExplain.ts
Normal file
84
src/api/jqka/aiExplain.ts
Normal file
@ -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<AiExplainVariety[]> {
|
||||||
|
const { data } = await jqkaHttp.get<AiExplainVarietyListResponse>(
|
||||||
|
'/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<AiExplainVariety[]> {
|
||||||
|
const { data } = await jqkaHttp.get<AiExplainVarietyListResponse>(
|
||||||
|
'/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<AiVarietyScore[]> {
|
||||||
|
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())),
|
||||||
|
)
|
||||||
|
}
|
||||||
44
src/api/jqka/aiExplainTypes.ts
Normal file
44
src/api/jqka/aiExplainTypes.ts
Normal file
@ -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
|
||||||
|
}
|
||||||
@ -4,6 +4,15 @@
|
|||||||
<div class="summary">
|
<div class="summary">
|
||||||
<span class="badge" :class="data.direction">AI</span>
|
<span class="badge" :class="data.direction">AI</span>
|
||||||
<strong>{{ data.action }}</strong>
|
<strong>{{ data.action }}</strong>
|
||||||
|
<span
|
||||||
|
v-if="aiScore"
|
||||||
|
class="score"
|
||||||
|
:class="scoreClass"
|
||||||
|
title="同花顺 AI 品种评分"
|
||||||
|
>
|
||||||
|
评分 {{ formatSigned(aiScore.score) }}
|
||||||
|
<span v-if="aiScore.isBest" class="best-tag">最佳</span>
|
||||||
|
</span>
|
||||||
<span class="text">{{ data.summary }}</span>
|
<span class="text">{{ data.summary }}</span>
|
||||||
<span class="meta">
|
<span class="meta">
|
||||||
置信度 {{ data.confidence }}%
|
置信度 {{ data.confidence }}%
|
||||||
@ -27,7 +36,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="expanded" class="body">
|
<div v-show="expanded" class="body">
|
||||||
<div class="hint">基于行情、盘口、新闻与持仓,由 DeepSeek 综合分析</div>
|
<div class="hint">基于行情、盘口、新闻、持仓与同花顺 AI 评分,由 DeepSeek 综合分析</div>
|
||||||
|
<div v-if="aiScore" class="score-detail">
|
||||||
|
<div class="score-head">
|
||||||
|
<span
|
||||||
|
>{{ aiScore.varietyName }} AI 评分
|
||||||
|
<strong :class="scoreClass">{{
|
||||||
|
formatSigned(aiScore.score)
|
||||||
|
}}</strong></span
|
||||||
|
>
|
||||||
|
<el-tag v-if="aiScore.isBest" size="small" type="danger" effect="dark"
|
||||||
|
>最佳</el-tag
|
||||||
|
>
|
||||||
|
<span class="score-pct"
|
||||||
|
>涨跌幅 {{ formatSigned(aiScore.percent) }}%</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<ul v-if="aiScore.factors.length" class="factors">
|
||||||
|
<li v-for="(f, i) in aiScore.factors" :key="i">
|
||||||
|
<span class="judge" :class="judgeClass(f.judge)">{{
|
||||||
|
f.judge
|
||||||
|
}}</span>
|
||||||
|
{{ f.factor }}
|
||||||
|
<span class="factor-score">{{ formatSigned(f.score) }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</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>
|
||||||
@ -36,15 +70,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import type { AiAdvice } from "../../types";
|
import type { AiAdvice, AiVarietyScoreView } from "../../types";
|
||||||
|
|
||||||
withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
data: AiAdvice;
|
data: AiAdvice;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
aiScore?: AiVarietyScoreView | null;
|
||||||
}>(),
|
}>(),
|
||||||
{ loading: false },
|
{ loading: false, aiScore: null },
|
||||||
);
|
);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@ -52,6 +87,24 @@ const emit = defineEmits<{
|
|||||||
}>();
|
}>();
|
||||||
const expanded = ref(false);
|
const expanded = ref(false);
|
||||||
|
|
||||||
|
const scoreClass = computed(() => {
|
||||||
|
const s = props.aiScore?.score ?? 0;
|
||||||
|
if (s > 0) return "bull";
|
||||||
|
if (s < 0) return "bear";
|
||||||
|
return "flat";
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatSigned(n: number): string {
|
||||||
|
if (!Number.isFinite(n)) return "—";
|
||||||
|
return `${n > 0 ? "+" : ""}${n}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function judgeClass(judge: string): string {
|
||||||
|
if (judge.includes("多")) return "bull";
|
||||||
|
if (judge.includes("空")) return "bear";
|
||||||
|
return "flat";
|
||||||
|
}
|
||||||
|
|
||||||
function onRefresh() {
|
function onRefresh() {
|
||||||
emit("refresh");
|
emit("refresh");
|
||||||
}
|
}
|
||||||
@ -100,6 +153,45 @@ function onRefresh() {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.score {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-elevated, #f5f7fa);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score.bull {
|
||||||
|
color: #cf1322;
|
||||||
|
background: #fff1f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score.bear {
|
||||||
|
color: #389e0d;
|
||||||
|
background: #f6ffed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score.flat {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.best-tag {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 2px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #cf1322;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.text {
|
.text {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@ -132,6 +224,64 @@ function onRefresh() {
|
|||||||
margin: 10px 0 6px;
|
margin: 10px 0 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.score-detail {
|
||||||
|
margin: 8px 0 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-elevated, #f8f9fb);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-head .bull {
|
||||||
|
color: #cf1322;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-head .bear {
|
||||||
|
color: #389e0d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-pct {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.factors {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #3d4450;
|
||||||
|
}
|
||||||
|
|
||||||
|
.judge {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 2em;
|
||||||
|
margin-right: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.judge.bull {
|
||||||
|
color: #cf1322;
|
||||||
|
}
|
||||||
|
|
||||||
|
.judge.bear {
|
||||||
|
color: #389e0d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.factor-score {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-left: 18px;
|
padding-left: 18px;
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { computed, ref, toRaw } from 'vue'
|
import { computed, ref, toRaw } from 'vue'
|
||||||
import type {
|
import type {
|
||||||
|
AiVarietyScoreView,
|
||||||
Candle,
|
Candle,
|
||||||
MainForceTraceData,
|
MainForceTraceData,
|
||||||
NewsItem,
|
NewsItem,
|
||||||
@ -8,6 +9,11 @@ import type {
|
|||||||
PositionsData,
|
PositionsData,
|
||||||
QuoteData,
|
QuoteData,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
|
import {
|
||||||
|
fetchAiVarietyScores,
|
||||||
|
findVarietyScore,
|
||||||
|
} from '../api/jqka/aiExplain'
|
||||||
|
import type { AiVarietyScore } from '../api/jqka/aiExplainTypes'
|
||||||
import { useContractStore } from './contract'
|
import { useContractStore } from './contract'
|
||||||
import { getStockKline, getStockQuotation } from '../api/baidu/quotation'
|
import { getStockKline, getStockQuotation } from '../api/baidu/quotation'
|
||||||
import { getFuturesNews } from '../api/baidu/news'
|
import { getFuturesNews } from '../api/baidu/news'
|
||||||
@ -65,6 +71,8 @@ export interface AnalysisSnapshot {
|
|||||||
mainForceTrace: MainForceTraceData | null
|
mainForceTrace: MainForceTraceData | null
|
||||||
/** 主力异动六类排行(全市场,各合约共用) */
|
/** 主力异动六类排行(全市场,各合约共用) */
|
||||||
positionAnomaly: PositionAnomalyData | null
|
positionAnomaly: PositionAnomalyData | null
|
||||||
|
/** 当前合约对应的同花顺 AI 评分(含影响因素) */
|
||||||
|
aiVarietyScore: AiVarietyScoreView | null
|
||||||
fetchedAt: string
|
fetchedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -278,6 +286,13 @@ export const useQuotaStore = defineStore('quota', () => {
|
|||||||
const positionAnomalyLoading = ref(false)
|
const positionAnomalyLoading = ref(false)
|
||||||
const positionAnomalyError = ref<unknown>(null)
|
const positionAnomalyError = ref<unknown>(null)
|
||||||
|
|
||||||
|
// ─── 同花顺 AI 品种评分(全品种列表,按当前合约匹配)─────
|
||||||
|
const aiVarietyScores = ref<AiVarietyScore[]>([])
|
||||||
|
const aiVarietyScore = ref<AiVarietyScoreView | null>(null)
|
||||||
|
const aiVarietyScoreLoading = ref(false)
|
||||||
|
const aiVarietyScoreError = ref<unknown>(null)
|
||||||
|
const aiVarietyScoresLoaded = ref(false)
|
||||||
|
|
||||||
/** 是否已有至少一次成功的行情拉取(可用于 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)
|
||||||
@ -306,6 +321,52 @@ export const useQuotaStore = defineStore('quota', () => {
|
|||||||
positionAnomaly.value.shortToLong.length > 0 ||
|
positionAnomaly.value.shortToLong.length > 0 ||
|
||||||
positionAnomaly.value.longToShort.length > 0,
|
positionAnomaly.value.longToShort.length > 0,
|
||||||
)
|
)
|
||||||
|
const hasAiVarietyScore = computed(() => aiVarietyScore.value != null)
|
||||||
|
|
||||||
|
function resolveCurrentAiScore(list: AiVarietyScore[]): AiVarietyScoreView | null {
|
||||||
|
const c = activeContract()
|
||||||
|
const variety = (c.variety || extractVariety(c.code)).toUpperCase()
|
||||||
|
const hit = findVarietyScore(list, variety)
|
||||||
|
if (!hit) return null
|
||||||
|
return {
|
||||||
|
score: hit.score,
|
||||||
|
percent: hit.percent,
|
||||||
|
varietyName: hit.varietyName,
|
||||||
|
mainContract: hit.mainContract,
|
||||||
|
variety: hit.variety,
|
||||||
|
factors: hit.factors,
|
||||||
|
isBest: hit.isBest,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 刷新当前合约对应的 AI 评分(全品种列表已缓存时只做匹配) */
|
||||||
|
function bindCurrentAiScore() {
|
||||||
|
aiVarietyScore.value = resolveCurrentAiScore(aiVarietyScores.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉取全品种 AI 评分 + 推荐列表,并绑定当前合约。
|
||||||
|
* 列表本身走 axios localStorage 缓存,切换合约时复用。
|
||||||
|
*/
|
||||||
|
async function fetchAiVarietyScore(force = false) {
|
||||||
|
if (aiVarietyScoresLoaded.value && !force) {
|
||||||
|
bindCurrentAiScore()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
aiVarietyScoreLoading.value = true
|
||||||
|
aiVarietyScoreError.value = null
|
||||||
|
try {
|
||||||
|
const list = await fetchAiVarietyScores()
|
||||||
|
aiVarietyScores.value = list
|
||||||
|
aiVarietyScoresLoaded.value = true
|
||||||
|
bindCurrentAiScore()
|
||||||
|
} catch (e) {
|
||||||
|
aiVarietyScoreError.value = e
|
||||||
|
console.error('[quota] 拉取 AI 品种评分失败', e)
|
||||||
|
} finally {
|
||||||
|
aiVarietyScoreLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchQuote() {
|
async function fetchQuote() {
|
||||||
const c = activeContract()
|
const c = activeContract()
|
||||||
@ -467,13 +528,19 @@ export const useQuotaStore = defineStore('quota', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 并行刷新行情 + 新闻 + 持仓(K 线仍按需) */
|
/** 并行刷新行情 + 新闻 + 持仓 + AI 评分(K 线仍按需) */
|
||||||
async function fetchAll() {
|
async function fetchAll() {
|
||||||
await Promise.all([fetchQuote(), fetchNews(), fetchPositions()])
|
await Promise.all([
|
||||||
|
fetchQuote(),
|
||||||
|
fetchNews(),
|
||||||
|
fetchPositions(),
|
||||||
|
fetchAiVarietyScore(),
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 切换合约后:断开旧 WS、清空缓存、重新拉取并按需重连。
|
* 切换合约后:断开旧 WS、清空合约级缓存、重新拉取并按需重连。
|
||||||
|
* AI 全品种评分列表可复用,仅重新匹配当前合约。
|
||||||
*/
|
*/
|
||||||
async function reloadForContract() {
|
async function reloadForContract() {
|
||||||
quoteWs?.disconnect()
|
quoteWs?.disconnect()
|
||||||
@ -485,6 +552,7 @@ export const useQuotaStore = defineStore('quota', () => {
|
|||||||
positions.value = emptyPositions()
|
positions.value = emptyPositions()
|
||||||
mainForceTrace.value = emptyMainForceTrace()
|
mainForceTrace.value = emptyMainForceTrace()
|
||||||
positionAnomaly.value = emptyPositionAnomaly()
|
positionAnomaly.value = emptyPositionAnomaly()
|
||||||
|
aiVarietyScore.value = null
|
||||||
loadedKlines.value = { day: false, week: false, month: false }
|
loadedKlines.value = { day: false, week: false, month: false }
|
||||||
newsLoaded.value = false
|
newsLoaded.value = false
|
||||||
positionsLoaded.value = false
|
positionsLoaded.value = false
|
||||||
@ -495,6 +563,7 @@ export const useQuotaStore = defineStore('quota', () => {
|
|||||||
positionsError.value = null
|
positionsError.value = null
|
||||||
mainForceTraceError.value = null
|
mainForceTraceError.value = null
|
||||||
positionAnomalyError.value = null
|
positionAnomalyError.value = null
|
||||||
|
aiVarietyScoreError.value = null
|
||||||
|
|
||||||
await fetchAll()
|
await fetchAll()
|
||||||
}
|
}
|
||||||
@ -502,10 +571,10 @@ export const useQuotaStore = defineStore('quota', () => {
|
|||||||
/**
|
/**
|
||||||
* AI 分析前取数:行情每次重新拉取;
|
* AI 分析前取数:行情每次重新拉取;
|
||||||
* 新闻 / 机构持仓若已有成功数据则跳过,避免重复请求。
|
* 新闻 / 机构持仓若已有成功数据则跳过,避免重复请求。
|
||||||
* (主力追踪 / 主力异动随持仓一并拉取)
|
* (主力追踪 / 主力异动随持仓一并拉取;AI 评分一并带上)
|
||||||
*/
|
*/
|
||||||
async function fetchForAnalysis() {
|
async function fetchForAnalysis() {
|
||||||
const tasks: Promise<void>[] = [fetchQuote()]
|
const tasks: Promise<void>[] = [fetchQuote(), fetchAiVarietyScore()]
|
||||||
if (!newsLoaded.value) tasks.push(fetchNews())
|
if (!newsLoaded.value) tasks.push(fetchNews())
|
||||||
if (
|
if (
|
||||||
!positionsLoaded.value ||
|
!positionsLoaded.value ||
|
||||||
@ -546,6 +615,9 @@ export const useQuotaStore = defineStore('quota', () => {
|
|||||||
positionAnomaly: hasPositionAnomaly.value
|
positionAnomaly: hasPositionAnomaly.value
|
||||||
? clonePlain(positionAnomaly.value)
|
? clonePlain(positionAnomaly.value)
|
||||||
: null,
|
: null,
|
||||||
|
aiVarietyScore: aiVarietyScore.value
|
||||||
|
? clonePlain(aiVarietyScore.value)
|
||||||
|
: null,
|
||||||
fetchedAt: new Date().toISOString(),
|
fetchedAt: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -569,6 +641,9 @@ export const useQuotaStore = defineStore('quota', () => {
|
|||||||
positionAnomaly,
|
positionAnomaly,
|
||||||
positionAnomalyLoading,
|
positionAnomalyLoading,
|
||||||
positionAnomalyError,
|
positionAnomalyError,
|
||||||
|
aiVarietyScore,
|
||||||
|
aiVarietyScoreLoading,
|
||||||
|
aiVarietyScoreError,
|
||||||
wsStatus,
|
wsStatus,
|
||||||
wsInSession,
|
wsInSession,
|
||||||
// computed
|
// computed
|
||||||
@ -577,15 +652,18 @@ export const useQuotaStore = defineStore('quota', () => {
|
|||||||
hasPositions,
|
hasPositions,
|
||||||
hasMainForceTrace,
|
hasMainForceTrace,
|
||||||
hasPositionAnomaly,
|
hasPositionAnomaly,
|
||||||
|
hasAiVarietyScore,
|
||||||
newsLoaded,
|
newsLoaded,
|
||||||
positionsLoaded,
|
positionsLoaded,
|
||||||
mainForceTraceLoaded,
|
mainForceTraceLoaded,
|
||||||
positionAnomalyLoaded,
|
positionAnomalyLoaded,
|
||||||
|
aiVarietyScoresLoaded,
|
||||||
// actions
|
// actions
|
||||||
fetchQuote,
|
fetchQuote,
|
||||||
loadKline,
|
loadKline,
|
||||||
fetchNews,
|
fetchNews,
|
||||||
fetchPositions,
|
fetchPositions,
|
||||||
|
fetchAiVarietyScore,
|
||||||
fetchAll,
|
fetchAll,
|
||||||
fetchForAnalysis,
|
fetchForAnalysis,
|
||||||
reloadForContract,
|
reloadForContract,
|
||||||
|
|||||||
@ -279,6 +279,22 @@ export interface PositionAnomalySection {
|
|||||||
|
|
||||||
export type AdviceDirection = 'long' | 'short' | 'neutral'
|
export type AdviceDirection = 'long' | 'short' | 'neutral'
|
||||||
|
|
||||||
|
/** 同花顺 AI 品种评分(展示 / 分析用) */
|
||||||
|
export interface AiVarietyScoreView {
|
||||||
|
score: number
|
||||||
|
percent: number
|
||||||
|
varietyName: string
|
||||||
|
mainContract: string
|
||||||
|
variety: string
|
||||||
|
factors: Array<{
|
||||||
|
factor: string
|
||||||
|
judge: string
|
||||||
|
score: number
|
||||||
|
}>
|
||||||
|
/** 是否在推荐列表(最佳) */
|
||||||
|
isBest: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface AiAdvice {
|
export interface AiAdvice {
|
||||||
action: string
|
action: string
|
||||||
direction: AdviceDirection
|
direction: AdviceDirection
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user