AI定时分析

This commit is contained in:
dongzp 2026-07-22 10:18:51 +08:00
parent 8fe2373b2d
commit 27c957d3fb
5 changed files with 225 additions and 15 deletions

View File

@ -24,6 +24,25 @@
placeholder="用于规定 AI 角色与分析增强,多个词用逗号或换行分隔" placeholder="用于规定 AI 角色与分析增强,多个词用逗号或换行分隔"
/> />
</el-form-item> </el-form-item>
<el-form-item label="定时分析">
<div class="schedule-row">
<el-switch v-model="localScheduledEnabled" active-text="开启" inactive-text="关闭" />
<template v-if="localScheduledEnabled">
<span class="schedule-label">每</span>
<el-input-number
v-model="localScheduledMinutes"
:min="1"
:max="1440"
:step="1"
controls-position="right"
/>
<span class="schedule-label">分钟分析一次</span>
</template>
</div>
<p class="schedule-hint">
开启后仅在开盘时段(09:00–11:30 / 13:30–15:30 / 21:00–23:00)按间隔自动分析;出现买入/卖出信号时浏览器标题会闪烁提醒
</p>
</el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="emit('update:modelValue', false)">取消</el-button> <el-button @click="emit('update:modelValue', false)">取消</el-button>
@ -35,7 +54,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useSettingsStore, DEFAULT_KEYWORDS } from '../../stores/settings' import {
useSettingsStore,
DEFAULT_KEYWORDS,
DEFAULT_SCHEDULE_MINUTES,
} from '../../stores/settings'
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@ -50,6 +73,8 @@ const emit = defineEmits<{
const settings = useSettingsStore() const settings = useSettingsStore()
const localKey = ref('') const localKey = ref('')
const localKeywords = ref('') const localKeywords = ref('')
const localScheduledEnabled = ref(false)
const localScheduledMinutes = ref(DEFAULT_SCHEDULE_MINUTES)
watch( watch(
() => props.modelValue, () => props.modelValue,
@ -57,6 +82,9 @@ watch(
if (open) { if (open) {
localKey.value = settings.apiKey localKey.value = settings.apiKey
localKeywords.value = settings.keywords || DEFAULT_KEYWORDS localKeywords.value = settings.keywords || DEFAULT_KEYWORDS
localScheduledEnabled.value = settings.scheduledAnalysisEnabled
localScheduledMinutes.value =
settings.scheduledAnalysisMinutes || DEFAULT_SCHEDULE_MINUTES
} }
}, },
) )
@ -64,7 +92,34 @@ watch(
function save() { function save() {
settings.apiKey = localKey.value.trim() settings.apiKey = localKey.value.trim()
settings.keywords = localKeywords.value.trim() || DEFAULT_KEYWORDS settings.keywords = localKeywords.value.trim() || DEFAULT_KEYWORDS
settings.scheduledAnalysisEnabled = localScheduledEnabled.value
settings.scheduledAnalysisMinutes = Math.max(
1,
Math.min(1440, Math.floor(localScheduledMinutes.value || DEFAULT_SCHEDULE_MINUTES)),
)
ElMessage.success('设置已保存到本地') ElMessage.success('设置已保存到本地')
emit('update:modelValue', false) emit('update:modelValue', false)
} }
</script> </script>
<style scoped>
.schedule-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
width: 100%;
}
.schedule-label {
color: var(--el-text-color-regular);
font-size: 14px;
}
.schedule-hint {
margin: 8px 0 0;
font-size: 12px;
line-height: 1.5;
color: var(--el-text-color-secondary);
}
</style>

View File

