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 8d0570451f
33 changed files with 10667 additions and 948 deletions
+9 -1
View File
@@ -17,4 +17,12 @@ __pycache__/
.claude/
# Node.js
node_modules/
node_modules/
# Environment
.env
# Data
data/
# Build
frontend/dist/
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>量化交易系统</title>
<title>AITrader</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900&display=swap">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@5.x/css/materialdesignicons.min.css">
</head>
+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>
+6 -2
View File
@@ -14,8 +14,12 @@ export default defineConfig({
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
changeOrigin: true
// 不要重写路径,保持 /api 前缀
},
'/ws': {
target: 'ws://localhost:8000',
ws: true
}
}
}
+36 -7
View File
@@ -6,6 +6,7 @@
import sys
import os
import asyncio
import uvloop
import uvicorn
from fastapi import FastAPI
@@ -13,7 +14,6 @@ from fastapi.middleware.cors import CORSMiddleware
# 使用 uvloop 加速
asyncio_policy = uvloop.EventLoopPolicy()
import asyncio
asyncio.set_event_loop_policy(asyncio_policy)
from server import TradingServer
@@ -21,14 +21,16 @@ from routes_ea import create_ea_routes
from routes_trader import create_trader_routes
from routes_system import create_system_routes
from routes_market import create_market_routes
from routes_position import create_position_routes
from routes_news import create_news_routes
def create_app():
"""创建并配置 FastAPI 应用"""
# 初始化服务
server = TradingServer()
# 创建 FastAPI 应用
app = FastAPI(
title="高频交易服务 (HFT Trading Service)",
@@ -53,7 +55,7 @@ def create_app():
""",
version="2.0.0"
)
# 添加 CORS 中间件
app.add_middleware(
CORSMiddleware,
@@ -62,7 +64,7 @@ def create_app():
allow_methods=["*"],
allow_headers=["*"],
)
# 注册路由
app.include_router(create_ea_routes(server))
app.include_router(create_trader_routes(server))
@@ -72,9 +74,36 @@ def create_app():
server.pivot_detector,
server.pivot_monitor,
server.trend_analyzer,
server.pending_orders
server.pending_orders,
server.llm_analyzer
))
app.include_router(create_position_routes())
app.include_router(create_news_routes())
# 启动时设置事件循环
@app.on_event("startup")
async def startup_event():
loop = asyncio.get_running_loop()
server.llm_analyzer.set_event_loop(loop)
server.pivot_monitor.set_event_loop(loop)
# 设置系统日志的事件循环
from market.system_log import get_system_log
system_log = get_system_log()
system_log.set_event_loop(loop)
# 记录系统启动日志
system_log.add_log("system_startup", message="服务已启动")
# 启动新闻监控后台任务
from market.news_monitor import get_news_monitor
news_monitor = get_news_monitor()
news_monitor.set_event_loop(loop)
asyncio.create_task(news_monitor.run())
print("[Startup] 事件循环已设置")
print("[Startup] 新闻监控已启动")
return app
+438
View File
@@ -0,0 +1,438 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
市场事件配置
定义关注的财经数据、关键人物和事件
"""
# 关注的交易品种
WATCH_SYMBOLS = ["GOLD", "OIL", "BTC", "SPX", "USDJPY"]
# 定期财经数据(财经日历)
ECONOMIC_DATA = [
# ============ 就业类 ============
{
"name": "非农就业人数",
"name_en": "Non-Farm Payrolls",
"country": "US",
"importance": 3, # 3=高影响, 2=中, 1=低
"symbols": ["GOLD", "SPX", "USDJPY"],
"unit": "万人"
},
{
"name": "失业率",
"name_en": "Unemployment Rate",
"country": "US",
"importance": 3,
"symbols": ["GOLD", "SPX", "USDJPY"],
"unit": "%"
},
{
"name": "ADP就业人数",
"name_en": "ADP Nonfarm Employment Change",
"country": "US",
"importance": 2,
"symbols": ["GOLD", "SPX"],
"unit": "万人"
},
{
"name": "初请失业金人数",
"name_en": "Initial Jobless Claims",
"country": "US",
"importance": 2,
"symbols": ["GOLD", "SPX"],
"unit": "万人"
},
# ============ 通胀类 ============
{
"name": "CPI年率",
"name_en": "CPI YoY",
"country": "US",
"importance": 3,
"symbols": ["GOLD", "SPX", "USDJPY", "BTC"],
"unit": "%"
},
{
"name": "核心CPI年率",
"name_en": "Core CPI YoY",
"country": "US",
"importance": 3,
"symbols": ["GOLD", "SPX", "USDJPY"],
"unit": "%"
},
{
"name": "PPI年率",
"name_en": "PPI YoY",
"country": "US",
"importance": 2,
"symbols": ["GOLD", "SPX"],
"unit": "%"
},
{
"name": "PCE物价指数年率",
"name_en": "PCE Price Index YoY",
"country": "US",
"importance": 3,
"symbols": ["GOLD", "SPX"],
"unit": "%"
},
{
"name": "核心PCE年率",
"name_en": "Core PCE YoY",
"country": "US",
"importance": 3,
"symbols": ["GOLD", "SPX"],
"unit": "%"
},
# ============ 利率类 ============
{
"name": "美联储利率决议",
"name_en": "Federal Funds Rate",
"country": "US",
"importance": 3,
"symbols": ["GOLD", "SPX", "USDJPY", "BTC"],
"unit": "%"
},
{
"name": "日本央行利率决议",
"name_en": "BoJ Interest Rate",
"country": "JP",
"importance": 3,
"symbols": ["USDJPY"],
"unit": "%"
},
{
"name": "欧洲央行利率决议",
"name_en": "ECB Interest Rate",
"country": "EU",
"importance": 2,
"symbols": ["GOLD"],
"unit": "%"
},
# ============ 经济类 ============
{
"name": "GDP年率",
"name_en": "GDP YoY",
"country": "US",
"importance": 3,
"symbols": ["GOLD", "SPX"],
"unit": "%"
},
{
"name": "零售销售月率",
"name_en": "Retail Sales MoM",
"country": "US",
"importance": 2,
"symbols": ["SPX"],
"unit": "%"
},
{
"name": "ISM制造业PMI",
"name_en": "ISM Manufacturing PMI",
"country": "US",
"importance": 2,
"symbols": ["SPX"],
"unit": ""
},
{
"name": "ISM服务业PMI",
"name_en": "ISM Services PMI",
"country": "US",
"importance": 2,
"symbols": ["SPX"],
"unit": ""
},
# ============ 原油类 ============
{
"name": "EIA原油库存",
"name_en": "EIA Crude Oil Inventories",
"country": "US",
"importance": 2,
"symbols": ["OIL"],
"unit": "万桶"
},
{
"name": "API原油库存",
"name_en": "API Crude Oil Stock",
"country": "US",
"importance": 1,
"symbols": ["OIL"],
"unit": "万桶"
},
# ============ 日本数据 ============
{
"name": "日本CPI年率",
"name_en": "Japan CPI YoY",
"country": "JP",
"importance": 2,
"symbols": ["USDJPY"],
"unit": "%"
},
{
"name": "日本GDP年率",
"name_en": "Japan GDP YoY",
"country": "JP",
"importance": 2,
"symbols": ["USDJPY"],
"unit": "%"
},
]
# 关键人物讲话配置
KEY_SPEAKERS = [
{
"name": "特朗普",
"name_en": "Trump",
"title": "美国总统",
"title_en": "US President",
"keywords": ["特朗普", "Trump", "总统"],
"importance": 3,
"watch_topics": ["关税", "贸易", "制裁", "中国", "利率", "美元", "北约", "俄乌", "战争", "减税"],
"impact_symbols": ["GOLD", "SPX", "USDJPY", "BTC", "OIL"],
"default_impact": {
"GOLD": {"关税/制裁": "利好", "战争/冲突": "利好", "减税": "中性"},
"SPX": {"关税/制裁": "利空", "减税": "利好"},
"USDJPY": {"关税": "不确定", "利率": "利好"},
"OIL": {"制裁": "利好", "战争": "利好"},
}
},
{
"name": "鲍威尔",
"name_en": "Powell",
"title": "美联储主席",
"title_en": "Fed Chair",
"keywords": ["鲍威尔", "Powell", "美联储主席", "Fed Chair"],
"importance": 3,
"watch_topics": ["利率", "通胀", "就业", "降息", "加息", "货币政策", "缩表"],
"impact_symbols": ["GOLD", "SPX", "USDJPY", "BTC"],
"default_impact": {
"GOLD": {"降息": "利好", "加息": "利空", "鸽派": "利好", "鹰派": "利空"},
"SPX": {"降息": "利好", "加息": "利空", "鸽派": "利好", "鹰派": "利空"},
"USDJPY": {"降息": "利空", "加息": "利好"},
"BTC": {"降息": "利好", "加息": "利空"},
}
},
{
"name": "贝森特",
"name_en": "Bessent",
"title": "美国财长",
"title_en": "US Treasury Secretary",
"keywords": ["贝森特", "Bessent", "财长", "Treasury Secretary", "财政部"],
"importance": 2,
"watch_topics": ["债务", "预算", "制裁", "汇率", "国债"],
"impact_symbols": ["GOLD", "SPX", "USDJPY"],
"default_impact": {
"GOLD": {"债务担忧": "利好", "制裁": "利好"},
"SPX": {"债务担忧": "利空"},
}
},
{
"name": "植田和男",
"name_en": "Ueda",
"title": "日本央行行长",
"title_en": "BoJ Governor",
"keywords": ["植田", "Ueda", "日本央行", "日银", "BoJ"],
"importance": 2,
"watch_topics": ["利率", "YCC", "干预", "日元", "宽松"],
"impact_symbols": ["USDJPY"],
"default_impact": {
"USDJPY": {"加息": "利空", "干预": "利空", "宽松": "利好"},
}
},
{
"name": "拉加德",
"name_en": "Lagarde",
"title": "欧洲央行行长",
"title_en": "ECB President",
"keywords": ["拉加德", "Lagarde", "欧洲央行", "ECB"],
"importance": 2,
"watch_topics": ["利率", "通胀", "欧元"],
"impact_symbols": ["GOLD"],
"default_impact": {
"GOLD": {"降息": "利好", "加息": "利空"},
}
},
]
# 关键事件配置
KEY_EVENTS = [
{
"name": "FOMC会议",
"name_en": "FOMC Meeting",
"type": "scheduled",
"importance": 3,
"symbols": ["GOLD", "SPX", "USDJPY", "BTC"],
"watch_keywords": ["利率决议", "点阵图", "经济预测", "发布会", "FOMC"],
"description": "美联储联邦公开市场委员会会议"
},
{
"name": "OPEC会议",
"name_en": "OPEC Meeting",
"type": "scheduled",
"importance": 3,
"symbols": ["OIL"],
"watch_keywords": ["减产", "增产", "产量配额", "OPEC", "OPEC+"],
"description": "石油输出国组织会议"
},
{
"name": "G7/G20峰会",
"name_en": "G7/G20 Summit",
"type": "scheduled",
"importance": 2,
"symbols": ["GOLD", "OIL", "SPX"],
"watch_keywords": ["G7", "G20", "峰会", "制裁", "贸易"],
"description": "七国集团/二十国集团峰会"
},
{
"name": "地缘冲突",
"name_en": "Geopolitical Conflict",
"type": "breaking",
"importance": 3,
"symbols": ["GOLD", "OIL"],
"watch_keywords": ["战争", "冲突", "制裁", "导弹", "", "恐怖袭击", "入侵", "军事行动"],
"description": "地缘政治突发事件"
},
{
"name": "加密监管",
"name_en": "Crypto Regulation",
"type": "breaking",
"importance": 2,
"symbols": ["BTC"],
"watch_keywords": ["SEC", "ETF", "比特币", "监管", "禁令", "审批"],
"description": "加密货币监管新闻"
},
{
"name": "贸易战",
"name_en": "Trade War",
"type": "breaking",
"importance": 3,
"symbols": ["GOLD", "SPX", "OIL"],
"watch_keywords": ["关税", "贸易战", "制裁", "禁运", "贸易谈判"],
"description": "贸易战相关新闻"
},
]
# 数据影响规则(实际值 vs 预期值)
DATA_IMPACT_RULES = {
"GOLD": {
"非农就业人数": {
"better": "利空", # 好于预期 -> 利空黄金
"worse": "利好", # 差于预期 -> 利好黄金
"reason_better": "就业强劲,美元走强,黄金承压",
"reason_worse": "就业疲软,美元走弱,黄金上涨"
},
"失业率": {
"better": "利好", # 失业率下降
"worse": "利空", # 失业率上升
"reason_better": "失业率下降,经济向好,但可能提前加息",
"reason_worse": "失业率上升,经济疲软,可能降息"
},
"CPI年率": {
"better": "利空", # 高于预期 -> 利空
"worse": "利好",
"reason_better": "通胀超预期,加息预期升温",
"reason_worse": "通胀低于预期,降息预期升温"
},
"美联储利率决议": {
"hike": "利空", # 加息
"cut": "利好", # 降息
"hold": "中性",
"reason_hike": "加息推高美元,黄金承压",
"reason_cut": "降息削弱美元,黄金上涨"
},
"EIA原油库存": {
"higher": "利空",
"lower": "利好",
"reason_higher": "库存增加,需求疲软",
"reason_lower": "库存下降,需求旺盛"
},
},
"SPX": {
"非农就业人数": {
"better": "利好",
"worse": "利空",
"reason_better": "就业强劲,经济向好",
"reason_worse": "就业疲软,经济担忧"
},
"CPI年率": {
"better": "利空", # 高通胀利空股市
"worse": "利好",
"reason_better": "通胀超预期,加息预期升温",
"reason_worse": "通胀降温,降息预期升温"
},
"美联储利率决议": {
"hike": "利空",
"cut": "利好",
"hold": "中性",
},
},
"USDJPY": {
"非农就业人数": {
"better": "利好", # 好于预期 -> 美元涨 -> USDJPY涨
"worse": "利空",
},
"美联储利率决议": {
"hike": "利好",
"cut": "利空",
},
"日本央行利率决议": {
"hike": "利空", # 日本加息 -> 日元涨 -> USDJPY跌
"cut": "利好",
},
},
"BTC": {
"美联储利率决议": {
"hike": "利空",
"cut": "利好",
},
"CPI年率": {
"better": "利空",
"worse": "利好",
},
},
"OIL": {
"EIA原油库存": {
"higher": "利空",
"lower": "利好",
"reason_higher": "库存增加,供过于求",
"reason_lower": "库存下降,供不应求"
},
"OPEC会议": {
"cut_production": "利好", # 减产
"increase_production": "利空", # 增产
},
},
}
# 获取重要事件名称列表(用于日历过滤)
def get_important_event_names() -> list:
"""获取所有重要事件名称"""
names = set()
for event in ECONOMIC_DATA:
if event['importance'] >= 2: # 中等及以上重要
names.add(event['name'])
names.add(event['name_en'])
return list(names)
# 获取高影响事件名称列表
def get_high_impact_event_names() -> list:
"""获取高影响事件名称"""
names = set()
for event in ECONOMIC_DATA:
if event['importance'] == 3: # 高影响
names.add(event['name'])
names.add(event['name_en'])
return list(names)
# 获取事件影响的品种
def get_event_symbols(event_name: str) -> list:
"""获取事件影响的品种列表"""
for event in ECONOMIC_DATA:
if event_name in [event['name'], event['name_en']]:
return event['symbols']
return WATCH_SYMBOLS # 默认返回所有品种
+793
View File
@@ -0,0 +1,793 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
大模型行情趋势分析模块
使用大语言模型分析K线数据,生成趋势判断和交易建议
"""
import os
import json
import threading
import asyncio
import requests
from datetime import datetime
from typing import List, Dict, Optional, Set
from collections import defaultdict
# 加载 .env 文件
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
from .system_log import get_system_log
class LLMAnalyzer:
"""大模型行情分析器"""
# 分析间隔(秒)
ANALYZE_INTERVAL = 300 # 5分钟
# 趋势类型
TREND_TYPES = [
"单边上涨",
"单边下跌",
"区间震荡",
"震荡上升",
"震荡下跌",
"震荡收窄",
"震荡扩大"
]
# 各周期K线数量限制
KLINE_LIMITS = {
'H4': 20, # 4小时,发送最近20根
'H1': 24, # 1小时,发送最近24根(一天)
'M15': 32, # 15分钟,发送最近32根(8小时)
'M5': 48, # 5分钟,发送最近48根(4小时)
'M1': 60 # 1分钟,发送最近60根(1小时)
}
# 配置文件路径
CONFIG_FILE = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "llm_config.json")
def __init__(self, market_store):
"""
初始化大模型分析器
Args:
market_store: K线存储对象
"""
self.market_store = market_store
# 存储分析结果: {SYMBOL: analysis_result}
self._analysis_results = {}
self._last_analysis_time = None
self._lock = threading.RLock()
# WebSocket连接管理
self._ws_clients: Set = set()
self._ws_lock = threading.Lock()
# 主事件循环引用(在FastAPI启动时设置)
self._main_loop = None
# 已提醒的AI入场价记录(避免重复提醒)
# 结构: {(symbol, period, direction, entry_price): datetime}
self._alerted_entries: Dict[tuple, datetime] = {}
self._entry_alert_lock = threading.Lock()
# AI入场价提醒冷却时间(秒)
self.entry_alert_cooldown = 300 # 5分钟
# 配置(先从文件加载,再从环境变量补充)
self._api_key = ""
self._api_base = "https://api.openai.com/v1"
self._model = "gpt-4o-mini"
self._enabled = False
# 从文件加载配置
self._load_from_file()
# 环境变量覆盖(如果文件中没有配置)
if not self._api_key and os.environ.get("LLM_API_KEY"):
self._api_key = os.environ.get("LLM_API_KEY", "")
if not self._api_base or self._api_base == "https://api.openai.com/v1":
self._api_base = os.environ.get("LLM_API_BASE", "https://api.openai.com/v1")
if not self._model or self._model == "gpt-4o-mini":
self._model = os.environ.get("LLM_MODEL", "gpt-4o-mini")
self._enabled = bool(self._api_key)
# 启动定时分析线程
if self._enabled:
self._start_analyze_thread()
print("[LLMAnalyzer] 大模型分析器已初始化(已启用)")
else:
print("[LLMAnalyzer] 大模型分析器已初始化(未配置API Key,功能禁用)")
def set_event_loop(self, loop):
"""设置主事件循环引用"""
self._main_loop = loop
print(f"[LLMAnalyzer] 已设置主事件循环")
def _load_from_file(self):
"""从文件加载配置"""
try:
if os.path.exists(self.CONFIG_FILE):
with open(self.CONFIG_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
self._api_key = data.get("api_key", "")
self._api_base = data.get("api_base", "https://api.openai.com/v1")
self._model = data.get("model", "gpt-4o-mini")
print(f"[LLMAnalyzer] 已从文件加载配置: {self.CONFIG_FILE}")
except Exception as e:
print(f"[LLMAnalyzer] 加载配置文件失败: {e}")
def _save_to_file(self):
"""保存配置到文件"""
try:
# 确保目录存在
config_dir = os.path.dirname(self.CONFIG_FILE)
os.makedirs(config_dir, exist_ok=True)
data = {
"api_key": self._api_key,
"api_base": self._api_base,
"model": self._model
}
with open(self.CONFIG_FILE, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
print(f"[LLMAnalyzer] 配置已保存到文件")
except Exception as e:
print(f"[LLMAnalyzer] 保存配置文件失败: {e}")
def get_config(self) -> Dict:
"""获取当前配置(API Key会脱敏显示)"""
# 脱敏API Key:只显示前4位和后4位
masked_key = ""
if self._api_key:
if len(self._api_key) > 8:
masked_key = self._api_key[:4] + "****" + self._api_key[-4:]
else:
masked_key = "****"
return {
"api_key": masked_key,
"api_key_set": bool(self._api_key),
"api_base": self._api_base,
"model": self._model,
"enabled": self._enabled
}
def _start_analyze_thread(self):
"""启动定时分析线程"""
def analyze_loop():
# 等待事件循环设置完成
import time
time.sleep(5) # 等待5秒让服务完全启动
print("[LLMAnalyzer] 分析线程启动,开始第一次分析...")
while True:
try:
self._run_analysis()
except Exception as e:
print(f"[LLMAnalyzer] 分析异常: {e}")
import traceback
traceback.print_exc()
# 等待5分钟
threading.Event().wait(self.ANALYZE_INTERVAL)
thread = threading.Thread(target=analyze_loop, daemon=True)
thread.start()
print("[LLMAnalyzer] 分析线程已创建")
def _run_analysis(self):
"""执行分析 - 合并所有品种到一次请求(流式输出)"""
symbols = self.market_store.get_symbols()
print(f"[LLMAnalyzer] _run_analysis 调用,获取到 {len(symbols) if symbols else 0} 个品种")
if not symbols:
print("[LLMAnalyzer] 没有品种数据,跳过分析")
return
print(f"[LLMAnalyzer] 开始分析 {len(symbols)} 个品种: {symbols}")
# 广播分析开始
self._broadcast_analysis_status("analyzing", f"正在检查 {len(symbols)} 个品种的数据更新状态...")
# 检查每个品种的M1 K线更新状态(3分钟内有效)
STALE_THRESHOLD = 180 # 3分钟
active_symbols = [] # 有数据更新的品种
stale_symbols = [] # 数据过期的品种
for symbol in symbols:
m1_status = self.market_store.check_m1_updated_within(symbol, STALE_THRESHOLD)
market_status = m1_status.get("market_status", "closed")
if market_status == "active":
active_symbols.append(symbol)
print(f"[LLMAnalyzer] {symbol} M1数据有效,距今 {m1_status['seconds_ago']}")
elif market_status == "stale":
stale_symbols.append(symbol)
print(f"[LLMAnalyzer] {symbol} M1数据过期,距今 {m1_status['seconds_ago']} 秒,跳过分析")
else: # closed
stale_symbols.append(symbol)
print(f"[LLMAnalyzer] {symbol} 休市中,无新数据,跳过分析")
# 标记休市状态
with self._lock:
if symbol in self._analysis_results:
self._analysis_results[symbol]["market_status"] = "closed"
else:
# 没有历史分析结果,创建一个标记休市的记录
self._analysis_results[symbol] = {
"symbol": symbol,
"analysis": None,
"analyzed_at": None,
"market_status": "closed",
"data_stale": True
}
# 更新过期品种的状态标记(不包括休市品种,它们已经在上面处理了)
with self._lock:
for symbol in stale_symbols:
m1_status = self.market_store.check_m1_updated_within(symbol, STALE_THRESHOLD)
if m1_status.get("market_status") == "stale" and symbol in self._analysis_results:
# 保留上次分析结果,但标记为过期
self._analysis_results[symbol]["data_stale"] = True
self._analysis_results[symbol]["market_status"] = "stale"
self._analysis_results[symbol]["stale_seconds"] = m1_status.get("seconds_ago")
# 如果没有活跃品种,广播状态并返回
if not active_symbols:
print("[LLMAnalyzer] 所有品种数据均过期,跳过大模型调用")
self._broadcast_analysis_status("stale", "所有品种行情数据均未更新,使用上次分析结果")
self._last_analysis_time = datetime.now().isoformat()
self._broadcast_analysis_update()
return
# 广播实际分析的品种
if stale_symbols:
self._broadcast_analysis_status("analyzing",
f"分析 {len(active_symbols)} 个品种,{len(stale_symbols)} 个品种数据未更新")
else:
self._broadcast_analysis_status("analyzing",
f"正在分析 {len(active_symbols)} 个品种...")
# 收集活跃品种的K线数据
all_klines_data = {}
for symbol in active_symbols:
klines_data = {}
for period in ['H4', 'H1', 'M15', 'M5', 'M1']:
limit = self.KLINE_LIMITS.get(period, 30)
klines = self.market_store.get_klines(symbol, period, limit)
if klines:
klines_data[period] = klines
print(f"[LLMAnalyzer] {symbol} {period} 获取到 {len(klines)} 条K线")
if klines_data:
all_klines_data[symbol] = klines_data
print(f"[LLMAnalyzer] 共收集 {len(all_klines_data)} 个品种的K线数据: {list(all_klines_data.keys())}")
if not all_klines_data:
print("[LLMAnalyzer] 无K线数据可分析")
self._broadcast_analysis_status("error", "无K线数据可分析")
return
# 构建合并的提示词
prompt = self._build_combined_prompt(all_klines_data)
# 记录分析开始
system_log = get_system_log()
system_log.add_log(
"llm_analysis_start",
{"symbols": active_symbols, "symbol_count": len(active_symbols)},
message=f"开始分析 {len(active_symbols)} 个品种"
)
# 调用大模型(流式)
response = self._call_llm_stream(prompt)
print(f"[LLMAnalyzer] 大模型返回结果: {type(response)}, 内容长度: {len(response) if response else 0}")
if response:
print(f"[LLMAnalyzer] 返回的品种: {list(response.keys())}")
# 解析结果,按品种存储
with self._lock:
for symbol, analysis in response.items():
if isinstance(analysis, dict):
self._analysis_results[symbol] = {
"symbol": symbol,
"analysis": analysis,
"analyzed_at": datetime.now().isoformat(),
"data_stale": False # 标记数据是最新的
}
print(f"[LLMAnalyzer] 已存储 {symbol} 的分析结果")
# 记录分析完成
system_log.add_log(
"llm_analysis_complete",
{"symbols": list(response.keys()), "symbol_count": len(response)},
message=f"分析完成,{len(response)} 个品种"
)
else:
print(f"[LLMAnalyzer] 大模型返回为空,分析失败")
# 记录分析错误
system_log.add_log(
"llm_analysis_error",
{"reason": "大模型返回为空"},
message="分析失败"
)
self._last_analysis_time = datetime.now().isoformat()
print(f"[LLMAnalyzer] 分析完成,时间: {self._last_analysis_time}")
# 广播分析完成通知
self._broadcast_analysis_update()
def _build_combined_prompt(self, all_klines_data: Dict) -> str:
"""构建合并的分析提示词"""
prompt = """你是一位专业的金融分析师。请分析以下多个交易品种的K线数据,给出每个品种的趋势判断和交易建议。
## 分析要求
对于每个品种,请分析:
1. 各周期(H4、H1、M15、M5、M1)的趋势判断,包含趋势类型、置信度(0-100)和判断理由
2. 整体趋势方向、强度(0-100)和总结
3. 关键支撑位和压力位(请根据K线数据自行判断,各列出3个)
4. 交易建议:必须包含M1、M5、M15三个周期的具体交易建议
趋势类型可选值:单边上涨、单边下跌、区间震荡、震荡上升、震荡下跌、震荡收窄、震荡扩大
请按以下JSON格式输出(必须是有效的JSON格式,包含所有品种):
```json
{
"品种1": {
"trend_analysis": {
"H4": {"trend": "趋势类型", "confidence": 置信度, "reason": "判断理由"},
"H1": {"trend": "趋势类型", "confidence": 置信度, "reason": "判断理由"},
"M15": {"trend": "趋势类型", "confidence": 置信度, "reason": "判断理由"},
"M5": {"trend": "趋势类型", "confidence": 置信度, "reason": "判断理由"},
"M1": {"trend": "趋势类型", "confidence": 置信度, "reason": "判断理由"}
},
"overall_trend": {
"direction": "整体趋势方向",
"strength": 强度,
"summary": "整体趋势总结"
},
"key_levels": {
"resistance": [压力位1, 压力位2, 压力位3],
"support": [支撑位1, 支撑位2, 支撑位3]
},
"trade_suggestions": [
{
"period": "M15",
"direction": "buy或sell",
"entry_price": 入场价格,
"stop_loss": 止损价格,
"take_profit": 止盈价格,
"reason": "交易理由"
},
{
"period": "M5",
"direction": "buy或sell",
"entry_price": 入场价格,
"stop_loss": 止损价格,
"take_profit": 止盈价格,
"reason": "交易理由"
},
{
"period": "M1",
"direction": "buy或sell",
"entry_price": 入场价格,
"stop_loss": 止损价格,
"take_profit": 止盈价格,
"reason": "交易理由"
}
]
},
"品种2": { ... }
}
```
## K线数据
"""
# 添加各品种的K线数据
for symbol, klines_data in all_klines_data.items():
prompt += f"\n### {symbol}\n"
for period, klines in klines_data.items():
prompt += f"\n#### {period} 周期({len(klines)}根K线)\n"
prompt += "| 时间 | 开盘 | 最高 | 最低 | 收盘 |\n"
prompt += "|------|------|------|------|------|\n"
for k in klines:
prompt += f"| {k['timestamp']} | {k['open']:.2f} | {k['high']:.2f} | {k['low']:.2f} | {k['close']:.2f} |\n"
prompt += """
请确保输出是纯JSON格式,不要有其他文字说明。每个品种的分析结果都要完整,trade_suggestions必须包含M1、M5、M15三个周期的建议。
"""
return prompt
def _call_llm(self, prompt: str) -> Optional[Dict]:
"""调用大模型API(非流式,保留兼容)"""
if not self._api_key:
return None
try:
headers = {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json"
}
data = {
"model": self._model,
"messages": [
{"role": "system", "content": "你是一位专业的金融分析师,擅长技术分析和趋势判断。请用JSON格式输出分析结果,不要有任何额外的文字说明。"},
{"role": "user", "content": prompt}
],
"temperature": 0.3,
"max_tokens": 4000
}
response = requests.post(
f"{self._api_base}/chat/completions",
headers=headers,
json=data,
timeout=120
)
if response.status_code == 200:
result = response.json()
content = result["choices"][0]["message"]["content"]
# 提取JSON部分
if "```json" in content:
content = content.split("```json")[1].split("```")[0]
elif "```" in content:
content = content.split("```")[1].split("```")[0]
return json.loads(content.strip())
else:
print(f"[LLMAnalyzer] API调用失败: {response.status_code} - {response.text}")
return None
except Exception as e:
print(f"[LLMAnalyzer] 调用异常: {e}")
import traceback
traceback.print_exc()
return None
def _call_llm_stream(self, prompt: str) -> Optional[Dict]:
"""调用大模型API(流式输出)"""
if not self._api_key:
return None
try:
headers = {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json"
}
data = {
"model": self._model,
"messages": [
{"role": "system", "content": "你是一位专业的金融分析师,擅长技术分析和趋势判断。请用JSON格式输出分析结果,不要有任何额外的文字说明。"},
{"role": "user", "content": prompt}
],
"temperature": 0.3,
"max_tokens": 4000,
"stream": True # 启用流式输出
}
response = requests.post(
f"{self._api_base}/chat/completions",
headers=headers,
json=data,
timeout=120,
stream=True # 流式响应
)
if response.status_code != 200:
print(f"[LLMAnalyzer] API调用失败: {response.status_code} - {response.text}")
self._broadcast_analysis_status("error", f"API调用失败: {response.status_code}")
return None
# 收集完整响应
full_content = ""
chunk_count = 0
for line in response.iter_lines():
if not line:
continue
line = line.decode('utf-8')
if line.startswith('data: '):
data_str = line[6:] # 去掉 'data: '
if data_str == '[DONE]':
break
try:
chunk_data = json.loads(data_str)
if 'choices' in chunk_data and len(chunk_data['choices']) > 0:
delta = chunk_data['choices'][0].get('delta', {})
content_piece = delta.get('content', '')
if content_piece:
full_content += content_piece
chunk_count += 1
# 每50个chunk广播一次进度
if chunk_count % 50 == 0:
self._broadcast_analysis_status(
"streaming",
f"正在接收分析结果... ({len(full_content)} 字符)"
)
except json.JSONDecodeError:
continue
print(f"[LLMAnalyzer] 流式接收完成,共 {chunk_count} 个chunk{len(full_content)} 字符")
# 提取JSON部分
if "```json" in full_content:
full_content = full_content.split("```json")[1].split("```")[0]
elif "```" in full_content:
full_content = full_content.split("```")[1].split("```")[0]
result = json.loads(full_content.strip())
return result
except json.JSONDecodeError as e:
print(f"[LLMAnalyzer] JSON解析失败: {e}")
self._broadcast_analysis_status("error", "JSON解析失败")
return None
except Exception as e:
print(f"[LLMAnalyzer] 流式调用异常: {e}")
import traceback
traceback.print_exc()
self._broadcast_analysis_status("error", f"调用异常: {str(e)}")
return None
def get_analysis(self, symbol: str = None) -> Dict:
"""
获取分析结果
Args:
symbol: 品种名称,不指定则返回所有
Returns:
分析结果
"""
with self._lock:
if symbol:
return self._analysis_results.get(symbol)
return dict(self._analysis_results)
def get_status(self) -> Dict:
"""获取分析器状态"""
with self._lock:
return {
"enabled": self._enabled,
"model": self._model,
"api_base": self._api_base,
"last_analysis_time": self._last_analysis_time,
"symbols_analyzed": list(self._analysis_results.keys()),
"interval_seconds": self.ANALYZE_INTERVAL
}
def trigger_analysis(self) -> Dict:
"""手动触发分析"""
if not self._enabled:
return {"status": "error", "message": "大模型分析未启用"}
try:
print("[LLMAnalyzer] 手动触发分析...")
self._run_analysis()
return {"status": "ok", "message": "分析完成", "analyzed_at": self._last_analysis_time}
except Exception as e:
print(f"[LLMAnalyzer] 手动触发分析失败: {e}")
import traceback
traceback.print_exc()
return {"status": "error", "message": str(e)}
def configure(self, api_key: str = None, api_base: str = None, model: str = None) -> Dict:
"""
配置大模型参数
Args:
api_key: API密钥
api_base: API基础URL
model: 模型名称
Returns:
配置结果
"""
if api_key:
self._api_key = api_key
os.environ["LLM_API_KEY"] = api_key
if api_base:
self._api_base = api_base
os.environ["LLM_API_BASE"] = api_base
if model:
self._model = model
os.environ["LLM_MODEL"] = model
# 保存到文件
self._save_to_file()
# 检查是否可以启用
was_enabled = self._enabled
self._enabled = bool(self._api_key)
# 如果从禁用变为启用,启动分析线程
if self._enabled and not was_enabled:
self._start_analyze_thread()
return {
"status": "ok",
"enabled": self._enabled,
"model": self._model,
"api_base": self._api_base
}
# ==================== WebSocket管理 ====================
def add_ws_client(self, client):
"""添加WebSocket客户端"""
with self._ws_lock:
self._ws_clients.add(client)
print(f"[LLMAnalyzer] WebSocket客户端已连接, 当前连接数: {len(self._ws_clients)}")
def remove_ws_client(self, client):
"""移除WebSocket客户端"""
with self._ws_lock:
self._ws_clients.discard(client)
print(f"[LLMAnalyzer] WebSocket客户端已断开, 当前连接数: {len(self._ws_clients)}")
def _broadcast_analysis_update(self):
"""广播分析更新通知"""
message = json.dumps({
"type": "llm_analysis_update",
"timestamp": self._last_analysis_time,
"symbols": list(self._analysis_results.keys())
})
self._broadcast_message(message)
def _broadcast_analysis_status(self, status: str, message: str):
"""广播分析状态更新"""
msg = json.dumps({
"type": "llm_analysis_status",
"status": status,
"message": message,
"timestamp": datetime.now().isoformat()
})
self._broadcast_message(msg)
def _broadcast_message(self, message: str):
"""广播消息到所有WebSocket客户端"""
with self._ws_lock:
clients = list(self._ws_clients)
if not clients:
return
# 使用保存的主事件循环
if self._main_loop and self._main_loop.is_running():
for client in clients:
try:
asyncio.run_coroutine_threadsafe(
self._send_to_client(client, message),
self._main_loop
)
except Exception as e:
print(f"[LLMAnalyzer] 广播消息失败: {e}")
else:
print(f"[LLMAnalyzer] 事件循环未就绪,跳过广播({len(clients)}个客户端)")
async def _send_to_client(self, client, message: str):
"""发送消息到客户端"""
try:
await client.send_text(message)
except Exception as e:
print(f"[LLMAnalyzer] 发送消息到客户端失败: {e}")
with self._ws_lock:
self._ws_clients.discard(client)
def check_entry_price_nearby(self, symbol: str, current_price: float, threshold: float = 0.0001) -> List[Dict]:
"""
检查当前价格是否接近AI建议的入场价
Args:
symbol: 交易品种
current_price: 当前价格
threshold: 价格接近阈值,默认万分之一(0.0001)
Returns:
匹配的交易建议列表
"""
matched_suggestions = []
current_time = datetime.now()
with self._lock:
analysis_data = self._analysis_results.get(symbol)
if not analysis_data or 'analysis' not in analysis_data:
return matched_suggestions
trade_suggestions = analysis_data['analysis'].get('trade_suggestions', [])
if not trade_suggestions:
return matched_suggestions
for suggestion in trade_suggestions:
entry_price = suggestion.get('entry_price')
period = suggestion.get('period')
direction = suggestion.get('direction')
if not entry_price or entry_price <= 0:
continue
# 计算价格差距百分比
if entry_price > 0:
price_diff_pct = abs(current_price - entry_price) / entry_price
# 如果在阈值范围内
if price_diff_pct <= threshold:
# 检查冷却
alert_key = (symbol, period, direction, entry_price)
with self._entry_alert_lock:
should_alert = True
if alert_key in self._alerted_entries:
last_alert_time = self._alerted_entries[alert_key]
elapsed = (current_time - last_alert_time).total_seconds()
if elapsed < self.entry_alert_cooldown:
should_alert = False
print(f"[LLMAnalyzer] 跳过AI入场价提醒(冷却中): {symbol} {period} "
f"入场价 {entry_price:.2f}, 剩余 {self.entry_alert_cooldown - elapsed:.0f}")
if should_alert:
# 记录提醒时间
self._alerted_entries[alert_key] = current_time
matched = {
"symbol": symbol,
"period": period,
"direction": direction,
"entry_price": entry_price,
"current_price": current_price,
"price_diff_pct": round(price_diff_pct * 100, 4),
"stop_loss": suggestion.get('stop_loss'),
"take_profit": suggestion.get('take_profit'),
"reason": suggestion.get('reason'),
"analyzed_at": analysis_data.get('analyzed_at'),
"match_type": "ai_entry_nearby"
}
matched_suggestions.append(matched)
print(f"[LLMAnalyzer] 价格接近AI入场价: {symbol} {period} "
f"入场价 {entry_price:.2f}, 当前价 {current_price:.2f}, 差距 {price_diff_pct*100:.4f}%")
# 清理过期的提醒记录
self._cleanup_entry_alerts()
return matched_suggestions
def _cleanup_entry_alerts(self):
"""清理过期的AI入场价提醒记录"""
current_time = datetime.now()
with self._entry_alert_lock:
keys_to_remove = []
for key, alert_time in self._alerted_entries.items():
elapsed = (current_time - alert_time).total_seconds()
if elapsed > self.entry_alert_cooldown * 2:
keys_to_remove.append(key)
for key in keys_to_remove:
del self._alerted_entries[key]
+655 -18
View File
@@ -10,12 +10,17 @@ from datetime import datetime
import threading
import asyncio
import json
import os
from .store import MarketStore, normalize_symbol
from .store import MarketStore
from .pivot_detector import PivotDetector
from .pending_orders import PendingOrderManager
# 配置文件路径
CONFIG_FILE = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'data', 'trade_config.json')
# 交易配置
class TradeConfig:
"""交易配置"""
@@ -29,12 +34,45 @@ class TradeConfig:
self.default_volume = 0.01 # 默认手数
self.default_sl_offset = 0.05 # 默认止损偏移(固定点数)
# 按品种配置: {symbol: {"volume": 0.01, "sl_offset": 0.05}}
# MT5服务器时区偏移(单位:小时)
# 正数表示MT5时间比本地时间快,负数表示比本地时间慢
# 例如:MT5服务器时间是GMT+2,本地时间是GMT+8,则偏移为 -6
self.mt5_timezone_offset = 0
# 按品种配置: {symbol: {"volume": 0.01, "sl_offset": 0.05, "key_levels": "5000,5100", "key_level_threshold": 0.0008}}
self.symbol_config = {
"GOLD#": {"volume": 0.01, "sl_offset": 0.5},
"OILCASH#": {"volume": 0.01, "sl_offset": 0.05},
}
# 启动时自动加载配置文件
self._load_from_file()
def _load_from_file(self):
"""从配置文件加载配置"""
try:
if os.path.exists(CONFIG_FILE):
with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
self.update(data)
print(f"[TradeConfig] 已从配置文件加载: mt5_timezone_offset={self.mt5_timezone_offset}")
else:
print(f"[TradeConfig] 配置文件不存在: {CONFIG_FILE},使用默认配置")
except Exception as e:
print(f"[TradeConfig] 加载配置文件失败: {e},使用默认配置")
def save_to_file(self):
"""保存配置到文件"""
try:
os.makedirs(os.path.dirname(CONFIG_FILE), exist_ok=True)
with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
json.dump(self.to_dict(), f, indent=2, ensure_ascii=False)
print(f"[TradeConfig] 配置已保存到: {CONFIG_FILE}")
return True
except Exception as e:
print(f"[TradeConfig] 保存配置文件失败: {e}")
return False
@classmethod
def get_instance(cls):
if cls._instance is None:
@@ -45,23 +83,52 @@ class TradeConfig:
def get_symbol_config(self, symbol: str) -> Dict:
"""获取品种配置,如果未配置则返回默认值"""
symbol = symbol.upper()
if symbol in self.symbol_config:
config = self.symbol_config[symbol]
return {
"volume": config.get("volume", self.default_volume),
"sl_offset": config.get("sl_offset", self.default_sl_offset)
"sl_offset": config.get("sl_offset", self.default_sl_offset),
"key_levels": config.get("key_levels", ""),
"key_level_threshold": config.get("key_level_threshold", 0.0008)
}
return {
"volume": self.default_volume,
"sl_offset": self.default_sl_offset
"sl_offset": self.default_sl_offset,
"key_levels": "",
"key_level_threshold": 0.0008
}
def get_key_levels(self, symbol: str) -> List[float]:
"""
获取品种的关键点位列表
Args:
symbol: 品种名称
Returns:
关键点位列表,如 [5000, 5100, 5200]
"""
config = self.get_symbol_config(symbol)
key_levels_str = config.get("key_levels", "")
if not key_levels_str:
return []
levels = []
for level_str in key_levels_str.split(","):
level_str = level_str.strip()
if level_str:
try:
levels.append(float(level_str))
except ValueError:
continue
return sorted(levels)
def to_dict(self) -> Dict:
return {
"enabled": self.enabled,
"default_volume": self.default_volume,
"default_sl_offset": self.default_sl_offset,
"mt5_timezone_offset": self.mt5_timezone_offset,
"symbol_config": self.symbol_config
}
@@ -72,6 +139,8 @@ class TradeConfig:
self.default_volume = float(data["default_volume"])
if "default_sl_offset" in data:
self.default_sl_offset = float(data["default_sl_offset"])
if "mt5_timezone_offset" in data:
self.mt5_timezone_offset = float(data["mt5_timezone_offset"])
if "symbol_config" in data:
self.symbol_config = data["symbol_config"]
@@ -80,11 +149,12 @@ class PivotMonitor:
"""转折点监控器"""
def __init__(self, store: MarketStore, detector: PivotDetector,
pending_orders: PendingOrderManager = None):
pending_orders: PendingOrderManager = None, llm_analyzer=None):
self.store = store
self.detector = detector
self.pending_orders = pending_orders
self.trade_config = TradeConfig.get_instance()
self.llm_analyzer = llm_analyzer
# WebSocket连接管理
self._ws_clients: Set = set()
@@ -95,14 +165,534 @@ class PivotMonitor:
self._alerted_pivots: Dict[tuple, datetime] = {}
self._alert_lock = threading.Lock()
# AI入场价提醒冷却(避免重复提醒)
self._alerted_ai_entries: Dict[str, datetime] = {}
# 关键点位订单冷却(避免重复生成订单)
# 结构: {symbol_key: datetime}
self._alerted_key_levels: Dict[str, datetime] = {}
# 主事件循环引用(在FastAPI启动时设置)
self._main_loop = None
# 提醒冷却时间(秒)
self.alert_cooldown = 300 # 5分钟内同一转折点不重复提醒
# 关键点位订单冷却时间(秒)- 与订单超时时间一致
self.key_level_cooldown = 180 # 3分钟
print("[PivotMonitor] 转折点监控器已初始化")
def set_event_loop(self, loop):
"""设置主事件循环引用"""
self._main_loop = loop
print(f"[PivotMonitor] 已设置主事件循环")
def set_statistics_history(self, statistics_history):
"""设置统计数据历史引用(用于获取价差)"""
self._statistics_history = statistics_history
def _get_symbol_spread(self, symbol: str) -> Optional[float]:
"""
获取指定品种的最新价差
Args:
symbol: 品种名称
Returns:
价差(金额),如果没有返回None
"""
if not hasattr(self, '_statistics_history') or not self._statistics_history:
return None
symbol_normalized = symbol.replace('#', '')
# 从最新的统计数据中查找该品种的价差
for stat in reversed(list(self._statistics_history)):
stat_symbol = stat.get('symbol', '')
stat_normalized = stat_symbol.replace('#', '')
if stat_normalized == symbol_normalized:
spread = stat.get('spread')
if spread is not None and spread > 0:
return spread
return None
def _calculate_take_profit(self, action: str, entry_price: float, sl: float, tp: float = None) -> Optional[float]:
"""
计算并修正止盈价格
规则:
1. 止盈方向必须正确(买入止盈>入场价,卖出止盈<入场价)
2. 风险回报比至少为1(止盈距离 >= 止损距离)
3. 如果不满足,按照风险回报比=1重新计算
Args:
action: 'b' 买入 或 's' 卖出
entry_price: 入场价格
sl: 止损价格
tp: 原始止盈价格(可能为None)
Returns:
修正后的止盈价格,如果止损设置有问题返回None
"""
if action == 'b':
# 买入:止损应该 < 入场价
risk = entry_price - sl
if risk <= 0:
# 止损设置有问题(止损高于入场价),不生成订单
print(f"[PivotMonitor] 警告: 买入止损{sl}高于入场价{entry_price},跳过订单")
return None
# 计算最小止盈(风险回报比=1
min_tp = entry_price + risk
# 如果没有止盈,或者止盈不满足条件,使用最小止盈
if tp is None or tp <= entry_price or (tp - entry_price) < risk:
print(f"[PivotMonitor] 修正买入止盈: 原{tp} -> 新{min_tp:.2f} (风险={risk:.2f})")
return round(min_tp, 2)
return round(tp, 2)
else: # action == 's'
# 卖出:止损应该 > 入场价
risk = sl - entry_price
if risk <= 0:
# 止损设置有问题(止损低于入场价),不生成订单
print(f"[PivotMonitor] 警告: 卖出止损{sl}低于入场价{entry_price},跳过订单")
return None
# 计算最小止盈(风险回报比=1
min_tp = entry_price - risk
# 如果没有止盈,或者止盈不满足条件,使用最小止盈
if tp is None or tp >= entry_price or (entry_price - tp) < risk:
print(f"[PivotMonitor] 修正卖出止盈: 原{tp} -> 新{min_tp:.2f} (风险={risk:.2f})")
return round(min_tp, 2)
return round(tp, 2)
def _get_auto_key_levels(self, symbol: str, current_price: float) -> List[float]:
"""
根据品种价格位数自动计算关键点位
规则:
- 一位数价格:能被1整除
- 两位数价格:能被5整除
- 三位数价格:能被10整除
- 四位数价格:能被100整除
- 五位数或六位数价格:能被1000整除
Args:
symbol: 品种名称
current_price: 当前价格
Returns:
关键点位列表(当前价格上下各3个)
"""
if current_price <= 0:
return []
# 计算整数部分位数
int_part = int(current_price)
num_digits = len(str(int_part)) if int_part > 0 else 1
# 根据位数确定步长
if num_digits == 1:
step = 1
elif num_digits == 2:
step = 5
elif num_digits == 3:
step = 10
elif num_digits == 4:
step = 100
else: # 5位数或6位数
step = 1000
# 计算当前价格所在的基础点位
base_level = int(current_price / step) * step
# 生成上下各3个关键点位
levels = []
for i in range(-3, 4):
level = base_level + i * step
if level > 0: # 确保价格为正
levels.append(float(level))
return sorted(levels)
def check_key_levels(self, symbol: str, current_price: float) -> Optional[Dict]:
"""
检查价格是否接近关键点位,并生成交易指令
策略逻辑:
- 向下走接近关键点位 → 买入(支撑位)
- 向上走接近关键点位 → 卖出(压力位)
如果没有配置关键点位,则自动计算关键点位
Args:
symbol: 交易品种
current_price: 当前价格
Returns:
交易指令或None
"""
if not self.trade_config.enabled:
return None
# 获取关键点位配置
key_levels = self.trade_config.get_key_levels(symbol)
# 如果没有配置关键点位,自动计算
if not key_levels:
key_levels = self._get_auto_key_levels(symbol, current_price)
if not key_levels:
return None
threshold = self.trade_config.get_symbol_config(symbol).get("key_level_threshold", 0.0008)
# 找到最近的关键点位
nearest_level = None
min_distance = float('inf')
for level in key_levels:
distance_pct = abs(current_price - level) / current_price
if distance_pct < min_distance:
min_distance = distance_pct
nearest_level = level
if nearest_level is None:
return None
# 判断是否在阈值范围内
distance_pct = abs(current_price - nearest_level) / current_price
if distance_pct > threshold:
return None
# 检查是否已经为该关键点位生成过订单(在冷却时间内)
current_time = datetime.now()
key_level_key = f"{symbol}_{nearest_level}"
if key_level_key in self._alerted_key_levels:
last_alert = self._alerted_key_levels[key_level_key]
elapsed = (current_time - last_alert).total_seconds()
if elapsed < self.key_level_cooldown:
# 还在冷却时间内,跳过
return None
# 记录提醒时间
self._alerted_key_levels[key_level_key] = current_time
# 判断走势方向:通过价格相对于关键点位的位置
# 获取品种配置
config = self.trade_config.get_symbol_config(symbol)
volume = config["volume"]
# 获取价差
spread = self._get_symbol_spread(symbol)
# 根据价格与关键点位的关系判断方向
if current_price > nearest_level:
# 价格在关键点位上方,向下接近 → 买入(支撑位)
action = 'b'
sl = nearest_level - (nearest_level * 0.006) # 关键点位下方万分之六
if spread:
sl -= spread # 买入止损需要更低
# 止盈:1.5倍风险回报比
risk = current_price - sl
tp = current_price + risk * 1.5
if spread:
tp -= spread # 买入止盈需要更低
reason = f"关键点位策略: 价格向下接近 {nearest_level}(支撑位)"
else:
# 价格在关键点位下方,向上接近 → 卖出(压力位)
action = 's'
sl = nearest_level + (nearest_level * 0.006) # 关键点位上方万分之六
if spread:
sl += spread # 卖出止损需要更高
# 止盈:1.5倍风险回报比
risk = sl - current_price
tp = current_price - risk * 1.5
if spread:
tp += spread # 卖出止盈需要更高
reason = f"关键点位策略: 价格向上接近 {nearest_level}(压力位)"
# 验证并修正止盈
tp = self._calculate_take_profit(action, current_price, sl, tp)
if tp is None:
# 止损设置有问题,不生成订单
return None
# 获取各周期的AI建议方向
ai_directions = self._get_ai_directions_by_period(symbol)
key_level_direction_text = '买入' if action == 'b' else '卖出'
# 判断方向一致性并生成建议
direction_analysis = self._analyze_direction_consistency(action, ai_directions)
# 创建订单
order = {
"symbol": symbol,
"action": action,
"price": current_price,
"mount": volume,
"sl": round(sl, 2),
"tp": tp,
"reason": reason,
"description": "Key Level Strategy",
"source": "key_level",
"key_level": nearest_level,
"distance_pct": round(distance_pct * 100, 4),
"generated_at": current_time.isoformat(),
# 新增AI方向对比字段
"ai_directions": ai_directions, # 各周期AI方向
"key_level_direction_text": key_level_direction_text,
"direction_consistent": direction_analysis['is_consistent'],
"consistent_periods": direction_analysis['consistent_periods'],
"inconsistent_periods": direction_analysis['inconsistent_periods'],
"recommendation": direction_analysis['recommendation'],
"recommendation_color": direction_analysis['recommendation_color']
}
# 添加到待确认订单
if self.pending_orders:
order_id = self.pending_orders.add_order(order)
order["order_id"] = order_id
print(f"[PivotMonitor] 关键点位策略生成订单: {order_id} - {action} {symbol} @ {current_price}, 关键位={nearest_level}, SL={sl:.2f}, TP={tp:.2f}")
print(f"[PivotMonitor] AI各周期方向: {ai_directions}, 关键点位方向: {key_level_direction_text}, 一致周期: {direction_analysis['consistent_periods']}, 建议: {direction_analysis['recommendation']}")
# 推送关键点位订单通知到前端
self._broadcast_key_level_order(order)
return order
return None
def _get_ai_directions_by_period(self, symbol: str) -> Dict[str, Dict]:
"""
获取AI各周期的交易建议方向
Args:
symbol: 交易品种
Returns:
{period: {'direction': 'buy'/'sell', 'text': '买入'/'卖出', 'entry_price': xxx}}
"""
if not self.llm_analyzer:
return {}
result = {}
try:
analysis = self.llm_analyzer.get_analysis(symbol)
if not analysis:
return {}
# 从交易建议中获取各周期方向
analysis_data = analysis.get('analysis', {})
trade_suggestions = analysis_data.get('trade_suggestions', [])
for suggestion in trade_suggestions:
period = suggestion.get('period', '')
direction = suggestion.get('direction', '')
entry_price = suggestion.get('entry_price')
if period and direction:
# 标准化方向
direction_lower = direction.lower().strip()
if direction_lower in ['buy', '买入', '多头']:
direction_normalized = 'buy'
direction_text = '买入'
elif direction_lower in ['sell', '卖出', '空头']:
direction_normalized = 'sell'
direction_text = '卖出'
else:
continue
result[period] = {
'direction': direction_normalized,
'text': direction_text,
'entry_price': entry_price
}
return result
except Exception as e:
print(f"[PivotMonitor] 获取AI各周期方向失败: {e}")
return {}
def _analyze_direction_consistency(self, key_level_action: str, ai_directions: Dict[str, Dict]) -> Dict:
"""
分析关键点位方向与AI各周期方向的一致性
Args:
key_level_action: 'b''s'
ai_directions: {period: {'direction': 'buy'/'sell', ...}}
Returns:
{
'is_consistent': bool, # 是否有任一周期一致
'consistent_periods': [], # 一致的周期列表
'inconsistent_periods': [], # 不一致的周期列表
'recommendation': str, # 建议文本
'recommendation_color': str # 建议颜色
}
"""
if not ai_directions:
return {
'is_consistent': False,
'consistent_periods': [],
'inconsistent_periods': [],
'recommendation': 'AI暂无建议,请谨慎操作',
'recommendation_color': 'warning'
}
consistent_periods = []
inconsistent_periods = []
for period, dir_info in ai_directions.items():
ai_dir = dir_info.get('direction', '')
# b = buy, s = sell
if (key_level_action == 'b' and ai_dir == 'buy') or \
(key_level_action == 's' and ai_dir == 'sell'):
consistent_periods.append(period)
else:
inconsistent_periods.append(period)
# 判断整体一致性
is_consistent = len(consistent_periods) > 0 and len(inconsistent_periods) == 0
# 生成建议
if len(consistent_periods) == len(ai_directions):
# 全部一致
recommendation = f"AI各周期方向一致,建议下单"
recommendation_color = "success"
elif len(consistent_periods) > 0:
# 部分一致
recommendation = f"AI部分周期一致({','.join(consistent_periods)}),建议谨慎"
recommendation_color = "warning"
else:
# 全部不一致
recommendation = f"AI方向不一致,建议慎重"
recommendation_color = "error"
return {
'is_consistent': is_consistent,
'consistent_periods': consistent_periods,
'inconsistent_periods': inconsistent_periods,
'recommendation': recommendation,
'recommendation_color': recommendation_color
}
def check_ai_entry(self, symbol: str, current_price: float) -> List[Dict]:
"""
检查价格是否接近AI建议的入场价,并生成交易指令
Args:
symbol: 交易品种
current_price: 当前价格
Returns:
AI入场价提醒列表
"""
if not self.llm_analyzer:
return []
if not self.trade_config.enabled:
return []
# 检查AI入场价
ai_matches = self.llm_analyzer.check_entry_price_nearby(symbol, current_price, threshold=0.0001)
ai_entry_alerts = []
current_time = datetime.now()
# 获取价差
spread = self._get_symbol_spread(symbol)
for match in ai_matches:
# 生成待确认订单
action = 'b' if match['direction'] == 'buy' else 's'
# 检查是否已经提醒过这个AI入场价(5分钟内不重复)
ai_key = f"{symbol}_{match['period']}_{match['entry_price']}_{match['direction']}"
if ai_key in self._alerted_ai_entries:
last_alert = self._alerted_ai_entries[ai_key]
elapsed = (current_time - last_alert).total_seconds()
if elapsed < self.alert_cooldown:
continue
# 记录提醒时间
self._alerted_ai_entries[ai_key] = current_time
# 根据方向调整止损止盈(考虑价差)
sl = match['stop_loss']
tp = match['take_profit']
if spread:
if action == 'b':
# 买入:止损需要更低,止盈需要更低
sl -= spread
tp -= spread
else:
# 卖出:止损需要更高,止盈需要更高
sl += spread
tp += spread
# 验证并修正止盈
tp = self._calculate_take_profit(action, current_price, sl, tp)
if tp is None:
# 止损设置有问题,跳过此订单
continue
order = {
"symbol": symbol,
"action": action,
"price": current_price,
"mount": self.trade_config.get_symbol_config(symbol).get("volume", 0.01),
"sl": round(sl, 2) if sl else None,
"tp": tp,
"reason": f"AI建议入场: {match['reason']}",
"description": "AI Trend Strategy",
"source": "ai_entry_nearby",
"ai_period": match['period'],
"ai_entry_price": match['entry_price'],
"ai_direction": match['direction'],
"generated_at": current_time.isoformat()
}
# 添加到待确认订单
if self.pending_orders:
order_id = self.pending_orders.add_order(order)
order["order_id"] = order_id
# 构建提醒
alert = {
"type": "ai_entry_alert",
"symbol": symbol,
"period": match['period'],
"direction": match['direction'],
"entry_price": match['entry_price'],
"current_price": current_price,
"price_diff_pct": match['price_diff_pct'],
"stop_loss": sl,
"take_profit": tp,
"reason": match['reason'],
"pending_order": order,
"timestamp": current_time.isoformat()
}
ai_entry_alerts.append(alert)
print(f"[PivotMonitor] AI趋势策略生成订单: {order_id} - {action} {symbol} @ {current_price}, AI入场价={match['entry_price']}")
# 广播AI入场价提醒
self._broadcast_alert(alert)
return ai_entry_alerts
def check_and_alert(self, symbol: str, current_price: float) -> List[Dict]:
"""
检查价格是否接近转折点,并发送提醒
同时检测关键点位策略
Args:
symbol: 交易品种
@@ -111,7 +701,11 @@ class PivotMonitor:
Returns:
接近的转折点列表
"""
symbol = normalize_symbol(symbol)
# 检查关键点位策略
self.check_key_levels(symbol, current_price)
# 检查AI趋势策略
self.check_ai_entry(symbol, current_price)
# 检查是否接近转折点
near_pivots = self.detector.check_near_pivot(symbol, current_price)
@@ -227,24 +821,32 @@ class PivotMonitor:
volume = config["volume"]
sl_offset = config["sl_offset"] # 固定点数偏移
# 获取价差
spread = self._get_symbol_spread(symbol)
order = None
if alert_type == 'near_low':
# 接近低点 → 买入
# 止损 = 低点 - 配置的偏移
sl = pivot_price - sl_offset
if spread:
sl -= spread # 买入止损需要更低
# 止盈 = 最近的高点
tp = self._find_nearest_pivot_price(symbol, 'high', current_price)
if tp and tp > current_price:
# 验证并修正止盈
tp = self._calculate_take_profit('b', current_price, sl, tp)
if tp is not None:
order = {
"symbol": symbol,
"action": "b", # 买入
"price": current_price,
"mount": volume,
"sl": round(sl, 2),
"tp": round(tp, 2),
"tp": tp,
"reason": f"M1接近低点{pivot_price:.2f},建议买入,止损{sl:.2f},止盈{tp:.2f}",
"description": "Pivot Strategy",
"source": "auto_pivot_m1",
"pivot_price": pivot_price,
"generated_at": current_time.isoformat()
@@ -254,18 +856,23 @@ class PivotMonitor:
# 接近高点 → 卖出
# 止损 = 高点 + 配置的偏移
sl = pivot_price + sl_offset
if spread:
sl += spread # 卖出止损需要更高
# 止盈 = 最近的低点
tp = self._find_nearest_pivot_price(symbol, 'low', current_price)
if tp and tp < current_price:
# 验证并修正止盈
tp = self._calculate_take_profit('s', current_price, sl, tp)
if tp is not None:
order = {
"symbol": symbol,
"action": "s", # 卖出
"price": current_price,
"mount": volume,
"sl": round(sl, 2),
"tp": round(tp, 2),
"tp": tp,
"reason": f"M1接近高点{pivot_price:.2f},建议卖出,止损{sl:.2f},止盈{tp:.2f}",
"description": "Pivot Strategy",
"source": "auto_pivot_m1",
"pivot_price": pivot_price,
"generated_at": current_time.isoformat()
@@ -293,7 +900,6 @@ class PivotMonitor:
Returns:
最近的转折点价格,如果没有返回None
"""
symbol = normalize_symbol(symbol)
nearest_price = None
min_distance = float('inf')
@@ -357,12 +963,45 @@ class PivotMonitor:
with self._ws_lock:
clients = list(self._ws_clients)
# 在事件循环中发送消息
for client in clients:
if not clients:
return
# 使用保存的主事件循环
if self._main_loop and self._main_loop.is_running():
for client in clients:
try:
asyncio.run_coroutine_threadsafe(
self._send_to_client(client, message),
self._main_loop
)
except Exception as e:
print(f"[PivotMonitor] 发送WebSocket消息失败: {e}")
else:
# 如果事件循环未就绪,尝试直接创建任务
try:
asyncio.create_task(self._send_to_client(client, message))
for client in clients:
asyncio.create_task(self._send_to_client(client, message))
except Exception as e:
print(f"[PivotMonitor] 发送WebSocket消息失败: {e}")
print(f"[PivotMonitor] 广播消息失败: {e}")
def _broadcast_key_level_order(self, order: Dict):
"""广播关键点位订单通知到前端"""
action_text = '买入' if order['action'] == 'b' else '卖出'
alert = {
"type": "key_level_alert",
"symbol": order['symbol'],
"action": order['action'],
"action_text": action_text,
"price": order['price'],
"sl": order['sl'],
"tp": order['tp'],
"key_level": order['key_level'],
"distance_pct": order['distance_pct'],
"reason": order['reason'],
"pending_order": order,
"message": f"{order['symbol']} 关键点位策略: {action_text} @ {order['price']}, 关键位={order['key_level']}"
}
self._broadcast_alert(alert)
async def _send_to_client(self, client, message: str):
"""发送消息到客户端"""
@@ -393,8 +1032,6 @@ class PivotMonitor:
def clear_symbol(self, symbol: str):
"""清除某个Symbol的提醒记录"""
symbol = normalize_symbol(symbol)
with self._alert_lock:
keys_to_remove = [k for k in self._alerted_pivots if k[0] == symbol]
for key in keys_to_remove:
File diff suppressed because it is too large Load Diff
+332
View File
@@ -0,0 +1,332 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
新闻监控模块
分析影响、推送提醒
财经日历数据由EA端通过MT5 API获取后推送
"""
import asyncio
from datetime import datetime, timedelta
from typing import List, Dict, Optional, Set
import json
import threading
from .news_crawler import Jin10Crawler, get_jin10_crawler
from .news_store import CalendarEvent, FlashNews, get_news_store
from .event_config import get_high_impact_event_names
from .system_log import get_system_log
class NewsMonitor:
"""新闻监控器"""
def __init__(self):
self.crawler = get_jin10_crawler() # 仅用于快讯
self.store = get_news_store()
self.system_log = get_system_log()
# WebSocket客户端
self._ws_clients: Set = set()
self._ws_lock = threading.Lock()
# 主事件循环引用
self._main_loop = None
# 是否正在运行
self._running = False
# 高影响事件名称
self._high_impact_names = get_high_impact_event_names()
# 已调度的事件
self._scheduled_events: Dict[str, asyncio.Task] = {}
print("[NewsMonitor] 新闻监控器已初始化")
# 记录日志
self.system_log.add_log("news_crawler_start", message="新闻监控器已初始化(财经日历由EA推送)")
def set_event_loop(self, loop):
"""设置主事件循环引用"""
self._main_loop = loop
print("[NewsMonitor] 已设置主事件循环")
def add_ws_client(self, client):
"""添加WebSocket客户端"""
with self._ws_lock:
self._ws_clients.add(client)
print(f"[NewsMonitor] WebSocket客户端已连接, 当前连接数: {len(self._ws_clients)}")
def remove_ws_client(self, client):
"""移除WebSocket客户端"""
with self._ws_lock:
self._ws_clients.discard(client)
print(f"[NewsMonitor] WebSocket客户端已断开, 当前连接数: {len(self._ws_clients)}")
def get_ws_client_count(self) -> int:
"""获取WebSocket客户端数量"""
with self._ws_lock:
return len(self._ws_clients)
# ==================== 主循环 ====================
async def run(self):
"""主运行循环"""
if self._running:
print("[NewsMonitor] 已经在运行中")
return
self._running = True
print("[NewsMonitor] 开始运行...")
# 启动多个并行任务
await asyncio.gather(
self._flash_news_loop(), # 快讯监控(每30秒)
self._event_reminder_loop(), # 事件提醒(每分钟检查)
self._cleanup_loop(), # 过期数据清理(每10分钟)
)
async def stop(self):
"""停止运行"""
self._running = False
await self.crawler.close()
print("[NewsMonitor] 已停止")
# ==================== 事件提醒循环 ====================
async def _event_reminder_loop(self):
"""检查即将发布的事件并发送提醒"""
while self._running:
try:
now = datetime.now()
# 获取未来1小时内的重要事件
events = self.store.get_upcoming_events(hours=1)
for event in events:
if not event.publish_time:
continue
# 发布前5分钟提醒
time_to_publish = (event.publish_time - now).total_seconds()
if 0 < time_to_publish <= 300: # 5分钟内
if not self.store.is_event_alerted(f"{event.id}_reminder"):
await self._send_event_reminder(event)
self.store.mark_event_alerted(f"{event.id}_reminder")
# 每分钟检查一次
await asyncio.sleep(60)
except Exception as e:
print(f"[NewsMonitor] 事件提醒检查异常: {e}")
await asyncio.sleep(30)
async def _send_event_reminder(self, event: CalendarEvent):
"""发送事件提醒"""
alert = {
"type": "event_reminder",
"event": event.to_dict(),
"message": f"重要数据 {event.name} 将在5分钟内发布",
"timestamp": datetime.now().isoformat()
}
await self._broadcast_alert(alert)
self.system_log.add_log("news_event_reminder", detail={
"event_id": event.id,
"event_name": event.name,
"currency": event.currency
}, message=f"事件发布前提醒: {event.name}")
print(f"[NewsMonitor] 事件提醒: {event.name}")
# ==================== 快讯循环 ====================
async def _flash_news_loop(self):
"""快讯监控循环"""
max_id = 0
check_count = 0
while self._running:
try:
check_count += 1
# 每10次检查记录一次日志
if check_count % 10 == 0:
self.system_log.add_log("news_flash_fetch", detail={
"check_count": check_count,
"max_id": max_id
}, message=f"快讯检查 #{check_count}")
# 获取最新快讯
news_list = await self.crawler.fetch_flash_news(max_id=max_id, count=20)
if news_list:
self.system_log.add_log("news_flash_fetch", detail={
"count": len(news_list)
}, message=f"获取到 {len(news_list)} 条快讯")
for news in reversed(news_list): # 按时间顺序处理
# 检查是否已处理
if self.store.is_news_alerted(news.id):
continue
# 分析影响
analysis = self.crawler.analyze_news_impact(news)
# 只推送有影响的快讯
if analysis['impact'] or analysis['speaker']:
news.speaker = analysis['speaker']
news.speaker_title = analysis['speaker_title']
news.impact = analysis['impact']
news.analyzed = True
news.importance = 2 if analysis['speaker'] else 1
# 添加到存储
self.store.add_flash_news(news)
# 推送提醒
alert = {
"type": "flash_news",
"news": news.to_dict(),
"analysis": analysis,
"timestamp": datetime.now().isoformat()
}
await self._broadcast_alert(alert)
self.system_log.add_log("news_impact_analysis", detail={
"news_id": news.id,
"speaker": news.speaker,
"impact": news.impact
}, message=f"快讯影响分析: {news.speaker or '事件'} -> {list(news.impact.keys())}")
print(f"[NewsMonitor] 快讯已推送: {news.id} - {news.speaker}")
# 标记已处理
self.store.mark_news_alerted(news.id)
# 更新max_id
try:
if int(news.id) > max_id:
max_id = int(news.id)
except:
pass
# 每30秒检查一次
await asyncio.sleep(30)
except Exception as e:
self.system_log.add_log("news_flash_fetch_error", detail={
"error": str(e)
}, message=f"快讯监控异常: {e}")
print(f"[NewsMonitor] 快讯监控异常: {e}")
await asyncio.sleep(10)
# ==================== 清理循环 ====================
async def _cleanup_loop(self):
"""定期清理过期数据"""
while self._running:
try:
# 每10分钟清理一次
await asyncio.sleep(600)
removed = self.store.cleanup_expired_events()
if removed > 0:
print(f"[NewsMonitor] 已清理 {removed} 条过期事件")
except Exception as e:
print(f"[NewsMonitor] 清理任务异常: {e}")
await asyncio.sleep(60)
# ==================== 广播消息 ====================
async def _broadcast_alert(self, alert: Dict):
"""广播提醒到所有WebSocket客户端"""
message = json.dumps(alert, ensure_ascii=False)
with self._ws_lock:
clients = list(self._ws_clients)
if not clients:
return
if self._main_loop and self._main_loop.is_running():
for client in clients:
try:
asyncio.run_coroutine_threadsafe(
self._send_to_client(client, message),
self._main_loop
)
except Exception as e:
print(f"[NewsMonitor] 发送WebSocket消息失败: {e}")
else:
for client in clients:
try:
await self._send_to_client(client, message)
except Exception as e:
print(f"[NewsMonitor] 发送消息失败: {e}")
async def _send_to_client(self, client, message: str):
"""发送消息到客户端"""
try:
await client.send_text(message)
except Exception as e:
print(f"[NewsMonitor] 发送消息到客户端失败: {e}")
with self._ws_lock:
self._ws_clients.discard(client)
async def _broadcast_calendar_update(self):
"""广播日历更新"""
calendar = self.store.get_calendar()
message = json.dumps({
"type": "calendar_update",
"data": calendar
}, ensure_ascii=False)
with self._ws_lock:
clients = list(self._ws_clients)
for client in clients:
try:
await self._send_to_client(client, message)
except Exception:
pass
# ==================== 状态查询 ====================
def get_status(self) -> Dict:
"""获取监控状态"""
return {
"running": self._running,
"ws_clients": self.get_ws_client_count(),
"store_status": self.store.get_status(),
"scheduled_events": len(self._scheduled_events)
}
def get_calendar(self, date_str: str = None) -> List[Dict]:
"""获取财经日历"""
return self.store.get_calendar(date_str)
def get_upcoming_events(self, hours: int = 24) -> List[Dict]:
"""获取即将发布的事件"""
events = self.store.get_upcoming_events(hours)
return [e.to_dict() for e in events]
def get_recent_news(self, count: int = 20) -> List[Dict]:
"""获取最近快讯"""
return self.store.get_flash_news(count)
# 全局单例
_news_monitor = None
def get_news_monitor() -> NewsMonitor:
"""获取新闻监控器单例"""
global _news_monitor
if _news_monitor is None:
_news_monitor = NewsMonitor()
return _news_monitor
+375
View File
@@ -0,0 +1,375 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
新闻数据存储模块
存储财经日历、快讯和事件数据
"""
from collections import deque
from datetime import datetime, timedelta
from typing import List, Dict, Optional
import threading
from dataclasses import dataclass, field
@dataclass
class CalendarEvent:
"""财经日历事件"""
id: str
name: str
name_en: str = ""
country: str = ""
currency: str = "" # 货币代码
importance: int = 0 # 0-3
publish_time: datetime = None
forecast: str = ""
previous: str = ""
actual: str = ""
unit: str = ""
symbols: List[str] = field(default_factory=list)
event_type: str = "" # 事件类型(指标、讲话等)
# 发布后填充
result: str = "" # better/worse/in_line
impact: Dict = field(default_factory=dict) # {symbol: {direction, reason}}
analyzed: bool = False
def to_dict(self) -> Dict:
return {
"id": self.id,
"name": self.name,
"name_en": self.name_en,
"country": self.country,
"currency": self.currency,
"importance": self.importance,
"publish_time": self.publish_time.isoformat() if self.publish_time else None,
"forecast": self.forecast,
"previous": self.previous,
"actual": self.actual,
"unit": self.unit,
"symbols": self.symbols,
"event_type": self.event_type,
"result": self.result,
"impact": self.impact,
"analyzed": self.analyzed
}
@dataclass
class FlashNews:
"""快讯数据"""
id: str
content: str
source: str = ""
time: datetime = None
importance: int = 0
keywords: List[str] = field(default_factory=list)
related_symbols: List[str] = field(default_factory=list)
# 分析后填充
speaker: str = ""
speaker_title: str = ""
impact: Dict = field(default_factory=dict)
analyzed: bool = False
def to_dict(self) -> Dict:
return {
"id": self.id,
"content": self.content,
"source": self.source,
"time": self.time.isoformat() if self.time else None,
"importance": self.importance,
"keywords": self.keywords,
"related_symbols": self.related_symbols,
"speaker": self.speaker,
"speaker_title": self.speaker_title,
"impact": self.impact,
"analyzed": self.analyzed
}
class NewsStore:
"""新闻存储"""
# 过期数据清理阈值(小时)
EXPIRY_HOURS = 6
def __init__(self):
# 财经日历: 使用列表存储所有事件,按时间排序
# 不再按日期分片,直接存储在内存中
self._calendar_events: List[CalendarEvent] = []
self._calendar_lock = threading.RLock()
# 快讯历史: deque[FlashNews]
# 保留最新100条
self._flash_news: deque = deque(maxlen=100)
self._news_lock = threading.RLock()
# 已提醒的事件ID
self._alerted_events: set = set()
self._alerted_news: set = set()
# 即将发布的重要事件(用于调度)
self._upcoming_events: Dict[str, CalendarEvent] = {}
print("[NewsStore] 新闻存储已初始化")
# ==================== 财经日历 ====================
def update_calendar_from_mt5(self, events: List[Dict]) -> int:
"""
从MT5数据更新财经日历
Args:
events: MT5返回的事件列表
Returns:
更新的事件数量
"""
now = datetime.now()
expiry_threshold = now - timedelta(hours=self.EXPIRY_HOURS)
with self._calendar_lock:
# 1. 清理过期数据
self._calendar_events = [
e for e in self._calendar_events
if e.publish_time and e.publish_time > expiry_threshold
]
# 2. 构建现有事件的ID集合
existing_ids = {e.id for e in self._calendar_events}
# 3. 添加或更新事件
new_count = 0
update_count = 0
for event_data in events:
event_id = str(event_data.get('id', ''))
# 解析发布时间
publish_time = event_data.get('publish_time')
if isinstance(publish_time, str):
try:
# 尝试ISO格式
publish_time = datetime.fromisoformat(publish_time.replace('Z', '+00:00'))
except:
try:
# 尝试MQL5 TimeToString格式: "2026.03.16 20:30:00"
publish_time = datetime.strptime(publish_time, '%Y.%m.%d %H:%M:%S')
except Exception as e:
print(f"[NewsStore] 无法解析时间 '{publish_time}': {e}")
continue
elif not isinstance(publish_time, datetime):
print(f"[NewsStore] 事件 {event_id} 缺少有效的publish_time")
continue
# 跳过过期数据
if publish_time < expiry_threshold:
continue
# 创建事件对象
event = CalendarEvent(
id=event_id,
name=event_data.get('name', ''),
name_en=event_data.get('name_en', ''),
country=event_data.get('country', ''),
currency=event_data.get('currency', ''),
importance=event_data.get('importance', 0),
publish_time=publish_time,
forecast=event_data.get('forecast', ''),
previous=event_data.get('previous', ''),
actual=event_data.get('actual', ''),
unit=event_data.get('unit', ''),
symbols=event_data.get('symbols', []),
event_type=event_data.get('event_type', '')
)
if event_id in existing_ids:
# 更新现有事件
for i, e in enumerate(self._calendar_events):
if e.id == event_id:
self._calendar_events[i] = event
update_count += 1
break
else:
# 添加新事件
self._calendar_events.append(event)
new_count += 1
# 4. 按时间排序
self._calendar_events.sort(key=lambda x: x.publish_time or datetime.min)
total = len(self._calendar_events)
print(f"[NewsStore] MT5财经日历更新: 新增{new_count}条, 更新{update_count}条, 当前共{total}")
return new_count + update_count
def get_calendar(self, date_str: str = None) -> List[Dict]:
"""
获取财经日历
Args:
date_str: 日期,None返回所有
Returns:
事件列表
"""
with self._calendar_lock:
if date_str:
# 过滤指定日期
filtered = [
e for e in self._calendar_events
if e.publish_time and e.publish_time.strftime('%Y-%m-%d') == date_str
]
return [e.to_dict() for e in filtered]
else:
return [e.to_dict() for e in self._calendar_events]
def get_upcoming_events(self, hours: int = 24) -> List[CalendarEvent]:
"""
获取即将发布的重要事件
Args:
hours: 未来多少小时内
Returns:
事件列表
"""
now = datetime.now()
upcoming = []
with self._calendar_lock:
for event in self._calendar_events:
if event.publish_time and event.importance >= 2:
delta = event.publish_time - now
if 0 < delta.total_seconds() <= hours * 3600:
upcoming.append(event)
return sorted(upcoming, key=lambda x: x.publish_time)
def get_event_by_id(self, event_id: str) -> Optional[CalendarEvent]:
"""根据ID获取事件"""
with self._calendar_lock:
for event in self._calendar_events:
if event.id == event_id:
return event
return None
def update_event_result(self, event_id: str, actual: str, result: str, impact: Dict) -> None:
"""更新事件结果"""
with self._calendar_lock:
event = self.get_event_by_id(event_id)
if event:
event.actual = actual
event.result = result
event.impact = impact
event.analyzed = True
print(f"[NewsStore] 更新事件结果: {event.name}, 实际值={actual}, 结果={result}")
def is_event_alerted(self, event_id: str) -> bool:
"""检查事件是否已提醒"""
return event_id in self._alerted_events
def mark_event_alerted(self, event_id: str) -> None:
"""标记事件已提醒"""
self._alerted_events.add(event_id)
def cleanup_expired_events(self) -> int:
"""
清理过期超过6小时的事件
Returns:
清理的事件数量
"""
now = datetime.now()
expiry_threshold = now - timedelta(hours=self.EXPIRY_HOURS)
with self._calendar_lock:
before_count = len(self._calendar_events)
self._calendar_events = [
e for e in self._calendar_events
if e.publish_time and e.publish_time > expiry_threshold
]
removed = before_count - len(self._calendar_events)
if removed > 0:
print(f"[NewsStore] 清理过期事件: {removed}")
return removed
# ==================== 快讯 ====================
def add_flash_news(self, news: FlashNews) -> bool:
"""
添加快讯
Returns:
是否新增(False表示已存在)
"""
with self._news_lock:
# 检查是否已存在
for existing in self._flash_news:
if existing.id == news.id:
return False
self._flash_news.appendleft(news)
print(f"[NewsStore] 新增快讯: {news.id}")
return True
def get_flash_news(self, count: int = 20) -> List[Dict]:
"""获取最新快讯"""
with self._news_lock:
news_list = list(self._flash_news)[:count]
return [n.to_dict() for n in news_list]
def is_news_alerted(self, news_id: str) -> bool:
"""检查快讯是否已提醒"""
return news_id in self._alerted_news
def mark_news_alerted(self, news_id: str) -> None:
"""标记快讯已提醒"""
self._alerted_news.add(news_id)
def update_news_analysis(self, news_id: str, speaker: str, speaker_title: str, impact: Dict) -> None:
"""更新快讯分析结果"""
with self._news_lock:
for news in self._flash_news:
if news.id == news_id:
news.speaker = speaker
news.speaker_title = speaker_title
news.impact = impact
news.analyzed = True
break
# ==================== 统计 ====================
def get_status(self) -> Dict:
"""获取存储状态"""
with self._calendar_lock, self._news_lock:
return {
"calendar_events": len(self._calendar_events),
"flash_news_count": len(self._flash_news),
"alerted_events": len(self._alerted_events),
"alerted_news": len(self._alerted_news)
}
def clear(self) -> None:
"""清空所有数据"""
with self._calendar_lock, self._news_lock:
self._calendar_events.clear()
self._flash_news.clear()
self._alerted_events.clear()
self._alerted_news.clear()
print("[NewsStore] 已清空所有数据")
# 全局单例
_news_store = None
def get_news_store() -> NewsStore:
"""获取新闻存储单例"""
global _news_store
if _news_store is None:
_news_store = NewsStore()
return _news_store
+134 -91
View File
@@ -10,7 +10,7 @@ from datetime import datetime
from typing import List, Dict, Optional, Tuple
import threading
from .store import KlineData, normalize_symbol
from .store import KlineData
class PivotPoint:
@@ -18,7 +18,7 @@ class PivotPoint:
def __init__(self, symbol: str, period: str, timestamp, price: float,
direction: str, strength: int = 3):
self.symbol = normalize_symbol(symbol)
self.symbol = symbol
self.period = period
self.timestamp = timestamp
self.price = price
@@ -55,15 +55,32 @@ class PivotDetector:
'M1': 0.0002 # 千分之0.2
}
# 各周期转折强度(左右各N根K线)
# M1: 6根K线, M5: 4根K线, M15/H1/H4: 3根K线
PERIOD_STRENGTH = {
'M1': 6,
'M5': 4,
'M15': 3,
'H1': 3,
'H4': 3
}
def __init__(self):
# 存储转折点: {SYMBOL: {PERIOD: [PivotPoint, ...]}}
# 这是合并后的转折点,用于价格接近检测
self._pivots = defaultdict(lambda: defaultdict(list))
# 转折点时间线: {SYMBOL: {PERIOD: [PivotPoint, ...]}}
# 这是合并前的原始转折点,按时间排序,用于判断趋势方向
self._pivots_timeline = defaultdict(lambda: defaultdict(list))
self._lock = threading.RLock()
# 默认转折强度(左右各N根K线)
# 默认转折强度(左右各N根K线)- 仅作为后备值
self.default_strength = 3
print("[PivotDetector] 转折点检测器已初始化")
print(f"[PivotDetector] 周期强度配置: {self.PERIOD_STRENGTH}")
def detect_pivots(self, symbol: str, period: str, klines: List[KlineData],
strength: int = None) -> List[PivotPoint]:
@@ -74,13 +91,14 @@ class PivotDetector:
symbol: 交易品种
period: 周期
klines: K线数据列表
strength: 转折强度(左右各N根K线)
strength: 转折强度(左右各N根K线)None则使用周期默认值
Returns:
检测到的转折点列表
"""
# 优先使用传入的strength,否则使用周期配置的strength
if strength is None:
strength = self.default_strength
strength = self.PERIOD_STRENGTH.get(period, self.default_strength)
if len(klines) < 2 * strength + 1:
return []
@@ -134,8 +152,8 @@ class PivotDetector:
合并相近的转折点
合并规则:
- K线距离小于26根
- 价格相差在万分之范围内
- 相邻两个同方向转折点
- 价格相差在万分之范围内
- 高点合并:取较高的价格
- 低点合并:取较低的价格
@@ -149,49 +167,37 @@ class PivotDetector:
if len(pivots) < 2:
return pivots
# 建立K线时间戳到索引的映射
kline_index = {str(k.timestamp): i for i, k in enumerate(klines)}
# 按时间排序
pivots = sorted(pivots, key=lambda p: str(p.timestamp))
# 分开处理高点和低点
high_pivots = [p for p in pivots if p.direction == "high"]
low_pivots = [p for p in pivots if p.direction == "low"]
# 合并高点
merged_highs = self._merge_same_direction(
high_pivots, kline_index, "high"
)
merged_highs = self._merge_same_direction(high_pivots, "high")
# 合并低点
merged_lows = self._merge_same_direction(
low_pivots, kline_index, "low"
)
merged_lows = self._merge_same_direction(low_pivots, "low")
# 合并结果
result = merged_highs + merged_lows
return result
def _merge_same_direction(self, pivots: List[PivotPoint],
kline_index: Dict[str, int],
direction: str) -> List[PivotPoint]:
def _merge_same_direction(self, pivots: List[PivotPoint], direction: str) -> List[PivotPoint]:
"""
合并同方向的转折点
合并规则:相邻两个转折点价格差距小于万分之四时合并
"""
if len(pivots) < 2:
return pivots
# 按时间排序
pivots = sorted(pivots, key=lambda p: str(p.timestamp))
merged = []
i = 0
while i < len(pivots):
current = pivots[i]
current_idx = kline_index.get(str(current.timestamp), -1)
if current_idx < 0:
i += 1
continue
# 查找需要合并的转折点
group = [current]
@@ -199,22 +205,11 @@ class PivotDetector:
j = i + 1
while j < len(pivots):
next_pivot = pivots[j]
next_idx = kline_index.get(str(next_pivot.timestamp), -1)
if next_idx < 0:
j += 1
continue
# 检查K线距离
kline_distance = abs(next_idx - current_idx)
if kline_distance >= 26:
break
# 检查价格差距(万分之三)
# 检查价格差距(万分之四)
if current.price > 0:
price_diff_pct = abs(next_pivot.price - current.price) / current.price
if price_diff_pct <= 0.0003: # 万分之
if price_diff_pct <= 0.0004: # 万分之
group.append(next_pivot)
j += 1
continue
@@ -239,27 +234,46 @@ class PivotDetector:
"""
更新转折点数据
Args:
symbol: 交易品种
period: 周期
klines: K线数据列表
strength: 转折强度,None则使用周期默认值
Returns:
更新后的转折点数量
"""
symbol = normalize_symbol(symbol)
# 使用周期配置的strength
if strength is None:
strength = self.PERIOD_STRENGTH.get(period, self.default_strength)
pivots = self.detect_pivots(symbol, period, klines, strength)
# 合并相近的转折点
merged_pivots = self._merge_pivots(pivots, klines)
with self._lock:
# 保存原始转折点到时间线(按时间排序,用于判断趋势)
# 高点和低点混合在一起,按时间戳排序
timeline = sorted(pivots, key=lambda p: self._normalize_timestamp(p.timestamp))
self._pivots_timeline[symbol][period] = timeline
# 合并相近的转折点(用于价格接近检测)
merged_pivots = self._merge_pivots(pivots, klines)
self._pivots[symbol][period] = merged_pivots
count = len(merged_pivots)
original_count = len(pivots)
timeline_count = len(timeline)
if original_count != count:
print(f"[PivotDetector] {symbol} {period} 检测到 {original_count} 个转折点,合并后 {count}")
print(f"[PivotDetector] {symbol} {period} 检测到 {original_count} 个转折点,时间线 {timeline_count} 个,合并后 {count}")
else:
print(f"[PivotDetector] {symbol} {period} 检测到 {count} 个转折点")
return count
def _normalize_timestamp(self, ts) -> str:
"""标准化时间戳为字符串,用于排序比较"""
if isinstance(ts, datetime):
return ts.strftime("%Y-%m-%d %H:%M:%S")
return str(ts)
def get_pivots(self, symbol: str, period: str, direction: str = None,
count: int = 50) -> List[Dict]:
"""
@@ -274,8 +288,6 @@ class PivotDetector:
Returns:
转折点列表
"""
symbol = normalize_symbol(symbol)
with self._lock:
pivots = self._pivots[symbol][period]
@@ -289,20 +301,23 @@ class PivotDetector:
def get_recent_pivots(self, symbol: str, period: str, count: int = 10) -> List[Dict]:
"""获取最近的转折点(按时间倒序)"""
symbol = normalize_symbol(symbol)
with self._lock:
pivots = self._pivots[symbol][period]
pivots = sorted(pivots, key=lambda x: str(x.timestamp), reverse=True)[:count]
return [p.to_dict() for p in pivots]
def check_near_pivot(self, symbol: str, current_price: float) -> List[Dict]:
def check_near_pivot(self, symbol: str, current_price: float,
trend_filter: Dict[str, str] = None) -> List[Dict]:
"""
检查当前价格是否接近某个转折点
Args:
symbol: 交易品种
current_price: 当前价格
trend_filter: 趋势过滤,格式 {period: "up"/"down"}
- "up": 趋势向上,只检查高点
- "down": 趋势向下,只检查低点
- 不提供或"unknown": 检查所有
Returns:
接近的转折点列表,包含距离信息
@@ -310,71 +325,49 @@ class PivotDetector:
预警逻辑:
- 接近高点:当前价格 < 高点价格 且 距离在阈值范围内
- 接近低点:当前价格 > 低点价格 且 距离在阈值范围内
- 突破高点:当前价格超过高点价格的万分之一点二(基于实时价格)
- 突破低点:当前价格低于低点价格的万分之一点二(基于实时价格)
- 超过千分之一不再提示
"""
symbol = normalize_symbol(symbol)
near_pivots = []
# 突破阈值:万分之一点二
BREAKTHROUGH_THRESHOLD = 0.00012
# 最大提示范围:千分之一
MAX_ALERT_THRESHOLD = 0.001
with self._lock:
for period in self._pivots[symbol]:
pivots = self._pivots[symbol][period]
threshold = self.THRESHOLDS.get(period, 0.001)
# 获取该周期的趋势方向
trend = trend_filter.get(period) if trend_filter else None
for pivot in pivots:
if pivot.price == 0 or current_price == 0:
continue
# 基于实时价格计算阈值
breakthrough_value = current_price * BREAKTHROUGH_THRESHOLD # 万分之一点二
max_alert_value = current_price * MAX_ALERT_THRESHOLD # 千分之一
# 根据趋势过滤
if trend == 'up' and pivot.direction != 'high':
# 趋势向上,只检查高点
continue
elif trend == 'down' and pivot.direction != 'low':
# 趋势向下,只检查低点
continue
# 判断是接近还是突破
is_near = False
is_breakthrough = False
alert_type = ""
if pivot.direction == "high":
# 高点转折
if current_price > pivot.price:
# 当前价格高于高点,判断是否突破
# 突破:超过高点的距离在万分之一点二到千分之一之间
distance = current_price - pivot.price
if distance >= breakthrough_value and distance < max_alert_value:
is_breakthrough = True
alert_type = "breakthrough_high"
# 超过千分之一不再提示
else:
# 当前价格低于高点
# 高点转折:当前价格低于高点
if current_price < pivot.price:
distance_pct = (pivot.price - current_price) / current_price
if distance_pct <= threshold:
is_near = True
alert_type = "near_high"
elif pivot.direction == "low":
# 低点转折
if current_price < pivot.price:
# 当前价格低于低点,判断是否突破
# 突破:低于低点的距离在万分之一点二到千分之一之间
distance = pivot.price - current_price
if distance >= breakthrough_value and distance < max_alert_value:
is_breakthrough = True
alert_type = "breakthrough_low"
# 超过千分之一不再提示
else:
# 当前价格高于低点
# 低点转折:当前价格高于低点
if current_price > pivot.price:
distance_pct = (current_price - pivot.price) / current_price
if distance_pct <= threshold:
is_near = True
alert_type = "near_low"
if is_near or is_breakthrough:
if is_near:
distance_pct = abs(current_price - pivot.price) / current_price
near_pivots.append({
**pivot.to_dict(),
@@ -383,7 +376,7 @@ class PivotDetector:
"threshold_pct": round(threshold * 100, 4),
"distance": round(current_price - pivot.price, 2),
"alert_type": alert_type,
"is_breakthrough": is_breakthrough
"trend": trend # 记录趋势方向
})
# 按距离排序,最近的优先
@@ -395,12 +388,54 @@ class PivotDetector:
"""获取某个周期的接近阈值"""
return self.THRESHOLDS.get(period, 0.001)
def get_trend_direction(self, symbol: str, period: str = None) -> Dict[str, str]:
"""
根据最近的转折点判断趋势方向
原理:
- 最近是高点 → 价格刚从高点下来 → 趋势向下 → 应检查低点
- 最近是低点 → 价格刚从低点上去 → 趋势向上 → 应检查高点
Args:
symbol: 交易品种
period: 指定周期,如果为None则判断所有周期
Returns:
{period: "up"/"down"/"unknown"}
- up: 趋势向上,应检查高点
- down: 趋势向下,应检查低点
"""
result = {}
periods_to_check = [period] if period else list(self._pivots_timeline[symbol].keys())
with self._lock:
for p in periods_to_check:
timeline = self._pivots_timeline[symbol][p]
if not timeline:
result[p] = 'unknown'
continue
# 时间线已按时间排序,最后一个就是最近的转折点
latest_pivot = timeline[-1]
if latest_pivot.direction == 'high':
# 最近是高点,价格往下走,趋势向下
result[p] = 'down'
else:
# 最近是低点,价格往上走,趋势向上
result[p] = 'up'
return result
def clear_symbol(self, symbol: str):
"""清除某个Symbol的转折点数据"""
symbol = normalize_symbol(symbol)
with self._lock:
if symbol in self._pivots:
del self._pivots[symbol]
if symbol in self._pivots_timeline:
del self._pivots_timeline[symbol]
def get_status(self) -> Dict:
"""获取状态"""
@@ -410,5 +445,13 @@ class PivotDetector:
status[symbol] = {}
for period in self._pivots[symbol]:
count = len(self._pivots[symbol][period])
status[symbol][period] = {"pivot_count": count}
return status
strength = self.PERIOD_STRENGTH.get(period, self.default_strength)
status[symbol][period] = {
"pivot_count": count,
"strength": strength
}
return status
def get_strength(self, period: str) -> int:
"""获取某个周期的转折强度"""
return self.PERIOD_STRENGTH.get(period, self.default_strength)
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
持仓数据存储模块
接收和存储EA上报的持仓数据
"""
from collections import defaultdict
from datetime import datetime
from typing import List, Dict, Optional
import threading
import json
class PositionData:
"""持仓数据结构"""
def __init__(self, ticket: int, symbol: str, volume: float, price_open: float,
position_type: str, profit: float, distance_sl: float = 0,
distance_tp: float = 0, sl: float = 0, tp: float = 0):
self.ticket = ticket
self.symbol = symbol
self.volume = volume
self.price_open = price_open
self.type = position_type # "BUY" or "SELL"
self.profit = profit
self.distance_sl = distance_sl
self.distance_tp = distance_tp
self.sl = sl
self.tp = tp
self.updated_at = datetime.now()
def to_dict(self) -> Dict:
"""转换为字典"""
return {
"ticket": self.ticket,
"symbol": self.symbol,
"volume": self.volume,
"price_open": self.price_open,
"type": self.type,
"profit": self.profit,
"distance_sl": self.distance_sl,
"distance_tp": self.distance_tp,
"sl": self.sl,
"tp": self.tp,
"updated_at": self.updated_at.isoformat()
}
class PositionStore:
"""持仓数据存储"""
def __init__(self):
# 存储结构: {SYMBOL: {TICKET: PositionData}}
self._positions = defaultdict(dict)
self._lock = threading.RLock()
# 最后更新时间
self._last_update_time = {}
print("[PositionStore] 持仓存储已初始化")
def update_positions(self, symbol: str, positions: List[Dict]) -> Dict:
"""
更新持仓数据
Args:
symbol: 交易品种(上报的品种)
positions: 持仓列表,每个持仓包含symbol字段
Returns:
{"status": "ok", "count": N}
"""
with self._lock:
# 上报的品种
report_symbol = symbol
# 获取当前品种的持仓ticket列表
current_tickets = set(self._positions[report_symbol].keys())
new_tickets = set()
total_count = 0
total_closed = 0
for pos in positions:
pos_symbol = pos.get('symbol', symbol)
ticket = pos.get('ticket')
if not ticket:
continue
new_tickets.add(ticket)
position = PositionData(
ticket=ticket,
symbol=pos_symbol,
volume=pos.get('volume', 0),
price_open=pos.get('priceOpen', 0),
position_type=pos.get('type', 'BUY'),
profit=pos.get('profit', 0),
distance_sl=pos.get('distanceSL', 0),
distance_tp=pos.get('distanceTP', 0),
sl=pos.get('sl', 0),
tp=pos.get('tp', 0)
)
self._positions[pos_symbol][ticket] = position
# 删除已平仓的持仓(当前品种)
closed_tickets = current_tickets - new_tickets
for ticket in closed_tickets:
del self._positions[report_symbol][ticket]
# 更新最后更新时间
self._last_update_time[report_symbol] = datetime.now()
total_count = len(self._positions[report_symbol])
total_closed = len(closed_tickets)
print(f"[PositionStore] 更新持仓: {report_symbol}, {total_count} 个持仓, 平仓 {total_closed}")
return {"status": "ok", "count": total_count, "closed": total_closed}
def get_positions(self, symbol: str = None) -> List[Dict]:
"""
获取持仓数据
Args:
symbol: 交易品种,None表示获取所有
Returns:
持仓列表
"""
with self._lock:
if symbol:
positions = list(self._positions[symbol].values())
else:
positions = []
for sym in self._positions:
positions.extend(self._positions[sym].values())
return [p.to_dict() for p in positions]
def get_position(self, symbol: str, ticket: int) -> Optional[Dict]:
"""获取单个持仓"""
with self._lock:
pos = self._positions[symbol].get(ticket)
return pos.to_dict() if pos else None
def get_summary(self, symbol: str = None) -> Dict:
"""
获取持仓汇总
Returns:
{
"total_count": 总持仓数,
"total_profit": 总盈亏,
"buy_count": 买单数,
"sell_count": 卖单数,
"positions": [...]
}
"""
positions = self.get_positions(symbol)
total_profit = sum(p['profit'] for p in positions)
buy_count = sum(1 for p in positions if p['type'] == 'BUY')
sell_count = sum(1 for p in positions if p['type'] == 'SELL')
return {
"total_count": len(positions),
"total_profit": round(total_profit, 2),
"buy_count": buy_count,
"sell_count": sell_count,
"positions": positions,
"last_update": self._last_update_time.get(symbol, max(self._last_update_time.values()) if self._last_update_time else None)
}
def clear_symbol(self, symbol: str):
"""清除某个品种的持仓数据"""
with self._lock:
if symbol in self._positions:
del self._positions[symbol]
if symbol in self._last_update_time:
del self._last_update_time[symbol]
def get_symbols(self) -> List[str]:
"""获取所有有持仓的品种"""
with self._lock:
return [s for s in self._positions if self._positions[s]]
# 全局单例
_position_store = None
def get_position_store() -> PositionStore:
"""获取持仓存储单例"""
global _position_store
if _position_store is None:
_position_store = PositionStore()
return _position_store
+216 -21
View File
@@ -11,19 +11,12 @@ from typing import List, Dict, Optional
import threading
def normalize_symbol(symbol: str) -> str:
"""
标准化品种名称(保持原样)
"""
return symbol if symbol else ""
class KlineData:
"""K线数据结构"""
def __init__(self, symbol: str, period: str, timestamp, open_price: float,
high: float, low: float, close: float, volume: float = 0):
self.symbol = normalize_symbol(symbol)
self.symbol = symbol
self.period = period # H4, H1, M15, M5, M1
self.timestamp = timestamp
self.open = open_price
@@ -67,6 +60,15 @@ class MarketStore:
'M1': 100 # 1分钟,1小时60根
}
# 各周期时间间隔(秒)
PERIOD_INTERVALS = {
'H4': 4 * 60 * 60, # 4小时
'H1': 1 * 60 * 60, # 1小时
'M15': 15 * 60, # 15分钟
'M5': 5 * 60, # 5分钟
'M1': 1 * 60 # 1分钟
}
def __init__(self):
# 存储结构: {SYMBOL: {PERIOD: [KlineData, ...]}}
self._klines = defaultdict(lambda: defaultdict(list))
@@ -76,6 +78,10 @@ class MarketStore:
# 结构: {SYMBOL: {PERIOD: True/False}}
self._initialized = defaultdict(lambda: defaultdict(bool))
# 记录每个symbol的M1数据最后更新时间(本地时间,用于判断数据是否过期)
# 结构: {SYMBOL: datetime}
self._m1_update_time = {}
print("[MarketStore] K线存储已初始化")
def save_klines(self, symbol: str, period: str, klines: List[Dict],
@@ -92,19 +98,23 @@ class MarketStore:
Returns:
{"status": "ok", "count": N, "is_full": bool}
"""
symbol = normalize_symbol(symbol)
period = period.upper()
if period not in self.PERIODS:
return {"status": "error", "message": f"不支持的周期: {period}"}
with self._lock:
# 注意:EA推送全量时会按顺序推送所有周期(H4→H1→M15→M5→M1
# 每个周期单独推送,is_full=true
# 所以这里只清空当前周期的数据,其他周期等待各自的推送
if is_full:
# 全量数据,直接覆盖
# 全量数据,清空该品种当前周期的历史数据
self._klines[symbol][period] = []
print(f"[MarketStore] 收到 {symbol} {period} 全量数据,清空该周期历史数据")
# 解析并存储K线数据
new_count = 0
update_count = 0 # 记录更新的数据条数
for k in klines:
kline = KlineData(
symbol=symbol,
@@ -131,6 +141,7 @@ class MarketStore:
if found_idx >= 0:
# 更新已有数据
existing[found_idx] = kline
update_count += 1
else:
# 添加新数据
existing.append(kline)
@@ -149,6 +160,10 @@ class MarketStore:
# 标记已初始化
self._initialized[symbol][period] = True
# 如果是M1数据,更新最后更新时间(有新数据或更新数据都算)
if period == 'M1' and (new_count > 0 or update_count > 0):
self._m1_update_time[symbol] = datetime.now()
total = len(self._klines[symbol][period])
print(f"[MarketStore] {symbol} {period} 保存了 {new_count} 条新数据, 当前共 {total}")
@@ -161,7 +176,6 @@ class MarketStore:
def get_klines(self, symbol: str, period: str, count: int = 100) -> List[Dict]:
"""获取K线数据"""
symbol = normalize_symbol(symbol)
period = period.upper()
with self._lock:
@@ -170,7 +184,6 @@ class MarketStore:
def get_all_klines(self, symbol: str, period: str) -> List[Dict]:
"""获取所有K线数据"""
symbol = normalize_symbol(symbol)
period = period.upper()
with self._lock:
@@ -178,17 +191,16 @@ class MarketStore:
def get_latest_price(self, symbol: str) -> Optional[float]:
"""获取最新价格(从K线的最新close,优先M1,依次尝试其他周期)"""
symbol = normalize_symbol(symbol)
with self._lock:
# 尝试找到匹配的symbol(支持带#后缀的symbol
# 尝试找到匹配的symbol
actual_symbol = None
if symbol in self._klines:
actual_symbol = symbol
else:
# 尝试添加#后缀
# 尝试模糊匹配(去除#后缀
symbol_base = symbol.replace('#', '')
for s in self._klines:
if s.upper().startswith(symbol.upper()):
if s.replace('#', '') == symbol_base:
actual_symbol = s
break
@@ -204,18 +216,15 @@ class MarketStore:
def is_initialized(self, symbol: str, period: str) -> bool:
"""检查某个周期的数据是否已初始化"""
symbol = normalize_symbol(symbol)
period = period.upper()
return self._initialized[symbol][period]
def check_all_initialized(self, symbol: str) -> bool:
"""检查所有周期是否都已初始化"""
symbol = normalize_symbol(symbol)
return all(self._initialized[symbol][p] for p in self.PERIODS)
def clear_symbol(self, symbol: str):
"""清除某个Symbol的数据"""
symbol = normalize_symbol(symbol)
with self._lock:
if symbol in self._klines:
del self._klines[symbol]
@@ -256,4 +265,190 @@ class MarketStore:
"""标准化时间戳为字符串"""
if isinstance(ts, datetime):
return ts.strftime("%Y-%m-%d %H:%M:%S")
return str(ts)
return str(ts)
def get_latest_kline_time(self, symbol: str, period: str = 'M1') -> Optional[datetime]:
"""
获取指定品种和周期的最新K线时间戳
Args:
symbol: 品种名称
period: 周期,默认M1
Returns:
最新K线时间戳,如果没有数据返回None
"""
period = period.upper()
with self._lock:
klines = self._klines[symbol][period]
if not klines:
return None
latest_ts = klines[-1].timestamp
if isinstance(latest_ts, datetime):
return latest_ts
else:
# 尝试解析字符串时间戳(支持多种格式)
ts_str = str(latest_ts)
for fmt in ["%Y-%m-%d %H:%M:%S", "%Y.%m.%d %H:%M", "%Y.%m.%d %H:%M:%S", "%Y-%m-%d %H:%M"]:
try:
return datetime.strptime(ts_str, fmt)
except:
continue
return None
def check_m1_updated_within(self, symbol: str, seconds: int = 180) -> Dict:
"""
检查M1 K线是否在指定秒数内更新
Args:
symbol: 品种名称
seconds: 秒数,默认180秒(3分钟)
Returns:
{
"has_data": bool, # 是否有M1数据
"latest_time": datetime, # 最新K线时间(MT5服务器时间)
"update_time": datetime, # 服务端收到更新的时间(本地时间)
"seconds_ago": int, # 距今多少秒(基于本地更新时间)
"is_stale": bool, # 是否过期(超过指定秒数)
"market_status": str # 市场状态: "active", "stale", "closed"
}
"""
with self._lock:
# 检查是否有M1数据
has_m1_data = len(self._klines[symbol]['M1']) > 0
if not has_m1_data:
return {
"has_data": False,
"latest_time": None,
"update_time": None,
"seconds_ago": None,
"is_stale": True,
"market_status": "closed" # 无数据,可能休市
}
# 获取最新K线时间(MT5服务器时间,仅用于显示)
latest_time = self.get_latest_kline_time(symbol, 'M1')
# 获取服务端收到更新的时间(本地时间,用于判断过期)
update_time = self._m1_update_time.get(symbol)
if update_time is None:
# 有数据但没有更新时间记录,说明是服务重启前的旧数据
# 这种情况也认为是休市,等下次推送数据时再处理
return {
"has_data": True,
"latest_time": latest_time,
"update_time": None,
"seconds_ago": None,
"is_stale": True,
"market_status": "closed" # 无新数据推送,可能休市
}
now = datetime.now()
seconds_ago = int((now - update_time).total_seconds())
if seconds_ago > seconds:
market_status = "stale" # 数据过期
else:
market_status = "active" # 活跃
return {
"has_data": True,
"latest_time": latest_time,
"update_time": update_time,
"seconds_ago": seconds_ago,
"is_stale": seconds_ago > seconds,
"market_status": market_status
}
def check_kline_continuity(self, symbol: str, period: str, new_klines: List[Dict]) -> Dict:
"""
检查增量K线数据是否连续
Args:
symbol: 品种名称
period: 周期
new_klines: 新推送的K线数据列表
Returns:
{
"is_continuous": bool, # 是否连续
"gap_count": int, # 缺失的K线数量
"last_existing_time": datetime, # 现有数据最后时间
"first_new_time": datetime, # 新数据最早时间
"expected_gap": int # 期望的间隔(周期数)
}
"""
period = period.upper()
if not new_klines:
return {"is_continuous": True, "gap_count": 0}
# 获取周期时间间隔(秒)
interval = self.PERIOD_INTERVALS.get(period, 60)
# 允许的间隔倍数(现有数据+1周期)
max_allowed_gap = interval * 2 # 允许最多1个周期的间隔
with self._lock:
existing = self._klines[symbol][period]
if not existing:
# 没有历史数据,需要检查是否初始化
return {"is_continuous": True, "gap_count": 0}
# 获取现有数据最后时间
last_existing = existing[-1]
last_existing_time = self._parse_timestamp(last_existing.timestamp)
if last_existing_time is None:
return {"is_continuous": True, "gap_count": 0}
# 获取新数据最早时间(新数据可能有多条,取最早的)
first_new_time = None
for k in new_klines:
ts = self._parse_timestamp(k.get('timestamp') or k.get('time'))
if ts:
if first_new_time is None or ts < first_new_time:
first_new_time = ts
if first_new_time is None:
return {"is_continuous": True, "gap_count": 0}
# 计算时间差
time_diff = (first_new_time - last_existing_time).total_seconds()
# 如果新数据时间早于或等于现有数据,是更新操作,算连续
if time_diff <= 0:
return {
"is_continuous": True,
"gap_count": 0,
"last_existing_time": last_existing_time,
"first_new_time": first_new_time
}
# 计算间隔的周期数
gap_periods = int(time_diff / interval)
return {
"is_continuous": gap_periods <= 1, # 允许最多1个周期的间隔
"gap_count": max(0, gap_periods - 1), # 缺失的周期数
"last_existing_time": last_existing_time,
"first_new_time": first_new_time,
"expected_gap": gap_periods
}
def _parse_timestamp(self, ts) -> Optional[datetime]:
"""解析时间戳为datetime对象"""
if ts is None:
return None
if isinstance(ts, datetime):
return ts
ts_str = str(ts)
for fmt in ["%Y-%m-%d %H:%M:%S", "%Y.%m.%d %H:%M", "%Y.%m.%d %H:%M:%S", "%Y-%m-%d %H:%M"]:
try:
return datetime.strptime(ts_str, fmt)
except:
continue
return None
+197
View File
@@ -0,0 +1,197 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
系统运行日志模块
保存在内存中,保留最新200条日志
"""
from collections import deque
from datetime import datetime
from typing import Dict, List, Optional, Any
import threading
import json
class SystemLog:
"""系统运行日志"""
# 日志事件类型
EVENT_TYPES = {
# 大模型相关
"llm_analysis_start": "大模型分析开始",
"llm_analysis_complete": "大模型分析完成",
"llm_analysis_error": "大模型分析错误",
# EA数据推送
"ea_statistics": "EA推送统计数据",
"ea_kline_full": "EA推送全量K线",
"ea_kline_incremental": "EA推送增量K线",
"ea_kline_stale": "K线数据过期",
"ea_trade_request": "EA请求交易指令",
# MT5财经日历推送
"mt5_calendar_update": "MT5财经日历上报",
"mt5_event_result": "MT5事件结果上报",
# 转折点相关
"pivot_detected": "转折点检测完成",
"pivot_alert": "转折点提醒",
# 交易指令
"order_generated": "交易指令生成",
"order_confirmed": "交易指令确认",
"order_rejected": "交易指令拒绝",
"close_position": "平仓指令",
# 持仓相关
"position_update": "持仓数据更新",
# 新闻爬虫相关
"news_crawler_start": "新闻爬虫启动",
"news_crawler_stop": "新闻爬虫停止",
"news_calendar_fetch": "财经日历获取",
"news_calendar_fetch_error": "财经日历获取失败",
"news_calendar_update": "财经日历更新",
"news_flash_fetch": "快讯获取",
"news_flash_fetch_error": "快讯获取失败",
"news_event_scheduled": "事件调度创建",
"news_event_reminder": "事件发布前提醒",
"news_event_result": "事件结果获取",
"news_impact_analysis": "影响分析完成",
"news_ws_broadcast": "新闻WebSocket推送",
# 系统事件
"system_startup": "系统启动",
"system_shutdown": "系统关闭",
"websocket_connect": "WebSocket连接",
"websocket_disconnect": "WebSocket断开",
}
def __init__(self, max_size: int = 200):
self._logs = deque(maxlen=max_size)
self._lock = threading.RLock()
self._ws_clients = set()
self._ws_lock = threading.Lock()
self._main_loop = None
print(f"[SystemLog] 系统日志已初始化,最大保留 {max_size}")
def set_event_loop(self, loop):
"""设置主事件循环引用"""
self._main_loop = loop
def add_log(self, event_type: str, detail: Dict[str, Any] = None,
symbol: str = None, message: str = None):
"""
添加日志
Args:
event_type: 事件类型
detail: 事件详情
symbol: 相关品种
message: 自定义消息
"""
log_entry = {
"timestamp": datetime.now().isoformat(),
"event_type": event_type,
"event_name": self.EVENT_TYPES.get(event_type, event_type),
"symbol": symbol,
"message": message,
"detail": detail or {}
}
with self._lock:
self._logs.append(log_entry)
# 广播到WebSocket客户端
self._broadcast_log(log_entry)
# 打印到控制台
log_str = f"[SystemLog] {log_entry['timestamp']} | {log_entry['event_name']}"
if symbol:
log_str += f" | {symbol}"
if message:
log_str += f" | {message}"
print(log_str)
def get_logs(self, count: int = 50, event_types: List[str] = None,
symbol: str = None) -> List[Dict]:
"""
获取日志
Args:
count: 获取数量
event_types: 过滤事件类型列表(支持多选)
symbol: 过滤品种
Returns:
日志列表(按时间倒序)
"""
with self._lock:
logs = list(self._logs)
# 过滤
if event_types:
logs = [l for l in logs if l['event_type'] in event_types]
if symbol:
logs = [l for l in logs if l.get('symbol') == symbol]
# 按时间倒序,取最新的
logs = logs[::-1][:count]
return logs
def clear_logs(self):
"""清空日志"""
with self._lock:
self._logs.clear()
print("[SystemLog] 日志已清空")
def add_ws_client(self, client):
"""添加WebSocket客户端"""
with self._ws_lock:
self._ws_clients.add(client)
def remove_ws_client(self, client):
"""移除WebSocket客户端"""
with self._ws_lock:
self._ws_clients.discard(client)
def _broadcast_log(self, log_entry: Dict):
"""广播日志到WebSocket客户端"""
if not self._main_loop:
return
import asyncio
message = json.dumps({
"type": "system_log",
"data": log_entry
})
with self._ws_lock:
clients = list(self._ws_clients)
if not clients:
return
# 在主事件循环中发送
for client in clients:
try:
asyncio.run_coroutine_threadsafe(
client.send_text(message),
self._main_loop
)
except Exception as e:
print(f"[SystemLog] 广播日志失败: {e}")
# 全局单例
_system_log = None
def get_system_log() -> SystemLog:
"""获取系统日志单例"""
global _system_log
if _system_log is None:
_system_log = SystemLog()
return _system_log
+302
View File
@@ -0,0 +1,302 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
交易历史存储模块
存储EA上报的交易历史数据
"""
from collections import defaultdict
from datetime import datetime, timedelta
from typing import List, Dict, Optional
import threading
from dataclasses import dataclass, field
@dataclass
class TradeDeal:
"""成交记录"""
ticket: int
order: int
symbol: str
type: int # 0=买入, 1=卖出
entry: int # 0=开仓, 1=平仓, 2=反向
volume: float
price: float
profit: float
swap: float
commission: float
time: datetime
comment: str
def to_dict(self) -> Dict:
return {
"ticket": self.ticket,
"order": self.order,
"symbol": self.symbol,
"type": self.type,
"type_text": "买入" if self.type == 0 else "卖出",
"entry": self.entry,
"entry_text": self._get_entry_text(),
"volume": self.volume,
"price": self.price,
"profit": self.profit,
"swap": self.swap,
"commission": self.commission,
"time": self.time.strftime("%Y-%m-%d %H:%M:%S") if self.time else None,
"comment": self.comment,
"is_auto": self._is_auto_order(),
"order_source": self._get_order_source()
}
def _get_entry_text(self) -> str:
if self.entry == 0:
return "开仓"
elif self.entry == 1:
return "平仓"
elif self.entry == 2:
return "反向"
else:
return "未知"
def _is_auto_order(self) -> bool:
"""判断是否为自动下单(排除MT5系统标记)"""
if not self.comment or not self.comment.strip():
return False
# 排除MT5系统标记
comment = self.comment.strip()
if comment.startswith('[sl') or comment.startswith('[tp') or comment.startswith('[so'):
return False
return True
def _get_order_source(self) -> str:
"""获取订单来源"""
if not self.comment or not self.comment.strip():
return "手动"
comment = self.comment.strip()
if comment.startswith('[sl'):
return "止损触发"
if comment.startswith('[tp'):
return "止盈触发"
if comment.startswith('[so'):
return "强制平仓"
return "自动"
class TradeHistoryStore:
"""交易历史存储"""
def __init__(self):
# 存储成交记录
self._deals: List[TradeDeal] = []
self._lock = threading.RLock()
# 上次更新时间
self._last_update_time: Optional[datetime] = None
print("[TradeHistoryStore] 交易历史存储已初始化")
def update_from_ea(self, deals_data: List[Dict]) -> int:
"""
从EA数据更新交易历史
Args:
deals_data: EA返回的成交列表
Returns:
更新的成交数量
"""
if not deals_data:
return 0
now = datetime.now()
new_deals = []
with self._lock:
# 获取现有票据集合
existing_tickets = {d.ticket for d in self._deals}
for deal_data in deals_data:
ticket = deal_data.get('ticket')
if ticket in existing_tickets:
continue
# 解析时间
deal_time = deal_data.get('time')
if isinstance(deal_time, str):
try:
deal_time = datetime.strptime(deal_time, '%Y.%m.%d %H:%M:%S')
except:
try:
deal_time = datetime.strptime(deal_time, '%Y-%m-%d %H:%M:%S')
except:
deal_time = now
elif not isinstance(deal_time, datetime):
deal_time = now
deal = TradeDeal(
ticket=ticket,
order=deal_data.get('order', 0),
symbol=deal_data.get('symbol', ''),
type=deal_data.get('type', 0),
entry=deal_data.get('entry', 0),
volume=deal_data.get('volume', 0),
price=deal_data.get('price', 0),
profit=deal_data.get('profit', 0),
swap=deal_data.get('swap', 0),
commission=deal_data.get('commission', 0),
time=deal_time,
comment=deal_data.get('comment', '')
)
new_deals.append(deal)
# 添加新记录
self._deals.extend(new_deals)
# 按时间排序
self._deals.sort(key=lambda d: d.time or datetime.min, reverse=True)
# 保留最近24小时的数据
cutoff = now - timedelta(hours=24)
self._deals = [d for d in self._deals if d.time and d.time > cutoff]
self._last_update_time = now
if new_deals:
print(f"[TradeHistoryStore] 新增 {len(new_deals)} 条成交记录,当前共 {len(self._deals)}")
return len(new_deals)
def get_all_deals(self) -> List[Dict]:
"""获取所有成交记录"""
with self._lock:
return [d.to_dict() for d in self._deals]
def get_statistics(self) -> Dict:
"""
获取交易统计
Returns:
统计数据
"""
with self._lock:
total_count = len(self._deals)
if total_count == 0:
return {
"total_count": 0,
"symbols": {},
"manual_count": 0,
"auto_count": 0,
"sl_tp_count": 0,
"so_count": 0,
"auto_categories": {},
"total_profit": 0,
"total_swap": 0,
"total_commission": 0,
"net_profit": 0,
"last_update": None
}
# 按品种统计
symbols = defaultdict(lambda: {"count": 0, "profit": 0, "volume": 0})
# 手动/自动/止损止盈/强制平仓统计
manual_count = 0
auto_count = 0
sl_tp_count = 0 # 止损/止盈触发
so_count = 0 # 强制平仓
auto_categories = defaultdict(lambda: {"count": 0, "profit": 0})
total_profit = 0
total_swap = 0
total_commission = 0
for deal in self._deals:
# 品种统计
symbols[deal.symbol]["count"] += 1
symbols[deal.symbol]["profit"] += deal.profit
symbols[deal.symbol]["volume"] += deal.volume
# 分类统计
comment = deal.comment.strip() if deal.comment else ""
if not comment:
# 无备注:手动单
manual_count += 1
elif comment.startswith('[sl') or comment.startswith('[tp'):
# 止损/止盈触发
sl_tp_count += 1
elif comment.startswith('[so'):
# 强制平仓
so_count += 1
else:
# 自动单:使用完整备注作为分类
auto_count += 1
auto_categories[comment]["count"] += 1
auto_categories[comment]["profit"] += deal.profit
# 总计
total_profit += deal.profit
total_swap += deal.swap
total_commission += deal.commission
net_profit = total_profit + total_swap - total_commission
# 转换auto_categories为普通字典并计算
auto_categories_dict = {}
for cat, data in auto_categories.items():
auto_categories_dict[cat] = {
"count": data["count"],
"profit": round(data["profit"], 2),
"percentage": round(data["count"] / auto_count * 100, 1) if auto_count > 0 else 0
}
# 转换symbols为普通字典
symbols_dict = {}
for sym, data in symbols.items():
symbols_dict[sym] = {
"count": data["count"],
"profit": round(data["profit"], 2),
"volume": round(data["volume"], 2)
}
return {
"total_count": total_count,
"symbols": symbols_dict,
"manual_count": manual_count,
"auto_count": auto_count,
"sl_tp_count": sl_tp_count,
"so_count": so_count,
"auto_categories": auto_categories_dict,
"total_profit": round(total_profit, 2),
"total_swap": round(total_swap, 2),
"total_commission": round(total_commission, 2),
"net_profit": round(net_profit, 2),
"last_update": self._last_update_time.isoformat() if self._last_update_time else None
}
def get_status(self) -> Dict:
"""获取存储状态"""
with self._lock:
return {
"deals_count": len(self._deals),
"last_update": self._last_update_time.isoformat() if self._last_update_time else None
}
def clear(self) -> None:
"""清空数据"""
with self._lock:
self._deals.clear()
self._last_update_time = None
print("[TradeHistoryStore] 已清空交易历史数据")
# 全局单例
_trade_history_store = None
def get_trade_history_store() -> TradeHistoryStore:
"""获取交易历史存储单例"""
global _trade_history_store
if _trade_history_store is None:
_trade_history_store = TradeHistoryStore()
return _trade_history_store
+40 -13
View File
@@ -10,7 +10,7 @@ from typing import List, Dict, Optional
from datetime import datetime
import threading
from .store import KlineData, normalize_symbol
from .store import KlineData
class TrendAnalyzer:
@@ -35,8 +35,41 @@ class TrendAnalyzer:
# 趋势转换历史
self._trend_changes = defaultdict(list)
# 统计数据历史引用(用于获取价差)
self._statistics_history = None
print("[TrendAnalyzer] 趋势分析器已初始化")
def set_statistics_history(self, statistics_history):
"""设置统计数据历史引用(用于获取价差)"""
self._statistics_history = statistics_history
def _get_symbol_spread(self, symbol: str) -> Optional[float]:
"""
获取指定品种的最新价差
Args:
symbol: 品种名称
Returns:
价差(金额),如果没有返回None
"""
if not self._statistics_history:
return None
symbol_normalized = symbol.replace('#', '')
# 从最新的统计数据中查找该品种的价差
for stat in reversed(list(self._statistics_history)):
stat_symbol = stat.get('symbol', '')
stat_normalized = stat_symbol.replace('#', '')
if stat_normalized == symbol_normalized:
spread = stat.get('spread')
if spread is not None and spread > 0:
return spread
return None
def analyze_trend(self, symbol: str, period: str, klines: List[KlineData]) -> Dict:
"""
分析单个周期的趋势
@@ -120,7 +153,7 @@ class TrendAnalyzer:
strength = int(adx)
# 检查趋势转换
symbol_key = normalize_symbol(symbol)
symbol_key = symbol
change_signal = False
previous_trend = None
@@ -170,7 +203,7 @@ class TrendAnalyzer:
"signal": str
}
"""
symbol_key = normalize_symbol(symbol)
symbol_key = symbol
with self._lock:
states = dict(self._trend_states[symbol_key])
@@ -221,19 +254,15 @@ class TrendAnalyzer:
def get_trend_state(self, symbol: str, period: str = None) -> Dict:
"""获取趋势状态"""
symbol_key = normalize_symbol(symbol)
with self._lock:
if period:
return self._trend_states[symbol_key].get(period, {})
return dict(self._trend_states[symbol_key])
return self._trend_states[symbol].get(period, {})
return dict(self._trend_states[symbol])
def get_trend_changes(self, symbol: str, count: int = 10) -> List[Dict]:
"""获取趋势转换历史"""
symbol_key = normalize_symbol(symbol)
with self._lock:
return self._trend_changes[symbol_key][-count:]
return self._trend_changes[symbol][-count:]
def _calculate_ma(self, data: List[float], period: int) -> float:
"""计算移动平均线"""
@@ -324,8 +353,6 @@ class TrendAnalyzer:
Returns:
交易建议 或 None
"""
symbol_key = normalize_symbol(symbol)
# 获取趋势状态
resonance = self.analyze_resonance(symbol)
@@ -377,7 +404,7 @@ class TrendAnalyzer:
return None
return {
"symbol": symbol_key,
"symbol": symbol,
"action": action,
"price": current_price,
"sl": round(sl, 4),
+1
View File
@@ -16,6 +16,7 @@ class TradeInstruction(BaseModel):
price: float # 指令执行价格(买入时为买入价,卖出时为卖出价)
sl: Optional[float] = 0.0 # 止损点, 可以缺省
tp: Optional[float] = 0.0 # 止盈点, 可以缺省,若未指定将在服务端设置为0.005
description: Optional[str] = "" # 订单描述(策略名称)
class StatisticData(BaseModel):
+1
View File
@@ -3,3 +3,4 @@ uvicorn[standard]==0.24.0
uvloop==0.19.0
pydantic==2.5.0
requests==2.31.0
python-dotenv==1.0.0
+377 -14
View File
@@ -4,10 +4,16 @@
EA 相关的接口路由
"""
import random
from fastapi import APIRouter, Query, Request
from typing import Optional, List, Dict
from models import TradeInstruction
from server import TradingServer
from market.system_log import get_system_log
# 统计数据日志打印概率 (5%)
STATISTICS_LOG_PROBABILITY = 0.05
def create_ea_routes(server: TradingServer) -> APIRouter:
@@ -61,9 +67,56 @@ def create_ea_routes(server: TradingServer) -> APIRouter:
# 添加平仓指令
result["close_tickets"] = server.get_close_position_instructions(symbol)
# 打印完整返回数据用于调试
import json
print(f"[EA API] 返回给EA的数据: {json.dumps(result, ensure_ascii=False)}")
# 如果结果不为空,记录到运行日志
trades = result.get("trades", [])
close_tickets = result.get("close_tickets", [])
pivot_alerts = result.get("pivot_alerts", [])
if trades or close_tickets:
import json
system_log = get_system_log()
# 打印完整返回数据
print(f"[EA API] 返回给EA的数据: {json.dumps(result, ensure_ascii=False)}")
# 记录交易指令日志
if trades:
for t in trades:
action_text = '买入' if t.get('action') == 'b' else '卖出'
system_log.add_log(
"order_generated",
{
"order_id": t.get('order_id'),
"action": t.get('action'),
"price": t.get('price'),
"mount": t.get('mount'),
"sl": t.get('sl'),
"tp": t.get('tp')
},
symbol=t.get('symbol'),
message=f"{action_text} @ {t.get('price')}, 手数={t.get('mount')}"
)
# 记录平仓指令日志
if close_tickets:
system_log.add_log(
"close_position",
{"tickets": close_tickets},
symbol=symbol,
message=f"平仓指令: {close_tickets}"
)
# 记录汇总日志
system_log.add_log(
"ea_trade_request",
{
"trades_count": len(trades),
"close_count": len(close_tickets),
"pivot_alerts_count": len(pivot_alerts)
},
symbol=symbol,
message=f"下发交易指令: {len(trades)}个开仓, {len(close_tickets)}个平仓"
)
return result
@@ -96,22 +149,33 @@ def create_ea_routes(server: TradingServer) -> APIRouter:
}
```
"""
# 获取原始请求体用于调试
body = await request.body()
print(f"[DEBUG] Raw body type: {type(body)}")
print(f"[DEBUG] Raw body: {body}")
print(f"[DEBUG] Raw body length: {len(body)}")
# 尝试解析JSON
import json
try:
data = await request.json()
print(f"[DEBUG] Parsed JSON successfully: {data}")
server.save_statistics(data)
# 随机打印日志 (5%概率)
if random.random() < STATISTICS_LOG_PROBABILITY:
symbol = data.get('symbol', 'UNKNOWN')
system_log = get_system_log()
system_log.add_log(
"ea_statistics",
{
"tick_count": data.get('tickCount'),
"bid": data.get('bidPrice'),
"ask": data.get('askPrice'),
"spread": data.get('spread'),
"spread_points": data.get('spreadPoints'),
"balance": data.get('balance'),
"equity": data.get('equity')
},
symbol=symbol,
message=f"Tick: {data.get('tickCount')}, Spread: {data.get('spreadPoints', 0):.1f}pts, Balance: {data.get('balance')}"
)
return {"status": "ok", "message": "统计数据已保存"}
except Exception as e:
print(f"[ERROR] Failed to parse JSON: {e}")
print(f"[ERROR] Body as string: {body.decode('utf-8', errors='ignore')}")
return {"status": "error", "message": str(e)}
@router.post("/close_position")
@@ -138,7 +202,7 @@ def create_ea_routes(server: TradingServer) -> APIRouter:
try:
data = await request.json()
ticket = data.get('ticket')
symbol = data.get('symbol', '').upper()
symbol = data.get('symbol', '')
if not ticket:
return {"status": "error", "message": "缺少订单号"}
@@ -147,10 +211,309 @@ def create_ea_routes(server: TradingServer) -> APIRouter:
server.add_close_position_instruction(symbol, ticket)
print(f"[EA API] 平仓指令已添加: {symbol} ticket={ticket}")
# 记录日志
system_log = get_system_log()
system_log.add_log(
"close_position",
{"ticket": ticket},
symbol=symbol,
message=f"Ticket: {ticket}"
)
return {"status": "ok", "message": "平仓指令已添加"}
except Exception as e:
print(f"[ERROR] close_position 异常: {str(e)}")
return {"status": "error", "message": str(e)}
return router
@router.post("/calendar")
async def send_calendar(request: Request) -> Dict:
"""
接收 EA 发送的财经日历数据(来自MT5 API)
EA调用MT5的calendar_*函数获取数据后,推送到此接口
请求体:
```json
{
"events": [
{
"id": "12345",
"name": "Nonfarm Payrolls",
"name_en": "Nonfarm Payrolls",
"country": "US",
"currency": "USD",
"importance": 3,
"publish_time": "2026-03-16T20:30:00",
"forecast": "200K",
"previous": "180K",
"actual": "",
"unit": "K",
"event_type": "indicator"
}
]
}
```
返回:
```json
{
"status": "ok",
"message": "财经日历已更新",
"count": 150
}
```
"""
import json as json_module
import re as re_module
try:
# 先获取原始body
raw_body = await request.body()
raw_text = raw_body.decode('utf-8', errors='replace')
print(f"[calendar] 收到请求, 数据长度: {len(raw_text)} 字节")
# 清理所有控制字符 (0x00-0x1F, 除了 \t \n \r)
# 保留 tab(0x09), LF(0x0A), CR(0x0D)
def clean_control_chars(text):
# 使用正则表达式一次性清理所有控制字符
# 除了 tab(0x09), LF(0x0A), CR(0x0D)
import re
# 匹配所有控制字符 (0x00-0x1F) 除了 \t \n \r
pattern = re.compile(r'[\x00-\x08\x0b\x0c\x0e-\x1f]')
cleaned = pattern.sub('', text)
removed_count = len(text) - len(cleaned)
if removed_count > 0:
print(f"[calendar] 已移除 {removed_count} 个控制字符")
return cleaned
cleaned_text = clean_control_chars(raw_text)
try:
data = json_module.loads(cleaned_text)
except json_module.JSONDecodeError as e:
# 如果仍然失败,打印问题位置附近的数据
print(f"[ERROR] calendar JSON解析失败: {e}")
error_pos = e.pos if hasattr(e, 'pos') else 0
start = max(0, error_pos - 50)
end = min(len(cleaned_text), error_pos + 50)
print(f"[ERROR] 问题位置附近数据[{start}:{end}]: {repr(cleaned_text[start:end])}")
return {"status": "error", "message": f"JSON解析失败: {e}"}
events = data.get('events', [])
print(f"[calendar] 解析成功, 收到 {len(events)} 个事件")
if not events:
print("[calendar] 警告: events数组为空")
return {"status": "ok", "message": "无数据需要更新", "count": 0}
from market.news_store import get_news_store
news_store = get_news_store()
# 更新财经日历
updated_count = news_store.update_calendar_from_mt5(events)
# 记录日志 - MT5上报财经日历
system_log = get_system_log()
system_log.add_log(
"mt5_calendar_update",
{
"events_received": len(events),
"events_updated": updated_count,
"total_events": news_store.get_status().get('calendar_events', 0)
},
message=f"MT5上报财经日历: 收到{len(events)}条, 更新{updated_count}"
)
return {
"status": "ok",
"message": "财经日历已更新",
"count": updated_count
}
except Exception as e:
print(f"[ERROR] calendar 更新异常: {str(e)}")
import traceback
traceback.print_exc()
return {"status": "error", "message": str(e)}
@router.post("/calendar_event_result")
async def send_calendar_event_result(request: Request) -> Dict:
"""
接收 EA 发送的事件结果(事件发布后EA获取实际值)
请求体:
```json
{
"event_id": "12345",
"actual": "210K",
"forecast": "200K",
"previous": "180K"
}
```
返回:
```json
{
"status": "ok",
"message": "事件结果已更新"
}
```
"""
try:
data = await request.json()
event_id = data.get('event_id')
actual = data.get('actual', '')
forecast = data.get('forecast', '')
previous = data.get('previous', '')
if not event_id:
return {"status": "error", "message": "缺少事件ID"}
from market.news_store import get_news_store
news_store = get_news_store()
# 获取事件
event = news_store.get_event_by_id(event_id)
if not event:
return {"status": "error", "message": f"未找到事件: {event_id}"}
# 更新事件结果
event.actual = actual
if forecast:
event.forecast = forecast
if previous:
event.previous = previous
# 计算结果(好于/差于/符合预期)
result = _calculate_event_result(actual, event.forecast)
event.result = result
event.analyzed = True
# 记录日志 - MT5上报事件结果
system_log = get_system_log()
system_log.add_log(
"mt5_event_result",
{
"event_id": event_id,
"event_name": event.name,
"actual": actual,
"forecast": event.forecast,
"previous": previous,
"result": result
},
symbol=event.currency,
message=f"MT5事件结果: {event.name} 实际={actual} 预测={event.forecast} ({result})"
)
return {
"status": "ok",
"message": "事件结果已更新"
}
except Exception as e:
print(f"[ERROR] calendar_event_result 更新异常: {str(e)}")
return {"status": "error", "message": str(e)}
@router.post("/trade_history")
async def receive_trade_history(request: Request) -> Dict:
"""
接收 EA 发送的交易历史数据
请求体:
```json
{
"deals": [
{
"ticket": 123456,
"order": 789012,
"symbol": "GOLD#",
"type": 0,
"entry": 0,
"volume": 0.1,
"price": 2050.50,
"profit": 0,
"swap": 0,
"commission": -5.0,
"time": "2026.03.16 15:30:00",
"comment": ""
}
]
}
```
返回:
```json
{
"status": "ok",
"message": "交易历史已更新",
"count": 50
}
```
"""
import json as json_module
try:
data = await request.json()
deals = data.get('deals', [])
print(f"[trade_history] 收到 {len(deals)} 条成交记录")
if not deals:
return {"status": "ok", "message": "无数据需要更新", "count": 0}
from market.trade_history_store import get_trade_history_store
store = get_trade_history_store()
# 更新交易历史
new_count = store.update_from_ea(deals)
# 记录日志
system_log = get_system_log()
system_log.add_log(
"trade_history_update",
{
"deals_received": len(deals),
"deals_new": new_count,
"total_deals": len(store.get_all_deals())
},
message=f"交易历史上报: 收到{len(deals)}条, 新增{new_count}"
)
return {
"status": "ok",
"message": "交易历史已更新",
"count": new_count
}
except Exception as e:
print(f"[ERROR] trade_history 更新异常: {str(e)}")
import traceback
traceback.print_exc()
return {"status": "error", "message": str(e)}
return router
def _calculate_event_result(actual: str, forecast: str) -> str:
"""计算事件结果"""
try:
# 尝试提取数字
import re
actual_num = float(re.sub(r'[^\d.-]', '', actual))
forecast_num = float(re.sub(r'[^\d.-]', '', forecast))
if forecast_num == 0:
return 'unknown'
diff_pct = (actual_num - forecast_num) / abs(forecast_num)
if abs(diff_pct) < 0.05:
return 'in_line'
elif diff_pct > 0:
return 'better'
else:
return 'worse'
except:
return 'unknown'
+386 -8
View File
@@ -8,18 +8,23 @@
from fastapi import APIRouter, Query, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import JSONResponse
from typing import Optional, List, Dict
from datetime import datetime, timedelta
import json
import random
from market.store import MarketStore
from market.pivot_detector import PivotDetector
from market.monitor import PivotMonitor
from market.monitor import PivotMonitor, TradeConfig
from market.trend_analyzer import TrendAnalyzer
from market.pending_orders import PendingOrderManager
from market.llm_analyzer import LLMAnalyzer
from market.system_log import get_system_log
def create_market_routes(store: MarketStore, detector: PivotDetector,
monitor: PivotMonitor, trend_analyzer: TrendAnalyzer,
pending_orders: PendingOrderManager) -> APIRouter:
pending_orders: PendingOrderManager,
llm_analyzer: LLMAnalyzer = None) -> APIRouter:
"""
创建行情相关路由
@@ -29,9 +34,13 @@ def create_market_routes(store: MarketStore, detector: PivotDetector,
monitor: 转折点监控器
trend_analyzer: 趋势分析器
pending_orders: 待确认订单管理器
llm_analyzer: 大模型分析器
"""
router = APIRouter()
# 增量K线日志打印概率 (5%)
KLINE_LOG_PROBABILITY = 0.05
# ==================== EA端接口 ====================
@router.post("/ea/kline/{period}")
@@ -75,13 +84,88 @@ def create_market_routes(store: MarketStore, detector: PivotDetector,
try:
data = await request.json()
symbol = data.get('symbol', 'GOLD').upper()
symbol = data.get('symbol', 'GOLD')
is_full = data.get('is_full', False)
klines = data.get('klines', [])
if not klines:
return {"status": "ok", "count": 0, "message": "无数据"}
# 全量数据时检查K线时效性
if is_full:
period_interval = store.PERIOD_INTERVALS.get(period.upper(), 60)
latest_kline_time = None
# 获取最新K线时间(取最后一条)
latest_kline = klines[-1] if klines else None
if latest_kline:
ts = latest_kline.get('timestamp') or latest_kline.get('time')
if ts:
# 解析时间戳
if isinstance(ts, datetime):
latest_kline_time = ts
else:
for fmt in ["%Y-%m-%d %H:%M:%S", "%Y.%m.%d %H:%M", "%Y.%m.%d %H:%M:%S", "%Y-%m-%d %H:%M"]:
try:
latest_kline_time = datetime.strptime(str(ts), fmt)
break
except:
continue
if latest_kline_time:
# 获取MT5时区偏移配置
# mt5_timezone_offset: MT5时间与本地时间的差值
# 正数表示MT5时间比本地时间快,负数表示MT5时间比本地时间慢
# 例如:MT5(GMT+2) vs 本地(GMT+8)MT5比本地慢6小时,offset = -6
trade_config = TradeConfig.get_instance()
timezone_offset_hours = trade_config.mt5_timezone_offset
now_local = datetime.now()
# 将K线时间(MT5服务器时间)转换为本地时间进行比较
# 本地时间 = MT5时间 - offset(因为offset是MT5相对本地的偏移)
# 例如:MT5时间 08:00offset=-6,本地时间 = 08:00 - (-6) = 08:00 + 6 = 14:00
kline_time_local = latest_kline_time - timedelta(hours=timezone_offset_hours)
time_diff = (now_local - kline_time_local).total_seconds()
# 调试日志
print(f"[MarketAPI] {symbol} {period} K线时间检查:")
print(f" - K线时间(MT5): {latest_kline_time}")
print(f" - 转换后本地时间: {kline_time_local}")
print(f" - 当前本地时间: {now_local}")
print(f" - 时区偏移: {timezone_offset_hours}小时")
print(f" - 时间差: {int(time_diff)}秒, 阈值: {period_interval}")
# 如果超过一个周期,说明数据不是最新的,可能休市
if time_diff > period_interval:
system_log = get_system_log()
system_log.add_log(
"ea_kline_stale",
{
"period": period,
"latest_kline_time": latest_kline_time.isoformat(),
"kline_time_local": kline_time_local.isoformat(),
"now_local": now_local.isoformat(),
"timezone_offset_hours": timezone_offset_hours,
"time_diff_seconds": int(time_diff),
"period_interval": period_interval
},
symbol=symbol,
message=f"K线数据过期,最新K线距当前 {int(time_diff)}秒,可能休市"
)
print(f"[MarketAPI] {symbol} {period} 全量K线数据过期,K线时间(MT5) {latest_kline_time},转换为本地时间 {kline_time_local},距当前 {int(time_diff)}秒,丢弃数据")
return {
"status": "ok",
"count": 0,
"message": "K线数据过期,可能休市",
"stale": True,
"latest_kline_time": latest_kline_time.isoformat(),
"kline_time_local": kline_time_local.isoformat(),
"time_diff_seconds": int(time_diff),
"timezone_offset_hours": timezone_offset_hours
}
# 检查是否需要全量数据
if not is_full and not store.is_initialized(symbol, period):
print(f"[MarketAPI] {symbol} {period} 未初始化,需要全量数据")
@@ -94,9 +178,39 @@ def create_market_routes(store: MarketStore, detector: PivotDetector,
}
)
# 增量数据时检查连续性
if not is_full and store.is_initialized(symbol, period):
continuity = store.check_kline_continuity(symbol, period, klines)
if not continuity["is_continuous"]:
print(f"[MarketAPI] {symbol} {period} 数据不连续,缺失 {continuity['gap_count']} 个周期")
print(f"[MarketAPI] 现有最后时间: {continuity.get('last_existing_time')}, 新数据最早时间: {continuity.get('first_new_time')}")
return JSONResponse(
status_code=400,
content={
"status": "error",
"code": 8888,
"message": f"数据不连续,缺失 {continuity['gap_count']} 个周期,需要全量数据"
}
)
# 保存K线数据
result = store.save_klines(symbol, period, klines, is_full)
# 记录日志 - 全量K线总是记录,增量K线5%概率记录
if is_full or random.random() < KLINE_LOG_PROBABILITY:
system_log = get_system_log()
event_type = "ea_kline_full" if is_full else "ea_kline_incremental"
system_log.add_log(
event_type,
{
"period": period,
"count": len(klines),
"is_full": is_full
},
symbol=symbol,
message=f"{'全量' if is_full else '增量'} {period} {len(klines)}"
)
if result['status'] == 'ok':
# 更新转折点
all_klines = store.get_all_klines(symbol, period)
@@ -149,11 +263,13 @@ def create_market_routes(store: MarketStore, detector: PivotDetector,
"""
try:
data = await request.json()
symbol = data.get('symbol', 'GOLD').upper()
symbol = data.get('symbol', 'GOLD')
is_full = data.get('is_full', False)
kline_data = data.get('data', {})
results = {}
system_log = get_system_log()
for period, klines in kline_data.items():
period = period.upper()
if period not in ['H4', 'H1', 'M15', 'M5', 'M1']:
@@ -162,6 +278,20 @@ def create_market_routes(store: MarketStore, detector: PivotDetector,
result = store.save_klines(symbol, period, klines, is_full)
results[period] = result
# 记录日志 - 全量K线总是记录,增量K线5%概率记录
if is_full or random.random() < KLINE_LOG_PROBABILITY:
event_type = "ea_kline_full" if is_full else "ea_kline_incremental"
system_log.add_log(
event_type,
{
"period": period,
"count": len(klines),
"is_full": is_full
},
symbol=symbol,
message=f"{'全量' if is_full else '增量'} {period} {len(klines)}"
)
# 更新转折点
if result['status'] == 'ok':
all_klines = store.get_all_klines(symbol, period)
@@ -206,7 +336,6 @@ def create_market_routes(store: MarketStore, detector: PivotDetector,
"""
获取K线数据
"""
symbol = symbol.upper()
period = period.upper()
klines = store.get_klines(symbol, period, count)
@@ -229,8 +358,6 @@ def create_market_routes(store: MarketStore, detector: PivotDetector,
"""
获取转折点数据
"""
symbol = symbol.upper()
if period:
period = period.upper()
pivots = detector.get_pivots(symbol, period, direction, count)
@@ -267,6 +394,52 @@ def create_market_routes(store: MarketStore, detector: PivotDetector,
"count": len(symbols)
}
@router.get("/market/configured_symbols")
async def get_configured_symbols() -> Dict:
"""
获取配置的品种列表及其数据状态
返回系统配置中的品种,以及每个品种的K线数据状态
"""
from market.monitor import TradeConfig
config = TradeConfig.get_instance()
# 获取配置的品种
configured_symbols = list(config.symbol_config.keys())
# 获取每个品种的状态
symbols_status = []
for symbol in configured_symbols:
# 检查是否有M1数据
m1_status = store.check_m1_updated_within(symbol, 180)
# 获取最新M1 K线时间
latest_m1_time = store.get_latest_kline_time(symbol, 'M1')
# 获取各周期数据条数
period_counts = {}
with store._lock:
for period in ['H4', 'H1', 'M15', 'M5', 'M1']:
period_counts[period] = len(store._klines[symbol][period])
symbols_status.append({
"symbol": symbol,
"has_data": m1_status["has_data"],
"m1_count": period_counts.get('M1', 0),
"latest_m1_time": latest_m1_time.isoformat() if latest_m1_time else None,
"m1_update_time": m1_status.get("update_time").isoformat() if m1_status.get("update_time") else None,
"seconds_ago": m1_status.get("seconds_ago"),
"market_status": m1_status.get("market_status", "closed"),
"period_counts": period_counts,
"config": config.symbol_config.get(symbol, {})
})
return {
"status": "ok",
"symbols": symbols_status,
"count": len(symbols_status)
}
@router.get("/market/status")
async def get_market_status() -> Dict:
"""
@@ -442,6 +615,32 @@ def create_market_routes(store: MarketStore, detector: PivotDetector,
if not order:
return {"status": "error", "message": "订单不存在"}
# 记录日志
system_log = get_system_log()
action_text = '买入' if order.get('action') == 'b' else '卖出'
symbol = order.get('symbol', '')
mount = order.get('mount')
price = order.get('price')
sl = order.get('sl')
tp = order.get('tp')
system_log.add_log(
"order_confirmed",
{
"order_id": order_id,
"action": order.get('action'),
"price": price,
"mount": mount,
"sl": sl,
"tp": tp
},
symbol=symbol,
message=f"{action_text} @ {price}, 手数={mount}, SL={sl}, TP={tp}"
)
# 打印确认订单信息
print(f"[订单确认] {symbol} | {action_text} | 价格={price} | 手数={mount} | SL={sl} | TP={tp}")
return {
"status": "ok",
"message": "订单已确认",
@@ -453,10 +652,23 @@ def create_market_routes(store: MarketStore, detector: PivotDetector,
"""
拒绝待确认订单
"""
# 先获取订单信息用于日志
order = pending_orders.get_order_by_id(order_id)
success = pending_orders.reject_order(order_id)
if not success:
return {"status": "error", "message": "订单不存在"}
# 记录日志
if order:
system_log = get_system_log()
system_log.add_log(
"order_rejected",
{"order_id": order_id, "action": order.get('action'), "price": order.get('price')},
symbol=order.get('symbol'),
message=f"订单已拒绝"
)
return {
"status": "ok",
"message": "订单已拒绝"
@@ -495,15 +707,55 @@ def create_market_routes(store: MarketStore, detector: PivotDetector,
except Exception as e:
return {"status": "error", "message": str(e)}
# ==================== 系统日志接口 ====================
@router.get("/system/logs")
async def get_system_logs(count: int = 50, event_type: str = None,
symbol: str = None) -> Dict:
"""
获取系统运行日志
Args:
count: 获取数量,默认50条
event_type: 过滤事件类型(多个用逗号分隔,如 "order_generated,order_confirmed"
symbol: 过滤品种
"""
system_log = get_system_log()
# 支持多个事件类型过滤
event_types = None
if event_type:
event_types = [et.strip() for et in event_type.split(',') if et.strip()]
logs = system_log.get_logs(count, event_types, symbol)
return {
"status": "ok",
"count": len(logs),
"logs": logs
}
@router.delete("/system/logs")
async def clear_system_logs() -> Dict:
"""清空系统日志"""
system_log = get_system_log()
system_log.clear_logs()
return {"status": "ok", "message": "日志已清空"}
# ==================== WebSocket接口 ====================
@router.websocket("/ws/market")
async def websocket_market(websocket: WebSocket):
"""
WebSocket连接,用于实时推送转折点提醒
WebSocket连接,用于实时推送转折点提醒和大模型分析更新
"""
await websocket.accept()
monitor.add_ws_client(websocket)
if llm_analyzer:
llm_analyzer.add_ws_client(websocket)
# 添加到系统日志的WebSocket客户端列表
system_log = get_system_log()
system_log.add_ws_client(websocket)
try:
# 发送欢迎消息
@@ -530,5 +782,131 @@ def create_market_routes(store: MarketStore, detector: PivotDetector,
finally:
monitor.remove_ws_client(websocket)
if llm_analyzer:
llm_analyzer.remove_ws_client(websocket)
system_log.remove_ws_client(websocket)
# ==================== 大模型分析接口 ====================
@router.get("/llm/analysis")
async def get_llm_analysis(symbol: Optional[str] = None) -> Dict:
"""
获取大模型分析结果
参数:
- symbol: 可选,指定品种;不提供则返回所有
返回:
```json
{
"status": "ok",
"data": {
"symbol": {
"analysis": {...},
"analyzed_at": "2024-01-01T00:00:00"
}
}
}
```
"""
if not llm_analyzer:
return {"status": "error", "message": "大模型分析器未初始化"}
result = llm_analyzer.get_analysis(symbol)
return {
"status": "ok",
"data": result
}
@router.get("/llm/status")
async def get_llm_status() -> Dict:
"""
获取大模型分析器状态
返回:
```json
{
"status": "ok",
"data": {
"enabled": true,
"model": "gpt-4o-mini",
"last_analysis_time": "2024-01-01T00:00:00",
"symbols_analyzed": ["GOLD", "EURUSD"]
}
}
```
"""
if not llm_analyzer:
return {"status": "ok", "data": {"enabled": False, "message": "大模型分析器未初始化"}}
return {
"status": "ok",
"data": llm_analyzer.get_status()
}
@router.get("/llm/config")
async def get_llm_config() -> Dict:
"""
获取大模型配置(API Key会脱敏显示)
返回:
```json
{
"status": "ok",
"config": {
"api_key": "sk-****1234",
"api_key_set": true,
"api_base": "https://api.openai.com/v1",
"model": "gpt-4o-mini",
"enabled": true
}
}
```
"""
if not llm_analyzer:
return {"status": "ok", "config": {"enabled": False, "message": "大模型分析器未初始化"}}
return {
"status": "ok",
"config": llm_analyzer.get_config()
}
@router.post("/llm/trigger")
async def trigger_llm_analysis() -> Dict:
"""
手动触发大模型分析
"""
if not llm_analyzer:
return {"status": "error", "message": "大模型分析器未初始化"}
return llm_analyzer.trigger_analysis()
@router.post("/llm/configure")
async def configure_llm(request: Request) -> Dict:
"""
配置大模型参数
请求体:
```json
{
"api_key": "your-api-key",
"api_base": "https://api.openai.com/v1",
"model": "gpt-4o-mini"
}
```
"""
if not llm_analyzer:
return {"status": "error", "message": "大模型分析器未初始化"}
try:
data = await request.json()
result = llm_analyzer.configure(
api_key=data.get("api_key"),
api_base=data.get("api_base"),
model=data.get("model")
)
return {"status": "ok", "data": result}
except Exception as e:
return {"status": "error", "message": str(e)}
return router
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
新闻路由
财经日历快讯查询和WebSocket推送
"""
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
from fastapi.responses import JSONResponse
from typing import Optional
def create_news_routes():
"""创建新闻相关路由"""
router = APIRouter(prefix="/api/news", tags=["新闻"])
@router.get("/calendar")
async def get_calendar(
date: Optional[str] = Query(None, description="日期,格式: 2026-03-15,不传返回所有")
):
"""
获取财经日历
返回指定日期或所有日期的财经事件
"""
from market.news_monitor import get_news_monitor
news_monitor = get_news_monitor()
calendar = news_monitor.get_calendar(date)
return {
"status": "ok",
"date": date,
"count": len(calendar),
"data": calendar
}
@router.get("/upcoming")
async def get_upcoming(
hours: int = Query(24, description="未来多少小时内的事件")
):
"""
获取即将发布的重要事件
默认返回未来24小时内的重要财经事件
"""
from market.news_monitor import get_news_monitor
news_monitor = get_news_monitor()
events = news_monitor.get_upcoming_events(hours)
return {
"status": "ok",
"hours": hours,
"count": len(events),
"data": events
}
@router.get("/flash")
async def get_flash_news(
count: int = Query(20, description="获取数量,默认20")
):
"""
获取最新快讯
返回最近的有影响的快讯关键人物讲话重要事件
"""
from market.news_monitor import get_news_monitor
news_monitor = get_news_monitor()
news_list = news_monitor.get_recent_news(count)
return {
"status": "ok",
"count": len(news_list),
"data": news_list
}
@router.get("/status")
async def get_status():
"""
获取新闻模块状态
"""
from market.news_monitor import get_news_monitor
news_monitor = get_news_monitor()
status = news_monitor.get_status()
return {
"status": "ok",
"data": status
}
@router.websocket("/ws")
async def news_websocket(websocket: WebSocket):
"""
新闻WebSocket推送
推送内容类型:
- event_reminder: 事件发布前提醒
- event_result: 事件发布结果
- flash_news: 重要快讯
- calendar_update: 日历更新
"""
from market.news_monitor import get_news_monitor
news_monitor = get_news_monitor()
await websocket.accept()
news_monitor.add_ws_client(websocket)
try:
# 发送欢迎消息
await websocket.send_json({
"type": "connected",
"message": "已连接到新闻推送服务"
})
# 保持连接,等待客户端消息或断开
while True:
# 接收客户端消息(心跳等)
data = await websocket.receive_text()
# 处理心跳
if data == "ping":
await websocket.send_json({"type": "pong"})
except WebSocketDisconnect:
pass
except Exception as e:
print(f"[NewsWebSocket] 连接异常: {e}")
finally:
news_monitor.remove_ws_client(websocket)
@router.get("/impact/{symbol}")
async def get_symbol_impact(symbol: str):
"""
获取特定品种的相关事件
返回影响该品种的即将发布事件
"""
from market.news_monitor import get_news_monitor
from market.event_config import WATCH_SYMBOLS
if symbol not in WATCH_SYMBOLS:
return {
"status": "error",
"message": f"不支持的品种: {symbol}",
"supported_symbols": WATCH_SYMBOLS
}
news_monitor = get_news_monitor()
events = news_monitor.get_upcoming_events(72) # 未来3天
# 过滤相关事件
related_events = [
e for e in events
if symbol in e.get('symbols', [])
]
return {
"status": "ok",
"symbol": symbol,
"count": len(related_events),
"data": related_events
}
return router
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
仓位管理相关的接口路由
"""
from fastapi import APIRouter, Request
from typing import Dict, Optional
import json
from market.position_store import get_position_store
from market.system_log import get_system_log
def create_position_routes() -> APIRouter:
"""
创建仓位管理路由
"""
router = APIRouter()
position_store = get_position_store()
@router.post("/ea/positions")
async def receive_positions(request: Request) -> Dict:
"""
EA推送持仓数据
请求体:
```json
{
"symbol": "BTCUSD#",
"positions": [
{
"ticket": 123456,
"volume": 0.01,
"priceOpen": 70000.00,
"type": "BUY",
"profit": 100.50,
"distanceSL": 50.0,
"distanceTP": 100.0
}
]
}
```
"""
try:
data = await request.json()
symbol = data.get('symbol', '')
positions = data.get('positions', [])
if not symbol:
return {"status": "error", "message": "缺少品种信息"}
result = position_store.update_positions(symbol, positions)
# 记录日志
if positions:
system_log = get_system_log()
system_log.add_log(
"position_update",
{
"count": len(positions),
"closed": result.get("closed", 0)
},
symbol=symbol,
message=f"更新 {len(positions)} 个持仓"
)
return result
except Exception as e:
print(f"[PositionAPI] 接收持仓数据异常: {e}")
return {"status": "error", "message": str(e)}
@router.get("/positions")
async def get_positions(symbol: Optional[str] = None) -> Dict:
"""
获取持仓数据
参数:
- symbol: 可选指定品种不提供则返回所有
"""
positions = position_store.get_positions(symbol)
return {
"status": "ok",
"count": len(positions),
"positions": positions
}
@router.get("/positions/summary")
async def get_positions_summary(symbol: Optional[str] = None) -> Dict:
"""
获取持仓汇总
参数:
- symbol: 可选指定品种不提供则返回所有
"""
summary = position_store.get_summary(symbol)
return {
"status": "ok",
**summary
}
@router.get("/positions/{symbol}/{ticket}")
async def get_position(symbol: str, ticket: int) -> Dict:
"""
获取单个持仓详情
"""
position = position_store.get_position(symbol, ticket)
if not position:
return {"status": "error", "message": "持仓不存在"}
return {
"status": "ok",
"position": position
}
# ==================== 交易历史接口 ====================
@router.get("/trade_history")
async def get_trade_history() -> Dict:
"""
获取交易历史数据
"""
from market.trade_history_store import get_trade_history_store
store = get_trade_history_store()
deals = store.get_all_deals()
statistics = store.get_statistics()
return {
"status": "ok",
"deals": deals,
"statistics": statistics
}
@router.get("/trade_history/statistics")
async def get_trade_history_statistics() -> Dict:
"""
获取交易历史统计
"""
from market.trade_history_store import get_trade_history_store
store = get_trade_history_store()
statistics = store.get_statistics()
return {
"status": "ok",
**statistics
}
return router
-1
View File
@@ -96,7 +96,6 @@ def create_trader_routes(server: TradingServer) -> APIRouter:
"""
all_trades = server.get_all_pending_trades()
if symbol:
symbol = symbol.upper()
result = {symbol: all_trades.get(symbol, [])}
else:
result = all_trades
+32 -19
View File
@@ -9,11 +9,12 @@ from typing import List, Dict, Optional
import threading
from models import TradeInstruction
from market.store import MarketStore, normalize_symbol
from market.store import MarketStore
from market.pivot_detector import PivotDetector
from market.monitor import PivotMonitor
from market.monitor import PivotMonitor, TradeConfig
from market.trend_analyzer import TrendAnalyzer
from market.pending_orders import PendingOrderManager
from market.llm_analyzer import LLMAnalyzer
class TradingServer:
@@ -44,10 +45,17 @@ class TradingServer:
self.pending_orders = PendingOrderManager()
# 设置订单确认回调
self.pending_orders.set_confirm_callback(self._on_order_confirmed)
# 大模型分析器(需要在 PivotMonitor 之前初始化)
self.llm_analyzer = LLMAnalyzer(self.market_store)
# 转折点监控器
self.pivot_monitor = PivotMonitor(self.market_store, self.pivot_detector, self.pending_orders)
self.pivot_monitor = PivotMonitor(self.market_store, self.pivot_detector, self.pending_orders, self.llm_analyzer)
# 设置统计数据历史引用(用于获取价差)
self.pivot_monitor.set_statistics_history(self.statistics_history)
# 趋势分析器
self.trend_analyzer = TrendAnalyzer()
self.trend_analyzer.set_statistics_history(self.statistics_history)
# 交易配置
self.trade_config = TradeConfig.get_instance()
print("[信息] 交易服务已初始化")
@@ -64,9 +72,10 @@ class TradingServer:
mount=order.get('mount', 0.01),
price=order.get('price', 0),
sl=order.get('sl', 0),
tp=order.get('tp', 0)
tp=order.get('tp', 0),
description=order.get('description', '')
)
print(f"[TradingServer] 创建交易指令: symbol={instruction.symbol}, action={instruction.action}, mount={instruction.mount}, price={instruction.price}, sl={instruction.sl}, tp={instruction.tp}")
print(f"[TradingServer] 创建交易指令: symbol={instruction.symbol}, action={instruction.action}, mount={instruction.mount}, price={instruction.price}, sl={instruction.sl}, tp={instruction.tp}, description={instruction.description}")
# 添加到交易队列
result = self.add_trade_instruction([instruction])
print(f"[TradingServer] 订单已加入交易队列: {result}")
@@ -112,7 +121,8 @@ class TradingServer:
rejected += 1
continue
symbol = instruction.symbol.upper()
# 直接使用原始symbol,不做转换
symbol = instruction.symbol
self.trade_instructions[symbol].append(instruction)
added += 1
@@ -127,25 +137,29 @@ class TradingServer:
"""
获取指定SYMBOL的交易指令并删除
同时检查价格是否接近转折点如果有则添加到返回结果中
同时调用策略检查 PivotMonitor 中执行
返回: {"trades": [...], "pivot_alerts": [...]}
"""
# 检查转折点
# 检查所有策略(关键点位、支撑压力、AI趋势)
pivot_alerts = []
if price is not None:
# 统一转换为大写进行检测
symbol_upper = symbol.upper()
pivot_alerts = self.pivot_monitor.check_and_alert(symbol_upper, price)
pivot_alerts = self.pivot_monitor.check_and_alert(symbol, price)
if pivot_alerts:
print(f"[信息] {symbol_upper} 当前价格 {price} 接近转折点")
print(f"[信息] {symbol} 当前价格 {price} 接近转折点")
with self.lock:
symbol = symbol.upper()
# 调试:打印当前所有待执行指令
if len(self.trade_instructions) > 0:
print(f"[调试] get_trades_by_symbol 查询symbol={symbol}")
print(f"[调试] 当前trade_instructions keys: {list(self.trade_instructions.keys())}")
for k, v in self.trade_instructions.items():
print(f"[调试] {k}: {len(v)}")
if symbol not in self.trade_instructions or len(self.trade_instructions[symbol]) == 0:
return {"trades": [], "pivot_alerts": pivot_alerts}
# 获取所有指令并直接返回(不再进行价格过滤)
# 获取所有指令并直接返回
trades = self.trade_instructions[symbol]
result = [{
"symbol": t.symbol,
@@ -153,7 +167,8 @@ class TradingServer:
"mount": t.mount,
"price": t.price,
"sl": t.sl,
"tp": t.tp
"tp": t.tp,
"description": t.description or ""
} for t in trades]
# 清空指令队列
@@ -196,7 +211,8 @@ class TradingServer:
"mount": t.mount,
"price": t.price,
"sl": t.sl,
"tp": t.tp
"tp": t.tp,
"description": t.description or ""
}
for t in trades
]
@@ -215,7 +231,6 @@ class TradingServer:
print(f"[信息] 已清空所有交易指令,共 {total}")
return total
else:
symbol = symbol.upper()
count = len(self.trade_instructions.get(symbol, []))
if symbol in self.trade_instructions:
del self.trade_instructions[symbol]
@@ -227,7 +242,6 @@ class TradingServer:
添加平仓指令
"""
with self.lock:
symbol = symbol.upper()
self.close_position_instructions[symbol].append(ticket)
print(f"[信息] 添加平仓指令: {symbol} ticket={ticket}")
@@ -236,7 +250,6 @@ class TradingServer:
获取并清空平仓指令
"""
with self.lock:
symbol = symbol.upper()
tickets = self.close_position_instructions.get(symbol, [])
self.close_position_instructions[symbol] = []
if tickets:
+894 -82
View File
File diff suppressed because it is too large Load Diff