From 51b2f30748c5ba813dce8874f491dfcd0dab39e1 Mon Sep 17 00:00:00 2001 From: guaiwoluo2020 <175821555@qq.com> Date: Tue, 10 Mar 2026 17:38:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=E5=92=8C=E5=B8=82=E5=9C=BA=E5=88=86=E6=9E=90?= =?UTF-8?q?=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Vue 3 + Vuetify 前端界面 - 新增市场分析模块 (market/) - 更新主服务器和路由 - 更新 MT5 EA 文件 - 添加 .gitignore 排除临时文件 --- .gitignore | 20 + FRONTEND_INTEGRATION.md | 268 +++++ MT5_WEBREQUEST_CHECKLIST.md | 221 ++++ QUICKSTART.md | 139 ++- README.md | 70 +- api_market_examples.py | 251 +++++ frontend/README.md | 207 ++++ frontend/index.html | 15 + frontend/package-lock.json | 1628 ++++++++++++++++++++++++++++ frontend/package.json | 24 + frontend/src/App.vue | 63 ++ frontend/src/api/market.js | 149 +++ frontend/src/api/trading.js | 71 ++ frontend/src/main.js | 13 + frontend/src/plugins/vuetify.js | 34 + frontend/src/router/index.js | 41 + frontend/src/style.css | 11 + frontend/src/views/Dashboard.vue | 136 +++ frontend/src/views/Market.vue | 1141 +++++++++++++++++++ frontend/src/views/Statistics.vue | 248 +++++ frontend/src/views/Status.vue | 250 +++++ frontend/src/views/TradeOrders.vue | 308 ++++++ frontend/start_vue.sh | 33 + frontend/vite.config.js | 22 + main.py | 30 +- market/__init__.py | 12 + market/merger.py | 120 ++ market/monitor.py | 412 +++++++ market/pending_orders.py | 199 ++++ market/pivot_detector.py | 414 +++++++ market/store.py | 259 +++++ market/trend_analyzer.py | 389 +++++++ monitor_logs.sh | 30 + routes_ea.py | 126 ++- routes_market.py | 534 +++++++++ server.py | 155 ++- start.sh | 0 start_frontend.bat | 72 ++ start_frontend.sh | 67 ++ wangxxGold.mq5 | 545 +++++++++- 40 files changed, 8576 insertions(+), 151 deletions(-) create mode 100644 .gitignore create mode 100644 FRONTEND_INTEGRATION.md create mode 100644 MT5_WEBREQUEST_CHECKLIST.md create mode 100644 api_market_examples.py create mode 100644 frontend/README.md create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api/market.js create mode 100644 frontend/src/api/trading.js create mode 100644 frontend/src/main.js create mode 100644 frontend/src/plugins/vuetify.js create mode 100644 frontend/src/router/index.js create mode 100644 frontend/src/style.css create mode 100644 frontend/src/views/Dashboard.vue create mode 100644 frontend/src/views/Market.vue create mode 100644 frontend/src/views/Statistics.vue create mode 100644 frontend/src/views/Status.vue create mode 100644 frontend/src/views/TradeOrders.vue create mode 100755 frontend/start_vue.sh create mode 100644 frontend/vite.config.js create mode 100644 market/__init__.py create mode 100644 market/merger.py create mode 100644 market/monitor.py create mode 100644 market/pending_orders.py create mode 100644 market/pivot_detector.py create mode 100644 market/store.py create mode 100644 market/trend_analyzer.py create mode 100755 monitor_logs.sh create mode 100644 routes_market.py mode change 100644 => 100755 start.sh create mode 100644 start_frontend.bat create mode 100755 start_frontend.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6072279 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# macOS +.DS_Store + +# Python +venv/ +__pycache__/ +*.pyc +*.pyo + +# Logs +*.log + +# Backups +*.backup + +# IDE +.claude/ + +# Node.js +node_modules/ \ No newline at end of file diff --git a/FRONTEND_INTEGRATION.md b/FRONTEND_INTEGRATION.md new file mode 100644 index 0000000..bf8c8fc --- /dev/null +++ b/FRONTEND_INTEGRATION.md @@ -0,0 +1,268 @@ +# 前端界面集成指南 + +## 🎉 完成!前端界面已成功集成 + +你的量化交易服务现在同时拥有了**高性能后端**和**现代化前端界面**! + +## 📋 项目概览 + +### 后端服务 (Python FastAPI) +- **端口**: 8000 +- **状态**: ✅ 运行中 +- **功能**: 交易指令管理、价格过滤、统计数据收集 + +### 前端界面 (React) +- **端口**: 3000 +- **状态**: ✅ 运行中 +- **功能**: 仪表板、交易管理、数据可视化、服务监控 + +## 🚀 快速启动 + +### 完整系统启动 (推荐) + +**方式1: 脚本启动** +```bash +# 终端1: 启动后端 +python main.py + +# 终端2: 启动前端 +./start_frontend.sh +``` + +**方式2: 手动启动** +```bash +# 终端1: 启动后端 +python main.py + +# 终端2: 启动前端 +cd frontend && npm start +``` + +### 验证启动成功 + +```bash +# 检查后端 +curl http://localhost:8000/health +# 应该返回: {"status":"ok"} + +# 检查前端 +curl -I http://localhost:3000 +# 应该返回: HTTP/1.1 200 OK +``` + +## 🎯 主要功能 + +### 1. 仪表板 (Dashboard) +- 📊 实时服务状态监控 +- 📈 关键指标展示 (待执行指令、统计记录、活跃品种) +- 🔄 自动刷新数据 + +### 2. 交易指令管理 (Trade Orders) +- ➕ 发送新的交易指令 +- ✅ **智能价格验证**: 自动检查买入/卖出指令的价格合理性 +- 📋 查看所有待执行指令 +- 🗑️ 批量清空指令 + +### 3. 统计数据分析 (Statistics) +- 📊 历史数据表格展示 +- 📈 价格和账户趋势图表 +- 📋 汇总统计指标 + +### 4. 服务状态监控 (Status) +- ❤️ 健康检查状态 +- 📊 详细的服务指标 +- 🔄 实时自动刷新 (每5秒) + +## 🔧 技术架构 + +``` +┌─────────────────┐ HTTP/JSON ┌─────────────────┐ +│ React前端 │◄──────────────►│ FastAPI后端 │ +│ (localhost:3000)│ │ (localhost:8000) │ +│ │ │ │ +│ • Material-UI │ │ • 交易指令管理 │ +│ • Axios │ │ • 价格过滤逻辑 │ +│ • Recharts │ │ • 统计数据收集 │ +│ • 响应式设计 │ │ • RESTful API │ +└─────────────────┘ └─────────────────┘ + │ + ▼ + ┌─────────────────┐ + │ MT5 EA │ + │ (MetaTrader) │ + │ │ + │ • TICK处理 │ + │ • 订单执行 │ + │ • 风险管理 │ + └─────────────────┘ +``` + +## 📱 使用指南 + +### 访问界面 +打开浏览器访问: **http://localhost:3000** + +### 发送交易指令 +1. 点击顶部导航栏的 **"交易指令"** +2. 填写交易表单: + - **交易品种**: GOLD, EURUSD 等 + - **买卖方向**: 买入/卖出 + - **手数**: 交易量 + - **执行价格**: 指令价格 (用于价格过滤) + - **止损/止盈**: 可选 (自动填充默认值) +3. 点击 **"发送交易指令"** + +### 查看统计数据 +1. 点击 **"统计数据"** +2. 查看历史统计表格 +3. 查看价格趋势图表 +4. 调整显示条数 (最多10条) + +### 监控服务状态 +1. 点击 **"服务状态"** +2. 查看实时服务指标 +3. 页面会自动每5秒刷新 + +## 🔒 安全特性 + +### 价格验证规则 +- **买入指令**: 必须满足 `sl < price < tp` +- **卖出指令**: 必须满足 `tp < price < sl` +- **自动拒绝**: 不符合规则的指令会被服务器拒绝 + +### 数据验证 +- 后端使用 Pydantic 进行数据验证 +- 前端进行表单验证和错误处理 +- API 请求包含错误处理和重试机制 + +## 📊 API 接口 + +### 主要端点 +- `GET /health` - 健康检查 +- `GET /status` - 服务状态 +- `POST /send_trade_instructions` - 发送交易指令 +- `GET /query_pending_trades` - 查询待执行指令 +- `GET /query_statistics` - 查询统计数据 +- `DELETE /clear_trades` - 清空指令 + +### API 文档 +访问: **http://localhost:8000/docs** + +## 🛠️ 开发和部署 + +### 开发环境 +```bash +# 后端开发 +python main.py # 支持热重载 + +# 前端开发 +cd frontend && npm start # 支持热重载 +``` + +### 生产部署 +```bash +# 后端 +pip install -r requirements.txt +python main.py + +# 前端 +cd frontend +npm run build +# 将 build 目录部署到 Web 服务器 +``` + +### Docker 部署 +```bash +# 后端 +docker build -t trading-backend . +docker run -p 8000:8000 trading-backend + +# 前端 +cd frontend +npm run build +# 使用 nginx 或其他 Web 服务器部署 build 目录 +``` + +## 🔍 故障排除 + +### 常见问题 + +**Q: 前端无法连接后端** +```bash +# 检查后端是否运行 +curl http://localhost:8000/health + +# 检查前端代理配置 +# frontend/package.json 中 proxy 应为 "http://localhost:8000" +``` + +**Q: 页面显示空白** +```bash +# 检查浏览器控制台错误 +# 确认 Node.js 和 npm 版本 +node --version && npm --version +``` + +**Q: API 请求失败** +```bash +# 检查 CORS 配置 +# 后端已配置允许所有源 +``` + +**Q: MT5 EA 无法连接** +```bash +# 确保 EA 启用 WebRequest +# 在 MT5 中: 工具 → 选项 → EA交易 → 勾选 WebRequest +``` + +### 日志查看 +```bash +# 后端日志 (控制台输出) +python main.py + +# 前端日志 (浏览器开发者工具) +# F12 → Console 标签 +``` + +## 📈 性能优化 + +- **后端**: FastAPI + uvloop 高性能异步处理 +- **前端**: React 虚拟DOM + Material-UI 优化渲染 +- **网络**: HTTP/2 支持,压缩传输 +- **缓存**: 浏览器缓存静态资源 + +## 🎯 下一步扩展 + +### 短期计划 +- [ ] 添加用户认证和权限管理 +- [ ] 实现实时 WebSocket 推送 +- [ ] 添加更多图表类型和指标 + +### 长期计划 +- [ ] 移动端适配优化 +- [ ] 多语言支持 (i18n) +- [ ] 主题切换 (暗色模式) +- [ ] 高级数据分析功能 + +## 📞 支持 + +如果遇到问题,请检查: +1. 浏览器开发者工具的错误信息 +2. 终端的日志输出 +3. API 文档的接口说明 + +## 📝 更新日志 + +### v1.0.0 (2026-03-05) +- ✅ 完成前后端集成 +- ✅ 实现完整的交易指令管理 +- ✅ 添加价格验证规则 +- ✅ 集成数据可视化图表 +- ✅ 实现实时状态监控 + +--- + +**恭喜!你的量化交易系统现在拥有了完整的现代化界面!** + +🌟 **访问地址**: http://localhost:3000 +📚 **API 文档**: http://localhost:8000/docs \ No newline at end of file diff --git a/MT5_WEBREQUEST_CHECKLIST.md b/MT5_WEBREQUEST_CHECKLIST.md new file mode 100644 index 0000000..c90a5f9 --- /dev/null +++ b/MT5_WEBREQUEST_CHECKLIST.md @@ -0,0 +1,221 @@ +# MT5 WebRequest 配置检查清单 + +## ⚠️ 重要提示 + +EA无法发送HTTP请求的最常见原因是**WebRequest权限未正确配置**。请按照以下步骤逐一检查。 + +## ✅ 配置步骤 + +### 1. 启用WebRequest权限 + +在MT5终端中: + +1. 打开 **工具(Tools)** → **选项(Options)** (或按 `Ctrl+O`) +2. 切换到 **EA交易(Expert Advisors)** 标签 +3. 勾选以下选项: + - ✅ **允许自动交易(Allow Automated Trading)** + - ✅ **允许WebRequest用于脚本...(Allow WebRequest for scripts and EA)** + +### 2. 添加URL到允许列表 + +在同一页面: + +1. 找到 **WebRequest允许的URL列表** 区域 +2. 点击 **添加(Add)** 按钮 +3. 输入以下URL(根据你的后端配置): + +``` +http://localhost:8000 +``` + +或如果使用trading_server.py: + +``` +http://localhost:5858 +``` + +**注意**: +- 必须包含 `http://` 前缀 +- 不要在末尾加 `/` +- 如果同时使用两个端口,两个都要添加 + +### 3. 重启EA + +配置完成后: + +1. 在图表上右键点击EA → **移除(Remove)** +2. 重新从导航器拖拽EA到图表 +3. 确保EA显示为 ✅ **启用(Enabled)** 状态 + +## 🔍 验证配置 + +### 方法1: 检查MT5日志 + +1. 打开 **终端(Terminal)** → **日志(Journal)** 标签 +2. 查找以下消息: + - ✅ `Expert initialized successfully` + - ✅ `Statistics sent successfully` (每分钟发送) + - ❌ `WebRequest is disabled!` → WebRequest未启用 + - ❌ `Failed to send statistics` → URL未添加或服务未启动 + +### 方法2: 检查后端日志 + +启动后端服务后,应该看到: + +```bash +# 启动后端 +python main.py + +# 观察日志,应该看到EA的请求: +# GET /get_trades?symbol=GOLD&price=2035.50 +# POST /send_statistics +``` + +### 方法3: 使用测试工具 + +运行自动化测试验证服务: + +```bash +python test_trading_service.py +``` + +## 🐛 常见问题排查 + +### 问题1: WebRequest is disabled + +**症状**: +``` +WebRequest is disabled! Please enable WebRequest in MT5 Options -> Expert Advisors +``` + +**解决方案**: +- 按照上述步骤1启用WebRequest权限 +- 重启MT5 + +### 问题2: Connection refused + +**症状**: +``` +Failed to send statistics. Response code: 404 +或 +Failed to send statistics. Response code: 500 +``` + +**解决方案**: +1. 确认后端服务正在运行 + ```bash + # 检查服务是否启动 + lsof -i :8000 # 或 lsof -i :5858 + + # 如果没有运行,启动服务 + python main.py # 端口8000 + # 或 + python trading_server.py # 端口5858 + ``` + +2. 确认端口匹配 + - EA配置: `wangxxGold.mq5` 第22行 + - 后端配置: `main.py` 第67行 (8000) 或 `trading_server.py` 第494行 (5858) + +### 问题3: URL not in allowed list + +**症状**: +``` +Failed to send statistics. Response code: -1 +``` + +**解决方案**: +- 确保已添加 `http://localhost:8000` 到允许列表 +- 不要添加 `https://`(除非你的服务器配置了HTTPS) +- 重启EA + +### 问题4: Timeout + +**症状**: +``` +Failed to send statistics. Response code: 408 +``` + +**解决方案**: +- 检查后端服务响应时间 +- 检查网络连接 +- 尝试在浏览器访问 `http://localhost:8000/health` 测试连接 + +## 📋 快速检查清单 + +``` +□ MT5选项中启用"允许自动交易" +□ MT5选项中启用"允许WebRequest" +□ 添加 http://localhost:8000 到允许列表 +□ 后端服务已启动 (python main.py) +□ EA已重启并启用 +□ MT5日志显示"Expert initialized successfully" +□ 后端日志显示EA的HTTP请求 +``` + +## 🧪 测试流程 + +### 完整测试流程: + +```bash +# 终端1: 启动后端 +python main.py + +# 终端2: 测试后端 +curl http://localhost:8000/health +# 应该返回: {"status": "healthy", ...} + +# MT5终端: +# 1. 配置WebRequest权限 +# 2. 加载EA到图表 +# 3. 观察MT5日志和后端日志 + +# 终端3: 发送测试交易指令 +python trade_client.py +# 选择1: 发送买入订单 +# 检查EA是否执行交易 +``` + +## 💡 调试技巧 + +### 启用详细日志 + +在EA代码中,所有HTTP请求都有详细的日志输出。如果你看不到任何WebRequest相关的日志: + +1. **检查EA是否真的在运行** + - 图表右上角应该有EA图标和笑脸 😊 + - 如果是哭脸 😢,说明EA初始化失败 + +2. **检查OnTick是否被调用** + - 每次价格变动都会调用OnTick + - 应该看到统计数据变化 + +3. **手动触发HTTP请求** + ```bash + # 测试后端连接 + curl -X GET "http://localhost:8000/get_trades?symbol=GOLD&price=2035.50" + + # 测试统计接口 + curl -X POST "http://localhost:8000/send_statistics" \ + -H "Content-Type: application/json" \ + -d '{"tickCount":100,"bidPrice":2035.50}' + ``` + +## 📚 相关文档 + +- [MT5 WebRequest官方文档](https://www.mql5.com/en/docs/network/webrequest) +- [项目README](README.md) +- [快速开始指南](QUICKSTART.md) + +## ❓ 需要帮助? + +如果按照以上步骤仍无法解决问题: + +1. 检查MT5日志文件(通常在 `MQL5/Logs/` 目录) +2. 检查后端服务日志 +3. 使用 `curl` 测试后端接口 +4. 确认防火墙没有阻止连接 + +--- + +**最后更新**: 2026-03-05 \ No newline at end of file diff --git a/QUICKSTART.md b/QUICKSTART.md index f0dce33..22240f4 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -43,7 +43,36 @@ curl http://localhost:5858/status ### 方式 C: 访问API文档 打开浏览器访问: [http://localhost:5858/docs](http://localhost:5858/docs) -## 4. 使用交易工具 +## 4. 启动前端界面 (Vue) + +### 方式 A: 使用启动脚本 (推荐) +```bash +cd frontend +./start_vue.sh +``` + +### 方式 B: 手动启动 +```bash +cd frontend +npm install # 首次运行需要 +npm run dev +``` + +### 方式 C: 直接运行 +```bash +cd frontend +npx vite --host 0.0.0.0 --port 3001 +``` + +**前端访问地址**: http://localhost:3001 + +### 前端功能 +- 📊 **仪表板**: 实时服务状态和关键指标 +- 📋 **交易指令**: 发送和管理交易指令 (带智能价格验证) +- 📈 **统计数据**: 数据可视化和历史分析 +- ❤️ **服务状态**: 实时监控和健康检查 + +## 5. 使用交易工具 ### 方式 A: 交互式命令行工具 ```bash @@ -118,7 +147,7 @@ curl "http://localhost:5858/query_pending_trades" curl "http://localhost:5858/query_statistics?count=5" ``` -## 5. MT5 EA集成 +## 6. MT5 EA集成 MT5 EA已经配置好,只需确保: @@ -137,7 +166,7 @@ MT5 EA已经配置好,只需确保: EA每分钟→ 统计数据 → Python服务 → 交易员查询统计 ``` -## 6. 常见操作 +## 7. 常见操作 ### 发送一个黄金买入单 @@ -289,14 +318,99 @@ while True: time.sleep(1) ``` -## 10. 下一步 +## 11. Vue 前端详细说明 + +### 🎯 技术栈 +- **Vue 3** + Composition API +- **Vuetify 3** (Material Design) +- **Vue Router 4** +- **Axios** (HTTP 客户端) +- **ECharts** (数据可视化) +- **Vite** (构建工具) + +### 🌟 前端特性 +- ✅ **现代化UI**: Material Design 设计规范 +- ✅ **响应式布局**: 支持桌面和移动设备 +- ✅ **实时更新**: 自动刷新数据和状态 +- ✅ **智能验证**: 交易指令价格自动验证 +- ✅ **数据可视化**: ECharts 图表展示 +- ✅ **中文界面**: 完全本地化 + +### 🎨 界面功能 + +#### 仪表板 (Dashboard) +- 📊 服务状态指示器 +- 📈 关键指标卡片 (待执行指令、统计记录、活跃品种) +- 🔄 自动数据刷新 (30秒间隔) + +#### 交易指令 (Trade Orders) +- ➕ 交易指令表单 (品种、方向、手数、价格、止损/止盈) +- ✅ **智能价格验证**: + - 买入: `止损 < 执行价格 < 止盈` + - 卖出: `止盈 < 执行价格 < 止损` +- 📋 待执行指令表格 +- 🗑️ 一键清空所有指令 + +#### 统计数据 (Statistics) +- 📊 历史数据表格 (时间、品种、价格、类型) +- 📈 价格趋势线图 (ECharts) +- 📋 汇总统计 (总记录数、平均价格、最高/最低价) + +#### 服务状态 (Status) +- ❤️ 健康检查状态 +- 📊 系统指标 (运行时间、内存使用等) +- 🔄 连接状态监控 (后端、MT5) +- ⏰ 实时更新 (5秒间隔) + +### 🚀 快速体验 + +```bash +# 启动完整系统 +# 终端1: 启动后端 +python main.py + +# 终端2: 启动前端 +cd frontend && ./start_vue.sh + +# 访问前端: http://localhost:3001 +``` + +### 🔧 开发和部署 + +#### 开发环境 +```bash +cd frontend +npm install +npm run dev # 开发服务器 +``` + +#### 生产构建 +```bash +cd frontend +npm run build # 构建生产版本 +npm run preview # 预览构建结果 +``` + +#### 项目结构 +``` +frontend/ +├── src/ +│ ├── views/ # 页面组件 +│ ├── api/ # API 接口 +│ ├── router/ # 路由配置 +│ └── plugins/ # Vuetify 配置 +├── public/ # 静态资源 +└── README.md # 详细文档 +``` + +## 12. 下一步 - 详细API文档: [README.md](README.md) - 自动化测试: `python test_trading_service.py` - API交互式文档: http://localhost:5858/docs - 性能监控: `curl http://localhost:5858/status` -## 11. 后续优化 +## 13. 后续优化 虽然当前HTTP性能足够,但可考虑: @@ -315,6 +429,17 @@ while True: ## 需要帮助? -查看完整文档: [README.md](README.md) +查看完整文档: +- [主项目文档](README.md) +- [Vue 前端文档](frontend/README.md) +- API交互式文档: http://localhost:8000/docs +- 前端界面: http://localhost:3001 -祝您交易愉快!🚀 +## 🎉 祝您使用愉快! + +现在您拥有了完整的量化交易系统: +- ⚡ 高性能 Python 后端 +- 🎨 现代化 Vue 前端界面 +- 🤖 智能 MT5 EA 集成 + +**开始您的量化交易之旅吧!** 🚀 diff --git a/README.md b/README.md index 5babb86..92f8573 100644 --- a/README.md +++ b/README.md @@ -5,19 +5,67 @@ 这是一个为MT5 EA提供支持的高性能交易服务,采用FastAPI框架,支持以下功能: ### 核心功能 -1. **EA接口** - MT5 EA与服务通信 - - `GET /get_trades` - EA获取待执行的交易指令(按SYMBOL分类) - - `POST /send_statistics` - EA发送每分钟的统计数据 -2. **交易员接口** - 交易员下发指令和查询数据 - - `POST /send_trade_instructions` - 下发交易指令 - - `GET /query_pending_trades` - 查询所有待执行指令 - - `DELETE /clear_trades` - 清空交易指令 - - `GET /query_statistics` - 查询统计数据(保留最新10条) +#### 1. EA接口 - MT5 EA与服务通信 +- `GET /get_trades` - EA获取待执行的交易指令(按SYMBOL分类) +- `POST /send_statistics` - EA发送每分钟的统计数据 +- `POST /ea/kline/{period}` - EA推送K线数据 (H4/H1/M15/M5/M1) +- `POST /ea/kline_batch` - EA批量推送多个周期的K线数据 -3. **系统接口** - 服务监控和健康检查 - - `GET /health` - 健康检查 - - `GET /status` - 服务状态 +#### 2. 行情分析接口 +- `GET /market/kline/{symbol}` - 查询K线数据 +- `GET /market/pivots/{symbol}` - 查询转折点数据 +- `GET /market/status` - 获取行情存储状态 +- `GET /market/thresholds` - 获取各周期接近阈值 +- `WebSocket /ws/market` - 实时转折点提醒推送 + +#### 3. 交易员接口 - 交易员下发指令和查询数据 +- `POST /send_trade_instructions` - 下发交易指令 +- `GET /query_pending_trades` - 查询所有待执行指令 +- `DELETE /clear_trades` - 清空交易指令 +- `GET /query_statistics` - 查询统计数据(保留最新10条) + +#### 4. 系统接口 - 服务监控和健康检查 +- `GET /health` - 健康检查 +- `GET /status` - 服务状态 + +## 新增功能:转折点检测与提醒 + +### K线数据接收 + +EA启动后会推送各周期K线数据: + +| 周期 | 历史数据要求 | +|------|-------------| +| H4 (4小时) | 最近6个月 | +| H1 (1小时) | 最近1个月 | +| M15 (15分钟) | 最近3天 | +| M5 (5分钟) | 最近24小时 | +| M1 (1分钟) | 最近1小时 | + +### 转折点检测 + +服务自动检测K线的转折点(高点/低点): +- 使用分型识别算法(顶分型/底分型) +- 默认左右各3根K线确认转折 + +### 接近阈值 + +各周期距离转折点的提醒阈值: + +| 周期 | 阈值 | 说明 | +|------|------|------| +| H4 | 千分之6 | 如GOLD高点5000,当前4980提醒 | +| H1 | 千分之3 | 如GOLD高点5000,当前4985提醒 | +| M15 | 千分之1.5 | - | +| M5 | 千分之0.5 | - | +| M1 | 千分之0.2 | - | + +### 实时提醒 + +EA调用 `/get_trades` 时携带价格参数,服务自动检查是否接近转折点,返回提醒信息。 + +同时支持WebSocket推送实时提醒到前端页面。 ## 安装和运行 diff --git a/api_market_examples.py b/api_market_examples.py new file mode 100644 index 0000000..3643131 --- /dev/null +++ b/api_market_examples.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +行情API调用示例 + +演示EA端如何推送K线数据到Python服务 +""" + +import requests +import json +from datetime import datetime, timedelta +import random + +# 服务地址 +BASE_URL = "http://localhost:8000" + + +def generate_mock_klines(count: int, base_price: float, period_minutes: int) -> list: + """ + 生成模拟K线数据 + """ + klines = [] + now = datetime.now() + + for i in range(count): + # 计算时间戳 + ts = now - timedelta(minutes=period_minutes * (count - i - 1)) + + # 生成随机价格波动 + change = random.uniform(-0.01, 0.01) * base_price + open_price = base_price + change + high = open_price + random.uniform(0, 0.005) * base_price + low = open_price - random.uniform(0, 0.005) * base_price + close = open_price + random.uniform(-0.003, 0.003) * base_price + + klines.append({ + "timestamp": ts.strftime("%Y-%m-%d %H:%M:%S"), + "open": round(open_price, 2), + "high": round(high, 2), + "low": round(low, 2), + "close": round(close, 2), + "volume": random.randint(100, 1000) + }) + + # 更新基准价格 + base_price = close + + return klines + + +def push_kline_single_period(symbol: str, period: str, klines: list, is_full: bool = False): + """ + 推送单个周期的K线数据 + """ + url = f"{BASE_URL}/ea/kline/{period}" + + payload = { + "symbol": symbol, + "is_full": is_full, + "klines": klines + } + + print(f"\n推送 {symbol} {period} K线数据 ({len(klines)} 条, {'全量' if is_full else '增量'})") + + try: + response = requests.post(url, json=payload) + print(f"状态码: {response.status_code}") + print(f"响应: {response.json()}") + + # 检查是否需要全量数据 + if response.status_code == 400: + data = response.json() + if data.get('code') == 8888: + print(">>> 服务需要全量数据,请重新发送历史数据") + return 8888 + + return response.status_code + + except Exception as e: + print(f"请求失败: {e}") + return None + + +def push_kline_batch(symbol: str, kline_data: dict, is_full: bool = False): + """ + 批量推送多个周期的K线数据 + """ + url = f"{BASE_URL}/ea/kline_batch" + + payload = { + "symbol": symbol, + "is_full": is_full, + "data": kline_data + } + + print(f"\n批量推送 {symbol} K线数据 ({'全量' if is_full else '增量'})") + + try: + response = requests.post(url, json=payload) + print(f"状态码: {response.status_code}") + print(f"响应: {json.dumps(response.json(), indent=2, ensure_ascii=False)}") + return response.status_code + + except Exception as e: + print(f"请求失败: {e}") + return None + + +def get_trades_with_price(symbol: str, price: float): + """ + 获取交易指令(携带价格,用于转折点检测) + """ + url = f"{BASE_URL}/get_trades" + params = { + "symbol": symbol, + "price": price + } + + try: + response = requests.get(url, params=params) + data = response.json() + + print(f"\n获取 {symbol} 交易指令 (价格: {price})") + print(f"交易指令: {data.get('trades', [])}") + + # 检查转折点提醒 + alerts = data.get('pivot_alerts', []) + if alerts: + print(f"\n⚠️ 转折点提醒 ({len(alerts)} 条):") + for alert in alerts: + print(f" - {alert['period']} {alert['direction'] == 'high' and '高点' or '低点'}") + print(f" 转折点价格: {alert['pivot_price']}") + print(f" 当前价格: {alert['current_price']}") + print(f" 距离: {alert['distance_pct']}%") + else: + print("无转折点提醒") + + return data + + except Exception as e: + print(f"请求失败: {e}") + return None + + +def get_klines(symbol: str, period: str, count: int = 100): + """查询K线数据""" + url = f"{BASE_URL}/market/kline/{symbol}" + params = {"period": period, "count": count} + + try: + response = requests.get(url, params=params) + data = response.json() + print(f"\n{symbol} {period} K线数据 ({data['count']} 条):") + for k in data['data'][-3:]: + print(f" {k['timestamp']}: O={k['open']} H={k['high']} L={k['low']} C={k['close']}") + return data + + except Exception as e: + print(f"请求失败: {e}") + return None + + +def get_pivots(symbol: str): + """查询转折点""" + url = f"{BASE_URL}/market/pivots/{symbol}" + + try: + response = requests.get(url) + data = response.json() + print(f"\n{symbol} 转折点数据:") + for period, pivots in data.get('data', {}).items(): + if pivots: + print(f" {period}: {len(pivots)} 个转折点") + for p in pivots[:3]: + print(f" - {p['direction'] == 'high' and '高点' or '低点'} @ {p['price']} ({p['timestamp']})") + return data + + except Exception as e: + print(f"请求失败: {e}") + return None + + +def main(): + """ + 演示完整的EA启动流程 + """ + print("=" * 60) + print("EA 行情数据推送示例") + print("=" * 60) + + symbol = "GOLD" + base_price = 2030.0 + + # ==================== 1. EA启动时推送全量K线数据 ==================== + print("\n[步骤1] EA启动,推送全量K线数据...") + + # 各周期历史数据条数(根据实际需求) + history_counts = { + 'H4': 1100, # 6个月约1100根4小时K线 + 'H1': 720, # 1个月约720根1小时K线 + 'M15': 288, # 3天约288根15分钟K线 + 'M5': 288, # 24小时288根5分钟K线 + 'M1': 60 # 1小时60根1分钟K线 + } + + kline_data = {} + period_minutes = {'H4': 240, 'H1': 60, 'M15': 15, 'M5': 5, 'M1': 1} + + for period, count in history_counts.items(): + kline_data[period] = generate_mock_klines(count, base_price, period_minutes[period]) + + push_kline_batch(symbol, kline_data, is_full=True) + + # ==================== 2. 查询K线和转折点 ==================== + print("\n[步骤2] 查询K线数据和转折点...") + + get_klines(symbol, 'H4', 10) + get_pivots(symbol) + + # ==================== 3. 模拟EA轮询获取交易指令 ==================== + print("\n[步骤3] EA轮询获取交易指令(携带当前价格)...") + + # 模拟当前价格 + current_price = base_price + random.uniform(-5, 5) + get_trades_with_price(symbol, current_price) + + # ==================== 4. 模拟增量数据推送 ==================== + print("\n[步骤4] 推送增量K线数据...") + + # 生成一根新的K线 + for period in ['H4', 'H1', 'M15', 'M5', 'M1']: + new_klines = generate_mock_klines(1, base_price, period_minutes[period]) + push_kline_single_period(symbol, period, new_klines, is_full=False) + + # ==================== 5. 查看服务状态 ==================== + print("\n[步骤5] 查看服务状态...") + + try: + response = requests.get(f"{BASE_URL}/market/status") + data = response.json() + print(json.dumps(data, indent=2, ensure_ascii=False)) + except Exception as e: + print(f"请求失败: {e}") + + print("\n" + "=" * 60) + print("示例完成") + print("=" * 60) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..1b82882 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,207 @@ +# 量化交易系统 - Vue 前端 + +基于 Vue 3 + Vuetify 的现代化量化交易前端界面 + +## 🚀 技术栈 + +- **Vue 3** - 渐进式 JavaScript 框架 +- **Vuetify 3** - Material Design 组件库 +- **Vue Router 4** - 官方路由管理器 +- **Axios** - HTTP 客户端 +- **ECharts** - 数据可视化图表 +- **Vite** - 快速构建工具 + +## 📦 安装依赖 + +```bash +cd frontend +npm install +``` + +## 🏃‍♂️ 开发运行 + +```bash +# 启动开发服务器 +npm run dev + +# 或直接使用 npx +npx vite --host 0.0.0.0 --port 3000 +``` + +## 🏗️ 生产构建 + +```bash +# 构建生产版本 +npm run build + +# 预览构建结果 +npm run preview +``` + +## 🌐 访问地址 + +- **开发环境**: http://localhost:3001 +- **生产环境**: 根据部署配置 + +## 📱 功能特性 + +### 1. 仪表板 (Dashboard) +- 📊 实时服务状态监控 +- 📈 关键指标展示 (待执行指令、统计记录、活跃品种) +- 🔄 自动刷新数据 + +### 2. 交易指令管理 (Trade Orders) +- ➕ 发送新的交易指令 +- ✅ **智能价格验证**: 自动检查买入/卖出指令的价格合理性 +- 📋 查看所有待执行指令 +- 🗑️ 批量清空指令 + +### 3. 统计数据分析 (Statistics) +- 📊 历史数据表格展示 +- 📈 价格趋势图表 +- 📋 汇总统计指标 + +### 4. 服务状态监控 (Status) +- ❤️ 健康检查状态 +- 📊 详细的服务指标 +- 🔄 实时自动刷新 (每5秒) + +## 🔧 项目结构 + +``` +frontend/ +├── public/ # 静态资源 +├── src/ +│ ├── api/ # API 接口 +│ │ └── trading.js # 交易 API +│ ├── components/ # 组件 (预留) +│ ├── plugins/ # 插件配置 +│ │ └── vuetify.js # Vuetify 配置 +│ ├── router/ # 路由配置 +│ │ └── index.js # 路由定义 +│ ├── views/ # 页面视图 +│ │ ├── Dashboard.vue # 仪表板 +│ │ ├── TradeOrders.vue # 交易指令 +│ │ ├── Statistics.vue # 统计数据 +│ │ └── Status.vue # 服务状态 +│ ├── App.vue # 根组件 +│ ├── main.js # 入口文件 +│ └── style.css # 全局样式 +├── package.json # 项目配置 +├── vite.config.js # Vite 配置 +└── index.html # HTML 模板 +``` + +## 🔗 API 集成 + +前端通过代理自动连接到后端 API: + +- **后端地址**: http://localhost:8000 +- **代理路径**: `/api/*` → `http://localhost:8000/*` + +## 🎨 UI 设计 + +- **Material Design**: 使用 Google Material Design 规范 +- **响应式布局**: 支持桌面和移动设备 +- **深色主题**: 支持亮色/暗色主题切换 +- **中文界面**: 完全本地化的中文界面 + +## 🔒 安全特性 + +### 价格验证规则 +- **买入指令**: 必须满足 `sl < price < tp` +- **卖出指令**: 必须满足 `tp < price < sl` +- **自动拒绝**: 不符合规则的指令会被服务器拒绝 + +## 📊 数据可视化 + +- **ECharts 图表**: 价格趋势线图 +- **实时更新**: 图表数据自动刷新 +- **交互式**: 支持缩放、拖拽等交互 + +## 🚀 性能优化 + +- **Vite 构建**: 快速的冷启动和热重载 +- **代码分割**: 自动路由级代码分割 +- **懒加载**: 组件按需加载 +- **缓存优化**: 浏览器缓存策略 + +## 🛠️ 开发工具 + +- **ESLint**: 代码规范检查 +- **Vue DevTools**: Vue 开发调试工具 +- **热重载**: 修改代码即时预览 + +## 📝 开发指南 + +### 添加新页面 +1. 在 `src/views/` 创建 Vue 组件 +2. 在 `src/router/index.js` 添加路由配置 +3. 在 `src/App.vue` 的菜单中添加导航项 + +### API 调用 +```javascript +import { tradingAPI } from '@/api/trading' + +// 发送交易指令 +await tradingAPI.sendTradeInstructions(instructions) + +// 查询统计数据 +const stats = await tradingAPI.getStatistics() +``` + +### 组件开发 +```vue + + + +``` + +## 🔄 与 React 版本对比 + +| 特性 | Vue 版本 | React 版本 | +|------|----------|------------| +| 框架 | Vue 3 + Composition API | React 18 + Hooks | +| UI库 | Vuetify 3 | Material-UI 5 | +| 路由 | Vue Router 4 | React Router 6 | +| 图表 | ECharts | Recharts | +| 构建 | Vite | Create React App | +| 学习曲线 | 较平缓 | 较陡峭 | +| 性能 | 优秀 | 优秀 | +| 生态 | 成熟 | 庞大 | + +## 🎯 优势特点 + +1. **Vue 生态**: 你熟悉的 Vue 框架和语法 +2. **Vuetify**: 功能完整的 Material Design 组件库 +3. **TypeScript 支持**: 可选的 TypeScript 支持 +4. **开发体验**: 优秀的开发工具和热重载 +5. **性能优化**: Vue 3 的优秀性能表现 + +## 📞 技术支持 + +如果遇到问题,请检查: +1. 浏览器开发者工具的错误信息 +2. 终端的日志输出 +3. 确保后端服务正在运行 (http://localhost:8000) + +--- + +**🌟 享受 Vue 开发的乐趣!** \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..fca5f89 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + + 量化交易系统 + + + + +
+ + + \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..b857764 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1628 @@ +{ + "name": "lianghua-trading-frontend-vue", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "lianghua-trading-frontend-vue", + "version": "1.0.0", + "dependencies": { + "@mdi/font": "^7.4.0", + "axios": "^1.6.0", + "echarts": "^5.4.0", + "vue": "^3.4.0", + "vue-echarts": "^6.6.0", + "vue-router": "^4.2.0", + "vuetify": "^3.5.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "vite": "^5.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@mdi/font": { + "version": "7.4.47", + "resolved": "https://registry.npmjs.org/@mdi/font/-/font-7.4.47.tgz", + "integrity": "sha512-43MtGpd585SNzHZPcYowu/84Vz2a2g31TvPMTm9uTiCSWzaheQySUcSyUH/46fPnuPQWof2yd0pGBtzee/IQWw==", + "license": "Apache-2.0" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.29.tgz", + "integrity": "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/shared": "3.5.29", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.29.tgz", + "integrity": "sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.29", + "@vue/shared": "3.5.29" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz", + "integrity": "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/compiler-core": "3.5.29", + "@vue/compiler-dom": "3.5.29", + "@vue/compiler-ssr": "3.5.29", + "@vue/shared": "3.5.29", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.29.tgz", + "integrity": "sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.29", + "@vue/shared": "3.5.29" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.29.tgz", + "integrity": "sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.29" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.29.tgz", + "integrity": "sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.29", + "@vue/shared": "3.5.29" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.29.tgz", + "integrity": "sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.29", + "@vue/runtime-core": "3.5.29", + "@vue/shared": "3.5.29", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.29.tgz", + "integrity": "sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.29", + "@vue/shared": "3.5.29" + }, + "peerDependencies": { + "vue": "3.5.29" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.29.tgz", + "integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/resize-detector": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/resize-detector/-/resize-detector-0.3.0.tgz", + "integrity": "sha512-R/tCuvuOHQ8o2boRP6vgx8hXCCy87H1eY9V5imBYeVNyNVpuL9ciReSccLj2gDcax9+2weXy3bc8Vv+NRXeEvQ==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz", + "integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.29", + "@vue/compiler-sfc": "3.5.29", + "@vue/runtime-dom": "3.5.29", + "@vue/server-renderer": "3.5.29", + "@vue/shared": "3.5.29" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-echarts": { + "version": "6.7.3", + "resolved": "https://registry.npmjs.org/vue-echarts/-/vue-echarts-6.7.3.tgz", + "integrity": "sha512-vXLKpALFjbPphW9IfQPOVfb1KjGZ/f8qa/FZHi9lZIWzAnQC1DgnmEK3pJgEkyo6EP7UnX6Bv/V3Ke7p+qCNXA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "resize-detector": "^0.3.0", + "vue-demi": "^0.13.11" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.5", + "@vue/runtime-core": "^3.0.0", + "echarts": "^5.4.1", + "vue": "^2.6.12 || ^3.1.1" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + }, + "@vue/runtime-core": { + "optional": true + } + } + }, + "node_modules/vue-echarts/node_modules/vue-demi": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.11.tgz", + "integrity": "sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vuetify": { + "version": "3.12.2", + "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.12.2.tgz", + "integrity": "sha512-cVQa4+5iQpDs00ToMUnWRHlMdv1d5tEH2wcZIthqSCmBipQAG4rQKE55zFwZFYlPyiDhUVY1RcAFtXCuHNcCww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/johnleider" + }, + "peerDependencies": { + "typescript": ">=4.7", + "vite-plugin-vuetify": ">=2.1.0", + "vue": "^3.5.0", + "webpack-plugin-vuetify": ">=3.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vite-plugin-vuetify": { + "optional": true + }, + "webpack-plugin-vuetify": { + "optional": true + } + } + }, + "node_modules/zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..9f1f952 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "lianghua-trading-frontend-vue", + "version": "1.0.0", + "description": "Vue 3 前端界面 for 高性能交易服务", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.4.0", + "vue-router": "^4.2.0", + "axios": "^1.6.0", + "vuetify": "^3.5.0", + "echarts": "^5.4.0", + "vue-echarts": "^6.6.0", + "@mdi/font": "^7.4.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "vite": "^5.0.0" + } +} \ No newline at end of file diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..ea5f7e8 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,63 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/api/market.js b/frontend/src/api/market.js new file mode 100644 index 0000000..862e993 --- /dev/null +++ b/frontend/src/api/market.js @@ -0,0 +1,149 @@ +import axios from 'axios' + +const api = axios.create({ + baseURL: 'http://localhost:8000', + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + }, +}) + +export const marketAPI = { + // 获取所有symbol列表 + async getSymbols() { + const response = await api.get('/market/symbols') + return response.data + }, + + // 获取K线数据 + async getKlines(symbol, period = 'M5', count = 100) { + const encodedSymbol = encodeURIComponent(symbol) + const response = await api.get(`/market/kline/${encodedSymbol}`, { + params: { period, count } + }) + return response.data + }, + + // 获取转折点数据 + async getPivots(symbol, period = null, direction = null, count = 50) { + const params = { count } + if (period) params.period = period + if (direction) params.direction = direction + const encodedSymbol = encodeURIComponent(symbol) + const response = await api.get(`/market/pivots/${encodedSymbol}`, { params }) + return response.data + }, + + // 获取行情状态 + async getStatus() { + const response = await api.get('/market/status') + return response.data + }, + + // 获取阈值配置 + async getThresholds() { + const response = await api.get('/market/thresholds') + return response.data + }, + + // 创建WebSocket连接 + createWebSocket(onMessage, onError, onOpen, onClose) { + const ws = new WebSocket('ws://localhost:8000/ws/market') + + ws.onopen = () => { + console.log('WebSocket 连接成功') + if (onOpen) onOpen() + } + + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data) + if (onMessage) onMessage(data) + } catch (e) { + console.error('WebSocket 消息解析错误:', e) + } + } + + ws.onerror = (error) => { + console.error('WebSocket 错误:', error) + if (onError) onError(error) + } + + ws.onclose = () => { + console.log('WebSocket 连接关闭') + if (onClose) onClose() + } + + return ws + }, + + // 发送心跳 + sendPing(ws) { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'ping' })) + } + }, + + // 获取趋势分析 + async getTrend(symbol) { + const response = await api.get(`/trend/${encodeURIComponent(symbol)}`) + return response.data + }, + + // 生成交易建议 + async generateTradeOrder(symbol) { + const response = await api.post(`/trend/generate_order/${encodeURIComponent(symbol)}`) + return response.data + }, + + // 获取待确认订单 + async getPendingOrders(symbol = null) { + const params = symbol ? { symbol } : {} + const response = await api.get('/pending_orders', { params }) + return response.data + }, + + // 确认订单 + async confirmOrder(orderId) { + const response = await api.post(`/pending_orders/${orderId}/confirm`) + return response.data + }, + + // 确认订单并更新参数 + async confirmOrderWithUpdate(orderId, updateData) { + const response = await api.post(`/pending_orders/${orderId}/confirm`, updateData) + return response.data + }, + + // 拒绝订单 + async rejectOrder(orderId) { + const response = await api.post(`/pending_orders/${orderId}/reject`) + return response.data + }, + + // 获取交易配置 + async getTradeConfig() { + const response = await api.get('/trade_config') + return response.data + }, + + // 更新交易配置 + async updateTradeConfig(config) { + const response = await api.post('/trade_config', config) + return response.data + }, + + // 获取统计数据(包含持仓) + async getStatistics(count = 1) { + const response = await api.get('/query_statistics', { params: { count } }) + return response.data + }, + + // 平仓 + async closePosition(ticket, symbol) { + const response = await api.post('/close_position', { ticket, symbol }) + return response.data + } +} + +export default api \ No newline at end of file diff --git a/frontend/src/api/trading.js b/frontend/src/api/trading.js new file mode 100644 index 0000000..93cf8a1 --- /dev/null +++ b/frontend/src/api/trading.js @@ -0,0 +1,71 @@ +import axios from 'axios' + +const api = axios.create({ + baseURL: 'http://localhost:8000', + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + }, +}) + +// 请求拦截器 +api.interceptors.request.use( + (config) => { + // 可以在这里添加认证token等 + return config + }, + (error) => { + return Promise.reject(error) + } +) + +// 响应拦截器 +api.interceptors.response.use( + (response) => { + return response + }, + (error) => { + console.error('API Error:', error) + return Promise.reject(error) + } +) + +export const tradingAPI = { + // 健康检查 + async health() { + const response = await api.get('/health') + return response.data + }, + + // 获取服务状态 + async getStatus() { + const response = await api.get('/status') + return response.data + }, + + // 发送交易指令 + async sendTradeInstructions(instructions) { + const response = await api.post('/send_trade_instructions', instructions) + return response.data + }, + + // 查询待执行指令 + async getPendingTrades() { + const response = await api.get('/query_pending_trades') + return response.data + }, + + // 查询统计数据 + async getStatistics() { + const response = await api.get('/query_statistics') + return response.data + }, + + // 清空指令 + async clearTrades() { + const response = await api.delete('/clear_trades') + return response.data + }, +} + +export default api \ No newline at end of file diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..cdb54dd --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,13 @@ +import { createApp } from 'vue' +import App from './App.vue' +import router from './router' +import vuetify from './plugins/vuetify' + +import './style.css' + +const app = createApp(App) + +app.use(router) +app.use(vuetify) + +app.mount('#app') \ No newline at end of file diff --git a/frontend/src/plugins/vuetify.js b/frontend/src/plugins/vuetify.js new file mode 100644 index 0000000..fda6791 --- /dev/null +++ b/frontend/src/plugins/vuetify.js @@ -0,0 +1,34 @@ +// plugins/vuetify.js +import 'vuetify/styles' +import { createVuetify } from 'vuetify' +import * as components from 'vuetify/components' +import * as directives from 'vuetify/directives' +import { aliases, mdi } from 'vuetify/iconsets/mdi' + +export default createVuetify({ + components, + directives, + icons: { + defaultSet: 'mdi', + aliases, + sets: { + mdi, + }, + }, + theme: { + defaultTheme: 'light', + themes: { + light: { + colors: { + primary: '#1976D2', + secondary: '#424242', + accent: '#82B1FF', + error: '#FF5252', + info: '#2196F3', + success: '#4CAF50', + warning: '#FFC107', + }, + }, + }, + }, +}) \ No newline at end of file diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..43b1f24 --- /dev/null +++ b/frontend/src/router/index.js @@ -0,0 +1,41 @@ +import { createRouter, createWebHistory } from 'vue-router' +import Dashboard from '../views/Dashboard.vue' +import TradeOrders from '../views/TradeOrders.vue' +import Statistics from '../views/Statistics.vue' +import Status from '../views/Status.vue' +import Market from '../views/Market.vue' + +const routes = [ + { + path: '/', + name: 'Dashboard', + component: Dashboard + }, + { + path: '/trades', + name: 'TradeOrders', + component: TradeOrders + }, + { + path: '/statistics', + name: 'Statistics', + component: Statistics + }, + { + path: '/status', + name: 'Status', + component: Status + }, + { + path: '/market', + name: 'Market', + component: Market + } +] + +const router = createRouter({ + history: createWebHistory(), + routes +}) + +export default router \ No newline at end of file diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..25299a1 --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,11 @@ +/* style.css */ +#app { + font-family: 'Roboto', sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + margin: 0; + padding: 0; +} \ No newline at end of file diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue new file mode 100644 index 0000000..dc452a6 --- /dev/null +++ b/frontend/src/views/Dashboard.vue @@ -0,0 +1,136 @@ + + + \ No newline at end of file diff --git a/frontend/src/views/Market.vue b/frontend/src/views/Market.vue new file mode 100644 index 0000000..2679427 --- /dev/null +++ b/frontend/src/views/Market.vue @@ -0,0 +1,1141 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/Statistics.vue b/frontend/src/views/Statistics.vue new file mode 100644 index 0000000..27477e6 --- /dev/null +++ b/frontend/src/views/Statistics.vue @@ -0,0 +1,248 @@ + + + \ No newline at end of file diff --git a/frontend/src/views/Status.vue b/frontend/src/views/Status.vue new file mode 100644 index 0000000..ac12a9f --- /dev/null +++ b/frontend/src/views/Status.vue @@ -0,0 +1,250 @@ + + + \ No newline at end of file diff --git a/frontend/src/views/TradeOrders.vue b/frontend/src/views/TradeOrders.vue new file mode 100644 index 0000000..5922f30 --- /dev/null +++ b/frontend/src/views/TradeOrders.vue @@ -0,0 +1,308 @@ + + + \ No newline at end of file diff --git a/frontend/start_vue.sh b/frontend/start_vue.sh new file mode 100755 index 0000000..0839df7 --- /dev/null +++ b/frontend/start_vue.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +# Vue 前端启动脚本 + +echo "🚀 启动量化交易系统 - Vue 前端" +echo "=================================" + +# 检查是否在正确的目录 +if [ ! -f "package.json" ]; then + echo "❌ 错误: 请在 frontend 目录下运行此脚本" + echo " cd frontend && ./start_vue.sh" + exit 1 +fi + +# 检查依赖是否已安装 +if [ ! -d "node_modules" ]; then + echo "📦 安装依赖..." + npm install + if [ $? -ne 0 ]; then + echo "❌ 依赖安装失败" + exit 1 + fi +fi + +echo "🌐 启动 Vue 开发服务器..." +echo " 前端地址: http://localhost:3001" +echo " 后端地址: http://localhost:8000" +echo "" +echo "按 Ctrl+C 停止服务器" +echo "" + +# 启动开发服务器 +npx vite --host 0.0.0.0 --port 3001 \ No newline at end of file diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..b1c0e95 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,22 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { fileURLToPath, URL } from 'node:url' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + } + }, + server: { + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, '') + } + } + } +}) \ No newline at end of file diff --git a/main.py b/main.py index d5d7185..276b58b 100644 --- a/main.py +++ b/main.py @@ -20,6 +20,7 @@ from server import TradingServer 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 def create_app(): @@ -31,8 +32,26 @@ def create_app(): # 创建 FastAPI 应用 app = FastAPI( title="高频交易服务 (HFT Trading Service)", - description="连接 MT5 EA 和交易指令源的高性能交易中心", - version="1.0.0" + description=""" +连接 MT5 EA 和交易指令源的高性能交易中心 + +## 功能模块 + +### 交易指令 +- EA获取交易指令 +- 交易员下发交易指令 +- 查询待执行指令 + +### 行情分析 +- K线数据接收与存储 (H4/H1/M15/M5/M1) +- 转折点自动检测 +- 实时转折点提醒 (WebSocket) + +### 系统监控 +- 健康检查 +- 服务状态查询 + """, + version="2.0.0" ) # 添加 CORS 中间件 @@ -48,6 +67,13 @@ def create_app(): app.include_router(create_ea_routes(server)) app.include_router(create_trader_routes(server)) app.include_router(create_system_routes(server)) + app.include_router(create_market_routes( + server.market_store, + server.pivot_detector, + server.pivot_monitor, + server.trend_analyzer, + server.pending_orders + )) return app diff --git a/market/__init__.py b/market/__init__.py new file mode 100644 index 0000000..925dba2 --- /dev/null +++ b/market/__init__.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +行情分析模块 +""" + +from .store import MarketStore +from .pivot_detector import PivotDetector +from .monitor import PivotMonitor +from .trend_analyzer import TrendAnalyzer + +__all__ = ['MarketStore', 'PivotDetector', 'PivotMonitor', 'TrendAnalyzer'] \ No newline at end of file diff --git a/market/merger.py b/market/merger.py new file mode 100644 index 0000000..54c5dd0 --- /dev/null +++ b/market/merger.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +K线合并模块 +处理增量K线数据的合并逻辑 +""" + +from typing import List, Dict, Optional +from datetime import datetime + +from .store import KlineData + + +class KlineMerger: + """K线合并器""" + + @staticmethod + def merge_klines(existing: List[KlineData], new_klines: List[KlineData]) -> List[KlineData]: + """ + 合并K线数据 + + Args: + existing: 现有K线数据 + new_klines: 新增K线数据 + + Returns: + 合并后的K线数据 + """ + if not new_klines: + return existing + + if not existing: + return new_klines + + # 使用字典来去重,以时间戳为key + kline_dict = {} + + # 添加现有数据 + for k in existing: + ts = KlineMerger._normalize_timestamp(k.timestamp) + kline_dict[ts] = k + + # 添加或更新新数据 + for k in new_klines: + ts = KlineMerger._normalize_timestamp(k.timestamp) + kline_dict[ts] = k + + # 按时间排序 + merged = sorted(kline_dict.values(), key=lambda x: KlineMerger._normalize_timestamp(x.timestamp)) + + return merged + + @staticmethod + def _normalize_timestamp(ts) -> str: + """标准化时间戳""" + if isinstance(ts, datetime): + return ts.strftime("%Y-%m-%d %H:%M:%S") + return str(ts) + + @staticmethod + def detect_gaps(klines: List[KlineData], period: str) -> List[Dict]: + """ + 检测K线数据缺口 + + Args: + klines: K线数据 + period: 周期 + + Returns: + 缺口列表 + """ + if len(klines) < 2: + return [] + + # 各周期对应的分钟数 + period_minutes = { + 'H4': 240, + 'H1': 60, + 'M15': 15, + 'M5': 5, + 'M1': 1 + } + + interval = period_minutes.get(period, 1) + gaps = [] + + for i in range(1, len(klines)): + prev_ts = KlineMerger._parse_timestamp(klines[i-1].timestamp) + curr_ts = KlineMerger._parse_timestamp(klines[i].timestamp) + + if prev_ts and curr_ts: + expected_diff = interval * 60 # 秒 + actual_diff = (curr_ts - prev_ts).total_seconds() + + # 如果实际差值大于预期的1.5倍,认为有缺口 + if actual_diff > expected_diff * 1.5: + gaps.append({ + "start": klines[i-1].timestamp, + "end": klines[i].timestamp, + "missing_bars": int(actual_diff / expected_diff) - 1 + }) + + return gaps + + @staticmethod + def _parse_timestamp(ts): + """解析时间戳""" + if isinstance(ts, datetime): + return ts + + if isinstance(ts, str): + try: + return datetime.strptime(ts, "%Y-%m-%d %H:%M:%S") + except: + try: + return datetime.strptime(ts, "%Y-%m-%d %H:%M") + except: + return None + + return None \ No newline at end of file diff --git a/market/monitor.py b/market/monitor.py new file mode 100644 index 0000000..a09c1ec --- /dev/null +++ b/market/monitor.py @@ -0,0 +1,412 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +转折点监控模块 +实时监控价格与转折点的接近程度,并通过WebSocket推送提醒 +""" + +from typing import Dict, List, Optional, Set +from datetime import datetime +import threading +import asyncio +import json + +from .store import MarketStore, normalize_symbol +from .pivot_detector import PivotDetector +from .pending_orders import PendingOrderManager + + +# 交易配置 +class TradeConfig: + """交易配置""" + _instance = None + _lock = threading.Lock() + + def __init__(self): + self.enabled = True # 是否启用自动生成 + + # 默认配置 + self.default_volume = 0.01 # 默认手数 + self.default_sl_offset = 0.05 # 默认止损偏移(固定点数) + + # 按品种配置: {symbol: {"volume": 0.01, "sl_offset": 0.05}} + self.symbol_config = { + "GOLD#": {"volume": 0.01, "sl_offset": 0.5}, + "OILCASH#": {"volume": 0.01, "sl_offset": 0.05}, + } + + @classmethod + def get_instance(cls): + if cls._instance is None: + with cls._lock: + if cls._instance is None: + cls._instance = cls() + return cls._instance + + 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) + } + return { + "volume": self.default_volume, + "sl_offset": self.default_sl_offset + } + + def to_dict(self) -> Dict: + return { + "enabled": self.enabled, + "default_volume": self.default_volume, + "default_sl_offset": self.default_sl_offset, + "symbol_config": self.symbol_config + } + + def update(self, data: Dict): + if "enabled" in data: + self.enabled = bool(data["enabled"]) + if "default_volume" in data: + self.default_volume = float(data["default_volume"]) + if "default_sl_offset" in data: + self.default_sl_offset = float(data["default_sl_offset"]) + if "symbol_config" in data: + self.symbol_config = data["symbol_config"] + + +class PivotMonitor: + """转折点监控器""" + + def __init__(self, store: MarketStore, detector: PivotDetector, + pending_orders: PendingOrderManager = None): + self.store = store + self.detector = detector + self.pending_orders = pending_orders + self.trade_config = TradeConfig.get_instance() + + # WebSocket连接管理 + self._ws_clients: Set = set() + self._ws_lock = threading.Lock() + + # 已提醒的转折点(避免重复提醒) + # 结构: {(symbol, period, timestamp, price): datetime} + self._alerted_pivots: Dict[tuple, datetime] = {} + self._alert_lock = threading.Lock() + + # 提醒冷却时间(秒) + self.alert_cooldown = 300 # 5分钟内同一转折点不重复提醒 + + print("[PivotMonitor] 转折点监控器已初始化") + + def check_and_alert(self, symbol: str, current_price: float) -> List[Dict]: + """ + 检查价格是否接近转折点,并发送提醒 + + Args: + symbol: 交易品种 + current_price: 当前价格 + + Returns: + 接近的转折点列表 + """ + symbol = normalize_symbol(symbol) + + # 检查是否接近转折点 + near_pivots = self.detector.check_near_pivot(symbol, current_price) + + if not near_pivots: + return [] + + # 过滤已提醒过的转折点 + new_alerts = [] + current_time = datetime.now() + + with self._alert_lock: + for pivot in near_pivots: + key = ( + pivot['symbol'], + pivot['period'], + pivot['timestamp'], + pivot['price'] + ) + + # 检查是否已提醒过 + if key in self._alerted_pivots: + last_alert = self._alerted_pivots[key] + elapsed = (current_time - last_alert).total_seconds() + + # 如果在冷却时间内,跳过 + if elapsed < self.alert_cooldown: + continue + + # 记录提醒时间 + self._alerted_pivots[key] = current_time + + # 构建提醒消息 + is_breakthrough = pivot.get('is_breakthrough', False) + alert_type = pivot.get('alert_type', '') + period = pivot['period'] + + # 根据类型生成不同的消息 + if is_breakthrough: + if 'high' in alert_type: + message = f"{pivot['symbol']} {period} 已突破高点 {pivot['price']}, 当前价格 {pivot['current_price']}" + else: + message = f"{pivot['symbol']} {period} 已突破低点 {pivot['price']}, 当前价格 {pivot['current_price']}" + else: + if 'high' in alert_type: + message = f"{pivot['symbol']} {period} 接近高点 {pivot['price']}, 当前价格 {pivot['current_price']}, 距离 {pivot['distance_pct']}%" + else: + message = f"{pivot['symbol']} {period} 接近低点 {pivot['price']}, 当前价格 {pivot['current_price']}, 距离 {pivot['distance_pct']}%" + + alert = { + "type": "pivot_alert", + "symbol": pivot['symbol'], + "period": period, + "direction": pivot['direction'], + "pivot_price": pivot['price'], + "current_price": pivot['current_price'], + "distance_pct": pivot['distance_pct'], + "threshold_pct": pivot['threshold_pct'], + "timestamp": current_time.isoformat(), + "alert_type": alert_type, + "is_breakthrough": is_breakthrough, + "message": message + } + + # M1和M5周期接近转折点时,自动生成交易指令 + pending_order = None + if period in ['M1', 'M5'] and not is_breakthrough: + pending_order = self._auto_generate_order(pivot, current_time) + + # 如果生成了订单,加入通知中 + if pending_order: + alert["pending_order"] = pending_order + + new_alerts.append(alert) + + # 异步推送WebSocket消息 + self._broadcast_alert(alert) + + # 清理过期的提醒记录 + self._cleanup_alerted() + + return new_alerts + + def _auto_generate_order(self, pivot: Dict, current_time: datetime) -> Optional[Dict]: + """ + M1周期接近转折点时,自动生成交易指令 + + Args: + pivot: 转折点信息 + current_time: 当前时间 + + Returns: + 生成的订单信息,包含order_id + """ + if not self.pending_orders: + return None + + if not self.trade_config.enabled: + return None + + symbol = pivot['symbol'] + current_price = pivot['current_price'] + pivot_price = pivot['price'] + direction = pivot['direction'] + alert_type = pivot['alert_type'] + + # 只处理"接近"类型(near_high, near_low) + if not alert_type.startswith('near_'): + return None + + # 获取品种配置 + config = self.trade_config.get_symbol_config(symbol) + volume = config["volume"] + sl_offset = config["sl_offset"] # 固定点数偏移 + + order = None + + if alert_type == 'near_low': + # 接近低点 → 买入 + # 止损 = 低点 - 配置的偏移 + sl = pivot_price - sl_offset + # 止盈 = 最近的高点 + tp = self._find_nearest_pivot_price(symbol, 'high', current_price) + + if tp and tp > current_price: + order = { + "symbol": symbol, + "action": "b", # 买入 + "price": current_price, + "mount": volume, + "sl": round(sl, 2), + "tp": round(tp, 2), + "reason": f"M1接近低点{pivot_price:.2f},建议买入,止损{sl:.2f},止盈{tp:.2f}", + "source": "auto_pivot_m1", + "pivot_price": pivot_price, + "generated_at": current_time.isoformat() + } + + elif alert_type == 'near_high': + # 接近高点 → 卖出 + # 止损 = 高点 + 配置的偏移 + sl = pivot_price + sl_offset + # 止盈 = 最近的低点 + tp = self._find_nearest_pivot_price(symbol, 'low', current_price) + + if tp and tp < current_price: + order = { + "symbol": symbol, + "action": "s", # 卖出 + "price": current_price, + "mount": volume, + "sl": round(sl, 2), + "tp": round(tp, 2), + "reason": f"M1接近高点{pivot_price:.2f},建议卖出,止损{sl:.2f},止盈{tp:.2f}", + "source": "auto_pivot_m1", + "pivot_price": pivot_price, + "generated_at": current_time.isoformat() + } + + if order: + order_id = self.pending_orders.add_order(order) + print(f"[PivotMonitor] 自动生成交易指令: {order_id} - {order['action']} {symbol} @ {current_price}") + # 返回订单信息(包含order_id) + order["order_id"] = order_id + return order + + return None + + def _find_nearest_pivot_price(self, symbol: str, direction: str, + current_price: float) -> Optional[float]: + """ + 找到离当前价格最近的转折点价格 + + Args: + symbol: 交易品种 + direction: 'high' 或 'low' + current_price: 当前价格 + + Returns: + 最近的转折点价格,如果没有返回None + """ + symbol = normalize_symbol(symbol) + nearest_price = None + min_distance = float('inf') + + with self.detector._lock: + for period in self.detector._pivots[symbol]: + pivots = self.detector._pivots[symbol][period] + + for pivot in pivots: + if pivot.direction != direction: + continue + + # 对于高点,只考虑价格高于当前价的 + # 对于低点,只考虑价格低于当前价的 + if direction == 'high' and pivot.price <= current_price: + continue + if direction == 'low' and pivot.price >= current_price: + continue + + distance = abs(pivot.price - current_price) + if distance < min_distance: + min_distance = distance + nearest_price = pivot.price + + return nearest_price + + def _broadcast_new_order(self, order_id: str, order: Dict) -> None: + """广播新订单通知""" + message = json.dumps({ + "type": "new_order", + "order_id": order_id, + "order": order + }) + + with self._ws_lock: + clients = list(self._ws_clients) + + for client in clients: + try: + asyncio.create_task(self._send_to_client(client, message)) + except Exception as e: + print(f"[PivotMonitor] 发送新订单通知失败: {e}") + + def _cleanup_alerted(self): + """清理过期的提醒记录""" + current_time = datetime.now() + + with self._alert_lock: + keys_to_remove = [] + for key, alert_time in self._alerted_pivots.items(): + elapsed = (current_time - alert_time).total_seconds() + if elapsed > self.alert_cooldown * 2: + keys_to_remove.append(key) + + for key in keys_to_remove: + del self._alerted_pivots[key] + + def _broadcast_alert(self, alert: Dict): + """广播提醒到所有WebSocket客户端""" + message = json.dumps(alert) + + with self._ws_lock: + clients = list(self._ws_clients) + + # 在事件循环中发送消息 + for client in clients: + try: + asyncio.create_task(self._send_to_client(client, message)) + except Exception as e: + print(f"[PivotMonitor] 发送WebSocket消息失败: {e}") + + async def _send_to_client(self, client, message: str): + """发送消息到客户端""" + try: + await client.send_text(message) + except Exception as e: + print(f"[PivotMonitor] 发送消息到客户端失败: {e}") + # 移除失效的客户端 + with self._ws_lock: + self._ws_clients.discard(client) + + def add_ws_client(self, client): + """添加WebSocket客户端""" + with self._ws_lock: + self._ws_clients.add(client) + print(f"[PivotMonitor] WebSocket客户端已连接, 当前连接数: {len(self._ws_clients)}") + + def remove_ws_client(self, client): + """移除WebSocket客户端""" + with self._ws_lock: + self._ws_clients.discard(client) + print(f"[PivotMonitor] WebSocket客户端已断开, 当前连接数: {len(self._ws_clients)}") + + def get_ws_client_count(self) -> int: + """获取WebSocket客户端数量""" + with self._ws_lock: + return len(self._ws_clients) + + 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: + del self._alerted_pivots[key] + + def get_status(self) -> Dict: + """获取监控状态""" + with self._alert_lock: + alerted_count = len(self._alerted_pivots) + + return { + "ws_clients": self.get_ws_client_count(), + "alerted_pivots": alerted_count, + "alert_cooldown": self.alert_cooldown + } \ No newline at end of file diff --git a/market/pending_orders.py b/market/pending_orders.py new file mode 100644 index 0000000..f7dc00b --- /dev/null +++ b/market/pending_orders.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +待确认订单管理模块 +存储交易员待确认的交易指令 +""" + +from collections import defaultdict +from typing import List, Dict, Optional, Callable +from datetime import datetime, timedelta +import threading +import uuid + + +class PendingOrderManager: + """待确认订单管理器""" + + # 订单超时时间(秒) + ORDER_TIMEOUT = 180 # 3分钟 + + def __init__(self): + # 待确认订单: {SYMBOL: [Order, ...]} + self._pending_orders = defaultdict(list) + self._lock = threading.RLock() + + # 订单ID到订单的映射 + self._order_by_id = {} + + # 订单确认回调(确认后将订单加入交易队列) + self._confirm_callback: Optional[Callable] = None + + # 启动超时清理线程 + self._start_cleanup_thread() + + print("[PendingOrderManager] 待确认订单管理器已初始化") + + def set_confirm_callback(self, callback: Callable): + """设置订单确认回调函数""" + self._confirm_callback = callback + + def _start_cleanup_thread(self): + """启动超时清理线程""" + def cleanup_loop(): + while True: + try: + self._cleanup_expired_orders() + except Exception as e: + print(f"[PendingOrderManager] 清理线程异常: {e}") + threading.Event().wait(10) # 每10秒检查一次 + + thread = threading.Thread(target=cleanup_loop, daemon=True) + thread.start() + + def _cleanup_expired_orders(self): + """清理超时订单""" + current_time = datetime.now() + expired_orders = [] + + with self._lock: + for order_id, order in list(self._order_by_id.items()): + created_at = datetime.fromisoformat(order['created_at']) + elapsed = (current_time - created_at).total_seconds() + + if elapsed > self.ORDER_TIMEOUT: + expired_orders.append(order_id) + + for order_id in expired_orders: + order = self._order_by_id[order_id] + symbol = order.get('symbol', 'UNKNOWN') + + self._pending_orders[symbol] = [ + o for o in self._pending_orders[symbol] if o['order_id'] != order_id + ] + del self._order_by_id[order_id] + + print(f"[PendingOrderManager] 订单超时自动移除: {order_id}") + + def add_order(self, order: Dict) -> str: + """ + 添加待确认订单 + + Args: + order: 订单信息 + + Returns: + 订单ID + """ + # 生成订单ID + order_id = str(uuid.uuid4())[:8] + + order_with_id = { + **order, + "order_id": order_id, + "status": "pending", + "created_at": datetime.now().isoformat(), + "expires_at": (datetime.now() + timedelta(seconds=self.ORDER_TIMEOUT)).isoformat() + } + + symbol = order.get('symbol', 'UNKNOWN') + + with self._lock: + self._pending_orders[symbol].append(order_with_id) + self._order_by_id[order_id] = order_with_id + + print(f"[PendingOrderManager] 添加待确认订单: {order_id} {symbol} {order.get('action')}") + + return order_id + + def confirm_order(self, order_id: str) -> Optional[Dict]: + """ + 确认订单(交易员确认后调用) + + Returns: + 确认后的订单,用于加入正式交易队列 + """ + with self._lock: + if order_id not in self._order_by_id: + return None + + order = self._order_by_id[order_id] + symbol = order.get('symbol', 'UNKNOWN') + + # 从待确认列表中移除 + self._pending_orders[symbol] = [ + o for o in self._pending_orders[symbol] if o['order_id'] != order_id + ] + del self._order_by_id[order_id] + + # 标记为已确认 + order['status'] = 'confirmed' + order['confirmed_at'] = datetime.now().isoformat() + + print(f"[PendingOrderManager] 订单已确认: {order_id}") + + # 调用确认回调(将订单加入交易队列) + if self._confirm_callback: + try: + self._confirm_callback(order) + print(f"[PendingOrderManager] 订单已加入交易队列: {order_id}") + except Exception as e: + print(f"[PendingOrderManager] 加入交易队列失败: {e}") + + return order + + def reject_order(self, order_id: str) -> bool: + """ + 拒绝订单(交易员点击放弃) + + Returns: + 是否成功 + """ + with self._lock: + if order_id not in self._order_by_id: + return False + + order = self._order_by_id[order_id] + symbol = order.get('symbol', 'UNKNOWN') + + # 从待确认列表中移除 + self._pending_orders[symbol] = [ + o for o in self._pending_orders[symbol] if o['order_id'] != order_id + ] + del self._order_by_id[order_id] + + print(f"[PendingOrderManager] 订单已拒绝: {order_id}") + + return True + + def get_pending_orders(self, symbol: str = None) -> List[Dict]: + """获取待确认订单列表""" + with self._lock: + if symbol: + return list(self._pending_orders.get(symbol, [])) + else: + # 返回所有 + orders = [] + for sym, order_list in self._pending_orders.items(): + orders.extend(order_list) + return sorted(orders, key=lambda x: x['created_at'], reverse=True) + + def get_order_by_id(self, order_id: str) -> Optional[Dict]: + """根据ID获取订单""" + with self._lock: + return self._order_by_id.get(order_id) + + def get_pending_count(self, symbol: str = None) -> int: + """获取待确认订单数量""" + with self._lock: + if symbol: + return len(self._pending_orders.get(symbol, [])) + return len(self._order_by_id) + + def clear_all(self) -> int: + """清空所有待确认订单""" + with self._lock: + count = len(self._order_by_id) + self._pending_orders.clear() + self._order_by_id.clear() + return count \ No newline at end of file diff --git a/market/pivot_detector.py b/market/pivot_detector.py new file mode 100644 index 0000000..ed0fb09 --- /dev/null +++ b/market/pivot_detector.py @@ -0,0 +1,414 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +转折点检测模块 +识别K线的高点和低点(分型识别) +""" + +from collections import defaultdict +from datetime import datetime +from typing import List, Dict, Optional, Tuple +import threading + +from .store import KlineData, normalize_symbol + + +class PivotPoint: + """转折点数据结构""" + + def __init__(self, symbol: str, period: str, timestamp, price: float, + direction: str, strength: int = 3): + self.symbol = normalize_symbol(symbol) + self.period = period + self.timestamp = timestamp + self.price = price + self.direction = direction # "high" 或 "low" + self.strength = strength # 转折强度(左右各N根K线) + + def to_dict(self) -> Dict: + """转换为字典""" + ts = self.timestamp + if isinstance(ts, datetime): + ts_str = ts.strftime("%Y-%m-%d %H:%M:%S") + else: + ts_str = str(ts) + + return { + "symbol": self.symbol, + "period": self.period, + "timestamp": ts_str, + "price": self.price, + "direction": self.direction, + "strength": self.strength + } + + +class PivotDetector: + """转折点检测器""" + + # 各周期接近阈值(千分比) + THRESHOLDS = { + 'H4': 0.0015, # 千分之1.5 + 'H1': 0.0015, # 千分之1.5 + 'M15': 0.0015, # 千分之1.5 + 'M5': 0.0005, # 千分之0.5 + 'M1': 0.0002 # 千分之0.2 + } + + def __init__(self): + # 存储转折点: {SYMBOL: {PERIOD: [PivotPoint, ...]}} + self._pivots = defaultdict(lambda: defaultdict(list)) + self._lock = threading.RLock() + + # 默认转折强度(左右各N根K线) + self.default_strength = 3 + + print("[PivotDetector] 转折点检测器已初始化") + + def detect_pivots(self, symbol: str, period: str, klines: List[KlineData], + strength: int = None) -> List[PivotPoint]: + """ + 检测转折点 + + Args: + symbol: 交易品种 + period: 周期 + klines: K线数据列表 + strength: 转折强度(左右各N根K线) + + Returns: + 检测到的转折点列表 + """ + if strength is None: + strength = self.default_strength + + if len(klines) < 2 * strength + 1: + return [] + + pivots = [] + + # 遍历K线,检测分型 + for i in range(strength, len(klines) - strength): + current = klines[i] + + # 检查是否为高点(顶分型) + is_high = True + for j in range(1, strength + 1): + if klines[i - j].high >= current.high or klines[i + j].high >= current.high: + is_high = False + break + + if is_high: + pivot = PivotPoint( + symbol=symbol, + period=period, + timestamp=current.timestamp, + price=current.high, + direction="high", + strength=strength + ) + pivots.append(pivot) + + # 检查是否为低点(底分型) + is_low = True + for j in range(1, strength + 1): + if klines[i - j].low <= current.low or klines[i + j].low <= current.low: + is_low = False + break + + if is_low: + pivot = PivotPoint( + symbol=symbol, + period=period, + timestamp=current.timestamp, + price=current.low, + direction="low", + strength=strength + ) + pivots.append(pivot) + + return pivots + + def _merge_pivots(self, pivots: List[PivotPoint], klines: List[KlineData]) -> List[PivotPoint]: + """ + 合并相近的转折点 + + 合并规则: + - K线距离小于26根 + - 价格相差在万分之三范围内 + - 高点合并:取较高的价格 + - 低点合并:取较低的价格 + + Args: + pivots: 原始转折点列表 + klines: K线数据(用于计算K线索引) + + Returns: + 合并后的转折点列表 + """ + 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_lows = self._merge_same_direction( + low_pivots, kline_index, "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]: + """ + 合并同方向的转折点 + """ + if len(pivots) < 2: + return pivots + + 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] + + 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: # 万分之三 + group.append(next_pivot) + j += 1 + continue + + break + + # 从组中选择代表性转折点 + if direction == "high": + # 高点:取价格最高的 + best = max(group, key=lambda p: p.price) + else: + # 低点:取价格最低的 + best = min(group, key=lambda p: p.price) + + merged.append(best) + i = j + + return merged + + def update_pivots(self, symbol: str, period: str, klines: List[KlineData], + strength: int = None) -> int: + """ + 更新转折点数据 + + Returns: + 更新后的转折点数量 + """ + symbol = normalize_symbol(symbol) + + pivots = self.detect_pivots(symbol, period, klines, strength) + + # 合并相近的转折点 + merged_pivots = self._merge_pivots(pivots, klines) + + with self._lock: + self._pivots[symbol][period] = merged_pivots + count = len(merged_pivots) + + original_count = len(pivots) + if original_count != count: + print(f"[PivotDetector] {symbol} {period} 检测到 {original_count} 个转折点,合并后 {count} 个") + else: + print(f"[PivotDetector] {symbol} {period} 检测到 {count} 个转折点") + return count + + def get_pivots(self, symbol: str, period: str, direction: str = None, + count: int = 50) -> List[Dict]: + """ + 获取转折点数据 + + Args: + symbol: 交易品种 + period: 周期 + direction: "high" 或 "low",None表示全部 + count: 返回数量 + + Returns: + 转折点列表 + """ + symbol = normalize_symbol(symbol) + + with self._lock: + pivots = self._pivots[symbol][period] + + if direction: + pivots = [p for p in pivots if p.direction == direction] + + # 按时间排序,返回最新的 + pivots = sorted(pivots, key=lambda x: str(x.timestamp), reverse=True)[:count] + + return [p.to_dict() for p in pivots] + + 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]: + """ + 检查当前价格是否接近某个转折点 + + Args: + symbol: 交易品种 + current_price: 当前价格 + + Returns: + 接近的转折点列表,包含距离信息 + + 预警逻辑: + - 接近高点:当前价格 < 高点价格 且 距离在阈值范围内 + - 接近低点:当前价格 > 低点价格 且 距离在阈值范围内 + - 突破高点:当前价格超过高点价格的万分之一点二(基于实时价格) + - 突破低点:当前价格低于低点价格的万分之一点二(基于实时价格) + - 超过千分之一不再提示 + """ + 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) + + 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 # 千分之一 + + # 判断是接近还是突破 + 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: + # 当前价格低于高点 + 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: + # 当前价格高于低点 + 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: + distance_pct = abs(current_price - pivot.price) / current_price + near_pivots.append({ + **pivot.to_dict(), + "current_price": current_price, + "distance_pct": round(distance_pct * 100, 4), + "threshold_pct": round(threshold * 100, 4), + "distance": round(current_price - pivot.price, 2), + "alert_type": alert_type, + "is_breakthrough": is_breakthrough + }) + + # 按距离排序,最近的优先 + near_pivots.sort(key=lambda x: x['distance_pct']) + + return near_pivots + + def get_threshold(self, period: str) -> float: + """获取某个周期的接近阈值""" + return self.THRESHOLDS.get(period, 0.001) + + def clear_symbol(self, symbol: str): + """清除某个Symbol的转折点数据""" + symbol = normalize_symbol(symbol) + with self._lock: + if symbol in self._pivots: + del self._pivots[symbol] + + def get_status(self) -> Dict: + """获取状态""" + with self._lock: + status = {} + for symbol in self._pivots: + 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 diff --git a/market/store.py b/market/store.py new file mode 100644 index 0000000..f11ebeb --- /dev/null +++ b/market/store.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +K线数据存储模块 +按周期和Symbol存储K线数据 +""" + +from collections import defaultdict +from datetime import datetime +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.period = period # H4, H1, M15, M5, M1 + self.timestamp = timestamp + self.open = open_price + self.high = high + self.low = low + self.close = close + self.volume = volume + + def to_dict(self) -> Dict: + """转换为字典""" + ts = self.timestamp + if isinstance(ts, datetime): + ts_str = ts.strftime("%Y-%m-%d %H:%M:%S") + else: + ts_str = str(ts) + + return { + "symbol": self.symbol, + "period": self.period, + "timestamp": ts_str, + "open": self.open, + "high": self.high, + "low": self.low, + "close": self.close, + "volume": self.volume + } + + +class MarketStore: + """K线数据存储""" + + # 支持的周期 + PERIODS = ['H4', 'H1', 'M15', 'M5', 'M1'] + + # 各周期最大存储条数 + MAX_KLINES = { + 'H4': 1500, # 4小时,6个月约1100根,留余量 + 'H1': 1000, # 1小时,1个月约720根 + 'M15': 500, # 15分钟,3天约288根 + 'M5': 400, # 5分钟,24小时288根 + 'M1': 100 # 1分钟,1小时60根 + } + + def __init__(self): + # 存储结构: {SYMBOL: {PERIOD: [KlineData, ...]}} + self._klines = defaultdict(lambda: defaultdict(list)) + self._lock = threading.RLock() + + # 标记每个symbol每个周期是否已收到全量数据 + # 结构: {SYMBOL: {PERIOD: True/False}} + self._initialized = defaultdict(lambda: defaultdict(bool)) + + print("[MarketStore] K线存储已初始化") + + def save_klines(self, symbol: str, period: str, klines: List[Dict], + is_full: bool = False) -> Dict: + """ + 保存K线数据 + + Args: + symbol: 交易品种 + period: 周期 (H4/H1/M15/M5/M1) + klines: K线数据列表 + is_full: 是否为全量数据 + + 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: + if is_full: + # 全量数据,直接覆盖 + self._klines[symbol][period] = [] + + # 解析并存储K线数据 + new_count = 0 + for k in klines: + kline = KlineData( + symbol=symbol, + period=period, + timestamp=k.get('timestamp') or k.get('time'), + open_price=float(k.get('open', 0)), + high=float(k.get('high', 0)), + low=float(k.get('low', 0)), + close=float(k.get('close', 0)), + volume=float(k.get('volume', 0)) + ) + + # 检查是否已存在相同时间戳的数据 + existing = self._klines[symbol][period] + ts = kline.timestamp + + # 查找是否已存在 + found_idx = -1 + for i, existing_kline in enumerate(existing): + if self._normalize_timestamp(existing_kline.timestamp) == self._normalize_timestamp(ts): + found_idx = i + break + + if found_idx >= 0: + # 更新已有数据 + existing[found_idx] = kline + else: + # 添加新数据 + existing.append(kline) + new_count += 1 + + # 按时间排序 + self._klines[symbol][period].sort( + key=lambda x: self._normalize_timestamp(x.timestamp) + ) + + # 限制最大条数,保留最新的 + max_count = self.MAX_KLINES.get(period, 500) + if len(self._klines[symbol][period]) > max_count: + self._klines[symbol][period] = self._klines[symbol][period][-max_count:] + + # 标记已初始化 + self._initialized[symbol][period] = True + + total = len(self._klines[symbol][period]) + print(f"[MarketStore] {symbol} {period} 保存了 {new_count} 条新数据, 当前共 {total} 条") + + return { + "status": "ok", + "count": new_count, + "total": total, + "is_full": is_full + } + + def get_klines(self, symbol: str, period: str, count: int = 100) -> List[Dict]: + """获取K线数据""" + symbol = normalize_symbol(symbol) + period = period.upper() + + with self._lock: + klines = self._klines[symbol][period][-count:] + return [k.to_dict() for k in klines] + + def get_all_klines(self, symbol: str, period: str) -> List[Dict]: + """获取所有K线数据""" + symbol = normalize_symbol(symbol) + period = period.upper() + + with self._lock: + return [k.to_dict() for k in self._klines[symbol][period]] + + def get_latest_price(self, symbol: str) -> Optional[float]: + """获取最新价格(从K线的最新close,优先M1,依次尝试其他周期)""" + symbol = normalize_symbol(symbol) + + with self._lock: + # 尝试找到匹配的symbol(支持带#后缀的symbol) + actual_symbol = None + if symbol in self._klines: + actual_symbol = symbol + else: + # 尝试添加#后缀 + for s in self._klines: + if s.upper().startswith(symbol.upper()): + actual_symbol = s + break + + if not actual_symbol: + return None + + # 按优先级尝试各周期(M1优先,然后更短周期) + for period in ['M1', 'M5', 'M15', 'H1', 'H4']: + klines = self._klines[actual_symbol][period] + if klines: + return klines[-1].close + return None + + 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] + if symbol in self._initialized: + del self._initialized[symbol] + + def get_status(self) -> Dict: + """获取存储状态""" + with self._lock: + status = {} + for symbol in self._klines: + status[symbol] = {} + for period in self.PERIODS: + count = len(self._klines[symbol][period]) + initialized = self._initialized[symbol][period] + status[symbol][period] = { + "count": count, + "initialized": initialized + } + return status + + def get_symbols(self) -> List[str]: + """获取所有有实际数据的symbol列表""" + with self._lock: + symbols = [] + for symbol in self._klines: + # 检查是否有实际数据(任一周期有K线数据) + has_data = False + for period in self.PERIODS: + if len(self._klines[symbol][period]) > 0: + has_data = True + break + if has_data: + symbols.append(symbol) + return symbols + + def _normalize_timestamp(self, ts) -> str: + """标准化时间戳为字符串""" + if isinstance(ts, datetime): + return ts.strftime("%Y-%m-%d %H:%M:%S") + return str(ts) \ No newline at end of file diff --git a/market/trend_analyzer.py b/market/trend_analyzer.py new file mode 100644 index 0000000..8535c70 --- /dev/null +++ b/market/trend_analyzer.py @@ -0,0 +1,389 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +趋势分析模块 +基于均线和ADX判断趋势方向和强度 +""" + +from collections import defaultdict +from typing import List, Dict, Optional +from datetime import datetime +import threading + +from .store import KlineData, normalize_symbol + + +class TrendAnalyzer: + """趋势分析器""" + + # 支持的周期 + PERIODS = ['H4', 'H1', 'M15', 'M5', 'M1'] + + # ADX阈值 + ADX_TREND_THRESHOLD = 25 # ADX > 25 表示有趋势 + ADX_STRONG_THRESHOLD = 40 # ADX > 40 表示强趋势 + + # 均线周期 + MA_FAST = 10 # 快线周期 + MA_SLOW = 20 # 慢线周期 + + def __init__(self): + # 存储各周期趋势状态: {SYMBOL: {PERIOD: TrendState}} + self._trend_states = defaultdict(lambda: defaultdict(dict)) + self._lock = threading.RLock() + + # 趋势转换历史 + self._trend_changes = defaultdict(list) + + print("[TrendAnalyzer] 趋势分析器已初始化") + + def analyze_trend(self, symbol: str, period: str, klines: List[KlineData]) -> Dict: + """ + 分析单个周期的趋势 + + Args: + symbol: 交易品种 + period: 周期 + klines: K线数据 + + Returns: + { + "trend": "up" / "down" / "sideways", + "strength": 0-100, + "adx": float, + "ma_fast": float, + "ma_slow": float, + "price": float, + "change_signal": bool, # 是否发生趋势转换 + "timestamp": str + } + """ + if len(klines) < 30: # 至少需要30根K线 + return { + "trend": "unknown", + "strength": 0, + "adx": 0, + "ma_fast": 0, + "ma_slow": 0, + "price": 0, + "change_signal": False, + "reason": "K线数据不足(需≥30根)", + "timestamp": datetime.now().isoformat() + } + + # 计算均线 + closes = [k.close for k in klines] + ma_fast = self._calculate_ma(closes, self.MA_FAST) + ma_slow = self._calculate_ma(closes, self.MA_SLOW) + current_price = closes[-1] + + # 计算ADX + adx = self._calculate_adx(klines) + + # 判断趋势方向和原因 + reason_parts = [] + + if adx < self.ADX_TREND_THRESHOLD: + # ADX较低,震荡行情 + trend = "sideways" + reason_parts.append(f"ADX={adx:.1f}<25 无明显趋势") + else: + # 根据均线和价格判断方向 + if ma_fast > ma_slow and current_price > ma_fast: + trend = "up" + reason_parts.append(f"MA{self.MA_FAST}({ma_fast:.2f}) > MA{self.MA_SLOW}({ma_slow:.2f})") + reason_parts.append(f"价格({current_price:.2f}) > MA{self.MA_FAST}") + reason_parts.append(f"ADX={adx:.1f}≥25 确认趋势") + elif ma_fast < ma_slow and current_price < ma_fast: + trend = "down" + reason_parts.append(f"MA{self.MA_FAST}({ma_fast:.2f}) < MA{self.MA_SLOW}({ma_slow:.2f})") + reason_parts.append(f"价格({current_price:.2f}) < MA{self.MA_FAST}") + reason_parts.append(f"ADX={adx:.1f}≥25 确认趋势") + else: + trend = "sideways" + if ma_fast > ma_slow: + reason_parts.append(f"MA{self.MA_FAST}({ma_fast:.2f}) > MA{self.MA_SLOW}({ma_slow:.2f})") + reason_parts.append(f"但价格({current_price:.2f})低于MA{self.MA_FAST}") + else: + reason_parts.append(f"MA{self.MA_FAST}({ma_fast:.2f}) < MA{self.MA_SLOW}({ma_slow:.2f})") + reason_parts.append(f"且价格({current_price:.2f})高于MA{self.MA_FAST}") + reason_parts.append("信号矛盾,判定震荡") + + reason = ";".join(reason_parts) + + # 计算趋势强度 (基于ADX) + if adx >= self.ADX_STRONG_THRESHOLD: + strength = min(100, int(adx + 20)) + elif adx >= self.ADX_TREND_THRESHOLD: + strength = int(adx + 10) + else: + strength = int(adx) + + # 检查趋势转换 + symbol_key = normalize_symbol(symbol) + change_signal = False + previous_trend = None + + with self._lock: + if period in self._trend_states[symbol_key]: + previous_trend = self._trend_states[symbol_key][period].get('trend') + if previous_trend and previous_trend != trend and previous_trend != "unknown": + change_signal = True + # 记录转换历史 + self._trend_changes[symbol_key].append({ + "period": period, + "from_trend": previous_trend, + "to_trend": trend, + "timestamp": datetime.now().isoformat(), + "price": current_price + }) + # 只保留最近20条 + if len(self._trend_changes[symbol_key]) > 20: + self._trend_changes[symbol_key] = self._trend_changes[symbol_key][-20:] + + # 更新状态 + self._trend_states[symbol_key][period] = { + "trend": trend, + "strength": strength, + "adx": round(adx, 2), + "ma_fast": round(ma_fast, 4), + "ma_slow": round(ma_slow, 4), + "price": current_price, + "change_signal": change_signal, + "previous_trend": previous_trend, + "reason": reason, + "timestamp": datetime.now().isoformat() + } + + return self._trend_states[symbol_key][period] + + def analyze_resonance(self, symbol: str) -> Dict: + """ + 分析多周期共振 + + Returns: + { + "resonance": "up" / "down" / "none", + "strength": 0-100, + "periods": {period: trend_state}, + "aligned_count": int, + "signal": str + } + """ + symbol_key = normalize_symbol(symbol) + + with self._lock: + states = dict(self._trend_states[symbol_key]) + + if not states: + return { + "resonance": "none", + "strength": 0, + "periods": {}, + "aligned_count": 0, + "signal": "等待数据" + } + + # 统计各趋势数量 + up_count = sum(1 for s in states.values() if s.get('trend') == 'up') + down_count = sum(1 for s in states.values() if s.get('trend') == 'down') + sideways_count = sum(1 for s in states.values() if s.get('trend') == 'sideways') + + # 计算平均强度 + strengths = [s.get('strength', 0) for s in states.values() if s.get('trend') != 'sideways'] + avg_strength = sum(strengths) / len(strengths) if strengths else 0 + + # 判断共振 + total = len(states) + if up_count >= total * 0.6: # 60%以上周期趋势一致 + resonance = "up" + aligned_count = up_count + signal = f"多周期向上共振 ({up_count}/{total})" + elif down_count >= total * 0.6: + resonance = "down" + aligned_count = down_count + signal = f"多周期向下共振 ({down_count}/{total})" + else: + resonance = "none" + aligned_count = max(up_count, down_count) + signal = f"趋势分歧 (↑{up_count} ↓{down_count} →{sideways_count})" + + return { + "resonance": resonance, + "strength": int(avg_strength), + "periods": states, + "aligned_count": aligned_count, + "up_count": up_count, + "down_count": down_count, + "sideways_count": sideways_count, + "signal": signal + } + + 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]) + + 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:] + + def _calculate_ma(self, data: List[float], period: int) -> float: + """计算移动平均线""" + if len(data) < period: + return data[-1] if data else 0 + return sum(data[-period:]) / period + + def _calculate_adx(self, klines: List[KlineData], period: int = 14) -> float: + """ + 计算ADX (Average Directional Index) + + ADX > 25: 有趋势 + ADX > 40: 强趋势 + ADX < 20: 无明显趋势 + """ + if len(klines) < period + 1: + return 0 + + # 计算 +DM 和 -DM + plus_dm = [] + minus_dm = [] + tr_list = [] + + for i in range(1, len(klines)): + high = klines[i].high + low = klines[i].low + prev_high = klines[i-1].high + prev_low = klines[i-1].low + prev_close = klines[i-1].close + + # +DM + up_move = high - prev_high + down_move = prev_low - low + + if up_move > down_move and up_move > 0: + plus_dm.append(up_move) + else: + plus_dm.append(0) + + # -DM + if down_move > up_move and down_move > 0: + minus_dm.append(down_move) + else: + minus_dm.append(0) + + # True Range + tr = max( + high - low, + abs(high - prev_close), + abs(low - prev_close) + ) + tr_list.append(tr) + + if len(tr_list) < period: + return 0 + + # 计算平滑值 + atr = sum(tr_list[-period:]) / period + smoothed_plus_dm = sum(plus_dm[-period:]) / period + smoothed_minus_dm = sum(minus_dm[-period:]) / period + + # 计算 +DI 和 -DI + if atr == 0: + return 0 + + plus_di = (smoothed_plus_dm / atr) * 100 + minus_di = (smoothed_minus_dm / atr) * 100 + + # 计算 DX + di_sum = plus_di + minus_di + if di_sum == 0: + return 0 + + dx = abs(plus_di - minus_di) / di_sum * 100 + + return dx + + def generate_trade_suggestion(self, symbol: str, pivots: List[Dict], + current_price: float) -> Optional[Dict]: + """ + 基于趋势和转折点生成交易建议 + + Args: + symbol: 交易品种 + pivots: 转折点数据 + current_price: 当前价格 + + Returns: + 交易建议 或 None + """ + symbol_key = normalize_symbol(symbol) + + # 获取趋势状态 + resonance = self.analyze_resonance(symbol) + + if resonance['resonance'] == 'none': + return None + + if resonance['strength'] < 30: + return None + + trend = resonance['resonance'] + + # 根据趋势找最近的转折点作为止损止盈 + recent_pivots = sorted(pivots, key=lambda x: x['timestamp'], reverse=True)[:10] + + sl = None + tp = None + action = None + reason = "" + + if trend == "up": + # 上升趋势,找最近的低点作为止损 + action = "b" + low_pivots = [p for p in recent_pivots if p['direction'] == 'low'] + if low_pivots: + # 找最近的低点作为止损 + sl = low_pivots[0]['price'] + # 止盈设为止损的1.5-2倍距离 + if sl and current_price > sl: + distance = current_price - sl + tp = current_price + distance * 1.5 + reason = f"多周期向上共振,建议买入,止损参考最近低点 {sl}" + else: + return None + + elif trend == "down": + # 下降趋势,找最近的高点作为止损 + action = "s" + high_pivots = [p for p in recent_pivots if p['direction'] == 'high'] + if high_pivots: + sl = high_pivots[0]['price'] + if sl and current_price < sl: + distance = sl - current_price + tp = current_price - distance * 1.5 + reason = f"多周期向下共振,建议卖出,止损参考最近高点 {sl}" + else: + return None + + if not all([action, sl, tp]): + return None + + return { + "symbol": symbol_key, + "action": action, + "price": current_price, + "sl": round(sl, 4), + "tp": round(tp, 4), + "reason": reason, + "trend_strength": resonance['strength'], + "resonance_periods": resonance['aligned_count'], + "generated_at": datetime.now().isoformat() + } \ No newline at end of file diff --git a/monitor_logs.sh b/monitor_logs.sh new file mode 100755 index 0000000..635b760 --- /dev/null +++ b/monitor_logs.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# 日志监控脚本 - 实时查看EA和后端服务的通信 + +echo "============================================================" +echo " EA与后端服务通信监控" +echo "============================================================" +echo "" +echo "监控内容:" +echo " - EA获取交易指令 (GET /get_trades)" +echo " - EA发送统计数据 (POST /send_statistics)" +echo " - 前端界面请求" +echo "" +echo "按 Ctrl+C 停止监控" +echo "============================================================" +echo "" + +# 实时监控后端日志 +tail -f /private/tmp/claude-501/-Users-wangxingxing--openclaw-workspace-lianghua/tasks/bpae1rf93.output | \ +while read line; do + # 高亮EA请求 + if echo "$line" | grep -q "GET /get_trades"; then + echo "🔴 [EA请求交易指令] $line" + elif echo "$line" | grep -q "POST /send_statistics"; then + echo "🟢 [EA发送统计数据] $line" + elif echo "$line" | grep -q "send_trade_instructions"; then + echo "🔵 [交易员下发指令] $line" + else + echo "$line" + fi +done \ No newline at end of file diff --git a/routes_ea.py b/routes_ea.py index 8af60b9..0121372 100644 --- a/routes_ea.py +++ b/routes_ea.py @@ -4,7 +4,7 @@ EA 相关的接口路由 """ -from fastapi import APIRouter, Query +from fastapi import APIRouter, Query, Request from typing import Optional, List, Dict from models import TradeInstruction from server import TradingServer @@ -15,7 +15,7 @@ def create_ea_routes(server: TradingServer) -> APIRouter: 创建 EA 相关路由 """ router = APIRouter() - + @router.get("/get_trades") async def get_trades( symbol: str = Query(..., description="交易品种"), @@ -23,11 +23,11 @@ def create_ea_routes(server: TradingServer) -> APIRouter: ) -> Dict: """ 获取指定SYMBOL的交易指令 - + 参数: - symbol: 交易品种 (e.g., "EURUSD") - price: 当前中间价格,用于条件过滤 - + 返回: ```json { @@ -40,21 +40,42 @@ def create_ea_routes(server: TradingServer) -> APIRouter: "sl": 1.0800, "tp": 1.0900 } + ], + "close_tickets": [123456, 789012], + "pivot_alerts": [ + { + "type": "pivot_alert", + "symbol": "EURUSD", + "period": "H4", + "direction": "high", + "pivot_price": 1.0900, + "current_price": 1.0880, + "distance_pct": 0.18, + "message": "EURUSD H4 接近高点 1.0900" + } ] } ``` """ - trades = server.get_trades_by_symbol(symbol, price) - return {"trades": trades} - + result = server.get_trades_by_symbol(symbol, price) + # 添加平仓指令 + result["close_tickets"] = server.get_close_position_instructions(symbol) + + # 打印完整返回数据用于调试 + import json + print(f"[EA API] 返回给EA的数据: {json.dumps(result, ensure_ascii=False)}") + + return result + @router.post("/send_statistics") - async def send_statistics(data: dict) -> Dict: + async def send_statistics(request: Request) -> Dict: """ 接收 EA 发送的统计数据 - + 参数 (JSON): ```json { + "symbol": "eurusd", "timestamp": "2024-01-15 14:30:45", "tickCount": 1234, "bidPrice": 1.0850, @@ -62,30 +83,11 @@ def create_ea_routes(server: TradingServer) -> APIRouter: "balance": 10000.00, "equity": 10500.50, "marginLevel": 150.0, - "positions": [ - { - "symbol": "eurusd", - "tickets": 123456, - "type": "buy", - "volume": 0.1, - "openPrice": 1.0800, - "takeProfit": 1.0900, - "stopLoss": 1.0750, - "profit": 50.00 - } - ], - "trades": [ - { - "tickets": 789012, - "symbol": "eurusd", - "action": "buy", - "openPrice": 1.0800, - "volume": 0.1 - } - ] + "positions": [], + "trades": [] } ``` - + 返回: ```json { @@ -94,7 +96,61 @@ def create_ea_routes(server: TradingServer) -> APIRouter: } ``` """ - server.save_statistics(data) - return {"status": "ok", "message": "统计数据已保存"} - - return router + # 获取原始请求体用于调试 + 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) + 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") + async def close_position(request: Request) -> Dict: + """ + 平仓指令 + + 请求体: + ```json + { + "ticket": 123456, + "symbol": "GOLD#" + } + ``` + + 返回: + ```json + { + "status": "ok", + "message": "平仓指令已添加" + } + ``` + """ + try: + data = await request.json() + ticket = data.get('ticket') + symbol = data.get('symbol', '').upper() + + if not ticket: + return {"status": "error", "message": "缺少订单号"} + + # 添加平仓指令到队列 + server.add_close_position_instruction(symbol, ticket) + + print(f"[EA API] 平仓指令已添加: {symbol} 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 diff --git a/routes_market.py b/routes_market.py new file mode 100644 index 0000000..1668997 --- /dev/null +++ b/routes_market.py @@ -0,0 +1,534 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +行情相关的接口路由 +包括K线数据接收、查询、WebSocket推送等 +""" + +from fastapi import APIRouter, Query, Request, WebSocket, WebSocketDisconnect +from fastapi.responses import JSONResponse +from typing import Optional, List, Dict +import json + +from market.store import MarketStore +from market.pivot_detector import PivotDetector +from market.monitor import PivotMonitor +from market.trend_analyzer import TrendAnalyzer +from market.pending_orders import PendingOrderManager + + +def create_market_routes(store: MarketStore, detector: PivotDetector, + monitor: PivotMonitor, trend_analyzer: TrendAnalyzer, + pending_orders: PendingOrderManager) -> APIRouter: + """ + 创建行情相关路由 + + Args: + store: K线存储 + detector: 转折点检测器 + monitor: 转折点监控器 + trend_analyzer: 趋势分析器 + pending_orders: 待确认订单管理器 + """ + router = APIRouter() + + # ==================== EA端接口 ==================== + + @router.post("/ea/kline/{period}") + async def receive_kline(period: str, request: Request) -> Dict: + """ + EA推送K线数据 + + Args: + period: 周期 (H4/H1/M15/M5/M1) + + 请求体: + ```json + { + "symbol": "GOLD", + "is_full": false, // 是否为全量数据 + "klines": [ + { + "timestamp": "2024-01-15 14:00:00", + "open": 2030.50, + "high": 2035.00, + "low": 2028.00, + "close": 2033.50, + "volume": 1234 + } + ] + } + ``` + + 返回: + - 成功: {"status": "ok", "count": N} + - 需要全量数据: {"status": "error", "code": 8888, "message": "需要全量数据"} + """ + period = period.upper() + + # 验证周期 + if period not in ['H4', 'H1', 'M15', 'M5', 'M1']: + return JSONResponse( + status_code=400, + content={"status": "error", "message": f"不支持的周期: {period}"} + ) + + try: + data = await request.json() + symbol = data.get('symbol', 'GOLD').upper() + is_full = data.get('is_full', False) + klines = data.get('klines', []) + + if not klines: + return {"status": "ok", "count": 0, "message": "无数据"} + + # 检查是否需要全量数据 + if not is_full and not store.is_initialized(symbol, period): + print(f"[MarketAPI] {symbol} {period} 未初始化,需要全量数据") + return JSONResponse( + status_code=400, + content={ + "status": "error", + "code": 8888, + "message": "需要全量数据" + } + ) + + # 保存K线数据 + result = store.save_klines(symbol, period, klines, is_full) + + if result['status'] == 'ok': + # 更新转折点 + all_klines = store.get_all_klines(symbol, period) + if all_klines: + # 转换为KlineData对象 + from market.store import KlineData + kline_objs = [ + KlineData( + symbol=k['symbol'], + period=k['period'], + timestamp=k['timestamp'], + open_price=k['open'], + high=k['high'], + low=k['low'], + close=k['close'], + volume=k['volume'] + ) + for k in all_klines + ] + detector.update_pivots(symbol, period, kline_objs) + + return result + + except Exception as e: + print(f"[MarketAPI] 接收K线数据异常: {e}") + return JSONResponse( + status_code=500, + content={"status": "error", "message": str(e)} + ) + + @router.post("/ea/kline_batch") + async def receive_kline_batch(request: Request) -> Dict: + """ + EA批量推送多个周期的K线数据 + + 请求体: + ```json + { + "symbol": "GOLD", + "is_full": true, + "data": { + "H4": [{...}, {...}], + "H1": [{...}, {...}], + "M15": [{...}, {...}], + "M5": [{...}, {...}], + "M1": [{...}, {...}] + } + } + ``` + """ + try: + data = await request.json() + symbol = data.get('symbol', 'GOLD').upper() + is_full = data.get('is_full', False) + kline_data = data.get('data', {}) + + results = {} + for period, klines in kline_data.items(): + period = period.upper() + if period not in ['H4', 'H1', 'M15', 'M5', 'M1']: + continue + + result = store.save_klines(symbol, period, klines, is_full) + results[period] = result + + # 更新转折点 + if result['status'] == 'ok': + all_klines = store.get_all_klines(symbol, period) + if all_klines: + from market.store import KlineData + kline_objs = [ + KlineData( + symbol=k['symbol'], + period=k['period'], + timestamp=k['timestamp'], + open_price=k['open'], + high=k['high'], + low=k['low'], + close=k['close'], + volume=k['volume'] + ) + for k in all_klines + ] + detector.update_pivots(symbol, period, kline_objs) + + return { + "status": "ok", + "symbol": symbol, + "results": results + } + + except Exception as e: + print(f"[MarketAPI] 批量接收K线数据异常: {e}") + return JSONResponse( + status_code=500, + content={"status": "error", "message": str(e)} + ) + + # ==================== 查询接口 ==================== + + @router.get("/market/kline/{symbol}") + async def get_kline( + symbol: str, + period: str = Query("M5", description="周期: H4/H1/M15/M5/M1"), + count: int = Query(100, description="返回条数") + ) -> Dict: + """ + 获取K线数据 + """ + symbol = symbol.upper() + period = period.upper() + + klines = store.get_klines(symbol, period, count) + + return { + "status": "ok", + "symbol": symbol, + "period": period, + "count": len(klines), + "data": klines + } + + @router.get("/market/pivots/{symbol}") + async def get_pivots( + symbol: str, + period: str = Query(None, description="周期,不指定则返回全部"), + direction: str = Query(None, description="方向: high/low"), + count: int = Query(50, description="返回条数") + ) -> Dict: + """ + 获取转折点数据 + """ + symbol = symbol.upper() + + if period: + period = period.upper() + pivots = detector.get_pivots(symbol, period, direction, count) + return { + "status": "ok", + "symbol": symbol, + "period": period, + "count": len(pivots), + "data": pivots + } + else: + # 返回所有周期的转折点 + result = {} + for p in ['H4', 'H1', 'M15', 'M5', 'M1']: + pivots = detector.get_pivots(symbol, p, direction, count) + if pivots: + result[p] = pivots + + return { + "status": "ok", + "symbol": symbol, + "data": result + } + + @router.get("/market/symbols") + async def get_symbols() -> Dict: + """ + 获取所有已存储数据的symbol列表 + """ + symbols = store.get_symbols() + return { + "status": "ok", + "symbols": symbols, + "count": len(symbols) + } + + @router.get("/market/status") + async def get_market_status() -> Dict: + """ + 获取行情存储状态 + """ + store_status = store.get_status() + detector_status = detector.get_status() + monitor_status = monitor.get_status() + + return { + "status": "ok", + "store": store_status, + "pivots": detector_status, + "monitor": monitor_status + } + + @router.get("/market/thresholds") + async def get_thresholds() -> Dict: + """ + 获取各周期的接近阈值 + """ + thresholds = detector.THRESHOLDS + + return { + "status": "ok", + "thresholds": { + period: { + "value": threshold, + "percent": f"{threshold * 100:.4f}%", + "description": f"千分之{threshold * 1000}" + } + for period, threshold in thresholds.items() + } + } + + # ==================== 趋势分析接口 ==================== + + @router.get("/trend/{symbol}") + async def get_trend(symbol: str) -> Dict: + """ + 获取单个品种的趋势分析 + """ + from market.store import KlineData + + # 分析每个周期的趋势 + for period in ['H4', 'H1', 'M15', 'M5', 'M1']: + all_klines = store.get_all_klines(symbol, period) + if all_klines: + kline_objs = [ + KlineData( + symbol=k['symbol'], + period=k['period'], + timestamp=k['timestamp'], + open_price=k['open'], + high=k['high'], + low=k['low'], + close=k['close'], + volume=k['volume'] + ) + for k in all_klines + ] + trend_analyzer.analyze_trend(symbol, period, kline_objs) + + # 获取共振分析 + resonance = trend_analyzer.analyze_resonance(symbol) + + # 获取趋势转换历史 + changes = trend_analyzer.get_trend_changes(symbol, 10) + + return { + "status": "ok", + "symbol": symbol, + "resonance": resonance, + "trend_changes": changes + } + + @router.post("/trend/generate_order/{symbol}") + async def generate_trade_order(symbol: str) -> Dict: + """ + 基于趋势分析生成交易建议 + """ + from market.store import KlineData + + # 更新趋势分析 + for period in ['H4', 'H1', 'M15', 'M5', 'M1']: + all_klines = store.get_all_klines(symbol, period) + if all_klines: + kline_objs = [ + KlineData( + symbol=k['symbol'], + period=k['period'], + timestamp=k['timestamp'], + open_price=k['open'], + high=k['high'], + low=k['low'], + close=k['close'], + volume=k['volume'] + ) + for k in all_klines + ] + trend_analyzer.analyze_trend(symbol, period, kline_objs) + + # 获取所有周期的转折点 + all_pivots = [] + for period in ['H4', 'H1', 'M15', 'M5', 'M1']: + pivot_list = detector.get_pivots(symbol, period, None, 20) + all_pivots.extend(pivot_list) + + # 获取当前价格 + current_price = store.get_latest_price(symbol) + if not current_price: + return {"status": "error", "message": "无法获取当前价格"} + + # 生成交易建议 + suggestion = trend_analyzer.generate_trade_suggestion(symbol, all_pivots, current_price) + + if not suggestion: + return { + "status": "ok", + "message": "当前无交易建议", + "resonance": trend_analyzer.analyze_resonance(symbol) + } + + # 添加到待确认订单 + order_id = pending_orders.add_order(suggestion) + + return { + "status": "ok", + "message": "交易建议已生成", + "order_id": order_id, + "suggestion": suggestion + } + + # ==================== 待确认订单接口 ==================== + + @router.get("/pending_orders") + async def get_pending_orders(symbol: Optional[str] = None) -> Dict: + """ + 获取待确认订单列表 + """ + orders = pending_orders.get_pending_orders(symbol) + return { + "status": "ok", + "count": len(orders), + "orders": orders + } + + @router.post("/pending_orders/{order_id}/confirm") + async def confirm_pending_order(order_id: str, request: Request = None) -> Dict: + """ + 确认待确认订单,可更新手数、止损、止盈 + """ + # 获取更新数据 + update_data = {} + if request: + try: + update_data = await request.json() + except: + pass + + # 更新订单参数 + if update_data: + order = pending_orders.get_order_by_id(order_id) + if order: + if 'mount' in update_data: + order['mount'] = update_data['mount'] + if 'sl' in update_data: + order['sl'] = update_data['sl'] + if 'tp' in update_data: + order['tp'] = update_data['tp'] + + order = pending_orders.confirm_order(order_id) + if not order: + return {"status": "error", "message": "订单不存在"} + + return { + "status": "ok", + "message": "订单已确认", + "order": order + } + + @router.post("/pending_orders/{order_id}/reject") + async def reject_pending_order(order_id: str) -> Dict: + """ + 拒绝待确认订单 + """ + success = pending_orders.reject_order(order_id) + if not success: + return {"status": "error", "message": "订单不存在"} + + return { + "status": "ok", + "message": "订单已拒绝" + } + + # ==================== 交易配置接口 ==================== + + @router.get("/trade_config") + async def get_trade_config() -> Dict: + """ + 获取交易配置 + """ + from market.monitor import TradeConfig + config = TradeConfig.get_instance() + return { + "status": "ok", + "config": config.to_dict() + } + + @router.post("/trade_config") + async def update_trade_config(request: Request) -> Dict: + """ + 更新交易配置 + """ + from market.monitor import TradeConfig + config = TradeConfig.get_instance() + + try: + data = await request.json() + config.update(data) + return { + "status": "ok", + "message": "配置已更新", + "config": config.to_dict() + } + except Exception as e: + return {"status": "error", "message": str(e)} + + # ==================== WebSocket接口 ==================== + + @router.websocket("/ws/market") + async def websocket_market(websocket: WebSocket): + """ + WebSocket连接,用于实时推送转折点提醒 + """ + await websocket.accept() + monitor.add_ws_client(websocket) + + try: + # 发送欢迎消息 + await websocket.send_text(json.dumps({ + "type": "connected", + "message": "已连接到行情监控服务" + })) + + # 保持连接,等待客户端消息或关闭 + while True: + try: + data = await websocket.receive_text() + # 可以处理客户端发来的消息 + msg = json.loads(data) + + if msg.get('type') == 'ping': + await websocket.send_text(json.dumps({"type": "pong"})) + + except WebSocketDisconnect: + break + + except Exception as e: + print(f"[WebSocket] 连接异常: {e}") + + finally: + monitor.remove_ws_client(websocket) + + return router \ No newline at end of file diff --git a/server.py b/server.py index c2b53a6..7271ed3 100644 --- a/server.py +++ b/server.py @@ -9,25 +9,72 @@ from typing import List, Dict, Optional import threading from models import TradeInstruction +from market.store import MarketStore, normalize_symbol +from market.pivot_detector import PivotDetector +from market.monitor import PivotMonitor +from market.trend_analyzer import TrendAnalyzer +from market.pending_orders import PendingOrderManager class TradingServer: """交易服务主类""" - + def __init__(self): # 交易指令队列 - 按SYMBOL分类 # 结构: {"SYMBOL1": [TradeInstruction1, ...], "SYMBOL2": [...]} self.trade_instructions = defaultdict(list) - + + # 平仓指令队列 - 按SYMBOL分类 + # 结构: {"SYMBOL1": [ticket1, ticket2, ...], ...} + self.close_position_instructions = defaultdict(list) + # 统计数据历史 - 保留最新10条 # 结构: deque([{stat_data1}, {stat_data2}, ...], maxlen=10) self.statistics_history = deque(maxlen=10) - + # 线程锁 - 确保线程安全 self.lock = threading.RLock() - + + # ==================== 行情模块 ==================== + # K线存储 + self.market_store = MarketStore() + # 转折点检测器 + self.pivot_detector = PivotDetector() + # 待确认订单管理器(需要在 PivotMonitor 之前初始化) + self.pending_orders = PendingOrderManager() + # 设置订单确认回调 + self.pending_orders.set_confirm_callback(self._on_order_confirmed) + # 转折点监控器 + self.pivot_monitor = PivotMonitor(self.market_store, self.pivot_detector, self.pending_orders) + # 趋势分析器 + self.trend_analyzer = TrendAnalyzer() + print("[信息] 交易服务已初始化") + def _on_order_confirmed(self, order: Dict): + """ + 订单确认回调 - 将确认的订单加入交易队列 + """ + print(f"[TradingServer] _on_order_confirmed 被调用,订单: {order}") + try: + # 创建交易指令 + instruction = TradeInstruction( + symbol=order.get('symbol', ''), + action=order.get('action', 'b'), + mount=order.get('mount', 0.01), + price=order.get('price', 0), + sl=order.get('sl', 0), + tp=order.get('tp', 0) + ) + print(f"[TradingServer] 创建交易指令: symbol={instruction.symbol}, action={instruction.action}, mount={instruction.mount}, price={instruction.price}, sl={instruction.sl}, tp={instruction.tp}") + # 添加到交易队列 + result = self.add_trade_instruction([instruction]) + print(f"[TradingServer] 订单已加入交易队列: {result}") + except Exception as e: + print(f"[TradingServer] 加入交易队列失败: {e}") + import traceback + traceback.print_exc() + def add_trade_instruction(self, instructions: List[TradeInstruction]) -> dict: """ 添加交易指令 @@ -76,59 +123,46 @@ class TradingServer: return {"added": added, "rejected": rejected} - def get_trades_by_symbol(self, symbol: str, price: Optional[float] = None) -> List[Dict]: + def get_trades_by_symbol(self, symbol: str, price: Optional[float] = None) -> Dict: """ 获取指定SYMBOL的交易指令并删除 - 根据价格条件过滤指令: - - 买入指令(action='b'):如果指令价格 > 当前价格,缓存(等待价格下跌到指令价格) - - 卖出指令(action='s'):如果指令价格 < 当前价格,缓存(等待价格上涨到指令价格) - 返回指令列表(JSON格式) + + 同时检查价格是否接近转折点,如果有则添加到返回结果中 + + 返回: {"trades": [...], "pivot_alerts": [...]} """ + # 先检查转折点 + pivot_alerts = [] + if price is not None: + # 统一转换为大写进行检测 + symbol_upper = symbol.upper() + pivot_alerts = self.pivot_monitor.check_and_alert(symbol_upper, price) + if pivot_alerts: + print(f"[信息] {symbol_upper} 当前价格 {price} 接近转折点") + with self.lock: symbol = symbol.upper() if symbol not in self.trade_instructions or len(self.trade_instructions[symbol]) == 0: - return [] - - # 获取所有指令 + return {"trades": [], "pivot_alerts": pivot_alerts} + + # 获取所有指令并直接返回(不再进行价格过滤) trades = self.trade_instructions[symbol] - result = [] - cached_trades = [] - - for t in trades: - should_send = True - - # 如果提供了价格,进行条件检查 - if price is not None: - if t.action.lower() == 'b': - # 买入指令:如果指令价格 > 当前价格,则缓存 - if t.price > price: - should_send = False - cached_trades.append(t) - elif t.action.lower() == 's': - # 卖出指令:如果指令价格 < 当前价格,则缓存 - if t.price < price: - should_send = False - cached_trades.append(t) - - if should_send: - result.append({ - "symbol": t.symbol.lower(), - "action": t.action.lower(), - "mount": t.mount, - "price": t.price, - "sl": t.sl, - "tp": t.tp - }) - - # 更新指令队列:移除已发送的,保留已缓存的 - self.trade_instructions[symbol] = cached_trades - + result = [{ + "symbol": t.symbol, + "action": t.action.lower(), + "mount": t.mount, + "price": t.price, + "sl": t.sl, + "tp": t.tp + } for t in trades] + + # 清空指令队列 + self.trade_instructions[symbol] = [] + if len(result) > 0: - print(f"[信息] 推送了 {len(result)} 条 {symbol} 指令给EA (当前价格: {price})") - if len(cached_trades) > 0: - print(f"[信息] 缓存了 {len(cached_trades)} 条 {symbol} 指令,等待价格条件满足") - - return result + print(f"[信息] 推送了 {len(result)} 条 {symbol} 指令给EA") + + return {"trades": result, "pivot_alerts": pivot_alerts} def save_statistics(self, stat_data: dict) -> None: """ @@ -157,7 +191,7 @@ class TradingServer: for symbol, trades in self.trade_instructions.items(): result[symbol] = [ { - "symbol": t.symbol.lower(), + "symbol": t.symbol, "action": t.action.lower(), "mount": t.mount, "price": t.price, @@ -187,3 +221,24 @@ class TradingServer: del self.trade_instructions[symbol] print(f"[信息] 已清空 {symbol} 的交易指令,共 {count} 条") return count + + def add_close_position_instruction(self, symbol: str, ticket: int) -> None: + """ + 添加平仓指令 + """ + with self.lock: + symbol = symbol.upper() + self.close_position_instructions[symbol].append(ticket) + print(f"[信息] 添加平仓指令: {symbol} ticket={ticket}") + + def get_close_position_instructions(self, symbol: str) -> List[int]: + """ + 获取并清空平仓指令 + """ + with self.lock: + symbol = symbol.upper() + tickets = self.close_position_instructions.get(symbol, []) + self.close_position_instructions[symbol] = [] + if tickets: + print(f"[信息] 返回平仓指令: {symbol} tickets={tickets}") + return tickets diff --git a/start.sh b/start.sh old mode 100644 new mode 100755 diff --git a/start_frontend.bat b/start_frontend.bat new file mode 100644 index 0000000..11f55d4 --- /dev/null +++ b/start_frontend.bat @@ -0,0 +1,72 @@ +@echo off +REM 前端启动脚本 (Windows) +REM 用于快速启动React前端 + +setlocal enabledelayedexpansion + +echo ================================================== +echo 量化交易服务 - 前端启动脚本 +echo ================================================== + +REM 检查Node.js版本 +node --version >nul 2>&1 +if errorlevel 1 ( + echo ❌ 错误: 未找到Node.js + echo 请先安装Node.js 16+版本 + echo 推荐使用: https://nodejs.org/ + pause + exit /b 1 +) + +for /f "tokens=*" %%i in ('node --version') do set NODE_VERSION=%%i +echo ✓ Node.js版本: %NODE_VERSION% + +REM 检查npm +npm --version >nul 2>&1 +if errorlevel 1 ( + echo ❌ 错误: 未找到npm + pause + exit /b 1 +) + +for /f "tokens=*" %%i in ('npm --version') do set NPM_VERSION=%%i +echo ✓ npm版本: %NPM_VERSION% + +REM 获取脚本目录 +cd /d "%~dp0" +cd frontend +echo ✓ 工作目录: %cd% + +REM 检查依赖 +echo → 检查依赖... +if not exist "node_modules" ( + echo → 安装依赖... + npm install + echo ✓ 依赖已安装 +) else ( + echo ✓ 依赖已存在 +) + +REM 显示启动信息 +echo. +echo ================================================== +echo 启动参数: +echo 开发服务器: http://localhost:3000 +echo 代理后端: http://localhost:8000 +echo 热重载: 启用 +echo ================================================== +echo. +echo ✓ 前端服务已启动! +echo. +echo 访问地址: +echo 前端界面: http://localhost:3000 +echo API文档: http://localhost:8000/docs +echo. +echo 注意:请确保后端服务 (python main.py) 已在运行 +echo ================================================== +echo. + +REM 启动开发服务器 +npm start + +pause \ No newline at end of file diff --git a/start_frontend.sh b/start_frontend.sh new file mode 100755 index 0000000..9af7662 --- /dev/null +++ b/start_frontend.sh @@ -0,0 +1,67 @@ +#!/bin/bash +# 前端启动脚本 +# 用于快速启动React前端 + +set -e + +echo "==================================================" +echo "量化交易服务 - 前端启动脚本" +echo "==================================================" + +# 检查Node.js版本 +if ! command -v node &> /dev/null; then + echo "❌ 错误: 未找到Node.js" + echo "请先安装Node.js 16+版本" + echo "推荐使用: https://nodejs.org/" + exit 1 +fi + +NODE_VERSION=$(node --version | sed 's/v//') +echo "✓ Node.js版本: $NODE_VERSION" + +# 检查npm +if ! command -v npm &> /dev/null; then + echo "❌ 错误: 未找到npm" + exit 1 +fi + +NPM_VERSION=$(npm --version) +echo "✓ npm版本: $NPM_VERSION" + +# 获取脚本目录 +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +cd "$SCRIPT_DIR/frontend" + +echo "✓ 工作目录: $(pwd)" + +# 检查依赖 +echo "→ 检查依赖..." +if [ ! -d "node_modules" ]; then + echo "→ 安装依赖..." + npm install + echo "✓ 依赖已安装" +else + echo "✓ 依赖已存在" +fi + +# 显示启动信息 +echo "" +echo "==================================================" +echo "启动参数:" +echo " 开发服务器: http://localhost:3000" +echo " 代理后端: http://localhost:8000" +echo " 热重载: 启用" +echo "==================================================" +echo "" +echo "✓ 前端服务已启动!" +echo "" +echo "访问地址:" +echo " 前端界面: http://localhost:3000" +echo " API文档: http://localhost:8000/docs" +echo "" +echo "注意:请确保后端服务 (python main.py) 已在运行" +echo "==================================================" +echo "" + +# 启动开发服务器 +npm run dev \ No newline at end of file diff --git a/wangxxGold.mq5 b/wangxxGold.mq5 index 694f9d6..d17edcb 100644 --- a/wangxxGold.mq5 +++ b/wangxxGold.mq5 @@ -5,27 +5,27 @@ //+------------------------------------------------------------------+ #property copyright "wwananggxxxx" #property link "https://www.mql5.com" -#property version "1.00" +#property version "2.00" #property strict //--- 需要访问Web请求权限 -#include -#include -#include -#include +#include +#include +#include +#include //+------------------------------------------------------------------+ //| 全局变量定义 | //+------------------------------------------------------------------+ // Python 服务配置 -string g_pythonServer = "http://localhost:5858"; +string g_pythonServer = "http://127.0.0.1:8000"; uint g_lastPythonRequestTime = 0; -int g_pythonRequestInterval = 100; // 毫秒 +uint g_pythonRequestInterval = 100; // 毫秒 // 统计数据 - 每分钟重置 datetime g_lastStatisticTime = 0; -int g_tickCountPerMinute = 0; +int g_tickCount = 0; double g_bidPrice = 0; double g_askPrice = 0; double g_accountBalance = 0; @@ -36,6 +36,16 @@ string g_positionsSummary = ""; // JSON 格式的持仓汇总 // 当日交易记录 - 用于发送到Python string g_tradesOfDay = ""; +// K线数据推送相关 +bool g_klineInitialized = false; // 是否已发送历史K线数据 +datetime g_lastKlinePushTime = 0; // 上次推送K线时间 +int g_klinePushInterval = 60; // K线推送间隔(秒) +datetime g_lastH4CloseTime = 0; // 上次H4 K线收盘时间 +datetime g_lastH1CloseTime = 0; // 上次H1 K线收盘时间 +datetime g_lastM15CloseTime = 0; // 上次M15 K线收盘时间 +datetime g_lastM5CloseTime = 0; // 上次M5 K线收盘时间 +datetime g_lastM1CloseTime = 0; // 上次M1 K线收盘时间 + // 交易类对象 CTrade trade; CSymbolInfo symbolInfo; @@ -44,6 +54,30 @@ CPositionInfo positionInfo; // 风险管理相关 double g_riskLimitPercent = 30.0; // 30% 账户风险限制 +//+------------------------------------------------------------------+ +//| URL编码函数 - 处理特殊字符 | +//+------------------------------------------------------------------+ +string URLEncode(string str) + { + string result = ""; + for(int i = 0; i < StringLen(str); i++) + { + ushort ch = StringGetCharacter(str, i); + // 字母、数字、连字符、下划线、点号不需要编码 + if((ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || + ch == '-' || ch == '_' || ch == '.') + { + result += CharToString(ch); + } + else + { + // 其他字符编码为 %XX 格式 + result += "%" + StringFormat("%02X", ch); + } + } + return result; + } + //+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ @@ -51,16 +85,21 @@ int OnInit() { //--- 初始化交易类 trade.SetExpertMagicNumber(123456); - + //--- 初始化时间 g_lastStatisticTime = TimeCurrent(); g_lastPythonRequestTime = GetTickCount(); - + g_lastKlinePushTime = TimeCurrent(); + //--- 打印初始化信息 Print("Expert initialized successfully"); Print("Python server: ", g_pythonServer); Print("Risk limit: ", g_riskLimitPercent, "%"); - + +//--- 启动时推送历史K线数据 + Print("Pushing historical K-line data..."); + PushAllKlineData(true); // is_full = true + //--- return(INIT_SUCCEEDED); } @@ -77,7 +116,7 @@ void OnDeinit(const int reason) //+------------------------------------------------------------------+ void UpdateStatistics() { - g_tickCountPerMinute++; + g_tickCount++; //--- 获取当前价格 MqlTick lastTick; @@ -157,7 +196,7 @@ void CheckAndCloseRiskyPositions() // 如果损失超过阈值,平仓 if(posProfit < -riskThreshold) { - long posTicket = PositionGetTicket(i); + long posTicket = PositionGetInteger(POSITION_TICKET); Print("Risk limit exceeded! Position profit: ", posProfit, " Limit: ", -riskThreshold); if(trade.PositionClose(posTicket)) @@ -180,17 +219,20 @@ void CheckAndCloseRiskyPositions() void RequestTradesFromPython() { string headers = "Content-Type: application/json\r\n"; - char responseData[]; + uchar responseData[]; string response = ""; + string outheaders = ""; int responseCode = 0; // 构建请求URL,携带SYMBOL和当前价格 string currentPrice = DoubleToString((g_bidPrice + g_askPrice) / 2, _Digits); - string url = g_pythonServer + "/get_trades?symbol=" + _Symbol + "&price=" + currentPrice; + string encodedSymbol = URLEncode(_Symbol); + string url = g_pythonServer + "/get_trades?symbol=" + encodedSymbol + "&price=" + currentPrice; // 建立HTTP请求到Python服务 - responseCode = WebRequest("GET", url, headers, NULL, responseData); - + uchar emptyData[]; + responseCode = WebRequest("GET", url, headers, 5000, emptyData, responseData, outheaders); // timeout设为5秒 + if(responseCode == 200) { // 将响应转换为字符串 @@ -200,7 +242,7 @@ void RequestTradesFromPython() { response += CharToString(responseData[i]); } - + // 解析JSON并执行交易 ParseAndExecuteTrades(response); } @@ -208,6 +250,18 @@ void RequestTradesFromPython() else if(responseCode != -1) // -1表示请求被禁用 { Print("WebRequest failed. Response code: ", responseCode); + Print("URL: ", url); + + // 打印错误详情 + if(responseCode == 404) + Print("Endpoint not found. Check server URL."); + else if(responseCode == 500) + Print("Server error. Check server logs."); + } + else if(responseCode == -1) + { + Print("WebRequest is disabled! Please enable WebRequest in MT5 Options -> Expert Advisors"); + Print("Make sure 'localhost' is added to the WebRequest allowed list"); } } @@ -216,31 +270,150 @@ void RequestTradesFromPython() //+------------------------------------------------------------------+ void ParseAndExecuteTrades(string jsonData) { - // JSON格式: [{"symbol":"gold","action":"b","mount":0.01,"sl":5000,"tp":5100}, ...] - // 这里需要简单的JSON解析 - + // JSON格式: {"trades": [...], "close_tickets": [...], "pivot_alerts": [...]} + // EA只处理trades和close_tickets,pivot_alerts由Python推送到前端 + if(StringLen(jsonData) == 0) return; - + + // 提取trades数组 + int tradesPos = StringFind(jsonData, "\"trades\":"); + if(tradesPos != -1) + { + int tradesStart = StringFind(jsonData, "[", tradesPos); + int tradesEnd = StringFind(jsonData, "]", tradesStart); + if(tradesStart != -1 && tradesEnd != -1) + { + string tradesJson = StringSubstr(jsonData, tradesStart, tradesEnd - tradesStart + 1); + // 如果trades数组不为空,打印出来 + if(tradesJson != "[]") + { + Print("[EA] 收到交易指令: ", tradesJson); + } + ParseTradeArray(tradesJson); + } + } + else + { + // 旧格式兼容:直接是数组 [...] + ParseTradeArray(jsonData); + } + + // 提取close_tickets数组并执行平仓 + int closePos = StringFind(jsonData, "\"close_tickets\":"); + if(closePos != -1) + { + int closeStart = StringFind(jsonData, "[", closePos); + int closeEnd = StringFind(jsonData, "]", closeStart); + if(closeStart != -1 && closeEnd != -1) + { + string closeJson = StringSubstr(jsonData, closeStart, closeEnd - closeStart + 1); + ParseAndExecuteClose(closeJson); + } + } + } + +//+------------------------------------------------------------------+ +//| 解析并执行平仓指令 | +//+------------------------------------------------------------------+ +void ParseAndExecuteClose(string jsonData) + { // 移除首尾的括号 - jsonData = StringSubstr(jsonData, 1, StringLen(jsonData) - 2); - + if(StringFind(jsonData, "[") == 0) + { + jsonData = StringSubstr(jsonData, 1, StringLen(jsonData) - 2); + } + + if(StringLen(jsonData) == 0) return; + + // 解析ticket列表 + string tickets[]; + int count = StringSplit(jsonData, ',', tickets); + + for(int i = 0; i < count; i++) + { + string ticketStr = tickets[i]; + ticketStr = StringTrimLeft(ticketStr); + ticketStr = StringTrimRight(ticketStr); + + long ticket = StringToInteger(ticketStr); + if(ticket > 0) + { + ClosePositionByTicket(ticket); + } + } + } + +//+------------------------------------------------------------------+ +//| 根据订单号平仓 | +//+------------------------------------------------------------------+ +void ClosePositionByTicket(long ticket) + { + // 查找持仓 + for(int i = 0; i < PositionsTotal(); i++) + { + 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); + } + +//+------------------------------------------------------------------+ +//| 解析交易数组 | +//+------------------------------------------------------------------+ +void ParseTradeArray(string jsonData) + { + // 移除首尾的括号 + if(StringFind(jsonData, "[") == 0) + { + jsonData = StringSubstr(jsonData, 1, StringLen(jsonData) - 2); + } + + if(StringLen(jsonData) == 0) return; + // 简单的JSON解析 int tradeCount = 0; int pos = -1; - + while(true) { int startPos = StringFind(jsonData, "{", pos + 1); int endPos = StringFind(jsonData, "}", startPos); - + if(startPos == -1 || endPos == -1) break; - + string tradeStr = StringSubstr(jsonData, startPos + 1, endPos - startPos - 1); ExecuteTradeFromJson(tradeStr); - + pos = endPos; tradeCount++; - + if(tradeCount > 100) break; // 防止无限循环 } } @@ -255,12 +428,24 @@ void ExecuteTradeFromJson(string tradeJson) double volume = ExtractJsonDouble(tradeJson, "mount"); double sl = ExtractJsonDouble(tradeJson, "sl"); double tp = ExtractJsonDouble(tradeJson, "tp"); - - if(symbol == "" || action == "" || volume <= 0) return; - if(symbol != _Symbol) return; // 只处理当前品种 - + + Print("[EA] 收到交易指令: symbol=", symbol, " action=", action, " volume=", volume, " sl=", sl, " tp=", tp); + + if(symbol == "" || action == "" || volume <= 0) + { + Print("[EA] 交易参数无效,跳过"); + return; + } + + if(symbol != _Symbol) + { + Print("[EA] Symbol不匹配,跳过。收到: ", symbol, " 当前品种: ", _Symbol); + return; + } + 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); } @@ -344,7 +529,7 @@ void ExecuteTrade(ENUM_ORDER_TYPE orderType, double volume, double sl, double tp if(trade.Buy(volume, _Symbol, 0, sl, tp, "Python AI Trade")) { Print("Buy order executed: Volume=", volume, " SL=", sl, " TP=", tp); - RecordTrade("BUY", _Symbol, volume, sl, tp, trade.OrderOpenPrice()); + RecordTrade("BUY", _Symbol, volume, sl, tp, trade.ResultPrice()); } else { @@ -356,7 +541,7 @@ void ExecuteTrade(ENUM_ORDER_TYPE orderType, double volume, double sl, double tp if(trade.Sell(volume, _Symbol, 0, sl, tp, "Python AI Trade")) { Print("Sell order executed: Volume=", volume, " SL=", sl, " TP=", tp); - RecordTrade("SELL", _Symbol, volume, sl, tp, trade.OrderOpenPrice()); + RecordTrade("SELL", _Symbol, volume, sl, tp, trade.ResultPrice()); } else { @@ -394,8 +579,9 @@ void SendMinuteStatistics() { // 构建统计JSON string statisticJson = "{"; + statisticJson += "\"symbol\":\"" + _Symbol + "\","; statisticJson += "\"timestamp\":\"" + TimeToString(TimeCurrent(), TIME_DATE | TIME_MINUTES) + "\","; - statisticJson += "\"tickCount\":" + IntegerToString(g_tickCountPerMinute) + ","; + statisticJson += "\"tickCount\":" + IntegerToString(g_tickCount) + ","; statisticJson += "\"bidPrice\":" + DoubleToString(g_bidPrice, _Digits) + ","; statisticJson += "\"askPrice\":" + DoubleToString(g_askPrice, _Digits) + ","; statisticJson += "\"balance\":" + DoubleToString(g_accountBalance, 2) + ","; @@ -418,11 +604,41 @@ void SendMinuteStatistics() void SendToPythonServer(string jsonData) { string headers = "Content-Type: application/json\r\n"; - char responseData[]; + uchar responseData[]; + string outheaders = ""; int responseCode = 0; - - responseCode = WebRequest("POST", g_pythonServer + "/send_statistics", headers, jsonData, responseData); - + + // 使用CharArrayToString确保正确转换,然后再转回uchar数组 + string jsonStr = jsonData; + uchar postData[]; + StringToCharArray(jsonStr, postData); + + // 移除StringToCharArray添加的null终止符 + int nullIndex = ArraySize(postData) - 1; + if(nullIndex >= 0 && postData[nullIndex] == 0) + { + ArrayResize(postData, nullIndex); + } + + int dataSize = ArraySize(postData); + + // 调试:打印发送的数据 + Print("Sending JSON data size: ", dataSize, " bytes"); + Print("JSON: ", jsonStr); + + // 修正: POST请求需要9个参数 (method, url, headers, cookie, timeout, data, dataSize, result, resultHeaders) + responseCode = WebRequest( + "POST", + g_pythonServer + "/send_statistics", + headers, + "", // cookie + 5000, // timeout (5秒) + postData, + dataSize, + responseData, + outheaders + ); + if(responseCode == 200) { Print("Statistics sent successfully"); @@ -430,6 +646,22 @@ void SendToPythonServer(string jsonData) else if(responseCode != -1) { Print("Failed to send statistics. Response code: ", responseCode); + + // 打印详细错误信息 + if(responseCode == -1) + { + Print("WebRequest is disabled! Please enable WebRequest in MT5 Options -> Expert Advisors"); + } + else + { + // 打印响应内容以便调试 + string responseText = ""; + for(int i = 0; i < ArraySize(responseData); i++) + { + responseText += CharToString(responseData[i]); + } + Print("Response: ", responseText); + } } } //+------------------------------------------------------------------+ @@ -439,19 +671,22 @@ void OnTick() { //--- 更新统计数据 UpdateStatistics(); - + +//--- 检查是否需要推送增量K线数据 + CheckAndPushIncrementalKlines(); + //--- 检查是否需要进行分钟级统计和发送 datetime now = TimeCurrent(); - if(now - g_lastStatisticTime >= 60) // 每分钟执行一次 + if(now - g_lastStatisticTime >= 6) // 每6秒执行一次 { SendMinuteStatistics(); g_lastStatisticTime = now; - g_tickCountPerMinute = 0; + g_tickCount = 0; } - + //--- 检查持仓风险并平仓 CheckAndCloseRiskyPositions(); - + //--- 每100毫秒请求一次Python服务 uint currentTime = GetTickCount(); if((currentTime - g_lastPythonRequestTime) >= g_pythonRequestInterval) @@ -461,3 +696,221 @@ void OnTick() } } //+------------------------------------------------------------------+ + +//+------------------------------------------------------------------+ +//| K线数据相关函数 | +//+------------------------------------------------------------------+ + +//+------------------------------------------------------------------+ +//| 推送所有周期的K线数据 | +//+------------------------------------------------------------------+ +bool PushAllKlineData(bool isFull) + { + bool success = true; + + // 推送各周期K线数据 + // H4: 6个月约1100根 + if(!PushKlineData(PERIOD_H4, isFull ? 1100 : 1)) + success = false; + + // H1: 1个月约720根 + if(!PushKlineData(PERIOD_H1, isFull ? 720 : 1)) + success = false; + + // M15: 3天约288根 + if(!PushKlineData(PERIOD_M15, isFull ? 288 : 1)) + success = false; + + // M5: 24小时约288根 + if(!PushKlineData(PERIOD_M5, isFull ? 288 : 1)) + success = false; + + // M1: 1小时约60根 + if(!PushKlineData(PERIOD_M1, isFull ? 60 : 1)) + success = false; + + if(success && isFull) + { + g_klineInitialized = true; + Print("Historical K-line data pushed successfully"); + } + + return success; + } + +//+------------------------------------------------------------------+ +//| 推送单个周期的K线数据 | +//+------------------------------------------------------------------+ +bool PushKlineData(ENUM_TIMEFRAMES period, int count) + { + MqlRates rates[]; + ArraySetAsSeries(rates, true); + + // 获取K线数据 + int copied = CopyRates(_Symbol, period, 0, count, rates); + if(copied <= 0) + { + Print("Failed to get K-line data for period: ", PeriodToString(period)); + return false; + } + + // 构建JSON + string klineJson = BuildKlineJson(period, rates, copied); + + // 发送到Python服务 + string periodStr = PeriodToString(period); + string url = g_pythonServer + "/ea/kline/" + periodStr; + + return SendKlineToServer(url, klineJson); + } + +//+------------------------------------------------------------------+ +//| 构建K线JSON数据 | +//+------------------------------------------------------------------+ +string BuildKlineJson(ENUM_TIMEFRAMES period, MqlRates &rates[], int count) + { + string json = "{\"symbol\":\"" + _Symbol + "\","; + json += "\"is_full\":" + (g_klineInitialized ? "false" : "true") + ","; + json += "\"klines\":["; + + for(int i = count - 1; i >= 0; i--) // 从旧到新排序 + { + if(i < count - 1) json += ","; + json += "{"; + json += "\"timestamp\":\"" + TimeToString(rates[i].time, TIME_DATE | TIME_MINUTES) + "\","; + json += "\"open\":" + DoubleToString(rates[i].open, _Digits) + ","; + json += "\"high\":" + DoubleToString(rates[i].high, _Digits) + ","; + json += "\"low\":" + DoubleToString(rates[i].low, _Digits) + ","; + json += "\"close\":" + DoubleToString(rates[i].close, _Digits) + ","; + json += "\"volume\":" + DoubleToString(rates[i].tick_volume, 0); + json += "}"; + } + + json += "]}"; + return json; + } + +//+------------------------------------------------------------------+ +//| 发送K线数据到服务器 | +//+------------------------------------------------------------------+ +bool SendKlineToServer(string url, string jsonData) + { + string headers = "Content-Type: application/json\r\n"; + uchar responseData[]; + uchar postData[]; + string outheaders = ""; + int responseCode = 0; + + StringToCharArray(jsonData, postData); + int nullIndex = ArraySize(postData) - 1; + if(nullIndex >= 0 && postData[nullIndex] == 0) + { + ArrayResize(postData, nullIndex); + } + + int dataSize = ArraySize(postData); + + responseCode = WebRequest( + "POST", + url, + headers, + "", + 10000, // 10秒超时 + postData, + dataSize, + responseData, + outheaders + ); + + if(responseCode == 200) + { + return true; + } + else if(responseCode == 400) + { + // 检查是否是8888错误码(需要全量数据) + string responseText = ""; + for(int i = 0; i < ArraySize(responseData); i++) + { + responseText += CharToString(responseData[i]); + } + + if(StringFind(responseText, "8888") >= 0) + { + Print("Server needs full K-line data, resending..."); + g_klineInitialized = false; + PushAllKlineData(true); + } + return false; + } + else + { + Print("Failed to push K-line data. Response code: ", responseCode); + return false; + } + } + +//+------------------------------------------------------------------+ +//| 检查并推送增量K线数据 | +//+------------------------------------------------------------------+ +void CheckAndPushIncrementalKlines() + { + datetime now = TimeCurrent(); + datetime barTime; + + // 检查H4 K线是否有新周期 + barTime = iTime(_Symbol, PERIOD_H4, 0); + if(barTime != 0 && barTime != g_lastH4CloseTime) + { + g_lastH4CloseTime = barTime; + if(g_klineInitialized) PushKlineData(PERIOD_H4, 1); + } + + // 检查H1 K线 + barTime = iTime(_Symbol, PERIOD_H1, 0); + if(barTime != 0 && barTime != g_lastH1CloseTime) + { + g_lastH1CloseTime = barTime; + if(g_klineInitialized) PushKlineData(PERIOD_H1, 1); + } + + // 检查M15 K线 + barTime = iTime(_Symbol, PERIOD_M15, 0); + if(barTime != 0 && barTime != g_lastM15CloseTime) + { + g_lastM15CloseTime = barTime; + if(g_klineInitialized) PushKlineData(PERIOD_M15, 1); + } + + // 检查M5 K线 + barTime = iTime(_Symbol, PERIOD_M5, 0); + if(barTime != 0 && barTime != g_lastM5CloseTime) + { + g_lastM5CloseTime = barTime; + if(g_klineInitialized) PushKlineData(PERIOD_M5, 1); + } + + // 检查M1 K线 + barTime = iTime(_Symbol, PERIOD_M1, 0); + if(barTime != 0 && barTime != g_lastM1CloseTime) + { + g_lastM1CloseTime = barTime; + if(g_klineInitialized) PushKlineData(PERIOD_M1, 1); + } + } + +//+------------------------------------------------------------------+ +//| 周期转换为字符串 | +//+------------------------------------------------------------------+ +string PeriodToString(ENUM_TIMEFRAMES period) + { + switch(period) + { + case PERIOD_H4: return "H4"; + case PERIOD_H1: return "H1"; + case PERIOD_M15: return "M15"; + case PERIOD_M5: return "M5"; + case PERIOD_M1: return "M1"; + default: return "M5"; + } + }