diff --git a/.gitignore b/.gitignore index 6072279..03daac2 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,12 @@ __pycache__/ .claude/ # Node.js -node_modules/ \ No newline at end of file +node_modules/ +# Environment +.env + +# Data +data/ + +# Build +frontend/dist/ diff --git a/frontend/index.html b/frontend/index.html index fca5f89..fee6cf1 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - 量化交易系统 + AITrader diff --git a/frontend/src/App.vue b/frontend/src/App.vue index ea5f7e8..8c4350d 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -2,7 +2,7 @@ - 量化交易系统 + AITrader mdi-refresh @@ -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 { diff --git a/frontend/src/api/market.js b/frontend/src/api/market.js index 862e993..752594b 100644 --- a/frontend/src/api/market.js +++ b/frontend/src/api/market.js @@ -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 } } diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 43b1f24..de1c345 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -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 } ] diff --git a/frontend/src/views/Market.vue b/frontend/src/views/Market.vue index 2679427..575305b 100644 --- a/frontend/src/views/Market.vue +++ b/frontend/src/views/Market.vue @@ -6,25 +6,78 @@ + + + + +
+ mdi-lightning-bolt + + {{ latestFlashNews.speaker }} + + {{ latestFlashNews.content }} + + {{ formatNewsTime(latestFlashNews.time) }} +
+ +
+ + {{ symbol }}: {{ impact.direction }} + +
+
+
+
+
- {{ alert.symbol }} {{ alert.period }} - - {{ alert.is_breakthrough ? '已突破' : '接近' }}{{ alert.direction === 'high' ? '高点' : '低点' }} - - {{ alert.pivot_price }} - 当前价格: {{ alert.current_price }} - 距离: {{ alert.distance_pct }}% + {{ alert.symbol }} + + 接近{{ alert.direction === 'high' ? '高点' : '低点' }} + + + {{ alert.period }} + +
+
+ 转折点: {{ alert.pivot_price }} + 当前价格: {{ alert.current_price }} + 距离: {{ (alert.distance_pct * 100).toFixed(2) }}% +
+ + +
+ + {{ p.period }}: {{ p.price }} ({{ p.distance_pct }}%) +
@@ -33,12 +86,160 @@ mdi-file-document-edit 自动生成交易指令 + + +
+
+ 技术信号: + + {{ alert.pending_order.tech_action }} + + AI建议: + + {{ alert.pending_order.ai_direction }} + +
+
+ + + +
+ mdi-alert-circle + AI与技术信号冲突!以AI方向为准 +
+
+ AI理由: {{ alert.pending_order.ai_reason }} +
+
+ + + +
+ mdi-check-circle + AI与技术信号一致 +
+
+ AI理由: {{ alert.pending_order.ai_reason }} +
+
+
{{ alert.pending_order.action === 'b' ? '买入' : '卖出' }} 价格: {{ alert.pending_order.price?.toFixed(2) }}
+ + + + + + + + + + + + + + mdi-check + 确认 + + + 放弃 + + + +
+ {{ alert.pending_order.reason }} +
+
+ mdi-clock-outline + 3分钟内未操作将自动移除 +
+ +
+
+
+ + + + + +
+ mdi-robot + {{ alert.symbol }} {{ alert.period }} + AI建议入场 + + {{ alert.direction === 'buy' ? '买入' : '卖出' }} + + 入场价: {{ alert.entry_price }} + 当前价: {{ alert.current_price }} + 差距: {{ alert.price_diff_pct }}% +
+ + +
+
+ mdi-file-document-edit + AI交易建议 +
+ +
+ + {{ alert.pending_order.action === 'b' ? '买入' : '卖出' }} + + 入场价: {{ alert.pending_order.price?.toFixed(2) }} + 止损: {{ alert.pending_order.sl }} + 止盈: {{ alert.pending_order.tp }} +
+ @@ -81,7 +282,7 @@ small class="mr-1" :loading="confirmingOrderId === alert.pending_order.order_id" - @click="confirmAlertOrder(alert.pending_order, index)" + @click="confirmAiEntryOrder(alert.pending_order, index)" > mdi-check 确认 @@ -91,14 +292,15 @@ small outlined :loading="rejectingOrderId === alert.pending_order.order_id" - @click="rejectAlertOrder(alert.pending_order.order_id, index)" + @click="rejectAiEntryOrder(alert.pending_order.order_id, index)" > 放弃
- {{ alert.pending_order.reason }} + mdi-lightbulb + {{ alert.reason }}
mdi-clock-outline @@ -109,93 +311,150 @@ - - - - - - - - - - - - + + - - - mdi-briefcase - 当前持仓 - - -
- - + + \ No newline at end of file diff --git a/frontend/src/views/Positions.vue b/frontend/src/views/Positions.vue new file mode 100644 index 0000000..62b20ad --- /dev/null +++ b/frontend/src/views/Positions.vue @@ -0,0 +1,497 @@ + + + \ No newline at end of file diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue new file mode 100644 index 0000000..2875ab2 --- /dev/null +++ b/frontend/src/views/Settings.vue @@ -0,0 +1,574 @@ + + + \ No newline at end of file diff --git a/frontend/src/views/SystemLog.vue b/frontend/src/views/SystemLog.vue new file mode 100644 index 0000000..f597f37 --- /dev/null +++ b/frontend/src/views/SystemLog.vue @@ -0,0 +1,470 @@ + + + + + \ No newline at end of file diff --git a/frontend/vite.config.js b/frontend/vite.config.js index b1c0e95..fdb2125 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -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 } } } diff --git a/main.py b/main.py index 276b58b..ac65e26 100644 --- a/main.py +++ b/main.py @@ -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 diff --git a/market/event_config.py b/market/event_config.py new file mode 100644 index 0000000..889e6a6 --- /dev/null +++ b/market/event_config.py @@ -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 # 默认返回所有品种 \ No newline at end of file diff --git a/market/llm_analyzer.py b/market/llm_analyzer.py new file mode 100644 index 0000000..5e74a24 --- /dev/null +++ b/market/llm_analyzer.py @@ -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] \ No newline at end of file diff --git a/market/monitor.py b/market/monitor.py index a09c1ec..9024f30 100644 --- a/market/monitor.py +++ b/market/monitor.py @@ -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: diff --git a/market/news_crawler.py b/market/news_crawler.py new file mode 100644 index 0000000..0aa0bff --- /dev/null +++ b/market/news_crawler.py @@ -0,0 +1,1682 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +金十数据爬虫 +获取财经日历、快讯和事件结果 +支持Playwright浏览器登录 +""" + +import asyncio +import aiohttp +import re +import json +import os +import hashlib +from datetime import datetime, timedelta +from typing import List, Dict, Optional +from bs4 import BeautifulSoup + +# Playwright 为可选依赖 +try: + from playwright.async_api import async_playwright, Browser, Page + PLAYWRIGHT_AVAILABLE = True +except ImportError: + PLAYWRIGHT_AVAILABLE = False + async_playwright = None + Browser = None + Page = None + +from .news_store import CalendarEvent, FlashNews, get_news_store +from .event_config import ( + ECONOMIC_DATA, KEY_SPEAKERS, KEY_EVENTS, + get_important_event_names, get_high_impact_event_names, + DATA_IMPACT_RULES, WATCH_SYMBOLS +) +from .system_log import get_system_log + + +class Jin10Crawler: + """金十数据爬虫""" + + # 金十数据API地址 + CALENDAR_API = "https://rmdex.jin10.com/data.json" + FLASH_NEWS_API = "https://flash-api.jin10.com/get_flash_list" + + # 备用地址 + CALENDAR_PAGE = "https://www.jin10.com/rili/calendar.html" + FLASH_PAGE = "https://www.jin10.com/flash" + + # 请求头 + HEADERS = { + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': 'application/json, text/plain, */*', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', + 'Referer': 'https://www.jin10.com/', + 'Origin': 'https://www.jin10.com', + } + + def __init__(self, username: str = None, password: str = None): + self.store = get_news_store() + self.system_log = get_system_log() + self._session = None + + # 重要事件名称(用于过滤) + self._important_names = get_important_event_names() + self._high_impact_names = get_high_impact_event_names() + + # Playwright相关 + self._playwright = None + self._browser: Optional[Browser] = None + self._context = None + self._page: Optional[Page] = None + self._logged_in = False + + # 登录凭据 + self._username = username or os.environ.get('JIN10_USERNAME', '18689211297') + self._password = password or os.environ.get('JIN10_PASSWORD', 'Wangxx1234') + + print("[Jin10Crawler] 金十数据爬虫已初始化") + self.system_log.add_log("news_crawler_start", message="金十数据爬虫已初始化") + + async def _get_session(self) -> aiohttp.ClientSession: + """获取HTTP会话""" + if self._session is None or self._session.closed: + timeout = aiohttp.ClientTimeout(total=30) + self._session = aiohttp.ClientSession( + headers=self.HEADERS, + timeout=timeout + ) + return self._session + + # ==================== Playwright登录 ==================== + + async def _init_browser(self): + """初始化Playwright浏览器""" + if not PLAYWRIGHT_AVAILABLE: + print("[Jin10Crawler] Playwright未安装,跳过浏览器初始化") + return + + if self._browser is not None: + return + + try: + self._playwright = await async_playwright().start() + self._browser = await self._playwright.chromium.launch( + headless=True, + args=['--no-sandbox', '--disable-setuid-sandbox'] + ) + self._context = await self._browser.new_context( + user_agent='Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + ) + self._page = await self._context.new_page() + print("[Jin10Crawler] Playwright浏览器已初始化") + except Exception as e: + print(f"[Jin10Crawler] 初始化浏览器失败: {e}") + self.system_log.add_log("news_calendar_fetch_error", detail={ + "error": str(e), + "step": "init_browser" + }, message=f"初始化浏览器失败: {e}") + + async def _handle_verify_dialog(self) -> bool: + """ + 处理登录后可能出现的验证对话框 + + Returns: + 是否成功处理(True表示可以继续,False表示需要人工介入) + """ + try: + await asyncio.sleep(1) + + # 检查是否有遮罩层 + mask = await self._page.query_selector('.user-modal-mask.active') + if mask: + print("[Jin10Crawler] 检测到遮罩层") + + # 检查遮罩层内是否有验证相关内容 + mask_content = await mask.inner_text() + + # 1. 滑块验证 + if '滑块' in mask_content or '滑动' in mask_content or 'verify' in mask_content.lower(): + print("[Jin10Crawler] 检测到滑块验证,需要人工处理") + self.system_log.add_log("news_calendar_fetch_error", detail={ + "type": "slider_captcha" + }, message="检测到滑块验证,需要人工处理") + return False + + # 2. 短信验证码 + if '验证码' in mask_content or '短信' in mask_content: + print("[Jin10Crawler] 检测到短信验证码,需要人工处理") + self.system_log.add_log("news_calendar_fetch_error", detail={ + "type": "sms_captcha" + }, message="检测到短信验证码,需要人工处理") + return False + + # 3. 风险提示弹窗 - 尝试关闭 + close_btns = await self._page.query_selector_all('.modal-close, .close-btn, .icon-close, [class*="close"]') + for btn in close_btns: + try: + if await btn.is_visible(): + await btn.click() + await asyncio.sleep(1) + print("[Jin10Crawler] 已尝试关闭风险提示弹窗") + return True + except: + continue + + # 4. 尝试按ESC关闭 + await self._page.keyboard.press('Escape') + await asyncio.sleep(1) + + # 5. 尝试点击遮罩层外部关闭 + try: + await self._page.mouse.click(10, 10) + await asyncio.sleep(1) + except: + pass + + return True + + except Exception as e: + print(f"[Jin10Crawler] 处理验证对话框异常: {e}") + return True + + async def _check_login_success(self) -> bool: + """ + 检查登录是否成功 + + Returns: + 是否登录成功 + """ + try: + # 方法1: 检查登录弹窗是否消失 + modal_visible = await self._page.is_visible('.user-modal, .login-modal, .user-modal-mask.active') + if not modal_visible: + print("[Jin10Crawler] 登录弹窗已消失") + return True + + # 方法2: 检查是否有用户信息显示 + user_selectors = [ + '.user-avatar', '.user-info', '.username', '.user-name', + '.header-user', '.user-dropdown', '[class*="user-avatar"]' + ] + for selector in user_selectors: + try: + user_el = await self._page.query_selector(selector) + if user_el: + visible = await user_el.is_visible() + if visible: + print(f"[Jin10Crawler] 找到用户元素: {selector}") + return True + except: + continue + + # 方法3: 检查URL是否不再包含login + current_url = self._page.url + if 'login' not in current_url.lower(): + print(f"[Jin10Crawler] URL已跳转: {current_url}") + return True + + # 方法4: 检查是否有登录状态的cookie或localStorage + try: + logged_in = await self._page.evaluate(''' + () => { + // 检查localStorage + const token = localStorage.getItem('token') || localStorage.getItem('access_token'); + if (token) return true; + + // 检查cookie + const cookies = document.cookie; + if (cookies.includes('token=') || cookies.includes('auth=')) return true; + + return false; + } + ''') + if logged_in: + print("[Jin10Crawler] 检测到登录token") + return True + except: + pass + + return False + + except Exception as e: + print(f"[Jin10Crawler] 检查登录状态异常: {e}") + return False + + async def login(self) -> bool: + """ + 使用Playwright登录金十数据 + + Returns: + 是否登录成功 + """ + try: + await self._init_browser() + + if self._logged_in: + return True + + self.system_log.add_log("news_crawler_start", detail={ + "username": self._username[:3] + "****" + }, message="开始登录金十数据...") + + print(f"[Jin10Crawler] 开始登录金十数据,用户: {self._username[:3]}****") + + # 访问首页,然后点击登录 + await self._page.goto('https://www.jin10.com/', wait_until='networkidle', timeout=60000) + await asyncio.sleep(2) + + # 点击登录按钮打开登录弹窗 + try: + # 尝试多种登录按钮选择器 + login_btn_selectors = [ + '.login-wall__btn', # 登录墙上的按钮 + '.header-login-btn', + '.user-login', + 'button:has-text("登录")', + 'button:has-text("立即登录")', + 'a:has-text("登录")' + ] + + login_clicked = False + for selector in login_btn_selectors: + try: + btn = await self._page.wait_for_selector(selector, timeout=3000, state='visible') + if btn: + await btn.click() + login_clicked = True + print(f"[Jin10Crawler] 已点击登录按钮: {selector}") + break + except: + continue + + if not login_clicked: + # 尝试查找包含登录文本的元素 + elements = await self._page.query_selector_all('button, a, span') + for el in elements: + try: + text = await el.text_content() + if text and ('登录' in text or '登錄' in text): + await el.click() + login_clicked = True + print(f"[Jin10Crawler] 已点击登录元素: {text.strip()}") + break + except: + continue + + # 等待登录弹窗出现 + await asyncio.sleep(2) + + except Exception as e: + print(f"[Jin10Crawler] 点击登录按钮失败: {e}") + + # 截图调试 + try: + await self._page.screenshot(path='/tmp/jin10_after_click.png') + print("[Jin10Crawler] 已保存点击后截图") + except: + pass + + # 尝试查找并填写登录表单 + try: + # 方法1: 查找手机号/用户名输入框 + username_filled = False + username_selectors = [ + 'input[placeholder*="手机"]', + 'input[placeholder*="账号"]', + 'input[placeholder*="用户名"]', + 'input[type="tel"]', + 'input[name="phone"]', + 'input[name="username"]', + '#phone', + '#username', + '.login-phone input', + '.login-form input:first-child', + '.user-modal input[type="tel"]', + '.user-modal input:first-of-type' + ] + + for selector in username_selectors: + try: + el = await self._page.wait_for_selector(selector, timeout=3000, state='visible') + if el: + await el.click() + await asyncio.sleep(0.3) + # 先清空再填写 + await el.fill('') + await el.type(self._username, delay=50) + username_filled = True + print(f"[Jin10Crawler] 已通过选择器 {selector} 输入用户名") + break + except: + continue + + if not username_filled: + # 尝试查找所有可见的输入框 + inputs = await self._page.query_selector_all('input:visible') + if inputs: + for inp in inputs: + try: + input_type = await inp.get_attribute('type') + name_attr = await inp.get_attribute('name') or '' + placeholder = await inp.get_attribute('placeholder') or '' + # 跳过checkbox, file, hidden等类型 + if input_type in ['checkbox', 'file', 'hidden', 'submit']: + continue + # 优先选择看起来像手机号输入框的 + if '手机' in placeholder or 'phone' in name_attr.lower() or input_type == 'tel': + await inp.click() + await asyncio.sleep(0.2) + await inp.fill('') + await inp.type(self._username, delay=50) + username_filled = True + print(f"[Jin10Crawler] 已通过遍历输入框填写用户名 (placeholder: {placeholder})") + break + except: + continue + + # 如果还没找到,就用第一个可输入的 + if not username_filled and inputs: + for inp in inputs: + try: + input_type = await inp.get_attribute('type') + if input_type not in ['checkbox', 'file', 'hidden', 'submit', 'password']: + await inp.click() + await asyncio.sleep(0.2) + await inp.fill('') + await inp.type(self._username, delay=50) + username_filled = True + print("[Jin10Crawler] 已通过遍历输入框填写用户名(默认)") + break + except: + continue + + if not username_filled: + print("[Jin10Crawler] 未找到用户名输入框") + # 检查页面是否有验证码登录 + page_content = await self._page.content() + if '验证码' in page_content or 'code' in page_content: + print("[Jin10Crawler] 页面可能需要验证码登录,暂不支持") + self.system_log.add_log("news_calendar_fetch_error", detail={ + "error": "验证码登录不支持", + "step": "login_captcha" + }, message="金十数据可能需要验证码登录") + return False + + # 填写密码 + await asyncio.sleep(0.5) + password_filled = False + password_selectors = [ + 'input[placeholder*="密码"]', + 'input[type="password"]', + 'input[name="password"]', + '#password', + '.login-password input', + '.login-form input[type="password"]', + '.user-modal input[type="password"]' + ] + + for selector in password_selectors: + try: + el = await self._page.wait_for_selector(selector, timeout=3000, state='visible') + if el: + await el.click() + await asyncio.sleep(0.3) + await el.fill('') + await el.type(self._password, delay=50) + password_filled = True + print(f"[Jin10Crawler] 已通过选择器 {selector} 输入密码") + break + except: + continue + + if not password_filled: + # 查找密码输入框 + inputs = await self._page.query_selector_all('input[type="password"]:visible') + if inputs: + await inputs[0].click() + await asyncio.sleep(0.2) + await inputs[0].fill('') + await inputs[0].type(self._password, delay=50) + password_filled = True + print("[Jin10Crawler] 已通过遍历密码框填写密码") + + if not password_filled: + print("[Jin10Crawler] 未找到密码输入框,可能需要验证码登录") + return False + + # 点击登录按钮 + await asyncio.sleep(0.5) + login_clicked = False + login_selectors = [ + 'button:has-text("登录")', + 'button:has-text("登錄")', + '.login-btn', + '.btn-login', + 'button[type="submit"]', + '.login-form button', + '.user-modal button[type="submit"]', + '.user-modal button:has-text("登")' + ] + + for selector in login_selectors: + try: + el = await self._page.wait_for_selector(selector, timeout=2000, state='visible') + if el: + await el.click() + login_clicked = True + print(f"[Jin10Crawler] 已通过选择器 {selector} 点击登录按钮") + break + except: + continue + + if not login_clicked: + # 查找包含"登录"文本的按钮 + buttons = await self._page.query_selector_all('button:visible') + for btn in buttons: + try: + text = await btn.text_content() + if text and ('登录' in text or '登錄' in text): + await btn.click() + login_clicked = True + print("[Jin10Crawler] 已通过文本查找点击登录按钮") + break + except: + continue + + if not login_clicked: + print("[Jin10Crawler] 未找到登录按钮") + return False + + # 等待登录处理 + await asyncio.sleep(3) + + # 处理可能出现的验证对话框 + verify_ok = await self._handle_verify_dialog() + if not verify_ok: + print("[Jin10Crawler] 需要人工处理验证") + # 不直接返回False,继续检查登录状态 + + # 再等待一下让登录完成 + await asyncio.sleep(3) + + # 使用新方法检查登录状态 + if await self._check_login_success(): + self._logged_in = True + print("[Jin10Crawler] 登录成功") + self.system_log.add_log("news_calendar_update", detail={ + "step": "login_success" + }, message="金十数据登录成功") + return True + return True + + print("[Jin10Crawler] 登录状态未知,可能需要人工处理验证") + return False + + except Exception as e: + print(f"[Jin10Crawler] 登录表单操作失败: {e}") + self.system_log.add_log("news_calendar_fetch_error", detail={ + "error": str(e), + "step": "login_form" + }, message=f"登录表单操作失败: {e}") + return False + + except Exception as e: + print(f"[Jin10Crawler] 登录异常: {e}") + self.system_log.add_log("news_calendar_fetch_error", detail={ + "error": str(e), + "step": "login_exception" + }, message=f"登录异常: {e}") + return False + + async def fetch_calendar_with_browser(self, days: int = 7) -> Dict[str, List[CalendarEvent]]: + """ + 使用浏览器获取财经日历(登录后) + + Args: + days: 获取未来多少天 + + Returns: + {date_str: [CalendarEvent]} + """ + try: + if not self._logged_in: + success = await self.login() + if not success: + print("[Jin10Crawler] 登录失败,无法获取财经日历") + return {} + + result = {} + today = datetime.now() + + for i in range(days): + date = today + timedelta(days=i) + date_str = date.strftime("%Y%m%d") + date_key = date.strftime("%Y-%m-%d") + + try: + url = f"https://www.jin10.com/rili/calendar_{date_str}.html" + await self._page.goto(url, wait_until='networkidle', timeout=30000) + await asyncio.sleep(1) + + # 解析页面内容 + events = await self._parse_calendar_from_page(date_key) + if events: + result[date_key] = events + print(f"[Jin10Crawler] 浏览器获取 {date_key} 成功: {len(events)} 条事件") + + except Exception as e: + print(f"[Jin10Crawler] 获取 {date_key} 失败: {e}") + continue + + if result: + self.system_log.add_log("news_calendar_update", detail={ + "source": "browser", + "dates": len(result), + "events": sum(len(v) for v in result.values()) + }, message=f"浏览器获取财经日历成功: {len(result)}天") + + return result + + except Exception as e: + print(f"[Jin10Crawler] 浏览器获取日历失败: {e}") + self.system_log.add_log("news_calendar_fetch_error", detail={ + "error": str(e), + "step": "browser_fetch" + }, message=f"浏览器获取日历失败: {e}") + return {} + + async def _parse_calendar_from_page(self, date_key: str) -> List[CalendarEvent]: + """从页面解析财经日历""" + events = [] + + try: + # 等待日历数据加载 + await asyncio.sleep(3) + + # 获取页面内容 + content = await self._page.content() + + # 保存HTML用于调试 + with open('/tmp/jin10_calendar_page.html', 'w') as f: + f.write(content) + print("[Jin10Crawler] 已保存日历页面HTML到 /tmp/jin10_calendar_page.html") + + # 尝试从页面JSON数据中提取 + # 金十数据通常会在页面中嵌入JSON数据 + json_patterns = [ + r'window\.__INITIAL_STATE__\s*=\s*(\{.*?\});', + r'__NEXT_DATA__\s*=\s*(\{.*?\})', + ] + + for json_pattern in json_patterns: + match = re.search(json_pattern, content, re.DOTALL) + if match: + try: + data = json.loads(match.group(1)) + print(f"[Jin10Crawler] 找到JSON数据,keys: {list(data.keys())[:10]}") + + # 解析数据 - 尝试不同的数据结构 + calendar_data = data.get('calendar', data.get('rili', data.get('calendarData', {}))) + + if isinstance(calendar_data, dict): + event_list = calendar_data.get('events', calendar_data.get('data', calendar_data.get('list', []))) + else: + event_list = calendar_data if isinstance(calendar_data, list) else [] + + if not event_list: + # 尝试其他路径 + event_list = data.get('events', data.get('data', [])) + + print(f"[Jin10Crawler] 找到 {len(event_list) if isinstance(event_list, list) else 0} 个事件") + + for item in event_list if isinstance(event_list, list) else []: + try: + event = self._parse_calendar_item(date_key.replace('-', ''), item) + if event and event.importance >= 2: + events.append(event) + except Exception as e: + continue + + if events: + return events + + except Exception as e: + print(f"[Jin10Crawler] 解析页面JSON失败: {e}") + + # 如果JSON解析失败,尝试解析HTML + soup = BeautifulSoup(content, 'html.parser') + + # 查找事件行 - 尝试多种选择器 + row_selectors = [ + '.calendar-item', '.rili-item', 'tr[data-time]', + '.jin-calendar__tr', '.event-row', '.calendar-row', + '[class*="calendar"] tr', '[class*="rili"] tr' + ] + + for selector in row_selectors: + rows = soup.select(selector) + if rows: + print(f"[Jin10Crawler] 使用选择器 {selector} 找到 {len(rows)} 行") + for row in rows: + try: + event = self._parse_calendar_row(row, date_key) + if event: + events.append(event) + except: + continue + if events: + break + + # 如果还是没有数据,尝试从表格解析 + if not events: + tables = soup.find_all('table') + for table in tables: + rows = table.find_all('tr') + for row in rows: + cells = row.find_all(['td', 'th']) + if len(cells) >= 3: + # 尝试从单元格提取信息 + try: + # 查找包含时间的单元格 + time_cell = None + name_cell = None + importance_cell = None + + for i, cell in enumerate(cells): + text = cell.get_text(strip=True) + if re.match(r'\d{1,2}:\d{2}', text): + time_cell = cell + elif any(imp in text.lower() for imp in ['非农', 'cpi', '利率', 'gdp', 'pmi', 'adp', 'eia']): + name_cell = cell + + if name_cell: + name = name_cell.get_text(strip=True) + # 检查重要性 + row_classes = row.get('class', []) + importance = 2 # 默认中等 + if any('high' in c.lower() or 'star-3' in c.lower() for c in row_classes): + importance = 3 + + time_str = time_cell.get_text(strip=True) if time_cell else '' + + event = CalendarEvent( + id=f"{date_key}_{name}_{time_str}", + name=name, + name_en='', + country='', + importance=importance, + publish_time=self._parse_datetime(date_key.replace('-', ''), time_str), + forecast='', + previous='', + actual='', + unit='', + symbols=self._get_event_symbols(name) + ) + events.append(event) + except: + continue + + except Exception as e: + print(f"[Jin10Crawler] 解析页面失败: {e}") + import traceback + traceback.print_exc() + + return events + + def _parse_calendar_row(self, row, date_key: str) -> Optional[CalendarEvent]: + """解析HTML行""" + try: + # 获取时间 + time_el = row.select_one('.time, .calendar-time') + time_str = time_el.get_text(strip=True) if time_el else '' + + # 获取事件名称 + name_el = row.select_one('.event, .calendar-event, .name') + name = name_el.get_text(strip=True) if name_el else '' + + if not name: + return None + + # 检查重要性 + importance_el = row.select_one('.star, .importance') + importance = 2 + if importance_el: + star_class = importance_el.get('class', []) + if 'star-3' in star_class or 'high' in star_class: + importance = 3 + elif 'star-2' in star_class or 'medium' in star_class: + importance = 2 + else: + importance = 1 + + # 过滤重要事件 + is_important = any( + imp_name.lower() in name.lower() + for imp_name in self._high_impact_names + ) + if not is_important: + return None + + # 获取数值 + forecast_el = row.select_one('.forecast, .consensus') + previous_el = row.select_one('.previous, .prev') + actual_el = row.select_one('.actual') + + forecast = forecast_el.get_text(strip=True) if forecast_el else '' + previous = previous_el.get_text(strip=True) if previous_el else '' + actual = actual_el.get_text(strip=True) if actual_el else '' + + # 解析时间 + publish_time = self._parse_datetime(date_key.replace('-', ''), time_str) + + return CalendarEvent( + id=f"{date_key}_{name}_{time_str}", + name=name, + name_en='', + country='', + importance=importance, + publish_time=publish_time, + forecast=forecast, + previous=previous, + actual=actual, + unit='', + symbols=self._get_event_symbols(name) + ) + + except Exception as e: + return None + + async def close(self): + """关闭会话和浏览器""" + if self._session and not self._session.closed: + await self._session.close() + + if self._browser: + await self._browser.close() + self._browser = None + self._page = None + self._context = None + + if self._playwright: + await self._playwright.stop() + self._playwright = None + + self._logged_in = False + print("[Jin10Crawler] 已关闭所有连接") + + # ==================== 财经日历 ==================== + + async def fetch_calendar(self, days: int = 7) -> Dict[str, List[CalendarEvent]]: + """ + 获取财经日历 + + Args: + days: 获取未来多少天 + + Returns: + {date_str: [CalendarEvent]} + """ + try: + self.system_log.add_log("news_calendar_fetch", detail={"days": days}, message="开始获取财经日历...") + + # 尝试金十数据API + session = await self._get_session() + result = await self._fetch_jin10_calendar(session, days) + if result: + self.system_log.add_log("news_calendar_update", detail={ + "source": "金十数据API", + "dates": len(result), + "events": sum(len(v) for v in result.values()) + }, message=f"金十数据API获取成功: {len(result)}天") + return result + + # 尝试使用浏览器登录获取 + try: + result = await self.fetch_calendar_with_browser(days) + if result: + self.system_log.add_log("news_calendar_update", detail={ + "source": "金十数据浏览器", + "dates": len(result), + "events": sum(len(v) for v in result.values()) + }, message=f"浏览器获取成功: {len(result)}天") + return result + except Exception as e: + print(f"[Jin10Crawler] 浏览器获取失败: {e}") + + # 如果所有真实数据源都失败,返回模拟数据用于测试 + self.system_log.add_log("news_calendar_fetch_error", detail={ + "reason": "所有数据源失败,使用模拟数据" + }, message="所有数据源失败,使用模拟数据") + return self._get_mock_calendar(days) + + except Exception as e: + self.system_log.add_log("news_calendar_fetch_error", detail={ + "error": str(e) + }, message=f"获取财经日历失败: {e}") + print(f"[Jin10Crawler] 获取财经日历失败: {e}") + import traceback + traceback.print_exc() + return self._get_mock_calendar(days) + + async def _fetch_jin10_calendar(self, session: aiohttp.ClientSession, days: int) -> Optional[Dict]: + """通过金十数据API获取财经日历""" + today = datetime.now() + date_list = [] + for i in range(days): + date = today + timedelta(days=i) + date_list.append(date.strftime("%Y%m%d")) + + result = {} + + for date_str in date_list: + try: + url = f"https://rili.jin10.com/datas/{date_str}.json" + async with session.get(url) as response: + if response.status == 200: + data = await response.json() + events = self._parse_calendar_data(date_str, data) + if events: + date_key = f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:8]}" + result[date_key] = events + except Exception as e: + continue + + if result: + print(f"[Jin10Crawler] 金十数据获取成功: {len(result)} 天") + return result + + return None + + def _get_mock_calendar(self, days: int) -> Dict[str, List[CalendarEvent]]: + """生成模拟数据用于测试""" + from .event_config import ECONOMIC_DATA + + result = {} + today = datetime.now() + + # 模拟一些重要事件 + mock_events = [ + {"name": "非农就业人数", "importance": 3, "hour": 20, "minute": 30, "country": "US"}, + {"name": "失业率", "importance": 3, "hour": 20, "minute": 30, "country": "US"}, + {"name": "CPI年率", "importance": 3, "hour": 20, "minute": 30, "country": "US"}, + {"name": "美联储利率决议", "importance": 3, "hour": 14, "minute": 0, "country": "US"}, + {"name": "EIA原油库存", "importance": 2, "hour": 22, "minute": 30, "country": "US"}, + {"name": "初请失业金人数", "importance": 2, "hour": 20, "minute": 30, "country": "US"}, + {"name": "日本央行利率决议", "importance": 3, "hour": 11, "minute": 0, "country": "JP"}, + {"name": "ADP就业人数", "importance": 2, "hour": 20, "minute": 15, "country": "US"}, + {"name": "零售销售月率", "importance": 2, "hour": 20, "minute": 30, "country": "US"}, + ] + + # 分配到未来几天 + for i in range(min(days, 3)): + date = today + timedelta(days=i) + date_key = date.strftime("%Y-%m-%d") + + events = [] + # 每天分配2-3个事件 + day_events = mock_events[i*3:(i+1)*3] if i < 3 else mock_events[:2] + + for idx, mock in enumerate(day_events): + event_date = date.replace(hour=mock["hour"], minute=mock["minute"]) + + event = CalendarEvent( + id=f"mock_{date_key}_{idx}", + name=mock["name"], + name_en=mock["name"], + country=mock["country"], + importance=mock["importance"], + publish_time=event_date, + forecast="待定", + previous="--", + actual="", + unit="", + symbols=self._get_event_symbols(mock["name"]) + ) + events.append(event) + + if events: + result[date_key] = events + + print(f"[Jin10Crawler] 生成模拟数据: {len(result)} 天") + return result + + async def _fetch_calendar_api(self, session: aiohttp.ClientSession, days: int) -> Optional[Dict]: + """通过API获取财经日历""" + try: + # 尝试金十数据 + today = datetime.now() + date_list = [] + for i in range(days): + date = today + timedelta(days=i) + date_list.append(date.strftime("%Y%m%d")) + + result = {} + + for date_str in date_list: + try: + # 金十数据日历API + url = f"https://rili.jin10.com/datas/{date_str}.json" + async with session.get(url) as response: + if response.status == 200: + data = await response.json() + events = self._parse_calendar_data(date_str, data) + if events: + # 转换日期格式 + date_key = f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:8]}" + result[date_key] = events + except Exception as e: + print(f"[Jin10Crawler] 获取 {date_str} 日历失败: {e}") + continue + + if result: + print(f"[Jin10Crawler] API获取财经日历成功: {len(result)} 天") + return result + + except Exception as e: + print(f"[Jin10Crawler] API获取失败: {e}") + + return None + + def _parse_calendar_data(self, date_str: str, data: Dict) -> List[CalendarEvent]: + """解析财经日历数据""" + events = [] + + try: + # 金十数据格式: {date: {events: [...]}} + if isinstance(data, dict): + date_data = data.get(date_str, data) + event_list = date_data.get('events', date_data.get('data', [])) + else: + event_list = data if isinstance(data, list) else [] + + for item in event_list: + try: + event = self._parse_calendar_item(date_str, item) + if event and event.importance > 0: # 只保留有影响的 + events.append(event) + except Exception as e: + continue + + except Exception as e: + print(f"[Jin10Crawler] 解析日历数据失败: {e}") + + return events + + def _parse_calendar_item(self, date_str: str, item: Dict) -> Optional[CalendarEvent]: + """解析单个日历事件""" + # 获取事件名称 + name = item.get('name', item.get('event', '')) + if not name: + return None + + # 过滤不重要的事件 + is_important = False + for important_name in self._important_names: + if important_name.lower() in name.lower(): + is_important = True + break + + if not is_important: + return None + + # 获取重要性星级 + star = item.get('star', item.get('importance', 0)) + if isinstance(star, str): + star = len(star) # 星星数量 + importance = min(3, max(0, int(star))) + + # 解析时间 + time_str = item.get('time', item.get('datetime', '')) + publish_time = self._parse_datetime(date_str, time_str) + + # 获取国家 + country = item.get('country', item.get('region', '')) + + # 获取数值 + forecast = item.get('forecast', item.get('consensus', '')) + previous = item.get('previous', item.get('prev', '')) + actual = item.get('actual', '') + unit = item.get('unit', '') + + # 生成ID + event_id = item.get('id', f"{date_str}_{name}_{time_str}") + + # 查找对应的事件配置 + symbols = self._get_event_symbols(name) + + return CalendarEvent( + id=str(event_id), + name=name, + name_en=item.get('name_en', ''), + country=country, + importance=importance, + publish_time=publish_time, + forecast=str(forecast), + previous=str(previous), + actual=str(actual), + unit=unit, + symbols=symbols + ) + + def _parse_datetime(self, date_str: str, time_str: str) -> datetime: + """解析日期时间""" + try: + # date_str: "20260315" 或 "2026-03-15" + if len(date_str) == 8: + year = int(date_str[:4]) + month = int(date_str[4:6]) + day = int(date_str[6:8]) + else: + parts = date_str.split('-') + year, month, day = int(parts[0]), int(parts[1]), int(parts[2]) + + # time_str: "20:30" 或 "2030" + hour, minute = 0, 0 + if time_str: + time_str = time_str.strip() + if ':' in time_str: + parts = time_str.split(':') + hour = int(parts[0]) + minute = int(parts[1]) if len(parts) > 1 else 0 + elif len(time_str) >= 3: + hour = int(time_str[:-2]) + minute = int(time_str[-2:]) + + return datetime(year, month, day, hour, minute) + + except Exception as e: + print(f"[Jin10Crawler] 解析时间失败: {date_str} {time_str}") + return datetime.now() + + def _get_event_symbols(self, name: str) -> List[str]: + """获取事件影响的品种""" + for event in ECONOMIC_DATA: + if event['name'] in name or event['name_en'].lower() in name.lower(): + return event['symbols'] + + # 根据国家推断 + if '美国' in name or 'US' in name.upper(): + return ["GOLD", "SPX", "USDJPY"] + elif '日本' in name or 'JP' in name.upper(): + return ["USDJPY"] + + return WATCH_SYMBOLS + + async def _fetch_calendar_page(self, session: aiohttp.ClientSession, days: int) -> Dict: + """通过页面爬取财经日历""" + # 备用方案:爬取HTML页面 + result = {} + today = datetime.now() + + for i in range(days): + date = today + timedelta(days=i) + date_str = date.strftime("%Y-%m-%d") + + try: + url = f"https://www.jin10.com/rili/calendar_{date.strftime('%Y%m%d')}.html" + async with session.get(url) as response: + if response.status == 200: + html = await response.text() + events = self._parse_calendar_html(html, date_str) + if events: + result[date_str] = events + except Exception as e: + print(f"[Jin10Crawler] 爬取页面 {date_str} 失败: {e}") + continue + + return result + + def _parse_calendar_html(self, html: str, date_str: str) -> List[CalendarEvent]: + """解析日历HTML""" + events = [] + + try: + soup = BeautifulSoup(html, 'html.parser') + # 这里需要根据实际HTML结构解析 + # 金十数据的HTML结构可能会变化,需要定期维护 + + event_rows = soup.select('.jin-calendar__tr') + for row in event_rows: + try: + time_td = row.select_one('.jin-calendar__time') + event_td = row.select_one('.jin-calendar__event') + if not time_td or not event_td: + continue + + name = event_td.get_text(strip=True) + time_str = time_td.get_text(strip=True) + + # 过滤重要事件 + is_important = any( + imp_name.lower() in name.lower() + for imp_name in self._important_names + ) + if not is_important: + continue + + event = CalendarEvent( + id=f"{date_str}_{name}_{time_str}", + name=name, + publish_time=self._parse_datetime(date_str, time_str), + importance=2, # 默认中等重要性 + symbols=self._get_event_symbols(name) + ) + events.append(event) + + except Exception: + continue + + except Exception as e: + print(f"[Jin10Crawler] 解析HTML失败: {e}") + + return events + + # ==================== 快讯 ==================== + + async def fetch_flash_news(self, max_id: int = 0, count: int = 30) -> List[FlashNews]: + """ + 获取快讯列表(无需登录,直接访问页面) + + Args: + max_id: 获取此ID之前的快讯(用于分页)- 保留参数但不使用 + count: 获取数量 + + Returns: + 快讯列表 + """ + try: + # 直接访问快讯页面获取数据,无需登录 + return await self.fetch_flash_news_without_login(count) + except Exception as e: + self.system_log.add_log("news_flash_fetch_error", detail={ + "source": "Playwright浏览器", + "error": str(e) + }, message=f"[Playwright] 获取快讯失败: {e}") + print(f"[Jin10Crawler] [Playwright] 获取快讯失败: {e}") + return [] + + async def fetch_flash_news_without_login(self, count: int = 30) -> List[FlashNews]: + """ + 获取快讯(无需登录,直接访问页面) + + Args: + count: 获取数量 + + Returns: + 快讯列表 + """ + try: + # 检查Playwright是否可用 + if not PLAYWRIGHT_AVAILABLE: + print("[Jin10Crawler] Playwright未安装,使用HTTP API获取快讯") + return await self._fetch_flash_news_via_api(count) + + # 初始化浏览器(如果还没初始化) + if self._browser is None: + await self._init_browser() + + # 检查浏览器是否初始化成功 + if self._page is None: + print("[Jin10Crawler] 浏览器初始化失败,使用HTTP API获取快讯") + return await self._fetch_flash_news_via_api(count) + + # 直接访问快讯页面 + url = 'https://www.jin10.com/flash' + print(f"[Jin10Crawler] 访问快讯页面: {url}") + await self._page.goto(url, wait_until='networkidle', timeout=30000) + await asyncio.sleep(3) + + # 获取页面内容 + content = await self._page.content() + + # 解析HTML获取快讯 + news_list = [] + soup = BeautifulSoup(content, 'html.parser') + items = soup.select('.jin-flash-item.flash') + + print(f"[Jin10Crawler] 找到 {len(items)} 个快讯元素") + + for idx, item in enumerate(items[:count]): + try: + # 获取时间: .item-time + time_el = item.select_one('.item-time') + time_str = time_el.get_text(strip=True) if time_el else '' + + # 获取内容: .flash-text 或 .item-right + content_el = item.select_one('.flash-text') + if not content_el: + right_el = item.select_one('.item-right') + if right_el: + title_el = right_el.select_one('.item-title') + if title_el: + content_text = title_el.get_text(strip=True) + else: + content_text = right_el.get_text(strip=True) + else: + continue + else: + content_text = content_el.get_text(strip=True) + + # 使用内容哈希生成唯一ID + content_hash = hashlib.md5(content_text.encode('utf-8')).hexdigest()[:12] + news_id = f"jin10_{content_hash}" + + # 解析时间 + news_time = self._parse_html_time(time_str) + + news = FlashNews( + id=news_id, + content=content_text, + source='jin10', + time=news_time, + importance=0, + keywords=[], + related_symbols=[] + ) + news_list.append(news) + + except Exception as e: + print(f"[Jin10Crawler] 解析快讯项失败: {e}") + continue + + if news_list: + self.system_log.add_log("news_flash_fetch", detail={ + "source": "金十网站", + "url": url, + "count": len(news_list) + }, message=f"[金十网站] 获取快讯成功: {len(news_list)}条") + print(f"[Jin10Crawler] [金十网站] 获取快讯成功: {len(news_list)}条") + else: + self.system_log.add_log("news_flash_fetch", detail={ + "source": "金十网站", + "url": url, + "count": 0 + }, message="[金十网站] 未获取到快讯") + + return news_list + + except Exception as e: + self.system_log.add_log("news_flash_fetch_error", detail={ + "source": "金十网站", + "error": str(e) + }, message=f"[金十网站] 获取快讯异常: {e}") + print(f"[Jin10Crawler] [金十网站] 获取快讯异常: {e}") + return [] + + async def _fetch_flash_news_via_api(self, count: int = 30) -> List[FlashNews]: + """ + 通过HTTP API获取快讯(备用方法,无需Playwright) + + Args: + count: 获取数量 + + Returns: + 快讯列表 + """ + try: + session = await self._get_session() + + # 金十快讯API + params = { + "channel": "-8200", # 全部快讯 + "vip": 1, + "max_time": int(datetime.now().timestamp()) + } + + async with session.get(self.FLASH_NEWS_API, params=params) as resp: + if resp.status != 200: + print(f"[Jin10Crawler] HTTP API请求失败: {resp.status}") + return [] + + data = await resp.json() + + # 解析快讯 + news_list = self._parse_flash_news(data, count) + + if news_list: + self.system_log.add_log("news_flash_fetch", detail={ + "source": "金十API", + "count": len(news_list) + }, message=f"[金十API] 获取快讯成功: {len(news_list)}条") + print(f"[Jin10Crawler] [金十API] 获取快讯成功: {len(news_list)}条") + else: + self.system_log.add_log("news_flash_fetch", detail={ + "source": "金十API", + "count": 0 + }, message="[金十API] 未获取到快讯") + + return news_list + + except Exception as e: + self.system_log.add_log("news_flash_fetch_error", detail={ + "source": "金十API", + "error": str(e) + }, message=f"[金十API] 获取快讯异常: {e}") + print(f"[Jin10Crawler] [金十API] 获取快讯异常: {e}") + return [] + + def _parse_flash_news(self, data: Dict, count: int) -> List[FlashNews]: + """解析快讯数据""" + news_list = [] + + try: + items = data.get('data', []) + + for item in items[:count]: + try: + news = FlashNews( + id=str(item.get('id', '')), + content=item.get('content', item.get('data', '')), + source='jin10', + time=self._parse_news_time(item.get('time', item.get('created_at', ''))), + importance=0, # 后续分析填充 + keywords=[], + related_symbols=[] + ) + news_list.append(news) + except Exception: + continue + + except Exception as e: + print(f"[Jin10Crawler] 解析快讯失败: {e}") + + return news_list + + def _parse_news_time(self, time_data) -> datetime: + """解析快讯时间""" + if isinstance(time_data, (int, float)): + # Unix时间戳 + return datetime.fromtimestamp(int(time_data)) + elif isinstance(time_data, str): + try: + # ISO格式 + return datetime.fromisoformat(time_data.replace('Z', '+00:00')) + except: + return datetime.now() + return datetime.now() + + def _parse_html_time(self, time_str: str) -> datetime: + """ + 解析HTML中的时间字符串 + + Args: + time_str: 时间字符串,如 "23:18:35" 或 "03-15 23:18" + + Returns: + datetime对象 + """ + if not time_str: + return datetime.now() + + try: + now = datetime.now() + + # 格式1: "HH:MM:SS" + if re.match(r'^\d{2}:\d{2}:\d{2}$', time_str): + hour, minute, second = map(int, time_str.split(':')) + return now.replace(hour=hour, minute=minute, second=second, microsecond=0) + + # 格式2: "MM-DD HH:MM" + if re.match(r'^\d{2}-\d{2} \d{2}:\d{2}$', time_str): + parts = time_str.split(' ') + month, day = map(int, parts[0].split('-')) + hour, minute = map(int, parts[1].split(':')) + return now.replace(month=month, day=day, hour=hour, minute=minute, second=0, microsecond=0) + + # 格式3: "HH:MM" + if re.match(r'^\d{2}:\d{2}$', time_str): + hour, minute = map(int, time_str.split(':')) + return now.replace(hour=hour, minute=minute, second=0, microsecond=0) + + except Exception as e: + print(f"[Jin10Crawler] 解析HTML时间失败: {time_str}, {e}") + + return datetime.now() + + # ==================== 事件结果 ==================== + + async def fetch_event_result(self, event_id: str) -> Optional[Dict]: + """ + 获取事件发布结果 + + Args: + event_id: 事件ID + + Returns: + {actual, forecast, previous, result} + """ + try: + session = await self._get_session() + + # 尝试从日历数据中获取 + url = f"https://rili.jin10.com/datas/{event_id.split('_')[0]}.json" + async with session.get(url) as response: + if response.status == 200: + data = await response.json() + return self._find_event_result(event_id, data) + + except Exception as e: + print(f"[Jin10Crawler] 获取事件结果失败: {e}") + + return None + + def _find_event_result(self, event_id: str, data: Dict) -> Optional[Dict]: + """从数据中查找事件结果""" + try: + # 遍历查找匹配的事件 + for key, value in data.items(): + events = value.get('events', value.get('data', [])) + for item in events: + if str(item.get('id', '')) == event_id: + return { + 'actual': item.get('actual', ''), + 'forecast': item.get('forecast', ''), + 'previous': item.get('previous', ''), + } + except Exception: + pass + + return None + + # ==================== 影响分析 ==================== + + def analyze_data_impact(self, event: CalendarEvent) -> Dict: + """ + 分析数据对品种的影响 + + Args: + event: 已发布的事件(含实际值) + + Returns: + {symbol: {direction, strength, reason}} + """ + if not event.actual or not event.forecast: + return {} + + result = self._compare_values(event.actual, event.forecast, event.unit) + impact = {} + + for symbol in event.symbols: + symbol_impact = self._get_symbol_impact(symbol, event.name, result) + if symbol_impact: + impact[symbol] = symbol_impact + + return impact + + def _compare_values(self, actual: str, forecast: str, unit: str) -> str: + """比较实际值和预期值""" + try: + # 提取数值 + actual_num = self._extract_number(actual) + forecast_num = self._extract_number(forecast) + + if actual_num is None or forecast_num is None: + return 'unknown' + + diff_pct = (actual_num - forecast_num) / abs(forecast_num) if forecast_num != 0 else 0 + + if abs(diff_pct) < 0.05: # 5%以内认为符合预期 + return 'in_line' + elif diff_pct > 0: + return 'better' # 实际值高于预期 + else: + return 'worse' # 实际值低于预期 + + except Exception: + return 'unknown' + + def _extract_number(self, value: str) -> Optional[float]: + """从字符串中提取数值""" + if not value: + return None + + try: + # 移除百分号、逗号等 + cleaned = re.sub(r'[,%$¥¥]', '', str(value)) + # 提取数字 + match = re.search(r'[-+]?\d*\.?\d+', cleaned) + if match: + return float(match.group()) + except Exception: + pass + + return None + + def _get_symbol_impact(self, symbol: str, event_name: str, result: str) -> Optional[Dict]: + """获取对特定品种的影响""" + if result == 'unknown' or result == 'in_line': + return None + + # 查找匹配的规则 + rules = DATA_IMPACT_RULES.get(symbol, {}) + + for event_key, rule in rules.items(): + if event_key in event_name: + direction = rule.get(result, '中性') + reason_key = f'reason_{result}' + reason = rule.get(reason_key, '') + + return { + 'direction': direction, + 'strength': '中', + 'reason': reason + } + + return None + + def analyze_news_impact(self, news: FlashNews) -> Dict: + """ + 分析快讯对品种的影响 + + Args: + news: 快讯内容 + + Returns: + {speaker, impact: {symbol: {direction, reason}}} + """ + content = news.content + + # 1. 检查是否涉及关键人物 + speaker_info = self._check_key_speaker(content) + + # 2. 检查是否涉及关键事件 + event_info = self._check_key_event(content) + + impact = {} + + if speaker_info: + # 根据讲话内容分析影响 + for symbol in speaker_info.get('impact_symbols', []): + symbol_impact = self._analyze_speaker_content( + symbol, content, speaker_info + ) + if symbol_impact: + impact[symbol] = symbol_impact + + if event_info: + # 根据事件类型分析影响 + for symbol in event_info.get('symbols', []): + symbol_impact = self._analyze_event_content( + symbol, content, event_info + ) + if symbol_impact: + impact[symbol] = symbol_impact + + return { + 'speaker': speaker_info.get('name', '') if speaker_info else '', + 'speaker_title': speaker_info.get('title', '') if speaker_info else '', + 'event_type': event_info.get('name', '') if event_info else '', + 'impact': impact + } + + def _check_key_speaker(self, content: str) -> Optional[Dict]: + """检查是否涉及关键人物""" + for speaker in KEY_SPEAKERS: + for keyword in speaker['keywords']: + if keyword in content: + # 检查是否涉及关键话题 + for topic in speaker['watch_topics']: + if topic in content: + return speaker + return None + + def _check_key_event(self, content: str) -> Optional[Dict]: + """检查是否涉及关键事件""" + for event in KEY_EVENTS: + for keyword in event['watch_keywords']: + if keyword in content: + return event + return None + + def _analyze_speaker_content(self, symbol: str, content: str, speaker: Dict) -> Optional[Dict]: + """分析讲话内容对品种的影响""" + default_impact = speaker.get('default_impact', {}).get(symbol, {}) + + for topic, direction in default_impact.items(): + if topic in content: + return { + 'direction': direction, + 'strength': '高' if speaker['importance'] == 3 else '中', + 'reason': f"{speaker['name']}关于{topic}的讲话" + } + + return { + 'direction': '不确定', + 'strength': '中', + 'reason': f"{speaker['name']}讲话" + } + + def _analyze_event_content(self, symbol: str, content: str, event: Dict) -> Optional[Dict]: + """分析事件内容对品种的影响""" + # 简单的情感分析 + negative_words = ['下跌', '暴跌', '利空', '担忧', '风险', '紧张', '冲突', '战争'] + positive_words = ['上涨', '暴涨', '利好', '乐观', '增长', '协议', '达成'] + + negative_count = sum(1 for w in negative_words if w in content) + positive_count = sum(1 for w in positive_words if w in content) + + if negative_count > positive_count: + direction = '利空' + elif positive_count > negative_count: + direction = '利好' + else: + direction = '不确定' + + return { + 'direction': direction, + 'strength': '高' if event['importance'] == 3 else '中', + 'reason': f"{event['name']}相关新闻" + } + + +# 全局单例 +_jin10_crawler = None + + +def get_jin10_crawler() -> Jin10Crawler: + """获取金十爬虫单例""" + global _jin10_crawler + if _jin10_crawler is None: + _jin10_crawler = Jin10Crawler() + return _jin10_crawler \ No newline at end of file diff --git a/market/news_monitor.py b/market/news_monitor.py new file mode 100644 index 0000000..f007218 --- /dev/null +++ b/market/news_monitor.py @@ -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 \ No newline at end of file diff --git a/market/news_store.py b/market/news_store.py new file mode 100644 index 0000000..9076bb1 --- /dev/null +++ b/market/news_store.py @@ -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 \ No newline at end of file diff --git a/market/pivot_detector.py b/market/pivot_detector.py index ed0fb09..09fafbd 100644 --- a/market/pivot_detector.py +++ b/market/pivot_detector.py @@ -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 \ No newline at end of file + 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) \ No newline at end of file diff --git a/market/position_store.py b/market/position_store.py new file mode 100644 index 0000000..ad79887 --- /dev/null +++ b/market/position_store.py @@ -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 \ No newline at end of file diff --git a/market/store.py b/market/store.py index f11ebeb..6ec94cb 100644 --- a/market/store.py +++ b/market/store.py @@ -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) \ No newline at end of file + 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 \ No newline at end of file diff --git a/market/system_log.py b/market/system_log.py new file mode 100644 index 0000000..6843d69 --- /dev/null +++ b/market/system_log.py @@ -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 \ No newline at end of file diff --git a/market/trade_history_store.py b/market/trade_history_store.py new file mode 100644 index 0000000..c166fad --- /dev/null +++ b/market/trade_history_store.py @@ -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 \ No newline at end of file diff --git a/market/trend_analyzer.py b/market/trend_analyzer.py index 8535c70..c3e2720 100644 --- a/market/trend_analyzer.py +++ b/market/trend_analyzer.py @@ -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), diff --git a/models.py b/models.py index 912d263..6c10e83 100644 --- a/models.py +++ b/models.py @@ -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): diff --git a/requirements.txt b/requirements.txt index 2cfde9e..5704d09 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/routes_ea.py b/routes_ea.py index 0121372..4006446 100644 --- a/routes_ea.py +++ b/routes_ea.py @@ -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 \ No newline at end of file + @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' \ No newline at end of file diff --git a/routes_market.py b/routes_market.py index 1668997..0c32311 100644 --- a/routes_market.py +++ b/routes_market.py @@ -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 \ No newline at end of file diff --git a/routes_news.py b/routes_news.py new file mode 100644 index 0000000..0044119 --- /dev/null +++ b/routes_news.py @@ -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 \ No newline at end of file diff --git a/routes_position.py b/routes_position.py new file mode 100644 index 0000000..ce94a25 --- /dev/null +++ b/routes_position.py @@ -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 \ No newline at end of file diff --git a/routes_trader.py b/routes_trader.py index 095553a..cc24862 100644 --- a/routes_trader.py +++ b/routes_trader.py @@ -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 diff --git a/server.py b/server.py index 7271ed3..292a89b 100644 --- a/server.py +++ b/server.py @@ -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: diff --git a/wangxxGold.mq5 b/wangxxGold.mq5 index d17edcb..affc062 100644 --- a/wangxxGold.mq5 +++ b/wangxxGold.mq5 @@ -5,7 +5,7 @@ //+------------------------------------------------------------------+ #property copyright "wwananggxxxx" #property link "https://www.mql5.com" -#property version "2.00" +#property version "2.01" // <-- 版本号已更新,确认编译的是最新版本 #property strict //--- 需要访问Web请求权限 @@ -14,6 +14,13 @@ #include #include +//--- 财经日历事件类型常量 (用于switch语句) +// 注意: 不使用const,因为switch需要编译时常量 +// CALENDAR_EVENT_TYPE_INDICATOR = 1 +// CALENDAR_EVENT_TYPE_SPEECH = 2 +// CALENDAR_EVENT_TYPE_MEETING = 3 +// CALENDAR_EVENT_TYPE_HOLIDAY = 4 + //+------------------------------------------------------------------+ //| 全局变量定义 | //+------------------------------------------------------------------+ @@ -28,6 +35,8 @@ datetime g_lastStatisticTime = 0; int g_tickCount = 0; double g_bidPrice = 0; double g_askPrice = 0; +double g_spread = 0; // 点差(金额) +double g_spreadPoints = 0; // 点差(点数) double g_accountBalance = 0; double g_accountEquity = 0; double g_marginLevel = 0; @@ -46,6 +55,9 @@ datetime g_lastM15CloseTime = 0; // 上次M15 K线收盘时间 datetime g_lastM5CloseTime = 0; // 上次M5 K线收盘时间 datetime g_lastM1CloseTime = 0; // 上次M1 K线收盘时间 +// 最后一次Tick时间戳 +datetime g_lastTickTime = 0; + // 交易类对象 CTrade trade; CSymbolInfo symbolInfo; @@ -54,6 +66,40 @@ CPositionInfo positionInfo; // 风险管理相关 double g_riskLimitPercent = 30.0; // 30% 账户风险限制 +//+------------------------------------------------------------------+ +//| 财经日历相关变量 | +//+------------------------------------------------------------------+ + +// 财经日历事件存储结构 +struct CalendarEventData + { + long event_id; // 事件ID + string name; // 事件名称 + string currency; // 货币代码 + string country; // 国家代码 + int importance; // 重要性 0-3 + datetime publish_time; // 发布时间 + string forecast; // 预测值 + string previous; // 前值 + string actual; // 实际值 + string event_type; // 事件类型 + }; + +// 存储最近2小时的财经事件(按时间排序) +CalendarEventData g_calendarEvents[]; +int g_calendarEventCount = 0; +int g_maxCalendarEvents = 500; // 最大存储事件数 + +// 日历检查相关 +datetime g_lastCalendarCheckTime = 0; // 上次检查时间 +int g_calendarCheckInterval = 300; // 检查间隔(秒),5分钟 +datetime g_nextEventPublishTime = 0; // 下一个重要事件发布时间 +bool g_calendarInitialized = false; // 日历是否已初始化 + +// 交易历史上报相关 +datetime g_lastTradeHistoryReportTime = 0; // 上次上报时间 +int g_tradeHistoryReportInterval = 600; // 上报间隔(秒),10分钟 + //+------------------------------------------------------------------+ //| URL编码函数 - 处理特殊字符 | //+------------------------------------------------------------------+ @@ -67,7 +113,7 @@ string URLEncode(string str) if((ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' || ch == '.') { - result += CharToString(ch); + result += CharToString((uchar)ch); } else { @@ -90,6 +136,18 @@ int OnInit() g_lastStatisticTime = TimeCurrent(); g_lastPythonRequestTime = GetTickCount(); g_lastKlinePushTime = TimeCurrent(); + g_lastCalendarCheckTime = 0; // 初始化财经日历检查时间 + g_lastTradeHistoryReportTime = 0; // 初始化交易历史上报时间 + +//--- 初始化随机数种子 + MathSrand((uint)TimeCurrent()); + +//--- 初始化财经日历事件数组 + ArrayResize(g_calendarEvents, g_maxCalendarEvents); + g_calendarEventCount = 0; + +//--- 设置定时器,每1秒触发一次 + EventSetTimer(1); //--- 打印初始化信息 Print("Expert initialized successfully"); @@ -100,6 +158,14 @@ int OnInit() Print("Pushing historical K-line data..."); PushAllKlineData(true); // is_full = true +//--- 启动时获取财经日历 + Print("Fetching calendar data..."); + CheckAndUpdateCalendar(); + +//--- 启动时上报交易历史 + Print("Reporting trade history..."); + ReportTradeHistory(); + //--- return(INIT_SUCCEEDED); } @@ -108,6 +174,8 @@ int OnInit() //+------------------------------------------------------------------+ void OnDeinit(const int reason) { +//--- 取消定时器 + EventKillTimer(); //--- Print("Expert deinitialized, reason: ", reason); } @@ -117,15 +185,24 @@ void OnDeinit(const int reason) void UpdateStatistics() { g_tickCount++; - + //--- 获取当前价格 MqlTick lastTick; if(SymbolInfoTick(_Symbol, lastTick)) { g_bidPrice = lastTick.bid; g_askPrice = lastTick.ask; + + // 计算点差 + g_spread = g_askPrice - g_bidPrice; + // 计算点差(点数)= 点差金额 / 点值 + double point = SymbolInfoDouble(_Symbol, SYMBOL_POINT); + if(point > 0) + { + g_spreadPoints = g_spread / point; + } } - + //--- 获取账户信息 g_accountBalance = AccountInfoDouble(ACCOUNT_BALANCE); g_accountEquity = AccountInfoDouble(ACCOUNT_EQUITY); @@ -134,49 +211,106 @@ void UpdateStatistics() //+------------------------------------------------------------------+ //| 获取持仓汇总信息 - 返回JSON格式字符串 | +//| 参数: onlyCurrentSymbol - true只获取当前品种,false获取所有品种 | //+------------------------------------------------------------------+ -string GetPositionsSummary() +string GetPositionsSummary(bool onlyCurrentSymbol = true) { string summary = "["; int positionCount = 0; - + for(int i = 0; i < PositionsTotal(); i++) { if(!PositionGetTicket(i)) continue; - - long posTicket = PositionGetInteger(POSITION_TICKET); + string posSymbol = PositionGetString(POSITION_SYMBOL); - if(posSymbol != _Symbol) continue; // 只统计当前品种 - + if(onlyCurrentSymbol && posSymbol != _Symbol) continue; // 只统计当前品种 + double posVolume = PositionGetDouble(POSITION_VOLUME); double posPriceOpen = PositionGetDouble(POSITION_PRICE_OPEN); double posProfit = PositionGetDouble(POSITION_PROFIT); double posSL = PositionGetDouble(POSITION_SL); double posTP = PositionGetDouble(POSITION_TP); ENUM_POSITION_TYPE posType = (ENUM_POSITION_TYPE)PositionGetInteger(POSITION_TYPE); - - double currentPrice = (posType == POSITION_TYPE_BUY) ? g_bidPrice : g_askPrice; + + // 获取当前价格(需要根据品种获取对应的bid/ask) + double currentPrice = 0; + if(posSymbol == _Symbol) + { + currentPrice = (posType == POSITION_TYPE_BUY) ? g_bidPrice : g_askPrice; + } + else + { + // 对于其他品种,使用当前tick价格 + MqlTick tick; + if(SymbolInfoTick(posSymbol, tick)) + { + currentPrice = (posType == POSITION_TYPE_BUY) ? tick.bid : tick.ask; + } + } + double distanceSL = (posSL > 0) ? MathAbs(currentPrice - posSL) : 0; double distanceTP = (posTP > 0) ? MathAbs(posTP - currentPrice) : 0; - + if(positionCount > 0) summary += ","; summary += "{"; - summary += "\"ticket\":" + IntegerToString(posTicket) + ","; + summary += "\"ticket\":" + IntegerToString(PositionGetInteger(POSITION_TICKET)) + ","; + summary += "\"symbol\":\"" + posSymbol + "\","; summary += "\"volume\":" + DoubleToString(posVolume, 2) + ","; summary += "\"priceOpen\":" + DoubleToString(posPriceOpen, _Digits) + ","; summary += "\"type\":\"" + (posType == POSITION_TYPE_BUY ? "BUY" : "SELL") + "\","; summary += "\"profit\":" + DoubleToString(posProfit, 2) + ","; + summary += "\"sl\":" + DoubleToString(posSL, _Digits) + ","; + summary += "\"tp\":" + DoubleToString(posTP, _Digits) + ","; summary += "\"distanceSL\":" + DoubleToString(distanceSL, _Digits) + ","; summary += "\"distanceTP\":" + DoubleToString(distanceTP, _Digits) + ""; summary += "}"; - + positionCount++; } - + summary += "]"; return summary; } +//+------------------------------------------------------------------+ +//| 发送持仓数据到Python服务 | +//| 参数: allSymbols - true发送所有品种持仓,false只发送当前品种 | +//+------------------------------------------------------------------+ +void SendPositionsToPython(bool allSymbols = true) + { + string positions = GetPositionsSummary(!allSymbols); // allSymbols=true时,onlyCurrentSymbol=false + + // 构建JSON请求体 + string jsonBody = "{"; + jsonBody += "\"symbol\":\"" + _Symbol + "\","; // 当前品种 + jsonBody += "\"positions\":" + positions; + jsonBody += "}"; + + // 发送HTTP POST请求 + string headers = "Content-Type: application/json\r\n"; + uchar postData[]; + uchar responseData[]; + string outheaders = ""; + int responseCode = 0; + + // 将JSON字符串转换为字节数组 + StringToCharArray(jsonBody, postData, 0, WHOLE_ARRAY, CP_UTF8); + // 移除末尾的null字符 + ArrayResize(postData, ArraySize(postData) - 1); + + string url = g_pythonServer + "/ea/positions"; + responseCode = WebRequest("POST", url, headers, 5000, postData, responseData, outheaders); + + if(responseCode == 200) + { + Print("[持仓上报] 成功上报持仓数据"); + } + else if(responseCode != -1) + { + Print("[持仓上报] 失败. Response code: ", responseCode); + } + } + //+------------------------------------------------------------------+ //| 检查并平仓风险持仓 | //+------------------------------------------------------------------+ @@ -275,6 +409,9 @@ void ParseAndExecuteTrades(string jsonData) if(StringLen(jsonData) == 0) return; + bool hasTrades = false; + bool hasCloseTickets = false; + // 提取trades数组 int tradesPos = StringFind(jsonData, "\"trades\":"); if(tradesPos != -1) @@ -284,18 +421,27 @@ void ParseAndExecuteTrades(string jsonData) if(tradesStart != -1 && tradesEnd != -1) { string tradesJson = StringSubstr(jsonData, tradesStart, tradesEnd - tradesStart + 1); - // 如果trades数组不为空,打印出来 + // 如果trades数组不为空 if(tradesJson != "[]") { Print("[EA] 收到交易指令: ", tradesJson); + hasTrades = true; + ParseTradeArray(tradesJson); } - ParseTradeArray(tradesJson); } } else { // 旧格式兼容:直接是数组 [...] - ParseTradeArray(jsonData); + if(StringFind(jsonData, "[") == 0 && StringFind(jsonData, "]") > 0) + { + string content = StringSubstr(jsonData, 1, StringLen(jsonData) - 2); + if(StringLen(content) > 0) + { + hasTrades = true; + ParseTradeArray(jsonData); + } + } } // 提取close_tickets数组并执行平仓 @@ -307,7 +453,12 @@ void ParseAndExecuteTrades(string jsonData) if(closeStart != -1 && closeEnd != -1) { string closeJson = StringSubstr(jsonData, closeStart, closeEnd - closeStart + 1); - ParseAndExecuteClose(closeJson); + Print("[EA] 收到close_tickets: ", closeJson); + if(closeJson != "[]") + { + hasCloseTickets = true; + ParseAndExecuteClose(closeJson); + } } } } @@ -317,28 +468,44 @@ void ParseAndExecuteTrades(string jsonData) //+------------------------------------------------------------------+ void ParseAndExecuteClose(string jsonData) { + Print("[EA] ParseAndExecuteClose 输入: ", jsonData, " 长度: ", StringLen(jsonData)); + // 移除首尾的括号 if(StringFind(jsonData, "[") == 0) { jsonData = StringSubstr(jsonData, 1, StringLen(jsonData) - 2); } + Print("[EA] 移除括号后: ", jsonData, " 长度: ", StringLen(jsonData)); + if(StringLen(jsonData) == 0) return; - // 解析ticket列表 - string tickets[]; - int count = StringSplit(jsonData, ',', tickets); + // 直接解析数字(假设只有一个ticket) + long ticket = StringToInteger(jsonData); + Print("[EA] 直接解析ticket: ", ticket); - for(int i = 0; i < count; i++) + if(ticket > 0) { - string ticketStr = tickets[i]; - ticketStr = StringTrimLeft(ticketStr); - ticketStr = StringTrimRight(ticketStr); + ClosePositionByTicket(ticket); + } + else + { + // 如果有逗号分隔的多个ticket + string tickets[]; + int count = StringSplit(jsonData, ',', tickets); + Print("[EA] 多ticket模式, count=", count); - long ticket = StringToInteger(ticketStr); - if(ticket > 0) + for(int i = 0; i < count; i++) { - ClosePositionByTicket(ticket); + string ticketStr = tickets[i]; + StringTrimLeft(ticketStr); + StringTrimRight(ticketStr); + ticket = StringToInteger(ticketStr); + Print("[EA] ticket[", i, "] str='", ticketStr, "' -> ", ticket); + if(ticket > 0) + { + ClosePositionByTicket(ticket); + } } } } @@ -348,40 +515,17 @@ void ParseAndExecuteClose(string jsonData) //+------------------------------------------------------------------+ void ClosePositionByTicket(long ticket) { - // 查找持仓 - for(int i = 0; i < PositionsTotal(); i++) + Print("[EA] ClosePositionByTicket 尝试平仓: ticket=", ticket); + + // 使用CTrade类平仓(更简单可靠) + if(trade.PositionClose(ticket)) { - if(PositionGetTicket(i) == ticket) - { - string posSymbol = PositionGetString(POSITION_SYMBOL); - double posVolume = PositionGetDouble(POSITION_VOLUME); - ENUM_POSITION_TYPE posType = (ENUM_POSITION_TYPE)PositionGetInteger(POSITION_TYPE); - - // 构造平仓请求 - MqlTradeRequest request = {}; - MqlTradeResult result = {}; - - request.action = TRADE_ACTION_DEAL; - request.position = ticket; - request.symbol = posSymbol; - request.volume = posVolume; - request.type = (posType == POSITION_TYPE_BUY) ? ORDER_TYPE_SELL : ORDER_TYPE_BUY; - request.comment = "Close by Python command"; - - if(OrderSend(request, result)) - { - Print("[平仓成功] Ticket: ", ticket, " Symbol: ", posSymbol); - } - else - { - Print("[平仓失败] Ticket: ", ticket, " Error: ", GetLastError()); - } - - return; - } + Print("[平仓成功] Ticket: ", ticket); + } + else + { + Print("[平仓失败] Ticket: ", ticket, " Error: ", GetLastError(), " Retcode: ", trade.ResultRetcode(), " ", trade.ResultRetcodeDescription()); } - - Print("[平仓] 未找到订单号: ", ticket); } //+------------------------------------------------------------------+ @@ -428,8 +572,9 @@ void ExecuteTradeFromJson(string tradeJson) double volume = ExtractJsonDouble(tradeJson, "mount"); double sl = ExtractJsonDouble(tradeJson, "sl"); double tp = ExtractJsonDouble(tradeJson, "tp"); + string description = ExtractJsonString(tradeJson, "description"); - Print("[EA] 收到交易指令: symbol=", symbol, " action=", action, " volume=", volume, " sl=", sl, " tp=", tp); + Print("[EA] 收到交易指令: symbol=", symbol, " action=", action, " volume=", volume, " sl=", sl, " tp=", tp, " description=", description); if(symbol == "" || action == "" || volume <= 0) { @@ -443,10 +588,16 @@ void ExecuteTradeFromJson(string tradeJson) return; } + // 如果没有description,使用默认值 + if(description == "") + { + description = "Python AI Trade"; + } + ENUM_ORDER_TYPE orderType = (action == "b") ? ORDER_TYPE_BUY : ORDER_TYPE_SELL; - Print("[EA] 准备执行交易: ", (orderType == ORDER_TYPE_BUY ? "BUY" : "SELL"), " ", volume, " ", symbol); - ExecuteTrade(orderType, volume, sl, tp); + Print("[EA] 准备执行交易: ", (orderType == ORDER_TYPE_BUY ? "BUY" : "SELL"), " ", volume, " ", symbol, " desc=", description); + ExecuteTrade(orderType, volume, sl, tp, description); } //+------------------------------------------------------------------+ @@ -490,14 +641,14 @@ double ExtractJsonDouble(string json, string key) //+------------------------------------------------------------------+ //| 执行交易 | //+------------------------------------------------------------------+ -void ExecuteTrade(ENUM_ORDER_TYPE orderType, double volume, double sl, double tp) +void ExecuteTrade(ENUM_ORDER_TYPE orderType, double volume, double sl, double tp, string description) { if(volume <= 0) { Print("Invalid volume: ", volume); return; } - + // 如果没有指定止损/止盈,按照千分之一计算 double price = (orderType == ORDER_TYPE_BUY) ? g_askPrice : g_bidPrice; if(sl <= 0) @@ -514,21 +665,21 @@ void ExecuteTrade(ENUM_ORDER_TYPE orderType, double volume, double sl, double tp else tp = price * (1.0 - 0.001); } - + // 标准化手数 double minVolume = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MIN); double maxVolume = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MAX); double stepVolume = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_STEP); - + volume = MathMax(minVolume, MathMin(volume, maxVolume)); volume = MathRound(volume / stepVolume) * stepVolume; - + // 执行订单 if(orderType == ORDER_TYPE_BUY) { - if(trade.Buy(volume, _Symbol, 0, sl, tp, "Python AI Trade")) + if(trade.Buy(volume, _Symbol, 0, sl, tp, description)) { - Print("Buy order executed: Volume=", volume, " SL=", sl, " TP=", tp); + Print("Buy order executed: Volume=", volume, " SL=", sl, " TP=", tp, " Description=", description); RecordTrade("BUY", _Symbol, volume, sl, tp, trade.ResultPrice()); } else @@ -538,9 +689,9 @@ void ExecuteTrade(ENUM_ORDER_TYPE orderType, double volume, double sl, double tp } else if(orderType == ORDER_TYPE_SELL) { - if(trade.Sell(volume, _Symbol, 0, sl, tp, "Python AI Trade")) + if(trade.Sell(volume, _Symbol, 0, sl, tp, description)) { - Print("Sell order executed: Volume=", volume, " SL=", sl, " TP=", tp); + Print("Sell order executed: Volume=", volume, " SL=", sl, " TP=", tp, " Description=", description); RecordTrade("SELL", _Symbol, volume, sl, tp, trade.ResultPrice()); } else @@ -584,16 +735,18 @@ void SendMinuteStatistics() statisticJson += "\"tickCount\":" + IntegerToString(g_tickCount) + ","; statisticJson += "\"bidPrice\":" + DoubleToString(g_bidPrice, _Digits) + ","; statisticJson += "\"askPrice\":" + DoubleToString(g_askPrice, _Digits) + ","; + statisticJson += "\"spread\":" + DoubleToString(g_spread, _Digits) + ","; + statisticJson += "\"spreadPoints\":" + DoubleToString(g_spreadPoints, 1) + ","; statisticJson += "\"balance\":" + DoubleToString(g_accountBalance, 2) + ","; statisticJson += "\"equity\":" + DoubleToString(g_accountEquity, 2) + ","; statisticJson += "\"marginLevel\":" + DoubleToString(g_marginLevel, 2) + ","; statisticJson += "\"positions\":" + GetPositionsSummary() + ","; statisticJson += "\"trades\":[" + g_tradesOfDay + "]"; statisticJson += "}"; - + // 发送到Python服务 SendToPythonServer(statisticJson); - + // 重置数据 g_tradesOfDay = ""; } @@ -669,14 +822,64 @@ void SendToPythonServer(string jsonData) //+------------------------------------------------------------------+ void OnTick() { +//--- 记录最后一次Tick时间戳 + g_lastTickTime = TimeCurrent(); + +//--- 每100毫秒请求一次Python服务 + uint currentTime = GetTickCount(); + if((currentTime - g_lastPythonRequestTime) >= g_pythonRequestInterval) + { + RequestTradesFromPython(); + g_lastPythonRequestTime = currentTime; + } + } + +//+------------------------------------------------------------------+ +//| Timer function - 定时任务处理 | +//+------------------------------------------------------------------+ +void OnTimer() + { +//--- 检查最后一次Tick时间,如果超过10秒无Tick则跳过(可能休市) + datetime now = TimeCurrent(); + if(g_lastTickTime == 0 || (now - g_lastTickTime) > 10) + { + // 无Tick超过10秒,跳过定时任务 + return; + } + //--- 更新统计数据 UpdateStatistics(); +//--- 检查并更新财经日历(每1秒调用,但内部会判断是否需要真正获取) + CheckAndUpdateCalendar(); + +//--- 检查即将发布的事件提醒 + CheckUpcomingEvents(); + +//--- 清理过期的财经事件(每小时清理一次) + static datetime lastCleanupTime = 0; + if(now - lastCleanupTime >= 3600) + { + CleanupExpiredCalendarEvents(); + lastCleanupTime = now; + } + +//--- 交易历史上报(每10分钟,20%概率上报) + if(g_lastTradeHistoryReportTime == 0 || (now - g_lastTradeHistoryReportTime) >= g_tradeHistoryReportInterval) + { + // 20%概率上报 (0-4, 共5个值,等于0时上报) + int randomReport = (int)(MathRand() % 5); + if(randomReport == 0) + { + ReportTradeHistory(); + } + g_lastTradeHistoryReportTime = now; + } + //--- 检查是否需要推送增量K线数据 CheckAndPushIncrementalKlines(); //--- 检查是否需要进行分钟级统计和发送 - datetime now = TimeCurrent(); if(now - g_lastStatisticTime >= 6) // 每6秒执行一次 { SendMinuteStatistics(); @@ -687,12 +890,11 @@ void OnTick() //--- 检查持仓风险并平仓 CheckAndCloseRiskyPositions(); -//--- 每100毫秒请求一次Python服务 - uint currentTime = GetTickCount(); - if((currentTime - g_lastPythonRequestTime) >= g_pythonRequestInterval) +//--- 持仓数据上报:生成0-10的随机数,等于5时上报 + int randomNum = (int)(MathRand() % 11); + if(randomNum == 5) { - RequestTradesFromPython(); - g_lastPythonRequestTime = currentTime; + SendPositionsToPython(true); // 上报所有品种持仓 } } //+------------------------------------------------------------------+ @@ -914,3 +1116,613 @@ string PeriodToString(ENUM_TIMEFRAMES period) default: return "M5"; } } + +//+------------------------------------------------------------------+ +//| 财经日历相关函数 | +//+------------------------------------------------------------------+ + +//+------------------------------------------------------------------+ +//| 检查并更新财经日历 | +//| 每1秒调用,但只在需要时才真正获取数据 | +//+------------------------------------------------------------------+ +void CheckAndUpdateCalendar() + { + datetime now = TimeCurrent(); + + // 检查是否需要刷新日历数据 + bool needRefresh = false; + bool refreshSingleEvent = false; + long eventToRefresh = 0; + + // 条件1:首次初始化 → 全量刷新 + if(!g_calendarInitialized) + { + needRefresh = true; + } + // 条件2:到了定期刷新时间(每5分钟) → 全量刷新 + else if(g_lastCalendarCheckTime == 0 || (now - g_lastCalendarCheckTime) >= g_calendarCheckInterval) + { + needRefresh = true; + } + // 条件3:检查是否有事件刚到发布时间(±3秒),且没有实际值 → 只刷新该事件 + else + { + for(int i = 0; i < g_calendarEventCount; i++) + { + // 只检查重要事件 + if(g_calendarEvents[i].importance < 2) continue; + + datetime publishTime = g_calendarEvents[i].publish_time; + int secondsDiff = (int)(now - publishTime); + + // 时间刚好到达发布时间(0-3秒内),且没有实际值 + if(secondsDiff >= 0 && secondsDiff <= 3 && g_calendarEvents[i].actual == "") + { + needRefresh = true; + refreshSingleEvent = true; + eventToRefresh = g_calendarEvents[i].event_id; + Print("[财经日历] 事件发布时间到达,刷新事件: ", g_calendarEvents[i].name); + break; + } + } + } + + if(!needRefresh) + { + return; // 无需刷新 + } + + // 需要更新日历数据 + if(refreshSingleEvent) + { + // 只刷新单个事件 + RefreshSingleEvent(eventToRefresh); + } + else + { + // 全量刷新 + RefreshAllCalendarEvents(); + } + } + +//+------------------------------------------------------------------+ +//| 刷新单个事件的实际值 | +//+------------------------------------------------------------------+ +void RefreshSingleEvent(long eventId) + { + // 找到该事件在数组中的位置 + int eventIndex = -1; + for(int i = 0; i < g_calendarEventCount; i++) + { + if(g_calendarEvents[i].event_id == eventId) + { + eventIndex = i; + break; + } + } + + if(eventIndex < 0) return; + + // 获取该事件的最新值 + MqlCalendarValue values[]; + datetime now = TimeCurrent(); + datetime startTime = now - 3600; // 过去1小时 + datetime endTime = now + 3600; // 未来1小时 + + if(CalendarValueHistoryByEvent(eventId, values, startTime, endTime) > 0) + { + if(ArraySize(values) > 0) + { + string actualValue = CalendarValueToString(values[0].actual_value, 0); + + // 只有当实际值有更新时才处理 + if(actualValue != "" && actualValue != g_calendarEvents[eventIndex].actual) + { + g_calendarEvents[eventIndex].actual = actualValue; + if(ArraySize(values) > 0 && values[0].forecast_value != DBL_MAX) + { + g_calendarEvents[eventIndex].forecast = CalendarValueToString(values[0].forecast_value, 0); + } + + Print("[财经日历] 事件实际值更新: ", g_calendarEvents[eventIndex].name, " = ", actualValue); + + // 发送单个事件更新到Python + SendSingleEventToPython(eventIndex); + + // 更新下一个重要事件时间 + CalculateNextEventTime(); + } + } + } + } + +//+------------------------------------------------------------------+ +//| 全量刷新财经日历 | +//+------------------------------------------------------------------+ +void RefreshAllCalendarEvents() + { + Print("[财经日历] 开始获取日历数据..."); + + // 获取过去6小时到未来48小时的事件 + // 注意:MT5日历API可能会返回这个范围内的所有数据 + datetime startTime = TimeCurrent() - 6 * 3600; // 过去6小时 + datetime endTime = startTime + 54 * 3600; // 到未来48小时 + + // 清空现有事件 + g_calendarEventCount = 0; + ArrayResize(g_calendarEvents, g_maxCalendarEvents); + + // 获取所有国家的事件(主要关注主要经济体) + // 注意:CalendarEventByCurrency 需要货币代码(USD, EUR等),不是国家代码 + string currencies[] = {"USD", "EUR", "GBP", "JPY", "CHF", "AUD", "CAD", "NZD"}; + + for(int i = 0; i < ArraySize(currencies); i++) + { + FetchCalendarEventsByCountry(currencies[i], startTime, endTime); + } + + // 按发布时间排序 + SortCalendarEvents(); + + // 计算下一个重要事件时间 + CalculateNextEventTime(); + + // 更新检查时间 + g_lastCalendarCheckTime = TimeCurrent(); + g_calendarInitialized = true; + + Print("[财经日历] 获取完成,共 ", g_calendarEventCount, " 条事件"); + + // 发送到Python服务端 + if(g_calendarEventCount > 0) + { + SendCalendarToPython(); + } + else + { + Print("[财经日历] 警告: 未获取到任何事件数据,请检查MT5日历设置"); + } + } + +//+------------------------------------------------------------------+ +//| 发送单个事件更新到Python | +//+------------------------------------------------------------------+ +void SendSingleEventToPython(int eventIndex) + { + if(eventIndex < 0 || eventIndex >= g_calendarEventCount) return; + + // 构建单个事件的JSON + string json = "{"; + json += "\"event_id\":\"" + IntegerToString(g_calendarEvents[eventIndex].event_id) + "\","; + json += "\"actual\":\"" + EscapeJsonString(g_calendarEvents[eventIndex].actual) + "\","; + json += "\"forecast\":\"" + EscapeJsonString(g_calendarEvents[eventIndex].forecast) + "\","; + json += "\"previous\":\"" + EscapeJsonString(g_calendarEvents[eventIndex].previous) + "\""; + json += "}"; + + // 发送到Python + string headers = "Content-Type: application/json\r\n"; + uchar postData[]; + uchar responseData[]; + string outheaders = ""; + int responseCode = 0; + + StringToCharArray(json, postData); + int nullIndex = ArraySize(postData) - 1; + if(nullIndex >= 0 && postData[nullIndex] == 0) + { + ArrayResize(postData, nullIndex); + } + + string url = g_pythonServer + "/calendar_event_result"; + responseCode = WebRequest("POST", url, headers, "", 10000, postData, ArraySize(postData), responseData, outheaders); + + if(responseCode == 200) + { + Print("[财经日历] 单事件更新发送成功: ", g_calendarEvents[eventIndex].name); + } + else + { + Print("[财经日历] 单事件更新发送失败,Response code: ", responseCode); + } + } + +//+------------------------------------------------------------------+ +//| 获取指定货币的财经事件 | +//+------------------------------------------------------------------+ +void FetchCalendarEventsByCountry(string currency, datetime startTime, datetime endTime) + { + // 获取该货币的所有事件 + MqlCalendarEvent events[]; + int count = CalendarEventByCurrency(currency, events); + + if(count <= 0) + { + Print("[财经日历] ", currency, ": 未获取到事件"); + return; + } + + Print("[财经日历] ", currency, ": 获取到 ", count, " 个事件"); + + // 用于跟踪变化的变量 + ulong changeTime = 0; + int addedCount = 0; + + // 遍历事件 + for(int i = 0; i < count && g_calendarEventCount < g_maxCalendarEvents; i++) + { + MqlCalendarEvent event = events[i]; + + // 获取事件值 + MqlCalendarValue values[]; + datetime eventTime = 0; + int eventImportance = 2; // 默认中等重要性 + + // 先尝试获取历史值(包含当前时间附近的值) + bool hasValues = CalendarValueHistoryByEvent(event.id, values, startTime, endTime) > 0; + + // 如果没有历史值,尝试获取最新值 + if(!hasValues) + { + changeTime = 0; + hasValues = CalendarValueLastByEvent(event.id, changeTime, values) > 0; + } + + if(hasValues && ArraySize(values) > 0) + { + // 从values获取时间 + eventTime = values[0].time; + + // 只保留指定时间范围内的事件 + if(eventTime < startTime || eventTime > endTime) continue; + + // 存储事件 + g_calendarEvents[g_calendarEventCount].event_id = (long)event.id; + g_calendarEvents[g_calendarEventCount].name = event.name; + g_calendarEvents[g_calendarEventCount].currency = currency; + g_calendarEvents[g_calendarEventCount].country = CharToString((uchar)event.country_id); + g_calendarEvents[g_calendarEventCount].importance = eventImportance; + g_calendarEvents[g_calendarEventCount].publish_time = eventTime; + g_calendarEvents[g_calendarEventCount].event_type = EventTypeToString((int)event.type); + + g_calendarEvents[g_calendarEventCount].forecast = CalendarValueToString(values[0].forecast_value, 0); + g_calendarEvents[g_calendarEventCount].previous = CalendarValueToString(values[0].prev_value, 0); + g_calendarEvents[g_calendarEventCount].actual = CalendarValueToString(values[0].actual_value, 0); + + g_calendarEventCount++; + addedCount++; + } + } + + Print("[财经日历] ", currency, ": 成功添加 ", addedCount, " 个事件"); + } + +//+------------------------------------------------------------------+ +//| 日历值转换为字符串 | +//+------------------------------------------------------------------+ +string CalendarValueToString(double value, ushort unit) + { + if(value == DBL_MAX || value == 0) return ""; + + string result = DoubleToString(value, 2); + + // 单位后缀已简化处理 + return result; + } + +//+------------------------------------------------------------------+ +//| 事件类型转换为字符串 | +//+------------------------------------------------------------------+ +string EventTypeToString(int type) + { + switch(type) + { + case 1: return "indicator"; // CALENDAR_EVENT_TYPE_INDICATOR + case 2: return "speech"; // CALENDAR_EVENT_TYPE_SPEECH + case 3: return "meeting"; // CALENDAR_EVENT_TYPE_MEETING + case 4: return "holiday"; // CALENDAR_EVENT_TYPE_HOLIDAY + default: return "other"; + } + } + +//+------------------------------------------------------------------+ +//| 按发布时间排序事件 | +//+------------------------------------------------------------------+ +void SortCalendarEvents() + { + // 简单的冒泡排序 + for(int i = 0; i < g_calendarEventCount - 1; i++) + { + for(int j = i + 1; j < g_calendarEventCount; j++) + { + if(g_calendarEvents[i].publish_time > g_calendarEvents[j].publish_time) + { + CalendarEventData temp = g_calendarEvents[i]; + g_calendarEvents[i] = g_calendarEvents[j]; + g_calendarEvents[j] = temp; + } + } + } + } + +//+------------------------------------------------------------------+ +//| 计算下一个重要事件时间 | +//+------------------------------------------------------------------+ +void CalculateNextEventTime() + { + datetime now = TimeCurrent(); + g_nextEventPublishTime = 0; + + for(int i = 0; i < g_calendarEventCount; i++) + { + // 只关注重要性 >= 2 的事件 + if(g_calendarEvents[i].importance >= 2 && g_calendarEvents[i].publish_time > now) + { + g_nextEventPublishTime = g_calendarEvents[i].publish_time; + Print("[财经日历] 下一个重要事件: ", g_calendarEvents[i].name, + " 时间: ", TimeToString(g_calendarEvents[i].publish_time, TIME_DATE | TIME_MINUTES)); + break; + } + } + } + +//+------------------------------------------------------------------+ +//| 发送财经日历到Python服务 | +//+------------------------------------------------------------------+ +void SendCalendarToPython() + { + if(g_calendarEventCount == 0) return; + + // 构建JSON + string json = "{\"events\":["; + + for(int i = 0; i < g_calendarEventCount; i++) + { + if(i > 0) json += ","; + + json += "{"; + json += "\"id\":\"" + IntegerToString(g_calendarEvents[i].event_id) + "\","; + json += "\"name\":\"" + EscapeJsonString(g_calendarEvents[i].name) + "\","; + json += "\"name_en\":\"" + EscapeJsonString(g_calendarEvents[i].name) + "\","; + json += "\"country\":\"" + EscapeJsonString(g_calendarEvents[i].country) + "\","; + json += "\"currency\":\"" + EscapeJsonString(g_calendarEvents[i].currency) + "\","; + json += "\"importance\":" + IntegerToString(g_calendarEvents[i].importance) + ","; + json += "\"publish_time\":\"" + TimeToString(g_calendarEvents[i].publish_time, TIME_DATE | TIME_MINUTES | TIME_SECONDS) + "\","; + json += "\"forecast\":\"" + EscapeJsonString(g_calendarEvents[i].forecast) + "\","; + json += "\"previous\":\"" + EscapeJsonString(g_calendarEvents[i].previous) + "\","; + json += "\"actual\":\"" + EscapeJsonString(g_calendarEvents[i].actual) + "\","; + json += "\"event_type\":\"" + EscapeJsonString(g_calendarEvents[i].event_type) + "\""; + json += "}"; + } + + json += "]}"; + + // 发送到Python + string headers = "Content-Type: application/json\r\n"; + uchar postData[]; + uchar responseData[]; + string outheaders = ""; + int responseCode = 0; + + StringToCharArray(json, postData); + int nullIndex = ArraySize(postData) - 1; + if(nullIndex >= 0 && postData[nullIndex] == 0) + { + ArrayResize(postData, nullIndex); + } + + string url = g_pythonServer + "/calendar"; + responseCode = WebRequest("POST", url, headers, "", 10000, postData, ArraySize(postData), responseData, outheaders); + + if(responseCode == 200) + { + Print("[财经日历] 发送成功,共 ", g_calendarEventCount, " 条事件"); + } + else + { + Print("[财经日历] 发送失败,Response code: ", responseCode); + } + } + +//+------------------------------------------------------------------+ +//| JSON字符串转义 | +//| 转义所有JSON无效的控制字符(0x00-0x1F) | +//+------------------------------------------------------------------+ +string EscapeJsonString(string str) + { + string result = ""; + for(int i = 0; i < StringLen(str); i++) + { + ushort ch = StringGetCharacter(str, i); + switch(ch) + { + case '"': result += "\\\""; break; + case '\\': result += "\\\\"; break; + case '\n': result += "\\n"; break; + case '\r': result += "\\r"; break; + case '\t': result += "\\t"; break; + // MQL5不支持\b和\f,使用Unicode转义 + case 0x08: result += "\\b"; break; // backspace + case 0x0C: result += "\\f"; break; // form feed + default: + // 转义所有其他控制字符 (0x00-0x1F) + if(ch < 32) + { + // 使用 \uXXXX 格式转义 + result += "\\u" + StringFormat("%04X", ch); + } + else + { + result += CharToString((uchar)ch); + } + } + } + return result; + } + +//+------------------------------------------------------------------+ +//| 检查是否有事件即将发布并提醒 | +//+------------------------------------------------------------------+ +void CheckUpcomingEvents() + { + if(!g_calendarInitialized || g_calendarEventCount == 0) return; + + datetime now = TimeCurrent(); + + for(int i = 0; i < g_calendarEventCount; i++) + { + // 只检查重要事件 + if(g_calendarEvents[i].importance < 2) continue; + + datetime publishTime = g_calendarEvents[i].publish_time; + int secondsToPublish = (int)(publishTime - now); + + // 事件将在5分钟内发布 + if(secondsToPublish > 0 && secondsToPublish <= 300) + { + string msg = StringFormat("[财经日历提醒] %s (%s) 将在 %d 分钟后发布", + g_calendarEvents[i].name, + g_calendarEvents[i].currency, + secondsToPublish / 60); + Print(msg); + + // TODO: 可以在这里添加推送通知到前端的逻辑 + } + } + } + +//+------------------------------------------------------------------+ +//| 清理过期的财经事件 | +//+------------------------------------------------------------------+ +void CleanupExpiredCalendarEvents() + { + datetime now = TimeCurrent(); + int writeIndex = 0; + + for(int readIndex = 0; readIndex < g_calendarEventCount; readIndex++) + { + // 保留未来事件和过去2小时内的事件 + if(g_calendarEvents[readIndex].publish_time > now - 2 * 3600) + { + if(writeIndex != readIndex) + { + g_calendarEvents[writeIndex] = g_calendarEvents[readIndex]; + } + writeIndex++; + } + } + + if(writeIndex != g_calendarEventCount) + { + Print("[财经日历] 清理过期事件: ", g_calendarEventCount - writeIndex, " 条"); + g_calendarEventCount = writeIndex; + } + } + +//+------------------------------------------------------------------+ +//| 获取并上报交易历史 | +//+------------------------------------------------------------------+ +void ReportTradeHistory() + { + datetime now = TimeCurrent(); + datetime from = now - 24 * 3600; // 最近24小时 + + // 选择交易历史 + if(!HistorySelect(from, now)) + { + Print("[交易历史] 获取交易历史失败"); + return; + } + + // 获取成交数量 + int deals_total = HistoryDealsTotal(); + if(deals_total == 0) + { + Print("[交易历史] 最近24小时无成交记录"); + return; + } + + Print("[交易历史] 最近24小时成交数: ", deals_total); + + // 构建JSON数据 + string json = "{\"deals\":["; + + int validDeals = 0; + for(int i = 0; i < deals_total; i++) + { + ulong deal_ticket = HistoryDealGetTicket(i); + if(deal_ticket == 0) continue; + + // 只处理实际成交记录(排除余额调整等) + long deal_entry = HistoryDealGetInteger(deal_ticket, DEAL_ENTRY); + if(deal_entry != DEAL_ENTRY_IN && deal_entry != DEAL_ENTRY_OUT && deal_entry != DEAL_ENTRY_OUT_BY) + continue; + + // 获取成交属性 + long deal_type = HistoryDealGetInteger(deal_ticket, DEAL_TYPE); + if(deal_type != DEAL_TYPE_BUY && deal_type != DEAL_TYPE_SELL) + continue; // 只处理买入和卖出 + + double deal_volume = HistoryDealGetDouble(deal_ticket, DEAL_VOLUME); + double deal_price = HistoryDealGetDouble(deal_ticket, DEAL_PRICE); + double deal_profit = HistoryDealGetDouble(deal_ticket, DEAL_PROFIT); + double deal_swap = HistoryDealGetDouble(deal_ticket, DEAL_SWAP); + double deal_commission = HistoryDealGetDouble(deal_ticket, DEAL_COMMISSION); + string deal_symbol = HistoryDealGetString(deal_ticket, DEAL_SYMBOL); + datetime deal_time = (datetime)HistoryDealGetInteger(deal_ticket, DEAL_TIME); + string deal_comment = HistoryDealGetString(deal_ticket, DEAL_COMMENT); + long deal_order = HistoryDealGetInteger(deal_ticket, DEAL_ORDER); + + if(validDeals > 0) json += ","; + + json += "{"; + json += "\"ticket\":" + IntegerToString(deal_ticket) + ","; + json += "\"order\":" + IntegerToString(deal_order) + ","; + json += "\"symbol\":\"" + deal_symbol + "\","; + json += "\"type\":" + IntegerToString(deal_type) + ","; + json += "\"entry\":" + IntegerToString(deal_entry) + ","; + json += "\"volume\":" + DoubleToString(deal_volume, 2) + ","; + json += "\"price\":" + DoubleToString(deal_price, 2) + ","; + json += "\"profit\":" + DoubleToString(deal_profit, 2) + ","; + json += "\"swap\":" + DoubleToString(deal_swap, 2) + ","; + json += "\"commission\":" + DoubleToString(deal_commission, 2) + ","; + json += "\"time\":\"" + TimeToString(deal_time, TIME_DATE | TIME_MINUTES | TIME_SECONDS) + "\","; + json += "\"comment\":\"" + EscapeJsonString(deal_comment) + "\""; + json += "}"; + + validDeals++; + } + + json += "]}"; + + if(validDeals == 0) + { + Print("[交易历史] 无有效成交记录"); + return; + } + + // 发送到Python服务端 + string headers = "Content-Type: application/json\r\n"; + uchar postData[]; + uchar responseData[]; + string outheaders = ""; + int responseCode = 0; + + StringToCharArray(json, postData); + int nullIndex = ArraySize(postData) - 1; + if(nullIndex >= 0 && postData[nullIndex] == 0) + { + ArrayResize(postData, nullIndex); + } + + string url = g_pythonServer + "/trade_history"; + responseCode = WebRequest("POST", url, headers, "", 15000, postData, ArraySize(postData), responseData, outheaders); + + if(responseCode == 200) + { + Print("[交易历史] 上报成功,共 ", validDeals, " 条成交记录"); + } + else + { + Print("[交易历史] 上报失败,Response code: ", responseCode); + } + }