From 2b8f9025486c3e4e35578e9463272e1aefa2c3e7 Mon Sep 17 00:00:00 2001 From: dekun Date: Sat, 16 May 2026 22:25:48 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A6=96=E6=AC=A1=E4=B8=8A=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 35 + CLONE.md | 167 ++ README.md | 157 ++ gate_order_executor/.gitignore | 8 + gate_order_executor/README.md | 60 + gate_order_executor/app/__init__.py | 1 + .../app/breakeven_active_store.py | 147 ++ gate_order_executor/app/breakeven_logic.py | 287 ++++ .../app/breakeven_prefs_store.py | 96 ++ gate_order_executor/app/breakeven_watcher.py | 243 +++ gate_order_executor/app/config.py | 137 ++ gate_order_executor/app/executor.py | 89 + gate_order_executor/app/gate_auth.py | 37 + gate_order_executor/app/gate_futures_live.py | 439 +++++ gate_order_executor/app/gate_history.py | 319 ++++ gate_order_executor/app/gate_operations.py | 263 +++ .../app/gate_price_rounding.py | 49 + gate_order_executor/app/main.py | 708 ++++++++ gate_order_executor/app/models_signal.py | 23 + gate_order_executor/app/models_test.py | 14 + gate_order_executor/app/oco_watcher.py | 143 ++ gate_order_executor/app/positions.py | 52 + gate_order_executor/app/proxy_util.py | 39 + gate_order_executor/app/risk_prefs_store.py | 63 + gate_order_executor/app/signal_history.py | 21 + gate_order_executor/app/signal_metrics.py | 100 ++ gate_order_executor/app/signal_repository.py | 204 +++ gate_order_executor/app/stats.py | 315 ++++ gate_order_executor/app/wecom_notify.py | 181 +++ gate_order_executor/config.example.yaml | 63 + gate_order_executor/deploy/README.md | 17 + gate_order_executor/deploy/bootstrap.sh | 30 + .../deploy/ecosystem.config.cjs | 38 + .../deploy/gate-order-executor.service | 18 + gate_order_executor/deploy/pm2-delete.sh | 9 + gate_order_executor/deploy/pm2-restart.sh | 8 + gate_order_executor/deploy/pm2-start.sh | 28 + gate_order_executor/deploy/pm2-stop.sh | 8 + gate_order_executor/deploy/start.sh | 10 + gate_order_executor/docs/使用说明.md | 218 +++ gate_order_executor/docs/部署说明.md | 189 +++ gate_order_executor/requirements.txt | 8 + gate_order_executor/run.py | 16 + gate_order_executor/static/exec.js | 1427 ++++++++++++++++ gate_order_executor/static/style.css | 874 ++++++++++ .../static/theme-matrix-terminal.css | 664 ++++++++ gate_order_executor/templates/dashboard.html | 463 ++++++ gate_order_executor/templates/login.html | 104 ++ .../tests/test_breakeven_logic.py | 57 + .../tests/test_price_rounding.py | 58 + onchain_scout_gate/README.md | 111 ++ onchain_scout_gate/app/__init__.py | 2 + onchain_scout_gate/app/btc_regime.py | 108 ++ onchain_scout_gate/app/chart_candles.py | 71 + onchain_scout_gate/app/config.py | 167 ++ onchain_scout_gate/app/daily_features.py | 112 ++ onchain_scout_gate/app/daily_report.py | 176 ++ onchain_scout_gate/app/exchange_rules.py | 145 ++ onchain_scout_gate/app/gate.py | 173 ++ onchain_scout_gate/app/gemma_client.py | 155 ++ onchain_scout_gate/app/main.py | 50 + onchain_scout_gate/app/models.py | 41 + onchain_scout_gate/app/monitor.py | 771 +++++++++ onchain_scout_gate/app/notifier.py | 199 +++ .../app/order_executor_forward.py | 174 ++ .../app/order_executors_store.py | 272 ++++ onchain_scout_gate/app/proxy_util.py | 16 + onchain_scout_gate/app/storage.py | 156 ++ onchain_scout_gate/app/time_cn.py | 21 + onchain_scout_gate/app/web.py | 626 +++++++ onchain_scout_gate/config.example.yaml | 73 + onchain_scout_gate/deploy/bootstrap.sh | 19 + .../deploy/ecosystem.config.cjs | 37 + .../deploy/onchain-scout.service | 18 + onchain_scout_gate/deploy/start.sh | 9 + .../docs/多执行器与信号转发归档.md | 209 +++ .../docs/本地部署-SOCKS5代理.md | 233 +++ onchain_scout_gate/requirements.txt | 15 + onchain_scout_gate/run.py | 2 + .../scripts/backtest_box_breakout.py | 428 +++++ onchain_scout_gate/static/app.js | 620 +++++++ onchain_scout_gate/static/style.css | 1438 +++++++++++++++++ onchain_scout_gate/templates/dashboard.html | 229 +++ onchain_scout_gate/templates/login.html | 30 + .../tests/test_order_executors_store.py | 75 + onchain_scout_gate/交易系统部署说明.md | 206 +++ onchain_scout_gate/安装与说明.md | 67 + 部署说明.md | 428 +++++ 88 files changed, 16386 insertions(+) create mode 100644 .gitignore create mode 100644 CLONE.md create mode 100644 README.md create mode 100644 gate_order_executor/.gitignore create mode 100644 gate_order_executor/README.md create mode 100644 gate_order_executor/app/__init__.py create mode 100644 gate_order_executor/app/breakeven_active_store.py create mode 100644 gate_order_executor/app/breakeven_logic.py create mode 100644 gate_order_executor/app/breakeven_prefs_store.py create mode 100644 gate_order_executor/app/breakeven_watcher.py create mode 100644 gate_order_executor/app/config.py create mode 100644 gate_order_executor/app/executor.py create mode 100644 gate_order_executor/app/gate_auth.py create mode 100644 gate_order_executor/app/gate_futures_live.py create mode 100644 gate_order_executor/app/gate_history.py create mode 100644 gate_order_executor/app/gate_operations.py create mode 100644 gate_order_executor/app/gate_price_rounding.py create mode 100644 gate_order_executor/app/main.py create mode 100644 gate_order_executor/app/models_signal.py create mode 100644 gate_order_executor/app/models_test.py create mode 100644 gate_order_executor/app/oco_watcher.py create mode 100644 gate_order_executor/app/positions.py create mode 100644 gate_order_executor/app/proxy_util.py create mode 100644 gate_order_executor/app/risk_prefs_store.py create mode 100644 gate_order_executor/app/signal_history.py create mode 100644 gate_order_executor/app/signal_metrics.py create mode 100644 gate_order_executor/app/signal_repository.py create mode 100644 gate_order_executor/app/stats.py create mode 100644 gate_order_executor/app/wecom_notify.py create mode 100644 gate_order_executor/config.example.yaml create mode 100644 gate_order_executor/deploy/README.md create mode 100644 gate_order_executor/deploy/bootstrap.sh create mode 100644 gate_order_executor/deploy/ecosystem.config.cjs create mode 100644 gate_order_executor/deploy/gate-order-executor.service create mode 100644 gate_order_executor/deploy/pm2-delete.sh create mode 100644 gate_order_executor/deploy/pm2-restart.sh create mode 100644 gate_order_executor/deploy/pm2-start.sh create mode 100644 gate_order_executor/deploy/pm2-stop.sh create mode 100644 gate_order_executor/deploy/start.sh create mode 100644 gate_order_executor/docs/使用说明.md create mode 100644 gate_order_executor/docs/部署说明.md create mode 100644 gate_order_executor/requirements.txt create mode 100644 gate_order_executor/run.py create mode 100644 gate_order_executor/static/exec.js create mode 100644 gate_order_executor/static/style.css create mode 100644 gate_order_executor/static/theme-matrix-terminal.css create mode 100644 gate_order_executor/templates/dashboard.html create mode 100644 gate_order_executor/templates/login.html create mode 100644 gate_order_executor/tests/test_breakeven_logic.py create mode 100644 gate_order_executor/tests/test_price_rounding.py create mode 100644 onchain_scout_gate/README.md create mode 100644 onchain_scout_gate/app/__init__.py create mode 100644 onchain_scout_gate/app/btc_regime.py create mode 100644 onchain_scout_gate/app/chart_candles.py create mode 100644 onchain_scout_gate/app/config.py create mode 100644 onchain_scout_gate/app/daily_features.py create mode 100644 onchain_scout_gate/app/daily_report.py create mode 100644 onchain_scout_gate/app/exchange_rules.py create mode 100644 onchain_scout_gate/app/gate.py create mode 100644 onchain_scout_gate/app/gemma_client.py create mode 100644 onchain_scout_gate/app/main.py create mode 100644 onchain_scout_gate/app/models.py create mode 100644 onchain_scout_gate/app/monitor.py create mode 100644 onchain_scout_gate/app/notifier.py create mode 100644 onchain_scout_gate/app/order_executor_forward.py create mode 100644 onchain_scout_gate/app/order_executors_store.py create mode 100644 onchain_scout_gate/app/proxy_util.py create mode 100644 onchain_scout_gate/app/storage.py create mode 100644 onchain_scout_gate/app/time_cn.py create mode 100644 onchain_scout_gate/app/web.py create mode 100644 onchain_scout_gate/config.example.yaml create mode 100644 onchain_scout_gate/deploy/bootstrap.sh create mode 100644 onchain_scout_gate/deploy/ecosystem.config.cjs create mode 100644 onchain_scout_gate/deploy/onchain-scout.service create mode 100644 onchain_scout_gate/deploy/start.sh create mode 100644 onchain_scout_gate/docs/多执行器与信号转发归档.md create mode 100644 onchain_scout_gate/docs/本地部署-SOCKS5代理.md create mode 100644 onchain_scout_gate/requirements.txt create mode 100644 onchain_scout_gate/run.py create mode 100644 onchain_scout_gate/scripts/backtest_box_breakout.py create mode 100644 onchain_scout_gate/static/app.js create mode 100644 onchain_scout_gate/static/style.css create mode 100644 onchain_scout_gate/templates/dashboard.html create mode 100644 onchain_scout_gate/templates/login.html create mode 100644 onchain_scout_gate/tests/test_order_executors_store.py create mode 100644 onchain_scout_gate/交易系统部署说明.md create mode 100644 onchain_scout_gate/安装与说明.md create mode 100644 部署说明.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5d83f6e --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# 根目录 monorepo:扫描端 + 执行器 + +# Python +**/__pycache__/ +**/*.py[cod] +**/.venv/ +**/venv/ +**/*.egg-info/ +**/.pytest_cache/ + +# 本地配置(勿提交密钥) +**/config.yaml +!**/config.example.yaml + +# 运行时与数据 +**/runtime/*.log +**/runtime/*.db +**/runtime/*.sqlite +**/runtime/*.json +**/runtime/pm2-*.log +**/runtime/backtest_*.csv + +# 保留 example 与空目录占位(若有) +!**/runtime/.gitkeep + +# IDE / OS +.idea/ +.vscode/ +*.swp +.DS_Store +Thumbs.db + +# 环境变量 +.env +.env.* diff --git a/CLONE.md b/CLONE.md new file mode 100644 index 0000000..1cbcc53 --- /dev/null +++ b/CLONE.md @@ -0,0 +1,167 @@ +# Git 克隆与仓库说明 + +**远程仓库:** https://git.bz121.com/dekun/gate_scout_order.git +([dekun/gate_scout_order](https://git.bz121.com/dekun/gate_scout_order) · Gitea) + +**完整部署(含升级备份):** 见 [部署说明.md](部署说明.md) + +`gate_scout_order` 目录采用 **monorepo**:一个 Git 仓库内包含两个子项目,无需分别克隆。 + +```text +gate_scout_order/ ← 仓库根目录(git clone 落点,目录名可自定) +├── onchain_scout_gate/ ← 扫描端 +├── gate_order_executor/ ← 执行器 +├── README.md +├── 部署说明.md +├── CLONE.md ← 本文件 +└── .gitignore +``` + +--- + +## 一、从远程克隆(推荐) + +### HTTPS + +```bash +cd /opt +git clone https://git.bz121.com/dekun/gate_scout_order.git gate_scout_order +cd gate_scout_order +``` + +### SSH(已配置 git.bz121.com 公钥时) + +```bash +cd /opt +git clone git@git.bz121.com:dekun/gate_scout_order.git gate_scout_order +cd gate_scout_order +``` + +### 指定分支 + +```bash +git clone -b main https://git.bz121.com/dekun/gate_scout_order.git gate_scout_order +cd gate_scout_order +``` + +--- + +## 二、克隆后首次配置 + +两个子项目 **各自** 需要虚拟环境与 `config.yaml`(不要共用 `.venv`)。 + +### 扫描端 + +```bash +cd onchain_scout_gate +python -m venv .venv +``` + +Windows PowerShell: + +```powershell +.\.venv\Scripts\Activate.ps1 +pip install -U pip +pip install -r requirements.txt +copy config.example.yaml config.yaml +``` + +Linux / macOS: + +```bash +source .venv/bin/activate +pip install -U pip +pip install -r requirements.txt +cp config.example.yaml config.yaml +``` + +编辑 `config.yaml` 后运行:`python run.py` + +### 执行器 + +```bash +cd ../gate_order_executor +python -m venv .venv +``` + +同样激活 venv、`pip install -r requirements.txt`、`cp config.example.yaml config.yaml`,编辑后 `python run.py`。 + +--- + +## 三、本机尚无远程仓库时 + +若当前只有本地文件夹、尚未 `git init`,可在 **`onchain_scout` 根目录** 执行: + +```bash +cd /path/to/onchain_scout +git init +git add . +git commit -m "Initial commit: onchain_scout_gate + gate_order_executor" +``` + +在 GitHub / Gitee 创建 **空仓库**(不要勾选自动 README),再: + +```bash +git branch -M main +git remote add origin +git push -u origin main +``` + +之后他人即可按 **第一节** 克隆。 + +--- + +## 四、更新已克隆的仓库 + +```bash +cd onchain_scout +git pull +``` + +若子项目依赖有变,分别在两个目录重新安装: + +```bash +cd onchain_scout_gate && pip install -r requirements.txt +cd ../gate_order_executor && pip install -r requirements.txt +``` + +修改 `config.yaml` 后重启对应进程(如 `pm2 restart onchain-scout` / `pm2 restart gate-order-executor`)。 + +--- + +## 五、不应提交的文件 + +根目录 `.gitignore` 已忽略常见敏感与运行时文件,例如: + +- 各子项目下的 `config.yaml`(保留 `config.example.yaml`) +- `.venv/`、`__pycache__/` +- `runtime/` 下日志、SQLite、`order_executors.json` 等 + +克隆后 **必须** 自行复制 example 配置并填写密钥,否则服务无法正常工作。 + +--- + +## 六、两个独立仓库(可选,非默认) + +若你希望扫描端与执行器 **分开版本管理**,可拆成两个远程仓库;联动时仍通过 HTTP + 相同 `webhook_secret` 对接。 +**当前推荐布局为 monorepo**,面板多执行器列表、归档文档均按此结构编写。 + +拆仓后克隆方式示例: + +```bash +git clone onchain_scout_gate +git clone gate_order_executor +mkdir onchain_scout && mv onchain_scout_gate gate_order_executor onchain_scout/ +``` + +需自行保证目录结构与本文一致,以便对照 [README.md](README.md) 部署。 + +--- + +## 七、相关文档 + +| 文档 | 说明 | +|------|------| +| [README.md](README.md) | 总览、端口、快速开始 | +| [onchain_scout_gate/交易系统部署说明.md](onchain_scout_gate/交易系统部署说明.md) | 扫描端 PM2 / 云部署 | +| [gate_order_executor/docs/部署说明.md](gate_order_executor/docs/部署说明.md) | 执行器 PM2 / 云部署 | diff --git a/README.md b/README.md new file mode 100644 index 0000000..39a3c05 --- /dev/null +++ b/README.md @@ -0,0 +1,157 @@ +# gate_scout_order(onchain_scout) + +Gate.io **USDT 永续** 量化辅助工具集:包含 **扫描监控端** 与 **下单执行器** 两个独立 Python 服务,可同机或分机部署,通过 HTTP 联动。 + +**Git 仓库:** https://git.bz121.com/dekun/gate_scout_order.git + +```text +gate_scout_order/ # git clone 后的根目录(名称可自定) +├── onchain_scout_gate/ # 扫描端 · 行情监控、企微告警、信号转发(默认 :8088) +├── gate_order_executor/ # 执行器 · 接信号、Gate 下单、面板风控(默认 :8090+) +├── 部署说明.md # ★ 完整部署 + 升级前备份清单 +├── CLONE.md +└── README.md # 本文件 +``` + +--- + +## 两个子项目 + +| 子目录 | 作用 | 默认端口 | 详细说明 | +|--------|------|----------|----------| +| [`onchain_scout_gate/`](onchain_scout_gate/) | 7×24 扫描 Gate 永续;5m 箱体突破;企微推送;可选 Gemma 漏斗;**面板维护多执行器转发列表** | `8088` | [onchain_scout_gate/README.md](onchain_scout_gate/README.md) | +| [`gate_order_executor/`](gate_order_executor/) | 接收 `POST /v1/signal`;市价开仓 + 计划止盈/止损;盈亏比门槛;移动保本;持仓面板 | `8090`(多账户可 `8091`…) | [gate_order_executor/README.md](gate_order_executor/README.md) | + +--- + +## 协作关系 + +```mermaid +flowchart LR + gate[Gate.io 公共行情 API] + scout[onchain_scout_gate] + wecom[企业微信] + ex1[gate_order_executor 实例 A] + ex2[gate_order_executor 实例 B] + gate --> scout + scout --> wecom + scout -->|同一 signal 广播| ex1 + scout -->|可选| ex2 + ex1 --> gate2[Gate 私有 API 账户 A] + ex2 --> gate3[Gate 私有 API 账户 B] +``` + +1. 扫描端发现 **TRIGGER** 且通过推送门控 → **企业微信** 告警。 +2. 企微成功后 → 向面板中 **已启用** 的执行器 `POST /v1/signal`(方案 A 止盈/止损,同一 `signal_id`)。 +3. 各执行器自行决定是否接单(最低盈亏比等),**规则不在扫描端区分**。 +4. 转发请求 **不走** 扫描端 `proxy`,直连各执行器 `base_url`。 + +设计归档:[onchain_scout_gate/docs/多执行器与信号转发归档.md](onchain_scout_gate/docs/多执行器与信号转发归档.md) + +--- + +## Git 克隆 + +```bash +git clone https://git.bz121.com/dekun/gate_scout_order.git gate_scout_order +cd gate_scout_order +``` + +- 克隆步骤摘要:[CLONE.md](CLONE.md) +- **完整部署、升级备份、PM2、多执行器、关代理**:[部署说明.md](部署说明.md) + +--- + +## 快速开始(本机) + +### 1. 扫描端 + +```bash +cd onchain_scout_gate +python -m venv .venv +# Windows: .\.venv\Scripts\Activate.ps1 +# Linux: source .venv/bin/activate +pip install -r requirements.txt +cp config.example.yaml config.yaml +# 编辑 config.yaml:wecom、gate、proxy、auth +python run.py +``` + +- 面板:`http://127.0.0.1:8088/dashboard` +- 部署:[onchain_scout_gate/交易系统部署说明.md](onchain_scout_gate/交易系统部署说明.md) +- 本地 SOCKS:[onchain_scout_gate/docs/本地部署-SOCKS5代理.md](onchain_scout_gate/docs/本地部署-SOCKS5代理.md) + +### 2. 执行器(可先 dry_run) + +```bash +cd gate_order_executor +python -m venv .venv +pip install -r requirements.txt +cp config.example.yaml config.yaml +# 编辑 config.yaml:security.webhook_secret、gate(dry_run: true 联调) +python run.py +``` + +- 健康检查:`http://127.0.0.1:8090/health` +- 面板:`http://127.0.0.1:8090/dashboard` +- 部署:[gate_order_executor/docs/部署说明.md](gate_order_executor/docs/部署说明.md) + +### 3. 串联 + +1. 执行器 `security.webhook_secret` 与扫描端面板 **「下单执行器」** 里填写的 Webhook 密钥 **一致**。 +2. 扫描端面板添加执行器:`http://127.0.0.1:8090`,打开总开关。 +3. 执行器 `gate.dry_run: false` 并配置子账户 API 后才会实盘下单。 + +--- + +## 多执行器 / 对照实验 + +- 在扫描端 Web **「下单执行器 · 转发链」** 添加多条 Base URL(如 `:8090`、`:8091`)。 +- 每个 URL 对应 **独立进程 + 独立 Gate API**;盈亏比、移动保本在各执行器自己的面板/配置中修改。 +- 列表保存在 `onchain_scout_gate/runtime/order_executors.json`,**仅扫描端维护**。 + +--- + +## 云服务器:关闭代理 + +本机开发若使用 `proxy.enabled: true`(SOCKS),迁到 **可直连** `api.gateio.ws` 的境外云主机后,扫描端与 **每个** 执行器均应设: + +```yaml +proxy: + enabled: false +``` + +说明见: + +- [onchain_scout_gate/交易系统部署说明.md §8](onchain_scout_gate/交易系统部署说明.md) +- [gate_order_executor/docs/部署说明.md §6.1](gate_order_executor/docs/部署说明.md) + +--- + +## 文档索引 + +| 主题 | 路径 | +|------|------| +| **完整部署与备份** | **[部署说明.md](部署说明.md)** | +| 克隆与分支 | [CLONE.md](CLONE.md) | +| 多执行器归档 | [onchain_scout_gate/docs/多执行器与信号转发归档.md](onchain_scout_gate/docs/多执行器与信号转发归档.md) | +| 扫描端安装 | [onchain_scout_gate/安装与说明.md](onchain_scout_gate/安装与说明.md) | +| 执行器使用 | [gate_order_executor/docs/使用说明.md](gate_order_executor/docs/使用说明.md) | + +--- + +## 环境要求 + +- Python **3.10+**(推荐 3.11) +- 访问 Gate.io API(扫描端可用公共行情;执行器需 API Key) +- 可选:Node.js + PM2(Linux 生产部署) +- 可选:本地 Ollama(扫描端 Gemma 漏斗) +- 走 SOCKS 时需 `httpx[socks]` / `socksio`(见各子项目 `requirements.txt`) + +--- + +## 安全提示 + +- **勿** 将含 `config.yaml`、API 密钥、`runtime/` 数据库的目录提交到公开仓库。 +- 执行器端口(8090 等)建议仅本机或内网访问;公网请 Nginx + HTTPS + 鉴权。 +- `webhook_secret` 修改后须同步到 **所有** 执行器实例。 diff --git a/gate_order_executor/.gitignore b/gate_order_executor/.gitignore new file mode 100644 index 0000000..730ad30 --- /dev/null +++ b/gate_order_executor/.gitignore @@ -0,0 +1,8 @@ +.venv/ +__pycache__/ +*.pyc +.pytest_cache/ +config.yaml +runtime/ +.env +*.log diff --git a/gate_order_executor/README.md b/gate_order_executor/README.md new file mode 100644 index 0000000..53d2375 --- /dev/null +++ b/gate_order_executor/README.md @@ -0,0 +1,60 @@ +# gate_order_executor + +> 仓库总览与 Git 克隆见上级目录:[../README.md](../README.md)、[../CLONE.md](../CLONE.md)。 + +与 `onchain_scout_gate`(MATRIX 扫描)**并列**的独立服务:接收结构化信号;在 **`gate.dry_run: false`** 且配置 API 密钥时,向 Gate USDT 永续发 **市价开仓 + 计划止盈/止损**(详见 [使用说明.md](docs/使用说明.md) §3.4.1)。扫描进程继续只用公共 API;本服务持有 API Key(仅本机 `config.yaml`)。 + +## 文档索引 + +| 文档 | 内容 | +|------|------| +| [docs/使用说明.md](docs/使用说明.md) | 职责、配置项、面板、接口、与扫描协作 | +| [docs/部署说明.md](docs/部署说明.md) | Ubuntu 安装、PM2、systemd、防火墙、同机部署 | +| [deploy/README.md](deploy/README.md) | `deploy/` 下各脚本与 service 文件说明 | + +## 目录位置(示例) + +- `山寨币扫描/onchain_scout_gate/` — 监控 + 企业微信 +- `山寨币扫描/gate_order_executor/` — 本下单执行器 + +## 本地快速运行 + +```bash +cd gate_order_executor +python3 -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +cp config.example.yaml config.yaml +# 编辑 config.yaml +python run.py +``` + +- 健康检查:`GET http://127.0.0.1:8090/health` +- 面板:`http://127.0.0.1:8090/dashboard`(**勿**用资源管理器直接打开 `templates/*.html`,否则为白底无样式;须先 `python run.py`)。详见 [使用说明.md](docs/使用说明.md)。 + +## 生产部署(PM2) + +```bash +chmod +x deploy/*.sh +bash deploy/bootstrap.sh /root/gate_order_executor +# 编辑 config.yaml 后: +bash deploy/pm2-start.sh +``` + +详见 **[docs/部署说明.md](docs/部署说明.md)**。 + +## 信号接口(摘要) + +`POST /v1/signal`,请求头 `X-Webhook-Secret` 与 `config.yaml` 中 `security.webhook_secret` 一致。JSON 字段:`signal_id`、`contract`(如 `BTC_USDT`)、`side`(`long`/`short`)、`take_profit`、`stop_loss`(方案 A),可选 `reference_price`。 + +**`gate.dry_run: true`** 时只记日志、不下单;**`false`** 且填写 `api_key` / `api_secret` 时走实盘(务必子账户、IP 白名单、先在小额上验证)。完整说明见 [使用说明.md](docs/使用说明.md)。 + +```bash +curl -sS -X POST http://127.0.0.1:8090/v1/signal \ + -H "Content-Type: application/json" \ + -H "X-Webhook-Secret: your-secret" \ + -d '{"signal_id":"demo-1","contract":"BTC_USDT","side":"long","take_profit":99000,"stop_loss":97000}' +``` + +## 代理与 SOCKS + +`proxy` 配置与扫描项目一致;SOCKS 需安装 `socksio`。详见 [使用说明.md](docs/使用说明.md) §3.6。 diff --git a/gate_order_executor/app/__init__.py b/gate_order_executor/app/__init__.py new file mode 100644 index 0000000..9cd5be0 --- /dev/null +++ b/gate_order_executor/app/__init__.py @@ -0,0 +1 @@ +# Gate order executor (separate from onchain_scout_gate scanner). diff --git a/gate_order_executor/app/breakeven_active_store.py b/gate_order_executor/app/breakeven_active_store.py new file mode 100644 index 0000000..bc1eaa1 --- /dev/null +++ b/gate_order_executor/app/breakeven_active_store.py @@ -0,0 +1,147 @@ +"""移动保本运行态:登记 entry/initial_sl/sl_order_id,平仓后清除。""" + +from __future__ import annotations + +import json +import logging +import threading +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +_ROOT = Path(__file__).resolve().parent.parent +_ACTIVE_PATH = _ROOT / "runtime" / "breakeven_active.json" +_lock = threading.Lock() + + +def _now_iso() -> str: + return datetime.now(timezone.utc).astimezone().isoformat() + + +def _read_all_unlocked() -> dict[str, Any]: + if not _ACTIVE_PATH.is_file(): + return {} + try: + raw = _ACTIVE_PATH.read_text(encoding="utf-8").strip() + if not raw: + return {} + data = json.loads(raw) + if not isinstance(data, dict): + return {} + return {str(k).strip().upper(): v for k, v in data.items() if isinstance(v, dict)} + except (OSError, json.JSONDecodeError) as exc: + logger.warning("breakeven_active_read_failed: %s", exc) + return {} + + +def _write_all_unlocked(rows: dict[str, Any]) -> None: + _ACTIVE_PATH.parent.mkdir(parents=True, exist_ok=True) + payload = json.dumps(rows, indent=2, ensure_ascii=False) + "\n" + tmp = _ACTIVE_PATH.with_suffix(".json.tmp") + tmp.write_text(payload, encoding="utf-8") + tmp.replace(_ACTIVE_PATH) + + +def read_all_active() -> dict[str, dict[str, Any]]: + with _lock: + return dict(_read_all_unlocked()) + + +def get_active(contract: str) -> dict[str, Any] | None: + ct = contract.strip().upper() + with _lock: + row = _read_all_unlocked().get(ct) + return dict(row) if isinstance(row, dict) else None + + +def upsert_active( + contract: str, + *, + side: str, + entry: float, + initial_sl: float, + sl_order_id: str, + moved: bool = False, + status: str = "waiting_1r", +) -> dict[str, Any]: + ct = contract.strip().upper() + row: dict[str, Any] = { + "side": str(side).lower(), + "entry": float(entry), + "initial_sl": float(initial_sl), + "sl_order_id": str(sl_order_id).strip(), + "moved": bool(moved), + "status": status, + "registered_at": _now_iso(), + } + with _lock: + all_rows = _read_all_unlocked() + prev = all_rows.get(ct) + if isinstance(prev, dict) and prev.get("registered_at"): + row["registered_at"] = prev["registered_at"] + if isinstance(prev, dict) and prev.get("moved"): + row["moved"] = bool(prev["moved"]) + row["status"] = prev.get("status") or row["status"] + all_rows[ct] = row + _write_all_unlocked(all_rows) + return row + + +def mark_unregistrable(contract: str) -> None: + ct = contract.strip().upper() + with _lock: + all_rows = _read_all_unlocked() + all_rows[ct] = { + "status": "cannot_register", + "moved": False, + "registered_at": _now_iso(), + } + _write_all_unlocked(all_rows) + + +def mark_moved(contract: str, *, new_sl_order_id: str, breakeven_sl: str) -> None: + ct = contract.strip().upper() + with _lock: + all_rows = _read_all_unlocked() + row = all_rows.get(ct) + if not isinstance(row, dict): + return + row["moved"] = True + row["status"] = "moved" + row["sl_order_id"] = str(new_sl_order_id).strip() + row["breakeven_sl"] = str(breakeven_sl) + row["moved_at"] = _now_iso() + all_rows[ct] = row + _write_all_unlocked(all_rows) + + +def update_sl_order_id(contract: str, sl_order_id: str) -> None: + ct = contract.strip().upper() + with _lock: + all_rows = _read_all_unlocked() + row = all_rows.get(ct) + if not isinstance(row, dict): + return + row["sl_order_id"] = str(sl_order_id).strip() + all_rows[ct] = row + _write_all_unlocked(all_rows) + + +def remove_active(contract: str) -> None: + ct = contract.strip().upper() + with _lock: + all_rows = _read_all_unlocked() + if ct in all_rows: + del all_rows[ct] + _write_all_unlocked(all_rows) + + +def remove_all_except(contracts: set[str]) -> None: + keep = {c.strip().upper() for c in contracts if c} + with _lock: + all_rows = _read_all_unlocked() + filtered = {k: v for k, v in all_rows.items() if k in keep} + if filtered != all_rows: + _write_all_unlocked(filtered) diff --git a/gate_order_executor/app/breakeven_logic.py b/gate_order_executor/app/breakeven_logic.py new file mode 100644 index 0000000..c5ad741 --- /dev/null +++ b/gate_order_executor/app/breakeven_logic.py @@ -0,0 +1,287 @@ +"""移动保本:1R 判断、保本价、从计划单/信号登记。""" +from __future__ import annotations + +import logging +from typing import Any + +from .breakeven_active_store import get_active, mark_unregistrable, upsert_active +from .breakeven_prefs_store import read_effective_enabled +from .config import Settings +from .gate_futures_live import GateFuturesLive, _float, post_stop_loss_price_order +from .gate_price_rounding import _format_trigger_price, _trigger_price_tick +from .models_signal import TradeSignal + +logger = logging.getLogger(__name__) + + +def sl_trigger_rule_for_side(side: str) -> int: + return 2 if side == "long" else 1 + + +def risk_distance(side: str, entry: float, initial_sl: float) -> float | None: + if entry <= 0 or initial_sl <= 0: + return None + if side == "long": + dist = entry - initial_sl + elif side == "short": + dist = initial_sl - entry + else: + return None + return dist if dist > 0 else None + + +def is_1r_reached( + side: str, + mark: float, + entry: float, + initial_sl: float, + *, + trigger_r: float, +) -> bool: + dist = risk_distance(side, entry, initial_sl) + if dist is None or mark <= 0: + return False + target = trigger_r * dist + if side == "long": + return mark >= entry + target + if side == "short": + return mark <= entry - target + return False + + +def breakeven_sl_price(side: str, entry: float, buffer_pct: float) -> float | None: + if entry <= 0 or buffer_pct < 0: + return None + if side == "long": + return entry * (1.0 + buffer_pct) + if side == "short": + return entry * (1.0 - buffer_pct) + return None + + +def sl_already_at_or_better(side: str, current_sl: float, target_sl: float) -> bool: + if current_sl <= 0 or target_sl <= 0: + return False + if side == "long": + return current_sl >= target_sl + if side == "short": + return current_sl <= target_sl + return False + + +def _order_id_from_plan(plan: dict[str, Any]) -> str | None: + oid = plan.get("order_id") + if oid is None: + return None + s = str(oid).strip() + return s or None + + +def find_sl_plan( + side: str, + contract: str, + open_plans: list[dict[str, Any]], +) -> tuple[str | None, float | None]: + """从 open 计划单中识别止损腿,返回 (order_id, trigger_price)。""" + ct = contract.strip().upper() + want_rule = sl_trigger_rule_for_side(side) + candidates: list[tuple[str, float]] = [] + for p in open_plans: + if str(p.get("contract") or "").strip().upper() != ct: + continue + try: + rule = int(p.get("rule")) + except (TypeError, ValueError): + continue + if rule != want_rule: + continue + try: + px = float(str(p.get("trigger_price") or "").strip()) + except ValueError: + continue + if px <= 0: + continue + oid = _order_id_from_plan(p) + if oid: + candidates.append((oid, px)) + if not candidates: + return None, None + # 多仓 SL 在 entry 下方取最高触发价;空仓 SL 在 entry 上方取最低 + if side == "long": + oid, px = max(candidates, key=lambda x: x[1]) + else: + oid, px = min(candidates, key=lambda x: x[1]) + return oid, px + + +def _gate_order_id(obj: Any) -> str | None: + if not isinstance(obj, dict): + return None + oid = obj.get("id") + if oid is None: + oid = obj.get("id_string") + if oid is None: + return None + s = str(oid).strip() + return s or None + + +def register_from_execution_result(settings: Settings, sig: TradeSignal, result: dict[str, Any]) -> None: + if result.get("status") != "accepted": + return + contract = sig.contract.strip().upper() + if not read_effective_enabled(settings, contract): + return + entry = _float(result.get("reference_entry")) + sl_sent = result.get("stop_loss_price_sent") + try: + initial_sl = float(sl_sent) if sl_sent is not None else float(sig.stop_loss) + except (TypeError, ValueError): + mark_unregistrable(contract) + return + sl_order = result.get("stop_loss_order") + sl_id = _gate_order_id(sl_order) + if entry <= 0 or initial_sl <= 0 or not sl_id: + mark_unregistrable(contract) + return + upsert_active( + contract, + side=str(sig.side).lower(), + entry=entry, + initial_sl=initial_sl, + sl_order_id=sl_id, + moved=False, + status="waiting_1r", + ) + logger.info("breakeven_registered contract=%s entry=%s initial_sl=%s", contract, entry, initial_sl) + + +def register_from_signal_db_row(row: dict[str, Any], sl_order_id: str) -> dict[str, Any] | None: + res = row.get("result") if isinstance(row.get("result"), dict) else {} + sig = row.get("signal") if isinstance(row.get("signal"), dict) else {} + if res.get("status") != "accepted": + return None + entry = _float(res.get("reference_entry")) + sl_sent = res.get("stop_loss_price_sent") + try: + initial_sl = float(sl_sent) if sl_sent is not None else float(sig.get("stop_loss") or 0) + except (TypeError, ValueError): + return None + side = str(sig.get("side") or res.get("side") or "").lower() + if entry <= 0 or initial_sl <= 0 or side not in ("long", "short") or not sl_order_id: + return None + return { + "side": side, + "entry": entry, + "initial_sl": initial_sl, + "sl_order_id": sl_order_id, + } + + +async def try_register_existing_position( + settings: Settings, + *, + contract: str, + side: str, + open_plans: list[dict[str, Any]], + signal_repo: Any | None, +) -> bool: + """有持仓但 active 无记录时尝试登记;失败则 cannot_register。返回是否已登记。""" + ct = contract.strip().upper() + existing = get_active(ct) + if existing: + return existing.get("status") != "cannot_register" + + sl_id, sl_px = find_sl_plan(side, ct, open_plans) + if not sl_id or sl_px is None: + mark_unregistrable(ct) + return False + + entry: float | None = None + initial_sl: float | None = sl_px + + if signal_repo is not None: + try: + db_row = signal_repo.find_latest_accepted_for_contract(ct) + except Exception: # noqa: BLE001 + logger.exception("breakeven_signal_db_lookup_failed contract=%s", ct) + db_row = None + if db_row: + reg = register_from_signal_db_row(db_row, sl_id) + if reg: + entry = reg["entry"] + initial_sl = reg["initial_sl"] + side = reg["side"] + + if entry is None or entry <= 0: + mark_unregistrable(ct) + return False + + upsert_active( + ct, + side=side, + entry=entry, + initial_sl=float(initial_sl), + sl_order_id=sl_id, + moved=False, + status="waiting_1r", + ) + logger.info("breakeven_registered_existing contract=%s", ct) + return True + + +async def move_sl_to_breakeven( + settings: Settings, + *, + contract: str, + side: str, + entry: float, + initial_sl: float, + sl_order_id: str, + mark_price: float, + open_plans: list[dict[str, Any]], +) -> tuple[bool, str | None, str | None]: + """撤旧 SL 并挂保本+缓冲止损。成功返回 (True, breakeven_sl_str, new_sl_order_id)。""" + if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()): + return False, "missing_api_keys", None + if settings.gate.dry_run: + return False, "dry_run_enabled", None + + be = settings.risk.breakeven_stop + target = breakeven_sl_price(side, entry, float(be.buffer_pct)) + if target is None: + return False, "invalid_breakeven_price", None + + client = GateFuturesLive(settings) + cdata = await client._public_get(f"{client._prefix}/contracts/{contract.strip().upper()}") + if not isinstance(cdata, dict): + return False, "contract_not_found", None + tick = _trigger_price_tick(cdata) + target_s = _format_trigger_price(target, tick) + + sl_id_plan, current_sl_px = find_sl_plan(side, contract, open_plans) + use_id = sl_order_id or sl_id_plan or "" + if current_sl_px is not None and sl_already_at_or_better(side, current_sl_px, float(target_s)): + keep_id = use_id or sl_id_plan or "" + return True, target_s, keep_id or None + + from .gate_futures_live import cancel_price_triggered_order + from .oco_watcher import update_oco_sl_order_id + + if use_id: + try: + await cancel_price_triggered_order(client, use_id) + except Exception as exc: # noqa: BLE001 + return False, f"cancel_sl_failed:{exc}", None + + try: + resp = await post_stop_loss_price_order(client, contract=contract, side=side, sl_price=target_s) + except Exception as exc: # noqa: BLE001 + return False, f"post_sl_failed:{exc}", None + + new_id = _gate_order_id(resp) + if not new_id: + return False, "post_sl_no_id", None + + await update_oco_sl_order_id(settings, contract=contract, new_sl_id=new_id) + return True, target_s, new_id diff --git a/gate_order_executor/app/breakeven_prefs_store.py b/gate_order_executor/app/breakeven_prefs_store.py new file mode 100644 index 0000000..3b8399e --- /dev/null +++ b/gate_order_executor/app/breakeven_prefs_store.py @@ -0,0 +1,96 @@ +"""移动保本偏好:全局与单合约开关,持久化 runtime/breakeven_prefs.json。""" + +from __future__ import annotations + +import json +import logging +import threading +from pathlib import Path +from typing import Any + +from .config import Settings + +logger = logging.getLogger(__name__) + +_ROOT = Path(__file__).resolve().parent.parent +_PREFS_PATH = _ROOT / "runtime" / "breakeven_prefs.json" +_lock = threading.Lock() + + +def _read_file() -> dict[str, Any]: + if not _PREFS_PATH.is_file(): + return {} + try: + raw = _PREFS_PATH.read_text(encoding="utf-8").strip() + if not raw: + return {} + data = json.loads(raw) + return data if isinstance(data, dict) else {} + except (OSError, json.JSONDecodeError) as exc: + logger.warning("breakeven_prefs_read_failed: %s", exc) + return {} + + +def _write_file(data: dict[str, Any]) -> None: + _PREFS_PATH.parent.mkdir(parents=True, exist_ok=True) + payload = json.dumps(data, indent=2, ensure_ascii=False) + "\n" + tmp = _PREFS_PATH.with_suffix(".json.tmp") + tmp.write_text(payload, encoding="utf-8") + tmp.replace(_PREFS_PATH) + + +def read_prefs_snapshot() -> dict[str, Any]: + with _lock: + return dict(_read_file()) + + +def read_effective_global_enabled(settings: Settings) -> bool: + base = bool(settings.risk.breakeven_stop.enabled) + with _lock: + data = _read_file() + if "global_enabled" not in data: + return base + return bool(data.get("global_enabled")) + + +def read_contract_override(contract: str) -> bool | None: + ct = contract.strip().upper() + with _lock: + data = _read_file() + contracts = data.get("contracts") + if not isinstance(contracts, dict): + return None + row = contracts.get(ct) + if not isinstance(row, dict) or "enabled" not in row: + return None + return bool(row.get("enabled")) + + +def read_effective_enabled(settings: Settings, contract: str) -> bool: + ov = read_contract_override(contract) + if ov is not None: + return ov + return read_effective_global_enabled(settings) + + +def write_global_enabled(value: bool) -> bool: + with _lock: + data = _read_file() + data["global_enabled"] = bool(value) + _write_file(data) + return bool(value) + + +def write_contract_enabled(contract: str, value: bool) -> tuple[str, bool]: + ct = contract.strip().upper() + if not ct: + raise ValueError("empty_contract") + with _lock: + data = _read_file() + contracts = data.get("contracts") + if not isinstance(contracts, dict): + contracts = {} + contracts[ct] = {"enabled": bool(value)} + data["contracts"] = contracts + _write_file(data) + return ct, bool(value) diff --git a/gate_order_executor/app/breakeven_watcher.py b/gate_order_executor/app/breakeven_watcher.py new file mode 100644 index 0000000..3d543a8 --- /dev/null +++ b/gate_order_executor/app/breakeven_watcher.py @@ -0,0 +1,243 @@ +"""移动保本后台轮询:达 1R 后撤旧 SL、挂保本+缓冲(仅一次)。""" +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from .breakeven_active_store import ( + get_active, + mark_moved, + mark_unregistrable, + read_all_active, + remove_active, + remove_all_except, +) +from .breakeven_logic import ( + is_1r_reached, + move_sl_to_breakeven, + try_register_existing_position, +) +from .breakeven_prefs_store import read_effective_enabled +from .config import Settings +from .gate_futures_live import _float +from .gate_operations import list_futures_positions, list_open_price_orders +from .wecom_notify import notify_breakeven_failed + +logger = logging.getLogger(__name__) + +_task: asyncio.Task[None] | None = None +_settings: Settings | None = None +_signal_repo: Any | None = None + + +def _live_ok(settings: Settings) -> bool: + g = settings.gate + return (not g.dry_run) and bool(g.api_key.strip() and g.api_secret.strip()) + + +def start_breakeven_watcher(settings: Settings, signal_repo: Any | None = None) -> None: + global _task, _settings, _signal_repo + _settings = settings + _signal_repo = signal_repo + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return + if _task is not None and not _task.done(): + return + _task = loop.create_task(_poll_loop(), name="breakeven_stop_watcher") + logger.info( + "breakeven_watcher_started poll=%ss enabled_default=%s", + settings.risk.breakeven_stop.poll_interval_sec, + settings.risk.breakeven_stop.enabled, + ) + + +async def stop_breakeven_watcher() -> None: + global _task + if _task is None: + return + _task.cancel() + try: + await _task + except asyncio.CancelledError: + pass + _task = None + + +async def _poll_loop() -> None: + assert _settings is not None + interval = float(_settings.risk.breakeven_stop.poll_interval_sec) + while True: + await asyncio.sleep(interval) + try: + await _tick(_settings, _signal_repo) + except asyncio.CancelledError: + raise + except Exception: # noqa: BLE001 + logger.exception("breakeven_watcher_tick_failed") + + +def _position_side(size: float) -> str | None: + if size > 1e-12: + return "long" + if size < -1e-12: + return "short" + return None + + +async def _tick(settings: Settings, signal_repo: Any | None) -> None: + if not _live_ok(settings): + return + + positions, pos_err = await list_futures_positions(settings) + if pos_err or not isinstance(positions, list): + return + + open_contracts: set[str] = set() + pos_by_contract: dict[str, dict[str, Any]] = {} + for row in positions: + ct = str(row.get("contract") or "").strip().upper() + sz = _float(row.get("size")) + if not ct or abs(sz) <= 1e-12: + continue + open_contracts.add(ct) + pos_by_contract[ct] = row + + remove_all_except(open_contracts) + + plans, _ = await list_open_price_orders(settings) + plan_list = plans if isinstance(plans, list) else [] + + be_cfg = settings.risk.breakeven_stop + trigger_r = float(be_cfg.trigger_r) + + for ct, prow in pos_by_contract.items(): + if not read_effective_enabled(settings, ct): + continue + + sz = _float(prow.get("size")) + side = _position_side(sz) + if not side: + continue + + mark = _float(prow.get("mark_price")) + active = get_active(ct) + + if not active or active.get("status") == "cannot_register": + if not active or active.get("status") != "cannot_register": + await try_register_existing_position( + settings, + contract=ct, + side=side, + open_plans=plan_list, + signal_repo=signal_repo, + ) + active = get_active(ct) + if not active or active.get("status") == "cannot_register": + continue + + if active.get("moved") or active.get("status") == "moved": + continue + + entry = _float(active.get("entry")) + initial_sl = _float(active.get("initial_sl")) + sl_order_id = str(active.get("sl_order_id") or "").strip() + reg_side = str(active.get("side") or side).lower() + + if entry <= 0 or initial_sl <= 0 or not sl_order_id: + mark_unregistrable(ct) + continue + + if not is_1r_reached(reg_side, mark, entry, initial_sl, trigger_r=trigger_r): + continue + + ok, be_px, new_sl_id = await move_sl_to_breakeven( + settings, + contract=ct, + side=reg_side, + entry=entry, + initial_sl=initial_sl, + sl_order_id=sl_order_id, + mark_price=mark, + open_plans=plan_list, + ) + if ok: + mark_moved( + ct, + new_sl_order_id=str(new_sl_id or sl_order_id), + breakeven_sl=str(be_px or ""), + ) + logger.info("breakeven_moved contract=%s sl=%s", ct, be_px) + else: + logger.warning("breakeven_move_failed contract=%s detail=%s", ct, be_px) + try: + await notify_breakeven_failed( + settings, + contract=ct, + detail=str(be_px or "unknown"), + ) + except Exception: # noqa: BLE001 + logger.exception("wecom_notify_breakeven_failed") + + # 清除已无持仓的 active(remove_all_except 已处理;显式删 cannot_register 残留) + for ct in list(read_all_active().keys()): + if ct not in open_contracts: + remove_active(ct) + + +async def build_breakeven_state_for_api( + settings: Settings, + *, + exchange_positions: list[dict[str, Any]] | None, +) -> dict[str, Any]: + from .breakeven_prefs_store import read_effective_global_enabled, read_prefs_snapshot + + be = settings.risk.breakeven_stop + prefs = read_prefs_snapshot() + active = read_all_active() + per_pos: list[dict[str, Any]] = [] + + for row in exchange_positions or []: + ct = str(row.get("contract") or "").strip().upper() + if not ct: + continue + sz = _float(row.get("size")) + if abs(sz) <= 1e-12: + continue + enabled = read_effective_enabled(settings, ct) + act = active.get(ct) or {} + st = "disabled" + if not enabled: + st = "disabled" + elif act.get("status") == "cannot_register": + st = "cannot_register" + elif act.get("moved") or act.get("status") == "moved": + st = "moved" + elif act.get("entry"): + st = "waiting_1r" + else: + st = "pending_register" + per_pos.append( + { + "contract": ct, + "effective_enabled": enabled, + "status": st, + "breakeven_sl": act.get("breakeven_sl"), + } + ) + + return { + "config": { + "enabled_default": bool(be.enabled), + "trigger_r": float(be.trigger_r), + "buffer_pct": float(be.buffer_pct), + "poll_interval_sec": float(be.poll_interval_sec), + }, + "global_enabled": read_effective_global_enabled(settings), + "global_enabled_config_default": bool(be.enabled), + "contracts": prefs.get("contracts") if isinstance(prefs.get("contracts"), dict) else {}, + "active": active, + "positions": per_pos, + } diff --git a/gate_order_executor/app/config.py b/gate_order_executor/app/config.py new file mode 100644 index 0000000..d4ade84 --- /dev/null +++ b/gate_order_executor/app/config.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +from pathlib import Path + +import yaml +from pydantic import BaseModel, Field, field_validator + + +class AppConfig(BaseModel): + host: str = "127.0.0.1" + port: int = 8090 + log_file: str = "./runtime/executor.log" + session_secret: str = "please-change-session-secret" + + +class AuthConfig(BaseModel): + """与扫描端一致:enabled=false 时仅建议局域网使用。""" + + enabled: bool = False + username: str = "admin" + password: str = "changeme" + + +class SecurityConfig(BaseModel): + webhook_secret: str = "" + + +class GateConfig(BaseModel): + api_base: str = "https://api.gateio.ws/api/v4" + settle: str = "usdt" + api_key: str = "" + api_secret: str = "" + dry_run: bool = True + # 仅人工测试:为 true 时允许 micro_market 真实 IOC 市价(仍受 test_max_contracts 限制);通过 POST /api/test、/v1/test 联调,见 docs/使用说明 §4.1 + test_orders_enabled: bool = False + test_max_contracts: int = Field(1, ge=1, le=30) + + +class BreakevenStopConfig(BaseModel): + """移动保本:1R 相对初始止损触发后,止损拉至开仓价 ± buffer_pct(仅一次)。""" + + enabled: bool = True + trigger_r: float = Field(1.0, ge=0.1, le=10.0, description="相对初始 SL 的 R 倍数") + buffer_pct: float = Field(0.002, ge=0.0, le=0.05, description="保本缓冲,价格的百分比(0.002=0.2%)") + poll_interval_sec: float = Field(8.0, ge=3.0, le=120.0) + + +class RiskConfig(BaseModel): + risk_per_trade_frac: float = Field(0.005, ge=0.0001, le=0.05) + max_open_positions: int = Field(5, ge=1, le=50) + scheme: str = "A" + # Gate 永续 v4 无官方「单笔原生 OCO」双计划互撤时:为 true 则在净持仓为 0 后轮询 DELETE 本次挂出的另一腿计划单 + oco_cleanup_enabled: bool = True + # 最低盈亏比(毛利/风险)门槛的 config 默认值;面板保存会写入 runtime/risk_prefs.json 覆盖 + min_reward_risk_ratio: float = Field(1.3, ge=0.1, le=50.0) + breakeven_stop: BreakevenStopConfig = Field(default_factory=BreakevenStopConfig) + + +class StatsConfig(BaseModel): + """面板「正式统计」:时区与起始时刻(仅统计该时刻之后平仓的 Gate 历史仓位记录)。""" + + timezone: str = "Asia/Shanghai" + official_start: str = Field( + default="2026-05-13T02:00:00+08:00", + description="ISO8601,建议带 +08:00;仅统计平仓 time 不早于此的历史平仓记录", + ) + max_trade_rows: int = Field( + 20000, + ge=500, + le=100000, + description="从 Gate 分页拉 position_close 历史平仓的上限(防爆内存;沿用键名 max_trade_rows)", + ) + + +class DatabaseConfig(BaseModel): + """信号流与执行结果默认写入 SQLite;面板与导出读同一库,进程重启后记录仍在。""" + + enabled: bool = Field( + default=True, + description="已废弃,保留兼容旧配置;是否落库由 sqlite_path 决定(空串会自动回退为默认路径)", + ) + sqlite_path: str = "./runtime/signals.sqlite" + + @field_validator("sqlite_path", mode="before") + @classmethod + def _sqlite_path_default_if_blank(cls, v: object) -> str: + if v is None: + return "./runtime/signals.sqlite" + s = str(v).strip() + return s if s else "./runtime/signals.sqlite" + + +class ProxyConfig(BaseModel): + """ + 出站 HTTP(httpx)代理,与 onchain_scout_gate 的 ``proxy:`` 块写法一致。 + 访问 Gate 私有 API 时使用此处;企业微信「策略类」仍由扫描端处理,执行结果见 ``wecom`` 配置。 + """ + + enabled: bool = False + url: str = "socks5h://127.0.0.1:1080" + + +class WecomNotifyConfig(BaseModel): + """企业微信群机器人:仅推送执行器侧执行结果(成交/拒单/异常/平仓等);策略发现类仍由扫描端。""" + + enabled: bool = False + webhook_url: str = Field( + default="", + description="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...", + ) + + @field_validator("webhook_url", mode="before") + @classmethod + def _strip_webhook(cls, v: object) -> str: + if v is None: + return "" + return str(v).strip() + + +class Settings(BaseModel): + app: AppConfig = Field(default_factory=AppConfig) + auth: AuthConfig = Field(default_factory=AuthConfig) + security: SecurityConfig = Field(default_factory=SecurityConfig) + gate: GateConfig = Field(default_factory=GateConfig) + risk: RiskConfig = Field(default_factory=RiskConfig) + stats: StatsConfig = Field(default_factory=StatsConfig) + database: DatabaseConfig = Field(default_factory=DatabaseConfig) + proxy: ProxyConfig = Field(default_factory=ProxyConfig) + wecom: WecomNotifyConfig = Field(default_factory=WecomNotifyConfig) + + +def load_settings(path: str | Path | None = None) -> Settings: + cfg_path = Path(path or Path(__file__).resolve().parents[1] / "config.yaml") + if not cfg_path.is_file(): + return Settings() + raw = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {} + return Settings.model_validate(raw) diff --git a/gate_order_executor/app/executor.py b/gate_order_executor/app/executor.py new file mode 100644 index 0000000..18b3616 --- /dev/null +++ b/gate_order_executor/app/executor.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .config import Settings + from .models_signal import TradeSignal + from .positions import PositionBook + +logger = logging.getLogger(__name__) + + +def _live_enabled(settings: "Settings") -> bool: + g = settings.gate + return (not g.dry_run) and bool(g.api_key.strip() and g.api_secret.strip()) + + +async def handle_signal(settings: "Settings", book: "PositionBook", sig: "TradeSignal") -> dict: + """ + 校验仓位上限与重复合约。 + dry_run:只打日志并释放占位槽。 + 实盘:同步交易所持仓 → 市价开仓 → 计划委托止盈/止损;成功则保留占位槽。 + """ + from .gate_futures_live import GateFuturesLive, execute_signal_live, fetch_open_contracts + + contract = sig.contract.strip().upper() + open_on_ex: set[str] = set() + + if _live_enabled(settings): + try: + gc = GateFuturesLive(settings) + open_on_ex = await fetch_open_contracts(gc) + book.sync_from_exchange(open_on_ex) + except Exception as exc: # noqa: BLE001 + logger.warning("exchange_sync_failed: %s", exc) + + if len(open_on_ex) >= settings.risk.max_open_positions: + return { + "status": "skipped", + "reason": "max_positions_exchange", + "max": settings.risk.max_open_positions, + } + + if contract in open_on_ex: + return {"status": "skipped", "reason": "already_open_on_exchange", "contract": contract} + + if book.has_contract(contract): + return {"status": "skipped", "reason": "already_open_for_contract", "contract": contract} + + if not book.try_reserve(contract, sig.signal_id): + return { + "status": "skipped", + "reason": "max_positions_or_race", + "max": settings.risk.max_open_positions, + } + + if not _live_enabled(settings): + logger.info( + "dry_run signal accepted contract=%s side=%s tp=%s sl=%s signal_id=%s", + contract, + sig.side, + sig.take_profit, + sig.stop_loss, + sig.signal_id, + ) + book.release(contract) + return { + "status": "accepted", + "mode": "dry_run", + "contract": contract, + "side": sig.side, + "take_profit": sig.take_profit, + "stop_loss": sig.stop_loss, + "signal_id": sig.signal_id, + } + + try: + out = await execute_signal_live(settings, sig) + except Exception as exc: # noqa: BLE001 + logger.exception("live_execute_exception contract=%s", contract) + book.release(contract) + return {"status": "error", "reason": "exception", "detail": str(exc)} + + if out.get("status") != "accepted": + book.release(contract) + return out + + return out diff --git a/gate_order_executor/app/gate_auth.py b/gate_order_executor/app/gate_auth.py new file mode 100644 index 0000000..d33b262 --- /dev/null +++ b/gate_order_executor/app/gate_auth.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import hashlib +import hmac +import time +from urllib.parse import urlparse + + +def gate_sign_path(api_base: str, path_rel: str) -> str: + """签名用路径:/api/v4 + /futures/usdt/...(不含 host)。""" + root = urlparse(api_base).path.rstrip("/") or "/api/v4" + rel = path_rel if path_rel.startswith("/") else "/" + path_rel + return root + rel + + +def gate_sign_v4_headers( + *, + api_key: str, + api_secret: str, + method: str, + sign_path: str, + query_string: str, + body: str, +) -> dict[str, str]: + ts = str(int(time.time())) + m = hashlib.sha512() + m.update((body or "").encode("utf-8")) + hashed = m.hexdigest() + payload = f"{method.upper()}\n{sign_path}\n{query_string}\n{hashed}\n{ts}" + sign = hmac.new(api_secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha512).hexdigest() + return { + "KEY": api_key, + "Timestamp": ts, + "SIGN": sign, + "Accept": "application/json", + "Content-Type": "application/json", + } diff --git a/gate_order_executor/app/gate_futures_live.py b/gate_order_executor/app/gate_futures_live.py new file mode 100644 index 0000000..ba666e5 --- /dev/null +++ b/gate_order_executor/app/gate_futures_live.py @@ -0,0 +1,439 @@ +from __future__ import annotations + +import json +import logging +import math +import re +from decimal import ROUND_DOWN, Decimal +from typing import Any + +import httpx + +from .config import Settings +from .gate_auth import gate_sign_path, gate_sign_v4_headers +from .gate_price_rounding import _format_trigger_price, _trigger_price_tick +from .models_signal import TradeSignal +from .proxy_util import httpx_client_kwargs + +logger = logging.getLogger(__name__) + +PRICE_ORDER_EXPIRATION_SEC = 604800 # 7 天 + + +def _safe_order_text(signal_id: str) -> str: + s = re.sub(r"[^0-9A-Za-z._-]", "_", (signal_id or "x").strip())[:22] + return "t-e" + s if s else "t-e" + + +def _json_compact(obj: Any) -> str: + return json.dumps(obj, separators=(",", ":"), ensure_ascii=False) + + +class GateFuturesLive: + """Gate USDT 永续私有 REST(市价开仓 + 计划委托止盈止损)。""" + + def __init__(self, settings: Settings) -> None: + self._settings = settings + self._base = settings.gate.api_base.rstrip("/") + self._settle = settings.gate.settle.strip().lower() + self._prefix = f"/futures/{self._settle}" + self._key = settings.gate.api_key.strip() + self._secret = settings.gate.api_secret.strip() + self._kw = httpx_client_kwargs(settings.proxy.enabled, settings.proxy.url) + + def _sign_path(self, rel: str) -> str: + return gate_sign_path(self._settings.gate.api_base, rel) + + async def _public_get(self, rel: str, *, params: dict[str, str] | None = None) -> Any: + url = f"{self._base}{rel}" + async with httpx.AsyncClient(**self._kw) as client: + r = await client.get(url, params=params) + r.raise_for_status() + return r.json() + + async def _signed( + self, + method: str, + rel: str, + *, + query_string: str = "", + body_obj: dict[str, Any] | None = None, + ) -> Any: + body_str = _json_compact(body_obj) if body_obj is not None else "" + sp = self._sign_path(rel) + headers = gate_sign_v4_headers( + api_key=self._key, + api_secret=self._secret, + method=method, + sign_path=sp, + query_string=query_string, + body=body_str, + ) + url = f"{self._base}{rel}" + async with httpx.AsyncClient(**self._kw) as client: + if method.upper() == "GET": + r = await client.get(url, headers=headers, params=params_from_qs(query_string)) + elif method.upper() == "POST": + r = await client.post(url, headers=headers, content=body_str.encode("utf-8")) + elif method.upper() == "DELETE": + r = await client.delete(url, headers=headers) + else: + raise ValueError(f"unsupported {method}") + try: + r.raise_for_status() + except httpx.HTTPStatusError as exc: + try: + detail = exc.response.json() + except Exception: + detail = exc.response.text if exc.response else "" + raise RuntimeError(f"gate_http_{exc.response.status_code}: {detail}") from exc + if not r.content.strip(): + return None + return r.json() + + +async def fetch_net_position_size(client: GateFuturesLive, contract: str) -> float: + """该合约净持仓张数(单向模式 size 正负表示方向)。""" + ct = contract.strip().upper() + data = await client._signed("GET", f"{client._prefix}/positions") + if not isinstance(data, list): + return 0.0 + for p in data: + if str(p.get("contract") or "").strip().upper() != ct: + continue + return _float(p.get("size")) + return 0.0 + + +async def post_stop_loss_price_order( + client: GateFuturesLive, + *, + contract: str, + side: str, + sl_price: str, +) -> dict[str, Any]: + """POST 一条 reduce_only 全平止损计划单(与信号开仓 SL 同形态)。""" + ct = contract.strip().upper() + sd = str(side).lower() + if sd not in ("long", "short"): + raise ValueError("invalid_side") + cdata = await client._public_get(f"{client._prefix}/contracts/{ct}") + if not isinstance(cdata, dict): + raise ValueError("contract_not_found") + price_tick = _trigger_price_tick(cdata) + sl_s = _format_trigger_price(float(sl_price), price_tick) + _, sl_tr = _tp_sl_triggers(sd, sl_s, sl_s) + import time as _time + + text = ("t-besl" + str(int(_time.time())))[-28:] + body: dict[str, Any] = { + "initial": { + "contract": ct, + "size": 0, + "price": "0", + "tif": "ioc", + "text": text, + "reduce_only": True, + "close": True, + }, + "trigger": sl_tr, + } + resp = await client._signed("POST", f"{client._prefix}/price_orders", body_obj=body) + if not isinstance(resp, dict): + raise RuntimeError("price_order_response_invalid") + return resp + + +async def cancel_price_triggered_order(client: GateFuturesLive, order_id: str | int) -> bool: + """DELETE /price_orders/{id}。成功删除返回 True。 + + 单已不存在(404,或 Gate 400 + 1034 AUTO_ORDER_NOT_FOUND)视为目标已达成,返回 True,不抛错。 + """ + oid = str(order_id).strip() + if not oid: + return False + rel = f"{client._prefix}/price_orders/{oid}" + try: + await client._signed("DELETE", rel) + return True + except RuntimeError as exc: + msg = str(exc) + if "gate_http_404" in msg: + return True + # OCO 一腿触发后另一腿常被交易所联动撤掉;再 DELETE 会得到 400+1034 而非 404。 + if "gate_http_400" in msg and ( + "AUTO_ORDER_NOT_FOUND" in msg or "'1034'" in msg or '"1034"' in msg + ): + return True + raise + + +def params_from_qs(qs: str) -> dict[str, str]: + if not qs.strip(): + return {} + out: dict[str, str] = {} + for part in qs.split("&"): + if "=" in part: + k, v = part.split("=", 1) + out[k] = v + return out + + +def _float(x: Any, default: float = 0.0) -> float: + try: + if x is None: + return default + return float(x) + except (TypeError, ValueError): + return default + + +async def fetch_open_contracts(client: GateFuturesLive) -> set[str]: + rel = f"{client._prefix}/positions" + data = await client._signed("GET", rel) + if not isinstance(data, list): + return set() + out: set[str] = set() + for p in data: + if not isinstance(p, dict): + continue + c = str(p.get("contract") or "").strip().upper() + if not c: + continue + if abs(_float(p.get("size"))) > 1e-12: + out.add(c) + return out + + +def _round_contract_size(raw: float, *, enable_decimal: bool, order_size_min: float) -> str | None: + if raw <= 0 or not math.isfinite(raw): + return None + if enable_decimal: + d = Decimal(str(raw)).quantize(Decimal("0.1"), rounding=ROUND_DOWN) + m = Decimal(str(order_size_min)) + if d < m: + return None + s = format(d, "f").rstrip("0").rstrip(".") + return s or None + n = int(math.floor(raw)) + if n < int(math.ceil(order_size_min)): + return None + return str(n) + + +def _tp_sl_triggers(side: str, tp_price: str, sl_price: str) -> tuple[dict[str, Any], dict[str, Any]]: + """返回 (tp_trigger, sl_trigger) 的 trigger 字段 dict;price 已为合约 tick 对齐后的字符串。""" + if side == "long": + tp_tr = { + "strategy_type": 0, + "price_type": 0, + "price": tp_price, + "rule": 1, + "expiration": PRICE_ORDER_EXPIRATION_SEC, + } + sl_tr = { + "strategy_type": 0, + "price_type": 0, + "price": sl_price, + "rule": 2, + "expiration": PRICE_ORDER_EXPIRATION_SEC, + } + else: + tp_tr = { + "strategy_type": 0, + "price_type": 0, + "price": tp_price, + "rule": 2, + "expiration": PRICE_ORDER_EXPIRATION_SEC, + } + sl_tr = { + "strategy_type": 0, + "price_type": 0, + "price": sl_price, + "rule": 1, + "expiration": PRICE_ORDER_EXPIRATION_SEC, + } + return tp_tr, sl_tr + + +async def execute_signal_live(settings: Settings, sig: TradeSignal) -> dict: + """ + 市价开仓 + 计划委托止盈/止损(reduce_only 市价 IOC)。 + 以损订仓:用 futures 账户 total × risk_per_trade_frac / (|entry-sl|×quanto_multiplier) 估算张数。 + """ + client = GateFuturesLive(settings) + contract = sig.contract.strip().upper() + ot = _safe_order_text(sig.signal_id) + + try: + ticker = await client._public_get(f"{client._prefix}/tickers", params={"contract": contract}) + last = 0.0 + if isinstance(ticker, list) and ticker: + last = _float(ticker[0].get("last")) + elif isinstance(ticker, dict): + last = _float(ticker.get("last")) + + entry = float(sig.reference_price) if sig.reference_price else last + if entry <= 0: + return {"status": "error", "reason": "no_entry_price", "detail": "缺少 reference_price 且无法从 ticker 取 last"} + + cdata = await client._public_get(f"{client._prefix}/contracts/{contract}") + if not isinstance(cdata, dict): + return {"status": "error", "reason": "contract_not_found", "contract": contract} + + mult = _float(cdata.get("quanto_multiplier")) + if mult <= 0: + return {"status": "error", "reason": "invalid_quanto_multiplier", "contract": contract} + + order_size_min = _float(cdata.get("order_size_min"), 1.0) + enable_decimal = bool(cdata.get("enable_decimal")) + price_tick = _trigger_price_tick(cdata) + if price_tick is None: + logger.warning("contract %s: missing order_price_round/mark_price_round; TP/SL may be rejected", contract) + + accounts = await client._signed("GET", f"{client._prefix}/accounts") + if not isinstance(accounts, dict): + return {"status": "error", "reason": "accounts_unexpected", "detail": str(type(accounts))} + equity = _float(accounts.get("total")) + if equity <= 0: + return {"status": "error", "reason": "zero_equity", "detail": "futures accounts total 为 0"} + + risk_usdt = equity * float(settings.risk.risk_per_trade_frac) + sl_dist = abs(entry - float(sig.stop_loss)) + if sl_dist <= 0: + return {"status": "error", "reason": "invalid_stop_distance"} + + raw_contracts = risk_usdt / (sl_dist * mult) + size_s = _round_contract_size(raw_contracts, enable_decimal=enable_decimal, order_size_min=order_size_min) + if not size_s: + return { + "status": "error", + "reason": "size_too_small", + "detail": f"以损订仓张数不足 order_size_min={order_size_min} raw={raw_contracts:.6f}", + } + + if sig.side == "long": + open_size = size_s if not size_s.startswith("-") else size_s.lstrip("-") + else: + open_size = "-" + size_s.lstrip("-") + + market_body: dict[str, Any] = { + "contract": contract, + "size": open_size, + "price": "0", + "tif": "ioc", + "text": ot, + "reduce_only": False, + } + order = await client._signed("POST", f"{client._prefix}/orders", body_obj=market_body) + if not isinstance(order, dict): + return {"status": "error", "reason": "order_response_invalid"} + + st = str(order.get("status") or "") + finish = str(order.get("finish_as") or "") + left_abs = abs(_float(order.get("left"))) + if st != "finished" or left_abs > 1e-12: + return {"status": "error", "reason": "market_not_filled", "order": order} + if finish and finish not in {"filled", "ioc"}: + return {"status": "error", "reason": "market_not_filled", "order": order} + + tp_s = _format_trigger_price(float(sig.take_profit), price_tick) + sl_s = _format_trigger_price(float(sig.stop_loss), price_tick) + tp_tr, sl_tr = _tp_sl_triggers(sig.side, tp_s, sl_s) + + def _price_order(trigger: dict[str, Any], text_val: str) -> dict[str, Any]: + # Gate:单向全平时 close=true 则 initial.size 必须为 0(否则会报 AUTO_INVALID_PARAM_INITIAL_SIZE) + return { + "initial": { + "contract": contract, + "size": 0, + "price": "0", + "tif": "ioc", + "text": text_val, + "reduce_only": True, + "close": True, + }, + "trigger": trigger, + } + + tp_po = _price_order(tp_tr, "api") + sl_po = _price_order(sl_tr, "app") + + # 市价已成交后单独捕获计划委托失败,便于返回 market_order(及已挂上的 TP)供落库、对账 + partial_base: dict[str, Any] = { + "status": "error", + "mode": "live", + "contract": contract, + "side": sig.side, + "signal_id": sig.signal_id, + "market_order": order, + "sized_contracts": open_size, + "risk_budget_usdt": round(risk_usdt, 6), + "reference_entry": entry, + "trigger_price_tick": str(price_tick) if price_tick is not None else None, + "take_profit_price_sent": tp_s, + "stop_loss_price_sent": sl_s, + } + + try: + tp_resp = await client._signed("POST", f"{client._prefix}/price_orders", body_obj=tp_po) + except RuntimeError as exc: + logger.exception("price_orders_take_profit_failed contract=%s", contract) + return { + **partial_base, + "reason": "gate_api", + "detail": str(exc), + "stage": "take_profit", + } + + try: + sl_resp = await client._signed("POST", f"{client._prefix}/price_orders", body_obj=sl_po) + except RuntimeError as exc: + logger.exception("price_orders_stop_loss_failed contract=%s", contract) + out_partial: dict[str, Any] = { + **partial_base, + "reason": "gate_api", + "detail": str(exc), + "stage": "stop_loss", + "take_profit_order": tp_resp, + } + return out_partial + + from .breakeven_logic import register_from_execution_result + from .oco_watcher import register_tp_sl_oco_cleanup + + if isinstance(tp_resp, dict) and isinstance(sl_resp, dict): + await register_tp_sl_oco_cleanup( + settings, + contract=contract, + tp_order=tp_resp, + sl_order=sl_resp, + ) + + accepted_out = { + "status": "accepted", + "mode": "live", + "contract": contract, + "side": sig.side, + "signal_id": sig.signal_id, + "market_order": order, + "take_profit_order": tp_resp, + "stop_loss_order": sl_resp, + "sized_contracts": open_size, + "risk_budget_usdt": round(risk_usdt, 6), + "reference_entry": entry, + "trigger_price_tick": str(price_tick) if price_tick is not None else None, + "take_profit_price_sent": tp_s, + "stop_loss_price_sent": sl_s, + } + register_from_execution_result(settings, sig, accepted_out) + return accepted_out + except httpx.HTTPStatusError as exc: + body = exc.response.text if exc.response else "" + logger.exception("gate_http_error %s", body[:500]) + return {"status": "error", "reason": "http_error", "detail": str(exc)} + except RuntimeError as exc: + return {"status": "error", "reason": "gate_api", "detail": str(exc)} + except Exception as exc: # noqa: BLE001 + logger.exception("execute_signal_live failed") + return {"status": "error", "reason": "exception", "detail": str(exc)} diff --git a/gate_order_executor/app/gate_history.py b/gate_order_executor/app/gate_history.py new file mode 100644 index 0000000..f930c53 --- /dev/null +++ b/gate_order_executor/app/gate_history.py @@ -0,0 +1,319 @@ +from __future__ import annotations + +import csv +import io +import time +from typing import Any +from urllib.parse import urlencode + +from .config import Settings +from .gate_futures_live import GateFuturesLive + + +def _keys_ok(settings: Settings) -> bool: + return bool(settings.gate.api_key.strip() and settings.gate.api_secret.strip()) + + +def _default_range_ts() -> tuple[int, int]: + now = int(time.time()) + return now - 86400 * 7, now + + +async def fetch_position_close_timerange( + settings: Settings, + *, + contract: str | None, + from_ts: int, + to_ts: int, + limit: int = 100, + offset: int = 0, +) -> tuple[list[dict[str, Any]] | None, str | None]: + """GET /futures/{{settle}}/position_close — 历史平仓(与 App「历史仓位」同源类数据)。""" + if not _keys_ok(settings): + return None, None + lim = max(1, min(int(limit), 500)) + off = max(0, int(offset)) + q: dict[str, Any] = { + "from": int(from_ts), + "to": int(to_ts), + "limit": lim, + "offset": off, + } + if contract and str(contract).strip(): + q["contract"] = str(contract).strip().upper() + qs = urlencode(q) + try: + c = GateFuturesLive(settings) + data = await c._signed("GET", f"{c._prefix}/position_close", query_string=qs) + if not isinstance(data, list): + return None, f"unexpected_response:{type(data).__name__}" + return [x for x in data if isinstance(x, dict)], None + except Exception as exc: # noqa: BLE001 + return None, str(exc) + + +async def fetch_my_trades_timerange( + settings: Settings, + *, + contract: str | None, + from_ts: int, + to_ts: int, + limit: int = 100, + offset: int = 0, +) -> tuple[list[dict[str, Any]] | None, str | None]: + """GET /futures/{{settle}}/my_trades_timerange — 成交记录(Gate 为准)。""" + if not _keys_ok(settings): + return None, None + lim = max(1, min(int(limit), 500)) + off = max(0, int(offset)) + q: dict[str, Any] = {"from": int(from_ts), "to": int(to_ts), "limit": lim, "offset": off} + if contract and str(contract).strip(): + q["contract"] = str(contract).strip().upper() + qs = urlencode(q) + try: + c = GateFuturesLive(settings) + data = await c._signed("GET", f"{c._prefix}/my_trades_timerange", query_string=qs) + if not isinstance(data, list): + return None, f"unexpected_response:{type(data).__name__}" + return [x for x in data if isinstance(x, dict)], None + except Exception as exc: # noqa: BLE001 + return None, str(exc) + + +async def fetch_orders_list( + settings: Settings, + *, + status: str, + contract: str | None, + limit: int = 100, + offset: int = 0, +) -> tuple[list[dict[str, Any]] | None, str | None]: + """GET /futures/{{settle}}/orders?status=… — 委托列表(Gate 为准)。""" + if not _keys_ok(settings): + return None, None + lim = max(1, min(int(limit), 500)) + off = max(0, int(offset)) + st = (status or "finished").strip().lower() + if st not in ("open", "finished"): + st = "finished" + q: dict[str, Any] = {"status": st, "limit": lim, "offset": off} + if contract and str(contract).strip(): + q["contract"] = str(contract).strip().upper() + qs = urlencode(q) + try: + c = GateFuturesLive(settings) + data = await c._signed("GET", f"{c._prefix}/orders", query_string=qs) + if not isinstance(data, list): + return None, f"unexpected_response:{type(data).__name__}" + return [x for x in data if isinstance(x, dict)], None + except Exception as exc: # noqa: BLE001 + return None, str(exc) + + +def trades_rows_to_csv(rows: list[dict[str, Any]]) -> str: + """成交记录 → CSV 文本(UTF-8 BOM 便于 Excel 打开)。""" + buf = io.StringIO() + buf.write("\ufeff") + w = csv.writer(buf) + w.writerow( + [ + "trade_id", + "create_time", + "contract", + "order_id", + "size", + "price", + "fee", + "point_fee", + "role", + "text", + "close_size", + "pnl", + ] + ) + for r in rows: + w.writerow( + [ + r.get("trade_id") or r.get("id") or "", + r.get("create_time"), + r.get("contract") or "", + r.get("order_id") or "", + r.get("size") or "", + r.get("price") or "", + r.get("fee") or "", + r.get("point_fee") or "", + r.get("role") or "", + r.get("text") or "", + r.get("close_size") or "", + r.get("pnl") if r.get("pnl") is not None else "", + ] + ) + return buf.getvalue() + + +def orders_rows_to_csv(rows: list[dict[str, Any]]) -> str: + buf = io.StringIO() + buf.write("\ufeff") + w = csv.writer(buf) + w.writerow( + [ + "id", + "create_time", + "finish_time", + "contract", + "size", + "left", + "price", + "fill_price", + "status", + "finish_as", + "tif", + "text", + "is_reduce_only", + ] + ) + for r in rows: + w.writerow( + [ + r.get("id") or "", + r.get("create_time"), + r.get("finish_time"), + r.get("contract") or "", + r.get("size") or "", + r.get("left") or "", + r.get("price") or "", + r.get("fill_price") or "", + r.get("status") or "", + r.get("finish_as") or "", + r.get("tif") or "", + r.get("text") or "", + r.get("is_reduce_only"), + ] + ) + return buf.getvalue() + + +async def collect_trades_rows( + settings: Settings, + *, + contract: str | None, + from_ts: int, + to_ts: int, + max_rows: int = 2000, +) -> tuple[list[dict[str, Any]] | None, str | None]: + """分页拉取成交原始行(上限 max_rows,单页最多 500)。""" + if not _keys_ok(settings): + return None, None + cap = max(1, min(int(max_rows), 100_000)) + page = 500 + all_rows: list[dict[str, Any]] = [] + offset = 0 + while len(all_rows) < cap: + lim = min(page, cap - len(all_rows)) + chunk, err = await fetch_my_trades_timerange( + settings, + contract=contract, + from_ts=from_ts, + to_ts=to_ts, + limit=lim, + offset=offset, + ) + if err: + return None, err + if not chunk: + break + all_rows.extend(chunk) + offset += len(chunk) + if len(chunk) < lim: + break + return all_rows[:cap], None + + +async def collect_position_close_rows( + settings: Settings, + *, + contract: str | None, + from_ts: int, + to_ts: int, + max_rows: int = 2000, +) -> tuple[list[dict[str, Any]] | None, str | None]: + """分页拉取历史平仓原始行(上限 max_rows,单页最多 500)。""" + if not _keys_ok(settings): + return None, None + cap = max(1, min(int(max_rows), 100_000)) + page = 500 + all_rows: list[dict[str, Any]] = [] + offset = 0 + while len(all_rows) < cap: + lim = min(page, cap - len(all_rows)) + chunk, err = await fetch_position_close_timerange( + settings, + contract=contract, + from_ts=from_ts, + to_ts=to_ts, + limit=lim, + offset=offset, + ) + if err: + return None, err + if not chunk: + break + all_rows.extend(chunk) + offset += len(chunk) + if len(chunk) < lim: + break + return all_rows[:cap], None + + +async def collect_trades_csv( + settings: Settings, + *, + contract: str | None, + from_ts: int, + to_ts: int, + max_rows: int = 2000, +) -> tuple[str | None, str | None]: + """分页拉取成交并拼成 CSV(上限 max_rows)。""" + all_rows, err = await collect_trades_rows( + settings, + contract=contract, + from_ts=from_ts, + to_ts=to_ts, + max_rows=max_rows, + ) + if err: + return None, err + if all_rows is None: + return None, None + return trades_rows_to_csv(all_rows), None + + +async def collect_orders_csv( + settings: Settings, + *, + status: str, + contract: str | None, + max_rows: int = 2000, +) -> tuple[str | None, str | None]: + cap = max(1, min(int(max_rows), 5000)) + page = 100 + all_rows: list[dict[str, Any]] = [] + offset = 0 + while len(all_rows) < cap: + lim = min(page, cap - len(all_rows)) + chunk, err = await fetch_orders_list( + settings, + status=status, + contract=contract, + limit=lim, + offset=offset, + ) + if err: + return None, err + if not chunk: + break + all_rows.extend(chunk) + offset += len(chunk) + if len(chunk) < lim: + break + return orders_rows_to_csv(all_rows[:cap]), None diff --git a/gate_order_executor/app/gate_operations.py b/gate_order_executor/app/gate_operations.py new file mode 100644 index 0000000..29d1cf6 --- /dev/null +++ b/gate_order_executor/app/gate_operations.py @@ -0,0 +1,263 @@ +from __future__ import annotations + +import math +import time +from typing import Any + +from .config import Settings +from .gate_futures_live import ( + PRICE_ORDER_EXPIRATION_SEC, + GateFuturesLive, + cancel_price_triggered_order, + fetch_net_position_size, +) + + +def _float_field(x: Any, default: float = 0.0) -> float: + try: + if x is None: + return default + return float(x) + except (TypeError, ValueError): + return default + + +def _slim_futures_position(row: dict[str, Any]) -> dict[str, Any]: + """GET /positions 单行摘要(面板用)。""" + return { + "contract": str(row.get("contract") or "").strip().upper(), + "size": row.get("size"), + "entry_price": row.get("entry_price") if row.get("entry_price") is not None else row.get("avg_entry_price"), + "mark_price": row.get("mark_price"), + "unrealised_pnl": row.get("unrealised_pnl"), + "leverage": row.get("leverage"), + "value": row.get("value"), + "liq_price": row.get("liq_price"), + "margin": row.get("margin"), + "mode": row.get("mode"), + } + + +async def list_futures_positions( + settings: Settings, *, limit: int = 80 +) -> tuple[list[dict[str, Any]] | None, str | None]: + """GET /futures/{{settle}}/positions,仅返回 |size|>0 的合约(最多 limit 条)。""" + if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()): + return None, None + cap = max(1, min(int(limit), 200)) + try: + c = GateFuturesLive(settings) + data = await c._signed("GET", f"{c._prefix}/positions") + if not isinstance(data, list): + return None, f"unexpected_response:{type(data).__name__}" + out: list[dict[str, Any]] = [] + for row in data: + if not isinstance(row, dict): + continue + if abs(_float_field(row.get("size"))) <= 1e-12: + continue + out.append(_slim_futures_position(row)) + if len(out) >= cap: + break + return out, None + except Exception as exc: # noqa: BLE001 + return None, str(exc) + + +async def read_futures_balance(settings: Settings) -> tuple[dict[str, Any] | None, str | None]: + """GET /futures/{{settle}}/accounts,返回 (payload, error_message)。""" + if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()): + return None, None + try: + c = GateFuturesLive(settings) + data = await c._signed("GET", f"{c._prefix}/accounts") + if isinstance(data, dict): + return data, None + return None, f"unexpected_response:{type(data).__name__}" + except Exception as exc: # noqa: BLE001 + return None, str(exc) + + +def _slim_price_order(row: dict[str, Any]) -> dict[str, Any]: + """面板展示用字段(避免整对象过大)。""" + ini = row.get("initial") if isinstance(row.get("initial"), dict) else {} + tr = row.get("trigger") if isinstance(row.get("trigger"), dict) else {} + oid = row.get("id_string") + if oid is None and row.get("id") is not None: + oid = str(row.get("id")) + return { + "order_id": str(oid or "").strip(), + "contract": str(ini.get("contract") or "").strip().upper(), + "status": str(row.get("status") or ""), + "order_type": str(row.get("order_type") or ""), + "trigger_price": str(tr.get("price") or ""), + "rule": tr.get("rule"), + "size": ini.get("size"), + "reduce_only": ini.get("reduce_only"), + "create_time": row.get("create_time"), + } + + +async def list_open_price_orders( + settings: Settings, *, limit: int = 50 +) -> tuple[list[dict[str, Any]] | None, str | None]: + """GET /futures/{{settle}}/price_orders?status=open,返回精简列表。""" + if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()): + return None, None + lim = max(1, min(int(limit), 100)) + qs = f"status=open&limit={lim}" + try: + c = GateFuturesLive(settings) + data = await c._signed("GET", f"{c._prefix}/price_orders", query_string=qs) + if not isinstance(data, list): + return None, f"unexpected_response:{type(data).__name__}" + out: list[dict[str, Any]] = [] + for row in data: + if isinstance(row, dict): + out.append(_slim_price_order(row)) + return out, None + except Exception as exc: # noqa: BLE001 + return None, str(exc) + + +async def cancel_plan_price_order(settings: Settings, order_id: str) -> tuple[bool, str | None]: + """撤销一条计划委托(price_orders)。""" + oid = (order_id or "").strip() + if not oid: + return False, "empty_order_id" + if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()): + return False, "missing_api_keys" + try: + c = GateFuturesLive(settings) + await cancel_price_triggered_order(c, oid) + return True, None + except Exception as exc: # noqa: BLE001 + return False, str(exc) + + +async def post_test_market_order(settings: Settings, *, contract: str, side: str, size_qty: int) -> dict[str, Any]: + """ + 极小市价 IOC 测试单。需 config gate.test_orders_enabled=true。 + size_qty 会被限制在 [1, test_max_contracts]。 + """ + if not settings.gate.test_orders_enabled: + return {"ok": False, "error": "test_orders_disabled", "hint": "请在 config.yaml 设置 gate.test_orders_enabled: true"} + if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()): + return {"ok": False, "error": "missing_api_keys"} + + cap = int(settings.gate.test_max_contracts) + n = max(1, min(int(size_qty), cap)) + c = GateFuturesLive(settings) + ct = contract.strip().upper() + if "_" not in ct or not ct.endswith("_USDT"): + return {"ok": False, "error": "invalid_contract", "contract": ct} + + sz = str(n) if side == "long" else f"-{n}" + text = "t-tst" + str(int(time.time()))[-12:] + body: dict[str, Any] = { + "contract": ct, + "size": sz, + "price": "0", + "tif": "ioc", + "text": text[:28], + "reduce_only": False, + } + order = await c._signed("POST", f"{c._prefix}/orders", body_obj=body) + return {"ok": True, "order": order, "request": body} + + +def _format_order_size_signed(value: float) -> str | None: + if value == 0 or not math.isfinite(value): + return None + if abs(value - round(value)) < 1e-8: + return str(int(round(value))) + s = f"{value:.10f}".rstrip("0").rstrip(".") + return s if s else None + + +async def market_close_futures_position(settings: Settings, *, contract: str) -> tuple[dict[str, Any] | None, str | None]: + """市价 IOC + reduce_only 平掉该合约全部净持仓(单向 size 正负)。""" + if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()): + return None, "missing_api_keys" + if settings.gate.dry_run: + return None, "dry_run_enabled" + c = GateFuturesLive(settings) + ct = contract.strip().upper() + try: + net = await fetch_net_position_size(c, ct) + except Exception as exc: # noqa: BLE001 + return None, str(exc) + if abs(net) < 1e-12: + return None, "no_position" + flip = -net + sz_s = _format_order_size_signed(flip) + if not sz_s: + return None, "invalid_close_size" + text = ("t-mcls" + str(int(time.time())))[-28:] + body: dict[str, Any] = { + "contract": ct, + "size": sz_s, + "price": "0", + "tif": "ioc", + "text": text, + "reduce_only": True, + } + try: + order = await c._signed("POST", f"{c._prefix}/orders", body_obj=body) + if not isinstance(order, dict): + return {"response": order}, None + return order, None + except Exception as exc: # noqa: BLE001 + return None, str(exc) + + +async def post_reduce_close_price_order( + settings: Settings, + *, + contract: str, + trigger_price: str, + rule: int, +) -> tuple[dict[str, Any] | None, str | None]: + """POST price_orders:全平 reduce_only 条件单(与信号挂 TP/SL 同一 initial 形态)。""" + if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()): + return None, "missing_api_keys" + if settings.gate.dry_run: + return None, "dry_run_enabled" + ct = contract.strip().upper() + try: + fp = float(str(trigger_price).strip()) + except ValueError: + return None, "invalid_trigger_price" + if fp <= 0 or not math.isfinite(fp): + return None, "invalid_trigger_price" + r = int(rule) + if r not in (1, 2): + return None, "invalid_rule" + trig: dict[str, Any] = { + "strategy_type": 0, + "price_type": 0, + "price": str(fp), + "rule": r, + "expiration": PRICE_ORDER_EXPIRATION_SEC, + } + text = ("t-padd" + str(int(time.time())))[-28:] + body: dict[str, Any] = { + "initial": { + "contract": ct, + "size": 0, + "price": "0", + "tif": "ioc", + "text": text, + "reduce_only": True, + "close": True, + }, + "trigger": trig, + } + c = GateFuturesLive(settings) + try: + resp = await c._signed("POST", f"{c._prefix}/price_orders", body_obj=body) + if not isinstance(resp, dict): + return {"response": resp}, None + return resp, None + except Exception as exc: # noqa: BLE001 + return None, str(exc) diff --git a/gate_order_executor/app/gate_price_rounding.py b/gate_order_executor/app/gate_price_rounding.py new file mode 100644 index 0000000..1f81aac --- /dev/null +++ b/gate_order_executor/app/gate_price_rounding.py @@ -0,0 +1,49 @@ +"""Gate 计划委托触发价:按合约 tick 对齐(仅标准库,可被离线测试直接导入)。""" +from __future__ import annotations + +from decimal import ROUND_HALF_UP, Decimal +from typing import Any + + +def _parse_positive_decimal(raw: Any) -> Decimal | None: + if raw is None: + return None + s = str(raw).strip() + if not s: + return None + try: + t = Decimal(s) + except Exception: + return None + if t <= 0 or not t.is_finite(): + return None + return t + + +def _trigger_price_tick(cdata: dict[str, Any]) -> Decimal | None: + """Gate 合约最小价格跳动;优先 order_price_round,其次 mark_price_round(小币种字段齐全)。""" + for key in ("order_price_round", "mark_price_round"): + t = _parse_positive_decimal(cdata.get(key)) + if t is not None: + return t + return None + + +def _decimal_plain_str(d: Decimal) -> str: + s = format(d, "f") + if "." in s: + s = s.rstrip("0").rstrip(".") + return s or "0" + + +def _format_trigger_price(price: float, tick: Decimal | None) -> str: + """将信号里的浮点止盈/止损价对齐到合约 tick,避免 4752.700000000001 这类导致 price_orders 400。""" + p = Decimal(str(price)) + if not p.is_finite(): + raise ValueError("invalid trigger price") + if tick is not None and tick > 0: + q = (p / tick).quantize(Decimal("1"), rounding=ROUND_HALF_UP) + snapped = q * tick + return _decimal_plain_str(snapped) + coarse = p.quantize(Decimal("1e-12"), rounding=ROUND_HALF_UP) + return _decimal_plain_str(coarse) diff --git a/gate_order_executor/app/main.py b/gate_order_executor/app/main.py new file mode 100644 index 0000000..ecbd5fb --- /dev/null +++ b/gate_order_executor/app/main.py @@ -0,0 +1,708 @@ +from __future__ import annotations + +import csv +import hashlib +import io +import logging +import time +from contextlib import asynccontextmanager +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from fastapi import FastAPI, Header, HTTPException, Query, Request, status +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from pydantic import BaseModel, Field +from starlette.middleware.sessions import SessionMiddleware + +from .config import load_settings +from .executor import handle_signal +from .gate_history import ( + _default_range_ts, + collect_orders_csv, + collect_trades_csv, + fetch_my_trades_timerange, + fetch_orders_list, +) +from .gate_operations import ( + cancel_plan_price_order, + list_futures_positions, + list_open_price_orders, + market_close_futures_position, + post_reduce_close_price_order, + post_test_market_order, + read_futures_balance, +) +from .models_signal import TradeSignal +from .models_test import GateTestRequest +from .positions import PositionBook +from .proxy_util import effective_proxy_url +from .breakeven_active_store import remove_active +from .breakeven_prefs_store import ( + read_effective_global_enabled, + read_prefs_snapshot, + write_contract_enabled, + write_global_enabled, +) +from .breakeven_watcher import build_breakeven_state_for_api, start_breakeven_watcher, stop_breakeven_watcher +from .risk_prefs_store import read_effective_min_reward_risk_ratio, write_min_reward_risk_ratio +from .signal_history import SignalHistory +from .signal_metrics import augment_signal_result, compute_signal_stream_metrics +from .signal_repository import SignalRepository +from .stats import build_dashboard_stats +from .wecom_notify import notify_manual_close, notify_signal_db_insert_failed, notify_signal_execution + +settings = load_settings() +book = PositionBook(settings.risk.max_open_positions) +signal_history = SignalHistory() + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s %(message)s", +) +logger = logging.getLogger(__name__) + +root_dir = Path(__file__).resolve().parent.parent +templates = Jinja2Templates(directory=str(root_dir / "templates")) + +# 信号流:每条 POST /v1/signal 写入 SQLite;配置里 path 留空时会在 DatabaseConfig 中回退为 ./runtime/signals.sqlite +signal_repo: SignalRepository | None = SignalRepository.from_settings( + settings.database.sqlite_path, + root_dir, +) +if signal_repo: + try: + signal_repo.init_schema() + except Exception: # noqa: BLE001 + logger.exception("signal_db_init_failed") + signal_repo = None + + +def _hash_password(plain: str) -> str: + return hashlib.sha256(plain.encode("utf-8")).hexdigest() + + +def _asset_version() -> str: + mt = 0 + for name in ("exec.js", "style.css", "theme-matrix-terminal.css"): + try: + mt = max(mt, int((root_dir / "static" / name).stat().st_mtime)) + except OSError: + continue + return str(mt or 1) + + +def _password_hash() -> str: + return _hash_password(settings.auth.password) + + +class LoginBody(BaseModel): + username: str = Field(..., min_length=1) + password: str = Field(..., min_length=1) + + +class CancelPlanOrderBody(BaseModel): + order_id: str = Field(..., min_length=1, description="price_orders 的 id 或 id_string") + + +class ClosePositionBody(BaseModel): + contract: str = Field(..., min_length=3, max_length=64, description="如 BTC_USDT") + + +class ManualPriceOrderBody(BaseModel): + contract: str = Field(..., min_length=3, max_length=64) + trigger_price: str = Field(..., min_length=1, max_length=32) + rule: int = Field(1, ge=1, le=2, description="Gate:1 为价格>=触发价,2 为价格<=触发价") + + +class RiskPrefsBody(BaseModel): + min_reward_risk_ratio: float = Field(..., ge=0.1, le=50.0, description="面板保存的最低盈亏比") + + +class BreakevenPrefsBody(BaseModel): + global_enabled: bool | None = Field(None, description="全局移动保本开关") + contract: str | None = Field(None, min_length=3, max_length=64) + enabled: bool | None = Field(None, description="单合约覆盖;需同时传 contract") + + +@asynccontextmanager +async def _lifespan(_app: FastAPI): + log_path = Path(settings.app.log_file) + log_path.parent.mkdir(parents=True, exist_ok=True) + p = effective_proxy_url(settings.proxy.enabled, settings.proxy.url) + logger.info( + "executor %s:%s dry_run=%s max_positions=%s proxy=%s wecom=%s", + settings.app.host, + settings.app.port, + settings.gate.dry_run, + settings.risk.max_open_positions, + "on" if p else "off", + "on" if (settings.wecom.enabled and (settings.wecom.webhook_url or "").strip()) else "off", + ) + start_breakeven_watcher(settings, signal_repo) + try: + yield + finally: + await stop_breakeven_watcher() + + +app = FastAPI(title="Gate Order Executor", version="0.2.0", lifespan=_lifespan) +app.add_middleware( + SessionMiddleware, + secret_key=settings.app.session_secret, + max_age=60 * 60 * 24 * 7, + same_site="lax", + https_only=False, +) +app.mount("/static", StaticFiles(directory=str(root_dir / "static")), name="static") + + +def _session_ok(request: Request) -> bool: + if not settings.auth.enabled: + return True + return request.session.get("logged_in") is True + + +def _require_ui_session(request: Request) -> None: + if not _session_ok(request): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="login required") + + +def _norm_contract_hist(contract: str | None) -> str | None: + if not contract: + return None + c = str(contract).strip() + return c.upper() if c else None + + +def _resolve_timerange(from_ts: int | None, to_ts: int | None) -> tuple[int, int]: + """未传 from/to 时用最近 7 天;只传其一则补齐另一端。""" + now = int(time.time()) + f, t = from_ts, to_ts + if f is None and t is None: + return _default_range_ts() + if f is None: + tt = int(t or now) + return tt - 86400 * 7, tt + if t is None: + ff = int(f) + return ff, now + return int(f), int(t) + + +@app.get("/health") +async def health() -> dict: + p = effective_proxy_url(settings.proxy.enabled, settings.proxy.url) + return { + "ok": True, + "dry_run": settings.gate.dry_run, + "open_slots": book.count(), + "proxy_on": bool(p), + "signal_db": bool(signal_repo), + "signals_persisted": bool(signal_repo), + "signals_sqlite_path": (settings.database.sqlite_path or "").strip(), + } + + +@app.get("/", response_model=None) +async def root(request: Request) -> RedirectResponse: + if settings.auth.enabled and not _session_ok(request): + return RedirectResponse("/login", status_code=302) + return RedirectResponse("/dashboard", status_code=302) + + +@app.get("/login", response_model=None) +async def login_page(request: Request) -> HTMLResponse | RedirectResponse: + if not settings.auth.enabled: + return RedirectResponse("/dashboard", status_code=302) + if _session_ok(request): + return RedirectResponse("/dashboard", status_code=302) + return templates.TemplateResponse( + request, + "login.html", + {"asset_version": _asset_version()}, + ) + + +@app.post("/login", response_model=None) +async def login_post(request: Request, body: LoginBody) -> JSONResponse | RedirectResponse: + if not settings.auth.enabled: + return JSONResponse({"ok": True, "redirect": "/dashboard"}) + if body.username.strip() != settings.auth.username.strip() or _hash_password(body.password) != _password_hash(): + return JSONResponse({"ok": False, "detail": "账号或密码错误"}, status_code=401) + request.session["logged_in"] = True + return JSONResponse({"ok": True, "redirect": "/dashboard"}) + + +@app.get("/logout", response_model=None) +async def logout(request: Request) -> RedirectResponse: + request.session.clear() + return RedirectResponse("/login" if settings.auth.enabled else "/", status_code=302) + + +@app.get("/dashboard", response_model=None) +async def dashboard(request: Request) -> HTMLResponse | RedirectResponse: + if settings.auth.enabled and not _session_ok(request): + return RedirectResponse("/login", status_code=302) + return templates.TemplateResponse( + request, + "dashboard.html", + { + "username": settings.auth.username if settings.auth.enabled else "local", + "asset_version": _asset_version(), + }, + ) + + +@app.get("/api/state") +async def api_state(request: Request) -> dict: + _require_ui_session(request) + p = effective_proxy_url(settings.proxy.enabled, settings.proxy.url) + fa: dict | None = None + fa_err: str | None = None + po: list[dict] | None = None + po_err: str | None = None + ex_pos: list[dict] | None = None + ex_pos_err: str | None = None + if settings.gate.api_key.strip() and settings.gate.api_secret.strip(): + fa, fa_err = await read_futures_balance(settings) + po, po_err = await list_open_price_orders(settings) + ex_pos, ex_pos_err = await list_futures_positions(settings) + return { + "dry_run": settings.gate.dry_run, + "live_trading_enabled": (not settings.gate.dry_run) + and bool(settings.gate.api_key.strip() and settings.gate.api_secret.strip()), + "gate_api_configured": bool(settings.gate.api_key.strip() and settings.gate.api_secret.strip()), + "test_orders_enabled": bool(settings.gate.test_orders_enabled), + "test_max_contracts": int(settings.gate.test_max_contracts), + "futures_account": fa, + "futures_account_error": fa_err, + "open_price_orders": po, + "open_price_orders_error": po_err, + "proxy": { + "enabled": settings.proxy.enabled, + "effective": bool(p), + "url": settings.proxy.url if settings.proxy.enabled else "", + }, + "risk": { + "risk_per_trade_frac": settings.risk.risk_per_trade_frac, + "max_open_positions": settings.risk.max_open_positions, + "scheme": settings.risk.scheme, + "min_reward_risk_ratio": read_effective_min_reward_risk_ratio(settings), + "min_reward_risk_ratio_default": float(settings.risk.min_reward_risk_ratio), + }, + "breakeven": await build_breakeven_state_for_api( + settings, + exchange_positions=ex_pos if isinstance(ex_pos, list) else None, + ), + "positions": { + "open_slot_count": book.count(), + "exchange": ex_pos, + "exchange_error": ex_pos_err, + }, + "recent_signals": _recent_signals_for_state(), + "signals_persisted": bool(signal_repo), + "signals_sqlite_path": (settings.database.sqlite_path or "").strip(), + } + + +def _recent_signals_for_state() -> list[dict]: + if not signal_repo: + return signal_history.list_recent() + try: + return signal_repo.list_recent(100) + except Exception: # noqa: BLE001 + logger.exception("signal_db_list_failed") + return signal_history.list_recent() + + +def _signals_export_rows(limit: int = 500) -> list[dict]: + if signal_repo: + try: + return signal_repo.list_recent(limit) + except Exception: # noqa: BLE001 + logger.exception("signal_export_list_failed") + return signal_history.list_recent() + + +def _test_http_status(body: GateTestRequest, out: dict) -> int: + if body.action == "balance": + return 502 if out.get("error") else 200 + return 400 if out.get("ok") is False else 200 + + +async def _run_gate_test(body: GateTestRequest) -> dict: + if body.action == "balance": + data, err = await read_futures_balance(settings) + return {"action": "balance", "balance": data, "error": err} + if body.action != "micro_market": + return {"ok": False, "error": "unsupported_action"} + if not body.contract.strip(): + return {"ok": False, "error": "contract_required"} + return await post_test_market_order( + settings, + contract=body.contract.strip(), + side=body.side, + size_qty=body.size, + ) + + +@app.post("/api/test") +async def api_test(request: Request, body: GateTestRequest) -> JSONResponse: + _require_ui_session(request) + out = await _run_gate_test(body) + return JSONResponse(out, status_code=_test_http_status(body, out)) + + +@app.post("/api/risk-prefs") +async def api_risk_prefs(request: Request, body: RiskPrefsBody) -> JSONResponse: + """面板保存最低盈亏比到 runtime/risk_prefs.json(需登录会话)。""" + _require_ui_session(request) + try: + v = write_min_reward_risk_ratio(body.min_reward_risk_ratio) + except ValueError as exc: + return JSONResponse({"ok": False, "detail": str(exc)}, status_code=400) + return JSONResponse({"ok": True, "min_reward_risk_ratio": v}) + + +@app.post("/api/breakeven-prefs") +async def api_breakeven_prefs(request: Request, body: BreakevenPrefsBody) -> JSONResponse: + """保存移动保本全局/单合约开关到 runtime/breakeven_prefs.json。""" + _require_ui_session(request) + out: dict[str, Any] = {"ok": True} + if body.global_enabled is not None: + out["global_enabled"] = write_global_enabled(bool(body.global_enabled)) + if body.contract is not None and body.enabled is not None: + try: + ct, en = write_contract_enabled(body.contract, bool(body.enabled)) + except ValueError as exc: + return JSONResponse({"ok": False, "detail": str(exc)}, status_code=400) + remove_active(ct) + out["contract"] = ct + out["enabled"] = en + if body.global_enabled is None and (body.contract is None or body.enabled is None): + return JSONResponse({"ok": False, "detail": "nothing_to_save"}, status_code=400) + out["global_enabled"] = read_effective_global_enabled(settings) + out["prefs"] = read_prefs_snapshot() + return JSONResponse(out) + + +@app.post("/api/positions/market_close") +async def api_positions_market_close(request: Request, body: ClosePositionBody) -> JSONResponse: + _require_ui_session(request) + ct = body.contract.strip().upper() + order, err = await market_close_futures_position(settings, contract=ct) + if err: + bad_req = { + "missing_api_keys", + "dry_run_enabled", + "no_position", + "invalid_close_size", + } + code = 400 if err in bad_req else 502 + try: + await notify_manual_close(settings, contract=ct, ok=False, detail=err, order=None) + except Exception: # noqa: BLE001 + logger.exception("wecom_notify_manual_close_failed") + return JSONResponse({"ok": False, "detail": err}, status_code=code) + book.release(ct) + remove_active(ct) + try: + await notify_manual_close(settings, contract=ct, ok=True, detail=None, order=order if isinstance(order, dict) else None) + except Exception: # noqa: BLE001 + logger.exception("wecom_notify_manual_close_failed") + return JSONResponse({"ok": True, "order": order}) + + +@app.post("/api/price_orders/manual") +async def api_price_orders_manual(request: Request, body: ManualPriceOrderBody) -> JSONResponse: + _require_ui_session(request) + resp, err = await post_reduce_close_price_order( + settings, + contract=body.contract.strip().upper(), + trigger_price=body.trigger_price.strip(), + rule=body.rule, + ) + if err: + bad_req = { + "missing_api_keys", + "dry_run_enabled", + "invalid_trigger_price", + "invalid_rule", + } + code = 400 if err in bad_req else 502 + return JSONResponse({"ok": False, "detail": err}, status_code=code) + return JSONResponse({"ok": True, "price_order": resp}) + + +@app.post("/api/price_orders/cancel") +async def api_cancel_plan_order(request: Request, body: CancelPlanOrderBody) -> JSONResponse: + _require_ui_session(request) + ok, err = await cancel_plan_price_order(settings, body.order_id) + if ok: + return JSONResponse({"ok": True}) + return JSONResponse({"ok": False, "detail": err or "cancel_failed"}, status_code=400) + + +@app.get("/api/stats/summary") +async def api_stats_summary( + request: Request, + contract: str | None = None, +) -> dict: + """正式统计:日/周/月(上海 08:00 统计日)基于 Gate 历史平仓 position_close 的 pnl 聚合。""" + _require_ui_session(request) + if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()): + raise HTTPException(status.HTTP_400_BAD_REQUEST, "api keys not configured") + c = _norm_contract_hist(contract) + return await build_dashboard_stats(settings, contract=c) + + +@app.get("/api/gate/trades") +async def api_gate_trades( + request: Request, + contract: str | None = None, + from_ts: int | None = Query(default=None, alias="from"), + to_ts: int | None = Query(default=None, alias="to"), + limit: int = Query(50, ge=1, le=500), + offset: int = Query(0, ge=0), +) -> dict: + """成交:Gate `GET /futures/{{settle}}/my_trades_timerange`(与面板「下载」同源)。""" + _require_ui_session(request) + if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()): + raise HTTPException(status.HTTP_400_BAD_REQUEST, "api keys not configured") + f, t = _resolve_timerange(from_ts, to_ts) + c = _norm_contract_hist(contract) + rows, err = await fetch_my_trades_timerange( + settings, contract=c, from_ts=f, to_ts=t, limit=limit, offset=offset + ) + return { + "source": "gate", + "endpoint": "futures/my_trades_timerange", + "contract": c, + "from": f, + "to": t, + "limit": limit, + "offset": offset, + "rows": rows, + "error": err, + } + + +@app.get("/api/gate/orders_history") +async def api_gate_orders_history( + request: Request, + status: str = Query("finished"), + contract: str | None = None, + limit: int = Query(50, ge=1, le=500), + offset: int = Query(0, ge=0), +) -> dict: + """委托列表:Gate `GET /futures/{{settle}}/orders`(status=open|finished)。""" + _require_ui_session(request) + if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()): + raise HTTPException(status.HTTP_400_BAD_REQUEST, "api keys not configured") + st = status.strip().lower() + if st not in ("open", "finished"): + raise HTTPException(status.HTTP_400_BAD_REQUEST, "status must be open or finished") + c = _norm_contract_hist(contract) + rows, err = await fetch_orders_list( + settings, status=st, contract=c, limit=limit, offset=offset + ) + return { + "source": "gate", + "endpoint": "futures/orders", + "status": st, + "contract": c, + "limit": limit, + "offset": offset, + "rows": rows, + "error": err, + } + + +@app.get("/api/gate/trades.csv", response_model=None) +async def api_gate_trades_csv( + request: Request, + contract: str | None = None, + from_ts: int | None = Query(default=None, alias="from"), + to_ts: int | None = Query(default=None, alias="to"), + max_rows: int = Query(2000, ge=1, le=5000, alias="max"), +) -> Response: + _require_ui_session(request) + f, t = _resolve_timerange(from_ts, to_ts) + c = _norm_contract_hist(contract) + csv_text, err = await collect_trades_csv( + settings, contract=c, from_ts=f, to_ts=t, max_rows=max_rows + ) + if err: + return JSONResponse({"detail": err}, status_code=502) + if csv_text is None: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "api keys not configured") + return Response( + content=csv_text.encode("utf-8"), + media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": 'attachment; filename="gate_futures_trades.csv"'}, + ) + + +@app.get("/api/gate/orders_history.csv", response_model=None) +async def api_gate_orders_history_csv( + request: Request, + status: str = Query("finished"), + contract: str | None = None, + max_rows: int = Query(2000, ge=1, le=5000, alias="max"), +) -> Response: + _require_ui_session(request) + if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()): + raise HTTPException(status.HTTP_400_BAD_REQUEST, "api keys not configured") + st = status.strip().lower() + if st not in ("open", "finished"): + raise HTTPException(status.HTTP_400_BAD_REQUEST, "status must be open or finished") + c = _norm_contract_hist(contract) + csv_text, err = await collect_orders_csv( + settings, status=st, contract=c, max_rows=max_rows + ) + if err: + return JSONResponse({"detail": err}, status_code=502) + if csv_text is None: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "api keys not configured") + fn = "gate_futures_orders_open.csv" if st == "open" else "gate_futures_orders_finished.csv" + return Response( + content=csv_text.encode("utf-8"), + media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": f'attachment; filename="{fn}"'}, + ) + + +@app.get("/api/signals/export.csv", response_model=None) +async def api_signals_export_csv(request: Request) -> Response: + """面板「信号流」导出;需登录会话。""" + _require_ui_session(request) + rows = _signals_export_rows(500) + buf = io.StringIO() + w = csv.writer(buf) + w.writerow( + [ + "ts_unix", + "time_utc", + "signal_id", + "contract", + "side", + "reference_price_used", + "take_profit_display", + "stop_loss_display", + "reward_risk_ratio", + "result_status", + "result_reason", + ] + ) + for row in rows: + s = row.get("signal") or {} + r = row.get("result") or {} + ts = row.get("ts") + try: + ts_f = float(ts) if ts is not None else 0.0 + except (TypeError, ValueError): + ts_f = 0.0 + tstr = datetime.fromtimestamp(ts_f, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + rp = r.get("reference_price_used") + if rp is None: + rp = r.get("reference_entry") + tp = r.get("take_profit_display") + if tp is None: + tp = r.get("take_profit_price_sent") + if tp is None: + tp = s.get("take_profit") + sl = r.get("stop_loss_display") + if sl is None: + sl = r.get("stop_loss_price_sent") + if sl is None: + sl = s.get("stop_loss") + rr = r.get("reward_risk_ratio") + w.writerow( + [ + f"{ts_f:.6f}", + tstr, + s.get("signal_id") or "", + s.get("contract") or "", + s.get("side") or "", + "" if rp is None else rp, + "" if tp is None else tp, + "" if sl is None else sl, + "" if rr is None else rr, + r.get("status") or "", + r.get("reason") or "", + ] + ) + return Response( + content=buf.getvalue().encode("utf-8-sig"), + media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": 'attachment; filename="signal_stream.csv"'}, + ) + + +@app.post("/v1/test") +async def v1_test( + body: GateTestRequest, + x_webhook_secret: str | None = Header(default=None, alias="X-Webhook-Secret"), +) -> JSONResponse: + expected = (settings.security.webhook_secret or "").strip() + if not expected or (x_webhook_secret or "").strip() != expected: + raise HTTPException(status_code=401, detail="invalid webhook secret") + out = await _run_gate_test(body) + return JSONResponse(out, status_code=_test_http_status(body, out)) + + +@app.post("/v1/signal") +async def post_signal( + body: TradeSignal, + x_webhook_secret: str | None = Header(default=None, alias="X-Webhook-Secret"), +) -> JSONResponse: + expected = (settings.security.webhook_secret or "").strip() + if not expected or (x_webhook_secret or "").strip() != expected: + raise HTTPException(status_code=401, detail="invalid webhook secret") + + min_rr = read_effective_min_reward_risk_ratio(settings) + try: + m = await compute_signal_stream_metrics(settings, body, prior=None) + except Exception as exc: # noqa: BLE001 + logger.warning("signal_metrics_pre_gate failed signal_id=%s: %s", body.signal_id, exc) + out = { + "status": "skipped", + "reason": "reward_risk_missing", + "min_reward_risk_ratio": min_rr, + "reward_risk_reason": "metrics_failed", + "metrics_error": str(exc), + } + else: + rr = m.get("reward_risk_ratio") + if rr is None: + out = {"status": "skipped", "reason": "reward_risk_missing", "min_reward_risk_ratio": min_rr} + out.update(m) + elif float(rr) < min_rr: + out = {"status": "skipped", "reason": "reward_risk_below_min", "min_reward_risk_ratio": min_rr} + out.update(m) + else: + out = await handle_signal(settings, book, body) + out = await augment_signal_result(settings, body, out) + code = 200 if out.get("status") in {"accepted", "skipped"} else 500 + signal_history.push({"signal": body.model_dump(), "result": out}) + if signal_repo: + try: + signal_repo.insert_run(body.model_dump(), out, code) + except Exception as exc: # noqa: BLE001 + logger.exception("signal_db_insert_failed") + try: + await notify_signal_db_insert_failed( + settings, + signal_id=str(body.signal_id or ""), + detail=str(exc), + ) + except Exception: # noqa: BLE001 + logger.exception("wecom_notify_signal_db_failed") + try: + await notify_signal_execution(settings, signal=body.model_dump(), result=out, http_status=code) + except Exception: # noqa: BLE001 + logger.exception("wecom_notify_signal_failed") + return JSONResponse(out, status_code=code) diff --git a/gate_order_executor/app/models_signal.py b/gate_order_executor/app/models_signal.py new file mode 100644 index 0000000..7139c09 --- /dev/null +++ b/gate_order_executor/app/models_signal.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field + + +Side = Literal["long", "short"] + + +class TradeSignal(BaseModel): + """ + 扫描端在 TRIGGER(且你允许自动执行)时 POST 的载荷。 + TP/SL 对应推送里「方案 A」已算好的价格;执行器永远按 scheme A 使用本字段。 + """ + + signal_id: str = Field(..., description="幂等键,建议 uuid 或 交易对+确认K时间戳") + contract: str = Field(..., description="Gate 永续合约名,如 BTC_USDT、XAU_USDT") + side: Side + take_profit: float = Field(..., gt=0, description="方案 A 止盈价") + stop_loss: float = Field(..., gt=0, description="方案 A 止损价") + # 可选:扫描端带的确认收盘价,用于日志与复核;市价单以成交为准 + reference_price: float | None = Field(None, gt=0, description="如确认K收盘价") diff --git a/gate_order_executor/app/models_test.py b/gate_order_executor/app/models_test.py new file mode 100644 index 0000000..7fca5a5 --- /dev/null +++ b/gate_order_executor/app/models_test.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field + + +class GateTestRequest(BaseModel): + """面板 / Webhook 测试请求体。""" + + action: Literal["balance", "micro_market"] = "balance" + contract: str = Field("", description="如 BTC_USDT;micro_market 时必填") + side: Literal["long", "short"] = "long" + size: int = Field(1, ge=1, le=30, description="张数绝对值,服务端再与 test_max_contracts 取小") diff --git a/gate_order_executor/app/oco_watcher.py b/gate_order_executor/app/oco_watcher.py new file mode 100644 index 0000000..a6477c5 --- /dev/null +++ b/gate_order_executor/app/oco_watcher.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import asyncio +import logging +import time +from typing import Any + +from .config import Settings +from .gate_futures_live import ( + GateFuturesLive, + cancel_price_triggered_order, + fetch_net_position_size, +) +from .wecom_notify import notify_oco_cancel_failed + +logger = logging.getLogger(__name__) + +_POLL_SEC = 18.0 +_MAX_AGE_SEC = 604800.0 # 与计划单 expiration 同量级,超时丢弃避免列表泄漏 + +_pending: list[dict[str, Any]] = [] +_lock = asyncio.Lock() +_task: asyncio.Task[None] | None = None + + +def _live_ok(settings: Settings) -> bool: + g = settings.gate + return (not g.dry_run) and bool(g.api_key.strip() and g.api_secret.strip()) + + +async def update_oco_sl_order_id(settings: Settings, *, contract: str, new_sl_id: str | int) -> None: + """移动保本改挂新 SL 后,同步 OCO 清理队列中的 sl_id。""" + ct = contract.strip().upper() + nid = new_sl_id + async with _lock: + for row in _pending: + if str(row.get("contract") or "").strip().upper() != ct: + continue + row["sl_id"] = nid + logger.info("oco_sl_id_updated contract=%s new_sl_id=%s", ct, nid) + + +async def register_tp_sl_oco_cleanup( + settings: Settings, + *, + contract: str, + tp_order: dict[str, Any], + sl_order: dict[str, Any], +) -> None: + """ + 登记一笔开仓挂出的止盈/止损计划单。当该合约净持仓为 0 时,尝试撤销两条计划单(另一腿未触发则清掉)。 + """ + if not settings.risk.oco_cleanup_enabled: + return + if not _live_ok(settings): + return + tp_id = tp_order.get("id") if tp_order.get("id") is not None else tp_order.get("id_string") + sl_id = sl_order.get("id") if sl_order.get("id") is not None else sl_order.get("id_string") + if tp_id is None or sl_id is None: + return + row = { + "settings": settings, + "contract": contract.strip().upper(), + "tp_id": tp_id, + "sl_id": sl_id, + "t": time.monotonic(), + } + async with _lock: + _pending.append(row) + _ensure_loop() + + +def _ensure_loop() -> None: + global _task + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return + if _task is not None and not _task.done(): + return + _task = loop.create_task(_poll_loop(), name="oco_price_order_cleanup") + + +async def _poll_loop() -> None: + while True: + await asyncio.sleep(_POLL_SEC) + try: + await _tick() + except asyncio.CancelledError: + raise + except Exception: # noqa: BLE001 + logger.exception("oco_watcher_tick_failed") + + +async def _tick() -> None: + async with _lock: + rows = list(_pending) + if not rows: + return + now = time.monotonic() + keep: list[dict[str, Any]] = [] + for row in rows: + settings: Settings = row["settings"] + if not _live_ok(settings): + continue + if now - float(row["t"]) > _MAX_AGE_SEC: + logger.info("oco_watch_expired contract=%s", row["contract"]) + continue + client = GateFuturesLive(settings) + contract = row["contract"] + try: + net = await fetch_net_position_size(client, contract) + except Exception as exc: # noqa: BLE001 + logger.warning("oco_fetch_position_failed contract=%s: %s", contract, exc) + keep.append(row) + continue + if abs(net) > 1e-12: + keep.append(row) + continue + tp_id, sl_id = row["tp_id"], row["sl_id"] + cleanup_failed = False + for name, oid in (("tp", tp_id), ("sl", sl_id)): + try: + ok = await cancel_price_triggered_order(client, oid) + if ok: + logger.info("oco_cancelled contract=%s leg=%s order_id=%s", contract, name, oid) + except Exception as exc: # noqa: BLE001 + logger.warning("oco_cancel_failed contract=%s leg=%s id=%s: %s", contract, name, oid, exc) + cleanup_failed = True + try: + await notify_oco_cancel_failed( + settings, + contract=contract, + leg=name, + order_id=str(oid), + detail=str(exc), + ) + except Exception: # noqa: BLE001 + logger.exception("wecom_notify_oco_failed") + if cleanup_failed: + keep.append(row) + async with _lock: + _pending[:] = keep + [r for r in _pending if r not in rows] diff --git a/gate_order_executor/app/positions.py b/gate_order_executor/app/positions.py new file mode 100644 index 0000000..b6350b2 --- /dev/null +++ b/gate_order_executor/app/positions.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import threading +import time +from dataclasses import dataclass, field + + +@dataclass +class OpenSlot: + contract: str + signal_id: str + opened_at: float = field(default_factory=time.time) + + +class PositionBook: + """进程内占位:实盘应对接交易所持仓或本地持久化。""" + + def __init__(self, max_positions: int) -> None: + self._max = max_positions + self._lock = threading.Lock() + self._slots: dict[str, OpenSlot] = {} + + def count(self) -> int: + with self._lock: + return len(self._slots) + + def has_contract(self, contract: str) -> bool: + c = contract.strip().upper() + with self._lock: + return c in self._slots + + def try_reserve(self, contract: str, signal_id: str) -> bool: + c = contract.strip().upper() + with self._lock: + if c in self._slots: + return False + if len(self._slots) >= self._max: + return False + self._slots[c] = OpenSlot(contract=c, signal_id=signal_id) + return True + + def release(self, contract: str) -> None: + c = contract.strip().upper() + with self._lock: + self._slots.pop(c, None) + + def sync_from_exchange(self, open_contracts: set[str]) -> None: + """移除本地有占位但交易所已无持仓的合约(避免槽位永久占满)。""" + with self._lock: + for c in list(self._slots.keys()): + if c not in open_contracts: + self._slots.pop(c, None) diff --git a/gate_order_executor/app/proxy_util.py b/gate_order_executor/app/proxy_util.py new file mode 100644 index 0000000..e7356f9 --- /dev/null +++ b/gate_order_executor/app/proxy_util.py @@ -0,0 +1,39 @@ +"""与 onchain_scout_gate 相同的代理 URL 处理,供 httpx 出站(Gate 私有 API 等)。""" + +from __future__ import annotations + +import httpx + + +def httpx_proxy_url(proxy_url: str | None) -> str | None: + """ + 将配置中的代理地址转为 httpx 可用形式。 + ``socks5h://`` 在部分环境下会报 Unknown scheme,退化为 ``socks5://``。 + """ + if not proxy_url or not str(proxy_url).strip(): + return None + u = str(proxy_url).strip() + if u.startswith("socks5h://"): + return "socks5://" + u[len("socks5h://") :] + return u + + +def effective_proxy_url(proxy_enabled: bool, proxy_url: str | None) -> str | None: + if not proxy_enabled: + return None + return httpx_proxy_url(proxy_url.strip() if proxy_url else None) + + +def httpx_client_kwargs( + proxy_enabled: bool, + proxy_url: str | None, + *, + timeout_connect: float = 10.0, + timeout_read: float = 16.0, +) -> dict: + """与扫描端 Gate 客户端一致的出站策略:有代理则 trust_env=False。""" + timeout = httpx.Timeout(timeout_connect, read=timeout_read) + p = effective_proxy_url(proxy_enabled, proxy_url) + if p: + return {"timeout": timeout, "proxy": p, "trust_env": False} + return {"timeout": timeout, "trust_env": True} diff --git a/gate_order_executor/app/risk_prefs_store.py b/gate_order_executor/app/risk_prefs_store.py new file mode 100644 index 0000000..7498016 --- /dev/null +++ b/gate_order_executor/app/risk_prefs_store.py @@ -0,0 +1,63 @@ +"""面板可写的风险偏好:持久化到 runtime/risk_prefs.json。""" + +from __future__ import annotations + +import json +import logging +import threading +from pathlib import Path +from typing import Any + +from .config import Settings + +logger = logging.getLogger(__name__) + +_ROOT = Path(__file__).resolve().parent.parent +_PREFS_PATH = _ROOT / "runtime" / "risk_prefs.json" +_lock = threading.Lock() + + +def _read_json_file(path: Path) -> dict[str, Any] | None: + if not path.is_file(): + return None + try: + raw = path.read_text(encoding="utf-8").strip() + if not raw: + return None + data = json.loads(raw) + return data if isinstance(data, dict) else None + except (OSError, json.JSONDecodeError) as exc: + logger.warning("risk_prefs_read_failed: %s", exc) + return None + + +def read_effective_min_reward_risk_ratio(settings: Settings) -> float: + """优先 runtime 文件,否则 risk.min_reward_risk_ratio(config 默认)。""" + base = float(settings.risk.min_reward_risk_ratio) + with _lock: + data = _read_json_file(_PREFS_PATH) + if not data: + return base + try: + v = float(data.get("min_reward_risk_ratio")) + except (TypeError, ValueError): + return base + lo, hi = 0.1, 50.0 + if not (lo <= v <= hi): + return base + return v + + +def write_min_reward_risk_ratio(value: float) -> float: + """写入并返回规范化后的值(与读侧范围一致)。""" + lo, hi = 0.1, 50.0 + v = float(value) + if not (lo <= v <= hi): + raise ValueError(f"min_reward_risk_ratio must be in [{lo}, {hi}]") + payload = json.dumps({"min_reward_risk_ratio": v}, indent=2, ensure_ascii=False) + "\n" + with _lock: + _PREFS_PATH.parent.mkdir(parents=True, exist_ok=True) + tmp = _PREFS_PATH.with_suffix(".json.tmp") + tmp.write_text(payload, encoding="utf-8") + tmp.replace(_PREFS_PATH) + return v diff --git a/gate_order_executor/app/signal_history.py b/gate_order_executor/app/signal_history.py new file mode 100644 index 0000000..e003404 --- /dev/null +++ b/gate_order_executor/app/signal_history.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import threading +import time +from collections import deque + + +class SignalHistory: + """最近信号与执行结果(内存,进程重启清空)。""" + + def __init__(self, maxlen: int = 100) -> None: + self._q: deque[dict] = deque(maxlen=maxlen) + self._lock = threading.Lock() + + def push(self, item: dict) -> None: + with self._lock: + self._q.appendleft({**item, "ts": time.time()}) + + def list_recent(self) -> list[dict]: + with self._lock: + return list(self._q) diff --git a/gate_order_executor/app/signal_metrics.py b/gate_order_executor/app/signal_metrics.py new file mode 100644 index 0000000..8ff40bf --- /dev/null +++ b/gate_order_executor/app/signal_metrics.py @@ -0,0 +1,100 @@ +"""信号流展示:现价、按合约 tick 对齐的 TP/SL 字符串、盈亏比(相对现价)。""" +from __future__ import annotations + +import logging +from typing import Any + +from .config import Settings +from .gate_futures_live import GateFuturesLive, _float +from .gate_price_rounding import _format_trigger_price, _trigger_price_tick +from .models_signal import TradeSignal + +logger = logging.getLogger(__name__) + + +def _reward_risk_ratio(side: str, p: float, tp: float, sl: float) -> tuple[float | None, str | None]: + if not (p > 0 and tp > 0 and sl > 0): + return None, "invalid_prices" + if side == "long": + reward = tp - p + risk = p - sl + elif side == "short": + reward = p - tp + risk = sl - p + else: + return None, "invalid_side" + if risk <= 0: + return None, "non_positive_risk" + if reward <= 0: + return None, "non_positive_reward" + return reward / risk, None + + +async def compute_signal_stream_metrics( + settings: Settings, sig: TradeSignal, prior: dict[str, Any] | None = None +) -> dict[str, Any]: + """ + 现价:优先信号 reference_price;否则 ticker last;再否则 prior.reference_entry(实盘已算 entry)。 + 止盈/止损展示:与下单相同的 tick 对齐字符串。 + """ + client = GateFuturesLive(settings) + contract = sig.contract.strip().upper() + + ticker = await client._public_get(f"{client._prefix}/tickers", params={"contract": contract}) + last = 0.0 + if isinstance(ticker, list) and ticker: + last = _float(ticker[0].get("last")) + elif isinstance(ticker, dict): + last = _float(ticker.get("last")) + + p: float | None = None + if sig.reference_price is not None and float(sig.reference_price) > 0: + p = float(sig.reference_price) + elif last > 0: + p = last + elif prior: + ref_e = prior.get("reference_entry") + if ref_e is not None: + try: + pe = float(ref_e) + except (TypeError, ValueError): + pe = 0.0 + if pe > 0: + p = pe + + cdata = await client._public_get(f"{client._prefix}/contracts/{contract}") + if not isinstance(cdata, dict): + raise ValueError("contract_not_found") + + tick = _trigger_price_tick(cdata) + tp_s = _format_trigger_price(float(sig.take_profit), tick) + sl_s = _format_trigger_price(float(sig.stop_loss), tick) + + rr: float | None = None + rr_reason: str | None = None + if p is not None and p > 0: + try: + tp_f = float(tp_s) + sl_f = float(sl_s) + except ValueError: + rr_reason = "invalid_trigger_float" + else: + rr, rr_reason = _reward_risk_ratio(str(sig.side), p, tp_f, sl_f) + + return { + "reference_price_used": float(p) if p is not None and p > 0 else None, + "take_profit_display": tp_s, + "stop_loss_display": sl_s, + "reward_risk_ratio": round(rr, 6) if rr is not None else None, + "reward_risk_reason": rr_reason, + } + + +async def augment_signal_result(settings: Settings, sig: TradeSignal, result: dict[str, Any]) -> dict[str, Any]: + out = dict(result) + try: + m = await compute_signal_stream_metrics(settings, sig, prior=out) + out.update(m) + except Exception as exc: # noqa: BLE001 + logger.warning("signal_metrics failed signal_id=%s: %s", sig.signal_id, exc) + return out diff --git a/gate_order_executor/app/signal_repository.py b/gate_order_executor/app/signal_repository.py new file mode 100644 index 0000000..5bcfbbb --- /dev/null +++ b/gate_order_executor/app/signal_repository.py @@ -0,0 +1,204 @@ +"""信号与执行结果 SQLite 落库(标准库 sqlite3)。""" +from __future__ import annotations + +import json +import logging +import sqlite3 +import threading +import time +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + + +def _gate_order_id(obj: Any) -> str | None: + if not isinstance(obj, dict): + return None + oid = obj.get("id") + if oid is None: + oid = obj.get("order_id") + if oid is None: + return None + s = str(oid).strip() + return s or None + + +def _resolve_sqlite_path(raw: str, root: Path) -> Path: + p = Path(raw.strip()) + if not p.is_absolute(): + p = (root / p).resolve() + return p + + +class SignalRepository: + """线程安全;每条 POST /v1/signal 处理完成后写入一行。""" + + def __init__(self, sqlite_path: Path) -> None: + self._path = sqlite_path + self._lock = threading.Lock() + + @classmethod + def from_settings(cls, sqlite_path_cfg: str, root: Path) -> SignalRepository | None: + """配置了非空 ``sqlite_path`` 即落库;空字符串则仅内存环形表(与旧版一致)。""" + raw = (sqlite_path_cfg or "").strip() + if not raw: + return None + return cls(_resolve_sqlite_path(raw, root)) + + def init_schema(self) -> None: + self._path.parent.mkdir(parents=True, exist_ok=True) + with self._lock: + con = sqlite3.connect(self._path, check_same_thread=False) + try: + con.execute( + """ + CREATE TABLE IF NOT EXISTS signal_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at REAL NOT NULL, + http_status INTEGER NOT NULL, + signal_id TEXT NOT NULL, + contract TEXT NOT NULL, + side TEXT NOT NULL, + take_profit REAL, + stop_loss REAL, + reference_price REAL, + signal_json TEXT NOT NULL, + result_status TEXT NOT NULL, + result_mode TEXT, + result_reason TEXT, + result_detail TEXT, + stage TEXT, + market_order_id TEXT, + take_profit_order_id TEXT, + stop_loss_order_id TEXT, + result_json TEXT NOT NULL + ) + """ + ) + con.execute( + "CREATE INDEX IF NOT EXISTS idx_signal_runs_signal_id ON signal_runs(signal_id)" + ) + con.execute( + "CREATE INDEX IF NOT EXISTS idx_signal_runs_created_at ON signal_runs(created_at)" + ) + con.commit() + finally: + con.close() + + def insert_run(self, signal: dict[str, Any], result: dict[str, Any], http_status: int) -> None: + created = time.time() + sig_json = json.dumps(signal, ensure_ascii=False, separators=(",", ":")) + res_json = json.dumps(result, ensure_ascii=False, separators=(",", ":")) + market_obj = result.get("market_order") or result.get("order") + detail_raw = result.get("detail") + if detail_raw is None: + detail_s: str | None = None + elif isinstance(detail_raw, str): + detail_s = detail_raw + else: + try: + detail_s = json.dumps(detail_raw, ensure_ascii=False) + except Exception: + detail_s = str(detail_raw) + + row = ( + created, + int(http_status), + str(signal.get("signal_id") or ""), + str(signal.get("contract") or "").strip().upper(), + str(signal.get("side") or ""), + signal.get("take_profit"), + signal.get("stop_loss"), + signal.get("reference_price"), + sig_json, + str(result.get("status") or ""), + result.get("mode"), + result.get("reason"), + detail_s, + result.get("stage") if isinstance(result.get("stage"), str) else None, + _gate_order_id(market_obj), + _gate_order_id(result.get("take_profit_order")), + _gate_order_id(result.get("stop_loss_order")), + res_json, + ) + with self._lock: + con = sqlite3.connect(self._path, check_same_thread=False) + try: + con.execute( + """ + INSERT INTO signal_runs ( + created_at, http_status, signal_id, contract, side, + take_profit, stop_loss, reference_price, signal_json, + result_status, result_mode, result_reason, result_detail, stage, + market_order_id, take_profit_order_id, stop_loss_order_id, result_json + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + """, + row, + ) + con.commit() + finally: + con.close() + + def find_latest_accepted_for_contract(self, contract: str) -> dict[str, Any] | None: + ct = str(contract or "").strip().upper() + if not ct: + return None + with self._lock: + con = sqlite3.connect(self._path, check_same_thread=False) + try: + cur = con.execute( + """ + SELECT created_at, signal_json, result_json + FROM signal_runs + WHERE contract = ? AND result_status = 'accepted' + ORDER BY id DESC + LIMIT 1 + """, + (ct,), + ) + row = cur.fetchone() + finally: + con.close() + if not row: + return None + created_at, sig_j, res_j = row + try: + sig = json.loads(sig_j) + except Exception: + sig = {} + try: + res = json.loads(res_j) + except Exception: + res = {} + return {"ts": float(created_at), "signal": sig, "result": res} + + def list_recent(self, limit: int = 100) -> list[dict[str, Any]]: + lim = max(1, min(int(limit), 500)) + with self._lock: + con = sqlite3.connect(self._path, check_same_thread=False) + try: + cur = con.execute( + """ + SELECT created_at, signal_json, result_json + FROM signal_runs + ORDER BY id DESC + LIMIT ? + """, + (lim,), + ) + rows = cur.fetchall() + finally: + con.close() + out: list[dict[str, Any]] = [] + for created_at, sig_j, res_j in rows: + try: + sig = json.loads(sig_j) + except Exception: + sig = {} + try: + res = json.loads(res_j) + except Exception: + res = {} + out.append({"ts": float(created_at), "signal": sig, "result": res}) + return out diff --git a/gate_order_executor/app/stats.py b/gate_order_executor/app/stats.py new file mode 100644 index 0000000..04d351b --- /dev/null +++ b/gate_order_executor/app/stats.py @@ -0,0 +1,315 @@ +from __future__ import annotations + +import time +from datetime import date, datetime, timedelta +from typing import Any +from zoneinfo import ZoneInfo + +from .config import Settings +from .gate_history import collect_position_close_rows + + +def _parse_official_start_iso(s: str) -> float: + raw = (s or "").strip() + if not raw: + raise ValueError("stats.official_start is empty") + if raw.endswith("Z"): + raw = raw[:-1] + "+00:00" + dt = datetime.fromisoformat(raw) + if dt.tzinfo is None: + raise ValueError("stats.official_start must include a timezone offset (e.g. +08:00)") + return dt.timestamp() + + +def _position_close_ts(row: dict[str, Any]) -> float | None: + """Gate `GET .../position_close` 行的平仓时间戳(字段 `time`)。""" + t = row.get("time") + if t is None: + return None + try: + return float(t) + except (TypeError, ValueError): + return None + + +def _float_field(row: dict[str, Any], *keys: str) -> float | None: + for k in keys: + v = row.get(k) + if v is None or v == "": + continue + try: + return float(v) + except (TypeError, ValueError): + return None + return None + + +def _row_close_pnl(row: dict[str, Any]) -> float | None: + """历史平仓记录上的净盈亏(字符串数值)。""" + return _float_field(row, "pnl", "realised_pnl", "realized_pnl") + + +def stat_day_date(ts: float, tz: ZoneInfo) -> date: + dt = datetime.fromtimestamp(ts, tz=tz) + shifted = dt - timedelta(hours=8) + return shifted.date() + + +def stat_month_key(ts: float, tz: ZoneInfo) -> tuple[int, int]: + dt = datetime.fromtimestamp(ts, tz=tz) + shifted = dt - timedelta(hours=8) + return shifted.year, shifted.month + + +def stat_day_window(d: date, tz: ZoneInfo) -> tuple[float, float]: + start = datetime(d.year, d.month, d.day, 8, 0, 0, tzinfo=tz) + end = start + timedelta(days=1) + return start.timestamp(), end.timestamp() + + +def month_window(y: int, m: int, tz: ZoneInfo) -> tuple[float, float]: + start = datetime(y, m, 1, 8, 0, 0, tzinfo=tz) + if m == 12: + end = datetime(y + 1, 1, 1, 8, 0, 0, tzinfo=tz) + else: + end = datetime(y, m + 1, 1, 8, 0, 0, tzinfo=tz) + return start.timestamp(), end.timestamp() + + +def monday_of_week(d: date) -> date: + return d - timedelta(days=d.weekday()) + + +def aggregate_pnls(pnls_ordered: list[float]) -> dict[str, Any]: + if not pnls_ordered: + return { + "trade_count": 0, + "wins": 0, + "losses": 0, + "breakeven": 0, + "win_rate": None, + "profit_factor": None, + "gross_profit": 0.0, + "gross_loss": 0.0, + "net_pnl": 0.0, + "max_single_loss": None, + "max_drawdown": 0.0, + "max_consecutive_losses": 0, + } + wins = losses = be = 0 + gp = 0.0 + gl = 0.0 + max_consec = 0 + streak = 0 + for p in pnls_ordered: + if p > 0: + wins += 1 + gp += p + streak = 0 + elif p < 0: + losses += 1 + gl += p + streak += 1 + max_consec = max(max_consec, streak) + else: + be += 1 + streak = 0 + total = len(pnls_ordered) + win_rate = wins / total if total else None + gross_loss_abs = abs(gl) + if gross_loss_abs > 1e-12: + profit_factor: float | None = gp / gross_loss_abs + else: + profit_factor = None + min_p = min(pnls_ordered) + max_single_loss = min_p if min_p < -1e-12 else None + eq = 0.0 + peak = 0.0 + mdd = 0.0 + for p in pnls_ordered: + eq += p + peak = max(peak, eq) + mdd = max(mdd, peak - eq) + return { + "trade_count": total, + "wins": wins, + "losses": losses, + "breakeven": be, + "win_rate": win_rate, + "profit_factor": profit_factor, + "gross_profit": gp, + "gross_loss": gl, + "net_pnl": sum(pnls_ordered), + "max_single_loss": max_single_loss, + "max_drawdown": mdd, + "max_consecutive_losses": max_consec, + } + + +def _detect_pnl_field(rows: list[dict[str, Any]]) -> str: + for r in rows: + if _float_field(r, "pnl") is not None: + return "pnl" + if _float_field(r, "realised_pnl", "realized_pnl") is not None: + return "realised_pnl" + return "missing" + + +def _pnls_for_stat_day(events: list[tuple[float, float]], d: date, tz: ZoneInfo, official_ts: float) -> list[float]: + out: list[tuple[float, float]] = [] + for ts, pnl in events: + if ts < official_ts: + continue + if stat_day_date(ts, tz) != d: + continue + out.append((ts, pnl)) + out.sort(key=lambda x: x[0]) + return [p for _, p in out] + + +def _pnls_for_week( + events: list[tuple[float, float]], mon: date, sun: date, tz: ZoneInfo, official_ts: float +) -> list[float]: + out: list[tuple[float, float]] = [] + for ts, pnl in events: + if ts < official_ts: + continue + dd = stat_day_date(ts, tz) + if dd < mon or dd > sun: + continue + out.append((ts, pnl)) + out.sort(key=lambda x: x[0]) + return [p for _, p in out] + + +def _pnls_for_month( + events: list[tuple[float, float]], y: int, m: int, tz: ZoneInfo, official_ts: float +) -> list[float]: + out: list[tuple[float, float]] = [] + for ts, pnl in events: + if ts < official_ts: + continue + if stat_month_key(ts, tz) != (y, m): + continue + out.append((ts, pnl)) + out.sort(key=lambda x: x[0]) + return [p for _, p in out] + + +async def build_dashboard_stats( + settings: Settings, + *, + contract: str | None, +) -> dict[str, Any]: + try: + tz = ZoneInfo(settings.stats.timezone) + except Exception as exc: # noqa: BLE001 + return {"ok": False, "error": f"invalid stats.timezone: {exc}"} + try: + official_ts = _parse_official_start_iso(settings.stats.official_start) + except ValueError as exc: + return {"ok": False, "error": str(exc)} + now = time.time() + cap = int(settings.stats.max_trade_rows) + rows, err = await collect_position_close_rows( + settings, + contract=contract, + from_ts=int(official_ts), + to_ts=int(now), + max_rows=cap, + ) + if err: + return {"ok": False, "error": err} + if rows is None: + return {"ok": False, "error": "api keys not configured"} + + missing_pnl = 0 + events: list[tuple[float, float]] = [] + for row in rows: + ts = _position_close_ts(row) + if ts is None or ts < official_ts: + continue + pnl = _row_close_pnl(row) + if pnl is None: + missing_pnl += 1 + continue + events.append((ts, pnl)) + events.sort(key=lambda x: x[0]) + + pnl_field = _detect_pnl_field(rows) + truncated = len(rows) >= cap + + d_cur = stat_day_date(now, tz) + day_start, day_end = stat_day_window(d_cur, tz) + mon, sun = monday_of_week(d_cur), monday_of_week(d_cur) + timedelta(days=6) + my, mm = stat_month_key(now, tz) + m_start, m_end = month_window(my, mm, tz) + + def pack_period( + *, + bucket: str, + label: str, + start_ts: float, + end_ts: float, + pnls: list[float], + ) -> dict[str, Any]: + partial = now < end_ts + metrics = aggregate_pnls(pnls) + return { + "bucket": bucket, + "label": label, + "start_ts": start_ts, + "end_ts": end_ts, + "partial": partial, + "metrics": metrics, + } + + day_pnls = _pnls_for_stat_day(events, d_cur, tz, official_ts) + week_pnls = _pnls_for_week(events, mon, sun, tz, official_ts) + month_pnls = _pnls_for_month(events, my, mm, tz, official_ts) + + return { + "ok": True, + "timezone": settings.stats.timezone, + "official_start": settings.stats.official_start, + "official_start_ts": official_ts, + "now_ts": now, + "contract": contract, + "pnl_field": pnl_field, + "closing_rows_missing_pnl": missing_pnl, + "fetched_position_close_rows": len(rows), + "fetched_trade_rows": len(rows), + "truncated": truncated, + "definitions": { + "unit": "每条 Gate 历史平仓(GET /futures/{settle}/position_close)且能解析到 pnl 的记录,按平仓 time 排序后做序列指标。", + "win_rate": "盈利笔数 / 总笔数(含盈亏为 0)。", + "profit_factor": "毛利和 / |毛亏和|;若无亏损则 null。", + "max_single_loss": "单笔最小 pnl(最负的一笔),无亏损为 null。", + "max_drawdown": "按时间累加 pnl 的权益曲线,相对历史峰值的 max(peak−equity)。", + "max_consecutive_losses": "连续 pnl<0 的最大笔数。", + "day": "统计日 [D 08:00, D+1 08:00) 上海;D=(本地时刻−8h) 的日历日。", + "week": "自然周周一至周日(上海日历),聚合落在该周内的平仓记录。", + "month": "自然月 [当月1日08:00, 次月1日08:00) 上海,与统计日对齐。", + }, + "day": pack_period( + bucket="day", + label=d_cur.isoformat(), + start_ts=day_start, + end_ts=day_end, + pnls=day_pnls, + ), + "week": pack_period( + bucket="week", + label=f"{mon.isoformat()}_{sun.isoformat()}", + start_ts=stat_day_window(mon, tz)[0], + end_ts=stat_day_window(sun, tz)[1], + pnls=week_pnls, + ), + "month": pack_period( + bucket="month", + label=f"{my:04d}-{mm:02d}", + start_ts=m_start, + end_ts=m_end, + pnls=month_pnls, + ), + } diff --git a/gate_order_executor/app/wecom_notify.py b/gate_order_executor/app/wecom_notify.py new file mode 100644 index 0000000..9b6a369 --- /dev/null +++ b/gate_order_executor/app/wecom_notify.py @@ -0,0 +1,181 @@ +"""企业微信群机器人:仅推送执行器侧执行结果(策略/发现类仍由扫描端)。""" +from __future__ import annotations + +import logging +import time +from typing import Any + +import httpx + +from .config import Settings +from .proxy_util import httpx_client_kwargs + +logger = logging.getLogger(__name__) + +_MAX_MD_LEN = 3500 +_oco_last_sent: dict[str, float] = {} +_OCO_COOLDOWN_SEC = 600.0 + + +def _wecom_ready(settings: Settings) -> str | None: + w = settings.wecom + if not w.enabled: + return None + url = (w.webhook_url or "").strip() + return url or None + + +def _clip(s: str, n: int = 800) -> str: + t = str(s).replace("\r\n", "\n").strip() + if len(t) > n: + return t[: n - 1] + "…" + return t + + +async def _post_markdown(settings: Settings, title: str, body_lines: list[str]) -> None: + url = _wecom_ready(settings) + if not url: + return + text = "\n".join([f"## {_clip(title, 120)}", ""] + body_lines) + if len(text) > _MAX_MD_LEN: + text = text[: _MAX_MD_LEN - 20] + "\n…(truncated)" + payload = {"msgtype": "markdown", "markdown": {"content": text}} + kw = httpx_client_kwargs(settings.proxy.enabled, settings.proxy.url, timeout_connect=6.0, timeout_read=12.0) + try: + async with httpx.AsyncClient(**kw) as client: + r = await client.post(url, json=payload) + r.raise_for_status() + data = r.json() + if isinstance(data, dict) and int(data.get("errcode") or 0) != 0: + logger.warning("wecom_api_err: %s", data) + except Exception: # noqa: BLE001 + logger.exception("wecom_post_failed") + + +async def notify_signal_execution( + settings: Settings, + *, + signal: dict[str, Any], + result: dict[str, Any], + http_status: int, +) -> None: + """每条 POST /v1/signal 处理结束后推送摘要。""" + if not _wecom_ready(settings): + return + st = str(result.get("status") or "") + title = "执行器 · 信号结果" + if st == "accepted": + title += " · accepted" + elif st == "skipped": + title += " · skipped" + else: + title += " · error" + lines = [ + f">signal_id: `{_clip(str(signal.get('signal_id') or ''), 80)}`", + f">contract: **{_clip(str(signal.get('contract') or ''), 32)}** side: `{_clip(str(signal.get('side') or ''), 8)}`", + f">http: **{http_status}** mode: `{_clip(str(result.get('mode') or ''), 20)}`", + f">status: **{st}**", + ] + if result.get("reason"): + lines.append(f">reason: `{_clip(str(result.get('reason')), 200)}`") + if result.get("stage"): + lines.append(f">stage: `{_clip(str(result.get('stage')), 40)}`") + if result.get("detail"): + lines.append(f">detail: `{_clip(str(result.get('detail')), 500)}`") + if result.get("sized_contracts") is not None: + lines.append(f">size: `{_clip(str(result.get('sized_contracts')), 40)}`") + if result.get("market_order") and isinstance(result.get("market_order"), dict): + mo = result["market_order"] + oid = mo.get("id") or mo.get("order_id") + if oid is not None: + lines.append(f">market_order_id: `{oid}`") + try: + await _post_markdown(settings, title, lines) + except Exception: # noqa: BLE001 + logger.exception("notify_signal_execution_failed") + + +async def notify_manual_close( + settings: Settings, + *, + contract: str, + ok: bool, + detail: str | None, + order: dict[str, Any] | None, +) -> None: + """面板一键市价全平结果。""" + if not _wecom_ready(settings): + return + title = "执行器 · 一键平仓 · 成功" if ok else "执行器 · 一键平仓 · 失败" + lines = [f">contract: **{_clip(contract, 32)}**"] + if detail: + lines.append(f">detail: `{_clip(detail, 400)}`") + if ok and isinstance(order, dict): + oid = order.get("id") or order.get("order_id") + if oid is not None: + lines.append(f">order_id: `{oid}`") + try: + await _post_markdown(settings, title, lines) + except Exception: # noqa: BLE001 + logger.exception("notify_manual_close_failed") + + +async def notify_oco_cancel_failed( + settings: Settings, + *, + contract: str, + leg: str, + order_id: str, + detail: str, +) -> None: + """OCO 清理撤另一腿失败:带冷却,避免 18s 轮询刷屏。""" + if not _wecom_ready(settings): + return + key = f"{contract}:{leg}:{order_id}" + now = time.time() + if now - _oco_last_sent.get(key, 0.0) < _OCO_COOLDOWN_SEC: + return + _oco_last_sent[key] = now + if len(_oco_last_sent) > 500: + _oco_last_sent.clear() + title = "执行器 · OCO 撤单异常" + lines = [ + f">contract: **{_clip(contract, 32)}**", + f">leg: `{_clip(leg, 8)}` price_order_id: `{_clip(order_id, 40)}`", + f">detail: `{_clip(detail, 500)}`", + ] + try: + await _post_markdown(settings, title, lines) + except Exception: # noqa: BLE001 + logger.exception("notify_oco_cancel_failed_post") + + +async def notify_breakeven_failed(settings: Settings, *, contract: str, detail: str) -> None: + """移动保本改挂止损失败(仅失败推送)。""" + if not _wecom_ready(settings): + return + title = "执行器 · 移动保本失败" + lines = [ + f">contract: **{_clip(contract, 32)}**", + f">detail: `{_clip(detail, 500)}`", + ] + try: + await _post_markdown(settings, title, lines) + except Exception: # noqa: BLE001 + logger.exception("notify_breakeven_failed_post") + + +async def notify_signal_db_insert_failed(settings: Settings, *, signal_id: str, detail: str) -> None: + """SQLite 落库失败(HTTP 仍返回信号结果时单独告警)。""" + if not _wecom_ready(settings): + return + title = "执行器 · 信号落库失败" + lines = [ + f">signal_id: `{_clip(signal_id, 80)}`", + f">detail: `{_clip(detail, 600)}`", + ] + try: + await _post_markdown(settings, title, lines) + except Exception: # noqa: BLE001 + logger.exception("notify_signal_db_insert_failed_post") + diff --git a/gate_order_executor/config.example.yaml b/gate_order_executor/config.example.yaml new file mode 100644 index 0000000..876cd22 --- /dev/null +++ b/gate_order_executor/config.example.yaml @@ -0,0 +1,63 @@ +# 复制为 config.yaml 后修改;勿将含密钥的 config.yaml 提交到 git。 + +app: + host: "127.0.0.1" + port: 8090 + log_file: "./runtime/executor.log" + session_secret: "change-me-to-long-random-string" + +auth: + enabled: true + username: "admin" + password: "ChangeThisPassword!" + +security: + webhook_secret: "change-me-to-long-random-string" + +# 企业微信群机器人:仅推送执行器「执行结果」(信号处理结果、一键平仓、OCO 撤单异常、SQLite 落库失败)。 +# 策略/发现类通知仍由 onchain_scout_gate 扫描端发送。需在企业微信建群机器人并复制 Webhook URL。 +wecom: + enabled: false + webhook_url: "" + +gate: + api_base: "https://api.gateio.ws/api/v4" + settle: "usdt" + api_key: "" + api_secret: "" + # true:只校验与日志,不下单。实盘:改为 false,并填写 api_key / api_secret(子账户 + IP 白名单) + dry_run: true + # 仅人工测试:为 true 时允许 POST /api/test 与 /v1/test 的 micro_market 发真实 IOC 市价(张数受 test_max_contracts 限制;联调见 docs/使用说明 §4.1) + test_orders_enabled: false + test_max_contracts: 1 + +risk: + risk_per_trade_frac: 0.005 + max_open_positions: 5 + scheme: "A" + # Gate 官方未提供「单笔双触发 OCO」时,为 true 表示净持仓为 0 后由本进程撤掉另一腿计划单(推荐保持 true) + oco_cleanup_enabled: true + # 最低盈亏比(config 默认);信号流面板可保存到 runtime/risk_prefs.json 覆盖,无需改 yaml + min_reward_risk_ratio: 1.3 + # 移动保本:浮盈达 1R(相对初始止损)后,止损拉至开仓价 ± buffer_pct;面板可写 runtime/breakeven_prefs.json + breakeven_stop: + enabled: true + trigger_r: 1.0 + buffer_pct: 0.002 + poll_interval_sec: 8 + +# 面板「统计」:正式起始时刻与统计日边界(见 docs/使用说明.md) +stats: + timezone: "Asia/Shanghai" + official_start: "2026-05-13T02:00:00+08:00" + max_trade_rows: 20000 + +# 信号流与执行结果:写入 SQLite(默认 ./runtime/signals.sqlite);留空会自动回退为该路径,保证重启后仍可读 +database: + enabled: true # 兼容旧字段 + sqlite_path: "./runtime/signals.sqlite" + +# 与 onchain_scout_gate 的 proxy 块相同写法;enabled=true 时访问 Gate 走此代理 +proxy: + enabled: true + url: "socks5h://127.0.0.1:1080" diff --git a/gate_order_executor/deploy/README.md b/gate_order_executor/deploy/README.md new file mode 100644 index 0000000..101e58c --- /dev/null +++ b/gate_order_executor/deploy/README.md @@ -0,0 +1,17 @@ +# deploy 脚本说明 + +所有脚本默认项目路径为 **`/root/gate_order_executor`**;可通过第一个参数传入你的绝对路径(`bootstrap.sh`、`start.sh`)。 + +| 文件 | 作用 | +|------|------| +| `ecosystem.config.cjs` | PM2 配置:单实例、`PYTHONUNBUFFERED=1`、日志在 `runtime/` | +| `bootstrap.sh` | 首次:`venv` + `pip install -r requirements.txt` + 生成 `config.yaml` 模板 | +| `start.sh` | 前台运行 `python run.py`(调试用) | +| `pm2-start.sh` | PM2 启动;若应用已存在则 `restart` | +| `pm2-restart.sh` | `pm2 restart gate-order-executor` | +| `pm2-stop.sh` | `pm2 stop gate-order-executor` | +| `pm2-delete.sh` | `pm2 delete gate-order-executor` | +| `gate-order-executor.service` | systemd 示例(需改 `WorkingDirectory` 与 `pm2-runtime` 路径) | + +使用说明与接口文档:`../docs/使用说明.md` +部署步骤:`../docs/部署说明.md` diff --git a/gate_order_executor/deploy/bootstrap.sh b/gate_order_executor/deploy/bootstrap.sh new file mode 100644 index 0000000..9e318e3 --- /dev/null +++ b/gate_order_executor/deploy/bootstrap.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# 首次部署:创建 venv、安装依赖、生成 config 模板、创建 runtime 目录。 +# 用法:bash deploy/bootstrap.sh [项目绝对路径] +# 默认:/root/gate_order_executor +set -euo pipefail + +PROJECT_DIR="${1:-/root/gate_order_executor}" +cd "$PROJECT_DIR" + +if ! command -v python3 >/dev/null 2>&1; then + echo "请先安装 python3" >&2 + exit 1 +fi + +python3 -m venv .venv +# shellcheck source=/dev/null +source .venv/bin/activate +python -m pip install -U pip +python -m pip install -r requirements.txt + +# SOCKS 代理与扫描一致时建议安装(与 httpx[socks] 一致) +python -m pip install "socksio>=1.0,<2" || true + +if [ ! -f config.yaml ]; then + cp -n config.example.yaml config.yaml + echo "已从 config.example.yaml 创建 config.yaml,请编辑:auth、security.webhook_secret、proxy、gate 等。" +fi + +mkdir -p runtime +echo "Bootstrap 完成:$PROJECT_DIR" diff --git a/gate_order_executor/deploy/ecosystem.config.cjs b/gate_order_executor/deploy/ecosystem.config.cjs new file mode 100644 index 0000000..3001b8f --- /dev/null +++ b/gate_order_executor/deploy/ecosystem.config.cjs @@ -0,0 +1,38 @@ +/** + * PM2 守护 gate_order_executor(Gate 下单执行器) + * + * 在项目根目录执行: + * ./deploy/pm2-start.sh + * 或: + * pm2 start deploy/ecosystem.config.cjs + * + * 监听 host/port 来自项目根目录 config.yaml → app.host / app.port(由 run.py 内 uvicorn 读取)。 + */ +const path = require("path"); +const ROOT = path.resolve(__dirname, ".."); +const isWin = process.platform === "win32"; +const py = path.join(ROOT, isWin ? path.join(".venv", "Scripts", "python.exe") : path.join(".venv", "bin", "python")); + +module.exports = { + apps: [ + { + name: "gate-order-executor", + cwd: ROOT, + script: py, + args: ["run.py"], + interpreter: "none", + autorestart: true, + watch: false, + max_restarts: 20, + min_uptime: "10s", + exp_backoff_restart_delay: 2000, + error_file: path.join(ROOT, "runtime", "pm2-executor-error.log"), + out_file: path.join(ROOT, "runtime", "pm2-executor-out.log"), + merge_logs: true, + time: true, + env: { + PYTHONUNBUFFERED: "1", + }, + }, + ], +}; diff --git a/gate_order_executor/deploy/gate-order-executor.service b/gate_order_executor/deploy/gate-order-executor.service new file mode 100644 index 0000000..15dae74 --- /dev/null +++ b/gate_order_executor/deploy/gate-order-executor.service @@ -0,0 +1,18 @@ +[Unit] +Description=Gate Order Executor (Python) via PM2 +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=/root/gate_order_executor +Environment=NODE_ENV=production +Environment=PYTHONUNBUFFERED=1 +# 需全局安装:npm install -g pm2 +# 若 pm2-runtime 不在 PATH,请改为绝对路径:which pm2-runtime +ExecStart=/usr/bin/pm2-runtime start deploy/ecosystem.config.cjs +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/gate_order_executor/deploy/pm2-delete.sh b/gate_order_executor/deploy/pm2-delete.sh new file mode 100644 index 0000000..3c390ad --- /dev/null +++ b/gate_order_executor/deploy/pm2-delete.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail +if ! command -v pm2 >/dev/null 2>&1; then + echo "未找到 pm2" >&2 + exit 1 +fi +pm2 delete gate-order-executor || true +pm2 save || true +echo "已从 PM2 删除 gate-order-executor" diff --git a/gate_order_executor/deploy/pm2-restart.sh b/gate_order_executor/deploy/pm2-restart.sh new file mode 100644 index 0000000..e2ea586 --- /dev/null +++ b/gate_order_executor/deploy/pm2-restart.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +if ! command -v pm2 >/dev/null 2>&1; then + echo "未找到 pm2" >&2 + exit 1 +fi +pm2 restart gate-order-executor --update-env +echo "已重启 gate-order-executor" diff --git a/gate_order_executor/deploy/pm2-start.sh b/gate_order_executor/deploy/pm2-start.sh new file mode 100644 index 0000000..00388b9 --- /dev/null +++ b/gate_order_executor/deploy/pm2-start.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# 使用 PM2 启动 gate-order-executor(需已 bootstrap 且已配置 config.yaml) +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +if ! command -v pm2 >/dev/null 2>&1; then + echo "未找到 pm2,请先执行:npm install -g pm2" >&2 + exit 1 +fi + +if [ ! -x ".venv/bin/python" ]; then + echo "未找到 .venv/bin/python,请先执行:bash deploy/bootstrap.sh \"$ROOT\"" >&2 + exit 1 +fi + +mkdir -p runtime + +if pm2 describe gate-order-executor >/dev/null 2>&1; then + echo "进程已存在,改为重启:pm2 restart gate-order-executor" + pm2 restart gate-order-executor --update-env +else + pm2 start "$ROOT/deploy/ecosystem.config.cjs" +fi + +pm2 save || true +echo "已启动。日志:pm2 logs gate-order-executor" diff --git a/gate_order_executor/deploy/pm2-stop.sh b/gate_order_executor/deploy/pm2-stop.sh new file mode 100644 index 0000000..7a6a444 --- /dev/null +++ b/gate_order_executor/deploy/pm2-stop.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +if ! command -v pm2 >/dev/null 2>&1; then + echo "未找到 pm2" >&2 + exit 1 +fi +pm2 stop gate-order-executor || true +echo "已停止 gate-order-executor(进程仍在列表中可用 pm2 delete 删除)" diff --git a/gate_order_executor/deploy/start.sh b/gate_order_executor/deploy/start.sh new file mode 100644 index 0000000..2debed8 --- /dev/null +++ b/gate_order_executor/deploy/start.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# 前台调试(无 PM2)。生产请用:./deploy/pm2-start.sh +# 用法:bash deploy/start.sh [项目绝对路径] +set -euo pipefail + +PROJECT_DIR="${1:-/root/gate_order_executor}" +cd "$PROJECT_DIR" +# shellcheck source=/dev/null +source .venv/bin/activate +exec python run.py diff --git a/gate_order_executor/docs/使用说明.md b/gate_order_executor/docs/使用说明.md new file mode 100644 index 0000000..851ef8e --- /dev/null +++ b/gate_order_executor/docs/使用说明.md @@ -0,0 +1,218 @@ +# Gate 下单执行器 · 使用说明 + +本文说明 `gate_order_executor` 的职责、配置项、Web 面板与 HTTP 接口。与 **部署安装** 相关的步骤见 [部署说明.md](./部署说明.md)。 + +## 1. 系统定位 + +- 与 `onchain_scout_gate`(MATRIX 扫描)**分进程**运行:扫描只用 Gate **公共**行情;本服务接收信号并可在 **`gate.dry_run: false`** 且配置密钥时 **向 Gate 发真实委托**。 +- **`gate.dry_run: true`(默认)**:只校验占位与日志,不下单。 +- 不建议把 Gate API Key 放在扫描服务的配置里;密钥仅配置在本项目的 `config.yaml`(本机、权限收紧)。 + +## 2. 目录结构(常用) + +| 路径 | 说明 | +|------|------| +| `config.yaml` | 本地配置(勿提交 git;由 `config.example.yaml` 复制) | +| `run.py` | 启动入口(uvicorn 读取 `app.host` / `app.port`) | +| `app/main.py` | FastAPI:健康检查、登录、面板、`POST /v1/signal`、`GET /api/state`、`GET /api/stats/summary`、`POST /api/test`、**Gate 成交/委托查询与 CSV**(`GET /api/gate/*`) | +| `app/gate_history.py` | 拉取 Gate **`my_trades_timerange`** / **`orders`**,拼 CSV(UTF-8 BOM);分页原始行 `collect_trades_rows` | +| `app/stats.py` | 面板统计:正式起点后历史平仓(`position_close`)的日/周/月聚合 | +| `app/gate_operations.py` | 拉取账户余额、**测试市价接口**(`post_test_market_order`)、列出/撤销计划委托、**非零持仓摘要**(`GET /futures/{settle}/positions`) | +| `app/gate_futures_live.py` | 实盘:签名请求、市价 IOC 开仓、计划委托止盈/止损、以损订仓 | +| `app/oco_watcher.py` | 净持仓为 0 后撤掉本次止盈/止损计划单另一腿(见 §3.4.2) | +| `app/gate_auth.py` | Gate APIv4 请求签名 | +| `app/proxy_util.py` | 与扫描相同的代理处理;`httpx_client_kwargs` 供访问 Gate 时使用 | +| `deploy/ecosystem.config.cjs` | PM2 配置 | +| `deploy/bootstrap.sh` | 首次创建 venv 与依赖 | +| `deploy/pm2-start.sh` | PM2 启动/已存在则重启 | + +## 3. 配置说明(`config.yaml`) + +### 3.1 `app` + +| 字段 | 说明 | +|------|------| +| `host` | 监听地址。仅本机访问面板/接口用 `127.0.0.1`;局域网或 SSH 隧道外访问用 `0.0.0.0`(请配合防火墙与 `auth`)。 | +| `port` | 默认 `8090`,避免与扫描默认 `8088` 冲突。 | +| `log_file` | 应用日志路径(目录会自动创建)。 | +| `session_secret` | Cookie 会话密钥,请改为长随机串。 | + +### 3.2 `auth` + +| 字段 | 说明 | +|------|------| +| `enabled` | `false` 时跳过登录(仅建议纯局域网)。公网或不可信网络务必 `true`。 | +| `username` / `password` | 面板登录;密码以 SHA256 摘要校验(与扫描面板思路一致)。 | + +### 3.3 `security` + +| 字段 | 说明 | +|------|------| +| `webhook_secret` | 调用 `POST /v1/signal`、`POST /v1/test` 时请求头 `X-Webhook-Secret` 必须与此一致。 | + +### 3.4 `gate` + +| 字段 | 说明 | +|------|------| +| `api_base` / `settle` | 与扫描端 Gate 公共 API 一致即可。 | +| `api_key` / `api_secret` | 实盘必填(建议子账户、只开合约、IP 白名单)。 | +| `dry_run` | `true` 不下单;**实盘改为 `false`** 且密钥有效时,收到信号将 **市价开仓** 并挂 **计划委托** 止盈/止损。 | +| `test_orders_enabled` | 默认 `false`。为 `true` 时允许通过 `POST /api/test` 与 `POST /v1/test` 的 `action: micro_market` 发送 **真实** 极小 IOC 市价单(**仅接口联调**,见 §4.1);务必使用子账户、小额、`test_max_contracts` 限制张数。 | +| `test_max_contracts` | 与测试请求里的张数取 **最小值**,上限 30,默认 1。 | + +### 3.4.1 实盘逻辑摘要(`app/gate_futures_live.py`) + +- **开仓**:`POST /futures/usdt/orders`,`price=0`、`tif=ioc`,做多 `size` 为正、做空为负;`text` 为 `t-e` + 清洗后的 `signal_id`。 +- **止盈 / 止损**:各 `POST /futures/usdt/price_orders`,`initial` 为 `reduce_only` + 市价 IOC + **`close: true`** + **`size: 0`**(Gate 要求:单向全平时 `close=true` 则 `initial.size` 必须为 0,否则会报 `AUTO_INVALID_PARAM_INITIAL_SIZE`),`trigger` 规则与方向匹配(多:TP 用 rule≥、SL 用 rule≤;空相反)。 +- **一腿成交后撤另一腿**:见 **§3.4.2**;默认 **`risk.oco_cleanup_enabled: true`** 时由 **`oco_watcher`**(约每 18s)在净持仓为 0 后 `DELETE` 本次两条 `price_orders` 中仍挂着的一腿。 +- **以损订仓**:用 `GET /futures/usdt/accounts` 的 `total`(USDT)× `risk.risk_per_trade_frac`,再除以 `|参考价−止损|×quanto_multiplier`(`quanto_multiplier` 来自合约详情)。若低于 `order_size_min` 则拒绝该笔信号。 +- **参考价**:优先 `reference_price`;否则用合约 ticker `last`。 +- **持仓上限**:每次信号前拉取 `GET /futures/usdt/positions`,与本地占位同步;交易所已有 **≥ max_open_positions** 个非零持仓则跳过;**该合约在交易所已有仓**也跳过(避免重复加仓)。 +- **假设**:经典 **单向持仓** 模式。若为双向对冲模式,Gate 对平仓字段要求不同,本实现可能需调整(`auto_size` 等);请在子账户上先以单向模式验证。 + +### 3.4.2 Gate 与「交易所原生 OCO」 + +- **官方能力**:USDT 永续 v4 的 `POST /futures/{settle}/price_orders` 在公开文档中是 **「一条请求 = 一条计划委托」**;**没有**与「网页止盈+止损一体、撮合层保证互撤」等价的、**写进 OpenAPI 且稳定**的单请求 OCO。文档里部分 `order_type`(如 `close-long-order`)存在 **read-only、不可在请求体传入** 的说明,**不能**当作已支持的双绑参数来用。 +- **现货**侧 `POST /spot/orders` 有 `take_profit`/`stop_loss` 等字段,与 **永续计划委托** 不是同一套接口,不能照搬。 +- **本仓库策略**:默认开启 **`oco_cleanup`**(应用侧在持仓清空后撤另一腿),**效果上接近 OCO**,代价是 **秒级延迟**、以及进程/网络异常时的理论竞态;这是在不依赖非官方 `text` 编码技巧的前提下较稳妥的做法。若你关闭 `oco_cleanup_enabled`,则完全依赖交易所对 `reduce_only` 计划单的处理,**常见现象是另一腿仍以 `open` 挂在计划列表**,需自行在网页撤单。 + +### 3.5 `risk` + +| 字段 | 说明 | +|------|------| +| `risk_per_trade_frac` | 以损订仓目标比例(如 `0.005` = 0.5%),用于按止损距离换算张数。 | +| `max_open_positions` | 同时占位/持仓品种上限(默认 5)。 | +| `scheme` | 固定为方案 **A**(与推送文案「入场区间 A」一致)。 | +| `oco_cleanup_enabled` | 默认 `true`:净持仓为 0 后由 **`oco_watcher`** 尝试 `DELETE` 本次挂出的两条计划单中仍有效的一腿(见 §3.4.2)。`false` 则不做应用侧撤单。 | + +### 3.6 `stats`(面板「统计」正式口径) + +| 字段 | 说明 | +|------|------| +| `timezone` | 统计用 IANA 时区,默认 `Asia/Shanghai`。 | +| `official_start` | **正式统计起点**(ISO8601,**必须带时区**,如 `2026-05-13T02:00:00+08:00`)。仅统计 Gate `position_close` 返回里 **`time`(平仓时间)不早于此** 的记录。 | +| `max_trade_rows` | 从 `position_close` 分页拉取的最大条数(默认 20000,上限 100000;配置键名沿用 `max_trade_rows`);超过则可能 `truncated: true`,序列型指标(如回撤)在截断下不完整。 | + +**时间桶(均按 `timezone` 本地理解)** + +- **统计日**:`[日历 D 日 08:00, D+1 日 08:00)`。标签 **D** 取 `(本地时刻 − 8h)` 的日历日(与 08:00 换日对齐)。 +- **自然周**:周一至周日(上海日历);聚合 **统计日标签**落在该周内的历史平仓记录。 +- **自然月**:`[当月 1 日 08:00, 次月 1 日 08:00)`,与统计日对齐。 + +**统计单元**:`GET /futures/{settle}/position_close` 每条历史平仓记录(与 App「历史仓位」同类);**`pnl`(或 `realised_pnl`)可解析**的才计入笔数与盈亏序列,按 **`time`(平仓时间)** 排序。**胜率** = 盈利笔数 / 总笔数(含盈亏为 0);**盈亏比** = 毛利和 / \|毛亏和\|(无亏损时为 `null`);**最大单笔亏损** = 最小 `pnl`;**最大回撤** = 按时间累加 `pnl` 的权益曲线相对历史峰值的 `max(peak − equity)`;**最大连续亏损次数** = 连续 `pnl < 0` 的最大长度。 + +面板 **「统计」** 分区调用 `GET /api/stats/summary`(需登录),**不会**随 `/api/state` 每 2 秒刷新,需手动点「刷新统计」。 + +### 3.7 `proxy` + +与 `onchain_scout_gate` 的 `proxy` **块写法一致**: + +- `enabled: true` 且填写 `url`(如 `socks5h://127.0.0.1:1080`)时,使用 `httpx_client_kwargs` 的出站逻辑与扫描端 Gate 客户端相同(`socks5h` 会转为 `socks5` 以兼容 httpx)。 +- 使用 **SOCKS** 时需安装 `socksio`:`pip install socksio`(`bootstrap.sh` 会尝试安装)。 + +### 3.8 `wecom`(企业微信群机器人 · 执行结果) + +- **定位**:仅推送本执行器侧 **执行结果**(每条 `POST /v1/signal` 处理摘要、面板一键平仓成败、OCO 撤另一腿失败、SQLite 落库失败)。**策略/发现类**仍由 `onchain_scout_gate` 扫描端发企业微信。 +- **配置**:`enabled: true` 且填写 `webhook_url`(群机器人 Webhook 完整 URL)。走与 Gate 相同的 **proxy** 出站策略(若启用)。 +- **关闭**:`enabled: false` 或留空 `webhook_url` 即不发送。 + +## 4. Web 面板 + +- **必须用运行中的服务访问**,在浏览器地址栏输入 `http://:/dashboard`(例如本机 `http://127.0.0.1:8090/dashboard`)。不要双击或用「打开文件」方式直接打开 `templates/dashboard.html`:那样是 `file://` 协议,**不会**经过 FastAPI、**不会**加载 `/static/style.css`,页面会变成未样式的白底裸 HTML,模板里的 `{{ username }}` 等也不会被渲染。 +- 开启 `auth.enabled` 时先访问 `/login`,使用 JSON 方式提交账号密码。 +- 面板为顶部分区:**概览**、**持仓与计划**、**成交与委托**、**统计**、**信号流**(`POST /v1/signal` 写入 SQLite,默认 `database.sqlite_path`,配置留空时亦回退为该路径;面板与 `GET /api/signals/export.csv` 读库,**进程重启后记录仍在**;`/api/state` 返回 `signals_persisted` / `signals_sqlite_path` 供前端展示持久化状态)。 +- 前端约 **每 2 秒** 轮询 `GET /api/state`,已配置密钥时会附带拉取账户、**持仓**、计划委托。 +- **联调(拉取余额 / 极小测试市价)不再放在面板**,请在服务器用 **`curl`** 或自写脚本调用 `POST /api/test`、`POST /v1/test`,见下文 **§4.1** 与 [部署说明.md](./部署说明.md) **§11**。 + +### 4.1 用 curl 联调 `POST /api/test` 与 `POST /v1/test` + +以下端口、账号、密钥请替换为你的 `config.yaml` 实际值;路径以本机 `127.0.0.1:8090` 为例。 + +**鉴权说明** + +- `POST /api/test`:需 **面板登录 Cookie**(`auth.enabled: true` 时先登录再带 `-b` cookie 文件)。 +- `POST /v1/test`:仅需请求头 **`X-Webhook-Secret`**,与 `security.webhook_secret` 一致,**无需 Cookie**(适合 SSH 在服务器上一条命令联调)。 + +**1)若开启 `auth.enabled`,先登录保存 Cookie** + +```bash +curl -s -c /tmp/gate_exec_cookies.txt -X POST "http://127.0.0.1:8090/login" \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","password":"你的面板密码"}' +``` + +若 `auth.enabled: false`,可跳过登录,下面 `/api/test` 的 `-b` 可省略。 + +**2)仅读合约账户(balance)** + +```bash +curl -s -b /tmp/gate_exec_cookies.txt -X POST "http://127.0.0.1:8090/api/test" \ + -H "Content-Type: application/json" \ + -d '{"action":"balance"}' +``` + +**3)极小真实 IOC 市价(micro_market)** + +须同时满足:`gate.test_orders_enabled: true`,且已配置 `api_key` / `api_secret`。张数与 `test_max_contracts` 取小。 + +```bash +curl -s -b /tmp/gate_exec_cookies.txt -X POST "http://127.0.0.1:8090/api/test" \ + -H "Content-Type: application/json" \ + -d '{"action":"micro_market","contract":"BTC_USDT","side":"long","size":1}' +``` + +**4)无 Cookie:用 Webhook 密钥调同一套逻辑(`/v1/test`)** + +```bash +curl -s -X POST "http://127.0.0.1:8090/v1/test" \ + -H "Content-Type: application/json" \ + -H "X-Webhook-Secret: 与_config.yaml_security.webhook_secret_一致" \ + -d '{"action":"balance"}' +``` + +`micro_market` 同理,把 `body` 换成与上一步相同的 JSON 即可。 + +**HTTP 状态**:`balance` 且 Gate 账户接口失败时一般为 **502**;`micro_market` 且 `{"ok":false}` 时一般为 **400**。 + +**Python(httpx)示例(`/v1/test`)** + +```python +import httpx + +r = httpx.post( + "http://127.0.0.1:8090/v1/test", + headers={"X-Webhook-Secret": "your-secret"}, + json={"action": "balance"}, + timeout=30.0, +) +print(r.status_code, r.text) +``` + +## 5. HTTP 接口摘要 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/health` | 健康检查;无需登录。 | +| GET | `/dashboard` | 面板页面。 | +| GET | `/api/state` | JSON 状态;未登录且开启 auth 时返回 401。已配密钥时含 `futures_account` / `futures_account_error`、`open_price_orders`(最多 50 条 open 计划委托摘要)/ `open_price_orders_error`;`positions.open_slot_count`(本地占位)、`positions.exchange`(非零持仓摘要列表)、`positions.exchange_error`。 | +| GET | `/api/stats/summary` | 需登录且已配密钥。Query:`contract`(可选,大写合约如 `BTC_USDT`)。返回当前 **统计日 / 自然周 / 自然月**(见 §3.6)内正式起点后的历史平仓 `pnl` 聚合;`ok: false` 时见 `error`。拉取可能较慢,勿高频轮询。 | +| GET | `/api/gate/trades` | 需登录。查询成交:Gate `GET /futures/{settle}/my_trades_timerange`。Query:`contract`(可选)、`from` / `to`(Unix 秒,均可省略则默认最近 7 天至此刻)、`limit`(1–500)、`offset`。返回 `rows` 与 `error`(接口异常时非空)。 | +| GET | `/api/gate/trades.csv` | 需登录。同上时间范围,分页拉取至最多 `max` 行(默认 2000,上限 5000),返回 CSV 附件 `gate_futures_trades.csv`。 | +| GET | `/api/gate/orders_history` | 需登录。Gate `GET /futures/{settle}/orders`。Query:`status=open|finished`(默认 `finished`)、`contract`(可选)、`limit`、`offset`。 | +| GET | `/api/gate/orders_history.csv` | 需登录。委托导出 CSV;`status` 与 `contract` 同上,`max` 同成交导出。 | +| POST | `/api/positions/market_close` | 需登录。JSON:`{"contract":"BTC_USDT"}`。实盘且非 `dry_run` 时向 Gate 发 **市价 IOC + reduce_only** 平掉该合约净持仓;成功后释放本地占位槽。`dry_run` / 无仓 / 无密钥 时 400;Gate 错误 502。 | +| POST | `/api/price_orders/manual` | 需登录。JSON:`contract`、`trigger_price`(字符串数字)、`rule`(1 或 2,与 Gate `price_orders` 触发规则一致)。挂一条 **全平** 条件计划单(`close`+`size`0 形态)。`dry_run` 时 400。 | +| POST | `/api/price_orders/cancel` | 需登录 Cookie。JSON:`{"order_id":"2054233581303107584"}`(与面板列表一致)。调用 Gate `DELETE .../price_orders/{id}`;失败时 400。 | +| POST | `/api/test` | 需登录 Cookie。JSON:`{"action":"balance"}` 仅读余额;`{"action":"micro_market","contract":"BTC_USDT","side":"long"|"short","size":1}` 发极小 IOC 市价(需 `test_orders_enabled`)。HTTP:`balance` 且 API 错误时 502;`micro_market` 且 `ok:false` 时 400。 | +| POST | `/v1/test` | 与 `/api/test` 相同 JSON;鉴权为请求头 `X-Webhook-Secret`(与 `security.webhook_secret` 一致),无需 Cookie。 | +| POST | `/v1/signal` | 扫描端推送信号;请求头 `X-Webhook-Secret`,JSON 体见项目根目录 `README.md` 表格。 | + +## 6. 与扫描服务协作 + +在 `onchain_scout_gate` 判定需要自动执行时,由扫描机 **内网** `httpx` 请求本服务 `POST /v1/signal`(例如 `http://127.0.0.1:8090/v1/signal`)。**不要**解析企业微信文本做下单。 + +## 7. 日志与排错 + +- PM2 标准输出/错误:`runtime/pm2-executor-out.log`、`runtime/pm2-executor-error.log`。 +- 改 `config.yaml` 后需 **重启进程**:`bash deploy/pm2-restart.sh` 或 `pm2 restart gate-order-executor`。 +- 若面板无法访问:检查 `app.host`/`app.port`、本机防火墙、以及是否与扫描端口冲突。 diff --git a/gate_order_executor/docs/部署说明.md b/gate_order_executor/docs/部署说明.md new file mode 100644 index 0000000..b1aef00 --- /dev/null +++ b/gate_order_executor/docs/部署说明.md @@ -0,0 +1,189 @@ +# Gate 下单执行器 · 部署说明 + +本文面向 **Ubuntu / Debian** 等 Linux 服务器,说明从零安装、PM2 守护、可选 systemd 开机自启,以及与 `onchain_scout_gate` 同机部署时的注意点。**功能与配置项含义**见 [使用说明.md](./使用说明.md)。 + +## 1. 环境要求 + +- Python 3.10+(推荐 3.11) +- 可访问公网拉取 PyPI(或自备镜像) +- 使用 PM2:`Node.js` + `npm install -g pm2` +- 若使用 **SOCKS** 代理访问 Gate:`pip install socksio`(`bootstrap.sh` 会尝试安装) + +## 2. 上传代码 + +将仓库目录放到服务器,例如: + +```text +/root/gate_order_executor/ +``` + +下文以该路径为例;若你的目录不同,请替换所有命令中的路径,并修改 `deploy/gate-order-executor.service` 里的 `WorkingDirectory`。 + +## 3. 首次安装(bootstrap) + +```bash +cd /root/gate_order_executor +chmod +x deploy/*.sh +bash deploy/bootstrap.sh /root/gate_order_executor +``` + +脚本会: + +- 创建 `.venv` 并安装 `requirements.txt` +- 尝试安装 `socksio`(失败可忽略,按需手动安装) +- 若不存在 `config.yaml`,从 `config.example.yaml` 复制一份 +- 创建 `runtime/` 目录 + +然后 **必须** 编辑 `config.yaml`: + +- `app.session_secret`、`security.webhook_secret` +- `auth`(若对外暴露建议 `enabled: true` 并改密码) +- `proxy`:本机走 SOCKS 时 `enabled: true`;**云服务器能直连 `api.gateio.ws` 时设为 `false`**(见下文 §6.1) +- 需要远端访问面板时,将 `app.host` 设为 `0.0.0.0`,并限制防火墙来源 IP + +## 4. PM2 启动与维护 + +### 4.1 启动(推荐脚本) + +```bash +cd /root/gate_order_executor +bash deploy/pm2-start.sh +``` + +若进程已在 PM2 列表中,脚本会执行 **`pm2 restart`** 而非重复 `start`。 + +### 4.2 常用命令 + +```bash +pm2 logs gate-order-executor # 实时日志 +pm2 status # 状态列表 +bash deploy/pm2-restart.sh # 改配置后重启 +bash deploy/pm2-stop.sh # 停止 +bash deploy/pm2-delete.sh # 从 PM2 删除该应用 +pm2 save # 保存进程列表(配合开机自启) +``` + +### 4.3 等价手动命令 + +```bash +cd /root/gate_order_executor +mkdir -p runtime +pm2 start deploy/ecosystem.config.cjs +pm2 save +``` + +`ecosystem.config.cjs` 使用项目内 `.venv/bin/python` 执行 `run.py`,日志写入 `runtime/pm2-executor-*.log`,并设置 `PYTHONUNBUFFERED=1`。 + +## 5. 前台调试(无 PM2) + +```bash +cd /root/gate_order_executor +bash deploy/start.sh /root/gate_order_executor +``` + +用于排查问题;生产环境请用 PM2。 + +## 6. 与扫描服务同机部署 + +典型端口: + +- 扫描:`8088`(以 `onchain_scout_gate/config.yaml` 为准) +- 执行器:`8090`(以本仓库 `config.yaml` 为准);多账户可再起 `8091` 等 + +两者使用 **不同 PM2 应用名**(`onchain-scout` 与 `gate-order-executor`),互不影响。 + +**多执行器:** 由扫描端 Web 面板维护转发列表(`runtime/order_executors.json`),同一信号广播到多个 URL;本仓库 **不** 提供向扫描端「反向注册」。详见 `onchain_scout_gate/docs/多执行器与信号转发归档.md`。 + +扫描端向本机执行器发信号示例: + +```text +POST http://127.0.0.1:8090/v1/signal +Header: X-Webhook-Secret: <与扫描端面板及本机 security.webhook_secret 一致> +``` + +### 6.1 云服务器关闭代理 + +境外云主机通常 **无需** SOCKS。在 **本仓库** `config.yaml` 中: + +```yaml +proxy: + enabled: false +``` + +保存后 `pm2 restart gate-order-executor`。自检:`curl -I --max-time 15 https://api.gateio.ws`。 + +扫描端 `onchain_scout_gate` 的 `proxy` 也需同样关闭(仅影响其拉行情,不影响 POST 信号)。见 `onchain_scout_gate/交易系统部署说明.md` §8。 + +## 7. 可选:systemd + pm2-runtime 开机自启 + +适合希望 **不依赖 `pm2 startup` 脚本**、由 systemd 直接拉起 PM2 托管进程的场景。 + +1. 编辑 `deploy/gate-order-executor.service`:将 `WorkingDirectory=` 改为你的项目绝对路径;确认 `ExecStart` 中 `pm2-runtime` 路径(`which pm2-runtime`)。 +2. 安装单元: + +```bash +sudo cp /root/gate_order_executor/deploy/gate-order-executor.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable gate-order-executor +sudo systemctl start gate-order-executor +sudo systemctl status gate-order-executor +``` + +注意:若同时使用 **交互式 `pm2 start`** 与 **systemd pm2-runtime**,容易重复启动;二选一即可。 + +## 8. 防火墙与安全 + +- 仅本机扫描调用信号时:可将 `app.host` 保持 `127.0.0.1`,则外网无法直连 HTTP。 +- 若需浏览器远程看面板:使用 `0.0.0.0` + 防火墙白名单 + **务必开启 `auth`**,并配合 HTTPS 反向代理(Nginx/Caddy)更佳。 +- `config.yaml` 含 API Key 与 Webhook 密钥,权限建议:`chmod 600 config.yaml`。 + +## 9. 升级代码后 + +```bash +cd /root/gate_order_executor +git pull # 或上传新文件 +source .venv/bin/activate +pip install -r requirements.txt +bash deploy/pm2-restart.sh +``` + +## 10. 故障速查 + +| 现象 | 可能原因 | +|------|----------| +| `TypeError: unhashable type: 'dict'`(Jinja 加载模板) | Starlette ≥0.29 起 `TemplateResponse` 须为 `TemplateResponse(request, "x.html", {...})`,勿把 `request` 放在第一个位置。请拉取最新 `app/main.py` 后重启。 | +| PM2 反复重启 | `config.yaml` 校验失败、端口被占用、依赖缺失;看 `pm2 logs` 与 `runtime/pm2-executor-error.log` | +| 面板打不开 | `host` 为 `127.0.0.1` 却从外网访问;或防火墙未放行 `port` | +| SOCKS 代理失败 | 未安装 `socksio`;或代理地址/协议错误 | +| 401 on `/v1/signal` | `X-Webhook-Secret` 与配置不一致 | + +## 11. 联调测试(curl / 无面板) + +面板 **不提供**「拉取余额 / 测试市价」按钮;联调请在本机或 SSH 到服务器后用 **`curl`**(或脚本调用 `httpx`)请求 **`POST /api/test`**、**`POST /v1/test`**。详细参数、Cookie 登录、`micro_market` 条件见 [使用说明.md](./使用说明.md) **§4.1**。 + +**快速示例(已关闭 `auth` 或已另行登录拿到 Cookie 时省略 `-b`)** + +```bash +# 仅读合约账户(需 Cookie 时加:-b /tmp/gate_exec_cookies.txt) +curl -s -X POST "http://127.0.0.1:8090/api/test" \ + -H "Content-Type: application/json" \ + -d '{"action":"balance"}' +``` + +```bash +# 无 Cookie,用 Webhook 密钥(与 security.webhook_secret 一致) +curl -s -X POST "http://127.0.0.1:8090/v1/test" \ + -H "Content-Type: application/json" \ + -H "X-Webhook-Secret: YOUR_WEBHOOK_SECRET" \ + -d '{"action":"balance"}' +``` + +```bash +# 极小真实 IOC(须 gate.test_orders_enabled: true;建议仍用子账户) +curl -s -X POST "http://127.0.0.1:8090/v1/test" \ + -H "Content-Type: application/json" \ + -H "X-Webhook-Secret: YOUR_WEBHOOK_SECRET" \ + -d '{"action":"micro_market","contract":"BTC_USDT","side":"long","size":1}' +``` + +改 `config.yaml` 后执行 **`bash deploy/pm2-restart.sh`** 再测。 diff --git a/gate_order_executor/requirements.txt b/gate_order_executor/requirements.txt new file mode 100644 index 0000000..e0c6a8d --- /dev/null +++ b/gate_order_executor/requirements.txt @@ -0,0 +1,8 @@ +fastapi>=0.110,<1 +uvicorn[standard]>=0.27,<1 +httpx>=0.27,<1 +# 使用 SOCKS 代理时再装:pip install socksio(与扫描端 httpx[socks] 一致) +pydantic>=2.6,<3 +PyYAML>=6.0,<7 +jinja2>=3.1,<4 +itsdangerous>=2.2,<3 diff --git a/gate_order_executor/run.py b/gate_order_executor/run.py new file mode 100644 index 0000000..7e915f3 --- /dev/null +++ b/gate_order_executor/run.py @@ -0,0 +1,16 @@ +"""本地启动:python run.py(需先 config.yaml + venv)。""" + +from __future__ import annotations + +import uvicorn + +from app.config import load_settings + +if __name__ == "__main__": + s = load_settings() + uvicorn.run( + "app.main:app", + host=s.app.host, + port=s.app.port, + reload=False, + ) diff --git a/gate_order_executor/static/exec.js b/gate_order_executor/static/exec.js new file mode 100644 index 0000000..05f3bb2 --- /dev/null +++ b/gate_order_executor/static/exec.js @@ -0,0 +1,1427 @@ +(function () { + function pad(n) { + return n < 10 ? "0" + n : String(n); + } + + function tickClock() { + var el = document.getElementById("execClock"); + if (!el) return; + var d = new Date(); + el.textContent = + pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds()); + } + + function fmtTs(ts) { + if (ts == null || ts === "") return "—"; + var d = new Date(typeof ts === "number" ? ts * 1000 : Date.parse(ts)); + if (isNaN(d.getTime())) return String(ts); + return ( + d.getFullYear() + + "-" + + pad(d.getMonth() + 1) + + "-" + + pad(d.getDate()) + + " " + + pad(d.getHours()) + + ":" + + pad(d.getMinutes()) + + ":" + + pad(d.getSeconds()) + ); + } + + function setChip(container, text, variant) { + if (!container) return; + var v = variant || "neutral"; + container.innerHTML = + '' + escapeHtml(text) + ""; + } + + function escapeHtml(s) { + var d = document.createElement("div"); + d.textContent = s; + return d.innerHTML; + } + + function fmtNum(x) { + if (x == null || x === "") return "—"; + var n = Number(x); + if (isNaN(n)) return String(x); + var s = n.toFixed(8); + s = s.replace(/\.?0+$/, ""); + return s || "0"; + } + + function fmtMoney2(x) { + if (x == null || x === "") return "—"; + var n = Number(x); + if (isNaN(n)) return String(x); + return n.toFixed(2); + } + + function renderAccount(data) { + var fa = data.futures_account; + var errEl = document.getElementById("faErr"); + var faErr = data.futures_account_error; + if (errEl) { + if (faErr) { + errEl.style.display = "block"; + errEl.textContent = "账户接口: " + faErr; + } else { + errEl.style.display = "none"; + errEl.textContent = ""; + } + } + var t = document.getElementById("faTotal"); + var a = document.getElementById("faAvail"); + var u = document.getElementById("faUpnl"); + var c = document.getElementById("faCur"); + if (!t || !a || !u || !c) return; + if (!fa || typeof fa !== "object") { + t.textContent = a.textContent = u.textContent = "—"; + c.textContent = "—"; + u.className = "exec-metric-value exec-metric-value--mono"; + } else { + t.textContent = fmtMoney2(fa.total); + a.textContent = fmtMoney2(fa.available); + u.textContent = fmtMoney2(fa.unrealised_pnl); + var upc = unrealizedPnlClass(fa.unrealised_pnl); + u.className = + "exec-metric-value exec-metric-value--mono" + (upc ? " " + upc : ""); + c.textContent = fa.currency != null ? String(fa.currency) : "—"; + } + } + + function countPlansForContract(po, contract) { + if (!Array.isArray(po) || !contract) return 0; + var c = String(contract).trim().toUpperCase(); + var n = 0; + for (var i = 0; i < po.length; i++) { + if (String((po[i] && po[i].contract) || "").trim().toUpperCase() === c) { + n++; + } + } + return n; + } + + function positionAbsSize(sz) { + var n = Number(sz); + return isNaN(n) ? 0 : Math.abs(n); + } + + /** 交易所持仓张数:正为多、负为空(与 Gate 一致)。 */ + function positionSideFromExchangeSize(sz) { + var n = Number(sz); + if (isNaN(n) || n === 0) return null; + return n > 0 ? "long" : "short"; + } + + function unrealizedPnlClass(up) { + if (up == null || up === "") return ""; + var n = Number(up); + if (isNaN(n)) return ""; + if (n > 0) return "exec-pnl--profit"; + if (n < 0) return "exec-pnl--loss"; + return "exec-pnl--flat"; + } + + function breakevenStatusLabel(st) { + var m = { + disabled: "已关闭", + waiting_1r: "等待 1R", + moved: "已保本+0.2%", + cannot_register: "无法登记", + pending_register: "待登记", + }; + return m[st] || st || "—"; + } + + function breakevenMapFromState(data) { + var out = {}; + var be = data.breakeven || {}; + var list = be.positions; + if (!Array.isArray(list)) return out; + list.forEach(function (row) { + var c = String((row && row.contract) || "") + .trim() + .toUpperCase(); + if (c) out[c] = row; + }); + return out; + } + + function renderExchangePositions(data) { + var tbody = document.getElementById("posBody"); + var errEl = document.getElementById("posErr"); + var hintEl = document.getElementById("posPlanHint"); + if (!tbody) return; + if (hintEl) { + hintEl.style.display = "none"; + hintEl.textContent = ""; + } + var btnDis = !data.gate_api_configured || data.dry_run ? " disabled" : ""; + var dryTitle = data.dry_run ? " title=\"dry_run 下不向交易所发单\"" : ""; + if (!data.gate_api_configured) { + tbody.innerHTML = + '未配置 Gate API 密钥'; + if (errEl) { + errEl.style.display = "none"; + errEl.textContent = ""; + } + return; + } + var beMap = breakevenMapFromState(data); + var posWrap = data.positions || {}; + var exch = posWrap.exchange; + var exErr = posWrap.exchange_error; + var po = data.open_price_orders; + if (errEl) { + if (exErr) { + errEl.style.display = "block"; + errEl.textContent = "持仓接口: " + exErr; + } else { + errEl.style.display = "none"; + errEl.textContent = ""; + } + } + if (exErr) { + tbody.innerHTML = + '无法加载持仓'; + return; + } + if (!Array.isArray(exch)) { + tbody.innerHTML = + '暂无持仓数据'; + return; + } + if (!exch.length) { + tbody.innerHTML = + '当前无持仓(张数为 0)'; + } else { + var posKeys = {}; + exch.forEach(function (row) { + var c0 = String(row.contract || "").trim().toUpperCase(); + if (c0 && positionAbsSize(row.size) > 0) { + posKeys[c0] = true; + } + }); + tbody.innerHTML = exch + .map(function (r) { + var ct = String(r.contract || ""); + var ctU = ct.trim().toUpperCase(); + var cnt = countPlansForContract(po, ct); + var enc = encodeURIComponent(ctU); + var plansCell = + cnt > 0 + ? '' + cnt + "" + : '0'; + var up = r.unrealised_pnl; + var upStr = fmtNum(up); + var pnlCls = unrealizedPnlClass(up); + var pnlHtml = + '" + + escapeHtml(upStr) + + ""; + var sideEx = positionSideFromExchangeSize(r.size); + var dirHtml = sideEx + ? ' ' + + sideEx + + "" + : ""; + var beRow = beMap[ctU] || {}; + var effEn = beRow.effective_enabled !== false; + var beSt = beRow.status || (effEn ? "pending_register" : "disabled"); + var beToggle = + '"; + var beStatusHtml = + '' + + escapeHtml(breakevenStatusLabel(beSt)) + + ""; + var act = + "" + + " " + + ""; + return ( + "" + + escapeHtml(ctU) + + dirHtml + + "" + + escapeHtml(String(r.size != null ? r.size : "")) + + "" + + escapeHtml(fmtNum(r.entry_price)) + + "" + + escapeHtml(fmtNum(r.mark_price)) + + "" + + pnlHtml + + "" + + escapeHtml(r.leverage != null ? String(r.leverage) : "—") + + "" + + plansCell + + "" + + beToggle + + "" + + beStatusHtml + + "" + + act + + "" + ); + }) + .join(""); + var noPlan = []; + exch.forEach(function (r) { + var c1 = String(r.contract || "").trim().toUpperCase(); + if (!c1 || positionAbsSize(r.size) <= 0) return; + if (countPlansForContract(po, c1) === 0) { + noPlan.push(c1); + } + }); + var planOnly = []; + if (Array.isArray(po)) { + var seen = {}; + po.forEach(function (o) { + var c2 = String(o.contract || "").trim().toUpperCase(); + if (!c2 || seen[c2]) return; + seen[c2] = true; + if (!posKeys[c2]) { + planOnly.push(c2); + } + }); + } + var parts = []; + if (noPlan.length) { + parts.push( + "下列合约有持仓但当前无 open 价格计划委托(请核对止盈/止损或撤单延迟):" + + noPlan.join(", ") + + "。" + ); + } + if (planOnly.length) { + parts.push( + "下列合约有 open 计划委托但持仓为 0(待触发或其它端下单):" + + planOnly.join(", ") + + "。" + ); + } + if (hintEl && parts.length) { + hintEl.style.display = "block"; + hintEl.textContent = parts.join(" "); + } + } + } + + function posModalOpen(contract) { + var m = document.getElementById("posPlanModal"); + var lab = document.getElementById("posPlanModalContract"); + var tr = document.getElementById("posPlanTrigger"); + var er = document.getElementById("posPlanModalErr"); + if (!m || !lab) return; + lab.textContent = contract; + m.setAttribute("data-contract", contract); + if (tr) tr.value = ""; + var rule = document.getElementById("posPlanRule"); + if (rule) rule.selectedIndex = 0; + if (er) { + er.style.display = "none"; + er.textContent = ""; + } + m.classList.add("exec-modal--open"); + m.setAttribute("aria-hidden", "false"); + } + + function posModalClose() { + var m = document.getElementById("posPlanModal"); + if (!m) return; + m.classList.remove("exec-modal--open"); + m.setAttribute("aria-hidden", "true"); + } + + function wirePositionActions() { + var tbl = document.getElementById("posTable"); + if (tbl && !tbl.getAttribute("data-delegated")) { + tbl.setAttribute("data-delegated", "1"); + tbl.addEventListener("click", function (ev) { + var closeBtn = ev.target.closest && ev.target.closest(".exec-pos-close"); + var planBtn = ev.target.closest && ev.target.closest(".exec-pos-plan"); + var beCb = + ev.target.closest && + ev.target.closest(".exec-be-contract"); + if (beCb && !beCb.disabled) { + var encBe = beCb.getAttribute("data-ct") || ""; + var ctBe = ""; + try { + ctBe = decodeURIComponent(encBe); + } catch (eBe) { + ctBe = encBe; + } + if (!ctBe) return; + saveBreakevenContract(ctBe, beCb.checked, beCb); + return; + } + if (!closeBtn && !planBtn) return; + if (closeBtn && closeBtn.disabled) return; + if (planBtn && planBtn.disabled) return; + var enc = (closeBtn || planBtn).getAttribute("data-ct") || ""; + var ct = ""; + try { + ct = decodeURIComponent(enc); + } catch (e0) { + ct = enc; + } + if (!ct) return; + if (closeBtn) { + if (!confirm("确认以市价 reduce_only 平掉 " + ct + " 的全部持仓?")) return; + closeBtn.disabled = true; + fetch("/api/positions/market_close", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ contract: ct }), + }) + .then(function (res) { + if (res.status === 401) { + window.location.href = "/login"; + return null; + } + return res.json().then(function (j) { + return { res: res, j: j }; + }); + }) + .then(function (pack) { + if (!pack) return; + if (!pack.res.ok) { + alert((pack.j && pack.j.detail) || "平仓失败 " + pack.res.status); + } else { + poll(); + } + }) + .catch(function (e) { + alert(String(e)); + }) + .finally(function () { + closeBtn.disabled = false; + }); + return; + } + if (planBtn) { + posModalOpen(ct); + } + }); + } + var mod = document.getElementById("posPlanModal"); + if (mod && !mod.getAttribute("data-wired")) { + mod.setAttribute("data-wired", "1"); + var bd = document.getElementById("posPlanModalBackdrop"); + var cx = document.getElementById("posPlanModalCancel"); + if (bd) bd.addEventListener("click", posModalClose); + if (cx) cx.addEventListener("click", posModalClose); + var sb = document.getElementById("posPlanModalSubmit"); + if (sb) { + sb.addEventListener("click", function () { + var m2 = document.getElementById("posPlanModal"); + var ctc = m2 ? m2.getAttribute("data-contract") || "" : ""; + var tr2 = document.getElementById("posPlanTrigger"); + var pr = tr2 ? String(tr2.value || "").trim() : ""; + var ruleEl = document.getElementById("posPlanRule"); + var rule = ruleEl ? parseInt(ruleEl.value, 10) || 1 : 1; + var er2 = document.getElementById("posPlanModalErr"); + if (!pr) { + if (er2) { + er2.style.display = "block"; + er2.textContent = "请填写触发价"; + } + return; + } + if (er2) { + er2.style.display = "none"; + er2.textContent = ""; + } + sb.disabled = true; + fetch("/api/price_orders/manual", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ + contract: ctc, + trigger_price: pr, + rule: rule, + }), + }) + .then(function (res) { + if (res.status === 401) { + window.location.href = "/login"; + return null; + } + return res.json().then(function (j) { + return { res: res, j: j }; + }); + }) + .then(function (pack) { + if (!pack) return; + if (!pack.res.ok) { + if (er2) { + er2.style.display = "block"; + er2.textContent = (pack.j && pack.j.detail) || "提交失败"; + } + } else { + posModalClose(); + poll(); + } + }) + .catch(function (e) { + if (er2) { + er2.style.display = "block"; + er2.textContent = String(e); + } + }) + .finally(function () { + sb.disabled = false; + }); + }); + } + } + } + + function renderPlanOrders(data) { + var errEl = document.getElementById("planErr"); + var tbody = document.getElementById("planBody"); + if (!tbody) return; + if (!data.gate_api_configured) { + tbody.innerHTML = + '未配置 Gate API 密钥,无法拉取计划委托'; + if (errEl) { + errEl.style.display = "none"; + errEl.textContent = ""; + } + return; + } + var poErr = data.open_price_orders_error; + if (poErr) { + if (errEl) { + errEl.style.display = "block"; + errEl.textContent = "计划委托接口: " + poErr; + } + } else if (errEl) { + errEl.style.display = "none"; + errEl.textContent = ""; + } + var po = data.open_price_orders; + if (!Array.isArray(po)) { + tbody.innerHTML = + '' + + (poErr ? "列表不可用(见上方错误)" : "暂无数据") + + ""; + return; + } + if (!po.length) { + tbody.innerHTML = + '当前无 open 状态计划委托'; + return; + } + tbody.innerHTML = po + .map(function (r) { + var oid = String(r.order_id || "").trim(); + var oidAttr = encodeURIComponent(oid); + var ct = escapeHtml(String(r.contract || "")); + var st = escapeHtml(String(r.status || "")); + var tp = escapeHtml(String(r.trigger_price || "")); + var ru = r.rule != null ? escapeHtml(String(r.rule)) : "—"; + var sz = r.size != null ? escapeHtml(String(r.size)) : "—"; + var ot = escapeHtml(String(r.order_type || "—")); + var ts = escapeHtml(fmtTs(r.create_time)); + return ( + "" + + ct + + "" + + st + + "" + + tp + + "" + + ru + + "" + + sz + + "" + + ot + + "" + + ts + + "" + ); + }) + .join(""); + } + + function ghReadRange() { + var cf = document.getElementById("ghContract"); + var fEl = document.getElementById("ghFrom"); + var tEl = document.getElementById("ghTo"); + var contract = cf ? String(cf.value || "").trim() : ""; + var fs = fEl ? String(fEl.value || "").trim() : ""; + var ts = tEl ? String(tEl.value || "").trim() : ""; + var from = fs === "" ? null : parseInt(fs, 10); + var to = ts === "" ? null : parseInt(ts, 10); + if (fs !== "" && isNaN(from)) { + return { error: "from 须为 Unix 秒整数" }; + } + if (ts !== "" && isNaN(to)) { + return { error: "to 须为 Unix 秒整数" }; + } + return { contract: contract || null, from: from, to: to }; + } + + function qsPart(k, v) { + if (v === null || v === undefined || v === "") return ""; + return encodeURIComponent(k) + "=" + encodeURIComponent(String(v)); + } + + function ghTradesQs(extra) { + var r = ghReadRange(); + if (r.error) return { error: r.error }; + var p = []; + var a = qsPart("contract", r.contract); + if (a) p.push(a); + a = qsPart("from", r.from); + if (a) p.push(a); + a = qsPart("to", r.to); + if (a) p.push(a); + if (extra) { + Object.keys(extra).forEach(function (k) { + var x = qsPart(k, extra[k]); + if (x) p.push(x); + }); + } + return { qs: p.length ? "?" + p.join("&") : "" }; + } + + function ghOrdersQs(extra) { + var r = ghReadRange(); + if (r.error) return { error: r.error }; + var p = []; + var a = qsPart("contract", r.contract); + if (a) p.push(a); + if (extra) { + Object.keys(extra).forEach(function (k) { + var x = qsPart(k, extra[k]); + if (x) p.push(x); + }); + } + return { qs: p.length ? "?" + p.join("&") : "" }; + } + + function setGhErr(msg) { + var el = document.getElementById("ghErr"); + if (!el) return; + if (msg) { + el.style.display = "block"; + el.textContent = msg; + } else { + el.style.display = "none"; + el.textContent = ""; + } + } + + function renderGhTrades(rows) { + var tbody = document.getElementById("ghTradesBody"); + if (!tbody) return; + if (!Array.isArray(rows) || !rows.length) { + tbody.innerHTML = + '无数据'; + return; + } + tbody.innerHTML = rows + .map(function (r) { + var tid = String(r.trade_id != null ? r.trade_id : r.id || ""); + return ( + "" + + escapeHtml(fmtTs(r.create_time)) + + "" + + escapeHtml(String(r.contract || "")) + + "" + + escapeHtml(String(r.size != null ? r.size : "")) + + "" + + escapeHtml(fmtNum(r.price)) + + "" + + escapeHtml(String(r.fee != null ? r.fee : "")) + + "" + + escapeHtml(String(r.role || "")) + + "" + + escapeHtml(tid) + + "" + ); + }) + .join(""); + } + + function renderGhOrders(rows) { + var tbody = document.getElementById("ghOrdersBody"); + if (!tbody) return; + if (!Array.isArray(rows) || !rows.length) { + tbody.innerHTML = + '无数据'; + return; + } + tbody.innerHTML = rows + .map(function (r) { + var px = + (r.price != null ? String(r.price) : "—") + + " / " + + (r.fill_price != null ? String(r.fill_price) : "—"); + return ( + "" + + escapeHtml(fmtTs(r.create_time)) + + "" + + escapeHtml(String(r.contract || "")) + + "" + + escapeHtml(String(r.status || "")) + + "" + + escapeHtml(String(r.size != null ? r.size : "")) + + "" + + escapeHtml(px) + + "" + + escapeHtml(String(r.id != null ? r.id : "")) + + "" + ); + }) + .join(""); + } + + async function downloadCsv(pathWithQs, filename) { + try { + var res = await fetch(pathWithQs, { credentials: "same-origin" }); + if (res.status === 401) { + window.location.href = "/login"; + return; + } + if (!res.ok) { + var detail = ""; + try { + var j = await res.json(); + detail = (j && j.detail) || ""; + } catch (e1) { + detail = ""; + } + alert(detail || "下载失败 " + res.status); + return; + } + var blob = await res.blob(); + var a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = filename; + a.click(); + URL.revokeObjectURL(a.href); + } catch (e) { + alert(String(e)); + } + } + + function wireGateHistory() { + var bT = document.getElementById("btnGhTrades"); + var bTC = document.getElementById("btnGhTradesCsv"); + var bO = document.getElementById("btnGhOrders"); + var bOC = document.getElementById("btnGhOrdersCsv"); + if (bT) { + bT.addEventListener("click", async function () { + setGhErr(""); + var q = ghTradesQs({ limit: 80, offset: 0 }); + if (q.error) { + setGhErr(q.error); + return; + } + try { + var res = await fetch("/api/gate/trades" + q.qs, { + credentials: "same-origin", + }); + if (res.status === 401) { + window.location.href = "/login"; + return; + } + var j = await res.json(); + if (!res.ok) { + setGhErr((j && j.detail) || "请求失败 " + res.status); + renderGhTrades([]); + return; + } + if (j.error) { + setGhErr(String(j.error)); + renderGhTrades([]); + return; + } + renderGhTrades(j.rows || []); + } catch (e) { + setGhErr(String(e)); + } + }); + } + if (bTC) { + bTC.addEventListener("click", function () { + setGhErr(""); + var q = ghTradesQs({ max: 2000 }); + if (q.error) { + setGhErr(q.error); + return; + } + downloadCsv("/api/gate/trades.csv" + q.qs, "gate_futures_trades.csv"); + }); + } + if (bO) { + bO.addEventListener("click", async function () { + setGhErr(""); + var stEl = document.getElementById("ghOrdStatus"); + var st = stEl ? String(stEl.value || "finished") : "finished"; + var q = ghOrdersQs({ status: st, limit: 80, offset: 0 }); + if (q.error) { + setGhErr(q.error); + return; + } + try { + var res = await fetch("/api/gate/orders_history" + q.qs, { + credentials: "same-origin", + }); + if (res.status === 401) { + window.location.href = "/login"; + return; + } + var j = await res.json(); + if (!res.ok) { + setGhErr((j && j.detail) || "请求失败 " + res.status); + renderGhOrders([]); + return; + } + if (j.error) { + setGhErr(String(j.error)); + renderGhOrders([]); + return; + } + renderGhOrders(j.rows || []); + } catch (e) { + setGhErr(String(e)); + } + }); + } + if (bOC) { + bOC.addEventListener("click", function () { + setGhErr(""); + var stEl = document.getElementById("ghOrdStatus"); + var st = stEl ? String(stEl.value || "finished") : "finished"; + var q = ghOrdersQs({ status: st, max: 2000 }); + if (q.error) { + setGhErr(q.error); + return; + } + var fn = + st === "open" + ? "gate_futures_orders_open.csv" + : "gate_futures_orders_finished.csv"; + downloadCsv("/api/gate/orders_history.csv" + q.qs, fn); + }); + } + } + + function fmtPct(x) { + if (x == null || x === "") return "—"; + var n = Number(x); + if (isNaN(n)) return "—"; + return (n * 100).toFixed(2) + "%"; + } + + function setStatsErr(msg) { + var el = document.getElementById("statsErr"); + if (!el) return; + if (msg) { + el.style.display = "block"; + el.textContent = msg; + } else { + el.style.display = "none"; + el.textContent = ""; + } + } + + function setStatsWarn(msg) { + var el = document.getElementById("statsWarn"); + if (!el) return; + if (msg) { + el.style.display = "block"; + el.textContent = msg; + } else { + el.style.display = "none"; + el.textContent = ""; + } + } + + function renderStatsPeriodMeta(metaEl, period) { + if (!metaEl || !period) return; + var lab = period.label != null ? String(period.label) : "—"; + var part = period.partial ? "进行中" : "已闭区间"; + metaEl.textContent = lab + " · " + part; + } + + function renderStatsPeriodBody(bodyEl, metrics) { + if (!bodyEl) return; + if (!metrics) { + bodyEl.innerHTML = '

无数据

'; + return; + } + var pf = metrics.profit_factor; + var pfStr = pf == null || pf === "" ? "—" : fmtNum(pf); + var msl = metrics.max_single_loss; + var mslStr = msl == null || msl === "" ? "—" : fmtNum(msl); + bodyEl.innerHTML = + '
' + + '
胜率' + + '' + + escapeHtml(fmtPct(metrics.win_rate)) + + "
" + + '
盈亏比' + + '' + + escapeHtml(pfStr) + + "
" + + '
最大单笔亏损' + + '' + + escapeHtml(mslStr) + + "
" + + '
最大回撤' + + '' + + escapeHtml(fmtNum(metrics.max_drawdown)) + + "
" + + '
最大连续亏损' + + '' + + escapeHtml( + metrics.max_consecutive_losses != null + ? String(metrics.max_consecutive_losses) + : "—" + ) + + "
" + + '
笔数' + + '' + + escapeHtml(metrics.trade_count != null ? String(metrics.trade_count) : "—") + + "
" + + '
净盈亏' + + '' + + escapeHtml(fmtNum(metrics.net_pnl)) + + "
" + + "
"; + } + + async function loadStats() { + setStatsErr(""); + setStatsWarn(""); + var btn = document.getElementById("btnStatsRefresh"); + var idle = document.getElementById("statsIdle"); + var cards = document.getElementById("statsCards"); + var cf = document.getElementById("stContract"); + var contract = cf ? String(cf.value || "").trim() : ""; + var qs = ""; + if (contract) qs = "?contract=" + encodeURIComponent(contract.toUpperCase()); + if (btn) btn.disabled = true; + try { + var res = await fetch("/api/stats/summary" + qs, { credentials: "same-origin" }); + if (res.status === 401) { + window.location.href = "/login"; + return; + } + var j = await res.json(); + if (!res.ok) { + setStatsErr((j && j.detail) || "HTTP " + res.status); + return; + } + if (!j.ok) { + setStatsErr(j.error || "统计失败"); + return; + } + if (idle) idle.style.display = "none"; + if (cards) cards.style.display = ""; + var parts = []; + if (j.truncated) parts.push("已拉满 max_trade_rows,更早历史平仓未纳入,回撤等序列指标可能偏低。"); + if (j.pnl_field === "missing") parts.push("返回中未解析到 pnl 字段,请确认 Gate position_close 是否返回净盈亏。"); + if (j.closing_rows_missing_pnl > 0) { + parts.push("有 " + j.closing_rows_missing_pnl + " 条历史平仓记录缺少可解析 pnl,已跳过。"); + } + setStatsWarn(parts.length ? parts.join(" ") : ""); + + renderStatsPeriodMeta(document.getElementById("stDayMeta"), j.day); + renderStatsPeriodMeta(document.getElementById("stWeekMeta"), j.week); + renderStatsPeriodMeta(document.getElementById("stMonthMeta"), j.month); + renderStatsPeriodBody(document.getElementById("stDayBody"), j.day && j.day.metrics); + renderStatsPeriodBody(document.getElementById("stWeekBody"), j.week && j.week.metrics); + renderStatsPeriodBody(document.getElementById("stMonthBody"), j.month && j.month.metrics); + } catch (e) { + setStatsErr(String(e)); + } finally { + if (btn) btn.disabled = false; + } + } + + function wireStatsPanel() { + var b = document.getElementById("btnStatsRefresh"); + if (b && !b.getAttribute("data-wired")) { + b.setAttribute("data-wired", "1"); + b.addEventListener("click", function () { + loadStats(); + }); + } + } + + function wireDashboardTabs() { + var nav = document.getElementById("execTabs"); + if (!nav || nav.getAttribute("data-wired")) return; + nav.setAttribute("data-wired", "1"); + function show(id) { + var tabs = nav.querySelectorAll(".exec-tab[data-exec-tab]"); + var panels = document.querySelectorAll(".exec-tab-panel[data-exec-panel]"); + tabs.forEach(function (b) { + var on = b.getAttribute("data-exec-tab") === id; + b.classList.toggle("exec-tab--active", on); + b.setAttribute("aria-selected", on ? "true" : "false"); + }); + panels.forEach(function (p) { + var on = p.getAttribute("data-exec-panel") === id; + p.classList.toggle("exec-tab-panel--active", on); + }); + try { + sessionStorage.setItem("execDashboardTab", id); + } catch (e1) {} + } + nav.addEventListener("click", function (ev) { + var btn = ev.target.closest(".exec-tab[data-exec-tab]"); + if (!btn) return; + show(btn.getAttribute("data-exec-tab")); + }); + var saved = null; + try { + saved = sessionStorage.getItem("execDashboardTab"); + } catch (e2) {} + if (saved === "plans") { + saved = "positions"; + try { + sessionStorage.setItem("execDashboardTab", "positions"); + } catch (e3) {} + } + if (saved && nav.querySelector('.exec-tab[data-exec-tab="' + saved + '"]')) { + show(saved); + } + } + + function wirePlanOrdersTable() { + var tbl = document.getElementById("planTable"); + if (!tbl || tbl.getAttribute("data-delegated")) return; + tbl.setAttribute("data-delegated", "1"); + tbl.addEventListener("click", async function (ev) { + var btn = ev.target && ev.target.closest && ev.target.closest(".exec-plan-cancel"); + if (!btn) return; + var enc = btn.getAttribute("data-oid") || ""; + var oid = ""; + try { + oid = decodeURIComponent(enc); + } catch (e1) { + oid = enc; + } + if (!oid) return; + if (!confirm("确认撤销计划委托 " + oid + " ?(将请求 Gate DELETE price_orders)")) return; + btn.disabled = true; + try { + var res = await fetch("/api/price_orders/cancel", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ order_id: oid }), + }); + if (res.status === 401) { + window.location.href = "/login"; + return; + } + var j = {}; + try { + j = await res.json(); + } catch (e2) { + j = {}; + } + if (!res.ok) { + alert((j && j.detail) || "撤单失败 " + res.status); + } else { + poll(); + } + } catch (e) { + alert(String(e)); + } finally { + btn.disabled = false; + } + }); + } + + function statusCellClass(st) { + if (st === "accepted") return "exec-cell-status exec-cell-status--ok"; + if (st === "skipped") return "exec-cell-status exec-cell-status--skip"; + if (st === "error") return "exec-cell-status exec-cell-status--err"; + return "exec-cell-status"; + } + + function renderState(data) { + var txCh = document.getElementById("execTxChannel"); + if (txCh) { + if (!data.gate_api_configured) { + txCh.textContent = "NO_API"; + } else if (data.live_trading_enabled) { + txCh.textContent = "LIVE"; + } else if (data.dry_run) { + txCh.textContent = "DRY_RUN"; + } else { + txCh.textContent = "STBY"; + } + } + var sigBan = document.getElementById("sigPersistBanner"); + if (sigBan) { + var pPath = data.signals_sqlite_path != null ? String(data.signals_sqlite_path) : ""; + if (data.signals_persisted) { + sigBan.className = "exec-sig-persist exec-sig-persist--ok"; + sigBan.textContent = + "持久化:已启用 SQLite · 文件 " + pPath + " · 重启进程后本页仍显示历史信号流。"; + } else { + sigBan.className = "exec-sig-persist exec-sig-persist--warn"; + sigBan.textContent = + "持久化:未就绪(仅内存环形缓冲,重启即清空)。请检查进程日志中的 signal_db_init_failed,并确认对 " + + (pPath || "database.sqlite_path") + + " 有读写权限。"; + } + } + var live = !!data.live_trading_enabled; + if (live) { + setChip(document.getElementById("stDryRunWrap"), "实盘 LIVE", "ok"); + } else { + var dry = !!data.dry_run; + setChip( + document.getElementById("stDryRunWrap"), + dry ? "模拟 dry_run" : "未启用实盘", + dry ? "warn" : "neutral" + ); + } + var keysOk = !!data.gate_api_configured; + setChip( + document.getElementById("stKeysWrap"), + keysOk ? "密钥已配置" : "未配置密钥", + keysOk ? "ok" : "neutral" + ); + + var slotsEl = document.getElementById("stSlots"); + if (slotsEl) { + slotsEl.textContent = + data.positions && data.positions.open_slot_count != null + ? String(data.positions.open_slot_count) + : "—"; + } + + var r = data.risk || {}; + var riskPct = + r.risk_per_trade_frac != null ? (Number(r.risk_per_trade_frac) * 100).toFixed(2) + "%" : "—"; + var riskEl = document.getElementById("stRisk"); + if (riskEl) riskEl.textContent = riskPct; + var schemeEl = document.getElementById("stScheme"); + if (schemeEl) schemeEl.textContent = r.scheme || "—"; + var riskLarge = document.getElementById("stRiskLarge"); + if (riskLarge) riskLarge.textContent = riskPct; + var maxPos = document.getElementById("stMaxPos"); + if (maxPos) { + maxPos.textContent = + r.max_open_positions != null ? String(r.max_open_positions) + " 个标的" : "—"; + } + + var minRrInp = document.getElementById("sigMinRrInput"); + if ( + minRrInp && + document.activeElement !== minRrInp && + r.min_reward_risk_ratio != null && + !isNaN(Number(r.min_reward_risk_ratio)) + ) { + minRrInp.value = String(r.min_reward_risk_ratio); + } + + var be = data.breakeven || {}; + var beGlobal = document.getElementById("beGlobalEnable"); + if ( + beGlobal && + document.activeElement !== beGlobal && + be.global_enabled != null + ) { + beGlobal.checked = !!be.global_enabled; + } + + var p = data.proxy || {}; + setChip( + document.getElementById("pxEnabledWrap"), + p.enabled ? "已启用" : "未启用", + p.enabled ? "ok" : "neutral" + ); + setChip( + document.getElementById("pxEffectiveWrap"), + p.effective ? "出站走代理" : "直连", + p.effective ? "ok" : "neutral" + ); + var pxUrl = document.getElementById("pxUrl"); + if (pxUrl) pxUrl.textContent = p.url || "(未启用)"; + + renderAccount(data); + renderExchangePositions(data); + renderPlanOrders(data); + + var tbody = document.getElementById("sigBody"); + var rows = data.recent_signals || []; + if (!tbody) return; + if (!rows.length) { + tbody.innerHTML = + '暂无记录'; + return; + } + tbody.innerHTML = rows + .map(function (row) { + var s = row.signal || {}; + var res = row.result || {}; + var st = res.status || "—"; + var reason = res.reason ? " · " + res.reason : ""; + var cellClass = statusCellClass(st); + var refP = + res.reference_price_used != null + ? res.reference_price_used + : res.reference_entry != null + ? res.reference_entry + : s.reference_price; + var refStr = + refP != null && refP !== "" && !isNaN(Number(refP)) + ? fmtNum(refP) + : "—"; + var tpD = + res.take_profit_display != null && res.take_profit_display !== "" + ? String(res.take_profit_display) + : res.take_profit_price_sent != null && + res.take_profit_price_sent !== "" + ? String(res.take_profit_price_sent) + : s.take_profit != null + ? fmtNum(s.take_profit) + : "—"; + var slD = + res.stop_loss_display != null && res.stop_loss_display !== "" + ? String(res.stop_loss_display) + : res.stop_loss_price_sent != null && + res.stop_loss_price_sent !== "" + ? String(res.stop_loss_price_sent) + : s.stop_loss != null + ? fmtNum(s.stop_loss) + : "—"; + var rr = res.reward_risk_ratio; + var rrStr = + rr != null && rr !== "" && !isNaN(Number(rr)) ? fmtNum(rr) : "—"; + var rrReason = res.reward_risk_reason + ? escapeHtml(String(res.reward_risk_reason)) + : ""; + var rrTitle = rrReason ? ' title="' + rrReason + '"' : ""; + var sd = String(s.side || "").toLowerCase(); + var sideCell = + sd === "long" + ? 'long' + : sd === "short" + ? 'short' + : escapeHtml(s.side || "—"); + return ( + "" + + escapeHtml(fmtTs(row.ts)) + + "" + + escapeHtml(s.contract || "—") + + "" + + sideCell + + "" + + escapeHtml(refStr) + + "" + + escapeHtml(tpD) + + "" + + escapeHtml(slD) + + "" + + escapeHtml(rrStr) + + "" + + escapeHtml(st + reason) + + "" + ); + }) + .join(""); + } + + async function poll() { + try { + var res = await fetch("/api/state", { credentials: "same-origin" }); + if (res.status === 401) { + window.location.href = "/login"; + return; + } + if (!res.ok) throw new Error("http " + res.status); + renderState(await res.json()); + } catch (e) { + var tbody = document.getElementById("sigBody"); + if (tbody && tbody.rows.length === 1) { + tbody.innerHTML = + '无法加载状态'; + } + var pt = document.getElementById("planBody"); + if (pt && pt.rows.length === 1) { + pt.innerHTML = + '无法加载计划委托'; + } + var posb = document.getElementById("posBody"); + if (posb && posb.rows.length === 1) { + posb.innerHTML = + '无法加载持仓'; + } + } + } + + async function saveBreakevenPrefs(body, msgEl, onOk) { + try { + var res = await fetch("/api/breakeven-prefs", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (res.status === 401) { + window.location.href = "/login"; + return; + } + var j = await res.json().catch(function () { + return null; + }); + if (!res.ok || !j || !j.ok) { + if (msgEl) + msgEl.textContent = (j && j.detail) || "保存失败 HTTP " + res.status; + return; + } + if (msgEl) msgEl.textContent = "已保存"; + if (onOk) onOk(j); + poll(); + } catch (e) { + if (msgEl) msgEl.textContent = String(e); + } + } + + function saveBreakevenContract(contract, enabled, checkboxEl) { + if (checkboxEl) checkboxEl.disabled = true; + return saveBreakevenPrefs( + { contract: contract, enabled: !!enabled }, + null, + null + ).finally(function () { + if (checkboxEl) checkboxEl.disabled = false; + }); + } + + function wireBreakevenPrefs() { + var cb = document.getElementById("beGlobalEnable"); + var btn = document.getElementById("beGlobalSave"); + var msg = document.getElementById("beGlobalMsg"); + if (!cb || !btn) return; + btn.addEventListener("click", function () { + if (msg) msg.textContent = ""; + btn.disabled = true; + saveBreakevenPrefs({ global_enabled: !!cb.checked }, msg, null).finally( + function () { + btn.disabled = false; + } + ); + }); + } + + function wireMinRiskPrefs() { + var inp = document.getElementById("sigMinRrInput"); + var btn = document.getElementById("sigMinRrSave"); + var msg = document.getElementById("sigMinRrMsg"); + if (!inp || !btn) return; + btn.addEventListener("click", async function () { + if (msg) msg.textContent = ""; + var raw = String(inp.value || "").trim(); + var v = parseFloat(raw); + if (!isFinite(v) || v < 0.1 || v > 50) { + if (msg) msg.textContent = "请输入 0.1~50 之间的数字"; + return; + } + btn.disabled = true; + try { + var res = await fetch("/api/risk-prefs", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ min_reward_risk_ratio: v }), + }); + if (res.status === 401) { + window.location.href = "/login"; + return; + } + var j = await res.json().catch(function () { + return null; + }); + if (!res.ok || !j || !j.ok) { + if (msg) + msg.textContent = + (j && j.detail) || "保存失败 HTTP " + res.status; + return; + } + if (j.min_reward_risk_ratio != null) inp.value = String(j.min_reward_risk_ratio); + if (msg) msg.textContent = "已保存"; + poll(); + } catch (e) { + if (msg) msg.textContent = String(e); + } finally { + btn.disabled = false; + } + }); + } + + function wireSignalExport() { + var cb = document.getElementById("sigExportEnable"); + var btn = document.getElementById("sigExportBtn"); + if (!cb || !btn) return; + var k = "exec_signal_export_enabled"; + cb.checked = localStorage.getItem(k) === "1"; + btn.disabled = !cb.checked; + cb.addEventListener("change", function () { + localStorage.setItem(k, cb.checked ? "1" : "0"); + btn.disabled = !cb.checked; + }); + btn.addEventListener("click", function () { + if (!cb.checked) return; + downloadCsv("/api/signals/export.csv", "signal_stream.csv"); + }); + } + + tickClock(); + setInterval(tickClock, 1000); + wireDashboardTabs(); + wirePositionActions(); + wirePlanOrdersTable(); + wireGateHistory(); + wireStatsPanel(); + wireSignalExport(); + wireMinRiskPrefs(); + wireBreakevenPrefs(); + poll(); + setInterval(poll, 2000); +})(); diff --git a/gate_order_executor/static/style.css b/gate_order_executor/static/style.css new file mode 100644 index 0000000..bf26597 --- /dev/null +++ b/gate_order_executor/static/style.css @@ -0,0 +1,874 @@ +/* Gate Order Executor — refined dark console */ +:root { + --exec-bg-deep: #070708; + --exec-bg-raised: #0c0c10; + --exec-bg-card: #101118; + --exec-bg-card-hover: #16161f; + --exec-border: rgba(255, 255, 255, 0.055); + --exec-border-strong: rgba(255, 255, 255, 0.12); + --exec-text: #f7f7fb; + --exec-text-muted: #a8a8b8; + --exec-text-dim: #6d6d7c; + --exec-accent: #ececf4; + --exec-accent-soft: rgba(236, 236, 244, 0.09); + --exec-line: rgba(255, 255, 255, 0.038); + --exec-glow: rgba(120, 160, 255, 0.08); + --exec-radius: 17px; + --exec-radius-sm: 12px; + --exec-font: "Plus Jakarta Sans", "SF Pro Text", "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; + --exec-mono: "JetBrains Mono", "SF Mono", ui-monospace, "Cascadia Code", monospace; + --exec-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.045), + 0 2px 4px rgba(0, 0, 0, 0.35), + 0 28px 56px rgba(0, 0, 0, 0.5), + 0 64px 128px rgba(0, 0, 0, 0.28); + --exec-shadow-hover: + 0 0 0 1px rgba(255, 255, 255, 0.07), + 0 36px 72px rgba(0, 0, 0, 0.55); + --exec-header-h: 78px; +} +*, +*::before, +*::after { + box-sizing: border-box; +} + +/* —— Shell(登录 + 控制台共用底层) —— */ +.exec-shell { + margin: 0; + min-height: 100vh; + font-family: var(--exec-font); + background: var(--exec-bg-deep); + color: var(--exec-text); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + color-scheme: dark; +} + +.exec-ambient { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 0; + background: + radial-gradient(ellipse 110% 85% at 50% -35%, rgba(115, 155, 255, 0.11), transparent 52%), + radial-gradient(ellipse 55% 42% at 100% 8%, rgba(255, 255, 255, 0.045), transparent 48%), + radial-gradient(ellipse 50% 38% at 0% 88%, rgba(72, 200, 190, 0.05), transparent 46%), + linear-gradient(180deg, #0b0b0f 0%, var(--exec-bg-deep) 38%, #050506 100%); +} +.exec-noise { + position: fixed; + inset: 0; + z-index: 0; + opacity: 0.035; + pointer-events: none; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); +} + +.exec-grid-faint { + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background-size: 48px 48px; + background-image: + linear-gradient(var(--exec-line) 1px, transparent 1px), + linear-gradient(90deg, var(--exec-line) 1px, transparent 1px); + mask-image: radial-gradient(ellipse 70% 60% at 50% 40%, black 20%, transparent 100%); + opacity: 0.5; +} + +/* —— 顶栏模块 —— */ +.exec-header { + position: sticky; + top: 0; + z-index: 20; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + min-height: var(--exec-header-h); + padding: 0 clamp(22px, 4.5vw, 44px); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + background: linear-gradient(180deg, rgba(14, 14, 18, 0.92) 0%, rgba(8, 8, 11, 0.82) 100%); + backdrop-filter: blur(22px) saturate(1.45); + -webkit-backdrop-filter: blur(22px) saturate(1.45); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.05) inset, + 0 20px 48px rgba(0, 0, 0, 0.45); +} +.exec-header__brand { + display: flex; + align-items: center; + gap: 16px; + min-width: 0; +} + +.exec-header__text { + min-width: 0; +} + +.exec-mark { + flex-shrink: 0; + width: 46px; + height: 46px; + border-radius: 14px; + display: grid; + place-items: center; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.08em; + color: var(--exec-text); + background: linear-gradient(152deg, #222632 0%, #12141d 48%, #161a24 100%); + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.1), + 0 0 0 1px rgba(0, 0, 0, 0.4), + 0 10px 28px rgba(0, 0, 0, 0.45); +} + +.exec-title { + margin: 0; + font-size: clamp(1.08rem, 2.1vw, 1.32rem); + font-weight: 600; + letter-spacing: -0.028em; + color: var(--exec-text); + line-height: 1.2; +} + +.exec-title__sep { + margin: 0 0.12em; + font-weight: 500; + color: var(--exec-text-dim); +} + +.exec-subtitle { + margin: 6px 0 0; + font-size: 0.8rem; + color: var(--exec-text-dim); + letter-spacing: 0.03em; + font-weight: 500; +} + +.exec-header__actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; +} + +.exec-clock { + font-family: var(--exec-mono); + font-size: 0.78rem; + font-weight: 500; + font-variant-numeric: tabular-nums; + color: var(--exec-text-muted); + padding: 9px 16px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.02) 100%); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +.exec-user { + font-size: 0.78rem; + color: var(--exec-text-muted); + padding: 9px 16px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.06); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.07) 0%, rgba(255, 255, 255, 0.02) 100%); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05); +} + +.exec-user strong { + color: var(--exec-text); + font-weight: 600; +} + +/* —— 主内容区:模块化栅格 —— */ +.exec-main { + position: relative; + z-index: 1; + max-width: 1360px; + margin: 0 auto; + padding: clamp(20px, 3vw, 32px) clamp(20px, 4vw, 40px) 48px; +} + +/* 标签页布局:顶部分区切换,内容区各自滚动(高度 ≈ 视口 − 顶栏) */ +.exec-main--tabbed { + min-height: calc(100vh - var(--exec-header-h)); + display: flex; + flex-direction: column; + width: 100%; + max-width: 1360px; + margin: 0 auto; + padding: clamp(14px, 2.2vw, 22px) clamp(22px, 4.5vw, 44px) 0; + padding-bottom: 0; +} + +.exec-tabs { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-bottom: 22px; + flex-shrink: 0; + padding: 6px 7px; + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.06); + background: rgba(255, 255, 255, 0.025); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05); +} + +.exec-tab { + appearance: none; + margin: 0; + cursor: pointer; + font: inherit; + font-size: 0.83rem; + font-weight: 500; + letter-spacing: 0.01em; + color: var(--exec-text-muted); + background: transparent; + border: 1px solid transparent; + border-radius: 11px; + padding: 10px 17px; + transition: + color 0.18s ease, + background 0.18s ease, + border-color 0.18s ease, + box-shadow 0.18s ease; +} + +.exec-tab:hover { + color: var(--exec-text); + background: rgba(255, 255, 255, 0.055); +} + +.exec-tab:focus-visible { + outline: 2px solid rgba(130, 165, 255, 0.35); + outline-offset: 2px; +} + +.exec-tab--active { + color: var(--exec-text); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.035) 100%); + border-color: rgba(255, 255, 255, 0.1); + box-shadow: + 0 0 28px rgba(100, 140, 255, 0.1), + inset 0 1px 0 rgba(255, 255, 255, 0.08); +} + +.exec-tab-panels { + flex: 1; + min-height: 0; + position: relative; + margin-bottom: clamp(16px, 2vw, 28px); +} + +.exec-tab-panel { + display: none; + position: absolute; + inset: 0; + overflow-y: auto; + overflow-x: hidden; + -webkit-overflow-scrolling: touch; + padding-bottom: 32px; + scrollbar-gutter: stable; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.14) transparent; +} + +.exec-tab-panel::-webkit-scrollbar { + width: 9px; +} + +.exec-tab-panel::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.12); + border-radius: 99px; + border: 2px solid transparent; + background-clip: padding-box; +} + +.exec-tab-panel::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.18); + background-clip: padding-box; +} + +.exec-tab-panel--active { + display: block; +} + +.exec-modal { + position: fixed; + inset: 0; + z-index: 100; + display: none; + align-items: center; + justify-content: center; + padding: 20px; +} + +.exec-modal.exec-modal--open { + display: flex; +} + +.exec-modal__backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.68); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + cursor: pointer; +} + +.exec-modal__card { + position: relative; + z-index: 1; + width: 100%; + max-width: 440px; + padding: 24px 24px 22px; + border-radius: calc(var(--exec-radius) + 2px); + border: 1px solid rgba(255, 255, 255, 0.1); + background: linear-gradient(180deg, rgba(24, 24, 32, 0.98) 0%, rgba(14, 14, 18, 0.99) 100%); + box-shadow: var(--exec-shadow), 0 0 80px rgba(0, 0, 0, 0.5); +} + +.exec-modal__title { + margin: 0 0 10px; + font-size: 1rem; + font-weight: 600; + letter-spacing: -0.02em; + color: var(--exec-text); +} + +.exec-btn--sm { + font-size: 0.72rem; + padding: 5px 10px; + border-radius: 8px; +} + +.exec-pos-actions { + white-space: nowrap; +} + +.exec-section-label { + font-size: 0.64rem; + font-weight: 600; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--exec-text-dim); + margin: 0 0 14px 4px; + opacity: 0.92; +} +.exec-module-row { + display: grid; + gap: 16px; + margin-bottom: 16px; +} + +.exec-module-row--3 { + grid-template-columns: repeat(3, 1fr); +} + +@media (max-width: 1020px) { + .exec-module-row--3 { + grid-template-columns: 1fr; + } +} + +/* 单模块卡片 */ +.exec-module { + background: linear-gradient(165deg, rgba(22, 22, 30, 0.92) 0%, rgba(12, 12, 16, 0.96) 55%, rgba(10, 10, 14, 0.98) 100%); + border: 1px solid rgba(255, 255, 255, 0.065); + border-radius: var(--exec-radius); + box-shadow: var(--exec-shadow); + overflow: hidden; + transition: + border-color 0.22s ease, + box-shadow 0.22s ease, + transform 0.22s ease; +} + +.exec-module:hover { + border-color: rgba(255, 255, 255, 0.1); + box-shadow: var(--exec-shadow-hover); + transform: translateY(-1px); +} +.exec-module--wide { + grid-column: 1 / -1; +} + +.exec-module-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 17px 22px; + border-bottom: 1px solid rgba(255, 255, 255, 0.055); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.055) 0%, rgba(255, 255, 255, 0) 100%); +} + +.exec-module-title { + margin: 0; + font-size: 0.94rem; + font-weight: 600; + letter-spacing: -0.015em; + color: var(--exec-text); +} + +.exec-module-meta { + font-size: 0.7rem; + color: var(--exec-text-dim); + letter-spacing: 0.06em; + font-weight: 500; + text-align: right; + max-width: 52%; + line-height: 1.45; +} + +.exec-module-body { + padding: 20px 22px 22px; +} +/* 指标栅格(子模块) */ +.exec-metric-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; +} + +.exec-metric { + padding: 15px 15px 13px; + border-radius: var(--exec-radius-sm); + border: 1px solid rgba(255, 255, 255, 0.055); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.045) 0%, rgba(255, 255, 255, 0.015) 100%); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); +} + +.exec-metric-label { + display: block; + font-size: 0.65rem; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--exec-text-dim); + margin-bottom: 9px; +} + +.exec-metric-value { + font-size: 0.96rem; + font-weight: 500; + color: var(--exec-text); + line-height: 1.35; +} + +.exec-metric-value--mono { + font-family: var(--exec-mono); + font-size: 0.83rem; + font-weight: 500; + font-variant-numeric: tabular-nums; + word-break: break-all; + color: var(--exec-text-muted); +} +/* 状态芯片 */ +.exec-chip { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 5px 11px; + border-radius: 8px; + font-size: 0.76rem; + font-weight: 600; + letter-spacing: 0.02em; +} +.exec-chip::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + opacity: 0.85; +} + +.exec-chip--neutral { + color: var(--exec-text-muted); + background: rgba(255, 255, 255, 0.06); + border: 1px solid var(--exec-border); +} + +.exec-chip--ok { + color: #86efac; + background: rgba(34, 197, 94, 0.1); + border: 1px solid rgba(34, 197, 94, 0.22); +} + +.exec-chip--warn { + color: #fcd34d; + background: rgba(245, 158, 11, 0.1); + border: 1px solid rgba(245, 158, 11, 0.22); +} + +.exec-chip--live { + color: #fca5a5; + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.22); +} + +/* 持仓:交易所张数符号 = 方向;未实现盈亏着色 */ +.exec-dir { + display: inline-block; + margin-left: 8px; + padding: 2px 8px; + border-radius: 4px; + font-size: 0.72rem; + font-weight: 600; + text-transform: lowercase; + vertical-align: middle; +} + +.exec-dir--long { + color: #86efac; + background: rgba(34, 197, 94, 0.12); + border: 1px solid rgba(34, 197, 94, 0.25); +} + +.exec-dir--short { + color: #fca5a5; + background: rgba(239, 68, 68, 0.12); + border: 1px solid rgba(239, 68, 68, 0.25); +} + +.exec-pnl--profit { + color: #86efac; + font-weight: 600; +} + +.exec-pnl--loss { + color: #fca5a5; + font-weight: 600; +} + +.exec-pnl--flat { + color: var(--exec-text-muted); +} + +.exec-prose { + margin: 16px 0 0; + font-size: 0.78rem; + line-height: 1.65; + color: var(--exec-text-dim); +} + +.exec-prose code { + font-family: var(--exec-mono); + font-size: 0.73rem; + color: var(--exec-text-muted); + padding: 3px 7px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.08); +} +/* 表格模块 */ +.exec-table-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 14px; +} + +.exec-table-hint { + margin: 0; + font-size: 0.75rem; + color: var(--exec-text-dim); + flex: 1 1 280px; + min-width: 0; +} + +.exec-sig-export { + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; +} + +.exec-sig-export-label { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 0.78rem; + color: var(--exec-text-muted); + cursor: pointer; + user-select: none; +} + +.exec-sig-export-label input { + accent-color: #22c55e; +} + +.exec-sig-persist { + margin: 0 0 14px; + padding: 9px 14px; + font-size: 0.74rem; + line-height: 1.5; + border-radius: var(--exec-radius-sm); + border: 1px solid var(--exec-border); + background: rgba(255, 255, 255, 0.03); + color: var(--exec-text-muted); +} + +.exec-sig-persist--ok { + border-color: rgba(34, 197, 94, 0.35); + background: rgba(34, 197, 94, 0.08); + color: #86efac; +} + +.exec-sig-persist--warn { + border-color: rgba(245, 158, 11, 0.45); + background: rgba(245, 158, 11, 0.1); + color: #fcd34d; +} + +.exec-table-scroll { + overflow-x: auto; + margin: 0 -2px; + padding: 0; + border-radius: calc(var(--exec-radius-sm) + 2px); + border: 1px solid rgba(255, 255, 255, 0.07); + background: linear-gradient(180deg, rgba(8, 8, 11, 0.95) 0%, rgba(5, 5, 8, 0.98) 100%); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); +} + +.exec-table { + width: 100%; + border-collapse: collapse; + font-size: 0.83rem; +} + +.exec-table th { + text-align: left; + padding: 13px 16px; + font-size: 0.64rem; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--exec-text-dim); + border-bottom: 1px solid rgba(255, 255, 255, 0.07); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.02) 100%); + white-space: nowrap; +} + +.exec-table td { + padding: 13px 16px; + border-bottom: 1px solid var(--exec-line); + color: var(--exec-text-muted); +} + +.exec-table tbody tr { + transition: background 0.16s ease; +} + +.exec-table tbody tr:hover { + background: rgba(120, 155, 255, 0.045); +} +.exec-table tbody tr:last-child td { + border-bottom: none; +} + +.exec-table .exec-mono { + font-family: var(--exec-mono); + font-size: 0.8rem; + color: var(--exec-text); +} + +.exec-cell-status { + font-weight: 500; +} + +.exec-cell-status--ok { + color: #86efac; +} + +.exec-cell-status--skip { + color: var(--exec-text-muted); +} + +.exec-cell-status--err { + color: #fca5a5; +} + +.exec-muted { + color: var(--exec-text-dim); + text-align: center; + padding: 28px 16px !important; +} + +/* 按钮 */ +.exec-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 10px 19px; + border-radius: 11px; + font-weight: 600; + font-size: 0.8rem; + letter-spacing: 0.02em; + text-decoration: none; + cursor: pointer; + border: 1px solid rgba(255, 255, 255, 0.12); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0.03) 100%); + color: var(--exec-text); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08); + transition: + background 0.18s ease, + border-color 0.18s ease, + box-shadow 0.18s ease, + transform 0.15s ease; +} + +.exec-btn:hover { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.12) 0%, rgba(255, 255, 255, 0.05) 100%); + border-color: rgba(255, 255, 255, 0.18); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.1), + 0 8px 24px rgba(0, 0, 0, 0.35); + transform: translateY(-0.5px); +} + +.exec-btn--primary { + border: 1px solid rgba(255, 255, 255, 0.28); + background: linear-gradient(185deg, #ffffff 0%, #e4e4ea 48%, #c8c8d4 100%); + color: #0a0a0f; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.65), + 0 10px 28px rgba(0, 0, 0, 0.35); +} + +.exec-btn--primary:hover { + background: linear-gradient(185deg, #ffffff 0%, #f0f0f6 50%, #dcdce6 100%); + border-color: rgba(255, 255, 255, 0.38); +} +/* —— 登录页(模块化单卡) —— */ +.exec-login-body.exec-shell { + display: grid; + place-items: center; + padding: 24px; +} + +.exec-login-stage { + position: relative; + z-index: 1; + width: 100%; + max-width: 420px; +} + +.exec-login-card { + border-radius: calc(var(--exec-radius) + 2px); + border: 1px solid rgba(255, 255, 255, 0.08); + background: linear-gradient(165deg, rgba(22, 22, 30, 0.95) 0%, rgba(12, 12, 16, 0.98) 100%); + box-shadow: var(--exec-shadow); + overflow: hidden; +} + +.exec-login-card::before { + content: ""; + display: block; + height: 3px; + background: linear-gradient( + 90deg, + transparent, + rgba(130, 165, 255, 0.45), + rgba(255, 255, 255, 0.35), + rgba(130, 165, 255, 0.45), + transparent + ); +} +.exec-login-inner { + padding: 36px 32px 32px; +} + +.exec-login-kicker { + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.2em; + color: var(--exec-text-dim); + margin-bottom: 12px; +} + +.exec-login-title { + margin: 0 0 10px; + font-size: 1.5rem; + font-weight: 600; + letter-spacing: -0.03em; + color: var(--exec-text); +} + +.exec-login-desc { + margin: 0 0 28px; + font-size: 0.85rem; + line-height: 1.55; + color: var(--exec-text-muted); +} + +.exec-field { + margin-bottom: 18px; +} + +.exec-label { + display: block; + font-size: 0.68rem; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--exec-text-dim); + margin-bottom: 8px; +} + +.exec-input { + width: 100%; + padding: 12px 14px; + border-radius: var(--exec-radius-sm); + border: 1px solid var(--exec-border); + background: var(--exec-bg-deep); + color: var(--exec-text); + font-size: 0.95rem; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.exec-input:focus { + outline: none; + border-color: rgba(255, 255, 255, 0.22); + box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.05); +} + +.exec-login-form .exec-btn--primary { + width: 100%; + margin-top: 8px; + padding: 12px; +} + +.exec-error { + min-height: 22px; + margin-top: 16px; + font-size: 0.82rem; + color: #fca5a5; +} + +.exec-shell ::selection { + background: rgba(130, 165, 255, 0.28); + color: var(--exec-text); +} + +@media (prefers-reduced-motion: reduce) { + .exec-module, + .exec-btn, + .exec-tab { + transition: none !important; + } + + .exec-module:hover, + .exec-btn:hover { + transform: none !important; + } +} diff --git a/gate_order_executor/static/theme-matrix-terminal.css b/gate_order_executor/static/theme-matrix-terminal.css new file mode 100644 index 0000000..c4979bb --- /dev/null +++ b/gate_order_executor/static/theme-matrix-terminal.css @@ -0,0 +1,664 @@ +/** + * MATRIX 式交易终端:黑底 · 霓虹青 · 洋红点缀 · 等宽信息密度 + * 仅当 body 含 .exec-theme-matrix 时由 dashboard / login 引入 + */ +body.exec-theme-matrix.exec-shell { + font-family: "JetBrains Mono", ui-monospace, "Cascadia Code", monospace; + background: #000; + color: rgba(126, 232, 234, 0.92); + letter-spacing: 0.02em; +} + +body.exec-theme-matrix .exec-ambient { + background: + radial-gradient(ellipse 90% 55% at 50% -10%, rgba(0, 255, 234, 0.12), transparent 52%), + radial-gradient(ellipse 45% 35% at 100% 80%, rgba(255, 46, 166, 0.06), transparent 50%), + linear-gradient(180deg, #030308 0%, #000 45%, #020204 100%); +} + +body.exec-theme-matrix .exec-noise { + opacity: 0.045; +} + +body.exec-theme-matrix .exec-grid-faint { + background-size: 28px 28px; + background-image: + linear-gradient(rgba(0, 255, 234, 0.07) 1px, transparent 1px), + linear-gradient(90deg, rgba(0, 255, 234, 0.07) 1px, transparent 1px); + mask-image: radial-gradient(ellipse 75% 65% at 50% 35%, black 15%, transparent 75%); + opacity: 0.4; +} + +/* —— 顶栏:状态条 + 主标题行 —— */ +body.exec-theme-matrix .exec-header { + flex-direction: column; + align-items: stretch; + gap: 0; + min-height: auto; + padding: 0; + border-bottom: 1px solid rgba(0, 255, 234, 0.45); + background: linear-gradient(180deg, rgba(0, 8, 10, 0.97) 0%, rgba(0, 0, 0, 0.92) 100%); + box-shadow: + 0 0 32px rgba(0, 255, 234, 0.12), + 0 1px 0 rgba(255, 46, 166, 0.15) inset; + backdrop-filter: blur(12px); +} + +body.exec-theme-matrix .exec-terminal-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 10px; + padding: 8px clamp(14px, 3vw, 28px); + border-bottom: 1px solid rgba(0, 255, 234, 0.2); + background: rgba(0, 0, 0, 0.55); +} + +body.exec-theme-matrix .exec-tx-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + font-size: 0.65rem; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: rgba(126, 232, 234, 0.75); + border: 1px solid rgba(0, 255, 234, 0.35); + border-radius: 2px; + background: rgba(0, 20, 22, 0.9); + box-shadow: 0 0 12px rgba(0, 255, 234, 0.08); +} + +body.exec-theme-matrix .exec-tx-chip strong, +body.exec-theme-matrix .exec-tx-chip .exec-tx-mono { + color: #bfffff; + font-weight: 600; + letter-spacing: 0.06em; +} + +body.exec-theme-matrix .exec-tx-chip--live { + border-color: rgba(0, 255, 234, 0.55); + color: #7ee8ea; + text-shadow: 0 0 12px rgba(0, 255, 234, 0.45); +} + +body.exec-theme-matrix .exec-tx-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: #00ffe0; + box-shadow: 0 0 10px #00ffe0; + animation: mtx-pulse 1.8s ease-in-out infinite; +} + +@keyframes mtx-pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.35; + } +} + +body.exec-theme-matrix .exec-tx-chip--time .exec-tx-mono { + font-variant-numeric: tabular-nums; +} + +body.exec-theme-matrix .exec-terminal-head { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 14px 20px; + padding: 14px clamp(14px, 3vw, 28px) 16px; +} + +body.exec-theme-matrix .exec-terminal-logo { + flex-shrink: 0; + width: 44px; + height: 44px; + display: grid; + place-items: center; + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.1em; + color: #000; + background: linear-gradient(145deg, #00ffe0 0%, #00b8a8 100%); + border: 1px solid rgba(0, 255, 234, 0.8); + border-radius: 2px; + box-shadow: 0 0 20px rgba(0, 255, 234, 0.35); +} + +body.exec-theme-matrix .exec-terminal-center { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 10px; +} + +body.exec-theme-matrix .exec-title-terminal { + margin: 0; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 10px 14px; + font-family: "Share Tech Mono", "JetBrains Mono", ui-monospace, monospace; + font-size: clamp(1.35rem, 4.2vw, 2.15rem); + font-weight: 400; + letter-spacing: 0.14em; + text-transform: uppercase; + line-height: 1.1; + color: #9ff; + text-shadow: + 0 0 20px rgba(0, 255, 234, 0.55), + 0 0 40px rgba(0, 255, 234, 0.2); +} + +body.exec-theme-matrix .exec-tt-part { + white-space: nowrap; +} + +body.exec-theme-matrix .exec-title-slash { + margin: 0 0.02em; + opacity: 0.55; + font-weight: 400; + letter-spacing: 0.2em; +} + +/* 雷达装饰 */ +body.exec-theme-matrix .exec-radar { + position: relative; + width: 46px; + height: 46px; + flex-shrink: 0; +} + +body.exec-theme-matrix .exec-radar__ring { + position: absolute; + inset: 0; + border-radius: 50%; + border: 1px solid rgba(0, 255, 234, 0.5); + box-shadow: + 0 0 14px rgba(0, 255, 234, 0.25), + inset 0 0 18px rgba(0, 255, 234, 0.06); +} + +body.exec-theme-matrix .exec-radar__sweep { + position: absolute; + inset: 3px; + border-radius: 50%; + background: conic-gradient(from -36deg, transparent 0deg, rgba(0, 255, 234, 0.22) 52deg, transparent 52deg); + animation: mtx-radar 2.8s linear infinite; +} + +@keyframes mtx-radar { + to { + transform: rotate(360deg); + } +} + +body.exec-theme-matrix .exec-radar__blip { + position: absolute; + width: 7px; + height: 7px; + top: 32%; + right: 18%; + border-radius: 50%; + background: #ff2ea6; + box-shadow: 0 0 12px #ff2ea6; +} + +body.exec-theme-matrix .exec-tagline-terminal { + margin: 0; + display: inline-block; + max-width: min(100%, 720px); + padding: 6px 14px; + font-size: 0.62rem; + font-weight: 500; + letter-spacing: 0.16em; + text-transform: uppercase; + color: rgba(255, 180, 220, 0.95); + border: 1px solid rgba(255, 46, 166, 0.55); + border-radius: 2px; + background: rgba(40, 0, 28, 0.45); + box-shadow: 0 0 18px rgba(255, 46, 166, 0.12); +} + +body.exec-theme-matrix .exec-terminal-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +/* —— Tab —— */ +body.exec-theme-matrix .exec-tabs { + gap: 4px; + padding: 5px 6px; + margin-bottom: 18px; + border-radius: 2px; + border: 1px solid rgba(0, 255, 234, 0.28); + background: rgba(0, 12, 14, 0.75); + box-shadow: inset 0 0 24px rgba(0, 255, 234, 0.04); +} + +body.exec-theme-matrix .exec-tab { + border-radius: 2px; + padding: 9px 14px; + font-size: 0.72rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: rgba(126, 232, 234, 0.55); +} + +body.exec-theme-matrix .exec-tab:hover { + color: #bfffff; + background: rgba(0, 255, 234, 0.08); +} + +body.exec-theme-matrix .exec-tab--active { + color: #000; + background: linear-gradient(180deg, #00ffe0 0%, #00c9b0 100%); + border-color: rgba(0, 255, 234, 0.7); + box-shadow: 0 0 22px rgba(0, 255, 234, 0.35); +} + +body.exec-theme-matrix .exec-tab:focus-visible { + outline: 2px solid #ff2ea6; + outline-offset: 2px; +} + +/* —— 分区标签 —— */ +body.exec-theme-matrix .exec-section-label { + letter-spacing: 0.22em; + color: rgba(0, 255, 234, 0.45); + text-shadow: 0 0 8px rgba(0, 255, 234, 0.25); +} + +/* —— 卡片 —— */ +body.exec-theme-matrix .exec-module { + border-radius: 2px; + border: 1px solid rgba(0, 255, 234, 0.38); + background: linear-gradient(165deg, rgba(0, 18, 20, 0.92) 0%, rgba(0, 0, 0, 0.94) 100%); + box-shadow: + 0 0 0 1px rgba(0, 0, 0, 0.5), + 0 0 28px rgba(0, 255, 234, 0.06); +} + +body.exec-theme-matrix .exec-module:hover { + border-color: rgba(0, 255, 234, 0.55); + box-shadow: + 0 0 0 1px rgba(255, 46, 166, 0.12), + 0 0 36px rgba(0, 255, 234, 0.12); + transform: none; +} + +body.exec-theme-matrix .exec-module-head { + padding: 12px 16px; + border-bottom: 1px solid rgba(0, 255, 234, 0.22); + background: linear-gradient(90deg, rgba(0, 255, 234, 0.08) 0%, transparent 55%); +} + +body.exec-theme-matrix .exec-module-title { + font-size: 0.78rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: #bfffff; +} + +body.exec-theme-matrix .exec-module-meta { + font-size: 0.58rem; + letter-spacing: 0.12em; + color: rgba(126, 232, 234, 0.45); +} + +body.exec-theme-matrix .exec-module-body { + padding: 14px 16px 16px; +} + +/* —— 指标格 —— */ +body.exec-theme-matrix .exec-metric { + border-radius: 2px; + border: 1px solid rgba(0, 255, 234, 0.22); + background: rgba(0, 0, 0, 0.45); + box-shadow: inset 0 0 20px rgba(0, 255, 234, 0.03); +} + +body.exec-theme-matrix .exec-metric-label { + font-size: 0.58rem; + letter-spacing: 0.14em; + color: rgba(126, 232, 234, 0.5); +} + +body.exec-theme-matrix .exec-metric-value { + font-size: 0.84rem; + color: #dff; +} + +body.exec-theme-matrix .exec-metric-value--mono { + font-size: 0.78rem; + color: rgba(191, 255, 255, 0.88); +} + +/* —— 芯片 / 方向 / 盈亏色(保持语义,套霓虹) —— */ +body.exec-theme-matrix .exec-chip--neutral { + border-color: rgba(0, 255, 234, 0.25); + color: rgba(126, 232, 234, 0.75); + background: rgba(0, 0, 0, 0.5); +} + +body.exec-theme-matrix .exec-chip--ok { + color: #5fffd0; + border-color: rgba(0, 255, 200, 0.45); + background: rgba(0, 40, 32, 0.5); + text-shadow: 0 0 10px rgba(0, 255, 200, 0.35); +} + +body.exec-theme-matrix .exec-chip--warn { + color: #ffe066; + border-color: rgba(255, 200, 80, 0.45); + background: rgba(40, 28, 0, 0.45); +} + +body.exec-theme-matrix .exec-chip--live { + color: #ff7ab8; + border-color: rgba(255, 46, 166, 0.5); + background: rgba(36, 0, 24, 0.45); + text-shadow: 0 0 10px rgba(255, 46, 166, 0.35); +} + +body.exec-theme-matrix .exec-dir--long { + color: #5fffd0; + border-color: rgba(0, 255, 200, 0.45); + background: rgba(0, 32, 28, 0.55); + text-shadow: 0 0 8px rgba(0, 255, 200, 0.3); +} + +body.exec-theme-matrix .exec-dir--short { + color: #ff7ab8; + border-color: rgba(255, 46, 166, 0.45); + background: rgba(36, 0, 22, 0.55); + text-shadow: 0 0 8px rgba(255, 46, 166, 0.3); +} + +body.exec-theme-matrix .exec-pnl--profit { + color: #5fffd0; + text-shadow: 0 0 12px rgba(0, 255, 200, 0.35); +} + +body.exec-theme-matrix .exec-pnl--loss { + color: #ff6eb0; + text-shadow: 0 0 12px rgba(255, 46, 166, 0.35); +} + +body.exec-theme-matrix .exec-pnl--flat { + color: rgba(126, 232, 234, 0.55); +} + +/* —— 表格 —— */ +body.exec-theme-matrix .exec-table-scroll { + border-radius: 2px; + border: 1px solid rgba(0, 255, 234, 0.28); + background: rgba(0, 0, 0, 0.55); + box-shadow: inset 0 0 32px rgba(0, 255, 234, 0.04); +} + +body.exec-theme-matrix .exec-table th { + font-size: 0.58rem; + letter-spacing: 0.14em; + color: rgba(0, 255, 234, 0.55); + border-bottom: 1px solid rgba(0, 255, 234, 0.25); + background: rgba(0, 20, 22, 0.85); +} + +body.exec-theme-matrix .exec-table td { + color: rgba(191, 255, 255, 0.78); + border-bottom: 1px solid rgba(0, 255, 234, 0.08); +} + +body.exec-theme-matrix .exec-table tbody tr:hover { + background: rgba(0, 255, 234, 0.06); +} + +body.exec-theme-matrix .exec-table .exec-mono { + color: #dff; +} + +body.exec-theme-matrix .exec-muted { + color: rgba(126, 232, 234, 0.35); +} + +body.exec-theme-matrix .exec-cell-status--ok { + color: #5fffd0; + text-shadow: 0 0 8px rgba(0, 255, 200, 0.3); +} + +body.exec-theme-matrix .exec-cell-status--skip { + color: rgba(126, 232, 234, 0.45); +} + +body.exec-theme-matrix .exec-cell-status--err { + color: #ff6eb0; + text-shadow: 0 0 8px rgba(255, 46, 166, 0.35); +} + +/* —— 按钮 —— */ +body.exec-theme-matrix .exec-btn { + border-radius: 2px; + border: 1px solid rgba(0, 255, 234, 0.45); + background: rgba(0, 24, 26, 0.85); + color: #bfffff; + box-shadow: 0 0 14px rgba(0, 255, 234, 0.12); + text-transform: uppercase; + letter-spacing: 0.1em; + font-size: 0.68rem; +} + +body.exec-theme-matrix .exec-btn:hover { + border-color: rgba(255, 46, 166, 0.55); + color: #ffd0ec; + background: rgba(32, 0, 22, 0.75); + box-shadow: 0 0 20px rgba(255, 46, 166, 0.2); + transform: none; +} + +body.exec-theme-matrix .exec-btn--primary { + border-color: rgba(255, 46, 166, 0.55); + background: linear-gradient(180deg, #ff5ec8 0%, #c21e6e 100%); + color: #fff; + text-shadow: 0 0 12px rgba(0, 0, 0, 0.5); + box-shadow: 0 0 24px rgba(255, 46, 166, 0.25); +} + +body.exec-theme-matrix .exec-btn--primary:hover { + background: linear-gradient(180deg, #ff7ad4 0%, #d02878 100%); + border-color: rgba(255, 46, 166, 0.75); +} + +body.exec-theme-matrix .exec-btn--sm { + border-radius: 2px; + font-size: 0.62rem; + padding: 4px 9px; +} + +/* —— 说明文字 —— */ +body.exec-theme-matrix .exec-prose { + color: rgba(126, 232, 234, 0.5); +} + +body.exec-theme-matrix .exec-prose code { + border-color: rgba(0, 255, 234, 0.25); + background: rgba(0, 0, 0, 0.5); + color: rgba(191, 255, 255, 0.85); +} + +body.exec-theme-matrix .exec-table-hint { + color: rgba(126, 232, 234, 0.45); + line-height: 1.55; +} + +body.exec-theme-matrix .exec-table-hint code { + font-family: inherit; + color: #7ee8ea; + border: 1px solid rgba(0, 255, 234, 0.3); + padding: 1px 5px; + border-radius: 2px; + background: rgba(0, 0, 0, 0.4); +} + +body.exec-theme-matrix .exec-sig-export-label { + color: rgba(126, 232, 234, 0.65); +} + +body.exec-theme-matrix .exec-sig-export-label input { + accent-color: #ff2ea6; +} + +/* —— 弹窗 —— */ +body.exec-theme-matrix .exec-modal__backdrop { + background: rgba(0, 0, 0, 0.82); +} + +body.exec-theme-matrix .exec-modal__card { + border-radius: 2px; + border: 1px solid rgba(0, 255, 234, 0.4); + background: linear-gradient(180deg, rgba(0, 22, 24, 0.98) 0%, rgba(0, 0, 0, 0.98) 100%); + box-shadow: 0 0 48px rgba(0, 255, 234, 0.15); +} + +body.exec-theme-matrix .exec-modal__title { + color: #bfffff; + letter-spacing: 0.12em; + text-transform: uppercase; + font-size: 0.82rem; +} + +/* —— 滚动条 —— */ +body.exec-theme-matrix .exec-tab-panel { + scrollbar-color: rgba(0, 255, 234, 0.25) transparent; +} + +body.exec-theme-matrix .exec-tab-panel::-webkit-scrollbar-thumb { + background: rgba(0, 255, 234, 0.2); +} + +body.exec-theme-matrix .exec-shell ::selection { + background: rgba(255, 46, 166, 0.35); + color: #fff; +} + +/* —— 登录页 —— */ +body.exec-theme-matrix.exec-login-body .exec-mark { + display: grid !important; + width: 44px; + height: 44px; + place-items: center; + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.1em; + color: #000; + background: linear-gradient(145deg, #00ffe0 0%, #00b8a8 100%); + border: 1px solid rgba(0, 255, 234, 0.8); + border-radius: 2px; + box-shadow: 0 0 20px rgba(0, 255, 234, 0.35); +} + +body.exec-theme-matrix.exec-login-body .exec-login-card { + border-radius: 2px; + border: 1px solid rgba(0, 255, 234, 0.4); + background: linear-gradient(165deg, rgba(0, 18, 20, 0.95) 0%, rgba(0, 0, 0, 0.97) 100%); + box-shadow: 0 0 40px rgba(0, 255, 234, 0.12); +} + +body.exec-theme-matrix .exec-login-card::before { + height: 3px; + background: linear-gradient(90deg, transparent, #00ffe0, #ff2ea6, #00ffe0, transparent); +} + +body.exec-theme-matrix .exec-login-kicker { + color: rgba(0, 255, 234, 0.55); + letter-spacing: 0.24em; +} + +body.exec-theme-matrix .exec-login-title { + font-family: "Share Tech Mono", "JetBrains Mono", monospace; + color: #9ff; + text-shadow: 0 0 20px rgba(0, 255, 234, 0.45); + letter-spacing: 0.12em; + text-transform: uppercase; +} + +body.exec-theme-matrix .exec-login-desc { + color: rgba(126, 232, 234, 0.55); +} + +body.exec-theme-matrix .exec-label { + color: rgba(0, 255, 234, 0.45); +} + +body.exec-theme-matrix .exec-input { + border-radius: 2px; + border: 1px solid rgba(0, 255, 234, 0.35); + background: #000; + color: #dff; + font-family: inherit; +} + +body.exec-theme-matrix .exec-input:focus { + border-color: rgba(255, 46, 166, 0.55); + box-shadow: 0 0 0 2px rgba(255, 46, 166, 0.15); +} + +body.exec-theme-matrix .exec-error { + color: #ff8ec4; + text-shadow: 0 0 10px rgba(255, 46, 166, 0.3); +} + +body.exec-theme-matrix .exec-sig-persist { + border-radius: 2px; + border-color: rgba(0, 255, 234, 0.25); + background: rgba(0, 0, 0, 0.45); + color: rgba(191, 255, 255, 0.75); +} + +body.exec-theme-matrix .exec-sig-persist--ok { + border-color: rgba(0, 255, 200, 0.45); + background: rgba(0, 32, 28, 0.55); + color: #5fffd0; + text-shadow: 0 0 10px rgba(0, 255, 200, 0.2); +} + +body.exec-theme-matrix .exec-sig-persist--warn { + border-color: rgba(255, 46, 166, 0.5); + background: rgba(40, 0, 28, 0.5); + color: #ffb8e0; +} + +@media (prefers-reduced-motion: reduce) { + body.exec-theme-matrix .exec-radar__sweep, + body.exec-theme-matrix .exec-tx-dot { + animation: none !important; + } +} + +@media (max-width: 720px) { + body.exec-theme-matrix .exec-terminal-head { + flex-direction: column; + align-items: stretch; + } + + body.exec-theme-matrix .exec-terminal-actions { + justify-content: flex-end; + } + + body.exec-theme-matrix .exec-title-terminal { + font-size: clamp(1.05rem, 6vw, 1.65rem); + } +} diff --git a/gate_order_executor/templates/dashboard.html b/gate_order_executor/templates/dashboard.html new file mode 100644 index 0000000..a510027 --- /dev/null +++ b/gate_order_executor/templates/dashboard.html @@ -0,0 +1,463 @@ + + + + + + + + + GATE // EXECUTOR + + + + + + + + + + + + + +
+
+ LINK ONLINE + CYCLE OK + CHANNEL + SYNC --:--:-- +
+
+ +
+

+ GATE + // + + // + EXECUTOR +

+

Gate USDT PERP · SCHEME-A · MATRIX SCAN ISOLATED

+
+
+ OP {{ username }} + LOGOUT +
+
+
+ +
+ +
+
+ +
+ +
+
+

运行态

+ Runtime +
+
+
+
+ 模式 +
+
+
+ Gate 密钥 +
+
+
+ 占位仓位 + +
+
+ 单笔风险 / 方案 + · +
+
+
+
+ + +
+
+

网络与代理

+ Egress +
+
+
+
+ 配置启用 +
+
+
+ 实际走代理 +
+
+
+
+ 代理地址 + +
+

onchain_scout_gateproxy 配置一致;访问 Gate 私有 API 时使用 httpx_client_kwargsapp/proxy_util.py)。

+
+
+ + +
+
+

风险参数

+ Risk +
+
+
+ 以损订仓(目标) + +
+
+ 最大同时标的 + +
+

gate.dry_run: false 且配置 API 密钥时,按上述参数与止损距离计算张数并下市价单 + 计划止盈/止损;dry_run: true 时仅校验占位与日志。

+
+
+
+ + +
+
+

合约账户

+ 与 Overview 同步刷新(联调请用 curl,见部署/使用说明) +
+
+
+
+ 权益 total + +
+
+ 可用 available + +
+
+ 未实现盈亏 + +
+
+ 币种 + +
+
+ +
+
+ +
+
+ +
+
+

当前持仓(Gate)

+ 与计划委托同一轮询 · 便于对账 +
+
+
+ + + +
+ + +
+ + + + + + + + + + + + + + + + + + +
合约张数开仓价标记价未实现盈亏杠杆open 计划单移动保本保本状态操作
加载中…
+
+
+
+ + + + +
+
+

计划委托(price_orders)

+ 与概览同步轮询 · 可手动撤单 +
+
+

+ 与上方持仓表按合约对账:有仓但「open 计划单」为 0 时多为止盈止损已触发/已撤或 OCO 清理延迟;无仓但有计划单时可能为挂单待触发或他端下的条件单。 +

+ +
+ + + + + + + + + + + + + + + + +
合约状态触发价rule张数类型创建时间操作
加载中…
+
+
+
+ +
+
+ +
+
+

成交与委托(Gate 官方接口)

+ 查询 / 下载 CSV · 与信号流无关 +
+
+

+ 数据以 Gate 合约私有 API 为准:my_trades_timerange(成交)、orders(委托)。 + 未填「from / to」时按服务端默认最近 7 天(Unix 秒)。导出最多 5000 行(分页拼接)。 +

+
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + + + + + + + + + + + + + +
时间合约张数价格手续费角色trade_id
点击「查询成交」
+
+ +
+ + + + + + + + + + + + + + +
创建合约状态张数价 / 成交价id
点击「查询委托」
+
+
+
+ +
+
+ +
+
+

正式统计

+ GET /api/stats/summary · 手动刷新 +
+
+

+ 口径:上海时区、统计日 [D 08:00, D+1 08:00)、自然周 周一至周日、自然月 [1日08:00, 次月1日08:00);数据来自 Gate GET /futures/<settle>/position_close(与 App「历史仓位」同类),仅统计 stats.official_start 之后平仓时间且能解析 pnl 的记录。详见 docs/使用说明.md §3.6。 +

+
+
+ + +
+ +
+ + +

尚未加载;请点击「刷新统计」(会请求 Gate 分页拉成交,请勿频繁点击)。

+ +
+
+
+
+ +
+
+

信号流

+ POST /v1/signal +
+
+
+

每次 POST /v1/signal 的处理结果写入本地 SQLite,本页「信号流」从该库读取最近记录,进程重启后仍在(与当前是否持仓无关)。止盈/止损展示价为按合约 tick 对齐;现价优先 reference_price,否则取推送时行情 last。

+
+ + +
+
+ + + + +
+
+

+
+ + + + + + + + + + + + + + + + +
时间合约方向现价止盈止损盈亏比结果
加载中…
+
+
+
+
+
+
+ + + diff --git a/gate_order_executor/templates/login.html b/gate_order_executor/templates/login.html new file mode 100644 index 0000000..0ab09ce --- /dev/null +++ b/gate_order_executor/templates/login.html @@ -0,0 +1,104 @@ + + + + + + + + GATE // EXECUTOR · 接入 + + + + + + + + + + + + + + + + + diff --git a/gate_order_executor/tests/test_breakeven_logic.py b/gate_order_executor/tests/test_breakeven_logic.py new file mode 100644 index 0000000..4b0eba5 --- /dev/null +++ b/gate_order_executor/tests/test_breakeven_logic.py @@ -0,0 +1,57 @@ +"""移动保本逻辑单元测试。""" +from __future__ import annotations + +import unittest + +from app.breakeven_logic import ( + breakeven_sl_price, + find_sl_plan, + is_1r_reached, + risk_distance, + sl_already_at_or_better, +) + + +class TestBreakevenLogic(unittest.TestCase): + def test_risk_distance_long(self) -> None: + self.assertAlmostEqual(risk_distance("long", 100.0, 95.0), 5.0) + + def test_is_1r_long(self) -> None: + self.assertFalse(is_1r_reached("long", 104.0, 100.0, 95.0, trigger_r=1.0)) + self.assertTrue(is_1r_reached("long", 105.0, 100.0, 95.0, trigger_r=1.0)) + + def test_is_1r_short(self) -> None: + self.assertFalse(is_1r_reached("short", 96.0, 100.0, 105.0, trigger_r=1.0)) + self.assertTrue(is_1r_reached("short", 95.0, 100.0, 105.0, trigger_r=1.0)) + + def test_breakeven_sl_price(self) -> None: + self.assertAlmostEqual(breakeven_sl_price("long", 1000.0, 0.002), 1002.0) + self.assertAlmostEqual(breakeven_sl_price("short", 1000.0, 0.002), 998.0) + + def test_sl_already_at_or_better(self) -> None: + self.assertTrue(sl_already_at_or_better("long", 1003.0, 1002.0)) + self.assertFalse(sl_already_at_or_better("long", 1001.0, 1002.0)) + self.assertTrue(sl_already_at_or_better("short", 997.0, 998.0)) + + def test_find_sl_plan_long(self) -> None: + plans = [ + { + "contract": "BTC_USDT", + "order_id": "tp1", + "rule": 1, + "trigger_price": "110000", + }, + { + "contract": "BTC_USDT", + "order_id": "sl1", + "rule": 2, + "trigger_price": "95000", + }, + ] + oid, px = find_sl_plan("long", "BTC_USDT", plans) + self.assertEqual(oid, "sl1") + self.assertAlmostEqual(px or 0, 95000.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/gate_order_executor/tests/test_price_rounding.py b/gate_order_executor/tests/test_price_rounding.py new file mode 100644 index 0000000..38b71be --- /dev/null +++ b/gate_order_executor/tests/test_price_rounding.py @@ -0,0 +1,58 @@ +"""离线测试:TP/SL 触发价按 Gate 合约 tick 对齐(无需 API 密钥)。""" +from __future__ import annotations + +import sys +from decimal import Decimal +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from app.gate_price_rounding import ( # noqa: E402 + _format_trigger_price, + _trigger_price_tick, +) + + +def test_format_xaut_like_float_garbage() -> None: + assert _format_trigger_price(4752.700000000001, Decimal("0.1")) == "4752.7" + assert _format_trigger_price(4691.7976, Decimal("0.1")) == "4691.8" + + +def test_format_half_tick() -> None: + assert _format_trigger_price(100.25, Decimal("0.5")) == "100.5" + # 100.24 落在 0.5 网格上为 100.0,去尾零后为 "100" + assert _format_trigger_price(100.24, Decimal("0.5")) == "100" + + +def test_format_small_coin() -> None: + tick = Decimal("0.00001") + assert _format_trigger_price(0.000123456, tick) == "0.00012" + assert _format_trigger_price(1.23456789e-5, tick) == "0.00001" + + +def test_trigger_price_tick_from_cdata() -> None: + assert _trigger_price_tick({"order_price_round": "0.1"}) == Decimal("0.1") + assert _trigger_price_tick({"order_price_round": "", "mark_price_round": "0.0001"}) == Decimal("0.0001") + assert _trigger_price_tick({"mark_price_round": "0.01"}) == Decimal("0.01") + assert _trigger_price_tick({}) is None + + +def test_fallback_no_tick_coarse() -> None: + s = _format_trigger_price(4752.700000000001, None) + assert "00000000000" not in s + assert s.replace(".", "").isdigit() or s.replace(".", "", 1).replace("-", "", 1).isdigit() + + +def main() -> None: + test_format_xaut_like_float_garbage() + test_format_half_tick() + test_format_small_coin() + test_trigger_price_tick_from_cdata() + test_fallback_no_tick_coarse() + print("test_price_rounding: all passed") + + +if __name__ == "__main__": + main() diff --git a/onchain_scout_gate/README.md b/onchain_scout_gate/README.md new file mode 100644 index 0000000..f47c1e0 --- /dev/null +++ b/onchain_scout_gate/README.md @@ -0,0 +1,111 @@ +# Gate.io USDT Perpetual Monitor (Exchange-Only) + +> 仓库总览与 Git 克隆见上级目录:[../README.md](../README.md)、[../CLONE.md](../CLONE.md)。 + +Python service for 7x24 monitoring of **Gate.io USDT-settled linear perpetual futures** using **public REST v4** market data (no on-chain dependency). + +## Policy(主线) + +- Only **live Gate USDT linear perpetual** contracts (`BASE_USDT`, non-delisting) are eligible. +- **`monitor.universe: all_swaps` (default)**:自动扫 Gate 上架中的线性合约,用 `min_24h_quote_volume_usdt` 按 24h **计价货币(USDT)成交额**过滤(默认 **≥ 1 百万 USDT**,可在 `config.yaml` 调整);**不要**混用 `watch_symbols`。成交额优先读 ticker 的 `volume_24h_quote`。 +- **`monitor.universe: watchlist`**:只监控 `watch_symbols` 里列出的 base 合约(须为 Gate 上架 USDT 线性)。 +- `all_swaps` 模式下 **`min_24h_quote_volume_usdt` 必须大于 0**,否则会扫全市场(极低流动性)。 +- 监控主周期 **5m**,规则与触发逻辑见 `app/exchange_rules.py`。 + +### 附加(非产品主线说明) + +- **`monitor.btc_daily_gate_enabled`(默认关闭)**:可选的 **BTC 日线横盘过滤**——在判定为日线横盘 regime 下叠加 **K 线形态**等附加条件;实验性/非必选风控,**不作为对外产品主线说明**。实现见 `app/btc_regime.py`,可在 `config.yaml` 关闭。 + +### 自动下单(gate_order_executor) + +- **下单执行器**:在 Web 面板 **「下单执行器 · 转发链」** 维护列表(`runtime/order_executors.json`),支持运行中增删;首次启动可从 `config.yaml` 的 `order_executor` 导入一条。 +- 仅在 **企业微信突破推送成功之后**,向列表中已启用的执行器 **广播** 同一 `POST /v1/signal`;价位与企微 **方案 A** 一致。详见 [`docs/多执行器与信号转发归档.md`](docs/多执行器与信号转发归档.md)。 +- 该 HTTP 请求 **不走** `proxy.url`,便于同机访问执行器。 + +全市场模式下扫描量较大,建议把 `poll_interval_seconds` 调到 **300 秒或更长**,并遵守 Gate 公开频率限制。 + +## Web Panel + +- Login required for all `/api/*` and dashboard (unless `auth.enabled: false`). +- **MATRIX 主视图**:表格列为 **Gemma 漏斗评分**(`/api/funnel`),按 `composite_score` 排序。 +- **K 线周期**:监控主周期 **5m**;其它周期沿用内部命名(如 `4H`、`1D`),在 `app/gate.py` 映射为 Gate 的 `interval`。 + +## Gemma 漏斗(本地 Ollama) + +1. 安装并启动 [Ollama](https://ollama.com),`ollama pull` 你的 Gemma 模型(如 `gemma2:2b`);多模态需带 vision 的 tag 才能送 K 线图。 +2. 在 `config.yaml` 中 `gemma.enabled: true`,按需改 `model`、`ollama_base_url`。 +3. 每个 **5m 扫描** 产生 `WATCH` / `TRIGGER` 后,按 **24h 成交额** 与 `max_funnel_per_cycle` 上限,取 **日线 OHLCV** + 可选图表;按 `vision_top_n` 张 **matplotlib 生成的 PNG 截图**(base64)给 Ollama(纯文本模型设 `send_chart_image: false`)。 +4. 模型必须返回 JSON 结构(见 `app/gemma_client.py`)。合并 `composite_score` 后写入 `alerts`(`details.source === gemma_funnel`);若 `priority >= gemma_push_priority_min` **或** `composite_score >= composite_push_min`,则 **企业微信** 推送一条「MATRIX · 漏斗优先」提醒。 +5. 若 Ollama 对 `format: json` 不稳,可将 `gemma.json_mode` 设为 `false`。 + +## Project Layout + +```text +onchain_scout_gate/ + app/ + main.py + web.py + monitor.py + gate.py + exchange_rules.py + btc_regime.py + daily_features.py + chart_candles.py + gemma_client.py + notifier.py + order_executor_forward.py + storage.py + models.py + config.py + templates/ + static/ + deploy/ + ecosystem.config.cjs # PM2: python -m app.main + onchain-scout.service # 可选:systemd + pm2-runtime + config.example.yaml + requirements.txt +``` + +## 本地 / 服务器部署(PM2,推荐) + +1. Python **3.10+**,`python -m venv .venv`,`pip install -r requirements.txt`。 +2. 复制 `config.example.yaml` → `config.yaml`,填写 `auth`、`wecom`、`monitor` 等。 +3. 安装 [Node.js](https://nodejs.org/) 后:`npm install -g pm2`。 +4. 在项目根:**`pm2 start deploy/ecosystem.config.cjs`** +5. 常用:`pm2 logs onchain-scout`、`pm2 restart onchain-scout`、`pm2 save`(配合 `pm2 startup` 开机)。 + +监听地址与端口以 **`config.yaml`** 的 `app.host` / `app.port`(与 `app.main` 起 uvicorn 一致)。 + +## SOCKS5 代理 + +- 代理写在 **`config.yaml` 的 `proxy`**(`enabled` / `url`),仅用于访问 **Gate** 的 httpx;**当前实现**里企业微信等为直连 `proxy_url=None`;**本地 Ollama 不走代理**。 +- 详见 **[docs/本地部署-SOCKS5代理.md](docs/本地部署-SOCKS5代理.md)**。 + +## Linux Quick Start(venv + PM2) + +```bash +cd /opt/onchain_scout_gate +chmod +x deploy/bootstrap.sh +./deploy/bootstrap.sh /opt/onchain_scout_gate +# 编辑 config.yaml +source .venv/bin/activate +npm install -g pm2 +pm2 start deploy/ecosystem.config.cjs +``` + +也可用当前脚本快速试:**`deploy/start.sh`**(uvicorn,非 PM2)。 + +## systemd(可选) + +长期用 systemd 托管 **PM2**(进程用 `pm2-runtime`),可参考 `deploy/onchain-scout.service`(需已安装 `pm2`,并按环境改 `WorkingDirectory` / `ExecStart`)。 + +## Config Keys + +- `gate.api_base`:默认 `https://api.gateio.ws/api/v4`;亦可按官方文档使用 `https://fx-api.gateio.ws/api/v4`。 +- `gate.settle`:USDT 线性填 `usdt`。 +- `gate.quote_currency`:计价货币,默认 `USDT`。 +- `monitor.universe`: `all_swaps` | `watchlist`. +- `watch_symbols`: 仅在 `watchlist` 模式下使用。 +- `monitor.min_24h_quote_volume_usdt`: 24h 成交额(USDT)下限;`all_swaps` 须填 `>0`。默认 **1 百万**。 +- `monitor.btc_daily_gate_enabled` / `btc_sideways_*`: **可选** BTC 日线横盘过滤(**非主线说明**);默认关闭,见 `config.yaml`。 +- `gemma.*`、`proxy.*`、`app.poll_interval_seconds`: 见 `config.example.yaml`。 diff --git a/onchain_scout_gate/app/__init__.py b/onchain_scout_gate/app/__init__.py new file mode 100644 index 0000000..c5c2203 --- /dev/null +++ b/onchain_scout_gate/app/__init__.py @@ -0,0 +1,2 @@ +"""On-chain first-mover monitoring system package.""" + diff --git a/onchain_scout_gate/app/btc_regime.py b/onchain_scout_gate/app/btc_regime.py new file mode 100644 index 0000000..ecc95fc --- /dev/null +++ b/onchain_scout_gate/app/btc_regime.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from statistics import mean + + +@dataclass +class BtcDailyGateResult: + """BTC 日线辅助门控(非产品主线):下跌不扫山寨,其它仍扫 —— 仅 downtrend 时关闭本轮 alt K 线请求。""" + + allow_alt_scan: bool + regime: str # sideways | downtrend | neutral_or_up | unknown + reason: str + metrics: dict = field(default_factory=dict) + + +def _rows_to_hlc(rows: list[list[str]]) -> tuple[list[float], list[float], list[float]]: + """与行情 K 线行对齐:h, l, c(ts,o,h,l,c,...)。""" + h, l_, c = [], [], [] + for item in rows: + if len(item) < 6: + continue + h.append(float(item[2])) + l_.append(float(item[3])) + c.append(float(item[4])) + return h, l_, c + + +def evaluate_btc_daily_gate( + btc_1d_rows: list[list[str]], + *, + sideways_lookback_days: int = 14, + sideways_max_range_pct: float = 10.0, + min_bars: int = 30, +) -> BtcDailyGateResult: + """ + 原则:下跌不扫,其它都扫。 + + - 下跌(唯一不扫):非横盘,且收盘低于近 20 日收盘均线,且该均线相对前一段走低。 + - 其余(横盘、上涨、宽幅震荡、数据不足 unknown 等):一律允许扫山寨。 + """ + ah, al, ac = _rows_to_hlc(btc_1d_rows) + + if len(ac) < min_bars: + return BtcDailyGateResult( + allow_alt_scan=True, + regime="unknown", + reason=f"insufficient_1d_bars have={len(ac)} need>={min_bars}, gate skipped", + metrics={"have": len(ac), "min_bars": min_bars}, + ) + + lb = max(5, min(sideways_lookback_days, len(ah) - 1)) + window_h = ah[-lb:] + window_l = al[-lb:] + range_high = max(window_h) + range_low = min(window_l) + mid = (range_high + range_low) / 2 if range_high > range_low else 0.0 + range_pct = ((range_high - range_low) / mid) * 100 if mid > 0 else 999.0 + + sma_curr = mean(ac[-20:]) + sma_prev = mean(ac[-26:-6]) if len(ac) >= 26 else sma_curr + + last_close = ac[-1] + is_sideways = range_pct <= sideways_max_range_pct + + if is_sideways: + return BtcDailyGateResult( + allow_alt_scan=True, + regime="sideways", + reason="btc_daily_sideways", + metrics={ + "range_lookback_days": lb, + "range_pct": round(range_pct, 4), + "sideways_max_range_pct": sideways_max_range_pct, + "last_close": last_close, + "sma20": round(sma_curr, 6), + "sma20_prev_block": round(sma_prev, 6), + }, + ) + + is_downtrend = last_close < sma_curr and sma_curr < sma_prev + if is_downtrend: + return BtcDailyGateResult( + allow_alt_scan=False, + regime="downtrend", + reason="btc_daily_downtrend_below_falling_sma20", + metrics={ + "range_lookback_days": lb, + "range_pct": round(range_pct, 4), + "sideways_max_range_pct": sideways_max_range_pct, + "last_close": last_close, + "sma20": round(sma_curr, 6), + "sma20_prev_block": round(sma_prev, 6), + }, + ) + + return BtcDailyGateResult( + allow_alt_scan=True, + regime="neutral_or_up", + reason="btc_not_sideways_not_downtrend_gate_open", + metrics={ + "range_lookback_days": lb, + "range_pct": round(range_pct, 4), + "last_close": last_close, + "sma20": round(sma_curr, 6), + "sma20_prev_block": round(sma_prev, 6), + }, + ) diff --git a/onchain_scout_gate/app/chart_candles.py b/onchain_scout_gate/app/chart_candles.py new file mode 100644 index 0000000..b57bfd4 --- /dev/null +++ b/onchain_scout_gate/app/chart_candles.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import base64 +import io +import logging + +LOGGER = logging.getLogger("onchain_scout.chart_candles") + + +def daily_candles_png_base64(rows_1d: list[list[str]], symbol: str, max_bars: int = 48) -> str | None: + """ + 生成简易日线蜡烛图 PNG(base64,无 data URL 前缀),供 Ollama 多模态。 + 若 matplotlib 不可用或失败则返回 None。 + """ + try: + import matplotlib + + matplotlib.use("Agg") + import matplotlib.pyplot as plt + from matplotlib.patches import Rectangle + except ImportError: + LOGGER.warning("matplotlib not installed, skip chart image") + return None + + o, h, l, c, _ = [], [], [], [], [] + for item in rows_1d: + if len(item) < 6: + continue + o.append(float(item[1])) + h.append(float(item[2])) + l.append(float(item[3])) + c.append(float(item[4])) + n = len(c) + if n < 5: + return None + start = max(0, n - max_bars) + o, h, l, c = o[start:], h[start:], l[start:], c[start:] + x = list(range(len(c))) + + fig, ax = plt.subplots(figsize=(7, 3), facecolor="#030308") + ax.set_facecolor("#050510") + for i in x: + up = c[i] >= o[i] + col = "#00f5d4" if up else "#ff006e" + ax.plot([i, i], [l[i], h[i]], color=col, linewidth=0.9, alpha=0.9) + body_low = min(o[i], c[i]) + body_h = abs(c[i] - o[i]) + if body_h < 1e-12: + body_h = (h[i] - l[i]) * 0.08 or 1e-8 + ax.add_patch( + Rectangle( + (i - 0.35, body_low), + 0.7, + body_h, + facecolor=col, + edgecolor=col, + linewidth=0.4, + alpha=0.85, + ) + ) + ax.set_title(f"{symbol} 1D", color="#00fff7", fontsize=11, fontfamily="monospace") + ax.tick_params(colors="#7dffb3", labelsize=7) + for spine in ax.spines.values(): + spine.set_color("#1b3d2f") + ax.grid(True, alpha=0.12, color="#00fff7") + plt.tight_layout() + buf = io.BytesIO() + fig.savefig(buf, format="png", dpi=100, facecolor=fig.get_facecolor()) + plt.close(fig) + buf.seek(0) + return base64.b64encode(buf.read()).decode("ascii") diff --git a/onchain_scout_gate/app/config.py b/onchain_scout_gate/app/config.py new file mode 100644 index 0000000..37a2a0c --- /dev/null +++ b/onchain_scout_gate/app/config.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Literal + +import yaml +from pydantic import BaseModel, Field, ValidationError + + +class AppConfig(BaseModel): + host: str = "0.0.0.0" + port: int = 8088 + poll_interval_seconds: int = 120 + log_file: str = "./runtime/system.log" + database_url: str = "sqlite+aiosqlite:///./runtime/alerts.db" + session_secret: str = "please-change-me" + + +class AuthConfig(BaseModel): + """ + enabled: 为 false 时跳过登录(仅建议纯局域网、无外网暴露时使用)。 + """ + + enabled: bool = True + username: str + password: str + + +class WeComConfig(BaseModel): + webhook: str + mentioned_mobile_list: list[str] = Field(default_factory=list) + + +class GateConfig(BaseModel): + """Gate.io 公共 REST v4(USDT 永续 settle=usdt)。""" + + api_base: str = "https://api.gateio.ws/api/v4" + settle: str = "usdt" + quote_currency: str = "USDT" + + +class ProxyConfig(BaseModel): + """ + 出站 HTTP 客户端代理(httpx),用于访问 Gate 等外网。 + 企业微信与本机/局域网 Ollama(Gemma)默认直连,不使用此配置。 + 可写 socks5h://…;程序在交给 httpx 时会自动改为 socks5://(避免 Unknown scheme)。 + """ + + enabled: bool = False + url: str = "socks5h://127.0.0.1:1080" + + +class OrderExecutorConfig(BaseModel): + """ + 与 gate_order_executor 联动:企微突破推送 **成功之后**,向执行器 POST /v1/signal。 + 请求不走 proxy.url(直连 base_url),便于同机 127.0.0.1。 + webhook_secret 须与执行器 config.yaml 的 security.webhook_secret 一致。 + """ + + enabled: bool = False + base_url: str = "http://127.0.0.1:8090" + webhook_secret: str = "" + timeout_seconds: float = Field(15.0, ge=3.0, le=120.0) + + +class WatchSymbol(BaseModel): + """Gate USDT 永续 base 资产符号,如 BTC、ORDI、1000PEPE(与合约名 BTC_USDT 的左侧一致)。""" + + symbol: str + + +class MonitorConfig(BaseModel): + """ + 监控侧过滤。 + universe: + - all_swaps: 监控 Gate 全部 USDT 本位线性永续中,24h 成交额达标的合约(不依赖 watch_symbols)。 + - watchlist: 仅监控 watch_symbols 中列出且满足成交额阈值的标的。 + min_24h_quote_volume_usdt: 近 24h 成交额下限(USDT)。优先使用 Gate ticker 的 volume_24h_quote。 + all_swaps 模式下若设为 0 或负数,将拒绝整轮扫描(避免无阈值拉全市场)。 + watchlist 模式下 0 表示关闭成交额过滤。 + btc_daily_gate_enabled: 可选;true 时仍计算 BTC 日线 regime 供面板/日志参考,不再拦截山寨扫描。 + btc_sideways_lookback_days / btc_sideways_max_range_pct: 与上述辅助门控配套的横盘区分参数。 + """ + + universe: Literal["all_swaps", "watchlist"] = "all_swaps" + min_24h_quote_volume_usdt: float = 10_000_000 + # 可选:BTC 日线 regime 仅展示/记录;推送门控用「近8h×15m BTC 环境(横盘则多空均可;否则涨→LONG、跌→SHORT)+ 本币4h同向」 + btc_daily_gate_enabled: bool = True + btc_sideways_lookback_days: int = 14 + btc_sideways_max_range_pct: float = 10.0 + # 同一币种在 N 小时内对同一条「链路」只落库一条告警、只推送一次(0 表示关闭去重) + # 链路含:GATE-USDT 5m WATCH / GATE-USDT 5m TRIGGER(分级)与 FUNNEL-GEMMA(漏斗) + symbol_signal_dedupe_hours: float = 4.0 + # 企业微信主推送(突破预警):仅对本轮监控池内 24h 成交额排名前 N 的合约推送;0 表示不限制 + wecom_push_max_volume_rank: int = 30 + + +class GemmaConfig(BaseModel): + """ + 本地 Ollama 跑 Gemma(或其它模型)做漏斗二次分拣。 + 需在机器上自行启动 ollama 并拉取模型;开启后仅对本轮 5m 扫描命中的 WATCH/TRIGGER 按成交额取前 N 再请求。 + """ + + enabled: bool = False + ollama_base_url: str = "http://127.0.0.1:11434" + model: str = "gemma2:2b" + timeout_seconds: float = 180.0 + temperature: float = 0.15 + json_mode: bool = True + send_chart_image: bool = True + max_funnel_per_cycle: int = 12 + vision_top_n: int = 4 + gemma_push_priority_min: float = 7.0 + composite_push_min: float = 72.0 + + +class DailyReportConfig(BaseModel): + """每日晨报:北京时间定时生成昨天复盘,并可推送企业微信。""" + + enabled: bool = True + run_time_cn: str = "08:30" + push_wecom: bool = True + run_on_startup: bool = False + + +class Settings(BaseModel): + app: AppConfig + auth: AuthConfig + wecom: WeComConfig + gate: GateConfig + proxy: ProxyConfig = Field(default_factory=ProxyConfig) + order_executor: OrderExecutorConfig = Field(default_factory=OrderExecutorConfig) + monitor: MonitorConfig = Field(default_factory=MonitorConfig) + gemma: GemmaConfig = Field(default_factory=GemmaConfig) + daily_report: DailyReportConfig = Field(default_factory=DailyReportConfig) + watch_symbols: list[WatchSymbol] = Field(default_factory=list) + + +def load_settings(config_path: str = "config.yaml") -> Settings: + path = Path(config_path).expanduser().resolve() + if not path.exists(): + raise FileNotFoundError( + f"配置文件不存在: {path}. 请先复制 config.example.yaml 为 config.yaml 并填写密钥。" + ) + raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + try: + return Settings.model_validate(raw) + except ValidationError as exc: + raise ValueError(f"配置文件校验失败: {exc}") from exc + + +# 兼容原 OKX 风格 bar 字符串(映射见 app.gate._to_gate_interval) +GATE_BAR_CHOICES: tuple[str, ...] = ( + "1m", + "3m", + "5m", + "15m", + "30m", + "1H", + "2H", + "4H", + "6H", + "12H", + "1D", + "1W", + "1M", +) diff --git a/onchain_scout_gate/app/daily_features.py b/onchain_scout_gate/app/daily_features.py new file mode 100644 index 0000000..8bd3156 --- /dev/null +++ b/onchain_scout_gate/app/daily_features.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import math +from statistics import mean + + +def rows_to_ohlcv(rows: list[list[str]]) -> tuple[list[float], list[float], list[float], list[float], list[float]]: + o, h, l, c, v = [], [], [], [], [] + for item in rows: + if len(item) < 6: + continue + o.append(float(item[1])) + h.append(float(item[2])) + l.append(float(item[3])) + c.append(float(item[4])) + v.append(float(item[5])) + return o, h, l, c, v + + +def build_daily_programmatic(rows_1d: list[list[str]], est_quote_vol_24h_usdt: float) -> dict: + """ + 日线程序化特征:上方空间(距阶段高)、成交活跃度、简单阻力代理(现价与区间高之间局部高点数量)。 + """ + _, high, low, close, vol = rows_to_ohlcv(rows_1d) + if len(close) < 10: + return {"error": "insufficient_daily", "have": len(close)} + + last = close[-1] + look = min(60, len(close)) + hi = max(high[-look:]) + lo = min(low[-look:]) + mid = (hi + lo) / 2 if hi > lo else last + range_pct = ((hi - lo) / mid) * 100 if mid > 0 else 0.0 + upside_pct = ((hi - last) / last) * 100 if last > 0 else 0.0 + + # 现价上方到区间高:统计「局部高点」数量作为中间阻力代理(越多越密) + seg_h = high[-look:] + seg_l = low[-look:] + local_peaks = 0 + for i in range(1, len(seg_h) - 1): + if seg_h[i] >= seg_h[i - 1] and seg_h[i] >= seg_h[i + 1]: + if seg_h[i] > last * 1.002 and seg_h[i] < hi * 0.998: + local_peaks += 1 + + vol_tail = vol[-20:] if len(vol) >= 20 else vol + vol_mean = mean(vol_tail[:-1]) if len(vol_tail) > 1 else (vol_tail[0] if vol_tail else 1.0) + vol_ratio = (vol_tail[-1] / vol_mean) if vol_mean > 0 else 0.0 + + sma20 = mean(close[-20:]) if len(close) >= 20 else mean(close) + structure_hint = "price_above_sma20" if last >= sma20 else "price_below_sma20" + + return { + "last_close": round(last, 8), + "range_60d_high": round(hi, 8), + "range_60d_low": round(lo, 8), + "range_pct_lookback": round(range_pct, 4), + "upside_to_range_high_pct": round(max(0.0, upside_pct), 4), + "mid_resistance_proxy_peaks": local_peaks, + "volume_last_vs_20d_mean": round(vol_ratio, 4), + "est_quote_vol_24h_usdt": round(est_quote_vol_24h_usdt, 2), + "structure_hint": structure_hint, + "sma20": round(sma20, 8), + } + + +def programmatic_scores(prog: dict) -> dict: + """归一化子分数 0–100,供合成 composite。""" + if prog.get("error"): + return {"vol": 0.0, "upside": 0.0, "liquidity": 0.0, "mid_clear": 0.0} + + est = float(prog.get("est_quote_vol_24h_usdt") or 0.0) + # 成交额:10M≈35,100M≈70 + vol_score = min(100.0, max(0.0, math.log10(est / 1e6 + 1) * 32.0)) + + upside = float(prog.get("upside_to_range_high_pct") or 0.0) + upside_score = min(100.0, upside * 4.0) + + vr = float(prog.get("volume_last_vs_20d_mean") or 0.0) + liquidity_score = min(100.0, max(0.0, (vr - 1.0) * 35.0 + 40.0)) + + peaks = int(prog.get("mid_resistance_proxy_peaks") or 0) + mid_clear_score = max(0.0, 100.0 - peaks * 12.0) + + return { + "vol": round(vol_score, 2), + "upside": round(upside_score, 2), + "liquidity": round(liquidity_score, 2), + "mid_clear": round(mid_clear_score, 2), + } + + +def composite_score(gemma_priority: float, sub: dict) -> float: + """gemma_priority 1–10;与程序化子分合成 0–100。""" + g = max(1.0, min(10.0, gemma_priority)) * 10.0 + p = 0.35 * g + p += 0.2 * sub.get("vol", 0.0) + p += 0.2 * sub.get("upside", 0.0) + p += 0.15 * sub.get("liquidity", 0.0) + p += 0.1 * sub.get("mid_clear", 0.0) + return round(min(100.0, max(0.0, p)), 2) + + +def daily_ohlc_text_block(rows_1d: list[list[str]], max_lines: int = 24) -> str: + """给 LLM 的紧凑 OHLCV 文本(时间正序:旧→新,最后一行为最新)。""" + rows = rows_1d[-max_lines:] if len(rows_1d) > max_lines else rows_1d + lines = ["ts,o,h,l,c,vol"] + for item in rows: + if len(item) < 6: + continue + ts, o, h, l, c, v = item[0], item[1], item[2], item[3], item[4], item[5] + lines.append(f"{ts},{o},{h},{l},{c},{v}") + return "\n".join(lines) diff --git a/onchain_scout_gate/app/daily_report.py b/onchain_scout_gate/app/daily_report.py new file mode 100644 index 0000000..63b6357 --- /dev/null +++ b/onchain_scout_gate/app/daily_report.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import json +from collections import Counter +from datetime import date, datetime, timedelta, timezone +from statistics import mean +from typing import TYPE_CHECKING +from zoneinfo import ZoneInfo + +from .config import Settings +from .notifier import WeComNotifier +from .gate import GateClient +from .storage import Storage +from .time_cn import format_beijing_wall, utc_now + +if TYPE_CHECKING: + from .gemma_client import OllamaGemmaClient + +CN_TZ = ZoneInfo("Asia/Shanghai") +BTC_INST = "BTC_USDT" + + +def _rows_to_close(rows: list[list[str]]) -> list[float]: + out: list[float] = [] + for r in rows: + if len(r) < 5: + continue + out.append(float(r[4])) + return out + + +def _sma(values: list[float], n: int) -> float: + if not values: + return 0.0 + if len(values) < n: + return mean(values) + return mean(values[-n:]) + + +def _btc_direction(close: float, prev: float, sma20: float, sma60: float) -> tuple[str, str]: + up = close >= prev + if close >= sma20 >= sma60 and up: + return "偏多上行", "收盘位于 SMA20/SMA60 上方,且日内延续上涨。" + if close < sma20 <= sma60 and not up: + return "偏空下行", "收盘位于 SMA20 下方且动能走弱。" + return "震荡中性", "价格位于均线附近,趋势延续性一般。" + + +def _cn_day_range(target_day: date) -> tuple[datetime, datetime]: + day_start_cn = datetime(target_day.year, target_day.month, target_day.day, tzinfo=CN_TZ) + day_end_cn = day_start_cn + timedelta(days=1) + start_utc = day_start_cn.astimezone(timezone.utc).replace(tzinfo=None) + end_utc = day_end_cn.astimezone(timezone.utc).replace(tzinfo=None) + return start_utc, end_utc + + +def _default_report_text(snapshot: dict, stats: dict, report_day_cn: str) -> dict: + top_symbols = stats.get("top_trigger_symbols", []) + top_line = "、".join(top_symbols[:5]) if top_symbols else "无" + risk = "若 BTC 回落并失守日内关键位,山寨延续将明显减弱。" + action = "优先跟踪成交额靠前且 5m 不创新低的标的,确认后再加仓。" + return { + "headline": f"{report_day_cn} 复盘:BTC {snapshot['direction']},触发层共 {stats['trigger_count']} 条", + "btc_explain": snapshot["direction_reason"], + "summary": ( + f"昨日 WATCH {stats['watch_count']} 条、TRIGGER {stats['trigger_count']} 条、" + f"漏斗优先推送 {stats['funnel_push_count']} 条。" + f"触发活跃币种:{top_line}。" + ), + "risk_points": [risk], + "action_hint": action, + } + + +class DailyReportService: + def __init__( + self, + settings: Settings, + storage: Storage, + gate_client: GateClient, + notifier: WeComNotifier, + gemma_client: OllamaGemmaClient | None, + ) -> None: + self.settings = settings + self.storage = storage + self.gate = gate_client + self.notifier = notifier + self.gemma_client = gemma_client + + async def _push_wecom_enabled(self) -> bool: + raw = await self.storage.get_kv("daily_report_push_wecom") + if raw is None: + return self.settings.daily_report.push_wecom + return str(raw).strip().lower() in {"1", "true", "yes", "y", "on"} + + async def run_once(self) -> dict: + now_utc = utc_now() + now_cn = now_utc.astimezone(CN_TZ) + report_day = now_cn.date() - timedelta(days=1) + start_utc, end_utc = _cn_day_range(report_day) + report_day_cn = report_day.strftime("%Y-%m-%d") + + alerts = await self.storage.get_alerts_between(start_utc, end_utc, limit=3000) + watch_count = 0 + trigger_count = 0 + funnel_push_count = 0 + trigger_symbols: Counter[str] = Counter() + for a in alerts: + d = a.get("details") or {} + lvl = str(d.get("signal_level") or "") + src = str(d.get("source") or "") + if lvl == "WATCH": + watch_count += 1 + elif lvl == "TRIGGER": + trigger_count += 1 + trigger_symbols[str(a.get("symbol") or "").upper()] += 1 + if src == "gemma_funnel" and bool(d.get("priority_push")): + funnel_push_count += 1 + + btc_rows = await self.gate.get_candles(BTC_INST, "1D", limit=100) + closes = _rows_to_close(btc_rows) + last_close = closes[-1] if closes else 0.0 + prev_close = closes[-2] if len(closes) >= 2 else last_close + day_change_pct = ((last_close - prev_close) / prev_close * 100.0) if prev_close else 0.0 + sma20 = _sma(closes, 20) + sma60 = _sma(closes, 60) + direction, direction_reason = _btc_direction(last_close, prev_close, sma20, sma60) + + snapshot = { + "symbol": "BTC", + "last_close": round(last_close, 4), + "prev_close": round(prev_close, 4), + "day_change_pct": round(day_change_pct, 2), + "sma20": round(sma20, 4), + "sma60": round(sma60, 4), + "direction": direction, + "direction_reason": direction_reason, + } + stats = { + "watch_count": watch_count, + "trigger_count": trigger_count, + "funnel_push_count": funnel_push_count, + "top_trigger_symbols": [s for s, _ in trigger_symbols.most_common(10)], + } + + ai_used = False + text_block = _default_report_text(snapshot, stats, report_day_cn) + if self.gemma_client and self.settings.gemma.enabled: + ai = await self.gemma_client.generate_daily_report(report_day_cn, snapshot, stats) + if ai and not ai.get("error"): + ai_used = True + text_block = { + "headline": ai.get("headline") or text_block["headline"], + "btc_explain": ai.get("btc_explain") or text_block["btc_explain"], + "summary": ai.get("summary") or text_block["summary"], + "risk_points": ai.get("risk_points") or text_block["risk_points"], + "action_hint": ai.get("action_hint") or text_block["action_hint"], + } + + report = { + "report_day_cn": report_day_cn, + "generated_at_utc": now_utc.isoformat(), + "generated_at_cn": format_beijing_wall(now_utc), + "ai_used": ai_used, + "btc": snapshot, + "stats": stats, + "text": text_block, + } + await self.storage.set_kv("daily_report_latest", json.dumps(report, ensure_ascii=False)) + await self.storage.add_log( + "INFO", + f"daily_report_generated day={report_day_cn} ai={'on' if ai_used else 'off'} trigger={trigger_count}", + ) + if await self._push_wecom_enabled(): + await self.notifier.send_daily_report(report) + return report diff --git a/onchain_scout_gate/app/exchange_rules.py b/onchain_scout_gate/app/exchange_rules.py new file mode 100644 index 0000000..e06c1d5 --- /dev/null +++ b/onchain_scout_gate/app/exchange_rules.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from statistics import mean + +# 以下换算仅针对 5m K(与是否单独拉 4h 图无关): +# 每小时 60/5 = 12 根;一根「4 小时」大周期对应 4×12 = 48 根 5m。 +BARS_5M_PER_HOUR = 12 +BARS_5M_PER_4H = BARS_5M_PER_HOUR * 4 # 48 +# 箱体回看最短不少于一根 4h 等价的 5m 长度,避免用不足一个 4h 的窗去定义箱体 +MIN_BOX_LOOKBACK_BARS_5M = BARS_5M_PER_4H + + +@dataclass +class IntradayRuleParams: + range_hours: float = 8.0 + range_max_pct: float = 1.5 + volume_spike_mult: float = 1.6 + volume_lookback_bars: int = 20 + breakout_buffer_pct: float = 0.05 + + +@dataclass +class ExchangeRuleResult: + signal_level: str = "NONE" # NONE | WATCH | TRIGGER + signal_side: str = "NONE" # NONE | LONG | SHORT + trigger_types: list[str] = field(default_factory=list) + score: float = 0.0 + metrics: dict = field(default_factory=dict) + + +def _rows_to_ohlcv(rows: list[list[str]]) -> tuple[list[float], list[float], list[float], list[float], list[float]]: + o, h, l, c, v = [], [], [], [], [] + for item in rows: + if len(item) < 6: + continue + o.append(float(item[1])) + h.append(float(item[2])) + l.append(float(item[3])) + c.append(float(item[4])) + v.append(float(item[5])) + return o, h, l, c, v + + +def evaluate_exchange( + symbol: str, + alt_rows: list[list[str]], + btc_rows: list[list[str]], + params: IntradayRuleParams, +) -> ExchangeRuleResult: + """ + 5m 日内结构规则(中文分级): + - range_hours 按「墙钟小时」换成 5m 根数:×12(根/小时);48 根 5m = 4 墙钟小时。 + - 观察:箱体回看窗口内(不含突破/确认 K)用最高/最低算振幅,不超过 range_max_pct + - 触发:突破 K 在有效带内,确认 K 收在箱体外,并满足放量等条件 + """ + breakout_max_pct = 0.5 + result = ExchangeRuleResult() + _, ah, al, ac, av = _rows_to_ohlcv(alt_rows) + + bars_for_range = max( + MIN_BOX_LOOKBACK_BARS_5M, + int(params.range_hours * BARS_5M_PER_HOUR), + ) + vol_lb = max(5, int(params.volume_lookback_bars)) + min_need = bars_for_range + vol_lb + 3 + if len(ac) < min_need: + result.metrics = {"error": "insufficient_candles", "need": min_need, "have": len(ac)} + return result + + # 区间边界:前 N 根(不含倒数第 1 确认 K、倒数第 2 突破 K),用区间内的 highest/lowest + seg_h = ah[-bars_for_range - 2 : -2] + seg_l = al[-bars_for_range - 2 : -2] + range_high = max(seg_h) + range_low = min(seg_l) + mid = (range_high + range_low) / 2 if range_high > range_low else 0 + range_pct = ((range_high - range_low) / mid) * 100 if mid > 0 else 999.0 + + breakout_close = ac[-2] + confirm_close = ac[-1] + breakout_high = ah[-2] + breakout_low = al[-2] + confirm_high = ah[-1] + confirm_low = al[-1] + last_volume = av[-1] + vol_base = mean(av[-vol_lb - 1 : -1]) if len(av) > vol_lb else mean(av) + vol_ratio = (last_volume / vol_base) if vol_base > 0 else 0.0 + + breakout_min_line = range_high * (1 + params.breakout_buffer_pct / 100) + breakout_max_line = range_high * (1 + breakout_max_pct / 100) + breakdown_min_line = range_low * (1 - params.breakout_buffer_pct / 100) + breakdown_max_line = range_low * (1 - breakout_max_pct / 100) + is_sideways = range_pct <= params.range_max_pct + is_volume_spike = vol_ratio >= params.volume_spike_mult + + breakout_long_ok = breakout_close > breakout_min_line and breakout_close < breakout_max_line + breakout_short_ok = breakout_close < breakdown_min_line and breakout_close > breakdown_max_line + confirm_long_ok = confirm_close > range_high + confirm_short_ok = confirm_close < range_low + + if is_sideways: + result.signal_level = "WATCH" + result.trigger_types = ["横盘结构成立"] + result.score = 1.0 + + if is_sideways and breakout_long_ok and confirm_long_ok and is_volume_spike: + result.signal_level = "TRIGGER" + result.signal_side = "LONG" + result.trigger_types = ["横盘结构成立", "突破K在有效区间", "第二根K确认未回箱体", "放量突破"] + result.score = 3.4 + elif is_sideways and breakout_short_ok and confirm_short_ok and is_volume_spike: + result.signal_level = "TRIGGER" + result.signal_side = "SHORT" + result.trigger_types = ["横盘结构成立", "突破K在有效区间", "第二根K确认未回箱体", "放量破位"] + result.score = 3.4 + + result.metrics = { + "symbol": symbol.upper(), + "bar": "5m", + "range_hours": params.range_hours, + "range_bars": bars_for_range, + "range_max_pct": params.range_max_pct, + "range_pct": round(range_pct, 4), + "range_high": range_high, + "range_low": range_low, + "breakout_min_pct": params.breakout_buffer_pct, + "breakout_max_pct": breakout_max_pct, + "breakout_min_line": breakout_min_line, + "breakout_max_line": breakout_max_line, + "breakdown_min_line": breakdown_min_line, + "breakdown_max_line": breakdown_max_line, + "breakout_close": breakout_close, + "confirm_close": confirm_close, + "breakout_high": breakout_high, + "breakout_low": breakout_low, + "confirm_high": confirm_high, + "confirm_low": confirm_low, + "volume_lookback_bars": vol_lb, + "volume_spike_mult": params.volume_spike_mult, + "last_volume": last_volume, + "volume_base": round(vol_base, 8), + "volume_ratio": round(vol_ratio, 4), + "signal_side": result.signal_side, + } + return result diff --git a/onchain_scout_gate/app/gate.py b/onchain_scout_gate/app/gate.py new file mode 100644 index 0000000..e35a94d --- /dev/null +++ b/onchain_scout_gate/app/gate.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +import asyncio +from typing import Any + +import httpx + +from .config import GateConfig +from .proxy_util import httpx_proxy_url + + +def _to_gate_interval(bar: str) -> str: + b = (bar or "").strip() + mapping = { + "1m": "1m", + "3m": "3m", + "5m": "5m", + "15m": "15m", + "30m": "30m", + "1H": "1h", + "2H": "2h", + "4H": "4h", + "6H": "6h", + "8H": "8h", + "12H": "12h", + "1D": "1d", + "1W": "7d", + "1M": "1M", + } + if b in mapping: + return mapping[b] + if len(b) >= 2 and b.endswith("H") and b[:-1].isdigit(): + return f"{b[:-1]}h" + if len(b) >= 2 and b.endswith("D"): + return b[:-1] + "d" + return b.lower() + + +def _candle_row(obj: dict[str, Any]) -> list[str]: + ts_ms = str(int(float(obj["t"])) * 1000) + o = str(obj.get("o") or "") + h = str(obj.get("h") or "") + l = str(obj.get("l") or "") + c = str(obj.get("c") or "") + v = str(obj.get("v") or "") + sum_q = str(obj.get("sum") or "") + return [ts_ms, o, h, l, c, v, v, sum_q, "1"] + + +def _is_linear_usdt_perp_contract(item: dict[str, Any]) -> bool: + name = str(item.get("name") or "") + parts = name.split("_") + if len(parts) != 2 or parts[1].upper() != "USDT": + return False + if item.get("in_delisting") is True: + return False + return True + + +class GateClient: + """Gate.io USDT 结算永续合约公共行情(REST v4)。""" + + def __init__(self, conf: GateConfig, proxy_url: str | None = None) -> None: + self.conf = conf + self._proxy = httpx_proxy_url(proxy_url.strip() if proxy_url and str(proxy_url).strip() else None) + self.timeout = httpx.Timeout(10.0, read=16.0) + self._candle_sem = asyncio.Semaphore(3) + + def _base_url(self) -> str: + return str(self.conf.api_base).rstrip("/") + + def _futures_prefix(self) -> str: + return f"{self._base_url()}/futures/{self.conf.settle.strip().lower()}" + + def _client_kwargs(self, timeout: httpx.Timeout) -> dict: + if self._proxy: + return {"timeout": timeout, "proxy": self._proxy, "trust_env": False} + return {"timeout": timeout, "trust_env": True} + + def symbol_to_swap_inst_id(self, symbol: str) -> str: + base = symbol.strip().upper() + return f"{base}_{self.conf.quote_currency.upper()}" + + def inst_id_to_base_symbol(self, inst_id: str) -> str: + inst = inst_id.strip().upper() + suf = f"_{self.conf.quote_currency.upper()}" + if inst.endswith(suf): + return inst[: -len(suf)] + return inst.split("_")[0].upper() if "_" in inst else inst + + async def _fetch_contracts(self) -> list[dict[str, Any]]: + url = f"{self._futures_prefix()}/contracts" + async with httpx.AsyncClient(**self._client_kwargs(self.timeout)) as client: + resp = await client.get(url) + resp.raise_for_status() + data = resp.json() + if not isinstance(data, list): + raise RuntimeError(f"Gate contracts unexpected payload: {type(data)}") + return data + + async def list_live_usdt_swap_inst_ids(self) -> list[str]: + """全部 USDT 本位线性永续合约名(如 BTC_USDT),剔除交割/下架中的条目。""" + data = await self._fetch_contracts() + out: list[str] = [] + for item in data: + if not isinstance(item, dict): + continue + if not _is_linear_usdt_perp_contract(item): + continue + name = str(item.get("name") or "").strip() + if name: + out.append(name) + return sorted(set(out)) + + async def get_perpetual_symbols(self) -> set[str]: + ids = await self.list_live_usdt_swap_inst_ids() + return {self.inst_id_to_base_symbol(i) for i in ids} + + async def get_candles(self, inst_id: str, bar: str, limit: int = 120) -> list[list[str]]: + """ + 返回按时间正序排列的 K 线列表(与旧 OKX 行格式对齐便于下游逻辑): + [ts_ms, o, h, l, c, vol, vol_dup, sum_quote, confirm] + """ + interval = _to_gate_interval(bar) + lim = max(1, min(int(limit), 2000)) + url = f"{self._futures_prefix()}/candlesticks" + params = {"contract": inst_id, "interval": interval, "limit": str(lim)} + async with self._candle_sem: + await asyncio.sleep(0.12) + async with httpx.AsyncClient(**self._client_kwargs(self.timeout)) as client: + resp = await client.get(url, params=params) + resp.raise_for_status() + payload = resp.json() + if not isinstance(payload, list): + raise RuntimeError(f"Gate candlesticks error: {payload}") + rows: list[list[str]] = [] + for item in payload: + if isinstance(item, dict) and "t" in item: + rows.append(_candle_row(item)) + rows.sort(key=lambda r: int(r[0]) if r and r[0].isdigit() else 0) + return rows + + async def get_usdt_swap_est_quote_volume_map(self) -> dict[str, float]: + """ + 合约名 -> 近 24h 计价币种成交额(USDT)。 + 优先使用 ticker 的 volume_24h_quote;缺失时再尝试简单估算。 + """ + url = f"{self._futures_prefix()}/tickers" + tick_timeout = httpx.Timeout(15.0, read=90.0) + async with httpx.AsyncClient(**self._client_kwargs(tick_timeout)) as client: + resp = await client.get(url) + resp.raise_for_status() + payload = resp.json() + if not isinstance(payload, list): + raise RuntimeError(f"Gate tickers error: {type(payload)}") + out: dict[str, float] = {} + for item in payload: + if not isinstance(item, dict): + continue + contract = str(item.get("contract") or "").strip() + if not contract.endswith("_USDT"): + continue + vol_quote = item.get("volume_24h_quote") or item.get("volume_24h_usd") + try: + if vol_quote is not None and str(vol_quote).strip(): + out[contract] = max(0.0, float(vol_quote)) + continue + last = float(item.get("last") or 0) + vol_base = float(item.get("volume_24h_base") or item.get("volume_24h") or 0) + out[contract] = max(0.0, vol_base * last) + except (TypeError, ValueError): + continue + return out diff --git a/onchain_scout_gate/app/gemma_client.py b/onchain_scout_gate/app/gemma_client.py new file mode 100644 index 0000000..72c66fd --- /dev/null +++ b/onchain_scout_gate/app/gemma_client.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import json +import logging +import re +from typing import Any + +import httpx + +from .config import GemmaConfig + +LOGGER = logging.getLogger("onchain_scout.gemma_client") + + +def _extract_json_object(text: str) -> dict[str, Any] | None: + text = text.strip() + m = re.search(r"\{[\s\S]*\}", text) + if not m: + return None + raw = m.group(0) + try: + return json.loads(raw) + except json.JSONDecodeError: + return None + + +class OllamaGemmaClient: + def __init__(self, conf: GemmaConfig) -> None: + self.conf = conf + self.timeout = httpx.Timeout(conf.timeout_seconds, read=conf.timeout_seconds + 30.0) + + async def rank_funnel( + self, + symbol: str, + programmatic_text: str, + ohlc_csv_block: str, + image_base64: str | None, + ) -> dict[str, Any]: + """ + 调用本地 Ollama,让 Gemma 按漏斗标准 JSON 回复。 + """ + system = ( + "你是加密货币永续合约的日线结构分析师。只输出一个 JSON 对象,不要 Markdown,不要代码围栏。" + "字段必须全部存在且为英文枚举/数字:" + '{"daily_structure":"strong|ok|weak",' + '"volume_view":"high|mid|low",' + '"upside_space":"high|mid|low",' + '"mid_resistance":"low|mid|high",' + '"priority":1-10整数,' + '"one_liner":"中文一句"}。' + "priority 越高越值得优先关注:成交大、日线结构好、上方空间大、中间阻力小则给高分。" + ) + user_body = ( + f"标的 {symbol} USDT 永续。\n" + f"程序化摘要:\n{programmatic_text}\n\n" + f"最近日线 OHLCV(时间正序最后一行为最新):\n{ohlc_csv_block}\n" + ) + url = f"{self.conf.ollama_base_url.rstrip('/')}/api/chat" + message: dict[str, Any] = {"role": "user", "content": user_body} + if image_base64 and self.conf.send_chart_image: + message["images"] = [image_base64] + + payload: dict[str, Any] = { + "model": self.conf.model, + "messages": [{"role": "system", "content": system}, message], + "stream": False, + "options": {"temperature": self.conf.temperature}, + } + if self.conf.json_mode: + payload["format"] = "json" + + async with httpx.AsyncClient(timeout=self.timeout, trust_env=False) as client: + resp = await client.post(url, json=payload) + resp.raise_for_status() + data = resp.json() + + msg = (data.get("message") or {}).get("content") or "" + parsed = _extract_json_object(msg) if msg else None + if parsed is None and isinstance(data.get("message"), dict): + parsed = _extract_json_object(str(data["message"])) + if parsed is None: + LOGGER.warning("gemma_parse_failed symbol=%s raw_len=%s", symbol, len(msg)) + return { + "error": "parse_failed", + "raw": msg[:2000], + "daily_structure": "weak", + "volume_view": "low", + "upside_space": "low", + "mid_resistance": "high", + "priority": 1, + "one_liner": "模型输出无法解析为 JSON", + } + return _normalize_gemma_dict(parsed) + + async def generate_daily_report(self, report_day_cn: str, btc_snapshot: dict, stats: dict) -> dict[str, Any]: + system = ( + "你是加密交易复盘助手。输出严格 JSON 对象,不要 Markdown。字段必须存在:" + '{"headline":"...","btc_explain":"...","summary":"...","risk_points":["..."],"action_hint":"..."}。' + "用中文,简洁专业,不写投资建议免责声明。" + ) + user_body = ( + f"请生成 {report_day_cn} 的晨报。\n" + f"BTC 快照: {json.dumps(btc_snapshot, ensure_ascii=False)}\n" + f"昨日统计: {json.dumps(stats, ensure_ascii=False)}\n" + "要求:1) headline 一句话;2) btc_explain 解释方向;" + "3) summary 覆盖 WATCH/TRIGGER/漏斗;4) risk_points 给1-3条;5) action_hint 给执行提示。" + ) + url = f"{self.conf.ollama_base_url.rstrip('/')}/api/chat" + payload: dict[str, Any] = { + "model": self.conf.model, + "messages": [{"role": "system", "content": system}, {"role": "user", "content": user_body}], + "stream": False, + "options": {"temperature": 0.1}, + "format": "json", + } + async with httpx.AsyncClient(timeout=self.timeout, trust_env=False) as client: + resp = await client.post(url, json=payload) + resp.raise_for_status() + data = resp.json() + + msg = (data.get("message") or {}).get("content") or "" + parsed = _extract_json_object(msg) if msg else None + if parsed is None: + return {"error": "parse_failed", "raw": msg[:1200]} + risk = parsed.get("risk_points") + if not isinstance(risk, list): + risk = [str(risk or "")] + risk = [str(x)[:120] for x in risk if str(x or "").strip()][:3] or ["注意高波动时的回撤风险。"] + return { + "headline": str(parsed.get("headline") or "")[:120], + "btc_explain": str(parsed.get("btc_explain") or "")[:220], + "summary": str(parsed.get("summary") or "")[:360], + "risk_points": risk, + "action_hint": str(parsed.get("action_hint") or "")[:220], + } + + +def _normalize_gemma_dict(d: dict[str, Any]) -> dict[str, Any]: + def _enum(v: Any, choices: set[str], default: str) -> str: + s = str(v or "").strip().lower() + return s if s in choices else default + + try: + pr = int(float(d.get("priority", 1))) + except (TypeError, ValueError): + pr = 1 + pr = max(1, min(10, pr)) + return { + "daily_structure": _enum(d.get("daily_structure"), {"strong", "ok", "weak"}, "weak"), + "volume_view": _enum(d.get("volume_view"), {"high", "mid", "low"}, "low"), + "upside_space": _enum(d.get("upside_space"), {"high", "mid", "low"}, "low"), + "mid_resistance": _enum(d.get("mid_resistance"), {"low", "mid", "high"}, "high"), + "priority": pr, + "one_liner": str(d.get("one_liner") or "")[:280], + } diff --git a/onchain_scout_gate/app/main.py b/onchain_scout_gate/app/main.py new file mode 100644 index 0000000..f9cd285 --- /dev/null +++ b/onchain_scout_gate/app/main.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import logging +from logging.handlers import RotatingFileHandler +from pathlib import Path + +import uvicorn + +from .config import load_settings +from .web import create_app + + +def setup_logging(log_file: str) -> None: + path = Path(log_file).resolve() + path.parent.mkdir(parents=True, exist_ok=True) + + fmt = logging.Formatter("%(asctime)s | %(levelname)s | %(name)s | %(message)s") + root = logging.getLogger() + root.setLevel(logging.INFO) + root.handlers.clear() + + fh = RotatingFileHandler(path, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8") + fh.setFormatter(fmt) + sh = logging.StreamHandler() + sh.setFormatter(fmt) + + root.addHandler(fh) + root.addHandler(sh) + + +def build_app(config_path: str = "config.yaml"): + settings = load_settings(config_path) + setup_logging(settings.app.log_file) + return create_app(settings) + + +app = build_app() + + +if __name__ == "__main__": + settings = load_settings("config.yaml") + setup_logging(settings.app.log_file) + uvicorn.run( + "app.main:app", + host=settings.app.host, + port=settings.app.port, + workers=1, + log_level="info", + ) + diff --git a/onchain_scout_gate/app/models.py b/onchain_scout_gate/app/models.py new file mode 100644 index 0000000..3af5d61 --- /dev/null +++ b/onchain_scout_gate/app/models.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, Float, Integer, String, Text +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + pass + + +class AlertRecord(Base): + __tablename__ = "alerts" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + symbol: Mapped[str] = mapped_column(String(32), index=True) + chain: Mapped[str] = mapped_column(String(32), index=True) + trigger_types: Mapped[str] = mapped_column(String(255)) + score: Mapped[float] = mapped_column(Float) + details_json: Mapped[str] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) + + +class RuntimeLog(Base): + __tablename__ = "runtime_logs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + level: Mapped[str] = mapped_column(String(12), index=True) + message: Mapped[str] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) + + +class KvStore(Base): + """Simple key-value settings persisted in SQLite (e.g. chart bar from web UI).""" + + __tablename__ = "kv_store" + + key: Mapped[str] = mapped_column(String(64), primary_key=True) + value: Mapped[str] = mapped_column(Text) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) diff --git a/onchain_scout_gate/app/monitor.py b/onchain_scout_gate/app/monitor.py new file mode 100644 index 0000000..4a79f8a --- /dev/null +++ b/onchain_scout_gate/app/monitor.py @@ -0,0 +1,771 @@ +from __future__ import annotations + +import asyncio +import json +import logging +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import TYPE_CHECKING + +from .btc_regime import evaluate_btc_daily_gate +from .chart_candles import daily_candles_png_base64 +from .config import Settings, WatchSymbol +from .daily_features import ( + build_daily_programmatic, + composite_score, + daily_ohlc_text_block, + programmatic_scores, +) +from .exchange_rules import IntradayRuleParams, evaluate_exchange +from .notifier import WeComNotifier +from .order_executor_forward import build_order_executor_payload, forward_signal_to_executors +from .order_executors_store import read_forward_config, record_last_forward +from .gate import GateClient +from .storage import Storage + +if TYPE_CHECKING: + from .gemma_client import OllamaGemmaClient + +LOGGER = logging.getLogger("onchain_scout.monitor") +FIXED_BAR = "5m" +# 最近 8 墙钟小时 ≈ 32 根 15m K +BTC_15M_BARS_PER_8H = 32 + + +@dataclass +class RuntimeState: + started_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat()) + last_cycle_at: str = "" + last_cycle_status: str = "INIT" + last_cycle_msg: str = "" + chart_bar: str = FIXED_BAR + universe: str = "all_swaps" + intraday_params: dict = field(default_factory=dict) + monitoring_pool: list[dict] = field(default_factory=list) + perpetual_symbols_count: int = 0 + monitored_inst_count: int = 0 + pushed_alerts_count: int = 0 + btc_gate_allow: bool = True + btc_gate_regime: str = "" + btc_gate_reason: str = "" + btc_gate_metrics: dict = field(default_factory=dict) + btc_env_8h_15m: str = "" + symbol_blocklist_count: int = 0 + symbol_blocklist_removed: int = 0 + last_funnel: list[dict] = field(default_factory=list) + last_funnel_at: str = "" + gemma_cycle_msg: str = "" + + +class MonitorService: + def __init__( + self, + settings: Settings, + storage: Storage, + gate_client: GateClient, + notifier: WeComNotifier, + gemma_client: OllamaGemmaClient | None = None, + ) -> None: + self.settings = settings + self.storage = storage + self.gate = gate_client + self.notifier = notifier + self.gemma_client = gemma_client + self.state = RuntimeState() + self._lock = asyncio.Lock() + self._funnel_bg_task: asyncio.Task[None] | None = None + + @staticmethod + def _symbol_blocklist_from_kv(raw: str | None) -> frozenset[str]: + if not raw or not str(raw).strip(): + return frozenset() + try: + data = json.loads(raw) + except json.JSONDecodeError: + return frozenset() + if not isinstance(data, list): + return frozenset() + out: set[str] = set() + for x in data: + s = str(x).strip().upper() + if s: + out.add(s) + return frozenset(out) + + async def _maybe_forward_order_executor(self, sym: str, inst: str, push_metrics: dict) -> None: + fwd = read_forward_config(self.settings) + if not fwd.get("enabled"): + return + secret = str(fwd.get("webhook_secret") or "").strip() + if not secret: + await self.storage.add_log( + "WARN", + "order_executor.enabled=true but webhook_secret is empty; skip POST /v1/signal", + ) + return + targets = list(fwd.get("executors") or []) + if not targets: + await self.storage.add_log( + "WARN", + "order_executor.enabled=true but no enabled executor in panel list; skip POST /v1/signal", + ) + return + payload = build_order_executor_payload(inst_id=inst, metrics=push_metrics) + if not payload: + await self.storage.add_log( + "WARN", + f"order_executor_build_payload_failed sym={sym} inst={inst}", + ) + return + try: + results = await forward_signal_to_executors( + self.settings, + executors=targets, + webhook_secret=secret, + timeout_seconds=float(fwd.get("timeout_seconds") or 15.0), + payload=payload, + ) + except Exception as exc: # noqa: BLE001 + await self.storage.add_log( + "ERROR", + f"order_executor_forward_exception sym={sym} inst={inst}: {exc}", + ) + return + for out in results: + name = out.get("name") or "?" + eid = str(out.get("executor_id") or "") + status = out.get("http_status") + ok = out.get("ok") + body = out.get("body") or {} + st = body.get("status") if isinstance(body, dict) else None + detail = out.get("error") + if not detail and isinstance(body, dict): + detail = body.get("reason") or body.get("detail") + try: + record_last_forward( + self.settings, + eid, + http_status=int(status or 0), + ok=bool(ok), + exec_status=str(st) if st is not None else None, + detail=str(detail) if detail is not None else None, + ) + except Exception: # noqa: BLE001 + pass + if ok: + await self.storage.add_log( + "INFO", + f"order_executor_ok name={name} sym={sym} inst={inst} http={status} exec_status={st}", + ) + else: + await self.storage.add_log( + "ERROR", + f"order_executor_failed name={name} sym={sym} inst={inst} http={status} body={body!r} err={detail}", + ) + + @staticmethod + def _btc_intraday_bias(btc_rows: list[list[str]]) -> str: + closes: list[float] = [] + for row in btc_rows: + if len(row) < 5: + continue + try: + closes.append(float(row[4])) + except (TypeError, ValueError): + continue + if len(closes) < 2: + return "NEUTRAL" + if closes[-1] > closes[-2]: + return "BULL" + if closes[-1] < closes[-2]: + return "BEAR" + return "NEUTRAL" + + @staticmethod + def _ema(values: list[float], period: int) -> float: + if not values: + return 0.0 + p = max(1, period) + alpha = 2.0 / (p + 1.0) + ema = values[0] + for v in values[1:]: + ema = alpha * v + (1 - alpha) * ema + return ema + + @staticmethod + def _status_by_ema55(rows: list[list[str]]) -> str: + closes: list[float] = [] + highs: list[float] = [] + lows: list[float] = [] + for row in rows: + if len(row) < 5: + continue + try: + highs.append(float(row[2])) + lows.append(float(row[3])) + closes.append(float(row[4])) + except (TypeError, ValueError): + continue + if len(closes) < 55: + return "横盘" + ema55 = MonitorService._ema(closes[-120:], 55) + last = closes[-1] + lb = min(21, len(closes)) + h = max(highs[-lb:]) + l = min(lows[-lb:]) + mid = (h + l) / 2 if h > l else 0.0 + range_pct = ((h - l) / mid * 100.0) if mid > 0 else 999.0 + if range_pct <= 2.0: + return "横盘" + if last >= ema55: + return "多头" + return "空头" + + @staticmethod + def _btc_env_15m_last_8h(rows_15m: list[list[str]]) -> str: + """最近 8 小时内 BTC 15m 走势:多头 / 空头 / 横盘(窄幅视为横盘)。""" + closes: list[float] = [] + highs: list[float] = [] + lows: list[float] = [] + for row in rows_15m: + if len(row) < 5: + continue + try: + highs.append(float(row[2])) + lows.append(float(row[3])) + closes.append(float(row[4])) + except (TypeError, ValueError): + continue + need = BTC_15M_BARS_PER_8H + if len(closes) < need: + return "横盘" + h_win = max(highs[-need:]) + l_win = min(lows[-need:]) + mid = (h_win + l_win) / 2 if h_win > l_win else 0.0 + range_pct = ((h_win - l_win) / mid * 100.0) if mid > 0 else 999.0 + if range_pct <= 1.8: + return "横盘" + warmup = min(len(closes), 96) + seq = closes[-warmup:] + if len(seq) < 21: + return "横盘" + ema21 = MonitorService._ema(seq, 21) + last = closes[-1] + if last >= ema21: + return "多头" + return "空头" + + @staticmethod + def _push_matches_btc_env(btc_env: str, signal_side: str) -> bool: + if signal_side not in {"LONG", "SHORT"}: + return False + if btc_env == "横盘": + return True + if btc_env == "多头": + return signal_side == "LONG" + if btc_env == "空头": + return signal_side == "SHORT" + return False + + @staticmethod + def _within_push_window_utc8(enabled: bool) -> bool: + if not enabled: + return True + now_utc = datetime.now(timezone.utc) + bj_hour = (now_utc.hour + 8) % 24 + return 9 <= bj_hour < 23 + + async def run_cycle(self) -> None: + funnel_candidates: list[dict] = [] + async with self._lock: + try: + funnel_candidates = await self._run_cycle_inner() + self.state.last_cycle_status = "OK" + self.state.last_cycle_msg = "cycle_completed" + except Exception as exc: # noqa: BLE001 + msg = f"cycle_failed: {exc}" + self.state.last_cycle_status = "ERROR" + self.state.last_cycle_msg = msg + LOGGER.exception(msg) + await self.storage.add_log("ERROR", msg) + funnel_candidates = [] + finally: + # 本轮扫描结束后刷新,避免 HUD「LAST」与墙钟脱节(原先在周期开始时写入) + self.state.last_cycle_at = datetime.now(timezone.utc).isoformat() + + if self.gemma_client and self.settings.gemma.enabled and funnel_candidates: + if self._funnel_bg_task is not None and not self._funnel_bg_task.done(): + await self.storage.add_log( + "WARN", + f"funnel_skipped_previous_still_running candidates={len(funnel_candidates)}", + ) + else: + self._funnel_bg_task = asyncio.create_task( + self._run_gemma_funnel_safe(funnel_candidates), + name="gemma_funnel", + ) + + async def _run_gemma_funnel_safe(self, candidates: list[dict]) -> None: + try: + await self._run_gemma_funnel(candidates) + except Exception as exc: # noqa: BLE001 + msg = f"funnel_failed: {exc}" + LOGGER.exception(msg) + await self.storage.add_log("ERROR", msg) + async with self._lock: + self.state.gemma_cycle_msg = f"funnel_failed: {exc!s}"[:500] + + async def _run_cycle_inner(self) -> list[dict]: + bar = FIXED_BAR + self.state.chart_bar = bar + + universe = self.settings.monitor.universe + self.state.universe = universe + rule_params = await self._load_intraday_params() + stop_buffer_pct = _as_float(await self.storage.get_kv("intraday_stop_buffer_pct"), 0.2) + stop_buffer_pct = max(0.0, min(stop_buffer_pct, 10.0)) + self.state.intraday_params = { + "range_hours": rule_params.range_hours, + "range_max_pct": rule_params.range_max_pct, + "volume_spike_mult": rule_params.volume_spike_mult, + "volume_lookback_bars": rule_params.volume_lookback_bars, + "breakout_buffer_pct": rule_params.breakout_buffer_pct, + "stop_buffer_pct": stop_buffer_pct, + } + + blocklist = self._symbol_blocklist_from_kv(await self.storage.get_kv("monitor_symbol_blocklist")) + self.state.symbol_blocklist_count = len(blocklist) + self.state.symbol_blocklist_removed = 0 + + listed_bases = await self.gate.get_perpetual_symbols() + self.state.perpetual_symbols_count = len(listed_bases) + + min_vol = float(self.settings.monitor.min_24h_quote_volume_usdt) + vol_map: dict[str, float] = {} + watch_insts: list[str] = [] + + if universe == "all_swaps": + if min_vol <= 0: + await self.storage.add_log( + "ERROR", + "all_swaps requires monitor.min_24h_quote_volume_usdt > 0; skipping cycle", + ) + self.state.monitoring_pool = [] + self.state.monitored_inst_count = 0 + return [] + vol_map = await self.gate.get_usdt_swap_est_quote_volume_map() + all_ids = await self.gate.list_live_usdt_swap_inst_ids() + watch_insts = [i for i in all_ids if vol_map.get(i, 0.0) >= min_vol] + await self.storage.add_log( + "INFO", + f"universe=all_swaps bar={bar} min_usdt={min_vol:.0f} pool={len(watch_insts)}/{len(all_ids)}", + ) + else: + watchlist = [w for w in self.settings.watch_symbols if w.symbol.upper() in listed_bases] + if min_vol > 0: + vol_map = await self.gate.get_usdt_swap_est_quote_volume_map() + before = len(watchlist) + kept: list[WatchSymbol] = [] + for w in watchlist: + inst = self.gate.symbol_to_swap_inst_id(w.symbol) + est = vol_map.get(inst, 0.0) + if est >= min_vol: + kept.append(w) + else: + await self.storage.add_log( + "INFO", + f"{w.symbol.upper()} skipped_24h_vol est_usdt={est:.0f} < min={min_vol:.0f}", + ) + watchlist = kept + await self.storage.add_log( + "INFO", + f"universe=watchlist volume_filter min_usdt={min_vol:.0f} kept={len(watchlist)}/{before}", + ) + watch_insts = [self.gate.symbol_to_swap_inst_id(w.symbol) for w in watchlist] + await self.storage.add_log( + "INFO", + f"universe=watchlist bar={bar} pool={len(watch_insts)} gate_bases={len(listed_bases)}", + ) + + if blocklist: + before_bl = len(watch_insts) + watch_insts = [ + i for i in watch_insts if self.gate.inst_id_to_base_symbol(i) not in blocklist + ] + removed = before_bl - len(watch_insts) + self.state.symbol_blocklist_removed = removed + if removed: + await self.storage.add_log( + "INFO", + f"symbol_blocklist removed={removed} pool_now={len(watch_insts)} rules={len(blocklist)}", + ) + + self.state.monitored_inst_count = len(watch_insts) + push_window_enabled = _as_bool(await self.storage.get_kv("intraday_push_time_window_enabled"), True) + vol_rank_map: dict[str, int] = {} + vol_rank_total = len(watch_insts) + if vol_map and watch_insts: + sorted_insts = sorted(watch_insts, key=lambda x: float(vol_map.get(x, 0.0)), reverse=True) + vol_rank_map = {inst_id: idx + 1 for idx, inst_id in enumerate(sorted_insts)} + self.state.monitoring_pool = [] + for inst in watch_insts: + sym = self.gate.inst_id_to_base_symbol(inst) + entry: dict = {"symbol": sym, "instId": inst} + if vol_map: + entry["est_quote_vol_24h_usdt"] = round(vol_map.get(inst, 0.0), 2) + self.state.monitoring_pool.append(entry) + + btc_inst = self.gate.symbol_to_swap_inst_id("BTC") + if self.settings.monitor.btc_daily_gate_enabled: + btc_1d = await self.gate.get_candles(btc_inst, "1D", limit=60) + gate = evaluate_btc_daily_gate( + btc_1d, + sideways_lookback_days=self.settings.monitor.btc_sideways_lookback_days, + sideways_max_range_pct=self.settings.monitor.btc_sideways_max_range_pct, + ) + self.state.btc_gate_allow = gate.allow_alt_scan + self.state.btc_gate_regime = gate.regime + self.state.btc_gate_reason = gate.reason + self.state.btc_gate_metrics = dict(gate.metrics) + if not gate.allow_alt_scan: + await self.storage.add_log( + "INFO", + ( + f"btc_daily_gate regime={gate.regime} reason={gate.reason} " + f"(informational only; scan continues) metrics={gate.metrics}" + ), + ) + else: + self.state.btc_gate_allow = True + self.state.btc_gate_regime = "disabled" + self.state.btc_gate_reason = "btc_daily_gate_enabled=false" + self.state.btc_gate_metrics = {} + + btc_rows = await self.gate.get_candles(btc_inst, bar, limit=120) + btc_bias_5m = self._btc_intraday_bias(btc_rows) + btc_15m_rows = await self.gate.get_candles(btc_inst, "15m", limit=120) + btc_env_8h_15m = self._btc_env_15m_last_8h(btc_15m_rows) + self.state.btc_env_8h_15m = btc_env_8h_15m + await self.storage.add_log( + "INFO", + f"btc_intraday_bias_5m={btc_bias_5m} btc_env_8h_15m={btc_env_8h_15m}", + ) + + funnel_candidates: list[dict] = [] + for inst in watch_insts: + sym = self.gate.inst_id_to_base_symbol(inst) + try: + alt_rows = await self.gate.get_candles(inst, bar, limit=120) + except Exception as exc: # noqa: BLE001 + await self.storage.add_log("WARN", f"{sym} candles_failed: {exc}") + continue + + result = evaluate_exchange(sym, alt_rows, btc_rows, rule_params) + if result.signal_level in {"WATCH", "TRIGGER"}: + est_vol = float(vol_map.get(inst, 0.0)) if vol_map else 0.0 + signal_side = str((result.metrics or {}).get("signal_side") or result.signal_side or "NONE") + push_allowed = result.signal_level == "TRIGGER" + funnel_candidates.append( + { + "symbol": sym, + "inst": inst, + "est_vol": est_vol, + "est_vol_rank": int(vol_rank_map.get(inst, 0)) if vol_rank_map else 0, + "est_vol_rank_total": int(vol_rank_total), + "signal_level": result.signal_level, + "signal_side": signal_side, + "btc_bias_5m": btc_bias_5m, + "push_allowed": push_allowed, + "btc_env_8h_15m": btc_env_8h_15m, + "intraday_metrics": dict(result.metrics), + } + ) + dedupe_h = float(self.settings.monitor.symbol_signal_dedupe_hours) + chain_suffix = signal_side if signal_side in {"LONG", "SHORT"} else "NONE" + surface_chain = f"GATE-USDT {bar} {result.signal_level} {chain_suffix}" + skip_surface_alert = dedupe_h > 0 and await self.storage.has_recent_alert( + sym, chain=surface_chain, within_hours=dedupe_h + ) + if not skip_surface_alert: + symbol_4h_status = "横盘" + symbol_side_ok = False + push_time_ok = True + vol_rank_ok = True + rank_max = int(getattr(self.settings.monitor, "wecom_push_max_volume_rank", 0) or 0) + if result.signal_level == "TRIGGER": + try: + sym_4h_rows = await self.gate.get_candles(inst, "4H", limit=120) + symbol_4h_status = self._status_by_ema55(sym_4h_rows) + except Exception as exc: # noqa: BLE001 + await self.storage.add_log("WARN", f"{sym} 4h_status_failed: {exc}") + symbol_side_ok = (signal_side == "LONG" and symbol_4h_status == "多头") or ( + signal_side == "SHORT" and symbol_4h_status == "空头" + ) + push_time_ok = self._within_push_window_utc8(push_window_enabled) + if rank_max > 0: + if vol_rank_map: + rnk = int(vol_rank_map.get(inst, 999)) + vol_rank_ok = 1 <= rnk <= rank_max + else: + vol_rank_ok = False + btc_env_ok = self._push_matches_btc_env(btc_env_8h_15m, signal_side) + strict_push_ok = bool( + push_allowed and symbol_side_ok and push_time_ok and vol_rank_ok and btc_env_ok + ) + push_reason = "trigger_pushed" + if result.signal_level == "TRIGGER" and not strict_push_ok: + reasons: list[str] = [] + if not btc_env_ok: + reasons.append("btc_env_8h_15m_direction_mismatch") + if not symbol_side_ok: + reasons.append("symbol_4h_not_aligned") + if not push_time_ok: + reasons.append("outside_push_time_window") + if not vol_rank_ok: + reasons.append("volume_rank_outside_top_n") + push_reason = ",".join(reasons) if reasons else "filtered_by_rules" + await self.storage.add_alert( + symbol=sym, + venue=surface_chain, + trigger_types=result.trigger_types, + score=result.score, + details={ + "metrics": result.metrics, + "instId": inst, + "signal_level": result.signal_level, + "signal_side": signal_side, + "btc_bias_5m": btc_bias_5m, + "push_allowed": push_allowed, + "btc_env_8h_15m": btc_env_8h_15m, + "btc_env_ok": btc_env_ok, + "symbol_4h_status": symbol_4h_status, + "push_time_ok": push_time_ok, + "vol_rank_ok": vol_rank_ok, + "strict_push_ok": strict_push_ok, + "push_block_reason": push_reason, + }, + ) + if result.signal_level == "TRIGGER": + if strict_push_ok: + push_metrics = dict(result.metrics) + push_metrics["signal_side"] = signal_side + push_metrics["btc_bias"] = btc_bias_5m + push_metrics["btc_env_8h_15m"] = btc_env_8h_15m + push_metrics["symbol_4h_status"] = symbol_4h_status + push_metrics["est_quote_vol_24h_usdt"] = est_vol + push_metrics["est_quote_vol_rank"] = int(vol_rank_map.get(inst, 0)) + push_metrics["est_quote_vol_rank_total"] = int(vol_rank_total) + push_metrics["stop_buffer_pct"] = stop_buffer_pct + try: + await self.notifier.send_breakout_alert( + symbol=sym, + bar=bar, + inst_id=inst, + trigger_types=result.trigger_types, + metrics=push_metrics, + ) + except Exception as exc: # noqa: BLE001 + await self.storage.add_log("ERROR", f"wecom_push_failed {sym}: {exc}") + else: + self.state.pushed_alerts_count += 1 + await self._maybe_forward_order_executor(sym, inst, push_metrics) + else: + await self.storage.add_log( + "INFO", + ( + f"signal_blocked sym={sym} side={signal_side} btc_bias={btc_bias_5m} " + f"btc_env_8h_15m={btc_env_8h_15m} btc_env_ok={btc_env_ok} " + f"sym_4h={symbol_4h_status} symbol_side_ok={symbol_side_ok} " + f"push_time_ok={push_time_ok} vol_rank_ok={vol_rank_ok} " + f"rank={vol_rank_map.get(inst, 0)}/{rank_max}" + ), + ) + await self.storage.add_log( + "WARN", + ( + f"signal={result.signal_level} side={signal_side} {sym} bar={bar} " + f"push_allowed={push_allowed} triggers={','.join(result.trigger_types)}" + ), + ) + else: + await self.storage.add_log( + "INFO", + f"signal_dedupe_skip sym={sym} chain={surface_chain} within_h={dedupe_h}", + ) + + await asyncio.sleep(0.08) + + if not self.settings.gemma.enabled: + self.state.last_funnel = [] + self.state.gemma_cycle_msg = "gemma_disabled" + elif not self.gemma_client: + self.state.last_funnel = [] + self.state.gemma_cycle_msg = "gemma_client_none" + elif not funnel_candidates: + self.state.last_funnel = [] + self.state.gemma_cycle_msg = "no_funnel_candidates" + else: + self.state.gemma_cycle_msg = "funnel_pending" + + await self.storage.add_log( + "INFO", + f"cycle_scan_done monitored={len(watch_insts)} funnel_candidates={len(funnel_candidates)}", + ) + return funnel_candidates + + async def _run_gemma_funnel(self, candidates: list[dict]) -> None: + assert self.gemma_client is not None + cfg = self.settings.gemma + candidates.sort(key=lambda x: float(x.get("est_vol") or 0.0), reverse=True) + take = candidates[: max(1, cfg.max_funnel_per_cycle)] + out: list[dict] = [] + dedupe_h = float(self.settings.monitor.symbol_signal_dedupe_hours) + for i, c in enumerate(take): + sym = str(c["symbol"]) + inst = str(c["inst"]) + est = float(c.get("est_vol") or 0.0) + if dedupe_h > 0 and await self.storage.has_recent_alert( + sym, chain="FUNNEL-GEMMA", within_hours=dedupe_h + ): + await self.storage.add_log("INFO", f"funnel_dedupe_skip sym={sym} within_h={dedupe_h}") + continue + try: + rows_1d = await self.gate.get_candles(inst, "1D", limit=80) + except Exception as exc: # noqa: BLE001 + await self.storage.add_log("WARN", f"funnel {sym} 1d_failed: {exc}") + continue + + prog = build_daily_programmatic(rows_1d, est) + subs = programmatic_scores(prog) + prog_text = json.dumps({**prog, **subs}, ensure_ascii=False) + ohlc_block = daily_ohlc_text_block(rows_1d) + img_b64: str | None = None + if cfg.send_chart_image and i < max(0, cfg.vision_top_n): + img_b64 = daily_candles_png_base64(rows_1d, sym) + + try: + gemma_out = await self.gemma_client.rank_funnel(sym, prog_text, ohlc_block, img_b64) + except Exception as exc: # noqa: BLE001 + await self.storage.add_log("ERROR", f"gemma_ollama_failed {sym}: {exc}") + gemma_out = { + "daily_structure": "weak", + "volume_view": "low", + "upside_space": "low", + "mid_resistance": "high", + "priority": 1, + "one_liner": f"Ollama 调用失败: {exc}", + "error": str(exc), + } + + pri = float(gemma_out.get("priority", 1)) + comp = composite_score(pri, subs) + signal_side = str(c.get("signal_side") or "NONE") + btc_bias_5m = str(c.get("btc_bias_5m") or "NEUTRAL") + btc_env_8h_15m = str(c.get("btc_env_8h_15m") or "横盘") + btc_env_ok = self._push_matches_btc_env(btc_env_8h_15m, signal_side) + threshold_ok = pri >= cfg.gemma_push_priority_min or comp >= cfg.composite_push_min + rank_max_f = int(getattr(self.settings.monitor, "wecom_push_max_volume_rank", 0) or 0) + vol_rank_ok_f = True + if rank_max_f > 0: + rnk_f = int(c.get("est_vol_rank") or 999) + vol_rank_ok_f = 1 <= rnk_f <= rank_max_f + should_push = btc_env_ok and threshold_ok and vol_rank_ok_f + + gemma_clean = {k: v for k, v in gemma_out.items() if k not in {"raw", "error"}} + details: dict = { + "source": "gemma_funnel", + "underlying_signal": c.get("signal_level"), + "signal_side": signal_side, + "btc_bias_5m": btc_bias_5m, + "btc_env_8h_15m": btc_env_8h_15m, + "gemma": gemma_clean, + "programmatic": prog, + "programmatic_subscores": subs, + "composite_score": comp, + "priority_push": should_push, + "priority_threshold_ok": threshold_ok, + "btc_env_ok": btc_env_ok, + "volume_rank_ok": vol_rank_ok_f, + "instId": inst, + "image_sent": bool(img_b64), + "intraday_signal_metrics": c.get("intraday_metrics"), + } + if gemma_out.get("error"): + details["gemma_error"] = str(gemma_out.get("error"))[:500] + raw_snip = gemma_out.get("raw") + if isinstance(raw_snip, str) and raw_snip: + details["gemma_raw_snip"] = raw_snip[:800] + + await self.storage.add_alert( + symbol=sym, + venue="FUNNEL-GEMMA", + trigger_types=["漏斗", f"P{int(pri)}", str(gemma_out.get("daily_structure", "?"))], + score=comp, + details=details, + ) + + if should_push: + try: + await self.notifier.send_funnel_priority( + symbol=sym, + inst_id=inst, + composite_score=comp, + gemma=gemma_clean, + programmatic=prog, + ) + self.state.pushed_alerts_count += 1 + await self.storage.add_log( + "WARN", + f"funnel_priority_push {sym} composite={comp} priority={pri}", + ) + except Exception as exc: # noqa: BLE001 + await self.storage.add_log("ERROR", f"funnel_wecom_push_failed {sym}: {exc}") + + out.append( + { + "symbol": sym, + "composite_score": comp, + "gemma_priority": pri, + "signal_side": signal_side, + "btc_bias_5m": btc_bias_5m, + "pushed": should_push, + "one_liner": gemma_clean.get("one_liner", ""), + } + ) + await asyncio.sleep(0.35) + + out.sort(key=lambda x: float(x.get("composite_score") or 0.0), reverse=True) + msg = f"funnel_ranked={len(out)}" + async with self._lock: + self.state.last_funnel = out[:40] + self.state.last_funnel_at = datetime.now(timezone.utc).isoformat() + self.state.gemma_cycle_msg = msg + await self.storage.add_log("INFO", msg) + + async def _load_intraday_params(self) -> IntradayRuleParams: + range_hours = _as_float(await self.storage.get_kv("intraday_range_hours"), 24.0) + range_max_pct = _as_float(await self.storage.get_kv("intraday_range_max_pct"), 1.5) + volume_spike_mult = _as_float(await self.storage.get_kv("intraday_volume_spike_mult"), 1.6) + volume_lookback_bars = int(_as_float(await self.storage.get_kv("intraday_volume_lookback_bars"), 20)) + breakout_buffer_pct = _as_float(await self.storage.get_kv("intraday_breakout_buffer_pct"), 0.05) + return IntradayRuleParams( + range_hours=max(1.0, range_hours), + range_max_pct=max(0.1, range_max_pct), + volume_spike_mult=max(1.0, volume_spike_mult), + volume_lookback_bars=max(5, volume_lookback_bars), + breakout_buffer_pct=max(0.0, breakout_buffer_pct), + ) + +def _as_float(raw: str | None, default: float) -> float: + try: + return float(raw) if raw is not None else default + except (TypeError, ValueError): + return default + + +def _as_bool(raw: str | None, default: bool) -> bool: + if raw is None: + return default + return str(raw).strip().lower() in {"1", "true", "yes", "y", "on"} + + diff --git a/onchain_scout_gate/app/notifier.py b/onchain_scout_gate/app/notifier.py new file mode 100644 index 0000000..e03fa98 --- /dev/null +++ b/onchain_scout_gate/app/notifier.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +import httpx + +from .config import WeComConfig +from .proxy_util import httpx_proxy_url +from .time_cn import format_beijing_wall, utc_now + + +class WeComNotifier: + def __init__(self, conf: WeComConfig, proxy_url: str | None = None) -> None: + self.conf = conf + self._proxy = httpx_proxy_url(proxy_url.strip() if proxy_url and str(proxy_url).strip() else None) + self.timeout = httpx.Timeout(8.0, read=10.0) + + def _client_kwargs(self) -> dict: + if self._proxy: + return {"timeout": self.timeout, "proxy": self._proxy, "trust_env": False} + return {"timeout": self.timeout, "trust_env": True} + + async def send_breakout_alert( + self, + symbol: str, + bar: str, + inst_id: str, + trigger_types: list[str], + metrics: dict, + ) -> None: + sym_u = symbol.strip().upper() + pair_line = f"{sym_u}-USDT 永续" + bar_cn = "5分钟" if bar == "5m" else f"{bar}" + range_h = float(metrics.get("range_hours") or 8) + range_pct = float(metrics.get("range_pct") or 0) + vol_ratio = float(metrics.get("volume_ratio") or 0) + range_high = float(metrics.get("range_high") or 0.0) + range_low = float(metrics.get("range_low") or 0.0) + confirm_close = float(metrics.get("confirm_close") or metrics.get("last_close") or 0.0) + breakout_high = float(metrics.get("breakout_high") or 0.0) + breakout_low = float(metrics.get("breakout_low") or 0.0) + est_vol = float(metrics.get("est_quote_vol_24h_usdt") or 0.0) + est_vol_rank = int(metrics.get("est_quote_vol_rank") or 0) + est_vol_rank_total = int(metrics.get("est_quote_vol_rank_total") or 0) + btc_env_8h_15m = str(metrics.get("btc_env_8h_15m") or metrics.get("btc_8h_status") or "横盘") + symbol_4h_status = str(metrics.get("symbol_4h_status") or "横盘") + + def _px(x: float) -> str: + s = f"{x:.8f}".rstrip("0").rstrip(".") + return s or "0" + + signal_side = str(metrics.get("signal_side") or "NONE") + signal_cn = "多头突破" if signal_side == "LONG" else ("空头破位" if signal_side == "SHORT" else "方向未定") + dir_line = "做多突破" if signal_side == "LONG" else ("做空破位" if signal_side == "SHORT" else "方向未定") + move_line = "放量上破" if signal_side == "LONG" else ("放量下破" if signal_side == "SHORT" else "等待确认") + state_line = f"{range_h:g}小时横盘箱体 {move_line}" + vol24_line = f"{est_vol:,.0f} USDT" if est_vol > 0 else "未知" + rank_line = ( + f"#{est_vol_rank} / {est_vol_rank_total}" + if est_vol_rank > 0 and est_vol_rank_total > 0 + else "未知" + ) + key_ref = _px(range_low if signal_side == "LONG" else range_high) + stop_pct = float(metrics.get("stop_buffer_pct") or 0.2) + stop_pct = max(0.0, min(stop_pct, 10.0)) + long_m = 1.0 - stop_pct / 100.0 + short_m = 1.0 + stop_pct / 100.0 + stop_a = _px(breakout_low * long_m if signal_side == "LONG" else breakout_high * short_m) + stop_b = _px(range_low * long_m if signal_side == "LONG" else range_high * short_m) + if abs(stop_pct - round(stop_pct)) < 1e-9: + stop_pct_label = str(int(round(stop_pct))) + else: + stop_pct_label = f"{stop_pct:.4f}".rstrip("0").rstrip(".") or "0" + box_size = (range_high - range_low) + tp_a = _px(confirm_close + box_size if signal_side == "LONG" else confirm_close - box_size) + tp_b = _px(confirm_close + box_size * 1.5 if signal_side == "LONG" else confirm_close - box_size * 1.5) + t_cn = format_beijing_wall(utc_now()) + content = ( + "🚨 Gate 突破预警信号\n" + "━━━━━━━━━━━━━━\n" + f"🔹 交易对:{pair_line}\n" + f"⏱️ K线周期:{bar_cn}\n" + f"📊 行情状态:{state_line}\n" + f"🧭 信号方向:{dir_line}\n" + "✅ 确认条件:\n" + f" 1. 震荡幅度:{range_pct:.2f}%\n" + f" 2. 成交量放大:{vol_ratio:.2f} 倍\n" + f" 3. BTC 近8小时(15m):{btc_env_8h_15m}(横盘多空均可推送;涨→仅LONG;跌→仅SHORT)\n" + f" 4. 日成交量:{vol24_line}\n" + f" 5. 当日成交量排名:{rank_line}\n" + f" 6. 本币种4h状态:{symbol_4h_status}(仅同向推送)\n" + "📌 关键价位:\n" + f" {'箱体下沿' if signal_side == 'LONG' else '箱体上沿'}:{key_ref}\n" + f" 确认K收盘价:{_px(confirm_close)}\n" + "💡 操作提示:\n" + f" 1. 入场区间A:止盈 {signal_cn} 箱体1.0倍距离({tp_a}),止损 突破K高低点±{stop_pct_label}%({stop_a})\n" + f" 2. 入场区间B:止盈 {signal_cn} 箱体1.5倍距离({tp_b}),止损 箱体边沿±{stop_pct_label}%({stop_b})\n" + f"⏰ 触发时间:{t_cn}(北京时间 UTC+8)" + ) + payload = { + "msgtype": "text", + "text": { + "content": content, + "mentioned_mobile_list": self.conf.mentioned_mobile_list, + }, + } + async with httpx.AsyncClient(**self._client_kwargs()) as client: + resp = await client.post(self.conf.webhook, json=payload) + resp.raise_for_status() + + async def send_funnel_priority( + self, + symbol: str, + inst_id: str, + composite_score: float, + gemma: dict, + programmatic: dict, + ) -> None: + sym_u = symbol.strip().upper() + pair_line = f"{sym_u}-USDT 永续" + pri = gemma.get("priority", "?") + one = str(gemma.get("one_liner", "") or "").strip() + t_cn = format_beijing_wall(utc_now()) + + def _pg(key: str, default: str = "—") -> str: + v = programmatic.get(key) + if v is None: + return default + if isinstance(v, bool): + return "是" if v else "否" + if isinstance(v, (int, float)): + return f"{float(v):.6f}".rstrip("0").rstrip(".") or "0" + return str(v) + + vol24 = programmatic.get("est_quote_vol_24h_usdt") + vol24_s = f"{float(vol24):,.0f}" if isinstance(vol24, (int, float)) else str(vol24 or "—") + prog_lines = [ + f" · 现价:{_pg('last_close')}", + f" · 24h 估算成交额 USDT:{vol24_s}", + f" · 60日区间高 / 低:{_pg('range_60d_high')} / {_pg('range_60d_low')}", + f" · 区间振幅%(回看):{_pg('range_pct_lookback')}", + f" · 距区间上沿空间%:{_pg('upside_to_range_high_pct')}", + f" · 结构提示:{_pg('structure_hint')}", + f" · SMA20:{_pg('sma20')}", + ] + + content = ( + "🎯 MATRIX · 漏斗优先推送\n" + "━━━━━━━━━━━━━━\n" + f"🔹 交易对:{pair_line}\n" + f"🔗 合约 ID:{inst_id}\n" + f"📈 合成评分:{composite_score:.2f}\n" + "🧩 Gemma 分项:\n" + f" 优先级 P{pri}|结构 {gemma.get('daily_structure', '?')}|量 {gemma.get('volume_view', '?')}|" + f"上方 {gemma.get('upside_space', '?')}|中间阻力 {gemma.get('mid_resistance', '?')}\n" + "💬 一句话:\n" + f" {one}\n" + "📌 程序化摘录:\n" + + "\n".join(prog_lines) + + "\n" + "💡 操作提示:\n" + "仅结构信号,严格执行交易纪律+仓位管理\n" + f"⏰ 触发时间:{t_cn}(北京时间 UTC+8)" + ) + payload = { + "msgtype": "text", + "text": { + "content": content, + "mentioned_mobile_list": self.conf.mentioned_mobile_list, + }, + } + async with httpx.AsyncClient(**self._client_kwargs()) as client: + resp = await client.post(self.conf.webhook, json=payload) + resp.raise_for_status() + + async def send_daily_report(self, report: dict) -> None: + text = report.get("text") or {} + btc = report.get("btc") or {} + stats = report.get("stats") or {} + risk_lines = text.get("risk_points") or [] + risk_block = "\n".join([f" - {str(x)}" for x in risk_lines[:3]]) if risk_lines else " - 暂无" + content = ( + "🗞️ MATRIX 每日晨报\n" + "━━━━━━━━━━━━━━\n" + f"📅 复盘日期:{report.get('report_day_cn', '—')}\n" + f"🤖 AI 生成:{'是' if report.get('ai_used') else '否(规则回退)'}\n" + f"📈 BTC 方向:{btc.get('direction', '—')} | 日涨跌 {btc.get('day_change_pct', '—')}%\n" + f"🧭 方向说明:{text.get('btc_explain', '—')}\n" + f"📊 昨日统计:WATCH {stats.get('watch_count', 0)} / TRIGGER {stats.get('trigger_count', 0)} / 漏斗优先 {stats.get('funnel_push_count', 0)}\n" + f"📝 总结:{text.get('summary', '—')}\n" + f"⚠️ 风险点:\n{risk_block}\n" + f"🎯 执行提示:{text.get('action_hint', '—')}\n" + f"⏰ 生成时间:{report.get('generated_at_cn', format_beijing_wall(utc_now()))}(北京时间 UTC+8)" + ) + payload = { + "msgtype": "text", + "text": {"content": content, "mentioned_mobile_list": self.conf.mentioned_mobile_list}, + } + async with httpx.AsyncClient(**self._client_kwargs()) as client: + resp = await client.post(self.conf.webhook, json=payload) + resp.raise_for_status() diff --git a/onchain_scout_gate/app/order_executor_forward.py b/onchain_scout_gate/app/order_executor_forward.py new file mode 100644 index 0000000..58b177d --- /dev/null +++ b/onchain_scout_gate/app/order_executor_forward.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +import asyncio +import logging +import uuid +from typing import Any + +import httpx + +from .config import Settings + +logger = logging.getLogger(__name__) + + +def build_order_executor_payload(*, inst_id: str, metrics: dict) -> dict[str, Any] | None: + """ + 与企微突破文案「方案 A」一致:止盈 = 确认收盘 ± 1 倍箱宽;止损 = 突破 K 高低点外侧 stop_buffer_pct(默认 0.2%,与面板一致)。 + 返回 gate_order_executor POST /v1/signal 的 JSON;无法构造则 None。 + """ + signal_side = str(metrics.get("signal_side") or "NONE") + if signal_side not in ("LONG", "SHORT"): + return None + range_high = float(metrics.get("range_high") or 0.0) + range_low = float(metrics.get("range_low") or 0.0) + confirm_close = float(metrics.get("confirm_close") or metrics.get("last_close") or 0.0) + breakout_high = float(metrics.get("breakout_high") or 0.0) + breakout_low = float(metrics.get("breakout_low") or 0.0) + if confirm_close <= 0 or range_high <= range_low: + return None + box_size = range_high - range_low + stop_pct = float(metrics.get("stop_buffer_pct") or 0.2) + stop_pct = max(0.0, min(stop_pct, 10.0)) + long_m = 1.0 - stop_pct / 100.0 + short_m = 1.0 + stop_pct / 100.0 + if signal_side == "LONG": + stop_loss = breakout_low * long_m + take_profit = confirm_close + box_size + else: + stop_loss = breakout_high * short_m + take_profit = confirm_close - box_size + if take_profit <= 0 or stop_loss <= 0: + return None + side = "long" if signal_side == "LONG" else "short" + ct = inst_id.strip().upper() + signal_id = f"scout-{ct}-{uuid.uuid4().hex[:12]}" + return { + "signal_id": signal_id, + "contract": ct, + "side": side, + "take_profit": float(take_profit), + "stop_loss": float(stop_loss), + "reference_price": float(confirm_close), + } + + +async def _post_one_executor( + *, + name: str, + executor_id: str, + base_url: str, + webhook_secret: str, + timeout_seconds: float, + payload: dict[str, Any], +) -> dict[str, Any]: + url = base_url.rstrip("/") + "/v1/signal" + t = float(timeout_seconds) + timeout = httpx.Timeout(t, connect=min(10.0, t), read=t + 5.0) + try: + async with httpx.AsyncClient(timeout=timeout, trust_env=False, proxy=None) as client: + resp = await client.post( + url, + json=payload, + headers={ + "Content-Type": "application/json", + "X-Webhook-Secret": webhook_secret, + }, + ) + try: + body: Any = resp.json() + except Exception: # noqa: BLE001 + body = {"_raw": (resp.text or "")[:800]} + ok = resp.is_success + if not ok: + logger.warning( + "order_executor_http_error name=%s status=%s body=%s", + name, + resp.status_code, + body, + ) + exec_status = body.get("status") if isinstance(body, dict) else None + return { + "executor_id": executor_id, + "name": name, + "base_url": base_url, + "http_status": resp.status_code, + "body": body, + "ok": ok, + "exec_status": exec_status, + "error": None, + } + except Exception as exc: # noqa: BLE001 + logger.warning("order_executor_forward_exception name=%s: %s", name, exc) + return { + "executor_id": executor_id, + "name": name, + "base_url": base_url, + "http_status": 0, + "body": None, + "ok": False, + "exec_status": None, + "error": str(exc), + } + + +async def forward_signal_to_executors( + settings: Settings, + *, + executors: list[dict[str, Any]], + webhook_secret: str, + timeout_seconds: float, + payload: dict[str, Any], +) -> list[dict[str, Any]]: + """ + 向多个执行器广播同一 signal(直连 base_url,不走 proxy)。 + executors 每项需含 id、name、base_url。 + """ + secret = (webhook_secret or "").strip() + if not secret: + return [] + if not executors: + return [] + tasks = [ + _post_one_executor( + name=str(ex.get("name") or "executor"), + executor_id=str(ex.get("id") or ""), + base_url=str(ex.get("base_url") or ""), + webhook_secret=secret, + timeout_seconds=timeout_seconds, + payload=payload, + ) + for ex in executors + if (ex.get("base_url") or "").strip() + ] + if not tasks: + return [] + results = await asyncio.gather(*tasks) + return list(results) + + +async def forward_signal_after_wecom(settings: Settings, payload: dict[str, Any]) -> dict[str, Any]: + """ + 兼容旧调用:单执行器转发(读取 runtime 中第一个 enabled 目标)。 + 新代码请使用 forward_signal_to_executors + order_executors_store.read_forward_config。 + """ + from .order_executors_store import read_forward_config + + cfg = read_forward_config(settings) + rows = cfg.get("executors") or [] + if not cfg.get("enabled") or not rows: + return {"ok": False, "error": "no_active_executor", "results": []} + results = await forward_signal_to_executors( + settings, + executors=rows[:1], + webhook_secret=str(cfg.get("webhook_secret") or ""), + timeout_seconds=float(cfg.get("timeout_seconds") or 15.0), + payload=payload, + ) + one = results[0] if results else {} + return { + "http_status": one.get("http_status"), + "body": one.get("body"), + "ok": one.get("ok"), + "results": results, + } diff --git a/onchain_scout_gate/app/order_executors_store.py b/onchain_scout_gate/app/order_executors_store.py new file mode 100644 index 0000000..44a64d6 --- /dev/null +++ b/onchain_scout_gate/app/order_executors_store.py @@ -0,0 +1,272 @@ +"""执行器列表与转发全局设置:runtime/order_executors.json(仅扫描端维护,不支持执行器反向注册)。""" +from __future__ import annotations + +import json +import logging +import threading +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +from .config import Settings + +logger = logging.getLogger(__name__) + +_ROOT = Path(__file__).resolve().parent.parent +_STORE_PATH = _ROOT / "runtime" / "order_executors.json" +_lock = threading.Lock() + + +def _now_iso() -> str: + return datetime.now(timezone.utc).astimezone().isoformat() + + +def _read_unlocked() -> dict[str, Any] | None: + if not _STORE_PATH.is_file(): + return None + try: + raw = _STORE_PATH.read_text(encoding="utf-8").strip() + if not raw: + return None + data = json.loads(raw) + return data if isinstance(data, dict) else None + except (OSError, json.JSONDecodeError) as exc: + logger.warning("order_executors_read_failed: %s", exc) + return None + + +def _write_unlocked(data: dict[str, Any]) -> None: + _STORE_PATH.parent.mkdir(parents=True, exist_ok=True) + payload = json.dumps(data, indent=2, ensure_ascii=False) + "\n" + tmp = _STORE_PATH.with_suffix(".json.tmp") + tmp.write_text(payload, encoding="utf-8") + tmp.replace(_STORE_PATH) + + +def _default_from_settings(settings: Settings) -> dict[str, Any]: + oe = settings.order_executor + executors: list[dict[str, Any]] = [] + base = (oe.base_url or "").strip() + if base: + executors.append( + { + "id": str(uuid.uuid4()), + "name": "default", + "base_url": base.rstrip("/"), + "enabled": True, + "created_at": _now_iso(), + "updated_at": _now_iso(), + "last_forward": None, + } + ) + return { + "enabled": bool(oe.enabled), + "webhook_secret": str(oe.webhook_secret or ""), + "timeout_seconds": float(oe.timeout_seconds), + "executors": executors, + } + + +def ensure_store_initialized(settings: Settings) -> None: + """首次启动:从 config.yaml 的 order_executor 段导入;已有文件则不覆盖。""" + with _lock: + if _read_unlocked() is not None: + return + _write_unlocked(_default_from_settings(settings)) + logger.info("order_executors_store_initialized path=%s", _STORE_PATH) + + +def read_snapshot(settings: Settings) -> dict[str, Any]: + with _lock: + data = _read_unlocked() + if data is None: + ensure_store_initialized(settings) + with _lock: + data = _read_unlocked() + if data is None: + data = _default_from_settings(settings) + return _normalize_snapshot(data, settings) + + +def _normalize_snapshot(data: dict[str, Any], settings: Settings) -> dict[str, Any]: + oe = settings.order_executor + out: dict[str, Any] = { + "enabled": bool(data.get("enabled", oe.enabled)), + "webhook_secret": str(data.get("webhook_secret") if data.get("webhook_secret") is not None else oe.webhook_secret), + "timeout_seconds": float(data.get("timeout_seconds") or oe.timeout_seconds), + "executors": [], + } + raw_list = data.get("executors") + if isinstance(raw_list, list): + for row in raw_list: + if not isinstance(row, dict): + continue + eid = str(row.get("id") or "").strip() or str(uuid.uuid4()) + name = str(row.get("name") or "executor").strip() or "executor" + url = str(row.get("base_url") or "").strip().rstrip("/") + if not url: + continue + out["executors"].append( + { + "id": eid, + "name": name, + "base_url": url, + "enabled": bool(row.get("enabled", True)), + "created_at": row.get("created_at"), + "updated_at": row.get("updated_at"), + "last_forward": row.get("last_forward") if isinstance(row.get("last_forward"), dict) else None, + } + ) + return out + + +def active_executors(settings: Settings) -> list[dict[str, Any]]: + snap = read_snapshot(settings) + if not snap.get("enabled"): + return [] + return [e for e in snap.get("executors") or [] if e.get("enabled")] + + +def read_forward_config(settings: Settings) -> dict[str, Any]: + snap = read_snapshot(settings) + return { + "enabled": bool(snap.get("enabled")), + "webhook_secret": str(snap.get("webhook_secret") or "").strip(), + "timeout_seconds": float(snap.get("timeout_seconds") or settings.order_executor.timeout_seconds), + "executors": active_executors(settings), + } + + +def write_global_settings( + settings: Settings, + *, + enabled: bool | None = None, + webhook_secret: str | None = None, + timeout_seconds: float | None = None, +) -> dict[str, Any]: + with _lock: + snap = _normalize_snapshot(_read_unlocked() or _default_from_settings(settings), settings) + if enabled is not None: + snap["enabled"] = bool(enabled) + if webhook_secret is not None: + snap["webhook_secret"] = str(webhook_secret) + if timeout_seconds is not None: + lo, hi = 3.0, 120.0 + v = float(timeout_seconds) + if not (lo <= v <= hi): + raise ValueError(f"timeout_seconds must be in [{lo}, {hi}]") + snap["timeout_seconds"] = v + _write_unlocked(snap) + return read_snapshot(settings) + + +def _validate_base_url(base_url: str) -> str: + u = (base_url or "").strip().rstrip("/") + if not u: + raise ValueError("base_url_required") + p = urlparse(u) + if p.scheme not in ("http", "https") or not p.netloc: + raise ValueError("base_url_must_be_http_or_https") + return u + + +def add_executor( + settings: Settings, + *, + name: str, + base_url: str, + enabled: bool = True, +) -> dict[str, Any]: + nm = (name or "").strip() or "executor" + url = _validate_base_url(base_url) + row = { + "id": str(uuid.uuid4()), + "name": nm, + "base_url": url, + "enabled": bool(enabled), + "created_at": _now_iso(), + "updated_at": _now_iso(), + "last_forward": None, + } + with _lock: + snap = _normalize_snapshot(_read_unlocked() or _default_from_settings(settings), settings) + for ex in snap["executors"]: + if str(ex.get("base_url") or "").rstrip("/") == url: + raise ValueError("base_url_already_exists") + snap["executors"].append(row) + _write_unlocked(snap) + return row + + +def update_executor( + settings: Settings, + executor_id: str, + *, + name: str | None = None, + base_url: str | None = None, + enabled: bool | None = None, +) -> dict[str, Any]: + eid = (executor_id or "").strip() + if not eid: + raise ValueError("executor_id_required") + with _lock: + snap = _normalize_snapshot(_read_unlocked() or _default_from_settings(settings), settings) + found: dict[str, Any] | None = None + for ex in snap["executors"]: + if str(ex.get("id")) == eid: + found = ex + break + if not found: + raise ValueError("executor_not_found") + if name is not None: + found["name"] = (name or "").strip() or found.get("name") or "executor" + if base_url is not None: + url = _validate_base_url(base_url) + for ex in snap["executors"]: + if str(ex.get("id")) != eid and str(ex.get("base_url") or "").rstrip("/") == url: + raise ValueError("base_url_already_exists") + found["base_url"] = url + if enabled is not None: + found["enabled"] = bool(enabled) + found["updated_at"] = _now_iso() + _write_unlocked(snap) + return dict(found) + + +def delete_executor(settings: Settings, executor_id: str) -> None: + eid = (executor_id or "").strip() + with _lock: + snap = _normalize_snapshot(_read_unlocked() or _default_from_settings(settings), settings) + before = len(snap["executors"]) + snap["executors"] = [e for e in snap["executors"] if str(e.get("id")) != eid] + if len(snap["executors"]) == before: + raise ValueError("executor_not_found") + _write_unlocked(snap) + + +def record_last_forward( + settings: Settings, + executor_id: str, + *, + http_status: int, + ok: bool, + exec_status: str | None, + detail: str | None = None, +) -> None: + eid = (executor_id or "").strip() + with _lock: + snap = _normalize_snapshot(_read_unlocked() or _default_from_settings(settings), settings) + for ex in snap["executors"]: + if str(ex.get("id")) == eid: + ex["last_forward"] = { + "at": _now_iso(), + "http_status": int(http_status), + "ok": bool(ok), + "exec_status": exec_status, + "detail": (detail or "")[:500] or None, + } + ex["updated_at"] = _now_iso() + break + _write_unlocked(snap) diff --git a/onchain_scout_gate/app/proxy_util.py b/onchain_scout_gate/app/proxy_util.py new file mode 100644 index 0000000..ba04b1b --- /dev/null +++ b/onchain_scout_gate/app/proxy_util.py @@ -0,0 +1,16 @@ +"""代理 URL 与 httpx 的兼容处理。""" + + +def httpx_proxy_url(proxy_url: str | None) -> str | None: + """ + 将配置中的代理地址转为 httpx 可用的形式。 + + 部分环境(socksio / httpx)不支持 ``socks5h://`` scheme,会报 + ``Unknown scheme for proxy URL``;此时退化为 ``socks5://``(域名在本机解析后再走 SOCKS)。 + """ + if not proxy_url or not str(proxy_url).strip(): + return None + u = str(proxy_url).strip() + if u.startswith("socks5h://"): + return "socks5://" + u[len("socks5h://") :] + return u diff --git a/onchain_scout_gate/app/storage.py b/onchain_scout_gate/app/storage.py new file mode 100644 index 0000000..abc9ab9 --- /dev/null +++ b/onchain_scout_gate/app/storage.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import json +from datetime import datetime, timedelta + +from sqlalchemy import desc, select +from sqlalchemy.dialects.sqlite import insert as sqlite_insert +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from .models import AlertRecord, Base, KvStore, RuntimeLog + +DEFAULT_CHART_BAR = "1D" + + +class Storage: + def __init__(self, database_url: str) -> None: + self.engine = create_async_engine(database_url, pool_pre_ping=True) + self.session_factory = async_sessionmaker(self.engine, expire_on_commit=False, class_=AsyncSession) + + async def init_db(self) -> None: + async with self.engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + await self._ensure_default_kv() + + async def _ensure_default_kv(self) -> None: + current = await self.get_kv("chart_bar") + if current is None: + await self.set_kv("chart_bar", DEFAULT_CHART_BAR) + + async def get_kv(self, key: str) -> str | None: + async with self.session_factory() as session: + row = await session.get(KvStore, key) + return row.value if row else None + + async def set_kv(self, key: str, value: str) -> None: + async with self.session_factory() as session: + await session.execute( + sqlite_insert(KvStore) + .values(key=key, value=value, updated_at=datetime.utcnow()) + .on_conflict_do_update( + index_elements=["key"], + set_={"value": value, "updated_at": datetime.utcnow()}, + ) + ) + await session.commit() + + async def has_recent_alert( + self, + symbol: str, + *, + chain: str, + within_hours: float, + ) -> bool: + """同一 symbol + chain 在 within_hours 内是否已有告警(用于去重显示与推送)。""" + if within_hours <= 0: + return False + sym = symbol.strip().upper() + cutoff = datetime.utcnow() - timedelta(hours=within_hours) + async with self.session_factory() as session: + stmt = ( + select(AlertRecord.id) + .where( + AlertRecord.symbol == sym, + AlertRecord.chain == chain, + AlertRecord.created_at > cutoff, + ) + .limit(1) + ) + row = (await session.execute(stmt)).scalar_one_or_none() + return row is not None + + async def add_alert( + self, + symbol: str, + venue: str, + trigger_types: list[str], + score: float, + details: dict, + ) -> None: + async with self.session_factory() as session: + session.add( + AlertRecord( + symbol=symbol.strip().upper(), + chain=venue, + trigger_types=",".join(trigger_types), + score=score, + details_json=json.dumps(details, ensure_ascii=False), + ) + ) + await session.commit() + + async def add_log(self, level: str, message: str) -> None: + async with self.session_factory() as session: + session.add(RuntimeLog(level=level.upper(), message=message)) + await session.commit() + + async def get_recent_alerts(self, limit: int = 100) -> list[dict]: + async with self.session_factory() as session: + stmt = select(AlertRecord).order_by(desc(AlertRecord.created_at)).limit(limit) + rows = (await session.execute(stmt)).scalars().all() + return [ + { + "id": row.id, + "symbol": row.symbol, + "chain": row.chain, + "trigger_types": row.trigger_types.split(",") if row.trigger_types else [], + "score": row.score, + "details": json.loads(row.details_json), + "created_at": row.created_at.isoformat(), + } + for row in rows + ] + + async def get_recent_logs(self, limit: int = 200) -> list[dict]: + async with self.session_factory() as session: + stmt = select(RuntimeLog).order_by(desc(RuntimeLog.created_at)).limit(limit) + rows = (await session.execute(stmt)).scalars().all() + return [ + { + "id": row.id, + "level": row.level, + "message": row.message, + "created_at": row.created_at.isoformat(), + } + for row in rows + ] + + async def get_alerts_between( + self, + start_utc_naive: datetime, + end_utc_naive: datetime, + limit: int = 2000, + ) -> list[dict]: + async with self.session_factory() as session: + stmt = ( + select(AlertRecord) + .where(AlertRecord.created_at >= start_utc_naive, AlertRecord.created_at < end_utc_naive) + .order_by(desc(AlertRecord.created_at)) + .limit(limit) + ) + rows = (await session.execute(stmt)).scalars().all() + return [ + { + "id": row.id, + "symbol": row.symbol, + "chain": row.chain, + "trigger_types": row.trigger_types.split(",") if row.trigger_types else [], + "score": row.score, + "details": json.loads(row.details_json), + "created_at": row.created_at.isoformat(), + } + for row in rows + ] + + async def close(self) -> None: + await self.engine.dispose() diff --git a/onchain_scout_gate/app/time_cn.py b/onchain_scout_gate/app/time_cn.py new file mode 100644 index 0000000..b44b346 --- /dev/null +++ b/onchain_scout_gate/app/time_cn.py @@ -0,0 +1,21 @@ +"""北京时间(Asia/Shanghai)格式化,用于推送与展示。""" + +from __future__ import annotations + +from datetime import datetime, timezone +from zoneinfo import ZoneInfo + +_TZ_CN = ZoneInfo("Asia/Shanghai") + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def format_beijing_wall(dt: datetime | None = None) -> str: + """与微信示例一致:YYYY-MM-DD HH:MM(北京时间,无时区后缀)。""" + if dt is None: + dt = utc_now() + elif dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(_TZ_CN).strftime("%Y-%m-%d %H:%M") diff --git a/onchain_scout_gate/app/web.py b/onchain_scout_gate/app/web.py new file mode 100644 index 0000000..46cfd82 --- /dev/null +++ b/onchain_scout_gate/app/web.py @@ -0,0 +1,626 @@ +from __future__ import annotations + +import hashlib +import json +import logging +from pathlib import Path + +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from fastapi import Depends, FastAPI, Form, HTTPException, Request, status +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from starlette.middleware.gzip import GZipMiddleware +from starlette.middleware.sessions import SessionMiddleware + +from .config import Settings +from .daily_report import DailyReportService +from .gemma_client import OllamaGemmaClient +from .monitor import MonitorService +from .notifier import WeComNotifier +from .gate import GateClient +from .order_executors_store import ( + add_executor, + delete_executor, + ensure_store_initialized, + read_snapshot, + update_executor, + write_global_settings, +) +from .storage import Storage + +LOGGER = logging.getLogger("onchain_scout.web") +FIXED_BAR = "5m" +DAILY_REPORT_JOB_ID = "daily_report_job" + + +def _hash_password(plain: str) -> str: + return hashlib.sha256(plain.encode("utf-8")).hexdigest() + + +def _asset_version(root: Path) -> str: + """静态资源 ?v= 避免浏览器强缓存旧 app.js。""" + mt = 0 + for name in ("app.js", "style.css"): + try: + mt = max(mt, int((root / "static" / name).stat().st_mtime)) + except OSError: + continue + return str(mt or 1) + + +def _dedupe_funnel_alerts_by_symbol(alerts: list[dict]) -> list[dict]: + """同一币种只保留一条漏斗记录:优先保留 created_at 最新的(避免历史轮次堆叠)。""" + by_time = sorted(alerts, key=lambda x: str(x.get("created_at") or ""), reverse=True) + seen: set[str] = set() + out: list[dict] = [] + for a in by_time: + sym = (a.get("symbol") or "").strip().upper() + if not sym or sym in seen: + continue + seen.add(sym) + out.append(a) + return out + + +def _slim_monitor_state(state) -> dict: + """避免 monitoring_pool 全量下发(可达上千条),局域网面板极慢。""" + raw = dict(state.__dict__) + pool = list(raw.pop("monitoring_pool", []) or []) + raw["monitoring_pool_count"] = len(pool) + raw["monitoring_pool_preview"] = pool[:50] + return raw + + +def _parse_hhmm(raw: str) -> tuple[int, int]: + s = (raw or "").strip() + if ":" not in s: + return 8, 30 + hh, mm = s.split(":", 1) + try: + h = max(0, min(23, int(hh))) + m = max(0, min(59, int(mm))) + return h, m + except ValueError: + return 8, 30 + + +def _to_bool(raw: str | None, default: bool) -> bool: + if raw is None: + return default + return str(raw).strip().lower() in {"1", "true", "yes", "y", "on"} + + +def _normalize_manual_symbols(raw: object) -> list[str]: + if isinstance(raw, list): + text = "\n".join([str(x) for x in raw]) + else: + text = str(raw or "") + out: list[str] = [] + for token in text.replace(",", "\n").replace(";", "\n").splitlines(): + s = token.strip().upper() + if not s: + continue + if "_USDT" in s: + s = s.split("_USDT", 1)[0] + elif "-USDT-SWAP" in s: + s = s.split("-USDT-SWAP", 1)[0] + elif "-USDT" in s: + s = s.split("-USDT", 1)[0] + s = "".join(ch for ch in s if ch.isalnum()) + if not s: + continue + if s not in out: + out.append(s) + return out[:200] + + +def _normalize_symbol_token(raw: object) -> str: + s = str(raw or "").strip().upper() + if not s: + return "" + if "_USDT" in s: + s = s.split("_USDT", 1)[0] + elif "-USDT-SWAP" in s: + s = s.split("-USDT-SWAP", 1)[0] + elif "-USDT" in s: + s = s.split("-USDT", 1)[0] + s = "".join(ch for ch in s if ch.isalnum()) + return s + + +def create_app(settings: Settings) -> FastAPI: + def require_login(request: Request) -> None: + if not settings.auth.enabled: + return + if request.session.get("logged_in") is not True: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized") + + app = FastAPI(title="MATRIX FUNNEL", version="2.1.0") + app.add_middleware(GZipMiddleware, minimum_size=800) + app.add_middleware( + SessionMiddleware, + secret_key=settings.app.session_secret, + max_age=60 * 60 * 24 * 7, + same_site="lax", + https_only=False, + ) + root_dir = Path(__file__).resolve().parent.parent + templates = Jinja2Templates(directory=str(root_dir / "templates")) + app.mount("/static", StaticFiles(directory=str(root_dir / "static")), name="static") + + storage = Storage(settings.app.database_url) + proxy_url = settings.proxy.url if settings.proxy.enabled else None + gate_client = GateClient(settings.gate, proxy_url=proxy_url) + notifier = WeComNotifier(settings.wecom, proxy_url=None) + gemma_client = OllamaGemmaClient(settings.gemma) if settings.gemma.enabled else None + monitor = MonitorService( + settings=settings, + storage=storage, + gate_client=gate_client, + notifier=notifier, + gemma_client=gemma_client, + ) + daily_report = DailyReportService( + settings=settings, + storage=storage, + gate_client=gate_client, + notifier=notifier, + gemma_client=gemma_client, + ) + scheduler = AsyncIOScheduler(timezone="UTC") + + app.state.settings = settings + app.state.storage = storage + app.state.monitor = monitor + app.state.scheduler = scheduler + app.state.auth_user = settings.auth.username + app.state.auth_password_hash = _hash_password(settings.auth.password) + + @app.on_event("startup") + async def on_startup() -> None: + runtime_dir = Path(settings.app.log_file).resolve().parent + runtime_dir.mkdir(parents=True, exist_ok=True) + await storage.init_db() + ensure_store_initialized(settings) + await _ensure_runtime_defaults(storage) + monitor.state.chart_bar = FIXED_BAR + scheduler.add_job(monitor.run_cycle, "interval", seconds=settings.app.poll_interval_seconds, max_instances=1) + dr = await _get_daily_report_settings(storage, settings) + if dr["enabled"]: + hh, mm = _parse_hhmm(str(dr["run_time_cn"])) + scheduler.add_job( + daily_report.run_once, + "cron", + hour=hh, + minute=mm, + max_instances=1, + timezone="Asia/Shanghai", + id=DAILY_REPORT_JOB_ID, + replace_existing=True, + ) + scheduler.start() + await monitor.run_cycle() + if dr["enabled"] and dr["run_on_startup"]: + await daily_report.run_once() + await storage.add_log( + "INFO", + ( + f"service_started_gate_usdt gemma={'on' if settings.gemma.enabled else 'off'} " + f"proxy={'on ' + settings.proxy.url if settings.proxy.enabled else 'off'} " + f"web_login={'on' if settings.auth.enabled else 'off'} " + f"daily_report={'on' if settings.daily_report.enabled else 'off'}" + ), + ) + LOGGER.info("Service started") + + @app.on_event("shutdown") + async def on_shutdown() -> None: + scheduler.shutdown(wait=False) + await storage.add_log("INFO", "service_stopped") + await storage.close() + + @app.get("/", response_class=HTMLResponse) + async def root(request: Request) -> HTMLResponse: + if not settings.auth.enabled: + return RedirectResponse("/dashboard", status_code=302) + if request.session.get("logged_in") is True: + return RedirectResponse("/dashboard", status_code=302) + return RedirectResponse("/login", status_code=302) + + @app.get("/login", response_class=HTMLResponse) + async def login_page(request: Request) -> HTMLResponse: + if not settings.auth.enabled: + return RedirectResponse("/dashboard", status_code=302) + return templates.TemplateResponse("login.html", {"request": request, "error": ""}) + + @app.post("/login", response_class=HTMLResponse) + async def login_submit(request: Request, username: str = Form(...), password: str = Form(...)) -> HTMLResponse: + if not settings.auth.enabled: + return RedirectResponse("/dashboard", status_code=302) + ok_user = username == app.state.auth_user + ok_pass = _hash_password(password) == app.state.auth_password_hash + if ok_user and ok_pass: + request.session["logged_in"] = True + request.session["username"] = username + return RedirectResponse("/dashboard", status_code=302) + return templates.TemplateResponse("login.html", {"request": request, "error": "用户名或密码错误"}) + + @app.get("/logout") + async def logout(request: Request) -> RedirectResponse: + request.session.clear() + if not settings.auth.enabled: + return RedirectResponse("/dashboard", status_code=302) + return RedirectResponse("/login", status_code=302) + + @app.get("/dashboard", response_class=HTMLResponse) + async def dashboard(request: Request) -> HTMLResponse: + if settings.auth.enabled and request.session.get("logged_in") is not True: + return RedirectResponse("/login", status_code=302) + display_name = request.session.get("username") or settings.auth.username or "admin" + return templates.TemplateResponse( + "dashboard.html", + { + "request": request, + "username": display_name, + "asset_version": _asset_version(root_dir), + }, + ) + + @app.get("/api/status") + async def api_status(_: None = Depends(require_login)) -> JSONResponse: + intraday = await _get_intraday_settings(storage) + return JSONResponse( + { + "running": True, + "state": _slim_monitor_state(monitor.state), + "poll_interval_seconds": settings.app.poll_interval_seconds, + "chart_bar": FIXED_BAR, + "mode": "GATE_USDT_PERP", + "universe": settings.monitor.universe, + "intraday_settings": intraday, + "gemma_enabled": settings.gemma.enabled, + "gemma_model": settings.gemma.model, + } + ) + + @app.get("/api/settings") + async def api_settings_get(_: None = Depends(require_login)) -> JSONResponse: + intraday = await _get_intraday_settings(storage) + daily = await _get_daily_report_settings(storage, settings) + blocklist = await _get_symbol_blocklist_settings(storage) + return JSONResponse( + { + "chart_bar": FIXED_BAR, + "intraday_settings": intraday, + "daily_report_settings": daily, + "symbol_blocklist_settings": blocklist, + "order_executors": read_snapshot(settings), + } + ) + + @app.get("/api/order-executors") + async def api_order_executors_get(_: None = Depends(require_login)) -> JSONResponse: + return JSONResponse(read_snapshot(settings)) + + @app.put("/api/order-executors/settings") + async def api_order_executors_settings(request: Request, _: None = Depends(require_login)) -> JSONResponse: + body = await request.json() + try: + snap = write_global_settings( + settings, + enabled=body.get("enabled") if "enabled" in body else None, + webhook_secret=body.get("webhook_secret") if "webhook_secret" in body else None, + timeout_seconds=body.get("timeout_seconds") if "timeout_seconds" in body else None, + ) + except ValueError as exc: + return JSONResponse({"ok": False, "detail": str(exc)}, status_code=400) + await storage.add_log( + "INFO", + ( + "order_executors_settings_updated " + f"enabled={snap.get('enabled')} timeout={snap.get('timeout_seconds')} " + f"secret_set={bool((snap.get('webhook_secret') or '').strip())}" + ), + ) + return JSONResponse({"ok": True, "order_executors": snap}) + + @app.post("/api/order-executors") + async def api_order_executors_add(request: Request, _: None = Depends(require_login)) -> JSONResponse: + body = await request.json() + try: + row = add_executor( + settings, + name=str(body.get("name") or ""), + base_url=str(body.get("base_url") or ""), + enabled=bool(body.get("enabled", True)), + ) + except ValueError as exc: + return JSONResponse({"ok": False, "detail": str(exc)}, status_code=400) + await storage.add_log( + "INFO", + f"order_executor_added name={row.get('name')} url={row.get('base_url')}", + ) + return JSONResponse({"ok": True, "executor": row, "order_executors": read_snapshot(settings)}) + + @app.patch("/api/order-executors/{executor_id}") + async def api_order_executors_patch( + executor_id: str, request: Request, _: None = Depends(require_login) + ) -> JSONResponse: + body = await request.json() + try: + row = update_executor( + settings, + executor_id, + name=body.get("name") if "name" in body else None, + base_url=body.get("base_url") if "base_url" in body else None, + enabled=body.get("enabled") if "enabled" in body else None, + ) + except ValueError as exc: + code = 404 if str(exc) == "executor_not_found" else 400 + return JSONResponse({"ok": False, "detail": str(exc)}, status_code=code) + await storage.add_log("INFO", f"order_executor_updated id={executor_id} name={row.get('name')}") + return JSONResponse({"ok": True, "executor": row, "order_executors": read_snapshot(settings)}) + + @app.delete("/api/order-executors/{executor_id}") + async def api_order_executors_delete(executor_id: str, _: None = Depends(require_login)) -> JSONResponse: + try: + delete_executor(settings, executor_id) + except ValueError as exc: + return JSONResponse({"ok": False, "detail": str(exc)}, status_code=404) + await storage.add_log("INFO", f"order_executor_deleted id={executor_id}") + return JSONResponse({"ok": True, "order_executors": read_snapshot(settings)}) + + @app.post("/api/settings/intraday") + async def api_settings_intraday(request: Request, _: None = Depends(require_login)) -> JSONResponse: + body = await request.json() + range_hours = _must_float(body.get("range_hours"), "range_hours") + range_max_pct = _must_float(body.get("range_max_pct"), "range_max_pct") + volume_spike_mult = _must_float(body.get("volume_spike_mult"), "volume_spike_mult") + volume_lookback_bars = int(_must_float(body.get("volume_lookback_bars"), "volume_lookback_bars")) + breakout_buffer_pct = _must_float(body.get("breakout_buffer_pct"), "breakout_buffer_pct") + stop_buffer_pct = _must_float(body.get("stop_buffer_pct"), "stop_buffer_pct") + push_time_window_enabled = _to_bool(body.get("push_time_window_enabled"), True) + + if range_hours < 1: + raise HTTPException(status_code=400, detail="range_hours must be >= 1") + if range_max_pct <= 0: + raise HTTPException(status_code=400, detail="range_max_pct must be > 0") + if volume_spike_mult < 1: + raise HTTPException(status_code=400, detail="volume_spike_mult must be >= 1") + if volume_lookback_bars < 5: + raise HTTPException(status_code=400, detail="volume_lookback_bars must be >= 5") + if breakout_buffer_pct < 0: + raise HTTPException(status_code=400, detail="breakout_buffer_pct must be >= 0") + if stop_buffer_pct < 0 or stop_buffer_pct > 10: + raise HTTPException(status_code=400, detail="stop_buffer_pct must be between 0 and 10") + + await storage.set_kv("intraday_range_hours", str(range_hours)) + await storage.set_kv("intraday_range_max_pct", str(range_max_pct)) + await storage.set_kv("intraday_volume_spike_mult", str(volume_spike_mult)) + await storage.set_kv("intraday_volume_lookback_bars", str(volume_lookback_bars)) + await storage.set_kv("intraday_breakout_buffer_pct", str(breakout_buffer_pct)) + await storage.set_kv("intraday_stop_buffer_pct", str(stop_buffer_pct)) + await storage.set_kv("intraday_push_time_window_enabled", "1" if push_time_window_enabled else "0") + await storage.add_log( + "INFO", + ( + "intraday_settings_updated " + f"range_hours={range_hours} range_max_pct={range_max_pct} " + f"volume_spike_mult={volume_spike_mult} volume_lookback_bars={volume_lookback_bars} " + f"breakout_buffer_pct={breakout_buffer_pct} stop_buffer_pct={stop_buffer_pct} " + f"push_time_window_enabled={push_time_window_enabled}" + ), + ) + return JSONResponse({"ok": True, "intraday_settings": await _get_intraday_settings(storage)}) + + @app.post("/api/settings/daily-report") + async def api_settings_daily_report(request: Request, _: None = Depends(require_login)) -> JSONResponse: + body = await request.json() + enabled = bool(body.get("enabled", True)) + run_time_cn = str(body.get("run_time_cn") or "08:30").strip() + push_wecom = bool(body.get("push_wecom", True)) + run_on_startup = bool(body.get("run_on_startup", False)) + hh, mm = _parse_hhmm(run_time_cn) + run_time_cn = f"{hh:02d}:{mm:02d}" + + await storage.set_kv("daily_report_enabled", "1" if enabled else "0") + await storage.set_kv("daily_report_run_time_cn", run_time_cn) + await storage.set_kv("daily_report_push_wecom", "1" if push_wecom else "0") + await storage.set_kv("daily_report_run_on_startup", "1" if run_on_startup else "0") + + if scheduler.get_job(DAILY_REPORT_JOB_ID): + scheduler.remove_job(DAILY_REPORT_JOB_ID) + if enabled: + scheduler.add_job( + daily_report.run_once, + "cron", + hour=hh, + minute=mm, + max_instances=1, + timezone="Asia/Shanghai", + id=DAILY_REPORT_JOB_ID, + replace_existing=True, + ) + + daily = await _get_daily_report_settings(storage, settings) + await storage.add_log( + "INFO", + ( + "daily_report_settings_updated " + f"enabled={daily['enabled']} run_time_cn={daily['run_time_cn']} " + f"push_wecom={daily['push_wecom']} run_on_startup={daily['run_on_startup']}" + ), + ) + return JSONResponse({"ok": True, "daily_report_settings": daily}) + + @app.post("/api/settings/symbol-blocklist") + async def api_settings_symbol_blocklist(request: Request, _: None = Depends(require_login)) -> JSONResponse: + body = await request.json() + symbols = _normalize_manual_symbols(body.get("symbols_text", "")) + await storage.set_kv("monitor_symbol_blocklist", json.dumps(symbols, ensure_ascii=False)) + await storage.add_log( + "INFO", + f"symbol_blocklist_updated count={len(symbols)} symbols={','.join(symbols[:30])}{'…' if len(symbols) > 30 else ''}", + ) + return JSONResponse({"ok": True, "symbol_blocklist_settings": await _get_symbol_blocklist_settings(storage)}) + + @app.get("/api/alerts") + async def api_alerts(_: None = Depends(require_login)) -> JSONResponse: + alerts = await storage.get_recent_alerts(limit=120) + return JSONResponse({"items": alerts}) + + @app.get("/api/logs") + async def api_logs(_: None = Depends(require_login)) -> JSONResponse: + logs = await storage.get_recent_logs(limit=120) + return JSONResponse({"items": logs}) + + @app.get("/api/config") + async def api_config(_: None = Depends(require_login)) -> JSONResponse: + symbols = [{"symbol": w.symbol.upper()} for w in settings.watch_symbols] + g = settings.gemma + dr = await _get_daily_report_settings(storage, settings) + return JSONResponse( + { + "auth_enabled": settings.auth.enabled, + "host": settings.app.host, + "port": settings.app.port, + "poll_interval_seconds": settings.app.poll_interval_seconds, + "universe": settings.monitor.universe, + "min_24h_quote_volume_usdt": settings.monitor.min_24h_quote_volume_usdt, + "btc_daily_gate_enabled": settings.monitor.btc_daily_gate_enabled, + "btc_sideways_lookback_days": settings.monitor.btc_sideways_lookback_days, + "btc_sideways_max_range_pct": settings.monitor.btc_sideways_max_range_pct, + "symbol_signal_dedupe_hours": settings.monitor.symbol_signal_dedupe_hours, + "wecom_push_max_volume_rank": settings.monitor.wecom_push_max_volume_rank, + "gemma": { + "enabled": g.enabled, + "ollama_base_url": g.ollama_base_url, + "model": g.model, + "max_funnel_per_cycle": g.max_funnel_per_cycle, + "vision_top_n": g.vision_top_n, + "gemma_push_priority_min": g.gemma_push_priority_min, + "composite_push_min": g.composite_push_min, + }, + "daily_report": dr, + "proxy": { + "enabled": settings.proxy.enabled, + "url": settings.proxy.url if settings.proxy.enabled else "", + }, + "order_executor": read_snapshot(settings), + "watch_symbols": symbols, + } + ) + + @app.get("/api/funnel") + async def api_funnel(_: None = Depends(require_login)) -> JSONResponse: + alerts = await storage.get_recent_alerts(limit=500) + items = [a for a in alerts if (a.get("details") or {}).get("source") == "gemma_funnel"] + items = _dedupe_funnel_alerts_by_symbol(items) + items.sort( + key=lambda x: float((x.get("details") or {}).get("composite_score") or 0.0), + reverse=True, + ) + return JSONResponse({"items": items[:100]}) + + @app.get("/api/daily-report") + async def api_daily_report(_: None = Depends(require_login)) -> JSONResponse: + raw = await storage.get_kv("daily_report_latest") + if not raw: + return JSONResponse( + { + "ready": False, + "message": "晨报尚未生成。请等待定时任务,或开启 daily_report.run_on_startup。", + } + ) + try: + obj = json.loads(raw) + except json.JSONDecodeError: + return JSONResponse({"ready": False, "message": "晨报解析失败"}, status_code=500) + return JSONResponse({"ready": True, "report": obj}) + + @app.post("/api/daily-report/run") + async def api_daily_report_run(_: None = Depends(require_login)) -> JSONResponse: + dr = await _get_daily_report_settings(storage, settings) + if not dr["enabled"]: + return JSONResponse({"ok": False, "message": "daily_report.enabled=false"}, status_code=400) + report = await daily_report.run_once() + return JSONResponse({"ok": True, "report": report}) + + return app + + +async def _ensure_runtime_defaults(storage: Storage) -> None: + defaults = { + "intraday_range_hours": "12", + "intraday_range_max_pct": "2.0", + "intraday_volume_spike_mult": "1.6", + "intraday_volume_lookback_bars": "18", + "intraday_breakout_buffer_pct": "0.03", + "intraday_push_time_window_enabled": "1", + "intraday_stop_buffer_pct": "0.2", + "daily_report_enabled": "1", + "daily_report_run_time_cn": "08:30", + "daily_report_push_wecom": "1", + "daily_report_run_on_startup": "0", + } + for key, value in defaults.items(): + if await storage.get_kv(key) is None: + await storage.set_kv(key, value) + + +async def _get_intraday_settings(storage: Storage) -> dict: + return { + "range_hours": _to_float(await storage.get_kv("intraday_range_hours"), 24.0), + "range_max_pct": _to_float(await storage.get_kv("intraday_range_max_pct"), 1.5), + "volume_spike_mult": _to_float(await storage.get_kv("intraday_volume_spike_mult"), 1.6), + "volume_lookback_bars": int(_to_float(await storage.get_kv("intraday_volume_lookback_bars"), 20.0)), + "breakout_buffer_pct": _to_float(await storage.get_kv("intraday_breakout_buffer_pct"), 0.05), + "push_time_window_enabled": _to_bool(await storage.get_kv("intraday_push_time_window_enabled"), True), + "stop_buffer_pct": _to_float(await storage.get_kv("intraday_stop_buffer_pct"), 0.2), + } + + +async def _get_daily_report_settings(storage: Storage, settings: Settings) -> dict: + return { + "enabled": _to_bool(await storage.get_kv("daily_report_enabled"), settings.daily_report.enabled), + "run_time_cn": str(await storage.get_kv("daily_report_run_time_cn") or settings.daily_report.run_time_cn), + "push_wecom": _to_bool(await storage.get_kv("daily_report_push_wecom"), settings.daily_report.push_wecom), + "run_on_startup": _to_bool(await storage.get_kv("daily_report_run_on_startup"), settings.daily_report.run_on_startup), + } + + +async def _get_symbol_blocklist_settings(storage: Storage) -> dict: + raw = await storage.get_kv("monitor_symbol_blocklist") + symbols: list[str] = [] + if raw and str(raw).strip(): + try: + data = json.loads(raw) + if isinstance(data, list): + seen: set[str] = set() + for x in data: + s = str(x).strip().upper() + if s and s not in seen: + seen.add(s) + symbols.append(s) + except json.JSONDecodeError: + symbols = [] + return { + "symbols": symbols, + "symbols_text": "\n".join(symbols), + "count": len(symbols), + } + + +def _to_float(raw: str | None, default: float) -> float: + try: + return float(raw) if raw is not None else default + except (TypeError, ValueError): + return default + + +def _must_float(raw: object, name: str) -> float: + try: + return float(raw) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail=f"{name} must be a number") diff --git a/onchain_scout_gate/config.example.yaml b/onchain_scout_gate/config.example.yaml new file mode 100644 index 0000000..ae8db41 --- /dev/null +++ b/onchain_scout_gate/config.example.yaml @@ -0,0 +1,73 @@ +app: + host: "0.0.0.0" + port: 8088 + poll_interval_seconds: 300 + log_file: "./runtime/system.log" + database_url: "sqlite+aiosqlite:///./runtime/alerts.db" + session_secret: "change-me-to-a-long-random-string" + +auth: + # 纯局域网可设 false,打开 / 与 /dashboard 不再要求登录(勿在公网关闭) + enabled: true + username: "admin" + password: "Admin@123456" + +wecom: + webhook: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=replace" + mentioned_mobile_list: [] + +gate: + api_base: "https://api.gateio.ws/api/v4" + settle: "usdt" + quote_currency: "USDT" + +# 出站代理:仅 Gate 等外网 httpx;企业微信与本机/局域网 Ollama 不走代理。需 pip 安装 httpx[socks] +proxy: + enabled: true + url: "socks5h://127.0.0.1:1080" + +# 企微「突破预警」推送成功后,向 gate_order_executor POST /v1/signal(与微信同源条件;方案 A 止盈止损) +# 首次启动时从本段导入 runtime/order_executors.json;之后以 Web 面板「下单执行器」为准(可热增删,无需重启)。 +# 请求直连各 base_url,不走 proxy。webhook_secret 须与各执行器 security.webhook_secret 一致。 +order_executor: + enabled: false + base_url: "http://127.0.0.1:8090" + webhook_secret: "same-as-gate-order-executor-security-webhook_secret" + timeout_seconds: 15 + +monitor: + universe: "all_swaps" + # 近 24h 估算成交额(USDT)下限,建议 ≥ 1 千万以缩小扫描面 + min_24h_quote_volume_usdt: 10000000 + # 可选:记录 BTC 日线 regime 供面板参考;不再拦截山寨扫描(推送门控见 monitor 内 8h×15m + 本币4h) + btc_daily_gate_enabled: true + btc_sideways_lookback_days: 14 + btc_sideways_max_range_pct: 10.0 + # 同一币种对同一类告警链 4 小时内只入库 + 推送一次(WATCH/TRIGGER/漏斗分别计数);0 关闭 + symbol_signal_dedupe_hours: 4 + # 企业微信主推送:仅成交量排名前 N;0 表示不限制 + wecom_push_max_volume_rank: 30 + +# 仅在 universe=watchlist 时使用;all_swaps 下可留空列表 +watch_symbols: [] + +# 本地 Ollama + Gemma 漏斗(扫描命中 → 日线+图 → JSON 打分 → 高优先级企业微信) +gemma: + enabled: true + ollama_base_url: "http://192.168.8.64:11434" + model: "gemma4:e4b" + timeout_seconds: 180 + temperature: 0.15 + json_mode: true + send_chart_image: true + max_funnel_per_cycle: 12 + vision_top_n: 4 + gemma_push_priority_min: 7.0 + composite_push_min: 72.0 + +# 每日晨报:北京时间定时生成“昨天复盘 + BTC 方向”,展示于网页并可推送企业微信 +daily_report: + enabled: true + run_time_cn: "08:30" + push_wecom: true + run_on_startup: false diff --git a/onchain_scout_gate/deploy/bootstrap.sh b/onchain_scout_gate/deploy/bootstrap.sh new file mode 100644 index 0000000..0ed5f6b --- /dev/null +++ b/onchain_scout_gate/deploy/bootstrap.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +PROJECT_DIR="${1:-/root/onchain_scout_gate}" +cd "$PROJECT_DIR" + +python3 -m venv .venv +source .venv/bin/activate +pip install -U pip +pip install -r requirements.txt + +if [ ! -f config.yaml ]; then + cp config.example.yaml config.yaml + echo "config.yaml created from template, please edit keys/password before start." +fi + +mkdir -p runtime +echo "Bootstrap done." + diff --git a/onchain_scout_gate/deploy/ecosystem.config.cjs b/onchain_scout_gate/deploy/ecosystem.config.cjs new file mode 100644 index 0000000..cc5d08b --- /dev/null +++ b/onchain_scout_gate/deploy/ecosystem.config.cjs @@ -0,0 +1,37 @@ +/** + * PM2 守护 onchain_scout_gate(Gate USDT 永续行情扫描) + * + * 在项目根目录: + * pm2 start deploy/ecosystem.config.cjs + * pm2 logs onchain-scout + * + * 监听地址与端口来自 config.yaml → app.host / app.port(python -m app.main 内 uvicorn)。 + */ +const path = require("path"); +const ROOT = path.resolve(__dirname, ".."); +const isWin = process.platform === "win32"; +const py = path.join(ROOT, isWin ? path.join(".venv", "Scripts", "python.exe") : path.join(".venv", "bin", "python")); + +module.exports = { + apps: [ + { + name: "onchain-scout", + cwd: ROOT, + script: py, + args: ["-m", "app.main"], + interpreter: "none", + autorestart: true, + watch: false, + max_restarts: 15, + min_uptime: "10s", + exp_backoff_restart_delay: 2000, + error_file: path.join(ROOT, "runtime", "pm2-error.log"), + out_file: path.join(ROOT, "runtime", "pm2-out.log"), + merge_logs: true, + time: true, + env: { + PYTHONUNBUFFERED: "1", + }, + }, + ], +}; diff --git a/onchain_scout_gate/deploy/onchain-scout.service b/onchain_scout_gate/deploy/onchain-scout.service new file mode 100644 index 0000000..b3875cb --- /dev/null +++ b/onchain_scout_gate/deploy/onchain-scout.service @@ -0,0 +1,18 @@ +[Unit] +Description=Onchain Scout (Gate) via PM2 runtime +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=/root/onchain_scout_gate +Environment=NODE_ENV=production +Environment=PYTHONUNBUFFERED=1 +# 需全局安装 pm2:npm install -g pm2 +# 路径按实际安装位置调整(which pm2-runtime) +ExecStart=/usr/bin/pm2-runtime start deploy/ecosystem.config.cjs +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/onchain_scout_gate/deploy/start.sh b/onchain_scout_gate/deploy/start.sh new file mode 100644 index 0000000..74fa932 --- /dev/null +++ b/onchain_scout_gate/deploy/start.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# 前台调试:直接 uvicorn(无守护)。生产环境请用 PM2:pm2 start deploy/ecosystem.config.cjs +set -euo pipefail + +PROJECT_DIR="${1:-/root/onchain_scout_gate}" +cd "$PROJECT_DIR" +source .venv/bin/activate +exec uvicorn app.main:app --host 0.0.0.0 --port 8088 --workers 1 + diff --git a/onchain_scout_gate/docs/多执行器与信号转发归档.md b/onchain_scout_gate/docs/多执行器与信号转发归档.md new file mode 100644 index 0000000..05891fa --- /dev/null +++ b/onchain_scout_gate/docs/多执行器与信号转发归档.md @@ -0,0 +1,209 @@ +# 多执行器与信号转发 · 设计归档 + +本文档归档 2026-05 前后关于 **onchain_scout_gate(扫描端)** 与 **gate_order_executor(执行器)** 联动的讨论结论与已实现能力,便于后期检阅。 + +--- + +## 1. 背景与目标 + +| 目标 | 说明 | +|------|------| +| **一套信号** | 扫描端在企微突破推送成功后,构造 **一份** 方案 A 止盈/止损 payload | +| **多套账户** | 可向多个执行器进程广播,各绑不同 Gate API,用于盈亏比等规则的对照实验 | +| **规则在执行器** | 最低盈亏比、仓位、移动保本等 **不在扫描端** 区分,由各执行器自行配置 | +| **统一 Webhook** | 全系统使用 **同一个** `webhook_secret` | +| **仅扫描端登记** | 执行器列表 **只能** 在扫描端 Web 面板(及对应 API)维护,**禁止执行器反向注册** | + +--- + +## 2. 架构 + +```mermaid +flowchart LR + scout[onchain_scout_gate :8088] + scout -->|1 次| wecom[企业微信] + scout -->|同一 payload| exA[执行器 A] + scout -->|同一 payload| exB[执行器 B] + exA --> gateA[Gate 账户 A] + exB --> gateB[Gate 账户 B] +``` + +- 转发 **不走** 扫描端 `proxy`,直连各 `base_url`(通常 `http://127.0.0.1:8090` 或内网 IP)。 +- 各执行器独立进程、独立 `config.yaml`、独立 SQLite;互不通信。 + +--- + +## 3. 扫描端实现要点(已实现) + +### 3.1 持久化 + +| 文件 | 内容 | +|------|------| +| `runtime/order_executors.json` | 总开关、`webhook_secret`、`timeout_seconds`、执行器列表 | +| `config.yaml` `order_executor` | **仅冷启动**:首次无 json 时从 `base_url` / `enabled` / `secret` 导入一条 | + +之后以 **面板修改** 为准;改 `config.yaml` **不会** 覆盖已有 json。 + +### 3.2 执行器列表字段 + +| 字段 | 说明 | +|------|------| +| `id` | UUID | +| `name` | 展示名(日志、面板) | +| `base_url` | 如 `http://127.0.0.1:8090` | +| `enabled` | 单条开关 | +| `last_forward` | 最近一次转发结果(HTTP、exec_status) | + +### 3.3 HTTP API(需登录) + +| 方法 | 路径 | 作用 | +|------|------|------| +| GET | `/api/order-executors` | 读取完整配置 | +| PUT | `/api/order-executors/settings` | 总开关、webhook_secret、timeout | +| POST | `/api/order-executors` | 新增 | +| PATCH | `/api/order-executors/{id}` | 改名称/URL/启用 | +| DELETE | `/api/order-executors/{id}` | 删除 | + +### 3.4 转发逻辑 + +1. `build_order_executor_payload()` 仍只构建 **一次**(与企微方案 A 一致)。 +2. 对 `enabled=true` 的列表项 **并行** `POST {base_url}/v1/signal`。 +3. **同一 `signal_id`** 发往所有目标。 +4. 部分失败只记日志,不阻断其他执行器。 + +### 3.5 Web 面板 + +路径:Dashboard → **「下单执行器 · 转发链」** + +- 总开关、Webhook 密钥(可改)、超时 +- 添加 / 启用 / 停用 / 删除 +- 展示上次转发状态 + +### 3.6 代码模块 + +| 文件 | 职责 | +|------|------| +| `app/order_executors_store.py` | 读写 json、CRUD | +| `app/order_executor_forward.py` | 构建 payload、多路 POST | +| `app/monitor.py` | 企微成功后调用转发 | +| `app/web.py` | API + 启动时 `ensure_store_initialized` | + +--- + +## 4. 执行器侧(gate_order_executor) + +本次 **未改** 执行器业务代码。多账户 = 多实例部署: + +| 实例 | 典型差异 | +|------|----------| +| 目录/PM2 名 | 两份 `gate_order_executor` | +| `app.port` | 8090 / 8091 | +| `gate.api_key/secret` | 不同子账户 | +| `security.webhook_secret` | 与扫描端面板 **相同** | +| `risk.*`、移动保本 | 各实例自行实验 | + +另见执行器仓库已实现的 **移动保本**(1R 拉至开仓价±0.2%、面板开关、`breakeven_prefs` 等),与多路转发正交。 + +--- + +## 5. 部署套数怎么选 + +| 场景 | 面板操作 | +|------|----------| +| 单账户 | 列表 **1 条** URL | +| 双账户对照 | **2 条** URL,各指向不同端口/机器 | +| 临时只跑一套 | 另一条 `enabled: false` 或关总开关 | +| 完全停止自动下单 | 总开关 `enabled: false` | + +--- + +## 6. 盈亏比对照实验(用法) + +1. 扫描端产生同一 `signal_id`、同一 TP/SL。 +2. 执行器 A:`min_reward_risk_ratio = 1.3` → 可能 `accepted`。 +3. 执行器 B:`min_reward_risk_ratio = 1.8` → 可能 `skipped` / `reward_risk_below_min`。 +4. 分别在两个执行器面板「信号流」与 Gate 平仓统计中对比结果。 + +--- + +## 7. 云服务器关闭代理 + +### 7.1 何时关闭 + +- **本机 + 本地 SOCKS**:`proxy.enabled: true` +- **境外云、可直连 Gate**:`proxy.enabled: false` + +### 7.2 扫描端 + +```yaml +proxy: + enabled: false +``` + +仅影响 **Gate 行情**;企微、转发执行器本就直连。 + +### 7.3 执行器(每个实例) + +```yaml +proxy: + enabled: false +``` + +影响 Gate 下单/查仓及(若开启)企微出站。 + +### 7.4 自检 + +```bash +curl -I --max-time 15 https://api.gateio.ws +``` + +### 7.5 文档索引 + +- `onchain_scout_gate/交易系统部署说明.md` §7、§8 +- `onchain_scout_gate/docs/本地部署-SOCKS5代理.md`(本地 SOCKS) +- `gate_order_executor/docs/部署说明.md` §6.1 + +--- + +## 8. 日志关键字 + +扫描端运行日志(面板「运行日志」或 `runtime/system.log`): + +| 日志前缀 | 含义 | +|----------|------| +| `order_executor_ok name=...` | 该执行器 HTTP 成功 | +| `order_executor_failed name=...` | HTTP 或业务失败 | +| `order_executor_no_active_targets` | 总开关开但无启用条目 | +| `webhook_secret is empty` | 未配置密钥 | + +--- + +## 9. 安全与约束 + +- **Webhook 密钥** 在面板修改后,须手动同步到 **每一个** 执行器 `security.webhook_secret`。 +- **勿** 将执行器 `8090/8091` 对公网裸奔;建议仅本机或内网 + 防火墙。 +- 执行器 **不会** 也 **不能** 向扫描端注册;避免运维混乱。 + +--- + +## 10. 变更记录 + +| 日期 | 内容 | +|------|------| +| 2026-05 | 多执行器运行时存储、面板 CRUD、并行广播、部署与代理文档 | + +--- + +## 11. 相关路径速查 + +```text +onchain_scout_gate/ + runtime/order_executors.json # 执行器列表(面板写入) + app/order_executors_store.py + app/order_executor_forward.py + templates/dashboard.html # 「下单执行器」区块 + static/app.js + +gate_order_executor/ # 多实例部署,代码无需为多账户改动 + config.yaml # 每实例独立 API / risk / proxy +``` diff --git a/onchain_scout_gate/docs/本地部署-SOCKS5代理.md b/onchain_scout_gate/docs/本地部署-SOCKS5代理.md new file mode 100644 index 0000000..a6a3d68 --- /dev/null +++ b/onchain_scout_gate/docs/本地部署-SOCKS5代理.md @@ -0,0 +1,233 @@ +# 本地部署说明(含 SOCKS5 代理 `socks5h://127.0.0.1:1080`) + +> **云服务器部署**:若主机在境外且可直连 `api.gateio.ws`,请将 `config.yaml` 中 `proxy.enabled` 设为 **`false`**,无需 SOCKS。详见 [`交易系统部署说明.md`](../交易系统部署说明.md) §8 与 [`多执行器与信号转发归档.md`](./多执行器与信号转发归档.md) §7。 + +本文说明如何在**本机**部署 **onchain_scout_gate**(Gate USDT 永续监控 + 可选 Gemma 漏斗 + Web 看板),并在访问 Gate 行情、企业微信等外网接口时使用 **本地 SOCKS5 代理**。环境变量统一使用 `**socks5h://127.0.0.1:1080`**(**h** = 主机名在代理端解析,等同 curl 的 `socks5h`,推荐)。端口 `**1080`** 与 Clash / v2rayN / Sing-box 等本地 SOCKS 入站一致。 + +--- + +## 1. 前置条件 + +### 1.1 系统与软件 + + +| 项目 | 说明 | +| ------ | ---------------------------------------------------------------------------- | +| 操作系统 | Windows 10/11 或 Linux / macOS 均可 | +| Python | **3.10+**(推荐 3.11 / 3.12) | +| 代理客户端 | 本机已运行 **SOCKS5** 监听 `**127.0.0.1:1080`**(常见为 Clash / v2rayN 的「本地 SOCKS5 端口」) | +| 浏览器 | 用于打开 `http://127.0.0.1:8088`(或你在 `config.yaml` 中配置的端口) | + + +### 1.2 代理必须可用(自检) + +在启动本服务前,请先确认 **1080 端口 SOCKS5 已连通外网**(否则 Gate 行情请求会超时或 TLS 失败)。 + +**Windows PowerShell**(若已安装 `curl` 且 curl 支持 socks5h): + +```powershell +curl -x socks5h://127.0.0.1:1080 -I "https://api.gateio.ws" --max-time 15 +``` + +期望看到 HTTP 状态行(如 `HTTP/1.1 200` 或 `HTTP/2 302` 等),而不是长时间卡住或 `Connection refused`。 + +**说明**:`socks5h` 表示把 **DNS 也走代理**(推荐,避免 DNS 污染)。Python 侧下文使用等价思路。 + +--- + +## 2. 获取代码与目录 + +将仓库(或 `onchain_scout` 目录)放到本机任意路径,例如: + +- Windows:`C:\opt\onchain_scout` +- Linux:`/opt/onchain_scout` + +下文以 `**onchain_scout` 为项目根目录**(即包含 `config.yaml`、`requirements.txt`、`app/` 的那一层)。 + +--- + +## 3. Python 虚拟环境 + +### 3.1 Windows(PowerShell) + +```powershell +cd C:\opt\onchain_scout +python -m venv .venv +.\.venv\Scripts\Activate.ps1 +python -m pip install -U pip +pip install -r requirements.txt +``` + +若执行策略禁止激活脚本: + +```powershell +Set-ExecutionPolicy -Scope CurrentUser RemoteSigned +``` + +### 3.2 Linux / macOS + +```bash +cd /opt/onchain_scout +python3 -m venv .venv +source .venv/bin/activate +pip install -U pip +pip install -r requirements.txt +``` + +### 3.3 SOCKS5 与 httpx + +本项目通过 **httpx** 访问 Gate 行情(及按需的企业微信 Webhook)。走 **SOCKS5** 需要安装带 socks 依赖的 httpx。`requirements.txt` 中已使用: + +```text +httpx[socks]==0.27.2 +``` + +若你曾单独安装过无 extras 的 `httpx`,请在本虚拟环境中重新执行: + +```bash +pip install -r requirements.txt +``` + +--- + +## 4. 配置文件 `config.yaml` + +1. 若不存在,从示例复制: + ```bash + copy config.example.yaml config.yaml + ``` +2. 至少修改: + - `auth.username` / `auth.password`:登录 Web 看板用(生产环境请使用强密码)。 + - `wecom.webhook`:企业微信群机器人 Webhook(若暂不用推送,可先填占位,但**触发类推送**仍可能失败,可先关相关逻辑或接受报错日志)。 + - `monitor.min_24h_quote_volume_usdt`:成交额过滤(默认一千万量级,按 README 说明)。 + - `**gemma`**:若本机已装 Ollama 且要跑漏斗,将 `enabled: true` 并设置 `model`;否则保持 `enabled: false`。 + - `**proxy**`:访问 Gate 行情需走本机 SOCKS 时,设 `proxy.enabled: true`,`proxy.url` 一般为 `**socks5h://127.0.0.1:1080**`(与 Clash 等本地 SOCKS 端口一致)。**本机 Ollama 不会使用该代理**。 +3. **不要将** `config.yaml` **提交到公开仓库**(内含密钥与 Webhook)。 + +--- + +## 5. 代理写入 `config.yaml`(推荐,无需环境变量) + +在 `config.yaml` 根级增加或修改 `**proxy`** 段(与 `config.example.yaml` 一致): + +```yaml +proxy: + enabled: true + url: "socks5h://127.0.0.1:1080" +``` + +说明: + +- `**socks5h://**`:配置里可继续写(与 curl 习惯一致)。程序在创建 httpx 客户端时会**自动改成 `socks5://`**,因部分环境下 httpx/socksio 不认 `socks5h` 会报 `Unknown scheme`;改为 `socks5` 后由**本机解析 DNS** 再走 SOCKS。若仍异常,可直接在配置里写 `**socks5://127.0.0.1:1080`**。 +- **作用范围**:**Gate 行情**相关 httpx 请求使用 `config.yaml` 中的 `proxy`;企业微信当前实现为直连。**不会**对 `gemma.ollama_base_url`(本机 Ollama)套代理。 +- `**proxy.enabled: false`**:Gate 客户端仍可使用系统环境变量中的 `HTTP_PROXY` / `ALL_PROXY`(`trust_env=True`);启用配置代理后则**固定走 `proxy.url`**,并 `trust_env=False`,避免与环境变量冲突。 + +--- + +## 6. PM2 守护进程(推荐) + +### 6.1 安装 PM2 + +需已安装 **Node.js**,然后全局安装 PM2: + +```bash +npm install -g pm2 +``` + +### 6.2 准备虚拟环境与配置 + +1. 项目根目录已创建 `.venv` 且 `pip install -r requirements.txt` 完成。 +2. `config.yaml` 已按上文填写(含 `proxy`、`app.port` 等)。 +3. 确保存在目录 `**runtime/**`(用于日志与 SQLite;首次启动会自动创建亦可)。 + +### 6.3 使用仓库内 `ecosystem` 启动 + +仓库提供 `**deploy/ecosystem.config.cjs**`: + +- `**cwd**`:自动设为项目根(`deploy` 的上一级)。 +- `**script**`:根据操作系统选择 `**.venv/Scripts/python.exe`(Windows)** 或 `**.venv/bin/python`(Linux/macOS)**。 +- `**args`**:`python -m app.main`,**监听地址与端口完全由 `config.yaml` 的 `app.host` / `app.port` 决定**,无需改 ecosystem 里的端口。 + +在项目根目录执行: + +```bash +cd /opt/onchain_scout # 或你的实际路径 +pm2 start deploy/ecosystem.config.cjs +pm2 status +pm2 logs onchain-scout +``` + +常用维护命令: + + +| 命令 | 说明 | +| --------------------------- | --------------------------- | +| `pm2 restart onchain-scout` | 热重启(改 `config.yaml` 后需重启生效) | +| `pm2 stop onchain-scout` | 停止 | +| `pm2 delete onchain-scout` | 从进程列表移除 | +| `pm2 save` | 保存当前进程列表 | +| `pm2 startup` | 生成开机自启脚本(按屏幕提示执行一次) | + + +标准输出与错误会写入项目 `**runtime/pm2-out.log**`、`**runtime/pm2-error.log**`(见 ecosystem 内配置)。 + +### 6.4 Windows 说明 + +在 **PowerShell** 或 **cmd** 中同样可使用 `pm2 start deploy\ecosystem.config.cjs`。若 `python.exe` 路径不对,请确认虚拟环境目录名为 `.venv` 且位于项目根。 + +--- + +## 7. 前台启动(调试用) + +不经过 PM2、仅本地调试时,**无需**再设置 `ALL_PROXY` 等环境变量(代理已由 `config.yaml` 的 `proxy` 段控制): + +```powershell +cd C:\opt\onchain_scout +.\.venv\Scripts\Activate.ps1 +python -m app.main +``` + +`app.host` / `app.port` 以 `config.yaml` 为准。 + +--- + +## 8. 验证 + +1. **代理**:本机 SOCKS 入站已监听;`config.yaml` 中 `proxy.enabled: true`。 +2. **Web**:浏览器访问 `http://127.0.0.1:`,登录后「监控池配置」JSON 中应出现 `**proxy`** 字段(`enabled` / `url`)。 +3. **Gate**:等待一个 `poll_interval` 周期,看日志是否仍有 TLS/连接错误;若有,尝试 `socks5` 或检查端口。 +4. **Ollama**:`gemma.enabled: true` 时,访问 `127.0.0.1:11434` **不**走 `proxy.url`,一般无需 `NO_PROXY`。 + +--- + +## 9. 常见问题(FAQ) + +### Q1:`Connection refused` 连 Gate / 代理 + +- 本机代理未开或端口不是 **1080**。 +- `proxy.enabled` 未设为 `true` 或 `proxy.url` 写错。 + +### Q2:改了 `config.yaml` 不生效 + +- PM2 下需执行 `**pm2 restart onchain-scout`** 重新加载进程与配置。 + +### Q3:企业微信推送失败 + +- 多为 Webhook 无效;若走代理仍失败,检查代理是否允许访问 `qyapi.weixin.qq.com`。 + +### Q4:pip / git 走代理 + +- 与应用程序无关;可在安装依赖的终端自行 `export HTTPS_PROXY=...`(**不必**写进应用 `config.yaml`)。 + +--- + +## 10. 备忘 + +```bash +cd /opt/onchain_scout +pm2 start deploy/ecosystem.config.cjs && pm2 save +``` + +--- + +文档版本:与仓库 `onchain_scout_gate` 当前结构对应;请以 `README.md`、`deploy/ecosystem.config.cjs`、`app/main.py` 为准。 \ No newline at end of file diff --git a/onchain_scout_gate/requirements.txt b/onchain_scout_gate/requirements.txt new file mode 100644 index 0000000..85e0b9e --- /dev/null +++ b/onchain_scout_gate/requirements.txt @@ -0,0 +1,15 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +httpx[socks]==0.27.2 +pydantic==2.9.2 +pydantic-settings==2.5.2 +python-multipart==0.0.9 +itsdangerous==2.2.0 +jinja2==3.1.4 +apscheduler==3.10.4 +sqlalchemy==2.0.35 +aiosqlite==0.20.0 +passlib[bcrypt]==1.7.4 +python-dotenv==1.0.1 +pyyaml==6.0.2 +matplotlib==3.9.2 diff --git a/onchain_scout_gate/run.py b/onchain_scout_gate/run.py new file mode 100644 index 0000000..2223cf6 --- /dev/null +++ b/onchain_scout_gate/run.py @@ -0,0 +1,2 @@ +from app.main import app # noqa: F401 + diff --git a/onchain_scout_gate/scripts/backtest_box_breakout.py b/onchain_scout_gate/scripts/backtest_box_breakout.py new file mode 100644 index 0000000..1262038 --- /dev/null +++ b/onchain_scout_gate/scripts/backtest_box_breakout.py @@ -0,0 +1,428 @@ +from __future__ import annotations + +import argparse +import csv +from collections import deque +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Iterable + + +@dataclass +class Bar: + ts: str + open: float + high: float + low: float + close: float + + +@dataclass +class Trade: + side: str # LONG | SHORT + entry_ts: str + entry_price: float + exit_ts: str + exit_price: float + reason: str + gross_return_pct: float + net_return_pct: float + + +@dataclass +class BarWithEpoch: + bar: Bar + ts_epoch: int + + +def _to_float(row: dict[str, str], key: str) -> float: + raw = (row.get(key) or "").strip() + if not raw: + raise ValueError(f"Empty numeric field: {key}") + return float(raw) + + +def _read_csv( + csv_path: Path, + ts_col: str, + open_col: str, + high_col: str, + low_col: str, + close_col: str, +) -> list[Bar]: + bars: list[Bar] = [] + with csv_path.open("r", encoding="utf-8-sig", newline="") as f: + reader = csv.DictReader(f) + needed = {ts_col, open_col, high_col, low_col, close_col} + missing = [c for c in needed if c not in (reader.fieldnames or [])] + if missing: + raise ValueError(f"CSV 缺少列: {missing}. 当前列: {reader.fieldnames}") + for row in reader: + bars.append( + Bar( + ts=str(row[ts_col]), + open=_to_float(row, open_col), + high=_to_float(row, high_col), + low=_to_float(row, low_col), + close=_to_float(row, close_col), + ) + ) + if len(bars) < 200: + raise ValueError(f"数据量过少: {len(bars)} 行,无法可靠回测。") + return bars + + +def _parse_ts_to_epoch_seconds(ts_raw: str) -> int: + s = str(ts_raw).strip() + if not s: + raise ValueError("timestamp is empty") + if s.isdigit() or (s.startswith("-") and s[1:].isdigit()): + n = int(s) + # 13 digits => milliseconds + if abs(n) >= 10_000_000_000: + return int(n / 1000) + return n + s_norm = s.replace("Z", "+00:00") + try: + return int(datetime.fromisoformat(s_norm).timestamp()) + except ValueError as exc: + raise ValueError(f"Unsupported timestamp format: {s}") from exc + + +def _sort_and_attach_epoch(bars: list[Bar]) -> list[BarWithEpoch]: + enriched = [BarWithEpoch(bar=b, ts_epoch=_parse_ts_to_epoch_seconds(b.ts)) for b in bars] + enriched.sort(key=lambda x: x.ts_epoch) + return enriched + + +def _aggregate_bars(bars_1m: list[Bar], timeframe_minutes: int) -> list[Bar]: + if timeframe_minutes <= 1: + return bars_1m + src = _sort_and_attach_epoch(bars_1m) + if not src: + return [] + + out: list[Bar] = [] + bucket_sec = timeframe_minutes * 60 + + cur_bucket = None + agg_open = agg_high = agg_low = agg_close = 0.0 + agg_ts = "" + + for item in src: + b = item.bar + bucket = (item.ts_epoch // bucket_sec) * bucket_sec + if cur_bucket is None or bucket != cur_bucket: + if cur_bucket is not None: + out.append(Bar(ts=agg_ts, open=agg_open, high=agg_high, low=agg_low, close=agg_close)) + cur_bucket = bucket + agg_open = b.open + agg_high = b.high + agg_low = b.low + agg_close = b.close + agg_ts = datetime.utcfromtimestamp(bucket).isoformat() + "Z" + else: + agg_high = max(agg_high, b.high) + agg_low = min(agg_low, b.low) + agg_close = b.close + + if cur_bucket is not None: + out.append(Bar(ts=agg_ts, open=agg_open, high=agg_high, low=agg_low, close=agg_close)) + return out + + +def _parse_timeframe_to_minutes(tf: str) -> int: + s = tf.strip().lower() + if s.endswith("m"): + return int(s[:-1]) + if s.endswith("h"): + return int(s[:-1]) * 60 + raise ValueError(f"Unsupported timeframe: {tf}. Use like 15m,30m,1h") + + +def _calc_stats(equity_curve: Iterable[float], trades: list[Trade], initial_capital: float) -> dict[str, float]: + curve = list(equity_curve) + if not curve: + return {} + final_capital = curve[-1] + total_return_pct = (final_capital / initial_capital - 1.0) * 100.0 + + peak = curve[0] + max_dd = 0.0 + for eq in curve: + if eq > peak: + peak = eq + dd = (eq / peak - 1.0) * 100.0 + if dd < max_dd: + max_dd = dd + + wins = [t for t in trades if t.net_return_pct > 0] + losses = [t for t in trades if t.net_return_pct <= 0] + win_rate = (len(wins) / len(trades) * 100.0) if trades else 0.0 + avg_win = sum(t.net_return_pct for t in wins) / len(wins) if wins else 0.0 + avg_loss = sum(t.net_return_pct for t in losses) / len(losses) if losses else 0.0 + profit_factor = ( + abs(sum(t.net_return_pct for t in wins) / sum(t.net_return_pct for t in losses)) + if losses and sum(t.net_return_pct for t in losses) != 0 + else 0.0 + ) + + return { + "initial_capital": initial_capital, + "final_capital": final_capital, + "total_return_pct": total_return_pct, + "max_drawdown_pct": max_dd, + "total_trades": float(len(trades)), + "win_rate_pct": win_rate, + "avg_win_pct": avg_win, + "avg_loss_pct": avg_loss, + "profit_factor": profit_factor, + } + + +def run_backtest( + bars: list[Bar], + box_len: int, + buf_pct: float, + min_box_pct: float, + sl_pct: float, + tp_pct: float, + commission_pct: float, + initial_capital: float, +) -> tuple[list[Trade], list[float]]: + high_win: deque[float] = deque(maxlen=box_len) + low_win: deque[float] = deque(maxlen=box_len) + close_hist: list[float] = [] + + trades: list[Trade] = [] + equity_curve: list[float] = [initial_capital] + capital = initial_capital + + position = 0 # 1 long, -1 short, 0 flat + entry_price = 0.0 + entry_ts = "" + + for i, bar in enumerate(bars): + # Build history first + close_hist.append(bar.close) + if i == 0: + high_win.append(bar.high) + low_win.append(bar.low) + continue + + # Exit check (intrabar, after entry bar) + if position != 0: + if position == 1: + stop = entry_price * (1 - sl_pct / 100.0) + take = entry_price * (1 + tp_pct / 100.0) + exit_price = 0.0 + reason = "" + # Conservative tie-break: stop first if both touched same bar + if bar.low <= stop: + exit_price, reason = stop, "SL" + elif bar.high >= take: + exit_price, reason = take, "TP" + if reason: + gross_ret = (exit_price / entry_price - 1.0) * 100.0 + net_ret = gross_ret - 2 * commission_pct + capital *= 1 + net_ret / 100.0 + trades.append( + Trade("LONG", entry_ts, entry_price, bar.ts, exit_price, reason, gross_ret, net_ret) + ) + equity_curve.append(capital) + position = 0 + elif position == -1: + stop = entry_price * (1 + sl_pct / 100.0) + take = entry_price * (1 - tp_pct / 100.0) + exit_price = 0.0 + reason = "" + if bar.high >= stop: + exit_price, reason = stop, "SL" + elif bar.low <= take: + exit_price, reason = take, "TP" + if reason: + gross_ret = (entry_price / exit_price - 1.0) * 100.0 + net_ret = gross_ret - 2 * commission_pct + capital *= 1 + net_ret / 100.0 + trades.append( + Trade("SHORT", entry_ts, entry_price, bar.ts, exit_price, reason, gross_ret, net_ret) + ) + equity_curve.append(capital) + position = 0 + + # Need full lookback and previous close for crossover. + if len(high_win) < box_len or len(low_win) < box_len or i < 2: + high_win.append(bar.high) + low_win.append(bar.low) + continue + + box_high = max(high_win) + box_low = min(low_win) + box_mid = (box_high + box_low) / 2.0 + box_pct = ((box_high - box_low) / box_mid * 100.0) if box_mid > 0 else 0.0 + box_ok = box_pct >= min_box_pct + + up_line = box_high * (1 + buf_pct / 100.0) + dn_line = box_low * (1 - buf_pct / 100.0) + + prev_close = close_hist[-2] + long_trig = box_ok and prev_close <= up_line and bar.close > up_line + short_trig = box_ok and prev_close >= dn_line and bar.close < dn_line + + # Reverse signal close at close price then flip. + if position == 1 and short_trig: + gross_ret = (bar.close / entry_price - 1.0) * 100.0 + net_ret = gross_ret - 2 * commission_pct + capital *= 1 + net_ret / 100.0 + trades.append(Trade("LONG", entry_ts, entry_price, bar.ts, bar.close, "REVERSE", gross_ret, net_ret)) + equity_curve.append(capital) + position = 0 + elif position == -1 and long_trig: + gross_ret = (entry_price / bar.close - 1.0) * 100.0 + net_ret = gross_ret - 2 * commission_pct + capital *= 1 + net_ret / 100.0 + trades.append(Trade("SHORT", entry_ts, entry_price, bar.ts, bar.close, "REVERSE", gross_ret, net_ret)) + equity_curve.append(capital) + position = 0 + + if position == 0: + if long_trig: + position = 1 + entry_price = bar.close + entry_ts = bar.ts + elif short_trig: + position = -1 + entry_price = bar.close + entry_ts = bar.ts + + high_win.append(bar.high) + low_win.append(bar.low) + + # Force close at final close + if position != 0: + last = bars[-1] + if position == 1: + gross_ret = (last.close / entry_price - 1.0) * 100.0 + side = "LONG" + else: + gross_ret = (entry_price / last.close - 1.0) * 100.0 + side = "SHORT" + net_ret = gross_ret - 2 * commission_pct + capital *= 1 + net_ret / 100.0 + trades.append(Trade(side, entry_ts, entry_price, last.ts, last.close, "FORCE_CLOSE", gross_ret, net_ret)) + equity_curve.append(capital) + + return trades, equity_curve + + +def _save_trades(path: Path, trades: list[Trade]) -> None: + with path.open("w", encoding="utf-8", newline="") as f: + w = csv.writer(f) + w.writerow( + [ + "side", + "entry_ts", + "entry_price", + "exit_ts", + "exit_price", + "reason", + "gross_return_pct", + "net_return_pct", + ] + ) + for t in trades: + w.writerow( + [ + t.side, + t.entry_ts, + f"{t.entry_price:.8f}", + t.exit_ts, + f"{t.exit_price:.8f}", + t.reason, + f"{t.gross_return_pct:.6f}", + f"{t.net_return_pct:.6f}", + ] + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description="ETH 1m 裸K箱体突破回测") + parser.add_argument("--csv", required=True, help="K线 CSV 路径") + parser.add_argument("--ts-col", default="timestamp", help="时间列名") + parser.add_argument("--open-col", default="open", help="开盘列名") + parser.add_argument("--high-col", default="high", help="最高列名") + parser.add_argument("--low-col", default="low", help="最低列名") + parser.add_argument("--close-col", default="close", help="收盘列名") + + parser.add_argument("--box-len", type=int, default=80, help="箱体回看K数") + parser.add_argument("--buf-pct", type=float, default=0.03, help="突破缓冲百分比") + parser.add_argument("--min-box-pct", type=float, default=1.5, help="最小箱体宽度百分比") + parser.add_argument("--sl-pct", type=float, default=0.8, help="止损百分比") + parser.add_argument("--tp-pct", type=float, default=2.4, help="止盈百分比") + parser.add_argument("--commission-pct", type=float, default=0.05, help="单边手续费百分比") + parser.add_argument("--capital", type=float, default=100000.0, help="初始资金") + parser.add_argument("--out", default="runtime/backtest_trades.csv", help="交易明细输出路径") + parser.add_argument( + "--timeframes", + default="15m,30m,1h", + help="回测周期,逗号分隔;会从1m聚合,如: 15m,30m,1h", + ) + + args = parser.parse_args() + csv_path = Path(args.csv).expanduser().resolve() + if not csv_path.exists(): + raise FileNotFoundError(f"CSV 不存在: {csv_path}") + + bars = _read_csv( + csv_path, + ts_col=args.ts_col, + open_col=args.open_col, + high_col=args.high_col, + low_col=args.low_col, + close_col=args.close_col, + ) + tfs = [x.strip() for x in str(args.timeframes).split(",") if x.strip()] + if not tfs: + raise ValueError("timeframes 不能为空") + + base_out = Path(args.out).expanduser().resolve() + base_out.parent.mkdir(parents=True, exist_ok=True) + + print("=== Backtest Done (1m聚合多周期) ===") + print(f"source_1m_bars: {len(bars)}") + print(f"source_period: {bars[0].ts} -> {bars[-1].ts}") + print("") + + for tf in tfs: + minutes = _parse_timeframe_to_minutes(tf) + agg = _aggregate_bars(bars, minutes) + trades, curve = run_backtest( + bars=agg, + box_len=args.box_len, + buf_pct=args.buf_pct, + min_box_pct=args.min_box_pct, + sl_pct=args.sl_pct, + tp_pct=args.tp_pct, + commission_pct=args.commission_pct, + initial_capital=args.capital, + ) + stats = _calc_stats(curve, trades, args.capital) + out_path = base_out.with_name(f"{base_out.stem}_{tf}{base_out.suffix}") + _save_trades(out_path, trades) + + print(f"[{tf}] bars={len(agg)} trades={int(stats.get('total_trades', 0))}") + print(f" period: {agg[0].ts} -> {agg[-1].ts}") + print(f" final_capital: {stats.get('final_capital', 0):.2f}") + print(f" total_return: {stats.get('total_return_pct', 0):.2f}%") + print(f" max_drawdown: {stats.get('max_drawdown_pct', 0):.2f}%") + print(f" win_rate: {stats.get('win_rate_pct', 0):.2f}%") + print(f" profit_factor: {stats.get('profit_factor', 0):.3f}") + print(f" trades_csv: {out_path}") + print("") + + print(f"generated_at: {datetime.now().isoformat(timespec='seconds')}") + + +if __name__ == "__main__": + main() diff --git a/onchain_scout_gate/static/app.js b/onchain_scout_gate/static/app.js new file mode 100644 index 0000000..1da3b78 --- /dev/null +++ b/onchain_scout_gate/static/app.js @@ -0,0 +1,620 @@ +async function fetchJson(url, options = {}) { + const response = await fetch(url, { + credentials: "same-origin", + cache: "no-store", + ...options, + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + return response.json(); +} + +function pretty(data) { + return JSON.stringify(data, null, 2); +} + +function renderItems(containerId, rows, rowRenderer) { + const target = document.getElementById(containerId); + if (!target) return; + target.innerHTML = ""; + rows.forEach((row) => { + const el = document.createElement("div"); + el.className = "item matrix-list-item"; + el.innerHTML = rowRenderer(row); + target.appendChild(el); + }); +} + +function setInput(id, value) { + const el = document.getElementById(id); + if (el) el.value = value; +} + +function setTextareaValue(id, value) { + const el = document.getElementById(id); + if (el) el.value = value != null ? String(value) : ""; +} + +function getTextareaValue(id) { + const el = document.getElementById(id); + return el ? String(el.value || "") : ""; +} + +function setCheck(id, value) { + const el = document.getElementById(id); + if (el) el.checked = !!value; +} + +function getInputNumber(id) { + return Number(document.getElementById(id).value); +} + +function getInputText(id) { + const el = document.getElementById(id); + return el ? String(el.value || "").trim() : ""; +} + +function getInputCheck(id) { + const el = document.getElementById(id); + return !!(el && el.checked); +} + +/** SQLite 常为无时区 naive UTC,补 Z 再解析,避免浏览器当成本地时区 */ +function normalizeUtcIsoString(iso) { + if (typeof iso !== "string") return iso; + const s = iso.trim(); + if (/^\d{4}-\d{2}-\d{2}T/.test(s) && !/[zZ]|[+-]\d{2}:?\d{2}$/.test(s)) return `${s}Z`; + return s; +} + +/** ISO 8601 → 北京时间展示 */ +function formatIsoToBeijing(iso) { + if (!iso || typeof iso !== "string") return "—"; + const t = Date.parse(normalizeUtcIsoString(iso)); + if (Number.isNaN(t)) return iso; + const s = new Date(t).toLocaleString("sv-SE", { timeZone: "Asia/Shanghai", hour12: false }); + return s.replace("T", " "); +} + +function tickClock() { + const el = document.getElementById("liveClock"); + if (!el) return; + const s = new Date().toLocaleString("sv-SE", { timeZone: "Asia/Shanghai", hour12: false }); + el.textContent = s.replace("T", " ") + " 北京时间 (UTC+8)"; +} + +function setText(id, text) { + const el = document.getElementById(id); + if (el) el.textContent = text; +} + +function updateHud(status) { + const st = (status && status.state) || {}; + setText("hudLink", "ONLINE"); + setText("hudCycle", st.last_cycle_status || "—"); + { + const env = st.btc_env_8h_15m || "—"; + const daily = st.btc_gate_regime && st.btc_gate_regime !== "disabled" ? st.btc_gate_regime : ""; + setText("hudBtc", daily ? `${env} · ${daily}` : env); + } + const pool = st.monitoring_pool_count; + setText("hudPool", pool != null ? String(pool) : "—"); + setText("hudPush", st.pushed_alerts_count != null ? String(st.pushed_alerts_count) : "—"); + const blChip = document.getElementById("symbolBlocklistCountChip"); + if (blChip && st.symbol_blocklist_count != null) { + blChip.textContent = `${st.symbol_blocklist_count} 条规则`; + } + const lastRaw = st.last_cycle_at || st.last_cycle_msg || "—"; + setText("hudLast", st.last_cycle_at ? `${formatIsoToBeijing(st.last_cycle_at)}(北京时间)` : lastRaw); + + const gemOn = status && status.gemma_enabled; + const nFun = Array.isArray(st.last_funnel) ? st.last_funnel.length : 0; + const model = (status && status.gemma_model) || ""; + let gLine = "—"; + if (gemOn === false) { + gLine = "配置未开启"; + } else if (gemOn === true) { + const msg = (st.gemma_cycle_msg || "").trim(); + gLine = msg ? `${msg} · 记忆体${nFun}行` : `${model || "ollama"} · 记忆体${nFun}行`; + } + setText("hudGemma", gLine); +} + +function renderFunnel(items, funnelCtx) { + const root = document.getElementById("funnelMatrix"); + if (!root) return; + root.innerHTML = ""; + const ctx = funnelCtx || {}; + const gemmaOn = !!ctx.gemmaEnabled; + const cycleMsg = String(ctx.cycleMsg || "").trim(); + const lastAt = String(ctx.lastFunnelAt || "").trim(); + if (!items.length) { + const empty = document.createElement("div"); + empty.className = "matrix-hint matrix-hint-empty"; + let why = + "// 暂无漏斗记录:本面板只展示 source=gemma_funnel 的排序结果(需配置开启且 Ollama 跑完一轮后写入告警表)。"; + if (!gemmaOn) { + why += " 当前 gemma.enabled=false,漏斗未运行。"; + } else if (cycleMsg === "funnel_pending") { + why += " 状态 funnel_pending:Gemma 在后台跑,完成后此处会出现卡片。"; + } else if (cycleMsg === "no_funnel_candidates") { + why += " 本轮扫描无 WATCH/TRIGGER,无漏斗输入。"; + } else if (cycleMsg === "gemma_client_none") { + why += " 服务未挂载 Gemma 客户端(检查配置并重启)。"; + } else if (cycleMsg && cycleMsg.startsWith("funnel_failed")) { + why += ` 最近错误:${escapeHtml(cycleMsg)}`; + } else if (cycleMsg) { + why += ` 运行时:${escapeHtml(cycleMsg)}`; + } + if (lastAt) { + why += ` last_funnel_at: ${escapeHtml(lastAt)}`; + } + empty.innerHTML = ` ${why}`; + root.appendChild(empty); + return; + } + items.forEach((a) => { + const d = a.details || {}; + const g = d.gemma || {}; + const comp = Number(d.composite_score || 0); + const pushed = !!d.priority_push; + const card = document.createElement("article"); + card.className = "matrix-card" + (pushed ? " hot" : ""); + const vol = (d.programmatic && d.programmatic.est_quote_vol_24h_usdt) || "—"; + card.innerHTML = ` +
${a.symbol}
+
+ COMPOSITE ${comp.toFixed(1)} · P${g.priority || "?"} · + 结构 ${g.daily_structure || "?"} · 量 ${g.volume_view || "?"} · + 上方 ${g.upside_space || "?"} · 阻力 ${g.mid_resistance || "?"} +
+
+
${escapeHtml(g.one_liner || "")}
+
24h 估算 USDT: ${vol} · 图: ${d.image_sent ? "Y" : "N"}
+ ${pushed ? "已优先推送" : "未达推送阈值"} + `; + root.appendChild(card); + }); +} + +function renderDailyReport(payload) { + const root = document.getElementById("dailyReportBox"); + const meta = document.getElementById("dailyReportMeta"); + if (!root || !meta) return; + root.innerHTML = ""; + if (!payload || !payload.ready || !payload.report) { + meta.textContent = `// ${payload && payload.message ? payload.message : "晨报暂不可用"}`; + root.innerHTML = + "
// 晨报会按北京时间定时生成,也可点“立即生成”。
"; + return; + } + const r = payload.report; + const t = r.text || {}; + const b = r.btc || {}; + const s = r.stats || {}; + const risks = Array.isArray(t.risk_points) ? t.risk_points : []; + meta.textContent = + `// 复盘日 ${r.report_day_cn || "—"} | 生成 ${r.generated_at_cn || "—"} | AI ${r.ai_used ? "on" : "fallback"} | BTC ${b.direction || "—"}`; + const riskHtml = risks.map((x) => `
• ${escapeHtml(String(x))}
`).join(""); + root.innerHTML = ` +
+
${escapeHtml(t.headline || "每日晨报")}
+
BTC: ${escapeHtml(String(b.direction || "—"))} · 日涨跌 ${escapeHtml(String(b.day_change_pct ?? "—"))}% · SMA20 ${escapeHtml(String(b.sma20 ?? "—"))} · SMA60 ${escapeHtml(String(b.sma60 ?? "—"))}
+
统计: WATCH ${escapeHtml(String(s.watch_count ?? 0))} / TRIGGER ${escapeHtml(String(s.trigger_count ?? 0))} / 漏斗优先 ${escapeHtml(String(s.funnel_push_count ?? 0))}
+
方向说明: ${escapeHtml(t.btc_explain || "—")}
+
总结: ${escapeHtml(t.summary || "—")}
+
风险点:
+
${riskHtml || "• —"}
+
执行提示: ${escapeHtml(t.action_hint || "—")}
+
${escapeHtml(String(r.generated_at_cn || "—"))}(北京时间)
+
+ `; +} + +function escapeHtml(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +async function loadIntradaySettings() { + const data = await fetchJson("/api/settings"); + const s = data.intraday_settings || {}; + setInput("rangeHoursInput", s.range_hours ?? 24); + setInput("rangeMaxPctInput", s.range_max_pct ?? 1.5); + setInput("volumeSpikeMultInput", s.volume_spike_mult ?? 1.6); + setInput("volumeLookbackInput", s.volume_lookback_bars ?? 20); + setInput("breakoutBufferInput", s.breakout_buffer_pct ?? 0.05); + setInput("stopBufferPctInput", s.stop_buffer_pct ?? 0.2); + setCheck("pushTimeWindowEnabledInput", s.push_time_window_enabled ?? true); + const b = data.symbol_blocklist_settings || {}; + setTextareaValue("symbolBlocklistInput", b.symbols_text ?? ""); + const chip = document.getElementById("symbolBlocklistCountChip"); + if (chip) chip.textContent = `${Number(b.count) || 0} 条规则`; +} + +async function loadDailyReportSettings() { + const data = await fetchJson("/api/settings"); + const d = data.daily_report_settings || {}; + setCheck("dailyReportEnabledInput", d.enabled ?? true); + setInput("dailyReportTimeInput", d.run_time_cn ?? "08:30"); + setCheck("dailyReportPushInput", d.push_wecom ?? true); + setCheck("dailyReportStartupInput", d.run_on_startup ?? false); +} + +async function saveIntradaySettings() { + const msg = document.getElementById("intradaySaveMsg"); + if (!msg) return; + msg.textContent = "写入中…"; + try { + const payload = { + range_hours: getInputNumber("rangeHoursInput"), + range_max_pct: getInputNumber("rangeMaxPctInput"), + volume_spike_mult: getInputNumber("volumeSpikeMultInput"), + volume_lookback_bars: getInputNumber("volumeLookbackInput"), + breakout_buffer_pct: getInputNumber("breakoutBufferInput"), + stop_buffer_pct: getInputNumber("stopBufferPctInput"), + push_time_window_enabled: getInputCheck("pushTimeWindowEnabledInput"), + }; + await fetchJson("/api/settings/intraday", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + msg.textContent = "// 已写入,下一轮监控生效"; + } catch (error) { + msg.textContent = `// 失败 ${error}`; + } +} + +async function saveSymbolBlocklistSettings() { + const msg = document.getElementById("symbolBlocklistSaveMsg"); + if (msg) msg.textContent = "写入中…"; + try { + const payload = { symbols_text: getTextareaValue("symbolBlocklistInput") }; + const data = await fetchJson("/api/settings/symbol-blocklist", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const b = data.symbol_blocklist_settings || {}; + setTextareaValue("symbolBlocklistInput", b.symbols_text ?? ""); + const chip = document.getElementById("symbolBlocklistCountChip"); + if (chip) chip.textContent = `${Number(b.count) || 0} 条规则`; + if (msg) msg.textContent = "// 已写入,下一轮监控生效"; + } catch (error) { + if (msg) msg.textContent = `// 失败 ${error}`; + } +} + +async function runDailyReportNow() { + const meta = document.getElementById("dailyReportMeta"); + if (meta) meta.textContent = "// 手动生成中…"; + try { + const data = await fetchJson("/api/daily-report/run", { method: "POST" }); + renderDailyReport({ ready: true, report: data.report || null }); + } catch (error) { + if (meta) meta.textContent = `// 手动生成失败: ${error}`; + } +} + +async function saveDailyReportSettings() { + const msg = document.getElementById("dailyReportSaveMsg"); + if (msg) msg.textContent = "写入中…"; + try { + const payload = { + enabled: getInputCheck("dailyReportEnabledInput"), + run_time_cn: getInputText("dailyReportTimeInput") || "08:30", + push_wecom: getInputCheck("dailyReportPushInput"), + run_on_startup: getInputCheck("dailyReportStartupInput"), + }; + await fetchJson("/api/settings/daily-report", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (msg) msg.textContent = "// 已写入晨报配置,定时任务已更新"; + } catch (error) { + if (msg) msg.textContent = `// 写入失败 ${error}`; + } +} + +async function refresh() { + try { + const [status, alerts, logs, config, funnel, dailyReport] = await Promise.all([ + fetchJson("/api/status"), + fetchJson("/api/alerts"), + fetchJson("/api/logs"), + fetchJson("/api/config"), + fetchJson("/api/funnel"), + fetchJson("/api/daily-report"), + ]); + updateHud(status); + + const statusPre = document.getElementById("status"); + const cf = document.getElementById("config"); + if (statusPre) statusPre.textContent = pretty(status); + if (cf) cf.textContent = pretty(config); + + const runState = (status && status.state) || {}; + renderFunnel(funnel.items || [], { + gemmaEnabled: !!(config.gemma && config.gemma.enabled), + cycleMsg: runState.gemma_cycle_msg || "", + lastFunnelAt: runState.last_funnel_at || "", + }); + renderDailyReport(dailyReport); + try { + const oe = await fetchJson("/api/order-executors"); + renderOrderExecutors(oe); + if (document.activeElement !== document.getElementById("oeWebhookSecret")) { + setCheck("oeGlobalEnabled", !!oe.enabled); + setInput("oeTimeout", oe.timeout_seconds ?? 15); + const sec = document.getElementById("oeWebhookSecret"); + if (sec) sec.value = oe.webhook_secret != null ? String(oe.webhook_secret) : ""; + } + } catch (eOe) { + console.warn("order executors refresh", eOe); + } + + const poll = status.poll_interval_seconds != null ? String(status.poll_interval_seconds) : "?"; + const pullCn = new Date() + .toLocaleString("sv-SE", { timeZone: "Asia/Shanghai", hour12: false }) + .replace("T", " "); + const fc = (funnel.items || []).length; + const lfAt = runState.last_funnel_at ? formatIsoToBeijing(runState.last_funnel_at) : "—"; + const gmsg = runState.gemma_cycle_msg || "—"; + const fm = document.getElementById("funnelMeta"); + if (fm) { + let line = + `// 浏览器刚拉完 API:${pullCn} | HUD 的 LAST:上一轮 Gate 扫描整轮结束(可与本行差约 ${poll}s)| ` + + `矩阵卡片 ${fc} 条:来自告警库「每币最新一条」| 记忆体 last_funnel 更新:${lfAt} | 后轮 gemma:${gmsg}`; + if (String(gmsg).includes("funnel_ranked=0") && fc > 0) { + line += + " | 说明:本轮后台漏斗未写入新排名(常见:4h 内同一币已跑过 FUNNEL-GEMMA 被跳过、或候选在取日线/Ollama 前被滤掉),卡片仍是历史结果,不是前端卡死。"; + } else { + line += " | 若文案长期不变=近期没有新的 gemma_funnel 入库。"; + } + fm.textContent = line; + } + + const allAlerts = alerts.items || []; + const watchRows = allAlerts.filter((a) => (a.details && a.details.signal_level) === "WATCH"); + const triggerRows = allAlerts.filter((a) => (a.details && a.details.signal_level) === "TRIGGER"); + + renderItems("watchAlerts", watchRows, (a) => ` +
${a.symbol} ${escapeHtml(a.chain || "")}
+
级别: ${(a.details && a.details.signal_level) || "N/A"}
+
信号: ${(a.trigger_types || []).join(" · ")}
+
评分: ${Number(a.score).toFixed(2)}
+
${formatIsoToBeijing(a.created_at)}
+ `); + + if (!triggerRows.length) { + const trig = document.getElementById("triggerAlerts"); + if (trig) { + trig.innerHTML = + "
" + + "// 暂无 TRIGGER:触发层只显示 signal_level=TRIGGER 的告警(通常需横盘后放量突破等更严条件)。有 WATCH 不代表已进入 TRIGGER。
"; + } + } else { + renderItems("triggerAlerts", triggerRows, (a) => ` +
${a.symbol} ${escapeHtml(a.chain || "")}
+
级别: ${(a.details && a.details.signal_level) || "N/A"}
+
信号: ${(a.trigger_types || []).join(" · ")}
+
推送状态: ${((a.details || {}).strict_push_ok === true) ? "已推送" : "未推送"}
+
未推送原因: ${escapeHtml(String(((a.details || {}).push_block_reason || "—")))}
+
评分: ${Number(a.score).toFixed(2)}
+
${formatIsoToBeijing(a.created_at)}
+ `); + } + + renderItems("logs", logs.items || [], (l) => ` +
[${l.level}] ${escapeHtml(l.message)}
+
${formatIsoToBeijing(l.created_at)}
+ `); + } catch (error) { + console.error("refresh failed", error); + setText("hudLink", "ERR"); + const fm = document.getElementById("funnelMeta"); + if (fm) fm.textContent = `// 拉取失败(检查登录是否过期、网络): ${error}`; + } +} + +/** 轻量 Canvas 代码雨(仅 dashboard 有 canvas) */ +function initMatrixRain() { + const canvas = document.getElementById("matrixRain"); + if (!canvas || !canvas.getContext) return; + const ctx = canvas.getContext("2d"); + const chars = "01アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモラリルレロ"; + let w = 0; + let h = 0; + let columns = []; + const fontSize = 14; + + function resize() { + w = canvas.width = window.innerWidth; + h = canvas.height = window.innerHeight; + const colCount = Math.min(48, Math.ceil(w / fontSize)); + columns = Array.from({ length: colCount }, () => ({ + y: Math.random() * h, + speed: 0.8 + Math.random() * 2.2, + head: Math.floor(Math.random() * chars.length), + })); + } + + window.addEventListener("resize", resize); + resize(); + + function frame() { + ctx.fillStyle = "rgba(2, 2, 6, 0.12)"; + ctx.fillRect(0, 0, w, h); + ctx.font = `${fontSize}px ui-monospace, monospace`; + for (let i = 0; i < columns.length; i++) { + const col = columns[i]; + const x = i * fontSize; + const ch = chars[(col.head + Math.floor(col.y / fontSize)) % chars.length]; + const flicker = 0.35 + Math.random() * 0.45; + ctx.fillStyle = `rgba(0, 255, 200, ${flicker})`; + ctx.fillText(ch, x, col.y % (h + fontSize)); + col.y += col.speed; + if (col.y > h + fontSize) col.y = -fontSize * (3 + Math.random() * 8); + } + requestAnimationFrame(frame); + } + requestAnimationFrame(frame); +} + +function formatOeLastForward(lf) { + if (!lf || typeof lf !== "object") return "—"; + const at = lf.at ? formatIsoToBeijing(lf.at) : "—"; + const st = lf.exec_status != null ? String(lf.exec_status) : "—"; + const http = lf.http_status != null ? String(lf.http_status) : "—"; + return `${at} · HTTP ${http} · ${st}`; +} + +function renderOrderExecutors(snap) { + const root = document.getElementById("oeList"); + if (!root) return; + const rows = (snap && snap.executors) || []; + if (!rows.length) { + root.innerHTML = + "
// 尚未添加执行器。单账户填一条 Base URL;多账户对照实验填多条(如 :8090 / :8091)。
"; + return; + } + root.innerHTML = rows + .map((ex) => { + const id = escapeHtml(String(ex.id || "")); + const en = !!ex.enabled; + const lf = formatOeLastForward(ex.last_forward); + return ` +
+
${escapeHtml(ex.name || "—")} + ${en ? "启用" : "停用"}
+
${escapeHtml(ex.base_url || "—")}
+
上次转发: ${escapeHtml(lf)}
+
+ + +
+
`; + }) + .join(""); +} + +async function loadOrderExecutors() { + const data = await fetchJson("/api/order-executors"); + setCheck("oeGlobalEnabled", !!data.enabled); + setInput("oeTimeout", data.timeout_seconds ?? 15); + const sec = document.getElementById("oeWebhookSecret"); + if (sec && document.activeElement !== sec) { + sec.value = data.webhook_secret != null ? String(data.webhook_secret) : ""; + } + renderOrderExecutors(data); +} + +async function saveOrderExecutorsGlobal() { + const msg = document.getElementById("oeGlobalMsg"); + if (msg) msg.textContent = "保存中…"; + try { + const payload = { + enabled: getInputCheck("oeGlobalEnabled"), + webhook_secret: getInputText("oeWebhookSecret"), + timeout_seconds: Number(getInputText("oeTimeout") || "15"), + }; + const data = await fetchJson("/api/order-executors/settings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (msg) msg.textContent = "// 已保存(改 webhook 后请同步各执行器 config)"; + renderOrderExecutors(data.order_executors || data); + } catch (error) { + if (msg) msg.textContent = `// 失败 ${error}`; + } +} + +async function addOrderExecutor() { + const msg = document.getElementById("oeAddMsg"); + if (msg) msg.textContent = "提交中…"; + try { + const data = await fetchJson("/api/order-executors", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: getInputText("oeNewName"), + base_url: getInputText("oeNewUrl"), + enabled: getInputCheck("oeNewEnabled"), + }), + }); + if (msg) msg.textContent = "// 已添加"; + setInput("oeNewName", ""); + setInput("oeNewUrl", ""); + setCheck("oeNewEnabled", true); + renderOrderExecutors(data.order_executors || data); + } catch (error) { + if (msg) msg.textContent = `// 失败 ${error}`; + } +} + +async function patchOrderExecutor(id, body) { + return fetchJson(`/api/order-executors/${encodeURIComponent(id)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function wireOrderExecutorsPanel() { + const saveG = document.getElementById("oeSaveGlobalBtn"); + if (saveG) saveG.addEventListener("click", saveOrderExecutorsGlobal); + const addB = document.getElementById("oeAddBtn"); + if (addB) addB.addEventListener("click", addOrderExecutor); + const list = document.getElementById("oeList"); + if (list) { + list.addEventListener("click", async (ev) => { + const tgl = ev.target.closest && ev.target.closest(".oe-toggle"); + const del = ev.target.closest && ev.target.closest(".oe-delete"); + const id = (tgl || del) && (tgl || del).getAttribute("data-id"); + if (!id) return; + try { + if (tgl) { + const en = tgl.getAttribute("data-enabled") === "1"; + const data = await patchOrderExecutor(id, { enabled: en }); + renderOrderExecutors(data.order_executors || data); + } else if (del) { + if (!confirm("确认从扫描端移除该执行器?(不会停止执行器进程)")) return; + const data = await fetchJson(`/api/order-executors/${encodeURIComponent(id)}`, { + method: "DELETE", + }); + renderOrderExecutors(data.order_executors || data); + } + } catch (error) { + alert(String(error)); + } + }); + } +} + +const saveBtn = document.getElementById("saveIntradayBtn"); +if (saveBtn) saveBtn.addEventListener("click", saveIntradaySettings); +const saveBlocklistBtn = document.getElementById("saveSymbolBlocklistBtn"); +if (saveBlocklistBtn) saveBlocklistBtn.addEventListener("click", saveSymbolBlocklistSettings); +const runDailyBtn = document.getElementById("runDailyReportBtn"); +if (runDailyBtn) runDailyBtn.addEventListener("click", runDailyReportNow); +const saveDailyBtn = document.getElementById("saveDailyReportBtn"); +if (saveDailyBtn) saveDailyBtn.addEventListener("click", saveDailyReportSettings); +loadIntradaySettings().catch(console.error); +loadDailyReportSettings().catch(console.error); +wireOrderExecutorsPanel(); +loadOrderExecutors().catch(console.error); +tickClock(); +setInterval(tickClock, 1000); +initMatrixRain(); +refresh(); +setInterval(refresh, 4000); +document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "visible") refresh(); +}); diff --git a/onchain_scout_gate/static/style.css b/onchain_scout_gate/static/style.css new file mode 100644 index 0000000..78463cd --- /dev/null +++ b/onchain_scout_gate/static/style.css @@ -0,0 +1,1438 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Segoe UI", "PingFang SC", sans-serif; + background: #090909; + color: #f4e7bf; +} + +.bg-glow { + position: fixed; + inset: 0; + background: + radial-gradient(circle at 20% 20%, rgba(239, 180, 36, 0.20), transparent 35%), + radial-gradient(circle at 80% 10%, rgba(255, 215, 128, 0.15), transparent 25%), + radial-gradient(circle at 50% 90%, rgba(255, 196, 0, 0.12), transparent 30%); + filter: blur(10px); + z-index: -1; +} + +.bg-grid { + position: fixed; + inset: 0; + background: + linear-gradient(rgba(255, 215, 90, 0.06) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 215, 90, 0.06) 1px, transparent 1px); + background-size: 30px 30px; + opacity: 0.35; + z-index: -1; +} + +.login-body { + min-height: 100vh; + display: grid; + place-items: center; +} + +.login-card { + width: 420px; + padding: 34px; + border-radius: 18px; + background: rgba(15, 15, 15, 0.9); + border: 1px solid rgba(255, 215, 120, 0.45); + box-shadow: 0 0 25px rgba(255, 199, 54, 0.35); + backdrop-filter: blur(8px); +} + +.login-card h1 { + margin: 0 0 8px; + font-size: 28px; +} + +.sub { + margin: 0 0 20px; + color: #d2bd89; +} + +label { + display: block; + margin: 14px 0 8px; +} + +input { + width: 100%; + padding: 11px 12px; + border-radius: 8px; + border: 1px solid #5f4f2b; + background: #111; + color: #f2dfab; +} + +button, .btn-ghost { + margin-top: 18px; + display: inline-block; + padding: 10px 16px; + background: linear-gradient(135deg, #ffd24a, #cf9a15); + color: #1e1a0f; + font-weight: 700; + border: none; + border-radius: 10px; + cursor: pointer; + text-decoration: none; +} + +.error { + color: #ff7f7f; + min-height: 20px; + margin-top: 10px; +} + +.topbar { + padding: 22px 26px; + border-bottom: 1px solid rgba(255, 212, 74, 0.35); + display: flex; + justify-content: space-between; + align-items: center; + background: rgba(13, 13, 13, 0.9); +} + +.topbar h1 { + margin: 0; + font-size: 24px; +} + +.top-right { + display: flex; + gap: 12px; + align-items: center; +} + +.layout { + padding: 22px; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +.panel { + border-radius: 14px; + border: 1px solid rgba(255, 212, 74, 0.35); + background: rgba(14, 14, 14, 0.82); + padding: 16px; + min-height: 240px; +} + +.panel.wide { + grid-column: 1 / span 2; +} + +.panel h2 { + margin-top: 0; + margin-bottom: 10px; + font-size: 19px; +} + +pre { + white-space: pre-wrap; + margin: 0; + color: #f7ebc2; +} + +.list { + max-height: 360px; + overflow: auto; + display: flex; + flex-direction: column; + gap: 10px; +} + +.item { + padding: 10px; + border-radius: 10px; + border: 1px solid rgba(255, 214, 82, 0.28); + background: rgba(40, 32, 14, 0.22); +} + +.time { + color: #cab884; + font-size: 12px; +} + +.hint { + color: #b9a66d; + font-size: 13px; + margin: 0 0 12px; +} + +.row { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; +} + +.row label { + margin: 0; +} + +.row select { + min-width: 120px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid #5f4f2b; + background: #111; + color: #f2dfab; +} + +.row input { + width: 130px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid #5f4f2b; + background: #111; + color: #f2dfab; +} + +/* —— MATRIX / 黑客风云 主题 —— */ +.matrix-theme, +.matrix-login { + margin: 0; + min-height: 100vh; + font-family: ui-monospace, "Cascadia Mono", "Cascadia Code", "SF Mono", Consolas, "Liberation Mono", Menlo, monospace; + background: #020204; + color: #c8fff0; + position: relative; + overflow-x: hidden; +} + +.matrix-scanlines { + pointer-events: none; + position: fixed; + inset: 0; + z-index: 50; + background: repeating-linear-gradient( + 0deg, + rgba(0, 255, 200, 0.03), + rgba(0, 255, 200, 0.03) 1px, + transparent 1px, + transparent 3px + ); + opacity: 0.35; + mix-blend-mode: screen; +} + +.matrix-scanlines-strong { + opacity: 0.5; +} + +.matrix-noise { + pointer-events: none; + position: fixed; + inset: 0; + z-index: 40; + opacity: 0.04; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); +} + +.matrix-grid-bg { + position: fixed; + inset: 0; + z-index: 0; + background: + linear-gradient(rgba(0, 255, 213, 0.04) 1px, transparent 1px), + linear-gradient(90deg, rgba(0, 255, 213, 0.04) 1px, transparent 1px); + background-size: 48px 48px; + mask-image: radial-gradient(ellipse 80% 60% at 50% 20%, black, transparent); +} + +.matrix-header { + position: relative; + z-index: 60; + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: flex-end; + gap: 14px 20px; + padding: 20px 0 16px; + border-bottom: 1px solid rgba(0, 255, 213, 0.25); + background: linear-gradient(180deg, rgba(0, 40, 32, 0.5), transparent); + width: 100%; + max-width: min(1760px, 100%); + margin: 0 auto; + box-sizing: border-box; +} + +.matrix-brand { + max-width: 70%; +} + +.matrix-glitch:not(.matrix-glitch-xl) { + font-size: clamp(1.4rem, 3vw, 2rem); + font-weight: 800; + letter-spacing: 0.08em; + color: #00ffd5; + text-shadow: + 0 0 12px rgba(0, 255, 213, 0.75), + 2px 0 #ff00aa, + -2px 0 #00aaff; + animation: matrix-glitch-skew 3.5s infinite linear alternate; + position: relative; +} + +.matrix-glitch-xl { + position: relative; + font-weight: 800; +} + +.matrix-tagline { + margin: 8px 0 0; + font-size: 12px; + color: #5ee0c5; + opacity: 0.85; +} + +.matrix-header-actions { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; +} + +.matrix-pill { + font-size: 11px; + padding: 6px 12px; + border: 1px solid rgba(0, 255, 213, 0.45); + border-radius: 2px; + color: #00ffd5; + background: rgba(0, 20, 18, 0.85); + box-shadow: 0 0 14px rgba(0, 255, 213, 0.15); +} + +.matrix-pill.dim { + color: #7dffc8; + border-color: rgba(125, 255, 200, 0.25); +} + +.matrix-btn { + font-family: inherit; + font-size: 12px; + font-weight: 700; + padding: 8px 16px; + border-radius: 2px; + border: 1px solid #00ffd5; + background: rgba(0, 255, 213, 0.12); + color: #00ffd5; + cursor: pointer; + text-decoration: none; + text-transform: uppercase; + letter-spacing: 0.06em; + transition: background 0.15s, box-shadow 0.15s; +} + +.matrix-btn:hover { + background: rgba(0, 255, 213, 0.22); + box-shadow: 0 0 18px rgba(0, 255, 213, 0.35); +} + +.matrix-btn.ghost { + background: transparent; +} + +.matrix-btn-full { + width: 100%; + margin-top: 8px; +} + +.matrix-main { + position: relative; + z-index: 60; + padding: 20px 0 48px; + display: flex; + flex-direction: column; + gap: 18px; + width: 100%; + max-width: min(1760px, 100%); + margin: 0 auto; + box-sizing: border-box; +} + +.matrix-main.matrix-crt-inner { + padding: 8px clamp(10px, 1.8vw, 24px) 28px; + width: 100%; + max-width: min(1760px, 100%); +} + +.matrix-panel { + border: 1px solid rgba(0, 255, 213, 0.22); + background: rgba(2, 18, 14, 0.72); + box-shadow: + 0 0 0 1px rgba(0, 0, 0, 0.6), + inset 0 0 40px rgba(0, 255, 213, 0.04); + padding: 16px 18px; + position: relative; +} + +.matrix-panel-hero { + border-color: rgba(255, 0, 170, 0.35); + box-shadow: + 0 0 24px rgba(255, 0, 170, 0.12), + inset 0 0 50px rgba(0, 255, 213, 0.05); + overflow: hidden; + isolation: isolate; +} + +.matrix-panel-wide { + width: 100%; +} + +.matrix-panel-head h2 { + margin: 0; + font-size: 14px; + letter-spacing: 0.12em; + color: #7dffb3; + text-transform: uppercase; +} + +.matrix-hint { + margin: 8px 0 14px; + font-size: 11px; + color: #4db89a; + line-height: 1.5; +} + +.matrix-form-row { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; +} + +.matrix-form-row label { + font-size: 10px; + color: #5ee0c5; + margin: 0; +} + +.matrix-form-row input { + width: 100px; + padding: 7px 8px; + font-family: inherit; + font-size: 12px; + border-radius: 2px; + border: 1px solid rgba(0, 255, 213, 0.35); + background: #030806; + color: #c8fff0; +} + +.matrix-msg { + min-height: 18px; + font-size: 11px; + color: #ff6ec7; + margin-top: 8px; +} + +.matrix-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 12px; +} + +.matrix-card { + border: 1px solid rgba(0, 255, 213, 0.28); + background: linear-gradient(145deg, rgba(0, 30, 26, 0.95), rgba(4, 8, 12, 0.98)); + padding: 12px 14px; + position: relative; + overflow: hidden; +} + +.matrix-card::before { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(120deg, transparent 40%, rgba(0, 255, 213, 0.06) 50%, transparent 60%); + transform: translateX(-100%); + animation: matrix-sheen 4s ease-in-out infinite; +} + +.matrix-card.hot { + border-color: rgba(255, 0, 170, 0.55); + box-shadow: 0 0 22px rgba(255, 0, 170, 0.18); +} + +.matrix-card-title { + font-size: 15px; + font-weight: 800; + color: #00ffd5; + letter-spacing: 0.06em; +} + +.matrix-card-meta { + font-size: 10px; + color: #5ee0c5; + margin-top: 4px; +} + +.matrix-bar-wrap { + margin-top: 10px; + height: 6px; + background: rgba(0, 0, 0, 0.55); + border: 1px solid rgba(0, 255, 213, 0.2); +} + +.matrix-bar { + height: 100%; + background: linear-gradient(90deg, #00ffd5, #ff00aa); + width: 0%; + transition: width 0.4s ease; +} + +.matrix-card-line { + margin-top: 8px; + font-size: 11px; + color: #9dffd9; + line-height: 1.45; +} + +.matrix-badge { + display: inline-block; + margin-top: 8px; + font-size: 9px; + padding: 3px 8px; + border: 1px solid #ff00aa; + color: #ff9de6; + letter-spacing: 0.1em; +} + +.matrix-badge.push { + border-color: #00ffd5; + color: #00ffd5; +} + +.matrix-list { + max-height: 320px; + overflow: auto; + display: flex; + flex-direction: column; + gap: 8px; +} + +.matrix-list .item { + padding: 10px 12px; + border-radius: 2px; + border: 1px solid rgba(0, 255, 213, 0.2); + background: rgba(0, 12, 10, 0.6); + font-size: 11px; +} + +.matrix-list .time { + color: #3d8f7a; + font-size: 10px; + margin-top: 4px; +} + +.matrix-split { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +@media (max-width: 900px) { + .matrix-split { + grid-template-columns: 1fr; + } +} + +.matrix-pre { + margin: 0; + max-height: 280px; + overflow: auto; + font-size: 10px; + line-height: 1.35; + color: #7dffc8; + background: #010403; + border: 1px solid rgba(0, 255, 213, 0.15); + padding: 10px; + white-space: pre-wrap; +} + +.matrix-login { + display: grid; + place-items: center; + min-height: 100vh; +} + +.matrix-login-card { + position: relative; + z-index: 60; + width: min(420px, 92vw); + padding: 32px 28px 28px; + border: 1px solid rgba(0, 255, 213, 0.35); + background: rgba(2, 14, 12, 0.92); + box-shadow: + 0 0 40px rgba(0, 255, 213, 0.12), + inset 0 0 30px rgba(255, 0, 170, 0.05); +} + +.matrix-login-card-chrome { + overflow: visible; +} + +.matrix-login-card-chrome::before { + content: ""; + position: absolute; + inset: -2px; + border-radius: 6px; + padding: 2px; + background: linear-gradient( + 135deg, + rgba(0, 255, 213, 0.65), + rgba(255, 0, 170, 0.45), + rgba(100, 200, 255, 0.4), + rgba(0, 255, 213, 0.55) + ); + background-size: 240% 240%; + animation: matrix-border-flow 6s linear infinite; + -webkit-mask: + linear-gradient(#fff 0 0) content-box, + linear-gradient(#fff 0 0); + mask: + linear-gradient(#fff 0 0) content-box, + linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + pointer-events: none; + z-index: -1; +} + +.matrix-login-badge { + font-size: 9px; + letter-spacing: 0.25em; + color: #ff6ec7; + margin-bottom: 14px; + text-shadow: 0 0 10px rgba(255, 0, 170, 0.45); +} + +.matrix-login-glitch { + font-size: 22px; + font-weight: 800; + color: #00ffd5; + letter-spacing: 0.15em; + text-shadow: 0 0 16px rgba(0, 255, 213, 0.6); +} + +.matrix-login-sub { + margin: 10px 0 20px; + font-size: 11px; + color: #4db89a; + line-height: 1.5; +} + +.matrix-login-form { + display: flex; + flex-direction: column; + gap: 4px; +} + +.matrix-label { + font-size: 10px; + color: #5ee0c5; + margin-top: 10px; +} + +.matrix-form-row-tight { + margin-top: 10px; +} + +.matrix-textarea { + width: 100%; + max-width: 100%; + box-sizing: border-box; + min-height: 100px; + resize: vertical; + line-height: 1.45; + margin-top: 8px; +} + +.matrix-input { + font-family: inherit; + padding: 10px 12px; + border-radius: 2px; + border: 1px solid rgba(0, 255, 213, 0.35); + background: #020403; + color: #c8fff0; +} + +.matrix-error { + margin-top: 14px; + min-height: 18px; + font-size: 11px; + color: #ff4d9a; +} + +@keyframes matrix-glitch-skew { + 0% { + transform: skewX(0deg); + } + 12% { + transform: skewX(-0.6deg); + } + 24% { + transform: skewX(0.4deg); + } + 100% { + transform: skewX(0deg); + } +} + +@keyframes matrix-sheen { + 0% { + transform: translateX(-120%); + } + 60% { + transform: translateX(120%); + } + 100% { + transform: translateX(120%); + } +} + +/* —— 炫酷增强:CRT / HUD / 代码雨 / 霓虹框 —— */ +.matrix-rain-canvas { + position: fixed; + inset: 0; + z-index: 12; + opacity: 0.22; + pointer-events: none; +} + +.matrix-vignette { + position: fixed; + inset: 0; + z-index: 45; + pointer-events: none; + background: radial-gradient(ellipse at 50% 40%, transparent 0%, transparent 45%, rgba(0, 0, 0, 0.55) 100%); + mix-blend-mode: multiply; +} + +.matrix-aurora { + position: fixed; + inset: -20%; + z-index: 8; + pointer-events: none; + background: + radial-gradient(ellipse 50% 40% at 20% 30%, rgba(255, 0, 170, 0.12), transparent 55%), + radial-gradient(ellipse 40% 35% at 85% 20%, rgba(0, 255, 213, 0.1), transparent 50%), + radial-gradient(ellipse 60% 50% at 50% 100%, rgba(100, 80, 255, 0.08), transparent 45%); + animation: matrix-aurora-drift 18s ease-in-out infinite alternate; +} + +@keyframes matrix-aurora-drift { + 0% { + transform: translate(0, 0) rotate(0deg); + } + 100% { + transform: translate(-2%, 1%) rotate(2deg); + } +} + +.matrix-chrome { + position: relative; + z-index: 60; + width: 100%; + max-width: 100%; + box-sizing: border-box; + padding-left: clamp(12px, 2.5vw, 36px); + padding-right: clamp(12px, 2.5vw, 36px); +} + +.matrix-hud { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(118px, 1fr)); + gap: 10px 12px; + align-items: stretch; + padding: 12px 0 10px; + margin: 0 auto; + width: 100%; + max-width: min(1760px, 100%); + box-sizing: border-box; + border-bottom: 1px solid rgba(0, 255, 213, 0.15); + background: linear-gradient(180deg, rgba(0, 25, 22, 0.92), rgba(0, 8, 10, 0.75)); +} + +@media (min-width: 1100px) { + .matrix-hud { + grid-template-columns: minmax(132px, 1.1fr) repeat(4, minmax(104px, 0.9fr)) minmax(220px, 2fr); + } +} + +.matrix-hud-block { + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px 12px; + min-width: 0; + min-height: 52px; + border: 1px solid rgba(0, 255, 213, 0.28); + background: linear-gradient(160deg, rgba(0, 40, 36, 0.55), rgba(2, 6, 8, 0.9)); + box-shadow: + 0 0 20px rgba(0, 255, 213, 0.08), + inset 0 1px 0 rgba(255, 255, 255, 0.04); + position: relative; + overflow: visible; +} + +.matrix-hud-block::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 2px; + background: linear-gradient(90deg, transparent, #00ffd5, #ff00aa, transparent); + opacity: 0.5; + animation: matrix-hud-scan 2.8s linear infinite; +} + +@keyframes matrix-hud-scan { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(100%); + } +} + +.matrix-hud-wide { + grid-column: 1 / -1; +} + +@media (min-width: 1100px) { + .matrix-hud-wide { + grid-column: auto; + } +} + +.matrix-hud-live { + flex-direction: row; + align-items: center; + border-color: rgba(0, 255, 170, 0.5); + box-shadow: 0 0 24px rgba(0, 255, 200, 0.12); +} + +.matrix-hud-stack { + display: flex; + flex-direction: column; + gap: 2px; +} + +.matrix-hud-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #00ff9d; + box-shadow: 0 0 10px #00ff9d; + animation: matrix-hud-blink 1.2s ease-in-out infinite; + margin-right: 8px; + align-self: center; +} + +@keyframes matrix-hud-blink { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.35; + } +} + +.matrix-hud-label { + font-size: 9px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #3d9a88; + white-space: nowrap; + overflow: visible; +} + +.matrix-hud-val { + font-size: 12px; + font-weight: 700; + color: #b8fff0; + text-shadow: 0 0 12px rgba(0, 255, 213, 0.35); + min-width: 0; + overflow-wrap: anywhere; + word-break: break-word; +} + +.matrix-hud-val.mono { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.04em; + word-break: break-all; +} + +.matrix-header-crt { + border-bottom: none; + padding-top: 8px; + background: linear-gradient(180deg, rgba(0, 30, 28, 0.65), transparent); +} + +.matrix-title-wrap { + position: relative; +} + +.matrix-glitch-xl { + font-size: clamp(1.6rem, 4vw, 2.35rem); + letter-spacing: 0.12em; + line-height: 1.15; + min-height: 1.2em; +} + +.matrix-glitch-base, +.matrix-glitch-layer { + display: block; +} + +.matrix-glitch-base { + position: relative; + z-index: 3; + color: #e8fff9; + text-shadow: + 0 0 20px rgba(0, 255, 213, 0.9), + 0 0 40px rgba(0, 255, 213, 0.35); +} + +.matrix-glitch-layer { + position: absolute; + left: 0; + top: 0; + z-index: 1; + width: 100%; + pointer-events: none; + mix-blend-mode: screen; +} + +.matrix-glitch-c { + color: #00fff7; + animation: matrix-glitch-x 2.8s infinite steps(2, end); + clip-path: inset(0 0 0 0); +} + +.matrix-glitch-m { + color: #ff2da8; + animation: matrix-glitch-x 3.1s infinite reverse steps(2, end); + transform: translate(2px, -1px); +} + +@keyframes matrix-glitch-x { + 0% { + clip-path: inset(0 88% 0 0); + opacity: 0.85; + } + 20% { + clip-path: inset(0 40% 30% 0); + opacity: 0.9; + } + 40% { + clip-path: inset(15% 0 55% 0); + opacity: 0.75; + } + 60% { + clip-path: inset(0 55% 10% 0); + opacity: 0.95; + } + 100% { + clip-path: inset(0 0 70% 0); + opacity: 0.8; + } +} + +.matrix-subdeck { + display: flex; + align-items: baseline; + gap: 6px; + margin-top: 10px; +} + +.matrix-deco { + color: #ff00aa; + font-weight: 800; + font-size: 14px; + text-shadow: 0 0 12px rgba(255, 0, 170, 0.6); +} + +.matrix-tagline-glow { + animation: matrix-tagline-pulse 4s ease-in-out infinite; +} + +@keyframes matrix-tagline-pulse { + 0%, + 100% { + opacity: 0.75; + text-shadow: 0 0 8px rgba(0, 255, 213, 0.25); + } + 50% { + opacity: 1; + text-shadow: 0 0 16px rgba(0, 255, 213, 0.45); + } +} + +.matrix-pill-clock { + font-variant-numeric: tabular-nums; + border-color: rgba(255, 0, 170, 0.45); + color: #ffc8ef; + box-shadow: 0 0 16px rgba(255, 0, 170, 0.12); +} + +.matrix-crt-inner { + border: 1px solid rgba(0, 255, 213, 0.12); + border-radius: 4px; + margin: 0 auto 24px; + padding: 20px 20px 32px; + background: linear-gradient(180deg, rgba(0, 12, 10, 0.35), rgba(0, 0, 0, 0.25)); + box-shadow: + inset 0 0 80px rgba(0, 0, 0, 0.45), + 0 0 0 1px rgba(0, 255, 213, 0.08); +} + +.matrix-panel-chrome { + position: relative; + border-radius: 4px; + overflow: visible; +} + +.matrix-panel-chrome::before { + content: ""; + position: absolute; + inset: -1px; + border-radius: 5px; + padding: 1px; + background: linear-gradient( + 120deg, + rgba(0, 255, 213, 0.5), + rgba(255, 0, 170, 0.35), + rgba(120, 100, 255, 0.4), + rgba(0, 255, 213, 0.45) + ); + background-size: 300% 300%; + animation: matrix-border-flow 8s linear infinite; + -webkit-mask: + linear-gradient(#fff 0 0) content-box, + linear-gradient(#fff 0 0); + mask: + linear-gradient(#fff 0 0) content-box, + linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + pointer-events: none; + z-index: 0; +} + +.matrix-panel-chrome > *:not(.matrix-hero-radar) { + position: relative; + z-index: 1; +} + +.matrix-panel-chrome > .matrix-hero-radar { + z-index: 0; +} + +@keyframes matrix-border-flow { + 0% { + background-position: 0% 50%; + } + 100% { + background-position: 200% 50%; + } +} + +.matrix-panel-head-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.matrix-chip { + font-size: 9px; + letter-spacing: 0.18em; + padding: 4px 10px; + border: 1px solid rgba(0, 255, 213, 0.4); + color: #00ffd5; +} + +.matrix-chip-magenta { + border-color: rgba(255, 0, 170, 0.55); + color: #ffb8e8; + box-shadow: 0 0 14px rgba(255, 0, 170, 0.2); + animation: matrix-chip-flicker 3s steps(2) infinite; +} + +@keyframes matrix-chip-flicker { + 0%, + 90% { + opacity: 1; + } + 92% { + opacity: 0.5; + } + 94% { + opacity: 1; + } +} + +.matrix-two-col { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18px; +} + +@media (max-width: 960px) { + .matrix-two-col { + grid-template-columns: 1fr; + } +} + +.matrix-btn-pulse { + animation: matrix-btn-glow 2.5s ease-in-out infinite; +} + +@keyframes matrix-btn-glow { + 0%, + 100% { + box-shadow: 0 0 8px rgba(0, 255, 213, 0.2); + } + 50% { + box-shadow: 0 0 22px rgba(0, 255, 213, 0.45); + } +} + +.matrix-pre-glow { + box-shadow: inset 0 0 24px rgba(0, 255, 213, 0.04); +} + +.matrix-list-logs { + max-height: 280px; +} + +.matrix-list-item { + border-left: 3px solid rgba(0, 255, 213, 0.35); + transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease; +} + +.matrix-list-item:hover { + transform: translateX(4px); + border-left-color: #ff00aa; + box-shadow: -6px 0 20px rgba(255, 0, 170, 0.12); +} + +.matrix-card { + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.matrix-card:hover { + transform: translateY(-3px) scale(1.01); + box-shadow: + 0 12px 32px rgba(0, 0, 0, 0.45), + 0 0 28px rgba(0, 255, 213, 0.15); +} + +.matrix-hint-empty { + padding: 28px 16px; + text-align: center; + border: 1px dashed rgba(0, 255, 213, 0.25); + background: rgba(0, 0, 0, 0.25); +} + +.matrix-hint-empty code { + color: #ff9de0; + font-size: 11px; +} + +.matrix-empty-icon { + display: block; + font-size: 28px; + color: #ff00aa; + text-shadow: 0 0 20px rgba(255, 0, 170, 0.5); + margin-bottom: 8px; +} + +.matrix-row-title { + margin-bottom: 4px; +} + +.matrix-dim { + color: #4a8f7c; + font-size: 10px; +} + +.matrix-log-lvl-error { + color: #ff5c9d; + text-shadow: 0 0 8px rgba(255, 0, 100, 0.4); +} + +.matrix-log-lvl-warn { + color: #ffd54a; +} + +.matrix-log-lvl-info { + color: #5ee0c5; +} + +.matrix-bar { + position: relative; + overflow: hidden; +} + +.matrix-bar::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.35), transparent); + animation: matrix-bar-shine 2s ease-in-out infinite; +} + +@keyframes matrix-bar-shine { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(100%); + } +} + +/* —— 雷达扫描(页头 HUD + 漏斗区背景) —— */ +.matrix-radar-header { + flex: 0 0 auto; + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; + margin: 0 4px; +} + +.matrix-radar-hud { + width: 86px; + height: 86px; + border-radius: 50%; + position: relative; + border: 1px solid rgba(0, 255, 213, 0.5); + box-shadow: + inset 0 0 36px rgba(0, 255, 213, 0.14), + 0 0 32px rgba(0, 255, 213, 0.22), + inset 0 0 0 1px rgba(255, 0, 170, 0.15); + background: radial-gradient(circle at 50% 50%, rgba(0, 45, 40, 0.95) 0%, rgba(0, 6, 10, 1) 58%, rgba(0, 25, 22, 0.5) 59%); + overflow: hidden; +} + +.matrix-radar-h-graticule { + position: absolute; + inset: 0; + border-radius: 50%; + background: repeating-conic-gradient( + from 0deg at 50% 50%, + transparent 0deg 11.25deg, + rgba(0, 255, 200, 0.09) 11.25deg 11.8deg + ); + opacity: 0.85; +} + +.matrix-radar-h-cross { + position: absolute; + inset: 0; + border-radius: 50%; + background: + linear-gradient(90deg, transparent calc(50% - 1px), rgba(0, 255, 213, 0.25) 50%, transparent calc(50% + 1px)), + linear-gradient(0deg, transparent calc(50% - 1px), rgba(0, 255, 213, 0.25) 50%, transparent calc(50% + 1px)); + opacity: 0.6; +} + +.matrix-radar-h-rings { + position: absolute; + inset: 10%; + border-radius: 50%; + border: 1px solid rgba(0, 255, 213, 0.18); + box-shadow: + inset 0 0 0 1px rgba(0, 255, 213, 0.1), + inset 0 0 0 14px rgba(0, 255, 213, 0.06), + inset 0 0 0 15px rgba(0, 0, 0, 0.2), + inset 0 0 0 28px rgba(255, 0, 170, 0.05); +} + +.matrix-radar-h-sweep { + position: absolute; + inset: -10%; + border-radius: 50%; + background: conic-gradient( + from 0deg at 50% 50%, + transparent 0deg, + transparent 260deg, + rgba(0, 255, 200, 0.15) 285deg, + rgba(0, 255, 220, 0.55) 305deg, + rgba(0, 255, 200, 0.2) 320deg, + transparent 340deg, + transparent 360deg + ); + animation: matrix-radar-spin 3.2s linear infinite; +} + +.matrix-radar-h-sweep-ghost { + inset: -5%; + opacity: 0.35; + animation-duration: 3.2s; + animation-direction: reverse; + filter: blur(0.5px); +} + +@keyframes matrix-radar-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.matrix-radar-h-blip { + position: absolute; + width: 6px; + height: 6px; + left: 68%; + top: 32%; + border-radius: 50%; + background: #ff2da8; + box-shadow: 0 0 10px #ff2da8, 0 0 20px rgba(255, 0, 170, 0.5); + animation: matrix-radar-blip 2.1s ease-in-out infinite; +} + +@keyframes matrix-radar-blip { + 0%, + 100% { + opacity: 0.35; + transform: scale(0.85); + } + 45% { + opacity: 1; + transform: scale(1.15); + } +} + +.matrix-radar-h-caption { + font-size: 8px; + letter-spacing: 0.35em; + color: #5ee0c5; + text-shadow: 0 0 8px rgba(0, 255, 213, 0.4); +} + +.matrix-hero-radar { + position: absolute; + right: -12%; + top: 50%; + width: min(380px, 55vw); + height: min(380px, 55vw); + transform: translateY(-50%); + pointer-events: none; + z-index: 0; + opacity: 0.22; +} + +.matrix-hero-radar-grid { + position: absolute; + inset: 0; + border-radius: 50%; + border: 1px solid rgba(0, 255, 213, 0.25); + background: repeating-conic-gradient( + from 0deg at 50% 50%, + transparent 0deg 7.5deg, + rgba(0, 255, 200, 0.07) 7.5deg 8deg + ); +} + +.matrix-hero-radar-ring { + position: absolute; + inset: 12%; + border-radius: 50%; + border: 1px solid rgba(255, 0, 170, 0.12); + box-shadow: + inset 0 0 40px rgba(0, 255, 213, 0.06), + inset 0 0 0 1px rgba(0, 255, 213, 0.08); +} + +.matrix-hero-radar-sweep { + position: absolute; + inset: -5%; + border-radius: 50%; + background: conic-gradient( + from 0deg at 50% 50%, + transparent 0deg 250deg, + rgba(0, 255, 200, 0.08) 270deg, + rgba(0, 255, 220, 0.35) 298deg, + rgba(255, 0, 170, 0.12) 312deg, + transparent 330deg + ); + animation: matrix-radar-spin 5.5s linear infinite; +} + +.matrix-hero-radar-sweep-trail { + inset: 0; + opacity: 0.45; + animation-duration: 5.5s; + animation-direction: reverse; + filter: blur(1px); +} + +@media (max-width: 720px) { + .matrix-radar-header { + order: 3; + width: 100%; + flex-direction: row; + justify-content: center; + } + + .matrix-hero-radar { + opacity: 0.12; + right: -25%; + } +} + +.matrix-table-wrap { + width: 100%; + overflow-x: auto; + margin-bottom: 10px; +} + +.matrix-table { + width: 100%; + border-collapse: collapse; + min-width: 700px; +} + +.matrix-table th, +.matrix-table td { + border: 1px solid rgba(0, 255, 200, 0.2); + padding: 6px 8px; + text-align: left; +} + +.matrix-table th { + color: #8ef7dc; + font-weight: 700; +} + +.matrix-table td input[type="text"], +.matrix-table td input[type="number"] { + width: 100%; + margin: 0; + padding: 6px 8px; +} + +.matrix-table td input[type="checkbox"] { + width: auto; + margin: 0; +} + diff --git a/onchain_scout_gate/templates/dashboard.html b/onchain_scout_gate/templates/dashboard.html new file mode 100644 index 0000000..2c9a6fc --- /dev/null +++ b/onchain_scout_gate/templates/dashboard.html @@ -0,0 +1,229 @@ + + + + + + MATRIX · FUNNEL + + + + + + + + + +
+
+
+ +
+ LINK + SYNC… +
+
+
+ CYCLE + +
+
+ BTC·辅 + +
+
+ POOL + +
+
+ PUSH + +
+
+ GEMMA + +
+
+ LAST + +
+
+ +
+
+
+
+ MATRIX // FUNNEL + MATRIX // FUNNEL + MATRIX // FUNNEL +
+
+ [ + Gate USDT 永续 · 5m 结构 → 日线漏斗 · Gemma 优先链 + ] +
+
+
+ +
+ --:--:-- + OP ▸ {{ username }} + 断开链路 +
+
+ +
+
+ +
+

// GEMMA 漏斗 · 优先矩阵

+ LIVE FEED +
+

合成评分 · 成交量 · 日线结构 · 上方空间 · 中间阻力 → 达标企业微信推送

+

// 数据同步中…

+
+
+ +
+
+

// 每日晨报 · 昨日复盘

+ +
+
+ + + + + + + + + +
+

+

// 等待晨报数据…

+
+
+ +
+
+

// 监控黑名单 · base

+ 0 条规则 +
+

+ Gate USDT 永续左侧 base(每行一个或逗号分隔),如 XAU XAUT XAGall_swaps 与 + watchlist 均会在入池后剔除。保存后下一轮扫描生效。 +

+ +
+ +
+

+
+ +
+
+

// 下单执行器 · 转发链

+ 仅扫描端维护 · 同一信号广播 +
+

+ 企微突破推送成功后,向列表中已启用的执行器 POST /v1/signal(方案 A 止盈止损)。 + 各执行器自行配置 Gate API、盈亏比、移动保本等;不支持执行器反向注册。 + 修改 webhook 密钥后请同步到各执行器 security.webhook_secret。 +

+
+ + + + + + + +
+

+
+ + + + + + + +
+

+
+
+ +
+

// 策略寄存器 · 5m

+

横盘 + 5m 收盘上破 + 放量 · 保存后下一轮生效 · 止损缓冲为企微区间A/B共用

+
+ + + + + + + + + + + + + + + +
+

+
+ +
+
+

// 观察层 · WATCH

+
+
+
+

// 触发层 · TRIGGER

+
+
+
+ +
+

// 系统遥测

+
+

+                    

+                
+
+ +
+

// 运行日志

+
+
+
+
+ + + + diff --git a/onchain_scout_gate/templates/login.html b/onchain_scout_gate/templates/login.html new file mode 100644 index 0000000..473848a --- /dev/null +++ b/onchain_scout_gate/templates/login.html @@ -0,0 +1,30 @@ + + + + + + MATRIX · 接入 + + + + + + + + + + + + diff --git a/onchain_scout_gate/tests/test_order_executors_store.py b/onchain_scout_gate/tests/test_order_executors_store.py new file mode 100644 index 0000000..8949fa9 --- /dev/null +++ b/onchain_scout_gate/tests/test_order_executors_store.py @@ -0,0 +1,75 @@ +"""执行器列表存储单元测试。""" +from __future__ import annotations + +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from app.config import OrderExecutorConfig, Settings, AppConfig, AuthConfig, WeComConfig, GateConfig +from app import order_executors_store as store + + +def _minimal_settings() -> Settings: + return Settings( + app=AppConfig( + host="127.0.0.1", + port=8088, + poll_interval_seconds=60, + log_file="./runtime/system.log", + database_url="sqlite+aiosqlite:///./runtime/t.db", + session_secret="x", + ), + auth=AuthConfig(enabled=False, username="a", password="b"), + wecom=WeComConfig(webhook="https://example.com/hook"), + gate=GateConfig(), + order_executor=OrderExecutorConfig( + enabled=True, + base_url="http://127.0.0.1:8090", + webhook_secret="sec", + timeout_seconds=15.0, + ), + ) + + +class TestOrderExecutorsStore(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.path = Path(self.tmp.name) / "order_executors.json" + self._patch = patch.object(store, "_STORE_PATH", self.path) + self._patch.start() + + def tearDown(self) -> None: + self._patch.stop() + self.tmp.cleanup() + + def test_migrate_from_settings(self) -> None: + s = _minimal_settings() + store.ensure_store_initialized(s) + snap = store.read_snapshot(s) + self.assertTrue(snap["enabled"]) + self.assertEqual(snap["webhook_secret"], "sec") + self.assertEqual(len(snap["executors"]), 1) + self.assertEqual(snap["executors"][0]["base_url"], "http://127.0.0.1:8090") + + def test_add_and_active(self) -> None: + s = _minimal_settings() + store.ensure_store_initialized(s) + row = store.add_executor(s, name="b", base_url="http://127.0.0.1:8091", enabled=True) + active = store.active_executors(s) + urls = {e["base_url"] for e in active} + self.assertIn("http://127.0.0.1:8090", urls) + self.assertIn("http://127.0.0.1:8091", urls) + self.assertEqual(row["name"], "b") + + def test_write_global(self) -> None: + s = _minimal_settings() + store.ensure_store_initialized(s) + snap = store.write_global_settings(s, enabled=False, webhook_secret="new") + self.assertFalse(snap["enabled"]) + self.assertEqual(snap["webhook_secret"], "new") + + +if __name__ == "__main__": + unittest.main() diff --git a/onchain_scout_gate/交易系统部署说明.md b/onchain_scout_gate/交易系统部署说明.md new file mode 100644 index 0000000..06146d5 --- /dev/null +++ b/onchain_scout_gate/交易系统部署说明.md @@ -0,0 +1,206 @@ +# 交易系统部署说明(Gate USDT 永续 · PM2) + +## 1. 系统概要 + +本系统是基于 Python 的云端监控服务,仅使用 **Gate.io** 交易所公共 API(REST v4)。 + +- 监控市场:Gate **USDT 本位线性永续**(合约名如 `BTC_USDT`) +- 方向:策略支持多空信号;企业微信推送文案随信号方向变化 +- 周期:监控主循环固定 **5m** +- 信号分级:WATCH / TRIGGER +- 数据:`/futures/usdt/contracts`、`/tickers`、`/candlesticks` + +## 2. 当前策略(摘要) + +- WATCH:横盘结构成立 +- TRIGGER:横盘 + 5m 收盘突破边界 + 放量 +- 可调参数:横盘时长、振幅、放量倍数、回看根数、缓冲(见 Web 面板 / SQLite `kv_store`) + +## 3. config.yaml 示例 + +```yaml +app: + host: 0.0.0.0 + port: 8088 + poll_interval_seconds: 300 + log_file: ./runtime/system.log + database_url: sqlite+aiosqlite:///./runtime/alerts.db + session_secret: please-replace-with-strong-random-value + +auth: + username: admin + password: ChangeThisPassword! + +wecom: + webhook: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=replace + mentioned_mobile_list: [] + +gate: + api_base: https://api.gateio.ws/api/v4 + settle: usdt + quote_currency: USDT + +monitor: + universe: all_swaps + min_24h_quote_volume_usdt: 10000000 + +watch_symbols: [] + +# 可选:与并列项目 gate_order_executor 联动(企微推送成功后再 POST /v1/signal) +order_executor: + enabled: false + base_url: "http://127.0.0.1:8090" + webhook_secret: "same-as-executor-security-webhook_secret" + timeout_seconds: 15 +``` + +### 3.1 企微与自动下单 + +- 默认仅 **企业微信** 文本告警。若部署 **gate_order_executor** 并设置 `order_executor.enabled: true`、**`webhook_secret` 与执行器一致**,则在 **企微推送成功之后** 自动向执行器发结构化信号(方案 A 止盈/止损与企微文案一致)。 +### 3.2 执行器联调(curl,无面板按钮) + +并列项目 **`gate_order_executor`** 的 Web 面板 **不再提供**「拉取余额 / 测试市价」入口;需在服务器用 **`curl`** 或脚本调用 **`POST /api/test`**、**`POST /v1/test`** 做联调(`micro_market` 须 `gate.test_orders_enabled: true`)。**完整命令与鉴权说明**见 **`gate_order_executor/docs/使用说明.md` §4.1** 与 **`gate_order_executor/docs/部署说明.md` §11**。 + +## 4. 云端部署(Python venv + PM2,推荐) + +以下以 Ubuntu/Debian、项目路径 `/root/onchain_scout_gate` 为例: + +### 4.1 系统依赖 + +```bash +apt update && apt install -y python3 python3-pip python3-venv curl +``` + +安装 Node.js(用于 PM2),参见 NodeSource 或发行版自带 `nodejs` / `npm`。 + +```bash +npm install -g pm2 +``` + +### 4.2 上传项目 + +将项目放到 `/root/onchain_scout_gate`(包含 `app/`、`requirements.txt`、`deploy/ecosystem.config.cjs`)。 + +### 4.3 虚拟环境与 Python 依赖 + +```bash +cd /root/onchain_scout_gate && \ +python3 -m venv .venv && \ +source .venv/bin/activate && \ +python -m pip install -U pip && \ +pip install -r requirements.txt +``` + +### 4.4 配置文件 + +```bash +nano /root/onchain_scout_gate/config.yaml +``` + +至少修改:`auth`、`session_secret`、`wecom.webhook`、`monitor`。**旧版 `okx:` 配置需改为 `gate:`**(见上方示例)。 + +### 4.5 PM2 启动与自检 + +在项目根目录执行: + +```bash +cd /root/onchain_scout_gate +pm2 start deploy/ecosystem.config.cjs +pm2 logs onchain-scout +``` + +验证:浏览器访问 `http://服务器IP:8088`(端口以 `config.yaml` 为准)。 + +常用运维: + +```bash +pm2 restart onchain-scout +pm2 save +pm2 startup # 按提示配置开机自启 +``` + +日志:`runtime/system.log`(应用轮转日志)、`runtime/pm2-out.log` / `runtime/pm2-error.log`(PM2)。 + +## 5. 前台调试(非守护) + +便于排查问题时临时使用: + +```bash +cd /root/onchain_scout_gate && source .venv/bin/activate && \ +python -m app.main +``` + +或:`uvicorn app.main:app --host 0.0.0.0 --port 8088 --workers 1` + +## 6. systemd(可选) + +若希望用 systemd 托管 **pm2-runtime**(保持进程在前台供 systemd 监控),可复制并修改 `deploy/onchain-scout.service` 中的路径与 `ExecStart`。**生产环境更常见做法是仅用 PM2 自带的 `pm2 startup`。** + +## 7. 多执行器转发(Web 面板维护) + +同一套突破信号可向 **多个** `gate_order_executor` 广播(对照实验:各执行器自行配置盈亏比、仓位等)。 + +| 部署 | 操作 | +|------|------| +| **单账户** | 面板「下单执行器」中保留 **1 条** Base URL(如 `http://127.0.0.1:8090`) | +| **多账户** | 添加多条 URL(如 `:8090`、`:8091`),各进程绑定不同 Gate API | +| **暂停某一账户** | 将该条设为「停用」,或关闭总开关 | +| **Webhook** | 面板保存的密钥须与各执行器 `security.webhook_secret` **一致**(改后需同步执行器 config) | + +- 列表保存在 `runtime/order_executors.json`,**仅扫描端维护**,执行器不会反向注册。 +- 转发请求 **不走** `proxy`,直连 `base_url`(同机可用 `127.0.0.1`)。 +- 详细设计见 `docs/多执行器与信号转发归档.md`。 + +## 8. 云服务器:关闭代理 + +本机开发若使用 Clash 等 SOCKS(`proxy.enabled: true`),迁到 **可直连 Gate 的境外云主机** 后应关闭代理。 + +### 8.1 扫描端 `config.yaml` + +```yaml +proxy: + enabled: false + url: "socks5h://127.0.0.1:1080" # enabled=false 时可保留 +``` + +修改后:`pm2 restart onchain-scout`(或你的 PM2 应用名)。 + +**说明:** + +- `proxy` 仅影响 **Gate 行情** 请求;企业微信、转发执行器均为 **直连**。 +- 自检:`curl -I --max-time 15 https://api.gateio.ws` 成功后再关代理。 + +### 8.2 执行器 `gate_order_executor/config.yaml`(每个实例) + +```yaml +proxy: + enabled: false +``` + +每个执行器进程改完后分别 `pm2 restart gate-order-executor`(多实例用不同应用名/端口)。 + +### 8.3 同机典型拓扑 + +```text +onchain_scout_gate :8088 proxy.enabled: false +gate_order_executor :8090 账户 A +gate_order_executor :8091 账户 B(第二份目录或第二 PM2 应用) +``` + +面板两条 Base URL 指向上述地址即可。 + +## 9. 常见问题 + +| 现象 | 处理 | +|------|------| +| `配置文件校验失败` / 缺少 `gate` | 将 `config.yaml` 中 `okx:` 改为本文 §3 的 `gate:` 段 | +| ModuleNotFoundError | 进入 `.venv` 后执行 `pip install -r requirements.txt` | +| 拉不到行情 | 检查网络、`proxy`、防火墙;可选 `curl -I https://api.gateio.ws` | +| 限流 / 周期过长 | 增大 `poll_interval_seconds` 或提高 `min_24h_quote_volume_usdt` | +| 有 TRIGGER 但未下单 | 看面板执行器总开关、列表是否为空、webhook 是否一致;查运行日志 `order_executor_*` | + +## 10. 运维建议 + +- 公网建议 Nginx 反代 + HTTPS,8088 仅内网暴露。 +- 定期备份:`runtime/alerts.db`、`runtime/order_executors.json`。 +- 修改 `config.yaml` 后执行 **`pm2 restart onchain-scout`**(执行器列表以面板为准,已有 `order_executors.json` 不会被 yaml 覆盖)。 diff --git a/onchain_scout_gate/安装与说明.md b/onchain_scout_gate/安装与说明.md new file mode 100644 index 0000000..1e32eda --- /dev/null +++ b/onchain_scout_gate/安装与说明.md @@ -0,0 +1,67 @@ +# 交易系统部署说明(Gate USDT 永续) + +## 1. 系统概要 + +本系统是基于 Python 的监控服务,使用 **Gate.io** 公共 API(USDT 永续)。 + +- 监控市场:Gate USDT 线性永续(如 `BTC_USDT`) +- 周期:固定 5m 扫描 +- 信号分级:WATCH / TRIGGER + +## 2. config.yaml(节选) + +```yaml +gate: + api_base: https://api.gateio.ws/api/v4 + settle: usdt + quote_currency: USDT + +monitor: + universe: all_swaps + min_24h_quote_volume_usdt: 10000000 + +watch_symbols: [] +``` + +完整示例见 `config.example.yaml`。若仍使用旧字段 **`okx:`**,请改为 **`gate:`**,否则服务无法启动。 + +## 3. 安装(Linux 示例) + +```bash +apt update && apt install -y python3 python3-pip python3-venv +cd /root/onchain_scout_gate +python3 -m venv .venv +source .venv/bin/activate +pip install -U pip +pip install -r requirements.txt +cp -n config.example.yaml config.yaml +nano config.yaml +``` + +## 4. 生产部署:PM2(推荐) + +```bash +npm install -g pm2 +cd /root/onchain_scout_gate +pm2 start deploy/ecosystem.config.cjs +pm2 logs onchain-scout +``` + +开机自启:`pm2 save` 与 `pm2 startup`(按 CLI 提示执行)。 + +## 5. 前台调试 + +```bash +cd /root/onchain_scout_gate && source .venv/bin/activate && python -m app.main +``` + +## 6. 常见问题 + +- 配置报错:确认已使用 `gate:` 配置块。 +- 依赖缺失:在 venv 内重装 `requirements.txt`。 +- 网络:需要能访问 `api.gateio.ws`(或通过 `proxy`)。 + +## 7. 运维 + +- 备份 `runtime/alerts.db` +- 改配置后:`pm2 restart onchain-scout` diff --git a/部署说明.md b/部署说明.md new file mode 100644 index 0000000..fd038a6 --- /dev/null +++ b/部署说明.md @@ -0,0 +1,428 @@ +# gate_scout_order · 完整部署说明 + +本文档面向 **Linux 云服务器(Ubuntu / Debian)** 从零部署 **扫描端 + 下单执行器**,仓库地址: + +**https://git.bz121.com/dekun/gate_scout_order.git** + +(Gitea 私有仓库:[dekun/gate_scout_order](https://git.bz121.com/dekun/gate_scout_order)) + +--- + +## 目录 + +1. [系统架构](#1-系统架构) +2. [升级前备份(已在跑旧版必读)](#2-升级前备份已在跑旧版必读) +3. [服务器环境准备](#3-服务器环境准备) +4. [克隆代码](#4-克隆代码) +5. [部署扫描端 onchain_scout_gate](#5-部署扫描端-onchain_scout_gate) +6. [部署执行器 gate_order_executor](#6-部署执行器-gate_order_executor) +7. [扫描端与执行器串联](#7-扫描端与执行器串联) +8. [多执行器 / 多账户](#8-多执行器--多账户) +9. [云服务器关闭代理](#9-云服务器关闭代理) +10. [日常运维与升级](#10-日常运维与升级) +11. [防火墙与安全](#11-防火墙与安全) +12. [故障速查](#12-故障速查) + +--- + +## 1. 系统架构 + +```text +/opt/gate_scout_order/ ← git clone 根目录 +├── onchain_scout_gate/ ← 扫描端 :8088 +│ ├── config.yaml ← 本地配置(不入库) +│ └── runtime/ +│ ├── alerts.db ← 告警、面板策略参数 +│ └── order_executors.json ← 执行器转发列表(面板维护) +└── gate_order_executor/ ← 执行器 :8090(可多实例) + ├── config.yaml + └── runtime/ + ├── signals.sqlite ← 信号流 / 执行结果 + ├── risk_prefs.json ← 面板「最低盈亏比」 + ├── breakeven_prefs.json ← 移动保本开关 + └── breakeven_active.json ← 移动保本运行态 +``` + +| 服务 | PM2 名称 | 默认端口 | 作用 | +|------|----------|----------|------| +| 扫描端 | `onchain-scout` | 8088 | Gate 5m 监控、企微告警、向执行器 POST 信号 | +| 执行器 | `gate-order-executor` | 8090 | 接信号、Gate 下单、止盈止损、移动保本 | + +--- + +## 2. 升级前备份(已在跑旧版必读) + +若你 **之前已在服务器上运行** 扫描端和/或执行器(目录可能是 `/root/onchain_scout_gate`、`/root/gate_order_executor` 等),在 **停进程、拉新代码或改目录** 之前,请先备份下列文件。**`config.yaml` 与 `runtime/` 不会进 Git**,丢失后需重新填密钥并丢失历史记录。 + +### 2.1 必须备份(丢失难恢复) + +| 来源目录 | 文件 | 内容 | +|----------|------|------| +| **扫描端** `onchain_scout_gate/` | `config.yaml` | 企微 Webhook、auth 密码、`session_secret`、proxy、monitor 等 | +| **扫描端** | `runtime/alerts.db` | 历史告警、**Web 面板写入的策略参数**(横盘/放量/黑名单/晨报开关等 SQLite KV) | +| **扫描端** | `runtime/order_executors.json` | **执行器转发列表**、总开关、面板里的 Webhook 密钥(若已用面板配置) | +| **执行器** `gate_order_executor/` | `config.yaml` | Gate `api_key` / `api_secret`、`webhook_secret`、`dry_run`、风险参数等 | +| **执行器** | `runtime/signals.sqlite` | **信号流与每笔下单结果**(面板导出/对账) | +| **执行器** | `runtime/risk_prefs.json` | 面板保存的 **最低盈亏比**(覆盖 config 默认) | +| **执行器** | `runtime/breakeven_prefs.json` | **移动保本** 全局/单合约开关 | +| **执行器** | `runtime/breakeven_active.json` | 当前持仓的移动保本登记态(entry、initial_sl、是否已拉保本) | + +### 2.2 建议备份(便于排障) + +| 来源 | 文件 | +|------|------| +| 扫描端 | `runtime/system.log` | +| 扫描端 | `runtime/pm2-out.log`、`runtime/pm2-error.log` | +| 执行器 | `runtime/executor.log` | +| 执行器 | `runtime/pm2-executor-out.log`、`runtime/pm2-executor-error.log` | + +### 2.3 一键打包示例(按你实际旧路径改) + +```bash +BACKUP_DIR=/root/backup_gate_$(date +%Y%m%d_%H%M%S) +mkdir -p "$BACKUP_DIR" + +# 若旧版扫描端在此路径: +OLD_SCOUT=/root/onchain_scout_gate +if [ -d "$OLD_SCOUT" ]; then + mkdir -p "$BACKUP_DIR/onchain_scout_gate" + cp -a "$OLD_SCOUT/config.yaml" "$BACKUP_DIR/onchain_scout_gate/" 2>/dev/null || true + cp -a "$OLD_SCOUT/runtime" "$BACKUP_DIR/onchain_scout_gate/" 2>/dev/null || true +fi + +# 若旧版执行器在此路径: +OLD_EXEC=/root/gate_order_executor +if [ -d "$OLD_EXEC" ]; then + mkdir -p "$BACKUP_DIR/gate_order_executor" + cp -a "$OLD_EXEC/config.yaml" "$BACKUP_DIR/gate_order_executor/" 2>/dev/null || true + cp -a "$OLD_EXEC/runtime" "$BACKUP_DIR/gate_order_executor/" 2>/dev/null || true +fi + +# 若已是 monorepo 子目录: +NEW_ROOT=/opt/gate_scout_order +if [ -d "$NEW_ROOT/onchain_scout_gate" ]; then + mkdir -p "$BACKUP_DIR/monorepo_scout" + cp -a "$NEW_ROOT/onchain_scout_gate/config.yaml" "$BACKUP_DIR/monorepo_scout/" 2>/dev/null || true + cp -a "$NEW_ROOT/onchain_scout_gate/runtime" "$BACKUP_DIR/monorepo_scout/" 2>/dev/null || true +fi +if [ -d "$NEW_ROOT/gate_order_executor" ]; then + mkdir -p "$BACKUP_DIR/monorepo_executor" + cp -a "$NEW_ROOT/gate_order_executor/config.yaml" "$BACKUP_DIR/monorepo_executor/" 2>/dev/null || true + cp -a "$NEW_ROOT/gate_order_executor/runtime" "$BACKUP_DIR/monorepo_executor/" 2>/dev/null || true +fi + +echo "备份完成: $BACKUP_DIR" +ls -la "$BACKUP_DIR" +``` + +### 2.4 停旧进程 + +```bash +pm2 stop onchain-scout gate-order-executor 2>/dev/null || true +pm2 delete onchain-scout gate-order-executor 2>/dev/null || true +pm2 save +``` + +### 2.5 新目录恢复配置 + +克隆新仓库后,将备份 **拷回对应子目录**(不要覆盖新代码,只覆盖 `config.yaml` 和 `runtime/`): + +```bash +# 示例:从备份恢复到新 clone 路径 +NEW=/opt/gate_scout_order +cp -a /root/backup_gate_xxxx/onchain_scout_gate/config.yaml "$NEW/onchain_scout_gate/" +cp -a /root/backup_gate_xxxx/onchain_scout_gate/runtime/* "$NEW/onchain_scout_gate/runtime/" + +cp -a /root/backup_gate_xxxx/gate_order_executor/config.yaml "$NEW/gate_order_executor/" +cp -a /root/backup_gate_xxxx/gate_order_executor/runtime/* "$NEW/gate_order_executor/runtime/" +``` + +恢复后执行 `git pull` 时 **不要** 用 `git checkout -- runtime/` 覆盖业务数据。 + +--- + +## 3. 服务器环境准备 + +```bash +sudo apt update && sudo apt install -y python3 python3-venv python3-pip curl git + +# Node.js + PM2(任选一种安装 Node 的方式) +curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - +sudo apt install -y nodejs +sudo npm install -g pm2 + +# 验证 Gate 网络(云主机通常直连即可) +curl -I --max-time 15 https://api.gateio.ws +``` + +建议部署路径(可自定): + +```text +/opt/gate_scout_order +``` + +--- + +## 4. 克隆代码 + +### 4.1 HTTPS(常用) + +```bash +sudo mkdir -p /opt +cd /opt +git clone https://git.bz121.com/dekun/gate_scout_order.git gate_scout_order +cd gate_scout_order +``` + +私有仓库会提示输入 Gitea **用户名 + 密码**(或 Personal Access Token)。 + +### 4.2 SSH(已配置公钥时) + +```bash +cd /opt +git clone git@git.bz121.com:dekun/gate_scout_order.git gate_scout_order +cd gate_scout_order +``` + +### 4.3 首次推送代码到空仓库(仅维护者) + +若远程仍为空仓库,在开发机仓库根目录: + +```bash +git remote add origin https://git.bz121.com/dekun/gate_scout_order.git +git branch -M main +git push -u origin main +``` + +--- + +## 5. 部署扫描端 onchain_scout_gate + +```bash +cd /opt/gate_scout_order/onchain_scout_gate +chmod +x deploy/*.sh + +python3 -m venv .venv +source .venv/bin/activate +pip install -U pip +pip install -r requirements.txt + +# 若无 config.yaml(升级已从备份恢复则跳过) +cp config.example.yaml config.yaml +chmod 600 config.yaml +nano config.yaml +``` + +**`config.yaml` 至少修改:** + +| 项 | 说明 | +|----|------| +| `app.session_secret` | 随机长字符串 | +| `auth.username` / `auth.password` | 面板登录 | +| `wecom.webhook` | 企业微信群机器人 URL | +| `proxy.enabled` | 云服务器能直连 Gate 时设为 **`false`**(见 §9) | +| `monitor.*` | 成交额门槛、universe 等 | + +**启动 PM2:** + +```bash +cd /opt/gate_scout_order/onchain_scout_gate +source .venv/bin/activate +pm2 start deploy/ecosystem.config.cjs +pm2 save +``` + +验证:`http://服务器IP:8088/dashboard`(端口以 `config.yaml` 的 `app.port` 为准)。 + +```bash +pm2 logs onchain-scout +``` + +--- + +## 6. 部署执行器 gate_order_executor + +```bash +cd /opt/gate_scout_order/gate_order_executor +chmod +x deploy/*.sh +bash deploy/bootstrap.sh /opt/gate_scout_order/gate_order_executor + +nano config.yaml # 或从备份恢复后再改 +``` + +**`config.yaml` 至少修改:** + +| 项 | 说明 | +|----|------| +| `security.webhook_secret` | 与扫描端面板/Webhook 一致 | +| `app.session_secret` | 随机长字符串 | +| `auth` | 对外暴露时 `enabled: true` | +| `gate.api_key` / `gate.api_secret` | Gate 子账户 API(建议 IP 白名单) | +| `gate.dry_run` | 联调 **`true`**,验证后再改 **`false`** | +| `proxy.enabled` | 云主机通常 **`false`** | +| `app.host` | 仅本机扫描调用可 `127.0.0.1`;远程看面板用 `0.0.0.0` | + +**启动 PM2:** + +```bash +cd /opt/gate_scout_order/gate_order_executor +bash deploy/pm2-start.sh +pm2 save +``` + +验证: + +```bash +curl -s http://127.0.0.1:8090/health +# 面板:http://服务器IP:8090/dashboard +pm2 logs gate-order-executor +``` + +--- + +## 7. 扫描端与执行器串联 + +1. 两边的 `webhook_secret` 一致(扫描端以 **面板「下单执行器」** 保存为准,会写入 `runtime/order_executors.json`)。 +2. 打开扫描端面板 → **「下单执行器 · 转发链」**: + - 打开 **总开关** + - 填写 **Webhook 密钥**(与各执行器 `security.webhook_secret` 相同) + - **添加执行器**:名称随意,Base URL 填 `http://127.0.0.1:8090`(同机) +3. 执行器 `gate.dry_run: false` 且 API 有效后,企微 **TRIGGER 推送成功** 才会向执行器 POST `/v1/signal`。 +4. 转发 **不走** 扫描端 proxy,直连 `base_url`。 + +联调执行器(可选): + +```bash +curl -s -X POST "http://127.0.0.1:8090/v1/test" \ + -H "Content-Type: application/json" \ + -H "X-Webhook-Secret: 你的密钥" \ + -d '{"action":"balance"}' +``` + +详见 [gate_order_executor/docs/使用说明.md](gate_order_executor/docs/使用说明.md) §4.1。 + +--- + +## 8. 多执行器 / 多账户 + +| 目标 | 做法 | +|------|------| +| 单账户 | 扫描面板只保留 **1 条** URL(`:8090`) | +| 两账户对照实验 | 再部署一份执行器(不同目录或同目录改 `app.port: 8091`),面板添加第二条 URL | +| 暂停某一账户 | 面板将该执行器设为「停用」 | + +每个执行器实例需要: + +- 独立 `config.yaml`(不同 Gate API) +- 独立 `runtime/`(尤其 `signals.sqlite`、移动保本状态) +- 独立 PM2 应用名(复制 `ecosystem.config.cjs` 并改 `name`、端口) + +扫描端仍只维护 **一份** `order_executors.json`。 + +设计说明:[onchain_scout_gate/docs/多执行器与信号转发归档.md](onchain_scout_gate/docs/多执行器与信号转发归档.md) + +--- + +## 9. 云服务器关闭代理 + +本机开发常用 `proxy.enabled: true` + 本地 SOCKS;**境外云主机**在能 `curl` 通 Gate 后应关闭: + +**扫描端** `onchain_scout_gate/config.yaml`: + +```yaml +proxy: + enabled: false +``` + +**执行器** `gate_order_executor/config.yaml`(每个实例都要改): + +```yaml +proxy: + enabled: false +``` + +```bash +pm2 restart onchain-scout +pm2 restart gate-order-executor +``` + +--- + +## 10. 日常运维与升级 + +### 10.1 拉取新代码 + +```bash +cd /opt/gate_scout_order +git pull +``` + +### 10.2 更新依赖并重启 + +```bash +cd /opt/gate_scout_order/onchain_scout_gate +source .venv/bin/activate && pip install -r requirements.txt +pm2 restart onchain-scout + +cd /opt/gate_scout_order/gate_order_executor +source .venv/bin/activate && pip install -r requirements.txt +bash deploy/pm2-restart.sh +``` + +### 10.3 定期备份(建议 cron 每周) + +```bash +BACKUP=/root/backup_gate_weekly/$(date +%Y%m%d) +mkdir -p "$BACKUP" +cp -a /opt/gate_scout_order/onchain_scout_gate/config.yaml "$BACKUP/scout_config.yaml" +cp -a /opt/gate_scout_order/onchain_scout_gate/runtime/alerts.db "$BACKUP/" +cp -a /opt/gate_scout_order/onchain_scout_gate/runtime/order_executors.json "$BACKUP/" 2>/dev/null || true +cp -a /opt/gate_scout_order/gate_order_executor/config.yaml "$BACKUP/executor_config.yaml" +cp -a /opt/gate_scout_order/gate_order_executor/runtime/signals.sqlite "$BACKUP/" +cp -a /opt/gate_scout_order/gate_order_executor/runtime/risk_prefs.json "$BACKUP/" 2>/dev/null || true +cp -a /opt/gate_scout_order/gate_order_executor/runtime/breakeven_prefs.json "$BACKUP/" 2>/dev/null || true +cp -a /opt/gate_scout_order/gate_order_executor/runtime/breakeven_active.json "$BACKUP/" 2>/dev/null || true +``` + +### 10.4 PM2 开机自启 + +```bash +pm2 startup +# 按提示执行 sudo 命令 +pm2 save +``` + +--- + +## 11. 防火墙与安全 + +- 执行器若仅本机扫描调用:`app.host: 127.0.0.1`,**不要**对公网开放 8090。 +- 需外网访问面板:`0.0.0.0` + **防火墙白名单** + `auth.enabled: true`,建议 Nginx HTTPS 反代。 +- `chmod 600` 各子项目的 `config.yaml`。 +- **勿** 将含 API Key 的 config 或 `runtime/*.db` 提交到 Git。 + +--- + +## 12. 故障速查 + +| 现象 | 处理 | +|------|------| +| `git clone` 403 / 认证失败 | 检查 Gitea 账号、Token;或改用 SSH | +| 扫描端拉不到行情 | `proxy`、防火墙;`curl -I https://api.gateio.ws` | +| 有 TRIGGER 未下单 | 面板执行器总开关、列表为空、webhook 不一致;日志搜 `order_executor_` | +| 执行器 401 | `X-Webhook-Secret` 与配置不一致 | +| 升级后面板策略丢了 | 是否未恢复 `runtime/alerts.db` | +| 信号流空了 | 是否未恢复 `runtime/signals.sqlite` | +| PM2 反复重启 | `pm2 logs`;检查 `config.yaml` 校验、端口占用 | + +--- + +## 相关文档 + +| 文档 | 路径 | +|------|------| +| 仓库总览 | [README.md](README.md) | +| Git 克隆摘要 | [CLONE.md](CLONE.md) | +| 扫描端专题 | [onchain_scout_gate/交易系统部署说明.md](onchain_scout_gate/交易系统部署说明.md) | +| 执行器专题 | [gate_order_executor/docs/部署说明.md](gate_order_executor/docs/部署说明.md) | +| 多执行器归档 | [onchain_scout_gate/docs/多执行器与信号转发归档.md](onchain_scout_gate/docs/多执行器与信号转发归档.md) |