新增AI评分
This commit is contained in:
parent
d88fc616f3
commit
4738c1c57a
@ -73,6 +73,7 @@
|
||||
v-if="advice"
|
||||
:data="advice"
|
||||
:loading="adviceLoading"
|
||||
:ai-score="aiVarietyScore"
|
||||
@refresh="refreshAdvice"
|
||||
/>
|
||||
</template>
|
||||
@ -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()
|
||||
|
||||
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 = [
|
||||
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 [
|
||||
|
||||
@ -4,7 +4,7 @@ import type { AnalysisSnapshot } from '../../stores/quota'
|
||||
export interface AiAnalysisPayload {
|
||||
/** 设置中的角色/分析关键词 */
|
||||
keywords: string
|
||||
/** 行情 + 新闻 + 机构持仓(含主力分析)+ 主力追踪 + 主力异动等接口快照 */
|
||||
/** 行情 + 新闻 + 机构持仓(含主力分析)+ 主力追踪 + 主力异动 + AI 评分等接口快照 */
|
||||
snapshot: AnalysisSnapshot
|
||||
/** 大单手数阈值,与 UI 大单分析一致 */
|
||||
largeOrderLots: number
|
||||
|
||||
@ -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',
|
||||
|
||||
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">
|
||||
<span class="badge" :class="data.direction">AI</span>
|
||||
<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="meta">
|
||||
置信度 {{ data.confidence }}%
|
||||
@ -27,7 +36,32 @@
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<li v-for="(r, i) in data.reasons" :key="i">{{ r }}</li>
|
||||
</ul>
|
||||
@ -36,15 +70,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import type { AiAdvice } from "../../types";
|
||||
import { computed, ref } from "vue";
|
||||
import type { AiAdvice, AiVarietyScoreView } from "../../types";
|
||||
|
||||
withDefaults(
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
data: AiAdvice;
|
||||
loading?: boolean;
|
||||
aiScore?: AiVarietyScoreView | null;
|
||||
}>(),
|
||||
{ loading: false },
|
||||
{ loading: false, aiScore: null },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
@ -52,6 +87,24 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
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() {
|
||||
emit("refresh");
|
||||
}
|
||||
@ -100,6 +153,45 @@ function onRefresh() {
|
||||
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 {
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
@ -132,6 +224,64 @@ function onRefresh() {
|
||||
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 {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, toRaw } from 'vue'
|
||||
import type {
|
||||
AiVarietyScoreView,
|
||||
Candle,
|
||||
MainForceTraceData,
|
||||
NewsItem,
|
||||
@ -8,6 +9,11 @@ import type {
|
||||
PositionsData,
|
||||
QuoteData,
|
||||
} from '../types'
|
||||
import {
|
||||
fetchAiVarietyScores,
|
||||
findVarietyScore,
|
||||
} from '../api/jqka/aiExplain'
|
||||
import type { AiVarietyScore } from '../api/jqka/aiExplainTypes'
|
||||
import { useContractStore } from './contract'
|
||||
import { getStockKline, getStockQuotation } from '../api/baidu/quotation'
|
||||
import { getFuturesNews } from '../api/baidu/news'
|
||||
@ -65,6 +71,8 @@ export interface AnalysisSnapshot {
|
||||
mainForceTrace: MainForceTraceData | null
|
||||
/** 主力异动六类排行(全市场,各合约共用) */
|
||||
positionAnomaly: PositionAnomalyData | null
|
||||
/** 当前合约对应的同花顺 AI 评分(含影响因素) */
|
||||
aiVarietyScore: AiVarietyScoreView | null
|
||||
fetchedAt: string
|
||||
}
|
||||
|
||||
@ -278,6 +286,13 @@ export const useQuotaStore = defineStore('quota', () => {
|
||||
const positionAnomalyLoading = ref(false)
|
||||
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 门禁) */
|
||||
const hasQuote = computed(() => quote.value.last > 0 && quote.value.intraday.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.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() {
|
||||
const c = activeContract()
|
||||
@ -467,13 +528,19 @@ export const useQuotaStore = defineStore('quota', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 并行刷新行情 + 新闻 + 持仓(K 线仍按需) */
|
||||
/** 并行刷新行情 + 新闻 + 持仓 + AI 评分(K 线仍按需) */
|
||||
async function fetchAll() {
|
||||
await Promise.all([fetchQuote(), fetchNews(), fetchPositions()])
|
||||
await Promise.all([
|
||||
fetchQuote(),
|
||||
fetchNews(),
|
||||
fetchPositions(),
|
||||
fetchAiVarietyScore(),
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换合约后:断开旧 WS、清空缓存、重新拉取并按需重连。
|
||||
* 切换合约后:断开旧 WS、清空合约级缓存、重新拉取并按需重连。
|
||||
* AI 全品种评分列表可复用,仅重新匹配当前合约。
|
||||
*/
|
||||
async function reloadForContract() {
|
||||
quoteWs?.disconnect()
|
||||
@ -485,6 +552,7 @@ export const useQuotaStore = defineStore('quota', () => {
|
||||
positions.value = emptyPositions()
|
||||
mainForceTrace.value = emptyMainForceTrace()
|
||||
positionAnomaly.value = emptyPositionAnomaly()
|
||||
aiVarietyScore.value = null
|
||||
loadedKlines.value = { day: false, week: false, month: false }
|
||||
newsLoaded.value = false
|
||||
positionsLoaded.value = false
|
||||
@ -495,6 +563,7 @@ export const useQuotaStore = defineStore('quota', () => {
|
||||
positionsError.value = null
|
||||
mainForceTraceError.value = null
|
||||
positionAnomalyError.value = null
|
||||
aiVarietyScoreError.value = null
|
||||
|
||||
await fetchAll()
|
||||
}
|
||||
@ -502,10 +571,10 @@ export const useQuotaStore = defineStore('quota', () => {
|
||||
/**
|
||||
* AI 分析前取数:行情每次重新拉取;
|
||||
* 新闻 / 机构持仓若已有成功数据则跳过,避免重复请求。
|
||||
* (主力追踪 / 主力异动随持仓一并拉取)
|
||||
* (主力追踪 / 主力异动随持仓一并拉取;AI 评分一并带上)
|
||||
*/
|
||||
async function fetchForAnalysis() {
|
||||
const tasks: Promise<void>[] = [fetchQuote()]
|
||||
const tasks: Promise<void>[] = [fetchQuote(), fetchAiVarietyScore()]
|
||||
if (!newsLoaded.value) tasks.push(fetchNews())
|
||||
if (
|
||||
!positionsLoaded.value ||
|
||||
@ -546,6 +615,9 @@ export const useQuotaStore = defineStore('quota', () => {
|
||||
positionAnomaly: hasPositionAnomaly.value
|
||||
? clonePlain(positionAnomaly.value)
|
||||
: null,
|
||||
aiVarietyScore: aiVarietyScore.value
|
||||
? clonePlain(aiVarietyScore.value)
|
||||
: null,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
@ -569,6 +641,9 @@ export const useQuotaStore = defineStore('quota', () => {
|
||||
positionAnomaly,
|
||||
positionAnomalyLoading,
|
||||
positionAnomalyError,
|
||||
aiVarietyScore,
|
||||
aiVarietyScoreLoading,
|
||||
aiVarietyScoreError,
|
||||
wsStatus,
|
||||
wsInSession,
|
||||
// computed
|
||||
@ -577,15 +652,18 @@ export const useQuotaStore = defineStore('quota', () => {
|
||||
hasPositions,
|
||||
hasMainForceTrace,
|
||||
hasPositionAnomaly,
|
||||
hasAiVarietyScore,
|
||||
newsLoaded,
|
||||
positionsLoaded,
|
||||
mainForceTraceLoaded,
|
||||
positionAnomalyLoaded,
|
||||
aiVarietyScoresLoaded,
|
||||
// actions
|
||||
fetchQuote,
|
||||
loadKline,
|
||||
fetchNews,
|
||||
fetchPositions,
|
||||
fetchAiVarietyScore,
|
||||
fetchAll,
|
||||
fetchForAnalysis,
|
||||
reloadForContract,
|
||||
|
||||
@ -279,6 +279,22 @@ export interface PositionAnomalySection {
|
||||
|
||||
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 {
|
||||
action: string
|
||||
direction: AdviceDirection
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user