feat: 添加新闻监控、持仓管理、系统日志等功能
- 新增新闻爬取和监控模块 (news_crawler, news_monitor) - 新增 LLM 分析模块 (llm_analyzer) - 新增持仓管理和交易历史存储 - 新增系统日志功能 - 新增前端页面: News, Positions, Settings, SystemLog - 更新路由和 API 接口 - 更新 .gitignore 排除敏感文件
This commit is contained in:
+9
-1
@@ -17,4 +17,12 @@ __pycache__/
|
||||
.claude/
|
||||
|
||||
# Node.js
|
||||
node_modules/
|
||||
node_modules/
|
||||
# Environment
|
||||
.env
|
||||
|
||||
# Data
|
||||
data/
|
||||
|
||||
# Build
|
||||
frontend/dist/
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
+922
-669
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 # 默认返回所有品种
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
@@ -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:00,offset=-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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user