From 7dbc5542de102843d9b6ec305d31095fd2b01248 Mon Sep 17 00:00:00 2001 From: dekun <253921841@qq.com> Date: Thu, 21 May 2026 16:44:31 +0800 Subject: [PATCH] first commit --- .gitattributes | 3 + .gitignore | 24 + README.md | 142 + crypto_monitor_binance/.env.example | 187 + crypto_monitor_binance/README.md | 82 + crypto_monitor_binance/app.py | 7638 ++++++++++++++++ crypto_monitor_binance/ecosystem.config.cjs | 33 + .../scripts/_layout_snippet.html | 2 + crypto_monitor_binance/scripts/backup_data.sh | 109 + .../scripts/fix_breakeven_labels.py | 108 + .../scripts/install_backup_cron.sh | 38 + .../scripts/patch_index_layout.py | 358 + .../scripts/sync_gate_app.py | 116 + .../scripts/verify_binance_funding.py | 52 + crypto_monitor_binance/start_utf8.ps1 | Bin 0 -> 1080 bytes crypto_monitor_binance/templates/index.html | 1868 ++++ .../templates/key_focus.html | 1 + .../templates/key_focus_v2.html | 261 + crypto_monitor_binance/templates/login.html | 118 + .../templates/order_focus.html | 194 + .../templates/order_focus_v2.html | 214 + crypto_monitor_binance/使用说明.md | 134 + crypto_monitor_binance/关键位自动下单说明.md | 101 + crypto_monitor_binance/更新文档.md | 147 + crypto_monitor_binance/部署文档.md | 378 + crypto_monitor_gate/.env.example | 181 + crypto_monitor_gate/README.md | 94 + crypto_monitor_gate/app.py | 7673 +++++++++++++++++ crypto_monitor_gate/ecosystem.config.cjs | 33 + crypto_monitor_gate/scripts/backup_data.sh | 109 + .../scripts/fix_breakeven_labels.py | 108 + .../scripts/install_backup_cron.sh | 38 + .../scripts/verify_gate_funding.py | 93 + crypto_monitor_gate/start_utf8.ps1 | Bin 0 -> 1080 bytes crypto_monitor_gate/templates/index.html | 1911 ++++ crypto_monitor_gate/templates/key_focus.html | 1 + .../templates/key_focus_v2.html | 261 + crypto_monitor_gate/templates/login.html | 118 + .../templates/order_focus.html | 194 + .../templates/order_focus_v2.html | 214 + crypto_monitor_gate/使用说明.md | 143 + crypto_monitor_gate/关键位自动下单说明.md | 98 + crypto_monitor_gate/更新文档.md | 148 + crypto_monitor_gate/部署文档.md | 299 + crypto_monitor_gate_bot/.env.example | 166 + crypto_monitor_gate_bot/app.py | 7315 ++++++++++++++++ crypto_monitor_gate_bot/ecosystem.config.cjs | 33 + .../scripts/backup_data.sh | 109 + .../scripts/fix_breakeven_labels.py | 108 + .../scripts/install_backup_cron.sh | 38 + .../scripts/verify_gate_funding.py | 93 + crypto_monitor_gate_bot/start_utf8.ps1 | Bin 0 -> 1080 bytes crypto_monitor_gate_bot/templates/index.html | 1663 ++++ .../templates/key_focus.html | 1 + .../templates/key_focus_v2.html | 261 + crypto_monitor_gate_bot/templates/login.html | 118 + .../templates/order_focus.html | 194 + .../templates/order_focus_v2.html | 214 + crypto_monitor_gate_bot/趋势回调策略说明.md | 125 + crypto_monitor_gate_bot/部署文档.md | 333 + crypto_monitor_okx/.env.example | 142 + crypto_monitor_okx/app.py | 5916 +++++++++++++ crypto_monitor_okx/ecosystem.config.cjs | 33 + .../scripts/fix_breakeven_labels.py | 108 + crypto_monitor_okx/start_utf8.ps1 | Bin 0 -> 1080 bytes crypto_monitor_okx/templates/index.html | 1419 +++ crypto_monitor_okx/templates/key_focus.html | 1 + .../templates/key_focus_v2.html | 260 + crypto_monitor_okx/templates/login.html | 107 + crypto_monitor_okx/templates/order_focus.html | 194 + .../templates/order_focus_v2.html | 210 + crypto_monitor_okx/更新文档.md | 79 + crypto_monitor_okx/部署文档.md | 349 + deploy/README.md | 90 + deploy/setup_env.ps1 | 210 + deploy/setup_env.sh | 258 + docs/LICENSE_API.md | 88 + fib_key_monitor_lib.py | 84 + history_window_lib.py | 127 + key_sl_tp_lib.py | 139 + license_lib.py | 437 + license_templates/license.html | 168 + manual_trading_hub/README.md | 201 + manual_trading_hub/agent.py | 568 ++ manual_trading_hub/hub.py | 278 + manual_trading_hub/requirements.txt | 5 + .../manual-agent-binance.service.example | 20 + .../manual-hub.service.example | 18 + .../scripts/start_agents_3screen.sh | 81 + .../scripts/start_hub_screen.sh | 75 + .../scripts/stop_agents_3screen.sh | 10 + manual_trading_hub/scripts/stop_hub_screen.sh | 8 + manual_trading_hub/scripts/后台运行-Ubuntu.md | 191 + manual_trading_hub/static/index.html | 398 + manual_trading_hub/部署文档.md | 220 + requirements.txt | 8 + 一键部署.bat | 16 + 关键位止盈止损与移动保本更新说明.md | 164 + 备份与恢复.md | 268 + 99 files changed, 47743 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 README.md create mode 100644 crypto_monitor_binance/.env.example create mode 100644 crypto_monitor_binance/README.md create mode 100644 crypto_monitor_binance/app.py create mode 100644 crypto_monitor_binance/ecosystem.config.cjs create mode 100644 crypto_monitor_binance/scripts/_layout_snippet.html create mode 100644 crypto_monitor_binance/scripts/backup_data.sh create mode 100644 crypto_monitor_binance/scripts/fix_breakeven_labels.py create mode 100644 crypto_monitor_binance/scripts/install_backup_cron.sh create mode 100644 crypto_monitor_binance/scripts/patch_index_layout.py create mode 100644 crypto_monitor_binance/scripts/sync_gate_app.py create mode 100644 crypto_monitor_binance/scripts/verify_binance_funding.py create mode 100644 crypto_monitor_binance/start_utf8.ps1 create mode 100644 crypto_monitor_binance/templates/index.html create mode 100644 crypto_monitor_binance/templates/key_focus.html create mode 100644 crypto_monitor_binance/templates/key_focus_v2.html create mode 100644 crypto_monitor_binance/templates/login.html create mode 100644 crypto_monitor_binance/templates/order_focus.html create mode 100644 crypto_monitor_binance/templates/order_focus_v2.html create mode 100644 crypto_monitor_binance/使用说明.md create mode 100644 crypto_monitor_binance/关键位自动下单说明.md create mode 100644 crypto_monitor_binance/更新文档.md create mode 100644 crypto_monitor_binance/部署文档.md create mode 100644 crypto_monitor_gate/.env.example create mode 100644 crypto_monitor_gate/README.md create mode 100644 crypto_monitor_gate/app.py create mode 100644 crypto_monitor_gate/ecosystem.config.cjs create mode 100644 crypto_monitor_gate/scripts/backup_data.sh create mode 100644 crypto_monitor_gate/scripts/fix_breakeven_labels.py create mode 100644 crypto_monitor_gate/scripts/install_backup_cron.sh create mode 100644 crypto_monitor_gate/scripts/verify_gate_funding.py create mode 100644 crypto_monitor_gate/start_utf8.ps1 create mode 100644 crypto_monitor_gate/templates/index.html create mode 100644 crypto_monitor_gate/templates/key_focus.html create mode 100644 crypto_monitor_gate/templates/key_focus_v2.html create mode 100644 crypto_monitor_gate/templates/login.html create mode 100644 crypto_monitor_gate/templates/order_focus.html create mode 100644 crypto_monitor_gate/templates/order_focus_v2.html create mode 100644 crypto_monitor_gate/使用说明.md create mode 100644 crypto_monitor_gate/关键位自动下单说明.md create mode 100644 crypto_monitor_gate/更新文档.md create mode 100644 crypto_monitor_gate/部署文档.md create mode 100644 crypto_monitor_gate_bot/.env.example create mode 100644 crypto_monitor_gate_bot/app.py create mode 100644 crypto_monitor_gate_bot/ecosystem.config.cjs create mode 100644 crypto_monitor_gate_bot/scripts/backup_data.sh create mode 100644 crypto_monitor_gate_bot/scripts/fix_breakeven_labels.py create mode 100644 crypto_monitor_gate_bot/scripts/install_backup_cron.sh create mode 100644 crypto_monitor_gate_bot/scripts/verify_gate_funding.py create mode 100644 crypto_monitor_gate_bot/start_utf8.ps1 create mode 100644 crypto_monitor_gate_bot/templates/index.html create mode 100644 crypto_monitor_gate_bot/templates/key_focus.html create mode 100644 crypto_monitor_gate_bot/templates/key_focus_v2.html create mode 100644 crypto_monitor_gate_bot/templates/login.html create mode 100644 crypto_monitor_gate_bot/templates/order_focus.html create mode 100644 crypto_monitor_gate_bot/templates/order_focus_v2.html create mode 100644 crypto_monitor_gate_bot/趋势回调策略说明.md create mode 100644 crypto_monitor_gate_bot/部署文档.md create mode 100644 crypto_monitor_okx/.env.example create mode 100644 crypto_monitor_okx/app.py create mode 100644 crypto_monitor_okx/ecosystem.config.cjs create mode 100644 crypto_monitor_okx/scripts/fix_breakeven_labels.py create mode 100644 crypto_monitor_okx/start_utf8.ps1 create mode 100644 crypto_monitor_okx/templates/index.html create mode 100644 crypto_monitor_okx/templates/key_focus.html create mode 100644 crypto_monitor_okx/templates/key_focus_v2.html create mode 100644 crypto_monitor_okx/templates/login.html create mode 100644 crypto_monitor_okx/templates/order_focus.html create mode 100644 crypto_monitor_okx/templates/order_focus_v2.html create mode 100644 crypto_monitor_okx/更新文档.md create mode 100644 crypto_monitor_okx/部署文档.md create mode 100644 deploy/README.md create mode 100644 deploy/setup_env.ps1 create mode 100644 deploy/setup_env.sh create mode 100644 docs/LICENSE_API.md create mode 100644 fib_key_monitor_lib.py create mode 100644 history_window_lib.py create mode 100644 key_sl_tp_lib.py create mode 100644 license_lib.py create mode 100644 license_templates/license.html create mode 100644 manual_trading_hub/README.md create mode 100644 manual_trading_hub/agent.py create mode 100644 manual_trading_hub/hub.py create mode 100644 manual_trading_hub/requirements.txt create mode 100644 manual_trading_hub/scripts/example-systemd/manual-agent-binance.service.example create mode 100644 manual_trading_hub/scripts/example-systemd/manual-hub.service.example create mode 100644 manual_trading_hub/scripts/start_agents_3screen.sh create mode 100644 manual_trading_hub/scripts/start_hub_screen.sh create mode 100644 manual_trading_hub/scripts/stop_agents_3screen.sh create mode 100644 manual_trading_hub/scripts/stop_hub_screen.sh create mode 100644 manual_trading_hub/scripts/后台运行-Ubuntu.md create mode 100644 manual_trading_hub/static/index.html create mode 100644 manual_trading_hub/部署文档.md create mode 100644 requirements.txt create mode 100644 一键部署.bat create mode 100644 关键位止盈止损与移动保本更新说明.md create mode 100644 备份与恢复.md diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..121743d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Shell 脚本在仓库内统一 LF,避免 Linux 上 bash: pipefail: invalid option name(CRLF) +*.sh text eol=lf +deploy/** text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..779dc92 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# 本地真实配置(含 API 密钥),勿提交 +**/.env +.env + +# 允许提交模板 +!**/.env.example +!.env.example + +# Python +**/__pycache__/ +**/*.pyc +**/.venv/ + +# 本地备份(可选,勿提交) +**/.env.backup* +**/.env.bak +**/.env.local + +# 数据库与上传(运行时生成) +**/*.sqlite +**/crypto.db + +# 整机许可缓存(仓库根 .license/) +.license/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..3cbea88 --- /dev/null +++ b/README.md @@ -0,0 +1,142 @@ +# 复盘交易系统(crypto_monitor) + +本仓库为 **多交易所 USDT 永续** 的下单监控、关键位监控与交易复盘工具集:四个子项目分别对接 **Binance、Gate.io(主号)、Gate.io(机器人/趋势策略)、OKX**,共享相似的 Flask 架构与本地 SQLite 记账思路,可按账户独立部署、独立端口运行。 + +**远程仓库(克隆地址)**:[https://git.bz121.com/dekun/crypto_monitor.git](https://git.bz121.com/dekun/crypto_monitor.git) + +```bash +git clone https://git.bz121.com/dekun/crypto_monitor.git +cd crypto_monitor +``` + +### 一键环境部署 + +| 系统 | 命令 | +|------|------| +| **Windows** | 双击根目录 **`一键部署.bat`**,或 `.\deploy\setup_env.ps1` | +| **Linux / macOS** | `bash deploy/setup_env.sh` | + +会为各子项目创建 `.venv`、安装依赖、从 `.env.example` 生成 `.env`(不覆盖已有)。详见 **[deploy/README.md](./deploy/README.md)**。 + +--- + +## 一、仓库目录一览 + +| 目录 | 交易所 / 角色 | 说明文档 | +|------|-----------------|----------| +| `crypto_monitor_binance/` | Binance USDT-M 永续 | [部署文档.md](./crypto_monitor_binance/部署文档.md) · [README.md](./crypto_monitor_binance/README.md) | +| `crypto_monitor_gate/` | Gate.io 永续(主号) | [部署文档.md](./crypto_monitor_gate/部署文档.md) | +| `crypto_monitor_gate_bot/` | Gate.io 永续(机器人;含趋势回调等) | [部署文档.md](./crypto_monitor_gate_bot/部署文档.md) · [趋势回调策略说明.md](./crypto_monitor_gate_bot/趋势回调策略说明.md) | +| `crypto_monitor_okx/` | OKX 永续 | [部署文档.md](./crypto_monitor_okx/部署文档.md) | +| `manual_trading_hub/` | 多账户中控(监控 + 紧急全平,**不参与开仓**) | [README.md](./manual_trading_hub/README.md) · [部署文档.md](./manual_trading_hub/部署文档.md) · [scripts/后台运行-Ubuntu.md](./manual_trading_hub/scripts/后台运行-Ubuntu.md) | + +前四列为四个 **`crypto_monitor_*`** 交易/监控应用;`manual_trading_hub` 与四者 **进程独立**,无需改四者代码即可并行使用。 + +--- + +## 二、四个 `crypto_monitor_*` 子项目:共同点 + +- **技术栈**:Python 3.10+、`Flask` Web、`ccxt` 调交易所 API、本地 SQLite(默认 `crypto.db`)等。 +- **能力类型**(各所细节见各自 README / 部署文档): + - **关键位监控**、**下单监控**(含风控与移动保本等逻辑)、**交易复盘**; + - **实盘(可选)**:在对应 `.env` 中开启 `LIVE_TRADING_ENABLED=true` 并配置各所 API 后,由程序发起真实委托(请务必理解风险并做好权限与 IP 白名单控制)。 +- **网络**:若本机直连交易所不稳定,可通过 **SSH 动态转发 SOCKS** 或 HTTP/S 代理;经 SOCKS 时依赖中需包含 **`PySocks`**(各《部署文档》中有说明)。 +- **进程托管**:Linux 上常用 **PM2** 托管 `app.py`;各目录内一般有 `ecosystem.config.cjs` 或文档中的等价命令。 + +--- + +## 三、四个子项目:差异速查 + +| 项目 | 环境变量前缀(示例) | 典型用途区分 | +|------|----------------------|--------------| +| `crypto_monitor_binance` | `BINANCE_*` | 币安 U 本位永续;止盈止损以 `STOP_MARKET` / `TAKE_PROFIT_MARKET` 等与币安规则对齐 | +| `crypto_monitor_gate` | `GATE_*` | Gate 主账户监控与交易页面 | +| `crypto_monitor_gate_bot` | `GATE_*` | Gate 侧 **独立子账户 / 机器人**;文档中含 **趋势回调** 等策略说明 | +| `crypto_monitor_okx` | `OKX_*` | OKX 永续;需 API Key / Secret / Passphrase | + +各目录根下: + +- **`.env.example`**:配置模板(**可** `git pull` 同步),新机执行 `cp .env.example .env` 后编辑。 +- **`.env`**:本机真实配置(**勿**提交 Git);`app.py` 只读此文件。`git pull` **不会**覆盖 `.env`;升级前建议 `cp .env .env.backup.$(date +%Y%m%d)`。 + +变量名以前缀区分,**不可混用**(例如在 Gate 项目中写 OKX 变量会导致代理与密钥不生效)。 + +--- + +## 四、与 `manual_trading_hub` 的关系(可选) + +- **中控** `hub.py`:汇总各子代理状态,提供网页与 HTTP API。 +- **子代理** `agent.py`:每个进程绑定一个交易所账户,默认监听 **`15200`~`15203`**,与四个 Flask 应用的 **`APP_PORT`**(如 5000、5001)**必须错开**。 +- 详细端口、`EXCHANGE`、`HUB_AGENTS` 等见 [manual_trading_hub/README.md](./manual_trading_hub/README.md)。 + +--- + +## 五、Linux 推荐目录布局(可选) + +为与仓库内《部署文档》示例一致,可将整个克隆结果置于 **`/opt/crypto_monitor/`** 下,例如: + +- `/opt/crypto_monitor/crypto_monitor_binance` +- `/opt/crypto_monitor/crypto_monitor_gate` +- `/opt/crypto_monitor/crypto_monitor_gate_bot` +- `/opt/crypto_monitor/crypto_monitor_okx` +- `/opt/crypto_monitor/manual_trading_hub` + +具体 `mkdir`、`venv`、`pm2` 与 **SSH SOCKS** 步骤以各子目录 **《部署文档.md》** 为准。 + +### 备份与恢复(服务器必读) + +项目路径 **`/opt/crypto_monitor`**,数据备份 **`/root/backups`**: + +| 类型 | 说明 | +|------|------| +| **数据库 + 复盘图片** | 每天北京时间 0:00 自动备份,保留 30 天 | +| **`.env`** | 升级 / 改配置前手动备份与恢复 | + +**一键复制命令(Ubuntu SSH)** 见根目录 **[备份与恢复.md](./备份与恢复.md)**:含安装 cron、手动备份、`.env` 备份/恢复、从备份还原数据库等整段脚本。 + +--- + +## 六、整机许可(月卡 / 季卡 / 年卡) + +- 用户自备电脑部署;**整机**一个设备 ID,四个 `crypto_monitor_*` 共用仓库根目录 **`.license/`** 缓存。 +- 未授权或已过期:关键位监控、复盘、统计等 **全部不可用**(后台监控线程亦停止)。 +- 每 **3** 天联网校验一次(`LICENSE_CHECK_INTERVAL_DAYS`);断网宽限默认 **7** 天。 +- 购买:微信 **`dekun03`**,添加好友时 **备注必须填写设备 ID**(打开任意实例的 **`/license`** 页复制)。 +- 定价:月卡 ¥199 / 季卡 ¥399 / 年卡 ¥699;续费在剩余天数上叠加(由云端实现)。 +- 签发与管理 Web 为 **独立云端项目**,不在本仓库;用户端 API 约定见 **[docs/LICENSE_API.md](./docs/LICENSE_API.md)**。 +- 配置:各子目录 `.env` 或仓库根 `.env` 中的 `LICENSE_API_URL`、`LICENSE_CLIENT_KEY` 等(见各 `.env.example`)。本地开发可设 `LICENSE_DISABLED=true`。 + +--- + +## 七、推荐阅读顺序 + +1. 克隆本仓库后,执行 **一键环境部署**(上表),或手动在各子目录 `python -m venv .venv` 与 `pip install`。 +2. 根据实际交易所进入对应 **`crypto_monitor_*`** 目录,编辑 **`.env`**(填入 API 与密码等;部署脚本已可从 `.env.example` 复制)。 +3. 阅读该目录下的 **《部署文档.md》**(Ubuntu / PM2 / 代理 / 升级说明)。 +4. 服务器部署完成后,按 **[备份与恢复.md](./备份与恢复.md)** 配置自动备份与 `.env` 备份习惯。 +5. 需要 **Gate 趋势回调** 规则时,阅读 [crypto_monitor_gate_bot/趋势回调策略说明.md](./crypto_monitor_gate_bot/趋势回调策略说明.md)。 +6. 需要 **多账户一块看 + 紧急全平** 时,阅读 [manual_trading_hub](./manual_trading_hub/) 下 README 与部署文档。 + +--- + +## 八、安全与合规 + +- **切勿**将 `.env`、`.env.backup*`、API Secret、SSH 私钥 `.pem` 等提交到版本库或公开渠道;仅 **`.env.example`** 可提交(占位符,无真实密钥)。 + +### 从旧版仓库升级(曾把 `.env` 提交进 Git) + +在 **`git pull` 之前**按 **[备份与恢复.md](./备份与恢复.md)** 备份 `.env` 与数据库;pull 后若本地 `.env` 被误删,用备份恢复;再对照新的 **`.env.example`** 补全可能新增的变量名。 +- 实盘前务必在 **`LIVE_TRADING_ENABLED=false`** 下验证页面与网络;API 权限与 IP 白名单遵循各交易所要求。 +- 使用本仓库进行实盘交易的风险由使用者自行承担;请遵守当地法律法规与交易所用户协议。 + +--- + +## 九、仓库信息摘要 + +| 项 | 内容 | +|----|------| +| 远程地址 | `https://git.bz121.com/dekun/crypto_monitor.git` | +| 说明 | 复盘交易系统(骆驼比特币私有代码仓库,Gitea) | +| 本说明 | 仓库根目录 `README.md`,仅描述结构与文档索引,不包含业务代码变更说明 | + +若各子项目 README 与根说明不一致,以 **子目录内当前代码与《部署文档》** 为准。 diff --git a/crypto_monitor_binance/.env.example b/crypto_monitor_binance/.env.example new file mode 100644 index 0000000..7f9c542 --- /dev/null +++ b/crypto_monitor_binance/.env.example @@ -0,0 +1,187 @@ +# ============================================================================= +# 环境配置模板(可提交 Git)。程序运行时只读取同目录下的 .env。 +# +# 首次部署 / 新机: +# cp .env.example .env +# nano .env # 填入真实密钥、端口、代理等 +# +# 升级代码(git pull)前建议备份(.env 不在 Git 中,pull 不会覆盖): +# cp .env .env.backup.$(date +%Y%m%d) +# +# 从备份恢复: +# cp .env.backup.YYYYMMDD .env +# ============================================================================= + +APP_ENV=production +# 服务监听地址(云服务器通常用 0.0.0.0) +APP_HOST=0.0.0.0 +# 服务端口 +APP_PORT=5001 +# 是否开启调试模式(生产建议 false) +APP_DEBUG=false + +# 登录账号 +APP_USERNAME=dekun +# 登录密码(请改成你自己的强密码) +APP_PASSWORD=ChangeMe123! +# 是否关闭登录校验(局域网可设 true;公网务必 false) +APP_AUTH_DISABLED=true +# Flask 会话密钥(必须替换为长随机字符串) +FLASK_SECRET_KEY=CHANGE_TO_LONG_RANDOM_SECRET + +# ---------- 整机许可(也可写在仓库根目录 .env 的 LICENSE_* 变量)---------- +# 云端授权服务根地址(独立项目部署,见 docs/LICENSE_API.md) +LICENSE_API_URL=https://license.example.com +LICENSE_CLIENT_KEY=REPLACE_WITH_CLIENT_KEY +# 联网校验间隔(天),默认 3 +LICENSE_CHECK_INTERVAL_DAYS=3 +# 断网后仍可使用的天数(基于上次校验成功时间),默认 7 +LICENSE_OFFLINE_GRACE_DAYS=7 +# 购买联系微信;添加好友时备注请填写设备 ID(见 /license 页) +LICENSE_WECHAT_ID=dekun03 +# LICENSE_WECHAT_REMARK=自定义备注文案 +# 仅本地开发可设为 true,跳过许可 +# LICENSE_DISABLED=false + +# 企业微信机器人 Webhook(用于行情/风控推送) +WECHAT_WEBHOOK=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=REPLACE_WITH_REAL_KEY + +# 数据库文件路径(相对路径会自动按项目目录解析) +DB_PATH=crypto.db +# 交易截图上传目录 +UPLOAD_DIR=static/images + +# 自动备份(scripts/backup_data.sh + cron,可选;默认即可) +# BACKUP_ROOT=/root/backups +# BACKUP_RETENTION_DAYS=30 +# BACKUP_INSTANCE=crypto_monitor_binance + +# 已废弃:资金账户仅显示交易所 funding 余额,不再读取此变量 +# TOTAL_CAPITAL=100 +# 每天起始基数(U) +DAILY_START_CAPITAL=30 +# 日内回撤后基数(U) +DAILY_LOSS_CAPITAL=20 +# 日内盈利后基数(U) +DAILY_PROFIT_CAPITAL=50 +# BTC 默认杠杆倍数 +BTC_LEVERAGE=10 +# 山寨币默认杠杆倍数 +ALT_LEVERAGE=5 +# 交易日重置小时(北京时间) +TRADING_DAY_RESET_HOUR=8 +# 整点前禁止新开仓:true=启用(默认),false=关闭(仍可保留 8 点作为交易日划分) +TRADING_DAY_RESET_OPEN_GUARD_ENABLED=true + +# 是否开启 Binance 实盘下单(false=只做本地流程,true=真实下单) +LIVE_TRADING_ENABLED=true +# Binance API Key(需开通合约、万向划转等权限) +BINANCE_API_KEY=REPLACE_WITH_BINANCE_API_KEY +# Binance API Secret +BINANCE_API_SECRET=REPLACE_WITH_BINANCE_API_SECRET +# 保证金模式:cross=全仓,isolated=逐仓 +BINANCE_MARGIN_MODE=cross +# 持仓模式:hedge=双向(需账户开启双向持仓,下单带 positionSide);oneway=单向 +BINANCE_POSITION_MODE=hedge +# 条件单触发参考价:CONTRACT_PRICE=最新成交价 MARK_PRICE=标记价(更易触发时用标记价) +BINANCE_TRIGGER_WORKING_TYPE=CONTRACT_PRICE +# 页面与浏览器标签展示的交易所名称(多环境区分时可改成例如 Binance·测试网) +# EXCHANGE_DISPLAY_NAME=Binance +# 企业微信推送里展示的账户备注 +# BINANCE_ACCOUNT_LABEL=binance实盘账户 +# 平仓盈亏估算:false=按仓位历史口径(已实现盈亏+手续费,不含资金费);true=含资金费 +# BINANCE_PNL_INCLUDE_FUNDING=false + +# ============================================================================= +# 关键位门控(页面「关键位监控」规则条与 _key_hard_checks 共用) +# ============================================================================= +# 【周期】门控 K 线周期,如 5m、15m;仅影响关键位硬条件,不改变顶栏分区 +KLINE_TIMEFRAME=5m +# 【确认K】闭合 K 序列中的棒偏移:突破棒默认 -2(倒数第2根),确认棒默认 -1(倒数第1根) +KEY_CONFIRM_BREAKOUT_BAR=-2 +KEY_CONFIRM_BAR=-1 +# 【量能】突破棒成交量 > 前 N 根均量 × 倍数(默认 N=20,倍数=1.3 即放大 30%) +KEY_VOLUME_MA_BARS=20 +KEY_VOLUME_RATIO_MIN=1.3 +# 【突破K实体幅度】占开盘价百分比区间(须同时满足有效突破) +KEY_BREAKOUT_AMP_MIN_PCT=0.03 +KEY_BREAKOUT_AMP_MAX_PCT=0.5 +# 【日成交量排名】品种须在该排名前 N 名(添加关键位与运行时门控均校验) +KEY_DAILY_VOLUME_RANK_MAX=30 +# 【关键位自动开仓盈亏比】按确认K收盘 E 计算,严格大于该值才市价开仓(如 1.5 表示须 >1.5:1) +KEY_AUTO_MIN_PLANNED_RR=1.5 +# 止损:突破 K 极值向外缓冲的百分比(默认 0.5 即 0.5%) +KEY_STOP_OUTSIDE_BREAKOUT_PCT=0.5 +# 趋势单方案:止损在突破 K 极值外侧的百分比(默认 1 即 1%) +KEY_TREND_STOP_OUTSIDE_PCT=1 +KEY_ALERT_MAX_TIMES=3 +KEY_ALERT_INTERVAL_MINUTES=5 + +# ============================================================================= +# 交易执行 / 人工风控(页面「实盘下单」) +# ============================================================================= +# 【最大同时持仓】active 订单数达到该值后禁止人工与关键位自动再加仓(默认 1=单仓) +MAX_ACTIVE_POSITIONS=1 +# 【人工下单最低盈亏比】按当前价与 SL/TP 计算,低于该值前后端均拒绝(默认 1.4,即须 >=1.4:1) +MANUAL_MIN_PLANNED_RR=1.4 +# 【关键位连开计仓】true=已有持仓时关键位自动单仍按「无仓时」资金快照算保证金基数 +KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT=true + +# 资金与仓位刷新周期(秒) +BALANCE_REFRESH_SECONDS=60 +# 前端价格快照轮询(秒) +PRICE_REFRESH_SECONDS=5 +# 后台监控轮询周期(秒) +MONITOR_POLL_SECONDS=3 +# 使用可用资金时的缓冲比例(如0.98代表用98%) +FULL_MARGIN_BUFFER_RATIO=0.98 + +# 自动划转:将目标账户补足到 AUTO_TRANSFER_AMOUNT(ccxt:funding↔swap 等) +AUTO_TRANSFER_ENABLED=false +AUTO_TRANSFER_AMOUNT=30 +AUTO_TRANSFER_FROM=funding +AUTO_TRANSFER_TO=swap +TRANSFER_CCY=USDT +# 强制清仓整点(北京时间,默认 0=凌晨00点) +FORCE_CLOSE_BJ_HOUR=0 +# 是否启用强制清仓(默认关闭,true 才会在整点执行) +FORCE_CLOSE_ENABLED=false + +# 推送与AI超时(秒) +WECHAT_TIMEOUT_SECONDS=10 +AI_TIMEOUT_SECONDS=120 + +# AI 复盘服务地址(本机 Ollama 默认地址) +OLLAMA_API=http://127.0.0.1:11434/api/generate +# AI 模型名称 +AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest + +# Binance 代理(可选):本机网络不稳定时通过 SSH 动态转发 SOCKS5 出口 +# 1) 先在本机建立隧道(示例): +# ssh -N -D 127.0.0.1:1080 user@vps -o ServerAliveInterval=30 -o ExitOnForwardFailure=yes +# 2) 再启用下面这一行(推荐 socks5h,让远端解析域名): +# BINANCE_SOCKS_PROXY=socks5h://127.0.0.1:1080 +# +# 如你更偏向 HTTP 代理(VPS 上跑 tinyproxy 之类),可用: +# BINANCE_HTTP_PROXY=http://127.0.0.1:3128 +# BINANCE_HTTPS_PROXY=http://127.0.0.1:3128 + +# 开仓多周期K线图(可选) +# ORDER_CHART_ENABLED=true +# ORDER_CHART_TFS=4h,1h,15m,5m +# ORDER_CHART_LIMIT=100 +# ORDER_CHART_DIR=static/images/order_charts +# DAILY_OPEN_ALERT_THRESHOLD=5 +# 以损定仓(按交易账户资金的百分比) +# RISK_PERCENT=2 +# 移动保本触发(达到多少R触发)与偏移(百分比) +# BREAKEVEN_RR_TRIGGER=1.0 +# 移动保本阶梯(每多少R继续上移一次,默认1R) +# BREAKEVEN_STEP_R=1.0 +# BREAKEVEN_OFFSET_PCT=0.02 +# 开单风格默认值:trend / swing +# DEFAULT_TRADE_STYLE=trend + +APP_TIMEZONE=Asia/Shanghai +AUTO_TRANSFER_BJ_HOUR=8 +# TRADING_DAY_RESET_HOUR 现在表示「北京时间」整点,默认 8 点起算新交易日;开仓整点限制见 TRADING_DAY_RESET_OPEN_GUARD_ENABLED diff --git a/crypto_monitor_binance/README.md b/crypto_monitor_binance/README.md new file mode 100644 index 0000000..ecce38c --- /dev/null +++ b/crypto_monitor_binance/README.md @@ -0,0 +1,82 @@ +# crypto_monitor_binance + +基于 **Flask** 的加密货币 **下单监控 / 关键位监控 / 交易复盘** 小系统,行情与实盘接口统一走 **Binance(USDT-M 永续)**,通过 **ccxt** 访问。 + +## 功能概要 + +- **关键位监控**:价格与硬条件校验、企业微信推送(可选) +- **下单监控**:本地风控(含移动保本逻辑)、触达止盈/止损后尝试市价平仓并记账 +- **实盘(可选)**:`LIVE_TRADING_ENABLED=true` 且配置 `BINANCE_API_KEY` / `BINANCE_API_SECRET` 时,支持合约开仓、平仓、余额读取与内部划转(依赖 API 权限) +- **止盈止损(Binance)**:市价成交后挂 **`STOP_MARKET`**(止损)、**`TAKE_PROFIT_MARKET`**(止盈);双向持仓带 `positionSide`;不显式传 `reduceOnly`(避免 API `-1106`)。触发参考价由 `BINANCE_TRIGGER_WORKING_TYPE` 控制(最新价 / 标记价) + +## 环境要求 + +- Python 3.10+(建议) +- 依赖:`flask`、`requests`、`ccxt`、`werkzeug`、`Pillow`(K 线图可选);经 SOCKS 代理时需 **`PySocks`** + +安装示例: + +```bash +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install flask requests ccxt werkzeug PySocks Pillow +``` + +页面上的 **「当日资金(交易账户)」** 与 **「可开仓」可用 U** 仅统计 **Binance U 本位永续合约账户**(`fetch_balance` 的 `swap` / FAPI `assets` 中的 USDT),**不会**再用现货余额顶替。 + +## 配置说明(`.env.example` → `.env`) + +- **`.env.example`**:模板(可提交 Git);首次:`cp .env.example .env` 后编辑。 +- **`.env`**:本机真实配置(勿提交);`app.py` 只读此文件。`git pull` 不覆盖 `.env`;升级前可 `cp .env .env.backup.$(date +%Y%m%d)`。 + +与 Binance 相关的常用变量: + +| 变量 | 说明 | +|------|------| +| `BINANCE_API_KEY` / `BINANCE_API_SECRET` | 币安 API(需合约等权限) | +| `LIVE_TRADING_ENABLED` | `true` 时允许真实下单;`false` 仅本地逻辑 | +| `BINANCE_MARGIN_MODE` | `cross` 全仓 / `isolated` 逐仓 | +| `BINANCE_POSITION_MODE` | `hedge` 双向(需账户开启双向持仓)/ `oneway` 单向 | +| `BINANCE_TRIGGER_WORKING_TYPE` | `CONTRACT_PRICE` 或 `MARK_PRICE`(条件单触发参考) | +| `BINANCE_SOCKS_PROXY` / `BINANCE_HTTP_PROXY` | 可选代理(与部署文档一致) | +| `EXCHANGE_DISPLAY_NAME` | 页面展示的交易所名称,默认 `Binance` | +| `BINANCE_ACCOUNT_LABEL` | 推送文案中的账户备注 | + +其余变量(登录、企业微信、风控参数、数据库路径等)见 **`.env.example` 内注释** 或 `app.py` 顶部默认值。 + +## 本地运行 + +**Windows(UTF-8 控制台)** 可使用: + +```powershell +.\start_utf8.ps1 +``` + +或直接: + +```powershell +python .\app.py +``` + +默认监听端口由 `.env` 的 `APP_PORT` 决定(未设置时多为 `5000`)。 + +## 部署(Linux / PM2 / SSH SOCKS) + +详见 **[部署文档.md](./部署文档.md)**(Ubuntu + PM2 + 可选 SOCKS 访问 Binance)。 + +## 自检脚本 + +```bash +python scripts/verify_binance_funding.py +``` + +用于核对 Key 前缀(不含 Secret)并尝试读取资金钱包 / 合约钱包 USDT(需网络与 API 权限)。 + +## 数据与脚本 + +- 默认 SQLite:`crypto.db`(路径由 `DB_PATH` 指定) +- `scripts/fix_breakeven_labels.py`:批量修正「止损」但盈亏为正的记录标签(见部署文档附录) + +## 风险与合规 + +实盘交易有亏损风险。请自行确认 API 权限、IP 白名单、杠杆与保证金模式与币安账户设置一致,并遵守当地法律法规与 Binance 用户协议。 diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py new file mode 100644 index 0000000..0e5fba2 --- /dev/null +++ b/crypto_monitor_binance/app.py @@ -0,0 +1,7638 @@ +from flask import Flask, render_template, request, redirect, url_for, flash, session, jsonify, Response +import sqlite3 +import csv +from io import StringIO +import time +import threading +import requests +import os +import re +import base64 +import json +import math +from datetime import datetime, timedelta, timezone + +try: + from zoneinfo import ZoneInfo +except ImportError: + ZoneInfo = None # type: ignore +from functools import wraps +import uuid +import ccxt +from werkzeug.utils import secure_filename + +try: + from PIL import Image, ImageDraw, ImageFont +except ImportError: + Image = None # type: ignore + ImageDraw = None # type: ignore + ImageFont = None # type: ignore + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +_REPO_ROOT = os.path.dirname(BASE_DIR) +import sys + +if _REPO_ROOT not in sys.path: + sys.path.insert(0, _REPO_ROOT) +from fib_key_monitor_lib import ( + FIB_KEY_MONITOR_TYPES, + calc_fib_plan, + entry_reason_from_key_signal, + fib_invalidate_by_mark, + fib_ratio_from_type, + is_fib_key_monitor_type, + key_signal_type_for_trade_record, + stored_key_signal_type, +) +from key_sl_tp_lib import ( + breakeven_enabled_from_row, + normalize_sl_tp_mode, + parse_breakeven_enabled_form, + plan_key_sl_tp, + sl_tp_mode_from_row, + sl_tp_mode_label, + sl_tp_plan_summary_text, +) +from history_window_lib import ( + PRESET_CUSTOM, + PRESET_UTC_LAST24H, + PRESET_UTC_LAST7D, + PRESET_UTC_TODAY, + list_window_redirect_query, + resolve_list_window, + resolve_window, + utc_window_to_bj_sql_strings, +) +import license_lib + +def load_env_file(path): + if not os.path.exists(path): + return + raw_bytes = open(path, "rb").read() + text = "" + for enc in ("utf-8-sig", "utf-16", "utf-16-le", "utf-16-be"): + try: + text = raw_bytes.decode(enc) + break + except Exception: + continue + if not text: + text = raw_bytes.decode("utf-8", errors="ignore") + text = text.replace("\x00", "") + for line in text.splitlines(): + raw = line.strip() + if not raw or raw.startswith("#") or "=" not in raw: + continue + key, value = raw.split("=", 1) + clean_key = key.strip().lstrip("\ufeff") + if not clean_key.replace("_", "").isalnum(): + continue + clean_value = value.strip().strip('"').strip("'") + os.environ[clean_key] = clean_value + +load_env_file(os.path.join(BASE_DIR, ".env")) + + +def resolve_path(path_value): + if os.path.isabs(path_value): + return path_value + return os.path.join(BASE_DIR, path_value) + +app = Flask(__name__) +app.secret_key = os.getenv("FLASK_SECRET_KEY", "crypto_monitor_2026_secret_key") + +# ====================== 登录配置 ====================== +USERNAME = os.getenv("APP_USERNAME", "dekun") +PASSWORD = os.getenv("APP_PASSWORD", "Woaini88@") +AUTH_DISABLED = os.getenv("APP_AUTH_DISABLED", "false").lower() in ("1", "true", "yes", "on") + +# 企业微信机器人Webhook +WECHAT_WEBHOOK = os.getenv("WECHAT_WEBHOOK", "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=replace-me") +SYSTEM_TYPE = "CRYPTO" +HOST = os.getenv("APP_HOST", "0.0.0.0") +PORT = int(os.getenv("APP_PORT", "5000")) +DEBUG = os.getenv("APP_DEBUG", "false").lower() == "true" +DB_PATH = resolve_path(os.getenv("DB_PATH", "crypto.db")) + +# 训练参数(可由 .env 覆盖) +DAILY_START_CAPITAL = float(os.getenv("DAILY_START_CAPITAL", "30")) +DAILY_LOSS_CAPITAL = float(os.getenv("DAILY_LOSS_CAPITAL", "20")) +DAILY_PROFIT_CAPITAL = float(os.getenv("DAILY_PROFIT_CAPITAL", "50")) +BTC_LEVERAGE = int(os.getenv("BTC_LEVERAGE", "10")) +ALT_LEVERAGE = int(os.getenv("ALT_LEVERAGE", "5")) +# 交易日滚动与「可开仓」整点:按应用本地时区 wall clock(默认北京时间 UTC+8) +TRADING_DAY_RESET_HOUR = int(os.getenv("TRADING_DAY_RESET_HOUR", "8")) +# false 时关闭「整点前禁止新开仓」守卫(交易日划分仍用 TRADING_DAY_RESET_HOUR) +TRADING_DAY_RESET_OPEN_GUARD_ENABLED = os.getenv( + "TRADING_DAY_RESET_OPEN_GUARD_ENABLED", "true" +).lower() in ("1", "true", "yes", "on") +APP_TIMEZONE = os.getenv("APP_TIMEZONE", "Asia/Shanghai") + + +def _resolve_app_tz(): + if ZoneInfo is not None: + try: + return ZoneInfo((APP_TIMEZONE or "Asia/Shanghai").strip()) + except Exception: + pass + return timezone(timedelta(hours=8)) + + +APP_TZ = _resolve_app_tz() +LIVE_TRADING_ENABLED = os.getenv("LIVE_TRADING_ENABLED", "false").lower() == "true" +BINANCE_API_KEY = (os.getenv("BINANCE_API_KEY") or "").strip() +BINANCE_API_SECRET = (os.getenv("BINANCE_API_SECRET") or "").strip() +BINANCE_MARGIN_MODE = (os.getenv("BINANCE_MARGIN_MODE") or "cross").strip().lower() +# hedge=双向持仓(需 positionSide);oneway / single=单向持仓 +_raw_binance_pos = (os.getenv("BINANCE_POSITION_MODE") or "hedge").strip().lower() +BINANCE_POSITION_MODE = "hedge" if _raw_binance_pos in ("hedge", "dual", "double", "hedged") else "oneway" +# 条件单触发参考:CONTRACT_PRICE=最新成交价 MARK_PRICE=标记价 +BINANCE_TRIGGER_WORKING_TYPE = (os.getenv("BINANCE_TRIGGER_WORKING_TYPE") or "CONTRACT_PRICE").strip().upper() +if BINANCE_TRIGGER_WORKING_TYPE not in ("CONTRACT_PRICE", "MARK_PRICE"): + BINANCE_TRIGGER_WORKING_TYPE = "CONTRACT_PRICE" +# 页面展示的交易所名称(多实例/多环境时可按需区分) +EXCHANGE_DISPLAY_NAME = (os.getenv("EXCHANGE_DISPLAY_NAME") or "Binance").strip() or "Binance" +license_lib.init_flask_app(app, exchange_display=EXCHANGE_DISPLAY_NAME) +_BINANCE_DEFAULT_MARGIN_MODE = "cross" if BINANCE_MARGIN_MODE in ("cross", "cross_margin") else "isolated" +BALANCE_REFRESH_SECONDS = int(os.getenv("BALANCE_REFRESH_SECONDS", "60")) +PRICE_REFRESH_SECONDS = int(os.getenv("PRICE_REFRESH_SECONDS", "5")) +KEY_ALERT_MAX_TIMES = int(os.getenv("KEY_ALERT_MAX_TIMES", "3")) +KEY_ALERT_INTERVAL_MINUTES = int(os.getenv("KEY_ALERT_INTERVAL_MINUTES", "5")) +KEY_AUTO_MIN_PLANNED_RR = float(os.getenv("KEY_AUTO_MIN_PLANNED_RR", "1.5")) +KEY_STOP_OUTSIDE_BREAKOUT_PCT = float(os.getenv("KEY_STOP_OUTSIDE_BREAKOUT_PCT", "0.5")) +KEY_TREND_STOP_OUTSIDE_PCT = float(os.getenv("KEY_TREND_STOP_OUTSIDE_PCT", "1")) +MANUAL_MIN_PLANNED_RR = float(os.getenv("MANUAL_MIN_PLANNED_RR", "1.4")) +MAX_ACTIVE_POSITIONS = max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", "1"))) +KEY_VOLUME_MA_BARS = max(1, int(os.getenv("KEY_VOLUME_MA_BARS", "20"))) +KEY_VOLUME_RATIO_MIN = float(os.getenv("KEY_VOLUME_RATIO_MIN", "1.3")) +KEY_BREAKOUT_AMP_MIN_PCT = float(os.getenv("KEY_BREAKOUT_AMP_MIN_PCT", "0.03")) +KEY_BREAKOUT_AMP_MAX_PCT = float(os.getenv("KEY_BREAKOUT_AMP_MAX_PCT", "0.5")) +KEY_DAILY_VOLUME_RANK_MAX = max(1, int(os.getenv("KEY_DAILY_VOLUME_RANK_MAX", "30"))) +KEY_CONFIRM_BREAKOUT_BAR = int(os.getenv("KEY_CONFIRM_BREAKOUT_BAR", "-2")) +KEY_CONFIRM_BAR = int(os.getenv("KEY_CONFIRM_BAR", "-1")) +KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT = os.getenv("KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT", "true").lower() == "true" +ORDER_MONITOR_TYPE_MANUAL = "下单监控" +ORDER_MONITOR_TYPE_KEY_AUTO = "关键位监控" +KEY_MONITOR_AUTO_TYPES = frozenset({"箱体突破", "收敛突破"}) +# 与币安 App「仓位历史-实现盈亏」对齐:默认仅 REALIZED_PNL(手续费另计;避免与 COMMISSION 重复扣) +BINANCE_APP_PNL_INCOME_TYPES = frozenset({"REALIZED_PNL"}) +BINANCE_APP_PNL_INCOME_WITH_FEE = frozenset({"REALIZED_PNL", "COMMISSION"}) +BINANCE_NET_INCOME_TYPES = frozenset( + {"REALIZED_PNL", "COMMISSION", "FUNDING_FEE", "INSURANCE_CLEAR", "INTERNAL_AUTO_CLOSE"} +) +BINANCE_PNL_INCLUDE_FUNDING = os.getenv("BINANCE_PNL_INCLUDE_FUNDING", "false").lower() in ( + "1", + "true", + "yes", +) +KEY_MONITOR_ALERT_ONLY_TYPES = frozenset({"关键阻力位", "关键支撑位"}) +AUTO_TRANSFER_ENABLED = os.getenv("AUTO_TRANSFER_ENABLED", "false").lower() == "true" +AUTO_TRANSFER_AMOUNT = float(os.getenv("AUTO_TRANSFER_AMOUNT", "30")) +AUTO_TRANSFER_FROM = os.getenv("AUTO_TRANSFER_FROM", "funding") +AUTO_TRANSFER_TO = os.getenv("AUTO_TRANSFER_TO", "swap") +FORCE_CLOSE_ENABLED = os.getenv("FORCE_CLOSE_ENABLED", "false").lower() == "true" +FORCE_CLOSE_BJ_HOUR = int(os.getenv("FORCE_CLOSE_BJ_HOUR", "0")) +# 自动划转:仅在北京时间该整点「小时」内尝试;transfer_logs.transfer_day 存 UTC 自然日便于对账 +AUTO_TRANSFER_BJ_HOUR = int(os.getenv("AUTO_TRANSFER_BJ_HOUR", "8")) +WECHAT_TIMEOUT_SECONDS = int(os.getenv("WECHAT_TIMEOUT_SECONDS", "10")) +AI_TIMEOUT_SECONDS = int(os.getenv("AI_TIMEOUT_SECONDS", "120")) +MONITOR_POLL_SECONDS = int(os.getenv("MONITOR_POLL_SECONDS", "3")) +KLINE_TIMEFRAME = os.getenv("KLINE_TIMEFRAME", "5m") +FULL_MARGIN_BUFFER_RATIO = float(os.getenv("FULL_MARGIN_BUFFER_RATIO", "0.98")) +TRANSFER_CCY = os.getenv("TRANSFER_CCY", "USDT") +UPLOAD_FOLDER = resolve_path(os.getenv("UPLOAD_DIR", "static/images")) +ORDER_CHART_ENABLED = os.getenv("ORDER_CHART_ENABLED", "true").lower() == "true" +ORDER_CHART_TFS = [x.strip() for x in (os.getenv("ORDER_CHART_TFS", "4h,1h,15m,5m") or "").split(",") if x.strip()] +ORDER_CHART_LIMIT = int(os.getenv("ORDER_CHART_LIMIT", "100")) +ORDER_CHART_DIR = resolve_path(os.getenv("ORDER_CHART_DIR", "static/images/order_charts")) +DAILY_OPEN_ALERT_THRESHOLD = int(os.getenv("DAILY_OPEN_ALERT_THRESHOLD", "5")) +RISK_PERCENT = float(os.getenv("RISK_PERCENT", "2")) +BREAKEVEN_RR_TRIGGER = float(os.getenv("BREAKEVEN_RR_TRIGGER", "1.0")) +BREAKEVEN_OFFSET_PCT = float(os.getenv("BREAKEVEN_OFFSET_PCT", "0.02")) +BREAKEVEN_STEP_R = float(os.getenv("BREAKEVEN_STEP_R", "1.0")) +DEFAULT_TRADE_STYLE = (os.getenv("DEFAULT_TRADE_STYLE", "trend") or "trend").strip().lower() +OLLAMA_API = os.getenv("OLLAMA_API", "http://127.0.0.1:11434/api/generate") +AI_MODEL = os.getenv("AI_MODEL", "huihui_ai/deepseek-r1-abliterated:latest") + +BINANCE_SOCKS_PROXY = (os.getenv("BINANCE_SOCKS_PROXY") or "").strip() +BINANCE_HTTP_PROXY = (os.getenv("BINANCE_HTTP_PROXY") or "").strip() +BINANCE_HTTPS_PROXY = (os.getenv("BINANCE_HTTPS_PROXY") or "").strip() + + +def build_binance_ccxt_proxies(): + """ + 为 ccxt 配置代理(常用于本机网络不稳定时通过 SSH 动态转发 SOCKS5 出口)。 + + 推荐: + - 本机:ssh -N -D 127.0.0.1:1080 user@vps + - .env:BINANCE_SOCKS_PROXY=socks5h://127.0.0.1:1080 + + 说明: + - socks5h 让代理端解析域名(避免本机 DNS/策略差异);若你明确要本机解析可用 socks5:// + """ + socks = BINANCE_SOCKS_PROXY.strip() + http = BINANCE_HTTP_PROXY.strip() + https = BINANCE_HTTPS_PROXY.strip() or http + if socks: + return {"http": socks, "https": socks} + if http or https: + return {"http": http, "https": https} + return None + + +BINANCE_CCXT_PROXIES = build_binance_ccxt_proxies() + +os.makedirs(UPLOAD_FOLDER, exist_ok=True) +os.makedirs(ORDER_CHART_DIR, exist_ok=True) +app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER + +# Binance USDT 本位永续(ccxt unified: defaultType=swap) +exchange = ccxt.binance({ + "enableRateLimit": True, + "options": { + "defaultType": "swap", + "defaultMarginMode": _BINANCE_DEFAULT_MARGIN_MODE, + "adjustForTimeDifference": True, + }, +}) +if BINANCE_CCXT_PROXIES: + exchange.proxies = BINANCE_CCXT_PROXIES +if BINANCE_API_KEY and BINANCE_API_SECRET: + exchange.apiKey = BINANCE_API_KEY + exchange.secret = BINANCE_API_SECRET +MARKETS_LOADED = False +ACCOUNT_BALANCE_CACHE = { + "updated_at": 0.0, + "funding_usdt": None, + "trading_usdt": None +} +LIQUIDITY_RANK_CACHE = { + "updated_at": 0.0, + "ranks": {}, + "total": 0, +} + +# 企业微信推送 +def send_wechat_msg(content): + prefix = "【加密货币】" + full_msg = f"{prefix}\n{content}" + data = { + "msgtype": "text", + "text": {"content": full_msg} + } + try: + requests.post(WECHAT_WEBHOOK, json=data, timeout=WECHAT_TIMEOUT_SECONDS) + except: + pass + + +_BREAKEVEN_EXCHANGE_WARNED_IDS = set() + + +def _send_breakeven_exchange_warn_once(order_id, message): + """移动保本同步交易所失败:同一笔监控单只推送一次,避免轮询刷屏。""" + oid = int(order_id) + if oid in _BREAKEVEN_EXCHANGE_WARNED_IDS: + return + _BREAKEVEN_EXCHANGE_WARNED_IDS.add(oid) + send_wechat_msg(message) + + +def _clear_breakeven_exchange_warn(order_id): + _BREAKEVEN_EXCHANGE_WARNED_IDS.discard(int(order_id)) + + +def _wechat_account_label(): + return (os.getenv("BINANCE_ACCOUNT_LABEL") or "binance实盘账户").strip() + + +def _wechat_direction_text(direction): + d = (direction or "").lower() + return "多头(long)" if d == "long" else "空头(short)" + + +def _wechat_trading_capital_text(fallback=None): + try: + _, trading_capital = get_exchange_capitals(force=True) + except Exception: + trading_capital = None + if trading_capital is not None: + return f"{round(float(trading_capital), FUNDS_DECIMALS)}U" + if fallback is not None: + try: + return f"{round(float(fallback), FUNDS_DECIMALS)}U" + except Exception: + pass + return "-" + + +def build_wechat_close_message( + symbol, + direction, + result, + pnl_amount, + hold_seconds=None, + trigger_price=None, + current_price=None, + stop_loss=None, + take_profit=None, + close_order_id=None, + extra_note=None, + session_capital_fallback=None, +): + hold_txt = format_hold_minutes(calc_hold_minutes(hold_seconds)) if hold_seconds is not None else "-" + ep = format_price_for_symbol(symbol, trigger_price) + cp = format_price_for_symbol(symbol, current_price) + tp = format_price_for_symbol(symbol, take_profit) + sl = format_price_for_symbol(symbol, stop_loss) + cap_txt = _wechat_trading_capital_text(session_capital_fallback) + try: + if pnl_amount is not None: + pv = float(pnl_amount) + pnl_disp = f"{'+' if pv > 0 else ''}{round(pv, FUNDS_DECIMALS)} U" + else: + pnl_disp = "-" + except (TypeError, ValueError): + pnl_disp = "-" + + lines = [ + f"📉 {symbol} 平仓完成", + f"💼 账户:{_wechat_account_label()}", + "", + "🧾 平仓概要", + f"🔖 平仓单号:{close_order_id or '-'}", + f"📌 方向:{_wechat_direction_text(direction)}", + f"📌 平仓结果:{result or '-'}", + f"💰 本单盈亏:{pnl_disp}", + f"⏱ 持仓时长:{hold_txt}", + f"💵 交易账户资金:{cap_txt}", + "", + "🎯 价位(计划)", + f"开仓成交价:{ep}", + f"离场参考价:{cp}", + f"止盈价位:{tp}", + f"止损价位:{sl}", + ] + if extra_note: + lines.extend(["", "📎 备注", extra_note]) + return "\n".join(lines) + + +def build_wechat_breakeven_message(symbol, direction, arm_txt, now_rr, locked_r, new_sl): + sl_fmt = format_price_for_symbol(symbol, new_sl) + return "\n".join( + [ + f"# 🛡️ {symbol} 保护位更新", + f"**账户:{_wechat_account_label()}**", + "", + "---", + "", + "### 移动保本/止盈", + f"- 方向:**{_wechat_direction_text(direction)}**", + f"- 类型:**{arm_txt}**", + f"- 当前RR:`{round(float(now_rr), 2)}R`", + f"- 锁定RR:`{round(float(locked_r), 2)}R`", + f"- 新保护位:`{sl_fmt}`", + ] + ) + + +def build_wechat_monitor_error_message(symbol, direction, scene, error_text): + return "\n".join( + [ + f"# ⚠️ {symbol} 下单监控异常", + f"**账户:{_wechat_account_label()}**", + "", + "---", + "", + "### 异常信息", + f"- 方向:**{_wechat_direction_text(direction)}**", + f"- 场景:{scene}", + f"- 错误:{str(error_text)}", + ] + ) + + +def build_wechat_key_monitor_message( + symbol, + direction, + monitor_type, + trigger_time, + key_price, + confirm_close, + hard_lines, + btc8h_status, + coin4h_status, + swing4h_pct, + op_lines, + risk_tip=None, +): + lines = [ + f"# 🎯 {symbol} 关键位确认推送", + f"**账户:{_wechat_account_label()}**", + "", + "---", + "", + "### 交易对 / 触发时间", + f"- 交易对:**{symbol}**", + f"- 触发时间:`{trigger_time}`", + "", + "### 方向与确认K", + f"- 方向:**{_wechat_direction_text(direction)}**", + "- 确认K:第二根5m收盘完成", + "", + "### 关键价位", + f"- 类型:**{monitor_type}**", + f"- 箱体关键位:`{key_price}`", + f"- 第二根确认收盘价:`{confirm_close}`", + "", + "### 硬条件校验结果", + ] + lines.extend([f"- {x}" for x in hard_lines]) + lines.extend( + [ + "", + "### 市场状态说明", + f"- BTC 8h 状态:**{btc8h_status}**", + f"- 本币 4h(EMA55) 状态:**{coin4h_status}**", + f"- 4h震荡幅度(5m近48根):`{round(float(swing4h_pct), 3)}%`", + "", + "### 操作提示", + ] + ) + lines.extend([f"- {x}" for x in op_lines]) + if risk_tip: + lines.extend(["", f"### 逆势风险提醒", f"- {risk_tip}"]) + return "\n".join(lines) + + +def _read_image_base64(image_path): + try: + with open(image_path, "rb") as f: + return base64.b64encode(f.read()).decode("utf-8") + except Exception: + return None + + +def _extract_json_object(text): + if not text: + return None + clean = text.strip() + if clean.startswith("```"): + clean = clean.replace("```json", "").replace("```", "").strip() + try: + return json.loads(clean) + except Exception: + pass + match = re.search(r"\{[\s\S]*\}", clean) + if not match: + return None + try: + return json.loads(match.group(0)) + except Exception: + return None + + +def _journal_row_lines_for_ai(idx, row, *, include_hold_duration=True): + """把 journal 字段拼成给 AI 的文本;字段之外的事实不要指望模型自己猜。""" + def nz(v, default="无"): + if v is None: + return default + s = str(v).strip() + return s if s else default + + lines = [ + f"{idx}. {nz(row['coin'])} {nz(row['tf'])} | 盈亏:{nz(row['pnl'])}U | 实际RR:{nz(row['real_rr'])} | 预期RR:{nz(row['expect_rr'])}", + f" 开仓逻辑:{nz(row['entry_reason'])}", + f" 平仓/离场(交易员自述):{nz(row['exit_reason'])}", + ] + if include_hold_duration: + lines.append(f" 持仓时长:{nz(row['hold_duration'])}") + ee_bits = [ + nz(row["early_exit"]), + nz(row["early_exit_reason"]), + nz(row["early_exit_trigger"]), + nz(row["early_exit_note"]), + ] + if any(x != "无" for x in ee_bits): + lines.append( + " 提前离场记录:" + f"{ee_bits[0]} | 原因:{ee_bits[1]} | 触发:{ee_bits[2]} | 备注:{ee_bits[3]}" + ) + mood_bits = f"心态标签:{nz(row['mood_issues'])}" + if row["mood_score"] is not None: + mood_bits += f" | 自评心态分:{row['mood_score']}" + lines.append(f" {mood_bits}") + if nz(row["post_breakeven_stare"]) != "无": + lines.append(f" 保本后盯盘:{nz(row['post_breakeven_stare'])}") + if nz(row["new_trade_while_occupied"]) != "无": + lines.append(f" 占用时新开仓:{nz(row['new_trade_while_occupied'])}") + if nz(row["note"]) != "无": + lines.append(f" 备注:{nz(row['note'])}") + return "\n".join(lines) + "\n" + + +def ai_review(trades_text, period_title, image_paths=None): + prompt = f""" +你是一位专业交易教练。下面是用户的{period_title}交易记录,请做简洁、可执行的复盘(中文)。 + +【硬性规则 — 必须遵守】 +- 你只能根据「交易记录」里**明确出现的字段**陈述事实;禁止编造:是否触发止损、是否扛单、亏损是否扩大、图上具体结构/进出场点位等记录里**没有**的信息。 +- 「平仓/离场」只是交易员自述摘要,不是客观成交明细;若记录未写明代币是否打到止损价、是否软件平仓等,不要断言执行路径,可用「在记录有限前提下,一种可能是……」或简短写「执行路径记录不足,无法判断」。 +- 「提前离场」类结论必须优先依据记录中的「提前离场记录」字段;若该段全为「无」或未出现有效内容,不得写道「明显扛单」「拒不止损」「未执行硬止损」等。 +- 实际RR为负只说明结果相对于预期RR不利,不等同于「风控失灵」或「止损纪律崩溃」,除非记录里另有依据。 +- 禁止用语:人身攻击、夸张定性(如「致命伤」「灾难」);语气克制、对事不对人。 +- 若有截图且你能辨认,再结合图讨论;看不清或无明确定位则明确说「无法从图确认」,不得虚构 K 线故事。 + +【输出结构】 +1. 总体盈亏结构(紧扣笔数、盈亏数字与 RR,少形容词) +2. 心态与执行(每笔 1–10 分 + 一句依据;依据必须对应记录字段) +3. 行为标签(提前离场 / 乱开仓 / 扛单等):仅在有字段或自述支撑时点名;否则写「记录未勾选或未描述,不作强加」 +4. 改进建议(最多 3 条,每条具体可执行) +5. 图表(若有且可读):结合价格行为简述;否则一两句说明无法看图分析 + +交易记录: +{trades_text} +""".strip() + payload = {"model": AI_MODEL, "prompt": prompt, "stream": False, "options": {"temperature": 0.2}} + images = [] + for p in image_paths or []: + b64 = _read_image_base64(p) + if b64: + images.append(b64) + if images: + payload["images"] = images + try: + r = requests.post(OLLAMA_API, json=payload, timeout=AI_TIMEOUT_SECONDS) + return r.json().get("response", "AI 生成失败") + except Exception as e: + return f"AI 调用失败:{str(e)}" + + +def ai_short_advice(prompt_text): + prompt = f""" +你是交易风控助理。请用中文给出**最多 3 条**提醒,要求: +- 每条不超过 25 个字 +- 语气克制、具体、可执行 +- 不要输出 Markdown,不要编号前缀以外的废话 + +场景: +{prompt_text} +""".strip() + payload = {"model": AI_MODEL, "prompt": prompt, "stream": False, "options": {"temperature": 0.2}} + try: + r = requests.post(OLLAMA_API, json=payload, timeout=AI_TIMEOUT_SECONDS) + return (r.json().get("response") or "").strip() + except Exception: + return "" + + +def _load_font(size): + if not ImageFont: + return None + candidates = [ + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", + "C:\\Windows\\Fonts\\msyh.ttc", + "C:\\Windows\\Fonts\\arial.ttf", + ] + for path in candidates: + if path and os.path.exists(path): + try: + return ImageFont.truetype(path, size) + except Exception: + continue + try: + return ImageFont.load_default() + except Exception: + return None + + +def _ohlcv_to_rows(ohlcv): + rows = [] + for bar in ohlcv or []: + if not bar or len(bar) < 6: + continue + try: + rows.append( + { + "ts": int(bar[0]), + "o": float(bar[1]), + "h": float(bar[2]), + "l": float(bar[3]), + "c": float(bar[4]), + "v": float(bar[5]), + } + ) + except Exception: + continue + return rows + + +def _local_input_datetime_to_ms(dt_text): + raw = str(dt_text or "").strip() + if not raw: + return None + raw = raw.replace("T", " ") + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"): + try: + dt = datetime.strptime(raw, fmt) + aware = dt.replace(tzinfo=APP_TZ) + return int(aware.timestamp() * 1000) + except Exception: + continue + return None + + +def _marker_tag_label(tag): + t = str(tag or "").strip().upper() + if t == "ENTRY": + return "开仓" + if t == "EXIT": + return "平仓" + return str(tag or "") + + +def _pick_marker_point(rows, target_ts_ms, target_price=None): + if not rows or target_ts_ms is None: + return None, None + idx = min(range(len(rows)), key=lambda i: abs(int(rows[i]["ts"]) - int(target_ts_ms))) + if target_price is not None: + try: + p = float(target_price) + if p > 0: + return idx, p + except Exception: + pass + return idx, float(rows[idx]["c"]) + + +def _render_candles_subplot(rows, title, width, height, bg_rgb=(255, 255, 255), marker_points=None): + if not Image or not ImageDraw: + raise RuntimeError("缺少依赖:Pillow(pip install Pillow)") + img = Image.new("RGB", (width, height), bg_rgb) + draw = ImageDraw.Draw(img) + font = _load_font(14) + small = _load_font(12) + + pad_l, pad_r, pad_t, pad_b = 46, 12, 26, 28 + plot_w = max(10, width - pad_l - pad_r) + plot_h = max(10, height - pad_t - pad_b) + + header_bg = (245, 247, 250) + draw.rectangle((0, 0, width, pad_t), fill=header_bg) + if font: + draw.text((10, 6), title, fill=(25, 35, 60), font=font) + else: + draw.text((10, 6), title, fill=(25, 35, 60)) + + if not rows: + if small: + draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120), font=small) + else: + draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120)) + return img + + lo = min(r["l"] for r in rows) + hi = max(r["h"] for r in rows) + if hi <= lo: + hi = lo + 1e-12 + + n = len(rows) + marker_by_idx = {} + for mp in marker_points or []: + try: + idx = int(mp.get("idx")) + except Exception: + continue + if idx < 0 or idx >= n: + continue + marker_by_idx.setdefault(idx, []).append(mp) + + x0 = pad_l + for i, r in enumerate(rows): + x1 = pad_l + int((i + 1) * plot_w / n) + x_mid = (x0 + x1) // 2 + wick_x = x_mid + y_high = pad_t + int((hi - r["h"]) / (hi - lo) * plot_h) + y_low = pad_t + int((hi - r["l"]) / (hi - lo) * plot_h) + y_open = pad_t + int((hi - r["o"]) / (hi - lo) * plot_h) + y_close = pad_t + int((hi - r["c"]) / (hi - lo) * plot_h) + top = min(y_open, y_close) + bot = max(y_open, y_close) + up = r["c"] >= r["o"] + wick_color = (120, 120, 120) + edge_color = (20, 20, 20) + draw.line((wick_x, y_high, wick_x, y_low), fill=wick_color) + body_w = max(1, (x1 - x0) - 2) + left = x0 + 1 + if bot - top < 2: + mid = (top + bot) // 2 + draw.rectangle((left, mid, left + body_w, mid + 1), fill=edge_color) + else: + if up: + draw.rectangle((left, top, left + body_w, bot), fill=(255, 255, 255), outline=edge_color, width=1) + else: + draw.rectangle((left, top, left + body_w, bot), fill=edge_color, outline=edge_color, width=1) + for j, mp in enumerate(marker_by_idx.get(i, [])): + tag = str(mp.get("tag") or "") + label = _marker_tag_label(tag) + m_price = float(mp.get("price") or r["c"]) + y_m = pad_t + int((hi - m_price) / (hi - lo) * plot_h) + y_m = max(pad_t + 4, min(pad_t + plot_h - 4, y_m)) + x_off = (j - (len(marker_by_idx[i]) - 1) / 2.0) * 14 + x_draw = int(x_mid + x_off) + if tag == "ENTRY": + m_color = (0, 195, 95) + tri = [(x_draw, y_m - 20), (x_draw - 9, y_m - 4), (x_draw + 9, y_m - 4)] + text_y = y_m - 36 + else: + m_color = (235, 65, 65) + tri = [(x_draw, y_m + 20), (x_draw - 9, y_m + 4), (x_draw + 9, y_m + 4)] + text_y = y_m + 12 + draw.ellipse((x_draw - 5, y_m - 5, x_draw + 5, y_m + 5), fill=m_color, outline=(255, 255, 255), width=1) + draw.polygon(tri, fill=m_color) + draw.line((x_draw, y_m, x_draw, y_m - 16 if tag == "ENTRY" else y_m + 16), fill=m_color, width=3) + if font: + draw.text((x_draw + 8, text_y), label, fill=m_color, font=font) + else: + draw.text((x_draw + 8, text_y), label, fill=m_color) + x0 = x1 + + if len(marker_points or []) >= 2: + try: + entry = next((m for m in marker_points if m.get("tag") == "ENTRY"), None) + exitp = next((m for m in marker_points if m.get("tag") == "EXIT"), None) + if entry is not None and exitp is not None: + ex_i, ex_p = int(entry["idx"]), float(entry["price"]) + xx_i, xx_p = int(exitp["idx"]), float(exitp["price"]) + x_ex = pad_l + int((ex_i + 0.5) * plot_w / n) + x_xx = pad_l + int((xx_i + 0.5) * plot_w / n) + y_ex = pad_t + int((hi - ex_p) / (hi - lo) * plot_h) + y_xx = pad_t + int((hi - xx_p) / (hi - lo) * plot_h) + draw.line((x_ex, y_ex, x_xx, y_xx), fill=(35, 135, 255), width=3) + except Exception: + pass + + # 极简风格:不画网格与坐标轴,仅保留右下角轻量区间信息 + if small: + draw.text((width - 210, height - 22), f"L={lo:.6g} H={hi:.6g}", fill=(120, 125, 135), font=small) + return img + + +def _timeframe_period_ms(tf): + s = (tf or "").strip().lower() + if s.endswith("m"): + try: + return int(s[:-1]) * 60 * 1000 + except ValueError: + pass + if s.endswith("h"): + try: + return int(s[:-1]) * 3600 * 1000 + except ValueError: + pass + if s.endswith("d"): + try: + return int(s[:-1]) * 86400 * 1000 + except ValueError: + pass + return 300000 + + +def _ohlcv_dict_rows_to_lists(rows, lim): + if not rows: + return [] + pick = rows[-lim:] if len(rows) >= lim else rows + return [[r["ts"], r["o"], r["h"], r["l"], r["c"], r.get("v", 0)] for r in pick] + + +def _fetch_ohlcv_ending_at(exchange_symbol, timeframe, limit, end_ts_ms): + """以 end_ts_ms 为终点向前取 K 线(无 end 则拉最近 limit 根)。""" + lim = max(2, int(limit or ORDER_CHART_LIMIT)) + try: + if not end_ts_ms: + ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=lim) + else: + period = _timeframe_period_ms(timeframe) + since = int(end_ts_ms) - period * (lim + 10) + ohlcv = exchange.fetch_ohlcv( + exchange_symbol, timeframe=timeframe, since=max(0, since), limit=lim + 20 + ) + except Exception: + return [] + rows = _ohlcv_to_rows(ohlcv) + if not rows: + return [] + if not end_ts_ms: + return _ohlcv_dict_rows_to_lists(rows, lim) + filtered = [r for r in rows if int(r["ts"]) <= int(end_ts_ms)] + if len(filtered) >= 2: + return _ohlcv_dict_rows_to_lists(filtered, lim) + return _ohlcv_dict_rows_to_lists(rows, lim) + + +def generate_multi_timeframe_chart_png( + exchange_symbol, + title_prefix, + timeframes=None, + limit=None, + out_dir=None, + filename=None, + filename_prefix="chart", + marker_payload=None, + marker_timeframes=None, +): + if not ORDER_CHART_ENABLED: + return None + if not Image: + return None + requested = timeframes or ORDER_CHART_TFS + limit = limit or ORDER_CHART_LIMIT + preferred_layout = ["5m", "15m", "1h", "4h"] + requested_set = set(requested or []) + ordered = [tf for tf in preferred_layout if tf in requested_set] + for tf in requested: + if tf not in ordered: + ordered.append(tf) + timeframes = ordered[:4] if ordered else preferred_layout + + ensure_markets_loaded() + panels = [] + cell_w, cell_h = 980, 520 + end_ts_ms = None + if marker_payload: + try: + end_ts_ms = int(marker_payload.get("exit_ts_ms") or marker_payload.get("entry_ts_ms") or 0) or None + except (TypeError, ValueError): + end_ts_ms = None + default_marker_tfs = {str(t).strip().lower() for t in timeframes} + for tf in timeframes: + try: + ohlcv = _fetch_ohlcv_ending_at(exchange_symbol, tf, limit, end_ts_ms) + if not ohlcv and end_ts_ms: + ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=tf, limit=limit) + except Exception: + ohlcv = [] + rows = _ohlcv_to_rows(ohlcv)[-limit:] + title = f"{title_prefix} | {tf} x{len(rows)}" + points = [] + tf_key = str(tf).strip().lower() + if marker_payload: + if marker_timeframes: + marker_tfs = {str(x).strip().lower() for x in marker_timeframes if str(x).strip()} + else: + marker_tfs = default_marker_tfs + else: + marker_tfs = set() + if marker_payload and tf_key in marker_tfs: + entry_idx, entry_price = _pick_marker_point(rows, marker_payload.get("entry_ts_ms"), marker_payload.get("entry_price")) + exit_idx, exit_price = _pick_marker_point(rows, marker_payload.get("exit_ts_ms"), marker_payload.get("exit_price")) + if entry_idx is not None and entry_price is not None: + points.append({"idx": entry_idx, "price": entry_price, "tag": "ENTRY"}) + if exit_idx is not None and exit_price is not None: + points.append({"idx": exit_idx, "price": exit_price, "tag": "EXIT"}) + panels.append( + _render_candles_subplot( + rows, + title, + width=cell_w, + height=cell_h, + bg_rgb=(255, 255, 255), + marker_points=points, + ) + ) + + if not panels: + return None + + gap = 10 + cols = 2 + rows_n = int(math.ceil(len(panels) / cols)) + w = cols * cell_w + (cols - 1) * gap + h = rows_n * cell_h + (rows_n - 1) * gap + out = Image.new("RGB", (w, h), (255, 255, 255)) + idx = 0 + for r in range(rows_n): + for c in range(cols): + if idx >= len(panels): + break + x = c * (cell_w + gap) + y = r * (cell_h + gap) + out.paste(panels[idx], (x, y)) + idx += 1 + + # 四宫格间隔线(仅在拼图间隙处画线,不进入单张子图) + if ImageDraw and rows_n >= 1: + draw_out = ImageDraw.Draw(out) + line_col = (220, 225, 232) + x_mid = cell_w + gap // 2 + if w > x_mid >= 0: + draw_out.line((x_mid, 0, x_mid, h), fill=line_col, width=2) + for rr in range(1, rows_n): + y_mid = rr * cell_h + (rr - 1) * gap + gap // 2 + if 0 <= y_mid <= h: + draw_out.line((0, y_mid, w, y_mid), fill=line_col, width=2) + + target_dir = out_dir or ORDER_CHART_DIR + os.makedirs(target_dir, exist_ok=True) + fname = filename or f"{filename_prefix}_{uuid.uuid4().hex}.png" + out_path = os.path.join(target_dir, fname) + out.save(out_path, format="PNG") + return fname + + +def generate_order_open_chart( + exchange_symbol, + title_prefix, + timeframes=None, + limit=None, + opened_at_ms=None, + entry_price=None, +): + marker_payload = None + if opened_at_ms: + marker_payload = { + "entry_ts_ms": opened_at_ms, + "exit_ts_ms": None, + "entry_price": entry_price, + "exit_price": None, + } + marker_tfs = ( + {x.strip().lower() for x in (timeframes or ORDER_CHART_TFS) if x and str(x).strip()} + or {"5m", "15m", "1h", "4h"} + ) + return generate_multi_timeframe_chart_png( + exchange_symbol, + title_prefix, + timeframes=timeframes, + limit=limit, + out_dir=ORDER_CHART_DIR, + filename=None, + filename_prefix="order", + marker_payload=marker_payload, + marker_timeframes=marker_tfs, + ) + + +def journal_coin_from_symbol(symbol): + sym = (symbol or "").strip().upper() + if not sym: + return "" + if "/" in sym: + return sym.split("/")[0].strip() + if "-" in sym: + return sym.split("-")[0].strip() + if sym.endswith("USDT"): + return sym[:-4].strip() + return sym + + +EARLY_EXIT_TRIGGERS = ( + "", + "止盈", + "保本止盈", + "移动止盈", + "手动平仓", + "止损", + "其他", +) + +# 与用户约定的固定开仓类型(仅做这几类单子) +ENTRY_REASON_OPTIONS = ( + "趋势多头:4h大结构突破前进场,确认条件:三次探顶,5m收敛不创新低", + "趋势空头:4h大结构突破前进场,确认条件:三次探底,5m收敛不创新高", + "趋势多头:小分歧低吸入场(左侧),确认条件:二次探底", + "趋势空头:小分歧高吸入场(左侧),确认条件:二次探顶", + "波段单:5m顺势突破,确认条件:2根k线+成交量放大+4h同向+日成交量前20", + "关键位箱体突破", + "关键位收敛突破", + "关键位斐波0.618", + "关键位斐波0.786", +) + +STATS_SEGMENT_DEFS = ( + ("all", "全部交易", {"segment": "all"}), + ("manual", "下单监控", {"segment": "manual"}), + ("key_box", "关键位箱体突破", {"segment": "key_box"}), + ("key_conv", "关键位收敛结构", {"segment": "key_conv"}), + ("key_fib618", "关键位斐波0.618", {"segment": "key_fib618"}), + ("key_fib786", "关键位斐波0.786", {"segment": "key_fib786"}), +) +# 复盘表单「其他」选项的 value(非入库值;自定义文本走 entry_reason_custom) +ENTRY_REASON_OTHER = "__OTHER__" + + +def normalize_entry_reason(raw, custom_text=None): + v = str(raw or "").strip() + if v == ENTRY_REASON_OTHER: + c = str(custom_text or "").strip() + return c[:2000] if c else "" + return v if v in ENTRY_REASON_OPTIONS else "" + + +def entry_reason_valid_for_storage(s): + """允许五种固定整句、或自定义短文本(不含未解析的 __OTHER__ 占位)。""" + t = str(s or "").strip() + if not t: + return True + if t == ENTRY_REASON_OTHER: + return False + if t in ENTRY_REASON_OPTIONS: + return True + return 1 <= len(t) <= 2000 + + +def normalize_early_exit_trigger(raw): + v = str(raw or "").strip() + return v if v in EARLY_EXIT_TRIGGERS else "" + + +def compose_early_exit_reason_saved(trigger, note): + """Readable single-line string stored in early_exit_reason for legacy consumers.""" + t = normalize_early_exit_trigger(trigger) + n = str(note or "").strip() + if t and n: + return f"{t}|{n}" + return t or n + + +def journal_exit_reason_stored(trigger, note): + """exit_reason 列与表单「一处」对齐:非手工=触发类型;手工=离场说明全文。""" + t = normalize_early_exit_trigger(trigger) + n = str(note or "").strip() + if t == "手动平仓": + return n + return t + + +def ai_extract_journal_from_image(image_b64): + prompt = """ +你是交易复盘信息提取助手。请从截图中提取可识别字段,并只输出 JSON(不要 markdown,不要解释)。 +要求: +1) 仅输出一个 JSON 对象。 +2) 时间输出为 YYYY-MM-DDTHH:MM(用于 HTML datetime-local),无法识别填空字符串。 +3) 不要猜测主观原因;early_exit_note(仅手工平仓)、note 默认留空,除非图中明确写出。 +4) 允许字段为空。 +5) entry_reason:优先从下列完整字符串中选一个(一字不差);若无法归类则可将简述写入 entry_reason(保存时也可选表单「其他」手写): + - 趋势多头:4h大结构突破前进场,确认条件:三次探顶,5m收敛不创新低 + - 趋势空头:4h大结构突破前进场,确认条件:三次探底,5m收敛不创新高 + - 趋势多头:小分歧低吸入场(左侧),确认条件:二次探底 + - 趋势空头:小分歧高吸入场(左侧),确认条件:二次探顶 + - 波段单:5m顺势突破,确认条件:2根k线+成交量放大+4h同向+日成交量前20 +6) early_exit_trigger 只能从下列取值中选一个(无法识别则填空字符串):保本止盈、移动止盈、手动平仓、止损、其他。 +7) 若触发为「手动平仓」,early_exit_note 必须写出图中可见的补充说明;其他触发类型 early_exit_note 留空。 +8) 若图中有无法归类的离场说明原文,可放进 early_exit_note,early_exit_trigger 填「其他」或留空。 + +JSON 字段: +{ + "open_datetime": "", + "close_datetime": "", + "coin": "", + "tf": "", + "pnl": "", + "expect_rr": "", + "real_rr": "", + "entry_reason": "", + "early_exit_trigger": "", + "early_exit_note": "", + "early_exit_reason": "", + "note": "" +} +""".strip() + payload = { + "model": AI_MODEL, + "prompt": prompt, + "images": [image_b64], + "stream": False, + "options": {"temperature": 0.1}, + } + try: + r = requests.post(OLLAMA_API, json=payload, timeout=AI_TIMEOUT_SECONDS) + raw = r.json().get("response", "") + data = _extract_json_object(raw) or {} + if not isinstance(data, dict): + data = {} + trig_in = data.get("early_exit_trigger") + note_in = data.get("early_exit_note") + legacy_reason = str(data.get("early_exit_reason") or "").strip() + out = { + "open_datetime": str(data.get("open_datetime") or "").strip(), + "close_datetime": str(data.get("close_datetime") or "").strip(), + "coin": str(data.get("coin") or "").strip(), + "tf": str(data.get("tf") or "").strip(), + "pnl": str(data.get("pnl") or "").strip(), + "expect_rr": str(data.get("expect_rr") or "").strip(), + "real_rr": str(data.get("real_rr") or "").strip(), + "entry_reason": normalize_entry_reason(data.get("entry_reason")), + "early_exit_trigger": normalize_early_exit_trigger(trig_in), + "early_exit_note": str(note_in or "").strip(), + "early_exit_reason": legacy_reason, + "note": str(data.get("note") or "").strip(), + } + if not out["early_exit_trigger"] and not out["early_exit_note"] and legacy_reason: + out["early_exit_note"] = legacy_reason + if out["early_exit_trigger"] == "手动平仓" and not out["early_exit_note"] and legacy_reason: + out["early_exit_note"] = legacy_reason + if out["early_exit_trigger"] != "手动平仓": + out["early_exit_note"] = "" + out["exit_reason"] = journal_exit_reason_stored(out["early_exit_trigger"], out["early_exit_note"]) + return out + except Exception: + return None + +# 初始化数据库(支持多空方向) +def init_db(): + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + + # 关键位监控 + c.execute('''CREATE TABLE IF NOT EXISTS key_monitors + (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT, + direction TEXT DEFAULT "long", upper REAL, lower REAL, + notification_count INTEGER DEFAULT 0, last_notified_at TEXT, + max_notify INTEGER DEFAULT 3, notify_interval_min INTEGER DEFAULT 5, + breakout_limit_pct REAL DEFAULT 1.5, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + # 订单监控(核心:加 direction 方向字段) + c.execute('''CREATE TABLE IF NOT EXISTS order_monitors + (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, direction TEXT DEFAULT "long", + exchange_symbol TEXT, + trigger_price REAL, stop_loss REAL, initial_stop_loss REAL, take_profit REAL, + margin_capital REAL DEFAULT 30, leverage INTEGER DEFAULT 5, + trade_style TEXT DEFAULT "trend", + risk_percent REAL, risk_amount REAL, + breakeven_rr_trigger REAL, breakeven_offset_pct REAL, breakeven_step_r REAL, + breakeven_armed INTEGER DEFAULT 0, breakeven_price REAL, + notional_value REAL, position_ratio REAL, base_amount REAL, + order_amount REAL, exchange_order_id TEXT, exchange_close_order_id TEXT, + opened_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, opened_at_ms INTEGER, session_date TEXT, + status TEXT DEFAULT "active")''') + + # 交易记录(必须存多空) + c.execute('''CREATE TABLE IF NOT EXISTS trade_records + (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT, + direction TEXT DEFAULT "long", trigger_price REAL, stop_loss REAL, initial_stop_loss REAL, take_profit REAL, + margin_capital REAL, leverage INTEGER, pnl_amount REAL DEFAULT 0, hold_seconds INTEGER DEFAULT 0, + trade_style TEXT DEFAULT "trend", risk_amount REAL, planned_rr REAL, actual_rr REAL, + hold_minutes INTEGER DEFAULT 0, opened_at TEXT, opened_at_ms INTEGER, closed_at TEXT, closed_at_ms INTEGER, + result TEXT, miss_reason TEXT, exchange_trade_id TEXT, + reviewed_opened_at TEXT, reviewed_closed_at TEXT, reviewed_stop_loss REAL, reviewed_take_profit REAL, reviewed_pnl_amount REAL, + reviewed_result TEXT, reviewed_miss_reason TEXT, reviewed_hold_seconds INTEGER, reviewed_hold_minutes INTEGER, + reviewed_at TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + c.execute('''CREATE TABLE IF NOT EXISTS trading_sessions + (session_date TEXT PRIMARY KEY, start_capital REAL, current_capital REAL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + c.execute('''CREATE TABLE IF NOT EXISTS journal_entries + (id TEXT PRIMARY KEY, open_datetime TEXT, close_datetime TEXT, hold_duration TEXT, + coin TEXT, tf TEXT, pnl TEXT, entry_reason TEXT, exit_reason TEXT, + expect_rr TEXT, real_rr TEXT, early_exit TEXT, early_exit_reason TEXT, + early_exit_trigger TEXT, early_exit_note TEXT, + mood_score INTEGER, mood_ai_score INTEGER, mood_ai_comment TEXT, mood_issues TEXT, post_breakeven_stare TEXT, + new_trade_while_occupied TEXT, note TEXT, image TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + c.execute('''CREATE TABLE IF NOT EXISTS ai_reviews + (id TEXT PRIMARY KEY, review_type TEXT, target_date TEXT, content TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + c.execute('''CREATE TABLE IF NOT EXISTS transfer_logs + (id INTEGER PRIMARY KEY AUTOINCREMENT, transfer_type TEXT, transfer_day TEXT, + amount REAL, from_account TEXT, to_account TEXT, status TEXT, message TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + c.execute('''DROP INDEX IF EXISTS idx_transfer_logs_unique_day''') + c.execute('''CREATE UNIQUE INDEX IF NOT EXISTS idx_transfer_logs_auto_daily_unique + ON transfer_logs(transfer_type, transfer_day) + WHERE transfer_type = 'auto_daily' ''') + + # 给旧表加 direction 字段(兼容老数据,不报错) + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN direction TEXT DEFAULT 'long'") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_symbol TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN margin_capital REAL DEFAULT 30") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN leverage INTEGER DEFAULT 5") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN trade_style TEXT DEFAULT 'trend'") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN risk_percent REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN risk_amount REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_rr_trigger REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_offset_pct REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_step_r REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_armed INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_price REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN initial_stop_loss REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN notional_value REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN position_ratio REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN base_amount REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN order_amount REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_order_id TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_close_order_id TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN opened_at TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN opened_at_ms INTEGER") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN session_date TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 1") + except Exception: + pass + try: + c.execute(f"ALTER TABLE order_monitors ADD COLUMN monitor_type TEXT DEFAULT '{ORDER_MONITOR_TYPE_MANUAL}'") + except Exception: + pass + try: + c.execute( + "UPDATE order_monitors SET monitor_type=? WHERE monitor_type IS NULL OR TRIM(monitor_type)=''", + (ORDER_MONITOR_TYPE_MANUAL,), + ) + except Exception: + pass + try: + c.execute("UPDATE order_monitors SET opened_at = datetime('now') WHERE opened_at IS NULL OR opened_at = ''") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN direction TEXT DEFAULT 'long'") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN margin_capital REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN leverage INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN pnl_amount REAL DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN hold_seconds INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN hold_minutes INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN trade_style TEXT DEFAULT 'trend'") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN risk_amount REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN planned_rr REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN actual_rr REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN initial_stop_loss REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN exchange_trade_id TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN opened_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN opened_at_ms INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN closed_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN closed_at_ms INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_opened_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_closed_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_stop_loss REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_take_profit REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_pnl_amount REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_result TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_miss_reason TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_hold_seconds INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_hold_minutes INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN entry_reason TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_entry_reason TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN mood_ai_score INTEGER") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN mood_ai_comment TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_trigger TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_note TEXT") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN direction TEXT DEFAULT 'long'") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN notification_count INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN last_notified_at TEXT") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN max_notify INTEGER DEFAULT 3") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN notify_interval_min INTEGER DEFAULT 5") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN breakout_limit_pct REAL DEFAULT 1.5") + except: pass + for ddl in ( + "ALTER TABLE key_monitors ADD COLUMN fib_limit_order_id TEXT", + "ALTER TABLE key_monitors ADD COLUMN fib_entry_price REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_stop_loss REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_take_profit REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_order_amount REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_margin_capital REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_leverage INTEGER", + "ALTER TABLE key_monitors ADD COLUMN sl_tp_mode TEXT DEFAULT 'standard'", + "ALTER TABLE key_monitors ADD COLUMN manual_take_profit REAL", + "ALTER TABLE key_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 0", + ): + try: + c.execute(ddl) + except Exception: + pass + + try: + c.execute("ALTER TABLE trading_sessions ADD COLUMN key_sizing_capital_snapshot REAL") + except Exception: + pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN key_signal_type TEXT") + except Exception: + pass + for col, ddl in ( + ("key_signal_type", "ALTER TABLE trade_records ADD COLUMN key_signal_type TEXT"), + ("exchange_realized_pnl", "ALTER TABLE trade_records ADD COLUMN exchange_realized_pnl REAL"), + ("exchange_opened_at", "ALTER TABLE trade_records ADD COLUMN exchange_opened_at TEXT"), + ("exchange_closed_at", "ALTER TABLE trade_records ADD COLUMN exchange_closed_at TEXT"), + ("exchange_sync_key", "ALTER TABLE trade_records ADD COLUMN exchange_sync_key TEXT"), + ): + try: + c.execute(ddl) + except Exception: + pass + + c.execute( + """CREATE TABLE IF NOT EXISTS key_monitor_history + (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT, direction TEXT, + upper REAL, lower REAL, notification_count INTEGER, last_alert_message TEXT, + close_reason TEXT, closed_at TEXT)""" + ) + + conn.commit() + conn.close() + +init_db() + +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def app_now(): + """应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较)。""" + return datetime.now(APP_TZ).replace(tzinfo=None) + + +def app_now_str(): + return app_now().strftime("%Y-%m-%d %H:%M:%S") + + +def utc_now_dt(): + """当前时刻(UTC,aware)。""" + return datetime.now(timezone.utc) + + +def utc_calendar_date_str(): + """UTC 自然日 YYYY-MM-DD(用于自动划转去重等与交易所日界对齐的计算)。""" + return utc_now_dt().strftime("%Y-%m-%d") + + +def get_trading_day(now=None): + """交易日字符串:本地时钟下若小时 < TRADING_DAY_RESET_HOUR 则归属「上一日历日」。""" + now = now or app_now() + if getattr(now, "tzinfo", None): + now = now.astimezone(APP_TZ).replace(tzinfo=None) + if now.hour < TRADING_DAY_RESET_HOUR: + return (now - timedelta(days=1)).strftime("%Y-%m-%d") + return now.strftime("%Y-%m-%d") + + +TRADE_COMPLETED_RESULTS = ( + "止盈", + "止损", + "保本止盈", + "移动止盈", + "手动平仓", + "强制清仓", + "外部平仓", +) + +REVIEW_RESULT_OPTIONS = ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓") + + +def parse_dt_for_trading_day(s): + if not s: + return None + s = str(s).strip().replace("Z", "").replace("T", " ") + if not s: + return None + for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)): + try: + return datetime.strptime(s[:ln], fmt) + except ValueError: + continue + return None + + +def insert_key_monitor_history(conn, row, notification_count, last_msg, close_reason): + conn.execute( + """INSERT INTO key_monitor_history + (symbol, monitor_type, direction, upper, lower, notification_count, last_alert_message, close_reason, closed_at) + VALUES (?,?,?,?,?,?,?,?,?)""", + ( + row["symbol"], + row["monitor_type"], + row["direction"] or "long", + row["upper"], + row["lower"], + int(notification_count or 0), + (last_msg or "")[:800] if last_msg else None, + close_reason, + app_now_str(), + ), + ) + + +def _session_week_bounds(trading_day_str): + end = datetime.strptime(trading_day_str, "%Y-%m-%d").date() + start = end - timedelta(days=6) + return start.strftime("%Y-%m-%d"), trading_day_str + + +def _calendar_month_bounds(local_dt): + y, m = local_dt.year, local_dt.month + start = f"{y:04d}-{m:02d}-01" + if m == 12: + end_d = datetime(y, 12, 31).date() + else: + end_d = (datetime(y, m + 1, 1) - timedelta(days=1)).date() + return start, end_d.strftime("%Y-%m-%d") + + +def _count_opens_between(conn, start_td, end_td): + return _count_opens_for_segment(conn, start_td, end_td, "all") + + +def _list_window_from_request(): + return resolve_list_window(request.args, session, default_preset=PRESET_UTC_TODAY) + + +def _redirect_records(): + qs = list_window_redirect_query(session) + return redirect(f"/records?{qs}" if qs else "/records") + + +def _pnl_row_matches_segment(row, segment_key): + try: + mt = (row["monitor_type"] or "").strip() + kst = (row["key_signal_type"] or "").strip() + except Exception: + return False + if segment_key == "all": + return True + if segment_key == "manual": + return mt == ORDER_MONITOR_TYPE_MANUAL and not kst + if segment_key == "key_box": + return kst == "箱体突破" + if segment_key == "key_conv": + return kst == "收敛突破" + if segment_key == "key_fib618": + return kst == "斐波回调0.618" + if segment_key == "key_fib786": + return kst == "斐波回调0.786" + return False + + +def _count_opens_for_segment(conn, start_td, end_td, segment_key): + if segment_key == "manual": + return conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ? " + "AND (monitor_type IS NULL OR monitor_type=? OR TRIM(monitor_type)='') " + "AND (key_signal_type IS NULL OR TRIM(key_signal_type)='')", + (start_td, end_td, ORDER_MONITOR_TYPE_MANUAL), + ).fetchone()[0] + kst_map = { + "key_box": "箱体突破", + "key_conv": "收敛突破", + "key_fib618": "斐波回调0.618", + "key_fib786": "斐波回调0.786", + } + kst = kst_map.get(segment_key) + if kst: + return conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ? AND key_signal_type=?", + (start_td, end_td, kst), + ).fetchone()[0] + return conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ?", + (start_td, end_td), + ).fetchone()[0] + + +def _load_completed_trade_pnls(conn): + q = """SELECT pnl_amount, reviewed_pnl_amount, closed_at, reviewed_closed_at, created_at, opened_at, + result, reviewed_result, monitor_type, key_signal_type + FROM trade_records + ORDER BY COALESCE(closed_at, created_at, opened_at) ASC, id ASC""" + rows = conn.execute(q).fetchall() + out = [] + for r in rows: + effective_result = (r["reviewed_result"] or r["result"] or "").strip() + if effective_result not in TRADE_COMPLETED_RESULTS: + continue + try: + p = float(r["reviewed_pnl_amount"] if r["reviewed_pnl_amount"] is not None else (r["pnl_amount"] or 0)) + except (TypeError, ValueError): + p = 0.0 + t = parse_dt_for_trading_day(r["reviewed_closed_at"]) or parse_dt_for_trading_day(r["closed_at"]) or parse_dt_for_trading_day(r["created_at"]) + td = get_trading_day(t) if t else None + out.append((p, t, td, r)) + return out + + +def _compute_period_metrics(trades): + """trades: list of (pnl, close_dt, close_trading_day)""" + trades = [(p, t, td) for p, t, td in trades if t is not None] + trades.sort(key=lambda x: x[1]) + closed = len(trades) + wins = sum(1 for p, _, _ in trades if p > 0) + losses = sum(1 for p, _, _ in trades if p < 0) + net = round(sum(p for p, _, _ in trades), FUNDS_DECIMALS) + loss_sum_raw = sum(p for p, _, _ in trades if p < 0) + loss_sum_u = round(abs(loss_sum_raw), FUNDS_DECIMALS) if loss_sum_raw < 0 else 0.0 + neg_pnls = [p for p, _, _ in trades if p < 0] + pos_pnls = [p for p, _, _ in trades if p > 0] + max_single_loss = round(min(neg_pnls), FUNDS_DECIMALS) if neg_pnls else None + max_single_profit = round(max(pos_pnls), FUNDS_DECIMALS) if pos_pnls else None + cum = peak = max_dd = 0.0 + for p, _, _ in trades: + cum += p + peak = max(peak, cum) + max_dd = max(max_dd, peak - cum) + max_dd = round(max_dd, FUNDS_DECIMALS) + streak = 0 + for p, _, _ in reversed(trades): + if p < 0: + streak += 1 + else: + break + daily = {} + for p, _, td in trades: + if td: + daily[td] = daily.get(td, 0.0) + p + max_loss_streak_days = 0 + worst_day = None + worst_day_pnl = None + if daily: + sorted_days = sorted(daily.keys()) + run = 0 + for d in sorted_days: + if daily[d] < 0: + run += 1 + max_loss_streak_days = max(max_loss_streak_days, run) + else: + run = 0 + worst_day = min(daily.keys(), key=lambda x: daily[x]) + worst_day_pnl = round(daily[worst_day], FUNDS_DECIMALS) + win_rate_pct = round(wins / (wins + losses) * 100, 2) if (wins + losses) else None + return { + "closed_count": closed, + "win_count": wins, + "loss_count": losses, + "win_rate_pct": win_rate_pct, + "net_pnl_u": net, + "loss_sum_u": loss_sum_u, + "max_single_loss": max_single_loss, + "max_single_profit": max_single_profit, + "max_drawdown_u": max_dd, + "consecutive_losses": streak, + "max_loss_streak_days": max_loss_streak_days, + "worst_day": worst_day, + "worst_day_pnl": worst_day_pnl, + "opens_count": 0, + "range_label": "", + } + + +def compute_stats_bundle(conn, trading_day, now_dt=None): + """日 / 周 / 月 统计:平仓按北京时间交易日(默认 8:00 切日)计入。""" + now_dt = now_dt or app_now() + pnls = _load_completed_trade_pnls(conn) + total_opens_all = conn.execute("SELECT COUNT(*) FROM order_monitors").fetchone()[0] + w_start, w_end = _session_week_bounds(trading_day) + m_start, m_end = _calendar_month_bounds(now_dt) + + def slice_metrics(seg_key): + seg_rows = [tr for tr in pnls if _pnl_row_matches_segment(tr[3], seg_key)] + day_tr = [(p, t, td) for p, t, td, _r in seg_rows if td == trading_day] + week_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and w_start <= td <= w_end] + month_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and m_start <= td <= m_end] + dm = _compute_period_metrics(day_tr) + wm = _compute_period_metrics(week_tr) + mm = _compute_period_metrics(month_tr) + dm["opens_count"] = _count_opens_for_segment(conn, trading_day, trading_day, seg_key) + wm["opens_count"] = _count_opens_for_segment(conn, w_start, w_end, seg_key) + mm["opens_count"] = _count_opens_for_segment(conn, m_start, m_end, seg_key) + dm["range_label"] = f"北京时间交易日 {trading_day}({TRADING_DAY_RESET_HOUR}:00 切日)" + wm["range_label"] = f"{w_start} ~ {w_end}(北京日期,近7天)" + mm["range_label"] = f"{m_start} ~ {m_end}(北京自然月)" + return dm, wm, mm + + segments = [] + for seg_key, seg_title, _meta in STATS_SEGMENT_DEFS: + dm, wm, mm = slice_metrics(seg_key) + segments.append({"key": seg_key, "title": seg_title, "day": dm, "week": wm, "month": mm}) + + dm, wm, mm = slice_metrics("all") + + return { + "trading_day": trading_day, + "total_opens_all": total_opens_all, + "day": dm, + "week": wm, + "month": mm, + "segments": segments, + "stats_reset_hour": TRADING_DAY_RESET_HOUR, + } + + +def infer_leverage(symbol): + sym = (symbol or "").strip().upper() + if sym.startswith("BTC") or sym.startswith("ETH"): + return BTC_LEVERAGE + return ALT_LEVERAGE + + +def normalize_exchange_symbol(symbol): + sym = symbol.strip().upper() + if ":" in sym: + return sym + if "/" in sym: + base, quote = sym.split("/", 1) + quote_clean = quote.split(":")[0] + return f"{base}/{quote_clean}:{quote_clean}" + return sym + + +def resolve_monitor_exchange_symbol(row): + """将监控行上的 symbol / exchange_symbol 统一到 ccxt 永续合约 symbol,便于与 fetch_positions 结果比对。""" + raw = "" + try: + if row["exchange_symbol"]: + raw = str(row["exchange_symbol"]).strip() + except (KeyError, IndexError, TypeError): + raw = "" + if not raw: + try: + raw = str(row["symbol"] or "").strip() + except (KeyError, IndexError, TypeError): + raw = "" + return normalize_exchange_symbol(raw) if raw else "" + + +def _position_contract_symbol_match(position_symbol, wanted_exchange_symbol): + if not position_symbol or not wanted_exchange_symbol: + return False + a = normalize_exchange_symbol(str(position_symbol).strip()) + b = normalize_exchange_symbol(str(wanted_exchange_symbol).strip()) + return a == b + + +def _row_matches_monitor_direction(direction, position_dict): + """ + 判断持仓行是否属于当前监控方向。 + 币安双向持仓为 LONG/SHORT;单向持仓常为 BOTH,此时不能用 side!=direction 过滤, + 否则会把整行跳过(live 恒为 0),平仓数量错误甚至误判「无仓」。 + """ + if not position_dict: + return False + direction = (direction or "").strip().lower() + info = position_dict.get("info", {}) or {} + ps = str( + info.get("positionSide") + or position_dict.get("side") + or info.get("posSide") + or "" + ).strip().lower() + signed_amt = None + for key in ("positionAmt", "pos", "size"): + v = info.get(key) + if v is None or v == "": + continue + try: + signed_amt = float(v) + break + except (TypeError, ValueError): + continue + if BINANCE_POSITION_MODE != "hedge": + return True + if ps in ("long", "short"): + return ps == direction + if ps in ("both", "net") or ps == "": + if signed_amt is None: + return True + if direction == "long": + return signed_amt > 0 + if direction == "short": + return signed_amt < 0 + return False + if ps and ps != direction: + return False + return True + + +def _position_matches_wanted_contract(wanted_unified_sym, position_dict): + """统一 symbol 比对;不一致时用交易所原始合约代码与 ccxt market.id 对齐(兼容命名差异)。""" + if not wanted_unified_sym or not position_dict: + return False + ps = position_dict.get("symbol") + if _position_contract_symbol_match(ps, wanted_unified_sym): + return True + try: + ensure_markets_loaded() + mid = (exchange.market(wanted_unified_sym).get("id") or "").strip().upper() + info = position_dict.get("info") or {} + c_raw = str(info.get("contract") or info.get("symbol") or info.get("pair") or "").strip().upper() + if mid and c_raw and mid == c_raw: + return True + except Exception: + pass + return False + + +def _position_row_effective_contracts(p): + """持仓数量:优先 ccxt contracts,否则用交易所原始 positionAmt/size/pos(避免统一层为 0 时被误判空仓)。""" + if not p: + return 0.0 + info = p.get("info") or {} + for val in (p.get("contracts"), info.get("positionAmt"), info.get("size"), info.get("pos")): + if val is None or val == "": + continue + try: + x = abs(float(val)) + if x > 0: + return x + except (TypeError, ValueError): + continue + return 0.0 + + +def normalize_symbol_input(symbol): + sym = (symbol or "").strip().upper() + if not sym: + return "" + if "/" in sym: + return sym + if ":" in sym: + sym = sym.split(":")[0] + return f"{sym}/USDT" + + +def normalize_kline_limit(limit_raw, default=200): + try: + n = int(limit_raw) + except Exception: + return default + return 200 if n >= 200 else 100 + + +def get_recommended_capital(current_capital): + if current_capital <= DAILY_LOSS_CAPITAL: + return DAILY_LOSS_CAPITAL + if current_capital >= DAILY_PROFIT_CAPITAL: + return DAILY_PROFIT_CAPITAL + return DAILY_START_CAPITAL + + +def ensure_session(conn, session_date): + row = conn.execute( + "SELECT * FROM trading_sessions WHERE session_date = ?", + (session_date,) + ).fetchone() + if row: + return row + conn.execute( + "INSERT INTO trading_sessions (session_date, start_capital, current_capital) VALUES (?,?,?)", + (session_date, DAILY_START_CAPITAL, DAILY_START_CAPITAL) + ) + conn.commit() + return conn.execute( + "SELECT * FROM trading_sessions WHERE session_date = ?", + (session_date,) + ).fetchone() + + +def update_session_capital(conn, session_date, pnl_amount): + session_row = ensure_session(conn, session_date) + new_capital = float(session_row["current_capital"]) + float(pnl_amount) + conn.execute( + "UPDATE trading_sessions SET current_capital = ?, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?", + (round(new_capital, FUNDS_DECIMALS), session_date) + ) + conn.commit() + return round(new_capital, FUNDS_DECIMALS) + + +def calc_hold_seconds(opened_at_str, closed_at_dt): + try: + opened_at = datetime.strptime(opened_at_str, "%Y-%m-%d %H:%M:%S") + return int((closed_at_dt - opened_at).total_seconds()) + except Exception: + return 0 + + +def calc_hold_minutes(seconds): + if not seconds or seconds <= 0: + return 0 + return max(1, int(seconds // 60)) + + +def get_opened_at_value(row): + try: + keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + keys = [] + if "opened_at" in keys: + value = row["opened_at"] + if value: + return value + return app_now_str() + + +def get_effective_trade_field(row, reviewed_key, base_key, default=None): + try: + keys = row.keys() if hasattr(row, "keys") else row.keys() + except Exception: + keys = [] + if reviewed_key in keys: + v = row[reviewed_key] + if v is not None and str(v).strip() != "": + return v + if base_key in keys: + v = row[base_key] + if v is not None and str(v).strip() != "": + return v + return default + + +def to_effective_trade_dict(row): + item = row_to_dict(row) + base_stop = item.get("initial_stop_loss") if item.get("initial_stop_loss") not in (None, "") else item.get("stop_loss") + item["effective_opened_at"] = get_effective_trade_field(row, "reviewed_opened_at", "opened_at", item.get("opened_at")) + item["effective_closed_at"] = get_effective_trade_field(row, "reviewed_closed_at", "closed_at", item.get("closed_at")) + open_stop = item.get("initial_stop_loss") + if open_stop in (None, ""): + open_stop = base_stop + item["display_open_stop_loss"] = open_stop + item["effective_stop_loss"] = get_effective_trade_field(row, "reviewed_stop_loss", "stop_loss", open_stop) + item["effective_take_profit"] = get_effective_trade_field(row, "reviewed_take_profit", "take_profit", item.get("take_profit")) + item["effective_result"] = get_effective_trade_field(row, "reviewed_result", "result", item.get("result")) + item["effective_miss_reason"] = get_effective_trade_field(row, "reviewed_miss_reason", "miss_reason", item.get("miss_reason")) + item["effective_pnl_amount"] = get_effective_trade_field(row, "reviewed_pnl_amount", "pnl_amount", item.get("pnl_amount")) + item["effective_hold_minutes"] = get_effective_trade_field(row, "reviewed_hold_minutes", "hold_minutes", item.get("hold_minutes")) + item["effective_hold_seconds"] = get_effective_trade_field(row, "reviewed_hold_seconds", "hold_seconds", item.get("hold_seconds")) + er_eff = get_effective_trade_field(row, "reviewed_entry_reason", "entry_reason", item.get("entry_reason")) + item["effective_entry_reason"] = (str(er_eff).strip() if er_eff is not None else "") or "" + try: + _keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + _keys = [] + _reviewed_pnl_raw = row["reviewed_pnl_amount"] if "reviewed_pnl_amount" in _keys else None + has_reviewed_pnl = _reviewed_pnl_raw is not None and str(_reviewed_pnl_raw).strip() != "" + ex_pnl = item.get("exchange_realized_pnl") + if not has_reviewed_pnl and ex_pnl is not None and str(ex_pnl).strip() != "": + try: + item["effective_pnl_amount"] = round(float(ex_pnl), FUNDS_DECIMALS) + item["display_pnl_source"] = "exchange" + ex_open = (str(item.get("exchange_opened_at") or "").strip() or None) + ex_close = (str(item.get("exchange_closed_at") or "").strip() or None) + if ex_open: + item["effective_opened_at"] = ex_open + if ex_close: + item["effective_closed_at"] = ex_close + except (TypeError, ValueError): + item["display_pnl_source"] = "local" + elif has_reviewed_pnl: + item["display_pnl_source"] = "reviewed" + else: + item["display_pnl_source"] = "local" + return item + + +# USDT 等资金类:展示与入库舍入统一为 2 位小数(与交易所常见口径一致) +FUNDS_DECIMALS = 2 + + +def format_funds_u(value): + if value in (None, ""): + return "-" + try: + return f"{float(value):.{FUNDS_DECIMALS}f}" + except (TypeError, ValueError): + return str(value) + + +def round_funds(value): + try: + return round(float(value), FUNDS_DECIMALS) + except (TypeError, ValueError): + return None + + +def _ccxt_swap_symbol_for_precision(symbol): + """解析为 ccxt markets 中的永续 symbol,供 price_to_precision 使用。""" + raw = (symbol or "").strip() + if not raw: + return None + try: + ensure_markets_loaded() + markets = getattr(exchange, "markets", {}) or {} + except Exception: + return None + upper = raw.upper().replace(" ", "") + candidates = [] + candidates.append(normalize_exchange_symbol(raw)) + if upper.endswith("USDT") and len(upper) > 4 and "/" not in raw and ":" not in raw: + candidates.append(f"{upper[:-4]}/USDT:USDT") + if "/" not in raw and ":" not in raw and upper.isalnum() and not upper.endswith("USDT"): + candidates.append(f"{upper}/USDT:USDT") + for c in candidates: + if c and c in markets: + return c + return None + + +def format_price_for_symbol(symbol, value): + if value in (None, ""): + return "-" + try: + v = float(value) + except (TypeError, ValueError): + return str(value) + if v == 0: + return "0" + try: + ex_sym = _ccxt_swap_symbol_for_precision(symbol) + if ex_sym: + return str(exchange.price_to_precision(ex_sym, v)) + except Exception: + pass + av = abs(v) + # 无法加载市场或无该合约时:按价格量级回退(尽量不阻断页面) + if av >= 10000: + d = 2 + elif av >= 100: + d = 3 + elif av >= 1: + d = 4 + elif av >= 0.01: + d = 6 + elif av >= 0.0001: + d = 8 + else: + d = 10 + text = f"{v:.{d}f}" + return text.rstrip("0").rstrip(".") if "." in text else text + + +def round_price_to_exchange(exchange_symbol, price): + """将价格按 U 本位永续 tick 取整;失败返回 None。""" + if price is None: + return None + try: + ensure_markets_loaded() + sym = normalize_exchange_symbol(exchange_symbol) + return float(exchange.price_to_precision(sym, float(price))) + except Exception: + return None + + +def format_hold_minutes(minutes): + if not minutes: + return "0分钟" + total = int(minutes) + hours = total // 60 + mins = total % 60 + if hours: + return f"{hours}小时{mins}分钟" + return f"{mins}分钟" + + +def calc_pnl(direction, trigger_price, exit_price, margin_capital, leverage, notional_usdt=None): + """估算盈亏(USDT)。优先用名义价值 notional_usdt,否则 margin×leverage。""" + try: + trigger = float(trigger_price) + exit_p = float(exit_price) + if trigger <= 0: + return 0.0 + if notional_usdt is not None: + notional = float(notional_usdt) + else: + margin = float(margin_capital) + lev = float(leverage) + notional = margin * lev + if notional <= 0: + return 0.0 + if direction == "short": + pnl_ratio = (trigger - exit_p) / trigger + else: + pnl_ratio = (exit_p - trigger) / trigger + return round(notional * pnl_ratio, FUNDS_DECIMALS) + except Exception: + return 0.0 + + +def get_plan_notional_usdt(row_or_dict): + """计划名义价值(USDT),与开仓 sizing 口径一致。""" + if row_or_dict is None: + return None + try: + if hasattr(row_or_dict, "keys"): + nv = row_or_dict["notional_value"] if "notional_value" in row_or_dict.keys() else None + margin = row_or_dict["margin_capital"] if "margin_capital" in row_or_dict.keys() else None + lev = row_or_dict["leverage"] if "leverage" in row_or_dict.keys() else None + sym = row_or_dict["symbol"] if "symbol" in row_or_dict.keys() else "" + else: + nv = row_or_dict.get("notional_value") + margin = row_or_dict.get("margin_capital") + lev = row_or_dict.get("leverage") + sym = row_or_dict.get("symbol") or "" + except Exception: + return None + try: + if nv is not None and str(nv).strip() != "": + v = float(nv) + if v > 0: + return round(v, FUNDS_DECIMALS) + except (TypeError, ValueError): + pass + try: + margin = float(margin or 0) + lev = float(lev or infer_leverage(sym) or 0) + if margin > 0 and lev > 0: + return round(margin * lev, FUNDS_DECIMALS) + except (TypeError, ValueError): + pass + return None + + +def _trade_ids_from_fills(trades): + """仅使用 Binance 原始 tradeId(与 income 流水一致),不用 ccxt 的 id。""" + ids = set() + for t in trades or []: + info = t.get("info") if isinstance(t.get("info"), dict) else {} + for k in ("tradeId", "trade_id"): + v = info.get(k) + if v is not None and str(v).strip() != "": + ids.add(str(v).strip()) + return ids + + +def _cluster_closing_trades_near_close(trades, closed_ms, spread_ms=8 * 60 * 1000): + """只保留平仓时刻附近的一簇减仓成交,避免把相邻其它仓位算进来。""" + if not trades: + return [] + if closed_ms is None: + return list(trades) + try: + closed_ms = int(closed_ms) + except (TypeError, ValueError): + return list(trades) + scored = [] + for t in trades: + ts = _coerce_ts_ms(t.get("timestamp")) + if ts is None: + continue + scored.append((abs(ts - closed_ms), t)) + if not scored: + return list(trades) + scored.sort(key=lambda x: x[0]) + anchor_ts = _coerce_ts_ms(scored[0][1].get("timestamp")) + if anchor_ts is None: + return [scored[0][1]] + return [ + t + for t in trades + if _coerce_ts_ms(t.get("timestamp")) is not None + and abs(_coerce_ts_ms(t.get("timestamp")) - anchor_ts) <= spread_ms + ] + + +def _income_entry_trade_id(entry): + if not isinstance(entry, dict): + return "" + info = entry.get("info") if isinstance(entry.get("info"), dict) else {} + for src in (entry, info): + for k in ("tradeId", "trade_id"): + v = src.get(k) + if v is not None and str(v).strip() != "": + return str(v).strip() + return "" + + +def calc_binance_realized_pnl_from_trades(trades): + """仅汇总成交回报中的 realizedPnl(勿再扣 commission,避免与 income 重复)。""" + if not trades: + return None + total = 0.0 + has = False + for t in trades: + info = t.get("info") if isinstance(t.get("info"), dict) else {} + v = info.get("realizedPnl") + if v is None or str(v).strip() == "": + v = t.get("realizedPnl") or t.get("realized_pnl") + if v is None or str(v).strip() == "": + continue + try: + total += float(v) + has = True + except (TypeError, ValueError): + pass + if not has: + return None + return round(total, FUNDS_DECIMALS) + + +def _sum_binance_income(entries, income_types, trade_ids=None): + net = 0.0 + first_t = None + last_t = None + strict = bool(trade_ids) + for e in entries: + it = (e.get("incomeType") or e.get("income_type") or "").strip() + if it not in income_types: + continue + if strict: + if it in ("REALIZED_PNL", "COMMISSION"): + tid = _income_entry_trade_id(e) + if not tid or tid not in trade_ids: + continue + else: + continue + elif trade_ids and it in ("REALIZED_PNL", "COMMISSION"): + tid = _income_entry_trade_id(e) + if tid and tid not in trade_ids: + continue + try: + net += float(e.get("income") or 0) + except (TypeError, ValueError): + pass + t = _coerce_ts_ms(e.get("time")) + if t: + first_t = t if first_t is None else min(first_t, t) + last_t = t if last_t is None else max(last_t, t) + if first_t is None: + return None, None, None + return round(net, FUNDS_DECIMALS), first_t, last_t + + +def calc_pnl_from_closing_trades(direction, entry_price, trades, exchange_symbol=None): + """按减仓成交数量×价差汇总盈亏(不含资金费;比单点标记价更接近交易所)。""" + try: + entry = float(entry_price) + except (TypeError, ValueError): + return None + if entry <= 0 or not trades: + return None + contract_size = 1.0 + if exchange_symbol and BINANCE_API_KEY and BINANCE_API_SECRET: + try: + ensure_markets_loaded() + contract_size = float(exchange.market(exchange_symbol).get("contractSize") or 1) + except Exception: + contract_size = 1.0 + pnl = 0.0 + qty = 0.0 + for t in trades: + try: + price = float(t.get("price") or 0) + amount = float(t.get("amount") or 0) * contract_size + except (TypeError, ValueError): + continue + if price <= 0 or amount <= 0: + continue + qty += amount + if direction == "short": + pnl += amount * (entry - price) + else: + pnl += amount * (price - entry) + if qty <= 0: + return None + return round(pnl, FUNDS_DECIMALS) + + +def resolve_trade_pnl_amount( + row, + entry_price, + exit_price=None, + opened_at_str=None, + opened_at_ms=None, + closed_at_str=None, + closed_at_ms=None, +): + """ + 平仓盈亏:优先 Binance income 净额(含手续费),其次按减仓成交汇总,最后用计划名义×涨跌。 + 返回 (pnl, exit_price, exchange_opened_at, exchange_closed_at, exchange_sync_key)。 + """ + direction = (row["direction"] if hasattr(row, "keys") else row.get("direction") or "long").strip().lower() + sym = row["symbol"] if hasattr(row, "keys") else row.get("symbol") + ex_sym = ( + row["exchange_symbol"] + if hasattr(row, "keys") and "exchange_symbol" in row.keys() + else row.get("exchange_symbol") + ) or normalize_exchange_symbol(sym) + open_ms = _to_ms_with_fallback( + opened_at_ms if opened_at_ms is not None else (row["opened_at_ms"] if hasattr(row, "keys") and "opened_at_ms" in row.keys() else None), + opened_at_str or (row["opened_at"] if hasattr(row, "keys") else row.get("opened_at")), + ) + close_ms = _to_ms_with_fallback( + closed_at_ms, + closed_at_str, + ) + closing_trades = [] + if open_ms and (close_ms or closed_at_str): + closing_trades = fetch_closing_fills_for_record( + ex_sym, + direction, + opened_at_str or (row["opened_at"] if hasattr(row, "keys") else ""), + closed_at_str, + opened_at_ms=open_ms, + closed_at_ms=close_ms, + ) + if closing_trades and close_ms: + closing_trades = _cluster_closing_trades_near_close(closing_trades, int(close_ms)) + if closing_trades: + wexit = calc_weighted_exit_price(closing_trades) + if wexit and (exit_price is None or float(exit_price or 0) <= 0): + exit_price = wexit + last_ts = closing_trades[-1].get("timestamp") + if last_ts and not closed_at_str: + closed_at_str = ms_to_app_local_str(int(last_ts)) + close_ms = int(last_ts) + net, sync_key, eo, ec = fetch_binance_net_pnl_for_trade( + ex_sym, direction, open_ms, close_ms, closing_trades=closing_trades + ) + if net is not None: + return net, exit_price, eo, ec, sync_key + if closing_trades: + trade_pnl = calc_binance_realized_pnl_from_trades(closing_trades) + if trade_pnl is not None: + return trade_pnl, exit_price, None, None, None + fill_pnl = calc_pnl_from_closing_trades(direction, entry_price, closing_trades, ex_sym) + if fill_pnl is not None: + return fill_pnl, exit_price, None, None, None + notional = get_plan_notional_usdt(row) + margin = row["margin_capital"] if hasattr(row, "keys") else row.get("margin_capital") + lev = row["leverage"] if hasattr(row, "keys") else row.get("leverage") + if exit_price: + pnl = calc_pnl( + direction, + entry_price, + exit_price, + margin or DAILY_START_CAPITAL, + lev or infer_leverage(sym), + notional_usdt=notional, + ) + return pnl, exit_price, None, None, None + return 0.0, exit_price, None, None, None + + +def calc_rr_ratio(direction, entry_price, stop_loss, take_profit): + try: + entry = float(entry_price) + sl = float(stop_loss) + tp = float(take_profit) + if entry <= 0 or sl <= 0 or tp <= 0: + return None + if direction == "short": + risk = sl - entry + reward = entry - tp + else: + risk = entry - sl + reward = tp - entry + if risk <= 0 or reward <= 0: + return None + return round(reward / risk, 4) + except Exception: + return None + + +def calc_risk_fraction(direction, entry_price, stop_loss): + try: + entry = float(entry_price) + sl = float(stop_loss) + if entry <= 0 or sl <= 0: + return None + if direction == "short": + risk = sl - entry + else: + risk = entry - sl + if risk <= 0: + return None + return risk / entry + except Exception: + return None + + +def calc_risk_amount_from_plan(direction, entry_price, stop_loss, margin_capital, leverage): + rf = calc_risk_fraction(direction, entry_price, stop_loss) + if rf is None: + return None + try: + notional = float(margin_capital) * float(leverage) + if notional <= 0: + return None + return round(notional * rf, FUNDS_DECIMALS) + except Exception: + return None + + +def calc_actual_rr(pnl_amount, risk_amount): + try: + r = float(risk_amount or 0) + if r <= 0: + return None + return round(float(pnl_amount or 0) / r, 4) + except Exception: + return None + + +def normalize_result_with_pnl(result, pnl_amount): + """ + 触发“止损”但实际已盈利时,归类为保本止盈,避免语义混淆。 + """ + if result == "止损": + try: + if float(pnl_amount or 0) > 0: + return "保本止盈" + except Exception: + pass + return result + + +def calc_breakeven_stop(direction, entry_price, risk_fraction, locked_r, offset_pct): + """ + 按“已锁定R”计算目标止损位: + - long: entry + locked_r * (entry*risk_fraction) + offset + - short: entry - locked_r * (entry*risk_fraction) - offset + """ + try: + entry = float(entry_price) + rf = float(risk_fraction) + lr = float(locked_r) + off = float(offset_pct) / 100.0 + if entry <= 0 or rf <= 0 or lr < 0: + return None + base_move = entry * rf * lr + offset_move = entry * off + if direction == "short": + return round(entry - base_move - offset_move, 8) + return round(entry + base_move + offset_move, 8) + except Exception: + return None + + +def insert_trade_record( + conn, + symbol, + monitor_type, + direction, + trigger_price, + stop_loss, + initial_stop_loss=None, + take_profit=None, + margin_capital=None, + leverage=None, + pnl_amount=0, + hold_seconds=0, + trade_style=None, + risk_amount=None, + planned_rr=None, + actual_rr=None, + result="", + miss_reason=None, + opened_at=None, + opened_at_ms=None, + closed_at=None, + closed_at_ms=None, + exchange_trade_id=None, + key_signal_type=None, + entry_reason=None, +): + hold_minutes = calc_hold_minutes(hold_seconds) + open_ts = opened_at or app_now_str() + close_ts = closed_at or app_now_str() + open_ts_ms = _to_ms_with_fallback(opened_at_ms, open_ts) + close_ts_ms = _to_ms_with_fallback(closed_at_ms, close_ts) + kst = key_signal_type_for_trade_record(key_signal_type, KEY_MONITOR_AUTO_TYPES) + snap_sl = initial_stop_loss if initial_stop_loss not in (None, "") else stop_loss + er = (entry_reason or "").strip() or entry_reason_from_key_signal(kst) or "" + cur = conn.execute( + "INSERT INTO trade_records (symbol,monitor_type,key_signal_type,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage,pnl_amount,hold_seconds,trade_style,risk_amount,planned_rr,actual_rr,hold_minutes,opened_at,opened_at_ms,closed_at,closed_at_ms,result,miss_reason,exchange_trade_id,entry_reason) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, monitor_type, kst, direction, trigger_price, snap_sl, snap_sl, take_profit, + margin_capital, leverage, pnl_amount, hold_seconds, + trade_style, risk_amount, planned_rr, actual_rr, hold_minutes, + open_ts, open_ts_ms, close_ts, close_ts_ms, result, miss_reason, exchange_trade_id, er or None + ) + ) + return int(cur.lastrowid or 0) + + +def calc_duration_text(open_str, close_str): + try: + fmt = "%Y-%m-%dT%H:%M" + o = datetime.strptime(open_str, fmt) + c = datetime.strptime(close_str, fmt) + delta = c - o + seconds = int(delta.total_seconds()) + if seconds <= 0: + return "0分钟" + d = seconds // 86400 + h = (seconds % 86400) // 3600 + m = (seconds % 3600) // 60 + parts = [] + if d: + parts.append(f"{d}天") + if h: + parts.append(f"{h}小时") + if m or not parts: + parts.append(f"{m}分钟") + return " ".join(parts) + except Exception: + return "计算失败" + + +def row_to_dict(row): + return {k: row[k] for k in row.keys()} + + +def enrich_order_item(raw_item, current_capital): + item = dict(raw_item or {}) + margin = float(item.get("margin_capital") or 0) + lev = float(item.get("leverage") or 0) + notional = item.get("notional_value") + ratio = item.get("position_ratio") + if notional is None: + notional = round(margin * lev, FUNDS_DECIMALS) if margin and lev else 0 + if ratio is None: + ratio = round(margin / current_capital * 100, 2) if current_capital else 0 + item["notional_value"] = notional + item["position_ratio"] = ratio + item["rr_ratio"] = calc_rr_ratio( + item.get("direction") or "long", + item.get("trigger_price"), + item.get("initial_stop_loss") or item.get("stop_loss"), + item.get("take_profit"), + ) + try: + be = item.get("breakeven_enabled") + item["breakeven_enabled"] = 0 if be is not None and int(be) == 0 else 1 + except Exception: + item["breakeven_enabled"] = 1 + if not (item.get("monitor_type") or "").strip(): + item["monitor_type"] = ORDER_MONITOR_TYPE_MANUAL + return item + + +def ensure_exchange_live_ready(): + if not LIVE_TRADING_ENABLED: + return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)" + if not (BINANCE_API_KEY and BINANCE_API_SECRET): + return False, "缺少 Binance API 密钥配置(BINANCE_API_KEY / BINANCE_API_SECRET)" + return True, "" + + +def order_row_monitor_type(row): + if row is None: + return ORDER_MONITOR_TYPE_MANUAL + try: + keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + keys = [] + if "monitor_type" in keys: + mt = (row["monitor_type"] or "").strip() + if mt: + return mt + return ORDER_MONITOR_TYPE_MANUAL + + +def order_row_key_signal_type(row): + if row is None: + return None + try: + keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + keys = [] + if "key_signal_type" not in keys: + return None + kst = (row["key_signal_type"] or "").strip() + if kst in KEY_MONITOR_AUTO_TYPES or is_fib_key_monitor_type(kst): + return kst + return None + + +def exchange_private_api_configured(): + """仅表示已配置密钥;与是否允许下单(LIVE_TRADING_ENABLED)无关,用于只读拉仓等。""" + return bool(BINANCE_API_KEY and BINANCE_API_SECRET) + + +def _extract_usdt_total(balance): + usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {} + total_map = balance.get("total", {}) if isinstance(balance, dict) else {} + free_map = balance.get("free", {}) if isinstance(balance, dict) else {} + total = usdt_info.get("total") + if total is None: + total = usdt_info.get("equity") + if total is None: + total = total_map.get("USDT") + if total is None: + total = usdt_info.get("free") + if total is None: + total = free_map.get("USDT") + try: + return float(total) if total is not None else None + except Exception: + return None + + +def _extract_usdt_free(balance): + usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {} + free_map = balance.get("free", {}) if isinstance(balance, dict) else {} + free = usdt_info.get("free") + if free is None: + free = free_map.get("USDT") + try: + return float(free) if free is not None else None + except Exception: + return None + + +def _binance_futures_usdt_asset_row(balance): + """从 U 本位合约 fetch_balance 的 info.assets 中取 USDT 一行(与币安后台口径一致)。""" + if not isinstance(balance, dict): + return None + info = balance.get("info") + if not isinstance(info, dict): + return None + assets = info.get("assets") + if not isinstance(assets, list): + return None + for a in assets: + if isinstance(a, dict) and str(a.get("asset") or "").upper() == "USDT": + return a + return None + + +def _fetch_binance_swap_usdt_total(): + """仅 U 本位永续合约账户 USDT(总额口径:优先 marginBalance / walletBalance,不回退现货)。""" + try: + ensure_markets_loaded() + bal = exchange.fetch_balance(params={"type": "swap"}) + row = _binance_futures_usdt_asset_row(bal) + if row: + for k in ("marginBalance", "walletBalance", "crossWalletBalance", "balance"): + x = row.get(k) + if x is not None and str(x).strip() != "": + try: + fv = float(x) + if fv >= 0: + return fv + except (TypeError, ValueError): + pass + v = _extract_usdt_total(bal) + return float(v) if v is not None else None + except Exception: + return None + + +def _fetch_binance_swap_usdt_free(): + """U 本位合约账户 USDT 可用(开仓可用保证金口径,不回退现货)。""" + try: + ensure_markets_loaded() + bal = exchange.fetch_balance(params={"type": "swap"}) + row = _binance_futures_usdt_asset_row(bal) + if row: + for k in ("availableBalance", "maxWithdrawAmount"): + x = row.get(k) + if x is not None and str(x).strip() != "": + try: + fv = float(x) + if fv >= 0: + return fv + except (TypeError, ValueError): + pass + return _extract_usdt_free(bal) + except Exception: + return None + + +def _fetch_binance_funding_usdt(): + """Binance 资金账户(Funding Wallet)USDT 总额。""" + try: + ensure_markets_loaded() + bal = exchange.fetch_balance(params={"type": "funding"}) + val = _extract_usdt_total(bal) + if val is not None: + return float(val) + except Exception: + pass + return None + + +def get_available_trading_usdt(): + ok_live, _ = ensure_exchange_live_ready() + if not ok_live: + return None + return _fetch_binance_swap_usdt_free() + + +def get_synced_leverage(exchange_symbol, direction): + ensure_markets_loaded() + try: + positions = exchange.fetch_positions([exchange_symbol]) + for p in positions: + if not _position_matches_wanted_contract(exchange_symbol, p): + continue + if not _row_matches_monitor_direction(direction, p): + continue + info = p.get("info", {}) or {} + if lev is None or lev == 0 or str(lev) == "0": + lev = info.get("cross_leverage_limit") or info.get("leverage") + if lev: + try: + return int(float(lev)) + except Exception: + pass + except Exception: + pass + return None + + +def friendly_exchange_error(err, available_usdt=None): + msg = str(err) + low = msg.lower() + if ( + "51008" in msg + or "insufficient" in low + or "margin" in low and ("not enough" in low or "不足" in msg) + or "balance" in low and "insufficient" in low + ): + tail = f"(当前交易账户可用约 {round(available_usdt, FUNDS_DECIMALS)}U)" if available_usdt is not None else "" + return f"交易所下单失败:保证金不足 {tail}。请降低保证金/杠杆,或先划转USDT到合约账户。" + clean = re.sub(r"\s+", " ", msg).strip() + return f"交易所下单失败:{clean}" + + +def get_exchange_capitals(force=False): + ok_live, _ = ensure_exchange_live_ready() + if not ok_live: + return None, None + now_ts = time.time() + if (not force) and ACCOUNT_BALANCE_CACHE["updated_at"] and now_ts - ACCOUNT_BALANCE_CACHE["updated_at"] < BALANCE_REFRESH_SECONDS: + return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"] + try: + ACCOUNT_BALANCE_CACHE["funding_usdt"] = _fetch_binance_funding_usdt() + except Exception: + ACCOUNT_BALANCE_CACHE["funding_usdt"] = None + try: + ACCOUNT_BALANCE_CACHE["trading_usdt"] = _fetch_binance_swap_usdt_total() + except Exception: + # 勿保留上一次成功请求的旧值:鉴权失败时否则会误以为「合约余额仍能读」 + ACCOUNT_BALANCE_CACHE["trading_usdt"] = None + ACCOUNT_BALANCE_CACHE["updated_at"] = now_ts + return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"] + + +def execute_transfer_usdt(amount, from_account, to_account): + if amount <= 0: + return False, "划转金额必须大于0", None + ok_live, reason = ensure_exchange_live_ready() + if not ok_live: + return False, reason, None + try: + resp = exchange.transfer(TRANSFER_CCY, float(amount), from_account, to_account) + return True, "划转成功", resp + except Exception as e: + msg = str(e) + if "INVALID_KEY" in msg or "Invalid key" in msg or "-2015" in msg: + msg += ( + "。常见原因:① BINANCE_API_SECRET 错误或 .env 里多了空格/换行;② IP 白名单未包含当前服务器出口 IP;" + "③ API Key 未勾选「允许合约」「允许万向划转」等所需权限;④ Key 已重置或权限变更。" + ) + return False, msg, None + + +def get_account_usdt_total(account_type): + """读取各账户 USDT。funding 走资金钱包;swap 仅合约账户;spot 仅现货。""" + raw = (account_type or "").strip().lower() + if raw == "funding": + return _fetch_binance_funding_usdt() + if raw == "swap": + return _fetch_binance_swap_usdt_total() + try: + ensure_markets_loaded() + bal = exchange.fetch_balance(params={"type": raw}) + val = _extract_usdt_total(bal) + if val is not None: + return val + return 0.0 if raw == "spot" else None + except Exception: + return None + + +def auto_transfer_once_per_day(): + if not AUTO_TRANSFER_ENABLED: + return + utc_dt = utc_now_dt() + bj = utc_dt.astimezone(APP_TZ) + if bj.hour != AUTO_TRANSFER_BJ_HOUR: + return + transfer_day = utc_calendar_date_str() + conn = get_db() + exists = conn.execute( + "SELECT id FROM transfer_logs WHERE transfer_type=? AND transfer_day=?", + ("auto_daily", transfer_day) + ).fetchone() + if exists: + conn.close() + return + target_amount = AUTO_TRANSFER_AMOUNT + to_balance = get_account_usdt_total(AUTO_TRANSFER_TO) + from_balance = get_account_usdt_total(AUTO_TRANSFER_FROM) + if to_balance is None: + conn.execute( + "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", + ("auto_daily", transfer_day, 0, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO, "failed", f"读取{AUTO_TRANSFER_TO}账户USDT失败") + ) + conn.commit() + conn.close() + return + needed = round(max(target_amount - float(to_balance), 0), FUNDS_DECIMALS) + if needed <= 0: + conn.execute( + "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", + ("auto_daily", transfer_day, 0, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO, "skipped", f"{AUTO_TRANSFER_TO}账户已达到目标{target_amount}U") + ) + conn.commit() + conn.close() + return + if from_balance is not None and from_balance < needed: + conn.execute( + "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", + ("auto_daily", transfer_day, needed, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO, "failed", f"{AUTO_TRANSFER_FROM}账户USDT不足,需{needed}U,当前{round(from_balance, FUNDS_DECIMALS)}U") + ) + conn.commit() + conn.close() + send_wechat_msg( + f"自动划转失败:{AUTO_TRANSFER_FROM}余额不足,需{needed}U,当前{round(from_balance, FUNDS_DECIMALS)}U\n" + f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" + ) + return + + ok, msg, _ = execute_transfer_usdt(needed, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO) + conn.execute( + "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", + ("auto_daily", transfer_day, needed, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO, "success" if ok else "failed", msg[:500]) + ) + conn.commit() + conn.close() + if ok: + send_wechat_msg( + f"自动划转成功:补足到{target_amount}U,实际划转{needed}U " + f"{AUTO_TRANSFER_FROM}->{AUTO_TRANSFER_TO}\n" + f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" + ) + else: + send_wechat_msg( + f"自动划转失败:计划补足到{target_amount}U,需划转{needed}U\n原因:{msg}\n" + f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" + ) + + +def trading_day_reset_allows_new_open(now): + """是否允许在满足其它风控的前提下于当前时刻新开仓(仅「整点前禁开」守卫)。""" + if not TRADING_DAY_RESET_OPEN_GUARD_ENABLED: + return True + return now.hour >= TRADING_DAY_RESET_HOUR + + +def get_active_position_count(conn): + return int(conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0]) + + +def clear_key_sizing_snapshot_if_flat(conn, session_date): + if get_active_position_count(conn) > 0: + return + conn.execute( + "UPDATE trading_sessions SET key_sizing_capital_snapshot = NULL, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?", + (session_date,), + ) + conn.commit() + + +def get_key_sizing_capital_snapshot(conn, session_date): + row = ensure_session(conn, session_date) + try: + val = row["key_sizing_capital_snapshot"] + except (KeyError, IndexError): + return None + if val is None: + return None + try: + return float(val) + except (TypeError, ValueError): + return None + + +def set_key_sizing_capital_snapshot(conn, session_date, capital): + ensure_session(conn, session_date) + conn.execute( + "UPDATE trading_sessions SET key_sizing_capital_snapshot = ?, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?", + (round(float(capital), FUNDS_DECIMALS), session_date), + ) + conn.commit() + + +def resolve_capital_base_for_key_open(conn, trading_day, live_capital): + """关键位自动开仓:有仓时用无仓时资金快照计仓(可配置)。""" + live = float(live_capital) + active = get_active_position_count(conn) + if active <= 0: + set_key_sizing_capital_snapshot(conn, trading_day, live) + return live + if KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT: + snap = get_key_sizing_capital_snapshot(conn, trading_day) + if snap is not None and snap > 0: + return snap + return live + + +def precheck_risk(conn, symbol, direction): + now = app_now() + if not trading_day_reset_allows_new_open(now): + return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓" + active_count = get_active_position_count(conn) + if active_count >= MAX_ACTIVE_POSITIONS: + return False, f"已达最大持仓数({active_count}/{MAX_ACTIVE_POSITIONS})" + if direction not in ("long", "short"): + return False, "方向必须为 long 或 short" + if symbol.upper().startswith("BTC") or symbol.upper().startswith("ETH"): + expected = BTC_LEVERAGE + else: + expected = ALT_LEVERAGE + if expected <= 0: + return False, "杠杆配置异常" + return True, "" + + +def prepare_order_amount(exchange_symbol, margin_capital, leverage, fallback_price): + ensure_markets_loaded() + notional = float(margin_capital) * float(leverage) + ticker = exchange.fetch_ticker(exchange_symbol) + price = float(ticker.get("last") or fallback_price) + if price <= 0: + raise ValueError("触发价必须大于 0") + market = exchange.market(exchange_symbol) + contract_size = float(market.get("contractSize") or 1) + if market.get("contract"): + # 合约 amount 按张数/合约乘数解析;ccxt 会再做精度与符号处理 + amount = notional / (price * contract_size) + else: + amount = notional / price + min_amount = (market.get("limits", {}).get("amount", {}) or {}).get("min") + if min_amount and amount < float(min_amount): + raise ValueError(f"下单数量过小,最小数量为 {min_amount}") + amount_precise = float(exchange.amount_to_precision(exchange_symbol, amount)) + if amount_precise <= 0: + raise ValueError("下单数量精度后为 0,请提高基数或降低价格") + return amount_precise, price + + +def _to_positive_float(value): + try: + n = float(value) + return n if n > 0 else None + except Exception: + return None + + +def _extract_order_price_value(order_obj): + if not isinstance(order_obj, dict): + return None + for key in ("average", "price"): + v = _to_positive_float(order_obj.get(key)) + if v is not None: + return v + cost = _to_positive_float(order_obj.get("cost")) + filled = _to_positive_float(order_obj.get("filled")) + if cost is not None and filled is not None and filled > 0: + return cost / filled + info = order_obj.get("info") if isinstance(order_obj.get("info"), dict) else {} + for key in ("avgPx", "fillPx", "avgPrice", "fillPrice", "px"): + v = _to_positive_float(info.get(key)) + if v is not None: + return v + return None + + +def resolve_order_entry_price(order_resp, exchange_symbol, fallback_price): + price = _extract_order_price_value(order_resp) + if price is not None: + return round(price, 8) + order_id = (order_resp or {}).get("id") + if order_id: + try: + fetched = exchange.fetch_order(order_id, exchange_symbol) + fetched_price = _extract_order_price_value(fetched) + if fetched_price is not None: + return round(fetched_price, 8) + except Exception: + pass + fallback = _to_positive_float(fallback_price) + return round(fallback, 8) if fallback is not None else 0.0 + + +def get_contract_size(exchange_symbol): + ensure_markets_loaded() + market = exchange.market(exchange_symbol) + return float(market.get("contractSize") or 1) + + +def parse_positive_float(value): + if value is None: + return None + raw = str(value).strip() + if not raw: + return None + num = float(raw) + if num <= 0: + raise ValueError("数值必须大于0") + return num + + +def build_binance_order_params(direction, reduce_only=False): + params = {} + if BINANCE_POSITION_MODE == "hedge": + params["positionSide"] = "LONG" if direction == "long" else "SHORT" + if reduce_only: + params["reduceOnly"] = True + return params + + +def _binance_market_close_param_candidates(direction): + """ + 平仓市价单参数组合(按顺序尝试)。 + 部分币安 U 本位账户对市价减仓报 -1106「reduceOnly sent when not required」, + 与条件单一致,需再试不带 reduceOnly 的写法;另保留双向/单向 positionSide 切换。 + """ + ps = "LONG" if direction == "long" else "SHORT" + hedge_ro = {"positionSide": ps, "reduceOnly": True} + hedge_plain = {"positionSide": ps} + oneway_ro = {"reduceOnly": True} + oneway_plain = {} + if BINANCE_POSITION_MODE == "hedge": + return [hedge_ro, hedge_plain, oneway_ro, oneway_plain] + return [oneway_ro, oneway_plain, hedge_ro, hedge_plain] + + +def _is_binance_close_param_retryable(err_msg): + s = (err_msg or "").lower() + if "-4061" in s: + return True + if "-1106" in s and ("reduceonly" in s or "reduce only" in s): + return True + if "position side" in s or "positionside" in s: + return True + if "dual side" in s or "position mode" in s: + return True + return False + + +def _filled_amount_for_tpsl(order, fallback_amount): + for key in ("filled", "amount"): + v = order.get(key) + try: + fv = float(v) + if fv > 0: + return fv + except Exception: + pass + return float(fallback_amount) + + +def _binance_trigger_order_params(): + p = {} + if BINANCE_TRIGGER_WORKING_TYPE: + p["workingType"] = BINANCE_TRIGGER_WORKING_TYPE + return p + + +def _binance_place_tp_sl_orders(exchange_symbol, direction, position_amount, stop_loss, take_profit): + """ + Binance USDT-M 永续:市价开仓成交后,挂 STOP_MARKET(止损)与 TAKE_PROFIT_MARKET(止盈)。 + 双向持仓时带 positionSide。不显式传 reduceOnly(否则会报 -1106 Parameter 'reduceOnly' sent when not required)。 + """ + ensure_markets_loaded() + market = exchange.market(exchange_symbol) + if not market.get("swap"): + raise RuntimeError("仅支持永续合约 symbol") + close_side = "sell" if direction == "long" else "buy" + amt = float(exchange.amount_to_precision(exchange_symbol, float(position_amount))) + if amt <= 0: + raise RuntimeError("止盈止损:可平数量经精度舍入后为 0") + sl_px = exchange.price_to_precision(exchange_symbol, float(stop_loss)) + tp_px = exchange.price_to_precision(exchange_symbol, float(take_profit)) + common = dict(_binance_trigger_order_params()) + if BINANCE_POSITION_MODE == "hedge": + common["positionSide"] = "LONG" if direction == "long" else "SHORT" + last_err = None + for attempt in range(8): + try: + exchange.create_order( + exchange_symbol, + "STOP_MARKET", + close_side, + amt, + None, + dict(common, stopPrice=sl_px), + ) + time.sleep(0.05) + exchange.create_order( + exchange_symbol, + "TAKE_PROFIT_MARKET", + close_side, + amt, + None, + dict(common, stopPrice=tp_px), + ) + return + except Exception as e: + last_err = e + try: + cancel_binance_futures_open_orders(exchange_symbol) + except Exception: + pass + time.sleep(0.2 * (attempt + 1)) + raise RuntimeError(f"Binance 未接受止盈/止损触发单:{last_err}") + + +def ensure_markets_loaded(force=False): + global MARKETS_LOADED + if force or not MARKETS_LOADED: + exchange.load_markets(reload=force) + MARKETS_LOADED = True + + +def place_exchange_order(exchange_symbol, direction, amount, leverage, stop_loss=None, take_profit=None): + ensure_markets_loaded() + mm = "cross" if BINANCE_MARGIN_MODE in ("cross", "cross_margin") else "isolated" + try: + exchange.set_margin_mode(mm, exchange_symbol) + except Exception: + pass + exchange.set_leverage(leverage, exchange_symbol) + side = "buy" if direction == "long" else "sell" + params = build_binance_order_params(direction, reduce_only=False) + order = exchange.create_order(exchange_symbol, "market", side, amount, None, params) + order.setdefault("tpsl_attached", False) + if stop_loss and take_profit: + try: + pos_amt = _filled_amount_for_tpsl(order, amount) + _binance_place_tp_sl_orders(exchange_symbol, direction, pos_amt, stop_loss, take_profit) + order["tpsl_attached"] = True + except RuntimeError: + raise + except Exception as e: + raise RuntimeError(f"交易所未接受条件止盈/止损委托,已拒绝开仓:{str(e)}") from e + return order + + +def close_exchange_order(order_row): + """ + 市价全平。数量优先取交易所当前持仓张数,避免仅用入库的 order_amount + 导致「只平一部分 → 撤单后委托没了但仓位还在」(加仓、精度或成交与计划不一致时常见)。 + """ + ensure_markets_loaded() + exchange_symbol = order_row["exchange_symbol"] or normalize_exchange_symbol(order_row["symbol"]) + direction = order_row["direction"] + db_amt = float(order_row["order_amount"] or 0) + side = "sell" if direction == "long" else "buy" + last_resp = None + for _ in range(3): + live = get_live_position_contracts(exchange_symbol, direction) + if live is not None and live > 0: + raw_amt = live + else: + raw_amt = db_amt + if raw_amt <= 0: + if last_resp is not None: + return last_resp + raise ValueError("平仓失败:缺少有效下单数量") + try: + amount = float(exchange.amount_to_precision(exchange_symbol, raw_amt)) + except Exception: + amount = float(raw_amt) + if amount <= 0: + if last_resp is not None: + return last_resp + raise ValueError("平仓失败:数量经精度舍入后为 0") + order_resp = None + last_close_err = None + for params in _binance_market_close_param_candidates(direction): + try: + order_resp = exchange.create_order(exchange_symbol, "market", side, amount, None, params) + last_close_err = None + break + except Exception as e: + last_close_err = e + if _is_binance_close_param_retryable(str(e)): + continue + raise + if order_resp is None: + raise last_close_err if last_close_err else RuntimeError("平仓失败:交易所未返回结果") + last_resp = order_resp + live_after = get_live_position_contracts(exchange_symbol, direction) + if live_after is None or live_after <= 0: + return last_resp + return last_resp + + +def cancel_binance_futures_open_orders(exchange_symbol): + """ + 平仓后撤销该合约下剩余挂单,避免孤儿单残留。 + Binance U 本位:普通挂单走 cancel_all_orders(DELETE allOpenOrders); + 止盈/止损等条件单在「Algo」通道,需再调 DELETE algoOpenOrders,否则手动平仓后仍会留在「当前委托」。 + """ + ok, _ = ensure_exchange_live_ready() + if not ok or not exchange_symbol: + return + ensure_markets_loaded() + sym = exchange_symbol + try: + exchange.cancel_all_orders(sym, params={}) + except Exception: + pass + try: + market = exchange.market(sym) + contract_id = market.get("id") + if contract_id and hasattr(exchange, "fapiPrivateDeleteAlgoOpenOrders"): + exchange.fapiPrivateDeleteAlgoOpenOrders({"symbol": contract_id}) + except Exception: + pass + try: + pending = exchange.fetch_open_orders(sym) + except Exception: + return + for o in pending or []: + oid = o.get("id") + if oid is None: + continue + try: + exchange.cancel_order(str(oid), sym) + except Exception: + pass + + +def _binance_list_raw_open_orders(exchange_symbol): + """普通挂单 + Algo 条件单(止盈/止损)。""" + ensure_markets_loaded() + market = exchange.market(exchange_symbol) + contract_id = market.get("id") + out = [] + try: + for o in exchange.fetch_open_orders(exchange_symbol) or []: + item = dict(o) + item["_channel"] = "regular" + out.append(item) + except Exception: + pass + try: + if contract_id and hasattr(exchange, "fapiPrivateGetOpenAlgoOrders"): + raw = exchange.fapiPrivateGetOpenAlgoOrders({"symbol": contract_id}) + items = raw if isinstance(raw, list) else (raw.get("orders") or raw.get("data") or []) + for info in items or []: + if not isinstance(info, dict): + continue + out.append( + { + "id": info.get("algoId") or info.get("orderId"), + "info": info, + "_channel": "algo", + "type": info.get("orderType") or info.get("type"), + "positionSide": info.get("positionSide"), + "stopPrice": info.get("triggerPrice") or info.get("stopPrice"), + "amount": info.get("quantity") or info.get("origQty"), + } + ) + except Exception: + pass + return out + + +def _binance_order_type_str(order): + info = order.get("info") or {} + if isinstance(info, dict): + for key in ("orderType", "type", "origType", "algoType"): + val = info.get(key) + if val: + return str(val).upper() + return str(order.get("type") or "").upper() + + +def _binance_order_matches_direction(order, direction): + if BINANCE_POSITION_MODE != "hedge": + return True + info = order.get("info") or {} + ps = str(order.get("positionSide") or info.get("positionSide") or "").upper() + want = "LONG" if direction == "long" else "SHORT" + if ps and ps not in ("", "BOTH") and ps != want: + return False + return True + + +def _binance_order_trigger_price(order): + for key in ("stopPrice", "triggerPrice", "activatePrice"): + try: + v = float(order.get(key) or 0) + if v > 0: + return v + except Exception: + pass + info = order.get("info") or {} + if isinstance(info, dict): + for key in ("triggerPrice", "stopPrice", "activatePrice"): + try: + v = float(info.get(key) or 0) + if v > 0: + return v + except Exception: + pass + return None + + +def _binance_tpsl_role_from_order(order): + typ = _binance_order_type_str(order) + if "TAKE_PROFIT" in typ: + return "tp" + if "STOP" in typ: + return "sl" + return None + + +def _binance_tpsl_slot_from_order(order, exchange_symbol): + trig = _binance_order_trigger_price(order) + try: + amt = float(order.get("amount") or order.get("remaining") or 0) + except Exception: + amt = None + if amt is not None and amt <= 0: + amt = None + channel = order.get("_channel") or "regular" + oid = order.get("id") + if oid is None and isinstance(order.get("info"), dict): + oid = order["info"].get("algoId") or order["info"].get("orderId") + disp = format_price_for_symbol(exchange_symbol, trig) if trig else "-" + return { + "order_id": str(oid) if oid is not None else "", + "channel": channel, + "trigger_price": trig, + "trigger_display": disp, + "amount": amt, + "type": _binance_order_type_str(order), + } + + +def fetch_exchange_tpsl_slots(exchange_symbol, direction): + """返回 { sl: slot|None, tp: slot|None },供页面展示与单笔撤单。""" + slots = {"sl": None, "tp": None} + if not exchange_symbol: + return slots + ok, _ = ensure_exchange_live_ready() + if not ok: + return slots + try: + for order in _binance_list_raw_open_orders(exchange_symbol): + if not _binance_order_matches_direction(order, direction): + continue + role = _binance_tpsl_role_from_order(order) + if role not in ("sl", "tp") or slots[role] is not None: + continue + slots[role] = _binance_tpsl_slot_from_order(order, exchange_symbol) + except Exception: + pass + return slots + + +def cancel_binance_tpsl_slot(exchange_symbol, slot): + if not slot or not exchange_symbol: + return + ensure_markets_loaded() + market = exchange.market(exchange_symbol) + contract_id = market.get("id") + oid = slot.get("order_id") + if not oid: + return + if slot.get("channel") == "algo" and contract_id and hasattr(exchange, "fapiPrivateDeleteAlgoOrder"): + exchange.fapiPrivateDeleteAlgoOrder({"symbol": contract_id, "algoId": oid}) + return + exchange.cancel_order(str(oid), exchange_symbol) + + +def _resolve_tpsl_prices_for_manual(direction, live_price, sltp_mode, data): + sltp_mode = (sltp_mode or "price").strip().lower() + if sltp_mode == "pct": + sl_pct = float(data.get("sl_pct") or 0) + tp_pct = float(data.get("tp_pct") or 0) + if sl_pct <= 0 or tp_pct <= 0: + raise ValueError("百分比止盈止损须为正数") + sl_ratio = sl_pct / 100.0 + tp_ratio = tp_pct / 100.0 + entry = float(live_price) + if direction == "short": + stop_loss = entry * (1 + sl_ratio) + take_profit = entry * (1 - tp_ratio) + else: + stop_loss = entry * (1 - sl_ratio) + take_profit = entry * (1 + tp_ratio) + else: + stop_loss = float(data.get("sl") or data.get("stop_loss") or 0) + take_profit = float(data.get("tp") or data.get("take_profit") or data.get("tgt") or 0) + if stop_loss <= 0 or take_profit <= 0: + raise ValueError("止盈止损价格须大于 0") + return stop_loss, take_profit + + +def replace_active_monitor_tpsl_on_exchange(order_row, stop_loss, take_profit): + """先撤该合约全部 TP/SL,再按新价重挂(与交易所 App 一致)。""" + ok, reason = ensure_exchange_live_ready() + if not ok: + raise RuntimeError(reason or "实盘未就绪") + ex_sym = resolve_monitor_exchange_symbol(order_row) + direction = order_row["direction"] + cancel_binance_futures_open_orders(ex_sym) + pos_amt = get_live_position_contracts(ex_sym, direction) + if pos_amt is None or float(pos_amt) <= 0: + raise ValueError("交易所当前无该方向持仓,无法挂止盈止损") + _binance_place_tp_sl_orders(ex_sym, direction, float(pos_amt), float(stop_loss), float(take_profit)) + + +def extract_trade_price_from_order(order): + if not order: + return None + for k in ("average", "avgPrice", "price"): + try: + v = float(order.get(k) or 0) + if v > 0: + return v + except Exception: + pass + try: + info = order.get("info") or {} + if isinstance(info, dict): + for k in ("fillPx", "avgPx", "fill_price"): + v = float(info.get(k) or 0) + if v > 0: + return v + except Exception: + pass + return None + + +def is_no_position_error(err_msg): + msg = (err_msg or "").lower() + # 禁止匹配笼统的 reduceonly / -4061:会与参数错误、单向/双向模式不匹配混淆, + # 误判后走「已无仓」同步结束,交易所仓位却仍在。 + keywords = [ + "no position", + "position does not exist", + "position not exist", + "nothing to close", + "pos size is 0", + "position amount is 0", + ] + return any(k in msg for k in keywords) + + +def get_live_position_contracts(exchange_symbol, direction): + ensure_markets_loaded() + try: + rows = exchange.fetch_positions([exchange_symbol]) + except Exception: + return None + total = 0.0 + for p in rows: + if not _position_matches_wanted_contract(exchange_symbol, p): + continue + if not _row_matches_monitor_direction(direction, p): + continue + contracts = _position_row_effective_contracts(p) + if contracts <= 0: + continue + total += contracts + return total + + +def _select_live_position_row(rows, exchange_symbol, direction, relax_hedge=False): + """在 fetch_positions 结果中取与当前监控方向一致、张数最大的一条(与 get_live_position_contracts 过滤规则一致)。""" + if not rows: + return None + candidates = [] + for p in rows: + if not _position_matches_wanted_contract(exchange_symbol, p): + continue + contracts = _position_row_effective_contracts(p) + if contracts <= 0: + continue + if (not relax_hedge) and not _row_matches_monitor_direction(direction, p): + continue + candidates.append((contracts, p)) + if not candidates and (not relax_hedge) and BINANCE_POSITION_MODE == "hedge": + return _select_live_position_row(rows, exchange_symbol, direction, relax_hedge=True) + if not candidates: + return None + candidates.sort(key=lambda x: x[0], reverse=True) + return candidates[0][1] + + +def _coerce_float(*values): + for v in values: + if v is None or v == "": + continue + try: + return float(v) + except (TypeError, ValueError): + continue + return None + + +def parse_ccxt_position_metrics(position, order_leverage=None): + """ + 从 ccxt 统一持仓结构解析保证金/名义/未实现盈亏。 + 「所保证金」对齐币安合约页的初始/持仓保证金:优先 initialMargin / positionInitialMargin。 + Binance 全仓下 ccxt 的 collateral 常来自 crossMargin,口径易与「名义」混淆,故不全仓优先用 collateral。 + """ + if not position: + return None + p = position + info = p.get("info", {}) or {} + margin_mode = str(p.get("marginMode") or info.get("marginType") or "").lower() + isolated = margin_mode.startswith("isolated") or str(info.get("isolated", "")).lower() == "true" + + initial = _coerce_float( + p.get("initialMargin"), + info.get("positionInitialMargin"), + info.get("initialMargin"), + ) + if (initial is None or initial <= 0) and isolated: + initial = _coerce_float(p.get("collateral"), info.get("isolatedWallet")) + if initial is None or initial <= 0: + initial = _coerce_float(p.get("margin")) + if initial is None or initial <= 0: + initial = _coerce_float( + info.get("initial_margin"), + info.get("position_margin"), + info.get("iso_margin"), + ) + notional = _coerce_float(p.get("notional"), p.get("notionalValue")) + if notional is None or notional <= 0: + notional = _coerce_float(info.get("value")) + if notional is not None: + notional = abs(notional) + # 全仓且 API margin 为 0 时:用名义/杠杆粗算展示(与交易所「约占用」接近) + if (initial is None or initial <= 0) and notional and notional > 0 and order_leverage: + try: + lev = float(order_leverage) + if lev > 0: + approx = notional / lev + if approx > 0: + initial = approx + except (TypeError, ValueError): + pass + unrealized = _coerce_float( + p.get("unrealizedPnl"), + info.get("unrealised_pnl"), + info.get("unrealized_pnl"), + ) + mark = _coerce_float(p.get("markPrice"), p.get("mark_price"), info.get("mark_price"), info.get("markPrice")) + out = {} + if initial is not None and initial > 0: + out["initial_margin"] = round(initial, FUNDS_DECIMALS) + if notional is not None and notional > 0: + out["notional"] = round(notional, FUNDS_DECIMALS) + if unrealized is not None: + out["unrealized_pnl"] = round(unrealized, FUNDS_DECIMALS) + if mark is not None and mark > 0: + ps = p.get("symbol") + try: + ex_sym = _ccxt_swap_symbol_for_precision(ps or "") + if ex_sym: + out["mark_price"] = float(exchange.price_to_precision(ex_sym, mark)) + else: + out["mark_price"] = round(mark, 8) + except Exception: + out["mark_price"] = round(mark, 8) + return out or None + + +def get_live_position_exchange_metrics(exchange_symbol, direction): + ensure_markets_loaded() + if not exchange_private_api_configured() or not exchange_symbol: + return None + try: + rows = exchange.fetch_positions() or [] + except Exception: + try: + rows = exchange.fetch_positions([exchange_symbol]) or [] + except Exception: + return None + p = _select_live_position_row(rows, exchange_symbol, direction) + return parse_ccxt_position_metrics(p) + + +def opened_at_str_to_ms(opened_at_str): + if not opened_at_str: + return None + try: + dt = datetime.strptime(str(opened_at_str).strip()[:19], "%Y-%m-%d %H:%M:%S") + except ValueError: + return None + try: + aware = dt.replace(tzinfo=APP_TZ) + return int(aware.timestamp() * 1000) + except Exception: + return None + + +def _to_ms_with_fallback(ms_value, dt_str): + try: + if ms_value is not None and str(ms_value).strip() != "": + v = int(float(ms_value)) + if v > 0: + return v + except Exception: + pass + return opened_at_str_to_ms(dt_str) + + +def ms_to_app_local_str(ms): + if ms is None: + return app_now_str() + try: + dt = datetime.fromtimestamp(ms / 1000.0, tz=timezone.utc).astimezone(APP_TZ) + return dt.replace(tzinfo=None).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return app_now_str() + + +def classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_price): + """根据成交价相对止盈/止损位归类;无法可靠归类时返回 None。""" + try: + tp = float(take_profit) + sl = float(stop_loss) + ex = float(exit_price) + trig = float(trigger_price) + except (TypeError, ValueError): + return None + band = max(abs(trig) * 0.0008, abs(tp - sl) * 0.003, 1e-12) + if direction == "long": + if ex >= tp - band: + return "止盈" + if ex <= sl + band: + return "止损" + else: + if ex <= tp + band: + return "止盈" + if ex >= sl - band: + return "止损" + return None + + +def fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=None): + """取开仓以来最近一笔减仓成交(与方向一致);失败返回 None。""" + if not (BINANCE_API_KEY and BINANCE_API_SECRET): + return None + ensure_markets_loaded() + since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) + close_side = "sell" if direction == "long" else "buy" + + def pick_from_trades(trades): + if not trades: + return None + candidates = [] + for t in trades: + if (t.get("side") or "").lower() != close_side: + continue + info = t.get("info") or {} + if not isinstance(info, dict): + info = {} + pos_side = (info.get("posSide") or t.get("posSide") or "").lower() + if BINANCE_POSITION_MODE == "hedge": + if pos_side in ("long", "short") and pos_side != direction: + continue + ts = t.get("timestamp") + if ts is None: + continue + candidates.append(t) + if not candidates: + return None + return max(candidates, key=lambda x: x.get("timestamp") or 0) + + try: + trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=100) + hit = pick_from_trades(trades) + if hit is None and since_ms: + trades = exchange.fetch_my_trades(exchange_symbol, since=None, limit=100) + hit = pick_from_trades(trades) + return hit + except Exception: + return None + + +def fetch_closing_fills_for_record(exchange_symbol, direction, opened_at_str, closed_at_str=None, opened_at_ms=None, closed_at_ms=None): + """ + 拉取某条历史记录对应的减仓成交(用于按 id 回填)。 + 返回按时间排序的成交列表。 + """ + if not (BINANCE_API_KEY and BINANCE_API_SECRET): + return [] + ensure_markets_loaded() + since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) + close_side = "sell" if direction == "long" else "buy" + closed_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str) if (closed_at_str or closed_at_ms is not None) else None + close_upper_ms = (int(closed_ms) + 15 * 60 * 1000) if closed_ms is not None else None + candidates = [] + all_side_candidates = [] + try: + trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=200) + except Exception: + trades = [] + if not trades and since_ms: + try: + trades = exchange.fetch_my_trades(exchange_symbol, since=None, limit=200) + except Exception: + trades = [] + for t in trades or []: + if (t.get("side") or "").lower() != close_side: + continue + ts = t.get("timestamp") + if ts is None: + continue + try: + ts = int(ts) + except Exception: + continue + if since_ms and ts < since_ms: + continue + if close_upper_ms and ts > close_upper_ms: + continue + info = t.get("info") or {} + if not isinstance(info, dict): + info = {} + pos_side = (info.get("posSide") or t.get("posSide") or "").lower() + if BINANCE_POSITION_MODE == "hedge": + if pos_side in ("long", "short") and pos_side != direction: + continue + all_side_candidates.append(t) + candidates.append(t) + candidates.sort(key=lambda x: x.get("timestamp") or 0) + if candidates: + return candidates + + # 严格窗口为空时,降级为“按平仓时间就近匹配”,降低时区/时间误差导致的回填失败。 + all_side_candidates.sort(key=lambda x: x.get("timestamp") or 0) + if not all_side_candidates: + return [] + if not closed_ms: + return all_side_candidates[-5:] + near = [] + for t in all_side_candidates: + ts = _coerce_ts_ms(t.get("timestamp")) + if ts is None: + continue + delta = abs(ts - int(closed_ms)) + if delta <= 45 * 60 * 1000: + near.append((delta, t)) + if near: + near.sort(key=lambda x: x[0]) + picked = [x[1] for x in near[:12]] + picked.sort(key=lambda x: x.get("timestamp") or 0) + return _cluster_closing_trades_near_close(picked, int(closed_ms)) + return _cluster_closing_trades_near_close(all_side_candidates[-5:], int(closed_ms)) + + +def calc_weighted_exit_price(trades): + if not trades: + return None + total_amount = 0.0 + weighted_sum = 0.0 + for t in trades: + try: + price = float(t.get("price") or 0) + amount = float(t.get("amount") or 0) + except Exception: + continue + if price <= 0: + continue + if amount <= 0: + amount = 1.0 + weighted_sum += price * amount + total_amount += amount + if total_amount <= 0: + return None + return weighted_sum / total_amount + + +def resolve_synced_flat_close(row, opened_at_str, opened_at_ms=None): + """ + 交易所已无仓、本地仍为 active 时,推断平仓类型/时间/盈亏。 + 返回 (result, pnl_amount, closed_at_str, miss_reason)。 + """ + direction = row["direction"] + sym = row["symbol"] + trigger_price = row["trigger_price"] + stop_loss = row["stop_loss"] + take_profit = row["take_profit"] + exchange_symbol = row["exchange_symbol"] or normalize_exchange_symbol(sym) + + closed_at_str = app_now_str() + closed_at_ms = None + closing_trades = fetch_closing_fills_for_record( + exchange_symbol, direction, opened_at_str, None, opened_at_ms=opened_at_ms + ) + exit_px = calc_weighted_exit_price(closing_trades) if closing_trades else None + if exit_px is None: + trade = fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=opened_at_ms) + if trade: + try: + exit_px = float(trade.get("price") or 0) or None + except (TypeError, ValueError): + exit_px = None + if not closing_trades: + closing_trades = [trade] + if closing_trades: + last_ts = closing_trades[-1].get("timestamp") + if last_ts: + closed_at_str = ms_to_app_local_str(int(last_ts)) + closed_at_ms = int(last_ts) + + open_ms = _to_ms_with_fallback( + row["opened_at_ms"] if "opened_at_ms" in row.keys() else None, opened_at_str + ) + close_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str) + pnl, exit_px2, _, _, _ = resolve_trade_pnl_amount( + row, + trigger_price, + exit_px, + opened_at_str=opened_at_str, + opened_at_ms=open_ms, + closed_at_str=closed_at_str, + closed_at_ms=close_ms, + ) + if exit_px2: + exit_px = float(exit_px2) + + if exit_px is None or exit_px <= 0: + p = get_price(sym) + if p: + guessed = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, p) + if guessed: + pnl2, _, _, _, _ = resolve_trade_pnl_amount( + row, + trigger_price, + p, + opened_at_str=opened_at_str, + opened_at_ms=open_ms, + closed_at_str=closed_at_str, + closed_at_ms=close_ms, + ) + return ( + guessed, + pnl2, + closed_at_str, + "未能拉取成交明细,按当前市价与止盈/止损位近似归类(建议核对交易所账单)", + ) + return ( + "外部平仓", + pnl, + closed_at_str, + "检测到交易所仓位已关闭,且无法从成交记录还原平仓价", + ) + + result = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_px) + if result: + return ( + result, + pnl, + closed_at_str, + "按交易所成交/流水同步为止盈/止损平仓", + ) + return ( + "外部平仓", + pnl, + closed_at_str, + "交易所已平仓,成交价不在计划止盈/止损带内(可能为手动或其他类型平仓)", + ) + + +def reconcile_external_closes(conn, days=None): + synced_count = 0 + cutoff_ms = None + if days is not None: + try: + d = int(days) + if d > 0: + cutoff_ms = int((app_now() - timedelta(days=d)).timestamp() * 1000) + except Exception: + cutoff_ms = None + rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() + for r in rows: + if cutoff_ms is not None: + opened_at_v = get_opened_at_value(r) + opened_ms = _to_ms_with_fallback(r["opened_at_ms"] if "opened_at_ms" in r.keys() else None, opened_at_v) + # 手动同步按最近 N 天过滤,避免把更早历史单误同步进来 + if opened_ms is None or opened_ms < cutoff_ms: + continue + exchange_symbol = r["exchange_symbol"] or normalize_exchange_symbol(r["symbol"]) + live_contracts = get_live_position_contracts(exchange_symbol, r["direction"]) + if live_contracts is None: + continue + if live_contracts > 0: + continue + cancel_binance_futures_open_orders(exchange_symbol) + opened_at = get_opened_at_value(r) + opened_at_ms = _to_ms_with_fallback(r["opened_at_ms"] if "opened_at_ms" in r.keys() else None, opened_at) + result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close(r, opened_at, opened_at_ms=opened_at_ms) + closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now() + hold_seconds = calc_hold_seconds(opened_at, closed_at_dt) + session_date = r["session_date"] or get_trading_day(closed_at_dt) + update_session_capital(conn, session_date, pnl_amount) + insert_trade_record( + conn, + symbol=r["symbol"], + monitor_type=order_row_monitor_type(r), + key_signal_type=order_row_key_signal_type(r), + direction=r["direction"], + trigger_price=r["trigger_price"], + stop_loss=r["stop_loss"], + initial_stop_loss=r["initial_stop_loss"] or r["stop_loss"], + take_profit=r["take_profit"], + margin_capital=r["margin_capital"], + leverage=r["leverage"], + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=r["trade_style"], + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio(r["direction"], r["trigger_price"], r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]), + actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), + result=result, + miss_reason=miss_reason, + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (r["id"],)) + clear_key_sizing_snapshot_if_flat(conn, r["session_date"] or get_trading_day()) + if result in ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓"): + send_wechat_msg( + build_wechat_close_message( + symbol=r["symbol"], + direction=r["direction"], + result=f"{result}(自动同步)", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=r["trigger_price"], + current_price="-", + stop_loss=r["stop_loss"], + take_profit=r["take_profit"], + close_order_id="-", + extra_note=miss_reason, + ) + ) + else: + send_wechat_msg( + build_wechat_close_message( + symbol=r["symbol"], + direction=r["direction"], + result="外部平仓(自动同步)", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=r["trigger_price"], + current_price="-", + stop_loss=r["stop_loss"], + take_profit=r["take_profit"], + close_order_id="-", + extra_note=miss_reason, + ) + ) + synced_count += 1 + return synced_count + +# 获取实时价格 +def get_price(symbol): + try: + ensure_markets_loaded() + return exchange.fetch_ticker(normalize_exchange_symbol(symbol))["last"] + except: + return None + +# 获取5分钟K线收盘价 +def get_5m_close(symbol): + try: + ensure_markets_loaded() + ohlcv = exchange.fetch_ohlcv(normalize_exchange_symbol(symbol), KLINE_TIMEFRAME, limit=1) + return ohlcv[-1][4] if ohlcv else None + except: + return None + + +def _safe_float(v): + try: + return float(v) + except Exception: + return None + + +def _compute_ema(values, period=55): + arr = [float(x) for x in values if x is not None] + if len(arr) < period: + return None + k = 2.0 / (period + 1.0) + ema = arr[0] + for val in arr[1:]: + ema = val * k + ema * (1 - k) + return ema + + +def _status_by_ema55(symbol, timeframe): + try: + bars = exchange.fetch_ohlcv(normalize_exchange_symbol(symbol), timeframe=timeframe, limit=80) + if not bars or len(bars) < 56: + return "横盘", None, None + closes = [float(x[4]) for x in bars if x and len(x) >= 5] + ema55 = _compute_ema(closes, 55) + last_close = closes[-1] + if ema55 is None or last_close <= 0: + return "横盘", last_close, ema55 + diff_pct = (last_close - ema55) / ema55 * 100.0 + if abs(diff_pct) < 0.1: + return "横盘", last_close, ema55 + return ("多头" if diff_pct > 0 else "空头"), last_close, ema55 + except Exception: + return "横盘", None, None + + +def _daily_volume_rank(symbol): + """ + 返回(symbol_rank, total_count),按 quoteVolume 降序,缺失时 fallback 到 baseVolume*last。 + """ + sym_norm = normalize_symbol_input(symbol) + target_base = journal_coin_from_symbol(sym_norm) + + def _ticker_base(sym_text): + s = str(sym_text or "").upper().strip() + if ":" in s: + s = s.split(":", 1)[0] + if "/" in s: + return s.split("/", 1)[0].strip() + if "-" in s: + return s.split("-", 1)[0].strip() + if s.endswith("USDT"): + return s[:-4].strip() + return s + now_ts = time.time() + cached_ok = ( + LIQUIDITY_RANK_CACHE["updated_at"] + and now_ts - float(LIQUIDITY_RANK_CACHE["updated_at"]) < max(30, BALANCE_REFRESH_SECONDS) + ) + if not cached_ok: + try: + ensure_markets_loaded() + tickers = exchange.fetch_tickers() + scored = [] + for s, t in (tickers or {}).items(): + try: + mk = exchange.markets.get(s) + if not mk or not mk.get("swap"): + continue + su = str(s).upper() + if "USDT" not in su: + continue + qv = _safe_float((t or {}).get("quoteVolume")) + if qv is None: + info = (t or {}).get("info") if isinstance((t or {}).get("info"), dict) else {} + qv = _safe_float(info.get("volCcy24h") or info.get("vol24h")) + if qv is None: + bv = _safe_float((t or {}).get("baseVolume")) + lp = _safe_float((t or {}).get("last")) + if bv is not None and lp is not None: + qv = bv * lp + if qv is None or qv <= 0: + continue + scored.append((_ticker_base(s), float(qv))) + except Exception: + continue + scored.sort(key=lambda x: x[1], reverse=True) + ranks = {} + for idx, (base, _) in enumerate(scored, 1): + if base and base not in ranks: + ranks[base] = idx + LIQUIDITY_RANK_CACHE["ranks"] = ranks + LIQUIDITY_RANK_CACHE["total"] = len(scored) + LIQUIDITY_RANK_CACHE["updated_at"] = now_ts + except Exception: + pass + ranks = LIQUIDITY_RANK_CACHE.get("ranks") or {} + total = int(LIQUIDITY_RANK_CACHE.get("total") or 0) + return ranks.get(target_base), total + + +def _key_hard_checks(symbol, direction, upper, lower, monitor_type): + """ + 关键位门控:量能、突破幅度、第二根确认、日成交量前30。 + 使用最近闭合K:breakout=倒数第2根,confirm=倒数第1根。 + """ + out = {"ok": False} + ex_sym = normalize_exchange_symbol(symbol) + bars = exchange.fetch_ohlcv(ex_sym, timeframe=KLINE_TIMEFRAME, limit=80) or [] + if len(bars) < 24: + out["reason"] = "5m K线数量不足" + return out + closed = bars[:-1] if len(bars) >= 3 else bars + min_closed = KEY_VOLUME_MA_BARS + 3 + if len(closed) < min_closed: + out["reason"] = f"{KLINE_TIMEFRAME} 闭合K线不足" + return out + try: + breakout = closed[KEY_CONFIRM_BREAKOUT_BAR] + confirm = closed[KEY_CONFIRM_BAR] + except IndexError: + out["reason"] = "确认K索引超出范围,请检查 KEY_CONFIRM_* 配置" + return out + prev_vol = closed[KEY_CONFIRM_BREAKOUT_BAR - KEY_VOLUME_MA_BARS : KEY_CONFIRM_BREAKOUT_BAR] + avg20 = sum(float(x[5]) for x in prev_vol) / max(len(prev_vol), 1) + vol_break = float(breakout[5]) + vol_ok = vol_break > avg20 * KEY_VOLUME_RATIO_MIN if avg20 > 0 else False + open_b = float(breakout[1]) + close_b = float(breakout[4]) + high_b = float(breakout[2]) + low_b = float(breakout[3]) + amp_pct = abs(close_b - open_b) / open_b * 100 if open_b > 0 else 0 + amp_ok = (amp_pct > KEY_BREAKOUT_AMP_MIN_PCT) and (amp_pct < KEY_BREAKOUT_AMP_MAX_PCT) + cfm_close = float(confirm[4]) + # 区间极值点严格以前端录入 upper/lower 为准:做多看上沿,做空看下沿 + edge = float(upper) if direction == "long" else float(lower) + breakout_ok = (close_b > float(upper)) if direction == "long" else (close_b < float(lower)) + confirm_ok_raw = (cfm_close > edge) if direction == "long" else (cfm_close < edge) + # 口径收紧:未发生有效突破时,不标记幅度/二确通过,避免出现“还没到位却显示Y” + amp_ok = amp_ok and breakout_ok + confirm_ok = confirm_ok_raw and breakout_ok + rank, total = _daily_volume_rank(symbol) + rank_ok = (rank is not None) and (rank <= KEY_DAILY_VOLUME_RANK_MAX) + swing4h_pct = 0.0 + try: + seg48 = closed[-48:] if len(closed) >= 48 else closed + hh = max(float(x[2]) for x in seg48) + ll = min(float(x[3]) for x in seg48) + swing4h_pct = ((hh - ll) / ll * 100.0) if ll > 0 else 0.0 + except Exception: + swing4h_pct = 0.0 + out.update( + { + "ok": all([vol_ok, amp_ok, breakout_ok, confirm_ok, rank_ok]), + "vol_ok": vol_ok, + "avg20": avg20, + "vol_break": vol_break, + "amp_ok": amp_ok, + "amp_pct": amp_pct, + "breakout_ok": breakout_ok, + "breakout_close": close_b, + "confirm_ok": confirm_ok, + "confirm_close": cfm_close, + "edge_price": edge, + "rank": rank, + "rank_total": total, + "rank_ok": rank_ok, + "breakout_high": high_b, + "breakout_low": low_b, + "breakout_ts": breakout[0], + "confirm_ts": confirm[0], + "swing4h_pct": swing4h_pct, + "monitor_type": monitor_type, + "direction": direction, + } + ) + return out + + +def calc_price_diff_pct(current_price, target_price): + try: + if target_price is None: + return None, None + t = float(target_price) + if t == 0: + return None, None + c = float(current_price) + diff = c - t + pct = diff / t * 100 + return round(diff, 6), round(pct, 4) + except Exception: + return None, None + + +def _finalize_key_monitor_one_shot(conn, row, last_msg, close_reason): + """本条关键位一次性结案:写历史并从当前表删除。""" + n = int(row["notification_count"] or 0) + 1 + insert_key_monitor_history(conn, row, n, last_msg, close_reason) + conn.execute("DELETE FROM key_monitors WHERE id=?", (row["id"],)) + + +def _key_hard_lines_from_checks(checks): + return [ + f"量能:{'通过' if checks['vol_ok'] else '不通过'}(突破K量 {round(checks['vol_break'], 4)} / 前20均量 {round(checks['avg20'], 4)},阈值1.3x)", + f"突破价位:{'通过' if checks['breakout_ok'] else '不通过'}(突破K收盘 {round(float(checks['breakout_close']), 8)},关键位 {checks['edge_price']})", + f"突破K幅度:{'通过' if checks['amp_ok'] else '不通过'}({round(checks['amp_pct'], 4)}%,要求0.03%~0.5%)", + f"第二根确认:{'通过' if checks['confirm_ok'] else '不通过'}(确认收盘 {checks['confirm_close']},关键位 {checks['edge_price']})", + f"日成交量排名:{'通过' if checks['rank_ok'] else '不通过'}({checks['rank']}/{checks['rank_total']},要求前30)", + ] + + +def _key_plan_sl_tp_for_row(row, direction, upper, lower, checks): + """按 key_monitors 录入的方案计算计划 SL/TP。""" + mode = sl_tp_mode_from_row(row, "standard") + manual_tp = _sqlite_row_val(row, "manual_take_profit") + planned = plan_key_sl_tp( + mode, + direction, + upper, + lower, + checks, + outside_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT, + trend_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT, + manual_take_profit=manual_tp, + ) + return planned, mode + + +def _market_open_for_key_monitor( + conn, + symbol, + direction, + exchange_symbol, + stop_loss, + take_profit, + key_signal_type=None, + breakeven_enabled=0, +): + """ + 与手动「实盘下单」对齐的市价开仓与 order_monitors 写入(Binance U 本位)。 + 返回 (ok: bool, err_msg: Optional[str], detail: Optional[dict]) + """ + now = app_now() + ok, reason = precheck_risk(conn, symbol, direction) + if not ok: + return False, f"风控拒绝下单:{reason}", None + ok_live, reason_live = ensure_exchange_live_ready() + if not ok_live: + return False, reason_live, None + + default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) + leverage = int(default_leverage) if default_leverage else 5 + if leverage <= 0: + leverage = 5 + + trading_day = get_trading_day(now) + opens_today_before = conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", + (trading_day,), + ).fetchone()[0] + session_row = ensure_session(conn, trading_day) + _, trading_capital_live = get_exchange_capitals(force=True) + live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"]) + capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital) + + trade_style = (DEFAULT_TRADE_STYLE or "trend").strip().lower() + if trade_style not in ("trend", "swing"): + trade_style = "trend" + + available_usdt = get_available_trading_usdt() + live_price = get_price(symbol) + if live_price is None: + return False, "获取交易所实时价格失败(以损定仓需要当前价)", None + try: + ensure_markets_loaded() + except Exception: + pass + lp_adj = round_price_to_exchange(exchange_symbol, live_price) + if lp_adj is not None: + live_price = float(lp_adj) + + sl_adj = round_price_to_exchange(exchange_symbol, float(stop_loss)) + tp_adj = round_price_to_exchange(exchange_symbol, float(take_profit)) + if sl_adj is not None: + stop_loss = float(sl_adj) + if tp_adj is not None: + take_profit = float(tp_adj) + + risk_fraction = calc_risk_fraction(direction, live_price, stop_loss) + if risk_fraction is None: + return False, "止损方向不合法(相对当前市价);请核对上下沿与方向", None + risk_percent = max(0.01, float(RISK_PERCENT)) + risk_amount = round(capital_base * risk_percent / 100.0, FUNDS_DECIMALS) + notional_value = round(risk_amount / risk_fraction, FUNDS_DECIMALS) + margin_capital = round(notional_value / leverage, FUNDS_DECIMALS) + + if capital_base and margin_capital > capital_base: + return False, "以损定仓后保证金超过当前交易资金", None + + if available_usdt is not None: + max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), FUNDS_DECIMALS) + if margin_capital > max_margin: + return ( + False, + f"保证金不足:交易账户可用约 {round(available_usdt, FUNDS_DECIMALS)}U,当前最多建议 {max_margin}U", + None, + ) + + position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0 + + try: + amount, quote_price = prepare_order_amount(exchange_symbol, margin_capital, leverage, live_price) + contract_size = get_contract_size(exchange_symbol) + base_amount = round(float(amount) * contract_size, 8) + order_resp = place_exchange_order( + exchange_symbol, direction, amount, leverage, + stop_loss=stop_loss, take_profit=take_profit, + ) + open_order_id = order_resp.get("id", "") + tpsl_attached = bool(order_resp.get("tpsl_attached")) + trigger_price = resolve_order_entry_price(order_resp, exchange_symbol, quote_price) + except Exception as e: + return False, friendly_exchange_error(e, available_usdt=available_usdt), None + + tr_adj = round_price_to_exchange(exchange_symbol, trigger_price) + if tr_adj is not None: + trigger_price = float(tr_adj) + sl_f = round_price_to_exchange(exchange_symbol, stop_loss) + if sl_f is not None: + stop_loss = float(sl_f) + tp_f = round_price_to_exchange(exchange_symbol, take_profit) + if tp_f is not None: + take_profit = float(tp_f) + + opened_at_bj = app_now_str() + opened_at_ms = _to_ms_with_fallback(None, opened_at_bj) + + planned_rr = calc_rr_ratio(direction, trigger_price, stop_loss, take_profit) + breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER) + breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT) + breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0 + risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage) or risk_amount + + if direction == "short": + breakeven_price = round(float(trigger_price) * (1 - breakeven_offset_pct / 100.0), 8) + else: + breakeven_price = round(float(trigger_price) * (1 + breakeven_offset_pct / 100.0), 8) + be_enabled = 1 if int(breakeven_enabled or 0) != 0 else 0 + + conn.execute( + "INSERT INTO order_monitors " + "(symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, " + "margin_capital, leverage, trade_style, risk_percent, risk_amount, " + "breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, " + "notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type, key_signal_type) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + exchange_symbol, + direction, + trigger_price, + stop_loss, + stop_loss, + take_profit, + margin_capital, + leverage, + trade_style, + risk_percent, + risk_amount_final, + breakeven_rr_trigger, + breakeven_offset_pct, + breakeven_step_r, + 0, + breakeven_price, + be_enabled, + notional_value, + position_ratio, + base_amount, + amount, + open_order_id, + opened_at_bj, + opened_at_ms, + trading_day, + ORDER_MONITOR_TYPE_KEY_AUTO, + stored_key_signal_type(key_signal_type), + ), + ) + new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + opens_today_after = conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", + (trading_day,), + ).fetchone()[0] + + return True, None, { + "new_order_id": new_order_id, + "open_order_id": open_order_id, + "trigger_price": trigger_price, + "planned_rr_fill": planned_rr, + "risk_amount_final": risk_amount_final, + "margin_capital": margin_capital, + "leverage": leverage, + "amount": amount, + "base_amount": base_amount, + "notional_value": notional_value, + "position_ratio": position_ratio, + "tpsl_attached": tpsl_attached, + "opens_today_before": opens_today_before, + "opens_today_after": opens_today_after, + "trading_day": trading_day, + "risk_percent": risk_percent, + "breakeven_rr_trigger": breakeven_rr_trigger, + "breakeven_price": breakeven_price, + "capital_base_at_open": capital_base, + } + + +def _sqlite_row_val(row, key, default=None): + try: + v = row[key] + return default if v is None else v + except (KeyError, IndexError, TypeError): + return default + + +def get_symbol_mark_price(symbol): + """斐波失效判定用标记价。""" + ex_sym = normalize_exchange_symbol(symbol) + try: + ensure_markets_loaded() + ticker = exchange.fetch_ticker(ex_sym) + m = _coerce_float(ticker.get("mark"), ticker.get("last")) + if m is None: + info = ticker.get("info") or {} + m = _coerce_float(info.get("mark_price"), info.get("last")) + if m is not None and m > 0: + return float(m) + except Exception: + pass + p = get_price(symbol) + return float(p) if p is not None else None + + +def cancel_fib_limit_order(exchange_symbol, order_id): + """仅撤销本条斐波限价单,不用 cancel_all。""" + if not order_id: + return False + ok_live, _ = ensure_exchange_live_ready() + if not ok_live: + return False + ensure_markets_loaded() + oid = str(order_id) + try: + exchange.cancel_order(oid, exchange_symbol) + return True + except Exception: + pass + try: + for o in exchange.fetch_open_orders(exchange_symbol) or []: + if str(o.get("id")) == oid: + exchange.cancel_order(oid, exchange_symbol) + return True + except Exception: + pass + return False + + +def fib_limit_order_status(exchange_symbol, order_id): + if not order_id: + return "missing" + ensure_markets_loaded() + oid = str(order_id) + try: + o = exchange.fetch_order(oid, exchange_symbol) + st = (o.get("status") or "").lower() + if st in ("closed", "filled"): + filled = float(o.get("filled") or 0) + if filled > 0 or st == "filled": + return "filled" + if st in ("canceled", "cancelled", "expired", "rejected"): + return "canceled" + if st in ("open", "new", "partially_filled"): + return "open" + except Exception: + pass + try: + for o in exchange.fetch_open_orders(exchange_symbol) or []: + if str(o.get("id")) == oid: + return "open" + except Exception: + pass + return "unknown" + + +def place_fib_limit_order(exchange_symbol, direction, amount, leverage, limit_price): + ensure_markets_loaded() + mm = "cross" if BINANCE_MARGIN_MODE in ("cross", "cross_margin") else "isolated" + try: + exchange.set_margin_mode(mm, exchange_symbol) + except Exception: + pass + exchange.set_leverage(leverage, exchange_symbol) + side = "buy" if direction == "long" else "sell" + price = round_price_to_exchange(exchange_symbol, float(limit_price)) + if price is None or price <= 0: + raise ValueError("挂单价无效") + params = build_binance_order_params(direction, reduce_only=False) + return exchange.create_order(exchange_symbol, "limit", side, amount, price, params) + + +def _fib_key_exists_for_symbol(conn, symbol): + ph = ",".join("?" * len(FIB_KEY_MONITOR_TYPES)) + row = conn.execute( + f"SELECT id FROM key_monitors WHERE symbol=? AND monitor_type IN ({ph})", + (symbol, *tuple(FIB_KEY_MONITOR_TYPES)), + ).fetchone() + return row is not None + + +def _fib_plan_for_row(row): + typ = (row["monitor_type"] or "").strip() + ratio = fib_ratio_from_type(typ) + if ratio is None: + return None + return calc_fib_plan(row["direction"], row["upper"], row["lower"], ratio) + + +def _cancel_fib_monitor_limit(row): + ex_sym = normalize_exchange_symbol(row["symbol"]) + oid = _sqlite_row_val(row, "fib_limit_order_id") + if oid: + cancel_fib_limit_order(ex_sym, oid) + + +def _fib_has_live_position(exchange_symbol, direction): + live = get_live_position_contracts(exchange_symbol, direction) + return live is not None and float(live) > 0 + + +def _insert_order_monitor_from_fib_fill( + conn, row, trigger_price, stop_loss, take_profit, amount, leverage, margin_capital, + notional_value, position_ratio, base_amount, exchange_order_id, tpsl_attached, +): + symbol = row["symbol"] + direction = (row["direction"] or "long").lower() + exchange_symbol = normalize_exchange_symbol(symbol) + typ = (row["monitor_type"] or "").strip() + now = app_now() + trading_day = get_trading_day(now) + trade_style = (DEFAULT_TRADE_STYLE or "trend").strip().lower() + if trade_style not in ("trend", "swing"): + trade_style = "trend" + risk_percent = max(0.01, float(RISK_PERCENT)) + risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage) + if risk_amount_final is None: + risk_amount_final = round(float(margin_capital) * risk_percent / 100.0, 4) + breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER) + breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT) + breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0 + if direction == "short": + breakeven_raw = float(trigger_price) * (1 - breakeven_offset_pct / 100.0) + else: + breakeven_raw = float(trigger_price) * (1 + breakeven_offset_pct / 100.0) + breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw) + be_enabled = 1 if breakeven_enabled_from_row(row, 0) else 0 + opened_at_bj = app_now_str() + opened_at_ms = _to_ms_with_fallback(None, opened_at_bj) + conn.execute( + "INSERT INTO order_monitors " + "(symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, " + "margin_capital, leverage, trade_style, risk_percent, risk_amount, " + "breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, " + "notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type, key_signal_type) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + exchange_symbol, + direction, + trigger_price, + stop_loss, + stop_loss, + take_profit, + margin_capital, + leverage, + trade_style, + risk_percent, + risk_amount_final, + breakeven_rr_trigger, + breakeven_offset_pct, + breakeven_step_r, + 0, + breakeven_price, + be_enabled, + notional_value, + position_ratio, + base_amount, + amount, + exchange_order_id or "", + opened_at_bj, + opened_at_ms, + trading_day, + ORDER_MONITOR_TYPE_KEY_AUTO, + stored_key_signal_type(typ), + ), + ) + new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + return new_order_id + + +def _finalize_fib_key_fill(conn, row): + symbol = row["symbol"] + direction = (row["direction"] or "long").lower() + typ = (row["monitor_type"] or "").strip() + ex_sym = normalize_exchange_symbol(symbol) + plan = _fib_plan_for_row(row) + if not plan: + _finalize_key_monitor_one_shot(conn, row, "斐波计划无效", "fib_plan_invalid") + return + entry_plan, sl_plan, tp_plan = plan + sl = float(_sqlite_row_val(row, "fib_stop_loss", sl_plan) or sl_plan) + tp = float(_sqlite_row_val(row, "fib_take_profit", tp_plan) or tp_plan) + sl_adj = round_price_to_exchange(ex_sym, sl) + tp_adj = round_price_to_exchange(ex_sym, tp) + if sl_adj is not None: + sl = float(sl_adj) + if tp_adj is not None: + tp = float(tp_adj) + amount = float(_sqlite_row_val(row, "fib_order_amount") or 0) + leverage = int(_sqlite_row_val(row, "fib_leverage") or infer_leverage(symbol) or 5) + margin_capital = float(_sqlite_row_val(row, "fib_margin_capital") or 0) + oid = _sqlite_row_val(row, "fib_limit_order_id") + entry_px = float(_sqlite_row_val(row, "fib_entry_price", entry_plan) or entry_plan) + trigger_price = entry_px + if oid: + try: + o = exchange.fetch_order(str(oid), ex_sym) + trigger_price = resolve_order_entry_price(o, ex_sym, entry_px) + except Exception: + pass + tr_adj = round_price_to_exchange(ex_sym, trigger_price) + if tr_adj is not None: + trigger_price = float(tr_adj) + if amount <= 0: + live_amt = get_live_position_contracts(ex_sym, direction) + amount = float(live_amt or 0) + if amount <= 0: + send_wechat_msg( + f"# ❌ {symbol} 斐波成交后处理失败\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 无法取得持仓/下单数量,未挂 TP/SL\n" + ) + return + ok, reason = precheck_risk(conn, symbol, direction) + if not ok: + send_wechat_msg( + f"# ❌ {symbol} 斐波成交后风控拒绝\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}\n" + f"- 原因:{reason}\n" + f"- 请手动处理仓位与挂单\n" + ) + return + tpsl_attached = False + try: + _binance_place_tp_sl_orders(ex_sym, direction, amount, sl, tp) + tpsl_attached = True + except Exception as e: + send_wechat_msg( + f"# ❌ {symbol} 斐波成交后挂 TP/SL 失败\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 错误:{friendly_exchange_error(e)}\n" + f"- 请手动补挂止盈止损\n" + ) + return + contract_size = get_contract_size(ex_sym) + base_amount = round(float(amount) * contract_size, 8) + notional_value = round(float(margin_capital) * leverage, 4) if margin_capital else 0 + session_row = ensure_session(conn, get_trading_day(app_now())) + capital_base = float(session_row["current_capital"] or 0) + position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base and margin_capital else 0 + planned_rr = calc_rr_ratio(direction, trigger_price, sl, tp) + new_order_id = _insert_order_monitor_from_fib_fill( + conn, row, trigger_price, sl, tp, amount, leverage, margin_capital, + notional_value, position_ratio, base_amount, oid, tpsl_attached, + ) + rr_txt = format_wechat_scalar_2dp(planned_rr) if planned_rr is not None else "-" + succ = ( + f"# ✅ {symbol} 斐波限价成交\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 来源:{ORDER_MONITOR_TYPE_KEY_AUTO}(限价 @ E)\n" + f"- 类型:{typ}|{_wechat_direction_text(direction)}\n" + f"- 订单 ID:**{new_order_id}**\n" + f"- 成交价:{format_price_for_symbol(symbol, trigger_price)}\n" + f"- 止损:{format_wechat_scalar_2dp(sl)}|止盈:{format_price_for_symbol(symbol, tp)}\n" + f"- 计划 RR:{rr_txt}:1\n" + f"- {'已挂交易所 TP/SL' if tpsl_attached else 'TP/SL 未挂上'}\n" + ) + send_wechat_msg(succ) + _finalize_key_monitor_one_shot(conn, row, succ, "fib_filled") + + +def check_fib_key_monitors(): + conn = get_db() + rows = conn.execute("SELECT * FROM key_monitors").fetchall() + for r in rows: + typ = (r["monitor_type"] or "").strip() + if not is_fib_key_monitor_type(typ): + continue + symbol = r["symbol"] + direction = (r["direction"] or "long").lower() + ex_sym = normalize_exchange_symbol(symbol) + up, low = float(r["upper"]), float(r["lower"]) + oid = _sqlite_row_val(r, "fib_limit_order_id") + mark = get_symbol_mark_price(symbol) + if mark is None: + continue + status = fib_limit_order_status(ex_sym, oid) if oid else "missing" + if status == "filled" or (status != "open" and _fib_has_live_position(ex_sym, direction)): + _finalize_fib_key_fill(conn, r) + continue + if status == "open": + if fib_invalidate_by_mark(direction, mark, up, low): + _cancel_fib_monitor_limit(r) + msg = ( + f"# ⚠️ {symbol} 斐波监控失效\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}|{_wechat_direction_text(direction)}\n" + f"- 标记价 {format_price_for_symbol(symbol, mark)} 已触达止盈侧(未成交),已撤限价单\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, "fib_invalidate") + continue + if status in ("canceled", "missing", "unknown") and fib_invalidate_by_mark(direction, mark, up, low): + msg = ( + f"# ⚠️ {symbol} 斐波监控失效(限价已不在挂单)\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 标记价触达止盈侧,本条已结案\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, "fib_invalidate") + conn.commit() + conn.close() + + +def _add_fib_key_monitor(conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=0): + if _fib_key_exists_for_symbol(conn, symbol): + return False, f"{symbol} 已有斐波监控(同币仅允许一条 0.618/0.786)" + ratio = fib_ratio_from_type(mt) + plan = calc_fib_plan(direction_sel, upper_px, lower_px, ratio) + if not plan: + return False, "斐波上下沿无效(需上沿 H > 下沿 L)" + entry, sl, tp = plan + ex_sym = normalize_exchange_symbol(symbol) + entry = round_price_to_exchange(ex_sym, entry) + sl = round_price_to_exchange(ex_sym, sl) + tp = round_price_to_exchange(ex_sym, tp) + if entry is None or sl is None or tp is None: + return False, "斐波价位经交易所精度舍入后无效" + entry, sl, tp = float(entry), float(sl), float(tp) + planned_rr = calc_rr_ratio(direction_sel, entry, sl, tp) + if planned_rr is None or planned_rr <= KEY_AUTO_MIN_PLANNED_RR: + fmt_rr = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算" + return False, f"斐波计划盈亏比 {fmt_rr}:1 未达要求(>{KEY_AUTO_MIN_PLANNED_RR}:1)" + ok, reason = precheck_risk(conn, symbol, direction_sel) + if not ok: + return False, reason + ok_live, reason_live = ensure_exchange_live_ready() + if not ok_live: + return False, reason_live + now = app_now() + trading_day = get_trading_day(now) + session_row = ensure_session(conn, trading_day) + _, trading_capital_live = get_exchange_capitals(force=True) + live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"]) + capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital) + default_leverage = get_synced_leverage(ex_sym, direction_sel) or infer_leverage(symbol) + leverage = int(default_leverage) if default_leverage else 5 + if leverage <= 0: + leverage = 5 + available_usdt = get_available_trading_usdt() + risk_fraction = calc_risk_fraction(direction_sel, entry, sl) + if risk_fraction is None: + return False, "止损方向不合法(相对挂单价 E);请核对上下沿与方向" + risk_percent = max(0.01, float(RISK_PERCENT)) + risk_amount = round(capital_base * risk_percent / 100.0, 4) + notional_value = round(risk_amount / risk_fraction, 4) + margin_capital = round(notional_value / leverage, 4) + if capital_base and margin_capital > capital_base: + return False, "以损定仓后保证金超过当前交易资金" + if available_usdt is not None: + max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4) + if margin_capital > max_margin: + return ( + False, + f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", + ) + try: + amount, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry) + order_resp = place_fib_limit_order(ex_sym, direction_sel, amount, leverage, entry) + oid = str(order_resp.get("id") or "") + if not oid: + return False, "交易所未返回限价单 ID" + except Exception as e: + return False, friendly_exchange_error(e, available_usdt=available_usdt) + be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0 + conn.execute( + "INSERT INTO key_monitors " + "(symbol, monitor_type, direction, upper, lower, " + "fib_limit_order_id, fib_entry_price, fib_stop_loss, fib_take_profit, " + "fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, mt, direction_sel, upper_px, lower_px, + oid, entry, sl, tp, float(amount), margin_capital, leverage, be_flag, + ), + ) + return True, None + + +# 关键位监控(箱体/收敛可自动开仓;阻力/支撑位仅单次提醒结案) +def check_key_monitors(): + conn = get_db() + rows = conn.execute("SELECT * FROM key_monitors").fetchall() + for r in rows: + sym, typ_raw, up, low = r["symbol"], r["monitor_type"], r["upper"], r["lower"] + typ = (typ_raw or "").strip() + if is_fib_key_monitor_type(typ): + continue + direction = (r["direction"] or "long").lower() + try: + checks = _key_hard_checks(sym, direction, up, low, typ) + except Exception: + checks = {"ok": False} + if not checks.get("ok"): + continue + + btc8h_status, _, _ = _status_by_ema55("BTC/USDT", "8h") + coin4h_status, _, _ = _status_by_ema55(sym, "4h") + risk_tip = None + if (direction == "long" and coin4h_status == "空头") or (direction == "short" and coin4h_status == "多头"): + risk_tip = "当前信号与本币4h(EMA55)主趋势逆势,建议降低仓位并严格执行止损。" + + key_price = float(low) if direction == "long" else float(up) + hard_lines = _key_hard_lines_from_checks(checks) + trigger_time = ms_to_app_local_str(int(checks["confirm_ts"])) if checks.get("confirm_ts") else app_now_str() + + alert_only = typ in KEY_MONITOR_ALERT_ONLY_TYPES or ( + typ not in KEY_MONITOR_AUTO_TYPES and typ not in KEY_MONITOR_ALERT_ONLY_TYPES + ) + + if alert_only: + op_lines = [ + "- 本条为关键阻力/支撑或非标类型:**仅单次推送**,不进行自动开仓。", + "- 本条关键位将在推送后记入历史并从监控列表移除。", + ] + msg = build_wechat_key_monitor_message( + symbol=sym, + direction=direction, + monitor_type=typ, + trigger_time=trigger_time, + key_price=key_price, + confirm_close=checks["confirm_close"], + hard_lines=hard_lines, + btc8h_status=btc8h_status, + coin4h_status=coin4h_status, + swing4h_pct=checks.get("swing4h_pct") or 0.0, + op_lines=op_lines, + risk_tip=risk_tip, + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, "key_level_alert_only") + continue + + plan_tuple, sl_tp_mode = _key_plan_sl_tp_for_row(r, direction, up, low, checks) + if not plan_tuple: + fmt_rr = "无法计算(止损/止盈与确认价几何关系无效)" + rr_msg = ( + f"# ⚠️ {sym} 关键位自动单:计划无效\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}|方案:{sl_tp_mode_label(sl_tp_mode)}\n" + f"- 方向:**{_wechat_direction_text(direction)}**\n" + f"- 触发时间:`{trigger_time}`\n" + f"- 确认K收盘(E):`{format_price_for_symbol(sym, checks.get('confirm_close'))}`\n" + f"- **{fmt_rr}**(未开仓)\n" + "---\n" + "### 硬条件\n" + + "\n".join(f"- {x}" for x in hard_lines) + ) + if risk_tip: + rr_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}" + send_wechat_msg(rr_msg) + _finalize_key_monitor_one_shot(conn, r, rr_msg, "rr_insufficient") + continue + E, sl_raw, tp_raw, box_h = plan_tuple + exchange_symbol = normalize_exchange_symbol(sym) + try: + ensure_markets_loaded() + except Exception: + pass + sl_px = round_price_to_exchange(exchange_symbol, sl_raw) + tp_px = round_price_to_exchange(exchange_symbol, tp_raw) + if sl_px is not None: + sl_raw = float(sl_px) + if tp_px is not None: + tp_raw = float(tp_px) + + planned_rr = calc_rr_ratio(direction, E, sl_raw, tp_raw) + rr_ok = planned_rr is not None and planned_rr > KEY_AUTO_MIN_PLANNED_RR + + if not rr_ok: + fmt_rr = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算(止损/止盈与确认价几何关系无效)" + plan_line = sl_tp_plan_summary_text( + sl_tp_mode, direction, E, sl_raw, tp_raw, box_h, + outside_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT, + trend_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT, + ) + rr_msg = ( + f"# ⚠️ {sym} 关键位自动单:计划 RR 未达标\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}|{plan_line}\n" + f"- 方向:**{_wechat_direction_text(direction)}**\n" + f"- 触发时间:`{trigger_time}`\n" + f"- 确认K收盘(E):`{format_price_for_symbol(sym, E)}`\n" + f"- 箱体高 H:`{format_price_for_symbol(sym, box_h)}`\n" + f"- 计划止损:`{format_wechat_scalar_2dp(sl_raw)}`\n" + f"- 计划止盈:`{format_price_for_symbol(sym, tp_raw)}`\n" + f"- **计划 RR(按确认收盘 E):{fmt_rr} : 1**(要求 **>{KEY_AUTO_MIN_PLANNED_RR}:1**,未开仓)\n" + "---\n" + "### 硬条件\n" + + "\n".join(f"- {x}" for x in hard_lines) + ) + if risk_tip: + rr_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}" + send_wechat_msg(rr_msg) + _finalize_key_monitor_one_shot(conn, r, rr_msg, "rr_insufficient") + continue + + key_sig = typ if typ in KEY_MONITOR_AUTO_TYPES else None + be_on = breakeven_enabled_from_row(r, 0) + ok_trade, trade_err, det = _market_open_for_key_monitor( + conn, + sym, + direction, + exchange_symbol, + sl_raw, + tp_raw, + key_signal_type=key_sig, + breakeven_enabled=1 if be_on else 0, + ) + planned_rr_txt = ( + format_wechat_scalar_2dp(planned_rr) if planned_rr is not None else "-" + ) + if not ok_trade: + fail_msg = ( + f"# ❌ {sym} 关键位自动单失败\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}\n" + f"- 方向:**{_wechat_direction_text(direction)}**\n" + f"- 触发时间:`{trigger_time}`\n" + f"- 确认K收盘(E):`{format_price_for_symbol(sym, E)}`\n" + f"- 计划止损:`{format_wechat_scalar_2dp(sl_raw)}`\n" + f"- 计划止盈:`{format_price_for_symbol(sym, tp_raw)}`\n" + f"- **计划 RR(按 E):{planned_rr_txt} : 1**(已通过 RR 阈值)\n" + f"- **失败原因:{trade_err}**\n" + "---\n" + "### 硬条件\n" + + "\n".join(f"- {x}" for x in hard_lines) + ) + if risk_tip: + fail_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}" + send_wechat_msg(fail_msg) + _finalize_key_monitor_one_shot(conn, r, fail_msg, "exchange_failed") + continue + + tpsl_txt = ( + "已在交易所挂止盈/止损触发单(Binance U 本位条件单)" + if det.get("tpsl_attached") + else "⚠️ 条件单挂接状态异常或未挂上" + ) + rr_fill = det.get("planned_rr_fill") + rr_fill_txt = format_wechat_scalar_2dp(rr_fill) if rr_fill is not None else "-" + + succ_msg_lines = [ + f"# ✅ {sym} 关键位自动开仓成功", + f"**账户:{_wechat_account_label()}**", + f"- **来源:**{ORDER_MONITOR_TYPE_KEY_AUTO}(市价)", + f"- 页面订单 ID:**{det['new_order_id']}**", + f"- 交易所订单 ID:`{det.get('open_order_id') or '-'}`", + f"- 类型:{typ}|方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'开' if be_on else '关'}", + f"- 方向:**{_wechat_direction_text(direction)}**", + f"- 触发时间:`{trigger_time}`", + f"- 确认K收盘(E):{format_price_for_symbol(sym, E)}(RR 阈值按此计价)", + f"- **计划 RR(E):{planned_rr_txt}:1**", + f"- 开仓成交价:**{format_price_for_symbol(sym, det['trigger_price'])}**", + f"- **成交价侧计划 RR:**{rr_fill_txt}:1", + f"- 止损:{format_wechat_scalar_2dp(sl_raw)}", + f"- 止盈:{format_price_for_symbol(sym, tp_raw)}", + f"- 风险:{det.get('risk_percent')}%≈{format_wechat_scalar_2dp(det.get('risk_amount_final'))}U|基数 {format_wechat_scalar_2dp(det.get('margin_capital'))}U|杠杆 {det.get('leverage')}x", + f"- 名义 {format_wechat_scalar_2dp(det.get('notional_value'))}U|张数 {format_wechat_scalar_2dp(det.get('amount'))}|折算标的 {det.get('base_amount')}", + f"- **{tpsl_txt}**", + f"- 保本触发:{det.get('breakeven_rr_trigger')}R→{format_price_for_symbol(sym, det.get('breakeven_price'))}", + f"- 当日开仓次数:**{det.get('opens_today_after')}** / {DAILY_OPEN_ALERT_THRESHOLD}(提醒阈值)", + ] + succ_msg_lines.extend(["---", "### 硬条件"] + [f"- {x}" for x in hard_lines]) + if risk_tip: + succ_msg_lines.extend(["---", "### 逆势风险提示", f"- {risk_tip}"]) + succ_msg = "\n".join(succ_msg_lines) + send_wechat_msg(succ_msg) + _finalize_key_monitor_one_shot(conn, r, succ_msg, "auto_opened") + + if det.get("opens_today_before", 0) < DAILY_OPEN_ALERT_THRESHOLD <= det.get("opens_today_after", 0): + advice = ai_short_advice( + f"用户在北京时间交易日 {det['trading_day']} 已累计开仓 {det['opens_today_after']} 次(阈值 {DAILY_OPEN_ALERT_THRESHOLD})。" + f"最新一笔来源为关键位自动单:{sym} {direction},杠杆{det['leverage']}x。" + f"用户自述“上头了”。请给克制提醒。" + ) + if advice: + send_wechat_msg(f"【AI提醒】今日开仓次数已达 {det['opens_today_after']}\n{advice[:800]}") + conn.commit() + conn.close() + +# 止盈止损监控(已修复:严格区分多空,无默认做多) +def check_order_monitors(): + conn = get_db() + rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() + for r in rows: + pid, sym, direction, trigger_price, stop_loss, take_profit = r["id"], r["symbol"], r["direction"], r["trigger_price"], r["stop_loss"], r["take_profit"] + margin_capital = r["margin_capital"] or DAILY_START_CAPITAL + leverage = r["leverage"] or infer_leverage(sym) + session_date = r["session_date"] or get_trading_day() + p = get_price(sym) + if not p: continue + + # 到达设定 R 倍后,按阶梯持续上移止损(本地风控层) + risk_amount = float(r["risk_amount"] or 0) + breakeven_armed = int(r["breakeven_armed"] or 0) + trigger_rr = float(r["breakeven_rr_trigger"] or BREAKEVEN_RR_TRIGGER) + step_r = float(r["breakeven_step_r"] or BREAKEVEN_STEP_R or 1.0) + step_r = 1.0 if step_r <= 0 else step_r + breakeven_enabled = True + try: + if "breakeven_enabled" in r.keys(): + breakeven_enabled = int(r["breakeven_enabled"] or 0) != 0 + except Exception: + breakeven_enabled = True + if breakeven_enabled and risk_amount > 0 and trigger_rr > 0: + now_pnl = calc_pnl(direction, trigger_price, p, margin_capital, leverage) + now_rr = now_pnl / risk_amount + if now_rr >= trigger_rr: + steps = int((now_rr - trigger_rr) // step_r) + locked_r = max(0.0, steps * step_r) + notional = float(margin_capital or 0) * float(leverage or 0) + risk_frac = (risk_amount / notional) if notional > 0 else None + if risk_frac and risk_frac > 0: + new_sl = calc_breakeven_stop( + direction, + trigger_price, + risk_frac, + locked_r=locked_r, + offset_pct=float(r["breakeven_offset_pct"] or BREAKEVEN_OFFSET_PCT), + ) + if new_sl is not None: + should_move = (direction == "short" and new_sl < float(stop_loss)) or ( + direction == "long" and new_sl > float(stop_loss) + ) + if should_move: + was_armed = breakeven_armed + ex_sym = resolve_monitor_exchange_symbol(r) + new_sl = round_price_to_exchange(ex_sym, new_sl) + tp_ex = float(take_profit or 0) + ok_live, _live_reason = ensure_exchange_live_ready() + synced_ex = not ok_live + if ok_live and tp_ex > 0: + try: + replace_active_monitor_tpsl_on_exchange(r, new_sl, tp_ex) + synced_ex = True + _clear_breakeven_exchange_warn(pid) + except Exception as e: + print( + f"[breakeven] exchange tpsl replace failed order={pid} {sym}: {e}", + flush=True, + ) + _send_breakeven_exchange_warn_once( + pid, + f"⚠️ {sym} 移动保本止损未同步交易所:{friendly_exchange_error(e)}", + ) + elif ok_live: + print( + f"[breakeven] skip exchange order={pid} {sym}: invalid take_profit", + flush=True, + ) + if synced_ex: + conn.execute( + "UPDATE order_monitors SET stop_loss=?, breakeven_armed=1, breakeven_price=? WHERE id=?", + (new_sl, new_sl, pid), + ) + stop_loss = new_sl + breakeven_armed = 1 + if not was_armed: + arm_txt = "保本止盈" + be_msg = build_wechat_breakeven_message( + sym, + direction, + arm_txt, + now_rr, + locked_r, + new_sl, + ) + if ok_live: + be_msg += "\n- 交易所:已先撤后挂止盈止损" + send_wechat_msg(be_msg) + + res = None + # 做多 + if direction == "long": + if p >= take_profit: res = "止盈" + elif p <= stop_loss: res = "止损" + # 做空 + elif direction == "short": + if p <= take_profit: res = "止盈" + elif p >= stop_loss: res = "止损" + + if res: + now = app_now() + opened_at = get_opened_at_value(r) + opened_at_ms = (r["opened_at_ms"] if "opened_at_ms" in r.keys() else None) + closed_at = now.strftime("%Y-%m-%d %H:%M:%S") + hold_seconds = calc_hold_seconds(opened_at, now) + pnl_amount = calc_pnl(direction, trigger_price, p, margin_capital, leverage) + if res == "止损" and float(pnl_amount or 0) > 0: + res = "移动止盈" if breakeven_armed else "保本止盈" + else: + res = normalize_result_with_pnl(res, pnl_amount) + close_order_id = "" + exit_p = None + try: + close_resp = close_exchange_order(r) + close_order_id = close_resp.get("id", "") + # 平仓入库优先使用交易所返回成交价;拿不到再回退拉成交明细。 + exit_p = extract_trade_price_from_order(close_resp) + if exit_p and exit_p > 0: + pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) + guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) + if guessed_res: + res = normalize_result_with_pnl(guessed_res, pnl_amount) + else: + res = normalize_result_with_pnl(res, pnl_amount) + else: + ex_sym = r["exchange_symbol"] or normalize_exchange_symbol(sym) + tr = fetch_latest_closing_fill( + ex_sym, + direction, + opened_at, + opened_at_ms=opened_at_ms, + ) + if tr and tr.get("price"): + try: + exit_p = float(tr["price"]) + pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) + guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) + if guessed_res: + if guessed_res == "止损" and float(pnl_amount or 0) > 0: + res = "移动止盈" if breakeven_armed else "保本止盈" + else: + res = normalize_result_with_pnl(guessed_res, pnl_amount) + else: + res = normalize_result_with_pnl(res, pnl_amount) + except (TypeError, ValueError): + pass + ts = tr.get("timestamp") + if ts: + closed_at = ms_to_app_local_str(int(ts)) + hold_seconds = calc_hold_seconds( + opened_at, parse_dt_for_trading_day(closed_at) or now + ) + except Exception as e: + if is_no_position_error(str(e)): + ex_sym = r["exchange_symbol"] or normalize_exchange_symbol(sym) + cancel_binance_futures_open_orders(ex_sym) + tr = fetch_latest_closing_fill( + ex_sym, + direction, + opened_at, + opened_at_ms=opened_at_ms, + ) + if tr and tr.get("price"): + try: + exit_p = float(tr["price"]) + pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) + # 交易所已返回真实成交价时,以真实成交结果为准,避免本地轮询竞态导致误判。 + guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) + if guessed_res: + if guessed_res == "止损" and float(pnl_amount or 0) > 0: + res = "移动止盈" if breakeven_armed else "保本止盈" + else: + res = normalize_result_with_pnl(guessed_res, pnl_amount) + else: + res = normalize_result_with_pnl(res, pnl_amount) + except (TypeError, ValueError): + pass + ts = tr.get("timestamp") + if ts: + closed_at = ms_to_app_local_str(int(ts)) + hold_seconds = calc_hold_seconds( + opened_at, parse_dt_for_trading_day(closed_at) or now + ) + exit_ref = exit_p if exit_p and float(exit_p) > 0 else p + pnl_amount, _, _, _, _ = resolve_trade_pnl_amount( + r, + trigger_price, + exit_ref, + opened_at_str=opened_at, + opened_at_ms=_to_ms_with_fallback(opened_at_ms, opened_at), + closed_at_str=closed_at, + closed_at_ms=_to_ms_with_fallback(None, closed_at), + ) + insert_trade_record( + conn, + symbol=sym, + monitor_type=order_row_monitor_type(r), + key_signal_type=order_row_key_signal_type(r), + direction=direction, + trigger_price=trigger_price, + stop_loss=stop_loss, + initial_stop_loss=r["initial_stop_loss"] or stop_loss, + take_profit=take_profit, + margin_capital=margin_capital, + leverage=leverage, + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=r["trade_style"], + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or stop_loss, take_profit), + actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), + result=res, + miss_reason="触发价已触达,仓位已由交易所止盈/止损或其他方式平掉(本地补记)", + opened_at=opened_at, + closed_at=closed_at, + ) + session_capital = update_session_capital(conn, session_date, pnl_amount) + send_wechat_msg( + build_wechat_close_message( + symbol=sym, + direction=direction, + result=f"{res}(交易所已先行平仓)", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=trigger_price, + current_price=p, + stop_loss=stop_loss, + take_profit=take_profit, + close_order_id="-", + extra_note="本地补记:仓位由交易所止盈/止损或其他方式先行平掉", + session_capital_fallback=session_capital, + ) + ) + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (pid,)) + conn.commit() + continue + conn.execute("UPDATE order_monitors SET status='error' WHERE id=?", (pid,)) + conn.commit() + send_wechat_msg( + build_wechat_monitor_error_message( + symbol=sym, + direction=direction, + scene=f"触发{res}后交易所平仓失败", + error_text=str(e), + ) + ) + continue + cancel_binance_futures_open_orders(r["exchange_symbol"] or normalize_exchange_symbol(sym)) + exit_ref = exit_p if exit_p and float(exit_p) > 0 else p + pnl_amount, _, _, _, _ = resolve_trade_pnl_amount( + r, + trigger_price, + exit_ref, + opened_at_str=opened_at, + opened_at_ms=_to_ms_with_fallback(opened_at_ms, opened_at), + closed_at_str=closed_at, + closed_at_ms=_to_ms_with_fallback(None, closed_at), + ) + session_capital = update_session_capital(conn, session_date, pnl_amount) + send_wechat_msg( + build_wechat_close_message( + symbol=sym, + direction=direction, + result=res, + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=trigger_price, + current_price=p, + stop_loss=stop_loss, + take_profit=take_profit, + close_order_id=close_order_id or "-", + session_capital_fallback=session_capital, + ) + ) + insert_trade_record( + conn, + symbol=sym, + monitor_type=order_row_monitor_type(r), + key_signal_type=order_row_key_signal_type(r), + direction=direction, + trigger_price=trigger_price, + stop_loss=stop_loss, + initial_stop_loss=r["initial_stop_loss"] or stop_loss, + take_profit=take_profit, + margin_capital=margin_capital, + leverage=leverage, + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=r["trade_style"], + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or stop_loss, take_profit), + actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), + result=res, + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, pid)) + clear_key_sizing_snapshot_if_flat(conn, get_trading_day()) + conn.commit() + conn.close() + + +def force_close_before_reset(): + if not FORCE_CLOSE_ENABLED: + return + now = app_now() + # 每天北京时间指定整点小时内执行一次性兜底清仓(默认 00:xx) + if now.hour != FORCE_CLOSE_BJ_HOUR: + return + conn = get_db() + rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() + for r in rows: + p = get_price(r["symbol"]) + if not p: + continue + direction = r["direction"] + trigger_price = r["trigger_price"] + margin_capital = r["margin_capital"] or DAILY_START_CAPITAL + leverage = r["leverage"] or infer_leverage(r["symbol"]) + session_date = r["session_date"] or get_trading_day(now) + opened_at = get_opened_at_value(r) + closed_at = now.strftime("%Y-%m-%d %H:%M:%S") + hold_seconds = calc_hold_seconds(opened_at, now) + pnl_amount = calc_pnl(direction, trigger_price, p, margin_capital, leverage) + try: + close_resp = close_exchange_order(r) + close_order_id = close_resp.get("id", "") + cancel_binance_futures_open_orders(r["exchange_symbol"] or normalize_exchange_symbol(r["symbol"])) + except Exception as e: + conn.execute("UPDATE order_monitors SET status='error' WHERE id=?", (r["id"],)) + conn.commit() + send_wechat_msg( + build_wechat_monitor_error_message( + symbol=r["symbol"], + direction=direction, + scene="强制清仓失败", + error_text=str(e), + ) + ) + continue + session_capital = update_session_capital(conn, session_date, pnl_amount) + insert_trade_record( + conn, + symbol=r["symbol"], + monitor_type=order_row_monitor_type(r), + key_signal_type=order_row_key_signal_type(r), + direction=direction, + trigger_price=trigger_price, + stop_loss=r["stop_loss"], + initial_stop_loss=r["initial_stop_loss"] or r["stop_loss"], + take_profit=r["take_profit"], + margin_capital=margin_capital, + leverage=leverage, + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=r["trade_style"], + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]), + actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), + result="强制清仓", + miss_reason=f"北京时间 {FORCE_CLOSE_BJ_HOUR}:00 整点风控清仓", + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, r["id"])) + send_wechat_msg( + build_wechat_close_message( + symbol=r["symbol"], + direction=direction, + result="强制清仓", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=trigger_price, + current_price=p, + stop_loss=r["stop_loss"], + take_profit=r["take_profit"], + close_order_id=close_order_id or "-", + extra_note=f"北京时间 {FORCE_CLOSE_BJ_HOUR}:00 整点风控清仓", + session_capital_fallback=session_capital, + ) + ) + conn.commit() + conn.close() + +# 后台线程 +def background_task(): + while True: + if not license_lib.is_licensed(): + time.sleep(MONITOR_POLL_SECONDS) + continue + try: + auto_transfer_once_per_day() + conn = get_db() + reconcile_external_closes(conn) + conn.commit() + conn.close() + force_close_before_reset() + check_fib_key_monitors() + check_key_monitors() + check_order_monitors() + except: + pass + time.sleep(MONITOR_POLL_SECONDS) + + +# ====================== 登录路由 ====================== +@app.route("/login", methods=["GET", "POST"]) +def login(): + if AUTH_DISABLED: + session["logged_in"] = True + return redirect("/") + if request.method == "POST": + username = request.form.get("username") + password = request.form.get("password") + if username == USERNAME and password == PASSWORD: + session["logged_in"] = True + return redirect("/") + else: + flash("账号或密码错误") + return render_template("login.html", exchange_display=EXCHANGE_DISPLAY_NAME) + +@app.route("/logout") +def logout(): + session.clear() + return redirect("/" if AUTH_DISABLED else "/login") + +# 登录校验装饰器 +def login_required(f): + @wraps(f) + def decorated(*args, **kwargs): + if AUTH_DISABLED: + return f(*args, **kwargs) + if not session.get("logged_in"): + return redirect("/login") + return f(*args, **kwargs) + return decorated + + +@app.route("/sync_positions") +@login_required +def sync_positions(): + days_raw = (request.args.get("days") or "").strip() + sync_days = None + if days_raw: + try: + sync_days = max(1, min(365, int(days_raw))) + except Exception: + sync_days = None + conn = get_db() + synced = reconcile_external_closes(conn, days=sync_days) + conn.commit() + conn.close() + if sync_days is not None: + flash(f"同步完成:最近 {sync_days} 天内 {synced} 笔持仓已按交易所状态更新") + else: + flash(f"同步完成:{synced} 笔持仓已按交易所状态更新") + return redirect("/") + + +@app.route("/api/sync_positions", methods=["POST"]) +@login_required +def api_sync_positions(): + payload = request.get_json(silent=True) or {} + days_raw = str(payload.get("days", "")).strip() + if not days_raw: + return jsonify({"ok": False, "msg": "请填写天数"}), 400 + try: + days = int(days_raw) + except Exception: + return jsonify({"ok": False, "msg": "天数必须是整数"}), 400 + if days < 1 or days > 365: + return jsonify({"ok": False, "msg": "天数范围 1-365"}), 400 + conn = get_db() + synced = reconcile_external_closes(conn, days=days) + conn.commit() + conn.close() + return jsonify({"ok": True, "days": days, "synced": int(synced)}) + + +def _coerce_ts_ms(val): + if val is None or val == "": + return None + try: + v = float(val) + except (TypeError, ValueError): + return None + if v > 1e12: + return int(v) + if v > 1e9: + return int(v * 1000.0) + return int(v * 1000.0) + + +def _fetch_binance_income_entries(exchange_symbol, start_ms, end_ms): + if not hasattr(exchange, "fapiPrivateGetIncome"): + return [] + ensure_markets_loaded() + market = exchange.market(exchange_symbol) + contract_id = market.get("id") + if not contract_id: + return [] + out = [] + cursor = int(start_ms) + end_ms = int(end_ms) + for _ in range(20): + try: + batch = exchange.fapiPrivateGetIncome( + {"symbol": contract_id, "startTime": cursor, "endTime": end_ms, "limit": 1000} + ) + except Exception: + break + if not batch: + break + out.extend(batch) + if len(batch) < 1000: + break + last_t = _coerce_ts_ms(batch[-1].get("time")) + if last_t is None or last_t >= end_ms: + break + cursor = last_t + 1 + return out + + +def fetch_binance_net_pnl_for_trade( + exchange_symbol, direction, open_ms, close_ms, closing_trades=None +): + if open_ms is None or close_ms is None or close_ms < open_ms: + return None, None, None, None + if closing_trades: + closing_trades = _cluster_closing_trades_near_close(closing_trades, int(close_ms)) + trade_ids = _trade_ids_from_fills(closing_trades) if closing_trades else None + buffer_ms = 3 * 60 * 1000 if trade_ids else 5 * 60 * 1000 + entries = _fetch_binance_income_entries( + exchange_symbol, max(0, int(open_ms) - buffer_ms), int(close_ms) + buffer_ms + ) + ensure_markets_loaded() + market = exchange.market(exchange_symbol) + cid = market.get("id") or exchange_symbol + + def _pack(net, first_t, last_t, prefix): + if net is None: + return None + sk = f"{prefix}|{cid}|{direction}|{open_ms}|{close_ms}|{net}" + eo = ms_to_app_local_str(first_t) if first_t else None + ec = ms_to_app_local_str(last_t) if last_t else None + return net, sk, eo, ec + + if entries and trade_ids: + net, ft, lt = _sum_binance_income(entries, BINANCE_APP_PNL_INCOME_WITH_FEE, trade_ids) + out = _pack(net, ft, lt, "income_net") + if out: + return out + net, ft, lt = _sum_binance_income(entries, BINANCE_APP_PNL_INCOME_TYPES, trade_ids) + out = _pack(net, ft, lt, "income_rp") + if out: + return out + + if closing_trades: + trade_pnl = calc_binance_realized_pnl_from_trades(closing_trades) + if trade_pnl is not None: + fts = [_coerce_ts_ms(t.get("timestamp")) for t in closing_trades] + fts = [x for x in fts if x] + ft = min(fts) if fts else None + lt = max(fts) if fts else None + out = _pack(trade_pnl, ft, lt, "trades_rp") + if out: + return out + + if entries: + loose_types = ( + BINANCE_NET_INCOME_TYPES + if BINANCE_PNL_INCLUDE_FUNDING + else BINANCE_APP_PNL_INCOME_WITH_FEE + ) + net, ft, lt = _sum_binance_income(entries, loose_types, trade_ids if trade_ids else None) + out = _pack(net, ft, lt, "income") + if out: + return out + + return None, None, None, None + + +# ====================== 主页面 ====================== +def render_main_page(page="trade"): + now = app_now() + trading_day = get_trading_day(now) + list_window = _list_window_from_request() + start_bj, end_bj = utc_window_to_bj_sql_strings(list_window["start_utc"], list_window["end_utc"], APP_TZ) + conn = get_db() + session_row = ensure_session(conn, trading_day) + local_current_capital = float(session_row["current_capital"]) + funding_capital, trading_capital = get_exchange_capitals() + # 资金账户:仅展示交易所读取结果(含 0)。不可用 TOTAL_CAPITAL 兜底,否则会与实盘不符。 + funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None + current_capital = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else round(local_current_capital, FUNDS_DECIMALS) + recommended_capital = get_recommended_capital(current_capital) + key_list = conn.execute("SELECT * FROM key_monitors").fetchall() + key_history = conn.execute( + "SELECT * FROM key_monitor_history WHERE closed_at >= ? AND closed_at <= ? ORDER BY id DESC LIMIT 500", + (start_bj, end_bj), + ).fetchall() + stats_bundle = compute_stats_bundle(conn, trading_day, now) + raw_order_list = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() + order_list = [] + for o in raw_order_list: + order_list.append(enrich_order_item(row_to_dict(o), current_capital)) + raw_records = conn.execute( + "SELECT * FROM trade_records WHERE COALESCE(closed_at, created_at, opened_at) >= ? " + "AND COALESCE(closed_at, created_at, opened_at) <= ? ORDER BY id DESC LIMIT 1000", + (start_bj, end_bj), + ).fetchall() + records = [to_effective_trade_dict(r) for r in raw_records] + total = len(records) + miss_count = sum(1 for r in records if (r.get("effective_result") or "") == "错过") + win = sum(1 for r in records if (r.get("effective_result") or "") in ("止盈", "保本止盈", "移动止盈")) + occupied_miss_total = sum( + 1 + for r in records + if (r.get("effective_result") or "") == "错过" + and ("持仓占用" in str(r.get("effective_miss_reason") or "")) + ) + rate = round(win/total*100,2) if total else 0 + active_count = len(order_list) + can_trade = trading_day_reset_allows_new_open(now) and active_count < MAX_ACTIVE_POSITIONS + key_gate_rule_text = ( + f"周期 {KLINE_TIMEFRAME}|确认K:突破棒偏移 {KEY_CONFIRM_BREAKOUT_BAR}、确认棒偏移 {KEY_CONFIRM_BAR}|" + f"量能:突破量 > 前{KEY_VOLUME_MA_BARS}均量×{KEY_VOLUME_RATIO_MIN}|" + f"自动开仓盈亏比 > {KEY_AUTO_MIN_PLANNED_RR}:1|日成交量排名前 {KEY_DAILY_VOLUME_RANK_MAX}|" + f"箱体/收敛可选 SL/TP 方案(标准 / 箱体1R·止盈1.5H / 趋势单+自填止盈)|移动保本默认关|" + f"斐波:限价 @ E(SL/TP 为 H/L),可选移动保本|趋势止损外侧 {KEY_TREND_STOP_OUTSIDE_PCT}%" + ) + conn.close() + return render_template( + "index.html", + page=page, + key=key_list, + key_history=key_history, + stats_bundle=stats_bundle, + order=order_list, + record=records, + total=total, + miss_count=miss_count, + rate=rate, + trading_day=trading_day, + funding_usdt=funding_usdt, + daily_start_capital=DAILY_START_CAPITAL, + current_capital=current_capital, + recommended_capital=recommended_capital, + btc_leverage=BTC_LEVERAGE, + alt_leverage=ALT_LEVERAGE, + reset_hour=TRADING_DAY_RESET_HOUR, + balance_refresh_seconds=BALANCE_REFRESH_SECONDS, + auto_transfer_enabled=AUTO_TRANSFER_ENABLED, + auto_transfer_amount=AUTO_TRANSFER_AMOUNT, + auto_transfer_from=AUTO_TRANSFER_FROM, + auto_transfer_to=AUTO_TRANSFER_TO, + auto_transfer_bj_hour=AUTO_TRANSFER_BJ_HOUR, + full_margin_buffer_ratio=FULL_MARGIN_BUFFER_RATIO, + price_refresh_seconds=PRICE_REFRESH_SECONDS, + active_count=active_count, + can_trade=can_trade, + focus_key_id=(key_list[0]["id"] if key_list else None), + focus_order_id=(order_list[0]["id"] if order_list else None), + data_export_version=3, + list_window=list_window, + list_window_presets={ + "utc_today": PRESET_UTC_TODAY, + "utc_last24h": PRESET_UTC_LAST24H, + "utc_last7d": PRESET_UTC_LAST7D, + "custom": PRESET_CUSTOM, + }, + key_alert_max_times=KEY_ALERT_MAX_TIMES, + risk_percent=RISK_PERCENT, + breakeven_rr_trigger=BREAKEVEN_RR_TRIGGER, + breakeven_offset_pct=BREAKEVEN_OFFSET_PCT, + occupied_miss_total=occupied_miss_total, + price_fmt=format_price_for_symbol, + funds_fmt=format_funds_u, + entry_reason_options=list(ENTRY_REASON_OPTIONS), + entry_reason_other_value=ENTRY_REASON_OTHER, + exchange_display=EXCHANGE_DISPLAY_NAME, + max_active_positions=MAX_ACTIVE_POSITIONS, + manual_min_planned_rr=MANUAL_MIN_PLANNED_RR, + key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR, + key_gate_rule_text=key_gate_rule_text, + kline_timeframe=KLINE_TIMEFRAME, + ) + + +@app.route("/") +@login_required +def index(): + return redirect("/trade") + + +@app.route("/key_monitor") +@login_required +def key_monitor_page(): + return render_main_page("key_monitor") + + +@app.route("/trade") +@login_required +def trade_page(): + return render_main_page("trade") + + +@app.route("/records") +@login_required +def records_page(): + return render_main_page("records") + + +@app.route("/stats") +@login_required +def stats_page(): + return render_main_page("stats") + + +@app.route("/api/account_snapshot") +@login_required +def api_account_snapshot(): + now = app_now() + trading_day = get_trading_day(now) + conn = get_db() + session_row = ensure_session(conn, trading_day) + local_current_capital = float(session_row["current_capital"]) + funding_capital, trading_capital = get_exchange_capitals(force=True) + funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None + current_capital = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else round(local_current_capital, FUNDS_DECIMALS) + recommended_capital = get_recommended_capital(current_capital) + active_count = get_active_position_count(conn) + conn.close() + can_trade = trading_day_reset_allows_new_open(now) and active_count < MAX_ACTIVE_POSITIONS + available_trading_usdt = get_available_trading_usdt() + return jsonify({ + "funding_usdt": funding_usdt, + "current_capital": current_capital, + "available_trading_usdt": round(available_trading_usdt, FUNDS_DECIMALS) if available_trading_usdt is not None else None, + "recommended_capital": recommended_capital, + "active_count": active_count, + "max_active_positions": MAX_ACTIVE_POSITIONS, + "can_trade": can_trade, + "manual_min_planned_rr": MANUAL_MIN_PLANNED_RR, + "trading_day": trading_day + }) + + +@app.route("/api/price_snapshot") +@login_required +def api_price_snapshot(): + conn = get_db() + key_rows = conn.execute( + "SELECT id,symbol,monitor_type,direction,upper,lower,fib_entry_price,fib_limit_order_id FROM key_monitors" + ).fetchall() + order_rows = conn.execute( + "SELECT id,symbol,exchange_symbol,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage FROM order_monitors WHERE status='active'" + ).fetchall() + conn.close() + + symbol_set = set() + for r in key_rows: + symbol_set.add(r["symbol"]) + for r in order_rows: + symbol_set.add(r["symbol"]) + + prices = {} + for s in symbol_set: + p = get_price(s) + if p is not None: + prices[s] = float(p) + + all_swap_positions = [] + if exchange_private_api_configured(): + try: + ensure_markets_loaded() + all_swap_positions = exchange.fetch_positions() or [] + except Exception: + all_swap_positions = [] + + key_prices = [] + for r in key_rows: + is_fib = is_fib_key_monitor_type(r["monitor_type"]) + if is_fib: + price = get_symbol_mark_price(r["symbol"]) + else: + price = prices.get(r["symbol"]) + if price is None: + continue + upper_diff, upper_pct = calc_price_diff_pct(price, r["upper"]) + lower_diff, lower_pct = calc_price_diff_pct(price, r["lower"]) + gate = None + gate_summary = "-" + gate_metrics = "" + fib_gate_ok = True + if is_fib: + direction = (r["direction"] or "long").lower() + inval = fib_invalidate_by_mark(direction, price, r["upper"], r["lower"]) + fib_gate_ok = not inval + entry = _sqlite_row_val(r, "fib_entry_price") + entry_txt = format_price_for_symbol(r["symbol"], entry) if entry else "-" + gate_summary = f"斐波 挂E={entry_txt} {'标记价将失效' if inval else '等待成交'}" + if _sqlite_row_val(r, "fib_limit_order_id"): + gate_metrics = f"限价单:{_sqlite_row_val(r, 'fib_limit_order_id')}" + else: + try: + gate = _key_hard_checks(r["symbol"], (r["direction"] or "long").lower(), r["upper"], r["lower"], r["monitor_type"]) + except Exception: + gate = None + if gate: + rank_seg = "ERR" if int(gate.get("rank_total") or 0) <= 0 else f"{gate.get('rank')}/{gate.get('rank_total')}" + gate_summary = ( + f"量:{'Y' if gate.get('vol_ok') else 'N'} " + f"破:{'Y' if gate.get('breakout_ok') else 'N'} " + f"幅:{'Y' if gate.get('amp_ok') else 'N'} " + f"二确:{'Y' if gate.get('confirm_ok') else 'N'} " + f"排:{'Y' if gate.get('rank_ok') else 'N'}({rank_seg})" + ) + if gate.get("breakout_ok"): + try: + vol_now = round(float(gate.get("vol_break") or 0), 4) + vol_avg = round(float(gate.get("avg20") or 0), 4) + amp_pct = round(float(gate.get("amp_pct") or 0), 4) + cfm_close = float(gate.get("confirm_close") or 0) + edge = float(gate.get("edge_price") or 0) + gate_metrics = ( + f"量值:{vol_now}/{vol_avg} " + f"幅值:{amp_pct}% " + f"二确值:{format_price_for_symbol(r['symbol'], cfm_close)}@{format_price_for_symbol(r['symbol'], edge)}" + ) + except Exception: + gate_metrics = "" + sym_k = r["symbol"] + key_prices.append({ + "id": r["id"], + "symbol": sym_k, + "price": round(price, 6), + "price_display": format_price_for_symbol(sym_k, price), + "upper_diff": upper_diff, + "upper_pct": upper_pct, + "lower_diff": lower_diff, + "lower_pct": lower_pct, + "gate_summary": gate_summary, + "gate_ok": fib_gate_ok if is_fib else bool(gate and gate.get("ok")), + "gate_metrics": gate_metrics, + }) + + order_prices = [] + for r in order_rows: + price = prices.get(r["symbol"]) + if price is None: + continue + margin = float(r["margin_capital"] or 0) + leverage = float(r["leverage"] or 0) + entry = float(r["trigger_price"] or 0) + pnl = calc_pnl(r["direction"], entry, price, margin, leverage) if entry > 0 else 0 + pnl_pct = round((pnl / margin * 100), 2) if margin > 0 else 0 + rr_ratio = calc_rr_ratio(r["direction"], entry, r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]) + ex_sym = resolve_monitor_exchange_symbol(r) + prow = _select_live_position_row(all_swap_positions, ex_sym, r["direction"]) + lev_row = r["leverage"] if "leverage" in r.keys() else None + ex_metrics = parse_ccxt_position_metrics(prow, order_leverage=lev_row) if prow else None + payload = { + "id": r["id"], + "symbol": r["symbol"], + "price": round(price, 6), + "price_display": format_price_for_symbol(ex_sym, price), + "float_pnl": round(pnl, FUNDS_DECIMALS), + "float_pct": pnl_pct, + "rr_ratio": rr_ratio, + "plan_margin": round(margin, FUNDS_DECIMALS) if margin else None, + "exchange_initial_margin": None, + "exchange_notional": None, + "exchange_mark_price": None, + "exchange_mark_price_display": None, + "pnl_source": "plan", + } + if ex_metrics: + if ex_metrics.get("initial_margin") is not None: + payload["exchange_initial_margin"] = ex_metrics["initial_margin"] + if ex_metrics.get("notional") is not None: + payload["exchange_notional"] = ex_metrics["notional"] + if ex_metrics.get("mark_price") is not None: + mp = ex_metrics["mark_price"] + payload["exchange_mark_price"] = mp + payload["exchange_mark_price_display"] = format_price_for_symbol(ex_sym, mp) + if ex_metrics.get("unrealized_pnl") is not None: + payload["float_pnl"] = round(float(ex_metrics["unrealized_pnl"]), FUNDS_DECIMALS) + payload["pnl_source"] = "exchange" + denom = ex_metrics.get("initial_margin") or margin + payload["float_pct"] = ( + round((payload["float_pnl"] / float(denom)) * 100, 2) if denom and float(denom) > 0 else pnl_pct + ) + if exchange_private_api_configured(): + try: + payload["exchange_tpsl"] = fetch_exchange_tpsl_slots(ex_sym, r["direction"]) + except Exception: + payload["exchange_tpsl"] = {"sl": None, "tp": None} + else: + payload["exchange_tpsl"] = {"sl": None, "tp": None} + order_prices.append(payload) + + return jsonify({ + "updated_at": app_now_str(), + "key_prices": key_prices, + "order_prices": order_prices, + "positions_raw_count": len(all_swap_positions), + }) + + +@app.route("/api/order//cancel_tpsl", methods=["POST"]) +@login_required +def api_order_cancel_tpsl(order_id): + data = request.get_json(silent=True) or {} + role = (data.get("role") or "").strip().lower() + if role not in ("sl", "tp"): + return jsonify({"ok": False, "msg": "role 须为 sl 或 tp"}), 400 + conn = get_db() + row = conn.execute( + "SELECT * FROM order_monitors WHERE id=? AND status='active'", + (order_id,), + ).fetchone() + conn.close() + if not row: + return jsonify({"ok": False, "msg": "持仓不存在或已结束"}), 404 + ok, reason = ensure_exchange_live_ready() + if not ok: + return jsonify({"ok": False, "msg": reason}), 400 + ex_sym = resolve_monitor_exchange_symbol(row) + slots = fetch_exchange_tpsl_slots(ex_sym, row["direction"]) + slot = slots.get(role) + if not slot: + return jsonify({"ok": False, "msg": f"交易所未找到{'止损' if role == 'sl' else '止盈'}委托"}), 404 + try: + cancel_binance_tpsl_slot(ex_sym, slot) + return jsonify({"ok": True, "msg": "已撤单", "exchange_tpsl": fetch_exchange_tpsl_slots(ex_sym, row["direction"])}) + except Exception as e: + return jsonify({"ok": False, "msg": friendly_exchange_error(e)}), 400 + + +@app.route("/api/order//place_tpsl", methods=["POST"]) +@login_required +def api_order_place_tpsl(order_id): + data = request.get_json(silent=True) or {} + conn = get_db() + row = conn.execute( + "SELECT * FROM order_monitors WHERE id=? AND status='active'", + (order_id,), + ).fetchone() + if not row: + conn.close() + return jsonify({"ok": False, "msg": "持仓不存在或已结束"}), 404 + symbol = row["symbol"] + direction = row["direction"] + live_price = get_price(symbol) + if live_price is None: + conn.close() + return jsonify({"ok": False, "msg": "获取交易所实时价格失败"}), 400 + try: + sltp_mode = (data.get("sltp_mode") or "price").strip().lower() + stop_loss, take_profit = _resolve_tpsl_prices_for_manual(direction, live_price, sltp_mode, data) + except Exception as e: + conn.close() + return jsonify({"ok": False, "msg": str(e)}), 400 + planned_rr = calc_rr_ratio(direction, live_price, stop_loss, take_profit) + if planned_rr is None or planned_rr < MANUAL_MIN_PLANNED_RR: + conn.close() + rr_txt = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算" + return jsonify( + { + "ok": False, + "msg": f"计划盈亏比 {rr_txt}:1 低于最低要求 {MANUAL_MIN_PLANNED_RR}:1", + } + ), 400 + try: + replace_active_monitor_tpsl_on_exchange(row, stop_loss, take_profit) + except Exception as e: + conn.close() + return jsonify({"ok": False, "msg": friendly_exchange_error(e)}), 400 + conn.execute( + "UPDATE order_monitors SET stop_loss=?, take_profit=? WHERE id=?", + (stop_loss, take_profit, order_id), + ) + conn.commit() + ex_sym = resolve_monitor_exchange_symbol(row) + slots = fetch_exchange_tpsl_slots(ex_sym, direction) + conn.close() + return jsonify( + { + "ok": True, + "msg": "已先撤后挂止盈止损", + "stop_loss": stop_loss, + "take_profit": take_profit, + "planned_rr": planned_rr, + "exchange_tpsl": slots, + } + ) + + +@app.route("/api/symbol_liquidity_rank") +@login_required +def api_symbol_liquidity_rank(): + symbol = normalize_symbol_input(request.args.get("symbol")) + if not symbol: + return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400 + rank, total = _daily_volume_rank(symbol) + if total <= 0: + return jsonify({"ok": False, "msg": "日成交量排名读取失败"}), 502 + if rank is None: + return jsonify({"ok": True, "symbol": symbol, "rank": None, "total": int(total), "in_top30": False}) + return jsonify( + { + "ok": True, + "symbol": symbol, + "rank": int(rank), + "total": int(total), + "in_top30": bool(rank <= KEY_DAILY_VOLUME_RANK_MAX), + "rank_max": KEY_DAILY_VOLUME_RANK_MAX, + } + ) + + +@app.route("/api/order_defaults") +@login_required +def api_order_defaults(): + symbol = normalize_symbol_input(request.args.get("symbol")) + direction = (request.args.get("direction") or "long").strip().lower() + if not symbol: + return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400 + if direction not in ("long", "short"): + direction = "long" + exchange_symbol = normalize_exchange_symbol(symbol) + leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) + available = get_available_trading_usdt() + last_price = get_price(symbol) + return jsonify({ + "ok": True, + "symbol": symbol, + "exchange_symbol": exchange_symbol, + "direction": direction, + "leverage": leverage, + "available_trading_usdt": round(available, FUNDS_DECIMALS) if available is not None else None, + "last_price": round(float(last_price), 8) if last_price is not None else None, + }) + + +@app.route("/order_focus") +@login_required +def order_focus(): + now = app_now() + trading_day = get_trading_day(now) + conn = get_db() + session_row = ensure_session(conn, trading_day) + local_current_capital = float(session_row["current_capital"]) + _, trading_capital_live = get_exchange_capitals() + current_capital = round(trading_capital_live, FUNDS_DECIMALS) if trading_capital_live is not None else round(local_current_capital, FUNDS_DECIMALS) + raw_orders = conn.execute("SELECT * FROM order_monitors WHERE status='active' ORDER BY id DESC").fetchall() + conn.close() + orders = [enrich_order_item(row_to_dict(r), current_capital) for r in raw_orders] + picked_id = request.args.get("order_id", "").strip() + selected = None + if picked_id.isdigit(): + selected = next((o for o in orders if int(o["id"]) == int(picked_id)), None) + if selected is None and orders: + selected = orders[0] + return render_template( + "order_focus_v2.html", + orders=orders, + selected_order=selected, + default_timeframe=KLINE_TIMEFRAME, + price_refresh_seconds=PRICE_REFRESH_SECONDS, + exchange_display=EXCHANGE_DISPLAY_NAME, + ) + + +@app.route("/api/order_kline") +@login_required +def api_order_kline(): + order_id_raw = (request.args.get("order_id") or "").strip() + if not order_id_raw.isdigit(): + return jsonify({"ok": False, "msg": "order_id 无效"}), 400 + order_id = int(order_id_raw) + timeframe = (request.args.get("timeframe") or KLINE_TIMEFRAME).strip() + allowed_tfs = {"1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d"} + if timeframe not in allowed_tfs: + timeframe = KLINE_TIMEFRAME + limit = 100 + + now = app_now() + trading_day = get_trading_day(now) + conn = get_db() + session_row = ensure_session(conn, trading_day) + local_current_capital = float(session_row["current_capital"]) + _, trading_capital_live = get_exchange_capitals() + current_capital = round(trading_capital_live, FUNDS_DECIMALS) if trading_capital_live is not None else round(local_current_capital, FUNDS_DECIMALS) + row = conn.execute("SELECT * FROM order_monitors WHERE id=? AND status='active'", (order_id,)).fetchone() + conn.close() + if not row: + return jsonify({"ok": False, "msg": "订单不存在或已结束"}), 404 + + order_item = enrich_order_item(row_to_dict(row), current_capital) + exchange_symbol = order_item.get("exchange_symbol") or normalize_exchange_symbol(order_item["symbol"]) + try: + ensure_markets_loaded() + ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=limit) + except Exception as e: + return jsonify({"ok": False, "msg": f"K线加载失败:{friendly_exchange_error(e)}"}), 500 + + candles = [] + for bar in ohlcv or []: + if not bar or len(bar) < 6: + continue + ts = int(bar[0] // 1000) + candles.append({ + "time": ts, + "open": float(bar[1]), + "high": float(bar[2]), + "low": float(bar[3]), + "close": float(bar[4]), + "volume": float(bar[5]), + }) + + current_price = get_price(order_item["symbol"]) + margin = float(order_item.get("margin_capital") or 0) + leverage = float(order_item.get("leverage") or 0) + entry = float(order_item.get("trigger_price") or 0) + float_pnl = calc_pnl(order_item.get("direction") or "long", entry, current_price, margin, leverage) if current_price else 0 + float_pct = round((float_pnl / margin * 100), 2) if margin > 0 else 0 + + return jsonify({ + "ok": True, + "timeframe": timeframe, + "limit": limit, + "order": { + "id": order_item["id"], + "symbol": order_item["symbol"], + "direction": order_item.get("direction") or "long", + "trigger_price": order_item.get("trigger_price"), + "stop_loss": order_item.get("stop_loss"), + "take_profit": order_item.get("take_profit"), + "trigger_price_display": format_price_for_symbol(exchange_symbol, order_item.get("trigger_price")), + "stop_loss_display": format_price_for_symbol(exchange_symbol, order_item.get("stop_loss")), + "take_profit_display": format_price_for_symbol(exchange_symbol, order_item.get("take_profit")), + "margin_capital": order_item.get("margin_capital"), + "leverage": order_item.get("leverage"), + "position_ratio": order_item.get("position_ratio"), + "rr_ratio": order_item.get("rr_ratio"), + "breakeven_enabled": bool(int(order_item.get("breakeven_enabled") or 0)), + "current_price": round(float(current_price), 8) if current_price else None, + "current_price_display": format_price_for_symbol(exchange_symbol, current_price) if current_price else None, + "float_pnl": round(float(float_pnl), FUNDS_DECIMALS), + "float_pct": float_pct, + }, + "candles": candles, + "updated_at": app_now_str(), + }) + + +@app.route("/key_focus") +@login_required +def key_focus(): + conn = get_db() + key_rows = conn.execute("SELECT * FROM key_monitors ORDER BY id DESC").fetchall() + conn.close() + key_list = [row_to_dict(r) for r in key_rows] + + key_id_raw = (request.args.get("key_id") or "").strip() + symbol_query = normalize_symbol_input(request.args.get("symbol")) + selected_key = None + if key_id_raw.isdigit(): + selected_key = next((k for k in key_list if int(k["id"]) == int(key_id_raw)), None) + if selected_key is None and symbol_query: + selected_key = next((k for k in key_list if (k.get("symbol") or "").upper() == symbol_query), None) + if selected_key is None and key_list: + selected_key = key_list[0] + default_symbol = symbol_query or ((selected_key or {}).get("symbol")) or "BTC/USDT" + return render_template( + "key_focus_v2.html", + key_list=key_list, + selected_key=selected_key, + default_symbol=default_symbol, + default_timeframe=KLINE_TIMEFRAME, + default_kline_limit=200, + price_refresh_seconds=PRICE_REFRESH_SECONDS, + exchange_display=EXCHANGE_DISPLAY_NAME, + ) + + +@app.route("/api/key_kline") +@login_required +def api_key_kline(): + key_id_raw = (request.args.get("key_id") or "").strip() + symbol_input = normalize_symbol_input(request.args.get("symbol")) + timeframe = (request.args.get("timeframe") or KLINE_TIMEFRAME).strip() + if timeframe not in {"1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d"}: + timeframe = KLINE_TIMEFRAME + limit = normalize_kline_limit(request.args.get("limit"), default=200) + + conn = get_db() + key_row = None + if key_id_raw.isdigit(): + key_row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (int(key_id_raw),)).fetchone() + if key_row is None and symbol_input: + key_row = conn.execute( + "SELECT * FROM key_monitors WHERE upper(symbol)=? ORDER BY id DESC LIMIT 1", + (symbol_input,), + ).fetchone() + if key_row is not None: + symbol = (key_row["symbol"] or "").upper() + else: + symbol = symbol_input + conn.close() + if not symbol: + return jsonify({"ok": False, "msg": "请先输入币种或选择关键位"}), 400 + + exchange_symbol = normalize_exchange_symbol(symbol) + try: + ensure_markets_loaded() + ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=limit) + except Exception as e: + return jsonify({"ok": False, "msg": f"K线加载失败:{friendly_exchange_error(e)}"}), 500 + + candles = [] + for bar in ohlcv or []: + if not bar or len(bar) < 6: + continue + candles.append({ + "time": int(bar[0] // 1000), + "open": float(bar[1]), + "high": float(bar[2]), + "low": float(bar[3]), + "close": float(bar[4]), + "volume": float(bar[5]), + }) + + current_price = get_price(symbol) + key_info = None + if key_row is not None: + upper = float(key_row["upper"]) if key_row["upper"] is not None else None + lower = float(key_row["lower"]) if key_row["lower"] is not None else None + upper_diff, upper_pct = calc_price_diff_pct(current_price, upper) if current_price else (None, None) + lower_diff, lower_pct = calc_price_diff_pct(current_price, lower) if current_price else (None, None) + key_info = { + "id": key_row["id"], + "monitor_type": key_row["monitor_type"], + "direction": key_row["direction"] or "long", + "upper": upper, + "lower": lower, + "upper_display": format_price_for_symbol(exchange_symbol, upper) if upper is not None else None, + "lower_display": format_price_for_symbol(exchange_symbol, lower) if lower is not None else None, + "notification_count": int(key_row["notification_count"] or 0), + "upper_diff": upper_diff, + "upper_pct": upper_pct, + "lower_diff": lower_diff, + "lower_pct": lower_pct, + } + + return jsonify({ + "ok": True, + "symbol": symbol, + "timeframe": timeframe, + "limit": limit, + "current_price": round(float(current_price), 8) if current_price is not None else None, + "current_price_display": format_price_for_symbol(exchange_symbol, current_price) if current_price is not None else None, + "key_monitor": key_info, + "candles": candles, + "updated_at": app_now_str(), + }) + + +@app.route("/add_key", methods=["POST"]) +@login_required +def add_key(): + d = request.form + symbol = normalize_symbol_input(d.get("symbol")) + if not symbol: + flash("symbol 不能为空") + return redirect("/key_monitor") + direction_sel = (d.get("direction") or "").strip().lower() + if direction_sel not in ("long", "short"): + flash("请选择做多或做空") + return redirect("/key_monitor") + mt = (d.get("type") or "").strip() + allowed_types = ( + tuple(KEY_MONITOR_AUTO_TYPES) + + tuple(KEY_MONITOR_ALERT_ONLY_TYPES) + + tuple(FIB_KEY_MONITOR_TYPES) + ) + if mt not in allowed_types: + flash("监控类型无效") + return redirect("/key_monitor") + rank, total = _daily_volume_rank(symbol) + if rank is None: + flash("日成交量排名读取失败,请稍后重试") + return redirect("/key_monitor") + if rank > KEY_DAILY_VOLUME_RANK_MAX: + flash(f"{symbol} 当前日成交量排名为 {rank}/{total},不在前{KEY_DAILY_VOLUME_RANK_MAX},已拒绝添加关键位") + return redirect("/key_monitor") + conn = get_db() + if mt in KEY_MONITOR_AUTO_TYPES: + occupied = get_active_position_count(conn) + if occupied >= MAX_ACTIVE_POSITIONS: + conn.close() + flash( + f"当前持仓已达上限({occupied}/{MAX_ACTIVE_POSITIONS}):无法添加「箱体突破 / 收敛突破」。" + "请平仓后再试,或使用「关键阻力位/关键支撑位」(仅单次提醒)。" + ) + return redirect("/key_monitor") + ex_sym_key = normalize_exchange_symbol(symbol) + try: + ensure_markets_loaded() + except Exception: + pass + uh = round_price_to_exchange(ex_sym_key, float(d["upper"])) + lw = round_price_to_exchange(ex_sym_key, float(d["lower"])) + upper_px = float(uh) if uh is not None else float(d["upper"]) + lower_px = float(lw) if lw is not None else float(d["lower"]) + be_flag = parse_breakeven_enabled_form(d.get("breakeven_enabled")) + if is_fib_key_monitor_type(mt): + ok_fib, err_fib = _add_fib_key_monitor( + conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=be_flag, + ) + conn.commit() + conn.close() + if not ok_fib: + flash(err_fib or "斐波监控添加失败") + return redirect("/key_monitor") + flash( + f"斐波监控已添加,限价单已挂出({symbol} 日成交量排名 {rank}/{total})" + f"|移动保本:{'开' if be_flag else '关'}" + ) + return redirect("/key_monitor") + sl_tp_mode = "standard" + manual_tp = None + if mt in KEY_MONITOR_AUTO_TYPES: + sl_tp_mode = normalize_sl_tp_mode(d.get("sl_tp_mode")) + if sl_tp_mode == "trend_manual": + try: + manual_tp = float(d.get("manual_take_profit") or 0) + except (TypeError, ValueError): + manual_tp = 0 + if manual_tp <= 0: + conn.close() + flash("趋势单方案须填写有效止盈价") + return redirect("/key_monitor") + if direction_sel == "long" and manual_tp <= upper_px: + conn.close() + flash("做多趋势单:止盈价应高于上沿(阻力)") + return redirect("/key_monitor") + if direction_sel == "short" and manual_tp >= lower_px: + conn.close() + flash("做空趋势单:止盈价应低于下沿(支撑)") + return redirect("/key_monitor") + mtpx = round_price_to_exchange(ex_sym_key, manual_tp) + if mtpx is not None: + manual_tp = float(mtpx) + conn.execute( + "INSERT INTO key_monitors " + "(symbol,monitor_type,direction,upper,lower,sl_tp_mode,manual_take_profit,breakeven_enabled) " + "VALUES (?,?,?,?,?,?,?,?)", + (symbol, mt, direction_sel, upper_px, lower_px, sl_tp_mode, manual_tp, be_flag), + ) + conn.commit() + conn.close() + ctr = False + try: + coin4h_status, _, _ = _status_by_ema55(symbol, "4h") + ctr = (direction_sel == "long" and coin4h_status == "空头") or ( + direction_sel == "short" and coin4h_status == "多头" + ) + except Exception: + pass + extra = "" + if mt in KEY_MONITOR_AUTO_TYPES: + extra = f"|方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'开' if be_flag else '关'}" + flash(f"添加成功({symbol} 日成交量排名 {rank}/{total}){extra}") + if ctr: + flash( + "⚠️ 4h EMA55 提示:当前与所选方向逆势;「箱体突破/收敛突破」在条件满足时仍会按计划自动市价开仓,请注意仓位。" + ) + return redirect("/key_monitor") + +@app.route("/add_order", methods=["POST"]) +@login_required +def add_order(): + d = request.form + now = app_now() + conn = get_db() + direction = d.get("direction", "long") + symbol = normalize_symbol_input(d.get("symbol")) + if not symbol: + conn.close() + flash("symbol 不能为空") + return redirect("/") + ok, reason = precheck_risk(conn, symbol, direction) + if not ok: + if "已达最大持仓数" in reason: + try: + tp_raw = parse_positive_float(d.get("tp")) + sl_raw = parse_positive_float(d.get("sl")) + tgt_raw = parse_positive_float(d.get("tgt")) + except Exception: + tp_raw = sl_raw = tgt_raw = None + insert_trade_record( + conn, + symbol=symbol, + monitor_type="下单监控", + direction=direction if direction in ("long", "short") else "long", + trigger_price=tp_raw or 0, + stop_loss=sl_raw or 0, + take_profit=tgt_raw or 0, + result="错过", + miss_reason=f"持仓占用:{reason}", + opened_at=app_now_str(), + closed_at=app_now_str(), + ) + conn.commit() + conn.close() + flash(f"风控拒绝下单:{reason}") + return redirect("/trade") + ok_live, reason_live = ensure_exchange_live_ready() + if not ok_live: + conn.close() + flash(f"风控拒绝下单:{reason_live}") + return redirect("/") + exchange_symbol = normalize_exchange_symbol(symbol) + default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) + try: + leverage_input = parse_positive_float(d.get("leverage")) + leverage = int(leverage_input) if leverage_input is not None else default_leverage + except Exception: + conn.close() + flash("杠杆参数格式错误") + return redirect("/") + if leverage <= 0: + conn.close() + flash("杠杆必须大于0") + return redirect("/") + + trading_day = get_trading_day(now) + opens_today_before = conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", + (trading_day,), + ).fetchone()[0] + session_row = ensure_session(conn, trading_day) + _, trading_capital_live = get_exchange_capitals(force=True) + capital_base = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"]) + trade_style = (d.get("trade_style") or DEFAULT_TRADE_STYLE or "trend").strip().lower() + if trade_style not in ("trend", "swing"): + trade_style = "trend" + available_usdt = get_available_trading_usdt() + live_price = get_price(symbol) + if live_price is None: + conn.close() + flash("获取交易所实时价格失败,请稍后重试") + return redirect("/") + sltp_mode = (d.get("sltp_mode") or "price").strip().lower() + if sltp_mode not in ("price", "pct"): + sltp_mode = "price" + if sltp_mode == "pct": + try: + sl_pct = float(d.get("sl_pct") or 0) + tp_pct = float(d.get("tp_pct") or 0) + if sl_pct <= 0 or tp_pct <= 0: + raise ValueError("pct") + sl_ratio = sl_pct / 100.0 + tp_ratio = tp_pct / 100.0 + if direction == "short": + stop_loss = float(live_price) * (1 + sl_ratio) + take_profit = float(live_price) * (1 - tp_ratio) + else: + stop_loss = float(live_price) * (1 - sl_ratio) + take_profit = float(live_price) * (1 + tp_ratio) + except Exception: + conn.close() + flash("百分比止盈止损参数错误,请填写正数百分比") + return redirect("/") + else: + try: + stop_loss = float(d["sl"]) + take_profit = float(d["tgt"]) + except Exception: + conn.close() + flash("价格参数格式错误") + return redirect("/") + if stop_loss <= 0 or take_profit <= 0: + conn.close() + flash("价格参数必须大于0") + return redirect("/trade") + planned_rr_manual = calc_rr_ratio(direction, live_price, stop_loss, take_profit) + if planned_rr_manual is None or planned_rr_manual < MANUAL_MIN_PLANNED_RR: + conn.close() + rr_txt = f"{planned_rr_manual:.4f}" if planned_rr_manual is not None else "无法计算" + flash(f"风控拒绝下单:计划盈亏比 {rr_txt}:1 低于最低要求 {MANUAL_MIN_PLANNED_RR}:1") + return redirect("/trade") + risk_fraction = calc_risk_fraction(direction, live_price, stop_loss) + if risk_fraction is None: + conn.close() + flash("止损方向不合法:请检查入场方向与止损价格关系") + return redirect("/") + risk_percent = max(0.01, float(RISK_PERCENT)) + risk_amount = round(capital_base * risk_percent / 100.0, FUNDS_DECIMALS) + notional_value = round(risk_amount / risk_fraction, FUNDS_DECIMALS) + margin_capital = round(notional_value / leverage, FUNDS_DECIMALS) + if capital_base and margin_capital > capital_base: + conn.close() + flash("以损定仓后保证金超过当前交易资金,请放宽止损或降低风险比例") + return redirect("/") + if available_usdt is not None: + max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), FUNDS_DECIMALS) + if margin_capital > max_margin: + conn.close() + flash(f"保证金不足:交易账户可用约 {round(available_usdt, FUNDS_DECIMALS)}U,当前最多建议 {max_margin}U") + return redirect("/") + position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0 + try: + amount, quote_price = prepare_order_amount(exchange_symbol, margin_capital, leverage, live_price) + contract_size = get_contract_size(exchange_symbol) + base_amount = round(float(amount) * contract_size, 8) + order_resp = place_exchange_order(exchange_symbol, direction, amount, leverage, stop_loss=stop_loss, take_profit=take_profit) + open_order_id = order_resp.get("id", "") + tpsl_attached = bool(order_resp.get("tpsl_attached")) + trigger_price = resolve_order_entry_price(order_resp, exchange_symbol, quote_price) + except Exception as e: + conn.close() + flash(friendly_exchange_error(e, available_usdt=available_usdt)) + return redirect("/") + + make_order_chart = d.get("order_chart", "").lower() in ("1", "true", "on", "yes") + opened_at_bj = app_now_str() + opened_at_ms = _to_ms_with_fallback(None, opened_at_bj) + planned_rr = calc_rr_ratio(direction, trigger_price, stop_loss, take_profit) + breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER) + breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT) + breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0 + risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage) or risk_amount + if direction == "short": + breakeven_price = round(float(trigger_price) * (1 - breakeven_offset_pct / 100.0), 8) + else: + breakeven_price = round(float(trigger_price) * (1 + breakeven_offset_pct / 100.0), 8) + breakeven_enabled = 1 if (d.get("breakeven_enabled") or "").strip() in ("1", "true", "on", "yes") else 0 + conn.execute( + "INSERT INTO order_monitors (symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, margin_capital, leverage, trade_style, risk_percent, risk_amount, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, exchange_symbol, direction, trigger_price, stop_loss, stop_loss, take_profit, + margin_capital, leverage, trade_style, risk_percent, risk_amount_final, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, 0, breakeven_price, + breakeven_enabled, + notional_value, position_ratio, base_amount, amount, open_order_id, opened_at_bj, opened_at_ms, trading_day, + ORDER_MONITOR_TYPE_MANUAL, + ) + ) + conn.commit() + new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + opens_today_after = conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", + (trading_day,), + ).fetchone()[0] + conn.close() + + chart_name = None + chart_url = None + if make_order_chart and ORDER_CHART_ENABLED: + try: + title_prefix = f"{symbol} {direction} #{new_order_id}" + chart_name = generate_order_open_chart( + exchange_symbol, + title_prefix, + opened_at_ms=opened_at_ms, + entry_price=trigger_price, + ) + if chart_name: + chart_url = f"/static/images/order_charts/{chart_name}" + except Exception: + chart_name = None + chart_url = None + + if chart_name: + try: + journal_id = f"order_{new_order_id}" + coin = journal_coin_from_symbol(symbol) + open_local = (opened_at_bj or "")[:16].replace(" ", "T") + if len(open_local) < 16: + open_local = app_now().strftime("%Y-%m-%dT%H:%M") + close_local = open_local + hold_duration = calc_duration_text(open_local, close_local) + note = ( + f"auto_from_open_order id={new_order_id} oid={open_order_id} " + f"chart={chart_name} tfs={','.join(ORDER_CHART_TFS)} limit={ORDER_CHART_LIMIT}" + ) + conn = get_db() + conn.execute( + """INSERT OR REPLACE INTO journal_entries + (id, open_datetime, close_datetime, hold_duration, coin, tf, pnl, entry_reason, exit_reason, + expect_rr, real_rr, early_exit, early_exit_reason, early_exit_trigger, early_exit_note, + mood_score, mood_ai_score, mood_ai_comment, mood_issues, post_breakeven_stare, + new_trade_while_occupied, note, image) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + journal_id, + open_local, + close_local, + hold_duration, + coin, + "multi", + "0", + "auto:open", + "待平仓", + "", + "", + "否", + "", + "", + "", + None, + None, + None, + "", + "否", + "否", + note, + chart_name, + ), + ) + conn.commit() + conn.close() + except Exception: + try: + conn.close() + except Exception: + pass + + _, trading_capital_after = get_exchange_capitals(force=True) + account_base_display = ( + round(float(trading_capital_after), FUNDS_DECIMALS) + if trading_capital_after is not None + else round(float(capital_base), FUNDS_DECIMALS) + ) + account_name = (os.getenv("BINANCE_ACCOUNT_LABEL") or "binance实盘账户").strip() + dir_text = "多头(long)" if direction == "long" else "空头(short)" + order_state_text = ( + "已在交易所挂条件委托(止盈、止损各一张触发单)" + if tpsl_attached + else "条件委托未挂上(已拦截)" + ) + rr_show = planned_rr if planned_rr is not None else "-" + try: + rr_show_fmt = round(float(planned_rr), 4) if planned_rr is not None else None + except (TypeError, ValueError): + rr_show_fmt = None + rr_line = f"RR {rr_show_fmt} : 1" if rr_show_fmt is not None else f"RR {rr_show} : 1" + ep_wx = format_price_for_symbol(symbol, trigger_price) + sl_wx = format_price_for_symbol(symbol, stop_loss) + tp_wx = format_price_for_symbol(symbol, take_profit) + be_wx = format_price_for_symbol(symbol, breakeven_price) + style_zh = "Swing 波段" if trade_style == "swing" else "Trend 趋势" + wx_lines = [ + f"📈 {symbol} 开仓成功", + f"💼 交易类型:{dir_text}", + "🧾 订单基础信息", + f"🔖 交易所订单 ID:{open_order_id}", + f"📈 交易风格:{style_zh}", + f"⚠️ 单笔风控风险:{risk_percent}% ≈ {round(float(risk_amount_final), FUNDS_DECIMALS)} U", + "📊 仓位配置详情", + f"账户基数:{account_base_display} USDT", + f"合约杠杆:{leverage} 倍", + f"名义仓位:{notional_value} USDT", + f"仓位占比:{position_ratio}%", + f"合约数量:{amount}", + f"折算标的:{base_amount} {journal_coin_from_symbol(symbol)}", + "🎯 价位 & 盈亏比", + f"开仓成交价:{ep_wx}", + f"止损价位:{sl_wx}", + f"止盈价位:{tp_wx}", + f"计划盈亏比:{rr_line}", + f"移动保本位:{breakeven_rr_trigger}R → {be_wx}", + "📌 状态统计", + f"✅ 条件委托:{order_state_text}", + f"📅 当日开仓次数:{opens_today_after} / {DAILY_OPEN_ALERT_THRESHOLD} 次(风控阈值提醒)", + ] + if chart_url: + wx_lines.append(f"多周期K线图:{chart_url}") + send_wechat_msg("\n".join(wx_lines)) + + flash_lines = [ + f"实盘开单成功:风格 {trade_style};风险 {risk_percent}%≈{risk_amount_final}U;基数 {margin_capital}U,杠杆 {leverage}x,名义仓位 {notional_value}U,仓位占比 {position_ratio}%,合约数量 {amount}(折算标的 {base_amount})," + f"计划RR {planned_rr if planned_rr is not None else '-'};已在交易所挂条件止盈/止损委托(非仓位绑定型)", + f"本交易日累计开仓:{opens_today_after}", + ] + if chart_url: + flash_lines.append(f"已生成多周期K线图:{chart_url}") + flash(" ".join(flash_lines)) + + if opens_today_before < DAILY_OPEN_ALERT_THRESHOLD <= opens_today_after: + advice = ai_short_advice( + f"用户在北京时间交易日 {trading_day} 已累计开仓 {opens_today_after} 次(阈值 {DAILY_OPEN_ALERT_THRESHOLD})。" + f"最新一笔:{symbol} {direction},杠杆{leverage}x,基数{margin_capital}U。" + f"用户自述“上头了”。请给克制提醒。" + ) + if advice: + send_wechat_msg(f"【AI提醒】今日开仓次数已达 {opens_today_after}\n{advice[:800]}") + flash(f"【AI提醒】今日开仓次数已达 {opens_today_after}:{advice[:300]}") + return redirect("/") + +@app.route("/delete_key_monitor/", methods=["POST"]) +@login_required +def delete_key_monitor(kid): + conn = get_db() + row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (kid,)).fetchone() + if not row: + conn.close() + return jsonify({"ok": False, "error": "not_found"}) + if is_fib_key_monitor_type(row["monitor_type"]): + _cancel_fib_monitor_limit(row) + insert_key_monitor_history(conn, row, int(row["notification_count"] or 0), None, "manual") + cur = conn.execute("DELETE FROM key_monitors WHERE id=?", (kid,)) + conn.commit() + conn.close() + return jsonify({"ok": cur.rowcount > 0}) + + +@app.route("/delete_key_history/", methods=["POST"]) +@login_required +def delete_key_history(hid): + conn = get_db() + cur = conn.execute("DELETE FROM key_monitor_history WHERE id=?", (hid,)) + conn.commit() + conn.close() + return jsonify({"ok": cur.rowcount > 0}) + + +@app.route("/del_key/") +@login_required +def del_key(id): + conn = get_db() + row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (id,)).fetchone() + if row: + if is_fib_key_monitor_type(row["monitor_type"]): + _cancel_fib_monitor_limit(row) + insert_key_monitor_history(conn, row, int(row["notification_count"] or 0), None, "manual") + conn.execute("DELETE FROM key_monitors WHERE id=?", (id,)) + conn.commit() + conn.close() + resp = redirect("/") + resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" + resp.headers["Pragma"] = "no-cache" + return resp + + +def _csv_response(filename, rows, header): + buf = StringIO() + w = csv.writer(buf) + w.writerow(header) + for row in rows: + w.writerow(row) + out = "\ufeff" + buf.getvalue() + return Response( + out, + mimetype="text/csv; charset=utf-8", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + "Cache-Control": "no-store", + }, + ) + + +def _md_response(filename, content): + return Response( + content, + mimetype="text/markdown; charset=utf-8", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + "Cache-Control": "no-store", + }, + ) + + +@app.route("/export/trade_records") +@login_required +def export_trade_records(): + win = _list_window_from_request() + start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ) + conn = get_db() + rows = conn.execute( + "SELECT id,symbol,monitor_type,key_signal_type,direction,trigger_price,stop_loss,initial_stop_loss,take_profit," + "margin_capital,leverage,pnl_amount,hold_seconds,hold_minutes,planned_rr,actual_rr,risk_amount," + "opened_at,closed_at,result,miss_reason,entry_reason,reviewed_entry_reason," + "exchange_realized_pnl,exchange_opened_at,exchange_closed_at,created_at " + "FROM trade_records WHERE COALESCE(closed_at, created_at, opened_at) >= ? " + "AND COALESCE(closed_at, created_at, opened_at) <= ? ORDER BY id ASC", + (start_bj, end_bj), + ).fetchall() + conn.close() + head = [ + "id", "symbol", "monitor_type", "key_signal_type", "direction", "trigger_price", + "stop_loss_open_snapshot", "initial_stop_loss", "take_profit", "margin_capital", "leverage", + "pnl_amount", "hold_seconds", "hold_minutes", "planned_rr", "actual_rr", "risk_amount", + "opened_at", "closed_at", "result", "miss_reason", "entry_reason", "reviewed_entry_reason", + "exchange_realized_pnl", "exchange_opened_at", "exchange_closed_at", "created_at", "开仓类型", + ] + data = [] + for r in rows: + er0 = (r["entry_reason"] or "").strip() if r["entry_reason"] else "" + er1 = (r["reviewed_entry_reason"] or "").strip() if r["reviewed_entry_reason"] else "" + kst = (r["key_signal_type"] or "").strip() if "key_signal_type" in r.keys() else "" + eff = er1 or er0 or entry_reason_from_key_signal(kst) or "" + snap = r["initial_stop_loss"] if r["initial_stop_loss"] not in (None, "") else r["stop_loss"] + data.append(( + r["id"], r["symbol"], r["monitor_type"], kst, r["direction"], r["trigger_price"], + snap, r["initial_stop_loss"], r["take_profit"], r["margin_capital"], r["leverage"], + r["pnl_amount"], r["hold_seconds"], r["hold_minutes"], r["planned_rr"], r["actual_rr"], r["risk_amount"], + r["opened_at"], r["closed_at"], r["result"], r["miss_reason"], r["entry_reason"], r["reviewed_entry_reason"], + r["exchange_realized_pnl"] if "exchange_realized_pnl" in r.keys() else None, + r["exchange_opened_at"] if "exchange_opened_at" in r.keys() else None, + r["exchange_closed_at"] if "exchange_closed_at" in r.keys() else None, + r["created_at"], eff, + )) + day = app_now().strftime("%Y%m%d") + return _csv_response(f"trade_records_v3_{day}.csv", data, head) + + +@app.route("/export/journal_entries") +@login_required +def export_journal_entries(): + conn = get_db() + rows = conn.execute( + "SELECT id,open_datetime,close_datetime,hold_duration,coin,tf,pnl,entry_reason,exit_reason," + "expect_rr,real_rr,early_exit,early_exit_trigger,early_exit_note,early_exit_reason,mood_issues," + "post_breakeven_stare,new_trade_while_occupied,note,image,created_at FROM journal_entries ORDER BY created_at ASC" + ).fetchall() + conn.close() + head = [ + "id", + "open_datetime", + "close_datetime", + "hold_duration", + "coin", + "tf", + "pnl", + "entry_reason", + "exit_reason", + "expect_rr", + "real_rr", + "early_exit", + "early_exit_trigger", + "early_exit_note", + "early_exit_reason", + "mood_issues", + "post_breakeven_stare", + "new_trade_while_occupied", + "note", + "image", + "created_at", + ] + data = [tuple(r[h] for h in head) for r in rows] + day = app_now().strftime("%Y%m%d") + return _csv_response(f"journal_entries_v1_{day}.csv", data, head) + + +@app.route("/export/key_monitors") +@login_required +def export_key_monitors(): + conn = get_db() + rows = conn.execute( + "SELECT id,symbol,monitor_type,direction,upper,lower,notification_count,last_notified_at,max_notify," + "notify_interval_min,breakout_limit_pct,created_at FROM key_monitors ORDER BY id ASC" + ).fetchall() + conn.close() + head = [ + "id", + "symbol", + "monitor_type", + "direction", + "upper", + "lower", + "notification_count", + "last_notified_at", + "max_notify", + "notify_interval_min", + "breakout_limit_pct", + "created_at", + ] + data = [tuple(r[h] for h in head) for r in rows] + day = app_now().strftime("%Y%m%d") + return _csv_response(f"key_monitors_active_v1_{day}.csv", data, head) + + +@app.route("/export/key_monitor_history") +@login_required +def export_key_monitor_history(): + win = _list_window_from_request() + start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ) + conn = get_db() + rows = conn.execute( + "SELECT id,symbol,monitor_type,direction,upper,lower,notification_count,last_alert_message,close_reason,closed_at " + "FROM key_monitor_history WHERE closed_at >= ? AND closed_at <= ? ORDER BY id ASC", + (start_bj, end_bj), + ).fetchall() + conn.close() + head = [ + "id", + "symbol", + "monitor_type", + "direction", + "upper", + "lower", + "notification_count", + "last_alert_message", + "close_reason", + "closed_at", + ] + data = [tuple(r[h] for h in head) for r in rows] + day = app_now().strftime("%Y%m%d") + return _csv_response(f"key_monitor_history_v1_{day}.csv", data, head) + +@app.route("/del_order/") +@login_required +def del_order(id): + conn = get_db() + row = conn.execute("SELECT * FROM order_monitors WHERE id=?", (id,)).fetchone() + if not row: + conn.close() + flash("订单不存在") + return redirect("/") + if row["status"] == "active": + try: + opened_at = get_opened_at_value(row) + opened_at_ms = _to_ms_with_fallback( + row["opened_at_ms"] if "opened_at_ms" in row.keys() else None, opened_at + ) + close_resp = close_exchange_order(row) + close_order_id = close_resp.get("id", "") + cancel_binance_futures_open_orders(row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"])) + exit_p = extract_trade_price_from_order(close_resp) + closed_at = app_now_str() + closed_at_ms = None + if not exit_p or float(exit_p) <= 0: + tr_fill = fetch_latest_closing_fill( + row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"]), + row["direction"], + opened_at, + opened_at_ms=opened_at_ms, + ) + if tr_fill and tr_fill.get("price"): + try: + exit_p = float(tr_fill["price"]) + except (TypeError, ValueError): + exit_p = None + ts = tr_fill.get("timestamp") + if ts: + closed_at = ms_to_app_local_str(int(ts)) + closed_at_ms = int(ts) + else: + tr_fill = fetch_latest_closing_fill( + row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"]), + row["direction"], + opened_at, + opened_at_ms=opened_at_ms, + ) + if tr_fill and tr_fill.get("timestamp"): + closed_at = ms_to_app_local_str(int(tr_fill["timestamp"])) + closed_at_ms = int(tr_fill["timestamp"]) + pnl_amount, exit_p, _, _, _ = resolve_trade_pnl_amount( + row, + row["trigger_price"], + exit_p, + opened_at_str=opened_at, + opened_at_ms=opened_at_ms, + closed_at_str=closed_at, + closed_at_ms=closed_at_ms, + ) + p = exit_p or get_price(row["symbol"]) or float(row["trigger_price"]) + closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now() + hold_seconds = calc_hold_seconds(opened_at, closed_at_dt) + session_date = row["session_date"] or get_trading_day(closed_at_dt) + session_capital = update_session_capital(conn, session_date, pnl_amount) + insert_trade_record( + conn, + symbol=row["symbol"], + monitor_type=order_row_monitor_type(row), + key_signal_type=order_row_key_signal_type(row), + direction=row["direction"], + trigger_price=row["trigger_price"], + stop_loss=row["stop_loss"], + initial_stop_loss=row["initial_stop_loss"] or row["stop_loss"], + take_profit=row["take_profit"], + margin_capital=row["margin_capital"], + leverage=row["leverage"], + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=row["trade_style"], + risk_amount=row["risk_amount"], + planned_rr=calc_rr_ratio(row["direction"], row["trigger_price"], row["initial_stop_loss"] or row["stop_loss"], row["take_profit"]), + actual_rr=calc_actual_rr(pnl_amount, row["risk_amount"]), + result="手动平仓", + miss_reason="用户手动删除订单触发平仓", + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, id)) + clear_key_sizing_snapshot_if_flat(conn, session_date) + conn.commit() + conn.close() + send_wechat_msg( + build_wechat_close_message( + symbol=row["symbol"], + direction=row["direction"], + result="手动平仓", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=row["trigger_price"], + current_price=p, + stop_loss=row["stop_loss"], + take_profit=row["take_profit"], + close_order_id=close_order_id or "-", + extra_note="用户在页面手动平仓", + session_capital_fallback=session_capital, + ) + ) + flash("已按实盘流程手动平仓") + return redirect("/trade") + except Exception as e: + if is_no_position_error(str(e)): + cancel_binance_futures_open_orders(row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"])) + opened_at = get_opened_at_value(row) + opened_at_ms = _to_ms_with_fallback(row["opened_at_ms"] if "opened_at_ms" in row.keys() else None, opened_at) + result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close(row, opened_at, opened_at_ms=opened_at_ms) + miss_reason = f"手动删除时无持仓:{miss_reason}" + closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now() + hold_seconds = calc_hold_seconds(opened_at, closed_at_dt) + session_date = row["session_date"] or get_trading_day(closed_at_dt) + update_session_capital(conn, session_date, pnl_amount) + insert_trade_record( + conn, + symbol=row["symbol"], + monitor_type=order_row_monitor_type(row), + key_signal_type=order_row_key_signal_type(row), + direction=row["direction"], + trigger_price=row["trigger_price"], + stop_loss=row["stop_loss"], + initial_stop_loss=row["initial_stop_loss"] or row["stop_loss"], + take_profit=row["take_profit"], + margin_capital=row["margin_capital"], + leverage=row["leverage"], + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=row["trade_style"], + risk_amount=row["risk_amount"], + planned_rr=calc_rr_ratio(row["direction"], row["trigger_price"], row["initial_stop_loss"] or row["stop_loss"], row["take_profit"]), + actual_rr=calc_actual_rr(pnl_amount, row["risk_amount"]), + result=result, + miss_reason=miss_reason, + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (id,)) + conn.commit() + conn.close() + flash("该仓位在交易所已不存在,已按成交记录同步结束并记账") + return redirect("/") + conn.close() + flash(f"手动平仓失败:{str(e)}") + return redirect("/") + conn.execute("DELETE FROM order_monitors WHERE id=?",(id,)) + conn.commit() + conn.close() + return redirect("/") + +@app.route("/add_miss", methods=["POST"]) +@login_required +def add_miss(): + d = request.form + direction = d.get("direction", "long") + conn = get_db() + insert_trade_record( + conn, + symbol=d["symbol"], + monitor_type=d["type"], + direction=direction, + trigger_price=d["tp"], + stop_loss=d["sl"], + take_profit=d["tgt"], + result="错过", + miss_reason=d["reason"], + opened_at=app_now_str(), + closed_at=app_now_str(), + ) + conn.commit() + conn.close() + flash("已记录错过机会") + return _redirect_records() + + +@app.route("/add_journal", methods=["POST"]) +@login_required +def add_journal(): + d = request.form + entry_reason_norm = normalize_entry_reason(d.get("entry_reason"), d.get("entry_reason_custom")) + if not entry_reason_norm: + flash("请选择开仓类型;若选「其他」请在下方填写自定义说明") + return _redirect_records() + early_exit_trigger = normalize_early_exit_trigger(d.get("early_exit_trigger")) + early_exit_note = str(d.get("early_exit_note") or "").strip() + if not early_exit_trigger: + flash("请选择离场触发") + return _redirect_records() + if early_exit_trigger == "手动平仓" and not early_exit_note: + flash("手工平仓必须填写补充说明") + return _redirect_records() + if early_exit_trigger != "手动平仓": + early_exit_note = "" + # 兼容字段:仅「手工平仓」记为「主观提前」语义下的「是」 + early_exit_raw = "是" if early_exit_trigger == "手动平仓" else "否" + early_exit_reason_saved = compose_early_exit_reason_saved(early_exit_trigger, early_exit_note) + exit_reason_stored = journal_exit_reason_stored(early_exit_trigger, early_exit_note) + image_filename = None + uploaded_tmp = None + entry_id = uuid.uuid4().hex + file = request.files.get("screenshot") + if file and file.filename: + ext = os.path.splitext(file.filename)[1] + image_filename = f"{uuid.uuid4().hex}{ext}" + save_path = os.path.join(app.config["UPLOAD_FOLDER"], secure_filename(image_filename)) + file.save(save_path) + uploaded_tmp = image_filename + + mood_issues = ",".join(request.form.getlist("mood_issues")) + hold_duration = calc_duration_text(d.get("open_datetime", ""), d.get("close_datetime", "")) + real_rr_text = (d.get("real_rr") or "").strip() + try: + risk_amount_hint = float(d.get("risk_amount_hint") or 0) + pnl_hint = float(d.get("pnl") or 0) + # 口径统一:实际RR = 实际盈亏 / 以损定仓对应的初始风险金额 + if risk_amount_hint > 0: + real_rr_text = f"{(pnl_hint / risk_amount_hint):.4f}" + except Exception: + pass + + want_exchange_chart = d.get("journal_exchange_chart", "").lower() in ("1", "true", "on", "yes") + chart_msg = None + if want_exchange_chart and ORDER_CHART_ENABLED: + coin = (d.get("coin") or "").strip().upper() + symbol_guess = normalize_symbol_input(coin) or coin + exchange_symbol = normalize_exchange_symbol(symbol_guess) + title_prefix = f"{symbol_guess} journal {entry_id[:8]}" + marker_payload = { + "entry_ts_ms": _local_input_datetime_to_ms(d.get("open_datetime")), + "exit_ts_ms": _local_input_datetime_to_ms(d.get("close_datetime")), + "entry_price": d.get("entry_price_hint"), + "exit_price": None, + } + try: + chart_fname = f"journal_{entry_id}.png" + saved = generate_multi_timeframe_chart_png( + exchange_symbol, + title_prefix, + timeframes=ORDER_CHART_TFS, + limit=ORDER_CHART_LIMIT, + out_dir=app.config["UPLOAD_FOLDER"], + filename=chart_fname, + filename_prefix="journal", + marker_payload=marker_payload, + marker_timeframes=( + {x.strip().lower() for x in ORDER_CHART_TFS if x and str(x).strip()} + if ORDER_CHART_TFS + else {"5m", "15m", "1h", "4h"} + ), + ) + if saved: + image_filename = saved + chart_msg = f"已生成多周期K线图:/static/images/{saved}" + if uploaded_tmp: + try: + old_path = os.path.join(app.config["UPLOAD_FOLDER"], uploaded_tmp) + if os.path.exists(old_path): + os.remove(old_path) + except Exception: + pass + else: + chart_msg = "已勾选自动生成K线图,但生成失败(返回空)。请检查 Pillow 是否安装、Binance 网络/代理是否正常。" + except Exception as e: + image_filename = uploaded_tmp + chart_msg = f"自动生成K线图失败:{str(e)}" + + conn = get_db() + conn.execute( + """INSERT INTO journal_entries + (id, open_datetime, close_datetime, hold_duration, coin, tf, pnl, entry_reason, exit_reason, + expect_rr, real_rr, early_exit, early_exit_reason, early_exit_trigger, early_exit_note, + mood_score, mood_ai_score, mood_ai_comment, mood_issues, post_breakeven_stare, + new_trade_while_occupied, note, image) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + entry_id, d.get("open_datetime"), d.get("close_datetime"), hold_duration, d.get("coin"), d.get("tf"), + d.get("pnl"), entry_reason_norm, exit_reason_stored, d.get("expect_rr"), real_rr_text, + early_exit_raw, early_exit_reason_saved, early_exit_trigger, early_exit_note, + None, None, None, mood_issues, + d.get("post_breakeven_stare"), d.get("new_trade_while_occupied"), d.get("note"), image_filename + ) + ) + conn.commit() + conn.close() + if chart_msg: + flash(f"交易复盘记录已保存。{chart_msg}") + else: + flash("交易复盘记录已保存") + return _redirect_records() + + +@app.route("/api/journals") +@login_required +def api_journals(): + win = _list_window_from_request() + start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ) + conn = get_db() + rows = conn.execute( + "SELECT * FROM journal_entries WHERE COALESCE(close_datetime, created_at, open_datetime) >= ? " + "AND COALESCE(close_datetime, created_at, open_datetime) <= ? ORDER BY created_at DESC LIMIT 500", + (start_bj, end_bj), + ).fetchall() + conn.close() + result = [] + for r in rows: + item = row_to_dict(r) + item["mood_issues"] = [x for x in (item.get("mood_issues") or "").split(",") if x] + result.append(item) + return jsonify(result) + + +@app.route("/api/journal_prefill", methods=["POST"]) +@login_required +def api_journal_prefill(): + file = request.files.get("screenshot") + if not file or not file.filename: + return jsonify({"ok": False, "msg": "请先选择截图文件"}), 400 + try: + raw = file.read() + if not raw: + return jsonify({"ok": False, "msg": "截图为空"}), 400 + image_b64 = base64.b64encode(raw).decode("utf-8") + except Exception as e: + return jsonify({"ok": False, "msg": f"读取截图失败:{str(e)}"}), 400 + + parsed = ai_extract_journal_from_image(image_b64) + if parsed is None: + return jsonify({"ok": False, "msg": "AI 识别失败,请稍后重试"}), 500 + return jsonify({"ok": True, "data": parsed}) + + +@app.route("/delete_journal/", methods=["POST"]) +@login_required +def delete_journal(jid): + conn = get_db() + row = conn.execute("SELECT image FROM journal_entries WHERE id=?", (jid,)).fetchone() + if row and row["image"]: + img_path = os.path.join(app.config["UPLOAD_FOLDER"], row["image"]) + if os.path.exists(img_path): + os.remove(img_path) + conn.execute("DELETE FROM journal_entries WHERE id=?", (jid,)) + conn.commit() + conn.close() + return jsonify({"ok": True}) + + +@app.route("/api/reviews") +@login_required +def api_reviews(): + win = _list_window_from_request() + start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ) + conn = get_db() + rows = conn.execute( + "SELECT * FROM ai_reviews WHERE created_at >= ? AND created_at <= ? ORDER BY created_at DESC LIMIT 200", + (start_bj, end_bj), + ).fetchall() + conn.close() + return jsonify([row_to_dict(r) for r in rows]) + + +@app.route("/export/review_md/") +@login_required +def export_review_md(rid): + conn = get_db() + row = conn.execute("SELECT * FROM ai_reviews WHERE id=?", (rid,)).fetchone() + conn.close() + if not row: + return Response("review not found", status=404, mimetype="text/plain; charset=utf-8") + + review_type = "日复盘" if row["review_type"] == "daily" else "周复盘" + target_date = row["target_date"] or "-" + created_at = row["created_at"] or app_now_str() + content = (row["content"] or "").strip() + if not content: + content = "(无内容)" + + md = ( + f"# {review_type}报告\n\n" + f"- 目标日期: {target_date}\n" + f"- 生成时间: {created_at}\n" + f"- 报告ID: {row['id']}\n\n" + f"---\n\n" + f"{content}\n" + ) + + safe_target = re.sub(r"[^0-9A-Za-z_-]+", "-", str(target_date)).strip("-") or "unknown-date" + safe_type = "daily" if row["review_type"] == "daily" else "weekly" + filename = f"ai_review_{safe_type}_{safe_target}_{row['id'][:8]}.md" + return _md_response(filename, md) + + +@app.route("/export/reviews_md_bundle") +@login_required +def export_reviews_md_bundle(): + review_type = (request.args.get("review_type") or "").strip().lower() + target_date = (request.args.get("target_date") or "").strip() + if review_type not in ("daily", "weekly"): + return Response("invalid review_type", status=400, mimetype="text/plain; charset=utf-8") + if not target_date: + return Response("target_date required", status=400, mimetype="text/plain; charset=utf-8") + + conn = get_db() + rows = conn.execute( + "SELECT * FROM ai_reviews WHERE review_type=? AND target_date=? ORDER BY created_at ASC, id ASC", + (review_type, target_date), + ).fetchall() + conn.close() + if not rows: + return Response("no reviews found", status=404, mimetype="text/plain; charset=utf-8") + + title = "日复盘" if review_type == "daily" else "周复盘" + lines = [ + f"# {title}汇总报告", + "", + f"- 目标日期: {target_date}", + f"- 条目数量: {len(rows)}", + f"- 导出时间: {app_now_str()}", + "", + "---", + "", + ] + for idx, row in enumerate(rows, 1): + created_at = row["created_at"] or "-" + content = (row["content"] or "").strip() or "(无内容)" + lines.extend( + [ + f"## 第{idx}条", + "", + f"- 报告ID: {row['id']}", + f"- 生成时间: {created_at}", + "", + content, + "", + "---", + "", + ] + ) + md = "\n".join(lines) + safe_target = re.sub(r"[^0-9A-Za-z_-]+", "-", str(target_date)).strip("-") or "unknown-date" + filename = f"ai_reviews_{review_type}_bundle_{safe_target}.md" + return _md_response(filename, md) + + +@app.route("/delete_review/", methods=["POST"]) +@login_required +def delete_review(rid): + conn = get_db() + conn.execute("DELETE FROM ai_reviews WHERE id=?", (rid,)) + conn.commit() + conn.close() + return jsonify({"ok": True}) + + +@app.route("/delete_trade_record/", methods=["POST"]) +@login_required +def delete_trade_record(rid): + conn = get_db() + cur = conn.execute("DELETE FROM trade_records WHERE id=?", (rid,)) + conn.commit() + conn.close() + return jsonify({"ok": cur.rowcount > 0, "deleted": cur.rowcount}) + + +@app.route("/api/trade_record_review_update", methods=["POST"]) +@login_required +def api_trade_record_review_update(): + payload = request.get_json(silent=True) or {} + rec_id = payload.get("id") + try: + rec_id = int(rec_id) + except Exception: + return jsonify({"ok": False, "msg": "记录ID无效"}), 400 + + reviewed_opened_at = str(payload.get("reviewed_opened_at") or "").strip() + reviewed_closed_at = str(payload.get("reviewed_closed_at") or "").strip() + reviewed_stop_loss_raw = payload.get("reviewed_stop_loss") + reviewed_take_profit_raw = payload.get("reviewed_take_profit") + reviewed_result = str(payload.get("reviewed_result") or "").strip() + reviewed_miss_reason = str(payload.get("reviewed_miss_reason") or "").strip() + reviewed_pnl_raw = payload.get("reviewed_pnl_amount") + + if reviewed_result and reviewed_result not in REVIEW_RESULT_OPTIONS: + return jsonify({"ok": False, "msg": "结果仅允许:止盈/止损/保本止盈/移动止盈/手动平仓"}), 400 + + try: + reviewed_open_dt = datetime.strptime(reviewed_opened_at[:19], "%Y-%m-%d %H:%M:%S") + reviewed_close_dt = datetime.strptime(reviewed_closed_at[:19], "%Y-%m-%d %H:%M:%S") + except Exception: + return jsonify({"ok": False, "msg": "开仓/平仓时间格式错误,需为 YYYY-MM-DD HH:MM:SS"}), 400 + if reviewed_close_dt < reviewed_open_dt: + return jsonify({"ok": False, "msg": "平仓时间不能早于开仓时间"}), 400 + hold_seconds = int((reviewed_close_dt - reviewed_open_dt).total_seconds()) + hold_minutes = calc_hold_minutes(hold_seconds) + + try: + reviewed_pnl_amount = float(reviewed_pnl_raw) + except Exception: + return jsonify({"ok": False, "msg": "盈亏必须为数字"}), 400 + reviewed_stop_loss = None + if reviewed_stop_loss_raw not in (None, ""): + try: + reviewed_stop_loss = float(reviewed_stop_loss_raw) + except Exception: + return jsonify({"ok": False, "msg": "止损必须为数字"}), 400 + reviewed_take_profit = None + if reviewed_take_profit_raw not in (None, ""): + try: + reviewed_take_profit = float(reviewed_take_profit_raw) + except Exception: + return jsonify({"ok": False, "msg": "止盈必须为数字"}), 400 + + _MISSING_ER = object() + reviewed_entry_reason_update = _MISSING_ER + if "reviewed_entry_reason" in payload: + s = str(payload.get("reviewed_entry_reason") or "").strip() + if s and not entry_reason_valid_for_storage(s): + return jsonify({"ok": False, "msg": "开仓类型须为五种固定整句之一、自定义说明(2000字内)或留空"}), 400 + reviewed_entry_reason_update = s or None + + conn = get_db() + row = conn.execute("SELECT risk_amount FROM trade_records WHERE id=?", (rec_id,)).fetchone() + if not row: + conn.close() + return jsonify({"ok": False, "msg": "记录不存在"}), 404 + risk_amount = row["risk_amount"] + actual_rr = calc_actual_rr(reviewed_pnl_amount, risk_amount) + base_params = [ + reviewed_opened_at, + reviewed_closed_at, + reviewed_stop_loss, + reviewed_take_profit, + round(reviewed_pnl_amount, FUNDS_DECIMALS), + reviewed_result or None, + reviewed_miss_reason or None, + hold_seconds, + hold_minutes, + app_now_str(), + actual_rr, + ] + if reviewed_entry_reason_update is not _MISSING_ER: + conn.execute( + """UPDATE trade_records + SET reviewed_opened_at=?, reviewed_closed_at=?, reviewed_stop_loss=?, reviewed_take_profit=?, reviewed_pnl_amount=?, + reviewed_result=?, reviewed_miss_reason=?, reviewed_hold_seconds=?, reviewed_hold_minutes=?, + reviewed_at=?, actual_rr=COALESCE(?, actual_rr), reviewed_entry_reason=? + WHERE id=?""", + tuple(base_params + [reviewed_entry_reason_update, rec_id]), + ) + else: + conn.execute( + """UPDATE trade_records + SET reviewed_opened_at=?, reviewed_closed_at=?, reviewed_stop_loss=?, reviewed_take_profit=?, reviewed_pnl_amount=?, + reviewed_result=?, reviewed_miss_reason=?, reviewed_hold_seconds=?, reviewed_hold_minutes=?, + reviewed_at=?, actual_rr=COALESCE(?, actual_rr) + WHERE id=?""", + tuple(base_params + [rec_id]), + ) + conn.commit() + conn.close() + return jsonify({"ok": True, "id": rec_id, "actual_rr": actual_rr, "hold_minutes": hold_minutes}) + + +@app.route("/manual_transfer", methods=["POST"]) +@login_required +def manual_transfer(): + try: + amount = float(request.form.get("amount", "0")) + except Exception: + flash("划转金额格式错误") + return redirect("/") + from_account = (request.form.get("from_account") or AUTO_TRANSFER_FROM).strip() + to_account = (request.form.get("to_account") or AUTO_TRANSFER_TO).strip() + ok, msg, _ = execute_transfer_usdt(amount, from_account, to_account) + conn = get_db() + conn.execute( + "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", + ("manual", get_trading_day(), amount, from_account, to_account, "success" if ok else "failed", msg[:500]) + ) + conn.commit() + conn.close() + if ok: + flash(f"手动划转成功:{amount}U {from_account}->{to_account}") + else: + flash(f"手动划转失败:{msg}") + return redirect("/") + + +@app.route("/ai_daily_review", methods=["POST"]) +@login_required +def ai_daily_review(): + date = request.form.get("date", "") + conn = get_db() + rows = conn.execute( + "SELECT * FROM journal_entries WHERE substr(open_datetime, 1, 10)=? ORDER BY open_datetime ASC", + (date,) + ).fetchall() + conn.close() + if not rows: + return jsonify({"result": "该日无交易记录"}) + + text = f"【每日交易记录】{date}\n总笔数:{len(rows)}\n\n" + for idx, row in enumerate(rows, 1): + text += _journal_row_lines_for_ai(idx, row) + text += "\n" + + image_paths = [] + for row in rows: + img = row["image"] + if not img: + continue + img_path = os.path.join(app.config["UPLOAD_FOLDER"], img) + if os.path.exists(img_path): + image_paths.append(img_path) + ai_result = ai_review(text, "每日", image_paths=image_paths) + full = f"【AI日复盘 {date}】\n{ai_result}\n\n原始记录:\n{text}" + conn = get_db() + conn.execute( + "INSERT INTO ai_reviews (id, review_type, target_date, content) VALUES (?,?,?,?)", + (uuid.uuid4().hex, "daily", date, full) + ) + conn.commit() + conn.close() + return jsonify({"result": full}) + + +@app.route("/ai_weekly_review", methods=["POST"]) +@login_required +def ai_weekly_review(): + start_date = request.form.get("start_date", "") + end_date = request.form.get("end_date", "") + conn = get_db() + rows = conn.execute( + "SELECT * FROM journal_entries WHERE substr(open_datetime,1,10) >= ? AND substr(open_datetime,1,10) <= ? ORDER BY open_datetime ASC", + (start_date, end_date) + ).fetchall() + conn.close() + if not rows: + return jsonify({"result": "该时间段无交易记录"}) + + text = f"【周交易记录】{start_date}~{end_date}\n总笔数:{len(rows)}\n\n" + for idx, row in enumerate(rows, 1): + text += _journal_row_lines_for_ai(idx, row) + text += "\n" + + image_paths = [] + for row in rows: + img = row["image"] + if not img: + continue + img_path = os.path.join(app.config["UPLOAD_FOLDER"], img) + if os.path.exists(img_path): + image_paths.append(img_path) + ai_result = ai_review(text, "周度", image_paths=image_paths) + full = f"【AI周复盘 {start_date}~{end_date}】\n{ai_result}\n\n原始记录:\n{text}" + conn = get_db() + conn.execute( + "INSERT INTO ai_reviews (id, review_type, target_date, content) VALUES (?,?,?,?)", + (uuid.uuid4().hex, "weekly", f"{start_date}~{end_date}", full) + ) + conn.commit() + conn.close() + return jsonify({"result": full}) + +# 启动 +if __name__ == "__main__": + threading.Thread(target=background_task, daemon=True).start() + app.run(host=HOST, port=PORT, debug=DEBUG) diff --git a/crypto_monitor_binance/ecosystem.config.cjs b/crypto_monitor_binance/ecosystem.config.cjs new file mode 100644 index 0000000..23e6255 --- /dev/null +++ b/crypto_monitor_binance/ecosystem.config.cjs @@ -0,0 +1,33 @@ +/** + * PM2 进程定义(Ubuntu / Linux)。 + * + * 仅托管 Flask 应用。**SSH SOCKS 隧道请在本机用 screen/tmux/systemd 等方式单独常驻**, + * 与 `.env` 里 `BINANCE_SOCKS_PROXY` 端口一致即可;不必交给 PM2。 + * + * 使用前:项目根目录存在 `.venv`,且已安装依赖(走 SOCKS 时需 PySocks)。 + * + * 启动: + * pm2 start ecosystem.config.cjs + * 保存开机列表: + * pm2 save && pm2 startup + */ +const path = require("path"); + +const ROOT = __dirname; +const PY = path.join(ROOT, ".venv", "bin", "python"); + +module.exports = { + apps: [ + { + name: "crypto_binance", + cwd: ROOT, + script: path.join(ROOT, "app.py"), + interpreter: PY, + instances: 1, + autorestart: true, + watch: false, + max_memory_restart: "800M", + // app.py 从项目根目录 .env 加载(由 .env.example 复制而来,勿提交 Git) + }, + ], +}; diff --git a/crypto_monitor_binance/scripts/_layout_snippet.html b/crypto_monitor_binance/scripts/_layout_snippet.html new file mode 100644 index 0000000..d278fe2 --- /dev/null +++ b/crypto_monitor_binance/scripts/_layout_snippet.html @@ -0,0 +1,2 @@ + {% if page == 'key_monitor' %} + diff --git a/crypto_monitor_binance/scripts/backup_data.sh b/crypto_monitor_binance/scripts/backup_data.sh new file mode 100644 index 0000000..9a25287 --- /dev/null +++ b/crypto_monitor_binance/scripts/backup_data.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Daily backup: SQLite DB + static/images → /root/backups/// +# Prune backup folders older than RETENTION_DAYS (default 30). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$PROJECT_DIR" + +BACKUP_ROOT="${BACKUP_ROOT:-/root/backups}" +RETENTION_DAYS="${RETENTION_DAYS:-30}" +INSTANCE_NAME="${BACKUP_INSTANCE:-$(basename "$PROJECT_DIR")}" +TZ_NAME="${BACKUP_TZ:-Asia/Shanghai}" + +log() { + printf '[%s] %s\n' "$(TZ="$TZ_NAME" date '+%Y-%m-%d %H:%M:%S %Z')" "$*" +} + +read_env_var() { + local key="$1" + local default="$2" + local line + if [[ ! -f .env ]]; then + printf '%s' "$default" + return + fi + line="$(grep -E "^${key}=" .env 2>/dev/null | tail -1 || true)" + if [[ -z "$line" ]]; then + printf '%s' "$default" + return + fi + printf '%s' "${line#*=}" | tr -d '\r' +} + +resolve_project_path() { + local p="$1" + if [[ "$p" == /* ]]; then + printf '%s' "$p" + else + printf '%s' "$PROJECT_DIR/$p" + fi +} + +prune_old_backups() { + local base="$BACKUP_ROOT/$INSTANCE_NAME" + [[ -d "$base" ]] || return 0 + local cutoff + cutoff="$(TZ="$TZ_NAME" date -d "-${RETENTION_DAYS} days" +%Y-%m-%d 2>/dev/null || true)" + if [[ -z "$cutoff" ]]; then + find "$base" -mindepth 1 -maxdepth 1 -type d -mtime +"$RETENTION_DAYS" -print0 | + xargs -r -0 rm -rf + return 0 + fi + local dir name + for dir in "$base"/*/; do + [[ -d "$dir" ]] || continue + name="$(basename "$dir")" + [[ "$name" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || continue + if [[ "$name" < "$cutoff" ]]; then + log "prune: remove $dir (older than ${RETENTION_DAYS} days)" + rm -rf "$dir" + fi + done +} + +DB_REL="$(read_env_var DB_PATH crypto.db)" +UPLOAD_REL="$(read_env_var UPLOAD_DIR static/images)" +BACKUP_ROOT="$(read_env_var BACKUP_ROOT "$BACKUP_ROOT")" +RETENTION_DAYS="$(read_env_var BACKUP_RETENTION_DAYS "$RETENTION_DAYS")" +INSTANCE_NAME="$(read_env_var BACKUP_INSTANCE "$INSTANCE_NAME")" + +DB_PATH="$(resolve_project_path "$DB_REL")" +UPLOAD_DIR="$(resolve_project_path "$UPLOAD_REL")" +DATE_TAG="$(TZ="$TZ_NAME" date +%Y-%m-%d)" +DEST="$BACKUP_ROOT/$INSTANCE_NAME/$DATE_TAG" + +if [[ ! -f "$DB_PATH" ]]; then + log "error: database not found: $DB_PATH" + exit 1 +fi + +mkdir -p "$DEST" +log "start backup instance=$INSTANCE_NAME dest=$DEST" + +if command -v sqlite3 >/dev/null 2>&1; then + sqlite3 "$DB_PATH" ".backup '$DEST/crypto.db'" + log "db: sqlite3 backup -> $DEST/crypto.db" +else + cp -a "$DB_PATH" "$DEST/crypto.db" + log "db: cp -> $DEST/crypto.db (sqlite3 not installed)" +fi + +if [[ -d "$UPLOAD_DIR" ]]; then + tar -czf "$DEST/static_images.tar.gz" -C "$(dirname "$UPLOAD_DIR")" "$(basename "$UPLOAD_DIR")" + log "images: $UPLOAD_DIR -> $DEST/static_images.tar.gz" +else + log "warn: upload dir missing, skip images: $UPLOAD_DIR" +fi + +{ + echo "instance=$INSTANCE_NAME" + echo "project_dir=$PROJECT_DIR" + echo "backup_date=$DATE_TAG" + echo "db_path=$DB_PATH" + echo "upload_dir=$UPLOAD_DIR" +} >"$DEST/manifest.txt" + +prune_old_backups +log "done" diff --git a/crypto_monitor_binance/scripts/fix_breakeven_labels.py b/crypto_monitor_binance/scripts/fix_breakeven_labels.py new file mode 100644 index 0000000..80b7d04 --- /dev/null +++ b/crypto_monitor_binance/scripts/fix_breakeven_labels.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +""" +一次性修复历史交易记录标签: +将 trade_records 里“止损但实际盈利”的记录改为“保本止盈”。 + +默认条件(可通过参数修改): +- monitor_type = 下单监控 +- result = 止损 +- pnl_amount > 0 + +用法示例: +1) 仅预览(不落库): + python scripts/fix_breakeven_labels.py --db ./crypto.db --dry-run + +2) 执行修复: + python scripts/fix_breakeven_labels.py --db ./crypto.db --apply +""" + +from __future__ import annotations + +import argparse +import sqlite3 +import sys +from pathlib import Path + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Fix historical stop-loss records with positive pnl.") + parser.add_argument("--db", required=True, help="Path to sqlite db file, e.g. ./crypto.db") + parser.add_argument("--monitor-type", default="下单监控", help="Filter by monitor_type (default: 下单监控)") + parser.add_argument("--from-result", default="止损", help="Source result label (default: 止损)") + parser.add_argument("--to-result", default="保本止盈", help="Target result label (default: 保本止盈)") + parser.add_argument("--dry-run", action="store_true", help="Preview only, no write") + parser.add_argument("--apply", action="store_true", help="Execute update") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + db_path = Path(args.db).expanduser().resolve() + if not db_path.exists(): + print(f"[ERR] DB not found: {db_path}") + return 1 + + if args.dry_run and args.apply: + print("[ERR] --dry-run and --apply are mutually exclusive.") + return 1 + if not args.dry_run and not args.apply: + print("[INFO] No mode provided, defaulting to --dry-run.") + args.dry_run = True + + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + cur = conn.cursor() + + where_sql = """ + monitor_type = ? + AND result = ? + AND CAST(COALESCE(pnl_amount, 0) AS REAL) > 0 + """ + params = (args.monitor_type, args.from_result) + + cur.execute(f"SELECT COUNT(*) AS c FROM trade_records WHERE {where_sql}", params) + will_change = int(cur.fetchone()["c"]) + print(f"[INFO] Candidate rows: {will_change}") + + if will_change == 0: + print("[INFO] Nothing to update.") + conn.close() + return 0 + + cur.execute( + f""" + SELECT id, symbol, result, pnl_amount, closed_at + FROM trade_records + WHERE {where_sql} + ORDER BY id DESC + LIMIT 10 + """, + params, + ) + sample = cur.fetchall() + print("[INFO] Sample (latest 10):") + for r in sample: + print( + f" id={r['id']} symbol={r['symbol']} result={r['result']} " + f"pnl={r['pnl_amount']} closed_at={r['closed_at']}" + ) + + if args.dry_run: + print("[DRY-RUN] No write executed.") + conn.close() + return 0 + + cur.execute( + f"UPDATE trade_records SET result=? WHERE {where_sql}", + (args.to_result, *params), + ) + changed = int(cur.rowcount) + conn.commit() + conn.close() + print(f"[DONE] Updated rows: {changed}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) + diff --git a/crypto_monitor_binance/scripts/install_backup_cron.sh b/crypto_monitor_binance/scripts/install_backup_cron.sh new file mode 100644 index 0000000..96053f4 --- /dev/null +++ b/crypto_monitor_binance/scripts/install_backup_cron.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Install daily backup cron: Beijing 00:00 (CRON_TZ=Asia/Shanghai). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +BACKUP_SCRIPT="$SCRIPT_DIR/backup_data.sh" +INSTANCE_NAME="${BACKUP_INSTANCE:-$(basename "$PROJECT_DIR")}" +LOG_FILE="${BACKUP_CRON_LOG:-/var/log/crypto-monitor-backup-${INSTANCE_NAME}.log}" +if [[ ! -x "$BACKUP_SCRIPT" ]]; then + chmod +x "$BACKUP_SCRIPT" +fi + +TMP="$(mktemp)" +trap 'rm -f "$TMP"' EXIT + +{ + crontab -l 2>/dev/null | grep -vF "$BACKUP_SCRIPT" || true + echo "CRON_TZ=Asia/Shanghai" + echo "0 0 * * * $BACKUP_SCRIPT >> $LOG_FILE 2>&1" +} >"$TMP" + +# Keep a single CRON_TZ line at top. +awk ' + BEGIN { tz = 0 } + /^CRON_TZ=Asia\/Shanghai$/ { + if (tz++) next + } + { print } +' "$TMP" >"${TMP}.2" +mv "${TMP}.2" "$TMP" + +crontab "$TMP" +echo "Installed cron for $INSTANCE_NAME" +echo " Schedule : daily 00:00 Asia/Shanghai" +echo " Script : $BACKUP_SCRIPT" +echo " Log : $LOG_FILE" +crontab -l | grep -F "$BACKUP_SCRIPT" || true diff --git a/crypto_monitor_binance/scripts/patch_index_layout.py b/crypto_monitor_binance/scripts/patch_index_layout.py new file mode 100644 index 0000000..8b68b31 --- /dev/null +++ b/crypto_monitor_binance/scripts/patch_index_layout.py @@ -0,0 +1,358 @@ +# -*- coding: utf-8 -*- +"""Patch index.html layout for key_monitor / trade split.""" +from pathlib import Path +import re + +TAG = "div" + +PATHS = [ + Path(__file__).resolve().parent.parent / "templates" / "index.html", + Path(r"c:\Users\dekun\Desktop\crypto_monitor\crypto_monitor_gate\templates\index.html"), +] + +KEY_START = " {% if page == 'key_monitor' %}" +KEY_START_ALT = " {% if page == 'trade' %}" +RECORDS_START = " {% if page == 'records' %}" + + +def build_section(order_loop: str) -> str: + t = TAG + return f""" {{% if page == 'key_monitor' %}} + <{t} class="dual-panel-grid" style="grid-column:1/-1"> + <{t} class="card"> + <{t} style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:8px"> +

关键位监控

+ {{% if focus_key_id %}} + 放大查看K线(默认200根) + {{% else %}} + 输入币种查看K线 + {{% endif %}} + +
+ + + + + + +
+ <{t} class="rule-tip">{{{{ key_gate_rule_text }}}} + <{t} class="panel-scroll pos-list"> + {{% for k in key %}} + <{t} class="pos-card" id="key-row-{{{{ k.id }}}}"> + <{t} class="pos-card-head"> + <{t} class="pos-card-symbol"> + {{{{ k.symbol }}}} + {{{{ '做多' if k.direction == 'long' else '做空' }}}} + {{{{ k.monitor_type }}}} + + + + <{t} class="pos-meta"> + 上沿: {{{{ k.upper }}}} + 下沿: {{{{ k.lower }}}} + 已提醒: {{{{ k.notification_count or 0 }}}}/{{{{ k.max_notify or 3 }}}} + + <{t} class="pos-grid"> + <{t} class="pos-cell">现价- + <{t} class="pos-cell">距上沿- + <{t} class="pos-cell">距下沿- + <{t} class="pos-cell">门控- + + <{t} class="pos-meta" style="margin-top:8px"> + + {{% else %}} + <{t} class="pos-empty">暂无监控中的关键位 + {{% endfor %}} + + + <{t} class="card"> +

关键位历史

+ <{t} class="sub" style="font-size:.72rem;color:#8892b0;margin-bottom:8px">失效或已结案的关键位 + <{t} class="panel-scroll pos-list"> + {{% for h in key_history %}} + <{t} class="pos-card"> + <{t} class="pos-card-head"> + <{t} class="pos-card-symbol"> + {{{{ h.symbol }}}} + {{{{ '做多' if h.direction == 'long' else '做空' }}}} + + + + <{t} class="pos-meta"> + {{{{ h.monitor_type }}}} + {{{{ h.close_reason }}}} + {{{{ (h.closed_at or '-')[:16] }}}} + + <{t} class="pos-meta"> + 上: {{{{ h.upper }}}} 下: {{{{ h.lower }}}} + 提醒: {{{{ h.notification_count }}}} + + {{% if h.last_alert_message %}}<{t} style="font-size:.75rem;color:#aab;margin-top:6px;white-space:pre-wrap">{{{{ h.last_alert_message[:180] }}}}{{% if h.last_alert_message|length > 180 %}}…{{% endif %}}{{% endif %}} + + {{% else %}} + <{t} class="pos-empty">暂无历史 + {{% endfor %}} + + + + {{% elif page == 'trade' %}} + <{t} class="dual-panel-grid" style="grid-column:1/-1"> + <{t} class="card"> + <{t} style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:8px"> +

实盘下单监控

+ {{% if focus_order_id %}} + 放大查看K线(100根) + {{% else %}} + 暂无持仓可放大 + {{% endif %}} + + <{t} class="rule-tip" id="order-rule-tip"> + 规则:最多 {{{{ max_active_positions }}}} 仓;BTC {{{{ btc_leverage }}}}x / 山寨 {{{{ alt_leverage }}}}x; + {{% if can_trade %}}可开仓{{% else %}}不可开仓(持仓已满或未到北京时间 {{{{ reset_hour }}}}:00){{% endif %}}; + 人工开仓盈亏比不得低于 {{{{ manual_min_planned_rr }}}}:1 + + <{t} class="rule-tip"> + 以损定仓:风险 {{{{ risk_percent }}}}% |移动保本:下单可勾选关闭;开启时 {{{{ breakeven_rr_trigger }}}}R 触发(每 1R 阶梯上移),偏移 {{{{ breakeven_offset_pct }}}}% + + <{t} class="rule-tip"> + 划转:自动划转 {{{{ '开启' if auto_transfer_enabled else '关闭' }}}}(每天北京时间 {{{{ auto_transfer_bj_hour }}}}:00起该整点小时内尝试;账簿按 UTC 自然日去重;界面时间为北京;将 {{{{ auto_transfer_to }}}} 补足到 {{{{ auto_transfer_amount }}}}U,来自 {{{{ auto_transfer_from }}}}) + +
+ + + + +
+
+ + + + + + + + 成交价自动取交易所实时+成交回报 + + + + + +
+ + <{t} class="card"> +

实时持仓

+ <{t} class="panel-scroll pos-list"> + {order_loop} + + + + {{% endif %}} + +""" + + +def patch_nav(text: str) -> str: + old = '交易执行' + new = ( + '关键位监控\n' + ' 实盘下单' + ) + if "关键位监控" not in text: + text = text.replace(old, new) + return text + + +def patch_js(text: str) -> str: + # page id on body + if 'id="page-trade"' not in text: + text = text.replace("", '', 1) + if "MANUAL_MIN_PLANNED_RR" not in text: + insert = """ +const MANUAL_MIN_PLANNED_RR = {{ manual_min_planned_rr }}; +function calcClientRr(direction, entry, sl, tp){ + const e = Number(entry), s = Number(sl), t = Number(tp); + if(!Number.isFinite(e) || !Number.isFinite(s) || !Number.isFinite(t)) return null; + if(direction === 'short'){ + if(s <= e || t >= e) return null; + return (e - t) / (s - e); + } + if(s >= e || t <= e) return null; + return (t - e) / (e - s); +} +""" + text = text.replace("let latestAvailableUsdt = null;", insert + "\nlet latestAvailableUsdt = null;") + if "add-order-form" not in text or "calcClientRr" in text and "addOrderForm" not in text: + hook = """ +const addOrderForm = document.getElementById("add-order-form"); +if(addOrderForm){ + addOrderForm.addEventListener("submit", function(ev){ + const direction = (document.getElementById("order-direction")||{}).value || "long"; + const mode = (document.getElementById("sltp-mode")||{}).value || "price"; + let sl, tp, entry; + if(mode === "pct"){ + alert("百分比模式请确认盈亏比后再提交;建议使用价格模式以便校验。"); + return; + } + sl = Number((document.getElementById("order-sl")||{}).value); + tp = Number((document.getElementById("order-tp")||{}).value); + entry = sl; + fetch(`/api/order_defaults?symbol=${encodeURIComponent((document.getElementById("order-symbol")||{}).value||"")}&direction=${encodeURIComponent(direction)}`) + .then(r=>r.json()) + .then(data=>{ + const px = data.last_price || data.price; + if(px) entry = Number(px); + const rr = calcClientRr(direction, entry, sl, tp); + if(rr === null || rr < MANUAL_MIN_PLANNED_RR){ + alert(`计划盈亏比 ${rr === null ? '无效' : rr.toFixed(2)}:1 低于最低要求 ${MANUAL_MIN_PLANNED_RR}:1,已阻止人工下单。`); + return; + } + addOrderForm.submit(); + }) + .catch(()=>{ ev.preventDefault(); alert("无法校验盈亏比,请稍后重试"); }); + ev.preventDefault(); + }); +} +""" + text = text.replace("refreshOrderDefaults();", hook + "\nrefreshOrderDefaults();") + if "max_active_positions" not in text and "order-rule-tip" in text: + text = text.replace( + "规则:单仓;", + "规则:最多 {{ max_active_positions }} 仓;", + ) + # account snapshot tip + old_tip = '`规则:单仓;BTC {{ btc_leverage }}x' + if old_tip in text: + text = text.replace( + old_tip, + "`规则:最多 ${data.max_active_positions || {{ max_active_positions }}} 仓;BTC {{ btc_leverage }}x", + ) + text = text.replace( + 'const canTradeText = data.can_trade ? "可开仓" : "不可开仓(有持仓或未到北京时间 {{ reset_hour }}:00)";', + 'const canTradeText = data.can_trade ? "可开仓" : `不可开仓(持仓 ${data.active_count||0}/${data.max_active_positions||{{ max_active_positions }}} 或未到北京时间 {{ reset_hour }}:00)`;', + ) + text = text.replace( + "if(!data.in_top30){", + "const rankMax = data.rank_max || 30;\n if(!data.in_top30){", + ) + text = text.replace( + "不在前30,已拦截", + "不在前${rankMax},已拦截", + ) + # conditional price refresh + if "data-page" in text and "refreshPriceSnapshotConditional" not in text: + text = text.replace( + "setInterval(refreshPriceSnapshot, {{ price_refresh_seconds * 1000 }});", + """function refreshPriceSnapshotConditional(){ + const page = document.body.getAttribute("data-page") || ""; + fetch("/api/price_snapshot").then(r=>r.json()).then(data=>{ + const updatedEl = document.getElementById("price-last-updated"); + if(data.updated_at && updatedEl) updatedEl.innerText = data.updated_at; + if(page === "key_monitor"){ + (data.key_prices || []).forEach(k=>{ + const pEl = document.getElementById(`key-price-${k.id}`); + if(pEl){ pEl.innerText = k.price_display || (Number.isFinite(Number(k.price)) ? Number(k.price).toFixed(6) : "-"); paintPriceTrend(pEl, `k-${k.id}`, Number(k.price)); } + const upEl = document.getElementById(`key-up-diff-${k.id}`); + if(upEl) upEl.innerText = `${formatSigned(k.upper_diff, 4)} (${formatSigned(k.upper_pct, 2)}%)`; + const lowEl = document.getElementById(`key-low-diff-${k.id}`); + if(lowEl) lowEl.innerText = `${formatSigned(k.lower_diff, 4)} (${formatSigned(k.lower_pct, 2)}%)`; + const gateEl = document.getElementById(`key-gate-${k.id}`); + if(gateEl){ gateEl.innerText = k.gate_summary || "-"; gateEl.style.color = k.gate_ok ? "#4cd97f" : "#ff8f8f"; } + const gateMetricEl = document.getElementById(`key-gate-metrics-${k.id}`); + if(gateMetricEl) gateMetricEl.innerText = k.gate_metrics || ""; + }); + } + if(page === "trade"){ + (data.order_prices || []).forEach(o=>{ + const pEl = document.getElementById(`order-price-${o.id}`); + if(pEl){ + const hasMark = (()=>{ const x = o.exchange_mark_price; if(x===null||x===undefined||x==="")return false; const n=Number(x); return !Number.isNaN(n); })(); + let disp = ""; + if(hasMark && o.exchange_mark_price_display) disp = o.exchange_mark_price_display; + else if(o.price_display) disp = o.price_display; + else { const px = hasMark ? Number(o.exchange_mark_price) : Number(o.price); disp = Number.isFinite(px) ? px.toFixed(6) : "-"; } + pEl.innerText = disp; + const pxNum = hasMark ? Number(o.exchange_mark_price) : Number(o.price); + paintPriceTrend(pEl, `o-${o.id}`, Number.isFinite(pxNum) ? pxNum : px); + } + const exM = document.getElementById(`order-ex-margin-${o.id}`); + if(exM){ + const mv = o.exchange_initial_margin; + const mn = (mv === null || mv === undefined || mv === "") ? NaN : Number(mv); + if(!Number.isNaN(mn)) exM.innerText = `${mn.toFixed(2)}U`; + else { const prc = (typeof data.positions_raw_count === "number") ? data.positions_raw_count : null; exM.innerText = (prc === 0) ? "无仓数据" : "-"; } + } + const pnlEl = document.getElementById(`order-pnl-${o.id}`); + if(pnlEl){ + pnlEl.innerText = `${formatSigned(o.float_pnl, 2)}U (${formatSigned(o.float_pct, 2)}%)`; + pnlEl.classList.remove("price-up","price-down","price-flat"); + if(Number(o.float_pnl) > 0) pnlEl.classList.add("price-up"); + else if(Number(o.float_pnl) < 0) pnlEl.classList.add("price-down"); + else pnlEl.classList.add("price-flat"); + } + const rrEl = document.getElementById(`order-rr-${o.id}`); + if(rrEl) rrEl.innerText = formatRrRatio(o.rr_ratio); + }); + } + }).catch(()=>{}); +} +setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }});""", + ) + return text + + +def main(): + for path in PATHS: + if not path.exists(): + print("skip", path) + continue + text = path.read_text(encoding="utf-8") + start = text.find(KEY_START) + if start < 0: + start = text.find(KEY_START_ALT) + end = text.find(RECORDS_START) + if start < 0 or end < 0: + raise SystemExit(f"markers not found: {path}") + old = text[start:end] + m = re.search(r"(\{% for o in order %\}.*?\{% endfor %\})", old, re.S) + if not m: + raise SystemExit(f"order loop not found: {path}") + order_loop = m.group(1) + section = build_section(order_loop) + section = section.replace("{{%", "{%").replace("%}}", "%}").replace("{{{{", "{{").replace("}}}}", "}}") + out = text[:start] + section + "\n\n" + text[end:] + out = patch_nav(out) + out = patch_js(out) + path.write_text(out, encoding="utf-8") + print("patched", path) + + +if __name__ == "__main__": + main() diff --git a/crypto_monitor_binance/scripts/sync_gate_app.py b/crypto_monitor_binance/scripts/sync_gate_app.py new file mode 100644 index 0000000..81e467d --- /dev/null +++ b/crypto_monitor_binance/scripts/sync_gate_app.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +"""Apply binance app.py risk/layout changes to gate app.py (pattern replace).""" +from pathlib import Path + +binance = Path(__file__).resolve().parent.parent / "app.py" +gate = Path(r"c:\Users\dekun\Desktop\crypto_monitor\crypto_monitor_gate\app.py") + +b = binance.read_text(encoding="utf-8") +g = gate.read_text(encoding="utf-8") + +# 1) env block +old_env = """KEY_BREAKOUT_LIMIT_PCT = float(os.getenv("KEY_BREAKOUT_LIMIT_PCT", "1.5")) +KEY_AUTO_MIN_PLANNED_RR = float(os.getenv("KEY_AUTO_MIN_PLANNED_RR", "1.5")) +KEY_STOP_OUTSIDE_BREAKOUT_PCT = float(os.getenv("KEY_STOP_OUTSIDE_BREAKOUT_PCT", "0.5"))""" + +new_env = """KEY_AUTO_MIN_PLANNED_RR = float(os.getenv("KEY_AUTO_MIN_PLANNED_RR", "1.5")) +KEY_STOP_OUTSIDE_BREAKOUT_PCT = float(os.getenv("KEY_STOP_OUTSIDE_BREAKOUT_PCT", "0.5")) +MANUAL_MIN_PLANNED_RR = float(os.getenv("MANUAL_MIN_PLANNED_RR", "1.4")) +MAX_ACTIVE_POSITIONS = max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", "1"))) +KEY_VOLUME_MA_BARS = max(1, int(os.getenv("KEY_VOLUME_MA_BARS", "20"))) +KEY_VOLUME_RATIO_MIN = float(os.getenv("KEY_VOLUME_RATIO_MIN", "1.3")) +KEY_BREAKOUT_AMP_MIN_PCT = float(os.getenv("KEY_BREAKOUT_AMP_MIN_PCT", "0.03")) +KEY_BREAKOUT_AMP_MAX_PCT = float(os.getenv("KEY_BREAKOUT_AMP_MAX_PCT", "0.5")) +KEY_DAILY_VOLUME_RANK_MAX = max(1, int(os.getenv("KEY_DAILY_VOLUME_RANK_MAX", "30"))) +KEY_CONFIRM_BREAKOUT_BAR = int(os.getenv("KEY_CONFIRM_BREAKOUT_BAR", "-2")) +KEY_CONFIRM_BAR = int(os.getenv("KEY_CONFIRM_BAR", "-1")) +KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT = os.getenv("KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT", "true").lower() == "true")""" + +if old_env in g: + g = g.replace(old_env, new_env) + +# 2) DB migration snippet +snip = """ try: + c.execute("ALTER TABLE trading_sessions ADD COLUMN key_sizing_capital_snapshot REAL") + except Exception: + pass + + c.execute(""" +if snip not in g and 'key_sizing_capital_snapshot' not in g: + g = g.replace( + ' c.execute(\n """CREATE TABLE IF NOT EXISTS key_monitor_history', + """ try: + c.execute("ALTER TABLE trading_sessions ADD COLUMN key_sizing_capital_snapshot REAL") + except Exception: + pass + + c.execute( + \"\"\"CREATE TABLE IF NOT EXISTS key_monitor_history""", + 1, + ) + +# 3) precheck block - extract from binance +import re +m = re.search( + r"def get_active_position_count\(conn\):.*?return True, \"\"\n\n\ndef prepare_order_amount", + b, + re.S, +) +if m and "get_active_position_count" not in g: + g = g.replace( + "def precheck_risk(conn, symbol, direction):\n now = app_now()\n if not trading_day_reset_allows_new_open(now):\n return False, f\"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓\"\n active_count = conn.execute(\"SELECT COUNT(*) FROM order_monitors WHERE status='active'\").fetchone()[0]\n if active_count > 0:\n return False, \"一次只能持有一个仓位\"\n if direction not in (\"long\", \"short\"):\n return False, \"方向必须为 long 或 short\"\n if symbol.upper().startswith(\"BTC\") or symbol.upper().startswith(\"ETH\"):\n expected = BTC_LEVERAGE\n else:\n expected = ALT_LEVERAGE\n if expected <= 0:\n return False, \"杠杆配置异常\"\n return True, \"\"\n\n\ndef prepare_order_amount", + m.group(0), + ) + +# 4) render_main_page can_trade + template vars + route +if "key_monitor_page" not in g: + g = g.replace( + " can_trade = trading_day_reset_allows_new_open(now) and active_count == 0\n conn.close()\n return render_template(", + """ can_trade = trading_day_reset_allows_new_open(now) and active_count < MAX_ACTIVE_POSITIONS + key_gate_rule_text = ( + f"周期 {KLINE_TIMEFRAME}|确认K:突破棒偏移 {KEY_CONFIRM_BREAKOUT_BAR}、确认棒偏移 {KEY_CONFIRM_BAR}|" + f"量能:突破量 > 前{KEY_VOLUME_MA_BARS}均量×{KEY_VOLUME_RATIO_MIN}|" + f"自动开仓盈亏比 > {KEY_AUTO_MIN_PLANNED_RR}:1|日成交量排名前 {KEY_DAILY_VOLUME_RANK_MAX}" + ) + conn.close() + return render_template(""", + ) + g = g.replace( + " exchange_display=EXCHANGE_DISPLAY_NAME,\n )\n\n\n@app.route(\"/\")\n@login_required\ndef index():\n return redirect(\"/trade\")\n\n\n@app.route(\"/trade\")", + """ exchange_display=EXCHANGE_DISPLAY_NAME, + max_active_positions=MAX_ACTIVE_POSITIONS, + manual_min_planned_rr=MANUAL_MIN_PLANNED_RR, + key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR, + key_gate_rule_text=key_gate_rule_text, + kline_timeframe=KLINE_TIMEFRAME, + ) + + +@app.route("/") +@login_required +def index(): + return redirect("/trade") + + +@app.route("/key_monitor") +@login_required +def key_monitor_page(): + return render_main_page("key_monitor") + + +@app.route("/trade")""", + ) + +# api account +g = g.replace( + " active_count = conn.execute(\"SELECT COUNT(*) FROM order_monitors WHERE status='active'\").fetchone()[0]\n conn.close()\n can_trade = trading_day_reset_allows_new_open(now) and active_count == 0", + " active_count = get_active_position_count(conn)\n conn.close()\n can_trade = trading_day_reset_allows_new_open(now) and active_count < MAX_ACTIVE_POSITIONS", +) +if '"max_active_positions"' not in g: + g = g.replace( + '"can_trade": can_trade,\n "trading_day": trading_day\n })', + '"can_trade": can_trade,\n "max_active_positions": MAX_ACTIVE_POSITIONS,\n "manual_min_planned_rr": MANUAL_MIN_PLANNED_RR,\n "trading_day": trading_day\n })', + ) + +gate.write_text(g, encoding="utf-8") +print("gate app partially synced; manual review _key_hard_checks add_order still needed") diff --git a/crypto_monitor_binance/scripts/verify_binance_funding.py b/crypto_monitor_binance/scripts/verify_binance_funding.py new file mode 100644 index 0000000..bbe7908 --- /dev/null +++ b/crypto_monitor_binance/scripts/verify_binance_funding.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +""" + python scripts/verify_binance_funding.py + +打印 BINANCE_API_KEY 前 8 位便于与 Binance 控制台核对(不含 Secret)。用于服务器自检。 +""" +import os +import sys + +BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, BASE) + + +def load_env(path): + if not os.path.exists(path): + return + for line in open(path, "r", encoding="utf-8", errors="ignore"): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + k = k.strip().lstrip("\ufeff") + if k.replace("_", "").isalnum(): + os.environ[k] = v.strip().strip('"').strip("'") + + +def main(): + load_env(os.path.join(BASE, ".env")) + k = (os.getenv("BINANCE_API_KEY") or "").strip() + s = (os.getenv("BINANCE_API_SECRET") or "").strip() + if not k or "REPLACE" in k.upper(): + print("WARN: BINANCE_API_KEY 为空或仍像占位符,请核对 .env") + if not s or "REPLACE" in s.upper(): + print("WARN: BINANCE_API_SECRET 为空或仍像占位符,请核对 .env") + print("BINANCE_API_KEY prefix (8 chars):", (k[:8] + "…") if len(k) > 8 else "(short)") + + import app as mod # noqa: E402 + + mod.ensure_markets_loaded() + fu = mod._fetch_binance_funding_usdt() + print(">>> _fetch_binance_funding_usdt() =", fu) + try: + sw = mod._fetch_binance_swap_usdt_total() + print(">>> _fetch_binance_swap_usdt_total() (合约账户) =", sw) + sf = mod._fetch_binance_swap_usdt_free() + print(">>> _fetch_binance_swap_usdt_free() (合约可用) =", sf) + except Exception as e: + print(">>> swap balance fetch error:", e) + + +if __name__ == "__main__": + main() diff --git a/crypto_monitor_binance/start_utf8.ps1 b/crypto_monitor_binance/start_utf8.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..4a8fd0cbe425e232604b47af88dfdd3bd069e311 GIT binary patch literal 1080 zcmcJOPfNo<5XIkF@H;HE2R*dtK@q`&Xw`z$Vighf5YtpEZ4#PjE%@2h-|V)wLg`JG z%}!=^-u!#}{Z&;%BUZ*fQmj&avy5bTN3d>>Rqb|9`=^ ziritv`1L_9b;bRRb1}bDj~nW^(X~#&*vGq1bq?P*yhE*)Pf6qvIh!@2GOH+I7ig?M zh8$B(OgyJO?P{B~gZ#<91kLhpaSm8^`(DT1v*0@b>rn@+CRn+@-9U=5Stn9GgJRvj zr!MQLByc2!!S68TEpZWdu3%Duw#@f`@ir68`|Bqgs_G5LVW!+ho;o@wq2Zf)Y zv9wLcjA=F}hc-;kO*wJu-)oS;FZ0_H?X>%j{~oF$P>^5ug!bjzq%f<%yF$-KLi}HnE(I) literal 0 HcmV?d00001 diff --git a/crypto_monitor_binance/templates/index.html b/crypto_monitor_binance/templates/index.html new file mode 100644 index 0000000..1e4336a --- /dev/null +++ b/crypto_monitor_binance/templates/index.html @@ -0,0 +1,1868 @@ + + + + + {{ exchange_display }} · 加密货币 | 交易监控复盘系统 + + + +{% macro period_stats(title, s) %} +
+

{{ title }}

+
{{ s.range_label }}
+
+
开单次数
{{ s.opens_count }}
+
平仓笔数
{{ s.closed_count }}
+
胜率
{% if s.win_rate_pct is not none %}{{ s.win_rate_pct }}%{% else %}-{% endif %}
+
净盈亏(U)
{{ funds_fmt(s.net_pnl_u) }}
+
亏损额合计(U)
{{ funds_fmt(s.loss_sum_u) }}
+
单笔最大亏损(U)
{% if s.max_single_loss is not none %}{{ funds_fmt(s.max_single_loss) }}{% else %}-{% endif %}
+
单笔最大盈利(U)
{% if s.max_single_profit is not none %}{{ funds_fmt(s.max_single_profit) }}{% else %}-{% endif %}
+
最大回撤(U)
{{ funds_fmt(s.max_drawdown_u) }}
+
当前连续亏损笔数
{{ s.consecutive_losses }}
+
最长连续亏损(交易日)
{{ s.max_loss_streak_days }} 天
+
期内最大亏损日
{% if s.worst_day %}{{ s.worst_day }}({{ funds_fmt(s.worst_day_pnl) }}U){% else %}-{% endif %}
+
+
+{% endmacro %} +
+
+

加密货币|交易监控 + AI复盘一体化

+
{{ exchange_display }}
+
+ + {% with msg=get_flashed_messages() %}{% if msg %}
{{ msg[0] }}
{% endif %}{% endwith %} + +
+ 列表筛选(UTC,默认当日):{{ list_window.label }} + + + + + + + 统计页仍按北京时间 {{ stats_bundle.stats_reset_hour|default(reset_hour) }}:00 切日 +
+
+ 数据导出(v{{ data_export_version }} CSV,UTF-8;交易记录含开仓类型列,复盘单独导出): + 交易记录 + 复盘记录 + 关键位(当前) + 关键位历史 +
+
+
交易所
{{ exchange_display }}
+
总交易
{{ total }}
+
错过次数
{{ miss_count }}
+
胜率
{{ rate }}%
+
资金账户(USDT)
{% if funding_usdt is not none %}{{ funds_fmt(funding_usdt) }}U{% else %}—{% endif %}
+
交易日
{{ trading_day }}
+
当日资金(交易账户)
{{ funds_fmt(current_capital) }}U
+
+
实时价格更新时间:--(北京时间 UTC+8)
+ +
+ {% if page == 'key_monitor' %} +
+
+
+

关键位监控

+ {% if focus_key_id %} + 放大查看K线(默认200根) + {% else %} + 输入币种查看K线 + {% endif %} +
+
+ + + + + + + + + +
+
{{ key_gate_rule_text }}
+
+ {% for k in key %} +
+
+
+ {{ k.symbol }} + {{ '做多' if k.direction == 'long' else '做空' }} + {{ k.monitor_type }} +
+ +
+
+ 上沿: {{ k.upper }} + 下沿: {{ k.lower }} + {% if k.fib_entry_price %}挂E: {{ k.fib_entry_price }}{% endif %} + 已提醒: {{ k.notification_count or 0 }}/{{ k.max_notify or 3 }} + {% if k.monitor_type in ['箱体突破','收敛突破'] %} + 方案: {{ '标准突破' if (k.sl_tp_mode or 'standard') == 'standard' else ('箱体1R·止盈1.5H' if k.sl_tp_mode == 'box_1p5' else '趋势单') }} + {% endif %} + 保本: {{ '开' if k.breakeven_enabled else '关' }} +
+
+
现价-
+
距上沿-
+
距下沿-
+
门控-
+
+
+
+ {% else %} +
暂无监控中的关键位
+ {% endfor %} +
+
+
+

关键位历史

+
失效或已结案的关键位
+
+ {% for h in key_history %} +
+
+
+ {{ h.symbol }} + {{ '做多' if h.direction == 'long' else '做空' }} +
+ +
+
+ {{ h.monitor_type }} + {{ h.close_reason }} + {{ (h.closed_at or '-')[:16] }} +
+
+ 上: {{ h.upper }} 下: {{ h.lower }} + 提醒: {{ h.notification_count }} +
+ {% if h.last_alert_message %}
{{ h.last_alert_message[:180] }}{% if h.last_alert_message|length > 180 %}…{% endif %}
{% endif %} +
+ {% else %} +
暂无历史
+ {% endfor %} +
+
+
+ {% elif page == 'trade' %} +
+
+
+

实盘下单监控

+ {% if focus_order_id %} + 放大查看K线(100根) + {% else %} + 暂无持仓可放大 + {% endif %} +
+
+ 规则:最多 {{ max_active_positions }} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x; + {% if can_trade %}可开仓{% else %}不可开仓(持仓已满或未到北京时间 {{ reset_hour }}:00){% endif %}; + 人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1 +
+
+ 以损定仓:风险 {{ risk_percent }}% |移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}% +
+
+ 划转:自动划转 {{ '开启' if auto_transfer_enabled else '关闭' }}(每天北京时间 {{ auto_transfer_bj_hour }}:00起该整点小时内尝试;账簿按 UTC 自然日去重;界面时间为北京;将 {{ auto_transfer_to }} 补足到 {{ auto_transfer_amount }}U,来自 {{ auto_transfer_from }}) +
+
+ + + + +
+
+ + + + + + + + 成交价自动取交易所实时+成交回报 + + + + + +
+
+
+

实时持仓

+
+ {% for o in order %} +
+
+
+ {{ o.exchange_symbol or o.symbol }} + {{ '做多' if o.direction == 'long' else '做空' }} +
+
+ + 平仓 +
+
+
+ 来源: {{ o.monitor_type|default('下单监控', true) }}{% if o.key_signal_type %} · {{ o.key_signal_type }}{% endif %} + 风格: {{ o.trade_style or 'trend' }} + 风险: {{ o.risk_percent or '-' }}%≈{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U + + {% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %} + +
+
+
+ 成交价 + {{ price_fmt(o.symbol, o.trigger_price) }} +
+
+ 止损 + {% if o.stop_loss %} + {{ price_fmt(o.symbol, o.stop_loss) }} + {% else %} + + {% endif %} +
+
+ 止盈 + {% if o.take_profit %} + {{ price_fmt(o.symbol, o.take_profit) }} + {% else %} + + {% endif %} +
+
+ 盈亏比 + {% if o.rr_ratio is not none %}{{ '%g'|format(o.rr_ratio) }}:1{% else %}-:1{% endif %} +
+
+ 标记价 + - +
+
+ 浮盈亏 + - +
+
+ +
+
交易所止盈止损
+
+ 止损:加载中… + +
+
+ 止盈:加载中… + +
+
+
+ {% else %} +
暂无持仓
+ {% endfor %} +
+
+ +
+
+

挂止盈止损

+

将先撤销该合约已有 TP/SL,再按下列价格重挂。

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ {% endif %} + + + + {% if page == 'records' %} +
+

交易记录 & 错过机会

+
+ +
+
+ + + {% for r in record %} + + {% set pnl_val = (r.pnl_amount or 0)|float %} + + + + + {% set stop_show = r.display_open_stop_loss or r.initial_stop_loss or r.stop_loss %} + {% set tp_show = r.effective_take_profit or r.take_profit %} + + + + + + + + {% set pnl_val = (r.effective_pnl_amount or 0)|float %} + + + + + {% endfor %} +
品种类型方向成交止损(开仓)止盈基数杠杆持仓分钟开仓时间(北京)平仓时间(北京)盈亏U结果操作
{{ r.symbol }}{{ r.monitor_type }}{% if r.key_signal_type %} · {{ r.key_signal_type }}{% endif %}{{ '做多' if r.direction == 'long' else '做空' }}{{ price_fmt(r.symbol, r.trigger_price) }}{{ price_fmt(r.symbol, stop_show) }}{{ price_fmt(r.symbol, tp_show) }}{% if r.margin_capital is not none and r.margin_capital != '' %}{{ funds_fmt(r.margin_capital) }}{% else %}-{% endif %}{{ r.leverage or '-' }}{{ r.effective_hold_minutes or 0 }}{{ (r.effective_opened_at or '-')[:16] }}{{ (r.effective_closed_at or r.created_at or '-')[:16] }}{{ funds_fmt(r.effective_pnl_amount or 0) }}{% if r.display_pnl_source == 'exchange' %}{% elif r.display_pnl_source != 'reviewed' %}{% endif %} + {% set effective_result = r.effective_result %} + {% if effective_result in ["止盈","保本止盈","移动止盈"] %}{{ effective_result }} + {% elif effective_result in ["止损","强制清仓","手动平仓"] %}{{ effective_result }} + {% else %}{{ effective_result }}{% endif %} + + + + +
+
+
+ +
+

记录错过机会

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

交易复盘记录上传(含截图)

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

AI复盘(按交易记录)

+
+ + + + + + + +
+ + +
+
+ 交易复盘记录 +
+
+
+ AI历史复盘 +
+
+
+
+
+
+ {% endif %} + + + {% if page == 'stats' %} +
+
+

数据统计

+ +
+
+
+
持仓占用导致错过(累计)
{{ occupied_miss_total }}
+
+
+ 统计分析按北京时间 {{ stats_bundle.stats_reset_hour }}:00切日计入(与顶栏 UTC 列表窗无关)。历史总开仓(累计): + {{ stats_bundle.total_opens_all }} 次 +
+
+ +
+ {% for seg in stats_bundle.segments %} + + {% endfor %} +
+
+ {% endif %} + + + +
+
+
+
详情
+ +
+
+ +
+
+ + + + \ No newline at end of file diff --git a/crypto_monitor_binance/templates/key_focus.html b/crypto_monitor_binance/templates/key_focus.html new file mode 100644 index 0000000..41a633a --- /dev/null +++ b/crypto_monitor_binance/templates/key_focus.html @@ -0,0 +1 @@ +ok2 \ No newline at end of file diff --git a/crypto_monitor_binance/templates/key_focus_v2.html b/crypto_monitor_binance/templates/key_focus_v2.html new file mode 100644 index 0000000..26af46d --- /dev/null +++ b/crypto_monitor_binance/templates/key_focus_v2.html @@ -0,0 +1,261 @@ + + + + + {{ exchange_display }} | 关键位放大 + + + +
+
+
+
+ 返回首页 + 关键位放大(可输入币种){{ exchange_display }} +
+
最近刷新:--
+
+ +
+ + + + + + + + + + + + + + +
+
+ +
+
+
交易对
-
+
监控类型
-
+
方向
-
+
上沿/阻力
-
+
下沿/支撑
-
+
现价
-
+
距上沿
-
+
距下沿
-
+
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/crypto_monitor_binance/templates/login.html b/crypto_monitor_binance/templates/login.html new file mode 100644 index 0000000..cfcc816 --- /dev/null +++ b/crypto_monitor_binance/templates/login.html @@ -0,0 +1,118 @@ + + + + + 登录 · {{ exchange_display }} + + + + + + diff --git a/crypto_monitor_binance/templates/order_focus.html b/crypto_monitor_binance/templates/order_focus.html new file mode 100644 index 0000000..c0992d4 --- /dev/null +++ b/crypto_monitor_binance/templates/order_focus.html @@ -0,0 +1,194 @@ + + + + + 实盘下单放大 | 100根K线 + + + +
+
+
+
+ 返回首页 + 实盘下单放大(100根K线) +
+
最近刷新:--
+
+ {% if orders %} +
+ + + + + + +
+ {% else %} +
当前没有激活订单,无法展示放大K线。
+ {% endif %} +
+ + {% if orders %} +
+
+
交易对
-
+
方向
-
+
成交价
-
+
止损
-
+
止盈
-
+
盈亏比
-
+
现价
-
+
浮盈亏
-
+
+
+ +
+
+
+ {% endif %} +
+ +{% if orders %} + + +{% endif %} + + diff --git a/crypto_monitor_binance/templates/order_focus_v2.html b/crypto_monitor_binance/templates/order_focus_v2.html new file mode 100644 index 0000000..f9bceab --- /dev/null +++ b/crypto_monitor_binance/templates/order_focus_v2.html @@ -0,0 +1,214 @@ + + + + + {{ exchange_display }} | 实盘下单放大 + + + +
+
+
+
+ 返回首页 + 实盘下单放大(100根K线){{ exchange_display }} +
+
最近刷新:--
+
+ {% if orders %} +
+ + + + + + +
+ {% else %} +
当前没有激活订单,无法展示放大K线。
+ {% endif %} +
+ + {% if orders %} +
+
+
交易对
-
+
方向
-
+
成交价
-
+
止损
-
+
止盈
-
+
盈亏比
-
+
移动保本
-
+
现价
-
+
浮盈亏
-
+
+
+ +
+
+
+ {% endif %} +
+ +{% if orders %} + + +{% endif %} + + + diff --git a/crypto_monitor_binance/使用说明.md b/crypto_monitor_binance/使用说明.md new file mode 100644 index 0000000..72871f8 --- /dev/null +++ b/crypto_monitor_binance/使用说明.md @@ -0,0 +1,134 @@ +# 使用说明 + +**本文件对应仓库:`crypto_monitor_binance`(Binance U 本位永续)。** +功能、界面与 **Gate.io USDT 永续版**(目录 `crypto_monitor_gate`)基本一致,差异主要在 **`.env` 里交易所密钥与部分参数名**(`BINANCE_*` / `GATE_*`),文末有对照。 + +**部署、代理、PM2 等**请参考本仓库说明或 **`crypto_monitor_gate`** 下的 **`部署文档.md`**(该文以 Gate + SSH SOCKS 为例;Binance 侧将 API 与密钥改为 `BINANCE_*` 即可类比)。 +**关键位自动开仓的规则、RR、结案原因**见本目录 **`关键位自动下单说明.md`**。 + +--- + +## 1. 它能做什么 + +面向个人盘面的 **Web 控制台**,主要能力包括: + +| 模块 | 说明 | +|------|------| +| **关键位监控** | 录入上/下沿与类型,按 **5m 收线** 做硬条件过滤;符合条件后 **企业微信** 提醒,部分类型可 **自动市价开仓**(见第 4 节与专门文档)。 | +| **实盘下单监控** | 手工填止损/止盈,**以损定仓** 市价开单,挂上条件止盈止损,并在页面跟踪浮盈亏、保本逻辑等。 | +| **交易记录 / 复盘** | 平仓结果、盈亏、错过的单等归档与导出。 | + +后台按 **`MONITOR_POLL_SECONDS`**(默认几秒)轮询行情与监控逻辑。**切勿**在未理解规则时同时运行两套程序共用一个实盘账户。 + +--- + +## 2. 运行前必须配置(`.env`) + +首次在本目录执行 **`cp .env.example .env`**,再编辑 `.env`(`.env` 勿提交 Git;`git pull` 不会改你的 `.env`,升级前建议 `cp .env .env.backup.$(date +%Y%m%d)`)。 + +至少检查以下项(具体键名以 **`.env.example`** 为准): + +| 类别 | 说明 | +|------|------| +| **登录网页** | `APP_PASSWORD`:打开站点后的登录口令。`FLASK_SECRET_KEY`:Session 密钥,请勿使用默认值。 | +| **企业微信** | `WECHAT_WEBHOOK`:告警与关键位推送机器人的 Webhook。 | +| **是否真下单** | `LIVE_TRADING_ENABLED=false`:**不会**向交易所发送开仓指令(适合测试流程)。改为 `true` 且密钥正确才会实盘。 | +| **交易所 API** | **本仓库:** `BINANCE_API_KEY`、`BINANCE_API_SECRET`;永续相关见 `BINANCE_MARGIN_MODE`、`BINANCE_POSITION_MODE`、`BINANCE_TRIGGER_WORKING_TYPE` 等。**勿**把 `.env` 提交到 Git。 | +| **关键位 RR / 止损外扩** | `KEY_AUTO_MIN_PLANNED_RR`、`KEY_STOP_OUTSIDE_BREAKOUT_PCT`(详见 `关键位自动下单说明.md`)。 | + +网络需要代理时可配置 **`BINANCE_SOCKS_PROXY` / `BINANCE_HTTP_PROXY`**(与 Gate 版 `GATE_*_PROXY` 用法类似)。 + +--- + +## 3. 如何启动与登录 + +1. 准备 Python 虚拟环境并安装依赖(如 `flask`、`requests`、`ccxt`、按需 `Pillow`、`PySocks` 等),配置好 `.env`。 +2. 启动 Flask 应用(可用 **`ecosystem.config.cjs`** 交给 PM2,或本地 `python app.py` / `flask run`,以你当前脚本为准)。 +3. 浏览器访问站点,打开 **`/login`**,使用 **`.env` 里的 `APP_PASSWORD`** 登录。 + +登录后顶栏为四页:**关键位监控** | **实盘下单**(默认首页)| **交易记录与复盘** | **统计分析**。 + +--- + +## 4. 关键位监控(顶栏「关键位监控」→ `/key_monitor`) + +### 4.1 添加一条关键位 + +1. **币种**:如 `BTC` 或 `BTC/USDT`(会规范成内部符号)。 +2. **类型**(必选其一): + + | 类型 | 行为摘要 | + |------|----------| + | **箱体突破** | 通过门控且计划 RR 达标 → **自动市价开仓**(需 `LIVE_TRADING_ENABLED=true` 且无其他持仓占位)。结案后本条从列表消失并记入历史。 | + | **收敛突破** | 同上(自动开仓类)。 | + | **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 | + | **关键支撑位** | 同上(仅提醒)。 | + +3. **方向**:做多 / 做空(必选)。 +4. **上沿 / 下沿**:必填;保存时会按交易所 **价格精度** 取整。 + +**限制:** +活跃持仓数达到 **`MAX_ACTIVE_POSITIONS`**(默认 1)时,**不允许**再添加「**箱体突破** / **收敛突破**」;仍可添加「**关键阻力位 / 支撑位**」。 +若 **4h EMA55** 与你的方向逆势,页面会 **额外 Flash 提示**,**不阻挡**提交。 + +### 4.2 触发后会发生什么(简版) + +- **箱体 / 收敛**:门控通过后算计划 SL/TP 与 RR;不达标 → 微信说明 + **`rr_insufficient`** 结案;达标 → **市价开仓**,成功 **`auto_opened`** / 失败 **`exchange_failed`**,均不重试同一关键位。 +- **阻力 / 支撑**:仅 **单次推送** → **`key_level_alert_only`** 结案。 + +详细公式与字段见 **`关键位自动下单说明.md`**。 + +### 4.3 列表与历史 + +当前条目与历史记录的用法与 Gate 版相同;结案后可在历史区查阅 **`close_reason`**。 + +--- + +## 5. 实盘下单(顶栏「实盘下单」→ `/trade`) + +- 持仓上限由 **`MAX_ACTIVE_POSITIONS`** 控制(默认 1)。 +- **人工开仓**计划盈亏比不得低于 **`MANUAL_MIN_PLANNED_RR`**(默认 1.4:1)。 +- 填写币种、方向、杠杆(可选)、止损/止盈(价格或百分比按表单)。 +- 移动保本等选项按页面与 `.env` 默认。 + +开仓成功后卡片 **「来源」**:手工一般为 **下单监控**;关键位自动为 **关键位监控**。 + +--- + +## 6. 企业微信 + +推送逻辑与 Gate 版一致;未配置 **`WECHAT_WEBHOOK`** 时可能没有消息,请以 **交易所端** 核对持仓与挂单。 + +--- + +## 7. 强烈建议的风险与运维习惯 + +1. **先用 `LIVE_TRADING_ENABLED=false`** 熟悉流程再实盘。 +2. **API 权限**最小化,密钥勿泄露。 +3. **同一账户避免多程序重复开仓**。 +4. **自动备份**:服务器上执行 `bash scripts/install_backup_cron.sh`(每天北京时间 0:00 → `/root/backups`,保留 30 天);升级前也可 `bash scripts/backup_data.sh` 手动跑一次。 +5. 升级代码后留意 **首轮启动**有无数据库迁移报错。 + +--- + +## 8. 常见问题(简要) + +| 现象 | 可自查 | +|------|--------| +| 关键位永远不触发 | 门控五项、日成交量排名、`KLINE_TIMEFRAME`。 | +| 有信号但不自动开仓 | `LIVE_TRADING_ENABLED`、RR 阈值、是否已有持仓、API/保证金错误信息。 | +| 加不了箱体/收敛 | 是否已有持仓。 | +| 推送收不到 | Webhook、网络。 | + +--- + +## 9. Gate 版(`crypto_monitor_gate`)差异速查 + +| 项目 | Binance 本仓库 | Gate 版 | +|------|----------------|--------| +| API 变量 | `BINANCE_API_KEY`、`BINANCE_API_SECRET`、`BINANCE_*` | `GATE_API_KEY`、`GATE_API_SECRET`、`GATE_*` | +| 代理示例 | `BINANCE_SOCKS_PROXY` | `GATE_SOCKS_PROXY` | +| TP/SL 实现 | `_binance_place_tp_sl_orders` | `_gate_place_tp_sl_orders`、`GATE_TPSL_*` | +| 资金舍入口径 | **`FUNDS_DECIMALS`**(与记账一致) | 以 Gate 仓库实现为准 | + +业务流程(登录、四种关键位、手工单、单仓)两份程序对齐;仅需更换目录与 `.env`。 diff --git a/crypto_monitor_binance/关键位自动下单说明.md b/crypto_monitor_binance/关键位自动下单说明.md new file mode 100644 index 0000000..235b52b --- /dev/null +++ b/crypto_monitor_binance/关键位自动下单说明.md @@ -0,0 +1,101 @@ +# 关键位自动下单说明 + +**适用仓库:`crypto_monitor_binance`|交易所:Binance U 本位永续**(Gate 版见同名的 `crypto_monitor_gate` 目录。) + +本文档与 `.env`、`app.check_key_monitors`、`app.add_key`、`_market_open_for_key_monitor` 的实现一致。 + +--- + +## 结构与是否自动开仓 + +| `key_monitors.monitor_type`(录入类型) | 自动下单 | 触发后处置 | +|---------------------------------------|----------|------------| +| **箱体突破** | 是(满足全部条件) | **一次性结案**:写 `key_monitor_history` → 从 `key_monitors` **删除** | +| **收敛突破** | 是(同上) | 同上 | +| **关键阻力位** | 否 | 企业微信 **1 次** → `close_reason=key_level_alert_only` → **失效** | +| **关键支撑位** | 否 | 同上 | + +触发条件:**5m 收线硬门控** `_key_hard_checks`(量能、突破幅度、第二根收盘确认、日成交量前 30 等)。 + +--- + +## 录入限制(`/add_key`) + +- 存在 **`order_monitors.status='active'`** 时:**禁止添加** 「箱体突破」「收敛突破」。 +- **关键阻力位 / 关键支撑位**:不受上条限制;触发后 **仅单次微信提醒**,然后结案。 +- **4h EMA55 与所选方向逆势**:**不拦截**;添加成功后 **Flash** 提示。 +- 上下沿入库前经 **`round_price_to_exchange`** 按合约 **价格精度** 取整。 + +--- + +## 环境与参数(`.env`) + +| 变量 | 含义 | 默认 | +|------|------|------| +| `KEY_AUTO_MIN_PLANNED_RR` | 计划 RR 阈值:**仅当严格大于该值** 才自动开仓(按下方 `E` 计算) | `1.5` | +| `KEY_STOP_OUTSIDE_BREAKOUT_PCT` | 止损:突破 K 极值向外 **百分比**(多:`低×(1−p/100)`;空:`高×(1+p/100)`) | `0.5` | + +**其余与本仓库手动实盘一致:** `KLINE_TIMEFRAME`、`RISK_PERCENT`、`LIVE_TRADING_ENABLED`、`BREAKEVEN_*`、`DAILY_OPEN_ALERT_THRESHOLD`,以及 **`BINANCE_*`**(密钥、`BINANCE_MARGIN_MODE`、`BINANCE_POSITION_MODE`、`BINANCE_TRIGGER_WORKING_TYPE` 等)。资金字段舍入端口径与 **`FUNDS_DECIMALS`** 一致。 + +--- + +## 计价与下单口径 + +| 用途 | 价格 | +|------|------| +| 企业微信展示、**与 RR 门槛比较的计划 RR** | 确认 K(第二根闭合 5m)收盘 **`E`** | +| **实际开仓** | **市价**(`place_exchange_order`,与 `/add_order` 一致);成交价可能与 `E` **滑点** | +| **以损定仓** | `calc_risk_fraction(direction, 当前市价, 止损)` + `RISK_PERCENT`(保证金等 **`FUNDS_DECIMALS`** 舍入,与 `/add_order` 一致) | + +- 开仓成功后:`order_monitors.monitor_type` 为 **关键位监控**;持仓卡片「来源」显示之。手动开仓为 **下单监控**。 +- 持仓列表中的 **盈亏比**:按 **实际成交价** 相对 SL/TP 重算,可与「按 `E` 算的计划 RR」略有偏差。 +- **本仓库止盈止损挂单**:开仓后由 **`_binance_place_tp_sl_orders`** 挂载(与手动一致:U 本位条件/Algo 类触发单;具体类型以 ccxt / 交易所为准)。 + +--- + +## 自动单止盈 / 止损(仅箱体突破、收敛突破) + +添加关键位时在页面选择 **止盈止损方案**(写入 `key_monitors.sl_tp_mode`)。确认 K 收盘 **E**,箱体高 **H = |upper − lower|`**。 + +| 方案 | `sl_tp_mode` | 多:SL / TP | 空:SL / TP | +|------|--------------|-------------|-------------| +| 标准突破(默认) | `standard` | 突破 K 低 × (1−`KEY_STOP_OUTSIDE_BREAKOUT_PCT`%) / **E+H** | 突破 K 高 × (1+外侧%) / **E−H** | +| 箱体1R·止盈1.5H | `box_1p5` | **E−H** / **E+1.5×H**(RR≈1.5) | **E+H** / **E−1.5×H** | +| 趋势单·自填止盈 | `trend_manual` | 突破 K 低 × (1−`KEY_TREND_STOP_OUTSIDE_PCT`%) / **录入止盈** | 突破 K 高 × (1+外侧%) / **录入止盈** | + +计划 **`RR = calc_rr_ratio(direction, E, SL, TP)`**。若为 `None` 或 **RR ≤ `KEY_AUTO_MIN_PLANNED_RR`** → **不下单**,走 `rr_insufficient` 结案。 + +**移动保本:** 添加时可勾选(默认关);开仓写入 `order_monitors.breakeven_enabled` 与勾选一致。详见仓库根目录 `关键位止盈止损与移动保本更新说明.md`。 + +--- + +## 一次性结案(`close_reason`) + +以下任一发生:**按需发微信** → **`key_monitor_history`** → **从 `key_monitors` 删除**;**不会对同一条关键位重复轮询重试开仓**。 + +| `close_reason` | 含义 | +|----------------|------| +| `rr_insufficient` | 门控通过,但计划 RR 未达标或 SL/TP / RR **几何无效** | +| `exchange_failed` | 计划 RR 达标,但未开实盘、`LIVE_TRADING_ENABLED=false`、风控、保证金或 **交易所报错** 等导致 **开仓失败** | +| `auto_opened` | 计划 RR 达标且 **市价开仓成功**(已写 `order_monitors`,并已挂止盈止损) | +| `key_level_alert_only` | 阻力/支撑位 **仅推送**结案 | + +--- + +## 与企业微信推送 + +每种结案路径 **至多一条**主业务推送(RR 不足 / 下单失败 / 开仓成功 / 阻力支撑仅提醒)。 + +旧版「满 `KEY_ALERT_MAX_TIMES` 次再归档」对已触发结案的路径 **不再适用**;表中 `notification_count`、`max_notify` 等字段仍可能存在,以 **导出、兼容** 为主。 + +--- + +## 相关代码位置(通用) + +| 说明 | 符号 | +|------|------| +| 门控与主循环 | `check_key_monitors` | +| 录入、有仓拦截、4h Flash | `add_key` | +| 市价开仓 + 写 `order_monitors` | `_market_open_for_key_monitor` | +| 计划 RR | `calc_rr_ratio(direction, E, SL, TP)` | +| 价格精度 | `round_price_to_exchange` | diff --git a/crypto_monitor_binance/更新文档.md b/crypto_monitor_binance/更新文档.md new file mode 100644 index 0000000..92b6d89 --- /dev/null +++ b/crypto_monitor_binance/更新文档.md @@ -0,0 +1,147 @@ +# 界面与风控更新说明(Binance 实例) + +## 顶栏导航(4 项) + +| 顺序 | 名称 | 路由 | 说明 | +|------|------|------|------| +| 1 | 关键位监控 | `/key_monitor` | 关键位添加、实时门控、历史 | +| 2 | 实盘下单 | `/trade` | 人工开仓、划转、实时持仓(**默认首页** `/` → `/trade`) | +| 3 | 交易记录与复盘 | `/records` | 交易记录、复盘表单、AI 历史(受顶栏 UTC 时间窗筛选) | +| 4 | 统计分析 | `/stats` | 按北京时间交易日切日 + 分品类统计块 | + +## 关键位监控页 + +- 标题去掉「5m」;规则条从 `.env` 读取(周期、确认K、量能、自动开仓盈亏比、日成交量排名)。 +- 左列:活跃关键位,**pos-card** 样式展示现价/距上沿/距下沿/门控。 +- 右列:关键位历史(失效/结案),与左列等高滚动;**受顶栏 UTC 列表时间窗筛选**(默认 UTC 当日)。 +- 监控类型新增:**斐波回调0.618**、**斐波回调0.786**(与 Gate 主站同一套规则,计算逻辑见仓库根目录 `fib_key_monitor_lib.py`)。 + +### 斐波关键位监控(方案 A:交易所限价) + +| 项 | 说明 | +|----|------| +| 同币互斥 | 每个币种只能有一条斐波监控(0.618 与 0.786 不可并存) | +| 上下沿 | 上沿 **H**、下沿 **L**(须 H > L) | +| 挂单价 E | **做多** `E = H − ratio × (H − L)`(自 H 向下回撤);**做空** `E = L + ratio × (H − L)`(自 L 向上反弹) | +| 做多 | 限价 @ E,止损 L,止盈 H | +| 做空 | 限价 @ E,止损 H,止盈 L | +| 添加后 | **立即**在 Binance U 本位挂限价单;卡片显示 **挂E**、限价单 ID | +| 失效 | 以**标记价**判断:做多且标记价 ≥ H、做空且标记价 ≤ L,且限价**未成交** → 撤销该限价单并结案 | +| 成交后 | 挂交易所 TP/SL(含 Algo 通道条件单)→ 写入 **实盘下单监控**(`monitor_type=关键位监控`,`key_signal_type=斐波回调…`)→ 从关键位列表移除 | +| 撤单 | 仅撤本条斐波的订单 ID,**不会**对该合约 `cancel_all_orders` / 全撤 Algo,避免误伤其他委托 | +| 盈亏比 | 计划 RR 须 > `KEY_AUTO_MIN_PLANNED_RR`;0.618 理论约 1.6:1,0.786 约 3.7:1 | +| 日成交量 | 与箱体/收敛相同,须在前 `KEY_DAILY_VOLUME_RANK_MAX` 名内方可添加 | + +后台轮询:`check_fib_key_monitors()`;箱体/收敛仍走 `check_key_monitors()`。 + +手动删除关键位时,未成交斐波会先撤限价再删库。 + +### 箱体 / 收敛自动开仓(来源标注) + +- 自动开仓写入 `order_monitors.key_signal_type`:`箱体突破` 或 `收敛突破`。 +- 持仓与交易记录展示「来源 · 信号类型」。 + +## 列表时间窗(UTC,全站顶栏) + +共用模块:仓库根目录 `history_window_lib.py`(Gate / Binance 主站一致)。 + +| 项 | 说明 | +|----|------| +| 默认 | **UTC 当日**(`win_preset=utc_today`,从 UTC 0:00 至当前时刻) | +| 可选 | 近 24 小时、近 7 天、自定义起止(UTC,`datetime-local`) | +| 作用范围 | 关键位历史、交易记录列表、复盘记录 API、AI 历史 API、导出「交易记录」「关键位历史」 | +| 与统计的关系 | **仅影响列表/导出**;**统计分析页仍按北京时间 `TRADING_DAY_RESET_HOUR`(默认 8:00)切交易日** | +| 库内时间 | DB 存北京时间字符串;后端用 `utc_window_to_bj_sql_strings()` 换算后再 SQL 比较 | +| 切换方式 | 顶栏「列表筛选(UTC)」→ 选预设 → **应用**(保留当前路由,如 `/records?win_preset=…`) | + +查询参数示例: + +- `?win_preset=utc_today` +- `?win_preset=utc_last24h` / `utc_last7d` +- `?win_preset=custom&from_utc=2026-05-18 00:00:00&to_utc=2026-05-19 12:00:00` + +## 交易记录与复盘 + +- 交易记录盈亏以**本地估算**为准(平仓时按成交/计划价计算);盈亏列可标注 **估**。 +- 与币安 App 不一致时,请在「核对修改」或复盘中 **手工填写** `reviewed_pnl_amount` 覆盖展示(不再提供批量「同步交易所盈亏」)。 +- **列表默认只显示当前 UTC 时间窗内**的记录(见上节);导出 CSV 同步该时间窗。 +- 表头 **「止损(开仓)」**:展示开仓快照 `initial_stop_loss`(无则回退 `stop_loss`);核对/复盘仍可用有效止损字段。 +- 平仓写入 `trade_records` 时:`stop_loss` 与 `initial_stop_loss` 均写入**开仓时止损快照**;`key_signal_type` 保留箱体/收敛/斐波来源(`fib_key_monitor_lib.key_signal_type_for_trade_record`)。 +- **开仓类型**(`entry_reason`):机器单平仓入库时,若未手填,按 `key_signal_type` 自动映射(见下表);列表/导出「开仓类型」列 = 复盘核对值优先,否则入库值,否则按信号映射。 + +| `key_signal_type` | 自动写入的 `entry_reason` | +|-------------------|---------------------------| +| 箱体突破 | 关键位箱体突破 | +| 收敛突破 | 关键位收敛突破 | +| 斐波回调0.618 | 关键位斐波0.618 | +| 斐波回调0.786 | 关键位斐波0.786 | + +- 复盘表单 **开仓类型** 下拉新增上述四条固定文案(与趋势/波段类并列)。 +- 复盘 **离场触发** 新增 **「止盈」**;从交易记录「填入复盘」时,若结果为「止盈/保本止盈/移动止盈/止损/手动平仓」会自动选中对应触发项,并按 `key_signal_type` 预填开仓类型。 +- 勾选「保存时自动生成多周期 K 线图」时:以 **平仓时间** 为锚点,各周期向前约 `ORDER_CHART_LIMIT`(默认 100)根 K 线(`_fetch_ohlcv_ending_at`),不再固定拉「最近 100 根」。 +- `/api/journals`、`/api/reviews` 支持同一时间窗 query,与列表一致。 + +### 导出(交易记录 v3) + +- 文件名:`trade_records_v3_YYYYMMDD.csv` +- 相对 v2 增加:`key_signal_type`、`initial_stop_loss`(及开仓快照列)、`planned_rr`、`actual_rr`、`risk_amount`、交易所盈亏与时间字段等;末列「开仓类型」为有效展示文案。 +- 「关键位历史」导出同样受 UTC 时间窗限制。 + +## 实盘下单页 + +- 左列:实盘下单监控(表单、划转、规则)。 +- 右列:实时持仓(独立模块)。 +- **人工开仓门控**:计划盈亏比 < `MANUAL_MIN_PLANNED_RR`(默认 **1.4**)时前端弹窗 + 后端拒绝。 +- **移动保本**(勾选启用):监控轮询达到触发 RR 后,止损阶梯上移时**同步交易所**——**先撤**该合约全部 TP/SL(含 Algo 条件单)**再挂**新止损 + 原止盈(`replace_active_monitor_tpsl_on_exchange`)。仅交易所成功后才写库;失败发企业微信告警。未配置实盘 API 时仍只更新本地。 + +## 统计分析页(`/stats`) + +| 项 | 说明 | +|----|------| +| 切日 | **北京时间**;交易日边界 = 每日 `TRADING_DAY_RESET_HOUR:00`(`.env` 默认 **8**) | +| 品类下拉 | 页顶 **「统计品类」** 下拉切换(默认「全部交易」):全部交易、下单监控、关键位箱体突破、关键位收敛结构、关键位斐波0.618、关键位斐波0.786;一次只显示所选品类的日/周/月 | +| URL | 切换后写入 `stats_segment=`(如 `all`、`manual`、`key_box`、`key_conv`、`key_fib618`、`key_fib786`),刷新 `/stats` 可保持选项 | +| 每块指标 | 日 / 周 / 月:开单次数、平仓笔数、胜率、净盈亏、回撤、连续亏损等(与原口径一致) | +| 开单次数 | 人工块:`monitor_type=下单监控` 且无 `key_signal_type`;关键位块:按 `order_monitors.key_signal_type` 计数 | +| 不受 UTC 窗影响 | 统计始终基于库内全部已平仓记录,按北京交易日归类,**不**随顶栏 UTC 列表窗切换 | + +## 持仓与计仓 + +- `MAX_ACTIVE_POSITIONS` 默认 **1**(可在 `.env` 调大)。 +- 关键位自动开仓:在已有持仓时,若 `KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT=true`,按**首笔开仓前**交易账户资金快照计仓(字段 `trading_sessions.key_sizing_capital_snapshot`)。 + +## 配置 + +详见 `.env.example` 中「关键位门控」「交易执行 / 人工风控」注释段。 + +## 自动备份(服务器) + +- 脚本:`scripts/backup_data.sh`(`crypto.db` + `static/images`) +- 定时:`scripts/install_backup_cron.sh` → 每天 **北京时间 0:00**,目录 **`/root/backups/<实例名>/YYYY-MM-DD/`**,保留 **30** 天 +- 详见 `部署文档.md` 第 5.3 节 + +## 数据库(启动时自动迁移) + +`key_monitors` 斐波字段:`fib_limit_order_id`、`fib_entry_price`、`fib_stop_loss`、`fib_take_profit`、`fib_order_amount`、`fib_margin_capital`、`fib_leverage`。 + +`trade_records` / `order_monitors`:`key_signal_type`、`exchange_realized_pnl`、`exchange_opened_at`、`exchange_closed_at`、`exchange_sync_key`、`entry_reason`、`reviewed_entry_reason`、`initial_stop_loss`。 + +**历史数据**:本次**不做**旧记录的批量回填(`entry_reason` / `initial_stop_loss` / `key_signal_type` 等);仅**新产生**的平仓与复盘按新逻辑写入。旧行展示可回退已有字段。 + +## 涉及文件(便于排查) + +| 路径 | 说明 | +|------|------| +| `history_window_lib.py` | UTC 时间窗解析与转北京时间 SQL 字符串 | +| `fib_key_monitor_lib.py` | 斐波计算、`KEY_ENTRY_REASON_BY_SIGNAL`、`entry_reason_from_key_signal` | +| `crypto_monitor_binance/app.py` | 列表筛选、统计分块、导出 v3、复盘 K 线锚点、入库逻辑 | +| `crypto_monitor_binance/templates/index.html` | 顶栏时间窗、统计分块 UI、止损(开仓)列、复盘预填 | + +## 升级步骤 + +1. `git pull` 后对比 `.env.example`,把新增变量合并进本地 `.env`。 +2. 在 VPS 上为 Binance / Gate / Gate Bot **各执行一次** `bash scripts/install_backup_cron.sh`(若尚未安装)。 +3. 重启 Binance 实例(如 `pm2 restart crypto_binance`);SQLite 会自动 `ALTER` 缺列(斐波、交易所盈亏、`entry_reason` 等)。 +4. 浏览器强刷(Ctrl+F5)避免旧版 `index.html` 缓存。 +5. 打开任意页确认顶栏出现 **「列表筛选(UTC)」**;`/stats` 可见分品类统计与「北京 8:00 切日」说明。 +6. 建议先用测试币验证斐波:限价挂出、标记价失效撤单、成交后 TP/SL 与订单监控是否正常;平仓后检查交易记录止损(开仓)与开仓类型。 diff --git a/crypto_monitor_binance/部署文档.md b/crypto_monitor_binance/部署文档.md new file mode 100644 index 0000000..9bff01e --- /dev/null +++ b/crypto_monitor_binance/部署文档.md @@ -0,0 +1,378 @@ +# `crypto_monitor_binance` 部署指南:SSH SOCKS + Binance + PM2(Ubuntu) + +项目功能、环境变量总览与本地运行说明见 **[README.md](./README.md)**。 + +本文面向:**在本机或 VPS 上运行本项目**,但 **直连 Binance API 不稳定、超时或被网络策略拦截** 的场景。思路是: + +- 本机用 `ssh -D` 做动态转发,把 **SOCKS5 出口**放到能稳定访问 Binance 的机器(常见为一台境外 VPS) +- 项目在 `.env` 中设置 **`BINANCE_SOCKS_PROXY=socks5h://127.0.0.1:1080`**(或你实际端口),`ccxt` 经 SOCKS 访问交易所 +- **SSH 隧道**:用 `ssh -D` 在本机常驻即可(screen / tmux / systemd 等),**不必交给 PM2** +- 使用 **PM2** 仅托管 **Flask 应用**;仓库根目录 **`ecosystem.config.cjs`** 默认进程名为 **`crypto-monitor-binance`** + +> 安全提醒:不要把 `.env`、私钥 `.pem`、Binance API Key / Secret 提交到 Git;下文只用占位符。 + +--- + +## 0. 你需要准备的东西 + +- 一台 **Ubuntu**(或同类 Linux)运行项目的机器(下文称「本机」) +- 一台可 SSH 登录、且 **能正常访问 Binance API** 的 VPS(示例:`HostName` 填你的服务器 IP,用户如 `root`) +- SSH:**私钥登录**(推荐,便于隧道脚本无人值守) +- 本机已安装:`python3`、`python3-venv`、`pip`、`curl`、`ssh`、`git`(可选)、`node` + `npm`(安装 PM2) +- Binance 账户:已开通 **USDT-M 永续合约**;API Key 勾选 **合约**、**万向划转**(若使用资金↔合约划转)等所需权限,并配置 **IP 白名单**(若启用) + +--- + +## 1. 获取代码与目录 + +将包含 `app.py` 的项目放到固定目录,例如: + +```bash +mkdir -p /opt/crypto_monitor +cd /opt/crypto_monitor +git clone https://git.bz121.com/dekun/crypto_monitor.git +cd crypto_monitor/crypto_monitor_binance +``` + +下文用 **`/opt/crypto_monitor/crypto_monitor_binance`** 仅为示例,请换成你的实际绝对路径。 + +拉取代码后,若目录下尚无 `.env`,先从模板生成(**勿**把填好密钥的 `.env` 提交 Git): + +```bash +cp -n .env.example .env # -n:已存在 .env 时不覆盖 +``` + +--- + +## 2. 配置 SSH 私钥与 `~/.ssh/config` + +```bash +mkdir -p ~/.ssh +chmod 700 ~/.ssh +# 私钥示例:~/.ssh/vps1.pem +chmod 600 ~/.ssh/vps1.pem +``` + +编辑 `~/.ssh/config`(示例别名 **`bn-vps`**,与你手工启动 `ssh -D ... bn-vps` 一致即可): + +```sshconfig +Host bn-vps + HostName 你的_VPS_IP + User root + IdentityFile ~/.ssh/vps1.pem + IdentitiesOnly yes + ServerAliveInterval 30 + ServerAliveCountMax 3 + ExitOnForwardFailure yes + BatchMode yes +``` + +测试: + +```bash +ssh bn-vps true +``` + +> 若尚未完全改为密钥登录,可暂时注释 `BatchMode yes`,调试完成后再打开。 + +--- + +## 3. 手工验证:SSH SOCKS + Binance API + +### 3.1 本地 SOCKS(示例端口 1080) + +```bash +ssh -N -D 127.0.0.1:1080 bn-vps +``` + +保持运行,另开终端继续。 + +### 3.2 验证经 SOCKS 可访问 Binance(公开接口) + +```bash +curl -4 -sS --max-time 15 --proxy socks5h://127.0.0.1:1080 https://api.binance.com/api/v3/time +``` + +应返回 JSON(含 `serverTime` 字段)。若此处失败,**不要先启动应用**:先修隧道或 VPS 出站。 + +--- + +## 4. Python 虚拟环境 + +```bash +cd /opt/crypto_monitor/crypto_monitor_binance + +python3 -m venv .venv +source .venv/bin/activate +python -m pip install -U pip +pip install flask requests ccxt werkzeug PySocks Pillow +``` + +走 SOCKS 时 **必须** 安装 **`PySocks`**,否则易出现代理相关报错。 + +可选: + +```bash +export PYTHONDONTWRITEBYTECODE=1 +``` + +--- + +## 5. 配置环境变量(`.env.example` → `.env`) + +| 文件 | 是否进 Git | 说明 | +|------|------------|------| +| **`.env.example`** | ✅ 是 | 变量模板与注释,可随 `git pull` 更新 | +| **`.env`** | ❌ 否 | 本机真实配置;`app.py` **只读此文件** | + +### 5.1 首次配置 + +```bash +cd /opt/crypto_monitor/crypto_monitor_binance + +cp -n .env.example .env # 已存在 .env 时不覆盖 +nano .env # 填入 API、登录密码、端口、代理等 +``` + +### 5.2 备份与 `git pull` + +- **`.env` 已被仓库根目录 `.gitignore` 忽略**:`git pull` **不会**覆盖或删除你本地的 `.env`。 +- 若远端更新了 **`.env.example`**(新增变量名),pull 后请对照模板,**手动把新行补进你的 `.env`**(不会自动合并进 `.env`)。 +- **建议在每次 `git pull` 或大批量改配置前备份**: + +```bash +cp .env .env.backup.$(date +%Y%m%d) +# 恢复示例:cp .env.backup.20260516 .env +``` + +- **换机 / 迁移**:用 `scp` 复制整份 `.env` 到新机器对应目录;或在新机重新 `cp .env.example .env` 后填写。 + +### 5.3 自动备份(数据库 + 复盘图片) + +默认每天 **北京时间 0:00** 备份到 **`/root/backups`**,保留 **30 天** 后自动删除更早的目录。 + +备份内容(路径来自 `.env` 的 `DB_PATH`、`UPLOAD_DIR`): + +- `crypto.db`(优先 `sqlite3 .backup` 热备) +- `static/images` 打包为 `static_images.tar.gz` + +目录结构示例: + +```text +/root/backups/crypto_monitor_binance/2026-05-17/ + crypto.db + static_images.tar.gz + manifest.txt +``` + +**一次性安装定时任务**(在对应项目目录执行,Binance / Gate 各执行一次): + +```bash +cd /opt/crypto_monitor/crypto_monitor_binance +chmod +x scripts/backup_data.sh scripts/install_backup_cron.sh +bash scripts/install_backup_cron.sh +``` + +Gate 实例: + +```bash +cd /opt/crypto_monitor/crypto_monitor_gate +bash scripts/install_backup_cron.sh +``` + +Gate Bot 实例(趋势回调等): + +```bash +cd /opt/crypto_monitor/crypto_monitor_gate_bot +bash scripts/install_backup_cron.sh +``` + +**立即试跑**(不写 cron): + +```bash +bash scripts/backup_data.sh +``` + +日志默认:`/var/log/crypto-monitor-backup-<项目目录名>.log`。可选在 `.env` 中覆盖:`BACKUP_ROOT`、`BACKUP_RETENTION_DAYS`、`BACKUP_INSTANCE`。 + +**恢复示例**(先停 PM2,再覆盖文件): + +```bash +pm2 stop crypto-monitor-binance +cp /root/backups/crypto_monitor_binance/2026-05-16/crypto.db ./crypto.db +tar -xzf /root/backups/crypto_monitor_binance/2026-05-16/static_images.tar.gz -C . +pm2 start ecosystem.config.cjs +``` + +建议安装:`apt install -y sqlite3`(热备更稳)。 + +### 5.4 必填项检查(Binance + 代理) + +与交易所相关的变量使用 **`BINANCE_`** 前缀(与代码一致)。至少确认: + +```env +APP_HOST=127.0.0.1 +APP_PORT=5000 + +# 实盘(按需) +LIVE_TRADING_ENABLED=false +BINANCE_API_KEY=你的_Key +BINANCE_API_SECRET=你的_Secret + +# 保证金:cross=全仓 isolated=逐仓(与币安账户/习惯一致) +BINANCE_MARGIN_MODE=cross + +# 持仓模式:hedge=双向(需在币安开启双向持仓);oneway=单向 +BINANCE_POSITION_MODE=hedge + +# 条件单触发参考:CONTRACT_PRICE=最新成交价 MARK_PRICE=标记价 +BINANCE_TRIGGER_WORKING_TYPE=CONTRACT_PRICE + +# 经本机 SSH 动态转发访问 Binance(端口与隧道一致) +BINANCE_SOCKS_PROXY=socks5h://127.0.0.1:1080 + +# 若不用 SOCKS,可改用 HTTP 代理(一般二选一) +# BINANCE_HTTP_PROXY=http://127.0.0.1:7890 +# BINANCE_HTTPS_PROXY=http://127.0.0.1:7890 +``` + +说明:**推荐 `socks5h://`**,由 SOCKS 端解析域名,与 `curl --proxy socks5h://...` 行为一致。 + +**止盈止损说明(应用逻辑)**:实盘开仓后,程序会在 Binance USDT-M 永续上挂 **`STOP_MARKET`(止损)** 与 **`TAKE_PROFIT_MARKET`(止盈)**;`BINANCE_POSITION_MODE=hedge` 时会自动带 **`positionSide`**,须与币安合约「双向持仓」开关一致。不显式传 **`reduceOnly`**(否则易触发 API **`-1106`**:`Parameter 'reduceOnly' sent when not required`)。 + +--- + +## 6. 自检脚本(可选) + +在已配置 `.env` 且网络可达的前提下: + +```bash +cd /opt/crypto_monitor/crypto_monitor_binance +source .venv/bin/activate +python scripts/verify_binance_funding.py +``` + +用于粗测资金钱包与合约钱包 USDT 读取(需有效 API 与权限)。 + +--- + +## 7. 手工启动 Flask(验证) + +1. SOCKS 已监听 `127.0.0.1:1080`(若使用代理) +2. 已 `source .venv/bin/activate` +3. `.env` 已按需配置 `BINANCE_SOCKS_PROXY` 等 + +```bash +cd /opt/crypto_monitor/crypto_monitor_binance +source .venv/bin/activate +python app.py +``` + +浏览器访问:`http://127.0.0.1:5000`(或你在 `.env` 中的端口)。 + +--- + +## 8. 安装 PM2 + +```bash +sudo npm i -g pm2 +pm2 -v +``` + +--- + +## 9. PM2:使用仓库内 `ecosystem.config.cjs`(推荐) + +在项目根目录: + +```bash +cd /opt/crypto_monitor/crypto_monitor_binance +pm2 start ecosystem.config.cjs +pm2 status +pm2 logs --lines 200 +``` + +默认只启动 **`crypto-monitor-binance`**(`.venv/bin/python app.py`)。 + +### 本机已可直连 Binance、不需要隧道时 + +`.env` 里应 **去掉或留空** `BINANCE_SOCKS_PROXY`(除非仍要走别的代理),再 `pm2 start ecosystem.config.cjs`。 + +### 开机自启 + +```bash +pm2 save +pm2 startup +# 按屏幕提示执行一条 sudo 命令 +``` + +--- + +## 10. 等价手工命令(不使用 ecosystem 文件时) + +### 10.1 SSH SOCKS(自行后台常驻,不推荐用 PM2) + +示例(前台;实际可用 `screen`/`tmux`/`-f` 后台化或 systemd): + +```bash +ssh -N -D 127.0.0.1:1080 bn-vps \ + -o ServerAliveInterval=30 -o ServerAliveCountMax=3 \ + -o ExitOnForwardFailure=yes +``` + +### 10.2 Flask + +```bash +cd /opt/crypto_monitor/crypto_monitor_binance +pm2 start /opt/crypto_monitor/crypto_monitor_binance/.venv/bin/python --name crypto-monitor-binance -- \ + /opt/crypto_monitor/crypto_monitor_binance/app.py +``` + +--- + +## 11. 交易所「连接不上」排查清单 + +1. **`.env` 是否为 Binance 变量**:`BINANCE_SOCKS_PROXY` / `BINANCE_HTTP_PROXY` / `BINANCE_API_KEY` / `BINANCE_API_SECRET` 等前缀需与代码一致。 +2. **隧道是否在本机端口监听**(若配置了 `BINANCE_SOCKS_PROXY`): + ```bash + ss -lntp | grep 1080 || true + ``` +3. **curl 复测 Binance**(与第 3.2 节相同);curl 不通则应用也不会通。 +4. **PySocks**:`pip show PySocks`,缺失则 `pip install PySocks`。 +5. **SSH 隧道连不上**:检查私钥权限、`~/.ssh/config`、VPS 出站与端口是否与 `.env` 一致。 +6. **API 权限与 IP 白名单**:Secret 错误、权限不足、未放行当前出口 IP 时,私有接口会失败。 +7. **启动顺序**:若走代理,先保证 SOCKS 已监听,再 `pm2 start` 应用(或重启应用)。 + +--- + +## 12. 推荐启动顺序(习惯) + +1. 若走代理:先启动并确认 SSH SOCKS 已监听,再 `curl --proxy socks5h://127.0.0.1:1080 https://api.binance.com/api/v3/time` 成功 +2. `pm2 start ecosystem.config.cjs` +3. 再确认页面与余额等接口正常 + +--- + +## 13. 免责声明 + +交易所有合规与地区政策要求。请确保使用方式符合当地法律法规与交易所条款。本文仅描述网络与工程部署路径。 + +--- + +## 附录:数据库标签修复脚本 `scripts/fix_breakeven_labels.py` + +在 Ubuntu 上: + +1)预览(不写库): + +```bash +python scripts/fix_breakeven_labels.py --db ./crypto.db --dry-run +``` + +2)确认后执行: + +```bash +python scripts/fix_breakeven_labels.py --db ./crypto.db --apply +``` + +默认修复条件:`monitor_type='下单监控'` 且 `result='止损'` 且 `pnl_amount > 0` → 改为 `result='保本止盈'`。 diff --git a/crypto_monitor_gate/.env.example b/crypto_monitor_gate/.env.example new file mode 100644 index 0000000..1afa85a --- /dev/null +++ b/crypto_monitor_gate/.env.example @@ -0,0 +1,181 @@ +# ============================================================================= +# 环境配置模板(可提交 Git)。程序运行时只读取同目录下的 .env。 +# +# 首次部署 / 新机: +# cp .env.example .env +# nano .env # 填入真实密钥、端口、代理等 +# +# 升级代码(git pull)前建议备份(.env 不在 Git 中,pull 不会覆盖): +# cp .env .env.backup.$(date +%Y%m%d) +# +# 从备份恢复: +# cp .env.backup.YYYYMMDD .env +# ============================================================================= + +APP_ENV=production +# 服务监听地址(云服务器通常用 0.0.0.0) +APP_HOST=0.0.0.0 +# 服务端口 +APP_PORT=5000 +# 是否开启调试模式(生产建议 false) +APP_DEBUG=false + +# 登录账号 +APP_USERNAME=dekun +# 登录密码(请改成你自己的强密码) +APP_PASSWORD=ChangeMe123! +# 是否关闭登录校验(局域网可设 true;公网务必 false) +APP_AUTH_DISABLED=true +# Flask 会话密钥(必须替换为长随机字符串) +FLASK_SECRET_KEY=CHANGE_TO_LONG_RANDOM_SECRET + +# ---------- 整机许可(也可写在仓库根目录 .env 的 LICENSE_* 变量)---------- +LICENSE_API_URL=https://license.example.com +LICENSE_CLIENT_KEY=REPLACE_WITH_CLIENT_KEY +LICENSE_CHECK_INTERVAL_DAYS=3 +LICENSE_OFFLINE_GRACE_DAYS=7 +LICENSE_WECHAT_ID=dekun03 +# LICENSE_DISABLED=false + +# 企业微信机器人 Webhook(用于行情/风控推送) +WECHAT_WEBHOOK=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=REPLACE_WITH_REAL_KEY + +# 数据库文件路径(相对路径会自动按项目目录解析) +DB_PATH=crypto.db +# 交易截图上传目录 +UPLOAD_DIR=static/images + +# 自动备份(scripts/backup_data.sh + cron,可选;默认即可) +# BACKUP_ROOT=/root/backups +# BACKUP_RETENTION_DAYS=30 +# BACKUP_INSTANCE=crypto_monitor_gate + +# 已废弃:资金账户仅显示交易所 funding 余额,不再读取此变量 +# TOTAL_CAPITAL=100 +# 每天起始基数(U) +DAILY_START_CAPITAL=30 +# 日内回撤后基数(U) +DAILY_LOSS_CAPITAL=20 +# 日内盈利后基数(U) +DAILY_PROFIT_CAPITAL=50 +# BTC 默认杠杆倍数 +BTC_LEVERAGE=10 +# 山寨币默认杠杆倍数 +ALT_LEVERAGE=5 +# 交易日重置小时(北京时间) +TRADING_DAY_RESET_HOUR=8 +# 整点前禁止新开仓:true=启用(默认),false=关闭(仍可保留 8 点作为交易日划分) +TRADING_DAY_RESET_OPEN_GUARD_ENABLED=true + +# 是否开启 Gate 实盘下单(false=只做本地流程,true=真实下单) +LIVE_TRADING_ENABLED=true +# Gate API Key(实盘) +GATE_API_KEY=REPLACE_WITH_GATE_API_KEY +# Gate API Secret(实盘) +GATE_API_SECRET=REPLACE_WITH_GATE_API_SECRET +# 保证金模式:cross=全仓,isolated=逐仓 +GATE_TD_MODE=cross +# 持仓筛选:hedge=双向持仓下按多空腿过滤;其它值(如 single)不按腿过滤 +GATE_POS_MODE=hedge +# 永续止盈止损:是否优先用官方仓位类触发单(POST price_orders,close-*-position);false=仅用旧版两张 ccxt 条件单 +GATE_TPSL_USE_POSITION_ORDER=true +# 触发单超时(秒),默认 604800=7 天;设为 0 或负数则不向 API 传 expiration +GATE_TPSL_TRIGGER_EXPIRATION=604800 +# 触发参考价:0=最新成交 1=标记价 2=指数价(非法值按 0) +GATE_TPSL_PRICE_TYPE=0 +# 页面与浏览器标签展示的交易所名称(多环境区分时可改成例如 Gate·模拟) +# EXCHANGE_DISPLAY_NAME=Gate.io + +# ============================================================================= +# 关键位门控(页面「关键位监控」规则条与 _key_hard_checks 共用) +# ============================================================================= +# 【周期】门控 K 线周期,如 5m、15m +KLINE_TIMEFRAME=5m +# 【确认K】闭合 K 序列中的棒偏移:突破棒默认 -2,确认棒默认 -1 +KEY_CONFIRM_BREAKOUT_BAR=-2 +KEY_CONFIRM_BAR=-1 +# 【量能】突破棒成交量 > 前 N 根均量 × 倍数 +KEY_VOLUME_MA_BARS=20 +KEY_VOLUME_RATIO_MIN=1.3 +# 【突破K实体幅度】占开盘价百分比区间 +KEY_BREAKOUT_AMP_MIN_PCT=0.03 +KEY_BREAKOUT_AMP_MAX_PCT=0.5 +# 【日成交量排名】品种须在该排名前 N 名 +KEY_DAILY_VOLUME_RANK_MAX=30 +# 【关键位自动开仓盈亏比】严格大于该值才市价开仓 +KEY_AUTO_MIN_PLANNED_RR=1.5 +# 止损:突破 K 极值向外缓冲的百分比(默认 0.5 即 0.5%) +KEY_STOP_OUTSIDE_BREAKOUT_PCT=0.5 +# 趋势单方案:止损在突破 K 极值外侧的百分比(默认 1 即 1%) +KEY_TREND_STOP_OUTSIDE_PCT=1 +KEY_ALERT_MAX_TIMES=3 +KEY_ALERT_INTERVAL_MINUTES=5 + +# ============================================================================= +# 交易执行 / 人工风控(页面「实盘下单」) +# ============================================================================= +# 【最大同时持仓】默认 1=单仓 +MAX_ACTIVE_POSITIONS=1 +# 【人工下单最低盈亏比】低于该值前后端均拒绝(默认 1.4,即须 >=1.4:1) +MANUAL_MIN_PLANNED_RR=1.4 +# 【关键位连开计仓】已有持仓时按无仓时资金快照算基数 +KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT=true + +# 资金与仓位刷新周期(秒) +BALANCE_REFRESH_SECONDS=60 +# 前端价格快照轮询(秒) +PRICE_REFRESH_SECONDS=5 +# 后台监控轮询周期(秒) +MONITOR_POLL_SECONDS=3 +# 使用可用资金时的缓冲比例(如0.98代表用98%) +FULL_MARGIN_BUFFER_RATIO=0.98 + +# 自动划转:将目标账户补足到 AUTO_TRANSFER_AMOUNT +AUTO_TRANSFER_ENABLED=false +AUTO_TRANSFER_AMOUNT=30 +AUTO_TRANSFER_FROM=funding +AUTO_TRANSFER_TO=swap +TRANSFER_CCY=USDT +# 强制清仓整点(北京时间,默认 0=凌晨00点) +FORCE_CLOSE_BJ_HOUR=0 +# 是否启用强制清仓(默认关闭,true 才会在整点执行) +FORCE_CLOSE_ENABLED=false + +# 推送与AI超时(秒) +WECHAT_TIMEOUT_SECONDS=10 +AI_TIMEOUT_SECONDS=120 + +# AI 复盘服务地址(本机 Ollama 默认地址) +OLLAMA_API=http://127.0.0.1:11434/api/generate +# AI 模型名称 +AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest + +# Gate 代理(可选):本机网络不稳定时通过 SSH 动态转发 SOCKS5 出口 +# 1) 先在本机建立隧道(示例): +# ssh -N -D 127.0.0.1:1080 root@你的VPS_IP -o ServerAliveInterval=30 -o ExitOnForwardFailure=yes +# 2) 再启用下面这一行(推荐 socks5h,让远端解析域名): +# GATE_SOCKS_PROXY=socks5h://127.0.0.1:1080 +# +# 如你更偏向 HTTP 代理(VPS 上跑 tinyproxy 之类),可用: +# GATE_HTTP_PROXY=http://127.0.0.1:3128 +# GATE_HTTPS_PROXY=http://127.0.0.1:3128 + +# 开仓多周期K线图(可选) +# ORDER_CHART_ENABLED=true +# ORDER_CHART_TFS=4h,1h,15m,5m +# ORDER_CHART_LIMIT=100 +# ORDER_CHART_DIR=static/images/order_charts +# DAILY_OPEN_ALERT_THRESHOLD=5 +# 以损定仓(按交易账户资金的百分比) +# RISK_PERCENT=2 +# 移动保本触发(达到多少R触发)与偏移(百分比) +# BREAKEVEN_RR_TRIGGER=1.0 +# 移动保本阶梯(每多少R继续上移一次,默认1R) +# BREAKEVEN_STEP_R=1.0 +# BREAKEVEN_OFFSET_PCT=0.02 +# 开单风格默认值:trend / swing +# DEFAULT_TRADE_STYLE=trend + +APP_TIMEZONE=Asia/Shanghai +AUTO_TRANSFER_BJ_HOUR=8 +# TRADING_DAY_RESET_HOUR 现在表示「北京时间」整点,默认 8 点起算新交易日;开仓整点限制见 TRADING_DAY_RESET_OPEN_GUARD_ENABLED diff --git a/crypto_monitor_gate/README.md b/crypto_monitor_gate/README.md new file mode 100644 index 0000000..183f8c4 --- /dev/null +++ b/crypto_monitor_gate/README.md @@ -0,0 +1,94 @@ +# crypto_monitor_gate + +基于 **Flask** 的加密货币 **下单监控 / 关键位监控 / 交易复盘** 小系统,行情与实盘接口统一走 **Gate.io USDT 永续**,通过 **ccxt** 访问。 + +## 文档导航 + +| 文档 | 说明 | +|------|------| +| **[使用说明.md](./使用说明.md)** | 日常怎么用:登录、关键位四类、手工开仓、单仓与微信等 | +| **[关键位自动下单说明.md](./关键位自动下单说明.md)** | 关键位自动开仓的 RR、止盈止损、结案原因与 `.env` | +| **[部署文档.md](./部署文档.md)** | Ubuntu、PM2、**SSH SOCKS** 访问 Gate API 等 | + +另:**Binance U 本位** 对等实现见同级的 **`crypto_monitor_binance`** 仓库。 + +--- + +## 功能概要 + +- **关键位监控**:5m 收线硬条件、企业微信推送;**箱体 / 收敛** 在 RR 达标时可 **自动市价开仓**(见专门文档);**阻力 / 支撑** 仅单次提醒结案 +- **下单监控**:本地风控(含移动保本)、止盈/止损触达后轮询尝试平仓并记账 +- **实盘(可选)**:`LIVE_TRADING_ENABLED=true` 且配置 **`GATE_API_KEY` / `GATE_API_SECRET`** 时,支持开仓、挂单 TP/SL、余额与划转(权限依账户而定) +- **止盈止损(Gate)**:市价成交后经 **`_gate_place_tp_sl_orders`** 挂单;优先 **仓位类 `price_orders`**(受 `GATE_TPSL_USE_POSITION_ORDER`、`GATE_TPSL_PRICE_TYPE`、`GATE_POS_MODE` 等影响) + +--- + +## 环境要求 + +- Python 3.10+(建议) +- 依赖:`flask`、`requests`、`ccxt`、`werkzeug`、`PySocks`(经 SOCKS 代理时);`Pillow`(K 线导出等可选用) + +安装示例: + +```bash +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install flask requests ccxt werkzeug PySocks Pillow +``` + +## 配置(`.env.example` → `.env`) + +- **`.env.example`**:模板(可提交 Git);首次:`cp .env.example .env` 后编辑。 +- **`.env`**:本机真实配置(勿提交);`git pull` 不覆盖;升级前建议备份(见《部署文档》§5.2)。 + +项目启动时加载**仓库根目录**下的 `.env`。常用项: + +| 变量 | 说明 | +|------|------| +| `GATE_API_KEY` / `GATE_API_SECRET` | Gate API(需合约与对应权限) | +| `LIVE_TRADING_ENABLED` | `true` 允许真实下单;`false` 仅本地与推送逻辑 | +| `GATE_MARGIN_MODE` / `GATE_POS_MODE` | 保证金与持仓模式 | +| `GATE_TPSL_USE_POSITION_ORDER` / `GATE_TPSL_PRICE_TYPE` 等 | 条件止盈止损行为 | +| `GATE_SOCKS_PROXY` | 可选;直连不稳时 SSH 动态转发(详见部署文档) | +| `APP_PASSWORD` / `FLASK_SECRET_KEY` | Web 登录与 Session | +| `WECHAT_WEBHOOK` | 企业微信机器人 | +| `EXCHANGE_DISPLAY_NAME` / `GATE_ACCOUNT_LABEL` | 页面与推送展示的账户文案 | + +其余见 **`.env.example` 内注释** 或 **`app.py` 顶部默认值**。 + +## 本地运行 + +**Windows** 推荐使用 UTF-8 控制台脚本: + +```powershell +.\start_utf8.ps1 +``` + +或直接: + +```powershell +python .\app.py +``` + +端口由 **`APP_PORT`** 控制(未设置默认 **5000**)。浏览器登录 **`/login`**,口令为 **`APP_PASSWORD`**。 + +## 部署(Linux / PM2 / SSH SOCKS) + +见 **[部署文档.md](./部署文档.md)**。 + +## 自检脚本 + +```bash +python scripts/verify_gate_funding.py +``` + +用于核对密钥前缀(不落 Secret)、资金/合约可读性等(需网络与权限)。 + +## 数据与脚本 + +- 默认 SQLite:由 **`DB_PATH`** 指定(常见为项目下 `crypto.db`) +- `scripts/fix_breakeven_labels.py`:修正「止损」但盈亏为正的记录标签(参见部署文档说明) + +## 风险与合规 + +实盘有亏损风险。请确认 API 权限、IP 白名单、杠杆与保证金模式与 **Gate.io** 后台一致,并遵守当地法律法规与交易所用户协议。 diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py new file mode 100644 index 0000000..1d8597b --- /dev/null +++ b/crypto_monitor_gate/app.py @@ -0,0 +1,7673 @@ +from flask import Flask, render_template, request, redirect, url_for, flash, session, jsonify, Response +import sqlite3 +import csv +from io import StringIO +import time +import threading +import requests +import os +import re +import base64 +import json +import math +from datetime import datetime, timedelta, timezone + +try: + from zoneinfo import ZoneInfo +except ImportError: + ZoneInfo = None # type: ignore +from functools import wraps +import uuid +import ccxt +from werkzeug.utils import secure_filename + +try: + from PIL import Image, ImageDraw, ImageFont +except ImportError: + Image = None # type: ignore + ImageDraw = None # type: ignore + ImageFont = None # type: ignore + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +_REPO_ROOT = os.path.dirname(BASE_DIR) +import sys + +if _REPO_ROOT not in sys.path: + sys.path.insert(0, _REPO_ROOT) +from fib_key_monitor_lib import ( + FIB_KEY_MONITOR_TYPES, + KEY_ENTRY_REASON_BY_SIGNAL, + calc_fib_plan, + entry_reason_from_key_signal, + fib_invalidate_by_mark, + fib_ratio_from_type, + is_fib_key_monitor_type, + key_signal_type_for_trade_record, + stored_key_signal_type, +) +from key_sl_tp_lib import ( + breakeven_enabled_from_row, + normalize_sl_tp_mode, + parse_breakeven_enabled_form, + plan_key_sl_tp, + sl_tp_mode_from_row, + sl_tp_mode_label, + sl_tp_plan_summary_text, +) +from history_window_lib import ( + PRESET_CUSTOM, + PRESET_UTC_LAST24H, + PRESET_UTC_LAST7D, + PRESET_UTC_TODAY, + list_window_redirect_query, + resolve_list_window, + resolve_window, + utc_window_to_bj_sql_strings, +) +import license_lib + + +def load_env_file(path): + if not os.path.exists(path): + return + raw_bytes = open(path, "rb").read() + text = "" + for enc in ("utf-8-sig", "utf-16", "utf-16-le", "utf-16-be"): + try: + text = raw_bytes.decode(enc) + break + except Exception: + continue + if not text: + text = raw_bytes.decode("utf-8", errors="ignore") + text = text.replace("\x00", "") + for line in text.splitlines(): + raw = line.strip() + if not raw or raw.startswith("#") or "=" not in raw: + continue + key, value = raw.split("=", 1) + clean_key = key.strip().lstrip("\ufeff") + if not clean_key.replace("_", "").isalnum(): + continue + clean_value = value.strip().strip('"').strip("'") + os.environ[clean_key] = clean_value + +load_env_file(os.path.join(BASE_DIR, ".env")) + + +def resolve_path(path_value): + if os.path.isabs(path_value): + return path_value + return os.path.join(BASE_DIR, path_value) + +app = Flask(__name__) +app.secret_key = os.getenv("FLASK_SECRET_KEY", "crypto_monitor_2026_secret_key") + +# ====================== 登录配置 ====================== +USERNAME = os.getenv("APP_USERNAME", "dekun") +PASSWORD = os.getenv("APP_PASSWORD", "Woaini88@") +AUTH_DISABLED = os.getenv("APP_AUTH_DISABLED", "false").lower() in ("1", "true", "yes", "on") + +# 企业微信机器人Webhook +WECHAT_WEBHOOK = os.getenv("WECHAT_WEBHOOK", "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=replace-me") +SYSTEM_TYPE = "CRYPTO" +HOST = os.getenv("APP_HOST", "0.0.0.0") +PORT = int(os.getenv("APP_PORT", "5000")) +DEBUG = os.getenv("APP_DEBUG", "false").lower() == "true" +DB_PATH = resolve_path(os.getenv("DB_PATH", "crypto.db")) + +# 训练参数(可由 .env 覆盖) +DAILY_START_CAPITAL = float(os.getenv("DAILY_START_CAPITAL", "30")) +DAILY_LOSS_CAPITAL = float(os.getenv("DAILY_LOSS_CAPITAL", "20")) +DAILY_PROFIT_CAPITAL = float(os.getenv("DAILY_PROFIT_CAPITAL", "50")) +BTC_LEVERAGE = int(os.getenv("BTC_LEVERAGE", "10")) +ALT_LEVERAGE = int(os.getenv("ALT_LEVERAGE", "5")) +# 交易日滚动与「可开仓」整点:按应用本地时区 wall clock(默认北京时间 UTC+8) +TRADING_DAY_RESET_HOUR = int(os.getenv("TRADING_DAY_RESET_HOUR", "8")) +# false 时关闭「整点前禁止新开仓」守卫(交易日划分仍用 TRADING_DAY_RESET_HOUR) +TRADING_DAY_RESET_OPEN_GUARD_ENABLED = os.getenv( + "TRADING_DAY_RESET_OPEN_GUARD_ENABLED", "true" +).lower() in ("1", "true", "yes", "on") +APP_TIMEZONE = os.getenv("APP_TIMEZONE", "Asia/Shanghai") + + +def _resolve_app_tz(): + if ZoneInfo is not None: + try: + return ZoneInfo((APP_TIMEZONE or "Asia/Shanghai").strip()) + except Exception: + pass + return timezone(timedelta(hours=8)) + + +APP_TZ = _resolve_app_tz() +LIVE_TRADING_ENABLED = os.getenv("LIVE_TRADING_ENABLED", "false").lower() == "true" +GATE_API_KEY = (os.getenv("GATE_API_KEY") or "").strip() +GATE_API_SECRET = (os.getenv("GATE_API_SECRET") or "").strip() +GATE_TD_MODE = (os.getenv("GATE_TD_MODE") or "cross").strip().lower() +GATE_POS_MODE = (os.getenv("GATE_POS_MODE") or "hedge").strip().lower() +# 永续仓位止盈止损触发单:POST /futures/{settle}/price_orders,order_type=close-*-position(全平) +GATE_TPSL_TRIGGER_EXPIRATION = int(os.getenv("GATE_TPSL_TRIGGER_EXPIRATION", str(7 * 86400))) +GATE_TPSL_PRICE_TYPE = int(os.getenv("GATE_TPSL_PRICE_TYPE", "0")) +if GATE_TPSL_PRICE_TYPE < 0 or GATE_TPSL_PRICE_TYPE > 2: + GATE_TPSL_PRICE_TYPE = 0 +GATE_TPSL_USE_POSITION_ORDER = os.getenv("GATE_TPSL_USE_POSITION_ORDER", "true").lower() in ("1", "true", "yes") +# 页面展示的交易所名称(多实例/多环境时可按需区分) +EXCHANGE_DISPLAY_NAME = (os.getenv("EXCHANGE_DISPLAY_NAME") or "Gate.io").strip() or "Gate.io" +license_lib.init_flask_app(app, exchange_display=EXCHANGE_DISPLAY_NAME) +_GATE_DEFAULT_MARGIN_MODE = "cross" if GATE_TD_MODE in ("cross", "cross_margin") else "isolated" +BALANCE_REFRESH_SECONDS = int(os.getenv("BALANCE_REFRESH_SECONDS", "60")) +PRICE_REFRESH_SECONDS = int(os.getenv("PRICE_REFRESH_SECONDS", "5")) +KEY_ALERT_MAX_TIMES = int(os.getenv("KEY_ALERT_MAX_TIMES", "3")) +KEY_ALERT_INTERVAL_MINUTES = int(os.getenv("KEY_ALERT_INTERVAL_MINUTES", "5")) +KEY_AUTO_MIN_PLANNED_RR = float(os.getenv("KEY_AUTO_MIN_PLANNED_RR", "1.5")) +KEY_STOP_OUTSIDE_BREAKOUT_PCT = float(os.getenv("KEY_STOP_OUTSIDE_BREAKOUT_PCT", "0.5")) +KEY_TREND_STOP_OUTSIDE_PCT = float(os.getenv("KEY_TREND_STOP_OUTSIDE_PCT", "1")) +MANUAL_MIN_PLANNED_RR = float(os.getenv("MANUAL_MIN_PLANNED_RR", "1.4")) +MAX_ACTIVE_POSITIONS = max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", "1"))) +KEY_VOLUME_MA_BARS = max(1, int(os.getenv("KEY_VOLUME_MA_BARS", "20"))) +KEY_VOLUME_RATIO_MIN = float(os.getenv("KEY_VOLUME_RATIO_MIN", "1.3")) +KEY_BREAKOUT_AMP_MIN_PCT = float(os.getenv("KEY_BREAKOUT_AMP_MIN_PCT", "0.03")) +KEY_BREAKOUT_AMP_MAX_PCT = float(os.getenv("KEY_BREAKOUT_AMP_MAX_PCT", "0.5")) +KEY_DAILY_VOLUME_RANK_MAX = max(1, int(os.getenv("KEY_DAILY_VOLUME_RANK_MAX", "30"))) +KEY_CONFIRM_BREAKOUT_BAR = int(os.getenv("KEY_CONFIRM_BREAKOUT_BAR", "-2")) +KEY_CONFIRM_BAR = int(os.getenv("KEY_CONFIRM_BAR", "-1")) +KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT = os.getenv("KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT", "true").lower() == "true" +ORDER_MONITOR_TYPE_MANUAL = "下单监控" +ORDER_MONITOR_TYPE_KEY_AUTO = "关键位监控" +EXCHANGE_POSITION_SYNC_FROM_BJ = (os.getenv("EXCHANGE_POSITION_SYNC_FROM_BJ") or "").strip() +EXCHANGE_POSITION_HISTORY_LIMIT = max(50, min(1000, int(os.getenv("EXCHANGE_POSITION_HISTORY_LIMIT", "200")))) +_LAST_EXCHANGE_PNL_SYNC_AT = 0.0 + +KEY_MONITOR_AUTO_TYPES = frozenset({"箱体突破", "收敛突破"}) +KEY_MONITOR_ALERT_ONLY_TYPES = frozenset({"关键阻力位", "关键支撑位"}) +AUTO_TRANSFER_ENABLED = os.getenv("AUTO_TRANSFER_ENABLED", "false").lower() == "true" +AUTO_TRANSFER_AMOUNT = float(os.getenv("AUTO_TRANSFER_AMOUNT", "30")) +AUTO_TRANSFER_FROM = os.getenv("AUTO_TRANSFER_FROM", "funding") +AUTO_TRANSFER_TO = os.getenv("AUTO_TRANSFER_TO", "swap") +FORCE_CLOSE_ENABLED = os.getenv("FORCE_CLOSE_ENABLED", "false").lower() == "true" +FORCE_CLOSE_BJ_HOUR = int(os.getenv("FORCE_CLOSE_BJ_HOUR", "0")) +# 自动划转:仅在北京时间该整点「小时」内尝试;transfer_logs.transfer_day 存 UTC 自然日便于对账 +AUTO_TRANSFER_BJ_HOUR = int(os.getenv("AUTO_TRANSFER_BJ_HOUR", "8")) +WECHAT_TIMEOUT_SECONDS = int(os.getenv("WECHAT_TIMEOUT_SECONDS", "10")) +AI_TIMEOUT_SECONDS = int(os.getenv("AI_TIMEOUT_SECONDS", "120")) +MONITOR_POLL_SECONDS = int(os.getenv("MONITOR_POLL_SECONDS", "3")) +KLINE_TIMEFRAME = os.getenv("KLINE_TIMEFRAME", "5m") +FULL_MARGIN_BUFFER_RATIO = float(os.getenv("FULL_MARGIN_BUFFER_RATIO", "0.98")) +TRANSFER_CCY = os.getenv("TRANSFER_CCY", "USDT") +UPLOAD_FOLDER = resolve_path(os.getenv("UPLOAD_DIR", "static/images")) +ORDER_CHART_ENABLED = os.getenv("ORDER_CHART_ENABLED", "true").lower() == "true" +ORDER_CHART_TFS = [x.strip() for x in (os.getenv("ORDER_CHART_TFS", "4h,1h,15m,5m") or "").split(",") if x.strip()] +ORDER_CHART_LIMIT = int(os.getenv("ORDER_CHART_LIMIT", "100")) +ORDER_CHART_DIR = resolve_path(os.getenv("ORDER_CHART_DIR", "static/images/order_charts")) +DAILY_OPEN_ALERT_THRESHOLD = int(os.getenv("DAILY_OPEN_ALERT_THRESHOLD", "5")) +RISK_PERCENT = float(os.getenv("RISK_PERCENT", "2")) +BREAKEVEN_RR_TRIGGER = float(os.getenv("BREAKEVEN_RR_TRIGGER", "1.0")) +BREAKEVEN_OFFSET_PCT = float(os.getenv("BREAKEVEN_OFFSET_PCT", "0.02")) +BREAKEVEN_STEP_R = float(os.getenv("BREAKEVEN_STEP_R", "1.0")) +DEFAULT_TRADE_STYLE = (os.getenv("DEFAULT_TRADE_STYLE", "trend") or "trend").strip().lower() +OLLAMA_API = os.getenv("OLLAMA_API", "http://127.0.0.1:11434/api/generate") +AI_MODEL = os.getenv("AI_MODEL", "huihui_ai/deepseek-r1-abliterated:latest") + +GATE_SOCKS_PROXY = (os.getenv("GATE_SOCKS_PROXY") or "").strip() +GATE_HTTP_PROXY = (os.getenv("GATE_HTTP_PROXY") or "").strip() +GATE_HTTPS_PROXY = (os.getenv("GATE_HTTPS_PROXY") or "").strip() + + +def build_gate_ccxt_proxies(): + """ + 为 ccxt 配置代理(常用于本机网络不稳定时通过 SSH 动态转发 SOCKS5 出口)。 + + 推荐: + - 本机:ssh -N -D 127.0.0.1:1080 user@vps + - .env:GATE_SOCKS_PROXY=socks5h://127.0.0.1:1080 + + 说明: + - socks5h 让代理端解析域名(避免本机 DNS/策略差异);若你明确要本机解析可用 socks5:// + """ + socks = GATE_SOCKS_PROXY.strip() + http = GATE_HTTP_PROXY.strip() + https = GATE_HTTPS_PROXY.strip() or http + if socks: + return {"http": socks, "https": socks} + if http or https: + return {"http": http, "https": https} + return None + + +GATE_CCXT_PROXIES = build_gate_ccxt_proxies() + +os.makedirs(UPLOAD_FOLDER, exist_ok=True) +os.makedirs(ORDER_CHART_DIR, exist_ok=True) +app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER + +# Gate.io USDT 永续(swap) +exchange = ccxt.gateio({ + "enableRateLimit": True, + "options": { + "defaultType": "swap", + "defaultMarginMode": _GATE_DEFAULT_MARGIN_MODE, + }, +}) +if GATE_CCXT_PROXIES: + exchange.proxies = GATE_CCXT_PROXIES +if GATE_API_KEY and GATE_API_SECRET: + exchange.apiKey = GATE_API_KEY + exchange.secret = GATE_API_SECRET +MARKETS_LOADED = False +ACCOUNT_BALANCE_CACHE = { + "updated_at": 0.0, + "funding_usdt": None, + "trading_usdt": None +} +LIQUIDITY_RANK_CACHE = { + "updated_at": 0.0, + "ranks": {}, + "total": 0, +} + +# 企业微信推送 +def send_wechat_msg(content): + prefix = "【加密货币】" + full_msg = f"{prefix}\n{content}" + data = { + "msgtype": "text", + "text": {"content": full_msg} + } + try: + requests.post(WECHAT_WEBHOOK, json=data, timeout=WECHAT_TIMEOUT_SECONDS) + except: + pass + + +_BREAKEVEN_EXCHANGE_WARNED_IDS = set() + + +def _send_breakeven_exchange_warn_once(order_id, message): + """移动保本同步交易所失败:同一笔监控单只推送一次,避免轮询刷屏。""" + oid = int(order_id) + if oid in _BREAKEVEN_EXCHANGE_WARNED_IDS: + return + _BREAKEVEN_EXCHANGE_WARNED_IDS.add(oid) + send_wechat_msg(message) + + +def _clear_breakeven_exchange_warn(order_id): + _BREAKEVEN_EXCHANGE_WARNED_IDS.discard(int(order_id)) + + +def _wechat_account_label(): + return (os.getenv("GATE_ACCOUNT_LABEL") or "gate实盘账户").strip() + + +def _wechat_direction_text(direction): + d = (direction or "").lower() + return "多头(long)" if d == "long" else "空头(short)" + + +def _wechat_trading_capital_text(fallback=None): + try: + _, trading_capital = get_exchange_capitals(force=True) + except Exception: + trading_capital = None + if trading_capital is not None: + return f"{round(float(trading_capital), 2)}U" + if fallback is not None: + try: + return f"{round(float(fallback), 2)}U" + except Exception: + pass + return "-" + + +def build_wechat_close_message( + symbol, + direction, + result, + pnl_amount, + hold_seconds=None, + trigger_price=None, + current_price=None, + stop_loss=None, + take_profit=None, + close_order_id=None, + extra_note=None, + session_capital_fallback=None, +): + hold_txt = format_hold_minutes(calc_hold_minutes(hold_seconds)) if hold_seconds is not None else "-" + ep = format_price_for_symbol(symbol, trigger_price) + cp = format_price_for_symbol(symbol, current_price) + tp = format_price_for_symbol(symbol, take_profit) + sl = format_wechat_scalar_2dp(stop_loss) + cap_txt = _wechat_trading_capital_text(session_capital_fallback) + try: + if pnl_amount is not None: + pv = float(pnl_amount) + pnl_disp = f"{'+' if pv > 0 else ''}{round(pv, 2)} U" + else: + pnl_disp = "-" + except (TypeError, ValueError): + pnl_disp = "-" + + lines = [ + f"📉 {symbol} 平仓完成", + f"💼 账户:{_wechat_account_label()}", + "", + "🧾 平仓概要", + f"🔖 平仓单号:{close_order_id or '-'}", + f"📌 方向:{_wechat_direction_text(direction)}", + f"📌 平仓结果:{result or '-'}", + f"💰 本单盈亏:{pnl_disp}", + f"⏱ 持仓时长:{hold_txt}", + f"💵 交易账户资金:{cap_txt}", + "", + "🎯 价位(计划)", + f"开仓成交价:{ep}", + f"离场参考价:{cp}", + f"止盈价位:{tp}", + f"止损价位:{sl}", + ] + if extra_note: + lines.extend(["", "📎 备注", extra_note]) + return "\n".join(lines) + + +def build_wechat_breakeven_message(symbol, direction, arm_txt, now_rr, locked_r, new_sl): + sl_fmt = format_wechat_scalar_2dp(new_sl) + return "\n".join( + [ + f"# 🛡️ {symbol} 保护位更新", + f"**账户:{_wechat_account_label()}**", + "", + "---", + "", + "### 移动保本/止盈", + f"- 方向:**{_wechat_direction_text(direction)}**", + f"- 类型:**{arm_txt}**", + f"- 当前RR:`{round(float(now_rr), 2)}R`", + f"- 锁定RR:`{round(float(locked_r), 2)}R`", + f"- 新保护位:`{sl_fmt}`", + ] + ) + + +def build_wechat_monitor_error_message(symbol, direction, scene, error_text): + return "\n".join( + [ + f"# ⚠️ {symbol} 下单监控异常", + f"**账户:{_wechat_account_label()}**", + "", + "---", + "", + "### 异常信息", + f"- 方向:**{_wechat_direction_text(direction)}**", + f"- 场景:{scene}", + f"- 错误:{str(error_text)}", + ] + ) + + +def build_wechat_key_monitor_message( + symbol, + direction, + monitor_type, + trigger_time, + key_price, + confirm_close, + hard_lines, + btc8h_status, + coin4h_status, + swing4h_pct, + op_lines, + risk_tip=None, +): + lines = [ + f"# 🎯 {symbol} 关键位确认推送", + f"**账户:{_wechat_account_label()}**", + "", + "---", + "", + "### 交易对 / 触发时间", + f"- 交易对:**{symbol}**", + f"- 触发时间:`{trigger_time}`", + "", + "### 方向与确认K", + f"- 方向:**{_wechat_direction_text(direction)}**", + "- 确认K:第二根5m收盘完成", + "", + "### 关键价位", + f"- 类型:**{monitor_type}**", + f"- 箱体关键位:`{key_price}`", + f"- 第二根确认收盘价:`{confirm_close}`", + "", + "### 硬条件校验结果", + ] + lines.extend([f"- {x}" for x in hard_lines]) + lines.extend( + [ + "", + "### 市场状态说明", + f"- BTC 8h 状态:**{btc8h_status}**", + f"- 本币 4h(EMA55) 状态:**{coin4h_status}**", + f"- 4h震荡幅度(5m近48根):`{round(float(swing4h_pct), 3)}%`", + "", + "### 操作提示", + ] + ) + lines.extend([f"- {x}" for x in op_lines]) + if risk_tip: + lines.extend(["", f"### 逆势风险提醒", f"- {risk_tip}"]) + return "\n".join(lines) + + +def _read_image_base64(image_path): + try: + with open(image_path, "rb") as f: + return base64.b64encode(f.read()).decode("utf-8") + except Exception: + return None + + +def _extract_json_object(text): + if not text: + return None + clean = text.strip() + if clean.startswith("```"): + clean = clean.replace("```json", "").replace("```", "").strip() + try: + return json.loads(clean) + except Exception: + pass + match = re.search(r"\{[\s\S]*\}", clean) + if not match: + return None + try: + return json.loads(match.group(0)) + except Exception: + return None + + +def _journal_row_lines_for_ai(idx, row, *, include_hold_duration=True): + """把 journal 字段拼成给 AI 的文本;字段之外的事实不要指望模型自己猜。""" + def nz(v, default="无"): + if v is None: + return default + s = str(v).strip() + return s if s else default + + lines = [ + f"{idx}. {nz(row['coin'])} {nz(row['tf'])} | 盈亏:{nz(row['pnl'])}U | 实际RR:{nz(row['real_rr'])} | 预期RR:{nz(row['expect_rr'])}", + f" 开仓逻辑:{nz(row['entry_reason'])}", + f" 平仓/离场(交易员自述):{nz(row['exit_reason'])}", + ] + if include_hold_duration: + lines.append(f" 持仓时长:{nz(row['hold_duration'])}") + ee_bits = [ + nz(row["early_exit"]), + nz(row["early_exit_reason"]), + nz(row["early_exit_trigger"]), + nz(row["early_exit_note"]), + ] + if any(x != "无" for x in ee_bits): + lines.append( + " 提前离场记录:" + f"{ee_bits[0]} | 原因:{ee_bits[1]} | 触发:{ee_bits[2]} | 备注:{ee_bits[3]}" + ) + mood_bits = f"心态标签:{nz(row['mood_issues'])}" + if row["mood_score"] is not None: + mood_bits += f" | 自评心态分:{row['mood_score']}" + lines.append(f" {mood_bits}") + if nz(row["post_breakeven_stare"]) != "无": + lines.append(f" 保本后盯盘:{nz(row['post_breakeven_stare'])}") + if nz(row["new_trade_while_occupied"]) != "无": + lines.append(f" 占用时新开仓:{nz(row['new_trade_while_occupied'])}") + if nz(row["note"]) != "无": + lines.append(f" 备注:{nz(row['note'])}") + return "\n".join(lines) + "\n" + + +def ai_review(trades_text, period_title, image_paths=None): + prompt = f""" +你是一位专业交易教练。下面是用户的{period_title}交易记录,请做简洁、可执行的复盘(中文)。 + +【硬性规则 — 必须遵守】 +- 你只能根据「交易记录」里**明确出现的字段**陈述事实;禁止编造:是否触发止损、是否扛单、亏损是否扩大、图上具体结构/进出场点位等记录里**没有**的信息。 +- 「平仓/离场」只是交易员自述摘要,不是客观成交明细;若记录未写明代币是否打到止损价、是否软件平仓等,不要断言执行路径,可用「在记录有限前提下,一种可能是……」或简短写「执行路径记录不足,无法判断」。 +- 「提前离场」类结论必须优先依据记录中的「提前离场记录」字段;若该段全为「无」或未出现有效内容,不得写道「明显扛单」「拒不止损」「未执行硬止损」等。 +- 实际RR为负只说明结果相对于预期RR不利,不等同于「风控失灵」或「止损纪律崩溃」,除非记录里另有依据。 +- 禁止用语:人身攻击、夸张定性(如「致命伤」「灾难」);语气克制、对事不对人。 +- 若有截图且你能辨认,再结合图讨论;看不清或无明确定位则明确说「无法从图确认」,不得虚构 K 线故事。 + +【输出结构】 +1. 总体盈亏结构(紧扣笔数、盈亏数字与 RR,少形容词) +2. 心态与执行(每笔 1–10 分 + 一句依据;依据必须对应记录字段) +3. 行为标签(提前离场 / 乱开仓 / 扛单等):仅在有字段或自述支撑时点名;否则写「记录未勾选或未描述,不作强加」 +4. 改进建议(最多 3 条,每条具体可执行) +5. 图表(若有且可读):结合价格行为简述;否则一两句说明无法看图分析 + +交易记录: +{trades_text} +""".strip() + payload = {"model": AI_MODEL, "prompt": prompt, "stream": False, "options": {"temperature": 0.2}} + images = [] + for p in image_paths or []: + b64 = _read_image_base64(p) + if b64: + images.append(b64) + if images: + payload["images"] = images + try: + r = requests.post(OLLAMA_API, json=payload, timeout=AI_TIMEOUT_SECONDS) + return r.json().get("response", "AI 生成失败") + except Exception as e: + return f"AI 调用失败:{str(e)}" + + +def ai_short_advice(prompt_text): + prompt = f""" +你是交易风控助理。请用中文给出**最多 3 条**提醒,要求: +- 每条不超过 25 个字 +- 语气克制、具体、可执行 +- 不要输出 Markdown,不要编号前缀以外的废话 + +场景: +{prompt_text} +""".strip() + payload = {"model": AI_MODEL, "prompt": prompt, "stream": False, "options": {"temperature": 0.2}} + try: + r = requests.post(OLLAMA_API, json=payload, timeout=AI_TIMEOUT_SECONDS) + return (r.json().get("response") or "").strip() + except Exception: + return "" + + +def _load_font(size): + if not ImageFont: + return None + candidates = [ + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", + "C:\\Windows\\Fonts\\msyh.ttc", + "C:\\Windows\\Fonts\\arial.ttf", + ] + for path in candidates: + if path and os.path.exists(path): + try: + return ImageFont.truetype(path, size) + except Exception: + continue + try: + return ImageFont.load_default() + except Exception: + return None + + +def _ohlcv_to_rows(ohlcv): + rows = [] + for bar in ohlcv or []: + if not bar or len(bar) < 6: + continue + try: + rows.append( + { + "ts": int(bar[0]), + "o": float(bar[1]), + "h": float(bar[2]), + "l": float(bar[3]), + "c": float(bar[4]), + "v": float(bar[5]), + } + ) + except Exception: + continue + return rows + + +def _local_input_datetime_to_ms(dt_text): + raw = str(dt_text or "").strip() + if not raw: + return None + raw = raw.replace("T", " ") + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"): + try: + dt = datetime.strptime(raw, fmt) + aware = dt.replace(tzinfo=APP_TZ) + return int(aware.timestamp() * 1000) + except Exception: + continue + return None + + +def _marker_tag_label(tag): + t = str(tag or "").strip().upper() + if t == "ENTRY": + return "开仓" + if t == "EXIT": + return "平仓" + return str(tag or "") + + +def _pick_marker_point(rows, target_ts_ms, target_price=None): + if not rows or target_ts_ms is None: + return None, None + idx = min(range(len(rows)), key=lambda i: abs(int(rows[i]["ts"]) - int(target_ts_ms))) + if target_price is not None: + try: + p = float(target_price) + if p > 0: + return idx, p + except Exception: + pass + return idx, float(rows[idx]["c"]) + + +def _render_candles_subplot(rows, title, width, height, bg_rgb=(255, 255, 255), marker_points=None): + if not Image or not ImageDraw: + raise RuntimeError("缺少依赖:Pillow(pip install Pillow)") + img = Image.new("RGB", (width, height), bg_rgb) + draw = ImageDraw.Draw(img) + font = _load_font(14) + small = _load_font(12) + + pad_l, pad_r, pad_t, pad_b = 46, 12, 26, 28 + plot_w = max(10, width - pad_l - pad_r) + plot_h = max(10, height - pad_t - pad_b) + + header_bg = (245, 247, 250) + draw.rectangle((0, 0, width, pad_t), fill=header_bg) + if font: + draw.text((10, 6), title, fill=(25, 35, 60), font=font) + else: + draw.text((10, 6), title, fill=(25, 35, 60)) + + if not rows: + if small: + draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120), font=small) + else: + draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120)) + return img + + lo = min(r["l"] for r in rows) + hi = max(r["h"] for r in rows) + if hi <= lo: + hi = lo + 1e-12 + + n = len(rows) + marker_by_idx = {} + for mp in marker_points or []: + try: + idx = int(mp.get("idx")) + except Exception: + continue + if idx < 0 or idx >= n: + continue + marker_by_idx.setdefault(idx, []).append(mp) + + x0 = pad_l + for i, r in enumerate(rows): + x1 = pad_l + int((i + 1) * plot_w / n) + x_mid = (x0 + x1) // 2 + wick_x = x_mid + y_high = pad_t + int((hi - r["h"]) / (hi - lo) * plot_h) + y_low = pad_t + int((hi - r["l"]) / (hi - lo) * plot_h) + y_open = pad_t + int((hi - r["o"]) / (hi - lo) * plot_h) + y_close = pad_t + int((hi - r["c"]) / (hi - lo) * plot_h) + top = min(y_open, y_close) + bot = max(y_open, y_close) + up = r["c"] >= r["o"] + wick_color = (120, 120, 120) + edge_color = (20, 20, 20) + draw.line((wick_x, y_high, wick_x, y_low), fill=wick_color) + body_w = max(1, (x1 - x0) - 2) + left = x0 + 1 + if bot - top < 2: + mid = (top + bot) // 2 + draw.rectangle((left, mid, left + body_w, mid + 1), fill=edge_color) + else: + if up: + draw.rectangle((left, top, left + body_w, bot), fill=(255, 255, 255), outline=edge_color, width=1) + else: + draw.rectangle((left, top, left + body_w, bot), fill=edge_color, outline=edge_color, width=1) + for j, mp in enumerate(marker_by_idx.get(i, [])): + tag = str(mp.get("tag") or "") + label = _marker_tag_label(tag) + m_price = float(mp.get("price") or r["c"]) + y_m = pad_t + int((hi - m_price) / (hi - lo) * plot_h) + y_m = max(pad_t + 4, min(pad_t + plot_h - 4, y_m)) + x_off = (j - (len(marker_by_idx[i]) - 1) / 2.0) * 14 + x_draw = int(x_mid + x_off) + if tag == "ENTRY": + m_color = (0, 195, 95) + tri = [(x_draw, y_m - 20), (x_draw - 9, y_m - 4), (x_draw + 9, y_m - 4)] + text_y = y_m - 36 + else: + m_color = (235, 65, 65) + tri = [(x_draw, y_m + 20), (x_draw - 9, y_m + 4), (x_draw + 9, y_m + 4)] + text_y = y_m + 12 + draw.ellipse((x_draw - 5, y_m - 5, x_draw + 5, y_m + 5), fill=m_color, outline=(255, 255, 255), width=1) + draw.polygon(tri, fill=m_color) + draw.line((x_draw, y_m, x_draw, y_m - 16 if tag == "ENTRY" else y_m + 16), fill=m_color, width=3) + if font: + draw.text((x_draw + 8, text_y), label, fill=m_color, font=font) + else: + draw.text((x_draw + 8, text_y), label, fill=m_color) + x0 = x1 + + if len(marker_points or []) >= 2: + try: + entry = next((m for m in marker_points if m.get("tag") == "ENTRY"), None) + exitp = next((m for m in marker_points if m.get("tag") == "EXIT"), None) + if entry is not None and exitp is not None: + ex_i, ex_p = int(entry["idx"]), float(entry["price"]) + xx_i, xx_p = int(exitp["idx"]), float(exitp["price"]) + x_ex = pad_l + int((ex_i + 0.5) * plot_w / n) + x_xx = pad_l + int((xx_i + 0.5) * plot_w / n) + y_ex = pad_t + int((hi - ex_p) / (hi - lo) * plot_h) + y_xx = pad_t + int((hi - xx_p) / (hi - lo) * plot_h) + draw.line((x_ex, y_ex, x_xx, y_xx), fill=(35, 135, 255), width=3) + except Exception: + pass + + # 极简风格:不画网格与坐标轴,仅保留右下角轻量区间信息 + if small: + draw.text((width - 210, height - 22), f"L={lo:.6g} H={hi:.6g}", fill=(120, 125, 135), font=small) + return img + + +def _timeframe_period_ms(tf): + s = (tf or "").strip().lower() + if s.endswith("m"): + try: + return int(s[:-1]) * 60 * 1000 + except ValueError: + pass + if s.endswith("h"): + try: + return int(s[:-1]) * 3600 * 1000 + except ValueError: + pass + if s.endswith("d"): + try: + return int(s[:-1]) * 86400 * 1000 + except ValueError: + pass + return 300000 + + +def _ohlcv_dict_rows_to_lists(rows, lim): + if not rows: + return [] + pick = rows[-lim:] if len(rows) >= lim else rows + return [[r["ts"], r["o"], r["h"], r["l"], r["c"], r.get("v", 0)] for r in pick] + + +def _fetch_ohlcv_ending_at(exchange_symbol, timeframe, limit, end_ts_ms): + """以 end_ts_ms 为终点向前取 K 线(无 end 则拉最近 limit 根)。""" + lim = max(2, int(limit or ORDER_CHART_LIMIT)) + try: + if not end_ts_ms: + ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=lim) + else: + period = _timeframe_period_ms(timeframe) + since = int(end_ts_ms) - period * (lim + 10) + ohlcv = exchange.fetch_ohlcv( + exchange_symbol, timeframe=timeframe, since=max(0, since), limit=lim + 20 + ) + except Exception: + return [] + rows = _ohlcv_to_rows(ohlcv) + if not rows: + return [] + if not end_ts_ms: + return _ohlcv_dict_rows_to_lists(rows, lim) + filtered = [r for r in rows if int(r["ts"]) <= int(end_ts_ms)] + if len(filtered) >= 2: + return _ohlcv_dict_rows_to_lists(filtered, lim) + return _ohlcv_dict_rows_to_lists(rows, lim) + + +def generate_multi_timeframe_chart_png( + exchange_symbol, + title_prefix, + timeframes=None, + limit=None, + out_dir=None, + filename=None, + filename_prefix="chart", + marker_payload=None, + marker_timeframes=None, +): + if not ORDER_CHART_ENABLED: + return None + if not Image: + return None + requested = timeframes or ORDER_CHART_TFS + limit = limit or ORDER_CHART_LIMIT + preferred_layout = ["5m", "15m", "1h", "4h"] + requested_set = set(requested or []) + ordered = [tf for tf in preferred_layout if tf in requested_set] + for tf in requested: + if tf not in ordered: + ordered.append(tf) + timeframes = ordered[:4] if ordered else preferred_layout + + ensure_markets_loaded() + panels = [] + cell_w, cell_h = 980, 520 + end_ts_ms = None + if marker_payload: + try: + end_ts_ms = int(marker_payload.get("exit_ts_ms") or marker_payload.get("entry_ts_ms") or 0) or None + except (TypeError, ValueError): + end_ts_ms = None + default_marker_tfs = {str(t).strip().lower() for t in timeframes} + for tf in timeframes: + try: + ohlcv = _fetch_ohlcv_ending_at(exchange_symbol, tf, limit, end_ts_ms) + if not ohlcv and end_ts_ms: + ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=tf, limit=limit) + except Exception: + ohlcv = [] + rows = _ohlcv_to_rows(ohlcv)[-limit:] + title = f"{title_prefix} | {tf} x{len(rows)}" + points = [] + tf_key = str(tf).strip().lower() + if marker_payload: + if marker_timeframes: + marker_tfs = {str(x).strip().lower() for x in marker_timeframes if str(x).strip()} + else: + marker_tfs = default_marker_tfs + else: + marker_tfs = set() + if marker_payload and tf_key in marker_tfs: + entry_idx, entry_price = _pick_marker_point(rows, marker_payload.get("entry_ts_ms"), marker_payload.get("entry_price")) + exit_idx, exit_price = _pick_marker_point(rows, marker_payload.get("exit_ts_ms"), marker_payload.get("exit_price")) + if entry_idx is not None and entry_price is not None: + points.append({"idx": entry_idx, "price": entry_price, "tag": "ENTRY"}) + if exit_idx is not None and exit_price is not None: + points.append({"idx": exit_idx, "price": exit_price, "tag": "EXIT"}) + panels.append( + _render_candles_subplot( + rows, + title, + width=cell_w, + height=cell_h, + bg_rgb=(255, 255, 255), + marker_points=points, + ) + ) + + if not panels: + return None + + gap = 10 + cols = 2 + rows_n = int(math.ceil(len(panels) / cols)) + w = cols * cell_w + (cols - 1) * gap + h = rows_n * cell_h + (rows_n - 1) * gap + out = Image.new("RGB", (w, h), (255, 255, 255)) + idx = 0 + for r in range(rows_n): + for c in range(cols): + if idx >= len(panels): + break + x = c * (cell_w + gap) + y = r * (cell_h + gap) + out.paste(panels[idx], (x, y)) + idx += 1 + + # 四宫格间隔线(仅在拼图间隙处画线,不进入单张子图) + if ImageDraw and rows_n >= 1: + draw_out = ImageDraw.Draw(out) + line_col = (220, 225, 232) + x_mid = cell_w + gap // 2 + if w > x_mid >= 0: + draw_out.line((x_mid, 0, x_mid, h), fill=line_col, width=2) + for rr in range(1, rows_n): + y_mid = rr * cell_h + (rr - 1) * gap + gap // 2 + if 0 <= y_mid <= h: + draw_out.line((0, y_mid, w, y_mid), fill=line_col, width=2) + + target_dir = out_dir or ORDER_CHART_DIR + os.makedirs(target_dir, exist_ok=True) + fname = filename or f"{filename_prefix}_{uuid.uuid4().hex}.png" + out_path = os.path.join(target_dir, fname) + out.save(out_path, format="PNG") + return fname + + +def generate_order_open_chart( + exchange_symbol, + title_prefix, + timeframes=None, + limit=None, + opened_at_ms=None, + entry_price=None, +): + marker_payload = None + if opened_at_ms: + marker_payload = { + "entry_ts_ms": opened_at_ms, + "exit_ts_ms": None, + "entry_price": entry_price, + "exit_price": None, + } + marker_tfs = ( + {x.strip().lower() for x in (timeframes or ORDER_CHART_TFS) if x and str(x).strip()} + or {"5m", "15m", "1h", "4h"} + ) + return generate_multi_timeframe_chart_png( + exchange_symbol, + title_prefix, + timeframes=timeframes, + limit=limit, + out_dir=ORDER_CHART_DIR, + filename=None, + filename_prefix="order", + marker_payload=marker_payload, + marker_timeframes=marker_tfs, + ) + + +def journal_coin_from_symbol(symbol): + sym = (symbol or "").strip().upper() + if not sym: + return "" + if "/" in sym: + return sym.split("/")[0].strip() + if "-" in sym: + return sym.split("-")[0].strip() + if sym.endswith("USDT"): + return sym[:-4].strip() + return sym + + +EARLY_EXIT_TRIGGERS = ( + "", + "止盈", + "保本止盈", + "移动止盈", + "手动平仓", + "止损", + "其他", +) + +# 与用户约定的固定开仓类型 +ENTRY_REASON_OPTIONS = ( + "趋势多头:4h大结构突破前进场,确认条件:三次探顶,5m收敛不创新低", + "趋势空头:4h大结构突破前进场,确认条件:三次探底,5m收敛不创新高", + "趋势多头:小分歧低吸入场(左侧),确认条件:二次探底", + "趋势空头:小分歧高吸入场(左侧),确认条件:二次探顶", + "波段单:5m顺势突破,确认条件:2根k线+成交量放大+4h同向+日成交量前20", + "关键位箱体突破", + "关键位收敛突破", + "关键位斐波0.618", + "关键位斐波0.786", +) + +STATS_SEGMENT_DEFS = ( + ("all", "全部交易", {"segment": "all"}), + ("manual", "下单监控", {"segment": "manual"}), + ("key_box", "关键位箱体突破", {"segment": "key_box"}), + ("key_conv", "关键位收敛结构", {"segment": "key_conv"}), + ("key_fib618", "关键位斐波0.618", {"segment": "key_fib618"}), + ("key_fib786", "关键位斐波0.786", {"segment": "key_fib786"}), +) +# 复盘表单「其他」选项的 value(非入库值;自定义文本走 entry_reason_custom) +ENTRY_REASON_OTHER = "__OTHER__" + + +def normalize_entry_reason(raw, custom_text=None): + v = str(raw or "").strip() + if v == ENTRY_REASON_OTHER: + c = str(custom_text or "").strip() + return c[:2000] if c else "" + return v if v in ENTRY_REASON_OPTIONS else "" + + +def entry_reason_valid_for_storage(s): + """允许五种固定整句、或自定义短文本(不含未解析的 __OTHER__ 占位)。""" + t = str(s or "").strip() + if not t: + return True + if t == ENTRY_REASON_OTHER: + return False + if t in ENTRY_REASON_OPTIONS: + return True + return 1 <= len(t) <= 2000 + + +def normalize_early_exit_trigger(raw): + v = str(raw or "").strip() + return v if v in EARLY_EXIT_TRIGGERS else "" + + +def compose_early_exit_reason_saved(trigger, note): + """Readable single-line string stored in early_exit_reason for legacy consumers.""" + t = normalize_early_exit_trigger(trigger) + n = str(note or "").strip() + if t and n: + return f"{t}|{n}" + return t or n + + +def journal_exit_reason_stored(trigger, note): + """exit_reason 列与表单「一处」对齐:非手工=触发类型;手工=离场说明全文。""" + t = normalize_early_exit_trigger(trigger) + n = str(note or "").strip() + if t == "手动平仓": + return n + return t + + +def ai_extract_journal_from_image(image_b64): + prompt = """ +你是交易复盘信息提取助手。请从截图中提取可识别字段,并只输出 JSON(不要 markdown,不要解释)。 +要求: +1) 仅输出一个 JSON 对象。 +2) 时间输出为 YYYY-MM-DDTHH:MM(用于 HTML datetime-local),无法识别填空字符串。 +3) 不要猜测主观原因;early_exit_note(仅手工平仓)、note 默认留空,除非图中明确写出。 +4) 允许字段为空。 +5) entry_reason:优先从下列完整字符串中选一个(一字不差);若无法归类则可将简述写入 entry_reason(保存时也可选表单「其他」手写): + - 趋势多头:4h大结构突破前进场,确认条件:三次探顶,5m收敛不创新低 + - 趋势空头:4h大结构突破前进场,确认条件:三次探底,5m收敛不创新高 + - 趋势多头:小分歧低吸入场(左侧),确认条件:二次探底 + - 趋势空头:小分歧高吸入场(左侧),确认条件:二次探顶 + - 波段单:5m顺势突破,确认条件:2根k线+成交量放大+4h同向+日成交量前20 +6) early_exit_trigger 只能从下列取值中选一个(无法识别则填空字符串):保本止盈、移动止盈、手动平仓、止损、其他。 +7) 若触发为「手动平仓」,early_exit_note 必须写出图中可见的补充说明;其他触发类型 early_exit_note 留空。 +8) 若图中有无法归类的离场说明原文,可放进 early_exit_note,early_exit_trigger 填「其他」或留空。 + +JSON 字段: +{ + "open_datetime": "", + "close_datetime": "", + "coin": "", + "tf": "", + "pnl": "", + "expect_rr": "", + "real_rr": "", + "entry_reason": "", + "early_exit_trigger": "", + "early_exit_note": "", + "early_exit_reason": "", + "note": "" +} +""".strip() + payload = { + "model": AI_MODEL, + "prompt": prompt, + "images": [image_b64], + "stream": False, + "options": {"temperature": 0.1}, + } + try: + r = requests.post(OLLAMA_API, json=payload, timeout=AI_TIMEOUT_SECONDS) + raw = r.json().get("response", "") + data = _extract_json_object(raw) or {} + if not isinstance(data, dict): + data = {} + trig_in = data.get("early_exit_trigger") + note_in = data.get("early_exit_note") + legacy_reason = str(data.get("early_exit_reason") or "").strip() + out = { + "open_datetime": str(data.get("open_datetime") or "").strip(), + "close_datetime": str(data.get("close_datetime") or "").strip(), + "coin": str(data.get("coin") or "").strip(), + "tf": str(data.get("tf") or "").strip(), + "pnl": str(data.get("pnl") or "").strip(), + "expect_rr": str(data.get("expect_rr") or "").strip(), + "real_rr": str(data.get("real_rr") or "").strip(), + "entry_reason": normalize_entry_reason(data.get("entry_reason")), + "early_exit_trigger": normalize_early_exit_trigger(trig_in), + "early_exit_note": str(note_in or "").strip(), + "early_exit_reason": legacy_reason, + "note": str(data.get("note") or "").strip(), + } + if not out["early_exit_trigger"] and not out["early_exit_note"] and legacy_reason: + out["early_exit_note"] = legacy_reason + if out["early_exit_trigger"] == "手动平仓" and not out["early_exit_note"] and legacy_reason: + out["early_exit_note"] = legacy_reason + if out["early_exit_trigger"] != "手动平仓": + out["early_exit_note"] = "" + out["exit_reason"] = journal_exit_reason_stored(out["early_exit_trigger"], out["early_exit_note"]) + return out + except Exception: + return None + +# 初始化数据库(支持多空方向) +def init_db(): + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + + # 关键位监控 + c.execute('''CREATE TABLE IF NOT EXISTS key_monitors + (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT, + direction TEXT DEFAULT "long", upper REAL, lower REAL, + notification_count INTEGER DEFAULT 0, last_notified_at TEXT, + max_notify INTEGER DEFAULT 3, notify_interval_min INTEGER DEFAULT 5, + breakout_limit_pct REAL DEFAULT 1.5, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + # 订单监控(核心:加 direction 方向字段) + c.execute('''CREATE TABLE IF NOT EXISTS order_monitors + (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, direction TEXT DEFAULT "long", + exchange_symbol TEXT, + trigger_price REAL, stop_loss REAL, initial_stop_loss REAL, take_profit REAL, + margin_capital REAL DEFAULT 30, leverage INTEGER DEFAULT 5, + trade_style TEXT DEFAULT "trend", + risk_percent REAL, risk_amount REAL, + breakeven_rr_trigger REAL, breakeven_offset_pct REAL, breakeven_step_r REAL, + breakeven_armed INTEGER DEFAULT 0, breakeven_price REAL, + notional_value REAL, position_ratio REAL, base_amount REAL, + order_amount REAL, exchange_order_id TEXT, exchange_close_order_id TEXT, + exchange_margin_usdt REAL, + opened_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, opened_at_ms INTEGER, session_date TEXT, + status TEXT DEFAULT "active")''') + + # 交易记录(必须存多空) + c.execute('''CREATE TABLE IF NOT EXISTS trade_records + (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT, + direction TEXT DEFAULT "long", trigger_price REAL, stop_loss REAL, initial_stop_loss REAL, take_profit REAL, + margin_capital REAL, leverage INTEGER, pnl_amount REAL DEFAULT 0, hold_seconds INTEGER DEFAULT 0, + trade_style TEXT DEFAULT "trend", risk_amount REAL, planned_rr REAL, actual_rr REAL, + hold_minutes INTEGER DEFAULT 0, opened_at TEXT, opened_at_ms INTEGER, closed_at TEXT, closed_at_ms INTEGER, + result TEXT, miss_reason TEXT, exchange_trade_id TEXT, + reviewed_opened_at TEXT, reviewed_closed_at TEXT, reviewed_stop_loss REAL, reviewed_take_profit REAL, reviewed_pnl_amount REAL, + reviewed_result TEXT, reviewed_miss_reason TEXT, reviewed_hold_seconds INTEGER, reviewed_hold_minutes INTEGER, + reviewed_at TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + c.execute('''CREATE TABLE IF NOT EXISTS trading_sessions + (session_date TEXT PRIMARY KEY, start_capital REAL, current_capital REAL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + c.execute('''CREATE TABLE IF NOT EXISTS journal_entries + (id TEXT PRIMARY KEY, open_datetime TEXT, close_datetime TEXT, hold_duration TEXT, + coin TEXT, tf TEXT, pnl TEXT, entry_reason TEXT, exit_reason TEXT, + expect_rr TEXT, real_rr TEXT, early_exit TEXT, early_exit_reason TEXT, + early_exit_trigger TEXT, early_exit_note TEXT, + mood_score INTEGER, mood_ai_score INTEGER, mood_ai_comment TEXT, mood_issues TEXT, post_breakeven_stare TEXT, + new_trade_while_occupied TEXT, note TEXT, image TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + c.execute('''CREATE TABLE IF NOT EXISTS ai_reviews + (id TEXT PRIMARY KEY, review_type TEXT, target_date TEXT, content TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + c.execute('''CREATE TABLE IF NOT EXISTS transfer_logs + (id INTEGER PRIMARY KEY AUTOINCREMENT, transfer_type TEXT, transfer_day TEXT, + amount REAL, from_account TEXT, to_account TEXT, status TEXT, message TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + c.execute('''DROP INDEX IF EXISTS idx_transfer_logs_unique_day''') + c.execute('''CREATE UNIQUE INDEX IF NOT EXISTS idx_transfer_logs_auto_daily_unique + ON transfer_logs(transfer_type, transfer_day) + WHERE transfer_type = 'auto_daily' ''') + + # 给旧表加 direction 字段(兼容老数据,不报错) + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN direction TEXT DEFAULT 'long'") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_symbol TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN margin_capital REAL DEFAULT 30") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN leverage INTEGER DEFAULT 5") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN trade_style TEXT DEFAULT 'trend'") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN risk_percent REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN risk_amount REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_rr_trigger REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_offset_pct REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_step_r REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_armed INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_price REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN initial_stop_loss REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN notional_value REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN position_ratio REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN base_amount REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN order_amount REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_order_id TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_close_order_id TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN opened_at TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN opened_at_ms INTEGER") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN session_date TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 1") + except Exception: + pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_margin_usdt REAL") + except Exception: + pass + try: + c.execute(f"ALTER TABLE order_monitors ADD COLUMN monitor_type TEXT DEFAULT '{ORDER_MONITOR_TYPE_MANUAL}'") + except Exception: + pass + try: + c.execute( + "UPDATE order_monitors SET monitor_type=? WHERE monitor_type IS NULL OR TRIM(monitor_type)=''", + (ORDER_MONITOR_TYPE_MANUAL,), + ) + except Exception: + pass + try: + c.execute("UPDATE order_monitors SET opened_at = datetime('now') WHERE opened_at IS NULL OR opened_at = ''") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN direction TEXT DEFAULT 'long'") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN margin_capital REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN leverage INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN pnl_amount REAL DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN hold_seconds INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN hold_minutes INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN trade_style TEXT DEFAULT 'trend'") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN risk_amount REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN planned_rr REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN actual_rr REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN initial_stop_loss REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN exchange_trade_id TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN opened_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN opened_at_ms INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN closed_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN closed_at_ms INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_opened_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_closed_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_stop_loss REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_take_profit REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_pnl_amount REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_result TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_miss_reason TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_hold_seconds INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_hold_minutes INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN entry_reason TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_entry_reason TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN mood_ai_score INTEGER") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN mood_ai_comment TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_trigger TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_note TEXT") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN direction TEXT DEFAULT 'long'") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN notification_count INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN last_notified_at TEXT") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN max_notify INTEGER DEFAULT 3") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN notify_interval_min INTEGER DEFAULT 5") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN breakout_limit_pct REAL DEFAULT 1.5") + except: pass + for ddl in ( + "ALTER TABLE key_monitors ADD COLUMN fib_limit_order_id TEXT", + "ALTER TABLE key_monitors ADD COLUMN fib_entry_price REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_stop_loss REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_take_profit REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_order_amount REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_margin_capital REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_leverage INTEGER", + "ALTER TABLE key_monitors ADD COLUMN sl_tp_mode TEXT DEFAULT 'standard'", + "ALTER TABLE key_monitors ADD COLUMN manual_take_profit REAL", + "ALTER TABLE key_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 0", + ): + try: + c.execute(ddl) + except Exception: + pass + try: + c.execute("ALTER TABLE trading_sessions ADD COLUMN key_sizing_capital_snapshot REAL") + except Exception: + pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN key_signal_type TEXT") + except Exception: + pass + for ddl in ( + "ALTER TABLE trade_records ADD COLUMN key_signal_type TEXT", + "ALTER TABLE trade_records ADD COLUMN exchange_realized_pnl REAL", + "ALTER TABLE trade_records ADD COLUMN exchange_opened_at TEXT", + "ALTER TABLE trade_records ADD COLUMN exchange_closed_at TEXT", + "ALTER TABLE trade_records ADD COLUMN exchange_sync_key TEXT", + ): + try: + c.execute(ddl) + except Exception: + pass + + c.execute( + """CREATE TABLE IF NOT EXISTS key_monitor_history + (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT, direction TEXT, + upper REAL, lower REAL, notification_count INTEGER, last_alert_message TEXT, + close_reason TEXT, closed_at TEXT)""" + ) + + conn.commit() + conn.close() + +init_db() + +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def app_now(): + """应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较)。""" + return datetime.now(APP_TZ).replace(tzinfo=None) + + +def app_now_str(): + return app_now().strftime("%Y-%m-%d %H:%M:%S") + + +def utc_now_dt(): + """当前时刻(UTC,aware)。""" + return datetime.now(timezone.utc) + + +def utc_calendar_date_str(): + """UTC 自然日 YYYY-MM-DD(用于自动划转去重等与交易所日界对齐的计算)。""" + return utc_now_dt().strftime("%Y-%m-%d") + + +def get_trading_day(now=None): + """交易日字符串:本地时钟下若小时 < TRADING_DAY_RESET_HOUR 则归属「上一日历日」。""" + now = now or app_now() + if getattr(now, "tzinfo", None): + now = now.astimezone(APP_TZ).replace(tzinfo=None) + if now.hour < TRADING_DAY_RESET_HOUR: + return (now - timedelta(days=1)).strftime("%Y-%m-%d") + return now.strftime("%Y-%m-%d") + + +TRADE_COMPLETED_RESULTS = ( + "止盈", + "止损", + "保本止盈", + "移动止盈", + "手动平仓", + "强制清仓", + "外部平仓", +) + +REVIEW_RESULT_OPTIONS = ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓") + + +def parse_dt_for_trading_day(s): + if not s: + return None + s = str(s).strip().replace("Z", "").replace("T", " ") + if not s: + return None + for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)): + try: + return datetime.strptime(s[:ln], fmt) + except ValueError: + continue + return None + + +def insert_key_monitor_history(conn, row, notification_count, last_msg, close_reason): + conn.execute( + """INSERT INTO key_monitor_history + (symbol, monitor_type, direction, upper, lower, notification_count, last_alert_message, close_reason, closed_at) + VALUES (?,?,?,?,?,?,?,?,?)""", + ( + row["symbol"], + row["monitor_type"], + row["direction"] or "long", + row["upper"], + row["lower"], + int(notification_count or 0), + (last_msg or "")[:800] if last_msg else None, + close_reason, + app_now_str(), + ), + ) + + +def _session_week_bounds(trading_day_str): + end = datetime.strptime(trading_day_str, "%Y-%m-%d").date() + start = end - timedelta(days=6) + return start.strftime("%Y-%m-%d"), trading_day_str + + +def _calendar_month_bounds(local_dt): + y, m = local_dt.year, local_dt.month + start = f"{y:04d}-{m:02d}-01" + if m == 12: + end_d = datetime(y, 12, 31).date() + else: + end_d = (datetime(y, m + 1, 1) - timedelta(days=1)).date() + return start, end_d.strftime("%Y-%m-%d") + + +def _count_opens_between(conn, start_td, end_td): + return conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ?", + (start_td, end_td), + ).fetchone()[0] + + +def _list_window_from_request(): + return resolve_list_window(request.args, session, default_preset=PRESET_UTC_TODAY) + + +def _redirect_records(): + qs = list_window_redirect_query(session) + return redirect(f"/records?{qs}" if qs else "/records") + + +def _pnl_row_matches_segment(row, segment_key): + try: + mt = (row["monitor_type"] or "").strip() + kst = (row["key_signal_type"] or "").strip() + except Exception: + return False + if segment_key == "all": + return True + if segment_key == "manual": + return mt == ORDER_MONITOR_TYPE_MANUAL and not kst + if segment_key == "key_box": + return kst == "箱体突破" + if segment_key == "key_conv": + return kst == "收敛突破" + if segment_key == "key_fib618": + return kst == "斐波回调0.618" + if segment_key == "key_fib786": + return kst == "斐波回调0.786" + return False + + +def _count_opens_for_segment(conn, start_td, end_td, segment_key): + if segment_key == "manual": + return conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ? " + "AND (monitor_type IS NULL OR monitor_type=? OR TRIM(monitor_type)='') " + "AND (key_signal_type IS NULL OR TRIM(key_signal_type)='')", + (start_td, end_td, ORDER_MONITOR_TYPE_MANUAL), + ).fetchone()[0] + kst_map = { + "key_box": "箱体突破", + "key_conv": "收敛突破", + "key_fib618": "斐波回调0.618", + "key_fib786": "斐波回调0.786", + } + kst = kst_map.get(segment_key) + if kst: + return conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ? AND key_signal_type=?", + (start_td, end_td, kst), + ).fetchone()[0] + return conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ?", + (start_td, end_td), + ).fetchone()[0] + + +def _load_completed_trade_pnls(conn): + q = """SELECT pnl_amount, reviewed_pnl_amount, closed_at, reviewed_closed_at, created_at, opened_at, + result, reviewed_result, monitor_type, key_signal_type + FROM trade_records + ORDER BY COALESCE(closed_at, created_at, opened_at) ASC, id ASC""" + rows = conn.execute(q).fetchall() + out = [] + for r in rows: + effective_result = (r["reviewed_result"] or r["result"] or "").strip() + if effective_result not in TRADE_COMPLETED_RESULTS: + continue + try: + p = float(r["reviewed_pnl_amount"] if r["reviewed_pnl_amount"] is not None else (r["pnl_amount"] or 0)) + except (TypeError, ValueError): + p = 0.0 + t = parse_dt_for_trading_day(r["reviewed_closed_at"]) or parse_dt_for_trading_day(r["closed_at"]) or parse_dt_for_trading_day(r["created_at"]) + td = get_trading_day(t) if t else None + out.append((p, t, td, r)) + return out + + +def _compute_period_metrics(trades): + """trades: list of (pnl, close_dt, close_trading_day)""" + trades = [(p, t, td) for p, t, td in trades if t is not None] + trades.sort(key=lambda x: x[1]) + closed = len(trades) + wins = sum(1 for p, _, _ in trades if p > 0) + losses = sum(1 for p, _, _ in trades if p < 0) + net = round(sum(p for p, _, _ in trades), 2) + loss_sum_raw = sum(p for p, _, _ in trades if p < 0) + loss_sum_u = round(abs(loss_sum_raw), 2) if loss_sum_raw < 0 else 0.0 + neg_pnls = [p for p, _, _ in trades if p < 0] + pos_pnls = [p for p, _, _ in trades if p > 0] + max_single_loss = round(min(neg_pnls), 2) if neg_pnls else None + max_single_profit = round(max(pos_pnls), 2) if pos_pnls else None + cum = peak = max_dd = 0.0 + for p, _, _ in trades: + cum += p + peak = max(peak, cum) + max_dd = max(max_dd, peak - cum) + max_dd = round(max_dd, 2) + streak = 0 + for p, _, _ in reversed(trades): + if p < 0: + streak += 1 + else: + break + daily = {} + for p, _, td in trades: + if td: + daily[td] = daily.get(td, 0.0) + p + max_loss_streak_days = 0 + worst_day = None + worst_day_pnl = None + if daily: + sorted_days = sorted(daily.keys()) + run = 0 + for d in sorted_days: + if daily[d] < 0: + run += 1 + max_loss_streak_days = max(max_loss_streak_days, run) + else: + run = 0 + worst_day = min(daily.keys(), key=lambda x: daily[x]) + worst_day_pnl = round(daily[worst_day], 2) + win_rate_pct = round(wins / (wins + losses) * 100, 2) if (wins + losses) else None + return { + "closed_count": closed, + "win_count": wins, + "loss_count": losses, + "win_rate_pct": win_rate_pct, + "net_pnl_u": net, + "loss_sum_u": loss_sum_u, + "max_single_loss": max_single_loss, + "max_single_profit": max_single_profit, + "max_drawdown_u": max_dd, + "consecutive_losses": streak, + "max_loss_streak_days": max_loss_streak_days, + "worst_day": worst_day, + "worst_day_pnl": worst_day_pnl, + "opens_count": 0, + "range_label": "", + } + + +def compute_stats_bundle(conn, trading_day, now_dt=None): + """日 / 周 / 月 统计:平仓按北京时间交易日(默认 8:00 切日)计入。""" + now_dt = now_dt or app_now() + pnls = _load_completed_trade_pnls(conn) + total_opens_all = conn.execute("SELECT COUNT(*) FROM order_monitors").fetchone()[0] + w_start, w_end = _session_week_bounds(trading_day) + m_start, m_end = _calendar_month_bounds(now_dt) + + def in_week(tr): + return tr[2] and w_start <= tr[2] <= w_end + + def in_month(tr): + return tr[2] and m_start <= tr[2] <= m_end + + def slice_metrics(seg_key): + seg_rows = [tr for tr in pnls if _pnl_row_matches_segment(tr[3], seg_key)] + day_tr = [(p, t, td) for p, t, td, _r in seg_rows if td == trading_day] + week_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and w_start <= td <= w_end] + month_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and m_start <= td <= m_end] + dm = _compute_period_metrics(day_tr) + wm = _compute_period_metrics(week_tr) + mm = _compute_period_metrics(month_tr) + dm["opens_count"] = _count_opens_for_segment(conn, trading_day, trading_day, seg_key) + wm["opens_count"] = _count_opens_for_segment(conn, w_start, w_end, seg_key) + mm["opens_count"] = _count_opens_for_segment(conn, m_start, m_end, seg_key) + dm["range_label"] = f"北京时间交易日 {trading_day}({TRADING_DAY_RESET_HOUR}:00 切日)" + wm["range_label"] = f"{w_start} ~ {w_end}(北京日期,近7天)" + mm["range_label"] = f"{m_start} ~ {m_end}(北京自然月)" + return dm, wm, mm + + segments = [] + for seg_key, seg_title, _meta in STATS_SEGMENT_DEFS: + dm, wm, mm = slice_metrics(seg_key) + segments.append({"key": seg_key, "title": seg_title, "day": dm, "week": wm, "month": mm}) + + dm, wm, mm = slice_metrics("all") + + return { + "trading_day": trading_day, + "total_opens_all": total_opens_all, + "day": dm, + "week": wm, + "month": mm, + "segments": segments, + "stats_reset_hour": TRADING_DAY_RESET_HOUR, + } + + +def infer_leverage(symbol): + sym = (symbol or "").strip().upper() + if sym.startswith("BTC") or sym.startswith("ETH"): + return BTC_LEVERAGE + return ALT_LEVERAGE + + +def normalize_exchange_symbol(symbol): + sym = symbol.strip().upper() + if ":" in sym: + return sym + if "/" in sym: + base, quote = sym.split("/", 1) + quote_clean = quote.split(":")[0] + return f"{base}/{quote_clean}:{quote_clean}" + return sym + + +def resolve_monitor_exchange_symbol(row): + """将监控行上的 symbol / exchange_symbol 统一到 ccxt 永续合约 symbol,便于与 fetch_positions 结果比对。""" + raw = "" + try: + if row["exchange_symbol"]: + raw = str(row["exchange_symbol"]).strip() + except (KeyError, IndexError, TypeError): + raw = "" + if not raw: + try: + raw = str(row["symbol"] or "").strip() + except (KeyError, IndexError, TypeError): + raw = "" + return normalize_exchange_symbol(raw) if raw else "" + + +def _position_contract_symbol_match(position_symbol, wanted_exchange_symbol): + if not position_symbol or not wanted_exchange_symbol: + return False + a = normalize_exchange_symbol(str(position_symbol).strip()) + b = normalize_exchange_symbol(str(wanted_exchange_symbol).strip()) + return a == b + + +def _position_matches_wanted_contract(wanted_unified_sym, position_dict): + """统一 symbol 比对;不一致时用 Gate 原始 contract 与 ccxt market.id 对齐(兼容 1000PEPE 等命名差异)。""" + if not wanted_unified_sym or not position_dict: + return False + ps = position_dict.get("symbol") + if _position_contract_symbol_match(ps, wanted_unified_sym): + return True + try: + ensure_markets_loaded() + mid = (exchange.market(wanted_unified_sym).get("id") or "").strip().upper() + info = position_dict.get("info") or {} + c_raw = str(info.get("contract") or "").strip().upper() + if mid and c_raw and mid == c_raw: + return True + except Exception: + pass + return False + + +def _position_row_effective_contracts(p): + """张数:优先 ccxt contracts,否则用 Gate 原始 size/pos(避免统一层为 0 时被误判空仓)。""" + if not p: + return 0.0 + info = p.get("info") or {} + for val in (p.get("contracts"), info.get("size"), info.get("pos")): + if val is None or val == "": + continue + try: + x = abs(float(val)) + if x > 0: + return x + except (TypeError, ValueError): + continue + return 0.0 + + +def normalize_symbol_input(symbol): + sym = (symbol or "").strip().upper() + if not sym: + return "" + if "/" in sym: + return sym + if ":" in sym: + sym = sym.split(":")[0] + return f"{sym}/USDT" + + +def normalize_kline_limit(limit_raw, default=200): + try: + n = int(limit_raw) + except Exception: + return default + return 200 if n >= 200 else 100 + + +def get_recommended_capital(current_capital): + if current_capital <= DAILY_LOSS_CAPITAL: + return DAILY_LOSS_CAPITAL + if current_capital >= DAILY_PROFIT_CAPITAL: + return DAILY_PROFIT_CAPITAL + return DAILY_START_CAPITAL + + +def ensure_session(conn, session_date): + row = conn.execute( + "SELECT * FROM trading_sessions WHERE session_date = ?", + (session_date,) + ).fetchone() + if row: + return row + conn.execute( + "INSERT INTO trading_sessions (session_date, start_capital, current_capital) VALUES (?,?,?)", + (session_date, DAILY_START_CAPITAL, DAILY_START_CAPITAL) + ) + conn.commit() + return conn.execute( + "SELECT * FROM trading_sessions WHERE session_date = ?", + (session_date,) + ).fetchone() + + +def update_session_capital(conn, session_date, pnl_amount): + session_row = ensure_session(conn, session_date) + new_capital = float(session_row["current_capital"]) + float(pnl_amount) + conn.execute( + "UPDATE trading_sessions SET current_capital = ?, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?", + (round(new_capital, 4), session_date) + ) + conn.commit() + return round(new_capital, 4) + + +def calc_hold_seconds(opened_at_str, closed_at_dt): + try: + opened_at = datetime.strptime(opened_at_str, "%Y-%m-%d %H:%M:%S") + return int((closed_at_dt - opened_at).total_seconds()) + except Exception: + return 0 + + +def calc_hold_minutes(seconds): + if not seconds or seconds <= 0: + return 0 + return max(1, int(seconds // 60)) + + +def get_opened_at_value(row): + try: + keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + keys = [] + if "opened_at" in keys: + value = row["opened_at"] + if value: + return value + return app_now_str() + + +def get_effective_trade_field(row, reviewed_key, base_key, default=None): + try: + keys = row.keys() if hasattr(row, "keys") else row.keys() + except Exception: + keys = [] + if reviewed_key in keys: + v = row[reviewed_key] + if v is not None and str(v).strip() != "": + return v + if base_key in keys: + v = row[base_key] + if v is not None and str(v).strip() != "": + return v + return default + + +def to_effective_trade_dict(row): + item = row_to_dict(row) + base_stop = item.get("initial_stop_loss") if item.get("initial_stop_loss") not in (None, "") else item.get("stop_loss") + item["effective_opened_at"] = get_effective_trade_field(row, "reviewed_opened_at", "opened_at", item.get("opened_at")) + item["effective_closed_at"] = get_effective_trade_field(row, "reviewed_closed_at", "closed_at", item.get("closed_at")) + open_stop = item.get("initial_stop_loss") + if open_stop in (None, ""): + open_stop = base_stop + item["display_open_stop_loss"] = open_stop + item["effective_stop_loss"] = get_effective_trade_field(row, "reviewed_stop_loss", "stop_loss", open_stop) + item["effective_take_profit"] = get_effective_trade_field(row, "reviewed_take_profit", "take_profit", item.get("take_profit")) + item["effective_result"] = get_effective_trade_field(row, "reviewed_result", "result", item.get("result")) + item["effective_miss_reason"] = get_effective_trade_field(row, "reviewed_miss_reason", "miss_reason", item.get("miss_reason")) + item["effective_pnl_amount"] = get_effective_trade_field(row, "reviewed_pnl_amount", "pnl_amount", item.get("pnl_amount")) + item["effective_hold_minutes"] = get_effective_trade_field(row, "reviewed_hold_minutes", "hold_minutes", item.get("hold_minutes")) + item["effective_hold_seconds"] = get_effective_trade_field(row, "reviewed_hold_seconds", "hold_seconds", item.get("hold_seconds")) + er_eff = get_effective_trade_field(row, "reviewed_entry_reason", "entry_reason", item.get("entry_reason")) + item["effective_entry_reason"] = (str(er_eff).strip() if er_eff is not None else "") or "" + try: + _keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + _keys = [] + _reviewed_pnl_raw = row["reviewed_pnl_amount"] if "reviewed_pnl_amount" in _keys else None + has_reviewed_pnl = _reviewed_pnl_raw is not None and str(_reviewed_pnl_raw).strip() != "" + ex_pnl = item.get("exchange_realized_pnl") + if not has_reviewed_pnl and ex_pnl is not None and str(ex_pnl).strip() != "": + try: + item["effective_pnl_amount"] = round(float(ex_pnl), 2) + item["display_pnl_source"] = "exchange" + ex_open = (str(item.get("exchange_opened_at") or "").strip() or None) + ex_close = (str(item.get("exchange_closed_at") or "").strip() or None) + if ex_open: + item["effective_opened_at"] = ex_open + if ex_close: + item["effective_closed_at"] = ex_close + except (TypeError, ValueError): + item["display_pnl_source"] = "local" + elif has_reviewed_pnl: + item["display_pnl_source"] = "reviewed" + else: + item["display_pnl_source"] = "local" + return item + + +def format_price_magnitude_fallback(value): + """无 markets 或解析失败时的价格展示兜底(按量级)。""" + try: + v = float(value) + except Exception: + return str(value) + if v == 0: + return "0" + av = abs(v) + if av >= 10000: + d = 2 + elif av >= 100: + d = 3 + elif av >= 1: + d = 4 + elif av >= 0.01: + d = 6 + elif av >= 0.0001: + d = 8 + else: + d = 10 + text = f"{v:.{d}f}" + return text.rstrip("0").rstrip(".") if "." in text else text + + +def resolve_ccxt_price_symbol(symbol): + """将界面/库中的品种名转为 ccxt 永续合约 id(如 BTC/USDT -> BTC/USDT:USDT)。""" + s = (symbol or "").strip() + if not s: + return "" + if "/" not in s and ":" not in s: + s = f"{s.upper()}/USDT" + else: + s = s.upper() + return normalize_exchange_symbol(s) + + +def round_price_to_exchange(exchange_symbol, price): + """与交易所 tick 对齐后的 float,供入库与计算;失败时退回 float(price)。""" + if price in (None, ""): + return None + try: + v = float(price) + except (TypeError, ValueError): + return None + if not exchange_symbol: + return v + try: + ensure_markets_loaded() + s = exchange.price_to_precision(exchange_symbol, v) + return float(s) + except Exception: + return v + + +def format_price_for_symbol(symbol, value): + """价格展示:与交易所 price_to_precision 一致(与入库 round_price_to_exchange 对齐)。""" + if value in (None, ""): + return "-" + try: + v = float(value) + except Exception: + return str(value) + ex = resolve_ccxt_price_symbol(symbol) + if not ex: + return format_price_magnitude_fallback(v) + try: + ensure_markets_loaded() + return exchange.price_to_precision(ex, v) + except Exception: + return format_price_magnitude_fallback(v) + + +def format_usdt(value): + """USDT 资金类展示:固定两位小数。""" + if value in (None, ""): + return "-" + try: + return f"{float(value):.2f}" + except (TypeError, ValueError): + return str(value) + + +def format_signed_usdt(value): + """USDT 盈亏等可正可负:+1.23 / -0.50 / 0.00""" + if value in (None, ""): + return "-" + try: + v = float(value) + except (TypeError, ValueError): + return str(value) + if v == 0: + return "0.00" + sign = "+" if v > 0 else "" + return f"{sign}{v:.2f}" + + +def format_wechat_scalar_2dp(value): + """企业微信推送:数值统一两位小数(与交易所 tick 无关)。""" + if value in (None, ""): + return "-" + try: + return f"{float(value):.2f}" + except (TypeError, ValueError): + return str(value) + + +def format_hold_minutes(minutes): + if not minutes: + return "0分钟" + total = int(minutes) + hours = total // 60 + mins = total % 60 + if hours: + return f"{hours}小时{mins}分钟" + return f"{mins}分钟" + + +def calc_pnl(direction, trigger_price, exit_price, margin_capital, leverage): + try: + trigger = float(trigger_price) + exit_p = float(exit_price) + margin = float(margin_capital) + lev = float(leverage) + if trigger <= 0: + return 0.0 + if direction == "short": + pnl_ratio = (trigger - exit_p) / trigger + else: + pnl_ratio = (exit_p - trigger) / trigger + return round(margin * lev * pnl_ratio, 4) + except Exception: + return 0.0 + + +def calc_rr_ratio(direction, entry_price, stop_loss, take_profit): + try: + entry = float(entry_price) + sl = float(stop_loss) + tp = float(take_profit) + if entry <= 0 or sl <= 0 or tp <= 0: + return None + if direction == "short": + risk = sl - entry + reward = entry - tp + else: + risk = entry - sl + reward = tp - entry + if risk <= 0 or reward <= 0: + return None + return round(reward / risk, 4) + except Exception: + return None + + +def calc_risk_fraction(direction, entry_price, stop_loss): + try: + entry = float(entry_price) + sl = float(stop_loss) + if entry <= 0 or sl <= 0: + return None + if direction == "short": + risk = sl - entry + else: + risk = entry - sl + if risk <= 0: + return None + return risk / entry + except Exception: + return None + + +def calc_risk_amount_from_plan(direction, entry_price, stop_loss, margin_capital, leverage): + rf = calc_risk_fraction(direction, entry_price, stop_loss) + if rf is None: + return None + try: + notional = float(margin_capital) * float(leverage) + if notional <= 0: + return None + return round(notional * rf, 6) + except Exception: + return None + + +def calc_actual_rr(pnl_amount, risk_amount): + try: + r = float(risk_amount or 0) + if r <= 0: + return None + return round(float(pnl_amount or 0) / r, 4) + except Exception: + return None + + +def normalize_result_with_pnl(result, pnl_amount): + """ + 触发“止损”但实际已盈利时,归类为保本止盈,避免语义混淆。 + """ + if result == "止损": + try: + if float(pnl_amount or 0) > 0: + return "保本止盈" + except Exception: + pass + return result + + +def calc_breakeven_stop(direction, entry_price, risk_fraction, locked_r, offset_pct): + """ + 按“已锁定R”计算目标止损位: + - long: entry + locked_r * (entry*risk_fraction) + offset + - short: entry - locked_r * (entry*risk_fraction) - offset + """ + try: + entry = float(entry_price) + rf = float(risk_fraction) + lr = float(locked_r) + off = float(offset_pct) / 100.0 + if entry <= 0 or rf <= 0 or lr < 0: + return None + base_move = entry * rf * lr + offset_move = entry * off + if direction == "short": + return round(entry - base_move - offset_move, 8) + return round(entry + base_move + offset_move, 8) + except Exception: + return None + + +def insert_trade_record( + conn, + symbol, + monitor_type, + direction, + trigger_price, + stop_loss, + initial_stop_loss=None, + take_profit=None, + margin_capital=None, + leverage=None, + pnl_amount=0, + hold_seconds=0, + trade_style=None, + risk_amount=None, + planned_rr=None, + actual_rr=None, + result="", + miss_reason=None, + opened_at=None, + opened_at_ms=None, + closed_at=None, + closed_at_ms=None, + exchange_trade_id=None, + key_signal_type=None, + entry_reason=None, +): + hold_minutes = calc_hold_minutes(hold_seconds) + open_ts = opened_at or app_now_str() + close_ts = closed_at or app_now_str() + open_ts_ms = _to_ms_with_fallback(opened_at_ms, open_ts) + close_ts_ms = _to_ms_with_fallback(closed_at_ms, close_ts) + kst = key_signal_type_for_trade_record(key_signal_type, KEY_MONITOR_AUTO_TYPES) + snap_sl = initial_stop_loss if initial_stop_loss not in (None, "") else stop_loss + er = (entry_reason or "").strip() or entry_reason_from_key_signal(kst) or "" + conn.execute( + "INSERT INTO trade_records (symbol,monitor_type,key_signal_type,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage,pnl_amount,hold_seconds,trade_style,risk_amount,planned_rr,actual_rr,hold_minutes,opened_at,opened_at_ms,closed_at,closed_at_ms,result,miss_reason,exchange_trade_id,entry_reason) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, monitor_type, kst, direction, trigger_price, snap_sl, snap_sl, take_profit, + margin_capital, leverage, pnl_amount, hold_seconds, + trade_style, risk_amount, planned_rr, actual_rr, hold_minutes, + open_ts, open_ts_ms, close_ts, close_ts_ms, result, miss_reason, exchange_trade_id, er or None + ) + ) + + +def calc_duration_text(open_str, close_str): + try: + fmt = "%Y-%m-%dT%H:%M" + o = datetime.strptime(open_str, fmt) + c = datetime.strptime(close_str, fmt) + delta = c - o + seconds = int(delta.total_seconds()) + if seconds <= 0: + return "0分钟" + d = seconds // 86400 + h = (seconds % 86400) // 3600 + m = (seconds % 3600) // 60 + parts = [] + if d: + parts.append(f"{d}天") + if h: + parts.append(f"{h}小时") + if m or not parts: + parts.append(f"{m}分钟") + return " ".join(parts) + except Exception: + return "计算失败" + + +def row_to_dict(row): + return {k: row[k] for k in row.keys()} + + +def enrich_order_item(raw_item, current_capital): + item = dict(raw_item or {}) + margin = float(item.get("margin_capital") or 0) + lev = float(item.get("leverage") or 0) + notional = item.get("notional_value") + ratio = item.get("position_ratio") + if notional is None: + notional = round(margin * lev, 2) if margin and lev else 0 + if ratio is None: + ratio = round(margin / current_capital * 100, 2) if current_capital else 0 + item["notional_value"] = notional + item["position_ratio"] = ratio + item["rr_ratio"] = calc_rr_ratio( + item.get("direction") or "long", + item.get("trigger_price"), + item.get("initial_stop_loss") or item.get("stop_loss"), + item.get("take_profit"), + ) + try: + be = item.get("breakeven_enabled") + item["breakeven_enabled"] = 0 if be is not None and int(be) == 0 else 1 + except Exception: + item["breakeven_enabled"] = 1 + if not (item.get("monitor_type") or "").strip(): + item["monitor_type"] = ORDER_MONITOR_TYPE_MANUAL + return item + + +def ensure_exchange_live_ready(): + if not LIVE_TRADING_ENABLED: + return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)" + if not (GATE_API_KEY and GATE_API_SECRET): + return False, "缺少 Gate API 密钥配置(GATE_API_KEY / GATE_API_SECRET)" + return True, "" + + +def order_row_monitor_type(row): + if row is None: + return ORDER_MONITOR_TYPE_MANUAL + try: + keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + keys = [] + if "monitor_type" in keys: + mt = (row["monitor_type"] or "").strip() + if mt: + return mt + return ORDER_MONITOR_TYPE_MANUAL + + +def order_row_key_signal_type(row): + if row is None: + return None + try: + keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + keys = [] + if "key_signal_type" not in keys: + return None + kst = (row["key_signal_type"] or "").strip() + if kst in KEY_MONITOR_AUTO_TYPES or is_fib_key_monitor_type(kst): + return kst + return None + + +def exchange_private_api_configured(): + """仅表示已配置密钥;与是否允许下单(LIVE_TRADING_ENABLED)无关,用于只读拉仓等。""" + return bool(GATE_API_KEY and GATE_API_SECRET) + + +def _extract_usdt_total(balance): + usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {} + total_map = balance.get("total", {}) if isinstance(balance, dict) else {} + free_map = balance.get("free", {}) if isinstance(balance, dict) else {} + total = usdt_info.get("total") + if total is None: + total = usdt_info.get("equity") + if total is None: + total = total_map.get("USDT") + if total is None: + total = usdt_info.get("free") + if total is None: + total = free_map.get("USDT") + try: + return float(total) if total is not None else None + except Exception: + return None + + +def _extract_usdt_free(balance): + usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {} + free_map = balance.get("free", {}) if isinstance(balance, dict) else {} + free = usdt_info.get("free") + if free is None: + free = free_map.get("USDT") + try: + return float(free) if free is not None else None + except Exception: + return None + + +def _parse_usdt_from_gate_unified_accounts_body(data): + """ + 解析 Gate GET /unified/accounts 响应体中的 USDT(dict 或 list 形态的 balances 均支持)。 + ccxt fetch_balance(unifiedAccount) 在 balances 为数组时会访问 .keys() 崩溃,故资金兜底走此解析。 + """ + if not isinstance(data, dict): + return None + raw_fd = data.get("funding") + if isinstance(raw_fd, (int, float)): + return float(raw_fd) + if isinstance(raw_fd, str) and raw_fd.strip(): + try: + return float(raw_fd) + except Exception: + pass + if isinstance(raw_fd, dict): + u = raw_fd.get("USDT") or raw_fd.get("usdt") + if isinstance(u, dict): + for k in ("equity", "available", "total", "amount"): + v = u.get(k) + if v is not None: + try: + return float(v) + except Exception: + pass + + balances = data.get("balances") + if isinstance(balances, list): + for row in balances: + if not isinstance(row, dict): + continue + sym = str(row.get("currency") or row.get("asset") or row.get("name") or "").upper() + if sym != "USDT": + continue + for k in ("equity", "balance", "available", "total", "amount"): + v = row.get(k) + if v is not None: + try: + return float(v) + except Exception: + pass + elif isinstance(balances, dict): + u = balances.get("USDT") or balances.get("usdt") + if isinstance(u, dict): + for k in ("equity", "available", "total", "amount"): + v = u.get(k) + if v is not None: + try: + return float(v) + except Exception: + pass + + tb = data.get("total_balance") + if isinstance(tb, dict): + u = tb.get("USDT") or tb.get("usdt") + if isinstance(u, (int, float, str)): + try: + return float(u) + except Exception: + pass + if isinstance(u, dict): + for k in ("equity", "available", "amount", "total"): + val = u.get(k) + if val is not None: + try: + return float(val) + except Exception: + pass + return None + + +def _parse_gate_spot_accounts_response_usdt(response): + """解析 GET /spot/accounts 列表中的 USDT(与 fetch_balance spot 同源,ccxt 解析失败时可兜底)。""" + rows = None + if isinstance(response, list): + rows = response + elif isinstance(response, dict): + inner = response.get("result") + if isinstance(inner, list): + rows = inner + elif isinstance(inner, dict) and isinstance(inner.get("list"), list): + rows = inner["list"] + if not rows: + return None + for row in rows: + if not isinstance(row, dict): + continue + if str(row.get("currency") or "").upper() != "USDT": + continue + ts = row.get("total") + if ts is not None and str(ts).strip() != "": + try: + return float(ts) + except Exception: + pass + try: + return float(row.get("available") or 0) + float(row.get("locked") or 0) + except Exception: + pass + return None + + +def _fetch_usdt_by_types(type_candidates): + """统一只用 ccxt.fetch_balance;spot 必须带 marginMode=spot,否则会随 defaultMarginMode 误走 cross_margin。""" + for t in type_candidates: + try: + params = {"type": t} + if t == "spot": + params["marginMode"] = "spot" + bal = exchange.fetch_balance(params=params) + val = _extract_usdt_total(bal) + if val is not None: + return val + except Exception: + continue + return None + + +def _fetch_gate_funding_usdt(): + """ + Gate「资金账户」: + 1) fetch_balance(type=spot, marginMode=spot) — 避免 defaultMarginMode=cross 误走 cross_margin; + 2) privateSpotGetAccounts — 与 1 同源,ccxt 聚合异常或解析不到 USDT 时再试原始列表; + 3) privateUnifiedGetAccounts + 自解析 — 统一账户 balances 常为数组,ccxt unified fetch_balance 会崩。 + """ + spot_seen_ok = False + try: + ensure_markets_loaded() + bal = exchange.fetch_balance(params={"type": "spot", "marginMode": "spot"}) + spot_seen_ok = True + val = _extract_usdt_total(bal) + if val is not None: + return float(val) + except Exception: + pass + + try: + resp = exchange.privateSpotGetAccounts({}) + v = _parse_gate_spot_accounts_response_usdt(resp) + if v is not None: + return float(v) + except Exception: + pass + + try: + raw = exchange.privateUnifiedGetAccounts({}) + body = raw + if isinstance(body, dict) and isinstance(body.get("result"), dict): + body = body["result"] + v = _parse_usdt_from_gate_unified_accounts_body(body) if isinstance(body, dict) else None + if v is not None: + return float(v) + except Exception: + pass + + if spot_seen_ok: + return 0.0 + return None + + +def get_available_trading_usdt(): + ok_live, _ = ensure_exchange_live_ready() + if not ok_live: + return None + for t in ["swap", "spot"]: + try: + params = {"type": t} + if t == "spot": + params["marginMode"] = "spot" + bal = exchange.fetch_balance(params=params) + free_val = _extract_usdt_free(bal) + if free_val is not None: + return free_val + except Exception: + continue + return None + + +def get_synced_leverage(exchange_symbol, direction): + ensure_markets_loaded() + try: + positions = exchange.fetch_positions([exchange_symbol]) + for p in positions: + if not _position_matches_wanted_contract(exchange_symbol, p): + continue + info = p.get("info", {}) or {} + side = (p.get("side") or info.get("posSide") or "").lower() + if GATE_POS_MODE == "hedge" and side and side != direction: + continue + lev = p.get("leverage") + if lev is None or lev == 0 or str(lev) == "0": + lev = info.get("cross_leverage_limit") or info.get("leverage") + if lev: + try: + return int(float(lev)) + except Exception: + pass + except Exception: + pass + return None + + +def friendly_exchange_error(err, available_usdt=None): + msg = str(err) + low = msg.lower() + if ( + "51008" in msg + or "insufficient" in low + or "margin" in low and ("not enough" in low or "不足" in msg) + or "balance" in low and "insufficient" in low + ): + tail = f"(当前交易账户可用约 {round(available_usdt, 2)}U)" if available_usdt is not None else "" + return f"交易所下单失败:保证金不足 {tail}。请降低保证金/杠杆,或先划转USDT到合约账户。" + clean = re.sub(r"\s+", " ", msg).strip() + return f"交易所下单失败:{clean}" + + +def get_exchange_capitals(force=False): + ok_live, _ = ensure_exchange_live_ready() + if not ok_live: + return None, None + now_ts = time.time() + if (not force) and ACCOUNT_BALANCE_CACHE["updated_at"] and now_ts - ACCOUNT_BALANCE_CACHE["updated_at"] < BALANCE_REFRESH_SECONDS: + return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"] + try: + ACCOUNT_BALANCE_CACHE["funding_usdt"] = _fetch_gate_funding_usdt() + except Exception: + ACCOUNT_BALANCE_CACHE["funding_usdt"] = None + try: + ACCOUNT_BALANCE_CACHE["trading_usdt"] = _fetch_usdt_by_types(["swap", "spot"]) + except Exception: + # 勿保留上一次成功请求的旧值:鉴权失败时否则会误以为「合约余额仍能读」 + ACCOUNT_BALANCE_CACHE["trading_usdt"] = None + ACCOUNT_BALANCE_CACHE["updated_at"] = now_ts + return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"] + + +def execute_transfer_usdt(amount, from_account, to_account): + if amount <= 0: + return False, "划转金额必须大于0", None + ok_live, reason = ensure_exchange_live_ready() + if not ok_live: + return False, reason, None + try: + resp = exchange.transfer(TRANSFER_CCY, float(amount), from_account, to_account) + return True, "划转成功", resp + except Exception as e: + msg = str(e) + if "INVALID_KEY" in msg or "Invalid key" in msg: + msg += ( + "。常见原因:① GATE_API_SECRET 错误或 .env 里多了空格/换行;② IP 白名单未包含当前服务器出口 IP;" + "③ Gate「交易账户」类 API Key 若不支持钱包接口则无法走账户内划转 POST /wallet/transfers(需在官网确认该 Key 类型是否开放划转);" + "④ Key 已重置或权限变更。你已勾选现货/统一账户仍报错时,优先核对 Secret 与白名单。" + ) + return False, msg, None + + +def get_account_usdt_total(account_type): + """读取各账户 USDT。funding 走 _fetch_gate_funding_usdt;spot 同样 marginMode=spot,一律 ccxt。""" + raw = (account_type or "").strip().lower() + if raw == "funding": + return _fetch_gate_funding_usdt() + at = raw + try: + params = {"type": at} + if at == "spot": + params["marginMode"] = "spot" + bal = exchange.fetch_balance(params=params) + val = _extract_usdt_total(bal) + if val is not None: + return val + return 0.0 if at == "spot" else None + except Exception: + return None + + +def auto_transfer_once_per_day(): + if not AUTO_TRANSFER_ENABLED: + return + utc_dt = utc_now_dt() + bj = utc_dt.astimezone(APP_TZ) + if bj.hour != AUTO_TRANSFER_BJ_HOUR: + return + transfer_day = utc_calendar_date_str() + conn = get_db() + exists = conn.execute( + "SELECT id FROM transfer_logs WHERE transfer_type=? AND transfer_day=?", + ("auto_daily", transfer_day) + ).fetchone() + if exists: + conn.close() + return + target_amount = AUTO_TRANSFER_AMOUNT + to_balance = get_account_usdt_total(AUTO_TRANSFER_TO) + from_balance = get_account_usdt_total(AUTO_TRANSFER_FROM) + if to_balance is None: + conn.execute( + "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", + ("auto_daily", transfer_day, 0, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO, "failed", f"读取{AUTO_TRANSFER_TO}账户USDT失败") + ) + conn.commit() + conn.close() + return + needed = round(max(target_amount - float(to_balance), 0), 4) + if needed <= 0: + conn.execute( + "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", + ("auto_daily", transfer_day, 0, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO, "skipped", f"{AUTO_TRANSFER_TO}账户已达到目标{round(float(target_amount), 2)}U") + ) + conn.commit() + conn.close() + return + if from_balance is not None and from_balance < needed: + conn.execute( + "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", + ("auto_daily", transfer_day, needed, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO, "failed", f"{AUTO_TRANSFER_FROM}账户USDT不足,需{round(needed, 2)}U,当前{round(from_balance, 2)}U") + ) + conn.commit() + conn.close() + send_wechat_msg( + f"自动划转失败:{AUTO_TRANSFER_FROM}余额不足,需{round(needed, 2)}U,当前{round(from_balance, 2)}U\n" + f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" + ) + return + + ok, msg, _ = execute_transfer_usdt(needed, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO) + conn.execute( + "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", + ("auto_daily", transfer_day, needed, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO, "success" if ok else "failed", msg[:500]) + ) + conn.commit() + conn.close() + if ok: + send_wechat_msg( + f"自动划转成功:补足到{round(float(target_amount), 2)}U,实际划转{round(needed, 2)}U " + f"{AUTO_TRANSFER_FROM}->{AUTO_TRANSFER_TO}\n" + f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" + ) + else: + send_wechat_msg( + f"自动划转失败:计划补足到{round(float(target_amount), 2)}U,需划转{round(needed, 2)}U\n原因:{msg}\n" + f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" + ) + + +def trading_day_reset_allows_new_open(now): + """是否允许在满足其它风控的前提下于当前时刻新开仓(仅「整点前禁开」守卫)。""" + if not TRADING_DAY_RESET_OPEN_GUARD_ENABLED: + return True + return now.hour >= TRADING_DAY_RESET_HOUR + + +def get_active_position_count(conn): + return int(conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0]) + + +def clear_key_sizing_snapshot_if_flat(conn, session_date): + if get_active_position_count(conn) > 0: + return + conn.execute( + "UPDATE trading_sessions SET key_sizing_capital_snapshot = NULL, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?", + (session_date,), + ) + conn.commit() + + +def get_key_sizing_capital_snapshot(conn, session_date): + row = ensure_session(conn, session_date) + try: + val = row["key_sizing_capital_snapshot"] + except (KeyError, IndexError): + return None + if val is None: + return None + try: + return float(val) + except (TypeError, ValueError): + return None + + +def set_key_sizing_capital_snapshot(conn, session_date, capital): + ensure_session(conn, session_date) + conn.execute( + "UPDATE trading_sessions SET key_sizing_capital_snapshot = ?, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?", + (round(float(capital), 2), session_date), + ) + conn.commit() + + +def resolve_capital_base_for_key_open(conn, trading_day, live_capital): + live = float(live_capital) + active = get_active_position_count(conn) + if active <= 0: + set_key_sizing_capital_snapshot(conn, trading_day, live) + return live + if KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT: + snap = get_key_sizing_capital_snapshot(conn, trading_day) + if snap is not None and snap > 0: + return snap + return live + + +def precheck_risk(conn, symbol, direction): + now = app_now() + if not trading_day_reset_allows_new_open(now): + return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓" + active_count = get_active_position_count(conn) + if active_count >= MAX_ACTIVE_POSITIONS: + return False, f"已达最大持仓数({active_count}/{MAX_ACTIVE_POSITIONS})" + if direction not in ("long", "short"): + return False, "方向必须为 long 或 short" + if symbol.upper().startswith("BTC") or symbol.upper().startswith("ETH"): + expected = BTC_LEVERAGE + else: + expected = ALT_LEVERAGE + if expected <= 0: + return False, "杠杆配置异常" + return True, "" + + +def prepare_order_amount(exchange_symbol, margin_capital, leverage, fallback_price): + ensure_markets_loaded() + notional = float(margin_capital) * float(leverage) + ticker = exchange.fetch_ticker(exchange_symbol) + price = float(ticker.get("last") or fallback_price) + if price <= 0: + raise ValueError("触发价必须大于 0") + market = exchange.market(exchange_symbol) + contract_size = float(market.get("contractSize") or 1) + if market.get("contract"): + # 合约 amount 按张数/合约乘数解析;ccxt 会再做精度与符号处理 + amount = notional / (price * contract_size) + else: + amount = notional / price + min_amount = (market.get("limits", {}).get("amount", {}) or {}).get("min") + if min_amount and amount < float(min_amount): + raise ValueError(f"下单数量过小,最小数量为 {min_amount}") + amount_precise = float(exchange.amount_to_precision(exchange_symbol, amount)) + if amount_precise <= 0: + raise ValueError("下单数量精度后为 0,请提高基数或降低价格") + return amount_precise, price + + +def _to_positive_float(value): + try: + n = float(value) + return n if n > 0 else None + except Exception: + return None + + +def _extract_order_price_value(order_obj): + if not isinstance(order_obj, dict): + return None + for key in ("average", "price"): + v = _to_positive_float(order_obj.get(key)) + if v is not None: + return v + cost = _to_positive_float(order_obj.get("cost")) + filled = _to_positive_float(order_obj.get("filled")) + if cost is not None and filled is not None and filled > 0: + return cost / filled + info = order_obj.get("info") if isinstance(order_obj.get("info"), dict) else {} + for key in ("avgPx", "fillPx", "avgPrice", "fillPrice", "px"): + v = _to_positive_float(info.get(key)) + if v is not None: + return v + return None + + +def resolve_order_entry_price(order_resp, exchange_symbol, fallback_price): + price = _extract_order_price_value(order_resp) + if price is not None: + return round(price, 8) + order_id = (order_resp or {}).get("id") + if order_id: + try: + fetched = exchange.fetch_order(order_id, exchange_symbol) + fetched_price = _extract_order_price_value(fetched) + if fetched_price is not None: + return round(fetched_price, 8) + except Exception: + pass + fallback = _to_positive_float(fallback_price) + return round(fallback, 8) if fallback is not None else 0.0 + + +def get_contract_size(exchange_symbol): + ensure_markets_loaded() + market = exchange.market(exchange_symbol) + return float(market.get("contractSize") or 1) + + +def parse_positive_float(value): + if value is None: + return None + raw = str(value).strip() + if not raw: + return None + num = float(raw) + if num <= 0: + raise ValueError("数值必须大于0") + return num + + +def build_gate_order_params(direction, reduce_only=False): + params = {} + if reduce_only: + params["reduceOnly"] = True + return params + + +def _gate_contracts_amount_for_tpsl(order, fallback_amount): + for key in ("filled", "amount"): + v = order.get(key) + try: + fv = float(v) + if fv > 0: + return fv + except Exception: + pass + return float(fallback_amount) + + +def _gate_place_tp_sl_orders_legacy_conditional(exchange_symbol, direction, contracts_amount, stop_loss, take_profit): + """ccxt 市价减仓条件单(两张单分别带 stopLossPrice / takeProfitPrice),与官方仓位类触发单等价逻辑不同路径。""" + ensure_markets_loaded() + close_side = "sell" if direction == "long" else "buy" + base = {"reduceOnly": True} + last_err = None + for attempt in range(8): + try: + exchange.create_order( + exchange_symbol, "market", close_side, contracts_amount, None, + dict(base, stopLossPrice=float(stop_loss)), + ) + exchange.create_order( + exchange_symbol, "market", close_side, contracts_amount, None, + dict(base, takeProfitPrice=float(take_profit)), + ) + return + except Exception as e: + last_err = e + time.sleep(0.2 * (attempt + 1)) + raise RuntimeError(f"交易所未接受条件止盈/止损委托参数:{last_err}") + + +def _gate_place_tp_sl_orders_position_price_orders(exchange_symbol, direction, stop_loss, take_profit): + """ + Gate 永续官方仓位类触发单:POST futures/{settle}/price_orders, + order_type=close-long-position / close-short-position,单向全平 close+size=0;双向需 auto_size。 + 与 App 内展示的「条件委托」一致,平仓后仍需 cancel_gate_swap_trigger_orders 避免残留。 + """ + ensure_markets_loaded() + market = exchange.market(exchange_symbol) + if not market.get("swap"): + raise RuntimeError("仅支持永续合约 symbol") + settle = market["settleId"] + contract = market["id"] + order_type = "close-long-position" if direction == "long" else "close-short-position" + close_side = "sell" if direction == "long" else "buy" + if close_side == "sell": + sl_rule, tp_rule = 2, 1 + else: + sl_rule, tp_rule = 1, 2 + initial = { + "contract": contract, + "size": 0, + "price": "0", + "close": True, + "reduce_only": True, + "tif": "ioc", + "text": "api", + } + if GATE_POS_MODE == "hedge": + initial["auto_size"] = "close_long" if direction == "long" else "close_short" + sl_s = exchange.price_to_precision(exchange_symbol, float(stop_loss)) + tp_s = exchange.price_to_precision(exchange_symbol, float(take_profit)) + + def _payload(trigger_price, rule): + trig = { + "strategy_type": 0, + "price_type": GATE_TPSL_PRICE_TYPE, + "price": trigger_price, + "rule": rule, + } + if GATE_TPSL_TRIGGER_EXPIRATION > 0: + trig["expiration"] = GATE_TPSL_TRIGGER_EXPIRATION + return { + "settle": settle, + "initial": dict(initial), + "trigger": trig, + "order_type": order_type, + } + + last_err = None + for attempt in range(8): + try: + exchange.privateFuturesPostSettlePriceOrders(_payload(sl_s, sl_rule)) + try: + exchange.privateFuturesPostSettlePriceOrders(_payload(tp_s, tp_rule)) + except Exception: + cancel_gate_swap_trigger_orders(exchange_symbol) + raise + return + except Exception as e: + last_err = e + time.sleep(0.2 * (attempt + 1)) + raise RuntimeError(f"交易所未接受仓位类条件止盈/止损:{last_err}") + + +def _gate_place_tp_sl_orders(exchange_symbol, direction, contracts_amount, stop_loss, take_profit): + if GATE_TPSL_USE_POSITION_ORDER: + try: + _gate_place_tp_sl_orders_position_price_orders(exchange_symbol, direction, stop_loss, take_profit) + return + except Exception: + pass + _gate_place_tp_sl_orders_legacy_conditional( + exchange_symbol, direction, contracts_amount, stop_loss, take_profit, + ) + + +def ensure_markets_loaded(force=False): + global MARKETS_LOADED + if force or not MARKETS_LOADED: + exchange.load_markets(reload=force) + MARKETS_LOADED = True + + +def place_exchange_order(exchange_symbol, direction, amount, leverage, stop_loss=None, take_profit=None): + ensure_markets_loaded() + exchange.set_leverage(leverage, exchange_symbol) + side = "buy" if direction == "long" else "sell" + params = build_gate_order_params(direction, reduce_only=False) + order = exchange.create_order(exchange_symbol, "market", side, amount, None, params) + order.setdefault("tpsl_attached", False) + if stop_loss and take_profit: + try: + contracts_amt = _gate_contracts_amount_for_tpsl(order, amount) + _gate_place_tp_sl_orders(exchange_symbol, direction, contracts_amt, stop_loss, take_profit) + order["tpsl_attached"] = True + except RuntimeError: + raise + except Exception as e: + raise RuntimeError(f"交易所未接受条件止盈/止损委托,已拒绝开仓:{str(e)}") from e + return order + + +def close_exchange_order(order_row): + ensure_markets_loaded() + exchange_symbol = order_row["exchange_symbol"] or normalize_exchange_symbol(order_row["symbol"]) + amount = float(order_row["order_amount"] or 0) + if amount <= 0: + raise ValueError("平仓失败:缺少有效下单数量") + direction = order_row["direction"] + side = "sell" if direction == "long" else "buy" + params = build_gate_order_params(direction, reduce_only=True) + return exchange.create_order(exchange_symbol, "market", side, amount, None, params) + + +def _gate_swap_trigger_order_params(): + """永续条件单(止盈/止损触发委托)查询/撤销用的 ccxt 参数。""" + p = {"type": "swap", "trigger": True} + try: + exchange.load_unified_status() + if exchange.options.get("unifiedAccount"): + p["unifiedAccount"] = True + except Exception: + pass + return p + + +def cancel_gate_swap_trigger_orders(exchange_symbol): + """ + 仓位已平时撤销该合约下剩余的永续条件委托(trigger / price_orders),避免孤儿单残留。 + 与 App 内「仓位附带止盈止损」不同,本系统挂的是独立触发单,平仓后交易所未必自动撤。 + """ + ok, _ = ensure_exchange_live_ready() + if not ok or not exchange_symbol: + return + ensure_markets_loaded() + params = _gate_swap_trigger_order_params() + sym = exchange_symbol + try: + exchange.cancel_all_orders(sym, params) + return + except Exception: + pass + try: + pending = exchange.fetch_open_orders(sym, params=params) + except Exception: + return + for o in pending or []: + oid = o.get("id") + if oid is None: + continue + try: + exchange.cancel_order(str(oid), sym, params) + except Exception: + pass + + +def _gate_list_trigger_open_orders(exchange_symbol): + params = _gate_swap_trigger_order_params() + try: + return exchange.fetch_open_orders(exchange_symbol, params=params) or [] + except Exception: + return [] + + +def _gate_order_trigger_price(order): + for key in ("stopPrice", "triggerPrice", "price"): + try: + v = float(order.get(key) or 0) + if v > 0: + return v + except Exception: + pass + info = order.get("info") or {} + if isinstance(info, dict): + trig = info.get("trigger") + if isinstance(trig, dict): + try: + v = float(trig.get("price") or 0) + if v > 0: + return v + except Exception: + pass + for key in ("trigger_price", "triggerPrice", "stopPrice", "price"): + try: + v = float(info.get(key) or 0) + if v > 0: + return v + except Exception: + pass + return None + + +def _gate_tpsl_role_from_order(order, direction): + info = order.get("info") or {} + if not isinstance(info, dict): + info = {} + ot = str(info.get("order_type") or info.get("orderType") or order.get("type") or "").lower() + if "take" in ot and "profit" in ot: + return "tp" + if "stop" in ot and "loss" in ot: + return "sl" + trig = info.get("trigger") + rule = None + if isinstance(trig, dict) and trig.get("rule") is not None: + try: + rule = int(trig["rule"]) + except Exception: + rule = None + if rule is None: + try: + rule = int(info.get("rule")) + except Exception: + rule = None + if rule is not None: + if direction == "long": + return "sl" if rule == 2 else ("tp" if rule == 1 else None) + return "sl" if rule == 1 else ("tp" if rule == 2 else None) + if order.get("stopLossPrice"): + return "sl" + if order.get("takeProfitPrice"): + return "tp" + typ = str(order.get("type") or "").upper() + if "TAKE" in typ: + return "tp" + if "STOP" in typ: + return "sl" + return None + + +def _gate_tpsl_slot_from_order(order, exchange_symbol): + trig = _gate_order_trigger_price(order) + try: + amt = float(order.get("amount") or order.get("remaining") or 0) + except Exception: + amt = None + if amt is not None and amt <= 0: + amt = None + oid = order.get("id") + if oid is None and isinstance(order.get("info"), dict): + oid = order["info"].get("id") or order["info"].get("order_id") + disp = format_price_for_symbol(exchange_symbol, trig) if trig else "-" + return { + "order_id": str(oid) if oid is not None else "", + "channel": "gate_trigger", + "trigger_price": trig, + "trigger_display": disp, + "amount": amt, + "type": str(order.get("type") or ""), + } + + +def fetch_exchange_tpsl_slots(exchange_symbol, direction, plan_sl=None, plan_tp=None): + slots = {"sl": None, "tp": None} + if not exchange_symbol: + return slots + ok, _ = ensure_exchange_live_ready() + if not ok: + return slots + try: + ensure_markets_loaded() + ambiguous = [] + for order in _gate_list_trigger_open_orders(exchange_symbol): + role = _gate_tpsl_role_from_order(order, direction) + slot = _gate_tpsl_slot_from_order(order, exchange_symbol) + if role in ("sl", "tp"): + if slots[role] is None: + slots[role] = slot + continue + ambiguous.append(slot) + for slot in ambiguous: + trig = slot.get("trigger_price") + if trig is None: + continue + try: + plan_sl_f = float(plan_sl) if plan_sl is not None else None + plan_tp_f = float(plan_tp) if plan_tp is not None else None + except Exception: + plan_sl_f = plan_tp_f = None + if plan_sl_f is not None and plan_tp_f is not None: + role = "sl" if abs(trig - plan_sl_f) <= abs(trig - plan_tp_f) else "tp" + elif plan_sl_f is not None: + role = "sl" + elif plan_tp_f is not None: + role = "tp" + else: + continue + if slots[role] is None: + slots[role] = slot + except Exception: + pass + return slots + + +def cancel_gate_tpsl_slot(exchange_symbol, slot): + if not slot or not exchange_symbol: + return + ensure_markets_loaded() + oid = slot.get("order_id") + if not oid: + return + params = _gate_swap_trigger_order_params() + exchange.cancel_order(str(oid), exchange_symbol, params) + + +def _resolve_tpsl_prices_for_manual(direction, live_price, sltp_mode, data): + sltp_mode = (sltp_mode or "price").strip().lower() + if sltp_mode == "pct": + sl_pct = float(data.get("sl_pct") or 0) + tp_pct = float(data.get("tp_pct") or 0) + if sl_pct <= 0 or tp_pct <= 0: + raise ValueError("百分比止盈止损须为正数") + sl_ratio = sl_pct / 100.0 + tp_ratio = tp_pct / 100.0 + entry = float(live_price) + if direction == "short": + stop_loss = entry * (1 + sl_ratio) + take_profit = entry * (1 - tp_ratio) + else: + stop_loss = entry * (1 - sl_ratio) + take_profit = entry * (1 + tp_ratio) + else: + stop_loss = float(data.get("sl") or data.get("stop_loss") or 0) + take_profit = float(data.get("tp") or data.get("take_profit") or data.get("tgt") or 0) + if stop_loss <= 0 or take_profit <= 0: + raise ValueError("止盈止损价格须大于 0") + return stop_loss, take_profit + + +def replace_active_monitor_tpsl_on_exchange(order_row, stop_loss, take_profit): + ok, reason = ensure_exchange_live_ready() + if not ok: + raise RuntimeError(reason or "实盘未就绪") + ex_sym = resolve_monitor_exchange_symbol(order_row) + direction = order_row["direction"] + cancel_gate_swap_trigger_orders(ex_sym) + contracts = get_live_position_contracts(ex_sym, direction) + if contracts is None or float(contracts) <= 0: + raise ValueError("交易所当前无该方向持仓,无法挂止盈止损") + amt = float(contracts) + if amt <= 0: + try: + amt = float(order_row["order_amount"] or 0) + except Exception: + amt = 0 + if amt <= 0: + raise ValueError("无法确定平仓数量") + _gate_place_tp_sl_orders(ex_sym, direction, amt, float(stop_loss), float(take_profit)) + + +def extract_trade_price_from_order(order): + if not order: + return None + for k in ("average", "avgPrice", "price"): + try: + v = float(order.get(k) or 0) + if v > 0: + return v + except Exception: + pass + try: + info = order.get("info") or {} + if isinstance(info, dict): + for k in ("fillPx", "avgPx", "fill_price"): + v = float(info.get(k) or 0) + if v > 0: + return v + except Exception: + pass + return None + + +def is_no_position_error(err_msg): + msg = (err_msg or "").lower() + keywords = [ + "no position", "position does not exist", "position not exist", + "pos size is 0", "nothing to close", "reduceonly", "51008" + ] + return any(k in msg for k in keywords) + + +def get_live_position_contracts(exchange_symbol, direction): + ensure_markets_loaded() + try: + rows = exchange.fetch_positions([exchange_symbol]) + except Exception: + return None + total = 0.0 + for p in rows: + if not _position_matches_wanted_contract(exchange_symbol, p): + continue + info = p.get("info", {}) or {} + side = (p.get("side") or info.get("posSide") or "").lower() + contracts = p.get("contracts") + if contracts is None: + raw_pos = info.get("pos") or info.get("size") + try: + contracts = abs(float(raw_pos)) if raw_pos is not None else 0.0 + except Exception: + contracts = 0.0 + try: + contracts = float(contracts) + except Exception: + contracts = 0.0 + if contracts <= 0: + continue + if GATE_POS_MODE == "hedge": + if side and side != direction: + continue + total += contracts + return total + + +def _select_live_position_row(rows, exchange_symbol, direction, relax_hedge=False): + """在 fetch_positions 结果中取与当前监控方向一致、张数最大的一条(与 get_live_position_contracts 过滤规则一致)。""" + if not rows: + return None + candidates = [] + for p in rows: + if not _position_matches_wanted_contract(exchange_symbol, p): + continue + info = p.get("info", {}) or {} + side = (p.get("side") or info.get("posSide") or "").lower() + contracts = _position_row_effective_contracts(p) + if contracts <= 0: + continue + if (not relax_hedge) and GATE_POS_MODE == "hedge": + if side and side != (direction or "").lower(): + continue + candidates.append((contracts, p)) + if not candidates and (not relax_hedge) and GATE_POS_MODE == "hedge": + return _select_live_position_row(rows, exchange_symbol, direction, relax_hedge=True) + if not candidates: + return None + candidates.sort(key=lambda x: x[0], reverse=True) + return candidates[0][1] + + +def _coerce_float(*values): + for v in values: + if v is None or v == "": + continue + try: + return float(v) + except (TypeError, ValueError): + continue + return None + + +def parse_ccxt_position_metrics(position, order_leverage=None): + """ + 从 ccxt 统一持仓结构解析保证金/名义/未实现盈亏(Gate 等所字段略有差异,做多键兜底)。 + 与 App「仓位保证金」对齐时优先用 initialMargin;缺失时再尝试 info 内字段。 + """ + if not position: + return None + p = position + info = p.get("info", {}) or {} + # Gate 全仓:ccxt 的 initialMargin 常为空;collateral 来自 API 的 margin,与 App「保证金」一致 + initial = _coerce_float(p.get("collateral"), p.get("initialMargin"), p.get("margin")) + if initial is None or initial <= 0: + initial = _coerce_float( + info.get("margin"), + info.get("cross_margin"), + info.get("iso_margin"), + info.get("initial_margin"), + info.get("position_margin"), + info.get("initialMargin"), + ) + notional = _coerce_float(p.get("notional"), p.get("notionalValue")) + if notional is None or notional <= 0: + notional = _coerce_float(info.get("value")) + if notional is not None: + notional = abs(notional) + # 全仓且 API margin 为 0 时:用名义/杠杆粗算展示(与交易所「约占用」接近) + if (initial is None or initial <= 0) and notional and notional > 0 and order_leverage: + try: + lev = float(order_leverage) + if lev > 0: + approx = notional / lev + if approx > 0: + initial = approx + except (TypeError, ValueError): + pass + unrealized = _coerce_float( + p.get("unrealizedPnl"), + info.get("unrealised_pnl"), + info.get("unrealized_pnl"), + ) + mark = _coerce_float(p.get("markPrice"), p.get("mark_price"), info.get("mark_price"), info.get("markPrice")) + out = {} + if initial is not None and initial > 0: + out["initial_margin"] = round(initial, 2) + if notional is not None and notional > 0: + out["notional"] = round(notional, 2) + if unrealized is not None: + out["unrealized_pnl"] = round(unrealized, 2) + if mark is not None and mark > 0: + out["mark_price"] = round(mark, 8) + return out or None + + +def get_live_position_exchange_metrics(exchange_symbol, direction, order_leverage=None): + ensure_markets_loaded() + if not exchange_private_api_configured() or not exchange_symbol: + return None + try: + rows = exchange.fetch_positions(None, {"settle": "usdt"}) or [] + except Exception: + try: + rows = exchange.fetch_positions([exchange_symbol]) or [] + except Exception: + return None + p = _select_live_position_row(rows, exchange_symbol, direction) + return parse_ccxt_position_metrics(p, order_leverage=order_leverage) + + +def _order_row_exchange_margin_usdt(row): + if not row: + return None + try: + keys = row.keys() + except Exception: + return None + if "exchange_margin_usdt" not in keys: + return None + v = row["exchange_margin_usdt"] + if v is None: + return None + try: + x = float(v) + except (TypeError, ValueError): + return None + return x if x > 0 else None + + +def margin_capital_for_trade_record(order_row): + """trade_records.基数:优先交易所持仓保证金快照,旧数据无快照时回退计划保证金。""" + ex = _order_row_exchange_margin_usdt(order_row) + if ex is not None: + return round(ex, 2) + if not order_row: + return None + try: + v = order_row["margin_capital"] + except (TypeError, KeyError, IndexError): + return None + if v is None: + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def try_persist_exchange_margin_for_order(conn, order_id, exchange_symbol, direction, order_leverage=None, max_attempts=6, sleep_s=0.45): + """开仓成功后持仓可见时拉取交易所保证金并写入 order_monitors(平仓后无法再取)。""" + if not conn or not order_id or not exchange_private_api_configured(): + return False + direction = (direction or "long").lower() + ex_sym = (exchange_symbol or "").strip() + if not ex_sym: + return False + n = max(1, int(max_attempts)) + delay = max(0.05, float(sleep_s)) + for _ in range(n): + pm = get_live_position_exchange_metrics(ex_sym, direction, order_leverage=order_leverage) + if pm and pm.get("initial_margin") is not None: + try: + v = float(pm["initial_margin"]) + except (TypeError, ValueError): + v = 0.0 + if v > 0: + conn.execute( + "UPDATE order_monitors SET exchange_margin_usdt=? WHERE id=?", + (round(v, 4), int(order_id)), + ) + return True + time.sleep(delay) + return False + + +def opened_at_str_to_ms(opened_at_str): + if not opened_at_str: + return None + dt = parse_dt_for_trading_day(opened_at_str) + if dt is None: + return None + try: + aware = dt.replace(tzinfo=APP_TZ) + return int(aware.timestamp() * 1000) + except Exception: + return None + + +def _to_ms_with_fallback(ms_value, dt_str): + try: + if ms_value is not None and str(ms_value).strip() != "": + v = int(float(ms_value)) + if v > 0: + return v + except Exception: + pass + return opened_at_str_to_ms(dt_str) + + +def ms_to_app_local_str(ms): + if ms is None: + return app_now_str() + try: + dt = datetime.fromtimestamp(ms / 1000.0, tz=timezone.utc).astimezone(APP_TZ) + return dt.replace(tzinfo=None).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return app_now_str() + + +def classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_price): + """根据成交价相对止盈/止损位归类;无法可靠归类时返回 None。""" + try: + tp = float(take_profit) + sl = float(stop_loss) + ex = float(exit_price) + trig = float(trigger_price) + except (TypeError, ValueError): + return None + band = max(abs(trig) * 0.0008, abs(tp - sl) * 0.003, 1e-12) + if direction == "long": + if ex >= tp - band: + return "止盈" + if ex <= sl + band: + return "止损" + else: + if ex <= tp + band: + return "止盈" + if ex >= sl - band: + return "止损" + return None + + +def fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=None): + """取开仓以来最近一笔减仓成交(与方向一致);失败返回 None。""" + if not (GATE_API_KEY and GATE_API_SECRET): + return None + ensure_markets_loaded() + since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) + close_side = "sell" if direction == "long" else "buy" + + def pick_from_trades(trades): + if not trades: + return None + candidates = [] + for t in trades: + if (t.get("side") or "").lower() != close_side: + continue + info = t.get("info") or {} + if not isinstance(info, dict): + info = {} + pos_side = (info.get("posSide") or t.get("posSide") or "").lower() + if GATE_POS_MODE == "hedge": + if pos_side in ("long", "short") and pos_side != direction: + continue + ts = t.get("timestamp") + if ts is None: + continue + candidates.append(t) + if not candidates: + return None + return max(candidates, key=lambda x: x.get("timestamp") or 0) + + try: + trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=100) + hit = pick_from_trades(trades) + if hit is None and since_ms: + trades = exchange.fetch_my_trades(exchange_symbol, since=None, limit=100) + hit = pick_from_trades(trades) + return hit + except Exception: + return None + + +def fetch_closing_fills_for_record(exchange_symbol, direction, opened_at_str, closed_at_str=None, opened_at_ms=None, closed_at_ms=None): + """ + 拉取某条历史记录对应的减仓成交(用于按 id 回填)。 + 返回按时间排序的成交列表。 + """ + if not (GATE_API_KEY and GATE_API_SECRET): + return [] + ensure_markets_loaded() + since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) + close_side = "sell" if direction == "long" else "buy" + closed_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str) if (closed_at_str or closed_at_ms is not None) else None + # 历史记录回填给一点缓冲,兼容成交落在记录时间附近的情况 + if closed_ms is not None: + closed_ms += 6 * 60 * 60 * 1000 + candidates = [] + all_side_candidates = [] + try: + trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=200) + except Exception: + trades = [] + if not trades and since_ms: + try: + trades = exchange.fetch_my_trades(exchange_symbol, since=None, limit=200) + except Exception: + trades = [] + for t in trades or []: + if (t.get("side") or "").lower() != close_side: + continue + ts = t.get("timestamp") + if ts is None: + continue + try: + ts = int(ts) + except Exception: + continue + if since_ms and ts < since_ms: + continue + if closed_ms and ts > closed_ms: + continue + info = t.get("info") or {} + if not isinstance(info, dict): + info = {} + pos_side = (info.get("posSide") or t.get("posSide") or "").lower() + if GATE_POS_MODE == "hedge": + if pos_side in ("long", "short") and pos_side != direction: + continue + all_side_candidates.append(t) + if since_ms and ts < since_ms: + continue + if closed_ms and ts > closed_ms: + continue + candidates.append(t) + candidates.sort(key=lambda x: x.get("timestamp") or 0) + if candidates: + return candidates + + # 严格窗口为空时,降级为“按平仓时间就近匹配”,降低时区/时间误差导致的回填失败。 + all_side_candidates.sort(key=lambda x: x.get("timestamp") or 0) + if not all_side_candidates: + return [] + if not closed_ms: + return all_side_candidates[-20:] + near = [] + for t in all_side_candidates: + ts = t.get("timestamp") + if ts is None: + continue + try: + delta = abs(int(ts) - int(closed_ms)) + except Exception: + continue + # 放宽到前后 7 天 + if delta <= 7 * 24 * 60 * 60 * 1000: + near.append((delta, t)) + if near: + near.sort(key=lambda x: x[0]) + picked = [x[1] for x in near[:20]] + picked.sort(key=lambda x: x.get("timestamp") or 0) + return picked + return all_side_candidates[-20:] + + +def calc_weighted_exit_price(trades): + if not trades: + return None + total_amount = 0.0 + weighted_sum = 0.0 + for t in trades: + try: + price = float(t.get("price") or 0) + amount = float(t.get("amount") or 0) + except Exception: + continue + if price <= 0: + continue + if amount <= 0: + amount = 1.0 + weighted_sum += price * amount + total_amount += amount + if total_amount <= 0: + return None + return weighted_sum / total_amount + + +def resolve_synced_flat_close(row, opened_at_str, opened_at_ms=None): + """ + 交易所已无仓、本地仍为 active 时,推断平仓类型/时间/盈亏。 + 返回 (result, pnl_amount, closed_at_str, miss_reason)。 + """ + direction = row["direction"] + sym = row["symbol"] + trigger_price = row["trigger_price"] + stop_loss = row["stop_loss"] + take_profit = row["take_profit"] + margin_capital = row["margin_capital"] or DAILY_START_CAPITAL + leverage = row["leverage"] or infer_leverage(sym) + exchange_symbol = row["exchange_symbol"] or normalize_exchange_symbol(sym) + + trade = fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=opened_at_ms) + exit_px = None + closed_at_str = app_now_str() + if trade: + try: + exit_px = float(trade.get("price") or 0) or None + except (TypeError, ValueError): + exit_px = None + ts = trade.get("timestamp") + if ts: + closed_at_str = ms_to_app_local_str(int(ts)) + + if exit_px is None or exit_px <= 0: + p = get_price(sym) + if p: + guessed = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, p) + if guessed: + pnl = calc_pnl(direction, trigger_price, p, margin_capital, leverage) + return ( + guessed, + pnl, + closed_at_str, + "未能拉取成交明细,按当前市价与止盈/止损位近似归类(建议核对交易所账单)", + ) + return ( + "外部平仓", + 0.0, + closed_at_str, + "检测到交易所仓位已关闭,且无法从成交记录还原平仓价", + ) + + result = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_px) + pnl = calc_pnl(direction, trigger_price, exit_px, margin_capital, leverage) + if result: + return ( + result, + pnl, + closed_at_str, + "按交易所成交记录同步为止盈/止损平仓", + ) + return ( + "外部平仓", + pnl, + closed_at_str, + "交易所已平仓,成交价不在计划止盈/止损带内(可能为手动或其他类型平仓)", + ) + + +def reconcile_external_closes(conn, days=None): + synced_count = 0 + cutoff_ms = None + if days is not None: + try: + d = int(days) + if d > 0: + cutoff_ms = int((app_now() - timedelta(days=d)).timestamp() * 1000) + except Exception: + cutoff_ms = None + rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() + for r in rows: + if cutoff_ms is not None: + opened_at_v = get_opened_at_value(r) + opened_ms = _to_ms_with_fallback(r["opened_at_ms"] if "opened_at_ms" in r.keys() else None, opened_at_v) + # 手动同步按最近 N 天过滤,避免把更早历史单误同步进来 + if opened_ms is None or opened_ms < cutoff_ms: + continue + exchange_symbol = r["exchange_symbol"] or normalize_exchange_symbol(r["symbol"]) + live_contracts = get_live_position_contracts(exchange_symbol, r["direction"]) + if live_contracts is None: + continue + if live_contracts > 0: + continue + cancel_gate_swap_trigger_orders(exchange_symbol) + opened_at = get_opened_at_value(r) + opened_at_ms = _to_ms_with_fallback(r["opened_at_ms"] if "opened_at_ms" in r.keys() else None, opened_at) + result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close(r, opened_at, opened_at_ms=opened_at_ms) + closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now() + hold_seconds = calc_hold_seconds(opened_at, closed_at_dt) + session_date = r["session_date"] or get_trading_day(closed_at_dt) + update_session_capital(conn, session_date, pnl_amount) + insert_trade_record( + conn, + symbol=r["symbol"], + monitor_type=order_row_monitor_type(r), + key_signal_type=order_row_key_signal_type(r), + direction=r["direction"], + trigger_price=r["trigger_price"], + stop_loss=r["stop_loss"], + initial_stop_loss=r["initial_stop_loss"] or r["stop_loss"], + take_profit=r["take_profit"], + margin_capital=margin_capital_for_trade_record(r), + leverage=r["leverage"], + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=r["trade_style"], + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio(r["direction"], r["trigger_price"], r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]), + actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), + result=result, + miss_reason=miss_reason, + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (r["id"],)) + clear_key_sizing_snapshot_if_flat(conn, r["session_date"] or get_trading_day()) + if result in ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓"): + send_wechat_msg( + build_wechat_close_message( + symbol=r["symbol"], + direction=r["direction"], + result=f"{result}(自动同步)", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=r["trigger_price"], + current_price="-", + stop_loss=r["stop_loss"], + take_profit=r["take_profit"], + close_order_id="-", + extra_note=miss_reason, + ) + ) + else: + send_wechat_msg( + build_wechat_close_message( + symbol=r["symbol"], + direction=r["direction"], + result="外部平仓(自动同步)", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=r["trigger_price"], + current_price="-", + stop_loss=r["stop_loss"], + take_profit=r["take_profit"], + close_order_id="-", + extra_note=miss_reason, + ) + ) + synced_count += 1 + return synced_count + +# 获取实时价格 +def get_price(symbol): + try: + ensure_markets_loaded() + return exchange.fetch_ticker(normalize_exchange_symbol(symbol))["last"] + except: + return None + +# 获取5分钟K线收盘价 +def get_5m_close(symbol): + try: + ensure_markets_loaded() + ohlcv = exchange.fetch_ohlcv(normalize_exchange_symbol(symbol), KLINE_TIMEFRAME, limit=1) + return ohlcv[-1][4] if ohlcv else None + except: + return None + + +def _safe_float(v): + try: + return float(v) + except Exception: + return None + + +def _compute_ema(values, period=55): + arr = [float(x) for x in values if x is not None] + if len(arr) < period: + return None + k = 2.0 / (period + 1.0) + ema = arr[0] + for val in arr[1:]: + ema = val * k + ema * (1 - k) + return ema + + +def _status_by_ema55(symbol, timeframe): + try: + bars = exchange.fetch_ohlcv(normalize_exchange_symbol(symbol), timeframe=timeframe, limit=80) + if not bars or len(bars) < 56: + return "横盘", None, None + closes = [float(x[4]) for x in bars if x and len(x) >= 5] + ema55 = _compute_ema(closes, 55) + last_close = closes[-1] + if ema55 is None or last_close <= 0: + return "横盘", last_close, ema55 + diff_pct = (last_close - ema55) / ema55 * 100.0 + if abs(diff_pct) < 0.1: + return "横盘", last_close, ema55 + return ("多头" if diff_pct > 0 else "空头"), last_close, ema55 + except Exception: + return "横盘", None, None + + +def _daily_volume_rank(symbol): + """ + 返回(symbol_rank, total_count),按 quoteVolume 降序,缺失时 fallback 到 baseVolume*last。 + """ + sym_norm = normalize_symbol_input(symbol) + target_base = journal_coin_from_symbol(sym_norm) + + def _ticker_base(sym_text): + s = str(sym_text or "").upper().strip() + if ":" in s: + s = s.split(":", 1)[0] + if "/" in s: + return s.split("/", 1)[0].strip() + if "-" in s: + return s.split("-", 1)[0].strip() + if s.endswith("USDT"): + return s[:-4].strip() + return s + now_ts = time.time() + cached_ok = ( + LIQUIDITY_RANK_CACHE["updated_at"] + and now_ts - float(LIQUIDITY_RANK_CACHE["updated_at"]) < max(30, BALANCE_REFRESH_SECONDS) + ) + if not cached_ok: + try: + ensure_markets_loaded() + tickers = exchange.fetch_tickers() + scored = [] + for s, t in (tickers or {}).items(): + try: + mk = exchange.markets.get(s) + if not mk or not mk.get("swap"): + continue + su = str(s).upper() + if "USDT" not in su: + continue + qv = _safe_float((t or {}).get("quoteVolume")) + if qv is None: + info = (t or {}).get("info") if isinstance((t or {}).get("info"), dict) else {} + qv = _safe_float(info.get("volCcy24h") or info.get("vol24h")) + if qv is None: + bv = _safe_float((t or {}).get("baseVolume")) + lp = _safe_float((t or {}).get("last")) + if bv is not None and lp is not None: + qv = bv * lp + if qv is None or qv <= 0: + continue + scored.append((_ticker_base(s), float(qv))) + except Exception: + continue + scored.sort(key=lambda x: x[1], reverse=True) + ranks = {} + for idx, (base, _) in enumerate(scored, 1): + if base and base not in ranks: + ranks[base] = idx + LIQUIDITY_RANK_CACHE["ranks"] = ranks + LIQUIDITY_RANK_CACHE["total"] = len(scored) + LIQUIDITY_RANK_CACHE["updated_at"] = now_ts + except Exception: + pass + ranks = LIQUIDITY_RANK_CACHE.get("ranks") or {} + total = int(LIQUIDITY_RANK_CACHE.get("total") or 0) + return ranks.get(target_base), total + + +def _key_hard_checks(symbol, direction, upper, lower, monitor_type): + """ + 关键位门控:量能、突破幅度、第二根确认、日成交量前30。 + 使用最近闭合K:breakout=倒数第2根,confirm=倒数第1根。 + """ + out = {"ok": False} + ex_sym = normalize_exchange_symbol(symbol) + bars = exchange.fetch_ohlcv(ex_sym, timeframe=KLINE_TIMEFRAME, limit=80) or [] + if len(bars) < 24: + out["reason"] = "5m K线数量不足" + return out + closed = bars[:-1] if len(bars) >= 3 else bars + min_closed = KEY_VOLUME_MA_BARS + 3 + if len(closed) < min_closed: + out["reason"] = f"{KLINE_TIMEFRAME} 闭合K线不足" + return out + try: + breakout = closed[KEY_CONFIRM_BREAKOUT_BAR] + confirm = closed[KEY_CONFIRM_BAR] + except IndexError: + out["reason"] = "确认K索引超出范围,请检查 KEY_CONFIRM_* 配置" + return out + prev_vol = closed[KEY_CONFIRM_BREAKOUT_BAR - KEY_VOLUME_MA_BARS : KEY_CONFIRM_BREAKOUT_BAR] + avg20 = sum(float(x[5]) for x in prev_vol) / max(len(prev_vol), 1) + vol_break = float(breakout[5]) + vol_ok = vol_break > avg20 * KEY_VOLUME_RATIO_MIN if avg20 > 0 else False + open_b = float(breakout[1]) + close_b = float(breakout[4]) + high_b = float(breakout[2]) + low_b = float(breakout[3]) + amp_pct = abs(close_b - open_b) / open_b * 100 if open_b > 0 else 0 + amp_ok = (amp_pct > KEY_BREAKOUT_AMP_MIN_PCT) and (amp_pct < KEY_BREAKOUT_AMP_MAX_PCT) + cfm_close = float(confirm[4]) + # 区间极值点严格以前端录入 upper/lower 为准:做多看上沿,做空看下沿 + edge = float(upper) if direction == "long" else float(lower) + breakout_ok = (close_b > float(upper)) if direction == "long" else (close_b < float(lower)) + confirm_ok_raw = (cfm_close > edge) if direction == "long" else (cfm_close < edge) + # 口径收紧:未发生有效突破时,不标记幅度/二确通过,避免出现“还没到位却显示Y” + amp_ok = amp_ok and breakout_ok + confirm_ok = confirm_ok_raw and breakout_ok + rank, total = _daily_volume_rank(symbol) + rank_ok = (rank is not None) and (rank <= KEY_DAILY_VOLUME_RANK_MAX) + swing4h_pct = 0.0 + try: + seg48 = closed[-48:] if len(closed) >= 48 else closed + hh = max(float(x[2]) for x in seg48) + ll = min(float(x[3]) for x in seg48) + swing4h_pct = ((hh - ll) / ll * 100.0) if ll > 0 else 0.0 + except Exception: + swing4h_pct = 0.0 + out.update( + { + "ok": all([vol_ok, amp_ok, breakout_ok, confirm_ok, rank_ok]), + "vol_ok": vol_ok, + "avg20": avg20, + "vol_break": vol_break, + "amp_ok": amp_ok, + "amp_pct": amp_pct, + "breakout_ok": breakout_ok, + "breakout_close": close_b, + "confirm_ok": confirm_ok, + "confirm_close": cfm_close, + "edge_price": edge, + "rank": rank, + "rank_total": total, + "rank_ok": rank_ok, + "breakout_high": high_b, + "breakout_low": low_b, + "breakout_ts": breakout[0], + "confirm_ts": confirm[0], + "swing4h_pct": swing4h_pct, + "monitor_type": monitor_type, + "direction": direction, + } + ) + return out + + +def calc_price_diff_pct(current_price, target_price): + try: + if target_price is None: + return None, None + t = float(target_price) + if t == 0: + return None, None + c = float(current_price) + diff = c - t + pct = diff / t * 100 + return round(diff, 6), round(pct, 4) + except Exception: + return None, None + + +def _finalize_key_monitor_one_shot(conn, row, last_msg, close_reason): + """本条关键位一次性结案:写历史并从当前表删除。""" + n = int(row["notification_count"] or 0) + 1 + insert_key_monitor_history(conn, row, n, last_msg, close_reason) + conn.execute("DELETE FROM key_monitors WHERE id=?", (row["id"],)) + + +def _key_hard_lines_from_checks(checks): + return [ + f"量能:{'通过' if checks['vol_ok'] else '不通过'}(突破K量 {round(checks['vol_break'], 4)} / 前20均量 {round(checks['avg20'], 4)},阈值1.3x)", + f"突破价位:{'通过' if checks['breakout_ok'] else '不通过'}(突破K收盘 {round(float(checks['breakout_close']), 8)},关键位 {checks['edge_price']})", + f"突破K幅度:{'通过' if checks['amp_ok'] else '不通过'}({round(checks['amp_pct'], 4)}%,要求0.03%~0.5%)", + f"第二根确认:{'通过' if checks['confirm_ok'] else '不通过'}(确认收盘 {checks['confirm_close']},关键位 {checks['edge_price']})", + f"日成交量排名:{'通过' if checks['rank_ok'] else '不通过'}({checks['rank']}/{checks['rank_total']},要求前30)", + ] + + +def _key_plan_sl_tp_for_row(row, direction, upper, lower, checks): + """按 key_monitors 录入的方案计算计划 SL/TP。""" + mode = sl_tp_mode_from_row(row, "standard") + manual_tp = _sqlite_row_val(row, "manual_take_profit") + planned = plan_key_sl_tp( + mode, + direction, + upper, + lower, + checks, + outside_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT, + trend_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT, + manual_take_profit=manual_tp, + ) + return planned, mode + + +def _market_open_for_key_monitor( + conn, + symbol, + direction, + exchange_symbol, + stop_loss, + take_profit, + key_signal_type=None, + breakeven_enabled=0, +): + """ + 与手动「实盘下单」对齐的市价开仓与 order_monitors 写入。 + 返回 (ok: bool, err_msg: Optional[str], detail: Optional[dict]) + """ + now = app_now() + ok, reason = precheck_risk(conn, symbol, direction) + if not ok: + return False, f"风控拒绝下单:{reason}", None + ok_live, reason_live = ensure_exchange_live_ready() + if not ok_live: + return False, reason_live, None + + default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) + leverage = int(default_leverage) if default_leverage else 5 + if leverage <= 0: + leverage = 5 + + trading_day = get_trading_day(now) + opens_today_before = conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", + (trading_day,), + ).fetchone()[0] + session_row = ensure_session(conn, trading_day) + _, trading_capital_live = get_exchange_capitals(force=True) + live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"]) + capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital) + + trade_style = (DEFAULT_TRADE_STYLE or "trend").strip().lower() + if trade_style not in ("trend", "swing"): + trade_style = "trend" + + available_usdt = get_available_trading_usdt() + live_price = get_price(symbol) + if live_price is None: + return False, "获取交易所实时价格失败(以损定仓需要当前价)", None + try: + ensure_markets_loaded() + except Exception: + pass + lp_r = round_price_to_exchange(exchange_symbol, live_price) + if lp_r is not None: + live_price = lp_r + + sl_adj = round_price_to_exchange(exchange_symbol, float(stop_loss)) + tp_adj = round_price_to_exchange(exchange_symbol, float(take_profit)) + if sl_adj is not None: + stop_loss = float(sl_adj) + if tp_adj is not None: + take_profit = float(tp_adj) + + risk_fraction = calc_risk_fraction(direction, live_price, stop_loss) + if risk_fraction is None: + return False, "止损方向不合法(相对当前市价);请核对上下沿与方向", None + risk_percent = max(0.01, float(RISK_PERCENT)) + risk_amount = round(capital_base * risk_percent / 100.0, 4) + notional_value = round(risk_amount / risk_fraction, 4) + margin_capital = round(notional_value / leverage, 4) + + if capital_base and margin_capital > capital_base: + return False, "以损定仓后保证金超过当前交易资金", None + + if available_usdt is not None: + max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4) + if margin_capital > max_margin: + return ( + False, + f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", + None, + ) + + position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0 + + try: + amount, quote_price = prepare_order_amount(exchange_symbol, margin_capital, leverage, live_price) + contract_size = get_contract_size(exchange_symbol) + base_amount = round(float(amount) * contract_size, 8) + order_resp = place_exchange_order( + exchange_symbol, direction, amount, leverage, + stop_loss=stop_loss, take_profit=take_profit, + ) + open_order_id = order_resp.get("id", "") + tpsl_attached = bool(order_resp.get("tpsl_attached")) + trigger_price = resolve_order_entry_price(order_resp, exchange_symbol, quote_price) + except Exception as e: + return False, friendly_exchange_error(e, available_usdt=available_usdt), None + + trigger_price = round_price_to_exchange(exchange_symbol, trigger_price) + stop_loss = round_price_to_exchange(exchange_symbol, stop_loss) + take_profit = round_price_to_exchange(exchange_symbol, take_profit) + + opened_at_bj = app_now_str() + opened_at_ms = _to_ms_with_fallback(None, opened_at_bj) + + planned_rr = calc_rr_ratio(direction, trigger_price, stop_loss, take_profit) + breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER) + breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT) + breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0 + risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage) + if risk_amount_final is None: + risk_amount_final = risk_amount + else: + try: + risk_amount_final = round(float(risk_amount_final), 4) + except (TypeError, ValueError): + risk_amount_final = risk_amount + + if direction == "short": + breakeven_raw = float(trigger_price) * (1 - breakeven_offset_pct / 100.0) + else: + breakeven_raw = float(trigger_price) * (1 + breakeven_offset_pct / 100.0) + breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw) + be_enabled = 1 if int(breakeven_enabled or 0) != 0 else 0 + + conn.execute( + "INSERT INTO order_monitors " + "(symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, " + "margin_capital, leverage, trade_style, risk_percent, risk_amount, " + "breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, " + "notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type, key_signal_type) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + exchange_symbol, + direction, + trigger_price, + stop_loss, + stop_loss, + take_profit, + margin_capital, + leverage, + trade_style, + risk_percent, + risk_amount_final, + breakeven_rr_trigger, + breakeven_offset_pct, + breakeven_step_r, + 0, + breakeven_price, + be_enabled, + notional_value, + position_ratio, + base_amount, + amount, + open_order_id, + opened_at_bj, + opened_at_ms, + trading_day, + ORDER_MONITOR_TYPE_KEY_AUTO, + stored_key_signal_type(key_signal_type), + ), + ) + new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + try_persist_exchange_margin_for_order(conn, new_order_id, exchange_symbol, direction, order_leverage=leverage) + opens_today_after = conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", + (trading_day,), + ).fetchone()[0] + + return True, None, { + "new_order_id": new_order_id, + "open_order_id": open_order_id, + "trigger_price": trigger_price, + "planned_rr_fill": planned_rr, + "risk_amount_final": risk_amount_final, + "margin_capital": margin_capital, + "leverage": leverage, + "amount": amount, + "base_amount": base_amount, + "notional_value": notional_value, + "position_ratio": position_ratio, + "tpsl_attached": tpsl_attached, + "opens_today_before": opens_today_before, + "opens_today_after": opens_today_after, + "trading_day": trading_day, + "risk_percent": risk_percent, + "breakeven_rr_trigger": breakeven_rr_trigger, + "breakeven_price": breakeven_price, + "capital_base_at_open": capital_base, + } + + +def _sqlite_row_val(row, key, default=None): + try: + v = row[key] + return default if v is None else v + except (KeyError, IndexError, TypeError): + return default + + +def get_symbol_mark_price(symbol): + """斐波失效判定用标记价。""" + ex_sym = normalize_exchange_symbol(symbol) + try: + ensure_markets_loaded() + ticker = exchange.fetch_ticker(ex_sym) + m = _coerce_float(ticker.get("mark"), ticker.get("last")) + if m is None: + info = ticker.get("info") or {} + m = _coerce_float(info.get("mark_price"), info.get("last")) + if m is not None and m > 0: + return float(m) + except Exception: + pass + p = get_price(symbol) + return float(p) if p is not None else None + + +def cancel_fib_limit_order(exchange_symbol, order_id): + """仅撤销本条斐波限价单,不用 cancel_all。""" + if not order_id: + return False + ok_live, _ = ensure_exchange_live_ready() + if not ok_live: + return False + ensure_markets_loaded() + oid = str(order_id) + try: + exchange.cancel_order(oid, exchange_symbol) + return True + except Exception: + pass + try: + for o in exchange.fetch_open_orders(exchange_symbol) or []: + if str(o.get("id")) == oid: + exchange.cancel_order(oid, exchange_symbol) + return True + except Exception: + pass + return False + + +def fib_limit_order_status(exchange_symbol, order_id): + if not order_id: + return "missing" + ensure_markets_loaded() + oid = str(order_id) + try: + o = exchange.fetch_order(oid, exchange_symbol) + st = (o.get("status") or "").lower() + if st in ("closed", "filled"): + filled = float(o.get("filled") or 0) + if filled > 0 or st == "filled": + return "filled" + if st in ("canceled", "cancelled", "expired", "rejected"): + return "canceled" + if st in ("open", "new", "partially_filled"): + return "open" + except Exception: + pass + try: + for o in exchange.fetch_open_orders(exchange_symbol) or []: + if str(o.get("id")) == oid: + return "open" + except Exception: + pass + return "unknown" + + +def place_fib_limit_order(exchange_symbol, direction, amount, leverage, limit_price): + ensure_markets_loaded() + exchange.set_leverage(leverage, exchange_symbol) + side = "buy" if direction == "long" else "sell" + price = round_price_to_exchange(exchange_symbol, float(limit_price)) + if price is None or price <= 0: + raise ValueError("挂单价无效") + params = build_gate_order_params(direction, reduce_only=False) + return exchange.create_order(exchange_symbol, "limit", side, amount, price, params) + + +def _fib_key_exists_for_symbol(conn, symbol): + ph = ",".join("?" * len(FIB_KEY_MONITOR_TYPES)) + row = conn.execute( + f"SELECT id FROM key_monitors WHERE symbol=? AND monitor_type IN ({ph})", + (symbol, *tuple(FIB_KEY_MONITOR_TYPES)), + ).fetchone() + return row is not None + + +def _fib_plan_for_row(row): + typ = (row["monitor_type"] or "").strip() + ratio = fib_ratio_from_type(typ) + if ratio is None: + return None + return calc_fib_plan(row["direction"], row["upper"], row["lower"], ratio) + + +def _cancel_fib_monitor_limit(row): + ex_sym = normalize_exchange_symbol(row["symbol"]) + oid = _sqlite_row_val(row, "fib_limit_order_id") + if oid: + cancel_fib_limit_order(ex_sym, oid) + + +def _fib_has_live_position(exchange_symbol, direction): + live = get_live_position_contracts(exchange_symbol, direction) + return live is not None and float(live) > 0 + + +def _insert_order_monitor_from_fib_fill( + conn, row, trigger_price, stop_loss, take_profit, amount, leverage, margin_capital, + notional_value, position_ratio, base_amount, exchange_order_id, tpsl_attached, +): + symbol = row["symbol"] + direction = (row["direction"] or "long").lower() + exchange_symbol = normalize_exchange_symbol(symbol) + typ = (row["monitor_type"] or "").strip() + now = app_now() + trading_day = get_trading_day(now) + trade_style = (DEFAULT_TRADE_STYLE or "trend").strip().lower() + if trade_style not in ("trend", "swing"): + trade_style = "trend" + risk_percent = max(0.01, float(RISK_PERCENT)) + risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage) + if risk_amount_final is None: + risk_amount_final = round(float(margin_capital) * risk_percent / 100.0, 4) + breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER) + breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT) + breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0 + if direction == "short": + breakeven_raw = float(trigger_price) * (1 - breakeven_offset_pct / 100.0) + else: + breakeven_raw = float(trigger_price) * (1 + breakeven_offset_pct / 100.0) + breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw) + opened_at_bj = app_now_str() + opened_at_ms = _to_ms_with_fallback(None, opened_at_bj) + conn.execute( + "INSERT INTO order_monitors " + "(symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, " + "margin_capital, leverage, trade_style, risk_percent, risk_amount, " + "breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, " + "notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type, key_signal_type) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + exchange_symbol, + direction, + trigger_price, + stop_loss, + stop_loss, + take_profit, + margin_capital, + leverage, + trade_style, + risk_percent, + risk_amount_final, + breakeven_rr_trigger, + breakeven_offset_pct, + breakeven_step_r, + 0, + breakeven_price, + 1 if breakeven_enabled_from_row(row, 0) else 0, + notional_value, + position_ratio, + base_amount, + amount, + exchange_order_id or "", + opened_at_bj, + opened_at_ms, + trading_day, + ORDER_MONITOR_TYPE_KEY_AUTO, + stored_key_signal_type(typ), + ), + ) + new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + try_persist_exchange_margin_for_order(conn, new_order_id, exchange_symbol, direction, order_leverage=leverage) + return new_order_id + + +def _finalize_fib_key_fill(conn, row): + symbol = row["symbol"] + direction = (row["direction"] or "long").lower() + typ = (row["monitor_type"] or "").strip() + ex_sym = normalize_exchange_symbol(symbol) + plan = _fib_plan_for_row(row) + if not plan: + _finalize_key_monitor_one_shot(conn, row, "斐波计划无效", "fib_plan_invalid") + return + entry_plan, sl_plan, tp_plan = plan + sl = float(_sqlite_row_val(row, "fib_stop_loss", sl_plan) or sl_plan) + tp = float(_sqlite_row_val(row, "fib_take_profit", tp_plan) or tp_plan) + sl_adj = round_price_to_exchange(ex_sym, sl) + tp_adj = round_price_to_exchange(ex_sym, tp) + if sl_adj is not None: + sl = float(sl_adj) + if tp_adj is not None: + tp = float(tp_adj) + amount = float(_sqlite_row_val(row, "fib_order_amount") or 0) + leverage = int(_sqlite_row_val(row, "fib_leverage") or infer_leverage(symbol) or 5) + margin_capital = float(_sqlite_row_val(row, "fib_margin_capital") or 0) + oid = _sqlite_row_val(row, "fib_limit_order_id") + entry_px = float(_sqlite_row_val(row, "fib_entry_price", entry_plan) or entry_plan) + trigger_price = entry_px + if oid: + try: + o = exchange.fetch_order(str(oid), ex_sym) + trigger_price = resolve_order_entry_price(o, ex_sym, entry_px) + except Exception: + pass + tr_adj = round_price_to_exchange(ex_sym, trigger_price) + if tr_adj is not None: + trigger_price = float(tr_adj) + if amount <= 0: + live_amt = get_live_position_contracts(ex_sym, direction) + amount = float(live_amt or 0) + if amount <= 0: + send_wechat_msg( + f"# ❌ {symbol} 斐波成交后处理失败\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 无法取得持仓/下单数量,未挂 TP/SL\n" + ) + return + ok, reason = precheck_risk(conn, symbol, direction) + if not ok: + send_wechat_msg( + f"# ❌ {symbol} 斐波成交后风控拒绝\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}\n" + f"- 原因:{reason}\n" + f"- 请手动处理仓位与挂单\n" + ) + return + tpsl_attached = False + try: + _gate_place_tp_sl_orders(ex_sym, direction, amount, sl, tp) + tpsl_attached = True + except Exception as e: + send_wechat_msg( + f"# ❌ {symbol} 斐波成交后挂 TP/SL 失败\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 错误:{friendly_exchange_error(e)}\n" + f"- 请手动补挂止盈止损\n" + ) + return + contract_size = get_contract_size(ex_sym) + base_amount = round(float(amount) * contract_size, 8) + notional_value = round(float(margin_capital) * leverage, 4) if margin_capital else 0 + session_row = ensure_session(conn, get_trading_day(app_now())) + capital_base = float(session_row["current_capital"] or 0) + position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base and margin_capital else 0 + planned_rr = calc_rr_ratio(direction, trigger_price, sl, tp) + new_order_id = _insert_order_monitor_from_fib_fill( + conn, row, trigger_price, sl, tp, amount, leverage, margin_capital, + notional_value, position_ratio, base_amount, oid, tpsl_attached, + ) + rr_txt = format_wechat_scalar_2dp(planned_rr) if planned_rr is not None else "-" + succ = ( + f"# ✅ {symbol} 斐波限价成交\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 来源:{ORDER_MONITOR_TYPE_KEY_AUTO}(限价 @ E)\n" + f"- 类型:{typ}|{_wechat_direction_text(direction)}\n" + f"- 订单 ID:**{new_order_id}**\n" + f"- 成交价:{format_price_for_symbol(symbol, trigger_price)}\n" + f"- 止损:{format_wechat_scalar_2dp(sl)}|止盈:{format_price_for_symbol(symbol, tp)}\n" + f"- 计划 RR:{rr_txt}:1\n" + f"- {'已挂交易所 TP/SL' if tpsl_attached else 'TP/SL 未挂上'}\n" + ) + send_wechat_msg(succ) + _finalize_key_monitor_one_shot(conn, row, succ, "fib_filled") + + +def check_fib_key_monitors(): + conn = get_db() + rows = conn.execute("SELECT * FROM key_monitors").fetchall() + for r in rows: + typ = (r["monitor_type"] or "").strip() + if not is_fib_key_monitor_type(typ): + continue + symbol = r["symbol"] + direction = (r["direction"] or "long").lower() + ex_sym = normalize_exchange_symbol(symbol) + up, low = float(r["upper"]), float(r["lower"]) + oid = _sqlite_row_val(r, "fib_limit_order_id") + mark = get_symbol_mark_price(symbol) + if mark is None: + continue + status = fib_limit_order_status(ex_sym, oid) if oid else "missing" + if status == "filled" or (status != "open" and _fib_has_live_position(ex_sym, direction)): + _finalize_fib_key_fill(conn, r) + continue + if status == "open": + if fib_invalidate_by_mark(direction, mark, up, low): + _cancel_fib_monitor_limit(r) + msg = ( + f"# ⚠️ {symbol} 斐波监控失效\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}|{_wechat_direction_text(direction)}\n" + f"- 标记价 {format_price_for_symbol(symbol, mark)} 已触达止盈侧(未成交),已撤限价单\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, "fib_invalidate") + continue + if status in ("canceled", "missing", "unknown") and fib_invalidate_by_mark(direction, mark, up, low): + msg = ( + f"# ⚠️ {symbol} 斐波监控失效(限价已不在挂单)\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 标记价触达止盈侧,本条已结案\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, "fib_invalidate") + conn.commit() + conn.close() + + +def _add_fib_key_monitor(conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=0): + if _fib_key_exists_for_symbol(conn, symbol): + return False, f"{symbol} 已有斐波监控(同币仅允许一条 0.618/0.786)" + ratio = fib_ratio_from_type(mt) + plan = calc_fib_plan(direction_sel, upper_px, lower_px, ratio) + if not plan: + return False, "斐波上下沿无效(需上沿 H > 下沿 L)" + entry, sl, tp = plan + ex_sym = normalize_exchange_symbol(symbol) + entry = round_price_to_exchange(ex_sym, entry) + sl = round_price_to_exchange(ex_sym, sl) + tp = round_price_to_exchange(ex_sym, tp) + if entry is None or sl is None or tp is None: + return False, "斐波价位经交易所精度舍入后无效" + entry, sl, tp = float(entry), float(sl), float(tp) + planned_rr = calc_rr_ratio(direction_sel, entry, sl, tp) + if planned_rr is None or planned_rr <= KEY_AUTO_MIN_PLANNED_RR: + fmt_rr = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算" + return False, f"斐波计划盈亏比 {fmt_rr}:1 未达要求(>{KEY_AUTO_MIN_PLANNED_RR}:1)" + ok, reason = precheck_risk(conn, symbol, direction_sel) + if not ok: + return False, reason + ok_live, reason_live = ensure_exchange_live_ready() + if not ok_live: + return False, reason_live + now = app_now() + trading_day = get_trading_day(now) + session_row = ensure_session(conn, trading_day) + _, trading_capital_live = get_exchange_capitals(force=True) + live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"]) + capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital) + default_leverage = get_synced_leverage(ex_sym, direction_sel) or infer_leverage(symbol) + leverage = int(default_leverage) if default_leverage else 5 + if leverage <= 0: + leverage = 5 + available_usdt = get_available_trading_usdt() + risk_fraction = calc_risk_fraction(direction_sel, entry, sl) + if risk_fraction is None: + return False, "止损方向不合法(相对挂单价 E);请核对上下沿与方向" + risk_percent = max(0.01, float(RISK_PERCENT)) + risk_amount = round(capital_base * risk_percent / 100.0, 4) + notional_value = round(risk_amount / risk_fraction, 4) + margin_capital = round(notional_value / leverage, 4) + if capital_base and margin_capital > capital_base: + return False, "以损定仓后保证金超过当前交易资金" + if available_usdt is not None: + max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4) + if margin_capital > max_margin: + return ( + False, + f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", + ) + try: + amount, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry) + order_resp = place_fib_limit_order(ex_sym, direction_sel, amount, leverage, entry) + oid = str(order_resp.get("id") or "") + if not oid: + return False, "交易所未返回限价单 ID" + except Exception as e: + return False, friendly_exchange_error(e, available_usdt=available_usdt) + be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0 + conn.execute( + "INSERT INTO key_monitors " + "(symbol, monitor_type, direction, upper, lower, " + "fib_limit_order_id, fib_entry_price, fib_stop_loss, fib_take_profit, " + "fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, mt, direction_sel, upper_px, lower_px, + oid, entry, sl, tp, float(amount), margin_capital, leverage, be_flag, + ), + ) + return True, None + + +# 关键位监控(箱体/收敛可自动开仓;阻力/支撑位仅单次提醒结案) +def check_key_monitors(): + conn = get_db() + rows = conn.execute("SELECT * FROM key_monitors").fetchall() + for r in rows: + sym, typ_raw, up, low = r["symbol"], r["monitor_type"], r["upper"], r["lower"] + typ = (typ_raw or "").strip() + if is_fib_key_monitor_type(typ): + continue + direction = (r["direction"] or "long").lower() + try: + checks = _key_hard_checks(sym, direction, up, low, typ) + except Exception: + checks = {"ok": False} + if not checks.get("ok"): + continue + + btc8h_status, _, _ = _status_by_ema55("BTC/USDT", "8h") + coin4h_status, _, _ = _status_by_ema55(sym, "4h") + risk_tip = None + if (direction == "long" and coin4h_status == "空头") or (direction == "short" and coin4h_status == "多头"): + risk_tip = "当前信号与本币4h(EMA55)主趋势逆势,建议降低仓位并严格执行止损。" + + key_price = float(low) if direction == "long" else float(up) + hard_lines = _key_hard_lines_from_checks(checks) + trigger_time = ms_to_app_local_str(int(checks["confirm_ts"])) if checks.get("confirm_ts") else app_now_str() + + alert_only = typ in KEY_MONITOR_ALERT_ONLY_TYPES or ( + typ not in KEY_MONITOR_AUTO_TYPES and typ not in KEY_MONITOR_ALERT_ONLY_TYPES + ) + + if alert_only: + op_lines = [ + "- 本条为关键阻力/支撑或非标类型:**仅单次推送**,不进行自动开仓。", + "- 本条关键位将在推送后记入历史并从监控列表移除。", + ] + msg = build_wechat_key_monitor_message( + symbol=sym, + direction=direction, + monitor_type=typ, + trigger_time=trigger_time, + key_price=key_price, + confirm_close=checks["confirm_close"], + hard_lines=hard_lines, + btc8h_status=btc8h_status, + coin4h_status=coin4h_status, + swing4h_pct=checks.get("swing4h_pct") or 0.0, + op_lines=op_lines, + risk_tip=risk_tip, + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, "key_level_alert_only") + continue + + plan_tuple, sl_tp_mode = _key_plan_sl_tp_for_row(r, direction, up, low, checks) + if not plan_tuple: + fmt_rr = "无法计算(止损/止盈与确认价几何关系无效)" + rr_msg = ( + f"# ⚠️ {sym} 关键位自动单:计划无效\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}|方案:{sl_tp_mode_label(sl_tp_mode)}\n" + f"- 方向:**{_wechat_direction_text(direction)}**\n" + f"- 触发时间:`{trigger_time}`\n" + f"- 确认K收盘(E):`{format_price_for_symbol(sym, checks.get('confirm_close'))}`\n" + f"- **{fmt_rr}**(未开仓)\n" + "---\n" + "### 硬条件\n" + + "\n".join(f"- {x}" for x in hard_lines) + ) + if risk_tip: + rr_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}" + send_wechat_msg(rr_msg) + _finalize_key_monitor_one_shot(conn, r, rr_msg, "rr_insufficient") + continue + E, sl_raw, tp_raw, box_h = plan_tuple + exchange_symbol = normalize_exchange_symbol(sym) + try: + ensure_markets_loaded() + except Exception: + pass + sl_px = round_price_to_exchange(exchange_symbol, sl_raw) + tp_px = round_price_to_exchange(exchange_symbol, tp_raw) + if sl_px is not None: + sl_raw = float(sl_px) + if tp_px is not None: + tp_raw = float(tp_px) + + planned_rr = calc_rr_ratio(direction, E, sl_raw, tp_raw) + rr_ok = planned_rr is not None and planned_rr > KEY_AUTO_MIN_PLANNED_RR + + if not rr_ok: + fmt_rr = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算(止损/止盈与确认价几何关系无效)" + plan_line = sl_tp_plan_summary_text( + sl_tp_mode, direction, E, sl_raw, tp_raw, box_h, + outside_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT, + trend_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT, + ) + rr_msg = ( + f"# ⚠️ {sym} 关键位自动单:计划 RR 未达标\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}|{plan_line}\n" + f"- 方向:**{_wechat_direction_text(direction)}**\n" + f"- 触发时间:`{trigger_time}`\n" + f"- 确认K收盘(E):`{format_price_for_symbol(sym, E)}`\n" + f"- 箱体高 H:`{format_price_for_symbol(sym, box_h)}`\n" + f"- 计划止损:`{format_wechat_scalar_2dp(sl_raw)}`\n" + f"- 计划止盈:`{format_price_for_symbol(sym, tp_raw)}`\n" + f"- **计划 RR(按确认收盘 E):{fmt_rr} : 1**(要求 **>{KEY_AUTO_MIN_PLANNED_RR}:1**,未开仓)\n" + "---\n" + "### 硬条件\n" + + "\n".join(f"- {x}" for x in hard_lines) + ) + if risk_tip: + rr_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}" + send_wechat_msg(rr_msg) + _finalize_key_monitor_one_shot(conn, r, rr_msg, "rr_insufficient") + continue + + key_sig = typ if typ in KEY_MONITOR_AUTO_TYPES else None + be_on = breakeven_enabled_from_row(r, 0) + ok_trade, trade_err, det = _market_open_for_key_monitor( + conn, + sym, + direction, + exchange_symbol, + sl_raw, + tp_raw, + key_signal_type=key_sig, + breakeven_enabled=1 if be_on else 0, + ) + planned_rr_txt = ( + format_wechat_scalar_2dp(planned_rr) if planned_rr is not None else "-" + ) + if not ok_trade: + fail_msg = ( + f"# ❌ {sym} 关键位自动单失败\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}\n" + f"- 方向:**{_wechat_direction_text(direction)}**\n" + f"- 触发时间:`{trigger_time}`\n" + f"- 确认K收盘(E):`{format_price_for_symbol(sym, E)}`\n" + f"- 计划止损:`{format_wechat_scalar_2dp(sl_raw)}`\n" + f"- 计划止盈:`{format_price_for_symbol(sym, tp_raw)}`\n" + f"- **计划 RR(按 E):{planned_rr_txt} : 1**(已通过 RR 阈值)\n" + f"- **失败原因:{trade_err}**\n" + "---\n" + "### 硬条件\n" + + "\n".join(f"- {x}" for x in hard_lines) + ) + if risk_tip: + fail_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}" + send_wechat_msg(fail_msg) + _finalize_key_monitor_one_shot(conn, r, fail_msg, "exchange_failed") + continue + + tpsl_txt = ( + "已在交易所挂条件委托(止盈、止损触发单)" + if det.get("tpsl_attached") + else "⚠️ 条件委托挂接状态异常或未挂上" + ) + rr_fill = det.get("planned_rr_fill") + rr_fill_txt = format_wechat_scalar_2dp(rr_fill) if rr_fill is not None else "-" + + succ_msg_lines = [ + f"# ✅ {sym} 关键位自动开仓成功", + f"**账户:{_wechat_account_label()}**", + f"- **来源:**{ORDER_MONITOR_TYPE_KEY_AUTO}(市价)", + f"- 页面订单 ID:**{det['new_order_id']}**", + f"- 交易所订单 ID:`{det.get('open_order_id') or '-'}`", + f"- 类型:{typ}|方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'开' if be_on else '关'}", + f"- 方向:**{_wechat_direction_text(direction)}**", + f"- 触发时间:`{trigger_time}`", + f"- 确认K收盘(E):{format_price_for_symbol(sym, E)}(RR 阈值按此计价)", + f"- **计划 RR(E):{planned_rr_txt}:1**", + f"- 开仓成交价:**{format_price_for_symbol(sym, det['trigger_price'])}**", + f"- **成交价侧计划 RR:**{rr_fill_txt}:1", + f"- 止损:{format_wechat_scalar_2dp(sl_raw)}", + f"- 止盈:{format_price_for_symbol(sym, tp_raw)}", + f"- 风险:{det.get('risk_percent')}%≈{format_wechat_scalar_2dp(det.get('risk_amount_final'))}U|基数 {format_wechat_scalar_2dp(det.get('margin_capital'))}U|杠杆 {det.get('leverage')}x", + f"- 名义 {format_wechat_scalar_2dp(det.get('notional_value'))}U|张数 {format_wechat_scalar_2dp(det.get('amount'))}|折算标的 {det.get('base_amount')}", + f"- **{tpsl_txt}**", + f"- 保本触发:{det.get('breakeven_rr_trigger')}R→{format_price_for_symbol(sym, det.get('breakeven_price'))}", + f"- 当日开仓次数:**{det.get('opens_today_after')}** / {DAILY_OPEN_ALERT_THRESHOLD}(提醒阈值)", + ] + succ_msg_lines.extend(["---", "### 硬条件"] + [f"- {x}" for x in hard_lines]) + if risk_tip: + succ_msg_lines.extend(["---", "### 逆势风险提示", f"- {risk_tip}"]) + succ_msg = "\n".join(succ_msg_lines) + send_wechat_msg(succ_msg) + _finalize_key_monitor_one_shot(conn, r, succ_msg, "auto_opened") + + if det.get("opens_today_before", 0) < DAILY_OPEN_ALERT_THRESHOLD <= det.get("opens_today_after", 0): + advice = ai_short_advice( + f"用户在北京时间交易日 {det['trading_day']} 已累计开仓 {det['opens_today_after']} 次(阈值 {DAILY_OPEN_ALERT_THRESHOLD})。" + f"最新一笔来源为关键位自动单:{sym} {direction},杠杆{det['leverage']}x。" + f"用户自述“上头了”。请给克制提醒。" + ) + if advice: + send_wechat_msg(f"【AI提醒】今日开仓次数已达 {det['opens_today_after']}\n{advice[:800]}") + conn.commit() + conn.close() + +# 止盈止损监控(已修复:严格区分多空,无默认做多) +def check_order_monitors(): + conn = get_db() + rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() + for r in rows: + pid, sym, direction, trigger_price, stop_loss, take_profit = r["id"], r["symbol"], r["direction"], r["trigger_price"], r["stop_loss"], r["take_profit"] + margin_capital = r["margin_capital"] or DAILY_START_CAPITAL + leverage = r["leverage"] or infer_leverage(sym) + trade_basis_row = row_to_dict(r) + ex_sym = r["exchange_symbol"] or normalize_exchange_symbol(sym) + if _order_row_exchange_margin_usdt(r) is None and exchange_private_api_configured(): + pm = get_live_position_exchange_metrics(ex_sym, direction, order_leverage=leverage) + if pm and pm.get("initial_margin") is not None: + try: + mv = float(pm["initial_margin"]) + if mv > 0: + conn.execute( + "UPDATE order_monitors SET exchange_margin_usdt=? WHERE id=?", + (round(mv, 4), pid), + ) + trade_basis_row["exchange_margin_usdt"] = round(mv, 4) + except (TypeError, ValueError): + pass + session_date = r["session_date"] or get_trading_day() + p = get_price(sym) + if not p: continue + + # 到达设定 R 倍后,按阶梯持续上移止损(本地风控层) + risk_amount = float(r["risk_amount"] or 0) + breakeven_armed = int(r["breakeven_armed"] or 0) + trigger_rr = float(r["breakeven_rr_trigger"] or BREAKEVEN_RR_TRIGGER) + step_r = float(r["breakeven_step_r"] or BREAKEVEN_STEP_R or 1.0) + step_r = 1.0 if step_r <= 0 else step_r + breakeven_enabled = True + try: + if "breakeven_enabled" in r.keys(): + breakeven_enabled = int(r["breakeven_enabled"] or 0) != 0 + except Exception: + breakeven_enabled = True + if breakeven_enabled and risk_amount > 0 and trigger_rr > 0: + now_pnl = calc_pnl(direction, trigger_price, p, margin_capital, leverage) + now_rr = now_pnl / risk_amount + if now_rr >= trigger_rr: + steps = int((now_rr - trigger_rr) // step_r) + locked_r = max(0.0, steps * step_r) + notional = float(margin_capital or 0) * float(leverage or 0) + risk_frac = (risk_amount / notional) if notional > 0 else None + if risk_frac and risk_frac > 0: + new_sl = calc_breakeven_stop( + direction, + trigger_price, + risk_frac, + locked_r=locked_r, + offset_pct=float(r["breakeven_offset_pct"] or BREAKEVEN_OFFSET_PCT), + ) + if new_sl is not None: + should_move = (direction == "short" and new_sl < float(stop_loss)) or ( + direction == "long" and new_sl > float(stop_loss) + ) + if should_move: + was_armed = breakeven_armed + ex_sym = resolve_monitor_exchange_symbol(r) + new_sl = round_price_to_exchange(ex_sym, new_sl) + tp_ex = float(take_profit or 0) + ok_live, _live_reason = ensure_exchange_live_ready() + synced_ex = not ok_live + if ok_live and tp_ex > 0: + try: + replace_active_monitor_tpsl_on_exchange(r, new_sl, tp_ex) + synced_ex = True + _clear_breakeven_exchange_warn(pid) + except Exception as e: + print( + f"[breakeven] exchange tpsl replace failed order={pid} {sym}: {e}", + flush=True, + ) + _send_breakeven_exchange_warn_once( + pid, + f"⚠️ {sym} 移动保本止损未同步交易所:{friendly_exchange_error(e)}", + ) + elif ok_live: + print( + f"[breakeven] skip exchange order={pid} {sym}: invalid take_profit", + flush=True, + ) + if synced_ex: + conn.execute( + "UPDATE order_monitors SET stop_loss=?, breakeven_armed=1, breakeven_price=? WHERE id=?", + (new_sl, new_sl, pid), + ) + stop_loss = new_sl + breakeven_armed = 1 + if not was_armed: + arm_txt = "保本止盈" + be_msg = build_wechat_breakeven_message( + sym, + direction, + arm_txt, + now_rr, + locked_r, + new_sl, + ) + if ok_live: + be_msg += "\n- 交易所:已先撤后挂止盈止损" + send_wechat_msg(be_msg) + + res = None + # 做多 + if direction == "long": + if p >= take_profit: res = "止盈" + elif p <= stop_loss: res = "止损" + # 做空 + elif direction == "short": + if p <= take_profit: res = "止盈" + elif p >= stop_loss: res = "止损" + + if res: + now = app_now() + opened_at = get_opened_at_value(r) + opened_at_ms = (r["opened_at_ms"] if "opened_at_ms" in r.keys() else None) + closed_at = now.strftime("%Y-%m-%d %H:%M:%S") + hold_seconds = calc_hold_seconds(opened_at, now) + pnl_amount = calc_pnl(direction, trigger_price, p, margin_capital, leverage) + if res == "止损" and float(pnl_amount or 0) > 0: + res = "移动止盈" if breakeven_armed else "保本止盈" + else: + res = normalize_result_with_pnl(res, pnl_amount) + close_order_id = "" + try: + close_resp = close_exchange_order(r) + close_order_id = close_resp.get("id", "") + # 平仓入库优先使用交易所返回成交价;拿不到再回退拉成交明细。 + exit_p = extract_trade_price_from_order(close_resp) + if exit_p and exit_p > 0: + pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) + guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) + if guessed_res: + res = normalize_result_with_pnl(guessed_res, pnl_amount) + else: + res = normalize_result_with_pnl(res, pnl_amount) + else: + ex_sym = r["exchange_symbol"] or normalize_exchange_symbol(sym) + tr = fetch_latest_closing_fill( + ex_sym, + direction, + opened_at, + opened_at_ms=opened_at_ms, + ) + if tr and tr.get("price"): + try: + exit_p = float(tr["price"]) + pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) + guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) + if guessed_res: + if guessed_res == "止损" and float(pnl_amount or 0) > 0: + res = "移动止盈" if breakeven_armed else "保本止盈" + else: + res = normalize_result_with_pnl(guessed_res, pnl_amount) + else: + res = normalize_result_with_pnl(res, pnl_amount) + except (TypeError, ValueError): + pass + ts = tr.get("timestamp") + if ts: + closed_at = ms_to_app_local_str(int(ts)) + hold_seconds = calc_hold_seconds( + opened_at, parse_dt_for_trading_day(closed_at) or now + ) + except Exception as e: + if is_no_position_error(str(e)): + ex_sym = r["exchange_symbol"] or normalize_exchange_symbol(sym) + cancel_gate_swap_trigger_orders(ex_sym) + tr = fetch_latest_closing_fill( + ex_sym, + direction, + opened_at, + opened_at_ms=opened_at_ms, + ) + if tr and tr.get("price"): + try: + exit_p = float(tr["price"]) + pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) + # 交易所已返回真实成交价时,以真实成交结果为准,避免本地轮询竞态导致误判。 + guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) + if guessed_res: + if guessed_res == "止损" and float(pnl_amount or 0) > 0: + res = "移动止盈" if breakeven_armed else "保本止盈" + else: + res = normalize_result_with_pnl(guessed_res, pnl_amount) + else: + res = normalize_result_with_pnl(res, pnl_amount) + except (TypeError, ValueError): + pass + ts = tr.get("timestamp") + if ts: + closed_at = ms_to_app_local_str(int(ts)) + hold_seconds = calc_hold_seconds( + opened_at, parse_dt_for_trading_day(closed_at) or now + ) + insert_trade_record( + conn, + symbol=sym, + monitor_type=order_row_monitor_type(r), + key_signal_type=order_row_key_signal_type(r), + direction=direction, + trigger_price=trigger_price, + stop_loss=stop_loss, + initial_stop_loss=r["initial_stop_loss"] or stop_loss, + take_profit=take_profit, + margin_capital=margin_capital_for_trade_record(trade_basis_row), + leverage=leverage, + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=r["trade_style"], + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or stop_loss, take_profit), + actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), + result=res, + miss_reason="触发价已触达,仓位已由交易所止盈/止损或其他方式平掉(本地补记)", + opened_at=opened_at, + closed_at=closed_at, + ) + session_capital = update_session_capital(conn, session_date, pnl_amount) + send_wechat_msg( + build_wechat_close_message( + symbol=sym, + direction=direction, + result=f"{res}(交易所已先行平仓)", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=trigger_price, + current_price=p, + stop_loss=stop_loss, + take_profit=take_profit, + close_order_id="-", + extra_note="本地补记:仓位由交易所止盈/止损或其他方式先行平掉", + session_capital_fallback=session_capital, + ) + ) + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (pid,)) + conn.commit() + continue + conn.execute("UPDATE order_monitors SET status='error' WHERE id=?", (pid,)) + conn.commit() + send_wechat_msg( + build_wechat_monitor_error_message( + symbol=sym, + direction=direction, + scene=f"触发{res}后交易所平仓失败", + error_text=str(e), + ) + ) + continue + cancel_gate_swap_trigger_orders(r["exchange_symbol"] or normalize_exchange_symbol(sym)) + session_capital = update_session_capital(conn, session_date, pnl_amount) + send_wechat_msg( + build_wechat_close_message( + symbol=sym, + direction=direction, + result=res, + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=trigger_price, + current_price=p, + stop_loss=stop_loss, + take_profit=take_profit, + close_order_id=close_order_id or "-", + session_capital_fallback=session_capital, + ) + ) + insert_trade_record( + conn, + symbol=sym, + monitor_type=order_row_monitor_type(r), + key_signal_type=order_row_key_signal_type(r), + direction=direction, + trigger_price=trigger_price, + stop_loss=stop_loss, + initial_stop_loss=r["initial_stop_loss"] or stop_loss, + take_profit=take_profit, + margin_capital=margin_capital_for_trade_record(trade_basis_row), + leverage=leverage, + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=r["trade_style"], + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or stop_loss, take_profit), + actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), + result=res, + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, pid)) + clear_key_sizing_snapshot_if_flat(conn, get_trading_day()) + conn.commit() + conn.close() + + +def force_close_before_reset(): + if not FORCE_CLOSE_ENABLED: + return + now = app_now() + # 每天北京时间指定整点小时内执行一次性兜底清仓(默认 00:xx) + if now.hour != FORCE_CLOSE_BJ_HOUR: + return + conn = get_db() + rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() + for r in rows: + p = get_price(r["symbol"]) + if not p: + continue + direction = r["direction"] + trigger_price = r["trigger_price"] + margin_capital = r["margin_capital"] or DAILY_START_CAPITAL + leverage = r["leverage"] or infer_leverage(r["symbol"]) + session_date = r["session_date"] or get_trading_day(now) + opened_at = get_opened_at_value(r) + closed_at = now.strftime("%Y-%m-%d %H:%M:%S") + hold_seconds = calc_hold_seconds(opened_at, now) + pnl_amount = calc_pnl(direction, trigger_price, p, margin_capital, leverage) + try: + close_resp = close_exchange_order(r) + close_order_id = close_resp.get("id", "") + cancel_gate_swap_trigger_orders(r["exchange_symbol"] or normalize_exchange_symbol(r["symbol"])) + except Exception as e: + conn.execute("UPDATE order_monitors SET status='error' WHERE id=?", (r["id"],)) + conn.commit() + send_wechat_msg( + build_wechat_monitor_error_message( + symbol=r["symbol"], + direction=direction, + scene="强制清仓失败", + error_text=str(e), + ) + ) + continue + session_capital = update_session_capital(conn, session_date, pnl_amount) + insert_trade_record( + conn, + symbol=r["symbol"], + monitor_type=order_row_monitor_type(r), + key_signal_type=order_row_key_signal_type(r), + direction=direction, + trigger_price=trigger_price, + stop_loss=r["stop_loss"], + initial_stop_loss=r["initial_stop_loss"] or r["stop_loss"], + take_profit=r["take_profit"], + margin_capital=margin_capital_for_trade_record(r), + leverage=leverage, + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=r["trade_style"], + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]), + actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), + result="强制清仓", + miss_reason=f"北京时间 {FORCE_CLOSE_BJ_HOUR}:00 整点风控清仓", + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, r["id"])) + send_wechat_msg( + build_wechat_close_message( + symbol=r["symbol"], + direction=direction, + result="强制清仓", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=trigger_price, + current_price=p, + stop_loss=r["stop_loss"], + take_profit=r["take_profit"], + close_order_id=close_order_id or "-", + extra_note=f"北京时间 {FORCE_CLOSE_BJ_HOUR}:00 整点风控清仓", + session_capital_fallback=session_capital, + ) + ) + conn.commit() + conn.close() + +# 后台线程 +def background_task(): + while True: + if not license_lib.is_licensed(): + time.sleep(MONITOR_POLL_SECONDS) + continue + try: + auto_transfer_once_per_day() + conn = get_db() + reconcile_external_closes(conn) + conn.commit() + conn.close() + force_close_before_reset() + check_fib_key_monitors() + check_key_monitors() + check_order_monitors() + except: + pass + time.sleep(MONITOR_POLL_SECONDS) + + +# ====================== 登录路由 ====================== +@app.route("/login", methods=["GET", "POST"]) +def login(): + if AUTH_DISABLED: + session["logged_in"] = True + return redirect("/") + if request.method == "POST": + username = request.form.get("username") + password = request.form.get("password") + if username == USERNAME and password == PASSWORD: + session["logged_in"] = True + return redirect("/") + else: + flash("账号或密码错误") + return render_template("login.html", exchange_display=EXCHANGE_DISPLAY_NAME) + +@app.route("/logout") +def logout(): + session.clear() + return redirect("/" if AUTH_DISABLED else "/login") + +# 登录校验装饰器 +def login_required(f): + @wraps(f) + def decorated(*args, **kwargs): + if AUTH_DISABLED: + return f(*args, **kwargs) + if not session.get("logged_in"): + return redirect("/login") + return f(*args, **kwargs) + return decorated + + +@app.route("/sync_positions") +@login_required +def sync_positions(): + days_raw = (request.args.get("days") or "").strip() + sync_days = None + if days_raw: + try: + sync_days = max(1, min(365, int(days_raw))) + except Exception: + sync_days = None + conn = get_db() + synced = reconcile_external_closes(conn, days=sync_days) + conn.commit() + conn.close() + if sync_days is not None: + flash(f"同步完成:最近 {sync_days} 天内 {synced} 笔持仓已按交易所状态更新") + else: + flash(f"同步完成:{synced} 笔持仓已按交易所状态更新") + return redirect("/") + + +@app.route("/api/sync_positions", methods=["POST"]) +@login_required +def api_sync_positions(): + payload = request.get_json(silent=True) or {} + days_raw = str(payload.get("days", "")).strip() + if not days_raw: + return jsonify({"ok": False, "msg": "请填写天数"}), 400 + try: + days = int(days_raw) + except Exception: + return jsonify({"ok": False, "msg": "天数必须是整数"}), 400 + if days < 1 or days > 365: + return jsonify({"ok": False, "msg": "天数范围 1-365"}), 400 + conn = get_db() + synced = reconcile_external_closes(conn, days=days) + conn.commit() + conn.close() + return jsonify({"ok": True, "days": days, "synced": int(synced)}) + + +def _coerce_ts_ms(val): + if val is None or val == "": + return None + try: + v = float(val) + except (TypeError, ValueError): + return None + if v > 1e12: + return int(v) + if v > 1e9: + return int(v * 1000.0) + return int(v * 1000.0) + + +def _unified_symbol_for_match(symbol_str): + """统一 ETH/USDT:USDT、ETH_USDT、ETH/USDT 便于与 trade_records 比对。""" + s = (symbol_str or "").strip().upper() + if not s: + return "" + if ":" in s: + s = s.split(":")[0] + if "_" in s and "/" not in s: + s = s.replace("_", "/") + if s.endswith("USDT") and "/" not in s and len(s) > 4: + s = f"{s[:-4]}/USDT" + return s + + +def exchange_position_sync_since_ms(): + s = EXCHANGE_POSITION_SYNC_FROM_BJ + if s: + for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d", 10)): + try: + chunk = s[:ln] if len(s) >= ln else s[:10] + dt = datetime.strptime(chunk, fmt) + aware = dt.replace(tzinfo=APP_TZ) + return int(aware.timestamp() * 1000) + except Exception: + continue + dt0 = app_now() - timedelta(days=90) + try: + aware0 = datetime(dt0.year, dt0.month, dt0.day, 0, 0, 0, tzinfo=APP_TZ) + except Exception: + aware0 = datetime.now(APP_TZ) + return int(aware0.timestamp() * 1000) + + +def _normalize_gate_position_history_entry(p): + if not p or not isinstance(p, dict): + return None + info = p.get("info") or {} + sym = p.get("symbol") or "" + if not sym: + c_alt = str(info.get("contract") or "").strip() + if c_alt: + sym = c_alt.replace("_", "/") + side = (p.get("side") or info.get("side") or "").strip().lower() + if side not in ("long", "short"): + sz = info.get("accum_size") if info.get("accum_size") is not None else info.get("size") + try: + szf = float(sz) + if szf > 0: + side = "long" + elif szf < 0: + side = "short" + except (TypeError, ValueError): + side = "" + rp = p.get("realizedPnl") + if rp is None: + rp = info.get("pnl") + try: + rp_f = float(rp) if rp is not None and str(rp).strip() != "" else None + except (TypeError, ValueError): + rp_f = None + close_ms = _coerce_ts_ms(p.get("lastUpdateTimestamp")) + if close_ms is None: + close_ms = _coerce_ts_ms(info.get("time")) + open_ms = _coerce_ts_ms(p.get("timestamp")) + if open_ms is None: + open_ms = _coerce_ts_ms(info.get("first_open_time")) + c_raw = str(info.get("contract") or "").strip() + t_raw = info.get("time") + sync_key = f"{c_raw}|{t_raw}|{side}" + return { + "symbol_u": _unified_symbol_for_match(sym), + "side": side, + "close_ms": close_ms, + "open_ms": open_ms, + "pnl": rp_f, + "sync_key": sync_key, + } + + +def fetch_gate_positions_close_history(): + if not exchange_private_api_configured(): + return [] + ensure_markets_loaded() + since_ms = exchange_position_sync_since_ms() + until_ms = int(time.time() * 1000) + out = [] + offset = 0 + page_limit = min(100, int(EXCHANGE_POSITION_HISTORY_LIMIT)) + max_total = int(EXCHANGE_POSITION_HISTORY_LIMIT) + + def _pull(params_extra): + nonlocal offset + offset = 0 + while len(out) < max_total: + params = dict(params_extra) + params["offset"] = offset + params["until"] = until_ms + try: + rows = exchange.fetch_positions_history( + None, + since=int(since_ms), + limit=page_limit, + params=params, + ) + except Exception: + return False + if not rows: + break + for p in rows: + h = _normalize_gate_position_history_entry(p) + if h and h["close_ms"] and h["side"] in ("long", "short") and h["symbol_u"]: + out.append(h) + offset += len(rows) + if len(rows) < page_limit: + break + return True + + if not _pull({"settle": "usdt"}): + _pull({}) + return out[:max_total] + + +def sync_trade_records_from_exchange(conn, force=False): + """为未同步的 trade_records 回填 Gate 平仓历史中的已实现盈亏。返回统计 dict。""" + global _LAST_EXCHANGE_PNL_SYNC_AT + stats = {"ok": False, "hist_count": 0, "matched": 0, "pending": 0, "skipped": False} + if not exchange_private_api_configured(): + stats["reason"] = "未配置 GATE_API_KEY / GATE_API_SECRET" + return stats + now = time.time() + if not force and now - _LAST_EXCHANGE_PNL_SYNC_AT < 25.0: + stats["ok"] = True + stats["skipped"] = True + return stats + try: + hist = fetch_gate_positions_close_history() + except Exception as e: + stats["reason"] = str(e) + return stats + stats["hist_count"] = len(hist) + if not hist: + stats["ok"] = True + stats["reason"] = "交易所平仓历史为空(请检查 API 权限或 EXCHANGE_POSITION_SYNC_FROM_BJ)" + return stats + candidates = conn.execute( + """ + SELECT id, symbol, direction, closed_at, closed_at_ms, opened_at, opened_at_ms + FROM trade_records + WHERE (exchange_sync_key IS NULL OR TRIM(exchange_sync_key) = '') + OR exchange_realized_pnl IS NULL + ORDER BY id DESC + LIMIT 200 + """ + ).fetchall() + stats["pending"] = len(candidates) + if not candidates: + stats["ok"] = True + _LAST_EXCHANGE_PNL_SYNC_AT = now + return stats + used = set() + matched = 0 + for tr in candidates: + close_ms_trade = _to_ms_with_fallback( + tr["closed_at_ms"] if "closed_at_ms" in tr.keys() else None, tr["closed_at"] + ) or opened_at_str_to_ms(tr["closed_at"]) + open_ms_trade = _to_ms_with_fallback( + tr["opened_at_ms"] if "opened_at_ms" in tr.keys() else None, tr["opened_at"] + ) or opened_at_str_to_ms(tr["opened_at"]) + if close_ms_trade is None: + continue + best = None + best_d = None + for h in hist: + sk = h["sync_key"] + if not sk or sk in used: + continue + if h["symbol_u"] != _unified_symbol_for_match(tr["symbol"]): + continue + if h["side"] != (tr["direction"] or "long").strip().lower(): + continue + cm = h["close_ms"] + if cm is None: + continue + if open_ms_trade is not None: + if cm < open_ms_trade - 15 * 60 * 1000: + continue + if cm > open_ms_trade + 15 * 86400 * 1000: + continue + else: + if abs(cm - close_ms_trade) > 3 * 86400 * 1000: + continue + d = abs(cm - close_ms_trade) + if best_d is None or d < best_d: + best_d = d + best = h + if best is None or best_d is None or best_d > 90 * 60 * 1000: + continue + sk = best["sync_key"] + if sk in used: + continue + eo = ms_to_app_local_str(best["open_ms"]) if best.get("open_ms") else None + ec = ms_to_app_local_str(best["close_ms"]) if best.get("close_ms") else None + pnl_val = best.get("pnl") + if pnl_val is None: + pnl_val = 0.0 + conn.execute( + """ + UPDATE trade_records + SET exchange_realized_pnl = ?, exchange_opened_at = ?, exchange_closed_at = ?, exchange_sync_key = ? + WHERE id = ? + """, + (float(pnl_val), eo, ec, sk, int(tr["id"])), + ) + used.add(sk) + matched += 1 + stats["matched"] = matched + stats["ok"] = True + _LAST_EXCHANGE_PNL_SYNC_AT = now + try: + conn.commit() + except Exception: + pass + return stats + + +# ====================== 主页面 ====================== +def render_main_page(page="trade"): + now = app_now() + trading_day = get_trading_day(now) + list_window = _list_window_from_request() + start_bj, end_bj = utc_window_to_bj_sql_strings(list_window["start_utc"], list_window["end_utc"], APP_TZ) + conn = get_db() + session_row = ensure_session(conn, trading_day) + local_current_capital = float(session_row["current_capital"]) + funding_capital, trading_capital = get_exchange_capitals() + # 资金账户:仅展示交易所读取结果(含 0)。不可用 TOTAL_CAPITAL 兜底,否则会与实盘不符。 + funding_usdt = round(funding_capital, 2) if funding_capital is not None else None + current_capital = round(trading_capital, 2) if trading_capital is not None else round(local_current_capital, 2) + recommended_capital = round(float(get_recommended_capital(current_capital)), 2) + key_list = conn.execute("SELECT * FROM key_monitors").fetchall() + key_history = conn.execute( + "SELECT * FROM key_monitor_history WHERE closed_at >= ? AND closed_at <= ? ORDER BY id DESC LIMIT 500", + (start_bj, end_bj), + ).fetchall() + stats_bundle = compute_stats_bundle(conn, trading_day, now) + raw_order_list = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() + order_list = [] + for o in raw_order_list: + order_list.append(enrich_order_item(row_to_dict(o), current_capital)) + exchange_pnl_sync = {} + if exchange_private_api_configured(): + try: + exchange_pnl_sync = sync_trade_records_from_exchange(conn) or {} + except Exception as e: + exchange_pnl_sync = {"ok": False, "reason": str(e)} + raw_records = conn.execute( + "SELECT * FROM trade_records WHERE COALESCE(closed_at, created_at, opened_at) >= ? " + "AND COALESCE(closed_at, created_at, opened_at) <= ? ORDER BY id DESC LIMIT 1000", + (start_bj, end_bj), + ).fetchall() + records = [to_effective_trade_dict(r) for r in raw_records] + total = len(records) + miss_count = sum(1 for r in records if (r.get("effective_result") or "") == "错过") + win = sum(1 for r in records if (r.get("effective_result") or "") in ("止盈", "保本止盈", "移动止盈")) + occupied_miss_total = sum( + 1 + for r in records + if (r.get("effective_result") or "") == "错过" + and ("持仓占用" in str(r.get("effective_miss_reason") or "")) + ) + rate = round(win/total*100,2) if total else 0 + active_count = len(order_list) + can_trade = trading_day_reset_allows_new_open(now) and active_count < MAX_ACTIVE_POSITIONS + key_gate_rule_text = ( + f"周期 {KLINE_TIMEFRAME}|确认K:突破棒偏移 {KEY_CONFIRM_BREAKOUT_BAR}、确认棒偏移 {KEY_CONFIRM_BAR}|" + f"量能:突破量 > 前{KEY_VOLUME_MA_BARS}均量×{KEY_VOLUME_RATIO_MIN}|" + f"自动开仓盈亏比 > {KEY_AUTO_MIN_PLANNED_RR}:1|日成交量排名前 {KEY_DAILY_VOLUME_RANK_MAX}|" + f"箱体/收敛可选 SL/TP 方案(标准 / 箱体1R·止盈1.5H / 趋势单+自填止盈)|移动保本默认关|" + f"斐波:限价 @ E(SL/TP 为 H/L),可选移动保本|趋势止损外侧 {KEY_TREND_STOP_OUTSIDE_PCT}%" + ) + conn.close() + return render_template( + "index.html", + page=page, + key=key_list, + key_history=key_history, + stats_bundle=stats_bundle, + order=order_list, + record=records, + total=total, + miss_count=miss_count, + rate=rate, + trading_day=trading_day, + funding_usdt=funding_usdt, + daily_start_capital=DAILY_START_CAPITAL, + current_capital=current_capital, + recommended_capital=recommended_capital, + btc_leverage=BTC_LEVERAGE, + alt_leverage=ALT_LEVERAGE, + reset_hour=TRADING_DAY_RESET_HOUR, + balance_refresh_seconds=BALANCE_REFRESH_SECONDS, + auto_transfer_enabled=AUTO_TRANSFER_ENABLED, + auto_transfer_amount=AUTO_TRANSFER_AMOUNT, + auto_transfer_from=AUTO_TRANSFER_FROM, + auto_transfer_to=AUTO_TRANSFER_TO, + auto_transfer_bj_hour=AUTO_TRANSFER_BJ_HOUR, + full_margin_buffer_ratio=FULL_MARGIN_BUFFER_RATIO, + price_refresh_seconds=PRICE_REFRESH_SECONDS, + active_count=active_count, + can_trade=can_trade, + focus_key_id=(key_list[0]["id"] if key_list else None), + focus_order_id=(order_list[0]["id"] if order_list else None), + data_export_version=3, + list_window=list_window, + list_window_presets={ + "utc_today": PRESET_UTC_TODAY, + "utc_last24h": PRESET_UTC_LAST24H, + "utc_last7d": PRESET_UTC_LAST7D, + "custom": PRESET_CUSTOM, + }, + key_alert_max_times=KEY_ALERT_MAX_TIMES, + risk_percent=RISK_PERCENT, + breakeven_rr_trigger=BREAKEVEN_RR_TRIGGER, + breakeven_offset_pct=BREAKEVEN_OFFSET_PCT, + occupied_miss_total=occupied_miss_total, + price_fmt=format_price_for_symbol, + usdt_fmt=format_usdt, + signed_usdt_fmt=format_signed_usdt, + entry_reason_options=list(ENTRY_REASON_OPTIONS), + entry_reason_other_value=ENTRY_REASON_OTHER, + exchange_display=EXCHANGE_DISPLAY_NAME, + max_active_positions=MAX_ACTIVE_POSITIONS, + manual_min_planned_rr=MANUAL_MIN_PLANNED_RR, + key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR, + key_gate_rule_text=key_gate_rule_text, + kline_timeframe=KLINE_TIMEFRAME, + exchange_pnl_sync=exchange_pnl_sync, + ) + + +@app.route("/api/sync_exchange_pnl") +@login_required +def api_sync_exchange_pnl(): + conn = get_db() + stats = sync_trade_records_from_exchange(conn, force=True) + try: + conn.commit() + except Exception: + pass + conn.close() + return jsonify(stats) + + +@app.route("/") +@login_required +def index(): + return redirect("/trade") + + +@app.route("/key_monitor") +@login_required +def key_monitor_page(): + return render_main_page("key_monitor") + + +@app.route("/trade") +@login_required +def trade_page(): + return render_main_page("trade") + + +@app.route("/records") +@login_required +def records_page(): + return render_main_page("records") + + +@app.route("/stats") +@login_required +def stats_page(): + return render_main_page("stats") + + +@app.route("/api/account_snapshot") +@login_required +def api_account_snapshot(): + now = app_now() + trading_day = get_trading_day(now) + conn = get_db() + session_row = ensure_session(conn, trading_day) + local_current_capital = float(session_row["current_capital"]) + funding_capital, trading_capital = get_exchange_capitals(force=True) + funding_usdt = round(funding_capital, 2) if funding_capital is not None else None + current_capital = round(trading_capital, 2) if trading_capital is not None else round(local_current_capital, 2) + recommended_capital = round(float(get_recommended_capital(current_capital)), 2) + active_count = get_active_position_count(conn) + conn.close() + can_trade = trading_day_reset_allows_new_open(now) and active_count < MAX_ACTIVE_POSITIONS + available_trading_usdt = get_available_trading_usdt() + return jsonify({ + "funding_usdt": funding_usdt, + "current_capital": current_capital, + "available_trading_usdt": round(available_trading_usdt, 2) if available_trading_usdt is not None else None, + "recommended_capital": recommended_capital, + "active_count": active_count, + "max_active_positions": MAX_ACTIVE_POSITIONS, + "can_trade": can_trade, + "manual_min_planned_rr": MANUAL_MIN_PLANNED_RR, + "trading_day": trading_day + }) + + +@app.route("/api/price_snapshot") +@login_required +def api_price_snapshot(): + conn = get_db() + key_rows = conn.execute( + "SELECT id,symbol,monitor_type,direction,upper,lower,fib_entry_price,fib_limit_order_id FROM key_monitors" + ).fetchall() + order_rows = conn.execute( + "SELECT id,symbol,exchange_symbol,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage FROM order_monitors WHERE status='active'" + ).fetchall() + conn.close() + + try: + ensure_markets_loaded() + except Exception: + pass + + symbol_set = set() + for r in key_rows: + symbol_set.add(r["symbol"]) + for r in order_rows: + symbol_set.add(r["symbol"]) + + prices = {} + for s in symbol_set: + p = get_price(s) + if p is not None: + prices[s] = float(p) + + all_swap_positions = [] + if exchange_private_api_configured(): + try: + ensure_markets_loaded() + # 显式 USDT 本位;不传 symbols 拉全量,再在本地按合约对齐 + all_swap_positions = exchange.fetch_positions(None, {"settle": "usdt"}) or [] + except Exception: + try: + all_swap_positions = exchange.fetch_positions() or [] + except Exception: + all_swap_positions = [] + + key_prices = [] + for r in key_rows: + is_fib = is_fib_key_monitor_type(r["monitor_type"]) + if is_fib: + price = get_symbol_mark_price(r["symbol"]) + else: + price = prices.get(r["symbol"]) + if price is None: + continue + upper_diff, upper_pct = calc_price_diff_pct(price, r["upper"]) + lower_diff, lower_pct = calc_price_diff_pct(price, r["lower"]) + gate = None + gate_summary = "-" + gate_metrics = "" + fib_gate_ok = True + if is_fib: + direction = (r["direction"] or "long").lower() + inval = fib_invalidate_by_mark(direction, price, r["upper"], r["lower"]) + fib_gate_ok = not inval + entry = _sqlite_row_val(r, "fib_entry_price") + entry_txt = format_price_for_symbol(r["symbol"], entry) if entry else "-" + gate_summary = f"斐波 挂E={entry_txt} {'标记价将失效' if inval else '等待成交'}" + if _sqlite_row_val(r, "fib_limit_order_id"): + gate_metrics = f"限价单:{_sqlite_row_val(r, 'fib_limit_order_id')}" + else: + try: + gate = _key_hard_checks(r["symbol"], (r["direction"] or "long").lower(), r["upper"], r["lower"], r["monitor_type"]) + except Exception: + gate = None + if gate: + rank_seg = "ERR" if int(gate.get("rank_total") or 0) <= 0 else f"{gate.get('rank')}/{gate.get('rank_total')}" + gate_summary = ( + f"量:{'Y' if gate.get('vol_ok') else 'N'} " + f"破:{'Y' if gate.get('breakout_ok') else 'N'} " + f"幅:{'Y' if gate.get('amp_ok') else 'N'} " + f"二确:{'Y' if gate.get('confirm_ok') else 'N'} " + f"排:{'Y' if gate.get('rank_ok') else 'N'}({rank_seg})" + ) + if gate.get("breakout_ok"): + try: + vol_now = round(float(gate.get("vol_break") or 0), 4) + vol_avg = round(float(gate.get("avg20") or 0), 4) + amp_pct = round(float(gate.get("amp_pct") or 0), 4) + cfm_close = round(float(gate.get("confirm_close") or 0), 8) + edge = round(float(gate.get("edge_price") or 0), 8) + gate_metrics = ( + f"量值:{vol_now}/{vol_avg} " + f"幅值:{amp_pct}% " + f"二确值:{cfm_close}@{edge}" + ) + except Exception: + gate_metrics = "" + px_disp = format_price_for_symbol(r["symbol"], price) + try: + price_num = float(px_disp) if px_disp != "-" else float(price) + except Exception: + price_num = float(price) + key_prices.append({ + "id": r["id"], + "symbol": r["symbol"], + "price": price_num, + "price_display": px_disp, + "upper_diff": upper_diff, + "upper_pct": upper_pct, + "lower_diff": lower_diff, + "lower_pct": lower_pct, + "gate_summary": gate_summary, + "gate_ok": fib_gate_ok if is_fib else bool(gate and gate.get("ok")), + "gate_metrics": gate_metrics, + }) + + order_prices = [] + for r in order_rows: + price = prices.get(r["symbol"]) + if price is None: + continue + margin = float(r["margin_capital"] or 0) + leverage = float(r["leverage"] or 0) + entry = float(r["trigger_price"] or 0) + pnl = calc_pnl(r["direction"], entry, price, margin, leverage) if entry > 0 else 0 + pnl_pct = round((pnl / margin * 100), 4) if margin > 0 else 0 + rr_ratio = calc_rr_ratio(r["direction"], entry, r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]) + ex_sym = resolve_monitor_exchange_symbol(r) + prow = _select_live_position_row(all_swap_positions, ex_sym, r["direction"]) + lev_row = r["leverage"] if "leverage" in r.keys() else None + ex_metrics = parse_ccxt_position_metrics(prow, order_leverage=lev_row) if prow else None + payload = { + "id": r["id"], + "symbol": r["symbol"], + "float_pnl": round(pnl, 2), + "float_pct": pnl_pct, + "rr_ratio": rr_ratio, + "plan_margin": round(margin, 2) if margin else None, + "exchange_initial_margin": None, + "exchange_notional": None, + "exchange_mark_price": None, + "pnl_source": "plan", + } + if ex_metrics: + if ex_metrics.get("initial_margin") is not None: + payload["exchange_initial_margin"] = ex_metrics["initial_margin"] + if ex_metrics.get("notional") is not None: + payload["exchange_notional"] = ex_metrics["notional"] + if ex_metrics.get("mark_price") is not None: + payload["exchange_mark_price"] = ex_metrics["mark_price"] + if ex_metrics.get("unrealized_pnl") is not None: + payload["float_pnl"] = round(float(ex_metrics["unrealized_pnl"]), 2) + payload["pnl_source"] = "exchange" + denom = ex_metrics.get("initial_margin") or margin + payload["float_pct"] = ( + round((payload["float_pnl"] / float(denom)) * 100, 4) if denom and float(denom) > 0 else pnl_pct + ) + px_for_fmt = float(price) + if ex_metrics and ex_metrics.get("mark_price") is not None: + try: + px_for_fmt = float(ex_metrics["mark_price"]) + except (TypeError, ValueError): + pass + px_disp = format_price_for_symbol(r["symbol"], px_for_fmt) + try: + payload["price"] = float(px_disp) if px_disp != "-" else px_for_fmt + except Exception: + payload["price"] = px_for_fmt + payload["price_display"] = px_disp + if exchange_private_api_configured(): + try: + payload["exchange_tpsl"] = fetch_exchange_tpsl_slots( + ex_sym, + r["direction"], + plan_sl=r["stop_loss"], + plan_tp=r["take_profit"], + ) + except Exception: + payload["exchange_tpsl"] = {"sl": None, "tp": None} + else: + payload["exchange_tpsl"] = {"sl": None, "tp": None} + order_prices.append(payload) + + return jsonify({ + "updated_at": app_now_str(), + "key_prices": key_prices, + "order_prices": order_prices, + "positions_raw_count": len(all_swap_positions), + }) + + +@app.route("/api/order//cancel_tpsl", methods=["POST"]) +@login_required +def api_order_cancel_tpsl(order_id): + data = request.get_json(silent=True) or {} + role = (data.get("role") or "").strip().lower() + if role not in ("sl", "tp"): + return jsonify({"ok": False, "msg": "role 须为 sl 或 tp"}), 400 + conn = get_db() + row = conn.execute( + "SELECT * FROM order_monitors WHERE id=? AND status='active'", + (order_id,), + ).fetchone() + conn.close() + if not row: + return jsonify({"ok": False, "msg": "持仓不存在或已结束"}), 404 + ok, reason = ensure_exchange_live_ready() + if not ok: + return jsonify({"ok": False, "msg": reason}), 400 + ex_sym = resolve_monitor_exchange_symbol(row) + slots = fetch_exchange_tpsl_slots( + ex_sym, row["direction"], plan_sl=row["stop_loss"], plan_tp=row["take_profit"] + ) + slot = slots.get(role) + if not slot: + return jsonify({"ok": False, "msg": f"交易所未找到{'止损' if role == 'sl' else '止盈'}委托"}), 404 + try: + cancel_gate_tpsl_slot(ex_sym, slot) + slots = fetch_exchange_tpsl_slots( + ex_sym, row["direction"], plan_sl=row["stop_loss"], plan_tp=row["take_profit"] + ) + return jsonify({"ok": True, "msg": "已撤单", "exchange_tpsl": slots}) + except Exception as e: + return jsonify({"ok": False, "msg": friendly_exchange_error(e)}), 400 + + +@app.route("/api/order//place_tpsl", methods=["POST"]) +@login_required +def api_order_place_tpsl(order_id): + data = request.get_json(silent=True) or {} + conn = get_db() + row = conn.execute( + "SELECT * FROM order_monitors WHERE id=? AND status='active'", + (order_id,), + ).fetchone() + if not row: + conn.close() + return jsonify({"ok": False, "msg": "持仓不存在或已结束"}), 404 + symbol = row["symbol"] + direction = row["direction"] + live_price = get_price(symbol) + if live_price is None: + conn.close() + return jsonify({"ok": False, "msg": "获取交易所实时价格失败"}), 400 + try: + sltp_mode = (data.get("sltp_mode") or "price").strip().lower() + stop_loss, take_profit = _resolve_tpsl_prices_for_manual(direction, live_price, sltp_mode, data) + except Exception as e: + conn.close() + return jsonify({"ok": False, "msg": str(e)}), 400 + planned_rr = calc_rr_ratio(direction, live_price, stop_loss, take_profit) + if planned_rr is None or planned_rr < MANUAL_MIN_PLANNED_RR: + conn.close() + rr_txt = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算" + return jsonify( + { + "ok": False, + "msg": f"计划盈亏比 {rr_txt}:1 低于最低要求 {MANUAL_MIN_PLANNED_RR}:1", + } + ), 400 + try: + replace_active_monitor_tpsl_on_exchange(row, stop_loss, take_profit) + except Exception as e: + conn.close() + return jsonify({"ok": False, "msg": friendly_exchange_error(e)}), 400 + conn.execute( + "UPDATE order_monitors SET stop_loss=?, take_profit=? WHERE id=?", + (stop_loss, take_profit, order_id), + ) + conn.commit() + ex_sym = resolve_monitor_exchange_symbol(row) + slots = fetch_exchange_tpsl_slots(ex_sym, direction, plan_sl=stop_loss, plan_tp=take_profit) + conn.close() + return jsonify( + { + "ok": True, + "msg": "已先撤后挂止盈止损", + "stop_loss": stop_loss, + "take_profit": take_profit, + "planned_rr": planned_rr, + "exchange_tpsl": slots, + } + ) + + +@app.route("/api/symbol_liquidity_rank") +@login_required +def api_symbol_liquidity_rank(): + symbol = normalize_symbol_input(request.args.get("symbol")) + if not symbol: + return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400 + rank, total = _daily_volume_rank(symbol) + if total <= 0: + return jsonify({"ok": False, "msg": "日成交量排名读取失败"}), 502 + if rank is None: + return jsonify({"ok": True, "symbol": symbol, "rank": None, "total": int(total), "in_top30": False}) + return jsonify( + { + "ok": True, + "symbol": symbol, + "rank": int(rank), + "total": int(total), + "in_top30": bool(rank <= KEY_DAILY_VOLUME_RANK_MAX), + "rank_max": KEY_DAILY_VOLUME_RANK_MAX, + } + ) + + +@app.route("/api/order_defaults") +@login_required +def api_order_defaults(): + symbol = normalize_symbol_input(request.args.get("symbol")) + direction = (request.args.get("direction") or "long").strip().lower() + if not symbol: + return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400 + if direction not in ("long", "short"): + direction = "long" + exchange_symbol = normalize_exchange_symbol(symbol) + leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) + available = get_available_trading_usdt() + last_price = get_price(symbol) + return jsonify({ + "ok": True, + "symbol": symbol, + "exchange_symbol": exchange_symbol, + "direction": direction, + "leverage": leverage, + "available_trading_usdt": round(available, 2) if available is not None else None, + "last_price": round(float(last_price), 8) if last_price is not None else None, + }) + + +@app.route("/order_focus") +@login_required +def order_focus(): + now = app_now() + trading_day = get_trading_day(now) + conn = get_db() + session_row = ensure_session(conn, trading_day) + local_current_capital = float(session_row["current_capital"]) + _, trading_capital_live = get_exchange_capitals() + current_capital = round(trading_capital_live, 2) if trading_capital_live is not None else round(local_current_capital, 2) + raw_orders = conn.execute("SELECT * FROM order_monitors WHERE status='active' ORDER BY id DESC").fetchall() + conn.close() + orders = [enrich_order_item(row_to_dict(r), current_capital) for r in raw_orders] + picked_id = request.args.get("order_id", "").strip() + selected = None + if picked_id.isdigit(): + selected = next((o for o in orders if int(o["id"]) == int(picked_id)), None) + if selected is None and orders: + selected = orders[0] + return render_template( + "order_focus_v2.html", + orders=orders, + selected_order=selected, + default_timeframe=KLINE_TIMEFRAME, + price_refresh_seconds=PRICE_REFRESH_SECONDS, + exchange_display=EXCHANGE_DISPLAY_NAME, + ) + + +@app.route("/api/order_kline") +@login_required +def api_order_kline(): + order_id_raw = (request.args.get("order_id") or "").strip() + if not order_id_raw.isdigit(): + return jsonify({"ok": False, "msg": "order_id 无效"}), 400 + order_id = int(order_id_raw) + timeframe = (request.args.get("timeframe") or KLINE_TIMEFRAME).strip() + allowed_tfs = {"1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d"} + if timeframe not in allowed_tfs: + timeframe = KLINE_TIMEFRAME + limit = 100 + + now = app_now() + trading_day = get_trading_day(now) + conn = get_db() + session_row = ensure_session(conn, trading_day) + local_current_capital = float(session_row["current_capital"]) + _, trading_capital_live = get_exchange_capitals() + current_capital = round(trading_capital_live, 2) if trading_capital_live is not None else round(local_current_capital, 2) + row = conn.execute("SELECT * FROM order_monitors WHERE id=? AND status='active'", (order_id,)).fetchone() + conn.close() + if not row: + return jsonify({"ok": False, "msg": "订单不存在或已结束"}), 404 + + order_item = enrich_order_item(row_to_dict(row), current_capital) + exchange_symbol = order_item.get("exchange_symbol") or normalize_exchange_symbol(order_item["symbol"]) + try: + ensure_markets_loaded() + ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=limit) + except Exception as e: + return jsonify({"ok": False, "msg": f"K线加载失败:{friendly_exchange_error(e)}"}), 500 + + candles = [] + for bar in ohlcv or []: + if not bar or len(bar) < 6: + continue + ts = int(bar[0] // 1000) + candles.append({ + "time": ts, + "open": float(bar[1]), + "high": float(bar[2]), + "low": float(bar[3]), + "close": float(bar[4]), + "volume": float(bar[5]), + }) + + current_price = get_price(order_item["symbol"]) + margin = float(order_item.get("margin_capital") or 0) + leverage = float(order_item.get("leverage") or 0) + entry = float(order_item.get("trigger_price") or 0) + float_pnl = calc_pnl(order_item.get("direction") or "long", entry, current_price, margin, leverage) if current_price else 0 + float_pct = round((float_pnl / margin * 100), 4) if margin > 0 else 0 + + sym = order_item["symbol"] + return jsonify({ + "ok": True, + "timeframe": timeframe, + "limit": limit, + "order": { + "id": order_item["id"], + "symbol": sym, + "direction": order_item.get("direction") or "long", + "trigger_price": order_item.get("trigger_price"), + "stop_loss": order_item.get("stop_loss"), + "take_profit": order_item.get("take_profit"), + "trigger_price_display": format_price_for_symbol(sym, order_item.get("trigger_price")), + "stop_loss_display": format_price_for_symbol(sym, order_item.get("stop_loss")), + "take_profit_display": format_price_for_symbol(sym, order_item.get("take_profit")), + "margin_capital": order_item.get("margin_capital"), + "leverage": order_item.get("leverage"), + "position_ratio": order_item.get("position_ratio"), + "rr_ratio": order_item.get("rr_ratio"), + "breakeven_enabled": bool(int(order_item.get("breakeven_enabled") or 0)), + "current_price": round(float(current_price), 8) if current_price else None, + "current_price_display": format_price_for_symbol(sym, current_price) if current_price else None, + "float_pnl": round(float(float_pnl), 2), + "float_pct": float_pct, + }, + "candles": candles, + "updated_at": app_now_str(), + }) + + +@app.route("/key_focus") +@login_required +def key_focus(): + conn = get_db() + key_rows = conn.execute("SELECT * FROM key_monitors ORDER BY id DESC").fetchall() + conn.close() + key_list = [row_to_dict(r) for r in key_rows] + + key_id_raw = (request.args.get("key_id") or "").strip() + symbol_query = normalize_symbol_input(request.args.get("symbol")) + selected_key = None + if key_id_raw.isdigit(): + selected_key = next((k for k in key_list if int(k["id"]) == int(key_id_raw)), None) + if selected_key is None and symbol_query: + selected_key = next((k for k in key_list if (k.get("symbol") or "").upper() == symbol_query), None) + if selected_key is None and key_list: + selected_key = key_list[0] + default_symbol = symbol_query or ((selected_key or {}).get("symbol")) or "BTC/USDT" + return render_template( + "key_focus_v2.html", + key_list=key_list, + selected_key=selected_key, + default_symbol=default_symbol, + default_timeframe=KLINE_TIMEFRAME, + default_kline_limit=200, + price_refresh_seconds=PRICE_REFRESH_SECONDS, + exchange_display=EXCHANGE_DISPLAY_NAME, + ) + + +@app.route("/api/key_kline") +@login_required +def api_key_kline(): + key_id_raw = (request.args.get("key_id") or "").strip() + symbol_input = normalize_symbol_input(request.args.get("symbol")) + timeframe = (request.args.get("timeframe") or KLINE_TIMEFRAME).strip() + if timeframe not in {"1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d"}: + timeframe = KLINE_TIMEFRAME + limit = normalize_kline_limit(request.args.get("limit"), default=200) + + conn = get_db() + key_row = None + if key_id_raw.isdigit(): + key_row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (int(key_id_raw),)).fetchone() + if key_row is None and symbol_input: + key_row = conn.execute( + "SELECT * FROM key_monitors WHERE upper(symbol)=? ORDER BY id DESC LIMIT 1", + (symbol_input,), + ).fetchone() + if key_row is not None: + symbol = (key_row["symbol"] or "").upper() + else: + symbol = symbol_input + conn.close() + if not symbol: + return jsonify({"ok": False, "msg": "请先输入币种或选择关键位"}), 400 + + exchange_symbol = normalize_exchange_symbol(symbol) + try: + ensure_markets_loaded() + ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=limit) + except Exception as e: + return jsonify({"ok": False, "msg": f"K线加载失败:{friendly_exchange_error(e)}"}), 500 + + candles = [] + for bar in ohlcv or []: + if not bar or len(bar) < 6: + continue + candles.append({ + "time": int(bar[0] // 1000), + "open": float(bar[1]), + "high": float(bar[2]), + "low": float(bar[3]), + "close": float(bar[4]), + "volume": float(bar[5]), + }) + + current_price = get_price(symbol) + key_info = None + if key_row is not None: + upper = float(key_row["upper"]) if key_row["upper"] is not None else None + lower = float(key_row["lower"]) if key_row["lower"] is not None else None + upper_diff, upper_pct = calc_price_diff_pct(current_price, upper) if current_price else (None, None) + lower_diff, lower_pct = calc_price_diff_pct(current_price, lower) if current_price else (None, None) + key_info = { + "id": key_row["id"], + "monitor_type": key_row["monitor_type"], + "direction": key_row["direction"] or "long", + "upper": upper, + "lower": lower, + "notification_count": int(key_row["notification_count"] or 0), + "upper_diff": upper_diff, + "upper_pct": upper_pct, + "lower_diff": lower_diff, + "lower_pct": lower_pct, + } + + return jsonify({ + "ok": True, + "symbol": symbol, + "timeframe": timeframe, + "limit": limit, + "current_price": round(float(current_price), 8) if current_price is not None else None, + "key_monitor": key_info, + "candles": candles, + "updated_at": app_now_str(), + }) + + +@app.route("/add_key", methods=["POST"]) +@login_required +def add_key(): + d = request.form + symbol = normalize_symbol_input(d.get("symbol")) + if not symbol: + flash("symbol 不能为空") + return redirect("/key_monitor") + direction_sel = (d.get("direction") or "").strip().lower() + if direction_sel not in ("long", "short"): + flash("请选择做多或做空") + return redirect("/key_monitor") + mt = (d.get("type") or "").strip() + allowed_types = ( + tuple(KEY_MONITOR_AUTO_TYPES) + + tuple(KEY_MONITOR_ALERT_ONLY_TYPES) + + tuple(FIB_KEY_MONITOR_TYPES) + ) + if mt not in allowed_types: + flash("监控类型无效") + return redirect("/key_monitor") + rank, total = _daily_volume_rank(symbol) + if rank is None: + flash("日成交量排名读取失败,请稍后重试") + return redirect("/key_monitor") + if rank > KEY_DAILY_VOLUME_RANK_MAX: + flash(f"{symbol} 当前日成交量排名为 {rank}/{total},不在前{KEY_DAILY_VOLUME_RANK_MAX},已拒绝添加关键位") + return redirect("/key_monitor") + conn = get_db() + if mt in KEY_MONITOR_AUTO_TYPES: + occupied = get_active_position_count(conn) + if occupied >= MAX_ACTIVE_POSITIONS: + conn.close() + flash( + f"当前持仓已达上限({occupied}/{MAX_ACTIVE_POSITIONS}):无法添加「箱体突破 / 收敛突破」。" + "请平仓后再试,或使用「关键阻力位/关键支撑位」(仅单次提醒)。" + ) + return redirect("/key_monitor") + ex_sym_key = normalize_exchange_symbol(symbol) + try: + ensure_markets_loaded() + except Exception: + pass + upper_px = round_price_to_exchange(ex_sym_key, float(d["upper"])) + lower_px = round_price_to_exchange(ex_sym_key, float(d["lower"])) + be_flag = parse_breakeven_enabled_form(d.get("breakeven_enabled")) + if is_fib_key_monitor_type(mt): + ok_fib, err_fib = _add_fib_key_monitor( + conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=be_flag, + ) + conn.commit() + conn.close() + if not ok_fib: + flash(err_fib or "斐波监控添加失败") + return redirect("/key_monitor") + flash( + f"斐波监控已添加,限价单已挂出({symbol} 日成交量排名 {rank}/{total})" + f"|移动保本:{'开' if be_flag else '关'}" + ) + return redirect("/key_monitor") + sl_tp_mode = "standard" + manual_tp = None + if mt in KEY_MONITOR_AUTO_TYPES: + sl_tp_mode = normalize_sl_tp_mode(d.get("sl_tp_mode")) + if sl_tp_mode == "trend_manual": + try: + manual_tp = float(d.get("manual_take_profit") or 0) + except (TypeError, ValueError): + manual_tp = 0 + if manual_tp <= 0: + conn.close() + flash("趋势单方案须填写有效止盈价") + return redirect("/key_monitor") + if direction_sel == "long" and manual_tp <= upper_px: + conn.close() + flash("做多趋势单:止盈价应高于上沿(阻力)") + return redirect("/key_monitor") + if direction_sel == "short" and manual_tp >= lower_px: + conn.close() + flash("做空趋势单:止盈价应低于下沿(支撑)") + return redirect("/key_monitor") + mtpx = round_price_to_exchange(ex_sym_key, manual_tp) + if mtpx is not None: + manual_tp = float(mtpx) + conn.execute( + "INSERT INTO key_monitors " + "(symbol,monitor_type,direction,upper,lower,sl_tp_mode,manual_take_profit,breakeven_enabled) " + "VALUES (?,?,?,?,?,?,?,?)", + (symbol, mt, direction_sel, upper_px, lower_px, sl_tp_mode, manual_tp, be_flag), + ) + conn.commit() + conn.close() + ctr = False + try: + coin4h_status, _, _ = _status_by_ema55(symbol, "4h") + ctr = (direction_sel == "long" and coin4h_status == "空头") or ( + direction_sel == "short" and coin4h_status == "多头" + ) + except Exception: + pass + extra = "" + if mt in KEY_MONITOR_AUTO_TYPES: + extra = f"|方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'开' if be_flag else '关'}" + flash(f"添加成功({symbol} 日成交量排名 {rank}/{total}){extra}") + if ctr: + flash( + "⚠️ 4h EMA55 提示:当前与所选方向逆势;「箱体突破/收敛突破」在条件满足时仍会按计划自动市价开仓,请注意仓位。" + ) + return redirect("/key_monitor") + +@app.route("/add_order", methods=["POST"]) +@login_required +def add_order(): + d = request.form + now = app_now() + conn = get_db() + direction = d.get("direction", "long") + symbol = normalize_symbol_input(d.get("symbol")) + if not symbol: + conn.close() + flash("symbol 不能为空") + return redirect("/") + ok, reason = precheck_risk(conn, symbol, direction) + if not ok: + if "已达最大持仓数" in reason: + try: + tp_raw = parse_positive_float(d.get("tp")) + sl_raw = parse_positive_float(d.get("sl")) + tgt_raw = parse_positive_float(d.get("tgt")) + except Exception: + tp_raw = sl_raw = tgt_raw = None + ex_miss = normalize_exchange_symbol(symbol) + try: + ensure_markets_loaded() + except Exception: + pass + insert_trade_record( + conn, + symbol=symbol, + monitor_type="下单监控", + direction=direction if direction in ("long", "short") else "long", + trigger_price=round_price_to_exchange(ex_miss, tp_raw) if tp_raw else 0, + stop_loss=round_price_to_exchange(ex_miss, sl_raw) if sl_raw else 0, + take_profit=round_price_to_exchange(ex_miss, tgt_raw) if tgt_raw else 0, + result="错过", + miss_reason=f"持仓占用:{reason}", + opened_at=app_now_str(), + closed_at=app_now_str(), + ) + conn.commit() + conn.close() + flash(f"风控拒绝下单:{reason}") + return redirect("/trade") + ok_live, reason_live = ensure_exchange_live_ready() + if not ok_live: + conn.close() + flash(f"风控拒绝下单:{reason_live}") + return redirect("/trade") + exchange_symbol = normalize_exchange_symbol(symbol) + default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) + try: + leverage_input = parse_positive_float(d.get("leverage")) + leverage = int(leverage_input) if leverage_input is not None else default_leverage + except Exception: + conn.close() + flash("杠杆参数格式错误") + return redirect("/") + if leverage <= 0: + conn.close() + flash("杠杆必须大于0") + return redirect("/") + + trading_day = get_trading_day(now) + opens_today_before = conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", + (trading_day,), + ).fetchone()[0] + session_row = ensure_session(conn, trading_day) + _, trading_capital_live = get_exchange_capitals(force=True) + capital_base = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"]) + trade_style = (d.get("trade_style") or DEFAULT_TRADE_STYLE or "trend").strip().lower() + if trade_style not in ("trend", "swing"): + trade_style = "trend" + available_usdt = get_available_trading_usdt() + live_price = get_price(symbol) + if live_price is None: + conn.close() + flash("获取交易所实时价格失败,请稍后重试") + return redirect("/") + try: + ensure_markets_loaded() + except Exception: + pass + lp_r = round_price_to_exchange(exchange_symbol, live_price) + if lp_r is not None: + live_price = lp_r + sltp_mode = (d.get("sltp_mode") or "price").strip().lower() + if sltp_mode not in ("price", "pct"): + sltp_mode = "price" + if sltp_mode == "pct": + try: + sl_pct = float(d.get("sl_pct") or 0) + tp_pct = float(d.get("tp_pct") or 0) + if sl_pct <= 0 or tp_pct <= 0: + raise ValueError("pct") + sl_ratio = sl_pct / 100.0 + tp_ratio = tp_pct / 100.0 + if direction == "short": + stop_loss = float(live_price) * (1 + sl_ratio) + take_profit = float(live_price) * (1 - tp_ratio) + else: + stop_loss = float(live_price) * (1 - sl_ratio) + take_profit = float(live_price) * (1 + tp_ratio) + except Exception: + conn.close() + flash("百分比止盈止损参数错误,请填写正数百分比") + return redirect("/") + else: + try: + stop_loss = float(d["sl"]) + take_profit = float(d["tgt"]) + except Exception: + conn.close() + flash("价格参数格式错误") + return redirect("/") + if stop_loss <= 0 or take_profit <= 0: + conn.close() + flash("价格参数必须大于0") + return redirect("/trade") + planned_rr_manual = calc_rr_ratio(direction, live_price, stop_loss, take_profit) + if planned_rr_manual is None or planned_rr_manual < MANUAL_MIN_PLANNED_RR: + conn.close() + rr_txt = f"{planned_rr_manual:.4f}" if planned_rr_manual is not None else "无法计算" + flash(f"风控拒绝下单:计划盈亏比 {rr_txt}:1 低于最低要求 {MANUAL_MIN_PLANNED_RR}:1") + return redirect("/trade") + sl_adj = round_price_to_exchange(exchange_symbol, stop_loss) + tp_adj = round_price_to_exchange(exchange_symbol, take_profit) + if sl_adj is not None: + stop_loss = sl_adj + if tp_adj is not None: + take_profit = tp_adj + risk_fraction = calc_risk_fraction(direction, live_price, stop_loss) + if risk_fraction is None: + conn.close() + flash("止损方向不合法:请检查入场方向与止损价格关系") + return redirect("/") + risk_percent = max(0.01, float(RISK_PERCENT)) + risk_amount = round(capital_base * risk_percent / 100.0, 4) + notional_value = round(risk_amount / risk_fraction, 4) + margin_capital = round(notional_value / leverage, 4) + if capital_base and margin_capital > capital_base: + conn.close() + flash("以损定仓后保证金超过当前交易资金,请放宽止损或降低风险比例") + return redirect("/") + if available_usdt is not None: + max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4) + if margin_capital > max_margin: + conn.close() + flash(f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U") + return redirect("/") + position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0 + try: + amount, quote_price = prepare_order_amount(exchange_symbol, margin_capital, leverage, live_price) + contract_size = get_contract_size(exchange_symbol) + base_amount = round(float(amount) * contract_size, 8) + order_resp = place_exchange_order(exchange_symbol, direction, amount, leverage, stop_loss=stop_loss, take_profit=take_profit) + open_order_id = order_resp.get("id", "") + tpsl_attached = bool(order_resp.get("tpsl_attached")) + trigger_price = resolve_order_entry_price(order_resp, exchange_symbol, quote_price) + except Exception as e: + conn.close() + flash(friendly_exchange_error(e, available_usdt=available_usdt)) + return redirect("/") + + trigger_price = round_price_to_exchange(exchange_symbol, trigger_price) + stop_loss = round_price_to_exchange(exchange_symbol, stop_loss) + take_profit = round_price_to_exchange(exchange_symbol, take_profit) + + make_order_chart = d.get("order_chart", "").lower() in ("1", "true", "on", "yes") + opened_at_bj = app_now_str() + opened_at_ms = _to_ms_with_fallback(None, opened_at_bj) + planned_rr = calc_rr_ratio(direction, trigger_price, stop_loss, take_profit) + breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER) + breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT) + breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0 + risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage) or risk_amount + if direction == "short": + breakeven_raw = float(trigger_price) * (1 - breakeven_offset_pct / 100.0) + else: + breakeven_raw = float(trigger_price) * (1 + breakeven_offset_pct / 100.0) + breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw) + breakeven_enabled = 1 if (d.get("breakeven_enabled") or "").strip() in ("1", "true", "on", "yes") else 0 + conn.execute( + "INSERT INTO order_monitors (symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, margin_capital, leverage, trade_style, risk_percent, risk_amount, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, exchange_symbol, direction, trigger_price, stop_loss, stop_loss, take_profit, + margin_capital, leverage, trade_style, risk_percent, risk_amount_final, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, 0, breakeven_price, + breakeven_enabled, + notional_value, position_ratio, base_amount, amount, open_order_id, opened_at_bj, opened_at_ms, trading_day, + ORDER_MONITOR_TYPE_MANUAL, + ) + ) + conn.commit() + new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + try_persist_exchange_margin_for_order(conn, new_order_id, exchange_symbol, direction, order_leverage=leverage) + conn.commit() + opens_today_after = conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", + (trading_day,), + ).fetchone()[0] + conn.close() + + chart_name = None + chart_url = None + if make_order_chart and ORDER_CHART_ENABLED: + try: + title_prefix = f"{symbol} {direction} #{new_order_id}" + chart_name = generate_order_open_chart( + exchange_symbol, + title_prefix, + opened_at_ms=opened_at_ms, + entry_price=trigger_price, + ) + if chart_name: + chart_url = f"/static/images/order_charts/{chart_name}" + except Exception: + chart_name = None + chart_url = None + + if chart_name: + try: + journal_id = f"order_{new_order_id}" + coin = journal_coin_from_symbol(symbol) + open_local = (opened_at_bj or "")[:16].replace(" ", "T") + if len(open_local) < 16: + open_local = app_now().strftime("%Y-%m-%dT%H:%M") + close_local = open_local + hold_duration = calc_duration_text(open_local, close_local) + note = ( + f"auto_from_open_order id={new_order_id} oid={open_order_id} " + f"chart={chart_name} tfs={','.join(ORDER_CHART_TFS)} limit={ORDER_CHART_LIMIT}" + ) + conn = get_db() + conn.execute( + """INSERT OR REPLACE INTO journal_entries + (id, open_datetime, close_datetime, hold_duration, coin, tf, pnl, entry_reason, exit_reason, + expect_rr, real_rr, early_exit, early_exit_reason, early_exit_trigger, early_exit_note, + mood_score, mood_ai_score, mood_ai_comment, mood_issues, post_breakeven_stare, + new_trade_while_occupied, note, image) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + journal_id, + open_local, + close_local, + hold_duration, + coin, + "multi", + "0", + "auto:open", + "待平仓", + "", + "", + "否", + "", + "", + "", + None, + None, + None, + "", + "否", + "否", + note, + chart_name, + ), + ) + conn.commit() + conn.close() + except Exception: + try: + conn.close() + except Exception: + pass + + _, trading_capital_after = get_exchange_capitals(force=True) + account_base_display = ( + round(float(trading_capital_after), 2) + if trading_capital_after is not None + else round(float(capital_base), 2) + ) + account_name = (os.getenv("GATE_ACCOUNT_LABEL") or "gate实盘账户").strip() + dir_text = "多头(long)" if direction == "long" else "空头(short)" + order_state_text = ( + "已在交易所挂条件委托(止盈、止损各一张触发单)" + if tpsl_attached + else "条件委托未挂上(已拦截)" + ) + rr_show = planned_rr if planned_rr is not None else "-" + try: + rr_show_fmt = f"{float(planned_rr):.2f}" if planned_rr is not None else None + except (TypeError, ValueError): + rr_show_fmt = None + rr_line = f"RR {rr_show_fmt} : 1" if rr_show_fmt is not None else f"RR {rr_show} : 1" + ep_wx = format_price_for_symbol(symbol, trigger_price) + sl_wx = format_wechat_scalar_2dp(stop_loss) + tp_wx = format_price_for_symbol(symbol, take_profit) + be_wx = format_price_for_symbol(symbol, breakeven_price) + style_zh = "Swing 波段" if trade_style == "swing" else "Trend 趋势" + wx_lines = [ + f"📈 {symbol} 开仓成功", + f"💼 交易类型:{dir_text}", + "🧾 订单基础信息", + f"🔖 交易所订单 ID:{open_order_id}", + f"📈 交易风格:{style_zh}", + f"⚠️ 单笔风控风险:{risk_percent}% ≈ {round(float(risk_amount_final), 2)} U", + "📊 仓位配置详情", + f"账户基数:{account_base_display} USDT", + f"合约杠杆:{leverage} 倍", + f"名义仓位:{format_wechat_scalar_2dp(notional_value)} USDT", + f"仓位占比:{position_ratio}%", + f"合约张数:{format_wechat_scalar_2dp(amount)} 张", + f"折算标的:{base_amount} {journal_coin_from_symbol(symbol)}", + "🎯 价位 & 盈亏比", + f"开仓成交价:{ep_wx}", + f"止损价位:{sl_wx}", + f"止盈价位:{tp_wx}", + f"计划盈亏比:{rr_line}", + f"移动保本位:{breakeven_rr_trigger}R → {be_wx}", + "📌 状态统计", + f"✅ 条件委托:{order_state_text}", + f"📅 当日开仓次数:{opens_today_after} / {DAILY_OPEN_ALERT_THRESHOLD} 次(风控阈值提醒)", + ] + if chart_url: + wx_lines.append(f"多周期K线图:{chart_url}") + send_wechat_msg("\n".join(wx_lines)) + + flash_lines = [ + f"实盘开单成功:风格 {trade_style};风险 {risk_percent}%≈{round(float(risk_amount_final), 2)}U;基数 {round(float(margin_capital), 2)}U,杠杆 {leverage}x,名义仓位 {format_wechat_scalar_2dp(notional_value)}U,仓位占比 {position_ratio}%,合约张数 {format_wechat_scalar_2dp(amount)}(折算标的 {base_amount})," + f"计划RR {format_wechat_scalar_2dp(planned_rr) if planned_rr is not None else '-'};已在交易所挂条件止盈/止损委托(非仓位绑定型)", + f"本交易日累计开仓:{opens_today_after}", + ] + if chart_url: + flash_lines.append(f"已生成多周期K线图:{chart_url}") + flash(" ".join(flash_lines)) + + if opens_today_before < DAILY_OPEN_ALERT_THRESHOLD <= opens_today_after: + advice = ai_short_advice( + f"用户在北京时间交易日 {trading_day} 已累计开仓 {opens_today_after} 次(阈值 {DAILY_OPEN_ALERT_THRESHOLD})。" + f"最新一笔:{symbol} {direction},杠杆{leverage}x,基数{round(float(margin_capital), 2)}U。" + f"用户自述“上头了”。请给克制提醒。" + ) + if advice: + send_wechat_msg(f"【AI提醒】今日开仓次数已达 {opens_today_after}\n{advice[:800]}") + flash(f"【AI提醒】今日开仓次数已达 {opens_today_after}:{advice[:300]}") + return redirect("/") + +@app.route("/delete_key_monitor/", methods=["POST"]) +@login_required +def delete_key_monitor(kid): + conn = get_db() + row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (kid,)).fetchone() + if not row: + conn.close() + return jsonify({"ok": False, "error": "not_found"}) + if is_fib_key_monitor_type(row["monitor_type"]): + _cancel_fib_monitor_limit(row) + insert_key_monitor_history(conn, row, int(row["notification_count"] or 0), None, "manual") + cur = conn.execute("DELETE FROM key_monitors WHERE id=?", (kid,)) + conn.commit() + conn.close() + return jsonify({"ok": cur.rowcount > 0}) + + +@app.route("/delete_key_history/", methods=["POST"]) +@login_required +def delete_key_history(hid): + conn = get_db() + cur = conn.execute("DELETE FROM key_monitor_history WHERE id=?", (hid,)) + conn.commit() + conn.close() + return jsonify({"ok": cur.rowcount > 0}) + + +@app.route("/del_key/") +@login_required +def del_key(id): + conn = get_db() + row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (id,)).fetchone() + if row: + if is_fib_key_monitor_type(row["monitor_type"]): + _cancel_fib_monitor_limit(row) + insert_key_monitor_history(conn, row, int(row["notification_count"] or 0), None, "manual") + conn.execute("DELETE FROM key_monitors WHERE id=?", (id,)) + conn.commit() + conn.close() + resp = redirect("/") + resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" + resp.headers["Pragma"] = "no-cache" + return resp + + +def _csv_response(filename, rows, header): + buf = StringIO() + w = csv.writer(buf) + w.writerow(header) + for row in rows: + w.writerow(row) + out = "\ufeff" + buf.getvalue() + return Response( + out, + mimetype="text/csv; charset=utf-8", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + "Cache-Control": "no-store", + }, + ) + + +def _md_response(filename, content): + return Response( + content, + mimetype="text/markdown; charset=utf-8", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + "Cache-Control": "no-store", + }, + ) + + +@app.route("/export/trade_records") +@login_required +def export_trade_records(): + win = _list_window_from_request() + start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ) + conn = get_db() + rows = conn.execute( + "SELECT id,symbol,monitor_type,key_signal_type,direction,trigger_price,stop_loss,initial_stop_loss,take_profit," + "margin_capital,leverage,pnl_amount,hold_seconds,hold_minutes,planned_rr,actual_rr,risk_amount," + "opened_at,closed_at,result,miss_reason,entry_reason,reviewed_entry_reason," + "exchange_realized_pnl,exchange_opened_at,exchange_closed_at,created_at " + "FROM trade_records WHERE COALESCE(closed_at, created_at, opened_at) >= ? " + "AND COALESCE(closed_at, created_at, opened_at) <= ? ORDER BY id ASC", + (start_bj, end_bj), + ).fetchall() + conn.close() + head = [ + "id", "symbol", "monitor_type", "key_signal_type", "direction", "trigger_price", + "stop_loss_open_snapshot", "initial_stop_loss", "take_profit", "margin_capital", "leverage", + "pnl_amount", "hold_seconds", "hold_minutes", "planned_rr", "actual_rr", "risk_amount", + "opened_at", "closed_at", "result", "miss_reason", "entry_reason", "reviewed_entry_reason", + "exchange_realized_pnl", "exchange_opened_at", "exchange_closed_at", "created_at", "开仓类型", + ] + data = [] + for r in rows: + er0 = (r["entry_reason"] or "").strip() if r["entry_reason"] else "" + er1 = (r["reviewed_entry_reason"] or "").strip() if r["reviewed_entry_reason"] else "" + kst = (r["key_signal_type"] or "").strip() if "key_signal_type" in r.keys() else "" + eff = er1 or er0 or entry_reason_from_key_signal(kst) or "" + snap = r["initial_stop_loss"] if r["initial_stop_loss"] not in (None, "") else r["stop_loss"] + data.append(( + r["id"], r["symbol"], r["monitor_type"], kst, r["direction"], r["trigger_price"], + snap, r["initial_stop_loss"], r["take_profit"], r["margin_capital"], r["leverage"], + r["pnl_amount"], r["hold_seconds"], r["hold_minutes"], r["planned_rr"], r["actual_rr"], r["risk_amount"], + r["opened_at"], r["closed_at"], r["result"], r["miss_reason"], r["entry_reason"], r["reviewed_entry_reason"], + r["exchange_realized_pnl"] if "exchange_realized_pnl" in r.keys() else None, + r["exchange_opened_at"] if "exchange_opened_at" in r.keys() else None, + r["exchange_closed_at"] if "exchange_closed_at" in r.keys() else None, + r["created_at"], eff, + )) + day = app_now().strftime("%Y%m%d") + return _csv_response(f"trade_records_v3_{day}.csv", data, head) + + +@app.route("/export/journal_entries") +@login_required +def export_journal_entries(): + conn = get_db() + rows = conn.execute( + "SELECT id,open_datetime,close_datetime,hold_duration,coin,tf,pnl,entry_reason,exit_reason," + "expect_rr,real_rr,early_exit,early_exit_trigger,early_exit_note,early_exit_reason,mood_issues," + "post_breakeven_stare,new_trade_while_occupied,note,image,created_at FROM journal_entries ORDER BY created_at ASC" + ).fetchall() + conn.close() + head = [ + "id", + "open_datetime", + "close_datetime", + "hold_duration", + "coin", + "tf", + "pnl", + "entry_reason", + "exit_reason", + "expect_rr", + "real_rr", + "early_exit", + "early_exit_trigger", + "early_exit_note", + "early_exit_reason", + "mood_issues", + "post_breakeven_stare", + "new_trade_while_occupied", + "note", + "image", + "created_at", + ] + data = [tuple(r[h] for h in head) for r in rows] + day = app_now().strftime("%Y%m%d") + return _csv_response(f"journal_entries_v1_{day}.csv", data, head) + + +@app.route("/export/key_monitors") +@login_required +def export_key_monitors(): + conn = get_db() + rows = conn.execute( + "SELECT id,symbol,monitor_type,direction,upper,lower,notification_count,last_notified_at,max_notify," + "notify_interval_min,breakout_limit_pct,created_at FROM key_monitors ORDER BY id ASC" + ).fetchall() + conn.close() + head = [ + "id", + "symbol", + "monitor_type", + "direction", + "upper", + "lower", + "notification_count", + "last_notified_at", + "max_notify", + "notify_interval_min", + "breakout_limit_pct", + "created_at", + ] + data = [tuple(r[h] for h in head) for r in rows] + day = app_now().strftime("%Y%m%d") + return _csv_response(f"key_monitors_active_v1_{day}.csv", data, head) + + +@app.route("/export/key_monitor_history") +@login_required +def export_key_monitor_history(): + win = _list_window_from_request() + start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ) + conn = get_db() + rows = conn.execute( + "SELECT id,symbol,monitor_type,direction,upper,lower,notification_count,last_alert_message,close_reason,closed_at " + "FROM key_monitor_history WHERE closed_at >= ? AND closed_at <= ? ORDER BY id ASC", + (start_bj, end_bj), + ).fetchall() + conn.close() + head = [ + "id", + "symbol", + "monitor_type", + "direction", + "upper", + "lower", + "notification_count", + "last_alert_message", + "close_reason", + "closed_at", + ] + data = [tuple(r[h] for h in head) for r in rows] + day = app_now().strftime("%Y%m%d") + return _csv_response(f"key_monitor_history_v1_{day}.csv", data, head) + +@app.route("/del_order/") +@login_required +def del_order(id): + conn = get_db() + row = conn.execute("SELECT * FROM order_monitors WHERE id=?", (id,)).fetchone() + if not row: + conn.close() + flash("订单不存在") + return redirect("/") + if row["status"] == "active": + try: + p = get_price(row["symbol"]) or float(row["trigger_price"]) + opened_at = get_opened_at_value(row) + closed_at = app_now_str() + hold_seconds = calc_hold_seconds(opened_at, app_now()) + pnl_amount = calc_pnl( + row["direction"], + row["trigger_price"], + p, + row["margin_capital"] or DAILY_START_CAPITAL, + row["leverage"] or infer_leverage(row["symbol"]) + ) + close_resp = close_exchange_order(row) + close_order_id = close_resp.get("id", "") + cancel_gate_swap_trigger_orders(row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"])) + session_date = row["session_date"] or get_trading_day() + session_capital = update_session_capital(conn, session_date, pnl_amount) + row_snap = conn.execute("SELECT * FROM order_monitors WHERE id=?", (id,)).fetchone() or row + insert_trade_record( + conn, + symbol=row["symbol"], + monitor_type=order_row_monitor_type(row), + key_signal_type=order_row_key_signal_type(row), + direction=row["direction"], + trigger_price=row["trigger_price"], + stop_loss=row["stop_loss"], + initial_stop_loss=row["initial_stop_loss"] or row["stop_loss"], + take_profit=row["take_profit"], + margin_capital=margin_capital_for_trade_record(row_snap), + leverage=row["leverage"], + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=row["trade_style"], + risk_amount=row["risk_amount"], + planned_rr=calc_rr_ratio(row["direction"], row["trigger_price"], row["initial_stop_loss"] or row["stop_loss"], row["take_profit"]), + actual_rr=calc_actual_rr(pnl_amount, row["risk_amount"]), + result="手动平仓", + miss_reason="用户手动删除订单触发平仓", + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, id)) + clear_key_sizing_snapshot_if_flat(conn, session_date) + conn.commit() + conn.close() + send_wechat_msg( + build_wechat_close_message( + symbol=row["symbol"], + direction=row["direction"], + result="手动平仓", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=row["trigger_price"], + current_price=p, + stop_loss=row["stop_loss"], + take_profit=row["take_profit"], + close_order_id=close_order_id or "-", + extra_note="用户在页面手动平仓", + session_capital_fallback=session_capital, + ) + ) + flash("已按实盘流程手动平仓") + return redirect("/trade") + except Exception as e: + if is_no_position_error(str(e)): + cancel_gate_swap_trigger_orders(row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"])) + opened_at = get_opened_at_value(row) + opened_at_ms = _to_ms_with_fallback(row["opened_at_ms"] if "opened_at_ms" in row.keys() else None, opened_at) + result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close(row, opened_at, opened_at_ms=opened_at_ms) + miss_reason = f"手动删除时无持仓:{miss_reason}" + closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now() + hold_seconds = calc_hold_seconds(opened_at, closed_at_dt) + session_date = row["session_date"] or get_trading_day(closed_at_dt) + update_session_capital(conn, session_date, pnl_amount) + row_snap = conn.execute("SELECT * FROM order_monitors WHERE id=?", (id,)).fetchone() or row + insert_trade_record( + conn, + symbol=row["symbol"], + monitor_type=order_row_monitor_type(row), + key_signal_type=order_row_key_signal_type(row), + direction=row["direction"], + trigger_price=row["trigger_price"], + stop_loss=row["stop_loss"], + initial_stop_loss=row["initial_stop_loss"] or row["stop_loss"], + take_profit=row["take_profit"], + margin_capital=margin_capital_for_trade_record(row_snap), + leverage=row["leverage"], + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=row["trade_style"], + risk_amount=row["risk_amount"], + planned_rr=calc_rr_ratio(row["direction"], row["trigger_price"], row["initial_stop_loss"] or row["stop_loss"], row["take_profit"]), + actual_rr=calc_actual_rr(pnl_amount, row["risk_amount"]), + result=result, + miss_reason=miss_reason, + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (id,)) + conn.commit() + conn.close() + flash("该仓位在交易所已不存在,已按成交记录同步结束并记账") + return redirect("/") + conn.close() + flash(f"手动平仓失败:{str(e)}") + return redirect("/") + conn.execute("DELETE FROM order_monitors WHERE id=?",(id,)) + conn.commit() + conn.close() + return redirect("/") + +@app.route("/add_miss", methods=["POST"]) +@login_required +def add_miss(): + d = request.form + direction = d.get("direction", "long") + sym_in = normalize_symbol_input(d.get("symbol")) + ex_sym = normalize_exchange_symbol(sym_in) + try: + ensure_markets_loaded() + except Exception: + pass + try: + tp_px = round_price_to_exchange(ex_sym, float(d["tp"])) + sl_px = round_price_to_exchange(ex_sym, float(d["sl"])) + tgt_px = round_price_to_exchange(ex_sym, float(d["tgt"])) + except Exception: + flash("价格格式错误") + return _redirect_records() + conn = get_db() + insert_trade_record( + conn, + symbol=sym_in, + monitor_type=d["type"], + direction=direction, + trigger_price=tp_px, + stop_loss=sl_px, + take_profit=tgt_px, + result="错过", + miss_reason=d["reason"], + opened_at=app_now_str(), + closed_at=app_now_str(), + ) + conn.commit() + conn.close() + flash("已记录错过机会") + return _redirect_records() + + +@app.route("/add_journal", methods=["POST"]) +@login_required +def add_journal(): + d = request.form + entry_reason_norm = normalize_entry_reason(d.get("entry_reason"), d.get("entry_reason_custom")) + if not entry_reason_norm: + flash("请选择开仓类型;若选「其他」请在下方填写自定义说明") + return _redirect_records() + early_exit_trigger = normalize_early_exit_trigger(d.get("early_exit_trigger")) + early_exit_note = str(d.get("early_exit_note") or "").strip() + if not early_exit_trigger: + flash("请选择离场触发") + return _redirect_records() + if early_exit_trigger == "手动平仓" and not early_exit_note: + flash("手工平仓必须填写补充说明") + return _redirect_records() + if early_exit_trigger != "手动平仓": + early_exit_note = "" + # 兼容字段:仅「手工平仓」记为「主观提前」语义下的「是」 + early_exit_raw = "是" if early_exit_trigger == "手动平仓" else "否" + early_exit_reason_saved = compose_early_exit_reason_saved(early_exit_trigger, early_exit_note) + exit_reason_stored = journal_exit_reason_stored(early_exit_trigger, early_exit_note) + image_filename = None + uploaded_tmp = None + entry_id = uuid.uuid4().hex + file = request.files.get("screenshot") + if file and file.filename: + ext = os.path.splitext(file.filename)[1] + image_filename = f"{uuid.uuid4().hex}{ext}" + save_path = os.path.join(app.config["UPLOAD_FOLDER"], secure_filename(image_filename)) + file.save(save_path) + uploaded_tmp = image_filename + + mood_issues = ",".join(request.form.getlist("mood_issues")) + hold_duration = calc_duration_text(d.get("open_datetime", ""), d.get("close_datetime", "")) + real_rr_text = (d.get("real_rr") or "").strip() + try: + risk_amount_hint = float(d.get("risk_amount_hint") or 0) + pnl_hint = float(d.get("pnl") or 0) + # 口径统一:实际RR = 实际盈亏 / 以损定仓对应的初始风险金额 + if risk_amount_hint > 0: + real_rr_text = f"{(pnl_hint / risk_amount_hint):.4f}" + except Exception: + pass + + want_exchange_chart = d.get("journal_exchange_chart", "").lower() in ("1", "true", "on", "yes") + chart_msg = None + if want_exchange_chart and ORDER_CHART_ENABLED: + coin = (d.get("coin") or "").strip().upper() + symbol_guess = normalize_symbol_input(coin) or coin + exchange_symbol = normalize_exchange_symbol(symbol_guess) + title_prefix = f"{symbol_guess} journal {entry_id[:8]}" + marker_payload = { + "entry_ts_ms": _local_input_datetime_to_ms(d.get("open_datetime")), + "exit_ts_ms": _local_input_datetime_to_ms(d.get("close_datetime")), + "entry_price": d.get("entry_price_hint"), + "exit_price": None, + } + try: + chart_fname = f"journal_{entry_id}.png" + saved = generate_multi_timeframe_chart_png( + exchange_symbol, + title_prefix, + timeframes=ORDER_CHART_TFS, + limit=ORDER_CHART_LIMIT, + out_dir=app.config["UPLOAD_FOLDER"], + filename=chart_fname, + filename_prefix="journal", + marker_payload=marker_payload, + marker_timeframes=( + {x.strip().lower() for x in ORDER_CHART_TFS if x and str(x).strip()} + if ORDER_CHART_TFS + else {"5m", "15m", "1h", "4h"} + ), + ) + if saved: + image_filename = saved + chart_msg = f"已生成多周期K线图:/static/images/{saved}" + if uploaded_tmp: + try: + old_path = os.path.join(app.config["UPLOAD_FOLDER"], uploaded_tmp) + if os.path.exists(old_path): + os.remove(old_path) + except Exception: + pass + else: + chart_msg = "已勾选自动生成K线图,但生成失败(返回空)。请检查 Pillow 是否安装、Gate 网络/代理是否正常。" + except Exception as e: + image_filename = uploaded_tmp + chart_msg = f"自动生成K线图失败:{str(e)}" + + conn = get_db() + conn.execute( + """INSERT INTO journal_entries + (id, open_datetime, close_datetime, hold_duration, coin, tf, pnl, entry_reason, exit_reason, + expect_rr, real_rr, early_exit, early_exit_reason, early_exit_trigger, early_exit_note, + mood_score, mood_ai_score, mood_ai_comment, mood_issues, post_breakeven_stare, + new_trade_while_occupied, note, image) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + entry_id, d.get("open_datetime"), d.get("close_datetime"), hold_duration, d.get("coin"), d.get("tf"), + d.get("pnl"), entry_reason_norm, exit_reason_stored, d.get("expect_rr"), real_rr_text, + early_exit_raw, early_exit_reason_saved, early_exit_trigger, early_exit_note, + None, None, None, mood_issues, + d.get("post_breakeven_stare"), d.get("new_trade_while_occupied"), d.get("note"), image_filename + ) + ) + conn.commit() + conn.close() + if chart_msg: + flash(f"交易复盘记录已保存。{chart_msg}") + else: + flash("交易复盘记录已保存") + return _redirect_records() + + +@app.route("/api/journals") +@login_required +def api_journals(): + win = _list_window_from_request() + start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ) + conn = get_db() + rows = conn.execute( + "SELECT * FROM journal_entries WHERE COALESCE(close_datetime, created_at, open_datetime) >= ? " + "AND COALESCE(close_datetime, created_at, open_datetime) <= ? ORDER BY created_at DESC LIMIT 500", + (start_bj, end_bj), + ).fetchall() + conn.close() + result = [] + for r in rows: + item = row_to_dict(r) + item["mood_issues"] = [x for x in (item.get("mood_issues") or "").split(",") if x] + result.append(item) + return jsonify(result) + + +@app.route("/api/journal_prefill", methods=["POST"]) +@login_required +def api_journal_prefill(): + file = request.files.get("screenshot") + if not file or not file.filename: + return jsonify({"ok": False, "msg": "请先选择截图文件"}), 400 + try: + raw = file.read() + if not raw: + return jsonify({"ok": False, "msg": "截图为空"}), 400 + image_b64 = base64.b64encode(raw).decode("utf-8") + except Exception as e: + return jsonify({"ok": False, "msg": f"读取截图失败:{str(e)}"}), 400 + + parsed = ai_extract_journal_from_image(image_b64) + if parsed is None: + return jsonify({"ok": False, "msg": "AI 识别失败,请稍后重试"}), 500 + return jsonify({"ok": True, "data": parsed}) + + +@app.route("/delete_journal/", methods=["POST"]) +@login_required +def delete_journal(jid): + conn = get_db() + row = conn.execute("SELECT image FROM journal_entries WHERE id=?", (jid,)).fetchone() + if row and row["image"]: + img_path = os.path.join(app.config["UPLOAD_FOLDER"], row["image"]) + if os.path.exists(img_path): + os.remove(img_path) + conn.execute("DELETE FROM journal_entries WHERE id=?", (jid,)) + conn.commit() + conn.close() + return jsonify({"ok": True}) + + +@app.route("/api/reviews") +@login_required +def api_reviews(): + win = _list_window_from_request() + start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ) + conn = get_db() + rows = conn.execute( + "SELECT * FROM ai_reviews WHERE created_at >= ? AND created_at <= ? ORDER BY created_at DESC LIMIT 200", + (start_bj, end_bj), + ).fetchall() + conn.close() + return jsonify([row_to_dict(r) for r in rows]) + + +@app.route("/export/review_md/") +@login_required +def export_review_md(rid): + conn = get_db() + row = conn.execute("SELECT * FROM ai_reviews WHERE id=?", (rid,)).fetchone() + conn.close() + if not row: + return Response("review not found", status=404, mimetype="text/plain; charset=utf-8") + + review_type = "日复盘" if row["review_type"] == "daily" else "周复盘" + target_date = row["target_date"] or "-" + created_at = row["created_at"] or app_now_str() + content = (row["content"] or "").strip() + if not content: + content = "(无内容)" + + md = ( + f"# {review_type}报告\n\n" + f"- 目标日期: {target_date}\n" + f"- 生成时间: {created_at}\n" + f"- 报告ID: {row['id']}\n\n" + f"---\n\n" + f"{content}\n" + ) + + safe_target = re.sub(r"[^0-9A-Za-z_-]+", "-", str(target_date)).strip("-") or "unknown-date" + safe_type = "daily" if row["review_type"] == "daily" else "weekly" + filename = f"ai_review_{safe_type}_{safe_target}_{row['id'][:8]}.md" + return _md_response(filename, md) + + +@app.route("/export/reviews_md_bundle") +@login_required +def export_reviews_md_bundle(): + review_type = (request.args.get("review_type") or "").strip().lower() + target_date = (request.args.get("target_date") or "").strip() + if review_type not in ("daily", "weekly"): + return Response("invalid review_type", status=400, mimetype="text/plain; charset=utf-8") + if not target_date: + return Response("target_date required", status=400, mimetype="text/plain; charset=utf-8") + + conn = get_db() + rows = conn.execute( + "SELECT * FROM ai_reviews WHERE review_type=? AND target_date=? ORDER BY created_at ASC, id ASC", + (review_type, target_date), + ).fetchall() + conn.close() + if not rows: + return Response("no reviews found", status=404, mimetype="text/plain; charset=utf-8") + + title = "日复盘" if review_type == "daily" else "周复盘" + lines = [ + f"# {title}汇总报告", + "", + f"- 目标日期: {target_date}", + f"- 条目数量: {len(rows)}", + f"- 导出时间: {app_now_str()}", + "", + "---", + "", + ] + for idx, row in enumerate(rows, 1): + created_at = row["created_at"] or "-" + content = (row["content"] or "").strip() or "(无内容)" + lines.extend( + [ + f"## 第{idx}条", + "", + f"- 报告ID: {row['id']}", + f"- 生成时间: {created_at}", + "", + content, + "", + "---", + "", + ] + ) + md = "\n".join(lines) + safe_target = re.sub(r"[^0-9A-Za-z_-]+", "-", str(target_date)).strip("-") or "unknown-date" + filename = f"ai_reviews_{review_type}_bundle_{safe_target}.md" + return _md_response(filename, md) + + +@app.route("/delete_review/", methods=["POST"]) +@login_required +def delete_review(rid): + conn = get_db() + conn.execute("DELETE FROM ai_reviews WHERE id=?", (rid,)) + conn.commit() + conn.close() + return jsonify({"ok": True}) + + +@app.route("/delete_trade_record/", methods=["POST"]) +@login_required +def delete_trade_record(rid): + conn = get_db() + cur = conn.execute("DELETE FROM trade_records WHERE id=?", (rid,)) + conn.commit() + conn.close() + return jsonify({"ok": cur.rowcount > 0, "deleted": cur.rowcount}) + + +@app.route("/api/trade_record_review_update", methods=["POST"]) +@login_required +def api_trade_record_review_update(): + payload = request.get_json(silent=True) or {} + rec_id = payload.get("id") + try: + rec_id = int(rec_id) + except Exception: + return jsonify({"ok": False, "msg": "记录ID无效"}), 400 + + reviewed_opened_at = str(payload.get("reviewed_opened_at") or "").strip() + reviewed_closed_at = str(payload.get("reviewed_closed_at") or "").strip() + reviewed_stop_loss_raw = payload.get("reviewed_stop_loss") + reviewed_take_profit_raw = payload.get("reviewed_take_profit") + reviewed_result = str(payload.get("reviewed_result") or "").strip() + reviewed_miss_reason = str(payload.get("reviewed_miss_reason") or "").strip() + reviewed_pnl_raw = payload.get("reviewed_pnl_amount") + + if reviewed_result and reviewed_result not in REVIEW_RESULT_OPTIONS: + return jsonify({"ok": False, "msg": "结果仅允许:止盈/止损/保本止盈/移动止盈/手动平仓"}), 400 + + try: + reviewed_open_dt = datetime.strptime(reviewed_opened_at[:19], "%Y-%m-%d %H:%M:%S") + reviewed_close_dt = datetime.strptime(reviewed_closed_at[:19], "%Y-%m-%d %H:%M:%S") + except Exception: + return jsonify({"ok": False, "msg": "开仓/平仓时间格式错误,需为 YYYY-MM-DD HH:MM:SS"}), 400 + if reviewed_close_dt < reviewed_open_dt: + return jsonify({"ok": False, "msg": "平仓时间不能早于开仓时间"}), 400 + hold_seconds = int((reviewed_close_dt - reviewed_open_dt).total_seconds()) + hold_minutes = calc_hold_minutes(hold_seconds) + + try: + reviewed_pnl_amount = float(reviewed_pnl_raw) + except Exception: + return jsonify({"ok": False, "msg": "盈亏必须为数字"}), 400 + reviewed_stop_loss = None + if reviewed_stop_loss_raw not in (None, ""): + try: + reviewed_stop_loss = float(reviewed_stop_loss_raw) + except Exception: + return jsonify({"ok": False, "msg": "止损必须为数字"}), 400 + reviewed_take_profit = None + if reviewed_take_profit_raw not in (None, ""): + try: + reviewed_take_profit = float(reviewed_take_profit_raw) + except Exception: + return jsonify({"ok": False, "msg": "止盈必须为数字"}), 400 + + _MISSING_ER = object() + reviewed_entry_reason_update = _MISSING_ER + if "reviewed_entry_reason" in payload: + s = str(payload.get("reviewed_entry_reason") or "").strip() + if s and not entry_reason_valid_for_storage(s): + return jsonify({"ok": False, "msg": "开仓类型须为五种固定整句之一、自定义说明(2000字内)或留空"}), 400 + reviewed_entry_reason_update = s or None + + conn = get_db() + row = conn.execute("SELECT risk_amount, symbol FROM trade_records WHERE id=?", (rec_id,)).fetchone() + if not row: + conn.close() + return jsonify({"ok": False, "msg": "记录不存在"}), 404 + risk_amount = row["risk_amount"] + ex_review = resolve_ccxt_price_symbol(row["symbol"]) + try: + ensure_markets_loaded() + except Exception: + pass + if reviewed_stop_loss is not None: + reviewed_stop_loss = round_price_to_exchange(ex_review, reviewed_stop_loss) + if reviewed_take_profit is not None: + reviewed_take_profit = round_price_to_exchange(ex_review, reviewed_take_profit) + actual_rr = calc_actual_rr(reviewed_pnl_amount, risk_amount) + base_params = [ + reviewed_opened_at, + reviewed_closed_at, + reviewed_stop_loss, + reviewed_take_profit, + round(reviewed_pnl_amount, 4), + reviewed_result or None, + reviewed_miss_reason or None, + hold_seconds, + hold_minutes, + app_now_str(), + actual_rr, + ] + if reviewed_entry_reason_update is not _MISSING_ER: + conn.execute( + """UPDATE trade_records + SET reviewed_opened_at=?, reviewed_closed_at=?, reviewed_stop_loss=?, reviewed_take_profit=?, reviewed_pnl_amount=?, + reviewed_result=?, reviewed_miss_reason=?, reviewed_hold_seconds=?, reviewed_hold_minutes=?, + reviewed_at=?, actual_rr=COALESCE(?, actual_rr), reviewed_entry_reason=? + WHERE id=?""", + tuple(base_params + [reviewed_entry_reason_update, rec_id]), + ) + else: + conn.execute( + """UPDATE trade_records + SET reviewed_opened_at=?, reviewed_closed_at=?, reviewed_stop_loss=?, reviewed_take_profit=?, reviewed_pnl_amount=?, + reviewed_result=?, reviewed_miss_reason=?, reviewed_hold_seconds=?, reviewed_hold_minutes=?, + reviewed_at=?, actual_rr=COALESCE(?, actual_rr) + WHERE id=?""", + tuple(base_params + [rec_id]), + ) + conn.commit() + conn.close() + return jsonify({"ok": True, "id": rec_id, "actual_rr": actual_rr, "hold_minutes": hold_minutes}) + + +@app.route("/manual_transfer", methods=["POST"]) +@login_required +def manual_transfer(): + try: + amount = float(request.form.get("amount", "0")) + except Exception: + flash("划转金额格式错误") + return redirect("/") + from_account = (request.form.get("from_account") or AUTO_TRANSFER_FROM).strip() + to_account = (request.form.get("to_account") or AUTO_TRANSFER_TO).strip() + ok, msg, _ = execute_transfer_usdt(amount, from_account, to_account) + conn = get_db() + conn.execute( + "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", + ("manual", get_trading_day(), amount, from_account, to_account, "success" if ok else "failed", msg[:500]) + ) + conn.commit() + conn.close() + if ok: + flash(f"手动划转成功:{amount}U {from_account}->{to_account}") + else: + flash(f"手动划转失败:{msg}") + return redirect("/") + + +@app.route("/ai_daily_review", methods=["POST"]) +@login_required +def ai_daily_review(): + date = request.form.get("date", "") + conn = get_db() + rows = conn.execute( + "SELECT * FROM journal_entries WHERE substr(open_datetime, 1, 10)=? ORDER BY open_datetime ASC", + (date,) + ).fetchall() + conn.close() + if not rows: + return jsonify({"result": "该日无交易记录"}) + + text = f"【每日交易记录】{date}\n总笔数:{len(rows)}\n\n" + for idx, row in enumerate(rows, 1): + text += _journal_row_lines_for_ai(idx, row) + text += "\n" + + image_paths = [] + for row in rows: + img = row["image"] + if not img: + continue + img_path = os.path.join(app.config["UPLOAD_FOLDER"], img) + if os.path.exists(img_path): + image_paths.append(img_path) + ai_result = ai_review(text, "每日", image_paths=image_paths) + full = f"【AI日复盘 {date}】\n{ai_result}\n\n原始记录:\n{text}" + conn = get_db() + conn.execute( + "INSERT INTO ai_reviews (id, review_type, target_date, content) VALUES (?,?,?,?)", + (uuid.uuid4().hex, "daily", date, full) + ) + conn.commit() + conn.close() + return jsonify({"result": full}) + + +@app.route("/ai_weekly_review", methods=["POST"]) +@login_required +def ai_weekly_review(): + start_date = request.form.get("start_date", "") + end_date = request.form.get("end_date", "") + conn = get_db() + rows = conn.execute( + "SELECT * FROM journal_entries WHERE substr(open_datetime,1,10) >= ? AND substr(open_datetime,1,10) <= ? ORDER BY open_datetime ASC", + (start_date, end_date) + ).fetchall() + conn.close() + if not rows: + return jsonify({"result": "该时间段无交易记录"}) + + text = f"【周交易记录】{start_date}~{end_date}\n总笔数:{len(rows)}\n\n" + for idx, row in enumerate(rows, 1): + text += _journal_row_lines_for_ai(idx, row) + text += "\n" + + image_paths = [] + for row in rows: + img = row["image"] + if not img: + continue + img_path = os.path.join(app.config["UPLOAD_FOLDER"], img) + if os.path.exists(img_path): + image_paths.append(img_path) + ai_result = ai_review(text, "周度", image_paths=image_paths) + full = f"【AI周复盘 {start_date}~{end_date}】\n{ai_result}\n\n原始记录:\n{text}" + conn = get_db() + conn.execute( + "INSERT INTO ai_reviews (id, review_type, target_date, content) VALUES (?,?,?,?)", + (uuid.uuid4().hex, "weekly", f"{start_date}~{end_date}", full) + ) + conn.commit() + conn.close() + return jsonify({"result": full}) + +# 启动 +if __name__ == "__main__": + threading.Thread(target=background_task, daemon=True).start() + app.run(host=HOST, port=PORT, debug=DEBUG) diff --git a/crypto_monitor_gate/ecosystem.config.cjs b/crypto_monitor_gate/ecosystem.config.cjs new file mode 100644 index 0000000..097bf52 --- /dev/null +++ b/crypto_monitor_gate/ecosystem.config.cjs @@ -0,0 +1,33 @@ +/** + * PM2 进程定义(Ubuntu / Linux)。 + * + * 仅托管 Flask 应用。**SSH SOCKS 隧道请在本机用 screen/tmux/systemd 等方式单独常驻**, + * 与 `.env` 里 `GATE_SOCKS_PROXY` 端口一致即可;不必交给 PM2。 + * + * 使用前:项目根目录存在 `.venv`,且已安装依赖(走 SOCKS 时需 PySocks)。 + * + * 启动: + * pm2 start ecosystem.config.cjs + * 保存开机列表: + * pm2 save && pm2 startup + */ +const path = require("path"); + +const ROOT = __dirname; +const PY = path.join(ROOT, ".venv", "bin", "python"); + +module.exports = { + apps: [ + { + name: "crypto_gate", + cwd: ROOT, + script: path.join(ROOT, "app.py"), + interpreter: PY, + instances: 1, + autorestart: true, + watch: false, + max_memory_restart: "800M", + // app.py 从项目根目录 .env 加载(由 .env.example 复制而来,勿提交 Git) + }, + ], +}; diff --git a/crypto_monitor_gate/scripts/backup_data.sh b/crypto_monitor_gate/scripts/backup_data.sh new file mode 100644 index 0000000..9a25287 --- /dev/null +++ b/crypto_monitor_gate/scripts/backup_data.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Daily backup: SQLite DB + static/images → /root/backups/// +# Prune backup folders older than RETENTION_DAYS (default 30). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$PROJECT_DIR" + +BACKUP_ROOT="${BACKUP_ROOT:-/root/backups}" +RETENTION_DAYS="${RETENTION_DAYS:-30}" +INSTANCE_NAME="${BACKUP_INSTANCE:-$(basename "$PROJECT_DIR")}" +TZ_NAME="${BACKUP_TZ:-Asia/Shanghai}" + +log() { + printf '[%s] %s\n' "$(TZ="$TZ_NAME" date '+%Y-%m-%d %H:%M:%S %Z')" "$*" +} + +read_env_var() { + local key="$1" + local default="$2" + local line + if [[ ! -f .env ]]; then + printf '%s' "$default" + return + fi + line="$(grep -E "^${key}=" .env 2>/dev/null | tail -1 || true)" + if [[ -z "$line" ]]; then + printf '%s' "$default" + return + fi + printf '%s' "${line#*=}" | tr -d '\r' +} + +resolve_project_path() { + local p="$1" + if [[ "$p" == /* ]]; then + printf '%s' "$p" + else + printf '%s' "$PROJECT_DIR/$p" + fi +} + +prune_old_backups() { + local base="$BACKUP_ROOT/$INSTANCE_NAME" + [[ -d "$base" ]] || return 0 + local cutoff + cutoff="$(TZ="$TZ_NAME" date -d "-${RETENTION_DAYS} days" +%Y-%m-%d 2>/dev/null || true)" + if [[ -z "$cutoff" ]]; then + find "$base" -mindepth 1 -maxdepth 1 -type d -mtime +"$RETENTION_DAYS" -print0 | + xargs -r -0 rm -rf + return 0 + fi + local dir name + for dir in "$base"/*/; do + [[ -d "$dir" ]] || continue + name="$(basename "$dir")" + [[ "$name" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || continue + if [[ "$name" < "$cutoff" ]]; then + log "prune: remove $dir (older than ${RETENTION_DAYS} days)" + rm -rf "$dir" + fi + done +} + +DB_REL="$(read_env_var DB_PATH crypto.db)" +UPLOAD_REL="$(read_env_var UPLOAD_DIR static/images)" +BACKUP_ROOT="$(read_env_var BACKUP_ROOT "$BACKUP_ROOT")" +RETENTION_DAYS="$(read_env_var BACKUP_RETENTION_DAYS "$RETENTION_DAYS")" +INSTANCE_NAME="$(read_env_var BACKUP_INSTANCE "$INSTANCE_NAME")" + +DB_PATH="$(resolve_project_path "$DB_REL")" +UPLOAD_DIR="$(resolve_project_path "$UPLOAD_REL")" +DATE_TAG="$(TZ="$TZ_NAME" date +%Y-%m-%d)" +DEST="$BACKUP_ROOT/$INSTANCE_NAME/$DATE_TAG" + +if [[ ! -f "$DB_PATH" ]]; then + log "error: database not found: $DB_PATH" + exit 1 +fi + +mkdir -p "$DEST" +log "start backup instance=$INSTANCE_NAME dest=$DEST" + +if command -v sqlite3 >/dev/null 2>&1; then + sqlite3 "$DB_PATH" ".backup '$DEST/crypto.db'" + log "db: sqlite3 backup -> $DEST/crypto.db" +else + cp -a "$DB_PATH" "$DEST/crypto.db" + log "db: cp -> $DEST/crypto.db (sqlite3 not installed)" +fi + +if [[ -d "$UPLOAD_DIR" ]]; then + tar -czf "$DEST/static_images.tar.gz" -C "$(dirname "$UPLOAD_DIR")" "$(basename "$UPLOAD_DIR")" + log "images: $UPLOAD_DIR -> $DEST/static_images.tar.gz" +else + log "warn: upload dir missing, skip images: $UPLOAD_DIR" +fi + +{ + echo "instance=$INSTANCE_NAME" + echo "project_dir=$PROJECT_DIR" + echo "backup_date=$DATE_TAG" + echo "db_path=$DB_PATH" + echo "upload_dir=$UPLOAD_DIR" +} >"$DEST/manifest.txt" + +prune_old_backups +log "done" diff --git a/crypto_monitor_gate/scripts/fix_breakeven_labels.py b/crypto_monitor_gate/scripts/fix_breakeven_labels.py new file mode 100644 index 0000000..80b7d04 --- /dev/null +++ b/crypto_monitor_gate/scripts/fix_breakeven_labels.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +""" +一次性修复历史交易记录标签: +将 trade_records 里“止损但实际盈利”的记录改为“保本止盈”。 + +默认条件(可通过参数修改): +- monitor_type = 下单监控 +- result = 止损 +- pnl_amount > 0 + +用法示例: +1) 仅预览(不落库): + python scripts/fix_breakeven_labels.py --db ./crypto.db --dry-run + +2) 执行修复: + python scripts/fix_breakeven_labels.py --db ./crypto.db --apply +""" + +from __future__ import annotations + +import argparse +import sqlite3 +import sys +from pathlib import Path + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Fix historical stop-loss records with positive pnl.") + parser.add_argument("--db", required=True, help="Path to sqlite db file, e.g. ./crypto.db") + parser.add_argument("--monitor-type", default="下单监控", help="Filter by monitor_type (default: 下单监控)") + parser.add_argument("--from-result", default="止损", help="Source result label (default: 止损)") + parser.add_argument("--to-result", default="保本止盈", help="Target result label (default: 保本止盈)") + parser.add_argument("--dry-run", action="store_true", help="Preview only, no write") + parser.add_argument("--apply", action="store_true", help="Execute update") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + db_path = Path(args.db).expanduser().resolve() + if not db_path.exists(): + print(f"[ERR] DB not found: {db_path}") + return 1 + + if args.dry_run and args.apply: + print("[ERR] --dry-run and --apply are mutually exclusive.") + return 1 + if not args.dry_run and not args.apply: + print("[INFO] No mode provided, defaulting to --dry-run.") + args.dry_run = True + + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + cur = conn.cursor() + + where_sql = """ + monitor_type = ? + AND result = ? + AND CAST(COALESCE(pnl_amount, 0) AS REAL) > 0 + """ + params = (args.monitor_type, args.from_result) + + cur.execute(f"SELECT COUNT(*) AS c FROM trade_records WHERE {where_sql}", params) + will_change = int(cur.fetchone()["c"]) + print(f"[INFO] Candidate rows: {will_change}") + + if will_change == 0: + print("[INFO] Nothing to update.") + conn.close() + return 0 + + cur.execute( + f""" + SELECT id, symbol, result, pnl_amount, closed_at + FROM trade_records + WHERE {where_sql} + ORDER BY id DESC + LIMIT 10 + """, + params, + ) + sample = cur.fetchall() + print("[INFO] Sample (latest 10):") + for r in sample: + print( + f" id={r['id']} symbol={r['symbol']} result={r['result']} " + f"pnl={r['pnl_amount']} closed_at={r['closed_at']}" + ) + + if args.dry_run: + print("[DRY-RUN] No write executed.") + conn.close() + return 0 + + cur.execute( + f"UPDATE trade_records SET result=? WHERE {where_sql}", + (args.to_result, *params), + ) + changed = int(cur.rowcount) + conn.commit() + conn.close() + print(f"[DONE] Updated rows: {changed}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) + diff --git a/crypto_monitor_gate/scripts/install_backup_cron.sh b/crypto_monitor_gate/scripts/install_backup_cron.sh new file mode 100644 index 0000000..2ebe5cc --- /dev/null +++ b/crypto_monitor_gate/scripts/install_backup_cron.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Install daily backup cron: Beijing 00:00 (CRON_TZ=Asia/Shanghai). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +BACKUP_SCRIPT="$SCRIPT_DIR/backup_data.sh" +INSTANCE_NAME="${BACKUP_INSTANCE:-$(basename "$PROJECT_DIR")}" +LOG_FILE="${BACKUP_CRON_LOG:-/var/log/crypto-monitor-backup-${INSTANCE_NAME}.log}" + +if [[ ! -x "$BACKUP_SCRIPT" ]]; then + chmod +x "$BACKUP_SCRIPT" +fi + +TMP="$(mktemp)" +trap 'rm -f "$TMP"' EXIT + +{ + crontab -l 2>/dev/null | grep -vF "$BACKUP_SCRIPT" || true + echo "CRON_TZ=Asia/Shanghai" + echo "0 0 * * * $BACKUP_SCRIPT >> $LOG_FILE 2>&1" +} >"$TMP" + +awk ' + BEGIN { tz = 0 } + /^CRON_TZ=Asia\/Shanghai$/ { + if (tz++) next + } + { print } +' "$TMP" >"${TMP}.2" +mv "${TMP}.2" "$TMP" + +crontab "$TMP" +echo "Installed cron for $INSTANCE_NAME" +echo " Schedule : daily 00:00 Asia/Shanghai" +echo " Script : $BACKUP_SCRIPT" +echo " Log : $LOG_FILE" +crontab -l | grep -F "$BACKUP_SCRIPT" || true diff --git a/crypto_monitor_gate/scripts/verify_gate_funding.py b/crypto_monitor_gate/scripts/verify_gate_funding.py new file mode 100644 index 0000000..bd410a8 --- /dev/null +++ b/crypto_monitor_gate/scripts/verify_gate_funding.py @@ -0,0 +1,93 @@ +""" +在项目根目录执行(会加载根目录 .env): + python scripts/verify_gate_funding.py + +依次探测:[0] swap 余额(与 App「交易账户」同源);[1]–[3] 现货 / 统一账户资金路径。 +打印 GATE_API_KEY 前 8 位便于与 Gate 控制台核对(不含 Secret)。用于服务器自检。 +""" +from __future__ import annotations + +import importlib.util +import os +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + + +def _load_app(): + path = os.path.join(ROOT, "app.py") + spec = importlib.util.spec_from_file_location("crypto_app", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def main(): + os.chdir(ROOT) + mod = _load_app() + print("LIVE_TRADING_ENABLED =", os.getenv("LIVE_TRADING_ENABLED")) + ok, reason = mod.ensure_exchange_live_ready() + print("ensure_exchange_live_ready =", ok, repr(reason)) + if not ok: + print("跳过私有接口探测") + return 1 + + mod.ensure_markets_loaded() + + k = (os.getenv("GATE_API_KEY") or "").strip() + s = (os.getenv("GATE_API_SECRET") or "").strip() + if not k or "REPLACE" in k.upper(): + print("WARN: GATE_API_KEY 为空或仍像占位符,请核对 .env") + if not s or "REPLACE" in s.upper(): + print("WARN: GATE_API_SECRET 为空或仍像占位符,请核对 .env") + print("GATE_API_KEY prefix (8 chars):", (k[:8] + "…") if len(k) > 8 else "(short)") + + # 0) swap — 与 App「交易账户」余额同源(优先看此项是否与网页一致) + try: + bal = mod.exchange.fetch_balance({"type": "swap"}) + v0 = mod._extract_usdt_total(bal) + print("[0] fetch_balance(swap) USDT total =", v0) + except Exception as e: + print("[0] fetch_balance(swap) FAILED:", type(e).__name__, e) + + # 1) fetch_balance spot + marginMode spot + try: + bal = mod.exchange.fetch_balance({"type": "spot", "marginMode": "spot"}) + v = mod._extract_usdt_total(bal) + print("[1] fetch_balance(spot,marginMode=spot) USDT total =", v) + except Exception as e: + print("[1] fetch_balance(spot) FAILED:", type(e).__name__, e) + + # 2) raw spot accounts + try: + resp = mod.exchange.privateSpotGetAccounts({}) + v2 = mod._parse_gate_spot_accounts_response_usdt(resp) + print("[2] privateSpotGetAccounts USDT =", v2) + except Exception as e: + print("[2] privateSpotGetAccounts FAILED:", type(e).__name__, e) + + # 3) unified accounts raw + try: + raw = mod.exchange.privateUnifiedGetAccounts({}) + body = raw + if isinstance(body, dict) and isinstance(body.get("result"), dict): + body = body["result"] + if isinstance(body, dict): + keys = sorted(body.keys()) + print("[3] unified top-level keys (sample):", keys[:25], "..." if len(keys) > 25 else "") + v3 = mod._parse_usdt_from_gate_unified_accounts_body(body) if isinstance(body, dict) else None + print("[3] parsed unified USDT =", v3) + except Exception as e: + print("[3] privateUnifiedGetAccounts FAILED:", type(e).__name__, e) + + fu = mod._fetch_gate_funding_usdt() + print(">>> _fetch_gate_funding_usdt() =", fu) + f, t = mod.get_exchange_capitals(force=True) + print(">>> get_exchange_capitals(force=True) funding, trading =", f, t) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/crypto_monitor_gate/start_utf8.ps1 b/crypto_monitor_gate/start_utf8.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..4a8fd0cbe425e232604b47af88dfdd3bd069e311 GIT binary patch literal 1080 zcmcJOPfNo<5XIkF@H;HE2R*dtK@q`&Xw`z$Vighf5YtpEZ4#PjE%@2h-|V)wLg`JG z%}!=^-u!#}{Z&;%BUZ*fQmj&avy5bTN3d>>Rqb|9`=^ ziritv`1L_9b;bRRb1}bDj~nW^(X~#&*vGq1bq?P*yhE*)Pf6qvIh!@2GOH+I7ig?M zh8$B(OgyJO?P{B~gZ#<91kLhpaSm8^`(DT1v*0@b>rn@+CRn+@-9U=5Stn9GgJRvj zr!MQLByc2!!S68TEpZWdu3%Duw#@f`@ir68`|Bqgs_G5LVW!+ho;o@wq2Zf)Y zv9wLcjA=F}hc-;kO*wJu-)oS;FZ0_H?X>%j{~oF$P>^5ug!bjzq%f<%yF$-KLi}HnE(I) literal 0 HcmV?d00001 diff --git a/crypto_monitor_gate/templates/index.html b/crypto_monitor_gate/templates/index.html new file mode 100644 index 0000000..191ce9f --- /dev/null +++ b/crypto_monitor_gate/templates/index.html @@ -0,0 +1,1911 @@ + + + + + {{ exchange_display }} · 加密货币 | 交易监控复盘系统 + + + +{% macro period_stats(title, s) %} +
+

{{ title }}

+
{{ s.range_label }}
+
+
开单次数
{{ s.opens_count }}
+
平仓笔数
{{ s.closed_count }}
+
胜率
{% if s.win_rate_pct is not none %}{{ s.win_rate_pct }}%{% else %}-{% endif %}
+
净盈亏(U)
{{ signed_usdt_fmt(s.net_pnl_u) }}
+
亏损额合计(U)
{{ usdt_fmt(s.loss_sum_u) }}
+
单笔最大亏损(U)
{% if s.max_single_loss is not none %}{{ signed_usdt_fmt(s.max_single_loss) }}{% else %}-{% endif %}
+
单笔最大盈利(U)
{% if s.max_single_profit is not none %}{{ usdt_fmt(s.max_single_profit) }}{% else %}-{% endif %}
+
最大回撤(U)
{{ usdt_fmt(s.max_drawdown_u) }}
+
当前连续亏损笔数
{{ s.consecutive_losses }}
+
最长连续亏损(交易日)
{{ s.max_loss_streak_days }} 天
+
期内最大亏损日
{% if s.worst_day %}{{ s.worst_day }}({{ signed_usdt_fmt(s.worst_day_pnl) }}U){% else %}-{% endif %}
+
+
+{% endmacro %} +
+
+

加密货币|交易监控 + AI复盘一体化

+
{{ exchange_display }}
+
+ + {% with msg=get_flashed_messages() %}{% if msg %}
{{ msg[0] }}
{% endif %}{% endwith %} + +
+ 列表筛选(UTC,默认当日):{{ list_window.label }} + + + + + + + 统计页仍按北京时间 {{ stats_bundle.stats_reset_hour|default(reset_hour) }}:00 切日 +
+
+ 数据导出(v{{ data_export_version }} CSV,UTF-8;交易记录含开仓类型列,复盘单独导出): + 交易记录 + 复盘记录 + 关键位(当前) + 关键位历史 +
+
+
交易所
{{ exchange_display }}
+
总交易
{{ total }}
+
错过次数
{{ miss_count }}
+
胜率
{{ rate }}%
+
资金账户(USDT)
{% if funding_usdt is not none %}{{ usdt_fmt(funding_usdt) }}U{% else %}—{% endif %}
+
交易日
{{ trading_day }}
+
当日资金(交易账户)
{{ usdt_fmt(current_capital) }}U
+
+
实时价格更新时间:--(北京时间 UTC+8)
+ +
+ {% if page == 'key_monitor' %} +
+
+
+

关键位监控

+ {% if focus_key_id %} + 放大查看K线(默认200根) + {% else %} + 输入币种查看K线 + {% endif %} +
+
+ + + + + + + + + +
+
{{ key_gate_rule_text }}
+
+ {% for k in key %} +
+
+
+ {{ k.symbol }} + {{ '做多' if k.direction == 'long' else '做空' }} + {{ k.monitor_type }} +
+ +
+
+ 上沿: {{ k.upper }} + 下沿: {{ k.lower }} + {% if k.fib_entry_price %}挂E: {{ k.fib_entry_price }}{% endif %} + 已提醒: {{ k.notification_count or 0 }}/{{ k.max_notify or 3 }} + {% if k.monitor_type in ['箱体突破','收敛突破'] %} + 方案: {{ '标准突破' if (k.sl_tp_mode or 'standard') == 'standard' else ('箱体1R·止盈1.5H' if k.sl_tp_mode == 'box_1p5' else '趋势单') }} + {% endif %} + 保本: {{ '开' if k.breakeven_enabled else '关' }} +
+
+
现价-
+
距上沿-
+
距下沿-
+
门控-
+
+
+
+ {% else %} +
暂无监控中的关键位
+ {% endfor %} +
+
+
+

关键位历史

+
失效或已结案的关键位
+
+ {% for h in key_history %} +
+
+
+ {{ h.symbol }} + {{ '做多' if h.direction == 'long' else '做空' }} +
+ +
+
+ {{ h.monitor_type }} + {{ h.close_reason }} + {{ (h.closed_at or '-')[:16] }} +
+
+ 上: {{ h.upper }} 下: {{ h.lower }} + 提醒: {{ h.notification_count }} +
+ {% if h.last_alert_message %}
{{ h.last_alert_message[:180] }}{% if h.last_alert_message|length > 180 %}…{% endif %}
{% endif %} +
+ {% else %} +
暂无历史
+ {% endfor %} +
+
+
+ {% elif page == 'trade' %} +
+
+
+

实盘下单监控

+ {% if focus_order_id %} + 放大查看K线(100根) + {% else %} + 暂无持仓可放大 + {% endif %} +
+
+ 规则:最多 {{ max_active_positions }} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x; + {% if can_trade %}可开仓{% else %}不可开仓(持仓已满或未到北京时间 {{ reset_hour }}:00){% endif %}; + 人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1 +
+
+ 以损定仓:风险 {{ risk_percent }}% |移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}% +
+
+ 划转:自动划转 {{ '开启' if auto_transfer_enabled else '关闭' }}(每天北京时间 {{ auto_transfer_bj_hour }}:00起该整点小时内尝试;账簿按 UTC 自然日去重;界面时间为北京;将 {{ auto_transfer_to }} 补足到 {{ auto_transfer_amount }}U,来自 {{ auto_transfer_from }}) +
+
+ + + + +
+
+ + + + + + + + 成交价自动取交易所实时+成交回报 + + + + + +
+
+
+

实时持仓

+
+ {% for o in order %} +
+
+
+ {{ o.exchange_symbol or o.symbol }} + {{ '做多' if o.direction == 'long' else '做空' }} +
+
+ + 平仓 +
+
+
+ 来源: {{ o.monitor_type|default('下单监控', true) }}{% if o.key_signal_type %} · {{ o.key_signal_type }}{% endif %} + 风格: {{ o.trade_style or 'trend' }} + 风险: {{ o.risk_percent or '-' }}%≈{% if o.risk_amount is not none %}{{ usdt_fmt(o.risk_amount) }}{% else %}-{% endif %}U + + {% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %} + +
+
+
+ 成交价 + {{ price_fmt(o.symbol, o.trigger_price) }} +
+
+ 止损 + {% if o.stop_loss %} + {{ price_fmt(o.symbol, o.stop_loss) }} + {% else %} + + {% endif %} +
+
+ 止盈 + {% if o.take_profit %} + {{ price_fmt(o.symbol, o.take_profit) }} + {% else %} + + {% endif %} +
+
+ 盈亏比 + {% if o.rr_ratio is not none %}{{ '%g'|format(o.rr_ratio) }}:1{% else %}-:1{% endif %} +
+
+ 标记价 + - +
+
+ 浮盈亏 + - +
+
+ +
+
交易所止盈止损
+
+ 止损:加载中… + +
+
+ 止盈:加载中… + +
+
+
+ {% else %} +
暂无持仓
+ {% endfor %} +
+
+ +
+
+

挂止盈止损

+

将先撤销该合约已有 TP/SL,再按下列价格重挂。

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ {% endif %} + + + + {% if page == 'records' %} +
+

交易记录 & 错过机会

+
+ 盈亏U:=交易所平仓历史, + =本地估算。 + {% if exchange_pnl_sync %} + {% if exchange_pnl_sync.skipped %}(25秒内已同步,可点右侧按钮强制){% else %} + 本轮:平仓历史 {{ exchange_pnl_sync.hist_count or 0 }} 条,对齐 {{ exchange_pnl_sync.matched or 0 }} 笔{% if exchange_pnl_sync.reason %} — {{ exchange_pnl_sync.reason }}{% endif %} + {% endif %} + {% endif %} + +
+
+ +
+
+ + + {% for r in record %} + + {% set pnl_val = (r.pnl_amount or 0)|float %} + + + + + {% set stop_show = r.display_open_stop_loss or r.initial_stop_loss or r.stop_loss %} + {% set tp_show = r.effective_take_profit or r.take_profit %} + + + + + + + + {% set pnl_val = (r.effective_pnl_amount or 0)|float %} + + + + + {% endfor %} +
品种类型方向成交止损(开仓)止盈基数杠杆持仓分钟开仓时间(北京)平仓时间(北京)盈亏U结果操作
{{ r.symbol }}{{ r.monitor_type }}{% if r.key_signal_type %} · {{ r.key_signal_type }}{% endif %}{{ '做多' if r.direction == 'long' else '做空' }}{{ price_fmt(r.symbol, r.trigger_price) }}{{ price_fmt(r.symbol, stop_show) }}{{ price_fmt(r.symbol, tp_show) }}{% if r.margin_capital is not none %}{{ usdt_fmt(r.margin_capital) }}{% else %}-{% endif %}{{ r.leverage or '-' }}{{ r.effective_hold_minutes or 0 }}{{ (r.effective_opened_at or '-')[:16] }}{{ (r.effective_closed_at or r.created_at or '-')[:16] }}{{ signed_usdt_fmt(r.effective_pnl_amount or 0) }}{% if r.display_pnl_source == 'exchange' %}{% elif r.display_pnl_source != 'reviewed' %}{% endif %} + {% set effective_result = r.effective_result %} + {% if effective_result in ["止盈","保本止盈","移动止盈"] %}{{ effective_result }} + {% elif effective_result in ["止损","强制清仓","手动平仓"] %}{{ effective_result }} + {% else %}{{ effective_result }}{% endif %} + + + + +
+
+
+ +
+

记录错过机会

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

交易复盘记录上传(含截图)

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

AI复盘(按交易记录)

+
+ + + + + + + +
+ + +
+
+ 交易复盘记录 +
+
+
+ AI历史复盘 +
+
+
+
+
+
+ {% endif %} + + + {% if page == 'stats' %} +
+
+

数据统计

+ +
+
+
+
持仓占用导致错过(累计)
{{ occupied_miss_total }}
+
+
+ 统计分析按北京时间 {{ stats_bundle.stats_reset_hour }}:00切日计入(与顶栏 UTC 列表窗无关)。历史总开仓(累计): + {{ stats_bundle.total_opens_all }} 次 +
+
+ +
+ {% for seg in stats_bundle.segments %} + + {% endfor %} +
+
+ {% endif %} + + + +
+
+
+
详情
+ +
+
+ +
+
+ + + + \ No newline at end of file diff --git a/crypto_monitor_gate/templates/key_focus.html b/crypto_monitor_gate/templates/key_focus.html new file mode 100644 index 0000000..41a633a --- /dev/null +++ b/crypto_monitor_gate/templates/key_focus.html @@ -0,0 +1 @@ +ok2 \ No newline at end of file diff --git a/crypto_monitor_gate/templates/key_focus_v2.html b/crypto_monitor_gate/templates/key_focus_v2.html new file mode 100644 index 0000000..d4b3492 --- /dev/null +++ b/crypto_monitor_gate/templates/key_focus_v2.html @@ -0,0 +1,261 @@ + + + + + {{ exchange_display }} | 关键位放大 + + + +
+
+
+
+ 返回首页 + 关键位放大(可输入币种){{ exchange_display }} +
+
最近刷新:--
+
+ +
+ + + + + + + + + + + + + + +
+
+ +
+
+
交易对
-
+
监控类型
-
+
方向
-
+
上沿/阻力
-
+
下沿/支撑
-
+
现价
-
+
距上沿
-
+
距下沿
-
+
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/crypto_monitor_gate/templates/login.html b/crypto_monitor_gate/templates/login.html new file mode 100644 index 0000000..cfcc816 --- /dev/null +++ b/crypto_monitor_gate/templates/login.html @@ -0,0 +1,118 @@ + + + + + 登录 · {{ exchange_display }} + + + + + + diff --git a/crypto_monitor_gate/templates/order_focus.html b/crypto_monitor_gate/templates/order_focus.html new file mode 100644 index 0000000..c0992d4 --- /dev/null +++ b/crypto_monitor_gate/templates/order_focus.html @@ -0,0 +1,194 @@ + + + + + 实盘下单放大 | 100根K线 + + + +
+
+
+
+ 返回首页 + 实盘下单放大(100根K线) +
+
最近刷新:--
+
+ {% if orders %} +
+ + + + + + +
+ {% else %} +
当前没有激活订单,无法展示放大K线。
+ {% endif %} +
+ + {% if orders %} +
+
+
交易对
-
+
方向
-
+
成交价
-
+
止损
-
+
止盈
-
+
盈亏比
-
+
现价
-
+
浮盈亏
-
+
+
+ +
+
+
+ {% endif %} +
+ +{% if orders %} + + +{% endif %} + + diff --git a/crypto_monitor_gate/templates/order_focus_v2.html b/crypto_monitor_gate/templates/order_focus_v2.html new file mode 100644 index 0000000..f9bceab --- /dev/null +++ b/crypto_monitor_gate/templates/order_focus_v2.html @@ -0,0 +1,214 @@ + + + + + {{ exchange_display }} | 实盘下单放大 + + + +
+
+
+
+ 返回首页 + 实盘下单放大(100根K线){{ exchange_display }} +
+
最近刷新:--
+
+ {% if orders %} +
+ + + + + + +
+ {% else %} +
当前没有激活订单,无法展示放大K线。
+ {% endif %} +
+ + {% if orders %} +
+
+
交易对
-
+
方向
-
+
成交价
-
+
止损
-
+
止盈
-
+
盈亏比
-
+
移动保本
-
+
现价
-
+
浮盈亏
-
+
+
+ +
+
+
+ {% endif %} +
+ +{% if orders %} + + +{% endif %} + + + diff --git a/crypto_monitor_gate/使用说明.md b/crypto_monitor_gate/使用说明.md new file mode 100644 index 0000000..e98c68d --- /dev/null +++ b/crypto_monitor_gate/使用说明.md @@ -0,0 +1,143 @@ +# 使用说明 + +**本文件对应仓库:`crypto_monitor_gate`(Gate.io USDT 永续)。** +功能、界面与 **Binance U 本位版**(目录 `crypto_monitor_binance`)基本一致,差异主要在 **`.env` 里交易所密钥与部分参数名**(`GATE_*` / `BINANCE_*`),文末有对照。 + +**更细的部署(SSH 代理、PM2、依赖安装)** 见同目录 **`部署文档.md`**。 +**关键位自动开仓的规则、RR、结案原因** 见 **`关键位自动下单说明.md`**。 + +--- + +## 1. 它能做什么 + +面向个人盘面的 **Web 控制台**,主要能力包括: + +| 模块 | 说明 | +|------|------| +| **关键位监控** | 录入上/下沿与类型,按 **5m 收线** 做硬条件过滤;符合条件后 **企业微信** 提醒,部分类型可 **自动市价开仓**(见第 4 节与专门文档)。 | +| **实盘下单监控** | 手工填止损/止盈,**以损定仓** 市价开单,挂上条件止盈止损,并在页面跟踪浮盈亏、保本逻辑等。 | +| **交易记录 / 复盘** | 平仓结果、盈亏、错过的单等归档与导出。 | + +后台按 **`MONITOR_POLL_SECONDS`**(默认几秒)轮询行情与监控逻辑。**切勿**在未理解规则时同时运行两套程序共用一个实盘账户。 + +--- + +## 2. 运行前必须配置(`.env`) + +首次在本目录执行 **`cp .env.example .env`**,再编辑 `.env`(`.env` 勿提交 Git;`git pull` 不会改你的 `.env`,升级前建议 `cp .env .env.backup.$(date +%Y%m%d)`)。 + +至少检查以下项(具体键名以 **`.env.example`** 为准): + +| 类别 | 说明 | +|------|------| +| **登录网页** | `APP_PASSWORD`:打开站点后的登录口令。`FLASK_SECRET_KEY`:Session 密钥,请勿使用默认值。 | +| **企业微信** | `WECHAT_WEBHOOK`:告警与关键位推送机器人的 Webhook。 | +| **是否真下单** | `LIVE_TRADING_ENABLED=false`:**不会**向交易所发送开仓指令(适合测试流程)。改为 `true` 且密钥正确才会实盘。 | +| **交易所 API** | **本仓库:** `GATE_API_KEY`、`GATE_API_SECRET`;合约相关见 `GATE_MARGIN_MODE`、`GATE_POS_MODE`、`GATE_TPSL_*` 等。**勿**把 `.env` 提交到 Git。 | +| **关键位 RR / 止损外扩** | `KEY_AUTO_MIN_PLANNED_RR`、`KEY_STOP_OUTSIDE_BREAKOUT_PCT`(详见 `关键位自动下单说明.md`)。 | + +网络不稳定时可为 Gate 配置 **`GATE_SOCKS_PROXY`** 等(见 **`部署文档.md`**)。 + +--- + +## 3. 如何启动与登录 + +1. 按 **`部署文档.md`** 建好虚拟环境、安装依赖(如 `flask`、`requests`、`ccxt`、按需 `Pillow`、`PySocks` 等),配置好 `.env`。 +2. 启动 Flask 应用(本仓库可用 **`ecosystem.config.cjs`** 交给 PM2,或本地 `python app.py` / `flask run`,以你当前脚本为准)。 +3. 浏览器访问站点,打开 **`/login`**,使用 **`.env` 里的 `APP_PASSWORD`** 登录。 + +登录后顶栏为四页:**关键位监控** | **实盘下单**(默认首页)| **交易记录与复盘** | **统计分析**。 + +--- + +## 4. 关键位监控(顶栏「关键位监控」→ `/key_monitor`) + +### 4.1 添加一条关键位 + +1. **币种**:如 `BTC` 或 `BTC/USDT`(会规范成内部符号)。 +2. **类型**(必选其一): + + | 类型 | 行为摘要 | + |------|----------| + | **箱体突破** | 通过门控且计划 RR 达标 → **自动市价开仓**(需 `LIVE_TRADING_ENABLED=true` 且无其他持仓占位)。结案后本条从列表消失并记入历史。 | + | **收敛突破** | 同上(自动开仓类)。 | + | **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 | + | **关键支撑位** | 同上(仅提醒)。 | + +3. **方向**:做多 / 做空(必选)。 +4. **上沿 / 下沿**:必填;保存时会按交易所 **价格精度** 取整。 + +**限制:** +活跃持仓数达到 **`MAX_ACTIVE_POSITIONS`**(默认 1)时,**不允许**再添加「**箱体突破** / **收敛突破**」;仍可添加「**关键阻力位 / 支撑位**」。 +若 **4h EMA55** 与你的方向逆势,页面会 **额外 Flash 提示**,**不阻挡**提交。 + +### 4.2 触发后会发生什么(简版) + +- **箱体 / 收敛**:门控通过后计算计划 SL/TP 与 RR;不达标则 **微信说明 + `rr_insufficient` 结案**;达标则尝试 **市价开仓**,成功 **`auto_opened`**,失败 **`exchange_failed`**——均 **不重试同一关键位**。 +- **阻力 / 支撑**:仅 **单次推送** → **`key_level_alert_only`** 结案。 + +详细公式、结案字段、与企业微信文案口径见 **`关键位自动下单说明.md`**。 + +### 4.3 列表与历史 + +- 当前条目可 **删除**(会按规则记入历史的情形见页面说明)。 +- **关键位历史**:已结案记录;可配合导出链接(若有)做备份。 + +--- + +## 5. 实盘下单(顶栏「实盘下单」→ `/trade`) + +用于 **自己点按钮** 开单: + +- 持仓上限由 **`MAX_ACTIVE_POSITIONS`** 控制(默认 1,与关键位自动单共用)。 +- **人工开仓**时计划盈亏比不得低于 **`MANUAL_MIN_PLANNED_RR`**(默认 1.4:1),否则页面弹窗且后端拒绝。 +- 填写币种、方向、杠杆(可选)、止损/止盈(价格或百分比按表单说明)。 +- 勾选是否启用 **移动保本** 等行为以 `.env`/页面默认值为准。 + +平仓通过页面 **平仓**(或等价入口),会从交易所市价处理并更新记录。**删除/误操作可能造成真实盈亏**,请先确认环境与方向。 + +开仓成功后持仓卡片上会显示 **「来源」**:手工单一般为 **下单监控**;来自关键位自动单的为 **关键位监控**。 + +--- + +## 6. 企业微信会看到什么 + +- 关键位:按类型与结案结果推送(RR 不足、下单失败、自动开仓成功、仅阻力支撑提醒等),**每条关键位结案路径原则上一条主推送**(详见 `关键位自动下单说明.md`)。 +- 手工开仓、平仓、部分异常也会在规则满足时推送(以代码与配置为准)。 + +若未配置 **`WECHAT_WEBHOOK`** 或网络失败,可能只是看不到推送,不代表逻辑未执行;要紧操作请以 **交易所端持仓与挂单** 为准核对。 + +--- + +## 7. 强烈建议的风险与运维习惯 + +1. **先用 `LIVE_TRADING_ENABLED=false`** 验证页面、录入、推送,再开小资金开实盘。 +2. **API 权限**:仅开所需合约权限;勿泄露密钥;定期轮换。 +3. **单进程控盘**:同一账户避免本程序与其他机器人 **重复开仓**。 +4. **自动备份**:服务器上执行 `bash scripts/install_backup_cron.sh`(每天北京时间 0:00 → `/root/backups`,保留 30 天);升级前也可 `bash scripts/backup_data.sh` 手动跑一次。 +5. **升级代码后**:启动时会跑 **数据库迁移**(如新列 `order_monitors.monitor_type`);首次启动关注一下日志或无报错页面。 + +--- + +## 8. 常见问题(简要) + +| 现象 | 可自查 | +|------|--------| +| 关键位永远不触发 | 5m 门控是否全通过(页面门控摘要)、币种日成交量是否在规则内、`KLINE_TIMEFRAME`。 | +| 有信号但不自动开仓 | `LIVE_TRADING_ENABLED`、`KEY_AUTO_MIN_PLANNED_RR`、计划 RR、是否已有持仓、API/余额报错(微信或日志)。 | +| 加不了箱体/收敛 | 是否已有活跃持仓;先平仓或改用「阻力/支撑位」仅提醒。 | +| 推送收不到 | `WECHAT_WEBHOOK`、企业微信机器人配额与网络。 | + +--- + +## 9. Binance 版(`crypto_monitor_binance`)差异速查 + +| 项目 | Gate 本仓库 | Binance 版 | +|------|-------------|------------| +| API 变量 | `GATE_API_KEY`、`GATE_API_SECRET`、`GATE_*` | `BINANCE_API_KEY`、`BINANCE_API_SECRET`、`BINANCE_*` | +| 实盘开关 | `LIVE_TRADING_ENABLED`(通用) | 同上 | +| 止盈止损挂载路径 | `_gate_place_tp_sl_orders` 与 `GATE_TPSL_*` | `_binance_place_tp_sl_orders`(U 本位条件单) | +| 资金显示舍入 | 以本仓库为准 | 与 **`FUNDS_DECIMALS`** 等一致 | +| 专门文档 | **`关键位自动下单说明.md`**(各仓库有一份,开头标明交易所) | 同左 | + +操作流程(登录、关键位四类、手工单、单仓)**两份程序一致**:换目录、换 `.env` 即可对照使用。 diff --git a/crypto_monitor_gate/关键位自动下单说明.md b/crypto_monitor_gate/关键位自动下单说明.md new file mode 100644 index 0000000..5ae3f80 --- /dev/null +++ b/crypto_monitor_gate/关键位自动下单说明.md @@ -0,0 +1,98 @@ +# 关键位自动下单说明 + +**适用仓库:`crypto_monitor_gate`|交易所:Gate.io USDT 永续**(Binance 版见同名的 `crypto_monitor_binance` 目录。) + +本文档与 `.env`、`app.check_key_monitors`、`app.add_key`、`_market_open_for_key_monitor` 的实现一致。 + +--- + +## 结构与是否自动开仓 + +| `key_monitors.monitor_type`(录入类型) | 自动下单 | 触发后处置 | +|---------------------------------------|----------|------------| +| **箱体突破** | 是(满足全部条件) | **一次性结案**:写 `key_monitor_history` → 从 `key_monitors` **删除** | +| **收敛突破** | 是(同上) | 同上 | +| **关键阻力位** | 否 | 企业微信 **1 次** → `close_reason=key_level_alert_only` → **失效** | +| **关键支撑位** | 否 | 同上 | + +触发条件:**5m 收线硬门控** `_key_hard_checks`(量能、突破幅度、第二根收盘确认、日成交量前 30 等)。 + +--- + +## 录入限制(`/add_key`) + +- 存在 **`order_monitors.status='active'`** 时:**禁止添加** 「箱体突破」「收敛突破」。 +- **关键阻力位 / 关键支撑位**:不受上条限制;触发后 **仅单次微信提醒**,然后结案。 +- **4h EMA55 与所选方向逆势**:**不拦截**;添加成功后 **Flash** 提示。 +- 上下沿入库前经 **`round_price_to_exchange`** 按合约 **价格精度** 取整。 + +--- + +## 环境与参数(`.env`) + +| 变量 | 含义 | 默认 | +|------|------|------| +| `KEY_AUTO_MIN_PLANNED_RR` | 计划 RR 阈值:**仅当严格大于该值** 才自动开仓(按下方 `E` 计算) | `1.5` | +| `KEY_STOP_OUTSIDE_BREAKOUT_PCT` | 止损:突破 K 极值向外 **百分比**(多:`低×(1−p/100)`;空:`高×(1+p/100)`) | `0.5` | + +**其余与本仓库手动实盘一致:** `KLINE_TIMEFRAME`、`RISK_PERCENT`、`LIVE_TRADING_ENABLED`、`BREAKEVEN_*`、`DAILY_OPEN_ALERT_THRESHOLD`,以及 **`GATE_*`**(密钥、止盈止损触发、`GATE_TPSL_*` 等)。 + +--- + +## 计价与下单口径 + +| 用途 | 价格 | +|------|------| +| 企业微信展示、**与 RR 门槛比较的计划 RR** | 确认 K(第二根闭合 5m)收盘 **`E`** | +| **实际开仓** | **市价**(`place_exchange_order`,与 `/add_order` 一致);成交价可能与 `E` **滑点** | +| **以损定仓** | `calc_risk_fraction(direction, 当前市价, 止损)` + `RISK_PERCENT`(与 `/add_order` 一致) | + +- 开仓成功后:`order_monitors.monitor_type` 为 **关键位监控**;持仓卡片「来源」显示之。手动开仓为 **下单监控**。 +- 持仓列表中的 **盈亏比**:按 **实际成交价** 相对 SL/TP 重算,可与「按 `E` 算的计划 RR」略有偏差。 +- **本仓库止盈止损挂单**:开仓后由 **`_gate_place_tp_sl_orders`** 挂载(仓位类 `price_orders` 或备选条件路径,逻辑与手动一致);细节受 `GATE_TPSL_USE_POSITION_ORDER`、`GATE_TPSL_PRICE_TYPE` 等影响。 + +--- + +## 自动单止盈 / 止损(仅箱体突破、收敛突破) + +设箱体高度 **`H = |upper − lower|`**(录入上下沿)。 + +| 方向 | 止损 SL | 止盈 TP | +|------|---------|---------| +| 多 | 突破 K **最低价** × (1 − `KEY_STOP_OUTSIDE_BREAKOUT_PCT`/100) | **`E + 1×H`** | +| 空 | 突破 K **最高价** × (1 + `KEY_STOP_OUTSIDE_BREAKOUT_PCT`/100) | **`E − 1×H`** | + +计划 **`RR = calc_rr_ratio(direction, E, SL, TP)`**。若为 `None` 或 **RR ≤ `KEY_AUTO_MIN_PLANNED_RR`** → **不下单**,走 `rr_insufficient` 结案。 + +--- + +## 一次性结案(`close_reason`) + +以下任一发生:**按需发微信** → **`key_monitor_history`** → **从 `key_monitors` 删除**;**不会对同一条关键位重复轮询重试开仓**。 + +| `close_reason` | 含义 | +|----------------|------| +| `rr_insufficient` | 门控通过,但计划 RR 未达标或 SL/TP / RR **几何无效** | +| `exchange_failed` | 计划 RR 达标,但未开实盘、`LIVE_TRADING_ENABLED=false`、风控、保证金或 **交易所报错** 等导致 **开仓失败** | +| `auto_opened` | 计划 RR 达标且 **市价开仓成功**(已写 `order_monitors`,并已挂止盈止损) | +| `key_level_alert_only` | 阻力/支撑位 **仅推送**结案 | + +--- + +## 与企业微信推送 + +每种结案路径 **至多一条**主业务推送(RR 不足 / 下单失败 / 开仓成功 / 阻力支撑仅提醒)。 + +旧版「满 `KEY_ALERT_MAX_TIMES` 次再归档」对已触发结案的路径 **不再适用**;表中 `notification_count`、`max_notify` 等字段仍可能存在,以 **导出、兼容** 为主。 + +--- + +## 相关代码位置(通用) + +| 说明 | 符号 | +|------|------| +| 门控与主循环 | `check_key_monitors` | +| 录入、有仓拦截、4h Flash | `add_key` | +| 市价开仓 + 写 `order_monitors` | `_market_open_for_key_monitor` | +| 计划 RR | `calc_rr_ratio(direction, E, SL, TP)` | +| 价格精度 | `round_price_to_exchange` | diff --git a/crypto_monitor_gate/更新文档.md b/crypto_monitor_gate/更新文档.md new file mode 100644 index 0000000..f29eb12 --- /dev/null +++ b/crypto_monitor_gate/更新文档.md @@ -0,0 +1,148 @@ +# 界面与风控更新说明(Gate 实例) + +## 顶栏导航(4 项) + +| 顺序 | 名称 | 路由 | 说明 | +|------|------|------|------| +| 1 | 关键位监控 | `/key_monitor` | 关键位添加、实时门控、历史 | +| 2 | 实盘下单 | `/trade` | 人工开仓、划转、实时持仓(**默认首页** `/` → `/trade`) | +| 3 | 交易记录与复盘 | `/records` | 交易记录、复盘表单、AI 历史(受顶栏 UTC 时间窗筛选) | +| 4 | 统计分析 | `/stats` | 按北京时间交易日切日 + 分品类统计块 | + +## 关键位监控页 + +- 标题去掉「5m」;规则条从 `.env` 读取(周期、确认K、量能、自动开仓盈亏比、日成交量排名)。 +- 左列:活跃关键位,**pos-card** 样式展示现价/距上沿/距下沿/门控。 +- 右列:关键位历史(失效/结案),与左列等高滚动;**受顶栏 UTC 列表时间窗筛选**(默认 UTC 当日)。 +- 监控类型新增:**斐波回调0.618**、**斐波回调0.786**(与 Binance 主站同一套规则,计算逻辑见仓库根目录 `fib_key_monitor_lib.py`)。 + +### 斐波关键位监控(方案 A:交易所限价) + +| 项 | 说明 | +|----|------| +| 同币互斥 | 每个币种只能有一条斐波监控(0.618 与 0.786 不可并存) | +| 上下沿 | 上沿 **H**、下沿 **L**(须 H > L) | +| 挂单价 E | **做多** `E = H − ratio × (H − L)`(自 H 向下回撤);**做空** `E = L + ratio × (H − L)`(自 L 向上反弹) | +| 做多 | 限价 @ E,止损 L,止盈 H | +| 做空 | 限价 @ E,止损 H,止盈 L | +| 添加后 | **立即**在 Gate 挂限价单;卡片显示 **挂E**、限价单 ID | +| 失效 | 以**标记价**判断:做多且标记价 ≥ H、做空且标记价 ≤ L,且限价**未成交** → 撤销该限价单并结案(不写历史开仓) | +| 成交后 | 按仓位挂交易所 TP/SL → 写入 **实盘下单监控**(`monitor_type=关键位监控`,`key_signal_type=斐波回调0.618/0.786`)→ 从关键位列表移除 | +| 撤单 | 仅撤本条斐波的 `fib_limit_order_id`,**不会** `cancel_all`,避免误伤其他委托 | +| 盈亏比 | 计划 RR 须 > `KEY_AUTO_MIN_PLANNED_RR`(与箱体/收敛一致);0.618 理论约 1.6:1,0.786 约 3.7:1 | +| 日成交量 | 与箱体/收敛相同,须在前 `KEY_DAILY_VOLUME_RANK_MAX` 名内方可添加 | + +后台轮询:`check_fib_key_monitors()`(标记价失效 / 成交检测);箱体/收敛仍走 `check_key_monitors()`,互不干扰。 + +手动删除关键位时,若斐波限价尚未成交,会先撤交易所限价再删库记录。 + +### 箱体 / 收敛自动开仓(来源标注) + +- 自动开仓写入 `order_monitors.key_signal_type`:`箱体突破` 或 `收敛突破`。 +- 持仓卡片、交易记录列表会显示「来源 · 信号类型」。 + +## 列表时间窗(UTC,全站顶栏) + +共用模块:仓库根目录 `history_window_lib.py`(Gate / Binance 主站一致)。 + +| 项 | 说明 | +|----|------| +| 默认 | **UTC 当日**(`win_preset=utc_today`,从 UTC 0:00 至当前时刻) | +| 可选 | 近 24 小时、近 7 天、自定义起止(UTC,`datetime-local`) | +| 作用范围 | 关键位历史、交易记录列表、复盘记录 API、AI 历史 API、导出「交易记录」「关键位历史」 | +| 与统计的关系 | **仅影响列表/导出**;**统计分析页仍按北京时间 `TRADING_DAY_RESET_HOUR`(默认 8:00)切交易日** | +| 库内时间 | DB 存北京时间字符串;后端用 `utc_window_to_bj_sql_strings()` 换算后再 SQL 比较 | +| 切换方式 | 顶栏「列表筛选(UTC)」→ 选预设 → **应用**(保留当前路由,如 `/records?win_preset=…`) | + +查询参数示例: + +- `?win_preset=utc_today` +- `?win_preset=utc_last24h` / `utc_last7d` +- `?win_preset=custom&from_utc=2026-05-18 00:00:00&to_utc=2026-05-19 12:00:00` + +## 交易记录与复盘 + +- 平仓记录可同步交易所已实现盈亏(Gate 仓位历史等);列表盈亏列优先显示交易所数据,标注 **所** / **估**。 +- 记录页提供 **立即同步**(`POST /api/sync_exchange_pnl`),用于补全或刷新 `exchange_realized_pnl` 等字段。 +- 未做人工复盘时,展示以交易所盈亏为准(有同步数据时)。 +- **列表默认只显示当前 UTC 时间窗内**的记录(见上节);导出 CSV 同步该时间窗。 +- 表头 **「止损(开仓)」**:展示开仓快照 `initial_stop_loss`(无则回退 `stop_loss`);核对/复盘仍可用有效止损字段。 +- 平仓写入 `trade_records` 时:`stop_loss` 与 `initial_stop_loss` 均写入**开仓时止损快照**;`key_signal_type` 保留箱体/收敛/斐波来源(`fib_key_monitor_lib.key_signal_type_for_trade_record`)。 +- **开仓类型**(`entry_reason`):机器单平仓入库时,若未手填,按 `key_signal_type` 自动映射(见下表);列表/导出「开仓类型」列 = 复盘核对值优先,否则入库值,否则按信号映射。 + +| `key_signal_type` | 自动写入的 `entry_reason` | +|-------------------|---------------------------| +| 箱体突破 | 关键位箱体突破 | +| 收敛突破 | 关键位收敛突破 | +| 斐波回调0.618 | 关键位斐波0.618 | +| 斐波回调0.786 | 关键位斐波0.786 | + +- 复盘表单 **开仓类型** 下拉新增上述四条固定文案(与趋势/波段类并列)。 +- 复盘 **离场触发** 新增 **「止盈」**;从交易记录「填入复盘」时,若结果为「止盈/保本止盈/移动止盈/止损/手动平仓」会自动选中对应触发项,并按 `key_signal_type` 预填开仓类型。 +- 勾选「保存时自动生成多周期 K 线图」时:以 **平仓时间** 为锚点,各周期向前约 `ORDER_CHART_LIMIT`(默认 100)根 K 线(`_fetch_ohlcv_ending_at`),不再固定拉「最近 100 根」。 +- `/api/journals`、`/api/reviews` 支持同一时间窗 query,与列表一致。 + +### 导出(交易记录 v3) + +- 文件名:`trade_records_v3_YYYYMMDD.csv` +- 相对 v2 增加:`key_signal_type`、`initial_stop_loss`(及开仓快照列)、`planned_rr`、`actual_rr`、`risk_amount`、交易所盈亏与时间字段等;末列「开仓类型」为有效展示文案。 +- 「关键位历史」导出同样受 UTC 时间窗限制。 + +## 实盘下单页 + +- 左列:实盘下单监控(表单、划转、规则)。 +- 右列:实时持仓(独立模块)。 +- **人工开仓门控**:计划盈亏比 < `MANUAL_MIN_PLANNED_RR`(默认 **1.4**)时前端弹窗 + 后端拒绝。 +- **移动保本**(勾选启用):监控轮询达到触发 RR 后,止损阶梯上移时**同步交易所**——调用与页面「挂止盈止损」相同的 **先撤后挂**(`replace_active_monitor_tpsl_on_exchange`:撤该合约全部 TP/SL 条件单 → 按新止损 + 原止盈重挂)。仅交易所成功后才写库;失败发企业微信告警,本地止损不变。未配置实盘 API 时仍只更新本地(与旧行为一致)。 + +## 统计分析页(`/stats`) + +| 项 | 说明 | +|----|------| +| 切日 | **北京时间**;交易日边界 = 每日 `TRADING_DAY_RESET_HOUR:00`(`.env` 默认 **8**) | +| 品类下拉 | 页顶 **「统计品类」** 下拉切换(默认「全部交易」):全部交易、下单监控、关键位箱体突破、关键位收敛结构、关键位斐波0.618、关键位斐波0.786;一次只显示所选品类的日/周/月 | +| URL | 切换后写入 `stats_segment=`(如 `all`、`manual`、`key_box`、`key_conv`、`key_fib618`、`key_fib786`),刷新 `/stats` 可保持选项 | +| 每块指标 | 日 / 周 / 月:开单次数、平仓笔数、胜率、净盈亏、回撤、连续亏损等(与原口径一致) | +| 开单次数 | 人工块:`monitor_type=下单监控` 且无 `key_signal_type`;关键位块:按 `order_monitors.key_signal_type` 计数 | +| 不受 UTC 窗影响 | 统计始终基于库内全部已平仓记录,按北京交易日归类,**不**随顶栏 UTC 列表窗切换 | + +## 持仓与计仓 + +- `MAX_ACTIVE_POSITIONS` 默认 **1**(可在 `.env` 调大)。 +- 关键位自动开仓:在已有持仓时,若 `KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT=true`,按**首笔开仓前**交易账户资金快照计仓(`trading_sessions.key_sizing_capital_snapshot`)。 + +## 配置 + +详见 `.env.example` 中「关键位门控」「交易执行 / 人工风控」注释段。Gate 专用项(`GATE_*`、止盈止损触发等)保持原有段落不变。 + +## 自动备份(服务器) + +- 脚本:`scripts/backup_data.sh`(`crypto.db` + `static/images`) +- 定时:`scripts/install_backup_cron.sh` → 每天 **北京时间 0:00**,目录 **`/root/backups/<实例名>/YYYY-MM-DD/`**,保留 **30** 天 +- 详见 `部署文档.md` 第 5.3 节 + +## 数据库(启动时自动迁移) + +`key_monitors` 新增斐波字段(示例):`fib_limit_order_id`、`fib_entry_price`、`fib_stop_loss`、`fib_take_profit`、`fib_order_amount`、`fib_margin_capital`、`fib_leverage`。 + +`trade_records` / `order_monitors` 新增或沿用:`key_signal_type`、`exchange_realized_pnl`、`exchange_opened_at`、`exchange_closed_at`、`exchange_sync_key`、`entry_reason`、`reviewed_entry_reason`、`initial_stop_loss`。 + +**历史数据**:本次**不做**旧记录的批量回填(`entry_reason` / `initial_stop_loss` / `key_signal_type` 等);仅**新产生**的平仓与复盘按新逻辑写入。旧行展示可回退已有字段。 + +## 涉及文件(便于排查) + +| 路径 | 说明 | +|------|------| +| `history_window_lib.py` | UTC 时间窗解析与转北京时间 SQL 字符串 | +| `fib_key_monitor_lib.py` | 斐波计算、`KEY_ENTRY_REASON_BY_SIGNAL`、`entry_reason_from_key_signal` | +| `crypto_monitor_gate/app.py` | 列表筛选、统计分块、导出 v3、复盘 K 线锚点、入库逻辑 | +| `crypto_monitor_gate/templates/index.html` | 顶栏时间窗、统计分块 UI、止损(开仓)列、复盘预填 | + +## 升级步骤 + +1. `git pull` 后对比 `.env.example`,把新增变量合并进本地 `.env`。 +2. 在 VPS 上为 Binance / Gate / Gate Bot **各执行一次** `bash scripts/install_backup_cron.sh`(若尚未安装)。 +3. 重启 Gate 实例服务(如 `pm2 restart crypto_gate`);首次启动会自动 `ALTER TABLE` 缺列(斐波、交易所盈亏、`entry_reason` 等)。 +4. 浏览器强刷(Ctrl+F5)避免旧版 `index.html` 缓存。 +5. 打开任意页确认顶栏出现 **「列表筛选(UTC)」**;`/stats` 可见分品类统计与「北京 8:00 切日」说明。 +6. 建议在测试币上先添加一条斐波监控,确认:限价已挂出、标记价失效会撤单、成交后出现持仓监控且 TP/SL 已挂上;平仓后交易记录止损(开仓)与开仓类型是否正确。 diff --git a/crypto_monitor_gate/部署文档.md b/crypto_monitor_gate/部署文档.md new file mode 100644 index 0000000..ae35ead --- /dev/null +++ b/crypto_monitor_gate/部署文档.md @@ -0,0 +1,299 @@ +# `crypto_monitor_gate` 部署指南:SSH SOCKS + Gate.io + PM2(Ubuntu) + +本文面向:**在本机运行本项目**,但 **直连 Gate.io API 不稳定或被重置** 的场景。思路是: + +- 本机用 `ssh -D` 做动态转发,把 **SOCKS5 出口**放到能正常访问 Gate 的机器(常见为一台境外 VPS) +- 项目在 `.env` 中设置 **`GATE_SOCKS_PROXY=socks5h://127.0.0.1:1080`**(或你实际端口),`ccxt` 经 SOCKS 访问交易所 +- **SSH 隧道**:用 `ssh -D` 在本机常驻即可(screen / tmux / systemd 等),**不必交给 PM2** +- 使用 **PM2** 仅托管 **Flask 应用**;仓库根目录 **`ecosystem.config.cjs`** 只定义 `crypto-monitor-gate` + +> 安全提醒:不要把 `.env`、私钥 `.pem`、Gate API Key 提交到 Git;下文只用占位符。 + +--- + +## 0. 你需要准备的东西 + +- 一台 **Ubuntu**(或同类 Linux)运行项目的机器(下文称「本机」) +- 一台可 SSH 登录、且 **能正常访问 Gate.io API** 的 VPS(示例:`HostName` 填你的服务器 IP,用户如 `root`) +- SSH:**私钥登录**(推荐,便于隧道脚本无人值守) +- 本机已安装:`python3`、`python3-venv`、`pip`、`curl`、`ssh`、`git`(可选)、`node` + `npm`(安装 PM2) + +--- + +## 1. 获取代码与目录 + +将包含 `app.py` 的项目放到固定目录,例如: + +```bash +mkdir -p /opt/crypto_monitor +cd /opt/crypto_monitor +git clone https://git.bz121.com/dekun/crypto_monitor.git +cd crypto_monitor/crypto_monitor_gate +``` + +下文用 **`/opt/crypto_monitor/crypto_monitor_gate`** 仅为示例,请换成你的实际绝对路径。 + +拉取代码后,若目录下尚无 `.env`: + +```bash +cp -n .env.example .env +``` + +--- + +## 2. 配置 SSH 私钥与 `~/.ssh/config` + +```bash +mkdir -p ~/.ssh +chmod 700 ~/.ssh +# 私钥示例:~/.ssh/vps1.pem +chmod 600 ~/.ssh/vps1.pem +``` + +编辑 `~/.ssh/config`(示例别名 **`gate-vps`**,与你手工启动 `ssh -D ... gate-vps` 一致即可): + +```sshconfig +Host gate-vps + HostName 你的_VPS_IP + User root + IdentityFile ~/.ssh/vps1.pem + IdentitiesOnly yes + ServerAliveInterval 30 + ServerAliveCountMax 3 + ExitOnForwardFailure yes + BatchMode yes +``` + +测试: + +```bash +ssh gate-vps true +``` + +> 若尚未完全改为密钥登录,可暂时注释 `BatchMode yes`,调试完成后再打开。 + +--- + +## 3. 手工验证:SSH SOCKS + Gate API + +### 3.1 本地 SOCKS(示例端口 1080) + +```bash +ssh -N -D 127.0.0.1:1080 gate-vps +``` + +保持运行,另开终端继续。 + +### 3.2 验证经 SOCKS 可访问 Gate + +```bash +curl -4 -sS --max-time 15 --proxy socks5h://127.0.0.1:1080 https://api.gateio.ws/api/v4/spot/time +``` + +应返回 JSON(含服务器时间字段)。若此处失败,**不要先启动应用**:先修隧道或 VPS 出站。 + +--- + +## 4. Python 虚拟环境 + +```bash +cd /opt/crypto_monitor/crypto_monitor_gate + +python3 -m venv .venv +source .venv/bin/activate +python -m pip install -U pip +pip install flask requests ccxt werkzeug PySocks Pillow +``` + +走 SOCKS 时 **必须** 安装 **`PySocks`**,否则易出现代理相关报错。 + +可选: + +```bash +export PYTHONDONTWRITEBYTECODE=1 +``` + +--- + +## 5. 配置环境变量(`.env.example` → `.env`) + +| 文件 | 是否进 Git | 说明 | +|------|------------|------| +| **`.env.example`** | ✅ 是 | 变量模板与注释,可随 `git pull` 更新 | +| **`.env`** | ❌ 否 | 本机真实配置;`app.py` **只读此文件** | + +### 5.1 首次配置 + +```bash +cd /opt/crypto_monitor/crypto_monitor_gate + +cp -n .env.example .env +nano .env +``` + +### 5.2 备份与 `git pull` + +- **`.env` 不在 Git 中**:`git pull` **不会**覆盖本地 `.env`。 +- 远端若更新 **`.env.example`**,pull 后请**手动**把新增变量补进你的 `.env`。 +- **升级前备份**:`cp .env .env.backup.$(date +%Y%m%d)`;恢复:`cp .env.backup.YYYYMMDD .env`。 +- **换机**:`scp` 复制 `.env`,或新机 `cp .env.example .env` 后重填。 + +### 5.3 自动备份(数据库 + 复盘图片) + +与 Binance 实例相同:每天 **北京时间 0:00** → **`/root/backups`**,保留 **30 天**。 + +```bash +cd /opt/crypto_monitor/crypto_monitor_gate +chmod +x scripts/backup_data.sh scripts/install_backup_cron.sh +bash scripts/install_backup_cron.sh +bash scripts/backup_data.sh # 试跑 +``` + +备份目录:`/root/backups/crypto_monitor_gate/YYYY-MM-DD/`。详见 Binance 项目 `部署文档.md` 第 5.3 节(恢复步骤、可选 `.env` 变量相同)。 + +若还部署了 **`crypto_monitor_gate_bot`**,请在该目录同样执行 `bash scripts/install_backup_cron.sh`。 + +### 5.4 必填项检查(Gate + 代理) + +与交易所相关的变量必须是 **Gate** 前缀(**不要**再写 OKX 变量,否则代理不会生效、密钥也不会被识别)。至少确认: + +```env +APP_HOST=127.0.0.1 +APP_PORT=5000 + +# 实盘(按需) +LIVE_TRADING_ENABLED=false +GATE_API_KEY=你的_Key +GATE_API_SECRET=你的_Secret + +# 经本机 SSH 动态转发访问 Gate(端口与隧道一致) +GATE_SOCKS_PROXY=socks5h://127.0.0.1:1080 + +# 若不用 SOCKS,可改用 HTTP 代理(一般二选一) +# GATE_HTTP_PROXY=http://127.0.0.1:7890 +# GATE_HTTPS_PROXY=http://127.0.0.1:7890 +``` + +说明:**推荐 `socks5h://`**,由 SOCKS 端解析域名,与 `curl --proxy socks5h://...` 行为一致。 + +--- + +## 6. 手工启动 Flask(验证) + +1. SOCKS 已监听 `127.0.0.1:1080` +2. 已 `source .venv/bin/activate` +3. `.env` 已含 `GATE_SOCKS_PROXY` + +```bash +cd /opt/crypto_monitor/crypto_monitor_gate +source .venv/bin/activate +python app.py +``` + +浏览器访问:`http://127.0.0.1:5000`(或你在 `.env` 中的端口)。 + +--- + +## 7. 安装 PM2 + +```bash +sudo npm i -g pm2 +pm2 -v +``` + +--- + +## 8. PM2:使用仓库内 `ecosystem.config.cjs`(推荐) + +在项目根目录: + +```bash +cd /opt/crypto_monitor/crypto_monitor_gate +pm2 start ecosystem.config.cjs +pm2 status +pm2 logs --lines 200 +``` + +默认只启动 **`crypto-monitor-gate`**(`.venv/bin/python app.py`)。 + +### 本机已可直连 Gate、不需要隧道时 + +`.env` 里应 **去掉或留空** `GATE_SOCKS_PROXY`(除非仍要走别的代理),再 `pm2 start ecosystem.config.cjs`。 + +### 开机自启 + +```bash +pm2 save +pm2 startup +# 按屏幕提示执行一条 sudo 命令 +``` + +--- + +## 9. 等价手工命令(不使用 ecosystem 文件时) + +### 9.1 SSH SOCKS(自行后台常驻,不推荐用 PM2) + +示例(前台;实际可用 `screen`/`tmux`/`-f` 后台化或 systemd): + +```bash +ssh -N -D 127.0.0.1:1080 gate-vps \ + -o ServerAliveInterval=30 -o ServerAliveCountMax=3 \ + -o ExitOnForwardFailure=yes +``` + +### 9.2 Flask + +```bash +cd /opt/crypto_monitor/crypto_monitor_gate +pm2 start /opt/crypto_monitor/crypto_monitor_gate/.venv/bin/python --name crypto-monitor-gate -- \ + /opt/crypto_monitor/crypto_monitor_gate/app.py +``` + +--- + +## 10. 交易所「连接不上」排查清单 + +1. **`.env` 是否为 Gate 变量**:必须是 `GATE_SOCKS_PROXY` / `GATE_API_KEY` / `GATE_API_SECRET`,不是 OKX。 +2. **隧道是否在本机端口监听**(若配置了 `GATE_SOCKS_PROXY`): + ```bash + ss -lntp | grep 1080 || true + ``` +3. **curl 复测 Gate**(与第 3.2 节相同);curl 不通则应用也不会通。 +4. **PySocks**:`pip show PySocks`,缺失则 `pip install PySocks`。 +5. **SSH 隧道连不上**:检查私钥权限、`~/.ssh/config`、VPS 出站与端口是否与 `.env` 一致。 +6. **启动顺序**:先保证 SOCKS 已监听,再 `pm2 start` 应用(或重启应用)。 + +--- + +## 11. 推荐启动顺序(习惯) + +1. 若走代理:先启动并确认 SSH SOCKS 已监听,再 `curl --proxy socks5h://127.0.0.1:1080 https://api.gateio.ws/api/v4/spot/time` 成功 +2. `pm2 start ecosystem.config.cjs` +3. 再确认页面与余额等接口正常 + +--- + +## 12. 免责声明 + +交易所有合规与地区政策要求。请确保使用方式符合当地法律法规与交易所条款。本文仅描述网络与工程部署路径。 + +--- + +## 附录:数据库标签修复脚本 `scripts/fix_breakeven_labels.py` + +在 Ubuntu 上: + +1)预览(不写库): + +```bash +python scripts/fix_breakeven_labels.py --db ./crypto.db --dry-run +``` + +2)确认后执行: + +```bash +python scripts/fix_breakeven_labels.py --db ./crypto.db --apply +``` + +默认修复条件:`monitor_type='下单监控'` 且 `result='止损'` 且 `pnl_amount > 0` → 改为 `result='保本止盈'`。 diff --git a/crypto_monitor_gate_bot/.env.example b/crypto_monitor_gate_bot/.env.example new file mode 100644 index 0000000..8202a73 --- /dev/null +++ b/crypto_monitor_gate_bot/.env.example @@ -0,0 +1,166 @@ +# ============================================================================= +# 环境配置模板(可提交 Git)。程序运行时只读取同目录下的 .env。 +# +# 首次部署 / 新机: +# cp .env.example .env +# nano .env # 填入真实密钥、端口、代理等 +# +# 升级代码(git pull)前建议备份(.env 不在 Git 中,pull 不会覆盖): +# cp .env .env.backup.$(date +%Y%m%d) +# +# 从备份恢复: +# cp .env.backup.YYYYMMDD .env +# ============================================================================= + +APP_ENV=production +# 服务监听地址(云服务器通常用 0.0.0.0) +APP_HOST=0.0.0.0 +# 服务端口 +APP_PORT=5002 +# 是否开启调试模式(生产建议 false) +APP_DEBUG=false + +# 登录账号 +APP_USERNAME=dekun +# 登录密码(请改成你自己的强密码) +APP_PASSWORD=ChangeMe123! +# 是否关闭登录校验(局域网可设 true;公网务必 false) +APP_AUTH_DISABLED=true +# Flask 会话密钥(必须替换为长随机字符串) +FLASK_SECRET_KEY=CHANGE_TO_LONG_RANDOM_SECRET + +# ---------- 整机许可(也可写在仓库根目录 .env 的 LICENSE_* 变量)---------- +LICENSE_API_URL=https://license.example.com +LICENSE_CLIENT_KEY=REPLACE_WITH_CLIENT_KEY +LICENSE_CHECK_INTERVAL_DAYS=3 +LICENSE_OFFLINE_GRACE_DAYS=7 +LICENSE_WECHAT_ID=dekun03 +# LICENSE_DISABLED=false + +# 企业微信机器人 Webhook(用于行情/风控推送) +WECHAT_WEBHOOK=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=REPLACE_WITH_REAL_KEY + +# 数据库文件路径(相对路径会自动按项目目录解析) +DB_PATH=crypto.db +# 交易截图上传目录 +UPLOAD_DIR=static/images + +# 自动备份(scripts/backup_data.sh + cron,可选;默认即可) +# BACKUP_ROOT=/root/backups +# BACKUP_RETENTION_DAYS=30 +# BACKUP_INSTANCE=crypto_monitor_gate_bot + +# 已废弃:资金账户仅显示交易所 funding 余额,不再读取此变量 +# TOTAL_CAPITAL=100 +# 每天起始基数(U) +DAILY_START_CAPITAL=30 +# 日内回撤后基数(U) +DAILY_LOSS_CAPITAL=20 +# 日内盈利后基数(U) +DAILY_PROFIT_CAPITAL=50 +# BTC 默认杠杆倍数 +BTC_LEVERAGE=10 +# 山寨币默认杠杆倍数 +ALT_LEVERAGE=5 +# 交易日重置小时(北京时间) +TRADING_DAY_RESET_HOUR=8 +# Gate 平仓历史:同步「趋势回调」交易记录与交易所已实现盈亏(北京日期 00:00 起,与 APP_TIMEZONE 一致);留空则从近 90 天拉取 +# EXCHANGE_POSITION_SYNC_FROM_BJ=2026-05-14 +# EXCHANGE_POSITION_HISTORY_LIMIT=200 + +# 是否开启 Gate 实盘下单(false=只做本地流程,true=真实下单) +LIVE_TRADING_ENABLED=true +# Gate API Key(实盘) +GATE_API_KEY=REPLACE_WITH_GATE_API_KEY +# Gate API Secret(实盘) +GATE_API_SECRET=REPLACE_WITH_GATE_API_SECRET +# 保证金模式:cross=全仓,isolated=逐仓 +GATE_TD_MODE=cross +# 持仓筛选:hedge=双向持仓下按多空腿过滤;其它值(如 single)不按腿过滤 +GATE_POS_MODE=hedge +# 永续止盈止损:是否优先用官方仓位类触发单(POST price_orders,close-*-position);false=仅用旧版两张 ccxt 条件单 +GATE_TPSL_USE_POSITION_ORDER=true +# 触发单超时(秒),默认 604800=7 天;设为 0 或负数则不向 API 传 expiration +GATE_TPSL_TRIGGER_EXPIRATION=604800 +# 触发参考价:0=最新成交 1=标记价 2=指数价(非法值按 0) +GATE_TPSL_PRICE_TYPE=0 +# 页面与浏览器标签展示的交易所名称(多环境区分时可改成例如 Gate·模拟) +# EXCHANGE_DISPLAY_NAME=Gate.io + +# ============================================================================= +# 交易执行 / 开仓限制(与 crypto_monitor_gate 主站一致) +# ============================================================================= +# 【最大同时持仓】active 下单监控数达到该值后禁止再开仓(默认 1=单仓) +MAX_ACTIVE_POSITIONS=1 +# 整点前禁止新开仓:true=启用(默认),false=关闭(交易日划分仍用 TRADING_DAY_RESET_HOUR) +# TRADING_DAY_RESET_OPEN_GUARD_ENABLED=true + +# 关键位监控:5m收线突破过滤参数 +KLINE_TIMEFRAME=5m +KEY_BREAKOUT_LIMIT_PCT=1.5 +KEY_ALERT_MAX_TIMES=3 +KEY_ALERT_INTERVAL_MINUTES=5 + +# 资金与仓位刷新周期(秒) +BALANCE_REFRESH_SECONDS=60 +# 后台监控轮询周期(秒) +MONITOR_POLL_SECONDS=3 +# 使用可用资金时的缓冲比例(如0.98代表用98%) +FULL_MARGIN_BUFFER_RATIO=0.98 + +# 自动划转:将目标账户补足到 AUTO_TRANSFER_AMOUNT +AUTO_TRANSFER_ENABLED=false +AUTO_TRANSFER_AMOUNT=30 +AUTO_TRANSFER_FROM=funding +AUTO_TRANSFER_TO=swap +TRANSFER_CCY=USDT +# 强制清仓整点(北京时间,默认 0=凌晨00点) +FORCE_CLOSE_BJ_HOUR=0 +# 是否启用强制清仓(默认关闭,true 才会在整点执行) +FORCE_CLOSE_ENABLED=false + +# 推送与AI超时(秒) +WECHAT_TIMEOUT_SECONDS=10 +AI_TIMEOUT_SECONDS=120 + +# AI 复盘服务地址(本机 Ollama 默认地址) +OLLAMA_API=http://127.0.0.1:11434/api/generate +# AI 模型名称 +AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest + +# Gate 代理(可选):本机网络不稳定时通过 SSH 动态转发 SOCKS5 出口 +# 1) 先在本机建立隧道(示例): +# ssh -N -D 127.0.0.1:1080 root@你的VPS_IP -o ServerAliveInterval=30 -o ExitOnForwardFailure=yes +# 2) 再启用下面这一行(推荐 socks5h,让远端解析域名): +# GATE_SOCKS_PROXY=socks5h://127.0.0.1:1080 +# +# 如你更偏向 HTTP 代理(VPS 上跑 tinyproxy 之类),可用: +# GATE_HTTP_PROXY=http://127.0.0.1:3128 +# GATE_HTTPS_PROXY=http://127.0.0.1:3128 + +# 开仓多周期K线图(可选) +# ORDER_CHART_ENABLED=true +# ORDER_CHART_TFS=4h,1h,15m,5m +# ORDER_CHART_LIMIT=100 +# ORDER_CHART_DIR=static/images/order_charts +# DAILY_OPEN_ALERT_THRESHOLD=5 +# 以损定仓(按交易账户资金的百分比) +# RISK_PERCENT=2 +# 移动保本触发(达到多少R触发)与偏移(百分比) +# BREAKEVEN_RR_TRIGGER=1.0 +# 移动保本阶梯(每多少R继续上移一次,默认1R) +# BREAKEVEN_STEP_R=1.0 +# BREAKEVEN_OFFSET_PCT=0.02 +# 开单风格默认值:trend / swing +# DEFAULT_TRADE_STYLE=trend + +# 趋势回调策略(可选,见 趋势回调策略说明.md) +# TREND_PULLBACK_DCA_LEGS=5 +# TREND_PULLBACK_PREVIEW_TTL_SECONDS=120 +# 趋势回调手动保本:相对持仓均价的默认偏移(%);多=均价×(1+pct/100),空=均价×(1-pct/100) +# TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT=0.3 +# TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT=5 + +APP_TIMEZONE=Asia/Shanghai +AUTO_TRANSFER_BJ_HOUR=8 +# TRADING_DAY_RESET_HOUR 现在表示「北京时间」整点,默认 8 点起算新交易日;开仓整点限制见 TRADING_DAY_RESET_OPEN_GUARD_ENABLED diff --git a/crypto_monitor_gate_bot/app.py b/crypto_monitor_gate_bot/app.py new file mode 100644 index 0000000..a052180 --- /dev/null +++ b/crypto_monitor_gate_bot/app.py @@ -0,0 +1,7315 @@ +from flask import Flask, render_template, request, redirect, url_for, flash, session, jsonify, Response +import sqlite3 +import csv +from io import StringIO +import time +import threading +import requests +import os +import re +import base64 +import json +import math +from datetime import datetime, timedelta, timezone + +try: + from zoneinfo import ZoneInfo +except ImportError: + ZoneInfo = None # type: ignore +from functools import wraps +import uuid +import ccxt +from werkzeug.utils import secure_filename + +try: + from PIL import Image, ImageDraw, ImageFont +except ImportError: + Image = None # type: ignore + ImageDraw = None # type: ignore + ImageFont = None # type: ignore + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +_REPO_ROOT = os.path.dirname(BASE_DIR) +import sys + +if _REPO_ROOT not in sys.path: + sys.path.insert(0, _REPO_ROOT) +from history_window_lib import ( + PRESET_CUSTOM, + PRESET_UTC_LAST24H, + PRESET_UTC_LAST7D, + PRESET_UTC_TODAY, + list_window_redirect_query, + resolve_list_window, + resolve_window, + utc_window_to_bj_sql_strings, +) +import license_lib + + +def load_env_file(path): + if not os.path.exists(path): + return + raw_bytes = open(path, "rb").read() + text = "" + for enc in ("utf-8-sig", "utf-16", "utf-16-le", "utf-16-be"): + try: + text = raw_bytes.decode(enc) + break + except Exception: + continue + if not text: + text = raw_bytes.decode("utf-8", errors="ignore") + text = text.replace("\x00", "") + for line in text.splitlines(): + raw = line.strip() + if not raw or raw.startswith("#") or "=" not in raw: + continue + key, value = raw.split("=", 1) + clean_key = key.strip().lstrip("\ufeff") + if not clean_key.replace("_", "").isalnum(): + continue + clean_value = value.strip().strip('"').strip("'") + os.environ[clean_key] = clean_value + +load_env_file(os.path.join(BASE_DIR, ".env")) + + +def resolve_path(path_value): + if os.path.isabs(path_value): + return path_value + return os.path.join(BASE_DIR, path_value) + +app = Flask(__name__) +app.secret_key = os.getenv("FLASK_SECRET_KEY", "crypto_monitor_2026_secret_key") + + +def trend_add_zone_label(direction): + """趋势回调:做多=补仓上沿,做空=补仓下沿(库字段仍为 add_upper)。""" + return "补仓上沿" if (direction or "long").strip().lower() == "long" else "补仓下沿" + + +@app.context_processor +def _inject_trend_ui_helpers(): + return {"trend_add_zone_label": trend_add_zone_label} + + +# ====================== 登录配置 ====================== +USERNAME = os.getenv("APP_USERNAME", "dekun") +PASSWORD = os.getenv("APP_PASSWORD", "Woaini88@") +AUTH_DISABLED = os.getenv("APP_AUTH_DISABLED", "false").lower() in ("1", "true", "yes", "on") + +# 企业微信机器人Webhook +WECHAT_WEBHOOK = os.getenv("WECHAT_WEBHOOK", "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=replace-me") +SYSTEM_TYPE = "CRYPTO" +HOST = os.getenv("APP_HOST", "0.0.0.0") +PORT = int(os.getenv("APP_PORT", "5000")) +DEBUG = os.getenv("APP_DEBUG", "false").lower() == "true" +DB_PATH = resolve_path(os.getenv("DB_PATH", "crypto.db")) + +# 训练参数(可由 .env 覆盖) +DAILY_START_CAPITAL = float(os.getenv("DAILY_START_CAPITAL", "30")) +DAILY_LOSS_CAPITAL = float(os.getenv("DAILY_LOSS_CAPITAL", "20")) +DAILY_PROFIT_CAPITAL = float(os.getenv("DAILY_PROFIT_CAPITAL", "50")) +BTC_LEVERAGE = int(os.getenv("BTC_LEVERAGE", "10")) +ALT_LEVERAGE = int(os.getenv("ALT_LEVERAGE", "5")) +# 与 Gate 主站一致:最大同时 active 下单监控数(默认 1=单仓) +MAX_ACTIVE_POSITIONS = max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", "1"))) +# 交易日滚动与「可开仓」整点:按应用本地时区 wall clock(默认北京时间 UTC+8) +TRADING_DAY_RESET_HOUR = int(os.getenv("TRADING_DAY_RESET_HOUR", "8")) +# false 时关闭「整点前禁止新开仓」守卫(交易日划分仍用 TRADING_DAY_RESET_HOUR) +TRADING_DAY_RESET_OPEN_GUARD_ENABLED = os.getenv( + "TRADING_DAY_RESET_OPEN_GUARD_ENABLED", "true" +).lower() in ("1", "true", "yes", "on") +APP_TIMEZONE = os.getenv("APP_TIMEZONE", "Asia/Shanghai") +# 交易所「平仓历史」同步:自北京日期 00:00 起(与 APP_TIMEZONE 一致);空则取最近 90 天 +EXCHANGE_POSITION_SYNC_FROM_BJ = (os.getenv("EXCHANGE_POSITION_SYNC_FROM_BJ") or "").strip() +EXCHANGE_POSITION_HISTORY_LIMIT = max(50, min(1000, int(os.getenv("EXCHANGE_POSITION_HISTORY_LIMIT", "200")))) + +_LAST_POSITION_HISTORY_SYNC_AT = 0.0 + + +def _resolve_app_tz(): + if ZoneInfo is not None: + try: + return ZoneInfo((APP_TIMEZONE or "Asia/Shanghai").strip()) + except Exception: + pass + return timezone(timedelta(hours=8)) + + +APP_TZ = _resolve_app_tz() +LIVE_TRADING_ENABLED = os.getenv("LIVE_TRADING_ENABLED", "false").lower() == "true" +GATE_API_KEY = (os.getenv("GATE_API_KEY") or "").strip() +GATE_API_SECRET = (os.getenv("GATE_API_SECRET") or "").strip() +GATE_TD_MODE = (os.getenv("GATE_TD_MODE") or "cross").strip().lower() +GATE_POS_MODE = (os.getenv("GATE_POS_MODE") or "hedge").strip().lower() +# 永续仓位止盈止损触发单:POST /futures/{settle}/price_orders,order_type=close-*-position(全平) +GATE_TPSL_TRIGGER_EXPIRATION = int(os.getenv("GATE_TPSL_TRIGGER_EXPIRATION", str(7 * 86400))) +GATE_TPSL_PRICE_TYPE = int(os.getenv("GATE_TPSL_PRICE_TYPE", "0")) +if GATE_TPSL_PRICE_TYPE < 0 or GATE_TPSL_PRICE_TYPE > 2: + GATE_TPSL_PRICE_TYPE = 0 +GATE_TPSL_USE_POSITION_ORDER = os.getenv("GATE_TPSL_USE_POSITION_ORDER", "true").lower() in ("1", "true", "yes") +# 页面展示的交易所名称(多实例/多环境时可按需区分) +EXCHANGE_DISPLAY_NAME = (os.getenv("EXCHANGE_DISPLAY_NAME") or "Gate.io").strip() or "Gate.io" +license_lib.init_flask_app(app, exchange_display=EXCHANGE_DISPLAY_NAME) +_GATE_DEFAULT_MARGIN_MODE = "cross" if GATE_TD_MODE in ("cross", "cross_margin") else "isolated" +BALANCE_REFRESH_SECONDS = int(os.getenv("BALANCE_REFRESH_SECONDS", "60")) +PRICE_REFRESH_SECONDS = int(os.getenv("PRICE_REFRESH_SECONDS", "5")) +KEY_ALERT_MAX_TIMES = int(os.getenv("KEY_ALERT_MAX_TIMES", "3")) +KEY_ALERT_INTERVAL_MINUTES = int(os.getenv("KEY_ALERT_INTERVAL_MINUTES", "5")) +KEY_BREAKOUT_LIMIT_PCT = float(os.getenv("KEY_BREAKOUT_LIMIT_PCT", "1.5")) +AUTO_TRANSFER_ENABLED = os.getenv("AUTO_TRANSFER_ENABLED", "false").lower() == "true" +AUTO_TRANSFER_AMOUNT = float(os.getenv("AUTO_TRANSFER_AMOUNT", "30")) +AUTO_TRANSFER_FROM = os.getenv("AUTO_TRANSFER_FROM", "funding") +AUTO_TRANSFER_TO = os.getenv("AUTO_TRANSFER_TO", "swap") +FORCE_CLOSE_ENABLED = os.getenv("FORCE_CLOSE_ENABLED", "false").lower() == "true" +FORCE_CLOSE_BJ_HOUR = int(os.getenv("FORCE_CLOSE_BJ_HOUR", "0")) +# 自动划转:仅在北京时间该整点「小时」内尝试;transfer_logs.transfer_day 存 UTC 自然日便于对账 +AUTO_TRANSFER_BJ_HOUR = int(os.getenv("AUTO_TRANSFER_BJ_HOUR", "8")) +WECHAT_TIMEOUT_SECONDS = int(os.getenv("WECHAT_TIMEOUT_SECONDS", "10")) +AI_TIMEOUT_SECONDS = int(os.getenv("AI_TIMEOUT_SECONDS", "120")) +MONITOR_POLL_SECONDS = int(os.getenv("MONITOR_POLL_SECONDS", "3")) +# 趋势回调:补仓触发档位数(平分剩余 50% 计划仓位) +TREND_PULLBACK_DCA_LEGS = max(1, int(os.getenv("TREND_PULLBACK_DCA_LEGS", "5"))) +# 预览有效期(秒);超时须重新「生成预览」 +TREND_PULLBACK_PREVIEW_TTL_SECONDS = max(10, int(os.getenv("TREND_PULLBACK_PREVIEW_TTL_SECONDS", "120"))) +# 确认执行时:当前可用余额与预览快照相对偏差超过该百分比则拒绝(避免余额被划走后仍按旧计划满仓) +TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT = float(os.getenv("TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT", "5")) +# 趋势回调:手动保本默认相对均价偏移(%);多=均价×(1+pct/100),空=均价×(1-pct/100) +TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT = float( + os.getenv("TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT", "0.3") +) +MONITOR_TYPE_TREND = "趋势回调" +KLINE_TIMEFRAME = os.getenv("KLINE_TIMEFRAME", "5m") +FULL_MARGIN_BUFFER_RATIO = float(os.getenv("FULL_MARGIN_BUFFER_RATIO", "0.98")) +TRANSFER_CCY = os.getenv("TRANSFER_CCY", "USDT") +UPLOAD_FOLDER = resolve_path(os.getenv("UPLOAD_DIR", "static/images")) +ORDER_CHART_ENABLED = os.getenv("ORDER_CHART_ENABLED", "true").lower() == "true" +ORDER_CHART_TFS = [x.strip() for x in (os.getenv("ORDER_CHART_TFS", "4h,1h,15m,5m") or "").split(",") if x.strip()] +ORDER_CHART_LIMIT = int(os.getenv("ORDER_CHART_LIMIT", "100")) +ORDER_CHART_DIR = resolve_path(os.getenv("ORDER_CHART_DIR", "static/images/order_charts")) +DAILY_OPEN_ALERT_THRESHOLD = int(os.getenv("DAILY_OPEN_ALERT_THRESHOLD", "5")) +RISK_PERCENT = float(os.getenv("RISK_PERCENT", "2")) +BREAKEVEN_RR_TRIGGER = float(os.getenv("BREAKEVEN_RR_TRIGGER", "1.0")) +BREAKEVEN_OFFSET_PCT = float(os.getenv("BREAKEVEN_OFFSET_PCT", "0.02")) +BREAKEVEN_STEP_R = float(os.getenv("BREAKEVEN_STEP_R", "1.0")) +DEFAULT_TRADE_STYLE = (os.getenv("DEFAULT_TRADE_STYLE", "trend") or "trend").strip().lower() +OLLAMA_API = os.getenv("OLLAMA_API", "http://127.0.0.1:11434/api/generate") +AI_MODEL = os.getenv("AI_MODEL", "huihui_ai/deepseek-r1-abliterated:latest") + +GATE_SOCKS_PROXY = (os.getenv("GATE_SOCKS_PROXY") or "").strip() +GATE_HTTP_PROXY = (os.getenv("GATE_HTTP_PROXY") or "").strip() +GATE_HTTPS_PROXY = (os.getenv("GATE_HTTPS_PROXY") or "").strip() + + +def build_gate_ccxt_proxies(): + """ + 为 ccxt 配置代理(常用于本机网络不稳定时通过 SSH 动态转发 SOCKS5 出口)。 + + 推荐: + - 本机:ssh -N -D 127.0.0.1:1080 user@vps + - .env:GATE_SOCKS_PROXY=socks5h://127.0.0.1:1080 + + 说明: + - socks5h 让代理端解析域名(避免本机 DNS/策略差异);若你明确要本机解析可用 socks5:// + """ + socks = GATE_SOCKS_PROXY.strip() + http = GATE_HTTP_PROXY.strip() + https = GATE_HTTPS_PROXY.strip() or http + if socks: + return {"http": socks, "https": socks} + if http or https: + return {"http": http, "https": https} + return None + + +GATE_CCXT_PROXIES = build_gate_ccxt_proxies() + +os.makedirs(UPLOAD_FOLDER, exist_ok=True) +os.makedirs(ORDER_CHART_DIR, exist_ok=True) +app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER + +# Gate.io USDT 永续(swap) +exchange = ccxt.gateio({ + "enableRateLimit": True, + "options": { + "defaultType": "swap", + "defaultMarginMode": _GATE_DEFAULT_MARGIN_MODE, + }, +}) +if GATE_CCXT_PROXIES: + exchange.proxies = GATE_CCXT_PROXIES +if GATE_API_KEY and GATE_API_SECRET: + exchange.apiKey = GATE_API_KEY + exchange.secret = GATE_API_SECRET +MARKETS_LOADED = False +ACCOUNT_BALANCE_CACHE = { + "updated_at": 0.0, + "funding_usdt": None, + "trading_usdt": None +} +LIQUIDITY_RANK_CACHE = { + "updated_at": 0.0, + "ranks": {}, + "total": 0, +} + +# 企业微信推送 +def send_wechat_msg(content): + prefix = "【加密货币】" + full_msg = f"{prefix}\n{content}" + data = { + "msgtype": "text", + "text": {"content": full_msg} + } + try: + requests.post(WECHAT_WEBHOOK, json=data, timeout=WECHAT_TIMEOUT_SECONDS) + except: + pass + + +_BREAKEVEN_EXCHANGE_WARNED_IDS = set() + + +def _send_breakeven_exchange_warn_once(order_id, message): + """移动保本同步交易所失败:同一笔监控单只推送一次,避免轮询刷屏。""" + oid = int(order_id) + if oid in _BREAKEVEN_EXCHANGE_WARNED_IDS: + return + _BREAKEVEN_EXCHANGE_WARNED_IDS.add(oid) + send_wechat_msg(message) + + +def _clear_breakeven_exchange_warn(order_id): + _BREAKEVEN_EXCHANGE_WARNED_IDS.discard(int(order_id)) + + +def _wechat_account_label(): + return (os.getenv("GATE_ACCOUNT_LABEL") or "gate实盘账户").strip() + + +def _wechat_direction_text(direction): + d = (direction or "").lower() + return "多头(long)" if d == "long" else "空头(short)" + + +def _wechat_trading_capital_text(fallback=None): + try: + _, trading_capital = get_exchange_capitals(force=True) + except Exception: + trading_capital = None + if trading_capital is not None: + return f"{round(float(trading_capital), 4)}U" + if fallback is not None: + try: + return f"{round(float(fallback), 4)}U" + except Exception: + pass + return "-" + + +def build_wechat_close_message( + symbol, + direction, + result, + pnl_amount, + hold_seconds=None, + trigger_price=None, + current_price=None, + stop_loss=None, + take_profit=None, + close_order_id=None, + extra_note=None, + session_capital_fallback=None, +): + hold_txt = format_hold_minutes(calc_hold_minutes(hold_seconds)) if hold_seconds is not None else "-" + ep = format_price_for_symbol(symbol, trigger_price) + cp = format_price_for_symbol(symbol, current_price) + tp = format_price_for_symbol(symbol, take_profit) + sl = format_price_for_symbol(symbol, stop_loss) + cap_txt = _wechat_trading_capital_text(session_capital_fallback) + try: + if pnl_amount is not None: + pv = float(pnl_amount) + pnl_disp = f"{'+' if pv > 0 else ''}{round(pv, 4)} U" + else: + pnl_disp = "-" + except (TypeError, ValueError): + pnl_disp = "-" + + lines = [ + f"📉 {symbol} 平仓完成", + f"💼 账户:{_wechat_account_label()}", + "", + "🧾 平仓概要", + f"🔖 平仓单号:{close_order_id or '-'}", + f"📌 方向:{_wechat_direction_text(direction)}", + f"📌 平仓结果:{result or '-'}", + f"💰 本单盈亏:{pnl_disp}", + f"⏱ 持仓时长:{hold_txt}", + f"💵 交易账户资金:{cap_txt}", + "", + "🎯 价位(计划)", + f"开仓成交价:{ep}", + f"离场参考价:{cp}", + f"止盈价位:{tp}", + f"止损价位:{sl}", + ] + if extra_note: + lines.extend(["", "📎 备注", extra_note]) + return "\n".join(lines) + + +def build_wechat_breakeven_message(symbol, direction, arm_txt, now_rr, locked_r, new_sl): + sl_fmt = format_price_for_symbol(symbol, new_sl) + return "\n".join( + [ + f"# 🛡️ {symbol} 保护位更新", + f"**账户:{_wechat_account_label()}**", + "", + "---", + "", + "### 移动保本/止盈", + f"- 方向:**{_wechat_direction_text(direction)}**", + f"- 类型:**{arm_txt}**", + f"- 当前RR:`{round(float(now_rr), 2)}R`", + f"- 锁定RR:`{round(float(locked_r), 2)}R`", + f"- 新保护位:`{sl_fmt}`", + ] + ) + + +def build_wechat_monitor_error_message(symbol, direction, scene, error_text): + return "\n".join( + [ + f"# ⚠️ {symbol} 下单监控异常", + f"**账户:{_wechat_account_label()}**", + "", + "---", + "", + "### 异常信息", + f"- 方向:**{_wechat_direction_text(direction)}**", + f"- 场景:{scene}", + f"- 错误:{str(error_text)}", + ] + ) + + +def build_wechat_key_monitor_message( + symbol, + direction, + monitor_type, + trigger_time, + key_price, + confirm_close, + hard_lines, + btc8h_status, + coin4h_status, + swing4h_pct, + op_lines, + risk_tip=None, +): + lines = [ + f"# 🎯 {symbol} 关键位确认推送", + f"**账户:{_wechat_account_label()}**", + "", + "---", + "", + "### 交易对 / 触发时间", + f"- 交易对:**{symbol}**", + f"- 触发时间:`{trigger_time}`", + "", + "### 方向与确认K", + f"- 方向:**{_wechat_direction_text(direction)}**", + "- 确认K:第二根5m收盘完成", + "", + "### 关键价位", + f"- 类型:**{monitor_type}**", + f"- 箱体关键位:`{key_price}`", + f"- 第二根确认收盘价:`{confirm_close}`", + "", + "### 硬条件校验结果", + ] + lines.extend([f"- {x}" for x in hard_lines]) + lines.extend( + [ + "", + "### 市场状态说明", + f"- BTC 8h 状态:**{btc8h_status}**", + f"- 本币 4h(EMA55) 状态:**{coin4h_status}**", + f"- 4h震荡幅度(5m近48根):`{round(float(swing4h_pct), 3)}%`", + "", + "### 操作提示", + ] + ) + lines.extend([f"- {x}" for x in op_lines]) + if risk_tip: + lines.extend(["", f"### 逆势风险提醒", f"- {risk_tip}"]) + return "\n".join(lines) + + +def _read_image_base64(image_path): + try: + with open(image_path, "rb") as f: + return base64.b64encode(f.read()).decode("utf-8") + except Exception: + return None + + +def _extract_json_object(text): + if not text: + return None + clean = text.strip() + if clean.startswith("```"): + clean = clean.replace("```json", "").replace("```", "").strip() + try: + return json.loads(clean) + except Exception: + pass + match = re.search(r"\{[\s\S]*\}", clean) + if not match: + return None + try: + return json.loads(match.group(0)) + except Exception: + return None + + +def _journal_row_lines_for_ai(idx, row, *, include_hold_duration=True): + """把 journal 字段拼成给 AI 的文本;字段之外的事实不要指望模型自己猜。""" + def nz(v, default="无"): + if v is None: + return default + s = str(v).strip() + return s if s else default + + lines = [ + f"{idx}. {nz(row['coin'])} {nz(row['tf'])} | 盈亏:{nz(row['pnl'])}U | 实际RR:{nz(row['real_rr'])} | 预期RR:{nz(row['expect_rr'])}", + f" 开仓逻辑:{nz(row['entry_reason'])}", + f" 平仓/离场(交易员自述):{nz(row['exit_reason'])}", + ] + if include_hold_duration: + lines.append(f" 持仓时长:{nz(row['hold_duration'])}") + ee_bits = [ + nz(row["early_exit"]), + nz(row["early_exit_reason"]), + nz(row["early_exit_trigger"]), + nz(row["early_exit_note"]), + ] + if any(x != "无" for x in ee_bits): + lines.append( + " 提前离场记录:" + f"{ee_bits[0]} | 原因:{ee_bits[1]} | 触发:{ee_bits[2]} | 备注:{ee_bits[3]}" + ) + mood_bits = f"心态标签:{nz(row['mood_issues'])}" + if row["mood_score"] is not None: + mood_bits += f" | 自评心态分:{row['mood_score']}" + lines.append(f" {mood_bits}") + if nz(row["post_breakeven_stare"]) != "无": + lines.append(f" 保本后盯盘:{nz(row['post_breakeven_stare'])}") + if nz(row["new_trade_while_occupied"]) != "无": + lines.append(f" 占用时新开仓:{nz(row['new_trade_while_occupied'])}") + if nz(row["note"]) != "无": + lines.append(f" 备注:{nz(row['note'])}") + return "\n".join(lines) + "\n" + + +def ai_review(trades_text, period_title, image_paths=None): + prompt = f""" +你是一位专业交易教练。下面是用户的{period_title}交易记录,请做简洁、可执行的复盘(中文)。 + +【硬性规则 — 必须遵守】 +- 你只能根据「交易记录」里**明确出现的字段**陈述事实;禁止编造:是否触发止损、是否扛单、亏损是否扩大、图上具体结构/进出场点位等记录里**没有**的信息。 +- 「平仓/离场」只是交易员自述摘要,不是客观成交明细;若记录未写明代币是否打到止损价、是否软件平仓等,不要断言执行路径,可用「在记录有限前提下,一种可能是……」或简短写「执行路径记录不足,无法判断」。 +- 「提前离场」类结论必须优先依据记录中的「提前离场记录」字段;若该段全为「无」或未出现有效内容,不得写道「明显扛单」「拒不止损」「未执行硬止损」等。 +- 实际RR为负只说明结果相对于预期RR不利,不等同于「风控失灵」或「止损纪律崩溃」,除非记录里另有依据。 +- 禁止用语:人身攻击、夸张定性(如「致命伤」「灾难」);语气克制、对事不对人。 +- 若有截图且你能辨认,再结合图讨论;看不清或无明确定位则明确说「无法从图确认」,不得虚构 K 线故事。 + +【输出结构】 +1. 总体盈亏结构(紧扣笔数、盈亏数字与 RR,少形容词) +2. 心态与执行(每笔 1–10 分 + 一句依据;依据必须对应记录字段) +3. 行为标签(提前离场 / 乱开仓 / 扛单等):仅在有字段或自述支撑时点名;否则写「记录未勾选或未描述,不作强加」 +4. 改进建议(最多 3 条,每条具体可执行) +5. 图表(若有且可读):结合价格行为简述;否则一两句说明无法看图分析 + +交易记录: +{trades_text} +""".strip() + payload = {"model": AI_MODEL, "prompt": prompt, "stream": False, "options": {"temperature": 0.2}} + images = [] + for p in image_paths or []: + b64 = _read_image_base64(p) + if b64: + images.append(b64) + if images: + payload["images"] = images + try: + r = requests.post(OLLAMA_API, json=payload, timeout=AI_TIMEOUT_SECONDS) + return r.json().get("response", "AI 生成失败") + except Exception as e: + return f"AI 调用失败:{str(e)}" + + +def ai_short_advice(prompt_text): + prompt = f""" +你是交易风控助理。请用中文给出**最多 3 条**提醒,要求: +- 每条不超过 25 个字 +- 语气克制、具体、可执行 +- 不要输出 Markdown,不要编号前缀以外的废话 + +场景: +{prompt_text} +""".strip() + payload = {"model": AI_MODEL, "prompt": prompt, "stream": False, "options": {"temperature": 0.2}} + try: + r = requests.post(OLLAMA_API, json=payload, timeout=AI_TIMEOUT_SECONDS) + return (r.json().get("response") or "").strip() + except Exception: + return "" + + +def _load_font(size): + if not ImageFont: + return None + candidates = [ + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", + "C:\\Windows\\Fonts\\msyh.ttc", + "C:\\Windows\\Fonts\\arial.ttf", + ] + for path in candidates: + if path and os.path.exists(path): + try: + return ImageFont.truetype(path, size) + except Exception: + continue + try: + return ImageFont.load_default() + except Exception: + return None + + +def _ohlcv_to_rows(ohlcv): + rows = [] + for bar in ohlcv or []: + if not bar or len(bar) < 6: + continue + try: + rows.append( + { + "ts": int(bar[0]), + "o": float(bar[1]), + "h": float(bar[2]), + "l": float(bar[3]), + "c": float(bar[4]), + "v": float(bar[5]), + } + ) + except Exception: + continue + return rows + + +def _local_input_datetime_to_ms(dt_text): + raw = str(dt_text or "").strip() + if not raw: + return None + raw = raw.replace("T", " ") + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"): + try: + dt = datetime.strptime(raw, fmt) + aware = dt.replace(tzinfo=APP_TZ) + return int(aware.timestamp() * 1000) + except Exception: + continue + return None + + +def _marker_tag_label(tag): + t = str(tag or "").strip().upper() + if t == "ENTRY": + return "开仓" + if t == "EXIT": + return "平仓" + return str(tag or "") + + +def _timeframe_period_ms(tf): + s = (tf or "").strip().lower() + if s.endswith("m"): + try: + return int(s[:-1]) * 60 * 1000 + except ValueError: + pass + if s.endswith("h"): + try: + return int(s[:-1]) * 3600 * 1000 + except ValueError: + pass + if s.endswith("d"): + try: + return int(s[:-1]) * 86400 * 1000 + except ValueError: + pass + return 300000 + + +def _ohlcv_dict_rows_to_lists(rows, lim): + if not rows: + return [] + pick = rows[-lim:] if len(rows) >= lim else rows + return [[r["ts"], r["o"], r["h"], r["l"], r["c"], r.get("v", 0)] for r in pick] + + +def _fetch_ohlcv_ending_at(exchange_symbol, timeframe, limit, end_ts_ms): + """以 end_ts_ms 为终点向前取 K 线(无 end 则拉最近 limit 根)。""" + lim = max(2, int(limit or ORDER_CHART_LIMIT)) + try: + if not end_ts_ms: + ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=lim) + else: + period = _timeframe_period_ms(timeframe) + since = int(end_ts_ms) - period * (lim + 10) + ohlcv = exchange.fetch_ohlcv( + exchange_symbol, timeframe=timeframe, since=max(0, since), limit=lim + 20 + ) + except Exception: + return [] + rows = _ohlcv_to_rows(ohlcv) + if not rows: + return [] + if not end_ts_ms: + return _ohlcv_dict_rows_to_lists(rows, lim) + filtered = [r for r in rows if int(r["ts"]) <= int(end_ts_ms)] + if len(filtered) >= 2: + return _ohlcv_dict_rows_to_lists(filtered, lim) + return _ohlcv_dict_rows_to_lists(rows, lim) + + +def _pick_marker_point(rows, target_ts_ms, target_price=None): + if not rows or target_ts_ms is None: + return None, None + idx = min(range(len(rows)), key=lambda i: abs(int(rows[i]["ts"]) - int(target_ts_ms))) + if target_price is not None: + try: + p = float(target_price) + if p > 0: + return idx, p + except Exception: + pass + return idx, float(rows[idx]["c"]) + + +def _render_candles_subplot(rows, title, width, height, bg_rgb=(255, 255, 255), marker_points=None): + if not Image or not ImageDraw: + raise RuntimeError("缺少依赖:Pillow(pip install Pillow)") + img = Image.new("RGB", (width, height), bg_rgb) + draw = ImageDraw.Draw(img) + font = _load_font(14) + small = _load_font(12) + + pad_l, pad_r, pad_t, pad_b = 46, 12, 26, 28 + plot_w = max(10, width - pad_l - pad_r) + plot_h = max(10, height - pad_t - pad_b) + + header_bg = (245, 247, 250) + draw.rectangle((0, 0, width, pad_t), fill=header_bg) + if font: + draw.text((10, 6), title, fill=(25, 35, 60), font=font) + else: + draw.text((10, 6), title, fill=(25, 35, 60)) + + if not rows: + if small: + draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120), font=small) + else: + draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120)) + return img + + lo = min(r["l"] for r in rows) + hi = max(r["h"] for r in rows) + if hi <= lo: + hi = lo + 1e-12 + + n = len(rows) + marker_by_idx = {} + for mp in marker_points or []: + try: + idx = int(mp.get("idx")) + except Exception: + continue + if idx < 0 or idx >= n: + continue + marker_by_idx.setdefault(idx, []).append(mp) + + x0 = pad_l + for i, r in enumerate(rows): + x1 = pad_l + int((i + 1) * plot_w / n) + x_mid = (x0 + x1) // 2 + wick_x = x_mid + y_high = pad_t + int((hi - r["h"]) / (hi - lo) * plot_h) + y_low = pad_t + int((hi - r["l"]) / (hi - lo) * plot_h) + y_open = pad_t + int((hi - r["o"]) / (hi - lo) * plot_h) + y_close = pad_t + int((hi - r["c"]) / (hi - lo) * plot_h) + top = min(y_open, y_close) + bot = max(y_open, y_close) + up = r["c"] >= r["o"] + wick_color = (120, 120, 120) + edge_color = (20, 20, 20) + draw.line((wick_x, y_high, wick_x, y_low), fill=wick_color) + body_w = max(1, (x1 - x0) - 2) + left = x0 + 1 + if bot - top < 2: + mid = (top + bot) // 2 + draw.rectangle((left, mid, left + body_w, mid + 1), fill=edge_color) + else: + if up: + draw.rectangle((left, top, left + body_w, bot), fill=(255, 255, 255), outline=edge_color, width=1) + else: + draw.rectangle((left, top, left + body_w, bot), fill=edge_color, outline=edge_color, width=1) + for j, mp in enumerate(marker_by_idx.get(i, [])): + tag = str(mp.get("tag") or "") + label = _marker_tag_label(tag) + m_price = float(mp.get("price") or r["c"]) + y_m = pad_t + int((hi - m_price) / (hi - lo) * plot_h) + y_m = max(pad_t + 4, min(pad_t + plot_h - 4, y_m)) + x_off = (j - (len(marker_by_idx[i]) - 1) / 2.0) * 14 + x_draw = int(x_mid + x_off) + if tag == "ENTRY": + m_color = (0, 195, 95) + tri = [(x_draw, y_m - 20), (x_draw - 9, y_m - 4), (x_draw + 9, y_m - 4)] + text_y = y_m - 36 + else: + m_color = (235, 65, 65) + tri = [(x_draw, y_m + 20), (x_draw - 9, y_m + 4), (x_draw + 9, y_m + 4)] + text_y = y_m + 12 + draw.ellipse((x_draw - 5, y_m - 5, x_draw + 5, y_m + 5), fill=m_color, outline=(255, 255, 255), width=1) + draw.polygon(tri, fill=m_color) + draw.line((x_draw, y_m, x_draw, y_m - 16 if tag == "ENTRY" else y_m + 16), fill=m_color, width=3) + if font: + draw.text((x_draw + 8, text_y), label, fill=m_color, font=font) + else: + draw.text((x_draw + 8, text_y), label, fill=m_color) + x0 = x1 + + if len(marker_points or []) >= 2: + try: + entry = next((m for m in marker_points if m.get("tag") == "ENTRY"), None) + exitp = next((m for m in marker_points if m.get("tag") == "EXIT"), None) + if entry is not None and exitp is not None: + ex_i, ex_p = int(entry["idx"]), float(entry["price"]) + xx_i, xx_p = int(exitp["idx"]), float(exitp["price"]) + x_ex = pad_l + int((ex_i + 0.5) * plot_w / n) + x_xx = pad_l + int((xx_i + 0.5) * plot_w / n) + y_ex = pad_t + int((hi - ex_p) / (hi - lo) * plot_h) + y_xx = pad_t + int((hi - xx_p) / (hi - lo) * plot_h) + draw.line((x_ex, y_ex, x_xx, y_xx), fill=(35, 135, 255), width=3) + except Exception: + pass + + # 极简风格:不画网格与坐标轴,仅保留右下角轻量区间信息 + if small: + draw.text((width - 210, height - 22), f"L={lo:.6g} H={hi:.6g}", fill=(120, 125, 135), font=small) + return img + + +def generate_multi_timeframe_chart_png( + exchange_symbol, + title_prefix, + timeframes=None, + limit=None, + out_dir=None, + filename=None, + filename_prefix="chart", + marker_payload=None, + marker_timeframes=None, +): + if not ORDER_CHART_ENABLED: + return None + if not Image: + return None + requested = timeframes or ORDER_CHART_TFS + limit = limit or ORDER_CHART_LIMIT + preferred_layout = ["5m", "15m", "1h", "4h"] + requested_set = set(requested or []) + ordered = [tf for tf in preferred_layout if tf in requested_set] + for tf in requested: + if tf not in ordered: + ordered.append(tf) + timeframes = ordered[:4] if ordered else preferred_layout + + ensure_markets_loaded() + panels = [] + cell_w, cell_h = 980, 520 + end_ts_ms = None + if marker_payload: + try: + end_ts_ms = int(marker_payload.get("exit_ts_ms") or marker_payload.get("entry_ts_ms") or 0) or None + except (TypeError, ValueError): + end_ts_ms = None + default_marker_tfs = {str(t).strip().lower() for t in timeframes} + for tf in timeframes: + try: + ohlcv = _fetch_ohlcv_ending_at(exchange_symbol, tf, limit, end_ts_ms) + if not ohlcv and end_ts_ms: + ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=tf, limit=limit) + except Exception: + ohlcv = [] + rows = _ohlcv_to_rows(ohlcv)[-limit:] + title = f"{title_prefix} | {tf} x{len(rows)}" + points = [] + tf_key = str(tf).strip().lower() + if marker_payload: + if marker_timeframes: + marker_tfs = {str(x).strip().lower() for x in marker_timeframes if str(x).strip()} + else: + marker_tfs = default_marker_tfs + else: + marker_tfs = set() + if marker_payload and tf_key in marker_tfs: + entry_idx, entry_price = _pick_marker_point(rows, marker_payload.get("entry_ts_ms"), marker_payload.get("entry_price")) + exit_idx, exit_price = _pick_marker_point(rows, marker_payload.get("exit_ts_ms"), marker_payload.get("exit_price")) + if entry_idx is not None and entry_price is not None: + points.append({"idx": entry_idx, "price": entry_price, "tag": "ENTRY"}) + if exit_idx is not None and exit_price is not None: + points.append({"idx": exit_idx, "price": exit_price, "tag": "EXIT"}) + panels.append( + _render_candles_subplot( + rows, + title, + width=cell_w, + height=cell_h, + bg_rgb=(255, 255, 255), + marker_points=points, + ) + ) + + if not panels: + return None + + gap = 10 + cols = 2 + rows_n = int(math.ceil(len(panels) / cols)) + w = cols * cell_w + (cols - 1) * gap + h = rows_n * cell_h + (rows_n - 1) * gap + out = Image.new("RGB", (w, h), (255, 255, 255)) + idx = 0 + for r in range(rows_n): + for c in range(cols): + if idx >= len(panels): + break + x = c * (cell_w + gap) + y = r * (cell_h + gap) + out.paste(panels[idx], (x, y)) + idx += 1 + + # 四宫格间隔线(仅在拼图间隙处画线,不进入单张子图) + if ImageDraw and rows_n >= 1: + draw_out = ImageDraw.Draw(out) + line_col = (220, 225, 232) + x_mid = cell_w + gap // 2 + if w > x_mid >= 0: + draw_out.line((x_mid, 0, x_mid, h), fill=line_col, width=2) + for rr in range(1, rows_n): + y_mid = rr * cell_h + (rr - 1) * gap + gap // 2 + if 0 <= y_mid <= h: + draw_out.line((0, y_mid, w, y_mid), fill=line_col, width=2) + + target_dir = out_dir or ORDER_CHART_DIR + os.makedirs(target_dir, exist_ok=True) + fname = filename or f"{filename_prefix}_{uuid.uuid4().hex}.png" + out_path = os.path.join(target_dir, fname) + out.save(out_path, format="PNG") + return fname + + +def generate_order_open_chart( + exchange_symbol, + title_prefix, + timeframes=None, + limit=None, + opened_at_ms=None, + entry_price=None, +): + marker_payload = None + if opened_at_ms: + marker_payload = { + "entry_ts_ms": opened_at_ms, + "exit_ts_ms": None, + "entry_price": entry_price, + "exit_price": None, + } + marker_tfs = ( + {x.strip().lower() for x in (timeframes or ORDER_CHART_TFS) if x and str(x).strip()} + or {"5m", "15m", "1h", "4h"} + ) + return generate_multi_timeframe_chart_png( + exchange_symbol, + title_prefix, + timeframes=timeframes, + limit=limit, + out_dir=ORDER_CHART_DIR, + filename=None, + filename_prefix="order", + marker_payload=marker_payload, + marker_timeframes=marker_tfs, + ) + + +def journal_coin_from_symbol(symbol): + sym = (symbol or "").strip().upper() + if not sym: + return "" + if "/" in sym: + return sym.split("/")[0].strip() + if "-" in sym: + return sym.split("-")[0].strip() + if sym.endswith("USDT"): + return sym[:-4].strip() + return sym + + +EARLY_EXIT_TRIGGERS = ( + "", + "保本止盈", + "移动止盈", + "手动平仓", + "止损", + "其他", +) + +# 与用户约定的固定开仓类型(仅做这几类单子) +ENTRY_REASON_OPTIONS = ( + "趋势多头:4h大结构突破前进场,确认条件:三次探顶,5m收敛不创新低", + "趋势空头:4h大结构突破前进场,确认条件:三次探底,5m收敛不创新高", + "趋势多头:小分歧低吸入场(左侧),确认条件:二次探底", + "趋势空头:小分歧高吸入场(左侧),确认条件:二次探顶", + "波段单:5m顺势突破,确认条件:2根k线+成交量放大+4h同向+日成交量前20", + "趋势回调", +) +# 复盘表单「其他」选项的 value(非入库值;自定义文本走 entry_reason_custom) +ENTRY_REASON_OTHER = "__OTHER__" + + +def normalize_entry_reason(raw, custom_text=None): + v = str(raw or "").strip() + if v == ENTRY_REASON_OTHER: + c = str(custom_text or "").strip() + return c[:2000] if c else "" + return v if v in ENTRY_REASON_OPTIONS else "" + + +def entry_reason_valid_for_storage(s): + """允许固定开仓类型选项、或自定义短文本(不含未解析的 __OTHER__ 占位)。""" + t = str(s or "").strip() + if not t: + return True + if t == ENTRY_REASON_OTHER: + return False + if t in ENTRY_REASON_OPTIONS: + return True + return 1 <= len(t) <= 2000 + + +def normalize_early_exit_trigger(raw): + v = str(raw or "").strip() + return v if v in EARLY_EXIT_TRIGGERS else "" + + +def compose_early_exit_reason_saved(trigger, note): + """Readable single-line string stored in early_exit_reason for legacy consumers.""" + t = normalize_early_exit_trigger(trigger) + n = str(note or "").strip() + if t and n: + return f"{t}|{n}" + return t or n + + +def journal_exit_reason_stored(trigger, note): + """exit_reason 列与表单「一处」对齐:非手工=触发类型;手工=离场说明全文。""" + t = normalize_early_exit_trigger(trigger) + n = str(note or "").strip() + if t == "手动平仓": + return n + return t + + +def ai_extract_journal_from_image(image_b64): + prompt = """ +你是交易复盘信息提取助手。请从截图中提取可识别字段,并只输出 JSON(不要 markdown,不要解释)。 +要求: +1) 仅输出一个 JSON 对象。 +2) 时间输出为 YYYY-MM-DDTHH:MM(用于 HTML datetime-local),无法识别填空字符串。 +3) 不要猜测主观原因;early_exit_note(仅手工平仓)、note 默认留空,除非图中明确写出。 +4) 允许字段为空。 +5) entry_reason:优先从下列完整字符串中选一个(一字不差);若无法归类则可将简述写入 entry_reason(保存时也可选表单「其他」手写): + - 趋势多头:4h大结构突破前进场,确认条件:三次探顶,5m收敛不创新低 + - 趋势空头:4h大结构突破前进场,确认条件:三次探底,5m收敛不创新高 + - 趋势多头:小分歧低吸入场(左侧),确认条件:二次探底 + - 趋势空头:小分歧高吸入场(左侧),确认条件:二次探顶 + - 波段单:5m顺势突破,确认条件:2根k线+成交量放大+4h同向+日成交量前20 + - 趋势回调 +6) early_exit_trigger 只能从下列取值中选一个(无法识别则填空字符串):保本止盈、移动止盈、手动平仓、止损、其他。 +7) 若触发为「手动平仓」,early_exit_note 必须写出图中可见的补充说明;其他触发类型 early_exit_note 留空。 +8) 若图中有无法归类的离场说明原文,可放进 early_exit_note,early_exit_trigger 填「其他」或留空。 + +JSON 字段: +{ + "open_datetime": "", + "close_datetime": "", + "coin": "", + "tf": "", + "pnl": "", + "expect_rr": "", + "real_rr": "", + "entry_reason": "", + "early_exit_trigger": "", + "early_exit_note": "", + "early_exit_reason": "", + "note": "" +} +""".strip() + payload = { + "model": AI_MODEL, + "prompt": prompt, + "images": [image_b64], + "stream": False, + "options": {"temperature": 0.1}, + } + try: + r = requests.post(OLLAMA_API, json=payload, timeout=AI_TIMEOUT_SECONDS) + raw = r.json().get("response", "") + data = _extract_json_object(raw) or {} + if not isinstance(data, dict): + data = {} + trig_in = data.get("early_exit_trigger") + note_in = data.get("early_exit_note") + legacy_reason = str(data.get("early_exit_reason") or "").strip() + out = { + "open_datetime": str(data.get("open_datetime") or "").strip(), + "close_datetime": str(data.get("close_datetime") or "").strip(), + "coin": str(data.get("coin") or "").strip(), + "tf": str(data.get("tf") or "").strip(), + "pnl": str(data.get("pnl") or "").strip(), + "expect_rr": str(data.get("expect_rr") or "").strip(), + "real_rr": str(data.get("real_rr") or "").strip(), + "entry_reason": normalize_entry_reason(data.get("entry_reason")), + "early_exit_trigger": normalize_early_exit_trigger(trig_in), + "early_exit_note": str(note_in or "").strip(), + "early_exit_reason": legacy_reason, + "note": str(data.get("note") or "").strip(), + } + if not out["early_exit_trigger"] and not out["early_exit_note"] and legacy_reason: + out["early_exit_note"] = legacy_reason + if out["early_exit_trigger"] == "手动平仓" and not out["early_exit_note"] and legacy_reason: + out["early_exit_note"] = legacy_reason + if out["early_exit_trigger"] != "手动平仓": + out["early_exit_note"] = "" + out["exit_reason"] = journal_exit_reason_stored(out["early_exit_trigger"], out["early_exit_note"]) + return out + except Exception: + return None + +# 初始化数据库(支持多空方向) +def init_db(): + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + + # 关键位监控 + c.execute('''CREATE TABLE IF NOT EXISTS key_monitors + (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT, + direction TEXT DEFAULT "long", upper REAL, lower REAL, + notification_count INTEGER DEFAULT 0, last_notified_at TEXT, + max_notify INTEGER DEFAULT 3, notify_interval_min INTEGER DEFAULT 5, + breakout_limit_pct REAL DEFAULT 1.5, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + # 订单监控(核心:加 direction 方向字段) + c.execute('''CREATE TABLE IF NOT EXISTS order_monitors + (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, direction TEXT DEFAULT "long", + exchange_symbol TEXT, + trigger_price REAL, stop_loss REAL, initial_stop_loss REAL, take_profit REAL, + margin_capital REAL DEFAULT 30, leverage INTEGER DEFAULT 5, + trade_style TEXT DEFAULT "trend", + risk_percent REAL, risk_amount REAL, + breakeven_rr_trigger REAL, breakeven_offset_pct REAL, breakeven_step_r REAL, + breakeven_armed INTEGER DEFAULT 0, breakeven_price REAL, + notional_value REAL, position_ratio REAL, base_amount REAL, + order_amount REAL, exchange_order_id TEXT, exchange_close_order_id TEXT, + opened_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, opened_at_ms INTEGER, session_date TEXT, + status TEXT DEFAULT "active")''') + + # 交易记录(必须存多空) + c.execute('''CREATE TABLE IF NOT EXISTS trade_records + (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT, + direction TEXT DEFAULT "long", trigger_price REAL, stop_loss REAL, initial_stop_loss REAL, take_profit REAL, + margin_capital REAL, leverage INTEGER, pnl_amount REAL DEFAULT 0, hold_seconds INTEGER DEFAULT 0, + trade_style TEXT DEFAULT "trend", risk_amount REAL, planned_rr REAL, actual_rr REAL, + hold_minutes INTEGER DEFAULT 0, opened_at TEXT, opened_at_ms INTEGER, closed_at TEXT, closed_at_ms INTEGER, + result TEXT, miss_reason TEXT, exchange_trade_id TEXT, + reviewed_opened_at TEXT, reviewed_closed_at TEXT, reviewed_stop_loss REAL, reviewed_take_profit REAL, reviewed_pnl_amount REAL, + reviewed_result TEXT, reviewed_miss_reason TEXT, reviewed_hold_seconds INTEGER, reviewed_hold_minutes INTEGER, + reviewed_at TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + c.execute('''CREATE TABLE IF NOT EXISTS trading_sessions + (session_date TEXT PRIMARY KEY, start_capital REAL, current_capital REAL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + c.execute('''CREATE TABLE IF NOT EXISTS journal_entries + (id TEXT PRIMARY KEY, open_datetime TEXT, close_datetime TEXT, hold_duration TEXT, + coin TEXT, tf TEXT, pnl TEXT, entry_reason TEXT, exit_reason TEXT, + expect_rr TEXT, real_rr TEXT, early_exit TEXT, early_exit_reason TEXT, + early_exit_trigger TEXT, early_exit_note TEXT, + mood_score INTEGER, mood_ai_score INTEGER, mood_ai_comment TEXT, mood_issues TEXT, post_breakeven_stare TEXT, + new_trade_while_occupied TEXT, note TEXT, image TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + c.execute('''CREATE TABLE IF NOT EXISTS ai_reviews + (id TEXT PRIMARY KEY, review_type TEXT, target_date TEXT, content TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + c.execute('''CREATE TABLE IF NOT EXISTS transfer_logs + (id INTEGER PRIMARY KEY AUTOINCREMENT, transfer_type TEXT, transfer_day TEXT, + amount REAL, from_account TEXT, to_account TEXT, status TEXT, message TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + c.execute('''DROP INDEX IF EXISTS idx_transfer_logs_unique_day''') + c.execute('''CREATE UNIQUE INDEX IF NOT EXISTS idx_transfer_logs_auto_daily_unique + ON transfer_logs(transfer_type, transfer_day) + WHERE transfer_type = 'auto_daily' ''') + + # 给旧表加 direction 字段(兼容老数据,不报错) + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN direction TEXT DEFAULT 'long'") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_symbol TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN margin_capital REAL DEFAULT 30") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN leverage INTEGER DEFAULT 5") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN trade_style TEXT DEFAULT 'trend'") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN risk_percent REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN risk_amount REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_rr_trigger REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_offset_pct REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_step_r REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_armed INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_price REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN initial_stop_loss REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN notional_value REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN position_ratio REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN base_amount REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN order_amount REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_order_id TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_close_order_id TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN opened_at TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN opened_at_ms INTEGER") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN session_date TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 1") + except Exception: + pass + try: + c.execute("UPDATE order_monitors SET opened_at = datetime('now') WHERE opened_at IS NULL OR opened_at = ''") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN direction TEXT DEFAULT 'long'") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN margin_capital REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN leverage INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN pnl_amount REAL DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN hold_seconds INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN hold_minutes INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN trade_style TEXT DEFAULT 'trend'") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN risk_amount REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN planned_rr REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN actual_rr REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN initial_stop_loss REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN exchange_trade_id TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN opened_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN opened_at_ms INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN closed_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN closed_at_ms INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_opened_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_closed_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_stop_loss REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_take_profit REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_pnl_amount REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_result TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_miss_reason TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_hold_seconds INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_hold_minutes INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN entry_reason TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_entry_reason TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN trend_plan_id INTEGER") + except Exception: + pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN exchange_realized_pnl REAL") + except Exception: + pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN exchange_opened_at TEXT") + except Exception: + pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN exchange_closed_at TEXT") + except Exception: + pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN exchange_sync_key TEXT") + except Exception: + pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN mood_ai_score INTEGER") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN mood_ai_comment TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_trigger TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_note TEXT") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN direction TEXT DEFAULT 'long'") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN notification_count INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN last_notified_at TEXT") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN max_notify INTEGER DEFAULT 3") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN notify_interval_min INTEGER DEFAULT 5") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN breakout_limit_pct REAL DEFAULT 1.5") + except: pass + + c.execute( + """CREATE TABLE IF NOT EXISTS key_monitor_history + (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT, direction TEXT, + upper REAL, lower REAL, notification_count INTEGER, last_alert_message TEXT, + close_reason TEXT, closed_at TEXT)""" + ) + + c.execute( + """CREATE TABLE IF NOT EXISTS trend_pullback_plans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + status TEXT DEFAULT 'active', + symbol TEXT NOT NULL, + exchange_symbol TEXT, + direction TEXT NOT NULL DEFAULT 'long', + leverage INTEGER NOT NULL, + stop_loss REAL NOT NULL, + add_upper REAL NOT NULL, + take_profit REAL NOT NULL, + risk_percent REAL DEFAULT 5, + snapshot_available_usdt REAL, + snapshot_at TEXT, + plan_margin_capital REAL, + target_order_amount REAL, + first_order_amount REAL, + remainder_total REAL, + dca_legs INTEGER DEFAULT 5, + per_leg_amount REAL, + grid_prices_json TEXT, + leg_amounts_json TEXT, + legs_done INTEGER DEFAULT 0, + first_order_done INTEGER DEFAULT 0, + last_mark_price REAL, + avg_entry_price REAL, + order_amount_open REAL, + opened_at TEXT, + opened_at_ms INTEGER, + session_date TEXT, + message TEXT + )""" + ) + + try: + c.execute("ALTER TABLE trend_pullback_plans ADD COLUMN leg_amounts_json TEXT") + except Exception: + pass + for ddl in ( + "ALTER TABLE trend_pullback_plans ADD COLUMN initial_stop_loss REAL", + "ALTER TABLE trend_pullback_plans ADD COLUMN breakeven_applied INTEGER DEFAULT 0", + "ALTER TABLE trend_pullback_plans ADD COLUMN breakeven_applied_at TEXT", + ): + try: + c.execute(ddl) + except Exception: + pass + + c.execute( + """CREATE TABLE IF NOT EXISTS trend_pullback_previews ( + id TEXT PRIMARY KEY, + symbol TEXT NOT NULL, + exchange_symbol TEXT NOT NULL, + direction TEXT NOT NULL, + leverage INTEGER NOT NULL, + stop_loss REAL NOT NULL, + add_upper REAL NOT NULL, + take_profit REAL NOT NULL, + risk_percent REAL NOT NULL, + snapshot_available_usdt REAL NOT NULL, + snapshot_at TEXT, + live_price_ref REAL, + plan_margin_capital REAL, + target_order_amount REAL, + first_order_amount REAL, + remainder_total REAL, + dca_legs INTEGER, + per_leg_amount REAL, + grid_prices_json TEXT, + leg_amounts_json TEXT, + expires_at_ms INTEGER NOT NULL, + created_at TEXT + )""" + ) + + c.execute( + """CREATE TABLE IF NOT EXISTS trend_pullback_preview_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + preview_id TEXT NOT NULL UNIQUE, + symbol TEXT NOT NULL, + exchange_symbol TEXT NOT NULL, + direction TEXT NOT NULL, + leverage INTEGER NOT NULL, + stop_loss REAL NOT NULL, + add_upper REAL NOT NULL, + take_profit REAL NOT NULL, + risk_percent REAL NOT NULL, + snapshot_available_usdt REAL NOT NULL, + snapshot_at TEXT, + live_price_ref REAL, + plan_margin_capital REAL, + target_order_amount REAL, + first_order_amount REAL, + remainder_total REAL, + dca_legs INTEGER, + per_leg_amount REAL, + grid_prices_json TEXT, + leg_amounts_json TEXT, + expires_at_ms INTEGER NOT NULL, + preview_created_at TEXT, + outcome TEXT DEFAULT 'open', + executed_plan_id INTEGER + )""" + ) + + conn.commit() + conn.close() + +init_db() + +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def app_now(): + """应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较)。""" + return datetime.now(APP_TZ).replace(tzinfo=None) + + +def app_now_str(): + return app_now().strftime("%Y-%m-%d %H:%M:%S") + + +def utc_now_dt(): + """当前时刻(UTC,aware)。""" + return datetime.now(timezone.utc) + + +def utc_calendar_date_str(): + """UTC 自然日 YYYY-MM-DD(用于自动划转去重等与交易所日界对齐的计算)。""" + return utc_now_dt().strftime("%Y-%m-%d") + + +def get_trading_day(now=None): + """交易日字符串:本地时钟下若小时 < TRADING_DAY_RESET_HOUR 则归属「上一日历日」。""" + now = now or app_now() + if getattr(now, "tzinfo", None): + now = now.astimezone(APP_TZ).replace(tzinfo=None) + if now.hour < TRADING_DAY_RESET_HOUR: + return (now - timedelta(days=1)).strftime("%Y-%m-%d") + return now.strftime("%Y-%m-%d") + + +TRADE_COMPLETED_RESULTS = ( + "止盈", + "止损", + "保本止盈", + "移动止盈", + "手动平仓", + "强制清仓", + "外部平仓", +) + +REVIEW_RESULT_OPTIONS = ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓") + + +def parse_dt_for_trading_day(s): + if not s: + return None + s = str(s).strip().replace("Z", "").replace("T", " ") + if not s: + return None + for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)): + try: + return datetime.strptime(s[:ln], fmt) + except ValueError: + continue + return None + + +def insert_key_monitor_history(conn, row, notification_count, last_msg, close_reason): + conn.execute( + """INSERT INTO key_monitor_history + (symbol, monitor_type, direction, upper, lower, notification_count, last_alert_message, close_reason, closed_at) + VALUES (?,?,?,?,?,?,?,?,?)""", + ( + row["symbol"], + row["monitor_type"], + row["direction"] or "long", + row["upper"], + row["lower"], + int(notification_count or 0), + (last_msg or "")[:800] if last_msg else None, + close_reason, + app_now_str(), + ), + ) + + +def _session_week_bounds(trading_day_str): + end = datetime.strptime(trading_day_str, "%Y-%m-%d").date() + start = end - timedelta(days=6) + return start.strftime("%Y-%m-%d"), trading_day_str + + +def _calendar_month_bounds(local_dt): + y, m = local_dt.year, local_dt.month + start = f"{y:04d}-{m:02d}-01" + if m == 12: + end_d = datetime(y, 12, 31).date() + else: + end_d = (datetime(y, m + 1, 1) - timedelta(days=1)).date() + return start, end_d.strftime("%Y-%m-%d") + + +def _count_opens_between(conn, start_td, end_td): + return conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ?", + (start_td, end_td), + ).fetchone()[0] + + +def _count_trend_plan_opens_between(conn, start_td, end_td): + """趋势回调:按计划在库里的 session_date(开仓所属北京交易日)计数。""" + return conn.execute( + "SELECT COUNT(*) FROM trend_pullback_plans WHERE session_date IS NOT NULL AND TRIM(session_date) != '' " + "AND session_date >= ? AND session_date <= ?", + (start_td, end_td), + ).fetchone()[0] + + +def _load_completed_trade_pnls(conn, monitor_type: str): + """已平仓实盘记录:按 monitor_type 过滤;趋势回调优先用交易所同步盈亏。""" + q = """SELECT pnl_amount, reviewed_pnl_amount, closed_at, reviewed_closed_at, created_at, + result, reviewed_result, exchange_realized_pnl + FROM trade_records + WHERE monitor_type = ? + ORDER BY COALESCE(closed_at, created_at, opened_at) ASC, id ASC""" + rows = conn.execute(q, (monitor_type,)).fetchall() + out = [] + for r in rows: + effective_result = (r["reviewed_result"] or r["result"] or "").strip() + if effective_result not in TRADE_COMPLETED_RESULTS: + continue + try: + if monitor_type == MONITOR_TYPE_TREND: + ex = None + try: + ex = r["exchange_realized_pnl"] + except (KeyError, IndexError, TypeError): + ex = None + if ex is not None and str(ex).strip() != "": + p = float(ex) + else: + p = float(r["reviewed_pnl_amount"] if r["reviewed_pnl_amount"] is not None else (r["pnl_amount"] or 0)) + else: + p = float(r["reviewed_pnl_amount"] if r["reviewed_pnl_amount"] is not None else (r["pnl_amount"] or 0)) + except (TypeError, ValueError): + p = 0.0 + t = parse_dt_for_trading_day(r["reviewed_closed_at"]) or parse_dt_for_trading_day(r["closed_at"]) or parse_dt_for_trading_day(r["created_at"]) + td = get_trading_day(t) if t else None + out.append((p, t, td)) + return out + + +def _compute_period_metrics(trades): + """trades: list of (pnl, close_dt, close_trading_day)""" + trades = [(p, t, td) for p, t, td in trades if t is not None] + trades.sort(key=lambda x: x[1]) + closed = len(trades) + wins = sum(1 for p, _, _ in trades if p > 0) + losses = sum(1 for p, _, _ in trades if p < 0) + net = round(sum(p for p, _, _ in trades), 4) + loss_sum_raw = sum(p for p, _, _ in trades if p < 0) + loss_sum_u = round(abs(loss_sum_raw), 4) if loss_sum_raw < 0 else 0.0 + neg_pnls = [p for p, _, _ in trades if p < 0] + pos_pnls = [p for p, _, _ in trades if p > 0] + max_single_loss = round(min(neg_pnls), 4) if neg_pnls else None + max_single_profit = round(max(pos_pnls), 4) if pos_pnls else None + cum = peak = max_dd = 0.0 + for p, _, _ in trades: + cum += p + peak = max(peak, cum) + max_dd = max(max_dd, peak - cum) + max_dd = round(max_dd, 4) + streak = 0 + for p, _, _ in reversed(trades): + if p < 0: + streak += 1 + else: + break + daily = {} + for p, _, td in trades: + if td: + daily[td] = daily.get(td, 0.0) + p + max_loss_streak_days = 0 + worst_day = None + worst_day_pnl = None + if daily: + sorted_days = sorted(daily.keys()) + run = 0 + for d in sorted_days: + if daily[d] < 0: + run += 1 + max_loss_streak_days = max(max_loss_streak_days, run) + else: + run = 0 + worst_day = min(daily.keys(), key=lambda x: daily[x]) + worst_day_pnl = round(daily[worst_day], 4) + win_rate_pct = round(wins / (wins + losses) * 100, 2) if (wins + losses) else None + return { + "closed_count": closed, + "win_count": wins, + "loss_count": losses, + "win_rate_pct": win_rate_pct, + "net_pnl_u": net, + "loss_sum_u": loss_sum_u, + "max_single_loss": max_single_loss, + "max_single_profit": max_single_profit, + "max_drawdown_u": max_dd, + "consecutive_losses": streak, + "max_loss_streak_days": max_loss_streak_days, + "worst_day": worst_day, + "worst_day_pnl": worst_day_pnl, + "opens_count": 0, + "range_label": "", + } + + +def compute_stats_bundle(conn, trading_day, now_dt=None): + """日 / 周 / 月 统计:平仓按平仓时间所在交易日计入;下单监控与趋势回调分列。""" + now_dt = now_dt or app_now() + pnls_order = _load_completed_trade_pnls(conn, "下单监控") + pnls_trend = _load_completed_trade_pnls(conn, MONITOR_TYPE_TREND) + total_opens_order = conn.execute("SELECT COUNT(*) FROM order_monitors").fetchone()[0] + total_opens_trend = conn.execute("SELECT COUNT(*) FROM trend_pullback_plans").fetchone()[0] + w_start, w_end = _session_week_bounds(trading_day) + m_start, m_end = _calendar_month_bounds(now_dt) + + def in_week(tr): + _p, _t, td = tr + return td and w_start <= td <= w_end + + def in_month(tr): + _p, _t, td = tr + return td and m_start <= td <= m_end + + day_range = f"北京时间交易日 {trading_day}" + week_range = f"{w_start} ~ {w_end}(北京日期,近7天窗口)" + month_range = f"{m_start} ~ {m_end}(北京时间自然月)" + + day_o = [tr for tr in pnls_order if tr[2] == trading_day] + day_t = [tr for tr in pnls_trend if tr[2] == trading_day] + dm_o = _compute_period_metrics(day_o) + dm_t = _compute_period_metrics(day_t) + dm_o["opens_count"] = _count_opens_between(conn, trading_day, trading_day) + dm_t["opens_count"] = _count_trend_plan_opens_between(conn, trading_day, trading_day) + + week_o = [tr for tr in pnls_order if in_week(tr)] + week_t = [tr for tr in pnls_trend if in_week(tr)] + wm_o = _compute_period_metrics(week_o) + wm_t = _compute_period_metrics(week_t) + wm_o["opens_count"] = _count_opens_between(conn, w_start, w_end) + wm_t["opens_count"] = _count_trend_plan_opens_between(conn, w_start, w_end) + + month_o = [tr for tr in pnls_order if in_month(tr)] + month_t = [tr for tr in pnls_trend if in_month(tr)] + mm_o = _compute_period_metrics(month_o) + mm_t = _compute_period_metrics(month_t) + mm_o["opens_count"] = _count_opens_between(conn, m_start, m_end) + mm_t["opens_count"] = _count_trend_plan_opens_between(conn, m_start, m_end) + + return { + "trading_day": trading_day, + "total_opens_order": total_opens_order, + "total_opens_trend": total_opens_trend, + "total_opens_all": int(total_opens_order) + int(total_opens_trend), + "day": {"range_label": day_range, "order": dm_o, "trend": dm_t}, + "week": {"range_label": week_range, "order": wm_o, "trend": wm_t}, + "month": {"range_label": month_range, "order": mm_o, "trend": mm_t}, + } + + +def infer_leverage(symbol): + sym = (symbol or "").strip().upper() + if sym.startswith("BTC") or sym.startswith("ETH"): + return BTC_LEVERAGE + return ALT_LEVERAGE + + +def normalize_exchange_symbol(symbol): + sym = symbol.strip().upper() + if ":" in sym: + return sym + if "/" in sym: + base, quote = sym.split("/", 1) + quote_clean = quote.split(":")[0] + return f"{base}/{quote_clean}:{quote_clean}" + return sym + + +def resolve_monitor_exchange_symbol(row): + """将监控行上的 symbol / exchange_symbol 统一到 ccxt 永续合约 symbol,便于与 fetch_positions 结果比对。""" + raw = "" + try: + if row["exchange_symbol"]: + raw = str(row["exchange_symbol"]).strip() + except (KeyError, IndexError, TypeError): + raw = "" + if not raw: + try: + raw = str(row["symbol"] or "").strip() + except (KeyError, IndexError, TypeError): + raw = "" + return normalize_exchange_symbol(raw) if raw else "" + + +def round_price_to_exchange(exchange_symbol, price): + """与交易所 tick 对齐后的 float,供入库与计算;失败时退回 float(price)。""" + if price in (None, ""): + return None + try: + v = float(price) + except (TypeError, ValueError): + return None + if not exchange_symbol: + return v + try: + ensure_markets_loaded() + s = exchange.price_to_precision(exchange_symbol, v) + return float(s) + except Exception: + return v + + +def _position_contract_symbol_match(position_symbol, wanted_exchange_symbol): + if not position_symbol or not wanted_exchange_symbol: + return False + a = normalize_exchange_symbol(str(position_symbol).strip()) + b = normalize_exchange_symbol(str(wanted_exchange_symbol).strip()) + return a == b + + +def _position_matches_wanted_contract(wanted_unified_sym, position_dict): + """统一 symbol 比对;不一致时用 Gate 原始 contract 与 ccxt market.id 对齐(兼容 1000PEPE 等命名差异)。""" + if not wanted_unified_sym or not position_dict: + return False + ps = position_dict.get("symbol") + if _position_contract_symbol_match(ps, wanted_unified_sym): + return True + try: + ensure_markets_loaded() + mid = (exchange.market(wanted_unified_sym).get("id") or "").strip().upper() + info = position_dict.get("info") or {} + c_raw = str(info.get("contract") or "").strip().upper() + if mid and c_raw and mid == c_raw: + return True + except Exception: + pass + return False + + +def _position_row_effective_contracts(p): + """张数:优先 ccxt contracts,否则用 Gate 原始 size/pos(避免统一层为 0 时被误判空仓)。""" + if not p: + return 0.0 + info = p.get("info") or {} + for val in (p.get("contracts"), info.get("size"), info.get("pos")): + if val is None or val == "": + continue + try: + x = abs(float(val)) + if x > 0: + return x + except (TypeError, ValueError): + continue + return 0.0 + + +def normalize_symbol_input(symbol): + sym = (symbol or "").strip().upper() + if not sym: + return "" + if "/" in sym: + return sym + if ":" in sym: + sym = sym.split(":")[0] + return f"{sym}/USDT" + + +def normalize_kline_limit(limit_raw, default=200): + try: + n = int(limit_raw) + except Exception: + return default + return 200 if n >= 200 else 100 + + +def get_recommended_capital(current_capital): + if current_capital <= DAILY_LOSS_CAPITAL: + return DAILY_LOSS_CAPITAL + if current_capital >= DAILY_PROFIT_CAPITAL: + return DAILY_PROFIT_CAPITAL + return DAILY_START_CAPITAL + + +def ensure_session(conn, session_date): + row = conn.execute( + "SELECT * FROM trading_sessions WHERE session_date = ?", + (session_date,) + ).fetchone() + if row: + return row + conn.execute( + "INSERT INTO trading_sessions (session_date, start_capital, current_capital) VALUES (?,?,?)", + (session_date, DAILY_START_CAPITAL, DAILY_START_CAPITAL) + ) + conn.commit() + return conn.execute( + "SELECT * FROM trading_sessions WHERE session_date = ?", + (session_date,) + ).fetchone() + + +def update_session_capital(conn, session_date, pnl_amount): + session_row = ensure_session(conn, session_date) + new_capital = float(session_row["current_capital"]) + float(pnl_amount) + conn.execute( + "UPDATE trading_sessions SET current_capital = ?, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?", + (round(new_capital, 4), session_date) + ) + conn.commit() + return round(new_capital, 4) + + +def calc_hold_seconds(opened_at_str, closed_at_dt): + try: + opened_at = datetime.strptime(opened_at_str, "%Y-%m-%d %H:%M:%S") + return int((closed_at_dt - opened_at).total_seconds()) + except Exception: + return 0 + + +def calc_hold_minutes(seconds): + if not seconds or seconds <= 0: + return 0 + return max(1, int(seconds // 60)) + + +def get_opened_at_value(row): + try: + keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + keys = [] + if "opened_at" in keys: + value = row["opened_at"] + if value: + return value + return app_now_str() + + +def get_effective_trade_field(row, reviewed_key, base_key, default=None): + try: + keys = row.keys() if hasattr(row, "keys") else row.keys() + except Exception: + keys = [] + if reviewed_key in keys: + v = row[reviewed_key] + if v is not None and str(v).strip() != "": + return v + if base_key in keys: + v = row[base_key] + if v is not None and str(v).strip() != "": + return v + return default + + +def to_effective_trade_dict(row): + item = row_to_dict(row) + base_stop = item.get("initial_stop_loss") if item.get("initial_stop_loss") not in (None, "") else item.get("stop_loss") + item["effective_opened_at"] = get_effective_trade_field(row, "reviewed_opened_at", "opened_at", item.get("opened_at")) + item["effective_closed_at"] = get_effective_trade_field(row, "reviewed_closed_at", "closed_at", item.get("closed_at")) + item["effective_stop_loss"] = get_effective_trade_field(row, "reviewed_stop_loss", "stop_loss", base_stop) + item["effective_take_profit"] = get_effective_trade_field(row, "reviewed_take_profit", "take_profit", item.get("take_profit")) + item["effective_result"] = get_effective_trade_field(row, "reviewed_result", "result", item.get("result")) + item["effective_miss_reason"] = get_effective_trade_field(row, "reviewed_miss_reason", "miss_reason", item.get("miss_reason")) + item["effective_pnl_amount"] = get_effective_trade_field(row, "reviewed_pnl_amount", "pnl_amount", item.get("pnl_amount")) + item["effective_hold_minutes"] = get_effective_trade_field(row, "reviewed_hold_minutes", "hold_minutes", item.get("hold_minutes")) + item["effective_hold_seconds"] = get_effective_trade_field(row, "reviewed_hold_seconds", "hold_seconds", item.get("hold_seconds")) + er_eff = get_effective_trade_field(row, "reviewed_entry_reason", "entry_reason", item.get("entry_reason")) + item["effective_entry_reason"] = (str(er_eff).strip() if er_eff is not None else "") or "" + mt = (item.get("monitor_type") or "").strip() + ex_pnl = item.get("exchange_realized_pnl") + ex_open = item.get("exchange_opened_at") + ex_close = item.get("exchange_closed_at") + if mt == MONITOR_TYPE_TREND and ex_pnl is not None and str(ex_pnl).strip() != "": + try: + item["display_pnl_amount"] = float(ex_pnl) + except (TypeError, ValueError): + item["display_pnl_amount"] = float(item.get("effective_pnl_amount") or 0) + item["display_pnl_source"] = "exchange" + eo = (str(ex_open).strip() if ex_open else "") or item.get("effective_opened_at") or "" + ec = (str(ex_close).strip() if ex_close else "") or item.get("effective_closed_at") or "" + item["display_opened_at"] = eo[:16] if eo else "-" + item["display_closed_at"] = ec[:16] if ec else "-" + else: + try: + item["display_pnl_amount"] = float(item.get("effective_pnl_amount") or 0) + except (TypeError, ValueError): + item["display_pnl_amount"] = 0.0 + item["display_pnl_source"] = "local" + eo = item.get("effective_opened_at") or "" + ec = item.get("effective_closed_at") or "" + item["display_opened_at"] = (eo[:16] if eo else "-") + item["display_closed_at"] = (ec[:16] if ec else "-") + return item + + +def format_money_usdt(value): + """资金类展示:固定两位小数(USDT)。""" + if value is None or value == "": + return "—" + try: + return f"{round(float(value), 2):.2f}" + except (TypeError, ValueError): + return "—" + + +def _exchange_unified_symbol_for_format(symbol_str): + if not symbol_str: + return None + s = str(symbol_str).strip() + if not s: + return None + try: + if ":" in s or "/" in s: + return normalize_exchange_symbol(s) + return normalize_exchange_symbol(f"{s}/USDT") + except Exception: + return None + + +def format_price_for_symbol(symbol, value): + if value in (None, ""): + return "-" + try: + v = float(value) + except Exception: + return str(value) + if v == 0: + return "0" + sym = _exchange_unified_symbol_for_format(symbol) + if sym and exchange_private_api_configured(): + try: + ensure_markets_loaded() + return str(exchange.price_to_precision(sym, v)) + except Exception: + pass + av = abs(v) + if av >= 10000: + d = 2 + elif av >= 100: + d = 3 + elif av >= 1: + d = 4 + elif av >= 0.01: + d = 6 + elif av >= 0.0001: + d = 8 + else: + d = 10 + text = f"{v:.{d}f}" + return text.rstrip("0").rstrip(".") if "." in text else text + + +def format_amount_for_symbol(symbol, value): + """合约张数等:尽量与交易所 amount 精度一致。""" + if value in (None, ""): + return "-" + try: + v = float(value) + except Exception: + return str(value) + sym = _exchange_unified_symbol_for_format(symbol) + if sym and exchange_private_api_configured(): + try: + ensure_markets_loaded() + return str(exchange.amount_to_precision(sym, v)) + except Exception: + pass + text = f"{v:.8f}" + return text.rstrip("0").rstrip(".") if "." in text else text + + +def insert_trend_preview_snapshot(conn, preview_id, created, exp_ms, pl): + """生成预览成功后归档一条快照(与 trend_pullback_previews 同参)。""" + conn.execute( + """INSERT INTO trend_pullback_preview_snapshots ( + preview_id,symbol,exchange_symbol,direction,leverage,stop_loss,add_upper,take_profit,risk_percent, + snapshot_available_usdt,snapshot_at,live_price_ref,plan_margin_capital,target_order_amount,first_order_amount,remainder_total, + dca_legs,per_leg_amount,grid_prices_json,leg_amounts_json,expires_at_ms,preview_created_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + preview_id, + pl["symbol"], + pl["exchange_symbol"], + pl["direction"], + pl["leverage"], + pl["stop_loss"], + pl["add_upper"], + pl["take_profit"], + pl["risk_percent"], + pl["snapshot_available_usdt"], + pl["snapshot_at"], + pl["live_price_ref"], + pl["plan_margin_capital"], + pl["target_order_amount"], + pl["first_order_amount"], + pl["remainder_total"], + pl["dca_legs"], + pl["per_leg_amount"], + pl["grid_prices_json"], + pl["leg_amounts_json"], + exp_ms, + created, + ), + ) + + +def preview_snapshot_outcome_label(outcome): + o = (outcome or "").strip().lower() + return { + "open": "待确认", + "executed": "已执行", + "cancelled": "已取消", + "expired": "已过期", + }.get(o, outcome or "-") + + +def format_hold_minutes(minutes): + if not minutes: + return "0分钟" + total = int(minutes) + hours = total // 60 + mins = total % 60 + if hours: + return f"{hours}小时{mins}分钟" + return f"{mins}分钟" + + +def calc_pnl(direction, trigger_price, exit_price, margin_capital, leverage): + try: + trigger = float(trigger_price) + exit_p = float(exit_price) + margin = float(margin_capital) + lev = float(leverage) + if trigger <= 0: + return 0.0 + if direction == "short": + pnl_ratio = (trigger - exit_p) / trigger + else: + pnl_ratio = (exit_p - trigger) / trigger + return round(margin * lev * pnl_ratio, 4) + except Exception: + return 0.0 + + +def calc_rr_ratio(direction, entry_price, stop_loss, take_profit): + try: + entry = float(entry_price) + sl = float(stop_loss) + tp = float(take_profit) + if entry <= 0 or sl <= 0 or tp <= 0: + return None + if direction == "short": + risk = sl - entry + reward = entry - tp + else: + risk = entry - sl + reward = tp - entry + if risk <= 0 or reward <= 0: + return None + return round(reward / risk, 4) + except Exception: + return None + + +def calc_risk_fraction(direction, entry_price, stop_loss): + try: + entry = float(entry_price) + sl = float(stop_loss) + if entry <= 0 or sl <= 0: + return None + if direction == "short": + risk = sl - entry + else: + risk = entry - sl + if risk <= 0: + return None + return risk / entry + except Exception: + return None + + +def calc_risk_amount_from_plan(direction, entry_price, stop_loss, margin_capital, leverage): + rf = calc_risk_fraction(direction, entry_price, stop_loss) + if rf is None: + return None + try: + notional = float(margin_capital) * float(leverage) + if notional <= 0: + return None + return round(notional * rf, 6) + except Exception: + return None + + +def calc_actual_rr(pnl_amount, risk_amount): + try: + r = float(risk_amount or 0) + if r <= 0: + return None + return round(float(pnl_amount or 0) / r, 4) + except Exception: + return None + + +def normalize_result_with_pnl(result, pnl_amount): + """ + 触发“止损”但实际已盈利时,归类为保本止盈,避免语义混淆。 + """ + if result == "止损": + try: + if float(pnl_amount or 0) > 0: + return "保本止盈" + except Exception: + pass + return result + + +def calc_breakeven_stop(direction, entry_price, risk_fraction, locked_r, offset_pct): + """ + 按“已锁定R”计算目标止损位: + - long: entry + locked_r * (entry*risk_fraction) + offset + - short: entry - locked_r * (entry*risk_fraction) - offset + """ + try: + entry = float(entry_price) + rf = float(risk_fraction) + lr = float(locked_r) + off = float(offset_pct) / 100.0 + if entry <= 0 or rf <= 0 or lr < 0: + return None + base_move = entry * rf * lr + offset_move = entry * off + if direction == "short": + return round(entry - base_move - offset_move, 8) + return round(entry + base_move + offset_move, 8) + except Exception: + return None + + +def insert_trade_record( + conn, + symbol, + monitor_type, + direction, + trigger_price, + stop_loss, + initial_stop_loss=None, + take_profit=None, + margin_capital=None, + leverage=None, + pnl_amount=0, + hold_seconds=0, + trade_style=None, + risk_amount=None, + planned_rr=None, + actual_rr=None, + result="", + miss_reason=None, + opened_at=None, + opened_at_ms=None, + closed_at=None, + closed_at_ms=None, + exchange_trade_id=None, + trend_plan_id=None, +): + hold_minutes = calc_hold_minutes(hold_seconds) + open_ts = opened_at or app_now_str() + close_ts = closed_at or app_now_str() + open_ts_ms = _to_ms_with_fallback(opened_at_ms, open_ts) + close_ts_ms = _to_ms_with_fallback(closed_at_ms, close_ts) + conn.execute( + "INSERT INTO trade_records (symbol,monitor_type,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage,pnl_amount,hold_seconds,trade_style,risk_amount,planned_rr,actual_rr,hold_minutes,opened_at,opened_at_ms,closed_at,closed_at_ms,result,miss_reason,exchange_trade_id,trend_plan_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, monitor_type, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, + margin_capital, leverage, pnl_amount, hold_seconds, + trade_style, risk_amount, planned_rr, actual_rr, hold_minutes, + open_ts, open_ts_ms, close_ts, close_ts_ms, result, miss_reason, exchange_trade_id, trend_plan_id + ) + ) + + +def calc_duration_text(open_str, close_str): + try: + fmt = "%Y-%m-%dT%H:%M" + o = datetime.strptime(open_str, fmt) + c = datetime.strptime(close_str, fmt) + delta = c - o + seconds = int(delta.total_seconds()) + if seconds <= 0: + return "0分钟" + d = seconds // 86400 + h = (seconds % 86400) // 3600 + m = (seconds % 3600) // 60 + parts = [] + if d: + parts.append(f"{d}天") + if h: + parts.append(f"{h}小时") + if m or not parts: + parts.append(f"{m}分钟") + return " ".join(parts) + except Exception: + return "计算失败" + + +def row_to_dict(row): + return {k: row[k] for k in row.keys()} + + +def enrich_order_item(raw_item, current_capital): + item = dict(raw_item or {}) + margin = float(item.get("margin_capital") or 0) + lev = float(item.get("leverage") or 0) + notional = item.get("notional_value") + ratio = item.get("position_ratio") + if notional is None: + notional = round(margin * lev, 4) if margin and lev else 0 + if ratio is None: + ratio = round(margin / current_capital * 100, 2) if current_capital else 0 + item["notional_value"] = notional + item["position_ratio"] = ratio + item["rr_ratio"] = calc_rr_ratio( + item.get("direction") or "long", + item.get("trigger_price"), + item.get("initial_stop_loss") or item.get("stop_loss"), + item.get("take_profit"), + ) + try: + be = item.get("breakeven_enabled") + item["breakeven_enabled"] = 0 if be is not None and int(be) == 0 else 1 + except Exception: + item["breakeven_enabled"] = 1 + return item + + +def ensure_exchange_live_ready(): + if not LIVE_TRADING_ENABLED: + return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)" + if not (GATE_API_KEY and GATE_API_SECRET): + return False, "缺少 Gate API 密钥配置(GATE_API_KEY / GATE_API_SECRET)" + return True, "" + + +def exchange_private_api_configured(): + """仅表示已配置密钥;与是否允许下单(LIVE_TRADING_ENABLED)无关,用于只读拉仓等。""" + return bool(GATE_API_KEY and GATE_API_SECRET) + + +def _extract_usdt_total(balance): + usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {} + total_map = balance.get("total", {}) if isinstance(balance, dict) else {} + free_map = balance.get("free", {}) if isinstance(balance, dict) else {} + total = usdt_info.get("total") + if total is None: + total = usdt_info.get("equity") + if total is None: + total = total_map.get("USDT") + if total is None: + total = usdt_info.get("free") + if total is None: + total = free_map.get("USDT") + try: + return float(total) if total is not None else None + except Exception: + return None + + +def _extract_usdt_free(balance): + usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {} + free_map = balance.get("free", {}) if isinstance(balance, dict) else {} + free = usdt_info.get("free") + if free is None: + free = free_map.get("USDT") + try: + return float(free) if free is not None else None + except Exception: + return None + + +def _parse_usdt_from_gate_unified_accounts_body(data): + """ + 解析 Gate GET /unified/accounts 响应体中的 USDT(dict 或 list 形态的 balances 均支持)。 + ccxt fetch_balance(unifiedAccount) 在 balances 为数组时会访问 .keys() 崩溃,故资金兜底走此解析。 + """ + if not isinstance(data, dict): + return None + raw_fd = data.get("funding") + if isinstance(raw_fd, (int, float)): + return float(raw_fd) + if isinstance(raw_fd, str) and raw_fd.strip(): + try: + return float(raw_fd) + except Exception: + pass + if isinstance(raw_fd, dict): + u = raw_fd.get("USDT") or raw_fd.get("usdt") + if isinstance(u, dict): + for k in ("equity", "available", "total", "amount"): + v = u.get(k) + if v is not None: + try: + return float(v) + except Exception: + pass + + balances = data.get("balances") + if isinstance(balances, list): + for row in balances: + if not isinstance(row, dict): + continue + sym = str(row.get("currency") or row.get("asset") or row.get("name") or "").upper() + if sym != "USDT": + continue + for k in ("equity", "balance", "available", "total", "amount"): + v = row.get(k) + if v is not None: + try: + return float(v) + except Exception: + pass + elif isinstance(balances, dict): + u = balances.get("USDT") or balances.get("usdt") + if isinstance(u, dict): + for k in ("equity", "available", "total", "amount"): + v = u.get(k) + if v is not None: + try: + return float(v) + except Exception: + pass + + tb = data.get("total_balance") + if isinstance(tb, dict): + u = tb.get("USDT") or tb.get("usdt") + if isinstance(u, (int, float, str)): + try: + return float(u) + except Exception: + pass + if isinstance(u, dict): + for k in ("equity", "available", "amount", "total"): + val = u.get(k) + if val is not None: + try: + return float(val) + except Exception: + pass + return None + + +def _parse_gate_spot_accounts_response_usdt(response): + """解析 GET /spot/accounts 列表中的 USDT(与 fetch_balance spot 同源,ccxt 解析失败时可兜底)。""" + rows = None + if isinstance(response, list): + rows = response + elif isinstance(response, dict): + inner = response.get("result") + if isinstance(inner, list): + rows = inner + elif isinstance(inner, dict) and isinstance(inner.get("list"), list): + rows = inner["list"] + if not rows: + return None + for row in rows: + if not isinstance(row, dict): + continue + if str(row.get("currency") or "").upper() != "USDT": + continue + ts = row.get("total") + if ts is not None and str(ts).strip() != "": + try: + return float(ts) + except Exception: + pass + try: + return float(row.get("available") or 0) + float(row.get("locked") or 0) + except Exception: + pass + return None + + +def _fetch_usdt_by_types(type_candidates): + """统一只用 ccxt.fetch_balance;spot 必须带 marginMode=spot,否则会随 defaultMarginMode 误走 cross_margin。""" + for t in type_candidates: + try: + params = {"type": t} + if t == "spot": + params["marginMode"] = "spot" + bal = exchange.fetch_balance(params=params) + val = _extract_usdt_total(bal) + if val is not None: + return val + except Exception: + continue + return None + + +def _fetch_gate_funding_usdt(): + """ + Gate「资金账户」: + 1) fetch_balance(type=spot, marginMode=spot) — 避免 defaultMarginMode=cross 误走 cross_margin; + 2) privateSpotGetAccounts — 与 1 同源,ccxt 聚合异常或解析不到 USDT 时再试原始列表; + 3) privateUnifiedGetAccounts + 自解析 — 统一账户 balances 常为数组,ccxt unified fetch_balance 会崩。 + """ + spot_seen_ok = False + try: + ensure_markets_loaded() + bal = exchange.fetch_balance(params={"type": "spot", "marginMode": "spot"}) + spot_seen_ok = True + val = _extract_usdt_total(bal) + if val is not None: + return float(val) + except Exception: + pass + + try: + resp = exchange.privateSpotGetAccounts({}) + v = _parse_gate_spot_accounts_response_usdt(resp) + if v is not None: + return float(v) + except Exception: + pass + + try: + raw = exchange.privateUnifiedGetAccounts({}) + body = raw + if isinstance(body, dict) and isinstance(body.get("result"), dict): + body = body["result"] + v = _parse_usdt_from_gate_unified_accounts_body(body) if isinstance(body, dict) else None + if v is not None: + return float(v) + except Exception: + pass + + if spot_seen_ok: + return 0.0 + return None + + +def get_available_trading_usdt(): + ok_live, _ = ensure_exchange_live_ready() + if not ok_live: + return None + for t in ["swap", "spot"]: + try: + params = {"type": t} + if t == "spot": + params["marginMode"] = "spot" + bal = exchange.fetch_balance(params=params) + free_val = _extract_usdt_free(bal) + if free_val is not None: + return free_val + except Exception: + continue + return None + + +def get_synced_leverage(exchange_symbol, direction): + ensure_markets_loaded() + try: + positions = exchange.fetch_positions([exchange_symbol]) + for p in positions: + if not _position_matches_wanted_contract(exchange_symbol, p): + continue + info = p.get("info", {}) or {} + side = (p.get("side") or info.get("posSide") or "").lower() + if GATE_POS_MODE == "hedge" and side and side != direction: + continue + lev = p.get("leverage") + if lev is None or lev == 0 or str(lev) == "0": + lev = info.get("cross_leverage_limit") or info.get("leverage") + if lev: + try: + return int(float(lev)) + except Exception: + pass + except Exception: + pass + return None + + +def friendly_exchange_error(err, available_usdt=None): + msg = str(err) + low = msg.lower() + if ( + "51008" in msg + or "insufficient" in low + or "margin" in low and ("not enough" in low or "不足" in msg) + or "balance" in low and "insufficient" in low + ): + tail = f"(当前交易账户可用约 {round(available_usdt, 4)}U)" if available_usdt is not None else "" + return f"交易所下单失败:保证金不足 {tail}。请降低保证金/杠杆,或先划转USDT到合约账户。" + clean = re.sub(r"\s+", " ", msg).strip() + return f"交易所下单失败:{clean}" + + +def get_exchange_capitals(force=False): + ok_live, _ = ensure_exchange_live_ready() + if not ok_live: + return None, None + now_ts = time.time() + if (not force) and ACCOUNT_BALANCE_CACHE["updated_at"] and now_ts - ACCOUNT_BALANCE_CACHE["updated_at"] < BALANCE_REFRESH_SECONDS: + return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"] + try: + ACCOUNT_BALANCE_CACHE["funding_usdt"] = _fetch_gate_funding_usdt() + except Exception: + ACCOUNT_BALANCE_CACHE["funding_usdt"] = None + try: + ACCOUNT_BALANCE_CACHE["trading_usdt"] = _fetch_usdt_by_types(["swap", "spot"]) + except Exception: + # 勿保留上一次成功请求的旧值:鉴权失败时否则会误以为「合约余额仍能读」 + ACCOUNT_BALANCE_CACHE["trading_usdt"] = None + ACCOUNT_BALANCE_CACHE["updated_at"] = now_ts + return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"] + + +def execute_transfer_usdt(amount, from_account, to_account): + if amount <= 0: + return False, "划转金额必须大于0", None + ok_live, reason = ensure_exchange_live_ready() + if not ok_live: + return False, reason, None + try: + resp = exchange.transfer(TRANSFER_CCY, float(amount), from_account, to_account) + return True, "划转成功", resp + except Exception as e: + msg = str(e) + if "INVALID_KEY" in msg or "Invalid key" in msg: + msg += ( + "。常见原因:① GATE_API_SECRET 错误或 .env 里多了空格/换行;② IP 白名单未包含当前服务器出口 IP;" + "③ Gate「交易账户」类 API Key 若不支持钱包接口则无法走账户内划转 POST /wallet/transfers(需在官网确认该 Key 类型是否开放划转);" + "④ Key 已重置或权限变更。你已勾选现货/统一账户仍报错时,优先核对 Secret 与白名单。" + ) + return False, msg, None + + +def get_account_usdt_total(account_type): + """读取各账户 USDT。funding 走 _fetch_gate_funding_usdt;spot 同样 marginMode=spot,一律 ccxt。""" + raw = (account_type or "").strip().lower() + if raw == "funding": + return _fetch_gate_funding_usdt() + at = raw + try: + params = {"type": at} + if at == "spot": + params["marginMode"] = "spot" + bal = exchange.fetch_balance(params=params) + val = _extract_usdt_total(bal) + if val is not None: + return val + return 0.0 if at == "spot" else None + except Exception: + return None + + +def auto_transfer_once_per_day(): + if not AUTO_TRANSFER_ENABLED: + return + utc_dt = utc_now_dt() + bj = utc_dt.astimezone(APP_TZ) + if bj.hour != AUTO_TRANSFER_BJ_HOUR: + return + transfer_day = utc_calendar_date_str() + conn = get_db() + exists = conn.execute( + "SELECT id FROM transfer_logs WHERE transfer_type=? AND transfer_day=?", + ("auto_daily", transfer_day) + ).fetchone() + if exists: + conn.close() + return + target_amount = AUTO_TRANSFER_AMOUNT + to_balance = get_account_usdt_total(AUTO_TRANSFER_TO) + from_balance = get_account_usdt_total(AUTO_TRANSFER_FROM) + if to_balance is None: + conn.execute( + "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", + ("auto_daily", transfer_day, 0, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO, "failed", f"读取{AUTO_TRANSFER_TO}账户USDT失败") + ) + conn.commit() + conn.close() + return + needed = round(max(target_amount - float(to_balance), 0), 4) + if needed <= 0: + conn.execute( + "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", + ("auto_daily", transfer_day, 0, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO, "skipped", f"{AUTO_TRANSFER_TO}账户已达到目标{target_amount}U") + ) + conn.commit() + conn.close() + return + if from_balance is not None and from_balance < needed: + conn.execute( + "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", + ("auto_daily", transfer_day, needed, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO, "failed", f"{AUTO_TRANSFER_FROM}账户USDT不足,需{needed}U,当前{round(from_balance,4)}U") + ) + conn.commit() + conn.close() + send_wechat_msg( + f"自动划转失败:{AUTO_TRANSFER_FROM}余额不足,需{needed}U,当前{round(from_balance,4)}U\n" + f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" + ) + return + + ok, msg, _ = execute_transfer_usdt(needed, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO) + conn.execute( + "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", + ("auto_daily", transfer_day, needed, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO, "success" if ok else "failed", msg[:500]) + ) + conn.commit() + conn.close() + if ok: + send_wechat_msg( + f"自动划转成功:补足到{target_amount}U,实际划转{needed}U " + f"{AUTO_TRANSFER_FROM}->{AUTO_TRANSFER_TO}\n" + f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" + ) + else: + send_wechat_msg( + f"自动划转失败:计划补足到{target_amount}U,需划转{needed}U\n原因:{msg}\n" + f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" + ) + + +def trading_day_reset_allows_new_open(now): + """是否允许在满足其它风控的前提下于当前时刻新开仓(仅「整点前禁开」守卫)。""" + if not TRADING_DAY_RESET_OPEN_GUARD_ENABLED: + return True + return now.hour >= TRADING_DAY_RESET_HOUR + + +def get_active_position_count(conn): + """与 Gate 主站一致:仅统计 order_monitors.status=active。""" + return int(conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0]) + + +def precheck_risk(conn, symbol, direction): + now = app_now() + if not trading_day_reset_allows_new_open(now): + return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓" + active_count = get_active_position_count(conn) + if active_count >= MAX_ACTIVE_POSITIONS: + return False, f"已达最大持仓数({active_count}/{MAX_ACTIVE_POSITIONS})" + trend_n = conn.execute( + "SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'" + ).fetchone()[0] + if trend_n > 0: + return False, "已存在运行中的趋势回调计划,请先结束该计划" + if direction not in ("long", "short"): + return False, "方向必须为 long 或 short" + if symbol.upper().startswith("BTC") or symbol.upper().startswith("ETH"): + expected = BTC_LEVERAGE + else: + expected = ALT_LEVERAGE + if expected <= 0: + return False, "杠杆配置异常" + return True, "" + + +def precheck_trend_pullback_start(conn): + """趋势回调启动前:不与机器人下单监控达持仓上限并存。""" + now = app_now() + if not trading_day_reset_allows_new_open(now): + return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓" + active_count = get_active_position_count(conn) + if active_count >= MAX_ACTIVE_POSITIONS: + return False, ( + f"已达最大持仓数({active_count}/{MAX_ACTIVE_POSITIONS})," + "请先结束「机器人下单监控」中的持仓,再启动趋势回调" + ) + trend_n = conn.execute( + "SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'" + ).fetchone()[0] + if trend_n > 0: + return False, "已存在运行中的趋势回调计划" + return True, "" + + +def _trend_cleanup_stale_previews(conn): + ms = int(time.time() * 1000) + stale = conn.execute("SELECT id FROM trend_pullback_previews WHERE expires_at_ms < ?", (ms,)).fetchall() + for row in stale: + try: + conn.execute( + "UPDATE trend_pullback_preview_snapshots SET outcome='expired' WHERE preview_id=? AND outcome='open'", + (row["id"],), + ) + except Exception: + pass + conn.execute("DELETE FROM trend_pullback_previews WHERE expires_at_ms < ?", (ms,)) + + +def parse_and_compute_trend_pullback_plan(form_dict): + """ + 解析表单并计算趋势回调预览参数(不写库、不下单)。 + 成功返回 (payload, None);失败返回 (None, 错误文案)。 + """ + d = form_dict or {} + symbol = normalize_symbol_input(d.get("symbol")) + if not symbol: + return None, "symbol 不能为空" + direction = (d.get("direction") or "long").strip().lower() + if direction not in ("long", "short"): + return None, "方向错误" + try: + stop_loss = float(d.get("sl")) + add_upper = float(d.get("add_upper")) + take_profit = float(d.get("take_profit")) + risk_percent = float(d.get("risk_percent") or "5") + except Exception: + return None, "价格或风险比例格式错误" + try: + lev_raw = parse_positive_float(d.get("leverage")) + leverage = int(lev_raw) if lev_raw is not None else infer_leverage(symbol) + except Exception: + return None, "杠杆格式错误" + if leverage <= 0 or risk_percent <= 0: + return None, "杠杆与风险比例必须大于0" + if direction == "long": + if not (stop_loss < add_upper): + return None, "做多:止损价须低于补仓上沿" + else: + if not (stop_loss > add_upper): + return None, "做空:止损价须高于补仓下沿" + snap = get_available_trading_usdt() + if snap is None or snap <= 0: + return None, "无法读取合约账户 USDT 可用余额,请检查 API 与账户类型" + live_price = get_price(symbol) + if live_price is None: + return None, "获取实时价格失败" + exchange_symbol = normalize_exchange_symbol(symbol) + rf = calc_risk_fraction(direction, add_upper, stop_loss) + if rf is None or rf <= 0: + return None, "止损与补仓区间边界组合无法计算风险比例" + risk_budget = float(snap) * (risk_percent / 100.0) + notional = risk_budget / rf + margin_plan = notional / float(leverage) + margin_plan = min(margin_plan, float(snap) * FULL_MARGIN_BUFFER_RATIO) + if margin_plan <= 0: + return None, "计划保证金过小" + try: + target_amt, _ = prepare_order_amount(exchange_symbol, margin_plan, leverage, live_price) + except Exception as e: + return None, str(e) + first_amt = _safe_amount_to_precision(exchange_symbol, target_amt * 0.5) + if first_amt is None or first_amt <= 0: + return None, "首仓张数过小(低于交易所最小张数),请提高风险比例或杠杆" + remainder_total = _safe_amount_to_precision( + exchange_symbol, max(0.0, float(target_amt) - float(first_amt)) + ) + if remainder_total is None: + remainder_total = 0.0 + n_legs, leg_json, per_ref = _trend_build_leg_amounts_json(exchange_symbol, remainder_total, TREND_PULLBACK_DCA_LEGS) + if n_legs <= 0: + return None, "剩余计划张数不足以拆出补仓档(低于交易所最小张数),请提高风险比例、放宽止损与补仓区间间距,或减少补仓档数" + grid = _trend_build_grid_prices(direction, stop_loss, add_upper, n_legs) + if len(grid) != n_legs: + return None, "补仓网格生成失败" + opened_at = app_now_str() + try: + leg_list = json.loads(leg_json) + except Exception: + leg_list = [] + payload = { + "symbol": symbol, + "exchange_symbol": exchange_symbol, + "direction": direction, + "leverage": leverage, + "stop_loss": stop_loss, + "add_upper": add_upper, + "take_profit": take_profit, + "risk_percent": risk_percent, + "snapshot_available_usdt": float(snap), + "snapshot_at": opened_at, + "live_price_ref": float(live_price), + "plan_margin_capital": float(margin_plan), + "target_order_amount": float(target_amt), + "first_order_amount": float(first_amt), + "remainder_total": float(remainder_total), + "dca_legs": int(n_legs), + "per_leg_amount": float(per_ref), + "grid_prices_json": json.dumps(grid), + "leg_amounts_json": leg_json, + "grid": grid, + "leg_amounts": leg_list, + } + return payload, None + + +def prepare_order_amount(exchange_symbol, margin_capital, leverage, fallback_price): + ensure_markets_loaded() + notional = float(margin_capital) * float(leverage) + ticker = exchange.fetch_ticker(exchange_symbol) + price = float(ticker.get("last") or fallback_price) + if price <= 0: + raise ValueError("触发价必须大于 0") + market = exchange.market(exchange_symbol) + contract_size = float(market.get("contractSize") or 1) + if market.get("contract"): + # 合约 amount 按张数/合约乘数解析;ccxt 会再做精度与符号处理 + amount = notional / (price * contract_size) + else: + amount = notional / price + min_amount = (market.get("limits", {}).get("amount", {}) or {}).get("min") + if min_amount and amount < float(min_amount): + raise ValueError(f"下单数量过小,最小数量为 {min_amount}") + amount_precise = float(exchange.amount_to_precision(exchange_symbol, amount)) + if amount_precise <= 0: + raise ValueError("下单数量精度后为 0,请提高基数或降低价格") + return amount_precise, price + + +def _to_positive_float(value): + try: + n = float(value) + return n if n > 0 else None + except Exception: + return None + + +def _extract_order_price_value(order_obj): + if not isinstance(order_obj, dict): + return None + for key in ("average", "price"): + v = _to_positive_float(order_obj.get(key)) + if v is not None: + return v + cost = _to_positive_float(order_obj.get("cost")) + filled = _to_positive_float(order_obj.get("filled")) + if cost is not None and filled is not None and filled > 0: + return cost / filled + info = order_obj.get("info") if isinstance(order_obj.get("info"), dict) else {} + for key in ("avgPx", "fillPx", "avgPrice", "fillPrice", "px"): + v = _to_positive_float(info.get(key)) + if v is not None: + return v + return None + + +def resolve_order_entry_price(order_resp, exchange_symbol, fallback_price): + price = _extract_order_price_value(order_resp) + if price is not None: + return round(price, 8) + order_id = (order_resp or {}).get("id") + if order_id: + try: + fetched = exchange.fetch_order(order_id, exchange_symbol) + fetched_price = _extract_order_price_value(fetched) + if fetched_price is not None: + return round(fetched_price, 8) + except Exception: + pass + fallback = _to_positive_float(fallback_price) + return round(fallback, 8) if fallback is not None else 0.0 + + +def get_contract_size(exchange_symbol): + ensure_markets_loaded() + market = exchange.market(exchange_symbol) + return float(market.get("contractSize") or 1) + + +def parse_positive_float(value): + if value is None: + return None + raw = str(value).strip() + if not raw: + return None + num = float(raw) + if num <= 0: + raise ValueError("数值必须大于0") + return num + + +def build_gate_order_params(direction, reduce_only=False): + params = {} + if reduce_only: + params["reduceOnly"] = True + return params + + +def _gate_contracts_amount_for_tpsl(order, fallback_amount): + for key in ("filled", "amount"): + v = order.get(key) + try: + fv = float(v) + if fv > 0: + return fv + except Exception: + pass + return float(fallback_amount) + + +def _gate_place_tp_sl_orders_legacy_conditional(exchange_symbol, direction, contracts_amount, stop_loss, take_profit): + """ccxt 市价减仓条件单(两张单分别带 stopLossPrice / takeProfitPrice),与官方仓位类触发单等价逻辑不同路径。""" + ensure_markets_loaded() + close_side = "sell" if direction == "long" else "buy" + base = {"reduceOnly": True} + last_err = None + for attempt in range(8): + try: + exchange.create_order( + exchange_symbol, "market", close_side, contracts_amount, None, + dict(base, stopLossPrice=float(stop_loss)), + ) + exchange.create_order( + exchange_symbol, "market", close_side, contracts_amount, None, + dict(base, takeProfitPrice=float(take_profit)), + ) + return + except Exception as e: + last_err = e + time.sleep(0.2 * (attempt + 1)) + raise RuntimeError(f"交易所未接受条件止盈/止损委托参数:{last_err}") + + +def _gate_place_tp_sl_orders_position_price_orders(exchange_symbol, direction, stop_loss, take_profit): + """ + Gate 永续官方仓位类触发单:POST futures/{settle}/price_orders, + order_type=close-long-position / close-short-position,单向全平 close+size=0;双向需 auto_size。 + 与 App 内展示的「条件委托」一致,平仓后仍需 cancel_gate_swap_trigger_orders 避免残留。 + """ + ensure_markets_loaded() + market = exchange.market(exchange_symbol) + if not market.get("swap"): + raise RuntimeError("仅支持永续合约 symbol") + settle = market["settleId"] + contract = market["id"] + order_type = "close-long-position" if direction == "long" else "close-short-position" + close_side = "sell" if direction == "long" else "buy" + if close_side == "sell": + sl_rule, tp_rule = 2, 1 + else: + sl_rule, tp_rule = 1, 2 + initial = { + "contract": contract, + "size": 0, + "price": "0", + "close": True, + "reduce_only": True, + "tif": "ioc", + "text": "api", + } + if GATE_POS_MODE == "hedge": + initial["auto_size"] = "close_long" if direction == "long" else "close_short" + # Gate API 1018:auto_size=close_long|close_short 时 initial.close 须为 false + initial["close"] = False + sl_s = exchange.price_to_precision(exchange_symbol, float(stop_loss)) + tp_s = exchange.price_to_precision(exchange_symbol, float(take_profit)) + + def _payload(trigger_price, rule): + trig = { + "strategy_type": 0, + "price_type": GATE_TPSL_PRICE_TYPE, + "price": trigger_price, + "rule": rule, + } + if GATE_TPSL_TRIGGER_EXPIRATION > 0: + trig["expiration"] = GATE_TPSL_TRIGGER_EXPIRATION + return { + "settle": settle, + "initial": dict(initial), + "trigger": trig, + "order_type": order_type, + } + + last_err = None + for attempt in range(8): + try: + exchange.privateFuturesPostSettlePriceOrders(_payload(sl_s, sl_rule)) + try: + exchange.privateFuturesPostSettlePriceOrders(_payload(tp_s, tp_rule)) + except Exception: + cancel_gate_swap_trigger_orders(exchange_symbol) + raise + return + except Exception as e: + last_err = e + time.sleep(0.2 * (attempt + 1)) + raise RuntimeError(f"交易所未接受仓位类条件止盈/止损:{last_err}") + + +def _gate_place_stop_loss_only_position(exchange_symbol, direction, stop_loss): + """Gate 永续:仅挂仓位类止损触发单(全平),止盈由程序监控市价平仓。""" + ensure_markets_loaded() + market = exchange.market(exchange_symbol) + if not market.get("swap"): + raise RuntimeError("仅支持永续合约 symbol") + settle = market["settleId"] + contract = market["id"] + order_type = "close-long-position" if direction == "long" else "close-short-position" + close_side = "sell" if direction == "long" else "buy" + sl_rule = 2 if close_side == "sell" else 1 + initial = { + "contract": contract, + "size": 0, + "price": "0", + "close": True, + "reduce_only": True, + "tif": "ioc", + "text": "api", + } + if GATE_POS_MODE == "hedge": + initial["auto_size"] = "close_long" if direction == "long" else "close_short" + initial["close"] = False # 与 _gate_place_tp_sl_orders_position_price_orders 相同,Gate 要求 + sl_s = exchange.price_to_precision(exchange_symbol, float(stop_loss)) + + def _payload(trigger_price, rule): + trig = { + "strategy_type": 0, + "price_type": GATE_TPSL_PRICE_TYPE, + "price": trigger_price, + "rule": rule, + } + if GATE_TPSL_TRIGGER_EXPIRATION > 0: + trig["expiration"] = GATE_TPSL_TRIGGER_EXPIRATION + return { + "settle": settle, + "initial": dict(initial), + "trigger": trig, + "order_type": order_type, + } + + last_err = None + for attempt in range(8): + try: + exchange.privateFuturesPostSettlePriceOrders(_payload(sl_s, sl_rule)) + return + except Exception as e: + last_err = e + time.sleep(0.2 * (attempt + 1)) + raise RuntimeError(f"交易所未接受仅止损仓位触发单:{last_err}") + + +def _gate_place_tp_sl_orders(exchange_symbol, direction, contracts_amount, stop_loss, take_profit): + if GATE_TPSL_USE_POSITION_ORDER: + try: + _gate_place_tp_sl_orders_position_price_orders(exchange_symbol, direction, stop_loss, take_profit) + return + except Exception: + pass + _gate_place_tp_sl_orders_legacy_conditional( + exchange_symbol, direction, contracts_amount, stop_loss, take_profit, + ) + + +def ensure_markets_loaded(force=False): + global MARKETS_LOADED + if force or not MARKETS_LOADED: + exchange.load_markets(reload=force) + MARKETS_LOADED = True + + +def place_exchange_order(exchange_symbol, direction, amount, leverage, stop_loss=None, take_profit=None): + ensure_markets_loaded() + exchange.set_leverage(leverage, exchange_symbol) + side = "buy" if direction == "long" else "sell" + params = build_gate_order_params(direction, reduce_only=False) + order = exchange.create_order(exchange_symbol, "market", side, amount, None, params) + order.setdefault("tpsl_attached", False) + if stop_loss and take_profit: + try: + contracts_amt = _gate_contracts_amount_for_tpsl(order, amount) + _gate_place_tp_sl_orders(exchange_symbol, direction, contracts_amt, stop_loss, take_profit) + order["tpsl_attached"] = True + except RuntimeError: + raise + except Exception as e: + raise RuntimeError(f"交易所未接受条件止盈/止损委托,已拒绝开仓:{str(e)}") from e + return order + + +def close_exchange_order(order_row): + ensure_markets_loaded() + exchange_symbol = order_row["exchange_symbol"] or normalize_exchange_symbol(order_row["symbol"]) + amount = float(order_row["order_amount"] or 0) + if amount <= 0: + raise ValueError("平仓失败:缺少有效下单数量") + direction = order_row["direction"] + side = "sell" if direction == "long" else "buy" + params = build_gate_order_params(direction, reduce_only=True) + return exchange.create_order(exchange_symbol, "market", side, amount, None, params) + + +def _gate_swap_trigger_order_params(): + """永续条件单(止盈/止损触发委托)查询/撤销用的 ccxt 参数。""" + p = {"type": "swap", "trigger": True} + try: + exchange.load_unified_status() + if exchange.options.get("unifiedAccount"): + p["unifiedAccount"] = True + except Exception: + pass + return p + + +def cancel_gate_swap_trigger_orders(exchange_symbol): + """ + 仓位已平时撤销该合约下剩余的永续条件委托(trigger / price_orders),避免孤儿单残留。 + 与 App 内「仓位附带止盈止损」不同,本系统挂的是独立触发单,平仓后交易所未必自动撤。 + """ + ok, _ = ensure_exchange_live_ready() + if not ok or not exchange_symbol: + return + ensure_markets_loaded() + params = _gate_swap_trigger_order_params() + sym = exchange_symbol + try: + exchange.cancel_all_orders(sym, params) + return + except Exception: + pass + try: + pending = exchange.fetch_open_orders(sym, params=params) + except Exception: + return + for o in pending or []: + oid = o.get("id") + if oid is None: + continue + try: + exchange.cancel_order(str(oid), sym, params) + except Exception: + pass + + +def cancel_all_open_orders_for_symbol(exchange_symbol): + """策略结束时:尽量撤掉该合约下条件单与普通挂单。""" + cancel_gate_swap_trigger_orders(exchange_symbol) + if not exchange_symbol: + return + ensure_markets_loaded() + plain_params = {"type": "swap"} + try: + exchange.load_unified_status() + if exchange.options.get("unifiedAccount"): + plain_params["unifiedAccount"] = True + except Exception: + pass + try: + exchange.cancel_all_orders(exchange_symbol, plain_params) + except Exception: + pass + try: + pending = exchange.fetch_open_orders(exchange_symbol, params=plain_params) + except Exception: + return + for o in pending or []: + oid = o.get("id") + if oid is None: + continue + try: + exchange.cancel_order(str(oid), exchange_symbol, plain_params) + except Exception: + pass + + +def replace_active_monitor_tpsl_on_exchange(order_row, stop_loss, take_profit): + """移动保本/手动改价:先撤该合约 TP/SL 条件单,再按新价重挂。""" + ok, reason = ensure_exchange_live_ready() + if not ok: + raise RuntimeError(reason or "实盘未就绪") + ex_sym = resolve_monitor_exchange_symbol(order_row) + direction = order_row["direction"] + cancel_gate_swap_trigger_orders(ex_sym) + contracts = get_live_position_contracts(ex_sym, direction) + if contracts is None or float(contracts) <= 0: + raise ValueError("交易所当前无该方向持仓,无法挂止盈止损") + amt = float(contracts) + if amt <= 0: + try: + amt = float(order_row["order_amount"] or 0) + except Exception: + amt = 0 + if amt <= 0: + raise ValueError("无法确定平仓数量") + _gate_place_tp_sl_orders(ex_sym, direction, amt, float(stop_loss), float(take_profit)) + + +def extract_trade_price_from_order(order): + if not order: + return None + for k in ("average", "avgPrice", "price"): + try: + v = float(order.get(k) or 0) + if v > 0: + return v + except Exception: + pass + try: + info = order.get("info") or {} + if isinstance(info, dict): + for k in ("fillPx", "avgPx", "fill_price"): + v = float(info.get(k) or 0) + if v > 0: + return v + except Exception: + pass + return None + + +def is_no_position_error(err_msg): + msg = (err_msg or "").lower() + keywords = [ + "no position", "position does not exist", "position not exist", + "pos size is 0", "nothing to close", "reduceonly", "51008" + ] + return any(k in msg for k in keywords) + + +def get_live_position_contracts(exchange_symbol, direction): + ensure_markets_loaded() + try: + rows = exchange.fetch_positions([exchange_symbol]) + except Exception: + return None + total = 0.0 + for p in rows: + if not _position_matches_wanted_contract(exchange_symbol, p): + continue + info = p.get("info", {}) or {} + side = (p.get("side") or info.get("posSide") or "").lower() + contracts = p.get("contracts") + if contracts is None: + raw_pos = info.get("pos") or info.get("size") + try: + contracts = abs(float(raw_pos)) if raw_pos is not None else 0.0 + except Exception: + contracts = 0.0 + try: + contracts = float(contracts) + except Exception: + contracts = 0.0 + if contracts <= 0: + continue + if GATE_POS_MODE == "hedge": + if side and side != direction: + continue + total += contracts + return total + + +def _select_live_position_row(rows, exchange_symbol, direction, relax_hedge=False): + """在 fetch_positions 结果中取与当前监控方向一致、张数最大的一条(与 get_live_position_contracts 过滤规则一致)。""" + if not rows: + return None + candidates = [] + for p in rows: + if not _position_matches_wanted_contract(exchange_symbol, p): + continue + info = p.get("info", {}) or {} + side = (p.get("side") or info.get("posSide") or "").lower() + contracts = _position_row_effective_contracts(p) + if contracts <= 0: + continue + if (not relax_hedge) and GATE_POS_MODE == "hedge": + if side and side != (direction or "").lower(): + continue + candidates.append((contracts, p)) + if not candidates and (not relax_hedge) and GATE_POS_MODE == "hedge": + return _select_live_position_row(rows, exchange_symbol, direction, relax_hedge=True) + if not candidates: + return None + candidates.sort(key=lambda x: x[0], reverse=True) + return candidates[0][1] + + +def _coerce_float(*values): + for v in values: + if v is None or v == "": + continue + try: + return float(v) + except (TypeError, ValueError): + continue + return None + + +def parse_ccxt_position_metrics(position, order_leverage=None): + """ + 从 ccxt 统一持仓结构解析保证金/名义/未实现盈亏(Gate 等所字段略有差异,做多键兜底)。 + 与 App「仓位保证金」对齐时优先用 initialMargin;缺失时再尝试 info 内字段。 + """ + if not position: + return None + p = position + info = p.get("info", {}) or {} + # Gate 全仓:ccxt 的 initialMargin 常为空;collateral 来自 API 的 margin,与 App「保证金」一致 + initial = _coerce_float(p.get("collateral"), p.get("initialMargin"), p.get("margin")) + if initial is None or initial <= 0: + initial = _coerce_float( + info.get("margin"), + info.get("cross_margin"), + info.get("iso_margin"), + info.get("initial_margin"), + info.get("position_margin"), + info.get("initialMargin"), + ) + notional = _coerce_float(p.get("notional"), p.get("notionalValue")) + if notional is None or notional <= 0: + notional = _coerce_float(info.get("value")) + if notional is not None: + notional = abs(notional) + # 全仓且 API margin 为 0 时:用名义/杠杆粗算展示(与交易所「约占用」接近) + if (initial is None or initial <= 0) and notional and notional > 0 and order_leverage: + try: + lev = float(order_leverage) + if lev > 0: + approx = notional / lev + if approx > 0: + initial = approx + except (TypeError, ValueError): + pass + unrealized = _coerce_float( + p.get("unrealizedPnl"), + info.get("unrealised_pnl"), + info.get("unrealized_pnl"), + ) + mark = _coerce_float(p.get("markPrice"), p.get("mark_price"), info.get("mark_price"), info.get("markPrice")) + out = {} + if initial is not None and initial > 0: + out["initial_margin"] = round(initial, 4) + if notional is not None and notional > 0: + out["notional"] = round(notional, 4) + if unrealized is not None: + out["unrealized_pnl"] = round(unrealized, 6) + if mark is not None and mark > 0: + out["mark_price"] = round(mark, 8) + return out or None + + +def get_live_position_exchange_metrics(exchange_symbol, direction): + ensure_markets_loaded() + if not exchange_private_api_configured() or not exchange_symbol: + return None + try: + rows = exchange.fetch_positions(None, {"settle": "usdt"}) or [] + except Exception: + try: + rows = exchange.fetch_positions([exchange_symbol]) or [] + except Exception: + return None + p = _select_live_position_row(rows, exchange_symbol, direction) + return parse_ccxt_position_metrics(p) + + +def _unified_symbol_for_match(symbol_str): + """统一 BTC/USDT:USDT 与 BTC/USDT 便于与 trade_records.symbol 比对。""" + x = (symbol_str or "").strip().upper() + if ":" in x: + x = x.split(":")[0] + return x + + +def exchange_position_sync_since_ms(): + """Gate fetch_positions_history 的 since(毫秒,含当日 0 点)。""" + s = EXCHANGE_POSITION_SYNC_FROM_BJ + if s: + for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d", 10)): + try: + chunk = s[:ln] if len(s) >= ln else s[:10] + dt = datetime.strptime(chunk, fmt) + aware = dt.replace(tzinfo=APP_TZ) + return int(aware.timestamp() * 1000) + except Exception: + continue + dt0 = app_now() - timedelta(days=90) + try: + aware0 = datetime(dt0.year, dt0.month, dt0.day, 0, 0, 0, tzinfo=APP_TZ) + except Exception: + aware0 = datetime.now(APP_TZ) + return int(aware0.timestamp() * 1000) + + +def _coerce_ts_ms(val): + if val is None or val == "": + return None + try: + v = float(val) + except (TypeError, ValueError): + return None + if v > 1e12: + return int(v) + if v > 1e10: + return int(v) + return int(v * 1000.0) + + +def _normalize_gate_position_history_entry(p): + if not p or not isinstance(p, dict): + return None + info = p.get("info") or {} + sym = p.get("symbol") or "" + side = (p.get("side") or "").strip().lower() + if side not in ("long", "short"): + sz = info.get("accum_size") if info.get("accum_size") is not None else info.get("size") + try: + szf = float(sz) + if szf > 0: + side = "long" + elif szf < 0: + side = "short" + except (TypeError, ValueError): + side = "" + rp = p.get("realizedPnl") + if rp is None: + rp = info.get("pnl") + try: + rp_f = float(rp) if rp is not None and str(rp).strip() != "" else None + except (TypeError, ValueError): + rp_f = None + close_ms = _coerce_ts_ms(p.get("lastUpdateTimestamp")) + if close_ms is None: + close_ms = _coerce_ts_ms(info.get("time")) + open_ms = _coerce_ts_ms(p.get("timestamp")) + if open_ms is None: + open_ms = _coerce_ts_ms(info.get("first_open_time")) + c_raw = str(info.get("contract") or "").strip() + t_raw = info.get("time") + sync_key = f"{c_raw}|{t_raw}|{side}" + return { + "symbol_u": _unified_symbol_for_match(sym), + "side": side, + "close_ms": close_ms, + "open_ms": open_ms, + "pnl": rp_f, + "sync_key": sync_key, + } + + +def fetch_gate_positions_close_history(): + if not exchange_private_api_configured(): + return [] + ensure_markets_loaded() + since_ms = exchange_position_sync_since_ms() + try: + rows = exchange.fetch_positions_history( + None, + since=int(since_ms), + limit=int(EXCHANGE_POSITION_HISTORY_LIMIT), + params={"settle": "usdt"}, + ) + except Exception: + try: + rows = exchange.fetch_positions_history( + None, + since=int(since_ms), + limit=int(EXCHANGE_POSITION_HISTORY_LIMIT), + params={}, + ) + except Exception: + return [] + out = [] + for p in rows or []: + h = _normalize_gate_position_history_entry(p) + if h and h["close_ms"] and h["side"] in ("long", "short") and h["symbol_u"]: + out.append(h) + return out + + +def sync_trend_trade_records_from_exchange(conn): + global _LAST_POSITION_HISTORY_SYNC_AT + if not exchange_private_api_configured(): + return + now = time.time() + if now - _LAST_POSITION_HISTORY_SYNC_AT < 25.0: + return + try: + hist = fetch_gate_positions_close_history() + except Exception: + return + if not hist: + _LAST_POSITION_HISTORY_SYNC_AT = now + return + candidates = conn.execute( + """ + SELECT id, symbol, direction, closed_at, opened_at, trend_plan_id, exchange_sync_key + FROM trade_records + WHERE monitor_type = ? AND (exchange_sync_key IS NULL OR TRIM(exchange_sync_key) = '') + ORDER BY id DESC + LIMIT 120 + """, + (MONITOR_TYPE_TREND,), + ).fetchall() + if not candidates: + _LAST_POSITION_HISTORY_SYNC_AT = now + return + used = set() + for tr in candidates: + tid = None + if "trend_plan_id" in tr.keys() and tr["trend_plan_id"]: + try: + tid = int(tr["trend_plan_id"]) + except (TypeError, ValueError): + tid = None + plan_open_ms = None + if tid: + prow = conn.execute("SELECT opened_at FROM trend_pullback_plans WHERE id=?", (tid,)).fetchone() + if prow and prow["opened_at"]: + plan_open_ms = opened_at_str_to_ms(prow["opened_at"]) + close_ms_trade = opened_at_str_to_ms(tr["closed_at"]) or opened_at_str_to_ms(tr["opened_at"]) + if close_ms_trade is None: + continue + best = None + best_d = None + for h in hist: + sk = h["sync_key"] + if not sk or sk in used: + continue + if h["symbol_u"] != _unified_symbol_for_match(tr["symbol"]): + continue + if h["side"] != (tr["direction"] or "long").strip().lower(): + continue + cm = h["close_ms"] + if cm is None: + continue + if plan_open_ms is not None: + if cm < plan_open_ms - 15 * 60 * 1000: + continue + if cm > plan_open_ms + 15 * 86400 * 1000: + continue + else: + if abs(cm - close_ms_trade) > 3 * 86400 * 1000: + continue + d = abs(cm - close_ms_trade) + if best_d is None or d < best_d: + best_d = d + best = h + if best is None or best_d is None or best_d > 25 * 60 * 1000: + continue + sk = best["sync_key"] + if sk in used: + continue + eo = ms_to_app_local_str(best["open_ms"]) if best.get("open_ms") else None + ec = ms_to_app_local_str(best["close_ms"]) if best.get("close_ms") else None + pnl_val = best.get("pnl") + if pnl_val is None: + pnl_val = 0.0 + conn.execute( + """ + UPDATE trade_records + SET exchange_realized_pnl = ?, exchange_opened_at = ?, exchange_closed_at = ?, exchange_sync_key = ? + WHERE id = ? + """, + (float(pnl_val), eo, ec, sk, int(tr["id"])), + ) + used.add(sk) + _LAST_POSITION_HISTORY_SYNC_AT = now + conn.commit() + + +def trend_plan_history_status_label(status): + s = (status or "").strip().lower() + return { + "stopped_tp": "止盈结束", + "stopped_sl": "止损结束", + "stopped_manual": "手动结束", + }.get(s, status or "-") + + +def _list_window_from_request(): + return resolve_list_window(request.args, session, default_preset=PRESET_UTC_TODAY) + + +def _redirect_records(): + qs = list_window_redirect_query(session) + return redirect(f"/records?{qs}" if qs else "/records") + + +def calc_trend_manual_breakeven_stop(direction, entry_price, offset_pct=None): + """趋势回调手动保本:默认开仓均价 + offset_pct%(多上移、空下移)。""" + try: + e = float(entry_price) + pct = float( + offset_pct + if offset_pct is not None + else TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT + ) + except (TypeError, ValueError): + return None + if e <= 0: + return None + direction = (direction or "long").strip().lower() + if direction == "short": + return e * (1.0 - pct / 100.0) + return e * (1.0 + pct / 100.0) + + +def enrich_active_trend_plan_row(row): + d = row_to_dict(row) + try: + d["breakeven_applied"] = int(d.get("breakeven_applied") or 0) != 0 + except Exception: + d["breakeven_applied"] = False + ex_sym = d.get("exchange_symbol") or normalize_exchange_symbol(d.get("symbol") or "") + direction = (d.get("direction") or "long").lower() + m = get_live_position_exchange_metrics(ex_sym, direction) + if m and m.get("unrealized_pnl") is not None: + d["floating_pnl"] = float(m["unrealized_pnl"]) + else: + d["floating_pnl"] = None + if m and m.get("mark_price") is not None: + d["floating_mark"] = float(m["mark_price"]) + else: + d["floating_mark"] = None + return d + + +def opened_at_str_to_ms(opened_at_str): + if not opened_at_str: + return None + try: + dt = datetime.strptime(str(opened_at_str).strip()[:19], "%Y-%m-%d %H:%M:%S") + except ValueError: + return None + try: + aware = dt.replace(tzinfo=APP_TZ) + return int(aware.timestamp() * 1000) + except Exception: + return None + + +def _to_ms_with_fallback(ms_value, dt_str): + try: + if ms_value is not None and str(ms_value).strip() != "": + v = int(float(ms_value)) + if v > 0: + return v + except Exception: + pass + return opened_at_str_to_ms(dt_str) + + +def ms_to_app_local_str(ms): + if ms is None: + return app_now_str() + try: + dt = datetime.fromtimestamp(ms / 1000.0, tz=timezone.utc).astimezone(APP_TZ) + return dt.replace(tzinfo=None).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return app_now_str() + + +def classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_price): + """根据成交价相对止盈/止损位归类;无法可靠归类时返回 None。""" + try: + tp = float(take_profit) + sl = float(stop_loss) + ex = float(exit_price) + trig = float(trigger_price) + except (TypeError, ValueError): + return None + band = max(abs(trig) * 0.0008, abs(tp - sl) * 0.003, 1e-12) + if direction == "long": + if ex >= tp - band: + return "止盈" + if ex <= sl + band: + return "止损" + else: + if ex <= tp + band: + return "止盈" + if ex >= sl - band: + return "止损" + return None + + +def fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=None): + """取开仓以来最近一笔减仓成交(与方向一致);失败返回 None。""" + if not (GATE_API_KEY and GATE_API_SECRET): + return None + ensure_markets_loaded() + since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) + close_side = "sell" if direction == "long" else "buy" + + def pick_from_trades(trades): + if not trades: + return None + candidates = [] + for t in trades: + if (t.get("side") or "").lower() != close_side: + continue + info = t.get("info") or {} + if not isinstance(info, dict): + info = {} + pos_side = (info.get("posSide") or t.get("posSide") or "").lower() + if GATE_POS_MODE == "hedge": + if pos_side in ("long", "short") and pos_side != direction: + continue + ts = t.get("timestamp") + if ts is None: + continue + candidates.append(t) + if not candidates: + return None + return max(candidates, key=lambda x: x.get("timestamp") or 0) + + try: + trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=100) + hit = pick_from_trades(trades) + if hit is None and since_ms: + trades = exchange.fetch_my_trades(exchange_symbol, since=None, limit=100) + hit = pick_from_trades(trades) + return hit + except Exception: + return None + + +def fetch_closing_fills_for_record(exchange_symbol, direction, opened_at_str, closed_at_str=None, opened_at_ms=None, closed_at_ms=None): + """ + 拉取某条历史记录对应的减仓成交(用于按 id 回填)。 + 返回按时间排序的成交列表。 + """ + if not (GATE_API_KEY and GATE_API_SECRET): + return [] + ensure_markets_loaded() + since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) + close_side = "sell" if direction == "long" else "buy" + closed_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str) if (closed_at_str or closed_at_ms is not None) else None + # 历史记录回填给一点缓冲,兼容成交落在记录时间附近的情况 + if closed_ms is not None: + closed_ms += 6 * 60 * 60 * 1000 + candidates = [] + all_side_candidates = [] + try: + trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=200) + except Exception: + trades = [] + if not trades and since_ms: + try: + trades = exchange.fetch_my_trades(exchange_symbol, since=None, limit=200) + except Exception: + trades = [] + for t in trades or []: + if (t.get("side") or "").lower() != close_side: + continue + ts = t.get("timestamp") + if ts is None: + continue + try: + ts = int(ts) + except Exception: + continue + if since_ms and ts < since_ms: + continue + if closed_ms and ts > closed_ms: + continue + info = t.get("info") or {} + if not isinstance(info, dict): + info = {} + pos_side = (info.get("posSide") or t.get("posSide") or "").lower() + if GATE_POS_MODE == "hedge": + if pos_side in ("long", "short") and pos_side != direction: + continue + all_side_candidates.append(t) + if since_ms and ts < since_ms: + continue + if closed_ms and ts > closed_ms: + continue + candidates.append(t) + candidates.sort(key=lambda x: x.get("timestamp") or 0) + if candidates: + return candidates + + # 严格窗口为空时,降级为“按平仓时间就近匹配”,降低时区/时间误差导致的回填失败。 + all_side_candidates.sort(key=lambda x: x.get("timestamp") or 0) + if not all_side_candidates: + return [] + if not closed_ms: + return all_side_candidates[-20:] + near = [] + for t in all_side_candidates: + ts = t.get("timestamp") + if ts is None: + continue + try: + delta = abs(int(ts) - int(closed_ms)) + except Exception: + continue + # 放宽到前后 7 天 + if delta <= 7 * 24 * 60 * 60 * 1000: + near.append((delta, t)) + if near: + near.sort(key=lambda x: x[0]) + picked = [x[1] for x in near[:20]] + picked.sort(key=lambda x: x.get("timestamp") or 0) + return picked + return all_side_candidates[-20:] + + +def calc_weighted_exit_price(trades): + if not trades: + return None + total_amount = 0.0 + weighted_sum = 0.0 + for t in trades: + try: + price = float(t.get("price") or 0) + amount = float(t.get("amount") or 0) + except Exception: + continue + if price <= 0: + continue + if amount <= 0: + amount = 1.0 + weighted_sum += price * amount + total_amount += amount + if total_amount <= 0: + return None + return weighted_sum / total_amount + + +def resolve_synced_flat_close(row, opened_at_str, opened_at_ms=None): + """ + 交易所已无仓、本地仍为 active 时,推断平仓类型/时间/盈亏。 + 返回 (result, pnl_amount, closed_at_str, miss_reason)。 + """ + direction = row["direction"] + sym = row["symbol"] + trigger_price = row["trigger_price"] + stop_loss = row["stop_loss"] + take_profit = row["take_profit"] + margin_capital = row["margin_capital"] or DAILY_START_CAPITAL + leverage = row["leverage"] or infer_leverage(sym) + exchange_symbol = row["exchange_symbol"] or normalize_exchange_symbol(sym) + + trade = fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=opened_at_ms) + exit_px = None + closed_at_str = app_now_str() + if trade: + try: + exit_px = float(trade.get("price") or 0) or None + except (TypeError, ValueError): + exit_px = None + ts = trade.get("timestamp") + if ts: + closed_at_str = ms_to_app_local_str(int(ts)) + + if exit_px is None or exit_px <= 0: + p = get_price(sym) + if p: + guessed = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, p) + if guessed: + pnl = calc_pnl(direction, trigger_price, p, margin_capital, leverage) + return ( + guessed, + pnl, + closed_at_str, + "未能拉取成交明细,按当前市价与止盈/止损位近似归类(建议核对交易所账单)", + ) + return ( + "外部平仓", + 0.0, + closed_at_str, + "检测到交易所仓位已关闭,且无法从成交记录还原平仓价", + ) + + result = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_px) + pnl = calc_pnl(direction, trigger_price, exit_px, margin_capital, leverage) + if result: + return ( + result, + pnl, + closed_at_str, + "按交易所成交记录同步为止盈/止损平仓", + ) + return ( + "外部平仓", + pnl, + closed_at_str, + "交易所已平仓,成交价不在计划止盈/止损带内(可能为手动或其他类型平仓)", + ) + + +def reconcile_external_closes(conn, days=None): + synced_count = 0 + cutoff_ms = None + if days is not None: + try: + d = int(days) + if d > 0: + cutoff_ms = int((app_now() - timedelta(days=d)).timestamp() * 1000) + except Exception: + cutoff_ms = None + rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() + for r in rows: + if cutoff_ms is not None: + opened_at_v = get_opened_at_value(r) + opened_ms = _to_ms_with_fallback(r["opened_at_ms"] if "opened_at_ms" in r.keys() else None, opened_at_v) + # 手动同步按最近 N 天过滤,避免把更早历史单误同步进来 + if opened_ms is None or opened_ms < cutoff_ms: + continue + exchange_symbol = r["exchange_symbol"] or normalize_exchange_symbol(r["symbol"]) + live_contracts = get_live_position_contracts(exchange_symbol, r["direction"]) + if live_contracts is None: + continue + if live_contracts > 0: + continue + cancel_gate_swap_trigger_orders(exchange_symbol) + opened_at = get_opened_at_value(r) + opened_at_ms = _to_ms_with_fallback(r["opened_at_ms"] if "opened_at_ms" in r.keys() else None, opened_at) + result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close(r, opened_at, opened_at_ms=opened_at_ms) + closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now() + hold_seconds = calc_hold_seconds(opened_at, closed_at_dt) + session_date = r["session_date"] or get_trading_day(closed_at_dt) + update_session_capital(conn, session_date, pnl_amount) + insert_trade_record( + conn, + symbol=r["symbol"], + monitor_type="下单监控", + direction=r["direction"], + trigger_price=r["trigger_price"], + stop_loss=r["stop_loss"], + initial_stop_loss=r["initial_stop_loss"] or r["stop_loss"], + take_profit=r["take_profit"], + margin_capital=r["margin_capital"], + leverage=r["leverage"], + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=r["trade_style"], + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio(r["direction"], r["trigger_price"], r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]), + actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), + result=result, + miss_reason=miss_reason, + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (r["id"],)) + if result in ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓"): + send_wechat_msg( + build_wechat_close_message( + symbol=r["symbol"], + direction=r["direction"], + result=f"{result}(自动同步)", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=r["trigger_price"], + current_price="-", + stop_loss=r["stop_loss"], + take_profit=r["take_profit"], + close_order_id="-", + extra_note=miss_reason, + ) + ) + else: + send_wechat_msg( + build_wechat_close_message( + symbol=r["symbol"], + direction=r["direction"], + result="外部平仓(自动同步)", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=r["trigger_price"], + current_price="-", + stop_loss=r["stop_loss"], + take_profit=r["take_profit"], + close_order_id="-", + extra_note=miss_reason, + ) + ) + synced_count += 1 + return synced_count + +# 获取实时价格 +def get_price(symbol): + try: + ensure_markets_loaded() + return exchange.fetch_ticker(normalize_exchange_symbol(symbol))["last"] + except: + return None + +# 获取5分钟K线收盘价 +def get_5m_close(symbol): + try: + ensure_markets_loaded() + ohlcv = exchange.fetch_ohlcv(normalize_exchange_symbol(symbol), KLINE_TIMEFRAME, limit=1) + return ohlcv[-1][4] if ohlcv else None + except: + return None + + +def _safe_float(v): + try: + return float(v) + except Exception: + return None + + +def _compute_ema(values, period=55): + arr = [float(x) for x in values if x is not None] + if len(arr) < period: + return None + k = 2.0 / (period + 1.0) + ema = arr[0] + for val in arr[1:]: + ema = val * k + ema * (1 - k) + return ema + + +def _status_by_ema55(symbol, timeframe): + try: + bars = exchange.fetch_ohlcv(normalize_exchange_symbol(symbol), timeframe=timeframe, limit=80) + if not bars or len(bars) < 56: + return "横盘", None, None + closes = [float(x[4]) for x in bars if x and len(x) >= 5] + ema55 = _compute_ema(closes, 55) + last_close = closes[-1] + if ema55 is None or last_close <= 0: + return "横盘", last_close, ema55 + diff_pct = (last_close - ema55) / ema55 * 100.0 + if abs(diff_pct) < 0.1: + return "横盘", last_close, ema55 + return ("多头" if diff_pct > 0 else "空头"), last_close, ema55 + except Exception: + return "横盘", None, None + + +def _daily_volume_rank(symbol): + """ + 返回(symbol_rank, total_count),按 quoteVolume 降序,缺失时 fallback 到 baseVolume*last。 + """ + sym_norm = normalize_symbol_input(symbol) + target_base = journal_coin_from_symbol(sym_norm) + + def _ticker_base(sym_text): + s = str(sym_text or "").upper().strip() + if ":" in s: + s = s.split(":", 1)[0] + if "/" in s: + return s.split("/", 1)[0].strip() + if "-" in s: + return s.split("-", 1)[0].strip() + if s.endswith("USDT"): + return s[:-4].strip() + return s + now_ts = time.time() + cached_ok = ( + LIQUIDITY_RANK_CACHE["updated_at"] + and now_ts - float(LIQUIDITY_RANK_CACHE["updated_at"]) < max(30, BALANCE_REFRESH_SECONDS) + ) + if not cached_ok: + try: + ensure_markets_loaded() + tickers = exchange.fetch_tickers() + scored = [] + for s, t in (tickers or {}).items(): + try: + mk = exchange.markets.get(s) + if not mk or not mk.get("swap"): + continue + su = str(s).upper() + if "USDT" not in su: + continue + qv = _safe_float((t or {}).get("quoteVolume")) + if qv is None: + info = (t or {}).get("info") if isinstance((t or {}).get("info"), dict) else {} + qv = _safe_float(info.get("volCcy24h") or info.get("vol24h")) + if qv is None: + bv = _safe_float((t or {}).get("baseVolume")) + lp = _safe_float((t or {}).get("last")) + if bv is not None and lp is not None: + qv = bv * lp + if qv is None or qv <= 0: + continue + scored.append((_ticker_base(s), float(qv))) + except Exception: + continue + scored.sort(key=lambda x: x[1], reverse=True) + ranks = {} + for idx, (base, _) in enumerate(scored, 1): + if base and base not in ranks: + ranks[base] = idx + LIQUIDITY_RANK_CACHE["ranks"] = ranks + LIQUIDITY_RANK_CACHE["total"] = len(scored) + LIQUIDITY_RANK_CACHE["updated_at"] = now_ts + except Exception: + pass + ranks = LIQUIDITY_RANK_CACHE.get("ranks") or {} + total = int(LIQUIDITY_RANK_CACHE.get("total") or 0) + return ranks.get(target_base), total + + +def _key_hard_checks(symbol, direction, upper, lower, monitor_type): + """ + 关键位门控:量能、突破幅度、第二根确认、日成交量前30。 + 使用最近闭合K:breakout=倒数第2根,confirm=倒数第1根。 + """ + out = {"ok": False} + ex_sym = normalize_exchange_symbol(symbol) + bars = exchange.fetch_ohlcv(ex_sym, timeframe=KLINE_TIMEFRAME, limit=80) or [] + if len(bars) < 24: + out["reason"] = "5m K线数量不足" + return out + closed = bars[:-1] if len(bars) >= 3 else bars + if len(closed) < 23: + out["reason"] = "闭合K线不足" + return out + breakout = closed[-2] + confirm = closed[-1] + prev20 = closed[-22:-2] + avg20 = sum(float(x[5]) for x in prev20) / max(len(prev20), 1) + vol_break = float(breakout[5]) + vol_ok = vol_break > avg20 * 1.3 if avg20 > 0 else False + open_b = float(breakout[1]) + close_b = float(breakout[4]) + high_b = float(breakout[2]) + low_b = float(breakout[3]) + amp_pct = abs(close_b - open_b) / open_b * 100 if open_b > 0 else 0 + amp_ok = (amp_pct > 0.03) and (amp_pct < 0.5) + cfm_close = float(confirm[4]) + # 区间极值点严格以前端录入 upper/lower 为准:做多看上沿,做空看下沿 + edge = float(upper) if direction == "long" else float(lower) + breakout_ok = (close_b > float(upper)) if direction == "long" else (close_b < float(lower)) + confirm_ok_raw = (cfm_close > edge) if direction == "long" else (cfm_close < edge) + # 口径收紧:未发生有效突破时,不标记幅度/二确通过,避免出现“还没到位却显示Y” + amp_ok = amp_ok and breakout_ok + confirm_ok = confirm_ok_raw and breakout_ok + rank, total = _daily_volume_rank(symbol) + rank_ok = (rank is not None) and (rank <= 30) + swing4h_pct = 0.0 + try: + seg48 = closed[-48:] if len(closed) >= 48 else closed + hh = max(float(x[2]) for x in seg48) + ll = min(float(x[3]) for x in seg48) + swing4h_pct = ((hh - ll) / ll * 100.0) if ll > 0 else 0.0 + except Exception: + swing4h_pct = 0.0 + out.update( + { + "ok": all([vol_ok, amp_ok, breakout_ok, confirm_ok, rank_ok]), + "vol_ok": vol_ok, + "avg20": avg20, + "vol_break": vol_break, + "amp_ok": amp_ok, + "amp_pct": amp_pct, + "breakout_ok": breakout_ok, + "breakout_close": close_b, + "confirm_ok": confirm_ok, + "confirm_close": cfm_close, + "edge_price": edge, + "rank": rank, + "rank_total": total, + "rank_ok": rank_ok, + "breakout_high": high_b, + "breakout_low": low_b, + "breakout_ts": breakout[0], + "confirm_ts": confirm[0], + "swing4h_pct": swing4h_pct, + "monitor_type": monitor_type, + "direction": direction, + } + ) + return out + + +def calc_price_diff_pct(current_price, target_price): + try: + if target_price is None: + return None, None + t = float(target_price) + if t == 0: + return None, None + c = float(current_price) + diff = c - t + pct = diff / t * 100 + return round(diff, 6), round(pct, 4) + except Exception: + return None, None + + +def can_notify_key_monitor(row, now_dt): + max_notify = int(row["max_notify"] or KEY_ALERT_MAX_TIMES) + if int(row["notification_count"] or 0) >= max_notify: + return False + last_at = row["last_notified_at"] + if not last_at: + return True + try: + last_dt = datetime.strptime(last_at, "%Y-%m-%d %H:%M:%S") + except Exception: + return True + interval_min = int(row["notify_interval_min"] or KEY_ALERT_INTERVAL_MINUTES) + return (now_dt - last_dt).total_seconds() >= interval_min * 60 + + +def breakout_too_far(p, edge_price, limit_pct): + try: + if edge_price is None or float(edge_price) <= 0: + return False + diff_pct = abs(float(p) - float(edge_price)) / float(edge_price) * 100 + return diff_pct > float(limit_pct) + except Exception: + return False + + +def _trend_build_grid_prices(direction, sl, upper, n_legs): + """在 (止损, 补仓区间远侧边界 add_upper) 开区间内生成 n_legs 个补仓触发价(不含端点)。""" + sl, upper = float(sl), float(upper) + out = [] + if n_legs <= 0: + return out + if direction == "long": + if upper <= sl: + return out + span = upper - sl + for i in range(1, n_legs + 1): + t = i / float(n_legs + 1) + out.append(sl + t * span) + out.sort(reverse=True) + else: + if sl <= upper: + return out + span = sl - upper + for i in range(1, n_legs + 1): + t = i / float(n_legs + 1) + out.append(upper + t * span) + out.sort() + return [round(p, 10) for p in out] + + +def _safe_amount_to_precision(exchange_symbol, raw_amount): + """amount_to_precision 在低于最小步长时会抛 InvalidOrder;返回 None 表示不可用。""" + try: + if raw_amount is None: + return None + x = float(raw_amount) + if x <= 0: + return None + return float(exchange.amount_to_precision(exchange_symbol, x)) + except Exception: + return None + + +def _trend_pick_dca_legs_and_per_leg(exchange_symbol, remainder_total, want_legs): + """按交易所最小张数约束,自动减少档位数。""" + ensure_markets_loaded() + market = exchange.market(exchange_symbol) + min_amt = (market.get("limits", {}).get("amount", {}) or {}).get("min") + min_amt = float(min_amt) if min_amt is not None else 0.0 + legs = max(1, int(want_legs)) + rem = float(remainder_total) + while legs >= 1: + per = rem / legs + per_p = _safe_amount_to_precision(exchange_symbol, per) + if per_p is None or per_p <= 0: + legs -= 1 + continue + if min_amt and per_p + 1e-12 < min_amt: + legs -= 1 + continue + return legs, per_p + one = _safe_amount_to_precision(exchange_symbol, rem) + if one is None or one <= 0: + return 0, 0.0 + return 1, one + + +def _trend_build_leg_amounts_json(exchange_symbol, remainder_total, want_legs): + """将剩余计划张数拆成若干补仓市价单张数(JSON 列表),并返回有效档位数。""" + rem = _safe_amount_to_precision(exchange_symbol, float(remainder_total)) + if rem is None or rem <= 0: + return 0, "[]", 0.0 + n, _ = _trend_pick_dca_legs_and_per_leg(exchange_symbol, rem, want_legs) + if n <= 0: + return 0, "[]", 0.0 + if n <= 1: + one = _safe_amount_to_precision(exchange_symbol, rem) + if one is None or one <= 0: + return 0, "[]", 0.0 + return 1, json.dumps([one]), one + unit = _safe_amount_to_precision(exchange_symbol, rem / n) + if unit is None or unit <= 0: + one = _safe_amount_to_precision(exchange_symbol, rem) + if one is None or one <= 0: + return 0, "[]", 0.0 + return 1, json.dumps([one]), one + parts = [] + acc = 0.0 + for _ in range(n - 1): + parts.append(unit) + acc += unit + last = _safe_amount_to_precision(exchange_symbol, max(0.0, rem - acc)) + if last is None or last <= 0: + one = _safe_amount_to_precision(exchange_symbol, rem) + if one is None or one <= 0: + return 0, "[]", 0.0 + return 1, json.dumps([one]), one + parts.append(last) + return n, json.dumps(parts), unit + + +def _trend_market_add_contracts(exchange_symbol, direction, contracts, leverage): + exchange.set_leverage(int(leverage), exchange_symbol) + side = "buy" if direction == "long" else "sell" + params = build_gate_order_params(direction, reduce_only=False) + return exchange.create_order(exchange_symbol, "market", side, float(contracts), None, params) + + +def _trend_refresh_stop_only(exchange_symbol, direction, stop_loss): + cancel_gate_swap_trigger_orders(exchange_symbol) + _gate_place_stop_loss_only_position(exchange_symbol, direction, stop_loss) + + +def apply_trend_pullback_manual_breakeven(conn, row, offset_pct=None): + """运行中趋势计划:将交易所止损移至均价+偏移(默认 0.3%),仅当新止损更优时生效。""" + if (row["status"] or "").strip() != "active": + return False, "计划已结束" + if not int(row["first_order_done"] or 0): + return False, "尚未完成首仓,无法保本" + avg_e = float(row["avg_entry_price"] or 0) + if avg_e <= 0: + return False, "缺少有效持仓均价" + direction = (row["direction"] or "long").lower() + ex_sym = row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"]) + pos = get_live_position_contracts(ex_sym, direction) + if pos is None or float(pos) <= 0: + return False, "交易所当前无该方向持仓" + new_sl_raw = calc_trend_manual_breakeven_stop(direction, avg_e, offset_pct) + if new_sl_raw is None: + return False, "保本价计算失败" + new_sl = round_price_to_exchange(ex_sym, new_sl_raw) + if new_sl is None: + return False, "保本价经交易所精度舍入后无效" + new_sl = float(new_sl) + cur_sl = float(row["stop_loss"] or 0) + if direction == "long": + if new_sl <= cur_sl: + return False, f"新止损 {new_sl} 未高于当前止损 {cur_sl}(多仓需上移)" + else: + if new_sl >= cur_sl: + return False, f"新止损 {new_sl} 未低于当前止损 {cur_sl}(空仓需下移)" + try: + _trend_refresh_stop_only(ex_sym, direction, new_sl) + except Exception as e: + return False, friendly_exchange_error(e) + now_s = app_now_str() + conn.execute( + "UPDATE trend_pullback_plans SET stop_loss=?, breakeven_applied=1, breakeven_applied_at=? WHERE id=?", + (new_sl, now_s, row["id"]), + ) + pct_used = float( + offset_pct + if offset_pct is not None + else TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT + ) + sym = row["symbol"] + send_wechat_msg( + "\n".join( + [ + f"# ✅ {sym} 趋势回调手动保本", + f"**账户:{_wechat_account_label()}**", + f"- 计划 ID:**{row['id']}**", + f"- 方向:{_wechat_direction_text(direction)}", + f"- 持仓均价:{format_price_for_symbol(sym, avg_e)}", + f"- 偏移:{pct_used}%(相对均价)", + f"- 新止损:{format_price_for_symbol(sym, new_sl)}", + f"- 交易所:已更新仓位止损触发单", + ] + ) + ) + return True, None + + +def _trend_weighted_avg(old_avg, old_amt, fill_px, add_amt): + try: + oa, aa = float(old_amt), float(add_amt) + if oa <= 0: + return float(fill_px) + return (float(old_avg) * oa + float(fill_px) * aa) / (oa + aa) + except Exception: + return float(fill_px or 0) + + +def _trend_finalize_plan(conn, row, result_label, exit_price, closed_at=None): + """平仓后记账、撤单、结束计划。""" + sym = row["symbol"] + direction = row["direction"] or "long" + ex_sym = row["exchange_symbol"] or normalize_exchange_symbol(sym) + closed_at = closed_at or app_now_str() + opened_at = row["opened_at"] or app_now_str() + hold_seconds = calc_hold_seconds(opened_at, parse_dt_for_trading_day(closed_at) or app_now()) + margin_cap = float(row["plan_margin_capital"] or 0) + lev = int(row["leverage"] or 1) + avg_e = float(row["avg_entry_price"] or 0) + pnl_amount = calc_pnl(direction, avg_e, float(exit_price), margin_cap, lev) + res = normalize_result_with_pnl(result_label, pnl_amount) + risk_amt = calc_risk_amount_from_plan(direction, float(row["add_upper"]), float(row["stop_loss"]), margin_cap, lev) + planned_rr = calc_rr_ratio(direction, avg_e, float(row["stop_loss"]), float(row["take_profit"])) + try: + cancel_all_open_orders_for_symbol(ex_sym) + except Exception: + try: + cancel_gate_swap_trigger_orders(ex_sym) + except Exception: + pass + session_date = row["session_date"] or get_trading_day() + session_capital = update_session_capital(conn, session_date, pnl_amount) + send_wechat_msg( + build_wechat_close_message( + symbol=sym, + direction=direction, + result=f"{res}({MONITOR_TYPE_TREND})", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=avg_e, + current_price=float(exit_price), + stop_loss=float(row["stop_loss"]), + take_profit=float(row["take_profit"]), + close_order_id="-", + extra_note="计划本金口径:启动时合约可用余额快照;止盈由程序监控", + session_capital_fallback=session_capital, + ) + ) + insert_trade_record( + conn, + symbol=sym, + monitor_type=MONITOR_TYPE_TREND, + direction=direction, + trigger_price=avg_e, + stop_loss=float(row["stop_loss"]), + initial_stop_loss=float(row["stop_loss"]), + take_profit=float(row["take_profit"]), + margin_capital=margin_cap, + leverage=lev, + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style="trend_pullback", + risk_amount=risk_amt, + planned_rr=planned_rr, + actual_rr=calc_actual_rr(pnl_amount, risk_amt), + result=res, + opened_at=opened_at, + closed_at=closed_at, + trend_plan_id=int(row["id"]), + ) + st = "stopped_tp" if result_label == "止盈" else ("stopped_sl" if result_label == "止损" else "stopped_manual") + conn.execute( + "UPDATE trend_pullback_plans SET status=?, message=? WHERE id=?", + (st, res, row["id"]), + ) + + +def check_trend_pullback_plans(): + ok_live, _ = ensure_exchange_live_ready() + if not ok_live: + return + conn = get_db() + rows = conn.execute("SELECT * FROM trend_pullback_plans WHERE status='active'").fetchall() + for row in rows: + try: + sym = row["symbol"] + direction = (row["direction"] or "long").lower() + ex_sym = row["exchange_symbol"] or normalize_exchange_symbol(sym) + sl = float(row["stop_loss"]) + upper = float(row["add_upper"]) + tp = float(row["take_profit"]) + lev = int(row["leverage"] or 1) + p = get_price(sym) + if not p: + continue + pf = float(p) + last_p = row["last_mark_price"] + last_pf = float(last_p) if last_p is not None else pf + + pos = get_live_position_contracts(ex_sym, direction) + if pos is None: + continue + + legs_done = int(row["legs_done"] or 0) + dca_legs = int(row["dca_legs"] or 0) + leg_amounts = [] + try: + leg_amounts = [float(x) for x in json.loads(row["leg_amounts_json"] or "[]")] + except Exception: + leg_amounts = [] + grid = [] + try: + grid = json.loads(row["grid_prices_json"] or "[]") + except Exception: + grid = [] + + hit_tp = (direction == "long" and pf >= tp) or (direction == "short" and pf <= tp) + if hit_tp and pos > 0: + try: + exchange.set_leverage(lev, ex_sym) + side = "sell" if direction == "long" else "buy" + params = build_gate_order_params(direction, reduce_only=True) + close_resp = exchange.create_order(ex_sym, "market", side, float(pos), None, params) + exit_p = extract_trade_price_from_order(close_resp) or pf + except Exception as e: + if not is_no_position_error(str(e)): + continue + exit_p = pf + _trend_finalize_plan(conn, row, "止盈", exit_p) + continue + + if pos <= 0 and int(row["first_order_done"] or 0): + exit_p = pf + _trend_finalize_plan(conn, row, "止损", exit_p) + continue + + if int(row["first_order_done"] or 0) and legs_done < len(grid) and legs_done < len(leg_amounts): + level = float(grid[legs_done]) + fired = False + if direction == "long": + if last_pf > level and pf <= level: + fired = True + else: + if last_pf < level and pf >= level: + fired = True + if fired: + amt = float(exchange.amount_to_precision(ex_sym, leg_amounts[legs_done])) + if amt > 0: + add_resp = _trend_market_add_contracts(ex_sym, direction, amt, lev) + fill_px = extract_trade_price_from_order(add_resp) or pf + old_avg = float(row["avg_entry_price"] or fill_px) + old_open = float(row["order_amount_open"] or 0) + new_open = old_open + amt + new_avg = _trend_weighted_avg(old_avg, old_open, fill_px, amt) + conn.execute( + "UPDATE trend_pullback_plans SET legs_done=?, avg_entry_price=?, order_amount_open=?, last_mark_price=? WHERE id=?", + (legs_done + 1, new_avg, new_open, pf, row["id"]), + ) + row = conn.execute("SELECT * FROM trend_pullback_plans WHERE id=?", (row["id"],)).fetchone() + try: + _trend_refresh_stop_only(ex_sym, direction, sl) + except Exception: + pass + + conn.execute( + "UPDATE trend_pullback_plans SET last_mark_price=? WHERE id=?", + (pf, row["id"]), + ) + except Exception: + continue + conn.commit() + conn.close() + + +# 关键位监控(前端已下线时仍保留函数体,后台默认不再调用) +def check_key_monitors(): + conn = get_db() + rows = conn.execute("SELECT * FROM key_monitors").fetchall() + for r in rows: + sym, typ, up, low = r["symbol"], r["monitor_type"], r["upper"], r["lower"] + direction = (r["direction"] or "long").lower() + now_dt = app_now() + if not can_notify_key_monitor(r, now_dt): + continue + try: + checks = _key_hard_checks(sym, direction, up, low, typ) + except Exception: + checks = {"ok": False} + if not checks.get("ok"): + continue + btc8h_status, _, _ = _status_by_ema55("BTC/USDT", "8h") + coin4h_status, _, _ = _status_by_ema55(sym, "4h") + risk_tip = None + if (direction == "long" and coin4h_status == "空头") or (direction == "short" and coin4h_status == "多头"): + risk_tip = "当前信号与本币4h(EMA55)主趋势逆势,建议降低仓位并严格执行止损。" + box_h = abs(float(up) - float(low)) if up is not None and low is not None else 0.0 + c_close = float(checks.get("confirm_close") or 0) + b_high = float(checks.get("breakout_high") or 0) + b_low = float(checks.get("breakout_low") or 0) + key_price = float(low) if direction == "long" else float(up) + if direction == "long": + tp1 = c_close + box_h + tp2 = c_close + box_h * 1.5 + sl1 = b_low * (1 - 0.002) if b_low > 0 else None + sl2 = key_price * (1 - 0.002) if key_price > 0 else None + else: + tp1 = c_close - box_h + tp2 = c_close - box_h * 1.5 + sl1 = b_high * (1 + 0.002) if b_high > 0 else None + sl2 = key_price * (1 + 0.002) if key_price > 0 else None + hard_lines = [ + f"量能:{'通过' if checks['vol_ok'] else '不通过'}(突破K量 {round(checks['vol_break'], 4)} / 前20均量 {round(checks['avg20'], 4)},阈值1.3x)", + f"突破价位:{'通过' if checks['breakout_ok'] else '不通过'}(突破K收盘 {round(float(checks['breakout_close']), 8)},关键位 {checks['edge_price']})", + f"突破K幅度:{'通过' if checks['amp_ok'] else '不通过'}({round(checks['amp_pct'], 4)}%,要求0.03%~0.5%)", + f"第二根确认:{'通过' if checks['confirm_ok'] else '不通过'}(确认收盘 {checks['confirm_close']},关键位 {checks['edge_price']})", + f"日成交量排名:{'通过' if checks['rank_ok'] else '不通过'}({checks['rank']}/{checks['rank_total']},要求前30)", + ] + op_lines = [ + f"方案A:止盈=箱体1.0倍({round(tp1, 8) if tp1 else '-' }),止损=突破K极值外0.2%({round(sl1, 8) if sl1 else '-' })", + f"方案B:止盈=箱体1.5倍({round(tp2, 8) if tp2 else '-' }),止损=箱体关键位外0.2%({round(sl2, 8) if sl2 else '-' })", + ] + trigger_time = ms_to_app_local_str(int(checks["confirm_ts"])) if checks.get("confirm_ts") else app_now_str() + msg = build_wechat_key_monitor_message( + symbol=sym, + direction=direction, + monitor_type=typ, + trigger_time=trigger_time, + key_price=key_price, + confirm_close=checks["confirm_close"], + hard_lines=hard_lines, + btc8h_status=btc8h_status, + coin4h_status=coin4h_status, + swing4h_pct=checks.get("swing4h_pct") or 0.0, + op_lines=op_lines, + risk_tip=risk_tip, + ) + send_wechat_msg(msg) + new_count = int(r["notification_count"] or 0) + 1 + max_n = int(r["max_notify"] or KEY_ALERT_MAX_TIMES) + conn.execute( + "UPDATE key_monitors SET notification_count = ?, last_notified_at = ? WHERE id = ?", + (new_count, app_now_str(), r["id"]), + ) + if new_count >= max_n: + insert_key_monitor_history(conn, r, new_count, msg, "alerts_complete") + conn.execute("DELETE FROM key_monitors WHERE id = ?", (r["id"],)) + send_wechat_msg( + "\n".join( + [ + f"# 🧾 {r['symbol']} 关键位监控结束", + "", + f"- 原因:已满 {max_n} 次提醒", + "- 状态:已自动结束并记入历史", + ] + ) + ) + conn.commit() + conn.close() + +# 止盈止损监控(已修复:严格区分多空,无默认做多) +def check_order_monitors(): + conn = get_db() + rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() + for r in rows: + pid, sym, direction, trigger_price, stop_loss, take_profit = r["id"], r["symbol"], r["direction"], r["trigger_price"], r["stop_loss"], r["take_profit"] + margin_capital = r["margin_capital"] or DAILY_START_CAPITAL + leverage = r["leverage"] or infer_leverage(sym) + session_date = r["session_date"] or get_trading_day() + p = get_price(sym) + if not p: continue + + # 到达设定 R 倍后,按阶梯持续上移止损(本地风控层) + risk_amount = float(r["risk_amount"] or 0) + breakeven_armed = int(r["breakeven_armed"] or 0) + trigger_rr = float(r["breakeven_rr_trigger"] or BREAKEVEN_RR_TRIGGER) + step_r = float(r["breakeven_step_r"] or BREAKEVEN_STEP_R or 1.0) + step_r = 1.0 if step_r <= 0 else step_r + breakeven_enabled = True + try: + if "breakeven_enabled" in r.keys(): + breakeven_enabled = int(r["breakeven_enabled"] or 0) != 0 + except Exception: + breakeven_enabled = True + if breakeven_enabled and risk_amount > 0 and trigger_rr > 0: + now_pnl = calc_pnl(direction, trigger_price, p, margin_capital, leverage) + now_rr = now_pnl / risk_amount + if now_rr >= trigger_rr: + steps = int((now_rr - trigger_rr) // step_r) + locked_r = max(0.0, steps * step_r) + notional = float(margin_capital or 0) * float(leverage or 0) + risk_frac = (risk_amount / notional) if notional > 0 else None + if risk_frac and risk_frac > 0: + new_sl = calc_breakeven_stop( + direction, + trigger_price, + risk_frac, + locked_r=locked_r, + offset_pct=float(r["breakeven_offset_pct"] or BREAKEVEN_OFFSET_PCT), + ) + if new_sl is not None: + should_move = (direction == "short" and new_sl < float(stop_loss)) or ( + direction == "long" and new_sl > float(stop_loss) + ) + if should_move: + was_armed = breakeven_armed + ex_sym = resolve_monitor_exchange_symbol(r) + new_sl = round_price_to_exchange(ex_sym, new_sl) + tp_ex = float(take_profit or 0) + ok_live, _live_reason = ensure_exchange_live_ready() + synced_ex = not ok_live + if ok_live and tp_ex > 0: + try: + replace_active_monitor_tpsl_on_exchange(r, new_sl, tp_ex) + synced_ex = True + _clear_breakeven_exchange_warn(pid) + except Exception as e: + print( + f"[breakeven] exchange tpsl replace failed order={pid} {sym}: {e}", + flush=True, + ) + _send_breakeven_exchange_warn_once( + pid, + f"⚠️ {sym} 移动保本止损未同步交易所:{friendly_exchange_error(e)}", + ) + elif ok_live: + print( + f"[breakeven] skip exchange order={pid} {sym}: invalid take_profit", + flush=True, + ) + if synced_ex: + conn.execute( + "UPDATE order_monitors SET stop_loss=?, breakeven_armed=1, breakeven_price=? WHERE id=?", + (new_sl, new_sl, pid), + ) + stop_loss = new_sl + breakeven_armed = 1 + if not was_armed: + arm_txt = "保本止盈" + be_msg = build_wechat_breakeven_message( + sym, + direction, + arm_txt, + now_rr, + locked_r, + new_sl, + ) + if ok_live: + be_msg += "\n- 交易所:已先撤后挂止盈止损" + send_wechat_msg(be_msg) + + res = None + # 做多 + if direction == "long": + if p >= take_profit: res = "止盈" + elif p <= stop_loss: res = "止损" + # 做空 + elif direction == "short": + if p <= take_profit: res = "止盈" + elif p >= stop_loss: res = "止损" + + if res: + now = app_now() + opened_at = get_opened_at_value(r) + opened_at_ms = (r["opened_at_ms"] if "opened_at_ms" in r.keys() else None) + closed_at = now.strftime("%Y-%m-%d %H:%M:%S") + hold_seconds = calc_hold_seconds(opened_at, now) + pnl_amount = calc_pnl(direction, trigger_price, p, margin_capital, leverage) + if res == "止损" and float(pnl_amount or 0) > 0: + res = "移动止盈" if breakeven_armed else "保本止盈" + else: + res = normalize_result_with_pnl(res, pnl_amount) + close_order_id = "" + try: + close_resp = close_exchange_order(r) + close_order_id = close_resp.get("id", "") + # 平仓入库优先使用交易所返回成交价;拿不到再回退拉成交明细。 + exit_p = extract_trade_price_from_order(close_resp) + if exit_p and exit_p > 0: + pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) + guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) + if guessed_res: + res = normalize_result_with_pnl(guessed_res, pnl_amount) + else: + res = normalize_result_with_pnl(res, pnl_amount) + else: + ex_sym = r["exchange_symbol"] or normalize_exchange_symbol(sym) + tr = fetch_latest_closing_fill( + ex_sym, + direction, + opened_at, + opened_at_ms=opened_at_ms, + ) + if tr and tr.get("price"): + try: + exit_p = float(tr["price"]) + pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) + guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) + if guessed_res: + if guessed_res == "止损" and float(pnl_amount or 0) > 0: + res = "移动止盈" if breakeven_armed else "保本止盈" + else: + res = normalize_result_with_pnl(guessed_res, pnl_amount) + else: + res = normalize_result_with_pnl(res, pnl_amount) + except (TypeError, ValueError): + pass + ts = tr.get("timestamp") + if ts: + closed_at = ms_to_app_local_str(int(ts)) + hold_seconds = calc_hold_seconds( + opened_at, parse_dt_for_trading_day(closed_at) or now + ) + except Exception as e: + if is_no_position_error(str(e)): + ex_sym = r["exchange_symbol"] or normalize_exchange_symbol(sym) + cancel_gate_swap_trigger_orders(ex_sym) + tr = fetch_latest_closing_fill( + ex_sym, + direction, + opened_at, + opened_at_ms=opened_at_ms, + ) + if tr and tr.get("price"): + try: + exit_p = float(tr["price"]) + pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) + # 交易所已返回真实成交价时,以真实成交结果为准,避免本地轮询竞态导致误判。 + guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) + if guessed_res: + if guessed_res == "止损" and float(pnl_amount or 0) > 0: + res = "移动止盈" if breakeven_armed else "保本止盈" + else: + res = normalize_result_with_pnl(guessed_res, pnl_amount) + else: + res = normalize_result_with_pnl(res, pnl_amount) + except (TypeError, ValueError): + pass + ts = tr.get("timestamp") + if ts: + closed_at = ms_to_app_local_str(int(ts)) + hold_seconds = calc_hold_seconds( + opened_at, parse_dt_for_trading_day(closed_at) or now + ) + insert_trade_record( + conn, + symbol=sym, + monitor_type="下单监控", + direction=direction, + trigger_price=trigger_price, + stop_loss=stop_loss, + initial_stop_loss=r["initial_stop_loss"] or stop_loss, + take_profit=take_profit, + margin_capital=margin_capital, + leverage=leverage, + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=r["trade_style"], + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or stop_loss, take_profit), + actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), + result=res, + miss_reason="触发价已触达,仓位已由交易所止盈/止损或其他方式平掉(本地补记)", + opened_at=opened_at, + closed_at=closed_at, + ) + session_capital = update_session_capital(conn, session_date, pnl_amount) + send_wechat_msg( + build_wechat_close_message( + symbol=sym, + direction=direction, + result=f"{res}(交易所已先行平仓)", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=trigger_price, + current_price=p, + stop_loss=stop_loss, + take_profit=take_profit, + close_order_id="-", + extra_note="本地补记:仓位由交易所止盈/止损或其他方式先行平掉", + session_capital_fallback=session_capital, + ) + ) + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (pid,)) + conn.commit() + continue + conn.execute("UPDATE order_monitors SET status='error' WHERE id=?", (pid,)) + conn.commit() + send_wechat_msg( + build_wechat_monitor_error_message( + symbol=sym, + direction=direction, + scene=f"触发{res}后交易所平仓失败", + error_text=str(e), + ) + ) + continue + cancel_gate_swap_trigger_orders(r["exchange_symbol"] or normalize_exchange_symbol(sym)) + session_capital = update_session_capital(conn, session_date, pnl_amount) + send_wechat_msg( + build_wechat_close_message( + symbol=sym, + direction=direction, + result=res, + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=trigger_price, + current_price=p, + stop_loss=stop_loss, + take_profit=take_profit, + close_order_id=close_order_id or "-", + session_capital_fallback=session_capital, + ) + ) + insert_trade_record( + conn, + symbol=sym, + monitor_type="下单监控", + direction=direction, + trigger_price=trigger_price, + stop_loss=stop_loss, + initial_stop_loss=r["initial_stop_loss"] or stop_loss, + take_profit=take_profit, + margin_capital=margin_capital, + leverage=leverage, + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=r["trade_style"], + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or stop_loss, take_profit), + actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), + result=res, + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, pid)) + conn.commit() + conn.close() + + +def force_close_before_reset(): + if not FORCE_CLOSE_ENABLED: + return + now = app_now() + # 每天北京时间指定整点小时内执行一次性兜底清仓(默认 00:xx) + if now.hour != FORCE_CLOSE_BJ_HOUR: + return + conn = get_db() + rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() + for r in rows: + p = get_price(r["symbol"]) + if not p: + continue + direction = r["direction"] + trigger_price = r["trigger_price"] + margin_capital = r["margin_capital"] or DAILY_START_CAPITAL + leverage = r["leverage"] or infer_leverage(r["symbol"]) + session_date = r["session_date"] or get_trading_day(now) + opened_at = get_opened_at_value(r) + closed_at = now.strftime("%Y-%m-%d %H:%M:%S") + hold_seconds = calc_hold_seconds(opened_at, now) + pnl_amount = calc_pnl(direction, trigger_price, p, margin_capital, leverage) + try: + close_resp = close_exchange_order(r) + close_order_id = close_resp.get("id", "") + cancel_gate_swap_trigger_orders(r["exchange_symbol"] or normalize_exchange_symbol(r["symbol"])) + except Exception as e: + conn.execute("UPDATE order_monitors SET status='error' WHERE id=?", (r["id"],)) + conn.commit() + send_wechat_msg( + build_wechat_monitor_error_message( + symbol=r["symbol"], + direction=direction, + scene="强制清仓失败", + error_text=str(e), + ) + ) + continue + session_capital = update_session_capital(conn, session_date, pnl_amount) + insert_trade_record( + conn, + symbol=r["symbol"], + monitor_type="下单监控", + direction=direction, + trigger_price=trigger_price, + stop_loss=r["stop_loss"], + initial_stop_loss=r["initial_stop_loss"] or r["stop_loss"], + take_profit=r["take_profit"], + margin_capital=margin_capital, + leverage=leverage, + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=r["trade_style"], + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]), + actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), + result="强制清仓", + miss_reason=f"北京时间 {FORCE_CLOSE_BJ_HOUR}:00 整点风控清仓", + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, r["id"])) + send_wechat_msg( + build_wechat_close_message( + symbol=r["symbol"], + direction=direction, + result="强制清仓", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=trigger_price, + current_price=p, + stop_loss=r["stop_loss"], + take_profit=r["take_profit"], + close_order_id=close_order_id or "-", + extra_note=f"北京时间 {FORCE_CLOSE_BJ_HOUR}:00 整点风控清仓", + session_capital_fallback=session_capital, + ) + ) + conn.commit() + conn.close() + +# 后台线程 +def background_task(): + while True: + if not license_lib.is_licensed(): + time.sleep(MONITOR_POLL_SECONDS) + continue + try: + auto_transfer_once_per_day() + conn = get_db() + reconcile_external_closes(conn) + conn.commit() + conn.close() + force_close_before_reset() + check_trend_pullback_plans() + check_order_monitors() + except: + pass + time.sleep(MONITOR_POLL_SECONDS) + + +# ====================== 登录路由 ====================== +@app.route("/login", methods=["GET", "POST"]) +def login(): + if AUTH_DISABLED: + session["logged_in"] = True + return redirect("/") + if request.method == "POST": + username = request.form.get("username") + password = request.form.get("password") + if username == USERNAME and password == PASSWORD: + session["logged_in"] = True + return redirect("/") + else: + flash("账号或密码错误") + return render_template("login.html", exchange_display=EXCHANGE_DISPLAY_NAME) + +@app.route("/logout") +def logout(): + session.clear() + return redirect("/" if AUTH_DISABLED else "/login") + +# 登录校验装饰器 +def login_required(f): + @wraps(f) + def decorated(*args, **kwargs): + if AUTH_DISABLED: + return f(*args, **kwargs) + if not session.get("logged_in"): + return redirect("/login") + return f(*args, **kwargs) + return decorated + + +@app.route("/sync_positions") +@login_required +def sync_positions(): + days_raw = (request.args.get("days") or "").strip() + sync_days = None + if days_raw: + try: + sync_days = max(1, min(365, int(days_raw))) + except Exception: + sync_days = None + conn = get_db() + synced = reconcile_external_closes(conn, days=sync_days) + conn.commit() + conn.close() + if sync_days is not None: + flash(f"同步完成:最近 {sync_days} 天内 {synced} 笔持仓已按交易所状态更新") + else: + flash(f"同步完成:{synced} 笔持仓已按交易所状态更新") + return redirect("/") + + +@app.route("/api/sync_positions", methods=["POST"]) +@login_required +def api_sync_positions(): + payload = request.get_json(silent=True) or {} + days_raw = str(payload.get("days", "")).strip() + if not days_raw: + return jsonify({"ok": False, "msg": "请填写天数"}), 400 + try: + days = int(days_raw) + except Exception: + return jsonify({"ok": False, "msg": "天数必须是整数"}), 400 + if days < 1 or days > 365: + return jsonify({"ok": False, "msg": "天数范围 1-365"}), 400 + conn = get_db() + synced = reconcile_external_closes(conn, days=days) + conn.commit() + conn.close() + return jsonify({"ok": True, "days": days, "synced": int(synced)}) + + +# ====================== 主页面 ====================== +def render_main_page(page="trade"): + now = app_now() + trading_day = get_trading_day(now) + list_window = _list_window_from_request() + start_bj, end_bj = utc_window_to_bj_sql_strings(list_window["start_utc"], list_window["end_utc"], APP_TZ) + conn = get_db() + session_row = ensure_session(conn, trading_day) + local_current_capital = float(session_row["current_capital"]) + funding_capital, trading_capital = get_exchange_capitals() + # 资金账户:仅展示交易所读取结果(含 0)。不可用 TOTAL_CAPITAL 兜底,否则会与实盘不符。 + funding_usdt = round(funding_capital, 2) if funding_capital is not None else None + current_capital = round(trading_capital, 2) if trading_capital is not None else round(local_current_capital, 2) + recommended_capital = round(get_recommended_capital(current_capital), 2) + key_list = conn.execute("SELECT * FROM key_monitors").fetchall() + key_history = conn.execute("SELECT * FROM key_monitor_history ORDER BY id DESC LIMIT 80").fetchall() + stats_bundle = compute_stats_bundle(conn, trading_day, now) + raw_order_list = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() + order_list = [] + for o in raw_order_list: + order_list.append(enrich_order_item(row_to_dict(o), current_capital)) + if page in ("trade", "records", "plan_history"): + try: + sync_trend_trade_records_from_exchange(conn) + except Exception: + pass + if page in ("records", "plan_history"): + raw_records = conn.execute( + "SELECT * FROM trade_records WHERE COALESCE(closed_at, created_at, opened_at) >= ? " + "AND COALESCE(closed_at, created_at, opened_at) <= ? ORDER BY id DESC LIMIT 2000", + (start_bj, end_bj), + ).fetchall() + else: + raw_records = conn.execute("SELECT * FROM trade_records ORDER BY id DESC LIMIT 500").fetchall() + records = [to_effective_trade_dict(r) for r in raw_records] + total = len(records) + miss_count = sum(1 for r in records if (r.get("effective_result") or "") == "错过") + win = sum(1 for r in records if (r.get("effective_result") or "") in ("止盈", "保本止盈", "移动止盈")) + occupied_miss_total = sum( + 1 + for r in records + if (r.get("effective_result") or "") == "错过" + and ("持仓占用" in str(r.get("effective_miss_reason") or "")) + ) + rate = round(win/total*100,2) if total else 0 + active_count = get_active_position_count(conn) + trend_active = conn.execute( + "SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'" + ).fetchone()[0] + trend_plans_raw = conn.execute( + "SELECT * FROM trend_pullback_plans WHERE status='active' ORDER BY id DESC" + ).fetchall() + trend_plans = [enrich_active_trend_plan_row(r) for r in trend_plans_raw] + plan_history = [] + preview_snapshots = [] + if page == "plan_history": + plan_history_raw = conn.execute( + "SELECT * FROM trend_pullback_plans WHERE status != 'active' " + "AND COALESCE(opened_at, '') >= ? AND COALESCE(opened_at, '') <= ? ORDER BY id DESC LIMIT 500", + (start_bj, end_bj), + ).fetchall() + for pr in plan_history_raw: + pd = row_to_dict(pr) + pd["status_label"] = trend_plan_history_status_label(pd.get("status")) + plan_history.append(pd) + snap_rows = conn.execute( + "SELECT * FROM trend_pullback_preview_snapshots WHERE COALESCE(preview_created_at, '') >= ? " + "AND COALESCE(preview_created_at, '') <= ? ORDER BY id DESC LIMIT 500", + (start_bj, end_bj), + ).fetchall() + for sr in snap_rows: + sd = row_to_dict(sr) + sd["outcome_label"] = preview_snapshot_outcome_label(sd.get("outcome")) + preview_snapshots.append(sd) + can_trade = ( + trading_day_reset_allows_new_open(now) + and active_count < MAX_ACTIVE_POSITIONS + and int(trend_active or 0) == 0 + ) + trend_preview = None + trend_preview_levels = [] + preview_expires_ms = None + trend_preview_expired = False + trend_preview_id_arg = "" + if page == "trade": + _trend_cleanup_stale_previews(conn) + trend_preview_id_arg = (request.args.get("preview_id") or "").strip() + if trend_preview_id_arg: + pr = conn.execute( + "SELECT * FROM trend_pullback_previews WHERE id=?", + (trend_preview_id_arg,), + ).fetchone() + now_ms = int(time.time() * 1000) + if pr and int(pr["expires_at_ms"] or 0) >= now_ms: + trend_preview = row_to_dict(pr) + preview_expires_ms = int(pr["expires_at_ms"]) + try: + grid = json.loads(trend_preview.get("grid_prices_json") or "[]") + legs = json.loads(trend_preview.get("leg_amounts_json") or "[]") + except Exception: + grid, legs = [], [] + for i, pair in enumerate(zip(grid, legs), 1): + trend_preview_levels.append({"i": i, "price": pair[0], "contracts": pair[1]}) + elif pr: + trend_preview_expired = True + conn.close() + return render_template( + "index.html", + page=page, + key=key_list, + key_history=key_history, + stats_bundle=stats_bundle, + order=order_list, + record=records, + total=total, + miss_count=miss_count, + rate=rate, + trading_day=trading_day, + funding_usdt=funding_usdt, + daily_start_capital=DAILY_START_CAPITAL, + current_capital=current_capital, + recommended_capital=recommended_capital, + btc_leverage=BTC_LEVERAGE, + alt_leverage=ALT_LEVERAGE, + reset_hour=TRADING_DAY_RESET_HOUR, + balance_refresh_seconds=BALANCE_REFRESH_SECONDS, + auto_transfer_enabled=AUTO_TRANSFER_ENABLED, + auto_transfer_amount=AUTO_TRANSFER_AMOUNT, + auto_transfer_from=AUTO_TRANSFER_FROM, + auto_transfer_to=AUTO_TRANSFER_TO, + auto_transfer_bj_hour=AUTO_TRANSFER_BJ_HOUR, + full_margin_buffer_ratio=FULL_MARGIN_BUFFER_RATIO, + price_refresh_seconds=PRICE_REFRESH_SECONDS, + active_count=active_count, + max_active_positions=MAX_ACTIVE_POSITIONS, + can_trade=can_trade, + trend_plans=trend_plans, + plan_history=plan_history, + preview_snapshots=preview_snapshots, + exchange_sync_from_label=(EXCHANGE_POSITION_SYNC_FROM_BJ or "最近90天"), + trend_pullback_dca_legs=TREND_PULLBACK_DCA_LEGS, + trend_pullback_preview_ttl=TREND_PULLBACK_PREVIEW_TTL_SECONDS, + trend_preview=trend_preview, + trend_preview_levels=trend_preview_levels, + preview_expires_ms=preview_expires_ms, + trend_preview_expired=trend_preview_expired, + trend_preview_id_arg=trend_preview_id_arg, + trend_preview_max_drift_pct=TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT, + trend_manual_breakeven_offset_pct=TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT, + list_window=list_window, + list_window_presets={ + "utc_today": PRESET_UTC_TODAY, + "utc_last24h": PRESET_UTC_LAST24H, + "utc_last7d": PRESET_UTC_LAST7D, + "custom": PRESET_CUSTOM, + }, + focus_key_id=(key_list[0]["id"] if key_list else None), + focus_order_id=(order_list[0]["id"] if order_list else None), + data_export_version=3, + key_alert_max_times=KEY_ALERT_MAX_TIMES, + risk_percent=RISK_PERCENT, + breakeven_rr_trigger=BREAKEVEN_RR_TRIGGER, + breakeven_offset_pct=BREAKEVEN_OFFSET_PCT, + occupied_miss_total=occupied_miss_total, + price_fmt=format_price_for_symbol, + amt_fmt=format_amount_for_symbol, + money_fmt=format_money_usdt, + entry_reason_options=list(ENTRY_REASON_OPTIONS), + entry_reason_other_value=ENTRY_REASON_OTHER, + exchange_display=EXCHANGE_DISPLAY_NAME, + ) + + +@app.route("/") +@login_required +def index(): + return redirect("/trade") + + +@app.route("/trade") +@login_required +def trade_page(): + return render_main_page("trade") + + +@app.route("/records") +@login_required +def records_page(): + return render_main_page("records") + + +@app.route("/stats") +@login_required +def stats_page(): + return render_main_page("stats") + + +@app.route("/plan_history") +@login_required +def plan_history_page(): + return render_main_page("plan_history") + + +@app.route("/api/preview_snapshot/") +@login_required +def api_preview_snapshot(sid): + conn = get_db() + row = conn.execute("SELECT * FROM trend_pullback_preview_snapshots WHERE id=?", (sid,)).fetchone() + conn.close() + if not row: + return jsonify({"ok": False, "msg": "not_found"}), 404 + d = row_to_dict(row) + d["outcome_label"] = preview_snapshot_outcome_label(d.get("outcome")) + return jsonify({"ok": True, "snapshot": d}) + + +@app.route("/api/account_snapshot") +@login_required +def api_account_snapshot(): + now = app_now() + trading_day = get_trading_day(now) + conn = get_db() + session_row = ensure_session(conn, trading_day) + local_current_capital = float(session_row["current_capital"]) + funding_capital, trading_capital = get_exchange_capitals(force=True) + funding_usdt = round(funding_capital, 2) if funding_capital is not None else None + current_capital = round(trading_capital, 2) if trading_capital is not None else round(local_current_capital, 2) + recommended_capital = round(get_recommended_capital(current_capital), 2) + active_count = get_active_position_count(conn) + conn.close() + can_trade = trading_day_reset_allows_new_open(now) and active_count < MAX_ACTIVE_POSITIONS + available_trading_usdt = get_available_trading_usdt() + return jsonify({ + "funding_usdt": funding_usdt, + "current_capital": current_capital, + "available_trading_usdt": round(available_trading_usdt, 2) if available_trading_usdt is not None else None, + "recommended_capital": recommended_capital, + "active_count": active_count, + "max_active_positions": MAX_ACTIVE_POSITIONS, + "can_trade": can_trade, + "trading_day": trading_day + }) + + +@app.route("/api/price_snapshot") +@login_required +def api_price_snapshot(): + conn = get_db() + key_rows = conn.execute("SELECT id,symbol,monitor_type,direction,upper,lower FROM key_monitors").fetchall() + order_rows = conn.execute( + "SELECT id,symbol,exchange_symbol,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage FROM order_monitors WHERE status='active'" + ).fetchall() + conn.close() + + symbol_set = set() + for r in key_rows: + symbol_set.add(r["symbol"]) + for r in order_rows: + symbol_set.add(r["symbol"]) + + prices = {} + for s in symbol_set: + p = get_price(s) + if p is not None: + prices[s] = float(p) + + all_swap_positions = [] + if exchange_private_api_configured(): + try: + ensure_markets_loaded() + # 显式 USDT 本位;不传 symbols 拉全量,再在本地按合约对齐 + all_swap_positions = exchange.fetch_positions(None, {"settle": "usdt"}) or [] + except Exception: + try: + all_swap_positions = exchange.fetch_positions() or [] + except Exception: + all_swap_positions = [] + + key_prices = [] + for r in key_rows: + price = prices.get(r["symbol"]) + if price is None: + continue + upper_diff, upper_pct = calc_price_diff_pct(price, r["upper"]) + lower_diff, lower_pct = calc_price_diff_pct(price, r["lower"]) + gate = None + try: + gate = _key_hard_checks(r["symbol"], (r["direction"] or "long").lower(), r["upper"], r["lower"], r["monitor_type"]) + except Exception: + gate = None + gate_summary = "-" + gate_metrics = "" + if gate: + rank_seg = "ERR" if int(gate.get("rank_total") or 0) <= 0 else f"{gate.get('rank')}/{gate.get('rank_total')}" + gate_summary = ( + f"量:{'Y' if gate.get('vol_ok') else 'N'} " + f"破:{'Y' if gate.get('breakout_ok') else 'N'} " + f"幅:{'Y' if gate.get('amp_ok') else 'N'} " + f"二确:{'Y' if gate.get('confirm_ok') else 'N'} " + f"排:{'Y' if gate.get('rank_ok') else 'N'}({rank_seg})" + ) + if gate.get("breakout_ok"): + try: + vol_now = round(float(gate.get("vol_break") or 0), 4) + vol_avg = round(float(gate.get("avg20") or 0), 4) + amp_pct = round(float(gate.get("amp_pct") or 0), 4) + cfm_close = round(float(gate.get("confirm_close") or 0), 8) + edge = round(float(gate.get("edge_price") or 0), 8) + gate_metrics = ( + f"量值:{vol_now}/{vol_avg} " + f"幅值:{amp_pct}% " + f"二确值:{cfm_close}@{edge}" + ) + except Exception: + gate_metrics = "" + key_prices.append({ + "id": r["id"], + "symbol": r["symbol"], + "price": round(price, 6), + "upper_diff": upper_diff, + "upper_pct": upper_pct, + "lower_diff": lower_diff, + "lower_pct": lower_pct, + "gate_summary": gate_summary, + "gate_ok": bool(gate and gate.get("ok")), + "gate_metrics": gate_metrics, + }) + + order_prices = [] + for r in order_rows: + price = prices.get(r["symbol"]) + if price is None: + continue + margin = float(r["margin_capital"] or 0) + leverage = float(r["leverage"] or 0) + entry = float(r["trigger_price"] or 0) + pnl = calc_pnl(r["direction"], entry, price, margin, leverage) if entry > 0 else 0 + pnl_pct = round((pnl / margin * 100), 4) if margin > 0 else 0 + rr_ratio = calc_rr_ratio(r["direction"], entry, r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]) + ex_sym = resolve_monitor_exchange_symbol(r) + prow = _select_live_position_row(all_swap_positions, ex_sym, r["direction"]) + lev_row = r["leverage"] if "leverage" in r.keys() else None + ex_metrics = parse_ccxt_position_metrics(prow, order_leverage=lev_row) if prow else None + payload = { + "id": r["id"], + "symbol": r["symbol"], + "price": round(price, 6), + "float_pnl": round(pnl, 6), + "float_pct": pnl_pct, + "rr_ratio": rr_ratio, + "plan_margin": round(margin, 4) if margin else None, + "exchange_initial_margin": None, + "exchange_notional": None, + "exchange_mark_price": None, + "pnl_source": "plan", + } + if ex_metrics: + if ex_metrics.get("initial_margin") is not None: + payload["exchange_initial_margin"] = ex_metrics["initial_margin"] + if ex_metrics.get("notional") is not None: + payload["exchange_notional"] = ex_metrics["notional"] + if ex_metrics.get("mark_price") is not None: + payload["exchange_mark_price"] = ex_metrics["mark_price"] + if ex_metrics.get("unrealized_pnl") is not None: + payload["float_pnl"] = round(float(ex_metrics["unrealized_pnl"]), 6) + payload["pnl_source"] = "exchange" + denom = ex_metrics.get("initial_margin") or margin + payload["float_pct"] = ( + round((payload["float_pnl"] / float(denom)) * 100, 4) if denom and float(denom) > 0 else pnl_pct + ) + order_prices.append(payload) + + return jsonify({ + "updated_at": app_now_str(), + "key_prices": key_prices, + "order_prices": order_prices, + "positions_raw_count": len(all_swap_positions), + }) + + +@app.route("/api/symbol_liquidity_rank") +@login_required +def api_symbol_liquidity_rank(): + symbol = normalize_symbol_input(request.args.get("symbol")) + if not symbol: + return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400 + rank, total = _daily_volume_rank(symbol) + if total <= 0: + return jsonify({"ok": False, "msg": "日成交量排名读取失败"}), 502 + if rank is None: + return jsonify({"ok": True, "symbol": symbol, "rank": None, "total": int(total), "in_top30": False}) + return jsonify( + { + "ok": True, + "symbol": symbol, + "rank": int(rank), + "total": int(total), + "in_top30": bool(rank <= 30), + } + ) + + +@app.route("/api/order_defaults") +@login_required +def api_order_defaults(): + symbol = normalize_symbol_input(request.args.get("symbol")) + direction = (request.args.get("direction") or "long").strip().lower() + if not symbol: + return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400 + if direction not in ("long", "short"): + direction = "long" + exchange_symbol = normalize_exchange_symbol(symbol) + leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) + available = get_available_trading_usdt() + return jsonify({ + "ok": True, + "symbol": symbol, + "exchange_symbol": exchange_symbol, + "direction": direction, + "leverage": leverage, + "available_trading_usdt": round(available, 4) if available is not None else None + }) + + +@app.route("/order_focus") +@login_required +def order_focus(): + now = app_now() + trading_day = get_trading_day(now) + conn = get_db() + session_row = ensure_session(conn, trading_day) + local_current_capital = float(session_row["current_capital"]) + _, trading_capital_live = get_exchange_capitals() + current_capital = round(trading_capital_live, 4) if trading_capital_live is not None else round(local_current_capital, 4) + raw_orders = conn.execute("SELECT * FROM order_monitors WHERE status='active' ORDER BY id DESC").fetchall() + conn.close() + orders = [enrich_order_item(row_to_dict(r), current_capital) for r in raw_orders] + picked_id = request.args.get("order_id", "").strip() + selected = None + if picked_id.isdigit(): + selected = next((o for o in orders if int(o["id"]) == int(picked_id)), None) + if selected is None and orders: + selected = orders[0] + return render_template( + "order_focus_v2.html", + orders=orders, + selected_order=selected, + default_timeframe=KLINE_TIMEFRAME, + price_refresh_seconds=PRICE_REFRESH_SECONDS, + exchange_display=EXCHANGE_DISPLAY_NAME, + ) + + +@app.route("/api/order_kline") +@login_required +def api_order_kline(): + order_id_raw = (request.args.get("order_id") or "").strip() + if not order_id_raw.isdigit(): + return jsonify({"ok": False, "msg": "order_id 无效"}), 400 + order_id = int(order_id_raw) + timeframe = (request.args.get("timeframe") or KLINE_TIMEFRAME).strip() + allowed_tfs = {"1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d"} + if timeframe not in allowed_tfs: + timeframe = KLINE_TIMEFRAME + limit = 100 + + now = app_now() + trading_day = get_trading_day(now) + conn = get_db() + session_row = ensure_session(conn, trading_day) + local_current_capital = float(session_row["current_capital"]) + _, trading_capital_live = get_exchange_capitals() + current_capital = round(trading_capital_live, 4) if trading_capital_live is not None else round(local_current_capital, 4) + row = conn.execute("SELECT * FROM order_monitors WHERE id=? AND status='active'", (order_id,)).fetchone() + conn.close() + if not row: + return jsonify({"ok": False, "msg": "订单不存在或已结束"}), 404 + + order_item = enrich_order_item(row_to_dict(row), current_capital) + exchange_symbol = order_item.get("exchange_symbol") or normalize_exchange_symbol(order_item["symbol"]) + try: + ensure_markets_loaded() + ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=limit) + except Exception as e: + return jsonify({"ok": False, "msg": f"K线加载失败:{friendly_exchange_error(e)}"}), 500 + + candles = [] + for bar in ohlcv or []: + if not bar or len(bar) < 6: + continue + ts = int(bar[0] // 1000) + candles.append({ + "time": ts, + "open": float(bar[1]), + "high": float(bar[2]), + "low": float(bar[3]), + "close": float(bar[4]), + "volume": float(bar[5]), + }) + + current_price = get_price(order_item["symbol"]) + margin = float(order_item.get("margin_capital") or 0) + leverage = float(order_item.get("leverage") or 0) + entry = float(order_item.get("trigger_price") or 0) + float_pnl = calc_pnl(order_item.get("direction") or "long", entry, current_price, margin, leverage) if current_price else 0 + float_pct = round((float_pnl / margin * 100), 4) if margin > 0 else 0 + + return jsonify({ + "ok": True, + "timeframe": timeframe, + "limit": limit, + "order": { + "id": order_item["id"], + "symbol": order_item["symbol"], + "direction": order_item.get("direction") or "long", + "trigger_price": order_item.get("trigger_price"), + "stop_loss": order_item.get("stop_loss"), + "take_profit": order_item.get("take_profit"), + "margin_capital": order_item.get("margin_capital"), + "leverage": order_item.get("leverage"), + "position_ratio": order_item.get("position_ratio"), + "rr_ratio": order_item.get("rr_ratio"), + "breakeven_enabled": bool(int(order_item.get("breakeven_enabled") or 0)), + "current_price": round(float(current_price), 8) if current_price else None, + "float_pnl": round(float(float_pnl), 6), + "float_pct": float_pct, + }, + "candles": candles, + "updated_at": app_now_str(), + }) + + +@app.route("/key_focus") +@login_required +def key_focus(): + conn = get_db() + key_rows = conn.execute("SELECT * FROM key_monitors ORDER BY id DESC").fetchall() + conn.close() + key_list = [row_to_dict(r) for r in key_rows] + + key_id_raw = (request.args.get("key_id") or "").strip() + symbol_query = normalize_symbol_input(request.args.get("symbol")) + selected_key = None + if key_id_raw.isdigit(): + selected_key = next((k for k in key_list if int(k["id"]) == int(key_id_raw)), None) + if selected_key is None and symbol_query: + selected_key = next((k for k in key_list if (k.get("symbol") or "").upper() == symbol_query), None) + if selected_key is None and key_list: + selected_key = key_list[0] + default_symbol = symbol_query or ((selected_key or {}).get("symbol")) or "BTC/USDT" + return render_template( + "key_focus_v2.html", + key_list=key_list, + selected_key=selected_key, + default_symbol=default_symbol, + default_timeframe=KLINE_TIMEFRAME, + default_kline_limit=200, + price_refresh_seconds=PRICE_REFRESH_SECONDS, + exchange_display=EXCHANGE_DISPLAY_NAME, + ) + + +@app.route("/api/key_kline") +@login_required +def api_key_kline(): + key_id_raw = (request.args.get("key_id") or "").strip() + symbol_input = normalize_symbol_input(request.args.get("symbol")) + timeframe = (request.args.get("timeframe") or KLINE_TIMEFRAME).strip() + if timeframe not in {"1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d"}: + timeframe = KLINE_TIMEFRAME + limit = normalize_kline_limit(request.args.get("limit"), default=200) + + conn = get_db() + key_row = None + if key_id_raw.isdigit(): + key_row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (int(key_id_raw),)).fetchone() + if key_row is None and symbol_input: + key_row = conn.execute( + "SELECT * FROM key_monitors WHERE upper(symbol)=? ORDER BY id DESC LIMIT 1", + (symbol_input,), + ).fetchone() + if key_row is not None: + symbol = (key_row["symbol"] or "").upper() + else: + symbol = symbol_input + conn.close() + if not symbol: + return jsonify({"ok": False, "msg": "请先输入币种或选择关键位"}), 400 + + exchange_symbol = normalize_exchange_symbol(symbol) + try: + ensure_markets_loaded() + ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=limit) + except Exception as e: + return jsonify({"ok": False, "msg": f"K线加载失败:{friendly_exchange_error(e)}"}), 500 + + candles = [] + for bar in ohlcv or []: + if not bar or len(bar) < 6: + continue + candles.append({ + "time": int(bar[0] // 1000), + "open": float(bar[1]), + "high": float(bar[2]), + "low": float(bar[3]), + "close": float(bar[4]), + "volume": float(bar[5]), + }) + + current_price = get_price(symbol) + key_info = None + if key_row is not None: + upper = float(key_row["upper"]) if key_row["upper"] is not None else None + lower = float(key_row["lower"]) if key_row["lower"] is not None else None + upper_diff, upper_pct = calc_price_diff_pct(current_price, upper) if current_price else (None, None) + lower_diff, lower_pct = calc_price_diff_pct(current_price, lower) if current_price else (None, None) + key_info = { + "id": key_row["id"], + "monitor_type": key_row["monitor_type"], + "direction": key_row["direction"] or "long", + "upper": upper, + "lower": lower, + "notification_count": int(key_row["notification_count"] or 0), + "upper_diff": upper_diff, + "upper_pct": upper_pct, + "lower_diff": lower_diff, + "lower_pct": lower_pct, + } + + return jsonify({ + "ok": True, + "symbol": symbol, + "timeframe": timeframe, + "limit": limit, + "current_price": round(float(current_price), 8) if current_price is not None else None, + "key_monitor": key_info, + "candles": candles, + "updated_at": app_now_str(), + }) + + +@app.route("/add_key", methods=["POST"]) +@login_required +def add_key(): + d = request.form + symbol = normalize_symbol_input(d.get("symbol")) + if not symbol: + flash("symbol 不能为空") + return redirect("/") + rank, total = _daily_volume_rank(symbol) + if rank is None: + flash("日成交量排名读取失败,请稍后重试") + return redirect("/") + if rank > 30: + flash(f"{symbol} 当前日成交量排名为 {rank}/{total},不在前30,已拒绝添加关键位") + return redirect("/") + conn = get_db() + conn.execute("INSERT INTO key_monitors (symbol,monitor_type,direction,upper,lower) VALUES (?,?,?,?,?)", + (symbol, d["type"], d.get("direction", "long"), d["upper"], d["lower"])) + conn.commit() + conn.close() + flash(f"添加成功({symbol} 日成交量排名 {rank}/{total})") + return redirect("/") + +@app.route("/add_order", methods=["POST"]) +@login_required +def add_order(): + d = request.form + now = app_now() + conn = get_db() + direction = d.get("direction", "long") + symbol = normalize_symbol_input(d.get("symbol")) + if not symbol: + conn.close() + flash("symbol 不能为空") + return redirect("/") + ok, reason = precheck_risk(conn, symbol, direction) + if not ok: + if "已达最大持仓数" in reason: + try: + tp_raw = parse_positive_float(d.get("tp")) + sl_raw = parse_positive_float(d.get("sl")) + tgt_raw = parse_positive_float(d.get("tgt")) + except Exception: + tp_raw = sl_raw = tgt_raw = None + insert_trade_record( + conn, + symbol=symbol, + monitor_type="下单监控", + direction=direction if direction in ("long", "short") else "long", + trigger_price=tp_raw or 0, + stop_loss=sl_raw or 0, + take_profit=tgt_raw or 0, + result="错过", + miss_reason=f"持仓占用:{reason}", + opened_at=app_now_str(), + closed_at=app_now_str(), + ) + conn.commit() + conn.close() + flash(f"风控拒绝下单:{reason}") + return redirect("/") + ok_live, reason_live = ensure_exchange_live_ready() + if not ok_live: + conn.close() + flash(f"风控拒绝下单:{reason_live}") + return redirect("/") + exchange_symbol = normalize_exchange_symbol(symbol) + default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) + try: + leverage_input = parse_positive_float(d.get("leverage")) + leverage = int(leverage_input) if leverage_input is not None else default_leverage + except Exception: + conn.close() + flash("杠杆参数格式错误") + return redirect("/") + if leverage <= 0: + conn.close() + flash("杠杆必须大于0") + return redirect("/") + + trading_day = get_trading_day(now) + opens_today_before = conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", + (trading_day,), + ).fetchone()[0] + session_row = ensure_session(conn, trading_day) + _, trading_capital_live = get_exchange_capitals(force=True) + capital_base = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"]) + trade_style = (d.get("trade_style") or DEFAULT_TRADE_STYLE or "trend").strip().lower() + if trade_style not in ("trend", "swing"): + trade_style = "trend" + available_usdt = get_available_trading_usdt() + live_price = get_price(symbol) + if live_price is None: + conn.close() + flash("获取交易所实时价格失败,请稍后重试") + return redirect("/") + sltp_mode = (d.get("sltp_mode") or "price").strip().lower() + if sltp_mode not in ("price", "pct"): + sltp_mode = "price" + if sltp_mode == "pct": + try: + sl_pct = float(d.get("sl_pct") or 0) + tp_pct = float(d.get("tp_pct") or 0) + if sl_pct <= 0 or tp_pct <= 0: + raise ValueError("pct") + sl_ratio = sl_pct / 100.0 + tp_ratio = tp_pct / 100.0 + if direction == "short": + stop_loss = float(live_price) * (1 + sl_ratio) + take_profit = float(live_price) * (1 - tp_ratio) + else: + stop_loss = float(live_price) * (1 - sl_ratio) + take_profit = float(live_price) * (1 + tp_ratio) + except Exception: + conn.close() + flash("百分比止盈止损参数错误,请填写正数百分比") + return redirect("/") + else: + try: + stop_loss = float(d["sl"]) + take_profit = float(d["tgt"]) + except Exception: + conn.close() + flash("价格参数格式错误") + return redirect("/") + if stop_loss <= 0 or take_profit <= 0: + conn.close() + flash("价格参数必须大于0") + return redirect("/") + risk_fraction = calc_risk_fraction(direction, live_price, stop_loss) + if risk_fraction is None: + conn.close() + flash("止损方向不合法:请检查入场方向与止损价格关系") + return redirect("/") + risk_percent = max(0.01, float(RISK_PERCENT)) + risk_amount = round(capital_base * risk_percent / 100.0, 4) + notional_value = round(risk_amount / risk_fraction, 4) + margin_capital = round(notional_value / leverage, 4) + if capital_base and margin_capital > capital_base: + conn.close() + flash("以损定仓后保证金超过当前交易资金,请放宽止损或降低风险比例") + return redirect("/") + if available_usdt is not None: + max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4) + if margin_capital > max_margin: + conn.close() + flash(f"保证金不足:交易账户可用约 {round(available_usdt,4)}U,当前最多建议 {max_margin}U") + return redirect("/") + position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0 + try: + amount, quote_price = prepare_order_amount(exchange_symbol, margin_capital, leverage, live_price) + contract_size = get_contract_size(exchange_symbol) + base_amount = round(float(amount) * contract_size, 8) + order_resp = place_exchange_order(exchange_symbol, direction, amount, leverage, stop_loss=stop_loss, take_profit=take_profit) + open_order_id = order_resp.get("id", "") + tpsl_attached = bool(order_resp.get("tpsl_attached")) + trigger_price = resolve_order_entry_price(order_resp, exchange_symbol, quote_price) + except Exception as e: + conn.close() + flash(friendly_exchange_error(e, available_usdt=available_usdt)) + return redirect("/") + + make_order_chart = d.get("order_chart", "").lower() in ("1", "true", "on", "yes") + opened_at_bj = app_now_str() + opened_at_ms = _to_ms_with_fallback(None, opened_at_bj) + planned_rr = calc_rr_ratio(direction, trigger_price, stop_loss, take_profit) + breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER) + breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT) + breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0 + risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage) or risk_amount + if direction == "short": + breakeven_price = round(float(trigger_price) * (1 - breakeven_offset_pct / 100.0), 8) + else: + breakeven_price = round(float(trigger_price) * (1 + breakeven_offset_pct / 100.0), 8) + breakeven_enabled = 1 if (d.get("breakeven_enabled") or "").strip() in ("1", "true", "on", "yes") else 0 + conn.execute( + "INSERT INTO order_monitors (symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, margin_capital, leverage, trade_style, risk_percent, risk_amount, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, exchange_symbol, direction, trigger_price, stop_loss, stop_loss, take_profit, + margin_capital, leverage, trade_style, risk_percent, risk_amount_final, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, 0, breakeven_price, + breakeven_enabled, + notional_value, position_ratio, base_amount, amount, open_order_id, opened_at_bj, opened_at_ms, trading_day + ) + ) + conn.commit() + new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + opens_today_after = conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", + (trading_day,), + ).fetchone()[0] + conn.close() + + chart_name = None + chart_url = None + if make_order_chart and ORDER_CHART_ENABLED: + try: + title_prefix = f"{symbol} {direction} #{new_order_id}" + chart_name = generate_order_open_chart( + exchange_symbol, + title_prefix, + opened_at_ms=opened_at_ms, + entry_price=trigger_price, + ) + if chart_name: + chart_url = f"/static/images/order_charts/{chart_name}" + except Exception: + chart_name = None + chart_url = None + + if chart_name: + try: + journal_id = f"order_{new_order_id}" + coin = journal_coin_from_symbol(symbol) + open_local = (opened_at_bj or "")[:16].replace(" ", "T") + if len(open_local) < 16: + open_local = app_now().strftime("%Y-%m-%dT%H:%M") + close_local = open_local + hold_duration = calc_duration_text(open_local, close_local) + note = ( + f"auto_from_open_order id={new_order_id} oid={open_order_id} " + f"chart={chart_name} tfs={','.join(ORDER_CHART_TFS)} limit={ORDER_CHART_LIMIT}" + ) + conn = get_db() + conn.execute( + """INSERT OR REPLACE INTO journal_entries + (id, open_datetime, close_datetime, hold_duration, coin, tf, pnl, entry_reason, exit_reason, + expect_rr, real_rr, early_exit, early_exit_reason, early_exit_trigger, early_exit_note, + mood_score, mood_ai_score, mood_ai_comment, mood_issues, post_breakeven_stare, + new_trade_while_occupied, note, image) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + journal_id, + open_local, + close_local, + hold_duration, + coin, + "multi", + "0", + "auto:open", + "待平仓", + "", + "", + "否", + "", + "", + "", + None, + None, + None, + "", + "否", + "否", + note, + chart_name, + ), + ) + conn.commit() + conn.close() + except Exception: + try: + conn.close() + except Exception: + pass + + _, trading_capital_after = get_exchange_capitals(force=True) + account_base_display = ( + round(float(trading_capital_after), 4) + if trading_capital_after is not None + else round(float(capital_base), 4) + ) + account_name = (os.getenv("GATE_ACCOUNT_LABEL") or "gate实盘账户").strip() + dir_text = "多头(long)" if direction == "long" else "空头(short)" + order_state_text = ( + "已在交易所挂条件委托(止盈、止损各一张触发单)" + if tpsl_attached + else "条件委托未挂上(已拦截)" + ) + rr_show = planned_rr if planned_rr is not None else "-" + try: + rr_show_fmt = round(float(planned_rr), 4) if planned_rr is not None else None + except (TypeError, ValueError): + rr_show_fmt = None + rr_line = f"RR {rr_show_fmt} : 1" if rr_show_fmt is not None else f"RR {rr_show} : 1" + ep_wx = format_price_for_symbol(symbol, trigger_price) + sl_wx = format_price_for_symbol(symbol, stop_loss) + tp_wx = format_price_for_symbol(symbol, take_profit) + be_wx = format_price_for_symbol(symbol, breakeven_price) + style_zh = "Swing 波段" if trade_style == "swing" else "Trend 趋势" + wx_lines = [ + f"📈 {symbol} 开仓成功", + f"💼 交易类型:{dir_text}", + "🧾 订单基础信息", + f"🔖 交易所订单 ID:{open_order_id}", + f"📈 交易风格:{style_zh}", + f"⚠️ 单笔风控风险:{risk_percent}% ≈ {round(float(risk_amount_final), 4)} U", + "📊 仓位配置详情", + f"账户基数:{account_base_display} USDT", + f"合约杠杆:{leverage} 倍", + f"名义仓位:{notional_value} USDT", + f"仓位占比:{position_ratio}%", + f"合约张数:{amount} 张", + f"折算标的:{base_amount} {journal_coin_from_symbol(symbol)}", + "🎯 价位 & 盈亏比", + f"开仓成交价:{ep_wx}", + f"止损价位:{sl_wx}", + f"止盈价位:{tp_wx}", + f"计划盈亏比:{rr_line}", + f"移动保本位:{breakeven_rr_trigger}R → {be_wx}", + "📌 状态统计", + f"✅ 条件委托:{order_state_text}", + f"📅 当日开仓次数:{opens_today_after} / {DAILY_OPEN_ALERT_THRESHOLD} 次(风控阈值提醒)", + ] + if chart_url: + wx_lines.append(f"多周期K线图:{chart_url}") + send_wechat_msg("\n".join(wx_lines)) + + flash_lines = [ + f"机器人开单成功:风格 {trade_style};风险 {risk_percent}%≈{risk_amount_final}U;基数 {margin_capital}U,杠杆 {leverage}x,名义仓位 {notional_value}U,仓位占比 {position_ratio}%,合约张数 {amount}(折算标的 {base_amount})," + f"计划RR {planned_rr if planned_rr is not None else '-'};已在交易所挂条件止盈/止损委托(非仓位绑定型)", + f"本交易日累计开仓:{opens_today_after}", + ] + if chart_url: + flash_lines.append(f"已生成多周期K线图:{chart_url}") + flash(" ".join(flash_lines)) + + if opens_today_before < DAILY_OPEN_ALERT_THRESHOLD <= opens_today_after: + advice = ai_short_advice( + f"用户在北京时间交易日 {trading_day} 已累计开仓 {opens_today_after} 次(阈值 {DAILY_OPEN_ALERT_THRESHOLD})。" + f"最新一笔:{symbol} {direction},杠杆{leverage}x,基数{margin_capital}U。" + f"用户自述“上头了”。请给克制提醒。" + ) + if advice: + send_wechat_msg(f"【AI提醒】今日开仓次数已达 {opens_today_after}\n{advice[:800]}") + flash(f"【AI提醒】今日开仓次数已达 {opens_today_after}:{advice[:300]}") + return redirect("/") + + +@app.route("/preview_trend_pullback", methods=["POST"]) +@login_required +def preview_trend_pullback(): + conn = get_db() + _trend_cleanup_stale_previews(conn) + okp, reasonp = precheck_trend_pullback_start(conn) + if not okp: + conn.close() + flash(reasonp) + return redirect(url_for("trade_page")) + ok_live, reason_live = ensure_exchange_live_ready() + if not ok_live: + conn.close() + flash(reason_live) + return redirect(url_for("trade_page")) + payload, err = parse_and_compute_trend_pullback_plan(request.form) + if err: + conn.close() + flash(err) + return redirect(url_for("trade_page")) + pid = str(uuid.uuid4()) + exp_ms = int(time.time() * 1000) + int(TREND_PULLBACK_PREVIEW_TTL_SECONDS) * 1000 + created = app_now_str() + conn.execute( + """INSERT INTO trend_pullback_previews ( + id,symbol,exchange_symbol,direction,leverage,stop_loss,add_upper,take_profit,risk_percent, + snapshot_available_usdt,snapshot_at,live_price_ref,plan_margin_capital,target_order_amount,first_order_amount,remainder_total, + dca_legs,per_leg_amount,grid_prices_json,leg_amounts_json,expires_at_ms,created_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + pid, + payload["symbol"], + payload["exchange_symbol"], + payload["direction"], + payload["leverage"], + payload["stop_loss"], + payload["add_upper"], + payload["take_profit"], + payload["risk_percent"], + payload["snapshot_available_usdt"], + payload["snapshot_at"], + payload["live_price_ref"], + payload["plan_margin_capital"], + payload["target_order_amount"], + payload["first_order_amount"], + payload["remainder_total"], + payload["dca_legs"], + payload["per_leg_amount"], + payload["grid_prices_json"], + payload["leg_amounts_json"], + exp_ms, + created, + ), + ) + insert_trend_preview_snapshot(conn, pid, created, exp_ms, payload) + conn.commit() + conn.close() + flash(f"预览已生成,有效期 {TREND_PULLBACK_PREVIEW_TTL_SECONDS} 秒,请核对后点击「确认执行」。") + return redirect(url_for("trade_page", preview_id=pid)) + + +@app.route("/execute_trend_pullback", methods=["POST"]) +@login_required +def execute_trend_pullback(): + pid = (request.form.get("preview_id") or "").strip() + if not pid: + flash("缺少预览 ID") + return redirect(url_for("trade_page")) + conn = get_db() + _trend_cleanup_stale_previews(conn) + pr = conn.execute("SELECT * FROM trend_pullback_previews WHERE id=?", (pid,)).fetchone() + now_ms = int(time.time() * 1000) + if not pr or int(pr["expires_at_ms"] or 0) < now_ms: + conn.close() + flash("预览已过期或不存在,请重新生成预览") + return redirect(url_for("trade_page")) + okp, reasonp = precheck_trend_pullback_start(conn) + if not okp: + conn.close() + flash(reasonp) + return redirect(url_for("trade_page", preview_id=pid)) + ok_live, reason_live = ensure_exchange_live_ready() + if not ok_live: + conn.close() + flash(reason_live) + return redirect(url_for("trade_page", preview_id=pid)) + snap_prev = float(pr["snapshot_available_usdt"] or 0) + snap_now = get_available_trading_usdt() + if snap_now is None or snap_now <= 0: + conn.close() + flash("无法读取当前合约可用余额,请稍后重试") + return redirect(url_for("trade_page", preview_id=pid)) + drift_pct = abs(float(snap_now) - snap_prev) / max(snap_prev, 1e-9) * 100.0 + if drift_pct > float(TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT): + conn.close() + flash( + f"当前可用余额与预览快照偏差 {drift_pct:.2f}%,超过允许 {TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT}% ,请重新生成预览" + ) + return redirect(url_for("trade_page")) + symbol = pr["symbol"] + exchange_symbol = pr["exchange_symbol"] + direction = pr["direction"] or "long" + leverage = int(pr["leverage"] or 1) + stop_loss = float(pr["stop_loss"]) + add_upper = float(pr["add_upper"]) + take_profit = float(pr["take_profit"]) + risk_percent = float(pr["risk_percent"] or 5) + snap = float(snap_now) + margin_plan = float(pr["plan_margin_capital"] or 0) + target_amt = float(pr["target_order_amount"] or 0) + first_amt = float(pr["first_order_amount"] or 0) + remainder_total = float(pr["remainder_total"] or 0) + n_legs = int(pr["dca_legs"] or 0) + per_ref = float(pr["per_leg_amount"] or 0) + grid_json = pr["grid_prices_json"] or "[]" + leg_json = pr["leg_amounts_json"] or "[]" + live_price = get_price(symbol) + if live_price is None: + conn.close() + flash("获取实时价格失败") + return redirect(url_for("trade_page", preview_id=pid)) + try: + o1 = place_exchange_order(exchange_symbol, direction, first_amt, leverage, stop_loss=None, take_profit=None) + fill1 = resolve_order_entry_price(o1, exchange_symbol, live_price) + _trend_refresh_stop_only(exchange_symbol, direction, stop_loss) + except Exception as e: + conn.close() + flash(friendly_exchange_error(e, available_usdt=snap_now)) + return redirect(url_for("trade_page", preview_id=pid)) + now = app_now() + trading_day = get_trading_day(now) + opened_at = app_now_str() + opened_ms = _to_ms_with_fallback(None, opened_at) + cur = conn.execute( + """INSERT INTO trend_pullback_plans ( + status,symbol,exchange_symbol,direction,leverage,stop_loss,initial_stop_loss,add_upper,take_profit,risk_percent, + snapshot_available_usdt,snapshot_at,plan_margin_capital,target_order_amount,first_order_amount,remainder_total, + dca_legs,per_leg_amount,grid_prices_json,leg_amounts_json,legs_done,first_order_done,last_mark_price,avg_entry_price,order_amount_open,opened_at,opened_at_ms,session_date,message + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + "active", + symbol, + exchange_symbol, + direction, + leverage, + stop_loss, + stop_loss, + add_upper, + take_profit, + risk_percent, + snap, + opened_at, + margin_plan, + target_amt, + first_amt, + remainder_total, + n_legs, + per_ref, + grid_json, + leg_json, + 0, + 1, + float(live_price), + fill1, + first_amt, + opened_at, + opened_ms, + trading_day, + f"预览ID:{pid[:8]}…", + ), + ) + new_plan_id = int(cur.lastrowid) + conn.execute( + "UPDATE trend_pullback_preview_snapshots SET outcome='executed', executed_plan_id=? WHERE preview_id=?", + (new_plan_id, pid), + ) + conn.execute("DELETE FROM trend_pullback_previews WHERE id=?", (pid,)) + conn.commit() + conn.close() + flash( + f"趋势回调已执行:可用余额(执行时){round(snap, 2)}U;计划保证金约 {round(margin_plan, 2)}U;" + f"总张数约 {target_amt},首仓 {first_amt},补仓 {n_legs} 档;已挂交易所止损,止盈由程序监控。" + ) + return redirect(url_for("trade_page")) + + +@app.route("/cancel_trend_pullback_preview", methods=["POST"]) +@login_required +def cancel_trend_pullback_preview(): + pid = (request.form.get("preview_id") or "").strip() + conn = get_db() + if pid: + conn.execute( + "UPDATE trend_pullback_preview_snapshots SET outcome='cancelled' WHERE preview_id=? AND outcome='open'", + (pid,), + ) + conn.execute("DELETE FROM trend_pullback_previews WHERE id=?", (pid,)) + conn.commit() + conn.close() + flash("已取消预览") + return redirect(url_for("trade_page")) + + +@app.route("/trend_pullback_breakeven/", methods=["POST"]) +@login_required +def trend_pullback_breakeven(pid): + offset_raw = (request.form.get("breakeven_offset_pct") or "").strip() + offset_pct = None + if offset_raw: + try: + offset_pct = float(offset_raw) + if offset_pct < 0: + raise ValueError + except ValueError: + flash("保本偏移% 格式无效") + return redirect(url_for("trade_page")) + conn = get_db() + row = conn.execute( + "SELECT * FROM trend_pullback_plans WHERE id=? AND status='active'", (pid,) + ).fetchone() + if not row: + conn.close() + flash("未找到运行中的趋势回调计划") + return redirect(url_for("trade_page")) + ok, err = apply_trend_pullback_manual_breakeven(conn, row, offset_pct=offset_pct) + conn.commit() + conn.close() + if ok: + flash("已手动保本:交易所止损已按均价+偏移更新") + else: + flash(err or "手动保本失败") + return redirect(url_for("trade_page")) + + +@app.route("/stop_trend_pullback/") +@login_required +def stop_trend_pullback(pid): + conn = get_db() + row = conn.execute("SELECT * FROM trend_pullback_plans WHERE id=? AND status='active'", (pid,)).fetchone() + if not row: + conn.close() + flash("未找到运行中的趋势回调计划") + return redirect("/trade") + ex_sym = row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"]) + direction = row["direction"] or "long" + lev = int(row["leverage"] or 1) + px = get_price(row["symbol"]) + exit_p = float(px) if px is not None else 0.0 + ok_live, _ = ensure_exchange_live_ready() + if ok_live: + pos = get_live_position_contracts(ex_sym, direction) + if pos is not None and pos > 0: + try: + exchange.set_leverage(lev, ex_sym) + side = "sell" if direction == "long" else "buy" + params = build_gate_order_params(direction, reduce_only=True) + close_resp = exchange.create_order(ex_sym, "market", side, float(pos), None, params) + ep = extract_trade_price_from_order(close_resp) + if ep: + exit_p = float(ep) + except Exception as e: + if not is_no_position_error(str(e)): + conn.close() + flash(f"平仓失败:{e}") + return redirect("/trade") + try: + cancel_all_open_orders_for_symbol(ex_sym) + except Exception: + pass + _trend_finalize_plan(conn, row, "手动平仓", exit_p) + conn.commit() + conn.close() + flash("已结束趋势回调计划(市价平仓、撤单)") + return redirect("/trade") + + +@app.route("/delete_trend_plan_history/", methods=["POST"]) +@login_required +def delete_trend_plan_history(pid): + conn = get_db() + row = conn.execute("SELECT id, status FROM trend_pullback_plans WHERE id=?", (pid,)).fetchone() + if not row: + conn.close() + flash("计划不存在") + return redirect(request.referrer or url_for("plan_history_page")) + if (row["status"] or "").strip() == "active": + conn.close() + flash("运行中的计划请使用「结束计划」,不可从历史中删除") + return redirect(request.referrer or url_for("plan_history_page")) + conn.execute("DELETE FROM trade_records WHERE trend_plan_id=?", (pid,)) + conn.execute("DELETE FROM trend_pullback_preview_snapshots WHERE executed_plan_id=?", (pid,)) + conn.execute("DELETE FROM trend_pullback_plans WHERE id=?", (pid,)) + conn.commit() + conn.close() + flash("已删除该计划历史及关联趋势交易记录(若有)") + return redirect(request.referrer or url_for("plan_history_page")) + + +@app.route("/delete_key_monitor/", methods=["POST"]) +@login_required +def delete_key_monitor(kid): + conn = get_db() + row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (kid,)).fetchone() + if not row: + conn.close() + return jsonify({"ok": False, "error": "not_found"}) + insert_key_monitor_history(conn, row, int(row["notification_count"] or 0), None, "manual") + cur = conn.execute("DELETE FROM key_monitors WHERE id=?", (kid,)) + conn.commit() + conn.close() + return jsonify({"ok": cur.rowcount > 0}) + + +@app.route("/delete_key_history/", methods=["POST"]) +@login_required +def delete_key_history(hid): + conn = get_db() + cur = conn.execute("DELETE FROM key_monitor_history WHERE id=?", (hid,)) + conn.commit() + conn.close() + return jsonify({"ok": cur.rowcount > 0}) + + +@app.route("/del_key/") +@login_required +def del_key(id): + conn = get_db() + row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (id,)).fetchone() + if row: + insert_key_monitor_history(conn, row, int(row["notification_count"] or 0), None, "manual") + conn.execute("DELETE FROM key_monitors WHERE id=?", (id,)) + conn.commit() + conn.close() + resp = redirect("/") + resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" + resp.headers["Pragma"] = "no-cache" + return resp + + +def _csv_response(filename, rows, header): + buf = StringIO() + w = csv.writer(buf) + w.writerow(header) + for row in rows: + w.writerow(row) + out = "\ufeff" + buf.getvalue() + return Response( + out, + mimetype="text/csv; charset=utf-8", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + "Cache-Control": "no-store", + }, + ) + + +def _md_response(filename, content): + return Response( + content, + mimetype="text/markdown; charset=utf-8", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + "Cache-Control": "no-store", + }, + ) + + +@app.route("/export/trade_records") +@login_required +def export_trade_records(): + win = _list_window_from_request() + start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ) + conn = get_db() + rows = conn.execute( + "SELECT id,symbol,monitor_type,direction,trigger_price,stop_loss,take_profit,margin_capital,leverage," + "pnl_amount,hold_seconds,hold_minutes,opened_at,closed_at,result,miss_reason," + "entry_reason,reviewed_entry_reason,created_at,trend_plan_id,exchange_realized_pnl," + "exchange_opened_at,exchange_closed_at,exchange_sync_key FROM trade_records " + "WHERE COALESCE(closed_at, created_at, opened_at) >= ? " + "AND COALESCE(closed_at, created_at, opened_at) <= ? ORDER BY id ASC", + (start_bj, end_bj), + ).fetchall() + conn.close() + head_base = [ + "id", + "symbol", + "monitor_type", + "direction", + "trigger_price", + "stop_loss", + "take_profit", + "margin_capital", + "leverage", + "pnl_amount", + "hold_seconds", + "hold_minutes", + "opened_at", + "closed_at", + "result", + "miss_reason", + "entry_reason", + "reviewed_entry_reason", + "created_at", + "trend_plan_id", + "exchange_realized_pnl", + "exchange_opened_at", + "exchange_closed_at", + "exchange_sync_key", + ] + head = head_base + ["开仓类型"] + data = [] + for r in rows: + er0 = (r["entry_reason"] or "").strip() if r["entry_reason"] else "" + er1 = (r["reviewed_entry_reason"] or "").strip() if r["reviewed_entry_reason"] else "" + eff = er1 or er0 + data.append(tuple(r[h] for h in head_base) + (eff,)) + day = app_now().strftime("%Y%m%d") + return _csv_response(f"trade_records_v2_{day}.csv", data, head) + + +@app.route("/export/journal_entries") +@login_required +def export_journal_entries(): + conn = get_db() + rows = conn.execute( + "SELECT id,open_datetime,close_datetime,hold_duration,coin,tf,pnl,entry_reason,exit_reason," + "expect_rr,real_rr,early_exit,early_exit_trigger,early_exit_note,early_exit_reason,mood_issues," + "post_breakeven_stare,new_trade_while_occupied,note,image,created_at FROM journal_entries ORDER BY created_at ASC" + ).fetchall() + conn.close() + head = [ + "id", + "open_datetime", + "close_datetime", + "hold_duration", + "coin", + "tf", + "pnl", + "entry_reason", + "exit_reason", + "expect_rr", + "real_rr", + "early_exit", + "early_exit_trigger", + "early_exit_note", + "early_exit_reason", + "mood_issues", + "post_breakeven_stare", + "new_trade_while_occupied", + "note", + "image", + "created_at", + ] + data = [tuple(r[h] for h in head) for r in rows] + day = app_now().strftime("%Y%m%d") + return _csv_response(f"journal_entries_v1_{day}.csv", data, head) + + +@app.route("/export/key_monitors") +@login_required +def export_key_monitors(): + conn = get_db() + rows = conn.execute( + "SELECT id,symbol,monitor_type,direction,upper,lower,notification_count,last_notified_at,max_notify," + "notify_interval_min,breakout_limit_pct,created_at FROM key_monitors ORDER BY id ASC" + ).fetchall() + conn.close() + head = [ + "id", + "symbol", + "monitor_type", + "direction", + "upper", + "lower", + "notification_count", + "last_notified_at", + "max_notify", + "notify_interval_min", + "breakout_limit_pct", + "created_at", + ] + data = [tuple(r[h] for h in head) for r in rows] + day = app_now().strftime("%Y%m%d") + return _csv_response(f"key_monitors_active_v1_{day}.csv", data, head) + + +@app.route("/export/key_monitor_history") +@login_required +def export_key_monitor_history(): + conn = get_db() + rows = conn.execute( + "SELECT id,symbol,monitor_type,direction,upper,lower,notification_count,last_alert_message,close_reason,closed_at " + "FROM key_monitor_history ORDER BY id ASC" + ).fetchall() + conn.close() + head = [ + "id", + "symbol", + "monitor_type", + "direction", + "upper", + "lower", + "notification_count", + "last_alert_message", + "close_reason", + "closed_at", + ] + data = [tuple(r[h] for h in head) for r in rows] + day = app_now().strftime("%Y%m%d") + return _csv_response(f"key_monitor_history_v1_{day}.csv", data, head) + +@app.route("/del_order/") +@login_required +def del_order(id): + conn = get_db() + row = conn.execute("SELECT * FROM order_monitors WHERE id=?", (id,)).fetchone() + if not row: + conn.close() + flash("订单不存在") + return redirect("/") + if row["status"] == "active": + try: + p = get_price(row["symbol"]) or float(row["trigger_price"]) + opened_at = get_opened_at_value(row) + closed_at = app_now_str() + hold_seconds = calc_hold_seconds(opened_at, app_now()) + pnl_amount = calc_pnl( + row["direction"], + row["trigger_price"], + p, + row["margin_capital"] or DAILY_START_CAPITAL, + row["leverage"] or infer_leverage(row["symbol"]) + ) + close_resp = close_exchange_order(row) + close_order_id = close_resp.get("id", "") + cancel_gate_swap_trigger_orders(row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"])) + session_date = row["session_date"] or get_trading_day() + session_capital = update_session_capital(conn, session_date, pnl_amount) + insert_trade_record( + conn, + symbol=row["symbol"], + monitor_type="下单监控", + direction=row["direction"], + trigger_price=row["trigger_price"], + stop_loss=row["stop_loss"], + initial_stop_loss=row["initial_stop_loss"] or row["stop_loss"], + take_profit=row["take_profit"], + margin_capital=row["margin_capital"], + leverage=row["leverage"], + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=row["trade_style"], + risk_amount=row["risk_amount"], + planned_rr=calc_rr_ratio(row["direction"], row["trigger_price"], row["initial_stop_loss"] or row["stop_loss"], row["take_profit"]), + actual_rr=calc_actual_rr(pnl_amount, row["risk_amount"]), + result="手动平仓", + miss_reason="用户手动删除订单触发平仓", + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, id)) + conn.commit() + conn.close() + send_wechat_msg( + build_wechat_close_message( + symbol=row["symbol"], + direction=row["direction"], + result="手动平仓", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=row["trigger_price"], + current_price=p, + stop_loss=row["stop_loss"], + take_profit=row["take_profit"], + close_order_id=close_order_id or "-", + extra_note="用户在页面手动平仓", + session_capital_fallback=session_capital, + ) + ) + flash("已按实盘流程手动平仓") + return redirect("/") + except Exception as e: + if is_no_position_error(str(e)): + cancel_gate_swap_trigger_orders(row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"])) + opened_at = get_opened_at_value(row) + opened_at_ms = _to_ms_with_fallback(row["opened_at_ms"] if "opened_at_ms" in row.keys() else None, opened_at) + result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close(row, opened_at, opened_at_ms=opened_at_ms) + miss_reason = f"手动删除时无持仓:{miss_reason}" + closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now() + hold_seconds = calc_hold_seconds(opened_at, closed_at_dt) + session_date = row["session_date"] or get_trading_day(closed_at_dt) + update_session_capital(conn, session_date, pnl_amount) + insert_trade_record( + conn, + symbol=row["symbol"], + monitor_type="下单监控", + direction=row["direction"], + trigger_price=row["trigger_price"], + stop_loss=row["stop_loss"], + initial_stop_loss=row["initial_stop_loss"] or row["stop_loss"], + take_profit=row["take_profit"], + margin_capital=row["margin_capital"], + leverage=row["leverage"], + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=row["trade_style"], + risk_amount=row["risk_amount"], + planned_rr=calc_rr_ratio(row["direction"], row["trigger_price"], row["initial_stop_loss"] or row["stop_loss"], row["take_profit"]), + actual_rr=calc_actual_rr(pnl_amount, row["risk_amount"]), + result=result, + miss_reason=miss_reason, + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (id,)) + conn.commit() + conn.close() + flash("该仓位在交易所已不存在,已按成交记录同步结束并记账") + return redirect("/") + conn.close() + flash(f"手动平仓失败:{str(e)}") + return redirect("/") + conn.execute("DELETE FROM order_monitors WHERE id=?",(id,)) + conn.commit() + conn.close() + return redirect("/") + +@app.route("/add_miss", methods=["POST"]) +@login_required +def add_miss(): + d = request.form + direction = d.get("direction", "long") + conn = get_db() + insert_trade_record( + conn, + symbol=d["symbol"], + monitor_type=d["type"], + direction=direction, + trigger_price=d["tp"], + stop_loss=d["sl"], + take_profit=d["tgt"], + result="错过", + miss_reason=d["reason"], + opened_at=app_now_str(), + closed_at=app_now_str(), + ) + conn.commit() + conn.close() + flash("已记录错过机会") + return _redirect_records() + + +@app.route("/add_journal", methods=["POST"]) +@login_required +def add_journal(): + d = request.form + entry_reason_norm = normalize_entry_reason(d.get("entry_reason"), d.get("entry_reason_custom")) + if not entry_reason_norm: + flash("请选择开仓类型;若选「其他」请在下方填写自定义说明") + return _redirect_records() + early_exit_trigger = normalize_early_exit_trigger(d.get("early_exit_trigger")) + early_exit_note = str(d.get("early_exit_note") or "").strip() + if not early_exit_trigger: + flash("请选择离场触发") + return _redirect_records() + if early_exit_trigger == "手动平仓" and not early_exit_note: + flash("手工平仓必须填写补充说明") + return _redirect_records() + if early_exit_trigger != "手动平仓": + early_exit_note = "" + # 兼容字段:仅「手工平仓」记为「主观提前」语义下的「是」 + early_exit_raw = "是" if early_exit_trigger == "手动平仓" else "否" + early_exit_reason_saved = compose_early_exit_reason_saved(early_exit_trigger, early_exit_note) + exit_reason_stored = journal_exit_reason_stored(early_exit_trigger, early_exit_note) + image_filename = None + uploaded_tmp = None + entry_id = uuid.uuid4().hex + file = request.files.get("screenshot") + if file and file.filename: + ext = os.path.splitext(file.filename)[1] + image_filename = f"{uuid.uuid4().hex}{ext}" + save_path = os.path.join(app.config["UPLOAD_FOLDER"], secure_filename(image_filename)) + file.save(save_path) + uploaded_tmp = image_filename + + mood_issues = ",".join(request.form.getlist("mood_issues")) + hold_duration = calc_duration_text(d.get("open_datetime", ""), d.get("close_datetime", "")) + real_rr_text = (d.get("real_rr") or "").strip() + try: + risk_amount_hint = float(d.get("risk_amount_hint") or 0) + pnl_hint = float(d.get("pnl") or 0) + # 口径统一:实际RR = 实际盈亏 / 以损定仓对应的初始风险金额 + if risk_amount_hint > 0: + real_rr_text = f"{(pnl_hint / risk_amount_hint):.4f}" + except Exception: + pass + + want_exchange_chart = d.get("journal_exchange_chart", "").lower() in ("1", "true", "on", "yes") + chart_msg = None + if want_exchange_chart and ORDER_CHART_ENABLED: + coin = (d.get("coin") or "").strip().upper() + symbol_guess = normalize_symbol_input(coin) or coin + exchange_symbol = normalize_exchange_symbol(symbol_guess) + title_prefix = f"{symbol_guess} journal {entry_id[:8]}" + marker_payload = { + "entry_ts_ms": _local_input_datetime_to_ms(d.get("open_datetime")), + "exit_ts_ms": _local_input_datetime_to_ms(d.get("close_datetime")), + "entry_price": d.get("entry_price_hint"), + "exit_price": None, + } + try: + chart_fname = f"journal_{entry_id}.png" + saved = generate_multi_timeframe_chart_png( + exchange_symbol, + title_prefix, + timeframes=ORDER_CHART_TFS, + limit=ORDER_CHART_LIMIT, + out_dir=app.config["UPLOAD_FOLDER"], + filename=chart_fname, + filename_prefix="journal", + marker_payload=marker_payload, + marker_timeframes=( + {x.strip().lower() for x in ORDER_CHART_TFS if x and str(x).strip()} + if ORDER_CHART_TFS + else {"5m", "15m", "1h", "4h"} + ), + ) + if saved: + image_filename = saved + chart_msg = f"已生成多周期K线图:/static/images/{saved}" + if uploaded_tmp: + try: + old_path = os.path.join(app.config["UPLOAD_FOLDER"], uploaded_tmp) + if os.path.exists(old_path): + os.remove(old_path) + except Exception: + pass + else: + chart_msg = "已勾选自动生成K线图,但生成失败(返回空)。请检查 Pillow 是否安装、Gate 网络/代理是否正常。" + except Exception as e: + image_filename = uploaded_tmp + chart_msg = f"自动生成K线图失败:{str(e)}" + + conn = get_db() + conn.execute( + """INSERT INTO journal_entries + (id, open_datetime, close_datetime, hold_duration, coin, tf, pnl, entry_reason, exit_reason, + expect_rr, real_rr, early_exit, early_exit_reason, early_exit_trigger, early_exit_note, + mood_score, mood_ai_score, mood_ai_comment, mood_issues, post_breakeven_stare, + new_trade_while_occupied, note, image) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + entry_id, d.get("open_datetime"), d.get("close_datetime"), hold_duration, d.get("coin"), d.get("tf"), + d.get("pnl"), entry_reason_norm, exit_reason_stored, d.get("expect_rr"), real_rr_text, + early_exit_raw, early_exit_reason_saved, early_exit_trigger, early_exit_note, + None, None, None, mood_issues, + d.get("post_breakeven_stare"), d.get("new_trade_while_occupied"), d.get("note"), image_filename + ) + ) + conn.commit() + conn.close() + if chart_msg: + flash(f"交易复盘记录已保存。{chart_msg}") + else: + flash("交易复盘记录已保存") + return _redirect_records() + + +@app.route("/api/journals") +@login_required +def api_journals(): + win = _list_window_from_request() + start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ) + conn = get_db() + rows = conn.execute( + "SELECT * FROM journal_entries WHERE COALESCE(close_datetime, created_at, open_datetime) >= ? " + "AND COALESCE(close_datetime, created_at, open_datetime) <= ? ORDER BY created_at DESC LIMIT 500", + (start_bj, end_bj), + ).fetchall() + conn.close() + result = [] + for r in rows: + item = row_to_dict(r) + item["mood_issues"] = [x for x in (item.get("mood_issues") or "").split(",") if x] + result.append(item) + return jsonify(result) + + +@app.route("/api/journal_prefill", methods=["POST"]) +@login_required +def api_journal_prefill(): + file = request.files.get("screenshot") + if not file or not file.filename: + return jsonify({"ok": False, "msg": "请先选择截图文件"}), 400 + try: + raw = file.read() + if not raw: + return jsonify({"ok": False, "msg": "截图为空"}), 400 + image_b64 = base64.b64encode(raw).decode("utf-8") + except Exception as e: + return jsonify({"ok": False, "msg": f"读取截图失败:{str(e)}"}), 400 + + parsed = ai_extract_journal_from_image(image_b64) + if parsed is None: + return jsonify({"ok": False, "msg": "AI 识别失败,请稍后重试"}), 500 + return jsonify({"ok": True, "data": parsed}) + + +@app.route("/delete_journal/", methods=["POST"]) +@login_required +def delete_journal(jid): + conn = get_db() + row = conn.execute("SELECT image FROM journal_entries WHERE id=?", (jid,)).fetchone() + if row and row["image"]: + img_path = os.path.join(app.config["UPLOAD_FOLDER"], row["image"]) + if os.path.exists(img_path): + os.remove(img_path) + conn.execute("DELETE FROM journal_entries WHERE id=?", (jid,)) + conn.commit() + conn.close() + return jsonify({"ok": True}) + + +@app.route("/api/reviews") +@login_required +def api_reviews(): + win = _list_window_from_request() + start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ) + conn = get_db() + rows = conn.execute( + "SELECT * FROM ai_reviews WHERE created_at >= ? AND created_at <= ? ORDER BY created_at DESC LIMIT 200", + (start_bj, end_bj), + ).fetchall() + conn.close() + return jsonify([row_to_dict(r) for r in rows]) + + +@app.route("/export/review_md/") +@login_required +def export_review_md(rid): + conn = get_db() + row = conn.execute("SELECT * FROM ai_reviews WHERE id=?", (rid,)).fetchone() + conn.close() + if not row: + return Response("review not found", status=404, mimetype="text/plain; charset=utf-8") + + review_type = "日复盘" if row["review_type"] == "daily" else "周复盘" + target_date = row["target_date"] or "-" + created_at = row["created_at"] or app_now_str() + content = (row["content"] or "").strip() + if not content: + content = "(无内容)" + + md = ( + f"# {review_type}报告\n\n" + f"- 目标日期: {target_date}\n" + f"- 生成时间: {created_at}\n" + f"- 报告ID: {row['id']}\n\n" + f"---\n\n" + f"{content}\n" + ) + + safe_target = re.sub(r"[^0-9A-Za-z_-]+", "-", str(target_date)).strip("-") or "unknown-date" + safe_type = "daily" if row["review_type"] == "daily" else "weekly" + filename = f"ai_review_{safe_type}_{safe_target}_{row['id'][:8]}.md" + return _md_response(filename, md) + + +@app.route("/export/reviews_md_bundle") +@login_required +def export_reviews_md_bundle(): + review_type = (request.args.get("review_type") or "").strip().lower() + target_date = (request.args.get("target_date") or "").strip() + if review_type not in ("daily", "weekly"): + return Response("invalid review_type", status=400, mimetype="text/plain; charset=utf-8") + if not target_date: + return Response("target_date required", status=400, mimetype="text/plain; charset=utf-8") + + conn = get_db() + rows = conn.execute( + "SELECT * FROM ai_reviews WHERE review_type=? AND target_date=? ORDER BY created_at ASC, id ASC", + (review_type, target_date), + ).fetchall() + conn.close() + if not rows: + return Response("no reviews found", status=404, mimetype="text/plain; charset=utf-8") + + title = "日复盘" if review_type == "daily" else "周复盘" + lines = [ + f"# {title}汇总报告", + "", + f"- 目标日期: {target_date}", + f"- 条目数量: {len(rows)}", + f"- 导出时间: {app_now_str()}", + "", + "---", + "", + ] + for idx, row in enumerate(rows, 1): + created_at = row["created_at"] or "-" + content = (row["content"] or "").strip() or "(无内容)" + lines.extend( + [ + f"## 第{idx}条", + "", + f"- 报告ID: {row['id']}", + f"- 生成时间: {created_at}", + "", + content, + "", + "---", + "", + ] + ) + md = "\n".join(lines) + safe_target = re.sub(r"[^0-9A-Za-z_-]+", "-", str(target_date)).strip("-") or "unknown-date" + filename = f"ai_reviews_{review_type}_bundle_{safe_target}.md" + return _md_response(filename, md) + + +@app.route("/delete_review/", methods=["POST"]) +@login_required +def delete_review(rid): + conn = get_db() + conn.execute("DELETE FROM ai_reviews WHERE id=?", (rid,)) + conn.commit() + conn.close() + return jsonify({"ok": True}) + + +@app.route("/delete_trade_record/", methods=["POST"]) +@login_required +def delete_trade_record(rid): + conn = get_db() + cur = conn.execute("DELETE FROM trade_records WHERE id=?", (rid,)) + conn.commit() + conn.close() + return jsonify({"ok": cur.rowcount > 0, "deleted": cur.rowcount}) + + +@app.route("/api/trade_record_review_update", methods=["POST"]) +@login_required +def api_trade_record_review_update(): + payload = request.get_json(silent=True) or {} + rec_id = payload.get("id") + try: + rec_id = int(rec_id) + except Exception: + return jsonify({"ok": False, "msg": "记录ID无效"}), 400 + + reviewed_opened_at = str(payload.get("reviewed_opened_at") or "").strip() + reviewed_closed_at = str(payload.get("reviewed_closed_at") or "").strip() + reviewed_stop_loss_raw = payload.get("reviewed_stop_loss") + reviewed_take_profit_raw = payload.get("reviewed_take_profit") + reviewed_result = str(payload.get("reviewed_result") or "").strip() + reviewed_miss_reason = str(payload.get("reviewed_miss_reason") or "").strip() + reviewed_pnl_raw = payload.get("reviewed_pnl_amount") + + if reviewed_result and reviewed_result not in REVIEW_RESULT_OPTIONS: + return jsonify({"ok": False, "msg": "结果仅允许:止盈/止损/保本止盈/移动止盈/手动平仓"}), 400 + + try: + reviewed_open_dt = datetime.strptime(reviewed_opened_at[:19], "%Y-%m-%d %H:%M:%S") + reviewed_close_dt = datetime.strptime(reviewed_closed_at[:19], "%Y-%m-%d %H:%M:%S") + except Exception: + return jsonify({"ok": False, "msg": "开仓/平仓时间格式错误,需为 YYYY-MM-DD HH:MM:SS"}), 400 + if reviewed_close_dt < reviewed_open_dt: + return jsonify({"ok": False, "msg": "平仓时间不能早于开仓时间"}), 400 + hold_seconds = int((reviewed_close_dt - reviewed_open_dt).total_seconds()) + hold_minutes = calc_hold_minutes(hold_seconds) + + try: + reviewed_pnl_amount = float(reviewed_pnl_raw) + except Exception: + return jsonify({"ok": False, "msg": "盈亏必须为数字"}), 400 + reviewed_stop_loss = None + if reviewed_stop_loss_raw not in (None, ""): + try: + reviewed_stop_loss = float(reviewed_stop_loss_raw) + except Exception: + return jsonify({"ok": False, "msg": "止损必须为数字"}), 400 + reviewed_take_profit = None + if reviewed_take_profit_raw not in (None, ""): + try: + reviewed_take_profit = float(reviewed_take_profit_raw) + except Exception: + return jsonify({"ok": False, "msg": "止盈必须为数字"}), 400 + + _MISSING_ER = object() + reviewed_entry_reason_update = _MISSING_ER + if "reviewed_entry_reason" in payload: + s = str(payload.get("reviewed_entry_reason") or "").strip() + if s and not entry_reason_valid_for_storage(s): + return jsonify({"ok": False, "msg": "开仓类型须为五种固定整句之一、自定义说明(2000字内)或留空"}), 400 + reviewed_entry_reason_update = s or None + + conn = get_db() + row = conn.execute("SELECT risk_amount FROM trade_records WHERE id=?", (rec_id,)).fetchone() + if not row: + conn.close() + return jsonify({"ok": False, "msg": "记录不存在"}), 404 + risk_amount = row["risk_amount"] + actual_rr = calc_actual_rr(reviewed_pnl_amount, risk_amount) + base_params = [ + reviewed_opened_at, + reviewed_closed_at, + reviewed_stop_loss, + reviewed_take_profit, + round(reviewed_pnl_amount, 4), + reviewed_result or None, + reviewed_miss_reason or None, + hold_seconds, + hold_minutes, + app_now_str(), + actual_rr, + ] + if reviewed_entry_reason_update is not _MISSING_ER: + conn.execute( + """UPDATE trade_records + SET reviewed_opened_at=?, reviewed_closed_at=?, reviewed_stop_loss=?, reviewed_take_profit=?, reviewed_pnl_amount=?, + reviewed_result=?, reviewed_miss_reason=?, reviewed_hold_seconds=?, reviewed_hold_minutes=?, + reviewed_at=?, actual_rr=COALESCE(?, actual_rr), reviewed_entry_reason=? + WHERE id=?""", + tuple(base_params + [reviewed_entry_reason_update, rec_id]), + ) + else: + conn.execute( + """UPDATE trade_records + SET reviewed_opened_at=?, reviewed_closed_at=?, reviewed_stop_loss=?, reviewed_take_profit=?, reviewed_pnl_amount=?, + reviewed_result=?, reviewed_miss_reason=?, reviewed_hold_seconds=?, reviewed_hold_minutes=?, + reviewed_at=?, actual_rr=COALESCE(?, actual_rr) + WHERE id=?""", + tuple(base_params + [rec_id]), + ) + conn.commit() + conn.close() + return jsonify({"ok": True, "id": rec_id, "actual_rr": actual_rr, "hold_minutes": hold_minutes}) + + +@app.route("/manual_transfer", methods=["POST"]) +@login_required +def manual_transfer(): + try: + amount = float(request.form.get("amount", "0")) + except Exception: + flash("划转金额格式错误") + return redirect("/") + from_account = (request.form.get("from_account") or AUTO_TRANSFER_FROM).strip() + to_account = (request.form.get("to_account") or AUTO_TRANSFER_TO).strip() + ok, msg, _ = execute_transfer_usdt(amount, from_account, to_account) + conn = get_db() + conn.execute( + "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", + ("manual", get_trading_day(), amount, from_account, to_account, "success" if ok else "failed", msg[:500]) + ) + conn.commit() + conn.close() + if ok: + flash(f"手动划转成功:{amount}U {from_account}->{to_account}") + else: + flash(f"手动划转失败:{msg}") + return redirect("/") + + +@app.route("/ai_daily_review", methods=["POST"]) +@login_required +def ai_daily_review(): + date = request.form.get("date", "") + conn = get_db() + rows = conn.execute( + "SELECT * FROM journal_entries WHERE substr(open_datetime, 1, 10)=? ORDER BY open_datetime ASC", + (date,) + ).fetchall() + conn.close() + if not rows: + return jsonify({"result": "该日无交易记录"}) + + text = f"【每日交易记录】{date}\n总笔数:{len(rows)}\n\n" + for idx, row in enumerate(rows, 1): + text += _journal_row_lines_for_ai(idx, row) + text += "\n" + + image_paths = [] + for row in rows: + img = row["image"] + if not img: + continue + img_path = os.path.join(app.config["UPLOAD_FOLDER"], img) + if os.path.exists(img_path): + image_paths.append(img_path) + ai_result = ai_review(text, "每日", image_paths=image_paths) + full = f"【AI日复盘 {date}】\n{ai_result}\n\n原始记录:\n{text}" + conn = get_db() + conn.execute( + "INSERT INTO ai_reviews (id, review_type, target_date, content) VALUES (?,?,?,?)", + (uuid.uuid4().hex, "daily", date, full) + ) + conn.commit() + conn.close() + return jsonify({"result": full}) + + +@app.route("/ai_weekly_review", methods=["POST"]) +@login_required +def ai_weekly_review(): + start_date = request.form.get("start_date", "") + end_date = request.form.get("end_date", "") + conn = get_db() + rows = conn.execute( + "SELECT * FROM journal_entries WHERE substr(open_datetime,1,10) >= ? AND substr(open_datetime,1,10) <= ? ORDER BY open_datetime ASC", + (start_date, end_date) + ).fetchall() + conn.close() + if not rows: + return jsonify({"result": "该时间段无交易记录"}) + + text = f"【周交易记录】{start_date}~{end_date}\n总笔数:{len(rows)}\n\n" + for idx, row in enumerate(rows, 1): + text += _journal_row_lines_for_ai(idx, row) + text += "\n" + + image_paths = [] + for row in rows: + img = row["image"] + if not img: + continue + img_path = os.path.join(app.config["UPLOAD_FOLDER"], img) + if os.path.exists(img_path): + image_paths.append(img_path) + ai_result = ai_review(text, "周度", image_paths=image_paths) + full = f"【AI周复盘 {start_date}~{end_date}】\n{ai_result}\n\n原始记录:\n{text}" + conn = get_db() + conn.execute( + "INSERT INTO ai_reviews (id, review_type, target_date, content) VALUES (?,?,?,?)", + (uuid.uuid4().hex, "weekly", f"{start_date}~{end_date}", full) + ) + conn.commit() + conn.close() + return jsonify({"result": full}) + +# 启动 +if __name__ == "__main__": + threading.Thread(target=background_task, daemon=True).start() + app.run(host=HOST, port=PORT, debug=DEBUG) diff --git a/crypto_monitor_gate_bot/ecosystem.config.cjs b/crypto_monitor_gate_bot/ecosystem.config.cjs new file mode 100644 index 0000000..536fec5 --- /dev/null +++ b/crypto_monitor_gate_bot/ecosystem.config.cjs @@ -0,0 +1,33 @@ +/** + * PM2 进程定义(Ubuntu / Linux)。 + * + * 仅托管 Flask 应用。**SSH SOCKS 隧道请在本机用 screen/tmux/systemd 等方式单独常驻**, + * 与 `.env` 里 `GATE_SOCKS_PROXY` 端口一致即可;不必交给 PM2。 + * + * 使用前:项目根目录存在 `.venv`,且已安装依赖(走 SOCKS 时需 PySocks)。 + * + * 启动: + * pm2 start ecosystem.config.cjs + * 保存开机列表: + * pm2 save && pm2 startup + */ +const path = require("path"); + +const ROOT = __dirname; +const PY = path.join(ROOT, ".venv", "bin", "python"); + +module.exports = { + apps: [ + { + name: "crypto_gate_bot", + cwd: ROOT, + script: path.join(ROOT, "app.py"), + interpreter: PY, + instances: 1, + autorestart: true, + watch: false, + max_memory_restart: "800M", + // app.py 从项目根目录 .env 加载(由 .env.example 复制而来,勿提交 Git) + }, + ], +}; diff --git a/crypto_monitor_gate_bot/scripts/backup_data.sh b/crypto_monitor_gate_bot/scripts/backup_data.sh new file mode 100644 index 0000000..9a25287 --- /dev/null +++ b/crypto_monitor_gate_bot/scripts/backup_data.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Daily backup: SQLite DB + static/images → /root/backups/// +# Prune backup folders older than RETENTION_DAYS (default 30). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$PROJECT_DIR" + +BACKUP_ROOT="${BACKUP_ROOT:-/root/backups}" +RETENTION_DAYS="${RETENTION_DAYS:-30}" +INSTANCE_NAME="${BACKUP_INSTANCE:-$(basename "$PROJECT_DIR")}" +TZ_NAME="${BACKUP_TZ:-Asia/Shanghai}" + +log() { + printf '[%s] %s\n' "$(TZ="$TZ_NAME" date '+%Y-%m-%d %H:%M:%S %Z')" "$*" +} + +read_env_var() { + local key="$1" + local default="$2" + local line + if [[ ! -f .env ]]; then + printf '%s' "$default" + return + fi + line="$(grep -E "^${key}=" .env 2>/dev/null | tail -1 || true)" + if [[ -z "$line" ]]; then + printf '%s' "$default" + return + fi + printf '%s' "${line#*=}" | tr -d '\r' +} + +resolve_project_path() { + local p="$1" + if [[ "$p" == /* ]]; then + printf '%s' "$p" + else + printf '%s' "$PROJECT_DIR/$p" + fi +} + +prune_old_backups() { + local base="$BACKUP_ROOT/$INSTANCE_NAME" + [[ -d "$base" ]] || return 0 + local cutoff + cutoff="$(TZ="$TZ_NAME" date -d "-${RETENTION_DAYS} days" +%Y-%m-%d 2>/dev/null || true)" + if [[ -z "$cutoff" ]]; then + find "$base" -mindepth 1 -maxdepth 1 -type d -mtime +"$RETENTION_DAYS" -print0 | + xargs -r -0 rm -rf + return 0 + fi + local dir name + for dir in "$base"/*/; do + [[ -d "$dir" ]] || continue + name="$(basename "$dir")" + [[ "$name" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || continue + if [[ "$name" < "$cutoff" ]]; then + log "prune: remove $dir (older than ${RETENTION_DAYS} days)" + rm -rf "$dir" + fi + done +} + +DB_REL="$(read_env_var DB_PATH crypto.db)" +UPLOAD_REL="$(read_env_var UPLOAD_DIR static/images)" +BACKUP_ROOT="$(read_env_var BACKUP_ROOT "$BACKUP_ROOT")" +RETENTION_DAYS="$(read_env_var BACKUP_RETENTION_DAYS "$RETENTION_DAYS")" +INSTANCE_NAME="$(read_env_var BACKUP_INSTANCE "$INSTANCE_NAME")" + +DB_PATH="$(resolve_project_path "$DB_REL")" +UPLOAD_DIR="$(resolve_project_path "$UPLOAD_REL")" +DATE_TAG="$(TZ="$TZ_NAME" date +%Y-%m-%d)" +DEST="$BACKUP_ROOT/$INSTANCE_NAME/$DATE_TAG" + +if [[ ! -f "$DB_PATH" ]]; then + log "error: database not found: $DB_PATH" + exit 1 +fi + +mkdir -p "$DEST" +log "start backup instance=$INSTANCE_NAME dest=$DEST" + +if command -v sqlite3 >/dev/null 2>&1; then + sqlite3 "$DB_PATH" ".backup '$DEST/crypto.db'" + log "db: sqlite3 backup -> $DEST/crypto.db" +else + cp -a "$DB_PATH" "$DEST/crypto.db" + log "db: cp -> $DEST/crypto.db (sqlite3 not installed)" +fi + +if [[ -d "$UPLOAD_DIR" ]]; then + tar -czf "$DEST/static_images.tar.gz" -C "$(dirname "$UPLOAD_DIR")" "$(basename "$UPLOAD_DIR")" + log "images: $UPLOAD_DIR -> $DEST/static_images.tar.gz" +else + log "warn: upload dir missing, skip images: $UPLOAD_DIR" +fi + +{ + echo "instance=$INSTANCE_NAME" + echo "project_dir=$PROJECT_DIR" + echo "backup_date=$DATE_TAG" + echo "db_path=$DB_PATH" + echo "upload_dir=$UPLOAD_DIR" +} >"$DEST/manifest.txt" + +prune_old_backups +log "done" diff --git a/crypto_monitor_gate_bot/scripts/fix_breakeven_labels.py b/crypto_monitor_gate_bot/scripts/fix_breakeven_labels.py new file mode 100644 index 0000000..80b7d04 --- /dev/null +++ b/crypto_monitor_gate_bot/scripts/fix_breakeven_labels.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +""" +一次性修复历史交易记录标签: +将 trade_records 里“止损但实际盈利”的记录改为“保本止盈”。 + +默认条件(可通过参数修改): +- monitor_type = 下单监控 +- result = 止损 +- pnl_amount > 0 + +用法示例: +1) 仅预览(不落库): + python scripts/fix_breakeven_labels.py --db ./crypto.db --dry-run + +2) 执行修复: + python scripts/fix_breakeven_labels.py --db ./crypto.db --apply +""" + +from __future__ import annotations + +import argparse +import sqlite3 +import sys +from pathlib import Path + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Fix historical stop-loss records with positive pnl.") + parser.add_argument("--db", required=True, help="Path to sqlite db file, e.g. ./crypto.db") + parser.add_argument("--monitor-type", default="下单监控", help="Filter by monitor_type (default: 下单监控)") + parser.add_argument("--from-result", default="止损", help="Source result label (default: 止损)") + parser.add_argument("--to-result", default="保本止盈", help="Target result label (default: 保本止盈)") + parser.add_argument("--dry-run", action="store_true", help="Preview only, no write") + parser.add_argument("--apply", action="store_true", help="Execute update") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + db_path = Path(args.db).expanduser().resolve() + if not db_path.exists(): + print(f"[ERR] DB not found: {db_path}") + return 1 + + if args.dry_run and args.apply: + print("[ERR] --dry-run and --apply are mutually exclusive.") + return 1 + if not args.dry_run and not args.apply: + print("[INFO] No mode provided, defaulting to --dry-run.") + args.dry_run = True + + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + cur = conn.cursor() + + where_sql = """ + monitor_type = ? + AND result = ? + AND CAST(COALESCE(pnl_amount, 0) AS REAL) > 0 + """ + params = (args.monitor_type, args.from_result) + + cur.execute(f"SELECT COUNT(*) AS c FROM trade_records WHERE {where_sql}", params) + will_change = int(cur.fetchone()["c"]) + print(f"[INFO] Candidate rows: {will_change}") + + if will_change == 0: + print("[INFO] Nothing to update.") + conn.close() + return 0 + + cur.execute( + f""" + SELECT id, symbol, result, pnl_amount, closed_at + FROM trade_records + WHERE {where_sql} + ORDER BY id DESC + LIMIT 10 + """, + params, + ) + sample = cur.fetchall() + print("[INFO] Sample (latest 10):") + for r in sample: + print( + f" id={r['id']} symbol={r['symbol']} result={r['result']} " + f"pnl={r['pnl_amount']} closed_at={r['closed_at']}" + ) + + if args.dry_run: + print("[DRY-RUN] No write executed.") + conn.close() + return 0 + + cur.execute( + f"UPDATE trade_records SET result=? WHERE {where_sql}", + (args.to_result, *params), + ) + changed = int(cur.rowcount) + conn.commit() + conn.close() + print(f"[DONE] Updated rows: {changed}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) + diff --git a/crypto_monitor_gate_bot/scripts/install_backup_cron.sh b/crypto_monitor_gate_bot/scripts/install_backup_cron.sh new file mode 100644 index 0000000..2ebe5cc --- /dev/null +++ b/crypto_monitor_gate_bot/scripts/install_backup_cron.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Install daily backup cron: Beijing 00:00 (CRON_TZ=Asia/Shanghai). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +BACKUP_SCRIPT="$SCRIPT_DIR/backup_data.sh" +INSTANCE_NAME="${BACKUP_INSTANCE:-$(basename "$PROJECT_DIR")}" +LOG_FILE="${BACKUP_CRON_LOG:-/var/log/crypto-monitor-backup-${INSTANCE_NAME}.log}" + +if [[ ! -x "$BACKUP_SCRIPT" ]]; then + chmod +x "$BACKUP_SCRIPT" +fi + +TMP="$(mktemp)" +trap 'rm -f "$TMP"' EXIT + +{ + crontab -l 2>/dev/null | grep -vF "$BACKUP_SCRIPT" || true + echo "CRON_TZ=Asia/Shanghai" + echo "0 0 * * * $BACKUP_SCRIPT >> $LOG_FILE 2>&1" +} >"$TMP" + +awk ' + BEGIN { tz = 0 } + /^CRON_TZ=Asia\/Shanghai$/ { + if (tz++) next + } + { print } +' "$TMP" >"${TMP}.2" +mv "${TMP}.2" "$TMP" + +crontab "$TMP" +echo "Installed cron for $INSTANCE_NAME" +echo " Schedule : daily 00:00 Asia/Shanghai" +echo " Script : $BACKUP_SCRIPT" +echo " Log : $LOG_FILE" +crontab -l | grep -F "$BACKUP_SCRIPT" || true diff --git a/crypto_monitor_gate_bot/scripts/verify_gate_funding.py b/crypto_monitor_gate_bot/scripts/verify_gate_funding.py new file mode 100644 index 0000000..bd410a8 --- /dev/null +++ b/crypto_monitor_gate_bot/scripts/verify_gate_funding.py @@ -0,0 +1,93 @@ +""" +在项目根目录执行(会加载根目录 .env): + python scripts/verify_gate_funding.py + +依次探测:[0] swap 余额(与 App「交易账户」同源);[1]–[3] 现货 / 统一账户资金路径。 +打印 GATE_API_KEY 前 8 位便于与 Gate 控制台核对(不含 Secret)。用于服务器自检。 +""" +from __future__ import annotations + +import importlib.util +import os +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + + +def _load_app(): + path = os.path.join(ROOT, "app.py") + spec = importlib.util.spec_from_file_location("crypto_app", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def main(): + os.chdir(ROOT) + mod = _load_app() + print("LIVE_TRADING_ENABLED =", os.getenv("LIVE_TRADING_ENABLED")) + ok, reason = mod.ensure_exchange_live_ready() + print("ensure_exchange_live_ready =", ok, repr(reason)) + if not ok: + print("跳过私有接口探测") + return 1 + + mod.ensure_markets_loaded() + + k = (os.getenv("GATE_API_KEY") or "").strip() + s = (os.getenv("GATE_API_SECRET") or "").strip() + if not k or "REPLACE" in k.upper(): + print("WARN: GATE_API_KEY 为空或仍像占位符,请核对 .env") + if not s or "REPLACE" in s.upper(): + print("WARN: GATE_API_SECRET 为空或仍像占位符,请核对 .env") + print("GATE_API_KEY prefix (8 chars):", (k[:8] + "…") if len(k) > 8 else "(short)") + + # 0) swap — 与 App「交易账户」余额同源(优先看此项是否与网页一致) + try: + bal = mod.exchange.fetch_balance({"type": "swap"}) + v0 = mod._extract_usdt_total(bal) + print("[0] fetch_balance(swap) USDT total =", v0) + except Exception as e: + print("[0] fetch_balance(swap) FAILED:", type(e).__name__, e) + + # 1) fetch_balance spot + marginMode spot + try: + bal = mod.exchange.fetch_balance({"type": "spot", "marginMode": "spot"}) + v = mod._extract_usdt_total(bal) + print("[1] fetch_balance(spot,marginMode=spot) USDT total =", v) + except Exception as e: + print("[1] fetch_balance(spot) FAILED:", type(e).__name__, e) + + # 2) raw spot accounts + try: + resp = mod.exchange.privateSpotGetAccounts({}) + v2 = mod._parse_gate_spot_accounts_response_usdt(resp) + print("[2] privateSpotGetAccounts USDT =", v2) + except Exception as e: + print("[2] privateSpotGetAccounts FAILED:", type(e).__name__, e) + + # 3) unified accounts raw + try: + raw = mod.exchange.privateUnifiedGetAccounts({}) + body = raw + if isinstance(body, dict) and isinstance(body.get("result"), dict): + body = body["result"] + if isinstance(body, dict): + keys = sorted(body.keys()) + print("[3] unified top-level keys (sample):", keys[:25], "..." if len(keys) > 25 else "") + v3 = mod._parse_usdt_from_gate_unified_accounts_body(body) if isinstance(body, dict) else None + print("[3] parsed unified USDT =", v3) + except Exception as e: + print("[3] privateUnifiedGetAccounts FAILED:", type(e).__name__, e) + + fu = mod._fetch_gate_funding_usdt() + print(">>> _fetch_gate_funding_usdt() =", fu) + f, t = mod.get_exchange_capitals(force=True) + print(">>> get_exchange_capitals(force=True) funding, trading =", f, t) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/crypto_monitor_gate_bot/start_utf8.ps1 b/crypto_monitor_gate_bot/start_utf8.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..4a8fd0cbe425e232604b47af88dfdd3bd069e311 GIT binary patch literal 1080 zcmcJOPfNo<5XIkF@H;HE2R*dtK@q`&Xw`z$Vighf5YtpEZ4#PjE%@2h-|V)wLg`JG z%}!=^-u!#}{Z&;%BUZ*fQmj&avy5bTN3d>>Rqb|9`=^ ziritv`1L_9b;bRRb1}bDj~nW^(X~#&*vGq1bq?P*yhE*)Pf6qvIh!@2GOH+I7ig?M zh8$B(OgyJO?P{B~gZ#<91kLhpaSm8^`(DT1v*0@b>rn@+CRn+@-9U=5Stn9GgJRvj zr!MQLByc2!!S68TEpZWdu3%Duw#@f`@ir68`|Bqgs_G5LVW!+ho;o@wq2Zf)Y zv9wLcjA=F}hc-;kO*wJu-)oS;FZ0_H?X>%j{~oF$P>^5ug!bjzq%f<%yF$-KLi}HnE(I) literal 0 HcmV?d00001 diff --git a/crypto_monitor_gate_bot/templates/index.html b/crypto_monitor_gate_bot/templates/index.html new file mode 100644 index 0000000..0231a5f --- /dev/null +++ b/crypto_monitor_gate_bot/templates/index.html @@ -0,0 +1,1663 @@ + + + + + {{ exchange_display }} · 加密货币 | 机器人交易监控 + + + +{% macro period_metrics_cells(s) %} +
开单次数
{{ s.opens_count }}
+
平仓笔数
{{ s.closed_count }}
+
胜率
{% if s.win_rate_pct is not none %}{{ s.win_rate_pct }}%{% else %}-{% endif %}
+
净盈亏(U)
{{ money_fmt(s.net_pnl_u) }}
+
亏损额合计(U)
{{ money_fmt(s.loss_sum_u) }}
+
单笔最大亏损(U)
{% if s.max_single_loss is not none %}{{ money_fmt(s.max_single_loss) }}{% else %}-{% endif %}
+
单笔最大盈利(U)
{% if s.max_single_profit is not none %}{{ money_fmt(s.max_single_profit) }}{% else %}-{% endif %}
+
最大回撤(U)
{{ money_fmt(s.max_drawdown_u) }}
+
当前连续亏损笔数
{{ s.consecutive_losses }}
+
最长连续亏损(交易日)
{{ s.max_loss_streak_days }} 天
+
期内最大亏损日
{% if s.worst_day %}{{ s.worst_day }}({{ money_fmt(s.worst_day_pnl) }}U){% else %}-{% endif %}
+{% endmacro %} +{% macro period_stats_dual(title, pair) %} +
+

{{ title }}

+
{{ pair.range_label }}
+
+
+
机器人下单监控
+
{{ period_metrics_cells(pair.order) }}
+
+
+
趋势回调策略
+
{{ period_metrics_cells(pair.trend) }}
+
+
+
+{% endmacro %} +
+
+

加密货币|Gate 机器人交易监控

+
{{ exchange_display }}
+
+ + {% with msg=get_flashed_messages() %}{% if msg %}
{{ msg[0] }}
{% endif %}{% endwith %} + + + {% if page in ('records', 'plan_history') %} +
+ 列表筛选(UTC,默认当日):{{ list_window.label }} + + + + + + + 统计页仍按北京时间 {{ reset_hour }}:00 切日 +
+ {% endif %} + +
+ 数据导出(v{{ data_export_version }} CSV,UTF-8;交易记录含开仓类型列及交易所对齐字段): + 交易记录 +
+
+
交易所
{{ exchange_display }}
+
总交易
{{ total }}
+
错过次数
{{ miss_count }}
+
胜率
{{ rate }}%
+
资金账户(USDT)
{% if funding_usdt is not none %}{{ money_fmt(funding_usdt) }}U{% else %}—{% endif %}
+
交易日
{{ trading_day }}
+
当日资金(交易账户)
{{ money_fmt(current_capital) }}U
+
+
实时价格更新时间:--(北京时间 UTC+8)
+ +
+ {% if page == 'trade' %} +
+
+
+
+

机器人下单监控(单仓)

+ {% if focus_order_id %} + 放大查看K线(100根) + {% else %} + 暂无持仓可放大 + {% endif %} +
+
+ 规则:最大同时持仓 {{ max_active_positions }}(与 Gate 主站 MAX_ACTIVE_POSITIONS 一致,当前 active {{ active_count }});与「趋势回调」计划互斥;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x; + {% if can_trade %}可开仓{% else %}不可开仓(持仓达上限、有趋势回调计划,或未到北京时间 {{ reset_hour }}:00){% endif %}; + 按风险比例自动计算仓位 +
+
+ 以损定仓:风险 {{ risk_percent }}% |移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}% +
+
+ 划转:自动划转 {{ '开启' if auto_transfer_enabled else '关闭' }}(每天北京时间 {{ auto_transfer_bj_hour }}:00起该整点小时内尝试;账簿按 UTC 自然日去重;界面时间为北京;将 {{ auto_transfer_to }} 补足到 {{ money_fmt(auto_transfer_amount) }}U,来自 {{ auto_transfer_from }}) +
+
+ + + + +
+
+ + + + + + + + 成交价自动取交易所实时+成交回报 + + + + + +
+
+

实时持仓

+
+ {% for o in order %} + {% set osym = o.exchange_symbol or o.symbol %} +
+
+
+ {{ osym }} + {{ '做多' if o.direction == 'long' else '做空' }} +
+ 平仓 +
+
+ 来源: 下单监控 | 风格: {{ o.trade_style or 'trend' }} | 风险: {% if o.risk_percent is not none %}{{ o.risk_percent }}%{% else %}—{% endif %}≈{{ money_fmt(o.risk_amount) }}U + | {% if o.breakeven_enabled %}移动保本: 开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(osym, o.breakeven_price) }}{% else %}移动保本: 关{% endif %} +
+
+
+ 成交价 + {{ price_fmt(osym, o.trigger_price) }} +
+
+ 止损 + {{ price_fmt(osym, o.stop_loss) }} +
+
+ 止盈 + {{ price_fmt(osym, o.take_profit) }} +
+
+ 盈亏比 + {% if o.rr_ratio is not none %}{{ '%.2f'|format(o.rr_ratio) }}:1{% else %}—{% endif %} +
+
+ 标记价 + - +
+
+ 浮盈亏 + - +
+
+
+ 保证金: - + | 计划基数: {{ money_fmt(o.margin_capital) }}U + | 杠杆: {{ o.leverage }}x + | 仓位占比: {{ o.position_ratio }}% +
+
+ {% else %} +
暂无机器人持仓
+ {% endfor %} +
+
+
+ +
+

趋势回调策略

+
+ ① 生成预览:读取合约 USDT 可用余额快照并计算计划(不下单)。预览有效期 {{ trend_pullback_preview_ttl }} 秒
+ ② 确认执行:市价首仓 50% + 挂交易所止损;首仓后可手动保本(默认均价+{{ trend_manual_breakeven_offset_pct }}%);剩余 50% 在止损与补仓区间之间共 {{ trend_pullback_dca_legs }} 档(做多为上沿、做空为下沿;程序可能因最小张数自动减档)市价补仓;止盈由程序监控
+ 确认执行时若当前可用余额与预览快照相对偏差 > {{ trend_preview_max_drift_pct }}% 会拒绝并要求重新预览。 +
+
+ + + + + + + + +
+ + + {% if trend_preview %} +
+
+ 当前预览(剩余 {{ trend_pullback_preview_ttl }}s) + 倒计时加载中… +
+
+ {{ trend_preview.symbol }} {{ '做多' if trend_preview.direction == 'long' else '做空' }} {{ trend_preview.leverage }}x | + 预览可用快照 {{ money_fmt(trend_preview.snapshot_available_usdt) }} U | 参考价 {{ price_fmt(trend_preview.symbol, trend_preview.live_price_ref) }} | + 计划保证金≈{{ money_fmt(trend_preview.plan_margin_capital) }} U | 总张≈{{ amt_fmt(trend_preview.symbol, trend_preview.target_order_amount) }}(首仓 {{ amt_fmt(trend_preview.symbol, trend_preview.first_order_amount) }} + 补仓 {{ amt_fmt(trend_preview.symbol, trend_preview.remainder_total) }})
+ 止损 {{ price_fmt(trend_preview.symbol, trend_preview.stop_loss) }} | {{ trend_add_zone_label(trend_preview.direction) }} {{ price_fmt(trend_preview.symbol, trend_preview.add_upper) }} | 止盈 {{ price_fmt(trend_preview.symbol, trend_preview.take_profit) }} | 风险比例 {{ trend_preview.risk_percent }}% +
+
+ + + {% for row in trend_preview_levels %} + + {% endfor %} +
#补仓触发价该档张数
{{ row.i }}{{ price_fmt(trend_preview.symbol, row.price) }}{{ amt_fmt(trend_preview.symbol, row.contracts) }}
+
+
+
+ + +
+
+ + +
+
+
+ + {% elif trend_preview_expired %} +
该预览已过期(超过 {{ trend_pullback_preview_ttl }} 秒),请重新点击「生成预览」。
+ {% endif %} + +
+

运行中的计划

+
+ {% for t in trend_plans %} + {% set sym = t.exchange_symbol or t.symbol %} + {% set calc = namespace(rr=None, pnlpct=None) %} + {% if t.avg_entry_price is not none and t.stop_loss is not none and t.take_profit is not none %} + {% set e = t.avg_entry_price|float %} + {% set sl = t.stop_loss|float %} + {% set tp = t.take_profit|float %} + {% if t.direction == 'long' %} + {% set risk = e - sl %} + {% set reward = tp - e %} + {% else %} + {% set risk = sl - e %} + {% set reward = e - tp %} + {% endif %} + {% if risk > 0 %} + {% set calc.rr = reward / risk %} + {% endif %} + {% endif %} + {% if t.floating_pnl is not none and t.plan_margin_capital is not none and t.plan_margin_capital|float > 0 %} + {% set calc.pnlpct = (t.floating_pnl|float) / (t.plan_margin_capital|float) * 100 %} + {% endif %} +
+
+
+ #{{ t.id }} {{ sym }} + {{ '做多' if t.direction == 'long' else '做空' }} +
+ 结束计划 +
+
+ 来源: 趋势回调计划 | 风险: {% if t.risk_percent is not none %}{{ t.risk_percent }}%{% else %}—{% endif %} + | {{ trend_add_zone_label(t.direction) }} {{ price_fmt(sym, t.add_upper) }} + | 已补仓 {{ t.legs_done }}/{{ t.dca_legs }} +
+
+
+ 均价 + {% if t.avg_entry_price is not none %}{{ price_fmt(sym, t.avg_entry_price) }}{% else %}—{% endif %} +
+
+ 止损 + {{ price_fmt(sym, t.stop_loss) }} +
+
+ 止盈 + {{ price_fmt(sym, t.take_profit) }} +
+
+ 盈亏比 + {% if calc.rr is not none %}{{ '%.2f'|format(calc.rr) }}:1{% else %}—{% endif %} +
+
+ 标记价 + {% if t.floating_mark is not none %}{{ price_fmt(sym, t.floating_mark) }}{% else %}—{% endif %} +
+
+ 浮盈亏 + + {% if t.floating_pnl is not none %} + {{ money_fmt(t.floating_pnl) }}U{% if calc.pnlpct is not none %} ({{ '%+.2f'|format(calc.pnlpct) }}%){% endif %} + {% else %}—{% endif %} + +
+
+ +
+
+ + + {% if t.breakeven_applied %}已保本 {{ (t.breakeven_applied_at or '')[:16] }}{% endif %} + {% if t.initial_stop_loss is not none and t.initial_stop_loss != t.stop_loss %}原止损 {{ price_fmt(sym, t.initial_stop_loss) }}{% endif %} +
+
+
+ 快照可用: {% if t.snapshot_available_usdt is not none %}{{ money_fmt(t.snapshot_available_usdt) }}U{% else %}—{% endif %} + | 计划保证金≈{% if t.plan_margin_capital is not none %}{{ money_fmt(t.plan_margin_capital) }}U{% else %}—{% endif %} + | 总张≈{{ amt_fmt(sym, t.target_order_amount) }}(首{{ amt_fmt(sym, t.first_order_amount) }} + 补{{ amt_fmt(sym, t.remainder_total) }}) + | 杠杆: {{ t.leverage }}x +
+
+ {% else %} +
暂无运行中的趋势回调计划
+ {% endfor %} +
+
+
+
+
+ {% endif %} + + {% if page == 'records' %} +
+

交易记录 & 错过机会

+
+ +
+
+ + + {% for r in record %} + + + + + + {% set stop_show = r.effective_stop_loss or r.initial_stop_loss or r.stop_loss %} + {% set tp_show = r.effective_take_profit or r.take_profit %} + + + + + + + + {% set pnl_val = (r.display_pnl_amount or 0)|float %} + + + + + {% endfor %} +
品种类型方向成交止损止盈基数杠杆持仓分钟开仓(展示)平仓(展示)盈亏U(展示)结果操作
{{ r.symbol }}{{ r.monitor_type }}{{ '做多' if r.direction == 'long' else '做空' }}{{ price_fmt(r.symbol, r.trigger_price) }}{{ price_fmt(r.symbol, stop_show) }}{{ price_fmt(r.symbol, tp_show) }}{% if r.margin_capital is not none and r.margin_capital != '' %}{{ money_fmt(r.margin_capital) }}{% else %}-{% endif %}{{ r.leverage or '-' }}{{ r.effective_hold_minutes or 0 }}{{ r.display_opened_at }}{{ r.display_closed_at }}{{ money_fmt(r.display_pnl_amount) }}{% if r.monitor_type == '趋势回调' and r.display_pnl_source == 'local' %}{% elif r.monitor_type == '趋势回调' and r.display_pnl_source == 'exchange' %}{% endif %} + {% set effective_result = r.effective_result %} + {% if effective_result in ["止盈","保本止盈","移动止盈"] %}{{ effective_result }} + {% elif effective_result in ["止损","强制清仓","手动平仓"] %}{{ effective_result }} + {% else %}{{ effective_result }}{% endif %} + + + + +
+
+
+ +
+

记录错过机会

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

交易复盘记录上传(含截图)

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

AI复盘(按交易记录)

+
+ + + + + + + +
+ + +
+
+ 交易复盘记录 +
+
+
+ AI历史复盘 +
+
+
+
+ + {% endif %} +
+ + {% if page == 'plan_history' %} +
+

已结束的趋势回调计划

+
删除将同时移除 trend_plan_id 关联的「趋势回调」交易记录及该计划对应的预览快照归档。交易所平仓同步起点(北京日期):{{ exchange_sync_from_label }}EXCHANGE_POSITION_SYNC_FROM_BJ)。
+ {% if plan_history and plan_history|length > 0 %} +
+ + + {% for p in plan_history %} + + + + + + + + + + + + {% endfor %} +
ID品种方向杠杆状态结束开仓时间计划保证金≈操作
{{ p.id }}{{ p.symbol }}{{ '做多' if p.direction == 'long' else '做空' }}{{ p.leverage }}x{{ p.status_label }}{{ p.message or '-' }}{{ (p.opened_at or '-')[:16] }}{% if p.plan_margin_capital is not none %}{{ money_fmt(p.plan_margin_capital) }}{% else %}-{% endif %} +
+ +
+
+
+ {% else %} +
暂无已结束的计划
+ {% endif %} +
+
+

预览快照(自本版本起留存)

+
每次「生成预览」自动归档;取消、过期或执行后仍可点开查看当时参数。执行后状态为「已执行」并带关联计划 ID。
+ {% if preview_snapshots and preview_snapshots|length > 0 %} +
+ + + {% for s in preview_snapshots %} + + + + + + + + + + + {% endfor %} +
ID时间品种方向杠杆状态快照余额U操作
{{ s.id }}{{ (s.preview_created_at or '-')[:16] }}{{ s.symbol }}{{ '多' if s.direction == 'long' else '空' }}{{ s.leverage }}x{{ s.outcome_label }}{% if s.executed_plan_id %} #{{ s.executed_plan_id }}{% endif %}{{ money_fmt(s.snapshot_available_usdt) }}
+
+ {% else %} +
暂无预览快照(新版本生成预览后将出现在此)
+ {% endif %} +
+ {% endif %} + + {% if page == 'stats' %} +
+
+

数据统计

+ +
+
+
+
持仓占用导致错过(累计)
{{ occupied_miss_total }}
+
+
+ 已平仓记录按平仓时间归入北京时间交易日;胜率按盈笔数/(盈+亏)。各策略分列统计。
+ 历史总开仓(累计):下单监控 {{ stats_bundle.total_opens_order }} 次 + | 趋势回调 {{ stats_bundle.total_opens_trend }} 次 + (合计 {{ stats_bundle.total_opens_all }} 次) +
+ {{ period_stats_dual("日统计", stats_bundle.day) }} + {{ period_stats_dual("周统计", stats_bundle.week) }} + {{ period_stats_dual("月统计", stats_bundle.month) }} +
+
+ {% endif %} +
+ + +
+
+
+
详情
+ +
+
+ +
+
+ + + + \ No newline at end of file diff --git a/crypto_monitor_gate_bot/templates/key_focus.html b/crypto_monitor_gate_bot/templates/key_focus.html new file mode 100644 index 0000000..41a633a --- /dev/null +++ b/crypto_monitor_gate_bot/templates/key_focus.html @@ -0,0 +1 @@ +ok2 \ No newline at end of file diff --git a/crypto_monitor_gate_bot/templates/key_focus_v2.html b/crypto_monitor_gate_bot/templates/key_focus_v2.html new file mode 100644 index 0000000..d4b3492 --- /dev/null +++ b/crypto_monitor_gate_bot/templates/key_focus_v2.html @@ -0,0 +1,261 @@ + + + + + {{ exchange_display }} | 关键位放大 + + + +
+
+
+
+ 返回首页 + 关键位放大(可输入币种){{ exchange_display }} +
+
最近刷新:--
+
+ +
+ + + + + + + + + + + + + + +
+
+ +
+
+
交易对
-
+
监控类型
-
+
方向
-
+
上沿/阻力
-
+
下沿/支撑
-
+
现价
-
+
距上沿
-
+
距下沿
-
+
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/crypto_monitor_gate_bot/templates/login.html b/crypto_monitor_gate_bot/templates/login.html new file mode 100644 index 0000000..cfcc816 --- /dev/null +++ b/crypto_monitor_gate_bot/templates/login.html @@ -0,0 +1,118 @@ + + + + + 登录 · {{ exchange_display }} + + + + + + diff --git a/crypto_monitor_gate_bot/templates/order_focus.html b/crypto_monitor_gate_bot/templates/order_focus.html new file mode 100644 index 0000000..0811e93 --- /dev/null +++ b/crypto_monitor_gate_bot/templates/order_focus.html @@ -0,0 +1,194 @@ + + + + + 实盘下单放大 | 100根K线 + + + +
+
+
+
+ 返回首页 + 实盘下单放大(100根K线) +
+
最近刷新:--
+
+ {% if orders %} +
+ + + + + + +
+ {% else %} +
当前没有激活订单,无法展示放大K线。
+ {% endif %} +
+ + {% if orders %} +
+
+
交易对
-
+
方向
-
+
成交价
-
+
止损
-
+
止盈
-
+
盈亏比
-
+
现价
-
+
浮盈亏
-
+
+
+ +
+
+
+ {% endif %} +
+ +{% if orders %} + + +{% endif %} + + diff --git a/crypto_monitor_gate_bot/templates/order_focus_v2.html b/crypto_monitor_gate_bot/templates/order_focus_v2.html new file mode 100644 index 0000000..9c9add3 --- /dev/null +++ b/crypto_monitor_gate_bot/templates/order_focus_v2.html @@ -0,0 +1,214 @@ + + + + + {{ exchange_display }} | 实盘下单放大 + + + +
+
+
+
+ 返回首页 + 实盘下单放大(100根K线){{ exchange_display }} +
+
最近刷新:--
+
+ {% if orders %} +
+ + + + + + +
+ {% else %} +
当前没有激活订单,无法展示放大K线。
+ {% endif %} +
+ + {% if orders %} +
+
+
交易对
-
+
方向
-
+
成交价
-
+
止损
-
+
止盈
-
+
盈亏比
-
+
移动保本
-
+
现价
-
+
浮盈亏
-
+
+
+ +
+
+
+ {% endif %} +
+ +{% if orders %} + + +{% endif %} + + + diff --git a/crypto_monitor_gate_bot/趋势回调策略说明.md b/crypto_monitor_gate_bot/趋势回调策略说明.md new file mode 100644 index 0000000..42cffd3 --- /dev/null +++ b/crypto_monitor_gate_bot/趋势回调策略说明.md @@ -0,0 +1,125 @@ +# 趋势回调策略(机器人)说明 + +本文描述本仓库内 **「趋势回调」** 自动交易计划的业务规则与实现口径,便于单独策略账户使用与审计。 + +--- + +## 1. 适用场景 + +- 单独用于跑策略的 **Gate.io USDT 永续** 子账户(建议与主资金隔离)。 +- 你已明确:**方向、止损价、补仓区间边界价、止盈价、杠杆**,并接受程序按风险预算拆分 **首仓 50% + 多档补仓 50%**。 + +--- + +## 2. 名词与参数 + +| 名称 | 含义 | +|------|------| +| **合约 USDT 可用余额** | **生成预览**时通过 API 读取的 **swap 账户 USDT `free`** 快照;**确认执行**时再次读取并与快照比对偏差。 | +| **风险比例** | 默认 **5%**:指「若整笔计划在 **补仓区间远侧边界**(做多=上沿、做空=下沿)这一侧的最坏价格结构下触及止损」,目标亏损上限约为 **可用余额快照 × 风险比例**(实现上用 `calc_risk_fraction` 与 `prepare_order_amount` 反推总张数,受交易所最小张数与精度约束)。 | +| **止损价** | 用户填写;开仓后挂 **交易所仓位类止损触发单**(全平)。 | +| **补仓区间边界**(库字段 `add_upper`) | 用户填写;**仅在该价位与止损价构成的区间内** 才允许程序触发剩余 50% 的市价补仓。**界面文案**:做多显示「补仓上沿」,做空显示「补仓下沿」。校验:做多 `止损 < 边界价`;做空 `止损 > 边界价`。 | +| **止盈价** | 用户填写的 **固定价格**;**不由交易所条件止盈单触发**,由应用后台 **按标记价/行情价轮询**,达到后 **市价全平**。 | +| **杠杆** | 计划内固定写入;用于 `set_leverage` 与名义换算。 | +| **补仓档位数** | 默认 **5** 档(环境变量 `TREND_PULLBACK_DCA_LEGS` 可调);程序在满足最小张数前提下可能 **自动减少档数**。 | + +--- + +## 3. 执行流程(时间顺序) + +### 3.0 列表时间窗(交易记录 / 计划历史) + +- **交易记录**、**计划历史**(含预览快照)列表与 **交易记录 CSV 导出** 支持 **UTC** 时间筛选(默认 UTC 当日;可选近 24h、近 7d、自定义起止)。 +- 查询参数:`win_preset`(`utc_today` / `utc_last24h` / `utc_last7d` / `custom`)、自定义时另传 `from_utc`、`to_utc`。 +- **统计分析**页仍按北京时间 `TRADING_DAY_RESET_HOUR` 切日,不受列表窗影响。 + +### 3.1 预览阶段(不下单) + +1. **风控**:与「机器人下单监控」**互斥**——存在活跃机器人持仓或运行中趋势计划时,不可生成预览。 +2. **读取可用余额快照** `get_available_trading_usdt()`,失败则拒绝。 +3. **计算**(写入表 `trend_pullback_previews`,并跳转带 `preview_id`): + - 在 **补仓区间边界 ↔ 止损** 区间内生成 `N` 个补仓触发价(做多从上沿向止损、做空从下沿向止损); + - 将 **剩余 50% 计划张数** 拆成 `N` 份写入 `leg_amounts_json`。 +4. **预览有效期**:默认 **120 秒**(`TREND_PULLBACK_PREVIEW_TTL_SECONDS`),超时须重新点「生成预览」。 + +### 3.2 确认执行(实盘) + +5. 再次校验:预览未过期;**当前可用余额**与预览快照相对偏差 ≤ `TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT`(默认 **5%**),否则拒绝执行并要求重新预览。 +6. **首仓**:**立即市价** 开立 **总计划张数 × 50%**(不附带交易所止盈单)。 +7. **止损**:撤销旧条件单后,挂 **仅止损** 的仓位触发单;之后每次补仓成交会 **刷新** 止损挂单。 +7b. **手动保本**(可选):首仓完成且交易所有持仓后,可在运行中计划卡片点击「应用保本止损」——将止损移至 **持仓均价 ± 偏移%**(默认 **+0.3%** 多 / **−0.3%** 空);仅当新止损 **优于** 当前止损时生效,并同步 Gate 仓位止损单。 +8. **补仓**:当价格 **穿越** 下一档触发价(做多为自上向下穿越,做空为自下向上穿越)时,按该档张数 **市价加仓**;直至 `N` 档执行完毕或计划结束。 +9. **止盈监控**:后台线程若发现价格触及止盈,则 **市价全平**。 +10. **止损触发**:若仓位被交易所止损打光,本地检测到 **持仓为 0** 后记账为 **止损** 并结束计划。 +11. **计划结束**:任一结束路径(止盈 / 止损 / 用户手动结束)均会 **撤单**(条件单 + 普通挂单,尽力而为)。 + +### 3.3 取消预览 + +用户可「取消预览」删除 `trend_pullback_previews` 中对应记录;过期记录会在新预览或页面加载时清理。 + +### 3.4 界面:计划历史与运行中浮动盈亏 + +- **计划历史(页顶卡片)** + - 仅展示 **`trend_pullback_plans` 中已结束的计划**(`status != 'active'`,如止盈结束、止损结束、手动结束)。 + - **不包含**仅存在于 `trend_pullback_previews`、从未「确认执行」的预览。 + - 每行提供 **删除**:删除该计划行,并删除 `trade_records` 中 **`trend_plan_id` 与之相同** 且类型为「趋势回调」的记录(用于与计划一一对应的新数据;历史旧行若无 `trend_plan_id` 则不会随删)。 +- **运行中的计划(交易执行页)** + - 在计划摘要下方展示 **浮盈亏(交易所)**:来自 Gate 当前持仓接口的 **未实现盈亏**(及标记价,若可得);与本地按均价估算可能略有差异,以交易所为准便于对照。 + - **补仓边界**按方向显示「补仓上沿」或「补仓下沿」(数值仍为 `add_upper` 字段)。 + - **手动保本**:表单可改偏移 %(默认见 `TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT`);成功后显示「已保本」时间与原止损(若与当前不同)。 + +### 3.5 交易记录与交易所「已实现盈亏」对齐 + +- 平仓时仍会写入一条 **`trade_records`**(`monitor_type=趋势回调`),其中的 **`pnl_amount` 等为本地估算**(`calc_pnl`,不含手续费、资金费等完整账单口径)。 +- 打开 **「交易执行」或「交易记录」** 页面时,若已配置 **`GATE_API_KEY` / `GATE_API_SECRET`**(不要求 `LIVE_TRADING_ENABLED=true`,只读即可),应用会按节流策略(同进程约 **25 秒**内最多一次)调用 Gate **`fetch_positions_history`(平仓历史)**,为尚未写入 `exchange_sync_key` 的趋势回调记录 **匹配一条平仓记录**,并回填: + - **`exchange_realized_pnl`**:交易所口径已实现盈亏(与 App「历史仓位」更接近); + - **`exchange_opened_at` / `exchange_closed_at`**:换算为应用时区(默认北京)下的开、平时间字符串。 +- **交易记录表**展示列「开仓(展示) / 平仓(展示) / 盈亏U(展示)」:对「趋势回调」行,若已同步则优先显示交易所字段(界面小字 **「所」**);未同步前仍显示本地复盘字段(小字 **「估」**)。 +- 匹配规则概要:同品种、同方向、平仓时间与本地 `closed_at` 接近,并结合 **`trend_plan_id`** 对应计划的 `opened_at` 收窄时间窗;极端情况下若短时间多笔同向同品种,仍存在错配可能,可对照 `exchange_sync_key` 与交易所记录。 + +--- + +## 4. 与「机器人下单监控」的差异 + +| 项目 | 机器人下单监控 | 趋势回调 | +|------|------------------|----------| +| 开仓 | 单次市价 + 条件止盈+止损 | 首仓 50% 市价 + 多档补仓 + **仅止损在交易所** | +| 止盈 | 条件单 + 本地监控 | **仅本地监控市价止盈** | +| 仓位基数 | 以损定仓(表单/会话基数) | **可用余额快照 × 风险比例** 推导 | +| 移动保本 | 支持(按 R 自动上移) | **手动保本**(首仓后有持仓即可点;默认均价 +0.3%,可改偏移;同步 Gate 止损单;**无**自动 R 保本) | + +--- + +## 5. 风险声明(必读) + +- 市价单存在 **滑点**;极端行情下实际亏损可能 **大于** 理论 5%。 +- 补仓触发依赖应用 **轮询间隔**(`MONITOR_POLL_SECONDS`),非毫秒级高频。 +- 交易所 **最小张数 / 精度** 可能导致计划张数被截断,实际风险略低于或偏离纸面计算。 +- 请使用 **单独 API Key / 子账户**,并先在 `LIVE_TRADING_ENABLED=false` 环境验证流程(若需沙盒请自行对接测试网,本仓库默认实盘接口)。 + +--- + +## 6. 相关环境变量 + +| 变量 | 说明 | 默认 | +|------|------|------| +| `TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT` | 手动保本默认偏移(相对持仓均价,%) | `0.3` | +| `TREND_PULLBACK_DCA_LEGS` | 剩余 50% 拆档数量上限 | `5` | +| `TREND_PULLBACK_PREVIEW_TTL_SECONDS` | 预览有效时间(秒) | `120` | +| `TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT` | 确认执行时允许「当前可用 / 预览快照」最大相对偏差(%) | `5` | +| `MONITOR_POLL_SECONDS` | 监控轮询间隔(秒) | `3` | +| `LIVE_TRADING_ENABLED` | 是否允许真实下单 | `false` | +| `FULL_MARGIN_BUFFER_RATIO` | 计划保证金相对可用余额上限比例 | `0.98` | +| `APP_TIMEZONE` | 应用墙钟与「北京日期」同步起点时区(如 `Asia/Shanghai`) | `Asia/Shanghai` | +| `EXCHANGE_POSITION_SYNC_FROM_BJ` | 拉取 Gate **平仓历史** 的最早日期(`YYYY-MM-DD`,按 `APP_TIMEZONE` 当日 **00:00** 起算)。**留空**则从近 **90 天** 起拉取 | 空 | +| `EXCHANGE_POSITION_HISTORY_LIMIT` | 单次拉取平仓历史条数上限(50–1000) | `200` | + +--- + +## 7. 数据库 + +- **`trend_pullback_previews`**:未执行的预览行(含 `expires_at_ms`),执行成功或取消后删除;过期可被清理。 +- **`trend_pullback_plans`**:趋势回调计划。执行后写入一行,`status='active'` 表示运行中;止盈 / 止损 / 手动结束后变为 **`stopped_tp` / `stopped_sl` / `stopped_manual`** 等非 `active` 状态,并出现在页顶 **计划历史**。字段含快照可用余额、计划保证金、总张数、首仓张数、补仓 JSON、网格价 JSON、已补仓档数、均价、`opened_at`、`message`(结束说明)等;**`add_upper`** 存补仓区间远侧边界价(做多=上沿、做空=下沿)。 +- **`trade_records`**(`monitor_type=趋势回调`):每次计划结束插入一行;含本地估算盈亏等。新写入行带 **`trend_plan_id`** 指向 `trend_pullback_plans.id`。另含 **`exchange_realized_pnl`、`exchange_opened_at`、`exchange_closed_at`、`exchange_sync_key`**,由页面触发的交易所平仓历史同步填充(见 3.5)。 + +**CSV 导出**:交易记录导出为 **v3**,包含上述交易所对齐字段及 `trend_plan_id`。 diff --git a/crypto_monitor_gate_bot/部署文档.md b/crypto_monitor_gate_bot/部署文档.md new file mode 100644 index 0000000..162292d --- /dev/null +++ b/crypto_monitor_gate_bot/部署文档.md @@ -0,0 +1,333 @@ +# `crypto_monitor_gate` 部署指南:SSH SOCKS + Gate.io + PM2(Ubuntu) + +本文面向:**在本机运行本项目**,但 **直连 Gate.io API 不稳定或被重置** 的场景。思路是: + +- 本机用 `ssh -D` 做动态转发,把 **SOCKS5 出口**放到能正常访问 Gate 的机器(常见为一台境外 VPS) +- 项目在 `.env` 中设置 **`GATE_SOCKS_PROXY=socks5h://127.0.0.1:1080`**(或你实际端口),`ccxt` 经 SOCKS 访问交易所 +- **SSH 隧道**:用 `ssh -D` 在本机常驻即可(screen / tmux / systemd 等),**不必交给 PM2** +- 使用 **PM2** 仅托管 **Flask 应用**;仓库根目录 **`ecosystem.config.cjs`** 只定义 `crypto-monitor-gate` + +> 安全提醒:不要把 `.env`、私钥 `.pem`、Gate API Key 提交到 Git;下文只用占位符。 + +--- + +## 0. 你需要准备的东西 + +- 一台 **Ubuntu**(或同类 Linux)运行项目的机器(下文称「本机」) +- 一台可 SSH 登录、且 **能正常访问 Gate.io API** 的 VPS(示例:`HostName` 填你的服务器 IP,用户如 `root`) +- SSH:**私钥登录**(推荐,便于隧道脚本无人值守) +- 本机已安装:`python3`、`python3-venv`、`pip`、`curl`、`ssh`、`git`(可选)、`node` + `npm`(安装 PM2) + +--- + +## 1. 获取代码与目录 + +将包含 `app.py` 的项目放到固定目录,例如: + +```bash +mkdir -p /opt/crypto_monitor +cd /opt/crypto_monitor +git clone https://git.bz121.com/dekun/crypto_monitor.git +cd crypto_monitor/crypto_monitor_gate_bot +``` + +下文用 **`/opt/crypto_monitor/crypto_monitor_gate_bot`** 仅为示例,请换成你的实际绝对路径。 + +拉取代码后,若目录下尚无 `.env`: + +```bash +cp -n .env.example .env +``` + +--- + +## 2. 配置 SSH 私钥与 `~/.ssh/config` + +```bash +mkdir -p ~/.ssh +chmod 700 ~/.ssh +# 私钥示例:~/.ssh/vps1.pem +chmod 600 ~/.ssh/vps1.pem +``` + +编辑 `~/.ssh/config`(示例别名 **`gate-vps`**,与你手工启动 `ssh -D ... gate-vps` 一致即可): + +```sshconfig +Host gate-vps + HostName 你的_VPS_IP + User root + IdentityFile ~/.ssh/vps1.pem + IdentitiesOnly yes + ServerAliveInterval 30 + ServerAliveCountMax 3 + ExitOnForwardFailure yes + BatchMode yes +``` + +测试: + +```bash +ssh gate-vps true +``` + +> 若尚未完全改为密钥登录,可暂时注释 `BatchMode yes`,调试完成后再打开。 + +--- + +## 3. 手工验证:SSH SOCKS + Gate API + +### 3.1 本地 SOCKS(示例端口 1080) + +```bash +ssh -N -D 127.0.0.1:1080 gate-vps +``` + +保持运行,另开终端继续。 + +### 3.2 验证经 SOCKS 可访问 Gate + +```bash +curl -4 -sS --max-time 15 --proxy socks5h://127.0.0.1:1080 https://api.gateio.ws/api/v4/spot/time +``` + +应返回 JSON(含服务器时间字段)。若此处失败,**不要先启动应用**:先修隧道或 VPS 出站。 + +--- + +## 4. Python 虚拟环境 + +```bash +cd /opt/crypto_monitor/crypto_monitor_gate_bot + +python3 -m venv .venv +source .venv/bin/activate +python -m pip install -U pip +pip install flask requests ccxt werkzeug PySocks Pillow +``` + +走 SOCKS 时 **必须** 安装 **`PySocks`**,否则易出现代理相关报错。 + +可选: + +```bash +export PYTHONDONTWRITEBYTECODE=1 +``` + +--- + +## 5. 配置环境变量(`.env.example` → `.env`) + +| 文件 | 是否进 Git | 说明 | +|------|------------|------| +| **`.env.example`** | ✅ 是 | 变量模板与注释,可随 `git pull` 更新 | +| **`.env`** | ❌ 否 | 本机真实配置;`app.py` **只读此文件** | + +### 5.1 首次配置 + +```bash +cd /opt/crypto_monitor/crypto_monitor_gate_bot + +cp -n .env.example .env +nano .env +``` + +### 5.2 备份与 `git pull` + +- **`.env` 不在 Git 中**:`git pull` **不会**覆盖本地 `.env`。 +- 远端若更新 **`.env.example`**,pull 后请**手动**把新增变量补进你的 `.env`。 +- **升级前备份**:`cp .env .env.backup.$(date +%Y%m%d)`;恢复:`cp .env.backup.YYYYMMDD .env`。 +- **换机**:`scp` 复制 `.env`,或新机 `cp .env.example .env` 后重填。 + +### 5.3 自动备份(数据库 + 复盘图片) + +每天 **北京时间 0:00** 备份到 **`/root/backups`**,保留 **30 天**(`crypto.db` + `static/images`)。 + +```bash +cd /opt/crypto_monitor/crypto_monitor_gate_bot +chmod +x scripts/backup_data.sh scripts/install_backup_cron.sh +bash scripts/install_backup_cron.sh +bash scripts/backup_data.sh # 试跑 +``` + +备份目录:`/root/backups/crypto_monitor_gate_bot/YYYY-MM-DD/`。与 Binance / Gate 实例规则相同,详见 `crypto_monitor_binance/部署文档.md` 第 5.3 节(恢复步骤、可选 `.env` 变量)。 + +若服务器同时跑 **binance、gate、gate_bot** 三个实例,请在**各自项目目录**各执行一次 `install_backup_cron.sh`。 + +### 5.4 必填项检查(Gate + 代理) + +与交易所相关的变量必须是 **Gate** 前缀(**不要**再写 OKX 变量,否则代理不会生效、密钥也不会被识别)。至少确认: + +```env +APP_HOST=127.0.0.1 +APP_PORT=5000 + +# 实盘(按需) +LIVE_TRADING_ENABLED=false +GATE_API_KEY=你的_Key +GATE_API_SECRET=你的_Secret + +# 经本机 SSH 动态转发访问 Gate(端口与隧道一致) +GATE_SOCKS_PROXY=socks5h://127.0.0.1:1080 + +# 若不用 SOCKS,可改用 HTTP 代理(一般二选一) +# GATE_HTTP_PROXY=http://127.0.0.1:7890 +# GATE_HTTPS_PROXY=http://127.0.0.1:7890 +``` + +说明:**推荐 `socks5h://`**,由 SOCKS 端解析域名,与 `curl --proxy socks5h://...` 行为一致。 + +### 5.4 趋势回调策略(可选) + +若使用「交易执行」页的 **趋势回调** 计划: + +- 详细规则见项目根目录 **`趋势回调策略说明.md`**。 +- **两阶段**:先「生成预览」(默认 **120 秒**内有效),再「确认执行」;执行时若可用余额与预览快照偏差超过 **5%** 会拒绝(可调 `.env`)。 +- 补仓档位数默认 **5**,预览有效期与余额偏差阈值可在 `.env` 覆盖: + +```env +TREND_PULLBACK_DCA_LEGS=5 +TREND_PULLBACK_PREVIEW_TTL_SECONDS=120 +TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT=5 +``` + +- **生成预览**与**确认执行**时都会读取 **Gate 永续账户 USDT 可用余额**;请尽量使用 **单独子账户** 承载策略资金。 + +**界面与对账(与策略说明 3.4–3.5 节一致)** + +- 页顶 **计划历史**:仅 **已结束** 的趋势计划(不含未执行预览);可 **删除** 计划行,并删除 `trend_plan_id` 关联的「趋势回调」`trade_records`(新数据;旧行无 `trend_plan_id` 不级联)。 +- **运行中计划**展示交易所 **未实现盈亏**(浮盈亏)。 +- **交易记录**:趋势单在配置 API Key 后,打开「交易执行 / 交易记录」页会按节流(约 **25 秒**内同进程最多一次)拉取 Gate **平仓历史**,回填 **`exchange_realized_pnl`** 等;列表展示优先用交易所口径(见策略说明)。 + +**与交易所对齐的可选环境变量** + +```env +# 平仓历史同步起点:北京日期 YYYY-MM-DD 的 0 点(与 APP_TIMEZONE 一致);留空则从近 90 天拉取 +# EXCHANGE_POSITION_SYNC_FROM_BJ=2026-05-14 +# EXCHANGE_POSITION_HISTORY_LIMIT=200 +``` + +说明:同步 **只读** 交易所接口,**不要求** `LIVE_TRADING_ENABLED=true`;无 Key 时不拉取,界面仍可用(浮盈亏可能为「—」、交易记录仍为本地「估」)。 + +**交易记录 CSV**:导出为 **v3**,含 `trend_plan_id` 与交易所对齐列(详见策略说明数据库一节)。 + +--- + +## 6. 手工启动 Flask(验证) + +1. SOCKS 已监听 `127.0.0.1:1080` +2. 已 `source .venv/bin/activate` +3. `.env` 已含 `GATE_SOCKS_PROXY` + +```bash +cd /opt/crypto_monitor/crypto_monitor_gate_bot +source .venv/bin/activate +python app.py +``` + +浏览器访问:`http://127.0.0.1:5000`(或你在 `.env` 中的端口)。 + +--- + +## 7. 安装 PM2 + +```bash +sudo npm i -g pm2 +pm2 -v +``` + +--- + +## 8. PM2:使用仓库内 `ecosystem.config.cjs`(推荐) + +在项目根目录: + +```bash +cd /opt/crypto_monitor/crypto_monitor_gate_bot +pm2 start ecosystem.config.cjs +pm2 status +pm2 logs --lines 200 +``` + +默认只启动 **`crypto-monitor-gate`**(`.venv/bin/python app.py`)。 + +### 本机已可直连 Gate、不需要隧道时 + +`.env` 里应 **去掉或留空** `GATE_SOCKS_PROXY`(除非仍要走别的代理),再 `pm2 start ecosystem.config.cjs`。 + +### 开机自启 + +```bash +pm2 save +pm2 startup +# 按屏幕提示执行一条 sudo 命令 +``` + +--- + +## 9. 等价手工命令(不使用 ecosystem 文件时) + +### 9.1 SSH SOCKS(自行后台常驻,不推荐用 PM2) + +示例(前台;实际可用 `screen`/`tmux`/`-f` 后台化或 systemd): + +```bash +ssh -N -D 127.0.0.1:1080 gate-vps \ + -o ServerAliveInterval=30 -o ServerAliveCountMax=3 \ + -o ExitOnForwardFailure=yes +``` + +### 9.2 Flask + +```bash +cd /opt/crypto_monitor/crypto_monitor_gate_bot +pm2 start /opt/crypto_monitor/crypto_monitor_gate_bot/.venv/bin/python --name crypto-monitor-gate -- \ + /opt/crypto_monitor/crypto_monitor_gate_bot/app.py +``` + +--- + +## 10. 交易所「连接不上」排查清单 + +1. **`.env` 是否为 Gate 变量**:必须是 `GATE_SOCKS_PROXY` / `GATE_API_KEY` / `GATE_API_SECRET`,不是 OKX。 +2. **隧道是否在本机端口监听**(若配置了 `GATE_SOCKS_PROXY`): + ```bash + ss -lntp | grep 1080 || true + ``` +3. **curl 复测 Gate**(与第 3.2 节相同);curl 不通则应用也不会通。 +4. **PySocks**:`pip show PySocks`,缺失则 `pip install PySocks`。 +5. **SSH 隧道连不上**:检查私钥权限、`~/.ssh/config`、VPS 出站与端口是否与 `.env` 一致。 +6. **启动顺序**:先保证 SOCKS 已监听,再 `pm2 start` 应用(或重启应用)。 + +--- + +## 11. 推荐启动顺序(习惯) + +1. 若走代理:先启动并确认 SSH SOCKS 已监听,再 `curl --proxy socks5h://127.0.0.1:1080 https://api.gateio.ws/api/v4/spot/time` 成功 +2. `pm2 start ecosystem.config.cjs` +3. 再确认页面与余额等接口正常 + +--- + +## 12. 免责声明 + +交易所有合规与地区政策要求。请确保使用方式符合当地法律法规与交易所条款。本文仅描述网络与工程部署路径。 + +--- + +## 附录:数据库标签修复脚本 `scripts/fix_breakeven_labels.py` + +在 Ubuntu 上: + +1)预览(不写库): + +```bash +python scripts/fix_breakeven_labels.py --db ./crypto.db --dry-run +``` + +2)确认后执行: + +```bash +python scripts/fix_breakeven_labels.py --db ./crypto.db --apply +``` + +默认修复条件:`monitor_type='下单监控'` 且 `result='止损'` 且 `pnl_amount > 0` → 改为 `result='保本止盈'`。 diff --git a/crypto_monitor_okx/.env.example b/crypto_monitor_okx/.env.example new file mode 100644 index 0000000..e3885a6 --- /dev/null +++ b/crypto_monitor_okx/.env.example @@ -0,0 +1,142 @@ +# ============================================================================= +# 环境配置模板(可提交 Git)。程序运行时只读取同目录下的 .env。 +# +# 首次部署 / 新机: +# cp .env.example .env +# nano .env # 填入真实密钥、端口、代理等 +# +# 升级代码(git pull)前建议备份(.env 不在 Git 中,pull 不会覆盖): +# cp .env .env.backup.$(date +%Y%m%d) +# +# 从备份恢复: +# cp .env.backup.YYYYMMDD .env +# ============================================================================= + +APP_ENV=production +# 服务监听地址(云服务器通常用 0.0.0.0) +APP_HOST=0.0.0.0 +# 服务端口 +APP_PORT=5004 +# 是否开启调试模式(生产建议 false) +APP_DEBUG=false + +# 登录账号 +APP_USERNAME=dekun +# 登录密码(请改成你自己的强密码) +APP_PASSWORD=ChangeMe123! +# 是否关闭登录校验(局域网可设 true;公网务必 false) +APP_AUTH_DISABLED=true +# Flask 会话密钥(必须替换为长随机字符串) +FLASK_SECRET_KEY=CHANGE_TO_LONG_RANDOM_SECRET + +# ---------- 整机许可(也可写在仓库根目录 .env 的 LICENSE_* 变量)---------- +LICENSE_API_URL=https://license.example.com +LICENSE_CLIENT_KEY=REPLACE_WITH_CLIENT_KEY +LICENSE_CHECK_INTERVAL_DAYS=3 +LICENSE_OFFLINE_GRACE_DAYS=7 +LICENSE_WECHAT_ID=dekun03 +# LICENSE_DISABLED=false + +# 企业微信机器人 Webhook(用于行情/风控推送) +WECHAT_WEBHOOK=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=REPLACE_WITH_REAL_KEY + +# 数据库文件路径(相对路径会自动按项目目录解析) +DB_PATH=crypto.db +# 交易截图上传目录 +UPLOAD_DIR=static/images + +# 训练总资金(U) +TOTAL_CAPITAL=100 +# 每天起始基数(U) +DAILY_START_CAPITAL=30 +# 日内回撤后基数(U) +DAILY_LOSS_CAPITAL=20 +# 日内盈利后基数(U) +DAILY_PROFIT_CAPITAL=50 +# BTC 默认杠杆倍数 +BTC_LEVERAGE=10 +# 山寨币默认杠杆倍数 +ALT_LEVERAGE=5 +# 交易日重置小时(北京时间) +TRADING_DAY_RESET_HOUR=8 + +# 是否开启 OKX 实盘下单(false=只做本地流程,true=真实下单) +LIVE_TRADING_ENABLED=true +# OKX API Key(实盘) +OKX_API_KEY=REPLACE_WITH_OKX_API_KEY +# OKX API Secret(实盘) +OKX_API_SECRET=REPLACE_WITH_OKX_API_SECRET +# OKX API Passphrase(实盘) +OKX_API_PASSPHRASE=REPLACE_WITH_OKX_API_PASSPHRASE +# 保证金模式:cross=全仓,isolated=逐仓 +OKX_TD_MODE=cross +# 持仓模式:hedge=双向持仓,net=单向净持仓 +OKX_POS_MODE=hedge +# 仓位查询 instType(OKX) +OKX_POSITION_INST_TYPE=SWAP + +# 关键位监控:5m收线突破过滤参数 +KLINE_TIMEFRAME=5m +KEY_BREAKOUT_LIMIT_PCT=1.5 +KEY_ALERT_MAX_TIMES=3 +KEY_ALERT_INTERVAL_MINUTES=5 + +# 资金与仓位刷新周期(秒) +BALANCE_REFRESH_SECONDS=60 +# 后台监控轮询周期(秒) +MONITOR_POLL_SECONDS=3 +# 使用可用资金时的缓冲比例(如0.98代表用98%) +FULL_MARGIN_BUFFER_RATIO=0.98 + +# 自动划转:将目标账户补足到 AUTO_TRANSFER_AMOUNT +AUTO_TRANSFER_ENABLED=false +AUTO_TRANSFER_AMOUNT=30 +AUTO_TRANSFER_FROM=funding +AUTO_TRANSFER_TO=swap +TRANSFER_CCY=USDT +# 强制清仓整点(北京时间,默认 0=凌晨00点) +FORCE_CLOSE_BJ_HOUR=0 +# 是否启用强制清仓(默认关闭,true 才会在整点执行) +FORCE_CLOSE_ENABLED=false + +# 推送与AI超时(秒) +WECHAT_TIMEOUT_SECONDS=10 +AI_TIMEOUT_SECONDS=120 + +# AI 复盘服务地址(本机 Ollama 默认地址) +OLLAMA_API=http://127.0.0.1:11434/api/generate +# AI 模型名称 +AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest + +# OKX 代理(可选):用于本机网络对 OKX TLS/SNI 不稳定时,通过 SSH 动态转发 SOCKS5 出口 +# 1) 先在本机建立隧道(示例): +# ssh -N -D 127.0.0.1:1080 root@你的VPS_IP -o ServerAliveInterval=30 -o ExitOnForwardFailure=yes +# 2) 再启用下面这一行(推荐 socks5h,让远端解析域名): +# OKX_SOCKS_PROXY=socks5h://127.0.0.1:1080 +# +# 如你更偏向 HTTP 代理(VPS 上跑 tinyproxy 之类),可用: +# OKX_HTTP_PROXY=http://127.0.0.1:3128 +# OKX_HTTPS_PROXY=http://127.0.0.1:3128 + +# 开仓多周期K线图(可选) +# ORDER_CHART_ENABLED=true +# ORDER_CHART_TFS=4h,1h,15m,5m +# ORDER_CHART_LIMIT=100 +# ORDER_CHART_DIR=static/images/order_charts +# DAILY_OPEN_ALERT_THRESHOLD=5 +# 关键位:标准方案止损外侧%、趋势单方案止损外侧%(默认 0.5 / 1) +# KEY_STOP_OUTSIDE_BREAKOUT_PCT=0.5 +# KEY_TREND_STOP_OUTSIDE_PCT=1 +# 以损定仓(按交易账户资金的百分比) +# RISK_PERCENT=2 +# 移动保本触发(达到多少R触发)与偏移(百分比) +# BREAKEVEN_RR_TRIGGER=1.0 +# 移动保本阶梯(每多少R继续上移一次,默认1R) +# BREAKEVEN_STEP_R=1.0 +# BREAKEVEN_OFFSET_PCT=0.02 +# 开单风格默认值:trend / swing +# DEFAULT_TRADE_STYLE=trend + +APP_TIMEZONE=Asia/Shanghai +AUTO_TRANSFER_BJ_HOUR=8 +# TRADING_DAY_RESET_HOUR 现在表示「北京时间」整点,默认 8 点起算新交易日/可开仓等 diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py new file mode 100644 index 0000000..2d8b97f --- /dev/null +++ b/crypto_monitor_okx/app.py @@ -0,0 +1,5916 @@ +from flask import Flask, render_template, request, redirect, url_for, flash, session, jsonify, Response +import sqlite3 +import csv +from io import StringIO +import time +import threading +import requests +import os +import re +import base64 +import json +import math +from datetime import datetime, timedelta, timezone + +try: + from zoneinfo import ZoneInfo +except ImportError: + ZoneInfo = None # type: ignore +from functools import wraps +import uuid +import ccxt +from werkzeug.utils import secure_filename + +try: + from PIL import Image, ImageDraw, ImageFont +except ImportError: + Image = None # type: ignore + ImageDraw = None # type: ignore + ImageFont = None # type: ignore + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +_REPO_ROOT = os.path.dirname(BASE_DIR) +import sys + +if _REPO_ROOT not in sys.path: + sys.path.insert(0, _REPO_ROOT) +from fib_key_monitor_lib import ( + FIB_KEY_MONITOR_TYPES, + calc_fib_plan, + entry_reason_from_key_signal, + fib_invalidate_by_mark, + fib_ratio_from_type, + is_fib_key_monitor_type, + key_signal_type_for_trade_record, + stored_key_signal_type, +) +from key_sl_tp_lib import ( + breakeven_enabled_from_row, + normalize_sl_tp_mode, + parse_breakeven_enabled_form, + plan_key_sl_tp, + sl_tp_mode_from_row, + sl_tp_mode_label, + sl_tp_plan_summary_text, +) +from history_window_lib import ( + PRESET_CUSTOM, + PRESET_UTC_LAST24H, + PRESET_UTC_LAST7D, + PRESET_UTC_TODAY, + list_window_redirect_query, + resolve_list_window, + resolve_window, + utc_window_to_bj_sql_strings, +) +import license_lib + + +def load_env_file(path): + if not os.path.exists(path): + return + raw_bytes = open(path, "rb").read() + text = "" + for enc in ("utf-8-sig", "utf-16", "utf-16-le", "utf-16-be"): + try: + text = raw_bytes.decode(enc) + break + except Exception: + continue + if not text: + text = raw_bytes.decode("utf-8", errors="ignore") + text = text.replace("\x00", "") + for line in text.splitlines(): + raw = line.strip() + if not raw or raw.startswith("#") or "=" not in raw: + continue + key, value = raw.split("=", 1) + clean_key = key.strip().lstrip("\ufeff") + if not clean_key.replace("_", "").isalnum(): + continue + clean_value = value.strip().strip('"').strip("'") + os.environ[clean_key] = clean_value + +load_env_file(os.path.join(BASE_DIR, ".env")) + + +def resolve_path(path_value): + if os.path.isabs(path_value): + return path_value + return os.path.join(BASE_DIR, path_value) + +app = Flask(__name__) +app.secret_key = os.getenv("FLASK_SECRET_KEY", "crypto_monitor_2026_secret_key") + +# ====================== 登录配置 ====================== +USERNAME = os.getenv("APP_USERNAME", "dekun") +PASSWORD = os.getenv("APP_PASSWORD", "Woaini88@") +AUTH_DISABLED = os.getenv("APP_AUTH_DISABLED", "false").lower() in ("1", "true", "yes", "on") + +# 企业微信机器人Webhook +WECHAT_WEBHOOK = os.getenv("WECHAT_WEBHOOK", "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=replace-me") +SYSTEM_TYPE = "CRYPTO" +HOST = os.getenv("APP_HOST", "0.0.0.0") +PORT = int(os.getenv("APP_PORT", "5000")) +DEBUG = os.getenv("APP_DEBUG", "false").lower() == "true" +DB_PATH = resolve_path(os.getenv("DB_PATH", "crypto.db")) + +# 训练参数(可由 .env 覆盖) +TOTAL_CAPITAL = float(os.getenv("TOTAL_CAPITAL", "100")) +DAILY_START_CAPITAL = float(os.getenv("DAILY_START_CAPITAL", "30")) +DAILY_LOSS_CAPITAL = float(os.getenv("DAILY_LOSS_CAPITAL", "20")) +DAILY_PROFIT_CAPITAL = float(os.getenv("DAILY_PROFIT_CAPITAL", "50")) +BTC_LEVERAGE = int(os.getenv("BTC_LEVERAGE", "10")) +ALT_LEVERAGE = int(os.getenv("ALT_LEVERAGE", "5")) +# 交易日滚动与「可开仓」整点:按应用本地时区 wall clock(默认北京时间 UTC+8) +TRADING_DAY_RESET_HOUR = int(os.getenv("TRADING_DAY_RESET_HOUR", "8")) +APP_TIMEZONE = os.getenv("APP_TIMEZONE", "Asia/Shanghai") + + +def _resolve_app_tz(): + if ZoneInfo is not None: + try: + return ZoneInfo((APP_TIMEZONE or "Asia/Shanghai").strip()) + except Exception: + pass + return timezone(timedelta(hours=8)) + + +APP_TZ = _resolve_app_tz() +LIVE_TRADING_ENABLED = os.getenv("LIVE_TRADING_ENABLED", "false").lower() == "true" +OKX_API_KEY = os.getenv("OKX_API_KEY", "") +OKX_API_SECRET = os.getenv("OKX_API_SECRET", "") +OKX_API_PASSPHRASE = os.getenv("OKX_API_PASSPHRASE", "") +OKX_TD_MODE = os.getenv("OKX_TD_MODE", "cross") +OKX_POS_MODE = os.getenv("OKX_POS_MODE", "hedge") +BALANCE_REFRESH_SECONDS = int(os.getenv("BALANCE_REFRESH_SECONDS", "60")) +PRICE_REFRESH_SECONDS = int(os.getenv("PRICE_REFRESH_SECONDS", "5")) +KEY_ALERT_MAX_TIMES = int(os.getenv("KEY_ALERT_MAX_TIMES", "3")) +KEY_ALERT_INTERVAL_MINUTES = int(os.getenv("KEY_ALERT_INTERVAL_MINUTES", "5")) +KEY_BREAKOUT_LIMIT_PCT = float(os.getenv("KEY_BREAKOUT_LIMIT_PCT", "1.5")) +AUTO_TRANSFER_ENABLED = os.getenv("AUTO_TRANSFER_ENABLED", "false").lower() == "true" +AUTO_TRANSFER_AMOUNT = float(os.getenv("AUTO_TRANSFER_AMOUNT", "30")) +AUTO_TRANSFER_FROM = os.getenv("AUTO_TRANSFER_FROM", "funding") +AUTO_TRANSFER_TO = os.getenv("AUTO_TRANSFER_TO", "swap") +FORCE_CLOSE_ENABLED = os.getenv("FORCE_CLOSE_ENABLED", "false").lower() == "true" +FORCE_CLOSE_BJ_HOUR = int(os.getenv("FORCE_CLOSE_BJ_HOUR", "0")) +# 自动划转:仅在北京时间该整点「小时」内尝试;transfer_logs.transfer_day 存 UTC 自然日(与 OKX 日界一致便于对账) +AUTO_TRANSFER_BJ_HOUR = int(os.getenv("AUTO_TRANSFER_BJ_HOUR", "8")) +WECHAT_TIMEOUT_SECONDS = int(os.getenv("WECHAT_TIMEOUT_SECONDS", "10")) +AI_TIMEOUT_SECONDS = int(os.getenv("AI_TIMEOUT_SECONDS", "120")) +MONITOR_POLL_SECONDS = int(os.getenv("MONITOR_POLL_SECONDS", "3")) +KLINE_TIMEFRAME = os.getenv("KLINE_TIMEFRAME", "5m") +FULL_MARGIN_BUFFER_RATIO = float(os.getenv("FULL_MARGIN_BUFFER_RATIO", "0.98")) +TRANSFER_CCY = os.getenv("TRANSFER_CCY", "USDT") +OKX_POSITION_INST_TYPE = os.getenv("OKX_POSITION_INST_TYPE", "SWAP") +UPLOAD_FOLDER = resolve_path(os.getenv("UPLOAD_DIR", "static/images")) +ORDER_CHART_ENABLED = os.getenv("ORDER_CHART_ENABLED", "true").lower() == "true" +ORDER_CHART_TFS = [x.strip() for x in (os.getenv("ORDER_CHART_TFS", "4h,1h,15m,5m") or "").split(",") if x.strip()] +ORDER_CHART_LIMIT = int(os.getenv("ORDER_CHART_LIMIT", "100")) +ORDER_CHART_DIR = resolve_path(os.getenv("ORDER_CHART_DIR", "static/images/order_charts")) +DAILY_OPEN_ALERT_THRESHOLD = int(os.getenv("DAILY_OPEN_ALERT_THRESHOLD", "5")) +RISK_PERCENT = float(os.getenv("RISK_PERCENT", "2")) +BREAKEVEN_RR_TRIGGER = float(os.getenv("BREAKEVEN_RR_TRIGGER", "1.0")) +BREAKEVEN_OFFSET_PCT = float(os.getenv("BREAKEVEN_OFFSET_PCT", "0.02")) +BREAKEVEN_STEP_R = float(os.getenv("BREAKEVEN_STEP_R", "1.0")) +ORDER_MONITOR_TYPE_MANUAL = "下单监控" +ORDER_MONITOR_TYPE_KEY_AUTO = "关键位监控" +KEY_MONITOR_AUTO_TYPES = frozenset({"箱体突破", "收敛突破"}) +KEY_MONITOR_ALERT_ONLY_TYPES = frozenset({"关键阻力位", "关键支撑位"}) +KEY_AUTO_MIN_PLANNED_RR = float(os.getenv("KEY_AUTO_MIN_PLANNED_RR", "1.5")) +KEY_STOP_OUTSIDE_BREAKOUT_PCT = float(os.getenv("KEY_STOP_OUTSIDE_BREAKOUT_PCT", "0.5")) +KEY_TREND_STOP_OUTSIDE_PCT = float(os.getenv("KEY_TREND_STOP_OUTSIDE_PCT", "1")) +KEY_DAILY_VOLUME_RANK_MAX = int(os.getenv("KEY_DAILY_VOLUME_RANK_MAX", "30")) +KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT = os.getenv("KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT", "true").lower() in ( + "1", + "true", + "yes", + "on", +) +DEFAULT_TRADE_STYLE = (os.getenv("DEFAULT_TRADE_STYLE", "trend") or "trend").strip().lower() +OLLAMA_API = os.getenv("OLLAMA_API", "http://127.0.0.1:11434/api/generate") +AI_MODEL = os.getenv("AI_MODEL", "huihui_ai/deepseek-r1-abliterated:latest") + +OKX_SOCKS_PROXY = (os.getenv("OKX_SOCKS_PROXY") or "").strip() +OKX_HTTP_PROXY = (os.getenv("OKX_HTTP_PROXY") or "").strip() +OKX_HTTPS_PROXY = (os.getenv("OKX_HTTPS_PROXY") or "").strip() + +EXCHANGE_DISPLAY_NAME = (os.getenv("EXCHANGE_DISPLAY_NAME") or "OKX").strip() or "OKX" +license_lib.init_flask_app(app, exchange_display=EXCHANGE_DISPLAY_NAME) + + +def build_okx_ccxt_proxies(): + """ + 为 ccxt 配置代理(常用于:本地网络对 OKX TLS/SNI 不稳定,通过 SSH 动态转发 SOCKS5 出口)。 + + 推荐: + - 本机:ssh -N -D 127.0.0.1:1080 user@vps + - .env:OKX_SOCKS_PROXY=socks5h://127.0.0.1:1080 + + 说明: + - socks5h 让代理端解析域名(避免本机 DNS/策略差异);若你明确要本机解析可用 socks5:// + """ + socks = OKX_SOCKS_PROXY.strip() + http = OKX_HTTP_PROXY.strip() + https = OKX_HTTPS_PROXY.strip() or http + if socks: + return {"http": socks, "https": socks} + if http or https: + return {"http": http, "https": https} + return None + + +OKX_CCXT_PROXIES = build_okx_ccxt_proxies() + +os.makedirs(UPLOAD_FOLDER, exist_ok=True) +os.makedirs(ORDER_CHART_DIR, exist_ok=True) +app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER + +# 换成 OKX 永续 +exchange = ccxt.okx({ + "enableRateLimit": True, + "options": {"defaultType": "swap"}, # OKX 用 swap 表示永续 +}) +if OKX_CCXT_PROXIES: + exchange.proxies = OKX_CCXT_PROXIES +if OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE: + exchange.apiKey = OKX_API_KEY + exchange.secret = OKX_API_SECRET + exchange.password = OKX_API_PASSPHRASE +MARKETS_LOADED = False +ACCOUNT_BALANCE_CACHE = { + "updated_at": 0.0, + "funding_usdt": None, + "trading_usdt": None +} +LIQUIDITY_RANK_CACHE = { + "updated_at": 0.0, + "ranks": {}, + "total": 0, +} + +# 企业微信推送 +def send_wechat_msg(content): + prefix = "【加密货币】" + full_msg = f"{prefix}\n{content}" + data = { + "msgtype": "text", + "text": {"content": full_msg} + } + try: + requests.post(WECHAT_WEBHOOK, json=data, timeout=WECHAT_TIMEOUT_SECONDS) + except: + pass + + +def _wechat_account_label(): + return (os.getenv("OKX_ACCOUNT_LABEL") or "okx实盘子账户").strip() + + +def _wechat_direction_text(direction): + d = (direction or "").lower() + return "多头(long)" if d == "long" else "空头(short)" + + +def _wechat_trading_capital_text(fallback=None): + try: + _, trading_capital = get_exchange_capitals(force=True) + except Exception: + trading_capital = None + if trading_capital is not None: + return f"{round(float(trading_capital), 4)}U" + if fallback is not None: + try: + return f"{round(float(fallback), 4)}U" + except Exception: + pass + return "-" + + +def build_wechat_close_message( + symbol, + direction, + result, + pnl_amount, + hold_seconds=None, + trigger_price=None, + current_price=None, + stop_loss=None, + take_profit=None, + close_order_id=None, + extra_note=None, + session_capital_fallback=None, +): + hold_txt = format_hold_minutes(calc_hold_minutes(hold_seconds)) if hold_seconds is not None else "-" + lines = [ + f"# ✅ {symbol} 实盘平仓记录", + f"**账户:{_wechat_account_label()}**", + "", + "---", + "", + "### 平仓基础信息", + f"- 方向:**{_wechat_direction_text(direction)}**", + f"- 平仓结果:**{result or '-'}**", + f"- 交易所平仓ID:`{close_order_id or '-'}`", + f"- 持仓时长:`{hold_txt}`", + f"- 本单盈亏:`{round(float(pnl_amount), 4) if pnl_amount is not None else '-'}U`", + f"- 交易账户资金:`{_wechat_trading_capital_text(session_capital_fallback)}`", + "", + "---", + "", + "### 价格信息", + f"- 入场价:`{trigger_price if trigger_price is not None else '-'}`", + f"- 平仓参考价:`{current_price if current_price is not None else '-'}`", + f"- 止盈/止损:`{take_profit if take_profit is not None else '-'}` / `{stop_loss if stop_loss is not None else '-'}`", + ] + if extra_note: + lines.extend(["", f"*备注:{extra_note}*"]) + return "\n".join(lines) + + +def build_wechat_breakeven_message(symbol, direction, arm_txt, now_rr, locked_r, new_sl): + return "\n".join( + [ + f"# 🛡️ {symbol} 保护位更新", + f"**账户:{_wechat_account_label()}**", + "", + "---", + "", + "### 移动保本/止盈", + f"- 方向:**{_wechat_direction_text(direction)}**", + f"- 类型:**{arm_txt}**", + f"- 当前RR:`{round(float(now_rr), 2)}R`", + f"- 锁定RR:`{round(float(locked_r), 2)}R`", + f"- 新保护位:`{new_sl}`", + ] + ) + + +def build_wechat_monitor_error_message(symbol, direction, scene, error_text): + return "\n".join( + [ + f"# ⚠️ {symbol} 下单监控异常", + f"**账户:{_wechat_account_label()}**", + "", + "---", + "", + "### 异常信息", + f"- 方向:**{_wechat_direction_text(direction)}**", + f"- 场景:{scene}", + f"- 错误:{str(error_text)}", + ] + ) + + +def build_wechat_key_monitor_message( + symbol, + direction, + monitor_type, + trigger_time, + key_price, + confirm_close, + hard_lines, + btc8h_status, + coin4h_status, + swing4h_pct, + op_lines, + risk_tip=None, +): + lines = [ + f"# 🎯 {symbol} 关键位确认推送", + f"**账户:{_wechat_account_label()}**", + "", + "---", + "", + "### 交易对 / 触发时间", + f"- 交易对:**{symbol}**", + f"- 触发时间:`{trigger_time}`", + "", + "### 方向与确认K", + f"- 方向:**{_wechat_direction_text(direction)}**", + "- 确认K:第二根5m收盘完成", + "", + "### 关键价位", + f"- 类型:**{monitor_type}**", + f"- 箱体关键位:`{key_price}`", + f"- 第二根确认收盘价:`{confirm_close}`", + "", + "### 硬条件校验结果", + ] + lines.extend([f"- {x}" for x in hard_lines]) + lines.extend( + [ + "", + "### 市场状态说明", + f"- BTC 8h 状态:**{btc8h_status}**", + f"- 本币 4h(EMA55) 状态:**{coin4h_status}**", + f"- 4h震荡幅度(5m近48根):`{round(float(swing4h_pct), 3)}%`", + "", + "### 操作提示", + ] + ) + lines.extend([f"- {x}" for x in op_lines]) + if risk_tip: + lines.extend(["", f"### 逆势风险提醒", f"- {risk_tip}"]) + return "\n".join(lines) + + +def _read_image_base64(image_path): + try: + with open(image_path, "rb") as f: + return base64.b64encode(f.read()).decode("utf-8") + except Exception: + return None + + +def _extract_json_object(text): + if not text: + return None + clean = text.strip() + if clean.startswith("```"): + clean = clean.replace("```json", "").replace("```", "").strip() + try: + return json.loads(clean) + except Exception: + pass + match = re.search(r"\{[\s\S]*\}", clean) + if not match: + return None + try: + return json.loads(match.group(0)) + except Exception: + return None + + +def ai_review(trades_text, period_title, image_paths=None): + prompt = f""" +你是一位专业交易教练。下面是用户的{period_title}交易记录,请做专业、简洁、可执行的复盘: +1. 总体盈亏结构 +2. 心态问题与执行漏洞(请给每笔交易一个1-10的心态分并简短说明) +3. 提前离场、乱开仓、扛单等行为分析 +4. 给出具体改进建议(最多3条) +5. 若附带截图,请结合图中价格行为、结构、进出场位置一起分析(看不清时请明确说明不确定) +交易记录: +{trades_text} +用中文输出,直接给结论与建议。 +""".strip() + payload = {"model": AI_MODEL, "prompt": prompt, "stream": False, "options": {"temperature": 0.3}} + images = [] + for p in image_paths or []: + b64 = _read_image_base64(p) + if b64: + images.append(b64) + if images: + payload["images"] = images + try: + r = requests.post(OLLAMA_API, json=payload, timeout=AI_TIMEOUT_SECONDS) + return r.json().get("response", "AI 生成失败") + except Exception as e: + return f"AI 调用失败:{str(e)}" + + +def ai_short_advice(prompt_text): + prompt = f""" +你是交易风控助理。请用中文给出**最多 3 条**提醒,要求: +- 每条不超过 25 个字 +- 语气克制、具体、可执行 +- 不要输出 Markdown,不要编号前缀以外的废话 + +场景: +{prompt_text} +""".strip() + payload = {"model": AI_MODEL, "prompt": prompt, "stream": False, "options": {"temperature": 0.2}} + try: + r = requests.post(OLLAMA_API, json=payload, timeout=AI_TIMEOUT_SECONDS) + return (r.json().get("response") or "").strip() + except Exception: + return "" + + +def _load_font(size): + if not ImageFont: + return None + candidates = [ + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", + "C:\\Windows\\Fonts\\msyh.ttc", + "C:\\Windows\\Fonts\\arial.ttf", + ] + for path in candidates: + if path and os.path.exists(path): + try: + return ImageFont.truetype(path, size) + except Exception: + continue + try: + return ImageFont.load_default() + except Exception: + return None + + +def _ohlcv_to_rows(ohlcv): + rows = [] + for bar in ohlcv or []: + if not bar or len(bar) < 6: + continue + try: + rows.append( + { + "ts": int(bar[0]), + "o": float(bar[1]), + "h": float(bar[2]), + "l": float(bar[3]), + "c": float(bar[4]), + "v": float(bar[5]), + } + ) + except Exception: + continue + return rows + + +def _local_input_datetime_to_ms(dt_text): + raw = str(dt_text or "").strip() + if not raw: + return None + raw = raw.replace("T", " ") + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"): + try: + dt = datetime.strptime(raw, fmt) + aware = dt.replace(tzinfo=APP_TZ) + return int(aware.timestamp() * 1000) + except Exception: + continue + return None + + +def _marker_tag_label(tag): + t = str(tag or "").strip().upper() + if t == "ENTRY": + return "开仓" + if t == "EXIT": + return "平仓" + return str(tag or "") + + +def _pick_marker_point(rows, target_ts_ms, target_price=None): + if not rows or target_ts_ms is None: + return None, None + idx = min(range(len(rows)), key=lambda i: abs(int(rows[i]["ts"]) - int(target_ts_ms))) + if target_price is not None: + try: + p = float(target_price) + if p > 0: + return idx, p + except Exception: + pass + return idx, float(rows[idx]["c"]) + + +def _render_candles_subplot(rows, title, width, height, bg_rgb=(255, 255, 255), marker_points=None): + if not Image or not ImageDraw: + raise RuntimeError("缺少依赖:Pillow(pip install Pillow)") + img = Image.new("RGB", (width, height), bg_rgb) + draw = ImageDraw.Draw(img) + font = _load_font(14) + small = _load_font(12) + + pad_l, pad_r, pad_t, pad_b = 46, 12, 26, 28 + plot_w = max(10, width - pad_l - pad_r) + plot_h = max(10, height - pad_t - pad_b) + + header_bg = (245, 247, 250) + draw.rectangle((0, 0, width, pad_t), fill=header_bg) + if font: + draw.text((10, 6), title, fill=(25, 35, 60), font=font) + else: + draw.text((10, 6), title, fill=(25, 35, 60)) + + if not rows: + if small: + draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120), font=small) + else: + draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120)) + return img + + lo = min(r["l"] for r in rows) + hi = max(r["h"] for r in rows) + if hi <= lo: + hi = lo + 1e-12 + + n = len(rows) + marker_by_idx = {} + for mp in marker_points or []: + try: + idx = int(mp.get("idx")) + except Exception: + continue + if idx < 0 or idx >= n: + continue + marker_by_idx.setdefault(idx, []).append(mp) + + x0 = pad_l + for i, r in enumerate(rows): + x1 = pad_l + int((i + 1) * plot_w / n) + x_mid = (x0 + x1) // 2 + wick_x = x_mid + y_high = pad_t + int((hi - r["h"]) / (hi - lo) * plot_h) + y_low = pad_t + int((hi - r["l"]) / (hi - lo) * plot_h) + y_open = pad_t + int((hi - r["o"]) / (hi - lo) * plot_h) + y_close = pad_t + int((hi - r["c"]) / (hi - lo) * plot_h) + top = min(y_open, y_close) + bot = max(y_open, y_close) + up = r["c"] >= r["o"] + wick_color = (120, 120, 120) + edge_color = (20, 20, 20) + draw.line((wick_x, y_high, wick_x, y_low), fill=wick_color) + body_w = max(1, (x1 - x0) - 2) + left = x0 + 1 + if bot - top < 2: + mid = (top + bot) // 2 + draw.rectangle((left, mid, left + body_w, mid + 1), fill=edge_color) + else: + if up: + draw.rectangle((left, top, left + body_w, bot), fill=(255, 255, 255), outline=edge_color, width=1) + else: + draw.rectangle((left, top, left + body_w, bot), fill=edge_color, outline=edge_color, width=1) + for j, mp in enumerate(marker_by_idx.get(i, [])): + tag = str(mp.get("tag") or "") + label = _marker_tag_label(tag) + m_price = float(mp.get("price") or r["c"]) + y_m = pad_t + int((hi - m_price) / (hi - lo) * plot_h) + y_m = max(pad_t + 4, min(pad_t + plot_h - 4, y_m)) + x_off = (j - (len(marker_by_idx[i]) - 1) / 2.0) * 14 + x_draw = int(x_mid + x_off) + if tag == "ENTRY": + m_color = (0, 195, 95) + tri = [(x_draw, y_m - 20), (x_draw - 9, y_m - 4), (x_draw + 9, y_m - 4)] + text_y = y_m - 36 + else: + m_color = (235, 65, 65) + tri = [(x_draw, y_m + 20), (x_draw - 9, y_m + 4), (x_draw + 9, y_m + 4)] + text_y = y_m + 12 + draw.ellipse((x_draw - 5, y_m - 5, x_draw + 5, y_m + 5), fill=m_color, outline=(255, 255, 255), width=1) + draw.polygon(tri, fill=m_color) + draw.line((x_draw, y_m, x_draw, y_m - 16 if tag == "ENTRY" else y_m + 16), fill=m_color, width=3) + if font: + draw.text((x_draw + 8, text_y), label, fill=m_color, font=font) + else: + draw.text((x_draw + 8, text_y), label, fill=m_color) + x0 = x1 + + if len(marker_points or []) >= 2: + try: + entry = next((m for m in marker_points if m.get("tag") == "ENTRY"), None) + exitp = next((m for m in marker_points if m.get("tag") == "EXIT"), None) + if entry is not None and exitp is not None: + ex_i, ex_p = int(entry["idx"]), float(entry["price"]) + xx_i, xx_p = int(exitp["idx"]), float(exitp["price"]) + x_ex = pad_l + int((ex_i + 0.5) * plot_w / n) + x_xx = pad_l + int((xx_i + 0.5) * plot_w / n) + y_ex = pad_t + int((hi - ex_p) / (hi - lo) * plot_h) + y_xx = pad_t + int((hi - xx_p) / (hi - lo) * plot_h) + draw.line((x_ex, y_ex, x_xx, y_xx), fill=(35, 135, 255), width=3) + except Exception: + pass + + # 极简风格:不画网格与坐标轴,仅保留右下角轻量区间信息 + if small: + draw.text((width - 210, height - 22), f"L={lo:.6g} H={hi:.6g}", fill=(120, 125, 135), font=small) + return img + + +def _timeframe_period_ms(tf): + s = (tf or "").strip().lower() + if s.endswith("m"): + try: + return int(s[:-1]) * 60 * 1000 + except ValueError: + pass + if s.endswith("h"): + try: + return int(s[:-1]) * 3600 * 1000 + except ValueError: + pass + if s.endswith("d"): + try: + return int(s[:-1]) * 86400 * 1000 + except ValueError: + pass + return 300000 + + +def _ohlcv_dict_rows_to_lists(rows, lim): + if not rows: + return [] + pick = rows[-lim:] if len(rows) >= lim else rows + return [[r["ts"], r["o"], r["h"], r["l"], r["c"], r.get("v", 0)] for r in pick] + + +def _fetch_ohlcv_ending_at(exchange_symbol, timeframe, limit, end_ts_ms): + lim = max(2, int(limit or ORDER_CHART_LIMIT)) + try: + if not end_ts_ms: + ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=lim) + else: + period = _timeframe_period_ms(timeframe) + since = int(end_ts_ms) - period * (lim + 10) + ohlcv = exchange.fetch_ohlcv( + exchange_symbol, timeframe=timeframe, since=max(0, since), limit=lim + 20 + ) + except Exception: + return [] + rows = _ohlcv_to_rows(ohlcv) + if not rows: + return [] + if not end_ts_ms: + return _ohlcv_dict_rows_to_lists(rows, lim) + filtered = [r for r in rows if int(r["ts"]) <= int(end_ts_ms)] + if len(filtered) >= 2: + return _ohlcv_dict_rows_to_lists(filtered, lim) + return _ohlcv_dict_rows_to_lists(rows, lim) + + +def generate_multi_timeframe_chart_png( + exchange_symbol, + title_prefix, + timeframes=None, + limit=None, + out_dir=None, + filename=None, + filename_prefix="chart", + marker_payload=None, + marker_timeframes=None, +): + if not ORDER_CHART_ENABLED: + return None + if not Image: + return None + requested = timeframes or ORDER_CHART_TFS + limit = limit or ORDER_CHART_LIMIT + preferred_layout = ["5m", "15m", "1h", "4h"] + requested_set = set(requested or []) + ordered = [tf for tf in preferred_layout if tf in requested_set] + for tf in requested: + if tf not in ordered: + ordered.append(tf) + timeframes = ordered[:4] if ordered else preferred_layout + + ensure_markets_loaded() + panels = [] + cell_w, cell_h = 980, 520 + end_ts_ms = None + if marker_payload: + try: + end_ts_ms = int(marker_payload.get("exit_ts_ms") or marker_payload.get("entry_ts_ms") or 0) or None + except (TypeError, ValueError): + end_ts_ms = None + default_marker_tfs = {str(t).strip().lower() for t in timeframes} + for tf in timeframes: + try: + ohlcv = _fetch_ohlcv_ending_at(exchange_symbol, tf, limit, end_ts_ms) + if not ohlcv and end_ts_ms: + ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=tf, limit=limit) + except Exception: + ohlcv = [] + rows = _ohlcv_to_rows(ohlcv)[-limit:] + title = f"{title_prefix} | {tf} x{len(rows)}" + points = [] + tf_key = str(tf).strip().lower() + if marker_payload: + if marker_timeframes: + marker_tfs = {str(x).strip().lower() for x in marker_timeframes if str(x).strip()} + else: + marker_tfs = default_marker_tfs + else: + marker_tfs = set() + if marker_payload and tf_key in marker_tfs: + entry_idx, entry_price = _pick_marker_point(rows, marker_payload.get("entry_ts_ms"), marker_payload.get("entry_price")) + exit_idx, exit_price = _pick_marker_point(rows, marker_payload.get("exit_ts_ms"), marker_payload.get("exit_price")) + if entry_idx is not None and entry_price is not None: + points.append({"idx": entry_idx, "price": entry_price, "tag": "ENTRY"}) + if exit_idx is not None and exit_price is not None: + points.append({"idx": exit_idx, "price": exit_price, "tag": "EXIT"}) + panels.append( + _render_candles_subplot( + rows, + title, + width=cell_w, + height=cell_h, + bg_rgb=(255, 255, 255), + marker_points=points, + ) + ) + + if not panels: + return None + + gap = 10 + cols = 2 + rows_n = int(math.ceil(len(panels) / cols)) + w = cols * cell_w + (cols - 1) * gap + h = rows_n * cell_h + (rows_n - 1) * gap + out = Image.new("RGB", (w, h), (255, 255, 255)) + idx = 0 + for r in range(rows_n): + for c in range(cols): + if idx >= len(panels): + break + x = c * (cell_w + gap) + y = r * (cell_h + gap) + out.paste(panels[idx], (x, y)) + idx += 1 + + # 四宫格间隔线(仅在拼图间隙处画线,不进入单张子图) + if ImageDraw and rows_n >= 1: + draw_out = ImageDraw.Draw(out) + line_col = (220, 225, 232) + x_mid = cell_w + gap // 2 + if w > x_mid >= 0: + draw_out.line((x_mid, 0, x_mid, h), fill=line_col, width=2) + for rr in range(1, rows_n): + y_mid = rr * cell_h + (rr - 1) * gap + gap // 2 + if 0 <= y_mid <= h: + draw_out.line((0, y_mid, w, y_mid), fill=line_col, width=2) + + target_dir = out_dir or ORDER_CHART_DIR + os.makedirs(target_dir, exist_ok=True) + fname = filename or f"{filename_prefix}_{uuid.uuid4().hex}.png" + out_path = os.path.join(target_dir, fname) + out.save(out_path, format="PNG") + return fname + + +def generate_order_open_chart( + exchange_symbol, + title_prefix, + timeframes=None, + limit=None, + opened_at_ms=None, + entry_price=None, +): + marker_payload = None + if opened_at_ms: + marker_payload = { + "entry_ts_ms": opened_at_ms, + "exit_ts_ms": None, + "entry_price": entry_price, + "exit_price": None, + } + marker_tfs = ( + {x.strip().lower() for x in (timeframes or ORDER_CHART_TFS) if x and str(x).strip()} + or {"5m", "15m", "1h", "4h"} + ) + return generate_multi_timeframe_chart_png( + exchange_symbol, + title_prefix, + timeframes=timeframes, + limit=limit, + out_dir=ORDER_CHART_DIR, + filename=None, + filename_prefix="order", + marker_payload=marker_payload, + marker_timeframes=marker_tfs, + ) + + +def journal_coin_from_symbol(symbol): + sym = (symbol or "").strip().upper() + if not sym: + return "" + if "/" in sym: + return sym.split("/")[0].strip() + if "-" in sym: + return sym.split("-")[0].strip() + if sym.endswith("USDT"): + return sym[:-4].strip() + return sym + + +EARLY_EXIT_TRIGGERS = ( + "", + "止盈", + "保本止盈", + "移动止盈", + "手动平仓", + "止损", + "其他", +) + +# 与用户约定的固定开仓类型(仅做这几类单子) +ENTRY_REASON_OPTIONS = ( + "趋势多头:4h大结构突破前进场,确认条件:三次探顶,5m收敛不创新低", + "趋势空头:4h大结构突破前进场,确认条件:三次探底,5m收敛不创新高", + "趋势多头:小分歧低吸入场(左侧),确认条件:二次探底", + "趋势空头:小分歧高吸入场(左侧),确认条件:二次探顶", + "波段单:5m顺势突破,确认条件:2根k线+成交量放大+4h同向+日成交量前20", + "关键位箱体突破", + "关键位收敛突破", + "关键位斐波0.618", + "关键位斐波0.786", +) + +STATS_SEGMENT_DEFS = ( + ("all", "全部交易", {"segment": "all"}), + ("manual", "下单监控", {"segment": "manual"}), + ("key_box", "关键位箱体突破", {"segment": "key_box"}), + ("key_conv", "关键位收敛结构", {"segment": "key_conv"}), + ("key_fib618", "关键位斐波0.618", {"segment": "key_fib618"}), + ("key_fib786", "关键位斐波0.786", {"segment": "key_fib786"}), +) +ENTRY_REASON_OTHER = "__OTHER__" + + +def normalize_entry_reason(raw, custom_text=None): + v = str(raw or "").strip() + if v == ENTRY_REASON_OTHER: + c = str(custom_text or "").strip() + return c[:2000] if c else "" + return v if v in ENTRY_REASON_OPTIONS else "" + + +def normalize_early_exit_trigger(raw): + v = str(raw or "").strip() + return v if v in EARLY_EXIT_TRIGGERS else "" + + +def compose_early_exit_reason_saved(trigger, note): + """Readable single-line string stored in early_exit_reason for legacy consumers.""" + t = normalize_early_exit_trigger(trigger) + n = str(note or "").strip() + if t and n: + return f"{t}|{n}" + return t or n + + +def journal_exit_reason_stored(trigger, note): + """exit_reason 列与表单「一处」对齐:非手工=触发类型;手工=离场说明全文。""" + t = normalize_early_exit_trigger(trigger) + n = str(note or "").strip() + if t == "手动平仓": + return n + return t + + +def ai_extract_journal_from_image(image_b64): + prompt = """ +你是交易复盘信息提取助手。请从截图中提取可识别字段,并只输出 JSON(不要 markdown,不要解释)。 +要求: +1) 仅输出一个 JSON 对象。 +2) 时间输出为 YYYY-MM-DDTHH:MM(用于 HTML datetime-local),无法识别填空字符串。 +3) 不要猜测主观原因;early_exit_note(仅手工平仓)、note 默认留空,除非图中明确写出。 +4) 允许字段为空。 +5) entry_reason 只能从下列完整字符串中选一个(一字不差;截图无法归类则填空字符串): + - 趋势多头:4h大结构突破前进场,确认条件:三次探顶,5m收敛不创新低 + - 趋势空头:4h大结构突破前进场,确认条件:三次探底,5m收敛不创新高 + - 趋势多头:小分歧低吸入场(左侧),确认条件:二次探底 + - 趋势空头:小分歧高吸入场(左侧),确认条件:二次探顶 + - 波段单:5m顺势突破,确认条件:2根k线+成交量放大+4h同向+日成交量前20 +6) early_exit_trigger 只能从下列取值中选一个(无法识别则填空字符串):保本止盈、移动止盈、手动平仓、止损、其他。 +7) 若触发为「手动平仓」,early_exit_note 必须写出图中可见的补充说明;其他触发类型 early_exit_note 留空。 +8) 若图中有无法归类的离场说明原文,可放进 early_exit_note,early_exit_trigger 填「其他」或留空。 + +JSON 字段: +{ + "open_datetime": "", + "close_datetime": "", + "coin": "", + "tf": "", + "pnl": "", + "expect_rr": "", + "real_rr": "", + "entry_reason": "", + "early_exit_trigger": "", + "early_exit_note": "", + "early_exit_reason": "", + "note": "" +} +""".strip() + payload = { + "model": AI_MODEL, + "prompt": prompt, + "images": [image_b64], + "stream": False, + "options": {"temperature": 0.1}, + } + try: + r = requests.post(OLLAMA_API, json=payload, timeout=AI_TIMEOUT_SECONDS) + raw = r.json().get("response", "") + data = _extract_json_object(raw) or {} + if not isinstance(data, dict): + data = {} + trig_in = data.get("early_exit_trigger") + note_in = data.get("early_exit_note") + legacy_reason = str(data.get("early_exit_reason") or "").strip() + out = { + "open_datetime": str(data.get("open_datetime") or "").strip(), + "close_datetime": str(data.get("close_datetime") or "").strip(), + "coin": str(data.get("coin") or "").strip(), + "tf": str(data.get("tf") or "").strip(), + "pnl": str(data.get("pnl") or "").strip(), + "expect_rr": str(data.get("expect_rr") or "").strip(), + "real_rr": str(data.get("real_rr") or "").strip(), + "entry_reason": normalize_entry_reason(data.get("entry_reason")), + "early_exit_trigger": normalize_early_exit_trigger(trig_in), + "early_exit_note": str(note_in or "").strip(), + "early_exit_reason": legacy_reason, + "note": str(data.get("note") or "").strip(), + } + if not out["early_exit_trigger"] and not out["early_exit_note"] and legacy_reason: + out["early_exit_note"] = legacy_reason + if out["early_exit_trigger"] == "手动平仓" and not out["early_exit_note"] and legacy_reason: + out["early_exit_note"] = legacy_reason + if out["early_exit_trigger"] != "手动平仓": + out["early_exit_note"] = "" + out["exit_reason"] = journal_exit_reason_stored(out["early_exit_trigger"], out["early_exit_note"]) + return out + except Exception: + return None + +# 初始化数据库(支持多空方向) +def init_db(): + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + + # 关键位监控 + c.execute('''CREATE TABLE IF NOT EXISTS key_monitors + (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT, + direction TEXT DEFAULT "long", upper REAL, lower REAL, + notification_count INTEGER DEFAULT 0, last_notified_at TEXT, + max_notify INTEGER DEFAULT 3, notify_interval_min INTEGER DEFAULT 5, + breakout_limit_pct REAL DEFAULT 1.5, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + # 订单监控(核心:加 direction 方向字段) + c.execute('''CREATE TABLE IF NOT EXISTS order_monitors + (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, direction TEXT DEFAULT "long", + exchange_symbol TEXT, + trigger_price REAL, stop_loss REAL, initial_stop_loss REAL, take_profit REAL, + margin_capital REAL DEFAULT 30, leverage INTEGER DEFAULT 5, + trade_style TEXT DEFAULT "trend", + risk_percent REAL, risk_amount REAL, + breakeven_rr_trigger REAL, breakeven_offset_pct REAL, breakeven_step_r REAL, + breakeven_armed INTEGER DEFAULT 0, breakeven_price REAL, + notional_value REAL, position_ratio REAL, base_amount REAL, + order_amount REAL, exchange_order_id TEXT, exchange_close_order_id TEXT, + opened_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, opened_at_ms INTEGER, session_date TEXT, + status TEXT DEFAULT "active")''') + + # 交易记录(必须存多空) + c.execute('''CREATE TABLE IF NOT EXISTS trade_records + (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT, + direction TEXT DEFAULT "long", trigger_price REAL, stop_loss REAL, initial_stop_loss REAL, take_profit REAL, + margin_capital REAL, leverage INTEGER, pnl_amount REAL DEFAULT 0, hold_seconds INTEGER DEFAULT 0, + trade_style TEXT DEFAULT "trend", risk_amount REAL, planned_rr REAL, actual_rr REAL, + hold_minutes INTEGER DEFAULT 0, opened_at TEXT, opened_at_ms INTEGER, closed_at TEXT, closed_at_ms INTEGER, + result TEXT, miss_reason TEXT, exchange_trade_id TEXT, + reviewed_opened_at TEXT, reviewed_closed_at TEXT, reviewed_stop_loss REAL, reviewed_take_profit REAL, reviewed_pnl_amount REAL, + reviewed_result TEXT, reviewed_miss_reason TEXT, reviewed_hold_seconds INTEGER, reviewed_hold_minutes INTEGER, + reviewed_at TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + c.execute('''CREATE TABLE IF NOT EXISTS trading_sessions + (session_date TEXT PRIMARY KEY, start_capital REAL, current_capital REAL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + c.execute('''CREATE TABLE IF NOT EXISTS journal_entries + (id TEXT PRIMARY KEY, open_datetime TEXT, close_datetime TEXT, hold_duration TEXT, + coin TEXT, tf TEXT, pnl TEXT, entry_reason TEXT, exit_reason TEXT, + expect_rr TEXT, real_rr TEXT, early_exit TEXT, early_exit_reason TEXT, + early_exit_trigger TEXT, early_exit_note TEXT, + mood_score INTEGER, mood_ai_score INTEGER, mood_ai_comment TEXT, mood_issues TEXT, post_breakeven_stare TEXT, + new_trade_while_occupied TEXT, note TEXT, image TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + c.execute('''CREATE TABLE IF NOT EXISTS ai_reviews + (id TEXT PRIMARY KEY, review_type TEXT, target_date TEXT, content TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + c.execute('''CREATE TABLE IF NOT EXISTS transfer_logs + (id INTEGER PRIMARY KEY AUTOINCREMENT, transfer_type TEXT, transfer_day TEXT, + amount REAL, from_account TEXT, to_account TEXT, status TEXT, message TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + c.execute('''DROP INDEX IF EXISTS idx_transfer_logs_unique_day''') + c.execute('''CREATE UNIQUE INDEX IF NOT EXISTS idx_transfer_logs_auto_daily_unique + ON transfer_logs(transfer_type, transfer_day) + WHERE transfer_type = 'auto_daily' ''') + + # 给旧表加 direction 字段(兼容老数据,不报错) + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN direction TEXT DEFAULT 'long'") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_symbol TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN margin_capital REAL DEFAULT 30") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN leverage INTEGER DEFAULT 5") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN trade_style TEXT DEFAULT 'trend'") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN risk_percent REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN risk_amount REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_rr_trigger REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_offset_pct REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_step_r REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_armed INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_price REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN initial_stop_loss REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN notional_value REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN position_ratio REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN base_amount REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN order_amount REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_order_id TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_close_order_id TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN opened_at TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN opened_at_ms INTEGER") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN session_date TEXT") + except: pass + try: + c.execute("UPDATE order_monitors SET opened_at = datetime('now') WHERE opened_at IS NULL OR opened_at = ''") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN direction TEXT DEFAULT 'long'") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN margin_capital REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN leverage INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN pnl_amount REAL DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN hold_seconds INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN hold_minutes INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN trade_style TEXT DEFAULT 'trend'") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN risk_amount REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN planned_rr REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN actual_rr REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN initial_stop_loss REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN exchange_trade_id TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN opened_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN opened_at_ms INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN closed_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN closed_at_ms INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_opened_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_closed_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_stop_loss REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_take_profit REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_pnl_amount REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_result TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_miss_reason TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_hold_seconds INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_hold_minutes INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN entry_reason TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_entry_reason TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN mood_ai_score INTEGER") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN mood_ai_comment TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_trigger TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_note TEXT") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN direction TEXT DEFAULT 'long'") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN notification_count INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN last_notified_at TEXT") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN max_notify INTEGER DEFAULT 3") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN notify_interval_min INTEGER DEFAULT 5") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN breakout_limit_pct REAL DEFAULT 1.5") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN key_signal_type TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN monitor_type TEXT DEFAULT '下单监控'") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 1") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN key_signal_type TEXT") + except: pass + for ddl in ( + "ALTER TABLE key_monitors ADD COLUMN fib_limit_order_id TEXT", + "ALTER TABLE key_monitors ADD COLUMN fib_entry_price REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_stop_loss REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_take_profit REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_order_amount REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_margin_capital REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_leverage INTEGER", + "ALTER TABLE key_monitors ADD COLUMN sl_tp_mode TEXT DEFAULT 'standard'", + "ALTER TABLE key_monitors ADD COLUMN manual_take_profit REAL", + "ALTER TABLE key_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 0", + ): + try: + c.execute(ddl) + except Exception: + pass + try: + c.execute("ALTER TABLE trading_sessions ADD COLUMN key_sizing_capital_snapshot REAL") + except Exception: + pass + + c.execute( + """CREATE TABLE IF NOT EXISTS key_monitor_history + (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT, direction TEXT, + upper REAL, lower REAL, notification_count INTEGER, last_alert_message TEXT, + close_reason TEXT, closed_at TEXT)""" + ) + + conn.commit() + conn.close() + +init_db() + +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def app_now(): + """应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较)。""" + return datetime.now(APP_TZ).replace(tzinfo=None) + + +def app_now_str(): + return app_now().strftime("%Y-%m-%d %H:%M:%S") + + +def utc_now_dt(): + """当前时刻(UTC,aware)。""" + return datetime.now(timezone.utc) + + +def utc_calendar_date_str(): + """UTC 自然日 YYYY-MM-DD(用于自动划转去重等与交易所日界对齐的计算)。""" + return utc_now_dt().strftime("%Y-%m-%d") + + +def get_trading_day(now=None): + """交易日字符串:本地时钟下若小时 < TRADING_DAY_RESET_HOUR 则归属「上一日历日」。""" + now = now or app_now() + if getattr(now, "tzinfo", None): + now = now.astimezone(APP_TZ).replace(tzinfo=None) + if now.hour < TRADING_DAY_RESET_HOUR: + return (now - timedelta(days=1)).strftime("%Y-%m-%d") + return now.strftime("%Y-%m-%d") + + +TRADE_COMPLETED_RESULTS = ( + "止盈", + "止损", + "保本止盈", + "移动止盈", + "手动平仓", + "强制清仓", + "外部平仓", +) + +REVIEW_RESULT_OPTIONS = ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓") + + +def parse_dt_for_trading_day(s): + if not s: + return None + s = str(s).strip().replace("Z", "").replace("T", " ") + if not s: + return None + for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)): + try: + return datetime.strptime(s[:ln], fmt) + except ValueError: + continue + return None + + +def insert_key_monitor_history(conn, row, notification_count, last_msg, close_reason): + conn.execute( + """INSERT INTO key_monitor_history + (symbol, monitor_type, direction, upper, lower, notification_count, last_alert_message, close_reason, closed_at) + VALUES (?,?,?,?,?,?,?,?,?)""", + ( + row["symbol"], + row["monitor_type"], + row["direction"] or "long", + row["upper"], + row["lower"], + int(notification_count or 0), + (last_msg or "")[:800] if last_msg else None, + close_reason, + app_now_str(), + ), + ) + + +def _session_week_bounds(trading_day_str): + end = datetime.strptime(trading_day_str, "%Y-%m-%d").date() + start = end - timedelta(days=6) + return start.strftime("%Y-%m-%d"), trading_day_str + + +def _calendar_month_bounds(local_dt): + y, m = local_dt.year, local_dt.month + start = f"{y:04d}-{m:02d}-01" + if m == 12: + end_d = datetime(y, 12, 31).date() + else: + end_d = (datetime(y, m + 1, 1) - timedelta(days=1)).date() + return start, end_d.strftime("%Y-%m-%d") + + +def _count_opens_between(conn, start_td, end_td): + return _count_opens_for_segment(conn, start_td, end_td, "all") + + +def _list_window_from_request(): + return resolve_list_window(request.args, session, default_preset=PRESET_UTC_TODAY) + + +def _redirect_records(): + qs = list_window_redirect_query(session) + return redirect(f"/records?{qs}" if qs else "/records") + + +def _pnl_row_matches_segment(row, segment_key): + try: + mt = (row["monitor_type"] or "").strip() + kst = (row["key_signal_type"] or "").strip() + except Exception: + return False + if segment_key == "all": + return True + if segment_key == "manual": + return mt == ORDER_MONITOR_TYPE_MANUAL and not kst + if segment_key == "key_box": + return kst == "箱体突破" + if segment_key == "key_conv": + return kst == "收敛突破" + if segment_key == "key_fib618": + return kst == "斐波回调0.618" + if segment_key == "key_fib786": + return kst == "斐波回调0.786" + return False + + +def _count_opens_for_segment(conn, start_td, end_td, segment_key): + if segment_key == "manual": + return conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ? " + "AND (monitor_type IS NULL OR monitor_type=? OR TRIM(monitor_type)='') " + "AND (key_signal_type IS NULL OR TRIM(key_signal_type)='')", + (start_td, end_td, ORDER_MONITOR_TYPE_MANUAL), + ).fetchone()[0] + kst_map = { + "key_box": "箱体突破", + "key_conv": "收敛突破", + "key_fib618": "斐波回调0.618", + "key_fib786": "斐波回调0.786", + } + kst = kst_map.get(segment_key) + if kst: + return conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ? AND key_signal_type=?", + (start_td, end_td, kst), + ).fetchone()[0] + return conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ?", + (start_td, end_td), + ).fetchone()[0] + + +def _load_completed_trade_pnls(conn): + q = """SELECT pnl_amount, reviewed_pnl_amount, closed_at, reviewed_closed_at, created_at, opened_at, + result, reviewed_result, monitor_type, key_signal_type + FROM trade_records + ORDER BY COALESCE(closed_at, created_at, opened_at) ASC, id ASC""" + rows = conn.execute(q).fetchall() + out = [] + for r in rows: + effective_result = (r["reviewed_result"] or r["result"] or "").strip() + if effective_result not in TRADE_COMPLETED_RESULTS: + continue + try: + p = float(r["reviewed_pnl_amount"] if r["reviewed_pnl_amount"] is not None else (r["pnl_amount"] or 0)) + except (TypeError, ValueError): + p = 0.0 + t = parse_dt_for_trading_day(r["reviewed_closed_at"]) or parse_dt_for_trading_day(r["closed_at"]) or parse_dt_for_trading_day(r["created_at"]) + td = get_trading_day(t) if t else None + out.append((p, t, td, r)) + return out + + +def _compute_period_metrics(trades): + """trades: list of (pnl, close_dt, close_trading_day)""" + trades = [(p, t, td) for p, t, td in trades if t is not None] + trades.sort(key=lambda x: x[1]) + closed = len(trades) + wins = sum(1 for p, _, _ in trades if p > 0) + losses = sum(1 for p, _, _ in trades if p < 0) + net = round(sum(p for p, _, _ in trades), 4) + loss_sum_raw = sum(p for p, _, _ in trades if p < 0) + loss_sum_u = round(abs(loss_sum_raw), 4) if loss_sum_raw < 0 else 0.0 + neg_pnls = [p for p, _, _ in trades if p < 0] + pos_pnls = [p for p, _, _ in trades if p > 0] + max_single_loss = round(min(neg_pnls), 4) if neg_pnls else None + max_single_profit = round(max(pos_pnls), 4) if pos_pnls else None + cum = peak = max_dd = 0.0 + for p, _, _ in trades: + cum += p + peak = max(peak, cum) + max_dd = max(max_dd, peak - cum) + max_dd = round(max_dd, 4) + streak = 0 + for p, _, _ in reversed(trades): + if p < 0: + streak += 1 + else: + break + daily = {} + for p, _, td in trades: + if td: + daily[td] = daily.get(td, 0.0) + p + max_loss_streak_days = 0 + worst_day = None + worst_day_pnl = None + if daily: + sorted_days = sorted(daily.keys()) + run = 0 + for d in sorted_days: + if daily[d] < 0: + run += 1 + max_loss_streak_days = max(max_loss_streak_days, run) + else: + run = 0 + worst_day = min(daily.keys(), key=lambda x: daily[x]) + worst_day_pnl = round(daily[worst_day], 4) + win_rate_pct = round(wins / (wins + losses) * 100, 2) if (wins + losses) else None + return { + "closed_count": closed, + "win_count": wins, + "loss_count": losses, + "win_rate_pct": win_rate_pct, + "net_pnl_u": net, + "loss_sum_u": loss_sum_u, + "max_single_loss": max_single_loss, + "max_single_profit": max_single_profit, + "max_drawdown_u": max_dd, + "consecutive_losses": streak, + "max_loss_streak_days": max_loss_streak_days, + "worst_day": worst_day, + "worst_day_pnl": worst_day_pnl, + "opens_count": 0, + "range_label": "", + } + + +def compute_stats_bundle(conn, trading_day, now_dt=None): + """日 / 周 / 月 统计:平仓按北京时间交易日(默认 8:00 切日)计入。""" + now_dt = now_dt or app_now() + pnls = _load_completed_trade_pnls(conn) + total_opens_all = conn.execute("SELECT COUNT(*) FROM order_monitors").fetchone()[0] + w_start, w_end = _session_week_bounds(trading_day) + m_start, m_end = _calendar_month_bounds(now_dt) + + def slice_metrics(seg_key): + seg_rows = [tr for tr in pnls if _pnl_row_matches_segment(tr[3], seg_key)] + day_tr = [(p, t, td) for p, t, td, _r in seg_rows if td == trading_day] + week_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and w_start <= td <= w_end] + month_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and m_start <= td <= m_end] + dm = _compute_period_metrics(day_tr) + wm = _compute_period_metrics(week_tr) + mm = _compute_period_metrics(month_tr) + dm["opens_count"] = _count_opens_for_segment(conn, trading_day, trading_day, seg_key) + wm["opens_count"] = _count_opens_for_segment(conn, w_start, w_end, seg_key) + mm["opens_count"] = _count_opens_for_segment(conn, m_start, m_end, seg_key) + dm["range_label"] = f"北京时间交易日 {trading_day}({TRADING_DAY_RESET_HOUR}:00 切日)" + wm["range_label"] = f"{w_start} ~ {w_end}(北京日期,近7天)" + mm["range_label"] = f"{m_start} ~ {m_end}(北京自然月)" + return dm, wm, mm + + segments = [] + for seg_key, seg_title, _meta in STATS_SEGMENT_DEFS: + dm, wm, mm = slice_metrics(seg_key) + segments.append({"key": seg_key, "title": seg_title, "day": dm, "week": wm, "month": mm}) + + dm, wm, mm = slice_metrics("all") + + return { + "trading_day": trading_day, + "total_opens_all": total_opens_all, + "day": dm, + "week": wm, + "month": mm, + "segments": segments, + "stats_reset_hour": TRADING_DAY_RESET_HOUR, + } + + +def infer_leverage(symbol): + sym = (symbol or "").strip().upper() + if sym.startswith("BTC") or sym.startswith("ETH"): + return BTC_LEVERAGE + return ALT_LEVERAGE + + +def normalize_okx_symbol(symbol): + sym = symbol.strip().upper() + if ":" in sym: + return sym + if "/" in sym: + base, quote = sym.split("/", 1) + quote_clean = quote.split(":")[0] + return f"{base}/{quote_clean}:{quote_clean}" + return sym + + +def resolve_monitor_exchange_symbol(row): + raw = "" + try: + if row["exchange_symbol"]: + raw = str(row["exchange_symbol"]).strip() + except (KeyError, IndexError, TypeError): + raw = "" + if not raw: + try: + raw = str(row["symbol"] or "").strip() + except (KeyError, IndexError, TypeError): + raw = "" + return normalize_okx_symbol(raw) if raw else "" + + +def round_price_to_exchange(exchange_symbol, price): + if price in (None, ""): + return None + try: + v = float(price) + except (TypeError, ValueError): + return None + if not exchange_symbol: + return v + try: + ensure_markets_loaded() + return float(exchange.price_to_precision(exchange_symbol, v)) + except Exception: + return v + + +def normalize_symbol_input(symbol): + sym = (symbol or "").strip().upper() + if not sym: + return "" + if "/" in sym: + return sym + if ":" in sym: + sym = sym.split(":")[0] + return f"{sym}/USDT" + + +def normalize_kline_limit(limit_raw, default=200): + try: + n = int(limit_raw) + except Exception: + return default + return 200 if n >= 200 else 100 + + +def get_recommended_capital(current_capital): + if current_capital <= DAILY_LOSS_CAPITAL: + return DAILY_LOSS_CAPITAL + if current_capital >= DAILY_PROFIT_CAPITAL: + return DAILY_PROFIT_CAPITAL + return DAILY_START_CAPITAL + + +def ensure_session(conn, session_date): + row = conn.execute( + "SELECT * FROM trading_sessions WHERE session_date = ?", + (session_date,) + ).fetchone() + if row: + return row + conn.execute( + "INSERT INTO trading_sessions (session_date, start_capital, current_capital) VALUES (?,?,?)", + (session_date, DAILY_START_CAPITAL, DAILY_START_CAPITAL) + ) + conn.commit() + return conn.execute( + "SELECT * FROM trading_sessions WHERE session_date = ?", + (session_date,) + ).fetchone() + + +def update_session_capital(conn, session_date, pnl_amount): + session_row = ensure_session(conn, session_date) + new_capital = float(session_row["current_capital"]) + float(pnl_amount) + conn.execute( + "UPDATE trading_sessions SET current_capital = ?, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?", + (round(new_capital, 4), session_date) + ) + conn.commit() + return round(new_capital, 4) + + +def calc_hold_seconds(opened_at_str, closed_at_dt): + try: + opened_at = datetime.strptime(opened_at_str, "%Y-%m-%d %H:%M:%S") + return int((closed_at_dt - opened_at).total_seconds()) + except Exception: + return 0 + + +def calc_hold_minutes(seconds): + if not seconds or seconds <= 0: + return 0 + return max(1, int(seconds // 60)) + + +def get_opened_at_value(row): + try: + keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + keys = [] + if "opened_at" in keys: + value = row["opened_at"] + if value: + return value + return app_now_str() + + +def get_effective_trade_field(row, reviewed_key, base_key, default=None): + try: + keys = row.keys() if hasattr(row, "keys") else row.keys() + except Exception: + keys = [] + if reviewed_key in keys: + v = row[reviewed_key] + if v is not None and str(v).strip() != "": + return v + if base_key in keys: + v = row[base_key] + if v is not None and str(v).strip() != "": + return v + return default + + +def to_effective_trade_dict(row): + item = row_to_dict(row) + base_stop = item.get("initial_stop_loss") if item.get("initial_stop_loss") not in (None, "") else item.get("stop_loss") + item["effective_opened_at"] = get_effective_trade_field(row, "reviewed_opened_at", "opened_at", item.get("opened_at")) + item["effective_closed_at"] = get_effective_trade_field(row, "reviewed_closed_at", "closed_at", item.get("closed_at")) + open_stop = item.get("initial_stop_loss") + if open_stop in (None, ""): + open_stop = base_stop + item["display_open_stop_loss"] = open_stop + item["effective_stop_loss"] = get_effective_trade_field(row, "reviewed_stop_loss", "stop_loss", open_stop) + item["effective_take_profit"] = get_effective_trade_field(row, "reviewed_take_profit", "take_profit", item.get("take_profit")) + item["effective_result"] = get_effective_trade_field(row, "reviewed_result", "result", item.get("result")) + item["effective_miss_reason"] = get_effective_trade_field(row, "reviewed_miss_reason", "miss_reason", item.get("miss_reason")) + item["effective_pnl_amount"] = get_effective_trade_field(row, "reviewed_pnl_amount", "pnl_amount", item.get("pnl_amount")) + item["effective_hold_minutes"] = get_effective_trade_field(row, "reviewed_hold_minutes", "hold_minutes", item.get("hold_minutes")) + item["effective_hold_seconds"] = get_effective_trade_field(row, "reviewed_hold_seconds", "hold_seconds", item.get("hold_seconds")) + er_eff = get_effective_trade_field(row, "reviewed_entry_reason", "entry_reason", item.get("entry_reason")) + item["effective_entry_reason"] = (str(er_eff).strip() if er_eff is not None else "") or "" + return item + + +def format_price_for_symbol(symbol, value): + if value in (None, ""): + return "-" + try: + v = float(value) + except Exception: + return str(value) + if v == 0: + return "0" + av = abs(v) + # 根据币价量级动态精度:低价币保留更多小数,高价币减少噪音位数 + if av >= 10000: + d = 2 + elif av >= 100: + d = 3 + elif av >= 1: + d = 4 + elif av >= 0.01: + d = 6 + elif av >= 0.0001: + d = 8 + else: + d = 10 + text = f"{v:.{d}f}" + return text.rstrip("0").rstrip(".") if "." in text else text + + +def format_hold_minutes(minutes): + if not minutes: + return "0分钟" + total = int(minutes) + hours = total // 60 + mins = total % 60 + if hours: + return f"{hours}小时{mins}分钟" + return f"{mins}分钟" + + +def calc_pnl(direction, trigger_price, exit_price, margin_capital, leverage): + try: + trigger = float(trigger_price) + exit_p = float(exit_price) + margin = float(margin_capital) + lev = float(leverage) + if trigger <= 0: + return 0.0 + if direction == "short": + pnl_ratio = (trigger - exit_p) / trigger + else: + pnl_ratio = (exit_p - trigger) / trigger + return round(margin * lev * pnl_ratio, 4) + except Exception: + return 0.0 + + +def calc_rr_ratio(direction, entry_price, stop_loss, take_profit): + try: + entry = float(entry_price) + sl = float(stop_loss) + tp = float(take_profit) + if entry <= 0 or sl <= 0 or tp <= 0: + return None + if direction == "short": + risk = sl - entry + reward = entry - tp + else: + risk = entry - sl + reward = tp - entry + if risk <= 0 or reward <= 0: + return None + return round(reward / risk, 4) + except Exception: + return None + + +def calc_risk_fraction(direction, entry_price, stop_loss): + try: + entry = float(entry_price) + sl = float(stop_loss) + if entry <= 0 or sl <= 0: + return None + if direction == "short": + risk = sl - entry + else: + risk = entry - sl + if risk <= 0: + return None + return risk / entry + except Exception: + return None + + +def calc_risk_amount_from_plan(direction, entry_price, stop_loss, margin_capital, leverage): + rf = calc_risk_fraction(direction, entry_price, stop_loss) + if rf is None: + return None + try: + notional = float(margin_capital) * float(leverage) + if notional <= 0: + return None + return round(notional * rf, 6) + except Exception: + return None + + +def calc_actual_rr(pnl_amount, risk_amount): + try: + r = float(risk_amount or 0) + if r <= 0: + return None + return round(float(pnl_amount or 0) / r, 4) + except Exception: + return None + + +def normalize_result_with_pnl(result, pnl_amount): + """ + 触发“止损”但实际已盈利时,归类为保本止盈,避免语义混淆。 + """ + if result == "止损": + try: + if float(pnl_amount or 0) > 0: + return "保本止盈" + except Exception: + pass + return result + + +def calc_breakeven_stop(direction, entry_price, risk_fraction, locked_r, offset_pct): + """ + 按“已锁定R”计算目标止损位: + - long: entry + locked_r * (entry*risk_fraction) + offset + - short: entry - locked_r * (entry*risk_fraction) - offset + """ + try: + entry = float(entry_price) + rf = float(risk_fraction) + lr = float(locked_r) + off = float(offset_pct) / 100.0 + if entry <= 0 or rf <= 0 or lr < 0: + return None + base_move = entry * rf * lr + offset_move = entry * off + if direction == "short": + return round(entry - base_move - offset_move, 8) + return round(entry + base_move + offset_move, 8) + except Exception: + return None + + +def insert_trade_record( + conn, + symbol, + monitor_type, + direction, + trigger_price, + stop_loss, + initial_stop_loss=None, + take_profit=None, + margin_capital=None, + leverage=None, + pnl_amount=0, + hold_seconds=0, + trade_style=None, + risk_amount=None, + planned_rr=None, + actual_rr=None, + result="", + miss_reason=None, + opened_at=None, + opened_at_ms=None, + closed_at=None, + closed_at_ms=None, + exchange_trade_id=None, + key_signal_type=None, + entry_reason=None, +): + hold_minutes = calc_hold_minutes(hold_seconds) + open_ts = opened_at or app_now_str() + close_ts = closed_at or app_now_str() + open_ts_ms = _to_ms_with_fallback(opened_at_ms, open_ts) + close_ts_ms = _to_ms_with_fallback(closed_at_ms, close_ts) + kst = key_signal_type_for_trade_record(key_signal_type, KEY_MONITOR_AUTO_TYPES) + snap_sl = initial_stop_loss if initial_stop_loss not in (None, "") else stop_loss + er = (entry_reason or "").strip() or entry_reason_from_key_signal(kst) or "" + conn.execute( + "INSERT INTO trade_records (symbol,monitor_type,key_signal_type,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage,pnl_amount,hold_seconds,trade_style,risk_amount,planned_rr,actual_rr,hold_minutes,opened_at,opened_at_ms,closed_at,closed_at_ms,result,miss_reason,exchange_trade_id,entry_reason) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, monitor_type, kst, direction, trigger_price, snap_sl, snap_sl, take_profit, + margin_capital, leverage, pnl_amount, hold_seconds, + trade_style, risk_amount, planned_rr, actual_rr, hold_minutes, + open_ts, open_ts_ms, close_ts, close_ts_ms, result, miss_reason, exchange_trade_id, er or None + ) + ) + + +def calc_duration_text(open_str, close_str): + try: + fmt = "%Y-%m-%dT%H:%M" + o = datetime.strptime(open_str, fmt) + c = datetime.strptime(close_str, fmt) + delta = c - o + seconds = int(delta.total_seconds()) + if seconds <= 0: + return "0分钟" + d = seconds // 86400 + h = (seconds % 86400) // 3600 + m = (seconds % 3600) // 60 + parts = [] + if d: + parts.append(f"{d}天") + if h: + parts.append(f"{h}小时") + if m or not parts: + parts.append(f"{m}分钟") + return " ".join(parts) + except Exception: + return "计算失败" + + +def row_to_dict(row): + return {k: row[k] for k in row.keys()} + + +def enrich_order_item(raw_item, current_capital): + item = dict(raw_item or {}) + margin = float(item.get("margin_capital") or 0) + lev = float(item.get("leverage") or 0) + notional = item.get("notional_value") + ratio = item.get("position_ratio") + if notional is None: + notional = round(margin * lev, 4) if margin and lev else 0 + if ratio is None: + ratio = round(margin / current_capital * 100, 2) if current_capital else 0 + item["notional_value"] = notional + item["position_ratio"] = ratio + item["rr_ratio"] = calc_rr_ratio( + item.get("direction") or "long", + item.get("trigger_price"), + item.get("initial_stop_loss") or item.get("stop_loss"), + item.get("take_profit"), + ) + try: + be = item.get("breakeven_enabled") + item["breakeven_enabled"] = 0 if be is not None and int(be) == 0 else 1 + except Exception: + item["breakeven_enabled"] = 1 + return item + + +def ensure_okx_live_ready(): + if not LIVE_TRADING_ENABLED: + return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)" + if not (OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE): + return False, "缺少 OKX API 密钥配置" + return True, "" + + +def _extract_usdt_total(balance): + usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {} + total_map = balance.get("total", {}) if isinstance(balance, dict) else {} + free_map = balance.get("free", {}) if isinstance(balance, dict) else {} + total = usdt_info.get("total") + if total is None: + total = total_map.get("USDT") + if total is None: + total = usdt_info.get("free") + if total is None: + total = free_map.get("USDT") + try: + return float(total) if total is not None else None + except Exception: + return None + + +def _extract_usdt_free(balance): + usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {} + free_map = balance.get("free", {}) if isinstance(balance, dict) else {} + free = usdt_info.get("free") + if free is None: + free = free_map.get("USDT") + try: + return float(free) if free is not None else None + except Exception: + return None + + +def _fetch_usdt_by_types(type_candidates): + for t in type_candidates: + try: + bal = exchange.fetch_balance(params={"type": t}) + val = _extract_usdt_total(bal) + if val is not None: + return val + except Exception: + continue + return None + + +def get_available_trading_usdt(): + ok_live, _ = ensure_okx_live_ready() + if not ok_live: + return None + for t in ["swap", "trading", "spot"]: + try: + bal = exchange.fetch_balance(params={"type": t}) + free_val = _extract_usdt_free(bal) + if free_val is not None: + return free_val + except Exception: + continue + return None + + +def get_synced_leverage(exchange_symbol, direction): + ensure_markets_loaded() + # 1) 优先读取交易所杠杆配置 + try: + if hasattr(exchange, "fetch_leverage"): + lev = exchange.fetch_leverage(exchange_symbol, params={"mgnMode": OKX_TD_MODE}) + long_lev = lev.get("longLeverage") or lev.get("long") + short_lev = lev.get("shortLeverage") or lev.get("short") + base_lev = lev.get("leverage") + if direction == "long" and long_lev: + return int(float(long_lev)) + if direction == "short" and short_lev: + return int(float(short_lev)) + if base_lev: + return int(float(base_lev)) + except Exception: + pass + # 2) 从当前仓位里兜底读取 + try: + positions = exchange.fetch_positions([exchange_symbol], params={"instType": "SWAP"}) + for p in positions: + if p.get("symbol") != exchange_symbol: + continue + info = p.get("info", {}) or {} + side = (p.get("side") or info.get("posSide") or "").lower() + if OKX_POS_MODE == "hedge" and side and side != direction: + continue + lev = p.get("leverage") or info.get("lever") + if lev: + return int(float(lev)) + except Exception: + pass + return None + + +def friendly_okx_error(err, available_usdt=None): + msg = str(err) + if "51008" in msg or "Insufficient USDT margin" in msg: + tail = f"(当前交易账户可用约 {round(available_usdt, 4)}U)" if available_usdt is not None else "" + return f"交易所下单失败:保证金不足 {tail}。请降低保证金/杠杆,或先划转USDT到交易账户。" + clean = re.sub(r"\s+", " ", msg).strip() + return f"交易所下单失败:{clean}" + + +def get_exchange_capitals(force=False): + ok_live, _ = ensure_okx_live_ready() + if not ok_live: + return None, None + now_ts = time.time() + if (not force) and ACCOUNT_BALANCE_CACHE["updated_at"] and now_ts - ACCOUNT_BALANCE_CACHE["updated_at"] < BALANCE_REFRESH_SECONDS: + return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"] + try: + funding = _fetch_usdt_by_types(["funding"]) + trading = _fetch_usdt_by_types(["swap", "trading", "spot"]) + ACCOUNT_BALANCE_CACHE["funding_usdt"] = funding + ACCOUNT_BALANCE_CACHE["trading_usdt"] = trading + ACCOUNT_BALANCE_CACHE["updated_at"] = now_ts + except Exception: + pass + return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"] + + +def execute_transfer_usdt(amount, from_account, to_account): + if amount <= 0: + return False, "划转金额必须大于0", None + ok_live, reason = ensure_okx_live_ready() + if not ok_live: + return False, reason, None + try: + resp = exchange.transfer(TRANSFER_CCY, float(amount), from_account, to_account) + return True, "划转成功", resp + except Exception as e: + return False, str(e), None + + +def get_account_usdt_total(account_type): + try: + bal = exchange.fetch_balance(params={"type": account_type}) + return _extract_usdt_total(bal) + except Exception: + return None + + +def auto_transfer_once_per_day(): + if not AUTO_TRANSFER_ENABLED: + return + utc_dt = utc_now_dt() + bj = utc_dt.astimezone(APP_TZ) + if bj.hour != AUTO_TRANSFER_BJ_HOUR: + return + transfer_day = utc_calendar_date_str() + conn = get_db() + exists = conn.execute( + "SELECT id FROM transfer_logs WHERE transfer_type=? AND transfer_day=?", + ("auto_daily", transfer_day) + ).fetchone() + if exists: + conn.close() + return + target_amount = AUTO_TRANSFER_AMOUNT + to_balance = get_account_usdt_total(AUTO_TRANSFER_TO) + from_balance = get_account_usdt_total(AUTO_TRANSFER_FROM) + if to_balance is None: + conn.execute( + "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", + ("auto_daily", transfer_day, 0, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO, "failed", f"读取{AUTO_TRANSFER_TO}账户USDT失败") + ) + conn.commit() + conn.close() + return + needed = round(max(target_amount - float(to_balance), 0), 4) + if needed <= 0: + conn.execute( + "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", + ("auto_daily", transfer_day, 0, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO, "skipped", f"{AUTO_TRANSFER_TO}账户已达到目标{target_amount}U") + ) + conn.commit() + conn.close() + return + if from_balance is not None and from_balance < needed: + conn.execute( + "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", + ("auto_daily", transfer_day, needed, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO, "failed", f"{AUTO_TRANSFER_FROM}账户USDT不足,需{needed}U,当前{round(from_balance,4)}U") + ) + conn.commit() + conn.close() + send_wechat_msg( + f"自动划转失败:{AUTO_TRANSFER_FROM}余额不足,需{needed}U,当前{round(from_balance,4)}U\n" + f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" + ) + return + + ok, msg, _ = execute_transfer_usdt(needed, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO) + conn.execute( + "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", + ("auto_daily", transfer_day, needed, AUTO_TRANSFER_FROM, AUTO_TRANSFER_TO, "success" if ok else "failed", msg[:500]) + ) + conn.commit() + conn.close() + if ok: + send_wechat_msg( + f"自动划转成功:补足到{target_amount}U,实际划转{needed}U " + f"{AUTO_TRANSFER_FROM}->{AUTO_TRANSFER_TO}\n" + f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" + ) + else: + send_wechat_msg( + f"自动划转失败:计划补足到{target_amount}U,需划转{needed}U\n原因:{msg}\n" + f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" + ) + + +def precheck_risk(conn, symbol, direction): + now = app_now() + if now.hour < TRADING_DAY_RESET_HOUR: + return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓" + active_count = conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0] + if active_count > 0: + return False, "一次只能持有一个仓位" + if direction not in ("long", "short"): + return False, "方向必须为 long 或 short" + if symbol.upper().startswith("BTC") or symbol.upper().startswith("ETH"): + expected = BTC_LEVERAGE + else: + expected = ALT_LEVERAGE + if expected <= 0: + return False, "杠杆配置异常" + return True, "" + + +def prepare_order_amount(exchange_symbol, margin_capital, leverage, fallback_price): + ensure_markets_loaded() + notional = float(margin_capital) * float(leverage) + ticker = exchange.fetch_ticker(exchange_symbol) + price = float(ticker.get("last") or fallback_price) + if price <= 0: + raise ValueError("触发价必须大于 0") + market = exchange.market(exchange_symbol) + contract_size = float(market.get("contractSize") or 1) + if market.get("contract"): + # OKX 永续 amount 是“张数”,需要按合约面值换算 + amount = notional / (price * contract_size) + else: + amount = notional / price + min_amount = (market.get("limits", {}).get("amount", {}) or {}).get("min") + if min_amount and amount < float(min_amount): + raise ValueError(f"下单数量过小,最小数量为 {min_amount}") + amount_precise = float(exchange.amount_to_precision(exchange_symbol, amount)) + if amount_precise <= 0: + raise ValueError("下单数量精度后为 0,请提高基数或降低价格") + return amount_precise, price + + +def _to_positive_float(value): + try: + n = float(value) + return n if n > 0 else None + except Exception: + return None + + +def _extract_order_price_value(order_obj): + if not isinstance(order_obj, dict): + return None + for key in ("average", "price"): + v = _to_positive_float(order_obj.get(key)) + if v is not None: + return v + cost = _to_positive_float(order_obj.get("cost")) + filled = _to_positive_float(order_obj.get("filled")) + if cost is not None and filled is not None and filled > 0: + return cost / filled + info = order_obj.get("info") if isinstance(order_obj.get("info"), dict) else {} + for key in ("avgPx", "fillPx", "avgPrice", "fillPrice", "px"): + v = _to_positive_float(info.get(key)) + if v is not None: + return v + return None + + +def resolve_order_entry_price(order_resp, exchange_symbol, fallback_price): + price = _extract_order_price_value(order_resp) + if price is not None: + return round(price, 8) + order_id = (order_resp or {}).get("id") + if order_id: + try: + fetched = exchange.fetch_order(order_id, exchange_symbol) + fetched_price = _extract_order_price_value(fetched) + if fetched_price is not None: + return round(fetched_price, 8) + except Exception: + pass + fallback = _to_positive_float(fallback_price) + return round(fallback, 8) if fallback is not None else 0.0 + + +def get_contract_size(exchange_symbol): + ensure_markets_loaded() + market = exchange.market(exchange_symbol) + return float(market.get("contractSize") or 1) + + +def parse_positive_float(value): + if value is None: + return None + raw = str(value).strip() + if not raw: + return None + num = float(raw) + if num <= 0: + raise ValueError("数值必须大于0") + return num + + +def build_okx_order_params(direction, reduce_only=False): + params = {"tdMode": OKX_TD_MODE} + if OKX_POS_MODE == "hedge": + params["posSide"] = "long" if direction == "long" else "short" + if reduce_only: + params["reduceOnly"] = True + return params + + +def ensure_markets_loaded(force=False): + global MARKETS_LOADED + if force or not MARKETS_LOADED: + exchange.load_markets(reload=force) + MARKETS_LOADED = True + + +def _okx_algo_trigger_price_str(exchange_symbol, price): + """OKX attachAlgoOrds 触发价须为按合约 tick 格式化的十进制字符串;直接用 str(float) 低价币会得到科学计数法(如 8.5e-06),会报 tpTriggerPx/slTriggerPx 参数错误。""" + ensure_markets_loaded() + return exchange.price_to_precision(exchange_symbol, float(price)) + + +def place_exchange_order(exchange_symbol, direction, amount, leverage, stop_loss=None, take_profit=None): + ensure_markets_loaded() + exchange.set_leverage(leverage, exchange_symbol) + side = "buy" if direction == "long" else "sell" + params = build_okx_order_params(direction, reduce_only=False) + if stop_loss and take_profit: + params["attachAlgoOrds"] = [{ + "tpTriggerPx": _okx_algo_trigger_price_str(exchange_symbol, take_profit), + "tpOrdPx": "-1", + "slTriggerPx": _okx_algo_trigger_price_str(exchange_symbol, stop_loss), + "slOrdPx": "-1" + }] + try: + order = exchange.create_order(exchange_symbol, "market", side, amount, None, params) + order["tpsl_attached"] = bool(stop_loss and take_profit) + return order + except Exception as e: + if stop_loss and take_profit: + raise RuntimeError(f"交易所未接受止盈止损挂单参数,已拒绝开仓:{str(e)}") + raise + + +def close_exchange_order(order_row): + ensure_markets_loaded() + exchange_symbol = order_row["exchange_symbol"] or normalize_okx_symbol(order_row["symbol"]) + amount = float(order_row["order_amount"] or 0) + if amount <= 0: + raise ValueError("平仓失败:缺少有效下单数量") + direction = order_row["direction"] + side = "sell" if direction == "long" else "buy" + params = build_okx_order_params(direction, reduce_only=True) + return exchange.create_order(exchange_symbol, "market", side, amount, None, params) + + +def cancel_okx_swap_open_orders(exchange_symbol): + ok, _ = ensure_okx_live_ready() + if not ok or not exchange_symbol: + return + ensure_markets_loaded() + try: + exchange.cancel_all_orders(exchange_symbol) + except Exception: + pass + try: + for o in exchange.fetch_open_orders(exchange_symbol) or []: + oid = o.get("id") + if oid is None: + continue + try: + exchange.cancel_order(str(oid), exchange_symbol) + except Exception: + pass + except Exception: + pass + + +def _okx_place_tp_sl_orders(exchange_symbol, direction, amount, stop_loss, take_profit): + ensure_markets_loaded() + close_side = "sell" if direction == "long" else "buy" + amt = float(exchange.amount_to_precision(exchange_symbol, float(amount))) + if amt <= 0: + raise RuntimeError("止盈止损:可平数量经精度舍入后为 0") + params = build_okx_order_params(direction, reduce_only=True) + params["stopLoss"] = { + "triggerPrice": _okx_algo_trigger_price_str(exchange_symbol, stop_loss), + "type": "market", + } + params["takeProfit"] = { + "triggerPrice": _okx_algo_trigger_price_str(exchange_symbol, take_profit), + "type": "market", + } + exchange.create_order(exchange_symbol, "market", close_side, amt, None, params) + + +def replace_active_monitor_tpsl_on_exchange(order_row, stop_loss, take_profit): + """先撤该合约挂单/条件单,再按新价重挂 TP/SL。""" + ok, reason = ensure_okx_live_ready() + if not ok: + raise RuntimeError(reason or "实盘未就绪") + ex_sym = resolve_monitor_exchange_symbol(order_row) + direction = order_row["direction"] + cancel_okx_swap_open_orders(ex_sym) + pos_amt = get_live_position_contracts(ex_sym, direction) + if pos_amt is None or float(pos_amt) <= 0: + try: + pos_amt = float(order_row["order_amount"] or 0) + except (TypeError, ValueError): + pos_amt = 0 + if float(pos_amt or 0) <= 0: + raise ValueError("交易所当前无该方向持仓,无法挂止盈止损") + _okx_place_tp_sl_orders(ex_sym, direction, float(pos_amt), float(stop_loss), float(take_profit)) + + +def extract_trade_price_from_order(order): + if not order: + return None + for k in ("average", "avgPrice", "price"): + try: + v = float(order.get(k) or 0) + if v > 0: + return v + except Exception: + pass + try: + info = order.get("info") or {} + if isinstance(info, dict): + for k in ("fillPx", "avgPx", "fill_price"): + v = float(info.get(k) or 0) + if v > 0: + return v + except Exception: + pass + return None + + +def is_no_position_error(err_msg): + msg = (err_msg or "").lower() + keywords = [ + "no position", "position does not exist", "position not exist", + "pos size is 0", "nothing to close", "reduceonly", "51008" + ] + return any(k in msg for k in keywords) + + +def get_live_position_contracts(exchange_symbol, direction): + ensure_markets_loaded() + try: + rows = exchange.fetch_positions([exchange_symbol], params={"instType": OKX_POSITION_INST_TYPE}) + except Exception: + return None + total = 0.0 + for p in rows: + if p.get("symbol") != exchange_symbol: + continue + info = p.get("info", {}) or {} + side = (p.get("side") or info.get("posSide") or "").lower() + contracts = p.get("contracts") + if contracts is None: + raw_pos = info.get("pos") + try: + contracts = abs(float(raw_pos)) if raw_pos is not None else 0.0 + except Exception: + contracts = 0.0 + try: + contracts = float(contracts) + except Exception: + contracts = 0.0 + if contracts <= 0: + continue + if OKX_POS_MODE == "hedge": + if side and side != direction: + continue + total += contracts + return total + + +def opened_at_str_to_ms(opened_at_str): + if not opened_at_str: + return None + try: + dt = datetime.strptime(str(opened_at_str).strip()[:19], "%Y-%m-%d %H:%M:%S") + except ValueError: + return None + try: + aware = dt.replace(tzinfo=APP_TZ) + return int(aware.timestamp() * 1000) + except Exception: + return None + + +def _to_ms_with_fallback(ms_value, dt_str): + try: + if ms_value is not None and str(ms_value).strip() != "": + v = int(float(ms_value)) + if v > 0: + return v + except Exception: + pass + return opened_at_str_to_ms(dt_str) + + +def ms_to_app_local_str(ms): + if ms is None: + return app_now_str() + try: + dt = datetime.fromtimestamp(ms / 1000.0, tz=timezone.utc).astimezone(APP_TZ) + return dt.replace(tzinfo=None).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return app_now_str() + + +def classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_price): + """根据成交价相对止盈/止损位归类;无法可靠归类时返回 None。""" + try: + tp = float(take_profit) + sl = float(stop_loss) + ex = float(exit_price) + trig = float(trigger_price) + except (TypeError, ValueError): + return None + band = max(abs(trig) * 0.0008, abs(tp - sl) * 0.003, 1e-12) + if direction == "long": + if ex >= tp - band: + return "止盈" + if ex <= sl + band: + return "止损" + else: + if ex <= tp + band: + return "止盈" + if ex >= sl - band: + return "止损" + return None + + +def fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=None): + """取开仓以来最近一笔减仓成交(与方向一致);失败返回 None。""" + if not (OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE): + return None + ensure_markets_loaded() + since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) + close_side = "sell" if direction == "long" else "buy" + + def pick_from_trades(trades): + if not trades: + return None + candidates = [] + for t in trades: + if (t.get("side") or "").lower() != close_side: + continue + info = t.get("info") or {} + if not isinstance(info, dict): + info = {} + pos_side = (info.get("posSide") or t.get("posSide") or "").lower() + if OKX_POS_MODE == "hedge": + if pos_side in ("long", "short") and pos_side != direction: + continue + ts = t.get("timestamp") + if ts is None: + continue + candidates.append(t) + if not candidates: + return None + return max(candidates, key=lambda x: x.get("timestamp") or 0) + + try: + trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=100) + hit = pick_from_trades(trades) + if hit is None and since_ms: + trades = exchange.fetch_my_trades(exchange_symbol, since=None, limit=100) + hit = pick_from_trades(trades) + return hit + except Exception: + return None + + +def fetch_closing_fills_for_record(exchange_symbol, direction, opened_at_str, closed_at_str=None, opened_at_ms=None, closed_at_ms=None): + """ + 拉取某条历史记录对应的减仓成交(用于按 id 回填)。 + 返回按时间排序的成交列表。 + """ + if not (OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE): + return [] + ensure_markets_loaded() + since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) + close_side = "sell" if direction == "long" else "buy" + closed_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str) if (closed_at_str or closed_at_ms is not None) else None + # 历史记录回填给一点缓冲,兼容成交落在记录时间附近的情况 + if closed_ms is not None: + closed_ms += 6 * 60 * 60 * 1000 + candidates = [] + all_side_candidates = [] + try: + trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=200) + except Exception: + trades = [] + if not trades and since_ms: + try: + trades = exchange.fetch_my_trades(exchange_symbol, since=None, limit=200) + except Exception: + trades = [] + for t in trades or []: + if (t.get("side") or "").lower() != close_side: + continue + ts = t.get("timestamp") + if ts is None: + continue + try: + ts = int(ts) + except Exception: + continue + if since_ms and ts < since_ms: + continue + if closed_ms and ts > closed_ms: + continue + info = t.get("info") or {} + if not isinstance(info, dict): + info = {} + pos_side = (info.get("posSide") or t.get("posSide") or "").lower() + if OKX_POS_MODE == "hedge": + if pos_side in ("long", "short") and pos_side != direction: + continue + all_side_candidates.append(t) + if since_ms and ts < since_ms: + continue + if closed_ms and ts > closed_ms: + continue + candidates.append(t) + candidates.sort(key=lambda x: x.get("timestamp") or 0) + if candidates: + return candidates + + # 严格窗口为空时,降级为“按平仓时间就近匹配”,降低时区/时间误差导致的回填失败。 + all_side_candidates.sort(key=lambda x: x.get("timestamp") or 0) + if not all_side_candidates: + return [] + if not closed_ms: + return all_side_candidates[-20:] + near = [] + for t in all_side_candidates: + ts = t.get("timestamp") + if ts is None: + continue + try: + delta = abs(int(ts) - int(closed_ms)) + except Exception: + continue + # 放宽到前后 7 天 + if delta <= 7 * 24 * 60 * 60 * 1000: + near.append((delta, t)) + if near: + near.sort(key=lambda x: x[0]) + picked = [x[1] for x in near[:20]] + picked.sort(key=lambda x: x.get("timestamp") or 0) + return picked + return all_side_candidates[-20:] + + +def calc_weighted_exit_price(trades): + if not trades: + return None + total_amount = 0.0 + weighted_sum = 0.0 + for t in trades: + try: + price = float(t.get("price") or 0) + amount = float(t.get("amount") or 0) + except Exception: + continue + if price <= 0: + continue + if amount <= 0: + amount = 1.0 + weighted_sum += price * amount + total_amount += amount + if total_amount <= 0: + return None + return weighted_sum / total_amount + + +def resolve_synced_flat_close(row, opened_at_str, opened_at_ms=None): + """ + 交易所已无仓、本地仍为 active 时,推断平仓类型/时间/盈亏。 + 返回 (result, pnl_amount, closed_at_str, miss_reason)。 + """ + direction = row["direction"] + sym = row["symbol"] + trigger_price = row["trigger_price"] + stop_loss = row["stop_loss"] + take_profit = row["take_profit"] + margin_capital = row["margin_capital"] or DAILY_START_CAPITAL + leverage = row["leverage"] or infer_leverage(sym) + exchange_symbol = row["exchange_symbol"] or normalize_okx_symbol(sym) + + trade = fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=opened_at_ms) + exit_px = None + closed_at_str = app_now_str() + if trade: + try: + exit_px = float(trade.get("price") or 0) or None + except (TypeError, ValueError): + exit_px = None + ts = trade.get("timestamp") + if ts: + closed_at_str = ms_to_app_local_str(int(ts)) + + if exit_px is None or exit_px <= 0: + p = get_price(sym) + if p: + guessed = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, p) + if guessed: + pnl = calc_pnl(direction, trigger_price, p, margin_capital, leverage) + return ( + guessed, + pnl, + closed_at_str, + "未能拉取成交明细,按当前市价与止盈/止损位近似归类(建议核对交易所账单)", + ) + return ( + "外部平仓", + 0.0, + closed_at_str, + "检测到交易所仓位已关闭,且无法从成交记录还原平仓价", + ) + + result = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_px) + pnl = calc_pnl(direction, trigger_price, exit_px, margin_capital, leverage) + if result: + return ( + result, + pnl, + closed_at_str, + "按交易所成交记录同步为止盈/止损平仓", + ) + return ( + "外部平仓", + pnl, + closed_at_str, + "交易所已平仓,成交价不在计划止盈/止损带内(可能为手动或其他类型平仓)", + ) + + +def reconcile_external_closes(conn, days=None): + synced_count = 0 + cutoff_ms = None + if days is not None: + try: + d = int(days) + if d > 0: + cutoff_ms = int((app_now() - timedelta(days=d)).timestamp() * 1000) + except Exception: + cutoff_ms = None + rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() + for r in rows: + if cutoff_ms is not None: + opened_at_v = get_opened_at_value(r) + opened_ms = _to_ms_with_fallback(r["opened_at_ms"] if "opened_at_ms" in r.keys() else None, opened_at_v) + # 手动同步按最近 N 天过滤,避免把更早历史单误同步进来 + if opened_ms is None or opened_ms < cutoff_ms: + continue + exchange_symbol = r["exchange_symbol"] or normalize_okx_symbol(r["symbol"]) + live_contracts = get_live_position_contracts(exchange_symbol, r["direction"]) + if live_contracts is None: + continue + if live_contracts > 0: + continue + opened_at = get_opened_at_value(r) + opened_at_ms = _to_ms_with_fallback(r["opened_at_ms"] if "opened_at_ms" in r.keys() else None, opened_at) + result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close(r, opened_at, opened_at_ms=opened_at_ms) + closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now() + hold_seconds = calc_hold_seconds(opened_at, closed_at_dt) + session_date = r["session_date"] or get_trading_day(closed_at_dt) + update_session_capital(conn, session_date, pnl_amount) + insert_trade_record( + conn, + symbol=r["symbol"], + monitor_type="下单监控", + direction=r["direction"], + trigger_price=r["trigger_price"], + stop_loss=r["stop_loss"], + initial_stop_loss=r["initial_stop_loss"] or r["stop_loss"], + take_profit=r["take_profit"], + margin_capital=r["margin_capital"], + leverage=r["leverage"], + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=r["trade_style"], + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio(r["direction"], r["trigger_price"], r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]), + actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), + result=result, + miss_reason=miss_reason, + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (r["id"],)) + if result in ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓"): + send_wechat_msg( + build_wechat_close_message( + symbol=r["symbol"], + direction=r["direction"], + result=f"{result}(自动同步)", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=r["trigger_price"], + current_price="-", + stop_loss=r["stop_loss"], + take_profit=r["take_profit"], + close_order_id="-", + extra_note=miss_reason, + ) + ) + else: + send_wechat_msg( + build_wechat_close_message( + symbol=r["symbol"], + direction=r["direction"], + result="外部平仓(自动同步)", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=r["trigger_price"], + current_price="-", + stop_loss=r["stop_loss"], + take_profit=r["take_profit"], + close_order_id="-", + extra_note=miss_reason, + ) + ) + synced_count += 1 + return synced_count + +# 获取实时价格 +def get_price(symbol): + try: + ensure_markets_loaded() + return exchange.fetch_ticker(normalize_okx_symbol(symbol))["last"] + except: + return None + +# 获取5分钟K线收盘价 +def get_5m_close(symbol): + try: + ensure_markets_loaded() + ohlcv = exchange.fetch_ohlcv(normalize_okx_symbol(symbol), KLINE_TIMEFRAME, limit=1) + return ohlcv[-1][4] if ohlcv else None + except: + return None + + +def _safe_float(v): + try: + return float(v) + except Exception: + return None + + +def _compute_ema(values, period=55): + arr = [float(x) for x in values if x is not None] + if len(arr) < period: + return None + k = 2.0 / (period + 1.0) + ema = arr[0] + for val in arr[1:]: + ema = val * k + ema * (1 - k) + return ema + + +def _status_by_ema55(symbol, timeframe): + try: + bars = exchange.fetch_ohlcv(normalize_okx_symbol(symbol), timeframe=timeframe, limit=80) + if not bars or len(bars) < 56: + return "横盘", None, None + closes = [float(x[4]) for x in bars if x and len(x) >= 5] + ema55 = _compute_ema(closes, 55) + last_close = closes[-1] + if ema55 is None or last_close <= 0: + return "横盘", last_close, ema55 + diff_pct = (last_close - ema55) / ema55 * 100.0 + if abs(diff_pct) < 0.1: + return "横盘", last_close, ema55 + return ("多头" if diff_pct > 0 else "空头"), last_close, ema55 + except Exception: + return "横盘", None, None + + +def _daily_volume_rank(symbol): + """ + 返回(symbol_rank, total_count),按 quoteVolume 降序,缺失时 fallback 到 baseVolume*last。 + """ + sym_norm = normalize_symbol_input(symbol) + target_base = journal_coin_from_symbol(sym_norm) + + def _ticker_base(sym_text): + s = str(sym_text or "").upper().strip() + if ":" in s: + s = s.split(":", 1)[0] + if "/" in s: + return s.split("/", 1)[0].strip() + if "-" in s: + return s.split("-", 1)[0].strip() + if s.endswith("USDT"): + return s[:-4].strip() + return s + now_ts = time.time() + cached_ok = ( + LIQUIDITY_RANK_CACHE["updated_at"] + and now_ts - float(LIQUIDITY_RANK_CACHE["updated_at"]) < max(30, BALANCE_REFRESH_SECONDS) + ) + if not cached_ok: + try: + ensure_markets_loaded() + try: + tickers = exchange.fetch_tickers(params={"instType": "SWAP"}) + except Exception: + tickers = exchange.fetch_tickers() + scored = [] + for s, t in (tickers or {}).items(): + try: + su = str(s).upper() + if "USDT" not in su: + continue + qv = _safe_float((t or {}).get("quoteVolume")) + if qv is None: + info = (t or {}).get("info") if isinstance((t or {}).get("info"), dict) else {} + qv = _safe_float(info.get("volCcy24h") or info.get("vol24h")) + if qv is None: + bv = _safe_float((t or {}).get("baseVolume")) + lp = _safe_float((t or {}).get("last")) + if bv is not None and lp is not None: + qv = bv * lp + if qv is None or qv <= 0: + continue + scored.append((_ticker_base(s), float(qv))) + except Exception: + continue + scored.sort(key=lambda x: x[1], reverse=True) + ranks = {} + for idx, (base, _) in enumerate(scored, 1): + if base and base not in ranks: + ranks[base] = idx + LIQUIDITY_RANK_CACHE["ranks"] = ranks + LIQUIDITY_RANK_CACHE["total"] = len(scored) + LIQUIDITY_RANK_CACHE["updated_at"] = now_ts + except Exception: + pass + ranks = LIQUIDITY_RANK_CACHE.get("ranks") or {} + total = int(LIQUIDITY_RANK_CACHE.get("total") or 0) + return ranks.get(target_base), total + + +def _key_hard_checks(symbol, direction, upper, lower, monitor_type): + """ + 关键位门控:量能、突破幅度、第二根确认、日成交量前30。 + 使用最近闭合K:breakout=倒数第2根,confirm=倒数第1根。 + """ + out = {"ok": False} + ex_sym = normalize_okx_symbol(symbol) + bars = exchange.fetch_ohlcv(ex_sym, timeframe=KLINE_TIMEFRAME, limit=80) or [] + if len(bars) < 24: + out["reason"] = "5m K线数量不足" + return out + closed = bars[:-1] if len(bars) >= 3 else bars + if len(closed) < 23: + out["reason"] = "闭合K线不足" + return out + breakout = closed[-2] + confirm = closed[-1] + prev20 = closed[-22:-2] + avg20 = sum(float(x[5]) for x in prev20) / max(len(prev20), 1) + vol_break = float(breakout[5]) + vol_ok = vol_break > avg20 * 1.3 if avg20 > 0 else False + open_b = float(breakout[1]) + close_b = float(breakout[4]) + high_b = float(breakout[2]) + low_b = float(breakout[3]) + amp_pct = abs(close_b - open_b) / open_b * 100 if open_b > 0 else 0 + amp_ok = (amp_pct > 0.03) and (amp_pct < 0.5) + cfm_close = float(confirm[4]) + # 区间极值点严格以前端录入 upper/lower 为准:做多看上沿,做空看下沿 + edge = float(upper) if direction == "long" else float(lower) + breakout_ok = (close_b > float(upper)) if direction == "long" else (close_b < float(lower)) + confirm_ok_raw = (cfm_close > edge) if direction == "long" else (cfm_close < edge) + # 口径收紧:未发生有效突破时,不标记幅度/二确通过,避免出现“还没到位却显示Y” + amp_ok = amp_ok and breakout_ok + confirm_ok = confirm_ok_raw and breakout_ok + rank, total = _daily_volume_rank(symbol) + rank_ok = (rank is not None) and (rank <= 30) + swing4h_pct = 0.0 + try: + seg48 = closed[-48:] if len(closed) >= 48 else closed + hh = max(float(x[2]) for x in seg48) + ll = min(float(x[3]) for x in seg48) + swing4h_pct = ((hh - ll) / ll * 100.0) if ll > 0 else 0.0 + except Exception: + swing4h_pct = 0.0 + out.update( + { + "ok": all([vol_ok, amp_ok, breakout_ok, confirm_ok, rank_ok]), + "vol_ok": vol_ok, + "avg20": avg20, + "vol_break": vol_break, + "amp_ok": amp_ok, + "amp_pct": amp_pct, + "breakout_ok": breakout_ok, + "breakout_close": close_b, + "confirm_ok": confirm_ok, + "confirm_close": cfm_close, + "edge_price": edge, + "rank": rank, + "rank_total": total, + "rank_ok": rank_ok, + "breakout_high": high_b, + "breakout_low": low_b, + "breakout_ts": breakout[0], + "confirm_ts": confirm[0], + "swing4h_pct": swing4h_pct, + "monitor_type": monitor_type, + "direction": direction, + } + ) + return out + + +def _key_plan_sl_tp_for_row(row, direction, upper, lower, checks): + mode = sl_tp_mode_from_row(row, "standard") + manual_tp = _sqlite_row_val(row, "manual_take_profit") + return plan_key_sl_tp( + mode, + direction, + upper, + lower, + checks, + outside_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT, + trend_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT, + manual_take_profit=manual_tp, + ), mode + + +def calc_price_diff_pct(current_price, target_price): + try: + if target_price is None: + return None, None + t = float(target_price) + if t == 0: + return None, None + c = float(current_price) + diff = c - t + pct = diff / t * 100 + return round(diff, 6), round(pct, 4) + except Exception: + return None, None + + + +def _coerce_float(*values): + for v in values: + if v is None: + continue + try: + f = float(v) + if f > 0: + return f + except (TypeError, ValueError): + continue + return None + + +def _sqlite_row_val(row, key, default=None): + try: + v = row[key] + return default if v is None else v + except (KeyError, IndexError, TypeError): + return default + + +def get_active_position_count(conn): + return int(conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0]) + + +def get_key_sizing_capital_snapshot(conn, session_date): + row = conn.execute( + "SELECT key_sizing_capital_snapshot FROM trading_sessions WHERE session_date=?", + (session_date,), + ).fetchone() + if not row: + return None + try: + v = row["key_sizing_capital_snapshot"] + return float(v) if v is not None else None + except (TypeError, ValueError, KeyError): + return None + + +def set_key_sizing_capital_snapshot(conn, session_date, capital): + ensure_session(conn, session_date) + conn.execute( + "UPDATE trading_sessions SET key_sizing_capital_snapshot = ?, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?", + (round(float(capital), 4), session_date), + ) + conn.commit() + + +def resolve_capital_base_for_key_open(conn, trading_day, live_capital): + live = float(live_capital) + active = get_active_position_count(conn) + if active <= 0: + set_key_sizing_capital_snapshot(conn, trading_day, live) + return live + if KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT: + snap = get_key_sizing_capital_snapshot(conn, trading_day) + if snap is not None and snap > 0: + return snap + return live + + +def _finalize_key_monitor_one_shot(conn, row, last_msg, close_reason): + n = int(row["notification_count"] or 0) + 1 + insert_key_monitor_history(conn, row, n, last_msg, close_reason) + conn.execute("DELETE FROM key_monitors WHERE id=?", (row["id"],)) + + +def get_symbol_mark_price(symbol): + """斐波失效判定用标记价。""" + ex_sym = normalize_okx_symbol(symbol) + try: + ensure_markets_loaded() + ticker = exchange.fetch_ticker(ex_sym) + m = _coerce_float(ticker.get("mark"), ticker.get("last")) + if m is None: + info = ticker.get("info") or {} + m = _coerce_float(info.get("markPx"), info.get("last")) + if m is not None: + return float(m) + except Exception: + pass + p = get_price(symbol) + return float(p) if p is not None else None + + +def cancel_fib_limit_order(exchange_symbol, order_id): + if not order_id: + return False + ok_live, _ = ensure_okx_live_ready() + if not ok_live: + return False + ensure_markets_loaded() + oid = str(order_id) + try: + exchange.cancel_order(oid, exchange_symbol) + return True + except Exception: + pass + try: + for o in exchange.fetch_open_orders(exchange_symbol) or []: + if str(o.get("id")) == oid: + exchange.cancel_order(oid, exchange_symbol) + return True + except Exception: + pass + return False + + +def fib_limit_order_status(exchange_symbol, order_id): + if not order_id: + return "missing" + ensure_markets_loaded() + oid = str(order_id) + try: + o = exchange.fetch_order(oid, exchange_symbol) + st = (o.get("status") or "").lower() + if st in ("closed", "filled"): + filled = float(o.get("filled") or 0) + if filled > 0 or st == "filled": + return "filled" + if st in ("canceled", "cancelled", "expired", "rejected"): + return "canceled" + if st in ("open", "new", "partially_filled", "live"): + return "open" + except Exception: + pass + try: + for o in exchange.fetch_open_orders(exchange_symbol) or []: + if str(o.get("id")) == oid: + return "open" + except Exception: + pass + return "unknown" + + +def place_fib_limit_order(exchange_symbol, direction, amount, leverage, limit_price): + ensure_markets_loaded() + exchange.set_leverage(leverage, exchange_symbol) + side = "buy" if direction == "long" else "sell" + price = round_price_to_exchange(exchange_symbol, float(limit_price)) + if price is None or price <= 0: + raise ValueError("挂单价无效") + params = build_okx_order_params(direction, reduce_only=False) + return exchange.create_order(exchange_symbol, "limit", side, amount, price, params) + + +def _fib_key_exists_for_symbol(conn, symbol): + ph = ",".join("?" * len(FIB_KEY_MONITOR_TYPES)) + row = conn.execute( + f"SELECT id FROM key_monitors WHERE symbol=? AND monitor_type IN ({ph})", + (symbol, *tuple(FIB_KEY_MONITOR_TYPES)), + ).fetchone() + return row is not None + + +def _fib_plan_for_row(row): + typ = (row["monitor_type"] or "").strip() + ratio = fib_ratio_from_type(typ) + if ratio is None: + return None + return calc_fib_plan(row["direction"], row["upper"], row["lower"], ratio) + + +def _cancel_fib_monitor_limit(row): + ex_sym = normalize_okx_symbol(row["symbol"]) + oid = _sqlite_row_val(row, "fib_limit_order_id") + if oid: + cancel_fib_limit_order(ex_sym, oid) + + +def _fib_has_live_position(exchange_symbol, direction): + live = get_live_position_contracts(exchange_symbol, direction) + return live is not None and float(live) > 0 + + +def _insert_order_monitor_from_fib_fill( + conn, + row, + trigger_price, + stop_loss, + take_profit, + amount, + leverage, + margin_capital, + notional_value, + position_ratio, + base_amount, + exchange_order_id, +): + symbol = row["symbol"] + direction = (row["direction"] or "long").lower() + exchange_symbol = normalize_okx_symbol(symbol) + typ = (row["monitor_type"] or "").strip() + now = app_now() + trading_day = get_trading_day(now) + trade_style = (DEFAULT_TRADE_STYLE or "trend").strip().lower() + if trade_style not in ("trend", "swing"): + trade_style = "trend" + risk_percent = max(0.01, float(RISK_PERCENT)) + risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage) + if risk_amount_final is None: + risk_amount_final = round(float(margin_capital) * risk_percent / 100.0, 4) + breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER) + breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT) + breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0 + if direction == "short": + breakeven_raw = float(trigger_price) * (1 - breakeven_offset_pct / 100.0) + else: + breakeven_raw = float(trigger_price) * (1 + breakeven_offset_pct / 100.0) + breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw) + opened_at_bj = app_now_str() + opened_at_ms = _to_ms_with_fallback(None, opened_at_bj) + conn.execute( + "INSERT INTO order_monitors " + "(symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, " + "margin_capital, leverage, trade_style, risk_percent, risk_amount, " + "breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, " + "notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type, key_signal_type) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + exchange_symbol, + direction, + trigger_price, + stop_loss, + stop_loss, + take_profit, + margin_capital, + leverage, + trade_style, + risk_percent, + risk_amount_final, + breakeven_rr_trigger, + breakeven_offset_pct, + breakeven_step_r, + 0, + breakeven_price, + 1 if breakeven_enabled_from_row(row, 0) else 0, + notional_value, + position_ratio, + base_amount, + amount, + exchange_order_id or "", + opened_at_bj, + opened_at_ms, + trading_day, + ORDER_MONITOR_TYPE_KEY_AUTO, + stored_key_signal_type(typ), + ), + ) + return int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + + +def _finalize_fib_key_fill(conn, row): + symbol = row["symbol"] + direction = (row["direction"] or "long").lower() + typ = (row["monitor_type"] or "").strip() + ex_sym = normalize_okx_symbol(symbol) + plan = _fib_plan_for_row(row) + if not plan: + _finalize_key_monitor_one_shot(conn, row, "斐波计划无效", "fib_plan_invalid") + return + entry_plan, sl_plan, tp_plan = plan + sl = float(_sqlite_row_val(row, "fib_stop_loss", sl_plan) or sl_plan) + tp = float(_sqlite_row_val(row, "fib_take_profit", tp_plan) or tp_plan) + sl_adj = round_price_to_exchange(ex_sym, sl) + tp_adj = round_price_to_exchange(ex_sym, tp) + if sl_adj is not None: + sl = float(sl_adj) + if tp_adj is not None: + tp = float(tp_adj) + amount = float(_sqlite_row_val(row, "fib_order_amount") or 0) + leverage = int(_sqlite_row_val(row, "fib_leverage") or infer_leverage(symbol) or 5) + margin_capital = float(_sqlite_row_val(row, "fib_margin_capital") or 0) + oid = _sqlite_row_val(row, "fib_limit_order_id") + entry_px = float(_sqlite_row_val(row, "fib_entry_price", entry_plan) or entry_plan) + trigger_price = entry_px + if oid: + try: + o = exchange.fetch_order(str(oid), ex_sym) + trigger_price = resolve_order_entry_price(o, ex_sym, entry_px) + except Exception: + pass + tr_adj = round_price_to_exchange(ex_sym, trigger_price) + if tr_adj is not None: + trigger_price = float(tr_adj) + if amount <= 0: + live_amt = get_live_position_contracts(ex_sym, direction) + amount = float(live_amt or 0) + if amount <= 0: + send_wechat_msg( + f"# ❌ {symbol} 斐波成交后处理失败\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 无法取得持仓/下单数量,未挂 TP/SL\n" + ) + return + ok, reason = precheck_risk(conn, symbol, direction) + if not ok: + send_wechat_msg( + f"# ❌ {symbol} 斐波成交后风控拒绝\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}\n" + f"- 原因:{reason}\n" + f"- 请手动处理仓位与挂单\n" + ) + return + tpsl_attached = False + try: + _okx_place_tp_sl_orders(ex_sym, direction, amount, sl, tp) + tpsl_attached = True + except Exception as e: + send_wechat_msg( + f"# ❌ {symbol} 斐波成交后挂 TP/SL 失败\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 错误:{friendly_okx_error(e)}\n" + f"- 请手动补挂止盈止损\n" + ) + return + contract_size = get_contract_size(ex_sym) + base_amount = round(float(amount) * contract_size, 8) + notional_value = round(float(margin_capital) * leverage, 4) if margin_capital else 0 + session_row = ensure_session(conn, get_trading_day(app_now())) + capital_base = float(session_row["current_capital"] or 0) + position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base and margin_capital else 0 + planned_rr = calc_rr_ratio(direction, trigger_price, sl, tp) + new_order_id = _insert_order_monitor_from_fib_fill( + conn, + row, + trigger_price, + sl, + tp, + amount, + leverage, + margin_capital, + notional_value, + position_ratio, + base_amount, + oid, + ) + rr_txt = f"{planned_rr:.4f}" if planned_rr is not None else "-" + succ = ( + f"# ✅ {symbol} 斐波限价成交\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 来源:{ORDER_MONITOR_TYPE_KEY_AUTO}(限价 @ E)\n" + f"- 类型:{typ}|{_wechat_direction_text(direction)}\n" + f"- 订单 ID:**{new_order_id}**\n" + f"- 成交价:{format_price_for_symbol(symbol, trigger_price)}\n" + f"- 止损:{sl}|止盈:{format_price_for_symbol(symbol, tp)}\n" + f"- 计划 RR:{rr_txt}:1\n" + f"- {'已挂交易所 TP/SL' if tpsl_attached else 'TP/SL 未挂上'}\n" + ) + send_wechat_msg(succ) + _finalize_key_monitor_one_shot(conn, row, succ, "fib_filled") + + +def check_fib_key_monitors(): + conn = get_db() + rows = conn.execute("SELECT * FROM key_monitors").fetchall() + for r in rows: + typ = (r["monitor_type"] or "").strip() + if not is_fib_key_monitor_type(typ): + continue + symbol = r["symbol"] + direction = (r["direction"] or "long").lower() + ex_sym = normalize_okx_symbol(symbol) + up, low = float(r["upper"]), float(r["lower"]) + oid = _sqlite_row_val(r, "fib_limit_order_id") + mark = get_symbol_mark_price(symbol) + if mark is None: + continue + status = fib_limit_order_status(ex_sym, oid) if oid else "missing" + if status == "filled" or (status != "open" and _fib_has_live_position(ex_sym, direction)): + _finalize_fib_key_fill(conn, r) + continue + if status == "open": + if fib_invalidate_by_mark(direction, mark, up, low): + _cancel_fib_monitor_limit(r) + msg = ( + f"# ⚠️ {symbol} 斐波监控失效\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}|{_wechat_direction_text(direction)}\n" + f"- 标记价 {format_price_for_symbol(symbol, mark)} 已触达止盈侧(未成交),已撤限价单\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, "fib_invalidate") + continue + if status in ("canceled", "missing", "unknown") and fib_invalidate_by_mark(direction, mark, up, low): + msg = ( + f"# ⚠️ {symbol} 斐波监控失效(限价已不在挂单)\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 标记价触达止盈侧,本条已结案\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, "fib_invalidate") + conn.commit() + conn.close() + + +def _add_fib_key_monitor(conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=0): + if _fib_key_exists_for_symbol(conn, symbol): + return False, f"{symbol} 已有斐波监控(同币仅允许一条 0.618/0.786)" + ratio = fib_ratio_from_type(mt) + plan = calc_fib_plan(direction_sel, upper_px, lower_px, ratio) + if not plan: + return False, "斐波上下沿无效(需上沿 H > 下沿 L)" + entry, sl, tp = plan + ex_sym = normalize_okx_symbol(symbol) + entry = round_price_to_exchange(ex_sym, entry) + sl = round_price_to_exchange(ex_sym, sl) + tp = round_price_to_exchange(ex_sym, tp) + if entry is None or sl is None or tp is None: + return False, "斐波价位经交易所精度舍入后无效" + entry, sl, tp = float(entry), float(sl), float(tp) + planned_rr = calc_rr_ratio(direction_sel, entry, sl, tp) + if planned_rr is None or planned_rr <= KEY_AUTO_MIN_PLANNED_RR: + fmt_rr = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算" + return False, f"斐波计划盈亏比 {fmt_rr}:1 未达要求(>{KEY_AUTO_MIN_PLANNED_RR}:1)" + ok, reason = precheck_risk(conn, symbol, direction_sel) + if not ok: + return False, reason + ok_live, reason_live = ensure_okx_live_ready() + if not ok_live: + return False, reason_live + now = app_now() + trading_day = get_trading_day(now) + session_row = ensure_session(conn, trading_day) + _, trading_capital_live = get_exchange_capitals(force=True) + live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"]) + capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital) + default_leverage = get_synced_leverage(ex_sym, direction_sel) or infer_leverage(symbol) + leverage = int(default_leverage) if default_leverage else 5 + if leverage <= 0: + leverage = 5 + available_usdt = get_available_trading_usdt() + risk_fraction = calc_risk_fraction(direction_sel, entry, sl) + if risk_fraction is None: + return False, "止损方向不合法(相对挂单价 E);请核对上下沿与方向" + risk_percent = max(0.01, float(RISK_PERCENT)) + risk_amount = round(capital_base * risk_percent / 100.0, 4) + notional_value = round(risk_amount / risk_fraction, 4) + margin_capital = round(notional_value / leverage, 4) + if capital_base and margin_capital > capital_base: + return False, "以损定仓后保证金超过当前交易资金" + if available_usdt is not None: + max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4) + if margin_capital > max_margin: + return ( + False, + f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", + ) + try: + amount, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry) + order_resp = place_fib_limit_order(ex_sym, direction_sel, amount, leverage, entry) + oid = str(order_resp.get("id") or "") + if not oid: + return False, "交易所未返回限价单 ID" + except Exception as e: + return False, friendly_okx_error(e, available_usdt=available_usdt) + be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0 + conn.execute( + "INSERT INTO key_monitors " + "(symbol, monitor_type, direction, upper, lower, " + "fib_limit_order_id, fib_entry_price, fib_stop_loss, fib_take_profit, " + "fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + mt, + direction_sel, + upper_px, + lower_px, + oid, + entry, + sl, + tp, + float(amount), + margin_capital, + leverage, + be_flag, + ), + ) + return True, None + + +def can_notify_key_monitor(row, now_dt): + max_notify = int(row["max_notify"] or KEY_ALERT_MAX_TIMES) + if int(row["notification_count"] or 0) >= max_notify: + return False + last_at = row["last_notified_at"] + if not last_at: + return True + try: + last_dt = datetime.strptime(last_at, "%Y-%m-%d %H:%M:%S") + except Exception: + return True + interval_min = int(row["notify_interval_min"] or KEY_ALERT_INTERVAL_MINUTES) + return (now_dt - last_dt).total_seconds() >= interval_min * 60 + + +def breakout_too_far(p, edge_price, limit_pct): + try: + if edge_price is None or float(edge_price) <= 0: + return False + diff_pct = abs(float(p) - float(edge_price)) / float(edge_price) * 100 + return diff_pct > float(limit_pct) + except Exception: + return False + + +# 关键位监控 +def check_key_monitors(): + conn = get_db() + rows = conn.execute("SELECT * FROM key_monitors").fetchall() + for r in rows: + sym, typ_raw, up, low = r["symbol"], r["monitor_type"], r["upper"], r["lower"] + typ = (typ_raw or "").strip() + if is_fib_key_monitor_type(typ): + continue + direction = (r["direction"] or "long").lower() + now_dt = app_now() + if not can_notify_key_monitor(r, now_dt): + continue + try: + checks = _key_hard_checks(sym, direction, up, low, typ) + except Exception: + checks = {"ok": False} + if not checks.get("ok"): + continue + btc8h_status, _, _ = _status_by_ema55("BTC/USDT", "8h") + coin4h_status, _, _ = _status_by_ema55(sym, "4h") + risk_tip = None + if (direction == "long" and coin4h_status == "空头") or (direction == "short" and coin4h_status == "多头"): + risk_tip = "当前信号与本币4h(EMA55)主趋势逆势,建议降低仓位并严格执行止损。" + key_price = float(low) if direction == "long" else float(up) + sl_tp_mode = sl_tp_mode_from_row(r, "standard") + be_on = breakeven_enabled_from_row(r, 0) + plan_tuple, _mode = _key_plan_sl_tp_for_row(r, direction, up, low, checks) + hard_lines = [ + f"量能:{'通过' if checks['vol_ok'] else '不通过'}(突破K量 {round(checks['vol_break'], 4)} / 前20均量 {round(checks['avg20'], 4)},阈值1.3x)", + f"突破价位:{'通过' if checks['breakout_ok'] else '不通过'}(突破K收盘 {round(float(checks['breakout_close']), 8)},关键位 {checks['edge_price']})", + f"突破K幅度:{'通过' if checks['amp_ok'] else '不通过'}({round(checks['amp_pct'], 4)}%,要求0.03%~0.5%)", + f"第二根确认:{'通过' if checks['confirm_ok'] else '不通过'}(确认收盘 {checks['confirm_close']},关键位 {checks['edge_price']})", + f"日成交量排名:{'通过' if checks['rank_ok'] else '不通过'}({checks['rank']}/{checks['rank_total']},要求前30)", + ] + if plan_tuple: + E, sl_raw, tp_raw, box_h = plan_tuple + planned_rr = calc_rr_ratio(direction, E, sl_raw, tp_raw) + rr_txt = f"{planned_rr:.4f}" if planned_rr is not None else "-" + op_lines = [ + f"录入方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'开' if be_on else '关'}", + sl_tp_plan_summary_text( + sl_tp_mode, direction, E, sl_raw, tp_raw, box_h, + outside_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT, + trend_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT, + ), + f"计划 SL:`{round(sl_raw, 8)}`|计划 TP:`{round(tp_raw, 8)}`|计划 RR(E):{rr_txt}:1", + "说明:OKX 本实例为提醒模式,不自动市价开仓;请按方案自行下单。", + ] + else: + op_lines = [ + f"录入方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'开' if be_on else '关'}", + "计划 SL/TP 几何无效,请检查上下沿或趋势单止盈价。", + ] + trigger_time = ms_to_app_local_str(int(checks["confirm_ts"])) if checks.get("confirm_ts") else app_now_str() + msg = build_wechat_key_monitor_message( + symbol=sym, + direction=direction, + monitor_type=typ, + trigger_time=trigger_time, + key_price=key_price, + confirm_close=checks["confirm_close"], + hard_lines=hard_lines, + btc8h_status=btc8h_status, + coin4h_status=coin4h_status, + swing4h_pct=checks.get("swing4h_pct") or 0.0, + op_lines=op_lines, + risk_tip=risk_tip, + ) + send_wechat_msg(msg) + new_count = int(r["notification_count"] or 0) + 1 + max_n = int(r["max_notify"] or KEY_ALERT_MAX_TIMES) + conn.execute( + "UPDATE key_monitors SET notification_count = ?, last_notified_at = ? WHERE id = ?", + (new_count, app_now_str(), r["id"]), + ) + if new_count >= max_n: + insert_key_monitor_history(conn, r, new_count, msg, "alerts_complete") + conn.execute("DELETE FROM key_monitors WHERE id = ?", (r["id"],)) + send_wechat_msg( + "\n".join( + [ + f"# 🧾 {r['symbol']} 关键位监控结束", + "", + f"- 原因:已满 {max_n} 次提醒", + "- 状态:已自动结束并记入历史", + ] + ) + ) + conn.commit() + conn.close() + +# 止盈止损监控(已修复:严格区分多空,无默认做多) +def check_order_monitors(): + conn = get_db() + rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() + for r in rows: + pid, sym, direction, trigger_price, stop_loss, take_profit = r["id"], r["symbol"], r["direction"], r["trigger_price"], r["stop_loss"], r["take_profit"] + margin_capital = r["margin_capital"] or DAILY_START_CAPITAL + leverage = r["leverage"] or infer_leverage(sym) + session_date = r["session_date"] or get_trading_day() + p = get_price(sym) + if not p: continue + + # 到达设定 R 倍后,按阶梯持续上移止损(本地风控层) + risk_amount = float(r["risk_amount"] or 0) + breakeven_armed = int(r["breakeven_armed"] or 0) + trigger_rr = float(r["breakeven_rr_trigger"] or BREAKEVEN_RR_TRIGGER) + step_r = float(r["breakeven_step_r"] or BREAKEVEN_STEP_R or 1.0) + step_r = 1.0 if step_r <= 0 else step_r + breakeven_enabled = True + try: + if "breakeven_enabled" in r.keys(): + breakeven_enabled = int(r["breakeven_enabled"] or 0) != 0 + except Exception: + breakeven_enabled = True + if breakeven_enabled and risk_amount > 0 and trigger_rr > 0: + now_pnl = calc_pnl(direction, trigger_price, p, margin_capital, leverage) + now_rr = now_pnl / risk_amount + if now_rr >= trigger_rr: + steps = int((now_rr - trigger_rr) // step_r) + locked_r = max(0.0, steps * step_r) + notional = float(margin_capital or 0) * float(leverage or 0) + risk_frac = (risk_amount / notional) if notional > 0 else None + if risk_frac and risk_frac > 0: + new_sl = calc_breakeven_stop( + direction, + trigger_price, + risk_frac, + locked_r=locked_r, + offset_pct=float(r["breakeven_offset_pct"] or BREAKEVEN_OFFSET_PCT), + ) + if new_sl is not None: + should_move = (direction == "short" and new_sl < float(stop_loss)) or ( + direction == "long" and new_sl > float(stop_loss) + ) + if should_move: + ex_sym = resolve_monitor_exchange_symbol(r) + new_sl = round_price_to_exchange(ex_sym, new_sl) + tp_ex = float(take_profit or 0) + ok_live, _live_reason = ensure_okx_live_ready() + synced_ex = not ok_live + if ok_live and tp_ex > 0: + try: + replace_active_monitor_tpsl_on_exchange(r, new_sl, tp_ex) + synced_ex = True + except Exception as e: + print( + f"[breakeven] exchange tpsl replace failed order={pid} {sym}: {e}", + flush=True, + ) + send_wechat_msg( + f"⚠️ {sym} 移动保本止损未同步交易所:{friendly_okx_error(e)}" + ) + elif ok_live: + print( + f"[breakeven] skip exchange order={pid} {sym}: invalid take_profit", + flush=True, + ) + if synced_ex: + conn.execute( + "UPDATE order_monitors SET stop_loss=?, breakeven_armed=1, breakeven_price=? WHERE id=?", + (new_sl, new_sl, pid), + ) + stop_loss = new_sl + arm_txt = "保本止盈" if not breakeven_armed else "移动止盈" + be_msg = build_wechat_breakeven_message( + sym, + direction, + arm_txt, + now_rr, + locked_r, + new_sl, + ) + if ok_live: + be_msg += "\n- 交易所:已先撤后挂止盈止损" + send_wechat_msg(be_msg) + + res = None + # 做多 + if direction == "long": + if p >= take_profit: res = "止盈" + elif p <= stop_loss: res = "止损" + # 做空 + elif direction == "short": + if p <= take_profit: res = "止盈" + elif p >= stop_loss: res = "止损" + + if res: + now = app_now() + opened_at = get_opened_at_value(r) + opened_at_ms = (r["opened_at_ms"] if "opened_at_ms" in r.keys() else None) + closed_at = now.strftime("%Y-%m-%d %H:%M:%S") + hold_seconds = calc_hold_seconds(opened_at, now) + pnl_amount = calc_pnl(direction, trigger_price, p, margin_capital, leverage) + if res == "止损" and float(pnl_amount or 0) > 0: + res = "移动止盈" if breakeven_armed else "保本止盈" + else: + res = normalize_result_with_pnl(res, pnl_amount) + close_order_id = "" + try: + close_resp = close_exchange_order(r) + close_order_id = close_resp.get("id", "") + # 平仓入库优先使用交易所返回成交价;拿不到再回退拉成交明细。 + exit_p = extract_trade_price_from_order(close_resp) + if exit_p and exit_p > 0: + pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) + guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) + if guessed_res: + res = normalize_result_with_pnl(guessed_res, pnl_amount) + else: + res = normalize_result_with_pnl(res, pnl_amount) + else: + ex_sym = r["exchange_symbol"] or normalize_okx_symbol(sym) + tr = fetch_latest_closing_fill( + ex_sym, + direction, + opened_at, + opened_at_ms=opened_at_ms, + ) + if tr and tr.get("price"): + try: + exit_p = float(tr["price"]) + pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) + guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) + if guessed_res: + if guessed_res == "止损" and float(pnl_amount or 0) > 0: + res = "移动止盈" if breakeven_armed else "保本止盈" + else: + res = normalize_result_with_pnl(guessed_res, pnl_amount) + else: + res = normalize_result_with_pnl(res, pnl_amount) + except (TypeError, ValueError): + pass + ts = tr.get("timestamp") + if ts: + closed_at = ms_to_app_local_str(int(ts)) + hold_seconds = calc_hold_seconds( + opened_at, parse_dt_for_trading_day(closed_at) or now + ) + except Exception as e: + if is_no_position_error(str(e)): + ex_sym = r["exchange_symbol"] or normalize_okx_symbol(sym) + tr = fetch_latest_closing_fill( + ex_sym, + direction, + opened_at, + opened_at_ms=opened_at_ms, + ) + if tr and tr.get("price"): + try: + exit_p = float(tr["price"]) + pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) + # 交易所已返回真实成交价时,以真实成交结果为准,避免本地轮询竞态导致误判。 + guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) + if guessed_res: + if guessed_res == "止损" and float(pnl_amount or 0) > 0: + res = "移动止盈" if breakeven_armed else "保本止盈" + else: + res = normalize_result_with_pnl(guessed_res, pnl_amount) + else: + res = normalize_result_with_pnl(res, pnl_amount) + except (TypeError, ValueError): + pass + ts = tr.get("timestamp") + if ts: + closed_at = ms_to_app_local_str(int(ts)) + hold_seconds = calc_hold_seconds( + opened_at, parse_dt_for_trading_day(closed_at) or now + ) + insert_trade_record( + conn, + symbol=sym, + monitor_type="下单监控", + direction=direction, + trigger_price=trigger_price, + stop_loss=stop_loss, + initial_stop_loss=r["initial_stop_loss"] or stop_loss, + take_profit=take_profit, + margin_capital=margin_capital, + leverage=leverage, + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=r["trade_style"], + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or stop_loss, take_profit), + actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), + result=res, + miss_reason="触发价已触达,仓位已由交易所止盈/止损或其他方式平掉(本地补记)", + opened_at=opened_at, + closed_at=closed_at, + ) + session_capital = update_session_capital(conn, session_date, pnl_amount) + send_wechat_msg( + build_wechat_close_message( + symbol=sym, + direction=direction, + result=f"{res}(交易所已先行平仓)", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=trigger_price, + current_price=p, + stop_loss=stop_loss, + take_profit=take_profit, + close_order_id="-", + extra_note="本地补记:仓位由交易所止盈/止损或其他方式先行平掉", + session_capital_fallback=session_capital, + ) + ) + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (pid,)) + conn.commit() + continue + conn.execute("UPDATE order_monitors SET status='error' WHERE id=?", (pid,)) + conn.commit() + send_wechat_msg( + build_wechat_monitor_error_message( + symbol=sym, + direction=direction, + scene=f"触发{res}后交易所平仓失败", + error_text=str(e), + ) + ) + continue + session_capital = update_session_capital(conn, session_date, pnl_amount) + send_wechat_msg( + build_wechat_close_message( + symbol=sym, + direction=direction, + result=res, + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=trigger_price, + current_price=p, + stop_loss=stop_loss, + take_profit=take_profit, + close_order_id=close_order_id or "-", + session_capital_fallback=session_capital, + ) + ) + insert_trade_record( + conn, + symbol=sym, + monitor_type="下单监控", + direction=direction, + trigger_price=trigger_price, + stop_loss=stop_loss, + initial_stop_loss=r["initial_stop_loss"] or stop_loss, + take_profit=take_profit, + margin_capital=margin_capital, + leverage=leverage, + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=r["trade_style"], + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or stop_loss, take_profit), + actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), + result=res, + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, pid)) + conn.commit() + conn.close() + + +def force_close_before_reset(): + if not FORCE_CLOSE_ENABLED: + return + now = app_now() + # 每天北京时间指定整点小时内执行一次性兜底清仓(默认 00:xx) + if now.hour != FORCE_CLOSE_BJ_HOUR: + return + conn = get_db() + rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() + for r in rows: + p = get_price(r["symbol"]) + if not p: + continue + direction = r["direction"] + trigger_price = r["trigger_price"] + margin_capital = r["margin_capital"] or DAILY_START_CAPITAL + leverage = r["leverage"] or infer_leverage(r["symbol"]) + session_date = r["session_date"] or get_trading_day(now) + opened_at = get_opened_at_value(r) + closed_at = now.strftime("%Y-%m-%d %H:%M:%S") + hold_seconds = calc_hold_seconds(opened_at, now) + pnl_amount = calc_pnl(direction, trigger_price, p, margin_capital, leverage) + try: + close_resp = close_exchange_order(r) + close_order_id = close_resp.get("id", "") + except Exception as e: + conn.execute("UPDATE order_monitors SET status='error' WHERE id=?", (r["id"],)) + conn.commit() + send_wechat_msg( + build_wechat_monitor_error_message( + symbol=r["symbol"], + direction=direction, + scene="强制清仓失败", + error_text=str(e), + ) + ) + continue + session_capital = update_session_capital(conn, session_date, pnl_amount) + insert_trade_record( + conn, + symbol=r["symbol"], + monitor_type="下单监控", + direction=direction, + trigger_price=trigger_price, + stop_loss=r["stop_loss"], + initial_stop_loss=r["initial_stop_loss"] or r["stop_loss"], + take_profit=r["take_profit"], + margin_capital=margin_capital, + leverage=leverage, + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=r["trade_style"], + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]), + actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), + result="强制清仓", + miss_reason=f"北京时间 {FORCE_CLOSE_BJ_HOUR}:00 整点风控清仓", + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, r["id"])) + send_wechat_msg( + build_wechat_close_message( + symbol=r["symbol"], + direction=direction, + result="强制清仓", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=trigger_price, + current_price=p, + stop_loss=r["stop_loss"], + take_profit=r["take_profit"], + close_order_id=close_order_id or "-", + extra_note=f"北京时间 {FORCE_CLOSE_BJ_HOUR}:00 整点风控清仓", + session_capital_fallback=session_capital, + ) + ) + conn.commit() + conn.close() + +# 后台线程 +def background_task(): + while True: + if not license_lib.is_licensed(): + time.sleep(MONITOR_POLL_SECONDS) + continue + try: + auto_transfer_once_per_day() + conn = get_db() + reconcile_external_closes(conn) + conn.commit() + conn.close() + force_close_before_reset() + check_fib_key_monitors() + check_key_monitors() + check_order_monitors() + except: + pass + time.sleep(MONITOR_POLL_SECONDS) + + +# ====================== 登录路由 ====================== +@app.route("/login", methods=["GET", "POST"]) +def login(): + if AUTH_DISABLED: + session["logged_in"] = True + return redirect("/") + if request.method == "POST": + username = request.form.get("username") + password = request.form.get("password") + if username == USERNAME and password == PASSWORD: + session["logged_in"] = True + return redirect("/") + else: + flash("账号或密码错误") + return render_template("login.html") + +@app.route("/logout") +def logout(): + session.clear() + return redirect("/" if AUTH_DISABLED else "/login") + +# 登录校验装饰器 +def login_required(f): + @wraps(f) + def decorated(*args, **kwargs): + if AUTH_DISABLED: + return f(*args, **kwargs) + if not session.get("logged_in"): + return redirect("/login") + return f(*args, **kwargs) + return decorated + + +@app.route("/sync_positions") +@login_required +def sync_positions(): + days_raw = (request.args.get("days") or "").strip() + sync_days = None + if days_raw: + try: + sync_days = max(1, min(365, int(days_raw))) + except Exception: + sync_days = None + conn = get_db() + synced = reconcile_external_closes(conn, days=sync_days) + conn.commit() + conn.close() + if sync_days is not None: + flash(f"同步完成:最近 {sync_days} 天内 {synced} 笔持仓已按交易所状态更新") + else: + flash(f"同步完成:{synced} 笔持仓已按交易所状态更新") + return redirect("/") + + +@app.route("/api/sync_positions", methods=["POST"]) +@login_required +def api_sync_positions(): + payload = request.get_json(silent=True) or {} + days_raw = str(payload.get("days", "")).strip() + if not days_raw: + return jsonify({"ok": False, "msg": "请填写天数"}), 400 + try: + days = int(days_raw) + except Exception: + return jsonify({"ok": False, "msg": "天数必须是整数"}), 400 + if days < 1 or days > 365: + return jsonify({"ok": False, "msg": "天数范围 1-365"}), 400 + conn = get_db() + synced = reconcile_external_closes(conn, days=days) + conn.commit() + conn.close() + return jsonify({"ok": True, "days": days, "synced": int(synced)}) + + +# ====================== 主页面 ====================== +def render_main_page(page="trade"): + now = app_now() + trading_day = get_trading_day(now) + list_window = _list_window_from_request() + start_bj, end_bj = utc_window_to_bj_sql_strings(list_window["start_utc"], list_window["end_utc"], APP_TZ) + conn = get_db() + session_row = ensure_session(conn, trading_day) + local_current_capital = float(session_row["current_capital"]) + funding_capital, trading_capital = get_exchange_capitals() + total_capital = round(funding_capital, 4) if funding_capital is not None else TOTAL_CAPITAL + current_capital = round(trading_capital, 4) if trading_capital is not None else round(local_current_capital, 4) + recommended_capital = get_recommended_capital(current_capital) + key_list = conn.execute("SELECT * FROM key_monitors").fetchall() + key_history = conn.execute( + "SELECT * FROM key_monitor_history WHERE closed_at >= ? AND closed_at <= ? ORDER BY id DESC LIMIT 500", + (start_bj, end_bj), + ).fetchall() + stats_bundle = compute_stats_bundle(conn, trading_day, now) + raw_order_list = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() + order_list = [] + for o in raw_order_list: + order_list.append(enrich_order_item(row_to_dict(o), current_capital)) + raw_records = conn.execute( + "SELECT * FROM trade_records WHERE COALESCE(closed_at, created_at, opened_at) >= ? " + "AND COALESCE(closed_at, created_at, opened_at) <= ? ORDER BY id DESC LIMIT 1000", + (start_bj, end_bj), + ).fetchall() + records = [to_effective_trade_dict(r) for r in raw_records] + total = len(records) + miss_count = sum(1 for r in records if (r.get("effective_result") or "") == "错过") + win = sum(1 for r in records if (r.get("effective_result") or "") in ("止盈", "保本止盈", "移动止盈")) + occupied_miss_total = sum( + 1 + for r in records + if (r.get("effective_result") or "") == "错过" + and ("持仓占用" in str(r.get("effective_miss_reason") or "")) + ) + rate = round(win/total*100,2) if total else 0 + active_count = len(order_list) + can_trade = now.hour >= TRADING_DAY_RESET_HOUR and active_count == 0 + key_gate_rule_text = ( + f"周期 {KLINE_TIMEFRAME}|量能/突破/二确门控见箱体与收敛规则|" + f"自动开仓盈亏比 > {KEY_AUTO_MIN_PLANNED_RR}:1|日成交量排名前 {KEY_DAILY_VOLUME_RANK_MAX}|" + f"斐波:添加后立即挂限价 @ E,失效按标记价触达 H/L(未成交撤单)" + ) + conn.close() + return render_template( + "index.html", + page=page, + key=key_list, + key_history=key_history, + stats_bundle=stats_bundle, + order=order_list, + record=records, + total=total, + miss_count=miss_count, + rate=rate, + trading_day=trading_day, + total_capital=total_capital, + daily_start_capital=DAILY_START_CAPITAL, + current_capital=current_capital, + recommended_capital=recommended_capital, + btc_leverage=BTC_LEVERAGE, + alt_leverage=ALT_LEVERAGE, + reset_hour=TRADING_DAY_RESET_HOUR, + balance_refresh_seconds=BALANCE_REFRESH_SECONDS, + auto_transfer_enabled=AUTO_TRANSFER_ENABLED, + auto_transfer_amount=AUTO_TRANSFER_AMOUNT, + auto_transfer_from=AUTO_TRANSFER_FROM, + auto_transfer_to=AUTO_TRANSFER_TO, + auto_transfer_bj_hour=AUTO_TRANSFER_BJ_HOUR, + full_margin_buffer_ratio=FULL_MARGIN_BUFFER_RATIO, + price_refresh_seconds=PRICE_REFRESH_SECONDS, + active_count=active_count, + can_trade=can_trade, + focus_key_id=(key_list[0]["id"] if key_list else None), + focus_order_id=(order_list[0]["id"] if order_list else None), + data_export_version=3, + list_window=list_window, + list_window_presets={ + "utc_today": PRESET_UTC_TODAY, + "utc_last24h": PRESET_UTC_LAST24H, + "utc_last7d": PRESET_UTC_LAST7D, + "custom": PRESET_CUSTOM, + }, + key_alert_max_times=KEY_ALERT_MAX_TIMES, + risk_percent=RISK_PERCENT, + breakeven_rr_trigger=BREAKEVEN_RR_TRIGGER, + breakeven_offset_pct=BREAKEVEN_OFFSET_PCT, + occupied_miss_total=occupied_miss_total, + price_fmt=format_price_for_symbol, + entry_reason_options=list(ENTRY_REASON_OPTIONS), + entry_reason_other_value=ENTRY_REASON_OTHER, + key_gate_rule_text=key_gate_rule_text, + key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR, + ) + + +@app.route("/") +@login_required +def index(): + return redirect("/trade") + + +@app.route("/trade") +@login_required +def trade_page(): + return render_main_page("trade") + + +@app.route("/records") +@login_required +def records_page(): + return render_main_page("records") + + +@app.route("/stats") +@login_required +def stats_page(): + return render_main_page("stats") + + +@app.route("/api/account_snapshot") +@login_required +def api_account_snapshot(): + now = app_now() + trading_day = get_trading_day(now) + conn = get_db() + session_row = ensure_session(conn, trading_day) + local_current_capital = float(session_row["current_capital"]) + funding_capital, trading_capital = get_exchange_capitals(force=True) + total_capital = round(funding_capital, 4) if funding_capital is not None else TOTAL_CAPITAL + current_capital = round(trading_capital, 4) if trading_capital is not None else round(local_current_capital, 4) + recommended_capital = get_recommended_capital(current_capital) + active_count = conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0] + conn.close() + can_trade = now.hour >= TRADING_DAY_RESET_HOUR and active_count == 0 + available_trading_usdt = get_available_trading_usdt() + return jsonify({ + "total_capital": total_capital, + "current_capital": current_capital, + "available_trading_usdt": round(available_trading_usdt, 4) if available_trading_usdt is not None else None, + "recommended_capital": recommended_capital, + "active_count": active_count, + "can_trade": can_trade, + "trading_day": trading_day + }) + + +@app.route("/api/price_snapshot") +@login_required +def api_price_snapshot(): + conn = get_db() + key_rows = conn.execute( + "SELECT id,symbol,monitor_type,direction,upper,lower,fib_entry_price,fib_limit_order_id FROM key_monitors" + ).fetchall() + order_rows = conn.execute( + "SELECT id,symbol,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage FROM order_monitors WHERE status='active'" + ).fetchall() + conn.close() + + symbol_set = set() + for r in key_rows: + symbol_set.add(r["symbol"]) + for r in order_rows: + symbol_set.add(r["symbol"]) + + prices = {} + for s in symbol_set: + p = get_price(s) + if p is not None: + prices[s] = float(p) + + key_prices = [] + for r in key_rows: + price = prices.get(r["symbol"]) + if price is None: + continue + upper_diff, upper_pct = calc_price_diff_pct(price, r["upper"]) + lower_diff, lower_pct = calc_price_diff_pct(price, r["lower"]) + is_fib = is_fib_key_monitor_type(r["monitor_type"]) + gate = None + if not is_fib: + try: + gate = _key_hard_checks(r["symbol"], (r["direction"] or "long").lower(), r["upper"], r["lower"], r["monitor_type"]) + except Exception: + gate = None + gate_summary = "-" + gate_metrics = "" + fib_gate_ok = True + if is_fib: + direction = (r["direction"] or "long").lower() + inval = fib_invalidate_by_mark(direction, price, r["upper"], r["lower"]) + fib_gate_ok = not inval + entry = _sqlite_row_val(r, "fib_entry_price") + entry_txt = format_price_for_symbol(r["symbol"], entry) if entry is not None else "-" + gate_summary = f"斐波 挂E={entry_txt} {'标记价将失效' if inval else '等待成交'}" + if _sqlite_row_val(r, "fib_limit_order_id"): + gate_metrics = f"限价单:{_sqlite_row_val(r, 'fib_limit_order_id')}" + elif gate: + rank_seg = "ERR" if int(gate.get("rank_total") or 0) <= 0 else f"{gate.get('rank')}/{gate.get('rank_total')}" + gate_summary = ( + f"量:{'Y' if gate.get('vol_ok') else 'N'} " + f"破:{'Y' if gate.get('breakout_ok') else 'N'} " + f"幅:{'Y' if gate.get('amp_ok') else 'N'} " + f"二确:{'Y' if gate.get('confirm_ok') else 'N'} " + f"排:{'Y' if gate.get('rank_ok') else 'N'}({rank_seg})" + ) + if gate.get("breakout_ok"): + try: + vol_now = round(float(gate.get("vol_break") or 0), 4) + vol_avg = round(float(gate.get("avg20") or 0), 4) + amp_pct = round(float(gate.get("amp_pct") or 0), 4) + cfm_close = round(float(gate.get("confirm_close") or 0), 8) + edge = round(float(gate.get("edge_price") or 0), 8) + gate_metrics = ( + f"量值:{vol_now}/{vol_avg} " + f"幅值:{amp_pct}% " + f"二确值:{cfm_close}@{edge}" + ) + except Exception: + gate_metrics = "" + key_prices.append({ + "id": r["id"], + "symbol": r["symbol"], + "price": round(price, 6), + "upper_diff": upper_diff, + "upper_pct": upper_pct, + "lower_diff": lower_diff, + "lower_pct": lower_pct, + "gate_summary": gate_summary, + "gate_ok": fib_gate_ok if is_fib else bool(gate and gate.get("ok")), + "gate_metrics": gate_metrics, + }) + + order_prices = [] + for r in order_rows: + price = prices.get(r["symbol"]) + if price is None: + continue + margin = float(r["margin_capital"] or 0) + leverage = float(r["leverage"] or 0) + entry = float(r["trigger_price"] or 0) + pnl = calc_pnl(r["direction"], entry, price, margin, leverage) if entry > 0 else 0 + pnl_pct = round((pnl / margin * 100), 4) if margin > 0 else 0 + rr_ratio = calc_rr_ratio(r["direction"], entry, r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]) + order_prices.append({ + "id": r["id"], + "symbol": r["symbol"], + "price": round(price, 6), + "float_pnl": round(pnl, 6), + "float_pct": pnl_pct, + "rr_ratio": rr_ratio, + }) + + return jsonify({ + "updated_at": app_now_str(), + "key_prices": key_prices, + "order_prices": order_prices + }) + + +@app.route("/api/symbol_liquidity_rank") +@login_required +def api_symbol_liquidity_rank(): + symbol = normalize_symbol_input(request.args.get("symbol")) + if not symbol: + return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400 + rank, total = _daily_volume_rank(symbol) + if total <= 0: + return jsonify({"ok": False, "msg": "日成交量排名读取失败"}), 502 + if rank is None: + return jsonify({"ok": True, "symbol": symbol, "rank": None, "total": int(total), "in_top30": False}) + return jsonify( + { + "ok": True, + "symbol": symbol, + "rank": int(rank), + "total": int(total), + "in_top30": bool(rank <= 30), + } + ) + + +@app.route("/api/order_defaults") +@login_required +def api_order_defaults(): + symbol = normalize_symbol_input(request.args.get("symbol")) + direction = (request.args.get("direction") or "long").strip().lower() + if not symbol: + return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400 + if direction not in ("long", "short"): + direction = "long" + exchange_symbol = normalize_okx_symbol(symbol) + leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) + available = get_available_trading_usdt() + return jsonify({ + "ok": True, + "symbol": symbol, + "exchange_symbol": exchange_symbol, + "direction": direction, + "leverage": leverage, + "available_trading_usdt": round(available, 4) if available is not None else None + }) + + +@app.route("/order_focus") +@login_required +def order_focus(): + now = app_now() + trading_day = get_trading_day(now) + conn = get_db() + session_row = ensure_session(conn, trading_day) + local_current_capital = float(session_row["current_capital"]) + _, trading_capital_live = get_exchange_capitals() + current_capital = round(trading_capital_live, 4) if trading_capital_live is not None else round(local_current_capital, 4) + raw_orders = conn.execute("SELECT * FROM order_monitors WHERE status='active' ORDER BY id DESC").fetchall() + conn.close() + orders = [enrich_order_item(row_to_dict(r), current_capital) for r in raw_orders] + picked_id = request.args.get("order_id", "").strip() + selected = None + if picked_id.isdigit(): + selected = next((o for o in orders if int(o["id"]) == int(picked_id)), None) + if selected is None and orders: + selected = orders[0] + return render_template( + "order_focus_v2.html", + orders=orders, + selected_order=selected, + default_timeframe=KLINE_TIMEFRAME, + price_refresh_seconds=PRICE_REFRESH_SECONDS, + ) + + +@app.route("/api/order_kline") +@login_required +def api_order_kline(): + order_id_raw = (request.args.get("order_id") or "").strip() + if not order_id_raw.isdigit(): + return jsonify({"ok": False, "msg": "order_id 无效"}), 400 + order_id = int(order_id_raw) + timeframe = (request.args.get("timeframe") or KLINE_TIMEFRAME).strip() + allowed_tfs = {"1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d"} + if timeframe not in allowed_tfs: + timeframe = KLINE_TIMEFRAME + limit = 100 + + now = app_now() + trading_day = get_trading_day(now) + conn = get_db() + session_row = ensure_session(conn, trading_day) + local_current_capital = float(session_row["current_capital"]) + _, trading_capital_live = get_exchange_capitals() + current_capital = round(trading_capital_live, 4) if trading_capital_live is not None else round(local_current_capital, 4) + row = conn.execute("SELECT * FROM order_monitors WHERE id=? AND status='active'", (order_id,)).fetchone() + conn.close() + if not row: + return jsonify({"ok": False, "msg": "订单不存在或已结束"}), 404 + + order_item = enrich_order_item(row_to_dict(row), current_capital) + exchange_symbol = order_item.get("exchange_symbol") or normalize_okx_symbol(order_item["symbol"]) + try: + ensure_markets_loaded() + ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=limit) + except Exception as e: + return jsonify({"ok": False, "msg": f"K线加载失败:{friendly_okx_error(e)}"}), 500 + + candles = [] + for bar in ohlcv or []: + if not bar or len(bar) < 6: + continue + ts = int(bar[0] // 1000) + candles.append({ + "time": ts, + "open": float(bar[1]), + "high": float(bar[2]), + "low": float(bar[3]), + "close": float(bar[4]), + "volume": float(bar[5]), + }) + + current_price = get_price(order_item["symbol"]) + margin = float(order_item.get("margin_capital") or 0) + leverage = float(order_item.get("leverage") or 0) + entry = float(order_item.get("trigger_price") or 0) + float_pnl = calc_pnl(order_item.get("direction") or "long", entry, current_price, margin, leverage) if current_price else 0 + float_pct = round((float_pnl / margin * 100), 4) if margin > 0 else 0 + + return jsonify({ + "ok": True, + "timeframe": timeframe, + "limit": limit, + "order": { + "id": order_item["id"], + "symbol": order_item["symbol"], + "direction": order_item.get("direction") or "long", + "trigger_price": order_item.get("trigger_price"), + "stop_loss": order_item.get("stop_loss"), + "take_profit": order_item.get("take_profit"), + "margin_capital": order_item.get("margin_capital"), + "leverage": order_item.get("leverage"), + "position_ratio": order_item.get("position_ratio"), + "rr_ratio": order_item.get("rr_ratio"), + "current_price": round(float(current_price), 8) if current_price else None, + "float_pnl": round(float(float_pnl), 6), + "float_pct": float_pct, + }, + "candles": candles, + "updated_at": app_now_str(), + }) + + +@app.route("/key_focus") +@login_required +def key_focus(): + conn = get_db() + key_rows = conn.execute("SELECT * FROM key_monitors ORDER BY id DESC").fetchall() + conn.close() + key_list = [row_to_dict(r) for r in key_rows] + + key_id_raw = (request.args.get("key_id") or "").strip() + symbol_query = normalize_symbol_input(request.args.get("symbol")) + selected_key = None + if key_id_raw.isdigit(): + selected_key = next((k for k in key_list if int(k["id"]) == int(key_id_raw)), None) + if selected_key is None and symbol_query: + selected_key = next((k for k in key_list if (k.get("symbol") or "").upper() == symbol_query), None) + if selected_key is None and key_list: + selected_key = key_list[0] + default_symbol = symbol_query or ((selected_key or {}).get("symbol")) or "BTC/USDT" + return render_template( + "key_focus_v2.html", + key_list=key_list, + selected_key=selected_key, + default_symbol=default_symbol, + default_timeframe=KLINE_TIMEFRAME, + default_kline_limit=200, + price_refresh_seconds=PRICE_REFRESH_SECONDS, + ) + + +@app.route("/api/key_kline") +@login_required +def api_key_kline(): + key_id_raw = (request.args.get("key_id") or "").strip() + symbol_input = normalize_symbol_input(request.args.get("symbol")) + timeframe = (request.args.get("timeframe") or KLINE_TIMEFRAME).strip() + if timeframe not in {"1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d"}: + timeframe = KLINE_TIMEFRAME + limit = normalize_kline_limit(request.args.get("limit"), default=200) + + conn = get_db() + key_row = None + if key_id_raw.isdigit(): + key_row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (int(key_id_raw),)).fetchone() + if key_row is None and symbol_input: + key_row = conn.execute( + "SELECT * FROM key_monitors WHERE upper(symbol)=? ORDER BY id DESC LIMIT 1", + (symbol_input,), + ).fetchone() + if key_row is not None: + symbol = (key_row["symbol"] or "").upper() + else: + symbol = symbol_input + conn.close() + if not symbol: + return jsonify({"ok": False, "msg": "请先输入币种或选择关键位"}), 400 + + exchange_symbol = normalize_okx_symbol(symbol) + try: + ensure_markets_loaded() + ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=limit) + except Exception as e: + return jsonify({"ok": False, "msg": f"K线加载失败:{friendly_okx_error(e)}"}), 500 + + candles = [] + for bar in ohlcv or []: + if not bar or len(bar) < 6: + continue + candles.append({ + "time": int(bar[0] // 1000), + "open": float(bar[1]), + "high": float(bar[2]), + "low": float(bar[3]), + "close": float(bar[4]), + "volume": float(bar[5]), + }) + + current_price = get_price(symbol) + key_info = None + if key_row is not None: + upper = float(key_row["upper"]) if key_row["upper"] is not None else None + lower = float(key_row["lower"]) if key_row["lower"] is not None else None + upper_diff, upper_pct = calc_price_diff_pct(current_price, upper) if current_price else (None, None) + lower_diff, lower_pct = calc_price_diff_pct(current_price, lower) if current_price else (None, None) + key_info = { + "id": key_row["id"], + "monitor_type": key_row["monitor_type"], + "direction": key_row["direction"] or "long", + "upper": upper, + "lower": lower, + "notification_count": int(key_row["notification_count"] or 0), + "upper_diff": upper_diff, + "upper_pct": upper_pct, + "lower_diff": lower_diff, + "lower_pct": lower_pct, + } + + return jsonify({ + "ok": True, + "symbol": symbol, + "timeframe": timeframe, + "limit": limit, + "current_price": round(float(current_price), 8) if current_price is not None else None, + "key_monitor": key_info, + "candles": candles, + "updated_at": app_now_str(), + }) + + +@app.route("/add_key", methods=["POST"]) +@login_required +def add_key(): + d = request.form + symbol = normalize_symbol_input(d.get("symbol")) + if not symbol: + flash("symbol 不能为空") + return redirect("/") + direction_sel = (d.get("direction") or "").strip().lower() + if direction_sel not in ("long", "short"): + flash("请选择做多或做空") + return redirect("/") + mt = (d.get("type") or "").strip() + allowed_types = tuple(KEY_MONITOR_AUTO_TYPES) + tuple(KEY_MONITOR_ALERT_ONLY_TYPES) + tuple(FIB_KEY_MONITOR_TYPES) + if mt not in allowed_types: + flash("监控类型无效") + return redirect("/") + rank, total = _daily_volume_rank(symbol) + if rank is None: + flash("日成交量排名读取失败,请稍后重试") + return redirect("/") + if rank > KEY_DAILY_VOLUME_RANK_MAX: + flash(f"{symbol} 当前日成交量排名为 {rank}/{total},不在前{KEY_DAILY_VOLUME_RANK_MAX},已拒绝添加关键位") + return redirect("/") + conn = get_db() + if mt in KEY_MONITOR_AUTO_TYPES: + if get_active_position_count(conn) > 0: + conn.close() + flash("当前已有持仓:无法添加「箱体突破 / 收敛突破」(请先平仓或使用阻力/支撑/斐波类型)") + return redirect("/") + ex_sym_key = normalize_okx_symbol(symbol) + try: + ensure_markets_loaded() + except Exception: + pass + uh = round_price_to_exchange(ex_sym_key, float(d["upper"])) + lw = round_price_to_exchange(ex_sym_key, float(d["lower"])) + upper_px = float(uh) if uh is not None else float(d["upper"]) + lower_px = float(lw) if lw is not None else float(d["lower"]) + be_flag = parse_breakeven_enabled_form(d.get("breakeven_enabled")) + if is_fib_key_monitor_type(mt): + ok_fib, err_fib = _add_fib_key_monitor( + conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=be_flag, + ) + conn.commit() + conn.close() + if not ok_fib: + flash(err_fib or "斐波监控添加失败") + return redirect("/") + flash( + f"斐波监控已添加,限价单已挂出({symbol} 日成交量排名 {rank}/{total})" + f"|移动保本:{'开' if be_flag else '关'}" + ) + return redirect("/") + sl_tp_mode = "standard" + manual_tp = None + if mt in KEY_MONITOR_AUTO_TYPES: + sl_tp_mode = normalize_sl_tp_mode(d.get("sl_tp_mode")) + if sl_tp_mode == "trend_manual": + try: + manual_tp = float(d.get("manual_take_profit") or 0) + except (TypeError, ValueError): + manual_tp = 0 + if manual_tp <= 0: + conn.close() + flash("趋势单方案须填写有效止盈价") + return redirect("/") + if direction_sel == "long" and manual_tp <= upper_px: + conn.close() + flash("做多趋势单:止盈价应高于上沿(阻力)") + return redirect("/") + if direction_sel == "short" and manual_tp >= lower_px: + conn.close() + flash("做空趋势单:止盈价应低于下沿(支撑)") + return redirect("/") + mtpx = round_price_to_exchange(ex_sym_key, manual_tp) + if mtpx is not None: + manual_tp = float(mtpx) + conn.execute( + "INSERT INTO key_monitors " + "(symbol,monitor_type,direction,upper,lower,sl_tp_mode,manual_take_profit,breakeven_enabled) " + "VALUES (?,?,?,?,?,?,?,?)", + (symbol, mt, direction_sel, upper_px, lower_px, sl_tp_mode, manual_tp, be_flag), + ) + conn.commit() + conn.close() + extra = "" + if mt in KEY_MONITOR_AUTO_TYPES: + extra = f"|方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'开' if be_flag else '关'}" + flash(f"添加成功({symbol} 日成交量排名 {rank}/{total}){extra}") + return redirect("/") + +@app.route("/add_order", methods=["POST"]) +@login_required +def add_order(): + d = request.form + now = app_now() + conn = get_db() + direction = d.get("direction", "long") + symbol = normalize_symbol_input(d.get("symbol")) + if not symbol: + conn.close() + flash("symbol 不能为空") + return redirect("/") + ok, reason = precheck_risk(conn, symbol, direction) + if not ok: + if "一次只能持有一个仓位" in reason: + try: + tp_raw = parse_positive_float(d.get("tp")) + sl_raw = parse_positive_float(d.get("sl")) + tgt_raw = parse_positive_float(d.get("tgt")) + except Exception: + tp_raw = sl_raw = tgt_raw = None + insert_trade_record( + conn, + symbol=symbol, + monitor_type="下单监控", + direction=direction if direction in ("long", "short") else "long", + trigger_price=tp_raw or 0, + stop_loss=sl_raw or 0, + take_profit=tgt_raw or 0, + result="错过", + miss_reason="持仓占用:一次只能持有一个仓位", + opened_at=app_now_str(), + closed_at=app_now_str(), + ) + conn.commit() + conn.close() + flash(f"风控拒绝下单:{reason}") + return redirect("/") + ok_live, reason_live = ensure_okx_live_ready() + if not ok_live: + conn.close() + flash(f"风控拒绝下单:{reason_live}") + return redirect("/") + exchange_symbol = normalize_okx_symbol(symbol) + default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) + try: + leverage_input = parse_positive_float(d.get("leverage")) + leverage = int(leverage_input) if leverage_input is not None else default_leverage + except Exception: + conn.close() + flash("杠杆参数格式错误") + return redirect("/") + if leverage <= 0: + conn.close() + flash("杠杆必须大于0") + return redirect("/") + + trading_day = get_trading_day(now) + opens_today_before = conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", + (trading_day,), + ).fetchone()[0] + session_row = ensure_session(conn, trading_day) + _, trading_capital_live = get_exchange_capitals(force=True) + capital_base = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"]) + trade_style = (d.get("trade_style") or DEFAULT_TRADE_STYLE or "trend").strip().lower() + if trade_style not in ("trend", "swing"): + trade_style = "trend" + available_usdt = get_available_trading_usdt() + live_price = get_price(symbol) + if live_price is None: + conn.close() + flash("获取交易所实时价格失败,请稍后重试") + return redirect("/") + sltp_mode = (d.get("sltp_mode") or "price").strip().lower() + if sltp_mode not in ("price", "pct"): + sltp_mode = "price" + if sltp_mode == "pct": + try: + sl_pct = float(d.get("sl_pct") or 0) + tp_pct = float(d.get("tp_pct") or 0) + if sl_pct <= 0 or tp_pct <= 0: + raise ValueError("pct") + sl_ratio = sl_pct / 100.0 + tp_ratio = tp_pct / 100.0 + if direction == "short": + stop_loss = float(live_price) * (1 + sl_ratio) + take_profit = float(live_price) * (1 - tp_ratio) + else: + stop_loss = float(live_price) * (1 - sl_ratio) + take_profit = float(live_price) * (1 + tp_ratio) + except Exception: + conn.close() + flash("百分比止盈止损参数错误,请填写正数百分比") + return redirect("/") + else: + try: + stop_loss = float(d["sl"]) + take_profit = float(d["tgt"]) + except Exception: + conn.close() + flash("价格参数格式错误") + return redirect("/") + if stop_loss <= 0 or take_profit <= 0: + conn.close() + flash("价格参数必须大于0") + return redirect("/") + risk_fraction = calc_risk_fraction(direction, live_price, stop_loss) + if risk_fraction is None: + conn.close() + flash("止损方向不合法:请检查入场方向与止损价格关系") + return redirect("/") + risk_percent = max(0.01, float(RISK_PERCENT)) + risk_amount = round(capital_base * risk_percent / 100.0, 4) + notional_value = round(risk_amount / risk_fraction, 4) + margin_capital = round(notional_value / leverage, 4) + if capital_base and margin_capital > capital_base: + conn.close() + flash("以损定仓后保证金超过当前交易资金,请放宽止损或降低风险比例") + return redirect("/") + if available_usdt is not None: + max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4) + if margin_capital > max_margin: + conn.close() + flash(f"保证金不足:交易账户可用约 {round(available_usdt,4)}U,当前最多建议 {max_margin}U") + return redirect("/") + position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0 + try: + amount, quote_price = prepare_order_amount(exchange_symbol, margin_capital, leverage, live_price) + contract_size = get_contract_size(exchange_symbol) + base_amount = round(float(amount) * contract_size, 8) + order_resp = place_exchange_order(exchange_symbol, direction, amount, leverage, stop_loss=stop_loss, take_profit=take_profit) + open_order_id = order_resp.get("id", "") + tpsl_attached = bool(order_resp.get("tpsl_attached")) + trigger_price = resolve_order_entry_price(order_resp, exchange_symbol, quote_price) + except Exception as e: + conn.close() + flash(friendly_okx_error(e, available_usdt=available_usdt)) + return redirect("/") + + make_order_chart = d.get("order_chart", "").lower() in ("1", "true", "on", "yes") + opened_at_bj = app_now_str() + opened_at_ms = _to_ms_with_fallback(None, opened_at_bj) + planned_rr = calc_rr_ratio(direction, trigger_price, stop_loss, take_profit) + breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER) + breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT) + breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0 + risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage) or risk_amount + if direction == "short": + breakeven_price = round(float(trigger_price) * (1 - breakeven_offset_pct / 100.0), 8) + else: + breakeven_price = round(float(trigger_price) * (1 + breakeven_offset_pct / 100.0), 8) + breakeven_enabled = 1 if (d.get("breakeven_enabled") or "").strip() in ("1", "true", "on", "yes") else 0 + conn.execute( + "INSERT INTO order_monitors (symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, margin_capital, leverage, trade_style, risk_percent, risk_amount, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, exchange_symbol, direction, trigger_price, stop_loss, stop_loss, take_profit, + margin_capital, leverage, trade_style, risk_percent, risk_amount_final, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, 0, breakeven_price, + breakeven_enabled, + notional_value, position_ratio, base_amount, amount, open_order_id, opened_at_bj, opened_at_ms, trading_day, "下单监控", + ) + ) + conn.commit() + new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + opens_today_after = conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", + (trading_day,), + ).fetchone()[0] + conn.close() + + chart_name = None + chart_url = None + if make_order_chart and ORDER_CHART_ENABLED: + try: + title_prefix = f"{symbol} {direction} #{new_order_id}" + chart_name = generate_order_open_chart( + exchange_symbol, + title_prefix, + opened_at_ms=opened_at_ms, + entry_price=trigger_price, + ) + if chart_name: + chart_url = f"/static/images/order_charts/{chart_name}" + except Exception: + chart_name = None + chart_url = None + + if chart_name: + try: + journal_id = f"order_{new_order_id}" + coin = journal_coin_from_symbol(symbol) + open_local = (opened_at_bj or "")[:16].replace(" ", "T") + if len(open_local) < 16: + open_local = app_now().strftime("%Y-%m-%dT%H:%M") + close_local = open_local + hold_duration = calc_duration_text(open_local, close_local) + note = ( + f"auto_from_open_order id={new_order_id} oid={open_order_id} " + f"chart={chart_name} tfs={','.join(ORDER_CHART_TFS)} limit={ORDER_CHART_LIMIT}" + ) + conn = get_db() + conn.execute( + """INSERT OR REPLACE INTO journal_entries + (id, open_datetime, close_datetime, hold_duration, coin, tf, pnl, entry_reason, exit_reason, + expect_rr, real_rr, early_exit, early_exit_reason, early_exit_trigger, early_exit_note, + mood_score, mood_ai_score, mood_ai_comment, mood_issues, post_breakeven_stare, + new_trade_while_occupied, note, image) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + journal_id, + open_local, + close_local, + hold_duration, + coin, + "multi", + "0", + "auto:open", + "待平仓", + "", + "", + "否", + "", + "", + "", + None, + None, + None, + "", + "否", + "否", + note, + chart_name, + ), + ) + conn.commit() + conn.close() + except Exception: + try: + conn.close() + except Exception: + pass + + _, trading_capital_after = get_exchange_capitals(force=True) + account_base_display = ( + round(float(trading_capital_after), 4) + if trading_capital_after is not None + else round(float(capital_base), 4) + ) + account_name = (os.getenv("OKX_ACCOUNT_LABEL") or "okx实盘子账户").strip() + dir_text = "多头(long)" if direction == "long" else "空头(short)" + order_state_text = "交易所止盈止损已挂单" if tpsl_attached else "未挂单(已拦截)" + rr_show = planned_rr if planned_rr is not None else "-" + style_zh = "Swing 波段" if trade_style == "swing" else "Trend 趋势" + wx_lines = [ + f"📈 {symbol} 开仓成功", + f"💼 交易类型:{dir_text}", + "🧾 订单基础信息", + f"🔖 交易所订单 ID:{open_order_id}", + f"📈 交易风格:{style_zh}", + f"⚠️ 单笔风控风险:{risk_percent}% ≈ {round(float(risk_amount_final), 4)} U", + "📊 仓位配置详情", + f"账户基数:{account_base_display} USDT", + f"合约杠杆:{leverage} 倍", + f"名义仓位:{notional_value} USDT", + f"仓位占比:{position_ratio}%", + f"合约张数:{amount} 张", + f"折算标的:{base_amount} {journal_coin_from_symbol(symbol)}", + "🎯 价位 & 盈亏比", + f"开仓成交价:{trigger_price}", + f"止损价位:{stop_loss}", + f"止盈价位:{take_profit}", + f"计划盈亏比:RR {rr_show} : 1", + f"移动保本位:{breakeven_rr_trigger}R → {breakeven_price}", + "📌 状态统计", + f"✅ 止盈止损:{order_state_text}", + f"📅 当日开仓次数:{opens_today_after} / {DAILY_OPEN_ALERT_THRESHOLD} 次(风控阈值提醒)", + ] + if chart_url: + wx_lines.append(f"多周期K线图:{chart_url}") + send_wechat_msg("\n".join(wx_lines)) + + flash_lines = [ + f"实盘开单成功:风格 {trade_style};风险 {risk_percent}%≈{risk_amount_final}U;基数 {margin_capital}U,杠杆 {leverage}x,名义仓位 {notional_value}U,仓位占比 {position_ratio}%,合约张数 {amount}(折算标的 {base_amount})," + f"计划RR {planned_rr if planned_rr is not None else '-'};止盈止损已挂交易所", + f"本交易日累计开仓:{opens_today_after}", + ] + if chart_url: + flash_lines.append(f"已生成多周期K线图:{chart_url}") + flash(" ".join(flash_lines)) + + if opens_today_before < DAILY_OPEN_ALERT_THRESHOLD <= opens_today_after: + advice = ai_short_advice( + f"用户在北京时间交易日 {trading_day} 已累计开仓 {opens_today_after} 次(阈值 {DAILY_OPEN_ALERT_THRESHOLD})。" + f"最新一笔:{symbol} {direction},杠杆{leverage}x,基数{margin_capital}U。" + f"用户自述“上头了”。请给克制提醒。" + ) + if advice: + send_wechat_msg(f"【AI提醒】今日开仓次数已达 {opens_today_after}\n{advice[:800]}") + flash(f"【AI提醒】今日开仓次数已达 {opens_today_after}:{advice[:300]}") + return redirect("/") + +@app.route("/delete_key_monitor/", methods=["POST"]) +@login_required +def delete_key_monitor(kid): + conn = get_db() + row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (kid,)).fetchone() + if not row: + conn.close() + return jsonify({"ok": False, "error": "not_found"}) + if is_fib_key_monitor_type((row["monitor_type"] or "").strip()): + _cancel_fib_monitor_limit(row) + insert_key_monitor_history(conn, row, int(row["notification_count"] or 0), None, "manual") + cur = conn.execute("DELETE FROM key_monitors WHERE id=?", (kid,)) + conn.commit() + conn.close() + return jsonify({"ok": cur.rowcount > 0}) + + +@app.route("/delete_key_history/", methods=["POST"]) +@login_required +def delete_key_history(hid): + conn = get_db() + cur = conn.execute("DELETE FROM key_monitor_history WHERE id=?", (hid,)) + conn.commit() + conn.close() + return jsonify({"ok": cur.rowcount > 0}) + + +@app.route("/del_key/") +@login_required +def del_key(id): + conn = get_db() + row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (id,)).fetchone() + if row: + if is_fib_key_monitor_type((row["monitor_type"] or "").strip()): + _cancel_fib_monitor_limit(row) + insert_key_monitor_history(conn, row, int(row["notification_count"] or 0), None, "manual") + conn.execute("DELETE FROM key_monitors WHERE id=?", (id,)) + conn.commit() + conn.close() + resp = redirect("/") + resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" + resp.headers["Pragma"] = "no-cache" + return resp + + +def _csv_response(filename, rows, header): + buf = StringIO() + w = csv.writer(buf) + w.writerow(header) + for row in rows: + w.writerow(row) + out = "\ufeff" + buf.getvalue() + return Response( + out, + mimetype="text/csv; charset=utf-8", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + "Cache-Control": "no-store", + }, + ) + + +def _md_response(filename, content): + return Response( + content, + mimetype="text/markdown; charset=utf-8", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + "Cache-Control": "no-store", + }, + ) + + +@app.route("/export/trade_records") +@login_required +def export_trade_records(): + win = _list_window_from_request() + start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ) + conn = get_db() + rows = conn.execute( + "SELECT id,symbol,monitor_type,key_signal_type,direction,trigger_price,stop_loss,initial_stop_loss,take_profit," + "margin_capital,leverage,pnl_amount,hold_seconds,hold_minutes,planned_rr,actual_rr,risk_amount," + "opened_at,closed_at,result,miss_reason,entry_reason,reviewed_entry_reason,created_at " + "FROM trade_records WHERE COALESCE(closed_at, created_at, opened_at) >= ? " + "AND COALESCE(closed_at, created_at, opened_at) <= ? ORDER BY id ASC", + (start_bj, end_bj), + ).fetchall() + conn.close() + head = [ + "id", "symbol", "monitor_type", "key_signal_type", "direction", "trigger_price", + "stop_loss_open_snapshot", "initial_stop_loss", "take_profit", "margin_capital", "leverage", + "pnl_amount", "hold_seconds", "hold_minutes", "planned_rr", "actual_rr", "risk_amount", + "opened_at", "closed_at", "result", "miss_reason", "entry_reason", "reviewed_entry_reason", + "created_at", "开仓类型", + ] + data = [] + for r in rows: + er0 = (r["entry_reason"] or "").strip() if r["entry_reason"] else "" + er1 = (r["reviewed_entry_reason"] or "").strip() if r["reviewed_entry_reason"] else "" + kst = (r["key_signal_type"] or "").strip() if "key_signal_type" in r.keys() else "" + eff = er1 or er0 or entry_reason_from_key_signal(kst) or "" + snap = r["initial_stop_loss"] if r["initial_stop_loss"] not in (None, "") else r["stop_loss"] + data.append(( + r["id"], r["symbol"], r["monitor_type"], kst, r["direction"], r["trigger_price"], + snap, r["initial_stop_loss"], r["take_profit"], r["margin_capital"], r["leverage"], + r["pnl_amount"], r["hold_seconds"], r["hold_minutes"], r["planned_rr"], r["actual_rr"], r["risk_amount"], + r["opened_at"], r["closed_at"], r["result"], r["miss_reason"], r["entry_reason"], r["reviewed_entry_reason"], + r["created_at"], eff, + )) + day = app_now().strftime("%Y%m%d") + return _csv_response(f"trade_records_v3_{day}.csv", data, head) + + +@app.route("/export/journal_entries") +@login_required +def export_journal_entries(): + conn = get_db() + rows = conn.execute( + "SELECT id,open_datetime,close_datetime,hold_duration,coin,tf,pnl,entry_reason,exit_reason," + "expect_rr,real_rr,early_exit,early_exit_trigger,early_exit_note,early_exit_reason,mood_issues," + "post_breakeven_stare,new_trade_while_occupied,note,image,created_at FROM journal_entries ORDER BY created_at ASC" + ).fetchall() + conn.close() + head = [ + "id", + "open_datetime", + "close_datetime", + "hold_duration", + "coin", + "tf", + "pnl", + "entry_reason", + "exit_reason", + "expect_rr", + "real_rr", + "early_exit", + "early_exit_trigger", + "early_exit_note", + "early_exit_reason", + "mood_issues", + "post_breakeven_stare", + "new_trade_while_occupied", + "note", + "image", + "created_at", + ] + data = [tuple(r[h] for h in head) for r in rows] + day = app_now().strftime("%Y%m%d") + return _csv_response(f"journal_entries_v1_{day}.csv", data, head) + + +@app.route("/export/key_monitors") +@login_required +def export_key_monitors(): + conn = get_db() + rows = conn.execute( + "SELECT id,symbol,monitor_type,direction,upper,lower,notification_count,last_notified_at,max_notify," + "notify_interval_min,breakout_limit_pct,created_at FROM key_monitors ORDER BY id ASC" + ).fetchall() + conn.close() + head = [ + "id", + "symbol", + "monitor_type", + "direction", + "upper", + "lower", + "notification_count", + "last_notified_at", + "max_notify", + "notify_interval_min", + "breakout_limit_pct", + "created_at", + ] + data = [tuple(r[h] for h in head) for r in rows] + day = app_now().strftime("%Y%m%d") + return _csv_response(f"key_monitors_active_v1_{day}.csv", data, head) + + +@app.route("/export/key_monitor_history") +@login_required +def export_key_monitor_history(): + win = _list_window_from_request() + start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ) + conn = get_db() + rows = conn.execute( + "SELECT id,symbol,monitor_type,direction,upper,lower,notification_count,last_alert_message,close_reason,closed_at " + "FROM key_monitor_history WHERE closed_at >= ? AND closed_at <= ? ORDER BY id ASC", + (start_bj, end_bj), + ).fetchall() + conn.close() + head = [ + "id", + "symbol", + "monitor_type", + "direction", + "upper", + "lower", + "notification_count", + "last_alert_message", + "close_reason", + "closed_at", + ] + data = [tuple(r[h] for h in head) for r in rows] + day = app_now().strftime("%Y%m%d") + return _csv_response(f"key_monitor_history_v1_{day}.csv", data, head) + +@app.route("/del_order/") +@login_required +def del_order(id): + conn = get_db() + row = conn.execute("SELECT * FROM order_monitors WHERE id=?", (id,)).fetchone() + if not row: + conn.close() + flash("订单不存在") + return redirect("/") + if row["status"] == "active": + try: + p = get_price(row["symbol"]) or float(row["trigger_price"]) + opened_at = get_opened_at_value(row) + closed_at = app_now_str() + hold_seconds = calc_hold_seconds(opened_at, app_now()) + pnl_amount = calc_pnl( + row["direction"], + row["trigger_price"], + p, + row["margin_capital"] or DAILY_START_CAPITAL, + row["leverage"] or infer_leverage(row["symbol"]) + ) + close_resp = close_exchange_order(row) + close_order_id = close_resp.get("id", "") + session_date = row["session_date"] or get_trading_day() + session_capital = update_session_capital(conn, session_date, pnl_amount) + insert_trade_record( + conn, + symbol=row["symbol"], + monitor_type="下单监控", + direction=row["direction"], + trigger_price=row["trigger_price"], + stop_loss=row["stop_loss"], + initial_stop_loss=row["initial_stop_loss"] or row["stop_loss"], + take_profit=row["take_profit"], + margin_capital=row["margin_capital"], + leverage=row["leverage"], + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=row["trade_style"], + risk_amount=row["risk_amount"], + planned_rr=calc_rr_ratio(row["direction"], row["trigger_price"], row["initial_stop_loss"] or row["stop_loss"], row["take_profit"]), + actual_rr=calc_actual_rr(pnl_amount, row["risk_amount"]), + result="手动平仓", + miss_reason="用户手动删除订单触发平仓", + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, id)) + conn.commit() + conn.close() + send_wechat_msg( + build_wechat_close_message( + symbol=row["symbol"], + direction=row["direction"], + result="手动平仓", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=row["trigger_price"], + current_price=p, + stop_loss=row["stop_loss"], + take_profit=row["take_profit"], + close_order_id=close_order_id or "-", + extra_note="用户在页面手动平仓", + session_capital_fallback=session_capital, + ) + ) + flash("已按实盘流程手动平仓") + return redirect("/") + except Exception as e: + if is_no_position_error(str(e)): + opened_at = get_opened_at_value(row) + opened_at_ms = _to_ms_with_fallback(row["opened_at_ms"] if "opened_at_ms" in row.keys() else None, opened_at) + result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close(row, opened_at, opened_at_ms=opened_at_ms) + miss_reason = f"手动删除时无持仓:{miss_reason}" + closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now() + hold_seconds = calc_hold_seconds(opened_at, closed_at_dt) + session_date = row["session_date"] or get_trading_day(closed_at_dt) + update_session_capital(conn, session_date, pnl_amount) + insert_trade_record( + conn, + symbol=row["symbol"], + monitor_type="下单监控", + direction=row["direction"], + trigger_price=row["trigger_price"], + stop_loss=row["stop_loss"], + initial_stop_loss=row["initial_stop_loss"] or row["stop_loss"], + take_profit=row["take_profit"], + margin_capital=row["margin_capital"], + leverage=row["leverage"], + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=row["trade_style"], + risk_amount=row["risk_amount"], + planned_rr=calc_rr_ratio(row["direction"], row["trigger_price"], row["initial_stop_loss"] or row["stop_loss"], row["take_profit"]), + actual_rr=calc_actual_rr(pnl_amount, row["risk_amount"]), + result=result, + miss_reason=miss_reason, + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (id,)) + conn.commit() + conn.close() + flash("该仓位在交易所已不存在,已按成交记录同步结束并记账") + return redirect("/") + conn.close() + flash(f"手动平仓失败:{str(e)}") + return redirect("/") + conn.execute("DELETE FROM order_monitors WHERE id=?",(id,)) + conn.commit() + conn.close() + return redirect("/") + +@app.route("/add_miss", methods=["POST"]) +@login_required +def add_miss(): + d = request.form + direction = d.get("direction", "long") + conn = get_db() + insert_trade_record( + conn, + symbol=d["symbol"], + monitor_type=d["type"], + direction=direction, + trigger_price=d["tp"], + stop_loss=d["sl"], + take_profit=d["tgt"], + result="错过", + miss_reason=d["reason"], + opened_at=app_now_str(), + closed_at=app_now_str(), + ) + conn.commit() + conn.close() + flash("已记录错过机会") + return _redirect_records() + + +@app.route("/add_journal", methods=["POST"]) +@login_required +def add_journal(): + d = request.form + entry_reason_norm = normalize_entry_reason(d.get("entry_reason"), d.get("entry_reason_custom")) + if not entry_reason_norm: + flash("请选择开仓类型") + return _redirect_records() + early_exit_trigger = normalize_early_exit_trigger(d.get("early_exit_trigger")) + early_exit_note = str(d.get("early_exit_note") or "").strip() + if not early_exit_trigger: + flash("请选择离场触发") + return _redirect_records() + if early_exit_trigger == "手动平仓" and not early_exit_note: + flash("手工平仓必须填写补充说明") + return _redirect_records() + if early_exit_trigger != "手动平仓": + early_exit_note = "" + # 兼容字段:仅「手工平仓」记为「主观提前」语义下的「是」 + early_exit_raw = "是" if early_exit_trigger == "手动平仓" else "否" + early_exit_reason_saved = compose_early_exit_reason_saved(early_exit_trigger, early_exit_note) + exit_reason_stored = journal_exit_reason_stored(early_exit_trigger, early_exit_note) + image_filename = None + uploaded_tmp = None + entry_id = uuid.uuid4().hex + file = request.files.get("screenshot") + if file and file.filename: + ext = os.path.splitext(file.filename)[1] + image_filename = f"{uuid.uuid4().hex}{ext}" + save_path = os.path.join(app.config["UPLOAD_FOLDER"], secure_filename(image_filename)) + file.save(save_path) + uploaded_tmp = image_filename + + mood_issues = ",".join(request.form.getlist("mood_issues")) + hold_duration = calc_duration_text(d.get("open_datetime", ""), d.get("close_datetime", "")) + real_rr_text = (d.get("real_rr") or "").strip() + try: + risk_amount_hint = float(d.get("risk_amount_hint") or 0) + pnl_hint = float(d.get("pnl") or 0) + # 口径统一:实际RR = 实际盈亏 / 以损定仓对应的初始风险金额 + if risk_amount_hint > 0: + real_rr_text = f"{(pnl_hint / risk_amount_hint):.4f}" + except Exception: + pass + + want_exchange_chart = d.get("journal_exchange_chart", "").lower() in ("1", "true", "on", "yes") + chart_msg = None + if want_exchange_chart and ORDER_CHART_ENABLED: + coin = (d.get("coin") or "").strip().upper() + symbol_guess = normalize_symbol_input(coin) or coin + exchange_symbol = normalize_okx_symbol(symbol_guess) + title_prefix = f"{symbol_guess} journal {entry_id[:8]}" + marker_payload = { + "entry_ts_ms": _local_input_datetime_to_ms(d.get("open_datetime")), + "exit_ts_ms": _local_input_datetime_to_ms(d.get("close_datetime")), + "entry_price": d.get("entry_price_hint"), + "exit_price": None, + } + try: + chart_fname = f"journal_{entry_id}.png" + saved = generate_multi_timeframe_chart_png( + exchange_symbol, + title_prefix, + timeframes=ORDER_CHART_TFS, + limit=ORDER_CHART_LIMIT, + out_dir=app.config["UPLOAD_FOLDER"], + filename=chart_fname, + filename_prefix="journal", + marker_payload=marker_payload, + marker_timeframes=( + {x.strip().lower() for x in ORDER_CHART_TFS if x and str(x).strip()} + if ORDER_CHART_TFS + else {"5m", "15m", "1h", "4h"} + ), + ) + if saved: + image_filename = saved + chart_msg = f"已生成多周期K线图:/static/images/{saved}" + if uploaded_tmp: + try: + old_path = os.path.join(app.config["UPLOAD_FOLDER"], uploaded_tmp) + if os.path.exists(old_path): + os.remove(old_path) + except Exception: + pass + else: + chart_msg = "已勾选自动生成K线图,但生成失败(返回空)。请检查 Pillow 是否安装、OKX 网络/代理是否正常。" + except Exception as e: + image_filename = uploaded_tmp + chart_msg = f"自动生成K线图失败:{str(e)}" + + conn = get_db() + conn.execute( + """INSERT INTO journal_entries + (id, open_datetime, close_datetime, hold_duration, coin, tf, pnl, entry_reason, exit_reason, + expect_rr, real_rr, early_exit, early_exit_reason, early_exit_trigger, early_exit_note, + mood_score, mood_ai_score, mood_ai_comment, mood_issues, post_breakeven_stare, + new_trade_while_occupied, note, image) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + entry_id, d.get("open_datetime"), d.get("close_datetime"), hold_duration, d.get("coin"), d.get("tf"), + d.get("pnl"), entry_reason_norm, exit_reason_stored, d.get("expect_rr"), real_rr_text, + early_exit_raw, early_exit_reason_saved, early_exit_trigger, early_exit_note, + None, None, None, mood_issues, + d.get("post_breakeven_stare"), d.get("new_trade_while_occupied"), d.get("note"), image_filename + ) + ) + conn.commit() + conn.close() + if chart_msg: + flash(f"交易复盘记录已保存。{chart_msg}") + else: + flash("交易复盘记录已保存") + return _redirect_records() + + +@app.route("/api/journals") +@login_required +def api_journals(): + win = _list_window_from_request() + start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ) + conn = get_db() + rows = conn.execute( + "SELECT * FROM journal_entries WHERE COALESCE(close_datetime, created_at, open_datetime) >= ? " + "AND COALESCE(close_datetime, created_at, open_datetime) <= ? ORDER BY created_at DESC LIMIT 500", + (start_bj, end_bj), + ).fetchall() + conn.close() + result = [] + for r in rows: + item = row_to_dict(r) + item["mood_issues"] = [x for x in (item.get("mood_issues") or "").split(",") if x] + result.append(item) + return jsonify(result) + + +@app.route("/api/journal_prefill", methods=["POST"]) +@login_required +def api_journal_prefill(): + file = request.files.get("screenshot") + if not file or not file.filename: + return jsonify({"ok": False, "msg": "请先选择截图文件"}), 400 + try: + raw = file.read() + if not raw: + return jsonify({"ok": False, "msg": "截图为空"}), 400 + image_b64 = base64.b64encode(raw).decode("utf-8") + except Exception as e: + return jsonify({"ok": False, "msg": f"读取截图失败:{str(e)}"}), 400 + + parsed = ai_extract_journal_from_image(image_b64) + if parsed is None: + return jsonify({"ok": False, "msg": "AI 识别失败,请稍后重试"}), 500 + return jsonify({"ok": True, "data": parsed}) + + +@app.route("/delete_journal/", methods=["POST"]) +@login_required +def delete_journal(jid): + conn = get_db() + row = conn.execute("SELECT image FROM journal_entries WHERE id=?", (jid,)).fetchone() + if row and row["image"]: + img_path = os.path.join(app.config["UPLOAD_FOLDER"], row["image"]) + if os.path.exists(img_path): + os.remove(img_path) + conn.execute("DELETE FROM journal_entries WHERE id=?", (jid,)) + conn.commit() + conn.close() + return jsonify({"ok": True}) + + +@app.route("/api/reviews") +@login_required +def api_reviews(): + win = _list_window_from_request() + start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ) + conn = get_db() + rows = conn.execute( + "SELECT * FROM ai_reviews WHERE created_at >= ? AND created_at <= ? ORDER BY created_at DESC LIMIT 200", + (start_bj, end_bj), + ).fetchall() + conn.close() + return jsonify([row_to_dict(r) for r in rows]) + + +@app.route("/export/review_md/") +@login_required +def export_review_md(rid): + conn = get_db() + row = conn.execute("SELECT * FROM ai_reviews WHERE id=?", (rid,)).fetchone() + conn.close() + if not row: + return Response("review not found", status=404, mimetype="text/plain; charset=utf-8") + + review_type = "日复盘" if row["review_type"] == "daily" else "周复盘" + target_date = row["target_date"] or "-" + created_at = row["created_at"] or app_now_str() + content = (row["content"] or "").strip() + if not content: + content = "(无内容)" + + md = ( + f"# {review_type}报告\n\n" + f"- 目标日期: {target_date}\n" + f"- 生成时间: {created_at}\n" + f"- 报告ID: {row['id']}\n\n" + f"---\n\n" + f"{content}\n" + ) + + safe_target = re.sub(r"[^0-9A-Za-z_-]+", "-", str(target_date)).strip("-") or "unknown-date" + safe_type = "daily" if row["review_type"] == "daily" else "weekly" + filename = f"ai_review_{safe_type}_{safe_target}_{row['id'][:8]}.md" + return _md_response(filename, md) + + +@app.route("/export/reviews_md_bundle") +@login_required +def export_reviews_md_bundle(): + review_type = (request.args.get("review_type") or "").strip().lower() + target_date = (request.args.get("target_date") or "").strip() + if review_type not in ("daily", "weekly"): + return Response("invalid review_type", status=400, mimetype="text/plain; charset=utf-8") + if not target_date: + return Response("target_date required", status=400, mimetype="text/plain; charset=utf-8") + + conn = get_db() + rows = conn.execute( + "SELECT * FROM ai_reviews WHERE review_type=? AND target_date=? ORDER BY created_at ASC, id ASC", + (review_type, target_date), + ).fetchall() + conn.close() + if not rows: + return Response("no reviews found", status=404, mimetype="text/plain; charset=utf-8") + + title = "日复盘" if review_type == "daily" else "周复盘" + lines = [ + f"# {title}汇总报告", + "", + f"- 目标日期: {target_date}", + f"- 条目数量: {len(rows)}", + f"- 导出时间: {app_now_str()}", + "", + "---", + "", + ] + for idx, row in enumerate(rows, 1): + created_at = row["created_at"] or "-" + content = (row["content"] or "").strip() or "(无内容)" + lines.extend( + [ + f"## 第{idx}条", + "", + f"- 报告ID: {row['id']}", + f"- 生成时间: {created_at}", + "", + content, + "", + "---", + "", + ] + ) + md = "\n".join(lines) + safe_target = re.sub(r"[^0-9A-Za-z_-]+", "-", str(target_date)).strip("-") or "unknown-date" + filename = f"ai_reviews_{review_type}_bundle_{safe_target}.md" + return _md_response(filename, md) + + +@app.route("/delete_review/", methods=["POST"]) +@login_required +def delete_review(rid): + conn = get_db() + conn.execute("DELETE FROM ai_reviews WHERE id=?", (rid,)) + conn.commit() + conn.close() + return jsonify({"ok": True}) + + +@app.route("/delete_trade_record/", methods=["POST"]) +@login_required +def delete_trade_record(rid): + conn = get_db() + cur = conn.execute("DELETE FROM trade_records WHERE id=?", (rid,)) + conn.commit() + conn.close() + return jsonify({"ok": cur.rowcount > 0, "deleted": cur.rowcount}) + + +@app.route("/api/trade_record_review_update", methods=["POST"]) +@login_required +def api_trade_record_review_update(): + payload = request.get_json(silent=True) or {} + rec_id = payload.get("id") + try: + rec_id = int(rec_id) + except Exception: + return jsonify({"ok": False, "msg": "记录ID无效"}), 400 + + reviewed_opened_at = str(payload.get("reviewed_opened_at") or "").strip() + reviewed_closed_at = str(payload.get("reviewed_closed_at") or "").strip() + reviewed_stop_loss_raw = payload.get("reviewed_stop_loss") + reviewed_take_profit_raw = payload.get("reviewed_take_profit") + reviewed_result = str(payload.get("reviewed_result") or "").strip() + reviewed_miss_reason = str(payload.get("reviewed_miss_reason") or "").strip() + reviewed_pnl_raw = payload.get("reviewed_pnl_amount") + + if reviewed_result and reviewed_result not in REVIEW_RESULT_OPTIONS: + return jsonify({"ok": False, "msg": "结果仅允许:止盈/止损/保本止盈/移动止盈/手动平仓"}), 400 + + try: + reviewed_open_dt = datetime.strptime(reviewed_opened_at[:19], "%Y-%m-%d %H:%M:%S") + reviewed_close_dt = datetime.strptime(reviewed_closed_at[:19], "%Y-%m-%d %H:%M:%S") + except Exception: + return jsonify({"ok": False, "msg": "开仓/平仓时间格式错误,需为 YYYY-MM-DD HH:MM:SS"}), 400 + if reviewed_close_dt < reviewed_open_dt: + return jsonify({"ok": False, "msg": "平仓时间不能早于开仓时间"}), 400 + hold_seconds = int((reviewed_close_dt - reviewed_open_dt).total_seconds()) + hold_minutes = calc_hold_minutes(hold_seconds) + + try: + reviewed_pnl_amount = float(reviewed_pnl_raw) + except Exception: + return jsonify({"ok": False, "msg": "盈亏必须为数字"}), 400 + reviewed_stop_loss = None + if reviewed_stop_loss_raw not in (None, ""): + try: + reviewed_stop_loss = float(reviewed_stop_loss_raw) + except Exception: + return jsonify({"ok": False, "msg": "止损必须为数字"}), 400 + reviewed_take_profit = None + if reviewed_take_profit_raw not in (None, ""): + try: + reviewed_take_profit = float(reviewed_take_profit_raw) + except Exception: + return jsonify({"ok": False, "msg": "止盈必须为数字"}), 400 + + _MISSING_ER = object() + reviewed_entry_reason_update = _MISSING_ER + if "reviewed_entry_reason" in payload: + s = str(payload.get("reviewed_entry_reason") or "").strip() + if s and not normalize_entry_reason(s): + return jsonify({"ok": False, "msg": "开仓类型须为五种固定枚举整句之一或留空"}), 400 + reviewed_entry_reason_update = s or None + + conn = get_db() + row = conn.execute("SELECT risk_amount FROM trade_records WHERE id=?", (rec_id,)).fetchone() + if not row: + conn.close() + return jsonify({"ok": False, "msg": "记录不存在"}), 404 + risk_amount = row["risk_amount"] + actual_rr = calc_actual_rr(reviewed_pnl_amount, risk_amount) + base_params = [ + reviewed_opened_at, + reviewed_closed_at, + reviewed_stop_loss, + reviewed_take_profit, + round(reviewed_pnl_amount, 4), + reviewed_result or None, + reviewed_miss_reason or None, + hold_seconds, + hold_minutes, + app_now_str(), + actual_rr, + ] + if reviewed_entry_reason_update is not _MISSING_ER: + conn.execute( + """UPDATE trade_records + SET reviewed_opened_at=?, reviewed_closed_at=?, reviewed_stop_loss=?, reviewed_take_profit=?, reviewed_pnl_amount=?, + reviewed_result=?, reviewed_miss_reason=?, reviewed_hold_seconds=?, reviewed_hold_minutes=?, + reviewed_at=?, actual_rr=COALESCE(?, actual_rr), reviewed_entry_reason=? + WHERE id=?""", + tuple(base_params + [reviewed_entry_reason_update, rec_id]), + ) + else: + conn.execute( + """UPDATE trade_records + SET reviewed_opened_at=?, reviewed_closed_at=?, reviewed_stop_loss=?, reviewed_take_profit=?, reviewed_pnl_amount=?, + reviewed_result=?, reviewed_miss_reason=?, reviewed_hold_seconds=?, reviewed_hold_minutes=?, + reviewed_at=?, actual_rr=COALESCE(?, actual_rr) + WHERE id=?""", + tuple(base_params + [rec_id]), + ) + conn.commit() + conn.close() + return jsonify({"ok": True, "id": rec_id, "actual_rr": actual_rr, "hold_minutes": hold_minutes}) + + +@app.route("/manual_transfer", methods=["POST"]) +@login_required +def manual_transfer(): + try: + amount = float(request.form.get("amount", "0")) + except Exception: + flash("划转金额格式错误") + return redirect("/") + from_account = (request.form.get("from_account") or AUTO_TRANSFER_FROM).strip() + to_account = (request.form.get("to_account") or AUTO_TRANSFER_TO).strip() + ok, msg, _ = execute_transfer_usdt(amount, from_account, to_account) + conn = get_db() + conn.execute( + "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", + ("manual", get_trading_day(), amount, from_account, to_account, "success" if ok else "failed", msg[:500]) + ) + conn.commit() + conn.close() + if ok: + flash(f"手动划转成功:{amount}U {from_account}->{to_account}") + else: + flash(f"手动划转失败:{msg}") + return redirect("/") + + +@app.route("/ai_daily_review", methods=["POST"]) +@login_required +def ai_daily_review(): + date = request.form.get("date", "") + conn = get_db() + rows = conn.execute( + "SELECT * FROM journal_entries WHERE substr(open_datetime, 1, 10)=? ORDER BY open_datetime ASC", + (date,) + ).fetchall() + conn.close() + if not rows: + return jsonify({"result": "该日无交易记录"}) + + text = f"【每日交易记录】{date}\n总笔数:{len(rows)}\n\n" + for idx, row in enumerate(rows, 1): + issues = row["mood_issues"] or "无" + exit_one = (row["exit_reason"] or "").strip() or "无" + text += ( + f"{idx}. {row['coin']} {row['tf']} | 盈亏:{row['pnl']}U | RR:{row['real_rr']}\n" + f" 开仓类型:{row['entry_reason'] or '无'}\n" + f" 心态标签:{issues}\n" + f" 平仓/离场:{exit_one}\n" + f" 问题:{issues}\n\n" + ) + + image_paths = [] + for row in rows: + img = row["image"] + if not img: + continue + img_path = os.path.join(app.config["UPLOAD_FOLDER"], img) + if os.path.exists(img_path): + image_paths.append(img_path) + ai_result = ai_review(text, "每日", image_paths=image_paths) + full = f"【AI日复盘 {date}】\n{ai_result}\n\n原始记录:\n{text}" + conn = get_db() + conn.execute( + "INSERT INTO ai_reviews (id, review_type, target_date, content) VALUES (?,?,?,?)", + (uuid.uuid4().hex, "daily", date, full) + ) + conn.commit() + conn.close() + return jsonify({"result": full}) + + +@app.route("/ai_weekly_review", methods=["POST"]) +@login_required +def ai_weekly_review(): + start_date = request.form.get("start_date", "") + end_date = request.form.get("end_date", "") + conn = get_db() + rows = conn.execute( + "SELECT * FROM journal_entries WHERE substr(open_datetime,1,10) >= ? AND substr(open_datetime,1,10) <= ? ORDER BY open_datetime ASC", + (start_date, end_date) + ).fetchall() + conn.close() + if not rows: + return jsonify({"result": "该时间段无交易记录"}) + + text = f"【周交易记录】{start_date}~{end_date}\n总笔数:{len(rows)}\n\n" + for idx, row in enumerate(rows, 1): + issues = row["mood_issues"] or "无" + exit_one = (row["exit_reason"] or "").strip() or "无" + text += ( + f"{idx}. {row['coin']} {row['tf']} | 盈亏:{row['pnl']}U | RR:{row['real_rr']}\n" + f" 开仓类型:{row['entry_reason'] or '无'}\n" + f" 平仓/离场:{exit_one}\n" + f" 心态标签:{issues} | 持仓:{row['hold_duration']}\n\n" + ) + + image_paths = [] + for row in rows: + img = row["image"] + if not img: + continue + img_path = os.path.join(app.config["UPLOAD_FOLDER"], img) + if os.path.exists(img_path): + image_paths.append(img_path) + ai_result = ai_review(text, "周度", image_paths=image_paths) + full = f"【AI周复盘 {start_date}~{end_date}】\n{ai_result}\n\n原始记录:\n{text}" + conn = get_db() + conn.execute( + "INSERT INTO ai_reviews (id, review_type, target_date, content) VALUES (?,?,?,?)", + (uuid.uuid4().hex, "weekly", f"{start_date}~{end_date}", full) + ) + conn.commit() + conn.close() + return jsonify({"result": full}) + +# 启动 +if __name__ == "__main__": + threading.Thread(target=background_task, daemon=True).start() + app.run(host=HOST, port=PORT, debug=DEBUG) diff --git a/crypto_monitor_okx/ecosystem.config.cjs b/crypto_monitor_okx/ecosystem.config.cjs new file mode 100644 index 0000000..adf8ac4 --- /dev/null +++ b/crypto_monitor_okx/ecosystem.config.cjs @@ -0,0 +1,33 @@ +/** + * PM2 进程定义(Ubuntu / Linux)。 + * + * 仅托管 Flask 应用。**SSH SOCKS 隧道请在本机用 screen/tmux/systemd 等方式单独常驻**, + * 与 `.env` 里 `GATE_SOCKS_PROXY` 端口一致即可;不必交给 PM2。 + * + * 使用前:项目根目录存在 `.venv`,且已安装依赖(走 SOCKS 时需 PySocks)。 + * + * 启动: + * pm2 start ecosystem.config.cjs + * 保存开机列表: + * pm2 save && pm2 startup + */ +const path = require("path"); + +const ROOT = __dirname; +const PY = path.join(ROOT, ".venv", "bin", "python"); + +module.exports = { + apps: [ + { + name: "crypto_okx", + cwd: ROOT, + script: path.join(ROOT, "app.py"), + interpreter: PY, + instances: 1, + autorestart: true, + watch: false, + max_memory_restart: "800M", + // app.py 从项目根目录 .env 加载(由 .env.example 复制而来,勿提交 Git) + }, + ], +}; diff --git a/crypto_monitor_okx/scripts/fix_breakeven_labels.py b/crypto_monitor_okx/scripts/fix_breakeven_labels.py new file mode 100644 index 0000000..80b7d04 --- /dev/null +++ b/crypto_monitor_okx/scripts/fix_breakeven_labels.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +""" +一次性修复历史交易记录标签: +将 trade_records 里“止损但实际盈利”的记录改为“保本止盈”。 + +默认条件(可通过参数修改): +- monitor_type = 下单监控 +- result = 止损 +- pnl_amount > 0 + +用法示例: +1) 仅预览(不落库): + python scripts/fix_breakeven_labels.py --db ./crypto.db --dry-run + +2) 执行修复: + python scripts/fix_breakeven_labels.py --db ./crypto.db --apply +""" + +from __future__ import annotations + +import argparse +import sqlite3 +import sys +from pathlib import Path + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Fix historical stop-loss records with positive pnl.") + parser.add_argument("--db", required=True, help="Path to sqlite db file, e.g. ./crypto.db") + parser.add_argument("--monitor-type", default="下单监控", help="Filter by monitor_type (default: 下单监控)") + parser.add_argument("--from-result", default="止损", help="Source result label (default: 止损)") + parser.add_argument("--to-result", default="保本止盈", help="Target result label (default: 保本止盈)") + parser.add_argument("--dry-run", action="store_true", help="Preview only, no write") + parser.add_argument("--apply", action="store_true", help="Execute update") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + db_path = Path(args.db).expanduser().resolve() + if not db_path.exists(): + print(f"[ERR] DB not found: {db_path}") + return 1 + + if args.dry_run and args.apply: + print("[ERR] --dry-run and --apply are mutually exclusive.") + return 1 + if not args.dry_run and not args.apply: + print("[INFO] No mode provided, defaulting to --dry-run.") + args.dry_run = True + + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + cur = conn.cursor() + + where_sql = """ + monitor_type = ? + AND result = ? + AND CAST(COALESCE(pnl_amount, 0) AS REAL) > 0 + """ + params = (args.monitor_type, args.from_result) + + cur.execute(f"SELECT COUNT(*) AS c FROM trade_records WHERE {where_sql}", params) + will_change = int(cur.fetchone()["c"]) + print(f"[INFO] Candidate rows: {will_change}") + + if will_change == 0: + print("[INFO] Nothing to update.") + conn.close() + return 0 + + cur.execute( + f""" + SELECT id, symbol, result, pnl_amount, closed_at + FROM trade_records + WHERE {where_sql} + ORDER BY id DESC + LIMIT 10 + """, + params, + ) + sample = cur.fetchall() + print("[INFO] Sample (latest 10):") + for r in sample: + print( + f" id={r['id']} symbol={r['symbol']} result={r['result']} " + f"pnl={r['pnl_amount']} closed_at={r['closed_at']}" + ) + + if args.dry_run: + print("[DRY-RUN] No write executed.") + conn.close() + return 0 + + cur.execute( + f"UPDATE trade_records SET result=? WHERE {where_sql}", + (args.to_result, *params), + ) + changed = int(cur.rowcount) + conn.commit() + conn.close() + print(f"[DONE] Updated rows: {changed}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) + diff --git a/crypto_monitor_okx/start_utf8.ps1 b/crypto_monitor_okx/start_utf8.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..4a8fd0cbe425e232604b47af88dfdd3bd069e311 GIT binary patch literal 1080 zcmcJOPfNo<5XIkF@H;HE2R*dtK@q`&Xw`z$Vighf5YtpEZ4#PjE%@2h-|V)wLg`JG z%}!=^-u!#}{Z&;%BUZ*fQmj&avy5bTN3d>>Rqb|9`=^ ziritv`1L_9b;bRRb1}bDj~nW^(X~#&*vGq1bq?P*yhE*)Pf6qvIh!@2GOH+I7ig?M zh8$B(OgyJO?P{B~gZ#<91kLhpaSm8^`(DT1v*0@b>rn@+CRn+@-9U=5Stn9GgJRvj zr!MQLByc2!!S68TEpZWdu3%Duw#@f`@ir68`|Bqgs_G5LVW!+ho;o@wq2Zf)Y zv9wLcjA=F}hc-;kO*wJu-)oS;FZ0_H?X>%j{~oF$P>^5ug!bjzq%f<%yF$-KLi}HnE(I) literal 0 HcmV?d00001 diff --git a/crypto_monitor_okx/templates/index.html b/crypto_monitor_okx/templates/index.html new file mode 100644 index 0000000..b992d3f --- /dev/null +++ b/crypto_monitor_okx/templates/index.html @@ -0,0 +1,1419 @@ + + + + + 加密货币 | 交易监控复盘系统 + + + +{% macro period_stats(title, s) %} +
+

{{ title }}

+
{{ s.range_label }}
+
+
开单次数
{{ s.opens_count }}
+
平仓笔数
{{ s.closed_count }}
+
胜率
{% if s.win_rate_pct is not none %}{{ s.win_rate_pct }}%{% else %}-{% endif %}
+
净盈亏(U)
{{ s.net_pnl_u }}
+
亏损额合计(U)
{{ s.loss_sum_u }}
+
单笔最大亏损(U)
{% if s.max_single_loss is not none %}{{ s.max_single_loss }}{% else %}-{% endif %}
+
单笔最大盈利(U)
{% if s.max_single_profit is not none %}{{ s.max_single_profit }}{% else %}-{% endif %}
+
最大回撤(U)
{{ s.max_drawdown_u }}
+
当前连续亏损笔数
{{ s.consecutive_losses }}
+
最长连续亏损(交易日)
{{ s.max_loss_streak_days }} 天
+
期内最大亏损日
{% if s.worst_day %}{{ s.worst_day }}({{ s.worst_day_pnl }}U){% else %}-{% endif %}
+
+
+{% endmacro %} +
+

加密货币|交易监控 + AI复盘一体化

+ + {% with msg=get_flashed_messages() %}{% if msg %}
{{ msg[0] }}
{% endif %}{% endwith %} + +
+ 列表筛选(UTC,默认当日):{{ list_window.label }} + + + + + + + 统计页仍按北京时间 {{ stats_bundle.stats_reset_hour|default(reset_hour) }}:00 切日 +
+
+ 数据导出(v{{ data_export_version }} CSV,UTF-8;交易记录含开仓类型列,复盘单独导出): + 交易记录 + 复盘记录 + 关键位(当前) + 关键位历史 +
+
+
总交易
{{ total }}
+
错过次数
{{ miss_count }}
+
胜率
{{ rate }}%
+
训练总资金(资金账户)
{{ total_capital }}U
+
交易日
{{ trading_day }}
+
当日资金(交易账户)
{{ current_capital }}U
+
+
实时价格更新时间:--(北京时间 UTC+8)
+ +
+ {% if page == 'trade' %} +
+
+

关键位监控(5m)

+ {% if focus_key_id %} + 放大查看K线(默认200根) + {% else %} + 输入币种查看K线 + {% endif %} +
+
+ + + + + + + + + +
+
{{ key_gate_rule_text }}
+
+ {% for k in key %} +
+
{{ k.symbol }} | {{ k.monitor_type }} | {{ '做多' if k.direction == 'long' else '做空' }}
+
+ 上:{{ k.upper }} 下:{{ k.lower }} + {% if k.fib_entry_price %}| 挂E:{{ k.fib_entry_price }}{% endif %} + | 已提醒:{{ k.notification_count or 0 }}/{{ k.max_notify or 3 }} + {% if k.monitor_type in ['箱体突破','收敛突破'] %}| 方案:{{ '标准' if (k.sl_tp_mode or 'standard') == 'standard' else ('1.5H' if k.sl_tp_mode == 'box_1p5' else '趋势') }}{% endif %} + | 保本:{{ '开' if k.breakeven_enabled else '关' }} + | 现价:- + | 距上沿:- + | 距下沿:- + | 门控:- + +
+ +
+ {% endfor %} +
+
+

关键位历史(满次提醒或手动删除)

+
满 {{ key_alert_max_times }} 次企业微信提醒后自动移入此处;手动删除也会归档。受顶栏 UTC 列表时间窗筛选。
+
+ {% for h in key_history %} +
+
+ {{ h.symbol }} | {{ h.monitor_type }} | {{ '做多' if h.direction == 'long' else '做空' }} | {{ h.close_reason }} + +
+
上:{{ h.upper }} 下:{{ h.lower }} | 提醒次数:{{ h.notification_count }} | {{ (h.closed_at or '-')[:16] }}
+ {% if h.last_alert_message %}
{{ h.last_alert_message[:200] }}{% if h.last_alert_message|length > 200 %}…{% endif %}
{% endif %} +
+ {% else %} +
暂无历史
+ {% endfor %} +
+
+
+ +
+
+

实盘下单监控

+ {% if focus_order_id %} + 放大查看K线(100根) + {% else %} + 暂无持仓可放大 + {% endif %} +
+
+ 规则:单仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x; + {% if can_trade %}可开仓{% else %}不可开仓(有持仓或未到北京时间 {{ reset_hour }}:00){% endif %}; + 按风险比例自动计算仓位 +
+
+ 以损定仓:风险 {{ risk_percent }}% |移动保本:{{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}% +
+
+ 划转:自动划转 {{ '开启' if auto_transfer_enabled else '关闭' }}(每天北京时间 {{ auto_transfer_bj_hour }}:00起该整点小时内尝试;账簿按 UTC 自然日去重;界面时间为北京;将 {{ auto_transfer_to }} 补足到 {{ auto_transfer_amount }}U,来自 {{ auto_transfer_from }}) +
+
+ + + + +
+
+ + + + + + + + 成交价自动取交易所实时+成交回报 + + + + + +
+
+ {% for o in order %} +
+
{{ o.symbol }} | {{ '做多' if o.direction == 'long' else '做空' }}
+
+ 风格:{{ o.trade_style or 'trend' }} | 风险:{{ o.risk_percent or '-' }}%≈{{ o.risk_amount or '-' }}U + | {% if o.breakeven_enabled %}移动保本:开{% else %}移动保本:关{% endif %} {{ o.breakeven_rr_trigger or '-' }}R→{{ o.breakeven_price or '-' }} +
+ 成交:{{ o.trigger_price }} 止损:{{ o.stop_loss }} 止盈:{{ o.take_profit }} + | 盈亏比:{% if o.rr_ratio is not none %}1:{{ '%.2f'|format(o.rr_ratio) }}{% else %}-{% endif %} + | 现价:- + | 浮盈亏:- + | 保证金:{{ o.margin_capital }}U | 杠杆:{{ o.leverage }}x | 仓位占比:{{ o.position_ratio }}% +
+ 平仓 +
+ {% endfor %} +
+
+ {% endif %} + + {% if page == 'records' %} +
+

交易记录 & 错过机会

+
+ +
+
+ + + {% for r in record %} + + {% set pnl_val = (r.pnl_amount or 0)|float %} + + + + + {% set stop_show = r.display_open_stop_loss or r.initial_stop_loss or r.stop_loss %} + {% set tp_show = r.effective_take_profit or r.take_profit %} + + + + + + + + {% set pnl_val = (r.effective_pnl_amount or 0)|float %} + + + + + {% endfor %} +
品种类型方向成交止损(开仓)止盈基数杠杆持仓分钟开仓时间(北京)平仓时间(北京)盈亏U结果操作
{{ r.symbol }}{{ r.monitor_type }}{% if r.key_signal_type %} · {{ r.key_signal_type }}{% endif %}{{ '做多' if r.direction == 'long' else '做空' }}{{ r.trigger_price }}{{ price_fmt(r.symbol, stop_show) }}{{ price_fmt(r.symbol, tp_show) }}{{ r.margin_capital or '-' }}{{ r.leverage or '-' }}{{ r.effective_hold_minutes or 0 }}{{ (r.effective_opened_at or '-')[:16] }}{{ (r.effective_closed_at or r.created_at or '-')[:16] }}{{ r.effective_pnl_amount or 0 }} + {% set effective_result = r.effective_result %} + {% if effective_result in ["止盈","保本止盈","移动止盈"] %}{{ effective_result }} + {% elif effective_result in ["止损","强制清仓","手动平仓"] %}{{ effective_result }} + {% else %}{{ effective_result }}{% endif %} + + + + +
+
+
+ +
+

记录错过机会

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

交易复盘记录上传(含截图)

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

AI复盘(按交易记录)

+
+ + + + + + + +
+ + +
+
+ 交易复盘记录 +
+
+
+ AI历史复盘 +
+
+
+
+ {% endif %} +
+ + {% if page == 'stats' %} +
+
+

数据统计

+ +
+
+
+
持仓占用导致错过(累计)
{{ occupied_miss_total }}
+
+
+ 统计分析按北京时间 {{ stats_bundle.stats_reset_hour }}:00切日计入(与顶栏 UTC 列表窗无关)。历史总开仓(累计): + {{ stats_bundle.total_opens_all }} 次 +
+
+ +
+ {% for seg in stats_bundle.segments %} + + {% endfor %} +
+
+ {% endif %} +
+ + +
+
+
+
详情
+ +
+
+ +
+
+ + + + \ No newline at end of file diff --git a/crypto_monitor_okx/templates/key_focus.html b/crypto_monitor_okx/templates/key_focus.html new file mode 100644 index 0000000..41a633a --- /dev/null +++ b/crypto_monitor_okx/templates/key_focus.html @@ -0,0 +1 @@ +ok2 \ No newline at end of file diff --git a/crypto_monitor_okx/templates/key_focus_v2.html b/crypto_monitor_okx/templates/key_focus_v2.html new file mode 100644 index 0000000..622c7da --- /dev/null +++ b/crypto_monitor_okx/templates/key_focus_v2.html @@ -0,0 +1,260 @@ + + + + + 关键位放大 | K线查看 + + + +
+
+
+
+ 返回首页 + 关键位放大(可输入币种) +
+
最近刷新:--
+
+ +
+ + + + + + + + + + + + + + +
+
+ +
+
+
交易对
-
+
监控类型
-
+
方向
-
+
上沿/阻力
-
+
下沿/支撑
-
+
现价
-
+
距上沿
-
+
距下沿
-
+
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/crypto_monitor_okx/templates/login.html b/crypto_monitor_okx/templates/login.html new file mode 100644 index 0000000..f31ba84 --- /dev/null +++ b/crypto_monitor_okx/templates/login.html @@ -0,0 +1,107 @@ + + + + + 系统登录 + + + + + + diff --git a/crypto_monitor_okx/templates/order_focus.html b/crypto_monitor_okx/templates/order_focus.html new file mode 100644 index 0000000..0811e93 --- /dev/null +++ b/crypto_monitor_okx/templates/order_focus.html @@ -0,0 +1,194 @@ + + + + + 实盘下单放大 | 100根K线 + + + +
+
+
+
+ 返回首页 + 实盘下单放大(100根K线) +
+
最近刷新:--
+
+ {% if orders %} +
+ + + + + + +
+ {% else %} +
当前没有激活订单,无法展示放大K线。
+ {% endif %} +
+ + {% if orders %} +
+
+
交易对
-
+
方向
-
+
成交价
-
+
止损
-
+
止盈
-
+
盈亏比
-
+
现价
-
+
浮盈亏
-
+
+
+ +
+
+
+ {% endif %} +
+ +{% if orders %} + + +{% endif %} + + diff --git a/crypto_monitor_okx/templates/order_focus_v2.html b/crypto_monitor_okx/templates/order_focus_v2.html new file mode 100644 index 0000000..b2ecdf9 --- /dev/null +++ b/crypto_monitor_okx/templates/order_focus_v2.html @@ -0,0 +1,210 @@ + + + + + 实盘下单放大 | 100根K线 + + + +
+
+
+
+ 返回首页 + 实盘下单放大(100根K线) +
+
最近刷新:--
+
+ {% if orders %} +
+ + + + + + +
+ {% else %} +
当前没有激活订单,无法展示放大K线。
+ {% endif %} +
+ + {% if orders %} +
+
+
交易对
-
+
方向
-
+
成交价
-
+
止损
-
+
止盈
-
+
盈亏比
-
+
现价
-
+
浮盈亏
-
+
+
+ +
+
+
+ {% endif %} +
+ +{% if orders %} + + +{% endif %} + + + diff --git a/crypto_monitor_okx/更新文档.md b/crypto_monitor_okx/更新文档.md new file mode 100644 index 0000000..05e4aa9 --- /dev/null +++ b/crypto_monitor_okx/更新文档.md @@ -0,0 +1,79 @@ +# 界面与风控更新说明(OKX 实例) + +与 Gate / Binance 主站对齐的列表窗、统计分品类、交易记录展示、复盘与移动保本交易所同步;OKX 仍为 **三页导航**(交易执行 / 记录复盘 / 统计),关键位监控合并在 **交易执行** 页,**无** Gate 独立「关键位监控」页与斐波限价监控。 + +## 顶栏导航(3 项) + +| 顺序 | 名称 | 路由 | 说明 | +|------|------|------|------| +| 1 | 交易执行 | `/trade` | 关键位监控 + 实盘下单(**默认首页** `/` → `/trade`) | +| 2 | 交易记录与复盘 | `/records` | 交易记录、复盘表单、AI 历史(受顶栏 UTC 时间窗筛选) | +| 3 | 统计分析 | `/stats` | 按北京时间交易日切日 + 分品类统计块 | + +## 列表时间窗(UTC,全站顶栏) + +共用模块:仓库根目录 `history_window_lib.py`(与 Gate / Binance 一致)。 + +| 项 | 说明 | +|----|------| +| 默认 | **UTC 当日**(`win_preset=utc_today`) | +| 可选 | 近 24 小时、近 7 天、自定义起止(UTC) | +| 作用范围 | 关键位历史、交易记录列表、复盘 API、AI 历史 API、导出「交易记录」「关键位历史」 | +| 与统计 | **仅影响列表/导出**;统计页仍按北京时间 `TRADING_DAY_RESET_HOUR`(默认 8:00)切日 | +| 切换 | 顶栏「列表筛选(UTC)」→ 应用(保留当前路由 query) | + +## 交易记录与复盘 + +- 列表 **止损(开仓)**:展示 `initial_stop_loss` 快照(`display_open_stop_loss`)。 +- 类型列显示 `monitor_type` 与 `key_signal_type`(若有)。 +- 平仓入库:`stop_loss` / `initial_stop_loss` 为开仓止损快照;机器单 `entry_reason` 可按 `key_signal_type` 自动映射(箱体突破 / 收敛突破 → 四条固定关键位开仓类型文案)。 +- 复盘:开仓类型下拉含四条关键位固定文案 +「其他」;离场触发含 **「止盈」**;从交易记录填入时按结果与信号预填。 +- 复盘 K 线图:以 **平仓时间** 为锚点向前约 `ORDER_CHART_LIMIT`(默认 100)根(`_fetch_ohlcv_ending_at`)。 +- `/api/journals`、`/api/reviews` 与顶栏 UTC 窗一致。 + +### 导出(交易记录 v3) + +- 文件名:`trade_records_v3_YYYYMMDD.csv` +- 含 `key_signal_type`、`initial_stop_loss`、计划/实际 RR、`risk_amount` 等;末列「开仓类型」为有效展示文案。 +- 受 UTC 列表窗限制;关键位历史导出同理。 + +## 实盘下单(交易执行页) + +- **移动保本**:表单可勾选「启用移动保本」;触发阶梯上移后 **先撤后挂** 交易所 TP/SL(`replace_active_monitor_tpsl_on_exchange`),仅成功后才写库;企业微信提示含「交易所:已先撤后挂止盈止损」。未配置实盘 API 时仅更新本地止损。 +- 开仓 TP/SL 仍通过 OKX `attachAlgoOrds`(与原有逻辑一致);重挂使用 ccxt `stopLoss` / `takeProfit` 参数,触发价经 `_okx_algo_trigger_price_str` 格式化。 + +## 统计分析页(`/stats`) + +| 项 | 说明 | +|----|------| +| 切日 | 北京时间;边界 = `TRADING_DAY_RESET_HOUR:00`(默认 8) | +| 品类下拉 | 全部交易、下单监控、关键位箱体突破、关键位收敛结构、关键位斐波0.618、关键位斐波0.786 | +| URL | `stats_segment=`(`all` / `manual` / `key_box` / `key_conv` / `key_fib618` / `key_fib786`) | +| 与 UTC 窗 | 统计 **不** 随顶栏列表窗变化 | + +## 斐波关键位监控(与 Gate / Binance 对齐) + +| 项 | 说明 | +|----|------| +| 类型 | **斐波回调0.618**、**斐波回调0.786**(交易执行页关键位表单) | +| 同币互斥 | 每币仅一条斐波监控 | +| 挂单价 E | 做多 `E = H − ratio×(H−L)`;做空 `E = L + ratio×(H−L)`;SL/TP 为 L/H | +| 添加后 | 立即在 OKX 挂限价单;卡片显示 **挂E**、限价单 ID | +| 失效 | 标记价触达止盈侧且限价未成交 → 仅撤本条限价单(`cancel_fib_limit_order`) | +| 成交后 | 挂交易所 TP/SL → 写入 `order_monitors`(`monitor_type=关键位监控`,`key_signal_type=斐波回调…`)→ 从关键位表移除 | +| 轮询 | `check_fib_key_monitors()`(与箱体/收敛 `check_key_monitors()` 分离) | +| 盈亏比 | 计划 RR 须 > `KEY_AUTO_MIN_PLANNED_RR`(默认 1.5) | +| 日成交量 | 排名前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30) | + +计算逻辑见仓库根目录 `fib_key_monitor_lib.py`。 + +## 与 Gate 的差异(其余) + +- 无独立「关键位监控」导航页(斐波在 **交易执行** 页添加)。 +- 无交易所已实现盈亏同步(`/api/sync_exchange_pnl`)。 +- 箱体/收敛仍为 **提醒** 模式,不自动市价开仓(Gate/Binance 主站为自动开仓)。 + +## 配置与部署 + +- 详见 `.env.example` 中 OKX(`OKX_*`)与通用风控项。 +- 代码更新后请 **重启 OKX 监控进程**;旧库行不做批量回填,展示字段有则用之、无则回退。 diff --git a/crypto_monitor_okx/部署文档.md b/crypto_monitor_okx/部署文档.md new file mode 100644 index 0000000..b1a13b1 --- /dev/null +++ b/crypto_monitor_okx/部署文档.md @@ -0,0 +1,349 @@ +# `crypto_monitor` 本地部署 + SSH SOCKS 转发 + PM2 启动指南(Ubuntu) + +本文面向:**本地 Ubuntu 机器运行项目**,但 **本机直连 OKX 会被 TLS/SNI reset** 的场景。解决思路是: + +- 本机启动 `ssh -D` 动态转发,把 **SOCKS5 出口**放到你可正常访问 OKX 的 VPS 上 +- 项目通过环境变量 `OKX_SOCKS_PROXY=socks5h://127.0.0.1:1080` 让 `ccxt` 走 SOCKS +- 用 `pm2` 托管 **SSH 隧道** 与 **Flask 应用**(你也可以只用 `screen`,但本文按你要求用 PM2) + +> 安全提醒:不要把 `.env`、私钥 `.pem`、OKX API Key 提交到 Git;文档里只用占位符。 + +--- + +## 0. 你需要准备的东西 + +- 一台 **Ubuntu** 本地机器(下文称“本机”) +- 一台可 SSH 登录、且 **能正常访问 OKX** 的 VPS(示例公网 IP:`47.76.87.111`,用户:`root`) +- VPS 登录方式:**SSH 私钥**(推荐)或密码(不推荐用于无人值守) +- 本机已安装: + - `python3`、`python3-venv`、`pip`(或 `python3-pip`) + - `git`(可选) + - `curl`、`ssh` + - `node` + `npm`(用于安装 `pm2`) + +--- + +## 1. 从云服务器把项目同步到本地(推荐:打包下载) + +在云服务器项目目录(包含 `app.py` 的目录)执行: + +```bash +cd /opt/crypto_monitor/crypto_monitor_okx + +# 可选:清理 Python 缓存,减少小文件传输 +find . -type d -name __pycache__ -prune -exec rm -rf {} + +find . -type f -name "*.pyc" -delete + +tar -czf crypto_monitor.tgz . +``` + +下载 `crypto_monitor.tgz` 到本机后解压: + +```bash +mkdir -p /opt/crypto_monitor/crypto_monitor_okx +cd /opt/crypto_monitor +tar -xzf crypto_monitor.tgz -C crypto_monitor_okx +cd crypto_monitor_okx +cp -n .env.example .env # 若尚无 .env +``` + +--- + +## 2. 配置 SSH 私钥与 `~/.ssh/config`(推荐) + +把私钥放到本机(示例:`~/.ssh/vps1.pem`),并设置权限: + +```bash +mkdir -p ~/.ssh +chmod 700 ~/.ssh +mv ~/Downloads/vps1.pem ~/.ssh/vps1.pem +chmod 600 ~/.ssh/vps1.pem +``` + +编辑 `~/.ssh/config`(没有就创建),添加: + +```sshconfig +Host okx-vps + HostName 47.76.87.111 + User root + IdentityFile ~/.ssh/vps1.pem + IdentitiesOnly yes + ServerAliveInterval 30 + ServerAliveCountMax 3 + ExitOnForwardFailure yes + BatchMode yes +``` + +测试: + +```bash +ssh okx-vps true +``` + +> 如果你还没完全切到密钥登录(还会交互要密码),先把 `BatchMode yes` 注释掉,等密钥登录稳定后再打开。 + +--- + +## 3. 先手工验证:SSH SOCKS + OKX API + +### 3.1 开一个本地 SOCKS(1080) + +```bash +ssh -N -D 127.0.0.1:1080 okx-vps +``` + +保持该进程运行(另开终端继续下面步骤)。 + +### 3.2 验证 OKX 走 SOCKS 可用 + +```bash +curl -4 -Iv --max-time 15 --proxy socks5h://127.0.0.1:1080 https://www.okx.com/api/v5/public/time +``` + +看到 `HTTP/2 200`(或至少 TLS 握手成功且返回 JSON)即 OK。 + +--- + +## 4. Python 虚拟环境(venv) + +在本机项目目录: + +```bash +cd /opt/crypto_monitor/crypto_monitor_okx + +python3 -m venv .venv +source .venv/bin/activate + +python -m pip install -U pip +pip install flask requests ccxt werkzeug PySocks Pillow +``` + +> 说明:本仓库当前没有 `requirements.txt`。如果你希望“完全复刻云服务器依赖”,可以在云服务器项目环境里执行 `pip freeze > requirements.txt` 带回本机再 `pip install -r requirements.txt`(记得删掉明显无关/体积巨大的包)。 + +建议减少 `.pyc` 垃圾文件(可选): + +```bash +export PYTHONDONTWRITEBYTECODE=1 +``` + +--- + +## 5. 配置环境变量(`.env.example` → `.env`) + +| 文件 | 是否进 Git | 说明 | +|------|------------|------| +| **`.env.example`** | ✅ 是 | 变量模板与注释,可随 `git pull` 更新 | +| **`.env`** | ❌ 否 | 本机真实配置;`app.py` **只读此文件** | + +### 5.1 首次配置 + +```bash +cd /opt/crypto_monitor/crypto_monitor_okx + +cp -n .env.example .env # 已存在 .env 时不覆盖 +nano .env +``` + +### 5.2 备份与 `git pull` + +- **`.env` 不在 Git 中**:`git pull` **不会**覆盖本地 `.env`。 +- 远端若更新 **`.env.example`**,pull 后请**手动**把新增变量补进你的 `.env`。 +- **升级前备份**:`cp .env .env.backup.$(date +%Y%m%d)`;恢复:`cp .env.backup.YYYYMMDD .env`。 +- **换机**:`scp` 复制 `.env`,或新机 `cp .env.example .env` 后重填。 + +### 5.3 必填项检查(OKX + 代理) + +至少确认/填写这些关键项(示例): + +```env +APP_HOST=127.0.0.1 +APP_PORT=5000 + +# OKX(如需实盘) +LIVE_TRADING_ENABLED=false +OKX_API_KEY=... +OKX_API_SECRET=... +OKX_API_PASSPHRASE=... + +# OKX 出口:走本机 SSH 动态转发 SOCKS +OKX_SOCKS_PROXY=socks5h://127.0.0.1:1080 + +# 开仓多周期K线图(可选) +# ORDER_CHART_ENABLED=true +# ORDER_CHART_TFS=4h,1h,15m,5m +# ORDER_CHART_LIMIT=100 +# ORDER_CHART_DIR=static/images/order_charts +# DAILY_OPEN_ALERT_THRESHOLD=5 + +# Ollama(如本机跑) +OLLAMA_API=http://127.0.0.1:11434/api/generate +AI_MODEL=你的模型名 +``` + +> `OKX_SOCKS_PROXY` 使用 `socks5h`:让 SOCKS 侧做域名解析(更贴近你 `curl --proxy socks5h://...` 的成功路径)。 + +--- + +## 6. 本机手工启动(验证 Flask) + +确保: + +1. SOCKS 隧道已运行(127.0.0.1:1080) +2. 虚拟环境已 `activate` +3. `.env` 已配置 + +启动: + +```bash +cd /opt/crypto_monitor/crypto_monitor_okx +source .venv/bin/activate +python app.py +``` + +浏览器访问:`http://127.0.0.1:5000`(或你在 `.env` 配的端口)。 + +--- + +## 7. 安装 PM2(Node) + +```bash +sudo npm i -g pm2 +pm2 -v +``` + +--- + +## 8. 用 PM2 启动 SSH SOCKS 隧道(推荐:密钥免交互) + +### 8.1 启动隧道进程 + +```bash +pm2 start "ssh" --name okx-socks-tunnel -- \ + -N -D 127.0.0.1:1080 okx-vps \ + -o ServerAliveInterval=30 -o ServerAliveCountMax=3 \ + -o ExitOnForwardFailure=yes -o BatchMode=yes +``` + +查看日志: + +```bash +pm2 logs okx-socks-tunnel --lines 200 +``` + +### 8.2 仍然验证 OKX + +```bash +curl -4 -Iv --max-time 15 --proxy socks5h://127.0.0.1:1080 https://www.okx.com/api/v5/public/time +``` + +### 8.3 开机自启(可选) + +```bash +pm2 save +pm2 startup +``` + +--- + +## 9. 用 PM2 启动 Flask(`app.py`) + +`pm2` 管理 Python 的常用方式是直接启动解释器: + +```bash +cd /opt/crypto_monitor/crypto_monitor_okx + +pm2 start /opt/crypto_monitor/crypto_monitor_okx/.venv/bin/python --name crypto-monitor -- \ + /opt/crypto_monitor/crypto_monitor_okx/app.py +``` + +> 若项目目录与上文不一致,请替换为实际绝对路径;或用 `readlink -f app.py` 得到绝对路径。 + +查看日志: + +```bash +pm2 logs crypto-monitor --lines 200 +``` + +保存进程列表: + +```bash +pm2 save +``` + +--- + +## 10. 常见问题排查(高频) + +### 10.1 OKX 仍然失败:先看隧道是否在 + +```bash +ss -lntp | grep 1080 || true +pm2 status +``` + +### 10.2 `pm2` 里的 `ssh` 立刻退出 + +常见原因: + +- 私钥权限不对(`chmod 600`) +- `~/.ssh/config` 写错 `HostName/User/IdentityFile` +- 开了 `BatchMode yes` 但仍需要密码(会失败) + +### 10.3 `ccxt` SOCKS 报错 / 代理不生效 + +本机 Python 依赖通常需要: + +```bash +source .venv/bin/activate +pip install PySocks +``` + +### 10.4 `.pyc` 很多导致同步慢 + +`.pyc` 是缓存,删除不影响功能: + +```bash +find . -type d -name __pycache__ -prune -exec rm -rf {} + +find . -type f -name "*.pyc" -delete +``` + +--- + +## 11. 推荐的启动顺序(固定习惯) + +1. `pm2` 启动 `okx-socks-tunnel` +2. `curl --proxy socks5h://127.0.0.1:1080 ...` 验证 OKX +3. `pm2` 启动 `crypto-monitor` + +--- + +## 12. 免责声明 + +交易所有合规与地区政策要求。请确保你的使用方式符合当地法律法规与交易所条款。本文仅描述网络与工程部署技术路径。 + + + + +写好了,脚本路径: + +- `scripts/fix_breakeven_labels.py` + +你在 Ubuntu 上这样用: + +1) 先预览(不写库): +```bash +python scripts/fix_breakeven_labels.py --db ./crypto.db --dry-run +``` + +2) 确认后执行: +```bash +python scripts/fix_breakeven_labels.py --db ./crypto.db --apply +``` + +默认修复条件就是你要的: +- `monitor_type='下单监控'` +- `result='止损'` +- `pnl_amount > 0` +- 改成 `result='保本止盈'` + +如果你想,我还可以再给你一条“先自动备份 DB 再执行”的一键命令。 \ No newline at end of file diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..0fe9a4f --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,90 @@ +# 环境一键部署 + +为仓库内各子项目创建 Python 虚拟环境、安装依赖、初始化 `.env` 与静态目录。 + +## Windows(推荐) + +双击仓库根目录 **`一键部署.bat`**,或在 PowerShell 中: + +```powershell +cd C:\path\to\crypto_monitor +.\deploy\setup_env.ps1 +``` + +仅部署部分项目: + +```powershell +.\deploy\setup_env.ps1 -Only binance,gate_bot +``` + +重建虚拟环境: + +```powershell +.\deploy\setup_env.ps1 -RecreateVenv +``` + +跳过 PM2、跳过复制 `.env`: + +```powershell +.\deploy\setup_env.ps1 -SkipPm2 -SkipEnvCopy +``` + +## Linux / macOS + +**Ubuntu / Debian 首次部署**(若 `python -m venv` 报 `ensurepip is not available`): + +```bash +apt update +apt install -y python3.10-venv python3-pip curl # 版本号与 python3 --version 一致 +bash deploy/setup_env.sh +``` + +脚本在 **root** 下会自动尝试 `apt install python*-venv`;非 root 请先装系统包或使用: + +```bash +sudo bash deploy/setup_env.sh --install-system-deps +``` + +```bash +cd /opt/crypto_monitor +bash deploy/setup_env.sh +bash deploy/setup_env.sh --only binance,gate +bash deploy/setup_env.sh --recreate-venv +``` + +若在 Windows 编辑过脚本后在 Linux 报错 `set: pipefail: invalid option name`,先去掉 CRLF 再执行: + +```bash +sed -i 's/\r$//' deploy/setup_env.sh +# 或: apt install -y dos2unix && dos2unix deploy/setup_env.sh +bash deploy/setup_env.sh +``` + +## 脚本会做什么 + +| 步骤 | 说明 | +|------|------| +| 检查 Python | 需要 **3.10+** | +| `crypto_monitor_*` | 各目录 `.venv` + `pip install -r ../requirements.txt` | +| `manual_trading_hub` | 独立 `requirements.txt` | +| `.env` | 若不存在则从 `.env.example` 复制(**不覆盖**已有) | +| 目录 | 创建 `static/images`、`static/images/order_charts` | +| PM2 | 若已装 Node.js 且未 `-SkipPm2`,尝试 `npm install -g pm2` | + +## 部署之后 + +1. 编辑各子目录 **`.env`**(API、登录密码、SOCKS 代理等)。 +2. 本地试运行(以 Binance 为例): + + ```bash + cd crypto_monitor_binance + source .venv/bin/activate # Windows: .\.venv\Scripts\activate + python app.py + ``` + +3. 服务器长期运行见各目录 **《部署文档.md》**(SSH SOCKS、PM2)。 + +## 依赖说明 + +- 四个监控子项目共用仓库根目录 **[requirements.txt](../requirements.txt)**。 +- 走 SOCKS 代理时必须安装 **PySocks**(已包含在 requirements 中)。 diff --git a/deploy/setup_env.ps1 b/deploy/setup_env.ps1 new file mode 100644 index 0000000..6fd6c34 --- /dev/null +++ b/deploy/setup_env.ps1 @@ -0,0 +1,210 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + crypto_monitor 一键环境部署(Windows PowerShell) + +.DESCRIPTION + - 为各子项目创建 Python venv 并安装依赖 + - 从 .env.example 复制 .env(不覆盖已有) + - 创建 static/images 等运行时目录 + - 可选安装 PM2(需已安装 Node.js) + +.EXAMPLE + .\deploy\setup_env.ps1 + .\deploy\setup_env.ps1 -Only binance,gate_bot + .\deploy\setup_env.ps1 -SkipPm2 +#> +param( + [string]$Only = "all", + [switch]$SkipPm2, + [switch]$SkipEnvCopy, + [switch]$RecreateVenv +) + +$ErrorActionPreference = "Stop" +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +$DeployDir = $PSScriptRoot +$RepoRoot = (Resolve-Path (Join-Path $DeployDir "..")).Path +$ReqFile = Join-Path $RepoRoot "requirements.txt" +$HubReqFile = Join-Path $RepoRoot "manual_trading_hub\requirements.txt" + +$MonitorProjects = @( + @{ Key = "binance"; Dir = "crypto_monitor_binance" }, + @{ Key = "gate"; Dir = "crypto_monitor_gate" }, + @{ Key = "gate_bot"; Dir = "crypto_monitor_gate_bot" }, + @{ Key = "okx"; Dir = "crypto_monitor_okx" } +) +$HubProject = @{ Key = "hub"; Dir = "manual_trading_hub" } + +function Write-Step([string]$Msg) { + Write-Host "" + Write-Host "==> $Msg" -ForegroundColor Cyan +} + +function Test-Python310 { + $py = Get-Command python -ErrorAction SilentlyContinue + if (-not $py) { + throw "未找到 python。请安装 Python 3.10+ 并加入 PATH:https://www.python.org/downloads/" + } + $verText = & python -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" + $parts = $verText.Trim() -split "\." + $major = [int]$parts[0] + $minor = [int]$parts[1] + if ($major -lt 3 -or ($major -eq 3 -and $minor -lt 10)) { + throw "需要 Python 3.10+,当前: $verText" + } + Write-Host "Python: $(python --version 2>&1)" -ForegroundColor DarkGray +} + +function Should-Include([string]$Key, [string[]]$Selected) { + if ($Selected -contains "all") { return $true } + return $Selected -contains $Key +} + +function Setup-MonitorProject([hashtable]$Proj) { + $projPath = Join-Path $RepoRoot $Proj.Dir + if (-not (Test-Path $projPath)) { + Write-Host " 跳过(目录不存在): $($Proj.Dir)" -ForegroundColor Yellow + return + } + Write-Step "$($Proj.Dir)" + Push-Location $projPath + try { + $venvDir = Join-Path $projPath ".venv" + $venvPy = Join-Path $venvDir "Scripts\python.exe" + $venvPip = Join-Path $venvDir "Scripts\pip.exe" + + if ($RecreateVenv -and (Test-Path $venvDir)) { + Write-Host " 删除旧 venv ..." + Remove-Item -Recurse -Force $venvDir + } + if (-not (Test-Path $venvPy)) { + Write-Host " 创建 venv ..." + & python -m venv .venv + } + + Write-Host " 升级 pip ..." + & $venvPy -m pip install -U pip setuptools wheel -q + + Write-Host " 安装依赖 (requirements.txt) ..." + & $venvPip install -r $ReqFile -q + + if (-not $SkipEnvCopy) { + $envExample = Join-Path $projPath ".env.example" + $envFile = Join-Path $projPath ".env" + if ((Test-Path $envExample) -and -not (Test-Path $envFile)) { + Copy-Item $envExample $envFile + Write-Host " 已复制 .env.example -> .env" -ForegroundColor Green + } elseif (Test-Path $envFile) { + Write-Host " 保留已有 .env" -ForegroundColor DarkGray + } else { + Write-Host " 无 .env.example,请手动配置 .env" -ForegroundColor Yellow + } + } + + $staticDirs = @( + "static\images", + "static\images\order_charts" + ) + foreach ($d in $staticDirs) { + $full = Join-Path $projPath $d + if (-not (Test-Path $full)) { + New-Item -ItemType Directory -Path $full -Force | Out-Null + } + } + Write-Host " 完成: $venvPy" -ForegroundColor Green + } finally { + Pop-Location + } +} + +function Setup-HubProject() { + $projPath = Join-Path $RepoRoot $HubProject.Dir + if (-not (Test-Path $projPath)) { + Write-Host " 跳过 hub(目录不存在)" -ForegroundColor Yellow + return + } + Write-Step $HubProject.Dir + Push-Location $projPath + try { + $venvDir = Join-Path $projPath ".venv" + $venvPy = Join-Path $venvDir "Scripts\python.exe" + $venvPip = Join-Path $venvDir "Scripts\pip.exe" + + if ($RecreateVenv -and (Test-Path $venvDir)) { + Remove-Item -Recurse -Force $venvDir + } + if (-not (Test-Path $venvPy)) { + & python -m venv .venv + } + & $venvPy -m pip install -U pip setuptools wheel -q + if (Test-Path $HubReqFile) { + & $venvPip install -r $HubReqFile -q + } + + if (-not $SkipEnvCopy) { + $envExample = Join-Path $projPath ".env.example" + $envFile = Join-Path $projPath ".env" + if ((Test-Path $envExample) -and -not (Test-Path $envFile)) { + Copy-Item $envExample $envFile + Write-Host " 已复制 .env.example -> .env" -ForegroundColor Green + } + } + Write-Host " 完成: $venvPy" -ForegroundColor Green + } finally { + Pop-Location + } +} + +function Install-Pm2IfNeeded() { + if ($SkipPm2) { return } + $node = Get-Command node -ErrorAction SilentlyContinue + if (-not $node) { + Write-Host "未检测到 Node.js,跳过 PM2。安装 Node 后执行: npm install -g pm2" -ForegroundColor Yellow + return + } + Write-Step "PM2(可选进程托管)" + $pm2 = Get-Command pm2 -ErrorAction SilentlyContinue + if ($pm2) { + Write-Host " PM2 已安装: $(pm2 -v)" -ForegroundColor Green + return + } + Write-Host " 正在全局安装 pm2 ..." + & npm install -g pm2 + Write-Host " PM2 安装完成。在各子目录执行: pm2 start ecosystem.config.cjs" -ForegroundColor Green +} + +# --- main --- +Write-Host "crypto_monitor 环境部署" -ForegroundColor White +Write-Host "仓库根目录: $RepoRoot" -ForegroundColor DarkGray + +if (-not (Test-Path $ReqFile)) { + throw "缺少 $ReqFile" +} + +Test-Python310 + +$selected = ($Only -split "[,;\s]+" | ForEach-Object { $_.Trim().ToLower() } | Where-Object { $_ }) +if (-not $selected -or $selected.Count -eq 0) { $selected = @("all") } + +foreach ($p in $MonitorProjects) { + if (Should-Include $p.Key $selected) { + Setup-MonitorProject $p + } +} +if (Should-Include $HubProject.Key $selected) { + Setup-HubProject +} + +Install-Pm2IfNeeded + +Write-Host "" +Write-Host "部署完成。下一步:" -ForegroundColor Green +Write-Host " 1. 编辑各子目录 .env(API Key、密码、代理等)" +Write-Host " 2. 启动示例(Binance):" +Write-Host " cd crypto_monitor_binance" +Write-Host " .\.venv\Scripts\activate" +Write-Host " python app.py" +Write-Host " 3. Linux 服务器可用: bash deploy/setup_env.sh" +Write-Host "" diff --git a/deploy/setup_env.sh b/deploy/setup_env.sh new file mode 100644 index 0000000..bf64c19 --- /dev/null +++ b/deploy/setup_env.sh @@ -0,0 +1,258 @@ +#!/usr/bin/env bash +# crypto_monitor 一键环境部署(Linux / macOS / Git Bash) +# +# 用法: +# bash deploy/setup_env.sh +# bash deploy/setup_env.sh --only binance,gate_bot +# bash deploy/setup_env.sh --skip-pm2 +# bash deploy/setup_env.sh --recreate-venv +# bash deploy/setup_env.sh --install-system-deps # root + apt 时安装 python*-venv +# +set -e +set -u +# 避免 Windows CRLF 导致 set -euo pipefail 一行报错;pipefail 仅 bash 支持 +if [ -n "${BASH_VERSION:-}" ]; then + set -o pipefail +fi + +DEPLOY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${DEPLOY_DIR}/.." && pwd)" +REQ_FILE="${REPO_ROOT}/requirements.txt" +HUB_REQ="${REPO_ROOT}/manual_trading_hub/requirements.txt" + +ONLY="all" +SKIP_PM2=0 +SKIP_ENV_COPY=0 +RECREATE_VENV=0 +INSTALL_APT_DEPS=0 +PY="" + +usage() { + sed -n '2,12p' "$0" | sed 's/^# \?//' + exit "${1:-0}" +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --only) ONLY="${2:-all}"; shift 2 ;; + --skip-pm2) SKIP_PM2=1; shift ;; + --skip-env-copy) SKIP_ENV_COPY=1; shift ;; + --recreate-venv) RECREATE_VENV=1; shift ;; + --install-system-deps) INSTALL_APT_DEPS=1; shift ;; + -h|--help) usage 0 ;; + *) echo "未知参数: $1" >&2; usage 1 ;; + esac +done + +step() { echo ""; echo "==> $*"; } + +should_include() { + local key="$1" + if [[ "${ONLY}" == "all" ]]; then + return 0 + fi + local item + IFS=',' read -ra PARTS <<< "${ONLY}" + for item in "${PARTS[@]}"; do + item="$(echo "${item}" | tr '[:upper:]' '[:lower:]' | xargs)" + [[ "${item}" == "${key}" ]] && return 0 + done + return 1 +} + +find_python() { + if command -v python3 >/dev/null 2>&1; then + echo python3 + return + fi + if command -v python >/dev/null 2>&1; then + echo python + return + fi + echo "未找到 python3/python,请先安装 Python 3.10+" >&2 + exit 1 +} + +check_python_version() { + local py="$1" + local ver + ver="$("${py}" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" + local major minor + major="${ver%%.*}" + minor="${ver#*.}" + if [[ "${major}" -lt 3 ]] || [[ "${major}" -eq 3 && "${minor}" -lt 10 ]]; then + echo "需要 Python 3.10+,当前: ${ver}" >&2 + exit 1 + fi + echo "Python: $("${py}" --version 2>&1)" +} + +python_minor_version() { + local py="$1" + "${py}" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' +} + +check_venv_available() { + local py="$1" + local tmp + tmp="$(mktemp -d 2>/dev/null || mktemp -d -t cmvenv)" + if "${py}" -m venv "${tmp}" >/dev/null 2>&1 && [[ -x "${tmp}/bin/python" ]]; then + rm -rf "${tmp}" + return 0 + fi + rm -rf "${tmp}" 2>/dev/null || true + return 1 +} + +install_debian_venv_packages() { + local py="$1" + local ver + ver="$(python_minor_version "${py}")" + if ! command -v apt-get >/dev/null 2>&1; then + echo " 未检测到 apt-get,请手动安装 python${ver}-venv 与 python3-pip" >&2 + return 1 + fi + if [[ "$(id -u)" -ne 0 ]]; then + echo " 需要 root 安装系统包,请执行:" >&2 + echo " sudo apt update && sudo apt install -y python${ver}-venv python3-pip curl" >&2 + echo " 或: sudo bash deploy/setup_env.sh --install-system-deps" >&2 + return 1 + fi + step "安装系统依赖 (python${ver}-venv) ..." + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + if ! apt-get install -y "python${ver}-venv" python3-pip curl ca-certificates; then + apt-get install -y python3-venv python3-pip curl ca-certificates + fi +} + +ensure_venv_prereqs() { + local py="$1" + if check_venv_available "${py}"; then + return 0 + fi + echo " 当前 Python 无法创建 venv(缺少 ensurepip,常见于未安装 python*-venv)" >&2 + if [[ "${INSTALL_APT_DEPS}" -eq 1 ]] || [[ "$(id -u)" -eq 0 ]]; then + install_debian_venv_packages "${py}" || exit 1 + if check_venv_available "${py}"; then + return 0 + fi + fi + local ver + ver="$(python_minor_version "${py}")" + echo "请安装后重试:" >&2 + echo " apt update && apt install -y python${ver}-venv python3-pip" >&2 + echo " bash deploy/setup_env.sh" >&2 + exit 1 +} + +create_project_venv() { + local py="$1" + if [[ "${RECREATE_VENV}" -eq 1 && -d .venv ]]; then + echo " 删除旧 venv ..." + rm -rf .venv + fi + if [[ -d .venv && ! -x .venv/bin/python ]]; then + echo " 清理未完成的 venv ..." + rm -rf .venv + fi + if [[ -x .venv/bin/python ]]; then + return 0 + fi + echo " 创建 venv ..." + if ! "${py}" -m venv .venv; then + rm -rf .venv 2>/dev/null || true + echo " venv 创建失败" >&2 + exit 1 + fi +} + +setup_monitor() { + local dir_name="$1" + local proj="${REPO_ROOT}/${dir_name}" + if [[ ! -d "${proj}" ]]; then + echo " 跳过(目录不存在): ${dir_name}" + return + fi + step "${dir_name}" + cd "${proj}" + create_project_venv "${PY}" + echo " 升级 pip ..." + .venv/bin/python -m pip install -U pip setuptools wheel -q + echo " 安装依赖 ..." + .venv/bin/pip install -r "${REQ_FILE}" -q + if [[ "${SKIP_ENV_COPY}" -eq 0 ]]; then + if [[ -f .env.example && ! -f .env ]]; then + cp -n .env.example .env 2>/dev/null || cp .env.example .env + echo " 已复制 .env.example -> .env" + elif [[ -f .env ]]; then + echo " 保留已有 .env" + else + echo " 无 .env.example,请手动配置 .env" + fi + fi + mkdir -p static/images/order_charts + echo " 完成: ${proj}/.venv/bin/python" +} + +setup_hub() { + local proj="${REPO_ROOT}/manual_trading_hub" + if [[ ! -d "${proj}" ]]; then + echo " 跳过 hub(目录不存在)" + return + fi + step "manual_trading_hub" + cd "${proj}" + create_project_venv "${PY}" + .venv/bin/python -m pip install -U pip setuptools wheel -q + if [[ -f "${HUB_REQ}" ]]; then + .venv/bin/pip install -r "${HUB_REQ}" -q + fi + if [[ "${SKIP_ENV_COPY}" -eq 0 && -f .env.example && ! -f .env ]]; then + cp -n .env.example .env 2>/dev/null || cp .env.example .env + echo " 已复制 .env.example -> .env" + fi + echo " 完成: ${proj}/.venv/bin/python" +} + +install_pm2() { + if [[ "${SKIP_PM2}" -eq 1 ]]; then + return + fi + step "PM2(可选)" + if ! command -v node >/dev/null 2>&1; then + echo " 未检测到 Node.js,跳过。安装后执行: npm install -g pm2" + return + fi + if command -v pm2 >/dev/null 2>&1; then + echo " PM2 已安装: $(pm2 -v)" + return + fi + echo " 正在安装 pm2 ..." + npm install -g pm2 + echo " 各子目录: pm2 start ecosystem.config.cjs" +} + +echo "crypto_monitor 环境部署" +echo "仓库根目录: ${REPO_ROOT}" + +[[ -f "${REQ_FILE}" ]] || { echo "缺少 ${REQ_FILE}" >&2; exit 1; } + +PY="$(find_python)" +check_python_version "${PY}" +ensure_venv_prereqs "${PY}" + +should_include binance && setup_monitor crypto_monitor_binance +should_include gate && setup_monitor crypto_monitor_gate +should_include gate_bot && setup_monitor crypto_monitor_gate_bot +should_include okx && setup_monitor crypto_monitor_okx +should_include hub && setup_hub + +install_pm2 + +echo "" +echo "部署完成。下一步:" +echo " 1. 编辑各子目录 .env" +echo " 2. 启动示例: cd crypto_monitor_binance && source .venv/bin/activate && python app.py" +echo " 3. Windows 可用: powershell -File deploy/setup_env.ps1" +echo "" diff --git a/docs/LICENSE_API.md b/docs/LICENSE_API.md new file mode 100644 index 0000000..f289bce --- /dev/null +++ b/docs/LICENSE_API.md @@ -0,0 +1,88 @@ +# 授权云端 API 约定(独立服务实现) + +用户端仓库仅包含 `license_lib.py`;签发 Web 与数据库部署在**独立项目**。 + +## 环境变量(用户端 `.env`) + +| 变量 | 说明 | +|------|------| +| `LICENSE_API_URL` | 云端根地址,如 `https://license.example.com` | +| `LICENSE_CLIENT_KEY` | 客户端密钥,请求头 `X-Client-Key` | +| `LICENSE_CHECK_INTERVAL_DAYS` | 校验间隔,默认 `3` | +| `LICENSE_OFFLINE_GRACE_DAYS` | 断网宽限,默认 `7` | +| `LICENSE_DISABLED` | `true` 时关闭许可(仅开发) | +| `LICENSE_WECHAT_ID` | 默认 `dekun03` | +| `LICENSE_WECHAT_REMARK` | 可选;未设则提示用户用设备 ID 作备注 | + +本地缓存:仓库根目录 `.license/license.cache`(勿提交 Git)。 + +## POST /v1/redeem + +请求: + +```json +{ + "device_id": "32位十六进制", + "code": "一次性激活码" +} +``` + +成功响应: + +```json +{ + "ok": true, + "subscription_id": "sub_xxx", + "plan": "monthly", + "expires_at": "2026-06-21T23:59:59+08:00" +} +``` + +续费叠加(服务端实现): + +```text +base = max(now, current_expires_at) +expires_at = base + days(plan) # monthly 30 / quarterly 90 / yearly 365 +``` + +失败:`ok: false`,`message` 说明原因。 + +## POST /v1/validate + +请求: + +```json +{ + "device_id": "...", + "subscription_id": "sub_xxx" +} +``` + +成功且有效: + +```json +{ + "ok": true, + "valid": true, + "expires_at": "2026-06-21T23:59:59+08:00", + "plan": "monthly" +} +``` + +无效(换机、过期等): + +```json +{ + "ok": true, + "valid": false, + "reason": "device_revoked" +} +``` + +## 套餐定价(商务参考) + +| plan | 售价 | 天数 | +|------|------|------| +| monthly | 199 | 30 | +| quarterly | 399 | 90 | +| yearly | 699 | 365 | diff --git a/fib_key_monitor_lib.py b/fib_key_monitor_lib.py new file mode 100644 index 0000000..9909cec --- /dev/null +++ b/fib_key_monitor_lib.py @@ -0,0 +1,84 @@ +"""斐波关键位监控:纯计算与类型判断(Gate / Binance 主站共用)。""" + +FIB_KEY_MONITOR_TYPES = frozenset({"斐波回调0.618", "斐波回调0.786"}) + +FIB_RATIO_BY_TYPE = { + "斐波回调0.618": 0.618, + "斐波回调0.786": 0.786, +} + + +def is_fib_key_monitor_type(monitor_type): + return (monitor_type or "").strip() in FIB_KEY_MONITOR_TYPES + + +def fib_ratio_from_type(monitor_type): + return FIB_RATIO_BY_TYPE.get((monitor_type or "").strip()) + + +def calc_fib_plan(direction, upper, lower, ratio): + """ + 上沿 H、下沿 L(H > L)。 + 做多:自 H 向下回撤 ratio,E = H - ratio*(H-L);SL=L,TP=H。 + 做空:自 L 向上反弹 ratio,E = L + ratio*(H-L);SL=H,TP=L。 + 返回 (entry, stop_loss, take_profit) 或 None。 + """ + try: + h = float(upper) + l = float(lower) + r = float(ratio) + except (TypeError, ValueError): + return None + if h <= l or r <= 0 or r >= 1: + return None + span = h - l + direction = (direction or "long").strip().lower() + if direction == "short": + entry = l + r * span + return entry, h, l + entry = h - r * span + return entry, l, h + + +def stored_key_signal_type(monitor_type): + """写入 order_monitors / trade_records 的 key_signal_type(箱体/收敛/斐波)。""" + mt = (monitor_type or "").strip() + if mt in FIB_KEY_MONITOR_TYPES: + return mt + return None + + +KEY_ENTRY_REASON_BY_SIGNAL = { + "箱体突破": "关键位箱体突破", + "收敛突破": "关键位收敛突破", + "斐波回调0.618": "关键位斐波0.618", + "斐波回调0.786": "关键位斐波0.786", +} + + +def entry_reason_from_key_signal(key_signal_type): + return KEY_ENTRY_REASON_BY_SIGNAL.get((key_signal_type or "").strip()) + + +def key_signal_type_for_trade_record(key_signal_type, box_auto_types): + """平仓写入 trade_records 时保留箱体/收敛/斐波来源。""" + kst = (key_signal_type or "").strip() + if kst in FIB_KEY_MONITOR_TYPES: + return kst + if box_auto_types and kst in box_auto_types: + return kst + return None + + +def fib_invalidate_by_mark(direction, mark_price, upper, lower): + """先触达止盈侧(标记价)则失效。多:mark>=H;空:mark<=L。""" + try: + m = float(mark_price) + h = float(upper) + l = float(lower) + except (TypeError, ValueError): + return False + direction = (direction or "long").strip().lower() + if direction == "short": + return m <= l + return m >= h diff --git a/history_window_lib.py b/history_window_lib.py new file mode 100644 index 0000000..f9df102 --- /dev/null +++ b/history_window_lib.py @@ -0,0 +1,127 @@ +"""列表/导出用 UTC 时间窗(Gate / Binance 主站共用)。""" + +from datetime import datetime, timedelta, timezone + +PRESET_UTC_TODAY = "utc_today" +PRESET_UTC_LAST24H = "utc_last24h" +PRESET_UTC_LAST7D = "utc_last7d" +PRESET_CUSTOM = "custom" + + +def utc_now(): + return datetime.now(timezone.utc) + + +def utc_today_bounds(now=None): + now = now or utc_now() + start = now.replace(hour=0, minute=0, second=0, microsecond=0) + return start, now + + +def resolve_window(query_mapping, default_preset=PRESET_UTC_TODAY): + """ + 从 ?win_preset= & from_utc= & to_utc= 解析窗口。 + 返回 dict: preset, start_utc, end_utc, label, start_ms, end_ms + """ + preset = (query_mapping.get("win_preset") or default_preset or PRESET_UTC_TODAY).strip().lower() + now = utc_now() + + if preset == PRESET_UTC_LAST24H: + start = now - timedelta(hours=24) + end = now + label = "近24小时(UTC)" + elif preset == PRESET_UTC_LAST7D: + start = now - timedelta(days=7) + end = now + label = "近7天(UTC)" + elif preset == PRESET_CUSTOM: + start = _parse_utc_input(query_mapping.get("from_utc")) or utc_today_bounds(now)[0] + end = _parse_utc_input(query_mapping.get("to_utc")) or now + if end < start: + start, end = end, start + label = f"{start.strftime('%Y-%m-%d %H:%M')} ~ {end.strftime('%Y-%m-%d %H:%M')} UTC" + else: + start, end = utc_today_bounds(now) + preset = PRESET_UTC_TODAY + label = f"UTC当日 {start.strftime('%Y-%m-%d')}" + + return { + "preset": preset, + "start_utc": start, + "end_utc": end, + "label": label, + "start_ms": int(start.timestamp() * 1000), + "end_ms": int(end.timestamp() * 1000), + } + + +def _parse_utc_input(raw): + s = (raw or "").strip().replace("T", " ").replace("Z", "").strip() + if not s: + return None + for fmt, n in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)): + try: + dt = datetime.strptime(s[:n], fmt) + return dt.replace(tzinfo=timezone.utc) + except Exception: + continue + return None + + +def utc_window_to_bj_sql_strings(start_utc, end_utc, app_tz): + """DB 存北京时间字符串时,用于 SQLite 字符串范围比较。""" + start_bj = start_utc.astimezone(app_tz).strftime("%Y-%m-%d %H:%M:%S") + end_bj = end_utc.astimezone(app_tz).strftime("%Y-%m-%d %H:%M:%S") + return start_bj, end_bj + + +SESSION_KEY_LIST_WIN = "list_win_filter" + + +def query_mapping_from_session(session_store): + """从 Flask session 恢复 win_preset / from_utc / to_utc。""" + if not session_store: + return {} + block = session_store.get(SESSION_KEY_LIST_WIN) + if not isinstance(block, dict): + return {} + preset = (block.get("preset") or "").strip() + if not preset: + return {} + return { + "win_preset": preset, + "from_utc": (block.get("from_utc") or "").strip(), + "to_utc": (block.get("to_utc") or "").strip(), + } + + +def resolve_list_window(query_mapping, session_store=None, default_preset=PRESET_UTC_TODAY): + """ + URL 带 win_preset 时解析并写入 session;无参数时用 session 中上次「应用」的预设。 + """ + qm = query_mapping or {} + preset_in_q = (qm.get("win_preset") or "").strip() + if preset_in_q: + win = resolve_window(qm, default_preset=default_preset) + if session_store is not None: + session_store[SESSION_KEY_LIST_WIN] = { + "preset": win["preset"], + "from_utc": (qm.get("from_utc") or "").strip(), + "to_utc": (qm.get("to_utc") or "").strip(), + } + return win + stored = query_mapping_from_session(session_store) + if stored.get("win_preset"): + return resolve_window(stored, default_preset=default_preset) + return resolve_window(qm, default_preset=default_preset) + + +def list_window_redirect_query(session_store): + """复盘/表单 POST 后重定向时附带列表筛选 query。""" + from urllib.parse import urlencode + + stored = query_mapping_from_session(session_store) + if not stored.get("win_preset"): + return "" + params = {k: v for k, v in stored.items() if v} + return urlencode(params) diff --git a/key_sl_tp_lib.py b/key_sl_tp_lib.py new file mode 100644 index 0000000..dfcef35 --- /dev/null +++ b/key_sl_tp_lib.py @@ -0,0 +1,139 @@ +"""关键位箱体/收敛:止盈止损方案(Binance / Gate / OKX 共用)。""" + +KEY_SL_TP_MODES = frozenset({"standard", "box_1p5", "trend_manual"}) + +KEY_SL_TP_MODE_LABELS = { + "standard": "标准突破", + "box_1p5": "箱体1R·止盈1.5H", + "trend_manual": "趋势单·自填止盈", +} + +KEY_MONITOR_AUTO_TYPES_FOR_FORM = frozenset({"箱体突破", "收敛突破"}) + + +def normalize_sl_tp_mode(raw): + m = (raw or "standard").strip().lower() + if m in ("box_1p5", "box15", "box-1.5", "box_1.5"): + return "box_1p5" + if m in ("trend_manual", "trend", "manual"): + return "trend_manual" + if m in KEY_SL_TP_MODES: + return m + return "standard" + + +def sl_tp_mode_label(mode): + return KEY_SL_TP_MODE_LABELS.get(normalize_sl_tp_mode(mode), normalize_sl_tp_mode(mode)) + + +def sl_tp_mode_from_row(row, default="standard"): + try: + if hasattr(row, "keys") and "sl_tp_mode" in row.keys(): + raw = row["sl_tp_mode"] + else: + raw = row.get("sl_tp_mode") if isinstance(row, dict) else None + except Exception: + raw = None + return normalize_sl_tp_mode(raw if raw not in (None, "") else default) + + +def breakeven_enabled_from_row(row, default=0): + try: + if hasattr(row, "keys") and "breakeven_enabled" in row.keys(): + v = row["breakeven_enabled"] + else: + v = row.get("breakeven_enabled") if isinstance(row, dict) else None + except Exception: + v = None + if v is None: + return int(default) != 0 + return int(v) != 0 + + +def parse_breakeven_enabled_form(form_value): + return 1 if (form_value or "").strip().lower() in ("1", "true", "on", "yes") else 0 + + +def plan_key_sl_tp( + mode, + direction, + upper, + lower, + checks, + *, + outside_pct, + trend_outside_pct, + manual_take_profit=None, +): + """ + 以确认 K 收盘 E 为「当前价」计算计划 SL/TP。 + 返回 (E, sl_raw, tp_raw, box_h) 或 None(几何无效 / 模式3缺止盈)。 + """ + try: + E = float(checks["confirm_close"]) + H = abs(float(upper) - float(lower)) + except (TypeError, ValueError, KeyError): + return None + if H <= 0: + return None + direction = (direction or "long").strip().lower() + mode = normalize_sl_tp_mode(mode) + + if mode == "box_1p5": + if direction == "long": + sl_raw = E - H + tp_raw = E + 1.5 * H + else: + sl_raw = E + H + tp_raw = E - 1.5 * H + return E, sl_raw, tp_raw, H + + if mode == "trend_manual": + try: + br_hi = float(checks["breakout_high"]) + br_lo = float(checks["breakout_low"]) + tp_raw = float(manual_take_profit) + except (TypeError, ValueError, KeyError): + return None + m = float(trend_outside_pct) / 100.0 + if direction == "long": + sl_raw = br_lo * (1.0 - m) if br_lo > 0 else 0.0 + if tp_raw <= E or sl_raw <= 0: + return None + else: + sl_raw = br_hi * (1.0 + m) if br_hi > 0 else 0.0 + if tp_raw >= E or sl_raw <= 0: + return None + return E, sl_raw, tp_raw, H + + # standard:突破 K 极值外侧 + 止盈 E±1×H + try: + br_hi = float(checks["breakout_high"]) + br_lo = float(checks["breakout_low"]) + except (TypeError, ValueError, KeyError): + return None + om = float(outside_pct) / 100.0 + if direction == "long": + sl_raw = br_lo * (1.0 - om) if br_lo > 0 else 0.0 + tp_raw = E + H + else: + sl_raw = br_hi * (1.0 + om) if br_hi > 0 else 0.0 + tp_raw = E - H + return E, sl_raw, tp_raw, H + + +def sl_tp_plan_summary_text(mode, direction, E, sl_raw, tp_raw, box_h, *, outside_pct, trend_outside_pct): + """微信/页面用一行计划 SL/TP 说明。""" + mode = normalize_sl_tp_mode(mode) + direction = (direction or "long").strip().lower() + if mode == "box_1p5": + return ( + f"方案:{sl_tp_mode_label(mode)}|E={E}|SL=E∓1×H({box_h})|TP=E∓1.5×H" + ) + if mode == "trend_manual": + return ( + f"方案:{sl_tp_mode_label(mode)}|E={E}|SL=突破K极值外{trend_outside_pct}%|TP={tp_raw}(录入)" + ) + return ( + f"方案:{sl_tp_mode_label(mode)}|E={E}|SL=突破K外{outside_pct}%|TP=E±1×H({box_h})" + ) diff --git a/license_lib.py b/license_lib.py new file mode 100644 index 0000000..4a28767 --- /dev/null +++ b/license_lib.py @@ -0,0 +1,437 @@ +""" +整机许可客户端:设备 ID、激活码兑换、定期云端校验。 +签发与管理 Web 为独立云端服务,不在本仓库内。 +""" +from __future__ import annotations + +import hashlib +import json +import os +import platform +import subprocess +from datetime import datetime, timedelta, timezone +from functools import wraps +from typing import Any, Callable, Optional + +import requests + +_REPO_ROOT = os.path.dirname(os.path.abspath(__file__)) +LICENSE_DIR = os.path.join(_REPO_ROOT, ".license") +DEVICE_ID_FILE = os.path.join(LICENSE_DIR, "device.id") +CACHE_FILE = os.path.join(LICENSE_DIR, "license.cache") +_TEMPLATE_DIR = os.path.join(_REPO_ROOT, "license_templates") + +BJ_TZ = timezone(timedelta(hours=8)) + + +def load_shared_env() -> None: + """从仓库根目录 .env 加载 LICENSE_*(子项目 .env 已存在同名变量时不覆盖)。""" + path = os.path.join(_REPO_ROOT, ".env") + if not os.path.isfile(path): + return + try: + raw_bytes = open(path, "rb").read() + except OSError: + return + text = "" + for enc in ("utf-8-sig", "utf-16", "utf-16-le", "utf-16-be"): + try: + text = raw_bytes.decode(enc) + break + except Exception: + continue + if not text: + text = raw_bytes.decode("utf-8", errors="ignore") + text = text.replace("\x00", "") + for line in text.splitlines(): + raw = line.strip() + if not raw or raw.startswith("#") or "=" not in raw: + continue + key, value = raw.split("=", 1) + clean_key = key.strip().lstrip("\ufeff") + if not clean_key.startswith("LICENSE_"): + continue + if clean_key in os.environ and (os.environ.get(clean_key) or "").strip(): + continue + clean_value = value.strip().strip('"').strip("'") + os.environ[clean_key] = clean_value + + +def _env_bool(name: str, default: bool = False) -> bool: + return (os.getenv(name) or "").strip().lower() in ("1", "true", "yes", "on") + + +def license_disabled() -> bool: + return _env_bool("LICENSE_DISABLED", False) + + +def license_api_url() -> str: + return (os.getenv("LICENSE_API_URL") or "").strip().rstrip("/") + + +def license_client_key() -> str: + return (os.getenv("LICENSE_CLIENT_KEY") or "").strip() + + +def check_interval_days() -> int: + try: + return max(1, int(os.getenv("LICENSE_CHECK_INTERVAL_DAYS", "3"))) + except ValueError: + return 3 + + +def offline_grace_days() -> int: + try: + return max(1, int(os.getenv("LICENSE_OFFLINE_GRACE_DAYS", "7"))) + except ValueError: + return 7 + + +def wechat_id() -> str: + return (os.getenv("LICENSE_WECHAT_ID") or "dekun03").strip() + + +def wechat_remark_hint(device_id: str) -> str: + custom = (os.getenv("LICENSE_WECHAT_REMARK") or "").strip() + if custom: + return custom + return device_id + + +def _machine_fingerprint() -> str: + parts = [ + platform.node() or "", + platform.system() or "", + platform.release() or "", + platform.machine() or "", + ] + if platform.system() == "Windows": + try: + out = subprocess.check_output( + ["wmic", "csproduct", "get", "uuid"], + stderr=subprocess.DEVNULL, + timeout=8, + text=True, + encoding="utf-8", + errors="ignore", + ) + for line in out.splitlines(): + line = line.strip() + if line and line.lower() != "uuid": + parts.append(line) + break + except Exception: + pass + else: + for path in ("/etc/machine-id", "/var/lib/dbus/machine-id"): + try: + if os.path.isfile(path): + parts.append(open(path, encoding="utf-8", errors="ignore").read().strip()) + break + except Exception: + pass + return "|".join(parts) + + +def get_device_id() -> str: + os.makedirs(LICENSE_DIR, exist_ok=True) + if os.path.isfile(DEVICE_ID_FILE): + with open(DEVICE_ID_FILE, encoding="utf-8") as f: + existing = f.read().strip() + if existing: + return existing + raw = _machine_fingerprint() + device_id = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32] + with open(DEVICE_ID_FILE, "w", encoding="utf-8") as f: + f.write(device_id) + return device_id + + +def _parse_iso(value: str | None) -> Optional[datetime]: + if not value: + return None + s = str(value).strip() + if not s: + return None + if s.endswith("Z"): + s = s[:-1] + "+00:00" + try: + dt = datetime.fromisoformat(s) + except ValueError: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=BJ_TZ) + return dt.astimezone(BJ_TZ) + + +def _now_bj() -> datetime: + return datetime.now(BJ_TZ) + + +def _load_cache() -> Optional[dict[str, Any]]: + if not os.path.isfile(CACHE_FILE): + return None + try: + with open(CACHE_FILE, encoding="utf-8") as f: + data = json.load(f) + return data if isinstance(data, dict) else None + except Exception: + return None + + +def _save_cache(data: dict[str, Any]) -> None: + os.makedirs(LICENSE_DIR, exist_ok=True) + with open(CACHE_FILE, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + +def _api_headers() -> dict[str, str]: + headers = {"Content-Type": "application/json"} + key = license_client_key() + if key: + headers["X-Client-Key"] = key + return headers + + +def _api_configured() -> bool: + return bool(license_api_url() and license_client_key()) + + +def redeem_code(code: str) -> tuple[bool, str]: + if license_disabled(): + return True, "许可校验已关闭(开发模式)" + if not _api_configured(): + return False, "未配置 LICENSE_API_URL / LICENSE_CLIENT_KEY" + device_id = get_device_id() + url = f"{license_api_url()}/v1/redeem" + try: + r = requests.post( + url, + json={"device_id": device_id, "code": (code or "").strip()}, + headers=_api_headers(), + timeout=30, + ) + body = r.json() if r.content else {} + except requests.RequestException as e: + return False, f"无法连接授权服务:{e}" + except ValueError: + return False, "授权服务返回非 JSON" + + if r.status_code >= 400 or not body.get("ok"): + return False, (body.get("message") or body.get("detail") or f"兑换失败 HTTP {r.status_code}") + + expires_at = body.get("expires_at") or "" + cache = { + "device_id": device_id, + "subscription_id": body.get("subscription_id") or "", + "plan": body.get("plan") or "", + "expires_at": expires_at, + "last_validated_at": _now_bj().isoformat(), + "last_validated_ok_at": _now_bj().isoformat(), + } + _save_cache(cache) + return True, "激活成功" + + +def validate_remote() -> tuple[bool, str, Optional[dict[str, Any]]]: + if license_disabled(): + return True, "", _load_cache() + if not _api_configured(): + return False, "未配置授权服务", None + + cache = _load_cache() + if not cache or not cache.get("subscription_id"): + return False, "未激活", None + + device_id = get_device_id() + url = f"{license_api_url()}/v1/validate" + try: + r = requests.post( + url, + json={ + "device_id": device_id, + "subscription_id": cache.get("subscription_id"), + }, + headers=_api_headers(), + timeout=20, + ) + body = r.json() if r.content else {} + except requests.RequestException as e: + return False, f"校验请求失败:{e}", cache + except ValueError: + return False, "授权服务返回非 JSON", cache + + now_iso = _now_bj().isoformat() + cache["last_validated_at"] = now_iso + + if r.status_code >= 400 or not body.get("ok"): + _save_cache(cache) + return False, body.get("message") or body.get("detail") or "校验失败", cache + + if not body.get("valid"): + reason = body.get("reason") or body.get("message") or "许可无效" + _save_cache(cache) + return False, reason, cache + + if body.get("expires_at"): + cache["expires_at"] = body["expires_at"] + if body.get("plan"): + cache["plan"] = body["plan"] + cache["last_validated_ok_at"] = now_iso + _save_cache(cache) + return True, "", cache + + +def _needs_periodic_check(last_validated_at: str | None) -> bool: + if not last_validated_at: + return True + last = _parse_iso(last_validated_at) + if not last: + return True + return _now_bj() - last >= timedelta(days=check_interval_days()) + + +def _within_offline_grace(cache: dict[str, Any]) -> bool: + ref = cache.get("last_validated_ok_at") or cache.get("last_validated_at") + last_ok = _parse_iso(ref if isinstance(ref, str) else None) + if not last_ok: + return False + return _now_bj() - last_ok <= timedelta(days=offline_grace_days()) + + +def _is_expired(cache: dict[str, Any]) -> bool: + exp = _parse_iso(cache.get("expires_at")) + if not exp: + return True + return _now_bj() > exp + + +def is_licensed(*, force_validate: bool = False) -> bool: + if license_disabled(): + return True + + cache = _load_cache() + if not cache: + return False + if cache.get("device_id") and cache.get("device_id") != get_device_id(): + return False + if _is_expired(cache): + return False + + if force_validate or _needs_periodic_check(cache.get("last_validated_at")): + ok, _msg, updated = validate_remote() + if ok: + return True + use_cache = updated or cache + if _within_offline_grace(use_cache) and not _is_expired(use_cache): + return True + return False + + return True + + +def license_status() -> dict[str, Any]: + device_id = get_device_id() + cache = _load_cache() or {} + exp = _parse_iso(cache.get("expires_at")) + licensed = is_licensed() + days_left = None + if exp: + days_left = max(0, (exp - _now_bj()).days) + + return { + "device_id": device_id, + "licensed": licensed, + "disabled": license_disabled(), + "api_configured": _api_configured(), + "subscription_id": cache.get("subscription_id") or "", + "plan": cache.get("plan") or "", + "expires_at": cache.get("expires_at") or "", + "expires_at_display": exp.strftime("%Y-%m-%d %H:%M") if exp else "", + "days_left": days_left, + "last_validated_at": cache.get("last_validated_at") or "", + "check_interval_days": check_interval_days(), + "offline_grace_days": offline_grace_days(), + "wechat_id": wechat_id(), + "wechat_remark": wechat_remark_hint(device_id), + "plans": [ + {"id": "monthly", "name": "月卡", "price": 199, "days": 30}, + {"id": "quarterly", "name": "季卡", "price": 399, "days": 90}, + {"id": "yearly", "name": "年卡", "price": 699, "days": 365}, + ], + } + + +def _render_license_html(ctx: dict[str, Any]) -> str: + path = os.path.join(_TEMPLATE_DIR, "license.html") + with open(path, encoding="utf-8") as f: + template = f.read() + from jinja2 import Template + + return Template(template).render(**ctx) + + +def init_flask_app(app, exchange_display: str = "交易系统") -> None: + """注册 /license 路由与全局门禁。""" + load_shared_env() + + @app.route("/license", methods=["GET", "POST"]) + def license_page(): + from flask import flash, get_flashed_messages, redirect, render_template_string, request, url_for + + if request.method == "POST": + code = (request.form.get("activation_code") or "").strip() + if not code: + flash("请输入激活码") + else: + ok, msg = redeem_code(code) + if ok: + flash(msg or "激活成功", "ok") + return redirect(url_for("license_page")) + flash(msg or "激活失败") + return redirect(url_for("license_page")) + + status = license_status() + html = _render_license_html( + { + **status, + "exchange_display": exchange_display, + "messages": get_flashed_messages(with_categories=True), + } + ) + return render_template_string(html) + + @app.before_request + def _license_gate(): + from flask import redirect, request, url_for + + if license_disabled(): + return None + endpoint = request.endpoint or "" + if endpoint in ("license_page", "login", "logout") or endpoint.startswith("static"): + return None + if not is_licensed(): + return redirect(url_for("license_page")) + return None + + +def init_fastapi_app(app) -> None: + from fastapi import Request + from fastapi.responses import HTMLResponse, RedirectResponse + + load_shared_env() + + @app.get("/license", response_class=HTMLResponse) + async def license_page(request: Request): + return HTMLResponse(_render_license_html({**license_status(), "exchange_display": "中控", "messages": []})) + + @app.middleware("http") + async def _license_gate(request: Request, call_next): + if license_disabled(): + return await call_next(request) + path = request.url.path + if path == "/license" or path.startswith("/assets"): + return await call_next(request) + if not is_licensed(): + return RedirectResponse(url="/license", status_code=302) + return await call_next(request) diff --git a/license_templates/license.html b/license_templates/license.html new file mode 100644 index 0000000..cdbbc34 --- /dev/null +++ b/license_templates/license.html @@ -0,0 +1,168 @@ + + + + + 软件授权 · {{ exchange_display }} + + + +
+

复盘交易系统 · 授权

+

整机许可 · 每 {{ check_interval_days }} 天联网校验一次

+ + {% for msg in messages %} +
+ {% if msg is iterable and msg|length > 1 %}{{ msg[1] }}{% else %}{{ msg }}{% endif %} +
+ {% endfor %} + +
+

授权状态

+ {% if licensed %} + 已授权 + {% else %} + 未授权或已过期 + {% endif %} + {% if expires_at_display %} +

到期时间:{{ expires_at_display }}(BJ)

+ {% endif %} + {% if days_left is not none and licensed %} +

剩余约 {{ days_left }} 天

+ {% endif %} + {% if plan %} +

套餐:{{ plan }}

+ {% endif %} +

+ 校验间隔 {{ check_interval_days }} 天;断网宽限 {{ offline_grace_days }} 天 +

+
+ +
+

设备 ID(换机需重新联系签发)

+
{{ device_id }}
+

复制上方 ID,联系管理员获取激活码。续费在剩余天数上叠加。

+
+ +
+

购买 / 续费

+
    + {% for p in plans %} +
  • {{ p.name }}:¥{{ p.price }} / {{ p.days }} 天
  • + {% endfor %} +
+

+ 微信:{{ wechat_id }} +

+
+ 添加微信时必须填写备注:
+ 请将上方 设备 ID 完整复制 到好友验证备注中(或填写:{{ wechat_remark }}) +
+
+ +
+

激活码兑换

+
+ + + +
+ {% if not api_configured and not disabled %} +

未配置 LICENSE_API_URL,请在 .env 中设置授权服务地址。

+ {% endif %} +
+
+ + diff --git a/manual_trading_hub/README.md b/manual_trading_hub/README.md new file mode 100644 index 0000000..6aeb436 --- /dev/null +++ b/manual_trading_hub/README.md @@ -0,0 +1,201 @@ +# 手工交易多账户中控(manual_trading_hub) + +本目录提供**极简中控**:只负责多账户**监控**(持仓、盈亏、余额等)与**紧急全平**,不参与开仓、策略或任何自动化下单。策略账户侧的 `crypto_monitor_*` 项目**无需改代码**,与中控并行运行即可。 + +--- + +## 功能概览 + +| 能力 | 说明 | +|------|------| +| 监控 | 汇总各子代理的永续持仓、未实现盈亏、余额(USDT)、持仓模式等 | +| 单账户全平 | 对某一子代理发起市价减仓,尽量平掉该所 USDT 永续仓位 | +| 全局全平 | 对当前「已开启监控」的子代理依次发起全平 | +| 关闭某账户 | 网页上取消「参与监控」,或环境变量 `HUB_DISABLED_IDS`;被关闭的不轮询、不参与全局全平 | + +--- + +## 架构说明 + +``` +浏览器 → 中控 hub.py(默认监听 0.0.0.0:5100,私网可访问;本机仍可用 127.0.0.1) + ↓ HTTP + 子代理 agent.py × N(默认 127.0.0.1:15200~15203,与 Flask 的 APP_PORT 错开) + ↓ ccxt + 各交易所 API +``` + +- **中控(hub)**:读配置、并行请求各子代理 `/status`;全平时转发 `POST /emergency/close-all`。 +- **子代理(agent)**:每个进程绑定一个交易所 API Key,只做只读状态 + 紧急平仓;与 `crypto_monitor_*` 里的 Flask **独立进程**,互不影响日常手工/策略操作。 + +### 与四个 `crypto_monitor_*` 目录的关系(已对照 `app.py`) + +各策略项目在**本目录**用 **`.env`**(由 **`cp .env.example .env`** 生成,勿提交 Git;`git pull` 不覆盖)加载配置(`load_env_file`),并用其中的 **`APP_HOST` / `APP_PORT`** 启动 **Flask 网页**(`app.py` 里 `HOST`、`PORT`)。这是你在浏览器里打开策略/监控后台用的端口,例如 `APP_PORT=5001`。 + +子代理 **不用 `APP_PORT`**,而是用环境变量 **`PORT`**(FastAPI/uvicorn 监听)。若子代理与 Flask 抢同一端口,后启动的会起不来,因此中控默认把子代理配在 **`15200`~`15203`**,与常见 `5000` 段 Flask 配置**错开**。 + +| 子代理 `PORT`(建议) | 对应策略目录 | `EXCHANGE` | +|----------------------|--------------|------------| +| 15200 | `crypto_monitor_binance` | `binance` | +| 15201 | `crypto_monitor_okx` | `okx` | +| 15202 | `crypto_monitor_gate` | `gate` | +| 15203 | `crypto_monitor_gate_bot` | `gate` | + +Flask 仍可继续用 `APP_HOST=0.0.0.0` 方便云服务器外网访问;**子代理**建议 **`HOST=127.0.0.1`**。中控默认 `HUB_HOST=0.0.0.0` 便于局域网打开页面;若只给自己用,设 `HUB_HOST=127.0.0.1` 或 `HUB_TRUST_LAN=0`。 + +--- + +## 依赖 + +- Python 3.10+(与仓库其他项目相近即可) +- 见 `requirements.txt`:`fastapi`、`uvicorn`、`httpx`、`ccxt` + +安装: + +```bash +cd manual_trading_hub +pip install -r requirements.txt +``` + +建议使用独立虚拟环境,避免与全局包冲突。 + +--- + +## 快速启动(本机) + +### 1. 启动子代理(每个账户一个终端) + +在**对应策略目录**下加载该目录已有 `.env`(含 API 密钥与可选代理),再启动 agent,避免密钥分散维护两套。 + +**Binance(子代理端口 15200,与 Flask APP_PORT 无关)** + +```powershell +cd ..\crypto_monitor_binance +$env:EXCHANGE="binance" +$env:PORT="15200" +$env:HOST="127.0.0.1" +python ..\manual_trading_hub\agent.py +``` + +**OKX(15201)** + +```powershell +cd ..\crypto_monitor_okx +$env:EXCHANGE="okx" +$env:PORT="15201" +python ..\manual_trading_hub\agent.py +``` + +**Gate / Gate-Bot(15202 / 15203)** + +```powershell +$env:EXCHANGE="gate" +$env:PORT="15202" # Gate-Bot 用 15203 +python ..\manual_trading_hub\agent.py +``` + +### 2. 启动中控 + +```powershell +cd manual_trading_hub +$env:HUB_HOST="0.0.0.0" +$env:HUB_PORT="5100" +# 可选:自定义各账户卡片标题(顺序与默认 HUB_AGENTS 一致:15200→15201→15202→15203) +# $env:HUB_AGENT_NAMES="币安主号,OKX,Gate主号,Gate机器人" +python hub.py +``` + +浏览器打开:**http://127.0.0.1:5100/** 或 **http://本机局域网IP:5100/** + +--- + +## 子代理(agent)环境变量 + +| 变量 | 含义 | 默认 | +|------|------|------| +| `EXCHANGE` | `binance` / `okx` / `gate` | `binance` | +| `HOST` | 监听地址 | `127.0.0.1` | +| `PORT` | 子代理监听端口(勿与 Flask 的 `APP_PORT` 相同) | `15200` | +| `CONTROL_TOKEN` | 若设置,请求须带头 `X-Control-Token` | 空 | + +**Binance**:`BINANCE_API_KEY`、`BINANCE_API_SECRET`;可选 `BINANCE_POSITION_MODE`(`hedge`/`oneway`)、`BINANCE_MARGIN_MODE`;代理 `BINANCE_SOCKS_PROXY` 或 `BINANCE_HTTP_PROXY` / `HTTPS_PROXY`。`/status` 的 `balance_usdt` 为 **U 本位永续合约**账户 USDT(与 `crypto_monitor_binance` 合约口径一致,非现货钱包)。 + +**OKX**:`OKX_API_KEY`、`OKX_API_SECRET`、`OKX_API_PASSPHRASE`;可选 `OKX_TD_MODE`、`OKX_POS_MODE`;代理 `OKX_SOCKS_PROXY` 等。 + +**Gate**:`GATE_API_KEY`、`GATE_API_SECRET`;可选 `GATE_TD_MODE`、`GATE_POS_MODE`;代理 `GATE_SOCKS_PROXY` 等。 + +--- + +## 中控(hub)环境变量 + +| 变量 | 含义 | 默认 | +|------|------|------| +| `HUB_HOST` | 监听地址 | `0.0.0.0`(局域网可连);`127.0.0.1` 仅本机 | +| `HUB_PORT` | 监听端口 | `5100` | +| `HUB_AGENTS` | 子代理 base URL,逗号分隔 | `http://127.0.0.1:15200` … `15203` | +| `HUB_AGENT_NAMES` | 与 `HUB_AGENTS` **顺序一一对应**的卡片显示名(逗号分隔)。不设则用内置默认名。改后需重启 hub,**所有访问该中控的浏览器**显示一致 | 内置与四目录对应的英文标签 | +| `HUB_DISABLED_IDS` | 不参与监控与全局全平的账户 `id`,逗号分隔(如暂不用 OKX 写 `1`) | 空 | +| `HUB_TRUST_LAN` | 默认 `true`;设为 `0`/`false`/`off` 则仅允许本机 IP 访问中控 | 开 | +| `CONTROL_TOKEN` | 与子代理一致时,中控代发 `X-Control-Token` | 空 | + +--- + +## 网页操作说明 + +- **立即刷新 / 自动刷新**:拉取最新汇总;自动刷新默认约 3 秒一轮(仅请求已开启监控的账户)。 +- **账户显示名**:在运行 `hub.py` 的机器上设置环境变量 **`HUB_AGENT_NAMES`**(逗号分隔,顺序与 **`HUB_AGENTS`** 里每个子代理 URL 一致),重启中控后,任意电脑打开同一中控地址都会看到相同名称。名称里若含逗号,需整体用引号包裹或避免使用逗号。 +- **参与监控**:勾选则参与轮询与「全局一键全平」;取消则本浏览器记住(`localStorage` 键 `manual_trading_hub_excluded`),不再请求该子代理。 +- **该账户全平**:仅针对该子代理;与是否关闭「参与监控」无关(仍可直接调交易所紧急平仓)。 +- **全局一键全平**:只对当前「参与监控」且未被 `HUB_DISABLED_IDS` 关闭的账户发起全平;请求体中的 `exclude_ids` 与网页关闭状态一致。 + +服务端 `HUB_DISABLED_IDS` 与浏览器关闭**取并集**:任一方关闭即不轮询、不进全局全平。 + +--- + +## HTTP API(访问控制与浏览器一致) + +允许来源:**本机**或 **RFC1918 私网 IPv4**(与 `HUB_TRUST_LAN` 开启时一致);`HUB_TRUST_LAN=0` 时仅本机。 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/` | 中控页面 | +| GET | `/api/agents` | 当前配置的账户列表(id、name、url) | +| GET | `/api/snapshot` | 聚合状态;可选查询参数 `exclude_ids`(逗号分隔 id) | +| POST | `/api/close/{agent_id}` | 单账户紧急全平 | +| POST | `/api/close-all` | JSON 体可选 `{"exclude_ids":["1"]}`,与 `HUB_DISABLED_IDS` 合并 | + +子代理: + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/health` | 健康检查 | +| GET | `/status` | 余额、持仓、盈亏汇总 | +| POST | `/emergency/close-all` | 市价尽量平掉 USDT 永续仓位,并尝试撤该合约挂单 | + +--- + +## 安全与边界 + +- 中控默认 **监听 0.0.0.0** 并放行私网访问,便于局域网使用;**公网非私网 IP** 仍会被中间件拒绝。 +- 若机器有公网 IP,请用 **防火墙** 限制 `HUB_PORT` 仅内网可进,或改为 `HUB_HOST=127.0.0.1` + `HUB_TRUST_LAN=0` 仅本机。 +- 子代理建议仍用 **`HOST=127.0.0.1`**,不要对局域网暴露交易所密钥通道。 +- 中控**不具备**开仓、改策略、划转账能力;全平为**市价减仓**,请谨慎操作。 +- 子代理与主策略进程共用密钥时,注意权限与 IP 白名单仍按交易所要求配置。 + +--- + +## 常见问题 + +**1. 某一行一直连不上** +检查该端口上的 `agent.py` 是否已启动、防火墙是否放行本机回环、`.env` 密钥是否与交易所一致。 + +**2. 暂时不用 OKX** +网页取消该行的「参与监控」,或启动中控前设置 `HUB_DISABLED_IDS=1`(默认 OKX 的 id 为 `1`)。 + +**3. 策略项目要不要改?** +不需要改 `crypto_monitor_*` 代码;只需额外运行 `agent.py` 进程。 + +**5. 局域网里别的电脑打不开中控** +默认应已可访问:中控监听 `0.0.0.0` 且 `HUB_TRUST_LAN` 默认开启。请检查:防火墙是否放行 `HUB_PORT`;浏览器是否使用 **中控机器的局域网 IP**(不要用另一台电脑上的 `127.0.0.1`)。若你曾设置 `HUB_TRUST_LAN=0` 或 `HUB_HOST=127.0.0.1`,改回默认或删掉环境变量后重启 hub。 + +更细的安装顺序、验收清单与可选常驻方式见同目录 **《部署文档.md》**。 diff --git a/manual_trading_hub/agent.py b/manual_trading_hub/agent.py new file mode 100644 index 0000000..a3b2e1d --- /dev/null +++ b/manual_trading_hub/agent.py @@ -0,0 +1,568 @@ +""" +子账户极轻代理:仅 GET /status + POST /emergency/close-all,仅监听 127.0.0.1。 + +与仓库内四个策略/监控目录一一对应时,典型用法(各目录自己的 .env 里已有密钥;子代理用环境变量 PORT,勿与 Flask 的 APP_PORT 相同): + EXCHANGE=binance → crypto_monitor_binance(BINANCE_*) + EXCHANGE=okx → crypto_monitor_okx(OKX_*) + EXCHANGE=gate → crypto_monitor_gate / crypto_monitor_gate_bot(GATE_*) + +环境变量: + EXCHANGE binance(默认)| okx | gate + PORT 默认 15200(与 crypto_monitor_* 的 Flask APP_PORT 错开;中控默认聚合 15200–15203) + HOST 默认 127.0.0.1 + CONTROL_TOKEN 可选;请求头 X-Control-Token + +Binance:BINANCE_API_KEY / BINANCE_API_SECRET;余额为 **U 本位永续合约账户** USDT(与 `crypto_monitor_binance` 的合约口径一致,非现货钱包);BINANCE_POSITION_MODE;BINANCE_MARGIN_MODE +OKX:OKX_API_KEY / OKX_API_SECRET / OKX_API_PASSPHRASE;OKX_TD_MODE;OKX_POS_MODE +Gate:GATE_API_KEY / GATE_API_SECRET;GATE_TD_MODE;GATE_POS_MODE + +代理与主项目一致时可设:BINANCE_SOCKS_PROXY / OKX_SOCKS_PROXY / GATE_SOCKS_PROXY(或 HTTP(S)_PROXY)。 +""" +from __future__ import annotations + +import math +import os +import time +from typing import Any + +import ccxt +from fastapi import FastAPI, Header, HTTPException, Request +from fastapi.responses import JSONResponse + +HOST = os.getenv("HOST", "127.0.0.1") +PORT = int(os.getenv("PORT", "15200")) +CONTROL_TOKEN = (os.getenv("CONTROL_TOKEN") or "").strip() + +_raw_ex = (os.getenv("EXCHANGE") or "binance").strip().lower() +if _raw_ex in ("binance", "bnb", "ba"): + EXCHANGE_KIND = "binance" +elif _raw_ex in ("okx", "okex"): + EXCHANGE_KIND = "okx" +elif _raw_ex in ("gate", "gateio"): + EXCHANGE_KIND = "gate" +else: + EXCHANGE_KIND = "binance" + +# —— Binance —— +_bin_pos = (os.getenv("BINANCE_POSITION_MODE") or "hedge").strip().lower() +BINANCE_POSITION_MODE = "hedge" if _bin_pos in ("hedge", "dual", "double", "hedged") else "oneway" +_bin_margin = (os.getenv("BINANCE_MARGIN_MODE") or "cross").strip().lower() +BINANCE_DEFAULT_MARGIN_MODE = "cross" if _bin_margin in ("cross", "cross_margin") else "isolated" + +# —— OKX —— +OKX_TD_MODE = (os.getenv("OKX_TD_MODE") or "cross").strip() +_okx_pos = (os.getenv("OKX_POS_MODE") or "hedge").strip().lower() +OKX_POS_MODE = "hedge" if _okx_pos in ("hedge", "long_short_mode", "dual") else "net" + +# —— Gate —— +_gate_td = (os.getenv("GATE_TD_MODE") or "cross").strip().lower() +GATE_DEFAULT_MARGIN_MODE = "cross" if _gate_td in ("cross", "cross_margin") else "isolated" +_gate_pos = (os.getenv("GATE_POS_MODE") or "hedge").strip().lower() +GATE_POS_MODE = "hedge" if _gate_pos in ("hedge", "dual", "double") else "single" + +app = FastAPI(title="sub-agent", docs_url=None, redoc_url=None) +_ccxt_ex: Any = None +_markets_loaded = False + + +def _socks_proxy_url(prefix: str) -> str: + return (os.getenv(f"{prefix}_SOCKS_PROXY") or "").strip() + + +def _http_https_proxy(prefix: str) -> dict[str, str] | None: + http = (os.getenv(f"{prefix}_HTTP_PROXY") or "").strip() + https = (os.getenv(f"{prefix}_HTTPS_PROXY") or "").strip() + socks = _socks_proxy_url(prefix) + if socks: + return {"http": socks, "https": socks} + if http or https: + return {"http": http, "https": https} + return None + + +def _attach_proxies(ex: Any, prefix: str) -> None: + p = _http_https_proxy(prefix) + if p: + ex.proxies = p + + +def _make_exchange() -> Any: + if EXCHANGE_KIND == "binance": + key = (os.getenv("BINANCE_API_KEY") or "").strip() + secret = (os.getenv("BINANCE_API_SECRET") or "").strip() + if not key or not secret: + raise RuntimeError("缺少 BINANCE_API_KEY / BINANCE_API_SECRET") + ex = ccxt.binance( + { + "apiKey": key, + "secret": secret, + "enableRateLimit": True, + "options": { + "defaultType": "swap", + # ccxt 默认 fetch_balance 走现货;与监控项目一致,固定为 U 本位合约钱包 + "fetchBalance": {"defaultType": "swap"}, + "defaultMarginMode": BINANCE_DEFAULT_MARGIN_MODE, + "adjustForTimeDifference": True, + }, + } + ) + _attach_proxies(ex, "BINANCE") + return ex + + if EXCHANGE_KIND == "okx": + key = (os.getenv("OKX_API_KEY") or "").strip() + secret = (os.getenv("OKX_API_SECRET") or "").strip() + password = (os.getenv("OKX_API_PASSPHRASE") or "").strip() + if not key or not secret or not password: + raise RuntimeError("缺少 OKX_API_KEY / OKX_API_SECRET / OKX_API_PASSPHRASE") + ex = ccxt.okx( + { + "apiKey": key, + "secret": secret, + "password": password, + "enableRateLimit": True, + "options": {"defaultType": "swap"}, + } + ) + _attach_proxies(ex, "OKX") + return ex + + # gate + key = (os.getenv("GATE_API_KEY") or "").strip() + secret = (os.getenv("GATE_API_SECRET") or "").strip() + if not key or not secret: + raise RuntimeError("缺少 GATE_API_KEY / GATE_API_SECRET") + ex = ccxt.gateio( + { + "apiKey": key, + "secret": secret, + "enableRateLimit": True, + "options": { + "defaultType": "swap", + "defaultMarginMode": GATE_DEFAULT_MARGIN_MODE, + }, + } + ) + _attach_proxies(ex, "GATE") + return ex + + +def get_exchange() -> Any: + global _ccxt_ex + if _ccxt_ex is None: + _ccxt_ex = _make_exchange() + return _ccxt_ex + + +def _ensure_markets() -> None: + global _markets_loaded + if not _markets_loaded: + get_exchange().load_markets() + _markets_loaded = True + + +def _check_token(x_control_token: str | None) -> None: + if not CONTROL_TOKEN: + return + if (x_control_token or "").strip() != CONTROL_TOKEN: + raise HTTPException(status_code=401, detail="invalid token") + + +def _position_mode_label() -> str: + if EXCHANGE_KIND == "binance": + return BINANCE_POSITION_MODE + if EXCHANGE_KIND == "okx": + return OKX_POS_MODE + return GATE_POS_MODE + + +def _close_param_candidates_binance(direction: str) -> list[dict[str, Any]]: + ps = "LONG" if direction == "long" else "SHORT" + hedge_ro = {"positionSide": ps, "reduceOnly": True} + hedge_plain = {"positionSide": ps} + oneway_ro = {"reduceOnly": True} + oneway_plain: dict[str, Any] = {} + if BINANCE_POSITION_MODE == "hedge": + return [hedge_ro, hedge_plain, oneway_ro, oneway_plain] + return [oneway_ro, oneway_plain, hedge_ro, hedge_plain] + + +def _close_param_candidates_okx(direction: str) -> list[dict[str, Any]]: + base: dict[str, Any] = {"tdMode": OKX_TD_MODE} + out: list[dict[str, Any]] = [] + if OKX_POS_MODE == "hedge": + ps = "long" if direction == "long" else "short" + out.extend( + [ + {**base, "posSide": ps, "reduceOnly": True}, + {**base, "posSide": ps}, + ] + ) + out.extend([{**base, "reduceOnly": True}, dict(base)]) + return out + + +def _close_param_candidates_gate(_direction: str) -> list[dict[str, Any]]: + return [{"reduceOnly": True}, {}] + + +def _close_param_candidates(direction: str) -> list[dict[str, Any]]: + if EXCHANGE_KIND == "binance": + return _close_param_candidates_binance(direction) + if EXCHANGE_KIND == "okx": + return _close_param_candidates_okx(direction) + return _close_param_candidates_gate(direction) + + +def _retryable_close_err(msg: str) -> bool: + s = (msg or "").lower() + if "-4061" in s: + return True + if "-1106" in s and "reduceonly" in s: + return True + if "reduceonly" in s or "reduce only" in s: + return True + if "position side" in s or "positionside" in s or "pos side" in s: + return True + if "dual side" in s or "position mode" in s: + return True + return False + + +def _position_contracts(p: dict[str, Any]) -> float: + raw = p.get("contracts") + if raw is not None: + try: + return float(raw) + except (TypeError, ValueError): + pass + info = p.get("info") or {} + for k in ("positionAmt", "positionamt", "pos", "size"): + if k in info: + try: + v = float(info[k]) + if v != 0: + return v + except (TypeError, ValueError): + pass + return 0.0 + + +def _position_side(p: dict[str, Any], contracts: float) -> str: + s = (p.get("side") or "").lower() + if s in ("long", "short"): + return s + if contracts > 0: + return "long" + if contracts < 0: + return "short" + return "long" + + +def _cancel_symbol_orders(ex: Any, sym: str) -> None: + try: + ex.cancel_all_orders(sym, params={}) + except Exception: + pass + if EXCHANGE_KIND != "binance": + return + try: + m = ex.market(sym) + cid = m.get("id") + if cid and hasattr(ex, "fapiPrivateDeleteAlgoOpenOrders"): + ex.fapiPrivateDeleteAlgoOpenOrders({"symbol": cid}) + except Exception: + pass + + +def _is_local(host: str | None) -> bool: + if not host: + return False + h = host.lower() + return h in ("127.0.0.1", "::1", "localhost") or h.startswith("::ffff:127.0.0.1") + + +def _finite_or_none(x: Any) -> float | None: + try: + f = float(x) + return f if math.isfinite(f) else None + except (TypeError, ValueError): + return None + + +def _extract_usdt_total(balance: dict[str, Any]) -> float | None: + """从 ccxt balance 结构中尽量取出 USDT 总额(与 crypto_monitor_binance 一致)。""" + usdt_info = balance.get("USDT") or {} + if not isinstance(usdt_info, dict): + usdt_info = {} + total_map = balance.get("total") or {} + if not isinstance(total_map, dict): + total_map = {} + free_map = balance.get("free") or {} + if not isinstance(free_map, dict): + free_map = {} + total = usdt_info.get("total") + if total is None: + total = usdt_info.get("equity") + if total is None: + total = total_map.get("USDT") + if total is None: + total = usdt_info.get("free") + if total is None: + total = free_map.get("USDT") + try: + return float(total) if total is not None else None + except (TypeError, ValueError): + return None + + +def _binance_futures_usdt_asset_row(balance: Any) -> dict[str, Any] | None: + """U 本位合约 fetch_balance(type=swap) 的 info.assets 中 USDT 一行(与币安合约后台口径一致)。""" + if not isinstance(balance, dict): + return None + info = balance.get("info") + if not isinstance(info, dict): + return None + assets = info.get("assets") + if not isinstance(assets, list): + return None + for a in assets: + if isinstance(a, dict) and str(a.get("asset") or "").upper() == "USDT": + return a + return None + + +def _binance_swap_usdt_total(ex: Any) -> float | None: + """仅 U 本位永续合约账户 USDT(显式 type=swap,不用现货余额)。""" + try: + bal = ex.fetch_balance({"type": "swap"}) + except Exception: + return None + row = _binance_futures_usdt_asset_row(bal) + if row: + for k in ("marginBalance", "walletBalance", "crossWalletBalance", "balance"): + x = row.get(k) + if x is not None and str(x).strip() != "": + try: + fv = float(x) + if fv >= 0: + return fv + except (TypeError, ValueError): + pass + v = _extract_usdt_total(bal) + return float(v) if v is not None else None + + +@app.middleware("http") +async def local_only(request: Request, call_next): + if request.client and not _is_local(request.client.host): + return JSONResponse({"detail": "forbidden"}, status_code=403) + return await call_next(request) + + +@app.get("/health") +def health(): + return {"ok": True, "exchange": EXCHANGE_KIND} + + +@app.get("/status") +def status(x_control_token: str | None = Header(default=None, alias="X-Control-Token")): + try: + return _status_inner(x_control_token) + except HTTPException: + raise + except Exception as e: + return JSONResponse( + { + "ok": False, + "error": f"status: {e}", + "exchange": EXCHANGE_KIND, + "balance_usdt": None, + "positions": [], + "total_unrealized_pnl": None, + }, + status_code=200, + ) + + +def _status_inner(x_control_token: str | None) -> Any: + _check_token(x_control_token) + try: + ex = get_exchange() + except RuntimeError as e: + return JSONResponse( + { + "ok": False, + "error": str(e), + "exchange": EXCHANGE_KIND, + "balance_usdt": None, + "positions": [], + "total_unrealized_pnl": None, + }, + status_code=200, + ) + try: + _ensure_markets() + except Exception as e: + return JSONResponse( + { + "ok": False, + "error": f"load_markets: {e}", + "exchange": EXCHANGE_KIND, + "balance_usdt": None, + "positions": [], + "total_unrealized_pnl": None, + }, + status_code=200, + ) + balance_usdt: float | None = None + try: + if EXCHANGE_KIND == "binance": + balance_usdt = _binance_swap_usdt_total(ex) + else: + bal = ex.fetch_balance() + u = bal.get("USDT") or {} + if isinstance(u, dict) and u.get("total") is not None: + balance_usdt = _finite_or_none(u["total"]) + except Exception: + pass + + positions_out: list[dict[str, Any]] = [] + total_upnl = 0.0 + try: + raw = ex.fetch_positions() or [] + except Exception as e: + return JSONResponse( + { + "ok": False, + "error": str(e), + "exchange": EXCHANGE_KIND, + "balance_usdt": balance_usdt, + "positions": [], + "total_unrealized_pnl": None, + }, + status_code=200, + ) + + for p in raw: + if not isinstance(p, dict): + continue + c = _position_contracts(p) + if abs(c) < 1e-12: + continue + sym = p.get("symbol") or "" + side = _position_side(p, c) + upnl = p.get("unrealizedPnl") + try: + upnl_f = float(upnl) if upnl is not None else 0.0 + except (TypeError, ValueError): + upnl_f = 0.0 + total_upnl += upnl_f + notional = p.get("notional") + try: + notional_f = float(notional) if notional is not None else None + except (TypeError, ValueError): + notional_f = None + entry = p.get("entryPrice") + try: + entry_f = float(entry) if entry is not None else None + except (TypeError, ValueError): + entry_f = None + positions_out.append( + { + "symbol": sym, + "side": side, + "contracts": abs(c), + "contracts_signed": c, + "notional_usdt": _finite_or_none(notional_f) if notional_f is not None else None, + "unrealized_pnl": _finite_or_none(upnl_f), + "entry_price": _finite_or_none(entry_f) if entry_f is not None else None, + } + ) + + try: + pm = _position_mode_label() + except Exception: + pm = EXCHANGE_KIND + return { + "ok": True, + "exchange": EXCHANGE_KIND, + "balance_usdt": balance_usdt, + "positions": positions_out, + "total_unrealized_pnl": _finite_or_none(total_upnl), + "position_mode": pm, + } + + +@app.post("/emergency/close-all") +def emergency_close_all(x_control_token: str | None = Header(default=None, alias="X-Control-Token")): + _check_token(x_control_token) + try: + ex = get_exchange() + except RuntimeError as e: + raise HTTPException(status_code=503, detail=str(e)) from e + try: + _ensure_markets() + except Exception as e: + return JSONResponse( + {"ok": False, "error": f"load_markets: {e}", "closed": [], "errors": [str(e)], "exchange": EXCHANGE_KIND}, + status_code=200, + ) + errors: list[str] = [] + closed: list[dict[str, Any]] = [] + + try: + raw = ex.fetch_positions() or [] + except Exception as e: + raise HTTPException(status_code=502, detail=f"fetch_positions: {e}") from e + + for p in raw: + if not isinstance(p, dict): + continue + c = _position_contracts(p) + if abs(c) < 1e-12: + continue + sym = p.get("symbol") + if not sym: + continue + side = _position_side(p, c) + close_side = "sell" if side == "long" else "buy" + direction = "long" if side == "long" else "short" + try: + amt = float(ex.amount_to_precision(sym, abs(c))) + except Exception: + amt = abs(c) + if amt <= 0: + continue + order_resp = None + last_err: Exception | None = None + for params in _close_param_candidates(direction): + try: + order_resp = ex.create_order(sym, "market", close_side, amt, None, params) + last_err = None + break + except Exception as e: + last_err = e + if _retryable_close_err(str(e)): + continue + errors.append(f"{sym}: {e}") + order_resp = None + break + if order_resp is None and last_err and sym not in "".join(errors): + errors.append(f"{sym}: {last_err}") + if order_resp is not None: + closed.append({"symbol": sym, "side": side, "amount": amt, "order_id": order_resp.get("id")}) + _cancel_symbol_orders(ex, sym) + time.sleep(0.05) + + return {"ok": len(errors) == 0, "closed": closed, "errors": errors, "exchange": EXCHANGE_KIND} + + +def main(): + import uvicorn + + uvicorn.run(app, host=HOST, port=PORT, log_level="warning", access_log=False) + + +if __name__ == "__main__": + main() diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py new file mode 100644 index 0000000..8c893d7 --- /dev/null +++ b/manual_trading_hub/hub.py @@ -0,0 +1,278 @@ +""" +中控:聚合各子账户 /status,转发紧急全平。 + +默认 **HUB_HOST=0.0.0.0** 且 **HUB_TRUST_LAN=开启**,便于局域网内浏览器访问;中间件仍拒绝非公网、非 RFC1918 私网的来源(本机 127.0.0.1 始终允许)。 +若仅需本机访问,请设置:HUB_HOST=127.0.0.1 或 HUB_TRUST_LAN=0(false/off)。 + +与仓库根目录下四个策略/监控项目对应时,中控默认聚合的子代理地址为 127.0.0.1:15200–15203 +(与各 crypto_monitor_* 里 Flask 的 APP_PORT 错开;Flask 仍用各自 .env 的 APP_HOST/APP_PORT)。 + + crypto_monitor_binance → 子代理建议 15200 + crypto_monitor_okx → 子代理建议 15201 + crypto_monitor_gate → 子代理建议 15202 + crypto_monitor_gate_bot→ 子代理建议 15203 + +各目录单独启动 agent.py 时设置 PORT=上述端口(环境变量名是 PORT,不是 APP_PORT),与 Flask 并存。 + +环境变量: + HUB_PORT 默认 5100 + HUB_HOST 默认 0.0.0.0(局域网可连);改为 127.0.0.1 则仅本机 + HUB_AGENTS 逗号分隔子代理 URL,留空则默认 15200–15203(避免与 Flask APP_PORT 冲突) + HUB_AGENT_NAMES 可选,逗号分隔显示名,与 URL 顺序对应 + HUB_DISABLED_IDS 可选,逗号分隔不参与监控/全平的账户 id(与 /api/agents 中 id 一致),例:暂不用 OKX 时写 1 + CONTROL_TOKEN 若子代理启用校验,在此填同一令牌(由中控代发请求头) + HUB_TRUST_LAN 默认开启;设为 0/false/off 则仅允许本机 IP 访问(与 HUB_HOST=0.0.0.0 搭配时仍只放行 127.0.0.1) +""" +from __future__ import annotations + +import asyncio +import os +import sys +from pathlib import Path + +import httpx +from fastapi import Body, FastAPI, HTTPException, Query, Request +from fastapi.responses import FileResponse, JSONResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel, Field + +HUB_HOST = os.getenv("HUB_HOST", "0.0.0.0") +HUB_PORT = int(os.getenv("HUB_PORT", "5100")) +CONTROL_TOKEN = (os.getenv("CONTROL_TOKEN") or "").strip() +_trust_raw = (os.getenv("HUB_TRUST_LAN", "true") or "").strip().lower() +HUB_TRUST_LAN = _trust_raw not in ("0", "false", "no", "off") +DIR = Path(__file__).resolve().parent +_REPO_ROOT = DIR.parent +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) +import license_lib # noqa: E402 + + +def _is_local(host: str | None) -> bool: + if not host: + return False + h = host.lower() + return h in ("127.0.0.1", "::1", "localhost") or h.startswith("::ffff:127.0.0.1") + + +def _ipv4_rfc1918_private(host: str) -> bool: + h = host.lower() + if h.startswith("::ffff:"): + h = h[7:] + parts = h.split(".") + if len(parts) != 4: + return False + try: + a, b, c, d = (int(x) for x in parts) + except ValueError: + return False + if any(x < 0 or x > 255 for x in (a, b, c, d)): + return False + if a == 10: + return True + if a == 172 and 16 <= b <= 31: + return True + if a == 192 and b == 168: + return True + return False + + +def _client_allowed(host: str | None) -> bool: + if _is_local(host): + return True + if HUB_TRUST_LAN and host and _ipv4_rfc1918_private(host): + return True + return False + + +def _agent_headers() -> dict[str, str]: + if not CONTROL_TOKEN: + return {} + return {"X-Control-Token": CONTROL_TOKEN} + + +_DEFAULT_FOLDER_LABELS = ( + "币安山寨账户 · crypto_monitor_binance", + "OKX · crypto_monitor_okx", + "Gate训练账户 · crypto_monitor_gate", + "Gate趋势回调 · crypto_monitor_gate_bot", +) + + +def _ids_from_csv(raw: str | None) -> set[str]: + if not raw or not str(raw).strip(): + return set() + return {x.strip() for x in str(raw).split(",") if x.strip()} + + +def hub_env_excluded_ids() -> set[str]: + """服务端固定关闭的账户(不参与拉取 /status、不参与全局全平)。""" + return _ids_from_csv(os.getenv("HUB_DISABLED_IDS")) + + +def merged_excluded_ids(query_exclude: str | None, body_ids: list[str] | None) -> set[str]: + s = hub_env_excluded_ids() + s |= _ids_from_csv(query_exclude) + if body_ids: + s |= {str(x).strip() for x in body_ids if str(x).strip()} + return s + + +def parse_agents() -> list[dict[str, str]]: + urls_s = (os.getenv("HUB_AGENTS") or "").strip() + if urls_s: + urls = [u.strip() for u in urls_s.split(",") if u.strip()] + else: + urls = [f"http://127.0.0.1:{p}" for p in range(15200, 15204)] + # 注意:若环境变量 HUB_AGENT_NAMES 非空,会完全优先于 _DEFAULT_FOLDER_LABELS(改代码不生效时请检查是否设了该变量) + names_s = (os.getenv("HUB_AGENT_NAMES") or "").strip() + names = [n.strip() for n in names_s.split(",") if n.strip()] if names_s else [] + out = [] + for i, url in enumerate(urls): + if i < len(names): + name = names[i] + elif i < len(_DEFAULT_FOLDER_LABELS): + name = _DEFAULT_FOLDER_LABELS[i] + else: + name = f"账户{i + 1}" + out.append({"id": str(i), "name": name, "url": url.rstrip("/")}) + return out + + +app = FastAPI(title="hub", docs_url=None, redoc_url=None) +license_lib.init_fastapi_app(app) +STATIC_DIR = DIR / "static" +if STATIC_DIR.is_dir(): + app.mount("/assets", StaticFiles(directory=str(STATIC_DIR)), name="assets") + + +@app.middleware("http") +async def local_only(request: Request, call_next): + if request.client and not _client_allowed(request.client.host): + return JSONResponse({"detail": "forbidden"}, status_code=403) + return await call_next(request) + + +@app.get("/") +def index_page(): + index = STATIC_DIR / "index.html" + if not index.is_file(): + return JSONResponse({"detail": "missing static/index.html"}, status_code=500) + return FileResponse(index) + + +@app.get("/api/agents") +def api_agents(): + return {"agents": parse_agents()} + + +class CloseAllBody(BaseModel): + exclude_ids: list[str] = Field(default_factory=list) + + +@app.get("/api/snapshot") +async def api_snapshot( + exclude_ids: str | None = Query( + default=None, + description="逗号分隔,浏览器侧再关闭的账户 id,与服务端 HUB_DISABLED_IDS 合并", + ), +): + excl = merged_excluded_ids(exclude_ids, None) + agents = [a for a in parse_agents() if a["id"] not in excl] + headers = _agent_headers() + + async def one(client: httpx.AsyncClient, a: dict[str, str]) -> dict: + url = f"{a['url']}/status" + try: + r = await client.get(url, headers=headers, timeout=10.0) + body = None + if r.content: + try: + body = r.json() + except Exception as je: + preview = (r.text or "")[:400].replace("\n", " ") + return { + "id": a["id"], + "name": a["name"], + "url": a["url"], + "http_ok": False, + "status_code": r.status_code, + "error": f"子代理返回非 JSON({je})。响应片段: {preview!r}", + "payload": None, + } + return { + "id": a["id"], + "name": a["name"], + "url": a["url"], + "http_ok": r.status_code == 200, + "status_code": r.status_code, + "payload": body, + } + except Exception as e: + return { + "id": a["id"], + "name": a["name"], + "url": a["url"], + "http_ok": False, + "status_code": None, + "error": str(e), + "payload": None, + } + + async with httpx.AsyncClient() as client: + rows = await asyncio.gather(*[one(client, a) for a in agents]) + env_ex = sorted(hub_env_excluded_ids()) + return {"rows": list(rows), "env_excluded_ids": env_ex} + + +@app.post("/api/close/{agent_id}") +async def api_close_one(agent_id: str): + agents = parse_agents() + target = next((a for a in agents if a["id"] == agent_id), None) + if not target: + raise HTTPException(status_code=404, detail="unknown agent") + headers = _agent_headers() + url = f"{target['url']}/emergency/close-all" + try: + async with httpx.AsyncClient() as client: + r = await client.post(url, headers=headers, timeout=120.0) + try: + body = r.json() + except Exception: + body = {"raw": r.text[:2000]} + return {"agent": target, "status_code": r.status_code, "payload": body} + except Exception as e: + raise HTTPException(status_code=502, detail=str(e)) from e + + +@app.post("/api/close-all") +async def api_close_all(body: CloseAllBody | None = Body(default=None)): + excl = merged_excluded_ids(None, body.exclude_ids if body else None) + agents = [a for a in parse_agents() if a["id"] not in excl] + headers = _agent_headers() + + async def post_close(client: httpx.AsyncClient, a: dict[str, str]) -> dict: + url = f"{a['url']}/emergency/close-all" + try: + r = await client.post(url, headers=headers, timeout=120.0) + try: + body = r.json() + except Exception: + body = {"raw": r.text[:2000]} + return {"id": a["id"], "name": a["name"], "status_code": r.status_code, "payload": body} + except Exception as e: + return {"id": a["id"], "name": a["name"], "status_code": None, "error": str(e)} + + async with httpx.AsyncClient() as client: + results = await asyncio.gather(*[post_close(client, a) for a in agents]) + return {"results": list(results)} + + +def main(): + import uvicorn + + uvicorn.run(app, host=HUB_HOST, port=HUB_PORT, log_level="warning", access_log=False) + + +if __name__ == "__main__": + main() diff --git a/manual_trading_hub/requirements.txt b/manual_trading_hub/requirements.txt new file mode 100644 index 0000000..a29f6d3 --- /dev/null +++ b/manual_trading_hub/requirements.txt @@ -0,0 +1,5 @@ +fastapi>=0.110,<1 +uvicorn[standard]>=0.27,<1 +httpx>=0.27,<1 +ccxt>=4.2,<5 +PySocks>=1.7,<2 diff --git a/manual_trading_hub/scripts/example-systemd/manual-agent-binance.service.example b/manual_trading_hub/scripts/example-systemd/manual-agent-binance.service.example new file mode 100644 index 0000000..366174f --- /dev/null +++ b/manual_trading_hub/scripts/example-systemd/manual-agent-binance.service.example @@ -0,0 +1,20 @@ +[Unit] +Description=手工交易子代理 Binance(复制为 manual-agent-binance.service 并修改路径) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=YOUR_USER +WorkingDirectory=YOUR_REPO/crypto_monitor_binance +Environment=PATH=YOUR_REPO/manual_trading_hub/.venv/bin:/usr/bin:/bin +Environment=EXCHANGE=binance +Environment=PORT=15200 +Environment=HOST=127.0.0.1 +EnvironmentFile=-YOUR_REPO/crypto_monitor_binance/.env +ExecStart=YOUR_REPO/manual_trading_hub/.venv/bin/python YOUR_REPO/manual_trading_hub/agent.py +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/manual_trading_hub/scripts/example-systemd/manual-hub.service.example b/manual_trading_hub/scripts/example-systemd/manual-hub.service.example new file mode 100644 index 0000000..702f82f --- /dev/null +++ b/manual_trading_hub/scripts/example-systemd/manual-hub.service.example @@ -0,0 +1,18 @@ +[Unit] +Description=手工交易中控 hub(复制到 /etc/systemd/system/manual-hub.service 并修改路径) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=YOUR_USER +WorkingDirectory=YOUR_REPO/manual_trading_hub +Environment=HUB_HOST=0.0.0.0 +Environment=HUB_TRUST_LAN=1 +Environment=HUB_PORT=5100 +ExecStart=YOUR_REPO/manual_trading_hub/.venv/bin/python YOUR_REPO/manual_trading_hub/hub.py +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/manual_trading_hub/scripts/start_agents_3screen.sh b/manual_trading_hub/scripts/start_agents_3screen.sh new file mode 100644 index 0000000..2a45f93 --- /dev/null +++ b/manual_trading_hub/scripts/start_agents_3screen.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# 一键用 screen 后台启动 3 个子代理(不含 OKX):Binance:15200、Gate:15202、Gate-Bot:15203 +# 用法:在任意目录执行 bash /path/to/manual_trading_hub/scripts/start_agents_3screen.sh +# 若 hub 单独在 /opt/manual_trading_hub,四个策略目录在别的路径,请先: +# export MANUAL_TRADING_REPO_ROOT=/path/to/含_crypto_monitor_*_的目录 +# 依赖:各策略目录已 cp .env.example .env 并配置;manual_trading_hub/.venv 已 pip install -r requirements.txt +# 日志:manual_trading_hub/logs/<会话名>.log(若 screen 里进程秒退,tail 该文件排查) +# 查看:screen -ls 接入:screen -r mt-agent-bn 停:./stop_agents_3screen.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HUB_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +if [[ -n "${MANUAL_TRADING_REPO_ROOT:-}" ]]; then + REPO_ROOT="$(cd "${MANUAL_TRADING_REPO_ROOT}" && pwd)" +else + REPO_ROOT="$(cd "${HUB_DIR}/.." && pwd)" +fi +AGENT_PY="${HUB_DIR}/agent.py" +VENV_PY="${HUB_DIR}/.venv/bin/python" +LOG_DIR="${HUB_DIR}/logs" +mkdir -p "${LOG_DIR}" + +echo "REPO_ROOT=${REPO_ROOT} (其下应有 crypto_monitor_binance、crypto_monitor_gate 等目录)" + +if ! command -v screen >/dev/null 2>&1; then + echo "未找到 screen,请先安装:sudo apt install screen" >&2 + exit 1 +fi + +if [[ ! -f "${VENV_PY}" ]]; then + echo "未找到 ${VENV_PY},请先在 manual_trading_hub 下创建 venv 并 pip install -r requirements.txt" >&2 + exit 1 +fi + +if [[ ! -f "${AGENT_PY}" ]]; then + echo "未找到 agent.py:${AGENT_PY}" >&2 + exit 1 +fi + +start_one() { + local name="$1" subdir="$2" exchange="$3" port="$4" + local work="${REPO_ROOT}/${subdir}" + local logf="${LOG_DIR}/${name}.log" + if [[ ! -d "${work}" ]]; then + echo "目录不存在,跳过:${work}" >&2 + echo " 若项目在别处,请设置 export MANUAL_TRADING_REPO_ROOT=/正确上级目录 后重跑" >&2 + return 1 + fi + if screen -ls 2>/dev/null | grep -qF ".${name}"; then + echo "已存在会话 ${name},跳过。要重建请先: screen -S ${name} -X quit" >&2 + return 0 + fi + screen -dmS "${name}" bash -c " +cd '${work}' || { echo 'cd failed' >>'${logf}'; exit 1; } +set -a +if [[ -f .env ]]; then + . ./.env +fi +set +a +export EXCHANGE='${exchange}' PORT='${port}' HOST=127.0.0.1 +exec '${VENV_PY}' '${AGENT_PY}' >>'${logf}' 2>&1 +" + sleep 0.5 + if screen -ls 2>/dev/null | grep -qF ".${name}"; then + echo "已启动 screen:${name} (${subdir} EXCHANGE=${exchange} PORT=${port}) 日志:${logf}" + else + echo "错误:${name} 启动后立刻退出。请执行: tail -80 '${logf}'" >&2 + fi +} + +start_one "mt-agent-bn" "crypto_monitor_binance" "binance" "15200" +start_one "mt-agent-gate" "crypto_monitor_gate" "gate" "15202" +start_one "mt-agent-gatebot" "crypto_monitor_gate_bot" "gate" "15203" + +echo "" +echo "下一步(一键中控 screen):" +echo " chmod +x ${SCRIPT_DIR}/start_hub_screen.sh && ${SCRIPT_DIR}/start_hub_screen.sh" +echo "或手动:cd ${HUB_DIR} && source .venv/bin/activate && export HUB_AGENTS=... && python hub.py" +echo "" +echo "查看会话: screen -ls" diff --git a/manual_trading_hub/scripts/start_hub_screen.sh b/manual_trading_hub/scripts/start_hub_screen.sh new file mode 100644 index 0000000..d505d1f --- /dev/null +++ b/manual_trading_hub/scripts/start_hub_screen.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# 一键用 screen 后台启动中控 hub.py(默认对接 3 个 agent:不含 OKX) +# 用法:先启动 3 个 agent(start_agents_3screen.sh),再执行本脚本 +# bash /path/to/manual_trading_hub/scripts/start_hub_screen.sh +# 可在运行前 export 覆盖:HUB_AGENTS、HUB_AGENT_NAMES、HUB_HOST、HUB_PORT、CONTROL_TOKEN、HUB_TRUST_LAN +# 默认 HUB_HOST=0.0.0.0、HUB_TRUST_LAN=1(局域网可访问私网 IP)。仅本机: export HUB_HOST=127.0.0.1 HUB_TRUST_LAN=0 +# 查看:screen -ls 接入:screen -r mt-hub 停:./stop_hub_screen.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HUB_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +VENV_PY="${HUB_DIR}/.venv/bin/python" +HUB_PY="${HUB_DIR}/hub.py" + +HUB_AGENTS="${HUB_AGENTS:-http://127.0.0.1:15200,http://127.0.0.1:15202,http://127.0.0.1:15203}" +# 不设 HUB_AGENT_NAMES 时不在此写死,由 hub.py 里 _DEFAULT_FOLDER_LABELS 生效;要覆盖可: export HUB_AGENT_NAMES="名1,名2,名3" 或写在 manual_trading_hub/.env +HUB_AGENT_NAMES="${HUB_AGENT_NAMES:-}" +HUB_HOST="${HUB_HOST:-0.0.0.0}" +HUB_PORT="${HUB_PORT:-5100}" +HUB_TRUST_LAN="${HUB_TRUST_LAN:-1}" + +if ! command -v screen >/dev/null 2>&1; then + echo "未找到 screen,请先安装:sudo apt install screen" >&2 + exit 1 +fi + +if [[ ! -f "${VENV_PY}" ]]; then + echo "未找到 ${VENV_PY},请先在 manual_trading_hub 下创建 venv 并 pip install -r requirements.txt" >&2 + exit 1 +fi + +if [[ ! -f "${HUB_PY}" ]]; then + echo "未找到 hub.py:${HUB_PY}" >&2 + exit 1 +fi + +if screen -ls 2>/dev/null | grep -qF ".mt-hub"; then + echo "已存在会话 mt-hub,跳过。要重建请先: screen -S mt-hub -X quit" >&2 + exit 0 +fi + +# 仅当外层显式设置了 HUB_AGENT_NAMES 时才 export,避免 export 空串覆盖 .env 里已有配置 +EXTRA_NAMES="" +if [[ -n "${HUB_AGENT_NAMES:-}" ]]; then + EXTRA_NAMES="export HUB_AGENT_NAMES=$(printf '%q' "${HUB_AGENT_NAMES}")" +fi + +screen -dmS mt-hub bash -c " +set -e +cd '${HUB_DIR}' +set -a +if [[ -f .env ]]; then + . ./.env +fi +set +a +export HUB_AGENTS='${HUB_AGENTS}' +${EXTRA_NAMES} +export HUB_HOST='${HUB_HOST}' +export HUB_PORT='${HUB_PORT}' +export HUB_TRUST_LAN='${HUB_TRUST_LAN}' +exec '${VENV_PY}' '${HUB_PY}' +" + +echo "已启动 screen:mt-hub" +echo " HUB_AGENTS=${HUB_AGENTS}" +if [[ -n "${HUB_AGENT_NAMES}" ]]; then + echo " HUB_AGENT_NAMES=${HUB_AGENT_NAMES}" +else + echo " HUB_AGENT_NAMES=(未设,使用 hub.py 内 _DEFAULT_FOLDER_LABELS)" +fi +echo " 监听:${HUB_HOST}:${HUB_PORT} HUB_TRUST_LAN=${HUB_TRUST_LAN}(默认允许私网访问中控)" +echo " 本机:http://127.0.0.1:${HUB_PORT}/ 局域网:http://<本机局域网IP>:${HUB_PORT}/" +echo " 仅本机请: export HUB_HOST=127.0.0.1 HUB_TRUST_LAN=0 后重启本脚本" +echo "接入: screen -r mt-hub" diff --git a/manual_trading_hub/scripts/stop_agents_3screen.sh b/manual_trading_hub/scripts/stop_agents_3screen.sh new file mode 100644 index 0000000..81d89f5 --- /dev/null +++ b/manual_trading_hub/scripts/stop_agents_3screen.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# 关闭 start_agents_3screen.sh 启动的 3 个 screen 会话 +set -euo pipefail +for s in mt-agent-bn mt-agent-gate mt-agent-gatebot; do + if screen -ls 2>/dev/null | grep -qF ".${s}"; then + screen -S "${s}" -X quit && echo "已关闭:${s}" || true + else + echo "未运行:${s}" + fi +done diff --git a/manual_trading_hub/scripts/stop_hub_screen.sh b/manual_trading_hub/scripts/stop_hub_screen.sh new file mode 100644 index 0000000..49f25c5 --- /dev/null +++ b/manual_trading_hub/scripts/stop_hub_screen.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# 关闭 start_hub_screen.sh 启动的中控 screen 会话 +set -euo pipefail +if screen -ls 2>/dev/null | grep -qF ".mt-hub"; then + screen -S mt-hub -X quit && echo "已关闭:mt-hub" || true +else + echo "未运行:mt-hub" +fi diff --git a/manual_trading_hub/scripts/后台运行-Ubuntu.md b/manual_trading_hub/scripts/后台运行-Ubuntu.md new file mode 100644 index 0000000..ffe5a73 --- /dev/null +++ b/manual_trading_hub/scripts/后台运行-Ubuntu.md @@ -0,0 +1,191 @@ +# Ubuntu 后台运行(中控 + 子代理) + +各 `crypto_monitor_*` 策略目录:首次 **`cp .env.example .env`** 并编辑;**`.env` 不进 Git**,`git pull` 不覆盖。升级前建议 `cp .env .env.backup.$(date +%Y%m%d)`。子代理启动前须 **`source` 该目录 `.env`**(见下文)。 + +前台跑 `python agent.py` / `python hub.py` 时,关掉终端进程会结束。要**常驻后台**,可用下面三种之一(推荐 **systemd**)。 + +--- + +## 一、tmux / screen(最简单,适合先试用) + +仓库已提供 **一键起 3 个 agent(不含 OKX)** 的 screen 脚本(需可执行权限): + +```bash +chmod +x manual_trading_hub/scripts/start_agents_3screen.sh +chmod +x manual_trading_hub/scripts/start_hub_screen.sh +chmod +x manual_trading_hub/scripts/stop_agents_3screen.sh +chmod +x manual_trading_hub/scripts/stop_hub_screen.sh +./manual_trading_hub/scripts/start_agents_3screen.sh +./manual_trading_hub/scripts/start_hub_screen.sh +# 关闭: +./manual_trading_hub/scripts/stop_hub_screen.sh +./manual_trading_hub/scripts/stop_agents_3screen.sh +``` + +脚本默认认为:`manual_trading_hub` 的**上一级目录**里并列放着三个 `crypto_monitor_*`。若你把 hub 单独放在 `/opt/crypto_monitor/manual_trading_hub`,而策略项目在例如 `/opt/crypto_monitor/` 下的其他位置,请先执行 +`export MANUAL_TRADING_REPO_ROOT=/opt/crypto_monitor` 再运行 `start_agents_3screen.sh`。 +启动后若 `screen -ls` 里没有 `mt-agent-*`,看日志:`tail -80 /opt/crypto_monitor/manual_trading_hub/logs/mt-agent-bn.log`。 + +### 局域网内其他电脑访问中控 + +中控 **默认** `HUB_HOST=0.0.0.0`、`HUB_TRUST_LAN=开启`,同一局域网内可用 `http://<中控机局域网IP>:5100/` 打开页面(本机仍可用 `http://127.0.0.1:5100/`)。请确保防火墙放行端口,例如:`sudo ufw allow 5100/tcp`。 + +若改为 **仅本机** 访问:`HUB_HOST=127.0.0.1` 或 `HUB_TRUST_LAN=0`,重启 hub。 + +也可把上述变量写进 `manual_trading_hub/.env`,再用 `start_hub_screen.sh` 启动。 + +以下为手工 tmux 示例: + +```bash +# 新建会话,在里面照常启动 agent 或 hub,然后按键 Ctrl+B 再按 D 脱离 +tmux new -s hub +cd /opt/crypto_monitor/manual_trading_hub && source .venv/bin/activate && python hub.py +# Ctrl+B, D + +tmux new -s agent-bn +cd /opt/crypto_monitor/crypto_monitor_binance && set -a && source .env && set +a +export EXCHANGE=binance PORT=15200 HOST=127.0.0.1 +source /opt/crypto_monitor/manual_trading_hub/.venv/bin/activate +python /opt/crypto_monitor/manual_trading_hub/agent.py +# Ctrl+B, D +``` + +重新连上:`tmux attach -t hub` + +--- + +## 二、nohup(快速、无守护重启) + +```bash +cd /opt/crypto_monitor/manual_trading_hub +source .venv/bin/activate +nohup python hub.py > /tmp/manual-hub.log 2>&1 & +``` + +子代理同理(每个账户一条):**先 `source` 该策略目录的 `.env`**(`agent.py` 不会自己读文件),再 `nohup python …/agent.py`。 + +停进程:`ps aux | grep hub.py` 或 `grep agent.py`,再 `kill `。 + +--- + +## 三、systemd(推荐:开机自启、崩溃自动拉起) + +1. 把下面两个示例里的 **`YOUR_REPO`** 改成 **`/opt/crypto_monitor`**(或你本机实际仓库根目录),`YOUR_USER` 改成 Linux 用户名。 +2. 复制到 `/etc/systemd/system/`(需 sudo),文件名例如 `manual-hub.service`、`manual-agent-binance.service`。 +3. 执行: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now manual-hub.service +sudo systemctl enable --now manual-agent-binance.service +# 其余 OKX / Gate 同理再建 3 个 unit 或合并为多条 +``` + +查看状态:`sudo systemctl status manual-hub` +日志:`journalctl -u manual-hub -f` + +### 示例:`/etc/systemd/system/manual-hub.service` + +```ini +[Unit] +Description=手工交易中控 hub +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=YOUR_USER +WorkingDirectory=YOUR_REPO/manual_trading_hub +Environment=HUB_HOST=0.0.0.0 +Environment=HUB_TRUST_LAN=1 +Environment=HUB_PORT=5100 +ExecStart=YOUR_REPO/manual_trading_hub/.venv/bin/python YOUR_REPO/manual_trading_hub/hub.py +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +**注意:** `agent.py` **不会**像 Flask 那样自动加载目录里的 `.env`,密钥必须由 **systemd 的 `EnvironmentFile=`** 注入,或用下面 `bash -c` 方式 `source .env` 后再启动。 + +### 示例:`/etc/systemd/system/manual-agent-binance.service` + +```ini +[Unit] +Description=手工交易子代理 Binance +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=YOUR_USER +WorkingDirectory=YOUR_REPO/crypto_monitor_binance +Environment=PATH=YOUR_REPO/manual_trading_hub/.venv/bin:/usr/bin:/bin +Environment=EXCHANGE=binance +Environment=PORT=15200 +Environment=HOST=127.0.0.1 +# 把该账户的 .env 注入进程(与 Flask 同一份即可;仅支持 KEY=VALUE 行,勿写 shell 语法) +EnvironmentFile=-YOUR_REPO/crypto_monitor_binance/.env +ExecStart=YOUR_REPO/manual_trading_hub/.venv/bin/python YOUR_REPO/manual_trading_hub/agent.py +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +若 `.env` 含 **systemd 无法解析** 的内容(复杂引号、`export` 等),改用: + +```ini +ExecStart=/bin/bash -lc 'set -a; source YOUR_REPO/crypto_monitor_binance/.env; set +a; exec YOUR_REPO/manual_trading_hub/.venv/bin/python YOUR_REPO/manual_trading_hub/agent.py' +``` + +并删掉或注释掉 `EnvironmentFile=` 行,避免重复注入。 + +**OKX / Gate / Gate-Bot**:各复制一份 `.service`,改 `Description`、`WorkingDirectory`、以及 `EXCHANGE` / `PORT`(`15201`、`15202`、`15203`)。 + +--- + +## 四、常见问题(子代理 / screen / 依赖) + +1. **`curl http://127.0.0.1:15202/status`(或其它端口)返回 `ok:false`,错误里提到 pysocks / SOCKS** + 策略目录 `.env` 里配置了 `GATE_SOCKS_PROXY`(或 `BINANCE_SOCKS_PROXY`、`OKX_SOCKS_PROXY`)时,ccxt 需要 **PySocks**。在 **`/opt/crypto_monitor/manual_trading_hub/.venv`**(或你本机的 `manual_trading_hub/.venv`)中执行: + `pip install PySocks` 或 `pip install -r requirements.txt`。 + +2. **已经 `pip install PySocks`,错误文案完全不变** + 子代理是**常驻进程**,首次请求已创建 ccxt;在运行中的进程里**仅安装包不会自动生效**。须**重启**该 agent:例如 + `screen -S mt-agent-gate -X quit` + 再执行 `start_agents_3screen.sh`(或你的等价启动方式)。**不要**依赖「会话还在、以为已经更新」的旧进程。 + +3. **`start_agents_3screen.sh` 打印「已存在会话、跳过」** + 脚本检测到 `mt-agent-*` 已在跑会跳过创建。需要先停再启: + `./stop_agents_3screen.sh` + 或对单个会话:`screen -S mt-agent-gate -X quit`,再跑启动脚本。 + +4. **确认 15200/15202/15203 上的进程用的是 hub 的 venv** + ```bash + ps aux | grep agent.py + tr '\0' ' ' < /proc//cmdline; echo + ``` + 应看到 `…/manual_trading_hub/.venv/bin/python` 与 `…/manual_trading_hub/agent.py`。若用的是系统 `python3`,要么在**同一解释器环境**里装依赖,要么改为用 `start_agents_3screen.sh` 启动(脚本内写死 `VENV_PY`)。 + +5. **中控某账户一直红 / 非 JSON** + 对应该端口的 agent 未启动,或 `HUB_AGENTS` 与 agent 的 `PORT` 不一致。本机先测: + `curl -sS http://127.0.0.1:1520x/status | head -c 400` + 再看 `logs/mt-agent-*.log`。 + +6. **子代理端口与 Flask 冲突** + agent 使用环境变量 **`PORT`**(脚本里 15200、15202、15203);各策略 `.env` 里的 **`APP_PORT`** 给 Flask。二者**不能**相同。 + +7. **systemd 下改依赖或 `.env` 后** + 与 screen 相同:`pip install` 或改 `EnvironmentFile` 后需 **`systemctl restart `**,否则仍是旧进程。 + +--- + +## 五、注意 + +- 子代理与中控仍建议只监听 **127.0.0.1**;Flask 的 `APP_HOST=0.0.0.0` 与中控无关。 +- 若策略项目用**自己的** `.venv`,把 `ExecStart` 里的 Python 改成该 venv 的 `python`,但 `agent.py` 路径仍指向 `manual_trading_hub/agent.py`。 + +同目录下另有 `example-systemd/*.service.example` 可复制后改路径使用。 diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html new file mode 100644 index 0000000..d2ba263 --- /dev/null +++ b/manual_trading_hub/static/index.html @@ -0,0 +1,398 @@ + + + + + + 手工交易中控 + + + +
+

手工交易 · 多账户中控

+
+ + + + 关闭的账户不轮询、不参与全平(本机记住);账户显示名由中控环境变量 HUB_AGENT_NAMES 配置,所有访问同一中控的电脑一致。 + +
+
+
+
+ + + diff --git a/manual_trading_hub/部署文档.md b/manual_trading_hub/部署文档.md new file mode 100644 index 0000000..4253d93 --- /dev/null +++ b/manual_trading_hub/部署文档.md @@ -0,0 +1,220 @@ +# 手工交易中控 — 部署文档 + +本文档描述在本机(以 Windows 为主)将 **manual_trading_hub** 部署为「多账户监控 + 紧急全平」的推荐步骤、验收方法与注意事项。功能说明与配置项详见同目录 **《README.md》**。 + +--- + +## 一、部署目标 + +- 本机或局域网可访问 **中控页面**(默认监听 `0.0.0.0:5100`,私网 IP 可打开;本机可用 `127.0.0.1`)。 +- 每个需要纳入监控的交易所账户,有独立的 **子代理** 进程(默认端口 **`15200`~`15203`**,与各 `crypto_monitor_*` 里 Flask 的 **`APP_PORT`** 错开)。 +- 策略项目 `crypto_monitor_binance` / `crypto_monitor_okx` / `crypto_monitor_gate` / `crypto_monitor_gate_bot` **无需修改代码**,与中控并行运行。 + +--- + +## 二、前置条件 + +1. 已安装 **Python 3.10+**,且 `pip` 可用。 +2. 各策略目录下已配置好交易所 API:在对应 `crypto_monitor_*` 目录执行过 **`cp .env.example .env`** 并填好密钥(与平时运行 Flask 时同一份 **`.env`**,或等价环境变量)。各项目 `app.py` 会读取同目录 `.env` 中的 **`APP_HOST` / `APP_PORT`** 用于 **Flask**;子代理使用环境变量 **`PORT`**,二者不能占用同一端口。策略目录的 **`.env` 不在 Git 中**,`git pull` 不会覆盖;升级前建议在各策略目录 `cp .env .env.backup.$(date +%Y%m%d)`。 +3. 本机端口 **`15200`~`15203`、5100**(及你各策略 `.env` 里的 `APP_PORT`)无冲突;若被占用,须改子代理 `PORT` 并同步修改 `HUB_AGENTS`。 +4. **Linux 路径约定(可选)**:若部署在 Ubuntu 等环境,建议将整个代码树放在 **`/opt/crypto_monitor/`** 下(与 `scripts/后台运行-Ubuntu.md` 示例一致),例如策略目录为 `/opt/crypto_monitor/crypto_monitor_binance`、中控为 `/opt/crypto_monitor/manual_trading_hub`。 + +--- + +## 三、目录与文件 + +``` +manual_trading_hub/ + agent.py # 子代理(单账户) + hub.py # 中控 + requirements.txt # Python 依赖 + static/ + index.html # 中控前端页面 + README.md # 说明文档 + 部署文档.md # 本文档 +``` + +--- + +## 四、安装依赖 + +在 `manual_trading_hub` 目录执行: + +```powershell +cd manual_trading_hub +python -m venv .venv +.\.venv\Scripts\Activate.ps1 +pip install -r requirements.txt +``` + +Linux / macOS: + +```bash +cd manual_trading_hub +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +``` + +后续启动 `hub.py` / `agent.py` 时,请使用**同一虚拟环境**中的 `python`,保证已安装 `fastapi`、`uvicorn`、`httpx`、`ccxt`;若各策略 `.env` 中配置了 **SOCKS 代理**(`GATE_SOCKS_PROXY` / `BINANCE_SOCKS_PROXY` / `OKX_SOCKS_PROXY` 等),还需 **`PySocks`**(已写入 `requirements.txt`,新环境 `pip install -r requirements.txt` 即可)。 + +--- + +## 五、部署步骤(推荐顺序) + +### 步骤 1:确认策略进程(可选) + +若你平时已运行各目录下的 Flask(`app.py`),可保持运行;中控与子代理**不依赖** Flask 是否启动。 + +### 步骤 2:为每个账户启动子代理 + +**每个账户一个终端窗口**,在对应策略项目目录进入后加载环境变量,再启动 agent(路径按你仓库实际位置调整)。 + +示例:**Binance 账户 → 子代理端口 15200**(Flask 的 `APP_PORT` 可为 5000、5001 等,由该目录 `.env` 决定,二者不同即可) + +```powershell +cd D:\你的路径\交易复盘系统\crypto_monitor_binance +.\.venv\Scripts\Activate.ps1 # 若策略项目使用独立 venv,可在此激活 +# 确保已加载 .env(若你用 dotenv-cli 等工具,在此执行) +$env:EXCHANGE="binance" +$env:PORT="15200" +$env:HOST="127.0.0.1" +python ..\manual_trading_hub\agent.py +``` + +**OKX → 子代理 `PORT=15201`**、`EXCHANGE=okx`;**Gate → 15202**、**Gate-Bot → 15203**,`EXCHANGE=gate`。 + +**可选安全**:各 agent 与中控制台设置相同随机串: + +```powershell +$env:CONTROL_TOKEN="你的长随机串" +``` + +子代理与中控均需设置同一 `CONTROL_TOKEN`。 + +### 步骤 3:启动中控 + +新开终端: + +```powershell +cd D:\你的路径\交易复盘系统\manual_trading_hub +.\.venv\Scripts\Activate.ps1 +$env:HUB_HOST="0.0.0.0" +$env:HUB_PORT="5100" +# 若暂不使用 OKX,可在服务端固定关闭 id=1: +# $env:HUB_DISABLED_IDS="1" +python hub.py +``` + +控制台无报错即表示监听成功(日志级别为 `warning`,默认不刷屏)。 + +### 步骤 4:浏览器验收 + +1. 打开 **http://127.0.0.1:5100/** 或 **http://本机局域网IP:5100/** 。 +2. 应看到与 `HUB_AGENTS` 配置一致的账户卡片;已启动子代理的行应能显示余额或持仓(无持仓则显示「无持仓」)。 +3. 点击 **立即刷新**,数据应更新。 +4. 取消某一行的 **「参与监控」**,该行应变灰且不再刷新数据;再勾选应恢复。 + +**不建议**在生产环境对实盘轻易点击「全平」做测试;若必须测试,请使用测试网或小资金账户。 + +--- + +## 六、接口验收(可选) + +在已启动 hub 的本机 PowerShell: + +```powershell +Invoke-RestMethod "http://127.0.0.1:5100/api/agents" +Invoke-RestMethod "http://127.0.0.1:5100/api/snapshot" +``` + +子代理单测(需 agent 已启动): + +```powershell +Invoke-RestMethod "http://127.0.0.1:15200/health" +Invoke-RestMethod "http://127.0.0.1:15200/status" +``` + +若启用了 `CONTROL_TOKEN`,需加请求头(PowerShell 示例): + +```powershell +$h = @{ "X-Control-Token" = "你的长随机串" } +Invoke-RestMethod "http://127.0.0.1:15200/status" -Headers $h +``` + +--- + +## 七、自定义端口与账户数量 + +仅部署 3 个账户时,可只启动 3 个 agent,并设置: + +```powershell +$env:HUB_AGENTS="http://127.0.0.1:15200,http://127.0.0.1:15202,http://127.0.0.1:15203" +$env:HUB_AGENT_NAMES="Binance,Gate,Gate-Bot" +``` + +`HUB_AGENT_NAMES` 与 URL **数量、顺序**一一对应。 + +--- + +## 八、常驻运行(可选) + +**不必一直开着终端。** Ubuntu 下可用 **tmux 脱离**、**nohup &**、或 **systemd**(推荐:崩溃自启、可开机启动)。 + +详细命令、**常见问题(screen / PySocks / 重启)**与 **systemd 单元示例**见: + +- `manual_trading_hub/scripts/后台运行-Ubuntu.md` +- `manual_trading_hub/scripts/example-systemd/*.service.example`(改路径后复制到 `/etc/systemd/system/`) + +Windows 可将 `hub.py` 与各 `agent.py` 写入「启动」文件夹,或使用 **NSSM** / **任务计划程序** 在登录后启动;注意工作目录与环境变量要在任务里写全。 + +--- + +## 九、升级与回滚 + +- **升级**:在仓库根目录 `git pull` 后,于 `manual_trading_hub` 重新 `pip install -r requirements.txt`(若依赖有变)。**各策略目录 `.env` 不会被 pull 覆盖**;若 `.env.example` 有新增变量,请对照模板**手动补进**你的 `.env`。 +- **升级前备份(推荐)**:在每个用到的 `crypto_monitor_*` 目录执行 `cp .env .env.backup.$(date +%Y%m%d)`。 +- **回滚**:恢复上一版本代码与依赖;配置在各策略 `.env` 与浏览器 localStorage,无中控数据库。 + +--- + +## 十、故障排查 + +### 10.1 现象速查表 + +| 现象 | 可能原因 | 处理 | +|------|----------|------| +| 页面打不开 | 中控未启动或端口错 | 检查 `HUB_PORT`、防火墙、是否用 127.0.0.1 访问 | +| 某账户一直报错 | 子代理未启动或端口不一致 | 核对 `HUB_AGENTS` 与该 agent 的 `PORT` | +| 中控 JSON 报错 / `Expecting value` | 子代理未启动、返回非 JSON、或端口错 | 本机 `curl http://127.0.0.1:1520x/status`;确认 agent 已起且端口与 `HUB_AGENTS` 一致 | +| 401 / 连不上子代理 | `CONTROL_TOKEN` 不一致 | 中控与子代理设为同一令牌,或全部去掉令牌 | +| 有密钥仍报缺密钥 | 启动 agent 时未加载策略目录的 `.env` | 在对应目录启动,或手动 export 全部密钥变量 | +| `/status` 里 `ok:false`,文案含 **pysocks** / **SOCKS** | 使用 SOCKS 代理但未装 **PySocks** | 在 **`manual_trading_hub/.venv`** 执行 `pip install PySocks` 或 `pip install -r requirements.txt` | +| **已安装 PySocks**,`/status` 仍报同样 pysocks 文案 | 子代理进程未重启 | 子代理是常驻进程,**仅 pip 不会替换已运行进程**;退出对应 screen / systemd 单元后重新拉起(Ubuntu 见 `scripts/后台运行-Ubuntu.md` §四) | +| 跑 `start_agents_3screen.sh` 无新会话 | screen 会话已存在被脚本跳过 | 先 `stop_agents_3screen.sh`,或 `screen -S mt-agent-xxx -X quit` 后再启动 | +| 子代理行为异常、依赖已装仍报错 | 实际用的不是 hub 的 venv | `ps aux` 查看命令行,应为 **`/opt/crypto_monitor/manual_trading_hub/.venv/bin/python`** `…/manual_trading_hub/agent.py`(路径按你机器实际根目录调整);否则在**当前使用的解释器**对应环境中装依赖,或改用官方脚本启动 | +| 子代理端口与 Flask 抢端口 | `PORT` 与策略目录 `.env` 的 `APP_PORT` 相同 | 子代理用 **15200~15203**(或自改),Flask 继续用 `APP_PORT`,二者勿重复 | +| 全平失败 | 持仓模式、精度、交易所维护 | 看返回 JSON 中 `errors` 字段;对照交易所 App | + +### 10.2 依赖或代码更新后 + +- **`pip install -r requirements.txt` 或单独 `pip install` 之后**:须**重启**受影响的 **hub**、**各子代理** 进程,变更才会生效。 +- **拉取新版本代码后**:同样重启进程;若曾遇 `'function' object has no attribute load_markets'` 等旧版 agent 异常,升级后重启子代理即可。 + +--- + +## 十一、安全清单(部署前自检) + +- [ ] 若机器暴露在公网,已用防火墙限制 `HUB_PORT` 或已改为 `HUB_HOST=127.0.0.1` / `HUB_TRUST_LAN=0` 仅本机。 +- [ ] API Key 权限最小化;生产环境建议启用 IP 白名单。 +- [ ] `CONTROL_TOKEN` 已设为足够长的随机串(若启用)。 +- [ ] 已告知实际操作人员:**全局全平**不可撤销。 + +--- + +## 十二、与说明文档的关系 + +- **《README.md》**:产品能力、架构、环境变量表、API 简表、常见问题。 +- **《部署文档.md》**:按步骤安装、启动、验收与运维注意(本文)。 + +两处如有端口/变量不一致,以**当前代码**与 **README 中的表格**为准。 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..556e7c5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +# crypto_monitor 四个 Flask 子项目共用依赖(Binance / Gate / Gate_bot / OKX) +# 安装:在各子目录 venv 内执行 pip install -r ../requirements.txt +flask>=3.0,<4 +requests>=2.31,<3 +ccxt>=4.2,<5 +werkzeug>=3.0,<4 +PySocks>=1.7,<2 +Pillow>=10.0,<12 diff --git a/一键部署.bat b/一键部署.bat new file mode 100644 index 0000000..11671b0 --- /dev/null +++ b/一键部署.bat @@ -0,0 +1,16 @@ +@echo off +chcp 65001 >nul +setlocal +cd /d "%~dp0" +echo crypto_monitor 一键环境部署 ... +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0deploy\setup_env.ps1" %* +set ERR=%ERRORLEVEL% +if not "%ERR%"=="0" ( + echo. + echo 部署失败,退出码 %ERR% + pause + exit /b %ERR% +) +echo. +pause +exit /b 0 diff --git a/关键位止盈止损与移动保本更新说明.md b/关键位止盈止损与移动保本更新说明.md new file mode 100644 index 0000000..164b456 --- /dev/null +++ b/关键位止盈止损与移动保本更新说明.md @@ -0,0 +1,164 @@ +# 关键位止盈止损方案 & 移动保本开关 — 更新说明 + +**版本日期:** 2026-05-21 +**影响实例:** `crypto_monitor_binance`(币安)、`crypto_monitor_gate`(Gate)、`crypto_monitor_okx`(OKX) +**共用库:** 仓库根目录 `key_sl_tp_lib.py`(计算逻辑)、`fib_key_monitor_lib.py`(斐波 SL/TP 不变) + +--- + +## 1. 变更摘要 + +| 项 | 变更前 | 变更后 | +|----|--------|--------| +| 箱体/收敛 SL/TP | 仅一种(标准突破) | 添加时可三选一 | +| 移动保本(关键位) | 自动单写死开启 | 添加时可勾选,**默认关闭** | +| 斐波 SL/TP | H/L 限价方案 | **不变**;仅增加移动保本开关 | +| 历史 `key_monitors` | 无新字段 | 缺字段视为 **标准方案 + 保本关** | + +--- + +## 2. 数据库(`key_monitors` 新增列) + +启动时自动 `ALTER TABLE`(已有库兼容): + +| 字段 | 类型 | 默认 | 说明 | +|------|------|------|------| +| `sl_tp_mode` | TEXT | `standard` | `standard` / `box_1p5` / `trend_manual` | +| `manual_take_profit` | REAL | NULL | 仅 `trend_manual` 使用 | +| `breakeven_enabled` | INTEGER | `0` | 0=关,1=开 | + +旧记录无上述列时,读取逻辑按 **`standard` + 保本关** 处理。 + +--- + +## 3. 三种止盈止损方案(仅箱体突破 / 收敛突破) + +计划价 **E** = 确认 K(倒数第 1 根已闭合 5m)收盘价;**H** = \|上沿 − 下沿\|。 +自动开仓(币安/Gate)前仍须:**计划 RR(按 E)> `KEY_AUTO_MIN_PLANNED_RR`**(默认 1.5)。 + +### 3.1 标准突破 `standard`(原逻辑) + +| 方向 | 止损 SL | 止盈 TP | +|------|---------|---------| +| 多 | 突破 K 最低价 × (1 − `KEY_STOP_OUTSIDE_BREAKOUT_PCT`/100) | E + 1×H | +| 空 | 突破 K 最高价 × (1 + 外侧%) | E − 1×H | + +默认外侧:**0.5%**(`KEY_STOP_OUTSIDE_BREAKOUT_PCT`)。 + +### 3.2 箱体 1R / 止盈 1.5H `box_1p5` + +以 **E 为当前价**,风险距离 = 1×H,止盈距离 = 1.5×H,**计划 RR 固定约 1.5:1**。 + +| 方向 | 止损 SL | 止盈 TP | +|------|---------|---------| +| 多 | E − H | E + 1.5×H | +| 空 | E + H | E − 1.5×H | + +### 3.3 趋势单 + 自填止盈 `trend_manual` + +| 方向 | 止损 SL | 止盈 TP | +|------|---------|---------| +| 多 | 突破 K 最低价 × (1 − `KEY_TREND_STOP_OUTSIDE_PCT`/100) | 添加时录入的 `manual_take_profit` | +| 空 | 突破 K 最高价 × (1 + 外侧%) | 同上 | + +- 环境变量 **`KEY_TREND_STOP_OUTSIDE_PCT`**,默认 **1**(即 1%)。 +- 添加时校验:做多止盈 > 上沿;做空止盈 < 下沿。 + +--- + +## 4. 斐波回调 0.618 / 0.786 + +- **SL/TP**:仍为 `calc_fib_plan`(多:SL=L、TP=H;空:SL=H、TP=L),**无**三方案下拉。 +- **移动保本**:添加时可勾选;成交写入 `order_monitors` 时带入该勾选状态(默认关)。 + +--- + +## 5. 移动保本 + +| 场景 | 行为 | +|------|------| +| 关键位添加 | 复选框「移动保本」,**默认不勾选** | +| 箱体/收敛自动开仓成功 | `order_monitors.breakeven_enabled` = 添加时的选择 | +| 斐波限价成交后 | 同上 | +| 人工「实盘下单」 | **不变**:仍为表单勾选,默认仍可按原页面逻辑 | + +触发参数仍用全局 `.env`:`BREAKEVEN_RR_TRIGGER`、`BREAKEVEN_STEP_R`、`BREAKEVEN_OFFSET_PCT`。 + +--- + +## 6. 前端(关键位添加表单) + +在「上沿 / 下沿」后增加: + +1. **止盈止损方案**(仅类型为箱体突破、收敛突破时显示) +2. **趋势单止盈价**(仅选「趋势单·自填止盈」时显示且必填) +3. **移动保本**(箱体/收敛/斐波显示;默认不勾) + +活跃列表卡片展示:**方案**、**保本:开/关**。 + +--- + +## 7. 环境变量 + +```env +# 标准方案:突破 K 极值外侧 % +KEY_STOP_OUTSIDE_BREAKOUT_PCT=0.5 + +# 趋势单方案:突破 K 极值外侧 % +KEY_TREND_STOP_OUTSIDE_PCT=1 +``` + +已写入各实例 `.env.example`(Binance / Gate / OKX)。 + +--- + +## 8. 交易所差异 + +| 实例 | 箱体/收敛触发后 | +|------|----------------| +| **Binance / Gate** | 门控通过 → 按方案算 SL/TP → 市价开仓 → 挂交易所 TP/SL → 写入下单监控 | +| **OKX** | 门控通过 → **企业微信提醒**(推送中含录入方案的计划 SL/TP/RR),**不自动市价开仓** | + +OKX 用户按推送中的计划价自行下单;斐波仍为限价 + 成交后挂 TP/SL(与原先一致)。 + +--- + +## 9. 涉及文件清单 + +| 路径 | 说明 | +|------|------| +| `key_sl_tp_lib.py` | **新建**,三方案计算与文案 | +| `crypto_monitor_binance/app.py` | 门控触发、开仓、斐波、add_key | +| `crypto_monitor_binance/templates/index.html` | 表单 + JS + 列表展示 | +| `crypto_monitor_binance/.env.example` | `KEY_TREND_STOP_OUTSIDE_PCT` | +| `crypto_monitor_gate/app.py` | 同 Binance | +| `crypto_monitor_gate/templates/index.html` | 同 Binance | +| `crypto_monitor_gate/.env.example` | 同上 | +| `crypto_monitor_okx/app.py` | add_key、提醒文案、斐波保本 | +| `crypto_monitor_okx/templates/index.html` | 表单 + JS | +| `crypto_monitor_okx/.env.example` | 注释项 | + +--- + +## 10. 部署与验证建议 + +1. `git pull` 后重启三个实例的 Flask 进程(会自动迁移 `key_monitors` 列)。 +2. 在 `.env` 中按需设置 `KEY_TREND_STOP_OUTSIDE_PCT`(不配则用默认 1)。 +3. **验证 Binance/Gate** + - 添加箱体突破,选「箱体1R·止盈1.5H」,不勾保本 → 触发后微信应显示方案名、保本关、SL/TP 符合 E±H / E±1.5H。 + - 添加趋势单,填止盈,勾保本 → 成交后持仓卡片「移动保本:开」。 +4. **验证 OKX**:门控通过时微信应含「录入方案」与计划 SL/TP,并注明提醒模式不自动开仓。 +5. 旧关键位条目:列表应显示「方案:标准突破」「保本:关」(除非库中已有新字段值)。 + +--- + +## 11. 代码入口(便于二次开发) + +| 功能 | 符号 | +|------|------| +| 计划 SL/TP | `plan_key_sl_tp()` in `key_sl_tp_lib.py` | +| 按监控行计算 | `_key_plan_sl_tp_for_row()` in各 `app.py` | +| 添加关键位 | `add_key()` | +| 箱体/收敛轮询 | `check_key_monitors()`(OKX 仅提醒) | +| 斐波添加 | `_add_fib_key_monitor(..., breakeven_enabled=)` | +| 自动开仓写监控 | `_market_open_for_key_monitor(..., breakeven_enabled=)` | diff --git a/备份与恢复.md b/备份与恢复.md new file mode 100644 index 0000000..f106e71 --- /dev/null +++ b/备份与恢复.md @@ -0,0 +1,268 @@ +# 备份与恢复(Ubuntu 服务器) + +本文档面向 **VPS / Ubuntu**,项目统一放在 **`/opt/crypto_monitor`**,数据备份统一放在 **`/root/backups`**。 + +| 类型 | 内容 | 存放位置 | 频率 | +|------|------|----------|------| +| **数据库 + 复盘图片** | `crypto.db`、`static/images` | `/root/backups/<实例名>/YYYY-MM-DD/` | 每天北京时间 **0:00**(cron) | +| **`.env` 配置** | API、密码、风控参数等 | 项目目录 `.env.backup.日期`;可选集中拷到 `/root/backups/env/` | **升级 / 改配置前**手动执行 | + +> `.env` **不会**被自动备份脚本包含(含密钥,请单独备份)。 +> 三个常用实例:`crypto_monitor_binance`、`crypto_monitor_gate`、`crypto_monitor_gate_bot`。 + +--- + +## 一、首次安装:三个实例自动备份 + 试跑 + +整段复制到 SSH 终端执行(需 **root** 或对该目录有写权限): + +```bash +apt install -y sqlite3 2>/dev/null || true + +for dir in crypto_monitor_binance crypto_monitor_gate crypto_monitor_gate_bot; do + cd "/opt/crypto_monitor/${dir}" || exit 1 + chmod +x scripts/backup_data.sh scripts/install_backup_cron.sh + bash scripts/install_backup_cron.sh + bash scripts/backup_data.sh +done + +echo "=== crontab ===" +crontab -l +echo "=== backup dirs ===" +ls -la /root/backups/*/ +``` + +成功后应有: + +- `crontab -l` 含一行 `CRON_TZ=Asia/Shanghai` + 三条 `0 0 * * * .../backup_data.sh` +- `/root/backups/crypto_monitor_binance/2026-05-17/`(日期为当天)等目录,内含 `crypto.db`、`static_images.tar.gz`、`manifest.txt` + +日志路径: + +- `/var/log/crypto-monitor-backup-crypto_monitor_binance.log` +- `/var/log/crypto-monitor-backup-crypto_monitor_gate.log` +- `/var/log/crypto-monitor-backup-crypto_monitor_gate_bot.log` + +--- + +## 二、仅安装某一个实例的自动备份 + +把 `INSTANCE` 改成目录名后整段执行: + +```bash +INSTANCE=crypto_monitor_binance +cd "/opt/crypto_monitor/${INSTANCE}" +chmod +x scripts/backup_data.sh scripts/install_backup_cron.sh +bash scripts/install_backup_cron.sh +bash scripts/backup_data.sh +``` + +`INSTANCE` 可选:`crypto_monitor_binance` | `crypto_monitor_gate` | `crypto_monitor_gate_bot` + +--- + +## 三、手动立即备份(数据库 + 图片,三个实例) + +不等到 0 点,立刻各备份一次: + +```bash +for dir in crypto_monitor_binance crypto_monitor_gate crypto_monitor_gate_bot; do + echo ">>> ${dir}" + bash "/opt/crypto_monitor/${dir}/scripts/backup_data.sh" +done +ls -la /root/backups/*/*/ +``` + +--- + +## 四、检查定时任务与备份是否正常 + +```bash +crontab -l +ls -la /root/backups/*/ +du -sh /root/backups/*/ +tail -n 20 /var/log/crypto-monitor-backup-crypto_monitor_binance.log +tail -n 20 /var/log/crypto-monitor-backup-crypto_monitor_gate.log +tail -n 20 /var/log/crypto-monitor-backup-crypto_monitor_gate_bot.log +``` + +--- + +## 五、`.env` 备份(升级 / git pull / 改密钥前) + +### 5.1 三个实例一次性备份到各自项目目录 + +```bash +DATE=$(TZ=Asia/Shanghai date +%Y%m%d) +for dir in crypto_monitor_binance crypto_monitor_gate crypto_monitor_gate_bot; do + src="/opt/crypto_monitor/${dir}/.env" + dst="/opt/crypto_monitor/${dir}/.env.backup.${DATE}" + if [ -f "$src" ]; then + cp -a "$src" "$dst" + echo "ok: $dst" + else + echo "skip (no .env): $src" + fi +done +``` + +### 5.2 同时集中备份到 `/root/backups/env/`(推荐) + +```bash +DATE=$(TZ=Asia/Shanghai date +%Y%m%d) +mkdir -p /root/backups/env +for dir in crypto_monitor_binance crypto_monitor_gate crypto_monitor_gate_bot; do + src="/opt/crypto_monitor/${dir}/.env" + if [ -f "$src" ]; then + cp -a "$src" "/root/backups/env/${dir}.env.${DATE}" + echo "ok: /root/backups/env/${dir}.env.${DATE}" + fi +done +ls -la /root/backups/env/ +``` + +> `/root/backups/env/` 含密钥,勿上传网盘、勿提交 Git。 + +--- + +## 六、`.env` 恢复 + +### 6.1 从项目目录内的备份恢复 + +把 `INSTANCE` 和 `DATE` 改成实际值(`DATE` 为备份当天的 `YYYYMMDD`): + +```bash +INSTANCE=crypto_monitor_binance +DATE=20260517 +cd "/opt/crypto_monitor/${INSTANCE}" +cp -a ".env.backup.${DATE}" .env +echo "restored .env from .env.backup.${DATE}" +``` + +### 6.2 从 `/root/backups/env/` 恢复 + +```bash +INSTANCE=crypto_monitor_binance +DATE=20260517 +cp -a "/root/backups/env/${INSTANCE}.env.${DATE}" "/opt/crypto_monitor/${INSTANCE}/.env" +echo "restored from /root/backups/env/${INSTANCE}.env.${DATE}" +``` + +恢复后重启对应 PM2 进程,例如: + +```bash +pm2 restart crypto-monitor-binance +pm2 restart crypto-monitor-gate +pm2 restart crypto-monitor-gate-bot +``` + +(进程名以你 `pm2 list` 为准。) + +--- + +## 七、数据库 + 复盘图片恢复 + +从自动备份目录恢复。先停服务再覆盖,避免 SQLite 写入冲突。 + +把 `INSTANCE`、`DATE`(文件夹名 `YYYY-MM-DD`)改成实际值: + +```bash +INSTANCE=crypto_monitor_binance +DATE=2026-05-17 +BK="/root/backups/${INSTANCE}/${DATE}" +PROJ="/opt/crypto_monitor/${INSTANCE}" + +test -f "${BK}/crypto.db" || { echo "backup not found: ${BK}"; exit 1; } + +pm2 stop crypto-monitor-binance 2>/dev/null || true + +cp -a "${PROJ}/crypto.db" "${PROJ}/crypto.db.before_restore.$(date +%Y%m%d%H%M)" 2>/dev/null || true +cp -a "${BK}/crypto.db" "${PROJ}/crypto.db" + +if [ -f "${BK}/static_images.tar.gz" ]; then + tar -xzf "${BK}/static_images.tar.gz" -C "${PROJ}" +fi + +pm2 start crypto-monitor-binance 2>/dev/null || true +echo "restored ${INSTANCE} from ${BK}" +``` + +Gate / Gate Bot 将 `INSTANCE`、`pm2` 名称改为对应实例即可。 + +--- + +## 八、升级代码推荐顺序(含备份) + +```bash +DATE=$(TZ=Asia/Shanghai date +%Y%m%d) +mkdir -p /root/backups/env + +for dir in crypto_monitor_binance crypto_monitor_gate crypto_monitor_gate_bot; do + PROJ="/opt/crypto_monitor/${dir}" + [ -f "${PROJ}/.env" ] && cp -a "${PROJ}/.env" "/root/backups/env/${dir}.env.${DATE}" + bash "${PROJ}/scripts/backup_data.sh" 2>/dev/null || true +done + +cd /opt/crypto_monitor +git pull + +for dir in crypto_monitor_binance crypto_monitor_gate crypto_monitor_gate_bot; do + echo ">>> merge .env.example if needed: ${dir}" + diff -u "${dir}/.env.example" "${dir}/.env" | head -30 || true +done + +pm2 restart all +``` + +`git pull` 后对照各目录 **`.env.example`**,把**新增变量名**手动补进 `.env`(不会自动合并)。 + +--- + +## 九、备份目录结构说明 + +```text +/root/backups/ + env/ # .env 集中备份(手动) + crypto_monitor_binance.env.20260517 + crypto_monitor_gate.env.20260517 + crypto_monitor_gate_bot.env.20260517 + crypto_monitor_binance/ + 2026-05-17/ + crypto.db + static_images.tar.gz + manifest.txt + crypto_monitor_gate/ + 2026-05-17/ + ... + crypto_monitor_gate_bot/ + 2026-05-17/ + ... +``` + +- **保留策略**:自动备份目录按日期文件夹保留 **30 天**,超期在下次 `backup_data.sh` 运行时删除。 +- **可选 `.env` 变量**(写在各实例 `.env` 中):`BACKUP_ROOT`、`BACKUP_RETENTION_DAYS`、`BACKUP_INSTANCE`(见各目录 `.env.example` 注释)。 + +--- + +## 十、卸载自动备份定时任务 + +仅删除三个实例的 backup 行(保留其它 cron): + +```bash +for dir in crypto_monitor_binance crypto_monitor_gate crypto_monitor_gate_bot; do + SCRIPT="/opt/crypto_monitor/${dir}/scripts/backup_data.sh" + crontab -l 2>/dev/null | grep -vF "$SCRIPT" | crontab - +done +crontab -l +``` + +--- + +## 十一、相关文档 + +| 文档 | 说明 | +|------|------| +| [README.md](./README.md) | 仓库总览 | +| [crypto_monitor_binance/部署文档.md](./crypto_monitor_binance/部署文档.md) | Binance 部署与备份细节 | +| [crypto_monitor_gate/部署文档.md](./crypto_monitor_gate/部署文档.md) | Gate 部署 | +| [crypto_monitor_gate_bot/部署文档.md](./crypto_monitor_gate_bot/部署文档.md) | Gate Bot 部署 |