- 趋势强度: {{ trendData.resonance.strength }}%
+
+
+ 上次分析: {{ llmStatus.last_analysis_time }}
+
+
+
+
+
mdi-robot-outline
+
+
+ {{ llmAnalysisStatus || '正在分析...' }}
+
+
+ {{ llmStatus.enabled ? '等待分析完成...' : '请先配置大模型API' }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ symbol }}
+
+ {{ data.analysis.overall_trend.direction }}
+
+
+
+ mdi-pause-circle
+ 休市中
+
+
+
+ mdi-alert
+ 数据未更新
+
-
-
-
+
+
+
+
+
+ mdi-pause-circle
+
+ 休市中,暂无行情数据。下次开市时将自动更新分析。
+
+
+
+
+
+
+ mdi-clock-alert
+
+ 行情数据已 {{ data.stale_seconds || '?' }} 秒未更新,当前显示上次分析结果。
+ ({{ data.analyzed_at }})
+
+
+
-
-
-
-
-
-
-
- {{ period }}
-
- {{ getTrendLabel(state.trend) }}
-
-
-
- 强度: {{ state.strength }}%
- ADX: {{ state.adx }}
-
-
- MA10: {{ state.ma_fast?.toFixed(2) }}
- MA20: {{ state.ma_slow?.toFixed(2) }}
-
-
- mdi-information-outline
- {{ state.reason || '分析中...' }}
-
-
-
-
-
-
-
-
- 暂无趋势分析数据
-
-
-
-
-
+
+
+
各周期趋势
+
+
+
+
+ | 周期 |
+ AI趋势 |
+ 置信度 |
+ AI说明 |
+ 技术趋势 |
+ 技术说明 |
+ 结论 |
+
+
+
+
+ | {{ period }} |
+
+
+ {{ trend.trend }}
+
+ |
+
+ {{ trend.confidence }}%
+ |
+
+ {{ trend.reason }}
+ |
+
+
+ {{ getTrendLabel(getTechTrend(symbol, period).trend) }}
+
+ -
+ |
+
+
+ {{ getTechTrend(symbol, period).reason }}
+
+ -
+ |
+
+
+ {{ getConclusion(symbol, period, trend) }}
+
+ |
+
+
+
+
+
-
-
-
-
-
- mdi-cog
- 自动交易配置
-
-
-
-
-
-
-
+
+
+
关键价位
+
+
+ 压力位
+
+ {{ level }}
+
+
+
+ 支撑位
+
+ {{ level }}
+
+
+
+
-
- 品种配置
-
-
-
-
- | 品种 |
- 手数 |
- 止损偏移(点) |
- 操作 |
-
-
-
-
- |
- {{ symbol }}
- |
-
-
- |
-
-
- |
-
- 保存
- |
-
-
-
-
+
+
+
交易建议
+
+
+
+
+ | 周期 |
+ 方向 |
+ 入场价 |
+ 止损 |
+ 止盈 |
+ 理由 |
+
+
+
+
+ | {{ suggestion.period }} |
+
+
+ {{ suggestion.direction === 'buy' ? '买入' : '卖出' }}
+
+ |
+ {{ suggestion.entry_price }} |
+ {{ suggestion.stop_loss }} |
+ {{ suggestion.take_profit }} |
+ {{ suggestion.reason }} |
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
- 添加
-
-
-
-
- mdi-information
- M1周期接近转折点时自动生成交易指令。止损偏移为固定点数,如GOLD设0.5表示止损在转折点±0.5点。
-
-
-
-
-
-
-
-
-
-
-
- mdi-chart-candlestick
- K线图表 - {{ selectedSymbol }} {{ selectedPeriod }}
-
-
-
-
mdi-chart-box-outline
-
暂无K线数据,请等待EA推送数据
-
-
-
-
-
-
-
-
-
-
-
-
- mdi-information
- 数据状态
-
-
-
-
-
-
- | 品种 |
- 周期 |
- K线数量 |
- 已初始化 |
- 转折点数量 |
-
-
-
-
-
- | {{ symbol }} |
- {{ period }} |
- {{ periodData.count }} |
-
-
- {{ periodData.initialized ? '是' : '否' }}
-
- |
-
- {{ (marketStatus.pivots && marketStatus.pivots[symbol] && marketStatus.pivots[symbol][period]) ? marketStatus.pivots[symbol][period].pivot_count : 0 }}
- |
-
-
-
-
-
-
- 暂无状态数据
+
+ 分析时间: {{ data.analyzed_at }}
+
+
+
+
@@ -476,36 +708,21 @@
+
+
\ 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 @@
+
+
+
+
+ 仓位管理
+
+
+
+
+
+
+
+ mdi-chart-box
+ 当前持仓
+
+
+ mdi-history
+ 历史交易
+
+
+
+
+
+
+
+
+
+
+ {{ summary.total_count }}
+ 总持仓数
+
+
+
+
+
+
+
+ {{ summary.total_profit >= 0 ? '+' : '' }}{{ summary.total_profit.toFixed(2) }}
+
+ 总盈亏
+
+
+
+
+
+
+ {{ summary.buy_count }}
+ 买单数量
+
+
+
+
+
+
+ {{ summary.sell_count }}
+ 卖单数量
+
+
+
+
+
+
+
+
+ mdi-refresh
+ 刷新
+
+
+
+
+
+
+ | 订单号 |
+ 品种 |
+ 方向 |
+ 手数 |
+ 开仓价 |
+ 当前盈亏 |
+ 止损距离 |
+ 止盈距离 |
+ 更新时间 |
+ 操作 |
+
+
+
+
+ | {{ pos.ticket }} |
+ {{ pos.symbol }} |
+
+
+ {{ pos.type === 'BUY' ? '买入' : '卖出' }}
+
+ |
+ {{ pos.volume }} |
+ {{ pos.price_open }} |
+
+ {{ pos.profit >= 0 ? '+' : '' }}{{ pos.profit.toFixed(2) }}
+ |
+ {{ pos.distance_sl || '-' }} |
+ {{ pos.distance_tp || '-' }} |
+ {{ formatTime(pos.updated_at) }} |
+
+
+ 平仓
+
+ |
+
+
+
+
+
+
mdi-folder-open-outline
+
暂无持仓
+
+
+
+
+
+
+
+
+ mdi-refresh
+ 刷新
+
+
+
+
+
+
+
+ {{ historyStats.total_count || 0 }}
+ 总成交数
+
+
+
+
+
+
+
+ {{ (historyStats.net_profit || 0) >= 0 ? '+' : '' }}{{ (historyStats.net_profit || 0).toFixed(2) }}
+
+ 净盈亏
+
+
+
+
+
+
+ {{ historyStats.manual_count || 0 }}
+ 手动
+
+
+
+
+
+
+ {{ historyStats.auto_count || 0 }}
+ 自动
+
+
+
+
+
+
+ {{ historyStats.sl_tp_count || 0 }}
+ 止损/止盈
+
+
+
+
+
+
+ {{ historyStats.so_count || 0 }}
+ 强制平仓
+
+
+
+
+
+
+ {{ (historyStats.total_commission || 0).toFixed(2) }}
+ 手续费
+
+
+
+
+
+
+ {{ (historyStats.total_swap || 0).toFixed(2) }}
+ 库存费
+
+
+
+
+
+
+
+
+ 品种分布
+
+ {{ symbol }}: {{ data.count }}单, 盈亏 {{ data.profit >= 0 ? '+' : '' }}{{ data.profit.toFixed(2) }}
+
+
+
+
+
+
+
+ 自动单分类
+
+
+
+ {{ item.profit >= 0 ? '+' : '' }}{{ item.profit.toFixed(2) }}
+
+
+
+
+
+ {{ item.percentage }}%
+
+
+
+
+
+
+
+
+ 成交记录
+
+
+
+
+ | 订单号 |
+ 品种 |
+ 方向 |
+ 类型 |
+ 手数 |
+ 价格 |
+ 盈亏 |
+ 手续费 |
+ 时间 |
+ 备注 |
+
+
+
+
+ | {{ deal.ticket }} |
+ {{ deal.symbol }} |
+
+
+ {{ deal.type_text }}
+
+ |
+
+
+ {{ deal.entry_text }}
+
+ |
+ {{ deal.volume }} |
+ {{ deal.price }} |
+
+ {{ deal.profit >= 0 ? '+' : '' }}{{ deal.profit.toFixed(2) }}
+ |
+ {{ deal.commission.toFixed(2) }} |
+ {{ deal.time }} |
+
+
+ {{ deal.comment }}
+
+
+ {{ deal.comment }}
+
+
+ {{ deal.comment }}
+
+
+ {{ deal.comment }}
+
+ {{ deal.order_source }}
+ |
+
+
+
+
+
+
mdi-history
+
暂无历史交易数据
+
+
+
+
+
+
+
+
+ 确认平仓
+
+
+
订单号: {{ selectedPosition.ticket }}
+
品种: {{ selectedPosition.symbol }}
+
手数: {{ selectedPosition.volume }}
+
盈亏: {{ selectedPosition.profit }}
+
+
+
+
+ 取消
+ 确认平仓
+
+
+
+
+
+
+ {{ snackbarMessage }}
+
+
+
+
+
\ 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 @@
+
+
+
+
+ 系统设置
+
+
+
+
+
+
+
+
+ mdi-cog
+ 自动交易配置
+
+
+
+
+
+
+
+
+
+ 品种配置
+
+
+
+
+ | 品种 |
+ 手数 |
+ 止损偏移(点) |
+ 关键点位 |
+ 阈值 |
+ 操作 |
+
+
+
+
+ |
+ {{ symbol }}
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+ 保存
+ 删除
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ mdi-plus
+ 添加
+
+
+
+
+
+ mdi-information
+ 支撑压力策略: M1周期接近转折点时自动生成交易指令,止损偏移为固定点数。
+ 关键点位策略: 价格接近关键点位时生成反向订单。例如下降趋势接近5000时生成买单。阈值表示触发距离(默认0.0008)。
+
+
+
+
+
+
+
+
+
+
+
+ mdi-brain
+ 大模型配置
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ mdi-content-save
+ 保存配置
+
+
+ {{ llmConfig.enabled ? '已启用' : '未启用' }}
+
+
+
+
+
+
+ mdi-information
+ 配置大模型用于生成AI趋势分析和交易建议。支持OpenAI兼容的API接口。
+
+
+
+
+
+
+
+
+
+
+
+ mdi-chart-line
+ 品种数据状态
+
+ mdi-refresh
+
+
+
+
+
+
+
+ | 品种 |
+ 数据状态 |
+ M1数量 |
+ 最新M1时间 |
+ 距上次更新 |
+ 市场状态 |
+
+
+
+
+ | {{ item.symbol }} |
+
+
+ {{ item.has_data ? '有数据' : '无数据' }}
+
+ |
+ {{ item.m1_count || 0 }} |
+ {{ item.latest_m1_time || '-' }} |
+
+ {{ item.seconds_ago }}秒前
+ -
+ |
+
+
+ {{ getMarketStatusText(item.market_status) }}
+
+ |
+
+
+
+
+
+
mdi-database-off
+
暂无已配置的品种
+
+
+
+ mdi-information
+ 显示交易配置中的品种K线数据状态。M1数据超过3分钟未更新视为休市。
+
+
+
+
+
+
+
+
+ {{ errorMessage }}
+
+
+
+
+ {{ successMessage }}
+
+
+
+
+
\ 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 @@
+
+
+
+
+ 系统运行日志
+
+
+
+
+
+
+
+ mdi-text-box-outline
+ 实时日志
+
+
+ mdi-refresh
+
+
+ mdi-delete
+ 清空
+
+
+
+
+
+
+
+
+
+
+
+
+
+ WebSocket: {{ wsConnected ? '已连接' : '未连接' }}
+
+
+
+
+
+
+
+
mdi-text-box-remove-outline
+
暂无日志
+
+
+
+ {{ formatTime(log.timestamp) }}
+
+ {{ log.event_name }}
+
+ [{{ log.symbol }}]
+ {{ log.message }}
+
+
+
+
+
+
+
+
+
+
+
+ 确认清空
+ 确定要清空所有日志吗?此操作不可撤销。
+
+
+ 取消
+ 确认清空
+
+
+
+
+
+
+
+
+
\ 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);
+ }
+ }