feat: 添加新闻监控、持仓管理、系统日志等功能

- 新增新闻爬取和监控模块 (news_crawler, news_monitor)
- 新增 LLM 分析模块 (llm_analyzer)
- 新增持仓管理和交易历史存储
- 新增系统日志功能
- 新增前端页面: News, Positions, Settings, SystemLog
- 更新路由和 API 接口
- 更新 .gitignore 排除敏感文件
This commit is contained in:
guaiwoluo2020
2026-03-17 11:32:37 +08:00
parent 51b2f30748
commit a705593955
33 changed files with 10667 additions and 948 deletions
+5 -1
View File
@@ -2,7 +2,7 @@
<v-app>
<v-app-bar app color="primary" dark>
<v-app-bar-nav-icon @click="drawer = !drawer"></v-app-bar-nav-icon>
<v-toolbar-title>量化交易系统</v-toolbar-title>
<v-toolbar-title>AITrader</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn icon>
<v-icon>mdi-refresh</v-icon>
@@ -44,8 +44,12 @@ export default {
{ title: '仪表板', path: '/', icon: 'mdi-view-dashboard' },
{ title: '交易指令', path: '/trades', icon: 'mdi-format-list-bulleted' },
{ title: '行情分析', path: '/market', icon: 'mdi-chart-candlestick' },
{ title: '仓位管理', path: '/positions', icon: 'mdi-chart-box' },
{ title: '财经日历', path: '/news', icon: 'mdi-newspaper-variant-outline' },
{ title: '统计数据', path: '/statistics', icon: 'mdi-chart-line' },
{ title: '服务状态', path: '/status', icon: 'mdi-information' },
{ title: '系统设置', path: '/settings', icon: 'mdi-cog' },
{ title: '运行日志', path: '/logs', icon: 'mdi-text-box-outline' },
]
return {
+86
View File
@@ -143,6 +143,92 @@ export const marketAPI = {
async closePosition(ticket, symbol) {
const response = await api.post('/close_position', { ticket, symbol })
return response.data
},
// ==================== 大模型分析 ====================
// 获取大模型分析结果
async getLLMAnalysis(symbol = null) {
const params = symbol ? { symbol } : {}
const response = await api.get('/llm/analysis', { params })
return response.data
},
// 获取大模型分析器状态
async getLLMStatus() {
const response = await api.get('/llm/status')
return response.data
},
// 获取大模型配置
async getLLMConfig() {
const response = await api.get('/llm/config')
return response.data
},
// 手动触发大模型分析
async triggerLLMAnalysis() {
const response = await api.post('/llm/trigger')
return response.data
},
// 配置大模型参数
async configureLLM(config) {
const response = await api.post('/llm/configure', config)
return response.data
},
// 获取已配置品种的K线数据状态
async getConfiguredSymbols() {
const response = await api.get('/market/configured_symbols')
return response.data
},
// 获取系统运行日志
async getSystemLogs(count = 50, eventTypes = null, symbol = null) {
const params = { count }
if (eventTypes && eventTypes.length > 0) {
params.event_type = eventTypes.join(',')
}
if (symbol) params.symbol = symbol
const response = await api.get('/system/logs', { params })
return response.data
},
// 清空系统日志
async clearSystemLogs() {
const response = await api.delete('/system/logs')
return response.data
},
// ==================== 仓位管理 ====================
// 获取持仓数据
async getPositions(symbol = null) {
const params = symbol ? { symbol } : {}
const response = await api.get('/positions', { params })
return response.data
},
// 获取持仓汇总
async getPositionsSummary(symbol = null) {
const params = symbol ? { symbol } : {}
const response = await api.get('/positions/summary', { params })
return response.data
},
// ==================== 交易历史 ====================
// 获取交易历史
async getTradeHistory() {
const response = await api.get('/trade_history')
return response.data
},
// 获取交易历史统计
async getTradeHistoryStatistics() {
const response = await api.get('/trade_history/statistics')
return response.data
}
}
+24
View File
@@ -4,6 +4,10 @@ import TradeOrders from '../views/TradeOrders.vue'
import Statistics from '../views/Statistics.vue'
import Status from '../views/Status.vue'
import Market from '../views/Market.vue'
import Settings from '../views/Settings.vue'
import SystemLog from '../views/SystemLog.vue'
import Positions from '../views/Positions.vue'
import News from '../views/News.vue'
const routes = [
{
@@ -30,6 +34,26 @@ const routes = [
path: '/market',
name: 'Market',
component: Market
},
{
path: '/positions',
name: 'Positions',
component: Positions
},
{
path: '/news',
name: 'News',
component: News
},
{
path: '/settings',
name: 'Settings',
component: Settings
},
{
path: '/logs',
name: 'SystemLog',
component: SystemLog
}
]
File diff suppressed because it is too large Load Diff
+667
View File
@@ -0,0 +1,667 @@
<template>
<v-container fluid>
<!-- 页面标题 -->
<v-row class="mb-4">
<v-col cols="12">
<h2 class="text-h4">
<v-icon large class="mr-2">mdi-newspaper-variant-outline</v-icon>
财经日历与新闻
</h2>
</v-col>
</v-row>
<!-- 状态卡片 -->
<v-row class="mb-4">
<v-col cols="12" md="3">
<v-card outlined>
<v-card-text class="d-flex align-center">
<v-icon large color="primary" class="mr-3">mdi-calendar-check</v-icon>
<div>
<div class="text-caption text-grey">日历天数</div>
<div class="text-h5">{{ status.store_status?.calendar_dates || 0 }}</div>
</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card outlined>
<v-card-text class="d-flex align-center">
<v-icon large color="warning" class="mr-3">mdi-alert-circle</v-icon>
<div>
<div class="text-caption text-grey">重要事件</div>
<div class="text-h5">{{ status.store_status?.calendar_events || 0 }}</div>
</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card outlined>
<v-card-text class="d-flex align-center">
<v-icon large color="info" class="mr-3">mdi-lightning-bolt</v-icon>
<div>
<div class="text-caption text-grey">快讯数量</div>
<div class="text-h5">{{ status.store_status?.flash_news_count || 0 }}</div>
</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card outlined>
<v-card-text class="d-flex align-center">
<v-icon large :color="status.running ? 'success' : 'error'" class="mr-3">
{{ status.running ? 'mdi-check-circle' : 'mdi-close-circle' }}
</v-icon>
<div>
<div class="text-caption text-grey">监控状态</div>
<div class="text-h5">{{ status.running ? '运行中' : '已停止' }}</div>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- 标签页 -->
<v-card>
<v-tabs v-model="activeTab" background-color="primary" dark>
<v-tab>
<v-icon class="mr-2">mdi-calendar</v-icon>
财经日历
</v-tab>
<v-tab>
<v-icon class="mr-2">mdi-lightning-bolt</v-icon>
实时快讯
</v-tab>
<v-tab>
<v-icon class="mr-2">mdi-chart-timeline-variant</v-icon>
品种影响
</v-tab>
</v-tabs>
<!-- 财经日历 -->
<v-tab-item>
<v-card-text>
<!-- 筛选栏 -->
<v-row class="mb-4">
<v-col cols="12" md="4">
<v-select
v-model="selectedImportance"
:items="importanceOptions"
label="重要性筛选"
outlined
dense
hide-details
clearable
></v-select>
</v-col>
<v-col cols="12" md="4">
<v-select
v-model="selectedCountry"
:items="countryOptions"
label="国家筛选"
outlined
dense
hide-details
clearable
></v-select>
</v-col>
<v-col cols="12" md="4">
<v-btn color="primary" @click="fetchCalendar" :loading="loading">
<v-icon class="mr-2">mdi-refresh</v-icon>
刷新
</v-btn>
</v-col>
</v-row>
<!-- 事件列表 -->
<v-data-table
:headers="calendarHeaders"
:items="filteredCalendar"
:loading="loading"
item-key="id"
class="elevation-1"
:items-per-page="20"
>
<!-- 重要性 -->
<template v-slot:item.importance="{ item }">
<v-chip
:color="getImportanceColor(item.importance)"
small
dark
>
{{ getImportanceText(item.importance) }}
</v-chip>
</template>
<!-- 发布时间 -->
<template v-slot:item.publish_time="{ item }">
<div>
<div class="font-weight-medium">{{ formatDate(item.publish_time) }}</div>
<div class="text-caption text-grey">{{ formatTime(item.publish_time) }}</div>
</div>
</template>
<!-- 影响品种 -->
<template v-slot:item.symbols="{ item }">
<v-chip
v-for="symbol in item.symbols"
:key="symbol"
:color="getSymbolColor(symbol)"
small
class="mr-1"
>
{{ symbol }}
</v-chip>
</template>
<!-- 数值 -->
<template v-slot:item.values="{ item }">
<div class="text-caption">
<div>预期: <span class="font-weight-medium">{{ item.forecast || '--' }}</span></div>
<div>前值: <span class="text-grey">{{ item.previous || '--' }}</span></div>
<div v-if="item.actual" class="success--text">
实际: {{ item.actual }}
</div>
</div>
</template>
<!-- 结果 -->
<template v-slot:item.result="{ item }">
<v-chip
v-if="item.result"
:color="getResultColor(item.result)"
small
dark
>
{{ getResultText(item.result) }}
</v-chip>
<span v-else class="text-grey">待发布</span>
</template>
</v-data-table>
</v-card-text>
</v-tab-item>
<!-- 实时快讯 -->
<v-tab-item>
<v-card-text>
<v-btn color="primary" class="mb-4" @click="fetchFlashNews" :loading="loading">
<v-icon class="mr-2">mdi-refresh</v-icon>
刷新快讯
</v-btn>
<v-timeline v-if="flashNews.length > 0" dense>
<v-timeline-item
v-for="news in flashNews"
:key="news.id"
:color="news.importance >= 2 ? 'error' : 'info'"
small
>
<v-card outlined class="mb-2">
<v-card-text>
<div class="d-flex justify-space-between align-start">
<div class="flex-grow-1">
<!-- 讲话者标签 -->
<v-chip
v-if="news.speaker"
color="primary"
small
class="mr-2 mb-2"
>
<v-icon small class="mr-1">mdi-account</v-icon>
{{ news.speaker }}
<span v-if="news.speaker_title" class="ml-1">({{ news.speaker_title }})</span>
</v-chip>
<!-- 内容 -->
<div class="text-body-1 mb-2">{{ news.content }}</div>
<!-- 影响分析 -->
<div v-if="news.impact && Object.keys(news.impact).length > 0" class="mt-2">
<div class="text-caption text-grey mb-1">影响分析:</div>
<v-chip
v-for="(impact, symbol) in news.impact"
:key="symbol"
:color="getImpactColor(impact.direction)"
small
class="mr-1 mb-1"
>
{{ symbol }}: {{ impact.direction }}
<span v-if="impact.reason" class="ml-1">- {{ impact.reason }}</span>
</v-chip>
</div>
</div>
<div class="text-caption text-grey ml-4">
{{ formatDateTime(news.time) }}
</div>
</div>
</v-card-text>
</v-card>
</v-timeline-item>
</v-timeline>
<v-alert v-else type="info" text>
暂无快讯数据
</v-alert>
</v-card-text>
</v-tab-item>
<!-- 品种影响 -->
<v-tab-item>
<v-card-text>
<v-row class="mb-4">
<v-col cols="12" md="6">
<v-select
v-model="selectedSymbol"
:items="symbolOptions"
label="选择品种"
outlined
@change="fetchSymbolImpact"
></v-select>
</v-col>
</v-row>
<v-row v-if="symbolEvents.length > 0">
<v-col
v-for="event in symbolEvents"
:key="event.id"
cols="12"
md="6"
>
<v-card outlined class="mb-3">
<v-card-text>
<div class="d-flex justify-space-between align-start mb-2">
<div>
<v-chip
:color="getImportanceColor(event.importance)"
small
dark
class="mr-2"
>
{{ getImportanceText(event.importance) }}
</v-chip>
<span class="font-weight-medium">{{ event.name }}</span>
</div>
<v-chip small>{{ event.country }}</v-chip>
</div>
<div class="text-caption text-grey mb-2">
{{ formatDateTime(event.publish_time) }}
</div>
<v-row>
<v-col cols="4">
<div class="text-caption text-grey">预期</div>
<div class="font-weight-medium">{{ event.forecast || '--' }}</div>
</v-col>
<v-col cols="4">
<div class="text-caption text-grey">前值</div>
<div class="font-weight-medium">{{ event.previous || '--' }}</div>
</v-col>
<v-col cols="4">
<div class="text-caption text-grey">实际</div>
<div class="font-weight-medium success--text">{{ event.actual || '待发布' }}</div>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-col>
</v-row>
<v-alert v-else-if="selectedSymbol" type="info" text>
该品种暂无即将发布的重要事件
</v-alert>
</v-card-text>
</v-tab-item>
</v-card>
<!-- 新闻提醒弹窗 -->
<v-snackbar
v-model="snackbar.show"
:color="snackbar.color"
:timeout="5000"
top
right
>
<v-icon class="mr-2">{{ snackbar.icon }}</v-icon>
{{ snackbar.message }}
<template v-slot:action>
<v-btn text @click="snackbar.show = false">关闭</v-btn>
</template>
</v-snackbar>
</v-container>
</template>
<script>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import axios from 'axios'
export default {
name: 'News',
setup() {
const activeTab = ref(0)
const loading = ref(false)
const status = ref({
running: false,
ws_clients: 0,
store_status: {
calendar_dates: 0,
total_events: 0,
flash_news_count: 0
},
scheduled_events: 0
})
const calendar = ref([])
const flashNews = ref([])
const symbolEvents = ref([])
const selectedImportance = ref(null)
const selectedCountry = ref(null)
const selectedSymbol = ref('GOLD')
const ws = ref(null)
const snackbar = ref({
show: false,
color: 'info',
icon: 'mdi-bell',
message: ''
})
const importanceOptions = [
{ text: '高影响', value: 3 },
{ text: '中等影响', value: 2 },
{ text: '低影响', value: 1 }
]
const countryOptions = [
{ text: '美国 (US)', value: 'US' },
{ text: '日本 (JP)', value: 'JP' },
{ text: '欧洲 (EU)', value: 'EU' },
{ text: '英国 (UK)', value: 'UK' },
{ text: '中国 (CN)', value: 'CN' }
]
const symbolOptions = [
{ text: '黄金 (GOLD)', value: 'GOLD' },
{ text: '原油 (OIL)', value: 'OIL' },
{ text: '比特币 (BTC)', value: 'BTC' },
{ text: '标普500 (SPX)', value: 'SPX' },
{ text: '美日 (USDJPY)', value: 'USDJPY' }
]
const calendarHeaders = [
{ text: '重要性', value: 'importance', width: 100 },
{ text: '事件', value: 'name', width: 200 },
{ text: '国家', value: 'country', width: 80 },
{ text: '发布时间', value: 'publish_time', width: 150 },
{ text: '数值', value: 'values', width: 120 },
{ text: '结果', value: 'result', width: 100 },
{ text: '影响品种', value: 'symbols', width: 200 }
]
const filteredCalendar = computed(() => {
let items = calendar.value
if (selectedImportance.value) {
items = items.filter(e => e.importance === selectedImportance.value)
}
if (selectedCountry.value) {
items = items.filter(e => e.country === selectedCountry.value)
}
return items
})
// 获取状态
const fetchStatus = async () => {
try {
const response = await axios.get('/api/news/status')
status.value = response.data.data
} catch (error) {
console.error('获取状态失败:', error)
}
}
// 获取财经日历
const fetchCalendar = async () => {
loading.value = true
try {
const response = await axios.get('/api/news/upcoming', {
params: { hours: 168 } // 未来7天
})
calendar.value = response.data.data || []
} catch (error) {
console.error('获取日历失败:', error)
} finally {
loading.value = false
}
}
// 获取快讯
const fetchFlashNews = async () => {
loading.value = true
try {
const response = await axios.get('/api/news/flash', {
params: { count: 30 }
})
flashNews.value = response.data.data || []
} catch (error) {
console.error('获取快讯失败:', error)
} finally {
loading.value = false
}
}
// 获取品种影响
const fetchSymbolImpact = async () => {
if (!selectedSymbol.value) return
loading.value = true
try {
const response = await axios.get(`/api/news/impact/${selectedSymbol.value}`)
symbolEvents.value = response.data.data || []
} catch (error) {
console.error('获取品种影响失败:', error)
} finally {
loading.value = false
}
}
// WebSocket连接
const connectWebSocket = () => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = `${protocol}//${window.location.host}/api/news/ws`
ws.value = new WebSocket(wsUrl)
ws.value.onopen = () => {
console.log('新闻WebSocket已连接')
}
ws.value.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
handleWebSocketMessage(data)
} catch (error) {
console.error('解析WebSocket消息失败:', error)
}
}
ws.value.onerror = (error) => {
console.error('WebSocket错误:', error)
}
ws.value.onclose = () => {
console.log('新闻WebSocket已断开,5秒后重连...')
setTimeout(connectWebSocket, 5000)
}
}
// 处理WebSocket消息
const handleWebSocketMessage = (data) => {
switch (data.type) {
case 'event_reminder':
showNotification('warning', 'mdi-clock-alert', data.message)
fetchCalendar()
break
case 'event_result':
showNotification('success', 'mdi-check-circle', data.message)
fetchCalendar()
break
case 'flash_news':
showNotification('info', 'mdi-lightning-bolt', data.news?.content?.substring(0, 50) + '...')
fetchFlashNews()
break
case 'calendar_update':
fetchCalendar()
break
}
}
// 显示通知
const showNotification = (color, icon, message) => {
snackbar.value = {
show: true,
color,
icon,
message
}
}
// 格式化函数
const formatDate = (dateStr) => {
if (!dateStr) return '--'
const date = new Date(dateStr)
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
}
const formatTime = (dateStr) => {
if (!dateStr) return '--'
const date = new Date(dateStr)
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
const formatDateTime = (dateStr) => {
if (!dateStr) return '--'
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const getImportanceColor = (importance) => {
switch (importance) {
case 3: return 'error'
case 2: return 'warning'
case 1: return 'info'
default: return 'grey'
}
}
const getImportanceText = (importance) => {
switch (importance) {
case 3: return '高'
case 2: return '中'
case 1: return '低'
default: return '--'
}
}
const getSymbolColor = (symbol) => {
const colors = {
'GOLD': 'amber',
'OIL': 'black',
'BTC': 'orange',
'SPX': 'blue',
'USDJPY': 'red'
}
return colors[symbol] || 'grey'
}
const getResultColor = (result) => {
switch (result) {
case 'better': return 'success'
case 'worse': return 'error'
case 'in_line': return 'info'
default: return 'grey'
}
}
const getResultText = (result) => {
switch (result) {
case 'better': return '好于预期'
case 'worse': return '差于预期'
case 'in_line': return '符合预期'
default: return '--'
}
}
const getImpactColor = (direction) => {
switch (direction) {
case '利好': return 'success'
case '利空': return 'error'
case '中性': return 'info'
default: return 'grey'
}
}
onMounted(() => {
fetchStatus()
fetchCalendar()
fetchFlashNews()
fetchSymbolImpact()
connectWebSocket()
})
onUnmounted(() => {
if (ws.value) {
ws.value.close()
}
})
return {
activeTab,
loading,
status,
calendar,
flashNews,
symbolEvents,
selectedImportance,
selectedCountry,
selectedSymbol,
importanceOptions,
countryOptions,
symbolOptions,
calendarHeaders,
filteredCalendar,
snackbar,
fetchStatus,
fetchCalendar,
fetchFlashNews,
fetchSymbolImpact,
formatDate,
formatTime,
formatDateTime,
getImportanceColor,
getImportanceText,
getSymbolColor,
getResultColor,
getResultText,
getImpactColor
}
}
}
</script>
<style scoped>
.v-timeline-item {
padding-bottom: 0;
}
</style>
+497
View File
@@ -0,0 +1,497 @@
<template>
<v-container fluid>
<v-row>
<v-col cols="12">
<h1 class="mb-4">仓位管理</h1>
</v-col>
</v-row>
<!-- 标签页 -->
<v-card>
<v-tabs v-model="activeTab" background-color="primary" dark>
<v-tab>
<v-icon class="mr-2">mdi-chart-box</v-icon>
当前持仓
</v-tab>
<v-tab>
<v-icon class="mr-2">mdi-history</v-icon>
历史交易
</v-tab>
</v-tabs>
<!-- 当前持仓 -->
<v-tab-item>
<!-- 汇总卡片 -->
<v-row class="pa-4">
<v-col cols="12" md="3">
<v-card outlined>
<v-card-text class="text-center">
<div class="text-h4">{{ summary.total_count }}</div>
<div class="text-caption grey--text">总持仓数</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card outlined>
<v-card-text class="text-center">
<div class="text-h4" :class="summary.total_profit >= 0 ? 'success--text' : 'error--text'">
{{ summary.total_profit >= 0 ? '+' : '' }}{{ summary.total_profit.toFixed(2) }}
</div>
<div class="text-caption grey--text">总盈亏</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card outlined>
<v-card-text class="text-center">
<div class="text-h4 success--text">{{ summary.buy_count }}</div>
<div class="text-caption grey--text">买单数量</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card outlined>
<v-card-text class="text-center">
<div class="text-h4 error--text">{{ summary.sell_count }}</div>
<div class="text-caption grey--text">卖单数量</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- 持仓列表 -->
<v-card-text>
<v-btn color="primary" small class="mb-3" @click="loadPositions" :loading="loading">
<v-icon left small>mdi-refresh</v-icon>
刷新
</v-btn>
<v-simple-table v-if="positions.length > 0">
<template v-slot:default>
<thead>
<tr>
<th>订单号</th>
<th>品种</th>
<th>方向</th>
<th>手数</th>
<th>开仓价</th>
<th>当前盈亏</th>
<th>止损距离</th>
<th>止盈距离</th>
<th>更新时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="pos in positions" :key="pos.ticket">
<td>{{ pos.ticket }}</td>
<td><strong>{{ pos.symbol }}</strong></td>
<td>
<v-chip x-small :color="pos.type === 'BUY' ? 'success' : 'error'">
{{ pos.type === 'BUY' ? '买入' : '卖出' }}
</v-chip>
</td>
<td>{{ pos.volume }}</td>
<td>{{ pos.price_open }}</td>
<td :class="pos.profit >= 0 ? 'success--text' : 'error--text'">
{{ pos.profit >= 0 ? '+' : '' }}{{ pos.profit.toFixed(2) }}
</td>
<td>{{ pos.distance_sl || '-' }}</td>
<td>{{ pos.distance_tp || '-' }}</td>
<td>{{ formatTime(pos.updated_at) }}</td>
<td>
<v-btn x-small color="error" outlined @click="closePosition(pos)">
平仓
</v-btn>
</td>
</tr>
</tbody>
</template>
</v-simple-table>
<div v-else class="text-center grey--text py-8">
<v-icon large>mdi-folder-open-outline</v-icon>
<div class="mt-2">暂无持仓</div>
</div>
</v-card-text>
</v-tab-item>
<!-- 历史交易 -->
<v-tab-item>
<v-card-text>
<v-btn color="primary" small class="mb-3" @click="loadTradeHistory" :loading="historyLoading">
<v-icon left small>mdi-refresh</v-icon>
刷新
</v-btn>
<!-- 统计卡片 -->
<v-row class="mb-4">
<v-col cols="12" md="2">
<v-card outlined>
<v-card-text class="text-center">
<div class="text-h5">{{ historyStats.total_count || 0 }}</div>
<div class="text-caption grey--text">总成交数</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="2">
<v-card outlined>
<v-card-text class="text-center">
<div class="text-h5" :class="(historyStats.net_profit || 0) >= 0 ? 'success--text' : 'error--text'">
{{ (historyStats.net_profit || 0) >= 0 ? '+' : '' }}{{ (historyStats.net_profit || 0).toFixed(2) }}
</div>
<div class="text-caption grey--text">净盈亏</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="6" md="1">
<v-card outlined>
<v-card-text class="text-center">
<div class="text-h6">{{ historyStats.manual_count || 0 }}</div>
<div class="text-caption grey--text">手动</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="6" md="1">
<v-card outlined>
<v-card-text class="text-center">
<div class="text-h6 primary--text">{{ historyStats.auto_count || 0 }}</div>
<div class="text-caption grey--text">自动</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="6" md="1">
<v-card outlined>
<v-card-text class="text-center">
<div class="text-h6 warning--text">{{ historyStats.sl_tp_count || 0 }}</div>
<div class="text-caption grey--text">止损/止盈</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="6" md="1">
<v-card outlined>
<v-card-text class="text-center">
<div class="text-h6 error--text">{{ historyStats.so_count || 0 }}</div>
<div class="text-caption grey--text">强制平仓</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="6" md="2">
<v-card outlined>
<v-card-text class="text-center">
<div class="text-h6">{{ (historyStats.total_commission || 0).toFixed(2) }}</div>
<div class="text-caption grey--text">手续费</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="6" md="2">
<v-card outlined>
<v-card-text class="text-center">
<div class="text-h6">{{ (historyStats.total_swap || 0).toFixed(2) }}</div>
<div class="text-caption grey--text">库存费</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- 品种分布 -->
<v-row class="mb-4" v-if="historyStats.symbols && Object.keys(historyStats.symbols).length > 0">
<v-col cols="12">
<div class="text-subtitle-1 font-weight-bold mb-2">品种分布</div>
<v-chip
v-for="(data, symbol) in historyStats.symbols"
:key="symbol"
class="mr-2 mb-2"
:color="data.profit >= 0 ? 'success' : 'error'"
outlined
>
{{ symbol }}: {{ data.count }}, 盈亏 {{ data.profit >= 0 ? '+' : '' }}{{ data.profit.toFixed(2) }}
</v-chip>
</v-col>
</v-row>
<!-- 自动单分类 -->
<v-row class="mb-4" v-if="historyStats.auto_categories && Object.keys(historyStats.auto_categories).length > 0">
<v-col cols="12">
<div class="text-subtitle-1 font-weight-bold mb-2">自动单分类</div>
<v-data-table
:headers="categoryHeaders"
:items="categoryItems"
dense
hide-default-footer
class="elevation-1"
>
<template v-slot:item.profit="{ item }">
<span :class="item.profit >= 0 ? 'success--text' : 'error--text'">
{{ item.profit >= 0 ? '+' : '' }}{{ item.profit.toFixed(2) }}
</span>
</template>
<template v-slot:item.percentage="{ item }">
<v-progress-linear
:value="item.percentage"
color="primary"
height="20"
>
<template v-slot:default>
{{ item.percentage }}%
</template>
</v-progress-linear>
</template>
</v-data-table>
</v-col>
</v-row>
<!-- 成交列表 -->
<div class="text-subtitle-1 font-weight-bold mb-2">成交记录</div>
<v-simple-table v-if="tradeDeals.length > 0" fixed-header height="400">
<template v-slot:default>
<thead>
<tr>
<th>订单号</th>
<th>品种</th>
<th>方向</th>
<th>类型</th>
<th>手数</th>
<th>价格</th>
<th>盈亏</th>
<th>手续费</th>
<th>时间</th>
<th>备注</th>
</tr>
</thead>
<tbody>
<tr v-for="deal in tradeDeals" :key="deal.ticket">
<td>{{ deal.ticket }}</td>
<td><strong>{{ deal.symbol }}</strong></td>
<td>
<v-chip x-small :color="deal.type === 0 ? 'success' : 'error'">
{{ deal.type_text }}
</v-chip>
</td>
<td>
<v-chip x-small outlined :color="deal.entry === 1 ? 'warning' : 'info'">
{{ deal.entry_text }}
</v-chip>
</td>
<td>{{ deal.volume }}</td>
<td>{{ deal.price }}</td>
<td :class="deal.profit >= 0 ? 'success--text' : 'error--text'">
{{ deal.profit >= 0 ? '+' : '' }}{{ deal.profit.toFixed(2) }}
</td>
<td>{{ deal.commission.toFixed(2) }}</td>
<td>{{ deal.time }}</td>
<td>
<v-chip v-if="deal.order_source === '自动'" x-small color="primary">
{{ deal.comment }}
</v-chip>
<v-chip v-else-if="deal.order_source === '止损触发'" x-small color="error" outlined>
{{ deal.comment }}
</v-chip>
<v-chip v-else-if="deal.order_source === '止盈触发'" x-small color="success" outlined>
{{ deal.comment }}
</v-chip>
<v-chip v-else-if="deal.order_source === '强制平仓'" x-small color="error" dark>
{{ deal.comment }}
</v-chip>
<span v-else class="grey--text">{{ deal.order_source }}</span>
</td>
</tr>
</tbody>
</template>
</v-simple-table>
<div v-else class="text-center grey--text py-8">
<v-icon large>mdi-history</v-icon>
<div class="mt-2">暂无历史交易数据</div>
</div>
</v-card-text>
</v-tab-item>
</v-card>
<!-- 平仓确认对话框 -->
<v-dialog v-model="closeDialog" max-width="400">
<v-card>
<v-card-title>确认平仓</v-card-title>
<v-card-text>
<div v-if="selectedPosition">
<div>订单号: {{ selectedPosition.ticket }}</div>
<div>品种: {{ selectedPosition.symbol }}</div>
<div>手数: {{ selectedPosition.volume }}</div>
<div>盈亏: <span :class="selectedPosition.profit >= 0 ? 'success--text' : 'error--text'">{{ selectedPosition.profit }}</span></div>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn text @click="closeDialog = false">取消</v-btn>
<v-btn color="error" @click="confirmClosePosition" :loading="closing">确认平仓</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 提示 -->
<v-snackbar v-model="showSnackbar" :color="snackbarColor" timeout="3000">
{{ snackbarMessage }}
</v-snackbar>
</v-container>
</template>
<script>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { marketAPI } from '@/api/market'
export default {
name: 'Positions',
setup() {
const activeTab = ref(0)
const positions = ref([])
const summary = ref({
total_count: 0,
total_profit: 0,
buy_count: 0,
sell_count: 0
})
const loading = ref(false)
const closeDialog = ref(false)
const selectedPosition = ref(null)
const closing = ref(false)
const showSnackbar = ref(false)
const snackbarMessage = ref('')
const snackbarColor = ref('success')
// 交易历史
const tradeDeals = ref([])
const historyStats = ref({})
const historyLoading = ref(false)
let refreshInterval = null
const categoryHeaders = [
{ text: '分类', value: 'category', width: 150 },
{ text: '数量', value: 'count', width: 80 },
{ text: '占比', value: 'percentage', width: 150 },
{ text: '盈亏', value: 'profit', width: 100 }
]
const categoryItems = computed(() => {
if (!historyStats.value.auto_categories) return []
return Object.entries(historyStats.value.auto_categories).map(([category, data]) => ({
category,
count: data.count,
percentage: data.percentage,
profit: data.profit
}))
})
const loadPositions = async () => {
loading.value = true
try {
const data = await marketAPI.getPositionsSummary()
if (data.status === 'ok') {
positions.value = data.positions || []
summary.value = {
total_count: data.total_count || 0,
total_profit: data.total_profit || 0,
buy_count: data.buy_count || 0,
sell_count: data.sell_count || 0
}
}
} catch (err) {
console.error('加载持仓失败:', err)
} finally {
loading.value = false
}
}
const loadTradeHistory = async () => {
historyLoading.value = true
try {
const data = await marketAPI.getTradeHistory()
if (data.status === 'ok') {
tradeDeals.value = data.deals || []
historyStats.value = data.statistics || {}
}
} catch (err) {
console.error('加载交易历史失败:', err)
} finally {
historyLoading.value = false
}
}
const closePosition = (pos) => {
selectedPosition.value = pos
closeDialog.value = true
}
const confirmClosePosition = async () => {
if (!selectedPosition.value) return
closing.value = true
try {
const data = await marketAPI.closePosition(selectedPosition.value.ticket, selectedPosition.value.symbol)
if (data.status === 'ok') {
snackbarMessage.value = '平仓指令已发送'
snackbarColor.value = 'success'
showSnackbar.value = true
closeDialog.value = false
// 刷新持仓
setTimeout(loadPositions, 1000)
} else {
snackbarMessage.value = data.message || '平仓失败'
snackbarColor.value = 'error'
showSnackbar.value = true
}
} catch (err) {
snackbarMessage.value = '平仓失败: ' + err.message
snackbarColor.value = 'error'
showSnackbar.value = true
} finally {
closing.value = false
}
}
const formatTime = (timestamp) => {
if (!timestamp) return '-'
const date = new Date(timestamp)
return date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
onMounted(() => {
loadPositions()
loadTradeHistory()
// 每5秒刷新一次持仓
refreshInterval = setInterval(loadPositions, 5000)
})
onUnmounted(() => {
if (refreshInterval) {
clearInterval(refreshInterval)
}
})
return {
activeTab,
positions,
summary,
loading,
closeDialog,
selectedPosition,
closing,
showSnackbar,
snackbarMessage,
snackbarColor,
tradeDeals,
historyStats,
historyLoading,
categoryHeaders,
categoryItems,
loadPositions,
loadTradeHistory,
closePosition,
confirmClosePosition,
formatTime
}
}
}
</script>
+574
View File
@@ -0,0 +1,574 @@
<template>
<v-container fluid>
<v-row>
<v-col cols="12">
<h1 class="mb-4">系统设置</h1>
</v-col>
</v-row>
<!-- 自动交易配置 -->
<v-row>
<v-col cols="12">
<v-card>
<v-card-title>
<v-icon class="mr-2">mdi-cog</v-icon>
自动交易配置
</v-card-title>
<v-card-text>
<v-row align="center">
<v-col cols="12">
<v-switch
v-model="tradeConfig.enabled"
label="启用自动生成"
@change="saveTradeConfig"
></v-switch>
</v-col>
</v-row>
<!-- 品种配置表格 -->
<div class="text-subtitle-2 mt-2 mb-2">品种配置</div>
<v-simple-table dense>
<template v-slot:default>
<thead>
<tr>
<th>品种</th>
<th>手数</th>
<th>止损偏移()</th>
<th>关键点位</th>
<th>阈值</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(config, symbol) in tradeConfig.symbol_config" :key="symbol">
<td>
<strong>{{ symbol }}</strong>
</td>
<td>
<v-text-field
v-model.number="config.volume"
type="number"
step="0.01"
min="0.01"
dense
hide-details
style="width: 80px"
></v-text-field>
</td>
<td>
<v-text-field
v-model.number="config.sl_offset"
type="number"
step="0.01"
min="0"
dense
hide-details
style="width: 80px"
></v-text-field>
</td>
<td>
<v-text-field
v-model="config.key_levels"
type="text"
dense
hide-details
placeholder="如: 5000,5100"
style="width: 120px"
></v-text-field>
</td>
<td>
<v-text-field
v-model.number="config.key_level_threshold"
type="number"
step="0.0001"
min="0"
dense
hide-details
style="width: 80px"
></v-text-field>
</td>
<td>
<v-btn x-small color="primary" @click="saveTradeConfig">保存</v-btn>
<v-btn x-small color="error" outlined class="ml-1" @click="removeSymbolConfig(symbol)">删除</v-btn>
</td>
</tr>
</tbody>
</template>
</v-simple-table>
<!-- 添加新品种配置 -->
<v-row class="mt-3" align="center">
<v-col cols="2">
<v-select
v-model="newSymbol"
:items="availableSymbols"
label="选择品种"
dense
hide-details
@change="onSymbolSelect"
></v-select>
</v-col>
<v-col cols="2">
<v-text-field
v-model.number="newVolume"
label="手数"
type="number"
step="0.01"
min="0.01"
dense
hide-details
></v-text-field>
</v-col>
<v-col cols="2">
<v-text-field
v-model.number="newSlOffset"
label="止损偏移"
type="number"
step="0.01"
min="0"
dense
hide-details
></v-text-field>
</v-col>
<v-col cols="2">
<v-text-field
v-model="newKeyLevels"
label="关键点位"
type="text"
dense
hide-details
placeholder="如: 5000,5100"
></v-text-field>
</v-col>
<v-col cols="2">
<v-text-field
v-model.number="newKeyLevelThreshold"
label="阈值"
type="number"
step="0.0001"
min="0"
dense
hide-details
></v-text-field>
</v-col>
<v-col cols="2">
<v-btn color="primary" small @click="addSymbolConfig">
<v-icon left small>mdi-plus</v-icon>
添加
</v-btn>
</v-col>
</v-row>
<div class="text-caption grey--text mt-3">
<v-icon small>mdi-information</v-icon>
支撑压力策略: M1周期接近转折点时自动生成交易指令止损偏移为固定点数<br/>
关键点位策略: 价格接近关键点位时生成反向订单例如下降趋势接近5000时生成买单阈值表示触发距离默认0.0008
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- 大模型配置 -->
<v-row class="mt-4">
<v-col cols="12">
<v-card>
<v-card-title>
<v-icon class="mr-2">mdi-brain</v-icon>
大模型配置
</v-card-title>
<v-card-text>
<v-form ref="llmForm">
<v-row>
<v-col cols="12" md="4">
<v-text-field
v-model="llmConfig.api_key"
label="API Key"
:type="showApiKey ? 'text' : 'password'"
:append-icon="showApiKey ? 'mdi-eye-off' : 'mdi-eye'"
@click:append="showApiKey = !showApiKey"
dense
hide-details
:placeholder="llmConfig.api_key_set ? '已设置(输入可更新)' : '请输入 API Key'"
></v-text-field>
</v-col>
<v-col cols="12" md="4">
<v-text-field
v-model="llmConfig.api_base"
label="API Base URL"
dense
hide-details
placeholder="https://api.openai.com/v1"
></v-text-field>
</v-col>
<v-col cols="12" md="4">
<v-text-field
v-model="llmConfig.model"
label="模型名称"
dense
hide-details
placeholder="gpt-4o-mini"
></v-text-field>
</v-col>
</v-row>
<v-row class="mt-2">
<v-col cols="12">
<v-btn color="primary" @click="saveLLMConfig" :loading="llmSaving">
<v-icon left>mdi-content-save</v-icon>
保存配置
</v-btn>
<v-chip
class="ml-3"
:color="llmConfig.enabled ? 'success' : 'error'"
small
>
{{ llmConfig.enabled ? '已启用' : '未启用' }}
</v-chip>
</v-col>
</v-row>
</v-form>
<div class="text-caption grey--text mt-3">
<v-icon small>mdi-information</v-icon>
配置大模型用于生成AI趋势分析和交易建议支持OpenAI兼容的API接口
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- 品种数据状态 -->
<v-row class="mt-4">
<v-col cols="12">
<v-card>
<v-card-title>
<v-icon class="mr-2">mdi-chart-line</v-icon>
品种数据状态
<v-btn icon small class="ml-2" @click="loadSymbolStatus" :loading="symbolStatusLoading">
<v-icon small>mdi-refresh</v-icon>
</v-btn>
</v-card-title>
<v-card-text>
<v-simple-table dense v-if="symbolStatus.length > 0">
<template v-slot:default>
<thead>
<tr>
<th>品种</th>
<th>数据状态</th>
<th>M1数量</th>
<th>最新M1时间</th>
<th>距上次更新</th>
<th>市场状态</th>
</tr>
</thead>
<tbody>
<tr v-for="item in symbolStatus" :key="item.symbol">
<td><strong>{{ item.symbol }}</strong></td>
<td>
<v-chip x-small :color="item.has_data ? 'success' : 'error'">
{{ item.has_data ? '有数据' : '无数据' }}
</v-chip>
</td>
<td>{{ item.m1_count || 0 }}</td>
<td>{{ item.latest_m1_time || '-' }}</td>
<td>
<span v-if="item.seconds_ago !== null">{{ item.seconds_ago }}秒前</span>
<span v-else>-</span>
</td>
<td>
<v-chip x-small :color="getMarketStatusColor(item.market_status)">
{{ getMarketStatusText(item.market_status) }}
</v-chip>
</td>
</tr>
</tbody>
</template>
</v-simple-table>
<div v-else class="text-center grey--text py-4">
<v-icon large>mdi-database-off</v-icon>
<div class="mt-2">暂无已配置的品种</div>
</div>
<div class="text-caption grey--text mt-3">
<v-icon small>mdi-information</v-icon>
显示交易配置中的品种K线数据状态M1数据超过3分钟未更新视为休市
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- 错误提示 -->
<v-snackbar v-model="showError" color="error" timeout="5000">
{{ errorMessage }}
</v-snackbar>
<!-- 成功提示 -->
<v-snackbar v-model="showSuccess" color="success" timeout="3000">
{{ successMessage }}
</v-snackbar>
</v-container>
</template>
<script>
import { ref, computed, onMounted } from 'vue'
import { marketAPI } from '@/api/market'
export default {
name: 'Settings',
setup() {
// 交易配置
const tradeConfig = ref({
enabled: true,
default_volume: 0.01,
default_sl_offset: 0.05,
symbol_config: {}
})
// 添加新品种
const newSymbol = ref('')
const newVolume = ref(0.01)
const newSlOffset = ref(0.05)
const newKeyLevels = ref('')
const newKeyLevelThreshold = ref(0.0008)
const symbols = ref([])
// 提示
const showError = ref(false)
const errorMessage = ref('')
const showSuccess = ref(false)
const successMessage = ref('')
// 大模型配置
const llmConfig = ref({
api_key: '',
api_key_set: false,
api_base: 'https://api.openai.com/v1',
model: 'gpt-4o-mini',
enabled: false
})
const showApiKey = ref(false)
const llmSaving = ref(false)
// 品种数据状态
const symbolStatus = ref([])
const symbolStatusLoading = ref(false)
// 可用品种列表(已连接但未配置的)
const availableSymbols = computed(() => {
const configured = Object.keys(tradeConfig.value.symbol_config || {})
return symbols.value.filter(s => !configured.includes(s))
})
// 加载配置
const loadTradeConfig = async () => {
try {
const data = await marketAPI.getTradeConfig()
if (data.config) {
tradeConfig.value = {
enabled: data.config.enabled,
default_volume: data.config.default_volume,
default_sl_offset: data.config.default_sl_offset,
symbol_config: data.config.symbol_config || {}
}
}
} catch (err) {
console.error('加载交易配置失败:', err)
}
}
// 加载品种列表
const loadSymbols = async () => {
try {
const data = await marketAPI.getSymbols()
symbols.value = data.symbols || []
} catch (err) {
console.error('加载品种列表失败:', err)
}
}
// 保存配置
const saveTradeConfig = async () => {
try {
const data = await marketAPI.updateTradeConfig({
enabled: tradeConfig.value.enabled,
default_volume: tradeConfig.value.default_volume,
default_sl_offset: tradeConfig.value.default_sl_offset,
symbol_config: tradeConfig.value.symbol_config
})
if (data.status !== 'ok') {
errorMessage.value = data.message || '保存配置失败'
showError.value = true
} else {
successMessage.value = '配置已保存'
showSuccess.value = true
}
} catch (err) {
errorMessage.value = `保存配置失败: ${err.message}`
showError.value = true
}
}
// 添加品种配置
const addSymbolConfig = () => {
if (!newSymbol.value) return
const symbol = newSymbol.value
tradeConfig.value.symbol_config[symbol] = {
volume: newVolume.value || 0.01,
sl_offset: newSlOffset.value || 0.05,
key_levels: newKeyLevels.value || '',
key_level_threshold: newKeyLevelThreshold.value || 0.0008
}
saveTradeConfig()
// 清空输入
newSymbol.value = ''
newVolume.value = 0.01
newSlOffset.value = 0.05
newKeyLevels.value = ''
newKeyLevelThreshold.value = 0.0008
}
// 删除品种配置
const removeSymbolConfig = (symbol) => {
delete tradeConfig.value.symbol_config[symbol]
saveTradeConfig()
}
// 选择品种时自动填充默认值
const onSymbolSelect = (symbol) => {
if (symbol && tradeConfig.value.symbol_config && tradeConfig.value.symbol_config[symbol]) {
const config = tradeConfig.value.symbol_config[symbol]
newVolume.value = config.volume || 0.01
newSlOffset.value = config.sl_offset || 0.05
newKeyLevels.value = config.key_levels || ''
newKeyLevelThreshold.value = config.key_level_threshold || 0.0008
} else {
newVolume.value = tradeConfig.value.default_volume || 0.01
newSlOffset.value = tradeConfig.value.default_sl_offset || 0.05
newKeyLevels.value = ''
newKeyLevelThreshold.value = 0.0008
}
}
// 加载大模型配置
const loadLLMConfig = async () => {
try {
const data = await marketAPI.getLLMConfig()
if (data.config) {
llmConfig.value = {
api_key: '', // 不显示已有key,只显示是否设置
api_key_set: data.config.api_key_set || false,
api_base: data.config.api_base || 'https://api.openai.com/v1',
model: data.config.model || 'gpt-4o-mini',
enabled: data.config.enabled || false
}
}
} catch (err) {
console.error('加载大模型配置失败:', err)
}
}
// 保存大模型配置
const saveLLMConfig = async () => {
llmSaving.value = true
try {
const updateData = {
api_base: llmConfig.value.api_base,
model: llmConfig.value.model
}
// 只有输入了新的API Key才更新
if (llmConfig.value.api_key) {
updateData.api_key = llmConfig.value.api_key
}
const data = await marketAPI.configureLLM(updateData)
if (data.status === 'ok') {
successMessage.value = '大模型配置已保存'
showSuccess.value = true
// 重新加载配置
await loadLLMConfig()
} else {
errorMessage.value = data.message || '保存配置失败'
showError.value = true
}
} catch (err) {
errorMessage.value = `保存配置失败: ${err.message}`
showError.value = true
} finally {
llmSaving.value = false
}
}
// 加载品种数据状态
const loadSymbolStatus = async () => {
symbolStatusLoading.value = true
try {
const data = await marketAPI.getConfiguredSymbols()
if (data.status === 'ok') {
symbolStatus.value = data.symbols || []
}
} catch (err) {
console.error('加载品种状态失败:', err)
} finally {
symbolStatusLoading.value = false
}
}
// 获取市场状态颜色
const getMarketStatusColor = (status) => {
switch (status) {
case 'active': return 'success'
case 'stale': return 'warning'
case 'closed': return 'error'
default: return 'grey'
}
}
// 获取市场状态文本
const getMarketStatusText = (status) => {
switch (status) {
case 'active': return '活跃'
case 'stale': return '数据过期'
case 'closed': return '休市中'
default: return '未知'
}
}
onMounted(() => {
loadSymbols()
loadTradeConfig()
loadLLMConfig()
loadSymbolStatus()
})
return {
tradeConfig,
newSymbol,
newVolume,
newSlOffset,
newKeyLevels,
newKeyLevelThreshold,
availableSymbols,
showError,
errorMessage,
showSuccess,
successMessage,
saveTradeConfig,
addSymbolConfig,
removeSymbolConfig,
onSymbolSelect,
// 大模型配置
llmConfig,
showApiKey,
llmSaving,
saveLLMConfig,
// 品种数据状态
symbolStatus,
symbolStatusLoading,
loadSymbolStatus,
getMarketStatusColor,
getMarketStatusText
}
}
}
</script>
+470
View File
@@ -0,0 +1,470 @@
<template>
<v-container fluid>
<v-row>
<v-col cols="12">
<h1 class="mb-4">系统运行日志</h1>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-card>
<v-card-title>
<v-icon class="mr-2">mdi-text-box-outline</v-icon>
实时日志
<v-spacer></v-spacer>
<v-btn icon small class="mr-2" @click="loadLogs" :loading="loading">
<v-icon small>mdi-refresh</v-icon>
</v-btn>
<v-btn color="error" small outlined @click="confirmClear">
<v-icon left small>mdi-delete</v-icon>
清空
</v-btn>
</v-card-title>
<v-card-text>
<!-- 过滤器 -->
<v-row class="mb-2">
<v-col cols="4">
<v-select
v-model="filterEventTypes"
:items="eventTypes"
label="事件类型"
dense
hide-details
clearable
multiple
chips
small-chips
deletable-chips
@change="loadLogs"
></v-select>
</v-col>
<v-col cols="4">
<v-text-field
v-model="filterSymbol"
label="品种"
dense
hide-details
clearable
@change="loadLogs"
></v-text-field>
</v-col>
<v-col cols="4">
<v-chip :color="wsConnected ? 'success' : 'error'" small>
WebSocket: {{ wsConnected ? '已连接' : '未连接' }}
</v-chip>
</v-col>
</v-row>
<!-- 日志列表 -->
<div class="log-container" ref="logContainer">
<div v-if="logs.length === 0" class="text-center grey--text py-8">
<v-icon large>mdi-text-box-remove-outline</v-icon>
<div class="mt-2">暂无日志</div>
</div>
<div v-else>
<div
v-for="(log, index) in logs"
:key="index"
class="log-entry"
:class="'log-' + log.event_type"
>
<span class="log-time">{{ formatTime(log.timestamp) }}</span>
<v-chip
x-small
:color="getEventColor(log.event_type)"
class="mx-2"
>
{{ log.event_name }}
</v-chip>
<span v-if="log.symbol" class="log-symbol">[{{ log.symbol }}]</span>
<span class="log-message">{{ log.message }}</span>
</div>
</div>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- 清空确认对话框 -->
<v-dialog v-model="clearDialog" max-width="400">
<v-card>
<v-card-title>确认清空</v-card-title>
<v-card-text>确定要清空所有日志吗此操作不可撤销</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn text @click="clearDialog = false">取消</v-btn>
<v-btn color="error" @click="clearLogs">确认清空</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<script>
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { marketAPI } from '@/api/market'
export default {
name: 'SystemLog',
setup() {
const logs = ref([])
const loading = ref(false)
const wsConnected = ref(false)
const clearDialog = ref(false)
const filterEventTypes = ref([])
const filterSymbol = ref(null)
const logContainer = ref(null)
let ws = null
const eventTypes = [
// 大模型相关
{ text: '大模型分析开始', value: 'llm_analysis_start' },
{ text: '大模型分析完成', value: 'llm_analysis_complete' },
{ text: '大模型分析错误', value: 'llm_analysis_error' },
// EA数据推送
{ text: 'EA推送统计数据', value: 'ea_statistics' },
{ text: 'EA推送全量K线', value: 'ea_kline_full' },
{ text: 'EA推送增量K线', value: 'ea_kline_incremental' },
{ text: 'K线数据过期', value: 'ea_kline_stale' },
{ text: 'EA请求交易指令', value: 'ea_trade_request' },
// MT5财经日历推送
{ text: 'MT5财经日历上报', value: 'mt5_calendar_update' },
{ text: 'MT5事件结果上报', value: 'mt5_event_result' },
// 转折点相关
{ text: '转折点检测完成', value: 'pivot_detected' },
{ text: '转折点提醒', value: 'pivot_alert' },
// 交易指令
{ text: '交易指令生成', value: 'order_generated' },
{ text: '交易指令确认', value: 'order_confirmed' },
{ text: '交易指令拒绝', value: 'order_rejected' },
{ text: '平仓指令', value: 'close_position' },
// 持仓相关
{ text: '持仓数据更新', value: 'position_update' },
// 新闻爬虫相关
{ text: '新闻爬虫启动', value: 'news_crawler_start' },
{ text: '财经日历获取', value: 'news_calendar_fetch' },
{ text: '财经日历更新', value: 'news_calendar_update' },
{ text: '财经日历获取失败', value: 'news_calendar_fetch_error' },
{ text: '快讯获取', value: 'news_flash_fetch' },
{ text: '快讯获取失败', value: 'news_flash_fetch_error' },
{ text: '事件调度创建', value: 'news_event_scheduled' },
{ text: '事件发布前提醒', value: 'news_event_reminder' },
{ text: '事件结果获取', value: 'news_event_result' },
{ text: '影响分析完成', value: 'news_impact_analysis' },
{ text: '新闻WebSocket推送', value: 'news_ws_broadcast' },
// 系统事件
{ text: '系统启动', value: 'system_startup' },
{ text: '系统关闭', value: 'system_shutdown' },
{ text: 'WebSocket连接', value: 'websocket_connect' },
{ text: 'WebSocket断开', value: 'websocket_disconnect' },
]
const loadLogs = async () => {
loading.value = true
try {
const data = await marketAPI.getSystemLogs(100, filterEventTypes.value, filterSymbol.value)
if (data.status === 'ok') {
logs.value = data.logs
}
} catch (err) {
console.error('加载日志失败:', err)
} finally {
loading.value = false
}
}
const connectWebSocket = () => {
ws = new WebSocket('ws://localhost:8000/ws/market')
ws.onopen = () => {
wsConnected.value = true
console.log('[SystemLog] WebSocket已连接')
}
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
if (data.type === 'system_log') {
// 新日志推送到列表顶部
logs.value.unshift(data.data)
// 保持最多200条
if (logs.value.length > 200) {
logs.value = logs.value.slice(0, 200)
}
// 滚动到顶部
nextTick(() => {
if (logContainer.value) {
logContainer.value.scrollTop = 0
}
})
}
} catch (e) {
// 忽略非JSON消息
}
}
ws.onerror = (error) => {
console.error('[SystemLog] WebSocket错误:', error)
}
ws.onclose = () => {
wsConnected.value = false
console.log('[SystemLog] WebSocket已断开')
// 5秒后重连
setTimeout(connectWebSocket, 5000)
}
}
const confirmClear = () => {
clearDialog.value = true
}
const clearLogs = async () => {
try {
await marketAPI.clearSystemLogs()
logs.value = []
clearDialog.value = false
} catch (err) {
console.error('清空日志失败:', err)
}
}
const formatTime = (timestamp) => {
if (!timestamp) return ''
const date = new Date(timestamp)
return date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
const getEventColor = (eventType) => {
const colors = {
// 大模型相关
'llm_analysis_start': 'info',
'llm_analysis_complete': 'success',
'llm_analysis_error': 'error',
// EA数据推送
'ea_statistics': 'grey',
'ea_kline_full': 'primary',
'ea_kline_incremental': 'primary',
'ea_kline_stale': 'warning',
'ea_trade_request': 'success',
// MT5财经日历推送
'mt5_calendar_update': 'primary',
'mt5_event_result': 'success',
// 转折点相关
'pivot_detected': 'warning',
'pivot_alert': 'warning',
// 交易指令
'order_generated': 'success',
'order_confirmed': 'success',
'order_rejected': 'error',
'close_position': 'error',
// 持仓相关
'position_update': 'info',
// 新闻爬虫相关
'news_crawler_start': 'success',
'news_calendar_fetch': 'info',
'news_calendar_update': 'success',
'news_calendar_fetch_error': 'error',
'news_flash_fetch': 'info',
'news_flash_fetch_error': 'error',
'news_event_scheduled': 'warning',
'news_event_reminder': 'warning',
'news_event_result': 'success',
'news_impact_analysis': 'primary',
'news_ws_broadcast': 'info',
// 系统事件
'system_startup': 'success',
'system_shutdown': 'error',
'websocket_connect': 'success',
'websocket_disconnect': 'warning',
}
return colors[eventType] || 'grey'
}
onMounted(() => {
loadLogs()
connectWebSocket()
})
onUnmounted(() => {
if (ws) {
ws.close()
}
})
return {
logs,
loading,
wsConnected,
clearDialog,
filterEventTypes,
filterSymbol,
logContainer,
eventTypes,
loadLogs,
confirmClear,
clearLogs,
formatTime,
getEventColor
}
}
}
</script>
<style scoped>
.log-container {
max-height: 600px;
overflow-y: auto;
background-color: #1e1e1e;
border-radius: 4px;
padding: 12px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 13px;
}
.log-entry {
padding: 6px 0;
border-bottom: 1px solid #333;
color: #e0e0e0;
}
.log-entry:last-child {
border-bottom: none;
}
.log-time {
color: #888;
margin-right: 8px;
}
.log-symbol {
color: #64b5f6;
margin-right: 8px;
}
.log-message {
color: #e0e0e0;
}
.log-llm_analysis_start {
border-left: 3px solid #2196f3;
padding-left: 8px;
}
.log-llm_analysis_complete {
border-left: 3px solid #4caf50;
padding-left: 8px;
}
.log-llm_analysis_error {
border-left: 3px solid #f44336;
padding-left: 8px;
}
.log-ea_kline_full {
border-left: 3px solid #9c27b0;
padding-left: 8px;
}
.log-ea_kline_incremental {
border-left: 3px solid #673ab7;
padding-left: 8px;
}
.log-ea_kline_stale {
border-left: 3px solid #ff9800;
padding-left: 8px;
}
.log-ea_statistics {
border-left: 3px solid #607d8b;
padding-left: 8px;
}
.log-pivot_alert {
border-left: 3px solid #ff9800;
padding-left: 8px;
}
.log-order_generated {
border-left: 3px solid #4caf50;
padding-left: 8px;
}
.log-order_confirmed {
border-left: 3px solid #2e7d32;
padding-left: 8px;
}
.log-order_rejected {
border-left: 3px solid #f44336;
padding-left: 8px;
}
.log-close_position {
border-left: 3px solid #e91e63;
padding-left: 8px;
}
.log-position_update {
border-left: 3px solid #00bcd4;
padding-left: 8px;
}
/* 新闻爬虫相关样式 */
.log-news_crawler_start {
border-left: 3px solid #4caf50;
padding-left: 8px;
}
.log-news_calendar_fetch {
border-left: 3px solid #2196f3;
padding-left: 8px;
}
.log-news_calendar_update {
border-left: 3px solid #4caf50;
padding-left: 8px;
}
.log-news_calendar_fetch_error {
border-left: 3px solid #f44336;
padding-left: 8px;
}
.log-news_flash_fetch {
border-left: 3px solid #00bcd4;
padding-left: 8px;
}
.log-news_flash_fetch_error {
border-left: 3px solid #ff5722;
padding-left: 8px;
}
.log-news_event_scheduled {
border-left: 3px solid #ff9800;
padding-left: 8px;
}
.log-news_event_reminder {
border-left: 3px solid #ffc107;
padding-left: 8px;
}
.log-news_event_result {
border-left: 3px solid #8bc34a;
padding-left: 8px;
}
.log-news_impact_analysis {
border-left: 3px solid #9c27b0;
padding-left: 8px;
}
</style>