@ -1,10 +1,12 @@
import { ref } from 'vue' import { onUnmounted, ref, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import type { AiAdvice } from '../types' import type { AiAdvice } from '../types'
import { fetchDeepSeekAdvice } from '../api/deepseek/analyze' import { fetchDeepSeekAdvice } from '../api/deepseek/analyze'
import type { AiAnalysisPayload } from '../api/deepseek/types' import type { AiAnalysisPayload } from '../api/deepseek/types'
import { useQuotaStore, type KlinePeriod } from '../stores/quota' import { useQuotaStore, type KlinePeriod } from '../stores/quota'
import { useSettingsStore } from '../stores/settings' import { useSettingsStore } from '../stores/settings'
import { isWsTradingSession } from '../utils/tradingDate'
import { useTitleBlink } from './useTitleBlink'
const KLINE_PERIODS: KlinePeriod[] = ['day', 'week', 'month'] const KLINE_PERIODS: KlinePeriod[] = ['day', 'week', 'month']
@ -26,6 +28,10 @@ export function useAiAdvice() {
const quota = useQuotaStore() const quota = useQuotaStore()
const settings = useSettingsStore() const settings = useSettingsStore()
useTitleBlink(data)
let scheduleTimer: ReturnType<typeof setInterval> | null = null
/** 尽量补全日/周/月 K 线,失败不阻断主流程 */ /** 尽量补全日/周/月 K 线,失败不阻断主流程 */
async function ensureKlines() { async function ensureKlines() {
await Promise.all( await Promise.all(
@ -41,13 +47,19 @@ export function useAiAdvice() {
/** /**
* 一键获取建议:收集行情 / 新闻 / 持仓后提交 DeepSeek,返回真实 AI 建议。 * 一键获取建议:收集行情 / 新闻 / 持仓后提交 DeepSeek,返回真实 AI 建议。
* @param opts.silent 定时任务时静默:无 Key / 非开盘 / 进行中则跳过,成功不弹 toast
*/ */
async function refresh() { async function refresh(opts?: { silent?: boolean }) {
const silent = Boolean(opts?.silent)
// 定时分析仅在开盘时段执行(与行情 WS 时段一致)
if (silent && !isWsTradingSession()) return
const apiKey = settings.apiKey.trim() const apiKey = settings.apiKey.trim()
if (!apiKey) { if (!apiKey) {
ElMessage.warning('请先在设置中配置 DeepSeek API Key') if (!silent) ElMessage.warning('请先在设置中配置 DeepSeek API Key')
return return
} }
if (loading.value) return
loading.value = true loading.value = true
error.value = null error.value = null
@ -64,17 +76,45 @@ export function useAiAdvice() {
console.log('[AI] 提交给 DeepSeek 的数据快照', payload.snapshot) console.log('[AI] 提交给 DeepSeek 的数据快照', payload.snapshot)
data.value = await fetchDeepSeekAdvice(apiKey, payload) data.value = await fetchDeepSeekAdvice(apiKey, payload)
ElMessage.success('AI 建议已更新') if (!silent) ElMessage.success('AI 建议已更新')
} catch (e) { } catch (e) {
error.value = e error.value = e
const msg = formatError(e) const msg = formatError(e)
console.error('[AI] 一键获取建议失败', e) console.error('[AI] 一键获取建议失败', e)
ElMessage.error(msg) if (!silent) ElMessage.error(msg)
} finally { } finally {
loading.value = false loading.value = false
} }
} }
function clearSchedule() {
if (scheduleTimer) {
clearInterval(scheduleTimer)
scheduleTimer = null
}
}
function setupSchedule() {
clearSchedule()
if (!settings.scheduledAnalysisEnabled) return
const minutes = Math.max(1, settings.scheduledAnalysisMinutes || 5)
scheduleTimer = setInterval(
() => {
void refresh({ silent: true })
},
minutes * 60_000,
)
}
watch(
() =>
[settings.scheduledAnalysisEnabled, settings.scheduledAnalysisMinutes] as const,
() => setupSchedule(),
{ immediate: true },
)
onUnmounted(clearSchedule)
return { data, loading, error, refresh } return { data, loading, error, refresh }
} }

View File

@ -0,0 +1,89 @@
import { onUnmounted, watch, type Ref } from 'vue'
import type { AiAdvice } from '../types'
const DEFAULT_TITLE = 'AI 实时交易辅助系统'
const BLINK_MS = 800
function hasTradeSignal(advice: AiAdvice): boolean {
return advice.direction === 'long' || advice.direction === 'short'
}
function signalTitle(advice: AiAdvice): string {
const tag = advice.direction === 'long' ? '买入' : '卖出'
return `【${tag}信号】${advice.action} · ${DEFAULT_TITLE}`
}
/**
* 有多空交易信号时闪烁 document.title;页面获得焦点后停止,直至下一次新信号。
*/
export function useTitleBlink(advice: Ref<AiAdvice>) {
const baseTitle = document.title || DEFAULT_TITLE
let timer: ReturnType<typeof setInterval> | null = null
let showAlert = false
/** 用户已看过当前信号,不再闪烁,直到信号内容变化 */
let dismissedKey = ''
let activeKey = ''
function stopBlink() {
if (timer) {
clearInterval(timer)
timer = null
}
showAlert = false
activeKey = ''
document.title = baseTitle
}
function startBlink(alertTitle: string, key: string) {
stopBlink()
activeKey = key
showAlert = true
document.title = alertTitle
timer = setInterval(() => {
showAlert = !showAlert
document.title = showAlert ? alertTitle : baseTitle
}, BLINK_MS)
}
function signalKey(a: AiAdvice): string {
return `${a.direction}|${a.action}|${a.updatedAt}`
}
function sync() {
const a = advice.value
if (!hasTradeSignal(a)) {
stopBlink()
dismissedKey = ''
return
}
const key = signalKey(a)
if (key === dismissedKey) {
stopBlink()
return
}
if (timer && activeKey === key) return
startBlink(signalTitle(a), key)
}
function onFocus() {
const a = advice.value
if (hasTradeSignal(a) && timer) {
dismissedKey = signalKey(a)
}
stopBlink()
}
function onVisibility() {
if (document.visibilityState === 'visible') onFocus()
}
watch(advice, sync, { deep: true, immediate: true })
window.addEventListener('focus', onFocus)
document.addEventListener('visibilitychange', onVisibility)
onUnmounted(() => {
stopBlink()
window.removeEventListener('focus', onFocus)
document.removeEventListener('visibilitychange', onVisibility)
})
}

View File

@ -8,6 +8,9 @@ const STORAGE_KEY = 'ai-trade-settings'
export const DEFAULT_KEYWORDS = export const DEFAULT_KEYWORDS =
'你是一名专业的国内期货交易员和分析员,精通机构操盘手法,能够根据持仓与交易数据分析当前走势。' '你是一名专业的国内期货交易员和分析员,精通机构操盘手法,能够根据持仓与交易数据分析当前走势。'
/** 定时分析默认间隔(分钟) */
export const DEFAULT_SCHEDULE_MINUTES = 5
function load(): Partial<AppSettings> { function load(): Partial<AppSettings> {
try { try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') as Partial<AppSettings> return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') as Partial<AppSettings>
@ -16,18 +19,37 @@ function load(): Partial<AppSettings> {
} }
} }
function clampMinutes(n: unknown): number {
const v = typeof n === 'number' ? n : Number(n)
if (!Number.isFinite(v) || v < 1) return DEFAULT_SCHEDULE_MINUTES
return Math.min(1440, Math.floor(v))
}
export const useSettingsStore = defineStore('settings', () => { export const useSettingsStore = defineStore('settings', () => {
const saved = load() const saved = load()
const apiKey = ref(saved.apiKey || '') const apiKey = ref(saved.apiKey || '')
const keywords = ref(saved.keywords || DEFAULT_KEYWORDS) const keywords = ref(saved.keywords || DEFAULT_KEYWORDS)
const scheduledAnalysisEnabled = ref(Boolean(saved.scheduledAnalysisEnabled))
const scheduledAnalysisMinutes = ref(clampMinutes(saved.scheduledAnalysisMinutes))
watch([apiKey, keywords], () => { watch(
[apiKey, keywords, scheduledAnalysisEnabled, scheduledAnalysisMinutes],
() => {
const payload: AppSettings = { const payload: AppSettings = {
apiKey: apiKey.value, apiKey: apiKey.value,
keywords: keywords.value, keywords: keywords.value,
scheduledAnalysisEnabled: scheduledAnalysisEnabled.value,
scheduledAnalysisMinutes: clampMinutes(scheduledAnalysisMinutes.value),
} }
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)) localStorage.setItem(STORAGE_KEY, JSON.stringify(payload))
}, { immediate: true }) },
{ immediate: true },
)
return { apiKey, keywords } return {
apiKey,
keywords,
scheduledAnalysisEnabled,
scheduledAnalysisMinutes,
}
}) })

View File

@ -137,4 +137,8 @@ export interface AiAdvice {
export interface AppSettings { export interface AppSettings {
apiKey: string apiKey: string
keywords: string keywords: string
/** 是否开启定时分析 */
scheduledAnalysisEnabled: boolean
/** 定时分析间隔(分钟) */
scheduledAnalysisMinutes: number
} }