commit 53863559f495ec1511164f697ee24e1612b158ef Author: dekun Date: Fri Jul 17 16:18:13 2026 +0800 Initialize crypto_monitor_user (user edition) from monitor codebase. Retarget git remote, install path, and deploy docs from crypto_monitor to crypto_monitor_user. Co-authored-by: Cursor diff --git a/.cursor/rules/auto-push-deploy.mdc b/.cursor/rules/auto-push-deploy.mdc new file mode 100644 index 0000000..317bb6a --- /dev/null +++ b/.cursor/rules/auto-push-deploy.mdc @@ -0,0 +1,18 @@ +--- +description: After each completed code change, commit, push origin/main, and deploy to zk.hyf2.cc +alwaysApply: true +--- + +# Auto push & deploy + +When a user-facing code change is **finished** (not mid-debug / not "先不要改代码"): + +1. Commit only the relevant files (skip unrelated CRLF-only docs noise). +2. `git push origin main` to `https://git.bz121.com/dekun/crypto_monitor_user.git`. +3. Deploy to production `zk.hyf2.cc`:`cd /opt/crypto_monitor_user && git pull && bash deploy/pull_and_restart.sh`. +4. Confirm PM2 processes are online; briefly report commit hash + deploy status. + +Do **not** wait for the user to say "推送并部署" again unless they cancel this habit. + +SSH: Prefer key auth; if BatchMode fails, use existing Paramiko root login path used in this project. +Do not print or put passwords in user-facing replies. diff --git a/.cursor/rules/no-touch-existing-positions.mdc b/.cursor/rules/no-touch-existing-positions.mdc new file mode 100644 index 0000000..fd6c8eb --- /dev/null +++ b/.cursor/rules/no-touch-existing-positions.mdc @@ -0,0 +1,14 @@ +--- +description: Never trade or close the user's existing option/perp positions during testing +alwaysApply: true +--- + +# Do not touch existing positions + +When testing option open/close, pending orders, monitors, or any live exchange path on this project: + +- **Never** flatten, reduce, cancel-close, or otherwise close positions the user already holds. +- **Never** reuse an existing live position for smoke tests. +- If a live trade test is required: open a **new** minimal test position only after explicit approval, then close **only that test position**. +- If the only way to verify something needs an existing position: **stop and ask the user first**. +- Prefer unit tests / dry paths over live orders when sufficient. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fefbae4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# Shell 脚本在仓库内统一 LF,避免 Linux 上 bash: pipefail: invalid option name(CRLF) +*.sh text eol=lf +deploy/** text eol=lf +# 文档统一 LF,避免 Windows 编辑后产生 CRLF 脏 diff +docs/** text eol=lf +# .env 模板统一 LF,避免 Linux PM2 source 报 $'\r': command not found +**/.env.example text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6b82791 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# 本地真实配置(含 API 密钥),勿提交 +**/.env +.env + +# 允许提交模板 +!**/.env.example +!.env.example + +# Python +**/__pycache__/ +**/*.pyc +**/.venv/ + +# 本地备份(可选,勿提交) +**/.env.backup* +**/.env.bak +**/.env.local +manual_trading_hub/hub_settings.json +manual_trading_hub/hub_backup_state.json +manual_trading_hub/hub_fund_history.json +manual_trading_hub/hub_supervisor_state.json +manual_trading_hub/hub_ai_summaries.json +manual_trading_hub/hub_ai_chat.json +manual_trading_hub/hub_ai_fund_history.json +manual_trading_hub/data/ +backups/ + +# 数据库与上传(运行时生成) +**/*.sqlite +**/crypto.db diff --git a/AI复盘与模型配置说明.md b/AI复盘与模型配置说明.md new file mode 100644 index 0000000..560ea28 --- /dev/null +++ b/AI复盘与模型配置说明.md @@ -0,0 +1,70 @@ +# AI 复盘与模型配置说明 + +三个 `crypto_monitor_*` 实例共用仓库根目录 **`ai_client.py`**(通过 `PYTHONPATH=..` 导入).用于 **交易记录与复盘** 页的 AI 点评,短评建议,以及从复盘截图提取结构化 JSON. + +--- + +## 一,二选一:`AI_PROVIDER` + +| 值 | 说明 | +|----|------| +| **`openai`**(默认) | OpenAI 兼容 **Chat Completions** 接口 | +| **`ollama`** | 本机 Ollama **`/api/generate`**(流式 NDJSON) | + +在对应子目录 **`.env`** 中设置(各所 `.env.example` 已含模板): + +```bash +AI_PROVIDER=openai +AI_TIMEOUT_SECONDS=120 + +# OpenAI 兼容网关(默认) +OPENAI_API_BASE=https://op.bz121.com/v1 +OPENAI_API_KEY=你的密钥 +OPENAI_MODEL=gemma4:e4b + +# 本机 Ollama(仅当 AI_PROVIDER=ollama) +OLLAMA_API=http://127.0.0.1:11434/api/generate +AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest +``` + +### OpenAI 兼容网关 + +- **Base URL**:`https://op.bz121.com/v1`(请求路径为 `{base}/chat/completions`). +- **API Key**:在 [op.bz121.com](https://op.bz121.com/) 登录后,于 **`gateway.json`** 页面复制(与网关账号一致). +- **默认模型**:`gemma4:e4b`(可通过 `OPENAI_MODEL` 覆盖). + +### Ollama + +- 需本机已安装并拉取对应模型;`AI_PROVIDER=ollama` 时使用 `OLLAMA_API` 与 `AI_MODEL`. +- 三所 `app.py` **不再** 直连 Ollama;统一走 `ai_client.ai_generate` / `ai_review` / `ai_short_advice`. + +--- + +## 二,部署注意 + +1. **PM2 / 手工启动**:`ecosystem.config.cjs` 中 **`PYTHONPATH=..`** 必须包含仓库根,否则无法 `from ai_client import ...`. +2. 修改 `.env` 后重启对应实例,例如:`pm2 restart crypto_binance`(名称以你机器为准). +3. **`git pull`** 不会改 `.env`;若 `.env.example` 新增 AI 变量,请手动补进本机 `.env`. +4. **勿** 将含真实 `OPENAI_API_KEY` 的 `.env` 提交 Git. + +--- + +## 三,功能入口(网页) + +登录后进入 **「交易记录与复盘」**: + +- 单条记录 **AI 复盘** / **短评**(依赖上述配置). +- 上传复盘图后 **从图片提取** 字段(内部调用 `ai_generate`,与所选 provider 一致). + +若请求超时或返回错误,请检查:密钥是否有效,网关是否可达,`AI_TIMEOUT_SECONDS` 是否过短,Ollama 是否已启动(仅 ollama 模式). + +--- + +## 四,相关文件 + +| 路径 | 说明 | +|------|------| +| `ai_client.py` | 统一封装 OpenAI / Ollama | +| `crypto_monitor_*/.env.example` | 各所环境变量模板 | +| 各所《部署文档.md》§ AI 复盘 | 与本文一致的简表 | +| 各所《使用说明.md》 | 运行前配置中的 AI 项 | diff --git a/README.md b/README.md new file mode 100644 index 0000000..02c690a --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# 复盘交易系统(crypto_monitor_user · 用户版) + +多交易所 **USDT 永续** 的下单监控,**关键位**,**策略交易**,**止盈止损 / 移动保本** 与 **AI 复盘**,三所独立部署 + 可选 **中控** 聚合监控. + +**远程仓库**:[https://git.bz121.com/dekun/crypto_monitor_user.git](https://git.bz121.com/dekun/crypto_monitor_user.git) + +--- + +## 部署环境(必读) + +| 项 | 约定 | +|----|------| +| 系统 | **Ubuntu 22.04 / 24.04** | +| 用户 | **root** | +| 路径 | **`/opt/crypto_monitor_user`** | +| 进程 | **PM2**(唯一推荐的常驻方式) | + +**环境详解**(Python 3.10+,Node,PM2 安装与启动顺序):**[docs/ubuntu-server.md](./docs/ubuntu-server.md)** +**一键部署管理器**:`curl -fsSL .../deploy/manage.sh | bash` → **[deploy/README.md](./deploy/README.md)** + +```bash +# 新服务器(推荐) +curl -fsSL https://git.bz121.com/dekun/crypto_monitor_user/raw/branch/main/deploy/manage.sh | bash + +# 或手动 clone +cd /opt +git clone https://git.bz121.com/dekun/crypto_monitor_user.git crypto_monitor_user +cd /opt/crypto_monitor_user +bash deploy/manage.sh +``` + +配置与运维脚本: **[docs/env-sync-scripts.md](./docs/env-sync-scripts.md)** · **[备份与恢复.md](./备份与恢复.md)** + +--- + +## 功能导航 + +| 功能 | 说明 | 文档 | +|------|------|------| +| **关键位监控** | 箱体/收敛自动开仓,阻力支撑提醒,斐波限价;止盈止损方案与 **移动保本** 开关 | 各所 [关键位自动下单说明.md](./crypto_monitor_binance/关键位自动下单说明.md)(Gate/OKX 目录内同名);方案细则 **[关键位止盈止损与移动保本更新说明.md](./关键位止盈止损与移动保本更新说明.md)** | +| **实盘下单 / 下单监控** | 首仓,以损定仓;监控内 **止盈 / 止损**,**移动保本**(步进 R,偏移%) | 各所 [使用说明.md](./crypto_monitor_binance/使用说明.md) · 顶栏「实盘下单」`/trade` | +| **策略交易** | **趋势回调** + **顺势加仓**(`/strategy` 双栏) | **[策略交易说明.md](./策略交易说明.md)** · 趋势细则 [docs/trend-pullback-strategy.md](./docs/trend-pullback-strategy.md) | +| **策略交易记录** | 已结束计划快照(最近 100 条),筛选与展开详情 | [策略交易说明.md §五](./策略交易说明.md) · 顶栏 `/strategy/records` | +| **交易复盘** | 平仓记录,错过机会,图表;**AI 点评** | **[AI复盘与模型配置说明.md](./AI复盘与模型配置说明.md)** · 顶栏「交易记录与复盘」`/records` | +| **中控** | 多账户持仓/委托聚合,行情 K 线,紧急全平(**不在中控网页下单**) | [manual_trading_hub/使用说明.md](./manual_trading_hub/使用说明.md) · [部署文档.md](./manual_trading_hub/部署文档.md) | + +其它专题:[计仓模式](./docs/position-sizing-mode.md) · [每日自动划转](./docs/auto-transfer-daily.md) · [Chrome 快捷方式图标](./docs/shortcut-icon.md) + +--- + +## 仓库目录 + +| 目录 | 交易所 / 角色 | 部署文档 | +|------|----------------|----------| +| `crypto_monitor_binance/` | Binance U 本位永续 | [部署文档.md](./crypto_monitor_binance/部署文档.md) | +| `crypto_monitor_gate/` | Gate | [部署文档.md](./crypto_monitor_gate/部署文档.md) | +| `crypto_monitor_okx/` | OKX 永续 | [部署文档.md](./crypto_monitor_okx/部署文档.md) | +| `manual_trading_hub/` | 中控 + 子代理 | [部署文档.md](./manual_trading_hub/部署文档.md) | +| `lib/` | **共用模块**(策略,关键位,交易,中控库,AI,静态与模板) | **[docs/lib-structure.md](./docs/lib-structure.md)** | +| `brand/` | 各所共用图标与 manifest | — | +| `docs/`,`deploy/`,`scripts/`,`tests/` | 文档,环境,脚本,单元测试 | — | + +共用代码 import 示例:`from lib.strategy.strategy_db import init_strategy_tables`(各所启动时仍将仓库根加入 `PYTHONPATH`).详见 **[docs/lib-structure.md](./docs/lib-structure.md)**. + +--- + +## 技术要点 + +- **Python 3.10+**,Flask,ccxt,SQLite(`crypto.db`) +- 三所 `.env` 前缀不同(`BINANCE_*` / `GATE_*` / `OKX_*`),**不可混用** +- 实盘须 `LIVE_TRADING_ENABLED=true` 且理解 API 权限与 IP 白名单风险 +- 经 **SOCKS** 访问交易所时配置各所 `*_SOCKS_PROXY` 并安装 PySocks + +--- + +## 推荐阅读顺序 + +1. [docs/ubuntu-server.md](./docs/ubuntu-server.md) — 装 Python / Node / PM2,PM2 启动三所 + 中控 +2. 各所 **`.env`**(从 `.env.example` 复制) +3. 所用功能对应上表 **功能导航** 文档 +4. [备份与恢复.md](./备份与恢复.md) — 生产机备份习惯 + +--- + +## 安全 + +- **勿** 将 `.env`,API Secret,`.pem` 提交 Git +- 公网暴露中控须配置登录,`HUB_BRIDGE_TOKEN`,HTTPS Cookie +- 实盘风险由使用者自行承担 + +若子目录 README 与本文冲突,以 **子目录《部署文档》与当前代码** 为准. diff --git a/brand/icon.svg b/brand/icon.svg new file mode 100644 index 0000000..2277788 --- /dev/null +++ b/brand/icon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/brand/icons/apple-touch-icon.png b/brand/icons/apple-touch-icon.png new file mode 100644 index 0000000..bd835ad Binary files /dev/null and b/brand/icons/apple-touch-icon.png differ diff --git a/brand/icons/binance/apple-touch-icon.png b/brand/icons/binance/apple-touch-icon.png new file mode 100644 index 0000000..702fc96 Binary files /dev/null and b/brand/icons/binance/apple-touch-icon.png differ diff --git a/brand/icons/binance/favicon.ico b/brand/icons/binance/favicon.ico new file mode 100644 index 0000000..26172c8 Binary files /dev/null and b/brand/icons/binance/favicon.ico differ diff --git a/brand/icons/binance/icon-16.png b/brand/icons/binance/icon-16.png new file mode 100644 index 0000000..c623509 Binary files /dev/null and b/brand/icons/binance/icon-16.png differ diff --git a/brand/icons/binance/icon-192.png b/brand/icons/binance/icon-192.png new file mode 100644 index 0000000..4cda818 Binary files /dev/null and b/brand/icons/binance/icon-192.png differ diff --git a/brand/icons/binance/icon-32.png b/brand/icons/binance/icon-32.png new file mode 100644 index 0000000..c706b7d Binary files /dev/null and b/brand/icons/binance/icon-32.png differ diff --git a/brand/icons/binance/icon-48.png b/brand/icons/binance/icon-48.png new file mode 100644 index 0000000..877ec15 Binary files /dev/null and b/brand/icons/binance/icon-48.png differ diff --git a/brand/icons/binance/icon-512.png b/brand/icons/binance/icon-512.png new file mode 100644 index 0000000..6f48a2b Binary files /dev/null and b/brand/icons/binance/icon-512.png differ diff --git a/brand/icons/binance/icon.svg b/brand/icons/binance/icon.svg new file mode 100644 index 0000000..70a7d51 --- /dev/null +++ b/brand/icons/binance/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/brand/icons/favicon.ico b/brand/icons/favicon.ico new file mode 100644 index 0000000..0af9b9c Binary files /dev/null and b/brand/icons/favicon.ico differ diff --git a/brand/icons/gate/apple-touch-icon.png b/brand/icons/gate/apple-touch-icon.png new file mode 100644 index 0000000..129f899 Binary files /dev/null and b/brand/icons/gate/apple-touch-icon.png differ diff --git a/brand/icons/gate/favicon.ico b/brand/icons/gate/favicon.ico new file mode 100644 index 0000000..dcb291e Binary files /dev/null and b/brand/icons/gate/favicon.ico differ diff --git a/brand/icons/gate/icon-16.png b/brand/icons/gate/icon-16.png new file mode 100644 index 0000000..8ebd22a Binary files /dev/null and b/brand/icons/gate/icon-16.png differ diff --git a/brand/icons/gate/icon-192.png b/brand/icons/gate/icon-192.png new file mode 100644 index 0000000..9039264 Binary files /dev/null and b/brand/icons/gate/icon-192.png differ diff --git a/brand/icons/gate/icon-32.png b/brand/icons/gate/icon-32.png new file mode 100644 index 0000000..4add43f Binary files /dev/null and b/brand/icons/gate/icon-32.png differ diff --git a/brand/icons/gate/icon-48.png b/brand/icons/gate/icon-48.png new file mode 100644 index 0000000..1e10957 Binary files /dev/null and b/brand/icons/gate/icon-48.png differ diff --git a/brand/icons/gate/icon-512.png b/brand/icons/gate/icon-512.png new file mode 100644 index 0000000..7c41ef9 Binary files /dev/null and b/brand/icons/gate/icon-512.png differ diff --git a/brand/icons/gate/icon.svg b/brand/icons/gate/icon.svg new file mode 100644 index 0000000..e0249d5 --- /dev/null +++ b/brand/icons/gate/icon.svg @@ -0,0 +1,6 @@ + + + + + G + diff --git a/brand/icons/icon-16.png b/brand/icons/icon-16.png new file mode 100644 index 0000000..b3a4ee1 Binary files /dev/null and b/brand/icons/icon-16.png differ diff --git a/brand/icons/icon-180.png b/brand/icons/icon-180.png new file mode 100644 index 0000000..b6b498f Binary files /dev/null and b/brand/icons/icon-180.png differ diff --git a/brand/icons/icon-192.png b/brand/icons/icon-192.png new file mode 100644 index 0000000..92351e1 Binary files /dev/null and b/brand/icons/icon-192.png differ diff --git a/brand/icons/icon-32.png b/brand/icons/icon-32.png new file mode 100644 index 0000000..dc2186f Binary files /dev/null and b/brand/icons/icon-32.png differ diff --git a/brand/icons/icon-48.png b/brand/icons/icon-48.png new file mode 100644 index 0000000..219285a Binary files /dev/null and b/brand/icons/icon-48.png differ diff --git a/brand/icons/icon-512.png b/brand/icons/icon-512.png new file mode 100644 index 0000000..a46fe93 Binary files /dev/null and b/brand/icons/icon-512.png differ diff --git a/brand/icons/icon.svg b/brand/icons/icon.svg new file mode 100644 index 0000000..2277788 --- /dev/null +++ b/brand/icons/icon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/brand/icons/okx/apple-touch-icon.png b/brand/icons/okx/apple-touch-icon.png new file mode 100644 index 0000000..82d1e82 Binary files /dev/null and b/brand/icons/okx/apple-touch-icon.png differ diff --git a/brand/icons/okx/favicon.ico b/brand/icons/okx/favicon.ico new file mode 100644 index 0000000..2dbb0dd Binary files /dev/null and b/brand/icons/okx/favicon.ico differ diff --git a/brand/icons/okx/icon-16.png b/brand/icons/okx/icon-16.png new file mode 100644 index 0000000..5ea0f54 Binary files /dev/null and b/brand/icons/okx/icon-16.png differ diff --git a/brand/icons/okx/icon-192.png b/brand/icons/okx/icon-192.png new file mode 100644 index 0000000..55ed1de Binary files /dev/null and b/brand/icons/okx/icon-192.png differ diff --git a/brand/icons/okx/icon-32.png b/brand/icons/okx/icon-32.png new file mode 100644 index 0000000..ea8a9a2 Binary files /dev/null and b/brand/icons/okx/icon-32.png differ diff --git a/brand/icons/okx/icon-48.png b/brand/icons/okx/icon-48.png new file mode 100644 index 0000000..16a80dc Binary files /dev/null and b/brand/icons/okx/icon-48.png differ diff --git a/brand/icons/okx/icon-512.png b/brand/icons/okx/icon-512.png new file mode 100644 index 0000000..e526b77 Binary files /dev/null and b/brand/icons/okx/icon-512.png differ diff --git a/brand/icons/okx/icon.svg b/brand/icons/okx/icon.svg new file mode 100644 index 0000000..b7eaa46 --- /dev/null +++ b/brand/icons/okx/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/brand/manifest.binance.webmanifest b/brand/manifest.binance.webmanifest new file mode 100644 index 0000000..eddc20d --- /dev/null +++ b/brand/manifest.binance.webmanifest @@ -0,0 +1,23 @@ +{ + "name": "Binance 交易系统", + "short_name": "Binance 交易系统", + "description": "Binance 永续交易监控与复盘", + "start_url": "/", + "display": "standalone", + "background_color": "#0b0d14", + "theme_color": "#F0B90B", + "icons": [ + { + "src": "__ICON_PREFIX__/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "__ICON_PREFIX__/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/brand/manifest.exchange.webmanifest b/brand/manifest.exchange.webmanifest new file mode 100644 index 0000000..7464c0f --- /dev/null +++ b/brand/manifest.exchange.webmanifest @@ -0,0 +1,23 @@ +{ + "name": "交易系统", + "short_name": "交易系统", + "description": "加密货币永续交易监控与复盘(请使用各所独立 manifest)", + "start_url": "/", + "display": "standalone", + "background_color": "#0b0d14", + "theme_color": "#0b0d14", + "icons": [ + { + "src": "__ICON_PREFIX__/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "__ICON_PREFIX__/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/brand/manifest.gate.webmanifest b/brand/manifest.gate.webmanifest new file mode 100644 index 0000000..6dbd346 --- /dev/null +++ b/brand/manifest.gate.webmanifest @@ -0,0 +1,23 @@ +{ + "name": "Gate 交易系统", + "short_name": "Gate 交易系统", + "description": "Gate 永续交易监控与复盘", + "start_url": "/", + "display": "standalone", + "background_color": "#0b0d14", + "theme_color": "#17E6A1", + "icons": [ + { + "src": "__ICON_PREFIX__/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "__ICON_PREFIX__/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/brand/manifest.okx.webmanifest b/brand/manifest.okx.webmanifest new file mode 100644 index 0000000..bad9fb4 --- /dev/null +++ b/brand/manifest.okx.webmanifest @@ -0,0 +1,23 @@ +{ + "name": "OKX 交易系统", + "short_name": "OKX 交易系统", + "description": "OKX 永续交易监控与复盘", + "start_url": "/", + "display": "standalone", + "background_color": "#0b0d14", + "theme_color": "#FFFFFF", + "icons": [ + { + "src": "__ICON_PREFIX__/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "__ICON_PREFIX__/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/brand/manifest.webmanifest b/brand/manifest.webmanifest new file mode 100644 index 0000000..f63dbd9 --- /dev/null +++ b/brand/manifest.webmanifest @@ -0,0 +1,23 @@ +{ + "name": "复盘系统中控", + "short_name": "中控", + "description": "三所交易监控与行情中控", + "start_url": "/monitor", + "display": "standalone", + "background_color": "#0b0e18", + "theme_color": "#0b0e18", + "icons": [ + { + "src": "__ICON_PREFIX__/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "__ICON_PREFIX__/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/crypto_monitor_binance/.env.example b/crypto_monitor_binance/.env.example new file mode 100644 index 0000000..46d99b9 --- /dev/null +++ b/crypto_monitor_binance/.env.example @@ -0,0 +1,233 @@ +# ============================================================================= +# 环境配置模板(可提交 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=admin +# 登录密码(请改成你自己的强密码) +APP_PASSWORD=admin123 +# 是否关闭登录校验(局域网可设 true;公网务必 false) +APP_AUTH_DISABLED=true +# --- 多账户交易中控 manual_trading_hub --- +# 中控请求本实例 /api/hub/* 时携带请求头 X-Hub-Token,须与中控启动环境变量 HUB_BRIDGE_TOKEN 一致 +# 未设置且 APP_AUTH_DISABLED=false 时,仅网页登录后可访问;本机联调可保持 APP_AUTH_DISABLED=true +# HUB_BRIDGE_TOKEN=your-long-random-token +# Flask 会话密钥(必须替换为长随机字符串) +FLASK_SECRET_KEY=CHANGE_TO_LONG_RANDOM_SECRET + +# 企业微信机器人 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 +# 页顶「资金账户」默认仅 Binance Funding 钱包;若 USDT 主要在现货,可改为 true 合并 Spot +# BINANCE_FUNDING_INCLUDE_SPOT=false +# 计仓:risk=以损定仓(默认);full_margin=合约可用×FULL_MARGIN_BUFFER_RATIO 全仓杠杆(须无仓后重启) +POSITION_SIZING_MODE=risk +# 方向限制(默认 false=双向均可;true 时按 TRADE_DIRECTION 限制,修改后须重启) +# TRADE_DIRECTION=long_only | short_only | both(或 多/空/双向) +TRADE_DIRECTION_RESTRICT_ENABLED=false +TRADE_DIRECTION=both +# 币种白名单(默认 false=全币种可手输;true 时关键位/下单/策略仅下拉选择) +TRADE_SYMBOL_RESTRICT_ENABLED=false +TRADE_SYMBOL_WHITELIST=BTC,ETH +# 每天起始基数(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 + +# ============================================================================= +# 关键位程序自动下单(与 POSITION_SIZING_MODE 联动,修改后须重启 PM2) +# ============================================================================= +# 默认 false = 关闭所有关键位程序自动单(箱体/收敛/斐波/假突破/触价) +# +# POSITION_SIZING_MODE=risk(以损定仓) +# false → 不执行任何关键位自动单;支撑/阻力提醒,人工下单,顺势加仓不受影响 +# true → 允许关键位全套自动(含触价) +# +# POSITION_SIZING_MODE=full_margin(全仓杠杆,须无仓切换) +# false → 不执行触价自动单 +# true → 仅回调/突破触价可程序自动开仓;箱体/斐波等仍禁止 +# +# 顺势加仓,趋势回调不受本开关控制;全仓模式下策略自动仍禁止. +KEY_AUTO_ORDER_ENABLED=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收盘越过关键位(占该侧价格%)的下限;无上限(过猛由计划RR过滤) +KEY_BREAKOUT_AMP_MIN_PCT=0.03 +# 已不参与门控,可保留配置项兼容旧环境 +KEY_BREAKOUT_AMP_MAX_PCT=0.5 +# 【阻力/支撑】突破后微信提醒次数与间隔(分钟) +KEY_ALERT_MAX_TIMES=3 +KEY_ALERT_INTERVAL_MINUTES=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 + +# ============================================================================= +# 交易执行 / 人工风控(页面「实盘下单」) +# ============================================================================= +# 【最大同时持仓】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 +# 【单日开仓 AI 提醒】本交易日开仓达到该次数时推送企业微信 AI 克制提醒(不拦单) +DAILY_OPEN_ALERT_THRESHOLD=5 +# 【单日开仓硬上限】本交易日开仓次数>=该值后禁止一切新开仓直至下一交易日(北京时间 TRADING_DAY_RESET_HOUR 切日);0=不启用 +DAILY_OPEN_HARD_LIMIT=0 + +# ============================================================================= +# 账户冷静期 / 日冻结风控(手动平仓,外部平仓,复盘情绪标签) +# 详见 docs/account-risk-cooldown.md +# ============================================================================= +RISK_CONTROL_ENABLED=true +RISK_COOLING_HOURS_MANUAL=4 +RISK_COOLING_HOURS_MANUAL_JOURNAL=1 +RISK_MANUAL_CLOSE_DAILY_LIMIT=2 +RISK_MOOD_ISSUES_DAILY_FREEZE=true + +# 资金与仓位刷新周期(秒) +BALANCE_REFRESH_SECONDS=60 +# 前端价格快照轮询(秒) +PRICE_REFRESH_SECONDS=5 +# 后台监控轮询周期(秒) +MONITOR_POLL_SECONDS=3 +# 重启后多少秒内不做「外部平仓」同步(避免 API 未就绪误判) +RECONCILE_STARTUP_GRACE_SEC=90 +# 连续多少次轮询确认交易所空仓后,才记为外部平仓(默认 3 次 ≈ 9 秒) +RECONCILE_FLAT_CONFIRM_POLLS=3 +# 使用可用资金时的缓冲比例(如0.98代表用98%) +FULL_MARGIN_BUFFER_RATIO=0.98 + +# ============================================================================= +# 自动划转(页顶「将 swap 补足到 XU」;与 DAILY_START_CAPITAL 独立,需一致时请设为相同值) +# ============================================================================= +AUTO_TRANSFER_ENABLED=false +# 交易账户(swap)目标余额 U:每日 8 点(北京)自动划入或划出至 funding;持仓中不划转 +AUTO_TRANSFER_AMOUNT=30 +AUTO_TRANSFER_FROM=funding +AUTO_TRANSFER_TO=swap +TRANSFER_CCY=USDT +# 北京时间该整点小时内尝试;账簿按 UTC 自然日去重 +AUTO_TRANSFER_BJ_HOUR=8 +# 强制清仓整点(北京时间,默认 0=凌晨00点) +FORCE_CLOSE_BJ_HOUR=0 +# 是否启用强制清仓(默认关闭,true 才会在整点执行) +FORCE_CLOSE_ENABLED=false + +# 推送与AI超时(秒) +WECHAT_TIMEOUT_SECONDS=10 +AI_TIMEOUT_SECONDS=120 + +# AI 提供方:openai(默认,OpenAI 兼容网关)| ollama(本机 Ollama) +AI_PROVIDER=openai +# OpenAI 兼容接口(示例:https://op.bz121.com/v1 ,账号见 gateway.json) +OPENAI_API_BASE=https://op.bz121.com/v1 +OPENAI_API_KEY=你的密钥 +OPENAI_MODEL=gemma4:e4b +# 本机 Ollama(AI_PROVIDER=ollama 时使用) +OLLAMA_API=http://127.0.0.1:11434/api/generate +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 / DAILY_OPEN_HARD_LIMIT;说明文档 docs/daily-open-limit.md +# 以损定仓(按交易账户资金的百分比) +# 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 +# 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..f217cd7 --- /dev/null +++ b/crypto_monitor_binance/README.md @@ -0,0 +1,83 @@ +# crypto_monitor_binance + +基于 **Flask** 的加密货币 **下单监控 / 关键位监控 / 交易复盘** 小系统,行情与实盘接口统一走 **Binance(USDT-M 永续)**,通过 **ccxt** 访问. + +## 功能概要 + +- **关键位监控**:价格与硬条件校验,企业微信推送(可选) +- **下单监控**:本地风控(含移动保本逻辑),触达止盈/止损后尝试市价平仓并记账 +- **策略交易**:顶栏 `/strategy`(趋势回调 + 顺势加仓),见仓库根 [策略交易说明.md](../策略交易说明.md) +- **AI 复盘**:OpenAI 兼容网关(默认)或 Ollama,见 [AI复盘与模型配置说明.md](../AI复盘与模型配置说明.md) +- **实盘(可选)**:`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 +# 推荐在 /opt/crypto_monitor_user 执行仓库根目录 deploy/setup_env.sh +cd /opt/crypto_monitor_user/crypto_monitor_binance +source .venv/bin/activate +pip install -r ../requirements.txt +``` + +页面上的 **「当日资金(交易账户)」** 与 **「可开仓」可用 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` | 推送文案中的账户备注 | + +其余变量(登录,企业微信,风控参数,**`AI_PROVIDER` / `OPENAI_*` / `OLLAMA_*`**,数据库路径等)见 **`.env.example` 内注释** 或 `app.py` 顶部默认值. + +## 运行 + +生产环境使用 **PM2**(`ecosystem.config.cjs`).临时调试: + +```bash +cd /opt/crypto_monitor_user/crypto_monitor_binance +source .venv/bin/activate +python app.py +``` + +环境说明见 [docs/ubuntu-server.md](../docs/ubuntu-server.md). + +默认监听端口由 `.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..07dc57d --- /dev/null +++ b/crypto_monitor_binance/app.py @@ -0,0 +1,10060 @@ +from flask import Flask, render_template, request, redirect, url_for, flash, session, jsonify, Response, send_file +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 lib.paths import common_static_dir +from lib.ai.ai_client import ai_generate, ai_review, ai_short_advice +from lib.ai.ai_review_lib import ( + build_journal_ai_chart_path, + collect_images_for_ai_review, + journal_row_lines_for_ai, +) +from lib.common.form_submit_lib import check_duplicate_submit, submit_scope_add_key, submit_scope_add_order +from lib.key_monitor.fib_key_monitor_lib import ( + FIB_KEY_MONITOR_TYPES, + backfill_missing_key_signal_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 lib.key_monitor.false_breakout_key_monitor_lib import ( + FALSE_BREAKOUT_MONITOR_TYPE, + FALSE_BREAKOUT_VALIDITY_HOURS, + calc_false_breakout_plan, + expires_at_text, + false_breakout_gate_preview, + is_false_breakout_expired, + is_false_breakout_key_monitor_type, + is_limit_key_monitor_type, + key_price_from_row, + normalize_false_breakout_symbol, + storage_bounds_from_key_price, +) +from lib.strategy.strategy_trade_labels import ( + JOURNAL_ORDER_TYPE_OPTIONS, + apply_order_monitor_source_labels, + entry_reason_for_monitor_type, + handoff_trade_miss_reason, + normalize_journal_order_type, + order_monitor_source_type, + trade_record_monitor_type as resolve_trade_record_monitor_type, + trend_plan_id_from_monitor_row, +) +from lib.instance.journal_form_lib import normalize_journal_direction, normalize_journal_entry_reason +from lib.instance.journal_images_lib import ( + collect_journal_slot_images, + enrich_journal_api_item, + images_json_dumps, + journal_image_paths, + normalize_journal_draft_id, + primary_journal_image, +) +from lib.instance.journal_upload_api_lib import handle_journal_upload_slot +from lib.instance.journal_chart_lib import ( + JOURNAL_CHART_DEFAULT_LIMIT, + JOURNAL_CHART_DEFAULT_TF1, + JOURNAL_CHART_DEFAULT_TF2, + JOURNAL_CHART_TF_CHOICES, + compose_chart_panels, + marker_points_for_timeframe, + parse_journal_chart_anchor, + parse_journal_chart_limit, + parse_journal_chart_timeframes, + JOURNAL_CHART_DEFAULT_ANCHOR, + price_levels_from_marker_payload, + render_candles_subplot, + trade_review_fetch_window, + trim_rows_for_trade_review, +) +from lib.key_monitor.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 lib.trade.time_close_lib import ( + TIME_CLOSE_RESULT, + apply_time_close_to_payload, + ensure_time_close_schema, + parse_time_close_enabled_form, + parse_time_close_hours_form, + should_trigger_time_close, + time_close_insert_values, + time_close_label, + time_close_settings_from_row, +) +from lib.trade.force_close_lib import ( + apply_force_close_display_result, + apply_force_close_to_payload, + enrich_orders_force_close, + force_close_template_context, +) +from lib.trade.manual_sltp_lib import ( + normalize_open_sltp_mode, + resolve_entrust_sltp_prices, + resolve_open_sltp_prices, +) +from lib.key_monitor.key_monitor_schema_lib import ensure_key_monitor_schema +from lib.key_monitor.trigger_entry_key_monitor_lib import ( + BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE, + CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE, + TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED, + TRIGGER_ENTRY_CLOSE_EXPIRED, + TRIGGER_ENTRY_CLOSE_FILLED, + TRIGGER_ENTRY_CLOSE_SL_INVALIDATE, + TRIGGER_ENTRY_CLOSE_TP_INVALIDATE, + TRIGGER_ENTRY_MONITOR_TYPE, + TRIGGER_ENTRY_MONITOR_TYPES, + TRIGGER_ENTRY_VALIDITY_HOURS, + check_trigger_entry_intent_limit, + count_pending_trigger_entries, + acquire_trigger_entry_exec_lock, + is_trigger_entry_in_flight_row, + release_trigger_entry_exec_lock, + is_breakout_trigger_entry_key_monitor_type, + is_trigger_entry_expired, + is_trigger_entry_key_monitor_type, + trigger_entry_expires_at_text, + trigger_entry_gate_preview, + trigger_entry_invalidate, + trigger_should_fire, + validate_trigger_entry_geometry, + validate_trigger_entry_rr, +) +from lib.trade.position_sizing_lib import ( + OPEN_SOURCE_KEY_AUTO, + OPEN_SOURCE_KEY_TRIGGER, + OPEN_SOURCE_MANUAL, + OPEN_SOURCE_ROLL, + OPEN_SOURCE_TREND, + assert_open_source_allowed, + compute_full_margin_sizing, + format_risk_display_text, + full_margin_requires_flat_position, + is_full_margin_mode, + leverage_for_full_margin, + load_position_sizing_mode, + mode_label_zh, + risk_percent_for_storage, +) +from lib.trade.trade_policy_lib import load_trade_policy +from lib.trade.entry_model_lib import ( + build_intraday_entry_reason_options, + build_journal_entry_reason_options, + enrich_entry_model_display, + hub_meta_entry_context, + migrate_entry_model_columns, + order_entry_template_context, + open_position_button_label, + parse_manual_order_style_fields, + resolve_effective_trade_entry_reason, + format_entry_type_display, + resolve_trade_record_entry_reason, + trend_manual_entry_reason_count, +) +from lib.trade.trade_policy_app_lib import ( + check_direction_policy, + check_open_policy, + check_symbol_policy, + default_symbol_for_policy, + trade_policy_template_context, +) +from lib.key_monitor.key_auto_order_lib import ( + check_monitor_type_add_allowed, + effective_entry_reason_options, + effective_stats_segment_defs, + load_key_auto_order_enabled, +) +from lib.key_monitor.key_monitor_full_margin_lib import ( + monitor_type_disallowed_in_full_margin, + purge_disallowed_key_monitors, +) +from lib.common.auto_transfer_daily_lib import run_auto_transfer_once_per_day +from lib.key_monitor.key_monitor_lib import ( + KEY_DIRECTION_WATCH, + KEY_MONITOR_ALERT_ONLY_TYPES, + KEY_MONITOR_AUTO_TYPES, + KEY_MONITOR_RS_TYPE, + KEY_MONITOR_RS_TYPES, + auto_amp_ok, + auto_confirm_ok, + box_breakout_invalidate_by_mark, + box_breakout_invalidate_edge_label, + claim_rs_level_notify, + detect_rs_box_break, + format_auto_amp_line, + format_auto_confirm_line, + key_monitor_rule_template_context, + notify_interval_elapsed, + resolve_rs_break_for_alert, + rs_break_from_direction, + run_rs_level_alert_tick, +) +from lib.trade.order_monitor_display_lib import ( + apply_order_price_display_fields, + enrich_order_display_fields, + order_monitor_tpsl_needs_sync, + stale_breakeven_armed, +) +from lib.common.wechat_notify_lib import build_wechat_rs_level_message, send_wechat_webhook +from lib.hub.hub_auth import request_allowed as hub_request_allowed +from lib.hub.hub_volume_rank_lib import resolve_daily_volume_rank +from lib.common.history_window_lib import ( + PRESET_ALL, + PRESET_CUSTOM, + PRESET_DEFAULT, + PRESET_UTC_LAST24H, + PRESET_UTC_LAST3M, + PRESET_UTC_LAST6M, + PRESET_UTC_LAST7D, + PRESET_UTC_THIS_MONTH, + PRESET_UTC_TODAY, + list_window_redirect_query, + normalize_bj_datetime_storage, + resolve_list_window, + resolve_window, + sql_list_time_field, + utc_window_to_bj_sql_strings, + utc_window_to_utc_sql_strings, +) +from lib.trade.trade_result_lib import ( + count_winning_trades, + filter_trade_records_excluding_miss, + normalize_result_with_pnl, +) +from lib.trade.trade_exchange_stats_lib import ( + attach_exchange_stats_to_trade, + filter_position_lifecycle_fills, + sum_binance_commission_income, + trade_ids_from_fills, +) + +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") +from lib.instance.instance_embed_lib import attach_embed_templates + +attach_embed_templates(app, _REPO_ROOT) + +# ====================== 登录配置 ====================== +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" +_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 / KEY_MONITOR_ALERT_ONLY_TYPES:见 key_monitor_lib +# 与币安 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", +) +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")) +# 计仓模式:risk=以损定仓(默认);full_margin=合约可用保证金×比例全仓杠杆(仅 env 切换,须无仓) +POSITION_SIZING_MODE = load_position_sizing_mode() +KEY_AUTO_ORDER_ENABLED = load_key_auto_order_enabled() +TRADE_POLICY = load_trade_policy() +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")) +RECONCILE_STARTUP_GRACE_SEC = int(os.getenv("RECONCILE_STARTUP_GRACE_SEC", "90")) +RECONCILE_FLAT_CONFIRM_POLLS = max(1, int(os.getenv("RECONCILE_FLAT_CONFIRM_POLLS", "3"))) +_APP_STARTED_AT = time.time() +_RECONCILE_FLAT_STREAK = {} +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")) +from lib.trade.daily_open_limit_lib import ( + build_daily_open_alert_prompt, + can_trade_new_open, + check_daily_open_hard_limit, + count_opens_for_trading_day, + format_daily_open_counter_line, + format_daily_open_summary_short, + load_daily_open_limits_from_env, + should_send_daily_open_alert, +) + +DAILY_OPEN_ALERT_THRESHOLD, DAILY_OPEN_HARD_LIMIT = load_daily_open_limits_from_env() +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() + +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() +# 页顶「资金账户」是否合并现货 USDT(部分用户把现货当资金仓;默认仅 Funding) +BINANCE_FUNDING_INCLUDE_SPOT = os.getenv("BINANCE_FUNDING_INCLUDE_SPOT", "false").lower() in ( + "1", + "true", + "yes", + "on", +) + +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, + "version": 0, + "ranks": {}, + "total": 0, +} + +# 企业微信推送 +def send_wechat_msg(content): + send_wechat_webhook( + WECHAT_WEBHOOK, content, timeout=WECHAT_TIMEOUT_SECONDS + ) + + +_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 _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, + layout="grid", +): + if not ORDER_CHART_ENABLED: + return None + if not Image: + return None + requested = list(timeframes or ORDER_CHART_TFS) + limit = limit or ORDER_CHART_LIMIT + if layout == "vertical": + timeframes = requested[:2] if requested else [JOURNAL_CHART_DEFAULT_TF1, JOURNAL_CHART_DEFAULT_TF2] + else: + 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} + price_levels = price_levels_from_marker_payload(marker_payload) + for tf in timeframes: + rows = [] + try: + if layout == "vertical" and marker_payload: + win = trade_review_fetch_window( + marker_payload.get("entry_ts_ms"), + marker_payload.get("exit_ts_ms"), + tf, + limit, + anchor=marker_payload.get("chart_anchor"), + now_ms=marker_payload.get("now_ts_ms"), + ) + if win: + ohlcv = exchange.fetch_ohlcv( + exchange_symbol, + timeframe=tf, + since=max(0, int(win["since_ms"])), + limit=int(win["fetch_limit"]), + ) + rows = trim_rows_for_trade_review(_ohlcv_to_rows(ohlcv), win) + if not rows: + 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) + rows = _ohlcv_to_rows(ohlcv)[-limit:] + except Exception: + rows = [] + title = f"{title_prefix} | {tf} x{len(rows)}" + 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() + points = ( + marker_points_for_timeframe(rows, marker_payload) + if marker_payload and tf_key in marker_tfs + else [] + ) + panels.append( + render_candles_subplot( + rows, + title, + width=cell_w, + height=cell_h, + bg_rgb=(255, 255, 255), + marker_points=points, + price_levels=price_levels, + ) + ) + + if not panels: + return None + + out = compose_chart_panels(panels, layout=layout, cell_w=cell_w, cell_h=cell_h, gap=10) + if out is None: + return None + + 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 = ( + "", + "止盈", + "保本止盈", + "移动止盈", + TIME_CLOSE_RESULT, + "强制清仓", + "手动平仓", + "止损", + "其他", +) + +# 趋势户:复盘开仓类型仅 entry model;策略/风格项已拆至下单类型 +ENTRY_REASON_OPTIONS = build_journal_entry_reason_options() + +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"}), + ("key_false_breakout", "关键位假突破", {"segment": "key_false_breakout"}), + ("key_trigger", "关键位触价开仓", {"segment": "key_trigger"}), +) +def normalize_entry_reason(raw, custom_text=None): + del custom_text + return normalize_journal_entry_reason(raw, ENTRY_REASON_OPTIONS, allow_legacy=True) + + +def entry_reason_valid_for_storage(s): + t = str(s or "").strip() + if not t: + return True + return bool(normalize_entry_reason(t)) + + +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 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( + """CREATE TABLE IF NOT EXISTS app_runtime_settings + (key TEXT PRIMARY KEY, value TEXT, + updated_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 journal_entries ADD COLUMN images_json TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN order_type TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN direction 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", + "ALTER TABLE key_monitors ADD COLUMN last_rs_bar_ts INTEGER", + "ALTER TABLE key_monitors ADD COLUMN session_date TEXT", + ): + try: + c.execute(ddl) + except Exception: + pass + ensure_time_close_schema(c) + ensure_key_monitor_schema(c) + + 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"), + ("exchange_turnover_usdt", "ALTER TABLE trade_records ADD COLUMN exchange_turnover_usdt REAL"), + ("exchange_commission_usdt", "ALTER TABLE trade_records ADD COLUMN exchange_commission_usdt REAL"), + ): + 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)""" + ) + + from lib.strategy.strategy_db import init_strategy_tables + + init_strategy_tables(conn) + from lib.trade.account_risk_lib import ensure_account_risk_schema + + ensure_account_risk_schema(conn) + migrate_entry_model_columns(conn) + backfill_missing_key_signal_types(conn, monitor_type=ORDER_MONITOR_TYPE_KEY_AUTO) + conn.commit() + conn.close() + +init_db() + + +def _purge_key_monitors_if_full_margin(): + if not is_full_margin_mode(POSITION_SIZING_MODE): + return + conn = get_db() + try: + purge_disallowed_key_monitors( + conn, + sizing_mode=POSITION_SIZING_MODE, + select_rows=lambda c: c.execute("SELECT * FROM key_monitors").fetchall(), + cancel_fib_limit=_cancel_fib_monitor_limit, + delete_monitor=lambda c, kid: c.execute("DELETE FROM key_monitors WHERE id=?", (kid,)), + send_wechat=send_wechat_msg, + ) + conn.commit() + except Exception as e: + print(f"[full_margin] purge key monitors: {e}", flush=True) + finally: + conn.close() + + +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def hub_account_risk_status(conn): + from lib.trade.account_risk_lib import ( + apply_position_limit_risk, + compute_account_risk_status, + enrich_risk_status_countdown, + ensure_account_risk_schema, + ) + + ensure_account_risk_schema(conn) + now = app_now() + st = compute_account_risk_status( + conn, + trading_day=get_trading_day(), + now=now, + fmt_local_ms=ms_to_app_local_str, + ) + st = enrich_risk_status_countdown(st, now=now, daily_reset_hour=TRADING_DAY_RESET_HOUR) + from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors + + return apply_position_limit_risk( + st, + count_position_limit_active_monitors(conn), + max_active_positions=MAX_ACTIVE_POSITIONS, + ) + + +def hub_user_initiated_close( + conn, + *, + source, + count=1, + trade_record_id=None, + closed_at_ms=None, +): + from lib.trade.account_risk_lib import CLOSE_SOURCE_USER_HUB, on_user_initiated_close + + src = (source or "").strip() or CLOSE_SOURCE_USER_HUB + on_user_initiated_close( + conn, + source=src, + trade_record_id=trade_record_id, + closed_at_ms=closed_at_ms, + trading_day=get_trading_day(), + now=app_now(), + count=count, + ) + + +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 = ( + "止盈", + "止损", + "保本止盈", + "移动止盈", + "手动平仓", + "强制清仓", + "外部平仓", + TIME_CLOSE_RESULT, +) + +REVIEW_RESULT_OPTIONS = ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓", TIME_CLOSE_RESULT) + + +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_DEFAULT) + + +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" + if segment_key == "key_false_breakout": + return kst == FALSE_BREAKOUT_MONITOR_TYPE + if segment_key == "key_trigger": + return kst in TRIGGER_ENTRY_MONITOR_TYPES + 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", + "key_false_breakout": FALSE_BREAKOUT_MONITOR_TYPE, + "key_trigger": None, # 见 _count_opens_for_segment 多类型 + } + if segment_key == "key_trigger": + placeholders = ",".join("?" * len(TRIGGER_ENTRY_MONITOR_TYPES)) + return conn.execute( + f"SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ? " + f"AND key_signal_type IN ({placeholders})", + (start_td, end_td, *TRIGGER_ENTRY_MONITOR_TYPES), + ).fetchone()[0] + 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 = [] + seg_defs = effective_stats_segment_defs( + STATS_SEGMENT_DEFS, POSITION_SIZING_MODE, KEY_AUTO_ORDER_ENABLED + ) + for seg_key, seg_title, _meta in seg_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 时被误判空仓).""" + from lib.hub.hub_position_metrics import normalize_contracts_qty + + 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 normalize_contracts_qty(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 validate_trade_policy_open(symbol, direction): + return check_open_policy( + TRADE_POLICY, symbol, direction, normalize_symbol_input + ) + + +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) + from lib.trade.order_monitor_display_lib import snapshot_stop_loss + + open_stop = snapshot_stop_loss(item.get("initial_stop_loss"), item.get("stop_loss")) + item["display_open_stop_loss"] = open_stop + 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", 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")) + try: + _er_keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + _er_keys = [] + reviewed_er = row["reviewed_entry_reason"] if "reviewed_entry_reason" in _er_keys else None + item["effective_entry_reason"] = resolve_effective_trade_entry_reason( + reviewed_entry_reason=reviewed_er, + entry_reason=item.get("entry_reason"), + entry_model=item.get("entry_model"), + key_signal_type=(item.get("key_signal_type") or "").strip() or None, + monitor_type=item.get("monitor_type"), + trade_style=item.get("trade_style"), + entry_reason_from_key_signal=entry_reason_from_key_signal, + entry_reason_for_monitor_type=entry_reason_for_monitor_type, + ) + 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" + item["effective_result"] = normalize_result_with_pnl( + item.get("effective_result"), + item.get("effective_pnl_amount"), + ) + item["effective_result"] = apply_force_close_display_result( + item.get("effective_result"), + item.get("effective_closed_at"), + enabled=FORCE_CLOSE_ENABLED, + bj_hour=FORCE_CLOSE_BJ_HOUR, + ) + 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;扣双边 taker 费.""" + 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 + gross = notional * pnl_ratio + try: + from lib.trade.trade_fee_lib import net_pnl_after_fee + + net = net_pnl_after_fee(gross, trigger, exit_p, open_notional=notional) + return round(float(net), FUNDS_DECIMALS) if net is not None else round(gross, FUNDS_DECIMALS) + except Exception: + return round(gross, 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): + """按减仓成交数量×价差汇总净盈亏(扣固定双边 taker 费;不含资金费).""" + 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 + notional_close = 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 + notional_close += amount * price + if direction == "short": + pnl += amount * (entry - price) + else: + pnl += amount * (price - entry) + if qty <= 0: + return None + exit_px = (notional_close / qty) if qty > 0 else entry + try: + from lib.trade.trade_fee_lib import net_pnl_after_fee + + # amount 已乘 contractSize,此处面值用 1 + net = net_pnl_after_fee(pnl, entry, exit_px, qty, 1.0) + return round(float(net), FUNDS_DECIMALS) if net is not None else round(pnl, FUNDS_DECIMALS) + except Exception: + 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: + # income 已含真实手续费,直接用. + 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: + # fill.realizedPnl 通常不含 commission,补固定双边费. + try: + from lib.trade.trade_fee_lib import net_pnl_after_fee + + entry = float(entry_price or 0) + exit_p = float(exit_price or entry or 0) + if entry > 0 and exit_p > 0: + open_n = get_plan_notional_usdt(row) + if open_n is None: + margin = row["margin_capital"] if hasattr(row, "keys") else row.get("margin_capital") + lev = row["leverage"] if hasattr(row, "keys") else row.get("leverage") + try: + open_n = float(margin or 0) * float(lev or 1) + except (TypeError, ValueError): + open_n = None + adj = net_pnl_after_fee(trade_pnl, entry, exit_p, open_notional=open_n) + if adj is not None: + trade_pnl = adj + except Exception: + pass + 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, 2) + except Exception: + return None + + +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, + entry_model=None, + trend_plan_id=None, + exchange_symbol=None, + attach_exchange_stats=True, +): + 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) + from lib.trade.order_monitor_display_lib import snapshot_stop_loss + + snap_sl = snapshot_stop_loss(initial_stop_loss, stop_loss) + er = resolve_trade_record_entry_reason( + entry_reason=entry_reason, + entry_model=entry_model, + key_signal_type=kst, + monitor_type=monitor_type, + trade_style=trade_style, + entry_reason_from_key_signal=entry_reason_from_key_signal, + entry_reason_for_monitor_type=entry_reason_for_monitor_type, + ) + 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,trend_plan_id) 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, + trend_plan_id, + ) + ) + tid = int(cur.lastrowid or 0) + if attach_exchange_stats and tid: + ex_sym = (exchange_symbol or "").strip() or normalize_exchange_symbol(symbol) + _attach_binance_trade_exchange_stats( + conn, + tid, + exchange_symbol=ex_sym, + direction=direction, + opened_at_str=open_ts, + closed_at_str=close_ts, + opened_at_ms=open_ts_ms, + closed_at_ms=close_ts_ms, + ) + return tid + + +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 + enrich_order_display_fields(item, calc_rr_ratio) + enrich_entry_model_display(item) + 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 apply_order_monitor_source_labels(item, default_manual=ORDER_MONITOR_TYPE_MANUAL) + + +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): + return order_monitor_source_type(row, default_manual=ORDER_MONITOR_TYPE_MANUAL) + + +def trade_record_monitor_type(conn, row): + return resolve_trade_record_monitor_type( + conn, row, default_manual=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) or is_false_breakout_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 _float_balance_field(val): + if val is None or val == "": + return None + try: + return float(val) + except (TypeError, ValueError): + return None + + +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 {} + used_map = balance.get("used", {}) 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 not None: + fv = _float_balance_field(total) + if fv is not None: + return fv + free = usdt_info.get("free") + if free is None: + free = free_map.get("USDT") + used = usdt_info.get("used") + if used is None: + used = used_map.get("USDT") + if used is None: + used = usdt_info.get("locked") + free_f = _float_balance_field(free) + used_f = _float_balance_field(used) or 0.0 + if free_f is not None: + return free_f + used_f + return None + + +def _parse_binance_funding_asset_rows(rows): + """解析 /sapi/v1/asset/get-funding-asset:USDT 总额 = free + freeze + locked + withdrawing.""" + if isinstance(rows, dict): + rows = [rows] + if not isinstance(rows, list): + return None + for row in rows: + if not isinstance(row, dict): + continue + if str(row.get("asset") or "").upper() != "USDT": + continue + parts = [ + _float_balance_field(row.get("free")), + _float_balance_field(row.get("freeze")), + _float_balance_field(row.get("locked")), + _float_balance_field(row.get("withdrawing")), + ] + nums = [p for p in parts if p is not None] + if nums: + return sum(nums) + return None + + +def _parse_binance_wallet_balance_usdt(rows, wallet_names): + """解析 /sapi/v1/asset/wallet/balance(quoteAsset=USDT):按 walletName 取折合 USDT 余额.""" + if isinstance(rows, dict): + rows = [rows] + if not isinstance(rows, list): + return None + want = {str(n).strip().lower() for n in (wallet_names or []) if str(n).strip()} + for row in rows: + if not isinstance(row, dict): + continue + name = str(row.get("walletName") or row.get("name") or "").strip().lower() + if name not in want: + continue + if row.get("activate") is False: + continue + bal = _float_balance_field(row.get("balance")) + if bal is not None: + return bal + return None + + +def _fetch_binance_funding_usdt_from_wallet_overview(): + """与币安 App 资产页「资金/Funding」钱包 USDT 估值一致(wallet/balance).""" + try: + ensure_markets_loaded() + raw = exchange.sapiGetAssetWalletBalance({"quoteAsset": TRANSFER_CCY}) + val = _parse_binance_wallet_balance_usdt(raw, ("Funding",)) + if val is not None: + return float(val) + except Exception: + pass + return None + + +def _fetch_binance_spot_usdt_total(): + """现货账户 USDT 总额(free+locked).""" + try: + ensure_markets_loaded() + raw = exchange.sapiGetAssetWalletBalance({"quoteAsset": TRANSFER_CCY}) + val = _parse_binance_wallet_balance_usdt(raw, ("Spot",)) + if val is not None: + return float(val) + except Exception: + pass + try: + ensure_markets_loaded() + bal = exchange.fetch_balance(params={"type": "spot"}) + val = _extract_usdt_total(bal) + if val is not None: + return float(val) + except Exception: + pass + 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 总额,与 App「资金账户」一致.""" + candidates = [] + wallet_val = _fetch_binance_funding_usdt_from_wallet_overview() + if wallet_val is not None: + candidates.append(wallet_val) + try: + ensure_markets_loaded() + raw = exchange.sapiPostAssetGetFundingAsset({"asset": TRANSFER_CCY}) + val = _parse_binance_funding_asset_rows(raw) + if val is not None: + candidates.append(float(val)) + except Exception: + pass + if not candidates: + try: + ensure_markets_loaded() + raw = exchange.sapiPostAssetGetFundingAsset({}) + val = _parse_binance_funding_asset_rows(raw) + if val is not None: + candidates.append(float(val)) + except Exception: + pass + try: + ensure_markets_loaded() + bal = exchange.fetch_balance(params={"type": "funding"}) + val = _extract_usdt_total(bal) + if val is not None: + candidates.append(float(val)) + except Exception: + pass + if not candidates: + base = None + else: + base = max(candidates) + if BINANCE_FUNDING_INCLUDE_SPOT: + spot_val = _fetch_binance_spot_usdt_total() + if spot_val is not None: + base = (base or 0.0) + float(spot_val) + return base + + +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(): + run_auto_transfer_once_per_day( + enabled=AUTO_TRANSFER_ENABLED, + bj_hour=AUTO_TRANSFER_BJ_HOUR, + target_amount=AUTO_TRANSFER_AMOUNT, + from_account=AUTO_TRANSFER_FROM, + to_account=AUTO_TRANSFER_TO, + funds_decimals=FUNDS_DECIMALS, + get_db=get_db, + get_active_position_count=get_active_position_count, + get_account_usdt_total=get_account_usdt_total, + execute_transfer_usdt=execute_transfer_usdt, + send_wechat_msg=send_wechat_msg, + utc_now_dt=utc_now_dt, + app_tz=APP_TZ, + utc_calendar_date_str=utc_calendar_date_str, + app_now_str=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() + from lib.trade.account_risk_lib import account_risk_blocks_trading + + ok_risk, risk_reason = account_risk_blocks_trading( + conn, + trading_day=get_trading_day(now), + now=now, + fmt_local_ms=ms_to_app_local_str, + ) + if not ok_risk: + return False, risk_reason + if not trading_day_reset_allows_new_open(now): + return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓" + from lib.trade.account_risk_lib import position_limit_reached + + reached, active_count, mx = position_limit_reached(conn, max_active_positions=MAX_ACTIVE_POSITIONS) + if reached: + return False, f"已达最大持仓数({active_count}/{mx})" + ok_daily, daily_reason, _opens = check_daily_open_hard_limit( + conn, get_trading_day(now), DAILY_OPEN_HARD_LIMIT, TRADING_DAY_RESET_HOUR + ) + if not ok_daily: + return False, daily_reason + 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 _binance_place_stop_loss_only(exchange_symbol, direction, stop_loss): + """趋势回调:仅挂止损触发单,止盈由程序监控.""" + ensure_markets_loaded() + pos_amt = get_live_position_contracts(exchange_symbol, direction) + if pos_amt is None or float(pos_amt) <= 0: + raise RuntimeError("交易所当前无持仓,无法挂止损") + cancel_binance_futures_open_orders(exchange_symbol) + 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(pos_amt))) + sl_px = exchange.price_to_precision(exchange_symbol, float(stop_loss)) + common = dict(_binance_trigger_order_params()) + if BINANCE_POSITION_MODE == "hedge": + common["positionSide"] = "LONG" if direction == "long" else "SHORT" + exchange.create_order( + exchange_symbol, + "STOP_MARKET", + close_side, + amt, + None, + dict(common, stopPrice=sl_px), + ) + + +def calc_trend_manual_breakeven_stop(direction, entry_price, offset_pct=None): + try: + e = float(entry_price) + pct = float( + offset_pct + if offset_pct is not None + else float(os.getenv("TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT", "0.3")) + ) + 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 ensure_markets_loaded(force=False): + global MARKETS_LOADED + if force or not MARKETS_LOADED: + exchange.load_markets(reload=force) + MARKETS_LOADED = True + + +def _abort_market_open_after_tpsl_failure(exchange_symbol, direction, order, planned_amount): + from lib.trade.compensating_close_lib import run_compensating_close + + def _close(): + ensure_markets_loaded() + try: + cancel_binance_futures_open_orders(exchange_symbol) + except Exception: + pass + live = get_live_position_contracts(exchange_symbol, direction) + amt = live if live is not None and live > 0 else _filled_amount_for_tpsl(order, planned_amount) + if amt is None or float(amt) <= 0: + return + side = "sell" if direction == "long" else "buy" + try: + amount = float(exchange.amount_to_precision(exchange_symbol, float(amt))) + except Exception: + amount = float(amt) + last_err = None + for params in _binance_market_close_param_candidates(direction): + try: + exchange.create_order(exchange_symbol, "market", side, amount, None, params) + return + except Exception as e: + last_err = e + if _is_binance_close_param_retryable(str(e)): + continue + raise + if last_err: + raise last_err + + run_compensating_close(_close, log_prefix="binance_compensating_close") + + +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: + _abort_market_open_after_tpsl_failure(exchange_symbol, direction, order, amount) + raise + except Exception as e: + _abort_market_open_after_tpsl_failure(exchange_symbol, direction, order, amount) + 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): + return resolve_entrust_sltp_prices(direction, live_price, sltp_mode, data) + + +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", + "empty position", + ] + 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 _infer_position_direction_from_row(position_dict): + if not position_dict: + return "long" + info = position_dict.get("info") or {} + ps = str( + info.get("positionSide") + or position_dict.get("side") + or info.get("posSide") + or "" + ).strip().lower() + if ps in ("long", "short"): + return ps + for key in ("positionAmt", "pos", "size"): + v = info.get(key) + if v is None or v == "": + continue + try: + amt = float(v) + if amt > 0: + return "long" + if amt < 0: + return "short" + except (TypeError, ValueError): + continue + side = str(position_dict.get("side") or "").strip().lower() + if side in ("long", "short"): + return side + return "long" + + +def _monitor_symbol_from_ccxt_symbol(ccxt_symbol): + s = str(ccxt_symbol or "").strip() + if ":" in s: + return s.split(":")[0].upper() + return s.upper() + + +def _fetch_nonempty_live_position_rows(): + if not exchange_private_api_configured(): + return [] + ensure_markets_loaded() + try: + rows = exchange.fetch_positions() or [] + except Exception: + return [] + out = [] + for p in rows: + contracts = _position_row_effective_contracts(p) + if contracts <= 0: + continue + ex_sym = p.get("symbol") + if not ex_sym: + continue + direction = _infer_position_direction_from_row(p) + out.append( + { + "exchange_symbol": normalize_exchange_symbol(str(ex_sym)), + "monitor_symbol": _monitor_symbol_from_ccxt_symbol(ex_sym), + "direction": direction, + "contracts": contracts, + "position_row": p, + } + ) + return out + + +def _find_inactive_monitor_for_live(conn, exchange_symbol, monitor_symbol, direction): + direction = (direction or "long").strip().lower() + norm_ex = normalize_exchange_symbol(exchange_symbol or monitor_symbol) + rows = conn.execute( + """ + SELECT * FROM order_monitors + WHERE status IN ('stopped', 'error') AND direction=? + ORDER BY id DESC + LIMIT 20 + """, + (direction,), + ).fetchall() + for r in rows: + row_ex = normalize_exchange_symbol(r["exchange_symbol"] or r["symbol"]) + if row_ex == norm_ex: + return r + row_sym = str(r["symbol"] or "").strip().upper() + if row_sym and row_sym == str(monitor_symbol or "").strip().upper(): + return r + return None + + +def list_orphan_live_positions(conn): + """交易所有仓,但无对应 active 监控的持仓(可尝试恢复本地监控).""" + live_rows = _fetch_nonempty_live_position_rows() + if not live_rows: + return [] + active_keys = set() + for r in conn.execute( + "SELECT symbol, exchange_symbol, direction FROM order_monitors WHERE status='active'" + ): + ex = normalize_exchange_symbol(r["exchange_symbol"] or r["symbol"]) + active_keys.add((ex, (r["direction"] or "long").strip().lower())) + + from lib.hub.hub_position_metrics import parse_position_entry_price + + orphans = [] + for lp in live_rows: + key = (lp["exchange_symbol"], lp["direction"]) + if key in active_keys: + continue + mon = _find_inactive_monitor_for_live( + conn, lp["exchange_symbol"], lp["monitor_symbol"], lp["direction"] + ) + entry = parse_position_entry_price(lp["position_row"]) + item = { + "exchange_symbol": lp["exchange_symbol"], + "symbol": lp["monitor_symbol"], + "direction": lp["direction"], + "contracts": lp["contracts"], + "entry_price": entry, + "recoverable_monitor_id": int(mon["id"]) if mon else None, + "plan_stop_loss": float(mon["stop_loss"]) if mon and mon["stop_loss"] else None, + "plan_take_profit": float(mon["take_profit"]) if mon and mon["take_profit"] else None, + "monitor_status": mon["status"] if mon else None, + } + orphans.append(item) + return orphans + + +def recover_live_position_monitor(conn, monitor_id=None, place_tpsl=True): + orphans = list_orphan_live_positions(conn) + if not orphans: + return False, "未检测到「交易所有仓但未在监控」的持仓", None + + row = None + if monitor_id is not None: + row = conn.execute("SELECT * FROM order_monitors WHERE id=?", (int(monitor_id),)).fetchone() + if not row: + return False, "监控记录不存在", None + if row["status"] == "active": + return True, "该监控已在实时持仓中", int(row["id"]) + ex_sym = normalize_exchange_symbol(row["exchange_symbol"] or row["symbol"]) + direction = (row["direction"] or "long").strip().lower() + matched = any(o["exchange_symbol"] == ex_sym and o["direction"] == direction for o in orphans) + if not matched: + live = get_live_position_contracts(ex_sym, direction) + if live is None: + return False, "暂时无法读取交易所持仓,请稍后重试", None + if live <= 0: + return False, "交易所该方向已无持仓,无法恢复", None + else: + for o in orphans: + rid = o.get("recoverable_monitor_id") + if not rid: + continue + row = conn.execute("SELECT * FROM order_monitors WHERE id=?", (int(rid),)).fetchone() + if row: + break + if not row: + o = orphans[0] + dir_zh = "多" if o["direction"] == "long" else "空" + return ( + False, + f"检测到 {o['symbol']} {dir_zh}仓,但无匹配的已停监控记录(可能已被删除),需在数据库手动处理", + None, + ) + + if get_active_position_count(conn) >= MAX_ACTIVE_POSITIONS: + return False, f"已达最大持仓数({MAX_ACTIVE_POSITIONS})", None + + ex_sym = resolve_monitor_exchange_symbol(row) + live = get_live_position_contracts(ex_sym, row["direction"]) + if live is None: + return False, "暂时无法读取交易所持仓,请稍后重试", None + if live <= 0: + return False, "交易所该方向已无持仓,无法恢复监控", None + + oid = int(row["id"]) + conn.execute( + "UPDATE order_monitors SET status='active', exchange_close_order_id=NULL WHERE id=?", + (oid,), + ) + conn.commit() + + tpsl_msg = "" + if place_tpsl and row["stop_loss"] and row["take_profit"]: + ok_live, _live_reason = ensure_exchange_live_ready() + if ok_live: + try: + replace_active_monitor_tpsl_on_exchange(row, row["stop_loss"], row["take_profit"]) + tpsl_msg = ",并已重新挂止盈止损" + except Exception as e: + tpsl_msg = f".监控已恢复,但挂止盈止损失败:{friendly_exchange_error(e)}" + + return True, f"已恢复实时监控{tpsl_msg}", oid + + +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) + if out: + sym = (p.get("symbol") or "").strip() + try: + cs = float(get_contract_size(sym)) if sym else 1.0 + except Exception: + cs = 1.0 + from lib.hub.hub_position_metrics import enrich_ccxt_position_metrics_out + + enrich_ccxt_position_metrics_out( + p, out, contract_size=cs, funds_decimals=FUNDS_DECIMALS + ) + 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, min_ts=None): + 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 + try: + ts_i = int(ts) + except (TypeError, ValueError): + continue + if min_ts and ts_i < int(min_ts): + 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) + return pick_from_trades(trades, since_ms) + 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 = [] + 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 fetch_all_position_fills_for_record( + exchange_symbol, + direction, + opened_at_str, + closed_at_str=None, + opened_at_ms=None, + closed_at_ms=None, +): + """持仓生命周期内全部 fill(开+平),用于双边成交额与手续费.""" + 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) + 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 + try: + trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=200) + except Exception: + trades = [] + return filter_position_lifecycle_fills( + trades or [], + direction, + since_ms, + closed_ms, + hedge_mode=(BINANCE_POSITION_MODE == "hedge"), + ) + + +def _attach_binance_trade_exchange_stats( + conn, + trade_id, + *, + exchange_symbol, + direction, + opened_at_str, + closed_at_str, + opened_at_ms=None, + closed_at_ms=None, +): + if not (BINANCE_API_KEY and BINANCE_API_SECRET): + return + open_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) + close_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str) + contract_size = 1.0 + try: + ensure_markets_loaded() + contract_size = float(exchange.market(exchange_symbol).get("contractSize") or 1) + except Exception: + pass + + def _fetch(): + return fetch_all_position_fills_for_record( + exchange_symbol, + direction, + opened_at_str, + closed_at_str, + opened_at_ms=open_ms, + closed_at_ms=close_ms, + ) + + income_comm = None + if open_ms and close_ms: + fills_preview = _fetch() + trade_ids = trade_ids_from_fills(fills_preview) + 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, + ) + income_comm = sum_binance_commission_income(entries, trade_ids or None) + try: + attach_exchange_stats_to_trade( + conn, + trade_id, + fetch_fills=_fetch, + contract_size=contract_size, + income_commission=income_comm, + ) + except Exception: + pass + + +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) + + open_ms = _to_ms_with_fallback( + row["opened_at_ms"] if "opened_at_ms" in row.keys() else None, opened_at_str + ) + 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: + try: + last_ts_i = int(last_ts) + except (TypeError, ValueError): + last_ts_i = None + if last_ts_i is not None and open_ms and last_ts_i < int(open_ms): + closing_trades = [] + exit_px = None + closed_at_str = app_now_str() + closed_at_ms = None + elif last_ts_i is not None: + closed_at_str = ms_to_app_local_str(last_ts_i) + closed_at_ms = last_ts_i + + 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 ( + normalize_result_with_pnl(guessed, pnl2), + 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 ( + normalize_result_with_pnl(result, pnl), + pnl, + closed_at_str, + "按交易所成交/流水同步为止盈/止损平仓", + ) + return ( + "外部平仓", + pnl, + closed_at_str, + "交易所已平仓,成交价不在计划止盈/止损带内(可能为手动或其他类型平仓)", + ) + + +def _finalize_hub_flat_monitor_binance(conn, r, *, result, pnl_amount, closed_at, miss_reason): + opened_at = get_opened_at_value(r) + 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=trade_record_monitor_type(conn, r), + trend_plan_id=trend_plan_id_from_monitor_row(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"], + entry_model=(r["entry_model"] if "entry_model" in r.keys() else None), + 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=handoff_trade_miss_reason(miss_reason, r), + 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()) + + +def reconcile_hub_external_close(conn, symbol, direction): + from lib.hub.hub_reconcile_flat_lib import reconcile_hub_external_close_impl + from lib.hub.hub_symbol_lib import symbols_match + + global _RECONCILE_FLAT_STREAK + + return reconcile_hub_external_close_impl( + conn, + symbol, + direction, + exchange_configured=exchange_private_api_configured, + not_configured_msg="未配置 BINANCE_API_KEY / BINANCE_API_SECRET", + symbols_match=symbols_match, + get_opened_at_value=get_opened_at_value, + resolve_monitor_exchange_symbol=resolve_monitor_exchange_symbol, + get_live_position_contracts=get_live_position_contracts, + cancel_conditional_orders=cancel_binance_futures_open_orders, + resolve_synced_flat_close=resolve_synced_flat_close, + finalize_stopped_monitor=_finalize_hub_flat_monitor_binance, + sync_trade_records=None, + reconcile_flat_streak=_RECONCILE_FLAT_STREAK, + to_ms_with_fallback=_to_ms_with_fallback, + prefer_manual_resolve=False, + order_row_monitor_type=order_row_monitor_type, + ) + + +def reconcile_external_closes(conn, days=None): + global _RECONCILE_FLAT_STREAK + if not exchange_private_api_configured(): + return 0 + if time.time() - _APP_STARTED_AT < RECONCILE_STARTUP_GRACE_SEC: + return 0 + 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 IN ('active', 'error')" + ).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 + oid = int(r["id"]) + if r["status"] == "error": + opened_at_chk = get_opened_at_value(r) + existing = conn.execute( + "SELECT id FROM trade_records WHERE symbol=? AND opened_at=? AND monitor_type=? LIMIT 1", + (r["symbol"], opened_at_chk, order_row_monitor_type(r)), + ).fetchone() + if existing: + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (oid,)) + synced_count += 1 + 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: + _RECONCILE_FLAT_STREAK.pop(oid, None) + continue + if live_contracts > 0: + _RECONCILE_FLAT_STREAK.pop(oid, None) + continue + if r["status"] != "error": + streak = int(_RECONCILE_FLAT_STREAK.get(oid, 0)) + 1 + _RECONCILE_FLAT_STREAK[oid] = streak + if streak < RECONCILE_FLAT_CONFIRM_POLLS: + continue + _RECONCILE_FLAT_STREAK.pop(oid, None) + print( + f"[reconcile_external_closes] {r['symbol']} id={oid} " + f"flat x{streak} polls -> sync close" + ) + else: + _RECONCILE_FLAT_STREAK.pop(oid, None) + print( + f"[reconcile_external_closes] error recovery {r['symbol']} id={oid} flat -> sync close" + ) + 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=trade_record_monitor_type(conn, r), + trend_plan_id=trend_plan_id_from_monitor_row(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"], + entry_model=(r["entry_model"] if "entry_model" in r.keys() else None), + 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=handoff_trade_miss_reason(miss_reason, r), + 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),按 USDT 永续 24h 成交额降序. + 走 hub_volume_rank_lib 轻量 ticker API,避免 fetch_tickers() 全市场拉取. + """ + sym_norm = normalize_symbol_input(symbol) + target_base = journal_coin_from_symbol(sym_norm) + return resolve_daily_volume_rank( + target_base, + LIQUIDITY_RANK_CACHE, + now_ts=time.time(), + ttl_sec=max(30, BALANCE_REFRESH_SECONDS), + exchange=exchange, + ensure_markets_loaded=ensure_markets_loaded, + ) + + +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 + close_b = float(breakout[4]) + high_b = float(breakout[2]) + low_b = float(breakout[3]) + cfm_close = float(confirm[4]) + edge = float(upper) if direction == "long" else float(lower) + breakout_ok = (close_b > float(upper)) if direction == "long" else (close_b < float(lower)) + amp_ok, amp_pct = auto_amp_ok( + direction, close_b, float(upper), float(lower), KEY_BREAKOUT_AMP_MIN_PCT + ) + amp_ok = amp_ok and breakout_ok + confirm_ok_raw = auto_confirm_ok(direction, cfm_close, float(upper), float(lower)) + 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 _fetch_last_closed_bar(symbol): + """最近一根闭合 K:[ts, o, h, l, c, v] 或 None.""" + ex_sym = normalize_exchange_symbol(symbol) + bars = exchange.fetch_ohlcv(ex_sym, timeframe=KLINE_TIMEFRAME, limit=5) or [] + if len(bars) < 2: + return None + closed = bars[:-1] + return closed[-1] if closed else None + + +def _key_rs_gate_preview(symbol, upper, lower): + """页面门控预览:阻力/支撑仅显示距上/下沿与是否已越线.""" + bar = _fetch_last_closed_bar(symbol) + if not bar: + return {"summary": "5m数据不足", "metrics": ""} + close = float(bar[4]) + br = detect_rs_box_break(close, upper, lower) + if br: + return { + "summary": f"已越线:{br['break_label']}", + "metrics": f"收盘:{format_price_for_symbol(symbol, close)}", + } + return { + "summary": "待突破", + "metrics": f"收盘:{format_price_for_symbol(symbol, close)}", + } + + +def _process_key_rs_level_alert(conn, row): + """关键阻力位/支撑位:5m 收盘越上沿或下沿后,按间隔推送最多 KEY_ALERT_MAX_TIMES 次.""" + sym = row["symbol"] + typ = (row["monitor_type"] or "").strip() + up, low = float(row["upper"]), float(row["lower"]) + if up <= low: + return + bar = _fetch_last_closed_bar(sym) + if not bar: + return + close = float(bar[4]) + ts = bar[0] + now_dt = app_now() + tick = run_rs_level_alert_tick( + row, + close, + ts, + now_dt, + default_max_notify=KEY_ALERT_MAX_TIMES, + default_interval_min=KEY_ALERT_INTERVAL_MINUTES, + ) + if not tick: + return + + br = tick["break_info"] + notify_index = int(tick["notify_index"]) + max_n = int(tick["notify_max"]) + interval = int(tick["interval_min"]) + bar_ts = tick.get("bar_ts") + prior_count = int(tick.get("prior_count", notify_index - 1)) + + notified_at = app_now_str() + if not claim_rs_level_notify( + conn, + row["id"], + notify_index, + br["direction"], + notified_at, + bar_ts, + prior_count=prior_count, + ): + return + conn.commit() + + trigger_time = ms_to_app_local_str(int(ts)) if ts else app_now_str() + msg = build_wechat_rs_level_message( + symbol=sym, + monitor_type=typ, + account_label=_wechat_account_label(), + trigger_time=trigger_time, + upper_txt=format_price_for_symbol(sym, up), + lower_txt=format_price_for_symbol(sym, low), + close_txt=format_price_for_symbol(sym, close), + edge_txt=format_price_for_symbol(sym, br["edge_price"]), + break_label=br["break_label"], + direction=br["direction"], + notify_index=notify_index, + notify_max=max_n, + interval_min=interval, + ) + send_wechat_msg(msg) + conn.execute( + "UPDATE key_monitors SET last_alert_message=? WHERE id=?", + (msg, row["id"]), + ) + conn.commit() + if notify_index >= max_n: + hist_row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (row["id"],)).fetchone() + if hist_row: + insert_key_monitor_history(conn, hist_row, notify_index, msg, "key_level_alert_done") + conn.execute("DELETE FROM key_monitors WHERE id=?", (row["id"],)) + conn.commit() + + +def _key_hard_lines_from_checks(checks): + direction = (checks.get("direction") or "long").lower() + 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']})", + format_auto_amp_line(checks["amp_ok"], checks["amp_pct"], KEY_BREAKOUT_AMP_MIN_PCT), + format_auto_confirm_line( + checks["confirm_ok"], checks["confirm_close"], checks["edge_price"], direction + ), + f"日成交量排名:{'通过' if checks['rank_ok'] else '不通过'}({checks['rank']}/{checks['rank_total']},要求前{KEY_DAILY_VOLUME_RANK_MAX})", + ] + + +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, + time_close_enabled=0, + time_close_hours=None, +): + """ + 与手动「实盘下单」对齐的市价开仓与 order_monitors 写入(Binance U 本位). + 返回 (ok: bool, err_msg: Optional[str], detail: Optional[dict]) + """ + ok_src, src_msg = assert_open_source_allowed(POSITION_SIZING_MODE, OPEN_SOURCE_KEY_AUTO) + if not ok_src: + return False, src_msg, None + 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 _limit_key_plan_for_row(row): + typ = (row["monitor_type"] or "").strip() + if is_fib_key_monitor_type(typ): + return _fib_plan_for_row(row) + if is_false_breakout_key_monitor_type(typ): + direction = (row["direction"] or "long").lower() + key_px = key_price_from_row(direction, row["upper"], row["lower"]) + if key_px is None: + return None + return calc_false_breakout_plan(direction, key_px) + return None + + +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() + kind = "假突破" if is_false_breakout_key_monitor_type(typ) else "斐波" + ex_sym = normalize_exchange_symbol(symbol) + plan = _limit_key_plan_for_row(row) + if not plan: + _finalize_key_monitor_one_shot(conn, row, f"{kind}计划无效", "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} {kind}成交后处理失败\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} {kind}成交后风控拒绝\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} {kind}成交后挂 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 "-" + close_reason = "false_breakout_filled" if is_false_breakout_key_monitor_type(typ) else "fib_filled" + succ = ( + f"# ✅ {symbol} {kind}限价成交\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, close_reason) + + +def _trigger_entry_exists_for_symbol(conn, symbol): + placeholders = ",".join("?" * len(TRIGGER_ENTRY_MONITOR_TYPES)) + row = conn.execute( + f"SELECT id FROM key_monitors WHERE symbol=? AND monitor_type IN ({placeholders})", + (symbol, *TRIGGER_ENTRY_MONITOR_TYPES), + ).fetchone() + return row is not None + + +def _add_trigger_entry_key_monitor( + conn, + symbol, + direction_sel, + entry, + sl, + tp, + monitor_type=CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE, + breakeven_enabled=0, + time_close_enabled=0, + time_close_hours=None, +): + mt = (monitor_type or CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE).strip() + if mt not in TRIGGER_ENTRY_MONITOR_TYPES: + mt = CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE + if _trigger_entry_exists_for_symbol(conn, symbol): + return False, f"{symbol} 已有触价开仓监控(同币仅允许一条)" + ex_sym = normalize_exchange_symbol(symbol) + mark = get_symbol_mark_price(symbol) + geom_err = validate_trigger_entry_geometry( + direction_sel, entry, sl, tp, mark_at_add=mark, monitor_type=mt + ) + if geom_err: + return False, geom_err + rr_err = validate_trigger_entry_rr( + direction_sel, entry, sl, tp, KEY_AUTO_MIN_PLANNED_RR, calc_rr_ratio + ) + if rr_err: + return False, rr_err + entry = float(round_price_to_exchange(ex_sym, entry) or entry) + sl = float(round_price_to_exchange(ex_sym, sl) or sl) + tp = float(round_price_to_exchange(ex_sym, tp) or tp) + geom_err = validate_trigger_entry_geometry( + direction_sel, entry, sl, tp, mark_at_add=mark, monitor_type=mt + ) + if geom_err: + return False, geom_err + rr_err = validate_trigger_entry_rr( + direction_sel, entry, sl, tp, KEY_AUTO_MIN_PLANNED_RR, calc_rr_ratio + ) + if rr_err: + return False, rr_err + 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) + opens_today = count_opens_for_trading_day(conn, trading_day) + ok_intent, intent_msg = check_trigger_entry_intent_limit( + conn, trading_day, opens_today, DAILY_OPEN_HARD_LIMIT + ) + if not ok_intent: + return False, intent_msg + if is_full_margin_mode(POSITION_SIZING_MODE): + ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn)) + if not ok_flat: + return False, flat_msg + if count_pending_trigger_entries(conn, trading_day) > 0: + return False, "全仓杠杆模式下仅允许一条待触发触价监控" + 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) + available_usdt = get_available_trading_usdt() + if is_full_margin_mode(POSITION_SIZING_MODE): + leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE) + sizing, sizing_err = compute_full_margin_sizing( + symbol=symbol, + available_usdt=available_usdt if available_usdt is not None else 0.0, + capital_base=capital_base, + buffer_ratio=FULL_MARGIN_BUFFER_RATIO, + btc_leverage=BTC_LEVERAGE, + alt_leverage=ALT_LEVERAGE, + funds_decimals=2, + ) + if sizing_err: + return False, sizing_err + margin_capital = float(sizing["margin_capital"]) + amount_plan = None + else: + 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 + risk_fraction = calc_risk_fraction(direction_sel, entry, sl) + if risk_fraction is None: + return False, "止损方向不合法(相对计划入场价)" + 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_plan, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry) + except Exception as e: + return False, friendly_exchange_error(e, available_usdt=available_usdt) + upper_px = round_price_to_exchange(ex_sym, max(entry, tp)) + lower_px = round_price_to_exchange(ex_sym, min(entry, sl)) + if upper_px is None or lower_px is None or float(upper_px) <= float(lower_px): + upper_px, lower_px = float(max(entry, tp, sl)), float(min(entry, tp, sl)) + if upper_px <= lower_px: + lower_px = upper_px * 0.9999 + be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0 + tc_en, tc_h, _ = time_close_insert_values(time_close_enabled, time_close_hours, None) + conn.execute( + "INSERT INTO key_monitors " + "(symbol, monitor_type, direction, upper, lower, " + "fib_entry_price, fib_stop_loss, fib_take_profit, " + "fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled, " + "time_close_enabled, time_close_hours, session_date) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + mt, + direction_sel, + float(upper_px), + float(lower_px), + entry, + sl, + tp, + float(amount_plan) if amount_plan is not None else None, + margin_capital, + leverage, + be_flag, + tc_en, + tc_h, + trading_day, + ), + ) + return True, None + + +def _market_open_for_trigger_entry( + conn, + symbol, + direction, + exchange_symbol, + entry_price, + stop_loss, + take_profit, + monitor_type=CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE, + breakeven_enabled=0, + time_close_enabled=0, + time_close_hours=None, +): + """触价触发后市价开仓,计仓规则与实盘下单/关键位 RR 门槛一致.""" + ok_src, src_msg = assert_open_source_allowed(POSITION_SIZING_MODE, OPEN_SOURCE_KEY_TRIGGER) + if not ok_src: + return False, src_msg, None + 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 + + trading_day = get_trading_day(now) + opens_today_before = count_opens_for_trading_day(conn, trading_day) + 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_symbol_mark_price(symbol) or 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 = float(lp_r) + + entry_price = float(entry_price) + 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) + + planned_rr = calc_rr_ratio(direction, entry_price, stop_loss, take_profit) + if planned_rr is None or planned_rr <= KEY_AUTO_MIN_PLANNED_RR: + rr_txt = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算" + return False, f"计划盈亏比 {rr_txt}:1 未达要求(>{KEY_AUTO_MIN_PLANNED_RR}:1)", None + + risk_percent = max(0.01, float(RISK_PERCENT)) + if is_full_margin_mode(POSITION_SIZING_MODE): + ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn)) + if not ok_flat: + return False, flat_msg, None + leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE) + sizing, sizing_err = compute_full_margin_sizing( + symbol=symbol, + available_usdt=available_usdt if available_usdt is not None else 0.0, + capital_base=capital_base, + buffer_ratio=FULL_MARGIN_BUFFER_RATIO, + btc_leverage=BTC_LEVERAGE, + alt_leverage=ALT_LEVERAGE, + funds_decimals=2, + ) + if sizing_err: + return False, sizing_err, None + margin_capital = float(sizing["margin_capital"]) + notional_value = float(sizing["notional_value"]) + position_ratio = float(sizing["position_ratio"]) + risk_amount = margin_capital + else: + 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 + risk_fraction = calc_risk_fraction(direction, entry_price, stop_loss) + if risk_fraction is None: + return False, "止损方向不合法(相对计划入场价)", None + 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_fill = 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 + tc_en, tc_h, tc_at = time_close_insert_values(time_close_enabled, time_close_hours, opened_at_ms) + risk_percent_db = risk_percent_for_storage(POSITION_SIZING_MODE, risk_percent) + + 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, " + "time_close_enabled, time_close_hours, time_close_at_ms) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + exchange_symbol, + direction, + trigger_price, + stop_loss, + stop_loss, + take_profit, + margin_capital, + leverage, + trade_style, + risk_percent_db, + 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(monitor_type), + tc_en, + tc_h, + tc_at, + ), + ) + 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 = count_opens_for_trading_day(conn, trading_day) + + return True, None, { + "new_order_id": new_order_id, + "open_order_id": open_order_id, + "trigger_price": trigger_price, + "planned_rr_fill": planned_rr_fill, + "risk_amount_final": risk_amount_final, + "margin_capital": margin_capital, + "leverage": leverage, + "amount": amount, + "tpsl_attached": tpsl_attached, + "opens_today_before": opens_today_before, + "opens_today_after": opens_today_after, + "trading_day": trading_day, + "stop_loss": stop_loss, + "take_profit": take_profit, + } + + +def _execute_trigger_entry_cross(conn, row): + """标记价触达计划入场:加锁防重复触发,成交成功后再删监控行.""" + symbol = row["symbol"] + direction = (row["direction"] or "long").lower() + ex_sym = normalize_exchange_symbol(symbol) + entry = float(_sqlite_row_val(row, "fib_entry_price") or 0) + sl = float(_sqlite_row_val(row, "fib_stop_loss") or 0) + tp = float(_sqlite_row_val(row, "fib_take_profit") or 0) + be_en = breakeven_enabled_from_row(row, 0) + tc_en, tc_h, _ = time_close_settings_from_row(row) + + kid = int(row["id"]) + if not acquire_trigger_entry_exec_lock(conn, kid): + return False, "触价开仓进行中" + conn.commit() + + try: + ok, err, det = _market_open_for_trigger_entry( + conn, + symbol, + direction, + ex_sym, + entry, + sl, + tp, + monitor_type=(row["monitor_type"] or CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE), + breakeven_enabled=be_en, + time_close_enabled=tc_en, + time_close_hours=tc_h, + ) + except Exception as e: + release_trigger_entry_exec_lock(conn, kid) + conn.commit() + fail_msg = friendly_exchange_error(e) + send_wechat_msg( + f"# ❌ {symbol} 触价开仓异常\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 计划入场:{format_price_for_symbol(symbol, entry)}\n" + f"- 原因:{fail_msg}\n" + ) + insert_key_monitor_history(conn, row, 0, fail_msg, TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED) + return False, fail_msg + + if ok and det: + conn.execute("DELETE FROM key_monitors WHERE id=?", (kid,)) + conn.commit() + rr_txt = format_wechat_scalar_2dp(det.get("planned_rr_fill")) if det.get("planned_rr_fill") is not None else "-" + msg = ( + f"# ✅ {symbol} 触价开仓成交\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 来源:{ORDER_MONITOR_TYPE_KEY_AUTO}(程序触价 @ E)\n" + f"- 类型:{TRIGGER_ENTRY_MONITOR_TYPE}|{_wechat_direction_text(direction)}\n" + f"- 订单 ID:**{det.get('new_order_id')}**\n" + f"- 计划入场:{format_price_for_symbol(symbol, entry)}\n" + f"- 成交价:{format_price_for_symbol(symbol, det.get('trigger_price'))}\n" + f"- 止损:{format_wechat_scalar_2dp(det.get('stop_loss'))}|止盈:{format_price_for_symbol(symbol, det.get('take_profit'))}\n" + f"- 计划 RR:{rr_txt}:1\n" + f"- {'已挂交易所 TP/SL' if det.get('tpsl_attached') else 'TP/SL 未挂上'}\n" + ) + send_wechat_msg(msg) + insert_key_monitor_history(conn, row, 0, msg, TRIGGER_ENTRY_CLOSE_FILLED) + return True, None + release_trigger_entry_exec_lock(conn, kid) + conn.commit() + fail_msg = err or "触价触发后开仓失败" + send_wechat_msg( + f"# ❌ {symbol} 触价开仓失败\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 计划入场:{format_price_for_symbol(symbol, entry)}\n" + f"- 原因:{fail_msg}\n" + ) + insert_key_monitor_history(conn, row, 0, fail_msg, TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED) + return False, fail_msg + + +def check_trigger_entry_key_monitors(): + if not KEY_AUTO_ORDER_ENABLED: + return + conn = get_db() + placeholders = ",".join("?" * len(TRIGGER_ENTRY_MONITOR_TYPES)) + rows = conn.execute( + f"SELECT * FROM key_monitors WHERE monitor_type IN ({placeholders})", + tuple(TRIGGER_ENTRY_MONITOR_TYPES), + ).fetchall() + now_dt = app_now() + for r in rows: + symbol = r["symbol"] + direction = (r["direction"] or "long").lower() + mt = (r["monitor_type"] or CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE).strip() + entry = float(_sqlite_row_val(r, "fib_entry_price") or 0) + sl = float(_sqlite_row_val(r, "fib_stop_loss") or 0) + tp = float(_sqlite_row_val(r, "fib_take_profit") or 0) + kid = int(r["id"]) + if is_trigger_entry_in_flight_row(r): + continue + if entry <= 0 or sl <= 0 or tp <= 0: + _finalize_key_monitor_one_shot(conn, r, "触价计划价位无效", "fib_plan_invalid") + continue + mark = get_symbol_mark_price(symbol) + if mark is None: + continue + prev_mark = _sqlite_row_val(r, "last_mark_price") + prev_mark_f = float(prev_mark) if prev_mark not in (None, "") else None + if is_trigger_entry_expired(r["created_at"], now_dt, hours=TRIGGER_ENTRY_VALIDITY_HOURS): + exp_txt = trigger_entry_expires_at_text(r["created_at"], hours=TRIGGER_ENTRY_VALIDITY_HOURS) + msg = ( + f"# ⚠️ {symbol} 触价开仓已过期\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{mt}|{_wechat_direction_text(direction)}\n" + f"- 有效期 {TRIGGER_ENTRY_VALIDITY_HOURS}h(应于 {exp_txt} 前触发)\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, TRIGGER_ENTRY_CLOSE_EXPIRED) + continue + inv = trigger_entry_invalidate(mt, direction, mark, sl, tp) + if inv == "tp": + msg = ( + f"# ⚠️ {symbol} 触价开仓失效\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{mt}|标记价 {format_price_for_symbol(symbol, mark)} 已触达止盈侧(未成交)\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, TRIGGER_ENTRY_CLOSE_TP_INVALIDATE) + continue + if inv == "sl": + msg = ( + f"# ⚠️ {symbol} 触价开仓失效\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{mt}|标记价 {format_price_for_symbol(symbol, mark)} 已触达止损侧(未突破)\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, TRIGGER_ENTRY_CLOSE_SL_INVALIDATE) + continue + if trigger_should_fire(mt, direction, mark, entry, prev_mark_f): + _execute_trigger_entry_cross(conn, r) + continue + conn.execute("UPDATE key_monitors SET last_mark_price=? WHERE id=?", (float(mark), kid)) + conn.commit() + conn.close() + + +def check_fib_key_monitors(): + if not KEY_AUTO_ORDER_ENABLED: + return + conn = get_db() + rows = conn.execute("SELECT * FROM key_monitors").fetchall() + for r in rows: + typ = (r["monitor_type"] or "").strip() + if not is_limit_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") + if is_false_breakout_key_monitor_type(typ): + now_dt = app_now() + if is_false_breakout_expired(r["created_at"], now_dt): + _cancel_fib_monitor_limit(r) + exp_txt = expires_at_text(r["created_at"]) + msg = ( + f"# ⚠️ {symbol} 假突破监控已过期\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}|{_wechat_direction_text(direction)}\n" + f"- 有效期 {FALSE_BREAKOUT_VALIDITY_HOURS}h(应于 {exp_txt} 前成交)\n" + f"- 已撤销限价单\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, "false_breakout_expired") + continue + 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 is_fib_key_monitor_type(typ) and 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 is_fib_key_monitor_type(typ) and 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, + time_close_enabled=0, time_close_hours=None, +): + 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 + tc_en, tc_h, _ = time_close_insert_values(time_close_enabled, time_close_hours, None) + 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, time_close_enabled, time_close_hours) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, mt, direction_sel, upper_px, lower_px, + oid, entry, sl, tp, float(amount), margin_capital, leverage, be_flag, tc_en, tc_h, + ), + ) + return True, None + + +def _false_breakout_exists_for_symbol(conn, symbol): + row = conn.execute( + "SELECT id FROM key_monitors WHERE symbol=? AND monitor_type=?", + (symbol, FALSE_BREAKOUT_MONITOR_TYPE), + ).fetchone() + return row is not None + + +def _add_false_breakout_key_monitor( + conn, symbol, direction_sel, upper_px, lower_px, key_px, breakeven_enabled=0, + time_close_enabled=0, time_close_hours=None, +): + if _false_breakout_exists_for_symbol(conn, symbol): + return False, f"{symbol} 已有假突破监控(同币仅允许一条)" + plan = calc_false_breakout_plan(direction_sel, key_px) + if not plan: + return False, "假突破价位无效,请核对方向与关键价位" + 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) + 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, "止损方向不合法(相对挂单价);请核对方向与关键价位" + 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 + tc_en, tc_h, _ = time_close_insert_values(time_close_enabled, time_close_hours, None) + 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, time_close_enabled, time_close_hours) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, FALSE_BREAKOUT_MONITOR_TYPE, direction_sel, upper_px, lower_px, + oid, entry, sl, tp, float(amount), margin_capital, leverage, be_flag, tc_en, tc_h, + ), + ) + return True, None + + +# 关键位监控(箱体/收敛可自动开仓;阻力/支撑为双向 5m 收盘突破 + 三次提醒) +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_limit_key_monitor_type(typ): + continue + if typ in KEY_MONITOR_RS_TYPES: + try: + _process_key_rs_level_alert(conn, r) + except Exception as e: + print(f"[key_rs_level_alert] {sym} id={r['id']}: {e}") + continue + + if not KEY_AUTO_ORDER_ENABLED: + continue + + direction = (r["direction"] or "long").lower() + if direction == KEY_DIRECTION_WATCH: + continue + if typ in KEY_MONITOR_AUTO_TYPES: + mark = get_symbol_mark_price(sym) + if mark is not None and box_breakout_invalidate_by_mark(direction, mark, up, low): + edge = float(low) if direction == "long" else float(up) + edge_label = box_breakout_invalidate_edge_label(direction) + msg = ( + f"# ⚠️ {sym} 关键位监控失效\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}|{_wechat_direction_text(direction)}\n" + f"- 标记价 {format_price_for_symbol(sym, mark)} 已突破反向{edge_label} " + f"{format_price_for_symbol(sym, edge)}(设置失效)\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, "box_opposite_break") + 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) + 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() + + if typ not in KEY_MONITOR_AUTO_TYPES: + 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) + tc_en, tc_h, _ = time_close_settings_from_row(r) + 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, + time_close_enabled=tc_en, + time_close_hours=tc_h, + ) + 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"- {format_daily_open_summary_short(det.get('opens_today_after'), DAILY_OPEN_ALERT_THRESHOLD, DAILY_OPEN_HARD_LIMIT)}", + ] + 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 should_send_daily_open_alert( + det.get("opens_today_before", 0), + det.get("opens_today_after", 0), + DAILY_OPEN_ALERT_THRESHOLD, + ): + advice = ai_short_advice( + build_daily_open_alert_prompt( + det["trading_day"], + det.get("opens_today_after", 0), + DAILY_OPEN_ALERT_THRESHOLD, + hard_limit=DAILY_OPEN_HARD_LIMIT, + detail_line=f"最新一笔来源为关键位自动单:{sym} {direction},杠杆{det['leverage']}x.", + ) + ) + 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) + if stale_breakeven_armed(direction, trigger_price, stop_loss, breakeven_armed): + conn.execute( + "UPDATE order_monitors SET breakeven_armed=0, breakeven_price=NULL WHERE id=?", + (pid,), + ) + breakeven_armed = 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 = False + 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 should_trigger_time_close(r): + res = TIME_CLOSE_RESULT + # 做多 + if not res and direction == "long": + if p >= take_profit: res = "止盈" + elif p <= stop_loss: res = "止损" + # 做空 + elif not res and 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 = normalize_result_with_pnl("止损", pnl_amount) + 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 = normalize_result_with_pnl("止损", pnl_amount) + 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 = normalize_result_with_pnl("止损", pnl_amount) + 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=trade_record_monitor_type(conn, r), + trend_plan_id=trend_plan_id_from_monitor_row(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"], + entry_model=(r["entry_model"] if "entry_model" in r.keys() else None), + 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=handoff_trade_miss_reason( + "触发价已触达,仓位已由交易所止盈/止损或其他方式平掉(本地补记)", + r, + ), + 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 + ex_sym_fail = r["exchange_symbol"] or normalize_exchange_symbol(sym) + cancel_binance_futures_open_orders(ex_sym_fail) + live_contracts = get_live_position_contracts(ex_sym_fail, direction) + if live_contracts is not None and live_contracts <= 0: + record_res, record_pnl, record_closed, sync_miss = resolve_synced_flat_close( + r, opened_at, opened_at_ms=opened_at_ms + ) + record_miss = f"{sync_miss};本地触发{res}时平仓API失败:{e}" + monitor_status = "stopped" + else: + record_res, record_pnl, record_closed = res, pnl_amount, closed_at + record_miss = f"触发{res}后交易所平仓失败(请核对交易所仓位):{e}" + monitor_status = "error" + record_hold = calc_hold_seconds( + opened_at, parse_dt_for_trading_day(record_closed) or now + ) + insert_trade_record( + conn, + symbol=sym, + monitor_type=trade_record_monitor_type(conn, r), + trend_plan_id=trend_plan_id_from_monitor_row(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=record_pnl, + hold_seconds=record_hold, + trade_style=r["trade_style"], + entry_model=(r["entry_model"] if "entry_model" in r.keys() else None), + 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(record_pnl, r["risk_amount"]), + result=record_res, + miss_reason=handoff_trade_miss_reason(record_miss, r), + opened_at=opened_at, + closed_at=record_closed, + ) + session_capital = update_session_capital(conn, session_date, record_pnl) + conn.execute("UPDATE order_monitors SET status=? WHERE id=?", (monitor_status, pid)) + conn.commit() + send_wechat_msg( + build_wechat_monitor_error_message( + symbol=sym, + direction=direction, + scene=f"触发{res}后交易所平仓失败", + error_text=str(e), + ) + ) + if monitor_status == "stopped": + send_wechat_msg( + build_wechat_close_message( + symbol=sym, + direction=direction, + result=f"{record_res}(已补记入交易记录)", + pnl_amount=record_pnl, + hold_seconds=record_hold, + trigger_price=trigger_price, + current_price=p, + stop_loss=stop_loss, + take_profit=take_profit, + close_order_id="-", + extra_note=record_miss, + session_capital_fallback=session_capital, + ) + ) + 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=trade_record_monitor_type(conn, r), + trend_plan_id=trend_plan_id_from_monitor_row(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"], + entry_model=(r["entry_model"] if "entry_model" in r.keys() else None), + 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=handoff_trade_miss_reason(None, r), + 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=trade_record_monitor_type(conn, r), + trend_plan_id=trend_plan_id_from_monitor_row(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"], + entry_model=(r["entry_model"] if "entry_model" in r.keys() else None), + 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=handoff_trade_miss_reason( + f"北京时间 {FORCE_CLOSE_BJ_HOUR}:00 整点风控清仓", + r, + ), + 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: + try: + auto_transfer_once_per_day() + conn = get_db() + force_close_before_reset() + reconcile_external_closes(conn) + conn.commit() + conn.close() + check_fib_key_monitors() + check_trigger_entry_key_monitors() + _roll_cfg = app.extensions.get("strategy_roll_cfg") + if _roll_cfg: + from lib.strategy.strategy_roll_monitor_lib import check_roll_monitors + + check_roll_monitors(_roll_cfg) + check_key_monitors() + check_order_monitors() + cfg = app.extensions.get("strategy_trend_cfg") + if cfg: + from lib.strategy.strategy_trend_register import check_trend_pullback_plans + + check_trend_pullback_plans(cfg) + except Exception as e: + print(f"[monitor_loop] {e}", flush=True) + 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, + pwa_app_name="Binance 交易系统", + ) + +@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 hub_request_allowed(bool(session.get("logged_in")), AUTH_DISABLED): + return f(*args, **kwargs) + return redirect("/login") + 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", embed_mode=None): + 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"]) + from lib.instance.instance_embed_context_lib import ( + embed_render_plan, + minimal_stats_bundle, + profit_loss_ratio_from_trades, + total_funds_usdt, + trade_records_summary, + ) + + plan = embed_render_plan(page, embed_mode) + if plan.exchange_capitals: + funding_capital, trading_capital = get_exchange_capitals() + else: + funding_capital, trading_capital = None, None + # 资金账户:仅展示交易所读取结果(含 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() if plan.key_list else [] + ) + 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() + if plan.key_history + else [] + ) + stats_bundle = ( + compute_stats_bundle(conn, trading_day, now) + if plan.stats_bundle + else minimal_stats_bundle(TRADING_DAY_RESET_HOUR) + ) + order_list = [] + if plan.orders: + raw_order_list = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() + for o in raw_order_list: + order_list.append(enrich_order_item(row_to_dict(o), current_capital)) + enrich_orders_force_close( + order_list, + FORCE_CLOSE_ENABLED, + FORCE_CLOSE_BJ_HOUR, + now_ms=int(app_now().timestamp() * 1000), + ) + tr_ts = sql_list_time_field("closed_at", "created_at", "opened_at") + if plan.records_rows: + raw_records = conn.execute( + f"SELECT * FROM trade_records WHERE {tr_ts} >= ? AND {tr_ts} <= ? ORDER BY id DESC LIMIT 1000", + (start_bj, end_bj), + ).fetchall() + records = filter_trade_records_excluding_miss( + [to_effective_trade_dict(r) for r in raw_records] + ) + total = len(records) + win = count_winning_trades(records) + rate = round(win / total * 100, 2) if total else 0 + profit_loss_ratio = profit_loss_ratio_from_trades(records) + elif plan.records_summary: + summary = trade_records_summary(conn, start_bj, end_bj, tr_ts) + records = summary["records"] + total = summary["total"] + rate = summary["rate"] + profit_loss_ratio = summary.get("profit_loss_ratio") + else: + records = [] + total = rate = 0 + profit_loss_ratio = None + active_count = len(order_list) + from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors + + position_limit_count = count_position_limit_active_monitors(conn) + opens_today = count_opens_for_trading_day(conn, trading_day) + risk_status = hub_account_risk_status(conn) + can_trade = can_trade_new_open( + time_allows=trading_day_reset_allows_new_open(now), + active_count=position_limit_count, + max_active_positions=MAX_ACTIVE_POSITIONS, + opens_today=opens_today, + hard_limit=DAILY_OPEN_HARD_LIMIT, + extra_blocks=not risk_status.get("can_trade", True), + ) + key_rule_ctx = key_monitor_rule_template_context( + kline_timeframe=KLINE_TIMEFRAME, + key_breakout_amp_min_pct=KEY_BREAKOUT_AMP_MIN_PCT, + key_volume_ma_bars=KEY_VOLUME_MA_BARS, + key_volume_ratio_min=KEY_VOLUME_RATIO_MIN, + key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR, + key_daily_volume_rank_max=KEY_DAILY_VOLUME_RANK_MAX, + key_confirm_breakout_bar=KEY_CONFIRM_BREAKOUT_BAR, + key_confirm_bar=KEY_CONFIRM_BAR, + key_alert_max_times=KEY_ALERT_MAX_TIMES, + key_alert_interval_minutes=KEY_ALERT_INTERVAL_MINUTES, + key_stop_outside_breakout_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT, + key_trend_stop_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT, + false_breakout_validity_hours=FALSE_BREAKOUT_VALIDITY_HOURS, + trigger_entry_validity_hours=TRIGGER_ENTRY_VALIDITY_HOURS, + ) + strategy_extra = {} + if plan.strategy: + from lib.strategy.strategy_ui import strategy_render_extras + + strategy_extra = strategy_render_extras( + conn, + page, + default_risk_percent=float(RISK_PERCENT), + request_obj=request, + trend_cfg=app.extensions.get("strategy_trend_cfg"), + ) + orphan_live_positions = [] + if plan.orphan_live and not order_list and exchange_private_api_configured(): + orphan_live_positions = list_orphan_live_positions(conn) + conn.close() + from lib.instance.instance_embed_lib import embed_context_extras + from lib.instance.instance_settings_lib import settings_page_context + from lib.instance.instance_display_prefs_lib import display_prefs_template_context + + _display_ctx = display_prefs_template_context(get_db) + template_ctx = dict( + page=page, + key=key_list, + key_history=key_history, + stats_bundle=stats_bundle, + order=order_list, + orphan_live_positions=orphan_live_positions, + record=records, + total=total, + rate=rate, + profit_loss_ratio=profit_loss_ratio, + total_funds=total_funds_usdt(funding_usdt, current_capital), + 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=position_limit_count, + can_trade=can_trade, + opens_today=opens_today, + daily_open_hard_limit=DAILY_OPEN_HARD_LIMIT, + daily_open_alert_threshold=DAILY_OPEN_ALERT_THRESHOLD, + 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_this_month": PRESET_UTC_THIS_MONTH, + "utc_last3m": PRESET_UTC_LAST3M, + "utc_last6m": PRESET_UTC_LAST6M, + "all": PRESET_ALL, + "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, + position_sizing_mode=POSITION_SIZING_MODE, + position_sizing_mode_label=mode_label_zh(POSITION_SIZING_MODE), + trade_policy=trade_policy_template_context(TRADE_POLICY), + **order_entry_template_context(TRADE_POLICY), + open_position_button_label=open_position_button_label(TRADE_POLICY, POSITION_SIZING_MODE), + breakeven_rr_trigger=BREAKEVEN_RR_TRIGGER, + breakeven_offset_pct=BREAKEVEN_OFFSET_PCT, + price_fmt=format_price_for_symbol, + funds_fmt=format_funds_u, + entry_reason_options=list( + effective_entry_reason_options( + ENTRY_REASON_OPTIONS, + POSITION_SIZING_MODE, + KEY_AUTO_ORDER_ENABLED, + trend_manual_count=trend_manual_entry_reason_count(TRADE_POLICY), + ) + ), + order_type_options=list(JOURNAL_ORDER_TYPE_OPTIONS), + key_auto_order_enabled=KEY_AUTO_ORDER_ENABLED, + journal_chart_tf_choices=JOURNAL_CHART_TF_CHOICES, + journal_chart_default_tf1=JOURNAL_CHART_DEFAULT_TF1, + journal_chart_default_tf2=JOURNAL_CHART_DEFAULT_TF2, + journal_chart_default_limit=JOURNAL_CHART_DEFAULT_LIMIT, + journal_chart_default_anchor=JOURNAL_CHART_DEFAULT_ANCHOR, + exchange_display=EXCHANGE_DISPLAY_NAME, + risk_status=risk_status, + 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_rule_ctx=key_rule_ctx, + kline_timeframe=KLINE_TIMEFRAME, + **strategy_extra, + **embed_context_extras("binance"), + **_display_ctx, + **settings_page_context( + page, + display=_display_ctx["display"], + instance_base_dir=BASE_DIR, + exchange_key="binance", + exchange_display=EXCHANGE_DISPLAY_NAME, + risk_status=risk_status, + trade_policy=TRADE_POLICY, + data_export_version=3, + ), + **force_close_template_context( + FORCE_CLOSE_ENABLED, + FORCE_CLOSE_BJ_HOUR, + now_ms=int(app_now().timestamp() * 1000), + ), + ) + if embed_mode == "fragment": + return render_template("embed_page_fragment.html", **template_ctx) + if embed_mode == "shell": + return render_template("embed_shell.html", initial_tab=page, **template_ctx) + return render_template("index.html", **template_ctx) + + +@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("/dashboard") +@login_required +def dashboard_page(): + return render_main_page("dashboard") + + +@app.route("/risk_policy") +@login_required +def risk_policy_page(): + return render_main_page("risk_policy") + + +@app.route("/env_config") +@login_required +def env_config_page(): + return render_main_page("env_config") + + +@app.route("/settings") +@login_required +def settings_page(): + return render_main_page("settings") + + +@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) + from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors + + position_limit_count = count_position_limit_active_monitors(conn) + opens_today = count_opens_for_trading_day(conn, trading_day) + risk_status = hub_account_risk_status(conn) + active_pnl_rows = conn.execute( + "SELECT exchange_symbol, symbol, direction FROM order_monitors WHERE status='active'" + ).fetchall() + from lib.instance.instance_embed_context_lib import header_trade_stats_for_window, total_funds_usdt + + header_trade_stats = header_trade_stats_for_window(conn, _list_window_from_request(), APP_TZ) + conn.close() + can_trade = can_trade_new_open( + time_allows=trading_day_reset_allows_new_open(now), + active_count=position_limit_count, + max_active_positions=MAX_ACTIVE_POSITIONS, + opens_today=opens_today, + hard_limit=DAILY_OPEN_HARD_LIMIT, + extra_blocks=not risk_status.get("can_trade", True), + ) + available_trading_usdt = get_available_trading_usdt() + + unrealized_pnl = None + if exchange_private_api_configured(): + from lib.instance.instance_live_pnl_lib import resolve_instance_unrealized_pnl + + def _binance_positions(): + ensure_markets_loaded() + return exchange.fetch_positions() or [] + + unrealized_pnl = resolve_instance_unrealized_pnl( + _binance_positions, + active_pnl_rows, + get_live_position_exchange_metrics, + ) + return jsonify({ + "funding_usdt": funding_usdt, + "current_capital": current_capital, + "total_funds": total_funds_usdt(funding_usdt, current_capital), + "available_trading_usdt": round(available_trading_usdt, FUNDS_DECIMALS) if available_trading_usdt is not None else None, + "unrealized_pnl": unrealized_pnl, + "recommended_capital": recommended_capital, + "active_count": position_limit_count, + "max_active_positions": MAX_ACTIVE_POSITIONS, + "can_trade": can_trade, + "opens_today": opens_today, + "daily_open_hard_limit": DAILY_OPEN_HARD_LIMIT, + "daily_open_alert_threshold": DAILY_OPEN_ALERT_THRESHOLD, + "manual_min_planned_rr": MANUAL_MIN_PLANNED_RR, + "trading_day": trading_day, + "total": header_trade_stats["total"], + "rate": header_trade_stats["rate"], + "profit_loss_ratio": header_trade_stats.get("profit_loss_ratio"), + "risk_status": risk_status, + **force_close_template_context( + FORCE_CLOSE_ENABLED, + FORCE_CLOSE_BJ_HOUR, + now_ms=int(now.timestamp() * 1000), + ), + }) + + +@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_stop_loss,fib_take_profit,fib_limit_order_id,created_at 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," + "time_close_enabled,time_close_hours,time_close_at_ms,opened_at_ms FROM order_monitors WHERE status='active'" + ).fetchall() + + 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"]) + is_fb = is_false_breakout_key_monitor_type(r["monitor_type"]) + is_te = is_trigger_entry_key_monitor_type(r["monitor_type"]) + if is_fib or is_fb or is_te: + 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 + fb_gate_ok = True + te_gate_ok = True + box_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')}" + elif is_fb: + entry = _sqlite_row_val(r, "fib_entry_price") + entry_txt = format_price_for_symbol(r["symbol"], entry) if entry else "-" + prev = false_breakout_gate_preview( + entry_display=entry_txt, + limit_order_id=_sqlite_row_val(r, "fib_limit_order_id"), + created_at=_sqlite_row_val(r, "created_at"), + now=app_now(), + ) + gate_summary = prev.get("summary") or "-" + gate_metrics = prev.get("metrics") or "" + fb_gate_ok = bool(prev.get("gate_ok")) + elif is_te: + direction = (r["direction"] or "long").lower() + entry = _sqlite_row_val(r, "fib_entry_price") + tp_v = _sqlite_row_val(r, "fib_take_profit") + entry_txt = format_price_for_symbol(r["symbol"], entry) if entry else "-" + tp_txt = format_price_for_symbol(r["symbol"], tp_v) if tp_v else "-" + sl_v = _sqlite_row_val(r, "fib_stop_loss") + inv = ( + trigger_entry_invalidate( + r["monitor_type"], direction, price, float(sl_v or 0), float(tp_v or 0) + ) + if tp_v + else None + ) + prev = trigger_entry_gate_preview( + monitor_type=r["monitor_type"], + entry_display=entry_txt, + take_profit_display=tp_txt, + created_at=_sqlite_row_val(r, "created_at"), + now=app_now(), + tp_invalidated=inv == "tp", + sl_invalidated=inv == "sl", + hours=TRIGGER_ENTRY_VALIDITY_HOURS, + ) + gate_summary = prev.get("summary") or "-" + gate_metrics = prev.get("metrics") or "" + te_gate_ok = bool(prev.get("gate_ok")) + elif (r["monitor_type"] or "").strip() in KEY_MONITOR_RS_TYPES: + try: + prev = _key_rs_gate_preview(r["symbol"], r["upper"], r["lower"]) + gate_summary = prev.get("summary") or "-" + gate_metrics = prev.get("metrics") or "" + except Exception: + gate_summary = "-" + elif (r["monitor_type"] or "").strip() in KEY_MONITOR_AUTO_TYPES: + direction = (r["direction"] or "long").lower() + if box_breakout_invalidate_by_mark(direction, price, r["upper"], r["lower"]): + edge_label = box_breakout_invalidate_edge_label(direction) + gate_summary = f"反向突破{edge_label}·将撤销" + box_gate_ok = False + else: + try: + gate = _key_hard_checks( + r["symbol"], + direction, + 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 fb_gate_ok if is_fb + else te_gate_ok if is_te + else box_gate_ok and bool(gate and gate.get("ok")) + ), + "gate_metrics": gate_metrics, + }) + + order_prices = [] + from lib.hub.price_snapshot_lib import resolve_order_snapshot_price + + for r in order_rows: + margin = float(r["margin_capital"] or 0) + leverage = float(r["leverage"] or 0) + entry = float(r["trigger_price"] or 0) + exchange_tpsl = {"sl": None, "tp": None} + 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 + price = resolve_order_snapshot_price( + r["symbol"], + prices, + position_row=prow, + order_leverage=lev_row, + parse_position_metrics_fn=parse_ccxt_position_metrics, + get_mark_price_fn=get_symbol_mark_price, + fallback_entry=entry if entry > 0 else None, + ) + pnl = calc_pnl(r["direction"], entry, price, margin, leverage) if entry > 0 and price else 0 + pnl_pct = round((pnl / margin * 100), 2) if margin > 0 else 0 + payload = { + "id": r["id"], + "symbol": r["symbol"], + "float_pnl": round(pnl, FUNDS_DECIMALS), + "float_pct": pnl_pct, + "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 price is not None: + payload["price"] = round(price, 6) + payload["price_display"] = format_price_for_symbol(ex_sym, price) + else: + payload["price"] = None + payload["price_display"] = "-" + 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: + exchange_tpsl = fetch_exchange_tpsl_slots(ex_sym, r["direction"]) + except Exception: + exchange_tpsl = {"sl": None, "tp": None} + payload["exchange_tpsl"] = exchange_tpsl + avg_entry = None + if ex_metrics and ex_metrics.get("entry_price") is not None: + avg_entry = ex_metrics["entry_price"] + elif prow: + from lib.hub.hub_position_metrics import parse_position_entry_price + + avg_entry = parse_position_entry_price(prow) + apply_order_price_display_fields( + payload, + direction=r["direction"], + entry_price=entry, + initial_stop_loss=r["initial_stop_loss"], + stop_loss=r["stop_loss"], + take_profit=r["take_profit"], + calc_rr_ratio_fn=calc_rr_ratio, + exchange_tpsl=exchange_tpsl, + format_price_fn=format_price_for_symbol, + symbol=r["symbol"], + margin_capital=margin, + leverage=leverage, + exchange_notional=ex_metrics.get("notional") if ex_metrics else None, + contracts=abs(_position_row_effective_contracts(prow)) if prow else None, + contract_size=float(get_contract_size(ex_sym)) if ex_sym else 1.0, + mark_price=ex_metrics.get("mark_price") if ex_metrics else price, + avg_entry_price=avg_entry, + funds_decimals=FUNDS_DECIMALS, + ) + apply_time_close_to_payload(payload, r) + apply_force_close_to_payload( + payload, + enabled=FORCE_CLOSE_ENABLED, + bj_hour=FORCE_CLOSE_BJ_HOUR, + ) + payload["opened_at"] = r["opened_at"] if "opened_at" in r.keys() else None + open_ms = r["opened_at_ms"] if "opened_at_ms" in r.keys() else None + payload["opened_at_ms"] = int(open_ms) if open_ms not in (None, "") else None + new_sl, new_tp, changed = order_monitor_tpsl_needs_sync( + r["stop_loss"], r["take_profit"], exchange_tpsl + ) + if changed: + try: + conn.execute( + "UPDATE order_monitors SET stop_loss=?, take_profit=? WHERE id=?", + (new_sl, new_tp, int(r["id"])), + ) + except Exception: + pass + order_prices.append(payload) + + orphan_live_positions = list_orphan_live_positions(conn) if exchange_private_api_configured() else [] + + try: + conn.commit() + except Exception: + pass + conn.close() + + from lib.hub.hub_position_metrics import build_position_marks_list + + position_marks = build_position_marks_list( + all_swap_positions, + format_mark_display=lambda sym, px: format_price_for_symbol(sym, px), + ) + + return jsonify({ + "updated_at": app_now_str(), + "key_prices": key_prices, + "order_prices": order_prices, + "position_marks": position_marks, + "positions_raw_count": len(all_swap_positions), + "orphan_live_positions": orphan_live_positions, + **force_close_template_context( + FORCE_CLOSE_ENABLED, + FORCE_CLOSE_BJ_HOUR, + ), + }) + + +@app.route("/api/order//cancel_tpsl", methods=["POST"]) +@login_required +def api_order_cancel_tpsl(order_id): + from lib.trade.trade_policy_lib import is_intraday_trading_profile + + if is_intraday_trading_profile(TRADE_POLICY): + return jsonify({"ok": False, "msg": "日内纪律账户禁止撤销交易所止盈止损"}), 403 + 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) + prow = None + ex_metrics = None + if exchange_private_api_configured(): + try: + rows = exchange.fetch_positions([ex_sym]) or exchange.fetch_positions() or [] + prow = _select_live_position_row(rows, ex_sym, direction) + if prow: + ex_metrics = parse_ccxt_position_metrics(prow, order_leverage=row["leverage"]) + except Exception: + pass + from lib.trade.order_monitor_display_lib import enrich_active_monitor_tpsl_json + + ex_sym = resolve_monitor_exchange_symbol(row) + display_extra = enrich_active_monitor_tpsl_json( + row, + stop_loss, + take_profit, + slots, + position_row=prow, + exchange_notional=ex_metrics.get("notional") if ex_metrics else None, + contract_size=float(get_contract_size(ex_sym)) if ex_sym else 1.0, + mark_price=live_price, + calc_rr_ratio_fn=calc_rr_ratio, + format_price_fn=format_price_for_symbol, + symbol=symbol, + funds_decimals=FUNDS_DECIMALS, + ) + conn.close() + return jsonify( + { + "ok": True, + "msg": "已先撤后挂止盈止损", + "stop_loss": stop_loss, + "take_profit": take_profit, + "planned_rr": planned_rr, + "exchange_tpsl": slots, + **display_extra, + } + ) + + +@app.route("/api/orphan_live_positions") +@login_required +def api_orphan_live_positions(): + conn = get_db() + orphans = list_orphan_live_positions(conn) + conn.close() + return jsonify({"ok": True, "orphan_live_positions": orphans}) + + +@app.route("/api/recover_live_position", methods=["POST"]) +@login_required +def api_recover_live_position(): + data = request.get_json(silent=True) or {} + monitor_id = data.get("monitor_id") + if monitor_id is not None: + try: + monitor_id = int(monitor_id) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "monitor_id 无效"}), 400 + place_tpsl = data.get("place_tpsl", True) + if isinstance(place_tpsl, str): + place_tpsl = place_tpsl.lower() not in ("0", "false", "no") + conn = get_db() + ok, msg, oid = recover_live_position_monitor(conn, monitor_id=monitor_id, place_tpsl=bool(place_tpsl)) + conn.close() + if not ok: + return jsonify({"ok": False, "msg": msg}), 400 + return jsonify({"ok": True, "msg": msg, "monitor_id": oid}) + + +@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]), + }) + + from lib.instance.focus_chart_lib import ( + build_order_kline_order_payload, + load_swap_positions_for_order_kline, + metrics_for_order_item, + ) + + current_price = get_price(order_item["symbol"]) + positions = load_swap_positions_for_order_kline( + exchange, + private_configured=exchange_private_api_configured(), + ensure_markets_fn=ensure_markets_loaded, + ) + ex_metrics = metrics_for_order_item( + order_item, + positions, + resolve_ex_sym_fn=resolve_monitor_exchange_symbol, + select_live_fn=_select_live_position_row, + parse_metrics_fn=parse_ccxt_position_metrics, + ) + order_payload = build_order_kline_order_payload( + order_item, + ticker_price=current_price, + format_price_fn=format_price_for_symbol, + calc_pnl_fn=calc_pnl, + calc_rr_ratio_fn=calc_rr_ratio, + ex_metrics=ex_metrics, + ) + + from lib.instance.focus_chart_lib import kline_api_price_fields + + price_fields = kline_api_price_fields( + exchange, + exchange_symbol, + candles, + ensure_markets_fn=ensure_markets_loaded, + ) + + return jsonify({ + "ok": True, + "timeframe": timeframe, + "limit": limit, + "order": order_payload, + "candles": candles, + "updated_at": app_now_str(), + **price_fields, + }) + + +@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 = default_symbol_for_policy( + TRADE_POLICY, + 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, + trade_policy=trade_policy_template_context(TRADE_POLICY), + ) + + +@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, + } + + from lib.instance.focus_chart_lib import enrich_key_kline_response + + price_display, key_info = enrich_key_kline_response( + symbol=symbol, + current_price=current_price, + key_info=key_info, + format_price_fn=format_price_for_symbol, + ) + + from lib.instance.focus_chart_lib import kline_api_price_fields + + price_fields = kline_api_price_fields( + exchange, + exchange_symbol, + candles, + ensure_markets_fn=ensure_markets_loaded, + ) + + 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": price_display, + "key_monitor": key_info, + "candles": candles, + "updated_at": app_now_str(), + **price_fields, + }) + + +@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") + ok_sym, sym_msg = check_symbol_policy( + TRADE_POLICY, symbol, normalize_symbol_input + ) + if not ok_sym: + flash(sym_msg) + return redirect("/key_monitor") + mt = (d.get("type") or "").strip() + direction_sel = (d.get("direction") or "").strip().lower() + dup_msg = check_duplicate_submit( + session, submit_scope_add_key(symbol, mt, direction_sel or "watch") + ) + if dup_msg: + flash(dup_msg) + return redirect("/key_monitor") + if mt in KEY_MONITOR_RS_TYPES: + direction_sel = KEY_DIRECTION_WATCH + mt = KEY_MONITOR_RS_TYPE + elif direction_sel not in ("long", "short"): + flash("箱体/收敛突破请选择做多或做空") + return redirect("/key_monitor") + ok_dir, dir_msg = check_direction_policy(TRADE_POLICY, direction_sel) + if not ok_dir: + flash(dir_msg) + return redirect("/key_monitor") + allowed_types = ( + tuple(KEY_MONITOR_AUTO_TYPES) + + tuple(KEY_MONITOR_ALERT_ONLY_TYPES) + + tuple(FIB_KEY_MONITOR_TYPES) + + (FALSE_BREAKOUT_MONITOR_TYPE,) + + tuple(TRIGGER_ENTRY_MONITOR_TYPES) + ) + if mt not in allowed_types: + flash("监控类型无效") + return redirect("/key_monitor") + ok_mt, mt_msg = check_monitor_type_add_allowed( + mt, POSITION_SIZING_MODE, KEY_AUTO_ORDER_ENABLED + ) + if not ok_mt: + flash(mt_msg) + return redirect("/key_monitor") + skip_volume_rank = is_false_breakout_key_monitor_type(mt) + rank, total = None, None + if not skip_volume_rank: + 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 + be_flag = parse_breakeven_enabled_form(d.get("breakeven_enabled")) + tc_en = parse_time_close_enabled_form(d.get("time_close_enabled")) + tc_h = parse_time_close_hours_form(d.get("time_close_hours")) if tc_en else None + if tc_en and not tc_h: + tc_en = 0 + if is_trigger_entry_key_monitor_type(mt): + if direction_sel not in ("long", "short"): + conn.close() + conn = None + flash("触价请选择做多或做空") + return redirect("/key_monitor") + try: + entry_px = float(d.get("trigger_entry") or 0) + sl_px = float(d.get("trigger_sl") or 0) + tp_px = float(d.get("trigger_tp") or 0) + except (TypeError, ValueError): + entry_px = sl_px = tp_px = 0 + if entry_px <= 0 or sl_px <= 0 or tp_px <= 0: + conn.close() + conn = None + flash("触价须填写有效的入场价,止损价,止盈价") + return redirect("/key_monitor") + ok_te, err_te = _add_trigger_entry_key_monitor( + conn, + symbol, + direction_sel, + entry_px, + sl_px, + tp_px, + monitor_type=mt, + breakeven_enabled=be_flag, + time_close_enabled=tc_en, + time_close_hours=tc_h, + ) + conn.commit() + conn.close() + conn = None + if not ok_te: + flash(err_te or "触价开仓监控添加失败") + return redirect("/key_monitor") + trigger_hint = ( + "标记价穿越入场价后立即市价开仓" + if is_breakout_trigger_entry_key_monitor_type(mt) + else "标记价回调触达入场价后下一轮询市价开仓" + ) + flash( + f"{mt}已添加({symbol} 日成交量排名 {rank}/{total})" + f"|有效期 {TRIGGER_ENTRY_VALIDITY_HOURS}h" + f"|{trigger_hint}" + f"|移动保本:{'开' if be_flag else '关'}" + + (f"|{time_close_label(tc_h)}" if tc_en else "") + ) + return redirect("/key_monitor") + if is_false_breakout_key_monitor_type(mt): + fb_sym = normalize_false_breakout_symbol(symbol) + if not fb_sym: + conn.close() + flash("假突破仅支持 BTC / ETH") + return redirect("/key_monitor") + symbol = fb_sym + if direction_sel not in ("long", "short"): + conn.close() + flash("假突破请选择做多或做空") + return redirect("/key_monitor") + try: + key_px = float(d.get("key_price") or 0) + except (TypeError, ValueError): + key_px = 0 + if key_px <= 0: + conn.close() + flash("请填写关键价位(做空填高点,做多填低点)") + return redirect("/key_monitor") + ex_sym_key = normalize_exchange_symbol(symbol) + key_adj = round_price_to_exchange(ex_sym_key, key_px) + key_px = float(key_adj) if key_adj is not None else float(key_px) + try: + upper_px, lower_px = storage_bounds_from_key_price(direction_sel, key_px) + except ValueError as e: + conn.close() + flash(str(e)) + return redirect("/key_monitor") + ok_fb, err_fb = _add_false_breakout_key_monitor( + conn, symbol, direction_sel, upper_px, lower_px, key_px, breakeven_enabled=be_flag, + ) + conn.commit() + conn.close() + if not ok_fb: + flash(err_fb or "假突破监控添加失败") + return redirect("/key_monitor") + flash( + f"假突破监控已添加,限价单已挂出({symbol})" + f"|有效期 {FALSE_BREAKOUT_VALIDITY_HOURS}h|移动保本:{'开' if be_flag else '关'}" + ) + return redirect("/key_monitor") + 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"]) + if upper_px <= lower_px: + conn.close() + flash("上沿必须大于下沿") + return redirect("/key_monitor") + 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) + if mt in KEY_MONITOR_RS_TYPES: + conn.execute( + "INSERT INTO key_monitors " + "(symbol,monitor_type,direction,upper,lower,sl_tp_mode,manual_take_profit,breakeven_enabled," + "max_notify,notify_interval_min) " + "VALUES (?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + mt, + direction_sel, + upper_px, + lower_px, + sl_tp_mode, + manual_tp, + be_flag, + KEY_ALERT_MAX_TIMES, + KEY_ALERT_INTERVAL_MINUTES, + ), + ) + else: + 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 '关'}" + if mt in KEY_MONITOR_RS_TYPES: + flash( + f"添加成功({symbol} 日成交量排名 {rank}/{total})|关键支撑阻力:双向监控上/下沿," + f"5m 收盘突破后微信提醒 {KEY_ALERT_MAX_TIMES} 次(间隔 {KEY_ALERT_INTERVAL_MINUTES} 分钟)" + ) + 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_pol, pol_msg = validate_trade_policy_open(symbol, direction) + if not ok_pol: + conn.close() + flash(f"账户限制:{pol_msg}") + return redirect("/trade") + dup_msg = check_duplicate_submit(session, submit_scope_add_order(symbol, direction)) + if dup_msg: + conn.close() + flash(dup_msg) + return redirect("/trade") + ok, reason = precheck_risk(conn, symbol, direction) + if not ok: + 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) + 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, entry_model, style_err = parse_manual_order_style_fields( + TRADE_POLICY, d, default_trade_style=DEFAULT_TRADE_STYLE or "trend" + ) + if style_err: + conn.close() + flash(style_err) + return redirect("/trade") + available_usdt = get_available_trading_usdt() + live_price = get_price(symbol) + if live_price is None: + conn.close() + flash("获取交易所实时价格失败,请稍后重试") + return redirect("/") + sltp_mode = normalize_open_sltp_mode(d.get("sltp_mode")) + try: + stop_loss, take_profit = resolve_open_sltp_prices( + direction, live_price, sltp_mode, d + ) + except ValueError as e: + conn.close() + flash(str(e) or "止盈止损参数错误") + 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) + if is_full_margin_mode(POSITION_SIZING_MODE): + ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn)) + if not ok_flat: + conn.close() + flash(flat_msg) + return redirect("/") + leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE) + sizing, sizing_err = compute_full_margin_sizing( + symbol=symbol, + available_usdt=available_usdt if available_usdt is not None else 0.0, + capital_base=capital_base, + buffer_ratio=FULL_MARGIN_BUFFER_RATIO, + btc_leverage=BTC_LEVERAGE, + alt_leverage=ALT_LEVERAGE, + funds_decimals=FUNDS_DECIMALS, + ) + if sizing_err: + conn.close() + flash(sizing_err) + return redirect("/") + margin_capital = sizing["margin_capital"] + notional_value = sizing["notional_value"] + position_ratio = sizing["position_ratio"] + else: + 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("/") + 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 + risk_percent_db = risk_percent_for_storage(POSITION_SIZING_MODE, risk_percent) + risk_display = format_risk_display_text( + POSITION_SIZING_MODE, risk_percent, risk_amount_final, decimals=FUNDS_DECIMALS + ) + 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 + tc_en = parse_time_close_enabled_form(d.get("time_close_enabled")) + tc_h = parse_time_close_hours_form(d.get("time_close_hours")) if tc_en else None + if tc_en and not tc_h: + tc_en = 0 + tc_en, tc_h, tc_at = time_close_insert_values(tc_en, tc_h, opened_at_ms) + conn.execute( + "INSERT INTO order_monitors (symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, margin_capital, leverage, trade_style, entry_model, 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, time_close_enabled, time_close_hours, time_close_at_ms) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, exchange_symbol, direction, trigger_price, stop_loss, stop_loss, take_profit, + margin_capital, leverage, trade_style, entry_model, risk_percent_db, 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, + tc_en, tc_h, tc_at, + ) + ) + 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_display}", + "📊 仓位配置详情", + 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}", + format_daily_open_counter_line( + opens_today_after, DAILY_OPEN_ALERT_THRESHOLD, DAILY_OPEN_HARD_LIMIT + ), + ] + if chart_url: + wx_lines.append(f"多周期K线图:{chart_url}") + send_wechat_msg("\n".join(wx_lines)) + + flash_lines = [ + f"实盘开单成功:风格 {trade_style};风险 {risk_display};基数 {margin_capital}U,杠杆 {leverage}x,名义仓位 {notional_value}U,仓位占比 {position_ratio}%,合约数量 {amount}(折算标的 {base_amount})," + f"计划RR {planned_rr if planned_rr is not None else '-'};已在交易所挂条件止盈/止损委托(非仓位绑定型)", + format_daily_open_summary_short( + opens_today_after, DAILY_OPEN_ALERT_THRESHOLD, DAILY_OPEN_HARD_LIMIT + ), + ] + if chart_url: + flash_lines.append(f"已生成多周期K线图:{chart_url}") + flash(" ".join(flash_lines)) + + if should_send_daily_open_alert( + opens_today_before, opens_today_after, DAILY_OPEN_ALERT_THRESHOLD + ): + advice = ai_short_advice( + build_daily_open_alert_prompt( + trading_day, + opens_today_after, + DAILY_OPEN_ALERT_THRESHOLD, + hard_limit=DAILY_OPEN_HARD_LIMIT, + detail_line=f"最新一笔:{symbol} {direction},杠杆{leverage}x,基数{margin_capital}U.", + ) + ) + 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_limit_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_limit_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 " + f"FROM trade_records WHERE {sql_list_time_field('closed_at', 'created_at', 'opened_at')} >= ? " + f"AND {sql_list_time_field('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 = format_entry_type_display( + er1 or er0 or entry_reason_from_key_signal(kst) or "", + entry_model=r["entry_model"] if "entry_model" in r.keys() else None, + trade_style=r["trade_style"] if "trade_style" in r.keys() else None, + ) + 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,images_json,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", + "images_json", + "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=trade_record_monitor_type(conn, row), + trend_plan_id=trend_plan_id_from_monitor_row(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"], + entry_model=(row["entry_model"] if "entry_model" in row.keys() else None), + 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=handoff_trade_miss_reason("用户手动删除订单触发平仓", row), + opened_at=opened_at, + closed_at=closed_at, + ) + from lib.trade.account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close + + on_user_initiated_close( + conn, + source=CLOSE_SOURCE_USER_INSTANCE, + trade_record_id=insert_trade_record_id(conn), + closed_at_ms=_to_ms_with_fallback(closed_at_ms, closed_at), + trading_day=session_date, + now=app_now(), + ) + conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, id)) + try: + _rcfg = app.extensions.get("strategy_roll_cfg") + if isinstance(_rcfg, dict): + from lib.strategy.strategy_register import roll_sync_after_external_close + + roll_sync_after_external_close(_rcfg, conn, row["symbol"], row["direction"]) + except Exception: + pass + 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=trade_record_monitor_type(conn, row), + trend_plan_id=trend_plan_id_from_monitor_row(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"], + entry_model=(row["entry_model"] if "entry_model" in row.keys() else None), + 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=handoff_trade_miss_reason(miss_reason, row), + opened_at=opened_at, + closed_at=closed_at, + ) + from lib.trade.account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close + + on_user_initiated_close( + conn, + source=CLOSE_SOURCE_USER_INSTANCE, + trade_record_id=insert_trade_record_id(conn), + closed_at_ms=_to_ms_with_fallback(None, closed_at), + trading_day=session_date, + now=app_now(), + ) + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (id,)) + try: + _rcfg = app.extensions.get("strategy_roll_cfg") + if isinstance(_rcfg, dict): + from lib.strategy.strategy_register import roll_sync_after_external_close + + roll_sync_after_external_close(_rcfg, conn, row["symbol"], row["direction"]) + except Exception: + pass + 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_journal", methods=["POST"]) +@login_required +def add_journal(): + d = request.form + order_type_norm = normalize_journal_order_type(d.get("order_type")) + if not order_type_norm: + flash("请选择下单类型") + return _redirect_records() + direction_norm = normalize_journal_direction(d.get("direction") or d.get("direction_hint")) + if not direction_norm: + flash("请选择方向") + return _redirect_records() + entry_reason_norm = normalize_journal_entry_reason( + d.get("entry_reason"), ENTRY_REASON_OPTIONS, allow_legacy=False + ) + 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) + entry_id = normalize_journal_draft_id(d.get("journal_draft_id")) or uuid.uuid4().hex + manual_images = collect_journal_slot_images( + d, + request.files, + entry_id, + app.config["UPLOAD_FOLDER"], + secure_filename_fn=secure_filename, + ) + images_json_str = images_json_dumps(manual_images) + image_filename = primary_journal_image(manual_images) + has_manual_uploads = bool(manual_images) + + 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):.2f}" + except Exception: + pass + + want_exchange_chart = ( + not has_manual_uploads + and 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]}" + journal_tfs = parse_journal_chart_timeframes( + d.get("journal_chart_tf1"), + d.get("journal_chart_tf2"), + ORDER_CHART_TFS[:2] if ORDER_CHART_TFS else None, + ) + journal_limit = parse_journal_chart_limit(d.get("journal_chart_limit"), ORDER_CHART_LIMIT) + chart_anchor = parse_journal_chart_anchor(d.get("journal_chart_anchor")) + 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": d.get("exit_price_hint"), + "stop_loss_price": d.get("stop_loss_hint"), + "chart_anchor": chart_anchor, + "now_ts_ms": int(app_now().timestamp() * 1000), + } + try: + chart_fname = f"journal_{entry_id}.png" + saved = generate_multi_timeframe_chart_png( + exchange_symbol, + title_prefix, + timeframes=journal_tfs, + limit=journal_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 journal_tfs}, + layout="vertical", + ) + if saved: + image_filename = saved + chart_msg = f"已生成复盘K线图({'/'.join(journal_tfs)} 各{journal_limit}根):/static/images/{saved}" + else: + chart_msg = "已勾选自动生成K线图,但生成失败(返回空).请检查 Pillow 是否安装,Binance 网络/代理是否正常." + except Exception as e: + chart_msg = f"自动生成K线图失败:{str(e)}" + + conn = get_db() + conn.execute( + """INSERT INTO journal_entries + (id, open_datetime, close_datetime, hold_duration, coin, tf, direction, pnl, order_type, 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, images_json) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + entry_id, + normalize_bj_datetime_storage(d.get("open_datetime")), + normalize_bj_datetime_storage(d.get("close_datetime")), + hold_duration, + d.get("coin"), + d.get("tf"), + direction_norm, + d.get("pnl"), order_type_norm, 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"), None, d.get("note"), image_filename, + images_json_str, + ) + ) + from lib.trade.account_risk_lib import on_journal_saved + + on_journal_saved( + conn, + early_exit_trigger=early_exit_trigger, + early_exit_note=early_exit_note, + mood_issues_raw=mood_issues, + trading_day=get_trading_day(), + now=app_now(), + ) + conn.commit() + conn.close() + if chart_msg: + flash(f"交易复盘记录已保存.{chart_msg}") + else: + flash("交易复盘记录已保存") + return _redirect_records() + + +@app.route("/api/journal_upload_slot", methods=["POST"]) +@login_required +def api_journal_upload_slot(): + payload, code = handle_journal_upload_slot( + request, + upload_folder=app.config["UPLOAD_FOLDER"], + secure_filename_fn=secure_filename, + ) + return jsonify(payload), code + + +from lib.instance.records_api_register import register_trade_records_api + +register_trade_records_api( + app, + login_required=login_required, + get_db=get_db, + list_window_from_request=_list_window_from_request, + utc_window_to_bj_sql_strings=utc_window_to_bj_sql_strings, + sql_list_time_field=sql_list_time_field, + to_effective_trade_dict=to_effective_trade_dict, + filter_trade_records_excluding_miss=filter_trade_records_excluding_miss, + app_tz=APP_TZ, +) + +from lib.instance.instance_dashboard_register import register_instance_dashboard_routes + +register_instance_dashboard_routes( + app, + login_required=login_required, + get_db=get_db, + hedge_enabled=False, +) + + +@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() + j_ts = sql_list_time_field("close_datetime", "created_at", "open_datetime") + rows = conn.execute( + f"SELECT * FROM journal_entries WHERE {j_ts} >= ? AND {j_ts} <= ? ORDER BY created_at DESC LIMIT 500", + (start_bj, end_bj), + ).fetchall() + conn.close() + result = [] + for r in rows: + item = enrich_journal_api_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("/delete_journal/", methods=["POST"]) +@login_required +def delete_journal(jid): + conn = get_db() + row = conn.execute( + "SELECT image, images_json FROM journal_entries WHERE id=?", + (jid,), + ).fetchone() + if row: + for img_path in journal_image_paths(row, app.config["UPLOAD_FOLDER"]): + try: + if os.path.exists(img_path): + os.remove(img_path) + except Exception: + pass + 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_sql, end_sql = utc_window_to_utc_sql_strings(win["start_utc"], win["end_utc"]) + conn = get_db() + rows = conn.execute( + "SELECT * FROM ai_reviews WHERE created_at >= ? AND created_at <= ? ORDER BY created_at DESC LIMIT 200", + (start_sql, end_sql), + ).fetchall() + conn.close() + return jsonify([row_to_dict(r) for r in rows]) + + +_REPO_STATIC_DIR = common_static_dir(os.path.dirname(BASE_DIR)) +_AI_REVIEW_RENDER_JS = os.path.join(_REPO_STATIC_DIR, "ai_review_render.js") +_FORM_SUBMIT_GUARD_JS = os.path.join(_REPO_STATIC_DIR, "form_submit_guard.js") +_MANUAL_ORDER_RR_PREVIEW_JS = os.path.join(_REPO_STATIC_DIR, "manual_order_rr_preview.js") + + +@app.route("/static/ai_review_render.js") +def static_ai_review_render_js(): + if not os.path.isfile(_AI_REVIEW_RENDER_JS): + return Response("not found", status=404, mimetype="text/plain; charset=utf-8") + return send_file(_AI_REVIEW_RENDER_JS, mimetype="application/javascript; charset=utf-8") + + +@app.route("/static/form_submit_guard.js") +def static_form_submit_guard_js(): + if not os.path.isfile(_FORM_SUBMIT_GUARD_JS): + return Response("not found", status=404, mimetype="text/plain; charset=utf-8") + return send_file(_FORM_SUBMIT_GUARD_JS, mimetype="application/javascript; charset=utf-8") + + +@app.route("/static/manual_order_rr_preview.js") +def static_manual_order_rr_preview_js(): + if not os.path.isfile(_MANUAL_ORDER_RR_PREVIEW_JS): + return Response("not found", status=404, mimetype="text/plain; charset=utf-8") + return send_file(_MANUAL_ORDER_RR_PREVIEW_JS, mimetype="application/javascript; charset=utf-8") + + +@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": "结果仅允许:" + "/".join(REVIEW_RESULT_OPTIONS)}), 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": "开仓类型须为下拉选项之一或留空"}), 400 + reviewed_entry_reason_update = normalize_entry_reason(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]), + ) + if reviewed_result == "手动平仓" and reviewed_miss_reason: + from lib.trade.account_risk_lib import apply_manual_close_journal_cooloff + + apply_manual_close_journal_cooloff( + conn, + early_exit_note=reviewed_miss_reason, + trading_day=get_trading_day(), + now=app_now(), + ) + 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("/settings") + 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("/settings") + + +def _journal_ai_chart_builder(row): + return build_journal_ai_chart_path( + row, + app.config["UPLOAD_FOLDER"], + order_chart_enabled=ORDER_CHART_ENABLED, + normalize_exchange_symbol_fn=lambda c: normalize_exchange_symbol(normalize_symbol_input(c)), + generate_chart_fn=generate_multi_timeframe_chart_png, + local_datetime_to_ms_fn=_local_input_datetime_to_ms, + now_ts_ms_fn=lambda: int(app_now().timestamp() * 1000), + ) + + +@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 = collect_images_for_ai_review( + rows, + app.config["UPLOAD_FOLDER"], + build_chart_if_missing=_journal_ai_chart_builder, + ) + 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 = collect_images_for_ai_review( + rows, + app.config["UPLOAD_FOLDER"], + build_chart_if_missing=_journal_ai_chart_builder, + ) + 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}) + +def _hub_meta_bundle(): + return { + "exchange_display": EXCHANGE_DISPLAY_NAME, + "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}" + ), + "manual_min_planned_rr": MANUAL_MIN_PLANNED_RR, + "max_active_positions": MAX_ACTIVE_POSITIONS, + "btc_leverage": BTC_LEVERAGE, + "alt_leverage": ALT_LEVERAGE, + "trade_policy": trade_policy_template_context(TRADE_POLICY), + **hub_meta_entry_context(TRADE_POLICY), + } + + +def _hub_account_bundle(): + funding_capital, trading_capital = get_exchange_capitals(force=True) + funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None + trading_usdt = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else None + available = get_available_trading_usdt() + return { + "funding_usdt": funding_usdt, + "trading_usdt": trading_usdt, + "available_trading_usdt": round(available, FUNDS_DECIMALS) if available is not None else None, + "trading_day": get_trading_day(app_now()), + } + + +def _hub_fetch_market(base=""): + from lib.hub.hub_market_info_lib import fetch_usdt_swap_market_info + + return fetch_usdt_swap_market_info( + base_or_symbol=base, + normalize_symbol_input=normalize_symbol_input, + normalize_exchange_symbol=normalize_exchange_symbol, + ensure_markets_loaded=ensure_markets_loaded, + exchange=exchange, + exchange_id="binance", + ) + + +def _hub_fetch_ohlcv(symbol, timeframe, since_ms=None, limit=500): + from lib.hub.hub_ohlcv_lib import fetch_ohlcv_for_hub + + return fetch_ohlcv_for_hub( + symbol=symbol, + timeframe=timeframe, + since_ms=since_ms, + limit=limit, + normalize_symbol_input=normalize_symbol_input, + normalize_exchange_symbol=normalize_exchange_symbol, + ensure_markets_loaded=ensure_markets_loaded, + exchange=exchange, + friendly_error=friendly_exchange_error, + ) + + +def _hub_fetch_volume_rank(top_n=20): + from lib.hub.hub_volume_rank_lib import fetch_usdt_swap_volume_rank + + return fetch_usdt_swap_volume_rank( + exchange=exchange, + ensure_markets_loaded=ensure_markets_loaded, + top_n=top_n, + exchange_id="binance", + ) + + +try: + import sys + from pathlib import Path + + _repo_root = Path(__file__).resolve().parent.parent + if str(_repo_root) not in sys.path: + sys.path.insert(0, str(_repo_root)) + from lib.hub.hub_bridge import install_on_app + + install_on_app( + app, + exchange="binance", + capabilities=["order", "key"], + has_trend=True, + get_db=get_db, + row_to_dict=row_to_dict, + meta_fn=_hub_meta_bundle, + account_fn=_hub_account_bundle, + views={"add_order": add_order, "add_key": add_key}, + ohlcv_fn=_hub_fetch_ohlcv, + volume_rank_fn=_hub_fetch_volume_rank, + market_fn=_hub_fetch_market, + reconcile_hub_flat_fn=reconcile_hub_external_close, + risk_status_fn=hub_account_risk_status, + user_close_fn=hub_user_initiated_close, + render_main_page_fn=render_main_page, + login_required_fn=login_required, + ) +except Exception as _hub_err: + print(f"[hub_bridge] binance: {_hub_err}") + +try: + from lib.instance.instance_settings_register import register_instance_settings_routes + + register_instance_settings_routes( + app, + get_db=get_db, + login_required_fn=login_required, + base_dir=BASE_DIR, + exchange_key="binance", + username=USERNAME, + password=PASSWORD, + ) +except Exception as _settings_err: + print(f"[instance_settings] binance: {_settings_err}") + + +@app.route("/strategy") +@login_required +def strategy_trading_page(): + return render_main_page("strategy") + + +@app.route("/strategy/trend") +@login_required +def strategy_trend_page(): + qs = request.query_string.decode() + return redirect(f"/strategy?{qs}" if qs else "/strategy") + + +@app.route("/strategy/roll") +@login_required +def strategy_roll_page(): + return redirect("/strategy") + + +from lib.strategy.strategy_register import install_strategy_trading +from lib.strategy.strategy_trend_register import install_strategy_trend + +install_strategy_trading(app, _REPO_ROOT, app_module=sys.modules[__name__]) +install_strategy_trend(app, _REPO_ROOT, app_module=sys.modules[__name__]) + +_purge_key_monitors_if_full_margin() + + +# 启动 +if __name__ == "__main__": + from lib.common.flask_access_log_lib import silence_werkzeug_access_log + + silence_werkzeug_access_log() + threading.Thread(target=background_task, daemon=True).start() + app.run(host=HOST, port=PORT, debug=DEBUG, threaded=True) diff --git a/crypto_monitor_binance/ecosystem.config.cjs b/crypto_monitor_binance/ecosystem.config.cjs new file mode 100644 index 0000000..1e65906 --- /dev/null +++ b/crypto_monitor_binance/ecosystem.config.cjs @@ -0,0 +1,34 @@ +/** + * PM2 进程定义(Ubuntu / Linux). + * + * 仅托管 Flask 应用.**SSH SOCKS 隧道**用 `ssh -D` 常驻(可用 tmux / autossh),勿交给 PM2. + * 与 `.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 REPO_ROOT = path.join(ROOT, ".."); +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", + env: { PYTHONPATH: REPO_ROOT }, + }, + ], +}; 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..97a910a --- /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..3f239f1 --- /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..9668455 --- /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..f966267 --- /dev/null +++ b/crypto_monitor_binance/scripts/verify_binance_funding.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +""" + python scripts/verify_binance_funding.py + +打印 BINANCE_API_KEY 前 8 位便于与 Binance 控制台核对(不含 Secret).用于服务器自检. +对比 App:资产 → 资金账户(Funding) / 现货账户(Spot) / U本位合约. +""" +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)") + print("BINANCE_FUNDING_INCLUDE_SPOT:", os.getenv("BINANCE_FUNDING_INCLUDE_SPOT", "false")) + + import app as mod # noqa: E402 + + mod.ensure_markets_loaded() + ccy = getattr(mod, "TRANSFER_CCY", "USDT") + + try: + raw = mod.exchange.sapiGetAssetWalletBalance({"quoteAsset": ccy}) + print(f"\n>>> sapi/v1/asset/wallet/balance (quoteAsset={ccy}):") + if isinstance(raw, list): + for row in raw: + if isinstance(row, dict): + print( + " ", + row.get("walletName"), + "activate=", + row.get("activate"), + "balance=", + row.get("balance"), + ) + else: + print(" ", raw) + except Exception as e: + print(">>> wallet/balance error:", e) + + try: + raw = mod.exchange.sapiPostAssetGetFundingAsset({"asset": ccy}) + print(f"\n>>> get-funding-asset (asset={ccy}):", raw) + except Exception as e: + print(">>> get-funding-asset error:", e) + + fu = mod._fetch_binance_funding_usdt() + print("\n>>> _fetch_binance_funding_usdt() (页顶资金账户) =", fu) + try: + fw = mod._fetch_binance_funding_usdt_from_wallet_overview() + print(">>> _fetch_binance_funding_usdt_from_wallet_overview() =", fw) + sp = mod._fetch_binance_spot_usdt_total() + print(">>> _fetch_binance_spot_usdt_total() =", sp) + 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(">>> balance fetch error:", e) + + +if __name__ == "__main__": + main() diff --git a/crypto_monitor_binance/static/icons/apple-touch-icon.png b/crypto_monitor_binance/static/icons/apple-touch-icon.png new file mode 100644 index 0000000..702fc96 Binary files /dev/null and b/crypto_monitor_binance/static/icons/apple-touch-icon.png differ diff --git a/crypto_monitor_binance/static/icons/favicon.ico b/crypto_monitor_binance/static/icons/favicon.ico new file mode 100644 index 0000000..26172c8 Binary files /dev/null and b/crypto_monitor_binance/static/icons/favicon.ico differ diff --git a/crypto_monitor_binance/static/icons/icon-16.png b/crypto_monitor_binance/static/icons/icon-16.png new file mode 100644 index 0000000..c623509 Binary files /dev/null and b/crypto_monitor_binance/static/icons/icon-16.png differ diff --git a/crypto_monitor_binance/static/icons/icon-192.png b/crypto_monitor_binance/static/icons/icon-192.png new file mode 100644 index 0000000..4cda818 Binary files /dev/null and b/crypto_monitor_binance/static/icons/icon-192.png differ diff --git a/crypto_monitor_binance/static/icons/icon-32.png b/crypto_monitor_binance/static/icons/icon-32.png new file mode 100644 index 0000000..c706b7d Binary files /dev/null and b/crypto_monitor_binance/static/icons/icon-32.png differ diff --git a/crypto_monitor_binance/static/icons/icon-512.png b/crypto_monitor_binance/static/icons/icon-512.png new file mode 100644 index 0000000..6f48a2b Binary files /dev/null and b/crypto_monitor_binance/static/icons/icon-512.png differ diff --git a/crypto_monitor_binance/static/icons/icon.svg b/crypto_monitor_binance/static/icons/icon.svg new file mode 100644 index 0000000..70a7d51 --- /dev/null +++ b/crypto_monitor_binance/static/icons/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crypto_monitor_binance/static/icons/manifest.webmanifest b/crypto_monitor_binance/static/icons/manifest.webmanifest new file mode 100644 index 0000000..1c3edbf --- /dev/null +++ b/crypto_monitor_binance/static/icons/manifest.webmanifest @@ -0,0 +1,23 @@ +{ + "name": "Binance 交易系统", + "short_name": "Binance 交易系统", + "description": "Binance 永续交易监控与复盘", + "start_url": "/", + "display": "standalone", + "background_color": "#0b0d14", + "theme_color": "#F0B90B", + "icons": [ + { + "src": "/static/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/static/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} 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/order_focus.html b/crypto_monitor_binance/templates/order_focus.html new file mode 100644 index 0000000..cb7c8df --- /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/使用说明.md b/crypto_monitor_binance/使用说明.md new file mode 100644 index 0000000..2dff3d7 --- /dev/null +++ b/crypto_monitor_binance/使用说明.md @@ -0,0 +1,139 @@ +# 使用说明 + +**本文件对应仓库:`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 节与专门文档). | +| **实盘下单监控** | 手工填止损/止盈,**以损定仓** 市价开单,挂上条件止盈止损,并在页面跟踪浮盈亏,保本逻辑等. | +| **交易记录 / 复盘** | 平仓结果,盈亏,错过的单等归档与导出;可选 **AI 复盘**(见仓库根 [AI复盘与模型配置说明.md](../AI复盘与模型配置说明.md)). | +| **策略交易** | 顶栏 `/strategy`:**趋势回调**(左)与 **顺势加仓**(右)左右并列;细则见 [策略交易说明.md](../策略交易说明.md). | +| **策略交易记录** | 顶栏 `/strategy/records`:趋势/顺势分两栏,可筛选,库内保留最近 100 条结束快照. | + +后台按 **`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`). | +| **AI 复盘** | 默认 `AI_PROVIDER=openai`,`OPENAI_API_BASE=https://op.bz121.com/v1`,`OPENAI_API_KEY`,`OPENAI_MODEL=gemma4:e4b`;或 `AI_PROVIDER=ollama` + `OLLAMA_API` / `AI_MODEL`.详见 [AI复盘与模型配置说明.md](../AI复盘与模型配置说明.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`** 登录. + +登录后顶栏:**关键位监控** | **实盘下单**(默认首页)| **策略交易**(`/strategy`,趋势回调 + 顺势加仓双栏)| **策略交易记录**(`/strategy/records`,最近 100 条结束快照)| **交易记录与复盘** | **统计分析**. + +--- + +## 4. 关键位监控(顶栏「关键位监控」→ `/key_monitor`) + +### 4.1 添加一条关键位 + +1. **币种**:如 `BTC` 或 `BTC/USDT`(会规范成内部符号). +2. **类型**(必选其一): + + | 类型 | 行为摘要 | + |------|----------| + | **箱体突破** | 通过门控且计划 RR 达标 → **自动市价开仓**(需 `LIVE_TRADING_ENABLED=true` 且无其他持仓占位).结案后本条从列表消失并记入历史. | + | **收敛突破** | 同上(自动开仓类). | + | **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**. | + | **关键支撑位** | 同上(仅提醒). | + | **回调触价开仓** | **不挂交易所限价**;标记价回调触达 E 后 **下一轮询市价开仓**(RR 门槛同 `KEY_AUTO_MIN_PLANNED_RR`);有效期 **24h** | + | **突破触价开仓** | **不挂交易所限价**;标记价 **穿越 E 立即市价开仓**;先触 SL/TP 侧失效;有效期 **24h** | + +3. **方向**:做多 / 做空(回调/突破触价,箱体 / 收敛 / 斐波必选;阻力/支撑不选). +4. **价位**:箱体/收敛/阻力/支撑填 **上沿 / 下沿**;触价填 **入场 E / 止损 SL / 止盈 TP**. + +**限制:** +活跃持仓数达到 **`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..4c76756 --- /dev/null +++ b/crypto_monitor_binance/关键位自动下单说明.md @@ -0,0 +1,192 @@ +# 关键位监控说明(自动开仓 + 人工盯盘) + +**适用:Gate / Binance / OKX 三所实例(共用 `lib/key_monitor/key_auto_order_lib.py`)** + +## 环境开关 `KEY_AUTO_ORDER_ENABLED`(默认 `false`) + +| 计仓模式 | 开关 | 关键位程序自动单 | +|----------|------|------------------| +| `risk`(以损定仓) | `false` | **全部关闭**(含触价);支撑/阻力微信提醒仍可用 | +| `risk` | `true` | 箱体/收敛/斐波/假突破/触价均可自动(旧行为) | +| `full_margin`(全仓) | `false` | 全部关闭(含触价) | +| `full_margin` | `true` | **仅触价**自动;箱体/斐波等仍禁止 | + +**不受本开关影响:** 人工实盘下单,关键支撑/阻力提醒,**顺势加仓**(`risk` 下),趋势回调(`risk` 下).全仓模式下策略自动仍禁止. + +修改 `.env` 后须 **重启 PM2**.复盘「开仓类型」与统计分段会随开关联动隐藏关键位选项. + +--- + +**适用:`crypto_monitor_binance`(Binance U 本位)** +Gate / OKX 见各自目录下同名文档;共享逻辑在 `lib/key_monitor/`. + +本文档与 `.env`,`check_key_monitors`,`add_key`,`_key_hard_checks`,`_process_key_rs_level_alert` 一致. + +--- + +## 一,监控类型总览 + +| 录入类型 | 录入时选方向 | 自动市价开仓 | 触发与结案 | +|----------|--------------|--------------|------------| +| **箱体突破** | **必选** 多/空 | **是**(门控 + RR) | 条件满足 → 开仓或 `rr_insufficient` / `exchange_failed` → **一次性删除** | +| **收敛突破** | **必选** 多/空 | **是**(同上) | 同上 | +| **关键阻力位** | **不选**(`direction=watch`) | **否** | 5m 收盘突破上/下沿 → 微信 **3 次** → `key_level_alert_done` | +| **关键支撑位** | **不选** | **否** | 同上(与阻力位**相同规则**:填上沿+下沿,程序双向监控) | +| 斐波回调 0.618 / 0.786 | 必选 | 限价挂单逻辑 | 见斐波说明(**不在下文展开**) | +| **回调触价开仓** | **必选** 多/空 | **程序盯价 → 回调触 E 后市价** | 见下文 **§四** | +| **突破触价开仓** | **必选** 多/空 | **程序盯价 → 穿越 E 立即市价** | 见下文 **§四** | + +**添加时(箱体/收敛/斐波/触价):** 品种须 **日成交量排名前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30)**;上沿 **>** 下沿(触价开仓填 E/SL/TP,上下沿仅作展示占位). + +--- + +## 二,关键阻力位 / 关键支撑位(人工盯盘) + +### 2.1 录入 + +- 填写 **上沿 `upper`** 与 **下沿 `lower`**(程序同时监控两侧,**无法预先判定**做多还是做空). +- 页面 **不显示,不要求** 方向;库中 `direction` 初始为 `watch`,**首次突破后** 写入 `long`(向上突破上沿)或 `short`(向下突破下沿). + +### 2.2 触发(极简) + +- 周期:**`KLINE_TIMEFRAME`(默认 5m)最近一根已闭合 K** 的 **收盘价**(非影线). +- **向上突破上沿:** `收盘 > upper` → 推断方向 **多 / 向上**,本次监控任务开始按节奏提醒. +- **向下突破下沿:** `收盘 < lower` → 推断方向 **空 / 向下**,本次任务同样开始提醒. +- **任一侧突破即结束本条监控周期**(不会在突破后再等待另一侧;上沿,下沿谁先满足用谁,同根 K 仅可能满足一侧). + +**不参与:** 量能,二确 K,越过幅度下限,日成交排名(运行时),计划 RR,自动开仓. + +### 2.3 微信提醒次数 + +| 配置 | 默认 | 含义 | +|------|------|------| +| `KEY_ALERT_MAX_TIMES` | `3` | 突破后最多推送 3 次 | +| `KEY_ALERT_INTERVAL_MINUTES` | `5` | 相邻两次推送至少间隔 5 分钟 | + +- 第 1 次:首次检测到突破的当次轮询(若已闭合 5m 满足条件). +- 第 2,3 次:仅按间隔推送(**不要求**价格仍在箱外). +- 第 3 次推送后:写入 `key_monitor_history`,`close_reason=**key_level_alert_done**`,从 `key_monitors` **删除**. + +### 2.4 与箱体/收敛的区别 + +| 项目 | 阻力/支撑 | 箱体/收敛 | +|------|-----------|-----------| +| 方向 | 程序推断 | 人工选择 | +| K 线根数 | 1 根闭合 5m | 2 根(突破 K + 确认 K) | +| 提醒次数 | 3 次后结案 | 自动单:触发后 1 次业务推送并结案 | + +--- + +## 三,箱体突破 / 收敛突破(自动开仓) + +### 3.1 K 线结构(默认索引) + +| 角色 | 环境变量 | 默认 | 含义 | +|------|----------|------|------| +| 突破 K | `KEY_CONFIRM_BREAKOUT_BAR` | `-2` | 倒数第 2 根闭合 K | +| 确认 K | `KEY_CONFIRM_BAR` | `-1` | 倒数第 1 根闭合 K | + +### 3.2 硬门控(须全部通过) + +1. **有效突破(收盘越界)** + - 多:`突破 K 收盘 > upper` + - 空:`突破 K 收盘 < lower` + +2. **突破越过幅度(仅下限)** + - 多:`(突破 K 收盘 − upper) / upper × 100 > KEY_BREAKOUT_AMP_MIN_PCT`(默认 **0.03%**) + - 空:`(lower − 突破 K 收盘) / lower × 100 >` 同上 + - **无上限**;突破过猛由 **计划 RR** 过滤. + - **不再**使用 K 线实体占开盘价比例;`KEY_BREAKOUT_AMP_MAX_PCT` **已不参与门控**. + +3. **确认 K 不进箱体** + - 多:确认 K 收盘 **`> upper`**(不得在 `[lower, upper]` 内) + - 空:确认 K 收盘 **`< lower`** + +4. **量能:** 突破 K 成交量 > 前 `KEY_VOLUME_MA_BARS`(默认 20)根均量 × `KEY_VOLUME_RATIO_MIN`(默认 1.3) + +5. **日成交量排名:** 运行时仍须前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30) + +6. **计划 RR(最后经济门控):** 按确认 K 收盘 **E** 计算 SL/TP 后,`RR` **严格大于** `KEY_AUTO_MIN_PLANNED_RR`(默认 1.5)才市价开仓 + +### 3.3 止损 / 止盈(确认 K 收盘为 E) + +箱体高 **H = |upper − lower|**.止损锚在 **突破 K 极值** 外侧: + +| 方向 | 止损(标准/趋势方案) | +|------|------------------------| +| 多 | 突破 K **最低价** × (1 − `KEY_STOP_OUTSIDE_BREAKOUT_PCT`%) | +| 空 | 突破 K **最高价** × (1 + `KEY_STOP_OUTSIDE_BREAKOUT_PCT`%) | + +止盈方案见下表(与改版前一致): + +| 方案 | `sl_tp_mode` | 多:SL / TP | 空:SL / TP | +|------|--------------|-------------|-------------| +| 标准突破 | `standard` | 突破 K 低外侧% / **E+H** | 突破 K 高外侧% / **E−H** | +| 箱体 1R·止盈 1.5H | `box_1p5` | **E−H** / **E+1.5×H** | **E+H** / **E−1.5×H** | +| 趋势单·自填止盈 | `trend_manual` | 突破 K 低 × (1−`KEY_TREND_STOP_OUTSIDE_PCT`%) / **录入止盈** | 突破 K 高外侧% / **录入止盈** | + +### 3.4 一次性结案(`close_reason`) + +| `close_reason` | 含义 | +|----------------|------| +| `box_opposite_break` | 标记价先突破反向边界(多:≤下沿;空:≥上沿) | +| `rr_insufficient` | 门控通过但 RR 不达标或 SL/TP 几何无效 | +| `exchange_failed` | RR 达标但实盘/交易所等原因未开仓 | +| `auto_opened` | RR 达标且市价开仓成功 | +| `key_level_alert_done` | 阻力/支撑 **3 次提醒** 完成 | + +--- + +## 四,回调 / 突破触价开仓(程序触价,无交易所挂单) + +### 4.1 录入 + +- **回调触价开仓**:方向必选多/空;填写 **计划入场价 E**,**止损 SL**,**止盈 TP**(做多须 `SL < E < TP`). +- **突破触价开仓**:同上;添加时当前价须在突破方向一侧(做多:价低于 E;做空:价高于 E). +- 计划 RR 以 **E** 为基准,须 **严格大于** `KEY_AUTO_MIN_PLANNED_RR`(默认 1.5). +- 可选移动保本,时间平仓;**全仓杠杆模式**下可用. + +### 4.2 触发与结案 + +| 类型 | 触发条件(标记价) | +|------|-------------------| +| **回调触价** | 做多 `≤ E`;做空 `≥ E` → 下一轮询市价开仓 | +| **突破触价** | 做多**向上穿越** E;做空**向下穿越** E → **立即**市价开仓 | + +- 未成交前标记价先触 **TP 侧** → `trigger_tp_invalidate`. +- **突破触价**另:未穿越 E 先触 **SL 侧** → `trigger_sl_invalidate`. +- **24h** 未触发 → `trigger_entry_expired`. +- 成功 → `trigger_entry_filled`;触发后开仓失败 → `trigger_exchange_failed`. + +### 4.3 计仓与占位 + +- **以损定仓**:按 E,SL 反推保证金,触发时重算;**全仓杠杆**:可用×缓冲比例,BTC/ETH 10x,其它 5x. +- **占当日开仓意图**(已开 + 待触发),未成交不占持仓;同币仅 1 条触价监控(含回调/突破). + +共享逻辑:`trigger_entry_key_monitor_lib.py`;轮询:`check_trigger_entry_key_monitors`. + +--- + +## 五,环境与参数(`.env` 摘要) + +| 变量 | 箱体/收敛 | 阻力/支撑 | +|------|-----------|-----------| +| `KEY_BREAKOUT_AMP_MIN_PCT` | 突破越过下限(默认 0.03) | 不用 | +| `KEY_BREAKOUT_AMP_MAX_PCT` | **已废弃门控** | 不用 | +| `KEY_VOLUME_*` / `KEY_CONFIRM_*` | 用 | 不用 | +| `KEY_AUTO_MIN_PLANNED_RR` | 用 | 不用 | +| `KEY_ALERT_MAX_TIMES` / `KEY_ALERT_INTERVAL_MINUTES` | 不用 | 用(默认 3 次 / 5 分钟) | +| `KEY_DAILY_VOLUME_RANK_MAX` | 添加时 + 运行时 | **仅添加时** | + +--- + +## 六,相关代码 + +| 说明 | 位置 | +|------|------| +| 共享判定 | `key_monitor_lib.py` | +| 主循环 | `check_key_monitors` | +| 自动门控 | `_key_hard_checks` | +| 阻力/支撑提醒 | `_process_key_rs_level_alert` | +| 录入 | `add_key` | +| 开仓 | `_market_open_for_key_monitor` | diff --git a/crypto_monitor_binance/更新文档.md b/crypto_monitor_binance/更新文档.md new file mode 100644 index 0000000..5289238 --- /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.4 节(自动备份) + +## 数据库(启动时自动迁移) + +`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 / **各执行一次** `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..d7edba2 --- /dev/null +++ b/crypto_monitor_binance/部署文档.md @@ -0,0 +1,389 @@ +# `crypto_monitor_binance` 部署指南:SSH SOCKS + Binance + PM2(Ubuntu) + +项目功能,环境变量总览见 **[README.md](./README.md)**.Ubuntu 环境(Python / Node / PM2)见 **[docs/ubuntu-server.md](../docs/ubuntu-server.md)**. + +本文面向:**在本机或 VPS 上运行本项目**,但 **直连 Binance API 不稳定,超时或被网络策略拦截** 的场景.思路是: + +- 本机用 `ssh -D` 做动态转发,把 **SOCKS5 出口**放到能稳定访问 Binance 的机器(常见为一台境外 VPS) +- 项目在 `.env` 中设置 **`BINANCE_SOCKS_PROXY=socks5h://127.0.0.1:1080`**(或你实际端口),`ccxt` 经 SOCKS 访问交易所 +- **SSH 隧道**:用 `ssh -D` 在本机常驻(可用 **tmux** 或 **autossh** 保持连接),**不要** 把 `ssh` 交给 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_user +cd /opt/crypto_monitor_user +git clone https://git.bz121.com/dekun/crypto_monitor_user.git +cd crypto_monitor/crypto_monitor_binance +``` + +下文用 **`/opt/crypto_monitor_user/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_user/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_user/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 AI 复盘与模型(可选) + +三所共用仓库根目录 **`ai_client.py`**(PM2 的 **`PYTHONPATH=..`** 须包含仓库根).在 `.env` 中配置 **`AI_PROVIDER`**: + +| 模式 | 主要变量 | +|------|----------| +| **`openai`**(默认) | `OPENAI_API_BASE=https://op.bz121.com/v1`,`OPENAI_API_KEY`,`OPENAI_MODEL=gemma4:e4b` | +| **`ollama`** | `OLLAMA_API`,`AI_MODEL`(本机 Ollama) | + +密钥在 [op.bz121.com](https://op.bz121.com/) 的 **`gateway.json`** 页面获取.改 `.env` 后需 **`pm2 restart`** 对应进程.详见根目录 **[AI复盘与模型配置说明.md](../AI复盘与模型配置说明.md)**. + +### 5.4 自动备份(数据库 + 复盘图片) + +默认每天 **北京时间 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_user/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_user/crypto_monitor_gate +bash scripts/install_backup_cron.sh +``` + +实例(趋势回调等): + +```bash +cd /opt/crypto_monitor_user/crypto_monitor_gate +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.5 必填项检查(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_user/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_user/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_user/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) + +示例(前台调试;生产请用 **PM2**,见本文 §6 与 [docs/ubuntu-server.md](../docs/ubuntu-server.md)): + +```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_user/crypto_monitor_binance +pm2 start /opt/crypto_monitor_user/crypto_monitor_binance/.venv/bin/python --name crypto-monitor-binance -- \ + /opt/crypto_monitor_user/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..b6371fb --- /dev/null +++ b/crypto_monitor_gate/.env.example @@ -0,0 +1,233 @@ +# ============================================================================= +# 环境配置模板(可提交 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=admin +# 登录密码(请改成你自己的强密码) +APP_PASSWORD=admin123 +# 是否关闭登录校验(局域网可设 true;公网务必 false) +APP_AUTH_DISABLED=true +# --- 多账户交易中控 manual_trading_hub --- +# 中控请求本实例 /api/hub/* 时携带请求头 X-Hub-Token,须与中控启动环境变量 HUB_BRIDGE_TOKEN 一致 +# 未设置且 APP_AUTH_DISABLED=false 时,仅网页登录后可访问;本机联调可保持 APP_AUTH_DISABLED=true +# HUB_BRIDGE_TOKEN=your-long-random-token +# Flask 会话密钥(必须替换为长随机字符串) +FLASK_SECRET_KEY=CHANGE_TO_LONG_RANDOM_SECRET + +# 企业微信机器人 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 +# 计仓:risk=以损定仓(默认);full_margin=合约可用×FULL_MARGIN_BUFFER_RATIO 全仓杠杆(须无仓后重启) +POSITION_SIZING_MODE=risk +# 方向限制(默认 false=双向均可;true 时按 TRADE_DIRECTION 限制,修改后须重启) +# TRADE_DIRECTION=long_only | short_only | both(或 多/空/双向) +TRADE_DIRECTION_RESTRICT_ENABLED=false +TRADE_DIRECTION=both +# 币种白名单(默认 false=全币种可手输;true 时关键位/下单/策略仅下拉选择) +TRADE_SYMBOL_RESTRICT_ENABLED=false +TRADE_SYMBOL_WHITELIST=BTC,ETH +# 每天起始基数(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 +# 仓位类 TP/SL 相对现价的最小间距(%),避免 Gate 1026「触发价须高于/低于现价」 +GATE_TPSL_LAST_PRICE_GAP_PCT=0.05 +# 页面与浏览器标签展示的交易所名称(多环境区分时可改成例如 Gate·模拟) +EXCHANGE_DISPLAY_NAME=Gate.io + +# ============================================================================= +# 关键位程序自动下单(与 POSITION_SIZING_MODE 联动,修改后须重启 PM2) +# ============================================================================= +# 默认 false = 关闭所有关键位程序自动单(箱体/收敛/斐波/假突破/触价) +# +# POSITION_SIZING_MODE=risk(以损定仓) +# false → 不执行任何关键位自动单;支撑/阻力提醒,人工下单,顺势加仓不受影响 +# true → 允许关键位全套自动(含触价) +# +# POSITION_SIZING_MODE=full_margin(全仓杠杆,须无仓切换) +# false → 不执行触价自动单 +# true → 仅回调/突破触价可程序自动开仓;箱体/斐波等仍禁止 +# +# 顺势加仓,趋势回调不受本开关控制;全仓模式下策略自动仍禁止. +KEY_AUTO_ORDER_ENABLED=false + +# ============================================================================= +# 关键位门控(页面「关键位监控」规则条与 _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实体幅度】占开盘价百分比区间 +# 【箱体/收敛】突破K收盘越过关键位下限%;无上限(过猛由计划RR过滤) +KEY_BREAKOUT_AMP_MIN_PCT=0.03 +KEY_BREAKOUT_AMP_MAX_PCT=0.5 +# 【阻力/支撑】突破后微信提醒 +KEY_ALERT_MAX_TIMES=3 +KEY_ALERT_INTERVAL_MINUTES=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 +# 【单日开仓 AI 提醒】本交易日开仓达到该次数时推送企业微信 AI 克制提醒(不拦单) +DAILY_OPEN_ALERT_THRESHOLD=5 +# 【单日开仓硬上限】本交易日开仓次数>=该值后禁止一切新开仓直至下一交易日(北京时间 TRADING_DAY_RESET_HOUR 切日);0=不启用 +DAILY_OPEN_HARD_LIMIT=0 + +# ============================================================================= +# 账户冷静期 / 日冻结风控(手动平仓,外部平仓,复盘情绪标签) +# 详见 docs/account-risk-cooldown.md +# ============================================================================= +RISK_CONTROL_ENABLED=true +RISK_COOLING_HOURS_MANUAL=4 +RISK_COOLING_HOURS_MANUAL_JOURNAL=1 +RISK_MANUAL_CLOSE_DAILY_LIMIT=2 +RISK_MOOD_ISSUES_DAILY_FREEZE=true + +# 资金与仓位刷新周期(秒) +BALANCE_REFRESH_SECONDS=60 +# 前端价格快照轮询(秒) +PRICE_REFRESH_SECONDS=5 +# 后台监控轮询周期(秒) +MONITOR_POLL_SECONDS=3 +# 重启后多少秒内不做「外部平仓」同步(避免 API 未就绪误判) +RECONCILE_STARTUP_GRACE_SEC=90 +# 连续多少次轮询确认交易所空仓后,才记为外部平仓(默认 3 次 ≈ 9 秒) +RECONCILE_FLAT_CONFIRM_POLLS=3 +# 使用可用资金时的缓冲比例(如0.98代表用98%) +FULL_MARGIN_BUFFER_RATIO=0.98 + +# ============================================================================= +# 自动划转(页顶「将 swap 补足到 XU」;与 DAILY_START_CAPITAL 独立,需一致时请设为相同值) +# ============================================================================= +AUTO_TRANSFER_ENABLED=false +# 交易账户(swap)目标余额 U:每日 8 点(北京)自动划入或划出至 funding;持仓中不划转 +AUTO_TRANSFER_AMOUNT=30 +AUTO_TRANSFER_FROM=funding +AUTO_TRANSFER_TO=swap +TRANSFER_CCY=USDT +# 北京时间该整点小时内尝试;账簿按 UTC 自然日去重 +AUTO_TRANSFER_BJ_HOUR=8 +# 强制清仓整点(北京时间,默认 0=凌晨00点) +FORCE_CLOSE_BJ_HOUR=0 +# 是否启用强制清仓(默认关闭,true 才会在整点执行) +FORCE_CLOSE_ENABLED=false + +# 推送与AI超时(秒) +WECHAT_TIMEOUT_SECONDS=10 +AI_TIMEOUT_SECONDS=120 + +# AI 提供方:openai(默认)| ollama +AI_PROVIDER=openai +OPENAI_API_BASE=https://op.bz121.com/v1 +OPENAI_API_KEY=你的密钥 +OPENAI_MODEL=gemma4:e4b +OLLAMA_API=http://127.0.0.1:11434/api/generate +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 / DAILY_OPEN_HARD_LIMIT;说明文档 docs/daily-open-limit.md +# 以损定仓(按交易账户资金的百分比) +# 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 +# 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..e74af0a --- /dev/null +++ b/crypto_monitor_gate/README.md @@ -0,0 +1,90 @@ +# 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 +cd /opt/crypto_monitor_user/crypto_monitor_gate +source .venv/bin/activate +pip install -r ../requirements.txt +``` + +## 配置(`.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` 顶部默认值**. + +## 运行 + +生产使用 **PM2**(`ecosystem.config.cjs`).调试: + +```bash +source .venv/bin/activate && python app.py +``` + +见 [docs/ubuntu-server.md](../docs/ubuntu-server.md). + +端口由 **`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..fe54bdd --- /dev/null +++ b/crypto_monitor_gate/app.py @@ -0,0 +1,9907 @@ +from flask import Flask, render_template, request, redirect, url_for, flash, session, jsonify, Response, send_file +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 lib.paths import common_static_dir +from lib.ai.ai_client import ai_generate, ai_review, ai_short_advice +from lib.ai.ai_review_lib import ( + build_journal_ai_chart_path, + collect_images_for_ai_review, + journal_row_lines_for_ai, +) +from lib.common.form_submit_lib import check_duplicate_submit, submit_scope_add_key, submit_scope_add_order +from lib.key_monitor.fib_key_monitor_lib import ( + FIB_KEY_MONITOR_TYPES, + KEY_ENTRY_REASON_BY_SIGNAL, + backfill_missing_key_signal_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 lib.key_monitor.false_breakout_key_monitor_lib import ( + FALSE_BREAKOUT_MONITOR_TYPE, + FALSE_BREAKOUT_VALIDITY_HOURS, + calc_false_breakout_plan, + expires_at_text, + false_breakout_gate_preview, + is_false_breakout_expired, + is_false_breakout_key_monitor_type, + is_limit_key_monitor_type, + key_price_from_row, + normalize_false_breakout_symbol, + storage_bounds_from_key_price, +) +from lib.strategy.strategy_trade_labels import ( + JOURNAL_ORDER_TYPE_OPTIONS, + STRATEGY_ENTRY_REASON_OPTIONS, + apply_order_monitor_source_labels, + entry_reason_for_monitor_type, + handoff_trade_miss_reason, + normalize_journal_order_type, + order_monitor_source_type, + trade_record_monitor_type as resolve_trade_record_monitor_type, + trend_plan_id_from_monitor_row, +) +from lib.instance.journal_form_lib import normalize_journal_direction, normalize_journal_entry_reason +from lib.instance.journal_images_lib import ( + collect_journal_slot_images, + enrich_journal_api_item, + images_json_dumps, + journal_image_paths, + normalize_journal_draft_id, + primary_journal_image, +) +from lib.instance.journal_upload_api_lib import handle_journal_upload_slot +from lib.instance.journal_chart_lib import ( + JOURNAL_CHART_DEFAULT_LIMIT, + JOURNAL_CHART_DEFAULT_TF1, + JOURNAL_CHART_DEFAULT_TF2, + JOURNAL_CHART_TF_CHOICES, + compose_chart_panels, + marker_points_for_timeframe, + parse_journal_chart_anchor, + parse_journal_chart_limit, + parse_journal_chart_timeframes, + JOURNAL_CHART_DEFAULT_ANCHOR, + price_levels_from_marker_payload, + render_candles_subplot, + trade_review_fetch_window, + trim_rows_for_trade_review, +) +from lib.key_monitor.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 lib.trade.time_close_lib import ( + TIME_CLOSE_RESULT, + apply_time_close_to_payload, + ensure_time_close_schema, + parse_time_close_enabled_form, + parse_time_close_hours_form, + should_trigger_time_close, + time_close_insert_values, + time_close_label, + time_close_settings_from_row, +) +from lib.trade.force_close_lib import ( + apply_force_close_to_payload, + apply_force_close_display_result, + coerce_force_close_result, + enrich_orders_force_close, + force_close_template_context, +) +from lib.trade.manual_sltp_lib import ( + normalize_open_sltp_mode, + resolve_entrust_sltp_prices, + resolve_open_sltp_prices, +) +from lib.key_monitor.key_monitor_schema_lib import ensure_key_monitor_schema +from lib.key_monitor.trigger_entry_key_monitor_lib import ( + BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE, + CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE, + TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED, + TRIGGER_ENTRY_CLOSE_EXPIRED, + TRIGGER_ENTRY_CLOSE_FILLED, + TRIGGER_ENTRY_CLOSE_SL_INVALIDATE, + TRIGGER_ENTRY_CLOSE_TP_INVALIDATE, + TRIGGER_ENTRY_MONITOR_TYPE, + TRIGGER_ENTRY_MONITOR_TYPES, + TRIGGER_ENTRY_VALIDITY_HOURS, + check_trigger_entry_intent_limit, + count_pending_trigger_entries, + acquire_trigger_entry_exec_lock, + is_trigger_entry_in_flight_row, + release_trigger_entry_exec_lock, + is_breakout_trigger_entry_key_monitor_type, + is_trigger_entry_expired, + is_trigger_entry_key_monitor_type, + trigger_entry_expires_at_text, + trigger_entry_gate_preview, + trigger_entry_invalidate, + trigger_should_fire, + validate_trigger_entry_geometry, + validate_trigger_entry_rr, +) +from lib.trade.position_sizing_lib import ( + OPEN_SOURCE_KEY_AUTO, + OPEN_SOURCE_KEY_TRIGGER, + OPEN_SOURCE_MANUAL, + assert_open_source_allowed, + compute_full_margin_sizing, + format_risk_display_text, + full_margin_requires_flat_position, + is_full_margin_mode, + leverage_for_full_margin, + load_position_sizing_mode, + mode_label_zh, + risk_percent_for_storage, +) +from lib.trade.trade_policy_lib import load_trade_policy +from lib.trade.entry_model_lib import ( + build_intraday_entry_reason_options, + build_trend_div_entry_reason_options, + enrich_entry_model_display, + hub_meta_entry_context, + migrate_entry_model_columns, + order_entry_template_context, + open_position_button_label, + parse_manual_order_style_fields, + resolve_effective_trade_entry_reason, + format_entry_type_display, + resolve_trade_record_entry_reason, + trend_manual_entry_reason_count, +) +from lib.trade.trade_policy_app_lib import ( + check_direction_policy, + check_open_policy, + check_symbol_policy, + default_symbol_for_policy, + trade_policy_template_context, +) +from lib.key_monitor.key_auto_order_lib import ( + KEY_ENTRY_REASON_OPTIONS, + check_monitor_type_add_allowed, + effective_entry_reason_options, + effective_stats_segment_defs, + load_key_auto_order_enabled, +) +from lib.key_monitor.key_monitor_full_margin_lib import ( + monitor_type_disallowed_in_full_margin, + purge_disallowed_key_monitors, +) +from lib.common.auto_transfer_daily_lib import run_auto_transfer_once_per_day +from lib.key_monitor.key_monitor_lib import ( + KEY_DIRECTION_WATCH, + KEY_MONITOR_ALERT_ONLY_TYPES, + KEY_MONITOR_AUTO_TYPES, + KEY_MONITOR_RS_TYPE, + KEY_MONITOR_RS_TYPES, + auto_amp_ok, + auto_confirm_ok, + box_breakout_invalidate_by_mark, + box_breakout_invalidate_edge_label, + claim_rs_level_notify, + detect_rs_box_break, + format_auto_amp_line, + format_auto_confirm_line, + key_monitor_rule_template_context, + notify_interval_elapsed, + resolve_rs_break_for_alert, + rs_break_from_direction, + run_rs_level_alert_tick, +) +from lib.trade.order_monitor_display_lib import ( + apply_order_price_display_fields, + enrich_order_display_fields, + order_monitor_tpsl_needs_sync, + stale_breakeven_armed, +) +from lib.common.wechat_notify_lib import build_wechat_rs_level_message, send_wechat_webhook +from lib.hub.hub_auth import request_allowed as hub_request_allowed +from lib.instance.instance_nav_lib import request_is_hub_soft_nav +from lib.hub.hub_volume_rank_lib import resolve_daily_volume_rank +from lib.common.history_window_lib import ( + PRESET_ALL, + PRESET_CUSTOM, + PRESET_DEFAULT, + PRESET_UTC_LAST24H, + PRESET_UTC_LAST3M, + PRESET_UTC_LAST6M, + PRESET_UTC_LAST7D, + PRESET_UTC_THIS_MONTH, + PRESET_UTC_TODAY, + list_window_redirect_query, + normalize_bj_datetime_storage, + resolve_list_window, + resolve_window, + sql_list_time_field, + utc_window_to_bj_sql_strings, + utc_window_to_utc_sql_strings, +) +from lib.trade.trade_result_lib import ( + count_winning_trades, + filter_trade_records_excluding_miss, + normalize_result_with_pnl, +) +from lib.trade.trade_exchange_stats_lib import attach_exchange_stats_to_trade, filter_position_lifecycle_fills + + +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") +from lib.instance.instance_embed_lib import attach_embed_templates + +attach_embed_templates(app, _REPO_ROOT) + +# ====================== 登录配置 ====================== +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") +# 仓位类触发单相对 mark/last 的最小间距(%),避免 Gate 1026 AUTO_TRIGGER_PRICE_*_LAST +GATE_TPSL_LAST_PRICE_GAP_PCT = float(os.getenv("GATE_TPSL_LAST_PRICE_GAP_PCT", "0.05")) +# 页面展示的交易所名称(多实例/多环境时可按需区分) +EXCHANGE_DISPLAY_NAME = (os.getenv("EXCHANGE_DISPLAY_NAME") or "Gate.io").strip() or "Gate.io" +_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 / KEY_MONITOR_ALERT_ONLY_TYPES:见 key_monitor_lib +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")) +POSITION_SIZING_MODE = load_position_sizing_mode() +KEY_AUTO_ORDER_ENABLED = load_key_auto_order_enabled() +TRADE_POLICY = load_trade_policy() +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")) +RECONCILE_STARTUP_GRACE_SEC = int(os.getenv("RECONCILE_STARTUP_GRACE_SEC", "90")) +RECONCILE_FLAT_CONFIRM_POLLS = max(1, int(os.getenv("RECONCILE_FLAT_CONFIRM_POLLS", "3"))) +KLINE_TIMEFRAME = os.getenv("KLINE_TIMEFRAME", "5m") +_APP_STARTED_AT = time.time() +_RECONCILE_FLAT_STREAK = {} +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")) +from lib.trade.daily_open_limit_lib import ( + build_daily_open_alert_prompt, + can_trade_new_open, + check_daily_open_hard_limit, + count_opens_for_trading_day, + format_daily_open_counter_line, + format_daily_open_summary_short, + load_daily_open_limits_from_env, + should_send_daily_open_alert, +) + +DAILY_OPEN_ALERT_THRESHOLD, DAILY_OPEN_HARD_LIMIT = load_daily_open_limits_from_env() +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() +FUNDS_DECIMALS = 2 + +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 + +from lib.exchange.gate_ccxt_lib import gate_ccxt_class + +# Gate.io USDT 永续(swap) +exchange = gate_ccxt_class()({ + "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, + "version": 0, + "ranks": {}, + "total": 0, +} + +# 企业微信推送 +def send_wechat_msg(content): + send_wechat_webhook( + WECHAT_WEBHOOK, content, timeout=WECHAT_TIMEOUT_SECONDS + ) + + +_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 _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, + layout="grid", +): + if not ORDER_CHART_ENABLED: + return None + if not Image: + return None + requested = list(timeframes or ORDER_CHART_TFS) + limit = limit or ORDER_CHART_LIMIT + if layout == "vertical": + timeframes = requested[:2] if requested else [JOURNAL_CHART_DEFAULT_TF1, JOURNAL_CHART_DEFAULT_TF2] + else: + 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} + price_levels = price_levels_from_marker_payload(marker_payload) + for tf in timeframes: + rows = [] + try: + if layout == "vertical" and marker_payload: + win = trade_review_fetch_window( + marker_payload.get("entry_ts_ms"), + marker_payload.get("exit_ts_ms"), + tf, + limit, + anchor=marker_payload.get("chart_anchor"), + now_ms=marker_payload.get("now_ts_ms"), + ) + if win: + ohlcv = exchange.fetch_ohlcv( + exchange_symbol, + timeframe=tf, + since=max(0, int(win["since_ms"])), + limit=int(win["fetch_limit"]), + ) + rows = trim_rows_for_trade_review(_ohlcv_to_rows(ohlcv), win) + if not rows: + 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) + rows = _ohlcv_to_rows(ohlcv)[-limit:] + except Exception: + rows = [] + title = f"{title_prefix} | {tf} x{len(rows)}" + 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() + points = ( + marker_points_for_timeframe(rows, marker_payload) + if marker_payload and tf_key in marker_tfs + else [] + ) + panels.append( + render_candles_subplot( + rows, + title, + width=cell_w, + height=cell_h, + bg_rgb=(255, 255, 255), + marker_points=points, + price_levels=price_levels, + ) + ) + + if not panels: + return None + + out = compose_chart_panels(panels, layout=layout, cell_w=cell_w, cell_h=cell_h, gap=10) + if out is None: + return None + + 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 = ( + "", + "止盈", + "保本止盈", + "移动止盈", + TIME_CLOSE_RESULT, + "强制清仓", + "手动平仓", + "止损", + "其他", +) + +# 日内户:长句开仓类型 + 关键位 + 策略(大分歧 A/B/小分歧 仅趋势户) +ENTRY_REASON_OPTIONS = build_intraday_entry_reason_options( + KEY_ENTRY_REASON_OPTIONS, + STRATEGY_ENTRY_REASON_OPTIONS, +) + +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"}), + ("key_false_breakout", "关键位假突破", {"segment": "key_false_breakout"}), + ("key_trigger", "关键位触价开仓", {"segment": "key_trigger"}), +) +def normalize_entry_reason(raw, custom_text=None): + del custom_text + return normalize_journal_entry_reason(raw, ENTRY_REASON_OPTIONS, allow_legacy=True) + + +def entry_reason_valid_for_storage(s): + t = str(s or "").strip() + if not t: + return True + return bool(normalize_entry_reason(t)) + + +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 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( + """CREATE TABLE IF NOT EXISTS app_runtime_settings + (key TEXT PRIMARY KEY, value TEXT, + updated_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 journal_entries ADD COLUMN images_json TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN order_type TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN direction 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", + "ALTER TABLE key_monitors ADD COLUMN last_rs_bar_ts INTEGER", + "ALTER TABLE key_monitors ADD COLUMN session_date TEXT", + ): + try: + c.execute(ddl) + except Exception: + pass + ensure_time_close_schema(c) + ensure_key_monitor_schema(c) + 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", + "ALTER TABLE trade_records ADD COLUMN exchange_turnover_usdt REAL", + "ALTER TABLE trade_records ADD COLUMN exchange_commission_usdt REAL", + ): + 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)""" + ) + + from lib.strategy.strategy_db import init_strategy_tables + + init_strategy_tables(conn) + from lib.trade.account_risk_lib import ensure_account_risk_schema + + ensure_account_risk_schema(conn) + migrate_entry_model_columns(conn) + backfill_missing_key_signal_types(conn, monitor_type=ORDER_MONITOR_TYPE_KEY_AUTO) + conn.commit() + conn.close() + +init_db() + + +def _purge_key_monitors_if_full_margin(): + if not is_full_margin_mode(POSITION_SIZING_MODE): + return + conn = get_db() + try: + purge_disallowed_key_monitors( + conn, + sizing_mode=POSITION_SIZING_MODE, + select_rows=lambda c: c.execute("SELECT * FROM key_monitors").fetchall(), + cancel_fib_limit=_cancel_fib_monitor_limit, + delete_monitor=lambda c, kid: c.execute("DELETE FROM key_monitors WHERE id=?", (kid,)), + send_wechat=send_wechat_msg, + ) + conn.commit() + except Exception as e: + print(f"[full_margin] purge key monitors: {e}", flush=True) + finally: + conn.close() + + +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def hub_account_risk_status(conn): + from lib.trade.account_risk_lib import ( + apply_position_limit_risk, + compute_account_risk_status, + enrich_risk_status_countdown, + ensure_account_risk_schema, + ) + + ensure_account_risk_schema(conn) + now = app_now() + st = compute_account_risk_status( + conn, + trading_day=get_trading_day(), + now=now, + fmt_local_ms=ms_to_app_local_str, + ) + st = enrich_risk_status_countdown(st, now=now, daily_reset_hour=TRADING_DAY_RESET_HOUR) + from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors + + return apply_position_limit_risk( + st, + count_position_limit_active_monitors(conn), + max_active_positions=MAX_ACTIVE_POSITIONS, + ) + + +def hub_user_initiated_close( + conn, + *, + source, + count=1, + trade_record_id=None, + closed_at_ms=None, +): + from lib.trade.account_risk_lib import CLOSE_SOURCE_USER_HUB, on_user_initiated_close + + src = (source or "").strip() or CLOSE_SOURCE_USER_HUB + on_user_initiated_close( + conn, + source=src, + trade_record_id=trade_record_id, + closed_at_ms=closed_at_ms, + trading_day=get_trading_day(), + now=app_now(), + count=count, + ) + + +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 = ( + "止盈", + "止损", + "保本止盈", + "移动止盈", + "手动平仓", + "强制清仓", + "外部平仓", + TIME_CLOSE_RESULT, +) + +REVIEW_RESULT_OPTIONS = ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓", TIME_CLOSE_RESULT) + + +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_DEFAULT) + + +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" + if segment_key == "key_false_breakout": + return kst == FALSE_BREAKOUT_MONITOR_TYPE + if segment_key == "key_trigger": + return kst in TRIGGER_ENTRY_MONITOR_TYPES + 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", + "key_false_breakout": FALSE_BREAKOUT_MONITOR_TYPE, + "key_trigger": None, # 见 _count_opens_for_segment 多类型 + } + if segment_key == "key_trigger": + placeholders = ",".join("?" * len(TRIGGER_ENTRY_MONITOR_TYPES)) + return conn.execute( + f"SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ? " + f"AND key_signal_type IN ({placeholders})", + (start_td, end_td, *TRIGGER_ENTRY_MONITOR_TYPES), + ).fetchone()[0] + 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 = [] + seg_defs = effective_stats_segment_defs( + STATS_SEGMENT_DEFS, POSITION_SIZING_MODE, KEY_AUTO_ORDER_ENABLED + ) + for seg_key, seg_title, _meta in seg_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 时被误判空仓).""" + from lib.hub.hub_position_metrics import normalize_contracts_qty + + 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 normalize_contracts_qty(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 validate_trade_policy_open(symbol, direction): + return check_open_policy( + TRADE_POLICY, symbol, direction, normalize_symbol_input + ) + + +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) + from lib.trade.order_monitor_display_lib import snapshot_stop_loss + + open_stop = snapshot_stop_loss(item.get("initial_stop_loss"), item.get("stop_loss")) + item["display_open_stop_loss"] = open_stop + 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", 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")) + try: + _er_keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + _er_keys = [] + reviewed_er = row["reviewed_entry_reason"] if "reviewed_entry_reason" in _er_keys else None + item["effective_entry_reason"] = resolve_effective_trade_entry_reason( + reviewed_entry_reason=reviewed_er, + entry_reason=item.get("entry_reason"), + entry_model=item.get("entry_model"), + key_signal_type=(item.get("key_signal_type") or "").strip() or None, + monitor_type=item.get("monitor_type"), + trade_style=item.get("trade_style"), + entry_reason_from_key_signal=entry_reason_from_key_signal, + entry_reason_for_monitor_type=entry_reason_for_monitor_type, + ) + 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" + item["effective_result"] = normalize_result_with_pnl( + item.get("effective_result"), + item.get("effective_pnl_amount"), + ) + item["effective_result"] = apply_force_close_display_result( + item.get("effective_result"), + item.get("effective_closed_at"), + enabled=FORCE_CLOSE_ENABLED, + bj_hour=FORCE_CLOSE_BJ_HOUR, + ) + 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): + """估算净盈亏(USDT):价差毛利 − 双边 taker 费(默认各 0.05%).""" + 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 + notional = margin * lev + gross = notional * pnl_ratio + try: + from lib.trade.trade_fee_lib import net_pnl_after_fee + + net = net_pnl_after_fee(gross, trigger, exit_p, open_notional=notional) + return float(net) if net is not None else round(gross, 4) + except Exception: + return round(gross, 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, 2) + except Exception: + return None + + +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, + entry_model=None, + trend_plan_id=None, + exchange_symbol=None, + attach_exchange_stats=True, +): + 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) + from lib.trade.order_monitor_display_lib import snapshot_stop_loss + + snap_sl = snapshot_stop_loss(initial_stop_loss, stop_loss) + er = resolve_trade_record_entry_reason( + entry_reason=entry_reason, + entry_model=entry_model, + key_signal_type=kst, + monitor_type=monitor_type, + trade_style=trade_style, + entry_reason_from_key_signal=entry_reason_from_key_signal, + entry_reason_for_monitor_type=entry_reason_for_monitor_type, + ) + 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,trend_plan_id) 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, + trend_plan_id, + ) + ) + tid = int(cur.lastrowid or 0) + if attach_exchange_stats and tid: + ex_sym = (exchange_symbol or "").strip() or normalize_exchange_symbol(symbol) + _attach_gate_trade_exchange_stats( + conn, + tid, + exchange_symbol=ex_sym, + direction=direction, + opened_at_str=open_ts, + closed_at_str=close_ts, + opened_at_ms=open_ts_ms, + closed_at_ms=close_ts_ms, + ) + return tid + + +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 + enrich_order_display_fields(item, calc_rr_ratio) + enrich_entry_model_display(item) + 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 apply_order_monitor_source_labels(item, default_manual=ORDER_MONITOR_TYPE_MANUAL) + + +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): + return order_monitor_source_type(row, default_manual=ORDER_MONITOR_TYPE_MANUAL) + + +def trade_record_monitor_type(conn, row): + return resolve_trade_record_monitor_type( + conn, row, default_manual=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) or is_false_breakout_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): + from lib.exchange.gate_transfer_lib import execute_transfer_usdt as _gate_execute_transfer_usdt + + return _gate_execute_transfer_usdt( + exchange, + amount, + from_account, + to_account, + transfer_ccy=TRANSFER_CCY, + ensure_live_ready=ensure_exchange_live_ready, + ensure_markets_loaded=ensure_markets_loaded, + ) + + +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_active_count(conn): + from lib.exchange.gate_transfer_lib import count_auto_transfer_blockers + + return count_auto_transfer_blockers(conn, count_order_monitors=get_active_position_count) + + +def auto_transfer_once_per_day(): + run_auto_transfer_once_per_day( + enabled=AUTO_TRANSFER_ENABLED, + bj_hour=AUTO_TRANSFER_BJ_HOUR, + target_amount=AUTO_TRANSFER_AMOUNT, + from_account=AUTO_TRANSFER_FROM, + to_account=AUTO_TRANSFER_TO, + funds_decimals=2, + get_db=get_db, + get_active_position_count=_auto_transfer_active_count, + get_account_usdt_total=get_account_usdt_total, + execute_transfer_usdt=execute_transfer_usdt, + send_wechat_msg=send_wechat_msg, + utc_now_dt=utc_now_dt, + app_tz=APP_TZ, + utc_calendar_date_str=utc_calendar_date_str, + app_now_str=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() + from lib.trade.account_risk_lib import account_risk_blocks_trading + + ok_risk, risk_reason = account_risk_blocks_trading( + conn, + trading_day=get_trading_day(now), + now=now, + fmt_local_ms=ms_to_app_local_str, + ) + if not ok_risk: + return False, risk_reason + if not trading_day_reset_allows_new_open(now): + return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓" + from lib.trade.account_risk_lib import position_limit_reached + + reached, active_count, mx = position_limit_reached(conn, max_active_positions=MAX_ACTIVE_POSITIONS) + if reached: + return False, f"已达最大持仓数({active_count}/{mx})" + ok_daily, daily_reason, _opens = check_daily_open_hard_limit( + conn, get_trading_day(now), DAILY_OPEN_HARD_LIMIT, TRADING_DAY_RESET_HOUR + ) + if not ok_daily: + return False, daily_reason + 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_clamp_tpsl_to_last_price(exchange_symbol, direction, stop_loss, take_profit, *, sl_only=False): + """ + Gate price_orders 规则:空仓止损/多仓止盈 trigger>last;空仓止盈/多仓止损 trigger= last: + tp = float(exchange.price_to_precision(exchange_symbol, last * (1 - gap))) + notes.append(f"止盈触发价须低于现价 {last},已调整为 {tp}") + else: + if sl >= last: + sl = float(exchange.price_to_precision(exchange_symbol, last * (1 - gap))) + notes.append(f"止损触发价须低于现价 {last},已调整为 {sl}") + if not sl_only and tp <= last: + tp = float(exchange.price_to_precision(exchange_symbol, last * (1 + gap))) + notes.append(f"止盈触发价须高于现价 {last},已调整为 {tp}") + return sl, tp, (";".join(notes) if notes else None) + + +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 避免残留. + """ + stop_loss, take_profit, _ = _gate_clamp_tpsl_to_last_price( + exchange_symbol, direction, stop_loss, take_profit + ) + 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: + # 保留已挂止损,仅放弃本次 TP;上层可补偿平仓或重试 + raise + return + except Exception as e: + last_err = e + time.sleep(0.2 * (attempt + 1)) + raise RuntimeError(f"交易所未接受仓位类条件止盈/止损:{last_err}") + + +def _gate_td_mode_is_cross(): + return _GATE_DEFAULT_MARGIN_MODE == "cross" + + +def _gate_place_tp_sl_orders(exchange_symbol, direction, contracts_amount, stop_loss, take_profit): + pos_err = None + 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 as e: + pos_err = e + if _gate_td_mode_is_cross(): + raise RuntimeError( + f"交易所未接受仓位类条件止盈/止损(全仓不支持 ccxt 条件单回退):{pos_err}" + ) from e + try: + _gate_place_tp_sl_orders_legacy_conditional( + exchange_symbol, direction, contracts_amount, stop_loss, take_profit, + ) + except Exception as legacy_err: + if pos_err is not None: + raise RuntimeError( + f"交易所未接受仓位类条件止盈/止损:{pos_err};条件单回退亦失败:{legacy_err}" + ) from legacy_err + raise + + +def _gate_place_stop_loss_only_position(exchange_symbol, direction, stop_loss): + """Gate 永续:仅挂仓位类止损触发单(趋势回调用).""" + stop_loss, _, _ = _gate_clamp_tpsl_to_last_price( + exchange_symbol, direction, stop_loss, stop_loss, sl_only=True + ) + 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 + 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 calc_trend_manual_breakeven_stop(direction, entry_price, offset_pct=None): + try: + e = float(entry_price) + pct = float( + offset_pct + if offset_pct is not None + else float(os.getenv("TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT", "0.3")) + ) + 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 ensure_markets_loaded(force=False): + global MARKETS_LOADED + if force or not MARKETS_LOADED: + exchange.load_markets(reload=force) + MARKETS_LOADED = True + + +def _abort_market_open_after_tpsl_failure(exchange_symbol, direction, order, planned_amount): + """TP/SL 挂失败时市价平掉刚开的仓并撤残留条件单.""" + from lib.trade.compensating_close_lib import run_compensating_close + + def _close(): + ensure_markets_loaded() + try: + cancel_gate_swap_trigger_orders(exchange_symbol) + except Exception: + pass + live = get_live_position_contracts(exchange_symbol, direction) + amt = live if live is not None and live > 0 else _gate_contracts_amount_for_tpsl(order, planned_amount) + if amt is None or float(amt) <= 0: + return + side = "sell" if direction == "long" else "buy" + params = build_gate_order_params(direction, reduce_only=True) + exchange.create_order(exchange_symbol, "market", side, float(amt), None, params) + + run_compensating_close(_close, log_prefix="gate_compensating_close") + + +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: + _abort_market_open_after_tpsl_failure(exchange_symbol, direction, order, amount) + raise + except Exception as e: + _abort_market_open_after_tpsl_failure(exchange_symbol, direction, order, amount) + 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") + params = build_gate_order_params(direction, reduce_only=True) + last_resp = exchange.create_order(exchange_symbol, "market", side, amount, None, params) + 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 _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): + return resolve_entrust_sltp_prices(direction, live_price, sltp_mode, data) + + +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"] + sl, tp, adjust_note = _gate_clamp_tpsl_to_last_price( + ex_sym, direction, float(stop_loss), float(take_profit) + ) + 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, sl, tp) + + +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", + "empty position", "increase_position", + ] + return any(k in msg for k in keywords) + + +def _gate_fetch_position_rows(exchange_symbol): + """优先拉 USDT 本位全量持仓(与页面一致),避免单合约查询在重启后返回空列表误判空仓.""" + try: + ensure_markets_loaded() + except Exception: + return None + try: + return exchange.fetch_positions(None, {"settle": "usdt"}) or [] + except Exception: + pass + if not exchange_symbol: + return None + try: + return exchange.fetch_positions([exchange_symbol]) or [] + except Exception: + return None + + +def _sum_live_position_contracts(rows, exchange_symbol, direction, relax_direction=False): + total = 0.0 + if not rows: + return total + direction = (direction or "long").strip().lower() + 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_direction) and GATE_POS_MODE == "hedge": + info = p.get("info", {}) or {} + side = (p.get("side") or info.get("posSide") or "").lower() + if side and side != direction: + continue + total += contracts + return total + + +def get_live_position_contracts(exchange_symbol, direction): + rows = _gate_fetch_position_rows(exchange_symbol) + if rows is None: + return None + total = _sum_live_position_contracts(rows, exchange_symbol, direction, relax_direction=False) + if total <= 0 and GATE_POS_MODE == "hedge": + total = _sum_live_position_contracts(rows, exchange_symbol, direction, relax_direction=True) + 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) + if out: + sym = (p.get("symbol") or "").strip() + try: + cs = float(get_contract_size(sym)) if sym else 1.0 + except Exception: + cs = 1.0 + from lib.hub.hub_position_metrics import enrich_ccxt_position_metrics_out + + enrich_ccxt_position_metrics_out(p, out, contract_size=cs, funds_decimals=2) + 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) + if hit is not None: + return hit + except Exception: + pass + try: + from lib.exchange.gate_position_history_lib import pick_gate_position_close + + pos = pick_gate_position_close( + fetch_gate_positions_close_history(), + exchange_symbol, + direction, + opened_at_ms=since_ms, + ) + if pos: + return { + "price": None, + "timestamp": pos["close_ms"], + "side": close_side, + "_from_position_history": True, + "_realized_pnl": pos.get("pnl"), + "_sync_key": pos.get("sync_key"), + "_open_ms": pos.get("open_ms"), + } + except Exception: + pass + 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 fetch_all_position_fills_for_record( + exchange_symbol, direction, opened_at_str, closed_at_str=None, opened_at_ms=None, closed_at_ms=None +): + if not exchange_private_api_configured(): + return [] + ensure_markets_loaded() + since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) + 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 + 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 = [] + return filter_position_lifecycle_fills( + trades or [], + direction, + since_ms, + closed_ms, + hedge_mode=(GATE_POS_MODE == "hedge"), + close_buffer_ms=0, + ) + + +def _attach_gate_trade_exchange_stats( + conn, trade_id, *, exchange_symbol, direction, opened_at_str, closed_at_str, opened_at_ms=None, closed_at_ms=None +): + if not exchange_private_api_configured(): + return + open_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) + close_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str) + contract_size = 1.0 + try: + ensure_markets_loaded() + contract_size = float(exchange.market(exchange_symbol).get("contractSize") or 1) + except Exception: + pass + + def _fetch(): + return fetch_all_position_fills_for_record( + exchange_symbol, direction, opened_at_str, closed_at_str, opened_at_ms=open_ms, closed_at_ms=close_ms + ) + + try: + attach_exchange_stats_to_trade(conn, trade_id, fetch_fills=_fetch, contract_size=contract_size) + except Exception: + pass + + +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, *, prefer_manual=False): + """ + 交易所已无仓,本地仍为 active 时,推断平仓类型/时间/盈亏. + 返回 (result, pnl_amount, closed_at_str, miss_reason). + """ + + def _finish(result, pnl_amount, closed_at_str, miss_reason): + res, note = coerce_force_close_result( + result, + closed_at_str, + enabled=FORCE_CLOSE_ENABLED, + bj_hour=FORCE_CLOSE_BJ_HOUR, + miss_reason=miss_reason, + ) + return res, pnl_amount, closed_at_str, note + + 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 trade.get("_from_position_history"): + pnl_hist = trade.get("_realized_pnl") + if pnl_hist is not None: + note = "中控平仓后按 Gate 平仓历史同步盈亏" if prefer_manual else "按 Gate 平仓历史同步盈亏" + res = "手动平仓" if prefer_manual else "外部平仓" + return _finish(res, float(pnl_hist), closed_at_str, note) + + 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 _finish( + normalize_result_with_pnl(guessed, pnl), + pnl, + closed_at_str, + "未能拉取成交明细,按当前市价与止盈/止损位近似归类(建议核对交易所账单)", + ) + return _finish( + "外部平仓", + 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 prefer_manual: + return _finish( + "手动平仓", + pnl, + closed_at_str, + "中控平仓后按交易所成交记录同步", + ) + if result: + return _finish( + normalize_result_with_pnl(result, pnl), + pnl, + closed_at_str, + "按交易所成交记录同步为止盈/止损平仓", + ) + return _finish( + "外部平仓", + pnl, + closed_at_str, + "交易所已平仓,成交价不在计划止盈/止损带内(可能为手动或其他类型平仓)", + ) + + +def _finalize_hub_flat_monitor(conn, r, *, result, pnl_amount, closed_at, miss_reason): + opened_at = get_opened_at_value(r) + 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=trade_record_monitor_type(conn, r), + trend_plan_id=trend_plan_id_from_monitor_row(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"], + entry_model=(r["entry_model"] if "entry_model" in r.keys() else None), + 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=handoff_trade_miss_reason(miss_reason, r), + 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()) + + +def reconcile_hub_external_close(conn, symbol, direction): + """中控市价全平后:立即同步匹配 order_monitor,并读 Gate 平仓历史.""" + from lib.hub.hub_reconcile_flat_lib import reconcile_hub_external_close_impl + from lib.hub.hub_symbol_lib import symbols_match + + global _RECONCILE_FLAT_STREAK + + return reconcile_hub_external_close_impl( + conn, + symbol, + direction, + exchange_configured=exchange_private_api_configured, + not_configured_msg="未配置 GATE_API_KEY / GATE_API_SECRET", + symbols_match=symbols_match, + get_opened_at_value=get_opened_at_value, + resolve_monitor_exchange_symbol=resolve_monitor_exchange_symbol, + get_live_position_contracts=get_live_position_contracts, + cancel_conditional_orders=cancel_gate_swap_trigger_orders, + resolve_synced_flat_close=resolve_synced_flat_close, + finalize_stopped_monitor=_finalize_hub_flat_monitor, + sync_trade_records=sync_trade_records_from_exchange, + reconcile_flat_streak=_RECONCILE_FLAT_STREAK, + to_ms_with_fallback=_to_ms_with_fallback, + prefer_manual_resolve=True, + order_row_monitor_type=order_row_monitor_type, + ) + + +def reconcile_external_closes(conn, days=None): + global _RECONCILE_FLAT_STREAK + if not exchange_private_api_configured(): + return 0 + if time.time() - _APP_STARTED_AT < RECONCILE_STARTUP_GRACE_SEC: + return 0 + 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 IN ('active', 'error')" + ).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 + oid = int(r["id"]) + if r["status"] == "error": + opened_at_chk = get_opened_at_value(r) + existing = conn.execute( + "SELECT id FROM trade_records WHERE symbol=? AND opened_at=? AND monitor_type=? LIMIT 1", + (r["symbol"], opened_at_chk, order_row_monitor_type(r)), + ).fetchone() + if existing: + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (oid,)) + synced_count += 1 + continue + exchange_symbol = resolve_monitor_exchange_symbol(r) + live_contracts = get_live_position_contracts(exchange_symbol, r["direction"]) + if live_contracts is None: + _RECONCILE_FLAT_STREAK.pop(oid, None) + continue + if live_contracts > 0: + _RECONCILE_FLAT_STREAK.pop(oid, None) + continue + if r["status"] != "error": + streak = int(_RECONCILE_FLAT_STREAK.get(oid, 0)) + 1 + _RECONCILE_FLAT_STREAK[oid] = streak + if streak < RECONCILE_FLAT_CONFIRM_POLLS: + continue + _RECONCILE_FLAT_STREAK.pop(oid, None) + print( + f"[reconcile_external_closes] {r['symbol']} id={oid} " + f"flat x{streak} polls -> sync close" + ) + else: + _RECONCILE_FLAT_STREAK.pop(oid, None) + print( + f"[reconcile_external_closes] error recovery {r['symbol']} id={oid} flat -> sync close" + ) + 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=trade_record_monitor_type(conn, r), + trend_plan_id=trend_plan_id_from_monitor_row(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"], + entry_model=(r["entry_model"] if "entry_model" in r.keys() else None), + 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=handoff_trade_miss_reason(miss_reason, r), + 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),按 USDT 永续 24h 成交额降序. + 走 hub_volume_rank_lib 轻量 ticker API,避免 fetch_tickers() 全市场拉取. + """ + sym_norm = normalize_symbol_input(symbol) + target_base = journal_coin_from_symbol(sym_norm) + return resolve_daily_volume_rank( + target_base, + LIQUIDITY_RANK_CACHE, + now_ts=time.time(), + ttl_sec=max(30, BALANCE_REFRESH_SECONDS), + exchange=exchange, + ensure_markets_loaded=ensure_markets_loaded, + ) + + +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 + close_b = float(breakout[4]) + high_b = float(breakout[2]) + low_b = float(breakout[3]) + cfm_close = float(confirm[4]) + edge = float(upper) if direction == "long" else float(lower) + breakout_ok = (close_b > float(upper)) if direction == "long" else (close_b < float(lower)) + amp_ok, amp_pct = auto_amp_ok( + direction, close_b, float(upper), float(lower), KEY_BREAKOUT_AMP_MIN_PCT + ) + amp_ok = amp_ok and breakout_ok + confirm_ok_raw = auto_confirm_ok(direction, cfm_close, float(upper), float(lower)) + 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 _fetch_last_closed_bar(symbol): + """最近一根闭合 K:[ts, o, h, l, c, v] 或 None.""" + ex_sym = normalize_exchange_symbol(symbol) + bars = exchange.fetch_ohlcv(ex_sym, timeframe=KLINE_TIMEFRAME, limit=5) or [] + if len(bars) < 2: + return None + closed = bars[:-1] + return closed[-1] if closed else None + + +def _key_rs_gate_preview(symbol, upper, lower): + """页面门控预览:阻力/支撑仅显示距上/下沿与是否已越线.""" + bar = _fetch_last_closed_bar(symbol) + if not bar: + return {"summary": "5m数据不足", "metrics": ""} + close = float(bar[4]) + br = detect_rs_box_break(close, upper, lower) + if br: + return { + "summary": f"已越线:{br['break_label']}", + "metrics": f"收盘:{format_price_for_symbol(symbol, close)}", + } + return { + "summary": "待突破", + "metrics": f"收盘:{format_price_for_symbol(symbol, close)}", + } + + +def _process_key_rs_level_alert(conn, row): + """关键阻力位/支撑位:5m 收盘越上沿或下沿后,按间隔推送最多 KEY_ALERT_MAX_TIMES 次.""" + sym = row["symbol"] + typ = (row["monitor_type"] or "").strip() + up, low = float(row["upper"]), float(row["lower"]) + if up <= low: + return + bar = _fetch_last_closed_bar(sym) + if not bar: + return + close = float(bar[4]) + ts = bar[0] + now_dt = app_now() + tick = run_rs_level_alert_tick( + row, + close, + ts, + now_dt, + default_max_notify=KEY_ALERT_MAX_TIMES, + default_interval_min=KEY_ALERT_INTERVAL_MINUTES, + ) + if not tick: + return + + br = tick["break_info"] + notify_index = int(tick["notify_index"]) + max_n = int(tick["notify_max"]) + interval = int(tick["interval_min"]) + bar_ts = tick.get("bar_ts") + prior_count = int(tick.get("prior_count", notify_index - 1)) + + notified_at = app_now_str() + if not claim_rs_level_notify( + conn, + row["id"], + notify_index, + br["direction"], + notified_at, + bar_ts, + prior_count=prior_count, + ): + return + conn.commit() + + trigger_time = ms_to_app_local_str(int(ts)) if ts else app_now_str() + msg = build_wechat_rs_level_message( + symbol=sym, + monitor_type=typ, + account_label=_wechat_account_label(), + trigger_time=trigger_time, + upper_txt=format_price_for_symbol(sym, up), + lower_txt=format_price_for_symbol(sym, low), + close_txt=format_price_for_symbol(sym, close), + edge_txt=format_price_for_symbol(sym, br["edge_price"]), + break_label=br["break_label"], + direction=br["direction"], + notify_index=notify_index, + notify_max=max_n, + interval_min=interval, + ) + send_wechat_msg(msg) + conn.execute( + "UPDATE key_monitors SET last_alert_message=? WHERE id=?", + (msg, row["id"]), + ) + conn.commit() + if notify_index >= max_n: + hist_row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (row["id"],)).fetchone() + if hist_row: + insert_key_monitor_history(conn, hist_row, notify_index, msg, "key_level_alert_done") + conn.execute("DELETE FROM key_monitors WHERE id=?", (row["id"],)) + conn.commit() + + +def _key_hard_lines_from_checks(checks): + direction = (checks.get("direction") or "long").lower() + 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']})", + format_auto_amp_line(checks["amp_ok"], checks["amp_pct"], KEY_BREAKOUT_AMP_MIN_PCT), + format_auto_confirm_line( + checks["confirm_ok"], checks["confirm_close"], checks["edge_price"], direction + ), + f"日成交量排名:{'通过' if checks['rank_ok'] else '不通过'}({checks['rank']}/{checks['rank_total']},要求前{KEY_DAILY_VOLUME_RANK_MAX})", + ] + + +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, + time_close_enabled=0, + time_close_hours=None, +): + """ + 与手动「实盘下单」对齐的市价开仓与 order_monitors 写入. + 返回 (ok: bool, err_msg: Optional[str], detail: Optional[dict]) + """ + ok_src, src_msg = assert_open_source_allowed(POSITION_SIZING_MODE, OPEN_SOURCE_KEY_AUTO) + if not ok_src: + return False, src_msg, None + 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 + tc_en, tc_h, tc_at = time_close_insert_values( + time_close_enabled, time_close_hours, opened_at_ms + ) + + 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, " + "time_close_enabled, time_close_hours, time_close_at_ms) " + "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), + tc_en, + tc_h, + tc_at, + ), + ) + 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 _limit_key_plan_for_row(row): + typ = (row["monitor_type"] or "").strip() + if is_fib_key_monitor_type(typ): + return _fib_plan_for_row(row) + if is_false_breakout_key_monitor_type(typ): + direction = (row["direction"] or "long").lower() + key_px = key_price_from_row(direction, row["upper"], row["lower"]) + if key_px is None: + return None + return calc_false_breakout_plan(direction, key_px) + return None + + +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) + tc_en, tc_h, _ = time_close_settings_from_row(row) + tc_en, tc_h, tc_at = time_close_insert_values(tc_en, tc_h, opened_at_ms) + 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, " + "time_close_enabled, time_close_hours, time_close_at_ms) " + "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), + tc_en, + tc_h, + tc_at, + ), + ) + 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() + kind = "假突破" if is_false_breakout_key_monitor_type(typ) else "斐波" + ex_sym = normalize_exchange_symbol(symbol) + plan = _limit_key_plan_for_row(row) + if not plan: + _finalize_key_monitor_one_shot(conn, row, f"{kind}计划无效", "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} {kind}成交后处理失败\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} {kind}成交后风控拒绝\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} {kind}成交后挂 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 "-" + close_reason = "false_breakout_filled" if is_false_breakout_key_monitor_type(typ) else "fib_filled" + succ = ( + f"# ✅ {symbol} {kind}限价成交\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, close_reason) + + +def _trigger_entry_exists_for_symbol(conn, symbol): + placeholders = ",".join("?" * len(TRIGGER_ENTRY_MONITOR_TYPES)) + row = conn.execute( + f"SELECT id FROM key_monitors WHERE symbol=? AND monitor_type IN ({placeholders})", + (symbol, *TRIGGER_ENTRY_MONITOR_TYPES), + ).fetchone() + return row is not None + + +def _add_trigger_entry_key_monitor( + conn, + symbol, + direction_sel, + entry, + sl, + tp, + monitor_type=CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE, + breakeven_enabled=0, + time_close_enabled=0, + time_close_hours=None, +): + mt = (monitor_type or CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE).strip() + if mt not in TRIGGER_ENTRY_MONITOR_TYPES: + mt = CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE + if _trigger_entry_exists_for_symbol(conn, symbol): + return False, f"{symbol} 已有触价开仓监控(同币仅允许一条)" + ex_sym = normalize_exchange_symbol(symbol) + mark = get_symbol_mark_price(symbol) + geom_err = validate_trigger_entry_geometry( + direction_sel, entry, sl, tp, mark_at_add=mark, monitor_type=mt + ) + if geom_err: + return False, geom_err + rr_err = validate_trigger_entry_rr( + direction_sel, entry, sl, tp, KEY_AUTO_MIN_PLANNED_RR, calc_rr_ratio + ) + if rr_err: + return False, rr_err + entry = float(round_price_to_exchange(ex_sym, entry) or entry) + sl = float(round_price_to_exchange(ex_sym, sl) or sl) + tp = float(round_price_to_exchange(ex_sym, tp) or tp) + geom_err = validate_trigger_entry_geometry( + direction_sel, entry, sl, tp, mark_at_add=mark, monitor_type=mt + ) + if geom_err: + return False, geom_err + rr_err = validate_trigger_entry_rr( + direction_sel, entry, sl, tp, KEY_AUTO_MIN_PLANNED_RR, calc_rr_ratio + ) + if rr_err: + return False, rr_err + 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) + opens_today = count_opens_for_trading_day(conn, trading_day) + ok_intent, intent_msg = check_trigger_entry_intent_limit( + conn, trading_day, opens_today, DAILY_OPEN_HARD_LIMIT + ) + if not ok_intent: + return False, intent_msg + if is_full_margin_mode(POSITION_SIZING_MODE): + ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn)) + if not ok_flat: + return False, flat_msg + if count_pending_trigger_entries(conn, trading_day) > 0: + return False, "全仓杠杆模式下仅允许一条待触发触价监控" + 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) + available_usdt = get_available_trading_usdt() + if is_full_margin_mode(POSITION_SIZING_MODE): + leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE) + sizing, sizing_err = compute_full_margin_sizing( + symbol=symbol, + available_usdt=available_usdt if available_usdt is not None else 0.0, + capital_base=capital_base, + buffer_ratio=FULL_MARGIN_BUFFER_RATIO, + btc_leverage=BTC_LEVERAGE, + alt_leverage=ALT_LEVERAGE, + funds_decimals=2, + ) + if sizing_err: + return False, sizing_err + margin_capital = float(sizing["margin_capital"]) + amount_plan = None + else: + 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 + risk_fraction = calc_risk_fraction(direction_sel, entry, sl) + if risk_fraction is None: + return False, "止损方向不合法(相对计划入场价)" + 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_plan, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry) + except Exception as e: + return False, friendly_exchange_error(e, available_usdt=available_usdt) + upper_px = round_price_to_exchange(ex_sym, max(entry, tp)) + lower_px = round_price_to_exchange(ex_sym, min(entry, sl)) + if upper_px is None or lower_px is None or float(upper_px) <= float(lower_px): + upper_px, lower_px = float(max(entry, tp, sl)), float(min(entry, tp, sl)) + if upper_px <= lower_px: + lower_px = upper_px * 0.9999 + be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0 + tc_en, tc_h, _ = time_close_insert_values(time_close_enabled, time_close_hours, None) + conn.execute( + "INSERT INTO key_monitors " + "(symbol, monitor_type, direction, upper, lower, " + "fib_entry_price, fib_stop_loss, fib_take_profit, " + "fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled, " + "time_close_enabled, time_close_hours, session_date) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + mt, + direction_sel, + float(upper_px), + float(lower_px), + entry, + sl, + tp, + float(amount_plan) if amount_plan is not None else None, + margin_capital, + leverage, + be_flag, + tc_en, + tc_h, + trading_day, + ), + ) + return True, None + + +def _market_open_for_trigger_entry( + conn, + symbol, + direction, + exchange_symbol, + entry_price, + stop_loss, + take_profit, + monitor_type=CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE, + breakeven_enabled=0, + time_close_enabled=0, + time_close_hours=None, +): + """触价触发后市价开仓,计仓规则与实盘下单/关键位 RR 门槛一致.""" + ok_src, src_msg = assert_open_source_allowed(POSITION_SIZING_MODE, OPEN_SOURCE_KEY_TRIGGER) + if not ok_src: + return False, src_msg, None + 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 + + trading_day = get_trading_day(now) + opens_today_before = count_opens_for_trading_day(conn, trading_day) + 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_symbol_mark_price(symbol) or 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 = float(lp_r) + + entry_price = float(entry_price) + 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) + + planned_rr = calc_rr_ratio(direction, entry_price, stop_loss, take_profit) + if planned_rr is None or planned_rr <= KEY_AUTO_MIN_PLANNED_RR: + rr_txt = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算" + return False, f"计划盈亏比 {rr_txt}:1 未达要求(>{KEY_AUTO_MIN_PLANNED_RR}:1)", None + + risk_percent = max(0.01, float(RISK_PERCENT)) + if is_full_margin_mode(POSITION_SIZING_MODE): + ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn)) + if not ok_flat: + return False, flat_msg, None + leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE) + sizing, sizing_err = compute_full_margin_sizing( + symbol=symbol, + available_usdt=available_usdt if available_usdt is not None else 0.0, + capital_base=capital_base, + buffer_ratio=FULL_MARGIN_BUFFER_RATIO, + btc_leverage=BTC_LEVERAGE, + alt_leverage=ALT_LEVERAGE, + funds_decimals=2, + ) + if sizing_err: + return False, sizing_err, None + margin_capital = float(sizing["margin_capital"]) + notional_value = float(sizing["notional_value"]) + position_ratio = float(sizing["position_ratio"]) + risk_amount = margin_capital + else: + 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 + risk_fraction = calc_risk_fraction(direction, entry_price, stop_loss) + if risk_fraction is None: + return False, "止损方向不合法(相对计划入场价)", None + 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_fill = 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 + tc_en, tc_h, tc_at = time_close_insert_values(time_close_enabled, time_close_hours, opened_at_ms) + risk_percent_db = risk_percent_for_storage(POSITION_SIZING_MODE, risk_percent) + + 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, " + "time_close_enabled, time_close_hours, time_close_at_ms) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + exchange_symbol, + direction, + trigger_price, + stop_loss, + stop_loss, + take_profit, + margin_capital, + leverage, + trade_style, + risk_percent_db, + 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(monitor_type), + tc_en, + tc_h, + tc_at, + ), + ) + 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 = count_opens_for_trading_day(conn, trading_day) + + return True, None, { + "new_order_id": new_order_id, + "open_order_id": open_order_id, + "trigger_price": trigger_price, + "planned_rr_fill": planned_rr_fill, + "risk_amount_final": risk_amount_final, + "margin_capital": margin_capital, + "leverage": leverage, + "amount": amount, + "tpsl_attached": tpsl_attached, + "opens_today_before": opens_today_before, + "opens_today_after": opens_today_after, + "trading_day": trading_day, + "stop_loss": stop_loss, + "take_profit": take_profit, + } + + +def _execute_trigger_entry_cross(conn, row): + """标记价触达计划入场:加锁防重复触发,成交成功后再删监控行.""" + symbol = row["symbol"] + direction = (row["direction"] or "long").lower() + ex_sym = normalize_exchange_symbol(symbol) + entry = float(_sqlite_row_val(row, "fib_entry_price") or 0) + sl = float(_sqlite_row_val(row, "fib_stop_loss") or 0) + tp = float(_sqlite_row_val(row, "fib_take_profit") or 0) + be_en = breakeven_enabled_from_row(row, 0) + tc_en, tc_h, _ = time_close_settings_from_row(row) + + kid = int(row["id"]) + if not acquire_trigger_entry_exec_lock(conn, kid): + return False, "触价开仓进行中" + conn.commit() + + try: + ok, err, det = _market_open_for_trigger_entry( + conn, + symbol, + direction, + ex_sym, + entry, + sl, + tp, + monitor_type=(row["monitor_type"] or CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE), + breakeven_enabled=be_en, + time_close_enabled=tc_en, + time_close_hours=tc_h, + ) + except Exception as e: + release_trigger_entry_exec_lock(conn, kid) + conn.commit() + fail_msg = friendly_exchange_error(e) + send_wechat_msg( + f"# ❌ {symbol} 触价开仓异常\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 计划入场:{format_price_for_symbol(symbol, entry)}\n" + f"- 原因:{fail_msg}\n" + ) + insert_key_monitor_history(conn, row, 0, fail_msg, TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED) + return False, fail_msg + + if ok and det: + conn.execute("DELETE FROM key_monitors WHERE id=?", (kid,)) + conn.commit() + rr_txt = format_wechat_scalar_2dp(det.get("planned_rr_fill")) if det.get("planned_rr_fill") is not None else "-" + msg = ( + f"# ✅ {symbol} 触价开仓成交\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 来源:{ORDER_MONITOR_TYPE_KEY_AUTO}(程序触价 @ E)\n" + f"- 类型:{TRIGGER_ENTRY_MONITOR_TYPE}|{_wechat_direction_text(direction)}\n" + f"- 订单 ID:**{det.get('new_order_id')}**\n" + f"- 计划入场:{format_price_for_symbol(symbol, entry)}\n" + f"- 成交价:{format_price_for_symbol(symbol, det.get('trigger_price'))}\n" + f"- 止损:{format_wechat_scalar_2dp(det.get('stop_loss'))}|止盈:{format_price_for_symbol(symbol, det.get('take_profit'))}\n" + f"- 计划 RR:{rr_txt}:1\n" + f"- {'已挂交易所 TP/SL' if det.get('tpsl_attached') else 'TP/SL 未挂上'}\n" + ) + send_wechat_msg(msg) + insert_key_monitor_history(conn, row, 0, msg, TRIGGER_ENTRY_CLOSE_FILLED) + return True, None + release_trigger_entry_exec_lock(conn, kid) + conn.commit() + fail_msg = err or "触价触发后开仓失败" + send_wechat_msg( + f"# ❌ {symbol} 触价开仓失败\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 计划入场:{format_price_for_symbol(symbol, entry)}\n" + f"- 原因:{fail_msg}\n" + ) + insert_key_monitor_history(conn, row, 0, fail_msg, TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED) + return False, fail_msg + + +def check_trigger_entry_key_monitors(): + if not KEY_AUTO_ORDER_ENABLED: + return + conn = get_db() + placeholders = ",".join("?" * len(TRIGGER_ENTRY_MONITOR_TYPES)) + rows = conn.execute( + f"SELECT * FROM key_monitors WHERE monitor_type IN ({placeholders})", + tuple(TRIGGER_ENTRY_MONITOR_TYPES), + ).fetchall() + now_dt = app_now() + for r in rows: + symbol = r["symbol"] + direction = (r["direction"] or "long").lower() + mt = (r["monitor_type"] or CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE).strip() + entry = float(_sqlite_row_val(r, "fib_entry_price") or 0) + sl = float(_sqlite_row_val(r, "fib_stop_loss") or 0) + tp = float(_sqlite_row_val(r, "fib_take_profit") or 0) + kid = int(r["id"]) + if is_trigger_entry_in_flight_row(r): + continue + if entry <= 0 or sl <= 0 or tp <= 0: + _finalize_key_monitor_one_shot(conn, r, "触价计划价位无效", "fib_plan_invalid") + continue + mark = get_symbol_mark_price(symbol) + if mark is None: + continue + prev_mark = _sqlite_row_val(r, "last_mark_price") + prev_mark_f = float(prev_mark) if prev_mark not in (None, "") else None + if is_trigger_entry_expired(r["created_at"], now_dt, hours=TRIGGER_ENTRY_VALIDITY_HOURS): + exp_txt = trigger_entry_expires_at_text(r["created_at"], hours=TRIGGER_ENTRY_VALIDITY_HOURS) + msg = ( + f"# ⚠️ {symbol} 触价开仓已过期\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{mt}|{_wechat_direction_text(direction)}\n" + f"- 有效期 {TRIGGER_ENTRY_VALIDITY_HOURS}h(应于 {exp_txt} 前触发)\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, TRIGGER_ENTRY_CLOSE_EXPIRED) + continue + inv = trigger_entry_invalidate(mt, direction, mark, sl, tp) + if inv == "tp": + msg = ( + f"# ⚠️ {symbol} 触价开仓失效\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{mt}|标记价 {format_price_for_symbol(symbol, mark)} 已触达止盈侧(未成交)\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, TRIGGER_ENTRY_CLOSE_TP_INVALIDATE) + continue + if inv == "sl": + msg = ( + f"# ⚠️ {symbol} 触价开仓失效\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{mt}|标记价 {format_price_for_symbol(symbol, mark)} 已触达止损侧(未突破)\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, TRIGGER_ENTRY_CLOSE_SL_INVALIDATE) + continue + if trigger_should_fire(mt, direction, mark, entry, prev_mark_f): + _execute_trigger_entry_cross(conn, r) + continue + conn.execute("UPDATE key_monitors SET last_mark_price=? WHERE id=?", (float(mark), kid)) + conn.commit() + conn.close() + + +def check_fib_key_monitors(): + if not KEY_AUTO_ORDER_ENABLED: + return + conn = get_db() + rows = conn.execute("SELECT * FROM key_monitors").fetchall() + for r in rows: + typ = (r["monitor_type"] or "").strip() + if not is_limit_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") + if is_false_breakout_key_monitor_type(typ): + now_dt = app_now() + if is_false_breakout_expired(r["created_at"], now_dt): + _cancel_fib_monitor_limit(r) + exp_txt = expires_at_text(r["created_at"]) + msg = ( + f"# ⚠️ {symbol} 假突破监控已过期\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}|{_wechat_direction_text(direction)}\n" + f"- 有效期 {FALSE_BREAKOUT_VALIDITY_HOURS}h(应于 {exp_txt} 前成交)\n" + f"- 已撤销限价单\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, "false_breakout_expired") + continue + 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 is_fib_key_monitor_type(typ) and 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 is_fib_key_monitor_type(typ) and 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 _false_breakout_exists_for_symbol(conn, symbol): + row = conn.execute( + "SELECT id FROM key_monitors WHERE symbol=? AND monitor_type=?", + (symbol, FALSE_BREAKOUT_MONITOR_TYPE), + ).fetchone() + return row is not None + + +def _add_false_breakout_key_monitor( + conn, symbol, direction_sel, upper_px, lower_px, key_px, breakeven_enabled=0, + time_close_enabled=0, time_close_hours=None, +): + if _false_breakout_exists_for_symbol(conn, symbol): + return False, f"{symbol} 已有假突破监控(同币仅允许一条)" + plan = calc_false_breakout_plan(direction_sel, key_px) + if not plan: + return False, "假突破价位无效,请核对方向与关键价位" + 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) + 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, "止损方向不合法(相对挂单价);请核对方向与关键价位" + 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 + tc_en, tc_h, _ = time_close_insert_values(time_close_enabled, time_close_hours, None) + 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, time_close_enabled, time_close_hours) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, FALSE_BREAKOUT_MONITOR_TYPE, direction_sel, upper_px, lower_px, + oid, entry, sl, tp, float(amount), margin_capital, leverage, be_flag, tc_en, tc_h, + ), + ) + return True, None + + +def _add_fib_key_monitor( + conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=0, + time_close_enabled=0, time_close_hours=None, +): + 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 + tc_en, tc_h, _ = time_close_insert_values(time_close_enabled, time_close_hours, None) + 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, time_close_enabled, time_close_hours) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, mt, direction_sel, upper_px, lower_px, + oid, entry, sl, tp, float(amount), margin_capital, leverage, be_flag, tc_en, tc_h, + ), + ) + return True, None + + +# 关键位监控(箱体/收敛可自动开仓;阻力/支撑为双向 5m 收盘突破 + 三次提醒) +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_limit_key_monitor_type(typ): + continue + if typ in KEY_MONITOR_RS_TYPES: + try: + _process_key_rs_level_alert(conn, r) + except Exception as e: + print(f"[key_rs_level_alert] {sym} id={r['id']}: {e}") + continue + + if not KEY_AUTO_ORDER_ENABLED: + continue + + direction = (r["direction"] or "long").lower() + if direction == KEY_DIRECTION_WATCH: + continue + if typ in KEY_MONITOR_AUTO_TYPES: + mark = get_symbol_mark_price(sym) + if mark is not None and box_breakout_invalidate_by_mark(direction, mark, up, low): + edge = float(low) if direction == "long" else float(up) + edge_label = box_breakout_invalidate_edge_label(direction) + msg = ( + f"# ⚠️ {sym} 关键位监控失效\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}|{_wechat_direction_text(direction)}\n" + f"- 标记价 {format_price_for_symbol(sym, mark)} 已突破反向{edge_label} " + f"{format_price_for_symbol(sym, edge)}(设置失效)\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, "box_opposite_break") + 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) + 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() + + if typ not in KEY_MONITOR_AUTO_TYPES: + 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) + tc_en, tc_h, _ = time_close_settings_from_row(r) + 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, + time_close_enabled=tc_en, + time_close_hours=tc_h, + ) + 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"- {format_daily_open_summary_short(det.get('opens_today_after'), DAILY_OPEN_ALERT_THRESHOLD, DAILY_OPEN_HARD_LIMIT)}", + ] + 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 should_send_daily_open_alert( + det.get("opens_today_before", 0), + det.get("opens_today_after", 0), + DAILY_OPEN_ALERT_THRESHOLD, + ): + advice = ai_short_advice( + build_daily_open_alert_prompt( + det["trading_day"], + det.get("opens_today_after", 0), + DAILY_OPEN_ALERT_THRESHOLD, + hard_limit=DAILY_OPEN_HARD_LIMIT, + detail_line=f"最新一笔来源为关键位自动单:{sym} {direction},杠杆{det['leverage']}x.", + ) + ) + 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) + if stale_breakeven_armed(direction, trigger_price, stop_loss, breakeven_armed): + conn.execute( + "UPDATE order_monitors SET breakeven_armed=0, breakeven_price=NULL WHERE id=?", + (pid,), + ) + breakeven_armed = 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 = False + 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 should_trigger_time_close(r): + res = TIME_CLOSE_RESULT + # 做多 + if not res and direction == "long": + if p >= take_profit: res = "止盈" + elif p <= stop_loss: res = "止损" + # 做空 + elif not res and 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 = normalize_result_with_pnl("止损", pnl_amount) + 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 = normalize_result_with_pnl("止损", pnl_amount) + 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 = normalize_result_with_pnl("止损", pnl_amount) + 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=trade_record_monitor_type(conn, r), + trend_plan_id=trend_plan_id_from_monitor_row(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"], + entry_model=(r["entry_model"] if "entry_model" in r.keys() else None), + 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=handoff_trade_miss_reason( + "触发价已触达,仓位已由交易所止盈/止损或其他方式平掉(本地补记)", + r, + ), + 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 + ex_sym_fail = r["exchange_symbol"] or normalize_exchange_symbol(sym) + cancel_gate_swap_trigger_orders(ex_sym_fail) + live_contracts = get_live_position_contracts(ex_sym_fail, direction) + if live_contracts is not None and live_contracts <= 0: + record_res, record_pnl, record_closed, sync_miss = resolve_synced_flat_close( + r, opened_at, opened_at_ms=opened_at_ms + ) + record_miss = f"{sync_miss};本地触发{res}时平仓API失败:{e}" + monitor_status = "stopped" + else: + record_res, record_pnl, record_closed = res, pnl_amount, closed_at + record_miss = f"触发{res}后交易所平仓失败(请核对交易所仓位):{e}" + monitor_status = "error" + record_hold = calc_hold_seconds( + opened_at, parse_dt_for_trading_day(record_closed) or now + ) + insert_trade_record( + conn, + symbol=sym, + monitor_type=trade_record_monitor_type(conn, r), + trend_plan_id=trend_plan_id_from_monitor_row(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=record_pnl, + hold_seconds=record_hold, + trade_style=r["trade_style"], + entry_model=(r["entry_model"] if "entry_model" in r.keys() else None), + 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(record_pnl, r["risk_amount"]), + result=record_res, + miss_reason=handoff_trade_miss_reason(record_miss, r), + opened_at=opened_at, + closed_at=record_closed, + ) + session_capital = update_session_capital(conn, session_date, record_pnl) + conn.execute("UPDATE order_monitors SET status=? WHERE id=?", (monitor_status, pid)) + conn.commit() + send_wechat_msg( + build_wechat_monitor_error_message( + symbol=sym, + direction=direction, + scene=f"触发{res}后交易所平仓失败", + error_text=str(e), + ) + ) + if monitor_status == "stopped": + send_wechat_msg( + build_wechat_close_message( + symbol=sym, + direction=direction, + result=f"{record_res}(已补记入交易记录)", + pnl_amount=record_pnl, + hold_seconds=record_hold, + trigger_price=trigger_price, + current_price=p, + stop_loss=stop_loss, + take_profit=take_profit, + close_order_id="-", + extra_note=record_miss, + session_capital_fallback=session_capital, + ) + ) + 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=trade_record_monitor_type(conn, r), + trend_plan_id=trend_plan_id_from_monitor_row(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"], + entry_model=(r["entry_model"] if "entry_model" in r.keys() else None), + 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=handoff_trade_miss_reason(None, r), + 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=trade_record_monitor_type(conn, r), + trend_plan_id=trend_plan_id_from_monitor_row(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"], + entry_model=(r["entry_model"] if "entry_model" in r.keys() else None), + 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=handoff_trade_miss_reason( + f"北京时间 {FORCE_CLOSE_BJ_HOUR}:00 整点风控清仓", + r, + ), + 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: + try: + auto_transfer_once_per_day() + conn = get_db() + force_close_before_reset() + reconcile_external_closes(conn) + conn.commit() + conn.close() + check_fib_key_monitors() + check_trigger_entry_key_monitors() + _roll_cfg = app.extensions.get("strategy_roll_cfg") + if _roll_cfg: + from lib.strategy.strategy_roll_monitor_lib import check_roll_monitors + + check_roll_monitors(_roll_cfg) + check_key_monitors() + check_order_monitors() + cfg = app.extensions.get("strategy_trend_cfg") + if cfg: + from lib.strategy.strategy_trend_register import check_trend_pullback_plans + + check_trend_pullback_plans(cfg) + except Exception as e: + print(f"[monitor_loop] {e}", flush=True) + 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, + pwa_app_name="Gate 交易系统", + ) + +@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 hub_request_allowed(bool(session.get("logged_in")), AUTH_DISABLED): + return f(*args, **kwargs) + return redirect("/login") + 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", embed_mode=None): + 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"]) + from lib.instance.instance_embed_context_lib import ( + embed_render_plan, + minimal_stats_bundle, + profit_loss_ratio_from_trades, + total_funds_usdt, + trade_records_summary, + ) + + plan = embed_render_plan(page, embed_mode) + if plan.exchange_capitals: + funding_capital, trading_capital = get_exchange_capitals() + else: + funding_capital, trading_capital = None, None + # 资金账户:仅展示交易所读取结果(含 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() if plan.key_list else [] + ) + 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() + if plan.key_history + else [] + ) + stats_bundle = ( + compute_stats_bundle(conn, trading_day, now) + if plan.stats_bundle + else minimal_stats_bundle(TRADING_DAY_RESET_HOUR) + ) + order_list = [] + if plan.orders: + raw_order_list = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() + for o in raw_order_list: + order_list.append(enrich_order_item(row_to_dict(o), current_capital)) + enrich_orders_force_close( + order_list, + FORCE_CLOSE_ENABLED, + FORCE_CLOSE_BJ_HOUR, + now_ms=int(app_now().timestamp() * 1000), + ) + exchange_pnl_sync = {} + if exchange_private_api_configured() and not request_is_hub_soft_nav() and embed_mode not in ( + "fragment", + "shell", + ): + try: + exchange_pnl_sync = sync_trade_records_from_exchange(conn) or {} + except Exception as e: + exchange_pnl_sync = {"ok": False, "reason": str(e)} + tr_ts = sql_list_time_field("closed_at", "created_at", "opened_at") + if plan.records_rows: + raw_records = conn.execute( + f"SELECT * FROM trade_records WHERE {tr_ts} >= ? AND {tr_ts} <= ? ORDER BY id DESC LIMIT 1000", + (start_bj, end_bj), + ).fetchall() + records = filter_trade_records_excluding_miss( + [to_effective_trade_dict(r) for r in raw_records] + ) + total = len(records) + win = count_winning_trades(records) + rate = round(win / total * 100, 2) if total else 0 + profit_loss_ratio = profit_loss_ratio_from_trades(records) + elif plan.records_summary: + summary = trade_records_summary(conn, start_bj, end_bj, tr_ts) + records = summary["records"] + total = summary["total"] + rate = summary["rate"] + profit_loss_ratio = summary.get("profit_loss_ratio") + else: + records = [] + total = rate = 0 + profit_loss_ratio = None + active_count = len(order_list) + from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors + + position_limit_count = count_position_limit_active_monitors(conn) + opens_today = count_opens_for_trading_day(conn, trading_day) + risk_status = hub_account_risk_status(conn) + can_trade = can_trade_new_open( + time_allows=trading_day_reset_allows_new_open(now), + active_count=position_limit_count, + max_active_positions=MAX_ACTIVE_POSITIONS, + opens_today=opens_today, + hard_limit=DAILY_OPEN_HARD_LIMIT, + extra_blocks=not risk_status.get("can_trade", True), + ) + key_rule_ctx = key_monitor_rule_template_context( + kline_timeframe=KLINE_TIMEFRAME, + key_breakout_amp_min_pct=KEY_BREAKOUT_AMP_MIN_PCT, + key_volume_ma_bars=KEY_VOLUME_MA_BARS, + key_volume_ratio_min=KEY_VOLUME_RATIO_MIN, + key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR, + key_daily_volume_rank_max=KEY_DAILY_VOLUME_RANK_MAX, + key_confirm_breakout_bar=KEY_CONFIRM_BREAKOUT_BAR, + key_confirm_bar=KEY_CONFIRM_BAR, + key_alert_max_times=KEY_ALERT_MAX_TIMES, + key_alert_interval_minutes=KEY_ALERT_INTERVAL_MINUTES, + key_stop_outside_breakout_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT, + key_trend_stop_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT, + false_breakout_validity_hours=FALSE_BREAKOUT_VALIDITY_HOURS, + trigger_entry_validity_hours=TRIGGER_ENTRY_VALIDITY_HOURS, + ) + strategy_extra = {} + if plan.strategy: + from lib.strategy.strategy_ui import strategy_render_extras + + strategy_extra = strategy_render_extras( + conn, + page, + default_risk_percent=float(RISK_PERCENT), + request_obj=request, + trend_cfg=app.extensions.get("strategy_trend_cfg"), + ) + conn.close() + from lib.instance.instance_embed_lib import embed_context_extras + from lib.instance.instance_settings_lib import settings_page_context + from lib.instance.instance_display_prefs_lib import display_prefs_template_context + + _display_ctx = display_prefs_template_context(get_db) + template_ctx = dict( + page=page, + key=key_list, + key_history=key_history, + stats_bundle=stats_bundle, + order=order_list, + record=records, + total=total, + rate=rate, + profit_loss_ratio=profit_loss_ratio, + total_funds=total_funds_usdt(funding_usdt, current_capital), + 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, + transfer_amount_fmt=format_usdt(AUTO_TRANSFER_AMOUNT), + full_margin_buffer_ratio=FULL_MARGIN_BUFFER_RATIO, + price_refresh_seconds=PRICE_REFRESH_SECONDS, + active_count=position_limit_count, + can_trade=can_trade, + opens_today=opens_today, + daily_open_hard_limit=DAILY_OPEN_HARD_LIMIT, + daily_open_alert_threshold=DAILY_OPEN_ALERT_THRESHOLD, + 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_this_month": PRESET_UTC_THIS_MONTH, + "utc_last3m": PRESET_UTC_LAST3M, + "utc_last6m": PRESET_UTC_LAST6M, + "all": PRESET_ALL, + "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, + position_sizing_mode=POSITION_SIZING_MODE, + position_sizing_mode_label=mode_label_zh(POSITION_SIZING_MODE), + trade_policy=trade_policy_template_context(TRADE_POLICY), + **order_entry_template_context(TRADE_POLICY), + open_position_button_label=open_position_button_label(TRADE_POLICY, POSITION_SIZING_MODE), + breakeven_rr_trigger=BREAKEVEN_RR_TRIGGER, + breakeven_offset_pct=BREAKEVEN_OFFSET_PCT, + price_fmt=format_price_for_symbol, + funds_fmt=format_usdt, + usdt_fmt=format_usdt, + signed_usdt_fmt=format_signed_usdt, + entry_reason_options=list( + effective_entry_reason_options( + ENTRY_REASON_OPTIONS, + POSITION_SIZING_MODE, + KEY_AUTO_ORDER_ENABLED, + trend_manual_count=trend_manual_entry_reason_count(TRADE_POLICY), + ) + ), + order_type_options=list(JOURNAL_ORDER_TYPE_OPTIONS), + key_auto_order_enabled=KEY_AUTO_ORDER_ENABLED, + journal_chart_tf_choices=JOURNAL_CHART_TF_CHOICES, + journal_chart_default_tf1=JOURNAL_CHART_DEFAULT_TF1, + journal_chart_default_tf2=JOURNAL_CHART_DEFAULT_TF2, + journal_chart_default_limit=JOURNAL_CHART_DEFAULT_LIMIT, + journal_chart_default_anchor=JOURNAL_CHART_DEFAULT_ANCHOR, + exchange_display=EXCHANGE_DISPLAY_NAME, + risk_status=risk_status, + 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_rule_ctx=key_rule_ctx, + kline_timeframe=KLINE_TIMEFRAME, + exchange_pnl_sync=exchange_pnl_sync, + **strategy_extra, + **force_close_template_context( + FORCE_CLOSE_ENABLED, + FORCE_CLOSE_BJ_HOUR, + now_ms=int(app_now().timestamp() * 1000), + has_active_positions=bool(order_list), + ), + **embed_context_extras("gate"), + **_display_ctx, + **settings_page_context( + page, + display=_display_ctx["display"], + instance_base_dir=BASE_DIR, + exchange_key="gate", + exchange_display=EXCHANGE_DISPLAY_NAME, + risk_status=risk_status, + trade_policy=TRADE_POLICY, + data_export_version=3, + ), + ) + if embed_mode == "fragment": + return render_template("embed_page_fragment.html", **template_ctx) + if embed_mode == "shell": + return render_template( + "embed_shell.html", + initial_tab=page, + **template_ctx, + ) + return render_template("index.html", **template_ctx) + + +@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("/dashboard") +@login_required +def dashboard_page(): + return render_main_page("dashboard") + + +@app.route("/risk_policy") +@login_required +def risk_policy_page(): + return render_main_page("risk_policy") + + +@app.route("/env_config") +@login_required +def env_config_page(): + return render_main_page("env_config") + + +@app.route("/settings") +@login_required +def settings_page(): + return render_main_page("settings") + + +@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) + from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors + + position_limit_count = count_position_limit_active_monitors(conn) + opens_today = count_opens_for_trading_day(conn, trading_day) + risk_status = hub_account_risk_status(conn) + active_pnl_rows = conn.execute( + "SELECT exchange_symbol, symbol, direction FROM order_monitors WHERE status='active'" + ).fetchall() + from lib.instance.instance_embed_context_lib import header_trade_stats_for_window, total_funds_usdt + + header_trade_stats = header_trade_stats_for_window(conn, _list_window_from_request(), APP_TZ) + conn.close() + can_trade = can_trade_new_open( + time_allows=trading_day_reset_allows_new_open(now), + active_count=position_limit_count, + max_active_positions=MAX_ACTIVE_POSITIONS, + opens_today=opens_today, + hard_limit=DAILY_OPEN_HARD_LIMIT, + extra_blocks=not risk_status.get("can_trade", True), + ) + available_trading_usdt = get_available_trading_usdt() + + unrealized_pnl = None + if exchange_private_api_configured(): + from lib.instance.instance_live_pnl_lib import resolve_instance_unrealized_pnl + + def _gate_positions(): + ensure_markets_loaded() + try: + return exchange.fetch_positions(None, {"settle": "usdt"}) or [] + except Exception: + return exchange.fetch_positions() or [] + + unrealized_pnl = resolve_instance_unrealized_pnl( + _gate_positions, + active_pnl_rows, + get_live_position_exchange_metrics, + ) + return jsonify({ + "funding_usdt": funding_usdt, + "current_capital": current_capital, + "total_funds": total_funds_usdt(funding_usdt, current_capital), + "available_trading_usdt": round(available_trading_usdt, 2) if available_trading_usdt is not None else None, + "unrealized_pnl": unrealized_pnl, + "recommended_capital": recommended_capital, + "active_count": position_limit_count, + "max_active_positions": MAX_ACTIVE_POSITIONS, + "can_trade": can_trade, + "opens_today": opens_today, + "daily_open_hard_limit": DAILY_OPEN_HARD_LIMIT, + "daily_open_alert_threshold": DAILY_OPEN_ALERT_THRESHOLD, + "manual_min_planned_rr": MANUAL_MIN_PLANNED_RR, + "trading_day": trading_day, + "total": header_trade_stats["total"], + "rate": header_trade_stats["rate"], + "profit_loss_ratio": header_trade_stats.get("profit_loss_ratio"), + "risk_status": risk_status, + **force_close_template_context( + FORCE_CLOSE_ENABLED, + FORCE_CLOSE_BJ_HOUR, + now_ms=int(now.timestamp() * 1000), + has_active_positions=position_limit_count > 0, + ), + }) + + +@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_stop_loss,fib_take_profit,fib_limit_order_id,created_at 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," + "time_close_enabled,time_close_hours,time_close_at_ms,opened_at_ms FROM order_monitors WHERE status='active'" + ).fetchall() + + 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 = [] + + from lib.hub.price_snapshot_lib import resolve_order_snapshot_price, seed_prices_from_positions + + seed_prices_from_positions( + prices, + order_rows, + all_swap_positions, + resolve_ex_sym_fn=resolve_monitor_exchange_symbol, + ) + + key_prices = [] + for r in key_rows: + is_fib = is_fib_key_monitor_type(r["monitor_type"]) + is_fb = is_false_breakout_key_monitor_type(r["monitor_type"]) + is_te = is_trigger_entry_key_monitor_type(r["monitor_type"]) + if is_fib or is_fb or is_te: + 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 + fb_gate_ok = True + te_gate_ok = True + box_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')}" + elif is_fb: + entry = _sqlite_row_val(r, "fib_entry_price") + entry_txt = format_price_for_symbol(r["symbol"], entry) if entry else "-" + prev = false_breakout_gate_preview( + entry_display=entry_txt, + limit_order_id=_sqlite_row_val(r, "fib_limit_order_id"), + created_at=_sqlite_row_val(r, "created_at"), + now=app_now(), + ) + gate_summary = prev.get("summary") or "-" + gate_metrics = prev.get("metrics") or "" + fb_gate_ok = bool(prev.get("gate_ok")) + elif is_te: + direction = (r["direction"] or "long").lower() + entry = _sqlite_row_val(r, "fib_entry_price") + tp_v = _sqlite_row_val(r, "fib_take_profit") + entry_txt = format_price_for_symbol(r["symbol"], entry) if entry else "-" + tp_txt = format_price_for_symbol(r["symbol"], tp_v) if tp_v else "-" + sl_v = _sqlite_row_val(r, "fib_stop_loss") + inv = ( + trigger_entry_invalidate( + r["monitor_type"], direction, price, float(sl_v or 0), float(tp_v or 0) + ) + if tp_v + else None + ) + prev = trigger_entry_gate_preview( + monitor_type=r["monitor_type"], + entry_display=entry_txt, + take_profit_display=tp_txt, + created_at=_sqlite_row_val(r, "created_at"), + now=app_now(), + tp_invalidated=inv == "tp", + sl_invalidated=inv == "sl", + hours=TRIGGER_ENTRY_VALIDITY_HOURS, + ) + gate_summary = prev.get("summary") or "-" + gate_metrics = prev.get("metrics") or "" + te_gate_ok = bool(prev.get("gate_ok")) + elif (r["monitor_type"] or "").strip() in KEY_MONITOR_RS_TYPES: + try: + prev = _key_rs_gate_preview(r["symbol"], r["upper"], r["lower"]) + gate_summary = prev.get("summary") or "-" + gate_metrics = prev.get("metrics") or "" + except Exception: + gate_summary = "-" + elif (r["monitor_type"] or "").strip() in KEY_MONITOR_AUTO_TYPES: + direction = (r["direction"] or "long").lower() + if box_breakout_invalidate_by_mark(direction, price, r["upper"], r["lower"]): + edge_label = box_breakout_invalidate_edge_label(direction) + gate_summary = f"反向突破{edge_label}·将撤销" + box_gate_ok = False + else: + try: + gate = _key_hard_checks( + r["symbol"], + direction, + 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 fb_gate_ok if is_fb + else te_gate_ok if is_te + else box_gate_ok and bool(gate and gate.get("ok")) + ), + "gate_metrics": gate_metrics, + }) + + order_prices = [] + for r in order_rows: + margin = float(r["margin_capital"] or 0) + leverage = float(r["leverage"] or 0) + entry = float(r["trigger_price"] or 0) + exchange_tpsl = {"sl": None, "tp": None} + 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 + price = resolve_order_snapshot_price( + r["symbol"], + prices, + position_row=prow, + order_leverage=lev_row, + parse_position_metrics_fn=parse_ccxt_position_metrics, + get_mark_price_fn=get_symbol_mark_price, + fallback_entry=entry if entry > 0 else None, + ) + pnl = calc_pnl(r["direction"], entry, price, margin, leverage) if entry > 0 and price else 0 + pnl_pct = round((pnl / margin * 100), 4) if margin > 0 else 0 + payload = { + "id": r["id"], + "symbol": r["symbol"], + "float_pnl": round(pnl, 2), + "float_pct": pnl_pct, + "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: + mp = ex_metrics["mark_price"] + payload["exchange_mark_price"] = mp + payload["exchange_mark_price_display"] = format_price_for_symbol( + r["symbol"], mp + ) + 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 = None + if price is not None: + try: + px_for_fmt = float(price) + except (TypeError, ValueError): + px_for_fmt = None + 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 + if px_for_fmt is not None: + 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 payload.get("exchange_mark_price") is None: + payload["exchange_mark_price"] = px_for_fmt + payload["exchange_mark_price_display"] = px_disp + else: + payload["price"] = None + payload["price_display"] = "-" + if exchange_private_api_configured(): + try: + exchange_tpsl = fetch_exchange_tpsl_slots( + ex_sym, + r["direction"], + plan_sl=r["stop_loss"], + plan_tp=r["take_profit"], + ) + except Exception: + exchange_tpsl = {"sl": None, "tp": None} + payload["exchange_tpsl"] = exchange_tpsl + avg_entry = None + if ex_metrics and ex_metrics.get("entry_price") is not None: + avg_entry = ex_metrics["entry_price"] + elif prow: + from lib.hub.hub_position_metrics import parse_position_entry_price + + avg_entry = parse_position_entry_price(prow) + apply_order_price_display_fields( + payload, + direction=r["direction"], + entry_price=entry, + initial_stop_loss=r["initial_stop_loss"], + stop_loss=r["stop_loss"], + take_profit=r["take_profit"], + calc_rr_ratio_fn=calc_rr_ratio, + exchange_tpsl=exchange_tpsl, + format_price_fn=format_price_for_symbol, + symbol=r["symbol"], + margin_capital=margin, + leverage=leverage, + exchange_notional=ex_metrics.get("notional") if ex_metrics else None, + contracts=abs(_position_row_effective_contracts(prow)) if prow else None, + contract_size=float(get_contract_size(ex_sym)) if ex_sym else 1.0, + mark_price=ex_metrics.get("mark_price") if ex_metrics else price, + avg_entry_price=avg_entry, + funds_decimals=FUNDS_DECIMALS, + ) + apply_time_close_to_payload(payload, r) + apply_force_close_to_payload( + payload, + enabled=FORCE_CLOSE_ENABLED, + bj_hour=FORCE_CLOSE_BJ_HOUR, + ) + payload["opened_at"] = r["opened_at"] if "opened_at" in r.keys() else None + open_ms = r["opened_at_ms"] if "opened_at_ms" in r.keys() else None + payload["opened_at_ms"] = int(open_ms) if open_ms not in (None, "") else None + new_sl, new_tp, changed = order_monitor_tpsl_needs_sync( + r["stop_loss"], r["take_profit"], exchange_tpsl + ) + if changed: + try: + conn.execute( + "UPDATE order_monitors SET stop_loss=?, take_profit=? WHERE id=?", + (new_sl, new_tp, int(r["id"])), + ) + except Exception: + pass + order_prices.append(payload) + + try: + conn.commit() + except Exception: + pass + conn.close() + + from lib.hub.hub_position_metrics import build_position_marks_list + + position_marks = build_position_marks_list( + all_swap_positions, + format_mark_display=lambda sym, px: format_price_for_symbol(sym, px), + ) + + return jsonify({ + "updated_at": app_now_str(), + "key_prices": key_prices, + "order_prices": order_prices, + "position_marks": position_marks, + "positions_raw_count": len(all_swap_positions), + **force_close_template_context( + FORCE_CLOSE_ENABLED, + FORCE_CLOSE_BJ_HOUR, + has_active_positions=bool(order_prices), + ), + }) + + +@app.route("/api/order//cancel_tpsl", methods=["POST"]) +@login_required +def api_order_cancel_tpsl(order_id): + from lib.trade.trade_policy_lib import is_intraday_trading_profile + + if is_intraday_trading_profile(TRADE_POLICY): + return jsonify({"ok": False, "msg": "日内纪律账户禁止撤销交易所止盈止损"}), 403 + 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) + prow = None + ex_metrics = None + if exchange_private_api_configured(): + try: + rows = exchange.fetch_positions([ex_sym]) or exchange.fetch_positions() or [] + prow = _select_live_position_row(rows, ex_sym, direction) + if prow: + ex_metrics = parse_ccxt_position_metrics(prow, order_leverage=row["leverage"]) + except Exception: + pass + from lib.trade.order_monitor_display_lib import enrich_active_monitor_tpsl_json + + ex_sym = resolve_monitor_exchange_symbol(row) + display_extra = enrich_active_monitor_tpsl_json( + row, + stop_loss, + take_profit, + slots, + position_row=prow, + exchange_notional=ex_metrics.get("notional") if ex_metrics else None, + contract_size=float(get_contract_size(ex_sym)) if ex_sym else 1.0, + mark_price=live_price, + calc_rr_ratio_fn=calc_rr_ratio, + format_price_fn=format_price_for_symbol, + symbol=symbol, + funds_decimals=FUNDS_DECIMALS, + ) + conn.close() + return jsonify( + { + "ok": True, + "msg": "已先撤后挂止盈止损", + "stop_loss": stop_loss, + "take_profit": take_profit, + "planned_rr": planned_rr, + "exchange_tpsl": slots, + **display_extra, + } + ) + + +@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]), + }) + + from lib.instance.focus_chart_lib import ( + build_order_kline_order_payload, + load_swap_positions_for_order_kline, + metrics_for_order_item, + ) + + current_price = get_price(order_item["symbol"]) + positions = load_swap_positions_for_order_kline( + exchange, + private_configured=exchange_private_api_configured(), + ensure_markets_fn=ensure_markets_loaded, + ) + ex_metrics = metrics_for_order_item( + order_item, + positions, + resolve_ex_sym_fn=resolve_monitor_exchange_symbol, + select_live_fn=_select_live_position_row, + parse_metrics_fn=parse_ccxt_position_metrics, + ) + order_payload = build_order_kline_order_payload( + order_item, + ticker_price=current_price, + format_price_fn=format_price_for_symbol, + calc_pnl_fn=calc_pnl, + calc_rr_ratio_fn=calc_rr_ratio, + ex_metrics=ex_metrics, + ) + + from lib.instance.focus_chart_lib import kline_api_price_fields + + price_fields = kline_api_price_fields( + exchange, + exchange_symbol, + candles, + ensure_markets_fn=ensure_markets_loaded, + ) + + return jsonify({ + "ok": True, + "timeframe": timeframe, + "limit": limit, + "order": order_payload, + "candles": candles, + "updated_at": app_now_str(), + **price_fields, + }) + + +@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 = default_symbol_for_policy( + TRADE_POLICY, + 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, + trade_policy=trade_policy_template_context(TRADE_POLICY), + ) + + +@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, + } + + from lib.instance.focus_chart_lib import enrich_key_kline_response + + price_display, key_info = enrich_key_kline_response( + symbol=symbol, + current_price=current_price, + key_info=key_info, + format_price_fn=format_price_for_symbol, + ) + + from lib.instance.focus_chart_lib import kline_api_price_fields + + price_fields = kline_api_price_fields( + exchange, + exchange_symbol, + candles, + ensure_markets_fn=ensure_markets_loaded, + ) + + 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": price_display, + "key_monitor": key_info, + "candles": candles, + "updated_at": app_now_str(), + **price_fields, + }) + + +@app.route("/add_key", methods=["POST"]) +@login_required +def add_key(): + conn = None + try: + d = request.form + symbol = normalize_symbol_input(d.get("symbol")) + if not symbol: + flash("symbol 不能为空") + return redirect("/key_monitor") + ok_sym, sym_msg = check_symbol_policy( + TRADE_POLICY, symbol, normalize_symbol_input + ) + if not ok_sym: + flash(sym_msg) + return redirect("/key_monitor") + mt = (d.get("type") or "").strip() + direction_pre = (d.get("direction") or "").strip().lower() + dup_msg = check_duplicate_submit( + session, submit_scope_add_key(symbol, mt, direction_pre or "watch") + ) + if dup_msg: + flash(dup_msg) + return redirect("/key_monitor") + direction_sel = (d.get("direction") or "").strip().lower() + if mt in KEY_MONITOR_RS_TYPES: + direction_sel = KEY_DIRECTION_WATCH + mt = KEY_MONITOR_RS_TYPE + elif direction_sel not in ("long", "short"): + flash("箱体/收敛突破请选择做多或做空") + return redirect("/key_monitor") + ok_dir, dir_msg = check_direction_policy(TRADE_POLICY, direction_sel) + if not ok_dir: + flash(dir_msg) + return redirect("/key_monitor") + allowed_types = ( + tuple(KEY_MONITOR_AUTO_TYPES) + + tuple(KEY_MONITOR_ALERT_ONLY_TYPES) + + tuple(FIB_KEY_MONITOR_TYPES) + + (FALSE_BREAKOUT_MONITOR_TYPE,) + + tuple(TRIGGER_ENTRY_MONITOR_TYPES) + ) + if mt not in allowed_types: + flash("监控类型无效") + return redirect("/key_monitor") + ok_mt, mt_msg = check_monitor_type_add_allowed( + mt, POSITION_SIZING_MODE, KEY_AUTO_ORDER_ENABLED + ) + if not ok_mt: + flash(mt_msg) + return redirect("/key_monitor") + skip_volume_rank = is_false_breakout_key_monitor_type(mt) + rank, total = None, None + if not skip_volume_rank: + 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() + conn = None + flash( + f"当前持仓已达上限({occupied}/{MAX_ACTIVE_POSITIONS}):无法添加「箱体突破 / 收敛突破」." + "请平仓后再试,或使用「关键支撑阻力」(仅提醒)." + ) + return redirect("/key_monitor") + ex_sym_key = normalize_exchange_symbol(symbol) + try: + ensure_markets_loaded() + except Exception: + pass + be_flag = parse_breakeven_enabled_form(d.get("breakeven_enabled")) + tc_en = parse_time_close_enabled_form(d.get("time_close_enabled")) + tc_h = parse_time_close_hours_form(d.get("time_close_hours")) if tc_en else None + if tc_en and not tc_h: + tc_en = 0 + if is_trigger_entry_key_monitor_type(mt): + if direction_sel not in ("long", "short"): + conn.close() + conn = None + flash("触价请选择做多或做空") + return redirect("/key_monitor") + try: + entry_px = float(d.get("trigger_entry") or 0) + sl_px = float(d.get("trigger_sl") or 0) + tp_px = float(d.get("trigger_tp") or 0) + except (TypeError, ValueError): + entry_px = sl_px = tp_px = 0 + if entry_px <= 0 or sl_px <= 0 or tp_px <= 0: + conn.close() + conn = None + flash("触价须填写有效的入场价,止损价,止盈价") + return redirect("/key_monitor") + ok_te, err_te = _add_trigger_entry_key_monitor( + conn, + symbol, + direction_sel, + entry_px, + sl_px, + tp_px, + monitor_type=mt, + breakeven_enabled=be_flag, + time_close_enabled=tc_en, + time_close_hours=tc_h, + ) + conn.commit() + conn.close() + conn = None + if not ok_te: + flash(err_te or "触价开仓监控添加失败") + return redirect("/key_monitor") + trigger_hint = ( + "标记价穿越入场价后立即市价开仓" + if is_breakout_trigger_entry_key_monitor_type(mt) + else "标记价回调触达入场价后下一轮询市价开仓" + ) + flash( + f"{mt}已添加({symbol} 日成交量排名 {rank}/{total})" + f"|有效期 {TRIGGER_ENTRY_VALIDITY_HOURS}h" + f"|{trigger_hint}" + f"|移动保本:{'开' if be_flag else '关'}" + + (f"|{time_close_label(tc_h)}" if tc_en else "") + ) + return redirect("/key_monitor") + if is_false_breakout_key_monitor_type(mt): + fb_sym = normalize_false_breakout_symbol(symbol) + if not fb_sym: + conn.close() + conn = None + flash("假突破仅支持 BTC / ETH") + return redirect("/key_monitor") + symbol = fb_sym + if direction_sel not in ("long", "short"): + conn.close() + conn = None + flash("假突破请选择做多或做空") + return redirect("/key_monitor") + try: + key_px = float(d.get("key_price") or 0) + except (TypeError, ValueError): + key_px = 0 + if key_px <= 0: + conn.close() + conn = None + flash("请填写关键价位(做空填高点,做多填低点)") + return redirect("/key_monitor") + ex_sym_key = normalize_exchange_symbol(symbol) + key_adj = round_price_to_exchange(ex_sym_key, key_px) + key_px = float(key_adj) if key_adj is not None else float(key_px) + try: + upper_px, lower_px = storage_bounds_from_key_price(direction_sel, key_px) + except ValueError as e: + conn.close() + conn = None + flash(str(e)) + return redirect("/key_monitor") + ok_fb, err_fb = _add_false_breakout_key_monitor( + conn, symbol, direction_sel, upper_px, lower_px, key_px, breakeven_enabled=be_flag, + time_close_enabled=tc_en, time_close_hours=tc_h, + ) + conn.commit() + conn.close() + conn = None + if not ok_fb: + flash(err_fb or "假突破监控添加失败") + return redirect("/key_monitor") + flash( + f"假突破监控已添加,限价单已挂出({symbol})" + f"|有效期 {FALSE_BREAKOUT_VALIDITY_HOURS}h|移动保本:{'开' if be_flag else '关'}" + + (f"|{time_close_label(tc_h)}" if tc_en else "") + ) + return redirect("/key_monitor") + try: + upper_raw = float(d.get("upper") or 0) + lower_raw = float(d.get("lower") or 0) + except (TypeError, ValueError): + conn.close() + conn = None + flash("上下沿须为有效数字") + return redirect("/key_monitor") + upper_px = round_price_to_exchange(ex_sym_key, upper_raw) + lower_px = round_price_to_exchange(ex_sym_key, lower_raw) + if float(upper_px) <= float(lower_px): + conn.close() + conn = None + flash("上沿必须大于下沿") + return redirect("/key_monitor") + 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, + time_close_enabled=tc_en, time_close_hours=tc_h, + ) + conn.commit() + conn.close() + conn = None + if not ok_fib: + flash(err_fib or "斐波监控添加失败") + return redirect("/key_monitor") + flash( + f"斐波监控已添加,限价单已挂出({symbol} 日成交量排名 {rank}/{total})" + f"|移动保本:{'开' if be_flag else '关'}" + + (f"|{time_close_label(tc_h)}" if tc_en 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() + conn = None + flash("趋势单方案须填写有效止盈价") + return redirect("/key_monitor") + if direction_sel == "long" and manual_tp <= upper_px: + conn.close() + conn = None + flash("做多趋势单:止盈价应高于上沿(阻力)") + return redirect("/key_monitor") + if direction_sel == "short" and manual_tp >= lower_px: + conn.close() + conn = None + flash("做空趋势单:止盈价应低于下沿(支撑)") + return redirect("/key_monitor") + mtpx = round_price_to_exchange(ex_sym_key, manual_tp) + if mtpx is not None: + manual_tp = float(mtpx) + if mt in KEY_MONITOR_RS_TYPES: + conn.execute( + "INSERT INTO key_monitors " + "(symbol,monitor_type,direction,upper,lower,sl_tp_mode,manual_take_profit,breakeven_enabled," + "max_notify,notify_interval_min,time_close_enabled,time_close_hours) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + mt, + direction_sel, + upper_px, + lower_px, + sl_tp_mode, + manual_tp, + be_flag, + KEY_ALERT_MAX_TIMES, + KEY_ALERT_INTERVAL_MINUTES, + tc_en, + tc_h, + ), + ) + else: + conn.execute( + "INSERT INTO key_monitors " + "(symbol,monitor_type,direction,upper,lower,sl_tp_mode,manual_take_profit,breakeven_enabled," + "time_close_enabled,time_close_hours) " + "VALUES (?,?,?,?,?,?,?,?,?,?)", + (symbol, mt, direction_sel, upper_px, lower_px, sl_tp_mode, manual_tp, be_flag, tc_en, tc_h), + ) + conn.commit() + conn.close() + conn = None + 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 '关'}" + if tc_en: + extra += f"|{time_close_label(tc_h)}" + if mt in KEY_MONITOR_RS_TYPES: + flash( + f"添加成功({symbol} 日成交量排名 {rank}/{total})|关键支撑阻力:双向监控上/下沿," + f"5m 收盘突破后微信提醒 {KEY_ALERT_MAX_TIMES} 次(间隔 {KEY_ALERT_INTERVAL_MINUTES} 分钟)" + ) + else: + flash(f"添加成功({symbol} 日成交量排名 {rank}/{total}){extra}") + if ctr: + flash( + "⚠️ 4h EMA55 提示:当前与所选方向逆势;「箱体突破/收敛突破」在条件满足时仍会按计划自动市价开仓,请注意仓位." + ) + return redirect("/key_monitor") + except Exception as e: + if conn is not None: + try: + conn.close() + except Exception: + pass + flash(f"添加关键位失败:{e}") + 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_pol, pol_msg = validate_trade_policy_open(symbol, direction) + if not ok_pol: + conn.close() + flash(f"账户限制:{pol_msg}") + return redirect("/trade") + dup_msg = check_duplicate_submit(session, submit_scope_add_order(symbol, direction)) + if dup_msg: + conn.close() + flash(dup_msg) + return redirect("/trade") + ok, reason = precheck_risk(conn, symbol, direction) + if not ok: + 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) + 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, entry_model, style_err = parse_manual_order_style_fields( + TRADE_POLICY, d, default_trade_style=DEFAULT_TRADE_STYLE or "trend" + ) + if style_err: + conn.close() + flash(style_err) + return redirect("/trade") + if entry_model: + 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 = normalize_open_sltp_mode(d.get("sltp_mode")) + try: + stop_loss, take_profit = resolve_open_sltp_prices( + direction, live_price, sltp_mode, d + ) + except ValueError as e: + conn.close() + flash(str(e) or "止盈止损参数错误") + 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, 2) + if is_full_margin_mode(POSITION_SIZING_MODE): + ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn)) + if not ok_flat: + conn.close() + flash(flat_msg) + return redirect("/") + leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE) + sizing, sizing_err = compute_full_margin_sizing( + symbol=symbol, + available_usdt=available_usdt if available_usdt is not None else 0.0, + capital_base=capital_base, + buffer_ratio=FULL_MARGIN_BUFFER_RATIO, + btc_leverage=BTC_LEVERAGE, + alt_leverage=ALT_LEVERAGE, + funds_decimals=2, + ) + if sizing_err: + conn.close() + flash(sizing_err) + return redirect("/") + margin_capital = sizing["margin_capital"] + notional_value = sizing["notional_value"] + position_ratio = sizing["position_ratio"] + else: + 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("/") + notional_value = round(risk_amount / risk_fraction, 2) + margin_capital = round(notional_value / leverage, 2) + 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), 2) + 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 + risk_percent_db = risk_percent_for_storage(POSITION_SIZING_MODE, risk_percent) + risk_display = format_risk_display_text( + POSITION_SIZING_MODE, risk_percent, risk_amount_final, decimals=2 + ) + 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 + tc_en = parse_time_close_enabled_form(d.get("time_close_enabled")) + tc_h = parse_time_close_hours_form(d.get("time_close_hours")) if tc_en else None + if tc_en and not tc_h: + tc_en = 0 + tc_en, tc_h, tc_at = time_close_insert_values(tc_en, tc_h, opened_at_ms) + conn.execute( + "INSERT INTO order_monitors (symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, margin_capital, leverage, trade_style, entry_model, 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, time_close_enabled, time_close_hours, time_close_at_ms) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, exchange_symbol, direction, trigger_price, stop_loss, stop_loss, take_profit, + margin_capital, leverage, trade_style, entry_model, risk_percent_db, 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, + tc_en, tc_h, tc_at, + ) + ) + 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_display}", + "📊 仓位配置详情", + 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}", + format_daily_open_counter_line( + opens_today_after, DAILY_OPEN_ALERT_THRESHOLD, DAILY_OPEN_HARD_LIMIT + ), + ] + if chart_url: + wx_lines.append(f"多周期K线图:{chart_url}") + send_wechat_msg("\n".join(wx_lines)) + + flash_lines = [ + f"实盘开单成功:风格 {trade_style};风险 {risk_display};基数 {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 '-'};已在交易所挂条件止盈/止损委托(非仓位绑定型)", + format_daily_open_summary_short( + opens_today_after, DAILY_OPEN_ALERT_THRESHOLD, DAILY_OPEN_HARD_LIMIT + ), + ] + if chart_url: + flash_lines.append(f"已生成多周期K线图:{chart_url}") + flash(" ".join(flash_lines)) + + if should_send_daily_open_alert( + opens_today_before, opens_today_after, DAILY_OPEN_ALERT_THRESHOLD + ): + advice = ai_short_advice( + build_daily_open_alert_prompt( + trading_day, + opens_today_after, + DAILY_OPEN_ALERT_THRESHOLD, + hard_limit=DAILY_OPEN_HARD_LIMIT, + detail_line=f"最新一笔:{symbol} {direction},杠杆{leverage}x,基数{round(float(margin_capital), 2)}U.", + ) + ) + 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_limit_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_limit_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 " + f"FROM trade_records WHERE {sql_list_time_field('closed_at', 'created_at', 'opened_at')} >= ? " + f"AND {sql_list_time_field('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 = format_entry_type_display( + er1 or er0 or entry_reason_from_key_signal(kst) or "", + entry_model=r["entry_model"] if "entry_model" in r.keys() else None, + trade_style=r["trade_style"] if "trade_style" in r.keys() else None, + ) + 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,images_json,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", + "images_json", + "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=trade_record_monitor_type(conn, row), + trend_plan_id=trend_plan_id_from_monitor_row(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"], + entry_model=(row["entry_model"] if "entry_model" in row.keys() else None), + 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=handoff_trade_miss_reason("用户手动删除订单触发平仓", row), + opened_at=opened_at, + closed_at=closed_at, + ) + from lib.trade.account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close + + on_user_initiated_close( + conn, + source=CLOSE_SOURCE_USER_INSTANCE, + trade_record_id=insert_trade_record_id(conn), + closed_at_ms=_to_ms_with_fallback(None, closed_at), + trading_day=session_date, + now=app_now(), + ) + conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, id)) + try: + _rcfg = app.extensions.get("strategy_roll_cfg") + if isinstance(_rcfg, dict): + from lib.strategy.strategy_register import roll_sync_after_external_close + + roll_sync_after_external_close(_rcfg, conn, row["symbol"], row["direction"]) + except Exception: + pass + 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=trade_record_monitor_type(conn, row), + trend_plan_id=trend_plan_id_from_monitor_row(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"], + entry_model=(row["entry_model"] if "entry_model" in row.keys() else None), + 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=handoff_trade_miss_reason(miss_reason, row), + opened_at=opened_at, + closed_at=closed_at, + ) + from lib.trade.account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close + + on_user_initiated_close( + conn, + source=CLOSE_SOURCE_USER_INSTANCE, + trade_record_id=insert_trade_record_id(conn), + closed_at_ms=_to_ms_with_fallback(None, closed_at), + trading_day=session_date, + now=app_now(), + ) + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (id,)) + try: + _rcfg = app.extensions.get("strategy_roll_cfg") + if isinstance(_rcfg, dict): + from lib.strategy.strategy_register import roll_sync_after_external_close + + roll_sync_after_external_close(_rcfg, conn, row["symbol"], row["direction"]) + except Exception: + pass + 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_journal", methods=["POST"]) +@login_required +def add_journal(): + d = request.form + order_type_norm = normalize_journal_order_type(d.get("order_type")) + if not order_type_norm: + flash("请选择下单类型") + return _redirect_records() + direction_norm = normalize_journal_direction(d.get("direction") or d.get("direction_hint")) + if not direction_norm: + flash("请选择方向") + return _redirect_records() + entry_reason_norm = normalize_journal_entry_reason( + d.get("entry_reason"), ENTRY_REASON_OPTIONS, allow_legacy=False + ) + 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) + entry_id = normalize_journal_draft_id(d.get("journal_draft_id")) or uuid.uuid4().hex + manual_images = collect_journal_slot_images( + d, + request.files, + entry_id, + app.config["UPLOAD_FOLDER"], + secure_filename_fn=secure_filename, + ) + images_json_str = images_json_dumps(manual_images) + image_filename = primary_journal_image(manual_images) + has_manual_uploads = bool(manual_images) + + 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):.2f}" + except Exception: + pass + + want_exchange_chart = ( + not has_manual_uploads + and 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]}" + journal_tfs = parse_journal_chart_timeframes( + d.get("journal_chart_tf1"), + d.get("journal_chart_tf2"), + ORDER_CHART_TFS[:2] if ORDER_CHART_TFS else None, + ) + journal_limit = parse_journal_chart_limit(d.get("journal_chart_limit"), ORDER_CHART_LIMIT) + chart_anchor = parse_journal_chart_anchor(d.get("journal_chart_anchor")) + 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": d.get("exit_price_hint"), + "stop_loss_price": d.get("stop_loss_hint"), + "chart_anchor": chart_anchor, + "now_ts_ms": int(app_now().timestamp() * 1000), + } + try: + chart_fname = f"journal_{entry_id}.png" + saved = generate_multi_timeframe_chart_png( + exchange_symbol, + title_prefix, + timeframes=journal_tfs, + limit=journal_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 journal_tfs}, + layout="vertical", + ) + if saved: + image_filename = saved + chart_msg = f"已生成复盘K线图({'/'.join(journal_tfs)} 各{journal_limit}根):/static/images/{saved}" + else: + chart_msg = "已勾选自动生成K线图,但生成失败(返回空).请检查 Pillow 是否安装,Gate 网络/代理是否正常." + except Exception as e: + chart_msg = f"自动生成K线图失败:{str(e)}" + + conn = get_db() + conn.execute( + """INSERT INTO journal_entries + (id, open_datetime, close_datetime, hold_duration, coin, tf, direction, pnl, order_type, 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, images_json) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + entry_id, + normalize_bj_datetime_storage(d.get("open_datetime")), + normalize_bj_datetime_storage(d.get("close_datetime")), + hold_duration, + d.get("coin"), + d.get("tf"), + direction_norm, + d.get("pnl"), order_type_norm, 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"), None, d.get("note"), image_filename, + images_json_str, + ) + ) + from lib.trade.account_risk_lib import on_journal_saved + + on_journal_saved( + conn, + early_exit_trigger=early_exit_trigger, + early_exit_note=early_exit_note, + mood_issues_raw=mood_issues, + trading_day=get_trading_day(), + now=app_now(), + ) + conn.commit() + conn.close() + if chart_msg: + flash(f"交易复盘记录已保存.{chart_msg}") + else: + flash("交易复盘记录已保存") + return _redirect_records() + + +@app.route("/api/journal_upload_slot", methods=["POST"]) +@login_required +def api_journal_upload_slot(): + payload, code = handle_journal_upload_slot( + request, + upload_folder=app.config["UPLOAD_FOLDER"], + secure_filename_fn=secure_filename, + ) + return jsonify(payload), code + + +from lib.instance.records_api_register import register_trade_records_api + +register_trade_records_api( + app, + login_required=login_required, + get_db=get_db, + list_window_from_request=_list_window_from_request, + utc_window_to_bj_sql_strings=utc_window_to_bj_sql_strings, + sql_list_time_field=sql_list_time_field, + to_effective_trade_dict=to_effective_trade_dict, + filter_trade_records_excluding_miss=filter_trade_records_excluding_miss, + app_tz=APP_TZ, +) + +from lib.instance.instance_dashboard_register import register_instance_dashboard_routes + +register_instance_dashboard_routes( + app, + login_required=login_required, + get_db=get_db, + hedge_enabled=False, +) + + +@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() + j_ts = sql_list_time_field("close_datetime", "created_at", "open_datetime") + rows = conn.execute( + f"SELECT * FROM journal_entries WHERE {j_ts} >= ? AND {j_ts} <= ? ORDER BY created_at DESC LIMIT 500", + (start_bj, end_bj), + ).fetchall() + conn.close() + result = [] + for r in rows: + item = enrich_journal_api_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("/delete_journal/", methods=["POST"]) +@login_required +def delete_journal(jid): + conn = get_db() + row = conn.execute( + "SELECT image, images_json FROM journal_entries WHERE id=?", + (jid,), + ).fetchone() + if row: + for img_path in journal_image_paths(row, app.config["UPLOAD_FOLDER"]): + try: + if os.path.exists(img_path): + os.remove(img_path) + except Exception: + pass + 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_sql, end_sql = utc_window_to_utc_sql_strings(win["start_utc"], win["end_utc"]) + conn = get_db() + rows = conn.execute( + "SELECT * FROM ai_reviews WHERE created_at >= ? AND created_at <= ? ORDER BY created_at DESC LIMIT 200", + (start_sql, end_sql), + ).fetchall() + conn.close() + return jsonify([row_to_dict(r) for r in rows]) + + +_REPO_STATIC_DIR = common_static_dir(os.path.dirname(BASE_DIR)) +_AI_REVIEW_RENDER_JS = os.path.join(_REPO_STATIC_DIR, "ai_review_render.js") +_FORM_SUBMIT_GUARD_JS = os.path.join(_REPO_STATIC_DIR, "form_submit_guard.js") +_MANUAL_ORDER_RR_PREVIEW_JS = os.path.join(_REPO_STATIC_DIR, "manual_order_rr_preview.js") + + +@app.route("/static/ai_review_render.js") +def static_ai_review_render_js(): + if not os.path.isfile(_AI_REVIEW_RENDER_JS): + return Response("not found", status=404, mimetype="text/plain; charset=utf-8") + return send_file(_AI_REVIEW_RENDER_JS, mimetype="application/javascript; charset=utf-8") + + +@app.route("/static/form_submit_guard.js") +def static_form_submit_guard_js(): + if not os.path.isfile(_FORM_SUBMIT_GUARD_JS): + return Response("not found", status=404, mimetype="text/plain; charset=utf-8") + return send_file(_FORM_SUBMIT_GUARD_JS, mimetype="application/javascript; charset=utf-8") + + +@app.route("/static/manual_order_rr_preview.js") +def static_manual_order_rr_preview_js(): + if not os.path.isfile(_MANUAL_ORDER_RR_PREVIEW_JS): + return Response("not found", status=404, mimetype="text/plain; charset=utf-8") + return send_file(_MANUAL_ORDER_RR_PREVIEW_JS, mimetype="application/javascript; charset=utf-8") + + +@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": "结果仅允许:" + "/".join(REVIEW_RESULT_OPTIONS)}), 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": "开仓类型须为下拉选项之一或留空"}), 400 + reviewed_entry_reason_update = normalize_entry_reason(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]), + ) + if reviewed_result == "手动平仓" and reviewed_miss_reason: + from lib.trade.account_risk_lib import apply_manual_close_journal_cooloff + + apply_manual_close_journal_cooloff( + conn, + early_exit_note=reviewed_miss_reason, + trading_day=get_trading_day(), + now=app_now(), + ) + 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("/settings") + 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("/settings") + + +def _journal_ai_chart_builder(row): + return build_journal_ai_chart_path( + row, + app.config["UPLOAD_FOLDER"], + order_chart_enabled=ORDER_CHART_ENABLED, + normalize_exchange_symbol_fn=lambda c: normalize_exchange_symbol(normalize_symbol_input(c)), + generate_chart_fn=generate_multi_timeframe_chart_png, + local_datetime_to_ms_fn=_local_input_datetime_to_ms, + now_ts_ms_fn=lambda: int(app_now().timestamp() * 1000), + ) + + +@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 = collect_images_for_ai_review( + rows, + app.config["UPLOAD_FOLDER"], + build_chart_if_missing=_journal_ai_chart_builder, + ) + 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 = collect_images_for_ai_review( + rows, + app.config["UPLOAD_FOLDER"], + build_chart_if_missing=_journal_ai_chart_builder, + ) + 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}) + +def _hub_meta_bundle(): + return { + "exchange_display": EXCHANGE_DISPLAY_NAME, + "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}" + ), + "manual_min_planned_rr": MANUAL_MIN_PLANNED_RR, + "max_active_positions": MAX_ACTIVE_POSITIONS, + "btc_leverage": BTC_LEVERAGE, + "alt_leverage": ALT_LEVERAGE, + "trade_policy": trade_policy_template_context(TRADE_POLICY), + **hub_meta_entry_context(TRADE_POLICY), + } + + +def _hub_account_bundle(): + funding_capital, trading_capital = get_exchange_capitals(force=True) + funding_usdt = round(funding_capital, 2) if funding_capital is not None else None + trading_usdt = round(trading_capital, 2) if trading_capital is not None else None + available = get_available_trading_usdt() + return { + "funding_usdt": funding_usdt, + "trading_usdt": trading_usdt, + "available_trading_usdt": round(available, 2) if available is not None else None, + "trading_day": get_trading_day(app_now()), + } + + +def _hub_fetch_market(base=""): + from lib.hub.hub_market_info_lib import fetch_usdt_swap_market_info + + return fetch_usdt_swap_market_info( + base_or_symbol=base, + normalize_symbol_input=normalize_symbol_input, + normalize_exchange_symbol=normalize_exchange_symbol, + ensure_markets_loaded=ensure_markets_loaded, + exchange=exchange, + exchange_id="gate", + ) + + +def _hub_fetch_ohlcv(symbol, timeframe, since_ms=None, limit=500): + from lib.hub.hub_ohlcv_lib import fetch_ohlcv_for_hub + + return fetch_ohlcv_for_hub( + symbol=symbol, + timeframe=timeframe, + since_ms=since_ms, + limit=limit, + normalize_symbol_input=normalize_symbol_input, + normalize_exchange_symbol=normalize_exchange_symbol, + ensure_markets_loaded=ensure_markets_loaded, + exchange=exchange, + friendly_error=friendly_exchange_error, + ) + + +def _hub_fetch_volume_rank(top_n=20): + from lib.hub.hub_volume_rank_lib import fetch_usdt_swap_volume_rank + + return fetch_usdt_swap_volume_rank( + exchange=exchange, + ensure_markets_loaded=ensure_markets_loaded, + top_n=top_n, + exchange_id="gateio", + ) + + +try: + import sys + from pathlib import Path + + _repo_root = Path(__file__).resolve().parent.parent + if str(_repo_root) not in sys.path: + sys.path.insert(0, str(_repo_root)) + from lib.hub.hub_bridge import install_on_app + + install_on_app( + app, + exchange="gate", + capabilities=["order", "key"], + has_trend=True, + get_db=get_db, + row_to_dict=row_to_dict, + meta_fn=_hub_meta_bundle, + account_fn=_hub_account_bundle, + views={"add_order": add_order, "add_key": add_key}, + ohlcv_fn=_hub_fetch_ohlcv, + volume_rank_fn=_hub_fetch_volume_rank, + market_fn=_hub_fetch_market, + reconcile_hub_flat_fn=reconcile_hub_external_close, + risk_status_fn=hub_account_risk_status, + user_close_fn=hub_user_initiated_close, + render_main_page_fn=render_main_page, + login_required_fn=login_required, + ) +except Exception as _hub_err: + print(f"[hub_bridge] gate: {_hub_err}") + +try: + from lib.instance.instance_settings_register import register_instance_settings_routes + + register_instance_settings_routes( + app, + get_db=get_db, + login_required_fn=login_required, + base_dir=BASE_DIR, + exchange_key="gate", + username=USERNAME, + password=PASSWORD, + ) +except Exception as _settings_err: + print(f"[instance_settings] gate: {_settings_err}") + + +@app.route("/strategy") +@login_required +def strategy_trading_page(): + return render_main_page("strategy") + + +@app.route("/strategy/trend") +@login_required +def strategy_trend_page(): + qs = request.query_string.decode() + return redirect(f"/strategy?{qs}" if qs else "/strategy") + + +@app.route("/strategy/roll") +@login_required +def strategy_roll_page(): + return redirect("/strategy") + + +from lib.strategy.strategy_register import install_strategy_trading +from lib.strategy.strategy_trend_register import install_strategy_trend + +install_strategy_trading(app, _REPO_ROOT, app_module=sys.modules[__name__]) +install_strategy_trend(app, _REPO_ROOT, app_module=sys.modules[__name__]) + +_purge_key_monitors_if_full_margin() + + +# 启动 +if __name__ == "__main__": + from lib.common.flask_access_log_lib import silence_werkzeug_access_log + + silence_werkzeug_access_log() + threading.Thread(target=background_task, daemon=True).start() + app.run(host=HOST, port=PORT, debug=DEBUG, threaded=True) diff --git a/crypto_monitor_gate/ecosystem.config.cjs b/crypto_monitor_gate/ecosystem.config.cjs new file mode 100644 index 0000000..ffb5053 --- /dev/null +++ b/crypto_monitor_gate/ecosystem.config.cjs @@ -0,0 +1,34 @@ +/** + * PM2 进程定义(Ubuntu / Linux). + * + * 仅托管 Flask 应用.**SSH SOCKS 隧道**用 `ssh -D` 常驻(可用 tmux / autossh),勿交给 PM2. + * 与 `.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 REPO_ROOT = path.join(ROOT, ".."); +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", + env: { PYTHONPATH: REPO_ROOT }, + }, + ], +}; 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..97a910a --- /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..5ef52a3 --- /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/static/icons/apple-touch-icon.png b/crypto_monitor_gate/static/icons/apple-touch-icon.png new file mode 100644 index 0000000..129f899 Binary files /dev/null and b/crypto_monitor_gate/static/icons/apple-touch-icon.png differ diff --git a/crypto_monitor_gate/static/icons/favicon.ico b/crypto_monitor_gate/static/icons/favicon.ico new file mode 100644 index 0000000..dcb291e Binary files /dev/null and b/crypto_monitor_gate/static/icons/favicon.ico differ diff --git a/crypto_monitor_gate/static/icons/icon-16.png b/crypto_monitor_gate/static/icons/icon-16.png new file mode 100644 index 0000000..8ebd22a Binary files /dev/null and b/crypto_monitor_gate/static/icons/icon-16.png differ diff --git a/crypto_monitor_gate/static/icons/icon-192.png b/crypto_monitor_gate/static/icons/icon-192.png new file mode 100644 index 0000000..9039264 Binary files /dev/null and b/crypto_monitor_gate/static/icons/icon-192.png differ diff --git a/crypto_monitor_gate/static/icons/icon-32.png b/crypto_monitor_gate/static/icons/icon-32.png new file mode 100644 index 0000000..4add43f Binary files /dev/null and b/crypto_monitor_gate/static/icons/icon-32.png differ diff --git a/crypto_monitor_gate/static/icons/icon-512.png b/crypto_monitor_gate/static/icons/icon-512.png new file mode 100644 index 0000000..7c41ef9 Binary files /dev/null and b/crypto_monitor_gate/static/icons/icon-512.png differ diff --git a/crypto_monitor_gate/static/icons/icon.svg b/crypto_monitor_gate/static/icons/icon.svg new file mode 100644 index 0000000..e0249d5 --- /dev/null +++ b/crypto_monitor_gate/static/icons/icon.svg @@ -0,0 +1,6 @@ + + + + + G + diff --git a/crypto_monitor_gate/static/icons/manifest.webmanifest b/crypto_monitor_gate/static/icons/manifest.webmanifest new file mode 100644 index 0000000..8d8b774 --- /dev/null +++ b/crypto_monitor_gate/static/icons/manifest.webmanifest @@ -0,0 +1,23 @@ +{ + "name": "Gate 交易系统", + "short_name": "Gate 交易系统", + "description": "Gate 永续交易监控与复盘", + "start_url": "/", + "display": "standalone", + "background_color": "#0b0d14", + "theme_color": "#17E6A1", + "icons": [ + { + "src": "/static/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/static/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} 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/order_focus.html b/crypto_monitor_gate/templates/order_focus.html new file mode 100644 index 0000000..cb7c8df --- /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/使用说明.md b/crypto_monitor_gate/使用说明.md new file mode 100644 index 0000000..937e86e --- /dev/null +++ b/crypto_monitor_gate/使用说明.md @@ -0,0 +1,147 @@ +# 使用说明 + +**本文件对应仓库:`crypto_monitor_gate`(Gate.io USDT 永续).** +功能,界面与 **Binance U 本位版**(目录 `crypto_monitor_binance`)基本一致,差异主要在 **`.env` 里交易所密钥与部分参数名**(`GATE_*` / `BINANCE_*`),文末有对照. + +**更细的部署(SSH 代理,PM2,依赖安装)** 见同目录 **`部署文档.md`**. +**关键位自动开仓的规则,RR,结案原因** 见 **`关键位自动下单说明.md`**. + +--- + +## 1. 它能做什么 + +面向个人盘面的 **Web 控制台**,主要能力包括: + +| 模块 | 说明 | +|------|------| +| **关键位监控** | 录入上/下沿与类型,按 **5m 收线** 做硬条件过滤;符合条件后 **企业微信** 提醒,部分类型可 **自动市价开仓**(见第 4 节与专门文档). | +| **实盘下单监控** | 手工填止损/止盈,**以损定仓** 市价开单,挂上条件止盈止损,并在页面跟踪浮盈亏,保本逻辑等. | +| **交易记录 / 复盘** | 平仓结果,盈亏,错过的单等归档与导出;可选 **AI 复盘**(见 [AI复盘与模型配置说明.md](../AI复盘与模型配置说明.md)). | +| **策略交易** | 顶栏 `/strategy`:趋势回调 + 顺势加仓双栏;见 [策略交易说明.md](../策略交易说明.md). | + +后台按 **`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`). | +| **AI 复盘** | `AI_PROVIDER=openai`(默认)或 `ollama`;变量见 `.env.example` 与 [AI复盘与模型配置说明.md](../AI复盘与模型配置说明.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`** 登录. + +登录后顶栏:**关键位监控** | **实盘下单** | **策略交易**(`/strategy`)| **策略交易记录**(`/strategy/records`)| **交易记录与复盘** | **统计分析**. + +--- + +## 4. 关键位监控(顶栏「关键位监控」→ `/key_monitor`) + +### 4.1 添加一条关键位 + +1. **币种**:如 `BTC` 或 `BTC/USDT`(会规范成内部符号). +2. **类型**(必选其一): + + | 类型 | 行为摘要 | + |------|----------| + | **箱体突破** | 通过门控且计划 RR 达标 → **自动市价开仓**(需 `LIVE_TRADING_ENABLED=true` 且无其他持仓占位).结案后本条从列表消失并记入历史. | + | **收敛突破** | 同上(自动开仓类). | + | **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**. | + | **关键支撑位** | 同上(仅提醒). | + | **回调触价开仓** | **不挂交易所限价**;标记价回调触达 E 后 **下一轮询市价开仓**(RR 门槛同 `KEY_AUTO_MIN_PLANNED_RR`);有效期 **24h** | + | **突破触价开仓** | **不挂交易所限价**;标记价 **穿越 E 立即市价开仓**;先触 SL/TP 侧失效;有效期 **24h** | + +3. **方向**:做多 / 做空(触价开仓 / 箱体 / 收敛 / 斐波必选;阻力/支撑不选). +4. **价位**:箱体/收敛/阻力/支撑填 **上沿 / 下沿**;触价开仓填 **入场 E / 止损 SL / 止盈 TP**. + +**限制:** +活跃持仓数达到 **`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..a8ca535 --- /dev/null +++ b/crypto_monitor_gate/关键位自动下单说明.md @@ -0,0 +1,192 @@ +# 关键位监控说明(自动开仓 + 人工盯盘) + +**适用:Gate / Binance / OKX 三所实例(共用 `lib/key_monitor/key_auto_order_lib.py`)** + +## 环境开关 `KEY_AUTO_ORDER_ENABLED`(默认 `false`) + +| 计仓模式 | 开关 | 关键位程序自动单 | +|----------|------|------------------| +| `risk`(以损定仓) | `false` | **全部关闭**(含触价);支撑/阻力微信提醒仍可用 | +| `risk` | `true` | 箱体/收敛/斐波/假突破/触价均可自动(旧行为) | +| `full_margin`(全仓) | `false` | 全部关闭(含触价) | +| `full_margin` | `true` | **仅触价**自动;箱体/斐波等仍禁止 | + +**不受本开关影响:** 人工实盘下单,关键支撑/阻力提醒,**顺势加仓**(`risk` 下),趋势回调(`risk` 下).全仓模式下策略自动仍禁止. + +修改 `.env` 后须 **重启 PM2**.复盘「开仓类型」与统计分段会随开关联动隐藏关键位选项. + +--- + +**适用:`crypto_monitor_gate`(Gate U 本位永续)** +Binance / OKX 见各自目录下同名文档;共享逻辑在 `lib/key_monitor/`. + +本文档与 `.env`,`check_key_monitors`,`add_key`,`_key_hard_checks`,`_process_key_rs_level_alert` 一致. + +--- + +## 一,监控类型总览 + +| 录入类型 | 录入时选方向 | 自动市价开仓 | 触发与结案 | +|----------|--------------|--------------|------------| +| **箱体突破** | **必选** 多/空 | **是**(门控 + RR) | 条件满足 → 开仓或 `rr_insufficient` / `exchange_failed` → **一次性删除** | +| **收敛突破** | **必选** 多/空 | **是**(同上) | 同上 | +| **关键阻力位** | **不选**(`direction=watch`) | **否** | 5m 收盘突破上/下沿 → 微信 **3 次** → `key_level_alert_done` | +| **关键支撑位** | **不选** | **否** | 同上(与阻力位**相同规则**:填上沿+下沿,程序双向监控) | +| 斐波回调 0.618 / 0.786 | 必选 | 限价挂单逻辑 | 见斐波说明(**不在下文展开**) | +| **回调触价开仓** | **必选** 多/空 | **程序盯价 → 回调触 E 后市价** | 见下文 **§四** | +| **突破触价开仓** | **必选** 多/空 | **程序盯价 → 穿越 E 立即市价** | 见下文 **§四** | + +**添加时(箱体/收敛/斐波/触价):** 品种须 **日成交量排名前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30)**;上沿 **>** 下沿(触价开仓填 E/SL/TP,上下沿仅作展示占位). + +--- + +## 二,关键阻力位 / 关键支撑位(人工盯盘) + +### 2.1 录入 + +- 填写 **上沿 `upper`** 与 **下沿 `lower`**(程序同时监控两侧,**无法预先判定**做多还是做空). +- 页面 **不显示,不要求** 方向;库中 `direction` 初始为 `watch`,**首次突破后** 写入 `long`(向上突破上沿)或 `short`(向下突破下沿). + +### 2.2 触发(极简) + +- 周期:**`KLINE_TIMEFRAME`(默认 5m)最近一根已闭合 K** 的 **收盘价**(非影线). +- **向上突破上沿:** `收盘 > upper` → 推断方向 **多 / 向上**,本次监控任务开始按节奏提醒. +- **向下突破下沿:** `收盘 < lower` → 推断方向 **空 / 向下**,本次任务同样开始提醒. +- **任一侧突破即结束本条监控周期**(不会在突破后再等待另一侧;上沿,下沿谁先满足用谁,同根 K 仅可能满足一侧). + +**不参与:** 量能,二确 K,越过幅度下限,日成交排名(运行时),计划 RR,自动开仓. + +### 2.3 微信提醒次数 + +| 配置 | 默认 | 含义 | +|------|------|------| +| `KEY_ALERT_MAX_TIMES` | `3` | 突破后最多推送 3 次 | +| `KEY_ALERT_INTERVAL_MINUTES` | `5` | 相邻两次推送至少间隔 5 分钟 | + +- 第 1 次:首次检测到突破的当次轮询(若已闭合 5m 满足条件). +- 第 2,3 次:仅按间隔推送(**不要求**价格仍在箱外). +- 第 3 次推送后:写入 `key_monitor_history`,`close_reason=**key_level_alert_done**`,从 `key_monitors` **删除**. + +### 2.4 与箱体/收敛的区别 + +| 项目 | 阻力/支撑 | 箱体/收敛 | +|------|-----------|-----------| +| 方向 | 程序推断 | 人工选择 | +| K 线根数 | 1 根闭合 5m | 2 根(突破 K + 确认 K) | +| 提醒次数 | 3 次后结案 | 自动单:触发后 1 次业务推送并结案 | + +--- + +## 三,箱体突破 / 收敛突破(自动开仓) + +### 3.1 K 线结构(默认索引) + +| 角色 | 环境变量 | 默认 | 含义 | +|------|----------|------|------| +| 突破 K | `KEY_CONFIRM_BREAKOUT_BAR` | `-2` | 倒数第 2 根闭合 K | +| 确认 K | `KEY_CONFIRM_BAR` | `-1` | 倒数第 1 根闭合 K | + +### 3.2 硬门控(须全部通过) + +1. **有效突破(收盘越界)** + - 多:`突破 K 收盘 > upper` + - 空:`突破 K 收盘 < lower` + +2. **突破越过幅度(仅下限)** + - 多:`(突破 K 收盘 − upper) / upper × 100 > KEY_BREAKOUT_AMP_MIN_PCT`(默认 **0.03%**) + - 空:`(lower − 突破 K 收盘) / lower × 100 >` 同上 + - **无上限**;突破过猛由 **计划 RR** 过滤. + - **不再**使用 K 线实体占开盘价比例;`KEY_BREAKOUT_AMP_MAX_PCT` **已不参与门控**. + +3. **确认 K 不进箱体** + - 多:确认 K 收盘 **`> upper`**(不得在 `[lower, upper]` 内) + - 空:确认 K 收盘 **`< lower`** + +4. **量能:** 突破 K 成交量 > 前 `KEY_VOLUME_MA_BARS`(默认 20)根均量 × `KEY_VOLUME_RATIO_MIN`(默认 1.3) + +5. **日成交量排名:** 运行时仍须前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30) + +6. **计划 RR(最后经济门控):** 按确认 K 收盘 **E** 计算 SL/TP 后,`RR` **严格大于** `KEY_AUTO_MIN_PLANNED_RR`(默认 1.5)才市价开仓 + +### 3.3 止损 / 止盈(确认 K 收盘为 E) + +箱体高 **H = |upper − lower|**.止损锚在 **突破 K 极值** 外侧: + +| 方向 | 止损(标准/趋势方案) | +|------|------------------------| +| 多 | 突破 K **最低价** × (1 − `KEY_STOP_OUTSIDE_BREAKOUT_PCT`%) | +| 空 | 突破 K **最高价** × (1 + `KEY_STOP_OUTSIDE_BREAKOUT_PCT`%) | + +止盈方案见下表(与改版前一致): + +| 方案 | `sl_tp_mode` | 多:SL / TP | 空:SL / TP | +|------|--------------|-------------|-------------| +| 标准突破 | `standard` | 突破 K 低外侧% / **E+H** | 突破 K 高外侧% / **E−H** | +| 箱体 1R·止盈 1.5H | `box_1p5` | **E−H** / **E+1.5×H** | **E+H** / **E−1.5×H** | +| 趋势单·自填止盈 | `trend_manual` | 突破 K 低 × (1−`KEY_TREND_STOP_OUTSIDE_PCT`%) / **录入止盈** | 突破 K 高外侧% / **录入止盈** | + +### 3.4 一次性结案(`close_reason`) + +| `close_reason` | 含义 | +|----------------|------| +| `box_opposite_break` | 标记价先突破反向边界(多:≤下沿;空:≥上沿) | +| `rr_insufficient` | 门控通过但 RR 不达标或 SL/TP 几何无效 | +| `exchange_failed` | RR 达标但实盘/交易所等原因未开仓 | +| `auto_opened` | RR 达标且市价开仓成功 | +| `key_level_alert_done` | 阻力/支撑 **3 次提醒** 完成 | + +--- + +## 四,回调 / 突破触价开仓(程序触价,无交易所挂单) + +### 4.1 录入 + +- **回调触价开仓**:方向必选多/空;填写 **计划入场价 E**,**止损 SL**,**止盈 TP**(做多须 `SL < E < TP`). +- **突破触价开仓**:同上;添加时当前价须在突破方向一侧(做多:价低于 E;做空:价高于 E). +- 计划 RR 以 **E** 为基准,须 **严格大于** `KEY_AUTO_MIN_PLANNED_RR`(默认 1.5). +- 可选移动保本,时间平仓;**全仓杠杆模式**下可用. + +### 4.2 触发与结案 + +| 类型 | 触发条件(标记价) | +|------|-------------------| +| **回调触价** | 做多 `≤ E`;做空 `≥ E` → 下一轮询市价开仓 | +| **突破触价** | 做多**向上穿越** E;做空**向下穿越** E → **立即**市价开仓 | + +- 未成交前标记价先触 **TP 侧** → `trigger_tp_invalidate`. +- **突破触价**另:未穿越 E 先触 **SL 侧** → `trigger_sl_invalidate`. +- **24h** 未触发 → `trigger_entry_expired`. +- 成功 → `trigger_entry_filled`;触发后开仓失败 → `trigger_exchange_failed`. + +### 4.3 计仓与占位 + +- **以损定仓**:按 E,SL 反推保证金,触发时重算;**全仓杠杆**:可用×缓冲比例,BTC/ETH 10x,其它 5x. +- **占当日开仓意图**(已开 + 待触发),未成交不占持仓;同币仅 1 条触价监控(含回调/突破). + +共享逻辑:`trigger_entry_key_monitor_lib.py`;轮询:`check_trigger_entry_key_monitors`. + +--- + +## 五,环境与参数(`.env` 摘要) + +| 变量 | 箱体/收敛 | 阻力/支撑 | +|------|-----------|-----------| +| `KEY_BREAKOUT_AMP_MIN_PCT` | 突破越过下限(默认 0.03) | 不用 | +| `KEY_BREAKOUT_AMP_MAX_PCT` | **已废弃门控** | 不用 | +| `KEY_VOLUME_*` / `KEY_CONFIRM_*` | 用 | 不用 | +| `KEY_AUTO_MIN_PLANNED_RR` | 用 | 不用 | +| `KEY_ALERT_MAX_TIMES` / `KEY_ALERT_INTERVAL_MINUTES` | 不用 | 用(默认 3 次 / 5 分钟) | +| `KEY_DAILY_VOLUME_RANK_MAX` | 添加时 + 运行时 | **仅添加时** | + +--- + +## 六,相关代码 + +| 说明 | 位置 | +|------|------| +| 共享判定 | `key_monitor_lib.py` | +| 主循环 | `check_key_monitors` | +| 自动门控 | `_key_hard_checks` | +| 阻力/支撑提醒 | `_process_key_rs_level_alert` | +| 录入 | `add_key` | +| 开仓 | `_market_open_for_key_monitor` | diff --git a/crypto_monitor_gate/更新文档.md b/crypto_monitor_gate/更新文档.md new file mode 100644 index 0000000..158e370 --- /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.4 节(自动备份) + +## 数据库(启动时自动迁移) + +`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 / **各执行一次** `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..7265569 --- /dev/null +++ b/crypto_monitor_gate/部署文档.md @@ -0,0 +1,305 @@ +# `crypto_monitor_gate` 部署指南:SSH SOCKS + Gate.io + PM2(Ubuntu) + +Ubuntu 环境(Python / Node / PM2,/opt 路径)见 **[docs/ubuntu-server.md](../docs/ubuntu-server.md)**. + +本文面向:**在本机运行本项目**,但 **直连 Gate.io API 不稳定或被重置** 的场景.思路是: + +- 本机用 `ssh -D` 做动态转发,把 **SOCKS5 出口**放到能正常访问 Gate 的机器(常见为一台境外 VPS) +- 项目在 `.env` 中设置 **`GATE_SOCKS_PROXY=socks5h://127.0.0.1:1080`**(或你实际端口),`ccxt` 经 SOCKS 访问交易所 +- **SSH 隧道**:用 `ssh -D` 在本机常驻(可用 **tmux** 或 **autossh** 保持连接),**不要** 把 `ssh` 交给 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_user +cd /opt/crypto_monitor_user +git clone https://git.bz121.com/dekun/crypto_monitor_user.git +cd crypto_monitor/crypto_monitor_gate +``` + +下文用 **`/opt/crypto_monitor_user/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_user/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_user/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 AI 复盘与模型(可选) + +共用根目录 **`ai_client.py`**(`PYTHONPATH=..`).`.env` 中 **`AI_PROVIDER=openai`**(默认)时使用 `OPENAI_API_BASE=https://op.bz121.com/v1`,`OPENAI_API_KEY`,`OPENAI_MODEL=gemma4:e4b`;改 **`ollama`** 则用 `OLLAMA_API` + `AI_MODEL`.详见 **[AI复盘与模型配置说明.md](../AI复盘与模型配置说明.md)**. + +### 5.4 自动备份(数据库 + 复盘图片) + +与 Binance 实例相同:每天 **北京时间 0:00** → **`/root/backups`**,保留 **30 天**. + +```bash +cd /opt/crypto_monitor_user/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.4 节(恢复步骤,可选 `.env` 变量相同). + +若还部署了 **`crypto_monitor_okx`**,请在该目录同样执行 `bash scripts/install_backup_cron.sh`. + +### 5.5 必填项检查(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_user/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_user/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) + +示例(前台调试;生产请用 **PM2**,见本文与 [docs/ubuntu-server.md](../docs/ubuntu-server.md)): + +```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_user/crypto_monitor_gate +pm2 start /opt/crypto_monitor_user/crypto_monitor_gate/.venv/bin/python --name crypto-monitor-gate -- \ + /opt/crypto_monitor_user/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_okx/.env.example b/crypto_monitor_okx/.env.example new file mode 100644 index 0000000..41b86d1 --- /dev/null +++ b/crypto_monitor_okx/.env.example @@ -0,0 +1,276 @@ +# ============================================================================= +# 环境配置模板(可提交 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=admin +# 登录密码(请改成你自己的强密码) +APP_PASSWORD=admin123 +# 是否关闭登录校验(局域网可设 true;公网务必 false) +APP_AUTH_DISABLED=true +# --- 多账户交易中控 manual_trading_hub --- +# 中控请求本实例 /api/hub/* 时携带请求头 X-Hub-Token,须与中控启动环境变量 HUB_BRIDGE_TOKEN 一致 +# 未设置且 APP_AUTH_DISABLED=false 时,仅网页登录后可访问;本机联调可保持 APP_AUTH_DISABLED=true +# HUB_BRIDGE_TOKEN=your-long-random-token +# 允许复盘中控 iframe 内嵌本实例(与 hub 域名一致;默认已开启) +# APP_ALLOW_HUB_EMBED=true +# HUB_EMBED_PARENT_ORIGINS=https://hub.example.com +# HTTPS 且经 iframe 打开时建议 true;不设则 hub-sso 在 HTTPS 下也会自动尝试 SameSite=None +# APP_COOKIE_SECURE=true +# Flask 会话密钥(必须替换为长随机字符串) +FLASK_SECRET_KEY=CHANGE_TO_LONG_RANDOM_SECRET + +# 企业微信机器人 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_okx + +# 训练总资金(U) +# TOTAL_CAPITAL=100 # 已弃用,资金展示读交易所 +# 计仓:risk=以损定仓(默认);full_margin=合约可用×FULL_MARGIN_BUFFER_RATIO 全仓杠杆(须无仓后重启) +POSITION_SIZING_MODE=risk +# 方向限制(默认 false=双向均可;true 时按 TRADE_DIRECTION 限制,修改后须重启) +# TRADE_DIRECTION=long_only | short_only | both(或 多/空/双向) +TRADE_DIRECTION_RESTRICT_ENABLED=false +TRADE_DIRECTION=both +# 币种白名单(默认 false=全币种可手输;true 时关键位/下单/策略仅下拉选择) +TRADE_SYMBOL_RESTRICT_ENABLED=false +TRADE_SYMBOL_WHITELIST=BTC,ETH +# 每天起始基数(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 + +# 是否开启 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 +# 从 OKX 历史仓位同步已实现盈亏(北京时间起点,空=近 90 天 0 点起) +# EXCHANGE_POSITION_SYNC_FROM_BJ=2026-01-01 +# 单次拉取历史仓位条数上限(OKX 每页最多 100,程序会分页) +# EXCHANGE_POSITION_HISTORY_LIMIT=200 +# 页面与浏览器标签展示的交易所名称(多环境区分时可改成例如 OKX·测试网) +EXCHANGE_DISPLAY_NAME=OKX +# 企业微信推送里展示的账户备注 +# OKX_ACCOUNT_LABEL= + +# ============================================================================= +# 期权(主账户 API,与永续子账户 OKX_API_* 分离;修改后须重启 PM2) +# 详见 docs/期权方案.md 与 docs/期权用法.md +# ============================================================================= +OKX_OPTIONS_ENABLED=false +OKX_OPTIONS_API_KEY= +OKX_OPTIONS_API_SECRET= +OKX_OPTIONS_API_PASSPHRASE= +OKX_OPTIONS_ACCOUNT_LABEL=主账户·期权 +OKX_OPTIONS_TRADE_BUDGET_USDC=10 +OKX_OPTIONS_BUDGET_BUFFER=0.95 +OKX_OPTIONS_DEFAULT_UNDERLY=ETH +OKX_OPTIONS_MAX_DTE_DAYS=2 +OKX_OPTIONS_CHAIN_MAX_DTE_DAYS=14 +OKX_SUB_ACCOUNT_NAME= +OKX_OPTIONS_ITM_MAX_DIST_USD=30 +OKX_OPTIONS_PROFIT_ALERT_RATIO=1.0 +OKX_OPTIONS_POLL_SECONDS=15 +OKX_OPTIONS_TD_MODE=isolated +OKX_OPTIONS_ALLOW_MARKET_CLOSE=false + +# ============================================================================= +# 对冲计划(仅 OKX;前端 env「对冲计划」;详见 docs/对冲计划开发方案.md) +# ============================================================================= +HEDGE_PLAN_ENABLED=false +HEDGE_PLAN_LIVE_ORDER=false +HEDGE_PLAN_OPEN_ORDER=options_first +HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS=true +HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS=false +HEDGE_PLAN_OO_CLOSE_WINNER_ONLY=true +MAX_ACTIVE_HEDGE_PLANS=1 +HEDGE_PLAN_MONITOR_POLL_SECONDS=15 +HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION=true + +# ============================================================================= +# 关键位程序自动下单(与 POSITION_SIZING_MODE 联动,修改后须重启 PM2) +# ============================================================================= +# 默认 false = 关闭所有关键位程序自动单(箱体/收敛/斐波/假突破/触价) +# +# POSITION_SIZING_MODE=risk(以损定仓) +# false → 不执行任何关键位自动单;支撑/阻力提醒,人工下单,顺势加仓不受影响 +# true → 允许关键位全套自动(含触价) +# +# POSITION_SIZING_MODE=full_margin(全仓杠杆,须无仓切换) +# false → 不执行触价自动单 +# true → 仅回调/突破触价可程序自动开仓;箱体/斐波等仍禁止 +# +# 顺势加仓,趋势回调不受本开关控制;全仓模式下策略自动仍禁止. +KEY_AUTO_ORDER_ENABLED=false + +# ============================================================================= +# 关键位门控(页面「关键位监控」规则条与 _key_hard_checks 共用) +# ============================================================================= +# 【周期】门控 K 线周期,如 5m,15m;仅影响关键位硬条件,不改变顶栏分区 +KLINE_TIMEFRAME=5m +# OKX 遗留:突破过滤百分比(与 KEY_BREAKOUT_AMP_* 并存,程序仍读取) +KEY_BREAKOUT_LIMIT_PCT=1.5 +# 【确认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收盘越过关键位(占该侧价格%)的下限;无上限(过猛由计划RR过滤) +KEY_BREAKOUT_AMP_MIN_PCT=0.03 +# 已不参与门控,可保留配置项兼容旧环境 +KEY_BREAKOUT_AMP_MAX_PCT=0.5 +# 【阻力/支撑】突破后微信提醒次数与间隔(分钟) +KEY_ALERT_MAX_TIMES=3 +KEY_ALERT_INTERVAL_MINUTES=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 + +# ============================================================================= +# 交易执行 / 人工风控(页面「实盘下单」) +# ============================================================================= +# 【最大同时持仓】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 +# 【单日开仓 AI 提醒】本交易日开仓达到该次数时推送企业微信 AI 克制提醒(不拦单) +DAILY_OPEN_ALERT_THRESHOLD=5 +# 【单日开仓硬上限】本交易日开仓次数>=该值后禁止一切新开仓直至下一交易日(北京时间 TRADING_DAY_RESET_HOUR 切日);0=不启用 +DAILY_OPEN_HARD_LIMIT=0 + +# ============================================================================= +# 账户冷静期 / 日冻结风控(手动平仓,外部平仓,复盘情绪标签) +# 详见 docs/account-risk-cooldown.md +# ============================================================================= +RISK_CONTROL_ENABLED=true +RISK_COOLING_HOURS_MANUAL=4 +RISK_COOLING_HOURS_MANUAL_JOURNAL=1 +RISK_MANUAL_CLOSE_DAILY_LIMIT=2 +RISK_MOOD_ISSUES_DAILY_FREEZE=true + +# 资金与仓位刷新周期(秒) +BALANCE_REFRESH_SECONDS=60 +# 前端价格快照轮询(秒) +PRICE_REFRESH_SECONDS=5 +# 后台监控轮询周期(秒) +MONITOR_POLL_SECONDS=3 +# 移动保本同步交易所止盈止损的最小间隔(秒),避免频繁撤挂叠单 +BREAKEVEN_EXCHANGE_MIN_INTERVAL_SEC=60 +# 重启后多少秒内不做「外部平仓」同步(避免 API 未就绪误判) +RECONCILE_STARTUP_GRACE_SEC=90 +# 连续多少次轮询确认交易所空仓后,才记为外部平仓(默认 3 次 ≈ 9 秒) +RECONCILE_FLAT_CONFIRM_POLLS=3 +# 使用可用资金时的缓冲比例(如0.98代表用98%) +FULL_MARGIN_BUFFER_RATIO=0.98 + +# ============================================================================= +# 自动划转(页顶「将 swap 补足到 XU」;与 DAILY_START_CAPITAL 独立,需一致时请设为相同值) +# ============================================================================= +AUTO_TRANSFER_ENABLED=false +# 交易账户(swap)目标余额 U:每日 8 点(北京)自动划入或划出至 funding;持仓中不划转 +AUTO_TRANSFER_AMOUNT=30 +AUTO_TRANSFER_FROM=funding +AUTO_TRANSFER_TO=swap +TRANSFER_CCY=USDT +# 北京时间该整点小时内尝试;账簿按 UTC 自然日去重 +AUTO_TRANSFER_BJ_HOUR=8 +# 强制清仓整点(北京时间,默认 0=凌晨00点) +FORCE_CLOSE_BJ_HOUR=0 +# 是否启用强制清仓(默认关闭,true 才会在整点执行) +FORCE_CLOSE_ENABLED=false + +# 推送与AI超时(秒) +WECHAT_TIMEOUT_SECONDS=10 +AI_TIMEOUT_SECONDS=120 + +# AI 复盘服务地址(本机 Ollama 默认地址) +AI_PROVIDER=openai +OPENAI_API_BASE=https://op.bz121.com/v1 +OPENAI_API_KEY=你的密钥 +OPENAI_MODEL=gemma4:e4b +OLLAMA_API=http://127.0.0.1:11434/api/generate +AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest + +# OKX 代理(可选,仅本地开发网络受限时用;云服务器部署请留空,直连 OKX 即可) +# 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 / DAILY_OPEN_HARD_LIMIT;说明文档 docs/daily-open-limit.md +# 以损定仓(按交易账户资金的百分比) +# 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 +# TRADING_DAY_RESET_HOUR 现在表示「北京时间」整点,默认 8 点起算新交易日;开仓整点限制见 TRADING_DAY_RESET_OPEN_GUARD_ENABLED diff --git a/crypto_monitor_okx/README.md b/crypto_monitor_okx/README.md new file mode 100644 index 0000000..3ac412c --- /dev/null +++ b/crypto_monitor_okx/README.md @@ -0,0 +1,53 @@ +# crypto_monitor_okx + +基于 **Flask** 的加密货币 **下单监控 / 关键位监控 / 交易复盘** 小系统,行情与实盘接口统一走 **OKX(USDT 永续)**,通过 **ccxt** 访问.功能与界面已与 **`crypto_monitor_binance`** 对齐(顶栏分栏,风控参数,交易所 TP/SL 管理等),差异主要在 **`.env` 的 `OKX_*` 变量** 与 OKX API(含 Passphrase). + +## 功能概要 + +- **关键位监控**:`/key_monitor`,5m 门控,企业微信,部分类型自动开仓(见 `关键位自动下单说明.md`) +- **实盘下单**:`/trade`,以损定仓,移动保本,页面内撤挂止盈止损 +- **策略交易**:`/strategy`(趋势回调 + 顺势加仓),见 [策略交易说明.md](../策略交易说明.md) +- **AI 复盘**:见 [AI复盘与模型配置说明.md](../AI复盘与模型配置说明.md) +- **实盘(可选)**:`LIVE_TRADING_ENABLED=true` 且配置 `OKX_API_KEY` / `OKX_API_SECRET` / `OKX_API_PASSPHRASE` +- **止盈止损(OKX)**:市价成交后通过 ccxt 挂 **止损 / 止盈** 条件单(`attachAlgoOrds` 或 reduceOnly 市价单路径,见 `app.py`) + +## 环境要求 + +- Python 3.10+ +- 依赖见仓库根 `requirements.txt`;经 **SSH SOCKS** 访问 OKX 时需 **`PySocks`**,并配置 `OKX_SOCKS_PROXY=socks5h://127.0.0.1:1080` + +## 配置说明 + +| 变量 | 说明 | +|------|------| +| `OKX_API_KEY` / `OKX_API_SECRET` / `OKX_API_PASSPHRASE` | OKX API | +| `OKX_TD_MODE` / `OKX_POS_MODE` | 全仓/逐仓,单向/双向 | +| `OKX_SOCKS_PROXY` | 本机 SSH 动态转发时常用 | +| `MAX_ACTIVE_POSITIONS` / `MANUAL_MIN_PLANNED_RR` | 与币安版一致的风控 | +| `EXCHANGE_DISPLAY_NAME` | 页面展示名,默认 `OKX` | + +完整模板见 **`.env.example`**. + +## 运行 + +```bash +cd /opt/crypto_monitor_user/crypto_monitor_okx +source .venv/bin/activate +python app.py +``` + +生产使用 **PM2**;见 [docs/ubuntu-server.md](../docs/ubuntu-server.md).默认 **`APP_PORT`** 常为 `5004`. + +## 部署 + +详见 **[部署文档.md](./部署文档.md)**,**[使用说明.md](./使用说明.md)**. + +## 自检 + +```bash +python scripts/verify_okx_funding.py +``` + +## 风险与合规 + +实盘风险自负;请确认 API 权限,IP 白名单与 OKX 账户设置一致. diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py new file mode 100644 index 0000000..7a43a2d --- /dev/null +++ b/crypto_monitor_okx/app.py @@ -0,0 +1,9666 @@ +from flask import Flask, render_template, request, redirect, url_for, flash, session, jsonify, Response, send_file +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 lib.paths import common_static_dir +from lib.ai.ai_client import ai_generate, ai_review, ai_short_advice +from lib.ai.ai_review_lib import ( + build_journal_ai_chart_path, + collect_images_for_ai_review, + journal_row_lines_for_ai, +) +from lib.common.form_submit_lib import check_duplicate_submit, submit_scope_add_key, submit_scope_add_order +from lib.key_monitor.fib_key_monitor_lib import ( + FIB_KEY_MONITOR_TYPES, + backfill_missing_key_signal_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 lib.key_monitor.false_breakout_key_monitor_lib import ( + FALSE_BREAKOUT_MONITOR_TYPE, + FALSE_BREAKOUT_VALIDITY_HOURS, + calc_false_breakout_plan, + expires_at_text, + false_breakout_gate_preview, + is_false_breakout_expired, + is_false_breakout_key_monitor_type, + is_limit_key_monitor_type, + key_price_from_row, + normalize_false_breakout_symbol, + storage_bounds_from_key_price, +) +from lib.strategy.strategy_trade_labels import ( + JOURNAL_ORDER_TYPE_OPTIONS, + apply_order_monitor_source_labels, + entry_reason_for_monitor_type, + handoff_trade_miss_reason, + normalize_journal_order_type, + order_monitor_source_type, + trade_record_monitor_type as resolve_trade_record_monitor_type, + trend_plan_id_from_monitor_row, +) +from lib.instance.journal_form_lib import normalize_journal_direction, normalize_journal_entry_reason +from lib.exchange.okx_orders_lib import cancel_okx_all_open_orders, fetch_okx_all_open_orders +from lib.instance.journal_images_lib import ( + collect_journal_slot_images, + enrich_journal_api_item, + images_json_dumps, + journal_image_paths, + normalize_journal_draft_id, + primary_journal_image, +) +from lib.instance.journal_upload_api_lib import handle_journal_upload_slot +from lib.instance.journal_chart_lib import ( + JOURNAL_CHART_DEFAULT_LIMIT, + JOURNAL_CHART_DEFAULT_TF1, + JOURNAL_CHART_DEFAULT_TF2, + JOURNAL_CHART_TF_CHOICES, + compose_chart_panels, + marker_points_for_timeframe, + parse_journal_chart_anchor, + parse_journal_chart_limit, + parse_journal_chart_timeframes, + JOURNAL_CHART_DEFAULT_ANCHOR, + price_levels_from_marker_payload, + render_candles_subplot, + trade_review_fetch_window, + trim_rows_for_trade_review, +) +from lib.key_monitor.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 lib.trade.time_close_lib import ( + TIME_CLOSE_RESULT, + apply_time_close_to_payload, + ensure_time_close_schema, + parse_time_close_enabled_form, + parse_time_close_hours_form, + should_trigger_time_close, + time_close_insert_values, + time_close_label, + time_close_settings_from_row, +) +from lib.trade.force_close_lib import ( + apply_force_close_display_result, + apply_force_close_to_payload, + enrich_orders_force_close, + force_close_template_context, +) +from lib.trade.manual_sltp_lib import ( + normalize_open_sltp_mode, + resolve_entrust_sltp_prices, + resolve_open_sltp_prices, +) +from lib.key_monitor.key_monitor_schema_lib import ensure_key_monitor_schema +from lib.key_monitor.trigger_entry_key_monitor_lib import ( + BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE, + CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE, + TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED, + TRIGGER_ENTRY_CLOSE_EXPIRED, + TRIGGER_ENTRY_CLOSE_FILLED, + TRIGGER_ENTRY_CLOSE_SL_INVALIDATE, + TRIGGER_ENTRY_CLOSE_TP_INVALIDATE, + TRIGGER_ENTRY_MONITOR_TYPE, + TRIGGER_ENTRY_MONITOR_TYPES, + TRIGGER_ENTRY_VALIDITY_HOURS, + check_trigger_entry_intent_limit, + count_pending_trigger_entries, + acquire_trigger_entry_exec_lock, + is_trigger_entry_in_flight_row, + release_trigger_entry_exec_lock, + is_breakout_trigger_entry_key_monitor_type, + is_trigger_entry_expired, + is_trigger_entry_key_monitor_type, + trigger_entry_expires_at_text, + trigger_entry_gate_preview, + trigger_entry_invalidate, + trigger_should_fire, + validate_trigger_entry_geometry, + validate_trigger_entry_rr, +) +from lib.trade.position_sizing_lib import ( + OPEN_SOURCE_KEY_AUTO, + OPEN_SOURCE_MANUAL, + assert_open_source_allowed, + compute_full_margin_sizing, + format_risk_display_text, + full_margin_requires_flat_position, + is_full_margin_mode, + leverage_for_full_margin, + load_position_sizing_mode, + mode_label_zh, + risk_percent_for_storage, +) +from lib.trade.trade_policy_lib import load_trade_policy +from lib.trade.entry_model_lib import ( + build_intraday_entry_reason_options, + build_journal_entry_reason_options, + enrich_entry_model_display, + hub_meta_entry_context, + migrate_entry_model_columns, + order_entry_template_context, + open_position_button_label, + parse_manual_order_style_fields, + resolve_effective_trade_entry_reason, + format_entry_type_display, + resolve_trade_record_entry_reason, + trend_manual_entry_reason_count, +) +from lib.trade.trade_policy_app_lib import ( + check_direction_policy, + check_open_policy, + check_symbol_policy, + default_symbol_for_policy, + trade_policy_template_context, +) +from lib.key_monitor.key_auto_order_lib import ( + check_monitor_type_add_allowed, + effective_entry_reason_options, + effective_stats_segment_defs, + load_key_auto_order_enabled, +) +from lib.key_monitor.key_monitor_full_margin_lib import ( + monitor_type_disallowed_in_full_margin, + purge_disallowed_key_monitors, +) +from lib.common.auto_transfer_daily_lib import run_auto_transfer_once_per_day +from lib.key_monitor.key_monitor_lib import ( + KEY_DIRECTION_WATCH, + KEY_MONITOR_ALERT_ONLY_TYPES, + KEY_MONITOR_AUTO_TYPES, + KEY_MONITOR_RS_TYPE, + KEY_MONITOR_RS_TYPES, + auto_amp_ok, + auto_confirm_ok, + box_breakout_invalidate_by_mark, + box_breakout_invalidate_edge_label, + claim_rs_level_notify, + detect_rs_box_break, + format_auto_amp_line, + format_auto_confirm_line, + key_monitor_rule_template_context, + notify_interval_elapsed, + resolve_rs_break_for_alert, + rs_break_from_direction, + run_rs_level_alert_tick, +) +from lib.trade.order_monitor_display_lib import ( + apply_order_price_display_fields, + enrich_order_display_fields, + order_monitor_tpsl_needs_sync, + stale_breakeven_armed, +) +from lib.common.wechat_notify_lib import build_wechat_rs_level_message, send_wechat_webhook +from lib.hub.hub_auth import request_allowed as hub_request_allowed +from lib.instance.instance_nav_lib import request_is_hub_soft_nav +from lib.hub.hub_volume_rank_lib import resolve_daily_volume_rank +from lib.common.history_window_lib import ( + PRESET_ALL, + PRESET_CUSTOM, + PRESET_DEFAULT, + PRESET_UTC_LAST24H, + PRESET_UTC_LAST3M, + PRESET_UTC_LAST6M, + PRESET_UTC_LAST7D, + PRESET_UTC_THIS_MONTH, + PRESET_UTC_TODAY, + list_window_redirect_query, + normalize_bj_datetime_storage, + resolve_list_window, + resolve_window, + sql_list_time_field, + utc_window_to_bj_sql_strings, + utc_window_to_utc_sql_strings, +) +from lib.trade.trade_result_lib import ( + count_winning_trades, + filter_trade_records_excluding_miss, + normalize_result_with_pnl, +) +from lib.trade.trade_exchange_stats_lib import attach_exchange_stats_to_trade, filter_position_lifecycle_fills + + +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") +from lib.instance.instance_embed_lib import attach_embed_templates, redirect_to_embed_shell_if_enabled + +attach_embed_templates(app, _REPO_ROOT) + +# ====================== 登录配置 ====================== +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")) +TRADING_DAY_RESET_OPEN_GUARD_ENABLED = os.getenv( + "TRADING_DAY_RESET_OPEN_GUARD_ENABLED", "true" +).lower() in ("1", "true", "yes", "on") +RUNTIME_KEY_OPEN_GUARD = "trading_day_reset_open_guard_enabled" +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_OPTIONS_ENABLED = os.getenv("OKX_OPTIONS_ENABLED", "false").lower() in ("1", "true", "yes", "on") +OKX_OPTIONS_API_KEY = os.getenv("OKX_OPTIONS_API_KEY", "") +OKX_OPTIONS_API_SECRET = os.getenv("OKX_OPTIONS_API_SECRET", "") +OKX_OPTIONS_API_PASSPHRASE = os.getenv("OKX_OPTIONS_API_PASSPHRASE", "") +OKX_OPTIONS_TRADE_BUDGET_USDC = float(os.getenv("OKX_OPTIONS_TRADE_BUDGET_USDC", "10")) +OKX_OPTIONS_DEFAULT_UNDERLY = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() +OKX_SUB_ACCOUNT_NAME = (os.getenv("OKX_SUB_ACCOUNT_NAME") or "").strip() +OKX_TD_MODE = os.getenv("OKX_TD_MODE", "cross") +OKX_POS_MODE = os.getenv("OKX_POS_MODE", "hedge") +EXCHANGE_DISPLAY_NAME = (os.getenv("EXCHANGE_DISPLAY_NAME") or "OKX").strip() or "OKX" +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")) +POSITION_SIZING_MODE = load_position_sizing_mode() +KEY_AUTO_ORDER_ENABLED = load_key_auto_order_enabled() +TRADE_POLICY = load_trade_policy() +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")) +RECONCILE_STARTUP_GRACE_SEC = int(os.getenv("RECONCILE_STARTUP_GRACE_SEC", "90")) +RECONCILE_FLAT_CONFIRM_POLLS = max(1, int(os.getenv("RECONCILE_FLAT_CONFIRM_POLLS", "3"))) +_APP_STARTED_AT = time.time() +_RECONCILE_FLAT_STREAK = {} +BREAKEVEN_EXCHANGE_MIN_INTERVAL_SEC = max( + 15, int(os.getenv("BREAKEVEN_EXCHANGE_MIN_INTERVAL_SEC", "60")) +) +_BREAKEVEN_LAST_EX_SYNC: dict[int, float] = {} +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") +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 +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")) +from lib.trade.daily_open_limit_lib import ( + build_daily_open_alert_prompt, + can_trade_new_open, + check_daily_open_hard_limit, + count_opens_for_trading_day, + format_daily_open_counter_line, + format_daily_open_summary_short, + load_daily_open_limits_from_env, + should_send_daily_open_alert, +) + +DAILY_OPEN_ALERT_THRESHOLD, DAILY_OPEN_HARD_LIMIT = load_daily_open_limits_from_env() +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 / KEY_MONITOR_ALERT_ONLY_TYPES:见 key_monitor_lib +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 = max(1, int(os.getenv("KEY_DAILY_VOLUME_RANK_MAX", "30"))) + +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_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() in ( + "1", + "true", + "yes", + "on", +) +DEFAULT_TRADE_STYLE = (os.getenv("DEFAULT_TRADE_STYLE", "trend") or "trend").strip().lower() + +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() + + +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 + +exchange_options = ccxt.okx( + { + "enableRateLimit": True, + "options": {"defaultType": "option"}, + } +) +if OKX_CCXT_PROXIES: + exchange_options.proxies = OKX_CCXT_PROXIES +if OKX_OPTIONS_API_KEY and OKX_OPTIONS_API_SECRET and OKX_OPTIONS_API_PASSPHRASE: + exchange_options.apiKey = OKX_OPTIONS_API_KEY + exchange_options.secret = OKX_OPTIONS_API_SECRET + exchange_options.password = OKX_OPTIONS_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, + "version": 0, + "ranks": {}, + "total": 0, +} + +# 企业微信推送 +def send_wechat_msg(content): + send_wechat_webhook( + WECHAT_WEBHOOK, content, timeout=WECHAT_TIMEOUT_SECONDS + ) + + +_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("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), 2)}U" + if fallback is not None: + try: + return f"{round(float(fallback), 2)}U" + except Exception: + pass + return "-" + + +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 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): + 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"- 新保护位:`{format_wechat_scalar_2dp(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 _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, + layout="grid", +): + if not ORDER_CHART_ENABLED: + return None + if not Image: + return None + requested = list(timeframes or ORDER_CHART_TFS) + limit = limit or ORDER_CHART_LIMIT + if layout == "vertical": + timeframes = requested[:2] if requested else [JOURNAL_CHART_DEFAULT_TF1, JOURNAL_CHART_DEFAULT_TF2] + else: + 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} + price_levels = price_levels_from_marker_payload(marker_payload) + for tf in timeframes: + rows = [] + try: + if layout == "vertical" and marker_payload: + win = trade_review_fetch_window( + marker_payload.get("entry_ts_ms"), + marker_payload.get("exit_ts_ms"), + tf, + limit, + anchor=marker_payload.get("chart_anchor"), + now_ms=marker_payload.get("now_ts_ms"), + ) + if win: + ohlcv = exchange.fetch_ohlcv( + exchange_symbol, + timeframe=tf, + since=max(0, int(win["since_ms"])), + limit=int(win["fetch_limit"]), + ) + rows = trim_rows_for_trade_review(_ohlcv_to_rows(ohlcv), win) + if not rows: + 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) + rows = _ohlcv_to_rows(ohlcv)[-limit:] + except Exception: + rows = [] + title = f"{title_prefix} | {tf} x{len(rows)}" + 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() + points = ( + marker_points_for_timeframe(rows, marker_payload) + if marker_payload and tf_key in marker_tfs + else [] + ) + panels.append( + render_candles_subplot( + rows, + title, + width=cell_w, + height=cell_h, + bg_rgb=(255, 255, 255), + marker_points=points, + price_levels=price_levels, + ) + ) + + if not panels: + return None + + out = compose_chart_panels(panels, layout=layout, cell_w=cell_w, cell_h=cell_h, gap=10) + if out is None: + return None + + 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 = ( + "", + "止盈", + "保本止盈", + "移动止盈", + TIME_CLOSE_RESULT, + "强制清仓", + "手动平仓", + "止损", + "其他", +) + +# 趋势户:复盘开仓类型仅 entry model;策略/风格项已拆至下单类型 +ENTRY_REASON_OPTIONS = build_journal_entry_reason_options() + +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"}), + ("key_false_breakout", "关键位假突破", {"segment": "key_false_breakout"}), + ("key_trigger", "关键位触价开仓", {"segment": "key_trigger"}), +) +def normalize_entry_reason(raw, custom_text=None): + del custom_text + return normalize_journal_entry_reason(raw, ENTRY_REASON_OPTIONS, allow_legacy=True) + + +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 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( + """CREATE TABLE IF NOT EXISTS app_runtime_settings + (key TEXT PRIMARY KEY, value TEXT, + updated_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 + for ddl in ( + "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", + "ALTER TABLE trade_records ADD COLUMN exchange_turnover_usdt REAL", + "ALTER TABLE trade_records ADD COLUMN exchange_commission_usdt REAL", + ): + try: + c.execute(ddl) + 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 journal_entries ADD COLUMN images_json TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN order_type TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN direction 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", + "ALTER TABLE key_monitors ADD COLUMN last_rs_bar_ts INTEGER", + "ALTER TABLE key_monitors ADD COLUMN session_date TEXT", + ): + try: + c.execute(ddl) + except Exception: + pass + ensure_time_close_schema(c) + ensure_key_monitor_schema(c) + 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)""" + ) + + from lib.strategy.strategy_db import init_strategy_tables + from lib.options.options_db import init_options_tables + from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables + + init_strategy_tables(conn) + init_options_tables(conn) + init_hedge_plan_tables(conn) + from lib.trade.account_risk_lib import ensure_account_risk_schema + + ensure_account_risk_schema(conn) + migrate_entry_model_columns(conn) + backfill_missing_key_signal_types(conn, monitor_type=ORDER_MONITOR_TYPE_KEY_AUTO) + conn.commit() + conn.close() + +init_db() + + +def _purge_key_monitors_if_full_margin(): + if not is_full_margin_mode(POSITION_SIZING_MODE): + return + conn = get_db() + try: + purge_disallowed_key_monitors( + conn, + sizing_mode=POSITION_SIZING_MODE, + select_rows=lambda c: c.execute("SELECT * FROM key_monitors").fetchall(), + cancel_fib_limit=_cancel_fib_monitor_limit, + delete_monitor=lambda c, kid: c.execute("DELETE FROM key_monitors WHERE id=?", (kid,)), + send_wechat=send_wechat_msg, + ) + conn.commit() + except Exception as e: + print(f"[full_margin] purge key monitors: {e}", flush=True) + finally: + conn.close() + + +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def hub_account_risk_status(conn): + from lib.trade.account_risk_lib import ( + apply_position_limit_risk, + compute_account_risk_status, + enrich_risk_status_countdown, + ensure_account_risk_schema, + ) + + ensure_account_risk_schema(conn) + now = app_now() + st = compute_account_risk_status( + conn, + trading_day=get_trading_day(), + now=now, + fmt_local_ms=ms_to_app_local_str, + ) + st = enrich_risk_status_countdown(st, now=now, daily_reset_hour=TRADING_DAY_RESET_HOUR) + from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors + + return apply_position_limit_risk( + st, + count_position_limit_active_monitors(conn), + max_active_positions=MAX_ACTIVE_POSITIONS, + ) + + +def hub_user_initiated_close( + conn, + *, + source, + count=1, + trade_record_id=None, + closed_at_ms=None, +): + from lib.trade.account_risk_lib import CLOSE_SOURCE_USER_HUB, on_user_initiated_close + + src = (source or "").strip() or CLOSE_SOURCE_USER_HUB + on_user_initiated_close( + conn, + source=src, + trade_record_id=trade_record_id, + closed_at_ms=closed_at_ms, + trading_day=get_trading_day(), + now=app_now(), + count=count, + ) + + +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 = ( + "止盈", + "止损", + "保本止盈", + "移动止盈", + "手动平仓", + "强制清仓", + "外部平仓", + TIME_CLOSE_RESULT, +) + +REVIEW_RESULT_OPTIONS = ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓", TIME_CLOSE_RESULT) + + +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_DEFAULT) + + +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" + if segment_key == "key_false_breakout": + return kst == FALSE_BREAKOUT_MONITOR_TYPE + if segment_key == "key_trigger": + return kst in TRIGGER_ENTRY_MONITOR_TYPES + 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", + "key_false_breakout": FALSE_BREAKOUT_MONITOR_TYPE, + "key_trigger": None, # 见 _count_opens_for_segment 多类型 + } + if segment_key == "key_trigger": + placeholders = ",".join("?" * len(TRIGGER_ENTRY_MONITOR_TYPES)) + return conn.execute( + f"SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ? " + f"AND key_signal_type IN ({placeholders})", + (start_td, end_td, *TRIGGER_ENTRY_MONITOR_TYPES), + ).fetchone()[0] + 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 = [] + seg_defs = effective_stats_segment_defs( + STATS_SEGMENT_DEFS, POSITION_SIZING_MODE, KEY_AUTO_ORDER_ENABLED + ) + for seg_key, seg_title, _meta in seg_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 validate_trade_policy_open(symbol, direction): + return check_open_policy( + TRADE_POLICY, symbol, direction, normalize_symbol_input + ) + + +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) + from lib.trade.order_monitor_display_lib import snapshot_stop_loss + + open_stop = snapshot_stop_loss(item.get("initial_stop_loss"), item.get("stop_loss")) + item["display_open_stop_loss"] = open_stop + 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", 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")) + try: + _er_keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + _er_keys = [] + reviewed_er = row["reviewed_entry_reason"] if "reviewed_entry_reason" in _er_keys else None + item["effective_entry_reason"] = resolve_effective_trade_entry_reason( + reviewed_entry_reason=reviewed_er, + entry_reason=item.get("entry_reason"), + entry_model=item.get("entry_model"), + key_signal_type=(item.get("key_signal_type") or "").strip() or None, + monitor_type=item.get("monitor_type"), + trade_style=item.get("trade_style"), + entry_reason_from_key_signal=entry_reason_from_key_signal, + entry_reason_for_monitor_type=entry_reason_for_monitor_type, + ) + 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" + item["effective_result"] = normalize_result_with_pnl( + item.get("effective_result"), + item.get("effective_pnl_amount"), + ) + item["effective_result"] = apply_force_close_display_result( + item.get("effective_result"), + item.get("effective_closed_at"), + enabled=FORCE_CLOSE_ENABLED, + bj_hour=FORCE_CLOSE_BJ_HOUR, + ) + 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 + + +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 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): + """估算净盈亏(USDT):价差毛利 − 双边 taker 费(默认各 0.05%).""" + 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 + notional = margin * lev + gross = notional * pnl_ratio + try: + from lib.trade.trade_fee_lib import net_pnl_after_fee + + net = net_pnl_after_fee(gross, trigger, exit_p, open_notional=notional) + return float(net) if net is not None else round(gross, 4) + except Exception: + return round(gross, 4) + except Exception: + return 0.0 + + +def calc_rr_ratio(direction, entry_price, stop_loss, take_profit): + """ + 计划盈亏比 = 盈利空间 / 亏损空间(展示为 X:1,即 reward:risk). + 做多:止损须低于入场,止盈须高于入场;做空相反. + """ + 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 active_sl_tp_for_rr(stop_loss, initial_stop_loss, take_profit): + """展示/校验用:优先当前 stop_loss(委托改价后),否则回落 initial_stop_loss.""" + sl = stop_loss if stop_loss not in (None, "") else initial_stop_loss + return sl, take_profit + + +def calc_planned_rr_ratio(direction, entry_price, stop_loss, initial_stop_loss, take_profit): + sl, tp = active_sl_tp_for_rr(stop_loss, initial_stop_loss, take_profit) + return calc_rr_ratio(direction, entry_price, sl, tp) + + +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, 2) + except Exception: + return None + + +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, + entry_model=None, + trend_plan_id=None, + exchange_symbol=None, + attach_exchange_stats=True, +): + 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) + from lib.trade.order_monitor_display_lib import snapshot_stop_loss + + snap_sl = snapshot_stop_loss(initial_stop_loss, stop_loss) + er = resolve_trade_record_entry_reason( + entry_reason=entry_reason, + entry_model=entry_model, + key_signal_type=kst, + monitor_type=monitor_type, + trade_style=trade_style, + entry_reason_from_key_signal=entry_reason_from_key_signal, + entry_reason_for_monitor_type=entry_reason_for_monitor_type, + ) + 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,trend_plan_id) 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, + trend_plan_id, + ) + ) + tid = int(cur.lastrowid or 0) + if attach_exchange_stats and tid: + ex_sym = (exchange_symbol or "").strip() or normalize_exchange_symbol(symbol) + _attach_okx_trade_exchange_stats( + conn, + tid, + exchange_symbol=ex_sym, + direction=direction, + opened_at_str=open_ts, + closed_at_str=close_ts, + opened_at_ms=open_ts_ms, + closed_at_ms=close_ts_ms, + ) + return tid + + +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 + enrich_order_display_fields(item, calc_rr_ratio) + enrich_entry_model_display(item) + 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 apply_order_monitor_source_labels(item, default_manual=ORDER_MONITOR_TYPE_MANUAL) + + +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 order_row_monitor_type(row): + return order_monitor_source_type(row, default_manual=ORDER_MONITOR_TYPE_MANUAL) + + +def trade_record_monitor_type(conn, row): + return resolve_trade_record_monitor_type( + conn, row, default_manual=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) or is_false_breakout_key_monitor_type(kst): + return kst + return None + + +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}" + + +friendly_exchange_error = friendly_okx_error + + +def invalidate_account_balance_cache() -> None: + ACCOUNT_BALANCE_CACHE["updated_at"] = 0 + + +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(): + run_auto_transfer_once_per_day( + enabled=AUTO_TRANSFER_ENABLED, + bj_hour=AUTO_TRANSFER_BJ_HOUR, + target_amount=AUTO_TRANSFER_AMOUNT, + from_account=AUTO_TRANSFER_FROM, + to_account=AUTO_TRANSFER_TO, + funds_decimals=FUNDS_DECIMALS, + get_db=get_db, + get_active_position_count=get_active_position_count, + get_account_usdt_total=get_account_usdt_total, + execute_transfer_usdt=execute_transfer_usdt, + send_wechat_msg=send_wechat_msg, + utc_now_dt=utc_now_dt, + app_tz=APP_TZ, + utc_calendar_date_str=utc_calendar_date_str, + app_now_str=app_now_str, + ) + + +def get_trading_day_reset_open_guard_enabled(conn=None): + """True=启用整点限制(默认 8:00 前禁止新开仓/登记监控).""" + owns = conn is None + if owns: + conn = get_db() + try: + row = conn.execute( + "SELECT value FROM app_runtime_settings WHERE key=?", + (RUNTIME_KEY_OPEN_GUARD,), + ).fetchone() + if row is not None: + return str(row[0]).lower() in ("1", "true", "yes", "on") + except Exception: + pass + finally: + if owns: + conn.close() + return TRADING_DAY_RESET_OPEN_GUARD_ENABLED + + +def set_trading_day_reset_open_guard_enabled(enabled: bool, conn=None): + owns = conn is None + if owns: + conn = get_db() + try: + conn.execute( + "INSERT INTO app_runtime_settings(key, value, updated_at) VALUES (?,?,?) " + "ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at", + (RUNTIME_KEY_OPEN_GUARD, "1" if enabled else "0", app_now_str()), + ) + if owns: + conn.commit() + finally: + if owns: + conn.close() + + +def trading_day_reset_allows_new_open(now, conn=None): + if not get_trading_day_reset_open_guard_enabled(conn): + return True + return now.hour >= TRADING_DAY_RESET_HOUR + + +def precheck_risk(conn, symbol, direction): + now = app_now() + from lib.trade.account_risk_lib import account_risk_blocks_trading + + ok_risk, risk_reason = account_risk_blocks_trading( + conn, + trading_day=get_trading_day(now), + now=now, + fmt_local_ms=ms_to_app_local_str, + ) + if not ok_risk: + return False, risk_reason + if not trading_day_reset_allows_new_open(now): + return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓" + from lib.trade.account_risk_lib import position_limit_reached + + reached, active_count, mx = position_limit_reached(conn, max_active_positions=MAX_ACTIVE_POSITIONS) + if reached: + return False, f"已达最大持仓数({active_count}/{mx})" + ok_daily, daily_reason, _opens = check_daily_open_hard_limit( + conn, get_trading_day(now), DAILY_OPEN_HARD_LIMIT, TRADING_DAY_RESET_HOUR + ) + if not ok_daily: + return False, daily_reason + 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): + try: + ensure_markets_loaded() + market = exchange.market(exchange_symbol) + return float(market.get("contractSize") or 1) + except Exception: + return 1.0 + + +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): + """ + 市价全平.数量优先取交易所当前持仓张数,避免仅用入库 order_amount 导致平不干净. + """ + ensure_markets_loaded() + exchange_symbol = order_row["exchange_symbol"] or normalize_okx_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") + params = build_okx_order_params(direction, reduce_only=True) + last_resp = exchange.create_order(exchange_symbol, "market", side, amount, None, params) + 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_okx_swap_open_orders(exchange_symbol): + ok, _ = ensure_okx_live_ready() + if not ok or not exchange_symbol: + return + ensure_markets_loaded() + try: + cancel_okx_all_open_orders(exchange, exchange_symbol) + except Exception: + pass + + +def _okx_place_tp_sl_orders(exchange_symbol, direction, amount, stop_loss, take_profit): + """ + 为已有持仓挂条件止盈/止损(一笔 OCO 算法单). + 勿带 reduceOnly,勿分两笔 reduce-only 市价单,否则 OKX/ccxt 可能当成立即全平. + """ + 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") + base = build_okx_order_params(direction, reduce_only=False) + sl_px = _okx_algo_trigger_price_str(exchange_symbol, stop_loss) + tp_px = _okx_algo_trigger_price_str(exchange_symbol, take_profit) + order_params = { + **base, + "stopLossPrice": float(sl_px), + "takeProfitPrice": float(tp_px), + "tpOrdPx": "-1", + "slOrdPx": "-1", + } + if OKX_POS_MODE == "hedge": + ps = "long" if direction == "long" else "short" + order_params["positionSide"] = ps + last_err = None + for attempt in range(6): + try: + exchange.create_order(exchange_symbol, "oco", close_side, amt, None, order_params) + return + except Exception as e: + last_err = e + cancel_okx_swap_open_orders(exchange_symbol) + time.sleep(0.2 * (attempt + 1)) + raise RuntimeError(f"OKX 未接受止盈/止损条件单:{last_err}") + + + +def exchange_private_api_configured(): + return bool(OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE) + + +def _position_row_effective_contracts(p): + """张数:OKX 以 info.pos 为准,再兜底 ccxt contracts 等(与 Binance/Gate 多字段一致).""" + from lib.hub.hub_position_metrics import normalize_contracts_qty + + if not p: + return 0.0 + info = p.get("info", {}) or {} + for val in (info.get("pos"), p.get("contracts"), info.get("positionAmt"), info.get("size")): + if val is None or val == "": + continue + try: + x = abs(float(val)) + if x > 0: + return normalize_contracts_qty(x) + except (TypeError, ValueError): + continue + return 0.0 + + +def _position_matches_wanted_contract(exchange_symbol, position): + if not position: + return False + sym = position.get("symbol") + if sym == exchange_symbol: + return True + try: + if normalize_okx_symbol(sym or "") == normalize_okx_symbol(exchange_symbol or ""): + return True + except Exception: + pass + info = position.get("info") or {} + inst = (info.get("instId") or "").strip().upper() + if not inst: + return False + try: + ensure_markets_loaded() + want = exchange.market(exchange_symbol) + mid = (want.get("id") or "").strip().upper() + if mid and inst == mid: + return True + base = (want.get("base") or "").strip().upper() + quote = (want.get("quote") or "").strip().upper() + if base and quote and inst == f"{base}-{quote}-SWAP": + return True + except Exception: + pass + return False + + +def _okx_position_direction(position): + info = position.get("info") or {} + side = (position.get("side") or info.get("posSide") or "").strip().lower() + if side in ("long", "short"): + return side + try: + raw = float(info.get("pos") or position.get("contracts") or 0) + except (TypeError, ValueError): + raw = 0.0 + if raw > 0: + return "long" + if raw < 0: + return "short" + return "" + + +def _fetch_okx_swap_position_rows(): + """OKX 单合约 fetch_positions([sym]) 常返回空;与 /api/prices 一致拉全量 SWAP 再本地匹配.""" + ensure_markets_loaded() + rows = None + for fetcher in ( + lambda: exchange.fetch_positions(None, {"instType": OKX_POSITION_INST_TYPE}), + lambda: exchange.fetch_positions(), + ): + try: + rows = fetcher() or [] + break + except Exception: + continue + if rows is None: + return None + return rows + + +def _select_live_position_row(rows, exchange_symbol, direction, relax_hedge=False): + exchange_symbol = normalize_okx_symbol(exchange_symbol or "") + 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 + want_dir = (direction or "").lower() + if OKX_POS_MODE == "net" or side == "net": + pos_dir = _okx_position_direction(p) + if pos_dir and pos_dir != want_dir: + continue + elif (not relax_hedge) and OKX_POS_MODE == "hedge": + if side and side != want_dir: + continue + candidates.append((contracts, p)) + if not candidates and (not relax_hedge) and OKX_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 parse_ccxt_position_metrics(position, order_leverage=None): + if not position: + return None + p = position + info = p.get("info", {}) or {} + 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("imr"), + info.get("initial_margin"), + ) + notional = _coerce_float(p.get("notional"), p.get("notionalValue")) + if notional is None or notional <= 0: + notional = _coerce_float(info.get("notionalUsd"), info.get("notional")) + if notional is not None: + notional = abs(notional) + 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_signed( + p.get("unrealizedPnl"), + info.get("upl"), + info.get("uplLast"), + info.get("unrealized_pnl"), + info.get("unrealisedPnl"), + ) + mark = _coerce_float(p.get("markPrice"), p.get("mark_price"), info.get("markPx")) + 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: + out["mark_price"] = round(mark, 8) + if out: + sym = (p.get("symbol") or "").strip() + try: + cs = float(get_contract_size(sym)) if sym else 1.0 + except Exception: + cs = 1.0 + from lib.hub.hub_position_metrics import enrich_ccxt_position_metrics_out + + enrich_ccxt_position_metrics_out( + p, out, contract_size=cs, funds_decimals=FUNDS_DECIMALS + ) + return out or None + + +def _resolve_tpsl_prices_for_manual(direction, live_price, sltp_mode, data): + return resolve_entrust_sltp_prices(direction, live_price, sltp_mode, data) + + +def _okx_tpsl_slot_build(exchange_symbol, order_id, trigger_price, order_type=""): + if trigger_price is None or order_id is None: + return None + sym = exchange_symbol.replace(":USDT", "").replace("/USDT:USDT", "") + return { + "order_id": str(order_id), + "trigger_price": float(trigger_price), + "trigger_display": format_price_for_symbol(sym, trigger_price), + "type": str(order_type or ""), + } + + +def _okx_tpsl_slots_from_order(order, exchange_symbol): + """从单笔 OKX 订单解析 SL/TP(算法单常同时带 slTriggerPx 与 tpTriggerPx).""" + if not isinstance(order, dict): + return None, None + info = order.get("info") or {} + if not isinstance(info, dict): + info = {} + oid = order.get("id") or info.get("algoId") or info.get("ordId") + if oid is None: + return None, None + ord_type = str(order.get("type") or info.get("ordType") or "") + sl_px = _coerce_float( + order.get("stopLossPrice"), + info.get("slTriggerPx"), + info.get("slOrdPx"), + ) + tp_px = _coerce_float( + order.get("takeProfitPrice"), + info.get("tpTriggerPx"), + info.get("tpOrdPx"), + ) + sl_slot = _okx_tpsl_slot_build(exchange_symbol, oid, sl_px, ord_type) if sl_px is not None else None + tp_slot = _okx_tpsl_slot_build(exchange_symbol, oid, tp_px, ord_type) if tp_px is not None else None + if sl_slot or tp_slot: + return sl_slot, tp_slot + trig = _coerce_float( + info.get("triggerPx"), + order.get("triggerPrice"), + order.get("stopPrice"), + ) + if trig is None: + return None, None + one = _okx_tpsl_slot_build(exchange_symbol, oid, trig, ord_type) + return one, None + + +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_okx_live_ready() + if not ok: + return slots + try: + ensure_markets_loaded() + plan_sl_f = plan_tp_f = None + try: + if plan_sl is not None: + plan_sl_f = float(plan_sl) + if plan_tp is not None: + plan_tp_f = float(plan_tp) + except Exception: + plan_sl_f = plan_tp_f = None + + def assign_role(trig, slot): + if trig is None or slot is None: + return + 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: + return + if slots[role] is None: + slots[role] = slot + + for order in fetch_okx_all_open_orders(exchange, exchange_symbol): + sl_slot, tp_slot = _okx_tpsl_slots_from_order(order, exchange_symbol) + if sl_slot and slots["sl"] is None: + slots["sl"] = sl_slot + if tp_slot and slots["tp"] is None: + slots["tp"] = tp_slot + if sl_slot or tp_slot: + continue + info = order.get("info") or {} + oid = order.get("id") or info.get("algoId") + trig = _coerce_float(info.get("triggerPx"), order.get("triggerPrice")) + if oid is None or trig is None: + continue + slot = _okx_tpsl_slot_build( + exchange_symbol, + oid, + trig, + str(order.get("type") or info.get("ordType") or ""), + ) + assign_role(trig, slot) + except Exception: + pass + return slots + + +def cancel_okx_tpsl_slot(exchange_symbol, slot): + if not slot or not exchange_symbol: + return + oid = slot.get("order_id") + if not oid: + return + ensure_markets_loaded() + cancel_id = str(oid).split(":", 1)[0] + try: + exchange.cancel_order(cancel_id, exchange_symbol, {"stop": True}) + except Exception: + exchange.cancel_order(str(oid), exchange_symbol, {"stop": True}) + + +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"] + cancelled = cancel_okx_all_open_orders(exchange, ex_sym) + if cancelled > 0: + time.sleep(0.12) + 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 _okx_place_stop_loss_only(exchange_symbol, direction, stop_loss): + """OKX 永续:仅挂止损(趋势回调),止盈由程序监控. + + 须用 stopLossPrice 挂条件单;勿用 reduce-only 市价单 + params['stopLoss'], + 后者会当成立即市价平仓(开仓后约 1 秒内全平). + """ + ensure_markets_loaded() + pos_amt = get_live_position_contracts(exchange_symbol, direction) + if pos_amt is None or float(pos_amt) <= 0: + raise RuntimeError("交易所当前无持仓,无法挂止损") + cancel_okx_swap_open_orders(exchange_symbol) + close_side = "sell" if direction == "long" else "buy" + amt = float(exchange.amount_to_precision(exchange_symbol, float(pos_amt))) + if amt <= 0: + raise RuntimeError("止损:可平数量经精度舍入后为 0") + base = build_okx_order_params(direction, reduce_only=True) + sl_px = float(stop_loss) + last_err = None + for attempt in range(6): + try: + exchange.create_order( + exchange_symbol, + "market", + close_side, + amt, + None, + {**base, "stopLossPrice": sl_px}, + ) + return + except Exception as e: + last_err = e + cancel_okx_swap_open_orders(exchange_symbol) + time.sleep(0.2 * (attempt + 1)) + raise RuntimeError(f"OKX 未接受止损条件单:{last_err}") + + +def calc_trend_manual_breakeven_stop(direction, entry_price, offset_pct=None): + try: + e = float(entry_price) + pct = float( + offset_pct + if offset_pct is not None + else float(os.getenv("TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT", "0.3")) + ) + 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 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", + "empty position", "increase_position", + ] + return any(k in msg for k in keywords) + + +def get_live_position_contracts(exchange_symbol, direction): + ex_sym = normalize_okx_symbol(exchange_symbol or "") + rows = _fetch_okx_swap_position_rows() + if rows is None: + return None + prow = _select_live_position_row(rows, ex_sym, direction) + if not prow: + return 0.0 + return _position_row_effective_contracts(prow) + + +def get_live_position_exchange_metrics(exchange_symbol, direction, order_leverage=None): + """趋势回调/下单监控:从交易所持仓读标记价与未实现盈亏.""" + if not exchange_private_api_configured() or not exchange_symbol: + return None + rows = _fetch_okx_swap_position_rows() + if rows is None: + return None + prow = _select_live_position_row(rows, exchange_symbol, direction) + return parse_ccxt_position_metrics(prow, order_leverage=order_leverage) + + +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, min_ts=None): + 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 + try: + ts_i = int(ts) + except (TypeError, ValueError): + continue + if min_ts and ts_i < int(min_ts): + 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) + return pick_from_trades(trades, since_ms) + 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 = [] + 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 fetch_all_position_fills_for_record( + exchange_symbol, direction, opened_at_str, closed_at_str=None, opened_at_ms=None, closed_at_ms=None +): + 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) + 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 + try: + trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=200) + except Exception: + trades = [] + return filter_position_lifecycle_fills( + trades or [], + direction, + since_ms, + closed_ms, + hedge_mode=(OKX_POS_MODE == "hedge"), + close_buffer_ms=0, + ) + + +def _attach_okx_trade_exchange_stats( + conn, trade_id, *, exchange_symbol, direction, opened_at_str, closed_at_str, opened_at_ms=None, closed_at_ms=None +): + if not (OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE): + return + open_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) + close_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str) + contract_size = 1.0 + try: + ensure_markets_loaded() + contract_size = float(exchange.market(exchange_symbol).get("contractSize") or 1) + except Exception: + pass + + def _fetch(): + return fetch_all_position_fills_for_record( + exchange_symbol, direction, opened_at_str, closed_at_str, opened_at_ms=open_ms, closed_at_ms=close_ms + ) + + try: + attach_exchange_stats_to_trade(conn, trade_id, fetch_fills=_fetch, contract_size=contract_size) + except Exception: + pass + + +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) + + open_ms = _to_ms_with_fallback( + row["opened_at_ms"] if "opened_at_ms" in row.keys() else None, opened_at_str + ) + 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: + try: + ts_i = int(ts) + except (TypeError, ValueError): + ts_i = None + if ts_i is not None and open_ms and ts_i < int(open_ms): + exit_px = None + elif ts_i is not None: + closed_at_str = ms_to_app_local_str(ts_i) + + 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 ( + normalize_result_with_pnl(guessed, pnl), + 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 ( + normalize_result_with_pnl(result, pnl), + pnl, + closed_at_str, + "按交易所成交记录同步为止盈/止损平仓", + ) + return ( + "外部平仓", + pnl, + closed_at_str, + "交易所已平仓,成交价不在计划止盈/止损带内(可能为手动或其他类型平仓)", + ) + + +def _finalize_hub_flat_monitor_okx(conn, r, *, result, pnl_amount, closed_at, miss_reason): + opened_at = get_opened_at_value(r) + 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=trade_record_monitor_type(conn, r), + trend_plan_id=trend_plan_id_from_monitor_row(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"], + entry_model=(r["entry_model"] if "entry_model" in r.keys() else None), + 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=handoff_trade_miss_reason(miss_reason, r), + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (r["id"],)) + + +def reconcile_hub_external_close(conn, symbol, direction): + from lib.hub.hub_reconcile_flat_lib import reconcile_hub_external_close_impl + from lib.hub.hub_symbol_lib import symbols_match + + global _RECONCILE_FLAT_STREAK + + return reconcile_hub_external_close_impl( + conn, + symbol, + direction, + exchange_configured=exchange_private_api_configured, + not_configured_msg="未配置 OKX_API_KEY / OKX_API_SECRET", + symbols_match=symbols_match, + get_opened_at_value=get_opened_at_value, + resolve_monitor_exchange_symbol=resolve_monitor_exchange_symbol, + get_live_position_contracts=get_live_position_contracts, + cancel_conditional_orders=cancel_okx_swap_open_orders, + resolve_synced_flat_close=resolve_synced_flat_close, + finalize_stopped_monitor=_finalize_hub_flat_monitor_okx, + sync_trade_records=sync_trade_records_from_exchange, + reconcile_flat_streak=_RECONCILE_FLAT_STREAK, + to_ms_with_fallback=_to_ms_with_fallback, + prefer_manual_resolve=False, + order_row_monitor_type=order_row_monitor_type, + ) + + +def reconcile_external_closes(conn, days=None): + global _RECONCILE_FLAT_STREAK + if not exchange_private_api_configured(): + return 0 + if time.time() - _APP_STARTED_AT < RECONCILE_STARTUP_GRACE_SEC: + return 0 + 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 IN ('active', 'error')" + ).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 + oid = int(r["id"]) + if r["status"] == "error": + opened_at_chk = get_opened_at_value(r) + existing = conn.execute( + "SELECT id FROM trade_records WHERE symbol=? AND opened_at=? AND monitor_type=? LIMIT 1", + (r["symbol"], opened_at_chk, order_row_monitor_type(r)), + ).fetchone() + if existing: + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (oid,)) + synced_count += 1 + 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: + _RECONCILE_FLAT_STREAK.pop(oid, None) + continue + if live_contracts > 0: + _RECONCILE_FLAT_STREAK.pop(oid, None) + continue + if r["status"] != "error": + streak = int(_RECONCILE_FLAT_STREAK.get(oid, 0)) + 1 + _RECONCILE_FLAT_STREAK[oid] = streak + if streak < RECONCILE_FLAT_CONFIRM_POLLS: + continue + _RECONCILE_FLAT_STREAK.pop(oid, None) + print( + f"[reconcile_external_closes] {r['symbol']} id={oid} " + f"flat x{streak} polls -> sync close" + ) + else: + _RECONCILE_FLAT_STREAK.pop(oid, None) + print( + f"[reconcile_external_closes] error recovery {r['symbol']} id={oid} flat -> sync close" + ) + 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=trade_record_monitor_type(conn, r), + trend_plan_id=trend_plan_id_from_monitor_row(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"], + entry_model=(r["entry_model"] if "entry_model" in r.keys() else None), + 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=handoff_trade_miss_reason(miss_reason, r), + 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 _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-SWAP 便于与 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: + parts = s.split("-") + if len(parts) >= 2 and parts[-1] in ("SWAP", "FUTURES", "FUTURE"): + s = f"{parts[0]}/{parts[1]}" + else: + s = s.replace("-", "/") + 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_okx_position_history_entry(p): + if not p or not isinstance(p, dict): + return None + info = p.get("info") or {} + if not isinstance(info, dict): + info = {} + sym = p.get("symbol") or "" + if not sym: + inst = str(info.get("instId") or "").strip() + if inst: + try: + ensure_markets_loaded() + sym = exchange.market(inst).get("symbol") or "" + except Exception: + parts = inst.split("-") + if len(parts) >= 2: + sym = f"{parts[0]}/{parts[1]}" + side = (p.get("side") or info.get("direction") or info.get("posSide") or "").strip().lower() + if side not in ("long", "short"): + try: + pos_val = float(info.get("pos") or 0) + if pos_val > 0: + side = "long" + elif pos_val < 0: + side = "short" + except (TypeError, ValueError): + side = "" + rp = p.get("realizedPnl") + if rp is None: + rp = info.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("uTime")) + open_ms = _coerce_ts_ms(p.get("timestamp")) + if open_ms is None: + open_ms = _coerce_ts_ms(info.get("cTime")) + pos_id = str(info.get("posId") or "").strip() + inst_id = str(info.get("instId") or "").strip() + u_raw = info.get("uTime") + sync_key = pos_id or f"{inst_id}|{u_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_okx_positions_close_history(): + if not exchange_private_api_configured(): + return [] + ensure_markets_loaded() + since_ms = exchange_position_sync_since_ms() + out = [] + page_limit = 100 + max_total = int(EXCHANGE_POSITION_HISTORY_LIMIT) + before = None + while len(out) < max_total: + params = {"instType": OKX_POSITION_INST_TYPE} + if before is not None: + params["before"] = str(before) + try: + rows = exchange.fetch_positions_history( + None, + since=int(since_ms), + limit=page_limit, + params=params, + ) + except Exception: + break + if not rows: + break + batch_min_u = None + for p in rows: + h = _normalize_okx_position_history_entry(p) + if h and h["close_ms"] and h["side"] in ("long", "short") and h["symbol_u"]: + out.append(h) + info = p.get("info") or {} + u = _coerce_ts_ms(info.get("uTime")) or _coerce_ts_ms(p.get("lastUpdateTimestamp")) + if u and (batch_min_u is None or u < batch_min_u): + batch_min_u = u + if len(rows) < page_limit or batch_min_u is None: + break + if before is not None and batch_min_u >= before: + break + before = batch_min_u + return out[:max_total] + + +def sync_trade_records_from_exchange(conn, force=False): + """为未同步的 trade_records 回填 OKX 历史仓位中的已实现盈亏.返回统计 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"] = "未配置 OKX_API_KEY / OKX_API_SECRET / OKX_API_PASSPHRASE" + 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_okx_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 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):OKX USDT 永续 24h 成交额(USDT) 在全市场币种中的排名. + """ + sym_norm = normalize_symbol_input(symbol) + target_base = journal_coin_from_symbol(sym_norm) + return resolve_daily_volume_rank( + target_base, + LIQUIDITY_RANK_CACHE, + now_ts=time.time(), + ttl_sec=max(30, BALANCE_REFRESH_SECONDS), + exchange=exchange, + ensure_markets_loaded=ensure_markets_loaded, + ) + + +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 + 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 + close_b = float(breakout[4]) + high_b = float(breakout[2]) + low_b = float(breakout[3]) + cfm_close = float(confirm[4]) + edge = float(upper) if direction == "long" else float(lower) + breakout_ok = (close_b > float(upper)) if direction == "long" else (close_b < float(lower)) + amp_ok, amp_pct = auto_amp_ok( + direction, close_b, float(upper), float(lower), KEY_BREAKOUT_AMP_MIN_PCT + ) + amp_ok = amp_ok and breakout_ok + confirm_ok_raw = auto_confirm_ok(direction, cfm_close, float(upper), float(lower)) + 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 _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): + """取第一个可解析且 > 0 的数(用于价格,保证金等).""" + 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 _coerce_float_signed(*values): + """取第一个有限浮点数(含 0 与负数),用于未实现盈亏等.""" + for v in values: + if v is None or v == "": + continue + try: + f = float(v) + if math.isfinite(f): + 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 _fetch_last_closed_bar(symbol): + ex_sym = normalize_okx_symbol(symbol) + bars = exchange.fetch_ohlcv(ex_sym, timeframe=KLINE_TIMEFRAME, limit=5) or [] + if len(bars) < 2: + return None + closed = bars[:-1] + return closed[-1] if closed else None + + +def _key_rs_gate_preview(symbol, upper, lower): + bar = _fetch_last_closed_bar(symbol) + if not bar: + return {"summary": "5m数据不足", "metrics": ""} + close = float(bar[4]) + br = detect_rs_box_break(close, upper, lower) + if br: + return { + "summary": f"已越线:{br['break_label']}", + "metrics": f"收盘:{format_price_for_symbol(symbol, close)}", + } + return { + "summary": "待突破", + "metrics": f"收盘:{format_price_for_symbol(symbol, close)}", + } + + +def _process_key_rs_level_alert(conn, row): + sym = row["symbol"] + typ = (row["monitor_type"] or "").strip() + up, low = float(row["upper"]), float(row["lower"]) + if up <= low: + return + bar = _fetch_last_closed_bar(sym) + if not bar: + return + close = float(bar[4]) + ts = bar[0] + now_dt = app_now() + tick = run_rs_level_alert_tick( + row, + close, + ts, + now_dt, + default_max_notify=KEY_ALERT_MAX_TIMES, + default_interval_min=KEY_ALERT_INTERVAL_MINUTES, + ) + if not tick: + return + + br = tick["break_info"] + notify_index = int(tick["notify_index"]) + max_n = int(tick["notify_max"]) + interval = int(tick["interval_min"]) + bar_ts = tick.get("bar_ts") + prior_count = int(tick.get("prior_count", notify_index - 1)) + + notified_at = app_now_str() + if not claim_rs_level_notify( + conn, + row["id"], + notify_index, + br["direction"], + notified_at, + bar_ts, + prior_count=prior_count, + ): + return + conn.commit() + + trigger_time = ms_to_app_local_str(int(ts)) if ts else app_now_str() + msg = build_wechat_rs_level_message( + symbol=sym, + monitor_type=typ, + account_label=_wechat_account_label(), + trigger_time=trigger_time, + upper_txt=format_price_for_symbol(sym, up), + lower_txt=format_price_for_symbol(sym, low), + close_txt=format_price_for_symbol(sym, close), + edge_txt=format_price_for_symbol(sym, br["edge_price"]), + break_label=br["break_label"], + direction=br["direction"], + notify_index=notify_index, + notify_max=max_n, + interval_min=interval, + ) + send_wechat_msg(msg) + conn.execute( + "UPDATE key_monitors SET last_alert_message=? WHERE id=?", + (msg, row["id"]), + ) + conn.commit() + if notify_index >= max_n: + hist_row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (row["id"],)).fetchone() + if hist_row: + insert_key_monitor_history(conn, hist_row, notify_index, msg, "key_level_alert_done") + conn.execute("DELETE FROM key_monitors WHERE id=?", (row["id"],)) + conn.commit() + + +def _key_hard_lines_from_checks(checks): + direction = (checks.get("direction") or "long").lower() + 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']})", + format_auto_amp_line(checks["amp_ok"], checks["amp_pct"], KEY_BREAKOUT_AMP_MIN_PCT), + format_auto_confirm_line( + checks["confirm_ok"], checks["confirm_close"], checks["edge_price"], direction + ), + f"日成交量排名:{'通过' if checks['rank_ok'] else '不通过'}({checks['rank']}/{checks['rank_total']},要求前{KEY_DAILY_VOLUME_RANK_MAX})", + ] + + +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 fetch_okx_all_open_orders(exchange, exchange_symbol): + 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, + stop_loss=None, + take_profit=None, +): + 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) + 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", + } + ] + 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 _limit_key_plan_for_row(row): + typ = (row["monitor_type"] or "").strip() + if is_fib_key_monitor_type(typ): + return _fib_plan_for_row(row) + if is_false_breakout_key_monitor_type(typ): + direction = (row["direction"] or "long").lower() + key_px = key_price_from_row(direction, row["upper"], row["lower"]) + if key_px is None: + return None + return calc_false_breakout_plan(direction, key_px) + return None + + +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) + tc_en, tc_h, _ = time_close_settings_from_row(row) + tc_en, tc_h, tc_at = time_close_insert_values(tc_en, tc_h, opened_at_ms) + 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, " + "time_close_enabled, time_close_hours, time_close_at_ms) " + "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), + tc_en, + tc_h, + tc_at, + ), + ) + 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() + kind = "假突破" if is_false_breakout_key_monitor_type(typ) else "斐波" + ex_sym = normalize_okx_symbol(symbol) + plan = _limit_key_plan_for_row(row) + if not plan: + _finalize_key_monitor_one_shot(conn, row, f"{kind}计划无效", "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: + msg = ( + f"# ❌ {symbol} {kind}成交后处理失败\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 无法取得持仓/下单数量,未挂 TP/SL\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, row, msg, "fib_fill_no_amount") + return + ok, reason = precheck_risk(conn, symbol, direction) + if not ok: + msg = ( + f"# ❌ {symbol} {kind}成交后风控拒绝\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}\n" + f"- 原因:{reason}\n" + f"- 请手动处理仓位与挂单\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, row, msg, "fib_risk_rejected") + return + tpsl_attached = False + try: + slots = fetch_exchange_tpsl_slots(ex_sym, direction, plan_sl=sl, plan_tp=tp) + if slots.get("sl") and slots.get("tp"): + tpsl_attached = True + else: + _okx_place_tp_sl_orders(ex_sym, direction, amount, sl, tp) + slots2 = fetch_exchange_tpsl_slots(ex_sym, direction, plan_sl=sl, plan_tp=tp) + tpsl_attached = bool(slots2.get("sl") and slots2.get("tp")) + except Exception as e: + msg = ( + f"# ❌ {symbol} {kind}成交后挂 TP/SL 失败\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 错误:{friendly_okx_error(e)}\n" + f"- 请手动补挂止盈止损\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, row, msg, "fib_tpsl_failed") + 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 = format_wechat_scalar_2dp(planned_rr) if planned_rr is not None else "-" + close_reason = "false_breakout_filled" if is_false_breakout_key_monitor_type(typ) else "fib_filled" + succ = ( + f"# ✅ {symbol} {kind}限价成交\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, close_reason) + + +def _trigger_entry_exists_for_symbol(conn, symbol): + placeholders = ",".join("?" * len(TRIGGER_ENTRY_MONITOR_TYPES)) + row = conn.execute( + f"SELECT id FROM key_monitors WHERE symbol=? AND monitor_type IN ({placeholders})", + (symbol, *TRIGGER_ENTRY_MONITOR_TYPES), + ).fetchone() + return row is not None + + +def _add_trigger_entry_key_monitor( + conn, + symbol, + direction_sel, + entry, + sl, + tp, + monitor_type=CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE, + breakeven_enabled=0, + time_close_enabled=0, + time_close_hours=None, +): + mt = (monitor_type or CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE).strip() + if mt not in TRIGGER_ENTRY_MONITOR_TYPES: + mt = CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE + if _trigger_entry_exists_for_symbol(conn, symbol): + return False, f"{symbol} 已有触价开仓监控(同币仅允许一条)" + ex_sym = normalize_exchange_symbol(symbol) + mark = get_symbol_mark_price(symbol) + geom_err = validate_trigger_entry_geometry( + direction_sel, entry, sl, tp, mark_at_add=mark, monitor_type=mt + ) + if geom_err: + return False, geom_err + rr_err = validate_trigger_entry_rr( + direction_sel, entry, sl, tp, KEY_AUTO_MIN_PLANNED_RR, calc_rr_ratio + ) + if rr_err: + return False, rr_err + entry = float(round_price_to_exchange(ex_sym, entry) or entry) + sl = float(round_price_to_exchange(ex_sym, sl) or sl) + tp = float(round_price_to_exchange(ex_sym, tp) or tp) + geom_err = validate_trigger_entry_geometry( + direction_sel, entry, sl, tp, mark_at_add=mark, monitor_type=mt + ) + if geom_err: + return False, geom_err + rr_err = validate_trigger_entry_rr( + direction_sel, entry, sl, tp, KEY_AUTO_MIN_PLANNED_RR, calc_rr_ratio + ) + if rr_err: + return False, rr_err + 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) + opens_today = count_opens_for_trading_day(conn, trading_day) + ok_intent, intent_msg = check_trigger_entry_intent_limit( + conn, trading_day, opens_today, DAILY_OPEN_HARD_LIMIT + ) + if not ok_intent: + return False, intent_msg + if is_full_margin_mode(POSITION_SIZING_MODE): + ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn)) + if not ok_flat: + return False, flat_msg + if count_pending_trigger_entries(conn, trading_day) > 0: + return False, "全仓杠杆模式下仅允许一条待触发触价监控" + 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) + available_usdt = get_available_trading_usdt() + if is_full_margin_mode(POSITION_SIZING_MODE): + leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE) + sizing, sizing_err = compute_full_margin_sizing( + symbol=symbol, + available_usdt=available_usdt if available_usdt is not None else 0.0, + capital_base=capital_base, + buffer_ratio=FULL_MARGIN_BUFFER_RATIO, + btc_leverage=BTC_LEVERAGE, + alt_leverage=ALT_LEVERAGE, + funds_decimals=2, + ) + if sizing_err: + return False, sizing_err + margin_capital = float(sizing["margin_capital"]) + amount_plan = None + else: + 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 + risk_fraction = calc_risk_fraction(direction_sel, entry, sl) + if risk_fraction is None: + return False, "止损方向不合法(相对计划入场价)" + 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_plan, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry) + except Exception as e: + return False, friendly_exchange_error(e, available_usdt=available_usdt) + upper_px = round_price_to_exchange(ex_sym, max(entry, tp)) + lower_px = round_price_to_exchange(ex_sym, min(entry, sl)) + if upper_px is None or lower_px is None or float(upper_px) <= float(lower_px): + upper_px, lower_px = float(max(entry, tp, sl)), float(min(entry, tp, sl)) + if upper_px <= lower_px: + lower_px = upper_px * 0.9999 + be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0 + tc_en, tc_h, _ = time_close_insert_values(time_close_enabled, time_close_hours, None) + conn.execute( + "INSERT INTO key_monitors " + "(symbol, monitor_type, direction, upper, lower, " + "fib_entry_price, fib_stop_loss, fib_take_profit, " + "fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled, " + "time_close_enabled, time_close_hours, session_date) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + mt, + direction_sel, + float(upper_px), + float(lower_px), + entry, + sl, + tp, + float(amount_plan) if amount_plan is not None else None, + margin_capital, + leverage, + be_flag, + tc_en, + tc_h, + trading_day, + ), + ) + return True, None + + +def _market_open_for_trigger_entry( + conn, + symbol, + direction, + exchange_symbol, + entry_price, + stop_loss, + take_profit, + monitor_type=CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE, + breakeven_enabled=0, + time_close_enabled=0, + time_close_hours=None, +): + """触价触发后市价开仓,计仓规则与实盘下单/关键位 RR 门槛一致.""" + ok_src, src_msg = assert_open_source_allowed(POSITION_SIZING_MODE, OPEN_SOURCE_KEY_TRIGGER) + if not ok_src: + return False, src_msg, None + 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 + + trading_day = get_trading_day(now) + opens_today_before = count_opens_for_trading_day(conn, trading_day) + 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_symbol_mark_price(symbol) or 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 = float(lp_r) + + entry_price = float(entry_price) + 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) + + planned_rr = calc_rr_ratio(direction, entry_price, stop_loss, take_profit) + if planned_rr is None or planned_rr <= KEY_AUTO_MIN_PLANNED_RR: + rr_txt = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算" + return False, f"计划盈亏比 {rr_txt}:1 未达要求(>{KEY_AUTO_MIN_PLANNED_RR}:1)", None + + risk_percent = max(0.01, float(RISK_PERCENT)) + if is_full_margin_mode(POSITION_SIZING_MODE): + ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn)) + if not ok_flat: + return False, flat_msg, None + leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE) + sizing, sizing_err = compute_full_margin_sizing( + symbol=symbol, + available_usdt=available_usdt if available_usdt is not None else 0.0, + capital_base=capital_base, + buffer_ratio=FULL_MARGIN_BUFFER_RATIO, + btc_leverage=BTC_LEVERAGE, + alt_leverage=ALT_LEVERAGE, + funds_decimals=2, + ) + if sizing_err: + return False, sizing_err, None + margin_capital = float(sizing["margin_capital"]) + notional_value = float(sizing["notional_value"]) + position_ratio = float(sizing["position_ratio"]) + risk_amount = margin_capital + else: + 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 + risk_fraction = calc_risk_fraction(direction, entry_price, stop_loss) + if risk_fraction is None: + return False, "止损方向不合法(相对计划入场价)", None + 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_fill = 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 + tc_en, tc_h, tc_at = time_close_insert_values(time_close_enabled, time_close_hours, opened_at_ms) + risk_percent_db = risk_percent_for_storage(POSITION_SIZING_MODE, risk_percent) + + 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, " + "time_close_enabled, time_close_hours, time_close_at_ms) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + exchange_symbol, + direction, + trigger_price, + stop_loss, + stop_loss, + take_profit, + margin_capital, + leverage, + trade_style, + risk_percent_db, + 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(monitor_type), + tc_en, + tc_h, + tc_at, + ), + ) + 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 = count_opens_for_trading_day(conn, trading_day) + + return True, None, { + "new_order_id": new_order_id, + "open_order_id": open_order_id, + "trigger_price": trigger_price, + "planned_rr_fill": planned_rr_fill, + "risk_amount_final": risk_amount_final, + "margin_capital": margin_capital, + "leverage": leverage, + "amount": amount, + "tpsl_attached": tpsl_attached, + "opens_today_before": opens_today_before, + "opens_today_after": opens_today_after, + "trading_day": trading_day, + "stop_loss": stop_loss, + "take_profit": take_profit, + } + + +def _execute_trigger_entry_cross(conn, row): + """标记价触达计划入场:加锁防重复触发,成交成功后再删监控行.""" + symbol = row["symbol"] + direction = (row["direction"] or "long").lower() + ex_sym = normalize_exchange_symbol(symbol) + entry = float(_sqlite_row_val(row, "fib_entry_price") or 0) + sl = float(_sqlite_row_val(row, "fib_stop_loss") or 0) + tp = float(_sqlite_row_val(row, "fib_take_profit") or 0) + be_en = breakeven_enabled_from_row(row, 0) + tc_en, tc_h, _ = time_close_settings_from_row(row) + + kid = int(row["id"]) + if not acquire_trigger_entry_exec_lock(conn, kid): + return False, "触价开仓进行中" + conn.commit() + + try: + ok, err, det = _market_open_for_trigger_entry( + conn, + symbol, + direction, + ex_sym, + entry, + sl, + tp, + monitor_type=(row["monitor_type"] or CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE), + breakeven_enabled=be_en, + time_close_enabled=tc_en, + time_close_hours=tc_h, + ) + except Exception as e: + release_trigger_entry_exec_lock(conn, kid) + conn.commit() + fail_msg = friendly_exchange_error(e) + send_wechat_msg( + f"# ❌ {symbol} 触价开仓异常\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 计划入场:{format_price_for_symbol(symbol, entry)}\n" + f"- 原因:{fail_msg}\n" + ) + insert_key_monitor_history(conn, row, 0, fail_msg, TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED) + return False, fail_msg + + if ok and det: + conn.execute("DELETE FROM key_monitors WHERE id=?", (kid,)) + conn.commit() + rr_txt = format_wechat_scalar_2dp(det.get("planned_rr_fill")) if det.get("planned_rr_fill") is not None else "-" + msg = ( + f"# ✅ {symbol} 触价开仓成交\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 来源:{ORDER_MONITOR_TYPE_KEY_AUTO}(程序触价 @ E)\n" + f"- 类型:{TRIGGER_ENTRY_MONITOR_TYPE}|{_wechat_direction_text(direction)}\n" + f"- 订单 ID:**{det.get('new_order_id')}**\n" + f"- 计划入场:{format_price_for_symbol(symbol, entry)}\n" + f"- 成交价:{format_price_for_symbol(symbol, det.get('trigger_price'))}\n" + f"- 止损:{format_wechat_scalar_2dp(det.get('stop_loss'))}|止盈:{format_price_for_symbol(symbol, det.get('take_profit'))}\n" + f"- 计划 RR:{rr_txt}:1\n" + f"- {'已挂交易所 TP/SL' if det.get('tpsl_attached') else 'TP/SL 未挂上'}\n" + ) + send_wechat_msg(msg) + insert_key_monitor_history(conn, row, 0, msg, TRIGGER_ENTRY_CLOSE_FILLED) + return True, None + release_trigger_entry_exec_lock(conn, kid) + conn.commit() + fail_msg = err or "触价触发后开仓失败" + send_wechat_msg( + f"# ❌ {symbol} 触价开仓失败\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 计划入场:{format_price_for_symbol(symbol, entry)}\n" + f"- 原因:{fail_msg}\n" + ) + insert_key_monitor_history(conn, row, 0, fail_msg, TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED) + return False, fail_msg + + +def check_trigger_entry_key_monitors(): + if not KEY_AUTO_ORDER_ENABLED: + return + conn = get_db() + placeholders = ",".join("?" * len(TRIGGER_ENTRY_MONITOR_TYPES)) + rows = conn.execute( + f"SELECT * FROM key_monitors WHERE monitor_type IN ({placeholders})", + tuple(TRIGGER_ENTRY_MONITOR_TYPES), + ).fetchall() + now_dt = app_now() + for r in rows: + symbol = r["symbol"] + direction = (r["direction"] or "long").lower() + mt = (r["monitor_type"] or CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE).strip() + entry = float(_sqlite_row_val(r, "fib_entry_price") or 0) + sl = float(_sqlite_row_val(r, "fib_stop_loss") or 0) + tp = float(_sqlite_row_val(r, "fib_take_profit") or 0) + kid = int(r["id"]) + if is_trigger_entry_in_flight_row(r): + continue + if entry <= 0 or sl <= 0 or tp <= 0: + _finalize_key_monitor_one_shot(conn, r, "触价计划价位无效", "fib_plan_invalid") + continue + mark = get_symbol_mark_price(symbol) + if mark is None: + continue + prev_mark = _sqlite_row_val(r, "last_mark_price") + prev_mark_f = float(prev_mark) if prev_mark not in (None, "") else None + if is_trigger_entry_expired(r["created_at"], now_dt, hours=TRIGGER_ENTRY_VALIDITY_HOURS): + exp_txt = trigger_entry_expires_at_text(r["created_at"], hours=TRIGGER_ENTRY_VALIDITY_HOURS) + msg = ( + f"# ⚠️ {symbol} 触价开仓已过期\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{mt}|{_wechat_direction_text(direction)}\n" + f"- 有效期 {TRIGGER_ENTRY_VALIDITY_HOURS}h(应于 {exp_txt} 前触发)\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, TRIGGER_ENTRY_CLOSE_EXPIRED) + continue + inv = trigger_entry_invalidate(mt, direction, mark, sl, tp) + if inv == "tp": + msg = ( + f"# ⚠️ {symbol} 触价开仓失效\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{mt}|标记价 {format_price_for_symbol(symbol, mark)} 已触达止盈侧(未成交)\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, TRIGGER_ENTRY_CLOSE_TP_INVALIDATE) + continue + if inv == "sl": + msg = ( + f"# ⚠️ {symbol} 触价开仓失效\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{mt}|标记价 {format_price_for_symbol(symbol, mark)} 已触达止损侧(未突破)\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, TRIGGER_ENTRY_CLOSE_SL_INVALIDATE) + continue + if trigger_should_fire(mt, direction, mark, entry, prev_mark_f): + _execute_trigger_entry_cross(conn, r) + continue + conn.execute("UPDATE key_monitors SET last_mark_price=? WHERE id=?", (float(mark), kid)) + conn.commit() + conn.close() + + +def check_fib_key_monitors(): + if not KEY_AUTO_ORDER_ENABLED: + return + conn = get_db() + rows = conn.execute("SELECT * FROM key_monitors").fetchall() + for r in rows: + typ = (r["monitor_type"] or "").strip() + if not is_limit_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") + if is_false_breakout_key_monitor_type(typ): + now_dt = app_now() + if is_false_breakout_expired(r["created_at"], now_dt): + _cancel_fib_monitor_limit(r) + exp_txt = expires_at_text(r["created_at"]) + msg = ( + f"# ⚠️ {symbol} 假突破监控已过期\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}|{_wechat_direction_text(direction)}\n" + f"- 有效期 {FALSE_BREAKOUT_VALIDITY_HOURS}h(应于 {exp_txt} 前成交)\n" + f"- 已撤销限价单\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, "false_breakout_expired") + continue + 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 is_fib_key_monitor_type(typ) and 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 is_fib_key_monitor_type(typ) and 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 _false_breakout_exists_for_symbol(conn, symbol): + row = conn.execute( + "SELECT id FROM key_monitors WHERE symbol=? AND monitor_type=?", + (symbol, FALSE_BREAKOUT_MONITOR_TYPE), + ).fetchone() + return row is not None + + +def _add_false_breakout_key_monitor( + conn, symbol, direction_sel, upper_px, lower_px, key_px, breakeven_enabled=0, + time_close_enabled=0, time_close_hours=None, +): + if _false_breakout_exists_for_symbol(conn, symbol): + return False, f"{symbol} 已有假突破监控(同币仅允许一条)" + plan = calc_false_breakout_plan(direction_sel, key_px) + if not plan: + return False, "假突破价位无效,请核对方向与关键价位" + 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) + 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, "止损方向不合法(相对挂单价);请核对方向与关键价位" + 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 + tc_en, tc_h, _ = time_close_insert_values(time_close_enabled, time_close_hours, None) + 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, time_close_enabled, time_close_hours) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, FALSE_BREAKOUT_MONITOR_TYPE, direction_sel, upper_px, lower_px, + oid, entry, sl, tp, float(amount), margin_capital, leverage, be_flag, tc_en, tc_h, + ), + ) + return True, None + + +def _add_fib_key_monitor( + conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=0, + time_close_enabled=0, time_close_hours=None, +): + 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, stop_loss=sl, take_profit=tp + ) + 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 _market_open_for_key_monitor( + conn, + symbol, + direction, + exchange_symbol, + stop_loss, + take_profit, + key_signal_type=None, + breakeven_enabled=0, + time_close_enabled=0, + time_close_hours=None, +): + """ + 与手动「实盘下单」对齐的市价开仓与 order_monitors 写入(OKX 永续). + 返回 (ok: bool, err_msg: Optional[str], detail: Optional[dict]) + """ + ok_src, src_msg = assert_open_source_allowed(POSITION_SIZING_MODE, OPEN_SOURCE_KEY_AUTO) + if not ok_src: + return False, src_msg, None + 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_okx_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 + tc_en, tc_h, tc_at = time_close_insert_values( + time_close_enabled, time_close_hours, opened_at_ms + ) + + 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, " + "time_close_enabled, time_close_hours, time_close_at_ms) " + "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), + tc_en, + tc_h, + tc_at, + ), + ) + 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 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 + + +# 关键位监控(箱体/收敛可自动开仓;阻力/支撑为双向 5m 收盘突破 + 三次提醒) +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_limit_key_monitor_type(typ): + continue + if typ in KEY_MONITOR_RS_TYPES: + try: + _process_key_rs_level_alert(conn, r) + except Exception as e: + print(f"[key_rs_level_alert] {sym} id={r['id']}: {e}") + continue + + if not KEY_AUTO_ORDER_ENABLED: + continue + + direction = (r["direction"] or "long").lower() + if direction == KEY_DIRECTION_WATCH: + continue + if typ in KEY_MONITOR_AUTO_TYPES: + mark = get_symbol_mark_price(sym) + if mark is not None and box_breakout_invalidate_by_mark(direction, mark, up, low): + edge = float(low) if direction == "long" else float(up) + edge_label = box_breakout_invalidate_edge_label(direction) + msg = ( + f"# ⚠️ {sym} 关键位监控失效\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}|{_wechat_direction_text(direction)}\n" + f"- 标记价 {format_price_for_symbol(sym, mark)} 已突破反向{edge_label} " + f"{format_price_for_symbol(sym, edge)}(设置失效)\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, "box_opposite_break") + 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) + 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() + + if typ not in KEY_MONITOR_AUTO_TYPES: + 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_okx_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) + tc_en, tc_h, _ = time_close_settings_from_row(r) + 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, + time_close_enabled=tc_en, + time_close_hours=tc_h, + ) + 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 = ( + "已在交易所挂止盈/止损触发单(OKX 条件单)" + 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"- {format_daily_open_summary_short(det.get('opens_today_after'), DAILY_OPEN_ALERT_THRESHOLD, DAILY_OPEN_HARD_LIMIT)}", + ] + 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 should_send_daily_open_alert( + det.get("opens_today_before", 0), + det.get("opens_today_after", 0), + DAILY_OPEN_ALERT_THRESHOLD, + ): + advice = ai_short_advice( + build_daily_open_alert_prompt( + det["trading_day"], + det.get("opens_today_after", 0), + DAILY_OPEN_ALERT_THRESHOLD, + hard_limit=DAILY_OPEN_HARD_LIMIT, + detail_line=f"最新一笔来源为关键位自动单:{sym} {direction},杠杆{det['leverage']}x.", + ) + ) + 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) + if stale_breakeven_armed(direction, trigger_price, stop_loss, breakeven_armed): + conn.execute( + "UPDATE order_monitors SET breakeven_armed=0, breakeven_price=NULL WHERE id=?", + (pid,), + ) + breakeven_armed = 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_okx_live_ready() + synced_ex = False + last_ex_sync = float(_BREAKEVEN_LAST_EX_SYNC.get(pid, 0)) + interval_ok = ( + time.time() - last_ex_sync + ) >= BREAKEVEN_EXCHANGE_MIN_INTERVAL_SEC + if ok_live and tp_ex > 0 and interval_ok: + try: + replace_active_monitor_tpsl_on_exchange(r, new_sl, tp_ex) + synced_ex = True + _BREAKEVEN_LAST_EX_SYNC[pid] = time.time() + _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_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 + 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 should_trigger_time_close(r): + res = TIME_CLOSE_RESULT + # 做多 + if not res and direction == "long": + if p >= take_profit: res = "止盈" + elif p <= stop_loss: res = "止损" + # 做空 + elif not res and 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 = normalize_result_with_pnl("止损", pnl_amount) + 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 = normalize_result_with_pnl("止损", pnl_amount) + 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 = normalize_result_with_pnl("止损", pnl_amount) + 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=trade_record_monitor_type(conn, r), + trend_plan_id=trend_plan_id_from_monitor_row(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"], + entry_model=(r["entry_model"] if "entry_model" in r.keys() else None), + 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=handoff_trade_miss_reason( + "触发价已触达,仓位已由交易所止盈/止损或其他方式平掉(本地补记)", + r, + ), + 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 + ex_sym_fail = r["exchange_symbol"] or normalize_okx_symbol(sym) + live_contracts = get_live_position_contracts(ex_sym_fail, direction) + if live_contracts is not None and live_contracts <= 0: + record_res, record_pnl, record_closed, sync_miss = resolve_synced_flat_close( + r, opened_at, opened_at_ms=opened_at_ms + ) + record_miss = f"{sync_miss};本地触发{res}时平仓API失败:{e}" + monitor_status = "stopped" + else: + record_res, record_pnl, record_closed = res, pnl_amount, closed_at + record_miss = f"触发{res}后交易所平仓失败(请核对交易所仓位):{e}" + monitor_status = "error" + record_hold = calc_hold_seconds( + opened_at, parse_dt_for_trading_day(record_closed) or now + ) + insert_trade_record( + conn, + symbol=sym, + monitor_type=trade_record_monitor_type(conn, r), + trend_plan_id=trend_plan_id_from_monitor_row(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=record_pnl, + hold_seconds=record_hold, + trade_style=r["trade_style"], + entry_model=(r["entry_model"] if "entry_model" in r.keys() else None), + 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(record_pnl, r["risk_amount"]), + result=record_res, + miss_reason=handoff_trade_miss_reason(record_miss, r), + opened_at=opened_at, + closed_at=record_closed, + ) + session_capital = update_session_capital(conn, session_date, record_pnl) + conn.execute("UPDATE order_monitors SET status=? WHERE id=?", (monitor_status, pid)) + conn.commit() + send_wechat_msg( + build_wechat_monitor_error_message( + symbol=sym, + direction=direction, + scene=f"触发{res}后交易所平仓失败", + error_text=str(e), + ) + ) + if monitor_status == "stopped": + send_wechat_msg( + build_wechat_close_message( + symbol=sym, + direction=direction, + result=f"{record_res}(已补记入交易记录)", + pnl_amount=record_pnl, + hold_seconds=record_hold, + trigger_price=trigger_price, + current_price=p, + stop_loss=stop_loss, + take_profit=take_profit, + close_order_id="-", + extra_note=record_miss, + session_capital_fallback=session_capital, + ) + ) + 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=trade_record_monitor_type(conn, r), + trend_plan_id=trend_plan_id_from_monitor_row(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"], + entry_model=(r["entry_model"] if "entry_model" in r.keys() else None), + 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=handoff_trade_miss_reason(None, r), + 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=trade_record_monitor_type(conn, r), + trend_plan_id=trend_plan_id_from_monitor_row(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"], + entry_model=(r["entry_model"] if "entry_model" in r.keys() else None), + 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=handoff_trade_miss_reason( + f"北京时间 {FORCE_CLOSE_BJ_HOUR}:00 整点风控清仓", + r, + ), + 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: + try: + auto_transfer_once_per_day() + conn = get_db() + force_close_before_reset() + reconcile_external_closes(conn) + conn.commit() + conn.close() + check_fib_key_monitors() + check_trigger_entry_key_monitors() + _roll_cfg = app.extensions.get("strategy_roll_cfg") + if _roll_cfg: + from lib.strategy.strategy_roll_monitor_lib import check_roll_monitors + + check_roll_monitors(_roll_cfg) + check_key_monitors() + check_order_monitors() + cfg = app.extensions.get("strategy_trend_cfg") + if cfg: + from lib.strategy.strategy_trend_register import check_trend_pullback_plans + + check_trend_pullback_plans(cfg) + except Exception as e: + print(f"[monitor_loop] {e}", flush=True) + 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, + pwa_app_name="OKX 交易系统", + ) + +@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 hub_request_allowed(bool(session.get("logged_in")), AUTH_DISABLED): + return f(*args, **kwargs) + return redirect("/login") + 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", embed_mode=None): + 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"]) + from lib.instance.instance_embed_context_lib import ( + embed_render_plan, + minimal_stats_bundle, + options_funding_label, + profit_loss_ratio_from_trades, + total_funds_usdt, + trade_records_summary, + ) + + plan = embed_render_plan(page, embed_mode) + if plan.exchange_capitals: + funding_capital, trading_capital = get_exchange_capitals() + else: + funding_capital, trading_capital = None, None + 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) + options_trading_usdc = None + options_funding_usdc = None + options_funding_usdt = None + options_trading_usdt = None + if ( + OKX_OPTIONS_ENABLED + and exchange_options.apiKey + and embed_mode != "fragment" + ): + try: + from lib.exchange.okx_options_lib import options_header_balances + + options_trading_usdc, options_funding_usdc, options_funding_usdt, options_trading_usdt = options_header_balances( + exchange_options + ) + except Exception: + options_trading_usdc = None + options_funding_usdc = None + options_funding_usdt = None + options_trading_usdt = None + recommended_capital = get_recommended_capital(current_capital) + key_list = ( + conn.execute("SELECT * FROM key_monitors").fetchall() if plan.key_list else [] + ) + 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() + if plan.key_history + else [] + ) + stats_bundle = ( + compute_stats_bundle(conn, trading_day, now) + if plan.stats_bundle + else minimal_stats_bundle(TRADING_DAY_RESET_HOUR) + ) + order_list = [] + if plan.orders: + raw_order_list = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() + for o in raw_order_list: + order_list.append(enrich_order_item(row_to_dict(o), current_capital)) + enrich_orders_force_close( + order_list, + FORCE_CLOSE_ENABLED, + FORCE_CLOSE_BJ_HOUR, + now_ms=int(app_now().timestamp() * 1000), + ) + exchange_pnl_sync = {} + if exchange_private_api_configured() and not request_is_hub_soft_nav() and embed_mode not in ( + "fragment", + "shell", + ): + try: + exchange_pnl_sync = sync_trade_records_from_exchange(conn) or {} + except Exception as e: + exchange_pnl_sync = {"ok": False, "reason": str(e)} + tr_ts = sql_list_time_field("closed_at", "created_at", "opened_at") + if plan.records_rows: + raw_records = conn.execute( + f"SELECT * FROM trade_records WHERE {tr_ts} >= ? AND {tr_ts} <= ? ORDER BY id DESC LIMIT 1000", + (start_bj, end_bj), + ).fetchall() + records = filter_trade_records_excluding_miss( + [to_effective_trade_dict(r) for r in raw_records] + ) + total = len(records) + win = count_winning_trades(records) + rate = round(win / total * 100, 2) if total else 0 + profit_loss_ratio = profit_loss_ratio_from_trades(records) + elif plan.records_summary: + summary = trade_records_summary(conn, start_bj, end_bj, tr_ts) + records = summary["records"] + total = summary["total"] + rate = summary["rate"] + profit_loss_ratio = summary.get("profit_loss_ratio") + else: + records = [] + total = rate = 0 + profit_loss_ratio = None + active_count = len(order_list) + from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors + + position_limit_count = count_position_limit_active_monitors(conn) + open_guard_enabled = get_trading_day_reset_open_guard_enabled(conn) + open_guard_blocks_now = open_guard_enabled and now.hour < TRADING_DAY_RESET_HOUR + opens_today = count_opens_for_trading_day(conn, trading_day) + risk_status = hub_account_risk_status(conn) + can_trade = can_trade_new_open( + time_allows=trading_day_reset_allows_new_open(now, conn), + active_count=position_limit_count, + max_active_positions=MAX_ACTIVE_POSITIONS, + opens_today=opens_today, + hard_limit=DAILY_OPEN_HARD_LIMIT, + extra_blocks=not risk_status.get("can_trade", True), + ) + key_rule_ctx = {} + if page in ("key_monitor", "trade") or page in ( + "strategy", + "strategy_trend", + "strategy_roll", + "strategy_records", + ): + key_rule_ctx = key_monitor_rule_template_context( + kline_timeframe=KLINE_TIMEFRAME, + key_breakout_amp_min_pct=KEY_BREAKOUT_AMP_MIN_PCT, + key_volume_ma_bars=KEY_VOLUME_MA_BARS, + key_volume_ratio_min=KEY_VOLUME_RATIO_MIN, + key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR, + key_daily_volume_rank_max=KEY_DAILY_VOLUME_RANK_MAX, + key_confirm_breakout_bar=KEY_CONFIRM_BREAKOUT_BAR, + key_confirm_bar=KEY_CONFIRM_BAR, + key_alert_max_times=KEY_ALERT_MAX_TIMES, + key_alert_interval_minutes=KEY_ALERT_INTERVAL_MINUTES, + key_stop_outside_breakout_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT, + key_trend_stop_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT, + false_breakout_validity_hours=FALSE_BREAKOUT_VALIDITY_HOURS, + trigger_entry_validity_hours=TRIGGER_ENTRY_VALIDITY_HOURS, + ) + strategy_extra = {} + if plan.strategy: + from lib.strategy.strategy_ui import strategy_render_extras + + strategy_extra = strategy_render_extras( + conn, + page, + default_risk_percent=float(RISK_PERCENT), + request_obj=request, + trend_cfg=app.extensions.get("strategy_trend_cfg"), + ) + conn.close() + from lib.instance.instance_embed_lib import embed_context_extras + from lib.instance.instance_settings_lib import settings_page_context + from lib.instance.instance_display_prefs_lib import display_prefs_template_context + + _display_ctx = display_prefs_template_context(get_db) + template_ctx = dict( + page=page, + key=key_list, + key_history=key_history, + stats_bundle=stats_bundle, + order=order_list, + record=records, + total=total, + rate=rate, + profit_loss_ratio=profit_loss_ratio, + total_funds=total_funds_usdt( + funding_usdt, + current_capital, + options_trading_usdc, + options_funding_usdc, + options_funding_usdt, + options_trading_usdt, + ), + options_funding_usdc=options_funding_usdc, + options_funding_usdt=options_funding_usdt, + options_trading_usdc=options_trading_usdc, + options_trading_usdt=options_trading_usdt, + trading_day=trading_day, + 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, + open_guard_enabled=open_guard_enabled, + open_guard_blocks_now=open_guard_blocks_now, + 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=position_limit_count, + can_trade=can_trade, + opens_today=opens_today, + daily_open_hard_limit=DAILY_OPEN_HARD_LIMIT, + daily_open_alert_threshold=DAILY_OPEN_ALERT_THRESHOLD, + 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_this_month": PRESET_UTC_THIS_MONTH, + "utc_last3m": PRESET_UTC_LAST3M, + "utc_last6m": PRESET_UTC_LAST6M, + "all": PRESET_ALL, + "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, + position_sizing_mode=POSITION_SIZING_MODE, + position_sizing_mode_label=mode_label_zh(POSITION_SIZING_MODE), + trade_policy=trade_policy_template_context(TRADE_POLICY), + **order_entry_template_context(TRADE_POLICY), + open_position_button_label=open_position_button_label(TRADE_POLICY, POSITION_SIZING_MODE), + breakeven_rr_trigger=BREAKEVEN_RR_TRIGGER, + breakeven_offset_pct=BREAKEVEN_OFFSET_PCT, + price_fmt=format_price_for_symbol, + entry_reason_options=list( + effective_entry_reason_options( + ENTRY_REASON_OPTIONS, + POSITION_SIZING_MODE, + KEY_AUTO_ORDER_ENABLED, + trend_manual_count=trend_manual_entry_reason_count(TRADE_POLICY), + ) + ), + order_type_options=list(JOURNAL_ORDER_TYPE_OPTIONS), + key_auto_order_enabled=KEY_AUTO_ORDER_ENABLED, + journal_chart_tf_choices=JOURNAL_CHART_TF_CHOICES, + journal_chart_default_tf1=JOURNAL_CHART_DEFAULT_TF1, + journal_chart_default_tf2=JOURNAL_CHART_DEFAULT_TF2, + journal_chart_default_limit=JOURNAL_CHART_DEFAULT_LIMIT, + journal_chart_default_anchor=JOURNAL_CHART_DEFAULT_ANCHOR, + key_rule_ctx=key_rule_ctx, + funds_fmt=format_funds_u, + options_funding_label=options_funding_label, + exchange_display=EXCHANGE_DISPLAY_NAME, + options_enabled=OKX_OPTIONS_ENABLED, + options_nav_visible=True, + hedge_plan_enabled=os.getenv("HEDGE_PLAN_ENABLED", "false").lower() in ("1", "true", "yes", "on"), + hedge_plan_nav_visible=os.getenv("HEDGE_PLAN_ENABLED", "false").lower() in ("1", "true", "yes", "on"), + options_trade_budget=OKX_OPTIONS_TRADE_BUDGET_USDC, + options_default_underly=OKX_OPTIONS_DEFAULT_UNDERLY, + risk_status=risk_status, + max_active_positions=MAX_ACTIVE_POSITIONS, + manual_min_planned_rr=MANUAL_MIN_PLANNED_RR, + key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR, + kline_timeframe=KLINE_TIMEFRAME, + funding_usdt=funding_usdt, + exchange_pnl_sync=exchange_pnl_sync, + **strategy_extra, + **embed_context_extras("okx"), + **_display_ctx, + **settings_page_context( + page, + display=_display_ctx["display"], + instance_base_dir=BASE_DIR, + exchange_key="okx", + exchange_display=EXCHANGE_DISPLAY_NAME, + risk_status=risk_status, + trade_policy=TRADE_POLICY, + data_export_version=3, + ), + **force_close_template_context( + FORCE_CLOSE_ENABLED, + FORCE_CLOSE_BJ_HOUR, + now_ms=int(app_now().timestamp() * 1000), + ), + ) + if embed_mode == "fragment": + return render_template("embed_page_fragment.html", **template_ctx) + if embed_mode == "shell": + return render_template("embed_shell.html", initial_tab=page, **template_ctx) + return render_template("index.html", **template_ctx) + + +@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(): + redir = redirect_to_embed_shell_if_enabled("key_monitor") + if redir is not None: + return redir + return render_main_page("key_monitor") + + +@app.route("/trade") +@login_required +def trade_page(): + redir = redirect_to_embed_shell_if_enabled("trade") + if redir is not None: + return redir + return render_main_page("trade") + + +@app.route("/records") +@login_required +def records_page(): + redir = redirect_to_embed_shell_if_enabled("records") + if redir is not None: + return redir + return render_main_page("records") + + +@app.route("/stats") +@login_required +def stats_page(): + redir = redirect_to_embed_shell_if_enabled("stats") + if redir is not None: + return redir + return render_main_page("stats") + + +@app.route("/dashboard") +@login_required +def dashboard_page(): + redir = redirect_to_embed_shell_if_enabled("dashboard") + if redir is not None: + return redir + return render_main_page("dashboard") + + +@app.route("/risk_policy") +@login_required +def risk_policy_page(): + redir = redirect_to_embed_shell_if_enabled("risk_policy") + if redir is not None: + return redir + return render_main_page("risk_policy") + + +@app.route("/env_config") +@login_required +def env_config_page(): + redir = redirect_to_embed_shell_if_enabled("env_config") + if redir is not None: + return redir + return render_main_page("env_config") + + +@app.route("/settings") +@login_required +def settings_page(): + redir = redirect_to_embed_shell_if_enabled("settings") + if redir is not None: + return redir + return render_main_page("settings") + + +@app.route("/options") +@login_required +def options_main_page(): + redir = redirect_to_embed_shell_if_enabled("options") + if redir is not None: + return redir + return render_main_page("options") + + +@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"]) + force_refresh = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes") + funding_capital, trading_capital = get_exchange_capitals(force=force_refresh) + 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) + options_trading_usdc = None + options_funding_usdc = None + options_funding_usdt = None + options_trading_usdt = None + if OKX_OPTIONS_ENABLED and exchange_options.apiKey: + try: + from lib.exchange.okx_options_lib import options_header_balances + + options_trading_usdc, options_funding_usdc, options_funding_usdt, options_trading_usdt = options_header_balances( + exchange_options, + force=force_refresh, + ) + except Exception: + options_trading_usdc = None + options_funding_usdc = None + options_funding_usdt = None + options_trading_usdt = None + recommended_capital = get_recommended_capital(current_capital) + from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors + + position_limit_count = count_position_limit_active_monitors(conn) + open_guard_enabled = get_trading_day_reset_open_guard_enabled(conn) + opens_today = count_opens_for_trading_day(conn, trading_day) + risk_status = hub_account_risk_status(conn) + active_pnl_rows = conn.execute( + "SELECT exchange_symbol, symbol, direction FROM order_monitors WHERE status='active'" + ).fetchall() + from lib.instance.instance_embed_context_lib import header_trade_stats_for_window, total_funds_usdt + + header_trade_stats = header_trade_stats_for_window(conn, _list_window_from_request(), APP_TZ) + conn.close() + open_guard_blocks_now = open_guard_enabled and now.hour < TRADING_DAY_RESET_HOUR + can_trade = can_trade_new_open( + time_allows=trading_day_reset_allows_new_open(now), + active_count=position_limit_count, + max_active_positions=MAX_ACTIVE_POSITIONS, + opens_today=opens_today, + hard_limit=DAILY_OPEN_HARD_LIMIT, + extra_blocks=not risk_status.get("can_trade", True), + ) + available_trading_usdt = get_available_trading_usdt() + + unrealized_pnl = None + if exchange_private_api_configured(): + from lib.instance.instance_live_pnl_lib import resolve_instance_unrealized_pnl + + def _okx_positions(): + ensure_markets_loaded() + try: + return exchange.fetch_positions(None, {"instType": OKX_POSITION_INST_TYPE}) or [] + except Exception: + return exchange.fetch_positions() or [] + + try: + unrealized_pnl = resolve_instance_unrealized_pnl( + _okx_positions, + active_pnl_rows, + get_live_position_exchange_metrics, + ) + except Exception: + unrealized_pnl = None + options_unrealized_pnl = None + if OKX_OPTIONS_ENABLED and exchange_options.apiKey: + try: + from lib.instance.instance_live_pnl_lib import merge_unrealized_pnl_components + from lib.options.options_positions_lib import sum_options_net_pnl_usdc + + opt_cfg = app.extensions.get("options_cfg") + if opt_cfg: + # 与持仓卡「净盈亏」同口径(买一回收−权利金),不用交易所标记价 upl + options_unrealized_pnl = sum_options_net_pnl_usdc(opt_cfg, exchange_options) + else: + from lib.exchange.okx_options_lib import fetch_options_unrealized_pnl_usdc + + options_unrealized_pnl = fetch_options_unrealized_pnl_usdc(exchange_options) + unrealized_pnl = merge_unrealized_pnl_components(unrealized_pnl, options_unrealized_pnl) + except Exception: + options_unrealized_pnl = None + return jsonify({ + "funding_usdt": funding_usdt, + "current_capital": current_capital, + "options_funding_usdc": options_funding_usdc, + "options_funding_usdt": options_funding_usdt, + "options_trading_usdc": options_trading_usdc, + "options_trading_usdt": options_trading_usdt, + "total_funds": total_funds_usdt( + funding_usdt, + current_capital, + options_trading_usdc, + options_funding_usdc, + options_funding_usdt, + options_trading_usdt, + ), + "available_trading_usdt": round(available_trading_usdt, FUNDS_DECIMALS) if available_trading_usdt is not None else None, + "unrealized_pnl": unrealized_pnl, + "options_unrealized_pnl": options_unrealized_pnl, + "recommended_capital": recommended_capital, + "active_count": position_limit_count, + "max_active_positions": MAX_ACTIVE_POSITIONS, + "can_trade": can_trade, + "opens_today": opens_today, + "daily_open_hard_limit": DAILY_OPEN_HARD_LIMIT, + "daily_open_alert_threshold": DAILY_OPEN_ALERT_THRESHOLD, + "open_guard_enabled": open_guard_enabled, + "open_guard_blocks_now": open_guard_blocks_now, + "reset_hour": TRADING_DAY_RESET_HOUR, + "manual_min_planned_rr": MANUAL_MIN_PLANNED_RR, + "trading_day": trading_day, + "total": header_trade_stats["total"], + "rate": header_trade_stats["rate"], + "profit_loss_ratio": header_trade_stats.get("profit_loss_ratio"), + "risk_status": risk_status, + **force_close_template_context( + FORCE_CLOSE_ENABLED, + FORCE_CLOSE_BJ_HOUR, + now_ms=int(now.timestamp() * 1000), + ), + }) + + +@app.route("/api/settings/open_guard", methods=["POST"]) +@login_required +def api_settings_open_guard(): + data = request.get_json(silent=True) or {} + raw = data.get("enabled") + if raw is None: + raw = request.form.get("enabled") + if raw is None: + return jsonify({"ok": False, "msg": "缺少 enabled 参数"}), 400 + enabled = str(raw).lower() in ("1", "true", "yes", "on") + set_trading_day_reset_open_guard_enabled(enabled) + now = app_now() + conn = get_db() + trading_day = get_trading_day(now) + from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors + + position_limit_count = count_position_limit_active_monitors(conn) + guard_on = get_trading_day_reset_open_guard_enabled(conn) + opens_today = count_opens_for_trading_day(conn, trading_day) + conn.close() + can_trade = can_trade_new_open( + time_allows=trading_day_reset_allows_new_open(now), + active_count=position_limit_count, + max_active_positions=MAX_ACTIVE_POSITIONS, + opens_today=opens_today, + hard_limit=DAILY_OPEN_HARD_LIMIT, + ) + return jsonify( + { + "ok": True, + "open_guard_enabled": guard_on, + "can_trade": can_trade, + "opens_today": opens_today, + "daily_open_hard_limit": DAILY_OPEN_HARD_LIMIT, + "reset_hour": TRADING_DAY_RESET_HOUR, + } + ) + + +@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_stop_loss,fib_take_profit,fib_limit_order_id,created_at 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,order_amount," + "time_close_enabled,time_close_hours,time_close_at_ms,opened_at_ms FROM order_monitors WHERE status='active'" + ).fetchall() + + 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, {"instType": OKX_POSITION_INST_TYPE}) 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"]) + is_fb = is_false_breakout_key_monitor_type(r["monitor_type"]) + is_te = is_trigger_entry_key_monitor_type(r["monitor_type"]) + if is_fib or is_fb or is_te: + 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 + fb_gate_ok = True + te_gate_ok = True + box_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')}" + elif is_fb: + entry = _sqlite_row_val(r, "fib_entry_price") + entry_txt = format_price_for_symbol(r["symbol"], entry) if entry else "-" + prev = false_breakout_gate_preview( + entry_display=entry_txt, + limit_order_id=_sqlite_row_val(r, "fib_limit_order_id"), + created_at=_sqlite_row_val(r, "created_at"), + now=app_now(), + ) + gate_summary = prev.get("summary") or "-" + gate_metrics = prev.get("metrics") or "" + fb_gate_ok = bool(prev.get("gate_ok")) + elif is_te: + direction = (r["direction"] or "long").lower() + entry = _sqlite_row_val(r, "fib_entry_price") + tp_v = _sqlite_row_val(r, "fib_take_profit") + entry_txt = format_price_for_symbol(r["symbol"], entry) if entry else "-" + tp_txt = format_price_for_symbol(r["symbol"], tp_v) if tp_v else "-" + sl_v = _sqlite_row_val(r, "fib_stop_loss") + inv = ( + trigger_entry_invalidate( + r["monitor_type"], direction, price, float(sl_v or 0), float(tp_v or 0) + ) + if tp_v + else None + ) + prev = trigger_entry_gate_preview( + monitor_type=r["monitor_type"], + entry_display=entry_txt, + take_profit_display=tp_txt, + created_at=_sqlite_row_val(r, "created_at"), + now=app_now(), + tp_invalidated=inv == "tp", + sl_invalidated=inv == "sl", + hours=TRIGGER_ENTRY_VALIDITY_HOURS, + ) + gate_summary = prev.get("summary") or "-" + gate_metrics = prev.get("metrics") or "" + te_gate_ok = bool(prev.get("gate_ok")) + elif (r["monitor_type"] or "").strip() in KEY_MONITOR_RS_TYPES: + try: + prev = _key_rs_gate_preview(r["symbol"], r["upper"], r["lower"]) + gate_summary = prev.get("summary") or "-" + gate_metrics = prev.get("metrics") or "" + except Exception: + gate_summary = "-" + elif (r["monitor_type"] or "").strip() in KEY_MONITOR_AUTO_TYPES: + direction = (r["direction"] or "long").lower() + if box_breakout_invalidate_by_mark(direction, price, r["upper"], r["lower"]): + edge_label = box_breakout_invalidate_edge_label(direction) + gate_summary = f"反向突破{edge_label}·将撤销" + box_gate_ok = False + else: + try: + gate = _key_hard_checks( + r["symbol"], + direction, + 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 fb_gate_ok if is_fb + else te_gate_ok if is_te + else box_gate_ok and bool(gate and gate.get("ok")) + ), + "gate_metrics": gate_metrics, + }) + + order_prices = [] + from lib.hub.price_snapshot_lib import resolve_order_snapshot_price + + for r in order_rows: + margin = float(r["margin_capital"] or 0) + leverage = float(r["leverage"] or 0) + entry = float(r["trigger_price"] or 0) + exchange_tpsl = {"sl": None, "tp": None} + 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 + price = resolve_order_snapshot_price( + r["symbol"], + prices, + position_row=prow, + order_leverage=lev_row, + parse_position_metrics_fn=parse_ccxt_position_metrics, + get_mark_price_fn=get_symbol_mark_price, + fallback_entry=entry if entry > 0 else None, + ) + pnl = calc_pnl(r["direction"], entry, price, margin, leverage) if entry > 0 and price else 0 + pnl_pct = round((pnl / margin * 100), 4) if margin > 0 else 0 + payload = { + "id": r["id"], + "symbol": r["symbol"], + "float_pnl": round(pnl, 2), + "float_pct": pnl_pct, + "plan_margin": round(margin, 2) if margin else None, + "order_amount": float(r["order_amount"]) if r["order_amount"] not in (None, "") 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 = None + if price is not None: + try: + px_for_fmt = float(price) + except (TypeError, ValueError): + px_for_fmt = None + 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 + if px_for_fmt is not None: + 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 + else: + payload["price"] = None + payload["price_display"] = "-" + if exchange_private_api_configured(): + try: + exchange_tpsl = fetch_exchange_tpsl_slots( + ex_sym, + r["direction"], + plan_sl=r["stop_loss"], + plan_tp=r["take_profit"], + ) + except Exception: + exchange_tpsl = {"sl": None, "tp": None} + payload["exchange_tpsl"] = exchange_tpsl + avg_entry = None + if ex_metrics and ex_metrics.get("entry_price") is not None: + avg_entry = ex_metrics["entry_price"] + elif prow: + from lib.hub.hub_position_metrics import parse_position_entry_price + + avg_entry = parse_position_entry_price(prow) + apply_order_price_display_fields( + payload, + direction=r["direction"], + entry_price=entry, + initial_stop_loss=r["initial_stop_loss"], + stop_loss=r["stop_loss"], + take_profit=r["take_profit"], + calc_rr_ratio_fn=calc_rr_ratio, + exchange_tpsl=exchange_tpsl, + format_price_fn=format_price_for_symbol, + symbol=r["symbol"], + margin_capital=margin, + leverage=leverage, + exchange_notional=ex_metrics.get("notional") if ex_metrics else None, + contracts=abs(_position_row_effective_contracts(prow)) if prow else None, + contract_size=float(get_contract_size(ex_sym)) if ex_sym else 1.0, + mark_price=ex_metrics.get("mark_price") if ex_metrics else price, + avg_entry_price=avg_entry, + funds_decimals=FUNDS_DECIMALS, + ) + apply_time_close_to_payload(payload, r) + apply_force_close_to_payload( + payload, + enabled=FORCE_CLOSE_ENABLED, + bj_hour=FORCE_CLOSE_BJ_HOUR, + ) + payload["opened_at"] = r["opened_at"] if "opened_at" in r.keys() else None + open_ms = r["opened_at_ms"] if "opened_at_ms" in r.keys() else None + payload["opened_at_ms"] = int(open_ms) if open_ms not in (None, "") else None + new_sl, new_tp, changed = order_monitor_tpsl_needs_sync( + r["stop_loss"], r["take_profit"], exchange_tpsl + ) + if changed: + try: + conn.execute( + "UPDATE order_monitors SET stop_loss=?, take_profit=? WHERE id=?", + (new_sl, new_tp, int(r["id"])), + ) + except Exception: + pass + order_prices.append(payload) + + try: + conn.commit() + except Exception: + pass + conn.close() + + from lib.hub.hub_position_metrics import build_position_marks_list + + position_marks = build_position_marks_list( + all_swap_positions, + format_mark_display=lambda sym, px: format_price_for_symbol(sym, px), + ) + + options_unrealized_pnl = None + if OKX_OPTIONS_ENABLED and exchange_options.apiKey: + try: + from lib.options.options_positions_lib import sum_options_net_pnl_usdc + + opt_cfg = app.extensions.get("options_cfg") + if opt_cfg: + options_unrealized_pnl = sum_options_net_pnl_usdc(opt_cfg, exchange_options) + else: + from lib.exchange.okx_options_lib import fetch_options_unrealized_pnl_usdc + + options_unrealized_pnl = fetch_options_unrealized_pnl_usdc(exchange_options) + except Exception: + options_unrealized_pnl = None + + return jsonify({ + "updated_at": app_now_str(), + "key_prices": key_prices, + "order_prices": order_prices, + "position_marks": position_marks, + "positions_raw_count": len(all_swap_positions), + "options_unrealized_pnl": options_unrealized_pnl, + **force_close_template_context( + FORCE_CLOSE_ENABLED, + FORCE_CLOSE_BJ_HOUR, + ), + }) + + +@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) + base = journal_coin_from_symbol(symbol) + vol_24h = (LIQUIDITY_RANK_CACHE.get("volumes") or {}).get(base) + if total <= 0: + return jsonify({"ok": False, "msg": "24h成交额排名读取失败"}), 502 + if rank is None: + return jsonify( + { + "ok": True, + "symbol": symbol, + "rank": None, + "total": int(total), + "vol_usdt_24h": vol_24h, + "in_top30": False, + "rank_max": KEY_DAILY_VOLUME_RANK_MAX, + } + ) + in_top = bool(rank <= KEY_DAILY_VOLUME_RANK_MAX) + return jsonify( + { + "ok": True, + "symbol": symbol, + "rank": int(rank), + "total": int(total), + "vol_usdt_24h": vol_24h, + "in_top30": in_top, + "in_top": in_top, + "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_okx_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, 4) if available is not None else None, + "last_price": round(float(last_price), 8) if last_price is not None else None, + "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, 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_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]), + }) + + from lib.instance.focus_chart_lib import ( + build_order_kline_order_payload, + load_swap_positions_for_order_kline, + metrics_for_order_item, + ) + + current_price = get_price(order_item["symbol"]) + positions = load_swap_positions_for_order_kline( + exchange, + private_configured=exchange_private_api_configured(), + ensure_markets_fn=ensure_markets_loaded, + ) + ex_metrics = metrics_for_order_item( + order_item, + positions, + resolve_ex_sym_fn=resolve_monitor_exchange_symbol, + select_live_fn=_select_live_position_row, + parse_metrics_fn=parse_ccxt_position_metrics, + ) + order_payload = build_order_kline_order_payload( + order_item, + ticker_price=current_price, + format_price_fn=format_price_for_symbol, + calc_pnl_fn=calc_pnl, + calc_rr_ratio_fn=calc_rr_ratio, + ex_metrics=ex_metrics, + ) + + from lib.instance.focus_chart_lib import kline_api_price_fields + + price_fields = kline_api_price_fields( + exchange, + exchange_symbol, + candles, + ensure_markets_fn=ensure_markets_loaded, + ) + + return jsonify({ + "ok": True, + "timeframe": timeframe, + "limit": limit, + "order": order_payload, + "candles": candles, + "updated_at": app_now_str(), + **price_fields, + }) + + +@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 = default_symbol_for_policy( + TRADE_POLICY, + 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, + trade_policy=trade_policy_template_context(TRADE_POLICY), + ) + + +@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, + } + + from lib.instance.focus_chart_lib import enrich_key_kline_response + + price_display, key_info = enrich_key_kline_response( + symbol=symbol, + current_price=current_price, + key_info=key_info, + format_price_fn=format_price_for_symbol, + ) + + from lib.instance.focus_chart_lib import kline_api_price_fields + + price_fields = kline_api_price_fields( + exchange, + exchange_symbol, + candles, + ensure_markets_fn=ensure_markets_loaded, + ) + + 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": price_display, + "key_monitor": key_info, + "candles": candles, + "updated_at": app_now_str(), + **price_fields, + }) + + +@app.route("/api/order//cancel_tpsl", methods=["POST"]) +@login_required +def api_order_cancel_tpsl(order_id): + from lib.trade.trade_policy_lib import is_intraday_trading_profile + + if is_intraday_trading_profile(TRADE_POLICY): + return jsonify({"ok": False, "msg": "日内纪律账户禁止撤销交易所止盈止损"}), 403 + 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_okx_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_okx_tpsl_slot(ex_sym, slot) + return jsonify({"ok": True, "msg": "已撤单", "exchange_tpsl": fetch_exchange_tpsl_slots(ex_sym, row["direction"], plan_sl=row["stop_loss"], plan_tp=row["take_profit"])}) + 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) + prow = None + ex_metrics = None + if exchange_private_api_configured(): + try: + rows = exchange.fetch_positions([ex_sym]) or exchange.fetch_positions() or [] + prow = _select_live_position_row(rows, ex_sym, direction) + if prow: + ex_metrics = parse_ccxt_position_metrics(prow, order_leverage=row["leverage"]) + except Exception: + pass + from lib.trade.order_monitor_display_lib import enrich_active_monitor_tpsl_json + + ex_sym = resolve_monitor_exchange_symbol(row) + display_extra = enrich_active_monitor_tpsl_json( + row, + stop_loss, + take_profit, + slots, + position_row=prow, + exchange_notional=ex_metrics.get("notional") if ex_metrics else None, + contract_size=float(get_contract_size(ex_sym)) if ex_sym else 1.0, + mark_price=live_price, + calc_rr_ratio_fn=calc_rr_ratio, + format_price_fn=format_price_for_symbol, + symbol=symbol, + funds_decimals=FUNDS_DECIMALS, + ) + conn.close() + return jsonify( + { + "ok": True, + "msg": "已先撤后挂止盈止损", + "stop_loss": stop_loss, + "take_profit": take_profit, + "planned_rr": planned_rr, + "exchange_tpsl": slots, + **display_extra, + } + ) + +@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") + ok_sym, sym_msg = check_symbol_policy( + TRADE_POLICY, symbol, normalize_symbol_input + ) + if not ok_sym: + flash(sym_msg) + return redirect("/key_monitor") + mt = (d.get("type") or "").strip() + direction_sel = (d.get("direction") or "").strip().lower() + dup_msg = check_duplicate_submit( + session, submit_scope_add_key(symbol, mt, direction_sel or "watch") + ) + if dup_msg: + flash(dup_msg) + return redirect("/key_monitor") + if mt in KEY_MONITOR_RS_TYPES: + direction_sel = KEY_DIRECTION_WATCH + mt = KEY_MONITOR_RS_TYPE + elif direction_sel not in ("long", "short"): + flash("箱体/收敛突破请选择做多或做空") + return redirect("/key_monitor") + ok_dir, dir_msg = check_direction_policy(TRADE_POLICY, direction_sel) + if not ok_dir: + flash(dir_msg) + return redirect("/key_monitor") + allowed_types = ( + tuple(KEY_MONITOR_AUTO_TYPES) + + tuple(KEY_MONITOR_ALERT_ONLY_TYPES) + + tuple(FIB_KEY_MONITOR_TYPES) + + (FALSE_BREAKOUT_MONITOR_TYPE,) + + tuple(TRIGGER_ENTRY_MONITOR_TYPES) + ) + if mt not in allowed_types: + flash("监控类型无效") + return redirect("/key_monitor") + ok_mt, mt_msg = check_monitor_type_add_allowed( + mt, POSITION_SIZING_MODE, KEY_AUTO_ORDER_ENABLED + ) + if not ok_mt: + flash(mt_msg) + return redirect("/key_monitor") + skip_volume_rank = is_false_breakout_key_monitor_type(mt) + rank, total = None, None + if not skip_volume_rank: + 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_okx_symbol(symbol) + try: + ensure_markets_loaded() + except Exception: + pass + be_flag = parse_breakeven_enabled_form(d.get("breakeven_enabled")) + tc_en = parse_time_close_enabled_form(d.get("time_close_enabled")) + tc_h = parse_time_close_hours_form(d.get("time_close_hours")) if tc_en else None + if tc_en and not tc_h: + tc_en = 0 + if is_trigger_entry_key_monitor_type(mt): + if direction_sel not in ("long", "short"): + conn.close() + flash("触价请选择做多或做空") + return redirect("/key_monitor") + try: + entry_px = float(d.get("trigger_entry") or 0) + sl_px = float(d.get("trigger_sl") or 0) + tp_px = float(d.get("trigger_tp") or 0) + except (TypeError, ValueError): + entry_px = sl_px = tp_px = 0 + if entry_px <= 0 or sl_px <= 0 or tp_px <= 0: + conn.close() + flash("触价须填写有效的入场价,止损价,止盈价") + return redirect("/key_monitor") + ok_te, err_te = _add_trigger_entry_key_monitor( + conn, + symbol, + direction_sel, + entry_px, + sl_px, + tp_px, + monitor_type=mt, + breakeven_enabled=be_flag, + time_close_enabled=tc_en, + time_close_hours=tc_h, + ) + conn.commit() + conn.close() + if not ok_te: + flash(err_te or "触价开仓监控添加失败") + return redirect("/key_monitor") + trigger_hint = ( + "标记价穿越入场价后立即市价开仓" + if is_breakout_trigger_entry_key_monitor_type(mt) + else "标记价回调触达入场价后下一轮询市价开仓" + ) + flash( + f"{mt}已添加({symbol} 日成交量排名 {rank}/{total})" + f"|有效期 {TRIGGER_ENTRY_VALIDITY_HOURS}h" + f"|{trigger_hint}" + f"|移动保本:{'开' if be_flag else '关'}" + + (f"|{time_close_label(tc_h)}" if tc_en else "") + ) + return redirect("/key_monitor") + if is_false_breakout_key_monitor_type(mt): + fb_sym = normalize_false_breakout_symbol(symbol) + if not fb_sym: + conn.close() + flash("假突破仅支持 BTC / ETH") + return redirect("/key_monitor") + symbol = fb_sym + if direction_sel not in ("long", "short"): + conn.close() + flash("假突破请选择做多或做空") + return redirect("/key_monitor") + try: + key_px = float(d.get("key_price") or 0) + except (TypeError, ValueError): + key_px = 0 + if key_px <= 0: + conn.close() + flash("请填写关键价位(做空填高点,做多填低点)") + return redirect("/key_monitor") + ex_sym_key = normalize_okx_symbol(symbol) + key_adj = round_price_to_exchange(ex_sym_key, key_px) + key_px = float(key_adj) if key_adj is not None else float(key_px) + try: + upper_px, lower_px = storage_bounds_from_key_price(direction_sel, key_px) + except ValueError as e: + conn.close() + flash(str(e)) + return redirect("/key_monitor") + ok_fb, err_fb = _add_false_breakout_key_monitor( + conn, symbol, direction_sel, upper_px, lower_px, key_px, breakeven_enabled=be_flag, + time_close_enabled=tc_en, time_close_hours=tc_h, + ) + conn.commit() + conn.close() + if not ok_fb: + flash(err_fb or "假突破监控添加失败") + return redirect("/key_monitor") + flash( + f"假突破监控已添加,限价单已挂出({symbol})" + f"|有效期 {FALSE_BREAKOUT_VALIDITY_HOURS}h|移动保本:{'开' if be_flag else '关'}" + + (f"|{time_close_label(tc_h)}" if tc_en else "") + ) + return redirect("/key_monitor") + 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"]) + if upper_px <= lower_px: + conn.close() + flash("上沿必须大于下沿") + return redirect("/key_monitor") + 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, + time_close_enabled=tc_en, time_close_hours=tc_h, + ) + 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 '关'}" + + (f"|{time_close_label(tc_h)}" if tc_en 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) + if mt in KEY_MONITOR_RS_TYPES: + conn.execute( + "INSERT INTO key_monitors " + "(symbol,monitor_type,direction,upper,lower,sl_tp_mode,manual_take_profit,breakeven_enabled," + "max_notify,notify_interval_min,time_close_enabled,time_close_hours) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + mt, + direction_sel, + upper_px, + lower_px, + sl_tp_mode, + manual_tp, + be_flag, + KEY_ALERT_MAX_TIMES, + KEY_ALERT_INTERVAL_MINUTES, + tc_en, + tc_h, + ), + ) + else: + conn.execute( + "INSERT INTO key_monitors " + "(symbol,monitor_type,direction,upper,lower,sl_tp_mode,manual_take_profit,breakeven_enabled," + "time_close_enabled,time_close_hours) " + "VALUES (?,?,?,?,?,?,?,?,?,?)", + (symbol, mt, direction_sel, upper_px, lower_px, sl_tp_mode, manual_tp, be_flag, tc_en, tc_h), + ) + 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 '关'}" + if tc_en: + extra += f"|{time_close_label(tc_h)}" + 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 + if mt in KEY_MONITOR_RS_TYPES: + flash( + f"添加成功({symbol} 日成交量排名 {rank}/{total})|关键支撑阻力:双向监控上/下沿," + f"5m 收盘突破后微信提醒 {KEY_ALERT_MAX_TIMES} 次(间隔 {KEY_ALERT_INTERVAL_MINUTES} 分钟)" + ) + else: + flash(f"添加成功({symbol} 日成交量排名 {rank}/{total}){extra}") + if ctr and mt in KEY_MONITOR_AUTO_TYPES: + 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("/trade") + ok_pol, pol_msg = validate_trade_policy_open(symbol, direction) + if not ok_pol: + conn.close() + flash(f"账户限制:{pol_msg}") + return redirect("/trade") + dup_msg = check_duplicate_submit(session, submit_scope_add_order(symbol, direction)) + if dup_msg: + conn.close() + flash(dup_msg) + return redirect("/trade") + ok, reason = precheck_risk(conn, symbol, direction) + if not ok: + conn.close() + flash(f"风控拒绝下单:{reason}") + return redirect("/trade") + ok_live, reason_live = ensure_okx_live_ready() + if not ok_live: + conn.close() + flash(f"风控拒绝下单:{reason_live}") + return redirect("/trade") + exchange_symbol = normalize_okx_symbol(symbol) + 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, entry_model, style_err = parse_manual_order_style_fields( + TRADE_POLICY, d, default_trade_style=DEFAULT_TRADE_STYLE or "trend" + ) + if style_err: + conn.close() + flash(style_err) + return redirect("/trade") + available_usdt = get_available_trading_usdt() + live_price = get_price(symbol) + if live_price is None: + conn.close() + flash("获取交易所实时价格失败,请稍后重试") + return redirect("/trade") + sltp_mode = normalize_open_sltp_mode(d.get("sltp_mode")) + try: + stop_loss, take_profit = resolve_open_sltp_prices( + direction, live_price, sltp_mode, d + ) + except ValueError as e: + conn.close() + flash(str(e) or "止盈止损参数错误") + return redirect("/trade") + 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("/trade") + risk_percent = max(0.01, float(RISK_PERCENT)) + risk_amount = round(capital_base * risk_percent / 100.0, FUNDS_DECIMALS) + if is_full_margin_mode(POSITION_SIZING_MODE): + ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn)) + if not ok_flat: + conn.close() + flash(flat_msg) + return redirect("/trade") + leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE) + sizing, sizing_err = compute_full_margin_sizing( + symbol=symbol, + available_usdt=available_usdt if available_usdt is not None else 0.0, + capital_base=capital_base, + buffer_ratio=FULL_MARGIN_BUFFER_RATIO, + btc_leverage=BTC_LEVERAGE, + alt_leverage=ALT_LEVERAGE, + funds_decimals=FUNDS_DECIMALS, + ) + if sizing_err: + conn.close() + flash(sizing_err) + return redirect("/trade") + margin_capital = sizing["margin_capital"] + notional_value = sizing["notional_value"] + position_ratio = sizing["position_ratio"] + else: + 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("/trade") + if leverage <= 0: + conn.close() + flash("杠杆必须大于0") + return redirect("/trade") + 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("/trade") + 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("/trade") + 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("/trade") + + 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 + risk_percent_db = risk_percent_for_storage(POSITION_SIZING_MODE, risk_percent) + risk_display = format_risk_display_text( + POSITION_SIZING_MODE, risk_percent, risk_amount_final, decimals=FUNDS_DECIMALS + ) + 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 + tc_en = parse_time_close_enabled_form(d.get("time_close_enabled")) + tc_h = parse_time_close_hours_form(d.get("time_close_hours")) if tc_en else None + if tc_en and not tc_h: + tc_en = 0 + tc_en, tc_h, tc_at = time_close_insert_values(tc_en, tc_h, opened_at_ms) + conn.execute( + "INSERT INTO order_monitors (symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, margin_capital, leverage, trade_style, entry_model, 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, time_close_enabled, time_close_hours, time_close_at_ms) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, exchange_symbol, direction, trigger_price, stop_loss, stop_loss, take_profit, + margin_capital, leverage, trade_style, entry_model, risk_percent_db, 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, "下单监控", + tc_en, tc_h, tc_at, + ) + ) + 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), 2) + if trading_capital_after is not None + else round(float(capital_base), 2) + ) + 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_display}", + "📊 仓位配置详情", + 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}", + format_daily_open_counter_line( + opens_today_after, DAILY_OPEN_ALERT_THRESHOLD, DAILY_OPEN_HARD_LIMIT + ), + ] + if chart_url: + wx_lines.append(f"多周期K线图:{chart_url}") + send_wechat_msg("\n".join(wx_lines)) + + flash_lines = [ + f"实盘开单成功:风格 {trade_style};风险 {risk_display};基数 {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 '-'};已在交易所挂条件止盈/止损委托(非仓位绑定型)", + format_daily_open_summary_short( + opens_today_after, DAILY_OPEN_ALERT_THRESHOLD, DAILY_OPEN_HARD_LIMIT + ), + ] + if chart_url: + flash_lines.append(f"已生成多周期K线图:{chart_url}") + flash(" ".join(flash_lines)) + + if should_send_daily_open_alert( + opens_today_before, opens_today_after, DAILY_OPEN_ALERT_THRESHOLD + ): + advice = ai_short_advice( + build_daily_open_alert_prompt( + trading_day, + opens_today_after, + DAILY_OPEN_ALERT_THRESHOLD, + hard_limit=DAILY_OPEN_HARD_LIMIT, + detail_line=f"最新一笔:{symbol} {direction},杠杆{leverage}x,基数{margin_capital}U.", + ) + ) + if advice: + send_wechat_msg(f"【AI提醒】今日开仓次数已达 {opens_today_after}\n{advice[:800]}") + flash(f"【AI提醒】今日开仓次数已达 {opens_today_after}:{advice[:300]}") + return redirect("/trade") + +@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_limit_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_limit_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," + "exchange_realized_pnl,exchange_opened_at,exchange_closed_at,created_at " + f"FROM trade_records WHERE {sql_list_time_field('closed_at', 'created_at', 'opened_at')} >= ? " + f"AND {sql_list_time_field('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 = format_entry_type_display( + er1 or er0 or entry_reason_from_key_signal(kst) or "", + entry_model=r["entry_model"] if "entry_model" in r.keys() else None, + trade_style=r["trade_style"] if "trade_style" in r.keys() else None, + ) + 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,images_json,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", + "images_json", + "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=trade_record_monitor_type(conn, row), + trend_plan_id=trend_plan_id_from_monitor_row(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"], + entry_model=(row["entry_model"] if "entry_model" in row.keys() else None), + 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=handoff_trade_miss_reason("用户手动删除订单触发平仓", row), + opened_at=opened_at, + closed_at=closed_at, + ) + from lib.trade.account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close + + on_user_initiated_close( + conn, + source=CLOSE_SOURCE_USER_INSTANCE, + trade_record_id=insert_trade_record_id(conn), + closed_at_ms=_to_ms_with_fallback(None, closed_at), + trading_day=session_date, + now=app_now(), + ) + conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, id)) + try: + _rcfg = app.extensions.get("strategy_roll_cfg") + if isinstance(_rcfg, dict): + from lib.strategy.strategy_register import roll_sync_after_external_close + + roll_sync_after_external_close(_rcfg, conn, row["symbol"], row["direction"]) + except Exception: + pass + 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=trade_record_monitor_type(conn, row), + trend_plan_id=trend_plan_id_from_monitor_row(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"], + entry_model=(row["entry_model"] if "entry_model" in row.keys() else None), + 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=handoff_trade_miss_reason(miss_reason, row), + opened_at=opened_at, + closed_at=closed_at, + ) + from lib.trade.account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close + + on_user_initiated_close( + conn, + source=CLOSE_SOURCE_USER_INSTANCE, + trade_record_id=insert_trade_record_id(conn), + closed_at_ms=_to_ms_with_fallback(None, closed_at), + trading_day=session_date, + now=app_now(), + ) + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (id,)) + try: + _rcfg = app.extensions.get("strategy_roll_cfg") + if isinstance(_rcfg, dict): + from lib.strategy.strategy_register import roll_sync_after_external_close + + roll_sync_after_external_close(_rcfg, conn, row["symbol"], row["direction"]) + except Exception: + pass + 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_journal", methods=["POST"]) +@login_required +def add_journal(): + d = request.form + order_type_norm = normalize_journal_order_type(d.get("order_type")) + if not order_type_norm: + flash("请选择下单类型") + return _redirect_records() + direction_norm = normalize_journal_direction(d.get("direction") or d.get("direction_hint")) + if not direction_norm: + flash("请选择方向") + return _redirect_records() + entry_reason_norm = normalize_journal_entry_reason( + d.get("entry_reason"), ENTRY_REASON_OPTIONS, allow_legacy=False + ) + 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) + entry_id = normalize_journal_draft_id(d.get("journal_draft_id")) or uuid.uuid4().hex + manual_images = collect_journal_slot_images( + d, + request.files, + entry_id, + app.config["UPLOAD_FOLDER"], + secure_filename_fn=secure_filename, + ) + images_json_str = images_json_dumps(manual_images) + image_filename = primary_journal_image(manual_images) + has_manual_uploads = bool(manual_images) + + 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):.2f}" + except Exception: + pass + + want_exchange_chart = ( + not has_manual_uploads + and 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]}" + journal_tfs = parse_journal_chart_timeframes( + d.get("journal_chart_tf1"), + d.get("journal_chart_tf2"), + ORDER_CHART_TFS[:2] if ORDER_CHART_TFS else None, + ) + journal_limit = parse_journal_chart_limit(d.get("journal_chart_limit"), ORDER_CHART_LIMIT) + chart_anchor = parse_journal_chart_anchor(d.get("journal_chart_anchor")) + 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": d.get("exit_price_hint"), + "stop_loss_price": d.get("stop_loss_hint"), + "chart_anchor": chart_anchor, + "now_ts_ms": int(app_now().timestamp() * 1000), + } + try: + chart_fname = f"journal_{entry_id}.png" + saved = generate_multi_timeframe_chart_png( + exchange_symbol, + title_prefix, + timeframes=journal_tfs, + limit=journal_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 journal_tfs}, + layout="vertical", + ) + if saved: + image_filename = saved + chart_msg = f"已生成复盘K线图({'/'.join(journal_tfs)} 各{journal_limit}根):/static/images/{saved}" + else: + chart_msg = "已勾选自动生成K线图,但生成失败(返回空).请检查 Pillow 是否安装,OKX 网络/代理是否正常." + except Exception as e: + chart_msg = f"自动生成K线图失败:{str(e)}" + + conn = get_db() + conn.execute( + """INSERT INTO journal_entries + (id, open_datetime, close_datetime, hold_duration, coin, tf, direction, pnl, order_type, 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, images_json) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + entry_id, + normalize_bj_datetime_storage(d.get("open_datetime")), + normalize_bj_datetime_storage(d.get("close_datetime")), + hold_duration, + d.get("coin"), + d.get("tf"), + direction_norm, + d.get("pnl"), order_type_norm, 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"), None, d.get("note"), image_filename, + images_json_str, + ) + ) + from lib.trade.account_risk_lib import on_journal_saved + + on_journal_saved( + conn, + early_exit_trigger=early_exit_trigger, + early_exit_note=early_exit_note, + mood_issues_raw=mood_issues, + trading_day=get_trading_day(), + now=app_now(), + ) + conn.commit() + conn.close() + if chart_msg: + flash(f"交易复盘记录已保存.{chart_msg}") + else: + flash("交易复盘记录已保存") + return _redirect_records() + + +@app.route("/api/journal_upload_slot", methods=["POST"]) +@login_required +def api_journal_upload_slot(): + payload, code = handle_journal_upload_slot( + request, + upload_folder=app.config["UPLOAD_FOLDER"], + secure_filename_fn=secure_filename, + ) + return jsonify(payload), code + + +from lib.instance.records_api_register import register_trade_records_api + +register_trade_records_api( + app, + login_required=login_required, + get_db=get_db, + list_window_from_request=_list_window_from_request, + utc_window_to_bj_sql_strings=utc_window_to_bj_sql_strings, + sql_list_time_field=sql_list_time_field, + to_effective_trade_dict=to_effective_trade_dict, + filter_trade_records_excluding_miss=filter_trade_records_excluding_miss, + app_tz=APP_TZ, +) + + +def _dashboard_fetch_options_positions(): + if not OKX_OPTIONS_ENABLED: + return [] + cfg = app.extensions.get("options_cfg") + if not isinstance(cfg, dict) or not cfg.get("enabled"): + return [] + try: + from lib.options.options_hub_lib import build_options_hub_snapshot + + snap = build_options_hub_snapshot(cfg) + except Exception: + return [] + if not snap.get("ok"): + return [] + return list(snap.get("positions") or []) + + +from lib.instance.instance_dashboard_register import register_instance_dashboard_routes + +register_instance_dashboard_routes( + app, + login_required=login_required, + get_db=get_db, + fetch_options_positions=_dashboard_fetch_options_positions, + hedge_enabled=os.getenv("HEDGE_PLAN_ENABLED", "false").lower() in ("1", "true", "yes", "on"), +) + + +@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( + f"SELECT * FROM journal_entries WHERE {sql_list_time_field('close_datetime', 'created_at', 'open_datetime')} >= ? " + f"AND {sql_list_time_field('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 = enrich_journal_api_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("/delete_journal/", methods=["POST"]) +@login_required +def delete_journal(jid): + conn = get_db() + row = conn.execute( + "SELECT image, images_json FROM journal_entries WHERE id=?", + (jid,), + ).fetchone() + if row: + for img_path in journal_image_paths(row, app.config["UPLOAD_FOLDER"]): + try: + if os.path.exists(img_path): + os.remove(img_path) + except Exception: + pass + 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_sql, end_sql = utc_window_to_utc_sql_strings(win["start_utc"], win["end_utc"]) + conn = get_db() + rows = conn.execute( + "SELECT * FROM ai_reviews WHERE created_at >= ? AND created_at <= ? ORDER BY created_at DESC LIMIT 200", + (start_sql, end_sql), + ).fetchall() + conn.close() + return jsonify([row_to_dict(r) for r in rows]) + + +_REPO_STATIC_DIR = common_static_dir(os.path.dirname(BASE_DIR)) +_AI_REVIEW_RENDER_JS = os.path.join(_REPO_STATIC_DIR, "ai_review_render.js") +_FORM_SUBMIT_GUARD_JS = os.path.join(_REPO_STATIC_DIR, "form_submit_guard.js") +_MANUAL_ORDER_RR_PREVIEW_JS = os.path.join(_REPO_STATIC_DIR, "manual_order_rr_preview.js") +_OPTIONS_PANEL_JS = os.path.join(_REPO_STATIC_DIR, "options_panel.js") +_OPTIONS_EXPIRY_COUNTDOWN_JS = os.path.join(_REPO_STATIC_DIR, "options_expiry_countdown.js") +_OPTIONS_SETTINGS_JS = os.path.join(_REPO_STATIC_DIR, "options_settings.js") +_HEDGE_PLAN_JS = os.path.join(_REPO_STATIC_DIR, "hedge_plan.js") + + +@app.route("/static/ai_review_render.js") +def static_ai_review_render_js(): + if not os.path.isfile(_AI_REVIEW_RENDER_JS): + return Response("not found", status=404, mimetype="text/plain; charset=utf-8") + return send_file(_AI_REVIEW_RENDER_JS, mimetype="application/javascript; charset=utf-8") + + +@app.route("/static/form_submit_guard.js") +def static_form_submit_guard_js(): + if not os.path.isfile(_FORM_SUBMIT_GUARD_JS): + return Response("not found", status=404, mimetype="text/plain; charset=utf-8") + return send_file(_FORM_SUBMIT_GUARD_JS, mimetype="application/javascript; charset=utf-8") + + +@app.route("/static/manual_order_rr_preview.js") +def static_manual_order_rr_preview_js(): + if not os.path.isfile(_MANUAL_ORDER_RR_PREVIEW_JS): + return Response("not found", status=404, mimetype="text/plain; charset=utf-8") + return send_file(_MANUAL_ORDER_RR_PREVIEW_JS, mimetype="application/javascript; charset=utf-8") + + +@app.route("/static/options_panel.js") +def static_options_panel_js(): + if not os.path.isfile(_OPTIONS_PANEL_JS): + return Response("not found", status=404, mimetype="text/plain; charset=utf-8") + return send_file(_OPTIONS_PANEL_JS, mimetype="application/javascript; charset=utf-8") + + +@app.route("/static/options_expiry_countdown.js") +def static_options_expiry_countdown_js(): + if not os.path.isfile(_OPTIONS_EXPIRY_COUNTDOWN_JS): + return Response("not found", status=404, mimetype="text/plain; charset=utf-8") + return send_file(_OPTIONS_EXPIRY_COUNTDOWN_JS, mimetype="application/javascript; charset=utf-8") + + +@app.route("/static/options_settings.js") +def static_options_settings_js(): + if not os.path.isfile(_OPTIONS_SETTINGS_JS): + return Response("not found", status=404, mimetype="text/plain; charset=utf-8") + return send_file(_OPTIONS_SETTINGS_JS, mimetype="application/javascript; charset=utf-8") + + +@app.route("/static/hedge_plan.js") +def static_hedge_plan_js(): + if not os.path.isfile(_HEDGE_PLAN_JS): + return Response("not found", status=404, mimetype="text/plain; charset=utf-8") + return send_file(_HEDGE_PLAN_JS, mimetype="application/javascript; charset=utf-8") + + +@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": "结果仅允许:" + "/".join(REVIEW_RESULT_OPTIONS)}), 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() + norm = normalize_entry_reason(s) if s else None + if s and not norm: + return jsonify({"ok": False, "msg": "开仓类型须为下拉选项之一或留空"}), 400 + reviewed_entry_reason_update = norm + + 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]), + ) + if reviewed_result == "手动平仓" and reviewed_miss_reason: + from lib.trade.account_risk_lib import apply_manual_close_journal_cooloff + + apply_manual_close_journal_cooloff( + conn, + early_exit_note=reviewed_miss_reason, + trading_day=get_trading_day(), + now=app_now(), + ) + 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("/settings") + 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: + invalidate_account_balance_cache() + try: + from lib.instance.instance_live_push_lib import notify_instance_balance_changed + + notify_instance_balance_changed() + except Exception: + pass + flash(f"手动划转成功:{amount}U {from_account}->{to_account}") + else: + flash(f"手动划转失败:{msg}") + return redirect("/settings") + + +def _journal_ai_chart_builder(row): + return build_journal_ai_chart_path( + row, + app.config["UPLOAD_FOLDER"], + order_chart_enabled=ORDER_CHART_ENABLED, + normalize_exchange_symbol_fn=lambda c: normalize_exchange_symbol(normalize_symbol_input(c)), + generate_chart_fn=generate_multi_timeframe_chart_png, + local_datetime_to_ms_fn=_local_input_datetime_to_ms, + now_ts_ms_fn=lambda: int(app_now().timestamp() * 1000), + ) + + +@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 = collect_images_for_ai_review( + rows, + app.config["UPLOAD_FOLDER"], + build_chart_if_missing=_journal_ai_chart_builder, + ) + 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 = collect_images_for_ai_review( + rows, + app.config["UPLOAD_FOLDER"], + build_chart_if_missing=_journal_ai_chart_builder, + ) + 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}) + +def _hub_meta_bundle(): + return { + "exchange_display": EXCHANGE_DISPLAY_NAME, + "key_gate_rule_text": ( + f"周期 {KLINE_TIMEFRAME}|量能/突破/二确门控见箱体与收敛规则|" + 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}%" + ), + "manual_min_planned_rr": MANUAL_MIN_PLANNED_RR, + "max_active_positions": MAX_ACTIVE_POSITIONS, + "btc_leverage": BTC_LEVERAGE, + "alt_leverage": ALT_LEVERAGE, + "trade_policy": trade_policy_template_context(TRADE_POLICY), + **hub_meta_entry_context(TRADE_POLICY), + "options_enabled": OKX_OPTIONS_ENABLED, + } + + +def _hub_account_bundle(): + funding_capital, trading_capital = get_exchange_capitals(force=True) + funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None + trading_usdt = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else None + available = get_available_trading_usdt() + return { + "funding_usdt": funding_usdt, + "trading_usdt": trading_usdt, + "available_trading_usdt": round(available, FUNDS_DECIMALS) if available is not None else None, + "trading_day": get_trading_day(app_now()), + } + + +def _hub_fetch_market(base=""): + from lib.hub.hub_market_info_lib import fetch_usdt_swap_market_info + + return fetch_usdt_swap_market_info( + base_or_symbol=base, + normalize_symbol_input=normalize_symbol_input, + normalize_exchange_symbol=normalize_okx_symbol, + ensure_markets_loaded=ensure_markets_loaded, + exchange=exchange, + exchange_id="okx", + ) + + +def _hub_fetch_ohlcv(symbol, timeframe, since_ms=None, limit=500): + from lib.hub.hub_ohlcv_lib import fetch_ohlcv_for_hub + + return fetch_ohlcv_for_hub( + symbol=symbol, + timeframe=timeframe, + since_ms=since_ms, + limit=limit, + normalize_symbol_input=normalize_symbol_input, + normalize_exchange_symbol=normalize_okx_symbol, + ensure_markets_loaded=ensure_markets_loaded, + exchange=exchange, + friendly_error=friendly_okx_error, + ) + + +def _hub_fetch_volume_rank(top_n=20): + from lib.hub.hub_volume_rank_lib import fetch_usdt_swap_volume_rank + + return fetch_usdt_swap_volume_rank( + exchange=exchange, + ensure_markets_loaded=ensure_markets_loaded, + top_n=top_n, + exchange_id="okx", + ) + + +try: + import sys + from pathlib import Path + + _repo_root = Path(__file__).resolve().parent.parent + if str(_repo_root) not in sys.path: + sys.path.insert(0, str(_repo_root)) + from lib.hub.hub_bridge import install_on_app + + install_on_app( + app, + exchange="okx", + capabilities=["order", "key"], + has_trend=True, + get_db=get_db, + row_to_dict=row_to_dict, + meta_fn=_hub_meta_bundle, + account_fn=_hub_account_bundle, + views={"add_order": add_order, "add_key": add_key}, + ohlcv_fn=_hub_fetch_ohlcv, + volume_rank_fn=_hub_fetch_volume_rank, + market_fn=_hub_fetch_market, + reconcile_hub_flat_fn=reconcile_hub_external_close, + risk_status_fn=hub_account_risk_status, + user_close_fn=hub_user_initiated_close, + render_main_page_fn=render_main_page, + login_required_fn=login_required, + ) +except Exception as _hub_err: + print(f"[hub_bridge] okx: {_hub_err}") + +try: + from lib.instance.instance_settings_register import register_instance_settings_routes + + register_instance_settings_routes( + app, + get_db=get_db, + login_required_fn=login_required, + base_dir=BASE_DIR, + exchange_key="okx", + username=USERNAME, + password=PASSWORD, + ) +except Exception as _settings_err: + print(f"[instance_settings] okx: {_settings_err}") + + +@app.route("/strategy") +@login_required +def strategy_trading_page(): + return render_main_page("strategy") + + +@app.route("/strategy/trend") +@login_required +def strategy_trend_page(): + qs = request.query_string.decode() + return redirect(f"/strategy?{qs}" if qs else "/strategy") + + +@app.route("/strategy/roll") +@login_required +def strategy_roll_page(): + return redirect("/strategy") + + +# 根目录 strategy_* 与币安/Gate 共用同一套属性名(OKX 内部仍用 normalize_okx_symbol / ensure_okx_live_ready) +normalize_exchange_symbol = normalize_okx_symbol +ensure_exchange_live_ready = ensure_okx_live_ready + +from lib.strategy.strategy_register import install_strategy_trading +from lib.strategy.strategy_trend_register import install_strategy_trend + +install_strategy_trading(app, _REPO_ROOT, app_module=sys.modules[__name__]) +install_strategy_trend(app, _REPO_ROOT, app_module=sys.modules[__name__]) + +from lib.options.options_register import install_options_trading + +install_options_trading(app, _REPO_ROOT, app_module=sys.modules[__name__]) + +from lib.options.options_review_register import install_options_review + +install_options_review(app, _REPO_ROOT, app_module=sys.modules[__name__]) + +from lib.hedge_plan.hedge_plan_register import install_hedge_plan + +install_hedge_plan(app, _REPO_ROOT, app_module=sys.modules[__name__]) + +_purge_key_monitors_if_full_margin() + + +# 启动 +if __name__ == "__main__": + from lib.common.flask_access_log_lib import silence_werkzeug_access_log + + silence_werkzeug_access_log() + threading.Thread(target=background_task, daemon=True).start() + app.run(host=HOST, port=PORT, debug=DEBUG, threaded=True) diff --git a/crypto_monitor_okx/ecosystem.config.cjs b/crypto_monitor_okx/ecosystem.config.cjs new file mode 100644 index 0000000..5abd1f6 --- /dev/null +++ b/crypto_monitor_okx/ecosystem.config.cjs @@ -0,0 +1,34 @@ +/** + * PM2 进程定义(Ubuntu / Linux). + * + * 仅托管 Flask 应用.**SSH SOCKS 隧道**用 `ssh -D` 常驻(可用 tmux / autossh),勿交给 PM2. + * 与 `.env` 里 `OKX_SOCKS_PROXY` 端口一致即可;不必交给 PM2. + * + * 使用前:项目根目录存在 `.venv`,且已安装依赖(走 SOCKS 时需 PySocks). + * + * 启动: + * pm2 start ecosystem.config.cjs + * 保存开机列表: + * pm2 save && pm2 startup + */ +const path = require("path"); + +const ROOT = __dirname; +const REPO_ROOT = path.join(ROOT, ".."); +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", + env: { PYTHONPATH: REPO_ROOT }, + }, + ], +}; diff --git a/crypto_monitor_okx/scripts/backup_data.sh b/crypto_monitor_okx/scripts/backup_data.sh new file mode 100644 index 0000000..9a25287 --- /dev/null +++ b/crypto_monitor_okx/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_okx/scripts/fix_breakeven_labels.py b/crypto_monitor_okx/scripts/fix_breakeven_labels.py new file mode 100644 index 0000000..97a910a --- /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/scripts/install_backup_cron.sh b/crypto_monitor_okx/scripts/install_backup_cron.sh new file mode 100644 index 0000000..96053f4 --- /dev/null +++ b/crypto_monitor_okx/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_okx/scripts/verify_okx_funding.py b/crypto_monitor_okx/scripts/verify_okx_funding.py new file mode 100644 index 0000000..1550dc7 --- /dev/null +++ b/crypto_monitor_okx/scripts/verify_okx_funding.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +""" + python scripts/verify_okx_funding.py + +打印 OKX_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("OKX_API_KEY") or "").strip() + s = (os.getenv("OKX_API_SECRET") or "").strip() + if not k or "REPLACE" in k.upper(): + print("WARN: OKX_API_KEY 为空或仍像占位符,请核对 .env") + if not s or "REPLACE" in s.upper(): + print("WARN: OKX_API_SECRET 为空或仍像占位符,请核对 .env") + print("OKX_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_okx_funding_usdt() + print(">>> _fetch_okx_funding_usdt() =", fu) + try: + sw = mod._fetch_okx_swap_usdt_total() + print(">>> _fetch_okx_swap_usdt_total() (合约账户) =", sw) + sf = mod._fetch_okx_swap_usdt_free() + print(">>> _fetch_okx_swap_usdt_free() (合约可用) =", sf) + except Exception as e: + print(">>> swap balance fetch error:", e) + + +if __name__ == "__main__": + main() diff --git a/crypto_monitor_okx/static/icons/apple-touch-icon.png b/crypto_monitor_okx/static/icons/apple-touch-icon.png new file mode 100644 index 0000000..82d1e82 Binary files /dev/null and b/crypto_monitor_okx/static/icons/apple-touch-icon.png differ diff --git a/crypto_monitor_okx/static/icons/favicon.ico b/crypto_monitor_okx/static/icons/favicon.ico new file mode 100644 index 0000000..2dbb0dd Binary files /dev/null and b/crypto_monitor_okx/static/icons/favicon.ico differ diff --git a/crypto_monitor_okx/static/icons/icon-16.png b/crypto_monitor_okx/static/icons/icon-16.png new file mode 100644 index 0000000..5ea0f54 Binary files /dev/null and b/crypto_monitor_okx/static/icons/icon-16.png differ diff --git a/crypto_monitor_okx/static/icons/icon-192.png b/crypto_monitor_okx/static/icons/icon-192.png new file mode 100644 index 0000000..55ed1de Binary files /dev/null and b/crypto_monitor_okx/static/icons/icon-192.png differ diff --git a/crypto_monitor_okx/static/icons/icon-32.png b/crypto_monitor_okx/static/icons/icon-32.png new file mode 100644 index 0000000..ea8a9a2 Binary files /dev/null and b/crypto_monitor_okx/static/icons/icon-32.png differ diff --git a/crypto_monitor_okx/static/icons/icon-512.png b/crypto_monitor_okx/static/icons/icon-512.png new file mode 100644 index 0000000..e526b77 Binary files /dev/null and b/crypto_monitor_okx/static/icons/icon-512.png differ diff --git a/crypto_monitor_okx/static/icons/icon.svg b/crypto_monitor_okx/static/icons/icon.svg new file mode 100644 index 0000000..b7eaa46 --- /dev/null +++ b/crypto_monitor_okx/static/icons/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crypto_monitor_okx/static/icons/manifest.webmanifest b/crypto_monitor_okx/static/icons/manifest.webmanifest new file mode 100644 index 0000000..7d82187 --- /dev/null +++ b/crypto_monitor_okx/static/icons/manifest.webmanifest @@ -0,0 +1,23 @@ +{ + "name": "OKX 交易系统", + "short_name": "OKX 交易系统", + "description": "OKX 永续交易监控与复盘", + "start_url": "/", + "display": "standalone", + "background_color": "#0b0d14", + "theme_color": "#FFFFFF", + "icons": [ + { + "src": "/static/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/static/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} 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/order_focus.html b/crypto_monitor_okx/templates/order_focus.html new file mode 100644 index 0000000..3dc7ce3 --- /dev/null +++ b/crypto_monitor_okx/templates/order_focus.html @@ -0,0 +1,195 @@ + + + + + 实盘下单放大 | 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..e27782b --- /dev/null +++ b/crypto_monitor_okx/使用说明.md @@ -0,0 +1,138 @@ +# 使用说明 + +**本文件对应仓库:`crypto_monitor_okx`(OKX USDT 本位永续).** +功能,界面与 **Gate.io USDT 永续版**(目录 `crypto_monitor_gate`)基本一致,差异主要在 **`.env` 里交易所密钥与部分参数名**(`OKX_*` / `GATE_*`),文末有对照. + +**部署,代理,PM2 等**请参考本仓库说明或 **`crypto_monitor_gate`** 下的 **`部署文档.md`**(该文以 Gate + SSH SOCKS 为例;OKX 侧将 API 与密钥改为 `OKX_*` 即可类比). +**关键位自动开仓的规则,RR,结案原因**见本目录 **`关键位自动下单说明.md`**. + +--- + +## 1. 它能做什么 + +面向个人盘面的 **Web 控制台**,主要能力包括: + +| 模块 | 说明 | +|------|------| +| **关键位监控** | 录入上/下沿与类型,按 **5m 收线** 做硬条件过滤;符合条件后 **企业微信** 提醒,部分类型可 **自动市价开仓**(见第 4 节与专门文档). | +| **实盘下单监控** | 手工填止损/止盈,**以损定仓** 市价开单,挂上条件止盈止损,并在页面跟踪浮盈亏,保本逻辑等. | +| **交易记录 / 复盘** | 平仓结果,盈亏,错过的单等归档与导出;可选 **AI 复盘**(见仓库根 [AI复盘与模型配置说明.md](../AI复盘与模型配置说明.md)). | +| **策略交易** | 顶栏 `/strategy`:**趋势回调**(左)与 **顺势加仓**(右)左右并列;细则见 [策略交易说明.md](../策略交易说明.md). | + +后台按 **`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** | **本仓库:** `OKX_API_KEY`,`OKX_API_SECRET`;永续相关见 `OKX_TD_MODE`,`OKX_POS_MODE`,`OKX_TRIGGER_WORKING_TYPE` 等.**勿**把 `.env` 提交到 Git. | +| **关键位 RR / 止损外扩** | `KEY_AUTO_MIN_PLANNED_RR`,`KEY_STOP_OUTSIDE_BREAKOUT_PCT`(详见 `关键位自动下单说明.md`). | +| **AI 复盘** | 默认 `AI_PROVIDER=openai`,`OPENAI_API_BASE=https://op.bz121.com/v1`,`OPENAI_API_KEY`,`OPENAI_MODEL=gemma4:e4b`;或 `AI_PROVIDER=ollama` + `OLLAMA_API` / `AI_MODEL`.详见 [AI复盘与模型配置说明.md](../AI复盘与模型配置说明.md). | + +网络需要代理时可配置 **`OKX_SOCKS_PROXY` / `OKX_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`** 登录. + +登录后顶栏:**关键位监控** | **实盘下单**(默认首页)| **策略交易**(`/strategy`,趋势回调 + 顺势加仓双栏)| **策略交易记录**(`/strategy/records`)| **交易记录与复盘** | **统计分析**. + +--- + +## 4. 关键位监控(顶栏「关键位监控」→ `/key_monitor`) + +### 4.1 添加一条关键位 + +1. **币种**:如 `BTC` 或 `BTC/USDT`(会规范成内部符号). +2. **类型**(必选其一): + + | 类型 | 行为摘要 | + |------|----------| + | **箱体突破** | 通过门控且计划 RR 达标 → **自动市价开仓**(需 `LIVE_TRADING_ENABLED=true` 且无其他持仓占位).结案后本条从列表消失并记入历史. | + | **收敛突破** | 同上(自动开仓类). | + | **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**. | + | **关键支撑位** | 同上(仅提醒). | + | **回调触价开仓** | **不挂交易所限价**;标记价回调触达 E 后 **下一轮询市价开仓**(RR 门槛同 `KEY_AUTO_MIN_PLANNED_RR`);有效期 **24h** | + | **突破触价开仓** | **不挂交易所限价**;标记价 **穿越 E 立即市价开仓**;先触 SL/TP 侧失效;有效期 **24h** | + +3. **方向**:做多 / 做空(触价开仓 / 箱体 / 收敛 / 斐波必选;阻力/支撑不选). +4. **价位**:箱体/收敛/阻力/支撑填 **上沿 / 下沿**;触价开仓填 **入场 E / 止损 SL / 止盈 TP**. + +**限制:** +活跃持仓数达到 **`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. 与币安版(`crypto_monitor_binance`)差异速查 + +| 项目 | OKX 本仓库 | 币安版 | +|------|------------|--------| +| API 变量 | `OKX_API_KEY`,`OKX_API_SECRET`,`OKX_API_PASSPHRASE` | `BINANCE_API_KEY`,`BINANCE_API_SECRET` | +| 代理 | `OKX_SOCKS_PROXY` | `BINANCE_SOCKS_PROXY` | +| 默认端口 | 常为 `5004` | 常为 `5001` | +| TP/SL 实现 | `_okx_place_tp_sl_orders`,页面 `/api/order/.../cancel_tpsl` | `_binance_place_tp_sl_orders` | + +业务流程,顶栏分栏,策略交易,风控参数名已与币安版对齐;仅需更换目录与 `.env`. diff --git a/crypto_monitor_okx/关键位自动下单说明.md b/crypto_monitor_okx/关键位自动下单说明.md new file mode 100644 index 0000000..a8ca535 --- /dev/null +++ b/crypto_monitor_okx/关键位自动下单说明.md @@ -0,0 +1,192 @@ +# 关键位监控说明(自动开仓 + 人工盯盘) + +**适用:Gate / Binance / OKX 三所实例(共用 `lib/key_monitor/key_auto_order_lib.py`)** + +## 环境开关 `KEY_AUTO_ORDER_ENABLED`(默认 `false`) + +| 计仓模式 | 开关 | 关键位程序自动单 | +|----------|------|------------------| +| `risk`(以损定仓) | `false` | **全部关闭**(含触价);支撑/阻力微信提醒仍可用 | +| `risk` | `true` | 箱体/收敛/斐波/假突破/触价均可自动(旧行为) | +| `full_margin`(全仓) | `false` | 全部关闭(含触价) | +| `full_margin` | `true` | **仅触价**自动;箱体/斐波等仍禁止 | + +**不受本开关影响:** 人工实盘下单,关键支撑/阻力提醒,**顺势加仓**(`risk` 下),趋势回调(`risk` 下).全仓模式下策略自动仍禁止. + +修改 `.env` 后须 **重启 PM2**.复盘「开仓类型」与统计分段会随开关联动隐藏关键位选项. + +--- + +**适用:`crypto_monitor_gate`(Gate U 本位永续)** +Binance / OKX 见各自目录下同名文档;共享逻辑在 `lib/key_monitor/`. + +本文档与 `.env`,`check_key_monitors`,`add_key`,`_key_hard_checks`,`_process_key_rs_level_alert` 一致. + +--- + +## 一,监控类型总览 + +| 录入类型 | 录入时选方向 | 自动市价开仓 | 触发与结案 | +|----------|--------------|--------------|------------| +| **箱体突破** | **必选** 多/空 | **是**(门控 + RR) | 条件满足 → 开仓或 `rr_insufficient` / `exchange_failed` → **一次性删除** | +| **收敛突破** | **必选** 多/空 | **是**(同上) | 同上 | +| **关键阻力位** | **不选**(`direction=watch`) | **否** | 5m 收盘突破上/下沿 → 微信 **3 次** → `key_level_alert_done` | +| **关键支撑位** | **不选** | **否** | 同上(与阻力位**相同规则**:填上沿+下沿,程序双向监控) | +| 斐波回调 0.618 / 0.786 | 必选 | 限价挂单逻辑 | 见斐波说明(**不在下文展开**) | +| **回调触价开仓** | **必选** 多/空 | **程序盯价 → 回调触 E 后市价** | 见下文 **§四** | +| **突破触价开仓** | **必选** 多/空 | **程序盯价 → 穿越 E 立即市价** | 见下文 **§四** | + +**添加时(箱体/收敛/斐波/触价):** 品种须 **日成交量排名前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30)**;上沿 **>** 下沿(触价开仓填 E/SL/TP,上下沿仅作展示占位). + +--- + +## 二,关键阻力位 / 关键支撑位(人工盯盘) + +### 2.1 录入 + +- 填写 **上沿 `upper`** 与 **下沿 `lower`**(程序同时监控两侧,**无法预先判定**做多还是做空). +- 页面 **不显示,不要求** 方向;库中 `direction` 初始为 `watch`,**首次突破后** 写入 `long`(向上突破上沿)或 `short`(向下突破下沿). + +### 2.2 触发(极简) + +- 周期:**`KLINE_TIMEFRAME`(默认 5m)最近一根已闭合 K** 的 **收盘价**(非影线). +- **向上突破上沿:** `收盘 > upper` → 推断方向 **多 / 向上**,本次监控任务开始按节奏提醒. +- **向下突破下沿:** `收盘 < lower` → 推断方向 **空 / 向下**,本次任务同样开始提醒. +- **任一侧突破即结束本条监控周期**(不会在突破后再等待另一侧;上沿,下沿谁先满足用谁,同根 K 仅可能满足一侧). + +**不参与:** 量能,二确 K,越过幅度下限,日成交排名(运行时),计划 RR,自动开仓. + +### 2.3 微信提醒次数 + +| 配置 | 默认 | 含义 | +|------|------|------| +| `KEY_ALERT_MAX_TIMES` | `3` | 突破后最多推送 3 次 | +| `KEY_ALERT_INTERVAL_MINUTES` | `5` | 相邻两次推送至少间隔 5 分钟 | + +- 第 1 次:首次检测到突破的当次轮询(若已闭合 5m 满足条件). +- 第 2,3 次:仅按间隔推送(**不要求**价格仍在箱外). +- 第 3 次推送后:写入 `key_monitor_history`,`close_reason=**key_level_alert_done**`,从 `key_monitors` **删除**. + +### 2.4 与箱体/收敛的区别 + +| 项目 | 阻力/支撑 | 箱体/收敛 | +|------|-----------|-----------| +| 方向 | 程序推断 | 人工选择 | +| K 线根数 | 1 根闭合 5m | 2 根(突破 K + 确认 K) | +| 提醒次数 | 3 次后结案 | 自动单:触发后 1 次业务推送并结案 | + +--- + +## 三,箱体突破 / 收敛突破(自动开仓) + +### 3.1 K 线结构(默认索引) + +| 角色 | 环境变量 | 默认 | 含义 | +|------|----------|------|------| +| 突破 K | `KEY_CONFIRM_BREAKOUT_BAR` | `-2` | 倒数第 2 根闭合 K | +| 确认 K | `KEY_CONFIRM_BAR` | `-1` | 倒数第 1 根闭合 K | + +### 3.2 硬门控(须全部通过) + +1. **有效突破(收盘越界)** + - 多:`突破 K 收盘 > upper` + - 空:`突破 K 收盘 < lower` + +2. **突破越过幅度(仅下限)** + - 多:`(突破 K 收盘 − upper) / upper × 100 > KEY_BREAKOUT_AMP_MIN_PCT`(默认 **0.03%**) + - 空:`(lower − 突破 K 收盘) / lower × 100 >` 同上 + - **无上限**;突破过猛由 **计划 RR** 过滤. + - **不再**使用 K 线实体占开盘价比例;`KEY_BREAKOUT_AMP_MAX_PCT` **已不参与门控**. + +3. **确认 K 不进箱体** + - 多:确认 K 收盘 **`> upper`**(不得在 `[lower, upper]` 内) + - 空:确认 K 收盘 **`< lower`** + +4. **量能:** 突破 K 成交量 > 前 `KEY_VOLUME_MA_BARS`(默认 20)根均量 × `KEY_VOLUME_RATIO_MIN`(默认 1.3) + +5. **日成交量排名:** 运行时仍须前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30) + +6. **计划 RR(最后经济门控):** 按确认 K 收盘 **E** 计算 SL/TP 后,`RR` **严格大于** `KEY_AUTO_MIN_PLANNED_RR`(默认 1.5)才市价开仓 + +### 3.3 止损 / 止盈(确认 K 收盘为 E) + +箱体高 **H = |upper − lower|**.止损锚在 **突破 K 极值** 外侧: + +| 方向 | 止损(标准/趋势方案) | +|------|------------------------| +| 多 | 突破 K **最低价** × (1 − `KEY_STOP_OUTSIDE_BREAKOUT_PCT`%) | +| 空 | 突破 K **最高价** × (1 + `KEY_STOP_OUTSIDE_BREAKOUT_PCT`%) | + +止盈方案见下表(与改版前一致): + +| 方案 | `sl_tp_mode` | 多:SL / TP | 空:SL / TP | +|------|--------------|-------------|-------------| +| 标准突破 | `standard` | 突破 K 低外侧% / **E+H** | 突破 K 高外侧% / **E−H** | +| 箱体 1R·止盈 1.5H | `box_1p5` | **E−H** / **E+1.5×H** | **E+H** / **E−1.5×H** | +| 趋势单·自填止盈 | `trend_manual` | 突破 K 低 × (1−`KEY_TREND_STOP_OUTSIDE_PCT`%) / **录入止盈** | 突破 K 高外侧% / **录入止盈** | + +### 3.4 一次性结案(`close_reason`) + +| `close_reason` | 含义 | +|----------------|------| +| `box_opposite_break` | 标记价先突破反向边界(多:≤下沿;空:≥上沿) | +| `rr_insufficient` | 门控通过但 RR 不达标或 SL/TP 几何无效 | +| `exchange_failed` | RR 达标但实盘/交易所等原因未开仓 | +| `auto_opened` | RR 达标且市价开仓成功 | +| `key_level_alert_done` | 阻力/支撑 **3 次提醒** 完成 | + +--- + +## 四,回调 / 突破触价开仓(程序触价,无交易所挂单) + +### 4.1 录入 + +- **回调触价开仓**:方向必选多/空;填写 **计划入场价 E**,**止损 SL**,**止盈 TP**(做多须 `SL < E < TP`). +- **突破触价开仓**:同上;添加时当前价须在突破方向一侧(做多:价低于 E;做空:价高于 E). +- 计划 RR 以 **E** 为基准,须 **严格大于** `KEY_AUTO_MIN_PLANNED_RR`(默认 1.5). +- 可选移动保本,时间平仓;**全仓杠杆模式**下可用. + +### 4.2 触发与结案 + +| 类型 | 触发条件(标记价) | +|------|-------------------| +| **回调触价** | 做多 `≤ E`;做空 `≥ E` → 下一轮询市价开仓 | +| **突破触价** | 做多**向上穿越** E;做空**向下穿越** E → **立即**市价开仓 | + +- 未成交前标记价先触 **TP 侧** → `trigger_tp_invalidate`. +- **突破触价**另:未穿越 E 先触 **SL 侧** → `trigger_sl_invalidate`. +- **24h** 未触发 → `trigger_entry_expired`. +- 成功 → `trigger_entry_filled`;触发后开仓失败 → `trigger_exchange_failed`. + +### 4.3 计仓与占位 + +- **以损定仓**:按 E,SL 反推保证金,触发时重算;**全仓杠杆**:可用×缓冲比例,BTC/ETH 10x,其它 5x. +- **占当日开仓意图**(已开 + 待触发),未成交不占持仓;同币仅 1 条触价监控(含回调/突破). + +共享逻辑:`trigger_entry_key_monitor_lib.py`;轮询:`check_trigger_entry_key_monitors`. + +--- + +## 五,环境与参数(`.env` 摘要) + +| 变量 | 箱体/收敛 | 阻力/支撑 | +|------|-----------|-----------| +| `KEY_BREAKOUT_AMP_MIN_PCT` | 突破越过下限(默认 0.03) | 不用 | +| `KEY_BREAKOUT_AMP_MAX_PCT` | **已废弃门控** | 不用 | +| `KEY_VOLUME_*` / `KEY_CONFIRM_*` | 用 | 不用 | +| `KEY_AUTO_MIN_PLANNED_RR` | 用 | 不用 | +| `KEY_ALERT_MAX_TIMES` / `KEY_ALERT_INTERVAL_MINUTES` | 不用 | 用(默认 3 次 / 5 分钟) | +| `KEY_DAILY_VOLUME_RANK_MAX` | 添加时 + 运行时 | **仅添加时** | + +--- + +## 六,相关代码 + +| 说明 | 位置 | +|------|------| +| 共享判定 | `key_monitor_lib.py` | +| 主循环 | `check_key_monitors` | +| 自动门控 | `_key_hard_checks` | +| 阻力/支撑提醒 | `_process_key_rs_level_alert` | +| 录入 | `add_key` | +| 开仓 | `_market_open_for_key_monitor` | diff --git a/crypto_monitor_okx/更新文档.md b/crypto_monitor_okx/更新文档.md new file mode 100644 index 0000000..aaa80a8 --- /dev/null +++ b/crypto_monitor_okx/更新文档.md @@ -0,0 +1,98 @@ +# 界面与风控更新说明(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 的差异(其余) + +- 无独立「关键位监控」导航页(斐波在 **交易执行** 页添加). +- 箱体/收敛与 Gate/Binance 相同:**门控 + RR 达标后自动市价开仓**(须 `LIVE_TRADING_ENABLED=true`). + +## 交易所已实现盈亏(与 Gate 一致) + +- 打开 **交易执行 / 交易记录** 等主页面时,若已配置 `OKX_API_KEY` / `OKX_API_SECRET` / `OKX_API_PASSPHRASE`(只读即可),同进程约 **25 秒**内最多调用一次 OKX **历史仓位**(`fetch_positions_history`),为未写入 `exchange_sync_key` 的记录匹配并回填 `exchange_realized_pnl`. +- 复盘列表盈亏优先展示交易所 U(旁标 **所**);本地公式估算标 **估**;人工复核优先. +- 手动强制同步:`GET /api/sync_exchange_pnl`(需登录). +- 可选 `.env`:`EXCHANGE_POSITION_SYNC_FROM_BJ`(北京时间起点),`EXCHANGE_POSITION_HISTORY_LIMIT`(默认 200). + +## 企业微信推送(与 Gate 对齐) + +- 平仓:`📉 … 平仓完成` 模板(盈亏 ±X.XX U,价位两位/按币价精度,账户资金 2 位小数). +- 开仓成功:与 Gate 相同的 emoji 分段(条件委托状态文案,RR/张数/名义 2 位小数). +- 移动保本:仅首次触发推送;交易所同步失败同一监控单只告警一次. +- 斐波/关键位/划转等推送数值格式与 Gate 一致(`format_wechat_scalar_2dp`). + +## 配置与部署 + +- 详见 `.env.example` 中 OKX(`OKX_*`)与通用风控项. +- 代码更新后请 **重启 OKX 监控进程**;旧库行不做批量回填,展示字段有则用之,无则回退. + +--- + +## 共享更新记录 + +自 2026-07-16 起,期权/对冲等共享逻辑的变更统一记在仓库根目录 **[docs/更新文档.md](../docs/更新文档.md)**(含原因、改动文件、目标、验收)。最新一条:期权/对冲开仓仅认真实卖一深度。 diff --git a/crypto_monitor_okx/部署文档.md b/crypto_monitor_okx/部署文档.md new file mode 100644 index 0000000..43fb44c --- /dev/null +++ b/crypto_monitor_okx/部署文档.md @@ -0,0 +1,367 @@ +# `crypto_monitor_okx` 部署文档(Ubuntu) + +**功能与页面操作** 见同目录 **[使用说明.md](./使用说明.md)**.Ubuntu 环境(Python / Node / PM2)见 **[docs/ubuntu-server.md](../docs/ubuntu-server.md)**.策略与 AI 见 **[策略交易说明.md](../策略交易说明.md)**,**[AI复盘与模型配置说明.md](../AI复盘与模型配置说明.md)**. + +--- + +# 本地部署 + 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 +- **SSH 隧道**用 `ssh -D` 常驻(可用 tmux / autossh);**Flask 应用** 仅用 **PM2** 托管(见 [docs/ubuntu-server.md](../docs/ubuntu-server.md)) + +> 安全提醒:不要把 `.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_user/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_user/crypto_monitor_okx +cd /opt/crypto_monitor_user +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_user/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_user/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` 后重填. + +**AI 复盘**:三所共用根目录 **`ai_client.py`**.默认 **`AI_PROVIDER=openai`**,网关 `https://op.bz121.com/v1`,模型 `gemma4:e4b`;或改 **`ollama`** 走本机 Ollama.PM2 须 **`PYTHONPATH=..`**.详见 **[AI复盘与模型配置说明.md](../AI复盘与模型配置说明.md)**. + +### 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 +# DAILY_OPEN_HARD_LIMIT=0 +# 说明见仓库 docs/daily-open-limit.md + +# AI 复盘(默认 OpenAI 兼容网关;与 Ollama 二选一) +AI_PROVIDER=openai +AI_TIMEOUT_SECONDS=120 +OPENAI_API_BASE=https://op.bz121.com/v1 +OPENAI_API_KEY=你的密钥 +OPENAI_MODEL=gemma4:e4b +# 本机 Ollama(仅 AI_PROVIDER=ollama) +OLLAMA_API=http://127.0.0.1:11434/api/generate +AI_MODEL=你的模型名 +``` + +> 完整说明见仓库根 **[AI复盘与模型配置说明.md](../AI复盘与模型配置说明.md)**.`OPENAI_API_KEY` 在 [op.bz121.com](https://op.bz121.com/) 的 `gateway.json` 获取. + +> `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_user/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_user/crypto_monitor_okx + +pm2 start /opt/crypto_monitor_user/crypto_monitor_okx/.venv/bin/python --name crypto-monitor -- \ + /opt/crypto_monitor_user/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..fee7c32 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,147 @@ +# 环境一键部署(Ubuntu / root /opt) + +在 **`/opt/crypto_monitor_user`** 下以 **root** 部署三所 Flask + 中控 hub/agent,使用 **PM2** 常驻. + +完整系统要求见 **[docs/ubuntu-server.md](../docs/ubuntu-server.md)**. + +--- + +## 一键部署管理器(推荐) + +新服务器**无需先 clone**,一条命令进入菜单: + +```bash +curl -fsSL https://git.bz121.com/dekun/crypto_monitor_user/raw/branch/main/deploy/manage.sh | bash +``` + +已安装机器: + +```bash +bash /opt/crypto_monitor_user/deploy/manage.sh +``` + +### 菜单 + +| 选项 | 功能 | +|------|------| +| **1) 一键部署(全套)** | 装系统依赖 + Node/PM2 + venv + 自动生成密钥 + 启动 7 进程(三所+中控) | +| **4) 仅 OKX 实例** | 只部署/启动 `crypto_okx`(不含中控/agent) | +| **5) 仅 Binance 实例** | 只部署/启动 `crypto_binance` | +| **6) 仅 Gate 实例** | 只部署/启动 `crypto_gate` | +| **2) 一键卸载** | 备份 `.env` / `hub_settings.json` → 停 PM2 → 移走目录 | +| **3) 更新** | 快速更新 / 依赖更新 / 深度重装 | +| **0) 退出** | | + +单所模式仍 clone 整仓到 `/opt/crypto_monitor_user`(共用 `lib/`),但 `setup_env.sh --only <所>` 只建该所 venv,且 PM2 只起对应 Flask. + +### 部署完成后 + +脚本自动验收(PM2 7 进程 + 页面可访问),并提示: + +- 登录账号: **admin** +- 登录密码: **admin123** +- 浏览器配置: 各所 **env 配置**(API,风控) + 中控 **系统设置** + +**无需 SSH 编辑 `.env` 填 API**;密钥由 `bootstrap_deploy_secrets.py` 自动生成. + +| 地址 | 端口 | +|------|------| +| 中控 | 5100 | +| Binance | 5001 | +| Gate | 5000 | +| OKX | 5004 | + +agent 默认 `127.0.0.1:15200/15201/15202`,由代码内置,一般无需改. + +--- + +## 脚本结构 + +``` +deploy/ +├── manage.sh # 入口(自举 + 菜单) +├── lib/ +│ ├── common.sh # 公共函数,验收 +│ ├── install.sh # 一键部署 +│ ├── uninstall.sh # 一键卸载 +│ └── update.sh # 更新子菜单 +├── setup_env.sh # venv + 依赖(被 install 调用) +├── pm2_start_all.sh # 启动 7 进程 +├── pull_and_restart.sh # 快速更新(被 update 调用) +└── reinstall.sh # 深度重装(被 update 调用) +``` + +--- + +## 前置条件 + +- **Ubuntu 22.04 / 24.04**,用户 **root** +- 能 `git clone` 仓库到 `/opt/crypto_monitor_user` + +--- + +## 分步安装(仍可用) + +若不使用 `manage.sh`,可手动: + +```bash +cd /opt +git clone https://git.bz121.com/dekun/crypto_monitor_user.git crypto_monitor_user +cd /opt/crypto_monitor_user +bash deploy/setup_env.sh --install-system-deps +bash deploy/pm2_start_all.sh +pm2 save && pm2 startup +``` + +`setup_env.sh` 常用参数: + +```bash +bash deploy/setup_env.sh --only binance,gate # 仅部分子项目 +bash deploy/setup_env.sh --recreate-venv # 重建虚拟环境 +bash deploy/setup_env.sh --skip-pm2 # 不尝试安装 pm2 +bash deploy/setup_env.sh --skip-env-copy # 不复制 .env.example +``` + +**整目录重装**(保留 `.env`,清库)见 **[reinstall-plan-b.md](./reinstall-plan-b.md)**: + +```bash +bash deploy/reinstall.sh --yes +``` + +若在其它环境编辑过脚本后报 `pipefail` 错误,先转 LF: + +```bash +sed -i 's/\r$//' deploy/manage.sh deploy/lib/*.sh +``` + +--- + +## setup_env.sh 会做什么 + +| 步骤 | 说明 | +|------|------| +| 检查 Python | 需要 **3.10+** | +| `crypto_monitor_*` | 各目录 `.venv` + `pip install -r ../requirements.txt` | +| `manual_trading_hub` | 独立 `requirements.txt` | +| `.env` | 不存在则从 `.env.example` 复制 | +| 部署密钥 | `python3 scripts/bootstrap_deploy_secrets.py`(不覆盖已有值) | +| 目录 | `static/images`,`static/images/order_charts` | +| PM2 | 已装 Node 时 `npm install -g pm2` | + +--- + +## 环境变量(可选) + +```bash +INSTALL_ROOT=/opt/crypto_monitor_user +GIT_URL=https://git.bz121.com/dekun/crypto_monitor_user.git +GIT_BRANCH=main +BACKUP_ROOT=/root/backups +``` + +--- + +## 依赖说明 + +- 三个监控子项目共用根目录 **[requirements.txt](../requirements.txt)**. +- 走 SOCKS 须 **PySocks**(已包含在 requirements 中). diff --git a/deploy/lib/common.sh b/deploy/lib/common.sh new file mode 100644 index 0000000..8012bce --- /dev/null +++ b/deploy/lib/common.sh @@ -0,0 +1,509 @@ +#!/usr/bin/env bash +# deploy/lib/common.sh — 部署管理器公共函数 +set -e +set -u +if [ -n "${BASH_VERSION:-}" ]; then + set -o pipefail +fi + +INSTALL_ROOT="${INSTALL_ROOT:-/opt/crypto_monitor_user}" +GIT_URL="${GIT_URL:-https://git.bz121.com/dekun/crypto_monitor_user.git}" +GIT_BRANCH="${GIT_BRANCH:-main}" +BACKUP_ROOT="${BACKUP_ROOT:-/root/backups}" +TZ_NAME="${CM_TZ:-Asia/Shanghai}" +NODE_MAJOR="${NODE_MAJOR:-20}" + +LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEPLOY_DIR="$(cd "${LIB_DIR}/.." && pwd)" +REPO_ROOT="$(cd "${DEPLOY_DIR}/.." && pwd)" + +PM2_APPS=( + crypto_binance + crypto_gate + crypto_okx + manual-trading-hub + manual-agent-binance + manual-agent-okx + manual-agent-gate +) + +# 历史进程名(仅 stop/delete 时兼容,不计入验收) +PM2_APPS_LEGACY=( + crypto-monitor-binance + crypto-monitor-gate + crypto-monitor-okx +) + +CONFIG_PATHS=( + crypto_monitor_binance/.env + crypto_monitor_okx/.env + crypto_monitor_gate/.env + manual_trading_hub/.env + manual_trading_hub/hub_settings.json +) + +INSTANCE_DIRS=( + crypto_monitor_binance + crypto_monitor_gate + crypto_monitor_okx + manual_trading_hub +) + +log() { printf '[%s] %s\n' "$(TZ="${TZ_NAME}" date '+%Y-%m-%d %H:%M:%S')" "$*"; } +step() { echo ""; log "==> $*"; } + +die() { + echo "错误: $*" >&2 + exit 1 +} + +require_root() { + if [[ "$(id -u)" -ne 0 ]]; then + die "请使用 root 执行(推荐: sudo -i 后运行)" + fi +} + +require_ubuntu() { + if [[ ! -f /etc/os-release ]]; then + log "警告: 未检测到 /etc/os-release,跳过 Ubuntu 版本检查" + return 0 + fi + # shellcheck source=/dev/null + source /etc/os-release + if [[ "${ID:-}" != "ubuntu" ]]; then + log "警告: 当前系统为 ${ID:-unknown},官方仅测试 Ubuntu 22.04/24.04" + return 0 + fi + local ver="${VERSION_ID:-}" + if [[ "${ver}" != "22.04" && "${ver}" != "24.04" ]]; then + log "警告: Ubuntu ${ver} 未在文档中明确测试,继续执行" + fi +} + +detect_server_ip() { + local ip="" + if command -v hostname >/dev/null 2>&1; then + ip="$(hostname -I 2>/dev/null | awk '{print $1}')" + fi + if [[ -z "${ip}" ]]; then + ip="127.0.0.1" + fi + echo "${ip}" +} + +confirm_yes() { + local msg="$1" + local ans="" + cm_read ans "${msg} [y/N] " + [[ "${ans}" == [yY] || "${ans}" == [yY][eE][sS] ]] +} + +confirm_uninstall() { + local ans="" + echo "此操作将停止全部 PM2 进程并移走安装目录." + cm_read ans "输入 UNINSTALL 确认卸载: " + [[ "${ans}" == "UNINSTALL" ]] +} + +# curl | bash 时 stdin 是管道,须从 /dev/tty 读取用户输入 +cm_read() { + local __var="$1" + local __prompt="$2" + local __val="" + if [[ -r /dev/tty ]]; then + IFS= read -r -p "${__prompt}" __val /dev/null 2>&1; then + die "未检测到 apt-get,请手动安装 python3-venv git curl" + fi + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y python3 python3-pip python3-venv curl git ca-certificates + local pyver="" + if command -v python3 >/dev/null 2>&1; then + pyver="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" + apt-get install -y "python${pyver}-venv" 2>/dev/null || apt-get install -y python3-venv + fi +} + +install_node_pm2() { + step "检查 Node.js 与 PM2" + if command -v pm2 >/dev/null 2>&1; then + log "PM2 已安装: $(pm2 -v)" + return 0 + fi + if ! command -v node >/dev/null 2>&1; then + log "安装 Node.js ${NODE_MAJOR}.x ..." + curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | bash - + apt-get install -y nodejs + fi + log "安装 PM2 ..." + npm install -g pm2 + log "PM2: $(pm2 -v)" +} + +# pip 安装时显示进度条(避免 -q 静默导致用户以为卡死) +# pip>=26 已移除 ascii,仅支持 auto|on|off|raw +pip_progress_bar_arg() { + echo "on" +} + +pip_upgrade_tools() { + local pip_bin="$1" + local bar + bar="$(pip_progress_bar_arg)" + echo " 升级 pip ..." + "${pip_bin}" install -U pip setuptools wheel \ + --disable-pip-version-check \ + --progress-bar "${bar}" +} + +pip_install_requirements() { + local pip_bin="$1" + local req_file="$2" + local label="${3:-依赖}" + local bar + bar="$(pip_progress_bar_arg)" + echo " 安装${label} (下方为 pip 进度) ..." + [[ -f "${req_file}" ]] || die "缺少依赖文件: ${req_file}" + "${pip_bin}" install -r "${req_file}" \ + --disable-pip-version-check \ + --retries 5 \ + --progress-bar "${bar}" + echo " ${label}安装完成" +} + +install_backup_cron_all() { + step "安装三所每日备份 cron" + local dir + for dir in crypto_monitor_binance crypto_monitor_gate crypto_monitor_okx; do + local inst="${REPO_ROOT}/${dir}/scripts/install_backup_cron.sh" + if [[ -f "${inst}" ]]; then + chmod +x "${inst}" + bash "${inst}" || log "警告: ${dir} cron 安装失败" + fi + done +} + +remove_backup_cron_all() { + step "移除三所备份 cron" + local tmp removed=0 + tmp="$(mktemp)" + if ! crontab -l 2>/dev/null >"${tmp}"; then + rm -f "${tmp}" + return 0 + fi + local filtered + filtered="$(grep -vF "backup_data.sh" "${tmp}" || true)" + if [[ "${filtered}" != "$(cat "${tmp}")" ]]; then + printf '%s\n' "${filtered}" | awk ' + BEGIN { tz = 0 } + /^CRON_TZ=Asia\/Shanghai$/ { + if (tz++) next + } + { print } + ' | crontab - + removed=1 + fi + rm -f "${tmp}" + if [[ "${removed}" -eq 1 ]]; then + log "已移除 backup_data.sh 相关 cron" + fi +} + +pm2_app_exists() { + local name="$1" + pm2 pid "${name}" >/dev/null 2>&1 +} + +# 仅操作本项目 PM2 进程,不影响服务器上其它应用 +pm2_stop_project_apps() { + if ! command -v pm2 >/dev/null 2>&1; then + log "未安装 pm2,跳过" + return 0 + fi + local name stopped=0 + for name in "${PM2_APPS[@]}" "${PM2_APPS_LEGACY[@]}"; do + if pm2_app_exists "${name}"; then + log "pm2 stop ${name}" + pm2 stop "${name}" 2>/dev/null || true + stopped=$((stopped + 1)) + fi + done + if [[ "${stopped}" -eq 0 ]]; then + log "未发现本项目 PM2 进程(其它 PM2 不受影响)" + fi +} + +pm2_delete_project_apps() { + if ! command -v pm2 >/dev/null 2>&1; then + return 0 + fi + local name deleted=0 + for name in "${PM2_APPS[@]}" "${PM2_APPS_LEGACY[@]}"; do + if pm2_app_exists "${name}"; then + log "pm2 delete ${name}" + pm2 delete "${name}" 2>/dev/null || true + deleted=$((deleted + 1)) + fi + done + if [[ "${deleted}" -gt 0 ]]; then + pm2 save 2>/dev/null || true + fi +} + +pm2_save_startup() { + step "PM2 save & startup" + pm2 save 2>/dev/null || true + if pm2 startup systemd -u root --hp /root 2>/dev/null | grep -q "sudo"; then + pm2 startup systemd -u root --hp /root 2>/dev/null | grep "^sudo" | bash || true + else + pm2 startup 2>/dev/null || true + fi +} + +pm2_count_online() { + local name online=0 + if ! command -v pm2 >/dev/null 2>&1; then + echo "0" + return 0 + fi + for name in "${PM2_APPS[@]}"; do + if pm2 pid "${name}" >/dev/null 2>&1; then + online=$((online + 1)) + fi + done + echo "${online}" +} + +is_deployed() { + local root="$1" + [[ -x "${root}/crypto_monitor_binance/.venv/bin/python" ]] \ + || [[ -x "${root}/crypto_monitor_gate/.venv/bin/python" ]] \ + || [[ -x "${root}/crypto_monitor_okx/.venv/bin/python" ]] \ + || [[ -x "${root}/manual_trading_hub/.venv/bin/python" ]] +} + +# 单所实例: key -> 目录 / PM2 名 / 端口 / 展示名 +exchange_dir() { + case "$1" in + okx) echo "crypto_monitor_okx" ;; + binance) echo "crypto_monitor_binance" ;; + gate) echo "crypto_monitor_gate" ;; + *) return 1 ;; + esac +} + +exchange_pm2_name() { + case "$1" in + okx) echo "crypto_okx" ;; + binance) echo "crypto_binance" ;; + gate) echo "crypto_gate" ;; + *) return 1 ;; + esac +} + +exchange_http_port() { + case "$1" in + okx) echo "5004" ;; + binance) echo "5001" ;; + gate) echo "5000" ;; + *) return 1 ;; + esac +} + +exchange_label() { + case "$1" in + okx) echo "OKX" ;; + binance) echo "Binance" ;; + gate) echo "Gate" ;; + *) echo "$1" ;; + esac +} + +normalize_exchange_key() { + local k + k="$(echo "${1:-}" | tr '[:upper:]' '[:lower:]' | xargs)" + case "${k}" in + okx|binance|gate) echo "${k}" ;; + *) return 1 ;; + esac +} + +check_http() { + local url="$1" + local code + code="$(curl -sS -o /dev/null -w '%{http_code}' --connect-timeout 5 "${url}" 2>/dev/null || echo "000")" + [[ "${code}" == "200" || "${code}" == "302" || "${code}" == "301" ]] +} + +verify_deployment() { + local ip="${1:-$(detect_server_ip)}" + local mode="${2:-all}" + local ok=1 + local online total + local ex_key="" + + step "部署验收" + if [[ "${mode}" == "all" || -z "${mode}" ]]; then + total="${#PM2_APPS[@]}" + online="$(pm2_count_online)" + if [[ "${online}" -eq "${total}" ]]; then + echo " [✓] PM2 进程 ${online}/${total} online" + else + echo " [✗] PM2 进程 ${online}/${total} online" + ok=0 + pm2 list 2>/dev/null || true + fi + local checks=( + "中控:http://127.0.0.1:5100/" + "Binance:http://127.0.0.1:5001/" + "Gate:http://127.0.0.1:5000/" + "OKX:http://127.0.0.1:5004/" + ) + local item label url + for item in "${checks[@]}"; do + label="${item%%:*}" + url="${item#*:}" + if check_http "${url}"; then + echo " [✓] ${label} 可访问" + else + echo " [✗] ${label} 不可访问 (${url})" + ok=0 + fi + done + else + if ! ex_key="$(normalize_exchange_key "${mode}")"; then + echo " [✗] 未知验收模式: ${mode}" + return 1 + fi + local pm2_name port label + pm2_name="$(exchange_pm2_name "${ex_key}")" + port="$(exchange_http_port "${ex_key}")" + label="$(exchange_label "${ex_key}")" + if pm2_app_exists "${pm2_name}"; then + echo " [✓] PM2 ${pm2_name} 已注册" + else + echo " [✗] PM2 缺少进程 ${pm2_name}" + ok=0 + pm2 list 2>/dev/null || true + fi + if check_http "http://127.0.0.1:${port}/"; then + echo " [✓] ${label} 可访问 (http://127.0.0.1:${port}/)" + else + echo " [✗] ${label} 不可访问 (http://127.0.0.1:${port}/)" + ok=0 + fi + fi + + if [[ "${ok}" -eq 1 ]]; then + echo "" + log "验收通过: 进程都在 + 页面可打开" + return 0 + fi + echo "" + log "验收未完全通过,可执行 pm2 logs <进程名> --lines 30 排查" + return 1 +} + +print_post_install_guide() { + local ip="${1:-$(detect_server_ip)}" + cat <&2 + exit 1 + ;; + esac +done + +install_fresh() { + step "克隆仓库" + if [[ -d "${INSTALL_ROOT}" ]]; then + die "目录已存在: ${INSTALL_ROOT},请选修复环境或深度重装" + fi + mkdir -p "$(dirname "${INSTALL_ROOT}")" + git clone -b "${GIT_BRANCH}" "${GIT_URL}" "${INSTALL_ROOT}" +} + +install_repair() { + step "修复环境(保留数据与配置)" + bash "${REPO_ROOT}/deploy/setup_env.sh" --install-system-deps +} + +install_deep() { + step "深度重装(保留 .env,清库)" + bash "${REPO_ROOT}/deploy/reinstall.sh" --yes + verify_deployment "$(detect_server_ip)" || true + print_post_install_guide "$(detect_server_ip)" +} + +run_install_pipeline() { + step "环境部署 setup_env.sh" + bash "${REPO_ROOT}/deploy/setup_env.sh" --install-system-deps + + step "启动 PM2 全部进程" + bash "${REPO_ROOT}/deploy/pm2_start_all.sh" + + pm2_save_startup + install_backup_cron_all + + verify_deployment "$(detect_server_ip)" || true + print_post_install_guide "$(detect_server_ip)" +} + +run_instance_pipeline() { + local ex_key="$1" + local dir_name + dir_name="$(exchange_dir "${ex_key}")" + + step "单所环境部署 setup_env.sh --only ${ex_key}(不含中控)" + bash "${REPO_ROOT}/deploy/setup_env.sh" --only "${ex_key}" --install-system-deps + + step "启动 PM2 仅 ${dir_name}" + bash "${REPO_ROOT}/deploy/pm2_start_all.sh" --only "${ex_key}" + + pm2_save_startup + local cron_script="${REPO_ROOT}/${dir_name}/scripts/install_backup_cron.sh" + if [[ -x "${cron_script}" ]]; then + step "安装 ${ex_key} 备份 cron" + bash "${cron_script}" || true + fi + + verify_deployment "$(detect_server_ip)" "${ex_key}" || true + print_post_install_guide_instance "${ex_key}" "$(detect_server_ip)" +} + +handle_existing_install() { + echo "" + echo "检测到已部署安装: ${INSTALL_ROOT}" + echo " a) 取消" + echo " b) 修复环境(重建 venv,保留 .env 与数据库)" + echo " c) 深度重装(保留 .env,清库,见 reinstall.sh)" + local choice="" + cm_read choice "请选择 [a/b/c]: " + case "${choice}" in + b|B) + install_repair + if command -v pm2 >/dev/null 2>&1; then + bash "${REPO_ROOT}/deploy/pm2_start_all.sh" 2>/dev/null || pm2 restart all 2>/dev/null || true + pm2_save_startup + fi + verify_deployment "$(detect_server_ip)" || true + print_post_install_guide "$(detect_server_ip)" + ;; + c|C) + install_deep + ;; + *) + log "已取消" + ;; + esac +} + +ensure_repo_ready() { + if repo_ready "${INSTALL_ROOT}"; then + REPO_ROOT="${INSTALL_ROOT}" + elif [[ -n "${REPO_ROOT:-}" ]] && repo_ready "${REPO_ROOT}"; then + : + else + REPO_ROOT="" + fi + + if [[ -z "${REPO_ROOT}" ]]; then + install_system_packages + install_node_pm2 + install_fresh + REPO_ROOT="${INSTALL_ROOT}" + else + install_system_packages + install_node_pm2 + fi +} + +main_install_instance() { + local ex_key="$1" + require_root + require_ubuntu + ensure_repo_ready + run_instance_pipeline "${ex_key}" +} + +main_install() { + require_root + require_ubuntu + + if [[ -n "${EXCHANGE}" ]]; then + local ex_key="" + if ! ex_key="$(normalize_exchange_key "${EXCHANGE}")"; then + die "无效 --exchange: ${EXCHANGE} (期望 okx|binance|gate)" + fi + main_install_instance "${ex_key}" + return 0 + fi + + if repo_ready "${INSTALL_ROOT}"; then + REPO_ROOT="${INSTALL_ROOT}" + elif repo_ready "${REPO_ROOT}"; then + : + else + REPO_ROOT="" + fi + + if [[ -n "${REPO_ROOT}" ]] && is_deployed "${REPO_ROOT}"; then + handle_existing_install + return 0 + fi + + ensure_repo_ready + run_install_pipeline +} + +main_install "$@" diff --git a/deploy/lib/uninstall.sh b/deploy/lib/uninstall.sh new file mode 100644 index 0000000..807d2e9 --- /dev/null +++ b/deploy/lib/uninstall.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# deploy/lib/uninstall.sh — 一键卸载 +set -e +set -u +if [ -n "${BASH_VERSION:-}" ]; then + set -o pipefail +fi + +LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=common.sh +source "${LIB_DIR}/common.sh" + +main_uninstall() { + require_root + + local root="" + if ! root="$(resolve_repo_root)"; then + if [[ -d "${INSTALL_ROOT}" ]]; then + root="${INSTALL_ROOT}" + else + die "未找到安装目录 ${INSTALL_ROOT}" + fi + fi + REPO_ROOT="${root}" + + if ! confirm_uninstall; then + log "已取消卸载" + return 0 + fi + + local stamp backup_dir removed_dir + stamp="$(TZ="${TZ_NAME}" date +%Y%m%d-%H%M%S)" + backup_dir="${BACKUP_ROOT}/pre-uninstall-${stamp}" + removed_dir="${INSTALL_ROOT}.removed.${stamp}" + + step "备份配置到 ${backup_dir}" + backup_configs_to "${REPO_ROOT}" "${backup_dir}" + { + echo "created_at=${stamp}" + echo "install_root=${INSTALL_ROOT}" + echo "removed_dir=${removed_dir}" + } >"${backup_dir}/uninstall.manifest" + + step "停止并移除本项目 PM2 进程(不影响其它 PM2)" + pm2_stop_project_apps + pm2_delete_project_apps + + remove_backup_cron_all + + step "移走安装目录" + if [[ -d "${INSTALL_ROOT}" ]]; then + mv "${INSTALL_ROOT}" "${removed_dir}" + log "已移动: ${INSTALL_ROOT} -> ${removed_dir}" + else + log "安装目录不存在,跳过" + fi + + echo "" + echo "卸载完成." + echo " 配置备份: ${backup_dir}" + echo " 旧目录: ${removed_dir} (确认无误后可手动删除)" + echo "" + echo "回滚示例:" + echo " mv ${removed_dir} ${INSTALL_ROOT}" + echo " bash ${INSTALL_ROOT}/deploy/manage.sh # 选 1 修复环境" +} + +main_uninstall "$@" diff --git a/deploy/lib/update.sh b/deploy/lib/update.sh new file mode 100644 index 0000000..b2cc378 --- /dev/null +++ b/deploy/lib/update.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# deploy/lib/update.sh — 更新 +set -e +set -u +if [ -n "${BASH_VERSION:-}" ]; then + set -o pipefail +fi + +LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=common.sh +source "${LIB_DIR}/common.sh" + +require_installed() { + if ! repo_ready "${REPO_ROOT}"; then + die "未找到安装,请先执行「1) 一键部署」" + fi +} + +update_quick() { + step "快速更新" + bash "${REPO_ROOT}/deploy/pull_and_restart.sh" + verify_deployment "$(detect_server_ip)" || true +} + +update_deps() { + step "依赖更新" + local dir + for dir in crypto_monitor_binance crypto_monitor_gate crypto_monitor_okx; do + local proj="${REPO_ROOT}/${dir}" + if [[ -x "${proj}/.venv/bin/pip" ]]; then + step "${dir}" + pip_install_requirements "${proj}/.venv/bin/pip" "${REPO_ROOT}/requirements.txt" "交易所共用依赖" + fi + done + local hub="${REPO_ROOT}/manual_trading_hub" + if [[ -x "${hub}/.venv/bin/pip" && -f "${hub}/requirements.txt" ]]; then + step "manual_trading_hub" + pip_install_requirements "${hub}/.venv/bin/pip" "${hub}/requirements.txt" "中控依赖" + fi + update_quick +} + +update_deep() { + step "深度重装" + if ! confirm_yes "深度重装将清库并保留 .env,确认继续?"; then + log "已取消" + return 0 + fi + bash "${REPO_ROOT}/deploy/reinstall.sh" --yes + verify_deployment "$(detect_server_ip)" || true +} + +show_update_menu() { + while true; do + echo "" + echo " 更新选项:" + echo " 3-1) 快速更新(git pull + pm2 restart)" + echo " 3-2) 依赖更新(含 pip install)" + echo " 3-3) 深度重装(保留 .env,清库)" + echo " 0) 返回主菜单" + local choice="" + cm_read choice "请选择 [0/3-1/3-2/3-3]: " + case "${choice}" in + 3-1|31|1) update_quick; break ;; + 3-2|32|2) update_deps; break ;; + 3-3|33|3) update_deep; break ;; + 0) break ;; + *) echo "无效选项" ;; + esac + done +} + +main_update() { + require_root + if ! REPO_ROOT="$(resolve_repo_root)"; then + die "未找到安装目录 ${INSTALL_ROOT},请先执行「1) 一键部署」" + fi + require_installed + show_update_menu +} + +main_update "$@" diff --git a/deploy/manage.sh b/deploy/manage.sh new file mode 100644 index 0000000..fe68fa6 --- /dev/null +++ b/deploy/manage.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# crypto_monitor_user 部署管理器 — 一键部署 / 卸载 / 更新 +# +# 新服务器(免克隆): +# curl -fsSL https://git.bz121.com/dekun/crypto_monitor_user/raw/branch/main/deploy/manage.sh | bash +# +# 已安装: +# bash /opt/crypto_monitor_user/deploy/manage.sh +# +set -e +if [ -n "${BASH_VERSION:-}" ]; then + set -o pipefail +fi + +INSTALL_ROOT="${INSTALL_ROOT:-/opt/crypto_monitor_user}" +GIT_URL="${GIT_URL:-https://git.bz121.com/dekun/crypto_monitor_user.git}" +GIT_BRANCH="${GIT_BRANCH:-main}" + +# curl | bash 时脚本从 stdin 执行,BASH_SOURCE[0] 为空;须先安全探测再 set -u +_script_src="${BASH_SOURCE[0]:-}" +if [[ -n "${_script_src}" && -f "${_script_src}" ]]; then + DEPLOY_DIR="$(cd "$(dirname "${_script_src}")" && pwd)" + REPO_ROOT="$(cd "${DEPLOY_DIR}/.." && pwd)" + LIB_DIR="${DEPLOY_DIR}/lib" +else + DEPLOY_DIR="" + REPO_ROOT="" + LIB_DIR="" +fi +unset _script_src + +set -u + +repo_ready() { + [[ -f "${1}/deploy/setup_env.sh" && -f "${1}/deploy/manage.sh" ]] +} + +sync_repo_if_present() { + local root="$1" + if [[ -d "${root}/.git" ]] && command -v git >/dev/null 2>&1; then + git -C "${root}" pull -q --ff-only 2>/dev/null || true + fi +} + +bootstrap_repo() { + if repo_ready "${INSTALL_ROOT}"; then + REPO_ROOT="${INSTALL_ROOT}" + DEPLOY_DIR="${REPO_ROOT}/deploy" + LIB_DIR="${DEPLOY_DIR}/lib" + sync_repo_if_present "${REPO_ROOT}" + return 0 + fi + if [[ -n "${REPO_ROOT}" ]] && repo_ready "${REPO_ROOT}"; then + DEPLOY_DIR="${REPO_ROOT}/deploy" + LIB_DIR="${DEPLOY_DIR}/lib" + return 0 + fi + + echo "crypto_monitor_user 部署管理器 — 首次自举" + echo "将克隆到: ${INSTALL_ROOT}" + if [[ "$(id -u)" -ne 0 ]]; then + echo "错误: 请使用 root 执行" >&2 + exit 1 + fi + if ! command -v git >/dev/null 2>&1; then + if command -v apt-get >/dev/null 2>&1; then + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y git ca-certificates curl + else + echo "错误: 未找到 git" >&2 + exit 1 + fi + fi + if [[ -d "${INSTALL_ROOT}" ]]; then + echo "错误: ${INSTALL_ROOT} 已存在但不是有效仓库" >&2 + echo "请手动处理后再运行,或设置 INSTALL_ROOT 指向其它路径" >&2 + exit 1 + fi + mkdir -p "$(dirname "${INSTALL_ROOT}")" + git clone -b "${GIT_BRANCH}" "${GIT_URL}" "${INSTALL_ROOT}" + # clone 后用仓库内脚本 + 终端 stdin(管道已 EOF) + exec bash "${INSTALL_ROOT}/deploy/manage.sh" "$@" "$tmp" || true + if grep -q 'proxy_http_version' "$tmp"; then + awk -v t="$TIMEOUT_SEC" ' + {print} + /proxy_http_version/ && !done { + print " proxy_connect_timeout " t "s;" + print " proxy_send_timeout " t "s;" + print " proxy_read_timeout " t "s;" + done=1 + } + ' "$tmp" >"$conf" + else + # 兜底:插到 proxy_pass 后 + awk -v t="$TIMEOUT_SEC" ' + {print} + /proxy_pass/ && !done { + print " proxy_connect_timeout " t "s;" + print " proxy_send_timeout " t "s;" + print " proxy_read_timeout " t "s;" + done=1 + } + ' "$tmp" >"$conf" + fi + rm -f "$tmp" + echo "patched: $conf -> ${TIMEOUT_SEC}s" + patched=$((patched + 1)) + done +done + +# 同步抬高全局默认,防止其它 location 仍用 60s +GLOBAL_PROXY="${GLOBAL_PROXY:-/www/server/nginx/conf/proxy.conf}" +if [[ -f "$GLOBAL_PROXY" ]]; then + if grep -qE "proxy_read_timeout[[:space:]]+${TIMEOUT_SEC}" "$GLOBAL_PROXY"; then + echo "ok (unchanged): $GLOBAL_PROXY" + else + cp -a "$GLOBAL_PROXY" "${GLOBAL_PROXY}.bak.ai_timeout" + sed -i -E \ + -e "s/proxy_connect_timeout[[:space:]]+[0-9]+;/proxy_connect_timeout ${TIMEOUT_SEC};/" \ + -e "s/proxy_read_timeout[[:space:]]+[0-9]+;/proxy_read_timeout ${TIMEOUT_SEC};/" \ + -e "s/proxy_send_timeout[[:space:]]+[0-9]+;/proxy_send_timeout ${TIMEOUT_SEC};/" \ + "$GLOBAL_PROXY" + echo "patched: $GLOBAL_PROXY -> ${TIMEOUT_SEC}s" + patched=$((patched + 1)) + fi +fi + +if [[ "$patched" -eq 0 ]]; then + echo "done (nothing to patch)" + exit 0 +fi + +if command -v nginx >/dev/null 2>&1; then + nginx -t + # 宝塔常用 reload + if [[ -x /etc/init.d/nginx ]]; then + /etc/init.d/nginx reload + else + nginx -s reload + fi + echo "nginx reloaded" +else + echo "warn: nginx binary not found; configs patched but not reloaded" +fi +echo "done" diff --git a/deploy/pm2_log_policy.sh b/deploy/pm2_log_policy.sh new file mode 100644 index 0000000..6539dd8 --- /dev/null +++ b/deploy/pm2_log_policy.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# PM2 日志策略:关灌屏后的兜底轮转 — 按天保留 3 天,单文件过大也切分. +# 用法(root): +# bash deploy/pm2_log_policy.sh +# bash deploy/pm2_log_policy.sh --clean-only +set -euo pipefail + +PM2_LOG_DIR="${PM2_LOG_DIR:-/root/.pm2/logs}" +LOGROTATE_CONF="${LOGROTATE_CONF:-/etc/logrotate.d/crypto_monitor_user_pm2}" +RETENTION_DAYS="${RETENTION_DAYS:-3}" +MAXSIZE="${MAXSIZE:-20M}" +CLEAN_ONLY=0 +if [[ "${1:-}" == "--clean-only" ]]; then + CLEAN_ONLY=1 +fi + +echo ">>> PM2 log dir: ${PM2_LOG_DIR}" +if [[ ! -d "${PM2_LOG_DIR}" ]]; then + echo "目录不存在,跳过" + exit 0 +fi + +echo ">>> truncate oversized active logs (>20MB) and drop stale files older than ${RETENTION_DAYS}d" +# 当前正在写的大文件用 truncate 清空内容(copytruncate/空写兼容 PM2 仍持有 fd) +find "${PM2_LOG_DIR}" -type f -name '*.log' -size +20M -print -exec truncate -s 0 {} \; +# 过期归档/旧编号日志直接删 +find "${PM2_LOG_DIR}" -type f \( -name '*.log' -o -name '*.log.gz' -o -name '*.log.[0-9]*' \) -mtime +"${RETENTION_DAYS}" -print -delete || true + +if [[ "${CLEAN_ONLY}" -eq 1 ]]; then + echo ">>> --clean-only done" + du -sh "${PM2_LOG_DIR}" || true + exit 0 +fi + +echo ">>> install logrotate: ${LOGROTATE_CONF} (daily, rotate ${RETENTION_DAYS}, maxsize ${MAXSIZE})" +cat > "${LOGROTATE_CONF}" </dev/null 2>&1; then + logrotate -f "${LOGROTATE_CONF}" || true +else + echo "warn: logrotate 未安装,仅完成清理;请 apt install logrotate" +fi + +echo ">>> size after" +du -sh "${PM2_LOG_DIR}" || true +ls -lah "${PM2_LOG_DIR}" | head -30 || true +echo "done" diff --git a/deploy/pm2_start_all.sh b/deploy/pm2_start_all.sh new file mode 100644 index 0000000..4e60ce7 --- /dev/null +++ b/deploy/pm2_start_all.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# 按推荐顺序启动三所 Flask + 中控 hub/三 agent(PM2). +# 用法(仓库根或任意目录): +# bash deploy/pm2_start_all.sh +# bash deploy/pm2_start_all.sh --only okx +# bash deploy/pm2_start_all.sh --only binance +# bash deploy/pm2_start_all.sh --only gate +# +# 与 deploy/setup_env.sh 独立:setup_env 只建 venv;本脚本负责 PM2 启动. +set -e +set -u +if [ -n "${BASH_VERSION:-}" ]; then + set -o pipefail +fi + +DEPLOY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${DEPLOY_DIR}/.." && pwd)" +# shellcheck source=lib/common.sh +source "${DEPLOY_DIR}/lib/common.sh" + +ONLY="all" +while [[ $# -gt 0 ]]; do + case "$1" in + --only) + ONLY="${2:-all}" + shift 2 + ;; + -h|--help) + sed -n '2,10p' "$0" | sed 's/^# \?//' + exit 0 + ;; + *) + echo "未知参数: $1" >&2 + exit 1 + ;; + esac +done + +start_one() { + local dir_name="$1" + local proj="${REPO_ROOT}/${dir_name}" + local eco="${proj}/ecosystem.config.cjs" + if [[ ! -f "${eco}" ]]; then + echo "skip (no ecosystem): ${dir_name}" >&2 + return 0 + fi + echo "==> pm2 start ${dir_name}" + # 已存在则 restart,避免重复 start 导致 set -e 退出 + if (cd "${proj}" && pm2 start ecosystem.config.cjs); then + return 0 + fi + echo " (已存在,改为 restart)" + (cd "${proj}" && pm2 startOrReload ecosystem.config.cjs --update-env) \ + || (cd "${proj}" && pm2 reload ecosystem.config.cjs --update-env) \ + || true +} + +if ! command -v pm2 >/dev/null 2>&1; then + echo "未找到 pm2,请先安装 Node.js 与 pm2(见 docs/ubuntu-server.md)" >&2 + exit 1 +fi + +if [[ "${ONLY}" == "all" ]]; then + start_one crypto_monitor_binance + start_one crypto_monitor_gate + start_one crypto_monitor_okx + start_one manual_trading_hub +else + ex_key="$(normalize_exchange_key "${ONLY}" || true)" + if [[ -z "${ex_key}" ]]; then + echo "未知 --only 值: ${ONLY} (期望 okx|binance|gate|all)" >&2 + exit 1 + fi + dir_name="$(exchange_dir "${ex_key}")" + start_one "${dir_name}" +fi + +pm2 save 2>/dev/null || true +echo "" +echo "PM2 进程:" +pm2 list diff --git a/deploy/pull_and_restart.sh b/deploy/pull_and_restart.sh new file mode 100644 index 0000000..bc8ca5c --- /dev/null +++ b/deploy/pull_and_restart.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# 服务器上拉代码,同步 env,应用强制清仓策略并重启 PM2. +# 用法(/opt/crypto_monitor_user 下 root): +# bash deploy/pull_and_restart.sh +# bash deploy/pull_and_restart.sh --dry-run +set -euo pipefail + +REPO="${REPO:-/opt/crypto_monitor_user}" +DRY=() +if [[ "${1:-}" == "--dry-run" ]]; then + DRY=(--dry-run) + echo "(dry-run mode)" +fi + +cd "$REPO" +echo ">>> git pull" +git pull + +echo ">>> sync common trading env (binance + okx missing keys)" +python3 scripts/sync_common_trading_env.py "${DRY[@]}" + +echo ">>> force-close defaults only if missing (never overwrite manual)" +python3 scripts/sync_common_trading_env.py --apply-force-close-policy "${DRY[@]}" + +if [[ ${#DRY[@]} -gt 0 ]]; then + echo "(dry-run, skip pm2 log policy + restart)" + exit 0 +fi + +echo ">>> pm2 log policy (silence leftover + 3-day retain)" +sed -i 's/\r$//' deploy/pm2_log_policy.sh 2>/dev/null || true +bash deploy/pm2_log_policy.sh + +echo ">>> nginx AI review proxy timeouts (avoid 504 on /ai_*_review)" +sed -i 's/\r$//' deploy/nginx_ai_review_timeouts.sh 2>/dev/null || true +bash deploy/nginx_ai_review_timeouts.sh || echo "warn: nginx timeout patch skipped" + +echo ">>> pm2 restart --update-env" +# --update-env:避免 PM2 dump 里残留的跨实例环境变量(如 EXCHANGE_DISPLAY_NAME)继续污染 +pm2 restart crypto_gate crypto_binance crypto_okx manual-trading-hub manual-agent-gate --update-env 2>/dev/null \ + || pm2 restart crypto-monitor-gate crypto-monitor-binance crypto-monitor-okx manual-trading-hub --update-env 2>/dev/null \ + || pm2 restart all --update-env + +echo ">>> FORCE_CLOSE settings" +grep -E '^FORCE_CLOSE_' crypto_monitor_gate/.env crypto_monitor_binance/.env crypto_monitor_okx/.env || true + +echo "done" diff --git a/deploy/reinstall-plan-b.md b/deploy/reinstall-plan-b.md new file mode 100644 index 0000000..6275752 --- /dev/null +++ b/deploy/reinstall-plan-b.md @@ -0,0 +1,112 @@ +# Plan B:整目录重装(生产清库) + +适用于:**保留三所 `.env` 与中控配置,丢弃旧代码,旧 SQLite,脏 PM2 名单**(例如移除 `gate_bot` 后偶发重启). + +与 **[setup_env.sh](./setup_env.sh)** 的关系: + +| 脚本 | 用途 | +|------|------| +| `setup_env.sh` | **首次安装 / 日常**:建 venv,装依赖,从 `.env.example` 复制(**不变**) | +| `reinstall.sh` | **整目录重装**:备份 → 移走旧目录 → `git clone` → 调 `setup_env.sh` → 恢复配置 → PM2 | + +--- + +## 一键执行(推荐) + +在现有服务器安装上以 **root** 执行: + +```bash +cd /opt/crypto_monitor_user +bash deploy/reinstall.sh --yes +``` + +交互确认(不加 `--yes`): + +```bash +bash deploy/reinstall.sh +``` + +仅预览步骤: + +```bash +bash deploy/reinstall.sh --dry-run +``` + +--- + +## 脚本会做什么 + +1. 备份到 **`/root/backups/pre-reinstall-YYYYMMDD-HHMMSS/`** + - 三所 `crypto_monitor_*/.env` + - `manual_trading_hub/.env` + - `manual_trading_hub/hub_settings.json`(若有) + - 可选:仓库内 `one_shot` 备份目录 +2. **`pm2 stop all` + `pm2 delete all`** +3. **`mv /opt/crypto_monitor_user /opt/crypto_monitor_user.old.时间戳`** +4. **`git clone`** 到 `/opt/crypto_monitor_user`(默认 `main`) +5. **`bash deploy/setup_env.sh --skip-env-copy --recreate-venv --skip-pm2`** +6. 从备份 **恢复 `.env` / `hub_settings.json`** +7. **`deploy/sanitize_hub_settings.py`** 去掉 `gate_bot` / 第四账户 +8. **`deploy/pm2_start_all.sh`** + `pm2 save` +9. 为三所重装 **每日 0 点备份 cron**(可用 `--no-backup-cron` 跳过) + +**不会备份/恢复**:`crypto.db`,hub `data/*.db`,`static/images`(符合「全新启动」). + +**不会动**:宝塔/Nginx 反代,SSH SOCKS 隧道(tmux 内). + +--- + +## 环境变量 + +```bash +export INSTALL_ROOT=/opt/crypto_monitor_user +export GIT_URL=https://git.bz121.com/dekun/crypto_monitor_user.git +export GIT_BRANCH=main +export BACKUP_ROOT=/root/backups +bash deploy/reinstall.sh --yes +``` + +--- + +## 验收 + +```bash +pm2 list +# 应有 7 个: crypto_binance crypto_gate crypto_okx manual-trading-hub manual-agent-* + +curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:5100/ +``` + +浏览器:中控 `/monitor` 登录,三所 LINK 绿,监控区为空库. + +--- + +## 回滚 + +旧目录默认保留为 `/opt/crypto_monitor_user.old.时间戳`,配置在 `/root/backups/pre-reinstall-*`: + +```bash +pm2 delete all +rm -rf /opt/crypto_monitor_user +mv /opt/crypto_monitor_user.old.XXXXXXXX /opt/crypto_monitor_user +bash /opt/crypto_monitor_user/deploy/pm2_start_all.sh +``` + +确认新环境稳定后再删 `.old.*` 目录. + +--- + +## 辅助脚本 + +| 文件 | 说明 | +|------|------| +| [pm2_start_all.sh](./pm2_start_all.sh) | 按顺序 PM2 启动三所 + hub(setup_env 之后手动用) | +| [sanitize_hub_settings.py](./sanitize_hub_settings.py) | 清理 `hub_settings.json` 中 gate_bot 条目 | + +--- + +## 相关文档 + +- [deploy/README.md](./README.md) — 首次一键安装 +- [docs/ubuntu-server.md](../docs/ubuntu-server.md) — Python / PM2 版本 +- [备份与恢复.md](../备份与恢复.md) — 日常 DB 备份 cron diff --git a/deploy/reinstall.sh b/deploy/reinstall.sh new file mode 100644 index 0000000..e86cce0 --- /dev/null +++ b/deploy/reinstall.sh @@ -0,0 +1,318 @@ +#!/usr/bin/env bash +# Plan B:整目录重装 /opt/crypto_monitor_user(备份 .env → 移走旧目录 → git clone → setup_env → 恢复配置 → PM2) +# +# 与 deploy/setup_env.sh 分工: +# setup_env.sh — 首次 / 日常:建 venv,装依赖,复制 .env.example(一键安装,不变) +# reinstall.sh — 生产清库重装:保留密钥与 hub 配置,丢弃旧代码/旧库/脏 PM2 +# +# 用法(在现有安装目录以 root 执行): +# cd /opt/crypto_monitor_user +# bash deploy/reinstall.sh # 交互确认 +# bash deploy/reinstall.sh --yes # 跳过确认 +# bash deploy/reinstall.sh --dry-run # 仅打印步骤 +# +# 可选环境变量: +# INSTALL_ROOT=/opt/crypto_monitor_user +# GIT_URL=https://git.bz121.com/dekun/crypto_monitor_user.git +# GIT_BRANCH=main +# BACKUP_ROOT=/root/backups +# +set -e +set -u +if [ -n "${BASH_VERSION:-}" ]; then + set -o pipefail +fi + +DEPLOY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT_SOURCE="${DEPLOY_DIR}/reinstall.sh" +REPO_ROOT="$(cd "${DEPLOY_DIR}/.." && pwd)" +# shellcheck source=lib/common.sh +source "${DEPLOY_DIR}/lib/common.sh" + +INSTALL_ROOT="${INSTALL_ROOT:-/opt/crypto_monitor_user}" +GIT_URL="${GIT_URL:-https://git.bz121.com/dekun/crypto_monitor_user.git}" +GIT_BRANCH="${GIT_BRANCH:-main}" +BACKUP_ROOT="${BACKUP_ROOT:-/root/backups}" +TZ_NAME="${REINSTALL_TZ:-Asia/Shanghai}" + +ASSUME_YES=0 +DRY_RUN=0 +INSTALL_BACKUP_CRON=1 + +CONFIG_PATHS=( + "crypto_monitor_binance/.env" + "crypto_monitor_okx/.env" + "crypto_monitor_gate/.env" + "manual_trading_hub/.env" + "manual_trading_hub/hub_settings.json" +) + +usage() { + sed -n '2,18p' "$0" | sed 's/^# \?//' + exit "${1:-0}" +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --yes|-y) ASSUME_YES=1; shift ;; + --dry-run) DRY_RUN=1; shift ;; + --no-backup-cron) INSTALL_BACKUP_CRON=0; shift ;; + -h|--help) usage 0 ;; + *) echo "未知参数: $1" >&2; usage 1 ;; + esac +done + +log() { printf '[%s] %s\n' "$(TZ="${TZ_NAME}" date '+%Y-%m-%d %H:%M:%S')" "$*"; } +step() { echo ""; log "==> $*"; } + +run() { + if [[ "${DRY_RUN}" -eq 1 ]]; then + log "[dry-run] $*" + return 0 + fi + log "+ $*" + "$@" +} + +confirm() { + if [[ "${ASSUME_YES}" -eq 1 || "${DRY_RUN}" -eq 1 ]]; then + return 0 + fi + local msg="$1" + read -r -p "${msg} [y/N] " ans + [[ "${ans}" == [yY] || "${ans}" == [yY][eE][sS] ]] +} + +resolve_path() { + local base="$1" + local rel="$2" + printf '%s/%s' "${base}" "${rel}" +} + +backup_configs() { + local src_root="$1" + local dest="$2" + mkdir -p "${dest}" + local rel copied=0 + for rel in "${CONFIG_PATHS[@]}"; do + local src + src="$(resolve_path "${src_root}" "${rel}")" + if [[ -f "${src}" ]]; then + mkdir -p "${dest}/$(dirname "${rel}")" + if [[ "${DRY_RUN}" -eq 1 ]]; then + log "[dry-run] backup ${src} -> ${dest}/${rel}" + else + cp -a "${src}" "${dest}/${rel}" + log "backup ${rel}" + fi + copied=$((copied + 1)) + else + log "skip (missing): ${rel}" + fi + done + if [[ "${copied}" -eq 0 ]]; then + echo "错误: 未备份到任何配置文件,请检查 ${src_root}" >&2 + exit 1 + fi + if [[ -f "${src_root}/scripts/one_shot_backup_config_before_cleanup.py" ]]; then + if [[ "${DRY_RUN}" -eq 1 ]]; then + log "[dry-run] python3 scripts/one_shot_backup_config_before_cleanup.py (in ${src_root})" + else + (cd "${src_root}" && python3 scripts/one_shot_backup_config_before_cleanup.py) || true + if compgen -G "${src_root}/backups/one-shot-*" >/dev/null; then + cp -a "${src_root}"/backups/one-shot-* "${dest}/" 2>/dev/null || true + fi + fi + fi + if [[ "${DRY_RUN}" -eq 0 ]]; then + { + echo "created_at=${STAMP}" + echo "install_root=${INSTALL_ROOT}" + echo "old_dir=${OLD_DIR}" + echo "git_url=${GIT_URL}" + echo "git_branch=${GIT_BRANCH}" + echo "script=${SCRIPT_SOURCE}" + } >"${dest}/reinstall.manifest" + fi +} + +restore_configs() { + local backup_dir="$1" + local dest_root="$2" + local rel + for rel in "${CONFIG_PATHS[@]}"; do + local src dest + src="${backup_dir}/${rel}" + dest="$(resolve_path "${dest_root}" "${rel}")" + if [[ -f "${src}" ]]; then + mkdir -p "$(dirname "${dest}")" + if [[ "${DRY_RUN}" -eq 1 ]]; then + log "[dry-run] restore ${src} -> ${dest}" + else + cp -a "${src}" "${dest}" + log "restore ${rel}" + fi + fi + done + local hub_settings + hub_settings="$(resolve_path "${dest_root}" "manual_trading_hub/hub_settings.json")" + if [[ -f "${hub_settings}" && "${DRY_RUN}" -eq 0 ]]; then + python3 "${dest_root}/deploy/sanitize_hub_settings.py" "${hub_settings}" || true + fi +} + +install_instance_backup_cron() { + local dest_root="$1" + local dir + for dir in crypto_monitor_binance crypto_monitor_gate crypto_monitor_okx; do + local proj="${dest_root}/${dir}" + local inst="${proj}/scripts/install_backup_cron.sh" + local data="${proj}/scripts/backup_data.sh" + if [[ -f "${inst}" && -f "${data}" ]]; then + chmod +x "${inst}" "${data}" + run bash "${inst}" + fi + done +} + +verify_pm2() { + log "预期 PM2 进程(7 个): crypto_binance crypto_gate crypto_okx manual-trading-hub manual-agent-*" + if [[ "${DRY_RUN}" -eq 1 ]]; then + return 0 + fi + pm2 list || true + if pm2 list 2>/dev/null | grep -qiE 'gate_bot|15203'; then + log "警告: PM2 列表仍含 gate_bot 相关进程,请 pm2 delete 后 pm2 save" + fi +} + +# --- 前置检查 --- + +if [[ "$(id -u)" -ne 0 ]]; then + echo "请使用 root 执行(推荐路径 ${INSTALL_ROOT})" >&2 + exit 1 +fi + +if [[ ! -f "${REPO_ROOT}/deploy/setup_env.sh" ]]; then + echo "当前脚本不在有效仓库内: ${REPO_ROOT}" >&2 + exit 1 +fi + +if [[ "${REPO_ROOT}" != "${INSTALL_ROOT}" ]]; then + log "提示: 当前仓库 ${REPO_ROOT} 与 INSTALL_ROOT=${INSTALL_ROOT} 不一致;将备份当前仓库并克隆到 INSTALL_ROOT" +fi + +STAMP="$(TZ="${TZ_NAME}" date +%Y%m%d-%H%M%S)" +BACKUP_DIR="${BACKUP_ROOT}/pre-reinstall-${STAMP}" +OLD_DIR="${INSTALL_ROOT}.old.${STAMP}" +SRC_ROOT="${REPO_ROOT}" + +if [[ -d "${INSTALL_ROOT}" && "${REPO_ROOT}" != "${INSTALL_ROOT}" ]]; then + SRC_ROOT="${INSTALL_ROOT}" +fi + +step "计划" +echo " 备份目录: ${BACKUP_DIR}" +echo " 配置来源: ${SRC_ROOT}" +echo " 旧目录移走: ${OLD_DIR}" +echo " 新克隆: ${GIT_URL} (${GIT_BRANCH}) -> ${INSTALL_ROOT}" +echo " 环境: deploy/setup_env.sh --skip-env-copy --recreate-venv --skip-pm2" +echo "" +echo " 将停止并 delete 全部 PM2 进程;不备份 crypto.db / hub data / 图片." + +if ! confirm "确认执行 Plan B 整目录重装?"; then + log "已取消" + exit 0 +fi + +# --- 1. 备份 --- + +step "备份配置到 ${BACKUP_DIR}" +backup_configs "${SRC_ROOT}" "${BACKUP_DIR}" + +# --- 2. 停 PM2 --- + +step "停止并移除本项目 PM2 进程(不影响其它 PM2)" +if command -v pm2 >/dev/null 2>&1; then + if [[ "${DRY_RUN}" -eq 1 ]]; then + log "[dry-run] pm2 stop/delete 仅: ${PM2_APPS[*]} (及历史别名)" + else + pm2_stop_project_apps + pm2_delete_project_apps + fi +else + log "未安装 pm2,跳过" +fi + +# --- 3. 移走旧目录 --- + +step "移走旧安装 ${INSTALL_ROOT} -> ${OLD_DIR}" +if [[ -d "${INSTALL_ROOT}" ]]; then + if [[ "${DRY_RUN}" -eq 1 ]]; then + log "[dry-run] mv ${INSTALL_ROOT} ${OLD_DIR}" + else + mv "${INSTALL_ROOT}" "${OLD_DIR}" + fi +else + log "目标目录不存在,跳过 mv" +fi + +# --- 4. 克隆 --- + +step "git clone" +if [[ "${DRY_RUN}" -eq 1 ]]; then + log "[dry-run] git clone -b ${GIT_BRANCH} ${GIT_URL} ${INSTALL_ROOT}" +else + git clone -b "${GIT_BRANCH}" "${GIT_URL}" "${INSTALL_ROOT}" +fi + +# --- 5. setup_env(一键安装逻辑,不复制 .env)--- + +step "重建 Python 虚拟环境 (setup_env.sh)" +if [[ "${DRY_RUN}" -eq 1 ]]; then + log "[dry-run] bash ${INSTALL_ROOT}/deploy/setup_env.sh --skip-env-copy --recreate-venv --skip-pm2" +else + bash "${INSTALL_ROOT}/deploy/setup_env.sh" --skip-env-copy --recreate-venv --skip-pm2 +fi + +# --- 6. 恢复配置 --- + +step "恢复 .env 与 hub_settings.json" +restore_configs "${BACKUP_DIR}" "${INSTALL_ROOT}" + +# --- 7. PM2 启动 --- + +step "PM2 启动全部进程" +if command -v pm2 >/dev/null 2>&1; then + run bash "${INSTALL_ROOT}/deploy/pm2_start_all.sh" + run pm2 save +else + log "未安装 pm2;请手动: bash ${INSTALL_ROOT}/deploy/pm2_start_all.sh" +fi + +# --- 8. 定时备份 cron(可选)--- + +if [[ "${INSTALL_BACKUP_CRON}" -eq 1 ]]; then + step "安装三所每日备份 cron" + install_instance_backup_cron "${INSTALL_ROOT}" +fi + +# --- 完成 --- + +step "完成" +verify_pm2 +echo "" +echo "备份: ${BACKUP_DIR}" +echo "旧目录(确认无误后可删): ${OLD_DIR}" +echo "" +echo "验收建议:" +echo " pm2 list" +echo " curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:5100/" +echo " 浏览器打开中控 /monitor,确认三所 LINK 正常" +echo "" +echo "回滚(未删旧目录时):" +echo " pm2 delete all" +echo " rm -rf ${INSTALL_ROOT}" +echo " mv ${OLD_DIR} ${INSTALL_ROOT}" +echo " cp -a ${BACKUP_DIR}/*/ ${INSTALL_ROOT}/ # 若需恢复配置" +echo " bash ${INSTALL_ROOT}/deploy/pm2_start_all.sh" diff --git a/deploy/sanitize_hub_settings.py b/deploy/sanitize_hub_settings.py new file mode 100644 index 0000000..e3bcb17 --- /dev/null +++ b/deploy/sanitize_hub_settings.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""重装后清理 hub_settings.json 中已废弃的 gate_bot / 第四账户条目.""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +DROP_KEYS = frozenset({"gate_bot", "gate-bot"}) +DROP_MARKERS = ( + "gate_bot", + "crypto_monitor_gate_bot", + "15203", + ":5002", +) + + +def _text(*parts: object) -> str: + return " ".join(str(p) for p in parts if p is not None).lower() + + +def should_drop(ex: dict) -> bool: + key = str(ex.get("key") or "").strip().lower() + if key in DROP_KEYS: + return True + blob = _text( + ex.get("name"), + ex.get("flask_url"), + ex.get("agent_url"), + ex.get("review_url"), + ) + if any(m in blob for m in DROP_MARKERS): + return True + ex_id = str(ex.get("id") or "").strip() + if ex_id == "3" and key not in ("gate", ""): + return True + return False + + +def sanitize_settings(data: dict) -> tuple[dict, list[str]]: + removed: list[str] = [] + exchanges = data.get("exchanges") + if not isinstance(exchanges, list): + return data, removed + + kept: list[dict] = [] + seen_keys: set[str] = set() + for ex in exchanges: + if not isinstance(ex, dict): + continue + key = str(ex.get("key") or "").strip().lower() + label = f"id={ex.get('id')} key={key} name={ex.get('name')}" + if should_drop(ex): + removed.append(label) + continue + if key and key in seen_keys: + removed.append(f"duplicate {label}") + continue + if key: + seen_keys.add(key) + kept.append(ex) + + out = dict(data) + out["exchanges"] = kept + return out, removed + + +def main(argv: list[str] | None = None) -> int: + args = argv if argv is not None else sys.argv[1:] + if len(args) != 1: + print("用法: python deploy/sanitize_hub_settings.py ", file=sys.stderr) + return 2 + + path = Path(args[0]) + if not path.is_file(): + print(f"文件不存在: {path}", file=sys.stderr) + return 1 + + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as e: + print(f"JSON 解析失败: {e}", file=sys.stderr) + return 1 + if not isinstance(data, dict): + print("hub_settings.json 根节点必须是 object", file=sys.stderr) + return 1 + + cleaned, removed = sanitize_settings(data) + if removed: + path.write_text(json.dumps(cleaned, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print("已移除条目:") + for line in removed: + print(f" - {line}") + else: + print("无需修改(未发现 gate_bot / 第四账户)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/deploy/setup_env.sh b/deploy/setup_env.sh new file mode 100644 index 0000000..32258b2 --- /dev/null +++ b/deploy/setup_env.sh @@ -0,0 +1,263 @@ +#!/usr/bin/env bash +# crypto_monitor_user 一键环境部署(Ubuntu / root /opt/crypto_monitor_user) +# +# 用法: +# bash deploy/setup_env.sh +# bash deploy/setup_env.sh --only binance,gate +# 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" +# shellcheck source=lib/common.sh +source "${DEPLOY_DIR}/lib/common.sh" + +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}" + pip_upgrade_tools ".venv/bin/pip" + pip_install_requirements ".venv/bin/pip" "${REQ_FILE}" "交易所共用依赖" + 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}" + pip_upgrade_tools ".venv/bin/pip" + if [[ -f "${HUB_REQ}" ]]; then + pip_install_requirements ".venv/bin/pip" "${HUB_REQ}" "中控依赖" + 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_user 环境部署" +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 okx && setup_monitor crypto_monitor_okx +should_include hub && setup_hub + +install_pm2 + +step "部署密钥(首次自动生成,不覆盖已有)" +if command -v python3 >/dev/null 2>&1; then + python3 "${REPO_ROOT}/scripts/bootstrap_deploy_secrets.py" || true +else + echo " 跳过 bootstrap_deploy_secrets(未找到 python3)" +fi + +echo "" +echo "部署完成.下一步:" +echo " 1. 编辑各子目录 .env(交易所 API 等;AI 请在中控系统设置配置)" +echo " 2. 编辑各目录 .env 后使用 PM2: pm2 start ecosystem.config.cjs(见 docs/ubuntu-server.md)" +echo "" diff --git a/docs/account-risk-cooldown.md b/docs/account-risk-cooldown.md new file mode 100644 index 0000000..b34abb5 --- /dev/null +++ b/docs/account-risk-cooldown.md @@ -0,0 +1,130 @@ +# 账户冷静期 / 日冻结风控 + +三所实例(币安 / OKX / Gate / Gate)共用 `account_risk_lib.py`. +**仅用户主动平仓**计入风控;交易所止盈/止损,空仓同步,改保本/改委托等**不触发**冷静期. + +## 状态展示 + +实例页顶,中控监控卡片账户名旁显示风控徽章: + +| 状态 | 含义 | 倒计时 | +|------|------|--------| +| 正常 | 可新开仓 | 无 | +| 1h冻结 | 冷静期中(通常为复盘后缩短的 1 小时) | 剩余时间,如 `1h冻结 · 52m 08s` | +| 4h冻结 | 冷静期中(默认 4 小时) | 剩余时间,如 `4h冻结 · 3h 12m` | +| 日冻结 | 当日禁止一切新开仓 | 至下一 **交易日切点**(`TRADING_DAY_RESET_HOUR`) | + +- 倒计时每秒刷新;到期后徽章自动恢复为 **正常**(下次轮询/API 刷新会再次对齐服务端状态). +- 鼠标悬停徽章可见完整说明(含解除时刻,如有). + +## 什么算「手动平仓」(计入风控) + +以下操作通过 `close_source` 登记为 **用户主动平仓**: + +| 来源标识 | 操作 | +|----------|------| +| `user_instance` | 实例页删单/手动平仓(`del_order`) | +| `user_hub` | 中控「平仓」「全平」「紧急全平」 | +| `user_trend_stop` | 趋势计划 **「结束计划」**(手动结束) | + +**不算**手动平仓(不触发风控): + +- 趋势 **「保本移交下单监控」** +- 中控/实例修改委托,挂止盈止损,移动保本 +- 交易所止盈/止损/条件单成交 +- 后台 `reconcile_external_closes` 空仓同步(即使记账为「外部平仓」) +- 监控轮询自动止盈/止损/保本 + +## 触发规则 + +| 事件 | 行为 | +|------|------| +| 第 1 次用户主动平仓 | 默认 **4h** 冷静期 | +| 第 2 次用户主动平仓(同一交易日) | **日冻结** | +| 复盘勾选任意情绪标签 | **日冻结** | +| 复盘:离场=手动平仓 且说明非空 | 将当前冷静期降为 **1h**(须处于 4h 档冷静期中) | + +情绪标签:怕踏空,报复开仓,盈利飘了,拿不住单,扛单,重仓违规. + +### 复盘缩短为 1h + +任选一种方式,并填写说明: + +| 方式 | 必填 | +|------|------| +| **复盘表单**提交 | 离场触发 = **手动平仓**;**离场补充** 非空(不是下方「备注」) | +| **核对修改**保存 | 结果 = **手动平仓**;**备注** 非空 | + +说明: + +- 中控全平 / 实例手动平仓后,只要在 4h 窗口内完成上述操作即可降为 1h. +- 复盘保存后会同步更新 `last_close_at_ms`,倒计时以 **最后一次手动平仓 + 当前档位数** 为准,不会继续读库内旧 4h 结束时间. +- 1h 窗口已结束后,即使库里残留旧 `cooloff_until_ms`,状态也会恢复 **正常**. +- 若超过「平仓 + 1h」才复盘,则从 **保存复盘时刻** 起再计 1h(不延长原 4h). +- **止盈 / 保本止盈 / 止损** 等自动平仓不触发风控,也不会刷新冷静期. +- 代码更新后需 **重启对应实例** 并硬刷新页面. + +### 倒计时与标签 + +- 结束时刻 = `last_close_at_ms + cooloff_hours`(`APP_TIMEZONE` 默认北京时间) +- 1h / 4h 标签按实际剩余时长判断,与倒计时一致 +- 切交易日后,若冷静期已过期,自动清库内残留字段 + +## 环境变量 + +```env +RISK_CONTROL_ENABLED=true +RISK_COOLING_HOURS_MANUAL=4 +RISK_COOLING_HOURS_MANUAL_JOURNAL=1 +RISK_MANUAL_CLOSE_DAILY_LIMIT=2 +RISK_MOOD_ISSUES_DAILY_FREEZE=true +TRADING_DAY_RESET_HOUR=8 +APP_TIMEZONE=Asia/Shanghai +``` + +`RISK_COOLING_HOURS_EXTERNAL` 已废弃(外部平仓不再触发风控). + +## API 与 `risk_status` 字段 + +| 接口 | 说明 | +|------|------| +| `GET /api/account_snapshot` | 实例页轮询,含 `risk_status` | +| `GET /api/account_risk_status` | hub_bridge 专用 | +| `GET /api/hub/monitor` | 中控监控板,每账户含 `risk_status` | +| `POST /api/hub/account-risk/user-close` | 中控登记用户平仓,`body: { source, count }` | + +`risk_status` 主要字段: + +| 字段 | 说明 | +|------|------| +| `status` | `normal` / `freeze_1h` / `freeze_4h` / `freeze_daily` / `freeze_position` | +| `status_label` | 中文标签 | +| `can_trade` | 是否允许新开仓(仅风控维度) | +| `reason` | 悬停提示文案 | +| `active_count` / `max_active_positions` | 当前活跃持仓与 `.env` 中 `MAX_ACTIVE_POSITIONS` | +| `cooloff_until_ms` | 1h/4h 冷静期结束时间戳(毫秒) | +| `freeze_until_ms` | 倒计时结束时间戳(日冻结为下一交易日切点) | +| `freeze_remaining_sec` | 服务端计算的剩余秒数(供调试) | + +**仓位上限冻结**:当 **计入上限的** 活跃持仓数(不含趋势回调)≥ 实例 `.env` 的 `MAX_ACTIVE_POSITIONS`(默认 1)且账户无时间类冻结时,徽章显示 **仓位上限冻结**;此时 **新开仓** 被禁止,但 **顺势加仓**(在已有同向监控持仓上加仓)仍可用.仅存在趋势回调持仓时不触发该冻结.时间冻结(1h/4h/日)优先展示. + +`risk_status.can_roll`:仓位上限冻结时为 `true`,表示顺势加仓不受该冻结限制. + +## 前端倒计时 + +- 共用脚本:`static/account_risk_badge.js?v=4` +- 样式:`static/account_risk_badge.css` +- 展示格式:`4h冻结 · 3h 12m`;日冻结为距下一交易日切点剩余时间 +- 倒计时优先用服务端 `freeze_remaining_sec` 推算结束时刻,避免绝对时间戳与时区/脏数据偏差 +- 服务端在冷静期**已结束**或锚点无效时**自动清库**,避免重启后误读旧 `account_risk_state` 仍显示冻结 +- 无效的未来 `last_close_at_ms` **不会**被当作「现在」重启计时 +- 若当日手动平仓**已复盘**(journal 有说明)且 1h 窗口已过,即使 risk 表被误写也会强制恢复 **正常** +- 勿与交易记录列表中的历史平仓时间混淆:风控只看 `account_risk_state` 表内 **最后一次用户主动平仓** 及其复盘结果 + +## 相关代码 + +- `account_risk_lib.py` — 状态机,`enrich_risk_status_countdown`,`apply_position_limit_risk`,`on_user_initiated_close` +- `hub_bridge.py` — `/api/hub/account-risk/user-close` +- `manual_trading_hub/hub.py` — 中控平仓成功后调用 user-close +- `strategy_trend_register.py` — `stop_trend_pullback` 结束计划时登记风控 +- `tests/test_account_risk_lib.py` diff --git a/docs/auto-transfer-daily.md b/docs/auto-transfer-daily.md new file mode 100644 index 0000000..3a6ff73 --- /dev/null +++ b/docs/auto-transfer-daily.md @@ -0,0 +1,45 @@ +# 每日自动划转(三所统一) + +## 行为 + +在 `.env` 开启 `AUTO_TRANSFER_ENABLED=true` 后,监控轮询在**北京时间 `AUTO_TRANSFER_BJ_HOUR` 整点所在小时**内(默认 8:00–8:59)执行一次(按 **UTC 自然日** 去重): + +| 交易账户 (`AUTO_TRANSFER_TO`,默认 swap) | 动作 | +|------------------------------------------|------| +| 余额 **低于** `AUTO_TRANSFER_AMOUNT` | 从 `AUTO_TRANSFER_FROM`(默认 funding)划入差额 | +| 余额 **高于** `AUTO_TRANSFER_AMOUNT` | 将多余划回 `AUTO_TRANSFER_FROM` | +| 与目标相差 < 0.01U | 跳过,不写划转 | +| 存在 **active** 持仓(`order_monitors`,或 Gate回调已开仓计划) | **不划转**,写账簿 `skipped`,并**企业微信**说明「持仓中,本次资金无划转」 | + +## 配置示例(目标 50U) + +```env +AUTO_TRANSFER_ENABLED=true +AUTO_TRANSFER_AMOUNT=50 +AUTO_TRANSFER_FROM=funding +AUTO_TRANSFER_TO=swap +AUTO_TRANSFER_BJ_HOUR=8 +``` + +`AUTO_TRANSFER_AMOUNT` 与 `DAILY_START_CAPITAL`(每日开仓基数)**独立**. + +API Key 须具备万向划转权限(与手动划转相同). + +## 用脚本更新三所 `.env` + +详见 **[env-sync-scripts.md](./env-sync-scripts.md)**.常用命令: + +```bash +git pull + +# 仅补全划转相关项 +python scripts/sync_four_exchange_transfer_env.py + +# 目标 50U 并开启自动划转 +python scripts/sync_four_exchange_transfer_env.py --set-amount 50 --enable-auto-transfer + +# 计仓 + 划转一并补全 +python scripts/sync_four_exchange_env.py --set-transfer-amount 50 --enable-auto-transfer + +pm2 restart crypto-monitor-binance crypto-monitor-okx crypto-monitor-gate +``` diff --git a/docs/daily-open-limit.md b/docs/daily-open-limit.md new file mode 100644 index 0000000..0e92295 --- /dev/null +++ b/docs/daily-open-limit.md @@ -0,0 +1,81 @@ +# 单日开仓次数限制(三所统一) + +各交易实例(Binance / OKX / Gate)在 `.env` 中独立配置,互不影响. + +## 交易日口径 + +- 以 **北京时间** `TRADING_DAY_RESET_HOUR`(默认 **8:00**)切分交易日,与统计,顶栏「交易日」一致. +- **次日恢复**:过了切日时刻后 `session_date` 变为新日期,计数自动归零,无需清库. + +## 计数口径 + +每成功新建一条 `order_monitors` 记录计 **1 次**,包括: + +- 人工「实盘下单」 +- 关键位自动开仓 +- 其他写入 `order_monitors` 的成功开仓 + +平仓后再开仍算新的一单.当日总次数到硬上限后 **当天不再允许新开**(即使已空仓). + +## 环境变量 + +在「交易执行 / 人工风控」段配置: + +```env +# 【单日开仓 AI 提醒】本交易日开仓次数达到该值时,企业微信推送 AI 克制提醒(不拦单) +DAILY_OPEN_ALERT_THRESHOLD=5 + +# 【单日开仓硬上限】本交易日开仓次数 >= 该值后,禁止一切新开仓直至下一交易日;0=不启用 +DAILY_OPEN_HARD_LIMIT=0 +``` + +### 配置示例 + +```env +# 保守户:3 次提醒,5 次封死 +DAILY_OPEN_ALERT_THRESHOLD=3 +DAILY_OPEN_HARD_LIMIT=5 + +# 仅提醒,不封(与旧版行为接近) +DAILY_OPEN_ALERT_THRESHOLD=5 +DAILY_OPEN_HARD_LIMIT=0 + +# 严格户:到 3 次即封 +DAILY_OPEN_ALERT_THRESHOLD=2 +DAILY_OPEN_HARD_LIMIT=3 +``` + +建议 `DAILY_OPEN_ALERT_THRESHOLD <= DAILY_OPEN_HARD_LIMIT`(硬上限为 0 时除外). + +## 程序行为 + +| 次数 | 行为 | +|------|------| +| 未达提醒阈值 | 正常开仓 | +| 达到 `DAILY_OPEN_ALERT_THRESHOLD` | 成功开仓后 AI 企业微信提醒 | +| 达到 `DAILY_OPEN_HARD_LIMIT`(>0) | `precheck_risk` 拒绝人工/关键位开仓;顶栏 `can_trade=false` | + +硬限制与以下规则 **同时生效**(取交集): + +- `TRADING_DAY_RESET_OPEN_GUARD_ENABLED`:切日前禁止新开 +- `MAX_ACTIVE_POSITIONS`:同时持仓上限 +- Gate:`precheck_trend_pullback_start` 同样校验单日硬上限 + +## 页面与接口 + +- 顶栏 / `api/account_snapshot` 返回 `opens_today`,`daily_open_hard_limit`,`daily_open_alert_threshold`. +- 达硬上限时提示:`本交易日开仓 N/M 已达上限,次日 8:00 后恢复`(`M` 为配置的硬上限). + +## 部署 + +修改各实例 `.env` 后重启对应 pm2 进程,例如: + +```bash +pm2 restart crypto_binance crypto_okx crypto_gate +``` + +## 实现位置 + +- 共享逻辑:`daily_open_limit_lib.py` +- 三所 `app.py`:`precheck_risk`,`can_trade`,`api/account_snapshot`,开仓成功后的 AI 提醒文案 +- 单元测试:`tests/test_daily_open_limit_lib.py` diff --git a/docs/env-sync-scripts.md b/docs/env-sync-scripts.md new file mode 100644 index 0000000..222b0ae --- /dev/null +++ b/docs/env-sync-scripts.md @@ -0,0 +1,139 @@ +# 三所 `.env` 同步脚本说明 + +在**仓库根目录**执行.仅处理三所实例目录下的 `.env`,**不覆盖** API 密钥与已存在的自定义值;若某目录无 `.env` 会 `SKIP`(需先 `cp .env.example .env`). + +| 目录 | +|------| +| `crypto_monitor_binance` | +| `crypto_monitor_okx` | +| `crypto_monitor_gate` | +| `crypto_monitor_gate` | + +修改 `.env` 后须 **`pm2 restart`** 对应实例后生效. + +--- + +## 一键同步(推荐) + +`scripts/sync_four_exchange_env.py`:依次执行**计仓** + **自动划转** 两个子脚本. + +```bash +cd /path/to/crypto_monitor +git pull + +# 仅补全缺失项(已有值保留) +python scripts/sync_four_exchange_env.py + +# 预览,不写文件 +python scripts/sync_four_exchange_env.py --dry-run + +# 划转目标 50U 并开启自动划转(计仓仍只补缺失项) +python scripts/sync_four_exchange_env.py --set-transfer-amount 50 --enable-auto-transfer + +# 无仓后切换全仓杠杆(须先确认交易所无持仓) +python scripts/sync_four_exchange_env.py --set-mode full_margin +``` + +| 参数 | 说明 | +|------|------| +| `--dry-run` | 只打印将做的变更,不写 `.env` | +| `--set-mode risk\|full_margin` | 强制三所 `POSITION_SIZING_MODE` | +| `--set-transfer-amount U` | 强制三所 `AUTO_TRANSFER_AMOUNT` | +| `--enable-auto-transfer` | 强制三所 `AUTO_TRANSFER_ENABLED=true` | + +--- + +## 仅自动划转 + +`scripts/sync_four_exchange_transfer_env.py` + +行为说明见 [auto-transfer-daily.md](./auto-transfer-daily.md). + +```bash +# 补全缺失项 +python scripts/sync_four_exchange_transfer_env.py +python scripts/sync_four_exchange_transfer_env.py --dry-run + +# 目标 50U 并开启 +python scripts/sync_four_exchange_transfer_env.py --set-amount 50 --enable-auto-transfer +``` + +| 参数 | 说明 | +|------|------| +| `--dry-run` | 预览 | +| `--set-amount U` | 强制 `AUTO_TRANSFER_AMOUNT` | +| `--enable-auto-transfer` | 强制 `AUTO_TRANSFER_ENABLED=true` | + +**缺项默认**(未使用 `--set-amount` 且文件中无该键时): + +1. 若已有 `AUTO_TRANSFER_AMOUNT` → 保留 +2. 否则若存在 `DAILY_START_CAPITAL` → 沿用其值 +3. 否则 → **50** + +补全时会写入(若缺失):`AUTO_TRANSFER_FROM=funding`,`AUTO_TRANSFER_TO=swap`,`TRANSFER_CCY=USDT`,`AUTO_TRANSFER_BJ_HOUR=8`;币安额外补 `BINANCE_FUNDING_INCLUDE_SPOT=false`. + +--- + +## 仅计仓模式 + +`scripts/sync_four_exchange_position_sizing_env.py` + +行为说明见 [position-sizing-mode.md](./position-sizing-mode.md). + +```bash +# 补全缺失项(默认 risk,FULL_MARGIN_BUFFER_RATIO=0.98) +python scripts/sync_four_exchange_position_sizing_env.py +python scripts/sync_four_exchange_position_sizing_env.py --dry-run + +# 无仓后切全仓 +python scripts/sync_four_exchange_position_sizing_env.py --set-mode full_margin + +# 无仓后切回以损定仓 +python scripts/sync_four_exchange_position_sizing_env.py --set-mode risk + +# 强制缓冲比例 +python scripts/sync_four_exchange_position_sizing_env.py --set-buffer 0.98 +``` + +| 参数 | 说明 | +|------|------| +| `--dry-run` | 预览 | +| `--set-mode risk\|full_margin` | 强制 `POSITION_SIZING_MODE`(**须无持仓**后 restart) | +| `--set-buffer RATIO` | 强制 `FULL_MARGIN_BUFFER_RATIO` | + +--- + +## 共用交易 / 关键位 / 轮询项 + +`scripts/sync_common_trading_env.py`:以 Gate `.env.example` 为基准,向**币安,OKX** 的 `.env` **追加缺失项**(不覆盖 API 密钥与已有自定义值). + +```bash +python scripts/sync_common_trading_env.py +python scripts/sync_common_trading_env.py --dry-run +python scripts/sync_common_trading_env.py --instances crypto_monitor_okx +python scripts/sync_common_trading_env.py --apply-force-close-policy +``` + +补全项含:`RECONCILE_*`,`PRICE_REFRESH_SECONDS`,`KEY_*` 门控,`KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT` 等(**不含** `FORCE_CLOSE_*`,由 `--apply-force-close-policy` 单独处理). + +**强制清仓策略**(仅 Gate 开启): + +```bash +python scripts/sync_common_trading_env.py --apply-force-close-policy +``` + +或服务器一键:`bash deploy/pull_and_restart.sh` + +--- + +## 部署后重启 + +```bash +pm2 restart crypto-monitor-binance crypto-monitor-okx crypto-monitor-gate +``` + +## 相关文档 + +- [计仓模式](./position-sizing-mode.md) +- [每日自动划转](./auto-transfer-daily.md) +- [部署说明](../deploy/README.md) diff --git a/docs/env配置说明.md b/docs/env配置说明.md new file mode 100644 index 0000000..65385a8 --- /dev/null +++ b/docs/env配置说明.md @@ -0,0 +1,229 @@ +# env 配置页说明 + +本文档描述各交易实例 Web 端 **「env 配置」** 页展示项,含义,生效方式,以及与系统设置,中控密钥的分工. + +> **不在本页展示的配置**(服务端口,数据库路径,关键位门控,轮询间隔等)仍保存在实例目录 `.env` 中,需 SSH 编辑或部署脚本维护,见文末「隐藏项」. + +--- + +## 1. 设计原则 + +| 原则 | 说明 | +|------|------| +| **只展示运营相关项** | 不暴露全量 `.env`,避免误改基础设施 | +| **前端仅中文** | 页面只显示中文标签与说明,不显示 `APP_XXX` 等变量名 | +| **账户密码不进本页** | 登录用户名/密码在 **系统设置 → 账户密码修改** 中维护 | +| **密钥自动托管** | 中控通信密钥,登录会话密钥由 **首次部署脚本自动生成并写入**(一次生成,不轮换),本页不提供编辑 | +| **AI 仅中控配置** | OpenAI / Ollama 等 AI 项已从中控 **系统设置 → AI 配置** 统一维护并同步三所,本页不再展示 | +| **保存标注** | 每项标注「保存即生效」或「需重启」;含需重启项时可用「保存并重启」 | + +--- + +## 2. 密钥分工(自动生成,本页不可见) + +部署时由脚本统一生成并写入对应 `.env`(已有值则跳过,避免覆盖生产环境). + +| 类型 | 环境变量 | 写入位置 | 用途 | +|------|----------|----------|------| +| **中控通信密钥** | `HUB_BRIDGE_TOKEN` | 中控 `manual_trading_hub/.env` + 三实例 `.env`(**相同值**) | 中控调用实例 API(`X-Hub-Token`),iframe SSO 签发与校验 | +| **登录会话密钥** | `FLASK_SECRET_KEY` | 三实例 `.env`(**三所相同**) | Flask Session 签名;与网页登录态相关,与中控密钥 **分离** | +| **中控会话密钥** | `HUB_SESSION_SECRET` | 中控 `.env` | 中控登录 Cookie 签名 | +| **登录账号** | `APP_USERNAME` / `APP_PASSWORD` | 三实例 `.env`(建议三所统一) | 直链实例 `/login` 使用;**仅在系统设置中修改** | +| **中控登录** | `HUB_USERNAME` / `HUB_PASSWORD` | 中控 `.env` | 中控网页登录;**仅在中控系统设置改密** | + +说明: + +- **长期密钥一次生成,不轮换**:`setup_env.sh` 末尾调用 `scripts/bootstrap_deploy_secrets.py`;已有非占位值不会被覆盖. +- **SSO 链接不变**:仍为中控每次签发,默认 2 小时有效,单次使用(`HUB_SSO_TTL_SEC`),与长期 `HUB_BRIDGE_TOKEN` 分离. +- 经中控 iframe / SSO 打开实例时,可免输实例密码;直链 IP/域名仍走 `/login`. +- 首次部署可生成随机强密码;用户日后在 **系统设置** 改密,不经过本页. + +--- + +## 3. 页面布局(三列卡片) + +| 列 1 | 列 2 | 列 3 | +|------|------|------| +| 交易所与实盘 | 企业微信 | 交易执行 | +| 交易风控 | 账户冷静期 | 自动划转 | +| 当日资金 | 期权账户(仅 OKX) | | + +> **AI 复盘**(OpenAI / Ollama)已移至中控 **系统设置 → AI 配置**,保存后强制同步三所 `.env`.详见 [中控AI与密钥配置.md](./中控AI与密钥配置.md). + +Binance / Gate 无期权模块时,第三列最后一格不显示或显示「本所无期权」. + +--- + +## 4. 各卡片字段(中文展示名) + +### 4.1 交易所与实盘 + +| 中文名 | 说明 | 重启 | +|--------|------|------| +| 开启实盘下单 | 关闭时仅走本地流程,不向交易所发单 | 需重启 | +| API Key | 永续子账户 API Key | 需重启 | +| API Secret | 永续子账户 Secret | 需重启 | +| API Passphrase | 仅 OKX 显示 | 需重启 | +| 保证金模式 | 全仓 / 逐仓 | 需重启 | +| 持仓模式 | 双向 / 单向净持仓等(按所) | 需重启 | +| 仓位查询类型 | 仅 OKX:如 SWAP | 需重启 | +| 账户备注 | 企业微信推送中显示的交易所备注 | 保存即生效 | + +**本卡片不包含**:网页登录账号密码,是否关闭登录校验,中控通信密钥. + +--- + +### 4.2 企业微信 + +| 中文名 | 说明 | +|--------|------| +| 机器人 Webhook | 行情,风控,提醒推送地址 | +| 推送超时(秒) | 可选,默认 10 | + +--- + +### 4.3 AI 复盘(已移至中控) + +AI 相关环境变量(`AI_PROVIDER`,`OPENAI_*`,`OLLAMA_*`,`AI_MODEL`,`AI_TIMEOUT_SECONDS`)**不再在本页展示**. + +请在中控 **系统设置 → AI 配置** 修改;保存后写入中控 `.env` 并 **强制同步** 至 OKX / Binance / Gate 三实例.详见 [中控AI与密钥配置.md](./中控AI与密钥配置.md). + +--- + +### 4.4 交易执行 + +| 中文名 | 说明 | +|--------|------| +| 计仓模式 | 以损定仓 / 全仓杠杆 | +| 以损定仓风险% | 单笔风险占资金比例 | +| 全仓资金缓冲比例 | 全仓模式下可用资金折扣 | +| BTC 默认杠杆 | | +| 山寨默认杠杆 | | +| 方向限制开关 | | +| 允许方向 | 多 / 空 / 双向 | +| 币种白名单开关 | | +| 白名单币种 | 逗号分隔 | +| 交易日切点(北京时间) | 默认 8 点 | +| 切点前禁止新开仓 | | +| 最大同时持仓 | | +| 人工最低盈亏比 | | +| 强制清仓开关 | | +| 强制清仓整点(北京) | | + +--- + +### 4.5 交易风控(日内开仓) + +| 中文名 | 说明 | +|--------|------| +| 单日开仓提醒阈值 | 达到次数后 AI 克制提醒(不拦单) | +| 单日开仓硬上限 | 0 表示不启用;达到后禁止新开仓 | + +详见 [daily-open-limit.md](./daily-open-limit.md). + +--- + +### 4.6 账户冷静期 + +| 中文名 | 说明 | +|--------|------| +| 冷静期总开关 | | +| 手动平仓冷静(小时) | | +| 复盘情绪冷静(小时) | | +| 日手动平仓次数上限 | | +| 情绪标签日冻结 | | + +详见 [account-risk-cooldown.md](./account-risk-cooldown.md). + +--- + +### 4.7 自动划转 + +| 中文名 | 说明 | +|--------|------| +| 启用自动划转 | | +| 目标余额(U) | 交易账户目标 USDT | +| 划出账户 | funding / swap | +| 划入账户 | swap / funding | +| 执行整点(北京时间) | | +| 划转币种 | 默认 USDT | + +详见 [auto-transfer-daily.md](./auto-transfer-daily.md). + +--- + +### 4.8 当日资金 + +| 中文名 | 说明 | +|--------|------| +| 日起始基数(U) | | +| 回撤后基数(U) | | +| 盈利后基数(U) | | + +与自动划转目标余额相互独立;若需一致请手动对齐. + +--- + +### 4.9 期权账户(仅 OKX) + +| 中文名 | 说明 | +|--------|------| +| 启用期权模块 | | +| 期权 API Key / Secret / Passphrase | 主账户,与永续子账户分离 | +| 期权账户备注 | | +| 单笔预算(USDC) | | +| 预算缓冲比例 | | +| 默认标的 | 如 ETH | +| 最大到期天数 | 等常用策略参数 | + +高级参数与完整说明见 [期权方案.md](./期权方案.md),[期权用法.md](./期权用法.md),[期权开平仓与监控说明.md](./期权开平仓与监控说明.md)(线上 `/options/guide`). + +--- + +## 5. 操作说明 + +1. 修改后点 **保存**:即时生效项立即应用;需重启项写入 `.env` 但未重启进程. +2. 含需重启项时点 **保存并重启**:写 `.env` 后 PM2 重启当前实例. +3. **重新加载**:从磁盘重新读取 `.env` 刷新表单(放弃未保存修改). +4. 敏感项(API,密钥)显示为掩码;**留空提交表示不修改原值**. + +--- + +## 6. 隐藏项(本页不展示) + +以下仍存在于 `.env`,仅供运维或 SSH 修改: + +- 服务:`APP_HOST`,`APP_PORT`,`APP_DEBUG` +- 数据:`DB_PATH`,`UPLOAD_DIR` +- 关键位门控:全部 `KEY_*`,`KLINE_*` +- 轮询与同步:`BALANCE_REFRESH_SECONDS`,`PRICE_REFRESH_SECONDS`,`MONITOR_POLL_SECONDS`,`BREAKEVEN_*`,`RECONCILE_*` +- 代理:`OKX_SOCKS_PROXY`,`BINANCE_HTTP_PROXY` 等 +- 备份:`BACKUP_*` +- 中控嵌入细节:`APP_ALLOW_HUB_EMBED`,`HUB_EMBED_*`,`APP_COOKIE_SECURE` +- 登录相关:`APP_AUTH_DISABLED`,`APP_USERNAME`,`APP_PASSWORD`,`FLASK_SECRET_KEY`,`HUB_BRIDGE_TOKEN` + +后续若需要可增加「高级模式」折叠区,默认关闭. + +--- + +## 7. 与系统设置的关系 + +| 能力 | env 配置 | 系统设置 | 中控系统设置 | +|------|----------|----------|--------------| +| 登录用户名/密码 | ❌ | ✅ 账户密码修改 | ✅ 中控账户密码 | +| 交易所 API | ✅(各所自配) | ❌ | ❌ | +| AI / OpenAI | ❌ | ❌ | ✅ AI 配置(同步三所) | +| 导航/区块显示 | ❌ | ✅ 导航显示 | ✅ 显示与导航 | +| 手动资金划转 | ❌ | ✅ 永续资金划转 | ❌ | +| 数据导出 | ❌ | ✅ 数据导出 | ❌ | +| 期权兑换/划转 UI | ❌ | ✅(OKX,可开关) | ❌ | + +系统设置说明见 [系统设置说明.md](./系统设置说明.md);中控 AI 与部署密钥见 [中控AI与密钥配置.md](./中控AI与密钥配置.md). + +--- + +## 8. 实现备注(开发用) + +- 白名单分组:`lib/env/env_ui_manifest.py`(按 `exchange_key` 过滤) +- 中文标签:`ENV_UI_LABELS` 映射,模板只渲染 `label` / `note` +- 全量校验仍基于 `.env.example`;POST 仅接受 manifest 内 key diff --git a/docs/hub-symbol-archive-kline.md b/docs/hub-symbol-archive-kline.md new file mode 100644 index 0000000..c609050 --- /dev/null +++ b/docs/hub-symbol-archive-kline.md @@ -0,0 +1,135 @@ +# 内照明心与永久 K 线 + +## 概述 + +「内照明心」页(`/archive`)用于 **复盘语录 + 交易记录回顾 + 按需 K 线**.左侧维护每日复盘语录(最多 100 条);右侧按日期区间列出开仓记录,展示区间统计,并可展开 K 线图表对照单笔交易. + +与行情区 `hub_kline.db`(15 天滚动缓存)**完全独立**:档案库只增不删,从建档起永久保留. + +## 页面布局 + +| 区域 | 说明 | +|------|------| +| **复盘语录** | 左栏;按日期添加/编辑/删除,一日一条 | +| **日期与筛选** | 顶栏:本日 / 本周 / 本月 / 自选区间;盈利单,亏损单,犯病,交易所,搜索 | +| **区间统计** | 统计栏随日期选择自动更新(见下) | +| **K 线图表** | 默认折叠;点「图表」或展开后按需加载 | +| **交易记录** | 默认展开;犯病行 **红色字体**(无红底);可编辑标签与备注 | + +## 日期区间 + +交易日按北京时间 **8:00** 切日(`TRADING_DAY_RESET_HOUR`). + +| 模式 | 范围 | +|------|------| +| **本日** | 可选单个交易日(默认当前交易日) | +| **本周** | 当周周一至当前交易日 | +| **本月** | 当月 1 日至当前交易日 | +| **区间** | 自选 `date_from`~`date_to`(含首尾交易日) | + +## 区间统计(统计栏) + +基于当前 **列表筛选结果**(含盈利/亏损/犯病勾选,合约搜索;交易所下拉仍限定数据源): + +| 指标 | 说明 | +|------|------| +| 总开仓次数 | 区间内开仓笔数 | +| 盈利单 / 亏损单 | 盈亏 > 0 / < 0 的笔数(持平不计) | +| 平均盈利 / 平均亏损 | 盈利单,亏损单各自的均值(U) | +| 最大盈利 / 最大亏损 | 单笔最大盈利,最大亏损(U) | +| 犯病次数 / 占比 | `behavior_tag = sick` 的笔数及占开仓比例 | +| 盈亏 | 区间内全部已平仓盈亏合计 | +| 剔除犯病盈亏 | 排除犯病单后的盈亏合计 | +| 各交易所 | 每所同上分项 | + +在搜索框输入币种(如 `BTC`)后,统计栏与下方列表同步按该条件收窄. + +## 数据约定 + +| 项 | 约定 | +|----|------| +| 交易来源 | 三所 `trade_records` + 未落库的 `strategy_trade_snapshots`,经 `/api/hub/trades/archive` 拉取 | +| 犯病标签 | 中控 `trade_overlay.behavior_tag = sick` | +| K 线真源 | 仅 **5m** 写入 `hub_symbol_archive.db` | +| 建档种子 | 该币 **最早开仓** 向前 **30 天** 5m | +| 增量同步 | 默认每 **4 小时** 补新 5m 至当前 | +| 展示周期 | Tab:**5m / 15m / 1h / 4h**,默认 **15m** | +| 视窗模式 | **持仓过程**(锚平仓,默认)/ **进场决策**(锚开仓) | +| 时间跳转 | 输入 `YYYY-MM-DD HH:MM` 后点「跳转」 | + +## 存储 + +- 默认路径:`manual_trading_hub/data/hub_symbol_archive.db` +- 环境变量:`HUB_ARCHIVE_DB_PATH` +- 表: + - `archive_meta` — 建档元数据 + - `archive_bars_5m` — 永久 5m K 线 + - `archive_trade_cache` — 从实例同步的交易快照 + - `trade_overlay` — 犯病标签与备注(仅中控) + - `archive_review_quotes` — 复盘语录 + +## API(中控 FastAPI) + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/archive/meta` | 周期,交易所,同步间隔等 | +| GET | `/api/archive/daily-trades` | 区间交易列表与统计(见 query) | +| GET | `/api/archive/quotes` | 复盘语录列表 | +| POST | `/api/archive/quotes` | 新增语录 | +| PATCH | `/api/archive/quotes/{id}` | 更新语录 | +| DELETE | `/api/archive/quotes/{id}` | 删除语录 | +| GET | `/api/archive/ohlcv` | K 线视窗(`timeframe` / `mode` / `anchor_ms` / `at`) | +| PATCH | `/api/archive/trade/{exchange_key}/{trade_id}` | 更新标签/备注 | +| POST | `/api/archive/sync` | 立即同步三所交易 + K 线 | + +`GET /api/archive/daily-trades` 主要 query: + +| 参数 | 说明 | +|------|------| +| `period` | `today` / `week` / `month` / `range` | +| `trading_day` | 本日模式下的交易日 `YYYY-MM-DD` | +| `date_from` / `date_to` | 区间模式起止日 | +| `exchange_key` | 可选,按交易所筛选 | +| `filter_profit` / `filter_loss` / `filter_sick` | 过滤列表与统计 | +| `search` | 合约 / 交易所 / 备注搜索(同步过滤列表与统计) | + +返回 `stats` 含 `open_count`,`win_count`,`loss_count`,`win_rate`,`avg_win`,`avg_loss`,`profit_loss_ratio`,`max_win`,`max_loss`,`sick_count`,`sick_pct`,`pnl_total`,`pnl_ex_sick`,`by_exchange`. + +实例侧: + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/hub/trades/archive` | 近 N 天已平仓(`days` / `limit`) | + +## 后台任务 + +Hub 启动后在 lifespan 中运行 `hub-archive-sync`: + +1. 对各启用交易所调用 `/api/hub/trades/archive` +2. 写入 `archive_trade_cache` +3. 未建档币种:拉 30 天 5m 种子 +4. 已建档币种:增量补 5m + +间隔:`HUB_ARCHIVE_SYNC_INTERVAL_SEC`(默认 14400). + +## 代码位置 + +- `hub_symbol_archive_lib.py` — 库表,区间统计,种子,增量,聚合 +- `hub_trades_lib.py` — `fetch_trades_for_archive` +- `hub_bridge.py` — 实例 `/api/hub/trades/archive` +- `manual_trading_hub/hub.py` — 路由与后台同步 +- `manual_trading_hub/static/archive.js` — 内照明心前端 + +## 与行情区的区别 + +| | 行情区 | 内照明心 | +|--|--------|----------| +| DB | `hub_kline.db` | `hub_symbol_archive.db` | +| 保留 | 15 天滚动删除 | 建档起永久 | +| 周期 | 多周期直存/拉取 | 仅存 5m,高周期聚合 | +| 用途 | 实时看盘 | 复盘语录与交易回顾 | + +## 相关文档 + +- [中控平仓与交易记录](trend-hub-close-and-trade-records.md) +- [中控使用说明](../manual_trading_hub/使用说明.md) diff --git a/docs/lib-structure.md b/docs/lib-structure.md new file mode 100644 index 0000000..b241cd6 --- /dev/null +++ b/docs/lib-structure.md @@ -0,0 +1,147 @@ +# lib/ 共用模块结构 + +三所实例与中控共用的 Python 库,模板与静态资源统一放在仓库根目录的 **`lib/`** 下.部署单元(`crypto_monitor_*`,`manual_trading_hub`)仍保持独立目录与 PM2 配置不变. + +**重构前快照 Git 标签**:`pre-lib-modularization`(可用 `git checkout pre-lib-modularization` 查看旧布局). +**移除 gate_bot 前快照 Git 标签**:`pre-remove-gate-bot`. + +--- + +## 顶层目录 + +``` +crypto_monitor/ +├── crypto_monitor_binance/ # 三所:各自 app + .env + PM2 +├── crypto_monitor_gate/ +├── crypto_monitor_okx/ +├── manual_trading_hub/ # 中控 + 子代理 agent +│ +├── lib/ # 共用模块(本说明) +│ ├── strategy/ +│ ├── key_monitor/ +│ ├── trade/ +│ ├── hub/ +│ ├── ai/ +│ ├── instance/ +│ ├── exchange/ +│ ├── common/ +│ └── paths.py +│ +├── brand/ # 各所共用图标 +├── docs/ +├── deploy/ +├── scripts/ +├── tests/ +├── requirements.txt +└── README.md +``` + +--- + +## lib/ 子包说明 + +| 子包 | 职责 | 主要模块 | +|------|------|----------| +| **`lib/strategy/`** | 策略交易(顺势加仓,趋势回调,快照与记录) | `strategy_register.py`,`strategy_trend_register.py`,`strategy_db.py`,`strategy_roll_*`,`strategy_trend_*` | +| **`lib/strategy/templates/`** | 策略页 Jinja 模板(原 `strategy_templates/`) | `strategy_trading_page.html`,`strategy_roll_panel.html` 等 | +| **`lib/key_monitor/`** | 关键位监控,斐波,假突破,止盈止损方案 | `key_monitor_lib.py`,`fib_key_monitor_lib.py`,`key_sl_tp_lib.py` 等 | +| **`lib/trade/`** | 下单监控展示,计仓,账户风控,手动 SL/TP | `order_monitor_display_lib.py`,`position_sizing_lib.py`,`account_risk_lib.py` 等 | +| **`lib/hub/`** | 中控 API,K 线,归档,计仓器,SSO/Bridge | `hub_bridge.py`,`hub_kline_store.py`,`hub_trades_lib.py` 等 | +| **`lib/ai/`** | AI 复盘与文本生成 | `ai_client.py`,`ai_review_lib.py` | +| **`lib/instance/`** | 中控 iframe 嵌入,导航,复盘图表 | `instance_embed_lib.py`,`focus_chart_lib.py`,`journal_chart_lib.py` | +| **`lib/instance/templates/`** | 嵌入页片段(原 `embed_templates/`) | `embed_page_fragment.html` | +| **`lib/exchange/`** | 特定交易所工具 | `gate_transfer_lib.py`,`okx_orders_lib.py` 等 | +| **`lib/common/`** | 跨功能小工具 | `form_submit_lib.py`,`wechat_notify_lib.py` 等 | +| **`lib/common/static/`** | 三所与中控共用的 JS/CSS(原根目录 `static/`) | `instance_theme.js`,`strategy_roll.js` 等 | + +> **说明**:`hub_*` 命名表示「中控侧能力或行情聚合」,但部分模块(如 `hub_volume_rank_lib`,`hub_market_info_lib`)三所 `app.py` 也会调用,并非中控独占. + +--- + +## 路径辅助函数 + +`lib/paths.py` 集中维护资源目录,避免硬编码: + +```python +from lib.paths import strategy_templates_dir, embed_templates_dir, common_static_dir + +strategy_templates_dir() # .../lib/strategy/templates +embed_templates_dir() # .../lib/instance/templates +common_static_dir() # .../lib/common/static +``` + +可选传入 `repo_root`(字符串或 `Path`),默认使用 `lib/` 的上级目录即仓库根. + +--- + +## Python 导入约定 + +各部署目录在启动时将 **仓库根** 加入 `sys.path`(与重构前相同): + +```python +_REPO_ROOT = os.path.dirname(BASE_DIR) # 或 Path(__file__).resolve().parent.parent +if _REPO_ROOT not in sys.path: + sys.path.insert(0, _REPO_ROOT) +``` + +之后使用 **`lib.<子包>.<模块>`** 形式导入,例如: + +```python +from lib.strategy.strategy_db import init_strategy_tables +from lib.key_monitor.key_monitor_lib import check_key_monitors +from lib.hub.hub_bridge import install_on_app +from lib.ai.ai_client import ai_review +``` + +策略注册仍在各所 `app.py` 末尾: + +```python +from lib.strategy.strategy_register import install_strategy_trading +from lib.strategy.strategy_trend_register import install_strategy_trend + +install_strategy_trading(app, _REPO_ROOT, app_module=sys.modules[__name__]) +install_strategy_trend(app, _REPO_ROOT, app_module=sys.modules[__name__]) +``` + +--- + +## 静态资源与 URL + +- 三所页面仍通过 **`/static/...`** 访问共用脚本;`hub_bridge.install_instance_theme_static` 从 `lib/common/static/` 提供部分根级静态路由. +- 各所目录下 **`static/`**(图标,上传图片等)仍为实例私有,未迁入 `lib/`. +- 中控 `manual_trading_hub/hub.py` 通过 `_REPO_ROOT / "lib" / "common" / "static"` 挂载与三所共用的 badge,复盘 JS 等. + +--- + +## 测试 + +在仓库根执行(需将根目录置于 Python 路径,或从根目录运行): + +```bash +cd /opt/crypto_monitor_user +python -m unittest discover -s tests -p "test_*.py" +``` + +测试文件内统一 `from lib.<子包>.<模块> import ...`.使用 `@patch` 时目标写完整模块路径,例如 `lib.hub.hub_calculator_lib._resolve_market`. + +--- + +## 迁移脚本 + +一次性迁移由 `scripts/migrate_to_lib.py` 完成(移动文件 + 批量改写 import).**不要在已迁移后的仓库上重复执行**. + +--- + +## 后续可选整理 + +- 三所 `app.py` 体量接近,可逐步抽取公共 `exchange_app` 基座(改动面大,单独规划). +- `manual_trading_hub/okx_orders_lib.py` 为 agent 本地副本,可与 `lib/exchange/okx_orders_lib.py` 合并去重. +- 可引入 `pyproject.toml` + `pip install -e .`,替代 `sys.path.insert`(长期维护更规范). + +--- + +## 相关文档 + +- [README.md](../README.md) — 总览与部署 +- [策略交易说明.md](../策略交易说明.md) +- [manual_trading_hub/使用说明.md](../manual_trading_hub/使用说明.md) diff --git a/docs/macro-calendar.md b/docs/macro-calendar.md new file mode 100644 index 0000000..2803ac1 --- /dev/null +++ b/docs/macro-calendar.md @@ -0,0 +1,83 @@ +# 宏观关键数据 · 风控前置 + +中控 **系统设置** 手动录入 FOMC / CPI / 就业数据发布时间,在 **监控区** 发布前后各 1 小时给出风险提示. +**不看公布结果,不解读数据**,仅作波动窗口前的行为提醒;**不拦截下单**(与账户冷静期/日冻结独立). + +## 支持的数据类型 + +| 类型 ID | 显示名称 | +|---------|----------| +| `fomc` | FOMC 联邦基金利率 | +| `cpi` | 美国 CPI 通胀 | +| `employment` | 就业与劳工数据 | + +每项在设置中 **名称下拉三选一**,**发布时间** 手动输入(北京时间,精确到分钟).FOMC 只录 **一条**(决议公布时刻即可). + +## 风险窗口 + +- 默认:**发布时间 ±1 小时** +- 发布前 **30 分钟内**:文案加强为「即将发布」 +- 窗口结束后横幅自动消失;设置列表中过期记录逐步不再展示 + +环境变量(可选): + +```env +HUB_MACRO_WINDOW_BEFORE_SEC=3600 +HUB_MACRO_WINDOW_AFTER_SEC=3600 +HUB_MACRO_IMMINENT_BEFORE_SEC=1800 +HUB_MACRO_LIST_FUTURE_DAYS=60 +``` + +## 监控区提示文案 + +读取当前监控板:**任意交易所有持仓 = 有仓**,否则 = 无仓. + +| 场景 | 提示要点 | +|------|----------| +| 无仓 · 窗口内 | 建议等待,避免新开仓 | +| 有仓 · 窗口内 | 注意仓位,勿加仓,检查止损/减仓 | +| 即将发布(30 分钟内) | 在上述基础上标注剩余分钟数 | + +## 存储 + +- SQLite:`manual_trading_hub/data/hub_macro_calendar.db` +- 可覆盖:`HUB_MACRO_CALENDAR_DB_PATH` + +表 `macro_events`:`event_type`, `event_at_ms`, `note`, `created_at_ms`, `updated_at_ms` +同类型 + 同一发布时间不可重复录入. + +## API(均需中控登录) + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/macro-calendar/meta` | 类型列表与窗口说明 | +| GET | `/api/macro-calendar/events` | 设置页列表 | +| GET | `/api/macro-calendar/active` | 当前处于窗口内的事件(监控横幅) | +| POST | `/api/macro-calendar/events` | 新增 | +| PATCH | `/api/macro-calendar/events/{id}` | 更新 | +| DELETE | `/api/macro-calendar/events/{id}` | 删除 | + +请求体示例: + +```json +{ + "event_type": "cpi", + "event_at": "2026-06-18 20:30", + "note": "可选备注" +} +``` + +## 使用习惯 + +1. 每月在金十/日历查看 **FOMC,CPI,非农** 公布时间 +2. 中控 **系统设置 → 宏观关键数据** 录入 1~3 条 +3. 到点前后监控区顶栏出现 **宏观风控** 横幅;无操作则窗口结束后自动消失 + +## 与账户风控的关系 + +| 模块 | 时机 | 作用 | +|------|------|------| +| 宏观日历 | **事前** | 已知高波动窗口,提醒等待或管仓 | +| 账户冷静期/日冻结 | **事后** | 用户主动平仓后的惩罚性限制 | + +宏观提醒 **不触发** 冷静期,不计入手动平仓次数. diff --git a/docs/manual-order-rr-preview.md b/docs/manual-order-rr-preview.md new file mode 100644 index 0000000..60c5acf --- /dev/null +++ b/docs/manual-order-rr-preview.md @@ -0,0 +1,31 @@ +# 实盘下单 · 预估盈亏比 + +## 功能 + +三所(Binance / OKX / Gate)**实盘下单监控**表单中,在「开仓」按钮前显示 **预估盈亏比**. + +- **价格模式**:填完币种,方向,止损价,止盈价后,调用 `GET /api/order_defaults` 取标记价,按几何距离计算 RR. +- **百分比模式**:填完币种,方向,止损%,止盈% 后拉快照校验币种,再显示 RR(`止盈% / 止损%`). +- **固定盈亏比模式**:盈亏比由输入框直接指定;下方预览条显示预估风险/盈利/盈亏比(不再在表单行内显示预估止盈价). + +- **以损定仓**(`POSITION_SIZING_MODE=risk`):预估风险 = 当前交易基数 × `risk%`. +- **全仓杠杆**(`full_margin`):预估风险 = 合约可用 × 缓冲比例 × 杠杆(BTC/ETH 与山寨按 `.env` 配置)× 止损距离比例,与开仓时 `calc_risk_amount_from_plan` 一致. + +## 前端实现 + +- 共享脚本:`static/manual_order_rr_preview.js` +- 各所 `templates/index.html` 引入并在 `MANUAL_MIN_PLANNED_RR` 定义后执行: + ```js + ManualOrderRrPreview.wire({ minRr: MANUAL_MIN_PLANNED_RR }); + ``` +- 展示元素:`#order-rr-preview`(开仓按钮左侧) +- 颜色:≥ 最低要求为绿色,低于为红色,无效/取价失败为红色或灰色 + +## 与提交校验 + +提交时仍走原有 `calcClientRr` / `calcClientRrFromPct` 与 `rejectManualOrderRr`;预估仅用于下单前参考,不替代服务端风控. + +## 校验记录 + +- `node --check static/manual_order_rr_preview.js` +- `tests/test_manual_order_rr_preview.py`:RR 公式与三所 `calc_rr_ratio` 口径一致 diff --git a/docs/position-sizing-mode.md b/docs/position-sizing-mode.md new file mode 100644 index 0000000..22a964f --- /dev/null +++ b/docs/position-sizing-mode.md @@ -0,0 +1,73 @@ +# 计仓模式(三所统一) + +## 配置 + +在各实例 `.env` 中设置(**仅能通过 env 切换,修改后须重启进程**): + +```env +# risk(默认)= 以损定仓 +# full_margin = 全仓杠杆(合约可用保证金 × 比例) +POSITION_SIZING_MODE=risk +FULL_MARGIN_BUFFER_RATIO=0.98 + +# 关键位程序自动单(默认 false,详见各所 关键位自动下单说明.md) +KEY_AUTO_ORDER_ENABLED=false +``` + +切换为全仓杠杆前:**交易所须无持仓**(`MAX_ACTIVE_POSITIONS` 默认 1,全仓模式会强制单仓). + +## 模式说明 + +| 模式 | 保证金计算 | 杠杆 | 允许入口 | +|------|------------|------|----------| +| `risk` | `RISK_PERCENT` × 交易资金,按止损距离反推 | 表单可选 / 同步交易所 | 实盘人工,关键位自动(须 `KEY_AUTO_ORDER_ENABLED=true`),趋势回调,顺势加仓 | +| `full_margin` | **合约账户可用 USDT × `FULL_MARGIN_BUFFER_RATIO`**(保留 2 位小数) | BTC/ETH **10x**,其它 **5x**(与 `BTC_LEVERAGE`/`ALT_LEVERAGE` 一致) | **实盘人工下单**,**关键位触价**(须 `KEY_AUTO_ORDER_ENABLED=true`);阻力/支撑仅提醒 | + +全仓模式下: + +- **`KEY_AUTO_ORDER_ENABLED=false`(默认)** 时,触价程序自动单也不执行. +- **`KEY_AUTO_ORDER_ENABLED=true`** 时,仅触价可程序自动开仓;箱体/斐波等仍禁止. +- 仍校验 **计划盈亏比**(实盘用 `MANUAL_MIN_PLANNED_RR`;触价开仓用 `KEY_AUTO_MIN_PLANNED_RR`). +- 下单张数由 `prepare_order_amount` + 交易所 `amount_to_precision` 决定. +- `order_monitors.initial_stop_loss` 仍记录**开仓时**止损快照;交易记录复盘以该快照为准. +- 已存在的 **箱体突破 / 收敛突破 / 斐波 / 假突破** 监控:进程启动时**自动撤销**并企业微信通知. + +## 不允许(全仓模式) + +- 关键位:箱体突破,收敛突破,斐波,假突破(添加时拒绝;已存在则启动时撤销). +- 趋势回调,顺势加仓(策略入口返回明确错误). + +**允许(须 `KEY_AUTO_ORDER_ENABLED=true`):** 关键位 **回调触价开仓** / **突破触价开仓**(程序盯价,触达/穿越计划入场后市价成交,无交易所挂单;全仓下仅允许一条待触发). + +## `KEY_AUTO_ORDER_ENABLED`(三所统一,默认 `false`) + +| 计仓 | 开关 | 效果 | +|------|------|------| +| `risk` | `false` | 关闭全部关键位程序自动单(含触价);顺势加仓不受影响 | +| `risk` | `true` | 关键位全套自动(旧行为) | +| `full_margin` | `false` | 关闭触价自动 | +| `full_margin` | `true` | 仅触价自动 | + +详见各实例目录 `关键位自动下单说明.md`. + +## 用脚本更新三所 `.env` + +详见 **[env-sync-scripts.md](./env-sync-scripts.md)**.常用命令: + +```bash +git pull + +# 仅补全计仓相关项(缺省 risk,缓冲 0.98) +python scripts/sync_four_exchange_position_sizing_env.py + +# 无仓后切换全仓 +python scripts/sync_four_exchange_position_sizing_env.py --set-mode full_margin + +# 无仓后切回以损定仓 +python scripts/sync_four_exchange_position_sizing_env.py --set-mode risk + +# 计仓 + 划转一并补全 +python scripts/sync_four_exchange_env.py + +pm2 restart crypto-monitor-binance crypto-monitor-okx crypto-monitor-gate +``` diff --git a/docs/shortcut-icon.md b/docs/shortcut-icon.md new file mode 100644 index 0000000..ca869cf --- /dev/null +++ b/docs/shortcut-icon.md @@ -0,0 +1,45 @@ +# Chrome 桌面快捷方式图标说明 + +## 图标从哪来? + +用 Chrome **「创建快捷方式」** 或 **「安装应用」** 时,桌面/开始菜单图标**不是**操作系统自带的,而是浏览器从**你打开的网站**读取的,优先级大致为: + +1. `manifest.webmanifest` 里的 `icons`(192×192,512×512) +2. `link rel="apple-touch-icon"`(约 180×180) +3. `link rel="icon"` / `favicon.ico` +4. 若都没有 → 灰色地球或网页标题首字 + +本仓库已在 **中控** 与 **三所监控页** 配置品牌图标.PNG/ICO 由 **Pillow** 生成. + +- **中控**:深色圆角底 + 青绿趋势线 + K 线,安装名「复盘系统中控」 +- **三所**:各所用交易所标识色与字标(币安菱形 / OKX 方块 / Gate G),安装名分别为「Binance 交易系统」「OKX 交易系统」「Gate 交易系统」 + +## 文件位置 + +| 位置 | 访问路径 | +|------|----------| +| 源稿 | `brand/icon.svg`,`brand/icons/*.png`,`brand/icons/{binance,okx,gate}/` | +| Manifest | `brand/manifest.webmanifest`(中控),`brand/manifest.{binance,okx,gate}.webmanifest` | +| 中控 | `manual_trading_hub/static/icons/` → `/assets/icons/...` | +| 三所 | `crypto_monitor_*/static/icons/` → `/static/icons/...` | + +## 重新生成 / 同步 + +```bash +python scripts/generate_brand_icons.py +python scripts/sync_brand_icons.py +git pull # 服务器部署后 +pm2 restart … +``` + +## 快捷方式仍显示旧图标? + +Chrome / Windows 会**缓存** favicon: + +1. 浏览器打开站点,**Ctrl+F5** 强刷 +2. 删除旧快捷方式,重新「创建快捷方式」 +3. 必要时清除 Chrome 站点数据(该域名)后再创建 + +## 自定义图标 + +可替换 `brand/icon.svg` 后重新运行上面两条命令;或把设计好的 `icon-192.png`,`icon-512.png` 放入 `brand/icons/` 再 `sync_brand_icons.py`. diff --git a/docs/strategy/README.md b/docs/strategy/README.md new file mode 100644 index 0000000..20f88b6 --- /dev/null +++ b/docs/strategy/README.md @@ -0,0 +1,34 @@ +# 策略文档 + +各交易实例的人工下单策略,供 UI / 复盘对齐. + +| 文档 | 实例 | 状态 | +|------|------|------| +| [binance-alt-trend-long.md](./binance-alt-trend-long.md) | 币安山寨·多头趋势 | v0.4 讨论稿 | +| [okx-trend-both.md](./okx-trend-both.md) | OKX·多空趋势 | v0.4 讨论稿 | +| [gate-intraday.md](./gate-intraday.md) | Gate·BTC 日内 | v0.2 | + +## 约定 + +- **不写盈亏比(趋势户)**:币安/OKX 止盈/止损随行情人工设定,趋势 MD 不量化 RR. +- **日内例外**:Gate 日内 **最低 1:1** 才开仓,持仓目标可动态调整(见 [gate-intraday.md](./gate-intraday.md)). +- **界面(趋势户 · v0.4)**:两级 — **反转**(启动 A/B)| **顺势**(大分歧 A/B)| **波段**(小分歧);`lib/trade/entry_model_lib.py` 三所共用. +- **界面短标签(日内户)**:`假破` / `结构突破`. +- **多空共用三字**:方向由「做多/做空」表达,不复用为「大分歧A多」等. +- **自动联动**:大分歧 A/B → 趋势单;**小分歧 → 波段单**;平仓写入交易记录 `entry_reason`;复盘「填入」自动带入. +- **杠杆默认**:BTC/ETH **10x**,其它 **5x**;与开仓类型无关(env `BTC_LEVERAGE` / `ALT_LEVERAGE`). +- **日内 profile 独立**:env 启用 `TRADE_SYMBOL_WHITELIST=BTC,ETH` 且限制开启时,**不显示** 大分歧三项(Gate);开仓类型为 **假破 / 结构突破**(见 gate-intraday.md). +- **日内 0 点出场**:策略称「0 点平仓」;程序为 `FORCE_CLOSE_ENABLED` + `FORCE_CLOSE_BJ_HOUR=0`,交易记录 `result=强制清仓`(与表单 1h/2h/4h `time_close` 无关). +- **策略模块独立**:趋势回调,顺势加仓不走上述三项. + +## 系统实现 + +- 库:`lib/trade/entry_model_lib.py` +- 趋势户表单:`lib/instance/templates/order_entry_model_fields.html` +- 日内判定:`is_intraday_trading_profile()`(白名单仅含 BTC/ETH) +- 0 点强平:`force_close_before_reset()`(三所 `app.py`);env `FORCE_CLOSE_ENABLED` / `FORCE_CLOSE_BJ_HOUR` + +## 相关文档 + +- [计仓模式](../position-sizing-mode.md) +- [趋势回调策略](../trend-pullback-strategy.md) diff --git a/docs/strategy/binance-alt-trend-long.md b/docs/strategy/binance-alt-trend-long.md new file mode 100644 index 0000000..95bad40 --- /dev/null +++ b/docs/strategy/binance-alt-trend-long.md @@ -0,0 +1,194 @@ +# 币安山寨·多头趋势账户 + +> **状态**:v0.4(反转·启动 A/B 两级 UI 已实现;策略正文 + `entry_model_lib`) + +--- + +## 1. 账户定位 + +| 项 | 说明 | +|----|------| +| 交易所 | 币安合约 | +| 方向 | **仅做多**(`TRADE_DIRECTION=long_only`) | +| 计仓 | `POSITION_SIZING_MODE=risk` | +| 关键位自动单 | `KEY_AUTO_ORDER_ENABLED=false` | +| UI profile | **趋势户**(非 BTC/ETH 白名单日内) | + +--- + +## 2. 开仓类型(两级 UI · 三所共用) + +### 2.1 趋势户:反转 / 顺势 / 波段 + +| 第一级 | 第二级 | code | 联动 | +|--------|--------|------|------| +| **反转** | 启动 A / 启动 B | `launch_a` / `launch_b` | 趋势单 | +| **顺势** | 大分歧 A / 大分歧 B | `big_div_a` / `big_div_b` | 趋势单 | +| **波段** | 小分歧 | `small_div` | 波段单 | + +实现:`lib/trade/entry_model_lib.py` + `order_entry_model_fields.html` + `order_entry_model.js`(币安 / OKX / Gate 趋势户共用). + +### 2.2 杠杆(与开仓类型无关) + +| 币种 | 默认杠杆 | +|------|----------| +| BTC,ETH | **10x**(`BTC_LEVERAGE`) | +| 其它山寨 | **5x**(`ALT_LEVERAGE`) | + +--- + +## 3. 反转·启动(做多,讨论定稿) + +**性质**:反转 — 在**新主升确认之前**的作战;与大分歧(顺势)不是同一行情阶段. + +**周期**:背离与箱体以 **4h** 为主;**日线与 4h 同处筑底阶段时,结构边界以日线为准**.作战后半段可用 **5m** 节奏(启动 A2). + +### 3.0 流程总览 + +```text +MACD 背离(严格筛选)→ 标「参考高点」 + ↓ +第 1 次到高点附近 → 不做(V 形) + ↓ +回落箱内(中间复杂形态不盯) + ↓ +第 2 次到高点附近 → 开战 + ├─ 见小收敛 ──────────→ 启动 A(A1) + └─ 不见收敛 ──────────→ 启动 B(实体突破) + │ + ├─ 成 → 主升(后续才用顺势·大分歧 / 波段·小分歧) + └─ 败 → 止损(正常) + ↓ + 跌破箱高一半 → 暂弃,等再次到高点附近 + 仍在上半区 + 5m 不创新低 + N 字 → 启动 A(A2) +``` + +### 3.1 背离:何时进入候选池(MACD · 4h) + +全部满足才承认「有过背离」: + +| 规则 | 说明 | +|------|------| +| 级别 | **至少 4h** MACD 底背离 | +| 首次背离不做 | 第一段背离只观察,不交易 | +| 通道式下跌不做 | 顺滑通道下滑中的背离,不当反转依据 | +| 波段结构 | 有明显的波段高点,低点 | +| 分段下跌 | **三段及以上**明显下跌之后,才开始寻找背离 | + +背离确认后:**标注最后一个显著高点为「参考高点」**(后文「高点附近」均相对此点). + +> 横盘**时长不量化**;关键是顺序:**跌 → 背离 → 背离后的震荡**,而非下跌中继. + +### 3.2 参考高点:两次摸高 + +| 次序 | 规则 | +|------|------| +| **第 1 次**到参考高点附近 | **不关注,不做**.假突破,未突破都算「到过附近」— 视为 **V 形反弹**风险区 | +| 回落箱内 | 箱内复杂形态**不是主战场**,不强行交易 | +| **第 2 次**到参考高点附近 | **开始关注**,进入作战区 | + +### 3.3 启动 B(实体突破) + +**条件**:第二次(及以后)靠近参考高点,且**未见**再次摸高前的小收敛. + +| 项 | 说明 | +|----|------| +| 入场 | **实体突破**参考高点 / 箱体上沿(影线刺破不算) | +| 成功 | 走出主升 → 后续单型转为顺势·大分歧 / 波段·小分歧 | +| 失败 | 突破后未主升,回到箱内震荡 → **止损属正常** → 若满足 §3.4,下一笔用 **启动 A(A2)** | + +### 3.4 启动 A(结构内 / 试仓,含 A1 与 A2) + +**性质**:主升**确认前**在箱内找风险可控入场;**不是**小分歧. + +#### A1 · 第二次摸高前的小收敛 + +- 在第 2 次到参考高点**之前**,出现**小的收敛结构** → 突破前企稳进场. +- 若**看不到**小收敛,不强行做 A1,改走 **启动 B**(§3.3). + +#### A2 · 启动 B 失败后的 5m N 字(仍记启动 A) + +在启动 B 止损后: + +| 暂弃 | 可试启动 A2 | +|------|-------------| +| 回箱后继续跌,且**跌破箱体高度一半** | **未**跌破箱一半 | +| 暂不看,直至**再次**到参考高点附近 | 且 **5m 不创新低** | +| | 且 **突破 5m 高点** → 按 **5m N 字形突破** 试仓 | + +- 5m 止损一般不大;此位置**允许多次试错**(突破路径约可错 2 次;5m 试仓约可错 3 次 — 同一参考高点周期内,具体计数实操自定). +- **5m N 字试仓不单列开仓类型**,复盘统一记 **启动 A**(可备注「A2 / 5m N」). + +### 3.5 与大分歧,小分歧的边界 + +| | 反转·启动 | 顺势·大分歧 | 波段·小分歧 | +|---|-----------|-------------|-------------| +| 前端 | 跌 → 背离 → 箱 → 两次摸高 | 主升已确立 | 主升已确立 | +| V 形 | 第 1 次摸高不做 | — | — | +| 5m N 试仓 | **启动 A(A2)** | — | 不同于小分歧 | +| 第三次不做 | — | — | ✓ | + +--- + +## 4. 顺势·大分歧(趋势单) + +**前提**:**主升浪已确立**,上方仍有空间;前端是主升里的整理,**不要**求「跌 → 背离 → 箱 → 两次摸高」那条反转链. + +### 4.1 大分歧 A + +- 4h/日线大结构向上 +- 5m/15m 收敛,**不创新低** 企稳进(不等突破) + +### 4.2 大分歧 B + +- 同上大级别多头结构 +- **突破确认** 后入场(实体突破优先) + +--- + +## 5. 波段·小分歧(波段单) + +**前提**:主升**已走出**;反转链(启动 A/B)进行中**不做**小分歧. + +- **前两次**可做,**第三次不做** +- 低吸为主,不追突破 +- 入场:二次探底 → N 字突破;或 5m 三均线重新多头排列 + +--- + +## 6. 纪律 + +1. 不做空 +2. 反转:首次背离不做;通道跌背离不做;第 1 次摸参考高点不做(V 形) +3. 顺势:第三次小分歧不做新单;小分歧不追突破 +4. 止盈/止损/是否手平:**随行情**,本文档不量化 RR + +--- + +## 7. 持仓与出场(定性) + +| 单型 | 说明 | +|------|------| +| 启动 A/B | 赌新主升;B 失败可转 A2;未确立主升前不做小分歧 | +| 大分歧 | 可长持;途中两次小分歧后远目标未到,**可手平** | +| 小分歧 | 短拿,常手平 | + +--- + +## 8. 系统字段 + +| 操作 | 字段 | +|------|------| +| 下单 | `order_monitors.entry_model`:`launch_a` / `launch_b` / `big_div_a` / `big_div_b` / `small_div` | +| 平仓 | `trade_records.entry_reason` = 界面标签 | +| 复盘 | 与开仓类型一致 + 策略项 +「其他」 | + +--- + +## 修订记录 + +| 版本 | 日期 | 说明 | +|------|------|------| +| v0.4 | 2026-07-06 | 反转·启动 A/B 两级 UI 上线(共用 entry_model_lib) | +| v0.2 | 2026-07-06 | 定稿 UI 短标签(大分歧/小分歧);实现代码联动 | +| v0.1 | 2026-07-06 | 讨论稿 | diff --git a/docs/strategy/checklists/binance.json b/docs/strategy/checklists/binance.json new file mode 100644 index 0000000..84560d9 --- /dev/null +++ b/docs/strategy/checklists/binance.json @@ -0,0 +1,68 @@ +{ + "exchange": "binance", + "title": "币安 · 开仓检查清单", + "version": "v0.4", + "groups": [ + { + "title": "账户与方向", + "items": [ + "本账户仅做多,不做空", + "计仓模式为以损定仓(risk),关键位自动单已关闭", + "已明确第一级:反转 / 顺势 / 波段(两级下拉已上线)" + ] + }, + { + "title": "反转 · 背离与箱体(启动 A/B 共同前置)", + "items": [ + "4h MACD 底背离(非首次背离,非通道式下跌中的背离)", + "有明显波段高/低点,且三段及以上明显下跌后才认背离", + "背离后处于震荡箱体(时长不量化;非 V 形急跌急拉)", + "已标注参考高点(最后一个显著高点)", + "第 1 次到参考高点附近 → 不做;第 2 次到附近 → 才进入作战区" + ] + }, + { + "title": "反转 · 启动 B(实体突破)", + "items": [ + "第 2 次到参考高点附近,且未见再次摸高前的小收敛", + "实体突破参考高点/箱体上沿(非仅影线)", + "突破失败回箱止损属正常;跌破箱高一半则暂弃直至再次到高点附近" + ] + }, + { + "title": "反转 · 启动 A(结构内,含 A1 / A2)", + "items": [ + "性质:主升确认前;不是小分歧", + "A1:第 2 次摸高前出现小收敛 → 突破前企稳", + "A2:启动 B 止损后,未跌破箱高一半 + 5m 不创新低 + 5m N 字突破试仓", + "5m 试仓记为启动 A(不单列类型);小止损允许多次试错" + ] + }, + { + "title": "顺势 · 大分歧 A / B", + "items": [ + "主升已确立,上方仍有空间(非跌后背离筑底阶段)", + "大分歧A:5m/15m 收敛且不创新低企稳", + "大分歧B:突破已确认,优先实体突破" + ] + }, + { + "title": "波段 · 小分歧", + "items": [ + "主升已确立;反转链进行中不做小分歧", + "第三次小分歧 → 不做新单", + "不追突破;二次探底 N 字或 5m 三均线重新多头" + ] + }, + { + "title": "杠杆与出场", + "items": [ + "杠杆:BTC/ETH 10x,其它山寨 5x(可选手改但须有理由)", + "止盈止损随行情人工设定,不在此清单量化 RR" + ] + } + ], + "footnotes": [ + "v0.4:两级 UI 已实现;启动 A 含 A1 收敛与 A2(B 失败后 5m N 字)." + ] +} diff --git a/docs/strategy/checklists/gate.json b/docs/strategy/checklists/gate.json new file mode 100644 index 0000000..01a0181 --- /dev/null +++ b/docs/strategy/checklists/gate.json @@ -0,0 +1,38 @@ +{ + "exchange": "gate", + "title": "Gate · BTC 日内 · 开仓检查清单", + "version": "v0.2", + "groups": [ + { + "title": "方向与均线过滤", + "items": [ + "仅交易 BTC,同时仅 1 仓", + "15m 21/55/144 排列清晰(多或空),纠缠则不做", + "1H 方向与 15m 不冲突", + "21 均线关系满足:回踩支撑 / 站稳上方(多)或反弹承压 / 压在下方(空)" + ] + }, + { + "title": "开仓类型 A / B", + "items": [ + "已选定:假破 或 结构突破(二选一)", + "假破:扫流动性后回到结构内,5m N 字 + 15m 顶/底分型齐全", + "结构突破:15m 收盘价站稳关键位,非仅刺破", + "止损带宽 0.4%~1.5%,超出则不做", + "下单前空间至少 1:1,不足则不做" + ] + }, + { + "title": "一日节奏与笔数", + "items": [ + "非周末;在早窗 / 晚窗计划时段内", + "今日笔数未达上限 3,连错未达 2 笔", + "宽幅震荡(S1)时降频或不做", + "0 点前须了结(系统强制清仓);本清单不含手动平仓" + ] + } + ], + "footnotes": [ + "系统:FORCE_CLOSE_ENABLED 开启时,北京时间 0 点自动强制清仓(result=强制清仓)" + ] +} diff --git a/docs/strategy/checklists/okx.json b/docs/strategy/checklists/okx.json new file mode 100644 index 0000000..7680011 --- /dev/null +++ b/docs/strategy/checklists/okx.json @@ -0,0 +1,72 @@ +{ + "exchange": "okx", + "title": "OKX · 开仓检查清单", + "version": "v0.4", + "groups": [ + { + "title": "账户与方向", + "items": [ + "已选定做多或做空,且与 4H/大级别结构方向一致", + "趋势户 profile(非 Gate 日内 BTC/ETH 白名单)", + "计仓模式为以损定仓(risk),关键位自动单已关闭", + "同一币种无未计划的对冲叠仓", + "已明确第一级:反转 / 顺势 / 波段(两级下拉已上线)" + ] + }, + { + "title": "反转 · 背离与箱体(启动 A/B 共同前置)", + "items": [ + "做多:4h MACD 底背离;做空:4h MACD 顶背离", + "非首次背离;非通道式涨跌中的背离;明显波段高低点 + 三段及以上涨/跌后才认背离", + "背离后处于震荡箱体(时长不量化;非 V 形急拉急杀)", + "做多:已标参考高点;做空:已标参考低点", + "第 1 次到参考极值附近 → 不做;第 2 次到附近 → 才进入作战区" + ] + }, + { + "title": "反转 · 启动 B(实体突破)", + "items": [ + "第 2 次到参考极值附近,且未见再次摸极值前的小收敛", + "做多:实体突破参考高点/箱顶;做空:实体跌破参考低点/箱底", + "突破失败回箱止损属正常", + "做多:跌破箱高一半暂弃;做空:涨破箱低一半暂弃;直至再次到极值附近" + ] + }, + { + "title": "反转 · 启动 A(结构内,含 A1 / A2)", + "items": [ + "性质:主趋势确认前;不是小分歧", + "A1:第 2 次摸极值前出现小收敛 → 突破前企稳", + "A2 做多:B 止损后未跌破箱一半 + 5m 不创新低 + 5m N 字", + "A2 做空:B 止损后未涨破箱一半 + 5m 不创新高 + 5m 倒 N 字", + "5m 试仓记为启动 A(不单列);小止损允许多次试错" + ] + }, + { + "title": "顺势 · 大分歧 A / B", + "items": [ + "主趋势已确立(非涨/跌后背离筑底/筑顶阶段)", + "做多大分歧A:5m/15m 收敛且不创新低;做空:不创新高", + "大分歧B:突破已确认,优先实体突破" + ] + }, + { + "title": "波段 · 小分歧", + "items": [ + "主趋势已确立;反转链进行中不做小分歧", + "第三次小分歧 → 不做新单", + "做多:二次探底 N 字 / 5m 三均线多头;做空:二次探顶倒 N / 5m 空头" + ] + }, + { + "title": "杠杆与出场", + "items": [ + "杠杆:BTC/ETH 10x,其它山寨 5x(可选手改但须有理由)", + "止盈止损随行情人工设定,不在此清单量化 RR" + ] + } + ], + "footnotes": [ + "v0.4:两级 UI 反转/顺势/波段;做多细则见 binance-alt-trend-long.md §3." + ] +} diff --git a/docs/strategy/gate-intraday.md b/docs/strategy/gate-intraday.md new file mode 100644 index 0000000..661a929 --- /dev/null +++ b/docs/strategy/gate-intraday.md @@ -0,0 +1,277 @@ +# Gate·BTC 日内账户 + +> **状态**:v0.2(策略定稿;**0 点强平已实现**;日内 UI 隐藏平仓/委托/移动保本 **待实现**) + +--- + +## 1. 账户定位 + +| 项 | 说明 | +|----|------| +| 交易所 | Gate 合约 | +| 品种 | **仅 BTC** | +| 方向 | **多空都做**(由过滤条件决定,非手选方向) | +| 计仓 | `POSITION_SIZING_MODE=full_margin`(全仓杠杆) | +| UI profile | **日内户**(`TRADE_SYMBOL_WHITELIST=BTC,ETH` 且限制开启;本策略只交易 BTC) | +| 与趋势户关系 | **不使用** 大分歧 A/B/小分歧;**不使用**「趋势单 / 波段单」手选 | + +### 资金与杠杆(执行约定) + +| 项 | 说明 | +|----|------| +| 账户规模 | 约 300U(测试阶段) | +| 日交易基数 | **50U**(早 8:00 重置为 50U,不延续前日阶梯) | +| 单笔阶梯 | 上一笔 **+10U / −10U** 调节下一笔基数(赢 60U / 亏 40U 等) | +| 杠杆 | **10× 全仓** | +| 一次一单 | 同时仅 **1** 个 Gate 仓位 | +| 止损带宽 | **0.4%~1.5%**(结构要求更宽则 **不做**) | + +--- + +## 2. 周期分层 + +自上而下,**先定能不能做,再做哪一类**: + +| 层级 | 周期 | 作用 | +|------|------|------| +| 方向过滤 | **1H** | 大方向;**不得与 15m 排列反向** | +| 均线 + 结构 | **15m** | 21/55/144 排列,顶底分型,结构识别,**B 类收盘突破** | +| 触发 | **5m** | **A 类**:N 字形突破(配合 15m 分型) | +| 方法 | 裸 K + 三均线 | 形态确认,入场与止损锚点 | + +**不做「趋势单」概念**:持仓以 **小时** 计,当日了结;与币安/OKX 多日趋势户区分. + +--- + +## 3. 方向过滤(必过) + +### 3.1 15m 三均线(21 / 55 / 144) + +| 15m 排列 | 只允许 | +|----------|--------| +| **多头排列**(21 > 55 > 144) | **只做多** | +| **空头排列**(21 < 55 < 144) | **只做空** | +| 纠缠,粘合,不符合 | **不做** | + +### 3.2 与 1H 同向 + +- **做多**:15m 多头排列,且 **1H 不得为空头排列**(1H 均线不能与 15m 方向相反). +- **做空**:15m 空头排列,且 **1H 不得为多头排列**. +- 1H/15m 方向冲突 → **当日该方向不做**. + +### 3.3 21 均线关系(才允许开仓) + +入场须与 **21 均线** 发生有效关系,避免 distant 追单: + +| 方向 | 要求(定性) | +|------|----------------| +| **做多** | 多头排列下,**回踩 21 附近获支撑** 或 **站稳 21 上方** 后再按 playbook 入场 | +| **做空** | 空头排列下,**反弹 21 附近承压** 或 **压在 21 下方** 后再按 playbook 入场 | + +--- + +## 4. 开仓类型(仅两类) + +界面日后仅两个短标签(全称见下表 hover / 本文): + +| 界面标签 | 存储 code(建议) | 本质 | +|----------|-------------------|------| +| **假破** | `liquidity_false_break` | 流动性扫单 → 假突破验证 → **5m N 字** → **15m 顶/底分型** | +| **结构突破** | `structure_breakout` | **15m 结构有效突破**(**收盘确认**) | + +子结构 **不单独占主下拉**,可在复盘备注或二级标签中记录. + +--- + +## 5. A 类:假破(流动性 / 假突破) + +**适用**:关键位附近 **扫止损** 后价格 **回到结构内**,陷阱确认后再反向做. + +### 5.1 流程 + +```text +1H/15m 方向 + 21 均线过滤通过 + → 假突破出现(扫高/扫低) + → 验证为「假」(收回结构内 / 反向裸 K 确认) + → 5m 走出 N 字(二次探底/探顶后,沿允许方向突破) + → 15m 出现底分型(多)或顶分型(空) + → 入场 +``` + +### 5.2 做多 / 做空(对称) + +| 步骤 | 做多 | 做空 | +|------|------|------| +| 假破 | 向下扫低后快速拉回支撑/箱上 | 向上扫高后跌回阻力/箱下 | +| 5m N 字 | 扫低 → 反弹 → 不破前低 → 向上突破 | 扫高 → 回落 → 不过前高 → 向下突破 | +| 15m 确认 | **底分型** | **顶分型** | +| 止损 | 假破极值或 N 字低点 **外侧**(仍须落在 0.4%~1.5%) | 对称 | +| 目标 | **最低 1:1**;之后 **随行情动态** 部分止盈,移动止损或延伸 | 对称 | + +### 5.3 注意 + +- **须等假破验证完成**,扫完不追. +- **5m N + 15m 分型** 为入场必要条件,缺一不可. +- 与大级别 **宽幅震荡(S1)** 叠加时假信号多,优先 **降频或不做**. + +--- + +## 6. B 类:结构突破 + +**适用**:15m 上结构清晰,方向与均线排列一致,**收盘突破** 后顺势做. + +### 6.1 子结构(均属 B 类) + +双顶,双底,头肩顶/底,收敛(三角/楔形),箱体等——**统一记为「结构突破」**. + +### 6.2 突破确认 + +- **以 15m K 线收盘价为准** 突破关键位(颈线,箱边,收敛边界等). +- **仅刺破,未收盘站稳** → **不算** 有效突破,不做. +- 可选:**收盘突破后回踩** 再进(裸 K 确认),仍须满足 21 均线关系与 **≥1:1** 空间. + +### 6.3 止损与目标 + +| 项 | 说明 | +|----|------| +| 止损 | 结构另一侧或突破位回退点 **外侧**(0.4%~1.5%,超出则不做) | +| 目标 | 下单前 **至少 1:1**;到位后 **随行情动态** 调整,不写死固定 RR | +| 空间不足 | 最近阻力/支撑导致 **达不到 1:1** → **不做** | + +--- + +## 7. 行情状态(辅助过滤) + +| 状态 | 特征 | Gate 动作 | +|------|------|-----------| +| **S0 趋势** | 1H/15m 排列清晰,高低点有序 | 正常:A/B 均可 | +| **S1 宽幅震荡** | 大箱横盘多日,均线反复穿 | **降频或不做** | +| **S2 末期/选边** | 贴边收敛,刚突破或假破频发 | 优先 **A 假破** 或 **B 收敛突破** | + +--- + +## 8. 一日节奏与笔数 + +| 项 | 规则 | +|----|------| +| 周末 | **不开新仓** | +| 早窗 | 约 **9:00**(8:00~12:00 内),**计划内第 1 笔** | +| 下午 | **默认不开新仓**(持仓可保留至晚窗) | +| 晚窗 | 约 **21:00**(20:00~23:00 内),**计划内第 2 笔** | +| 第 3 笔 | 仅当 **未连错 2 笔**,且 **早/晚有一笔为止损出场**,可 **补 1 笔** | +| 日上限 | **最多 3 笔** | +| **连错 2 笔** | **当日不再开新仓**(第 3 笔名额作废) | + +**连错计数**: + +| 出场 | 是否算「错 1 笔」 | +|------|------------------| +| **计划止损**触发 | ✅ 算 | +| **0 点强制清仓**(系统结果 `强制清仓`)且亏损 | ✅ 算 | +| 止盈 / ≥1:1 按计划平 | ❌ 不算 | +| 0 点强制清仓且盈利或平推 | ❌ 不算 | + +--- + +## 9. 出场与统计纪律 + +### 9.1 盈亏比 + +- 开仓前:**第一目标空间 ≥ 止损距离(最低 1:1)**. +- 持仓中:目标 **随行情动态** 调整;本文档 **不量化** 固定止盈比例. + +### 9.2 禁止「手动止损」 + +- **亏损出场** 必须来自 **开仓时设定的计划止损**(交易所或监控等价执行). +- **禁止** 盘中亏着 **手点平仓** 充当止损(破坏统计与连错规则). +- 若违规手动平亏:**视为当日纪律失败,建议停手**;复盘结果 **不得** 记为「止损」糊弄统计. + +### 9.3 时间出场:仅 0 点(程序已实现) + +- **唯一** 时间类出场:**当日 0:00(北京时间)前必须空仓**(赚赔都平). +- **不使用** 下单表单里的 1h / 2h / 4h「开仓后 N 小时平」(`time_close`);与本策略无关. +- **程序兜底**(三所共用,Gate 已启用): + +| env | 说明 | +|-----|------| +| `FORCE_CLOSE_ENABLED=true` | 开启整点强制清仓 | +| `FORCE_CLOSE_BJ_HOUR=0` | 北京时间 **0 点那一小时**(00:00~00:59)执行 | + +- 实现:`force_close_before_reset()`(各实例 `app.py` 后台循环调用). +- 行为:对该小时仍 **active** 的 `order_monitors` **市价全平**,取消交易所触发单,写交易记录. +- **系统结果字段**:`result = 强制清仓`;备注含「北京时间 0:00 整点风控清仓」. +- **策略口语「0 点平仓」= 系统「强制清仓」**,统计连错时按 §8 盈亏判定,不按字段名区分. + +> **与 `TRADING_DAY_RESET_HOUR=8` 无关**:后者只切 **交易日**(统计,8 点前禁开等),**不会**自动平仓. + +### 9.4 允许的出场类型(统计用) + +| 策略说法 | 系统 `result` | 说明 | +|----------|---------------|------| +| 止盈 | 止盈 / 移动止盈 / 保本止盈 等 | 计划止盈或 ≥1:1 后按计划/动态平 | +| 止损 | 止损 | 仅 **计划止损** 触发 | +| 0 点平仓 | **强制清仓** | 整点风控兜底(§9.3) | +| ~~手动平仓~~ | 手动平仓 | **策略禁止**(除极端技术故障等,须复盘说明) | + +--- + +## 10. A / B 如何选择(当日) + +| 盘面 | 优先 | +|------|------| +| 刚扫流动性,回到箱内 | **A 假破** | +| 结构清晰,排列已顺,收敛末端 | **B 结构突破** | +| 大箱乱扫,均线粘合 | **不做** | + +早/晚窗 **有形态才做**,无形态 = **0 笔**,不占额度. + +--- + +## 11. 与其它账户边界 + +| 账户 | 周期 | 持仓 | 本户勿混 | +|------|------|------|----------| +| 币安 | 日线/4H 事件 | 数天~数周 | 不要用 Gate 扛隔夜趋势 | +| OKX | 4H 波段滚仓 | 数小时~数天 | 勿与 Gate 同向同结构叠隔夜 | +| **Gate 日内** | 1H 过滤 + 15m/5m | **当日 0 点前** | 见上文 | + +--- + +## 12. 系统对接 + +### 12.1 已实现 + +| 项 | 说明 | +|----|------| +| 日内 profile 判定 | `is_intraday_trading_profile()`(`lib/trade/entry_model_lib.py`) | +| 0 点强制清仓 | `FORCE_CLOSE_ENABLED` + `FORCE_CLOSE_BJ_HOUR`;`force_close_before_reset()`;结果 **`强制清仓`** | +| UI 标识 | 顶栏 **强制清仓 已开启** 徽章 + 持仓卡片 **倒计时**(三所 + 中控) | +| 交易记录展示 | 三所 UI / 中控:`强制清仓` 与止损同类 badge | +| 三所统一 | 币安 / OKX / Gate 同一函数与 env;**将来改日内只需各所 `.env` 打开,无需改代码** | + +Gate 当前建议 env(节选): + +```env +FORCE_CLOSE_ENABLED=true +FORCE_CLOSE_BJ_HOUR=0 +TRADING_DAY_RESET_HOUR=8 +``` + +### 12.2 待实现(UI / 纪律) + +| 项 | 说明 | +|----|------| +| 开仓类型 | 界面 **`假破` / `结构突破`**(code:`liquidity_false_break` / `structure_breakout`);**无** trend/swing 手选 | +| 写入字段 | `trade_records.entry_model` / 复盘下拉同两项 | +| 隐藏操作 | 日内 profile 下 **隐藏** 平仓,委托,移动保本(**实例页 + 中控**,`intraday_discipline` / `order_entry_profile=intraday`) | +| 隐藏表单项 | 不展示 1h/2h/4h 时间平仓,移动保本勾选(避免与 §9.3 混用) | +| 后端可选 | 严格模式下拒绝 `del_order` / 改委托 API | + +--- + +## 13. 修订记录 + +| 版本 | 日期 | 说明 | +|------|------|------| +| v0.1 | 2026-07-06 | 定稿:BTC 日内;1H+15m 均线;A 假破(5m N+15m 分型);B 结构突破(15m 收盘);早1晚1/最多3笔/连错2停;禁手动止损;仅 0 点强平 | +| v0.2 | 2026-07-06 | §9.3/§12:对齐 `FORCE_CLOSE_*` 与系统结果「强制清仓」;区分 `time_close` / `TRADING_DAY_RESET_HOUR`;Gate 已启用说明 | diff --git a/docs/strategy/okx-trend-both.md b/docs/strategy/okx-trend-both.md new file mode 100644 index 0000000..c9b32ed --- /dev/null +++ b/docs/strategy/okx-trend-both.md @@ -0,0 +1,157 @@ +# OKX·多空趋势账户 + +> **状态**:v0.4(讨论稿:反转·启动 A/B;**UI 仍为 v0.2 三档**,两级下拉待实现) + +--- + +## 1. 账户定位 + +| 项 | 说明 | +|----|------| +| 交易所 | OKX 永续合约 | +| 方向 | **做多 + 做空**(`TRADE_DIRECTION=both`,可按需限制) | +| 计仓 | `POSITION_SIZING_MODE=risk` | +| 关键位自动单 | `KEY_AUTO_ORDER_ENABLED=false` | +| UI profile | **趋势户**(非 Gate BTC/ETH 日内白名单) | + +**选方向原则**:开仓前确认 4H / 日线大级别与所选「做多/做空」一致;逆势单不在本策略范围内. + +做多侧反转细则与 [binance-alt-trend-long.md §3](./binance-alt-trend-long.md) 同构;本文 **§3.2** 给出做空镜像. + +--- + +## 2. 开仓类型(规划与现状) + +### 2.1 规划:两级选择(与币安共用实现) + +| 第一级 | 第二级 | 做多 | 做空 | +|--------|--------|------|------| +| **反转** | 启动 A / 启动 B | `launch_a` / `launch_b` | 同 code,方向在表单 | +| **顺势** | 大分歧 A / B | `big_div_a` / `big_div_b` | 同左 | +| **波段** | 小分歧 | `small_div` | 同左 | + +`lib/trade/entry_model_lib.py` + 共用模板/JS,三所趋势户一致. + +### 2.2 现状:下单监控 UI + +两级下拉:**性质** → **类型**;提交 `entry_model` code. + +### 2.3 杠杆 + +BTC/ETH **10x**,其它 **5x**;与方向,开仓类型无关. + +--- + +## 3. 反转·启动(讨论定稿) + +**性质**:反转 — 在新一轮主趋势**确认之前**作战;与顺势·大分歧不是同一阶段. + +**周期**:背离与箱体以 **4h** 为主;**日线与 4h 同阶段时,结构边界以日线为准**;A2 可用 **5m** 节奏. + +### 3.1 做多(跌后筑底 → 新主升) + +与币安 [§3](./binance-alt-trend-long.md) 一致,摘要如下: + +```text +4h MACD 底背离(严格筛选)→ 标「参考高点」 +第 1 次到高点附近 → 不做(V 形) +回落箱内 → 第 2 次到高点附近 → 开战 + ├─ 见小收敛 → 启动 A(A1) + └─ 不见收敛 → 启动 B(实体突破) +B 失败回箱 → 跌破箱一半暂弃;否则 5m 不创新低 + N 字 → 启动 A(A2) +``` + +| 环节 | 规则 | +|------|------| +| 背离 | 4h MACD **底背离**;**首次**不做;**通道式下跌**不做;明显波段高低点 + **三段及以上**跌后才开始找背离 | +| 参考点 | 背离后标 **最后一个显著高点** | +| 两次摸高 | 第 1 次到高点附近(假破/不破都算)**不做**;第 2 次才关注 | +| 启动 B | 无小收敛 → **实体突破**参考高点/箱顶 | +| 启动 A | A1:摸高前小收敛;A2:B 止损后,**未跌破箱一半** + **5m 不创新低** + **5m N 字** | + +### 3.2 做空(涨后筑顶 → 新主跌,镜像) + +| 环节 | 做多 | 做空(镜像) | +|------|------|----------------| +| 前端结构 | **跌** → 背离 → 箱 | **涨** → 背离 → 箱 | +| 背离 | 4h MACD **底背离** | 4h MACD **顶背离** | +| 背离过滤 | 首次不做;**通道式下跌**不做;三段及以上**跌** | 首次不做;**通道式上涨**不做;三段及以上**涨** | +| 参考点 | **参考高点** | **参考低点**(最后一个显著低点) | +| 两次摸极值 | 第 1 次到**高点**附近不做(V 形反弹) | 第 1 次到**低点**附近不做(V 形下跌) | +| 作战区 | 第 2 次到**高点**附近 | 第 2 次到**低点**附近 | +| 启动 B | **实体跌破**参考低点/箱底 | 同上(向下实体突破) | +| 启动 A1 | 第二次摸高前**小收敛** | 第二次摸低前**小收敛** | +| 启动 A2 | B 失败后**未跌破**箱一半;**5m 不创新低**;破 **5m 高** N 字 | B 失败后**未涨破**箱一半;**5m 不创新高**;破 **5m 低**倒 N 字 | +| 成功后 | 主升 → 顺势大分歧 / 小分歧 | 主跌 → 顺势大分歧 / 小分歧 | + +> 横盘**时长不量化**;顺序为 **涨/跌 → 背离 → 背离后震荡**,非趋势中继. + +### 3.3 与顺势,波段的边界 + +| | 反转·启动 | 顺势·大分歧 | 波段·小分歧 | +|---|-----------|-------------|-------------| +| 做多前端 | 跌→背离→箱→两次摸高 | 主升已确立 | 主升已确立 | +| 做空前端 | 涨→背离→箱→两次摸低 | 主跌已确立 | 主跌已确立 | +| V 形 | 第 1 次摸极值不做 | — | — | +| 5m N 试仓 | **启动 A(A2)**,非小分歧 | — | — | +| 第三次不做 | — | — | ✓ | + +--- + +## 4. 顺势·大分歧(趋势单) + +**前提**:主趋势**已确立**;上方(多)或下方(空)仍有空间;**不要**求反转链(§3). + +| | 做多 | 做空 | +|---|------|------| +| **大分歧 A** | 4h/日线多头;5m/15m 收敛,**不创新低**企稳 | 4h/日线空头;5m/15m 收敛,**不创新高**企稳 | +| **大分歧 B** | **向上突破**确认(实体优先) | **向下突破**确认(实体优先) | + +--- + +## 5. 波段·小分歧(波段单) + +**前提**:主趋势**已走出**;反转链进行中**不做**小分歧. + +- **前两次**可做,**第三次不做**;不追突破 +- **做多**:二次探底 → N 字;或 5m 三均线重新多头 +- **做空**:二次探顶 → 倒 N;或 5m 三均线重新空头 + +--- + +## 6. 纪律 + +1. 方向与大级别一致;不做顺手反向单;同一币种避免未计划对冲叠仓 +2. **反转**:首次背离不做;通道式涨跌中的背离不做;第 1 次摸参考极值不做(V 形) +3. **顺势**:第三次小分歧不做;小分歧不追突破 +4. 止盈/止损/是否手平:**随行情**,本文档不量化 RR + +--- + +## 7. 持仓与出场(定性) + +| 单型 | 做多 | 做空 | +|------|------|------| +| 启动 A/B | 赌新主升;B 失败可 A2;确立前不做小分歧 | 赌新主跌;同上镜像 | +| 大分歧 | 可长持;两次小分歧后远目标未到可手平 | 同左 | +| 小分歧 | 短拿,常手平 | 短拿,常手平 | + +--- + +## 8. 系统字段 + +| 操作 | 字段 | +|------|------| +| 下单(现状) | `entry_model` + `trade_style`;方向在订单侧 | +| 下单(规划) | `launch_a` / `launch_b` + 两级 UI | +| 平仓 / 复盘 | `entry_reason` = 界面标签;与币安一致 | + +--- + +## 修订记录 + +| 版本 | 日期 | 说明 | +|------|------|------| +| v0.4 | 2026-07-06 | 反转·启动 A/B(做多同币安 §3;做空镜像);两级 UI 规划 | +| v0.3 | 2026-07-06 | 独立完整策略说明 | +| v0.2 | 2026-07-06 | 定稿 UI 短标签 | diff --git a/docs/trend-hub-close-and-trade-records.md b/docs/trend-hub-close-and-trade-records.md new file mode 100644 index 0000000..5a7f802 --- /dev/null +++ b/docs/trend-hub-close-and-trade-records.md @@ -0,0 +1,184 @@ +# 趋势回调:中控平仓与交易记录(检阅备忘) + +本文档汇总 **中控手动结束趋势计划**,**交易记录 / 策略记录** 写入规则,以及 **三所展示统一**,**补仓表计价** 相关修复,便于自行检阅与排错. + +适用仓库:`crypto_monitor`(Binance / OKX / + `manual_trading_hub`). + +--- + +## 1. 中控手动平仓会不会写交易记录? + +**会.** 在实例已部署 **`80226ee` 及之后** 代码并 **重启对应 Flask** 的前提下: + +中控点击 **「结束计划」** → 实例执行市价平仓 + 结束计划 → **同时写入**: + +| 目标 | 表 | 页面入口 | +|------|-----|----------| +| 策略记录 | `strategy_trade_snapshots` | 顶栏 **策略交易记录** → 左栏「趋势回调记录」 | +| 交易记录 | `trade_records` | 顶栏 **交易记录与复盘** | + +手动结束的结果字段为 **「手动平仓」**(亏损时也不会被改成「止损」). + +--- + +## 2. 调用链(三所统一) + +``` +manual_trading_hub + POST /api/trend/{exchange_id}/stop + → 实例 POST /api/hub/trend/stop/{plan_id} + → stop_trend_pullback(pid) + → 市价平仓 + 撤单 + → _finalize_plan(cfg, conn, row, "手动平仓", exit_price) +``` + +共用实现:`strategy_trend_register.py`(三所同一套,各所的 `stop_trend_pullback` 也调用 `_finalize_plan`). + +--- + +## 3. `_finalize_plan` 写入顺序(修复后) + +1. 写 **策略快照** `save_trend_plan_snapshot` → `strategy_trade_snapshots` +2. 撤该品种挂单 +3. 若尚无 `trade_records.trend_plan_id = 计划ID`: + - 更新当日 session 资金 + - **`insert_trade_record`** 写入交易记录 +4. 更新 `trend_pullback_plans.status`(`stopped_manual` / `stopped_sl` / `stopped_tp`) +5. **`conn.commit()`** 一次提交 + +要点:**先写交易记录,再结束计划**,避免「计划已结束,交易记录未写入」的半成功状态. + +--- + +## 4. 曾出现的 Bug(#4 ONDO 漏记) + +**现象**:策略记录有(止损 -2.71U),**交易记录没有**. + +**原因**:各所的 `insert_trade_record` 曾 **缺少 `entry_reason` 参数**,而 `_finalize_plan` 固定传入 `entry_reason="趋势回调"`,触发: + +```text +TypeError: insert_trade_record() got an unexpected keyword argument 'entry_reason' +``` + +策略快照在异常 **之前** 已插入,交易记录插入失败,故只出现在策略记录页. + +**修复提交**:`80226ee` + +- `insert_trade_record` 增加 `entry_reason` +- `_call_insert_trade_record`:按各所函数 **签名过滤** 参数,避免未知字段导致失败 +- 调整写入顺序:交易记录 → 计划结束 → commit + +--- + +## 5. 历史漏记补录 + +对已结束,策略快照在,交易记录缺的计划(如 #4): + +```bash +cd /opt/crypto_monitor_user # 或本机仓库根目录 + +# 先预览 +python scripts/backfill_trend_trade_records.py \ + --db crypto_monitor_gate/crypto.db --dry-run + +# 确认后写入 +python scripts/backfill_trend_trade_records.py \ + --db crypto_monitor_gate/crypto.db --apply +``` + +其它所将 `--db` 换成对应 `crypto.db` 路径即可. + +--- + +## 6. 与「保本移交」的区别 + +| 操作 | 策略记录 | 交易记录 | +|------|----------|----------| +| 中控 **结束计划**(手动平仓) | 计划结束时写入 | **同一时刻**写入 | +| **保本移交** | 移交时写入策略快照 | **不立即写**;持仓移交到 `order_monitors`,**后续平仓** 再写入 `trade_records` | + +--- + +## 7. 三所展示统一(中控 ↔ 实例) + +### 7.1 数据 enrich 入口 + +| 场景 | 函数 | +|------|------| +| 实例策略页 | `enrich_trend_plan` | +| 中控 `/api/hub/monitor` | `enrich_trend_plan_for_hub` → 同上 | +| 补仓明细表 | `attach_trend_dca_levels` → `enrich_trend_dca_levels_with_tp` | + +在 `hub_bridge` 安装后调用 `patch_trend_hub_enrich`,与另外三所 `install_strategy_trend` 行为一致. + +### 7.2 补仓表「触发价 / 加仓后均价」 + +**禁止**为凑均价 **反推虚构成交价**(曾错误出现做多补仓触发价 0.3941 等离谱数值). + +**`trend_leg_display_price`(三所唯一口径)**: + +| 列 | 规则 | +|----|------| +| **触发价** | `leg_fill_prices_json` 有记录 → 实际成交价;无记录 → **计划网格价** | +| **末档已补仓的加仓后均价** | 与顶部均价一致,取 **交易所持仓 `entry_price`**(`avg_entry_price`) | +| **顶部均价** | 优先交易所 live `entry_price`,非计划库内估算值 | + +修复提交:`08082eb`(移除反推成交价逻辑). + +### 7.3 中控静态页 + +`manual_trading_hub/static/app.js`:趋势浮盈亏计算 **优先** `trendPlan.avg_entry_price`,与计划卡一致. + +--- + +## 8. 部署与自检 + +### 8.1 升级 + +```bash +cd /opt/crypto_monitor_user +git pull # 需含 80226ee,08082eb +pm2 restart crypto-monitor-binance crypto-monitor-okx crypto-monitor-gate manual-trading-hub +pm2 save +``` + +### 8.2 手动平仓后自检 + +1. 中控结束一笔测试计划(或极小仓位) +2. **策略交易记录**:出现对应条目 +3. **交易记录与复盘**:出现 `类型=趋势回调`,`结果=手动平仓`,且 `trend_plan_id` 与计划 ID 一致 +4. 若实例 flash / 日志出现「计划已结束但记账可能不完整」,说明 `insert_trade_record` 仍失败,需查 PM2 日志 + +### 8.3 相关代码文件 + +| 文件 | 作用 | +|------|------| +| `strategy_trend_register.py` | `_finalize_plan`,`_call_insert_trade_record`,`enrich_trend_plan` | +| `strategy_trend_lib.py` | `trend_leg_display_price`,`enrich_trend_dca_levels_with_tp` | +| `strategy_snapshot_lib.py` | 策略快照写入 | +| `hub_bridge.py` | `/api/hub/trend/stop/` | +| `crypto_monitor_gate/app.py` | `insert_trade_record`(含 `entry_reason`) | +| `scripts/backfill_trend_trade_records.py` | 漏记交易记录补录 | + +### 8.4 相关提交 + +| 提交 | 说明 | +|------|------| +| `6a4ec69` | 中控与三所趋势展示 enrich 统一 | +| `08082eb` | 移除补仓表反推虚构成交价 | +| `80226ee` | 修复 中控平仓漏写 `trade_records` | + +--- + +## 9. 相关文档 + +| 文档 | 内容 | +|------|------| +| [策略交易说明.md](../策略交易说明.md) | 策略总览,策略交易记录页 | +| [crypto_monitor_gate/趋势回调策略说明.md](../crypto_monitor_gate/趋势回调策略说明.md) | 趋势回调业务细则 | +| [manual_trading_hub/使用说明.md](../manual_trading_hub/使用说明.md) | 中控监控与趋势卡布局 | +| [hub-symbol-archive-kline.md](./hub-symbol-archive-kline.md) | 币种档案,永久 5m K 线,交易 overlay | + +--- + +*最后整理:2026-06-07(与对话中修复项同步)* diff --git a/docs/trend-pullback-strategy.md b/docs/trend-pullback-strategy.md new file mode 100644 index 0000000..f27129c --- /dev/null +++ b/docs/trend-pullback-strategy.md @@ -0,0 +1,129 @@ +# 趋势回调策略说明 + +本文描述 **「趋势回调」** 自动交易计划的业务规则与实现口径. + +**三所主站**(Binance / Gate / OKX)均在顶栏 **策略交易 → `/strategy`** 左栏提供同一套逻辑(共用 `strategy_trend_register.py`);各所使用各自 API 与 `crypto.db`. + +**检阅备忘**(中控平仓,交易记录,补仓展示,漏记补录):[trend-hub-close-and-trade-records.md](./trend-hub-close-and-trade-records.md) + +--- + +## 1. 适用场景 + +- 各 **USDT 永续** 实例独立部署,使用各自 API 与 `crypto.db`. +- 你已明确:**方向,止损价,补仓区间边界价,止盈价,杠杆**,并接受程序按风险预算拆分 **首仓 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%** 空),仅当新止损 **优于** 当前止损时生效;**本次趋势计划随即结束**,持仓写入 **下单监控**(备注 **趋势回调计划**),交易所在 **同一时刻挂保本止损 + 计划止盈**;后续无论中控平仓或交易所手动平仓,均经下单监控轮询 **`reconcile_external_closes` / `check_order_monitors`** 写入 **交易记录**(含 `trend_plan_id`,开仓类型「趋势回调」),供人工核对. +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 自动上移) | **保本移交**(结束计划→下单监控;交易所 TP+SL;**无**自动 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/docs/ubuntu-server.md b/docs/ubuntu-server.md new file mode 100644 index 0000000..98e3af1 --- /dev/null +++ b/docs/ubuntu-server.md @@ -0,0 +1,178 @@ +# Ubuntu 服务器部署与环境说明 + +本文档为 **生产环境唯一推荐路径**:**Ubuntu**,**root** 用户,代码目录 **`/opt/crypto_monitor_user`**,进程托管 **PM2**.不使用 Windows 部署,不使用 systemd/screen/nohup 托管应用(SSH 隧道除外). + +--- + +## 1. 系统要求 + +| 项 | 要求 | +|----|------| +| 操作系统 | **Ubuntu 22.04 LTS** 或 **24.04 LTS**(64 位) | +| 运行用户 | **root**(下文命令均按 root 编写) | +| 项目路径 | **`/opt/crypto_monitor_user`**(整仓克隆到此目录) | +| 进程管理 | **PM2**(全局安装,见 §3) | +| 网络 | 能 `git clone` 私有仓库;访问交易所不稳定时需 **SSH SOCKS**(见各所《部署文档》) | + +--- + +## 2. Python 环境 + +| 项 | 说明 | +|----|------| +| **版本** | **Python 3.10 或 3.11**(`python3 --version` ≥ 3.10);脚本会拒绝 3.9 及以下 | +| **虚拟环境** | 每个子项目独立 **`.venv`**(`deploy/setup_env.sh` 自动创建) | +| **依赖文件** | 三所监控共用仓库根目录 **`requirements.txt`**;中控用 **`manual_trading_hub/requirements.txt`** | +| **SOCKS** | 走代理时必须安装 **PySocks**(已写入 requirements) | + +### 2.1 系统包(root) + +```bash +apt update +apt install -y python3 python3-pip python3-venv curl git ca-certificates +# 若 python3 为 3.10: +apt install -y python3.10-venv +# 若为 3.12: +apt install -y python3.12-venv +``` + +### 2.2 一键创建各目录 venv + +**推荐**(新服务器一条命令): + +```bash +curl -fsSL https://git.bz121.com/dekun/crypto_monitor_user/raw/branch/main/deploy/manage.sh | bash +# 菜单选 1) 一键部署 +``` + +或已 clone 后: + +```bash +cd /opt/crypto_monitor_user +bash deploy/manage.sh +# 或仅建环境: +bash deploy/setup_env.sh --install-system-deps +``` + +完成后各目录使用 **`.venv/bin/python`** 运行 `app.py` / `hub.py`;**PM2 的 ecosystem 脚本已指向该解释器**. + +--- + +## 3. Node.js 与 PM2 + +| 项 | 说明 | +|----|------| +| **Node.js** | 建议 **18 LTS** 或 **20 LTS**(用于安装 PM2;应用本体为 Python) | +| **PM2** | 全局安装,托管所有 Flask 与中控/子代理 | + +### 3.1 安装 Node + PM2(root) + +```bash +# 方式 A:NodeSource(示例 Node 20) +curl -fsSL https://deb.nodesource.com/setup_20.x | bash - +apt install -y nodejs +node -v # v20.x +npm -v + +npm install -g pm2 +pm2 -v +pm2 startup # 按提示执行,保证重启后 PM2 自启 +``` + +`deploy/setup_env.sh` 在检测到 Node 时也会尝试 `npm install -g pm2`(未装 Node 则跳过并提示手动安装). + +### 3.2 PM2 启动顺序(推荐) + +```bash +# 1) 三所 Flask(在各子目录执行,或分别 start) +cd /opt/crypto_monitor_user/crypto_monitor_binance && pm2 start ecosystem.config.cjs +cd /opt/crypto_monitor_user/crypto_monitor_gate && pm2 start ecosystem.config.cjs +cd /opt/crypto_monitor_user/crypto_monitor_okx && pm2 start ecosystem.config.cjs + +# 2) 中控 + 三子代理(一条配置 4 进程:hub + 3 agent) +cd /opt/crypto_monitor_user/manual_trading_hub +pm2 start ecosystem.config.cjs + +pm2 save +pm2 list +``` + +升级代码后: + +```bash +cd /opt/crypto_monitor_user && git pull +# 若 requirements 有变,对各目录 .venv/bin/pip install -r ... +pm2 restart all # 或按进程名 restart +``` + +**不要** 再用 systemd unit,screen,nohup 启动 `app.py` / `hub.py` / `agent.py`,避免与 PM2 抢端口. + +### 3.3 常见 PM2 进程名 + +| 目录 | ecosystem 内典型名称 | +|------|---------------------| +| `crypto_monitor_binance` | `crypto_binance` | +| `crypto_monitor_gate` | `crypto_gate` | +| `crypto_monitor_okx` | `crypto_okx` | +| `manual_trading_hub` | `manual-trading-hub`,`manual-agent-*` | + +以各目录 **`ecosystem.config.cjs`** 为准. + +### 3.4 整目录重装(清库 / 去脏 PM2) + +保留 `.env`,丢弃旧库与旧 PM2 名单时,见 **[deploy/reinstall-plan-b.md](../deploy/reinstall-plan-b.md)**: + +```bash +cd /opt/crypto_monitor_user +bash deploy/reinstall.sh --yes +``` + +首次安装仍只用 `deploy/setup_env.sh`,二者互不影响. + +--- + +## 4. 目录与权限 + +```bash +mkdir -p /opt +cd /opt +git clone https://git.bz121.com/dekun/crypto_monitor_user.git crypto_monitor_user +chown -R root:root /opt/crypto_monitor_user +``` + +- 数据库默认:各所 **`crypto.db`**(SQLite) +- 备份目录建议:**`/root/backups`**(见 [备份与恢复.md](../备份与恢复.md)) +- **`.env`**:仅本机编辑,**勿提交 Git**;升级前 `cp .env .env.backup.$(date +%Y%m%d)` + +--- + +## 5. SSH 动态转发(SOCKS) + +若交易所 API 需经境外 VPS: + +- 在本机用 **`ssh -N -D 127.0.0.1:1080 别名`** 建立隧道(配置见各所《部署文档》`~/.ssh/config`) +- 隧道进程可用 **tmux** 或 **autossh** 保持常驻;**不必** 也不建议把 `ssh` 交给 PM2 +- 各所 `.env` 设置对应 `*_SOCKS_PROXY=socks5h://127.0.0.1:1080` + +--- + +## 6. 部署后检查 + +```bash +# 中控验收(需已 start hub) +bash /opt/crypto_monitor_user/manual_trading_hub/scripts/verify_hub_deploy.sh + +pm2 logs manual-trading-hub --lines 50 +curl -sS http://127.0.0.1:5100/api/monitor/board | head +``` + +--- + +## 7. 相关文档 + +| 文档 | 内容 | +|------|------| +| [deploy/README.md](../deploy/README.md) | `manage.sh` 一键部署;`setup_env.sh` 参数 | +| [备份与恢复.md](../备份与恢复.md) | 数据库与 `.env` 备份 | +| 各 `crypto_monitor_*/部署文档.md` | 交易所 SOCKS,`.env`,PM2 细节 | +| [manual_trading_hub/部署文档.md](../manual_trading_hub/部署文档.md) | 中控 PM2,端口,反代 | diff --git a/docs/中控AI与密钥配置.md b/docs/中控AI与密钥配置.md new file mode 100644 index 0000000..23cf8a7 --- /dev/null +++ b/docs/中控AI与密钥配置.md @@ -0,0 +1,103 @@ +# 中控 AI 与部署密钥配置 + +本文档说明:**长期部署密钥**(一次生成,不轮换),**SSO 临时链接**(保持不动),以及 **AI 配置**(中控统一维护并同步三所). + +--- + +## 1. 三类「密钥」分工 + +| 类型 | 变量 / 机制 | 谁维护 | 是否自动过期 | +|------|-------------|--------|--------------| +| **长期通信密钥** | `HUB_BRIDGE_TOKEN` | 部署脚本首次写入四份 `.env` | 否,不轮换 | +| **实例 Session 签名** | `FLASK_SECRET_KEY` | 部署脚本首次写入三实例 | 否 | +| **中控 Session 签名** | `HUB_SESSION_SECRET` | 部署脚本首次写入中控 | 否 | +| **SSO 开门链接** | `/hub-sso?token=...` | 中控每次点「打开实例」签发 | 默认 2h + 单次 | +| **AI 配置** | `OPENAI_*`,`AI_*` 等 | 中控系统设置 → AI 配置 | 否 | + +**SSO 保持不动**:仍为随机 nonce,默认 `HUB_SSO_TTL_SEC=7200`,成功登录一次后链接作废.长期 `HUB_BRIDGE_TOKEN` 只用于签名,不会每 2 小时变化. + +--- + +## 2. 首次部署自动生成 + +`bash deploy/setup_env.sh` 在复制 `.env.example` 后自动执行: + +```bash +python3 scripts/bootstrap_deploy_secrets.py +``` + +| 写入项 | 位置 | 规则 | +|--------|------|------| +| `HUB_BRIDGE_TOKEN` | 中控 + 三实例(同值) | 仅空或占位符时写入 | +| `FLASK_SECRET_KEY` | 三实例(同值) | 仅空或占位符时写入 | +| `HUB_SESSION_SECRET` | 中控 | 仅空时写入 | +| `HUB_USERNAME` / `HUB_PASSWORD` | 中控 | 默认 admin / admin123(仅空时) | +| `APP_USERNAME` / `APP_PASSWORD` | 三实例 | 默认 admin / admin123(仅空时) | + +**已有非空生产值不会被覆盖**(一次生成,不轮换). + +子代理 `agent.py` 优先读取 `HUB_BRIDGE_TOKEN` 作为 `X-Control-Token` 校验;独立配置 `CONTROL_TOKEN` 已废弃. + +--- + +## 3. 中控系统设置 → AI 配置 + +路径:**中控 Web → 系统设置 → AI 配置** Tab. + +### 3.1 可配置项 + +| 中文名 | 环境变量 | +|--------|----------| +| AI 提供方 | `AI_PROVIDER` | +| API 地址 | `OPENAI_API_BASE` | +| API 密钥 | `OPENAI_API_KEY`(掩码,留空不修改) | +| 云端模型 | `OPENAI_MODEL` | +| Ollama 地址 | `OLLAMA_API` | +| Ollama 模型 | `AI_MODEL` | +| 请求超时(秒) | `AI_TIMEOUT_SECONDS` | + +### 3.2 保存行为 + +1. 写入 `manual_trading_hub/.env` +2. **强制同步** 至 `crypto_monitor_okx/binance/gate/.env` 相同键 +3. 自动 `pm2 restart` 中控 + 三实例(`--update-env`) + +### 3.3 API(需已登录中控) + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/settings/ai-env` | 读取字段与三所同步状态 | +| POST | `/api/settings/ai-env` | body: `{ "values": {...}, "restart": true }` | + +实现:`lib/env/shared_env_lib.py`,`manual_trading_hub/hub_env_lib.py`. + +--- + +## 4. 实例 env 配置页变更 + +三所 **env 配置** 页已 **移除「AI 复盘」卡片**.交易所 API,企业微信,交易执行等仍各所自配. + +实例侧若通过 API 提交已移除的 AI 键,会被白名单过滤,不会写入. + +--- + +## 5. 与系统设置 / env 页对照 + +| 能力 | 实例 env | 实例系统设置 | 中控系统设置 | +|------|----------|--------------|--------------| +| 交易所 API | ✅ | ❌ | ❌ | +| OpenAI / AI | ❌ | ❌ | ✅(同步三所) | +| 实例登录密码 | ❌ | ✅ | ❌ | +| 中控登录密码 | ❌ | ❌ | ✅ | +| Bridge / Flask 长期密钥 | ❌(自动) | ❌ | ❌(自动) | +| SSO 链接 | — | — | 每次打开实例自动签发 | + +--- + +## 6. 运维提示 + +- 修改 AI 后若未自动重启成功,手动:`pm2 restart manual-trading-hub crypto_okx crypto_binance crypto_gate --update-env` +- 三所 AI 不一致时,中控 AI 配置页会提示「未完全同步」;点 **保存并同步** 即可对齐 +- 备份包可选包含各 `.env`(中控设置 → 备份恢复) + +相关文档:[env配置说明.md](./env配置说明.md),[系统设置说明.md](./系统设置说明.md),[manual_trading_hub/局域网与反代部署说明.md](../manual_trading_hub/局域网与反代部署说明.md)(SSO 2h 说明) diff --git a/docs/对冲计划开发方案.md b/docs/对冲计划开发方案.md new file mode 100644 index 0000000..b8d2e39 --- /dev/null +++ b/docs/对冲计划开发方案.md @@ -0,0 +1,678 @@ +# OKX 对冲计划 — 开发方案 + +> 状态:**方案冻结**(实现前对照本页;改需求先改本文). +> 范围:**仅 `crypto_monitor_okx` 实例**;中控不做对冲开平. +> 相关策略说明:[期权对冲方案分析.md](./期权对冲方案分析.md) + +--- + +## 1. 目标与命名 + +在 OKX 实例增加独立模块 **「对冲计划」**,把「行情选腿 → 情景测算 →(条件满足时)自动开仓 → 规则退出 → 独立复盘统计」串成一套可状态跟踪的计划. + +| 产品名 | 英文键 | 含义 | +|--------|--------|------| +| **永期对冲** | `perp_options` | 永续(子账户) + 买方期权(主账户) | +| **期期对冲** | `options_options` | 主账户内两条买方期权腿 | + +页面/导航展示用中文名;API/DB 用英文键. + +**不做(本方案外):** + +- 卖方期权、组合单原子成交 +- 中控代下单 +- 跨所对冲 +- 替代现有关键位/策略/期权页(可共存,但同标的限制见 §9) + +--- + +## 2. 计仓模式门禁 + +沿用 `[docs/position-sizing-mode.md](./position-sizing-mode.md)`: + +| `POSITION_SIZING_MODE` | 永期对冲 | 期期对冲 | +|------------------------|----------|----------| +| `risk`(以损定仓 / **非全仓**) | **仅测算**(左右行情 + 情景表);**禁止开仓/启动计划** | **允许开仓**(测算 + 启动计划) | +| `full_margin`(全仓杠杆) | **允许开仓** | **允许开仓** | + +说明: + +- **永期**依赖永续全仓名义与保证金节奏,故开仓仅限全仓. +- **期期**不占永续保证金,非全仓也允许开期权腿;非全仓下 UI 隐藏/禁用「启动永期」,仍可做永期**只读测算**. +- 切模式后若存在 `active` 永期计划,禁止切到 `risk`,或强制要求先结束计划(实现时二选一并写死校验). + +额外硬门: + +- 须 `OKX_OPTIONS_ENABLED=true` 且期权 API 可用. +- 实际开仓须实例允许实盘(`LIVE_ORDER` 等与永续/期权现有开关一致),并对冲计划自有开关见 §10. + +--- + +## 3. 永期对冲 — 仓位与选期权 + +### 3.1 永续开仓量(全仓) + +与现网全仓逻辑一致,对冲计划固定用于 **BTC / ETH**: + +``` +可用保证金 = 合约账户可用 USDT +占用保证金 = 可用 × FULL_MARGIN_BUFFER_RATIO(默认 0.98) +杠杆 = BTC_LEVERAGE / ETH 对应杠杆(默认 10x) +名义 ≈ 占用保证金 × 杠杆 +张数 = amount_to_precision(名义 / 价格 / 合约面值) +``` + +页面左侧展示:**建议张数、名义、止损亏损额、止盈盈利额**(用户填止盈价/止损价后按该仓位即时算). + +用户流程: + +1. 选标的 ETH/BTC、方向(多/空). +2. 系统按全仓 ×0.98×10x 算出永续张数与到止盈/止损的 U 盈亏. +3. **再据此挑选右侧期权**(保费、张数、行权价),使「止损时保险腿」与「止盈保费损耗」可接受. +4. 通过情景表确认后启动计划. + +### 3.2 期权腿选取 + +- 行情自动拉 OKX 期权链(复用 `build_option_chain`). +- **报价形态:列表式**;多仓默认筛 **Put**,空仓默认筛 **Call**. +- 权利金默认按 **卖一 ask** 估算;开仓限价买入. + +### 3.3 左右布局 + +``` +左:永续列表行情(标记/买卖一) + 方向/建议张数/开仓价/止盈/止损 +右:期权列表(可「选用」一条腿) +下:情景测算 → [保存草稿] [启动计划] +``` + +--- + +## 4. 期期对冲 — 仓位与选腿 + +### 4.1 报价与选腿 + +- **T 型报价链**(复用期权页 T 型样式/数据结构). +- 用户选 **腿 A + 腿 B**(通常 Call + Put,或主方向 + 尾部). +- 预算受 `OKX_OPTIONS_TRADE_BUDGET_USDC` 等既有约束;可拆预算到两腿. + +### 4.2 目标价 + +用户填 **预判价格 S\*** (「价格能到的位置」): + +- 系统标明在 S\* 时哪条腿为 **盈利方**、哪条为 **亏损方**. +- 到达规则见 §5.2. + +### 4.3 左右布局 + +``` +左:指数价 + 到期日 + 预算 + 目标价 S* +右:T 型链,依次选用两腿 +下:情景测算 → [保存草稿] [启动计划] +``` + +非全仓模式下期期布局同上(无永续区). + +--- + +## 5. 退出规则(冻结) + +### 5.1 永期对冲 + +| 事件 | 永续 | 期权 | 计划是否结束 | 设计意图 | +|------|------|------|--------------|----------| +| **永续止盈触发** | 交易所 TP 平仓 | **不强制平**(保险腿可自生自灭/人工) | **算结束** | 对冲计划以永续兑现目标收口 | +| **永续止损触发** | 交易所 SL 平仓 | **必须强制平仓** | **算结束** | 保护机制 | +| 期权单独到期 | — | 结算 | 若计划已因止盈结束则只更新腿快照,不再改计划合计 | | +| 人工结束计划 | 可选平永续 | 按选项 | **算结束** | | + +**计划结束时盈亏口径(写入 `realized_pnl_*`,推送与统计共用):** + +| 结束原因 | 公式(≈U,1:1) | 字段落库 | +|----------|--------------|----------| +| **止盈** `perp_tp` | **永续止盈已实现盈利 − 期权已付权利金** | `realized_pnl_perp` = 止盈盈利;`realized_pnl_options` = **−premium_total**(按权利金全额计成本,不论期权是否仍持仓);`realized_pnl_total` = 上两式之和 | +| **止损** `perp_sl` | **期权平仓盈利 − 永续止损亏损额** | `realized_pnl_options` = 期权强制平后已实现;`realized_pnl_perp` = 永续止损已实现(为负或记亏损额);`realized_pnl_total` = 期权盈利 − \|永续亏损\|(即有符号相加) | + +说明: + +- 止盈时期权**物理上可不平**,但 **计划账** 已按「保费打掉」收口,后续期权 thrift/到期盈亏 **不再回写计划合计**(可在腿上另记备注/浮盈,不进 `realized_pnl_total`). +- 止损时期权必须先强平再结账,用真实平仓盈亏,不是只扣权利金. + +永续侧 TP/SL:沿用实例 **交易所条件单**,监控识别成交后触发计划结束逻辑 + 微信推送. + +### 5.2 期期对冲 + +| 事件 | 盈利方 | 亏损方 | 计划是否结束 | +|------|--------|--------|--------------| +| **标的价到达用户目标价 S\*** | **自动平仓** | **不平**,持有至到期 | 平盈利腿后计划可标 `closing`;**全部腿终态后结束**(亏损腿到期后结账) | +| **到期且整体无盈利** | — | 到期结算 | **算结束**;合计记 **总亏损**(通常 ≈ −全部权利金,或到期结算净值 < 0 的合计) | +| 到期时组合合计仍盈利 | — | 到期结算 | **算结束**;按实际结算盈亏入账 | +| 未达 S\* 至到期 | 两腿均到期 | | 同上,按结算合计结束 | + +判定「整体无盈利」:到期(或计划收口)时 `realized_pnl_total ≤ 0`(含双腿权利金全损). + +盈利方判定规则仍按前文(触达 S\* 时按浮盈较大一侧平仓;皆亏则等到期). + +### 5.2.1 期权腿实盘平仓执行(与期权页共用) + +对冲计划凡**必须物理平掉期权腿**时(如永期止损联动、期期平盈利腿),执行口径与独立期权模块一致: + +| 规则 | 说明 | +|------|------| +| 禁市价 | 代码硬关闭,无市价兜底 | +| 只锁买一 | 本轮 `min(仓位, 买一深度)` × 买一限价;`reduceOnly` | +| 分批 | 买一不够则剩余下一轮再平再锁新买一 | +| 有效流动性 | 残档买一禁止按买盘平 | +| 2× 门控 | 目标位/自动类路径首次需可回收≥2×权利金并持续 hold;手动买一平只验流动性 | +| 平仓挂单 TTL | 卖出限价超 `OKX_OPTIONS_PENDING_TTL_SECONDS`(默认 10 分钟)自动撤;UI「委托」可见 | + +完整说明(可单独打开):**[期权开平仓与监控说明.md](./期权开平仓与监控说明.md)** · 线上 `/options/guide`. + +--- + +### 5.3 企业微信推送(起止必发) + +| 时机 | 是否必发 | 内容要点 | +|------|----------|----------| +| **计划开始**(开仓成功 → `active`) | **必发** | 类型(永期/期期)、标的方向、关键价位、张数/保费、计划 id | +| **计划结束** | **必发** | 结束原因、`realized_pnl_total`、分项(永续/期权)、是否止盈/止损/到期亏损 | +| 半腿失败 / 强平失败 | 必发告警 | 便于人工介入 | +| 目标价平掉盈利腿(期期中间态) | 建议发 | 注明亏损腿仍持有 | + +结束推送触发点与「算结束」一致:永期止盈、永期止损、期期到期收口(含无盈利总亏)、人工结束等. + +--- + +## 6. 情景测算(开仓前必显) + +### 6.1 永期 + +| 情景 | 含义 | +|------|------| +| 止盈 | 永续到 TP 的盈利 − 期权保费(期权按不强制平时的损耗估算) | +| 宽止损 | 永续到 SL 的亏损 + 期权平仓估值(强制平,用 mark/买一估算) | +| 到期横盘 | 永续≈0 + 期权权利金全损 | +| 价格扫描 | index ± 若干档合计 | + +核心输出:**宽止损合计亏损**(人工评估是否开仓). + +### 6.2 期期 + +| 情景 | 含义 | +|------|------| +| 到达 S\* | 盈利腿兑现估值 − 已付总保费中亏损腿残留 | +| 到期横盘 | 双腿权利金近似全损 | +| 到期大涨/大跌 | 结算内在价值 | + +--- + +## 7. 数据来源(行情自动) + +| 侧 | 来源 | +|----|------| +| 永续行情/规格 | OKX 子账户 ccxt:ticker + 现有 `/api/hub/market` 规格逻辑 | +| 期权链 | `build_option_chain` / `/api/options/chain`(本实例直连,无需中控代理) | +| 指数价 | 期权 `index_px`,左右对齐 | + +报价刷新:页面手动刷新 + 计划编辑态可选 10~30s 自动刷新. + +--- + +## 8. 自动开仓编排 + +### 8.1 永期(仅全仓) + +建议默认顺序:**先期权、后永续**(期权失败成本低;永续失败则提示处理刚开的期权). + +``` +校验 full_margin + LIVE + 无冲突计划 +→ 期权限价买入(卖一) +→ 永续全仓张数市价开仓 + 挂交易所 TP/SL +→ 写 hedge_plans / legs,状态 active +``` + +部分失败补偿(最小集): + +| 情况 | 动作 | +|------|------| +| 期权成、永续败 | 告警;建议自动平期权(可配置)或转人工 | +| 永续成、期权败 | 告警;可选撤永续或重试期权;计划标 `partial` | + +### 8.2 期期(全仓与非全仓均可开) + +``` +校验 LIVE + 期权资金 +→ 腿1 限价买 +→ 腿2 限价买 +→ active;记录 target_price S* +``` + +两腿间勿留长时间单腿敞口;第二腿失败则标 `partial` 并告警. + +--- + +## 9. 状态机与冲突 + +``` +draft → opening → active → closing → closed + ↘ partial / failed +draft → cancelled +``` + +冲突规则: + +- **同标的同时至多 1 条 active 永期计划**(全局可 `MAX_ACTIVE_HEDGE_PLANS`). +- 永期 active 时与全仓「单仓」一致:**不与额外永续仓并存**(启动前校验无其它持仓,或本计划即为该仓). +- 期权页对手动平「计划绑定 inst」应提示归属对冲计划. + +--- + +## 10. 历史记录、统计与复盘(独立) + +**入口:** OKX 顶栏 **「对冲计划」** 页内三个 Tab(不单开顶栏项): + +| Tab | 路由建议 | 内容 | +|-----|----------|------| +| **计划** | `/hedge-plan` | 新建 / 草稿 / 进行中 | +| **历史** | `/hedge-plan?tab=history` | 已结束与取消的计划列表 + 详情复盘 | +| **统计** | `/hedge-plan?tab=stats` | 独立统计看板 | + +**不得**并入普通「交易记录与复盘」`/records`、全站「统计分析」`/stats`、策略交易记录. +腿可可选关联 `options_trades` / 监控 id,但 **计划合计盈亏与胜率只读本模块表**. + +币种展示约定:永续腿 USDT、期权腿 USDC;合计列标注 **「≈U(1:1)」**,不做实时汇率换算. + +--- + +### 10.1 数据落库 + +库文件:OKX 实例 `crypto.db`(与其它表同库). + +#### `hedge_plans`(一条计划) + +| 字段 | 类型建议 | 说明 | +|------|----------|------| +| `id` | INTEGER PK | | +| `plan_type` | TEXT | `perp_options` 永期 / `options_options` 期期 | +| `status` | TEXT | draft / opening / active / closing / closed / partial / failed / cancelled | +| `underlying` | TEXT | BTC / ETH | +| `direction` | TEXT | 永期:long/short;期期可空 | +| `entry_mark` | REAL | 开仓参考价(标记/指数快照) | +| `tp` | REAL | 永期止盈价;可空 | +| `sl` | REAL | 永期止损价;可空 | +| `target_price` | REAL | 期期目标价 S\*;可空 | +| `sizing_mode_at_open` | TEXT | 开仓时 `risk`/`full_margin` 快照 | +| `perp_size` | REAL | 永期张数/币量快照;期期空 | +| `margin` | REAL | 占用保证金快照 | +| `leverage` | REAL | 杠杆快照 | +| `premium_total` | REAL | 期权已付权利金合计(USDC) | +| `realized_pnl_perp` | REAL | 永续已实现(USDT);止盈为正,止损为负 | +| `realized_pnl_options` | REAL | 期权账:止盈场景记 **−权利金**;止损场景记 **强平真实盈亏** | +| `realized_pnl_total` | REAL | 见 §5.1 / §5.2 公式;统计与微信共用此值 | +| `stats_bucket` | TEXT | 可选冗余:`tp` / `sl` / `oo_expiry_loss` / `oo_target` / `other` 便于统计筛选 | +| `close_reason` | TEXT | 见下表枚举 | +| `wechat_start_sent` | INTEGER | 开仓推送是否已发 | +| `wechat_end_sent` | INTEGER | 结束推送是否已发 | +| `note` | TEXT | 人工复盘短评 | +| `created_at` | TEXT | | +| `opened_at` | TEXT | 首次腿成交时间 | +| `closed_at` | TEXT | **计划结束时间**(止盈/止损/到期收口等) | +| `preview_json` | TEXT | 开仓前情景测算快照(可选) | + +**`close_reason` 枚举** + +| 值 | 含义 | 是否算计划结束 | 合计口径 | +|----|------|----------------|----------| +| `perp_tp` | 永续止盈 | **是**(立刻 closed) | **止盈盈利 − 权利金** | +| `perp_sl` | 永续止损 + 期权强制平 | **是** | **期权盈利 − 永续亏损** | +| `target_win_leg` | 期期已平盈利腿(中间态可暂不 closed) | 腿未齐前可不结束 | 待亏损腿到期后定合计 | +| `oo_expiry_loss` | 期期到期且合计无盈利 | **是** | **总亏损**(settled ≤ 0,常 ≈ −保费) | +| `oo_expiry_win` | 期期到期合计仍盈利 | **是** | 实际到期合计 | +| `expiry` | 其它到期收口 | **是** | 实际结算 | +| `manual` | 人工结束 | **是** | 按当时已实现 | +| `partial_fail` | 半腿失败收尾 | **是** | 按补偿结果 | +| `cancelled` | 未真正开仓取消 | 是(无盈亏) | 0 | + +说明:止盈结束时期权腿可标 `hold_to_expiry`/`orphaned_after_tp`,**计划已 closed**,后续期权盈亏不回写 `realized_pnl_total`. + +#### `hedge_plan_legs`(一条腿) + +| 字段 | 类型建议 | 说明 | +|------|----------|------| +| `id` | INTEGER PK | | +| `plan_id` | INTEGER FK | | +| `leg_role` | TEXT | `perp` / `option_hedge` / `option_a` / `option_b` | +| `symbol` 或 `inst_id` | TEXT | 永续符号或期权合约 id | +| `opt_type` | TEXT | C/P;永续空 | +| `strike` | REAL | 期权行权价 | +| `side` | TEXT | long/short 或 buy | +| `size` | REAL | 张数或币量 | +| `avg_open` | REAL | 开仓均价/权利金单价 | +| `premium` | REAL | 该腿已付权利金(期权) | +| `status` | TEXT | open / closed / hold_to_expiry | +| `linked_monitor_id` | INTEGER | 可选,永续监控 | +| `options_trade_id` | INTEGER | 可选,期权成交表 | +| `realized_pnl` | REAL | 该腿已实现 | +| `close_reason` | TEXT | 腿级原因 | +| `opened_at` / `closed_at` | TEXT | | + +--- + +### 10.2 历史列表(列定义) + +筛选:**类型**(全部/永期/期期)、**状态**、**标的**、**日期**(按 `opened_at` 或 `closed_at`). + +| 列 | 来源 | 展示 | +|----|------|------| +| ID | id | `#12` | +| 类型 | plan_type | 永期对冲 / 期期对冲 | +| 标的 | underlying + direction | 如 `ETH 多` / `ETH 双买` | +| 状态 | status | 中文标签 | +| 开仓时间 | opened_at | | +| 结束时间 | closed_at | 进行中显示 — | +| 平仓原因 | close_reason | 中文(止盈离场/止损联动平/目标价平盈利腿/到期…) | +| 保费 | premium_total | `x.xx USDC` | +| 合计盈亏 | realized_pnl_total | 着色 +/- ,单位 ≈U | +| 腿摘要 | legs | 如 `永续✓ · Put持仓` / `Call已平 · Put到期` | +| 操作 | | 详情 | + +行操作:**详情**(主)、可选「补写短评」. + +--- + +### 10.3 计划详情 / 复盘页(字段) + +从历史点进去的详情页 = **主复盘面**,分块如下. + +#### A. 计划摘要 + +| 项 | 字段 | +|----|------| +| 类型 / 标的 / 方向 | plan_type, underlying, direction | +| 状态 / 平仓原因 | status, close_reason | +| 时间线 | created_at → opened_at → closed_at | +| 开仓时计仓 | sizing_mode_at_open, margin, leverage, perp_size | +| 关键价位 | entry_mark, tp, sl(永期), target_price(期期) | +| 盈亏 | realized_pnl_perp / realized_pnl_options / realized_pnl_total | +| 开仓情景快照 | preview_json 折叠展示(止盈合计/宽止损合计等) | + +#### B. 腿明细表 + +| 列 | 说明 | +|----|------| +| 角色 | 永续 / 保险期权 / 期期腿A/B | +| 合约 | symbol / inst_id | +| 数量 | size | +| 开仓价/保费 | avg_open, premium | +| 状态 | open / closed / 持有至到期 | +| 盈亏 | realized_pnl | +| 平仓原因 | close_reason | +| 关联 | 链到期权成交或监控(有则显示) | + +#### C. 复盘短评(必做入口) + +| 项 | 说明 | +|----|------| +| `note` | 多行文本,可空;保存 `PATCH /api/hedge-plan//note` | +| 提示文案 | 建议写:开仓理由、结果是否符合情景测算、下次调整 | + +**不做(本期):** 复盘截图上传、填入「交易记录与复盘」表单、纳入 AI 日/周复盘. +**可后置:** 以计划摘要生成 AI 点评(独立按钮,不写进 trade_records). + +#### D. 终态示例文案(便于复盘理解) + +| 场景 | 详情页状态说明 | 合计 | +|------|----------------|------| +| 永期止盈 | 「计划已结束(止盈);期权腿可不强平,账上已扣全部权利金」 | 止盈盈利 − 权利金 | +| 永期止损 + 期权已强平 | 「计划已结束(止损保护:期权已联动平仓)」 | 期权盈利 − 永续亏损 | +| 期期到期无盈利 | 「计划已结束(到期无盈利)」 | 总亏损(计入统计) | +| 期期达 S\* 后亏损腿仍持有 | 「盈利腿已平;待亏损腿到期后结账」 | 暂不入 closed 统计,或单独「收尾中」 | + +--- + +### 10.4 统计页(独立看板) + +**筛选:** 日期区间、类型(全部/永期/期期)、标的、结束桶(`tp`/`sl`/`oo_expiry_loss`/…). + +**聚合规则:** 仅 `status=closed`;胜场 = `realized_pnl_total > 0`. + +#### 永期口径(冻结) + +| 统计桶 | `close_reason` | 单笔盈亏公式 | 汇总 | +|--------|----------------|--------------|------| +| **止盈统计** | `perp_tp` | **止盈盈利 − 期权权利金** | sum / 笔数 / 胜率 | +| **止损统计** | `perp_sl` | **期权盈利 − 永续亏损** | sum / 笔数 /「保护后净亏」均值 | + +实现校验示例: + +``` +# 止盈 +realized_pnl_total = pnl_perp_tp - premium_total + +# 止损(亏损额取绝对值) +realized_pnl_total = pnl_option_close - abs(pnl_perp_sl) +# 等价有符号: pnl_option_close + pnl_perp_sl(后者为负) +``` + +#### 期期口径(冻结) + +| 统计桶 | 条件 | 单笔盈亏 | +|--------|------|----------| +| **到期无盈利** | 到期收口且合计 ≤ 0 | **总亏损**写入 `realized_pnl_total`(负值),计入区间净亏与「到期亏损」汇总 | +| 目标价路径 | 盈利腿已平 + 亏损腿到期后 | 两腿 realized 之和 | +| 到期仍盈利 | 合计 > 0 | 实际结算合计 | + +#### 总览卡片 + +| 指标 | 计算 | +|------|------| +| 计划笔数 | count(closed) | +| 胜率 | 胜场 / 笔数 | +| 区间净盈亏 | sum(realized_pnl_total) | +| 止盈桶净盈亏 | sum where stats_bucket=tp | +| 止损桶净盈亏 | sum where stats_bucket=sl | +| 期期到期亏损合计 | sum where oo_expiry_loss(绝对值或带符号合计) | +| 总保费支出 | sum(premium_total) | +| 平均持仓时长 | avg(closed_at − opened_at) | + +#### 分类型卡片 + +永期、期期各一套:笔数、胜率、净盈亏、平均保费;永期再拆 **止盈桶 / 止损桶**. + +#### 退出结构 + +| 指标 | 过滤 | +|------|------| +| 止盈结束笔数 | `perp_tp` | +| 止损结束笔数 | `perp_sl` | +| 期期到期无盈利笔数 | `oo_expiry_loss` | +| 目标价路径完结 | 含 `target_win_leg` 后收尾 | +| 半腿/失败 | `partial_fail` / failed | + +#### 简易表(可选) + +最近 N 条已结束计划迷你列表,点击跳详情. + +**导出(P5 可选):** CSV. + +--- + +### 10.5 与现有页面关系 + +| 现有页 | 关系 | +|--------|------| +| 交易记录与复盘 | **不写入**;永续腿若系统仍落 `trade_records`,可标记来源「对冲计划#id」,但人工复盘以对冲详情为准 | +| 统计分析 | **不合并**对冲净盈亏到全站数字(避免重复或口径混乱) | +| 期权页成交/持仓 | 腿 `options_trade_id` 可跳转对照;期权页仍可看单腿 | +| 中控 | V1 不聚合;V2 可选只读摘要 | + +--- + +### 10.6 API(历史 / 统计 / 复盘) + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/hedge-plan/list` | 进行中+草稿;`status` 过滤 | +| GET | `/api/hedge-plan/history` | 已结束列表;类型/日期/标的 | +| GET | `/api/hedge-plan/` | 详情 = 计划 + legs + note + preview | +| PATCH | `/api/hedge-plan//note` | 保存复盘短评 | +| GET | `/api/hedge-plan/stats` | query:`from`,`to`,`plan_type`,`underlying` → 总览+分类型+退出结构 | + +--- + +## 11. 监控线程 + +在 `crypto_okx` 同进程内新增 **`hedge_plan_monitor_loop`**(可与 `options_monitor_loop` 并列): + +| 职责 | | +|------|--| +| 永期 | 侦测永续 TP/SL → 按 §5.1 结束计划并结账;SL 时强制平期权 | +| 期期 | 侦测价触 S\* → 平盈利腿;到期无盈利 → 结束并记总亏损 | +| 微信 | **开始必推、结束必推**(§5.3);半腿/强平失败告警 | +| 幂等 | `wechat_start_sent` / `wechat_end_sent` 防重复推送 | + +--- + +## 12. 配置项与前端 env 页 + +对冲相关开关 **一律在 OKX 实例「env 配置」页维护**,不要求 SSH 改 `.env`. +实现对齐现有白名单模式(`lib/env/env_ui_manifest.py` 的「期权账户」分组). + +### 12.1 前端分组 + +在 env 配置页新增独立卡片,标题 **「对冲计划」**: + +- **仅 OKX** 展示(Binance/Gate 不出现) +- 放在 **「期权账户」下方**(依赖期权模块) +- 卡片说明文案建议: + - 永期开仓还要求「交易执行」里计仓模式为 **全仓**(`POSITION_SIZING_MODE=full_margin`) + - 真实下单还与「交易所与实盘」→ `LIVE_TRADING_ENABLED`、本卡片 `HEDGE_PLAN_LIVE_ORDER` 同时开启 + - `HEDGE_PLAN_ENABLED` 关闭时隐藏顶栏「对冲计划」并拒绝启动计划 + +### 12.2 本分组字段(前端可配) + +| 变量 | 前端标签 | 默认 | 控件 | 热更新 | 说明 | +|------|----------|------|------|--------|------| +| `HEDGE_PLAN_ENABLED` | 启用对冲计划 | false | bool | 热更优先 | 总开关:导航 + API | +| `HEDGE_PLAN_LIVE_ORDER` | 允许对冲真实下单 | false | bool | 热更 | 关则只测算/草稿 | +| `HEDGE_PLAN_OPEN_ORDER` | 永期开仓顺序 | options_first | select:`options_first`/`perp_first` | 热更 | 默认先期权后永续 | +| `HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS` | 永期止损后强制平期权 | true | bool | 热更 | **保护机制,默认 true** | +| `HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS` | 永期止盈后强制平期权 | false | bool | 热更 | **默认 false,保险腿不平** | +| `HEDGE_PLAN_OO_CLOSE_WINNER_ONLY` | 期期只平盈利腿 | true | bool | 热更 | 达目标价只平盈利方 | +| `MAX_ACTIVE_HEDGE_PLANS` | 最大同时活跃计划数 | 1 | number | 热更 | 建议保持 1 | +| `HEDGE_PLAN_MONITOR_POLL_SECONDS` | 对冲监控轮询(秒) | 15 | number | 热更 | 侦测 TP/SL/目标价 | +| `HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION` | 半腿失败时自动平期权 | true | bool | 热更 | 期权成、永续败时的补偿 | + +**不放入本分组、沿用已有卡片:** + +| 已有位置 | 键 | 对冲用途 | +|----------|-----|----------| +| 交易执行 | `POSITION_SIZING_MODE` | 全仓才允许永期开仓 | +| 交易执行 | `FULL_MARGIN_BUFFER_RATIO` | 默认 0.98 | +| 交易执行 | `BTC_LEVERAGE` | BTC/ETH 档(默认 10) | +| 交易所与实盘 | `LIVE_TRADING_ENABLED` | 总实盘门 | +| 期权账户 | `OKX_OPTIONS_*` | 期权 API、预算、标的 | + +### 12.3 `.env.example` 片段(实现时写入 OKX) + +```env +# --- 对冲计划(仅 OKX;前端 env「对冲计划」) --- +HEDGE_PLAN_ENABLED=false +HEDGE_PLAN_LIVE_ORDER=false +HEDGE_PLAN_OPEN_ORDER=options_first +HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS=true +HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS=false +HEDGE_PLAN_OO_CLOSE_WINNER_ONLY=true +MAX_ACTIVE_HEDGE_PLANS=1 +HEDGE_PLAN_MONITOR_POLL_SECONDS=15 +HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION=true +``` + +### 12.4 编码触点 + +| 文件 | 改动 | +|------|------| +| `lib/env/env_ui_manifest.py` | `_HEDGE_PLAN_SECTION`,`exchanges={"okx"}`,接入 `ui_sections_for_exchange` | +| `crypto_monitor_okx/.env.example` | 增加上表键与分组注释 | +| `lib/env/env_schema.py` | 纳入 `HOT_RELOAD_EXACT`(及必要时重启列表) | +| `docs/env配置说明.md` | 上线时补「对冲计划」小节 | + +推荐分期:**P0 先做 env 白名单 + 开关可读**,页面按 `HEDGE_PLAN_ENABLED` 显隐导航. + +--- + +## 13. 前端与路由(实现要点) + +- 导航:OKX 实例顶栏 **「对冲计划」**(display prefs 可加 `show_nav_hedge_plan`). +- 路由建议:`/hedge-plan`(主)、`/hedge-plan/history`、`/hedge-plan/stats`(或单页 Tab). +- 模板/静态:`lib/options` 旁新增 `lib/hedge_plan/`(或 `lib/hedge/`),复用: + - 期权链 CSS/T 型渲染思路(`options_panel.js` / `options-strike-table--t`) + - `compute_full_margin_sizing` / `position_sizing_lib` + - 永续下单 + TPSL、期权限价开平 API + +API 草图: + +- `GET /api/hedge-plan/market` — 永续行情 + 全仓试算 +- `GET /api/hedge-plan/options-chain` — 期权链 +- `POST /api/hedge-plan/preview` — 情景测算 +- `GET /api/hedge-plan/list` / `history` / `` / `stats` +- `PATCH /api/hedge-plan//note` — 复盘短评 +- `POST /api/hedge-plan` — 保存草稿/启动 +- `POST /api/hedge-plan//cancel|close` + +非全仓请求启动永期 → `400` 明确:「永期对冲开仓仅全仓模式可用」. + +--- + +## 14. 分期实施 + +| 阶段 | 交付 | 验收 | +|------|------|------| +| **P0** | env「对冲计划」分组 + 页框 + 行情 + 永期列表/期期 T + 情景测算 + 门禁 | env 可改开关;非全仓无法启动永期;全仓可看建议张数 | +| **P1** | 表结构 + 草稿/列表 | DB 可查 | +| **P2** | 永期自动开仓(全仓) | 双腿成交入计划 | +| **P3** | 监控:止损强制平期权;止盈不平期权 | 用例测 TP/SL 分支 | +| **P4** | 期期开仓 + 目标价只平盈利腿 | 达价仅平一侧 | +| **P5** | 历史列表列 + 详情复盘(note) + 统计看板 + 微信 | 与 /records /stats 隔离;止损/止盈文案可区分 | + +建议顺序严格;P3 规则错误会误平保险腿,上线前用 dry-run / paper flags. + +--- + +## 15. 与现有文档关系 + +| 文档 | 关系 | +|------|------| +| [期权对冲方案分析.md](./期权对冲方案分析.md) | 策略观念;本模块是其「计划化 + 自动执行」实现 | +| [对冲计划策略与P0校验.md](./对冲计划策略与P0校验.md) | P0 交付与口径校验 | +| [期权方案.md](./期权方案.md) / [期权用法.md](./期权用法.md) | 期权 API、仅买方、限价规则必须遵守 | +| [期权开平仓与监控说明.md](./期权开平仓与监控说明.md) | 买一平仓、门控、监控与风险;线上 `/options/guide` | +| [position-sizing-mode.md](./position-sizing-mode.md) | 全仓公式与缓冲 0.98 | + +本模块上线后,可在《期权对冲方案分析》末尾增加「系统对冲计划」链接指向本文. + +--- + +## 16. 已拍板规则摘要(校验清单) + +- [x] 放在 **OKX 实例**,名 **永期对冲 / 期期对冲** +- [x] 永期止盈 → **期权不强制平**,但 **计划算结束**;统计 = **止盈盈利 − 权利金** +- [x] 永期止损 → **期权必须强制平**,计划结束;统计 = **期权盈利 − 永续亏损** +- [x] 期期按目标价 → **只平盈利方**;到期无盈利 → **算结束并统计总亏损** +- [x] 对冲计划 **开始与结束均企业微信推送** +- [x] **独立历史 + 独立统计 + 计划详情复盘**(短评 note;不进普通交易复盘) +- [x] 永期开仓 **仅全仓**;非全仓永期只算账 +- [x] 非全仓允许 **期期**开仓;全仓永期+期期均可 +- [x] 永期张数:**ETH/BTC 10x 全仓 × 0.98**,先算盈亏再选期权 +- [x] 行情自动;永期期权 **列表式**;期期 **T 型** +- [x] 对冲开关在前端 **env 配置 →「对冲计划」** 维护(仅 OKX);计仓/杠杆/期权 API 复用已有分组 +- [x] 期权腿实盘平仓:**禁市价、只锁买一、分批;流动性/2×门控见开平仓说明** + +--- + +## 17. 免责与边界 + +- 双账户非原子成交,存在半腿风险. +- 止盈结束账上按权利金全额计成本;期权若仍持仓,后续行情 **不再改计划合计**,属设计意图. +- 止盈后期权可能继续损耗直至到期,与「计划已结束」并存. +- 买方权利金可能全部损失;期期到期无盈利记总亏损,属设计意图. +- 本文不构成投资建议. diff --git a/docs/对冲计划策略与P0校验.md b/docs/对冲计划策略与P0校验.md new file mode 100644 index 0000000..04258fd --- /dev/null +++ b/docs/对冲计划策略与P0校验.md @@ -0,0 +1,109 @@ +# 对冲计划策略说明与 P0 校验 + +> 配套实现方案:[对冲计划开发方案.md](./对冲计划开发方案.md) +> 本文记录 **策略口径** 与 **P0 代码校验**,确认可继续 P1+. + +--- + +## 1. 策略摘要 + +| 类型 | 账户 | 作用 | +|------|------|------| +| **永期对冲** | 永续子账户 + 期权主账户买方 | 全仓做方向,期权买保险 | +| **期期对冲** | 仅期权主账户双买方 | 目标价兑现盈利腿,亏损腿到期 | + +### 永期结束与统计 + +| 事件 | 期权处理 | 计划 | 统计公式 | +|------|----------|------|----------| +| 止盈 | 不强平 | **结束** | **止盈盈利 − 权利金** | +| 止损 | **强制平** | **结束** | **期权盈利 − 永续亏损**(有符号相加) | + +### 期期结束与统计 + +| 事件 | 处理 | 统计 | +|------|------|------| +| 达目标价 | 只平盈利腿 | 待亏损腿到期后结账 | +| 到期无盈利 | **结束** | **总亏损** | + +起止均企业微信推送(P2+ 监控落地后再接). + +### 门禁 + +- 永期开仓:**仅** `POSITION_SIZING_MODE=full_margin` +- 非全仓:永期可测算不可开;期期 P0 起可测算(开仓后续版本) +- env:OKX「对冲计划」分组;须 `HEDGE_PLAN_ENABLED=true` 才显示导航 + +--- + +## 2. P0 已交付 + +| 项 | 状态 | +|----|------| +| env 白名单「对冲计划」9 字段 | 有 | +| OKX `.env.example` 字段 | 有 | +| `/hedge-plan` 页(永期列表 / 期期 T) | 有 | +| `/api/hedge-plan/market|options-chain|preview|gates` | 有 | +| 全仓建议张数(可用×0.98×10x) | 有 | +| 情景测算止盈/止损口径 | 有 | +| 启动开仓 | **禁用**(明示 P0) | +| 历史/统计/监控/微信 | **未做**(P1–P5) | + +关键文件: + +- `lib/hedge_plan/hedge_plan_calc_lib.py` +- `lib/hedge_plan/hedge_plan_register.py` +- `lib/hedge_plan/templates/hedge_plan_panel.html` +- `lib/common/static/hedge_plan.js` +- `tests/test_hedge_plan_calc.py` + +--- + +## 3. 校验清单(可行性) + +### 计算口径 + +``` +止盈: perp_pnl(tp) - premium +止损: option_expiry_pnl(spot=sl) + perp_pnl(sl) +期期到期无盈利: expiry_flat_total <= 0 → 记总亏损 +``` + +单测覆盖:`tests/test_hedge_plan_calc.py`. + +### 门禁 + +- `risk` + 永期 → `can_start=false`,文案含「全仓」 +- `HEDGE_PLAN_ENABLED=false` → 导航隐藏(服务端 template 读 env) + +### 行情 + +- 永续:`exchange.fetch_ticker` + `get_available_trading_usdt` + `compute_full_margin_sizing` +- 期权:`build_option_chain`(与期权页同源) + +### 已知边界(非 P0 bug) + +1. 建议张数未强制 `amount_to_precision`(开仓阶段再对齐交易所精度). +2. 止盈账扣全额权利金,与期权是否仍持仓无关(策略如此). +3. 热更新 `HEDGE_PLAN_ENABLED` 后需刷新页面才显隐导航. +4. 嵌入壳 Tab 已注册 `hedge_plan`;中控能力勾选若需显式「对冲」可后续加. + +--- + +## 4. 服务器启用步骤 + +```bash +# env 配置页 → 对冲计划 → HEDGE_PLAN_ENABLED=true → 保存 +# 或服务器: +cd /opt/crypto_monitor_user/crypto_monitor_okx +# 确保 .env 含 HEDGE_PLAN_* 字段后 +pm2 restart crypto_okx --update-env +``` + +验收: + +1. 顶栏出现「对冲计划」 +2. 永期可见建议张数(全仓时) +3. 点「计算」得到止盈/止损合计 +4. 「启动计划」禁用 +5. env 页可见「对冲计划」分组 diff --git a/docs/更新文档.md b/docs/更新文档.md new file mode 100644 index 0000000..3d85bc5 --- /dev/null +++ b/docs/更新文档.md @@ -0,0 +1,464 @@ +# 更新文档(仓库级) + +自 2026-07-16 起:**凡修改或更新功能,必须在本文件追加一条记录**,写明原因、改动位置、目标与交付验收。实例目录下旧版说明可保留,但共享逻辑(`lib/`)以本文为准。 + +--- + +## 2026-07-17 · 修复 pip>=26 部署依赖安装失败 + +### 修改原因 + +单所验证时 `setup_env` 升级到 pip 26 后,`--progress-bar ascii` 非法,依赖安装中断;腾讯源偶发空索引也会导致一次失败. + +### 修改的地方 + +| 文件 | 改动摘要 | +|------|----------| +| `deploy/lib/common.sh` | `pip_progress_bar_arg` 改为 `on`(兼容 pip 26);`pip install` 加 `--retries 5` | + +### 达成的目标 + +非 TTY / SSH 下一键安装可顺利 `pip install -r`. + +### 交付之后的验收 + +`bash deploy/lib/install.sh --exchange okx` 能过依赖安装并起 `crypto_okx`. + +--- + +## 2026-07-17 · 一键部署支持单所仅实例(不含中控) + +### 修改原因 + +新机或专用机只需跑某一所 Flask 时,全套 7 进程过重;希望菜单可直接选「仅 OKX / Binance / Gate」,不起中控与 agent. + +### 修改的地方 + +| 文件 | 改动摘要 | +|------|----------| +| `deploy/manage.sh` | 菜单增加 4/5/6 单所实例入口 | +| `deploy/lib/install.sh` | `--exchange okx\|binance\|gate` 单所流水线 | +| `deploy/pm2_start_all.sh` | `--only` 只启对应 ecosystem | +| `deploy/lib/common.sh` | 单所验收 / 完成提示 / 辅助映射 | +| `deploy/README.md` | 菜单说明 | + +### 达成的目标 + +1. 选 4/5/6: `setup_env --only <所>` + 仅启动该所 PM2,不含 hub/agent. +2. 选 1: 全套行为与改前一致. +3. 仍共用整仓 `/opt/crypto_monitor_user`,不拆仓库. + +### 交付之后的验收 + +1. 菜单可见 4/5/6. +2. `bash deploy/lib/install.sh --exchange okx` 后仅 `crypto_okx` 起来,`:5004` 可访问. +3. 全套选项 1 仍可部署三所+中控. + +--- + +## 2026-07-17 · 永续估算盈亏统一扣双边 taker 手续费 + +### 修改原因 + +中控/实例「盈利金额」、微信推送「本单盈亏」、交易记录 `pnl_amount` 使用价差毛利,未扣开平手续费,与交易所实际净盈亏及盈亏比体感偏差较大。 + +### 定稿口径 + +| 项 | 约定 | +|----|------| +| 浮盈亏 | 仍读交易所,不改 | +| 费率 | taker 单边 **0.05%**(`PERP_TAKER_FEE_RATE`,默认 `0.0005`),开+平双边 | +| 净盈亏 | 毛利 − 开仓名义×费率 − 平仓名义×费率(不考虑滑点) | +| RR | 净盈利 / 原风险(风险侧加费第二步再做) | +| 历史记录 | 不回算 | + +### 修改的地方 + +| 文件 | 改动摘要 | +|------|----------| +| `lib/trade/trade_fee_lib.py` | 新增公共扣费 / 净盈亏 | +| `lib/strategy/strategy_roll_ui_lib.py` | `reward_at_tp_usdt` → 净盈利 | +| `lib/strategy/strategy_roll_lib.py` | 同上 | +| `lib/strategy/strategy_trend_lib.py` | `calc_tp_profit_usdt` → 净盈利 | +| `lib/hub/hub_calculator_lib.py` | 滚仓预览止盈盈利 / 首仓盈利扣费;RR 跟净盈利 | +| `crypto_monitor_okx/app.py` | `calc_pnl` → 净盈亏(推送/记账) | +| `crypto_monitor_gate/app.py` | 同上 | +| `crypto_monitor_binance/app.py` | `calc_pnl` / 成交回退扣费;income 真费路径优先不改 | +| `tests/test_trade_fee_lib.py` | 新增 | +| `tests/test_strategy_roll_ui_lib.py` | 断言改净额 | +| `tests/test_order_monitor_display_lib.py` | 断言改净额 | + +### 达成的目标 + +1. 中控持仓「盈利金额」、实例「盈利金额」、计算器止盈盈利、趋势/滚仓预览一致为净盈亏。 +2. 微信推送与新建交易记录的 `pnl_amount` 与上述估算口径一致。 +3. 币安若能拉到 income 净额(已含真实手续费)仍优先用交易所数。 +4. 浮盈亏展示仍跟交易所。 + +### 交付之后的验收 + +1. 同一笔持仓:中控盈利金额 ≈ 实例盈利金额(均为扣费后)。 +2. 平仓推送「本单盈亏」与新写入记录接近,不再明显大于交易所净利。 +3. 浮盈亏与交易所 App 一致(本改不动)。 +4. 单测:`python -m unittest tests.test_trade_fee_lib tests.test_strategy_roll_ui_lib tests.test_order_monitor_display_lib tests.test_trend_preview_tp -v` 通过。 + +--- + +## 2026-07-16 · 计算器左侧 Tab + 三行输入 + +### 修改原因 + +电脑端顶部横向 Tab 与纵向表单不协调,输入项行数偏多。 + +### 修改的地方 + +| 文件 | 改动摘要 | +|------|----------| +| `manual_trading_hub/static/index.html` | 增加计算器工作区容器,更新 CSS 缓存 | +| `manual_trading_hub/static/app.css` | 电脑端 Tab 改为左侧竖排;≥1200px 基础输入改为五列、三行排列 | + +### 达成的目标 + +电脑端左侧切换计算器,右侧集中填写;宽屏基础输入压缩为三行。 + +### 交付之后的验收 + +1. 电脑端两个计算器 Tab 位于左侧。 +2. 1920px 宽屏基础输入区为三行。 +3. Tab 切换和计算功能正常;手机端保持原布局。 + +--- + +## 2026-07-16 · 电脑端计算器改为 Tab 切换 + +### 修改原因 + +电脑端同时并排显示趋势回调与滚仓计算器,横向空间利用和操作聚焦不理想。 + +### 修改的地方 + +| 文件 | 改动摘要 | +|------|----------| +| `manual_trading_hub/static/index.html` | 计算器 Tab 增加电脑端完整名称,更新 CSS 缓存 | +| `manual_trading_hub/static/app.css` | 电脑端显示 Tab、单列展示当前计算器;手机端沿用原紧凑 Tab | + +### 达成的目标 + +电脑端通过「趋势回调计算器 / 滚仓计算器」Tab 切换,一次只显示一个计算器。 + +### 交付之后的验收 + +1. 电脑端默认显示趋势回调计算器。 +2. 点击滚仓计算器 Tab 后只显示滚仓计算器,切回正常。 +3. 手机端原有计算器 Tab 样式和交互不变。 + +--- + +## 2026-07-16 · 资金概况移除累计盈亏长条 + +### 修改原因 + +「同步快照」后方的累计盈亏长条与下方汇总卡片重复,占用横向空间。 + +### 修改的地方 + +| 文件 | 改动摘要 | +|------|----------| +| `manual_trading_hub/static/index.html` | 删除资金工具栏中的累计盈亏/较昨日长条 | + +### 达成的目标 + +资金概况工具栏仅保留「同步快照」和状态信息,累计盈亏继续由下方汇总卡展示。 + +### 交付之后的验收 + +1. 「同步快照」按钮后不再显示累计盈亏长条。 +2. 下方累计盈亏、较昨日汇总卡数据正常显示。 + +--- + +## 2026-07-16 · 今日统计默认折叠 + 交易所标题行下移 + +### 修改原因 + +今日统计常占一行挤空间;默认只需看总浮盈亏。交易所卡标题/打开实例贴顶过紧。 + +### 修改的地方 + +| 文件 | 改动摘要 | +|------|----------| +| `manual_trading_hub/static/app.js` | 今日统计默认折叠只露总浮盈亏;展开显示明细;状态写入 localStorage | +| `manual_trading_hub/static/app.css` | 折叠/展开样式;展开后明细字号加大;分栏卡 `card-head` 上内边距加大 | +| `manual_trading_hub/static/index.html` | 缓存 `20260716-hub-stats-fold` | + +### 达成的目标 + +监控区默认更省高;需要时一键展开更大明细;交易所标识行不再贴顶。 + +### 交付之后的验收 + +1. 默认只见「今日统计 + 总浮盈亏」与「展开明细」。 +2. 点展开后六项明细可见且数字更大。 +3. OKX/币安等卡标题与按钮相对顶边有更明显间距。 + +--- + +## 2026-07-16 · 监控芯片还原 + 底栏贴底 + 1080p 留白 + +### 修改原因 + +「监控位」本意是原先关键位/趋势回调/顺势加仓芯片,不是单独「无监控位」槽;全屏提示须贴卡片最底;1920×1080 两侧需留白,带鱼屏保持现宽。 + +### 修改的地方 + +| 文件 | 改动摘要 | +|------|----------| +| `manual_trading_hub/static/app.js` | 去掉错误的监控位槽;恢复策略芯片(仍隐藏期权 N仓) | +| `manual_trading_hub/static/app.css` | 分栏卡 `card-expand-hint` `margin-top:auto` 贴底;≤2000px 加大左右留白,>2000px 保持 1860 内容宽 | +| `manual_trading_hub/static/index.html` | 缓存 `20260716-hub-chips-margin` | + +### 达成的目标 + +有关键位/趋势/顺势时仍以芯片显示;提示条在卡底;1080p 两侧有留白,带鱼屏观感不变。 + +### 交付之后的验收 + +1. 无「监控位 · 0 / 无监控位」区块;有关键位等时出现原芯片样式。 +2. 「点击标题栏进入全屏…」贴在各分栏卡最底部。 +3. 1920×1080 内容两侧留白明显;带鱼屏内容宽度仍约 1860。 + +--- + +## 2026-07-16 · 监控区 2×2 细化(目标监控列/预留行/去平板专属) + +### 修改原因 + +四卡对齐后需:左右等宽;期权表改目标监控列;去掉打开期权页与永续卡「期权 N仓」;合约卡预留仓位+监控位两行并统一全屏提示;多仓时同行左右一起长高;平板改用浏览器 80% 缩放,去掉 hub-tablet 专属样式(手机 UI 不动)。 + +### 修改的地方 + +| 文件 | 改动摘要 | +|------|----------| +| `manual_trading_hub/static/app.js` | 平铺 `monitor-split-2x2`;期权表删指数/到期平衡/平掉回本,加目标监控列(有=绿/无=`—`);去打开期权页;永续卡隐藏期权徽章;预留仓位/监控位槽;去掉 `isTabletLayout` | +| `manual_trading_hub/static/app.css` | 左右 1:1;`minmax(min-content,1fr)` 同行同高可外扩;删除全部 `hub-tablet` 规则 | +| `manual_trading_hub/static/index.html` | 缓存 `20260716-hub-2x2-slots` | + +### 达成的目标 + +桌面监控区四卡等宽对齐;期权看目标监控列;永续卡结构为仓位行+监控位行+提示行;≥2 仓左右一起加高;平板不再走单独 CSS。 + +### 交付之后的验收 + +1. 左右列等宽;永续卡无「期权 1仓」、无「打开期权页」。 +2. 期权表有「目标监控」列,有监控绿色、无则 `—`;无指数/到期平衡/平掉回本。 +3. 永续/币安卡可见预留仓位行、监控位行与完整全屏提示文案。 +4. 多开仓后该行变高且左右同高;`body` 无 `hub-tablet`;手机布局未改。 + +--- + +## 2026-07-16 · OKX 拆成永续/期权双卡(2×2 对齐) + +### 修改原因 + +OKX 单卡内嵌永续+期权过高,右侧币安/Gate 两卡对不齐,1080p 观感不协调。按产品建议改为四卡对齐。 + +### 修改的地方 + +| 文件 | 改动摘要 | +|------|----------| +| `manual_trading_hub/static/app.js` | 期权分栏时 OKX 渲染为「·永续」「·期权」两张独立卡;标题与操作按钮按卡片分流 | +| `manual_trading_hub/static/app.css` | 左右列均 `1fr/1fr`,四卡 2×2 等高对齐;卡体内滚 | +| `manual_trading_hub/static/index.html` | 静态资源缓存 `20260716-hub-okx-2x2` | + +### 达成的目标 + +监控区呈现:左上永续 / 左下期权 / 右上币安 / 右下 Gate,四卡对齐。 + +### 交付之后的验收 + +1. 桌面监控区可见 `OKX_趋势 · 永续` 与 `OKX_趋势 · 期权` 两张独立卡。 +2. 四卡与右侧币安、Gate 同行等高,不再一大两小。 +3. 点击任一张 OKX 卡标题仍可全屏;期权卡「全平」不重复出现。 + +--- + +## 2026-07-16 · 1080p 监控区无持仓空洞收紧 + +### 修改原因 + +1920×1080 上一屏适配用 `1fr/1fr` 把永续/分所空卡强行均分拉高,「无持仓」下方大片空洞;带鱼屏尚可,短屏观感差。 + +### 修改的地方 + +| 文件 | 改动摘要 | +|------|----------| +| `manual_trading_hub/static/app.css` | OKX 内卡改为 `auto + 1fr`(永续按内容、期权吃剩余);右侧 Gate/币安改为 `auto auto` 按内容收紧;1080p 短屏左侧略加宽 | +| `manual_trading_hub/static/index.html` | CSS 缓存 `20260716-hub-1080-tight` | + +### 达成的目标 + +无持仓区块不再被拉成半屏空黑;有期权/持仓的区域拿到更多可视高度。 + +### 交付之后的验收 + +1. 1920×1080 监控区:永续「无持仓」仅占内容高度,期权表区域明显变高。 +2. 右侧币安/Gate 无仓时卡片贴内容,不再卡片内大片空洞。 +3. 带鱼屏布局仍可用;持仓变多时右侧列可内滚。 + +--- + +## 2026-07-16 · 电脑端误判平板导致发糊 + +### 修改原因 + +`isTabletLayout()` 曾用「高度 ≤920 即平板」;1080p 电脑有任务栏/浏览器栏时 `innerHeight` 常落在此区间,桌面被套上平板压缩字号(约 10px),观感发糊发虚。 + +### 修改的地方 + +| 文件 | 改动摘要 | +|------|----------| +| `manual_trading_hub/static/app.js` | 平板判定改为横屏 `721–1366 × ≤900` / 竖屏 `≤920 × ≥900`;去掉仅按高度命中 | +| `manual_trading_hub/static/index.html` | JS 缓存 `20260716-hub-desktop-clear` | + +### 达成的目标 + +常规电脑端不再误加 `hub-tablet`,恢复桌面字号与清晰度;真平板视口仍走一屏密度样式。 + +### 交付之后的验收 + +1. 1080p/1440p 电脑打开中控,正文与表格清晰,非异常小字。 +2. 浏览器开发者工具确认 `body` 无 `hub-tablet`(窄窗模拟平板除外)。 +3. 平板横/竖仍为一屏密度布局。 + +--- + +## 2026-07-16 · 平板一屏密度适配(不裁切) + +### 修改原因 + +平板字号偏大、留白空、卡片半截裁切或底部大片空黑,显得 low;需要「一屏看完」且正文不被拦腰裁掉。 + +### 修改的地方 + +| 文件 | 改动摘要 | +|------|----------| +| `manual_trading_hub/static/app.css` | `hub-tablet`:锁 100dvh;压缩字号/间距;监控区 flex 填满;竖屏 OKX 上 + Gate/币安并排;表体内滚兜底;资金页缩曲线、压汇总数字、分户卡完整可见 | +| `manual_trading_hub/static/index.html` | CSS 缓存 `20260716-hub-tablet-onescreen` | + +### 达成的目标 + +平板监控区/资金概况一屏呈现、信息密度接近桌面;常规持仓与分户名称/余额不被裁半;持仓很多时仅表体内滚。 + +### 交付之后的验收 + +1. 平板强制刷新后,监控区三所卡片同屏,余额与持仓列完整可读。 +2. 资金概况:四格汇总 + 曲线 + 分户卡同一屏,账户名不被切半。 +3. 底部不再大片空黑;桌面大屏一屏规则不受影响。 + +--- + +## 2026-07-16 · 平板监控卡片内容裁切修复(已由一屏密度方案取代) + +先前改为整页可滚以避免裁切;用户要求改为「一屏 + 不裁切 + 提密度」,由上一条覆盖。 + +--- + +## 2026-07-16 · 中控平板一屏适配(2560×1600) + +### 修改原因 + +平板物理分辨率 2560×1600 在 2× 缩放下 CSS 视口约为 **1280×800**,进不了此前桌面规则 `min-width:1600px`,监控/行情/资金仍整页滚动。 + +### 修改的地方 + +| 文件 | 改动摘要 | +|------|----------| +| `manual_trading_hub/static/app.js` | 新增 `isTabletLayout()`;`hub-tablet` body class | +| `manual_trading_hub/static/app.css` | 一屏适配门槛降为 `min-width:721px` + `min-height:650px`;另增平板矮/窄视口加密度规则 | +| `manual_trading_hub/static/index.html` | 缓存版本 `20260716-hub-fit-tablet` | + +### 达成的目标 + +平板(含 2560×1600@2x)与桌面一样:监控区 / 行情区 / 资金概况尽量一屏、无整页下拉。 + +### 交付之后的验收 + +1. 平板横屏打开中控,强制刷新后 body 应有 `hub-tablet`(开发者工具)。 +2. 监控区(OKX 一期权 ± 永续空/一仓,另两所各 ≤1 仓):无整页纵向滚动。 +3. 行情区、资金概况同屏无整页滚动。 +4. 手机(≤720px)仍走 `hub-phone`,不受影响。 + +--- + +## 2026-07-16 · 中控三页 1920×1080 一屏显示 + +### 修改原因 + +监控区在 OKX「一永续 + 一期权」、另两所各一仓(或空仓)时,以及行情区、资金概况在 1920×1080 下出现整页纵向滚动,无法一屏看完。 + +### 修改的地方 + +| 文件 | 改动摘要 | +|------|----------| +| `manual_trading_hub/static/app.js` | `setActiveNav` 增加 `hub-page-monitor` / `hub-page-market` body class | +| `manual_trading_hub/static/app.css` | `@media (min-width:1600px) and (min-height:900px)` 一屏适配:壳层 `100dvh` 不滚动;三页 flex 填满;监控卡片/表格压缩;行情 K 线区 flex 吃剩余高度;资金曲线与分户区压缩 | +| `manual_trading_hub/static/index.html` | `app.css` / `app.js` 缓存版本 `20260716-hub-fit-1080` | + +**未改:** 期权开平仓规则、实例交易页、手机端 `hub-phone` 布局。 + +### 达成的目标 + +1. **监控区**:桌面大屏下整页无纵向滚动;OKX 左右分栏(永续+期权)与 Binance/Gate 同屏可见。仓位表过长时仅卡片内部滚动。 +2. **行情区**:工具条 + K 线同屏,图表占满剩余高度。 +3. **资金概况**:统计卡 + 曲线 + 三分户同屏。 + +### 交付之后的验收(1920×1080,浏览器缩放 100%) + +1. 打开中控监控区:服务器状态与操作栏保持折叠时,页面**无**浏览器纵向滚动条;OKX 期权 1 仓 + 另两所空仓/各 1 仓均一屏可见。 +2. 行情区:加载 BTC 日线后,OHLCV + 图在一屏内,无整页滚动。 +3. 资金概况:曲线与三分户卡片同屏,无整页滚动。 +4. 窄屏/手机(`hub-phone`)布局不受影响。 +5. 展开「服务器状态」后若内容过高,允许监控网格内部滚动,仍尽量避免整页滚动。 + +--- + +## 2026-07-16 · 期权/对冲开仓仅认真实卖一深度 + +### 修改原因 + +此前报价在无盘口卖一时会用**标记价顶进 `ask`**,界面仍显示「限价买入 @ 卖一」,造成误以为在吃卖一;深度实值合约还容易「链上有 `~` 价、点选却失败或按估算价下单」。需要与产品规则对齐:**开仓只吃真实卖一,且必须有卖一量**。 + +### 修改的地方(明确清单) + +| 文件 | 改动摘要 | +|------|----------| +| `lib/exchange/okx_options_lib.py` | 新增 `option_buy_liquidity_ok` / `cap_option_buy_sheets_to_ask_depth`;`quote_option_contract` **不再**用 mark 填充开仓 `ask`;返回 `can_open` / `ref_ask` / `open_block_msg` / `ask_source` | +| `lib/options/options_register.py` | `/api/options/quote` 开仓 sizing 仅在 `can_open` 时计算,张数 cap 到卖一深度;`/api/options/open` 服务端再验深度并 cap 张数 | +| `lib/common/static/options_panel.js` | 面板展示参考标记价;无深度禁用开仓按钮与说明文案;开仓前校验 `can_open` | +| `lib/options/templates/options_panel.html` | 提示文案;增加「参考标记价」字段;脚本 `?v=37` | +| `lib/hedge_plan/hedge_plan_orders_lib.py` | **仅** `_buy_option`(买入开仓)同步深度门禁与张数 cap;**未改** `_sell_option` 平仓 | +| `lib/hedge_plan/templates/hedge_plan_panel.html` | 单位说明补充开仓规则 | +| `tests/test_option_buy_liquidity.py` | 新增门禁/深度 cap 单测 | +| `tests/test_hedge_plan_orders.py` | mock 补 `ask_sz`;无深度拒绝 / 深度 cap 用例 | + +**铁律:未改动任何平仓规则**(期权买一平仓、`_sell_option`、close_preview / close 执行路径逻辑保持原样;报价里买一仍可用 mark 补展示,仅服务平仓读 bid)。 + +### 达成的目标 + +1. 开仓条件:`askPx` 有效 **且** `askSz > 0`。 +2. 无卖一/无深度:可展示参考标记价 `ref_ask`/`mark`,明确「不可用于开仓」,按钮禁用。 +3. 有深度时:限价买 @ 真实卖一;张数不超过卖一深度(向下取整)。 +4. 期权面板与对冲计划买入路径规则一致。 + +### 交付之后的验收 + +1. **有卖一深度**:选合约 → 卖一显示 `价/量` → 按钮「限价买入 @ 卖一」可点 → 下单张数 ≤ 卖一量。 +2. **无卖一或深度为 0**:卖一为 `—`;参考标记价显示 `~xx (不可开仓)`;红色说明含「仅供参考,不可用于开仓」;按钮为「暂无卖一深度,无法开仓」且不可点;直接调 open API 应返回失败文案。 +3. **链上 `~` 估算**:仍可浏览;点选后若无真实深度,不得用估算价成交。 +4. **平仓**:持仓「买一平仓」行为与改前一致(抽测一条即可)。 +5. **对冲计划**:执行买入腿时无深度应失败并提示;有深度 dry_run/实盘张数不超过卖一量。 +6. 单测:`python -m pytest tests/test_option_buy_liquidity.py tests/test_hedge_plan_orders.py -q` 通过。 + +### 未纳入本次(另单) + +硬刷新链可能毁掉下单面板、限频 fallback tick、CSS `?v=` 缓存等,见会话审计清单,不在本条范围。 diff --git a/docs/期权对冲方案分析.md b/docs/期权对冲方案分析.md new file mode 100644 index 0000000..65e7c32 --- /dev/null +++ b/docs/期权对冲方案分析.md @@ -0,0 +1,322 @@ +# 期权对冲方案分析 + +> 适用范围:OKX **永续子账户**(USDT 本位) + **期权主账户**(USDⓈ 本位买方). +> 本文档为 **策略与操盘说明**,非系统自动下单功能;组合须 **人工** 在永续页与期权页分别执行. + +--- + +## 1. 前提与账户分工 + +| 维度 | 永续合约(子账户) | 期权(主账户) | +|------|------------------|--------------| +| API | `OKX_API_*` | `OKX_OPTIONS_API_*` | +| 系统页面 | 实盘下单 / 关键位 / 策略 | 期权 | +| 保证金 | USDT | USDC / USDG | +| 本系统能力 | 开平仓、止损、关键位 | **仅买方** 开平仓,无组合单 | + +**重要约束:** + +- 两套资金 **不自动合并**;对冲是逻辑上的组合,资金与下单 **分账户** 管理. +- 系统 **不支持** 卖方开仓、跨式组合单、自动 Delta 调仓;下文操盘均为 **手工流程**. +- 期权模块默认:≤2 日到期、轻度实值、单笔权利金预算(如 10 USDC).对冲设计需与此预算协调. + +--- + +## 2. 期权 + 永续合约对冲 + +指用 **期权买方头寸** 去对冲或替代 **永续方向敞口**,降低单边暴跌/暴涨带来的尾部风险,或锁定部分利润. + +### 2.1 使用场景 + +| 场景 | 永续侧状态 | 期权侧动作 | 目的 | +|------|------------|------------|------| +| **保护性 Put** | 子账户 **多头** 永续(策略/关键位持仓) | 主账户买入 **看跌 Put**(轻度实值、近月) | 大跌时 Put 升值,抵消永续浮亏 | +| **看涨保险** | 子账户 **空头** 永续 | 主账户买入 **看涨 Call** | 急涨时 Call 升值,限制空头亏损 | +| **事件前对冲** | 永续有仓,临近 FOMC/CPI/非农 | 临时买 **Put 或 Call**(方向视净敞口) | 降低数据公布瞬间波动伤害 | +| **锁利减仓** | 永续浮盈较大,不愿全平 | 买 **反向期权** 作「便宜保险」 | 保留永续吃趋势,用权利金买回撤保护 | +| **替代硬止损** | 永续止损易被扫或滑点大 | Put/Call 权利金 ≈ 可接受最大额外损失 | 用期权时间价值换「软止损」 | + +**不适合:** + +- 长期持仓 + 远期期权(本系统偏 ≤2 日 DTE,时间价值流失快). +- 指望「完全对冲」零波动:买方期权有 **Theta 衰减**,永续有 **资金费**,不可能完美镜像. +- 资金不足:期权需先 **USDT→USDC 兑换 + 划转到交易户**,再下单. + +### 2.2 案例分析 + +#### 案例 A:保护性 Put(多头永续 + 买 Put) + +**背景** + +- 子账户 ETH 永续 **多 0.5 ETH**,均价 3,200,浮盈 +5%. +- 当晚有宏观数据,担心 30 分钟内急跌 3%~8%. + +**操作** + +1. 永续:维持多单,或略减杠杆(系统「实盘下单」/策略仓). +2. 期权主账户:USDT→USDC→划转到交易户. +3. 期权页:ETH **看跌 Put**,行权价略低于现价(轻度实值),1~2 日到期. +4. 按预算 10 USDC 买满(约 0.0x ETH 名义),记录权利金 **C**. + +**结果推演** + +| ETH 走势 | 永续盈亏(示意) | Put 盈亏(示意) | 组合效果 | +|----------|----------------|----------------|----------| +| 横盘 | 小亏资金费 | Put 因 Theta 贬值 | 净成本 ≈ 权利金 C | +| 跌 5% | 永续大亏 | Put 明显升值 | 部分对冲,净亏 < 无 Put | +| 涨 3% | 永续盈利 | Put 接近归零 | 永续盈利 − C | + +**要点:** 买的是 **保险**,不是赚钱工具;权利金 C 是「保费」. + +#### 案例 B:空头永续 + 买 Call + +**背景** + +- 子账户 BTC 永续 **空 0.02 BTC**,判断短期震荡偏空,但担心消息拉升. + +**操作** + +- 买 **看涨 Call**(轻度实值、近月),权利金控制在预算内. + +**结果推演** + +- 下跌:永续盈利,Call 损耗 → 净赚略少于裸空. +- 急涨:永续亏损,Call 盈利 → 涨幅越大对冲越有效. + +#### 案例 C:事件窗口「临时对冲」 + +**背景** + +- 永续多仓持有中,事件前 2 小时不想平仓(怕踏空). + +**操作** + +- 事件前:买小仓位 Put,事件后 1~2 小时内无论盈亏 **平掉 Put**. +- 永续:按原策略止损/止盈,不因期权改变永续规则. + +**要点:** 短期 Put 的 Theta 极快;**事件结束尽快平仓**,避免「保险变持仓」. + +### 2.3 风险评估 + +| 风险类型 | 说明 | 等级 | +|----------|------|------| +| **权利金损耗(Theta)** | 买方期权每天衰减;横盘即亏保费 | 高 | +| **对冲比例不足** | 10U 预算买到的名义远小于永续仓位 | 高 | +| **方向错配** | 多头却买 Call、空头却买 Put | 高 | +| **双账户操作延迟** | 永续已亏,期权尚未成交 | 中 | +| **流动性** | 限价未成交,极端行情无法对冲 | 中 | +| **资金费 vs 保费** | 长期持仓资金费 + 反复买 Put 成本叠加 | 中 | +| **汇率/币种** | 永续 USDT、期权 USDC,汇率波动次要 | 低 | +| **API/权限** | 子账户与主账户密钥混用 | 低(配置隔离即可) | + +**风险量化思路(手工估算):** + +``` +可接受保费上限 ≈ 永续名义价值 × 愿意承担的单日额外损失比例 +例:0.5 ETH × 3,200 × 0.5% ≈ 8 USDC → 与 OKX_OPTIONS_TRADE_BUDGET_USDC 对齐 +``` + +若保费 << 永续风险敞口,属于 **部分对冲**,需心里有数. + +### 2.4 操盘说明(期权 + 永续) + +**准备(每次对冲前)** + +1. 确认子账户永续:**方向、数量、均价、浮盈亏**(实盘顶栏/持仓). +2. 确认主账户期权:**交易户 USDC 余额** 足够(兑换+划转). +3. 明确本次是 **保护多仓(Put)** 还是 **保护空仓(Call)**. + +**执行顺序(推荐)** + +``` +① 永续侧:确认仓位与风控(止损/关键位)已就绪 +② 主账户:资金户 USDT → USDC(币种兑换) → 划转到交易户 +③ 期权页:选标的(ETH/BTC) → 到期日(1~2日) → Call/Put → 轻度实值行权价 +④ 限价买入(卖一),成交后记录:张数、权利金、行权价、到期时间 +⑤ 企业微信:关注翻倍提醒(浮盈≥100%权利金)作为减仓信号,非必须平永续 +``` + +**平仓/退出** + +| 情况 | 永续 | 期权 | +|------|------|------| +| 趋势延续、保险未触发 | 按原策略 | 临近到期或 Theta 损耗大时 **平 Put/Call** | +| 期权浮盈翻倍(系统提醒) | 可选:减永续仓或上移止损 | **平期权锁利**,保留永续 | +| 永续已止损离场 | 无仓 | **立即平期权**,避免裸买权衰减 | +| 事件结束 | 照旧 | 平期权,勿长期持有近月买方 | + +**检查清单** + +- [ ] 永续方向与期权类型匹配(多→Put,空→Call) +- [ ] 权利金 ≤ 预算,且 ≤ 心理「保费」上限 +- [ ] 成交后两边持仓在各自页面可核对 +- [ ] 到期日前 24h 评估是否平仓期权 + +--- + +## 3. 期权 + 期权对冲 + +指 **仅主账户** 内,用两个(或多个)买方期权组合,表达 **波动、方向区间或尾部保护**,不直接动永续仓位. + +> 本系统 **仅买方**;下列组合均为 **买 Call + 买 Put** 或 **不同行权价双买**,不含卖权收权利金策略. + +### 3.1 使用场景 + +| 场景 | 组合结构 | 目的 | +|------|----------|------| +| **Long Straddle(双买)** | 同行权附近 **Call + Put** | 赌大波动(突破),不怕方向 | +| **Long Strangle(宽双买)** | OTM **Call + OTM Put** | 降低成本,赌更大波动才盈利 | +| **风险逆转(买方版)** | 轻度 ITM Call + 轻度 ITM Put(不同行权) | 同时防暴涨暴跌(保费更高) | +| **方向 + 尾部** | 主方向 Call(或 Put) + 反向少量 Put(或 Call) | 主观点明确,反向作灾难保险 | +| **到期滚动** | 近月双买 → 波动未出则平掉 → 换远 1 日 | 控制 Theta,需严格纪律 | + +**不适合:** + +- 预期 **窄幅震荡**(双买最亏 Theta). +- 预算只够一笔 10U(双买需 **两倍保费** 或各减半张数). +- 把双买当「稳赚」:横盘天天亏. + +### 3.2 案例分析 + +#### 案例 D:Long Strangle(赌突破) + +**背景** + +- 认为 ETH 未来 24~48h 将选方向突破,但不确定涨跌. +- 永续不想开仓,仅用期权表达波动观点. + +**操作** + +1. 选 1~2 日到期. +2. 买 **轻度 OTM Call**(行权价略高于现价). +3. 买 **轻度 OTM Put**(行权价略低于现价). +4. 各用约一半预算(如各 5 USDC),总保费 ≈ 10U. + +**盈亏示意** + +| 市场 | 结果 | +|------|------| +| 横盘 | Call、Put 均衰减 → **最大亏全部保费** | +| 大涨 | Call 盈利可能覆盖 Put 亏损 | +| 大跌 | Put 盈利可能覆盖 Call 亏损 | + +**要点:** 需要波动 **幅度** 超过「总保费对应的隐含波动门槛」才划算. + +#### 案例 E:主多观点 + 尾部 Put(期权 + 期权) + +**背景** + +- 强烈看多 24h,但怕黑天鹅砸盘. + +**操作** + +- 70% 预算买 **Call**(进攻). +- 30% 预算买 **Put**(尾部保险). + +**与「永续 + Put」区别** + +- 不占用永续保证金,无资金费. +- 但 **无 Delta 线性收益**:涨得慢可能 Call 仍亏 Theta. + +#### 案例 F:事件双买 + +**背景** + +- 非农数据公布前后 2h. + +**操作** + +- 公布前 30min:Strangle 双买. +- 公布后:波动释放则 **平盈利腿 + 平亏损腿**;勿持仓过夜除非仍看好波动. + +### 3.3 风险评估 + +| 风险类型 | 说明 | 等级 | +|----------|------|------| +| **双倍 Theta** | 两条腿同时衰减 | 很高 | +| **预算分裂** | 单腿名义过小,波动不够覆盖成本 | 高 | +| **行权价选错** | Strangle 过宽,突破仍不够回本 | 高 | +| **执行误差** | 两腿须分别下单,一腿成交一腿未成交 | 中 | +| **IV crush** | 事件后隐含波动率骤降,双买同时贬值 | 中 | +| **仅买方限制** | 无法做卖方收窄成本(如 Iron Condor) | 中 | + +**与永续对冲对比** + +| 维度 | 期权 + 永续 | 期权 + 期权 | +|------|-------------|-------------| +| 资金账户 | 两账户 | 仅主账户 | +| 趋势收益 | 永续线性 | 非线性,近月衰减快 | +| 保费成本 | 永续资金费 + 期权 | 仅期权(常更高) | +| 适合行情 | 有主仓需保 | 无仓赌波动/事件 | + +### 3.4 操盘说明(期权 + 期权) + +**预算拆分建议** + +``` +总预算 B (如 10 USDC) +├── 腿 A(主观点):B × 60%~70% +└── 腿 B(对冲/反向):B × 30%~40% + +双买 Strangle:各 50%,但须接受单腿名义减半 +``` + +**执行顺序** + +1. 先下 **流动性更好的一腿**(通常轻度 ITM 或更近 ATM),减少单腿敞口时间. +2. 再下第二腿;若第二腿限价未成交,评估是否撤单重报或放弃组合. +3. 记录两笔 `options_trades` 对应关系(备注:组合 ID / 事件名). + +**平仓纪律** + +- **时间止损:** 距到期 < 12h 且未盈利 → 考虑双平,避免 Theta 加速. +- **盈利止损:** 组合净浮盈达保费 50%~100% → 可分批平(系统翻倍提醒可作参考). +- **单边平仓风险:** 只平盈利腿会留下裸反向腿,除非有意转方向. + +--- + +## 4. 方案选型简表 + +| 你的状态 | 推荐方案 | 理由 | +|----------|----------|------| +| 子账户已有永续多仓 | **永续 + Put** | 直接保护现有 Delta | +| 子账户已有永续空仓 | **永续 + Call** | 限制逼空风险 | +| 无永续仓,赌大波动 | **期权 Strangle** | 不付资金费,纯波动 | +| 有方向观点,不想开永续 | **Call 或 Put 单腿** | 简单,保费可控 | +| 宏观事件前 | **临时 Put/Call 或双买** | 短持有,事件后平 | +| 长期持仓数月 | 本系统近月买方 **不合适** | Theta 与操作频率不匹配 | + +--- + +## 5. 与本系统功能的衔接 + +| 功能 | 对冲中的用途 | +|------|--------------| +| 永续「实盘下单 / 关键位 / 策略」 | 建立或管理被对冲的合约仓 | +| 期权页链 + 限价开平仓 | 建立买方保险或双买腿 | +| 系统设置 → 币种兑换 / 划转 | 准备 USDC 权利金 | +| 期权顶栏资金(资金户/交易户) | 检查保费是否足够 | +| 企业微信翻倍提醒 | 期权腿止盈参考,非永续平仓信号 | +| 中控 / 子代理 | **不聚合期权仓**;对冲状态需人工台账 | + +**建议人工台账字段** + +- 日期、事件、永续方向与数量、期权合约与张数、权利金、计划平仓条件、实际结果. + +--- + +## 6. 免责声明 + +- 本文档为 **教育与操盘参考**,不构成投资建议. +- 加密货币期权与永续波动极大,买方权利金可能 **全部损失**. +- 对冲 **无法消除** 风险,只能改变风险形态;请用小资金验证全流程后再放大. + +--- + +## 7. 相关文档 + +| 文档 | 内容 | +|------|------| +| [期权方案.md](./期权方案.md) | 技术架构与 API | +| [期权用法.md](./期权用法.md) | 兑换、划转、开平仓操作 | +| [期权用法.md §9](./期权用法.md) | 基础风险说明 | diff --git a/docs/期权开平仓与监控说明.md b/docs/期权开平仓与监控说明.md new file mode 100644 index 0000000..7e8a97d --- /dev/null +++ b/docs/期权开平仓与监控说明.md @@ -0,0 +1,93 @@ +# OKX 期权 — 开平仓与监控说明 + +> 独立页查看(登录后):`/options/guide` +> 对冲计划侧同步见 [对冲计划开发方案.md](./对冲计划开发方案.md) §期权腿开平仓. + +--- + +## 1. 开仓方式 + +| 项 | 规则 | +|----|------| +| 方向 | **仅买方**(Call / Put 限价买入) | +| 价格 | **卖一 ask** 限价;无卖一时可用标记估算展示,实下单仍以可挂限价为准 | +| 张数 | 1 张 = 0.01 ETH/BTC;可按预算打满或指定数量 | +| 资金 | 交易账户 **USDC**(或 USDG);不自动兑划 | +| 入口 | OKX 实例 **期权** 页列表 / T 型;对冲计划可带期权腿开仓 | + +开仓后写入 `options_trades`(open),并在持仓卡展示权利金、买盘深度、按买一可回收等. + +**同合约加仓**:每次买入再插一条 open 记录;展示权利金 / 平仓门控 / 翻倍提醒按同合约 **SUM(premium_paid)** 汇总,不再只取最新一笔. + +--- + +## 2. 平仓方式 + +### 2.1 统一规则(手动 / 目标自动共用执行核) + +1. **禁止市价平仓**(代码硬关闭,忽略 `OKX_OPTIONS_ALLOW_MARKET_CLOSE`). +2. **只锁买一**:本轮张数 = `min(持仓, 买一深度)`,限价 = 校验通过当刻的买一价. +3. **不吃买二及以下**;买一不够则只平本轮能吃掉的部分,**剩余仓位保留**,下次再平再锁新的买一. +4. 全程 `reduceOnly` 限价卖. +5. **平仓限价挂单超时自动撤**:卖出/平仓委托未成交超过默认 **10 分钟**(`OKX_OPTIONS_PENDING_TTL_SECONDS`,默认 600)由监控自动撤销,并可微信通知;UI「委托」面板实时展示挂单与剩余自动撤倒计时. + +示例:持仓 300、买一深度 200 → 本轮只平 200;剩 100 等下次「买一平仓」或目标位再次触发. + +### 2.2 手动「买一平仓」 + +- 入口:持仓卡按钮. +- **只校验有效流动性**(买一非残档、有深度). +- **不卡**「回收 ≥ 2×权利金」门控(用户主动平仓). + +### 2.3 目标位自动平仓 + +- 设置目标指数后由监控轮询;Call 指数 ≥ 目标 / Put 指数 ≤ 目标触发. +- 触发后走同一买一执行核. +- **额外门控**(不是独立自动平):仅当目标已触达时才检查;买一可回收 ≥ **权利金 × 2**,且连续约 **120 秒**(env:`OKX_OPTIONS_CLOSE_RECYCLE_MULT` / `OKX_OPTIONS_CLOSE_HOLD_SECONDS`).**到 2× 本身不会自动平仓**. +- 首次通过后,同仓**续批**只再验流动性,不再重跑 2 分钟计时. +- 无有效买一或门控未就绪 → 本轮不挂单,等下一轮;已有未成交卖平单则等成交,不撤了重挂. + +--- + +## 3. 监控逻辑 + +| 监控 | 行为 | +|------|------| +| 持仓 / 买盘预览 | 轮询刷新;净盈亏按**本轮买一可回收 − 权利金** | +| 残档买一 | 买一 ≪ 标记/内在价值(默认 < 30%) → 禁止按买盘平,UI 显示无效 | +| 未成交委托 | 期权下单区右侧「委托」列表展示开/平仓限价单,可手动撤销;页面轮询刷新 | +| 平仓挂单超时 | 卖出平仓限价超 TTL 未成交 → 自动撤单(默认 10 分钟) | +| 目标位 | 独立监控表;触发后买一平;推送企业微信(防重复) | +| 翻倍提醒 | 未实现口径达权利金 × `OKX_OPTIONS_PROFIT_ALERT_RATIO` 时推送一次 | +| 到期 | 无系统止损;到期交割/保险腿自灭(对冲计划另有退出规则) | + +--- + +## 4. 平仓校验(门控) + +| 门控 | 手动买一平 | 目标自动平 | 说明 | +|------|------------|------------|------| +| 有效流动性 | ✅ 必验 | ✅ 必验 | 残档买一 / 无买一 → 拒平 | +| 回收 ≥ 2× 权利金 + 持续 hold | ❌ | ✅ 首次 | 通过后同仓续批只验流动性 | +| 锁定买一价 | ✅ | ✅ | 下单价 = 通过校验时的买一 | +| 市价兜底 | ❌ | ❌ | 永不市价 | + +--- + +## 5. 风险点 + +1. **流动性不足**:只平买一深度,大仓位可能多次才能平完;若买一突然撤档,限价可能挂着 — 超 TTL 会自动撤,之后需再次点平或等目标触发. +2. **开仓挂单**:买入委托不在超时自动撤范围(仅平仓卖单);可在「委托」面板手动撤销. +3. **残档假买一**:若未拦住残档会严重贱卖 — 系统用标记/内在价值比例拦截,但不等于保证最优成交价. +4. **权利金沉没**:手动可在未达 2× 时平仓,可能主动止损或提前锁利不及预期. +5. **无市价强平**:盘口真空时系统**不会**市价砸盘,仓位可能留到到期. +6. **目标位只看指数**:触达后仍受买一/2×门控约束,可能「到价却平不掉」. +7. **对冲计划腿**:期权腿退出规则见对冲方案;独立期权页平仓勿与计划状态脱节. + +--- + +## 6. 相关文档 + +- [期权用法.md](./期权用法.md) — 资金兑划与页面操作 +- [期权方案.md](./期权方案.md) — env 与架构 +- [对冲计划开发方案.md](./对冲计划开发方案.md) — 永期/期期与期权腿 diff --git a/docs/期权方案.md b/docs/期权方案.md new file mode 100644 index 0000000..c2bb51f --- /dev/null +++ b/docs/期权方案.md @@ -0,0 +1,151 @@ +# OKX 期权模块 — 技术方案 + +> 适用范围:`crypto_monitor_okx` 实例;与永续子账户并行,不新增 PM2 进程. + +## 1. 目标 + +在现有 OKX 监控实例中增加 **USDⓈ 本位期权(买方)** 能力: + +- 永续/关键位:继续走 **子账户 API-A**(现有 `OKX_API_*`) +- 期权:走 **主账户 API-B**(`OKX_OPTIONS_API_*`) +- 资金展示对齐 OKX:**资金账户 / 交易账户**,分币种显示 USDT,USDC,USDG +- 支持 **手动 USDT→USDC 兑换** 与 **USDC 账户划转** +- **无总资金池上限**;单笔权利金上限可配置(默认 10 USDC) + +## 2. 交易规则(硬约束) + +| 规则 | 说明 | +|------|------| +| 仅买方 | 开仓 `buy`,平仓 `sell`;禁止卖方开仓 | +| 产品 | `BTC-USD_UM` / `ETH-USD_UM`(线性,USDC/USDG 结算) | +| 到期 | 仅展示 ≤2 日到期合约(可配置 `OKX_OPTIONS_MAX_DTE_DAYS`) | +| 虚实 | 仅 **轻度实值**(`OKX_OPTIONS_ITM_ONLY`) | +| 合约规格 | **1 张 = 0.01 ETH/BTC**(`ctMult=0.01`,以接口为准) | +| 报价单位 | 盘口 ask/bid = **每 1 ETH/BTC** 的 USD 价 | +| 权利金 | `总权利金 = 报价 × ETH数量`;`张数 = ETH数量 / 0.01` | +| 单笔预算 | `≤ OKX_OPTIONS_TRADE_BUDGET_USDC`(默认 10),算张数 × `OKX_OPTIONS_BUDGET_BUFFER`(默认 0.95) | +| 开仓 | 限价买单,价格 = 卖一 | +| 平仓 | 限价卖单,价格 = 买一(市价需显式开启且二次确认) | +| 监控 | 浮盈 / 已付权利金 ≥ 100% → 企业微信推送一次 | + +## 3. 架构 + +``` +crypto_okx(单 PM2) +├── exchange (swap) ← OKX_API_* 子账户 +└── exchange_options ← OKX_OPTIONS_API_* 主账户 + +lib/options/ +├── okx_options_lib.py # 封装于 lib/exchange/ +├── options_pricing_lib.py +├── options_db.py +├── options_monitor_lib.py +└── options_register.py # 路由 + 监控线程 +``` + +**隔离:** 期权模块只调用 `exchange_options`;永续逻辑只调用 `exchange`. + +## 4. 资金与兑换 + +### 4.1 展示(期权页顶栏) + +| 账户 | 币种 | +|------|------| +| 资金账户 | USDT,USDC(若有) | +| 交易账户 | USDT,USDC,USDG(若有) | + +不展示「练手池」等抽象记账名称. + +### 4.2 推荐操作流程 + +``` +资金账户 USDT + → [手动兑换 USDT→USDC](OKX Convert API,资金账户内) + → [划转到交易账户](USDC) + → 交易账户 USDC + → [限价买入期权] +``` + +### 4.3 API + +| 接口 | OKX | +|------|-----| +| 余额 | `fetch_balance`(funding / trading)+ `GET /api/v5/asset/balances` | +| 询价兑换 | `POST /api/v5/asset/convert/estimate-quote` | +| 确认兑换 | `POST /api/v5/asset/convert/trade` | +| 划转 | `exchange.transfer(ccy, amt, from, to)` | + +## 5. 配置项(`.env`) + +```bash +OKX_OPTIONS_ENABLED=false +OKX_OPTIONS_API_KEY= +OKX_OPTIONS_API_SECRET= +OKX_OPTIONS_API_PASSPHRASE= +OKX_OPTIONS_ACCOUNT_LABEL=主账户·期权 + +OKX_OPTIONS_TRADE_BUDGET_USDC=10 +OKX_OPTIONS_BUDGET_BUFFER=0.95 +OKX_OPTIONS_DEFAULT_UNDERLY=ETH +OKX_OPTIONS_MAX_DTE_DAYS=2 +OKX_OPTIONS_ITM_MAX_DIST_USD=30 +OKX_OPTIONS_PROFIT_ALERT_RATIO=1.0 +OKX_OPTIONS_POLL_SECONDS=15 +OKX_OPTIONS_TD_MODE=cross +# 市价平仓已在代码中硬关闭,此变量无效,可删 +# OKX_OPTIONS_ALLOW_MARKET_CLOSE=false +OKX_OPTIONS_CLOSE_RECYCLE_MULT=2 +OKX_OPTIONS_CLOSE_HOLD_SECONDS=120 +# 平仓限价挂单超时自动撤(秒),默认 600=10 分钟;联调可临时改 60 +OKX_OPTIONS_PENDING_TTL_SECONDS=600 +``` + +平仓执行:**只锁买一限价**,说明见 [期权开平仓与监控说明.md](./期权开平仓与监控说明.md);线上 `/options/guide`. + +修改 `.env` 后须 `pm2 restart crypto_okx`. + +## 6. 数据库 + +### `options_trades` + +记录本地开仓/平仓,权利金,翻倍提醒状态. + +### `options_convert_log` / `options_transfer_log` + +可选记录兑换与划转操作. + +## 7. HTTP 路由 + +| 方法 | 路径 | +|------|------| +| GET | `/options` | +| GET | `/options/guide` | 开平仓与监控说明(独立页) | +| GET | `/api/options/balances` | +| GET | `/api/options/chain` | +| GET | `/api/options/quote` | +| POST | `/api/options/open` | +| POST | `/api/options/close` | +| POST | `/api/options/convert/quote` | +| POST | `/api/options/convert/execute` | +| POST | `/api/options/transfer` | +| GET | `/api/options/positions` | + +## 8. 分阶段交付 + +1. **基础设施**:双 API,余额,文档,设置页说明 +2. **兑换 + 划转**:资金账户 USDT→USDC,划转到交易户 +3. **交易**:链,报价,开平仓,持仓 +4. **监控**:翻倍微信提醒 + +## 9. 不在一期范围 + +- 卖方,组合单,RFQ +- 自动 USDT↔USDC +- `manual-agent-okx` / 中控聚合 +- 币本位期权 + +## 10. 安全 + +- 期权 API:**交易 + 读**,禁止提币 +- 日志不输出 Secret +- 下单前校验 `client is exchange_options` diff --git a/docs/期权用法.md b/docs/期权用法.md new file mode 100644 index 0000000..ec25efb --- /dev/null +++ b/docs/期权用法.md @@ -0,0 +1,149 @@ +# OKX 期权 — 使用说明 + +## 1. 前置条件 + +1. OKX **主账户**已开通期权(USDⓈ 本位),且 App 中可见 `ETHUSD UM` / `BTCUSD UM`. +2. 在 `crypto_monitor_okx/.env` 配置 **期权专用 API**(与永续子账户分开): + +```bash +OKX_OPTIONS_ENABLED=true +OKX_OPTIONS_API_KEY=你的主账户Key +OKX_OPTIONS_API_SECRET=... +OKX_OPTIONS_API_PASSPHRASE=... +``` + +3. 重启实例:`pm2 restart crypto_okx` + +> 永续仍用原有 `OKX_API_*`(子账户);期权只用 `OKX_OPTIONS_API_*`(主账户). + +## 2. 资金准备 + +期权权利金使用 **USDC 或 USDG**,不能直接用 USDT 买入. + +### 推荐步骤 + +1. 打开 **期权** 页,查看顶栏: + - **资金账户**:USDT 余额 + - **交易账户**:USDC 余额(买期权从这里扣) +2. **币种兑换**(资金账户内) + - 从 USDT 兑换为 USDC + - 先点 **询价**,确认预估获得量后点 **确认兑换** +3. **账户划转** + - 从:资金账户 → 到:交易账户 + - 币种:USDC + - 将兑换得到的 USDC 划到交易账户 +4. 确认 **交易账户 USDC** 足够支付本笔权利金 + +系统 **不会** 自动兑换或划转,避免误动资金. + +## 3. 下单流程 + +1. 顶栏进入 **期权** +2. 选择 **ETH** 或 **BTC** +3. 选择 **到期日**(默认仅 1~2 日) +4. 选择 **看涨 Call** 或 **看跌 Put** +5. 在行权价列表中选 **轻度实值** 合约 +6. 查看: + - **卖一价**(每 1 ETH/BTC 的报价) + - **张数 / ETH 数量** + - **预估权利金**(USDC) +7. 选择 **按预算打满**(默认 10U×0.95)或 **指定 ETH 数量** +8. 点击 **限价买入**(价格 = 卖一) + +### 张数说明 + +- **1 张 = 0.01 ETH**(或 0.01 BTC)— 与 OKX App「合约价值」一致 +- 盘口报价是 **每 1 ETH** 的价格 + 例:报价 15.6,买 0.5 ETH(50 张)→ 权利金 ≈ 15.6 × 0.5 = **7.8 USDC** + +## 4. 持仓与平仓 + +持仓表字段对齐 OKX:合约,张数,开仓均价,标记价,净盈亏,收益率,到期等. + +**买一平仓:** + +1. 在持仓卡点击 **买一平仓** +2. 系统重读盘口并校验有效买一(非残档) +3. 本轮只按买一深度限价卖出;买一不够则剩余下次再平 +4. **市价平仓已禁用**(代码硬关闭) + +目标位自动平另需「可回收 ≥ 2×权利金并持续约 2 分钟」;细则见独立说明: + +- 仓库文档:[期权开平仓与监控说明.md](./期权开平仓与监控说明.md) +- 线上(登录后):`/options/guide` + +## 5. 微信提醒 + +当某笔持仓 **未实现盈亏 ≥ 已付权利金的 100%**(翻倍)时,会发 **一条** 企业微信提醒(同一笔只提醒一次). + +需已配置 `WECHAT_WEBHOOK`. + +## 6. 与永续的关系 + +| | 永续(子账户) | 期权(主账户) | +|--|----------------|----------------| +| API | `OKX_API_*` | `OKX_OPTIONS_API_*` | +| 页面 | 实盘下单 / 关键位 | 期权 | +| 资金顶栏 | USDT 资金户+交易户 | 期权页单独显示 USDC 等 | + +两套资金 **不合并** 显示. + +## 7. 配置说明 + +| 变量 | 默认 | 含义 | +|------|------|------| +| `OKX_OPTIONS_TRADE_BUDGET_USDC` | 10 | 单笔权利金上限 | +| `OKX_OPTIONS_BUDGET_BUFFER` | 0.95 | 算张数时预留 5% 缓冲 | +| `OKX_OPTIONS_MAX_DTE_DAYS` | 2 | 最多选几天内到期 | +| `OKX_OPTIONS_ITM_MAX_DIST_USD` | 30 | 轻度实值:价内不超过多少 USD | +| `OKX_OPTIONS_PROFIT_ALERT_RATIO` | 1.0 | 浮盈/权利金 ≥ 此值推送 | + +## 8. 期权复盘(含对冲) + +仅 **OKX** 实例提供独立页 **期权复盘**(`/options/review`),与合约「交易记录与复盘」完全隔离. + +### 数据来源 + +| 类型 | source_type | 来源 | 粒度 | +|------|-------------|------|------| +| 纯期权 | `option_spot` | 本地 `options_trades` 已平仓 | 一仓一条 | +| 永期对冲 | `perp_options` | 本地 `hedge_plans` 且 `status=closed` | **一计划一条** | +| 期期对冲 | `options_options` | 同上 | **一计划一条** | + +- 打开复盘页即自动读取本地记录,**不访问交易所**. +- 对冲盈亏主口径:`realized_pnl_total`;详情另显永续/期权分项. +- 若某纯期权 `inst_id` 已出现在对冲腿中,默认标记排除,避免总盈亏双计. +- 人工复盘字段存在 `options_review_entries`,刷新本地源**不会覆盖**. + +### 图片 + +- 目录:`static/images/options_journal/` +- 文件名:`options_journal_{draftId}_{5m|15m|1h|4h}.ext`(与合约复盘同周期槽位) +- 备份时与 `crypto.db` 一并打包即可;勿与合约 `journal_*` 截图混用. + +### 页面 + +顶部三个 Tab:**期权交易记录** / **期期对冲记录** / **永期对冲记录**.点击列表行后在下方打开「复盘记录上传」,支持四周期即时截图与情绪标签. + +### 统计 + +同页 KPI + 分组:类型、标的、策略标签、对冲结束原因、持有周期、Call/Put.策略维度仅统计已填策略标签的记录. + +## 9. 常见问题 + +**Q:为什么买不了?** +- 交易账户 USDC 不足 → 先兑换再划转 +- 卖一价过高,10U 预算买不到 1 张 → 选更便宜合约或提高 `OKX_OPTIONS_TRADE_BUDGET_USDC` +- 期权 API 未配置或 `OKX_OPTIONS_ENABLED=false` + +**Q:报价 15 是每张 15U 吗?** +- 不是.15 是 **每 1 ETH** 的报价;每张(0.01 ETH)约 0.15 USDC. + +**Q:子账户能开期权吗?** +- 本系统期权走主账户 API;子账户永续不受影响. + +## 10. 风险说明 + +- 买方最大亏损为 **权利金**;近期实值仍会时间衰减 +- 限价单可能因无流动性未成交 +- 请先在小额下验证兑换,划转,开平仓全流程 diff --git a/docs/系统设置说明.md b/docs/系统设置说明.md new file mode 100644 index 0000000..6ef8f2b --- /dev/null +++ b/docs/系统设置说明.md @@ -0,0 +1,168 @@ +# 系统设置页说明 + +本文档描述各交易实例 Web 端 **「系统设置」** 页各区块功能,与 env 配置页的分工,以及导航显示开关规则. + +--- + +## 1. 页面结构 + +系统设置为 **两列卡片** 布局,各区块可在「导航显示」中单独开关(见第 2 节). + +| 区块 | 默认显示 | 说明 | +|------|----------|------| +| 导航显示 | 固定 | 控制顶栏与其它设置区块是否出现 | +| 账户密码修改 | 可关 | 修改网页登录用户名/密码 | +| 永续资金划转 | 可关 | 手动在资金账户与交易账户间划转 USDT | +| 数据导出 | 可关 | 下载 CSV | +| 币种兑换 | 可关 | 仅 OKX 期权相关 | +| 期权资金划转 | 可关 | 仅 OKX | +| 期权设置面板 | OKX 有模块时 | 较大块,占整行 | + +**固定不可隐藏**(顶栏):关键位监控,实盘下单,系统设置. + +--- + +## 2. 导航显示 + +### 2.1 顶栏导航开关 + +| 开关 | 对应 Tab | 默认 | +|------|----------|------| +| 数据看板 | 数据看板(本户活跃监控总览) | **关闭** | +| 策略交易 | 策略交易 | 开 | +| 策略交易记录 | 策略交易记录 | 开 | +| 交易记录与复盘 | 交易记录与复盘 | 开 | +| 统计分析 | 统计分析 | 开 | +| 风控说明 | 风控说明 | 开 | +| env 配置 | env 配置 | 开 | +| 期权 | 期权(仅 OKX 等有期权模块时有效) | 开 | + +保存后 **立即生效**,无需重启.中控 iframe 内嵌导航同步生效. + +### 2.2 系统设置内区块开关 + +| 开关 | 隐藏内容 | +|------|----------| +| 资金划转 | 永续资金划转卡片 | +| 数据导出 | 数据导出卡片 | +| 账户密码修改 | 改密卡片 | +| 期权币种兑换 | OKX 兑换卡片 | +| 期权资金划转 | OKX 期权划转卡片 | + +--- + +## 3. 账户密码修改 + +### 用途 + +- 修改 **直链打开实例** 时 `/login` 使用的用户名与密码. +- 写入本实例目录 `.env` 的 `APP_USERNAME`,`APP_PASSWORD`. +- **三所建议使用相同账号**,便于记忆;本页仅改 **当前实例** 的 `.env`,若需三所一致请分别保存或后续做批量同步. + +### 与中控 / 密钥的关系 + +| 项目 | 是否在系统设置改 | 说明 | +|------|------------------|------| +| 网页登录密码 | ✅ | 本区块 | +| 中控通信密钥 `HUB_BRIDGE_TOKEN` | ❌ | 部署时自动生成,中控与实例一致 | +| 登录会话密钥 `FLASK_SECRET_KEY` | ❌ | 部署时自动生成,三所相同 | +| 交易所 API | ❌ | 在 **env 配置** 页(各所自配) | +| AI 复盘 / OpenAI | ❌ | 在中控 **系统设置 → AI 配置**(同步三所) | + +### 操作流程 + +1. 输入 **当前密码**(与 `.env` 中 `APP_PASSWORD` 一致). +2. 可选填 **新用户名**;不填则保持原用户名. +3. 输入 **新密码** 与 **确认密码**(至少 6 位). +4. 保存后 **自动重启当前实例**(PM2),请用新密码登录. + +经中控 SSO 打开实例时,通常无需输入实例密码;改密主要影响 **直链访问**. + +--- + +## 4. 永续资金划转 + +### 用途 + +在 **子账户永续** 场景下,于 **资金账户(funding)** 与 **交易账户(swap)** 之间手动划转 USDT. + +### 与 env 配置的关系 + +| 能力 | 系统设置 | env 配置 | +|------|----------|----------| +| **手动**划转一笔 | ✅ 本区块 | ❌ | +| **自动**每日划转规则 | ❌ | ✅「自动划转」卡片 | + +自动划转规则(开关,目标余额,整点等)在 env 配置中维护,见 [env配置说明.md](./env配置说明.md). + +--- + +## 5. 数据导出 + +提供 CSV 下载(版本号见页内标注): + +| 链接 | 内容 | +|------|------| +| 交易记录 | 成交/订单相关导出 | +| 复盘记录 | 复盘日记 | +| 关键位(当前) | 当前关键位列表 | +| 关键位历史 | 历史关键位 | + +导出为只读操作,不修改配置. + +--- + +## 6. 期权相关(仅 OKX) + +当实例启用期权模块时,系统设置可能包含: + +- **币种兑换**:期权账户内币种兑换操作 +- **期权资金划转**:期权与永续/资金账户间划转 +- **期权设置面板**:页内期权参数与状态(大块区域) + +是否在顶栏显示「期权」Tab,由 **导航显示 → 期权** 控制;是否在设置页显示兑换/划转卡片,由对应子开关控制. + +期权 env 参数(API,预算,策略默认值)在 **env 配置 → 期权账户** 维护,见 [期权用法.md](./期权用法.md). + +--- + +## 7. 顶栏与设置页差异 + +| 页面 | 顶栏资金信息 | 说明 | +|------|--------------|------| +| 关键位,实盘,策略等 | 显示 | 含资金,盈亏等 | +| 系统设置,风控说明,env 配置 | 隐藏资金条 | 与实盘顶栏共用组件,设置类页面简化展示 | + +主题切换(明/暗)在系统设置页可用(若已接入主题切换 UI). + +--- + +## 8. 权限与安全 + +- 所有设置 API 需 **已登录**(或部署时 `APP_AUTH_DISABLED=true` 的联调环境). +- 改密,env 保存,PM2 重启等写操作 **不接受** 仅带 `X-Hub-Token` 的中控请求修改(防止中控误改实例配置). +- 生产环境建议 `APP_AUTH_DISABLED=false`,公网务必开启登录校验. + +--- + +## 9. 首次部署时的账号与密钥(规划) + +以下由 **部署脚本** 自动完成,**不在** 系统设置或 env 配置页手工填写: + +1. **生成 `HUB_BRIDGE_TOKEN`** → 写入中控 + 三实例 `.env`(相同). +2. **生成 `FLASK_SECRET_KEY`** → 写入三实例 `.env`(三所相同). +3. **生成初始 `APP_USERNAME=admin`,`APP_PASSWORD=admin123`** → 写入三实例(仅当尚未配置时);用户日后在 **系统设置** 改密. + +脚本应对 **已有非空值** 跳过写入,避免覆盖生产环境. + +--- + +## 10. 相关文档 + +| 文档 | 内容 | +|------|------| +| [env配置说明.md](./env配置说明.md) | env 配置页字段与密钥分工 | +| [account-risk-cooldown.md](./account-risk-cooldown.md) | 账户冷静期规则 | +| [auto-transfer-daily.md](./auto-transfer-daily.md) | 自动划转 | +| [daily-open-limit.md](./daily-open-limit.md) | 单日开仓限制 | +| [manual_trading_hub/使用说明.md](../manual_trading_hub/使用说明.md) | 中控与 SSO | diff --git a/lib/__init__.py b/lib/__init__.py new file mode 100644 index 0000000..54e157b --- /dev/null +++ b/lib/__init__.py @@ -0,0 +1 @@ +"""crypto_monitor shared libraries.""" diff --git a/lib/ai/__init__.py b/lib/ai/__init__.py new file mode 100644 index 0000000..ab164b5 --- /dev/null +++ b/lib/ai/__init__.py @@ -0,0 +1 @@ +"""Shared library package.""" diff --git a/lib/ai/ai_client.py b/lib/ai/ai_client.py new file mode 100644 index 0000000..454bffd --- /dev/null +++ b/lib/ai/ai_client.py @@ -0,0 +1,540 @@ +"""大模型调用:OpenAI 兼容接口(默认)或本机 Ollama 二选一. + +配置从 os.environ 惰性读取:各实例 app.py 在 import 本模块后才 load_env_file(.env), +若在 import 时缓存变量会导致 OPENAI_API_KEY 始终为空. +""" +from __future__ import annotations + +import base64 +import os +import re +from typing import List, Optional, Sequence, Tuple + +import requests + + +def _env_str(name: str, default: str = "") -> str: + v = os.getenv(name) + if v is None: + return default + return str(v).strip() + + +def _ai_timeout_seconds(*, image_count: int = 0, chat: bool = False) -> int: + if chat: + try: + return max(30, int(_env_str("CHAT_AI_TIMEOUT_SECONDS", "300") or "300")) + except ValueError: + return 300 + if image_count > 0: + try: + return max(30, int(_env_str("AI_REVIEW_TIMEOUT_SECONDS", "300") or "300")) + except ValueError: + return 300 + try: + return max(10, int(_env_str("AI_TIMEOUT_SECONDS", "120") or "120")) + except ValueError: + return 120 + + +def _ai_provider() -> str: + return (_env_str("AI_PROVIDER", "openai") or "openai").lower() + + +def _openai_api_base() -> str: + base = _env_str("OPENAI_API_BASE", "https://op.bz121.com/v1") or "https://op.bz121.com/v1" + return base.rstrip("/") + + +def _openai_api_key() -> str: + return _env_str("OPENAI_API_KEY") or _env_str("AI_API_KEY") + + +def _openai_model() -> str: + return _env_str("OPENAI_MODEL", "gemma4:e4b") or "gemma4:e4b" + + +def _ollama_api() -> str: + return _env_str("OLLAMA_API", "http://127.0.0.1:11434/api/generate") or "http://127.0.0.1:11434/api/generate" + + +def _ollama_model() -> str: + return _env_str("AI_MODEL", "huihui_ai/deepseek-r1-abliterated:latest") or "huihui_ai/deepseek-r1-abliterated:latest" + + +def _use_openai() -> bool: + return _ai_provider() in ("openai", "openai_compatible", "gateway") + + +def _image_mime_for_path(path: str) -> str: + ext = os.path.splitext(str(path or ""))[1].lower() + if ext == ".png": + return "image/png" + if ext in (".jpg", ".jpeg"): + return "image/jpeg" + if ext == ".webp": + return "image/webp" + if ext == ".gif": + return "image/gif" + return "image/jpeg" + + +def _read_image_base64(image_path: str) -> Optional[tuple]: + try: + with open(image_path, "rb") as f: + b64 = base64.b64encode(f.read()).decode("utf-8") + return b64, _image_mime_for_path(image_path) + except Exception: + return None + + +def _collect_images( + image_paths: Optional[Sequence[str]] = None, + images_b64: Optional[Sequence[str]] = None, +) -> List[tuple]: + out: List[tuple] = [] + for p in image_paths or []: + item = _read_image_base64(p) + if item: + out.append(item) + for b in images_b64 or []: + if b: + out.append((str(b), "image/jpeg")) + return out + + +def _openai_chat_url() -> str: + base = _openai_api_base() + if base.endswith("/chat/completions"): + return base + return f"{base}/chat/completions" + + +def _openai_message_text(msg: dict) -> str: + content = msg.get("content") + if isinstance(content, list): + parts: list[str] = [] + for part in content: + if isinstance(part, dict) and part.get("type") == "text": + parts.append(str(part.get("text") or "")) + content = "".join(parts) + text = str(content or "").strip() + if text: + return text + # 部分网关/模型把正文放在 reasoning_content;gemma 系则常写在 reasoning + for key in ("reasoning_content", "reasoning"): + alt = str(msg.get(key) or "").strip() + if not alt: + continue + # 英文链式思考不算可交付正文,留给上层按 finish=length 重试 + low = alt[:80].lower() + if low.startswith("here's a thinking process") or low.startswith("here is a thinking process"): + continue + if low.startswith("thinking process") or "analyze the request" in low: + continue + return alt + return "" + + +def _apply_max_tokens(body: dict, max_tokens: int | None, *, chat: bool = False) -> None: + if max_tokens is not None and max_tokens > 0: + mt = int(max_tokens) + body["max_tokens"] = mt + # 部分 OpenAI 兼容网关对 max_tokens + max_completion_tokens 双写不友好 + if chat: + body["max_completion_tokens"] = mt + + +def _openai_chat_completion( + messages: list[dict], + *, + temperature: float, + max_tokens: int | None = None, + image_count: int = 0, + chat: bool = False, +) -> Tuple[str, str]: + api_key = _openai_api_key() + if not api_key: + return "AI 调用失败:未配置 OPENAI_API_KEY(请在当前实例目录 .env 中设置,修改后需重启服务)", "error" + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + body: dict = { + "model": _openai_model(), + "messages": messages, + "temperature": temperature, + "stream": False, + } + _apply_max_tokens(body, max_tokens, chat=chat) + r = requests.post( + _openai_chat_url(), + headers=headers, + json=body, + timeout=_ai_timeout_seconds(image_count=image_count, chat=chat), + ) + r.raise_for_status() + data = r.json() + choices = data.get("choices") or [] + if not choices: + return "AI 生成失败:响应无 choices", "error" + choice = choices[0] or {} + msg = choice.get("message") or {} + text = _openai_message_text(msg) + finish = str(choice.get("finish_reason") or "") + + # gemma 等会先把 token 花在 reasoning 上:过小 max_tokens 时 content 为空且 finish=length + if not text: + retry_body = dict(body) + retry_body.pop("max_completion_tokens", None) + cur = int(retry_body.get("max_tokens") or 0) + retry_body["max_tokens"] = max(cur, 4096 if chat else 8192) + r2 = requests.post( + _openai_chat_url(), + headers=headers, + json=retry_body, + timeout=_ai_timeout_seconds(image_count=image_count, chat=chat), + ) + r2.raise_for_status() + data2 = r2.json() + choices2 = data2.get("choices") or [] + if choices2: + choice2 = choices2[0] or {} + msg2 = choice2.get("message") or {} + text2 = _openai_message_text(msg2) + finish2 = str(choice2.get("finish_reason") or finish) + if text2: + return text2, finish2 + finish = finish2 or finish + if not text: + return f"AI 生成失败:空内容(finish={finish or '?'})", finish or "error" + return text, finish + + +def _generate_openai( + prompt: str, + images: List[tuple], + temperature: float, + *, + max_tokens: int | None = None, +) -> str: + if images: + content: List[dict] = [{"type": "text", "text": prompt}] + for b64, mime in images: + content.append( + { + "type": "image_url", + "image_url": {"url": f"data:{mime};base64,{b64}"}, + } + ) + messages = [{"role": "user", "content": content}] + else: + messages = [{"role": "user", "content": prompt}] + text, _reason = _openai_chat_completion( + messages, + temperature=temperature, + max_tokens=max_tokens, + image_count=len(images), + ) + return text + + +def _generate_ollama( + prompt: str, + images: List[tuple], + temperature: float, + *, + max_tokens: int | None = None, + chat: bool = False, +) -> Tuple[str, str]: + options: dict = {"temperature": temperature} + if max_tokens is not None and max_tokens > 0: + options["num_predict"] = int(max_tokens) + payload = { + "model": _ollama_model(), + "prompt": prompt, + "stream": False, + "options": options, + } + if images: + payload["images"] = [b64 for b64, _mime in images] + r = requests.post( + _ollama_api(), + json=payload, + timeout=_ai_timeout_seconds(image_count=len(images), chat=chat), + ) + r.raise_for_status() + data = r.json() + text = (data.get("response") or "").strip() or "AI 生成失败" + return text, str(data.get("done_reason") or "") + + +def ai_generate( + prompt: str, + *, + image_paths: Optional[Sequence[str]] = None, + images_b64: Optional[Sequence[str]] = None, + temperature: float = 0.2, + max_tokens: int | None = None, +) -> str: + """统一文本生成;失败时返回以「AI 调用失败」开头的说明.""" + images = _collect_images(image_paths, images_b64) + try: + if _use_openai(): + out = _generate_openai(prompt, images, temperature, max_tokens=max_tokens) + else: + out, _reason = _generate_ollama(prompt, images, temperature, max_tokens=max_tokens) + # 附图导致空正文时,降级为纯文本再试一次(复盘仍可用) + if ( + images + and isinstance(out, str) + and (out.startswith("AI 生成失败:空内容") or out.startswith("AI 调用失败")) + ): + if _use_openai(): + return _generate_openai(prompt, [], temperature, max_tokens=max_tokens or 8192) + text, _reason = _generate_ollama(prompt, [], temperature, max_tokens=max_tokens or 8192) + return text + return out + except requests.HTTPError as e: + detail = "" + try: + detail = (e.response.text or "")[:500] + except Exception: + pass + prov = "OpenAI" if _use_openai() else "Ollama" + return f"AI 调用失败({prov} HTTP {e.response.status_code if e.response else '?'}):{detail or str(e)}" + except Exception as e: + prov = "OpenAI" if _use_openai() else "Ollama" + return f"AI 调用失败({prov}):{str(e)}" + + +_CHAT_CONTINUE_USER = ( + "你上一条回复在中途截断了.请从断点处继续写完,不要重复已写内容," + "保持同一语气;编号列表每条单独一行." +) +_CHAT_END_CHARS = ".!?.!?\"」』))>】" +_INCOMPLETE_TAIL_RE = re.compile( + r"(不会|不能|没有|会不会|是不是|够不够|能不能|要不要|如何|怎么|什么|哪里|多少|对吗|怎么样|" + r"这个\.\.\.|这个…|\.\.\.\d+\.|\d+\.)$" +) + + +def _looks_truncated(text: str) -> bool: + t = (text or "").rstrip() + if len(t) < 16: + return False + if t[-1] in _CHAT_END_CHARS: + return False + if _INCOMPLETE_TAIL_RE.search(t): + return True + if t.endswith("…") or t.endswith("..."): + return True + if re.search(r"\d+\.\s*$", t): + return True + return t[-1] not in ",,,;;::\n" + + +def _should_continue(reason: str, full_text: str) -> bool: + if reason in ("length", "max_tokens", "model_length"): + return True + return _looks_truncated(full_text) + + +def _chat_continue_message(full_text: str) -> str: + tail = full_text[-500:] if len(full_text) > 500 else full_text + return ( + f"{_CHAT_CONTINUE_USER}\n\n" + f"已写到最后这几句:\n「{tail}」\n\n" + f"请从断点接着写完.不要重复前文;最后一句话必须以句号,问号或感叹号结束." + ) + + +def _chat_continue_system(system: str) -> str: + return ( + f"{system.strip()}\n\n" + "【续写模式】只输出断点后的剩余内容,不要重复前文;" + "列表每条单独一行;必须以句号,问号或感叹号收尾." + ) + + +def ai_generate_chat( + *, + system: str, + user: str, + temperature: float = 0.5, + images_b64: Optional[Sequence[str]] = None, + max_tokens: int = 8192, + max_continuations: int = 4, +) -> str: + """聊天专用:system/user 分消息;输出触顶时轻量续写(不重复巨型上下文).""" + images = _collect_images(None, images_b64) + max_rounds = max(1, int(max_continuations) + 1) + try: + if _use_openai(): + if images: + user_content: List[dict] | str = [{"type": "text", "text": user.strip()}] + for b64, mime in images: + user_content.append( + { + "type": "image_url", + "image_url": {"url": f"data:{mime};base64,{b64}"}, + } + ) + else: + user_content = user.strip() + base_user_msg = {"role": "user", "content": user_content} + messages: list[dict] = [ + {"role": "system", "content": system.strip()}, + base_user_msg, + ] + + parts: list[str] = [] + for attempt in range(max_rounds): + chunk, reason = _openai_chat_completion( + messages, + temperature=temperature, + max_tokens=max_tokens, + image_count=len(images) if attempt == 0 else 0, + chat=True, + ) + if chunk.startswith("AI 调用失败") or chunk.startswith("AI 生成失败"): + return chunk if not parts else "".join(parts).strip() + parts.append(chunk) + full = "".join(parts) + if not _should_continue(reason, full) or attempt >= max_rounds - 1: + break + messages = [ + {"role": "system", "content": _chat_continue_system(system)}, + {"role": "assistant", "content": full}, + {"role": "user", "content": _chat_continue_message(full)}, + ] + return "".join(parts).strip() or "AI 生成失败:空内容" + + prompt = f"{system.strip()}\n\n---\n\n{user.strip()}" + parts: list[str] = [] + for attempt in range(max_rounds): + if parts: + full = "".join(parts) + current_prompt = ( + f"{_chat_continue_system(system)}\n\n" + f"【你已写道】\n{full}\n\n{_chat_continue_message(full)}" + ) + else: + current_prompt = prompt + chunk, reason = _generate_ollama( + current_prompt, + images if not parts else [], + temperature, + max_tokens=max_tokens, + chat=True, + ) + if chunk.startswith("AI 生成失败") and not parts: + return chunk + if chunk.startswith("AI 生成失败"): + break + parts.append(chunk) + full = "".join(parts) + if not _should_continue(reason, full) or attempt >= max_rounds - 1: + break + return "".join(parts).strip() or "AI 生成失败:空内容" + except requests.HTTPError as e: + detail = "" + try: + detail = (e.response.text or "")[:500] + except Exception: + pass + prov = "OpenAI" if _use_openai() else "Ollama" + return f"AI 调用失败({prov} HTTP {e.response.status_code if e.response else '?'}):{detail or str(e)}" + except Exception as e: + prov = "OpenAI" if _use_openai() else "Ollama" + return f"AI 调用失败({prov}):{str(e)}" + + +def ai_review(trades_text: str, period_title: str, image_paths=None) -> str: + # 附图过多时网关易超时/空回复;保留前几张即可支撑第5节 + raw_paths = [p for p in (image_paths or []) if p] + try: + max_imgs = max(0, int(_env_str("AI_REVIEW_MAX_IMAGES", "4") or "4")) + except ValueError: + max_imgs = 4 + capped_paths = raw_paths[:max_imgs] if max_imgs else [] + n_img = len(capped_paths) + n_skipped = max(0, len(raw_paths) - n_img) + period_label = "周" if "周" in str(period_title) else "日" + attach_note = ( + f"ℹ️ 【系统说明:已向模型附带 {n_img} 张复盘附图(自动K线或上传截图)" + + (f",另跳过 {n_skipped} 张以控制体积" if n_skipped else "") + + ",请结合附图分析第5节.】\n\n" + if n_img + else "ℹ️ 【系统说明:本次未附带复盘附图,第5节请写明「无附图,无法看图」;保存复盘记录时可勾选「自动生成K线图」.】\n\n" + ) + prompt = f""" +你是一位专业交易教练.下面是用户的{period_title}交易记录,请做简洁,可执行的复盘(中文). + +【硬性规则 — 必须遵守】 +- 你只能根据「交易记录」里**明确出现的字段**陈述事实;禁止编造:是否触发止损,是否扛单,亏损是否扩大,图上具体结构/进出场点位等记录里**没有**的信息. +- 「平仓/离场」只是交易员自述摘要,不是客观成交明细;若记录未写明代币是否打到止损价,是否软件平仓等,不要断言执行路径,可用「在记录有限前提下,一种可能是……」或简短写「执行路径记录不足,无法判断」. +- 「提前离场」类结论必须优先依据记录中的「提前离场记录」字段;若该段全为「无」或未出现有效内容,不得写道「明显扛单」「拒不止损」「未执行硬止损」等. +- 实际RR为负只说明结果相对于预期RR不利,不等同于「风控失灵」或「止损纪律崩溃」,除非记录里另有依据. +- 禁止用语:人身攻击,夸张定性(如「致命伤」「灾难」);语气克制,对事不对人. +- 若有截图且你能辨认,再结合图讨论;看不清或无明确定位则明确说「无法从图确认」,不得虚构 K 线故事. + +【输出格式 — Markdown,必须严格遵守】 +- 第一行:**交易复盘报告({period_label}度)** +- 五个大节标题必须**完全一致**(含 emoji,不要用其它编号或改名): + **1. 📊 总体盈亏结构** + **2. 🧠 心态与执行** + **3. 🏷️ 行为标签** + **4. ✅ 改进建议** + **5. 📈 图表分析** +- 每节正文用 `- **子项名**:内容` 列表;第4节改进建议用有序列表 `1. 2. 3.` +- 第1节至少包含:**笔数/盈亏**,**风险回报比**,**总结** +- 第2节至少包含:**得分**(1–10),**依据**(对应记录字段) +- 第5节至少包含:**趋势确认**,**执行路径**(记录不足则写明) +- 语气简洁,少形容词;不要输出代码块,不要表格 + +交易记录: +{trades_text} +""".strip() + try: + review_max = max(1024, int(_env_str("AI_REVIEW_MAX_TOKENS", "8192") or "8192")) + except ValueError: + review_max = 8192 + return attach_note + ai_generate( + prompt, + image_paths=capped_paths, + temperature=0.2, + max_tokens=review_max, + ) + + +def ai_short_advice(prompt_text: str) -> str: + prompt = f""" +你是交易风控助理.请用中文给出**最多 3 条**提醒,要求: +- 每条不超过 25 个字 +- 语气克制,具体,可执行 +- 不要输出 Markdown,不要编号前缀以外的废话 + +场景: +{prompt_text} +""".strip() + return ai_generate(prompt, temperature=0.2) + + +def ai_provider_label() -> str: + if _use_openai(): + return f"OpenAI 兼容 · {_openai_model()} @ {_openai_api_base()}" + return f"Ollama · {_ollama_model()}" + + +def ai_config_status() -> dict: + """调试用:当前进程内读到的 AI 配置(不含密钥明文).""" + key = _openai_api_key() + return { + "provider": _ai_provider(), + "openai_base": _openai_api_base(), + "openai_model": _openai_model(), + "openai_key_configured": bool(key), + "ollama_api": _ollama_api(), + "ollama_model": _ollama_model(), + } diff --git a/lib/ai/ai_review_lib.py b/lib/ai/ai_review_lib.py new file mode 100644 index 0000000..c81f443 --- /dev/null +++ b/lib/ai/ai_review_lib.py @@ -0,0 +1,178 @@ +"""AI 日复盘 / 周复盘:附图收集与 journal 文本格式化(三所共用).""" +from __future__ import annotations + +import os +import uuid +from typing import Any, Callable, List, Mapping, Optional, Sequence + +from lib.instance.journal_chart_lib import ( + JOURNAL_CHART_ANCHOR_CLOSE, + JOURNAL_CHART_DEFAULT_LIMIT, + JOURNAL_CHART_DEFAULT_TF1, + JOURNAL_CHART_DEFAULT_TF2, + normalize_chart_timeframe, +) +from lib.instance.journal_images_lib import journal_image_paths + + +def _journal_nz(v: Any, default: str = "无") -> str: + if v is None: + return default + s = str(v).strip() + return s if s else default + + +def _row_get(row: Any, key: str, default: Any = None) -> Any: + """兼容 dict 与 sqlite3.Row(Row 无 .get 方法).""" + if row is None: + return default + getter = getattr(row, "get", None) + if callable(getter): + return getter(key, default) + try: + keys = row.keys() if hasattr(row, "keys") else () + if key in keys: + return row[key] + except Exception: + pass + try: + return row[key] + except (KeyError, TypeError, IndexError): + return default + + +def journal_row_lines_for_ai( + idx: int, + row: Any, + *, + include_hold_duration: bool = True, +) -> str: + """把 journal 字段拼成给 AI 的文本;三所日复盘/周复盘共用.""" + lines = [ + ( + f"{idx}. {_journal_nz(_row_get(row, 'coin'))} {_journal_nz(_row_get(row, 'tf'))} " + f"| 盈亏:{_journal_nz(_row_get(row, 'pnl'))}U " + f"| 实际RR:{_journal_nz(_row_get(row, 'real_rr'))} " + f"| 预期RR:{_journal_nz(_row_get(row, 'expect_rr'))}" + ), + f" 开仓逻辑:{_journal_nz(_row_get(row, 'entry_reason'))}", + f" 平仓/离场(交易员自述):{_journal_nz(_row_get(row, 'exit_reason'))}", + ] + if include_hold_duration: + lines.append(f" 持仓时长:{_journal_nz(_row_get(row, 'hold_duration'))}") + ee_bits = [ + _journal_nz(_row_get(row, "early_exit")), + _journal_nz(_row_get(row, "early_exit_reason")), + _journal_nz(_row_get(row, "early_exit_trigger")), + _journal_nz(_row_get(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"心态标签:{_journal_nz(_row_get(row, 'mood_issues'))}" + mood_score = _row_get(row, "mood_score") + if mood_score is not None: + mood_bits += f" | 自评心态分:{mood_score}" + lines.append(f" {mood_bits}") + if _journal_nz(_row_get(row, "post_breakeven_stare")) != "无": + lines.append(f" 保本后盯盘:{_journal_nz(_row_get(row, 'post_breakeven_stare'))}") + if _journal_nz(_row_get(row, "note")) != "无": + lines.append(f" 备注:{_journal_nz(_row_get(row, 'note'))}") + return "\n".join(lines) + "\n" + + +def collect_images_for_ai_review( + rows: Sequence, + upload_folder: str, + *, + build_chart_if_missing: Optional[Callable] = None, +) -> List[str]: + """ + 收集传给视觉模型的本地图片路径. + - 优先 journal_entries.images_json / image 已存附图(含多周期手动上传); + - 若无附图且提供 build_chart_if_missing,则临时生成 K 线图. + """ + paths: List[str] = [] + seen = set() + upload_folder = os.path.abspath(upload_folder or "") + for row in rows or []: + row_paths = journal_image_paths(row, upload_folder) + if row_paths: + for candidate in row_paths: + if candidate not in seen: + seen.add(candidate) + paths.append(candidate) + continue + if build_chart_if_missing: + try: + candidate = build_chart_if_missing(row) + except Exception: + candidate = None + if not candidate: + continue + candidate = os.path.abspath(candidate) + if os.path.isfile(candidate) and candidate not in seen: + seen.add(candidate) + paths.append(candidate) + return paths + + +def build_journal_ai_chart_path( + row, + upload_folder: str, + *, + order_chart_enabled: bool, + normalize_exchange_symbol_fn: Callable[[str], str], + generate_chart_fn: Callable, + local_datetime_to_ms_fn: Callable[[str], Optional[int]], + now_ts_ms_fn: Callable[[], int], +) -> Optional[str]: + """无已存附图时,按复盘记录开平仓时间临时生成 K 线图路径.""" + if not order_chart_enabled: + return None + try: + keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + return None + coin = (row["coin"] if "coin" in keys else "") or "" + coin = str(coin).strip() + if not coin: + return None + try: + symbol = normalize_exchange_symbol_fn(coin) + except Exception: + return None + open_dt = row["open_datetime"] if "open_datetime" in keys else "" + close_dt = row["close_datetime"] if "close_datetime" in keys else "" + entry_ms = local_datetime_to_ms_fn(open_dt) + exit_ms = local_datetime_to_ms_fn(close_dt) + if not entry_ms: + return None + row_tf = row["tf"] if "tf" in keys else "" + tf1 = normalize_chart_timeframe(row_tf) or JOURNAL_CHART_DEFAULT_TF1 + tf2 = JOURNAL_CHART_DEFAULT_TF2 if tf1 != JOURNAL_CHART_DEFAULT_TF2 else "1h" + row_id = str(row["id"] if "id" in keys else "")[:8] or uuid.uuid4().hex[:8] + marker = { + "entry_ts_ms": entry_ms, + "exit_ts_ms": exit_ms, + "chart_anchor": JOURNAL_CHART_ANCHOR_CLOSE, + "now_ts_ms": int(now_ts_ms_fn()), + } + fname = f"ai_rev_{row_id}_{uuid.uuid4().hex[:6]}.png" + saved = generate_chart_fn( + symbol, + f"AI复盘 {coin}", + timeframes=[tf1, tf2], + limit=JOURNAL_CHART_DEFAULT_LIMIT, + out_dir=upload_folder, + filename=fname, + marker_payload=marker, + marker_timeframes={tf1, tf2}, + layout="vertical", + ) + if not saved: + return None + path = os.path.join(upload_folder, saved) + return path if os.path.isfile(path) else None diff --git a/lib/common/__init__.py b/lib/common/__init__.py new file mode 100644 index 0000000..ab164b5 --- /dev/null +++ b/lib/common/__init__.py @@ -0,0 +1 @@ +"""Shared library package.""" diff --git a/lib/common/auto_transfer_daily_lib.py b/lib/common/auto_transfer_daily_lib.py new file mode 100644 index 0000000..aaab5bf --- /dev/null +++ b/lib/common/auto_transfer_daily_lib.py @@ -0,0 +1,130 @@ +""" +每日自动划转:北京时间指定整点小时内,将交易账户(AUTO_TRANSFER_TO)余额调整至目标额. + +- 交易账户 < 目标:从资金账户划入差额 +- 交易账户 > 目标:将多余划回资金账户 +- 有 active 持仓:不划转,写账簿并企业微信说明 +""" +from __future__ import annotations + +from typing import Any, Callable + + +def run_auto_transfer_once_per_day( + *, + enabled: bool, + bj_hour: int, + target_amount: float, + from_account: str, + to_account: str, + funds_decimals: int, + get_db: Callable[[], Any], + get_active_position_count: Callable[[Any], int], + get_account_usdt_total: Callable[[str], float | None], + execute_transfer_usdt: Callable[[float, str, str], tuple[bool, str, Any]], + send_wechat_msg: Callable[[str], None], + utc_now_dt: Callable[[], Any], + app_tz: Any, + utc_calendar_date_str: Callable[[], str], + app_now_str: Callable[[], str], + min_transfer: float = 0.01, +) -> None: + if not enabled: + return + utc_dt = utc_now_dt() + bj = utc_dt.astimezone(app_tz) + if bj.hour != 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 + + def _log( + amount: float, + fr: str, + to: str, + status: str, + message: str, + *, + commit_close: bool = True, + ) -> None: + conn.execute( + "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", + ("auto_daily", transfer_day, amount, fr, to, status, message[:500]), + ) + conn.commit() + if commit_close: + conn.close() + + active = get_active_position_count(conn) + if active > 0: + msg = f"持仓中({active}笔),本次资金无划转" + _log(0, from_account, to_account, "skipped", msg) + send_wechat_msg( + f"自动划转:{msg}\n" + f"目标:{to_account} 调整至 {round(float(target_amount), funds_decimals)}U\n" + f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" + ) + return + + target = round(float(target_amount), funds_decimals) + trade_bal = get_account_usdt_total(to_account) + if trade_bal is None: + _log( + 0, + from_account, + to_account, + "failed", + f"读取{to_account}账户USDT失败", + ) + return + + trade = round(float(trade_bal), funds_decimals) + diff = round(target - trade, funds_decimals) + + if abs(diff) < min_transfer: + _log( + 0, + from_account, + to_account, + "skipped", + f"{to_account}账户已为{trade}U(目标{target}U)", + ) + return + + if diff > 0: + fr, to, amount = from_account, to_account, diff + action = "划入" + else: + fr, to, amount = to_account, from_account, round(abs(diff), funds_decimals) + action = "划出" + + from_bal = get_account_usdt_total(fr) + if from_bal is not None and round(float(from_bal), funds_decimals) < amount: + cur = round(float(from_bal), funds_decimals) + _log(amount, fr, to, "failed", f"{fr}账户USDT不足,需{amount}U,当前{cur}U") + send_wechat_msg( + f"自动划转失败:{fr}余额不足,需{amount}U,当前{cur}U({action}至{to_account}目标{target}U)\n" + f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" + ) + return + + ok, msg, _ = execute_transfer_usdt(amount, fr, to) + _log(amount, fr, to, "success" if ok else "failed", msg) + if ok: + send_wechat_msg( + f"自动划转成功:{to_account} {trade}U→目标{target}U,{action}{amount}U {fr}->{to}\n" + f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" + ) + else: + send_wechat_msg( + f"自动划转失败:计划{action}{amount}U {fr}->{to}(目标{target}U)\n原因:{msg}\n" + f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" + ) diff --git a/lib/common/flask_access_log_lib.py b/lib/common/flask_access_log_lib.py new file mode 100644 index 0000000..68e7403 --- /dev/null +++ b/lib/common/flask_access_log_lib.py @@ -0,0 +1,16 @@ +"""关闭 Flask/Werkzeug 开发服务器 access log 刷屏(避免灌满 PM2 error 日志).""" +from __future__ import annotations + +import logging + + +def silence_werkzeug_access_log() -> None: + """仅抑制 request access 行;WARNING/ERROR 仍可读.""" + log = logging.getLogger("werkzeug") + log.setLevel(logging.WARNING) + # 部分环境会挂 StreamHandler 到 stderr;抬高阈值即可 + for h in list(log.handlers): + try: + h.setLevel(logging.WARNING) + except Exception: + pass diff --git a/lib/common/form_submit_lib.py b/lib/common/form_submit_lib.py new file mode 100644 index 0000000..687fccf --- /dev/null +++ b/lib/common/form_submit_lib.py @@ -0,0 +1,51 @@ +"""防重复提交:Flask session 短窗口去重(下单 / 关键位等).""" +from __future__ import annotations + +import time +from typing import Any, Optional + + +DEFAULT_SUBMIT_GUARD_TTL = 90.0 + + +def _prune_locks(locks: dict, now: float) -> dict: + return {k: float(v) for k, v in (locks or {}).items() if float(v) > now} + + +def check_duplicate_submit( + session: Any, + scope: str, + *, + ttl: float = DEFAULT_SUBMIT_GUARD_TTL, +) -> Optional[str]: + """ + 同一 scope 在 ttl 秒内仅允许通过一次. + 返回提示文案表示应拒绝;返回 None 表示可继续处理. + """ + scope = (scope or "").strip() + if not scope: + return None + now = time.time() + locks = _prune_locks(session.get("_form_submit_guard") or {}, now) + if scope in locks: + return "请求正在处理或刚提交过,请勿重复点击(请等待页面刷新后再试)" + locks[scope] = now + float(ttl) + session["_form_submit_guard"] = locks + try: + session.modified = True + except Exception: + pass + return None + + +def submit_scope_add_order(symbol: str, direction: str) -> str: + sym = (symbol or "").strip().upper() + d = (direction or "").strip().lower() + return f"add_order:{sym}:{d}" + + +def submit_scope_add_key(symbol: str, monitor_type: str, direction: str) -> str: + sym = (symbol or "").strip().upper() + mt = (monitor_type or "").strip() + d = (direction or "").strip().lower() or "watch" + return f"add_key:{sym}:{mt}:{d}" diff --git a/lib/common/history_window_lib.py b/lib/common/history_window_lib.py new file mode 100644 index 0000000..760f13a --- /dev/null +++ b/lib/common/history_window_lib.py @@ -0,0 +1,187 @@ +"""列表/导出用 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_UTC_THIS_MONTH = "utc_this_month" +PRESET_UTC_LAST3M = "utc_last3m" +PRESET_UTC_LAST6M = "utc_last6m" +PRESET_ALL = "all" +PRESET_CUSTOM = "custom" +PRESET_DEFAULT = PRESET_UTC_THIS_MONTH + + +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_DEFAULT): + """ + 从 ?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_DEFAULT).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_UTC_THIS_MONTH: + start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + end = now + label = f"本月 {start.strftime('%Y-%m')}" + elif preset == PRESET_UTC_LAST3M: + start = now - timedelta(days=90) + end = now + label = "近3月" + elif preset == PRESET_UTC_LAST6M: + start = now - timedelta(days=180) + end = now + label = "近6月" + elif preset == PRESET_ALL: + start = datetime(2000, 1, 1, tzinfo=timezone.utc) + end = now + label = "全部" + 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" + elif preset == PRESET_UTC_TODAY: + start, end = utc_today_bounds(now) + label = f"UTC当日 {start.strftime('%Y-%m-%d')}" + else: + return resolve_window( + {**(query_mapping or {}), "win_preset": default_preset}, + default_preset=default_preset, + ) + + 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 + + +def utc_window_to_utc_sql_strings(start_utc, end_utc): + """SQLite CURRENT_TIMESTAMP 写入 UTC 时,用于 created_at 范围比较.""" + return ( + start_utc.strftime("%Y-%m-%d %H:%M:%S"), + end_utc.strftime("%Y-%m-%d %H:%M:%S"), + ) + + +def normalize_bj_datetime_storage(raw): + """表单 datetime-local(含 T)入库前统一为 YYYY-MM-DD HH:MM:SS(北京时间).""" + s = (raw or "").strip().replace("T", " ").replace("Z", "").strip() + if not s: + return "" + for fmt, n in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)): + try: + return datetime.strptime(s[:n], fmt).strftime("%Y-%m-%d %H:%M:%S") + except ValueError: + continue + return s + + +def sql_list_time_field(*columns): + """ + SQLite 列表时间窗比较表达式. + journal_entries 的 open/close 可能含 'T',直接与 bounds(空格格式)比会误判为超出上界. + 单列时不用 COALESCE(SQLite 要求 COALESCE 至少 2 个参数). + """ + cols = [c for c in columns if c] + if not cols: + raise ValueError("sql_list_time_field requires at least one column") + if len(cols) == 1: + return f"REPLACE({cols[0]}, 'T', ' ')" + return f"REPLACE(COALESCE({', '.join(cols)}), 'T', ' ')" + + +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_DEFAULT): + """ + 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/lib/common/static/account_risk_badge.css b/lib/common/static/account_risk_badge.css new file mode 100644 index 0000000..bd47181 --- /dev/null +++ b/lib/common/static/account_risk_badge.css @@ -0,0 +1,150 @@ +/* 账户风控状态徽章 — 三所实例 + 中控共用;兼容 data-theme light/dark */ + +:root, +html[data-theme="dark"] { + --risk-normal-fg: #9cf0c4; + --risk-normal-bg: rgba(36, 140, 96, 0.16); + --risk-normal-border: rgba(72, 190, 130, 0.42); + --risk-normal-glow: rgba(72, 190, 130, 0.35); + + --risk-1h-fg: #ffd27a; + --risk-1h-bg: rgba(210, 150, 40, 0.16); + --risk-1h-border: rgba(230, 170, 60, 0.45); + --risk-1h-glow: rgba(230, 170, 60, 0.32); + + --risk-4h-fg: #ffab8a; + --risk-4h-bg: rgba(210, 90, 55, 0.16); + --risk-4h-border: rgba(230, 110, 70, 0.48); + --risk-4h-glow: rgba(230, 110, 70, 0.34); + + --risk-daily-fg: #ff9ec4; + --risk-daily-bg: rgba(190, 55, 100, 0.18); + --risk-daily-border: rgba(210, 75, 120, 0.5); + --risk-daily-glow: rgba(210, 75, 120, 0.36); + + --risk-position-fg: #8ec8ff; + --risk-position-bg: rgba(55, 120, 210, 0.18); + --risk-position-border: rgba(75, 145, 230, 0.48); + --risk-position-glow: rgba(75, 145, 230, 0.34); + + --risk-badge-shadow: 0 1px 2px rgba(0, 0, 0, 0.28); +} + +html[data-theme="light"] { + --risk-normal-fg: #056b44; + --risk-normal-bg: rgba(10, 143, 92, 0.14); + --risk-normal-border: rgba(8, 122, 80, 0.38); + --risk-normal-glow: rgba(10, 143, 92, 0.22); + + --risk-1h-fg: #8a5a00; + --risk-1h-bg: rgba(200, 140, 20, 0.14); + --risk-1h-border: rgba(170, 115, 10, 0.38); + --risk-1h-glow: rgba(200, 140, 20, 0.2); + + --risk-4h-fg: #a83812; + --risk-4h-bg: rgba(210, 85, 35, 0.12); + --risk-4h-border: rgba(180, 65, 25, 0.36); + --risk-4h-glow: rgba(210, 85, 35, 0.2); + + --risk-daily-fg: #9a1248; + --risk-daily-bg: rgba(180, 35, 80, 0.1); + --risk-daily-border: rgba(155, 28, 68, 0.34); + --risk-daily-glow: rgba(180, 35, 80, 0.18); + + --risk-position-fg: #0b5cab; + --risk-position-bg: rgba(20, 100, 190, 0.12); + --risk-position-border: rgba(15, 85, 165, 0.36); + --risk-position-glow: rgba(20, 100, 190, 0.2); + + --risk-badge-shadow: 0 1px 2px rgba(20, 50, 80, 0.1); +} + +.risk-status-badge { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.76rem; + font-weight: 600; + letter-spacing: 0.03em; + line-height: 1.15; + padding: 5px 12px 5px 10px; + border-radius: 999px; + border: 1px solid var(--risk-border, transparent); + background: var(--risk-bg, transparent); + color: var(--risk-fg, inherit); + box-shadow: var(--risk-badge-shadow); + white-space: nowrap; + vertical-align: middle; + transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; +} + +/* 中控 iframe 内切页:避免徽章过渡动画造成 header 闪动 */ +html[data-hub-linked="1"] .header-row .risk-status-badge { + transition: none; +} + +.risk-status-badge::before { + content: ""; + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; + flex-shrink: 0; + box-shadow: 0 0 0 1px color-mix(in srgb, currentColor 30%, transparent), + 0 0 8px var(--risk-glow, currentColor); + opacity: 0.92; +} + +.risk-status-normal { + --risk-fg: var(--risk-normal-fg); + --risk-bg: var(--risk-normal-bg); + --risk-border: var(--risk-normal-border); + --risk-glow: var(--risk-normal-glow); +} + +.risk-status-freeze_1h { + --risk-fg: var(--risk-1h-fg); + --risk-bg: var(--risk-1h-bg); + --risk-border: var(--risk-1h-border); + --risk-glow: var(--risk-1h-glow); +} + +.risk-status-freeze_4h { + --risk-fg: var(--risk-4h-fg); + --risk-bg: var(--risk-4h-bg); + --risk-border: var(--risk-4h-border); + --risk-glow: var(--risk-4h-glow); +} + +.risk-status-freeze_daily { + --risk-fg: var(--risk-daily-fg); + --risk-bg: var(--risk-daily-bg); + --risk-border: var(--risk-daily-border); + --risk-glow: var(--risk-daily-glow); +} + +.risk-status-freeze_position { + --risk-fg: var(--risk-position-fg); + --risk-bg: var(--risk-position-bg); + --risk-border: var(--risk-position-border); + --risk-glow: var(--risk-position-glow); +} + +/* 实例页:与交易所标签并排 */ +.header-row .risk-status-badge { + min-height: 28px; +} + +/* 中控卡片标题内 */ +.card-title .risk-status-badge, +.hub-tile-name .risk-status-badge { + font-size: 0.7rem; + padding: 3px 10px 3px 8px; + vertical-align: middle; +} + +.card-title .risk-status-badge::before, +.hub-tile-name .risk-status-badge::before { + width: 6px; + height: 6px; +} diff --git a/lib/common/static/account_risk_badge.js b/lib/common/static/account_risk_badge.js new file mode 100644 index 0000000..68fe7dd --- /dev/null +++ b/lib/common/static/account_risk_badge.js @@ -0,0 +1,120 @@ +/** + * 账户风控徽章倒计时 — 三所实例 + 中控共用. + */ +(function (global) { + "use strict"; + + function formatRemaining(totalSec) { + const sec = Math.max(0, Math.floor(Number(totalSec) || 0)); + if (sec <= 0) return ""; + const h = Math.floor(sec / 3600); + const m = Math.floor((sec % 3600) / 60); + const s = sec % 60; + if (h > 0) return `${h}h ${String(m).padStart(2, "0")}m`; + if (m > 0) return `${m}m ${String(s).padStart(2, "0")}s`; + return `${s}s`; + } + + function baseLabel(riskStatus, el) { + if (riskStatus && riskStatus.status_label) return String(riskStatus.status_label); + if (el && el.dataset && el.dataset.statusLabel) return String(el.dataset.statusLabel); + return "正常"; + } + + function resolveFreezeUntilMs(riskStatus) { + if (!riskStatus) return null; + const sec = Number(riskStatus.freeze_remaining_sec); + if (Number.isFinite(sec) && sec > 0) { + return Date.now() + sec * 1000; + } + const until = Number(riskStatus.freeze_until_ms); + return Number.isFinite(until) && until > 0 ? until : null; + } + + function badgeText(riskStatus) { + const label = baseLabel(riskStatus, null); + const until = resolveFreezeUntilMs(riskStatus); + if (!until || until <= Date.now()) return label; + const cd = formatRemaining((until - Date.now()) / 1000); + return cd ? `${label} · ${cd}` : label; + } + + function setNormalBadge(el) { + el.className = "risk-status-badge risk-status-normal"; + el.dataset.statusLabel = "正常"; + el.textContent = "正常"; + el.title = ""; + if (el.dataset) delete el.dataset.freezeUntilMs; + } + + function refreshElement(el) { + if (!el) return; + const label = baseLabel(null, el); + const until = Number(el.dataset && el.dataset.freezeUntilMs); + if (!Number.isFinite(until) || until <= Date.now()) { + if (el.dataset && el.dataset.freezeUntilMs) { + setNormalBadge(el); + } else { + el.textContent = label; + } + return; + } + const cd = formatRemaining((until - Date.now()) / 1000); + el.textContent = cd ? `${label} · ${cd}` : label; + } + + function applyToElement(el, riskStatus) { + if (!el || !riskStatus) return; + const st = riskStatus.status || "normal"; + el.className = "risk-status-badge risk-status-" + st; + el.dataset.statusLabel = baseLabel(riskStatus, el); + const until = resolveFreezeUntilMs(riskStatus); + if (until) { + el.dataset.freezeUntilMs = String(until); + } else if (el.dataset) { + delete el.dataset.freezeUntilMs; + } + el.textContent = badgeText(riskStatus); + el.title = riskStatus.reason || ""; + } + + function formatBadgeHtml(riskStatus, esc) { + if (!riskStatus || typeof riskStatus !== "object") return ""; + const safe = typeof esc === "function" ? esc : (s) => String(s); + const st = riskStatus.status || "normal"; + const label = safe(riskStatus.status_label || "正常"); + const title = safe(riskStatus.reason || ""); + const text = safe(badgeText(riskStatus)); + const until = resolveFreezeUntilMs(riskStatus); + const untilAttr = + until != null + ? ` data-freeze-until-ms="${safe(String(Math.floor(until)))}"` + : ""; + return ( + `${text}` + ); + } + + function tickAll(root) { + const scope = root || document; + scope.querySelectorAll(".risk-status-badge[data-freeze-until-ms]").forEach(refreshElement); + } + + let timer = null; + function startTicker() { + if (timer) return; + tickAll(); + timer = setInterval(() => tickAll(), 1000); + } + + global.AccountRiskBadge = { + formatRemaining, + badgeText, + refreshElement, + applyToElement, + formatBadgeHtml, + tickAll, + startTicker, + }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/ai_review_render.js b/lib/common/static/ai_review_render.js new file mode 100644 index 0000000..8330aba --- /dev/null +++ b/lib/common/static/ai_review_render.js @@ -0,0 +1,223 @@ +/** + * AI 日复盘 / 周复盘:Markdown 子集渲染 + 五节大标题图标兜底 + */ +(function (global) { + "use strict"; + + var SECTION_FIXES = [ + { re: /^\*\*1\.\s*(?!📊)总体盈亏结构\*\*/m, rep: "**1. 📊 总体盈亏结构**" }, + { re: /^\*\*2\.\s*(?!🧠)心态与执行\*\*/m, rep: "**2. 🧠 心态与执行**" }, + { re: /^\*\*3\.\s*(?!🏷️)行为标签\*\*/m, rep: "**3. 🏷️ 行为标签**" }, + { re: /^\*\*4\.\s*(?!✅)改进建议\*\*/m, rep: "**4. ✅ 改进建议**" }, + { re: /^\*\*5\.\s*(?!📈)图表(?:分析)?\*\*/m, rep: "**5. 📈 图表分析**" }, + { re: /^1\.\s*(?!📊)总体盈亏结构/m, rep: "**1. 📊 总体盈亏结构**" }, + { re: /^2\.\s*(?!🧠)心态与执行/m, rep: "**2. 🧠 心态与执行**" }, + { re: /^3\.\s*(?!🏷️)行为标签/m, rep: "**3. 🏷️ 行为标签**" }, + { re: /^4\.\s*(?!✅)改进建议/m, rep: "**4. ✅ 改进建议**" }, + { re: /^5\.\s*(?!📈)图表/m, rep: "**5. 📈 图表分析**" }, + ]; + + function escapeHtml(s) { + return String(s || "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function parseInline(raw) { + var s = escapeHtml(raw); + s = s.replace(/\*\*([^*]+)\*\*/g, "$1"); + s = s.replace(/`([^`]+)`/g, "$1"); + return s; + } + + function enhanceReviewHeadings(text) { + var out = String(text || ""); + SECTION_FIXES.forEach(function (item) { + out = out.replace(item.re, item.rep); + }); + if (/^【系统说明/m.test(out) && !/^ℹ️/m.test(out)) { + out = out.replace(/^【系统说明/gm, "ℹ️ 【系统说明"); + } + if (/^原始记录:/m.test(out) && !/^📎/m.test(out)) { + out = out.replace(/^原始记录:/gm, "📎 **原始记录**"); + } + return out; + } + + function isNumberedListLine(trimmed) { + if (!trimmed) return false; + if (/^\d+\.\s+/.test(trimmed)) return true; + if (/^\*\*\d+\.\s*.+\*\*$/.test(trimmed)) return true; + return false; + } + + /** 编号列表项之间的空行不拆段,避免每条都从 1 重新开始 */ + function preprocessListBlanks(text) { + var lines = String(text || "").replace(/\r\n/g, "\n").split("\n"); + var out = []; + for (var i = 0; i < lines.length; i++) { + var trimmed = lines[i].trim(); + if (!trimmed) { + var prevTrim = out.length ? String(out[out.length - 1]).trim() : ""; + var nextTrim = ""; + for (var j = i + 1; j < lines.length; j++) { + var t = lines[j].trim(); + if (t) { + nextTrim = t; + break; + } + } + if (isNumberedListLine(prevTrim) && isNumberedListLine(nextTrim)) { + continue; + } + } + out.push(lines[i]); + } + return out.join("\n"); + } + + function renderMarkdown(text) { + var src = enhanceReviewHeadings(preprocessListBlanks(text)); + var lines = src.replace(/\r\n/g, "\n").split("\n"); + var html = []; + var inUl = false; + var inOl = false; + + function closeLists() { + if (inUl) { + html.push(""); + inUl = false; + } + if (inOl) { + html.push(""); + inOl = false; + } + } + + lines.forEach(function (line) { + var trimmed = line.trim(); + if (!trimmed) { + closeLists(); + return; + } + var hm = trimmed.match(/^(#{1,3})\s+(.+)$/); + if (hm) { + closeLists(); + var level = hm[1].length + 1; + if (level > 4) level = 4; + html.push("" + parseInline(hm[2]) + ""); + return; + } + var ulm = trimmed.match(/^[-*]\s+(.+)$/); + if (ulm) { + if (!inUl) { + closeLists(); + html.push("
    "); + inUl = true; + } + html.push("
  • " + parseInline(ulm[1]) + "
  • "); + return; + } + var boldOl = trimmed.match(/^\*\*(\d+)\.\s*(.+)\*\*$/); + if (boldOl) { + if (!inOl) { + closeLists(); + html.push("
      "); + inOl = true; + } + html.push("
    1. " + parseInline(trimmed) + "
    2. "); + return; + } + var olm = trimmed.match(/^\d+\.\s+(.+)$/); + if (olm) { + if (!inOl) { + closeLists(); + html.push("
        "); + inOl = true; + } + html.push("
      1. " + parseInline(olm[1]) + "
      2. "); + return; + } + closeLists(); + if (/^📎\s*\*\*原始记录\*\*/.test(trimmed) || /^原始记录:/.test(trimmed)) { + html.push('
        ' + parseInline(trimmed) + "
        "); + return; + } + html.push("

        " + parseInline(trimmed) + "

        "); + }); + closeLists(); + return html.join("\n"); + } + + var _genBusy = false; + + function setGenerating(opts) { + opts = opts || {}; + _genBusy = true; + var wrap = document.getElementById(opts.wrapId); + var el = document.getElementById(opts.elId); + var btn = opts.btnId ? document.getElementById(opts.btnId) : null; + if (wrap) wrap.style.display = "block"; + if (el) { + el.classList.remove("ai-result-md"); + el.classList.add("is-loading"); + el.innerHTML = ""; + el.innerText = opts.message || "生成复盘中,请稍候…"; + } + if (btn) { + btn.disabled = true; + if (!btn.dataset.aiOrigText) btn.dataset.aiOrigText = btn.textContent; + btn.textContent = opts.btnLabel || "生成中…"; + } + if (wrap && wrap.scrollIntoView) { + try { + wrap.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } catch (e) { /* ignore */ } + } + } + + function clearGenerating(btnId) { + _genBusy = false; + var btn = btnId ? document.getElementById(btnId) : null; + if (btn) { + btn.disabled = false; + if (btn.dataset.aiOrigText) { + btn.textContent = btn.dataset.aiOrigText; + delete btn.dataset.aiOrigText; + } + } + } + + function isGenerating() { + return _genBusy; + } + + function setElementMarkdown(el, rawText) { + if (!el) return; + var raw = String(rawText || ""); + el.dataset.markdownRaw = raw; + el.classList.remove("is-loading"); + el.classList.add("ai-result-md"); + el.innerHTML = renderMarkdown(raw); + } + + function getElementMarkdown(el) { + if (!el) return ""; + if (el.dataset && el.dataset.markdownRaw != null) { + return el.dataset.markdownRaw; + } + return el.innerText || ""; + } + + global.AiReviewRender = { + enhanceReviewHeadings: enhanceReviewHeadings, + renderMarkdown: renderMarkdown, + setElementMarkdown: setElementMarkdown, + getElementMarkdown: getElementMarkdown, + setGenerating: setGenerating, + clearGenerating: clearGenerating, + isGenerating: isGenerating, + }; +})(typeof window !== "undefined" ? window : this); diff --git a/lib/common/static/focus_chart_page.css b/lib/common/static/focus_chart_page.css new file mode 100644 index 0000000..608b1e9 --- /dev/null +++ b/lib/common/static/focus_chart_page.css @@ -0,0 +1,221 @@ +/* 实盘/关键位放大页:与 instance_theme 联动,高对比 meta + 主题感知图表区 */ +body.focus-page { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + padding: 14px; + margin: 0; + background: var(--focus-bg, #0b0d14); + color: var(--focus-fg, #eaeaea); +} + +html[data-theme="light"] body.focus-page { + --focus-bg: #eef3f8; + --focus-fg: #142232; + --focus-card-bg: #fff; + --focus-card-border: #b8c8d8; + --focus-meta-bg: #fff; + --focus-meta-border: #9eb4c8; + --focus-meta-label: #2a4a66; + --focus-meta-value: #0a1628; + --focus-status: #4a6078; + --focus-chart-bg: #f0f4f9; + --focus-chart-border: #b8c8d8; + --focus-btn-bg: #fff; + --focus-btn-fg: #006e9a; + --focus-btn-border: rgba(0, 95, 140, 0.22); + --focus-input-bg: #fff; + --focus-input-fg: #142232; + --focus-input-border: #b8c8d8; + --focus-title: #0a1628; + --focus-pnl-up: #0a7a3d; + --focus-pnl-down: #c62828; + --focus-dir-short: #b71c1c; + --focus-dir-long: #0a7a3d; +} + +html[data-theme="dark"] body.focus-page { + --focus-bg: #0b0d14; + --focus-fg: #eaeaea; + --focus-card-bg: #121726; + --focus-card-border: #2a3150; + --focus-meta-bg: #141b2f; + --focus-meta-border: #3d4f72; + --focus-meta-label: #c8d8f0; + --focus-meta-value: #f0f4ff; + --focus-status: #95a2c2; + --focus-chart-bg: #0f1320; + --focus-chart-border: #2a3150; + --focus-btn-bg: #151a2a; + --focus-btn-fg: #8fc8ff; + --focus-btn-border: #304164; + --focus-input-bg: #1a1a29; + --focus-input-fg: #fff; + --focus-input-border: #2e2e45; + --focus-title: #dbe4ff; + --focus-pnl-up: #3ddc84; + --focus-pnl-down: #ff7070; + --focus-dir-short: #ff8a80; + --focus-dir-long: #69f0ae; +} + +body.focus-page * { + box-sizing: border-box; +} + +.focus-page .container { + width: min(98vw, 1900px); + margin: 0 auto; +} + +.focus-page .card { + background: var(--focus-card-bg); + border-radius: 10px; + padding: 12px; + border: 1px solid var(--focus-card-border); + margin-bottom: 12px; +} + +.focus-page .row { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.focus-page .btn { + padding: 7px 10px; + border-radius: 8px; + text-decoration: none; + border: 1px solid var(--focus-btn-border); + background: var(--focus-btn-bg); + color: var(--focus-btn-fg); + cursor: pointer; +} + +.focus-page .btn:hover { + filter: brightness(1.06); +} + +.focus-page select, +.focus-page input, +.focus-page button { + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--focus-input-border); + background: var(--focus-input-bg); + color: var(--focus-input-fg); +} + +.focus-page .focus-title { + color: var(--focus-title); + font-weight: 700; +} + +.focus-page .meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 8px; + margin-top: 10px; +} + +.focus-page .meta-item { + background: var(--focus-meta-bg); + border: 1px solid var(--focus-meta-border); + border-radius: 8px; + padding: 10px 10px 9px; +} + +.focus-page .meta-item .k { + font-size: 0.78rem; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--focus-meta-label); +} + +.focus-page .meta-item .v { + font-size: 1.02rem; + font-weight: 600; + margin-top: 5px; + word-break: break-all; + color: var(--focus-meta-value); +} + +.focus-page .meta-item--emph { + border-width: 2px; + border-color: var(--focus-meta-label); +} + +.focus-page .meta-item--emph .k { + font-size: 0.82rem; + font-weight: 700; +} + +.focus-page .meta-item--emph .v { + font-size: 1.12rem; + font-weight: 800; +} + +.focus-page .meta-item--pnl .v { + font-size: 1.14rem; + font-weight: 800; + letter-spacing: 0.01em; +} + +.focus-page .meta-pnl-up { + color: var(--focus-pnl-up) !important; +} + +.focus-page .meta-pnl-down { + color: var(--focus-pnl-down) !important; +} + +.focus-page .meta-dir-long { + color: var(--focus-dir-long) !important; +} + +.focus-page .meta-dir-short { + color: var(--focus-dir-short) !important; +} + +.focus-page .status { + font-size: 0.84rem; + color: var(--focus-status); +} + +.focus-page .status.err { + color: var(--focus-pnl-down); +} + +.focus-page #chart-wrap { + height: 560px; + background: var(--focus-chart-bg); + border: 1px solid var(--focus-chart-border); + border-radius: 10px; + padding: 8px; +} + +.focus-page #chart { + width: 100%; + height: 100%; +} + +.focus-page .empty { + padding: 18px; + color: var(--focus-status); +} + +.focus-page .exchange-tag { + font-size: 0.72rem; + font-weight: 600; + color: #b8f5d0; + background: #14241e; + border: 1px solid #2d6a4f; + padding: 4px 10px; + border-radius: 999px; + margin-left: 8px; +} + +html[data-theme="light"] .focus-page .exchange-tag { + color: #0a5c38; + background: #e8f5ee; + border-color: #7bc9a0; +} diff --git a/lib/common/static/focus_chart_page.js b/lib/common/static/focus_chart_page.js new file mode 100644 index 0000000..8d2fc5b --- /dev/null +++ b/lib/common/static/focus_chart_page.js @@ -0,0 +1,401 @@ +/** + * 实盘/关键位放大 K 线:交易所 tick 精度,主题感知图表,高对比 meta. + */ +(function (global) { + "use strict"; + + let activePriceTick = null; + + function currentTheme() { + return document.documentElement.getAttribute("data-theme") === "light" + ? "light" + : "dark"; + } + + function chartTheme(theme) { + if (theme === "light") { + return { + layout: { background: { color: "#f0f4f9" }, textColor: "#142232" }, + grid: { vertLines: { color: "#d0dae4" }, horzLines: { color: "#d0dae4" } }, + rightPriceScale: { borderColor: "#b8c8d8" }, + timeScale: { borderColor: "#b8c8d8" }, + candle: { + upColor: "#0a7a3d", + downColor: "#c62828", + wickUpColor: "#0a7a3d", + wickDownColor: "#c62828", + }, + }; + } + return { + layout: { background: { color: "#0f1320" }, textColor: "#d6deff" }, + grid: { vertLines: { color: "#1e263d" }, horzLines: { color: "#1e263d" } }, + rightPriceScale: { borderColor: "#2a3150" }, + timeScale: { borderColor: "#2a3150" }, + candle: { + upColor: "#4cd97f", + downColor: "#ff6666", + wickUpColor: "#4cd97f", + wickDownColor: "#ff6666", + }, + }; + } + + const SAFE_PRICE_FORMAT = { type: "price", precision: 4, minMove: 0.0001 }; + + function decimalsFromTick(tick) { + if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) return null; + const minMove = Number(tick); + if (minMove >= 1) return 0; + const raw = String(minMove); + const sci = raw.match(/e-(\d+)/i); + if (sci) return Math.min(12, parseInt(sci[1], 10)); + const fixed = minMove.toFixed(12); + const frac = fixed.split(".")[1] || ""; + const trimmed = frac.replace(/0+$/, ""); + if (trimmed.length) return Math.min(12, trimmed.length); + return Math.max(0, Math.min(12, Math.round(-Math.log10(minMove)))); + } + + function tickToPriceFormat(tick) { + try { + if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) { + return { type: "price", precision: 2, minMove: 0.01 }; + } + const minMove = Number(tick); + let prec = decimalsFromTick(minMove); + if (prec == null || prec < 0) prec = 4; + prec = Math.min(12, Math.max(0, Math.floor(prec))); + return { type: "price", precision: prec, minMove: minMove }; + } catch (_) { + return SAFE_PRICE_FORMAT; + } + } + + function roundToTick(v, tick) { + if (v == null || Number.isNaN(Number(v))) return v; + const n = Number(v); + if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) return n; + const t = Number(tick); + const rounded = Math.round(n / t) * t; + const dec = decimalsFromTick(t); + if (dec == null) return rounded; + return parseFloat(rounded.toFixed(dec)); + } + + function fmtPriceByTick(v, tick) { + if (v == null || Number.isNaN(Number(v))) return "-"; + const n = Number(roundToTick(v, tick)); + if (n === 0) return "0"; + const dec = decimalsFromTick(tick); + if (dec != null) return n.toFixed(dec); + const av = Math.abs(n); + let d = 8; + if (av >= 10000) d = 2; + else if (av >= 100) d = 3; + else if (av >= 1) d = 4; + else if (av >= 0.01) d = 6; + const text = n.toFixed(d); + return text.includes(".") ? text.replace(/\.?0+$/, "") : text; + } + + function setActivePriceTick(tick) { + activePriceTick = + tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0 + ? null + : Number(tick); + } + + function formatSigned(v, digits) { + digits = digits === undefined ? 2 : digits; + if (v === null || typeof v === "undefined" || Number.isNaN(Number(v))) return "-"; + const n = Number(v); + const sign = n > 0 ? "+" : ""; + return sign + n.toFixed(digits); + } + + function formatSignedPrice(v) { + if (v === null || typeof v === "undefined" || Number.isNaN(Number(v))) return "-"; + const n = Number(v); + const body = fmtPriceByTick(Math.abs(n), activePriceTick); + if (body === "-") return "-"; + return (n > 0 ? "+" : n < 0 ? "-" : "") + body; + } + + function formatRrRatio(rr) { + if (rr === null || typeof rr === "undefined") return "-:1"; + const n = Number(rr); + if (Number.isNaN(n)) return "-:1"; + const body = Number.isInteger(n) ? String(n) : String(parseFloat(n.toFixed(2))); + return body + ":1"; + } + + function displayPrice(orderOrData, field, rawField) { + const dispKey = field + "_display"; + if (orderOrData && orderOrData[dispKey] && orderOrData[dispKey] !== "-") { + return String(orderOrData[dispKey]); + } + const raw = orderOrData ? orderOrData[rawField || field] : null; + if (raw === null || typeof raw === "undefined" || Number.isNaN(Number(raw))) return "-"; + return fmtPriceByTick(raw, activePriceTick); + } + + function lineTitle(label, display) { + const d = display && display !== "-" ? display : ""; + return d ? label + " " + d : label; + } + + function paintOrderMeta(order) { + const symEl = document.getElementById("m-symbol"); + const dirEl = document.getElementById("m-direction"); + const pnlEl = document.getElementById("m-pnl"); + if (symEl) symEl.textContent = order.symbol || "-"; + if (dirEl) { + const isShort = order.direction === "short"; + dirEl.textContent = isShort ? "做空" : "做多"; + dirEl.className = "v " + (isShort ? "meta-dir-short" : "meta-dir-long"); + } + const set = function (id, text) { + const el = document.getElementById(id); + if (el) el.textContent = text; + }; + set("m-entry", displayPrice(order, "trigger_price")); + set("m-sl", displayPrice(order, "stop_loss")); + set("m-tp", displayPrice(order, "take_profit")); + set("m-rr", formatRrRatio(order.rr_ratio)); + set( + "m-breakeven", + order.breakeven_enabled === false || order.breakeven_enabled === 0 ? "关闭" : "开启" + ); + set( + "m-price", + order.current_price_display || + order.price_display || + displayPrice(order, "current_price") + ); + if (pnlEl) { + pnlEl.textContent = + formatSigned(order.float_pnl, 2) + + "U (" + + formatSigned(order.float_pct, 2) + + "%)"; + pnlEl.className = "v"; + const pnl = Number(order.float_pnl || 0); + if (pnl > 0) pnlEl.classList.add("meta-pnl-up"); + else if (pnl < 0) pnlEl.classList.add("meta-pnl-down"); + } + } + + function paintKeyMeta(data) { + const key = data.key_monitor || null; + const symEl = document.getElementById("m-symbol"); + if (symEl) symEl.textContent = data.symbol || "-"; + const set = function (id, text) { + const el = document.getElementById(id); + if (el) el.textContent = text; + }; + set( + "m-price", + data.current_price_display || displayPrice(data, "current_price") + ); + const dirEl = document.getElementById("m-direction"); + if (!key) { + set("m-type", "未匹配到关键位"); + set("m-direction", "-"); + if (dirEl) dirEl.className = "v"; + set("m-upper", "-"); + set("m-lower", "-"); + set("m-updiff", "-"); + set("m-lowdiff", "-"); + return; + } + set("m-type", key.monitor_type || "-"); + if (dirEl) { + const isShort = key.direction === "short"; + dirEl.textContent = isShort ? "做空" : "做多"; + dirEl.className = "v " + (isShort ? "meta-dir-short" : "meta-dir-long"); + } + set("m-upper", key.upper_display || displayPrice(key, "upper")); + set("m-lower", key.lower_display || displayPrice(key, "lower")); + if (activePriceTick != null) { + set( + "m-updiff", + formatSignedPrice(key.upper_diff) + + " (" + + formatSigned(key.upper_pct, 2) + + "%)" + ); + set( + "m-lowdiff", + formatSignedPrice(key.lower_diff) + + " (" + + formatSigned(key.lower_pct, 2) + + "%)" + ); + } else { + set( + "m-updiff", + formatSigned(key.upper_diff, 4) + " (" + formatSigned(key.upper_pct, 2) + "%)" + ); + set( + "m-lowdiff", + formatSigned(key.lower_diff, 4) + " (" + formatSigned(key.lower_pct, 2) + "%)" + ); + } + } + + function applyPriceFormatToSeries(series, pf) { + if (!series || !series.applyOptions) return; + try { + series.applyOptions({ priceFormat: pf }); + } catch (_) { + try { + series.applyOptions({ priceFormat: SAFE_PRICE_FORMAT }); + } catch (_2) {} + } + } + + function createFocusChart(host) { + if (!global.LightweightCharts) return null; + const th = chartTheme(currentTheme()); + const chart = global.LightweightCharts.createChart(host, { + layout: th.layout, + grid: th.grid, + rightPriceScale: th.rightPriceScale, + timeScale: Object.assign({ timeVisible: true, secondsVisible: false }, th.timeScale), + crosshair: { mode: 0 }, + localization: { + priceFormatter: function (p) { + return fmtPriceByTick(p, activePriceTick); + }, + }, + }); + let candleSeries = null; + + function applyChartPriceFormat() { + let pf = SAFE_PRICE_FORMAT; + try { + pf = tickToPriceFormat(activePriceTick); + } catch (_) { + pf = SAFE_PRICE_FORMAT; + } + applyPriceFormatToSeries(candleSeries, pf); + try { + chart.applyOptions({ + localization: { + priceFormatter: function (p) { + return fmtPriceByTick(p, activePriceTick); + }, + }, + }); + } catch (_) {} + } + + function setPriceTick(tick) { + setActivePriceTick(tick); + applyChartPriceFormat(); + } + + const opts = Object.assign({ borderVisible: false }, th.candle); + if (typeof chart.addCandlestickSeries === "function") { + candleSeries = chart.addCandlestickSeries(opts); + } else if ( + typeof chart.addSeries === "function" && + global.LightweightCharts.CandlestickSeries + ) { + candleSeries = chart.addSeries(global.LightweightCharts.CandlestickSeries, opts); + } + applyChartPriceFormat(); + + const priceLines = []; + function resetPriceLines() { + if (!candleSeries) return; + priceLines.forEach(function (line) { + try { + candleSeries.removePriceLine(line); + } catch (_) {} + }); + priceLines.length = 0; + } + function addLine(price, title, color) { + if (!candleSeries || price === null || typeof price === "undefined") return; + const p = Number(roundToTick(price, activePriceTick)); + if (Number.isNaN(p) || p <= 0) return; + priceLines.push( + candleSeries.createPriceLine({ + price: p, + color: color, + lineWidth: 1, + lineStyle: 0, + axisLabelVisible: true, + title: title, + }) + ); + } + function applyTheme() { + const t = chartTheme(currentTheme()); + chart.applyOptions({ + layout: t.layout, + grid: t.grid, + rightPriceScale: t.rightPriceScale, + timeScale: t.timeScale, + localization: { + priceFormatter: function (p) { + return fmtPriceByTick(p, activePriceTick); + }, + }, + }); + if (candleSeries && typeof candleSeries.applyOptions === "function") { + candleSeries.applyOptions(t.candle); + } + applyChartPriceFormat(); + } + function resize() { + chart.applyOptions({ width: host.clientWidth, height: host.clientHeight }); + } + global.addEventListener("resize", resize); + resize(); + const obs = new MutationObserver(applyTheme); + obs.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-theme"], + }); + return { + chart: chart, + candleSeries: candleSeries, + resetPriceLines: resetPriceLines, + addLine: addLine, + applyTheme: applyTheme, + setPriceTick: setPriceTick, + ensureSeries: function () { + if (candleSeries) return true; + const t = chartTheme(currentTheme()); + const o = Object.assign({ borderVisible: false }, t.candle); + if (typeof chart.addCandlestickSeries === "function") { + candleSeries = chart.addCandlestickSeries(o); + } else if ( + typeof chart.addSeries === "function" && + global.LightweightCharts.CandlestickSeries + ) { + candleSeries = chart.addSeries(global.LightweightCharts.CandlestickSeries, o); + } + applyChartPriceFormat(); + return !!candleSeries; + }, + }; + } + + global.FocusChartPage = { + currentTheme: currentTheme, + chartTheme: chartTheme, + formatSigned: formatSigned, + formatRrRatio: formatRrRatio, + displayPrice: displayPrice, + lineTitle: lineTitle, + paintOrderMeta: paintOrderMeta, + paintKeyMeta: paintKeyMeta, + createFocusChart: createFocusChart, + setActivePriceTick: setActivePriceTick, + fmtPriceByTick: fmtPriceByTick, + }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/form_submit_guard.js b/lib/common/static/form_submit_guard.js new file mode 100644 index 0000000..25b56e0 --- /dev/null +++ b/lib/common/static/form_submit_guard.js @@ -0,0 +1,80 @@ +/** + * 表单提交防重复:网络慢时禁用按钮并显示「提交中」. + */ +(function (global) { + "use strict"; + + function submitButtons(form) { + if (!form) return []; + return Array.prototype.slice.call( + form.querySelectorAll('button[type="submit"], input[type="submit"]') + ); + } + + function lockForm(form, label) { + if (!form) return false; + if (form.dataset.submitGuard === "locked") return false; + form.dataset.submitGuard = "locked"; + form.classList.add("is-form-submitting"); + submitButtons(form).forEach(function (btn) { + if (btn.dataset.submitGuardOrig === undefined) { + btn.dataset.submitGuardOrig = + btn.tagName === "BUTTON" ? btn.textContent : btn.value; + } + btn.disabled = true; + if (label) { + if (btn.tagName === "BUTTON") btn.textContent = label; + else btn.value = label; + } + }); + return true; + } + + function unlockForm(form) { + if (!form) return; + delete form.dataset.submitGuard; + form.classList.remove("is-form-submitting"); + submitButtons(form).forEach(function (btn) { + btn.disabled = false; + var orig = btn.dataset.submitGuardOrig; + if (orig !== undefined) { + if (btn.tagName === "BUTTON") btn.textContent = orig; + else btn.value = orig; + delete btn.dataset.submitGuardOrig; + } + }); + } + + function isLocked(form) { + return !!(form && form.dataset.submitGuard === "locked"); + } + + /** 已锁定时仅更新按钮文案(校验通过 → 真正提交前) */ + function setSubmitLabel(form, label) { + if (!form || !label) return; + submitButtons(form).forEach(function (btn) { + if (btn.tagName === "BUTTON") btn.textContent = label; + else btn.value = label; + }); + } + + /** 已通过前端校验,发起最终 POST(页面将跳转) */ + function nativeSubmitOnce(form, label) { + if (!form) return; + var text = label || "提交中…"; + if (form.dataset.submitGuard === "locked") { + setSubmitLabel(form, text); + } else { + lockForm(form, text); + } + form.submit(); + } + + global.FormSubmitGuard = { + lock: lockForm, + unlock: unlockForm, + isLocked: isLocked, + setSubmitLabel: setSubmitLabel, + nativeSubmitOnce: nativeSubmitOnce, + }; +})(typeof window !== "undefined" ? window : this); diff --git a/lib/common/static/hedge_plan.js b/lib/common/static/hedge_plan.js new file mode 100644 index 0000000..92ba5ae --- /dev/null +++ b/lib/common/static/hedge_plan.js @@ -0,0 +1,1238 @@ +/** + * OKX 对冲计划 P0:行情 + 永期列表 / 期期 T + 情景测算 + 门禁. + */ +(function () { + const root = document.getElementById("hedge-plan-root"); + if (!root) return; + + const state = { + tab: "perp_options", + mode: "perp_options", + underlying: root.getAttribute("data-default-underly") || "ETH", + moneyFilter: "all", + chain: null, + selected: null, + legA: null, + legB: null, + market: null, + }; + + function $(id) { + return document.getElementById(id); + } + + async function apiJson(url, opts) { + const res = await fetch(url, Object.assign({ credentials: "same-origin" }, opts || {})); + const data = await res.json().catch(function () { + return {}; + }); + if (!res.ok) throw new Error(data.msg || res.statusText || "请求失败"); + return data; + } + + function fmt(v, d) { + if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; + return Number(v).toFixed(d == null ? 2 : d); + } + + function fmtOptionPx(v, tickSz) { + if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; + const n = Number(v); + const tick = Number(tickSz); + if (!tickSz || Number.isNaN(tick) || tick <= 0) { + return String(n).replace(/(\.\d*?[1-9])0+$/, "$1").replace(/\.0+$/, ""); + } + let decimals = 0; + if (tick < 1) decimals = Math.max(0, -Math.round(Math.log10(tick))); + else if (String(tick).indexOf(".") >= 0) decimals = String(tick).split(".")[1].length; + let s = n.toFixed(decimals); + // 仅裁小数尾零;整数 tick(BTC=5)时绝不能把 1370 裁成 137 + if (decimals > 0) s = s.replace(/\.?0+$/, ""); + return s || "0"; + } + + /** 价格/流动性(张),价格按 tick_sz 对齐交易所精度. */ + function fmtPxSz(px, sz, estimated, tickSz) { + if (px === null || px === undefined || Number.isNaN(Number(px))) return "—"; + let price = fmtOptionPx(px, tickSz); + if (price === "—") return "—"; + if (estimated) price += "~"; + if (sz === null || sz === undefined || sz === "" || Number.isNaN(Number(sz))) return price; + const s = Number(sz); + const size = Math.abs(s - Math.round(s)) < 1e-9 ? String(Math.round(s)) : String(s); + return price + "/" + size; + } + + function moneynessBadge(c) { + const m = (c && c.moneyness) || ""; + const label = (c && c.moneyness_label) || "—"; + return '' + label + ""; + } + + function matchesMoneyFilter(c) { + const f = state.moneyFilter || "all"; + if (f === "all") return true; + const m = (c.moneyness || "").toLowerCase(); + if (f === "itm") return m === "itm" || m === "atm"; + if (f === "otm") return m === "otm"; + return true; + } + + function optTypeForDirection(dir) { + return dir === "short" ? "C" : "P"; + } + + function syncUnderlyingUI() { + const uly = state.underlying || "ETH"; + document.querySelectorAll(".hp-uly-btn, .hp-uly-btn-oo").forEach(function (b) { + const on = b.getAttribute("data-uly") === uly; + b.classList.toggle("active", on); + b.setAttribute("aria-pressed", on ? "true" : "false"); + }); + const lab = $("hp-perp-uly-label"); + if (lab) lab.textContent = uly; + const ooLab = $("hp-oo-uly-label"); + if (ooLab) ooLab.textContent = uly; + } + + function syncMoneyUI() { + document.querySelectorAll(".hp-money-btn").forEach(function (b) { + const on = b.getAttribute("data-money") === state.moneyFilter; + b.classList.toggle("active", on); + }); + } + + function syncTabUI() { + const tab = state.tab || "perp_options"; + document.querySelectorAll(".hp-tab").forEach(function (b) { + const on = b.getAttribute("data-tab") === tab; + b.classList.toggle("active", on); + b.setAttribute("aria-selected", on ? "true" : "false"); + }); + ["perp_options", "options_options", "active", "history", "stats"].forEach(function (id) { + const panel = $("hp-tab-" + id); + if (!panel) return; + const on = id === tab; + panel.classList.toggle("hidden", !on); + if (on) panel.removeAttribute("hidden"); + else panel.setAttribute("hidden", ""); + }); + if (tab === "perp_options" || tab === "options_options") { + state.mode = tab; + } + } + + function setGateLine(gates) { + const el = $("hp-gate-line"); + if (!el) return; + if (!gates) { + el.textContent = ""; + return; + } + const parts = [ + "计仓:" + (gates.is_full_margin ? "全仓" : "非全仓"), + "测算:" + (gates.can_preview ? "可" : "否"), + "开仓:" + (gates.can_start ? "可" : "否"), + ]; + if (gates.reasons && gates.reasons.length) parts.push(gates.reasons.join("; ")); + el.textContent = parts.join(" · "); + const start = $("hp-start-btn"); + const startOo = $("hp-start-btn-oo"); + if ((gates.plan_type || state.mode) === "options_options") { + if (startOo) startOo.disabled = !gates.can_start; + } else { + if (start) start.disabled = !gates.can_start; + } + } + + function setOptionsBalance(chain) { + const acct = (chain && chain.options_account) || {}; + const label = (chain && chain.account_label) || acct.label || "期权账户"; + const tag = $("hp-opt-acct-tag"); + if (tag) tag.textContent = label; + const line = + label + + " · 交易 USDC " + + fmt(acct.trading_usdc, 2) + + " · 资金 USDC " + + fmt(acct.funding_usdc, 2); + const el = $("hp-opt-bal-line"); + if (el) el.textContent = line; + const oo = $("hp-oo-bal-line"); + if (oo) oo.textContent = line; + } + + async function loadGates() { + try { + const d = await apiJson("/api/hedge-plan/gates?plan_type=" + encodeURIComponent(state.mode)); + setGateLine(d); + } catch (e) { + setGateLine({ can_preview: false, can_start: false, reasons: [e.message], is_full_margin: false }); + } + } + + async function loadMarket() { + const dir = ($("hp-direction") && $("hp-direction").value) || "long"; + const d = await apiJson( + "/api/hedge-plan/market?base=" + + encodeURIComponent(state.underlying) + + "&direction=" + + encodeURIComponent(dir) + ); + state.market = d; + setGateLine(d.gates); + const acctLabel = d.account_label || "合约账户"; + const tag = $("hp-perp-acct-tag"); + if (tag) tag.textContent = acctLabel; + const amtPrec = d.amount_precision != null ? Number(d.amount_precision) : 4; + const q = $("hp-perp-quote"); + if (q) { + q.innerHTML = + "" + + (d.base || state.underlying) + + " · " + + acctLabel + + "可用 " + + fmt(d.available_usdt, 2) + + " USDT
        标记 " + + fmt(d.mark, 2) + + " · 最新 " + + fmt(d.last, 2) + + " · 卖一 " + + fmt(d.ask, 2) + + " · 买一 " + + fmt(d.bid, 2) + + " · 面值 " + + fmt(d.contract_size, 4) + + " · 张精度 " + + amtPrec + + " 位"; + } + const contractsInput = $("hp-contracts"); + if (contractsInput) { + const step = amtPrec <= 0 ? "1" : String(Math.pow(10, -amtPrec)); + contractsInput.step = step; + } + const sz = $("hp-sizing-line"); + if (sz) { + if (d.full_margin_sizing) { + const s = d.full_margin_sizing; + sz.textContent = + "全仓建议(" + + acctLabel + + "):保证金 " + + fmt(s.margin_capital, 2) + + " USDT × " + + s.leverage + + "x → 名义 " + + fmt(s.notional_value, 2) + + " USDT · 建议 " + + fmt(d.suggest_contracts, amtPrec) + + " 合约张(已按交易所精度)"; + } else { + sz.textContent = "非全仓或不具备保证金数据时仅手动填张数;永期开仓需全仓."; + } + } + const entry = $("hp-entry"); + if (entry && d.entry_ref && !entry.value) entry.value = d.entry_ref; + if (contractsInput && d.suggest_contracts != null && !contractsInput.value) { + contractsInput.value = fmt(d.suggest_contracts, amtPrec); + } + const label = $("hp-opt-type-label"); + if (label) label.textContent = d.suggested_opt_type === "C" ? "Call" : "Put"; + updatePerpPnlHint(); + } + + function updatePerpPnlHint() { + const el = $("hp-perp-pnl-line"); + if (!el) return; + const entry = Number(($("hp-entry") && $("hp-entry").value) || NaN); + const tp = Number(($("hp-tp") && $("hp-tp").value) || NaN); + const sl = Number(($("hp-sl") && $("hp-sl").value) || NaN); + const contracts = Number(($("hp-contracts") && $("hp-contracts").value) || NaN); + const cs = Number((state.market && state.market.contract_size) || 0.01); + const dir = (($("hp-direction") && $("hp-direction").value) || "long").toLowerCase(); + if (!(entry > 0) || !(contracts > 0) || !(cs > 0)) { + el.textContent = "填写开仓价与张数后,输入止盈/止损可看永续盈亏金额(USDT)"; + return; + } + function pnlAt(exitPx) { + const coins = contracts * cs; + if (dir === "short") return (entry - exitPx) * coins; + return (exitPx - entry) * coins; + } + const parts = []; + if (tp > 0) { + const p = pnlAt(tp); + parts.push( + "止盈预期 = 0 ? "hp-pnl-pos" : "hp-pnl-neg") + + "\">" + + (p >= 0 ? "+" : "") + + fmt(p, 2) + + " USDT" + ); + } else { + parts.push("止盈预期 —"); + } + if (sl > 0) { + const p = pnlAt(sl); + parts.push( + "止损预期 = 0 ? "hp-pnl-pos" : "hp-pnl-neg") + + "\">" + + (p >= 0 ? "+" : "") + + fmt(p, 2) + + " USDT" + ); + } else { + parts.push("止损预期 —"); + } + el.innerHTML = parts.join(" · "); + } + + function fillExpSelect(sel, chain) { + if (!sel) return; + const prev = sel.value; + sel.innerHTML = ''; + (chain.expiries || []).forEach(function (e) { + const opt = document.createElement("option"); + opt.value = String(e.exp_time); + const dt = new Date(Number(e.exp_time)); + opt.textContent = dt.toLocaleString(); + sel.appendChild(opt); + }); + if (prev) sel.value = prev; + if (!sel.value && chain.expiries && chain.expiries[0]) { + sel.value = String(chain.expiries[0].exp_time); + } + } + + async function loadChain() { + const d = await apiJson( + "/api/hedge-plan/options-chain?underlying=" + encodeURIComponent(state.underlying) + ); + state.chain = d; + const uly = d.underlying || state.underlying; + const idx = $("hp-index-line"); + if (idx) { + idx.textContent = + "指数 " + uly + " " + fmt(d.index_px, 2) + " · " + (d.inst_family || "") + " · 实值含平值"; + } + const ooIdx = $("hp-oo-index"); + if (ooIdx) ooIdx.textContent = "指数 " + uly + " " + fmt(d.index_px, 2); + setOptionsBalance(d); + fillExpSelect($("hp-exp-select"), d); + fillExpSelect($("hp-oo-exp-select"), d); + renderListStrikes(); + renderTStrikes(); + if (d.index_px) { + const idx = Number(d.index_px); + if ($("hp-target-up") && !$("hp-target-up").value) { + $("hp-target-up").value = String(Math.round(idx * 1.03)); + } + if ($("hp-target-down") && !$("hp-target-down").value) { + $("hp-target-down").value = String(Math.round(idx * 0.97)); + } + } + } + + function currentExp(selectId) { + const sel = $(selectId); + const expMs = sel && sel.value; + if (!expMs || !state.chain) return null; + return (state.chain.expiries || []).find(function (e) { + return String(e.exp_time) === String(expMs); + }); + } + + function pickContract(c) { + if (!c) return; + state.selected = c; + const el = $("hp-sel-inst"); + if (el) el.textContent = c.inst_id; + const tbody = $("hp-strike-tbody"); + if (tbody) { + tbody.querySelectorAll(".opt-strike-row").forEach(function (r) { + r.classList.toggle("opt-row-selected", r.getAttribute("data-inst") === c.inst_id); + }); + tbody.querySelectorAll(".hp-pick").forEach(function (b) { + b.classList.toggle("active", b.getAttribute("data-inst") === c.inst_id); + }); + } + updatePremiumLine(); + } + + function renderListStrikes() { + const tbody = $("hp-strike-tbody"); + if (!tbody) return; + const dir = ($("hp-direction") && $("hp-direction").value) || "long"; + const want = optTypeForDirection(dir); + const exp = currentExp("hp-exp-select"); + const prevInst = state.selected && state.selected.inst_id; + tbody.innerHTML = ""; + if (!exp) { + tbody.innerHTML = '请选择到期日'; + return; + } + const list = (exp.contracts || []).filter(function (c) { + return String(c.opt_type || "").toUpperCase() === want && matchesMoneyFilter(c); + }); + if (!list.length) { + tbody.innerHTML = '无匹配合约'; + return; + } + list.forEach(function (c) { + const tr = document.createElement("tr"); + tr.className = "opt-strike-row" + (c.moneyness ? " opt-row-" + c.moneyness : ""); + if (prevInst && c.inst_id === prevInst) tr.classList.add("opt-row-selected"); + tr.setAttribute("data-inst", c.inst_id); + tr.innerHTML = + "" + + c.strike + + "" + + moneynessBadge(c) + + '' + + fmtPxSz(c.ask, c.ask_sz, c.ask_estimated, c.tick_sz) + + '' + + fmtPxSz(c.bid, c.bid_sz, false, c.tick_sz) + + ''; + tbody.appendChild(tr); + }); + tbody.querySelectorAll(".hp-pick").forEach(function (btn) { + btn.addEventListener("click", function () { + const inst = btn.getAttribute("data-inst"); + const c = list.find(function (x) { + return x.inst_id === inst; + }); + pickContract(c); + }); + }); + } + + function updatePremiumLine() { + const line = $("hp-premium-line"); + if (!line || !state.selected) { + if (line) line.textContent = ""; + return; + } + const sheets = Number(($("hp-sheets") && $("hp-sheets").value) || 1); + const ct = Number(state.selected.ct_mult || 0.01); + const ask = Number(state.selected.ask || 0); + const prem = ask * sheets * ct; + line.textContent = "预估权利金 ≈ " + fmt(prem, 4) + " USDC(期权账户)"; + } + + function buildStraddleRows(contracts) { + const map = {}; + (contracts || []).forEach(function (c) { + const key = String(c.strike); + if (!map[key]) map[key] = { strike: c.strike, call: null, put: null }; + const o = (c.opt_type || "").toUpperCase(); + if (o === "C") map[key].call = c; + else if (o === "P") map[key].put = c; + }); + return Object.keys(map) + .map(function (k) { + return map[k]; + }) + .sort(function (a, b) { + return Number(a.strike) - Number(b.strike); + }); + } + + function renderTStrikes() { + const tbody = $("hp-oo-tbody"); + if (!tbody) return; + const exp = currentExp("hp-oo-exp-select"); + tbody.innerHTML = ""; + if (!exp) { + tbody.innerHTML = '请选择到期日'; + return; + } + const rows = buildStraddleRows(exp.contracts); + rows.forEach(function (row) { + const tr = document.createElement("tr"); + const call = row.call; + const put = row.put; + const callAsk = call ? fmtPxSz(call.ask, call.ask_sz, call.ask_estimated, call.tick_sz) : "—"; + const putAsk = put ? fmtPxSz(put.ask, put.ask_sz, put.ask_estimated, put.tick_sz) : "—"; + tr.innerHTML = + '' + + callAsk + + '' + + (call ? moneynessBadge(call) : "—") + + "" + + (call + ? '' + : "—") + + '' + + row.strike + + '' + + (put ? moneynessBadge(put) : "—") + + '' + + putAsk + + "" + + (put + ? '' + : "—") + + ""; + tbody.appendChild(tr); + }); + tbody.querySelectorAll(".hp-oo-pick").forEach(function (btn) { + btn.addEventListener("click", function () { + const inst = btn.getAttribute("data-inst"); + const exp2 = currentExp("hp-oo-exp-select"); + const c = (exp2.contracts || []).find(function (x) { + return x.inst_id === inst; + }); + if (!c) return; + if (!state.legA) state.legA = c; + else if (!state.legB || state.legB.inst_id === state.legA.inst_id) state.legB = c; + else { + state.legA = c; + state.legB = null; + } + renderOoLegs(); + }); + }); + } + + function renderOoLegs() { + function fill(tag, c, infoId, sheetsId) { + const info = $(infoId); + const sheets = $(sheetsId); + if (!info) return; + if (!c) { + info.textContent = tag + ": 尚未选用"; + if (sheets) { + sheets.disabled = true; + sheets.value = "1"; + } + return; + } + info.innerHTML = + tag + + ": " + + c.opt_type + + " K" + + c.strike + + " " + + (c.moneyness_label || "") + + " · 卖一 " + + fmtPxSz(c.ask, c.ask_sz, c.ask_estimated, c.tick_sz) + + " " + + c.inst_id + + ""; + if (sheets) sheets.disabled = false; + } + fill("腿A", state.legA, "hp-oo-leg-a-info", "hp-oo-sheets-a"); + fill("腿B", state.legB, "hp-oo-leg-b-info", "hp-oo-sheets-b"); + updateOoPremiumLine(); + } + + function ooSheets(id) { + const n = Number(($(id) && $(id).value) || 1); + return n > 0 ? n : 1; + } + + function updateOoPremiumLine() { + const line = $("hp-oo-prem-line"); + if (!line) return; + if (!state.legA && !state.legB) { + line.textContent = ""; + return; + } + function prem(c, sheets) { + if (!c) return 0; + return Number(c.ask || 0) * sheets * Number(c.ct_mult || 0.01); + } + const a = prem(state.legA, ooSheets("hp-oo-sheets-a")); + const b = prem(state.legB, ooSheets("hp-oo-sheets-b")); + line.textContent = + "预估权利金 A " + + fmt(a, 4) + + " + B " + + fmt(b, 4) + + " ≈ " + + fmt(a + b, 4) + + " USDC"; + } + + function legPayload(c, sheets) { + return { + opt_type: c.opt_type, + strike: c.strike, + sheets: sheets, + ct_mult: c.ct_mult || 0.01, + ask: c.ask, + inst_id: c.inst_id, + }; + } + + function setUnderlying(uly, forceReload) { + const next = (uly || "ETH").toUpperCase(); + const changed = next !== state.underlying; + state.underlying = next; + syncUnderlyingUI(); + if (!changed && !forceReload) return; + state.selected = null; + state.legA = null; + state.legB = null; + if ($("hp-entry")) $("hp-entry").value = ""; + if ($("hp-contracts")) $("hp-contracts").value = ""; + if ($("hp-tp")) $("hp-tp").value = ""; + if ($("hp-sl")) $("hp-sl").value = ""; + if ($("hp-target-up")) $("hp-target-up").value = ""; + if ($("hp-target-down")) $("hp-target-down").value = ""; + if ($("hp-sel-inst")) $("hp-sel-inst").textContent = "—"; + if ($("hp-premium-line")) $("hp-premium-line").textContent = ""; + if ($("hp-oo-sheets-a")) { + $("hp-oo-sheets-a").value = "1"; + $("hp-oo-sheets-a").disabled = true; + } + if ($("hp-oo-sheets-b")) { + $("hp-oo-sheets-b").value = "1"; + $("hp-oo-sheets-b").disabled = true; + } + renderOoLegs(); + void refreshAll(); + } + + async function runPreview() { + const isOo = state.mode === "options_options"; + const tbody = $(isOo ? "hp-result-tbody-oo" : "hp-result-tbody"); + const summary = $(isOo ? "hp-summary-oo" : "hp-summary"); + try { + let body; + if (isOo) { + if (!state.legA || !state.legB) throw new Error("请选用两条期权腿"); + const up = Number(($("hp-target-up") && $("hp-target-up").value) || 0); + const down = Number(($("hp-target-down") && $("hp-target-down").value) || 0); + if (!up || !down) throw new Error("请填写上破与下破目标价"); + if (up <= down) throw new Error("上破目标价必须大于下破目标价"); + body = { + plan_type: "options_options", + target_price_up: up, + target_price_down: down, + target_price: up, + index_px: (state.chain && state.chain.index_px) || (up + down) / 2, + leg_a: legPayload(state.legA, ooSheets("hp-oo-sheets-a")), + leg_b: legPayload(state.legB, ooSheets("hp-oo-sheets-b")), + }; + } else { + if (!state.selected) throw new Error("请选用期权腿"); + const entry = Number(($("hp-entry") && $("hp-entry").value) || 0); + const tp = Number(($("hp-tp") && $("hp-tp").value) || 0); + const sl = Number(($("hp-sl") && $("hp-sl").value) || 0); + const contracts = Number(($("hp-contracts") && $("hp-contracts").value) || 0); + const sheets = Number(($("hp-sheets") && $("hp-sheets").value) || 1); + if (!entry || !tp || !sl || !contracts) throw new Error("请完整填写开仓/止盈/止损/张数"); + body = { + plan_type: "perp_options", + direction: ($("hp-direction") && $("hp-direction").value) || "long", + entry: entry, + tp: tp, + sl: sl, + contracts: contracts, + contract_size: (state.market && state.market.contract_size) || 0.01, + opt_type: state.selected.opt_type, + strike: state.selected.strike, + sheets: sheets, + ct_mult: state.selected.ct_mult || 0.01, + ask: state.selected.ask, + index_px: state.chain && state.chain.index_px, + }; + } + const d = await apiJson("/api/hedge-plan/preview", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + setGateLine(d.gates); + const s = d.summary || {}; + if (summary) { + if (d.plan_type === "perp_options") { + summary.innerHTML = + "止盈合计 " + + fmt(s.tp_total) + + " · 止损合计 " + + fmt(s.sl_total) + + " · 保费 " + + fmt(s.premium_paid) + + (s.hedge_ratio_at_sl != null ? " · 止损对冲率 " + fmt(s.hedge_ratio_at_sl) + "%" : ""); + } else { + summary.innerHTML = + "目标价合计 " + + fmt(s.at_target_total) + + " · 到期现价 " + + fmt(s.expiry_flat_total) + + " · 保费 " + + fmt(s.premium_paid) + + (s.expiry_is_loss ? " · 到期无盈利(记总亏损)" : ""); + } + } + if (!tbody) return; + tbody.innerHTML = ""; + (d.scenarios || []).forEach(function (sc) { + const tr = document.createElement("tr"); + let mid; + if (sc.perp_pnl != null) { + mid = "永续 " + fmt(sc.perp_pnl); + } else { + mid = "A " + fmt(sc.leg_a_pnl) + " / B " + fmt(sc.leg_b_pnl); + } + const optCol = sc.options_pnl != null ? fmt(sc.options_pnl) : "—"; + tr.innerHTML = + "" + + (sc.label || sc.id) + + "" + + fmt(sc.spot) + + "" + + mid + + "" + + optCol + + "" + + fmt(sc.total) + + "" + + (sc.note || "") + + ""; + tbody.appendChild(tr); + }); + } catch (e) { + if (tbody) tbody.innerHTML = '' + (e.message || e) + ""; + if (summary) summary.textContent = ""; + } + } + + function bind() { + document.querySelectorAll(".hp-tab").forEach(function (b) { + b.addEventListener("click", function () { + state.tab = b.getAttribute("data-tab") || "perp_options"; + syncTabUI(); + if (state.tab === "perp_options" || state.tab === "options_options") { + void loadGates(); + } else if (state.tab === "active") { + void loadActivePlans(); + } else if (state.tab === "history") { + void loadHistory(); + } else if (state.tab === "stats") { + void loadStats(); + } else { + const el = $("hp-gate-line"); + if (el) el.textContent = ""; + } + }); + }); + document.querySelectorAll(".hp-uly-btn, .hp-uly-btn-oo").forEach(function (b) { + b.addEventListener("click", function () { + setUnderlying(b.getAttribute("data-uly") || "ETH", true); + }); + }); + document.querySelectorAll(".hp-money-btn").forEach(function (b) { + b.addEventListener("click", function () { + state.moneyFilter = b.getAttribute("data-money") || "all"; + syncMoneyUI(); + renderListStrikes(); + }); + }); + const dir = $("hp-direction"); + if (dir) { + dir.addEventListener("change", function () { + state.selected = null; + if ($("hp-sel-inst")) $("hp-sel-inst").textContent = "—"; + void loadMarket().then(function () { + renderListStrikes(); + }); + }); + } + ["hp-entry", "hp-tp", "hp-sl", "hp-contracts"].forEach(function (id) { + const el = $(id); + if (el) el.addEventListener("input", updatePerpPnlHint); + }); + if ($("hp-refresh")) + $("hp-refresh").addEventListener("click", function () { + void refreshAll(); + }); + if ($("hp-load-chain")) + $("hp-load-chain").addEventListener("click", function () { + void loadChain(); + }); + if ($("hp-oo-load-chain")) + $("hp-oo-load-chain").addEventListener("click", function () { + void loadChain(); + }); + if ($("hp-exp-select")) $("hp-exp-select").addEventListener("change", renderListStrikes); + if ($("hp-oo-exp-select")) $("hp-oo-exp-select").addEventListener("change", renderTStrikes); + if ($("hp-sheets")) $("hp-sheets").addEventListener("input", updatePremiumLine); + ["hp-oo-sheets-a", "hp-oo-sheets-b"].forEach(function (id) { + const el = $(id); + if (el) el.addEventListener("input", updateOoPremiumLine); + }); + if ($("hp-preview-btn")) + $("hp-preview-btn").addEventListener("click", function () { + state.mode = "perp_options"; + void runPreview(); + }); + if ($("hp-preview-btn-oo")) + $("hp-preview-btn-oo").addEventListener("click", function () { + state.mode = "options_options"; + void runPreview(); + }); + if ($("hp-start-btn")) + $("hp-start-btn").addEventListener("click", function () { + void startPlan("perp_options"); + }); + if ($("hp-start-btn-oo")) + $("hp-start-btn-oo").addEventListener("click", function () { + void startPlan("options_options"); + }); + if ($("hp-detail-close")) $("hp-detail-close").addEventListener("click", closeModal); + const modal = $("hp-detail-modal"); + if (modal) { + modal.addEventListener("click", function (ev) { + if (ev.target === modal) closeModal(); + }); + } + } + + async function loadHistory() { + const tbody = $("hp-history-tbody"); + if (!tbody) return; + try { + const d = await apiJson("/api/hedge-plan/history"); + const rows = d.plans || []; + if (!rows.length) { + tbody.innerHTML = '暂无已结束计划'; + return; + } + tbody.innerHTML = ""; + rows.forEach(function (p) { + const tr = document.createElement("tr"); + const typeLabel = p.plan_type === "perp_options" ? "永期" : "期期"; + const contracts = p.contracts_summary || "—"; + const pnl = p.realized_pnl_total; + const pnlCls = + pnl == null || Number.isNaN(Number(pnl)) ? "" : Number(pnl) >= 0 ? "hp-pnl-pos" : "hp-pnl-neg"; + tr.innerHTML = + "#" + + p.id + + "" + + typeLabel + + "" + + (p.underlying || "") + + "" + + contracts + + "" + + (p.status || "") + + '' + + fmt(pnl) + + "" + + reasonLabel(p.close_reason) + + "" + + (p.opened_at || "—") + + "" + + (p.closed_at || "—") + + '' + + ' ' + + '' + + ""; + tbody.appendChild(tr); + }); + tbody.querySelectorAll(".hp-btn-detail").forEach(function (btn) { + btn.addEventListener("click", function () { + void showPlanDetail(Number(btn.getAttribute("data-id"))); + }); + }); + tbody.querySelectorAll(".hp-btn-del").forEach(function (btn) { + btn.addEventListener("click", function () { + void deletePlan(Number(btn.getAttribute("data-id"))); + }); + }); + } catch (e) { + tbody.innerHTML = '' + (e.message || e) + ""; + } + } + + function activeTargetLabel(p) { + if (p.plan_type === "perp_options") { + return "止盈 " + fmt(p.tp) + " · 止损 " + fmt(p.sl); + } + return "上破 " + fmt(p.target_price_up || p.target_price) + " · 下破 " + fmt(p.target_price_down || p.target_price); + } + + async function loadActivePlans() { + const tbody = $("hp-active-tbody"); + if (!tbody) return; + try { + const d = await apiJson("/api/hedge-plan/active"); + const rows = d.plans || []; + if (!rows.length) { + tbody.innerHTML = '暂无进行中的计划'; + return; + } + tbody.innerHTML = ""; + rows.forEach(function (p) { + const tr = document.createElement("tr"); + const typeLabel = p.plan_type === "perp_options" ? "永期" : "期期"; + const contracts = p.contracts_summary || "—"; + tr.innerHTML = + "#" + + p.id + + "" + + typeLabel + + "" + + (p.underlying || "") + + "" + + contracts + + "" + + '进行中' + + "" + + activeTargetLabel(p) + + "" + + (p.opened_at || "—") + + ''; + tbody.appendChild(tr); + }); + tbody.querySelectorAll(".hp-btn-detail").forEach(function (btn) { + btn.addEventListener("click", function () { + void showPlanDetail(Number(btn.getAttribute("data-id"))); + }); + }); + } catch (e) { + tbody.innerHTML = '' + (e.message || e) + ""; + } + } + + function reasonLabel(r) { + const map = { + perp_tp: "永续止盈", + perp_sl: "永续止损", + oo_expiry_loss: "期期到期亏损", + oo_expiry_win: "期期到期盈利", + target_win_leg: "期期平盈利腿", + target_up_win_leg: "期期上破·平盈利腿", + target_down_win_leg: "期期下破·平盈利腿", + expiry: "到期", + manual: "人工结束", + partial_fail: "半腿失败", + cancelled: "已取消", + }; + return map[r] || r || "—"; + } + + function roleLabel(role) { + const map = { + perp: "永续腿", + option_hedge: "保险期权", + option_a: "期期腿A", + option_b: "期期腿B", + }; + return map[role] || role || "—"; + } + + function closeModal() { + const m = $("hp-detail-modal"); + if (m) m.hidden = true; + } + + async function showPlanDetail(planId) { + const modal = $("hp-detail-modal"); + const body = $("hp-detail-body"); + const title = $("hp-detail-title"); + if (!modal || !body) return; + modal.hidden = false; + body.innerHTML = '

        加载中…

        '; + if (title) title.textContent = "成交细节 #" + planId; + try { + const d = await apiJson("/api/hedge-plan/" + planId); + const p = d.plan || {}; + const legs = d.legs || []; + const typeLabel = p.plan_type === "perp_options" ? "永期对冲" : "期期对冲"; + let html = ""; + html += '
        '; + html += "
        类型 " + typeLabel + "
        "; + html += "
        标的 " + (p.underlying || "—"); + if (p.direction) html += " · " + (p.direction === "long" ? "做多" : "做空"); + html += "
        "; + html += "
        状态 " + (p.status || "—") + " / " + reasonLabel(p.close_reason) + "
        "; + html += "
        时间 " + (p.opened_at || "—") + " → " + (p.closed_at || "—") + "
        "; + html += + "
        盈亏 永续 " + + fmt(p.realized_pnl_perp) + + " · 期权 " + + fmt(p.realized_pnl_options) + + " · 合计 = 0 ? "hp-pnl-pos" : "hp-pnl-neg") + + '">' + + fmt(p.realized_pnl_total) + + " ≈U
        "; + if (p.plan_type === "perp_options") { + html += + "
        参考价 开 " + + fmt(p.entry_mark) + + " · 止盈 " + + fmt(p.tp) + + " · 止损 " + + fmt(p.sl) + + " · 杠杆 " + + fmt(p.leverage, 0) + + "x · 张数 " + + fmt(p.perp_size, 4) + + "
        "; + } else { + html += + "
        目标价 上破 " + + fmt(p.target_price_up || p.target_price) + + " · 下破 " + + fmt(p.target_price_down || p.target_price) + + "
        "; + } + html += + "
        权利金合计 " + + fmt(p.premium_total, 4) + + " USDC
        "; + html += + "
        合约摘要 " + + (d.contracts_summary || "—") + + "
        "; + html += "
        "; + html += ''; + html += + ""; + html += ""; + if (!legs.length) { + html += ''; + } else { + legs.forEach(function (leg) { + const contract = + leg.leg_role === "perp" + ? leg.symbol || "—" + : leg.inst_id || "—"; + const side = + leg.leg_role === "perp" + ? leg.side || "—" + : (leg.opt_type || "") + (leg.strike != null ? " K" + fmt(leg.strike, 0) : ""); + html += ""; + html += ""; + html += ""; + html += ""; + html += ""; + html += ""; + html += ""; + html += ""; + html += ""; + html += ""; + html += ""; + html += ""; + }); + } + html += "
        角色合约名称方向/类型数量开仓价权利金状态腿盈亏成交号平仓原因
        无腿记录
        " + roleLabel(leg.leg_role) + "" + contract + "" + side + "" + fmt(leg.size, leg.leg_role === "perp" ? 4 : 0) + "" + fmt(leg.avg_open, 4) + "" + (leg.premium != null ? fmt(leg.premium, 4) : "—") + "" + (leg.status || "—") + "" + fmt(leg.realized_pnl, 4) + "" + (leg.exchange_ord_id || "—") + "" + reasonLabel(leg.close_reason) + "
        "; + if (p.note) { + html += '

        备注 ' + String(p.note) + "

        "; + } + body.innerHTML = html; + } catch (e) { + body.innerHTML = '

        ' + (e.message || e) + "

        "; + } + } + + async function deletePlan(planId) { + if (!window.confirm("确认删除历史计划 #" + planId + "?此操作不可恢复。")) return; + try { + await apiJson("/api/hedge-plan/" + planId, { method: "DELETE" }); + await loadHistory(); + if (state.tab === "stats") await loadStats(); + } catch (e) { + window.alert(e.message || String(e)); + } + } + + function metricCard(title, m) { + if (!m || !m.count) { + return ( + '

        ' + + title + + '

        暂无已结束样本

        ' + ); + } + const wr = m.win_rate == null ? "—" : (Number(m.win_rate) * 100).toFixed(1) + "%"; + let pf = "—"; + if (m.profit_factor_infinite) pf = "∞"; + else if (m.profit_factor != null) pf = fmt(m.profit_factor, 2); + return ( + '

        ' + + title + + "

          " + + "
        • 笔数" + + m.count + + "
        • " + + "
        • 胜率" + + wr + + " (" + + m.wins + + "/" + + m.count + + ")
        • " + + "
        • 净盈亏≈U= 0 ? "hp-pnl-pos" : "hp-pnl-neg") + + '">' + + fmt(m.net_pnl) + + "
        • " + + "
        • 盈亏比" + + pf + + " 毛利/|毛亏|
        • " + + "
        • 最大盈利" + + fmt(m.max_profit) + + "
        • " + + "
        • 最大亏损" + + fmt(m.max_loss) + + "
        • " + + "
        • 最大回撤" + + fmt(m.max_drawdown) + + "
        • " + + "
        • 平均保费" + + fmt(m.avg_premium, 4) + + "
        • " + + "
        " + ); + } + + async function loadStats() { + const box = $("hp-stats-box"); + if (!box) return; + try { + const d = await apiJson("/api/hedge-plan/stats"); + const by = d.by_type || {}; + let html = '
        '; + html += + '

        总览

        活跃 ' + + (d.active || 0) + + " · 已结 " + + (d.closed_count || 0) + + ' · 合计 ' + + fmt(d.closed_pnl_total) + + " ≈U

        "; + html += metricCard("永期对冲", by.perp_options); + html += metricCard("期期对冲", by.options_options); + html += "
        "; + const poB = (by.perp_options && by.perp_options.buckets) || {}; + const ooB = (by.options_options && by.options_options.buckets) || {}; + if ((poB.tp && poB.tp.count) || (poB.sl && poB.sl.count) || (ooB.expiry_loss && ooB.expiry_loss.count)) { + html += '
        '; + if (poB.tp && poB.tp.count) html += metricCard("永期·止盈桶", poB.tp); + if (poB.sl && poB.sl.count) html += metricCard("永期·止损桶", poB.sl); + if (ooB.expiry_loss && ooB.expiry_loss.count) html += metricCard("期期·到期亏损", ooB.expiry_loss); + if (ooB.expiry_win && ooB.expiry_win.count) html += metricCard("期期·到期盈利", ooB.expiry_win); + html += "
        "; + } + box.innerHTML = html; + } catch (e) { + box.textContent = e.message || String(e); + } + } + + async function startPlan(planType) { + const isOo = planType === "options_options"; + try { + let body; + if (isOo) { + if (!state.legA || !state.legB) throw new Error("请选用两条期权腿"); + const up = Number(($("hp-target-up") && $("hp-target-up").value) || 0); + const down = Number(($("hp-target-down") && $("hp-target-down").value) || 0); + if (!up || !down) throw new Error("请填写上破与下破目标价"); + if (up <= down) throw new Error("上破目标价必须大于下破目标价"); + body = { + plan_type: "options_options", + underlying: state.underlying, + target_price_up: up, + target_price_down: down, + target_price: up, + leg_a: legPayload(state.legA, ooSheets("hp-oo-sheets-a")), + leg_b: legPayload(state.legB, ooSheets("hp-oo-sheets-b")), + }; + } else { + if (!state.selected) throw new Error("请选用期权腿"); + const entry = Number(($("hp-entry") && $("hp-entry").value) || 0); + const tp = Number(($("hp-tp") && $("hp-tp").value) || 0); + const sl = Number(($("hp-sl") && $("hp-sl").value) || 0); + const contracts = Number(($("hp-contracts") && $("hp-contracts").value) || 0); + const sheets = Number(($("hp-sheets") && $("hp-sheets").value) || 1); + if (!entry || !tp || !sl || !contracts) throw new Error("请完整填写开仓/止盈/止损/张数"); + body = { + plan_type: "perp_options", + underlying: state.underlying, + direction: ($("hp-direction") && $("hp-direction").value) || "long", + entry: entry, + tp: tp, + sl: sl, + contracts: contracts, + sheets: sheets, + opt_inst_id: state.selected.inst_id, + opt_type: state.selected.opt_type, + strike: state.selected.strike, + exchange_symbol: (state.market && state.market.exchange_symbol) || "", + leverage: 10, + margin: state.market && state.market.full_margin_sizing && state.market.full_margin_sizing.margin_capital, + }; + } + if (!window.confirm("确认启动对冲计划并真实下单?\n(将按期权账户/合约账户分别下单)")) return; + const d = await apiJson("/api/hedge-plan/start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + setGateLine(d.gates); + alert("计划已启动 #" + (d.plan_id || "") + (d.dry_run ? " (dry_run)" : "")); + void loadGates(); + } catch (e) { + alert(e.message || String(e)); + } + } + + async function refreshAll() { + if (state.tab === "perp_options" || state.tab === "options_options") { + await loadGates(); + } + try { + await loadMarket(); + } catch (e) { + const q = $("hp-perp-quote"); + if (q) q.textContent = e.message || String(e); + } + try { + await loadChain(); + } catch (e) { + const tbody = $("hp-strike-tbody"); + if (tbody) tbody.innerHTML = '' + (e.message || e) + ""; + } + } + + syncTabUI(); + syncUnderlyingUI(); + syncMoneyUI(); + bind(); + void refreshAll(); +})(); diff --git a/lib/common/static/instance_dashboard.js b/lib/common/static/instance_dashboard.js new file mode 100644 index 0000000..5fce5c2 --- /dev/null +++ b/lib/common/static/instance_dashboard.js @@ -0,0 +1,494 @@ +/** + * 实例数据看板:拉 /api/instance/dashboard 渲染只读表格. + * 各区块无数据时不展示;有数据按表格展示. + */ +(function (global) { + const SECTION_ORDER = ["orders", "keys", "strategy", "options", "hedge_plan"]; + let loading = false; + let timer = null; + + function root() { + const active = document.querySelector('.embed-tab-pane.is-active-pane [data-inst-dashboard="1"]'); + if (active) return active; + return document.getElementById("instance-dashboard"); + } + + function escapeHtml(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function fmtNum(v) { + if (v == null || v === "") return "—"; + const n = Number(v); + if (!Number.isFinite(n)) return escapeHtml(v); + return String(n); + } + + function fmtPnl(v) { + if (v == null || v === "") return "—"; + const n = Number(v); + if (!Number.isFinite(n)) return "—"; + const cls = n > 0 ? "pos-pnl-profit" : n < 0 ? "pos-pnl-loss" : ""; + const sign = n > 0 ? "+" : ""; + return '' + sign + n.toFixed(2) + "U"; + } + + function fmtPnlPlain(v) { + if (v == null || v === "") return "—"; + const n = Number(v); + if (!Number.isFinite(n)) return "—"; + const cls = n > 0 ? "pos-pnl-profit" : n < 0 ? "pos-pnl-loss" : ""; + return '' + n.toFixed(2) + ""; + } + + function dirCell(it) { + const d = String(it.direction || "").toLowerCase(); + const label = it.direction_label || (d === "short" ? "做空" : d === "long" ? "做多" : "-"); + const cls = d === "short" ? "inst-dash-dir-short" : d === "long" ? "inst-dash-dir-long" : ""; + return '' + escapeHtml(label) + ""; + } + + function fmtExpiry(ms) { + const n = Number(ms); + if (!Number.isFinite(n) || n <= 0) return "—"; + let fallback = "—"; + try { + const d = new Date(n); + if (!Number.isNaN(d.getTime())) { + const pad = function (x) { + return String(x).padStart(2, "0"); + }; + fallback = + d.getFullYear() + + "-" + + pad(d.getMonth() + 1) + + "-" + + pad(d.getDate()) + + " " + + pad(d.getHours()) + + ":" + + pad(d.getMinutes()); + } + } catch (_) {} + return ( + '' + + escapeHtml(fallback) + + "" + ); + } + + function goTab(tab) { + if (!tab) return; + if (global.InstanceEmbed && typeof global.InstanceEmbed.loadTab === "function") { + global.InstanceEmbed.loadTab(tab); + return; + } + const pathMap = { + trade: "/trade", + key_monitor: "/key_monitor", + strategy: "/strategy", + options: "/options", + hedge_plan: "/hedge-plan", + }; + const path = pathMap[tab] || "/" + tab; + location.href = path; + } + + function tableWrap(headers, rowsHtml) { + return ( + '
        ' + + '' + + "" + + headers + .map(function (h) { + return ""; + }) + .join("") + + "" + + "" + + rowsHtml + + "
        " + escapeHtml(h) + "
        " + ); + } + + function rowClickAttrs(tab) { + return ' class="inst-dash-row" data-dash-tab="' + escapeHtml(tab || "") + '" role="link" tabindex="0"'; + } + + function mergeOrderLive(items, orderPrices) { + const map = {}; + (orderPrices || []).forEach(function (p) { + if (p && p.id != null) map[String(p.id)] = p; + }); + return (items || []).map(function (it) { + const live = map[String(it.id)] || {}; + const mark = + live.exchange_mark_price != null + ? live.exchange_mark_price + : live.price != null + ? live.price + : it.mark_price; + const contracts = + live.contracts != null + ? live.contracts + : live.order_amount != null + ? live.order_amount + : it.contracts; + const entry = + live.avg_entry_price != null + ? live.avg_entry_price + : it.entry; + return Object.assign({}, it, { + entry: entry, + mark_price: mark, + mark_display: live.price_display || null, + contracts: contracts, + tp_profit: live.reward_at_tp_usdt != null ? live.reward_at_tp_usdt : it.tp_profit, + float_pnl: live.float_pnl != null ? live.float_pnl : it.float_pnl, + }); + }); + } + + function renderOrdersTable(items) { + const rows = items + .map(function (it) { + const sym = it.symbol || "-"; + const mark = + it.mark_display != null && it.mark_display !== "" + ? escapeHtml(it.mark_display) + : fmtNum(it.mark_price); + const tpProfit = + it.tp_profit != null && Number.isFinite(Number(it.tp_profit)) + ? '' + Number(it.tp_profit).toFixed(2) + "U" + : "—"; + return ( + "" + + '' + + escapeHtml(sym) + + "" + + dirCell(it) + + "" + + fmtNum(it.entry) + + "" + + "" + + mark + + "" + + "" + + fmtNum(it.contracts) + + "" + + "" + + tpProfit + + "" + + "" + + fmtPnlPlain(it.float_pnl) + + "" + + "—" + + "" + ); + }) + .join(""); + return tableWrap( + ["合约", "方向", "开仓价", "标记价", "张数", "盈利金额", "浮盈", "操作"], + rows + ); + } + + function renderKeysTable(items) { + const rows = items + .map(function (it) { + return ( + "" + + "" + + escapeHtml(it.symbol || "-") + + "" + + dirCell(it) + + "" + + escapeHtml(it.subtitle || "—") + + "" + + "" + + fmtNum(it.upper) + + "" + + "" + + fmtNum(it.lower) + + "" + + "" + ); + }) + .join(""); + return tableWrap(["合约", "方向", "信号", "上沿", "下沿"], rows); + } + + function renderStrategyTable(items) { + const rows = items + .map(function (it) { + const kindLabel = it.kind === "roll" ? "顺势加仓" : it.kind === "trend" ? "趋势回调" : "策略"; + return ( + "" + + "" + + escapeHtml(kindLabel) + + "" + + "" + + escapeHtml(it.symbol || "-") + + "" + + dirCell(it) + + "" + + escapeHtml(it.status || "—") + + "" + + "" + + fmtNum(it.entry) + + "" + + "" + ); + }) + .join(""); + return tableWrap(["类型", "合约", "方向", "状态", "入场"], rows); + } + + function renderOptionsTable(items) { + const rows = items + .map(function (it) { + const opt = it.opt_type_label || + (String(it.opt_type || "").toUpperCase() === "C" + ? "Call" + : String(it.opt_type || "").toUpperCase() === "P" + ? "Put" + : it.opt_type || "—"); + return ( + "" + + "" + + escapeHtml(it.inst_id || it.title || "-") + + "" + + "" + + escapeHtml(it.source_label || "纯期权") + + "" + + "" + + escapeHtml(opt) + + "" + + "" + + fmtNum(it.pos) + + "" + + "" + + fmtExpiry(it.exp_time_ms) + + "" + + "" + + escapeHtml(it.target_monitor || "—") + + "" + + "" + + fmtPnl(it.pnl) + + "" + + "" + ); + }) + .join(""); + return tableWrap(["合约", "来源", "类型", "张数", "到期时间", "目标监控", "盈亏"], rows); + } + + function renderHedgeTable(items) { + const rows = items + .map(function (it) { + const stCls = it.status_active ? "inst-dash-status-active" : ""; + const stText = it.status_label || (it.status_active ? "进行中" : it.status || "—"); + return ( + "" + + "#" + + escapeHtml(it.id != null ? it.id : "—") + + "" + + "" + + escapeHtml(it.underlying || "-") + + "" + + "" + + escapeHtml(it.plan_type_label || it.plan_type || "—") + + "" + + '' + + escapeHtml(stText) + + "" + + "" + + escapeHtml(it.contracts_summary || it.subtitle || "—") + + "" + + "" + ); + }) + .join(""); + return tableWrap(["ID", "标的", "计划类型", "状态", "说明"], rows); + } + + function renderTable(key, items) { + if (key === "orders") return renderOrdersTable(items); + if (key === "keys") return renderKeysTable(items); + if (key === "strategy") return renderStrategyTable(items); + if (key === "options") return renderOptionsTable(items); + if (key === "hedge_plan") return renderHedgeTable(items); + return ""; + } + + function sectionHasData(sec) { + if (!sec) return false; + const count = Number(sec.count); + if (Number.isFinite(count) && count > 0) return true; + return Array.isArray(sec.items) && sec.items.length > 0; + } + + function renderSection(key, sec) { + if (!sectionHasData(sec)) return ""; + const items = sec.items || []; + const count = Number(sec.count) || items.length; + return ( + '
        ' + + '
        ' + + "

        " + + escapeHtml(sec.title || key) + + ' ' + + count + + "

        " + + '' + + "
        " + + renderTable(key, items) + + "
        " + ); + } + + function bindClicks(el) { + if (!el) return; + el.querySelectorAll("[data-dash-tab]").forEach(function (node) { + const handler = function () { + goTab(node.getAttribute("data-dash-tab")); + }; + node.addEventListener("click", handler); + node.addEventListener("keydown", function (ev) { + if (ev.key === "Enter" || ev.key === " ") { + ev.preventDefault(); + handler(); + } + }); + }); + } + + async function load(opts) { + const el = root(); + if (!el) return; + const status = el.querySelector("#inst-dash-status") || document.getElementById("inst-dash-status"); + const sections = el.querySelector("#inst-dash-sections") || document.getElementById("inst-dash-sections"); + const updated = el.querySelector("#inst-dash-updated") || document.getElementById("inst-dash-updated"); + if (loading) return; + loading = true; + if (status && !(opts && opts.silent)) status.textContent = "加载中…"; + try { + const [dashRes, priceRes] = await Promise.all([ + fetch("/api/instance/dashboard", { credentials: "same-origin" }), + fetch("/api/price_snapshot", { credentials: "same-origin" }).catch(function () { + return null; + }), + ]); + const data = await dashRes.json().catch(function () { + return {}; + }); + if (!dashRes.ok || !data.ok) { + throw new Error(data.msg || dashRes.statusText || "加载失败"); + } + let orderPrices = []; + if (priceRes && priceRes.ok) { + try { + const snap = await priceRes.json(); + orderPrices = snap.order_prices || []; + } catch (_) {} + } + if (data.orders && Array.isArray(data.orders.items)) { + data.orders.items = mergeOrderLive(data.orders.items, orderPrices); + data.orders.count = data.orders.items.length; + } + if (updated) updated.textContent = "更新 " + (data.updated_at || "—"); + if (sections) { + const html = SECTION_ORDER.map(function (k) { + return renderSection(k, data[k]); + }).join(""); + sections.innerHTML = + html || '

        当前无活跃监控与持仓

        '; + bindClicks(sections); + if (global.OptionsExpiryCountdown) { + if (typeof global.OptionsExpiryCountdown.tick === "function") { + global.OptionsExpiryCountdown.tick(sections); + } + if (typeof global.OptionsExpiryCountdown.ensureTimer === "function") { + global.OptionsExpiryCountdown.ensureTimer(); + } + } + } + if (status) status.textContent = ""; + } catch (e) { + if (status) status.textContent = e.message || "加载失败"; + } finally { + loading = false; + } + } + + function stopAuto() { + if (timer) { + clearInterval(timer); + timer = null; + } + } + + function startAuto() { + stopAuto(); + timer = setInterval(function () { + const el = root(); + if (!el) return; + const pane = el.closest(".embed-tab-pane"); + if (pane && !pane.classList.contains("is-active-pane")) return; + load({ silent: true }); + }, 15000); + } + + function init(force) { + const el = root(); + if (!el) return; + if (!force && el.getAttribute("data-dash-booted") === "1") { + load({ silent: true }); + startAuto(); + return; + } + el.setAttribute("data-dash-booted", "1"); + const btn = el.querySelector("#inst-dash-refresh") || document.getElementById("inst-dash-refresh"); + if (btn && !btn.getAttribute("data-bound")) { + btn.setAttribute("data-bound", "1"); + btn.addEventListener("click", function () { + load({}); + }); + } + load({}); + startAuto(); + } + + function refreshSoft(opts) { + load(Object.assign({ silent: true }, opts || {})); + } + + global.InstanceDashboard = { + init: init, + refreshSoft: refreshSoft, + load: load, + stopAuto: stopAuto, + }; +})(window); diff --git a/lib/common/static/instance_embed.js b/lib/common/static/instance_embed.js new file mode 100644 index 0000000..cc3bef0 --- /dev/null +++ b/lib/common/static/instance_embed.js @@ -0,0 +1,525 @@ +/** + * 中控 iframe 壳:顶栏/统计常驻,tab 内容走 /api/embed/page/. + * 各 tab 面板常驻 DOM,切换时 show/hide;脚本延后到首次激活,回访零请求. + */ +(function (global) { + const TAB_PATH = { + dashboard: "/dashboard", + key_monitor: "/key_monitor", + trade: "/trade", + strategy: "/strategy", + strategy_records: "/strategy/records", + options: "/options", + options_review: "/options/review", + hedge_plan: "/hedge-plan", + records: "/records", + stats: "/stats", + risk_policy: "/risk_policy", + env_config: "/env_config", + settings: "/settings", + }; + + let navToken = 0; + let loadingTab = false; + let pendingTabLoad = null; + const tabPanes = new Map(); + const tabBooted = new Set(); + + /** 自带校验后 form.submit() 的表单,勿在捕获阶段再 fetch 一份(会双发 POST) */ + const CUSTOM_SUBMIT_FORM_IDS = new Set(["add-order-form", "key-form", "roll-form"]); + + function isEmbedShell() { + return document.body && document.body.getAttribute("data-embed-shell") === "1"; + } + + function getTab() { + try { + const t = new URLSearchParams(location.search).get("tab"); + if (t) return t; + } catch (_) {} + return document.body.getAttribute("data-page") || "trade"; + } + + function listWindowQueryString() { + if (typeof global.listWindowQueryString === "function") { + return global.listWindowQueryString(); + } + return ""; + } + + function pageRoot() { + return document.getElementById("embed-page-root"); + } + + function setNavActive(tab) { + document.querySelectorAll(".embed-top-nav [data-embed-tab]").forEach((a) => { + a.classList.toggle("active", a.getAttribute("data-embed-tab") === tab); + }); + } + + function pageNavAllowed(tab) { + if (global.InstanceSettingsPrefs && typeof global.InstanceSettingsPrefs.pageNavAllowed === "function") { + return global.InstanceSettingsPrefs.pageNavAllowed(tab); + } + return true; + } + + function syncUrl(tab, replace) { + const q = new URLSearchParams(location.search); + q.set("tab", tab); + q.set("embed", "1"); + const qs = q.toString(); + const url = "/embed?" + qs; + if (replace) history.replaceState({ embedTab: tab }, "", url); + else history.pushState({ embedTab: tab }, "", url); + } + + function notifyParentTabSwitch(tab) { + try { + window.parent.postMessage({ type: "instance-frame-navigating", embedShellTab: true, tab: tab }, "*"); + } catch (_) {} + } + + function runPageInit(tab, opts) { + const options = opts || {}; + const revisit = !!options.revisit; + document.body.setAttribute("data-page", tab); + if (!revisit && typeof global.attachListWindowToExports === "function") { + global.attachListWindowToExports(); + } + if (tab === "trade") { + if (!revisit && typeof global.refreshOrderDefaults === "function") global.refreshOrderDefaults(); + if (!revisit && typeof global.initOrderEntryModelSelect === "function") { + const root = pageRoot() || document; + global.initOrderEntryModelSelect(root); + } + if (!revisit && global.ManualOrderRrPreview && typeof global.ManualOrderRrPreview.wire === "function") { + global.ManualOrderRrPreview.wire(); + } + } + if (!revisit && tab === "key_monitor" && global.KeyMonitorForm && typeof global.KeyMonitorForm.init === "function") { + global.KeyMonitorForm.init(); + } + if (tab === "dashboard" && global.InstanceDashboard && typeof global.InstanceDashboard.init === "function") { + global.InstanceDashboard.init(!!revisit); + } + if (!revisit && tab === "strategy" && typeof global.initStrategyRollForm === "function") { + global.initStrategyRollForm(); + } + if (tab === "records") { + if (global.RecordsReviewPage && typeof global.RecordsReviewPage.init === "function") { + global.RecordsReviewPage.init({ refresh: !!revisit }); + } else { + if (!revisit && typeof global.loadJournals === "function") global.loadJournals(); + if (!revisit && typeof global.loadReviews === "function") global.loadReviews(); + } + if (global.InstanceTheme && typeof global.InstanceTheme.initReviewEditModeSync === "function") { + global.InstanceTheme.initReviewEditModeSync(); + } else if (typeof global.toggleReviewMode === "function") { + global.toggleReviewMode(); + } + } + if (tab === "stats") { + if (typeof global.initStatsSegmentFromUrl === "function") global.initStatsSegmentFromUrl(); + } + if (tab === "settings" || tab === "env_config") { + if (global.InstanceSettingsPrefs) { + if (typeof global.InstanceSettingsPrefs.bindEvents === "function") { + global.InstanceSettingsPrefs.bindEvents(); + } + if (tab === "settings" && typeof global.InstanceSettingsPrefs.loadDisplayPrefsForm === "function") { + global.InstanceSettingsPrefs.loadDisplayPrefsForm(); + } + if (tab === "env_config") { + if (typeof global.InstanceSettingsPrefs.loadEnvConfig === "function") { + global.InstanceSettingsPrefs.loadEnvConfig(); + } + if (typeof global.InstanceSettingsPrefs.bindEnvTabs === "function") { + global.InstanceSettingsPrefs.bindEnvTabs(); + } + } + } + } + if (!revisit) { + if (typeof global.refreshAccountSnapshot === "function") { + global.refreshAccountSnapshot({ silent: true }); + } + if (typeof global.refreshPriceSnapshotConditional === "function") { + global.refreshPriceSnapshotConditional(); + } + if (global.SymbolLivePrice && typeof global.SymbolLivePrice.init === "function") { + const root = pageRoot() || document; + global.SymbolLivePrice.init(root); + } + if (global.JournalUploadSlots && typeof global.JournalUploadSlots.init === "function") { + const root = pageRoot() || document; + global.JournalUploadSlots.init(root); + } + } + } + + function runScripts(container) { + container.querySelectorAll("script").forEach((old) => { + const s = document.createElement("script"); + if (old.src) s.src = old.src; + else s.textContent = old.textContent; + old.replaceWith(s); + }); + } + + function showPane(tab) { + tabPanes.forEach((pane, name) => { + const on = name === tab; + pane.hidden = !on; + pane.classList.toggle("is-active-pane", on); + }); + } + + function bootPaneScripts(tab) { + if (tabBooted.has(tab)) return; + const pane = tabPanes.get(tab); + if (!pane) return; + runScripts(pane); + tabBooted.add(tab); + } + + function mountPane(tab, html) { + const root = pageRoot(); + if (!root) return null; + const existing = tabPanes.get(tab); + if (existing) existing.remove(); + + const pane = document.createElement("div"); + pane.className = "embed-tab-pane"; + pane.setAttribute("data-embed-pane", tab); + pane.hidden = true; + + const holder = document.createElement("div"); + holder.innerHTML = html; + while (holder.firstChild) pane.appendChild(holder.firstChild); + + root.appendChild(pane); + tabPanes.set(tab, pane); + return pane; + } + + function initBootPane() { + const root = pageRoot(); + if (!root || tabPanes.size > 0) return; + const tab = getTab(); + if (root.querySelector("[data-embed-pane]")) return; + if (!root.childNodes.length) return; + + const pane = document.createElement("div"); + pane.className = "embed-tab-pane is-active-pane"; + pane.setAttribute("data-embed-pane", tab); + Array.from(root.childNodes).forEach((node) => pane.appendChild(node)); + root.appendChild(pane); + tabPanes.set(tab, pane); + tabBooted.add(tab); + showPane(tab); + } + + function embedPageUrl(tab) { + const qs = listWindowQueryString(); + let url = "/api/embed/page/" + encodeURIComponent(tab); + const parts = []; + if (qs) parts.push(qs); + parts.push("embed=1"); + return url + "?" + parts.join("&"); + } + + async function fetchTabHtml(tab) { + const r = await fetch(embedPageUrl(tab), { + credentials: "same-origin", + headers: { "X-Instance-Soft-Nav": "1" }, + }); + const ct = (r.headers.get("content-type") || "").toLowerCase(); + if (!ct.includes("application/json")) { + throw new Error("加载失败(HTTP " + r.status + ")"); + } + const j = await r.json(); + if (!j.ok || !j.html) throw new Error(j.msg || "加载失败"); + return j.html; + } + + function warmTabCache(tab) { + if (!tab || tabPanes.has(tab) || loadingTab) return; + fetchTabHtml(tab) + .then((html) => { + if (!tabPanes.has(tab)) mountPane(tab, html); + }) + .catch(() => {}); + } + + function preloadAllTabs() { + const tabs = Object.keys(TAB_PATH); + const current = getTab(); + const heavyLast = new Set(["options", "records", "stats"]); + const ordered = tabs.filter((t) => t !== current && !heavyLast.has(t)) + .concat(tabs.filter((t) => heavyLast.has(t) && t !== current)); + let idx = 0; + function step() { + if (idx >= ordered.length) return; + const tab = ordered[idx++]; + if (tabPanes.has(tab)) { + step(); + return; + } + fetchTabHtml(tab) + .then((html) => { + if (!tabPanes.has(tab)) mountPane(tab, html); + }) + .catch(() => {}) + .finally(() => { + setTimeout(step, heavyLast.has(tab) ? 400 : 180); + }); + } + const ric = global.requestIdleCallback || function (fn) { + setTimeout(fn, 2000); + }; + ric(step); + } + + function clearTabCache() { + tabPanes.forEach((pane) => pane.remove()); + tabPanes.clear(); + tabBooted.clear(); + } + + function syncShellChrome(tab) { + const hideTopBar = tab === "settings" || tab === "risk_policy" || tab === "env_config"; + document.querySelectorAll(".instance-top-bar").forEach((el) => { + el.hidden = hideTopBar; + }); + } + + function initPaneThemeToggle(tab) { + if (tab !== "settings") return; + const pane = tabPanes.get(tab); + if (!pane || !global.InstanceTheme) return; + if (typeof global.InstanceTheme.initToggleUI === "function") { + global.InstanceTheme.initToggleUI(pane); + } + if (typeof global.InstanceTheme.syncToggleUI === "function") { + global.InstanceTheme.syncToggleUI(pane); + } + } + + function activateTab(tab, opts) { + const options = opts || {}; + const revisit = !!options.revisit; + const firstBoot = !tabBooted.has(tab); + syncShellChrome(tab); + showPane(tab); + setNavActive(tab); + if (!options.skipUrl) syncUrl(tab, !!options.replace); + notifyParentTabSwitch(tab); + if (firstBoot) { + bootPaneScripts(tab); + initPaneThemeToggle(tab); + runPageInit(tab, { revisit: false }); + return; + } + if (revisit) { + document.body.setAttribute("data-page", tab); + runPageInit(tab, { revisit: true }); + return; + } + runPageInit(tab, { revisit: false }); + } + + async function loadTab(tab, opts) { + const options = opts || {}; + if (!tab) return; + if (!pageNavAllowed(tab)) { + void loadTab("trade", { replace: true }); + return; + } + + if (tabPanes.has(tab) && !options.force) { + activateTab(tab, Object.assign({}, options, { revisit: true })); + return; + } + + if (loadingTab) { + pendingTabLoad = { tab: tab, opts: options }; + return; + } + const token = ++navToken; + loadingTab = true; + try { + const html = await fetchTabHtml(tab); + if (token !== navToken) return; + mountPane(tab, html); + activateTab(tab, options); + } catch (e) { + if (token === navToken) { + const flash = document.getElementById("embed-flash"); + if (flash) { + flash.style.display = ""; + flash.textContent = String(e && e.message ? e.message : e); + } + } + } finally { + if (token === navToken) loadingTab = false; + if (pendingTabLoad) { + const pending = pendingTabLoad; + pendingTabLoad = null; + if (pending.tab !== tab) void loadTab(pending.tab, pending.opts); + } + } + } + + function reloadCurrentTab() { + const tab = getTab(); + const pane = tabPanes.get(tab); + if (pane) pane.remove(); + tabPanes.delete(tab); + tabBooted.delete(tab); + return loadTab(tab, { replace: true, skipUrl: true, force: true }); + } + + function postFormAndReload(form, label) { + if (!form) return Promise.resolve(); + if (global.FormSubmitGuard) { + if (global.FormSubmitGuard.isLocked(form)) { + global.FormSubmitGuard.setSubmitLabel(form, label || "提交中…"); + } else { + global.FormSubmitGuard.lock(form, label || "提交中…"); + } + } + const fd = new FormData(form); + return fetch(form.action, { + method: form.method || "POST", + body: fd, + credentials: "same-origin", + redirect: "manual", + }) + .then(() => reloadCurrentTab()) + .catch(() => reloadCurrentTab()); + } + + function patchApplyListWindow() { + if (typeof global.applyListWindow !== "function") return; + global.applyListWindow = function embedApplyListWindow() { + clearTabCache(); + const qs = listWindowQueryString(); + const tab = getTab(); + const q = new URLSearchParams(qs); + q.set("tab", tab); + q.set("embed", "1"); + window.location.href = "/embed?" + q.toString(); + }; + } + + function patchHardNavigations() { + const resubmitPaths = + /^\/(del_|delete_|add_|stop_|strategy\/|trend_|roll_|cancel_|place_)/; + + document.addEventListener( + "click", + (ev) => { + if (!isEmbedShell()) return; + const a = ev.target.closest("a[href]"); + if (!a || ev.defaultPrevented) return; + if (a.closest(".embed-top-nav")) return; + if (a.hasAttribute("download") || a.target === "_blank") return; + const raw = a.getAttribute("href"); + if (!raw || raw.startsWith("#") || raw.startsWith("javascript:")) return; + let url; + try { + url = new URL(raw, location.href); + } catch (_) { + return; + } + if (url.origin !== location.origin) return; + if (url.pathname.startsWith("/export/") || url.pathname.startsWith("/order_focus") || url.pathname.startsWith("/key_focus")) { + return; + } + if (!resubmitPaths.test(url.pathname)) return; + ev.preventDefault(); + fetch(url.pathname + url.search, { credentials: "same-origin", redirect: "manual" }) + .then(() => reloadCurrentTab()) + .catch(() => reloadCurrentTab()); + }, + false + ); + + document.addEventListener( + "submit", + (ev) => { + if (!isEmbedShell()) return; + const form = ev.target; + if (!(form instanceof HTMLFormElement)) return; + if (form.method && form.method.toUpperCase() === "GET") return; + if (CUSTOM_SUBMIT_FORM_IDS.has(form.id)) return; + ev.preventDefault(); + const fd = new FormData(form); + fetch(form.action, { + method: form.method || "POST", + body: fd, + credentials: "same-origin", + redirect: "manual", + }) + .then(() => reloadCurrentTab()) + .catch(() => reloadCurrentTab()); + }, + true + ); + } + + function bindNav() { + document.querySelectorAll(".embed-top-nav [data-embed-tab]").forEach((a) => { + a.addEventListener("mouseenter", () => { + warmTabCache(a.getAttribute("data-embed-tab")); + }); + a.addEventListener("click", (ev) => { + ev.preventDefault(); + const tab = a.getAttribute("data-embed-tab"); + if (!tab || tab === getTab()) return; + void loadTab(tab); + }); + }); + window.addEventListener("popstate", () => { + const tab = getTab(); + void loadTab(tab, { replace: true, skipUrl: true }); + }); + } + + function boot() { + if (!isEmbedShell()) return; + patchApplyListWindow(); + patchHardNavigations(); + initBootPane(); + const bootTab = getTab(); + if (!pageNavAllowed(bootTab)) { + void loadTab("trade", { replace: true }); + return; + } + if (bootTab === "settings") { + initPaneThemeToggle("settings"); + } + bindNav(); + syncShellChrome(getTab()); + runPageInit(getTab()); + preloadAllTabs(); + try { + window.parent.postMessage({ type: "instance-frame-ready" }, "*"); + } catch (_) {} + } + + global.InstanceEmbed = { + loadTab, + reloadCurrentTab, + getTab, + postFormAndReload, + clearTabCache, + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", boot); + } else { + boot(); + } +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/instance_live.js b/lib/common/static/instance_live.js new file mode 100644 index 0000000..f942c0d --- /dev/null +++ b/lib/common/static/instance_live.js @@ -0,0 +1,113 @@ +/** + * embed 壳:SSE 收到后台 tick 后拉 JSON 快照更新 DOM,切换 tab 不再重复请求 HTML. + */ +(function (global) { + let liveEventSource = null; + let liveReconnectTimer = null; + let localLiveVersion = -1; + let sseConnected = false; + let refreshTimer = null; + + function isEmbedShell() { + return document.body && document.body.getAttribute("data-embed-shell") === "1"; + } + + function currentTab() { + if (global.InstanceEmbed && typeof global.InstanceEmbed.getTab === "function") { + return global.InstanceEmbed.getTab(); + } + return document.body.getAttribute("data-page") || "trade"; + } + + function refreshTabData(tab, opts) { + const options = opts || {}; + if (typeof global.refreshAccountSnapshot === "function") { + global.refreshAccountSnapshot(options); + } + if (typeof global.refreshPriceSnapshotConditional === "function") { + global.refreshPriceSnapshotConditional(); + } + if (tab === "options" && global.OptionsPanelLive && typeof global.OptionsPanelLive.refreshSoft === "function") { + global.OptionsPanelLive.refreshSoft(options); + } + if (tab === "dashboard" && global.InstanceDashboard && typeof global.InstanceDashboard.refreshSoft === "function") { + global.InstanceDashboard.refreshSoft(options); + } + } + + function scheduleRefresh(opts) { + if (refreshTimer) return; + const options = opts || {}; + refreshTimer = setTimeout(function () { + refreshTimer = null; + if (document.hidden) return; + refreshTabData(currentTab(), { silent: true, force: !!options.force }); + }, 80); + } + + function onLiveEvent(data) { + const reason = data && data.reason; + const ver = Number(data && data.live_version) || 0; + if (!ver) return; + if (reason === "connect") { + localLiveVersion = ver; + scheduleRefresh(); + return; + } + if (ver === localLiveVersion) return; + localLiveVersion = ver; + scheduleRefresh({ force: reason === "balance" }); + } + + function closeLiveStream() { + if (liveEventSource) { + liveEventSource.close(); + liveEventSource = null; + } + if (liveReconnectTimer) { + clearTimeout(liveReconnectTimer); + liveReconnectTimer = null; + } + sseConnected = false; + } + + function connectLiveStream() { + if (!isEmbedShell()) return; + closeLiveStream(); + liveEventSource = new EventSource("/api/instance/live/stream"); + liveEventSource.addEventListener("live", function (ev) { + try { + onLiveEvent(JSON.parse(ev.data || "{}")); + } catch (_) {} + }); + liveEventSource.onopen = function () { + sseConnected = true; + }; + liveEventSource.onerror = function () { + sseConnected = false; + closeLiveStream(); + liveReconnectTimer = setTimeout(function () { + connectLiveStream(); + }, 8000); + }; + } + + function startLive() { + if (!isEmbedShell()) return; + connectLiveStream(); + } + + global.InstanceLive = { + start: startLive, + refreshTabData: refreshTabData, + isConnected: function () { + return sseConnected; + }, + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", startLive); + } else { + startLive(); + } +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/instance_page.css b/lib/common/static/instance_page.css new file mode 100644 index 0000000..0b56f37 --- /dev/null +++ b/lib/common/static/instance_page.css @@ -0,0 +1,294 @@ +.order-trade-style-hint{font-size:.78rem;color:#8fc8ff;margin-left:4px;white-space:nowrap} +.order-entry-model-row{display:flex;flex-wrap:wrap;align-items:center;gap:6px} +.order-entry-model-row select.order-entry-category{min-width:4.8em;max-width:6.5em} +.order-entry-model-row select.order-entry-model-sub{min-width:7em;max-width:10rem} +.order-leverage-hint{font-size:.78rem;color:#cfd3ef;white-space:nowrap;align-self:center} + body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;background:#0b0d14;color:#eaeaea;padding:14px 20px} + .container{width:100%;max-width:min(1440px,94vw);margin:0 auto;padding:0 clamp(8px,1.5vw,20px)} + .header{display:flex;flex-direction:column;align-items:center;gap:8px;margin-bottom:12px} + .header h1{font-size:1.75rem;color:#dbe4ff;text-align:center;line-height:1.25} + .exchange-tag{font-size:.82rem;font-weight:600;color:#b8f5d0;background:#14241e;border:1px solid #2d6a4f;padding:5px 14px;border-radius:999px;letter-spacing:.06em} + .header-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:center} + .top-nav{display:flex;gap:8px;flex-wrap:wrap;justify-content:center;margin-bottom:12px} + .top-nav a{padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a;color:#8fc8ff;text-decoration:none} + .top-nav a.active{background:#2a3f6c;color:#dbe4ff} + .stat-box{display:grid;grid-template-columns:repeat(auto-fit,minmax(148px,1fr));gap:12px;margin-bottom:16px;align-items:stretch} + .stat-item{min-width:0;min-height:76px;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:6px;background:#151a2a;padding:12px 10px;border-radius:10px;text-align:center;border:1px solid #2a3152} + .stat-item .label{font-size:.8rem;color:#aaa;line-height:1.25;max-width:100%} + .stat-item .value{font-size:1.25rem;font-weight:600;color:#fff;line-height:1.3;min-height:1.35em;display:flex;align-items:center;justify-content:center} + .grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px} + .card{background:#121726;border-radius:10px;padding:12px;border:1px solid #2a3150} + .full{grid-column:1/-1} + .card h2{font-size:1rem;margin-bottom:10px;color:#d4d9ff} + .form-row{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px;align-items:center} + .form-row > input:not([type=checkbox]):not([type=radio]),.form-row > select{flex:0 1 auto;width:10rem;max-width:200px;min-width:7rem} + #add-order-form #sltp-mode{min-width:12.5rem;max-width:16rem;width:auto} + .order-plan-preview{display:flex;gap:18px;flex-wrap:wrap;align-items:center;margin:4px 0 10px;padding:10px 12px;background:#151a28;border:1px solid #2a3150;border-radius:8px;font-size:.85rem} + .order-preview-risk{color:#ff6b6b} + .order-preview-risk strong{color:#ff8f8f;font-weight:600} + .order-preview-profit{color:#4cd97f} + .order-preview-profit strong{color:#6ee7a0;font-weight:600} + .order-preview-rr{color:#cfd3ef} + .order-preview-rr strong{font-weight:600;color:#dbe4ff} + .order-preview-rr.order-preview-rr-low strong{color:#ff8f8f} + .order-preview-rr.order-preview-rr-ok strong{color:#8fc8ff} + .form-row > button,.form-row > label{flex:0 0 auto} + .form-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px} + /* 复盘表单:长下拉文案需可收缩,否则会撑破四列网格 */ + .journal-card .form-grid{gap:10px} + .journal-card .form-grid > input, + .journal-card .form-grid > select{ + min-width:0; + width:100%; + max-width:100%; + box-sizing:border-box; + } + .journal-card #journal-form textarea[name="note"]{ + display:block;width:100%;max-width:100%;box-sizing:border-box;margin-top:8px; + } + input,select,button,textarea{padding:8px 10px;border-radius:8px;border:1px solid #2e2e45;background:#1a1a29;color:#fff;font-size:.88rem;outline:none} + button{background:linear-gradient(90deg,#4285f4,#7b42ff);border:none;cursor:pointer} + .list{display:flex;flex-direction:column;gap:8px;margin-top:8px;max-height:240px;overflow:auto} + .list-item{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:9px;background:#1a2034;border:1px solid #2a3150;border-radius:8px} + .btn-del{padding:5px 9px;background:#2f2134;color:#ff7b7b;border-radius:8px;text-decoration:none;font-size:.8rem} + .rule-tip{font-size:.8rem;color:#95a2c2;margin-bottom:8px} + table{width:100%;border-collapse:collapse} + th,td{padding:8px;text-align:left;border-bottom:1px solid #25253b;font-size:.85rem} + th{color:#a9a9ff} + .badge{padding:2px 6px;border-radius:6px;font-size:.72rem} + .profit{background:#1e332f;color:#4cd97f} + .loss{background:#331e24;color:#ff6666} + .miss{background:#29241e;color:#eac147} + .direction{background:#1e2533;color:#4cc2ff} + .direction-long{background:#1e332f;color:#4cd97f} + .direction-short{background:#331e24;color:#ff6666} + .pnl-profit{color:#4cd97f;font-weight:600} + .pnl-loss{color:#ff6666;font-weight:600} + .flash{padding:10px;background:#1e2533;color:#4cc2ff;border-radius:10px;margin-bottom:12px;text-align:center;border:1px solid #304164} + form.is-form-submitting{opacity:.88;pointer-events:none} + form.is-form-submitting button[type=submit],form.is-form-submitting input[type=submit]{cursor:wait} + .ai-result{background:#1a1a29;border:1px solid #2e2e45;border-radius:8px;padding:10px;white-space:pre-wrap;max-height:220px;overflow:auto;font-size:.84rem;line-height:1.45;margin-top:8px} + .ai-result.ai-result-md,.detail-modal .panel-body.md-review{white-space:normal} + .ai-result-md p,.detail-modal .panel-body.md-review p{margin:6px 0;color:#dde2ff} + .ai-result-md ul,.ai-result-md ol,.detail-modal .panel-body.md-review ul,.detail-modal .panel-body.md-review ol{margin:6px 0 8px 1.25em;padding:0} + .ai-result-md li,.detail-modal .panel-body.md-review li{margin:5px 0;line-height:1.5} + .ai-result-md strong,.detail-modal .panel-body.md-review strong{color:#f0f3ff;font-weight:600} + .ai-result-md h2,.detail-modal .panel-body.md-review h2{font-size:1.02rem;color:#b8c8ff;margin:14px 0 8px;padding-bottom:4px;border-bottom:1px solid #2e2e45} + .ai-result-md h3,.detail-modal .panel-body.md-review h3{font-size:.92rem;color:#c9d4ff;margin:10px 0 6px} + .ai-result-md code,.detail-modal .panel-body.md-review code{background:#252538;padding:1px 4px;border-radius:4px;font-size:.82em} + .ai-result-md .md-raw-block-title,.detail-modal .panel-body.md-review .md-raw-block-title{margin-top:14px;padding-top:10px;border-top:1px dashed #3a3a55;color:#a8b0d8;font-weight:600} + .price-up{color:#4cd97f} + .price-down{color:#ff6666} + .price-flat{color:#cfd3ef} + .panel-list{display:grid;grid-template-columns:1fr 1fr;gap:12px} + .panel-item{background:#141423;border:1px solid #24243b;border-radius:10px;padding:10px;max-height:260px;overflow:auto} + .entry{border-bottom:1px solid #2b2b43;padding:8px 0} + .entry:last-child{border-bottom:none} + .table-del{padding:4px 8px;background:#2f2134;color:#ff7b7b;border:none;border-radius:6px;cursor:pointer;font-size:.78rem} + .mood-grid{display:flex;gap:10px;flex-wrap:wrap;font-size:.82rem;color:#d7d7ea} + .mood-grid label{display:flex;align-items:center;gap:3px} + .screenshot{width:100px;border-radius:6px;cursor:pointer;margin-top:6px} + .modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.78);justify-content:center;align-items:center;z-index:1210} + .modal img{max-width:90%;max-height:90%;border-radius:8px} + .detail-modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.78);justify-content:center;align-items:center;z-index:1200;padding:20px} + .detail-modal .panel{width:min(92vw,980px);max-height:88vh;overflow:auto;background:#121726;border:1px solid #2a3150;border-radius:10px;padding:14px} + .detail-modal .panel-head{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:10px} + .detail-modal .panel-title{font-size:1rem;color:#dbe4ff} + .detail-modal .panel-close{padding:6px 10px;background:#2f2134;color:#ffb2b2;border:none;border-radius:8px;cursor:pointer} + .detail-modal .panel-body{white-space:pre-wrap;line-height:1.5;font-size:.86rem;color:#e5e9ff} + .detail-modal .panel-image{margin-top:10px;max-width:min(100%,680px);border-radius:8px;cursor:pointer;border:1px solid #2a3150} + .detail-modal .panel-actions{display:flex;gap:8px;align-items:center;flex-shrink:0} + .detail-modal .panel-fs{padding:6px 10px;background:#1f3a5a;color:#8fc8ff;border:none;border-radius:8px;cursor:pointer;font-size:.82rem} + .detail-modal.fullscreen{padding:10px} + .detail-modal.fullscreen .panel{width:100%;height:100%;max-width:none;max-height:none;display:flex;flex-direction:column;overflow:hidden} + .detail-modal.fullscreen .panel-body{flex:1;overflow:auto;min-height:0;font-size:.9rem} + .ai-result-wrap{margin-top:8px} + .ai-result-toolbar{display:flex;gap:8px;margin-top:6px} + .ai-result-toolbar .btn-fs{padding:4px 10px;font-size:.78rem;background:#1f3a5a;color:#8fc8ff;border:none;border-radius:6px;cursor:pointer} + .table-wrap{overflow-x:auto} + .dual-panel-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;align-items:stretch} + .dual-panel-grid .card{height:100%;display:flex;flex-direction:column} + .panel-scroll{flex:1;min-height:280px;max-height:420px;overflow:auto} + .records-card{grid-column:1/-1} + .review-card{grid-column:1/-1} + .review-card-head{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap} + .review-card-head h2{margin:0} + .review-card-fs-btn{padding:6px 12px;background:#1f3a5a;color:#8fc8ff;border:none;border-radius:8px;cursor:pointer;font-size:.82rem;white-space:nowrap} + .review-card-fs-btn:hover{filter:brightness(1.08)} + body.review-card-fullscreen-open{overflow:hidden} + .review-card.is-fullscreen{ + position:fixed;inset:12px;z-index:1100;margin:0; + width:auto !important;max-width:none;height:auto; + overflow:auto;display:flex;flex-direction:column; + box-shadow:0 12px 48px rgba(0,0,0,.55); + } + .review-card.is-fullscreen .panel-list{flex:1;min-height:320px} + .review-card.is-fullscreen .panel-item{max-height:none;height:auto;min-height:280px} + .review-card.is-fullscreen .ai-result{max-height:min(36vh, 320px)} + @media (max-width: 1200px){ + .stat-box{grid-template-columns:repeat(auto-fill,minmax(140px,1fr))} + } + @media (min-width: 1440px){ + .panel-scroll,.pos-list{max-height:420px} + .records-card .table-wrap{max-height:620px;overflow:auto} + } + @media (min-width: 2200px){ + .container{max-width:min(1720px,90vw)} + } + @media (min-width: 2560px){ + .container{max-width:min(1860px,88vw)} + .dual-panel-grid{gap:18px} + } + @media (min-width: 3000px){ + .container{max-width:min(1980px,86vw)} + .pos-grid{grid-template-columns:repeat(4,minmax(0,1fr))} + } + @media (max-width: 1100px){ + .grid{grid-template-columns:1fr} + .dual-panel-grid{grid-template-columns:1fr} + .records-card,.review-card{grid-column:auto} + .panel-list{grid-template-columns:1fr} + } + @media (max-width: 960px){ + body{padding:10px} + .form-grid{grid-template-columns:repeat(2,minmax(0,1fr))} + .stat-box{grid-template-columns:repeat(2,minmax(0,1fr))} + } + .stats-detail{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:10px;margin-top:10px} + .stats-detail .stat-item{min-width:0;min-height:0;display:block;text-align:left;padding:10px 12px;align-items:stretch;gap:4px} + .stats-detail .stat-item .value{min-height:0;display:block;font-size:1.05rem} + .stats-detail .stat-item .label{font-size:.75rem} + .stats-detail .stat-item .value{font-size:1.05rem;word-break:break-all} + .export-bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:12px;font-size:.85rem} + .export-bar a{color:#8fc8ff;text-decoration:none;padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a} + .export-bar a:hover{background:#1f2740} + .list-window-bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:12px;padding:10px 12px;background:#151a2a;border:1px solid #304164;border-radius:10px;font-size:.82rem} + .list-window-bar label{color:#9aa;display:flex;align-items:center;gap:6px} + .stats-segment-block{margin-top:20px;padding-top:14px;border-top:1px solid #3a4468} + .stats-segment-block h2{font-size:1.05rem;color:#dbe4ff;margin-bottom:8px} + .stats-period-tabs{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:12px;position:relative;z-index:2} + .stats-period-tab{background:#151a2a;color:#9aa3bf;border:1px solid #304164;border-radius:8px;padding:7px 14px;font-size:.84rem;cursor:pointer;transition:background .15s,border-color .15s,color .15s} + .stats-period-tab:hover{background:#1c2438;color:#cfd3ef} + .stats-period-tab.active{background:#1f3a5a;color:#8fc8ff;border-color:#3d5f8a;font-weight:600} + .stats-period-pane[hidden]{display:none!important} + .stats-period-range{font-size:.78rem;color:#8892b0;margin-bottom:12px;line-height:1.45} + .inst-stats-viz{display:flex;flex-direction:column;gap:14px;margin-bottom:14px} + .inst-stats-kpis{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px} + .inst-stats-kpi{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;padding:12px 10px;background:#151a2a;border:1px solid #2a3152;border-radius:10px;text-align:center;min-height:88px} + .inst-stats-kpi-val{font-size:1.15rem;font-weight:700;font-variant-numeric:tabular-nums;line-height:1.2} + .inst-stats-kpi-lbl{font-size:.72rem;color:#8892b0;line-height:1.3} + .inst-stats-ring{--win-pct:0;width:56px;height:56px;border-radius:50%;background:conic-gradient(#4cd97f 0 calc(var(--win-pct) * 1%),#ff6b6b calc(var(--win-pct) * 1%) 100%);display:flex;align-items:center;justify-content:center;position:relative} + .inst-stats-ring::before{content:"";position:absolute;inset:7px;border-radius:50%;background:#151a2a} + .inst-stats-ring-label{position:relative;z-index:1;font-size:.78rem;font-weight:700;font-variant-numeric:tabular-nums} + .inst-stats-block{padding:12px;background:#141923;border:1px solid #2a3150;border-radius:10px} + .inst-stats-block-title{font-size:.72rem;color:#8892b0;margin-bottom:8px} + .inst-stats-stacked-bar{display:flex;height:10px;border-radius:6px;overflow:hidden;background:#1e2438} + .inst-stats-stacked-fill{height:100%;min-width:0;transition:width .2s ease} + .inst-stats-stacked-fill--profit{background:#4cd97f} + .inst-stats-stacked-fill--loss{background:#ff6b6b} + .inst-stats-bar-labels{display:flex;justify-content:space-between;gap:10px;margin-top:8px;font-size:.76rem;font-variant-numeric:tabular-nums} + .inst-stats-risk-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 12px} + .inst-stats-risk-item{display:flex;flex-direction:column;gap:3px;min-width:0} + .inst-stats-risk-item .k{font-size:.7rem;color:#8892b0} + .inst-stats-risk-item .v{font-size:.84rem;font-weight:600;font-variant-numeric:tabular-nums;color:#e8ecf4;word-break:break-word} + .inst-stats-empty{margin:0;padding:18px;text-align:center;color:#8892b0;font-size:.85rem;background:#141923;border:1px dashed #2a3348;border-radius:10px} + .inst-stats-details{margin-top:4px} + .inst-stats-details>summary{cursor:pointer;font-size:.84rem;color:#9aa3bf;padding:8px 0;user-select:none;list-style-position:inside} + .inst-stats-details>summary::-webkit-details-marker{color:#6d7689} + .inst-stats-details[open]>summary{margin-bottom:6px;color:#cfd3ef} + @media (max-width:640px){.inst-stats-kpis{grid-template-columns:1fr}.inst-stats-risk-grid{grid-template-columns:1fr}} + .key-history{margin-top:12px;padding-top:10px;border-top:1px solid #2a3150} + .key-history h3{font-size:.88rem;color:#b8c4ff;margin-bottom:6px} + .key-history .sub{font-size:.72rem;color:#8892b0;margin-bottom:6px} + .key-history .list{max-height:200px} + .pos-section{margin-top:12px} + .pos-section-title{font-size:.82rem;color:#8892b0;margin-bottom:8px;font-weight:500} + .pos-list{display:flex;flex-direction:column;gap:10px;max-height:280px;overflow:auto} + .dual-panel-grid .pos-list-live{max-height:none;overflow:visible;flex:1 1 auto} + .dual-panel-grid .panel-scroll.pos-list-live{max-height:none;overflow:visible} + .pos-card{background:#141923;border:1px solid #2a3348;border-radius:10px;padding:12px 14px} + .pos-card-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px} + .pos-meta{font-size:.74rem;color:#8b95a8;line-height:1.45;margin-bottom:12px;display:flex;flex-wrap:wrap;align-items:center;gap:4px 0} + .pos-meta-item{display:inline-flex;align-items:center} + .pos-meta-item:not(:last-child)::after{content:'|';margin:0 8px;color:#3d4659} + .pos-meta-on{color:#6eb5ff} + .pos-meta-off{color:#7d8799} + .pos-breakeven-badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:6px;font-size:.72rem;font-weight:600;background:#1a3d2e;color:#4cd97f} + .pos-card-symbol{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0} + .pos-card-symbol strong{font-size:.95rem;color:#fff;font-weight:600} + .pos-side-badge{padding:3px 8px;border-radius:6px;font-size:.72rem;font-weight:500;line-height:1.2} + .pos-side-long{background:#253a6e;color:#6eb5ff} + .pos-side-short{background:#4a2230;color:#ff8a8a} + .pos-head-actions{display:flex;align-items:center;gap:6px;flex-shrink:0} + .pos-entrust-btn{padding:6px 12px;background:#2a4a7a;color:#8fc8ff;border:none;border-radius:8px;font-size:.82rem;font-weight:500;cursor:pointer;white-space:nowrap} + .pos-entrust-btn:hover{background:#355d96} + .pos-close-btn{padding:6px 14px;background:#c45454;color:#fff;border-radius:8px;text-decoration:none;font-size:.82rem;font-weight:500;flex-shrink:0;white-space:nowrap;border:none;cursor:pointer;display:inline-block} + .pos-close-btn:hover{background:#d66565;color:#fff} + .pos-ex-orders{margin-top:10px;padding-top:10px;border-top:1px dashed #2a3348} + .pos-ex-orders-title{font-size:.74rem;color:#7d8799;margin-bottom:6px} + .pos-ex-order-row{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:.78rem;color:#c5cce0;margin-top:5px} + .pos-ex-order-main{flex:1;min-width:0;line-height:1.35} + .pos-ex-cancel-btn{padding:3px 10px;background:#3a3048;color:#d4b8ff;border:none;border-radius:6px;font-size:.74rem;cursor:pointer;flex-shrink:0} + .pos-ex-cancel-btn:disabled{opacity:.4;cursor:not-allowed} + .tpsl-modal-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:9000;align-items:center;justify-content:center;padding:16px} + .tpsl-modal-backdrop.open{display:flex} + .tpsl-modal{background:#1a2030;border:1px solid #3a4a66;border-radius:12px;padding:16px 18px;width:min(440px,100%);max-height:90vh;overflow:auto} + .tpsl-modal h3{margin:0 0 12px;font-size:1rem;color:#fff} + .tpsl-modal .form-row{margin-bottom:10px} + .tpsl-modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:14px} + .tpsl-modal-actions button{padding:8px 16px;border-radius:8px;border:none;cursor:pointer;font-size:.85rem} + .tpsl-modal-submit{background:#2d6a4f;color:#fff} + .tpsl-modal-cancel{background:#3a3f52;color:#ddd} + .review-entry-reason-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:9100;align-items:center;justify-content:center;padding:16px} + .review-entry-reason-backdrop.open{display:flex} + .review-entry-reason-modal{background:#1a2030;border:1px solid #3a4a66;border-radius:12px;padding:16px 18px;width:min(480px,100%);max-height:90vh;overflow:auto} + .review-entry-reason-modal h3{margin:0 0 8px;font-size:1rem;color:#fff} + .review-entry-reason-hint{margin:0 0 12px;font-size:.82rem;color:#9aa3c7;line-height:1.45} + .review-entry-reason-select{width:100%;padding:8px 10px;border-radius:8px;border:1px solid #3a4a66;background:#121726;color:#e8ecff;font-size:.9rem} + .review-entry-reason-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:14px} + .review-entry-reason-actions button{padding:8px 16px;border-radius:8px;border:none;cursor:pointer;font-size:.85rem} + .review-entry-reason-ok{background:#2d6a4f;color:#fff} + .review-entry-reason-cancel{background:#3a3f52;color:#ddd} + .pos-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px 14px;margin-bottom:12px} + .pos-cell{display:flex;flex-direction:column;gap:4px;min-width:0} + .pos-label{font-size:.72rem;color:#7d8799} + .pos-value{font-size:.88rem;color:#e8ecf4;font-weight:500;line-height:1.25} + .pos-val-dash{opacity:.75;color:#8b95a8} + .pos-value.price-up{color:#4cd97f} + .pos-value.price-down{color:#ff6666} + .pos-value.price-flat{color:#e8ecf4} + .pos-footer{display:flex;flex-wrap:wrap;gap:14px 18px;font-size:.75rem;color:#6d7689} + .pos-empty{padding:18px;text-align:center;color:#8892b0;font-size:.85rem;background:#141923;border:1px dashed #2a3348;border-radius:10px} + @media (max-width:520px){.pos-grid{grid-template-columns:repeat(2,1fr)}} + .stats-card{grid-column:1/-1;margin-top:14px} + .stats-card .stats-toggle{background:#1f3a5a;color:#8fc8ff;border:none;border-radius:8px;padding:6px 10px;cursor:pointer} + .stats-card.collapsed .stats-content{display:none} + .stats-period-block{margin-bottom:18px;padding-bottom:14px;border-bottom:1px solid #2a3150} + .stats-period-block:last-child{border-bottom:none;margin-bottom:0;padding-bottom:0} + .stats-period-block h3{font-size:1rem;color:#dbe4ff;margin-bottom:4px} + .stats-period-block .sub{font-size:.78rem;color:#8892b0;margin-bottom:10px;line-height:1.4} +#embed-page-root{min-height:120px;position:relative} +.embed-tab-pane[hidden]{display:none!important} +.inst-dash-card{grid-column:1/-1} +.inst-dash-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:8px} +.inst-dash-desc{margin:0;font-size:.82rem} +.inst-dash-head-actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap} +.inst-dash-status{min-height:1.2em;margin:0 0 10px} +.inst-dash-sections{display:flex;flex-direction:column;gap:14px} +.inst-dash-section{padding:12px;background:#141923;border:1px solid #2a3150;border-radius:10px} +.inst-dash-section-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px} +.inst-dash-section-head h3{margin:0;font-size:.95rem;color:#dbe4ff} +.inst-dash-count{display:inline-block;min-width:1.4em;padding:1px 7px;margin-left:4px;border-radius:999px;background:#1f3a5a;color:#8fc8ff;font-size:.75rem;font-weight:600} +.inst-dash-empty{margin:0;padding:14px;text-align:center;border:1px dashed #2a3348;border-radius:8px;font-size:.84rem} +.inst-dash-table-wrap{overflow:auto;border:1px solid #2a3150;border-radius:8px} +.inst-dash-table{width:100%;border-collapse:collapse;font-size:.84rem} +.inst-dash-table th,.inst-dash-table td{padding:8px 10px;text-align:left;border-bottom:1px solid #25253b;white-space:nowrap} +.inst-dash-table th{color:#a9a9ff;background:#151a2a;font-weight:600} +.inst-dash-table tbody tr:last-child td{border-bottom:none} +.inst-dash-table tbody tr.inst-dash-row{cursor:pointer} +.inst-dash-table tbody tr.inst-dash-row:hover{background:#1e2740} +.inst-dash-sym-link{color:#8fc8ff;text-decoration:underline} +.inst-dash-dir-long{color:#4cd97f;font-weight:600} +.inst-dash-dir-short{color:#ff6666;font-weight:600} +.inst-dash-status-active{color:#4cd97f;font-weight:600} +.inst-dash-table .pos-tp-profit{color:#cfd3ef} diff --git a/lib/common/static/instance_records_mobile.js b/lib/common/static/instance_records_mobile.js new file mode 100644 index 0000000..8f165d2 --- /dev/null +++ b/lib/common/static/instance_records_mobile.js @@ -0,0 +1,74 @@ +/** + * 手机端:交易记录 / 复盘记录紧凑列表(币种 · 方向 · 盈亏),点击展开详情. + */ +(function (global) { + "use strict"; + + var resizeTimer = null; + + function refreshTradeRecords() { + var UI = global.InstanceUI; + if (!UI) return; + var card = document.querySelector(".records-card"); + if (!card) return; + var tableWrap = card.querySelector(".table-wrap"); + var table = tableWrap && tableWrap.querySelector("table"); + if (!table) return; + + var listEl = card.querySelector(".mobile-record-list"); + var mobile = UI.isMobileCompactRecords(); + + if (!mobile) { + if (listEl) listEl.remove(); + return; + } + + if (!listEl) { + listEl = document.createElement("div"); + listEl.className = "mobile-record-list"; + tableWrap.parentNode.insertBefore(listEl, tableWrap); + } + + var rows = table.querySelectorAll('tr[id^="trade-row-"]'); + listEl.innerHTML = rows.length + ? Array.prototype.map + .call(rows, function (tr) { + return UI.renderMobileTradeRow(tr); + }) + .join("") + : '
        暂无交易记录
        '; + + listEl.querySelectorAll(".mobile-record-row").forEach(function (btn) { + btn.addEventListener("click", function () { + var rowId = btn.getAttribute("data-row-id"); + var tr = rowId && document.getElementById(rowId); + if (tr) UI.openTradeRecordDetailModal(tr); + }); + }); + } + + function onResize() { + if (resizeTimer) clearTimeout(resizeTimer); + resizeTimer = setTimeout(function () { + refreshTradeRecords(); + if (typeof global.loadJournals === "function" && document.getElementById("journal-list")) { + global.loadJournals(); + } + }, 180); + } + + function init() { + refreshTradeRecords(); + global.addEventListener("resize", onResize); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } + + global.InstanceRecordsMobile = { + refresh: refreshTradeRecords, + }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/instance_settings_prefs.js b/lib/common/static/instance_settings_prefs.js new file mode 100644 index 0000000..e43676d --- /dev/null +++ b/lib/common/static/instance_settings_prefs.js @@ -0,0 +1,490 @@ +/** + * 实例:导航显示,env 配置,改密,PM2 重启. + */ +(function (global) { + const DISPLAY = () => global.__INSTANCE_DISPLAY__ || {}; + + function setStatus(el, text, isErr) { + if (!el) return; + el.textContent = text || ""; + el.classList.toggle("err", !!isErr); + } + + async function fetchJson(url, opts) { + const res = await fetch(url, Object.assign({ credentials: "same-origin" }, opts || {})); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(data.msg || res.statusText || "请求失败"); + } + return data; + } + + /** 默认关闭的导航开关:缺失时按 false,不能用 !== false */ + const NAV_DEFAULT_OFF = { show_nav_dashboard: true }; + + function navPrefShow(display, key) { + if (!key) return true; + if (NAV_DEFAULT_OFF[key]) return display[key] === true; + return display[key] !== false; + } + + function applyDisplayToNav(display) { + const map = { + dashboard: "show_nav_dashboard", + strategy: "show_nav_strategy", + strategy_records: "show_nav_strategy_records", + records: "show_nav_records", + stats: "show_nav_stats", + options: "show_nav_options", + "options-review": "show_nav_options_review", + options_review: "show_nav_options_review", + "hedge-plan": "show_nav_hedge_plan", + hedge_plan: "show_nav_hedge_plan", + risk_policy: "show_nav_risk_policy", + env_config: "show_nav_env_config", + }; + document.querySelectorAll(".embed-top-nav [data-embed-tab], .top-nav a[href^='/']").forEach((a) => { + const tab = a.getAttribute("data-embed-tab") || (a.getAttribute("href") || "").replace(/^\//, "").split("?")[0]; + const key = map[tab]; + if (!key) return; + const show = navPrefShow(display, key); + a.classList.toggle("nav-hidden", !show); + a.style.display = show ? "" : "none"; + }); + global.__INSTANCE_DISPLAY__ = display; + } + + function pageNavAllowed(tab) { + const d = DISPLAY(); + const map = { + dashboard: "show_nav_dashboard", + strategy: "show_nav_strategy", + strategy_records: "show_nav_strategy_records", + records: "show_nav_records", + stats: "show_nav_stats", + options: "show_nav_options", + "options-review": "show_nav_options_review", + options_review: "show_nav_options_review", + "hedge-plan": "show_nav_hedge_plan", + hedge_plan: "show_nav_hedge_plan", + risk_policy: "show_nav_risk_policy", + env_config: "show_nav_env_config", + }; + const key = map[tab]; + if (!key) return true; + return navPrefShow(d, key); + } + + function displayPrefsRoot() { + const settingsPane = document.querySelector('.embed-tab-pane[data-embed-pane="settings"]'); + if (settingsPane) { + const inSettings = settingsPane.querySelector("#display-prefs-form"); + if (inSettings) return inSettings; + } + const pane = document.querySelector(".embed-tab-pane.is-active-pane"); + if (pane) { + const inPane = pane.querySelector("#display-prefs-form"); + if (inPane) return inPane; + } + return document.getElementById("display-prefs-form"); + } + + function displayPrefsStatusEl() { + const card = document.getElementById("display-prefs-card"); + if (card) { + const el = card.querySelector("#display-prefs-status"); + if (el) return el; + } + return document.getElementById("display-prefs-status"); + } + + function envConfigRoot() { + const activePane = document.querySelector(".embed-tab-pane.is-active-pane"); + if (activePane) { + return activePane.querySelector(".env-config-page"); + } + return document.querySelector(".env-config-page"); + } + + function bindEnvTabs() { + /* Tab 切换由 CSS radio+label 实现 */ + } + + async function loadDisplayPrefsForm(force) { + const root = displayPrefsRoot(); + if (!root) return; + if (!force && root.getAttribute("data-prefs-ssr") === "1" && root.querySelector("[data-pref-key]")) { + return; + } + return loadDisplayPrefsFormIn(root); + } + + async function loadDisplayPrefsFormIn(root) { + try { + const data = await fetchJson("/api/settings/display"); + const display = data.display || {}; + const meta = data.meta || []; + root.innerHTML = ""; + meta.forEach((group) => { + const section = document.createElement("div"); + section.className = "display-prefs-group"; + const title = document.createElement("h3"); + title.className = "settings-subcard-title"; + title.textContent = group.group; + section.appendChild(title); + const grid = document.createElement("div"); + grid.className = "display-prefs-checks"; + (group.entries || []).forEach((item) => { + const label = document.createElement("label"); + label.className = "chk-label"; + const cb = document.createElement("input"); + cb.type = "checkbox"; + cb.dataset.prefKey = item.key; + cb.checked = NAV_DEFAULT_OFF[item.key] + ? display[item.key] === true + : display[item.key] !== false; + label.appendChild(cb); + label.appendChild(document.createTextNode(" " + item.label)); + grid.appendChild(label); + }); + section.appendChild(grid); + root.appendChild(section); + }); + root.setAttribute("data-prefs-ssr", "1"); + } catch (e) { + root.innerHTML = '' + (e.message || "加载失败") + ""; + } + } + + async function saveDisplayPrefs() { + const status = displayPrefsStatusEl(); + const root = displayPrefsRoot(); + if (!root) { + setStatus(status, "未找到导航设置表单", true); + return; + } + const display = {}; + root.querySelectorAll("input[data-pref-key]").forEach((cb) => { + display[cb.dataset.prefKey] = !!cb.checked; + }); + try { + const data = await fetchJson("/api/settings/display", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ display }), + }); + applyDisplayToNav(data.display || display); + setStatus(status, "已保存,导航已更新"); + } catch (e) { + setStatus(status, e.message || "保存失败", true); + } + } + + let envSchemaGroups = []; + + function renderEnvFieldRow(field) { + const row = document.createElement("div"); + row.className = "env-field-row" + (field.restart_required ? " env-field-row--restart" : ""); + const label = document.createElement("label"); + label.className = "env-field-label"; + label.htmlFor = "env-f-" + field.key; + label.textContent = field.label || field.key; + if (field.restart_required) { + const mark = document.createElement("span"); + mark.className = "env-restart-mark"; + mark.title = "需重启"; + mark.textContent = "*"; + label.appendChild(mark); + } + row.appendChild(label); + if (field.note) { + const note = document.createElement("div"); + note.className = "env-field-note muted"; + note.textContent = field.note; + row.appendChild(note); + } + let input; + if (field.type === "bool") { + input = document.createElement("select"); + input.id = "env-f-" + field.key; + [["true", "开启"], ["false", "关闭"]].forEach(([v, text]) => { + const o = document.createElement("option"); + o.value = v; + o.textContent = text; + input.appendChild(o); + }); + const cur = (field.current || field.default || "false").toLowerCase(); + input.value = cur === "true" || cur === "1" ? "true" : "false"; + } else { + input = document.createElement("input"); + input.id = "env-f-" + field.key; + input.type = "password"; + // 防止浏览器把登录密码自动填进 API Key/Secret(保存对冲开关时曾误写入密钥) + input.autocomplete = "new-password"; + input.setAttribute("data-lpignore", "true"); + input.setAttribute("data-1p-ignore", "true"); + input.setAttribute("data-form-type", "other"); + input.readOnly = true; + input.addEventListener("focus", function () { + input.readOnly = false; + }); + if (field.sensitive) { + input.dataset.envSensitive = "1"; + input.dataset.envDirty = "0"; + input.addEventListener("input", function () { + input.dataset.envDirty = "1"; + }); + if (field.has_value) { + const cur = document.createElement("div"); + cur.className = "env-sensitive-current muted"; + const labelSpan = document.createElement("span"); + labelSpan.textContent = "已配置 "; + const masked = document.createElement("span"); + masked.className = "env-masked-value"; + masked.textContent = field.masked || ""; + cur.appendChild(labelSpan); + cur.appendChild(masked); + row.appendChild(cur); + } + input.placeholder = field.has_value ? "修改时填写新值,留空不修改" : "请输入"; + } else { + input.type = "text"; + input.autocomplete = "off"; + input.value = field.current || field.default || ""; + } + } + input.dataset.envKey = field.key; + input.className = "env-field-input"; + row.appendChild(input); + return row; + } + + function renderEnvConfigBody(groups) { + const body = document.createElement("div"); + body.className = "env-config-body card"; + body.id = "env-config-body"; + body.setAttribute("data-env-ssr", "1"); + groups.forEach((_group, idx) => { + const radio = document.createElement("input"); + radio.type = "radio"; + radio.name = "env-section"; + radio.id = "env-sec-" + idx; + radio.className = "env-tab-radio"; + if (idx === 0) radio.checked = true; + body.appendChild(radio); + }); + const tabBar = document.createElement("div"); + tabBar.className = "env-config-tabs"; + tabBar.setAttribute("role", "tablist"); + const panelsWrap = document.createElement("div"); + panelsWrap.className = "env-config-panels"; + panelsWrap.id = "env-config-grid"; + groups.forEach((group, idx) => { + const label = document.createElement("label"); + label.className = "env-tab-btn"; + label.htmlFor = "env-sec-" + idx; + label.setAttribute("role", "tab"); + label.textContent = group.title || "其他"; + tabBar.appendChild(label); + const panel = document.createElement("section"); + panel.className = "env-panel env-panel--" + idx; + panel.setAttribute("role", "tabpanel"); + if (group.has_restart) { + const hint = document.createElement("p"); + hint.className = "env-panel-hint"; + hint.textContent = "本组含需重启项,修改后请点「保存并重启」."; + panel.appendChild(hint); + } + const grid = document.createElement("div"); + grid.className = "env-form-grid"; + (group.fields || []).forEach((field) => grid.appendChild(renderEnvFieldRow(field))); + panel.appendChild(grid); + panelsWrap.appendChild(panel); + }); + body.appendChild(tabBar); + body.appendChild(panelsWrap); + return body; + } + + async function loadEnvConfig(force) { + const root = envConfigRoot(); + const body = root && root.querySelector("#env-config-body"); + if (!force && body && body.getAttribute("data-env-ssr") === "1" && body.querySelector("[data-env-key]")) { + return; + } + return loadEnvConfigIn(root); + } + + async function loadEnvConfigIn(root) { + const page = root || envConfigRoot() || document.querySelector(".env-config-page"); + if (!page) return; + const loading = document.createElement("div"); + loading.className = "env-config-loading-wrap card"; + loading.id = "env-config-body"; + loading.innerHTML = '
        加载配置中…
        '; + const oldBody = page.querySelector("#env-config-body"); + const oldGrid = page.querySelector("#env-config-grid.env-config-loading-wrap"); + if (oldBody) oldBody.replaceWith(loading); + else if (oldGrid) oldGrid.replaceWith(loading); + try { + const data = await fetchJson("/api/settings/env"); + envSchemaGroups = data.groups || []; + loading.replaceWith(renderEnvConfigBody(envSchemaGroups)); + } catch (e) { + loading.innerHTML = '' + (e.message || "加载失败") + ""; + } + } + + function collectEnvValues() { + const root = envConfigRoot(); + const values = {}; + const scope = root || document; + scope.querySelectorAll(".env-field-input[data-env-key]").forEach((el) => { + if (el.dataset.envSensitive === "1" && el.dataset.envDirty !== "1") { + // 未改动过的敏感项不提交,避免浏览器自动填充覆盖已有密钥 + return; + } + values[el.dataset.envKey] = el.value; + }); + return values; + } + + async function saveEnvConfig(restartAfter) { + const status = document.getElementById("env-config-status"); + setStatus(status, "保存中…"); + try { + const data = await fetchJson("/api/settings/env", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ values: collectEnvValues() }), + }); + const needRestart = restartAfter || data.restart_required; + if (needRestart) { + setStatus(status, "已保存,正在重启实例…"); + await restartInstance(); + setStatus(status, "保存并重启完成"); + await loadEnvConfig(); + } else { + setStatus(status, "已保存(即时生效项已应用)"); + await loadEnvConfig(); + } + } catch (e) { + setStatus(status, e.message || "保存失败", true); + } + } + + async function restartInstance() { + try { + await fetchJson("/api/admin/restart", { method: "POST" }); + } catch (_) { + // 重启会中断当前 HTTP 连接;只要后续 health 恢复即视为成功. + } + const deadline = Date.now() + 90000; + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 2000)); + try { + const h = await fetch("/api/admin/health", { credentials: "same-origin" }); + if (h.ok) return; + } catch (_) {} + } + throw new Error("重启后服务未在预期时间内恢复"); + } + + async function savePassword() { + const status = document.getElementById("pwd-save-status"); + const body = { + old_password: (document.getElementById("pwd-old") || {}).value || "", + new_username: (document.getElementById("pwd-new-username") || {}).value || "", + new_password: (document.getElementById("pwd-new") || {}).value || "", + confirm_password: (document.getElementById("pwd-confirm") || {}).value || "", + }; + try { + const data = await fetchJson("/api/settings/password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (data.restart_required) { + setStatus(status, "密码已保存,正在重启…"); + await restartInstance(); + setStatus(status, "密码已更新,请用新密码登录"); + } else { + setStatus(status, "密码已更新"); + } + } catch (e) { + setStatus(status, e.message || "保存失败", true); + } + } + + function installDelegatedHandlers() { + if (document.documentElement.dataset.prefsDelegateBound === "1") return; + document.documentElement.dataset.prefsDelegateBound = "1"; + document.addEventListener("click", (ev) => { + const target = ev.target; + if (!(target instanceof Element)) return; + if (target.closest("#display-prefs-save")) { + ev.preventDefault(); + void saveDisplayPrefs(); + return; + } + if (target.closest("#env-config-save")) { + ev.preventDefault(); + void saveEnvConfig(false); + return; + } + if (target.closest("#env-config-save-restart")) { + ev.preventDefault(); + void saveEnvConfig(true); + return; + } + if (target.closest("#env-config-reload")) { + ev.preventDefault(); + void loadEnvConfig(true); + return; + } + if (target.closest("#pwd-save-btn")) { + ev.preventDefault(); + void savePassword(); + } + }); + } + + function bindClickOnce(id, handler) { + const el = document.getElementById(id); + if (!el || el.dataset.bound === "1") return; + el.dataset.bound = "1"; + el.addEventListener("click", handler); + } + + function bindEvents() { + bindClickOnce("display-prefs-save", saveDisplayPrefs); + bindClickOnce("env-config-save", () => saveEnvConfig(false)); + bindClickOnce("env-config-save-restart", () => saveEnvConfig(true)); + bindClickOnce("env-config-reload", () => loadEnvConfig(true)); + bindClickOnce("pwd-save-btn", savePassword); + } + + function initPage() { + installDelegatedHandlers(); + bindEvents(); + loadDisplayPrefsForm(false); + loadEnvConfig(false); + if (global.__INSTANCE_DISPLAY__) applyDisplayToNav(global.__INSTANCE_DISPLAY__); + } + + global.InstanceSettingsPrefs = { + pageNavAllowed, + applyDisplayToNav, + loadDisplayPrefsForm, + loadEnvConfig, + bindEnvTabs, + bindEvents, + restartInstance, + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initPage); + } else { + initPage(); + } +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/instance_stats.js b/lib/common/static/instance_stats.js new file mode 100644 index 0000000..7b7045a --- /dev/null +++ b/lib/common/static/instance_stats.js @@ -0,0 +1,117 @@ +(function (global) { + "use strict"; + + var PERIODS = ["day", "week", "month"]; + + function statsSegmentSelect() { + return document.getElementById("stats-segment-select"); + } + + function panelFromTrigger(triggerEl) { + if (triggerEl && triggerEl.closest) { + var fromBtn = triggerEl.closest(".stats-segment-panel"); + if (fromBtn) return fromBtn; + } + return null; + } + + function activeSegmentPanel(triggerEl) { + var panel = panelFromTrigger(triggerEl); + if (panel) return panel; + var sel = statsSegmentSelect(); + if (!sel) return null; + var key = sel.value; + return document.querySelector( + '.stats-segment-panel[data-stats-segment="' + key + '"]' + ); + } + + function replaceStatsUrl(params) { + var q = new URLSearchParams(global.location.search); + Object.keys(params).forEach(function (k) { + if (params[k] == null || params[k] === "") q.delete(k); + else q.set(k, params[k]); + }); + var qs = q.toString(); + global.history.replaceState( + null, + "", + qs ? global.location.pathname + "?" + qs : global.location.pathname + ); + } + + function switchStatsPeriod(periodKey, triggerEl) { + var panel = activeSegmentPanel(triggerEl); + if (!panel) return; + var key = PERIODS.indexOf(periodKey) >= 0 ? periodKey : "day"; + panel.querySelectorAll(".stats-period-pane").forEach(function (pane) { + var match = pane.getAttribute("data-stats-period") === key; + if (match) pane.removeAttribute("hidden"); + else pane.setAttribute("hidden", ""); + }); + panel.querySelectorAll(".stats-period-tab").forEach(function (btn) { + var on = btn.getAttribute("data-stats-period") === key; + btn.classList.toggle("active", on); + btn.setAttribute("aria-selected", on ? "true" : "false"); + }); + replaceStatsUrl({ stats_period: key }); + } + + function switchStatsSegment() { + var sel = statsSegmentSelect(); + if (!sel) return; + var key = sel.value; + document.querySelectorAll(".stats-segment-panel").forEach(function (p) { + p.style.display = + p.getAttribute("data-stats-segment") === key ? "block" : "none"; + }); + replaceStatsUrl({ stats_segment: key }); + var period = + new URLSearchParams(global.location.search).get("stats_period") || "day"; + switchStatsPeriod(period); + } + + function ensurePeriodTabDelegation() { + if (global.__instanceStatsTabsDelegated) return; + global.__instanceStatsTabsDelegated = true; + document.addEventListener( + "click", + function (e) { + var btn = + e.target && e.target.closest + ? e.target.closest(".stats-period-tab") + : null; + if (!btn) return; + var card = document.getElementById("stats-card"); + if (!card || !card.contains(btn)) return; + switchStatsPeriod(btn.getAttribute("data-stats-period") || "day", btn); + }, + true + ); + } + + function initStatsFromUrl() { + var sel = statsSegmentSelect(); + if (!sel) return; + ensurePeriodTabDelegation(); + var url = new URLSearchParams(global.location.search); + var segKey = url.get("stats_segment"); + if ( + segKey && + sel.querySelector('option[value="' + segKey.replace(/"/g, "") + '"]') + ) { + sel.value = segKey; + } + switchStatsSegment(); + var period = url.get("stats_period") || "day"; + if (PERIODS.indexOf(period) < 0) period = "day"; + switchStatsPeriod(period); + } + + ensurePeriodTabDelegation(); + + global.switchStatsSegment = switchStatsSegment; + global.switchStatsPeriod = switchStatsPeriod; + global.initStatsFromUrl = initStatsFromUrl; + global.initStatsSegmentFromUrl = initStatsFromUrl; +})(window); diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css new file mode 100644 index 0000000..9d705fd --- /dev/null +++ b/lib/common/static/instance_theme.css @@ -0,0 +1,4952 @@ +/* 实例页手机端:与中控一致,桌面专属区块隐藏;下载仅电脑端 */ +:root, +html[data-theme="dark"] { + --inst-label: #8892b0; + --inst-muted: #9aa3bf; + --inst-text: #e8ecff; + --inst-nav-idle: #8fc8ff; + --inst-nav-active-fg: #dbe4ff; +} + +html[data-theme="light"] { + --inst-label: #4a6078; + --inst-muted: #5a6f85; + --inst-text: #142232; + --inst-nav-idle: #006e9a; + --inst-nav-active-fg: #004d6e; +} + +@media (max-width: 720px) { + .instance-desktop-only { + display: none !important; + } + + a[href^="/export/"] { + display: none !important; + } + + button[onclick*="exportDailyBundleMd"], + button[onclick*="exportWeeklyBundleMd"] { + display: none !important; + } + + body { + padding: 8px 10px !important; + } + + .header h1 { + font-size: 1rem !important; + line-height: 1.35; + } + + .header-row { + flex-wrap: wrap; + gap: 8px; + } + + .container { + max-width: 100% !important; + width: 100% !important; + padding-left: 0 !important; + padding-right: 0 !important; + overflow: visible !important; + } + + .top-nav { + display: flex !important; + flex-wrap: nowrap !important; + justify-content: flex-start !important; + align-items: stretch; + overflow-x: auto !important; + overflow-y: hidden; + width: 100%; + max-width: 100%; + -webkit-overflow-scrolling: touch; + overscroll-behavior-x: contain; + scrollbar-width: none; + gap: 6px !important; + margin-bottom: 12px !important; + padding: 2px 2px 6px; + scroll-padding-inline: 10px; + touch-action: pan-x; + } + + .top-nav::-webkit-scrollbar { + display: none; + } + + .top-nav a { + flex: 0 0 auto; + white-space: nowrap; + padding: 8px 12px; + font-size: 0.78rem; + } + + .list-window-bar { + flex-direction: column; + align-items: stretch; + gap: 8px; + } + + .instance-toolbar-row { + flex-direction: column; + align-items: stretch; + } + + .instance-header-toolbar { + flex-direction: column; + align-items: stretch; + gap: 10px; + } + + .instance-header-toolbar-filter { + flex-wrap: wrap; + } + + .instance-header-toolbar-end { + width: 100%; + justify-content: flex-end; + } + + .instance-header-theme { + align-self: auto; + } + + .instance-header-stats { + flex-wrap: nowrap; + } + + .stat-strip-inner { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .grid { + gap: 10px; + } + + .card { + padding: 12px; + } + + .form-grid:not(.journal-form-row1):not(.journal-form-row2) { + grid-template-columns: minmax(0, 1fr) !important; + } + + .pos-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)) !important; + } + + .stat-box { + grid-template-columns: repeat(2, minmax(0, 1fr)) !important; + } + + .dual-panel-grid { + grid-template-columns: minmax(0, 1fr) !important; + } + + .grid { + grid-template-columns: minmax(0, 1fr) !important; + } + + .records-card .table-wrap { + display: none !important; + } + + .mobile-record-list { + display: flex !important; + flex-direction: column; + gap: 6px; + } + + .mobile-record-row-wrap { + display: flex; + align-items: stretch; + gap: 6px; + } + + .mobile-record-row { + flex: 1; + display: grid; + grid-template-columns: minmax(0, 1.2fr) auto minmax(0, 0.9fr); + align-items: center; + gap: 8px; + width: 100%; + margin: 0; + padding: 10px 12px; + border: 1px solid rgba(120, 140, 200, 0.28); + border-radius: 8px; + background: rgba(18, 24, 42, 0.65); + color: #e8ecff; + font-size: 0.82rem; + text-align: left; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + } + + .mobile-record-row:active { + background: rgba(30, 42, 72, 0.85); + } + + .mrr-symbol { + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .mrr-dir { + justify-self: center; + } + + .mrr-dir .badge { + font-size: 0.72rem; + padding: 2px 8px; + } + + .mrr-pnl { + justify-self: end; + font-weight: 600; + white-space: nowrap; + } + + .mrr-muted { + color: #8892b0; + font-size: 0.78rem; + } + + .mobile-record-del { + flex: 0 0 36px; + width: 36px; + border: 1px solid rgba(200, 80, 80, 0.35); + border-radius: 8px; + background: rgba(80, 24, 24, 0.35); + color: #ff9a9a; + font-size: 1.1rem; + line-height: 1; + cursor: pointer; + } + + #journal-list .entry { + display: none; + } + + #journal-list .journal-empty-msg { + color: #8892b0; + font-size: 0.82rem; + padding: 8px 4px; + } + + #detailActions.detail-actions, + .detail-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 10px 14px 14px; + border-top: 1px solid rgba(120, 140, 200, 0.2); + } + + .detail-actions-inner { + display: flex; + flex-wrap: wrap; + gap: 8px; + width: 100%; + } + + .detail-actions .table-del, + .detail-actions button { + font-size: 0.78rem !important; + padding: 6px 10px !important; + } + + .detail-modal .panel-body.trade-record-detail-wrap { + white-space: normal; + } + + .trd-row { + grid-template-columns: 76px minmax(0, 1fr); + } +} + +@media (min-width: 721px) { + .mobile-record-list { + display: none !important; + } +} + +.detail-modal .panel-body.trade-record-detail-wrap { + white-space: normal; +} + +.trade-record-detail { + display: flex; + flex-direction: column; + gap: 8px; +} + +.trd-row { + display: grid; + grid-template-columns: 92px minmax(0, 1fr); + gap: 8px 12px; + align-items: center; + line-height: 1.45; +} + +.trd-label { + color: #8892b0; + font-size: 0.82rem; +} + +.trd-value { + color: #e5e9ff; + font-size: 0.86rem; + text-align: left; + min-width: 0; +} + +.trd-value .badge { + display: inline-block; + vertical-align: middle; +} + +/* 手机竖屏(含大屏手机) */ +@media (max-width: 900px) and (orientation: portrait) { + .grid { + grid-template-columns: minmax(0, 1fr) !important; + } + + .dual-panel-grid { + grid-template-columns: minmax(0, 1fr) !important; + } + + .form-grid:not(.journal-form-row1):not(.journal-form-row2) { + grid-template-columns: minmax(0, 1fr) !important; + } +} + +/* 平板横屏:双列布局,充分利用宽屏 */ +@media (min-width: 721px) and (max-width: 1200px) and (orientation: landscape) { + body { + padding: 10px 14px !important; + } + + .grid { + grid-template-columns: repeat(2, minmax(0, 1fr)) !important; + gap: 12px; + } + + .dual-panel-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)) !important; + } + + .form-grid:not(.journal-form-row1):not(.journal-form-row2) { + grid-template-columns: repeat(3, minmax(0, 1fr)) !important; + } + + .pos-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)) !important; + } + + .stat-box { + grid-template-columns: repeat(4, minmax(0, 1fr)) !important; + } + + .records-card, + .review-card { + grid-column: 1 / -1; + } +} + +html[data-theme="light"] { + background: #c8d4de; + color-scheme: light; +} + +html[data-theme="light"] body { + background: #c8d4de !important; + color: #142232 !important; +} + +html[data-theme="light"] .header h1 { + color: #142232 !important; +} + +html[data-theme="light"] .exchange-tag { + color: #087a50 !important; + background: rgba(10, 143, 92, 0.12) !important; + border-color: rgba(10, 143, 92, 0.35) !important; +} + +html[data-theme="light"] .top-nav a { + background: #fff !important; + color: var(--inst-nav-idle) !important; + border-color: rgba(0, 95, 140, 0.22) !important; +} + +html[data-theme="light"] .top-nav a:hover, +html[data-theme="light"] .embed-top-nav a:hover, +html[data-theme="light"] .strategy-subnav a:hover { + background: rgba(0, 110, 154, 0.1) !important; + color: var(--inst-nav-active-fg) !important; +} + +html[data-theme="light"] .top-nav a.active, +html[data-theme="light"] .embed-top-nav a.active { + background: rgba(0, 110, 154, 0.12) !important; + color: var(--inst-nav-active-fg) !important; + border: 1px solid rgba(0, 95, 140, 0.28) !important; + font-weight: 600; +} + +html[data-theme="light"] .stat-item, +html[data-theme="light"] .card, +html[data-theme="light"] .meta-item, +html[data-theme="light"] .list-item, +html[data-theme="light"] .journal-card { + background: #fff !important; + border-color: #9eb0c4 !important; + box-shadow: 0 1px 3px rgba(20, 34, 50, 0.06); +} + +html[data-theme="light"] .stat-item .label, +html[data-theme="light"] .status, +html[data-theme="light"] .rule-tip, +html[data-theme="light"] .muted { + color: #3a5068 !important; +} + +html[data-theme="light"] .stat-item .value, +html[data-theme="light"] .card h2 { + color: #142232 !important; +} + +html[data-theme="light"] input:not([type="checkbox"]):not([type="radio"]), +html[data-theme="light"] select, +html[data-theme="light"] textarea { + background: #fff !important; + color: #142232 !important; + border-color: #9eb0c4 !important; +} + +html[data-theme="light"] input[type="checkbox"], +html[data-theme="light"] input[type="radio"] { + accent-color: #007aa8; + background: transparent !important; + border: none !important; + width: 1rem; + height: 1rem; + cursor: pointer; +} + +html[data-theme="light"] .mood-grid { + color: #1a2838 !important; +} + +html[data-theme="light"] .mood-grid label { + color: #1a2838 !important; +} + +/* 复盘区次要按钮(内联 #1f3a5a):浅底深字,避免白字看不见 */ +html[data-theme="light"] .journal-card .form-row button[type="button"], +html[data-theme="light"] .review-card .form-row button[type="button"][onclick*="export"], +html[data-theme="light"] .review-card-fs-btn, +html[data-theme="light"] .ai-result-toolbar .btn-fs { + background: #e8eef5 !important; + background-image: none !important; + color: #006e9a !important; + border: 1px solid rgba(0, 95, 140, 0.28) !important; +} + +html[data-theme="light"] .journal-card button[type="submit"], +html[data-theme="light"] .review-card .form-row button[onclick="genDaily()"], +html[data-theme="light"] .review-card .form-row button[onclick="genWeekly()"] { + background: linear-gradient(90deg, #007aa8, #5b4fc7) !important; + color: #fff !important; + border: none !important; +} + +html[data-theme="light"] .flash { + background: rgba(0, 110, 154, 0.1) !important; + color: #006e9a !important; + border-color: rgba(0, 95, 140, 0.22) !important; +} + +html[data-theme="light"] th { + color: #334155 !important; + font-weight: 600 !important; +} + +html[data-theme="light"] td { + color: #142232 !important; + border-bottom-color: #d0dae4 !important; +} + +html[data-theme="light"] .ai-result, +html[data-theme="light"] .login-box { + background: #fff !important; + border-color: #b8c8d8 !important; + color: #142232 !important; +} + +html[data-theme="light"] #chart-wrap { + background: #f0f4f9 !important; + border-color: #b8c8d8 !important; +} + +html[data-theme="light"] .btn { + background: #fff !important; + color: #006e9a !important; + border-color: rgba(0, 95, 140, 0.22) !important; +} + +html[data-theme="light"] .btn:hover { + background: #eef3f8 !important; +} + +.theme-toggle { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 3px; + border-radius: 8px; + border: 1px solid #304164; + background: #151a2a; +} + +html[data-theme="light"] .theme-toggle { + background: #fff; + border-color: #b8c8d8; +} + +.theme-toggle.is-hub-linked { + display: none !important; +} + +.theme-toggle-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 30px; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + color: #8fc8ff; + cursor: pointer; +} + +html[data-theme="light"] .theme-toggle-btn { + color: #334155; +} + +.theme-toggle-btn.is-active { + color: #dbe4ff; + background: rgba(79, 121, 255, 0.2); + box-shadow: inset 0 0 0 1px #304164; +} + +html[data-theme="light"] .theme-toggle-btn.is-active { + color: #004d6e; + background: rgba(0, 110, 154, 0.16); + box-shadow: inset 0 0 0 1px #9eb0c4; +} + +.header-row { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 10px; + margin-top: 6px; +} + +/* ── 统一顶栏面板:状态/筛选 + 统计条 ── */ +.instance-header-panel { + margin-top: 18px; + margin-bottom: 12px; + padding: 10px 14px; +} + +.instance-header-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px 14px; +} + +.instance-toolbar-status { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + flex: 0 1 auto; +} + +.instance-header-toolbar-filter { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 10px; + flex: 1 1 200px; + min-width: 0; + font-size: 0.82rem; +} + +.instance-header-toolbar-end { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 10px; + flex: 0 1 auto; + margin-left: auto; +} + +.instance-header-toolbar-filter label { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--inst-label); + margin: 0; +} + +.instance-header-theme { + flex: 0 0 auto; +} + +.list-window-label { + color: var(--inst-text); + font-size: 0.82rem; + white-space: nowrap; +} + +.list-window-hint { + color: var(--inst-muted); + font-size: 0.72rem; + white-space: nowrap; +} + +.list-window-apply { + padding: 5px 12px; + font-size: 0.82rem; +} + +.instance-header-stats-wrap { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border-soft, #2a3150); +} + +.instance-header-stats { + display: flex; + flex-wrap: nowrap; + overflow-x: auto; + gap: 0; + padding: 2px 0 0; + min-height: 56px; + align-items: stretch; + scrollbar-width: thin; +} + +.instance-header-stats--options .stat-strip-item { + min-width: 72px; +} + +.stat-strip-item { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1 1 0; + min-width: 64px; + padding: 4px 8px; + border-right: 1px solid var(--border-soft, #2a3150); + text-align: center; +} + +.stat-strip-item:first-child { + padding-left: 4px; +} + +.stat-strip-item:last-child { + border-right: none; + padding-right: 4px; +} + +.stat-strip-item .label { + font-size: 0.72rem; + color: var(--inst-label); + margin-bottom: 6px; + white-space: nowrap; +} + +.stat-strip-item .value { + font-size: 0.88rem; + font-weight: 600; + color: var(--inst-text); + line-height: 1.3; + white-space: nowrap; +} + +.stat-strip-item--primary .label { + font-size: 0.76rem; +} + +.stat-strip-item--primary .value { + font-size: 1.02rem; + font-weight: 700; +} + +.stat-strip-item--pnl .value.pnl-pos { + color: #3dd68c; +} + +.stat-strip-item--pnl .value.pnl-neg { + color: #ff6b7a; +} + +@media (max-width: 1100px) { + .instance-header-stats { + flex-wrap: nowrap; + } + + .stat-strip-item { + flex: 0 0 auto; + min-width: 72px; + border-right: none; + padding: 6px 8px; + border-right: 1px solid var(--border-soft, #2a3150); + } + + .stat-strip-item:first-child { + padding-left: 6px; + } +} + +/* 旧片段兼容(若仍被引用) */ +.instance-toolbar-row { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px 14px; + margin-bottom: 12px; +} + +.instance-toolbar-filter.list-window-bar { + flex: 1 1 320px; + margin-bottom: 0; +} + +.stat-strip { + margin-bottom: 12px; + padding: 10px 14px; +} + +.stat-strip-inner { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 0; + align-items: stretch; +} + +html[data-theme="light"] .stat-strip-item .value { + color: var(--inst-text); +} + +html[data-theme="light"] .stat-strip-item .label, +html[data-theme="light"] .instance-header-toolbar-filter label { + color: var(--inst-label) !important; +} + +html[data-theme="light"] .list-window-hint { + color: var(--inst-muted) !important; +} + +html[data-theme="light"] .instance-header-stats { + border-top-color: #c8d4e0; +} + +html[data-theme="light"] .stat-strip-item { + border-right-color: #d8e2ec; +} + +html[data-theme="light"] .list-window-label { + color: #1a2838; +} + +.login-theme-bar { + display: flex; + justify-content: flex-end; + width: 100%; + max-width: 400px; + margin: 0 0 10px; + flex-shrink: 0; +} + +/* ── 交易执行 / 复盘 / 统计(index 内联样式覆盖)── */ +html[data-theme="light"] .list-window-bar, +html[data-theme="light"] .export-bar a { + background: #fff !important; + border-color: #b8c8d8 !important; + color: #1a2838 !important; +} + +html[data-theme="light"] .list-window-bar label, +html[data-theme="light"] .export-bar { + color: #4a6078 !important; +} + +html[data-theme="light"] .stats-segment-block { + border-top-color: #c8d4e0 !important; +} + +html[data-theme="light"] .stats-segment-block h2, +html[data-theme="light"] .stats-period-block h3, +html[data-theme="light"] .key-history h3 { + color: #142232 !important; +} + +html[data-theme="light"] .stats-period-block .sub, +html[data-theme="light"] .key-history .sub, +html[data-theme="light"] .pos-section-title, +html[data-theme="light"] .pos-empty { + color: #4a6078 !important; +} + +html[data-theme="light"] .stats-period-block { + border-bottom-color: #d0dae4 !important; +} + +html[data-theme="light"] .stats-period-tab { + background: #f4f7fb !important; + color: #4a6078 !important; + border-color: #c8d4e0 !important; +} +html[data-theme="light"] .stats-period-tab:hover { + background: #e8eef5 !important; + color: #142232 !important; +} +html[data-theme="light"] .stats-period-tab.active { + background: #dce8f5 !important; + color: #0d4a7a !important; + border-color: #7eb0d8 !important; +} +html[data-theme="light"] .stats-period-range, +html[data-theme="light"] .inst-stats-kpi-lbl, +html[data-theme="light"] .inst-stats-block-title, +html[data-theme="light"] .inst-stats-risk-item .k, +html[data-theme="light"] .inst-stats-empty { + color: #4a6078 !important; +} +html[data-theme="light"] .inst-stats-kpi, +html[data-theme="light"] .inst-stats-block { + background: #f8fafc !important; + border-color: #c8d4e0 !important; +} +html[data-theme="light"] .inst-stats-ring::before { + background: #f8fafc !important; +} +html[data-theme="light"] .inst-stats-stacked-bar { + background: #e2e8f0 !important; +} +html[data-theme="light"] .inst-stats-risk-item .v, +html[data-theme="light"] .inst-stats-details > summary { + color: #142232 !important; +} +html[data-theme="light"] .inst-stats-details[open] > summary { + color: #0d4a7a !important; +} + +html[data-theme="light"] .key-history { + border-top-color: #d0dae4 !important; +} + +html[data-theme="light"] .pos-card, +html[data-theme="light"] .pos-empty { + background: #fff !important; + border-color: #b8c8d8 !important; +} + +html[data-theme="light"] .pos-card-symbol strong, +html[data-theme="light"] .pos-value, +html[data-theme="light"] .pos-value.price-flat { + color: #142232 !important; +} + +html[data-theme="light"] .pos-label, +html[data-theme="light"] .pos-meta, +html[data-theme="light"] .pos-footer, +html[data-theme="light"] .pos-ex-orders-title, +html[data-theme="light"] .pos-ex-order-row { + color: #1e293b !important; +} + +html[data-theme="light"] .pos-meta-item::after { + color: #94a3b8 !important; +} + +.pos-time-close-meta { + color: #8fc8ff; +} +.pos-time-close-meta .pos-time-close-cd { + font-variant-numeric: tabular-nums; + letter-spacing: 0.02em; +} +.pos-symbol-time-close { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.72rem; + font-weight: 500; + color: #8fc8ff; + padding: 1px 6px; + border-radius: 4px; + background: rgba(143, 200, 255, 0.1); + white-space: nowrap; +} +.pos-symbol-time-close .pos-time-close-cd { + font-variant-numeric: tabular-nums; + letter-spacing: 0.03em; +} +.force-close-badge { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.78rem; + font-weight: 600; + color: #ffc870; + background: #2a2218; + border: 1px solid #6a5020; + padding: 4px 12px; + border-radius: 999px; + letter-spacing: 0.02em; + white-space: nowrap; +} +.force-close-badge .force-close-header-cd { + font-variant-numeric: tabular-nums; + letter-spacing: 0.03em; +} +.pos-force-close-meta { + color: #ffc870; +} +.pos-symbol-force-close { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.72rem; + font-weight: 500; + color: #ffc870; + padding: 1px 6px; + border-radius: 4px; + background: rgba(255, 200, 112, 0.12); + white-space: nowrap; +} +.pos-symbol-force-close .pos-force-close-cd { + font-variant-numeric: tabular-nums; + letter-spacing: 0.03em; +} +html[data-theme="light"] .force-close-badge { + color: #9a6200; + background: #fff6e8; + border-color: #d4a84a; +} +html[data-theme="light"] .pos-symbol-force-close, +html[data-theme="light"] .pos-force-close-meta { + color: #9a6200; + background: rgba(212, 168, 74, 0.14); +} +.key-time-close-wrap.is-disabled > label, +.order-time-close-wrap.is-disabled > label { + opacity: 0.72; +} +.key-time-close-wrap select, +.order-time-close-wrap select { + cursor: pointer; +} +html[data-theme="light"] .pos-meta-on { + color: #006e9a !important; +} + +html[data-theme="light"] .pos-side-long { + background: #006e9a !important; + color: #fff !important; + border: 1px solid #005a82 !important; +} + +html[data-theme="light"] .pos-side-short { + background: #b03030 !important; + color: #fff !important; + border: 1px solid #8a2424 !important; +} + +html[data-theme="light"] .pos-entrust-btn, +html[data-theme="light"] .stats-card .stats-toggle, +html[data-theme="light"] .btn-del[style*="1f3a5a"], +html[data-theme="light"] a.btn-del[style*="1f3a5a"], +html[data-theme="light"] .detail-modal .panel-fs, +html[data-theme="light"] .review-card-fs-btn { + background: #e8eef5 !important; + color: #006e9a !important; +} + +html[data-theme="light"] .pos-ex-orders { + border-top-color: #d0dae4 !important; +} + +html[data-theme="light"] .pos-ex-cancel-btn { + background: #eef3f8 !important; + color: #5b4fc7 !important; +} + +html[data-theme="light"] .tpsl-modal { + background: #fff !important; + border-color: #b8c8d8 !important; +} + +html[data-theme="light"] .tpsl-modal h3 { + color: #142232 !important; +} + +html[data-theme="light"] .tpsl-modal-cancel { + background: #eef3f8 !important; + color: #4a6078 !important; +} + +html[data-theme="light"] .list-item { + background: #f6f9fc !important; + border-color: #d0dae4 !important; +} + +html[data-theme="light"] .price-flat { + color: #4a6078 !important; +} + +html[data-theme="light"] .detail-modal .panel, +html[data-theme="light"] .ai-result { + background: #fff !important; +} + +html[data-theme="light"] .detail-modal .panel-title { + color: #142232 !important; +} + +/* 交易复盘详情:上方元数据(非 Markdown 区)浅色主题对比度 */ +html[data-theme="light"] .detail-modal .panel-body:not(.md-review) { + color: #1a2838 !important; +} + +html[data-theme="light"] .detail-modal .panel { + border-color: #b8c8d8 !important; +} + +html[data-theme="light"] .detail-modal .panel-image { + border-color: #b8c8d8 !important; +} + +html[data-theme="light"] .detail-modal .panel-close { + background: #f6f9fc !important; + color: #4a6078 !important; + border: 1px solid #b8c8d8 !important; +} + +/* ── 交易记录:方向 / 结果徽章(浅底描边,避免黑底块)── */ +html[data-theme="light"] .badge.direction-long, +html[data-theme="light"] .direction-long { + background: rgba(8, 122, 80, 0.1) !important; + color: #087a50 !important; + border: 1px solid rgba(8, 122, 80, 0.28) !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .badge.direction-short, +html[data-theme="light"] .direction-short { + background: rgba(192, 48, 48, 0.08) !important; + color: #b03030 !important; + border: 1px solid rgba(192, 48, 48, 0.25) !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .badge.profit { + background: rgba(8, 122, 80, 0.1) !important; + color: #087a50 !important; + border: 1px solid rgba(8, 122, 80, 0.28) !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .badge.loss { + background: rgba(192, 48, 48, 0.08) !important; + color: #b03030 !important; + border: 1px solid rgba(192, 48, 48, 0.25) !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .badge.miss { + background: rgba(180, 130, 20, 0.1) !important; + color: #8a6200 !important; + border: 1px solid rgba(180, 130, 20, 0.28) !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .badge.direction { + background: rgba(0, 110, 154, 0.08) !important; + color: #006e9a !important; + border: 1px solid rgba(0, 110, 154, 0.22) !important; +} + +html[data-theme="light"] .table-del, +html[data-theme="light"] button.table-del { + background: #fff5f5 !important; + color: #b03030 !important; + border: 1px solid rgba(176, 48, 48, 0.28) !important; +} + +html[data-theme="light"] .pos-breakeven-badge { + background: rgba(8, 122, 80, 0.1) !important; + color: #087a50 !important; + border: 1px solid rgba(8, 122, 80, 0.25) !important; +} + +/* ── 实时持仓 / 行情:浮盈亏涨跌色 ── */ +html[data-theme="light"] .price-up, +html[data-theme="light"] .pos-value.price-up { + color: #087a50 !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .price-down, +html[data-theme="light"] .pos-value.price-down { + color: #c03030 !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .journal-detail-meta { + color: #1a2838 !important; + line-height: 1.65 !important; +} + +html[data-theme="light"] .journal-card .form-grid label, +html[data-theme="light"] .journal-card .sub { + color: #4a6078 !important; +} + +html[data-theme="light"] .btn-del:not([style*="1f3a5a"]) { + background: #fff5f5 !important; + color: #b03030 !important; + border: 1px solid rgba(176, 48, 48, 0.25) !important; +} + +html[data-theme="light"] table th { + background: #eef3f8 !important; +} + +html[data-theme="light"] .strategy-subnav { + border-bottom-color: #d0dae4 !important; +} + +html[data-theme="light"] .strategy-subnav a { + background: #fff !important; + color: var(--inst-nav-idle) !important; + border-color: rgba(0, 95, 140, 0.22) !important; +} + +html[data-theme="light"] .strategy-subnav a.active { + background: rgba(0, 110, 154, 0.12) !important; + color: var(--inst-nav-active-fg) !important; + border: 1px solid rgba(0, 95, 140, 0.28) !important; + font-weight: 600; +} + +/* ── 策略交易 / 策略记录(strategy_templates 内联)── */ +html[data-theme="dark"] .strategy-records-page .sr-summary, +html[data-theme="dark"] .strategy-records-page .sr-detail { + color: #cfd3ef !important; +} +html[data-theme="dark"] .strategy-records-page .sr-summary .sr-sym, +html[data-theme="dark"] .strategy-records-page .sr-detail-grid .val { + color: #f0f2ff !important; +} +html[data-theme="dark"] .strategy-records-page .sr-summary .sr-dca-tag { + color: #8892b0 !important; +} +html[data-theme="dark"] .strategy-records-page .sr-summary .sr-pnl.pos, +html[data-theme="dark"] .strategy-records-page .sr-pnl.pos { + color: #4cd97f !important; +} +html[data-theme="dark"] .strategy-records-page .sr-summary .sr-pnl.neg, +html[data-theme="dark"] .strategy-records-page .sr-pnl.neg { + color: #ff6666 !important; +} + +html[data-theme="light"] .strategy-records-page h2, +html[data-theme="light"] .plan-card-title, +html[data-theme="light"] .sr-panel-title, +html[data-theme="light"] .sr-summary .sr-sym, +html[data-theme="light"] .sr-detail-grid .val, +html[data-theme="light"] .plan-cell .val:not(.pnl-profit):not(.pnl-loss) { + color: #142232 !important; +} + +html[data-theme="light"] .plan-cell .val.pnl-profit, +html[data-theme="light"] .pnl-profit { + color: #087a50 !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .plan-cell .val.pnl-loss, +html[data-theme="light"] .pnl-loss { + color: #c03030 !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .plan-dca-table td.st-done, +html[data-theme="light"] .plan-dca-table .st-done, +html[data-theme="light"] .sr-dca-table .st-done { + color: #087a50 !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .plan-dca-table .st-pending, +html[data-theme="light"] .sr-dca-table .st-pending { + color: #6a7588 !important; +} + +html[data-theme="light"] .strategy-records-tip, +html[data-theme="light"] .plan-card-meta, +html[data-theme="light"] .plan-cell .lbl, +html[data-theme="light"] .sr-panel-count, +html[data-theme="light"] .sr-empty, +html[data-theme="light"] .plan-dca-title { + color: #4a6078 !important; +} + +html[data-theme="light"] .plan-position-card, +html[data-theme="light"] .sr-filters, +html[data-theme="light"] .sr-panel { + background: #fff !important; + border-color: #b8c8d8 !important; +} + +html[data-theme="light"] .sr-filters select, +html[data-theme="light"] .sr-filters input[type="datetime-local"] { + background: #f6f9fc !important; + color: #142232 !important; + border-color: #b8c8d8 !important; +} + +html[data-theme="light"] .sr-chip { + background: #fff !important; + color: #4a6078 !important; + border-color: #b8c8d8 !important; +} + +html[data-theme="light"] .sr-chip.active { + background: rgba(0, 110, 154, 0.12) !important; + color: #006e9a !important; + border-color: rgba(0, 95, 140, 0.35) !important; +} + +html[data-theme="light"] .sr-item { + background: #f6f9fc !important; + border-color: #d0dae4 !important; +} + +html[data-theme="light"] .sr-summary, +html[data-theme="light"] .sr-detail, +html[data-theme="light"] .plan-cell .val.pnl-neutral { + color: #1a2838 !important; +} + +html[data-theme="light"] .sr-summary:hover { + background: rgba(0, 110, 154, 0.06) !important; +} + +html[data-theme="light"] .sr-detail { + border-top-color: #d0dae4 !important; +} + +html[data-theme="light"] .plan-dca-block { + border-top-color: #d0dae4 !important; +} + +html[data-theme="light"] .plan-dca-table th, +html[data-theme="light"] .plan-dca-table td, +html[data-theme="light"] .sr-dca-table th, +html[data-theme="light"] .sr-dca-table td { + border-bottom-color: #d0dae4 !important; +} + +html[data-theme="light"] .plan-dca-table th, +html[data-theme="light"] .sr-dca-table th { + color: #4a6078 !important; +} + +html[data-theme="light"] .trend-running-plans { + border-top-color: #d0dae4 !important; +} + +html[data-theme="light"] .plan-card-meta .accent, +html[data-theme="light"] .sr-panel-title.trend, +html[data-theme="light"] .sr-summary::before { + color: #006e9a !important; +} + +html[data-theme="light"] .sr-panel-title.roll { + color: #a06010 !important; +} + +html[data-theme="light"] .btn-close-plan { + background: #fff5f5 !important; + color: #b03030 !important; +} + +html[data-theme="light"] .running-plans-stack .plan-position-card[style*="8892b0"] { + color: #4a6078 !important; + background: #f6f9fc !important; +} + +html[data-theme="light"] button[style*="1f4a3a"] { + background: #e8f5ef !important; + color: #087a50 !important; +} + +html[data-theme="light"] .strategy-trading-grid .card, +html[data-theme="light"] .dual-panel-grid .card { + background: #fff !important; +} + +/* ── AI 复盘(panel-list / ai-result)── */ +html[data-theme="light"] .panel-item { + background: #fff !important; + border-color: #b8c8d8 !important; + color: #1a2838 !important; +} + +html[data-theme="light"] .panel-item strong { + color: #142232 !important; +} + +html[data-theme="light"] .panel-item .entry { + border-bottom-color: #d0dae4 !important; + color: #1a2838 !important; +} + +html[data-theme="light"] .panel-item .entry div { + color: #4a6078 !important; +} + +html[data-theme="light"] .ai-result { + background: #f6f9fc !important; + border-color: #b8c8d8 !important; + color: #1a2838 !important; +} + +.ai-result.is-loading { + color: #8fc8ff; + font-style: italic; + animation: ai-review-pulse 1.2s ease-in-out infinite; +} + +html[data-theme="light"] .ai-result.is-loading { + color: #006e9a !important; +} + +@keyframes ai-review-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.55; } +} + +/* AI 日复盘 / 周复盘 Markdown(弹窗 + 内联结果区,三所共用) */ +html[data-theme="light"] .ai-result-md, +html[data-theme="light"] .detail-modal .panel-body.md-review { + color: #1a2838 !important; +} + +html[data-theme="light"] .ai-result-md p, +html[data-theme="light"] .detail-modal .panel-body.md-review p, +html[data-theme="light"] .ai-result-md li, +html[data-theme="light"] .detail-modal .panel-body.md-review li, +html[data-theme="light"] .ai-result-md ol, +html[data-theme="light"] .ai-result-md ul, +html[data-theme="light"] .detail-modal .panel-body.md-review ol, +html[data-theme="light"] .detail-modal .panel-body.md-review ul { + color: #1a2838 !important; +} + +html[data-theme="light"] .ai-result-md strong, +html[data-theme="light"] .detail-modal .panel-body.md-review strong { + color: #142232 !important; +} + +html[data-theme="light"] .ai-result-md h2, +html[data-theme="light"] .detail-modal .panel-body.md-review h2, +html[data-theme="light"] .ai-result-md h3, +html[data-theme="light"] .detail-modal .panel-body.md-review h3, +html[data-theme="light"] .ai-result-md h4, +html[data-theme="light"] .detail-modal .panel-body.md-review h4 { + color: #142232 !important; +} + +html[data-theme="light"] .ai-result-md h2, +html[data-theme="light"] .detail-modal .panel-body.md-review h2 { + border-bottom-color: #d0dae4 !important; +} + +html[data-theme="light"] .ai-result-md h3, +html[data-theme="light"] .detail-modal .panel-body.md-review h3 { + color: #006e9a !important; +} + +html[data-theme="light"] .ai-result-md code, +html[data-theme="light"] .detail-modal .panel-body.md-review code { + background: #eef3f8 !important; + color: #142232 !important; +} + +html[data-theme="light"] .ai-result-md .md-raw-block-title, +html[data-theme="light"] .detail-modal .panel-body.md-review .md-raw-block-title { + color: #4a6078 !important; + border-top-color: #d0dae4 !important; +} + +/* ── 统计分栏(机器人 / 趋势回调)── */ +html[data-theme="light"] .stats-split-col { + background: #fff !important; + border-color: #b8c8d8 !important; +} + +html[data-theme="light"] .stats-split-head { + color: #142232 !important; + border-bottom-color: #d0dae4 !important; +} + +html[data-theme="light"] .stats-split-col .stat-item { + background: #f6f9fc !important; + border-color: #d0dae4 !important; +} + +html[data-theme="light"] .stats-split-col .stat-item .label { + color: #4a6078 !important; +} + +html[data-theme="light"] .stats-split-col .stat-item .value { + color: #142232 !important; +} + +/* ── 可折叠说明(规则 / 划转 / 价格)── */ +.tip-collapse { + margin-bottom: 8px; + border: 1px solid #2a3348; + border-radius: 8px; + background: rgba(20, 25, 35, 0.45); + overflow: hidden; +} + +.tip-collapse-summary { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px 8px; + padding: 8px 12px; + cursor: pointer; + list-style: none; + font-size: 0.8rem; + color: #95a2c2; + line-height: 1.45; +} + +.tip-collapse-summary::-webkit-details-marker { + display: none; +} + +.tip-collapse-summary::before { + content: "▸"; + flex: 0 0 auto; + color: #6d7a99; + transition: transform 0.15s ease; +} + +.tip-collapse[open] > .tip-collapse-summary::before { + transform: rotate(90deg); +} + +.tip-collapse-hint { + color: #6d7a99; + font-size: 0.74rem; +} + +.tip-collapse-body { + padding: 0 12px 10px; + border-top: 1px solid #232b3d; +} + +.tip-collapse-body.rule-tip { + margin-bottom: 0; + padding-top: 8px; +} + +html[data-theme="light"] .tip-collapse { + background: #f6f9fc !important; + border-color: #b8c8d8 !important; +} + +html[data-theme="light"] .tip-collapse-summary { + color: #4a6078 !important; +} + +html[data-theme="light"] .tip-collapse-summary::before { + color: #6a7588 !important; +} + +html[data-theme="light"] .tip-collapse-hint { + color: #6a7588 !important; +} + +html[data-theme="light"] .tip-collapse-body { + border-top-color: #d0dae4 !important; +} + +html[data-theme="light"] .tip-collapse-body.rule-tip { + color: #4a6078 !important; +} + +html[data-theme="light"] .key-rule-table th, +html[data-theme="light"] .key-rule-table td { + border-color: #d0dae4 !important; +} + +html[data-theme="light"] .key-rule-table th { + background: #eef3f8 !important; + color: #4a6078 !important; +} + +html[data-theme="light"] .key-rule-table td { + color: #142232 !important; +} + +html[data-theme="light"] .key-rule-table .key-rule-type { + color: #142232 !important; +} + +html[data-theme="light"] .key-rule-table .key-rule-sub { + color: #006e9a !important; +} + +html[data-theme="light"] .key-rule-foot { + color: #6a7588 !important; +} + +html[data-theme="light"] .key-rule-foot code { + color: #006e9a !important; +} + +/* ── 关键位折叠行(亮色)── */ +html[data-theme="light"] .key-row-collapse { + background: #f6f9fc !important; + border-color: #b8c8d8 !important; +} + +html[data-theme="light"] .key-row-collapse-summary { + color: #1a2838 !important; +} + +html[data-theme="light"] .key-row-collapse-summary::before { + color: #6a7588 !important; +} + +html[data-theme="light"] .key-row-summary-title strong { + color: #142232 !important; +} + +html[data-theme="light"] .key-row-summary-line, +html[data-theme="light"] .key-history-brief { + color: #4a6078 !important; +} + +html[data-theme="light"] .key-row-summary-live { + color: #006e9a !important; +} + +html[data-theme="light"] .key-row-summary-live.key-row-summary-pending { + color: #087a50 !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .key-row-collapse-body { + border-top-color: #d0dae4 !important; +} + +html[data-theme="light"] .key-history-alert { + color: #4a6078 !important; +} + +html[data-theme="light"] .key-row-collapse .pos-side-badge[style*="2a3152"] { + background: rgba(0, 110, 154, 0.1) !important; + color: #006e9a !important; +} + +html[data-theme="light"] .key-row-collapse.key-history-success { + background: rgba(8, 122, 80, 0.08) !important; + border-color: rgba(8, 122, 80, 0.35) !important; +} + +html[data-theme="light"] .key-row-collapse.key-history-success .key-row-collapse-summary, +html[data-theme="light"] .key-row-collapse.key-history-success .key-row-summary-title strong { + color: #142232 !important; +} + +html[data-theme="light"] .key-row-collapse.key-history-success .key-history-brief, +html[data-theme="light"] .key-row-collapse.key-history-success .key-history-outcome-badge { + color: #087a50 !important; + background: rgba(8, 122, 80, 0.1) !important; + border-color: rgba(8, 122, 80, 0.28) !important; +} + +html[data-theme="light"] .key-row-collapse.key-history-manual { + background: #f0f2f6 !important; + border-color: #b8c0cc !important; +} + +html[data-theme="light"] .key-row-collapse.key-history-manual .key-history-brief, +html[data-theme="light"] .key-row-collapse.key-history-manual .key-history-outcome-badge { + color: #5a6478 !important; + background: rgba(90, 100, 120, 0.1) !important; + border-color: rgba(90, 100, 120, 0.22) !important; +} + +html[data-theme="light"] .key-row-collapse.key-history-failed { + background: rgba(192, 48, 48, 0.06) !important; + border-color: rgba(192, 48, 48, 0.28) !important; +} + +html[data-theme="light"] .key-row-collapse.key-history-failed .key-row-collapse-summary { + color: #1a2838 !important; +} + +html[data-theme="light"] .key-row-collapse.key-history-failed .key-history-brief, +html[data-theme="light"] .key-row-collapse.key-history-failed .key-history-outcome-badge { + color: #b04040 !important; + background: rgba(192, 48, 48, 0.08) !important; + border-color: rgba(192, 48, 48, 0.22) !important; +} + +html[data-theme="light"] .trd-label { + color: #6a7588 !important; +} + +html[data-theme="light"] .trd-value { + color: #142232 !important; +} + +html[data-theme="light"] .mobile-record-row { + background: #fff !important; + border-color: #b8c8d8 !important; + color: #142232 !important; +} + +html[data-theme="light"] .mobile-record-row:active { + background: #eef3f8 !important; +} + +html[data-theme="light"] .mrr-muted { + color: #6a7588 !important; +} + +html[data-theme="light"] .mobile-record-del { + background: rgba(192, 48, 48, 0.08) !important; + border-color: rgba(192, 48, 48, 0.28) !important; + color: #b04040 !important; +} + +html[data-theme="light"] .detail-actions { + border-top-color: #d0dae4 !important; +} + +/* ── 顺势加仓:表单字段按模式显隐(CSS 兜底,不依赖 JS)── */ +#roll-form[data-add-mode="market"] .roll-field-fib, +#roll-form[data-add-mode="market"] .roll-field-breakout { + display: none !important; +} + +#roll-form[data-add-mode="fib_618"] .roll-field-breakout, +#roll-form[data-add-mode="fib_786"] .roll-field-breakout { + display: none !important; +} + +#roll-form[data-add-mode="breakout"] .roll-field-fib { + display: none !important; +} + +#roll-form[data-add-mode="fib_618"] .roll-field-fib, +#roll-form[data-add-mode="fib_786"] .roll-field-fib, +#roll-form[data-add-mode="breakout"] .roll-field-breakout { + display: inline-flex !important; + gap: 8px; + flex-wrap: wrap; + align-items: center; +} + +#roll-form[data-add-mode="fib_618"] #roll-preview-btn, +#roll-form[data-add-mode="fib_786"] #roll-preview-btn, +#roll-form[data-add-mode="breakout"] #roll-preview-btn { + display: none !important; +} + +#strategy-roll-panel .roll-risk-banner { + margin-bottom: 8px; + color: #8fc8ff; +} + +html[data-theme="light"] #strategy-roll-panel .roll-risk-banner { + color: #006e9a !important; +} + +#strategy-roll-panel .roll-doc-link { + color: #8fc8ff; +} + +html[data-theme="light"] #strategy-roll-panel .roll-doc-link { + color: #006e9a !important; +} + +#strategy-roll-panel .roll-section-title { + margin: 14px 0 8px; + font-size: 0.95rem; + color: #b8c4ff; +} + +html[data-theme="light"] #strategy-roll-panel .roll-section-title { + color: #006e9a !important; +} + +#strategy-roll-panel .roll-active-groups-table .roll-tp-profit, +#strategy-roll-panel .roll-active-groups-table .roll-status-active { + color: #4cd97f; + font-weight: 600; +} + +.pos-tp-profit { + color: #4cd97f; + font-weight: 600; +} + +html[data-theme="light"] .pos-tp-profit { + color: #1a8f4a !important; +} + +html[data-theme="light"] #strategy-roll-panel .roll-active-groups-table .roll-tp-profit, +html[data-theme="light"] #strategy-roll-panel .roll-active-groups-table .roll-status-active { + color: #1a8f4a !important; +} + +#roll-preview-box.roll-preview-box { + margin: 8px 0; + padding: 10px; + border: 1px solid #3a5a8a; + border-radius: 8px; + background: #141a28; + color: #dde2ff; +} + +#roll-preview-box.roll-preview-box.is-error { + border-color: #8a3a4a; + background: #1a1218; + color: #ffb4b4; +} + +#roll-preview-box.roll-preview-box.is-preview { + border-color: #3a5a8a; + background: #141a28; + color: #dde2ff; +} + +html[data-theme="light"] #roll-preview-box.roll-preview-box { + background: #f6f9fc !important; + border-color: #b8c8d8 !important; + color: #1a2838 !important; +} + +html[data-theme="light"] #roll-preview-box.roll-preview-box.is-error { + background: #fff5f5 !important; + border-color: #d8a0a8 !important; + color: #8a2030 !important; +} + +#roll-countdown.roll-countdown { + margin-top: 6px; + color: #ffb347; +} + +html[data-theme="light"] #roll-countdown.roll-countdown { + color: #a06010 !important; +} + +/* ── 顺势加仓说明页 ── */ +body.roll-doc-page { + font-family: system-ui, sans-serif; + margin: 0; + padding: 16px; + background: #0f1117; + color: #e6e8ef; +} + +html[data-theme="light"] body.roll-doc-page { + background: #eef3f8 !important; + color: #142232 !important; +} + +.roll-doc-container { + max-width: 920px; + margin: 0 auto; +} + +.roll-doc-nav { + margin-bottom: 14px; +} + +.roll-doc-nav a { + color: #8fc8ff; + text-decoration: none; +} + +html[data-theme="light"] .roll-doc-nav a { + color: #006e9a !important; +} + +.roll-doc-body { + background: #151a2a; + border: 1px solid #2a3150; + border-radius: 10px; + padding: 18px 20px; + line-height: 1.65; + font-size: 0.92rem; +} + +html[data-theme="light"] .roll-doc-body { + background: #fff !important; + border-color: #b8c8d8 !important; + color: #1a2838 !important; +} + +.roll-doc-body h1 { + font-size: 1.35rem; + margin: 0 0 12px; + color: #f0f2ff; +} + +html[data-theme="light"] .roll-doc-body h1 { + color: #142232 !important; +} + +.roll-doc-body h2 { + font-size: 1.08rem; + margin: 22px 0 10px; + color: #b8c4ff; + border-bottom: 1px solid #2a3150; + padding-bottom: 6px; +} + +html[data-theme="light"] .roll-doc-body h2 { + color: #006e9a !important; + border-bottom-color: #d0dae4 !important; +} + +.roll-doc-body h3 { + font-size: 0.98rem; + margin: 16px 0 8px; + color: #c9d4ff; +} + +html[data-theme="light"] .roll-doc-body h3 { + color: #142232 !important; +} + +.roll-doc-body p, +.roll-doc-body li { + color: #dde2ff; +} + +html[data-theme="light"] .roll-doc-body p, +html[data-theme="light"] .roll-doc-body li { + color: #1a2838 !important; +} + +.roll-doc-body ul, +.roll-doc-body ol { + margin: 8px 0 12px 1.25em; +} + +.roll-doc-body code { + background: #252538; + padding: 1px 5px; + border-radius: 4px; + font-size: 0.88em; +} + +html[data-theme="light"] .roll-doc-body code { + background: #e8eef5 !important; + color: #142232 !important; +} + +.roll-doc-body pre { + background: #0f1420; + border: 1px solid #2a3150; + border-radius: 8px; + padding: 12px; + overflow: auto; + font-size: 0.84rem; + line-height: 1.5; + color: #dde2ff; +} + +html[data-theme="light"] .roll-doc-body pre { + background: #f6f9fc !important; + border-color: #b8c8d8 !important; + color: #142232 !important; +} + +.roll-doc-body pre code { + background: transparent; + padding: 0; +} + +.roll-doc-body table { + width: 100%; + border-collapse: collapse; + margin: 10px 0; + font-size: 0.86rem; +} + +.roll-doc-body th, +.roll-doc-body td { + border: 1px solid #2a3150; + padding: 6px 8px; + text-align: left; + color: #dde2ff; +} + +html[data-theme="light"] .roll-doc-body th, +html[data-theme="light"] .roll-doc-body td { + border-color: #b8c8d8 !important; + color: #1a2838 !important; +} + +.roll-doc-body th { + background: #1a2030; + color: #b8c4ff; +} + +html[data-theme="light"] .roll-doc-body th { + background: #e8eef5 !important; + color: #142232 !important; +} + +.roll-doc-body hr { + border: none; + border-top: 1px solid #2a3150; + margin: 20px 0; +} + +html[data-theme="light"] .roll-doc-body hr { + border-top-color: #d0dae4 !important; +} + +/* ── 实盘下单:预估风险/盈利/盈亏比条 ── */ +html[data-theme="light"] .order-plan-preview { + background: #f6f9fc !important; + border-color: #b8c8d8 !important; +} + +html[data-theme="light"] .order-preview-rr { + color: #4a6078 !important; +} + +html[data-theme="light"] .order-preview-rr strong { + color: #142232 !important; +} + +html[data-theme="light"] .order-preview-risk strong { + color: #b03030 !important; +} + +html[data-theme="light"] .order-preview-profit strong { + color: #087a50 !important; +} + +/* ── 账户交易限制(方向 / 币种白名单)── */ +.trade-policy-badge { + display: inline-flex; + align-items: center; + padding: 2px 10px; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 600; + color: #8fc8ff; + background: rgba(31, 58, 90, 0.55); + border: 1px solid rgba(143, 200, 255, 0.35); + line-height: 1.4; +} + +.trade-policy-dir-lock { + display: inline-flex; + align-items: center; + padding: 6px 12px; + border-radius: 8px; + font-size: 0.82rem; + font-weight: 600; + color: #4cd97f; + background: rgba(76, 217, 127, 0.1); + border: 1px solid rgba(76, 217, 127, 0.28); + white-space: nowrap; +} + +html[data-theme="light"] .trade-policy-badge { + color: #1a4a7a; + background: #e8f2fb; + border-color: #9ec5e8; +} + +html[data-theme="light"] .trade-policy-dir-lock { + color: #087a50; + background: #e8f8f0; + border-color: #9ed4b8; +} + +/* ── 币种输入实时现价 ── */ +.symbol-live-price { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: 8px; + font-size: 0.8rem; + font-weight: 600; + color: #8fc8ff; + background: rgba(31, 58, 90, 0.35); + border: 1px solid rgba(143, 200, 255, 0.22); + white-space: nowrap; + line-height: 1.35; +} + +.symbol-live-price--ok { + color: #4cd97f; + border-color: rgba(76, 217, 127, 0.35); + background: rgba(76, 217, 127, 0.08); +} + +.symbol-live-price--loading { + opacity: 0.75; +} + +.symbol-live-price--err { + color: #e8a090; + border-color: rgba(232, 160, 144, 0.35); +} + +.symbol-live-price-note { + font-size: 0.72rem; + color: #8892b0; + white-space: nowrap; +} + +html[data-theme="light"] .symbol-live-price { + color: #1a4a7a; + background: #eef4fb; + border-color: #b8cfe8; +} + +html[data-theme="light"] .symbol-live-price--ok { + color: #087a50; + background: #e8f8f0; + border-color: #9ed4b8; +} + +/* ── 复盘:字段按内容宽度;开仓类型与离场触发同一行 ── */ +.journal-card #journal-form { + min-width: 0; + max-width: 100%; +} + +.journal-card .form-grid { + gap: 10px; +} + +.journal-card .form-grid > input, +.journal-card .form-grid > select { + box-sizing: border-box; +} + +.journal-card .journal-form-row1 { + grid-template-columns: + minmax(11rem, 1.55fr) + minmax(11rem, 1.55fr) + minmax(4.2rem, 0.62fr) + minmax(3.2rem, 0.48fr) + minmax(4.8rem, 0.72fr) + minmax(4rem, 0.55fr) + minmax(4rem, 0.55fr); + margin-bottom: 10px; +} + +.journal-card .journal-form-row2 { + grid-template-columns: + minmax(6.5rem, 0.85fr) + minmax(7.5rem, 1.15fr) + minmax(7rem, 1fr) + minmax(0, 1.35fr) + minmax(6.5rem, 0.75fr); + margin-bottom: 8px; +} + +.journal-card .journal-form-row2 select[name="order_type"], +.journal-card .journal-form-row2 select[name="entry_reason"] { + font-size: 0.8rem; + line-height: 1.35; +} + +.journal-card #journal-form textarea[name="note"] { + display: block; + width: 100%; + max-width: 100%; + box-sizing: border-box; + margin-top: 8px; +} + +.journal-upload-slots { + display: flex; + flex-wrap: nowrap; + gap: 10px; + align-items: flex-start; + margin-top: 8px; +} + +.journal-upload-row { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 4px; + flex: 1 1 0; + min-width: 0; +} + +.journal-upload-slot-label { + color: #9aa3c7; + font-weight: 600; + font-size: 0.78rem; + letter-spacing: 0.02em; +} + +.journal-upload-slot-input { + width: 100%; + min-width: 0; + font-size: 0.72rem; + padding: 3px 4px; + line-height: 1.2; +} + +.journal-upload-status { + font-size: 0.68rem; + color: #8892b0; + min-height: 1.1em; + line-height: 1.25; + word-break: break-all; +} + +.journal-upload-status--pending { + color: #c9b458; +} + +.journal-upload-status--ok { + color: #6bc98a; +} + +.journal-upload-status--err { + color: #ff7b7b; +} + +.journal-upload-hint { + margin-top: 4px; + margin-bottom: 0; + font-size: 0.72rem; + color: #8892b0; +} + +.journal-card .journal-upload-slots { + margin-bottom: 2px; +} + +.journal-card .form-row.journal-chart-options { + margin-top: 6px; + margin-bottom: 6px; + gap: 6px; +} + +.journal-card .mood-grid { + margin-top: 6px; + gap: 8px; +} + +@media (max-width: 960px) { + .journal-card .journal-form-row1 { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .journal-card .journal-form-row1 .journal-field-datetime { + grid-column: span 2; + } + + .journal-card .journal-form-row2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .journal-card .journal-form-row2 input[name="early_exit_note"] { + grid-column: 1 / -1; + } + + .journal-upload-slots { + flex-wrap: wrap; + } + + .journal-upload-row { + flex: 1 1 calc(50% - 8px); + } +} + +@media (max-width: 560px) { + .journal-card .journal-form-row1 { + grid-template-columns: minmax(0, 1fr); + } + + .journal-card .journal-form-row1 .journal-field-datetime { + grid-column: auto; + } + + .journal-card .journal-form-row2 { + grid-template-columns: minmax(0, 1fr); + } + + .journal-card .journal-form-row2 input[name="early_exit_note"] { + grid-column: auto; + } +} + +@media (max-width: 560px) { + .journal-upload-row { + flex: 1 1 100%; + } +} + +.journal-detail-images { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + padding: 10px 14px 14px; + border-top: 1px solid rgba(130, 145, 190, 0.25); +} + +.journal-detail-img-cell { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.journal-detail-img-label { + font-size: 0.75rem; + color: #9aa3c7; + font-weight: 600; +} + +.journal-detail-img-thumb { + width: 100%; + max-height: 220px; + object-fit: contain; + background: rgba(0, 0, 0, 0.25); + border-radius: 6px; + cursor: zoom-in; +} + +html[data-theme="light"] .journal-detail-images { + border-top-color: #d0dae4; +} + +html[data-theme="light"] .journal-detail-img-thumb { + background: #eef2f7; +} + +.nav-hidden { + display: none !important; +} + +/* ── env 配置页(Tab + 双列表单) ── */ +.env-config-page { + margin-top: 12px; + width: 100%; + min-width: 0; + grid-column: 1 / -1; +} + +.env-config-head { + padding: 14px 16px; + margin-bottom: 12px; +} + +.env-config-head-row { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 12px 16px; +} + +.env-config-head h2 { + margin: 0 0 4px; + font-size: 1rem; +} + +.env-config-head-hint { + margin: 0; + font-size: 0.78rem; + max-width: 42rem; +} + +.env-config-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.env-config-body { + padding: 0; + overflow: hidden; + position: relative; +} + +.env-tab-radio { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.env-config-tabs { + display: flex; + flex-wrap: nowrap; + gap: 0; + overflow-x: auto; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + padding: 0 8px; + scrollbar-width: thin; +} + +.env-tab-btn { + flex: 0 0 auto; + display: inline-block; + border: none; + background: transparent; + color: var(--muted, #8892b0); + font-size: 0.8rem; + padding: 10px 14px; + cursor: pointer; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + white-space: nowrap; + transition: color 0.15s, border-color 0.15s; + user-select: none; +} + +.env-tab-btn:hover { + color: #c5cae0; +} + +.env-config-panels .env-panel { + display: none; +} + +#env-sec-0:checked ~ .env-config-tabs label[for="env-sec-0"], +#env-sec-1:checked ~ .env-config-tabs label[for="env-sec-1"], +#env-sec-2:checked ~ .env-config-tabs label[for="env-sec-2"], +#env-sec-3:checked ~ .env-config-tabs label[for="env-sec-3"], +#env-sec-4:checked ~ .env-config-tabs label[for="env-sec-4"], +#env-sec-5:checked ~ .env-config-tabs label[for="env-sec-5"], +#env-sec-6:checked ~ .env-config-tabs label[for="env-sec-6"], +#env-sec-7:checked ~ .env-config-tabs label[for="env-sec-7"], +#env-sec-8:checked ~ .env-config-tabs label[for="env-sec-8"], +#env-sec-9:checked ~ .env-config-tabs label[for="env-sec-9"], +#env-sec-10:checked ~ .env-config-tabs label[for="env-sec-10"], +#env-sec-11:checked ~ .env-config-tabs label[for="env-sec-11"] { + color: #e8ecff; + border-bottom-color: var(--accent, #7c6cf0); + font-weight: 600; +} + +#env-sec-0:checked ~ .env-config-panels .env-panel--0, +#env-sec-1:checked ~ .env-config-panels .env-panel--1, +#env-sec-2:checked ~ .env-config-panels .env-panel--2, +#env-sec-3:checked ~ .env-config-panels .env-panel--3, +#env-sec-4:checked ~ .env-config-panels .env-panel--4, +#env-sec-5:checked ~ .env-config-panels .env-panel--5, +#env-sec-6:checked ~ .env-config-panels .env-panel--6, +#env-sec-7:checked ~ .env-config-panels .env-panel--7, +#env-sec-8:checked ~ .env-config-panels .env-panel--8, +#env-sec-9:checked ~ .env-config-panels .env-panel--9, +#env-sec-10:checked ~ .env-config-panels .env-panel--10, +#env-sec-11:checked ~ .env-config-panels .env-panel--11 { + display: block; +} + +#settings-sec-0:checked ~ .env-config-tabs label[for="settings-sec-0"], +#settings-sec-1:checked ~ .env-config-tabs label[for="settings-sec-1"], +#settings-sec-2:checked ~ .env-config-tabs label[for="settings-sec-2"], +#settings-sec-3:checked ~ .env-config-tabs label[for="settings-sec-3"], +#settings-sec-4:checked ~ .env-config-tabs label[for="settings-sec-4"], +#settings-sec-5:checked ~ .env-config-tabs label[for="settings-sec-5"], +#settings-sec-6:checked ~ .env-config-tabs label[for="settings-sec-6"] { + color: #e8ecff; + border-bottom-color: var(--accent, #7c6cf0); + font-weight: 600; +} + +#settings-sec-0:checked ~ .env-config-panels .env-panel--0, +#settings-sec-1:checked ~ .env-config-panels .env-panel--1, +#settings-sec-2:checked ~ .env-config-panels .env-panel--2, +#settings-sec-3:checked ~ .env-config-panels .env-panel--3, +#settings-sec-4:checked ~ .env-config-panels .env-panel--4, +#settings-sec-5:checked ~ .env-config-panels .env-panel--5, +#settings-sec-6:checked ~ .env-config-panels .env-panel--6 { + display: block; +} + +.env-sensitive-current { + margin: 0 0 6px; + font-size: 0.75rem; +} + +.env-masked-value { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + letter-spacing: 0.04em; + color: #c5cae0; +} + +.settings-tab-panel h2 { + margin: 0 0 8px; + font-size: 1rem; +} + +.settings-tab-inner h2 { + margin: 0 0 8px; + font-size: 1rem; +} + +.settings-config-body { + margin-top: 10px; +} + +.env-config-panels { + padding: 14px 16px 16px; +} + +.env-panel-hint { + margin: 0 0 12px; + padding: 8px 10px; + font-size: 0.75rem; + border-radius: 6px; + background: rgba(251, 191, 36, 0.08); + color: #e8c468; + border: 1px solid rgba(251, 191, 36, 0.15); +} + +.env-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px 20px; + align-items: start; +} + +.env-field-row { + display: flex; + flex-direction: column; + gap: 5px; + min-width: 0; +} + +.env-field-row--restart .env-field-label { + color: #d4c4a0; +} + +.env-field-label { + font-size: 0.8rem; + font-weight: 600; + color: #c5cae0; + line-height: 1.3; +} + +.env-restart-mark { + color: #fbbf24; + font-weight: 700; + margin-left: 2px; +} + +.env-field-note { + font-size: 0.72rem; + line-height: 1.35; + margin-top: -2px; +} + +.env-field-input { + width: 100%; + font-size: 0.82rem; + padding: 7px 10px; + border-radius: 6px; + box-sizing: border-box; +} + +.env-config-loading-wrap { + padding: 24px; + text-align: center; +} + +/* 兼容旧结构 */ +.env-config-grid { + display: block; +} + +.env-group-card, +.env-field-card, +.env-field-badge, +.env-badge-hot, +.env-badge-restart { + display: none; +} + +@media (max-width: 900px) { + .env-form-grid { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (max-width: 720px) { + .env-config-head-row { + flex-direction: column; + } + .env-config-toolbar { + width: 100%; + } +} + +.display-prefs-form { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 8px; +} + +.display-prefs-checks { + display: flex; + flex-wrap: wrap; + gap: 8px 16px; +} + +.display-prefs-checks .chk-label { + font-size: 0.82rem; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.settings-password-form { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px 12px; + margin: 8px 0; +} + +.settings-password-form label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 0.78rem; + color: var(--muted, #8892b0); +} + +.settings-actions-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin-top: 8px; +} + +.settings-status-line.err { + color: var(--danger, #f87171); +} + +@media (max-width: 1100px) { + .env-form-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 720px) { + .env-form-grid { + grid-template-columns: minmax(0, 1fr); + } + .settings-password-form { + grid-template-columns: minmax(0, 1fr); + } +} + +/* ── 风控说明页 ── */ +.risk-policy-page { + margin-top: 12px; + width: 100%; + min-width: 0; + grid-column: 1 / -1; +} + +.risk-policy-page .settings-card--risk { + width: 100%; + min-width: 0; + box-sizing: border-box; + height: auto; +} + +.settings-risk-sections { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-auto-rows: auto; + gap: 10px; + margin-top: 10px; + align-items: stretch; +} + +.risk-policy-page .settings-subcard { + height: 100%; + display: flex; + flex-direction: column; + padding: 10px 12px; + min-width: 0; + margin: 0; + width: 100%; + box-sizing: border-box; +} + +.settings-page--grid { + margin-top: 12px; +} + +.settings-grid-2col { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + align-items: start; +} + +.settings-grid-cell { + min-width: 0; +} + +.settings-grid-cell--full { + grid-column: 1 / -1; +} + +.settings-card--standalone { + padding: 12px 14px; + height: 100%; +} + +.settings-card--standalone h2 { + margin: 0 0 8px; + font-size: 1.05rem; +} + +.settings-card--export .settings-export-links-block { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + margin-top: 8px; +} + +.settings-page--ops { + max-width: 720px; +} + +.settings-page--ops .settings-card--side-panel { + height: auto; +} + +/* ── 系统设置页 ── */ +.settings-page { + margin-top: 12px; + width: 100%; + min-width: 0; + grid-column: 1 / -1; +} + +.settings-account-summary { + margin-bottom: 16px; + padding: 12px 14px 10px; +} + +.settings-account-summary .instance-header-stats { + border-top: none; + margin-top: 0; + padding-top: 4px; +} + +.settings-account-summary-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 8px; + flex-wrap: wrap; +} + +.settings-account-summary-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.settings-cards-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 12px; + align-items: stretch; +} + +.settings-cards-grid > .settings-card--risk, +.settings-cards-grid > .settings-side-col { + min-height: 0; +} + +.settings-side-col { + display: flex; + flex-direction: column; + align-self: stretch; +} + +.settings-card--side-panel { + height: 100%; + display: flex; + flex-direction: column; + padding: 10px 12px; +} + +.settings-side-subcards { + display: flex; + flex-direction: column; + gap: 10px; + flex: 1 1 auto; +} + +.settings-side-subcards > .settings-subcard { + flex: 0 0 auto; + padding: 8px 10px; + margin: 0; +} + +.settings-subcard-desc { + font-size: 0.7rem; + margin: 0 0 6px; + line-height: 1.4; + color: var(--muted, #8892b0); +} + +.settings-card--risk { + min-width: 0; + height: 100%; + display: flex; + flex-direction: column; +} + +.settings-side-export { + flex: 0 0 auto; + margin-top: auto; + padding-top: 8px; + border-top: 1px solid var(--border-soft, #2a3150); +} + +.settings-side-export-head { + display: flex; + align-items: baseline; + gap: 8px; + margin-bottom: 4px; +} + +.settings-side-export-label { + font-size: 0.72rem; + font-weight: 600; + color: #a8b0cc; +} + +.settings-side-export-meta { + font-size: 0.68rem; +} + +.settings-export-links-inline { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 12px; +} + +.settings-export-links-inline a { + font-size: 0.72rem; + color: #8fc8ff; + text-decoration: none; + white-space: nowrap; +} + +.settings-export-links-inline a:hover { + text-decoration: underline; +} + +.settings-card--compact { + padding: 10px 12px; +} + +.settings-card--compact h2 { + margin: 0 0 6px; + font-size: 0.88rem; + font-weight: 600; +} + +.settings-card--compact .settings-card-desc { + font-size: 0.72rem; + margin: 0 0 8px; + line-height: 1.45; +} + +.settings-card--compact .settings-transfer-auto, +.settings-card--compact .settings-transfer-form { + font-size: 0.72rem; +} + +.settings-card--compact .settings-transfer-form input, +.settings-card--compact .settings-transfer-form select, +.settings-card--compact .settings-transfer-form button { + font-size: 0.75rem; + padding: 4px 8px; +} + +.settings-card--compact .settings-export-link { + font-size: 0.75rem; + padding: 5px 10px; +} + +@media (max-width: 900px) { + .settings-grid-2col { + grid-template-columns: minmax(0, 1fr); + } + .settings-cards-grid { + grid-template-columns: minmax(0, 1fr); + } +} + +.settings-card h2 { + margin: 0 0 10px; + font-size: 1.05rem; +} + +.settings-card-desc, +.settings-env-hint, +.settings-transfer-auto { + color: var(--muted, #8892b0); + font-size: 0.82rem; + line-height: 1.5; + margin: 0 0 12px; +} + +.settings-live-status { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + font-size: 0.88rem; + margin: 0 0 10px; +} + +.settings-status-reason, +.settings-policy-note { + color: var(--muted, #8892b0); + font-size: 0.8rem; +} + +.settings-subcard-title { + margin: 0 0 8px; + font-size: 0.82rem; + font-weight: 600; + color: #cfd3ef; +} + +.settings-kv--compact .settings-kv-row { + grid-template-columns: minmax(7em, auto) minmax(0, 1fr); + gap: 6px 12px; + padding: 4px 0; + font-size: 0.75rem; + align-items: start; +} + +@media (max-width: 720px) { + .settings-risk-sections { + grid-template-columns: minmax(0, 1fr); + } + + .settings-kv--compact .settings-kv-row { + grid-template-columns: minmax(0, 1fr); + gap: 2px; + } + + .settings-kv--compact .settings-kv-row dd { + margin-bottom: 6px; + } +} + +.settings-section { + margin-top: 14px; + padding-top: 12px; + border-top: 1px solid var(--border-soft, #2a3150); +} + +.settings-section h3 { + margin: 0 0 8px; + font-size: 0.92rem; + color: #cfd3ef; +} + +.settings-kv { + margin: 0; +} + +.settings-kv-row { + display: grid; + grid-template-columns: 9.5em 1fr; + gap: 8px 12px; + padding: 6px 0; + font-size: 0.82rem; + border-bottom: 1px dashed rgba(136, 146, 176, 0.15); +} + +.settings-kv-row:last-child { + border-bottom: none; +} + +.settings-kv-row dt { + margin: 0; + color: #9aa3c7; +} + +.settings-kv-row dd { + margin: 0; +} + +.settings-kv-value { + color: #e8ecff; + font-weight: 600; +} + +.settings-kv-note { + display: block; + margin-top: 2px; + color: #8892b0; + font-size: 0.75rem; + font-weight: 400; +} + +.settings-link-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.settings-export-link { + display: block; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--border-soft, #2a3150); + background: var(--inset-surface, #12151f); + color: #8fc8ff; + text-decoration: none; + font-size: 0.88rem; +} + +.settings-export-link:hover { + border-color: #3d4f7a; + background: #1a2030; +} + +.settings-transfer-form { + margin-top: 10px; +} + +html[data-theme="light"] .settings-section { + border-top-color: #d0dae4; +} + +html[data-theme="light"] .settings-subcard-title, +html[data-theme="light"] .settings-section h3, +html[data-theme="light"] .settings-kv-value { + color: #142232; +} + +html[data-theme="light"] .settings-export-link, +html[data-theme="light"] .settings-export-links-inline a { + background: transparent; + border-color: transparent; + color: #1d4f8c; +} + +html[data-theme="light"] .settings-side-export { + border-top-color: #d0dae4; +} + +html[data-theme="light"] .settings-side-export-label { + color: #142232; +} + +/* OKX 期权页 */ +.options-page-wrap { + font-size: 0.8rem; +} + +.options-page-wrap .card { + padding: 12px 14px; +} + +.options-page-wrap .card h2, +.options-page-wrap .options-order-card h2, +.options-page-wrap .options-pos-card-wrap h2, +.options-page-wrap .options-pos-head h2 { + font-size: 0.9rem; + margin: 0 0 8px; + font-weight: 600; +} + +.options-page-wrap .options-hint, +.options-page-wrap #opt-index-line { + font-size: 0.72rem; + line-height: 1.45; + margin-bottom: 6px; +} + +.options-page-wrap .options-chain-toolbar .btn-secondary, +.options-page-wrap .options-chain-toolbar select { + font-size: 0.72rem; + padding: 4px 8px; + min-height: 28px; +} + +.options-page-wrap .options-pos-head .btn-secondary { + font-size: 0.72rem; + padding: 3px 10px; + min-height: 26px; +} + +.options-funds-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px; + margin: 12px 0 16px; +} +.options-funds-col { + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 8px; + padding: 12px; +} +.options-fund-row { + display: flex; + justify-content: space-between; + gap: 8px; + margin: 6px 0; + font-size: 0.9rem; +} +.options-section { + margin: 16px 0; +} +.options-section.card-nested { + padding: 12px; + border-radius: 8px; + background: rgba(0, 0, 0, 0.15); +} +.options-strike-table-wrap { + overflow-x: auto; + overflow-y: auto; + max-height: 352px; + margin-top: 8px; +} +.options-strike-table thead th { + position: sticky; + top: 0; + z-index: 1; + background: rgba(18, 24, 38, 0.98); +} +/* 列表 / T 型表头互斥:类名 hidden 需显式隐藏(实例页无全局 .hidden) */ +.options-strike-table thead tr.hidden { + display: none !important; +} +.options-strike-table { + width: 100%; + border-collapse: collapse; + font-size: 0.75rem; +} +.options-page-wrap .options-strike-table { + font-size: 0.74rem; +} +.options-strike-table th, +.options-strike-table td { + padding: 6px 5px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + text-align: left; +} +.options-page-wrap .options-strike-table th, +.options-page-wrap .options-strike-table td { + padding: 5px 4px; +} +.options-page-wrap .options-strike-table code { + font-size: 0.66rem; +} +.opt-px-sz { + font-variant-numeric: tabular-nums; + white-space: nowrap; +} +.opt-be-dist-up { + color: #5ee89a; +} +.opt-be-dist-down { + color: #ff8a8a; +} +html[data-theme="light"] .opt-be-dist-up { + color: #0d7a45; +} +html[data-theme="light"] .opt-be-dist-down { + color: #c62828; +} +.options-chain-toolbar .btn-secondary.active, +.opt-uly-btn.active, +.opt-type-btn.active, +.opt-money-btn.active, +.opt-view-btn.active, +.opt-pos-tab.active { + border-color: #5b8cff; + color: #cfe0ff; + background: rgba(74, 124, 255, 0.28); + box-shadow: inset 0 0 0 1px rgba(120, 160, 255, 0.45); +} +.opt-pick-btn.active { + border-color: #5b8cff; + color: #fff; + background: rgba(74, 124, 255, 0.45); + box-shadow: inset 0 0 0 1px rgba(140, 175, 255, 0.6); +} +.opt-strike-row.opt-row-selected td { + background: rgba(74, 124, 255, 0.1); +} +.opt-strike-row.opt-row-selected td:first-child { + box-shadow: inset 3px 0 0 #5b8cff; +} +.opt-chain-view-group { + display: inline-flex; + gap: 4px; +} +.opt-type-btn-group { + display: inline-flex; + gap: 4px; +} +.opt-strike-expand-label { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.78rem; + color: var(--text-soft, #9aa4b2); + white-space: nowrap; + cursor: pointer; + user-select: none; +} +.opt-strike-expand-label input { + margin: 0; +} +.options-strike-table-wrap--t { + max-height: 380px; +} + +/* 对冲计划 Tab:高对比选中态 */ +.hedge-plan-page-wrap { + font-size: 0.8rem; +} +.hedge-plan-page-wrap .card { + padding: 12px 14px; +} +.hedge-plan-page-wrap .card h2, +.hedge-plan-page-wrap .hp-title { + font-size: 0.9rem; + margin: 0 0 8px; + font-weight: 600; +} +.hedge-plan-page-wrap .hp-head-card { + margin-bottom: 12px; +} +.hedge-plan-page-wrap .hp-head-row { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; +} +.hedge-plan-page-wrap .hp-title { + margin: 0; +} +.hedge-plan-page-wrap .hp-title-sub { + font-size: 0.75rem; + font-weight: 400; +} +.hedge-plan-page-wrap .hp-quote-line, +.hedge-plan-page-wrap .hp-acct-hint, +.hedge-plan-page-wrap #hp-sizing-line, +.hedge-plan-page-wrap #hp-gate-line { + font-size: 0.72rem; + line-height: 1.45; + margin: 4px 0 6px; +} +.hedge-plan-page-wrap .hp-acct-tag { + font-size: 0.68rem; + font-weight: 500; + margin-left: 4px; +} +.hedge-plan-page-wrap .hp-unit { + font-size: 0.66rem; + color: #8892b0; + font-weight: 500; + margin-right: 2px; +} +.hedge-plan-page-wrap .hp-unit-hint { + font-size: 0.68rem; + margin: 2px 0 6px; + line-height: 1.4; +} +.hedge-plan-page-wrap #hp-perp-pnl-line { + font-size: 0.74rem; + margin: 4px 0 6px; + line-height: 1.45; +} +.hedge-plan-page-wrap .hp-pnl-pos { + color: #7ee787; +} +.hedge-plan-page-wrap .hp-plan-active { + color: #7ee787; + font-weight: 700; +} +.hedge-plan-page-wrap .hp-pnl-neg { + color: #ff8a8a; +} +.hedge-plan-page-wrap .hp-oo-legs { + margin-top: 8px; + display: flex; + flex-direction: column; + gap: 8px; +} +.hedge-plan-page-wrap .hp-oo-leg-row { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 10px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); +} +.hedge-plan-page-wrap .hp-oo-leg-row input[type="number"] { + width: 72px; +} +.hedge-plan-page-wrap .form-row label, +.hedge-plan-page-wrap .form-row select, +.hedge-plan-page-wrap .form-row input, +.hedge-plan-page-wrap .form-row .btn-secondary, +.hedge-plan-page-wrap .form-row .primary { + font-size: 0.74rem; +} +.hedge-plan-page-wrap .form-row input[type="number"] { + max-width: 110px; + padding: 4px 6px; + min-height: 28px; +} +.hedge-plan-page-wrap .options-strike-table { + font-size: 0.74rem; +} +.hedge-plan-page-wrap .options-strike-table th, +.hedge-plan-page-wrap .options-strike-table td { + padding: 5px 4px; +} +.hedge-plan-page-wrap .options-strike-table code { + font-size: 0.66rem; +} +.hedge-plan-page-wrap .hp-tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 0 0 8px; + padding: 6px; + border-radius: 10px; + background: rgba(0, 0, 0, 0.28); + border: 1px solid rgba(255, 255, 255, 0.08); +} +.hedge-plan-page-wrap .hp-tab { + appearance: none; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.04); + color: #aeb6c5; + font-size: 0.82rem; + font-weight: 600; + padding: 7px 14px; + border-radius: 8px; + cursor: pointer; + line-height: 1.2; + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; +} +.hedge-plan-page-wrap .hp-tab:hover { + color: #eef3ff; + border-color: rgba(120, 170, 255, 0.45); + background: rgba(74, 124, 255, 0.16); +} +.hedge-plan-page-wrap .hp-tab.active { + color: #0b1220; + background: linear-gradient(180deg, #d7e6ff 0%, #8eb6ff 100%); + border-color: #fff; + box-shadow: 0 0 0 2px rgba(142, 182, 255, 0.55), 0 6px 16px rgba(0, 0, 0, 0.35); +} +.hedge-plan-page-wrap .hp-uly-btn, +.hedge-plan-page-wrap .hp-uly-btn-oo, +.hedge-plan-page-wrap .hp-money-btn { + min-width: 52px; + font-weight: 600; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.04); + color: #9aa4b2; +} +.hedge-plan-page-wrap .hp-uly-btn.active, +.hedge-plan-page-wrap .hp-uly-btn-oo.active, +.hedge-plan-page-wrap .hp-money-btn.active { + color: #0b1220; + background: linear-gradient(180deg, #ffffff 0%, #9ec0ff 100%); + border-color: #fff; + box-shadow: 0 0 0 2px rgba(100, 160, 255, 0.5); +} +.hedge-plan-page-wrap .hp-money-hint { + font-size: 0.68rem; + margin-left: 4px; +} +.hedge-plan-page-wrap .hp-opt-toolbar, +.hedge-plan-page-wrap .hp-pick-row { + flex-wrap: wrap; + gap: 6px; + margin: 4px 0; + align-items: center; +} +.hedge-plan-page-wrap .hp-opt-toolbar select { + max-width: 168px; + font-size: 0.72rem; + padding: 3px 6px; + min-height: 26px; +} +.hedge-plan-page-wrap .hp-opt-toolbar .btn-secondary, +.hedge-plan-page-wrap .hp-opt-toolbar .hp-money-btn { + padding: 3px 8px; + min-height: 26px; + font-size: 0.72rem; +} +/* 视口约 5 行数据 + 表头;超出表内滚动 */ +.hedge-plan-page-wrap .hp-strike-table-wrap--5, +.hedge-plan-page-wrap .options-strike-table-wrap--t { + max-height: 248px; + min-height: 248px; + overflow-y: auto; + margin-top: 4px; + flex: 1 1 auto; +} +.hedge-plan-page-wrap .hp-opt-bal-line { + margin-top: 4px; +} +.hedge-plan-page-wrap .hp-acct-hint { + display: none; +} +.hedge-plan-page-wrap .options-dual-grid { + grid-template-columns: 1fr 1fr; + align-items: stretch; + margin-bottom: 28px; +} +.hedge-plan-page-wrap .options-dual-grid > .card { + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; +} +.hedge-plan-page-wrap .hp-preview-card { + margin-top: 0; + clear: both; +} +.hedge-plan-page-wrap .hp-action-row { + margin-top: 10px; + gap: 8px; + justify-content: flex-end; +} +.hedge-plan-page-wrap .hp-pick.active, +.hedge-plan-page-wrap .opt-row-selected td { + background: rgba(90, 140, 255, 0.18); +} +.hedge-plan-page-wrap .hp-tab-panel.hidden, +.hedge-plan-page-wrap .hp-tab-panel[hidden] { + display: none !important; +} +.hedge-plan-page-wrap .hp-placeholder { + margin: 16px 0 4px; + padding: 18px; + text-align: center; + border-radius: 8px; + border: 1px dashed rgba(255, 255, 255, 0.16); + color: #9aa4b2; + background: rgba(255, 255, 255, 0.03); + font-size: 0.78rem; +} +.hedge-plan-page-wrap .hp-target-row { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + margin: 6px 0; +} +.hedge-plan-page-wrap .hp-target-row label { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.8rem; +} +.hedge-plan-page-wrap .hp-target-row input { + width: 110px; +} +.hedge-plan-page-wrap .hp-contracts-cell { + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.hedge-plan-page-wrap .hp-hist-actions { + white-space: nowrap; +} +.hedge-plan-page-wrap .hp-hist-actions .btn-secondary { + padding: 3px 8px; + font-size: 0.72rem; + min-height: 26px; +} +.hedge-plan-page-wrap .hp-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; + margin-top: 8px; +} +.hedge-plan-page-wrap .hp-stats-grid--sub { + margin-top: 14px; +} +.hedge-plan-page-wrap .hp-stats-card { + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + padding: 12px 14px; + background: rgba(0, 0, 0, 0.22); +} +.hedge-plan-page-wrap .hp-stats-card h3 { + margin: 0 0 8px; + font-size: 0.92rem; + color: #e8eefc; +} +.hedge-plan-page-wrap .hp-stats-list { + list-style: none; + margin: 0; + padding: 0; +} +.hedge-plan-page-wrap .hp-stats-list li { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 6px; + margin: 5px 0; + font-size: 0.8rem; +} +.hedge-plan-page-wrap .hp-stats-list li > span:first-child { + color: #9aa4b2; + min-width: 4.5em; +} +.hedge-plan-page-wrap .hp-modal-backdrop { + position: fixed; + inset: 0; + z-index: 1300; + background: rgba(0, 0, 0, 0.72); + display: flex; + align-items: center; + justify-content: center; + padding: 16px; +} +.hedge-plan-page-wrap .hp-modal-backdrop[hidden] { + display: none !important; +} +.hedge-plan-page-wrap .hp-modal { + width: min(96vw, 980px); + max-height: 88vh; + overflow: auto; + background: #121726; + border: 1px solid #2a3150; + border-radius: 12px; + padding: 14px 16px; +} +.hedge-plan-page-wrap .hp-modal-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + margin-bottom: 10px; +} +.hedge-plan-page-wrap .hp-modal-head h3 { + margin: 0; + font-size: 1rem; + color: #dbe4ff; +} +.hedge-plan-page-wrap .hp-detail-summary { + display: grid; + gap: 6px; + margin-bottom: 12px; + font-size: 0.82rem; + color: #e5e9ff; +} +.hedge-plan-page-wrap .hp-detail-legs { + margin-top: 4px; +} +.hedge-plan-page-wrap .hp-ord { + font-size: 0.62rem; + word-break: break-all; +} +.hedge-plan-page-wrap .hp-detail-note { + margin-top: 10px; + font-size: 0.82rem; +} +html[data-theme="light"] .hedge-plan-page-wrap .hp-modal { + background: #f7f8fc; + border-color: #c9d2e8; +} +html[data-theme="light"] .hedge-plan-page-wrap .hp-modal-head h3 { + color: #1a2438; +} +html[data-theme="light"] .hedge-plan-page-wrap .hp-stats-card { + background: rgba(255, 255, 255, 0.7); + border-color: rgba(0, 0, 0, 0.08); +} +html[data-theme="light"] .hedge-plan-page-wrap .hp-stats-card h3 { + color: #1a2438; +} +html[data-theme="light"] .hedge-plan-page-wrap .hp-tabs { + background: rgba(15, 23, 42, 0.06); + border-color: rgba(15, 23, 42, 0.1); +} +html[data-theme="light"] .hedge-plan-page-wrap .hp-tab { + background: #fff; + color: #5b6472; + border-color: rgba(15, 23, 42, 0.14); +} +html[data-theme="light"] .hedge-plan-page-wrap .hp-tab.active { + color: #0b1220; + background: linear-gradient(180deg, #ffffff 0%, #b9d2ff 100%); + border-color: #2f6fed; + box-shadow: 0 0 0 2px rgba(47, 111, 237, 0.25); +} +html[data-theme="light"] .hedge-plan-page-wrap .hp-uly-btn.active, +html[data-theme="light"] .hedge-plan-page-wrap .hp-uly-btn-oo.active, +html[data-theme="light"] .hedge-plan-page-wrap .hp-money-btn.active { + color: #0b1220; + background: linear-gradient(180deg, #ffffff 0%, #b9d2ff 100%); + border-color: #2f6fed; +} +html[data-theme="light"] .hedge-plan-page-wrap .hp-placeholder { + border-color: rgba(15, 23, 42, 0.18); + background: rgba(15, 23, 42, 0.03); +} +.options-strike-table--t thead th { + text-align: center; +} +.options-strike-table--t .opt-t-head-call { + text-align: center; + color: #8ec5ff; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} +.options-strike-table--t .opt-t-head-mid { + text-align: center; + color: #ffd48a; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} +.options-strike-table--t .opt-t-head-put { + text-align: center; + color: #ff9f9f; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} +.options-strike-table--t .opt-t-strike { + text-align: center; + font-variant-numeric: tabular-nums; + background: rgba(255, 255, 255, 0.03); +} +.options-strike-table--t .opt-t-mid { + text-align: center; + background: rgba(255, 212, 138, 0.04); +} +.options-strike-table--t .opt-t-straddle-prem { + font-variant-numeric: tabular-nums; + white-space: nowrap; +} +.options-strike-table--t .opt-t-straddle-band { + font-variant-numeric: tabular-nums; + white-space: nowrap; + font-size: 0.72rem; + color: var(--text-soft, #9aa4b2); +} +.options-strike-table--t .opt-t-call, +.options-strike-table--t .opt-t-put { + text-align: center; +} +.options-strike-table--t .opt-strike-row-atm td { + background: rgba(255, 212, 138, 0.08); +} +.options-strike-table--t .opt-strike-row-atm .opt-t-strike strong { + color: #ffd48a; +} +.options-strike-table--t .opt-strike-row.opt-row-selected td { + background: rgba(74, 124, 255, 0.12); +} +.opt-strike-hint-row td { + text-align: center; + font-size: 0.72rem; + padding: 8px 4px; + border-bottom: none; +} +html[data-theme="light"] .options-strike-table--t .opt-t-head-call { + color: #1565c0; +} +html[data-theme="light"] .options-strike-table--t .opt-t-head-mid { + color: #e65100; +} +html[data-theme="light"] .options-strike-table--t .opt-t-head-put { + color: #c62828; +} +html[data-theme="light"] .options-strike-table--t .opt-strike-row-atm td { + background: rgba(255, 152, 0, 0.08); +} +.opt-order-inline-row td { + padding: 14px 16px !important; + background: rgba(74, 124, 255, 0.07); + border-top: 1px solid rgba(74, 124, 255, 0.25); + border-bottom: 1px solid rgba(74, 124, 255, 0.25); +} +.opt-order-panel-inner { + border-radius: 8px; +} +.opt-order-layout { + display: flex; + align-items: stretch; + gap: 14px; +} +.opt-order-main { + flex: 1 1 auto; + min-width: 0; +} +.opt-order-pending { + flex: 0 0 280px; + max-width: 320px; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid rgba(158, 192, 255, 0.2); + background: rgba(0, 0, 0, 0.18); +} +.opt-order-pending-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; +} +.opt-order-pending-title { + margin: 0; + font-size: 0.82rem; + color: #9ec0ff; + font-weight: 600; +} +.opt-pending-ttl-hint { + margin: 0 0 8px; + font-size: 12px; + line-height: 1.4; +} +.opt-order-pending-head .btn-secondary { + font-size: 0.68rem; + padding: 2px 8px; +} +.opt-pending-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 220px; + overflow: auto; +} +.opt-pending-empty { + font-size: 0.72rem; +} +.opt-pending-item { + padding: 8px 9px; + border-radius: 7px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); +} +.opt-pending-item-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 4px; +} +.opt-pending-side { + font-size: 0.72rem; + font-weight: 600; +} +.opt-pending-side.is-buy { color: #3dd68c; } +.opt-pending-side.is-sell { color: #ff6b7a; } +.opt-pending-inst { + font-size: 0.68rem; + color: #c5d0ee; + word-break: break-all; + margin-bottom: 4px; +} +.opt-pending-meta { + font-size: 0.68rem; + color: #8892b0; + line-height: 1.35; +} +.opt-pending-item .opt-pending-cancel { + font-size: 0.68rem; + padding: 2px 8px; +} +html[data-theme="light"] .opt-order-pending { + background: rgba(0, 0, 0, 0.03); + border-color: rgba(0, 0, 0, 0.08); +} +html[data-theme="light"] .opt-pending-item { + background: #fff; + border-color: rgba(0, 0, 0, 0.08); +} +@media (max-width: 900px) { + .opt-order-layout { + flex-direction: column; + } + .opt-order-pending { + flex: 1 1 auto; + max-width: none; + } +} +.opt-order-panel-inner .opt-order-title { + margin: 0 0 8px; + font-size: 0.85rem; + color: #9ec0ff; +} +.options-page-wrap .opt-order-panel-inner .opt-order-title { + font-size: 0.82rem; +} +.opt-order-panel-host:not([hidden]) { + display: block; +} +.opt-order-panel-host[hidden] { + display: none !important; +} +.options-order-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 10px; + margin: 10px 0; +} +.options-order-grid .k { + display: block; + font-size: 0.68rem; + color: #8892b0; +} +.options-page-wrap .options-order-grid .v { + font-size: 0.8rem; +} +.options-estimate-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + margin: 8px 0 10px; + padding: 8px 10px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); + border: 1px dashed rgba(255, 255, 255, 0.08); + font-size: 0.74rem; +} +.options-estimate-row .opt-est-label { + color: #8892b0; +} +.options-estimate-row .opt-target-idx { + width: 120px; + font-size: 0.74rem; + padding: 3px 6px; +} +.options-estimate-row .k { + color: #8892b0; +} +.options-estimate-row .v { + font-size: 0.82rem; + font-weight: 600; +} +.options-estimate-row .opt-est-note { + font-size: 0.66rem; +} +html[data-theme="light"] .options-estimate-row { + background: rgba(0, 0, 0, 0.02); + border-color: rgba(0, 0, 0, 0.08); +} +.options-hint { + font-size: 0.75rem; + margin-bottom: 6px; +} +.options-page-wrap .options-order-mode-row { + font-size: 0.74rem; + gap: 6px; +} +.options-page-wrap .options-order-mode-row input[type="number"], +.options-page-wrap .options-order-mode-row input[type="text"] { + font-size: 0.74rem; + padding: 3px 6px; +} +.options-page-wrap .options-order-mode-row .btn-primary { + font-size: 0.74rem; + padding: 4px 10px; +} +.options-page-wrap .opt-row-actions .btn-primary, +.options-page-wrap .opt-row-actions .btn-secondary { + font-size: 0.7rem; + padding: 3px 7px; + min-height: 24px; +} +#opt-order-msg.opt-error, +.opt-error { + color: #ff6b6b; +} +.opt-success { + color: #3ecf8e; +} +.opt-row-actions { + white-space: nowrap; +} +.opt-row-actions .btn-primary, +.opt-row-actions .btn-secondary { + margin-right: 4px; +} +.opt-moneyness { + display: inline-block; + padding: 1px 6px; + border-radius: 4px; + font-size: 0.68rem; + font-weight: 600; +} +.opt-moneyness-itm { + color: #7ee787; + background: rgba(46, 160, 67, 0.15); +} +.opt-moneyness-otm { + color: #a8b3cf; + background: rgba(136, 146, 176, 0.12); +} +.opt-moneyness-atm { + color: #ffd166; + background: rgba(255, 209, 102, 0.12); +} +.options-order-mode-row { + flex-wrap: wrap; + gap: 8px; +} +.options-order-mode-row input[type="number"] { + width: 88px; +} +.options-dual-grid { + display: grid; + grid-template-columns: 1.15fr 0.85fr; + gap: 16px; + align-items: stretch; +} +.options-order-card, +.options-pos-card-wrap { + display: flex; + flex-direction: column; + min-height: 0; +} +.options-pos-stack, +.options-pos-tab-body { + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; + min-height: 0; +} +.options-pos-tabs { + display: flex; + gap: 6px; + margin-bottom: 8px; +} +.opt-pos-tab { + flex: 1; + font-size: 0.78rem; + padding: 6px 8px; + min-height: 32px; + white-space: nowrap; +} +.opt-pos-tab.active { + border-color: #5b8cff; + color: #cfe0ff; + background: rgba(74, 124, 255, 0.28); +} +.options-pos-pane { + display: none; + flex: 1; + flex-direction: column; + min-height: 0; +} +.options-pos-pane.is-active { + display: flex; +} +.options-pos-live-pane { + flex: 1; + min-height: 180px; + max-height: 420px; + overflow-y: auto; +} +.options-pos-live-pane.options-pos-live-pane--accordion { + max-height: 480px; +} +.options-pos-subcard { + display: flex; + flex-direction: column; + min-height: 0; + padding: 8px 10px; +} +.options-pos-subcard h3 { + margin: 0 0 6px; + font-size: 0.8rem; + font-weight: 600; +} +.options-page-wrap .options-pos-subcard h3 { + font-size: 0.78rem; + color: #b8c0dc; +} +.options-pos-stats-card { + flex-shrink: 0; +} +.options-stats-pnl-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 14px; +} +.options-stats-pnl-summary .options-stat-item { + padding: 10px 12px; + border-radius: 8px; + background: rgba(127, 127, 127, 0.12); +} +.options-stats-pnl-summary .opt-stats-net-item .v { + font-size: 1.15em; + font-weight: 650; +} +html[data-theme="light"] .options-stats-pnl-summary .options-stat-item { + background: rgba(0, 0, 0, 0.04); +} +.options-stats-panel { + display: flex; + flex-direction: column; + gap: 10px; + min-height: 0; +} +.options-stats-charts { + display: grid; + grid-template-columns: auto 1fr; + gap: 10px 12px; + align-items: center; +} +.opt-stats-chart--ring { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} +.opt-stats-ring { + --win-pct: 0; + width: 68px; + height: 68px; + border-radius: 50%; + background: conic-gradient( + #4cd97f 0 calc(var(--win-pct) * 1%), + #ff6b6b calc(var(--win-pct) * 1%) 100% + ); + display: flex; + align-items: center; + justify-content: center; + position: relative; +} +.opt-stats-ring::before { + content: ""; + position: absolute; + inset: 8px; + border-radius: 50%; + background: #141923; +} +.opt-stats-ring-label { + position: relative; + z-index: 1; + font-size: 0.82rem; + font-weight: 700; + font-variant-numeric: tabular-nums; +} +.opt-stats-chart-caption, +.opt-stats-chart-title { + font-size: 0.66rem; + color: #9aa3bf; + text-align: center; +} +.opt-stats-chart-title { + margin-bottom: 4px; + text-align: left; +} +.opt-stats-chart--pnl, +.opt-stats-chart--hold { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} +.opt-stats-bar-row { + display: grid; + grid-template-columns: 2.2em 1fr auto; + gap: 6px; + align-items: center; + font-size: 0.72rem; +} +.opt-stats-bar-row .k { + opacity: 0.8; +} +.opt-stats-bar-row .v { + font-size: 0.7rem; + font-weight: 600; + white-space: nowrap; +} +.opt-stats-bar-track { + height: 8px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.06); + overflow: hidden; +} +.opt-stats-bar-fill { + height: 100%; + width: 0; + border-radius: 999px; + transition: width 0.25s ease; +} +.opt-stats-bar-fill--profit { + background: linear-gradient(90deg, #2f9f62, #4cd97f); +} +.opt-stats-bar-fill--loss { + background: linear-gradient(90deg, #c44a4a, #ff6b6b); +} +.options-stats-grid { + display: flex; + flex-wrap: wrap; + gap: 10px 16px; + padding: 4px 2px 8px; +} +.options-stat-item { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 52px; +} +.options-stat-item .k { + font-size: 0.66rem; + opacity: 0.75; +} +.options-stat-item .v { + font-size: 0.82rem; + font-weight: 600; + line-height: 1.25; +} +.options-page-wrap .options-stat-item .v { + font-size: 0.8rem; +} +.options-history-table-wrap .opt-history-del { + font-size: 0.68rem; + padding: 2px 7px; + min-height: 22px; +} +.options-page-wrap .opt-pos-card { + font-size: 0.76rem; + margin-bottom: 8px; +} +.opt-pos-cards--accordion { + display: flex; + flex-direction: column; + gap: 6px; +} +.opt-pos-accordion-item { + display: flex; + flex-direction: column; +} +.opt-pos-bar { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 10px; + background: #141923; + border: 1px solid #2a3348; + border-radius: 8px; + cursor: pointer; + text-align: left; + color: inherit; + font: inherit; + transition: border-color 0.15s, background 0.15s; +} +.opt-pos-bar:hover { + border-color: #3d4d6e; + background: #171d2a; +} +.opt-pos-accordion-item.is-expanded .opt-pos-bar { + border-color: #4a6fd8; + border-radius: 8px 8px 0 0; + border-bottom-color: transparent; + background: #171d2a; +} +.opt-pos-accordion-body { + overflow: visible; +} +.opt-pos-card--inline { + margin-bottom: 0 !important; + border-top: none !important; + border-radius: 0 0 8px 8px !important; +} +.opt-pos-bar-main { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1 1 auto; + overflow: hidden; +} +.opt-pos-bar-id-group { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + flex-shrink: 1; + overflow: hidden; +} +.opt-pos-bar .pos-side-badge { + display: inline-flex; + align-items: center; + white-space: nowrap; + flex-shrink: 0; + padding: 2px 6px; + font-size: 0.62rem; + line-height: 1; +} +.opt-pos-bar-meta { + font-size: 0.66rem; + color: #8b95b0; + white-space: nowrap; + flex-shrink: 0; +} +.opt-pos-bar-side { + display: flex; + flex-direction: row; + align-items: center; + gap: 10px; + flex: 0 0 auto; + margin-left: 8px; + font-variant-numeric: tabular-nums; +} +.opt-pos-bar-title { + font-size: 0.68rem; + font-weight: 600; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 0 1 auto; + min-width: 0; +} +.opt-pos-bar-cd { + font-size: 0.66rem; + color: #8b95b0; + white-space: nowrap; +} +.opt-pos-bar-pnl, +.opt-pos-bar-roi { + font-size: 0.68rem; + font-weight: 600; + white-space: nowrap; + line-height: 1.15; +} +.opt-pos-bar-chevron { + display: inline-block; + font-size: 0.58rem; + color: #8b95b0; + transition: transform 0.15s ease; + flex-shrink: 0; +} +.opt-pos-accordion-item.is-expanded .opt-pos-bar-chevron { + transform: rotate(90deg); +} +.options-page-wrap .opt-pos-card .pos-card-symbol strong { + font-size: 0.78rem; +} +.options-page-wrap .opt-pos-card .pos-label, +.options-page-wrap .opt-pos-card .pos-meta-item { + font-size: 0.7rem; +} +.options-page-wrap .opt-pos-card .pos-value { + font-size: 0.78rem; +} +.options-page-wrap .opt-pos-cell--depth { + grid-column: span 2; +} +.options-page-wrap .opt-target-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid rgba(67, 82, 118, 0.45); +} +.options-page-wrap .opt-target-row-label { + font-size: 0.72rem; + color: #9aa8c7; + min-width: 2.5em; +} +.options-page-wrap .opt-pos-target-input { + width: 110px; + max-width: 36vw; + padding: 4px 8px; + border-radius: 6px; + border: 1px solid #3a4660; + background: #0f1420; + color: #e8eefc; + font-size: 0.82rem; +} +.options-page-wrap .opt-target-row .btn-secondary { + padding: 4px 10px; + font-size: 0.75rem; +} +.options-page-wrap .opt-target-row-hint { + font-size: 0.7rem; +} +.options-page-wrap .opt-target-armed { + font-size: 0.78rem; + color: #9ad0ff; + font-variant-numeric: tabular-nums; +} +.options-page-wrap .opt-target-row--managed { + border-color: rgba(126, 231, 135, 0.38); + background: rgba(46, 160, 67, 0.08); +} +.options-page-wrap .opt-target-row--managed .opt-target-armed, +.options-page-wrap .opt-target-mon-managed { + color: #7ee787; + font-weight: 600; +} +.options-page-wrap .opt-target-est { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + font-size: 0.78rem; +} +.options-page-wrap .opt-target-est--idle:empty { + display: none; +} +.options-page-wrap .opt-target-est-item { + display: inline-flex; + align-items: baseline; + gap: 4px; +} +.options-page-wrap .opt-target-est-item .k { + color: #9aa8c7; + font-size: 0.7rem; +} +.options-page-wrap .opt-target-est-item .v { + font-variant-numeric: tabular-nums; + font-weight: 600; +} +.opt-target-monitors { + margin: 0 0 10px; + padding: 10px 12px; + border: 1px solid rgba(99, 118, 168, 0.45); + border-radius: 10px; + background: rgba(18, 28, 48, 0.75); +} +.opt-target-monitors-head { + font-size: 0.78rem; + font-weight: 600; + color: #c9d6f5; + margin-bottom: 8px; +} +.opt-target-mon-item { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 6px 0; + border-top: 1px solid rgba(67, 82, 118, 0.35); +} +.opt-target-mon-item:first-child { + border-top: 0; + padding-top: 0; +} +.opt-target-mon-inst { + font-size: 0.72rem; + color: #dbe6ff; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; +} +.opt-target-mon-rule { + font-size: 0.78rem; + color: #9ad0ff; + font-variant-numeric: tabular-nums; +} +.opt-target-mon-item .btn-secondary { + margin-left: auto; + padding: 2px 8px; + font-size: 0.7rem; +} +.opt-target-mon-item--managed { + border-color: rgba(126, 231, 135, 0.28); +} +.opt-target-mon-managed { + margin-left: auto; + font-size: 0.72rem; +} +.options-page-wrap .opt-bid-plain { + color: #dbe6ff; + font-variant-numeric: tabular-nums; + line-height: 1.35; + white-space: normal; +} +.options-page-wrap .opt-close-value { + font-weight: 700; + font-variant-numeric: tabular-nums; +} +.options-page-wrap .opt-close-rule { + margin-top: 8px; + padding: 0; + border: 1px solid rgba(67, 82, 118, 0.55); + border-radius: 10px; + background: rgba(14, 19, 30, 0.58); + overflow: hidden; +} +.options-page-wrap .opt-close-rule summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 10px; + color: #c9d6f2; + font-size: 0.72rem; + font-weight: 650; + cursor: pointer; + user-select: none; + list-style: none; +} +.options-page-wrap .opt-close-rule summary::-webkit-details-marker { + display: none; +} +.options-page-wrap .opt-close-rule summary::after { + content: "展开"; + padding: 2px 7px; + border-radius: 999px; + background: rgba(82, 101, 143, 0.25); + color: #91a4cc; + font-size: 0.62rem; + font-weight: 600; +} +.options-page-wrap .opt-close-rule[open] summary { + border-bottom: 1px solid rgba(67, 82, 118, 0.45); + background: rgba(28, 38, 60, 0.55); +} +.options-page-wrap .opt-close-rule[open] summary::after { + content: "收起"; +} +.options-page-wrap .opt-close-rule-body { + padding: 8px 10px 10px; + color: #96a4bf; + font-size: 0.7rem; + line-height: 1.55; +} +.options-page-wrap .opt-close-rule-body p { + margin: 0 0 6px; + color: #b6c2dc; +} +.options-page-wrap .opt-close-rule-body ul { + margin: 0; + padding-left: 16px; +} +.options-page-wrap .opt-close-rule-body li + li { + margin-top: 3px; +} +.options-page-wrap .opt-close-rule-body code { + color: #dbe6ff; + background: rgba(82, 101, 143, 0.22); + border-radius: 4px; + padding: 1px 4px; +} +.options-page-wrap .pos-empty { + padding: 10px; + font-size: 0.72rem; +} +.options-page-wrap #opt-order-msg { + font-size: 0.72rem; +} +.options-pos-history-card { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} +.options-history-table-wrap { + flex: 1; + overflow-y: auto; + overflow-x: auto; + min-height: 180px; + max-height: 420px; +} +.opt-history-table { + table-layout: fixed; + width: 100%; +} +.opt-history-table .opt-hist-inst code { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.opt-history-table th:nth-child(1), +.opt-history-table td:nth-child(1) { + width: 34%; +} +.opt-history-table th:nth-child(2), +.opt-history-table td:nth-child(2) { + width: 7%; +} +.opt-history-table th:nth-child(3), +.opt-history-table td:nth-child(3) { + width: 11%; +} +.opt-history-table th:nth-child(4), +.opt-history-table td:nth-child(4) { + width: 9%; +} +.opt-history-table th:nth-child(5), +.opt-history-table td:nth-child(5) { + width: 11%; +} +.opt-history-table th:nth-child(6), +.opt-history-table td:nth-child(6) { + width: 20%; +} +.opt-history-table th:nth-child(7), +.opt-history-table td:nth-child(7) { + width: 8%; + text-align: center; +} +.opt-hist-time { + font-size: 0.64rem; + white-space: nowrap; +} +.opt-hist-status { + display: inline-flex; + align-items: center; + padding: 1px 6px; + border-radius: 4px; + font-size: 0.62rem; + font-weight: 600; + line-height: 1.3; + white-space: nowrap; +} +.opt-hist-status--closed { + background: rgba(74, 124, 255, 0.2); + color: #9ec0ff; +} +.opt-hist-status--expired { + background: rgba(255, 179, 71, 0.15); + color: #ffb347; +} +.opt-hist-status--open { + background: rgba(94, 232, 154, 0.12); + color: #5ee89a; +} +@media (max-width: 1100px) { + .options-dual-grid { + grid-template-columns: 1fr; + } +} +.options-order-card h2, +.options-pos-card-wrap h2 { + margin: 0 0 8px; +} +.options-pos-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; +} +.options-pos-head h2 { + margin: 0; +} +.opt-pos-card { + margin-bottom: 10px; +} +.opt-expiry-cd { + font-variant-numeric: tabular-nums; + font-weight: 600; +} +.opt-expiry-cd--urgent { + color: #ffb347; +} +.opt-expiry-cd--expired { + color: var(--muted); +} +.settings-card--compact .options-settings-section { + margin-bottom: 8px; +} + +.settings-card--compact .options-settings-section:last-child { + margin-bottom: 0; +} + +.options-settings-block { + margin-bottom: 14px; +} +.options-settings-section { + margin-bottom: 10px; +} +.options-settings-section:last-child { + margin-bottom: 0; +} +.options-settings-subtitle { + font-size: 0.72rem; + font-weight: 600; + color: #a8b0cc; + margin: 0 0 6px; +} +.options-settings-arrow { + font-size: 0.75rem; + color: #8892b0; + align-self: center; +} +.options-settings-row { + flex-wrap: wrap; + gap: 6px; + align-items: center; +} +.options-settings-row select, +.options-settings-row input[type="number"] { + font-size: 0.75rem; + padding: 4px 7px; + min-height: 28px; +} +.options-settings-row .btn-sm { + font-size: 0.72rem; + padding: 4px 10px; + min-height: 28px; +} +.options-settings-hint { + font-size: 0.72rem; + margin: 0 0 6px; + line-height: 1.45; +} +.options-settings-msg { + font-size: 0.68rem; + margin-top: 2px; + min-height: 1em; + line-height: 1.35; +} +html[data-theme="light"] .stat-strip-item--pnl .value.pnl-pos { + color: #087a50 !important; + font-weight: 700 !important; +} + +html[data-theme="light"] .stat-strip-item--pnl .value.pnl-neg { + color: #c03030 !important; + font-weight: 700 !important; +} + +html[data-theme="light"] .btn-secondary { + background: #fff !important; + color: #004d6e !important; + border: 1px solid rgba(0, 95, 140, 0.32) !important; +} + +html[data-theme="light"] .btn-secondary:hover { + background: #eef3f8 !important; +} + +html[data-theme="light"] .btn-primary { + background: linear-gradient(90deg, #007aa8, #5b4fc7) !important; + color: #fff !important; + border: none !important; +} + +html[data-theme="light"] code { + background: #eef3f8; + color: #142232; + border: 1px solid #c8d4e0; + padding: 1px 4px; + border-radius: 4px; + font-size: 0.92em; +} + +html[data-theme="light"] .card-nested, +html[data-theme="light"] .options-section.card-nested, +html[data-theme="light"] .options-pos-subcard { + background: #f6f9fc !important; + border: 1px solid #c8d4e0 !important; +} + +html[data-theme="light"] .options-strike-table thead th { + background: #eef3f8 !important; + color: #334155 !important; + border-bottom: 1px solid #c8d4e0 !important; +} + +html[data-theme="light"] .options-strike-table th, +html[data-theme="light"] .options-strike-table td { + border-bottom-color: #d0dae4 !important; + color: #142232 !important; +} + +html[data-theme="light"] .options-page-wrap .options-pos-subcard h3, +html[data-theme="light"] .options-settings-subtitle { + color: #142232 !important; +} + +html[data-theme="light"] .opt-pos-bar { + background: #f6f9fc; + border-color: #c8d4e0; + color: #142232; +} +html[data-theme="light"] .opt-pos-bar:hover, +html[data-theme="light"] .opt-pos-accordion-item.is-expanded .opt-pos-bar { + background: #eef3f8; + border-color: #9eb0c4; +} +html[data-theme="light"] .opt-pos-bar-title { + color: #142232; +} +html[data-theme="light"] .opt-pos-bar-meta, +html[data-theme="light"] .opt-pos-bar-cd, +html[data-theme="light"] .opt-pos-bar-chevron { + color: #5a6d82; +} +html[data-theme="light"] .opt-pos-accordion-body { + background: #f6f9fc; + border-color: #c8d4e0; +} + +html[data-theme="light"] .options-page-wrap .options-hint, +html[data-theme="light"] .options-page-wrap #opt-index-line, +html[data-theme="light"] .options-order-grid .k, +html[data-theme="light"] .options-stat-item .k { + color: #3a5068 !important; + opacity: 1 !important; +} + +html[data-theme="light"] .options-page-wrap .options-order-grid .v, +html[data-theme="light"] .options-page-wrap .options-stat-item .v { + color: #142232 !important; +} + +html[data-theme="light"] .options-chain-toolbar .btn-secondary.active, +html[data-theme="light"] .opt-uly-btn.active, +html[data-theme="light"] .opt-type-btn.active, +html[data-theme="light"] .opt-money-btn.active, +html[data-theme="light"] .opt-pos-tab.active { + border-color: rgba(0, 95, 140, 0.45) !important; + color: #004d6e !important; + background: rgba(0, 110, 154, 0.14) !important; + box-shadow: inset 0 0 0 1px rgba(0, 95, 140, 0.22) !important; +} + +html[data-theme="light"] .opt-stats-ring::before { + background: #f4f7fb; +} +html[data-theme="light"] .opt-stats-bar-track { + background: rgba(20, 34, 50, 0.08); +} +html[data-theme="light"] .opt-stats-chart-caption, +html[data-theme="light"] .opt-stats-chart-title { + color: #5a6a80 !important; +} +html[data-theme="light"] .opt-moneyness-itm { + color: #087a50 !important; + background: rgba(8, 122, 80, 0.12) !important; +} + +html[data-theme="light"] .opt-moneyness-otm { + color: #4a6078 !important; + background: rgba(74, 96, 120, 0.1) !important; +} + +html[data-theme="light"] .opt-moneyness-atm { + color: #8a6200 !important; + background: rgba(180, 130, 20, 0.12) !important; +} + +html[data-theme="light"] .opt-strike-row.opt-row-selected td { + background: rgba(0, 110, 154, 0.08) !important; +} + +html[data-theme="light"] .pos-pnl-profit { + color: #087a50 !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .pos-pnl-loss { + color: #c03030 !important; + font-weight: 600 !important; +} + +/* 期权持仓 · 亮色主题对比度 */ +html[data-theme="light"] .options-page-wrap .opt-pos-card { + background: #fff !important; + border-color: #94a3b8 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-pos-card .pos-label, +html[data-theme="light"] .options-page-wrap .opt-pos-card .pos-meta, +html[data-theme="light"] .options-page-wrap .opt-pos-card .pos-meta-item { + color: #0f172a !important; + font-weight: 500 !important; + opacity: 1 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-pos-card .pos-meta-item::after { + color: #64748b !important; +} + +html[data-theme="light"] .options-page-wrap .opt-pos-card .pos-value { + color: #020617 !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-bid-plain { + color: #0f172a !important; +} + +html[data-theme="light"] .options-page-wrap .opt-close-value { + color: #9f1239 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-bid-invalid-hint, +html[data-theme="light"] .options-page-wrap .opt-pos-card .muted { + color: #334155 !important; + opacity: 1 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-target-row { + border-top-color: #94a3b8 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-target-row-label { + color: #0f172a !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-pos-target-input { + background: #fff !important; + color: #0f172a !important; + border-color: #64748b !important; +} + +html[data-theme="light"] .options-page-wrap .opt-pos-target-input::placeholder { + color: #64748b !important; + opacity: 1 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-target-row .btn-secondary { + color: #004d6e !important; + border-color: #007aa8 !important; + background: #e8f4fa !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-target-armed { + color: #004d6e !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-target-est-item .k { + color: #334155 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-target-est-item .v { + color: #0f172a !important; +} + +html[data-theme="light"] .options-page-wrap .opt-target-row-hint { + color: #334155 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-expiry-cd { + color: #004d6e !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-expiry-cd--urgent { + color: #9a6200 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-expiry-cd--expired { + color: #475569 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-pos-tab:not(.active) { + color: #0f172a !important; + border-color: #64748b !important; + background: #fff !important; + font-weight: 500 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-pos-tab.active { + color: #003d57 !important; + border-color: #006e9a !important; + background: rgba(0, 110, 154, 0.16) !important; + font-weight: 700 !important; +} + +html[data-theme="light"] .options-page-wrap .options-pos-head h2, +html[data-theme="light"] .options-page-wrap .options-pos-card-wrap h2 { + color: #020617 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-close-rule { + border: 1px solid #64748b !important; + background: #e2e8f0 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-close-rule summary { + color: #0f172a !important; + background: #cbd5e1 !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-close-rule summary::after { + background: rgba(0, 95, 140, 0.18) !important; + color: #003d57 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-close-rule[open] summary { + background: #b8c8d8 !important; + border-bottom-color: #64748b !important; +} + +html[data-theme="light"] .options-page-wrap .opt-close-rule-body { + color: #1e293b !important; +} + +html[data-theme="light"] .options-page-wrap .opt-close-rule-body p { + color: #0f172a !important; +} + +html[data-theme="light"] .options-page-wrap .opt-close-rule-body li { + color: #1e293b !important; +} + +html[data-theme="light"] .options-page-wrap .opt-close-rule-body code { + color: #0f172a !important; + background: #fff !important; + border: 1px solid #94a3b8 !important; +} + +html[data-theme="light"] .options-page-wrap .pos-empty { + color: #334155 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-target-monitors { + background: #eef4fa !important; + border: 1px solid #94a3b8 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-target-monitors-head { + color: #0f172a !important; +} + +html[data-theme="light"] .options-page-wrap .opt-target-mon-item { + border-top-color: #cbd5e1 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-target-mon-inst { + color: #1e293b !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-target-mon-rule { + color: #004d6e !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .options-page-wrap .opt-target-mon-item .btn-secondary { + color: #004d6e !important; + border-color: #007aa8 !important; + background: #fff !important; + font-weight: 600 !important; +} + +html[data-theme="light"] .order-preview-profit { + color: #087a50 !important; +} + +html[data-theme="light"] .order-preview-risk { + color: #c03030 !important; +} + +html[data-theme="light"] .instance-header-panel { + background: #fff !important; + border: 1px solid #9eb0c4 !important; + box-shadow: 0 1px 3px rgba(20, 34, 50, 0.06); +} + +html[data-theme="light"] .settings-account-summary { + background: #fff !important; + border: 1px solid #9eb0c4 !important; + box-shadow: 0 1px 3px rgba(20, 34, 50, 0.06); +} + +.pos-pnl-profit { + color: #7ee787; +} +.pos-pnl-loss { + color: #ff8b8b; +} + diff --git a/lib/common/static/instance_theme.js b/lib/common/static/instance_theme.js new file mode 100644 index 0000000..c4b70af --- /dev/null +++ b/lib/common/static/instance_theme.js @@ -0,0 +1,570 @@ +/** + * 三所实例主题:默认暗色;单独登录用 instance-theme;中控 iframe/SSO 随 hub-theme 联动. + */ +(function (global) { + const STANDALONE_KEY = "instance-theme"; + const HUB_LINKED_THEME_KEY = "hub-linked-theme"; + const META = { dark: "#0b0d14", light: "#c8d4de" }; + + function normalize(theme) { + return theme === "light" ? "light" : "dark"; + } + + function isHubLinked() { + try { + if (window.self !== window.top) return true; + } catch (_) { + return true; + } + return false; + } + + function themeFromUrl() { + try { + const t = new URLSearchParams(location.search).get("hub_theme"); + if (t === "light" || t === "dark") return t; + } catch (_) {} + return null; + } + + function readLinkedThemeStorage() { + try { + const t = sessionStorage.getItem(HUB_LINKED_THEME_KEY); + if (t === "light" || t === "dark") return t; + } catch (_) {} + return null; + } + + function writeLinkedThemeStorage(theme) { + if (!isHubLinked()) return; + try { + sessionStorage.setItem(HUB_LINKED_THEME_KEY, normalize(theme)); + } catch (_) {} + } + + function getStandalone() { + try { + return normalize(localStorage.getItem(STANDALONE_KEY)); + } catch (_) { + return "dark"; + } + } + + function setStandalone(theme) { + try { + localStorage.setItem(STANDALONE_KEY, normalize(theme)); + } catch (_) {} + } + + let _linkedTheme = null; + let _appliedTheme = null; + + function get() { + if (isHubLinked()) { + return themeFromUrl() || _linkedTheme || readLinkedThemeStorage() || "dark"; + } + return getStandalone(); + } + + /** 模板内联暗色 → 亮色(切换时重写 style 属性) */ + const INLINE_HEX_LIGHT = { + "#cfd3ef": "#1a2838", + "#8892b0": "#4a6078", + "#9aa3c4": "#4a6078", + "#8b95a8": "#4a6078", + "#8b95b8": "#4a6078", + "#6a7598": "#4a6078", + "#7d8799": "#4a6078", + "#6d7689": "#4a6078", + "#dbe4ff": "#142232", + "#f0f2ff": "#142232", + "#e8ecf4": "#142232", + "#c5cce0": "#4a6078", + "#b8c4ff": "#142232", + "#8fc8ff": "#006e9a", + "#6ab8ff": "#006e9a", + "#6eb5ff": "#006e9a", + "#101522": "#ffffff", + "#121726": "#ffffff", + "#141423": "#ffffff", + "#24243b": "#b8c8d8", + "#252a45": "#b8c8d8", + "#252538": "#eef3f8", + "#1a1a29": "#f6f9fc", + "#2e2e45": "#b8c8d8", + "#2b2b43": "#d0dae4", + "#151a2a": "#eef3f8", + "#141a2a": "#ffffff", + "#141923": "#ffffff", + "#141a2e": "#ffffff", + "#0f1424": "#f6f9fc", + "#0f1420": "#f6f9fc", + "#0f1117": "#d8e2ec", + "#1a2034": "#eef3f8", + "#1a2030": "#ffffff", + "#1f3a5a": "#e8eef5", + "#2f2f44": "#dde5ec", + "#2a3f6c": "rgba(0,110,154,0.14)", + "#304164": "rgba(0,95,140,0.22)", + "#2a3150": "#b8c8d8", + "#2a3152": "#b8c8d8", + "#3a5a8a": "rgba(0,95,140,0.35)", + "#2a3348": "#b8c8d8", + "#243050": "rgba(0,75,115,0.16)", + "#2a3558": "#d0dae4", + "#3a4468": "#c8d4e0", + "#3a4a66": "#b8c8d8", + "#3a3f52": "#dde5ec", + "#3d4659": "#b8c8d8", + "#1f2740": "#eef3f8", + "#1f2a44": "rgba(0,110,154,0.1)", + "#1f4a3a": "#e8f5ef", + "#2a4a7a": "#e8eef5", + "#3a3048": "#eef3f8", + "#d4b8ff": "#5b4fc7", + "#e6e8ef": "#1a2838", + }; + + function remapInlineStyle(style, theme) { + if (!style) return style; + if (theme !== "light") return style; + const hadSecondaryBtnBg = /#1f3a5a/i.test(style); + let out = style; + for (const [from, to] of Object.entries(INLINE_HEX_LIGHT)) { + out = out.replace(new RegExp(from.replace("#", "\\#"), "gi"), to); + } + if (hadSecondaryBtnBg && !/color\s*:/i.test(style)) { + out = `${out.replace(/;+\s*$/, "")};color:#006e9a`; + } + return out; + } + + function syncInlineStyles(theme, root) { + const scope = root || document; + scope.querySelectorAll("[style]").forEach((el) => { + const raw = el.getAttribute("style"); + if (!raw) return; + if (!el.dataset.instStyleBase) { + el.dataset.instStyleBase = raw; + } + const base = el.dataset.instStyleBase; + el.setAttribute("style", theme === "light" ? remapInlineStyle(base, "light") : base); + }); + } + + function mergeHubQueryIntoHref(href, theme) { + if (!href || href.startsWith("#") || href.startsWith("javascript:")) return href; + try { + const u = new URL(href, location.origin); + if (u.origin !== location.origin) return href; + if (isHubLinked()) { + u.searchParams.set("embed", "1"); + if (theme === "light" || theme === "dark") { + u.searchParams.set("hub_theme", theme); + } + } + return u.pathname + u.search + u.hash; + } catch (_) { + return href; + } + } + + function patchHubNavLinks(theme) { + if (!isHubLinked()) return; + const t = normalize(theme || get()); + document + .querySelectorAll(".top-nav a[href], .strategy-subnav a[href]") + .forEach((a) => { + const href = a.getAttribute("href"); + if (!href) return; + const next = mergeHubQueryIntoHref(href, t); + if (next !== href) a.setAttribute("href", next); + }); + } + + function apply(theme, opts) { + const options = opts || {}; + const linked = isHubLinked(); + const t = normalize(theme); + const root = document.documentElement; + const unchanged = + !options.force && + _appliedTheme === t && + root.getAttribute("data-theme") === t; + if (unchanged) { + return t; + } + _appliedTheme = t; + if (linked) { + _linkedTheme = t; + writeLinkedThemeStorage(t); + root.setAttribute("data-hub-linked", "1"); + } else { + root.removeAttribute("data-hub-linked"); + } + if (!linked && !options.skipStore) { + setStandalone(t); + } + root.setAttribute("data-theme", t); + const meta = document.querySelector('meta[name="theme-color"]'); + if (meta) meta.setAttribute("content", META[t]); + root.style.colorScheme = t; + if (document.body) { + syncInlineStyles(t); + patchHubNavLinks(t); + } else { + document.addEventListener( + "DOMContentLoaded", + function onDom() { + syncInlineStyles(t); + patchHubNavLinks(t); + }, + { once: true } + ); + } + syncToggleUI(); + document.dispatchEvent( + new CustomEvent("instance-theme-change", { detail: { theme: t, hubLinked: linked } }) + ); + return t; + } + + function syncToggleUI(root) { + const scope = root || document; + const linked = isHubLinked(); + const toggle = scope.querySelector(".instance-theme-toggle"); + if (toggle) { + toggle.classList.toggle("is-hub-linked", linked); + toggle.setAttribute("aria-hidden", linked ? "true" : "false"); + } + if (linked) return; + scope.querySelectorAll(".theme-toggle-btn[data-theme-value]").forEach((btn) => { + const on = btn.getAttribute("data-theme-value") === getStandalone(); + btn.classList.toggle("is-active", on); + btn.setAttribute("aria-pressed", on ? "true" : "false"); + }); + } + + function initToggleUI(root) { + const scope = root || document; + syncToggleUI(scope); + scope.querySelectorAll(".theme-toggle-btn[data-theme-value]").forEach((btn) => { + if (btn.dataset.themeBound === "1") return; + btn.dataset.themeBound = "1"; + btn.addEventListener("click", () => { + if (isHubLinked()) return; + apply(btn.getAttribute("data-theme-value")); + }); + }); + } + + function initMobileTopNav() { + const mq = window.matchMedia("(max-width: 720px)"); + + function scrollActiveTab(nav) { + const active = nav.querySelector("a.active"); + if (!active) return; + requestAnimationFrame(() => { + try { + active.scrollIntoView({ inline: "center", block: "nearest", behavior: "instant" }); + } catch (_) { + active.scrollIntoView(false); + } + }); + } + + function apply() { + if (!mq.matches) return; + document.querySelectorAll(".top-nav").forEach(scrollActiveTab); + } + + apply(); + mq.addEventListener("change", apply); + window.addEventListener("resize", apply); + window.addEventListener("orientationchange", apply); + } + + function initFromHubMessage(data) { + if (!data || data.type !== "hub-theme-sync") return; + if (!isHubLinked()) return; + apply(data.theme, { skipStore: true }); + } + + /** 交易记录页:核对开关与按钮 disabled 保持同步(含 iframe 软导航后动态挂载的 toggle) */ + function syncReviewEditButtons() { + const toggle = document.getElementById("review-mode-toggle"); + if (!toggle) return; + const on = !!toggle.checked; + document.querySelectorAll(".review-edit-btn").forEach((btn) => { + btn.disabled = !on; + }); + } + + function initReviewEditModeSync() { + if (!global.__instReviewModeBound) { + global.__instReviewModeBound = true; + const onToggle = () => { + if (typeof global.toggleReviewMode === "function") global.toggleReviewMode(); + else syncReviewEditButtons(); + }; + document.addEventListener("change", (ev) => { + if (ev.target && ev.target.id === "review-mode-toggle") onToggle(); + }); + document.addEventListener("input", (ev) => { + if (ev.target && ev.target.id === "review-mode-toggle") onToggle(); + }); + } + const run = () => { + if (typeof global.toggleReviewMode === "function") global.toggleReviewMode(); + else syncReviewEditButtons(); + }; + run(); + requestAnimationFrame(run); + setTimeout(run, 0); + if (!global.__instReviewModePageshowBound) { + global.__instReviewModePageshowBound = true; + window.addEventListener("pageshow", run); + } + } + + function notifyParentFrameNavStart() { + if (!isHubLinked()) return; + try { + window.parent.postMessage({ type: "instance-frame-navigating", theme: get() }, "*"); + } catch (_) {} + } + + function notifyParentFrameReady() { + if (!isHubLinked()) return; + dismissNavOverlay(); + try { + window.parent.postMessage({ type: "instance-frame-ready", theme: get() }, "*"); + } catch (_) {} + } + + function ensureNavOverlay() { + const t = normalize(get()); + const bg = META[t]; + let el = document.getElementById("inst-nav-overlay"); + if (!el) { + el = document.createElement("div"); + el.id = "inst-nav-overlay"; + el.setAttribute("aria-hidden", "true"); + (document.body || document.documentElement).appendChild(el); + } + el.style.cssText = + "position:fixed;inset:0;z-index:2147483646;background:" + + bg + + ";opacity:1;pointer-events:auto;transition:opacity 80ms ease;"; + return el; + } + + function dismissNavOverlay() { + const el = document.getElementById("inst-nav-overlay"); + if (!el) return; + el.style.opacity = "0"; + window.setTimeout(() => { + try { + el.remove(); + } catch (_) {} + }, 90); + } + + function injectNavOverlayIntoHtml(html, theme) { + const t = normalize(theme || get()); + const bg = META[t]; + let out = html || ""; + const guard = + ''; + if (out.includes("")) { + out = out.replace("", guard + ""); + } else { + out = guard + out; + } + out = out.replace(/]*)>/i, (m, attrs) => { + if (/data-theme=/i.test(attrs)) { + return m.replace(/data-theme="[^"]*"/i, 'data-theme="' + t + '"'); + } + return "'; + }); + const overlay = + ''; + if (/]*>/i.test(out)) { + out = out.replace(/]*)>/i, "" + overlay); + } + return out; + } + + /** 中控 iframe:fetch 换页 + 页内遮罩,避免整页卸载与中控侧长时间空白. */ + function initHubEmbedInFrameNav() { + if (!isHubLinked()) return; + if (document.body && document.body.getAttribute("data-embed-shell") === "1") return; + + let navToken = 0; + + function isSoftNavLink(a) { + if (!a || !a.getAttribute) return false; + if (a.hasAttribute("download") || a.target === "_blank") return false; + return !!a.closest(".top-nav, .strategy-subnav"); + } + + function softNavFetch(href) { + return fetch(href, { + credentials: "same-origin", + headers: { "X-Instance-Soft-Nav": "1" }, + }); + } + + async function navigateInFrame(href, opts) { + const token = ++navToken; + notifyParentFrameNavStart(); + ensureNavOverlay(); + try { + const r = await softNavFetch(href); + if (token !== navToken) return; + if (!r.ok) { + location.assign(href); + return; + } + let html = await r.text(); + if (token !== navToken) return; + html = injectNavOverlayIntoHtml(html, get()); + let path = href; + try { + const u = new URL(href, location.href); + path = u.pathname + u.search + u.hash; + } catch (_) {} + if (opts && opts.replace) history.replaceState(null, "", path); + else history.pushState(null, "", path); + document.open(); + document.write(html); + document.close(); + } catch (_) { + if (token === navToken) location.assign(href); + } + } + + document.addEventListener( + "click", + (ev) => { + const a = ev.target.closest("a[href]"); + if (!a || !isSoftNavLink(a) || ev.defaultPrevented) return; + if (ev.button !== 0 || ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) return; + const rawHref = a.getAttribute("href"); + if (!rawHref || rawHref.startsWith("#") || rawHref.startsWith("javascript:")) return; + let target; + try { + target = new URL(rawHref, location.href); + } catch (_) { + return; + } + if (target.origin !== location.origin) return; + const nextHref = target.pathname + target.search + target.hash; + if (target.pathname === location.pathname && target.search === location.search) return; + ev.preventDefault(); + void navigateInFrame(nextHref); + }, + true + ); + + window.addEventListener("popstate", () => { + void navigateInFrame(location.pathname + location.search + location.hash, { replace: true }); + }); + } + + function purgeLegacySoftNavCache() { + try { + for (let i = localStorage.length - 1; i >= 0; i -= 1) { + const key = localStorage.key(i); + if (!key) continue; + if ( + key.startsWith("inst-pc:") || + key === "inst-page-cache-index" || + key === "inst-page-cache-days" + ) { + localStorage.removeItem(key); + } + } + sessionStorage.removeItem("inst-soft-nav"); + sessionStorage.removeItem("inst-cache-revalidate"); + } catch (_) {} + } + + function boot() { + purgeLegacySoftNavCache(); + if (isHubLinked()) { + apply(get(), { skipStore: true }); + window.addEventListener("message", (ev) => initFromHubMessage(ev.data)); + initHubEmbedInFrameNav(); + try { + window.parent.postMessage({ type: "instance-theme-ready" }, "*"); + } catch (_) {} + } else { + apply(getStandalone()); + } + + function observeDynamicLists() { + ["journal-list", "review-list"].forEach((id) => { + const el = document.getElementById(id); + if (!el || el.dataset.instThemeObserved === "1") return; + el.dataset.instThemeObserved = "1"; + new MutationObserver(() => { + syncInlineStyles(get()); + patchHubNavLinks(get()); + }).observe(el, { + childList: true, + subtree: true, + }); + }); + } + + const onReady = () => { + initToggleUI(); + initMobileTopNav(); + initReviewEditModeSync(); + syncInlineStyles(get()); + patchHubNavLinks(get()); + observeDynamicLists(); + if (isHubLinked()) { + requestAnimationFrame(() => { + requestAnimationFrame(() => notifyParentFrameReady()); + }); + } + }; + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", onReady); + } else { + onReady(); + } + document.addEventListener("instance-theme-change", (ev) => { + const t = ev.detail && ev.detail.theme; + if (t) { + syncInlineStyles(t); + patchHubNavLinks(t); + } + }); + } + + boot(); + + global.InstanceTheme = { + STANDALONE_KEY, + HUB_LINKED_THEME_KEY, + isHubLinked, + get, + apply, + initToggleUI, + syncToggleUI, + syncInlineStyles, + patchHubNavLinks, + mergeHubQueryIntoHref, + syncReviewEditButtons, + initReviewEditModeSync, + }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/instance_theme_early.css b/lib/common/static/instance_theme_early.css new file mode 100644 index 0000000..872f586 --- /dev/null +++ b/lib/common/static/instance_theme_early.css @@ -0,0 +1,54 @@ +/* 紧接 instance_theme.js 之后加载,避免亮色下先闪暗色底 */ +html { + background: #0b0d14; + color-scheme: dark; +} + +html[data-theme="light"] { + background: #c8d4de; + color-scheme: light; +} + +html[data-theme="light"] body { + background: #c8d4de !important; + color: #142232 !important; +} + +.review-edit-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +html[data-theme="light"] .header h1 { + color: #142232 !important; +} + +html[data-theme="light"] .top-nav a, +html[data-theme="light"] .embed-top-nav a, +html[data-theme="light"] .strategy-subnav a { + background: #fff !important; + color: #006e9a !important; + border-color: rgba(0, 95, 140, 0.22) !important; +} + +html[data-theme="light"] .top-nav a:hover, +html[data-theme="light"] .embed-top-nav a:hover, +html[data-theme="light"] .strategy-subnav a:hover { + background: rgba(0, 110, 154, 0.1) !important; + color: #004d6e !important; +} + +html[data-theme="light"] .top-nav a.active, +html[data-theme="light"] .embed-top-nav a.active, +html[data-theme="light"] .strategy-subnav a.active { + background: rgba(0, 110, 154, 0.12) !important; + color: #004d6e !important; + border: 1px solid rgba(0, 95, 140, 0.28) !important; + font-weight: 600; +} + +html[data-theme="light"] .card, +html[data-theme="light"] .stat-item { + background: #fff !important; + border-color: #b8c8d8 !important; +} diff --git a/lib/common/static/instance_ui.js b/lib/common/static/instance_ui.js new file mode 100644 index 0000000..158078e --- /dev/null +++ b/lib/common/static/instance_ui.js @@ -0,0 +1,456 @@ +/** + * 三所实例共用 UI:复盘详情,盈亏着色等. + */ +(function (global) { + "use strict"; + + function escapeHtml(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function pnlClassFromValue(val) { + const n = Number(String(val == null ? "" : val).replace(/[^\d.-]/g, "")); + if (!Number.isFinite(n) || n === 0) return ""; + return n > 0 ? "pnl-profit" : "pnl-loss"; + } + + function formatPnlSpan(val, suffix) { + const sfx = suffix == null ? "U" : suffix; + const cls = pnlClassFromValue(val); + const text = escapeHtml(val == null || val === "" ? "-" : val) + sfx; + return cls ? `${text}` : text; + } + + function buildJournalDetailHtml(o, formatExitLine) { + const moodTags = + Array.isArray(o.mood_issues) && o.mood_issues.length + ? o.mood_issues.join(",") + : o.mood_issues || "无"; + const exitText = + typeof formatExitLine === "function" ? formatExitLine(o) : o.exit_reason || "无"; + const lines = [ + `币种/周期:${escapeHtml(o.coin || "-")} ${escapeHtml(o.tf || "-")}`, + `方向:${escapeHtml((function(){ const d = inferJournalDirection(o); return d ? d.text : "-"; })())}`, + `开仓时间:${escapeHtml(o.open_datetime || "-")}`, + `平仓时间:${escapeHtml(o.close_datetime || "-")}`, + `持仓时长:${escapeHtml(o.hold_duration || "-")}`, + `盈亏:${formatPnlSpan(o.pnl)}`, + `下单类型:${escapeHtml(o.order_type || "无")}`, + `开仓类型:${escapeHtml(o.entry_reason || "无")}`, + `平仓/离场:${escapeHtml(exitText)}`, + `预期RR:${escapeHtml(o.expect_rr || "-")}`, + `实际RR:${escapeHtml(o.real_rr || "-")}`, + `保本后盯盘:${escapeHtml(o.post_breakeven_stare || "-")}`, + `心态标签:${escapeHtml(moodTags)}`, + `备注:${escapeHtml(o.note || "无")}`, + ]; + return lines.join("
        "); + } + + function resolveJournalImages(o) { + if (Array.isArray(o.images) && o.images.length) return o.images; + if (o.image) return [{ tf: "", file: o.image }]; + return []; + } + + function setJournalDetailImages(o) { + const grid = document.getElementById("detailImages"); + const legacyImg = document.getElementById("detailImage"); + const images = resolveJournalImages(o || {}); + + if (grid) { + if (!images.length) { + grid.innerHTML = ""; + grid.style.display = "none"; + } else { + grid.innerHTML = images + .map(function (img) { + const tf = String(img.tf || "").trim(); + const file = String(img.file || "").trim(); + if (!file) return ""; + const label = tf ? escapeHtml(tf) : "截图"; + const src = "/static/images/" + encodeURIComponent(file).replace(/%2F/g, "/"); + return ( + '
        ' + + '' + + label + + "" + + '' +
+              label +
+              '' + + "
        " + ); + }) + .join(""); + grid.style.display = "grid"; + } + if (legacyImg) { + legacyImg.src = ""; + legacyImg.style.display = "none"; + } + return; + } + + if (legacyImg) { + if (images.length === 1) { + legacyImg.src = "/static/images/" + images[0].file; + legacyImg.style.display = "block"; + } else { + legacyImg.src = ""; + legacyImg.style.display = "none"; + } + } + } + + function clearJournalDetailImages() { + const grid = document.getElementById("detailImages"); + if (grid) { + grid.innerHTML = ""; + grid.style.display = "none"; + } + const legacyImg = document.getElementById("detailImage"); + if (legacyImg) { + legacyImg.src = ""; + legacyImg.style.display = "none"; + } + } + + function setJournalDetailBody(o, formatExitLine) { + const body = document.getElementById("detailBody"); + if (!body) return; + body.classList.remove("md-review", "trade-record-detail-wrap"); + body.classList.add("journal-detail-meta"); + body.innerHTML = buildJournalDetailHtml(o, formatExitLine); + } + + function openJournalDetailModal(id, journalCache, formatExitLine) { + const o = journalCache && journalCache[id]; + if (!o) return; + const titleEl = document.getElementById("detailTitle"); + if (titleEl) { + titleEl.innerText = `交易复盘详情|${o.coin || "-"} ${o.tf || "-"}`; + } + setJournalDetailBody(o, formatExitLine); + clearDetailActions(); + setJournalDetailImages(o); + if (typeof setDetailModalFullscreen === "function") { + setDetailModalFullscreen(false); + } + const modal = document.getElementById("detailModal"); + if (modal) modal.style.display = "flex"; + } + + function isMobileCompactRecords() { + if (typeof window === "undefined" || !window.matchMedia) return false; + return window.matchMedia("(max-width: 720px)").matches; + } + + function inferJournalDirection(o) { + const hint = String((o && (o.direction_hint || o.direction)) || "").toLowerCase(); + if (hint === "long" || hint === "buy" || hint === "多") { + return { text: "做多", cls: "direction-long" }; + } + if (hint === "short" || hint === "sell" || hint === "空") { + return { text: "做空", cls: "direction-short" }; + } + const text = String((o && (o.entry_reason || o.note)) || ""); + if (/做空|空头|short/i.test(text)) { + return { text: "做空", cls: "direction-short" }; + } + if (/做多|多头|long/i.test(text)) { + return { text: "做多", cls: "direction-long" }; + } + return null; + } + + function renderJournalListHtml(data) { + if (!data || !data.length) return ""; + const mobile = isMobileCompactRecords(); + if (mobile) { + return data + .map(function (o) { + const dir = inferJournalDirection(o); + const pnlCls = pnlClassFromValue(o.pnl); + const dirHtml = dir + ? `${escapeHtml(dir.text)}` + : `-`; + const id = escapeHtml(o.id); + return `
        + + +
        `; + }) + .join(""); + } + const rows = data + .map(function (o) { + const moodTags = Array.isArray(o.mood_issues) + ? o.mood_issues.join(",") + : o.mood_issues || ""; + const mood = moodTags || "无"; + const id = escapeHtml(o.id); + const pnlCls = pnlClassFromValue(o.pnl); + const pnlTxt = + o.pnl == null || o.pnl === "" ? "-" : String(o.pnl); + const dir = inferJournalDirection(o); + const dirHtml = dir + ? `${escapeHtml(dir.text)}` + : "-"; + return ` + ${escapeHtml(o.coin || "-")} + ${escapeHtml(o.tf || "-")} + ${dirHtml} + ${escapeHtml(o.order_type || "-")} + ${escapeHtml(o.entry_reason || "-")} + ${escapeHtml(pnlTxt)} + ${escapeHtml((o.open_datetime || "-").toString().slice(0, 16))} + ${escapeHtml((o.close_datetime || "-").toString().slice(0, 16))} + ${escapeHtml(o.hold_duration || "-")} + ${escapeHtml(mood)} + + + + + `; + }) + .join(""); + return `
        + + + + + ${rows} +
        品种周期方向下单类型开仓类型盈亏U开仓时间平仓时间持仓心态标签操作
        `; + } + + function parseTradeRecordRow(tr) { + const cells = tr.querySelectorAll("td"); + if (cells.length < 15) return null; + const dirBadge = cells[3].querySelector(".badge"); + return { + rowId: tr.id, + symbol: cells[0].textContent.trim(), + type: cells[1].textContent.trim(), + entryReason: cells[2].textContent.trim(), + directionHtml: (dirBadge ? dirBadge.outerHTML : cells[3].innerHTML).trim(), + directionText: cells[3].textContent.trim(), + trigger: cells[4].textContent.trim(), + stopLoss: cells[5].textContent.trim(), + takeProfit: cells[6].textContent.trim(), + margin: cells[7].textContent.trim(), + leverage: cells[8].textContent.trim(), + holdMinutes: cells[9].textContent.trim(), + openedAt: cells[10].textContent.trim(), + closedAt: cells[11].textContent.trim(), + pnlHtml: cells[12].innerHTML.trim(), + pnlText: cells[12].textContent.trim(), + resultHtml: cells[13].innerHTML.trim(), + resultText: cells[13].textContent.trim(), + actionsHtml: cells[14].innerHTML, + }; + } + + function renderMobileTradeRow(tr) { + const row = parseTradeRecordRow(tr); + if (!row) return ""; + const pnlCls = pnlClassFromValue(row.pnlText); + return ``; + } + + function tradeDetailRow(label, valueHtml) { + return `
        ${escapeHtml(label)}${valueHtml}
        `; + } + + function buildTradeRecordDetailHtml(row) { + return `
        ${ + tradeDetailRow("品种", escapeHtml(row.symbol)) + + tradeDetailRow("下单类型", escapeHtml(row.type)) + + tradeDetailRow("开仓类型", escapeHtml(row.entryReason || "-")) + + tradeDetailRow("方向", row.directionHtml) + + tradeDetailRow("成交价", escapeHtml(row.trigger)) + + tradeDetailRow("止损(开仓)", escapeHtml(row.stopLoss)) + + tradeDetailRow("止盈", escapeHtml(row.takeProfit)) + + tradeDetailRow("基数", escapeHtml(row.margin)) + + tradeDetailRow("杠杆", escapeHtml(row.leverage)) + + tradeDetailRow("持仓分钟", escapeHtml(row.holdMinutes)) + + tradeDetailRow("开仓时间", escapeHtml(row.openedAt)) + + tradeDetailRow("平仓时间", escapeHtml(row.closedAt)) + + tradeDetailRow("盈亏U", row.pnlHtml) + + tradeDetailRow("结果", row.resultHtml) + }
        `; + } + + function clearDetailActions() { + const el = document.getElementById("detailActions"); + if (el) { + el.innerHTML = ""; + el.style.display = "none"; + } + } + + function setDetailActionsHtml(html) { + let el = document.getElementById("detailActions"); + if (!el) { + const panel = document.querySelector("#detailModal .panel"); + if (!panel) return; + el = document.createElement("div"); + el.id = "detailActions"; + el.className = "detail-actions"; + const body = document.getElementById("detailBody"); + if (body && body.parentNode === panel) { + panel.insertBefore(el, body.nextSibling); + } else { + panel.appendChild(el); + } + } + el.innerHTML = html || ""; + el.style.display = html ? "flex" : "none"; + } + + function promptReviewEntryReason(options, currentValue) { + const opts = Array.isArray(options) ? options : []; + const cur = String(currentValue == null ? "" : currentValue).trim(); + return new Promise(function (resolve) { + const backdrop = document.createElement("div"); + backdrop.className = "review-entry-reason-backdrop open"; + const modal = document.createElement("div"); + modal.className = "review-entry-reason-modal"; + modal.setAttribute("role", "dialog"); + modal.setAttribute("aria-modal", "true"); + + const title = document.createElement("h3"); + title.textContent = "开仓类型"; + modal.appendChild(title); + + const hint = document.createElement("p"); + hint.className = "review-entry-reason-hint"; + hint.textContent = "请选择下拉选项之一;选「不改该项」则保留原值."; + modal.appendChild(hint); + + const select = document.createElement("select"); + select.className = "review-entry-reason-select"; + const emptyOpt = document.createElement("option"); + emptyOpt.value = ""; + emptyOpt.textContent = "(不改该项)"; + select.appendChild(emptyOpt); + + const seen = new Set([""]); + if (cur && opts.indexOf(cur) < 0) { + const curOpt = document.createElement("option"); + curOpt.value = cur; + curOpt.textContent = cur + "(当前)"; + select.appendChild(curOpt); + seen.add(cur); + } + opts.forEach(function (opt) { + const v = String(opt || "").trim(); + if (!v || seen.has(v)) return; + const o = document.createElement("option"); + o.value = v; + o.textContent = v; + select.appendChild(o); + seen.add(v); + }); + if (cur) select.value = cur; + modal.appendChild(select); + + const actions = document.createElement("div"); + actions.className = "review-entry-reason-actions"; + const cancelBtn = document.createElement("button"); + cancelBtn.type = "button"; + cancelBtn.className = "review-entry-reason-cancel"; + cancelBtn.textContent = "取消"; + const okBtn = document.createElement("button"); + okBtn.type = "button"; + okBtn.className = "review-entry-reason-ok"; + okBtn.textContent = "确定"; + actions.appendChild(cancelBtn); + actions.appendChild(okBtn); + modal.appendChild(actions); + backdrop.appendChild(modal); + document.body.appendChild(backdrop); + + function cleanup(result) { + document.removeEventListener("keydown", onKey); + backdrop.remove(); + resolve(result); + } + function onKey(ev) { + if (ev.key === "Escape") cleanup(null); + } + cancelBtn.addEventListener("click", function () { + cleanup(null); + }); + backdrop.addEventListener("click", function (ev) { + if (ev.target === backdrop) cleanup(null); + }); + okBtn.addEventListener("click", function () { + cleanup(select.value); + }); + document.addEventListener("keydown", onKey); + select.focus(); + }); + } + + function openTradeRecordDetailModal(tr) { + const row = parseTradeRecordRow(tr); + if (!row) return; + const titleEl = document.getElementById("detailTitle"); + if (titleEl) { + titleEl.innerText = `交易记录|${row.symbol}`; + } + const body = document.getElementById("detailBody"); + if (body) { + body.classList.remove("md-review", "journal-detail-meta"); + body.classList.add("trade-record-detail-wrap"); + body.innerHTML = buildTradeRecordDetailHtml(row); + } + setDetailActionsHtml( + `
        ${row.actionsHtml}
        ` + ); + const imgEl = document.getElementById("detailImage"); + if (imgEl) { + imgEl.src = ""; + imgEl.style.display = "none"; + } + if (typeof setDetailModalFullscreen === "function") { + setDetailModalFullscreen(false); + } + const modal = document.getElementById("detailModal"); + if (modal) modal.style.display = "flex"; + } + + global.InstanceUI = { + escapeHtml: escapeHtml, + pnlClassFromValue: pnlClassFromValue, + formatPnlSpan: formatPnlSpan, + buildJournalDetailHtml: buildJournalDetailHtml, + setJournalDetailBody: setJournalDetailBody, + openJournalDetailModal: openJournalDetailModal, + isMobileCompactRecords: isMobileCompactRecords, + inferJournalDirection: inferJournalDirection, + renderJournalListHtml: renderJournalListHtml, + parseTradeRecordRow: parseTradeRecordRow, + renderMobileTradeRow: renderMobileTradeRow, + buildTradeRecordDetailHtml: buildTradeRecordDetailHtml, + openTradeRecordDetailModal: openTradeRecordDetailModal, + clearDetailActions: clearDetailActions, + clearJournalDetailImages: clearJournalDetailImages, + setJournalDetailImages: setJournalDetailImages, + promptReviewEntryReason: promptReviewEntryReason, + }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/journal_upload_slots.js b/lib/common/static/journal_upload_slots.js new file mode 100644 index 0000000..8e43422 --- /dev/null +++ b/lib/common/static/journal_upload_slots.js @@ -0,0 +1,145 @@ +/** + * 复盘表单:四周期截图即时上传与状态展示. + */ +(function (global) { + "use strict"; + + function newDraftId() { + if (global.crypto && typeof global.crypto.randomUUID === "function") { + return global.crypto.randomUUID().replace(/-/g, ""); + } + var s = ""; + for (var i = 0; i < 32; i++) { + s += Math.floor(Math.random() * 16).toString(16); + } + return s; + } + + function ensureDraftId(root) { + var scope = root || document; + var el = scope.querySelector("#journal-draft-id"); + if (!el) return ""; + if (!el.value) { + el.value = newDraftId(); + } + return el.value; + } + + function rowParts(input) { + var row = input.closest(".journal-upload-row"); + if (!row) return {}; + return { + row: row, + status: row.querySelector(".journal-upload-status"), + hidden: row.querySelector(".journal-upload-hidden-file"), + }; + } + + function setStatus(statusEl, text, kind) { + if (!statusEl) return; + statusEl.textContent = text || ""; + statusEl.classList.remove( + "journal-upload-status--pending", + "journal-upload-status--ok", + "journal-upload-status--err" + ); + if (kind) { + statusEl.classList.add("journal-upload-status--" + kind); + } + } + + function uploadSlotFile(input, file) { + var parts = rowParts(input); + var draftId = ensureDraftId(input.form || document); + if (!draftId || !file) { + setStatus(parts.status, "上传失败", "err"); + return; + } + + setStatus(parts.status, "上传中…", "pending"); + if (parts.hidden) { + parts.hidden.value = ""; + } + + var fd = new FormData(); + fd.append("journal_draft_id", draftId); + fd.append("tf", input.getAttribute("data-tf") || ""); + fd.append("file", file); + + fetch("/api/journal_upload_slot", { method: "POST", body: fd, credentials: "same-origin" }) + .then(function (res) { + return res.json().then(function (data) { + return { ok: res.ok, data: data }; + }); + }) + .then(function (result) { + if (!result.ok || !result.data || !result.data.ok) { + throw new Error( + (result.data && result.data.error) || "upload failed" + ); + } + var fname = String(result.data.file || "").trim(); + if (parts.hidden) { + parts.hidden.value = fname; + } + input.value = ""; + setStatus(parts.status, "上传成功 " + fname, "ok"); + }) + .catch(function () { + if (parts.hidden) { + parts.hidden.value = ""; + } + setStatus(parts.status, "上传失败", "err"); + }); + } + + function bindInput(input) { + if (!input || input.dataset.journalSlotBound === "1") return; + input.dataset.journalSlotBound = "1"; + input.addEventListener("change", function () { + var file = input.files && input.files[0]; + if (!file) { + var parts = rowParts(input); + if (parts.hidden) { + parts.hidden.value = ""; + } + setStatus(parts.status, "", ""); + return; + } + uploadSlotFile(input, file); + }); + } + + function resetSlots(root) { + var scope = root || document; + var draftEl = scope.querySelector("#journal-draft-id"); + if (draftEl) { + draftEl.value = newDraftId(); + } + scope.querySelectorAll(".journal-upload-hidden-file").forEach(function (el) { + el.value = ""; + }); + scope.querySelectorAll(".journal-upload-slot-input").forEach(function (el) { + el.value = ""; + }); + scope.querySelectorAll(".journal-upload-status").forEach(function (el) { + setStatus(el, "", ""); + }); + } + + function init(root) { + var scope = root || document; + ensureDraftId(scope); + scope.querySelectorAll(".journal-upload-slot-input").forEach(bindInput); + } + + global.JournalUploadSlots = { init: init, reset: resetSlots }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", function () { + init(document); + }); + } else { + init(document); + } +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/key_monitor_form.js b/lib/common/static/key_monitor_form.js new file mode 100644 index 0000000..43f143e --- /dev/null +++ b/lib/common/static/key_monitor_form.js @@ -0,0 +1,160 @@ +/** + * 关键位监控添加表单:类型切换显隐,成交量排名校验(三所实例共用). + */ +(function (global) { + const RS_TYPES = new Set([ + "关键支撑阻力", + "关键阻力位", + "关键支撑位", + ]); + + function syncKeyMonitorFormFields() { + const typeEl = document.querySelector('#key-form [name="type"]'); + const dirEl = document.getElementById("key-direction"); + const modeEl = document.getElementById("key-sl-tp-mode"); + const manualTp = document.getElementById("key-manual-tp"); + const beWrap = document.getElementById("key-breakeven-wrap"); + if (!typeEl) return; + const t = (typeEl.value || "").trim(); + const autoTypes = new Set(["箱体突破", "收敛突破"]); + const fibTypes = new Set(["斐波回调0.618", "斐波回调0.786"]); + const fbTypes = new Set(["假突破"]); + const teTypes = new Set(["回调触价开仓", "突破触价开仓", "触价开仓"]); + const showAuto = autoTypes.has(t); + const showFb = fbTypes.has(t); + const showTe = teTypes.has(t); + const showBe = showAuto || fibTypes.has(t) || showFb || showTe; + const showDir = !RS_TYPES.has(t); + const upperEl = document.getElementById("key-upper"); + const lowerEl = document.getElementById("key-lower"); + const fbPriceEl = document.getElementById("key-fb-price"); + const teEntryEl = document.getElementById("key-trigger-entry"); + const teSlEl = document.getElementById("key-trigger-sl"); + const teTpEl = document.getElementById("key-trigger-tp"); + if (dirEl) { + dirEl.style.display = showDir ? "" : "none"; + dirEl.required = showDir; + if (!showDir) dirEl.value = ""; + } + if (modeEl) modeEl.style.display = showAuto ? "" : "none"; + if (manualTp) { + const trend = showAuto && modeEl && modeEl.value === "trend_manual"; + manualTp.style.display = trend ? "" : "none"; + manualTp.required = !!trend; + } + if (beWrap) beWrap.style.display = showBe ? "inline-flex" : "none"; + if (global.TimeCloseUI) global.TimeCloseUI.syncKeyTimeCloseVisibility(showBe); + const hideBounds = showFb || showTe; + if (upperEl) { + upperEl.style.display = hideBounds ? "none" : ""; + upperEl.required = !hideBounds; + if (hideBounds) upperEl.value = ""; + } + if (lowerEl) { + lowerEl.style.display = hideBounds ? "none" : ""; + lowerEl.required = !hideBounds; + if (hideBounds) lowerEl.value = ""; + } + if (fbPriceEl) { + fbPriceEl.style.display = showFb ? "" : "none"; + fbPriceEl.required = showFb; + if (!showFb) fbPriceEl.value = ""; + fbPriceEl.placeholder = + dirEl && dirEl.value === "short" + ? "高点(阻力)" + : dirEl && dirEl.value === "long" + ? "低点(支撑)" + : "做空填高点/做多填低点"; + } + [teEntryEl, teSlEl, teTpEl].forEach((el) => { + if (!el) return; + el.style.display = showTe ? "" : "none"; + el.required = showTe; + if (!showTe) el.value = ""; + }); + } + + function submitKeyForm(keyForm, label) { + if ( + document.body && + document.body.getAttribute("data-embed-shell") === "1" && + global.InstanceEmbed && + typeof global.InstanceEmbed.postFormAndReload === "function" + ) { + global.InstanceEmbed.postFormAndReload(keyForm, label || "提交中…"); + return; + } + if (global.FormSubmitGuard) global.FormSubmitGuard.nativeSubmitOnce(keyForm, label || "提交中…"); + else keyForm.submit(); + } + + function bindKeyMonitorForm() { + const keyForm = document.getElementById("key-form"); + const keyTypeSel = document.querySelector('#key-form [name="type"]'); + const keyModeSel = document.getElementById("key-sl-tp-mode"); + const keyDirSel = document.getElementById("key-direction"); + if (keyTypeSel) keyTypeSel.addEventListener("change", syncKeyMonitorFormFields); + if (keyModeSel) keyModeSel.addEventListener("change", syncKeyMonitorFormFields); + if (keyDirSel) keyDirSel.addEventListener("change", syncKeyMonitorFormFields); + syncKeyMonitorFormFields(); + if (global.TimeCloseUI) { + global.TimeCloseUI.bindTimeCloseForm( + "key-time-close-cb", + "key-time-close-hours", + "key-time-close-wrap" + ); + } + if (!keyForm || keyForm.dataset.keyFormBound === "1") return; + keyForm.dataset.keyFormBound = "1"; + keyForm.addEventListener("submit", (e) => { + e.preventDefault(); + if (global.FormSubmitGuard && global.FormSubmitGuard.isLocked(keyForm)) return; + const symbolEl = keyForm.querySelector('[name="symbol"]'); + const symbol = (symbolEl ? symbolEl.value : "").trim(); + if (!symbol) { + alert("请先输入交易对"); + return; + } + const typeVal = (keyForm.querySelector('[name="type"]') || {}).value || ""; + if (typeVal === "假突破") { + submitKeyForm(keyForm, "提交中…"); + return; + } + if (global.FormSubmitGuard) global.FormSubmitGuard.lock(keyForm, "校验排名中…"); + fetch(`/api/symbol_liquidity_rank?symbol=${encodeURIComponent(symbol)}`) + .then((r) => r.json().then((d) => ({ status: r.status, data: d }))) + .then(({ status, data }) => { + if (status >= 400 || !data.ok) { + alert((data && data.msg) || "日成交量排名读取失败"); + if (global.FormSubmitGuard) global.FormSubmitGuard.unlock(keyForm); + return; + } + const rankMax = data.rank_max || 30; + const inTop = data.in_top != null ? data.in_top : data.in_top30; + if (data.rank == null || !inTop) { + alert( + `${data.symbol} 当前日成交量排名 ${data.rank == null ? "—" : data.rank}/${data.total},不在前${rankMax},已拦截.` + ); + if (global.FormSubmitGuard) global.FormSubmitGuard.unlock(keyForm); + return; + } + submitKeyForm(keyForm, "提交中…"); + }) + .catch(() => { + alert("日成交量排名检查失败,请稍后重试"); + if (global.FormSubmitGuard) global.FormSubmitGuard.unlock(keyForm); + }); + }); + } + + global.KeyMonitorForm = { + syncFields: syncKeyMonitorFormFields, + init: bindKeyMonitorForm, + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", bindKeyMonitorForm); + } else { + bindKeyMonitorForm(); + } +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/manual_order_rr_preview.js b/lib/common/static/manual_order_rr_preview.js new file mode 100644 index 0000000..eee1856 --- /dev/null +++ b/lib/common/static/manual_order_rr_preview.js @@ -0,0 +1,340 @@ +/** + * 实盘下单:填完币种与止盈止损后,在表单下方显示预估风险 / 预估盈利 / 预估盈亏比. + * 以损定仓:风险 = 当前交易基数 × risk%. + * 全仓杠杆:风险 = 可用保证金×缓冲 × 杠杆 × |SL-入场|/入场(与开仓 calc_risk_amount_from_plan 一致). + */ +(function (global) { + "use strict"; + + let debounceMs = 400; + let minRr = 1.5; + let debounceTimer = null; + let fetchSeq = 0; + + function $(id) { + return document.getElementById(id); + } + + function num(v) { + const n = Number(v); + return Number.isFinite(n) ? n : null; + } + + function formatRr(rr) { + if (rr === null || typeof rr === "undefined") return "—"; + const n = Number(rr); + if (!Number.isFinite(n)) return "—"; + const body = Number.isInteger(n) ? String(n) : String(parseFloat(n.toFixed(2))); + return body + ":1"; + } + + function formatU(v) { + if (v === null || typeof v === "undefined" || !Number.isFinite(Number(v))) return "—"; + return Number(v).toFixed(2) + "U"; + } + + function setMetric(el, label, valueText) { + if (!el) return; + el.innerHTML = label + ":" + valueText + ""; + } + + function sizingMode() { + return (document.body && document.body.getAttribute("data-position-sizing-mode")) || "risk"; + } + + function isFullMarginMode() { + return sizingMode() === "full_margin"; + } + + function fullMarginBuffer() { + const n = Number(document.body && document.body.getAttribute("data-full-margin-buffer")); + return Number.isFinite(n) && n > 0 ? n : 0.9; + } + + function leverageForSymbol(sym) { + const u = (sym || "").trim().toUpperCase(); + const btc = Number(document.body && document.body.getAttribute("data-btc-leverage")); + const alt = Number(document.body && document.body.getAttribute("data-alt-leverage")); + if (u.startsWith("BTC") || u.startsWith("ETH")) { + return Number.isFinite(btc) && btc > 0 ? btc : 10; + } + return Number.isFinite(alt) && alt > 0 ? alt : 5; + } + + function riskPercent() { + const form = $("add-order-form"); + const raw = + (form && form.getAttribute("data-risk-percent")) || + (document.body && document.body.getAttribute("data-risk-percent")) || + ""; + const n = Number(raw); + return Number.isFinite(n) && n > 0 ? n : 1; + } + + function calcRiskFraction(direction, entry, sl) { + const e = num(entry); + const s = num(sl); + if (e === null || s === null || e <= 0 || s <= 0) return null; + let risk = 0; + if (direction === "short") { + risk = s - e; + } else { + risk = e - s; + } + if (risk <= 0) return null; + return risk / e; + } + + function calcRr(direction, entry, sl, tp) { + const e = num(entry); + const s = num(sl); + const t = num(tp); + if (e === null || s === null || t === null) 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); + } + + function calcRrFromPct(slPct, tpPct) { + const sl = num(slPct); + const tp = num(tpPct); + if (sl === null || tp === null || sl <= 0 || tp <= 0) return null; + return tp / sl; + } + + function calcTpFromFixedRr(direction, entry, sl, rr) { + const e = num(entry); + const s = num(sl); + const r = num(rr); + if (e === null || s === null || r === null || r <= 0) return null; + if (direction === "short") { + if (s <= e) return null; + return e - (s - e) * r; + } + if (s >= e) return null; + return e + (e - s) * r; + } + + function resolveSlPrice(mode, direction, entry) { + if (mode === "pct") { + const slPct = num($("order-sl-pct") && $("order-sl-pct").value); + if (slPct === null || slPct <= 0) return null; + if (direction === "short") return entry * (1 + slPct / 100); + return entry * (1 - slPct / 100); + } + return num($("order-sl") && $("order-sl").value); + } + + function currentMode() { + return ($("sltp-mode") && $("sltp-mode").value) || "fixed_rr"; + } + + function currentDirection() { + return ($("order-direction") && $("order-direction").value) || "long"; + } + + function currentSymbol() { + return (($("order-symbol") && $("order-symbol").value) || "").trim(); + } + + function inputsComplete(m) { + const dir = currentDirection(); + if (!currentSymbol() || !dir) return false; + if (m === "pct") { + const sl = num($("order-sl-pct") && $("order-sl-pct").value); + const tp = num($("order-tp-pct") && $("order-tp-pct").value); + return sl !== null && tp !== null && sl > 0 && tp > 0; + } + if (m === "fixed_rr") { + const sl = num($("order-sl") && $("order-sl").value); + const rr = num($("order-fixed-rr") && $("order-fixed-rr").value); + return sl !== null && rr !== null && sl > 0 && rr > 0; + } + const sl = num($("order-sl") && $("order-sl").value); + const tp = num($("order-tp") && $("order-tp").value); + return sl !== null && tp !== null && sl > 0 && tp > 0; + } + + function paintEmpty() { + setMetric($("order-risk-preview"), "预估风险", "—"); + setMetric($("order-profit-preview"), "预估盈利", "—"); + setMetric($("order-rr-preview"), "预估盈亏比", "—"); + } + + function paintLoading() { + setMetric($("order-risk-preview"), "预估风险", "计算中…"); + setMetric($("order-profit-preview"), "预估盈利", "计算中…"); + setMetric($("order-rr-preview"), "预估盈亏比", "计算中…"); + } + + function paintFail(kind) { + const msg = kind === "fetch_fail" ? "取价失败" : "无效"; + setMetric($("order-risk-preview"), "预估风险", msg); + setMetric($("order-profit-preview"), "预估盈利", msg); + setMetric($("order-rr-preview"), "预估盈亏比", msg); + } + + function paintOk(riskU, profitU, rr) { + setMetric($("order-risk-preview"), "预估风险", formatU(riskU)); + setMetric($("order-profit-preview"), "预估盈利", formatU(profitU)); + const rrEl = $("order-rr-preview"); + const rrText = formatRr(rr); + setMetric(rrEl, "预估盈亏比", rrText); + if (rrEl && rr !== null && Number.isFinite(Number(rr))) { + rrEl.classList.toggle("order-preview-rr-low", Number(rr) < minRr); + rrEl.classList.toggle("order-preview-rr-ok", Number(rr) >= minRr); + } + } + + function plannedRiskFromRiskMode(capital) { + const cap = num(capital); + if (cap === null || cap <= 0) return null; + return Math.round((cap * riskPercent()) / 100 * 100) / 100; + } + + function plannedRiskFromFullMargin(availableUsdt, symbol, direction, entry, sl) { + const avail = num(availableUsdt); + if (avail === null || avail <= 0) return null; + const slPx = num(sl); + const entryPx = num(entry); + if (slPx === null || entryPx === null) return null; + const rf = calcRiskFraction(direction, entryPx, slPx); + if (rf === null) return null; + const margin = Math.round(avail * fullMarginBuffer() * 100) / 100; + const lev = leverageForSymbol(symbol); + return Math.round(margin * lev * rf * 100) / 100; + } + + function resolvePreviewRr(m, dir, entry) { + if (m === "pct") { + return calcRrFromPct( + $("order-sl-pct") && $("order-sl-pct").value, + $("order-tp-pct") && $("order-tp-pct").value + ); + } + const sl = num($("order-sl") && $("order-sl").value); + if (m === "fixed_rr") { + const fixed = num($("order-fixed-rr") && $("order-fixed-rr").value); + if (fixed !== null && fixed > 0) return fixed; + const tp = calcTpFromFixedRr(dir, entry, sl, fixed); + return calcRr(dir, entry, sl, tp); + } + const tp = num($("order-tp") && $("order-tp").value); + return calcRr(dir, entry, sl, tp); + } + + function refreshNow() { + if (!$("order-plan-preview")) return; + const m = currentMode(); + if (!inputsComplete(m)) { + paintEmpty(); + return; + } + + const sym = currentSymbol(); + const dir = currentDirection(); + const seq = ++fetchSeq; + paintLoading(); + + const defaultsP = fetch( + "/api/order_defaults?symbol=" + + encodeURIComponent(sym) + + "&direction=" + + encodeURIComponent(dir) + ).then(function (r) { + return r.json(); + }); + + const capitalP = fetch("/api/account_snapshot").then(function (r) { + return r.json(); + }); + + Promise.all([defaultsP, capitalP]) + .then(function (results) { + if (seq !== fetchSeq) return; + const data = results[0]; + const account = results[1] || {}; + if (!data.ok) { + paintFail("fetch_fail"); + return; + } + const entry = num(data.last_price != null ? data.last_price : data.price); + if (entry === null) { + paintFail("fetch_fail"); + return; + } + const rr = resolvePreviewRr(m, dir, entry); + if (rr === null) { + paintFail("invalid"); + return; + } + let riskU = null; + if (isFullMarginMode()) { + const slPx = resolveSlPrice(m, dir, entry); + const avail = + data.available_trading_usdt != null + ? data.available_trading_usdt + : account.available_trading_usdt; + riskU = plannedRiskFromFullMargin(avail, sym, dir, entry, slPx); + } else { + riskU = plannedRiskFromRiskMode(account.current_capital); + } + if (riskU === null) { + paintFail("fetch_fail"); + return; + } + const profitU = Math.round(riskU * rr * 100) / 100; + paintOk(riskU, profitU, rr); + }) + .catch(function () { + if (seq !== fetchSeq) return; + paintFail("fetch_fail"); + }); + } + + function schedule() { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(refreshNow, debounceMs); + } + + function wire(opts) { + opts = opts || {}; + if (opts.minRr != null && Number.isFinite(Number(opts.minRr))) { + minRr = Number(opts.minRr); + } + if (opts.debounceMs != null && Number.isFinite(Number(opts.debounceMs))) { + debounceMs = Number(opts.debounceMs); + } + [ + "order-symbol", + "order-direction", + "sltp-mode", + "order-sl", + "order-tp", + "order-sl-pct", + "order-tp-pct", + "order-fixed-rr", + "order-leverage", + ].forEach(function (id) { + const el = $(id); + if (!el || el._rrPreviewBound) return; + el._rrPreviewBound = true; + el.addEventListener("input", schedule); + el.addEventListener("change", schedule); + }); + schedule(); + } + + global.ManualOrderRrPreview = { + wire: wire, + schedule: schedule, + refresh: refreshNow, + calcRr: calcRr, + calcRrFromPct: calcRrFromPct, + calcRiskFraction: calcRiskFraction, + formatRr: formatRr, + }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/options_expiry_countdown.js b/lib/common/static/options_expiry_countdown.js new file mode 100644 index 0000000..db33172 --- /dev/null +++ b/lib/common/static/options_expiry_countdown.js @@ -0,0 +1,58 @@ +/** + * 期权到期倒计时(实例期权页 + 中控监控/看板共用) + */ +(function (global) { + function normalizeExpMs(v) { + if (v == null || v === "") return null; + var n = Number(v); + if (!Number.isFinite(n) || n <= 0) return null; + if (n < 1e12) n *= 1000; + return n; + } + + function formatCountdown(expMs, nowMs) { + var ms = normalizeExpMs(expMs); + if (ms == null) return "—"; + var now = nowMs != null ? nowMs : Date.now(); + var rem = Math.max(0, Math.floor((ms - now) / 1000)); + if (rem <= 0) return "已到期"; + var d = Math.floor(rem / 86400); + var h = Math.floor((rem % 86400) / 3600); + var m = Math.floor((rem % 3600) / 60); + var s = rem % 60; + var pad = function (x) { + return String(x).padStart(2, "0"); + }; + if (d > 0) return d + "天 " + pad(h) + ":" + pad(m) + ":" + pad(s); + return pad(h) + ":" + pad(m) + ":" + pad(s); + } + + function tick(root) { + var scope = root && root.querySelectorAll ? root : document; + var now = Date.now(); + scope.querySelectorAll("[data-opt-exp-ms]").forEach(function (el) { + var exp = el.getAttribute("data-opt-exp-ms"); + var text = formatCountdown(exp, now); + el.textContent = text; + var expMs = normalizeExpMs(exp); + el.classList.toggle("opt-expiry-cd--urgent", expMs != null && expMs - now > 0 && expMs - now < 3600000); + el.classList.toggle("opt-expiry-cd--expired", text === "已到期"); + }); + } + + var timer = null; + function ensureTimer() { + tick(); + if (timer) return; + timer = setInterval(function () { + tick(); + }, 1000); + } + + global.OptionsExpiryCountdown = { + normalizeExpMs: normalizeExpMs, + format: formatCountdown, + tick: tick, + ensureTimer: ensureTimer, + }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js new file mode 100644 index 0000000..42b609c --- /dev/null +++ b/lib/common/static/options_panel.js @@ -0,0 +1,2055 @@ +(function () { + "use strict"; + + const root = document.getElementById("options-root"); + if (!root) return; + if (root.getAttribute("data-options-booted") === "1") return; + root.setAttribute("data-options-booted", "1"); + + const panelCache = (window.__optionsPanelCache = window.__optionsPanelCache || {}); + + const state = { + underlying: root.dataset.defaultUnderly || "ETH", + optType: "C", + moneyFilter: "all", + chainView: "list", + strikeExpandAll: false, + chain: panelCache.chain || null, + selectedInst: null, + orderQuote: null, + expandedPosInst: null, + posTab: "live", + /** 未点设定前的目标输入草稿,避免持仓轮询重绘清空 */ + targetDraftByInst: {}, + }; + + let lastGoodPositions = null; + let lastGoodPositionsAt = 0; + let positionsRefreshSeq = 0; + let chainLoadSeq = 0; + let selectSeq = 0; + let refreshAllTimer = null; + let pendingRefreshTimer = null; + let pendingTtlSeconds = 600; + const POSITIONS_STALE_MS = 45000; + const PENDING_POLL_MS = 8000; + const orderPanelHome = (function () { + const host = document.getElementById("opt-order-panel-host"); + return host ? host.parentElement : null; + })(); + + function fmt(v, d) { + if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; + return Number(v).toFixed(d == null ? 2 : d); + } + + function fmtDisplay(v, fallback) { + if (v !== null && v !== undefined && String(v).trim() !== "") return String(v); + if (fallback !== undefined) return fmtDisplay(fallback); + return "—"; + } + + function fmtOptionPx(v, tickSz) { + if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; + const n = Number(v); + const tick = Number(tickSz); + if (!tickSz || Number.isNaN(tick) || tick <= 0) { + // 无 tick 时裁掉浮点毛刺,勿 482.4881990066513 + let s = n.toFixed(4).replace(/\.?0+$/, ""); + return s || "0"; + } + let decimals = 0; + if (tick < 1) decimals = Math.max(0, -Math.round(Math.log10(tick))); + else if (String(tick).indexOf(".") >= 0) decimals = String(tick).split(".")[1].length; + let s = n.toFixed(decimals); + // 仅裁小数尾零;整数 tick(BTC=5)时绝不能把 1370 裁成 137 + if (decimals > 0) s = s.replace(/\.?0+$/, ""); + return s || "0"; + } + + async function apiJson(url, opts) { + const r = await fetch(url, Object.assign({ credentials: "same-origin" }, opts || {})); + return r.json(); + } + + function orderPanel() { + return document.getElementById("opt-order-panel"); + } + + function orderPanelHost() { + return document.getElementById("opt-order-panel-host"); + } + + function syncPickButtons(instId) { + document.querySelectorAll(".opt-pick-btn").forEach(function (btn) { + const on = !!instId && btn.getAttribute("data-inst") === instId; + btn.classList.toggle("active", on); + btn.disabled = false; + if (!btn.dataset.origText) btn.dataset.origText = "选择"; + btn.textContent = on ? "已选" : btn.dataset.origText; + }); + } + + function parkOrderPanel() { + stopPendingOrdersPoll(); + const panel = orderPanel(); + const host = orderPanelHost(); + // 把整块 host(含面板)移回原位,再删行内 tr,避免 tbody 重绘销毁下单 DOM + if (host && orderPanelHome && host.parentElement !== orderPanelHome) { + orderPanelHome.appendChild(host); + } else if (panel && host && panel.parentElement !== host) { + host.appendChild(panel); + } + if (host) host.hidden = true; + if (panel) panel.style.display = "none"; + const inline = document.querySelector(".opt-order-inline-row"); + if (inline) inline.remove(); + document.querySelectorAll(".opt-strike-row").forEach(function (r) { + r.classList.remove("opt-row-selected"); + }); + syncPickButtons(null); + } + + function placeOrderPanelAfter(instId) { + const panel = orderPanel(); + const host = orderPanelHost(); + if (!panel || !host || !instId) { + syncPickButtons(instId || null); + return false; + } + const row = + document.querySelector('#opt-strike-tbody tr.opt-strike-row[data-inst="' + CSS.escape(instId) + '"]') || + document.querySelector('#opt-strike-tbody tr.opt-strike-row[data-call-inst="' + CSS.escape(instId) + '"]') || + document.querySelector('#opt-strike-tbody tr.opt-strike-row[data-put-inst="' + CSS.escape(instId) + '"]'); + if (!row) { + syncPickButtons(null); + return false; + } + document.querySelectorAll(".opt-strike-row").forEach(function (r) { + r.classList.toggle("opt-row-selected", r === row); + }); + syncPickButtons(instId); + const oldInline = document.querySelector(".opt-order-inline-row"); + if (oldInline) oldInline.remove(); + if (panel.parentElement !== host) host.appendChild(panel); + const tr = document.createElement("tr"); + tr.className = "opt-order-inline-row"; + const td = document.createElement("td"); + td.colSpan = strikeTableColspan(); + td.appendChild(host); + tr.appendChild(td); + row.after(tr); + host.hidden = false; + panel.style.display = ""; + tr.scrollIntoView({ behavior: "smooth", block: "nearest" }); + refreshPendingOrders(); + startPendingOrdersPoll(); + return true; + } + + function fmtPendingAge(sec) { + if (sec == null || Number.isNaN(Number(sec))) return "—"; + let s = Math.max(0, Math.round(Number(sec))); + if (s < 60) return s + "秒"; + const m = Math.floor(s / 60); + const rs = s % 60; + if (m < 60) return rs ? m + "分" + rs + "秒" : m + "分"; + const h = Math.floor(m / 60); + const rm = m % 60; + return rm ? h + "时" + rm + "分" : h + "时"; + } + + function paintPendingOrders(orders, ttlSec) { + const host = document.getElementById("opt-pending-list"); + const hint = document.getElementById("opt-pending-ttl-hint"); + if (ttlSec != null && !Number.isNaN(Number(ttlSec))) { + pendingTtlSeconds = Number(ttlSec); + } + if (hint) { + const ttl = pendingTtlSeconds; + hint.textContent = ttl > 0 + ? ("平仓限价超 " + fmtPendingAge(ttl) + " 未成交将自动撤销") + : "平仓超时自动撤单已关闭"; + } + if (!host) return; + const rows = Array.isArray(orders) ? orders : []; + if (!rows.length) { + host.innerHTML = '
        暂无未成交委托
        '; + return; + } + host.innerHTML = rows.map(function (o) { + const side = String(o.side || "").toLowerCase(); + const sideCls = side === "buy" ? "is-buy" : side === "sell" ? "is-sell" : ""; + const remain = (o.sz != null && o.fill_sz != null) ? Math.max(0, Number(o.sz) - Number(o.fill_sz)) : o.sz; + const pxTxt = o.px != null ? fmtOptionPx(o.px, null) : "—"; + const kind = o.is_close_order ? "平仓" : "开仓"; + let ttlTxt = ""; + if (o.auto_cancel_enabled) { + if (o.stale) ttlTxt = " · 超时待撤"; + else if (o.expire_in_sec != null) ttlTxt = " · 剩 " + fmtPendingAge(o.expire_in_sec) + " 自动撤"; + } + const ageTxt = o.age_sec != null ? ("已挂 " + fmtPendingAge(o.age_sec)) : ""; + return ( + '
        ' + + '
        ' + + '' + kind + " · " + (o.side_label || side || "—") + "" + + '' + + "
        " + + '
        ' + (o.inst_id || "—") + "
        " + + '
        价 ' + pxTxt + + " · 张数 " + (o.sz != null ? o.sz : "—") + + (o.fill_sz != null && Number(o.fill_sz) > 0 ? " · 已成 " + o.fill_sz : "") + + (remain != null && o.fill_sz != null && Number(o.fill_sz) > 0 ? " · 剩余 " + remain : "") + + (ageTxt ? " · " + ageTxt : "") + + ttlTxt + + "
        " + ); + }).join(""); + host.querySelectorAll(".opt-pending-cancel").forEach(function (btn) { + btn.addEventListener("click", function () { + cancelPendingOrder(btn.getAttribute("data-inst"), btn.getAttribute("data-ord"), btn); + }); + }); + } + + async function refreshPendingOrders() { + const host = document.getElementById("opt-pending-list"); + if (!host) return; + try { + const d = await apiJson("/api/options/orders/pending"); + if (!d.ok) { + host.innerHTML = '
        ' + (d.msg || "获取委托失败") + "
        "; + return; + } + paintPendingOrders(d.orders || [], d.pending_ttl_seconds); + } catch (e) { + host.innerHTML = '
        获取委托失败
        '; + } + } + + function startPendingOrdersPoll() { + stopPendingOrdersPoll(); + pendingRefreshTimer = setInterval(function () { + if (!document.getElementById("options-root")) { + stopPendingOrdersPoll(); + return; + } + refreshPendingOrders(); + }, PENDING_POLL_MS); + } + + function stopPendingOrdersPoll() { + if (pendingRefreshTimer) { + clearInterval(pendingRefreshTimer); + pendingRefreshTimer = null; + } + } + + async function cancelPendingOrder(inst, ordId, btn) { + if (!inst || !ordId) return; + if (!confirm("撤销该委托?\n合约: " + inst + "\n订单: " + ordId)) return; + if (btn) btn.disabled = true; + try { + const d = await apiJson("/api/options/orders/cancel", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ inst_id: inst, ord_id: ordId }), + }); + if (!d.ok) { + alert(d.msg || "撤销失败"); + return; + } + await refreshPendingOrders(); + refreshAllPositions(); + if (typeof refreshAccountSnapshot === "function") refreshAccountSnapshot(); + } finally { + if (btn) btn.disabled = false; + } + } + + function currentSizeMode() { + const el = document.querySelector('input[name="opt-size-mode"]:checked'); + return el ? el.value : "sheets"; + } + + function updateSizeInputs() { + const mode = currentSizeMode(); + const sheetsEl = document.getElementById("opt-sheets-amount"); + const ethEl = document.getElementById("opt-eth-amount"); + if (sheetsEl) sheetsEl.style.display = mode === "sheets" ? "" : "none"; + if (ethEl) ethEl.style.display = mode === "eth_amount" ? "" : "none"; + } + + function quoteUrl(instId) { + const mode = currentSizeMode(); + let url = "/api/options/quote?inst_id=" + encodeURIComponent(instId) + "&mode=" + mode; + if (mode === "eth_amount") { + const eth = document.getElementById("opt-eth-amount").value; + if (eth) url += "ð_amount=" + encodeURIComponent(eth); + } else if (mode === "sheets") { + const sheets = document.getElementById("opt-sheets-amount").value; + if (sheets) url += "&sheets=" + encodeURIComponent(sheets); + } + return url; + } + + function strikeTableColspan() { + return state.chainView === "t" ? 9 : 8; + } + + function syncChainViewUI() { + const isT = state.chainView === "t"; + document.querySelectorAll(".opt-view-btn").forEach(function (b) { + b.classList.toggle("active", (b.getAttribute("data-view") || "") === state.chainView); + }); + const typeGroup = document.getElementById("opt-type-btn-group"); + if (typeGroup) typeGroup.hidden = isT; + const expandWrap = document.getElementById("opt-strike-expand-wrap"); + if (expandWrap) expandWrap.hidden = !isT; + const headList = document.getElementById("opt-strike-head-list"); + const headT = document.getElementById("opt-strike-head-t"); + const headTCols = document.getElementById("opt-strike-head-t-cols"); + if (headList) { + headList.classList.toggle("hidden", isT); + headList.hidden = isT; + } + if (headT) { + headT.classList.toggle("hidden", !isT); + headT.hidden = !isT; + } + if (headTCols) { + headTCols.classList.toggle("hidden", !isT); + headTCols.hidden = !isT; + } + const wrap = document.getElementById("opt-strike-table-wrap"); + if (wrap) wrap.classList.toggle("options-strike-table-wrap--t", isT); + const table = document.getElementById("opt-strike-table"); + if (table) table.classList.toggle("options-strike-table--t", isT); + } + + function matchesMoneyFilter(moneyness) { + const m = (moneyness || "").toLowerCase(); + if (state.moneyFilter === "all") return true; + if (state.moneyFilter === "otm") return m === "otm"; + return m === "itm" || m === "atm"; + } + + function moneyFilterLabel() { + if (state.moneyFilter === "otm") return "虚值"; + if (state.moneyFilter === "itm") return "实值"; + return ""; + } + + function countContractsForType(contracts) { + if (state.chainView === "t") { + return countStraddleStrikes(contracts); + } + return (contracts || []).filter(function (c) { + return c.opt_type === state.optType; + }).length; + } + + function countStraddleStrikes(contracts) { + const strikes = new Set(); + (contracts || []).forEach(function (c) { + if (c.strike != null) strikes.add(String(c.strike)); + }); + return strikes.size; + } + + function buildStraddleRows(contracts) { + const map = {}; + (contracts || []).forEach(function (c) { + const key = String(c.strike); + if (!map[key]) map[key] = { strike: c.strike, call: null, put: null }; + const o = (c.opt_type || "").toUpperCase(); + if (o === "C") map[key].call = c; + else if (o === "P") map[key].put = c; + }); + return Object.keys(map) + .map(function (k) { return map[k]; }) + .sort(function (a, b) { return Number(a.strike) - Number(b.strike); }); + } + + function findAtmStrike(rows, indexPx) { + if (!rows.length || indexPx == null || Number.isNaN(Number(indexPx))) return null; + let best = rows[0].strike; + let bestDist = Math.abs(Number(rows[0].strike) - Number(indexPx)); + rows.forEach(function (row) { + const d = Math.abs(Number(row.strike) - Number(indexPx)); + if (d < bestDist || (d === bestDist && Number(row.strike) < Number(best))) { + bestDist = d; + best = row.strike; + } + }); + return best; + } + + function matchesStrikeRowFilter(strike, indexPx, atmStrike) { + if (state.moneyFilter === "all") return true; + if (atmStrike != null && Number(strike) === Number(atmStrike)) return true; + if (indexPx == null || Number.isNaN(Number(indexPx))) return true; + if (state.moneyFilter === "itm") return Number(strike) <= Number(indexPx); + if (state.moneyFilter === "otm") return Number(strike) >= Number(indexPx); + return true; + } + + function filterStraddleRows(rows, indexPx) { + const atmStrike = findAtmStrike(rows, indexPx); + return rows.filter(function (row) { + return matchesStrikeRowFilter(row.strike, indexPx, atmStrike); + }); + } + + function sliceAtmWindow(rows, indexPx) { + if (state.strikeExpandAll || !rows.length) return rows; + const atmStrike = findAtmStrike(rows, indexPx); + const idx = rows.findIndex(function (r) { return Number(r.strike) === Number(atmStrike); }); + if (idx < 0) return rows.slice(0, Math.min(rows.length, 11)); + const start = Math.max(0, idx - 5); + const end = Math.min(rows.length, idx + 6); + return rows.slice(start, end); + } + + function straddleAskPerUnit(callAsk, putAsk) { + const c = Number(callAsk); + const p = Number(putAsk); + if (!Number.isFinite(c) || !Number.isFinite(p) || c <= 0 || p <= 0) return null; + return Math.round((c + p) * 10000) / 10000; + } + + function formatStraddleBand(strike, combinedAsk) { + const per = combinedAsk; + if (strike == null || per == null) return "—"; + const k = Number(strike); + const d = Number(per); + if (!Number.isFinite(k) || !Number.isFinite(d)) return "—"; + const lo = Math.round((k - d) * 10) / 10; + const hi = Math.round((k + d) * 10) / 10; + return lo.toFixed(0) + " ~ " + hi.toFixed(0); + } + + function formatStraddlePremiumCell(callAsk, putAsk) { + const per = straddleAskPerUnit(callAsk, putAsk); + if (per == null) return '不可双买'; + return fmtUsdc(per) + " USDC"; + } + + function pickBtnHtml(instId) { + if (!instId) return "—"; + return ''; + } + + function syncMoneyFilterButtons() { + document.querySelectorAll(".opt-money-btn").forEach(function (b) { + b.classList.toggle("active", (b.getAttribute("data-money") || "") === state.moneyFilter); + }); + } + + function resetMoneyFilterToAll() { + state.moneyFilter = "all"; + syncMoneyFilterButtons(); + } + + function updateUnderlyingLabel() { + const el = document.getElementById("opt-order-eth-label"); + if (el) el.textContent = state.underlying + " 数量"; + } + + function filterChainContracts(contracts) { + return (contracts || []).filter(function (c) { + return c.opt_type === state.optType && matchesMoneyFilter(c.moneyness); + }); + } + + function moneynessBadge(c) { + const m = (c && c.moneyness) || ""; + const label = (c && c.moneyness_label) || "—"; + return '' + label + ""; + } + + function optTypeLabel(t) { + return (t || "").toUpperCase() === "P" ? "看跌 Put" : "看涨 Call"; + } + + function expLabel(ms) { + try { + const dt = new Date(Number(ms)); + const now = Date.now(); + const dte = Math.max(0, Math.ceil((Number(ms) - now) / 86400000)); + const base = dt.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }); + return base + " · " + dte + "D"; + } catch (e) { + return String(ms); + } + } + + function renderIndexLine() { + const idx = state.chain && state.chain.index_px; + const dte = state.chain && state.chain.chain_max_dte_days; + if (dte != null) { + const el = document.getElementById("opt-chain-dte"); + if (el) el.textContent = String(Math.round(dte)); + } + const line = document.getElementById("opt-index-line"); + if (line) { + line.textContent = + "指数 " + state.underlying + " ≈ " + fmt(idx, 2) + " · 默认显示全部 · 实值含平值 · 虚值=价外"; + } + } + + function renderExpiryOptions(preserveSelection) { + const sel = document.getElementById("opt-exp-select"); + if (!sel) return; + const prev = preserveSelection !== false ? sel.value : ""; + const exps = (state.chain && state.chain.expiries) || []; + sel.innerHTML = ''; + exps.forEach(function (e) { + const o = document.createElement("option"); + o.value = String(e.exp_time); + o.textContent = expLabel(e.exp_time) + " (" + countContractsForType(e.contracts) + ")"; + sel.appendChild(o); + }); + if (prev && exps.some(function (e) { return String(e.exp_time) === String(prev); })) { + sel.value = prev; + } + } + + function setExpirySelectStatus(text) { + const sel = document.getElementById("opt-exp-select"); + if (!sel) return; + sel.innerHTML = ""; + const o = document.createElement("option"); + o.value = ""; + o.textContent = text || "选择到期日"; + sel.appendChild(o); + } + + function chainHasExpiries(chain) { + return !!(chain && Array.isArray(chain.expiries) && chain.expiries.length > 0); + } + + function renderExpiries() { + renderExpiryOptions(true); + renderIndexLine(); + } + + function fmtPxSz(px, sz, estimated) { + if (px === null || px === undefined || Number.isNaN(Number(px))) return "—"; + let price = Number(px).toFixed(4).replace(/\.?0+$/, ""); + if (estimated) price += "~"; + if (sz === null || sz === undefined || sz === "" || Number.isNaN(Number(sz))) return price; + const s = Number(sz); + const size = Math.abs(s - Math.round(s)) < 1e-9 ? String(Math.round(s)) : String(s); + return price + "/" + size; + } + + /** 买盘深度:价格/流动性;仅展示平仓所需档位(买一不够才出买二…). */ + function fmtCloseLevels(preview, tickSz) { + if (preview && preview.bid_invalid) { + return "暂无有效买盘"; + } + const levels = ((preview && preview.levels) || []).slice(0, 5); + if (!levels.length) return "—"; + return levels.map(function (x, idx) { + const levelNo = x.level != null ? x.level : idx + 1; + const liq = x.available_sheets != null ? x.available_sheets : x.sz; + const pxTxt = fmtOptionPx(x.px, tickSz); + if (liq === null || liq === undefined || liq === "" || Number.isNaN(Number(liq))) { + return "买" + levelNo + " " + pxTxt; + } + const s = Number(liq); + const size = Math.abs(s - Math.round(s)) < 1e-9 ? String(Math.round(s)) : String(s); + return "买" + levelNo + " " + pxTxt + "/" + size; + }).join(" · "); + } + + function closeGateHint(preview) { + if (!preview) return ""; + if (preview.bid_invalid || preview.manual_close_blocked) { + return preview.bid_invalid_reason || "当前买一无效,禁止买一平仓"; + } + const gate = preview.close_gate || {}; + // 2× 只是目标平仓门控,本身不会自动平;手动买一平不拦截 + if (preview.close_gate_blocked || (gate.ready === false && !gate.passed)) { + return "目标门控: " + (preview.close_gate_msg || gate.msg || "可回收需≥2×权利金并持续2分钟"); + } + return ""; + } + + function netPnlFromPos(p) { + const preview = (p && p.close_preview) || {}; + if (preview.estimated_pnl != null && !Number.isNaN(Number(preview.estimated_pnl))) { + return Number(preview.estimated_pnl); + } + const recv = Number(preview.total_received); + const prem = Number(p && p.premium_paid); + if (preview.total_received != null && !Number.isNaN(recv) && !Number.isNaN(prem)) { + return recv - prem; + } + return null; + } + + function netRoiFromPos(p, net) { + const preview = (p && p.close_preview) || {}; + if (preview.estimated_pnl_ratio_pct != null && !Number.isNaN(Number(preview.estimated_pnl_ratio_pct))) { + return Number(preview.estimated_pnl_ratio_pct); + } + const prem = Number(p && p.premium_paid); + if (net == null || Number.isNaN(prem) || prem <= 0) return null; + return (net / prem) * 100; + } + + function fmtUsdc(v) { + if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; + return Number(v).toFixed(2); + } + + function fmtClosePreview(preview, premiumPaid) { + if (!preview || preview.total_received == null) return "—"; + const recvTxt = fmtUsdc(preview.total_received); + let cls = ""; + const prem = Number(premiumPaid); + const recv = Number(preview.total_received); + if (!Number.isNaN(prem) && !Number.isNaN(recv)) { + if (recv > prem) cls = " pos-pnl-profit"; + else if (recv < prem) cls = " pos-pnl-loss"; + } + return '' + recvTxt + " USDC"; + } + + function fmtClosePreviewText(preview) { + if (!preview || preview.total_received == null) return "—"; + let text = fmt(preview.total_received, 4) + " USDC"; + if (preview.covered_sheets != null) { + text += " · 覆盖 " + preview.covered_sheets + "张"; + } + if (preview.uncovered_sheets > 0) { + text += " · 缺 " + preview.uncovered_sheets + "张"; + } + return text; + } + + function fmtPreviewLevels(preview) { + const levels = (preview && preview.levels) || []; + if (!levels.length) return "暂无可用买盘深度"; + return levels.map(function (x) { + return "买" + x.level + " " + fmt(x.px, 4) + " × " + x.sheets + "张 ≈ " + fmt(x.received, 4) + " USDC"; + }).join("\n"); + } + + function pnlCls(v) { + if (v === null || v === undefined || Number.isNaN(Number(v))) return ""; + const n = Number(v); + if (n > 0) return "pos-pnl-profit"; + if (n < 0) return "pos-pnl-loss"; + return ""; + } + + function expiryIntrinsicPerUnit(optType, strike, targetIdx) { + const tgt = Number(targetIdx); + const k = Number(strike); + if (!Number.isFinite(tgt) || !Number.isFinite(k)) return null; + const o = (optType || "").toUpperCase(); + if (o === "C") return Math.max(0, tgt - k); + if (o === "P") return Math.max(0, k - tgt); + return null; + } + + function estimateExpiryValue(optType, strike, targetIdx, ethAmount) { + const amt = Number(ethAmount); + const intrinsic = expiryIntrinsicPerUnit(optType, strike, targetIdx); + if (intrinsic == null || !Number.isFinite(amt) || amt <= 0) return null; + return Math.round(intrinsic * amt * 100) / 100; + } + + function estimateExpiryProfit(optType, strike, targetIdx, ethAmount, totalPremium) { + const value = estimateExpiryValue(optType, strike, targetIdx, ethAmount); + const prem = Number(totalPremium); + if (value == null || !Number.isFinite(prem)) return null; + return Math.round((value - prem) * 100) / 100; + } + + function calcContractLeverage(indexPx, ethAmount, totalPremium) { + if (indexPx == null || ethAmount == null || totalPremium == null) return null; + const idx = Number(indexPx); + const amt = Number(ethAmount); + const prem = Number(totalPremium); + if (!Number.isFinite(idx) || !Number.isFinite(amt) || !Number.isFinite(prem) || amt <= 0 || prem <= 0) { + return null; + } + return Math.round((idx * amt) / prem * 10) / 10; + } + + function fmtLeverage(v) { + if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; + return "约 " + Number(v).toFixed(1) + "×"; + } + + function fmtUsdcSigned(v) { + if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; + const n = Number(v); + const sign = n > 0 ? "+" : ""; + return sign + fmtUsdc(n) + " USDC"; + } + + function updateOrderEstimates() { + const levEl = document.getElementById("opt-order-leverage"); + const valueEl = document.getElementById("opt-est-value"); + const profitEl = document.getElementById("opt-est-profit"); + const targetLevEl = document.getElementById("opt-est-leverage"); + const targetEl = document.getElementById("opt-target-idx"); + const q = state.orderQuote; + if (!q || !q.ok || !q.can_open) { + if (levEl) levEl.textContent = "—"; + if (valueEl) valueEl.textContent = "—"; + if (profitEl) { + profitEl.textContent = "—"; + profitEl.className = "v"; + } + if (targetLevEl) targetLevEl.textContent = "—"; + return; + } + const sz = q.sizing || {}; + const ethAmount = sz.eth_amount; + const premium = sz.total_premium; + const lev = calcContractLeverage(q.index_px, ethAmount, premium); + if (levEl) levEl.textContent = fmtLeverage(lev); + + if (valueEl && profitEl && targetEl) { + const targetRaw = targetEl.value; + if (targetRaw === "" || targetRaw == null) { + valueEl.textContent = "—"; + profitEl.textContent = "—"; + profitEl.className = "v"; + if (targetLevEl) targetLevEl.textContent = "—"; + } else { + const value = estimateExpiryValue(q.opt_type, q.strike, Number(targetRaw), ethAmount); + const profit = estimateExpiryProfit(q.opt_type, q.strike, Number(targetRaw), ethAmount, premium); + if (value == null || Number.isNaN(value)) { + valueEl.textContent = "—"; + } else { + valueEl.textContent = fmtUsdc(value) + " USDC"; + } + if (profit == null || Number.isNaN(profit)) { + profitEl.textContent = "—"; + profitEl.className = "v"; + } else { + profitEl.textContent = fmtUsdcSigned(profit); + profitEl.className = "v " + pnlCls(profit); + } + const targetLev = calcContractLeverage(Number(targetRaw), ethAmount, premium); + if (targetLevEl) targetLevEl.textContent = fmtLeverage(targetLev); + } + } + } + + function updateEstimatedProfit() { + updateOrderEstimates(); + } + + function fmtDist(v) { + if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; + const n = Number(v); + const sign = n > 0 ? "+" : ""; + return sign + n.toFixed(1); + } + + function distBeClass(v) { + if (v === null || v === undefined || Number.isNaN(Number(v))) return ""; + const n = Number(v); + if (n > 0) return "opt-be-dist-up"; + if (n < 0) return "opt-be-dist-down"; + return ""; + } + + function bindStrikePickButtons(tbody) { + tbody.querySelectorAll(".opt-pick-btn").forEach(function (btn) { + btn.addEventListener("click", function () { + selectContract(btn.getAttribute("data-inst"), btn); + }); + }); + } + + function finishStrikeRender(tbody, prevSelected, matchedSelected) { + bindStrikePickButtons(tbody); + if (matchedSelected && prevSelected) { + selectContract(prevSelected, null, true); + } else if (!matchedSelected) { + state.selectedInst = null; + } + } + + function renderStrikes() { + syncChainViewUI(); + if (state.chainView === "t") renderStrikesT(); + else renderStrikesList(); + } + + function renderStrikesList() { + const tbody = document.getElementById("opt-strike-tbody"); + const expMs = document.getElementById("opt-exp-select").value; + const prevSelected = state.selectedInst; + const cols = strikeTableColspan(); + parkOrderPanel(); + tbody.innerHTML = ""; + if (!expMs || !state.chain) { + tbody.innerHTML = '请选择到期日'; + state.selectedInst = null; + return; + } + const exp = (state.chain.expiries || []).find(function (e) { + return String(e.exp_time) === String(expMs); + }); + if (!exp) { + state.selectedInst = null; + return; + } + const list = filterChainContracts(exp.contracts); + if (!list.length) { + const label = moneyFilterLabel(); + const suffix = label ? label : optTypeLabel(state.optType); + tbody.innerHTML = '该到期日暂无' + suffix + "合约"; + state.selectedInst = null; + return; + } + let matchedSelected = false; + list.forEach(function (c) { + const tr = document.createElement("tr"); + tr.className = "opt-strike-row"; + tr.setAttribute("data-inst", c.inst_id); + if (c.moneyness) tr.classList.add("opt-row-" + c.moneyness); + tr.innerHTML = + "" + c.strike + "" + + "" + moneynessBadge(c) + "" + + "" + c.inst_id + "" + + "" + fmtPxSz(c.ask, c.ask_sz, c.ask_estimated) + "" + + "" + fmtPxSz(c.bid, c.bid_sz) + "" + + "" + (c.expiry_be_px != null ? fmt(c.expiry_be_px, 0) : "—") + "" + + '' + fmtDist(c.dist_expiry_be) + "" + + '' + + pickBtnHtml(c.inst_id) + + ""; + tbody.appendChild(tr); + if (c.inst_id === prevSelected) matchedSelected = true; + }); + finishStrikeRender(tbody, prevSelected, matchedSelected); + } + + function renderStrikesT() { + const tbody = document.getElementById("opt-strike-tbody"); + const expMs = document.getElementById("opt-exp-select").value; + const prevSelected = state.selectedInst; + const cols = strikeTableColspan(); + const indexPx = state.chain && state.chain.index_px; + parkOrderPanel(); + tbody.innerHTML = ""; + if (!expMs || !state.chain) { + tbody.innerHTML = '请选择到期日'; + state.selectedInst = null; + return; + } + const exp = (state.chain.expiries || []).find(function (e) { + return String(e.exp_time) === String(expMs); + }); + if (!exp) { + state.selectedInst = null; + return; + } + let rows = filterStraddleRows(buildStraddleRows(exp.contracts), indexPx); + rows = sliceAtmWindow(rows, indexPx); + if (!rows.length) { + const label = moneyFilterLabel(); + const suffix = label ? label + "区" : "匹配"; + tbody.innerHTML = '该到期日暂无' + suffix + "行权价"; + state.selectedInst = null; + return; + } + const atmStrike = findAtmStrike(rows, indexPx); + let matchedSelected = false; + rows.forEach(function (row) { + const call = row.call; + const put = row.put; + const combined = straddleAskPerUnit(call && call.ask, put && put.ask); + const tr = document.createElement("tr"); + tr.className = "opt-strike-row opt-strike-row-t"; + tr.setAttribute("data-strike", String(row.strike)); + if (Number(row.strike) === Number(atmStrike)) tr.classList.add("opt-strike-row-atm"); + if (call && call.inst_id) tr.setAttribute("data-call-inst", call.inst_id); + if (put && put.inst_id) tr.setAttribute("data-put-inst", put.inst_id); + tr.innerHTML = + '' + (call ? fmtPxSz(call.ask, call.ask_sz, call.ask_estimated) : "—") + "" + + '' + (call ? moneynessBadge(call) : "—") + "" + + '' + pickBtnHtml(call && call.inst_id) + "" + + '' + row.strike + "" + + '' + formatStraddlePremiumCell(call && call.ask, put && put.ask) + "" + + '' + formatStraddleBand(row.strike, combined) + "" + + '' + (put ? moneynessBadge(put) : "—") + "" + + '' + (put ? fmtPxSz(put.ask, put.ask_sz, put.ask_estimated) : "—") + "" + + '' + pickBtnHtml(put && put.inst_id) + ""; + tbody.appendChild(tr); + if (prevSelected && ((call && call.inst_id === prevSelected) || (put && put.inst_id === prevSelected))) { + matchedSelected = true; + } + }); + if (!state.strikeExpandAll && rows.length >= 1) { + const hint = document.createElement("tr"); + hint.className = "opt-strike-hint-row"; + hint.innerHTML = '默认显示 ATM ±5 档 · 勾选「展开全部」查看该到期全部行权价'; + tbody.appendChild(hint); + } + finishStrikeRender(tbody, prevSelected, matchedSelected); + } + + function fillOrderPanel(d) { + state.orderQuote = d && d.ok ? d : null; + const sz = d.sizing || {}; + const canOpen = !!(d && d.ok && d.can_open); + document.getElementById("opt-order-inst").textContent = d.inst_id || state.selectedInst || ""; + const askEl = document.getElementById("opt-order-ask"); + if (askEl) { + askEl.textContent = canOpen ? fmtPxSz(d.ask, d.ask_sz) : "—"; + } + const bidEl = document.getElementById("opt-order-bid"); + if (bidEl) bidEl.textContent = fmtPxSz(d.bid, d.bid_sz); + const refEl = document.getElementById("opt-order-ref-ask"); + if (refEl) { + if (canOpen) { + refEl.textContent = "—"; + } else if (d.ref_ask != null && !Number.isNaN(Number(d.ref_ask))) { + refEl.textContent = fmtPxSz(d.ref_ask, null, true) + " (不可开仓)"; + } else if (d.mark != null && !Number.isNaN(Number(d.mark))) { + refEl.textContent = fmtPxSz(d.mark, null, true) + " (不可开仓)"; + } else { + refEl.textContent = "—"; + } + } + document.getElementById("opt-order-sheets").textContent = canOpen && sz.sheets != null ? sz.sheets : "—"; + document.getElementById("opt-order-eth").textContent = canOpen && sz.eth_amount != null ? sz.eth_amount : "—"; + updateUnderlyingLabel(); + document.getElementById("opt-order-premium").textContent = + canOpen && sz.total_premium != null ? fmtUsdc(sz.total_premium) + " USDC" : "—"; + const beEl = document.getElementById("opt-order-expiry-be"); + const distEl = document.getElementById("opt-order-dist-be"); + if (beEl) { + beEl.textContent = d.expiry_be_px != null ? fmt(d.expiry_be_px, 0) : "—"; + } + if (distEl) { + distEl.textContent = fmtDist(d.dist_expiry_be); + distEl.className = "v " + distBeClass(d.dist_expiry_be); + } + const openBtn = document.getElementById("opt-open-btn"); + if (openBtn) { + openBtn.disabled = !canOpen || sz.ok === false; + openBtn.textContent = canOpen ? "限价买入 @ 卖一" : "暂无卖一深度,无法开仓"; + } + const msgEl = document.getElementById("opt-order-msg"); + if (!d.ok) { + msgEl.textContent = d.msg || "报价失败"; + msgEl.classList.add("opt-error"); + } else if (!canOpen) { + const ref = d.ref_ask != null ? d.ref_ask : d.mark; + let tip = d.msg || d.open_block_msg || "当前无卖一深度,无法按卖一限价买入"; + if (ref != null && !Number.isNaN(Number(ref))) { + tip += "。参考标记价 ~" + Number(ref).toFixed(4).replace(/\.?0+$/, "") + "(仅供参考,不可用于开仓)"; + } else { + tip += "。无可用参考标记价"; + } + msgEl.textContent = tip; + msgEl.classList.add("opt-error"); + } else if (sz.ok === false) { + msgEl.textContent = sz.msg || ""; + msgEl.classList.add("opt-error"); + } else if (sz.ask_depth_capped) { + msgEl.textContent = sz.msg || "已按卖一深度限制张数"; + msgEl.classList.remove("opt-error"); + } else { + msgEl.textContent = ""; + msgEl.classList.remove("opt-error"); + } + updateEstimatedProfit(); + } + + async function selectContract(instId, pickBtn, silent) { + const seq = ++selectSeq; + state.selectedInst = instId; + placeOrderPanelAfter(instId); + if (pickBtn) { + pickBtn.disabled = true; + if (!pickBtn.dataset.origText) pickBtn.dataset.origText = "选择"; + pickBtn.textContent = "加载…"; + } + try { + const d = await apiJson(quoteUrl(instId)); + if (seq !== selectSeq) return d; + fillOrderPanel(d); + return d; + } finally { + if (seq === selectSeq) { + syncPickButtons(instId); + if (!silent) placeOrderPanelAfter(instId); + } + } + } + + async function loadChain(opts) { + const soft = !!(opts && opts.soft); + const uly = state.underlying; + const seq = ++chainLoadSeq; + const btn = document.getElementById("opt-load-chain"); + if (btn && !soft) btn.disabled = true; + if (!soft) { + setExpirySelectStatus("加载到期日中…"); + const tbody = document.getElementById("opt-strike-tbody"); + if (tbody) { + tbody.innerHTML = + '加载期权链…'; + } + } + try { + let d = null; + let lastMsg = ""; + for (let attempt = 0; attempt < 2; attempt++) { + if (seq !== chainLoadSeq) return; + d = await apiJson("/api/options/chain?underlying=" + encodeURIComponent(uly)); + if (seq !== chainLoadSeq) return; + if (d && d.ok && chainHasExpiries(d)) break; + lastMsg = (d && (d.msg || d.chain_error)) || "暂无到期日"; + d = null; + if (attempt === 0) { + if (!soft) setExpirySelectStatus("重试加载到期日…"); + await new Promise(function (resolve) { setTimeout(resolve, 400); }); + } + } + if (seq !== chainLoadSeq) return; + if (!d || !d.ok || !chainHasExpiries(d)) { + if (chainHasExpiries(state.chain) && state.chain.underlying === uly) { + if (!soft) { + renderExpiries(); + renderStrikes(); + } + return; + } + if (soft) return; + setExpirySelectStatus("选择到期日"); + const tbody = document.getElementById("opt-strike-tbody"); + if (tbody) { + tbody.innerHTML = + '' + + (lastMsg || "暂无到期日,请点「刷新链」") + + ""; + } + alert(lastMsg || "加载到期日失败,请点「刷新链」重试"); + return; + } + const keepExp = soft ? (document.getElementById("opt-exp-select") || {}).value : ""; + state.chain = d; + panelCache.chain = d; + panelCache.underlying = uly; + panelCache.optType = state.optType; + if (!soft) { + state.selectedInst = null; + resetMoneyFilterToAll(); + state.strikeExpandAll = false; + const expandCb = document.getElementById("opt-strike-expand-all"); + if (expandCb) expandCb.checked = false; + parkOrderPanel(); + } + updateUnderlyingLabel(); + renderExpiries(); + if (soft && keepExp) { + const sel = document.getElementById("opt-exp-select"); + if (sel && Array.from(sel.options).some(function (o) { return o.value === keepExp; })) { + sel.value = keepExp; + } + } + // soft 时保留 selectedInst;renderStrikes 会先 park 再按 prevSelected 静默重挂下单面板 + renderStrikes(); + } catch (e) { + if (seq !== chainLoadSeq || soft) return; + setExpirySelectStatus("选择到期日"); + const tbody = document.getElementById("opt-strike-tbody"); + if (tbody) { + tbody.innerHTML = + '加载失败: ' + + String((e && e.message) || e) + + ""; + } + } finally { + if (seq === chainLoadSeq && btn) btn.disabled = false; + } + } + + async function openPosition() { + if (!state.selectedInst) { + alert("请先选择合约"); + return; + } + const q = state.orderQuote; + if (!q || !q.ok || !q.can_open) { + alert((q && (q.msg || q.open_block_msg)) || "暂无卖一深度,无法按卖一开仓"); + return; + } + if (q.sizing && q.sizing.ok === false) { + alert(q.sizing.msg || "张数无效"); + return; + } + const btn = document.getElementById("opt-open-btn"); + btn.disabled = true; + try { + const mode = currentSizeMode(); + const body = { + inst_id: state.selectedInst, + mode: mode, + signal_note: document.getElementById("opt-signal-note").value || "", + }; + if (mode === "eth_amount") { + body.eth_amount = parseFloat(document.getElementById("opt-eth-amount").value); + } else if (mode === "sheets") { + body.sheets = parseInt(document.getElementById("opt-sheets-amount").value, 10); + } + const tgtRaw = (document.getElementById("opt-target-idx").value || "").trim(); + if (tgtRaw !== "") { + const tgt = parseFloat(tgtRaw); + if (!Number.isFinite(tgt) || tgt <= 0) { + alert("目标位无效"); + return; + } + body.target_index = tgt; + } + const d = await apiJson("/api/options/open", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const msgEl = document.getElementById("opt-order-msg"); + msgEl.textContent = d.ok ? "下单已提交,右侧可查看/撤销未成交委托" : (d.msg || "失败"); + msgEl.classList.toggle("opt-error", !d.ok); + if (d.ok) { + refreshPendingOrders(); + startPendingOrdersPoll(); + refreshAllPositions(); + if (typeof refreshAccountSnapshot === "function") refreshAccountSnapshot(); + } else { + alert(d.msg || "下单失败"); + } + } finally { + const latest = state.orderQuote; + btn.disabled = !(latest && latest.ok && latest.can_open && !(latest.sizing && latest.sizing.ok === false)); + btn.textContent = (latest && latest.can_open) ? "限价买入 @ 卖一" : "暂无卖一深度,无法开仓"; + } + } + + function renderPositionCardInner(p) { + const net = netPnlFromPos(p); + const roi = netRoiFromPos(p, net); + const uplCls = pnlCls(net); + const sideCls = (p.opt_type || "").toUpperCase() === "P" ? "pos-side-short" : "pos-side-long"; + const expMs = p.exp_time_ms != null ? p.exp_time_ms : p.exp_time; + const expAttr = expMs != null && expMs !== "" ? String(expMs) : ""; + const closePreview = p.close_preview || {}; + const closeSheets = p.avail_pos != null && Number(p.avail_pos) > 0 ? p.avail_pos : p.pos; + const tickSz = p.tick_sz; + const premTxt = fmtDisplay(p.premium_paid_fmt, p.premium_paid != null ? fmtUsdc(p.premium_paid) : null); + // 优先用数值+tick 现算,避免接口侧 mark_px_fmt 带着浮点毛刺直出 + const avgTxt = p.avg_px != null ? fmtOptionPx(p.avg_px, tickSz) : fmtDisplay(p.avg_px_fmt); + const markTxt = p.mark_px != null ? fmtOptionPx(p.mark_px, tickSz) : fmtDisplay(p.mark_px_fmt); + return ( + '
        ' + + '
        ' + (p.inst_id || "") + '' + + '' + optTypeLabel(p.opt_type) + "
        " + + '
        ' + + '' + + "
        " + + '
        ' + + '行权价: ' + fmt(p.strike, 0) + "" + + '张数: ' + fmt(p.pos, 0) + " · 币量 " + fmt(p.eth_amount, 4) + "" + + (expAttr + ? '到期倒计时: ' + : "") + + "
        " + + '
        ' + + '
        权利金' + premTxt + " USDC
        " + + '
        开仓均价' + avgTxt + "
        " + + '
        标记价' + markTxt + "
        " + + '
        指数价' + fmt(p.idx_px, 0) + "
        " + + '
        到期平衡' + fmt(p.expiry_be_px, 0) + "
        " + + '
        平掉回本' + fmt(p.close_be_px, 0) + "
        " + + '
        净盈亏' + + (closePreview.bid_invalid || net == null ? "—" : fmt(net, 2)) + "
        " + + '
        收益率' + + (closePreview.bid_invalid || roi == null ? "—" : fmt(roi, 2) + "%") + "
        " + + '
        买盘深度' + fmtCloseLevels(closePreview, tickSz) + "
        " + + '
        按买盘回收' + + (closePreview.bid_invalid + ? '暂无有效买盘' + : fmtClosePreview(closePreview, p.premium_paid)) + "
        " + + "
        " + + (function () { + const hint = closeGateHint(closePreview); + return hint ? '
        ' + hint + "
        " : ""; + })() + + renderTargetDelegateRow(p) + ); + } + + function posEthAmount(p) { + if (p.eth_amount != null && Number(p.eth_amount) > 0) return Number(p.eth_amount); + const sheets = Number(p.avail_pos != null ? p.avail_pos : p.pos); + const ct = Number(p.ct_mult != null ? p.ct_mult : 0.01); + if (Number.isFinite(sheets) && sheets > 0 && Number.isFinite(ct) && ct > 0) return sheets * ct; + return null; + } + + function formatTargetEstimateHtml(optType, strike, targetIdx, ethAmount, premiumPaid) { + const value = estimateExpiryValue(optType, strike, targetIdx, ethAmount); + const profit = estimateExpiryProfit(optType, strike, targetIdx, ethAmount, premiumPaid); + if (value == null && profit == null) return ""; + let html = ''; + html += '价值' + + (value == null ? "—" : fmtUsdc(value) + " USDC") + ""; + html += '预估盈利' + + (profit == null ? "—" : fmtUsdcSigned(profit)) + ""; + html += ""; + return html; + } + + function renderTargetDelegateRow(p) { + const inst = p.inst_id || ""; + const hedgeTarget = p.hedge_plan_target || null; + if (hedgeTarget && Number(hedgeTarget.target_index) > 0) { + const side = (p.opt_type || hedgeTarget.opt_type || "").toUpperCase() === "P" ? "Put ≤" : "Call ≥"; + return ( + '
        ' + + '对冲计划' + + '计划 #' + + hedgeTarget.plan_id + + " · " + + side + + " " + + fmt(hedgeTarget.target_index, 1) + + "" + + '进行中 · 由对冲计划监控,到位后仅平盈利腿' + + "
        " + ); + } + const tgt = p.target_index != null && p.target_index !== "" ? Number(p.target_index) : null; + const armed = tgt != null && Number.isFinite(tgt) && tgt > 0; + const ethAmt = posEthAmount(p); + const prem = p.premium_paid; + const estHtml = armed + ? formatTargetEstimateHtml(p.opt_type, p.strike, tgt, ethAmt, prem) + : ''; + return ( + '
        ' + + '委托' + + '' + + '' + + '" + + (armed + ? '目标 ' + fmt(tgt, 1) + "" + : "") + + estHtml + + '' + + (armed ? "监控中 · 到位按买一限价平" : "输入后设定 · 到位按买一限价平 · 到期即止损") + + "" + + "
        " + ); + } + + function updatePosTargetEstimate(row) { + if (!row) return; + const est = row.querySelector(".opt-target-est"); + if (!est) return; + const inp = row.querySelector(".opt-pos-target-input"); + const typed = inp ? String(inp.value || "").trim() : ""; + const armed = row.getAttribute("data-armed-target") || ""; + const targetRaw = typed !== "" ? typed : armed; + if (targetRaw === "") { + est.className = "opt-target-est opt-target-est--idle"; + est.innerHTML = ""; + return; + } + const html = formatTargetEstimateHtml( + row.getAttribute("data-opt-type"), + row.getAttribute("data-strike"), + targetRaw, + row.getAttribute("data-eth"), + row.getAttribute("data-prem") + ); + if (!html) { + est.className = "opt-target-est opt-target-est--idle"; + est.innerHTML = ""; + return; + } + const tmp = document.createElement("div"); + tmp.innerHTML = html; + const node = tmp.firstChild; + est.className = "opt-target-est"; + est.innerHTML = node ? node.innerHTML : ""; + } + + function renderPositionCard(p) { + return ( + '
        ' + + renderPositionCardInner(p) + + "
        " + ); + } + + function renderPositionAccordionItem(p, expanded) { + const net = netPnlFromPos(p); + const roi = netRoiFromPos(p, net); + const uplCls = pnlCls(net); + const sideCls = (p.opt_type || "").toUpperCase() === "P" ? "pos-side-short" : "pos-side-long"; + const expMs = p.exp_time_ms != null ? p.exp_time_ms : p.exp_time; + const expAttr = expMs != null && expMs !== "" ? String(expMs) : ""; + const inst = p.inst_id || ""; + return ( + '
        ' + + '" + + '
        ' + + '
        ' + + renderPositionCardInner(p) + + "
        " + ); + } + + function applyAccordionState() { + const wrap = document.getElementById("opt-pos-cards"); + if (!wrap) return; + wrap.querySelectorAll(".opt-pos-accordion-item").forEach(function (el) { + const open = el.getAttribute("data-inst") === state.expandedPosInst; + el.classList.toggle("is-expanded", open); + const btn = el.querySelector(".opt-pos-bar"); + const body = el.querySelector(".opt-pos-accordion-body"); + if (btn) btn.setAttribute("aria-expanded", open ? "true" : "false"); + if (body) body.hidden = !open; + }); + } + + function bindPositionActions(container) { + if (!container) return; + container.querySelectorAll(".opt-close-btn").forEach(function (btn) { + btn.addEventListener("click", function (e) { + e.stopPropagation(); + closePosition(btn.getAttribute("data-inst"), btn); + }); + }); + container.querySelectorAll(".opt-target-set-btn").forEach(function (btn) { + btn.addEventListener("click", function (e) { + e.stopPropagation(); + setPositionTarget(btn.getAttribute("data-inst"), btn); + }); + }); + container.querySelectorAll(".opt-target-cancel-btn").forEach(function (btn) { + btn.addEventListener("click", function (e) { + e.stopPropagation(); + cancelPositionTarget(btn.getAttribute("data-inst"), btn); + }); + }); + container.querySelectorAll(".opt-pos-target-input").forEach(function (inp) { + inp.addEventListener("click", function (e) { e.stopPropagation(); }); + inp.addEventListener("input", function () { + const instId = inp.getAttribute("data-inst") || ""; + const draft = String(inp.value || ""); + if (instId) { + if (draft.trim() === "") delete state.targetDraftByInst[instId]; + else state.targetDraftByInst[instId] = draft; + } + updatePosTargetEstimate(inp.closest(".opt-target-row")); + }); + inp.addEventListener("keydown", function (e) { + if (e.key === "Enter") { + e.preventDefault(); + e.stopPropagation(); + setPositionTarget(inp.getAttribute("data-inst"), null); + } + }); + // 重绘后恢复预估展示(草稿或已设定目标) + updatePosTargetEstimate(inp.closest(".opt-target-row")); + }); + container.querySelectorAll(".opt-pos-bar").forEach(function (bar) { + bar.addEventListener("click", function () { + const item = bar.closest(".opt-pos-accordion-item"); + if (!item) return; + const inst = item.getAttribute("data-inst"); + state.expandedPosInst = state.expandedPosInst === inst ? null : inst; + applyAccordionState(); + if (window.OptionsExpiryCountdown && OptionsExpiryCountdown.ensureTimer) { + OptionsExpiryCountdown.ensureTimer(); + } + }); + }); + } + + async function setPositionTarget(inst, btn) { + if (!inst) return; + const card = document.querySelector('.opt-pos-card[data-inst="' + inst + '"]') || + document.querySelector('.opt-pos-accordion-item[data-inst="' + inst + '"]'); + const row = card ? card.querySelector(".opt-target-row") : null; + const inp = card ? card.querySelector(".opt-pos-target-input") : null; + const raw = inp ? String(inp.value || "").trim() : ""; + const tgt = parseFloat(raw); + if (!Number.isFinite(tgt) || tgt <= 0) { + alert("请输入有效目标指数价"); + return; + } + if (btn) btn.disabled = true; + try { + const d = await apiJson("/api/options/target", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ inst_id: inst, target_index: tgt }), + }); + if (!d.ok) { + alert(d.msg || "设定失败"); + return; + } + delete state.targetDraftByInst[inst]; + if (inp) inp.value = ""; + if (row) { + row.setAttribute("data-armed-target", String(tgt)); + updatePosTargetEstimate(row); + } + await refreshAllPositions(); + } finally { + if (btn) btn.disabled = false; + } + } + + async function cancelPositionTarget(inst, btn) { + if (!inst) return; + if (btn) btn.disabled = true; + try { + const d = await apiJson("/api/options/target/cancel", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ inst_id: inst }), + }); + if (!d.ok) { + alert(d.msg || "取消失败"); + return; + } + delete state.targetDraftByInst[inst]; + await refreshAllPositions(); + } finally { + if (btn) btn.disabled = false; + } + } + + function paintTargetMonitors(list) { + const box = document.getElementById("opt-target-monitors"); + const host = document.getElementById("opt-target-monitors-list"); + if (!box || !host) return; + const rows = Array.isArray(list) ? list.filter(function (t) { return t && t.inst_id; }) : []; + if (!rows.length) { + box.hidden = true; + host.innerHTML = ""; + return; + } + box.hidden = false; + host.innerHTML = rows.map(function (t) { + const side = (t.opt_type || "").toUpperCase() === "P" ? "Put ≤" : "Call ≥"; + const managed = t.managed_by === "hedge_plan"; + return ( + '
        ' + + '' + (t.inst_id || "") + "" + + '' + side + " " + fmt(t.target_index, 1) + "" + + (managed + ? '对冲计划 #' + (t.plan_id || "") + " · 进行中" + : '') + + "
        " + ); + }).join(""); + host.querySelectorAll(".opt-target-mon-cancel").forEach(function (btn) { + btn.addEventListener("click", function () { + cancelPositionTarget(btn.getAttribute("data-inst"), btn); + }); + }); + } + + async function closePosition(inst, btn) { + const sheets = btn && btn.getAttribute("data-sheets") ? parseInt(btn.getAttribute("data-sheets"), 10) : null; + let url = "/api/options/quote?inst_id=" + encodeURIComponent(inst) + "&mode=close_preview"; + if (sheets && sheets > 0) url += "&sheets=" + encodeURIComponent(sheets); + const q = await apiJson(url); + if (!q.ok) { + alert(q.msg || "获取买一价失败"); + return; + } + const preview = q.close_preview || {}; + if (preview.bid_invalid || preview.manual_close_blocked) { + alert(preview.bid_invalid_reason || "当前买一为无效残档,禁止买一平仓。"); + return; + } + if (!preview.covered_sheets || preview.covered_sheets <= 0) { + alert("暂无有效买一深度,请稍后重试或到 OKX App 挂限价"); + return; + } + const lv = (preview.levels && preview.levels[0]) || {}; + const msg = [ + "按买一限价卖出本轮可平张数?", + "合约: " + inst, + "锁定买一: " + (lv.px != null ? lv.px : "—") + " × " + (lv.sheets != null ? lv.sheets : preview.covered_sheets) + " 张", + "预计收回: " + fmtClosePreviewText(preview), + preview.estimated_pnl != null ? "预估盈亏: " + fmt(preview.estimated_pnl, 4) + " USDC" : "", + preview.uncovered_sheets > 0 ? "\n注意: 买一深度不足,预计仍剩 " + preview.uncovered_sheets + " 张,需下次再平。" : "" + ].filter(function (x) { return x !== ""; }).join("\n"); + if (!confirm(msg)) return; + if (btn) btn.disabled = true; + try { + const r = await apiJson("/api/options/close", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ inst_id: inst, mode: "bid1", sheets: sheets }), + }); + if (r.ok) { + let okMsg = "买一平仓已提交 " + (r.submitted_sheets || 0) + " 张"; + if (r.locked_bid_px != null) okMsg += "\n锁定买一: " + r.locked_bid_px; + if (r.premium_received != null) okMsg += "\n预估收回: " + fmt(r.premium_received, 4) + " USDC"; + if (r.remaining_sheets > 0) okMsg += "\n剩余: " + r.remaining_sheets + " 张(下次再平)"; + if (r.stopped_reason) okMsg += "\n状态: " + r.stopped_reason; + alert(okMsg); + } else { + alert(r.msg || "平仓失败"); + } + refreshAllPositions(); + if (typeof refreshAccountSnapshot === "function") refreshAccountSnapshot(); + } finally { + if (btn) btn.disabled = false; + } + } + + function setOptionsPosTab(tabId) { + const tab = tabId || "live"; + state.posTab = tab; + document.querySelectorAll(".opt-pos-tab").forEach(function (btn) { + const on = btn.getAttribute("data-opt-pos-tab") === tab; + btn.classList.toggle("active", on); + btn.setAttribute("aria-selected", on ? "true" : "false"); + }); + document.querySelectorAll("[data-opt-pos-pane]").forEach(function (pane) { + const on = pane.getAttribute("data-opt-pos-pane") === tab; + pane.classList.toggle("is-active", on); + pane.hidden = !on; + }); + if (tab === "live" && window.OptionsExpiryCountdown && OptionsExpiryCountdown.ensureTimer) { + OptionsExpiryCountdown.ensureTimer(); + } + } + + function bindOptionsPosTabs() { + document.querySelectorAll(".opt-pos-tab").forEach(function (btn) { + btn.addEventListener("click", function () { + setOptionsPosTab(btn.getAttribute("data-opt-pos-tab")); + }); + }); + setOptionsPosTab(state.posTab); + } + + function resolvePositionsList(d) { + const now = Date.now(); + const list = (d && d.ok && d.positions) ? d.positions : []; + if (d && d.ok) { + if (list.length) { + lastGoodPositions = list; + lastGoodPositionsAt = now; + return list; + } + lastGoodPositions = null; + lastGoodPositionsAt = 0; + return list; + } + if (lastGoodPositions && lastGoodPositions.length && now - lastGoodPositionsAt < POSITIONS_STALE_MS) { + return lastGoodPositions; + } + return []; + } + + function paintPositions(list) { + const wrap = document.getElementById("opt-pos-cards"); + const empty = document.getElementById("opt-pos-empty"); + const livePane = document.getElementById("opt-pos-live"); + if (!wrap) return; + const active = document.activeElement; + // 正在输入目标指数:先落到草稿,本轮不重绘整卡,避免数字往回退 + if (active && active.classList && active.classList.contains("opt-pos-target-input")) { + const focusInst = active.getAttribute("data-inst") || ""; + if (focusInst) { + state.targetDraftByInst[focusInst] = String(active.value || ""); + } + return; + } + // 未聚焦时也同步可见输入,防止漏掉 input 事件 + wrap.querySelectorAll(".opt-pos-target-input").forEach(function (inp) { + const id = inp.getAttribute("data-inst") || ""; + if (!id) return; + const v = String(inp.value || ""); + if (v.trim() === "") delete state.targetDraftByInst[id]; + else state.targetDraftByInst[id] = v; + }); + wrap.innerHTML = ""; + if (!list.length) { + if (empty) empty.style.display = ""; + state.expandedPosInst = null; + if (livePane) livePane.classList.remove("options-pos-live-pane--accordion"); + return; + } + if (empty) empty.style.display = "none"; + const multi = list.length >= 2; + wrap.classList.toggle("opt-pos-cards--accordion", multi); + if (livePane) livePane.classList.toggle("options-pos-live-pane--accordion", multi); + if (multi) { + const ids = list.map(function (p) { return p.inst_id; }); + if (state.expandedPosInst && ids.indexOf(state.expandedPosInst) < 0) { + state.expandedPosInst = null; + } + list.forEach(function (p) { + const div = document.createElement("div"); + div.innerHTML = renderPositionAccordionItem(p, p.inst_id === state.expandedPosInst); + wrap.appendChild(div.firstChild); + }); + } else { + state.expandedPosInst = null; + list.forEach(function (p) { + const div = document.createElement("div"); + div.innerHTML = renderPositionCard(p); + wrap.appendChild(div.firstChild); + }); + } + bindPositionActions(wrap); + if (window.OptionsExpiryCountdown && OptionsExpiryCountdown.ensureTimer) { + OptionsExpiryCountdown.ensureTimer(); + } + } + + async function refreshPositions() { + const seq = ++positionsRefreshSeq; + const d = await apiJson("/api/options/positions"); + if (seq !== positionsRefreshSeq) return; + const list = resolvePositionsList(d); + paintPositions(list); + const fromPos = list.reduce(function (targets, p) { + if (!p) return targets; + if (p.target_index != null) { + targets.push({ + id: p.target_monitor_id, + inst_id: p.inst_id, + opt_type: p.opt_type, + target_index: p.target_index, + }); + } + const hedgeTarget = p.hedge_plan_target; + if (hedgeTarget && hedgeTarget.target_index != null) { + targets.push({ + inst_id: p.inst_id, + opt_type: p.opt_type || hedgeTarget.opt_type, + target_index: hedgeTarget.target_index, + plan_id: hedgeTarget.plan_id, + managed_by: hedgeTarget.managed_by, + }); + } + return targets; + }, []); + if (fromPos.length) { + paintTargetMonitors(fromPos); + } else { + const t = await apiJson("/api/options/targets"); + if (seq !== positionsRefreshSeq) return; + paintTargetMonitors((t && t.ok && t.targets) ? t.targets : []); + } + } + + function paintPnlStat(el, value) { + if (!el) return; + if (value == null || value === "" || Number.isNaN(Number(value))) { + el.textContent = "—"; + el.classList.remove("pos-pnl-profit", "pos-pnl-loss"); + return; + } + const n = Number(value); + el.textContent = (n > 0 ? "+" : "") + fmt(n, 2) + " USDC"; + el.classList.toggle("pos-pnl-profit", n > 0); + el.classList.toggle("pos-pnl-loss", n < 0); + } + + async function refreshStats() { + const d = await apiJson("/api/options/stats"); + const winEl = document.getElementById("opt-stats-winrate"); + const plrEl = document.getElementById("opt-stats-plr"); + const closedEl = document.getElementById("opt-stats-closed"); + const profitEl = document.getElementById("opt-stats-profit"); + const lossEl = document.getElementById("opt-stats-loss"); + const avgHoldEl = document.getElementById("opt-stats-avg-hold"); + const winHoldEl = document.getElementById("opt-stats-win-hold"); + const lossHoldEl = document.getElementById("opt-stats-loss-hold"); + const openHoldEl = document.getElementById("opt-stats-open-hold"); + const totalPnlEl = document.getElementById("opt-stats-total-pnl"); + const netRealizedEl = document.getElementById("opt-stats-net-realized"); + const openFloatEl = document.getElementById("opt-stats-open-float"); + const statEls = [winEl, plrEl, closedEl, profitEl, lossEl, avgHoldEl, winHoldEl, lossHoldEl, openHoldEl]; + if (!d.ok) { + statEls.forEach(function (el) { + if (el) el.textContent = "—"; + }); + paintPnlStat(totalPnlEl, null); + paintPnlStat(netRealizedEl, null); + paintPnlStat(openFloatEl, null); + paintStatsCharts(null); + return; + } + paintPnlStat(totalPnlEl, d.total_pnl); + paintPnlStat(netRealizedEl, d.net_realized_pnl); + paintPnlStat(openFloatEl, d.open_float_pnl); + if (winEl) winEl.textContent = d.total_closed ? d.win_rate + "%" : "0%"; + if (plrEl) { + plrEl.textContent = d.profit_loss_ratio != null ? String(d.profit_loss_ratio) : "—"; + } + if (closedEl) closedEl.textContent = String(d.total_closed || 0); + if (profitEl) { + profitEl.textContent = d.avg_win != null && d.avg_win > 0 + ? fmt(d.avg_win, 2) + " USDC" : (d.win_count ? "0 USDC" : "—"); + } + if (lossEl) { + lossEl.textContent = d.avg_loss != null && d.avg_loss > 0 + ? fmt(d.avg_loss, 2) + " USDC" : (d.loss_count ? "0 USDC" : "—"); + } + if (avgHoldEl) avgHoldEl.textContent = fmtDuration(d.avg_hold_sec); + if (winHoldEl) winHoldEl.textContent = fmtDuration(d.avg_win_hold_sec); + if (lossHoldEl) lossHoldEl.textContent = fmtDuration(d.avg_loss_hold_sec); + if (openHoldEl) { + const cnt = Number(d.open_count) || 0; + if (!cnt) { + openHoldEl.textContent = "0 笔"; + } else { + openHoldEl.textContent = cnt + " 笔 · " + fmtDuration(d.avg_open_hold_sec); + } + } + paintStatsCharts(d); + } + + function fmtDuration(sec) { + if (sec == null || sec === "" || Number.isNaN(Number(sec))) return "—"; + let s = Math.max(0, Math.round(Number(sec))); + if (s < 60) return s + "秒"; + const m = Math.floor(s / 60); + if (m < 60) { + const rs = s % 60; + return rs ? m + "分" + rs + "秒" : m + "分"; + } + const h = Math.floor(m / 60); + const rm = m % 60; + if (h < 24) return rm ? h + "时" + rm + "分" : h + "时"; + const d = Math.floor(h / 24); + const rh = h % 24; + return rh ? d + "天" + rh + "时" : d + "天"; + } + + function setBarFill(el, pct) { + if (!el) return; + const n = Math.max(0, Math.min(100, Number(pct) || 0)); + el.style.width = n + "%"; + } + + function paintStatsCharts(d) { + const ring = document.getElementById("opt-stats-ring"); + const ringLabel = document.getElementById("opt-stats-ring-label"); + const profitBar = document.getElementById("opt-stats-bar-profit"); + const lossBar = document.getElementById("opt-stats-bar-loss"); + const profitBarLabel = document.getElementById("opt-stats-bar-profit-label"); + const lossBarLabel = document.getElementById("opt-stats-bar-loss-label"); + const winHoldBar = document.getElementById("opt-stats-bar-win-hold"); + const lossHoldBar = document.getElementById("opt-stats-bar-loss-hold"); + const winHoldBarLabel = document.getElementById("opt-stats-win-hold-label"); + const lossHoldBarLabel = document.getElementById("opt-stats-loss-hold-label"); + if (!d || !d.ok) { + if (ring) ring.style.setProperty("--win-pct", "0"); + if (ringLabel) ringLabel.textContent = "—"; + [profitBar, lossBar, winHoldBar, lossHoldBar].forEach(function (el) { setBarFill(el, 0); }); + [profitBarLabel, lossBarLabel, winHoldBarLabel, lossHoldBarLabel].forEach(function (el) { + if (el) el.textContent = "—"; + }); + return; + } + const winRate = d.total_closed ? Number(d.win_rate) || 0 : 0; + if (ring) ring.style.setProperty("--win-pct", String(winRate)); + if (ringLabel) ringLabel.textContent = d.total_closed ? winRate.toFixed(0) + "%" : "0%"; + + const profit = Math.max(0, Number(d.avg_win) || 0); + const loss = Math.max(0, Number(d.avg_loss) || 0); + const pnlTotal = profit + loss; + if (pnlTotal > 0) { + setBarFill(profitBar, (profit / pnlTotal) * 100); + setBarFill(lossBar, (loss / pnlTotal) * 100); + if (profitBarLabel) profitBarLabel.textContent = fmt(profit, 2) + " USDC"; + if (lossBarLabel) lossBarLabel.textContent = fmt(loss, 2) + " USDC"; + } else { + setBarFill(profitBar, 0); + setBarFill(lossBar, 0); + if (profitBarLabel) profitBarLabel.textContent = d.win_count ? "0 USDC" : "—"; + if (lossBarLabel) lossBarLabel.textContent = d.loss_count ? "0 USDC" : "—"; + } + + const winHold = Number(d.avg_win_hold_sec) || 0; + const lossHold = Number(d.avg_loss_hold_sec) || 0; + const holdMax = Math.max(winHold, lossHold); + if (holdMax > 0) { + setBarFill(winHoldBar, (winHold / holdMax) * 100); + setBarFill(lossHoldBar, (lossHold / holdMax) * 100); + if (winHoldBarLabel) winHoldBarLabel.textContent = fmtDuration(d.avg_win_hold_sec); + if (lossHoldBarLabel) lossHoldBarLabel.textContent = fmtDuration(d.avg_loss_hold_sec); + } else { + setBarFill(winHoldBar, 0); + setBarFill(lossHoldBar, 0); + if (winHoldBarLabel) winHoldBarLabel.textContent = "—"; + if (lossHoldBarLabel) lossHoldBarLabel.textContent = "—"; + } + } + + async function deleteHistoryRow(key, status, instId, closedAt) { + const warn = status === "open" + ? "该记录仍为持仓中,仅从列表隐藏,不影响交易所持仓.确认删除?" + : "确认从列表隐藏该条历史记录?(期权复盘页也会同步隐藏)"; + if (!confirm(warn)) return; + const body = {}; + if (instId) body.inst_id = instId; + if (closedAt) body.closed_at = closedAt; + const r = await apiJson("/api/options/history/" + encodeURIComponent(key), { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!r.ok) { + alert(r.msg || "删除失败"); + return; + } + refreshAllPositions(); + } + + function optHistoryStatus(h) { + if (h.status_label) return h.status_label; + if (h.status === "open") return "持仓中"; + if (h.status !== "closed") return "持仓中"; + return "已平"; + } + + function optHistoryStatusHtml(h) { + const s = optHistoryStatus(h); + let cls = "opt-hist-status"; + if (s === "已平") cls += " opt-hist-status--closed"; + else if (s === "到期" || s === "强平") cls += " opt-hist-status--expired"; + else cls += " opt-hist-status--open"; + return '' + s + ""; + } + + async function refreshHistory() { + const d = await apiJson("/api/options/history"); + const tbody = document.getElementById("opt-history-tbody"); + tbody.innerHTML = ""; + const list = (d.ok && d.history) || []; + if (!list.length) { + tbody.innerHTML = '暂无历史记录'; + return; + } + list.forEach(function (h) { + const tr = document.createElement("tr"); + const premTxt = fmtDisplay(h.premium_paid_fmt, h.premium_paid != null ? fmtUsdc(h.premium_paid) : null); + const isOpen = h.status === "open"; + const pnl = isOpen ? null : h.realized_pnl; + const pnlTxt = pnl != null ? fmt(pnl, 2) : "—"; + const pnlCls = pnl > 0 ? "pos-pnl-profit" : pnl < 0 ? "pos-pnl-loss" : ""; + const timeTxt = (h.closed_at || h.created_at || "—").replace("T", " ").slice(0, 19); + const histKey = h.history_key || ""; + tr.innerHTML = + '' + (h.inst_id || "") + "" + + "" + fmt(h.sheets, 0) + "" + + "" + premTxt + "" + + "" + optHistoryStatusHtml(h) + "" + + '' + pnlTxt + "" + + '' + timeTxt + "" + + ''; + tbody.appendChild(tr); + }); + tbody.querySelectorAll(".opt-history-del").forEach(function (btn) { + btn.addEventListener("click", function () { + deleteHistoryRow( + btn.getAttribute("data-key"), + btn.getAttribute("data-status"), + btn.getAttribute("data-inst"), + btn.getAttribute("data-closed") + ); + }); + }); + } + + function refreshAllPositions() { + if (refreshAllTimer) clearTimeout(refreshAllTimer); + refreshAllTimer = setTimeout(function () { + refreshAllTimer = null; + refreshPositions(); + refreshStats(); + refreshHistory(); + }, 120); + } + + function onExpiryChange() { + resetMoneyFilterToAll(); + state.strikeExpandAll = false; + const expandCb = document.getElementById("opt-strike-expand-all"); + if (expandCb) expandCb.checked = false; + renderStrikes(); + } + + function bootOptionsPanel() { + updateSizeInputs(); + syncMoneyFilterButtons(); + syncChainViewUI(); + updateUnderlyingLabel(); + refreshPendingOrders(); + startPendingOrdersPoll(); + const hasCache = + chainHasExpiries(panelCache.chain) && + panelCache.underlying === state.underlying && + panelCache.optType === state.optType; + if (hasCache) { + state.chain = panelCache.chain; + renderExpiries(); + renderStrikes(); + refreshAllPositions(); + // 后台静默刷新,避免缓存过期后到期日变空 + loadChain({ soft: true }); + return; + } + requestAnimationFrame(function () { + loadChain(); + refreshAllPositions(); + }); + } + + document.querySelectorAll(".opt-uly-btn").forEach(function (btn) { + btn.addEventListener("click", function () { + document.querySelectorAll(".opt-uly-btn").forEach(function (b) { b.classList.remove("active"); }); + btn.classList.add("active"); + state.underlying = btn.getAttribute("data-uly"); + loadChain(); + }); + }); + + document.querySelectorAll(".opt-view-btn").forEach(function (btn) { + btn.addEventListener("click", function () { + const view = btn.getAttribute("data-view") || "list"; + if (view === state.chainView) return; + state.chainView = view; + if (view === "t") { + state.strikeExpandAll = false; + const expandCb = document.getElementById("opt-strike-expand-all"); + if (expandCb) expandCb.checked = false; + } + syncChainViewUI(); + renderStrikes(); + }); + }); + + const expandAllCb = document.getElementById("opt-strike-expand-all"); + if (expandAllCb) { + expandAllCb.addEventListener("change", function () { + state.strikeExpandAll = !!expandAllCb.checked; + renderStrikes(); + }); + } + + document.querySelectorAll(".opt-type-btn").forEach(function (btn) { + btn.addEventListener("click", function () { + document.querySelectorAll(".opt-type-btn").forEach(function (b) { b.classList.remove("active"); }); + btn.classList.add("active"); + state.optType = btn.getAttribute("data-type"); + resetMoneyFilterToAll(); + renderExpiryOptions(true); + renderStrikes(); + }); + }); + + document.querySelectorAll(".opt-money-btn").forEach(function (btn) { + btn.addEventListener("click", function () { + state.moneyFilter = btn.getAttribute("data-money") || "all"; + syncMoneyFilterButtons(); + renderStrikes(); + }); + }); + + document.getElementById("opt-exp-select").addEventListener("change", onExpiryChange); + document.getElementById("opt-load-chain").addEventListener("click", loadChain); + document.getElementById("opt-refresh-positions").addEventListener("click", refreshAllPositions); + document.getElementById("opt-open-btn").addEventListener("click", openPosition); + const pendingRefreshBtn = document.getElementById("opt-pending-refresh"); + if (pendingRefreshBtn) { + pendingRefreshBtn.addEventListener("click", function () { + refreshPendingOrders(); + }); + } + bindOptionsPosTabs(); + + document.querySelectorAll('input[name="opt-size-mode"]').forEach(function (r) { + r.addEventListener("change", function () { + updateSizeInputs(); + if (state.selectedInst) selectContract(state.selectedInst, null, true); + }); + }); + + ["opt-sheets-amount", "opt-eth-amount", "opt-target-idx"].forEach(function (id) { + const el = document.getElementById(id); + if (!el) return; + el.addEventListener("change", function () { + if (id === "opt-target-idx") { + updateEstimatedProfit(); + return; + } + if (state.selectedInst) selectContract(state.selectedInst, null, true); + }); + if (id === "opt-target-idx") { + el.addEventListener("input", updateEstimatedProfit); + } + }); + + bootOptionsPanel(); + + window.OptionsPanelLive = { + refreshSoft: function () { + refreshAllPositions(); + }, + refreshChain: loadChain, + }; +})(); diff --git a/lib/common/static/options_position_cards.js b/lib/common/static/options_position_cards.js new file mode 100644 index 0000000..943babe --- /dev/null +++ b/lib/common/static/options_position_cards.js @@ -0,0 +1,232 @@ +(function (global) { + "use strict"; + + function fmt(v, d) { + if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; + return Number(v).toFixed(d == null ? 2 : d); + } + + function fmtDisplay(v, fallback) { + if (v !== null && v !== undefined && String(v).trim() !== "") return String(v); + if (fallback !== undefined) return fmtDisplay(fallback); + return "—"; + } + + function fmtOptionPx(v, tickSz) { + if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; + const n = Number(v); + const tick = Number(tickSz); + if (!tickSz || Number.isNaN(tick) || tick <= 0) { + let s = n.toFixed(4).replace(/\.?0+$/, ""); + return s || "0"; + } + let decimals = 0; + if (tick < 1) decimals = Math.max(0, -Math.round(Math.log10(tick))); + else if (String(tick).indexOf(".") >= 0) decimals = String(tick).split(".")[1].length; + let s = n.toFixed(decimals); + // 仅裁小数尾零;整数 tick(BTC=5)时绝不能把 1370 裁成 137 + if (decimals > 0) s = s.replace(/\.?0+$/, ""); + return s || "0"; + } + + function fmtUsdc(v) { + if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; + return Number(v).toFixed(2); + } + + function optTypeLabel(t) { + return (t || "").toUpperCase() === "P" ? "看跌 Put" : "看涨 Call"; + } + + function pnlCls(upl, hub) { + if (upl > 0) return hub ? "pnl-pos" : "pos-pnl-profit"; + if (upl < 0) return hub ? "pnl-neg" : "pos-pnl-loss"; + return ""; + } + + function fmtPxSz(px, sz, tickSz) { + if (px === null || px === undefined || Number.isNaN(Number(px))) return "—"; + let price = fmtOptionPx(px, tickSz); + if (sz === null || sz === undefined || sz === "" || Number.isNaN(Number(sz))) return price; + const s = Number(sz); + const size = Math.abs(s - Math.round(s)) < 1e-9 ? String(Math.round(s)) : String(s); + return price + "/" + size; + } + + /** 买盘深度:价格/流动性;仅展示平仓所需档位(买一不够才出买二…). */ + function fmtCloseLevels(preview, tickSz) { + if (preview && preview.bid_invalid) { + return "暂无有效买盘"; + } + const levels = ((preview && preview.levels) || []).slice(0, 5); + if (!levels.length) return "—"; + return levels.map(function (x, idx) { + const levelNo = x.level != null ? x.level : idx + 1; + const liq = x.available_sheets != null ? x.available_sheets : x.sz; + return "买" + levelNo + " " + fmtPxSz(x.px, liq, tickSz); + }).join(" · "); + } + + function closeGateHint(preview) { + if (!preview) return ""; + if (preview.bid_invalid || preview.manual_close_blocked) { + return preview.bid_invalid_reason || "当前买一无效,禁止买一平仓"; + } + const gate = preview.close_gate || {}; + if (preview.close_gate_blocked || (gate.ready === false && !gate.passed)) { + return "目标门控: " + (preview.close_gate_msg || gate.msg || "可回收需≥2×权利金并持续2分钟"); + } + return ""; + } + + function netPnlFromPos(p) { + const preview = (p && p.close_preview) || {}; + if (preview.estimated_pnl != null && !Number.isNaN(Number(preview.estimated_pnl))) { + return Number(preview.estimated_pnl); + } + const recv = Number(preview.total_received); + const prem = Number(p && p.premium_paid); + if (preview.total_received != null && !Number.isNaN(recv) && !Number.isNaN(prem)) { + return recv - prem; + } + return null; + } + + function netRoiFromPos(p, net) { + const preview = (p && p.close_preview) || {}; + if (preview.estimated_pnl_ratio_pct != null && !Number.isNaN(Number(preview.estimated_pnl_ratio_pct))) { + return Number(preview.estimated_pnl_ratio_pct); + } + const prem = Number(p && p.premium_paid); + if (net == null || Number.isNaN(prem) || prem <= 0) return null; + return (net / prem) * 100; + } + + function fmtClosePreview(preview, premiumPaid, hub) { + if (!preview || preview.total_received == null) return "—"; + const recvTxt = fmtUsdc(preview.total_received); + let cls = ""; + const prem = Number(premiumPaid); + const recv = Number(preview.total_received); + if (!Number.isNaN(prem) && !Number.isNaN(recv)) { + if (recv > prem) cls = " " + pnlCls(1, hub); + else if (recv < prem) cls = " " + pnlCls(-1, hub); + } + return '' + recvTxt + " USDC"; + } + + function expiryCdHtml(expMs) { + const ms = expMs != null && expMs !== "" ? String(expMs) : ""; + if (!ms) return "—"; + return ''; + } + + function renderCardInner(p, opts) { + opts = opts || {}; + const hub = !!opts.hub; + const readOnly = !!opts.readOnly; + const net = netPnlFromPos(p); + const roi = netRoiFromPos(p, net); + const uplCls = pnlCls(net, hub); + const sideCls = (p.opt_type || "").toUpperCase() === "P" ? "pos-side-short" : "pos-side-long"; + const expMs = p.exp_time_ms != null ? p.exp_time_ms : p.exp_time; + const expAttr = expMs != null && expMs !== "" ? String(expMs) : ""; + const closePreview = p.close_preview || {}; + const tickSz = p.tick_sz; + const premTxt = fmtDisplay(p.premium_paid_fmt, p.premium_paid != null ? fmtUsdc(p.premium_paid) : null); + const avgTxt = p.avg_px != null ? fmtOptionPx(p.avg_px, tickSz) : fmtDisplay(p.avg_px_fmt); + const markTxt = p.mark_px != null ? fmtOptionPx(p.mark_px, tickSz) : fmtDisplay(p.mark_px_fmt); + let headActions = ""; + if (!readOnly) { + const closeSheets = p.avail_pos != null && Number(p.avail_pos) > 0 ? p.avail_pos : p.pos; + headActions = + '
        ' + + '' + + "
        "; + } + return ( + '
        ' + + '
        ' + (p.inst_id || "") + "" + + '' + optTypeLabel(p.opt_type) + "
        " + + headActions + + "
        " + + '
        ' + + '行权价: ' + fmt(p.strike, 0) + "" + + '张数: ' + fmt(p.pos, 0) + " · 币量 " + fmt(p.eth_amount, 4) + "" + + (expAttr + ? '到期倒计时: ' + expiryCdHtml(expAttr) + "" + : "") + + "
        " + + '
        ' + + '
        权利金' + premTxt + " USDC
        " + + '
        开仓均价' + avgTxt + "
        " + + '
        标记价' + markTxt + "
        " + + '
        指数价' + fmt(p.idx_px, 0) + "
        " + + '
        到期平衡' + fmt(p.expiry_be_px, 0) + "
        " + + '
        平掉回本' + fmt(p.close_be_px, 0) + "
        " + + '
        净盈亏' + + (closePreview.bid_invalid || net == null ? "—" : fmt(net, 2)) + "
        " + + '
        收益率' + + (closePreview.bid_invalid || roi == null ? "—" : fmt(roi, 2) + "%") + "
        " + + '
        买盘深度' + fmtCloseLevels(closePreview, tickSz) + "
        " + + '
        按买盘回收' + + (closePreview.bid_invalid + ? '暂无有效买盘' + : fmtClosePreview(closePreview, p.premium_paid, hub)) + "
        " + + "
        " + + (function () { + const hint = closeGateHint(closePreview); + return hint ? '
        ' + hint + "
        " : ""; + })() + + (p.target_index != null + ? (function () { + const eth = p.eth_amount != null ? Number(p.eth_amount) + : (Number(p.pos) > 0 ? Number(p.pos) * Number(p.ct_mult || 0.01) : null); + const strike = Number(p.strike); + const tgt = Number(p.target_index); + const prem = Number(p.premium_paid); + let profit = null; + let value = null; + if (Number.isFinite(tgt) && Number.isFinite(strike) && eth > 0) { + const o = String(p.opt_type || "").toUpperCase(); + const intrinsic = o === "C" ? Math.max(0, tgt - strike) : o === "P" ? Math.max(0, strike - tgt) : null; + if (intrinsic != null) { + value = Math.round(intrinsic * eth * 100) / 100; + if (Number.isFinite(prem)) profit = Math.round((value - prem) * 100) / 100; + } + } + const profitTxt = profit == null ? "—" : ((profit > 0 ? "+" : "") + fmtUsdc(profit) + " USDC"); + const profitCls = profit > 0 ? " pnl-pos" : profit < 0 ? " pnl-neg" : ""; + const hedgeTarget = p.hedge_plan_target || null; + const managed = hedgeTarget && hedgeTarget.managed_by === "hedge_plan"; + return ( + '
        ' + + '' + (managed ? "对冲计划 #" + hedgeTarget.plan_id : "委托") + "" + + '目标 ' + fmt(p.target_index, 1) + "" + + '价值 ' + (value == null ? "—" : fmtUsdc(value) + " USDC") + "" + + '预估盈利 ' + profitTxt + "" + + '' + + (managed ? "进行中 · 由对冲计划监控,到位后仅平盈利腿" : "监控中 · 到位按买一限价平") + + "
        " + ); + })() + : "") + ); + } + + function renderCard(p, opts) { + opts = opts || {}; + const hub = !!opts.hub; + const extraCls = hub ? " hub-pos-card hub-opt-pos-card" : " opt-pos-card"; + return ( + '
        ' + + renderCardInner(p, opts) + + "
        " + ); + } + + global.OptionsPositionCards = { + renderCardInner: renderCardInner, + renderCard: renderCard, + }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/options_review.js b/lib/common/static/options_review.js new file mode 100644 index 0000000..ab35614 --- /dev/null +++ b/lib/common/static/options_review.js @@ -0,0 +1,1111 @@ +/** + * OKX 期权复盘:待复盘交易(5行) → 点复盘出表单 → 复盘记录详情 → 统计. + */ +(function (global) { + "use strict"; + + var PAGE_SIZE = 5; + var TAB_LABELS = { + option_spot: "期权交易记录", + options_options: "期期对冲记录", + perp_options: "永期对冲记录", + }; + var FORM_PRESETS = { + option_spot: { + strategy: ["顺势", "反转"], + direction: ["多", "空"], + entry: ["假突破", "结构突破"], + }, + hedge: { + strategy: ["横盘", "趋势"], + direction: ["多", "空"], + entry: ["横盘博弈方向", "趋势对冲止损"], + }, + }; + var RESULT_OPTIONS = ["盈利", "亏损", "持平"]; + var activeSource = "option_spot"; + var currentTradeId = null; + var draftId = ""; + var tradesCache = {}; + var reviewedCache = {}; + var tradesPage = 0; + var tradesPages = 1; + var reviewedPage = 0; + var reviewedPages = 1; + + function $(id) { + return document.getElementById(id); + } + + function escapeHtml(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function fmtPnl(v) { + if (v == null || v === "") return "—"; + var n = Number(v); + if (Number.isNaN(n)) return "—"; + return (n >= 0 ? "+" : "") + n.toFixed(2); + } + + function fmtHold(sec) { + if (sec == null) return "—"; + var s = Math.max(0, Number(sec) || 0); + if (s < 3600) return Math.round(s / 60) + "m"; + if (s < 86400) return (s / 3600).toFixed(1) + "h"; + return (s / 86400).toFixed(1) + "d"; + } + + function toLocalInput(ts) { + if (!ts) return ""; + var s = String(ts).trim().replace(" ", "T"); + if (s.length >= 16) return s.slice(0, 16); + return s; + } + + function tradeTitle(t) { + if (!t) return "—"; + if (t.source_type === "option_spot") return t.inst_id || "—"; + return ( + (t.underlying || "") + + (t.direction ? " " + t.direction : "") + + (t.plan_close_reason ? " · " + t.plan_close_reason : "") + ); + } + + function pnlStyle(v) { + var n = Number(v); + if (n > 0) return "color:#3dd68c"; + if (n < 0) return "color:#f07178"; + return ""; + } + + function newDraftId() { + if (global.crypto && typeof global.crypto.randomUUID === "function") { + return global.crypto.randomUUID().replace(/-/g, ""); + } + var s = ""; + for (var i = 0; i < 32; i++) s += Math.floor(Math.random() * 16).toString(16); + return s; + } + + function baseQs() { + var p = new URLSearchParams(); + p.set("source_type", activeSource); + var uly = ($("or-filter-uly") || {}).value || ""; + var opt = ($("or-filter-opt") || {}).value || ""; + var strategy = (($("or-filter-strategy") || {}).value || "").trim(); + var from = ($("or-filter-from") || {}).value || ""; + var to = ($("or-filter-to") || {}).value || ""; + if (uly) p.set("underlying", uly); + if (opt) p.set("opt_type", opt); + if (strategy) p.set("strategy_tag", strategy); + if (from) p.set("closed_from", from.replace("T", " ") + ":00"); + if (to) p.set("closed_to", to.replace("T", " ") + ":00"); + if (($("or-include-hedge-legs") || {}).checked) p.set("include_hedge_legs", "1"); + return p; + } + + function setSyncStatus(text) { + var el = $("or-sync-status"); + if (el) el.textContent = text || ""; + } + + function reloadAll() { + setSyncStatus("读取本地记录…"); + loadTrades({ sync: true }); + loadReviewed({ sync: false }); + loadStats(); + } + + function isHedgeSource(sourceType) { + return sourceType === "options_options" || sourceType === "perp_options"; + } + + function fillSelect(el, options, placeholder) { + if (!el) return; + var keep = el.value; + el.innerHTML = ""; + var first = document.createElement("option"); + first.value = ""; + first.textContent = placeholder || ""; + el.appendChild(first); + (options || []).forEach(function (v) { + var opt = document.createElement("option"); + opt.value = v; + opt.textContent = v; + el.appendChild(opt); + }); + if (keep) setSelectValue(el, keep); + } + + function setSelectValue(el, value) { + if (!el) return; + var v = value == null ? "" : String(value); + if (!v) { + el.value = ""; + return; + } + var found = false; + for (var i = 0; i < el.options.length; i++) { + if (el.options[i].value === v) { + found = true; + break; + } + } + if (!found) { + var opt = document.createElement("option"); + opt.value = v; + opt.textContent = v; + el.appendChild(opt); + } + el.value = v; + } + + function applyFormPresets(sourceType) { + var preset = isHedgeSource(sourceType) ? FORM_PRESETS.hedge : FORM_PRESETS.option_spot; + fillSelect($("or-f-strategy"), preset.strategy, "策略标签"); + fillSelect($("or-f-direction"), preset.direction, "方向判断"); + fillSelect($("or-f-entry"), preset.entry, "入场逻辑"); + fillSelect($("or-f-result"), RESULT_OPTIONS, "结果标签"); + } + + function autoDirection(t) { + if (!t) return ""; + if (isHedgeSource(t.source_type)) { + var d = String(t.direction || "").trim().toLowerCase(); + if (d === "long" || d === "buy" || d === "多") return "多"; + if (d === "short" || d === "sell" || d === "空") return "空"; + return ""; + } + var ot = String(t.opt_type || "").trim().toUpperCase(); + if (ot === "C" || ot === "CALL") return "多"; + if (ot === "P" || ot === "PUT") return "空"; + return ""; + } + + function autoResultTag(pnl) { + if (pnl == null || pnl === "") return ""; + var n = Number(pnl); + if (Number.isNaN(n)) return ""; + if (n > 0) return "盈利"; + if (n < 0) return "亏损"; + return "持平"; + } + + function setActiveTab(source) { + activeSource = source || "option_spot"; + tradesPage = 0; + reviewedPage = 0; + document.querySelectorAll(".or-tab").forEach(function (btn) { + btn.classList.toggle("active", btn.getAttribute("data-source") === activeSource); + }); + var title = $("or-list-title"); + if (title) title.textContent = TAB_LABELS[activeSource] || "记录"; + applyFormPresets(activeSource); + hideJournalForm(); + hideDetail(); + reloadAll(); + } + + function updateTradesPager() { + var label = $("or-trades-page-label"); + var prev = $("or-trades-prev"); + var next = $("or-trades-next"); + if (label) { + label.textContent = "第 " + (tradesPage + 1) + " / " + tradesPages + " 页"; + } + if (prev) prev.disabled = tradesPage <= 0; + if (next) next.disabled = tradesPage + 1 >= tradesPages; + } + + function updateReviewedPager() { + var label = $("or-reviewed-page-label"); + var prev = $("or-reviewed-prev"); + var next = $("or-reviewed-next"); + if (label) { + label.textContent = "第 " + (reviewedPage + 1) + " / " + reviewedPages + " 页"; + } + if (prev) prev.disabled = reviewedPage <= 0; + if (next) next.disabled = reviewedPage + 1 >= reviewedPages; + } + + function applyPagerMeta(data, kind) { + var pages = Number(data.pages || 1); + if (!pages || pages < 1) pages = 1; + var clamped = false; + if (kind === "trades") { + tradesPages = pages; + if (tradesPage >= tradesPages) { + tradesPage = Math.max(0, tradesPages - 1); + clamped = true; + } + updateTradesPager(); + } else { + reviewedPages = pages; + if (reviewedPage >= reviewedPages) { + reviewedPage = Math.max(0, reviewedPages - 1); + clamped = true; + } + updateReviewedPager(); + } + return clamped; + } + + function beginListLoad(wrapId, soft) { + var wrap = $(wrapId); + if (!wrap) return null; + if (soft) { + if (!wrap.style.minHeight) { + wrap.style.minHeight = Math.max(wrap.offsetHeight, 1) + "px"; + } + wrap.classList.add("or-list-loading"); + } else { + wrap.classList.remove("or-list-loading"); + wrap.style.minHeight = ""; + } + return wrap; + } + + function endListLoad(wrap) { + if (!wrap) return; + wrap.classList.remove("or-list-loading"); + wrap.style.minHeight = ""; + } + + function loadTrades(opts) { + opts = opts || {}; + var doSync = opts.sync !== false; + var soft = !!opts.soft; + var tbody = $("or-trades-tbody"); + if (!tbody) return; + var wrap = beginListLoad("or-trades-wrap", soft); + if (!soft) { + tbody.innerHTML = '加载中…'; + } + var p = baseQs(); + p.set("reviewed", "0"); + p.set("limit", String(PAGE_SIZE)); + p.set("offset", String(tradesPage * PAGE_SIZE)); + if (!doSync) p.set("sync", "0"); + fetch("/api/options/review/trades?" + p.toString(), { credentials: "same-origin" }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + if (doSync) setSyncStatus("本地记录已加载"); + if (!data.ok) { + tbody.innerHTML = '加载失败'; + endListLoad(wrap); + return; + } + if (applyPagerMeta(data, "trades") && Number(data.total || 0) > 0) { + loadTrades(opts); + return; + } + var rows = data.trades || []; + tradesCache = {}; + if (!rows.length) { + tbody.innerHTML = + '暂无待复盘记录'; + endListLoad(wrap); + return; + } + tbody.innerHTML = rows + .map(function (t) { + tradesCache[t.id] = t; + var active = currentTradeId === t.id ? " or-row-active" : ""; + return ( + '' + + "" + + escapeHtml(t.source_label || t.source_type) + + "" + + "" + + escapeHtml(tradeTitle(t)) + + "" + + '' + + fmtPnl(t.realized_pnl_total) + + "" + + '' + + escapeHtml(t.opened_at || "—") + + "
        " + + escapeHtml(t.closed_at || "—") + + "" + + "" + + fmtHold(t.hold_seconds) + + "" + + ' ' + + '' + + "" + ); + }) + .join(""); + tbody.querySelectorAll(".or-review-btn").forEach(function (btn) { + btn.addEventListener("click", function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + openJournalForm(Number(btn.getAttribute("data-id"))); + }); + }); + tbody.querySelectorAll(".or-hide-btn").forEach(function (btn) { + btn.addEventListener("click", function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + hideTrade(Number(btn.getAttribute("data-id"))); + }); + }); + endListLoad(wrap); + }) + .catch(function () { + tbody.innerHTML = '加载失败'; + endListLoad(wrap); + }); + } + + function loadReviewed(opts) { + opts = opts || {}; + var doSync = opts.sync === true; + var soft = !!opts.soft; + var tbody = $("or-reviewed-tbody"); + if (!tbody) return; + var wrap = beginListLoad("or-reviewed-wrap", soft); + if (!soft) { + tbody.innerHTML = '加载中…'; + } + var p = baseQs(); + p.set("reviewed", "1"); + p.set("limit", String(PAGE_SIZE)); + p.set("offset", String(reviewedPage * PAGE_SIZE)); + if (!doSync) p.set("sync", "0"); + fetch("/api/options/review/trades?" + p.toString(), { credentials: "same-origin" }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + if (!data.ok) { + tbody.innerHTML = '加载失败'; + endListLoad(wrap); + return; + } + if (applyPagerMeta(data, "reviewed") && Number(data.total || 0) > 0) { + loadReviewed(opts); + return; + } + var rows = data.trades || []; + reviewedCache = {}; + if (!rows.length) { + tbody.innerHTML = '暂无复盘记录'; + endListLoad(wrap); + return; + } + tbody.innerHTML = rows + .map(function (t) { + reviewedCache[t.id] = t; + return ( + '' + + "" + + escapeHtml(t.source_label || t.source_type) + + "" + + "" + + escapeHtml(tradeTitle(t)) + + "" + + '' + + fmtPnl(t.realized_pnl_total) + + "" + + "" + + escapeHtml(t.strategy_tag || "—") + + "" + + "" + + escapeHtml(t.result_tag || "—") + + "" + + '' + + escapeHtml(t.reviewed_at || "—") + + "" + + "" + ); + }) + .join(""); + tbody.querySelectorAll(".or-reviewed-row").forEach(function (tr) { + tr.addEventListener("click", function () { + openDetail(Number(tr.getAttribute("data-id"))); + }); + }); + endListLoad(wrap); + }) + .catch(function () { + tbody.innerHTML = '加载失败'; + endListLoad(wrap); + }); + } + + function hideDetail() { + var panel = $("or-detail-panel"); + if (panel) panel.classList.add("hidden"); + } + + function openDetail(tradeId) { + var panel = $("or-detail-panel"); + if (!panel) return; + panel.classList.remove("hidden"); + ($("or-detail-title") || {}).textContent = "加载中…"; + ($("or-detail-meta") || {}).innerHTML = ""; + ($("or-detail-text") || {}).innerHTML = ""; + ($("or-detail-images") || {}).innerHTML = ""; + panel.scrollIntoView({ behavior: "smooth", block: "nearest" }); + + fetch("/api/options/review/trades/" + tradeId, { credentials: "same-origin" }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + if (!data.ok || !data.trade) { + ($("or-detail-title") || {}).textContent = "加载失败"; + return; + } + renderDetail(data.trade); + }) + .catch(function () { + ($("or-detail-title") || {}).textContent = "加载失败"; + }); + } + + function renderDetail(t) { + var e = t.entry || {}; + reviewedCache[t.id] = t; + ($("or-detail-title") || {}).textContent = + "复盘详情 · " + (t.source_label || "") + " · " + tradeTitle(t); + var editBtn = $("or-detail-edit-btn"); + if (editBtn) editBtn.setAttribute("data-id", String(t.id)); + + var meta = $("or-detail-meta"); + if (meta) { + var cells = [ + ["标的", t.underlying || "—"], + ["合约/计划", tradeTitle(t)], + ["盈亏", fmtPnl(t.realized_pnl_total)], + ["持有", fmtHold(t.hold_seconds)], + ["开仓", t.opened_at || "—"], + ["平仓", t.closed_at || "—"], + ["策略", e.strategy_tag || "—"], + ["方向", e.direction_view || "—"], + ["结果", e.result_tag || "—"], + ["离场", e.exit_reason || "—"], + ["按计划", e.followed_plan || "—"], + ["入场逻辑", e.entry_logic || "—"], + ]; + if (t.is_hedge) { + cells.push(["永续盈亏", fmtPnl(t.realized_pnl_perp)]); + cells.push(["期权盈亏", fmtPnl(t.realized_pnl_options)]); + } + meta.innerHTML = cells + .map(function (pair) { + return ( + "
        " + + escapeHtml(pair[0]) + + "
        " + + escapeHtml(pair[1]) + + "
        " + ); + }) + .join(""); + } + + var text = $("or-detail-text"); + if (text) { + var lines = []; + if (e.mistake_tags) lines.push("
        心理标签:" + escapeHtml(e.mistake_tags) + "
        "); + if (e.note) lines.push("
        备注:" + escapeHtml(e.note).replace(/\n/g, "
        ") + "
        "); + if (t.legs && t.legs.length) { + lines.push( + "
        计划腿
        " + + t.legs + .map(function (leg) { + return ( + "" + ); + }) + .join("") + + "
        合约盈亏原因
        " + + escapeHtml(leg.leg_role || "") + + "" + + escapeHtml(leg.inst_id || leg.symbol || "") + + "" + + fmtPnl(leg.realized_pnl) + + "" + + escapeHtml(leg.close_reason || "") + + "
        " + ); + } + text.innerHTML = lines.join("") || '
        无额外备注
        '; + } + + var imagesHost = $("or-detail-images"); + if (imagesHost) { + var images = e.images || []; + if (!images.length) { + imagesHost.innerHTML = '
        无截图
        '; + } else { + imagesHost.innerHTML = images + .map(function (img) { + var file = String(img.file || "").trim(); + if (!file) return ""; + var src = "/static/images/options_journal/" + encodeURIComponent(file).replace(/%2F/g, "/"); + var label = escapeHtml(img.tf || "截图"); + return ( + '
        ' + + '' + + label + + "" + + '' +
+              label +
+              '' + + "
        " + ); + }) + .join(""); + imagesHost.querySelectorAll("img").forEach(function (img) { + img.addEventListener("click", function () { + if (typeof global.showImage === "function") { + global.showImage(img.getAttribute("data-src")); + } else { + global.open(img.getAttribute("data-src"), "_blank"); + } + }); + }); + } + } + } + + function renderGroup(title, items) { + if (!items || !items.length) { + return ( + '
        ' + + title + + '
        无数据
        ' + ); + } + var lines = items + .slice(0, 8) + .map(function (g) { + return ( + '
        ' + + "" + + escapeHtml(g.key) + + " · " + + g.count + + "笔" + + "" + + fmtPnl(g.pnl_sum) + + " / 胜" + + (g.win_rate || 0) + + "%" + + "
        " + ); + }) + .join(""); + return ( + '
        ' + + title + + "
        " + + lines + + "
        " + ); + } + + function loadStats() { + var kpi = $("or-kpi"); + var groups = $("or-stats-groups"); + if (!kpi || !groups) return; + fetch("/api/options/review/stats?" + baseQs().toString(), { credentials: "same-origin" }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + if (!data.ok) return; + var k = data.kpi || {}; + kpi.innerHTML = [ + ["笔数", k.total], + ["已复盘率", (k.review_rate || 0) + "%"], + ["胜率", (k.win_rate || 0) + "%"], + ["累计盈亏", fmtPnl(k.pnl_sum)], + ["平均盈亏", fmtPnl(k.avg_pnl)], + ["平均持有", fmtHold(k.avg_hold_sec)], + ] + .map(function (pair) { + return ( + '
        ' + + pair[0] + + '
        ' + + pair[1] + + "
        " + ); + }) + .join(""); + groups.innerHTML = [ + renderGroup("按类型", data.by_source_type), + renderGroup("按标的", data.by_underlying), + renderGroup("按策略", data.by_strategy), + renderGroup("对冲结束原因", data.by_close_reason), + renderGroup("持有周期", data.by_hold_bucket), + renderGroup("Call/Put", data.by_opt_type), + ].join(""); + }) + .catch(function () {}); + } + + function resetUploadSlots() { + draftId = newDraftId(); + var draftEl = $("or-draft-id"); + if (draftEl) draftEl.value = draftId; + document.querySelectorAll("#or-upload-slots .or-upload-hidden").forEach(function (el) { + el.value = ""; + }); + document.querySelectorAll("#or-upload-slots .or-upload-input").forEach(function (el) { + el.value = ""; + }); + document.querySelectorAll("#or-upload-slots .or-upload-status").forEach(function (el) { + el.textContent = ""; + }); + } + + function bindUploadSlots() { + document.querySelectorAll("#or-upload-slots .or-upload-input").forEach(function (input) { + if (input.dataset.orBound === "1") return; + input.dataset.orBound = "1"; + input.addEventListener("change", function () { + var file = input.files && input.files[0]; + var row = input.closest(".journal-upload-row"); + var status = row && row.querySelector(".or-upload-status"); + var hidden = row && row.querySelector(".or-upload-hidden"); + if (!file) { + if (hidden) hidden.value = ""; + if (status) { + status.textContent = ""; + status.className = "journal-upload-status or-upload-status"; + } + return; + } + if (!draftId) draftId = newDraftId(); + if (status) { + status.textContent = "上传中…"; + status.className = "journal-upload-status or-upload-status journal-upload-status--pending"; + } + var fd = new FormData(); + fd.append("draft_id", draftId); + fd.append("tf", input.getAttribute("data-tf") || ""); + fd.append("file", file); + fetch("/api/options/review/upload_slot", { + method: "POST", + body: fd, + credentials: "same-origin", + }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + if (!data.ok) throw new Error(data.error || "fail"); + if (hidden) hidden.value = data.file; + if (status) { + status.textContent = "上传成功 " + data.file; + status.className = "journal-upload-status or-upload-status journal-upload-status--ok"; + } + input.value = ""; + }) + .catch(function () { + if (hidden) hidden.value = ""; + if (status) { + status.textContent = "上传失败"; + status.className = "journal-upload-status or-upload-status journal-upload-status--err"; + } + }); + }); + }); + } + + function setMoodTags(raw) { + var set = {}; + String(raw || "") + .split(/[,,]/) + .map(function (x) { + return x.trim(); + }) + .filter(Boolean) + .forEach(function (x) { + set[x] = true; + }); + document.querySelectorAll(".or-mood").forEach(function (cb) { + cb.checked = !!set[cb.value]; + }); + } + + function collectMoodTags() { + var out = []; + document.querySelectorAll(".or-mood:checked").forEach(function (cb) { + out.push(cb.value); + }); + return out.join(","); + } + + function collectImages() { + var out = []; + document.querySelectorAll("#or-upload-slots .or-upload-hidden").forEach(function (el) { + var file = (el.value || "").trim(); + if (file) out.push({ tf: el.getAttribute("data-tf") || "", file: file }); + }); + return out; + } + + function hideJournalForm() { + currentTradeId = null; + var card = $("or-journal-card"); + if (card) card.classList.add("hidden"); + document.querySelectorAll(".or-trade-row").forEach(function (tr) { + tr.classList.remove("or-row-active"); + }); + ($("or-trade-id") || {}).value = ""; + ($("or-f-open") || {}).value = ""; + ($("or-f-close") || {}).value = ""; + ($("or-f-coin") || {}).value = ""; + ($("or-f-inst") || {}).value = ""; + ($("or-f-pnl") || {}).value = ""; + ($("or-f-hold") || {}).value = ""; + ($("or-f-strategy") || {}).value = ""; + ($("or-f-direction") || {}).value = ""; + ($("or-f-exit") || {}).value = ""; + ($("or-f-followed") || {}).value = ""; + ($("or-f-result") || {}).value = ""; + ($("or-f-entry") || {}).value = ""; + ($("or-f-note") || {}).value = ""; + setMoodTags(""); + resetUploadSlots(); + var summary = $("or-journal-summary"); + if (summary) { + summary.textContent = "截图槽位与合约复盘相同(5m / 15m / 1h / 4h)."; + } + var legsHost = $("or-legs-host"); + if (legsHost) legsHost.innerHTML = ""; + ($("or-save-status") || {}).textContent = ""; + } + + function openJournalForm(tradeId) { + currentTradeId = tradeId; + var card = $("or-journal-card"); + if (!card) return; + card.classList.remove("hidden"); + card.scrollIntoView({ behavior: "smooth", block: "start" }); + document.querySelectorAll(".or-trade-row").forEach(function (tr) { + tr.classList.toggle("or-row-active", Number(tr.getAttribute("data-id")) === tradeId); + }); + resetUploadSlots(); + bindUploadSlots(); + ($("or-save-status") || {}).textContent = "加载中…"; + + fetch("/api/options/review/trades/" + tradeId, { credentials: "same-origin" }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + if (!data.ok || !data.trade) { + ($("or-save-status") || {}).textContent = "加载失败"; + return; + } + fillForm(data.trade); + ($("or-save-status") || {}).textContent = "已选中 #" + tradeId; + }) + .catch(function () { + ($("or-save-status") || {}).textContent = "加载失败"; + }); + } + + function fillForm(t) { + var e = t.entry || {}; + applyFormPresets(t.source_type || activeSource); + ($("or-trade-id") || {}).value = String(t.id || ""); + ($("or-f-open") || {}).value = toLocalInput(t.opened_at); + ($("or-f-close") || {}).value = toLocalInput(t.closed_at); + ($("or-f-coin") || {}).value = t.underlying || ""; + ($("or-f-inst") || {}).value = + t.source_type === "option_spot" + ? t.inst_id || "" + : (t.source_label || "") + (t.plan_close_reason ? " · " + t.plan_close_reason : ""); + ($("or-f-pnl") || {}).value = fmtPnl(t.realized_pnl_total); + ($("or-f-hold") || {}).value = fmtHold(t.hold_seconds); + setSelectValue($("or-f-strategy"), e.strategy_tag || ""); + setSelectValue($("or-f-direction"), e.direction_view || autoDirection(t)); + ($("or-f-exit") || {}).value = e.exit_reason || t.plan_close_reason || ""; + ($("or-f-followed") || {}).value = e.followed_plan || ""; + setSelectValue($("or-f-result"), e.result_tag || autoResultTag(t.realized_pnl_total)); + setSelectValue($("or-f-entry"), e.entry_logic || ""); + ($("or-f-note") || {}).value = e.note || ""; + setMoodTags(e.mistake_tags); + + var summary = $("or-journal-summary"); + if (summary) { + summary.textContent = + (t.source_label || "") + + " · " + + (t.inst_id || t.underlying || "#" + t.id) + + " · 盈亏 " + + fmtPnl(t.realized_pnl_total) + + (t.is_hedge + ? " (永续 " + fmtPnl(t.realized_pnl_perp) + " / 期权 " + fmtPnl(t.realized_pnl_options) + ")" + : ""); + } + + (e.images || []).forEach(function (img) { + var hidden = document.querySelector( + '#or-upload-slots .or-upload-hidden[data-tf="' + img.tf + '"]' + ); + var status = document.querySelector( + '#or-upload-slots .or-upload-status[data-tf="' + img.tf + '"]' + ); + if (hidden && img.file) { + hidden.value = img.file; + if (status) status.textContent = "已有 " + img.file; + } + }); + + var legsHost = $("or-legs-host"); + if (legsHost) { + if (t.legs && t.legs.length) { + legsHost.innerHTML = + "

        计划腿

        " + + t.legs + .map(function (leg) { + return ( + "" + ); + }) + .join("") + + "
        合约盈亏原因
        " + + escapeHtml(leg.leg_role || "") + + "" + + escapeHtml(leg.inst_id || leg.symbol || "") + + "" + + fmtPnl(leg.realized_pnl) + + "" + + escapeHtml(leg.close_reason || "") + + "
        "; + } else { + legsHost.innerHTML = ""; + } + } + } + + function hideTrade(tradeId) { + if (!tradeId) return; + if (!confirm("从待复盘列表删除并隐藏?刷新后也不会再出现.")) return; + fetch("/api/options/review/trades/" + tradeId, { + method: "DELETE", + credentials: "same-origin", + }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + if (!data.ok) { + alert(data.msg || "删除失败"); + return; + } + if (currentTradeId === tradeId) hideJournalForm(); + reloadAll(); + }) + .catch(function () { + alert("删除失败"); + }); + } + + function saveEntry() { + var tradeId = Number(($("or-trade-id") || {}).value || 0); + if (!tradeId) { + alert("请先点击交易记录中的「复盘」"); + return; + } + var strategy = (($("or-f-strategy") || {}).value || "").trim(); + if (!strategy) { + alert("请选择策略标签"); + return; + } + var payload = { + trade_id: tradeId, + strategy_tag: strategy, + direction_view: ($("or-f-direction") || {}).value || "", + exit_reason: ($("or-f-exit") || {}).value || "", + followed_plan: ($("or-f-followed") || {}).value || "", + result_tag: ($("or-f-result") || {}).value || "", + mistake_tags: collectMoodTags(), + entry_logic: ($("or-f-entry") || {}).value || "", + note: ($("or-f-note") || {}).value || "", + images: collectImages(), + }; + ($("or-save-status") || {}).textContent = "保存中…"; + fetch("/api/options/review/entry", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + if (!data.ok) { + ($("or-save-status") || {}).textContent = data.msg || "保存失败"; + return; + } + ($("or-save-status") || {}).textContent = "已保存"; + hideJournalForm(); + reloadAll(); + openDetail(tradeId); + }) + .catch(function () { + ($("or-save-status") || {}).textContent = "保存失败"; + }); + } + + function deleteEntry() { + var tradeId = Number(($("or-trade-id") || {}).value || 0); + if (!tradeId) return; + if (!confirm("删除该条复盘内容与图片?交易记录会回到待复盘列表.")) return; + fetch("/api/options/review/entry/" + tradeId, { + method: "DELETE", + credentials: "same-origin", + }) + .then(function (r) { + return r.json(); + }) + .then(function () { + hideJournalForm(); + hideDetail(); + reloadAll(); + }); + } + + function init() { + if (!$("options-review-root")) return; + document.querySelectorAll(".or-tab").forEach(function (btn) { + btn.addEventListener("click", function () { + setActiveTab(btn.getAttribute("data-source")); + }); + }); + var reloadBtn = $("or-reload-btn"); + var saveBtn = $("or-save-btn"); + var clearBtn = $("or-clear-btn"); + var delBtn = $("or-del-btn"); + var prevBtn = $("or-trades-prev"); + var nextBtn = $("or-trades-next"); + var reviewedPrev = $("or-reviewed-prev"); + var reviewedNext = $("or-reviewed-next"); + var detailClose = $("or-detail-close-btn"); + var detailEdit = $("or-detail-edit-btn"); + if (reloadBtn) reloadBtn.addEventListener("click", reloadAll); + if (saveBtn) saveBtn.addEventListener("click", saveEntry); + if (clearBtn) clearBtn.addEventListener("click", hideJournalForm); + if (delBtn) delBtn.addEventListener("click", deleteEntry); + if (prevBtn) { + prevBtn.addEventListener("click", function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + if (tradesPage <= 0) return; + tradesPage -= 1; + updateTradesPager(); + loadTrades({ sync: false, soft: true }); + }); + } + if (nextBtn) { + nextBtn.addEventListener("click", function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + if (tradesPage + 1 >= tradesPages) return; + tradesPage += 1; + updateTradesPager(); + loadTrades({ sync: false, soft: true }); + }); + } + if (reviewedPrev) { + reviewedPrev.addEventListener("click", function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + if (reviewedPage <= 0) return; + reviewedPage -= 1; + updateReviewedPager(); + loadReviewed({ sync: false, soft: true }); + }); + } + if (reviewedNext) { + reviewedNext.addEventListener("click", function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + if (reviewedPage + 1 >= reviewedPages) return; + reviewedPage += 1; + updateReviewedPager(); + loadReviewed({ sync: false, soft: true }); + }); + } + if (detailClose) detailClose.addEventListener("click", hideDetail); + if (detailEdit) { + detailEdit.addEventListener("click", function () { + var id = Number(detailEdit.getAttribute("data-id") || 0); + if (id) openJournalForm(id); + }); + } + ["or-filter-uly", "or-filter-opt", "or-include-hedge-legs"].forEach(function (id) { + var el = $(id); + if (el) { + el.addEventListener("change", function () { + tradesPage = 0; + reviewedPage = 0; + reloadAll(); + }); + } + }); + ["or-filter-strategy", "or-filter-from", "or-filter-to"].forEach(function (id) { + var el = $(id); + if (el) { + el.addEventListener("change", function () { + tradesPage = 0; + reviewedPage = 0; + reloadAll(); + }); + } + }); + bindUploadSlots(); + hideJournalForm(); + hideDetail(); + setActiveTab("option_spot"); + } + + global.OptionsReview = { + init: init, + openJournalForm: openJournalForm, + hideJournalForm: hideJournalForm, + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/options_settings.js b/lib/common/static/options_settings.js new file mode 100644 index 0000000..71bb615 --- /dev/null +++ b/lib/common/static/options_settings.js @@ -0,0 +1,345 @@ +(function () { + "use strict"; + + const root = document.getElementById("options-settings-root"); + if (!root) return; + + const SWAP_BTNS = ["opt-set-swap-btn", "opt-set-swap-all-btn"]; + const INT_BTNS = ["opt-set-int-btn", "opt-set-int-all-btn"]; + const CROSS_BTNS = ["opt-set-cross-btn", "opt-set-cross-all-btn"]; + + async function apiJson(url, opts) { + const r = await fetch(url, Object.assign({ credentials: "same-origin" }, opts || {})); + return r.json(); + } + + function refreshFundsAfterMutation() { + if (typeof refreshAccountSnapshot !== "function") return; + refreshAccountSnapshot({ force: true }); + setTimeout(function () { + refreshAccountSnapshot({ force: true, silent: true }); + }, 1500); + } + + function setMsg(id, text, isErr) { + const el = document.getElementById(id); + if (!el) return; + el.textContent = text || ""; + el.classList.toggle("opt-error", !!isErr); + el.classList.toggle("opt-success", !!text && !isErr); + } + + function fmtAmt(amount, ccy) { + return `${Number(amount).toFixed(2)} ${ccy}`; + } + + function accountLabel(acct) { + return acct === "trading" ? "交易账户" : "资金账户"; + } + + function swapDirLabel(dir) { + return dir === "usdc_to_usdt" ? "USDC → USDT" : "USDT → USDC"; + } + + function confirmOk(message) { + return window.confirm(message); + } + + function setButtonsBusy(btnIds, busy, busyText) { + btnIds.forEach(function (id) { + const btn = document.getElementById(id); + if (!btn) return; + if (busy) { + if (!btn.dataset.origText) btn.dataset.origText = btn.textContent; + btn.disabled = true; + if (busyText) btn.textContent = busyText; + } else { + btn.disabled = false; + if (btn.dataset.origText) { + btn.textContent = btn.dataset.origText; + delete btn.dataset.origText; + } + } + }); + const amountIds = { + "opt-set-swap-btn": "opt-set-swap-amount", + "opt-set-swap-all-btn": "opt-set-swap-amount", + "opt-set-int-btn": "opt-set-int-amount", + "opt-set-int-all-btn": "opt-set-int-amount", + "opt-set-cross-btn": "opt-set-cross-amount", + "opt-set-cross-all-btn": "opt-set-cross-amount", + }; + btnIds.forEach(function (id) { + const input = document.getElementById(amountIds[id]); + if (input) input.disabled = busy; + }); + } + + function roundAvail(v) { + const n = Number(v); + if (!Number.isFinite(n) || n <= 0) return null; + return Math.round(n * 100) / 100; + } + + async function loadBalances(force, scope) { + const parts = []; + if (force) parts.push("force=1"); + if (scope && scope !== "main") parts.push("scope=" + encodeURIComponent(scope)); + const q = parts.length ? "?" + parts.join("&") : ""; + const d = await apiJson("/api/options/balances" + q); + if (!d.ok) throw new Error(d.msg || "余额拉取失败"); + return d; + } + + function pickBalance(bal, account, ccy) { + const acct = account === "trading" ? "trading" : "funding"; + const c = String(ccy || "").toLowerCase(); + const availKey = acct + "_" + c + "_avail"; + const totalKey = acct + "_" + c; + return roundAvail(bal[availKey] != null ? bal[availKey] : bal[totalKey]); + } + + async function resolveSwapMaxAmount(dir) { + const bal = await loadBalances(true, "main"); + const ccy = dir === "usdc_to_usdt" ? "USDC" : "USDT"; + // 币种兑换走资金账户现货;统一账户下 USDT 有时在交易户,市价单仍可能成交 + let amount = pickBalance(bal, "funding", ccy); + let source = "funding"; + if (!amount && ccy === "USDT") { + const tradingAmt = pickBalance(bal, "trading", ccy); + if (tradingAmt) { + amount = tradingAmt; + source = "trading"; + } + } + return { amount, bal, ccy, source }; + } + + async function resolveMaxAmount(account, ccy, scope) { + const bal = await loadBalances(true, scope || "main"); + return pickBalance(bal, account, ccy); + } + + async function submitSwap(amount) { + setButtonsBusy(SWAP_BTNS, true, "兑换中…"); + setMsg("opt-set-swap-msg", "兑换中,市价成交可能有延时…", false); + try { + const d = await apiJson("/api/options/spot/swap", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + direction: document.getElementById("opt-set-swap-dir").value, + amount: amount, + }), + }); + if (d.ok) { + setMsg("opt-set-swap-msg", "兑换成功", false); + refreshFundsAfterMutation(); + } else { + setMsg("opt-set-swap-msg", "兑换失败:" + (d.msg || "未知错误"), true); + } + return d; + } catch (e) { + setMsg("opt-set-swap-msg", "兑换失败:" + (e.message || "网络错误"), true); + return { ok: false }; + } finally { + setButtonsBusy(SWAP_BTNS, false); + } + } + + const swapBtn = document.getElementById("opt-set-swap-btn"); + if (swapBtn) { + swapBtn.addEventListener("click", async function () { + const amount = parseFloat(document.getElementById("opt-set-swap-amount").value); + if (!amount || amount <= 0) { + setMsg("opt-set-swap-msg", "请输入有效数量", true); + return; + } + await submitSwap(amount); + }); + } + + const swapAllBtn = document.getElementById("opt-set-swap-all-btn"); + if (swapAllBtn) { + swapAllBtn.addEventListener("click", async function () { + try { + const dir = document.getElementById("opt-set-swap-dir").value; + const { amount, bal, ccy, source } = await resolveSwapMaxAmount(dir); + if (!amount) { + const fu = bal.funding_usdt_avail != null ? bal.funding_usdt_avail : bal.funding_usdt; + const tu = bal.trading_usdt_avail != null ? bal.trading_usdt_avail : bal.trading_usdt; + const fc = bal.funding_usdc_avail != null ? bal.funding_usdc_avail : bal.funding_usdc; + setMsg( + "opt-set-swap-msg", + "资金账户可用 " + + ccy + + " 不足(资金户 USDT:" + + (fu != null ? fu : "—") + + " USDC:" + + (fc != null ? fc : "—") + + "; 交易户 USDT:" + + (tu != null ? tu : "—") + + ")", + true + ); + return; + } + const srcLabel = source === "trading" ? "交易账户" : "资金账户"; + const msg = + "确认全部兑换?\n\n" + + "方向:" + swapDirLabel(dir) + "\n" + + "金额:" + fmtAmt(amount, ccy) + "\n" + + "来源:" + srcLabel + "\n\n" + + "将按该账户可用余额发起市价兑换(可能有延时)。请确认。"; + if (!confirmOk(msg)) return; + document.getElementById("opt-set-swap-amount").value = String(amount); + await submitSwap(amount); + } catch (e) { + setMsg("opt-set-swap-msg", "兑换失败:" + (e.message || "余额拉取失败"), true); + } + }); + } + + async function submitInternalTransfer(amount) { + setButtonsBusy(INT_BTNS, true, "划转中…"); + setMsg("opt-set-int-msg", "划转中…", false); + try { + const d = await apiJson("/api/options/transfer", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ccy: document.getElementById("opt-set-int-ccy").value, + from: document.getElementById("opt-set-int-from").value, + to: document.getElementById("opt-set-int-to").value, + amount: amount, + }), + }); + if (d.ok) { + setMsg("opt-set-int-msg", "划转成功", false); + refreshFundsAfterMutation(); + } else { + setMsg("opt-set-int-msg", "划转失败:" + (d.msg || "未知错误"), true); + } + return d; + } catch (e) { + setMsg("opt-set-int-msg", "划转失败:" + (e.message || "网络错误"), true); + return { ok: false }; + } finally { + setButtonsBusy(INT_BTNS, false); + } + } + + const intBtn = document.getElementById("opt-set-int-btn"); + if (intBtn) { + intBtn.addEventListener("click", async function () { + const amount = parseFloat(document.getElementById("opt-set-int-amount").value); + if (!amount || amount <= 0) { + setMsg("opt-set-int-msg", "请输入有效数量", true); + return; + } + await submitInternalTransfer(amount); + }); + } + + const intAllBtn = document.getElementById("opt-set-int-all-btn"); + if (intAllBtn) { + intAllBtn.addEventListener("click", async function () { + try { + const ccy = document.getElementById("opt-set-int-ccy").value; + const from = document.getElementById("opt-set-int-from").value; + const to = document.getElementById("opt-set-int-to").value; + const amount = await resolveMaxAmount(from, ccy, "main"); + if (!amount) { + setMsg("opt-set-int-msg", "划出账户可用余额不足", true); + return; + } + const msg = + "确认全部划转?\n\n" + + "币种:" + ccy + "\n" + + "划出:" + accountLabel(from) + "\n" + + "划入:" + accountLabel(to) + "\n" + + "金额:" + fmtAmt(amount, ccy) + "\n\n" + + "将划转该账户全部可用余额。"; + if (!confirmOk(msg)) return; + document.getElementById("opt-set-int-amount").value = String(amount); + await submitInternalTransfer(amount); + } catch (e) { + setMsg("opt-set-int-msg", "划转失败:" + (e.message || "余额拉取失败"), true); + } + }); + } + + async function submitCrossTransfer(amount) { + setButtonsBusy(CROSS_BTNS, true, "划转中…"); + setMsg("opt-set-cross-msg", "划转中…", false); + try { + const d = await apiJson("/api/options/cross-transfer", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ccy: document.getElementById("opt-set-cross-ccy").value, + amount: amount, + from_account: document.getElementById("opt-set-cross-from").value, + to_account: document.getElementById("opt-set-cross-to").value, + direction: document.getElementById("opt-set-cross-dir").value, + }), + }); + if (d.ok) { + setMsg("opt-set-cross-msg", "划转成功", false); + refreshFundsAfterMutation(); + } else { + setMsg("opt-set-cross-msg", "划转失败:" + (d.msg || "未知错误"), true); + } + return d; + } catch (e) { + setMsg("opt-set-cross-msg", "划转失败:" + (e.message || "网络错误"), true); + return { ok: false }; + } finally { + setButtonsBusy(CROSS_BTNS, false); + } + } + + const crossBtn = document.getElementById("opt-set-cross-btn"); + if (crossBtn) { + crossBtn.addEventListener("click", async function () { + const amount = parseFloat(document.getElementById("opt-set-cross-amount").value); + if (!amount || amount <= 0) { + setMsg("opt-set-cross-msg", "请输入有效数量", true); + return; + } + await submitCrossTransfer(amount); + }); + } + + const crossAllBtn = document.getElementById("opt-set-cross-all-btn"); + if (crossAllBtn) { + crossAllBtn.addEventListener("click", async function () { + try { + const ccy = document.getElementById("opt-set-cross-ccy").value; + const from = document.getElementById("opt-set-cross-from").value; + const to = document.getElementById("opt-set-cross-to").value; + const direction = document.getElementById("opt-set-cross-dir").value; + const scope = direction === "sub_to_main" ? "sub" : "main"; + const sideLabel = direction === "sub_to_main" ? "子账户" : "主账户"; + const amount = await resolveMaxAmount(from, ccy, scope); + if (!amount) { + setMsg("opt-set-cross-msg", sideLabel + "划出账户可用余额不足", true); + return; + } + const msg = + "确认全部划转?\n\n" + + "方向:" + (direction === "main_to_sub" ? "主 → 子" : "子 → 主") + "\n" + + "币种:" + ccy + "\n" + + "划出:" + sideLabel + " · " + accountLabel(from) + "\n" + + "划入:" + (direction === "main_to_sub" ? "子账户" : "主账户") + " · " + accountLabel(to) + "\n" + + "金额:" + fmtAmt(amount, ccy) + "\n\n" + + "将划转该账户全部可用余额。"; + if (!confirmOk(msg)) return; + document.getElementById("opt-set-cross-amount").value = String(amount); + await submitCrossTransfer(amount); + } catch (e) { + setMsg("opt-set-cross-msg", "划转失败:" + (e.message || "余额拉取失败"), true); + } + }); + } +})(); diff --git a/lib/common/static/order_entry_model.js b/lib/common/static/order_entry_model.js new file mode 100644 index 0000000..e2ca41d --- /dev/null +++ b/lib/common/static/order_entry_model.js @@ -0,0 +1,214 @@ +(function (global) { + var delegated = false; + + function queryInScope(scope, id) { + if (scope && scope.querySelector) return scope.querySelector("#" + id); + return document.getElementById(id); + } + + function categoriesData() { + return global.ORDER_ENTRY_MODEL_CATEGORIES || []; + } + + function codeToCategoryMap() { + return global.ORDER_ENTRY_MODEL_CODE_TO_CATEGORY || {}; + } + + function tradeStyleForCode(code, modelSel) { + if (modelSel && code) { + var opt = modelSel.querySelector('option[value="' + code.replace(/"/g, '\\"') + '"]'); + if (opt) { + var ds = opt.getAttribute("data-trade-style"); + if (ds === "swing" || ds === "trend") return ds; + } + } + var map = global.ORDER_ENTRY_MODEL_TRADE_STYLE || {}; + return map[code] || "trend"; + } + + function findOption(catKey, code) { + var cats = categoriesData(); + for (var i = 0; i < cats.length; i++) { + if (cats[i].key !== catKey) continue; + var opts = cats[i].options || []; + for (var j = 0; j < opts.length; j++) { + if (opts[j].code === code) return opts[j]; + } + } + return null; + } + + function filterDomSubOptions(modelSel, catKey, preserveCode) { + var tagged = modelSel.querySelectorAll("option[data-entry-category]"); + if (!tagged.length) return false; + + var any = false; + for (var i = 0; i < tagged.length; i++) { + var opt = tagged[i]; + var show = !!catKey && opt.getAttribute("data-entry-category") === catKey; + opt.hidden = !show; + opt.disabled = !show; + if (show) any = true; + } + + modelSel.disabled = !any; + if (!any) { + modelSel.value = ""; + return true; + } + + var pick = preserveCode || ""; + if (pick) { + var picked = modelSel.querySelector('option[value="' + pick.replace(/"/g, '\\"') + '"]:not([disabled])'); + if (picked) { + modelSel.value = pick; + return true; + } + } + + var visible = []; + for (var k = 0; k < tagged.length; k++) { + if (!tagged[k].disabled) visible.push(tagged[k]); + } + if (visible.length === 1) modelSel.value = visible[0].value; + else modelSel.value = ""; + return true; + } + + function rebuildFromCategories(modelSel, catKey, preserveCode) { + var cats = categoriesData(); + var cat = null; + for (var i = 0; i < cats.length; i++) { + if (cats[i].key === catKey) { + cat = cats[i]; + break; + } + } + + modelSel.innerHTML = ""; + var placeholder = document.createElement("option"); + placeholder.value = ""; + placeholder.textContent = "类型"; + modelSel.appendChild(placeholder); + + if (!cat || !cat.options || !cat.options.length) { + modelSel.disabled = true; + modelSel.value = ""; + return; + } + + modelSel.disabled = false; + var pick = preserveCode || ""; + for (var k = 0; k < cat.options.length; k++) { + var o = cat.options[k]; + var opt = document.createElement("option"); + opt.value = o.code; + opt.textContent = o.label; + if (o.trade_style) opt.setAttribute("data-trade-style", o.trade_style); + if (o.help) opt.title = o.help; + modelSel.appendChild(opt); + } + + if (pick && findOption(catKey, pick)) { + modelSel.value = pick; + } else if (cat.options.length === 1) { + modelSel.value = cat.options[0].code; + } else { + modelSel.value = ""; + } + } + + function rebuildEntryModelSubSelect(preserveCode, scope) { + var root = scope && scope.querySelector ? scope : document; + var catSel = queryInScope(root, "order-entry-category"); + var modelSel = queryInScope(root, "order-entry-model"); + if (!catSel || !modelSel) return; + + var catKey = catSel.value; + if (filterDomSubOptions(modelSel, catKey, preserveCode)) { + syncOrderEntryModelTradeStyle(modelSel); + return; + } + rebuildFromCategories(modelSel, catKey, preserveCode); + syncOrderEntryModelTradeStyle(modelSel); + } + + function syncOrderEntryModelTradeStyle(modelSel) { + if (!modelSel) modelSel = document.getElementById("order-entry-model"); + var hidden = document.getElementById("order-trade-style-hidden"); + var hint = document.getElementById("order-trade-style-hint"); + if (!modelSel || !hidden) return; + var labels = { trend: "趋势单", swing: "波段单" }; + var code = modelSel.value || ""; + var ts = tradeStyleForCode(code, modelSel); + hidden.value = ts; + if (hint) hint.textContent = labels[ts] || ts; + } + + function wireDelegation() { + if (delegated) return; + delegated = true; + document.addEventListener( + "change", + function (ev) { + var t = ev.target; + if (!t || !t.id) return; + if (t.id === "order-entry-category") { + rebuildEntryModelSubSelect(""); + return; + } + if (t.id === "order-entry-model") { + syncOrderEntryModelTradeStyle(t); + } + }, + false + ); + } + + function initOrderEntryModelSelect(root) { + wireDelegation(); + var scope = root && root.querySelector ? root : document; + var catSel = queryInScope(scope, "order-entry-category"); + var modelSel = queryInScope(scope, "order-entry-model"); + if (!catSel || !modelSel) return; + + var presetCode = modelSel.getAttribute("data-preset-code") || modelSel.value || ""; + if (presetCode) { + var catMap = codeToCategoryMap(); + var catKey = catMap[presetCode]; + if (catKey) { + catSel.value = catKey; + rebuildEntryModelSubSelect(presetCode, scope); + return; + } + } + rebuildEntryModelSubSelect("", scope); + } + + global.paintOrderLeverageHint = function (leverage) { + var hidden = document.getElementById("order-leverage"); + var hint = document.getElementById("order-leverage-hint"); + if (!hidden && !hint) return; + var lev = parseInt(leverage, 10); + if (!Number.isFinite(lev) || lev <= 0) { + if (hint) hint.textContent = "杠杆 —"; + if (hidden) hidden.value = ""; + return; + } + if (hidden) hidden.value = String(lev); + if (hint) hint.textContent = "杠杆 " + lev + "x"; + }; + + global.initOrderEntryModelSelect = initOrderEntryModelSelect; + global.syncOrderEntryModelTradeStyle = syncOrderEntryModelTradeStyle; + global.rebuildEntryModelSubSelect = rebuildEntryModelSubSelect; + + wireDelegation(); + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", function () { + initOrderEntryModelSelect(); + }); + } else { + initOrderEntryModelSelect(); + } +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/records_review_page.js b/lib/common/static/records_review_page.js new file mode 100644 index 0000000..d4f7a13 --- /dev/null +++ b/lib/common/static/records_review_page.js @@ -0,0 +1,611 @@ +/** + * 三所 /records:交易记录分页 + 复盘表单显隐 + 复盘/AI 列表分页(soft,每页5). + */ +(function (global) { + "use strict"; + + var PAGE_SIZE = 5; + var tradesPage = 0; + var tradesPages = 1; + var journalsAll = []; + var journalsPage = 0; + var journalsPages = 1; + var reviewsAll = []; + var reviewsPage = 0; + var reviewsPages = 1; + var tradesCache = {}; + var booted = false; + + function $(id) { + return document.getElementById(id); + } + + function esc(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function listQs() { + if (typeof global.listWindowQueryString === "function") { + return global.listWindowQueryString() || ""; + } + return ""; + } + + function fmtNum(v, digits) { + if (v == null || v === "") return "—"; + var n = Number(v); + if (!Number.isFinite(n)) return esc(v); + return n.toFixed(digits == null ? 2 : digits); + } + + function fmtTime(s) { + if (!s) return "—"; + return esc(String(s).slice(0, 16)); + } + + function resultBadge(result) { + var er = String(result || "").trim(); + if (["止盈", "保本止盈", "移动止盈"].indexOf(er) >= 0) { + return '' + esc(er) + ""; + } + if (["止损", "强制清仓", "手动平仓"].indexOf(er) >= 0) { + return '' + esc(er) + ""; + } + if (er === "时间平仓") return '' + esc(er) + ""; + return '' + esc(er || "-") + ""; + } + + function pnlClass(v) { + var n = Number(v); + if (!Number.isFinite(n) || n === 0) return ""; + return n > 0 ? "pnl-profit" : "pnl-loss"; + } + + function beginSoft(wrapId, soft) { + var wrap = $(wrapId); + if (!wrap) return null; + if (soft) { + if (!wrap.style.minHeight) { + wrap.style.minHeight = Math.max(wrap.offsetHeight, 1) + "px"; + } + wrap.classList.add("rr-list-loading"); + } else { + wrap.classList.remove("rr-list-loading"); + wrap.style.minHeight = ""; + } + return wrap; + } + + function endSoft(wrap) { + if (!wrap) return; + wrap.classList.remove("rr-list-loading"); + wrap.style.minHeight = ""; + } + + function updatePager(kind) { + var map = { + trades: { + page: tradesPage, + pages: tradesPages, + label: "rr-trades-page-label", + prev: "rr-trades-prev", + next: "rr-trades-next", + }, + journals: { + page: journalsPage, + pages: journalsPages, + label: "rr-journals-page-label", + prev: "rr-journals-prev", + next: "rr-journals-next", + }, + reviews: { + page: reviewsPage, + pages: reviewsPages, + label: "rr-reviews-page-label", + prev: "rr-reviews-prev", + next: "rr-reviews-next", + }, + }; + var m = map[kind]; + if (!m) return; + var label = $(m.label); + var prev = $(m.prev); + var next = $(m.next); + if (label) label.textContent = "第 " + (m.page + 1) + " / " + m.pages + " 页"; + if (prev) prev.disabled = m.page <= 0; + if (next) next.disabled = m.page + 1 >= m.pages; + } + + function fillPayload(t) { + return { + symbol: t.symbol, + monitor_type: t.monitor_type, + key_signal_type: t.key_signal_type || "", + direction: t.direction, + trigger_price: t.trigger_price, + stop_loss: t.display_open_stop_loss || t.initial_stop_loss || t.stop_loss, + take_profit: t.effective_take_profit || t.take_profit, + opened_at: t.effective_opened_at, + closed_at: t.effective_closed_at, + pnl_amount: t.effective_pnl_amount, + result: t.effective_result, + risk_amount: t.risk_amount, + effective_entry_reason: t.effective_entry_reason || "", + }; + } + + function editPayload(t) { + return { + id: t.id, + opened_at: t.effective_opened_at, + closed_at: t.effective_closed_at, + stop_loss: t.effective_stop_loss || t.initial_stop_loss || t.stop_loss, + take_profit: t.effective_take_profit || t.take_profit, + pnl_amount: t.effective_pnl_amount, + result: t.effective_result, + miss_reason: t.effective_miss_reason, + effective_entry_reason: t.effective_entry_reason || "", + }; + } + + function renderTradesRows(rows) { + var tbody = $("rr-trades-tbody"); + if (!tbody) return; + tradesCache = {}; + if (!rows || !rows.length) { + tbody.innerHTML = '暂无交易记录'; + return; + } + tbody.innerHTML = rows + .map(function (t) { + tradesCache[t.id] = t; + var mon = esc(t.monitor_type || ""); + if (t.key_signal_type) mon += " · " + esc(t.key_signal_type); + var stopShow = t.display_open_stop_loss || t.initial_stop_loss || t.stop_loss; + var tpShow = t.effective_take_profit || t.take_profit; + var pnl = t.effective_pnl_amount; + var pnlSrc = ""; + if (t.display_pnl_source === "exchange") { + pnlSrc = ''; + } else if (t.display_pnl_source !== "reviewed") { + pnlSrc = ''; + } + var dirCls = t.direction === "long" ? "direction-long" : "direction-short"; + var dirTxt = t.direction === "long" ? "做多" : "做空"; + var margin = + t.margin_capital != null && t.margin_capital !== "" + ? fmtNum(t.margin_capital, 2) + : "-"; + return ( + '' + + "" + + esc(t.symbol) + + "" + + "" + + mon + + "" + + "" + + esc(t.effective_entry_reason || "-") + + "" + + '' + + dirTxt + + "" + + "" + + fmtNum(t.trigger_price, 4) + + "" + + "" + + fmtNum(stopShow, 4) + + "" + + "" + + fmtNum(tpShow, 4) + + "" + + "" + + margin + + "" + + "" + + esc(t.leverage != null ? t.leverage : "-") + + "" + + "" + + esc(t.effective_hold_minutes || 0) + + "" + + "" + + fmtTime(t.effective_opened_at) + + "" + + "" + + fmtTime(t.effective_closed_at || t.created_at) + + "" + + '' + + fmtNum(pnl, 2) + + "" + + pnlSrc + + "" + + "" + + resultBadge(t.effective_result) + + "" + + "" + + ' ' + + ' ' + + '' + + "" + + "" + ); + }) + .join(""); + + tbody.querySelectorAll(".rr-fill-btn").forEach(function (btn) { + btn.addEventListener("click", function () { + var id = btn.getAttribute("data-id"); + var t = tradesCache[id]; + if (!t) return; + showJournalCard(); + if (typeof global.fillJournalFromTrade === "function") { + global.fillJournalFromTrade(fillPayload(t)); + } + }); + }); + tbody.querySelectorAll(".review-edit-btn").forEach(function (btn) { + btn.addEventListener("click", function () { + var id = btn.getAttribute("data-id"); + var t = tradesCache[id]; + if (!t) return; + if (typeof global.editTradeRecordReview === "function") { + global.editTradeRecordReview(editPayload(t)); + } + }); + }); + if (typeof global.toggleReviewMode === "function") { + global.toggleReviewMode(); + } + } + + function loadTradeRecords(opts) { + opts = opts || {}; + var soft = !!opts.soft; + var tbody = $("rr-trades-tbody"); + if (!tbody) return; + var wrap = beginSoft("rr-trades-wrap", soft); + if (!soft) { + tbody.innerHTML = '加载中…'; + } + var qs = listQs(); + var p = new URLSearchParams(qs || ""); + p.set("limit", String(PAGE_SIZE)); + p.set("offset", String(tradesPage * PAGE_SIZE)); + fetch("/api/trade_records?" + p.toString(), { credentials: "same-origin" }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + if (!data || !data.ok) { + tbody.innerHTML = '加载失败'; + endSoft(wrap); + return; + } + tradesPages = Math.max(1, Number(data.pages) || 1); + if (tradesPage >= tradesPages) { + tradesPage = Math.max(0, tradesPages - 1); + updatePager("trades"); + if (Number(data.total || 0) > 0) { + loadTradeRecords(opts); + return; + } + } + updatePager("trades"); + renderTradesRows(data.items || []); + endSoft(wrap); + }) + .catch(function () { + tbody.innerHTML = '加载失败'; + endSoft(wrap); + }); + } + + function renderJournalsPage(soft) { + var box = $("journal-list"); + if (!box) return; + var wrap = beginSoft("journal-list-wrap", soft); + var total = journalsAll.length; + journalsPages = Math.max(1, Math.ceil(total / PAGE_SIZE) || 1); + if (journalsPage >= journalsPages) journalsPage = Math.max(0, journalsPages - 1); + updatePager("journals"); + var hint = $("rr-journals-hint"); + if (hint) { + hint.textContent = + total > 0 + ? "已保存的复盘(共" + total + "条,每页5条)." + : "已保存的复盘(每页5条)."; + } + var slice = journalsAll.slice( + journalsPage * PAGE_SIZE, + journalsPage * PAGE_SIZE + PAGE_SIZE + ); + if (global.InstanceUI && typeof InstanceUI.renderJournalListHtml === "function") { + var html = InstanceUI.renderJournalListHtml(slice); + box.innerHTML = html || "
        暂无数据
        "; + } else { + box.innerHTML = "
        暂无数据
        "; + } + endSoft(wrap); + } + + function renderReviewsPage(soft) { + var box = $("review-list"); + if (!box) return; + var wrap = beginSoft("review-list-wrap", soft); + var total = reviewsAll.length; + reviewsPages = Math.max(1, Math.ceil(total / PAGE_SIZE) || 1); + if (reviewsPage >= reviewsPages) reviewsPage = Math.max(0, reviewsPages - 1); + updatePager("reviews"); + var slice = reviewsAll.slice( + reviewsPage * PAGE_SIZE, + reviewsPage * PAGE_SIZE + PAGE_SIZE + ); + if (!slice.length) { + box.innerHTML = "
        暂无数据
        "; + endSoft(wrap); + return; + } + var html = ""; + slice.forEach(function (r) { + if (global.reviewCache) global.reviewCache[r.id] = r; + var preview = (r.content || "").replace(/\s+/g, " ").trim(); + var shortText = preview.length > 90 ? preview.slice(0, 90) + "..." : preview; + html += + '
        ' + + "
        " + + (r.review_type === "daily" ? "日复盘" : "周复盘") + + " | " + + esc(r.target_date) + + "
        " + + '
        ' + + esc(r.created_at || "") + + "
        " + + '
        ' + + esc(shortText || "(空)") + + "
        " + + '
        ' + + '" + + '" + + '导出MD' + + '" + + "
        "; + }); + box.innerHTML = html; + endSoft(wrap); + } + + function loadJournalsPaged() { + var qs = listQs(); + fetch("/api/journals" + (qs ? "?" + qs : ""), { credentials: "same-origin" }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + journalsAll = Array.isArray(data) ? data : []; + if (global.journalCache) { + Object.keys(global.journalCache).forEach(function (k) { + delete global.journalCache[k]; + }); + journalsAll.forEach(function (o) { + global.journalCache[o.id] = o; + }); + } + journalsPage = 0; + renderJournalsPage(false); + }); + } + + function loadReviewsPaged() { + var qs = listQs(); + fetch("/api/reviews" + (qs ? "?" + qs : ""), { credentials: "same-origin" }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + reviewsAll = Array.isArray(data) ? data : []; + if (global.reviewCache) { + Object.keys(global.reviewCache).forEach(function (k) { + delete global.reviewCache[k]; + }); + } else { + global.reviewCache = {}; + } + reviewsAll.forEach(function (r) { + global.reviewCache[r.id] = r; + }); + reviewsPage = 0; + renderReviewsPage(false); + }); + } + + function showJournalCard() { + var card = $("journal-card"); + if (card) card.classList.remove("hidden"); + var hint = $("rr-journal-fill-hint"); + if (hint) hint.style.display = ""; + } + + function hideJournalCard() { + var card = $("journal-card"); + if (card) card.classList.add("hidden"); + var hint = $("rr-journal-fill-hint"); + if (hint) hint.style.display = "none"; + } + + function patchFillJournalFromTrade() { + var prev = global.fillJournalFromTrade; + if (typeof prev !== "function") return; + if (prev.__rrPatched) return; + global.fillJournalFromTrade = function (t) { + showJournalCard(); + prev(t); + var hint = $("rr-journal-fill-hint"); + if (hint) hint.style.display = ""; + }; + global.fillJournalFromTrade.__rrPatched = true; + } + + function patchDeleteTradeRecord() { + var prev = global.deleteTradeRecord; + if (typeof prev !== "function") return; + if (prev.__rrPatched) return; + global.deleteTradeRecord = function (id) { + if (!confirm("确定删除这条交易记录?")) return; + fetch("/delete_trade_record/" + id, { method: "POST", credentials: "same-origin" }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + if (data && data.ok) { + loadTradeRecords({ soft: true }); + return; + } + if (typeof prev === "function") { + /* fallthrough reload */ + } + global.location.href = + (global.location.pathname || "/records") + "?_ts=" + Date.now(); + }) + .catch(function () { + global.location.href = + (global.location.pathname || "/records") + "?_ts=" + Date.now(); + }); + }; + global.deleteTradeRecord.__rrPatched = true; + } + + function bindPagers() { + var tp = $("rr-trades-prev"); + var tn = $("rr-trades-next"); + var jp = $("rr-journals-prev"); + var jn = $("rr-journals-next"); + var rp = $("rr-reviews-prev"); + var rn = $("rr-reviews-next"); + var hideBtn = $("rr-journal-hide-btn"); + if (tp) { + tp.addEventListener("click", function (ev) { + ev.preventDefault(); + if (tradesPage <= 0) return; + tradesPage -= 1; + updatePager("trades"); + loadTradeRecords({ soft: true }); + }); + } + if (tn) { + tn.addEventListener("click", function (ev) { + ev.preventDefault(); + if (tradesPage + 1 >= tradesPages) return; + tradesPage += 1; + updatePager("trades"); + loadTradeRecords({ soft: true }); + }); + } + if (jp) { + jp.addEventListener("click", function (ev) { + ev.preventDefault(); + if (journalsPage <= 0) return; + journalsPage -= 1; + renderJournalsPage(true); + }); + } + if (jn) { + jn.addEventListener("click", function (ev) { + ev.preventDefault(); + if (journalsPage + 1 >= journalsPages) return; + journalsPage += 1; + renderJournalsPage(true); + }); + } + if (rp) { + rp.addEventListener("click", function (ev) { + ev.preventDefault(); + if (reviewsPage <= 0) return; + reviewsPage -= 1; + renderReviewsPage(true); + }); + } + if (rn) { + rn.addEventListener("click", function (ev) { + ev.preventDefault(); + if (reviewsPage + 1 >= reviewsPages) return; + reviewsPage += 1; + renderReviewsPage(true); + }); + } + if (hideBtn) { + hideBtn.addEventListener("click", function (ev) { + ev.preventDefault(); + hideJournalCard(); + }); + } + } + + function init(opts) { + opts = opts || {}; + if (!$("records-panel-root")) return; + if (booted) { + if (opts.refresh) { + loadTradeRecords({ soft: true }); + loadJournalsPaged(); + loadReviewsPaged(); + } + patchFillJournalFromTrade(); + patchDeleteTradeRecord(); + return; + } + booted = true; + if (!global.journalCache) global.journalCache = {}; + if (!global.reviewCache) global.reviewCache = {}; + global.loadJournals = loadJournalsPaged; + global.loadReviews = loadReviewsPaged; + global.loadTradeRecords = loadTradeRecords; + patchFillJournalFromTrade(); + patchDeleteTradeRecord(); + bindPagers(); + updatePager("trades"); + updatePager("journals"); + updatePager("reviews"); + loadTradeRecords({ soft: false }); + loadJournalsPaged(); + loadReviewsPaged(); + } + + global.RecordsReviewPage = { + init: init, + loadTradeRecords: loadTradeRecords, + loadJournals: loadJournalsPaged, + loadReviews: loadReviewsPaged, + showJournalCard: showJournalCard, + hideJournalCard: hideJournalCard, + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/strategy_roll.js b/lib/common/static/strategy_roll.js new file mode 100644 index 0000000..388983a --- /dev/null +++ b/lib/common/static/strategy_roll.js @@ -0,0 +1,318 @@ +(function () { + "use strict"; + + function syncRollFormMode(form, mode) { + if (!form) return; + const m = mode || "market"; + form.setAttribute("data-add-mode", m); + const showFib = m === "fib_618" || m === "fib_786"; + const showBreakout = m === "breakout"; + const fibWrap = form.querySelector(".roll-field-fib"); + const breakoutWrap = form.querySelector(".roll-field-breakout"); + const fibUpper = form.querySelector("#roll-fib-upper"); + const fibLower = form.querySelector("#roll-fib-lower"); + const breakoutInput = form.querySelector("#roll-breakout"); + + function tuneInput(inp, active, required) { + if (!inp) return; + inp.disabled = !active; + inp.required = !!required && active; + inp.tabIndex = active ? 0 : -1; + if (!active) inp.value = ""; + } + + if (fibWrap) fibWrap.setAttribute("aria-hidden", showFib ? "false" : "true"); + if (breakoutWrap) breakoutWrap.setAttribute("aria-hidden", showBreakout ? "false" : "true"); + tuneInput(fibUpper, showFib, showFib); + tuneInput(fibLower, showFib, showFib); + tuneInput(breakoutInput, showBreakout, showBreakout); + } + + window.syncRollFormMode = syncRollFormMode; + + function isEmbedShell() { + return document.body && document.body.getAttribute("data-embed-shell") === "1"; + } + + function submitRollForm(form) { + if (isEmbedShell() && window.InstanceEmbed && typeof window.InstanceEmbed.postFormAndReload === "function") { + window.InstanceEmbed.postFormAndReload(form, "执行中…"); + return; + } + if (window.FormSubmitGuard && typeof window.FormSubmitGuard.nativeSubmitOnce === "function") { + window.FormSubmitGuard.nativeSubmitOnce(form, "执行中…"); + return; + } + form.submit(); + } + + function initStrategyRollForm() { + const form = document.getElementById("roll-form"); + if (!form) return; + if (form.dataset.rollJsInit === "1") return; + form.dataset.rollJsInit = "1"; + + const symbolSel = document.getElementById("roll-symbol"); + const dirInput = document.getElementById("roll-direction"); + const modeSel = document.getElementById("roll-add-mode"); + const riskBanner = document.getElementById("roll-risk-banner"); + const previewBtn = document.getElementById("roll-preview-btn"); + const submitBtn = document.getElementById("roll-submit-btn"); + const previewBox = document.getElementById("roll-preview-box"); + const previewText = document.getElementById("roll-preview-text"); + const countdownEl = document.getElementById("roll-countdown"); + const trendLocked = submitBtn && submitBtn.getAttribute("data-trend-locked") === "1"; + + let countdownTimer = null; + let previewOk = false; + let lastPreviewMode = ""; + let monitorSubmitting = false; + + function isMarketMode() { + return (modeSel.value || "market") === "market"; + } + + function isMonitorMode() { + const m = modeSel.value || "market"; + return m === "fib_618" || m === "fib_786" || m === "breakout"; + } + + function selectedOption() { + return symbolSel.options[symbolSel.selectedIndex]; + } + + function syncDirectionLock() { + const opt = selectedOption(); + if (!opt || !opt.value) { + riskBanner.textContent = "当前风险:请选择持仓币种"; + return; + } + const dir = opt.getAttribute("data-direction") || "long"; + const rp = opt.getAttribute("data-risk-percent") || "—"; + dirInput.value = dir; + riskBanner.textContent = + "当前风险:" + rp + "%(来自监控单 #" + (opt.getAttribute("data-monitor-id") || "?") + ")"; + } + + function syncSubmitButton() { + if (!submitBtn || trendLocked) return; + if (isMonitorMode()) { + submitBtn.disabled = false; + submitBtn.removeAttribute("disabled"); + return; + } + const blocked = !previewOk || !!countdownTimer; + submitBtn.disabled = blocked; + if (!blocked) submitBtn.removeAttribute("disabled"); + } + + function clearMessageBox() { + if (!previewBox) return; + previewBox.style.display = "none"; + previewBox.classList.remove("is-error", "is-preview"); + if (previewText) previewText.textContent = ""; + if (countdownEl) countdownEl.style.display = "none"; + } + + function showReject(msg) { + if (!previewBox || !previewText) return; + previewBox.style.display = "block"; + previewBox.classList.remove("is-preview"); + previewBox.classList.add("is-error"); + previewText.textContent = msg || "无法执行"; + if (countdownEl) countdownEl.style.display = "none"; + previewBox.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } + + function showPreviewResult(p) { + if (!previewBox || !previewText) return; + previewBox.style.display = "block"; + previewBox.classList.remove("is-error"); + previewBox.classList.add("is-preview"); + previewText.innerHTML = + "" + + (p.add_mode_label || "") + + " · 约 " + + (p.add_amount_display != null ? p.add_amount_display : p.add_amount_raw) + + "
        " + + "加仓参考价 " + + (p.add_price_display != null ? p.add_price_display : p.add_price) + + " · 新止损 " + + (p.new_sl_display != null ? p.new_sl_display : p.new_stop_loss) + + "
        " + + "合并均价 " + + p.avg_entry_after + + " · 打到止损约 " + + p.loss_at_sl_usdt + + "U(风险预算 " + + (p.risk_budget_usdt != null ? p.risk_budget_usdt : "—") + + "U)"; + } + + function syncFieldVisibility() { + syncRollFormMode(form, modeSel.value || "market"); + resetPreview(); + } + + function resetPreview() { + previewOk = false; + monitorSubmitting = false; + clearMessageBox(); + if (countdownTimer) { + clearInterval(countdownTimer); + countdownTimer = null; + } + syncSubmitButton(); + } + + function formPayload() { + const fd = new FormData(form); + const obj = {}; + fd.forEach(function (v, k) { + if (v !== "") obj[k] = v; + }); + return obj; + } + + function requestPreview() { + return fetch("/strategy/roll/preview", { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify(formPayload()), + credentials: "same-origin", + }).then(function (r) { + return r.json(); + }); + } + + function runPreview() { + resetPreview(); + if (!symbolSel.value) { + showReject("请先选择持仓币种"); + return; + } + if (previewBtn) previewBtn.disabled = true; + requestPreview() + .then(function (data) { + if (previewBtn) previewBtn.disabled = false; + if (!data.ok) { + showReject(data.msg || "预览失败"); + return; + } + const p = data.preview || {}; + lastPreviewMode = p.add_mode || modeSel.value; + showPreviewResult(p); + previewOk = true; + if (lastPreviewMode === "market") { + startCountdown(10); + } else { + syncSubmitButton(); + } + }) + .catch(function () { + if (previewBtn) previewBtn.disabled = false; + showReject("预览请求失败,请稍后重试"); + }); + } + + function runMonitorSubmit() { + if (monitorSubmitting) return; + if (!symbolSel.value) { + showReject("请先选择持仓币种"); + return; + } + monitorSubmitting = true; + if (submitBtn) submitBtn.disabled = true; + requestPreview() + .then(function (data) { + monitorSubmitting = false; + if (submitBtn && !trendLocked) { + submitBtn.disabled = false; + submitBtn.removeAttribute("disabled"); + } + if (!data.ok) { + showReject(data.msg || "无法提交监控"); + return; + } + const p = data.preview || {}; + const modeLabel = modeSel.options[modeSel.selectedIndex].text; + const summary = + "约 " + + (p.add_amount_display != null ? p.add_amount_display : p.add_amount_raw) + + " 张 · 触发参考价 " + + (p.add_price_display != null ? p.add_price_display : p.add_price) + + " · 新止损 " + + (p.new_sl_display != null ? p.new_sl_display : p.new_stop_loss); + if (!confirm("确认提交「" + modeLabel + "」?\n" + summary)) { + return; + } + submitRollForm(form); + }) + .catch(function () { + monitorSubmitting = false; + if (submitBtn && !trendLocked) { + submitBtn.disabled = false; + submitBtn.removeAttribute("disabled"); + } + showReject("校验请求失败,请稍后重试"); + }); + } + + function startCountdown(sec) { + let left = sec; + if (submitBtn) submitBtn.disabled = true; + if (countdownEl) { + countdownEl.style.display = "block"; + countdownEl.textContent = "市价加仓:" + left + " 秒后可执行(修改表单将取消预览)"; + } + countdownTimer = setInterval(function () { + left -= 1; + if (left <= 0) { + clearInterval(countdownTimer); + countdownTimer = null; + if (countdownEl) countdownEl.textContent = "可以执行市价加仓"; + syncSubmitButton(); + return; + } + if (countdownEl) countdownEl.textContent = "市价加仓:" + left + " 秒后可执行"; + }, 1000); + } + + symbolSel.addEventListener("change", function () { + syncDirectionLock(); + resetPreview(); + }); + modeSel.addEventListener("change", syncFieldVisibility); + form.addEventListener("input", resetPreview); + form.addEventListener("change", function (e) { + if (e.target !== previewBtn) resetPreview(); + }); + if (previewBtn) previewBtn.addEventListener("click", runPreview); + form.addEventListener("submit", function (e) { + e.preventDefault(); + if (isMonitorMode()) { + runMonitorSubmit(); + return; + } + if (!previewOk) { + showReject("请先点击「预览」并通过校验"); + return; + } + if (submitBtn && submitBtn.disabled) { + showReject("请等待 10 秒确认倒计时结束后再执行市价加仓"); + return; + } + const modeLabel = modeSel.options[modeSel.selectedIndex].text; + if (!confirm("确认提交「" + modeLabel + "」?")) { + return; + } + submitRollForm(form); + }); + + syncDirectionLock(); + syncFieldVisibility(); + } + + window.initStrategyRollForm = initStrategyRollForm; + initStrategyRollForm(); +})(); diff --git a/lib/common/static/symbol_live_price.js b/lib/common/static/symbol_live_price.js new file mode 100644 index 0000000..07db9c1 --- /dev/null +++ b/lib/common/static/symbol_live_price.js @@ -0,0 +1,169 @@ +/** + * 表单币种输入:防抖 + 定时刷新,展示交易所最新价(/api/order_defaults). + */ +(function (global) { + "use strict"; + + const DEFAULT_DEBOUNCE_MS = 350; + const DEFAULT_POLL_MS = 5000; + const bound = new WeakSet(); + + function $(id) { + return id ? document.getElementById(id) : null; + } + + function symbolValue(el) { + if (!el) return ""; + return (el.value || "").trim(); + } + + function directionValue(dirId) { + const el = dirId ? $(dirId) : null; + const v = (el && el.value ? el.value : "long").trim().toLowerCase(); + return v === "short" ? "short" : "long"; + } + + function formatPrice(px, sym) { + const n = Number(px); + if (!Number.isFinite(n)) return "—"; + const u = (sym || "").trim().toUpperCase(); + let digits = 4; + if (u.startsWith("BTC") || u.startsWith("ETH") || n >= 1000) digits = 2; + else if (n >= 10) digits = 3; + else if (n >= 1) digits = 4; + else if (n >= 0.01) digits = 5; + else digits = 6; + return n.toFixed(digits); + } + + function pollMs() { + const raw = + (document.body && document.body.getAttribute("data-price-refresh-ms")) || ""; + const n = Number(raw); + return Number.isFinite(n) && n >= 2000 ? n : DEFAULT_POLL_MS; + } + + function paint(el, sym, px, err) { + if (!el) return; + if (err) { + el.textContent = "现价:—"; + el.classList.add("symbol-live-price--err"); + el.classList.remove("symbol-live-price--ok"); + el.title = err; + return; + } + if (px === null || typeof px === "undefined") { + el.textContent = "现价:—"; + el.classList.remove("symbol-live-price--ok", "symbol-live-price--err"); + el.title = sym ? "无法读取交易所价格" : ""; + return; + } + const label = sym ? sym.toUpperCase().replace(/\/USDT.*/, "") : ""; + el.textContent = label ? label + " 现价 " + formatPrice(px, sym) : "现价 " + formatPrice(px, sym); + el.classList.add("symbol-live-price--ok"); + el.classList.remove("symbol-live-price--err"); + el.title = "交易所最新价(约 " + pollMs() / 1000 + "s 刷新)"; + } + + function bindOne(el) { + if (!el || bound.has(el)) return; + bound.add(el); + + const symId = el.getAttribute("data-symbol-input"); + const dirId = el.getAttribute("data-direction-input") || ""; + let debounceTimer = null; + let pollTimer = null; + let fetchSeq = 0; + + function clearPoll() { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + } + + function startPoll() { + clearPoll(); + pollTimer = setInterval(refresh, pollMs()); + } + + function refresh() { + const symEl = $(symId); + const sym = symbolValue(symEl); + if (!sym) { + paint(el, "", null, ""); + clearPoll(); + return; + } + const dir = directionValue(dirId); + const seq = ++fetchSeq; + el.classList.add("symbol-live-price--loading"); + fetch( + "/api/order_defaults?symbol=" + + encodeURIComponent(sym) + + "&direction=" + + encodeURIComponent(dir) + ) + .then(function (r) { + return r.json().then(function (d) { + return { status: r.status, data: d }; + }).catch(function () { + return { status: r.status, data: null }; + }); + }) + .then(function (res) { + if (seq !== fetchSeq) return; + el.classList.remove("symbol-live-price--loading"); + const data = res.data || {}; + if (res.status >= 400 || !data || !data.ok) { + paint(el, sym, null, (data && data.msg) || "读取失败"); + return; + } + const px = data.last_price != null ? data.last_price : data.price; + if (px === null || typeof px === "undefined") { + paint(el, data.symbol || sym, null, "无法读取交易所价格"); + return; + } + paint(el, data.symbol || sym, px, ""); + if (!pollTimer) startPoll(); + }) + .catch(function () { + if (seq !== fetchSeq) return; + el.classList.remove("symbol-live-price--loading"); + paint(el, sym, null, "网络错误"); + }); + } + + function schedule() { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(refresh, DEFAULT_DEBOUNCE_MS); + } + + const symEl = $(symId); + if (symEl) { + symEl.addEventListener("input", schedule); + symEl.addEventListener("change", schedule); + } + const dirEl = dirId ? $(dirId) : null; + if (dirEl) { + dirEl.addEventListener("change", schedule); + } + + schedule(); + } + + function init(root) { + const scope = root || document; + scope.querySelectorAll(".symbol-live-price").forEach(bindOne); + } + + global.SymbolLivePrice = { init: init, bind: bindOne }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", function () { + init(document); + }); + } else { + init(document); + } +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/time_close_ui.js b/lib/common/static/time_close_ui.js new file mode 100644 index 0000000..7d4933e --- /dev/null +++ b/lib/common/static/time_close_ui.js @@ -0,0 +1,194 @@ +/** + * 时间平仓 + 整点强制清仓:表单开关 + 持仓/顶栏倒计时. + */ +(function (global) { + "use strict"; + + function pad2(n) { + return n < 10 ? "0" + n : String(n); + } + + function formatCountdown(sec) { + const s = Math.max(0, parseInt(sec, 10) || 0); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const r = s % 60; + return pad2(h) + ":" + pad2(m) + ":" + pad2(r); + } + + function isForceCloseActive(wrap) { + if (!wrap) return false; + const raw = + wrap.dataset.forceCloseActive || + wrap.getAttribute("data-force-close-active") || + ""; + return raw === "1" || raw === "true"; + } + + function bindTimeCloseForm(checkboxId, selectId, wrapId) { + const cb = document.getElementById(checkboxId); + const sel = document.getElementById(selectId); + const wrap = wrapId ? document.getElementById(wrapId) : null; + if (!cb || !sel) return; + function sync() { + const on = !!cb.checked; + sel.disabled = false; + sel.tabIndex = 0; + if (wrap) wrap.classList.toggle("is-disabled", !on); + } + sel.addEventListener("mousedown", function (ev) { + ev.stopPropagation(); + }); + sel.addEventListener("click", function (ev) { + ev.stopPropagation(); + }); + cb.addEventListener("change", sync); + sync(); + } + + function paintCountdownEl(cd, rem, active) { + if (!cd) return; + if (active) { + cd.textContent = "执行中"; + return; + } + cd.textContent = Number.isFinite(rem) ? formatCountdown(rem) : "--:--:--"; + } + + function paintOrderTimeClose(order) { + if (!order || order.id == null) return; + const wrap = document.getElementById("order-time-close-wrap-" + order.id); + const cd = document.getElementById("order-time-close-cd-" + order.id); + if (!wrap || !cd) return; + const enabled = !!(order.time_close_enabled || order.time_close_at_ms); + if (!enabled) { + wrap.style.display = "none"; + return; + } + wrap.style.display = ""; + const hours = order.time_close_hours; + const label = order.time_close_label || (hours ? "时间平仓 " + hours + "h" : "时间平仓"); + const labelEl = wrap.querySelector(".pos-time-close-label"); + if (labelEl) labelEl.textContent = label; + let rem = + order.time_close_remaining_sec != null + ? Number(order.time_close_remaining_sec) + : null; + if ((rem == null || !Number.isFinite(rem)) && order.time_close_at_ms) { + rem = Math.max(0, Math.floor((Number(order.time_close_at_ms) - Date.now()) / 1000)); + } + paintCountdownEl(cd, rem, false); + wrap.dataset.closeAtMs = order.time_close_at_ms ? String(order.time_close_at_ms) : ""; + } + + function paintOrderForceClose(order) { + if (!order || order.id == null) return; + const wrap = document.getElementById("order-force-close-wrap-" + order.id); + const cd = document.getElementById("order-force-close-cd-" + order.id); + if (!wrap || !cd) return; + const enabled = !!order.force_close_enabled; + if (!enabled) { + wrap.style.display = "none"; + return; + } + wrap.style.display = ""; + const label = order.force_close_label || "强制清仓"; + const labelEl = wrap.querySelector(".pos-force-close-label"); + if (labelEl) labelEl.textContent = label; + let rem = + order.force_close_remaining_sec != null + ? Number(order.force_close_remaining_sec) + : null; + const atMs = order.force_close_at_ms; + if ((rem == null || !Number.isFinite(rem)) && atMs) { + rem = Math.max(0, Math.floor((Number(atMs) - Date.now()) / 1000)); + } + const active = !!order.force_close_active; + paintCountdownEl(cd, rem, active); + wrap.dataset.forceCloseAtMs = atMs ? String(atMs) : ""; + wrap.dataset.forceCloseActive = active ? "1" : "0"; + } + + function paintForceCloseHeader(state) { + const wrap = document.getElementById("force-close-header-badge"); + if (!wrap) return; + if (!state || !state.enabled) { + wrap.style.display = "none"; + return; + } + wrap.style.display = ""; + const label = state.label || "强制清仓"; + const labelPrefix = label + " 已开启 · "; + let prefixNode = wrap.querySelector(".force-close-header-prefix"); + if (!prefixNode) { + wrap.textContent = ""; + prefixNode = document.createElement("span"); + prefixNode.className = "force-close-header-prefix"; + prefixNode.textContent = labelPrefix; + wrap.appendChild(prefixNode); + const cd = document.createElement("span"); + cd.className = "force-close-header-cd"; + wrap.appendChild(cd); + } else { + prefixNode.textContent = labelPrefix; + } + const cd = wrap.querySelector(".force-close-header-cd"); + let rem = state.remaining_sec != null ? Number(state.remaining_sec) : null; + if ((rem == null || !Number.isFinite(rem)) && state.next_at_ms) { + rem = Math.max(0, Math.floor((Number(state.next_at_ms) - Date.now()) / 1000)); + } + paintCountdownEl(cd, rem, !!state.active); + wrap.dataset.forceCloseAtMs = state.next_at_ms ? String(state.next_at_ms) : ""; + wrap.dataset.forceCloseActive = state.active ? "1" : "0"; + } + + function tickLocalCountdowns() { + document.querySelectorAll("[data-close-at-ms]").forEach(function (wrap) { + const closeAtRaw = wrap.dataset.closeAtMs || wrap.getAttribute("data-close-at-ms") || ""; + const cd = wrap.querySelector(".pos-time-close-cd"); + if (!cd) return; + const closeAt = Number(closeAtRaw); + if (!closeAt) return; + const rem = Math.max(0, Math.floor((closeAt - Date.now()) / 1000)); + cd.textContent = formatCountdown(rem); + }); + document.querySelectorAll("[data-force-close-at-ms]").forEach(function (wrap) { + const closeAtRaw = + wrap.dataset.forceCloseAtMs || wrap.getAttribute("data-force-close-at-ms") || ""; + const cd = wrap.querySelector(".pos-force-close-cd, .force-close-header-cd"); + if (!cd) return; + const closeAt = Number(closeAtRaw); + if (!closeAt) return; + const rem = Math.max(0, Math.floor((closeAt - Date.now()) / 1000)); + paintCountdownEl(cd, rem, isForceCloseActive(wrap)); + }); + } + + function paintOrders(orders) { + (orders || []).forEach(function (order) { + paintOrderTimeClose(order); + paintOrderForceClose(order); + }); + } + + function syncKeyTimeCloseVisibility(show) { + const wrap = document.getElementById("key-time-close-wrap"); + if (!wrap) return; + wrap.style.display = show ? "inline-flex" : "none"; + } + + global.TimeCloseUI = { + bindTimeCloseForm: bindTimeCloseForm, + paintOrderTimeClose: paintOrderTimeClose, + paintOrderForceClose: paintOrderForceClose, + paintForceCloseHeader: paintForceCloseHeader, + paintOrders: paintOrders, + tickLocalCountdowns: tickLocalCountdowns, + syncKeyTimeCloseVisibility: syncKeyTimeCloseVisibility, + formatCountdown: formatCountdown, + }; + + if (!global.__timeCloseCountdownTimer) { + global.__timeCloseCountdownTimer = setInterval(tickLocalCountdowns, 1000); + } +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/trade_stats_calendar.css b/lib/common/static/trade_stats_calendar.css new file mode 100644 index 0000000..1eb1f05 --- /dev/null +++ b/lib/common/static/trade_stats_calendar.css @@ -0,0 +1,171 @@ +/* 交易日历:内照明心 + 三所统计分析共用,随 data-theme 浅/深切换 */ +.trade-cal-wrap { + --trade-cal-wrap-bg: var(--inset-surface, rgba(0, 0, 0, 0.22)); + --trade-cal-cell-bg: var(--section-surface, var(--inset-surface, rgba(0, 0, 0, 0.32))); + --trade-cal-cell-border: rgba(255, 255, 255, 0.14); + --trade-cal-cell-shadow: 0 1px 3px rgba(0, 0, 0, 0.22); + --trade-cal-cell-empty-bg: color-mix(in srgb, var(--trade-cal-cell-bg) 72%, transparent); + --trade-cal-cell-hover-bg: color-mix(in srgb, var(--accent, #6366f1) 12%, var(--trade-cal-cell-bg)); + --trade-cal-cell-hover-border: color-mix(in srgb, var(--accent, #6366f1) 45%, transparent); + --trade-cal-selected-border: rgba(59, 130, 246, 0.85); + --trade-cal-selected-bg: color-mix(in srgb, #3b82f6 16%, var(--trade-cal-cell-bg)); + --trade-cal-selected-shadow: rgba(59, 130, 246, 0.45); + --trade-cal-sick-bg: color-mix(in srgb, var(--red, #ef4444) 14%, var(--trade-cal-cell-bg)); + --trade-cal-sick-border: color-mix(in srgb, var(--red, #ef4444) 55%, transparent); + --trade-cal-sick-shadow: color-mix(in srgb, var(--red, #ef4444) 45%, transparent); + --trade-cal-sick-tag-bg: color-mix(in srgb, var(--red, #ef4444) 25%, transparent); + --trade-cal-sick-tag-fg: color-mix(in srgb, var(--red, #ef4444) 70%, #fff); + --trade-cal-pos: var(--green, #22c55e); + --trade-cal-neg: var(--red, #ef4444); + margin-top: 4px; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--border-soft, rgba(120, 140, 200, 0.28)); + background: var(--trade-cal-wrap-bg); +} +.stats-calendar-wrap { + margin-bottom: 14px; +} +.trade-cal-wrap button.trade-cal-cell { + background: var(--trade-cal-cell-bg) !important; + background-image: none !important; + border: 1px solid var(--trade-cal-cell-border); + box-shadow: var(--trade-cal-cell-shadow); + padding: 6px 4px; + min-height: 72px; + width: 100%; + line-height: 1.15; + font-size: inherit; + text-align: center; +} +.trade-cal-wrap button.trade-cal-cell:not(.has-trade) { + background: var(--trade-cal-cell-empty-bg) !important; + cursor: default; +} +.trade-cal-wrap button.trade-cal-cell:disabled { + opacity: 1; + cursor: default; +} +.trade-cal-wrap .trade-cal-head .btn, +.trade-cal-wrap .trade-cal-head button { + min-height: 0; + min-width: 34px; + padding: 4px 12px; + line-height: 1.2; +} +.trade-cal-head { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + margin-bottom: 8px; +} +.trade-cal-title { + font-size: 0.95rem; + font-weight: 600; + min-width: 120px; + text-align: center; + color: var(--text, #e8ecff); +} +.trade-cal-weekdays { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 4px; + margin-bottom: 4px; +} +.trade-cal-wd { + text-align: center; + font-size: 0.72rem; + color: var(--muted, #8892b0); +} +.trade-cal-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 6px; +} +.trade-cal-cell { + min-height: 72px; + padding: 6px 4px; + border-radius: 8px; + border: 1px solid var(--trade-cal-cell-border); + box-shadow: var(--trade-cal-cell-shadow); + background: var(--trade-cal-cell-bg); + color: inherit; + font: inherit; + cursor: default; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + gap: 2px; +} +.trade-cal-cell.has-trade { + cursor: pointer; +} +.trade-cal-wrap button.trade-cal-cell.has-trade:hover { + background: var(--trade-cal-cell-hover-bg) !important; + background-image: none !important; + border-color: var(--trade-cal-cell-hover-border); +} +.trade-cal-cell.is-selected { + border-color: var(--trade-cal-selected-border); + background: var(--trade-cal-selected-bg); + box-shadow: 0 0 0 2px var(--trade-cal-selected-shadow); +} +.trade-cal-cell.is-sick-day { + border-color: var(--trade-cal-sick-border); + background: var(--trade-cal-sick-bg); +} +.trade-cal-cell.is-sick-day.is-selected { + border-color: var(--trade-cal-selected-border); + background: color-mix(in srgb, #3b82f6 14%, var(--trade-cal-sick-bg)); + box-shadow: 0 0 0 2px var(--trade-cal-selected-shadow); +} +.trade-cal-day-num { + font-size: 0.78rem; + font-weight: 600; + color: var(--text, #e8ecff); +} +.trade-cal-pnl { + font-size: 0.72rem; + font-weight: 600; + line-height: 1.1; + color: var(--text, #e8ecff); +} +.trade-cal-cell.pnl-pos .trade-cal-pnl { + color: var(--trade-cal-pos); +} +.trade-cal-cell.pnl-neg .trade-cal-pnl { + color: var(--trade-cal-neg); +} +.trade-cal-cnt { + font-size: 0.65rem; + color: var(--muted, #8892b0); + font-weight: 500; +} +.trade-cal-sick-tag { + font-size: 0.62rem; + padding: 1px 4px; + border-radius: 4px; + background: var(--trade-cal-sick-tag-bg); + color: var(--trade-cal-sick-tag-fg); + font-weight: 600; +} +.trade-cal-pad { + background: transparent; + border: none; + min-height: 0; +} + +html[data-theme="light"] .trade-cal-wrap { + --trade-cal-wrap-bg: var(--inset-surface, #eef3f8); + --trade-cal-cell-bg: #ffffff; + --trade-cal-cell-empty-bg: #f6f9fc; + --trade-cal-cell-border: rgba(0, 75, 115, 0.18); + --trade-cal-cell-shadow: 0 1px 4px rgba(30, 60, 100, 0.08); + --trade-cal-cell-hover-bg: color-mix(in srgb, var(--accent, #2563eb) 10%, #ffffff); + --trade-cal-selected-border: rgba(37, 99, 235, 0.75); + --trade-cal-selected-bg: color-mix(in srgb, #2563eb 12%, #ffffff); + --trade-cal-selected-shadow: rgba(37, 99, 235, 0.35); + --trade-cal-sick-tag-fg: #b91c1c; +} diff --git a/lib/common/static/trade_stats_calendar.js b/lib/common/static/trade_stats_calendar.js new file mode 100644 index 0000000..73da916 --- /dev/null +++ b/lib/common/static/trade_stats_calendar.js @@ -0,0 +1,314 @@ +/** + * 交易日历组件:内照明心档案 + 三所统计分析共用. + */ +(function (global) { + "use strict"; + + var WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"]; + + function esc(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function monthLabel(y, m) { + return y + "年" + m + "月"; + } + + function formatCalPnl(pnl) { + var n = Number(pnl); + if (!Number.isFinite(n)) n = 0; + return (n >= 0 ? "+" : "") + n.toFixed(1) + "U"; + } + + function dayHasTrade(info) { + if (!info) return false; + var cnt = Number(info.open_count); + if (Number.isFinite(cnt) && cnt > 0) return true; + var pnl = Number(info.pnl_total); + return Number.isFinite(pnl) && Math.abs(pnl) > 0.0001; + } + + function dayOpenCount(info) { + var cnt = Number(info && info.open_count); + return Number.isFinite(cnt) && cnt > 0 ? cnt : 0; + } + + function dayPnl(info) { + return Number(info && info.pnl_total) || 0; + } + + function TradeStatsCalendar(config) { + this.gridEl = config.gridEl; + this.titleEl = config.titleEl; + this.prevBtn = config.prevBtn || null; + this.nextBtn = config.nextBtn || null; + this.apiUrl = config.apiUrl || "/api/stats/calendar"; + this.buildQuery = + config.buildQuery || + function (year, month) { + var q = new URLSearchParams(); + q.set("year", String(year)); + q.set("month", String(month)); + return q; + }; + this.parseResponse = + config.parseResponse || + function (data) { + if (data && data.ok === false) return {}; + return (data && data.days) || {}; + }; + this.fetchFn = config.fetchFn || null; + this.showSick = config.showSick !== false; + this.selectedDay = config.selectedDay || ""; + this.onDayClick = config.onDayClick || null; + this.onMonthChange = config.onMonthChange || null; + this.year = config.year || 0; + this.month = config.month || 0; + this.days = {}; + this.monthPnlTotal = 0; + this.monthOpenCount = 0; + this._navBound = false; + this._bindNav(); + } + + TradeStatsCalendar.prototype.ensureMonth = function (ref) { + if (this.year > 0 && this.month > 0) return; + var d; + if (ref instanceof Date) d = ref; + else if (typeof ref === "string" && ref.length >= 7) { + var p = ref.slice(0, 10).split("-"); + this.year = parseInt(p[0], 10) || new Date().getFullYear(); + this.month = parseInt(p[1], 10) || new Date().getMonth() + 1; + return; + } else d = new Date(); + this.year = d.getFullYear(); + this.month = d.getMonth() + 1; + }; + + TradeStatsCalendar.prototype.applyPayload = function (data) { + if (!data) return; + var y = Number(data.year); + var m = Number(data.month); + if (Number.isFinite(y) && y > 0) this.year = y; + if (Number.isFinite(m) && m > 0) this.month = m; + this.days = this.parseResponse(data) || {}; + this.monthPnlTotal = Number(data.month_pnl_total) || 0; + this.monthOpenCount = Number(data.month_open_count) || 0; + if (!this.monthOpenCount) { + var self = this; + Object.keys(this.days).forEach(function (k) { + if (dayHasTrade(self.days[k])) { + self.monthOpenCount += dayOpenCount(self.days[k]); + self.monthPnlTotal += dayPnl(self.days[k]); + } + }); + this.monthPnlTotal = Math.round(this.monthPnlTotal * 10000) / 10000; + } + }; + + function readStatsCalendarBootstrap() { + var el = document.getElementById("stats-calendar-bootstrap"); + if (!el || !el.textContent) return null; + try { + return JSON.parse(el.textContent); + } catch (e) { + console.warn("[trade calendar] bootstrap parse", e); + return null; + } + } + + TradeStatsCalendar.prototype.setSelectedDay = function (day) { + this.selectedDay = day || ""; + this.render(); + }; + + TradeStatsCalendar.prototype.render = function () { + if (!this.gridEl || !this.titleEl) return; + if (this.year <= 0 || this.month <= 0) this.ensureMonth(new Date()); + var title = monthLabel(this.year, this.month); + if (this.monthOpenCount > 0) { + title += + " · " + formatCalPnl(this.monthPnlTotal) + " · " + this.monthOpenCount + "笔"; + } + this.titleEl.textContent = title; + var first = new Date(this.year, this.month - 1, 1); + var lastDay = new Date(this.year, this.month, 0).getDate(); + var startWd = first.getDay(); + var html = + '
        ' + + WEEKDAYS.map(function (w) { + return '' + w + ""; + }).join("") + + '
        '; + var i; + for (i = 0; i < startWd; i++) { + html += ''; + } + for (var d = 1; d <= lastDay; d++) { + var dayStr = + this.year + + "-" + + String(this.month).padStart(2, "0") + + "-" + + String(d).padStart(2, "0"); + var info = this.days[dayStr]; + var hasTrade = dayHasTrade(info); + var sick = this.showSick && info && info.has_sick; + var pnl = hasTrade ? dayPnl(info) : null; + var cnt = hasTrade ? dayOpenCount(info) : 0; + var cls = + "trade-cal-cell" + + (hasTrade ? " has-trade" : "") + + (sick ? " is-sick-day" : "") + + (this.selectedDay === dayStr ? " is-selected" : "") + + (pnl != null && pnl > 0.0001 + ? " pnl-pos" + : pnl != null && pnl < -0.0001 + ? " pnl-neg" + : ""); + var body = '' + d + ""; + if (hasTrade) { + body += + '' + + esc(formatCalPnl(pnl)) + + "" + + '' + + cnt + + "笔"; + if (sick) body += '犯病'; + } + html += + '"; + } + html += "
        "; + this.gridEl.innerHTML = html; + var self = this; + this.gridEl.querySelectorAll(".trade-cal-cell[data-day]").forEach(function (btn) { + btn.addEventListener("click", function () { + var day = btn.getAttribute("data-day"); + if (!day || !self.onDayClick) return; + self.selectedDay = day; + self.render(); + self.onDayClick(day, btn.getAttribute("data-sick") === "1", self.days[day] || null); + }); + }); + }; + + TradeStatsCalendar.prototype.load = async function () { + this.ensureMonth(new Date()); + this.render(); + var q = this.buildQuery(this.year, this.month); + if (!q.has("year")) q.set("year", String(this.year)); + if (!q.has("month")) q.set("month", String(this.month)); + try { + var data; + if (this.fetchFn) { + data = await this.fetchFn(q); + } else { + var resp = await fetch(this.apiUrl + "?" + q.toString(), { + credentials: "same-origin", + }); + if (!resp.ok) { + console.warn("[trade calendar] api", resp.status); + this.render(); + return; + } + data = await resp.json(); + } + this.applyPayload(data); + this.render(); + if (this.onMonthChange) this.onMonthChange(this.year, this.month, this.days); + } catch (e) { + console.warn("[trade calendar]", e); + this.render(); + } + }; + + TradeStatsCalendar.prototype.shiftMonth = function (delta) { + this.ensureMonth(new Date()); + this.month += delta; + if (this.month > 12) { + this.month = 1; + this.year += 1; + } else if (this.month < 1) { + this.month = 12; + this.year -= 1; + } + void this.load(); + }; + + TradeStatsCalendar.prototype._bindNav = function () { + if (this._navBound) return; + var self = this; + if (this.prevBtn) { + this.prevBtn.addEventListener("click", function () { + self.shiftMonth(-1); + }); + } + if (this.nextBtn) { + this.nextBtn.addEventListener("click", function () { + self.shiftMonth(1); + }); + } + this._navBound = true; + }; + + global.TradeStatsCalendar = TradeStatsCalendar; + + global.statsCalendarWidget = null; + + global.initInstanceStatsCalendar = function () { + var grid = document.getElementById("stats-calendar"); + if (!grid || !global.TradeStatsCalendar) return null; + var bootstrap = readStatsCalendarBootstrap(); + if ( + global.statsCalendarWidget && + global.statsCalendarWidget.gridEl === grid + ) { + if (bootstrap) global.statsCalendarWidget.applyPayload(bootstrap); + global.statsCalendarWidget.render(); + void global.statsCalendarWidget.load(); + return global.statsCalendarWidget; + } + global.statsCalendarWidget = new TradeStatsCalendar({ + gridEl: grid, + titleEl: document.getElementById("stats-cal-title"), + prevBtn: document.getElementById("stats-cal-prev"), + nextBtn: document.getElementById("stats-cal-next"), + apiUrl: "/api/stats/calendar", + showSick: false, + buildQuery: function (year, month) { + var q = new URLSearchParams(); + q.set("year", String(year)); + q.set("month", String(month)); + var sel = document.getElementById("stats-segment-select"); + if (sel) q.set("segment", sel.value || "all"); + return q; + }, + parseResponse: function (data) { + if (data && data.ok === false) return {}; + return (data && data.days) || {}; + }, + }); + if (bootstrap) global.statsCalendarWidget.applyPayload(bootstrap); + global.statsCalendarWidget.render(); + void global.statsCalendarWidget.load(); + return global.statsCalendarWidget; + }; + + global.initStatsCalendarWidget = global.initInstanceStatsCalendar; +})(window); diff --git a/lib/common/wechat_notify_lib.py b/lib/common/wechat_notify_lib.py new file mode 100644 index 0000000..d24be62 --- /dev/null +++ b/lib/common/wechat_notify_lib.py @@ -0,0 +1,117 @@ +"""企业微信机器人 Webhook 推送(多实例共用).""" +from __future__ import annotations + +import re +from typing import Optional + +import requests + + +def strip_markdown_for_text(content: str) -> str: + s = str(content or "") + s = re.sub(r"\*\*([^*]+)\*\*", r"\1", s) + s = re.sub(r"`([^`]+)`", r"\1", s) + s = re.sub(r"^#+\s*", "", s, flags=re.MULTILINE) + s = re.sub(r"^---\s*$", "", s, flags=re.MULTILINE) + return s.strip() + + +def looks_like_wechat_markdown(content: str) -> bool: + if not content: + return False + if re.search(r"^#+\s", content, re.MULTILINE): + return True + return "**" in content or "`" in content + + +def send_wechat_webhook( + webhook_url: str, + content: str, + *, + timeout: int = 10, + prefix: str = "【加密货币】", +) -> bool: + url = (webhook_url or "").strip() + if not url or "replace-me" in url: + return False + body = str(content or "").strip() + if prefix: + full = f"{prefix}\n{body}" if body else prefix + else: + full = body + if not full.strip(): + return False + + payloads = [] + if looks_like_wechat_markdown(full): + payloads.append({"msgtype": "markdown", "markdown": {"content": full}}) + plain = strip_markdown_for_text(full) if looks_like_wechat_markdown(full) else full + payloads.append({"msgtype": "text", "text": {"content": plain}}) + + seen = set() + for payload in payloads: + key = payload["msgtype"] + if key in seen: + continue + seen.add(key) + try: + resp = requests.post(url, json=payload, timeout=timeout) + if resp.status_code != 200: + continue + data = resp.json() + if int(data.get("errcode", -1)) == 0: + return True + except Exception: + continue + return False + + +def wechat_direction_label(direction: str) -> str: + d = (direction or "").strip().lower() + if d == "long": + return "多头(long)" + if d == "short": + return "空头(short)" + return "双向(watch)" + + +def build_wechat_rs_level_message( + *, + symbol: str, + monitor_type: str, + account_label: str, + trigger_time: str, + upper_txt: str, + lower_txt: str, + close_txt: str, + edge_txt: str, + break_label: str, + direction: str, + notify_index: int, + notify_max: int, + interval_min: int, + extra_note: Optional[str] = None, +) -> str: + """阻力/支撑突破提醒(与开平仓推送一致的 emoji 纯文本风格).""" + head = "📈" if (direction or "").strip().lower() == "long" else "📉" + dir_txt = wechat_direction_label(direction) + lines = [ + f"{head} {symbol} 关键位突破提醒({notify_index}/{notify_max})", + f"💼 账户:{account_label}", + "", + "🧾 突破概要", + f"📌 类型:{monitor_type}", + f"⏱ 触发时间:{trigger_time}", + f"📊 上沿:{upper_txt}|下沿:{lower_txt}", + f"💹 触发收盘:{close_txt}", + f"🎯 {break_label}({dir_txt})", + f"📍 突破价位:{edge_txt}", + "", + "📎 说明", + f"· 人工盯盘,共推送 {notify_max} 次(间隔约 {interval_min} 分钟)", + "· 推送完毕后本条监控自动结案", + "· 不参与自动开仓", + ] + if extra_note: + lines.append(f"· {extra_note}") + return "\n".join(lines) diff --git a/lib/env/env_file_lib.py b/lib/env/env_file_lib.py new file mode 100644 index 0000000..e16a49c --- /dev/null +++ b/lib/env/env_file_lib.py @@ -0,0 +1,121 @@ +"""读写实例目录 .env(行级 upsert,原子落盘).""" +from __future__ import annotations + +import os +import re +import tempfile +from typing import Optional + +_KEY_LINE = re.compile(r"^(\s*)([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$") + + +def parse_env_lines(text: str) -> list[str]: + return text.replace("\r\n", "\n").replace("\r", "\n").splitlines() + + +def read_env_lines(path: str) -> list[str]: + if not os.path.isfile(path): + return [] + with open(path, "r", encoding="utf-8", errors="ignore") as f: + return parse_env_lines(f.read()) + + +def env_get(lines: list[str], key: str) -> Optional[str]: + for line in lines: + m = _KEY_LINE.match(line) + if m and m.group(2) == key: + raw = m.group(3).strip() + if (raw.startswith('"') and raw.endswith('"')) or (raw.startswith("'") and raw.endswith("'")): + return raw[1:-1] + return raw + return None + + +def env_get_all(lines: list[str]) -> dict[str, str]: + out: dict[str, str] = {} + for line in lines: + m = _KEY_LINE.match(line) + if m: + key = m.group(2) + raw = m.group(3).strip() + if (raw.startswith('"') and raw.endswith('"')) or (raw.startswith("'") and raw.endswith("'")): + out[key] = raw[1:-1] + else: + out[key] = raw + return out + + +def upsert_env_line(lines: list[str], key: str, value: str) -> list[str]: + pat = re.compile(r"^\s*" + re.escape(key) + r"\s*=") + out: list[str] = [] + replaced = False + safe = value if value is not None else "" + if any(c in safe for c in (' ', '#', '"', "'")): + safe = '"' + safe.replace("\\", "\\\\").replace('"', '\\"') + '"' + new_line = f"{key}={safe}" + for line in lines: + if pat.match(line): + if not replaced: + out.append(new_line) + replaced = True + continue + out.append(line) + if not replaced: + if out and out[-1].strip(): + out.append("") + out.append(new_line) + return out + + +def write_env_lines_atomic(path: str, lines: list[str]) -> None: + directory = os.path.dirname(os.path.abspath(path)) or "." + os.makedirs(directory, exist_ok=True) + fd, tmp = tempfile.mkstemp(prefix=".env.", dir=directory, text=True) + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f: + f.write("\n".join(lines)) + if lines: + f.write("\n") + os.replace(tmp, path) + finally: + if os.path.exists(tmp): + try: + os.remove(tmp) + except OSError: + pass + + +def apply_env_updates(path: str, updates: dict[str, str]) -> list[str]: + lines = read_env_lines(path) + changed: list[str] = [] + for key, value in updates.items(): + if value is None: + continue + old = env_get(lines, key) + if old == value: + continue + lines = upsert_env_line(lines, key, value) + changed.append(key) + if changed: + write_env_lines_atomic(path, lines) + return changed + + +def load_env_file_into_environ(path: str) -> None: + if not os.path.exists(path): + return + with open(path, "r", encoding="utf-8", errors="ignore") as f: + text = f.read() + if text.startswith("\ufeff"): + text = text[1:] + for line in parse_env_lines(text): + s = line.strip() + if not s or s.startswith("#"): + continue + if "=" not in s: + continue + k, _, v = s.partition("=") + clean_key = k.strip() + clean_val = v.strip().strip('"').strip("'") + if clean_key: + os.environ[clean_key] = clean_val diff --git a/lib/env/env_schema.py b/lib/env/env_schema.py new file mode 100644 index 0000000..45d2625 --- /dev/null +++ b/lib/env/env_schema.py @@ -0,0 +1,304 @@ +"""从 .env.example 构建 env 配置 schema(分组,敏感,重启标注).""" +from __future__ import annotations + +import os +import re +from typing import Any, Optional + +from lib.env.env_file_lib import env_get, env_get_all, read_env_lines + +_GROUP_RE = re.compile(r"^#\s*=+\s*(.+?)\s*=+\s*$") +_SEPARATOR_RE = re.compile(r"^#\s*=+\s*$") +_SECTION_DASH_RE = re.compile(r"^#\s*---\s*(.+?)\s*---\s*$") +_KEY_LINE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*=") + +RESTART_REQUIRED_EXACT = frozenset({ + "APP_HOST", + "APP_PORT", + "APP_DEBUG", + "DB_PATH", + "UPLOAD_DIR", + "FLASK_SECRET_KEY", + "POSITION_SIZING_MODE", + "LIVE_TRADING_ENABLED", + "OKX_TD_MODE", + "OKX_POS_MODE", + "OKX_POSITION_INST_TYPE", + "BINANCE_MARGIN_MODE", + "BINANCE_POSITION_MODE", + "GATE_TD_MODE", + "GATE_POS_MODE", + "PM2_APP_NAME", +}) + +RESTART_REQUIRED_PREFIXES = ( + "OKX_API_", + "OKX_OPTIONS_API_", + "BINANCE_API_", + "GATE_API_", + "OKX_SOCKS_", + "OKX_HTTP_", + "OKX_HTTPS_", + "BINANCE_HTTP_", + "BINANCE_HTTPS_", + "GATE_HTTP_", + "GATE_HTTPS_", +) + +HOT_RELOAD_EXACT = frozenset({ + "RISK_PERCENT", + "MAX_ACTIVE_POSITIONS", + "MANUAL_MIN_PLANNED_RR", + "KEY_AUTO_MIN_PLANNED_RR", + "DAILY_OPEN_ALERT_THRESHOLD", + "DAILY_OPEN_HARD_LIMIT", + "TRADING_DAY_RESET_HOUR", + "TRADING_DAY_RESET_OPEN_GUARD_ENABLED", + "RISK_CONTROL_ENABLED", + "RISK_COOLING_HOURS_MANUAL", + "RISK_COOLING_HOURS_MANUAL_JOURNAL", + "RISK_MANUAL_CLOSE_DAILY_LIMIT", + "RISK_MOOD_ISSUES_DAILY_FREEZE", + "KEY_AUTO_ORDER_ENABLED", + "TRADE_DIRECTION_RESTRICT_ENABLED", + "TRADE_DIRECTION", + "TRADE_SYMBOL_RESTRICT_ENABLED", + "TRADE_SYMBOL_WHITELIST", + "BALANCE_REFRESH_SECONDS", + "PRICE_REFRESH_SECONDS", + "MONITOR_POLL_SECONDS", + "AUTO_TRANSFER_ENABLED", + "AUTO_TRANSFER_AMOUNT", + "AUTO_TRANSFER_BJ_HOUR", + "FORCE_CLOSE_ENABLED", + "FORCE_CLOSE_BJ_HOUR", + "BTC_LEVERAGE", + "ALT_LEVERAGE", + "DAILY_START_CAPITAL", + "DAILY_LOSS_CAPITAL", + "DAILY_PROFIT_CAPITAL", + "FULL_MARGIN_BUFFER_RATIO", + "APP_USERNAME", + "APP_PASSWORD", + "APP_AUTH_DISABLED", + "WECHAT_WEBHOOK", + "HEDGE_PLAN_ENABLED", + "HEDGE_PLAN_LIVE_ORDER", + "HEDGE_PLAN_OPEN_ORDER", + "HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", + "HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", + "HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", + "MAX_ACTIVE_HEDGE_PLANS", + "HEDGE_PLAN_MONITOR_POLL_SECONDS", + "HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", +}) + +SENSITIVE_EXACT = frozenset({ + "APP_PASSWORD", + "FLASK_SECRET_KEY", + "HUB_BRIDGE_TOKEN", + "OPENAI_API_KEY", +}) + +SENSITIVE_SUBSTR = ("_SECRET", "_PASSPHRASE", "_API_KEY", "_PASSWORD") + + +def _is_sensitive(key: str) -> bool: + if key in SENSITIVE_EXACT: + return True + return any(s in key for s in SENSITIVE_SUBSTR) + + +def _restart_required(key: str) -> bool: + if key in HOT_RELOAD_EXACT: + return False + if key in RESTART_REQUIRED_EXACT: + return True + return any(key.startswith(p) for p in RESTART_REQUIRED_PREFIXES) + + +def _hot_reload(key: str) -> bool: + if key in HOT_RELOAD_EXACT: + return True + if _restart_required(key): + return False + return key.startswith(("KEY_", "KLINE_", "BREAKEVEN_", "RECONCILE_", "ORDER_CHART_")) + + +def _field_type(key: str, value: str) -> str: + low = (value or "").strip().lower() + if low in ("true", "false"): + return "bool" + if key.endswith("_ENABLED") or key.startswith("RISK_MOOD_"): + return "bool" + try: + if "." in low: + float(low) + return "float" + int(low) + return "int" + except ValueError: + pass + return "text" + + +def _mask_value(key: str, value: Optional[str]) -> dict[str, Any]: + if value is None or value == "": + return {"value": "", "masked": "", "tail": "", "has_value": False} + if not _is_sensitive(key): + return {"value": value, "masked": value, "tail": "", "has_value": True} + tail = value[-4:] if len(value) >= 4 else value + return {"value": "", "masked": f"****{tail}", "tail": tail, "has_value": True} + + +def parse_env_example_schema(example_path: str) -> list[dict[str, Any]]: + if not os.path.isfile(example_path): + return [] + lines = read_env_lines(example_path) + groups: list[dict[str, Any]] = [] + group_map: dict[str, dict[str, Any]] = {} + current_group = "基础配置" + pending_note: list[str] = [] + in_section_block = False + section_title_set = False + allow_section_blocks = False + + def _ensure_group(title: str) -> dict[str, Any]: + title = (title or "").strip() or "其他" + if title not in group_map: + group_map[title] = {"title": title, "fields": []} + groups.append(group_map[title]) + return group_map[title] + + for raw in lines: + line = raw.rstrip() + stripped = line.strip() + if not stripped: + pending_note = [] + continue + if _SEPARATOR_RE.match(stripped): + if not allow_section_blocks: + continue + if not in_section_block: + in_section_block = True + section_title_set = False + else: + in_section_block = False + continue + if in_section_block and stripped.startswith("#"): + note = stripped.lstrip("#").strip() + if note and not section_title_set: + current_group = note + _ensure_group(current_group) + section_title_set = True + elif note: + pending_note.append(note) + continue + gm = _GROUP_RE.match(stripped) + if gm: + title = gm.group(1).strip() + if title and title != "=": + current_group = title + _ensure_group(current_group) + in_section_block = False + section_title_set = False + pending_note = [] + continue + dash = _SECTION_DASH_RE.match(stripped) + if dash: + allow_section_blocks = True + current_group = dash.group(1).strip() + _ensure_group(current_group) + in_section_block = False + section_title_set = False + pending_note = [] + continue + if stripped.startswith("#"): + note = stripped.lstrip("#").strip() + if note and not note.startswith("="): + pending_note.append(note) + continue + km = _KEY_LINE.match(stripped) + if not km: + continue + key = km.group(1) + allow_section_blocks = True + default_val = env_get(lines, key) or "" + grp = _ensure_group(current_group) + note = " ".join(pending_note).strip() + grp["fields"].append( + { + "key": key, + "label": key, + "note": note, + "default": default_val, + "type": _field_type(key, default_val), + "sensitive": _is_sensitive(key), + "restart_required": _restart_required(key), + "hot_reload": _hot_reload(key), + } + ) + pending_note = [] + return [g for g in groups if g.get("fields")] + + +def build_env_payload(example_path: str, env_path: str) -> dict[str, Any]: + groups = parse_env_example_schema(example_path) + env_lines = read_env_lines(env_path) + values = env_get_all(env_lines) + for group in groups: + for field in group.get("fields") or []: + key = field["key"] + val = values.get(key) + if val is None: + val = field.get("default") or "" + masked = _mask_value(key, val) + field["current"] = masked["value"] if not field["sensitive"] else "" + field["masked"] = masked["masked"] + field["has_value"] = masked["has_value"] + return {"groups": groups} + + +def validate_env_updates(groups: list[dict], updates: dict[str, str]) -> tuple[dict[str, str], list[str]]: + allowed = {} + for group in groups: + for field in group.get("fields") or []: + allowed[field["key"]] = field + clean: dict[str, str] = {} + errors: list[str] = [] + for key, value in (updates or {}).items(): + if key not in allowed: + errors.append(f"未知配置项: {key}") + continue + if value is None: + continue + val = str(value).strip() + if allowed[key].get("sensitive") and (val == "" or (val.startswith("****") and len(val) <= 8)): + continue + # API Key 被密码管理器/自动填充成登录密码时通常很短;OKX Key 一般为 36 位 + if key.endswith("_API_KEY") and 0 < len(val) < 16: + errors.append(f"{key} 长度异常,疑似自动填充;留空则不修改已有密钥") + continue + ftype = allowed[key].get("type") + if ftype == "bool": + low = val.lower() + if low not in ("true", "false", "1", "0", "yes", "no", "on", "off"): + errors.append(f"{key} 须为 true/false") + continue + val = "true" if low in ("true", "1", "yes", "on") else "false" + clean[key] = val + return clean, errors + + +def updates_need_restart(groups: list[dict], changed_keys: list[str]) -> bool: + field_map = {} + for group in groups: + for field in group.get("fields") or []: + field_map[field["key"]] = field + for key in changed_keys: + meta = field_map.get(key) or {} + if meta.get("restart_required"): + return True + if not meta.get("hot_reload"): + return True + return False diff --git a/lib/env/env_ui_manifest.py b/lib/env/env_ui_manifest.py new file mode 100644 index 0000000..182dfd0 --- /dev/null +++ b/lib/env/env_ui_manifest.py @@ -0,0 +1,276 @@ +"""env 配置页 UI 白名单:中文标签,按交易所过滤.""" +from __future__ import annotations + +import os +from typing import Any, Optional + +from lib.env.env_file_lib import env_get_all, read_env_lines +from lib.env.env_schema import ( + _field_type, + _hot_reload, + _is_sensitive, + _mask_value, + _restart_required, + parse_env_example_schema, +) + +# 各所「交易所与实盘」字段(顺序即页面顺序) +_EXCHANGE_LIVE_FIELDS: dict[str, list[tuple[str, str, str]]] = { + "okx": [ + ("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"), + ("OKX_API_KEY", "API Key", "永续子账户"), + ("OKX_API_SECRET", "API Secret", "永续子账户"), + ("OKX_API_PASSPHRASE", "API Passphrase", "OKX 必填"), + ("OKX_TD_MODE", "保证金模式", "cross=全仓,isolated=逐仓"), + ("OKX_POS_MODE", "持仓模式", "hedge=双向,net=单向净持仓"), + ("OKX_POSITION_INST_TYPE", "仓位查询类型", "如 SWAP"), + ("OKX_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"), + ], + "binance": [ + ("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"), + ("BINANCE_API_KEY", "API Key", "永续子账户"), + ("BINANCE_API_SECRET", "API Secret", "永续子账户"), + ("BINANCE_MARGIN_MODE", "保证金模式", "cross=全仓,isolated=逐仓"), + ("BINANCE_POSITION_MODE", "持仓模式", "hedge=双向,one_way=单向"), + ("BINANCE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"), + ], + "gate": [ + ("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"), + ("GATE_API_KEY", "API Key", "永续子账户"), + ("GATE_API_SECRET", "API Secret", "永续子账户"), + ("GATE_TD_MODE", "保证金模式", "cross=全仓,isolated=逐仓"), + ("GATE_POS_MODE", "持仓模式", "hedge=双向,single=单向"), + ("GATE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"), + ], +} + +_SHARED_SECTIONS: list[dict[str, Any]] = [ + { + "title": "企业微信", + "fields": [ + ("WECHAT_WEBHOOK", "机器人 Webhook", "行情与风控推送地址"), + ("WECHAT_TIMEOUT_SECONDS", "推送超时(秒)", "默认 10"), + ], + }, + { + "title": "交易执行", + "fields": [ + ("POSITION_SIZING_MODE", "计仓模式", "risk=以损定仓,full_margin=全仓杠杆"), + ("RISK_PERCENT", "以损定仓风险%", "单笔风险占资金比例"), + ("FULL_MARGIN_BUFFER_RATIO", "全仓资金缓冲比例", "如 0.98"), + ("BTC_LEVERAGE", "BTC 默认杠杆", ""), + ("ALT_LEVERAGE", "山寨默认杠杆", ""), + ("TRADE_DIRECTION_RESTRICT_ENABLED", "方向限制开关", ""), + ("TRADE_DIRECTION", "允许方向", "long_only / short_only / both"), + ("TRADE_SYMBOL_RESTRICT_ENABLED", "币种白名单开关", ""), + ("TRADE_SYMBOL_WHITELIST", "白名单币种", "逗号分隔,如 BTC,ETH"), + ("TRADING_DAY_RESET_HOUR", "交易日切点(北京时间)", "整点,默认 8"), + ("TRADING_DAY_RESET_OPEN_GUARD_ENABLED", "切点前禁止新开仓", ""), + ("MAX_ACTIVE_POSITIONS", "最大同时持仓", ""), + ("MANUAL_MIN_PLANNED_RR", "人工最低盈亏比", "如 1.4"), + ("FORCE_CLOSE_ENABLED", "强制清仓开关", ""), + ("FORCE_CLOSE_BJ_HOUR", "强制清仓整点(北京)", ""), + ], + }, + { + "title": "交易风控", + "fields": [ + ("DAILY_OPEN_ALERT_THRESHOLD", "单日开仓提醒阈值", "达次数后 AI 提醒,不拦单"), + ("DAILY_OPEN_HARD_LIMIT", "单日开仓硬上限", "0=不启用"), + ], + }, + { + "title": "账户冷静期", + "fields": [ + ("RISK_CONTROL_ENABLED", "冷静期总开关", ""), + ("RISK_COOLING_HOURS_MANUAL", "手动平仓冷静(小时)", ""), + ("RISK_COOLING_HOURS_MANUAL_JOURNAL", "复盘情绪冷静(小时)", ""), + ("RISK_MANUAL_CLOSE_DAILY_LIMIT", "日手动平仓次数上限", ""), + ("RISK_MOOD_ISSUES_DAILY_FREEZE", "情绪标签日冻结", ""), + ], + }, + { + "title": "自动划转", + "fields": [ + ("AUTO_TRANSFER_ENABLED", "启用自动划转", ""), + ("AUTO_TRANSFER_AMOUNT", "目标余额(U)", "交易账户目标 USDT"), + ("AUTO_TRANSFER_FROM", "划出账户", "funding 或 swap"), + ("AUTO_TRANSFER_TO", "划入账户", "swap 或 funding"), + ("AUTO_TRANSFER_BJ_HOUR", "执行整点(北京时间)", ""), + ("TRANSFER_CCY", "划转币种", "默认 USDT"), + ], + }, + { + "title": "当日资金", + "fields": [ + ("DAILY_START_CAPITAL", "日起始基数(U)", ""), + ("DAILY_LOSS_CAPITAL", "回撤后基数(U)", ""), + ("DAILY_PROFIT_CAPITAL", "盈利后基数(U)", ""), + ], + }, +] + +_OPTIONS_SECTION: dict[str, Any] = { + "title": "期权账户", + "exchanges": frozenset({"okx"}), + "fields": [ + ("OKX_OPTIONS_ENABLED", "启用期权模块", ""), + ("OKX_OPTIONS_API_KEY", "期权 API Key", "主账户,与永续子账户分离"), + ("OKX_OPTIONS_API_SECRET", "期权 API Secret", ""), + ("OKX_OPTIONS_API_PASSPHRASE", "期权 API Passphrase", ""), + ("OKX_OPTIONS_ACCOUNT_LABEL", "期权账户备注", ""), + ("OKX_OPTIONS_TRADE_BUDGET_USDC", "单笔预算(USDC)", ""), + ("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95"), + ("OKX_OPTIONS_DEFAULT_UNDERLY", "默认标的", "如 ETH"), + ], +} + +_HEDGE_PLAN_SECTION: dict[str, Any] = { + "title": "对冲计划", + "exchanges": frozenset({"okx"}), + "fields": [ + ("HEDGE_PLAN_ENABLED", "启用对冲计划", "关闭则隐藏导航且不可开仓"), + ("HEDGE_PLAN_LIVE_ORDER", "允许对冲真实下单", "再与实盘 LIVE_TRADING_ENABLED 同开才可启动永期"), + ("HEDGE_PLAN_OPEN_ORDER", "永期开仓顺序", "options_first 或 perp_first"), + ("HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", "永期止损后强制平期权", "保护机制,建议保持 true"), + ("HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", "永期止盈后强制平期权", "默认 false,保险腿不平"), + ("HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", "期期只平盈利腿", "达目标价只平盈利方"), + ("MAX_ACTIVE_HEDGE_PLANS", "最大同时活跃计划数", "建议 1"), + ("HEDGE_PLAN_MONITOR_POLL_SECONDS", "对冲监控轮询(秒)", "默认 15"), + ("HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", "半腿失败时自动平期权", ""), + ], +} + + +# 与运行时 os.getenv 默认一致;.env 未写明时展示实际生效值(同风控说明页) +_RUNTIME_ENV_DEFAULTS: dict[str, str] = { + "RISK_CONTROL_ENABLED": "true", + "RISK_COOLING_HOURS_MANUAL": "4", + "RISK_COOLING_HOURS_MANUAL_JOURNAL": "1", + "RISK_MANUAL_CLOSE_DAILY_LIMIT": "2", + "RISK_MOOD_ISSUES_DAILY_FREEZE": "true", +} + + +def _effective_env_value(key: str, file_values: dict[str, str], schema_default: str = "") -> str: + if key in file_values: + return file_values[key] + runtime = os.getenv(key) + if runtime is not None and str(runtime).strip() != "": + return str(runtime).strip() + if schema_default: + return schema_default + return _RUNTIME_ENV_DEFAULTS.get(key, "") + + +def _schema_field_map(example_path: str) -> dict[str, dict[str, Any]]: + out: dict[str, dict[str, Any]] = {} + for group in parse_env_example_schema(example_path): + for field in group.get("fields") or []: + out[field["key"]] = dict(field) + return out + + +def _build_field( + key: str, + label: str, + note: str, + schema: dict[str, dict[str, Any]], + values: dict[str, str], +) -> dict[str, Any]: + meta = schema.get(key) or {} + schema_default = meta.get("default") or "" + val = _effective_env_value(key, values, schema_default) + masked = _mask_value(key, val) + ftype = meta.get("type") or _field_type(key, val or schema_default) + return { + "key": key, + "label": label, + "note": note or meta.get("note") or "", + "default": val, + "type": ftype, + "sensitive": meta.get("sensitive", _is_sensitive(key)), + "restart_required": meta.get("restart_required", _restart_required(key)), + "hot_reload": meta.get("hot_reload", _hot_reload(key)), + "current": masked["value"] if not _is_sensitive(key) else "", + "masked": masked["masked"], + "tail": masked.get("tail") or "", + "has_value": masked["has_value"], + } + + +def ui_sections_for_exchange(exchange_key: str) -> list[dict[str, Any]]: + ex = (exchange_key or "").strip().lower() + sections: list[dict[str, Any]] = [] + live_fields = _EXCHANGE_LIVE_FIELDS.get(ex, _EXCHANGE_LIVE_FIELDS["okx"]) + sections.append({"title": "交易所与实盘", "fields": live_fields}) + sections.extend(_SHARED_SECTIONS) + if ex in _OPTIONS_SECTION.get("exchanges", frozenset()): + sections.append(_OPTIONS_SECTION) + if ex in _HEDGE_PLAN_SECTION.get("exchanges", frozenset()): + sections.append(_HEDGE_PLAN_SECTION) + return sections + + +def ui_allowed_keys(exchange_key: str) -> frozenset[str]: + keys: set[str] = set() + for sec in ui_sections_for_exchange(exchange_key): + for item in sec["fields"]: + keys.add(item[0]) + return frozenset(keys) + + +def build_env_ui_payload( + exchange_key: str, + example_path: str, + env_path: str, +) -> list[dict[str, Any]]: + schema = _schema_field_map(example_path) + env_lines = read_env_lines(env_path) + values = env_get_all(env_lines) + groups: list[dict[str, Any]] = [] + for sec in ui_sections_for_exchange(exchange_key): + fields = [ + _build_field(key, label, note, schema, values) + for key, label, note in sec["fields"] + ] + groups.append({ + "title": sec["title"], + "fields": fields, + "has_restart": any(f.get("restart_required") for f in fields), + }) + return groups + + +def filter_updates_for_ui(exchange_key: str, updates: dict[str, str]) -> dict[str, str]: + allowed = ui_allowed_keys(exchange_key) + return {k: v for k, v in (updates or {}).items() if k in allowed} + + +def validate_env_ui_updates( + exchange_key: str, + example_path: str, + updates: dict[str, str], +) -> tuple[dict[str, str], list[str]]: + from lib.env.env_schema import validate_env_updates + + schema = _schema_field_map(example_path) + groups: list[dict[str, Any]] = [] + for sec in ui_sections_for_exchange(exchange_key): + fields: list[dict[str, Any]] = [] + for key, _label, _note in sec["fields"]: + if key in schema: + fields.append(schema[key]) + else: + default = "" + fields.append( + { + "key": key, + "type": _field_type(key, default), + "sensitive": _is_sensitive(key), + "restart_required": _restart_required(key), + "hot_reload": _hot_reload(key), + } + ) + groups.append({"title": sec["title"], "fields": fields}) + return validate_env_updates(groups, updates) diff --git a/lib/env/shared_env_lib.py b/lib/env/shared_env_lib.py new file mode 100644 index 0000000..bb8efa0 --- /dev/null +++ b/lib/env/shared_env_lib.py @@ -0,0 +1,237 @@ +"""中控统一 AI 环境变量:字段定义,读写,同步三实例.""" +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path +from typing import Any + +from lib.env.env_file_lib import apply_env_updates, env_get_all, load_env_file_into_environ, read_env_lines +from lib.env.env_schema import ( + _field_type, + _hot_reload, + _is_sensitive, + _mask_value, + _restart_required, + parse_env_example_schema, + validate_env_updates, +) +from lib.paths import REPO_ROOT + +AI_ENV_FIELDS: list[tuple[str, str, str]] = [ + ("AI_PROVIDER", "AI 提供方", "openai 或 ollama"), + ("OPENAI_API_BASE", "API 地址", "OpenAI 兼容接口"), + ("OPENAI_API_KEY", "API 密钥", "留空表示不修改"), + ("OPENAI_MODEL", "云端模型", ""), + ("OLLAMA_API", "Ollama 地址", "本地服务 URL"), + ("AI_MODEL", "Ollama 模型", ""), + ("AI_TIMEOUT_SECONDS", "请求超时(秒)", "默认 120"), +] + +AI_ENV_KEYS = frozenset(k for k, _l, _n in AI_ENV_FIELDS) + +INSTANCE_ENV_DIRS: dict[str, Path] = { + "okx": REPO_ROOT / "crypto_monitor_okx", + "binance": REPO_ROOT / "crypto_monitor_binance", + "gate": REPO_ROOT / "crypto_monitor_gate", +} + + +def hub_env_path() -> str: + return str(REPO_ROOT / "manual_trading_hub" / ".env") + + +def hub_example_path() -> str: + return str(REPO_ROOT / "manual_trading_hub" / ".env.example") + + +def instance_example_path(exchange_key: str = "okx") -> str: + ex = (exchange_key or "okx").strip().lower() + base = INSTANCE_ENV_DIRS.get(ex, INSTANCE_ENV_DIRS["okx"]) + return str(base / ".env.example") + + +def _schema_field_map(example_path: str) -> dict[str, dict[str, Any]]: + out: dict[str, dict[str, Any]] = {} + for group in parse_env_example_schema(example_path): + for field in group.get("fields") or []: + out[field["key"]] = dict(field) + return out + + +def _build_field( + key: str, + label: str, + note: str, + schema: dict[str, dict[str, Any]], + values: dict[str, str], +) -> dict[str, Any]: + meta = schema.get(key) or {} + schema_default = meta.get("default") or "" + val = values.get(key, "") + if val == "" and schema_default: + val = schema_default + masked = _mask_value(key, val) + ftype = meta.get("type") or _field_type(key, val or schema_default) + return { + "key": key, + "label": label, + "note": note or meta.get("note") or "", + "default": val, + "type": ftype, + "sensitive": meta.get("sensitive", _is_sensitive(key)), + "restart_required": meta.get("restart_required", _restart_required(key)), + "hot_reload": meta.get("hot_reload", _hot_reload(key)), + "current": masked["value"] if not _is_sensitive(key) else "", + "masked": masked["masked"], + "tail": masked.get("tail") or "", + "has_value": masked["has_value"], + } + + +def build_ai_env_payload(env_path: str | None = None, example_path: str | None = None) -> dict[str, Any]: + env_path = env_path or hub_env_path() + example_path = example_path or hub_example_path() + schema = _schema_field_map(example_path) + values = env_get_all(read_env_lines(env_path)) + fields = [ + _build_field(key, label, note, schema, values) + for key, label, note in AI_ENV_FIELDS + ] + sync_status = ai_sync_status() + return { + "title": "AI 复盘", + "fields": fields, + "sync_status": sync_status, + } + + +def ai_sync_status() -> dict[str, Any]: + """比较 hub 与三实例 AI 键是否一致(用于 UI 提示).""" + hub_vals = env_get_all(read_env_lines(hub_env_path())) + per_instance: dict[str, dict[str, Any]] = {} + all_ok = True + for ex, inst_dir in INSTANCE_ENV_DIRS.items(): + path = str(inst_dir / ".env") + if not os.path.isfile(path): + per_instance[ex] = {"ok": False, "msg": "缺少 .env"} + all_ok = False + continue + inst_vals = env_get_all(read_env_lines(path)) + mismatched = [ + k + for k in AI_ENV_KEYS + if (hub_vals.get(k) or "") != (inst_vals.get(k) or "") + ] + ok = not mismatched + if not ok: + all_ok = False + per_instance[ex] = { + "ok": ok, + "mismatched_keys": mismatched, + } + return {"all_synced": all_ok, "instances": per_instance} + + +def _ai_validate_groups(example_path: str) -> list[dict[str, Any]]: + schema = _schema_field_map(example_path) + fields: list[dict[str, Any]] = [] + for key, _label, _note in AI_ENV_FIELDS: + if key in schema: + fields.append(schema[key]) + else: + fields.append( + { + "key": key, + "type": _field_type(key, ""), + "sensitive": _is_sensitive(key), + "restart_required": _restart_required(key), + "hot_reload": _hot_reload(key), + } + ) + return [{"title": "AI 复盘", "fields": fields}] + + +def validate_ai_env_updates(updates: dict[str, str], example_path: str | None = None) -> tuple[dict[str, str], list[str]]: + example_path = example_path or hub_example_path() + groups = _ai_validate_groups(example_path) + filtered = {k: v for k, v in (updates or {}).items() if k in AI_ENV_KEYS} + unknown = [k for k in (updates or {}) if k not in AI_ENV_KEYS] + errors = [f"未知配置项: {k}" for k in unknown] + clean, val_errors = validate_env_updates(groups, filtered) + errors.extend(val_errors) + return clean, errors + + +def apply_ai_env_to_all(updates: dict[str, str]) -> dict[str, Any]: + """写入 hub .env 并强制同步三实例相同键.""" + clean, errors = validate_ai_env_updates(updates) + if errors: + return {"ok": False, "errors": errors, "changed": {}} + if not clean: + return {"ok": True, "changed": {}, "restart_required": False} + + changed: dict[str, list[str]] = {} + targets = [("hub", hub_env_path())] + for ex, inst_dir in INSTANCE_ENV_DIRS.items(): + targets.append((ex, str(inst_dir / ".env"))) + + for name, path in targets: + if not os.path.isfile(path): + if name == "hub": + return {"ok": False, "errors": [f"缺少 {path}"], "changed": {}} + continue + keys = apply_env_updates(path, clean) + if keys: + changed[name] = keys + load_env_file_into_environ(path) + + return { + "ok": True, + "changed": changed, + "restart_required": True, + "errors": [], + } + + +def restart_instances_then_hub_pm2() -> dict[str, Any]: + """先重启三实例,最后重启中控(避免当前请求被中断).""" + if not sys.platform.startswith("linux"): + return {"ok": False, "msg": "仅 Linux 服务器支持 PM2 重启", "results": []} + from lib.instance.instance_pm2_lib import restart_instance_pm2 + + results: list[dict[str, Any]] = [] + for ex in ("okx", "binance", "gate"): + r = restart_instance_pm2(ex) + results.append({"exchange": ex, **r}) + hub_result = _restart_pm2_app("manual-trading-hub") + results.append({"app": "manual-trading-hub", **hub_result}) + ok = all(r.get("ok") for r in results) + return {"ok": ok, "results": results} + + +def restart_hub_and_instances_pm2() -> dict[str, Any]: + """兼容旧调用:与 restart_instances_then_hub_pm2 相同顺序.""" + return restart_instances_then_hub_pm2() + + +def _restart_pm2_app(app_name: str) -> dict[str, Any]: + try: + proc = subprocess.run( + ["pm2", "restart", app_name, "--update-env"], + capture_output=True, + text=True, + timeout=120, + ) + return { + "ok": proc.returncode == 0, + "msg": (proc.stdout or proc.stderr or "").strip()[:500], + "returncode": proc.returncode, + } + except FileNotFoundError: + return {"ok": False, "msg": "未找到 pm2 命令"} + except subprocess.TimeoutExpired: + return {"ok": False, "msg": "pm2 restart 超时"} + except Exception as e: + return {"ok": False, "msg": str(e)} diff --git a/lib/exchange/__init__.py b/lib/exchange/__init__.py new file mode 100644 index 0000000..ab164b5 --- /dev/null +++ b/lib/exchange/__init__.py @@ -0,0 +1 @@ +"""Shared library package.""" diff --git a/lib/exchange/gate_ccxt_lib.py b/lib/exchange/gate_ccxt_lib.py new file mode 100644 index 0000000..0f143f6 --- /dev/null +++ b/lib/exchange/gate_ccxt_lib.py @@ -0,0 +1,9 @@ +"""Gate.io ccxt 构造(ccxt 4.x 起类名由 gateio 改为 gate).""" +from __future__ import annotations + +import ccxt + + +def gate_ccxt_class(): + """返回 ccxt Gate 交易所类(兼容旧版 gateio 名称).""" + return getattr(ccxt, "gate", None) or ccxt.gateio diff --git a/lib/exchange/gate_position_history_lib.py b/lib/exchange/gate_position_history_lib.py new file mode 100644 index 0000000..6fc37ab --- /dev/null +++ b/lib/exchange/gate_position_history_lib.py @@ -0,0 +1,66 @@ +"""Gate 平仓历史匹配(fetch_positions_history),供 reconcile / 中控全平同步共用.""" + +from __future__ import annotations + + +def unified_symbol_for_match(symbol_str: str) -> str: + x = (symbol_str or "").strip().upper() + if ":" in x: + x = x.split(":")[0] + return x + + +def pick_gate_position_close( + hist: list[dict], + symbol: str, + direction: str, + *, + opened_at_ms: int | None = None, + closed_at_ms: int | None = None, + used_keys: set[str] | None = None, + max_close_delta_ms: int = 25 * 60 * 1000, +) -> dict | None: + """ + 从 Gate 平仓历史列表中选取与 symbol/direction/开仓时间最匹配的一条. + 返回 normalize 后的 dict(含 close_ms,pnl,sync_key 等),无匹配则 None. + """ + if not hist: + return None + sym_u = unified_symbol_for_match(symbol) + dir_l = (direction or "long").strip().lower() + if dir_l not in ("long", "short"): + return None + used = used_keys or set() + ref_ms = closed_at_ms or opened_at_ms + best = None + best_d = None + for h in hist: + if not isinstance(h, dict): + continue + sk = h.get("sync_key") + if not sk or sk in used: + continue + if h.get("symbol_u") != sym_u: + continue + if (h.get("side") or "").strip().lower() != dir_l: + continue + cm = h.get("close_ms") + if cm is None: + continue + if opened_at_ms is not None: + if cm < opened_at_ms - 15 * 60 * 1000: + continue + if cm > opened_at_ms + 15 * 86400 * 1000: + continue + if ref_ms is not None: + d = abs(int(cm) - int(ref_ms)) + else: + d = 0 + if best_d is None or d < best_d: + best_d = d + best = h + if best is None or best_d is None: + return None + if ref_ms is not None and best_d > max_close_delta_ms: + return None + return best diff --git a/lib/exchange/gate_transfer_lib.py b/lib/exchange/gate_transfer_lib.py new file mode 100644 index 0000000..2adcb46 --- /dev/null +++ b/lib/exchange/gate_transfer_lib.py @@ -0,0 +1,55 @@ +"""Gate.io 资金划转(crypto_monitor_gate 共用).""" +from __future__ import annotations + +from typing import Any, Callable, Optional + +INVALID_KEY_HINT = ( + ".常见原因:① GATE_API_SECRET 错误或 .env 里多了空格/换行;② IP 白名单未包含当前服务器出口 IP;" + "③ Gate「交易账户」类 API Key 若不支持钱包接口则无法走账户内划转 POST /wallet/transfers(需在官网确认该 Key 类型是否开放划转);" + "④ Key 已重置或权限变更.你已勾选现货/统一账户仍报错时,优先核对 Secret 与白名单." +) + + +def execute_transfer_usdt( + exchange, + amount: float, + from_account: str, + to_account: str, + *, + transfer_ccy: str = "USDT", + ensure_live_ready: Callable[[], tuple[bool, str]], + ensure_markets_loaded: Optional[Callable[[], None]] = None, +) -> tuple[bool, str, Any]: + if amount <= 0: + return False, "划转金额必须大于0", None + ok_live, reason = ensure_live_ready() + if not ok_live: + return False, reason, None + if ensure_markets_loaded: + try: + ensure_markets_loaded() + except Exception: + pass + 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 += INVALID_KEY_HINT + return False, msg, None + + +def count_auto_transfer_blockers(conn, *, count_order_monitors: Callable[[Any], int]) -> int: + """自动划转持仓守卫:order_monitors active + 趋势回调已开仓计划.""" + n = int(count_order_monitors(conn) or 0) + if n > 0: + return n + try: + row = conn.execute( + "SELECT COUNT(*) FROM trend_pullback_plans " + "WHERE status='active' AND COALESCE(first_order_done, 0) != 0" + ).fetchone() + return int(row[0] or 0) if row else 0 + except Exception: + return n diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py new file mode 100644 index 0000000..7f58ec8 --- /dev/null +++ b/lib/exchange/okx_options_lib.py @@ -0,0 +1,1566 @@ +"""OKX USDⓈ 期权 API 封装(主账户 exchange_options 专用).""" +from __future__ import annotations + +import json +import math +import re +import threading +import time +from typing import Any, Callable + +import ccxt + +from lib.options.options_pricing_lib import ( + expiry_breakeven_from_ask, + idx_distance_to_be, + is_shallow_itm, + option_moneyness, + option_moneyness_label, +) + +_OKX_OPTION_ERR_ZH: dict[str, str] = { + "51008": "资金账户 USDT 可用余额不足", + "51018": "期权账户不能持有净空头头寸", + "51019": "期权买入须使用逐仓模式(全仓模式下不能持有多头净头寸)", +} + +_OPTIONS_BALANCE_CACHE: dict[str, Any] = {"updated_at": 0.0, "data": None} + + +def invalidate_options_balance_cache() -> None: + _OPTIONS_BALANCE_CACHE["updated_at"] = 0.0 + _OPTIONS_BALANCE_CACHE["data"] = None + + +def _okx_trade_error_message(exc: BaseException | None = None, resp: Any = None) -> str: + row: dict[str, Any] | None = None + if isinstance(resp, dict): + data = resp.get("data") or [] + if data and isinstance(data[0], dict): + row = data[0] + if row is None and exc is not None: + text = str(exc) + match = re.search(r"\{.*\}", text, re.DOTALL) + if match: + try: + payload = json.loads(match.group(0)) + data = payload.get("data") or [] + if data and isinstance(data[0], dict): + row = data[0] + except json.JSONDecodeError: + pass + if row: + code = str(row.get("sCode") or "") + zh = _OKX_OPTION_ERR_ZH.get(code) + if zh: + return zh + msg = str(row.get("sMsg") or "").strip() + if msg: + return msg + if exc is not None: + text = str(exc).strip() + if text.lower().startswith("okx "): + text = text[4:].strip() + return text or "下单失败" + return "下单失败" + + +def td_mode_for_option_buy(configured: str | None = None) -> str: + """OKX 买入期权(多头)必须使用逐仓.""" + mode = (configured or "isolated").strip().lower() + return "isolated" if mode == "cross" else mode or "isolated" + + +def create_options_exchange( + api_key: str, + api_secret: str, + passphrase: str, + proxies: dict[str, str] | None = None, +) -> ccxt.okx: + ex = ccxt.okx( + { + "apiKey": api_key, + "secret": api_secret, + "password": passphrase, + "enableRateLimit": True, + "options": {"defaultType": "option"}, + } + ) + if proxies: + ex.proxies = proxies + return ex + + +def _safe_float(v: Any) -> float | None: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def round_option_px(px: float, tick_sz: Any, side: str) -> float: + """按 OKX tickSz 对齐:买入向上取整,卖出向下取整.""" + tick = _safe_float(tick_sz) + if tick is None or tick <= 0 or px <= 0: + return px + steps = px / tick + side_l = (side or "").lower() + if side_l == "buy": + return math.ceil(steps - 1e-12) * tick + return math.floor(steps + 1e-12) * tick + + +def format_option_px(px: float, tick_sz: Any) -> str: + tick = _safe_float(tick_sz) + if tick is None or tick <= 0: + # 无 tick 时裁到 4 位并去尾零,避免 482.4881990066513 这类浮点毛刺 + s = f"{float(px):.4f}".rstrip("0").rstrip(".") + return s or "0" + if tick < 1: + decimals = max(0, -int(round(math.log10(tick)))) + return f"{px:.{decimals}f}".rstrip("0").rstrip(".") or "0" + # tick>=1(如 BTC 期权 tickSz=5):只按整数展示,禁止 rstrip('0') 把 1370 变成 137 + if "." in str(tick): + decimals = len(str(tick).split(".")[-1]) + return f"{px:.{decimals}f}".rstrip("0").rstrip(".") or "0" + return str(int(round(float(px)))) + + +def format_usdc_amount(v: float | None) -> str | None: + """USDC 金额展示(权利金/回收等,固定 2 位小数).""" + if v is None: + return None + return f"{float(v):.2f}" + + +def is_option_full_close_history(raw: dict[str, Any]) -> bool: + """仅保留 OKX 历史仓位中的「全部平仓/强平/ADL 全平」记录,排除部分平仓.""" + close_type = str(raw.get("type") or "").strip() + return close_type in ("2", "3", "6") + + +def option_history_row_key( + *, + source: str, + inst_id: str = "", + pos_id: str | None = None, + close_ms: int | None = None, +) -> str: + inst_id = (inst_id or "").strip() + pos_id = (pos_id or "").strip() + if source == "live": + return f"live:{inst_id}:{pos_id or close_ms or '0'}" + if pos_id: + return f"ex:{pos_id}" + return f"ex:{inst_id}:{close_ms or 0}" + + +def _ms_to_iso(ms: Any) -> str | None: + val = _safe_float(ms) + if val is None or val <= 0: + return None + try: + from datetime import datetime, timezone + + dt = datetime.fromtimestamp(int(val) / 1000.0, tz=timezone.utc).astimezone() + return dt.strftime("%Y-%m-%d %H:%M:%S") + except (TypeError, ValueError, OSError): + return None + + +def option_instrument_meta_cached( + ex: ccxt.okx, + inst_id: str, + cache: dict[str, dict[str, Any] | None] | None = None, +) -> dict[str, Any] | None: + inst_id = (inst_id or "").strip() + if not inst_id: + return None + if cache is not None and inst_id in cache: + return cache[inst_id] + meta = fetch_option_instrument_meta(ex, inst_id) + if cache is not None: + cache[inst_id] = meta + return meta + + +def tick_sz_and_ct_mult( + ex: ccxt.okx, + inst_id: str, + cache: dict[str, dict[str, Any] | None] | None = None, +) -> tuple[Any, float]: + meta = option_instrument_meta_cached(ex, inst_id, cache) + tick_sz = meta.get("tickSz") if meta else None + ct_mult = _safe_float(meta.get("ctMult")) if meta else None + return tick_sz, ct_mult or 0.01 + + +def _intrinsic_px_per_unit(opt_type: str, strike: float, index_px: float) -> float | None: + o = (opt_type or "").upper() + if o == "C" and index_px > strike: + return float(index_px) - float(strike) + if o == "P" and index_px < strike: + return float(strike) - float(index_px) + return None + + +def _resolve_chain_quote( + *, + ticker: dict[str, Any], + meta: dict[str, Any], + opt_type: str, + strike: float, + index_px: float, +) -> dict[str, Any]: + """链列表报价:卖一缺失时用标记价/内在价值估算(深度实值常见无卖一).""" + tick_sz = meta.get("tickSz") + ask = _safe_float(ticker.get("askPx")) + bid = _safe_float(ticker.get("bidPx")) + mark = _safe_float(ticker.get("markPx")) + ask_sz = _safe_float(ticker.get("askSz")) + bid_sz = _safe_float(ticker.get("bidSz")) + ask_estimated = False + + if ask is None and mark is not None and mark > 0: + ask = round_option_px(mark, tick_sz, "buy") + ask_estimated = True + if ask is None: + intrinsic = _intrinsic_px_per_unit(opt_type, strike, index_px) + if intrinsic is not None and intrinsic > 0: + ask = round_option_px(intrinsic, tick_sz, "buy") + ask_estimated = True + + if bid is None and mark is not None and mark > 0: + bid = round_option_px(mark, tick_sz, "sell") + if bid is None: + intrinsic = _intrinsic_px_per_unit(opt_type, strike, index_px) + if intrinsic is not None and intrinsic > 0: + bid = round_option_px(intrinsic, tick_sz, "sell") + + if ask_estimated: + ask_sz = None + + return { + "ask": ask, + "bid": bid, + "ask_sz": ask_sz, + "bid_sz": bid_sz, + "mark_px": mark, + "ask_estimated": ask_estimated, + } + + +def _fetch_book_bid_ask(ex: ccxt.okx, inst_id: str) -> tuple[float | None, float | None]: + bid, ask, _, _ = _fetch_book_top(ex, inst_id) + return bid, ask + + +def _normalize_book_levels(rows: list[Any], depth: int) -> list[dict[str, float]]: + levels: list[dict[str, float]] = [] + for row in rows[: max(0, int(depth))]: + if not isinstance(row, (list, tuple)) or len(row) < 2: + continue + px = _safe_float(row[0]) + sz = _safe_float(row[1]) + if px is None or sz is None or px <= 0 or sz <= 0: + continue + levels.append({"px": px, "sz": sz}) + return levels + + +def fetch_option_book_depth(ex: ccxt.okx, inst_id: str, depth: int = 5) -> dict[str, list[dict[str, float]]]: + """获取期权盘口深度,sz 为 OKX 返回的张数口径.""" + inst_id = (inst_id or "").strip() + if not inst_id: + return {"bids": [], "asks": []} + try: + sz = str(max(1, min(int(depth), 10))) + rows = ex.public_get_market_books({"instId": inst_id, "sz": sz}).get("data") or [] + if not rows: + return {"bids": [], "asks": []} + row = rows[0] + return { + "bids": _normalize_book_levels(row.get("bids") or [], int(depth)), + "asks": _normalize_book_levels(row.get("asks") or [], int(depth)), + } + except Exception: + return {"bids": [], "asks": []} + + +def _fetch_book_top( + ex: ccxt.okx, inst_id: str +) -> tuple[float | None, float | None, float | None, float | None]: + try: + rows = ex.public_get_market_books({"instId": inst_id, "sz": "1"}).get("data") or [] + if not rows: + return None, None, None, None + row = rows[0] + asks = row.get("asks") or [] + bids = row.get("bids") or [] + ask = _safe_float(asks[0][0]) if asks else None + bid = _safe_float(bids[0][0]) if bids else None + ask_sz = _safe_float(asks[0][1]) if asks and len(asks[0]) > 1 else None + bid_sz = _safe_float(bids[0][1]) if bids and len(bids[0]) > 1 else None + return bid, ask, bid_sz, ask_sz + except Exception: + return None, None, None, None + + +def _pos_side_from_position(pos: dict[str, Any] | None) -> str | None: + if not pos: + return None + ps = str(pos.get("posSide") or "").strip().lower() + if ps in ("long", "short", "net"): + return ps + sheets = _safe_float(pos.get("pos")) or 0.0 + if sheets > 0: + return "long" + if sheets < 0: + return "short" + return "net" + + +def inst_family_from_inst_id(inst_id: str) -> str | None: + """从 instId 解析 instFamily,如 ETH-USD_UM-260707-1790-C → ETH-USD_UM.""" + parts = (inst_id or "").strip().split("-") + if len(parts) < 4: + return None + return "-".join(parts[:-3]) + + +def option_fields_from_inst_id(inst_id: str) -> tuple[str | None, float | None]: + """从 instId 解析 optType 与 strike,如 ETH-USD_UM-260709-1700-P.""" + parts = (inst_id or "").strip().split("-") + if len(parts) < 2: + return None, None + tail = parts[-1].upper() + opt_type = tail if tail in ("C", "P") else None + strike = _safe_float(parts[-2]) if len(parts) >= 2 else None + return opt_type, strike + + +def expiry_ms_from_inst_id(inst_id: str) -> int | None: + """从 instId 日期段解析到期时刻(OKX 期权默认 08:00 UTC).""" + parts = (inst_id or "").strip().split("-") + if len(parts) < 3: + return None + date_part = parts[-3] + if not re.fullmatch(r"\d{6}", date_part): + return None + try: + from datetime import datetime, timezone + + yy, mm, dd = int(date_part[0:2]), int(date_part[2:4]), int(date_part[4:6]) + dt = datetime(2000 + yy, mm, dd, 8, 0, 0, tzinfo=timezone.utc) + return int(dt.timestamp() * 1000) + except (ValueError, OSError): + return None + + +def normalize_option_exp_ms(exp_time: Any, inst_id: str = "") -> int | None: + """统一期权到期毫秒时间戳(优先 API expTime,否则从 instId 推算).""" + raw = _safe_float(exp_time) + if raw is not None and raw > 0: + ms = int(raw) + if ms < 10_000_000_000: + ms *= 1000 + return ms + return expiry_ms_from_inst_id(inst_id) + + +def _is_okx_rate_limit(err: BaseException) -> bool: + text = str(err) or "" + name = err.__class__.__name__ + return "50011" in text or "Too Many Requests" in text or "RateLimit" in name + + +def _meta_from_inst_id_fallback(inst_id: str) -> dict[str, Any]: + """行情在但 instruments 限频时,用合约 ID 拼最小 meta,避免误报「合约不存在」.""" + family = inst_family_from_inst_id(inst_id) or "" + opt_type, strike = option_fields_from_inst_id(inst_id) + uly = family.replace("_UM", "") if family else "" + return { + "instId": inst_id, + "instFamily": family, + "uly": uly, + "optType": opt_type, + "stk": strike, + "ctMult": 0.01, + "minSz": "1", + "tickSz": "0.0001", + "state": "live", + } + + +def fetch_option_instrument_meta(ex: ccxt.okx, inst_id: str) -> dict[str, Any] | None: + family = inst_family_from_inst_id(inst_id) + if not family: + return None + last_err: BaseException | None = None + for attempt in range(3): + try: + rows = ex.public_get_public_instruments( + {"instType": "OPTION", "instFamily": family, "instId": inst_id} + ).get("data") or [] + if rows and isinstance(rows[0], dict): + return rows[0] + rows = ex.public_get_public_instruments( + {"instType": "OPTION", "instFamily": family} + ).get("data") or [] + for r in rows: + if isinstance(r, dict) and str(r.get("instId")) == inst_id: + return r + return None + except Exception as e: + last_err = e + if _is_okx_rate_limit(e) and attempt < 2: + time.sleep(0.45 * (attempt + 1)) + continue + break + if last_err is not None and _is_okx_rate_limit(last_err): + try: + t_rows = ex.public_get_market_ticker({"instId": inst_id}).get("data") or [] + if t_rows: + return _meta_from_inst_id_fallback(inst_id) + except Exception: + pass + return None + + +def _extract_ccy_free(balance: dict[str, Any], ccy: str) -> float | None: + ccy = (ccy or "").upper() + if not isinstance(balance, dict): + return None + info = balance.get(ccy) + if isinstance(info, dict): + v = _safe_float(info.get("free")) + if v is not None: + return v + free_map = balance.get("free") or {} + if isinstance(free_map, dict): + return _safe_float(free_map.get(ccy)) + return None + + +def _extract_ccy_balance(balance: dict[str, Any], ccy: str) -> float | None: + ccy = (ccy or "").upper() + if not isinstance(balance, dict): + return None + info = balance.get(ccy) + if isinstance(info, dict): + for k in ("free", "total", "eq"): + v = _safe_float(info.get(k)) + if v is not None: + return v + total_map = balance.get("total") or {} + if isinstance(total_map, dict): + v = _safe_float(total_map.get(ccy)) + if v is not None: + return v + free_map = balance.get("free") or {} + if isinstance(free_map, dict): + v = _safe_float(free_map.get(ccy)) + if v is not None: + return v + return None + + +def fetch_account_balances_by_type( + ex: ccxt.okx, + account_type: str, +) -> tuple[dict[str, float | None], dict[str, float | None]]: + out: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None} + avail: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None} + try: + bal = ex.fetch_balance(params={"type": account_type}) + for c in out: + out[c] = _extract_ccy_balance(bal, c) + avail[c] = _extract_ccy_free(bal, c) + except Exception: + pass + return out, avail + + +def fetch_funding_balances_via_asset_api( + ex: ccxt.okx, +) -> tuple[dict[str, float | None], dict[str, float | None]]: + """OKX 资金账户余额(GET /api/v5/asset/balances),比 ccxt fetch_balance 更准确.""" + out: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None} + avail: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None} + try: + resp = ex.private_get_asset_balances({}) + for row in (resp or {}).get("data") or []: + if not isinstance(row, dict): + continue + ccy = str(row.get("ccy") or "").upper() + if ccy not in out: + continue + a = _safe_float(row.get("availBal")) + b = _safe_float(row.get("bal")) or _safe_float(row.get("eq")) + avail[ccy] = a + out[ccy] = b if b is not None else a + except Exception: + pass + return out, avail + + +def _merge_balance_maps( + primary: dict[str, float | None], + secondary: dict[str, float | None], +) -> dict[str, float | None]: + merged = dict(primary) + for ccy, val in secondary.items(): + if merged.get(ccy) is None and val is not None: + merged[ccy] = val + return merged + + +def fetch_subaccount_asset_balances(ex: ccxt.okx, sub_acct: str) -> dict[str, float | None]: + """子账户各币种可用余额(主/子划转「全部」用).""" + sub = (sub_acct or "").strip() + out: dict[str, float | None] = {"USDT": None, "USDC": None} + if not sub: + return out + try: + resp = ex.private_get_asset_subaccount_balances({"subAcct": sub}) + for row in (resp or {}).get("data") or []: + if not isinstance(row, dict): + continue + ccy = str(row.get("ccy") or "").upper() + if ccy not in out: + continue + out[ccy] = _safe_float(row.get("availBal")) or _safe_float(row.get("bal")) + except Exception: + pass + return out + + +def fetch_options_balances( + ex: ccxt.okx, + *, + force: bool = False, + scope: str = "main", + sub_acct: str = "", +) -> dict[str, Any]: + import os + + if (scope or "").strip().lower() == "sub": + sub_bal = fetch_subaccount_asset_balances(ex, sub_acct) + return { + "scope": "sub", + "funding_usdt": sub_bal.get("USDT"), + "funding_usdc": sub_bal.get("USDC"), + "funding_usdt_avail": sub_bal.get("USDT"), + "funding_usdc_avail": sub_bal.get("USDC"), + "trading_usdt": sub_bal.get("USDT"), + "trading_usdc": sub_bal.get("USDC"), + "trading_usdt_avail": sub_bal.get("USDT"), + "trading_usdc_avail": sub_bal.get("USDC"), + } + + ttl = float(os.getenv("OKX_OPTIONS_BALANCE_REFRESH_SEC", "30")) + now = time.time() + cached = _OPTIONS_BALANCE_CACHE.get("data") + if not force and cached is not None and now - float(_OPTIONS_BALANCE_CACHE.get("updated_at") or 0) < ttl: + return dict(cached) + + funding, funding_avail = fetch_account_balances_by_type(ex, "funding") + asset_funding, asset_funding_avail = fetch_funding_balances_via_asset_api(ex) + funding = _merge_balance_maps(funding, asset_funding) + funding_avail = _merge_balance_maps(funding_avail, asset_funding_avail) + trading, trading_avail = fetch_account_balances_by_type(ex, "trading") + if trading.get("USDC") is None: + swap_bal, swap_avail = fetch_account_balances_by_type(ex, "swap") + if swap_bal.get("USDC") is not None: + trading["USDC"] = swap_bal["USDC"] + if trading_avail.get("USDC") is None and swap_avail.get("USDC") is not None: + trading_avail["USDC"] = swap_avail["USDC"] + result = { + "scope": "main", + "funding_usdt": funding.get("USDT"), + "funding_usdc": funding.get("USDC"), + "funding_usdg": funding.get("USDG"), + "funding_usdt_avail": funding_avail.get("USDT"), + "funding_usdc_avail": funding_avail.get("USDC"), + "trading_usdt": trading.get("USDT"), + "trading_usdc": trading.get("USDC"), + "trading_usdg": trading.get("USDG"), + "trading_usdt_avail": trading_avail.get("USDT"), + "trading_usdc_avail": trading_avail.get("USDC"), + } + _OPTIONS_BALANCE_CACHE["updated_at"] = now + _OPTIONS_BALANCE_CACHE["data"] = result + return result + + +def options_header_balances( + ex: ccxt.okx, + *, + force: bool = False, +) -> tuple[float | None, float | None, float | None, float | None]: + """顶栏四格:交易 USDC/USDT,资金 USDC/USDT(单次拉取 + 缓存).""" + bal = fetch_options_balances(ex, force=force) + + def _round(v: Any) -> float | None: + if v is None: + return None + try: + return round(float(v), 2) + except (TypeError, ValueError): + return None + + return ( + _round(bal.get("trading_usdc")), + _round(bal.get("funding_usdc")), + _round(bal.get("funding_usdt")), + _round(bal.get("trading_usdt")), + ) + + +def fetch_index_price(ex: ccxt.okx, uly: str) -> float | None: + inst = f"{uly}" if "-" in uly else f"{uly}-USD" + try: + rows = ex.public_get_market_index_tickers({"instId": inst}).get("data") or [] + if rows: + return _safe_float(rows[0].get("idxPx")) + except Exception: + pass + return None + + +def fetch_option_instruments( + ex: ccxt.okx, + inst_family: str, +) -> list[dict[str, Any]]: + rows = ex.public_get_public_instruments( + {"instType": "OPTION", "instFamily": inst_family} + ).get("data") or [] + return [r for r in rows if isinstance(r, dict) and r.get("state") == "live"] + + +def fetch_option_tickers(ex: ccxt.okx, inst_family: str) -> dict[str, dict[str, Any]]: + out: dict[str, dict[str, Any]] = {} + try: + rows = ex.public_get_market_tickers( + {"instType": "OPTION", "instFamily": inst_family} + ).get("data") or [] + for r in rows: + if isinstance(r, dict) and r.get("instId"): + out[str(r["instId"])] = r + except Exception: + pass + return out + + +def build_option_chain( + ex: ccxt.okx, + underlying: str, + *, + max_dte_days: float = 2.0, + itm_only: bool = True, + itm_max_dist_usd: float = 30.0, + index_px: float | None = None, +) -> dict[str, Any]: + u = (underlying or "ETH").upper() + family = f"{u}-USD_UM" + uly = f"{u}-USD" + idx = index_px if index_px is not None else fetch_index_price(ex, uly) + now_ms = time.time() * 1000 + max_ms = now_ms + max_dte_days * 86400 * 1000 + instruments_err = "" + instruments: list[dict[str, Any]] = [] + for attempt in range(2): + try: + instruments = fetch_option_instruments(ex, family) + instruments_err = "" + if instruments: + break + instruments_err = "期权合约列表为空" + except Exception as e: + instruments = [] + instruments_err = str(e) or e.__class__.__name__ + if attempt == 0: + time.sleep(0.35) + continue + break + if attempt == 0 and not instruments: + time.sleep(0.35) + tickers = fetch_option_tickers(ex, family) + expiries: dict[str, list[dict[str, Any]]] = {} + skipped_no_index = 0 + for meta in instruments: + try: + exp_ms = int(meta.get("expTime") or 0) + except (TypeError, ValueError): + continue + if exp_ms <= now_ms or exp_ms > max_ms: + continue + opt_type = str(meta.get("optType") or "") + strike = _safe_float(meta.get("stk")) + if strike is None: + continue + if idx is None: + skipped_no_index += 1 + continue + if itm_only and not is_shallow_itm( + opt_type=opt_type, + strike=strike, + index_px=idx, + max_dist_usd=itm_max_dist_usd, + ): + continue + inst_id = str(meta.get("instId") or "") + t = tickers.get(inst_id) or {} + q = _resolve_chain_quote( + ticker=t, + meta=meta, + opt_type=opt_type, + strike=strike, + index_px=idx, + ) + ask = q["ask"] + bid = q["bid"] + mark = q["mark_px"] + ask_sz = q["ask_sz"] + bid_sz = q["bid_sz"] + expiry_be = expiry_breakeven_from_ask( + opt_type=opt_type, + strike=strike, + ask_px=ask, + mark_px=mark, + ) + mny = option_moneyness(opt_type=opt_type, strike=strike, index_px=idx) + exp_key = str(exp_ms) + expiries.setdefault(exp_key, []).append( + { + "inst_id": inst_id, + "strike": strike, + "opt_type": opt_type, + "exp_time": exp_ms, + "ask": ask, + "bid": bid, + "ask_sz": ask_sz, + "bid_sz": bid_sz, + "mark_px": mark, + "ask_estimated": q["ask_estimated"], + "expiry_be_px": expiry_be, + "dist_expiry_be": idx_distance_to_be(idx, expiry_be), + "moneyness": mny, + "moneyness_label": option_moneyness_label(mny), + "ct_mult": _safe_float(meta.get("ctMult")) or 0.01, + "tick_sz": meta.get("tickSz"), + "min_sz": int(_safe_float(meta.get("minSz")) or 1), + } + ) + exp_list = [] + for exp_ms_str, contracts in sorted(expiries.items(), key=lambda x: int(x[0])): + contracts.sort(key=lambda c: (c["opt_type"], c["strike"])) + exp_list.append({"exp_time": int(exp_ms_str), "contracts": contracts}) + out: dict[str, Any] = { + "underlying": u, + "index_px": idx, + "inst_family": family, + "expiries": exp_list, + "instruments_count": len(instruments), + } + if not exp_list: + if instruments_err: + out["chain_error"] = f"拉取期权合约失败: {instruments_err}" + elif idx is None: + out["chain_error"] = "指数价获取失败,无法构建期权链" + elif skipped_no_index: + out["chain_error"] = "指数价缺失,合约已跳过" + elif instruments: + out["chain_error"] = f"近 {max_dte_days:g} 日内无可用到期(已过滤 {len(instruments)} 个合约)" + else: + out["chain_error"] = "期权合约列表为空,请稍后刷新" + return out + + +def option_buy_liquidity_ok(ask: Any, ask_sz: Any) -> tuple[bool, str]: + """开仓仅认真实卖一价+卖一深度;不接受标记价/内在价值顶包.""" + a = _safe_float(ask) + s = _safe_float(ask_sz) + if a is None or a <= 0: + return False, "暂无卖一价,无法买入" + if s is None or s <= 0: + return False, "暂无卖一深度,无法买入" + return True, "" + + +def cap_option_buy_sheets_to_ask_depth( + sheets: int, + ask_sz: Any, + *, + min_sz: int = 1, +) -> tuple[int | None, str]: + """将买入张数限制在卖一深度内(向下取整).""" + depth = _safe_float(ask_sz) + if depth is None or depth <= 0: + return None, "暂无卖一深度,无法买入" + max_sheets = int(math.floor(depth + 1e-12)) + need = max(1, int(min_sz or 1)) + if max_sheets < need: + return None, f"卖一深度不足 {need} 张(当前 {depth:g})" + want = max(0, int(sheets)) + capped = min(want, max_sheets) + if capped < need: + return None, f"卖一深度不足 {need} 张(当前 {depth:g})" + return capped, "" + + +def quote_option_contract(ex: ccxt.okx, inst_id: str) -> dict[str, Any]: + inst_id = (inst_id or "").strip() + if not inst_id: + return {"ok": False, "msg": "缺少 inst_id"} + try: + meta = fetch_option_instrument_meta(ex, inst_id) + t_rows: list[Any] = [] + ticker_err: BaseException | None = None + for attempt in range(3): + try: + t_rows = ex.public_get_market_ticker({"instId": inst_id}).get("data") or [] + ticker_err = None + break + except Exception as e: + ticker_err = e + if _is_okx_rate_limit(e) and attempt < 2: + time.sleep(0.45 * (attempt + 1)) + continue + break + if not meta and t_rows: + meta = _meta_from_inst_id_fallback(inst_id) + if not meta: + if ticker_err is not None and _is_okx_rate_limit(ticker_err): + return {"ok": False, "msg": "行情限频,请稍后重试"} + return {"ok": False, "msg": "合约不存在"} + t = t_rows[0] if t_rows else {} + # 开仓用真实盘口卖一;绝不把标记价写入 ask + ask = _safe_float(t.get("askPx")) + bid = _safe_float(t.get("bidPx")) + ask_sz = _safe_float(t.get("askSz")) + bid_sz = _safe_float(t.get("bidSz")) + if ask is None or bid is None or ask_sz is None or bid_sz is None: + book_bid, book_ask, book_bid_sz, book_ask_sz = _fetch_book_top(ex, inst_id) + if ask is None: + ask = book_ask + if bid is None: + bid = book_bid + if ask_sz is None: + ask_sz = book_ask_sz + if bid_sz is None: + bid_sz = book_bid_sz + mark = _safe_float(t.get("markPx")) + tick_sz = meta.get("tickSz") + # 买一缺失时仍可用标记价补展示(平仓路径读 bid);开仓 ask 不顶包 + if bid is None and mark is not None: + bid = round_option_px(mark, tick_sz, "sell") + ref_ask = None + if ask is None and mark is not None and mark > 0: + ref_ask = round_option_px(mark, tick_sz, "buy") + can_open, open_block_msg = option_buy_liquidity_ok(ask, ask_sz) + book_ask = ask + book_ask_sz = ask_sz + uly = str(meta.get("uly") or "") + idx = fetch_index_price(ex, uly) + opt_type = meta.get("optType") + strike = _safe_float(meta.get("stk")) + expiry_be = expiry_breakeven_from_ask( + opt_type=str(opt_type or ""), + strike=strike, + ask_px=book_ask if can_open else None, + mark_px=mark, + ) + return { + "ok": True, + "inst_id": inst_id, + "meta": meta, + "ask": book_ask if can_open else None, + "bid": bid, + "ask_sz": book_ask_sz if can_open else None, + "bid_sz": bid_sz, + "mark": mark, + "ref_ask": ref_ask, + "book_ask": book_ask, + "book_ask_sz": book_ask_sz, + "can_open": can_open, + "ask_source": "book" if can_open else "none", + "open_block_msg": "" if can_open else open_block_msg, + "index_px": idx, + "expiry_be_px": expiry_be, + "dist_expiry_be": idx_distance_to_be(idx, expiry_be), + "ct_mult": _safe_float(meta.get("ctMult")) or 0.01, + "min_sz": int(_safe_float(meta.get("minSz")) or 1), + "tick_sz": tick_sz, + "strike": strike, + "opt_type": opt_type, + "exp_time": meta.get("expTime"), + } + except Exception as e: + return {"ok": False, "msg": str(e)} + + +def fetch_option_pending_orders(ex: ccxt.okx, inst_id: str | None = None) -> list[dict[str, Any]]: + """未成交期权委托(限价挂单).""" + params: dict[str, Any] = {"instType": "OPTION"} + inst = (inst_id or "").strip() + if inst: + params["instId"] = inst + try: + rows = ex.private_get_trade_orders_pending(params).get("data") or [] + except Exception: + return [] + out: list[dict[str, Any]] = [] + for o in rows: + if not isinstance(o, dict): + continue + oid = str(o.get("ordId") or "").strip() + iid = str(o.get("instId") or "").strip() + if not oid or not iid: + continue + side = str(o.get("side") or "").lower() + px = _safe_float(o.get("px")) + sz = _safe_float(o.get("sz")) + fill_sz = _safe_float(o.get("fillSz")) or 0.0 + acc_fill = _safe_float(o.get("accFillSz")) + if acc_fill is not None: + fill_sz = acc_fill + out.append( + { + "ord_id": oid, + "inst_id": iid, + "side": side, + "side_label": "买入" if side == "buy" else ("卖出" if side == "sell" else side or "—"), + "px": px, + "sz": int(sz) if sz is not None else None, + "fill_sz": int(fill_sz) if fill_sz is not None else 0, + "state": str(o.get("state") or ""), + "ord_type": str(o.get("ordType") or ""), + "c_time": o.get("cTime"), + "u_time": o.get("uTime"), + "reduce_only": str(o.get("reduceOnly") or "").lower() in ("true", "1", "yes"), + } + ) + out.sort(key=lambda x: int(float(x.get("c_time") or 0)), reverse=True) + return out + + +def cancel_option_order(ex: ccxt.okx, *, inst_id: str, ord_id: str) -> dict[str, Any]: + inst_id = (inst_id or "").strip() + ord_id = (ord_id or "").strip() + if not inst_id or not ord_id: + return {"ok": False, "msg": "缺少 inst_id 或 ord_id"} + try: + resp = ex.private_post_trade_cancel_order({"instId": inst_id, "ordId": ord_id}) + data = (resp or {}).get("data") or [] + if data and str(data[0].get("sCode")) == "0": + return {"ok": True, "data": data[0], "raw": resp} + return {"ok": False, "msg": _okx_trade_error_message(resp=resp), "raw": resp} + except Exception as e: + return {"ok": False, "msg": _okx_trade_error_message(e)} + + +def place_option_limit_order( + ex: ccxt.okx, + *, + inst_id: str, + side: str, + sheets: int, + price: float, + td_mode: str = "isolated", + tick_sz: Any = None, + reduce_only: bool = False, + pos_side: str | None = None, +) -> dict[str, Any]: + side_l = (side or "").lower() + if side_l not in ("buy", "sell"): + return {"ok": False, "msg": "side 必须为 buy 或 sell"} + if sheets < 1: + return {"ok": False, "msg": "张数至少为 1"} + px = round_option_px(float(price), tick_sz, side_l) + if px <= 0: + return {"ok": False, "msg": "价格无效"} + body: dict[str, Any] = { + "instId": inst_id, + "tdMode": td_mode, + "side": side_l, + "ordType": "limit", + "px": format_option_px(px, tick_sz), + "sz": str(int(sheets)), + } + if pos_side: + body["posSide"] = pos_side + if reduce_only: + body["reduceOnly"] = "true" + try: + resp = ex.private_post_trade_order(body) + data = (resp or {}).get("data") or [] + if data and str(data[0].get("sCode")) == "0": + return {"ok": True, "data": data[0], "raw": resp, "px": px} + return {"ok": False, "msg": _okx_trade_error_message(resp=resp), "raw": resp, "px": px} + except Exception as e: + return {"ok": False, "msg": _okx_trade_error_message(e), "px": px} + + +def place_option_market_order( + ex: ccxt.okx, + *, + inst_id: str, + side: str, + sheets: int, + td_mode: str = "isolated", + reduce_only: bool = False, + pos_side: str | None = None, +) -> dict[str, Any]: + side_l = (side or "").lower() + if side_l not in ("buy", "sell"): + return {"ok": False, "msg": "side 必须为 buy 或 sell"} + if sheets < 1: + return {"ok": False, "msg": "张数至少为 1"} + body: dict[str, Any] = { + "instId": inst_id, + "tdMode": td_mode, + "side": side_l, + "ordType": "market", + "sz": str(int(sheets)), + } + if pos_side: + body["posSide"] = pos_side + if reduce_only: + body["reduceOnly"] = "true" + try: + resp = ex.private_post_trade_order(body) + data = (resp or {}).get("data") or [] + if data and str(data[0].get("sCode")) == "0": + return {"ok": True, "data": data[0], "raw": resp} + return {"ok": False, "msg": _okx_trade_error_message(resp=resp), "raw": resp} + except Exception as e: + return {"ok": False, "msg": _okx_trade_error_message(e)} + + +_OPTION_POSITIONS_CACHE: dict[str, Any] = {"updated_at": 0.0, "rows": None, "failed": False} +_OPTION_POSITIONS_CACHE_LOCK = threading.Lock() +_OPTION_POSITIONS_CACHE_TTL = 4.0 +_OPTION_POSITIONS_STALE_OK_SEC = 30.0 + + +def invalidate_option_positions_cache() -> None: + with _OPTION_POSITIONS_CACHE_LOCK: + _OPTION_POSITIONS_CACHE["updated_at"] = 0.0 + _OPTION_POSITIONS_CACHE["failed"] = False + + +def fetch_option_positions(ex: ccxt.okx) -> list[dict[str, Any]] | None: + """期权持仓:有仓返回列表,无仓返回 [],API 失败返回 None(短时回退缓存).""" + now = time.time() + with _OPTION_POSITIONS_CACHE_LOCK: + age = now - float(_OPTION_POSITIONS_CACHE["updated_at"] or 0.0) + cached = _OPTION_POSITIONS_CACHE["rows"] + if age < _OPTION_POSITIONS_CACHE_TTL and cached is not None and not _OPTION_POSITIONS_CACHE["failed"]: + return list(cached) + try: + rows = ex.private_get_account_positions({"instType": "OPTION"}).get("data") or [] + out = [] + for r in rows: + if not isinstance(r, dict): + continue + pos = _safe_float(r.get("pos")) + if pos is None or abs(pos) < 1e-12: + continue + out.append(r) + with _OPTION_POSITIONS_CACHE_LOCK: + _OPTION_POSITIONS_CACHE["updated_at"] = now + _OPTION_POSITIONS_CACHE["rows"] = out + _OPTION_POSITIONS_CACHE["failed"] = False + return out + except Exception: + with _OPTION_POSITIONS_CACHE_LOCK: + cached = _OPTION_POSITIONS_CACHE["rows"] + age = now - float(_OPTION_POSITIONS_CACHE["updated_at"] or 0.0) + if cached is not None and age < _OPTION_POSITIONS_STALE_OK_SEC: + return list(cached) + _OPTION_POSITIONS_CACHE["updated_at"] = now + _OPTION_POSITIONS_CACHE["rows"] = None + _OPTION_POSITIONS_CACHE["failed"] = True + return None + + +def fetch_option_position_history( + ex: ccxt.okx, + inst_id: str, + *, + limit: int = 20, +) -> list[dict[str, Any]]: + """OKX 期权历史仓位(含到期结算/平仓).""" + inst_id = (inst_id or "").strip() + if not inst_id: + return [] + try: + resp = ex.private_get_account_positions_history( + { + "instType": "OPTION", + "instId": inst_id, + "limit": str(max(1, min(int(limit), 100))), + } + ) + rows = (resp or {}).get("data") or [] + return [r for r in rows if isinstance(r, dict)] + except Exception: + return [] + + +def fetch_all_option_positions_history( + ex: ccxt.okx, + *, + limit: int = 200, +) -> list[dict[str, Any]]: + """拉取 OKX 期权全部历史仓位(分页,按平仓时间倒序).""" + cap = max(1, min(int(limit), 500)) + out: list[dict[str, Any]] = [] + after: str | None = None + while len(out) < cap: + page_limit = min(100, cap - len(out)) + params: dict[str, Any] = { + "instType": "OPTION", + "limit": str(page_limit), + } + if after is not None: + params["after"] = after + try: + resp = ex.private_get_account_positions_history(params) + except Exception: + break + rows = (resp or {}).get("data") or [] + batch = [r for r in rows if isinstance(r, dict)] + if not batch: + break + out.extend(batch) + if len(batch) < page_limit: + break + utimes = [_safe_float(r.get("uTime")) for r in batch] + utimes = [int(u) for u in utimes if u is not None and u > 0] + if not utimes: + break + oldest = min(utimes) + if after is not None and str(oldest) == after: + break + after = str(oldest) + out = [r for r in out if is_option_full_close_history(r)] + out.sort(key=lambda r: int(_safe_float(r.get("uTime")) or 0), reverse=True) + return out[:cap] + + +def format_option_history_row( + raw: dict[str, Any], + *, + tick_sz: Any = None, + ct_mult: float = 0.01, +) -> dict[str, Any]: + """标准化 OKX positions-history 单条记录供前端展示.""" + from lib.options.options_pricing_lib import total_premium + + inst_id = str(raw.get("instId") or "").strip() + open_avg = _safe_float(raw.get("openAvgPx")) + close_avg = _safe_float(raw.get("closeAvgPx")) + sheets = _safe_float(raw.get("closeTotalPos")) + if sheets is None or sheets <= 0: + sheets = _safe_float(raw.get("openMaxPos")) + sheets_i = int(abs(sheets or 0)) + eth_amount = round(abs(sheets or 0) * ct_mult, 8) if sheets else 0.0 + premium_paid = ( + round(total_premium(open_avg, eth_amount), 8) + if open_avg is not None and eth_amount > 0 + else None + ) + realized = _safe_float(raw.get("realizedPnl")) + if realized is None: + realized = _safe_float(raw.get("pnl")) + pnl_ratio = _safe_float(raw.get("pnlRatio")) + close_type = str(raw.get("type") or "").strip() + utime = _safe_float(raw.get("uTime")) + ctime = _safe_float(raw.get("cTime")) + opt_type, strike = option_fields_from_inst_id(inst_id) + uly = str(raw.get("uly") or inst_id.split("-")[0] or "").replace("-USD_UM", "").replace("-USD", "") + if close_type in ("3", "4"): + status_label = "强平" + else: + status_label = "已平" + pos_id = str(raw.get("posId") or "").strip() or None + close_ms = int(utime) if utime is not None else None + return { + "source": "exchange", + "history_key": option_history_row_key( + source="exchange", + inst_id=inst_id, + pos_id=pos_id, + close_ms=close_ms, + ), + "pos_id": pos_id, + "inst_id": inst_id, + "underlying": uly, + "opt_type": opt_type, + "strike": strike, + "sheets": sheets_i, + "eth_amount": eth_amount, + "open_avg_px": open_avg, + "open_avg_px_fmt": format_option_px(open_avg, tick_sz) if open_avg is not None else None, + "close_avg_px": close_avg, + "close_avg_px_fmt": format_option_px(close_avg, tick_sz) if close_avg is not None else None, + "premium_paid": premium_paid, + "premium_paid_fmt": format_usdc_amount(premium_paid), + "realized_pnl": realized, + "pnl_ratio_pct": round(pnl_ratio * 100, 2) if pnl_ratio is not None else None, + "status": "closed", + "status_label": status_label, + "close_type": close_type, + "created_at": _ms_to_iso(ctime), + "closed_at": _ms_to_iso(utime), + "close_ms": close_ms, + "tick_sz": tick_sz, + "raw": raw, + } + + +def format_live_option_history_row( + row: dict[str, Any], + *, + open_ms: int | None = None, +) -> dict[str, Any]: + """将当前持仓格式化为历史列表中的「持仓中」行.""" + inst_id = str(row.get("inst_id") or "").strip() + pos_id = str((row.get("raw") or {}).get("posId") or "").strip() or None + close_ms = open_ms + return { + "source": "live", + "history_key": option_history_row_key( + source="live", + inst_id=inst_id, + pos_id=pos_id, + close_ms=close_ms, + ), + "pos_id": pos_id, + "inst_id": inst_id, + "underlying": str(row.get("underlying") or inst_id.split("-")[0] or ""), + "opt_type": row.get("opt_type"), + "strike": row.get("strike"), + "sheets": int(abs(_safe_float(row.get("pos")) or 0)), + "eth_amount": row.get("eth_amount"), + "open_avg_px": row.get("avg_px"), + "open_avg_px_fmt": row.get("avg_px_fmt"), + "close_avg_px": None, + "close_avg_px_fmt": None, + "premium_paid": row.get("premium_paid"), + "premium_paid_fmt": row.get("premium_paid_fmt"), + "realized_pnl": row.get("upl"), + "pnl_ratio_pct": row.get("upl_ratio_pct"), + "status": "open", + "status_label": "持仓中", + "close_type": None, + "created_at": _ms_to_iso(open_ms), + "closed_at": None, + "close_ms": open_ms, + "tick_sz": row.get("tick_sz"), + "raw": row.get("raw"), + } + + +def resolve_option_close_from_history( + hist_rows: list[dict[str, Any]], + *, + open_ms: int | None = None, +) -> dict[str, Any] | None: + """从 positions-history 中选取最近一条有效平仓/结算记录.""" + best: dict[str, Any] | None = None + best_utime = -1 + for row in hist_rows: + u_ms = _safe_float(row.get("uTime")) + if u_ms is None or u_ms <= 0: + continue + if open_ms is not None and u_ms < int(open_ms) - 60_000: + continue + if u_ms > best_utime: + best = row + best_utime = int(u_ms) + if not best: + return None + realized = _safe_float(best.get("realizedPnl")) + if realized is None: + realized = _safe_float(best.get("pnl")) + return { + "close_quote": _safe_float(best.get("closeAvgPx")), + "realized_pnl": realized, + "close_ms": best_utime, + "pos_id": str(best.get("posId") or "").strip() or None, + } + + +def fetch_options_unrealized_pnl_usdc(ex: ccxt.okx) -> float | None: + """ + 期权浮盈合计(USDC≈U). + 优先返回交易所标记价 upl;实例顶栏应改用 + `options_positions_lib.sum_options_net_pnl_usdc`(买一净盈亏)以与持仓卡一致. + """ + positions = fetch_option_positions(ex) + if positions is None: + return None + total = 0.0 + found = False + for pos in positions: + upl = _safe_float(pos.get("upl")) + if upl is None: + continue + found = True + total += upl + return round(total, 4) if found else None + + +def estimate_usdt_to_usdc(ex: ccxt.okx, usdt_amount: float) -> dict[str, Any]: + if usdt_amount <= 0: + return {"ok": False, "msg": "兑换数量须大于 0"} + try: + resp = ex.private_post_asset_convert_estimate_quote( + { + "baseCcy": "USDC", + "quoteCcy": "USDT", + "side": "buy", + "rfqSz": str(usdt_amount), + "rfqSzCcy": "USDT", + } + ) + data = (resp or {}).get("data") or [] + if not data: + return {"ok": False, "msg": "询价失败", "raw": resp} + row = data[0] + return { + "ok": True, + "quote_id": row.get("quoteId"), + "base_ccy": row.get("baseCcy"), + "quote_ccy": row.get("quoteCcy"), + "cnvt_px": _safe_float(row.get("cnvtPx")), + "base_sz": _safe_float(row.get("baseSz")), + "quote_sz": _safe_float(row.get("quoteSz")), + "rfq_sz": usdt_amount, + "raw": row, + } + except Exception as e: + return {"ok": False, "msg": str(e)} + + +def execute_convert(ex: ccxt.okx, quote_id: str) -> dict[str, Any]: + if not quote_id: + return {"ok": False, "msg": "缺少 quoteId"} + try: + resp = ex.private_post_asset_convert_trade({"quoteId": str(quote_id)}) + data = (resp or {}).get("data") or [] + if data and str(data[0].get("sCode", "0")) == "0": + return {"ok": True, "data": data[0], "raw": resp} + return {"ok": False, "msg": _okx_trade_error_message(resp=resp), "raw": resp} + except Exception as e: + return {"ok": False, "msg": _okx_trade_error_message(e)} + + +def transfer_ccy( + ex: ccxt.okx, + ccy: str, + amount: float, + from_account: str, + to_account: str, +) -> dict[str, Any]: + if amount <= 0: + return {"ok": False, "msg": "划转金额须大于 0"} + try: + resp = ex.transfer(str(ccy).upper(), float(amount), from_account, to_account) + return {"ok": True, "data": resp} + except Exception as e: + return {"ok": False, "msg": _okx_trade_error_message(e)} + + +_OKX_ACCT_CODE = {"funding": "6", "trading": "18", "spot": "18"} + + +def fetch_options_trading_usdc(ex: ccxt.okx, *, force: bool = False) -> float | None: + bal = fetch_options_balances(ex, force=force) + v = bal.get("trading_usdc") + if v is None: + return None + return round(float(v), 2) + + +def fetch_options_funding_usdc(ex: ccxt.okx, *, force: bool = False) -> float | None: + bal = fetch_options_balances(ex, force=force) + v = bal.get("funding_usdc") + if v is None: + return None + return round(float(v), 2) + + +def fetch_options_funding_usdt(ex: ccxt.okx, *, force: bool = False) -> float | None: + bal = fetch_options_balances(ex, force=force) + v = bal.get("funding_usdt") + if v is None: + return None + return round(float(v), 2) + + +def spot_market_swap_usdt_usdc( + ex: ccxt.okx, + *, + direction: str, + amount: float, +) -> dict[str, Any]: + """现货市价兑换 USDC-USDT.direction: usdt_to_usdc | usdc_to_usdt.""" + if amount <= 0: + return {"ok": False, "msg": "数量须大于 0"} + d = (direction or "").lower() + inst_id = "USDC-USDT" + try: + if d == "usdt_to_usdc": + body = { + "instId": inst_id, + "tdMode": "cash", + "side": "buy", + "ordType": "market", + "sz": str(amount), + "tgtCcy": "quote_ccy", + } + elif d == "usdc_to_usdt": + body = { + "instId": inst_id, + "tdMode": "cash", + "side": "sell", + "ordType": "market", + "sz": str(amount), + "tgtCcy": "base_ccy", + } + else: + return {"ok": False, "msg": "direction 须为 usdt_to_usdc 或 usdc_to_usdt"} + resp = ex.private_post_trade_order(body) + data = (resp or {}).get("data") or [] + if data and str(data[0].get("sCode")) == "0": + return {"ok": True, "data": data[0], "raw": resp} + return {"ok": False, "msg": _okx_trade_error_message(resp=resp), "raw": resp} + except Exception as e: + return {"ok": False, "msg": _okx_trade_error_message(e)} + + +def transfer_main_sub_account( + ex: ccxt.okx, + *, + ccy: str, + amount: float, + sub_acct: str, + main_to_sub: bool, + from_account: str = "funding", + to_account: str = "funding", +) -> dict[str, Any]: + """主账户与子账户之间划转(须主账户 API).""" + if amount <= 0: + return {"ok": False, "msg": "划转金额须大于 0"} + sub = (sub_acct or "").strip() + if not sub: + return {"ok": False, "msg": "未配置子账户名称 OKX_SUB_ACCOUNT_NAME"} + from_code = _OKX_ACCT_CODE.get((from_account or "funding").lower(), "6") + to_code = _OKX_ACCT_CODE.get((to_account or "funding").lower(), "6") + try: + resp = ex.private_post_asset_transfer( + { + "type": "1" if main_to_sub else "2", + "ccy": str(ccy).upper(), + "amt": str(amount), + "from": from_code, + "to": to_code, + "subAcct": sub, + } + ) + data = (resp or {}).get("data") or [] + if data and str(data[0].get("sCode", "0")) == "0": + return {"ok": True, "data": data[0], "raw": resp} + return {"ok": False, "msg": _okx_trade_error_message(resp=resp), "raw": resp} + except Exception as e: + return {"ok": False, "msg": _okx_trade_error_message(e)} + + +def format_position_row( + pos: dict[str, Any], + ct_mult: float = 0.01, + *, + tick_sz: Any = None, +) -> dict[str, Any]: + from lib.options.options_pricing_lib import ( + close_breakeven_idx, + expiry_breakeven_px, + idx_distance_to_be, + total_premium, + ) + + sheets = _safe_float(pos.get("pos")) or 0.0 + avg = _safe_float(pos.get("avgPx")) + mark = _safe_float(pos.get("markPx")) + upl = _safe_float(pos.get("upl")) + upl_ratio = _safe_float(pos.get("uplRatio")) + idx_px = _safe_float(pos.get("idxPx")) + inst_id = str(pos.get("instId") or "") + opt_type = pos.get("optType") + strike = _safe_float(pos.get("stk")) + parsed_type, parsed_strike = option_fields_from_inst_id(inst_id) + if not opt_type: + opt_type = parsed_type + if strike is None: + strike = parsed_strike + eth_amount = round(abs(sheets) * ct_mult, 8) + premium_paid = ( + round(total_premium(avg, eth_amount), 8) if avg is not None and eth_amount > 0 else None + ) + delta_pa = _safe_float(pos.get("deltaPA")) + expiry_be = expiry_breakeven_px( + opt_type=str(opt_type or ""), + strike=strike, + avg_px=avg, + be_px_api=_safe_float(pos.get("bePx")), + ) + close_be = close_breakeven_idx( + opt_type=str(opt_type or ""), + idx_px=idx_px, + mark_px=mark, + avg_px=avg, + delta_pa=delta_pa, + pos=sheets, + ct_mult=ct_mult, + ) + exp_time_ms = normalize_option_exp_ms(pos.get("expTime"), inst_id) + return { + "inst_id": inst_id or pos.get("instId"), + "pos": sheets, + "eth_amount": eth_amount, + "avg_px": avg, + "mark_px": mark, + "avg_px_fmt": format_option_px(avg, tick_sz) if avg is not None else None, + "mark_px_fmt": format_option_px(mark, tick_sz) if mark is not None else None, + "premium_paid_fmt": format_usdc_amount(premium_paid), + "tick_sz": tick_sz, + "ct_mult": ct_mult, + "idx_px": idx_px, + "premium_paid": premium_paid, + "upl": upl, + "upl_ratio_pct": round(upl_ratio * 100, 2) if upl_ratio is not None else None, + "exp_time": exp_time_ms, + "exp_time_ms": exp_time_ms, + "opt_type": opt_type, + "strike": strike, + "avail_pos": _safe_float(pos.get("availPos")), + "expiry_be_px": expiry_be, + "close_be_px": close_be, + "dist_expiry_be": idx_distance_to_be(idx_px, expiry_be), + "dist_close_be": idx_distance_to_be(idx_px, close_be), + "raw": pos, + } + + +def options_api_ready(ex: ccxt.okx | None) -> tuple[bool, str]: + if ex is None: + return False, "期权 API 未配置" + if not ex.apiKey or not ex.secret or not ex.password: + return False, "期权 API Key 不完整" + return True, "" diff --git a/lib/exchange/okx_orders_lib.py b/lib/exchange/okx_orders_lib.py new file mode 100644 index 0000000..c112128 --- /dev/null +++ b/lib/exchange/okx_orders_lib.py @@ -0,0 +1,116 @@ +""" +OKX 挂单聚合:普通委托 + 算法单(conditional / oco / trigger). +交易所 App「止盈止损」页多为 orders-algo-pending,仅 fetch_open_orders 默认拿不到. +""" +from __future__ import annotations + +from typing import Any + + +def _order_dedupe_key(order: dict) -> str: + info = order.get("info") or {} + if not isinstance(info, dict): + info = {} + return str(order.get("id") or info.get("algoId") or info.get("ordId") or "") + + +def _okx_algo_cancel_id(order_id: str) -> str: + oid = str(order_id or "") + if ":" in oid: + return oid.split(":", 1)[0] + return oid + + +def _okx_order_needs_stop_cancel_param(order: dict) -> bool: + """OKX 条件/算法单撤单须 params.stop=True,否则 cancel_order 走普通单接口会静默失败.""" + if not isinstance(order, dict): + return False + info = order.get("info") or {} + if not isinstance(info, dict): + info = {} + if order.get("stopLossPrice") is not None or order.get("takeProfitPrice") is not None: + return True + if info.get("algoId") or info.get("slTriggerPx") or info.get("tpTriggerPx"): + return True + typ = str(order.get("type") or info.get("ordType") or "").lower() + for token in ("conditional", "oco", "trigger", "move_order_stop", "iceberg"): + if token in typ: + return True + return False + + +def fetch_okx_all_open_orders(ex, exchange_symbol: str) -> list[dict]: + """合并 OKX 普通挂单与算法挂单(去重).""" + if not exchange_symbol: + return [] + ex.load_markets() + sym = exchange_symbol + try: + sym = ex.market(exchange_symbol)["symbol"] + except Exception: + pass + seen: set[str] = set() + out: list[dict] = [] + + def add_batch(batch: list | None) -> None: + for o in batch or []: + if not isinstance(o, dict): + continue + k = _order_dedupe_key(o) + if not k or k in seen: + continue + seen.add(k) + out.append(o) + + try: + add_batch(ex.fetch_open_orders(sym)) + except Exception: + pass + for params in ( + {"ordType": "conditional"}, + {"ordType": "oco"}, + {"trigger": True}, + ): + try: + add_batch(ex.fetch_open_orders(sym, params=dict(params))) + except Exception: + pass + return out + + +def cancel_okx_all_open_orders(ex, exchange_symbol: str) -> int: + """ + 撤销某合约全部挂单(普通 + 条件/算法). + OKX 止盈止损在 orders-algo-pending,必须用 stop=True 才能撤掉. + """ + if not exchange_symbol: + return 0 + ex.load_markets() + sym = exchange_symbol + try: + sym = ex.market(exchange_symbol)["symbol"] + except Exception: + pass + n = 0 + for o in fetch_okx_all_open_orders(ex, sym): + oid = _order_dedupe_key(o) + if not oid: + continue + cancel_id = _okx_algo_cancel_id(oid) + params = {"stop": True} if _okx_order_needs_stop_cancel_param(o) else None + try: + ex.cancel_order(cancel_id, sym, params) + n += 1 + continue + except Exception: + pass + try: + ex.cancel_order(oid, sym, params) + n += 1 + except Exception: + pass + try: + ex.cancel_all_orders(sym) + except Exception: + pass + return n diff --git a/lib/hedge_plan/__init__.py b/lib/hedge_plan/__init__.py new file mode 100644 index 0000000..300c2bd --- /dev/null +++ b/lib/hedge_plan/__init__.py @@ -0,0 +1 @@ +# hedge_plan package diff --git a/lib/hedge_plan/hedge_plan_calc_lib.py b/lib/hedge_plan/hedge_plan_calc_lib.py new file mode 100644 index 0000000..a4fae7d --- /dev/null +++ b/lib/hedge_plan/hedge_plan_calc_lib.py @@ -0,0 +1,385 @@ +"""对冲计划:情景测算与全仓建议仓(纯函数,无 IO).""" +from __future__ import annotations + +from typing import Any, Optional + + +def _f(v: Any) -> Optional[float]: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def perp_coin_amount(*, contracts: float, contract_size: float) -> float: + return float(contracts) * float(contract_size or 1.0) + + +def perp_pnl( + *, + direction: str, + entry: float, + exit_px: float, + contracts: float, + contract_size: float, +) -> float: + coins = perp_coin_amount(contracts=contracts, contract_size=contract_size) + d = (direction or "long").strip().lower() + if d == "short": + return (float(entry) - float(exit_px)) * coins + return (float(exit_px) - float(entry)) * coins + + +def option_premium_total(*, ask: float, sheets: float, ct_mult: float) -> float: + """卖一报价为每 1 币;权利金 = ask × 张数 × ct_mult.""" + return float(ask) * float(sheets) * float(ct_mult or 0.01) + + +def option_expiry_pnl( + *, + opt_type: str, + strike: float, + spot: float, + sheets: float, + ct_mult: float, + premium_paid: float, +) -> float: + o = (opt_type or "").strip().upper() + intrinsic_per_coin = 0.0 + if o in ("C", "CALL"): + intrinsic_per_coin = max(0.0, float(spot) - float(strike)) + elif o in ("P", "PUT"): + intrinsic_per_coin = max(0.0, float(strike) - float(spot)) + else: + return -float(premium_paid) + value = intrinsic_per_coin * float(sheets) * float(ct_mult or 0.01) + return value - float(premium_paid) + + +def suggest_contracts_from_notional( + *, + notional: float, + entry: float, + contract_size: float, +) -> float: + if entry <= 0 or contract_size <= 0 or notional <= 0: + return 0.0 + return float(notional) / (float(entry) * float(contract_size)) + + +def floor_contracts_to_precision(contracts: float, decimals: int) -> float: + """按交易所张数精度向下取整,避免建议张数超过可用保证金.""" + import math + + raw = float(contracts or 0.0) + if raw <= 0: + return 0.0 + try: + d = int(decimals) + except (TypeError, ValueError): + d = 0 + if d <= 0: + return float(math.floor(raw + 1e-12)) + scale = 10**d + return math.floor(raw * scale + 1e-12) / scale + + +def build_perp_options_preview( + *, + direction: str, + entry: float, + tp: float, + sl: float, + contracts: float, + contract_size: float, + opt_type: str, + strike: float, + sheets: float, + ct_mult: float, + premium_paid: float, + index_px: Optional[float] = None, +) -> dict[str, Any]: + """ + 永期情景. + 止盈账:永续止盈盈利 - 权利金. + 止损账:期权到期内在(按 SL 价) - 永续止损亏损额. + """ + d = (direction or "long").strip().lower() + pnl_tp_perp = perp_pnl( + direction=d, entry=entry, exit_px=tp, contracts=contracts, contract_size=contract_size + ) + pnl_sl_perp = perp_pnl( + direction=d, entry=entry, exit_px=sl, contracts=contracts, contract_size=contract_size + ) + # 止盈统计口径 + tp_total = float(pnl_tp_perp) - float(premium_paid) + # 止损:期权按 SL 价结算内在 - |永续亏损| + opt_at_sl = option_expiry_pnl( + opt_type=opt_type, + strike=strike, + spot=sl, + sheets=sheets, + ct_mult=ct_mult, + premium_paid=premium_paid, + ) + sl_total = float(opt_at_sl) - abs(float(pnl_sl_perp)) if pnl_sl_perp < 0 else float(opt_at_sl) + float( + pnl_sl_perp + ) + # 有符号相加更稳:期权盈亏 + 永续盈亏 + sl_total_signed = float(opt_at_sl) + float(pnl_sl_perp) + + spot = float(index_px) if index_px is not None else float(entry) + opt_flat = option_expiry_pnl( + opt_type=opt_type, + strike=strike, + spot=spot, + sheets=sheets, + ct_mult=ct_mult, + premium_paid=premium_paid, + ) + flat_total = 0.0 + float(opt_flat) + + opt_at_tp = option_expiry_pnl( + opt_type=opt_type, + strike=strike, + spot=tp, + sheets=sheets, + ct_mult=ct_mult, + premium_paid=premium_paid, + ) + + return { + "plan_type": "perp_options", + "direction": d, + "contracts": contracts, + "coin_amount": perp_coin_amount(contracts=contracts, contract_size=contract_size), + "premium_paid": round(float(premium_paid), 6), + "scenarios": [ + { + "id": "tp", + "label": "止盈(计划结束口径)", + "spot": tp, + "perp_pnl": round(pnl_tp_perp, 4), + "options_pnl": round(-float(premium_paid), 4), + "total": round(tp_total, 4), + "note": "止盈盈利 − 权利金;期权可不强平", + }, + { + "id": "sl", + "label": "止损(计划结束口径)", + "spot": sl, + "perp_pnl": round(pnl_sl_perp, 4), + "options_pnl": round(opt_at_sl, 4), + "total": round(sl_total_signed, 4), + "note": "期权盈利 − 永续亏损(有符号相加);期权须强平", + }, + { + "id": "flat", + "label": "到期·现价附近", + "spot": spot, + "perp_pnl": 0.0, + "options_pnl": round(opt_flat, 4), + "total": round(flat_total, 4), + "note": "示意:永续未动,期权按到期内在", + }, + { + "id": "expiry_tp", + "label": "到期·止盈价", + "spot": tp, + "perp_pnl": round(pnl_tp_perp, 4), + "options_pnl": round(opt_at_tp, 4), + "total": round(pnl_tp_perp + opt_at_tp, 4), + "note": "若期权拿到 TP 价到期(参考)", + }, + { + "id": "expiry_sl", + "label": "到期·止损价", + "spot": sl, + "perp_pnl": round(pnl_sl_perp, 4), + "options_pnl": round(opt_at_sl, 4), + "total": round(pnl_sl_perp + opt_at_sl, 4), + "note": "与止损口径相近(期权用内在)", + }, + ], + "summary": { + "tp_total": round(tp_total, 4), + "sl_total": round(sl_total_signed, 4), + "premium_paid": round(float(premium_paid), 4), + "hedge_ratio_at_sl": _hedge_ratio(opt_at_sl, pnl_sl_perp), + }, + } + + +def _hedge_ratio(opt_pnl: float, perp_pnl: float) -> Optional[float]: + loss = abs(float(perp_pnl)) if float(perp_pnl) < 0 else 0.0 + if loss <= 1e-12: + return None + if float(opt_pnl) <= 0: + return 0.0 + return round(float(opt_pnl) / loss * 100.0, 2) + + +def build_options_options_preview( + *, + target_price: float | None = None, + target_price_up: float | None = None, + target_price_down: float | None = None, + index_px: float, + leg_a: dict[str, Any], + leg_b: dict[str, Any], +) -> dict[str, Any]: + """期期情景:上破/下破目标价 / 到期现价 / 最大保费损耗.""" + + def _leg_pnl(leg: dict[str, Any], spot: float) -> float: + return option_expiry_pnl( + opt_type=str(leg.get("opt_type") or ""), + strike=float(leg["strike"]), + spot=spot, + sheets=float(leg.get("sheets") or 0), + ct_mult=float(leg.get("ct_mult") or 0.01), + premium_paid=float(leg.get("premium_paid") or 0), + ) + + # 兼容旧单目标:若未传上下目标则用 target_price 填两边 + up = target_price_up if target_price_up is not None else target_price + down = target_price_down if target_price_down is not None else target_price + if up is None or down is None: + raise ValueError("缺少上破/下破目标价") + up_f = float(up) + down_f = float(down) + + prem = float(leg_a.get("premium_paid") or 0) + float(leg_b.get("premium_paid") or 0) + a_up = _leg_pnl(leg_a, up_f) + b_up = _leg_pnl(leg_b, up_f) + at_up = a_up + b_up + win_up = "a" if a_up >= b_up else "b" + + a_dn = _leg_pnl(leg_a, down_f) + b_dn = _leg_pnl(leg_b, down_f) + at_dn = a_dn + b_dn + win_dn = "a" if a_dn >= b_dn else "b" + + a_flat = _leg_pnl(leg_a, index_px) + b_flat = _leg_pnl(leg_b, index_px) + flat_total = a_flat + b_flat + expiry_loss = flat_total if flat_total <= 0 else flat_total + + return { + "plan_type": "options_options", + "premium_paid": round(prem, 6), + "target_price": up_f, # 兼容旧字段,取上破 + "target_price_up": up_f, + "target_price_down": down_f, + "winner_at_up": win_up, + "winner_at_down": win_dn, + "winner_at_target": win_up, + "scenarios": [ + { + "id": "target_up", + "label": "上破目标", + "spot": up_f, + "leg_a_pnl": round(a_up, 4), + "leg_b_pnl": round(b_up, 4), + "total": round(at_up, 4), + "note": f"盈利方≈腿{win_up.upper()}(可平);亏损方默认到期", + }, + { + "id": "target_down", + "label": "下破目标", + "spot": down_f, + "leg_a_pnl": round(a_dn, 4), + "leg_b_pnl": round(b_dn, 4), + "total": round(at_dn, 4), + "note": f"盈利方≈腿{win_dn.upper()}(可平);亏损方默认到期", + }, + { + "id": "expiry_flat", + "label": "到期·现价(无突破)", + "spot": index_px, + "leg_a_pnl": round(a_flat, 4), + "leg_b_pnl": round(b_flat, 4), + "total": round(flat_total, 4), + "note": "无盈利则记总亏损结束" if flat_total <= 0 else "到期仍可能有净值", + }, + { + "id": "max_premium_loss", + "label": "最大保费损耗", + "spot": None, + "leg_a_pnl": round(-float(leg_a.get("premium_paid") or 0), 4), + "leg_b_pnl": round(-float(leg_b.get("premium_paid") or 0), 4), + "total": round(-prem, 4), + "note": "双腿权利金全部损失", + }, + ], + "summary": { + "at_target_up_total": round(at_up, 4), + "at_target_down_total": round(at_dn, 4), + "at_target_total": round(at_up, 4), + "expiry_flat_total": round(expiry_loss, 4), + "premium_paid": round(prem, 6), + "expiry_is_loss": flat_total <= 0, + }, + } + + +def gate_status( + *, + hedge_enabled: bool, + sizing_mode: str, + plan_type: str, + options_enabled: bool, + live_order: bool = False, + live_trading: bool = False, + active_count: int = 0, + max_active: int = 1, +) -> dict[str, Any]: + from lib.trade.position_sizing_lib import is_full_margin_mode + + full = is_full_margin_mode(sizing_mode) + pt = (plan_type or "").strip().lower() + can_preview = True + can_start = True + reasons: list[str] = [] + if not hedge_enabled: + can_start = False + reasons.append("对冲计划未启用(HEDGE_PLAN_ENABLED)") + if not options_enabled: + can_preview = False + can_start = False + reasons.append("期权模块未启用") + if not live_order: + can_start = False + reasons.append("未允许对冲真实下单(HEDGE_PLAN_LIVE_ORDER)") + if active_count >= max(1, int(max_active or 1)): + can_start = False + reasons.append(f"活跃计划已达上限({max_active})") + if pt == "perp_options": + if not full: + can_start = False + reasons.append("永期开仓仅全仓模式可用(当前可测算)") + if not live_trading: + can_start = False + reasons.append("未开启实盘(LIVE_TRADING_ENABLED)") + elif pt == "options_options": + pass + else: + can_start = False + reasons.append("未知计划类型") + if can_start: + reasons = [] + return { + "hedge_enabled": hedge_enabled, + "options_enabled": options_enabled, + "sizing_mode": sizing_mode, + "is_full_margin": full, + "plan_type": pt, + "live_order": live_order, + "live_trading": live_trading, + "active_count": active_count, + "max_active": max_active, + "can_preview": can_preview, + "can_start": can_start, + "reasons": reasons, + } diff --git a/lib/hedge_plan/hedge_plan_db.py b/lib/hedge_plan/hedge_plan_db.py new file mode 100644 index 0000000..5458ada --- /dev/null +++ b/lib/hedge_plan/hedge_plan_db.py @@ -0,0 +1,373 @@ +"""对冲计划 SQLite 表.""" +from __future__ import annotations + +import sqlite3 +from typing import Any, Optional + + +def init_hedge_plan_tables(conn: sqlite3.Connection) -> None: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS hedge_plans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plan_type TEXT NOT NULL, + status TEXT NOT NULL, + underlying TEXT NOT NULL, + direction TEXT, + entry_mark REAL, + tp REAL, + sl REAL, + target_price REAL, + sizing_mode_at_open TEXT, + perp_size REAL, + margin REAL, + leverage REAL, + premium_total REAL, + realized_pnl_perp REAL, + realized_pnl_options REAL, + realized_pnl_total REAL, + stats_bucket TEXT, + close_reason TEXT, + wechat_start_sent INTEGER DEFAULT 0, + wechat_end_sent INTEGER DEFAULT 0, + note TEXT, + preview_json TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + opened_at TIMESTAMP, + closed_at TIMESTAMP + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS hedge_plan_legs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plan_id INTEGER NOT NULL, + leg_role TEXT NOT NULL, + symbol TEXT, + inst_id TEXT, + opt_type TEXT, + strike REAL, + side TEXT, + size REAL, + avg_open REAL, + premium REAL, + status TEXT, + linked_monitor_id INTEGER, + options_trade_id INTEGER, + exchange_ord_id TEXT, + realized_pnl REAL, + close_reason TEXT, + opened_at TIMESTAMP, + closed_at TIMESTAMP, + FOREIGN KEY(plan_id) REFERENCES hedge_plans(id) + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_hedge_plans_status ON hedge_plans(status)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_hedge_plan_legs_plan ON hedge_plan_legs(plan_id)" + ) + _ensure_column(conn, "hedge_plans", "target_price_up", "REAL") + _ensure_column(conn, "hedge_plans", "target_price_down", "REAL") + + +def _ensure_column(conn: sqlite3.Connection, table: str, col: str, typedef: str) -> None: + rows = conn.execute(f"PRAGMA table_info({table})").fetchall() + names: set[str] = set() + for r in rows: + try: + names.add(str(r["name"])) + except (TypeError, KeyError, IndexError): + names.add(str(r[1])) + if col not in names: + conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {typedef}") + + +def count_active_plans(conn: sqlite3.Connection, plan_type: Optional[str] = None) -> int: + if plan_type: + row = conn.execute( + "SELECT COUNT(1) AS c FROM hedge_plans WHERE status IN ('opening','active','partial') AND plan_type=?", + (plan_type,), + ).fetchone() + else: + row = conn.execute( + "SELECT COUNT(1) AS c FROM hedge_plans WHERE status IN ('opening','active','partial')" + ).fetchone() + return int((row["c"] if row else 0) or 0) + + +def insert_plan(conn: sqlite3.Connection, row: dict[str, Any]) -> int: + cols = list(row.keys()) + placeholders = ",".join(["?"] * len(cols)) + conn.execute( + f"INSERT INTO hedge_plans ({','.join(cols)}) VALUES ({placeholders})", + [row[c] for c in cols], + ) + return int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + + +def insert_leg(conn: sqlite3.Connection, row: dict[str, Any]) -> int: + cols = list(row.keys()) + placeholders = ",".join(["?"] * len(cols)) + conn.execute( + f"INSERT INTO hedge_plan_legs ({','.join(cols)}) VALUES ({placeholders})", + [row[c] for c in cols], + ) + return int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + + +def update_plan(conn: sqlite3.Connection, plan_id: int, **fields: Any) -> None: + if not fields: + return + sets = ", ".join(f"{k}=?" for k in fields) + conn.execute(f"UPDATE hedge_plans SET {sets} WHERE id=?", [*fields.values(), plan_id]) + + +def list_plans( + conn: sqlite3.Connection, + *, + status: Optional[str] = None, + plan_type: Optional[str] = None, + underlying: Optional[str] = None, + limit: int = 50, +) -> list[dict[str, Any]]: + wheres: list[str] = [] + args: list[Any] = [] + if status: + wheres.append("status=?") + args.append(status) + if plan_type: + wheres.append("plan_type=?") + args.append(plan_type) + if underlying: + wheres.append("underlying=?") + args.append(underlying) + where = (" WHERE " + " AND ".join(wheres)) if wheres else "" + rows = conn.execute( + f"SELECT * FROM hedge_plans{where} ORDER BY id DESC LIMIT ?", + [*args, int(limit)], + ).fetchall() + return [dict(r) for r in rows] + + +def get_plan(conn: sqlite3.Connection, plan_id: int) -> Optional[dict[str, Any]]: + row = conn.execute("SELECT * FROM hedge_plans WHERE id=?", (plan_id,)).fetchone() + return dict(row) if row else None + + +def get_plan_legs(conn: sqlite3.Connection, plan_id: int) -> list[dict[str, Any]]: + rows = conn.execute( + "SELECT * FROM hedge_plan_legs WHERE plan_id=? ORDER BY id", (plan_id,) + ).fetchall() + return [dict(r) for r in rows] + + +def delete_plan(conn: sqlite3.Connection, plan_id: int) -> dict[str, Any]: + """删除已结束/失败/取消的计划及其腿;活跃计划拒绝删除.""" + plan = get_plan(conn, int(plan_id)) + if not plan: + return {"ok": False, "msg": "计划不存在"} + st = str(plan.get("status") or "") + if st in ("opening", "active", "partial"): + return {"ok": False, "msg": "进行中的计划不可删除,请先结束"} + conn.execute("DELETE FROM hedge_plan_legs WHERE plan_id=?", (int(plan_id),)) + conn.execute("DELETE FROM hedge_plans WHERE id=?", (int(plan_id),)) + return {"ok": True, "deleted_id": int(plan_id)} + + +def legs_contract_summary(legs: list[dict[str, Any]]) -> str: + parts: list[str] = [] + for leg in legs: + role = str(leg.get("leg_role") or "") + if role == "perp": + name = str(leg.get("symbol") or "永续") + parts.append(f"永续 {name}") + else: + inst = str(leg.get("inst_id") or "") + ot = str(leg.get("opt_type") or "").upper() + strike = leg.get("strike") + label = inst or (f"{ot}{strike}" if ot or strike is not None else role) + parts.append(label) + return " · ".join(parts) if parts else "—" + + +def attach_legs_to_plans(conn: sqlite3.Connection, plans: list[dict[str, Any]]) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for p in plans: + legs = get_plan_legs(conn, int(p["id"])) + row = dict(p) + row["legs"] = legs + row["contracts_summary"] = legs_contract_summary(legs) + out.append(row) + return out + + +def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]: + """返回由进行中「期期对冲」托管的期权目标位,仅供期权页只读展示。 + + 这些目标由 hedge_plan_monitor_lib 执行,绝不能写入 options_target_monitors, + 否则两套监控会同时尝试平掉同一条期权腿。 + """ + rows = conn.execute( + """ + SELECT p.id AS plan_id, p.underlying, p.target_price_up, p.target_price_down, + l.inst_id, l.opt_type + FROM hedge_plans p + JOIN hedge_plan_legs l ON l.plan_id = p.id + WHERE p.plan_type = 'options_options' + AND p.status IN ('opening', 'active', 'partial') + AND l.status = 'open' + AND l.inst_id IS NOT NULL + AND l.inst_id != '' + ORDER BY p.id DESC, l.id DESC + """ + ).fetchall() + out: dict[str, dict[str, Any]] = {} + for raw in rows: + row = dict(raw) + inst_id = str(row.get("inst_id") or "") + opt_type = str(row.get("opt_type") or "").upper() + target = row.get("target_price_up") if opt_type == "C" else row.get("target_price_down") + target_f = _sf(target) + if not inst_id or target_f is None or target_f <= 0 or inst_id in out: + continue + out[inst_id] = { + "plan_id": int(row["plan_id"]), + "inst_id": inst_id, + "underlying": row.get("underlying"), + "opt_type": opt_type, + "target_index": target_f, + "plan_type": "options_options", + "managed_by": "hedge_plan", + } + return out + + +def _sf(v: Any) -> Optional[float]: + try: + if v is None or v == "": + return None + return float(v) + except (TypeError, ValueError): + return None + + +def _metrics_from_pnls(rows: list[dict[str, Any]]) -> dict[str, Any]: + """对一组已结束计划计算胜率/盈亏比/最大盈亏/最大回撤.""" + pnls: list[float] = [] + timed: list[tuple[str, float]] = [] + for r in rows: + pnl = _sf(r.get("realized_pnl_total")) + if pnl is None: + continue + pnls.append(pnl) + t = str(r.get("closed_at") or r.get("opened_at") or r.get("created_at") or "") + timed.append((t, pnl)) + n = len(pnls) + if n == 0: + return { + "count": 0, + "wins": 0, + "losses": 0, + "win_rate": None, + "net_pnl": 0.0, + "avg_pnl": None, + "avg_premium": None, + "profit_factor": None, + "max_profit": None, + "max_loss": None, + "max_drawdown": None, + } + wins = [x for x in pnls if x > 0] + losses = [x for x in pnls if x < 0] + gross_win = sum(wins) + gross_loss = abs(sum(losses)) + if gross_loss > 0: + profit_factor = round(gross_win / gross_loss, 4) + elif gross_win > 0: + profit_factor = None # 全胜,标无限 + else: + profit_factor = 0.0 + + timed.sort(key=lambda x: x[0] or "") + cum = 0.0 + peak = 0.0 + mdd = 0.0 + for _, p in timed: + cum += p + if cum > peak: + peak = cum + dd = peak - cum + if dd > mdd: + mdd = dd + + premiums = [_sf(r.get("premium_total")) for r in rows] + premiums_f = [x for x in premiums if x is not None] + return { + "count": n, + "wins": len(wins), + "losses": len(losses), + "win_rate": round(len(wins) / n, 4), + "net_pnl": round(sum(pnls), 4), + "avg_pnl": round(sum(pnls) / n, 4), + "avg_premium": round(sum(premiums_f) / len(premiums_f), 4) if premiums_f else None, + "profit_factor": profit_factor, + "profit_factor_infinite": bool(gross_loss <= 0 and gross_win > 0), + "max_profit": round(max(pnls), 4), + "max_loss": round(min(pnls), 4), + "max_drawdown": round(mdd, 4), + } + + +def stats_summary(conn: sqlite3.Connection) -> dict[str, Any]: + reason_rows = conn.execute( + """ + SELECT plan_type, close_reason, COUNT(1) AS n, + COALESCE(SUM(realized_pnl_total), 0) AS pnl + FROM hedge_plans + WHERE status='closed' + GROUP BY plan_type, close_reason + """ + ).fetchall() + closed_rows = [ + dict(r) + for r in conn.execute( + "SELECT * FROM hedge_plans WHERE status='closed' ORDER BY COALESCE(closed_at, opened_at, created_at), id" + ).fetchall() + ] + active = count_active_plans(conn) + overall = _metrics_from_pnls(closed_rows) + by_type = { + "perp_options": _metrics_from_pnls( + [r for r in closed_rows if r.get("plan_type") == "perp_options"] + ), + "options_options": _metrics_from_pnls( + [r for r in closed_rows if r.get("plan_type") == "options_options"] + ), + } + # 永期止盈/止损分桶 + po = [r for r in closed_rows if r.get("plan_type") == "perp_options"] + by_type["perp_options"]["buckets"] = { + "tp": _metrics_from_pnls([r for r in po if r.get("close_reason") == "perp_tp"]), + "sl": _metrics_from_pnls([r for r in po if r.get("close_reason") == "perp_sl"]), + } + oo = [r for r in closed_rows if r.get("plan_type") == "options_options"] + by_type["options_options"]["buckets"] = { + "expiry_loss": _metrics_from_pnls( + [r for r in oo if r.get("close_reason") == "oo_expiry_loss"] + ), + "expiry_win": _metrics_from_pnls( + [r for r in oo if r.get("close_reason") == "oo_expiry_win"] + ), + } + return { + "active": active, + "closed_count": overall["count"], + "closed_pnl_total": overall["net_pnl"], + "overall": overall, + "by_type": by_type, + "by_reason": [dict(r) for r in reason_rows], + } diff --git a/lib/hedge_plan/hedge_plan_monitor_lib.py b/lib/hedge_plan/hedge_plan_monitor_lib.py new file mode 100644 index 0000000..1048ea8 --- /dev/null +++ b/lib/hedge_plan/hedge_plan_monitor_lib.py @@ -0,0 +1,414 @@ +"""对冲计划监控:永期 TP/SL、期期目标价、到期结算与微信收口推送.""" +from __future__ import annotations + +import os +from datetime import datetime, timezone +from typing import Any, Optional + +from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, list_plans, update_plan +from lib.hedge_plan.hedge_plan_notify_lib import notify_hedge, notify_plan_end, build_hedge_alert_message +from lib.hedge_plan.hedge_plan_orders_lib import _sell_option +from lib.hedge_plan.hedge_plan_settle_lib import leg_is_expired, settle_option_leg_at_spot + + +def _now() -> str: + return datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S") + + +def _env_bool(key: str, default: bool = False) -> bool: + raw = (os.getenv(key) or "").strip().lower() + if not raw: + return default + return raw in ("1", "true", "yes", "on") + + +def _sf(v: Any) -> Optional[float]: + try: + if v is None or v == "": + return None + return float(v) + except (TypeError, ValueError): + return None + + +def _perp_live_contracts(cfg: dict[str, Any], symbol: str, direction: str) -> Optional[float]: + fn = cfg.get("get_live_position_contracts") + if not callable(fn): + return None + try: + return fn(symbol, direction) + except Exception: + return None + + +def _index_px(cfg: dict[str, Any], underlying: str) -> Optional[float]: + ex = cfg.get("exchange_options") + fn = cfg.get("fetch_index_price") + if callable(fn) and ex is not None: + try: + return fn(ex, underlying) + except Exception: + return None + # 无期权账户时回退永续 ticker + ex_perp = cfg.get("exchange") + if ex_perp is not None: + try: + base = (underlying or "ETH").upper() + sym = f"{base}/USDT:USDT" + t = ex_perp.fetch_ticker(sym) + return _sf((t.get("info") or {}).get("markPx")) or _sf(t.get("last")) + except Exception: + return None + return None + + +def tick_active_plans(cfg: dict[str, Any]) -> dict[str, Any]: + """扫描 active 计划 + 止盈后遗留期权到期收口.返回处理摘要.""" + get_db = cfg.get("get_db") + if not callable(get_db): + return {"ok": False, "msg": "get_db missing"} + conn = get_db() + acted: list[dict[str, Any]] = [] + try: + from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables + + init_hedge_plan_tables(conn) + plans = list_plans(conn, status="active", limit=40) + for plan in plans: + r = _tick_one(cfg, conn, plan) + if r: + acted.append(r) + orphaned = _settle_orphaned_after_tp(cfg, conn) + acted.extend(orphaned) + conn.commit() + finally: + conn.close() + return {"ok": True, "acted": acted} + + +def _notify_end_reload(cfg: dict[str, Any], conn: Any, plan_id: int) -> None: + plan = get_plan(conn, int(plan_id)) + if plan: + notify_plan_end(cfg, conn, plan) + + +def _tick_one(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> Optional[dict[str, Any]]: + pt = plan.get("plan_type") + legs = get_plan_legs(conn, int(plan["id"])) + if pt == "perp_options": + # 先判断期权是否已过期且永续仍在(罕见);主路径仍是永续平仓侦测 + r = _tick_po(cfg, conn, plan, legs) + return r + if pt == "options_options": + r = _tick_oo_expiry(cfg, conn, plan, legs) + if r: + return r + return _tick_oo_target(cfg, conn, plan, legs) + return None + + +def _tick_po(cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]) -> Optional[dict[str, Any]]: + perp = next((x for x in legs if x.get("leg_role") == "perp"), None) + opt = next((x for x in legs if x.get("leg_role") == "option_hedge"), None) + if not perp or perp.get("status") != "open": + return None + symbol = perp.get("symbol") or "" + direction = (plan.get("direction") or "long").lower() + live = _perp_live_contracts(cfg, symbol, direction) + # 仍有仓 → 未触达交易所 TP/SL + if live is not None and live > 0: + return None + # 仓已平:用标记/最新粗判 TP or SL + entry = _sf(plan.get("entry_mark")) or _sf(perp.get("avg_open")) or 0 + tp = _sf(plan.get("tp")) + sl = _sf(plan.get("sl")) + mark = None + ex = cfg.get("exchange") + if ex is not None and symbol: + try: + t = ex.fetch_ticker(symbol) + mark = _sf((t.get("info") or {}).get("markPx")) or _sf(t.get("last")) + except Exception: + mark = None + reason = "perp_tp" + if mark is not None and sl is not None and entry: + if direction == "long" and mark <= sl: + reason = "perp_sl" + elif direction == "short" and mark >= sl: + reason = "perp_sl" + elif tp is not None: + if direction == "long" and mark >= tp: + reason = "perp_tp" + elif direction == "short" and mark <= tp: + reason = "perp_tp" + premium = float(plan.get("premium_total") or 0) + cs = float(cfg.get("default_contract_size") or 0.01) + get_cs = cfg.get("get_contract_size") + if callable(get_cs) and symbol: + try: + cs = float(get_cs(symbol) or cs) + except Exception: + pass + size = float(perp.get("size") or 0) + exit_px = mark or (tp if reason == "perp_tp" else sl) or entry + coins = size * cs + if direction == "short": + perp_pnl = (entry - exit_px) * coins + else: + perp_pnl = (exit_px - entry) * coins + + opt_pnl = -premium + if reason == "perp_sl" and opt and _env_bool("HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", True): + close_r = _sell_option( + cfg, + inst_id=str(opt.get("inst_id") or ""), + sheets=float(opt.get("size") or 1), + ) + if not close_r.get("ok"): + notify_hedge( + cfg, + build_hedge_alert_message( + title="永续止损后期权强制平仓失败", + plan_id=plan.get("id"), + detail=str(close_r.get("msg") or close_r), + ), + ) + if close_r.get("ok"): + bid = _sf(close_r.get("bid")) + ask_open = _sf(opt.get("avg_open")) + if bid is not None and ask_open is not None: + ct = float(opt.get("ct_mult") or 0.01) + opt_pnl = (bid - ask_open) * float(opt.get("size") or 1) * ct + else: + opt_pnl = -premium + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", + ("closed", reason, _now(), opt_pnl, opt["id"]), + ) + elif reason == "perp_tp" and opt: + if _env_bool("HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", False): + close_r = _sell_option(cfg, inst_id=str(opt.get("inst_id") or ""), sheets=float(opt.get("size") or 1)) + if not close_r.get("ok"): + notify_hedge( + cfg, + build_hedge_alert_message( + title="永续止盈后期权平仓失败", + plan_id=plan.get("id"), + detail=str(close_r.get("msg") or close_r), + ), + ) + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=? WHERE id=?", + ("closed", reason, _now(), opt["id"]), + ) + else: + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=? WHERE id=?", + ("hold_to_expiry", "orphaned_after_tp", opt["id"]), + ) + opt_pnl = -premium + + if reason == "perp_tp": + total = perp_pnl + opt_pnl + else: + total = opt_pnl + perp_pnl + + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", + ("closed", reason, _now(), perp_pnl, perp["id"]), + ) + update_plan( + conn, + int(plan["id"]), + status="closed", + close_reason=reason, + realized_pnl_perp=round(perp_pnl, 4), + realized_pnl_options=round(opt_pnl, 4), + realized_pnl_total=round(total, 4), + stats_bucket="tp" if reason == "perp_tp" else "sl", + closed_at=_now(), + ) + _notify_end_reload(cfg, conn, int(plan["id"])) + return {"plan_id": plan["id"], "close_reason": reason, "total": total} + + +def _tick_oo_target( + cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]] +) -> Optional[dict[str, Any]]: + """期期:触及上破或下破目标价时平盈利腿.""" + idx = _index_px(cfg, str(plan.get("underlying") or "ETH")) + if idx is None: + return None + up = _sf(plan.get("target_price_up")) + down = _sf(plan.get("target_price_down")) + # 旧计划仅有单目标:两边都用它 + legacy = _sf(plan.get("target_price")) + if up is None and legacy is not None: + up = legacy + if down is None and legacy is not None: + down = legacy + if up is None and down is None: + return None + + hit_side: Optional[str] = None + # 上破:现价接近或超过上破目标 + if up is not None and idx >= up * 0.998: + hit_side = "up" + # 下破:现价接近或低于下破目标 + elif down is not None and idx <= down * 1.002: + hit_side = "down" + if not hit_side: + return None + if not _env_bool("HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", True): + return None + open_legs = [x for x in legs if x.get("status") == "open" and str(x.get("leg_role") or "").startswith("option")] + if len(open_legs) < 2: + return None + winners = [] + for leg in open_legs: + strike = _sf(leg.get("strike")) or 0 + o = (leg.get("opt_type") or "").upper() + intrinsic = max(0.0, idx - strike) if o == "C" else max(0.0, strike - idx) + premium = float(leg.get("premium") or 0) + pnl = intrinsic * float(leg.get("size") or 1) * float(leg.get("ct_mult") or 0.01) - premium + winners.append((pnl, leg)) + winners.sort(key=lambda x: x[0], reverse=True) + best_pnl, best = winners[0] + if best_pnl <= 0: + return None + close_r = _sell_option(cfg, inst_id=str(best.get("inst_id") or ""), sheets=float(best.get("size") or 1)) + if not close_r.get("ok"): + notify_hedge( + cfg, + build_hedge_alert_message( + title="期期平盈利腿失败", + plan_id=plan.get("id"), + detail=str(close_r.get("msg") or close_r), + ), + ) + return {"plan_id": plan["id"], "msg": "平盈利腿失败", "close": close_r} + reason = "target_up_win_leg" if hit_side == "up" else "target_down_win_leg" + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", + ("closed", reason, _now(), best_pnl, best["id"]), + ) + update_plan(conn, int(plan["id"]), close_reason=reason) + mid = dict(plan) + mid["close_reason"] = reason + mid["status"] = "active" + notify_plan_end(cfg, conn, mid) + return { + "plan_id": plan["id"], + "close_reason": reason, + "hit_side": hit_side, + "closed_leg": best.get("id"), + "index": idx, + } + + +def _tick_oo_expiry( + cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]] +) -> Optional[dict[str, Any]]: + """期期:剩余期权腿全部到期 → 结算合计并结束计划.""" + pending = [ + x + for x in legs + if str(x.get("leg_role") or "").startswith("option") + and str(x.get("status") or "") in ("open", "hold_to_expiry") + ] + if not pending: + # 若腿已全部 closed 但计划仍 active(异常残留)则用腿合计收口 + closed_opts = [ + x for x in legs if str(x.get("leg_role") or "").startswith("option") and x.get("status") == "closed" + ] + if len(closed_opts) < 1: + return None + total_opts = sum(float(x.get("realized_pnl") or 0) for x in closed_opts) + reason = "oo_expiry_loss" if total_opts <= 0 else "oo_expiry_win" + update_plan( + conn, + int(plan["id"]), + status="closed", + close_reason=reason, + realized_pnl_options=round(total_opts, 4), + realized_pnl_total=round(total_opts, 4), + stats_bucket=reason if reason == "oo_expiry_loss" else "oo_target", + closed_at=_now(), + ) + _notify_end_reload(cfg, conn, int(plan["id"])) + return {"plan_id": plan["id"], "close_reason": reason, "total": total_opts} + + if not all(leg_is_expired(x) for x in pending): + return None + + spot = _index_px(cfg, str(plan.get("underlying") or "ETH")) + if spot is None: + return None + + settled_sum = 0.0 + for leg in pending: + pnl = settle_option_leg_at_spot(leg, float(spot)) + settled_sum += pnl + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", + ("closed", "expiry", _now(), round(pnl, 4), leg["id"]), + ) + + already = sum( + float(x.get("realized_pnl") or 0) + for x in legs + if str(x.get("leg_role") or "").startswith("option") and x.get("status") == "closed" + ) + total = already + settled_sum + reason = "oo_expiry_loss" if total <= 0 else "oo_expiry_win" + bucket = "oo_expiry_loss" if reason == "oo_expiry_loss" else "oo_target" + update_plan( + conn, + int(plan["id"]), + status="closed", + close_reason=reason, + realized_pnl_options=round(total, 4), + realized_pnl_total=round(total, 4), + stats_bucket=bucket, + closed_at=_now(), + ) + _notify_end_reload(cfg, conn, int(plan["id"])) + return {"plan_id": plan["id"], "close_reason": reason, "total": total, "spot": spot} + + +def _settle_orphaned_after_tp(cfg: dict[str, Any], conn: Any) -> list[dict[str, Any]]: + """永期止盈后 hold_to_expiry 期权到期:只更新腿,不回写计划合计.""" + rows = conn.execute( + """ + SELECT l.id AS leg_id, l.plan_id, l.inst_id, l.opt_type, l.strike, l.size, l.premium, l.status, + p.underlying, p.status AS plan_status + FROM hedge_plan_legs l + JOIN hedge_plans p ON p.id = l.plan_id + WHERE l.status = 'hold_to_expiry' AND l.close_reason = 'orphaned_after_tp' + LIMIT 40 + """ + ).fetchall() + acted: list[dict[str, Any]] = [] + for row in rows: + leg = dict(row) + if not leg_is_expired(leg): + continue + spot = _index_px(cfg, str(leg.get("underlying") or "ETH")) + if spot is None: + continue + pnl = settle_option_leg_at_spot(leg, float(spot)) + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", + ("closed", "expiry", _now(), round(pnl, 4), leg["leg_id"]), + ) + # 故意不 UPDATE hedge_plans.realized_pnl_* + acted.append( + { + "plan_id": leg["plan_id"], + "close_reason": "orphaned_option_expiry", + "leg_id": leg["leg_id"], + "leg_pnl": round(pnl, 4), + "note": "不回写计划合计", + } + ) + return acted diff --git a/lib/hedge_plan/hedge_plan_notify_lib.py b/lib/hedge_plan/hedge_plan_notify_lib.py new file mode 100644 index 0000000..76a13a3 --- /dev/null +++ b/lib/hedge_plan/hedge_plan_notify_lib.py @@ -0,0 +1,186 @@ +"""对冲计划企业微信推送(起止必发,幂等落库标记).""" +from __future__ import annotations + +from typing import Any, Callable, Optional + +from lib.hedge_plan.hedge_plan_db import update_plan + + +def _fmt(v: Any, d: int = 2) -> str: + try: + if v is None or v == "": + return "—" + return f"{float(v):.{d}f}" + except (TypeError, ValueError): + return str(v) + + +def _type_label(plan_type: str) -> str: + return "永期对冲" if (plan_type or "") == "perp_options" else "期期对冲" + + +def _dir_label(direction: str) -> str: + d = (direction or "").lower() + if d == "long": + return "做多" + if d == "short": + return "做空" + return "—" + + +def build_hedge_start_message(plan: dict[str, Any], *, legs: Optional[list[dict[str, Any]]] = None) -> str: + pt = plan.get("plan_type") or "" + lines = [ + f"🟢 对冲计划启动 #{plan.get('id')}", + f"📌 类型:{_type_label(pt)}", + f"🪙 标的:{plan.get('underlying') or '—'}", + ] + if pt == "perp_options": + lines.extend( + [ + f"📈 方向:{_dir_label(plan.get('direction') or '')}", + f"💵 开仓参考:{_fmt(plan.get('entry_mark'))}", + f"🎯 止盈:{_fmt(plan.get('tp'))}|止损:{_fmt(plan.get('sl'))}", + f"📦 永续张数:{_fmt(plan.get('perp_size'), 4)}|杠杆:{_fmt(plan.get('leverage'), 0)}x", + f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC", + ] + ) + else: + lines.extend( + [ + f"🎯 上破:{_fmt(plan.get('target_price_up') or plan.get('target_price'))}" + f"|下破:{_fmt(plan.get('target_price_down') or plan.get('target_price'))}", + f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC", + ] + ) + if legs: + for leg in legs: + role = leg.get("leg_role") or "" + if role == "perp": + lines.append(f"· 永续腿 {leg.get('symbol') or ''} ×{_fmt(leg.get('size'), 4)}") + else: + lines.append( + f"· {role} {(leg.get('opt_type') or '')} K{_fmt(leg.get('strike'), 0)} " + f"×{_fmt(leg.get('size'), 0)}张 {leg.get('inst_id') or ''}" + ) + lines.append("📎 独立模块推送,不进普通交易复盘") + return "\n".join(lines) + + +def build_hedge_end_message(plan: dict[str, Any]) -> str: + reason = plan.get("close_reason") or "—" + total = plan.get("realized_pnl_total") + try: + tv = float(total) if total is not None else None + except (TypeError, ValueError): + tv = None + head = "🔴" if (tv is not None and tv < 0) else "🟢" + reason_map = { + "perp_tp": "永续止盈(期权默认不平)", + "perp_sl": "永续止损(期权强制平)", + "target_win_leg": "期期已平盈利腿(中间态)", + "target_up_win_leg": "期期上破·已平盈利腿", + "target_down_win_leg": "期期下破·已平盈利腿", + "oo_expiry_loss": "期期到期无盈利·总亏损", + "oo_expiry_win": "期期到期仍盈利", + "expiry": "到期收口", + "manual": "人工结束", + "partial_fail": "半腿失败收尾", + "cancelled": "已取消", + } + lines = [ + f"{head} 对冲计划结束 #{plan.get('id')}", + f"📌 类型:{_type_label(plan.get('plan_type') or '')}", + f"🪙 标的:{plan.get('underlying') or '—'}", + f"📎 原因:{reason_map.get(reason, reason)}", + f"💰 合计≈U:{_fmt(total)}", + f"· 永续分项:{_fmt(plan.get('realized_pnl_perp'))} USDT", + f"· 期权分项:{_fmt(plan.get('realized_pnl_options'))} USDC(≈U 1:1)", + f"⏱ 开仓:{plan.get('opened_at') or '—'}|结束:{plan.get('closed_at') or '—'}", + ] + return "\n".join(lines) + + +def build_hedge_alert_message( + *, + title: str, + plan_id: Any = None, + detail: str = "", +) -> str: + lines = [f"⚠️ 对冲计划告警{(' #' + str(plan_id)) if plan_id else ''}", f"📌 {title}"] + if detail: + lines.append(str(detail)[:800]) + return "\n".join(lines) + + +def notify_hedge( + cfg: dict[str, Any], + content: str, +) -> bool: + send: Optional[Callable[[str], Any]] = cfg.get("send_wechat") + if not callable(send): + return False + try: + send(content) + return True + except Exception: + return False + + +def notify_plan_start( + cfg: dict[str, Any], + conn: Any, + plan: dict[str, Any], + legs: Optional[list[dict[str, Any]]] = None, +) -> bool: + if int(plan.get("wechat_start_sent") or 0): + return False + ok = notify_hedge(cfg, build_hedge_start_message(plan, legs=legs)) + if ok and plan.get("id") is not None: + update_plan(conn, int(plan["id"]), wechat_start_sent=1) + plan["wechat_start_sent"] = 1 + return ok + + +def notify_plan_end(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> bool: + if int(plan.get("wechat_end_sent") or 0): + return False + # 中间态 target_win_leg 不算正式结束推送(用告警) + if (plan.get("close_reason") or "") in ( + "target_win_leg", + "target_up_win_leg", + "target_down_win_leg", + ) and (plan.get("status") or "") != "closed": + side = "上破" if "up" in str(plan.get("close_reason")) else ( + "下破" if "down" in str(plan.get("close_reason")) else "目标价" + ) + notify_hedge( + cfg, + build_hedge_alert_message( + title=f"期期{side}已平盈利腿,亏损腿继续持有至到期", + plan_id=plan.get("id"), + detail=( + f"上破 {_fmt(plan.get('target_price_up') or plan.get('target_price'))}" + f"|下破 {_fmt(plan.get('target_price_down') or plan.get('target_price'))}" + ), + ), + ) + return True + ok = notify_hedge(cfg, build_hedge_end_message(plan)) + if ok and plan.get("id") is not None: + update_plan(conn, int(plan["id"]), wechat_end_sent=1) + plan["wechat_end_sent"] = 1 + return ok + + +def notify_partial_fail(cfg: dict[str, Any], *, plan_type: str, msg: str, results: Any = None) -> bool: + detail = msg + if results: + try: + detail = f"{msg}\n路径结果:{results}"[:800] + except Exception: + pass + return notify_hedge( + cfg, + build_hedge_alert_message(title=f"{_type_label(plan_type)}半腿失败", detail=detail), + ) diff --git a/lib/hedge_plan/hedge_plan_orders_lib.py b/lib/hedge_plan/hedge_plan_orders_lib.py new file mode 100644 index 0000000..ba9f5c5 --- /dev/null +++ b/lib/hedge_plan/hedge_plan_orders_lib.py @@ -0,0 +1,429 @@ +"""对冲计划开仓/平仓编排(可 dry_run 校验下单路径).""" +from __future__ import annotations + +import json +import os +from datetime import datetime, timezone +from typing import Any, Callable, Optional + + +def _now() -> str: + return datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S") + + +def _env_bool(key: str, default: bool = False) -> bool: + raw = (os.getenv(key) or "").strip().lower() + if not raw: + return default + return raw in ("1", "true", "yes", "on") + + +def open_order_mode() -> str: + v = (os.getenv("HEDGE_PLAN_OPEN_ORDER") or "options_first").strip().lower() + return v if v in ("options_first", "perp_first") else "options_first" + + +def build_po_path_plan(body: dict[str, Any]) -> list[dict[str, Any]]: + """永期下单路径清单(不交易).""" + mode = open_order_mode() + opt = { + "step": "options_buy_limit", + "account": "options", + "inst_id": body.get("opt_inst_id"), + "sheets": float(body.get("sheets") or 1), + "side": "buy", + "price_hint": "ask", + } + perp = { + "step": "perp_market_open", + "account": "swap", + "symbol": body.get("exchange_symbol"), + "direction": body.get("direction") or "long", + "contracts": float(body.get("contracts") or 0), + "tp": body.get("tp"), + "sl": body.get("sl"), + "attach_tpsl": True, + } + return [opt, perp] if mode == "options_first" else [perp, opt] + + +def build_oo_path_plan(body: dict[str, Any]) -> list[dict[str, Any]]: + return [ + { + "step": "options_buy_limit", + "account": "options", + "leg": "a", + "inst_id": (body.get("leg_a") or {}).get("inst_id"), + "sheets": float((body.get("leg_a") or {}).get("sheets") or 1), + "side": "buy", + "price_hint": "ask", + }, + { + "step": "options_buy_limit", + "account": "options", + "leg": "b", + "inst_id": (body.get("leg_b") or {}).get("inst_id"), + "sheets": float((body.get("leg_b") or {}).get("sheets") or 1), + "side": "buy", + "price_hint": "ask", + }, + ] + + +def _buy_option( + cfg: dict[str, Any], + *, + inst_id: str, + sheets: float, + dry_run: bool, +) -> dict[str, Any]: + from lib.exchange.okx_options_lib import ( + cap_option_buy_sheets_to_ask_depth, + option_buy_liquidity_ok, + ) + + ex = cfg.get("exchange_options") + quote_fn = cfg.get("quote_option_contract") + place_fn = cfg.get("place_option_limit_order") + td_buy = cfg.get("td_mode_for_option_buy") + if not inst_id: + return {"ok": False, "msg": "缺少期权合约"} + if not callable(quote_fn) or ex is None: + return {"ok": False, "msg": "期权报价能力未就绪"} + q = quote_fn(ex, inst_id) + if not q.get("ok"): + return {"ok": False, "msg": q.get("msg") or "期权报价失败", "quote": q} + ask = q.get("ask") + ask_sz = q.get("ask_sz") + can_open, block_msg = option_buy_liquidity_ok(ask, ask_sz) + if not can_open: + return { + "ok": False, + "msg": block_msg or q.get("open_block_msg") or "暂无卖一深度,无法买入", + "quote": q, + "mark": q.get("mark"), + "ref_ask": q.get("ref_ask"), + "can_open": False, + } + sheets_i = max(1, int(round(float(sheets)))) + capped, cap_msg = cap_option_buy_sheets_to_ask_depth(sheets_i, ask_sz, min_sz=1) + if capped is None: + return {"ok": False, "msg": cap_msg or "卖一深度不足,无法买入", "quote": q} + sheets_i = capped + ct_mult = float(q.get("ct_mult") or 0.01) + premium = float(ask) * sheets_i * ct_mult + if dry_run: + return { + "ok": True, + "dry_run": True, + "inst_id": inst_id, + "sheets": sheets_i, + "ask": float(ask), + "ask_sz": float(ask_sz), + "premium": premium, + "ct_mult": ct_mult, + "tick_sz": q.get("tick_sz"), + "meta": q.get("meta") or {}, + "strike": q.get("strike"), + "exp_time": q.get("exp_time"), + "opt_type": (q.get("meta") or {}).get("optType") or q.get("opt_type"), + "can_open": True, + } + if not callable(place_fn): + return {"ok": False, "msg": "期权限价下单未注入"} + td = "isolated" + if callable(td_buy): + td = td_buy(cfg.get("options_td_mode") or "isolated") + order = place_fn( + ex, + inst_id=inst_id, + side="buy", + sheets=sheets_i, + price=float(ask), + td_mode=td, + tick_sz=q.get("tick_sz"), + ) + if not order.get("ok"): + return order + return { + "ok": True, + "inst_id": inst_id, + "sheets": sheets_i, + "ask": float(ask), + "ask_sz": float(ask_sz), + "premium": premium, + "ct_mult": ct_mult, + "tick_sz": q.get("tick_sz"), + "meta": q.get("meta") or {}, + "strike": q.get("strike"), + "exp_time": q.get("exp_time"), + "opt_type": (q.get("meta") or {}).get("optType") or q.get("opt_type"), + "exchange_ord_id": (order.get("data") or {}).get("ordId"), + "order": order, + "can_open": True, + } + + +def _open_perp( + cfg: dict[str, Any], + *, + symbol: str, + direction: str, + contracts: float, + leverage: int, + tp: float, + sl: float, + dry_run: bool, +) -> dict[str, Any]: + if not symbol or contracts <= 0: + return {"ok": False, "msg": "永续符号或张数无效"} + amount = float(contracts) + to_prec = cfg.get("amount_to_precision") + ex = cfg.get("exchange") + if callable(to_prec) and ex is not None: + try: + amount = float(to_prec(symbol, amount)) + except Exception: + pass + if amount <= 0: + return {"ok": False, "msg": "张数经精度舍入后为 0"} + if dry_run: + return { + "ok": True, + "dry_run": True, + "symbol": symbol, + "direction": direction, + "contracts": amount, + "leverage": leverage, + "tp": tp, + "sl": sl, + } + ensure = cfg.get("ensure_okx_live_ready") + if callable(ensure): + ok, msg = ensure() + if not ok: + return {"ok": False, "msg": msg or "实盘未就绪"} + place = cfg.get("place_exchange_order") + if not callable(place): + return {"ok": False, "msg": "永续下单函数未注入"} + try: + order = place(symbol, direction, amount, leverage, stop_loss=sl, take_profit=tp) + except Exception as e: + return {"ok": False, "msg": f"永续开仓失败: {e}"} + return { + "ok": True, + "symbol": symbol, + "direction": direction, + "contracts": amount, + "leverage": leverage, + "tp": tp, + "sl": sl, + "order": order, + "exchange_ord_id": str((order or {}).get("id") or (order or {}).get("info", {}).get("ordId") or ""), + } + + +def _sell_option( + cfg: dict[str, Any], + *, + inst_id: str, + sheets: float, + dry_run: bool = False, +) -> dict[str, Any]: + ex = cfg.get("exchange_options") + quote_fn = cfg.get("quote_option_contract") + place_fn = cfg.get("place_option_limit_order") + if not callable(quote_fn) or ex is None: + return {"ok": False, "msg": "期权报价能力未就绪"} + q = quote_fn(ex, inst_id) + bid = q.get("bid") if q.get("ok") else None + if bid is None or float(bid) <= 0: + return {"ok": False, "msg": "暂无买一价,无法平期权"} + sheets_i = max(1, int(round(float(sheets)))) + if dry_run: + return {"ok": True, "dry_run": True, "inst_id": inst_id, "sheets": sheets_i, "bid": float(bid)} + if not callable(place_fn): + return {"ok": False, "msg": "期权平仓未注入"} + order = place_fn( + ex, + inst_id=inst_id, + side="sell", + sheets=sheets_i, + price=float(bid), + td_mode="isolated", + tick_sz=q.get("tick_sz"), + reduce_only=True, + ) + return order if order.get("ok") else order + + +def execute_perp_options_start( + cfg: dict[str, Any], + body: dict[str, Any], + *, + dry_run: bool = False, + persist: Optional[Callable[..., Any]] = None, +) -> dict[str, Any]: + path = build_po_path_plan(body) + results: list[dict[str, Any]] = [] + opt_res: Optional[dict[str, Any]] = None + perp_res: Optional[dict[str, Any]] = None + for step in path: + if step["step"] == "options_buy_limit": + opt_res = _buy_option( + cfg, + inst_id=str(body.get("opt_inst_id") or ""), + sheets=float(body.get("sheets") or 1), + dry_run=dry_run, + ) + results.append({"step": step["step"], **opt_res}) + if not opt_res.get("ok"): + return {"ok": False, "msg": opt_res.get("msg") or "期权开仓失败", "path": path, "results": results} + else: + perp_res = _open_perp( + cfg, + symbol=str(body.get("exchange_symbol") or ""), + direction=str(body.get("direction") or "long"), + contracts=float(body.get("contracts") or 0), + leverage=int(body.get("leverage") or 10), + tp=float(body["tp"]), + sl=float(body["sl"]), + dry_run=dry_run, + ) + results.append({"step": step["step"], **perp_res}) + if not perp_res.get("ok"): + # 半腿补偿:期权已成 + 配置允许则平期权 + if opt_res and opt_res.get("ok") and not dry_run and _env_bool("HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", True): + close_r = _sell_option( + cfg, + inst_id=str(opt_res.get("inst_id") or body.get("opt_inst_id") or ""), + sheets=float(opt_res.get("sheets") or body.get("sheets") or 1), + ) + results.append({"step": "options_auto_close_on_perp_fail", **close_r}) + msg = perp_res.get("msg") or "永续开仓失败" + if not dry_run: + try: + from lib.hedge_plan.hedge_plan_notify_lib import notify_partial_fail + + notify_partial_fail( + cfg, plan_type="perp_options", msg=msg, results=results + ) + except Exception: + pass + return { + "ok": False, + "msg": msg, + "path": path, + "results": results, + "partial": True, + } + + out = { + "ok": True, + "dry_run": dry_run, + "plan_type": "perp_options", + "path": path, + "results": results, + "option": opt_res, + "perp": perp_res, + "opened_at": _now(), + } + if persist and not dry_run: + out["plan_id"] = persist(out, body) + return out + + +def execute_options_options_start( + cfg: dict[str, Any], + body: dict[str, Any], + *, + dry_run: bool = False, + persist: Optional[Callable[..., Any]] = None, +) -> dict[str, Any]: + path = build_oo_path_plan(body) + results: list[dict[str, Any]] = [] + leg_a = body.get("leg_a") or {} + leg_b = body.get("leg_b") or {} + a_res = _buy_option(cfg, inst_id=str(leg_a.get("inst_id") or ""), sheets=float(leg_a.get("sheets") or 1), dry_run=dry_run) + results.append({"step": "options_buy_limit", "leg": "a", **a_res}) + if not a_res.get("ok"): + return {"ok": False, "msg": a_res.get("msg") or "腿A开仓失败", "path": path, "results": results} + b_res = _buy_option(cfg, inst_id=str(leg_b.get("inst_id") or ""), sheets=float(leg_b.get("sheets") or 1), dry_run=dry_run) + results.append({"step": "options_buy_limit", "leg": "b", **b_res}) + if not b_res.get("ok"): + if not dry_run and _env_bool("HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", True): + close_r = _sell_option(cfg, inst_id=str(a_res.get("inst_id") or ""), sheets=float(a_res.get("sheets") or 1)) + results.append({"step": "options_auto_close_leg_a", **close_r}) + msg = b_res.get("msg") or "腿B开仓失败" + if not dry_run: + try: + from lib.hedge_plan.hedge_plan_notify_lib import notify_partial_fail + + notify_partial_fail(cfg, plan_type="options_options", msg=msg, results=results) + except Exception: + pass + return { + "ok": False, + "msg": msg, + "path": path, + "results": results, + "partial": True, + } + out = { + "ok": True, + "dry_run": dry_run, + "plan_type": "options_options", + "path": path, + "results": results, + "leg_a": a_res, + "leg_b": b_res, + "opened_at": _now(), + } + if persist and not dry_run: + out["plan_id"] = persist(out, body) + return out + + +def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]: + pt = (plan_type or "").strip().lower() + if pt == "perp_options": + need = ("direction", "entry", "tp", "sl", "contracts", "opt_inst_id", "sheets", "exchange_symbol") + for k in need: + if body.get(k) in (None, ""): + return f"缺少字段: {k}" + try: + if float(body["contracts"]) <= 0 or float(body["sheets"]) <= 0: + return "张数必须大于 0" + if float(body["tp"]) <= 0 or float(body["sl"]) <= 0: + return "止盈/止损无效" + except (TypeError, ValueError): + return "数值字段无效" + return None + if pt == "options_options": + a = body.get("leg_a") or {} + b = body.get("leg_b") or {} + if not a.get("inst_id") or not b.get("inst_id"): + return "请选用两条期权腿" + up = body.get("target_price_up") + down = body.get("target_price_down") + legacy = body.get("target_price") + if up in (None, "") and legacy not in (None, ""): + up = legacy + if down in (None, "") and legacy not in (None, ""): + down = legacy + if up in (None, "") or down in (None, ""): + return "请填写上破与下破目标价" + try: + if float(up) <= float(down): + return "上破目标价必须大于下破目标价" + except (TypeError, ValueError): + return "目标价无效" + return None + return "未知计划类型" + + +def dump_preview(preview: Any) -> str: + try: + return json.dumps(preview, ensure_ascii=False)[:8000] + except Exception: + return "" diff --git a/lib/hedge_plan/hedge_plan_register.py b/lib/hedge_plan/hedge_plan_register.py new file mode 100644 index 0000000..5a7e26a --- /dev/null +++ b/lib/hedge_plan/hedge_plan_register.py @@ -0,0 +1,816 @@ +"""OKX 对冲计划:P0 测算页与 API 注册.""" +from __future__ import annotations + +import os +from typing import Any + +from flask import Flask, jsonify, request +from jinja2 import ChoiceLoader, FileSystemLoader + +from lib.hedge_plan.hedge_plan_calc_lib import ( + build_options_options_preview, + build_perp_options_preview, + floor_contracts_to_precision, + gate_status, + option_premium_total, + suggest_contracts_from_notional, +) +from lib.hub.hub_calculator_market_lib import amount_decimals_from_exchange +from lib.trade.position_sizing_lib import ( + compute_full_margin_sizing, + load_position_sizing_mode, +) + + +def _env_bool(key: str, default: bool = False) -> bool: + raw = (os.getenv(key) or "").strip().lower() + if not raw: + return default + return raw in ("1", "true", "yes", "on") + + +def attach_hedge_plan_templates(app: Flask, repo_root: str) -> None: + tpl_dir = os.path.join(repo_root, "lib", "hedge_plan", "templates") + if not os.path.isdir(tpl_dir): + return + existing = app.jinja_loader + loaders = [FileSystemLoader(tpl_dir)] + if existing is not None: + if isinstance(existing, ChoiceLoader): + loaders = list(existing.loaders) + loaders + else: + loaders.insert(0, existing) + app.jinja_loader = ChoiceLoader(loaders) + + +def install_hedge_plan(app: Flask, repo_root: str, app_module: Any) -> None: + attach_hedge_plan_templates(app, repo_root) + cfg = _build_cfg(app_module) + app.extensions["hedge_plan_cfg"] = cfg + register_hedge_plan_routes(app, cfg) + _maybe_start_monitor(cfg) + + +def _build_cfg(app_module: Any) -> dict[str, Any]: + from lib.exchange.okx_options_lib import ( + build_option_chain, + fetch_index_price, + options_header_balances, + place_option_limit_order, + quote_option_contract, + td_mode_for_option_buy, + ) + + def _amount_to_precision(sym: str, amt: float) -> float: + ex = getattr(app_module, "exchange", None) + if ex is None: + return float(amt) + return float(ex.amount_to_precision(sym, amt)) + + return { + "get_db": app_module.get_db, + "login_required": app_module.login_required, + "render_main_page": app_module.render_main_page, + "exchange": getattr(app_module, "exchange", None), + "exchange_options": getattr(app_module, "exchange_options", None), + "get_available_trading_usdt": getattr(app_module, "get_available_trading_usdt", None), + "get_contract_size": getattr(app_module, "get_contract_size", None), + "normalize_exchange_symbol": getattr(app_module, "normalize_exchange_symbol", None), + "ensure_markets_loaded": getattr(app_module, "ensure_markets_loaded", None), + "ensure_okx_live_ready": getattr(app_module, "ensure_okx_live_ready", None), + "place_exchange_order": getattr(app_module, "place_exchange_order", None), + "get_live_position_contracts": getattr(app_module, "get_live_position_contracts", None), + "amount_to_precision": _amount_to_precision, + "build_option_chain": build_option_chain, + "options_header_balances": options_header_balances, + "quote_option_contract": quote_option_contract, + "place_option_limit_order": place_option_limit_order, + "td_mode_for_option_buy": td_mode_for_option_buy, + "fetch_index_price": fetch_index_price, + "options_td_mode": (os.getenv("OKX_OPTIONS_TD_MODE") or "isolated").strip(), + "btc_leverage": int(getattr(app_module, "BTC_LEVERAGE", 10) or 10), + "alt_leverage": int(getattr(app_module, "ALT_LEVERAGE", 5) or 5), + "full_margin_buffer": float(getattr(app_module, "FULL_MARGIN_BUFFER_RATIO", 0.98) or 0.98), + "funds_decimals": int(getattr(app_module, "FUNDS_DECIMALS", 2) or 2), + "options_enabled": _env_bool("OKX_OPTIONS_ENABLED", False), + "default_underly": (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper(), + "chain_max_dte": float(os.getenv("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS") or os.getenv("OKX_OPTIONS_MAX_DTE_DAYS") or "14"), + "perp_account_label": (os.getenv("OKX_ACCOUNT_LABEL") or "合约账户").strip(), + "options_account_label": (os.getenv("OKX_OPTIONS_ACCOUNT_LABEL") or "期权账户").strip(), + "live_trading": _env_bool("LIVE_TRADING_ENABLED", False), + "send_wechat": getattr(app_module, "send_wechat_msg", None), + } + + +def _hedge_enabled() -> bool: + return _env_bool("HEDGE_PLAN_ENABLED", False) + + +def _live_order() -> bool: + return _env_bool("HEDGE_PLAN_LIVE_ORDER", False) + + +def _max_active() -> int: + try: + return max(1, int(os.getenv("MAX_ACTIVE_HEDGE_PLANS") or "1")) + except ValueError: + return 1 + + +def _gates_dict(cfg: dict[str, Any], plan_type: str) -> dict[str, Any]: + active = 0 + try: + from lib.hedge_plan.hedge_plan_db import count_active_plans, init_hedge_plan_tables + + conn = cfg["get_db"]() + try: + init_hedge_plan_tables(conn) + active = count_active_plans(conn) + conn.commit() + finally: + conn.close() + except Exception: + active = 0 + return gate_status( + hedge_enabled=_hedge_enabled(), + sizing_mode=load_position_sizing_mode(), + plan_type=plan_type, + options_enabled=bool(cfg.get("options_enabled")), + live_order=_live_order(), + live_trading=bool(cfg.get("live_trading")) or _env_bool("LIVE_TRADING_ENABLED", False), + active_count=active, + max_active=_max_active(), + ) + + +def _maybe_start_monitor(cfg: dict[str, Any]) -> None: + if not _hedge_enabled(): + return + try: + secs = float(os.getenv("HEDGE_PLAN_MONITOR_POLL_SECONDS") or "15") + except ValueError: + secs = 15.0 + secs = max(5.0, secs) + + def _loop() -> None: + import time + + from lib.hedge_plan.hedge_plan_monitor_lib import tick_active_plans + + while True: + try: + tick_active_plans(cfg) + except Exception: + pass + time.sleep(secs) + + import threading + + t = threading.Thread(target=_loop, name="hedge-plan-monitor", daemon=True) + t.start() + cfg["hedge_monitor_thread"] = t + + +def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any]) -> int: + from lib.hedge_plan.hedge_plan_db import ( + get_plan, + get_plan_legs, + init_hedge_plan_tables, + insert_leg, + insert_plan, + ) + from lib.hedge_plan.hedge_plan_notify_lib import notify_plan_start + + conn = cfg["get_db"]() + try: + init_hedge_plan_tables(conn) + opt = result.get("option") or {} + perp = result.get("perp") or {} + premium = float(opt.get("premium") or 0) + plan_id = insert_plan( + conn, + { + "plan_type": "perp_options", + "status": "active", + "underlying": str(body.get("underlying") or "ETH").upper(), + "direction": str(body.get("direction") or "long"), + "entry_mark": float(body.get("entry") or 0), + "tp": float(body.get("tp") or 0), + "sl": float(body.get("sl") or 0), + "sizing_mode_at_open": load_position_sizing_mode(), + "perp_size": float(perp.get("contracts") or body.get("contracts") or 0), + "margin": body.get("margin"), + "leverage": float(body.get("leverage") or 10), + "premium_total": premium, + "opened_at": result.get("opened_at"), + }, + ) + insert_leg( + conn, + { + "plan_id": plan_id, + "leg_role": "perp", + "symbol": str(body.get("exchange_symbol") or ""), + "side": str(body.get("direction") or "long"), + "size": float(perp.get("contracts") or body.get("contracts") or 0), + "avg_open": float(body.get("entry") or 0), + "status": "open", + "exchange_ord_id": str(perp.get("exchange_ord_id") or ""), + "opened_at": result.get("opened_at"), + }, + ) + insert_leg( + conn, + { + "plan_id": plan_id, + "leg_role": "option_hedge", + "inst_id": str(opt.get("inst_id") or body.get("opt_inst_id") or ""), + "opt_type": str(opt.get("opt_type") or body.get("opt_type") or ""), + "strike": opt.get("strike") or body.get("strike"), + "side": "buy", + "size": float(opt.get("sheets") or body.get("sheets") or 1), + "avg_open": float(opt.get("ask") or 0), + "premium": premium, + "status": "open", + "exchange_ord_id": str(opt.get("exchange_ord_id") or ""), + "opened_at": result.get("opened_at"), + }, + ) + conn.commit() + plan = get_plan(conn, plan_id) + legs = get_plan_legs(conn, plan_id) + if plan: + notify_plan_start(cfg, conn, plan, legs) + conn.commit() + return plan_id + finally: + conn.close() + + +def _persist_oo(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any]) -> int: + from lib.hedge_plan.hedge_plan_db import ( + get_plan, + get_plan_legs, + init_hedge_plan_tables, + insert_leg, + insert_plan, + ) + from lib.hedge_plan.hedge_plan_notify_lib import notify_plan_start + + conn = cfg["get_db"]() + try: + init_hedge_plan_tables(conn) + a = result.get("leg_a") or {} + b = result.get("leg_b") or {} + premium = float(a.get("premium") or 0) + float(b.get("premium") or 0) + plan_id = insert_plan( + conn, + { + "plan_type": "options_options", + "status": "active", + "underlying": str(body.get("underlying") or "ETH").upper(), + "target_price": float( + body.get("target_price_up") + or body.get("target_price") + or 0 + ), + "target_price_up": float( + body.get("target_price_up") + or body.get("target_price") + or 0 + ), + "target_price_down": float( + body.get("target_price_down") + or body.get("target_price") + or 0 + ), + "sizing_mode_at_open": load_position_sizing_mode(), + "premium_total": premium, + "opened_at": result.get("opened_at"), + }, + ) + for role, res, src in (("option_a", a, body.get("leg_a") or {}), ("option_b", b, body.get("leg_b") or {})): + insert_leg( + conn, + { + "plan_id": plan_id, + "leg_role": role, + "inst_id": str(res.get("inst_id") or src.get("inst_id") or ""), + "opt_type": str(res.get("opt_type") or src.get("opt_type") or ""), + "strike": res.get("strike") or src.get("strike"), + "side": "buy", + "size": float(res.get("sheets") or src.get("sheets") or 1), + "avg_open": float(res.get("ask") or 0), + "premium": float(res.get("premium") or 0), + "status": "open", + "exchange_ord_id": str(res.get("exchange_ord_id") or ""), + "opened_at": result.get("opened_at"), + }, + ) + conn.commit() + plan = get_plan(conn, plan_id) + legs = get_plan_legs(conn, plan_id) + if plan: + notify_plan_start(cfg, conn, plan, legs) + conn.commit() + return plan_id + finally: + conn.close() + + +def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None: + lr = cfg["login_required"] + + @app.route("/hedge-plan") + @lr + def page_hedge_plan(): + from lib.instance.instance_embed_lib import redirect_to_embed_shell_if_enabled + + redir = redirect_to_embed_shell_if_enabled("hedge_plan") + if redir is not None: + return redir + return cfg["render_main_page"]("hedge_plan") + + @app.route("/api/hedge-plan/gates") + @lr + def api_hedge_gates(): + plan_type = (request.args.get("plan_type") or "perp_options").strip() + return jsonify({"ok": True, **_gates_dict(cfg, plan_type)}) + + @app.route("/api/hedge-plan/market") + @lr + def api_hedge_market(): + base = (request.args.get("base") or cfg.get("default_underly") or "ETH").strip().upper() + if base not in ("BTC", "ETH"): + return jsonify({"ok": False, "msg": "对冲计划仅支持 BTC/ETH"}), 400 + direction = (request.args.get("direction") or "long").strip().lower() + if direction not in ("long", "short"): + direction = "long" + data, err = _fetch_perp_market(cfg, base) + if err: + return jsonify({"ok": False, "msg": err}), 400 + sizing_mode = load_position_sizing_mode() + gates = _gates_dict(cfg, "perp_options") + out = { + "ok": True, + "base": base, + "direction": direction, + "suggested_opt_type": "P" if direction == "long" else "C", + **data, + "gates": gates, + "sizing_mode": sizing_mode, + "account_kind": "perp", + "account_label": cfg.get("perp_account_label") or "合约账户", + "account_note": "永续腿使用合约(交易)账户可用 USDT", + } + return jsonify(out) + + @app.route("/api/hedge-plan/options-chain") + @lr + def api_hedge_options_chain(): + if not cfg.get("options_enabled"): + return jsonify({"ok": False, "msg": "期权模块未启用"}), 400 + ex = cfg.get("exchange_options") + if ex is None: + return jsonify({"ok": False, "msg": "期权交易所未初始化"}), 400 + u = (request.args.get("underlying") or cfg.get("default_underly") or "ETH").upper() + try: + chain = cfg["build_option_chain"]( + ex, + u, + max_dte_days=float(cfg.get("chain_max_dte") or 14), + itm_only=False, + itm_max_dist_usd=float(os.getenv("OKX_OPTIONS_ITM_MAX_DIST_USD") or "30"), + ) + except Exception as e: + return jsonify({"ok": False, "msg": f"拉取期权链失败: {e}"}), 500 + opt_acct = _options_account_snapshot(cfg) + return jsonify( + { + "ok": True, + **chain, + "underlying": u, + "chain_max_dte_days": cfg.get("chain_max_dte"), + "account_kind": "options", + "account_label": cfg.get("options_account_label") or "期权账户", + "account_note": "期权腿使用期权账户(交易 USDC)", + "options_account": opt_acct, + } + ) + + @app.route("/api/hedge-plan/preview", methods=["POST"]) + @lr + def api_hedge_preview(): + body = request.get_json(silent=True) or {} + plan_type = (body.get("plan_type") or "perp_options").strip().lower() + gates = _gates_dict(cfg, plan_type) + if not gates.get("can_preview"): + return jsonify({"ok": False, "msg": "; ".join(gates.get("reasons") or ["不可测算"]), "gates": gates}), 400 + try: + if plan_type == "options_options": + data = _preview_oo(body) + else: + data = _preview_po(body) + except ValueError as e: + return jsonify({"ok": False, "msg": str(e)}), 400 + except Exception as e: + return jsonify({"ok": False, "msg": f"测算失败: {e}"}), 500 + return jsonify({"ok": True, "gates": gates, **data}) + + @app.route("/api/hedge-plan/validate-path", methods=["POST"]) + @lr + def api_hedge_validate_path(): + """只校验下单路径(强制 dry_run),不真实成交.""" + from lib.hedge_plan.hedge_plan_orders_lib import ( + execute_options_options_start, + execute_perp_options_start, + validate_start_body, + ) + + body = request.get_json(silent=True) or {} + plan_type = (body.get("plan_type") or "perp_options").strip().lower() + err = validate_start_body(plan_type, body) + if err: + return jsonify({"ok": False, "msg": err}), 400 + if plan_type == "options_options": + out = execute_options_options_start(cfg, body, dry_run=True) + else: + out = execute_perp_options_start(cfg, body, dry_run=True) + return jsonify(out), (200 if out.get("ok") else 400) + + @app.route("/api/hedge-plan/start", methods=["POST"]) + @lr + def api_hedge_start(): + from lib.hedge_plan.hedge_plan_orders_lib import ( + execute_options_options_start, + execute_perp_options_start, + validate_start_body, + ) + + body = request.get_json(silent=True) or {} + plan_type = (body.get("plan_type") or "perp_options").strip().lower() + dry_run = bool(body.get("dry_run")) or _env_bool("HEDGE_PLAN_DRY_RUN", False) + gates = _gates_dict(cfg, plan_type) + if not dry_run and not gates.get("can_start"): + return jsonify( + {"ok": False, "msg": "; ".join(gates.get("reasons") or ["不可开仓"]), "gates": gates} + ), 400 + err = validate_start_body(plan_type, body) + if err: + return jsonify({"ok": False, "msg": err, "gates": gates}), 400 + # 补齐永续杠杆 + if plan_type == "perp_options" and not body.get("leverage"): + base = str(body.get("underlying") or "ETH").upper() + body["leverage"] = cfg.get("btc_leverage") if base == "BTC" else (cfg.get("btc_leverage") or 10) + # ETH 也用 BTC 档 10x 按方案;ALT 为 alt_leverage 仅非 BTC/ETH + if base in ("BTC", "ETH"): + body["leverage"] = int(cfg.get("btc_leverage") or 10) + if plan_type == "options_options": + out = execute_options_options_start( + cfg, + body, + dry_run=dry_run, + persist=(None if dry_run else (lambda r, b: _persist_oo(cfg, r, b))), + ) + else: + out = execute_perp_options_start( + cfg, + body, + dry_run=dry_run, + persist=(None if dry_run else (lambda r, b: _persist_po(cfg, r, b))), + ) + out["gates"] = gates + return jsonify(out), (200 if out.get("ok") else 400) + + @app.route("/api/hedge-plan/list") + @lr + def api_hedge_list(): + from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables, list_plans + + status = (request.args.get("status") or "").strip() or None + plan_type = (request.args.get("plan_type") or "").strip() or None + underlying = (request.args.get("underlying") or "").strip() or None + conn = cfg["get_db"]() + try: + init_hedge_plan_tables(conn) + rows = list_plans( + conn, status=status, plan_type=plan_type, underlying=underlying, limit=80 + ) + conn.commit() + finally: + conn.close() + return jsonify({"ok": True, "plans": rows}) + + @app.route("/api/hedge-plan/history") + @lr + def api_hedge_history(): + from lib.hedge_plan.hedge_plan_db import ( + attach_legs_to_plans, + init_hedge_plan_tables, + list_plans, + ) + + conn = cfg["get_db"]() + try: + init_hedge_plan_tables(conn) + rows = list_plans(conn, status="closed", limit=100) + failed = list_plans(conn, status="failed", limit=50) + cancelled = list_plans(conn, status="cancelled", limit=50) + merged = attach_legs_to_plans(conn, rows + failed + cancelled) + conn.commit() + finally: + conn.close() + return jsonify({"ok": True, "plans": merged}) + + @app.route("/api/hedge-plan/active") + @lr + def api_hedge_active(): + from lib.hedge_plan.hedge_plan_db import ( + attach_legs_to_plans, + init_hedge_plan_tables, + list_plans, + ) + + conn = cfg["get_db"]() + try: + init_hedge_plan_tables(conn) + rows = [] + for status in ("opening", "active", "partial"): + rows.extend(list_plans(conn, status=status, limit=80)) + rows.sort(key=lambda row: int(row.get("id") or 0), reverse=True) + plans = attach_legs_to_plans(conn, rows) + conn.commit() + finally: + conn.close() + return jsonify({"ok": True, "plans": plans}) + + @app.route("/api/hedge-plan/stats") + @lr + def api_hedge_stats(): + from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables, stats_summary + + conn = cfg["get_db"]() + try: + init_hedge_plan_tables(conn) + s = stats_summary(conn) + conn.commit() + finally: + conn.close() + return jsonify({"ok": True, **s}) + + @app.route("/api/hedge-plan/") + @lr + def api_hedge_detail(plan_id: int): + from lib.hedge_plan.hedge_plan_db import ( + get_plan, + get_plan_legs, + init_hedge_plan_tables, + legs_contract_summary, + ) + + conn = cfg["get_db"]() + try: + init_hedge_plan_tables(conn) + plan = get_plan(conn, plan_id) + if not plan: + return jsonify({"ok": False, "msg": "计划不存在"}), 404 + legs = get_plan_legs(conn, plan_id) + conn.commit() + finally: + conn.close() + return jsonify( + { + "ok": True, + "plan": plan, + "legs": legs, + "contracts_summary": legs_contract_summary(legs), + } + ) + + @app.route("/api/hedge-plan/", methods=["DELETE"]) + @lr + def api_hedge_delete(plan_id: int): + from lib.hedge_plan.hedge_plan_db import delete_plan, init_hedge_plan_tables + + conn = cfg["get_db"]() + try: + init_hedge_plan_tables(conn) + out = delete_plan(conn, plan_id) + if not out.get("ok"): + return jsonify(out), 400 + conn.commit() + finally: + conn.close() + return jsonify(out) + + @app.route("/api/hedge-plan/monitor-tick", methods=["POST"]) + @lr + def api_hedge_monitor_tick(): + from lib.hedge_plan.hedge_plan_monitor_lib import tick_active_plans + + return jsonify(tick_active_plans(cfg)) + + +def _preview_po(body: dict[str, Any]) -> dict[str, Any]: + direction = str(body.get("direction") or "long").lower() + entry = float(body["entry"]) + tp = float(body["tp"]) + sl = float(body["sl"]) + contracts = float(body["contracts"]) + contract_size = float(body.get("contract_size") or 0.01) + opt_type = str(body.get("opt_type") or ("P" if direction == "long" else "C")) + strike = float(body["strike"]) + sheets = float(body.get("sheets") or 1) + ct_mult = float(body.get("ct_mult") or 0.01) + ask = body.get("ask") + premium = body.get("premium_paid") + if premium is None: + if ask is None: + raise ValueError("缺少权利金或卖一价") + premium = option_premium_total(ask=float(ask), sheets=sheets, ct_mult=ct_mult) + index_px = body.get("index_px") + return build_perp_options_preview( + direction=direction, + entry=entry, + tp=tp, + sl=sl, + contracts=contracts, + contract_size=contract_size, + opt_type=opt_type, + strike=strike, + sheets=sheets, + ct_mult=ct_mult, + premium_paid=float(premium), + index_px=float(index_px) if index_px is not None else None, + ) + + +def _preview_oo(body: dict[str, Any]) -> dict[str, Any]: + up = body.get("target_price_up") + down = body.get("target_price_down") + legacy = body.get("target_price") + if up in (None, "") and legacy not in (None, ""): + up = legacy + if down in (None, "") and legacy not in (None, ""): + down = legacy + if up in (None, "") or down in (None, ""): + raise ValueError("请填写上破与下破目标价") + up_f = float(up) + down_f = float(down) + if up_f <= down_f: + raise ValueError("上破目标价必须大于下破目标价") + index_px = float(body.get("index_px") or ((up_f + down_f) / 2)) + leg_a = body.get("leg_a") or {} + leg_b = body.get("leg_b") or {} + for name, leg in (("leg_a", leg_a), ("leg_b", leg_b)): + if not leg.get("strike"): + raise ValueError(f"缺少 {name} 行权价") + if leg.get("premium_paid") is None and leg.get("ask") is not None: + leg["premium_paid"] = option_premium_total( + ask=float(leg["ask"]), + sheets=float(leg.get("sheets") or 1), + ct_mult=float(leg.get("ct_mult") or 0.01), + ) + if leg.get("premium_paid") is None: + raise ValueError(f"缺少 {name} 权利金") + return build_options_options_preview( + target_price_up=up_f, + target_price_down=down_f, + index_px=index_px, + leg_a=leg_a, + leg_b=leg_b, + ) + + +def _fetch_perp_market(cfg: dict[str, Any], base: str) -> tuple[dict[str, Any], str | None]: + ex = cfg.get("exchange") + if ex is None: + return {}, "永续交易所未初始化" + ensure = cfg.get("ensure_markets_loaded") + if callable(ensure): + try: + ensure() + except Exception as e: + return {}, f"加载市场失败: {e}" + norm = cfg.get("normalize_exchange_symbol") + sym = f"{base}/USDT:USDT" + if callable(norm): + try: + sym = norm(f"{base}/USDT") + except Exception: + sym = f"{base}/USDT:USDT" + mark = bid = ask = last = None + try: + t = ex.fetch_ticker(sym) + last = _sf(t.get("last")) + mark = _sf(t.get("info", {}).get("markPx")) if isinstance(t.get("info"), dict) else None + if mark is None: + mark = _sf(t.get("mark")) or last + bid = _sf(t.get("bid")) + ask = _sf(t.get("ask")) + except Exception as e: + return {}, f"拉永续行情失败: {e}" + + cs = 0.01 + get_cs = cfg.get("get_contract_size") + if callable(get_cs): + try: + cs = float(get_cs(sym) or 0.01) + except Exception: + cs = 0.01 + + available = None + get_av = cfg.get("get_available_trading_usdt") + if callable(get_av): + try: + available = get_av() + except Exception: + available = None + + entry = float(mark or last or 0) + sizing = None + suggest_contracts = None + amount_precision = 4 + try: + amount_precision = int(amount_decimals_from_exchange(ex, sym)) + except Exception: + amount_precision = 4 + if available is not None and entry > 0: + sizing, _serr = compute_full_margin_sizing( + symbol=sym, + available_usdt=float(available), + capital_base=float(available), + buffer_ratio=float(cfg.get("full_margin_buffer") or 0.98), + btc_leverage=int(cfg.get("btc_leverage") or 10), + alt_leverage=int(cfg.get("alt_leverage") or 5), + funds_decimals=int(cfg.get("funds_decimals") or 2), + ) + if sizing: + raw_contracts = suggest_contracts_from_notional( + notional=float(sizing["notional_value"]), + entry=entry, + contract_size=cs, + ) + # 优先走交易所 amount_to_precision;失败则按精度位数向下取整 + suggest_contracts = None + try: + precise = float(ex.amount_to_precision(sym, raw_contracts)) + if precise > raw_contracts + 1e-12: + precise = floor_contracts_to_precision(raw_contracts, amount_precision) + suggest_contracts = precise + except Exception: + suggest_contracts = floor_contracts_to_precision(raw_contracts, amount_precision) + + return { + "exchange_symbol": sym, + "mark": mark, + "last": last, + "bid": bid, + "ask": ask, + "contract_size": cs, + "available_usdt": available, + "full_margin_sizing": sizing, + "suggest_contracts": suggest_contracts, + "amount_precision": amount_precision, + "unit_quote": "USDT", + "unit_contracts": "合约张", + "unit_note": "价格单位 USDT;张数=交易所永续合约张(与下单精度一致);名义≈张数×面值×价格", + "entry_ref": entry or None, + }, None + + +def _options_account_snapshot(cfg: dict[str, Any]) -> dict[str, Any]: + """期权账户资金快照(与期权页同源: exchange_options).""" + out: dict[str, Any] = { + "label": cfg.get("options_account_label") or "期权账户", + "trading_usdc": None, + "funding_usdc": None, + "trading_usdt": None, + "funding_usdt": None, + } + ex = cfg.get("exchange_options") + hdr = cfg.get("options_header_balances") + if ex is None or not callable(hdr): + return out + try: + trading_usdc, funding_usdc, funding_usdt, trading_usdt = hdr(ex, force=False) + out.update( + { + "trading_usdc": trading_usdc, + "funding_usdc": funding_usdc, + "trading_usdt": trading_usdt, + "funding_usdt": funding_usdt, + } + ) + except Exception: + pass + return out + + +def _sf(v: Any) -> float | None: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None diff --git a/lib/hedge_plan/hedge_plan_settle_lib.py b/lib/hedge_plan/hedge_plan_settle_lib.py new file mode 100644 index 0000000..a864e98 --- /dev/null +++ b/lib/hedge_plan/hedge_plan_settle_lib.py @@ -0,0 +1,62 @@ +"""对冲计划结算辅助:到期内在价值与期权腿收口.""" +from __future__ import annotations + +import time +from typing import Any, Optional + +from lib.exchange.okx_options_lib import normalize_option_exp_ms +from lib.hedge_plan.hedge_plan_calc_lib import option_expiry_pnl + + +def _sf(v: Any) -> Optional[float]: + try: + if v is None or v == "": + return None + return float(v) + except (TypeError, ValueError): + return None + + +def leg_exp_ms(leg: dict[str, Any]) -> Optional[int]: + return normalize_option_exp_ms(leg.get("exp_time"), str(leg.get("inst_id") or "")) + + +def leg_is_expired(leg: dict[str, Any], *, now_ms: Optional[int] = None) -> bool: + exp = leg_exp_ms(leg) + if exp is None: + return False + now = int(now_ms if now_ms is not None else time.time() * 1000) + return now >= int(exp) + + +def settle_option_leg_at_spot(leg: dict[str, Any], spot: float) -> float: + """按到期结算口径估算腿盈亏(USDC).""" + premium = float(leg.get("premium") or 0) + strike = _sf(leg.get("strike")) + if strike is None: + return -premium + sheets = float(leg.get("size") or 1) + # ct_mult 未入库时默认 0.01 + ct = float(leg.get("ct_mult") or 0.01) + return float( + option_expiry_pnl( + opt_type=str(leg.get("opt_type") or "P"), + strike=float(strike), + spot=float(spot), + sheets=sheets, + ct_mult=ct, + premium_paid=premium, + ) + ) + + +def all_option_legs_expired(legs: list[dict[str, Any]], *, now_ms: Optional[int] = None) -> bool: + opts = [ + x + for x in legs + if str(x.get("leg_role") or "").startswith("option") + and str(x.get("status") or "") in ("open", "hold_to_expiry") + ] + if not opts: + return False + return all(leg_is_expired(x, now_ms=now_ms) for x in opts) diff --git a/lib/hedge_plan/templates/hedge_plan_panel.html b/lib/hedge_plan/templates/hedge_plan_panel.html new file mode 100644 index 0000000..20176e0 --- /dev/null +++ b/lib/hedge_plan/templates/hedge_plan_panel.html @@ -0,0 +1,258 @@ +
        + {% if not hedge_plan_enabled %} +
        对冲计划未启用:请在 env配置 → 对冲计划 打开 HEDGE_PLAN_ENABLED(可热更).
        + {% endif %} + {% if not options_enabled %} +
        期权模块未启用,无法拉期权链.请先配置期权账户.
        + {% endif %} + +
        +
        +

        对冲计划 测算 · 下单 + 期权开平仓与监控说明 +

        + +
        +
        + + + + + +
        +

        +

        永续腿→合约账户 · 期权腿→期权账户

        +
        + +
        +
        +
        +

        永续 · ETH 合约账户

        +
        + + + +
        +
        加载中…
        +

        单位说明:价格=USDT · 张数=交易所永续合约张(精度与 OKX 下单一致) · 盈亏=USDT

        +
        + + + + +
        +

        +

        +
        +
        +

        期权(列表) · Put 期权账户

        +
        + + + + + +
        +
        +
        + + + + + + + + + + + + + +
        行权价实虚值卖一/张买一/张操作
        请刷新期权链
        +
        +
        + + + +
        +

        单位说明:权利金结算币=USDC · 张数=期权张(整张) · 卖一/买一=价格/张.期权买入仅认真实卖一价且卖一深度>0;无深度不可开仓(链上~为参考估算).

        +
        +
        + + +
        +
        +
        +
        +

        情景测算

        +
        +
        + + + + + + + + + + + + + + +
        情景现货价永续/腿盈亏期权盈亏合计≈U说明
        填写参数后点计算
        +
        +
        +
        + + + + + + + + + + +
        + diff --git a/lib/hub/__init__.py b/lib/hub/__init__.py new file mode 100644 index 0000000..ab164b5 --- /dev/null +++ b/lib/hub/__init__.py @@ -0,0 +1 @@ +"""Shared library package.""" diff --git a/lib/hub/hub_auth.py b/lib/hub/hub_auth.py new file mode 100644 index 0000000..a9015ab --- /dev/null +++ b/lib/hub/hub_auth.py @@ -0,0 +1,36 @@ +"""中控调用实例 API 时的鉴权(Flask request 头 X-Hub-Token).SSO 见 hub_sso.py.""" +from __future__ import annotations + +import os + +from lib.hub.hub_sso import ( + HUB_SSO_TTL_SEC, + hub_bridge_token, + mint_hub_sso_token, + safe_next_path, + verify_hub_sso_token, +) + +__all__ = [ + "HUB_SSO_TTL_SEC", + "hub_bridge_token", + "mint_hub_sso_token", + "safe_next_path", + "verify_hub_sso_token", + "request_allowed", +] + + +def request_allowed(session_logged_in: bool, auth_disabled: bool) -> bool: + if auth_disabled or session_logged_in: + return True + tok = hub_bridge_token() + if not tok: + return False + try: + from flask import request + except ImportError: + return False + if request.headers.get("X-Hub-Token") == tok: + return True + return False diff --git a/lib/hub/hub_backup_lib.py b/lib/hub/hub_backup_lib.py new file mode 100644 index 0000000..97aef76 --- /dev/null +++ b/lib/hub/hub_backup_lib.py @@ -0,0 +1,447 @@ +"""中控备份与恢复:三所 SQLite,K 线库,env,hub JSON.""" +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import tempfile +import zipfile +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any, Callable, Optional +from zoneinfo import ZoneInfo + +from lib.paths import REPO_ROOT, hub_data_dir, manual_trading_hub_dir + +HUB_DIR = manual_trading_hub_dir() +TZ_NAME = (os.getenv("HUB_BACKUP_TZ") or "Asia/Shanghai").strip() or "Asia/Shanghai" + +EXCHANGE_DIRS: list[tuple[str, str]] = [ + ("binance", "crypto_monitor_binance"), + ("okx", "crypto_monitor_okx"), + ("gate", "crypto_monitor_gate"), +] + +HUB_JSON_FILES = ( + "hub_settings.json", + "hub_fund_history.json", + "hub_ai_summaries.json", + "hub_ai_chat.json", + "hub_supervisor_state.json", +) + +HUB_DATA_FILES = ( + "hub_kline.db", + "hub_symbol_archive.db", + "hub_entry_plans.db", + "hub_macro_calendar.db", + "hub_volume_rank.json", + "hub_divergence_scan.json", +) + +DEFAULT_BACKUP_SETTINGS = { + "auto_enabled": True, + "auto_hour": 0, + "retention_days": 30, + "include_env": True, + "include_exchange_images": False, + "backup_root": "", +} + +BACKUP_STATE_PATH = HUB_DIR / "hub_backup_state.json" + + +def normalize_backup_settings(raw: dict | None) -> dict: + out = dict(DEFAULT_BACKUP_SETTINGS) + if isinstance(raw, dict): + for key in DEFAULT_BACKUP_SETTINGS: + if key in raw: + out[key] = raw[key] + try: + out["auto_hour"] = max(0, min(23, int(out.get("auto_hour", 0)))) + except (TypeError, ValueError): + out["auto_hour"] = 0 + try: + out["retention_days"] = max(1, min(365, int(out.get("retention_days", 30)))) + except (TypeError, ValueError): + out["retention_days"] = 30 + out["auto_enabled"] = bool(out.get("auto_enabled")) + out["include_env"] = bool(out.get("include_env", True)) + out["include_exchange_images"] = bool(out.get("include_exchange_images")) + out["backup_root"] = str(out.get("backup_root") or "").strip() + return out + + +def backup_root(settings: dict | None = None) -> Path: + cfg = normalize_backup_settings((settings or {}).get("backup") if settings else None) + raw = cfg.get("backup_root") or (os.getenv("HUB_BACKUP_ROOT") or "").strip() + if not raw: + raw = (os.getenv("BACKUP_ROOT") or "/root/backups").strip() + root = Path(raw).expanduser() + if not root.is_absolute(): + root = REPO_ROOT / root + portal = root / "crypto_monitor_portal" + portal.mkdir(parents=True, exist_ok=True) + return portal + + +def _now_local() -> datetime: + try: + return datetime.now(ZoneInfo(TZ_NAME)) + except Exception: + return datetime.now() + + +def _read_env_var(env_path: Path, key: str, default: str = "") -> str: + if not env_path.is_file(): + return default + try: + for line in env_path.read_text(encoding="utf-8", errors="ignore").splitlines(): + raw = line.strip() + if not raw or raw.startswith("#") or "=" not in raw: + continue + k, v = raw.split("=", 1) + if k.strip() == key: + return v.strip().strip('"').strip("'") + except Exception: + pass + return default + + +def _resolve_project_path(project_dir: Path, rel: str) -> Path: + p = Path(rel or "") + if p.is_absolute(): + return p + return project_dir / p + + +def _load_backup_state() -> dict: + if not BACKUP_STATE_PATH.is_file(): + return {} + try: + data = json.loads(BACKUP_STATE_PATH.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + +def _save_backup_state(state: dict) -> None: + BACKUP_STATE_PATH.write_text( + json.dumps(state, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + +def _safe_archive_name(name: str) -> bool: + return bool(re.fullmatch(r"backup_[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{6}\.zip", name or "")) + + +def _collect_targets( + *, + include_env: bool, + include_exchange_images: bool, +) -> list[tuple[str, Path, str]]: + """Return list of (archive_rel_path, source_path, kind).""" + items: list[tuple[str, Path, str]] = [] + + if include_env: + hub_env = HUB_DIR / ".env" + if hub_env.is_file(): + items.append(("hub/.env", hub_env, "env")) + + for name in HUB_JSON_FILES: + src = HUB_DIR / name + if src.is_file(): + items.append((f"hub/{name}", src, "json")) + + data_dir = hub_data_dir() + for name in HUB_DATA_FILES: + src = data_dir / name + if src.is_file(): + items.append((f"hub/data/{name}", src, "sqlite" if name.endswith(".db") else "json")) + + for key, dirname in EXCHANGE_DIRS: + proj = REPO_ROOT / dirname + prefix = dirname + env_path = proj / ".env" + db_rel = "crypto.db" + upload_rel = "static/images" + if env_path.is_file(): + db_rel = _read_env_var(env_path, "DB_PATH", "crypto.db") or "crypto.db" + upload_rel = _read_env_var(env_path, "UPLOAD_DIR", "static/images") or "static/images" + if include_env: + items.append((f"{prefix}/.env", env_path, "env")) + db_path = _resolve_project_path(proj, db_rel) + if db_path.is_file(): + items.append((f"{prefix}/{db_rel}", db_path, "sqlite")) + if include_exchange_images: + img_dir = _resolve_project_path(proj, upload_rel) + if img_dir.is_dir(): + for fp in sorted(img_dir.rglob("*")): + if fp.is_file(): + rel = fp.relative_to(proj).as_posix() + items.append((f"{prefix}/{rel}", fp, "image")) + return items + + +def _write_manifest(staging: Path, trigger: str, files: list[dict]) -> None: + manifest = { + "version": 1, + "created_at": _now_local().strftime("%Y-%m-%d %H:%M:%S"), + "timezone": TZ_NAME, + "trigger": trigger, + "repo_root": str(REPO_ROOT), + "files": files, + } + (staging / "manifest.json").write_text( + json.dumps(manifest, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + +def run_backup( + *, + trigger: str = "manual", + settings: dict | None = None, + log_fn: Callable[[str], None] | None = None, +) -> dict[str, Any]: + cfg = normalize_backup_settings((settings or {}).get("backup") if settings else None) + root = backup_root(settings) + ts = _now_local().strftime("%Y-%m-%d_%H%M%S") + archive_name = f"backup_{ts}.zip" + archive_path = root / archive_name + + def log(msg: str) -> None: + if log_fn: + log_fn(msg) + + targets = _collect_targets( + include_env=cfg["include_env"], + include_exchange_images=cfg["include_exchange_images"], + ) + if not targets: + return {"ok": False, "error": "没有可备份的文件"} + + file_meta: list[dict] = [] + with tempfile.TemporaryDirectory(prefix="hub_backup_") as tmp: + staging = Path(tmp) + for arc_rel, src, kind in targets: + dest = staging / arc_rel + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dest) + file_meta.append( + { + "path": arc_rel.replace("\\", "/"), + "size": src.stat().st_size, + "kind": kind, + } + ) + _write_manifest(staging, trigger, file_meta) + with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for fp in sorted(staging.rglob("*")): + if fp.is_file(): + zf.write(fp, fp.relative_to(staging).as_posix()) + + size = archive_path.stat().st_size + prune_old_backups(root, cfg["retention_days"]) + state = _load_backup_state() + if trigger == "auto": + state["last_auto_day"] = _now_local().strftime("%Y-%m-%d") + state["last_auto_at"] = _now_local().strftime("%Y-%m-%d %H:%M:%S") + state["last_backup_at"] = _now_local().strftime("%Y-%m-%d %H:%M:%S") + state["last_backup_file"] = archive_name + state["last_trigger"] = trigger + _save_backup_state(state) + log(f"backup written: {archive_path}") + return { + "ok": True, + "file": archive_name, + "path": str(archive_path), + "size": size, + "file_count": len(file_meta), + "trigger": trigger, + } + + +def prune_old_backups(root: Path, retention_days: int) -> int: + if not root.is_dir(): + return 0 + cutoff = _now_local() - timedelta(days=max(1, retention_days)) + removed = 0 + for fp in root.glob("backup_*.zip"): + try: + mtime = datetime.fromtimestamp(fp.stat().st_mtime, tz=cutoff.tzinfo) + except Exception: + continue + if mtime < cutoff: + fp.unlink(missing_ok=True) + removed += 1 + return removed + + +def list_backups(settings: dict | None = None) -> list[dict[str, Any]]: + root = backup_root(settings) + rows: list[dict[str, Any]] = [] + if not root.is_dir(): + return rows + for fp in sorted(root.glob("backup_*.zip"), reverse=True): + try: + st = fp.stat() + except OSError: + continue + rows.append( + { + "name": fp.name, + "size": st.st_size, + "modified_at": datetime.fromtimestamp(st.st_mtime).strftime("%Y-%m-%d %H:%M:%S"), + } + ) + return rows + + +def backup_status(settings: dict | None = None) -> dict[str, Any]: + cfg = normalize_backup_settings((settings or {}).get("backup") if settings else None) + state = _load_backup_state() + root = backup_root(settings) + return { + "ok": True, + "settings": cfg, + "backup_root": str(root), + "state": state, + "backups": list_backups(settings)[:50], + "timezone": TZ_NAME, + } + + +def _pm2_restart_all() -> dict[str, Any]: + if os.name != "posix": + return {"ok": False, "skipped": True, "reason": "non-posix"} + try: + proc = subprocess.run( + ["pm2", "restart", "all"], + capture_output=True, + text=True, + timeout=120, + ) + return { + "ok": proc.returncode == 0, + "returncode": proc.returncode, + "stdout": (proc.stdout or "")[-2000:], + "stderr": (proc.stderr or "")[-2000:], + } + except Exception as e: + return {"ok": False, "error": str(e)} + + +def restore_backup_archive( + archive_path: Path, + *, + settings: dict | None = None, + pre_backup: bool = True, + restart_pm2: bool = True, +) -> dict[str, Any]: + if not archive_path.is_file(): + return {"ok": False, "error": "备份文件不存在"} + + pre = None + if pre_backup: + pre = run_backup(trigger="pre_restore", settings=settings) + + restored: list[str] = [] + skipped: list[str] = [] + with tempfile.TemporaryDirectory(prefix="hub_restore_") as tmp: + extract_dir = Path(tmp) + with zipfile.ZipFile(archive_path, "r") as zf: + zf.extractall(extract_dir) + manifest_path = extract_dir / "manifest.json" + if not manifest_path.is_file(): + return {"ok": False, "error": "无效的备份包:缺少 manifest.json"} + + for fp in extract_dir.rglob("*"): + if not fp.is_file() or fp.name == "manifest.json": + continue + rel = fp.relative_to(extract_dir).as_posix() + parts = Path(rel).parts + if parts[0] == "hub": + if len(parts) >= 3 and parts[1] == "data": + dest = hub_data_dir() / parts[-1] + else: + dest = HUB_DIR.joinpath(*parts[1:]) + else: + matched = False + for _key, dirname in EXCHANGE_DIRS: + if rel.startswith(dirname + "/"): + dest = REPO_ROOT / rel + matched = True + break + if not matched: + skipped.append(rel) + continue + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(fp, dest) + restored.append(rel) + + pm2 = _pm2_restart_all() if restart_pm2 else {"ok": False, "skipped": True} + state = _load_backup_state() + state["last_restore_at"] = _now_local().strftime("%Y-%m-%d %H:%M:%S") + state["last_restore_from"] = archive_path.name + _save_backup_state(state) + return { + "ok": True, + "restored": restored, + "skipped": skipped, + "pre_backup": pre, + "pm2": pm2, + } + + +def restore_backup_upload( + content: bytes, + filename: str, + *, + settings: dict | None = None, +) -> dict[str, Any]: + if not content: + return {"ok": False, "error": "空文件"} + suffix = Path(filename or "").suffix.lower() + if suffix != ".zip": + return {"ok": False, "error": "仅支持 .zip 备份包"} + with tempfile.NamedTemporaryFile(prefix="hub_restore_upload_", suffix=".zip", delete=False) as tf: + tf.write(content) + temp_path = Path(tf.name) + try: + return restore_backup_archive(temp_path, settings=settings) + finally: + temp_path.unlink(missing_ok=True) + + +def resolve_backup_download(settings: dict | None, name: str) -> Optional[Path]: + if not _safe_archive_name(name): + return None + fp = backup_root(settings) / name + if fp.is_file(): + return fp + return None + + +def should_run_auto_backup(settings: dict) -> bool: + cfg = normalize_backup_settings(settings.get("backup")) + if not cfg.get("auto_enabled"): + return False + now = _now_local() + today = now.strftime("%Y-%m-%d") + state = _load_backup_state() + if state.get("last_auto_day") == today: + return False + if now.hour < int(cfg.get("auto_hour", 0)): + return False + return True + + +def mark_auto_backup_done() -> None: + state = _load_backup_state() + state["last_auto_day"] = _now_local().strftime("%Y-%m-%d") + state["last_auto_at"] = _now_local().strftime("%Y-%m-%d %H:%M:%S") + _save_backup_state(state) diff --git a/lib/hub/hub_bridge.py b/lib/hub/hub_bridge.py new file mode 100644 index 0000000..8f682e5 --- /dev/null +++ b/lib/hub/hub_bridge.py @@ -0,0 +1,1117 @@ +""" +各 crypto_monitor_* 注册 /api/hub/* JSON 接口,供 manual_trading_hub 调用. +实例末尾:app.config["HUB_CTX"] = {...}; register_hub_routes(app) +""" + +from __future__ import annotations + +import json +import time +from functools import wraps + +from flask import ( + current_app, + flash, + get_flashed_messages, + jsonify, + redirect, + request, + session, +) + +from lib.hub.hub_auth import request_allowed +from lib.hub.hub_sso import ( + mint_hub_embed_bootstrap, + safe_next_path, + verify_hub_embed_bootstrap, + verify_hub_sso_token, +) + + +def _merge_query_into_path(path: str, **params: str) -> str: + from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + + split = urlsplit(path or "/") + q = list(parse_qsl(split.query, keep_blank_values=True)) + keys = {k for k, _ in q} + for k, v in params.items(): + if not v or k in keys: + continue + q.append((k, str(v))) + return urlunsplit((split.scheme, split.netloc, split.path, urlencode(q), split.fragment)) + + +def install_instance_theme_static(app) -> None: + """仓库 lib/common/static 下 instance_theme.* 等供三所页面共用.""" + import os + + from flask import Response, send_file + + from lib.paths import common_static_dir + + repo_static = common_static_dir() + assets = { + "instance_theme.js": "application/javascript; charset=utf-8", + "instance_theme_early.css": "text/css; charset=utf-8", + "instance_theme.css": "text/css; charset=utf-8", + "account_risk_badge.css": "text/css; charset=utf-8", + "account_risk_badge.js": "application/javascript; charset=utf-8", + "instance_ui.js": "application/javascript; charset=utf-8", + "instance_records_mobile.js": "application/javascript; charset=utf-8", + "records_review_page.js": "application/javascript; charset=utf-8", + "ai_review_render.js": "application/javascript; charset=utf-8", + "form_submit_guard.js": "application/javascript; charset=utf-8", + "key_monitor_form.js": "application/javascript; charset=utf-8", + "time_close_ui.js": "application/javascript; charset=utf-8", + "manual_order_rr_preview.js": "application/javascript; charset=utf-8", + "symbol_live_price.js": "application/javascript; charset=utf-8", + "journal_upload_slots.js": "application/javascript; charset=utf-8", + "strategy_roll.js": "application/javascript; charset=utf-8", + "instance_page.css": "text/css; charset=utf-8", + "instance_embed.js": "application/javascript; charset=utf-8", + "instance_stats.js": "application/javascript; charset=utf-8", + "instance_live.js": "application/javascript; charset=utf-8", + "instance_settings_prefs.js": "application/javascript; charset=utf-8", + "instance_dashboard.js": "application/javascript; charset=utf-8", + "options_expiry_countdown.js": "application/javascript; charset=utf-8", + "options_panel.js": "application/javascript; charset=utf-8", + "order_entry_model.js": "application/javascript; charset=utf-8", + "focus_chart_page.js": "application/javascript; charset=utf-8", + "focus_chart_page.css": "text/css; charset=utf-8", + "trade_stats_calendar.js": "application/javascript; charset=utf-8", + "trade_stats_calendar.css": "text/css; charset=utf-8", + } + + for name, mime in assets.items(): + path = os.path.join(repo_static, name) + + def _view(p=path, m=mime): + if not os.path.isfile(p): + return Response("not found", status=404, mimetype="text/plain; charset=utf-8") + return send_file(p, mimetype=m) + + app.add_url_rule( + f"/static/{name}", + endpoint=f"repo_static_{name.replace('.', '_')}", + view_func=_view, + ) + + +def register_trade_stats_calendar_route( + app, + *, + login_required_fn, + load_pnls_fn, + row_matches_segment_fn, + reset_hour: int, + get_db_fn=None, +): + """三所统计分析页:按月返回各交易日盈亏/笔数.""" + from flask import jsonify, request + + from lib.trade.trade_stats_calendar_lib import build_trade_stats_calendar + + @app.route("/api/stats/calendar") + @login_required_fn + def api_stats_calendar(): + year = request.args.get("year", type=int) + month = request.args.get("month", type=int) + segment = (request.args.get("segment") or "all").strip() or "all" + if not year or not month: + from datetime import datetime + + now = datetime.now() + year = year or now.year + month = month or now.month + get_db = get_db_fn or (app.config.get("HUB_CTX") or {}).get("get_db") + if not get_db: + return jsonify({"ok": False, "msg": "未配置数据库"}), 500 + conn = get_db() + try: + pnls = load_pnls_fn(conn) + finally: + conn.close() + try: + payload = build_trade_stats_calendar( + pnls, + year, + month, + segment, + row_matches_segment_fn, + reset_hour=int(reset_hour), + ) + except ValueError as exc: + return jsonify({"ok": False, "msg": str(exc)}), 400 + return jsonify({"ok": True, **payload}) + + +def _hub_auth_required(f): + @wraps(f) + def wrapped(*args, **kwargs): + from flask import current_app as cap + + auth_disabled = bool(cap.config.get("HUB_AUTH_DISABLED")) + if not request_allowed(bool(session.get("logged_in")), auth_disabled): + return jsonify({"ok": False, "msg": "未授权(登录或 HUB_BRIDGE_TOKEN)"}), 401 + return f(*args, **kwargs) + + return wrapped + + +def _ctx(): + return current_app.config.get("HUB_CTX") or {} + + +def _row_to_dict(row): + fn = _ctx().get("row_to_dict") + if fn and row is not None: + return fn(row) + return dict(row) if row is not None else {} + + +def build_hub_monitor_payload( + *, + keys, + orders, + trends, + rolls, + hedges=None, + enrich=None, + risk_status=None, +) -> dict: + """合并 enrich 增量字段;enrich 只返回 trends 等局部时不得丢掉 keys/orders.""" + payload = { + "ok": True, + "keys": keys, + "orders": orders, + "trends": trends, + "rolls": rolls, + "hedges": hedges if isinstance(hedges, list) else [], + "key_prices": [], + } + if isinstance(risk_status, dict): + payload["risk_status"] = risk_status + if callable(enrich): + extra = enrich(keys=keys, orders=orders, trends=trends, rolls=rolls) + if isinstance(extra, dict): + payload.update(extra) + # enrich 可能不返回 hedges,保留本地组装的对冲列表. + if "hedges" not in extra: + payload["hedges"] = hedges if isinstance(hedges, list) else [] + return payload + + +_FAIL_HINTS = ( + "失败", + "错误", + "拒绝", + "无效", + "缺少", + "无法", + "过期", + "未达", + "不能为空", + "已有", + "不允许", + "异常", +) + + +def _invoke_view(view_name: str, path: str, form=None) -> dict: + views = _ctx().get("views") or {} + view = views.get(view_name) + if not view: + return {"ok": False, "messages": [f"未配置视图 {view_name}"]} + data = form if form is not None else request.form + if hasattr(data, "items") and not isinstance(data, dict): + data = {k: v for k, v in data.items()} + with current_app.test_request_context(path, method="POST", data=data): + session["logged_in"] = True + try: + view() + except Exception as e: + return {"ok": False, "messages": [str(e)]} + try: + msgs = [str(x) for x in get_flashed_messages()] + except Exception as e: + return {"ok": False, "messages": [f"读取提示信息失败: {e}"]} + ok = True + for m in msgs: + if any(k in m for k in _FAIL_HINTS): + ok = False + break + return {"ok": ok, "messages": msgs} + + +def _invoke_view_get(view_name: str, path: str) -> dict: + views = _ctx().get("views") or {} + view = views.get(view_name) + if not view: + return {"ok": False, "messages": [f"未配置视图 {view_name}"]} + with current_app.test_request_context(path, method="GET"): + session["logged_in"] = True + try: + view() + except Exception as e: + return {"ok": False, "messages": [str(e)]} + try: + msgs = [str(x) for x in get_flashed_messages()] + except Exception as e: + return {"ok": False, "messages": [f"读取提示信息失败: {e}"]} + ok = True + for m in msgs: + if any(k in m for k in _FAIL_HINTS): + ok = False + break + return {"ok": ok, "messages": msgs} + + +def _hub_json(view_name: str, path: str, form=None): + try: + return jsonify(_invoke_view(view_name, path, form=form)) + except Exception as e: + return jsonify({"ok": False, "messages": [str(e)]}) + + +def _embed_login_dest(next_path: str) -> str: + """embed=1 时把 /trade 等映射到 /embed?tab=…""" + ht = (request.args.get("hub_theme") or "").strip().lower() + hub_theme = ht if ht in ("light", "dark") else None + if request.args.get("embed", "").strip().lower() in ("1", "true", "yes", "on"): + from lib.instance.instance_embed_lib import rewrite_embed_dest + + return rewrite_embed_dest(next_path, hub_theme=hub_theme) + if hub_theme: + return _merge_query_into_path(next_path, hub_theme=hub_theme) + return next_path + + +def install_on_app( + app, + *, + exchange: str, + capabilities: list, + has_trend: bool, + get_db, + row_to_dict, + meta_fn, + views: dict, + ohlcv_fn=None, + account_fn=None, + volume_rank_fn=None, + market_fn=None, + reconcile_hub_flat_fn=None, + risk_status_fn=None, + user_close_fn=None, + render_main_page_fn=None, + login_required_fn=None, +): + app.config["HUB_CTX"] = { + "exchange": exchange, + "capabilities": list(capabilities), + "has_trend": bool(has_trend), + "get_db": get_db, + "row_to_dict": row_to_dict, + "meta_fn": meta_fn, + "account_fn": account_fn, + "views": views, + "ohlcv_fn": ohlcv_fn, + "volume_rank_fn": volume_rank_fn, + "market_fn": market_fn, + "reconcile_hub_flat_fn": reconcile_hub_flat_fn, + "risk_status_fn": risk_status_fn, + "user_close_fn": user_close_fn, + } + install_hub_embed_headers(app) + configure_hub_embed_session(app) + install_instance_theme_static(app) + register_hub_routes(app) + if render_main_page_fn and login_required_fn: + from lib.instance.instance_embed_lib import attach_embed_templates, register_embed_routes + from lib.paths import REPO_ROOT + + attach_embed_templates(app, str(REPO_ROOT)) + register_embed_routes(app, login_required_fn, render_main_page_fn) + + +def configure_hub_embed_session(app): + """HTTPS iframe 内嵌须 SameSite=None + Secure;hub-sso / hub-embed-auth 自动启用.""" + import os + + allowed = (os.getenv("APP_ALLOW_HUB_EMBED") or "true").strip().lower() in ( + "1", + "true", + "yes", + "on", + ) + if not allowed: + return + + secure_env = (os.getenv("APP_COOKIE_SECURE") or "auto").strip().lower() + if secure_env in ("1", "true", "yes", "on"): + app.config.update( + SESSION_COOKIE_SECURE=True, + SESSION_COOKIE_SAMESITE="None", + SESSION_COOKIE_HTTPONLY=True, + ) + return + + @app.before_request + def _hub_embed_session_cookie(): + if request.path not in ("/hub-sso", "/hub-embed-auth"): + return + embed = (request.args.get("embed") or "").strip().lower() in ( + "1", + "true", + "yes", + "on", + ) + in_iframe = (request.headers.get("Sec-Fetch-Dest") or "").lower() == "iframe" + if not embed and not in_iframe: + return + if not request.is_secure: + return + app.config["SESSION_COOKIE_SECURE"] = True + app.config["SESSION_COOKIE_SAMESITE"] = "None" + app.config["SESSION_COOKIE_HTTPONLY"] = True + + +def _sso_wants_embed_auth() -> bool: + embed = (request.args.get("embed") or "").strip().lower() in ( + "1", + "true", + "yes", + "on", + ) + in_iframe = (request.headers.get("Sec-Fetch-Dest") or "").lower() == "iframe" + return bool(embed or in_iframe) + + +def install_hub_embed_headers(app): + """允许复盘中控 iframe 内嵌打开本实例(须与 hub 的 HUB_EMBED_ORIGINS 或域名一致).""" + import os + + allowed = (os.getenv("APP_ALLOW_HUB_EMBED") or "true").strip().lower() in ( + "1", + "true", + "yes", + "on", + ) + if not allowed: + return + origins = ( + (os.getenv("HUB_EMBED_PARENT_ORIGINS") or os.getenv("HUB_EMBED_ORIGINS") or "*") + .strip() + ) + + @app.after_request + def _hub_embed_frame_headers(response): + if origins == "*": + response.headers["Content-Security-Policy"] = "frame-ancestors *" + else: + response.headers["Content-Security-Policy"] = ( + f"frame-ancestors 'self' {origins}" + ) + return response + + +def register_hub_routes(app): + auth_disabled = False + try: + import os + + auth_disabled = os.getenv("APP_AUTH_DISABLED", "false").lower() in ( + "1", + "true", + "yes", + "on", + ) + except Exception: + pass + app.config.setdefault("HUB_AUTH_DISABLED", auth_disabled) + + @app.route("/api/hub/ping") + @_hub_auth_required + def api_hub_ping(): + c = _ctx() + return jsonify( + { + "ok": True, + "exchange": c.get("exchange"), + "capabilities": c.get("capabilities") or [], + } + ) + + @app.route("/api/hub/meta") + @_hub_auth_required + def api_hub_meta(): + c = _ctx() + meta_fn = c.get("meta_fn") + meta = meta_fn() if callable(meta_fn) else {} + return jsonify({"ok": True, "meta": meta}) + + @app.route("/api/hub/account") + @_hub_auth_required + def api_hub_account(): + """中控 AI:资金账户 / 交易账户余额(无需浏览器登录).""" + fn = _ctx().get("account_fn") + if not callable(fn): + return jsonify({"ok": False, "msg": "未配置 account_fn"}), 501 + try: + data = fn() + if not isinstance(data, dict): + data = {} + return jsonify({"ok": True, **data}) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + @app.route("/api/hub/options/snapshot") + @_hub_auth_required + def api_hub_options_snapshot(): + """中控监控:期权持仓 / 资金 / 本地统计(只读).""" + fn = _ctx().get("options_snapshot_fn") + if not callable(fn): + return jsonify({"ok": True, "enabled": False}) + try: + data = fn() + if not isinstance(data, dict): + data = {"ok": False, "enabled": True, "msg": "invalid snapshot"} + return jsonify(data) + except Exception as e: + return jsonify({"ok": False, "enabled": True, "msg": str(e)}), 500 + + @app.route("/api/account_risk_status") + @_hub_auth_required + def api_account_risk_status(): + c = _ctx() + get_db = c.get("get_db") + risk_fn = c.get("risk_status_fn") + if not callable(get_db) or not callable(risk_fn): + return jsonify({"ok": False, "msg": "未配置风控"}), 501 + conn = get_db() + try: + payload = risk_fn(conn) + return jsonify({"ok": True, **(payload if isinstance(payload, dict) else {})}) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + finally: + conn.close() + + @app.route("/api/hub/account-risk/user-close", methods=["POST"]) + @_hub_auth_required + def api_hub_account_risk_user_close(): + """中控/实例:登记用户主动平仓(计入冷静期与日冻结).""" + c = _ctx() + get_db = c.get("get_db") + user_close_fn = c.get("user_close_fn") + if not callable(get_db) or not callable(user_close_fn): + return jsonify({"ok": False, "msg": "未配置 user_close_fn"}), 501 + body = request.get_json(silent=True) or {} + source = (body.get("source") or request.form.get("source") or "").strip() + try: + count = max(0, int(body.get("count") if body.get("count") is not None else 1)) + except (TypeError, ValueError): + count = 1 + trade_record_id = body.get("trade_record_id") + closed_at_ms = body.get("closed_at_ms") + if count <= 0: + return jsonify({"ok": True, "skipped": True, "count": 0}) + conn = get_db() + try: + user_close_fn( + conn, + source=source, + count=count, + trade_record_id=trade_record_id, + closed_at_ms=closed_at_ms, + ) + conn.commit() + return jsonify({"ok": True, "count": count, "source": source}) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + finally: + conn.close() + + @app.route("/api/hub/monitor") + @_hub_auth_required + def api_hub_monitor(): + c = _ctx() + get_db = c.get("get_db") + if not get_db: + return jsonify({"ok": False, "msg": "HUB_CTX 缺少 get_db"}), 500 + conn = get_db() + keys = [] + for row in conn.execute("SELECT * FROM key_monitors ORDER BY id DESC").fetchall(): + keys.append(_row_to_dict(row)) + orders = [] + for row in conn.execute( + "SELECT * FROM order_monitors WHERE status='active' ORDER BY id DESC" + ).fetchall(): + od = _row_to_dict(row) + try: + from lib.strategy.strategy_trade_labels import apply_order_monitor_source_labels + + od = apply_order_monitor_source_labels(od) + except Exception: + pass + try: + from lib.trade.entry_model_lib import enrich_entry_model_display + + enrich_entry_model_display(od) + except Exception: + pass + orders.append(od) + trends = [] + if c.get("has_trend"): + for row in conn.execute( + "SELECT * FROM trend_pullback_plans WHERE status='active' ORDER BY id DESC" + ).fetchall(): + trends.append(_row_to_dict(row)) + rolls = [] + try: + for row in conn.execute( + """SELECT g.* FROM roll_groups g + INNER JOIN order_monitors m ON m.id = g.order_monitor_id AND m.status='active' + WHERE g.status='active' ORDER BY g.id DESC""" + ).fetchall(): + rolls.append(_row_to_dict(row)) + except Exception: + pass + hedges = [] + try: + from lib.hedge_plan.hedge_plan_db import attach_legs_to_plans, list_plans + + hedge_rows: list = [] + for st in ("opening", "active", "partial"): + hedge_rows.extend(list_plans(conn, status=st, limit=80)) + hedge_rows.sort(key=lambda row: int(row.get("id") or 0), reverse=True) + hedges = attach_legs_to_plans(conn, hedge_rows) + except Exception: + hedges = [] + risk_status = None + risk_fn = c.get("risk_status_fn") + if callable(risk_fn): + try: + risk_status = risk_fn(conn) + except Exception: + risk_status = None + conn.close() + enrich = c.get("enrich_monitor") + if callable(enrich): + try: + return jsonify( + build_hub_monitor_payload( + keys=keys, + orders=orders, + trends=trends, + rolls=rolls, + hedges=hedges, + enrich=enrich, + risk_status=risk_status, + ) + ) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + return jsonify( + build_hub_monitor_payload( + keys=keys, + orders=orders, + trends=trends, + rolls=rolls, + hedges=hedges, + risk_status=risk_status, + ) + ) + + @app.route("/api/hub/trades/archive") + @_hub_auth_required + def api_hub_trades_archive(): + """中控币种档案:近 N 天已平仓记录.""" + from lib.hub.hub_trades_lib import fetch_trades_for_archive, summarize_trades + + c = _ctx() + get_db = c.get("get_db") + if not get_db: + return jsonify({"ok": False, "msg": "HUB_CTX 缺少 get_db"}), 500 + try: + days = int(request.args.get("days") or "365") + except ValueError: + days = 365 + try: + limit = int(request.args.get("limit") or "2000") + except ValueError: + limit = 2000 + try: + import os + + reset_hour = int(os.getenv("TRADING_DAY_RESET_HOUR", "8") or "8") + except ValueError: + reset_hour = 8 + conn = get_db() + try: + trades = fetch_trades_for_archive( + conn, + exchange_key=str(c.get("exchange") or ""), + days=days, + row_to_dict_fn=c.get("row_to_dict"), + reset_hour=reset_hour, + limit=limit, + ) + finally: + conn.close() + stats = summarize_trades(trades) + return jsonify( + { + "ok": True, + "days": max(1, min(days, 3650)), + "trading_day_reset_hour": reset_hour, + "trades": trades, + "stats": stats, + } + ) + + @app.route("/api/hub/trades/today") + @_hub_auth_required + def api_hub_trades_today(): + """中控 AI:当日已平仓记录(按实例交易日).""" + from lib.hub.hub_trades_lib import ( + current_trading_day, + fetch_trades_for_trading_day, + summarize_trades, + ) + from lib.trade.daily_open_limit_lib import count_opens_for_trading_day + + c = _ctx() + get_db = c.get("get_db") + if not get_db: + return jsonify({"ok": False, "msg": "HUB_CTX 缺少 get_db"}), 500 + day_arg = (request.args.get("trading_day") or request.args.get("date") or "").strip()[:10] + try: + import os + + reset_hour = int(os.getenv("TRADING_DAY_RESET_HOUR", "8") or "8") + except ValueError: + reset_hour = 8 + trading_day = day_arg or current_trading_day(reset_hour=reset_hour) + conn = get_db() + try: + trades = fetch_trades_for_trading_day( + conn, + trading_day, + row_to_dict_fn=c.get("row_to_dict"), + reset_hour=reset_hour, + ) + opens_today = count_opens_for_trading_day(conn, trading_day) + finally: + conn.close() + stats = summarize_trades(trades) + return jsonify( + { + "ok": True, + "trading_day": trading_day, + "trading_day_reset_hour": reset_hour, + "opens_today": opens_today, + "trades": trades, + "stats": stats, + } + ) + + @app.route("/api/hub/volume-rank") + @_hub_auth_required + def api_hub_volume_rank(): + fn = _ctx().get("volume_rank_fn") + if not callable(fn): + return jsonify({"ok": False, "msg": "该实例未配置成交量排名接口"}), 501 + top_raw = (request.args.get("top") or "").strip() + top_n = 20 + if top_raw.isdigit(): + top_n = int(top_raw) + try: + result = fn(top_n=top_n) + if isinstance(result, dict): + return jsonify(result) + return jsonify({"ok": False, "msg": "成交量排名返回格式无效"}), 500 + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + @app.route("/api/hub/market") + @_hub_auth_required + def api_hub_market(): + fn = _ctx().get("market_fn") + if not callable(fn): + return jsonify({"ok": False, "msg": "该实例未配置合约信息接口"}), 501 + base = (request.args.get("base") or request.args.get("symbol") or "").strip() + try: + result = fn(base=base) + if isinstance(result, dict): + return jsonify(result) + return jsonify({"ok": False, "msg": "合约信息返回格式无效"}), 500 + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + @app.route("/api/hub/ohlcv") + @_hub_auth_required + def api_hub_ohlcv(): + fn = _ctx().get("ohlcv_fn") + if not callable(fn): + return jsonify({"ok": False, "msg": "该实例未配置 OHLCV 接口"}), 501 + symbol = (request.args.get("symbol") or "").strip() + timeframe = (request.args.get("timeframe") or "5m").strip() + since_raw = (request.args.get("since_ms") or "").strip() + limit_raw = (request.args.get("limit") or "").strip() + since_ms = None + if since_raw.isdigit(): + since_ms = int(since_raw) + limit = 500 + if limit_raw.isdigit(): + limit = int(limit_raw) + try: + result = fn(symbol=symbol, timeframe=timeframe, since_ms=since_ms, limit=limit) + if isinstance(result, dict): + return jsonify(result) + return jsonify({"ok": False, "msg": "OHLCV 返回格式无效"}), 500 + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + @app.route("/api/hub/add_order", methods=["POST"]) + @_hub_auth_required + def api_hub_add_order(): + return _hub_json("add_order", "/add_order") + + @app.route("/api/hub/add_key", methods=["POST"]) + @_hub_auth_required + def api_hub_add_key(): + return _hub_json("add_key", "/add_key") + + @app.route("/api/hub/trend/preview", methods=["POST"]) + @_hub_auth_required + def api_hub_trend_preview(): + if not _ctx().get("has_trend"): + return jsonify({"ok": False, "msg": "该实例无趋势回调"}), 400 + data = _invoke_view("preview_trend_pullback", "/trade") + pid = _latest_preview_id() + preview = _fetch_preview(pid) if pid else None + return jsonify( + { + "ok": bool(data.get("ok")), + "messages": data.get("messages") or [], + "preview_id": pid, + "preview": preview, + } + ) + + @app.route("/api/hub/trend/execute", methods=["POST"]) + @_hub_auth_required + def api_hub_trend_execute(): + if not _ctx().get("has_trend"): + return jsonify({"ok": False, "msg": "该实例无趋势回调"}), 400 + pid = (request.form.get("preview_id") or "").strip() + if not pid: + body = request.get_json(silent=True) or {} + pid = str(body.get("preview_id") or "").strip() + form = {"preview_id": pid} if pid else {} + return jsonify(_invoke_view("execute_trend_pullback", "/trade", form=form)) + + @app.route("/api/hub/trend/preview/") + @_hub_auth_required + def api_hub_trend_preview_get(pid): + if not _ctx().get("has_trend"): + return jsonify({"ok": False, "msg": "该实例无趋势回调"}), 400 + preview = _fetch_preview(pid) + if not preview: + return jsonify({"ok": False, "msg": "预览不存在或已过期"}), 404 + return jsonify({"ok": True, "preview": preview}) + + @app.route("/api/hub/trend/stop/", methods=["POST"]) + @_hub_auth_required + def api_hub_trend_stop(pid): + if not _ctx().get("has_trend"): + return jsonify({"ok": False, "msg": "该实例无趋势回调"}), 400 + return jsonify(_invoke_view_get("stop_trend_pullback", f"/stop_trend_pullback/{pid}")) + + @app.route("/api/hub/order/sync-tpsl", methods=["POST"]) + @_hub_auth_required + def api_hub_order_sync_tpsl(): + """中控 agent 已挂 TP/SL 后:同步 order_monitors 计划价,避免刷新仍显示旧止损止盈.""" + body = request.get_json(silent=True) or {} + symbol = (body.get("symbol") or request.form.get("symbol") or "").strip() + side = ( + body.get("side") + or body.get("direction") + or request.form.get("side") + or "" + ).strip().lower() + if not symbol: + return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400 + if side not in ("long", "short"): + return jsonify({"ok": False, "msg": "side 须为 long 或 short"}), 400 + try: + sl = float(body.get("stop_loss")) + tp = float(body.get("take_profit")) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "stop_loss / take_profit 须为数字"}), 400 + get_db = _ctx().get("get_db") + if not callable(get_db): + return jsonify({"ok": False, "msg": "HUB_CTX 缺少 get_db"}), 500 + from lib.hub.hub_symbol_lib import symbols_match + from lib.hub.hub_order_sync_lib import sync_active_monitor_tpsl_prices + + conn = get_db() + try: + out = sync_active_monitor_tpsl_prices( + conn, symbol, side, sl, tp, symbols_match=symbols_match + ) + if out.get("ok"): + conn.commit() + return jsonify(out) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + finally: + conn.close() + + @app.route("/api/hub/order/sync-flat", methods=["POST"]) + @_hub_auth_required + def api_hub_order_sync_flat(): + """中控市价全平后:同步 order_monitors 并读 Gate 平仓历史写交易记录.""" + fn = _ctx().get("reconcile_hub_flat_fn") + if not callable(fn): + return jsonify({"ok": False, "msg": "该实例未配置 order sync-flat"}), 400 + body = request.get_json(silent=True) or {} + symbol = (body.get("symbol") or request.form.get("symbol") or "").strip() + side = ( + body.get("side") + or body.get("direction") + or request.form.get("side") + or "" + ).strip().lower() + if not symbol: + return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400 + if side not in ("long", "short"): + return jsonify({"ok": False, "msg": "side 须为 long 或 short"}), 400 + get_db = _ctx().get("get_db") + if not callable(get_db): + return jsonify({"ok": False, "msg": "HUB_CTX 缺少 get_db"}), 500 + conn = get_db() + try: + out = fn(conn, symbol, side) + if not isinstance(out, dict): + out = {"ok": True, "synced": int(out or 0)} + conn.commit() + return jsonify(out) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + finally: + conn.close() + + @app.route("/api/hub/trend/sync-flat", methods=["POST"]) + @_hub_auth_required + def api_hub_trend_sync_flat(): + """中控市价全平后:结束仍 active 的同币种同向趋势计划.""" + if not _ctx().get("has_trend"): + return jsonify({"ok": False, "msg": "该实例无趋势回调"}), 400 + body = request.get_json(silent=True) or {} + symbol = (body.get("symbol") or request.form.get("symbol") or "").strip() + side = ( + body.get("side") + or body.get("direction") + or request.form.get("side") + or "" + ).strip().lower() + if not symbol: + return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400 + if side not in ("long", "short"): + return jsonify({"ok": False, "msg": "side 须为 long 或 short"}), 400 + cfg = current_app.extensions.get("strategy_trend_cfg") + get_db = _ctx().get("get_db") + if not cfg or not callable(get_db): + return jsonify({"ok": False, "msg": "趋势配置未就绪"}), 500 + from lib.strategy.strategy_trend_register import sync_trend_plans_after_external_close + + conn = get_db() + try: + return jsonify(sync_trend_plans_after_external_close(cfg, conn, symbol, side)) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + finally: + conn.close() + + @app.route("/api/hub/roll/sync-flat", methods=["POST"]) + @_hub_auth_required + def api_hub_roll_sync_flat(): + """中控/实例手动平仓后:取消滚仓 pending 并关闭 active 滚仓组.""" + body = request.get_json(silent=True) or {} + symbol = (body.get("symbol") or request.form.get("symbol") or "").strip() + side = ( + body.get("side") + or body.get("direction") + or request.form.get("side") + or "" + ).strip().lower() + if not symbol: + return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400 + if side not in ("long", "short"): + return jsonify({"ok": False, "msg": "side 须为 long 或 short"}), 400 + cfg = current_app.extensions.get("strategy_roll_cfg") + get_db = _ctx().get("get_db") + if not cfg or not callable(get_db): + return jsonify({"ok": False, "msg": "滚仓配置未就绪"}), 500 + from lib.strategy.strategy_register import roll_sync_after_external_close + + conn = get_db() + try: + out = roll_sync_after_external_close(cfg, conn, symbol, side) + conn.commit() + return jsonify(out) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + finally: + conn.close() + + @app.route("/api/hub/trend/breakeven/", methods=["POST"]) + @_hub_auth_required + def api_hub_trend_breakeven(pid): + if not _ctx().get("has_trend"): + return jsonify({"ok": False, "msg": "该实例无趋势回调"}), 400 + body = request.get_json(silent=True) or {} + raw = (request.form.get("breakeven_offset_pct") or body.get("breakeven_offset_pct") or "").strip() + form = {} + if raw != "": + form["breakeven_offset_pct"] = raw + return jsonify( + _invoke_view( + "trend_pullback_breakeven", + f"/trend_pullback_breakeven/{pid}", + form=form, + ) + ) + + @app.route("/hub-sso") + def hub_sso_login(): + """中控签发的临时链接:写入 session 后跳转,直链访问仍走 /login.""" + from urllib.parse import urlencode + + auth_disabled = bool(current_app.config.get("HUB_AUTH_DISABLED")) + next_arg = request.args.get("next") + if auth_disabled: + session["logged_in"] = True + return redirect(safe_next_path(next_arg)) + ex = str((_ctx().get("exchange") or "")).strip().lower() + token = (request.args.get("token") or "").strip() + ok, next_path, err = verify_hub_sso_token(token, ex) + if ok: + embed_on = request.args.get("embed", "").strip().lower() in ( + "1", + "true", + "yes", + "on", + ) + dest_next = _embed_login_dest(next_path) if embed_on else next_path + if not embed_on: + ht = (request.args.get("hub_theme") or "").strip().lower() + if ht in ("light", "dark"): + dest_next = _merge_query_into_path(next_path, hub_theme=ht) + if embed_on and _sso_wants_embed_auth() and request.is_secure: + boot = mint_hub_embed_bootstrap(ex, dest_next) + if boot: + from urllib.parse import urlencode as _ue + + qdict = {"t": boot, "next": dest_next, "embed": "1"} + ht0 = (request.args.get("hub_theme") or "").strip().lower() + if ht0 in ("light", "dark"): + qdict["hub_theme"] = ht0 + return redirect(f"/hub-embed-auth?{_ue(qdict)}") + session["logged_in"] = True + session.modified = True + return redirect(dest_next) + hint = err or "校验失败" + flash( + f"中控 SSO 未生效({hint})." + "请确认中控与实例 .env 中 HUB_BRIDGE_TOKEN 一致," + f"且中控设置里该账户 key 为「{ex}」." + "经本地导航 iframe 打开时,实例须 HTTPS 且可设 APP_COOKIE_SECURE=true." + ) + return redirect("/login") + + @app.route("/hub-embed-auth") + def hub_embed_auth_login(): + """LocalNav 等 iframe 内嵌:单独写入 SameSite=None 会话后跳转.""" + auth_disabled = bool(current_app.config.get("HUB_AUTH_DISABLED")) + next_arg = request.args.get("next") + if auth_disabled: + session["logged_in"] = True + return redirect(safe_next_path(next_arg)) + ex = str((_ctx().get("exchange") or "")).strip().lower() + boot = (request.args.get("t") or "").strip() + ok, next_path, err = verify_hub_embed_bootstrap(boot, ex) + if ok: + session["logged_in"] = True + session.modified = True + return redirect(_embed_login_dest(next_path)) + hint = err or "校验失败" + flash(f"iframe 登录未生效({hint}).可点本地导航工具栏「实例免密」重试.") + return redirect("/login") + + +def _latest_preview_id(): + get_db = _ctx().get("get_db") + if not get_db: + return None + conn = get_db() + row = conn.execute( + "SELECT id FROM trend_pullback_previews ORDER BY created_at DESC LIMIT 1" + ).fetchone() + conn.close() + return row["id"] if row else None + + +def _fetch_preview(pid): + get_db = _ctx().get("get_db") + if not get_db or not pid: + return None + conn = get_db() + row = conn.execute( + "SELECT * FROM trend_pullback_previews WHERE id=?", (pid,) + ).fetchone() + conn.close() + if not row: + return None + d = _row_to_dict(row) + now_ms = int(time.time() * 1000) + d["expires_in_sec"] = max(0, int((int(d.get("expires_at_ms") or 0) - now_ms) / 1000)) + try: + from lib.strategy.strategy_trend_lib import build_trend_preview_level_rows + + enriched, level_rows = build_trend_preview_level_rows(d) + for key in ( + "preview_target_rr", + "preview_first_take_profit", + "preview_unified_stop_loss", + "preview_risk_amount_u", + "preview_first_profit_u", + "preview_take_profit_price", + ): + if key in enriched: + d[key] = enriched[key] + d["preview_level_rows"] = level_rows + d["grid_levels"] = [ + { + "i": row.get("i"), + "label": row.get("label"), + "price": row.get("price"), + "contracts": row.get("contracts"), + "cum_contracts": row.get("cum_contracts"), + "avg_entry": row.get("avg_entry"), + "take_profit_price": row.get("take_profit_price"), + "profit_u": row.get("profit_u"), + "risk_u": row.get("risk_u"), + "rr": row.get("rr"), + "stop_loss_price": row.get("stop_loss_price"), + "take_profit": row.get("profit_u"), + "stop_loss": row.get("risk_u"), + } + for row in level_rows + ] + except Exception: + d["grid_levels"] = [] + d["preview_level_rows"] = [] + return d diff --git a/lib/hub/hub_calculator_lib.py b/lib/hub/hub_calculator_lib.py new file mode 100644 index 0000000..524ebcf --- /dev/null +++ b/lib/hub/hub_calculator_lib.py @@ -0,0 +1,514 @@ +"""中控历史测算:趋势回调 / 滚仓,以损定仓(按交易所精度与张数规则).""" +from __future__ import annotations + +from typing import Any, Callable, Optional, Tuple + +from lib.strategy.strategy_roll_lib import max_roll_legs +from lib.strategy.strategy_trend_lib import ( + build_trend_preview_level_rows, + calc_risk_fraction, + compute_trend_plan_core, + validate_trend_bounds, +) + +DEFAULT_DCA_LEGS = 5 +MARGIN_BUFFER = 0.95 + + +def _resolve_market( + exchange_id: str, + base: str, +) -> Tuple[Optional[dict[str, Any]], Optional[Callable[[float], Optional[float]]], Optional[str]]: + from lib.hub.hub_calculator_market_lib import get_calculator_market, make_amount_precise_fn_from_market + + market, err = get_calculator_market(exchange_id, base) + if err or not market: + return None, None, err or "无法解析合约" + amount_precise = make_amount_precise_fn_from_market(market) + return market, amount_precise, None + + +def calc_trend_calculator( + *, + direction: str, + capital_usdt: float, + risk_percent: float, + leverage: int, + entry_price: float, + stop_loss: float, + add_upper: float, + take_profit: float, + dca_legs: int = DEFAULT_DCA_LEGS, + exchange_id: str = "0", + base: str = "ETH", +) -> Tuple[Optional[dict[str, Any]], Optional[str]]: + market, amount_precise, merr = _resolve_market(exchange_id, base) + if merr or not market or not amount_precise: + return None, merr or "无法解析合约" + contract_size = float(market.get("contract_size") or 1.0) + exchange_symbol = market["exchange_symbol"] + + direction = (direction or "long").strip().lower() + if direction not in ("long", "short"): + return None, "方向须为 long 或 short" + try: + capital = float(capital_usdt) + rp = float(risk_percent) + lev = int(leverage) + entry = float(entry_price) + sl = float(stop_loss) + upper = float(add_upper) + tp = float(take_profit) + legs = max(1, int(dca_legs)) + cs = float(contract_size) if contract_size else 1.0 + except (TypeError, ValueError): + return None, "参数格式错误" + if capital <= 0 or rp <= 0 or lev <= 0 or entry <= 0 or sl <= 0 or upper <= 0 or tp <= 0: + return None, "资金,风险,杠杆与价格须大于 0" + + bound_err = validate_trend_bounds(direction, sl, upper) + if bound_err: + return None, bound_err + + rf = calc_risk_fraction(direction, upper, sl) + if rf is None or rf <= 0: + return None, "止损与补仓区间边界组合无法计算风险比例" + + risk_budget = capital * (rp / 100.0) + notional = risk_budget / rf + margin_plan = min(notional / float(lev), capital * MARGIN_BUFFER) + if margin_plan <= 0: + return None, "计划保证金过小" + + target_amt = _amount_from_margin(margin_plan, lev, entry, cs) + if target_amt is None or target_amt <= 0: + return None, "无法计算计划张数,请检查入场价与杠杆" + target_amt = amount_precise(target_amt) + if target_amt is None or target_amt <= 0: + return None, "计划张数低于交易所最小精度" + + def _amount_precise(_symbol: str, amount: float) -> Optional[float]: + return amount_precise(amount) + + payload, err = compute_trend_plan_core( + direction=direction, + stop_loss=sl, + add_upper=upper, + risk_percent=rp, + snapshot_usdt=capital, + leverage=lev, + live_price=entry, + target_order_amount=target_amt, + exchange_symbol=exchange_symbol, + dca_legs=legs, + amount_precise=_amount_precise, + min_amount=float(market.get("min_amount") or 0.0), + full_margin_buffer_ratio=MARGIN_BUFFER, + ) + if err: + return None, err + + payload["take_profit"] = tp + payload["leverage"] = lev + payload["contract_size"] = cs + preview, rows = build_trend_preview_level_rows(payload) + + px_dec = int(market.get("price_decimals") or 4) + amt_dec = int(market.get("amount_decimals") or 4) + + def _f(v: Any, nd: int | None = None) -> Any: + if v is None: + return None + try: + return round(float(v), nd if nd is not None else 8) + except (TypeError, ValueError): + return v + + table = [] + for row in rows: + table.append( + { + "label": row.get("label"), + "price": _f(row.get("price"), px_dec), + "contracts": _f(row.get("contracts"), amt_dec), + "avg_entry": _f(row.get("avg_entry"), px_dec), + "profit_u": _f(row.get("profit_u")), + "risk_u": _f(row.get("risk_u")), + "rr": _f(row.get("rr"), 4), + } + ) + + return { + "direction": direction, + "capital_usdt": _f(capital), + "risk_percent": _f(rp, 2), + "risk_budget_u": _f(preview.get("preview_risk_amount_u")), + "leverage": lev, + "entry_price": _f(entry, px_dec), + "stop_loss": _f(sl, px_dec), + "add_upper": _f(upper, px_dec), + "take_profit": _f(tp, px_dec), + "plan_margin_u": _f(preview.get("plan_margin_capital")), + "target_contracts": _f(preview.get("target_order_amount"), amt_dec), + "first_contracts": _f(preview.get("first_order_amount"), amt_dec), + "dca_legs": int(preview.get("dca_legs") or legs), + "first_profit_u": _f(preview.get("preview_first_profit_u")), + "first_rr": _f(preview.get("preview_target_rr"), 4), + "market": market, + "rows": table, + }, None + + +def _amount_from_margin( + margin_capital: float, + leverage: int, + price: float, + contract_size: float, +) -> Optional[float]: + try: + margin = float(margin_capital) + lev = int(leverage) + px = float(price) + cs = float(contract_size) if contract_size else 1.0 + except (TypeError, ValueError): + return None + if margin <= 0 or lev <= 0 or px <= 0 or cs <= 0: + return None + notional = margin * lev + return notional / (px * cs) + + +def _round(v: Any, nd: int = 4) -> Any: + if v is None: + return None + try: + return round(float(v), nd) + except (TypeError, ValueError): + return v + + +def _money_rr(profit_u: Optional[float], risk_u: Optional[float]) -> Optional[float]: + try: + if risk_u is None or float(risk_u) <= 0 or profit_u is None: + return None + return round(float(profit_u) / float(risk_u), 4) + except (TypeError, ValueError): + return None + + +def calc_initial_roll_qty( + direction: str, + entry_price: float, + stop_loss: float, + risk_budget_usdt: float, + contract_size: float = 1.0, +) -> Tuple[Optional[float], Optional[str]]: + """首仓以损定仓:打到初始止损亏损 = 风险预算.""" + try: + entry = float(entry_price) + sl = float(stop_loss) + budget = float(risk_budget_usdt) + cs = float(contract_size) if contract_size else 1.0 + except (TypeError, ValueError): + return None, "参数格式错误" + if entry <= 0 or sl <= 0 or budget <= 0 or cs <= 0: + return None, "入场价,止损与风险预算须大于 0" + direction = (direction or "long").strip().lower() + if direction == "short": + per_unit = (sl - entry) * cs + if per_unit <= 0: + return None, "做空:止损价须高于首仓入场价" + else: + per_unit = (entry - sl) * cs + if per_unit <= 0: + return None, "做多:止损价须低于首仓入场价" + return budget / per_unit, None + + +def solve_add_amount_for_total_risk( + direction: str, + qty_existing: float, + entry_existing: float, + add_price: float, + new_stop: float, + risk_budget_usdt: float, + contract_size: float = 1.0, +) -> Tuple[Optional[float], Optional[str]]: + """合并持仓打到新止损总亏损 = 风险预算,反推本次加仓张数.""" + try: + q1 = float(qty_existing) + e1 = float(entry_existing) + e2 = float(add_price) + sl = float(new_stop) + b = float(risk_budget_usdt) + cs = float(contract_size) if contract_size else 1.0 + except (TypeError, ValueError): + return None, "参数格式错误" + if q1 <= 0 or e1 <= 0 or e2 <= 0 or b <= 0 or cs <= 0: + return None, "持仓或风险预算无效" + direction = (direction or "long").strip().lower() + if direction == "short": + denom = sl - e2 + numer = b / cs - q1 * (sl - e1) + if denom <= 0: + return None, "做空:新止损须高于限价加仓价" + else: + denom = e2 - sl + numer = b / cs - q1 * (e1 - sl) + if denom <= 0: + return None, "做多:新止损须低于限价/市价加仓价" + q2 = numer / denom + if q2 <= 0: + return None, "按当前新止损与总风险%,无需加仓或无法再加(已满足风险上限)" + return q2, None + + +def _roll_leg_preview( + *, + direction: str, + qty_existing: float, + entry_existing: float, + take_profit: float, + add_price: float, + new_stop_loss: float, + risk_budget: float, + contract_size: float, + amount_precise: Callable[[float], Optional[float]], +) -> Tuple[Optional[dict[str, Any]], Optional[str]]: + direction = (direction or "long").strip().lower() + try: + tp = float(take_profit) + sl = float(new_stop_loss) + entry_add = float(add_price) + e1 = float(entry_existing) + except (TypeError, ValueError): + return None, "止损/止盈格式错误" + if sl <= 0 or tp <= 0 or entry_add <= 0: + return None, "止损与首仓止盈须大于0" + if direction == "long": + if sl >= entry_add: + return None, "做多:新止损须低于加仓价" + if tp <= e1: + return None, "做多:首仓止盈须高于当前持仓均价参考" + else: + if sl <= entry_add: + return None, "做空:新止损须高于加仓价" + if tp >= e1: + return None, "做空:首仓止盈须低于当前持仓均价参考" + + q2_raw, err = solve_add_amount_for_total_risk( + direction, + qty_existing, + entry_existing, + entry_add, + sl, + risk_budget, + contract_size, + ) + if err: + return None, err + q2 = amount_precise(float(q2_raw)) + if q2 is None or q2 <= 0: + return None, "加仓张数低于交易所最小精度" + new_qty = float(qty_existing) + float(q2) + new_avg = (float(qty_existing) * float(entry_existing) + float(q2) * entry_add) / new_qty + cs = float(contract_size) if contract_size else 1.0 + if direction == "long": + loss_at_sl = (new_avg - sl) * new_qty * cs + reward_gross = (tp - new_avg) * new_qty * cs + else: + loss_at_sl = (sl - new_avg) * new_qty * cs + reward_gross = (new_avg - tp) * new_qty * cs + try: + from lib.trade.trade_fee_lib import net_pnl_after_fee + + reward_at_tp = net_pnl_after_fee(reward_gross, new_avg, tp, new_qty, cs) + if reward_at_tp is None: + reward_at_tp = reward_gross + except Exception: + reward_at_tp = reward_gross + return { + "add_amount_raw": q2, + "qty_after": new_qty, + "avg_entry_after": new_avg, + "add_price": entry_add, + "new_stop_loss": sl, + "loss_at_sl_usdt": loss_at_sl, + "reward_at_tp_usdt": reward_at_tp, + }, None + + +def calc_roll_calculator( + *, + direction: str, + capital_usdt: float, + risk_percent: float, + entry_price: float, + stop_loss: float, + take_profit: float, + add_legs: list[dict[str, float]] | None = None, + legs_done: int = 0, + exchange_id: str = "0", + base: str = "ETH", +) -> Tuple[Optional[dict[str, Any]], Optional[str]]: + """ + 滚仓历史测算:首仓自动以损定仓;止盈锁定首仓价;最多 3 次滚仓加仓. + add_legs: [{add_price, new_stop_loss}, ...],按顺序链式计算. + legs_done: 已完成滚仓次数(仅标记,仍参与链式状态推进). + """ + market, amount_precise, merr = _resolve_market(exchange_id, base) + if merr or not market or not amount_precise: + return None, merr or "无法解析合约" + contract_size = float(market.get("contract_size") or 1.0) + px_dec = int(market.get("price_decimals") or 4) + amt_dec = int(market.get("amount_decimals") or 4) + + direction = (direction or "long").strip().lower() + if direction not in ("long", "short"): + return None, "方向须为 long 或 short" + try: + capital = float(capital_usdt) + rp = float(risk_percent) + entry = float(entry_price) + initial_sl = float(stop_loss) + tp = float(take_profit) + done = max(0, int(legs_done)) + except (TypeError, ValueError): + return None, "参数格式错误" + if capital <= 0 or rp <= 0 or entry <= 0 or initial_sl <= 0 or tp <= 0: + return None, "资金,风险与价格须大于 0" + if done > max_roll_legs(direction): + return None, f"已完成滚仓次数不能超过 {max_roll_legs(direction)} 次" + + legs_in: list[dict[str, float]] = [] + for raw in add_legs or []: + if not isinstance(raw, dict): + continue + try: + ap = float(raw.get("add_price")) + nsl = float(raw.get("new_stop_loss")) + except (TypeError, ValueError): + return None, "加仓价与新止损须为有效数字" + if ap <= 0 or nsl <= 0: + return None, "加仓价与新止损须大于 0" + legs_in.append({"add_price": ap, "new_stop_loss": nsl}) + + if done + len(legs_in) > max_roll_legs(direction): + return None, f"已完成 {done} 次 + 待测算 {len(legs_in)} 次,合计不能超过 {max_roll_legs(direction)} 次滚仓" + + if direction == "long": + if tp <= entry: + return None, "做多:止盈价须高于首仓入场价" + else: + if tp >= entry: + return None, "做空:止盈价须低于首仓入场价" + + risk_budget = capital * (rp / 100.0) + qty, err = calc_initial_roll_qty(direction, entry, initial_sl, risk_budget, contract_size) + if err: + return None, err + if qty is None or qty <= 0: + return None, "无法计算首仓张数" + qty_p = amount_precise(float(qty)) + if qty_p is None or qty_p <= 0: + return None, "首仓张数低于交易所最小精度" + + qty_f = float(qty_p) + avg = entry + rows: list[dict[str, Any]] = [] + cs = contract_size + + if direction == "long": + first_loss = (avg - initial_sl) * qty_f * cs + first_profit_gross = (tp - avg) * qty_f * cs + else: + first_loss = (initial_sl - avg) * qty_f * cs + first_profit_gross = (avg - tp) * qty_f * cs + try: + from lib.trade.trade_fee_lib import net_pnl_after_fee + + first_profit = net_pnl_after_fee(first_profit_gross, avg, tp, qty_f, cs) + if first_profit is None: + first_profit = first_profit_gross + except Exception: + first_profit = first_profit_gross + + rows.append( + { + "label": "首仓", + "leg_index": 0, + "already_done": False, + "entry_or_add_price": _round(entry, px_dec), + "stop_loss": _round(initial_sl, px_dec), + "add_contracts": _round(qty_f, amt_dec), + "total_contracts": _round(qty_f, amt_dec), + "avg_entry": _round(avg, px_dec), + "take_profit": _round(tp, px_dec), + "loss_at_sl_u": _round(first_loss), + "profit_at_tp_u": _round(first_profit), + "rr": _money_rr(first_profit, first_loss), + } + ) + + current_qty = qty_f + current_avg = avg + + for i, leg in enumerate(legs_in): + leg_no = i + 1 + preview, err = _roll_leg_preview( + direction=direction, + qty_existing=current_qty, + entry_existing=current_avg, + take_profit=tp, + add_price=leg["add_price"], + new_stop_loss=leg["new_stop_loss"], + risk_budget=risk_budget, + contract_size=cs, + amount_precise=amount_precise, + ) + if err: + return None, f"滚仓第 {leg_no} 次:{err}" + if not preview: + return None, f"滚仓第 {leg_no} 次计算失败" + + current_qty = float(preview["qty_after"]) + current_avg = float(preview["avg_entry_after"]) + loss = preview.get("loss_at_sl_usdt") + reward = preview.get("reward_at_tp_usdt") + rows.append( + { + "label": f"滚仓{leg_no}", + "leg_index": leg_no, + "already_done": leg_no <= done, + "entry_or_add_price": _round(preview.get("add_price"), px_dec), + "stop_loss": _round(preview.get("new_stop_loss"), px_dec), + "add_contracts": _round(preview.get("add_amount_raw"), amt_dec), + "total_contracts": _round(current_qty, amt_dec), + "avg_entry": _round(current_avg, px_dec), + "take_profit": _round(tp, px_dec), + "loss_at_sl_u": _round(loss), + "profit_at_tp_u": _round(reward), + "rr": _money_rr(reward, loss), + } + ) + + last = rows[-1] + return { + "direction": direction, + "capital_usdt": _round(capital), + "risk_percent": _round(rp, 2), + "risk_budget_u": _round(risk_budget), + "entry_price": _round(entry, px_dec), + "stop_loss": _round(initial_sl, px_dec), + "take_profit": _round(tp, px_dec), + "legs_done": done, + "roll_legs_planned": len(legs_in), + "first_contracts": _round(qty_f, amt_dec), + "final_contracts": last.get("total_contracts"), + "final_avg_entry": last.get("avg_entry"), + "final_loss_at_sl_u": last.get("loss_at_sl_u"), + "final_profit_at_tp_u": last.get("profit_at_tp_u"), + "final_rr": last.get("rr"), + "market": market, + "rows": rows, + }, None diff --git a/lib/hub/hub_calculator_market_lib.py b/lib/hub/hub_calculator_market_lib.py new file mode 100644 index 0000000..d1ba5cd --- /dev/null +++ b/lib/hub/hub_calculator_market_lib.py @@ -0,0 +1,257 @@ +"""计算器:从已配置交易实例读取 USDT 永续合约精度与张数规则.""" + +from __future__ import annotations + +import json +import threading +import time +import urllib.error +import urllib.request +from typing import Any, Callable, Optional, Tuple +from urllib.parse import urlencode + +try: + from settings_store import enabled_exchanges, load_settings +except ImportError: + from manual_trading_hub.settings_store import enabled_exchanges, load_settings + +MARKET_CACHE: dict[str, tuple[float, dict[str, Any]]] = {} +MARKET_LOCK = threading.Lock() +MARKET_TTL_SEC = 300.0 +HUB_FLASK_TIMEOUT = float(__import__("os").getenv("HUB_FLASK_TIMEOUT", "20")) + + +def normalize_base_symbol(text: str) -> str: + s = str(text or "").upper().strip() + for suf in ("USDT:USDT", "/USDT:USDT", "/USDT", "USDT", "-USDT-SWAP"): + if s.endswith(suf) and len(s) > len(suf): + s = s[: -len(suf)].strip("-/") + break + if "/" in s: + s = s.split("/", 1)[0].strip() + if ":" in s: + s = s.split(":", 1)[0].strip() + return s + + +def resolve_usdt_perp_symbol(exchange: Any, base: str) -> Tuple[Optional[str], Optional[str]]: + base_u = normalize_base_symbol(base) + if not base_u: + return None, "请输入币种,如 ETH" + candidates = [f"{base_u}/USDT:USDT", f"{base_u}/USDT"] + markets = getattr(exchange, "markets", None) or {} + for sym in candidates: + m = markets.get(sym) + if not m: + continue + if m.get("active") is False: + continue + if m.get("swap") or m.get("linear") or m.get("contract"): + return sym, None + for sym, m in markets.items(): + if m.get("active") is False: + continue + if not (m.get("swap") or m.get("linear")): + continue + if (m.get("quote") or "").upper() != "USDT": + continue + if (m.get("base") or "").upper() == base_u: + return sym, None + return None, f"未找到 {base_u}/USDT 永续合约" + + +def _decimals_from_precision_value(value: Any) -> Optional[int]: + if value in (None, ""): + return None + try: + p = float(value) + except (TypeError, ValueError): + return None + if p >= 1 and abs(p - round(p)) < 1e-9 and p <= 12: + return int(round(p)) + if 0 < p < 1: + s = f"{p:.12f}".rstrip("0") + if "." in s: + return min(12, len(s.split(".", 1)[1])) + return None + + +def _decimals_from_ccxt_str(text: str) -> int: + s = str(text or "").strip() + if not s or "." not in s: + return 0 + frac = s.split(".", 1)[1] + if not frac: + return 0 + return min(12, len(frac.rstrip("0") or frac)) + + +def amount_decimals_from_exchange(exchange: Any, exchange_symbol: str) -> int: + try: + return _decimals_from_ccxt_str(exchange.amount_to_precision(exchange_symbol, 1.23456789)) + except Exception: + market = exchange.market(exchange_symbol) + prec = (market.get("precision") or {}).get("amount") + d = _decimals_from_precision_value(prec) + return d if d is not None else 4 + + +def price_decimals_from_exchange( + exchange: Any, exchange_symbol: str, price_tick: Optional[float] +) -> int: + from lib.hub.hub_ohlcv_lib import normalize_price_tick + + tick = normalize_price_tick(price_tick) + if tick and tick > 0: + if tick >= 1: + return 0 + s = f"{tick:.12f}".rstrip("0") + if "." in s: + return min(12, len(s.split(".", 1)[1])) + try: + return _decimals_from_ccxt_str(exchange.price_to_precision(exchange_symbol, 12345.678901234)) + except Exception: + market = exchange.market(exchange_symbol) + prec = (market.get("precision") or {}).get("price") + d = _decimals_from_precision_value(prec) + return d if d is not None else 4 + + +def make_amount_precise_fn_from_market(market: dict[str, Any]) -> Callable[[float], Optional[float]]: + dec = max(0, int(market.get("amount_decimals") or 4)) + min_amt = market.get("min_amount") + + def _fn(amount: float) -> Optional[float]: + try: + v = float(amount) + except (TypeError, ValueError): + return None + if v <= 0: + return None + factor = 10**dec + v = int(v * factor + 1e-12) / factor + if min_amt is not None: + try: + if v < float(min_amt): + return None + except (TypeError, ValueError): + pass + if v <= 0: + return None + return v + + return _fn + + +def find_exchange(exchange_id: str) -> dict | None: + needle = str(exchange_id or "").strip() + if not needle: + return None + for ex in load_settings().get("exchanges") or []: + if str(ex.get("id") or "").strip() == needle: + return ex + if str(ex.get("key") or "").strip().lower() == needle.lower(): + return ex + return None + + +def list_calculator_exchanges() -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for ex in enabled_exchanges(): + rows.append( + { + "id": str(ex.get("id") or ""), + "key": str(ex.get("key") or ""), + "name": str(ex.get("name") or ex.get("key") or ""), + "enabled": bool(ex.get("enabled")), + } + ) + return rows + + +def _hub_headers() -> dict[str, str]: + import os + + token = (os.getenv("HUB_BRIDGE_TOKEN") or os.getenv("CONTROL_TOKEN") or "").strip() + if token: + return {"X-Hub-Token": token} + return {} + + +def fetch_instance_market_sync(ex: dict, *, base: str) -> dict[str, Any]: + base_url = (ex.get("flask_url") or "").rstrip("/") + if not base_url: + return {"ok": False, "msg": "未配置 flask_url"} + params = urlencode({"base": normalize_base_symbol(base) or base}) + url = f"{base_url}/api/hub/market?{params}" + req = urllib.request.Request(url, headers=_hub_headers(), method="GET") + try: + with urllib.request.urlopen(req, timeout=HUB_FLASK_TIMEOUT) as resp: + status = int(getattr(resp, "status", 200) or 200) + raw = resp.read().decode("utf-8", errors="replace") + data = json.loads(raw) if raw else {} + if not isinstance(data, dict): + return {"ok": False, "msg": "无效 JSON"} + if status >= 400: + data.setdefault("ok", False) + return data + except urllib.error.HTTPError as exc: + try: + raw = exc.read().decode("utf-8", errors="replace") + body = json.loads(raw) if raw else {} + except Exception: + body = {"ok": False, "msg": raw if "raw" in locals() else str(exc)} + if isinstance(body, dict): + body.setdefault("ok", False) + return body + return {"ok": False, "msg": f"HTTP {exc.code}"} + except Exception as exc: + return {"ok": False, "msg": str(exc)} + + +def _enrich_market_from_settings(ex: dict, payload: dict[str, Any]) -> dict[str, Any]: + out = dict(payload) + out["exchange_id"] = str(ex.get("id") or "") + out["exchange_key"] = str(ex.get("key") or "") + out["exchange_name"] = str(ex.get("name") or ex.get("key") or "") + out["exchange_label"] = out["exchange_name"] + return out + + +def get_calculator_market( + exchange_id: str, + base: str, + *, + ex: dict | None = None, +) -> Tuple[Optional[dict[str, Any]], Optional[str]]: + """从系统设置中的交易实例拉取合约精度(与实盘一致).""" + row = ex or find_exchange(exchange_id) + if not row: + return None, "未找到该交易所配置" + if not row.get("enabled"): + return None, f"{row.get('name') or exchange_id} 未启用" + + base_u = normalize_base_symbol(base) + if not base_u: + return None, "请输入币种,如 ETH" + + cache_key = f"{row.get('id')}:{base_u}" + now = time.time() + with MARKET_LOCK: + cached = MARKET_CACHE.get(cache_key) + if cached and now - cached[0] < MARKET_TTL_SEC: + return dict(cached[1]), None + + remote = fetch_instance_market_sync(row, base=base_u) + if not remote.get("ok"): + return None, str(remote.get("msg") or "实例返回失败") + + data = _enrich_market_from_settings(row, remote) + with MARKET_LOCK: + MARKET_CACHE[cache_key] = (now, data) + return data, None + + +def clear_market_cache() -> None: + with MARKET_LOCK: + MARKET_CACHE.clear() diff --git a/lib/hub/hub_divergence_scan_lib.py b/lib/hub/hub_divergence_scan_lib.py new file mode 100644 index 0000000..53dc300 --- /dev/null +++ b/lib/hub/hub_divergence_scan_lib.py @@ -0,0 +1,465 @@ +"""行情区:Top20 内 MACD 背离扫描(档 A)+ 4h/日线/周线共振.""" +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path +from typing import Any, Callable, Mapping, Sequence + +from lib.hub.hub_volume_rank_lib import TOP_N_DEFAULT, get_cached_rank, volume_rank_timezone + +SCAN_CACHE_VERSION = 1 +SCAN_TIMEFRAMES: tuple[str, ...] = ("4h", "1d", "1w") +SWING_LOOKBACK = 4 +SWING_ALIGN_BARS = 30 +RECENCY_BARS = 60 +MACD_FAST = 12 +MACD_SLOW = 26 +MACD_SIGNAL = 9 + +TAB_LABELS: dict[str, str] = { + "4h": "4h背离", + "1d": "日线背离", + "1w": "周线背离", +} + +TF_SHORT: dict[str, str] = {"4h": "4h", "1d": "日线", "1w": "周线"} + + +def default_cache_path() -> Path: + from lib.paths import hub_data_dir + + return hub_data_dir() / "hub_divergence_scan.json" + + +def ema_array(values: Sequence[float | None], period: int) -> list[float | None]: + out: list[float | None] = [None] * len(values) + if period <= 0 or len(values) < period: + return out + k = 2.0 / (period + 1) + sma = sum(v for v in values[:period] if v is not None) / period + out[period - 1] = sma + prev = sma + for i in range(period, len(values)): + v = values[i] + if v is None: + continue + prev = v * k + prev * (1 - k) + out[i] = prev + return out + + +def find_swings(values: Sequence[float | None], lookback: int) -> tuple[list[dict], list[dict]]: + lows: list[dict] = [] + highs: list[dict] = [] + lb = max(1, int(lookback)) + n = len(values) + for i in range(lb, n - lb): + v = values[i] + if v is None: + continue + is_low = True + is_high = True + for j in range(1, lb + 1): + lv = values[i - j] + rv = values[i + j] + if lv is None or rv is None or v > lv or v > rv: + is_low = False + if lv is None or rv is None or v < lv or v < rv: + is_high = False + if is_low: + lows.append({"i": i, "v": float(v)}) + if is_high: + highs.append({"i": i, "v": float(v)}) + return lows, highs + + +def build_macd_by_index(closes: Sequence[float]) -> list[float | None]: + ema12 = ema_array(closes, MACD_FAST) + ema26 = ema_array(closes, MACD_SLOW) + macd: list[float | None] = [None] * len(closes) + for i in range(len(closes)): + if ema12[i] is not None and ema26[i] is not None: + macd[i] = ema12[i] - ema26[i] + return macd + + +def detect_latest_macd_divergence( + closes: Sequence[float], + *, + swing_lookback: int = SWING_LOOKBACK, + align_bars: int = SWING_ALIGN_BARS, + recency_bars: int = RECENCY_BARS, +) -> dict[str, Any]: + """档 A:最近一对摆动 MACD 顶/底背离(与 chart.js detectDivergences 同类).""" + if len(closes) < swing_lookback * 2 + 10: + return {"direction": None} + macd = build_macd_by_index(closes) + p_lows, p_highs = find_swings(closes, swing_lookback) + i_lows, i_highs = find_swings(macd, swing_lookback) + + def recent_enough(idx: int) -> bool: + return idx >= max(0, len(closes) - recency_bars) + + if len(p_lows) >= 2 and len(i_lows) >= 2: + p1, p2 = p_lows[-2], p_lows[-1] + i1, i2 = i_lows[-2], i_lows[-1] + if ( + abs(p1["i"] - i1["i"]) < align_bars + and abs(p2["i"] - i2["i"]) < align_bars + and p2["v"] < p1["v"] + and i2["v"] > i1["v"] + and recent_enough(p2["i"]) + ): + return {"direction": "bull", "bar_index": p2["i"]} + + if len(p_highs) >= 2 and len(i_highs) >= 2: + p1, p2 = p_highs[-2], p_highs[-1] + i1, i2 = i_highs[-2], i_highs[-1] + if ( + abs(p1["i"] - i1["i"]) < align_bars + and abs(p2["i"] - i2["i"]) < align_bars + and p2["v"] > p1["v"] + and i2["v"] < i1["v"] + and recent_enough(p2["i"]) + ): + return {"direction": "bear", "bar_index": p2["i"]} + + return {"direction": None} + + +def chart_candles_to_bars(candles: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for c in candles: + try: + t = c.get("time") + if t is None: + continue + ms = int(t) * 1000 if int(t) < 10_000_000_000 else int(t) + out.append( + { + "open_time_ms": ms, + "open": float(c["open"]), + "high": float(c["high"]), + "low": float(c["low"]), + "close": float(c["close"]), + "volume": float(c.get("volume") or 0), + } + ) + except (KeyError, TypeError, ValueError): + continue + return out + + +def normalize_ohlcv_rows(rows: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: + if not rows: + return [] + first = rows[0] + if first.get("open_time_ms") is not None: + return [dict(r) for r in rows] + return chart_candles_to_bars(rows) + + +def bars_to_closes(bars: Sequence[Mapping[str, Any]], *, exclude_open: bool = True) -> list[float]: + rows = list(bars) + if exclude_open and len(rows) > 1: + rows = rows[:-1] + out: list[float] = [] + for b in rows: + try: + out.append(float(b["close"])) + except (KeyError, TypeError, ValueError): + continue + return out + + +def bar_time_at(bars: Sequence[Mapping[str, Any]], index: int) -> int | None: + if index < 0 or index >= len(bars): + return None + try: + return int(bars[index]["open_time_ms"]) + except (KeyError, TypeError, ValueError): + return None + + +def analyze_ohlcv_bars(bars: Sequence[Mapping[str, Any]]) -> dict[str, Any]: + closed = list(bars) + if len(closed) > 1: + closed = closed[:-1] + closes = bars_to_closes(bars, exclude_open=True) + hit = detect_latest_macd_divergence(closes) + direction = hit.get("direction") + bar_index = hit.get("bar_index") + open_time_ms = None + if direction and bar_index is not None: + open_time_ms = bar_time_at(closed, int(bar_index)) + bars_ago = None + if direction and bar_index is not None: + bars_ago = max(0, len(closed) - 1 - int(bar_index)) + return { + "direction": direction, + "bar_index": bar_index, + "open_time_ms": open_time_ms, + "bars_ago": bars_ago, + } + + +def compute_confluence(tf_hits: Mapping[str, Mapping[str, Any]]) -> dict[str, Any]: + dirs: dict[str, str] = {} + for tf in SCAN_TIMEFRAMES: + d = (tf_hits.get(tf) or {}).get("direction") + if d in ("bull", "bear"): + dirs[tf] = d + + if not dirs: + return { + "confluence": 0, + "confluence_kind": "none", + "confluence_css": "none", + "is_split": False, + "split_detail": "", + "primary_direction": None, + "direction_label": "", + "timeframes_hit": [], + } + + unique = set(dirs.values()) + if len(unique) > 1: + parts = [] + for tf in SCAN_TIMEFRAMES: + if tf in dirs: + label = "底" if dirs[tf] == "bull" else "顶" + parts.append(f"{TF_SHORT.get(tf, tf)}{label}") + return { + "confluence": 0, + "confluence_kind": "分歧", + "confluence_css": "split", + "is_split": True, + "split_detail": " · ".join(parts), + "primary_direction": _latest_direction(tf_hits), + "direction_label": "分歧", + "timeframes_hit": list(dirs.keys()), + } + + direction = next(iter(unique)) + count = len(dirs) + return { + "confluence": count, + "confluence_kind": f"{count}周期", + "confluence_css": f"c{count}", + "is_split": False, + "split_detail": "", + "primary_direction": direction, + "direction_label": "底背离" if direction == "bull" else "顶背离", + "timeframes_hit": list(dirs.keys()), + } + + +def _latest_direction(tf_hits: Mapping[str, Mapping[str, Any]]) -> str | None: + best_tf = None + best_ms = -1 + for tf in SCAN_TIMEFRAMES: + row = tf_hits.get(tf) or {} + d = row.get("direction") + ms = row.get("open_time_ms") + if d not in ("bull", "bear") or ms is None: + continue + if int(ms) > best_ms: + best_ms = int(ms) + best_tf = tf + if best_tf is None: + return None + return (tf_hits.get(best_tf) or {}).get("direction") + + +def freshness_label(timeframe: str, bars_ago: int | None) -> str: + if bars_ago is None: + return "" + n = int(bars_ago) + if timeframe == "1w": + return f"{n}周前" if n else "本周" + if n <= 0: + return "当根" + return f"{n}根K前" + + +def build_symbol_scan_row( + *, + rank: int, + symbol: str, + volume_label: str, + tf_hits: Mapping[str, Mapping[str, Any]], +) -> dict[str, Any]: + conf = compute_confluence(tf_hits) + tf_map = {tf: (tf_hits.get(tf) or {}).get("direction") for tf in SCAN_TIMEFRAMES} + return { + "rank": rank, + "symbol": symbol, + "volume_label": volume_label, + "direction": conf.get("primary_direction"), + "direction_label": conf.get("direction_label") or "", + "confluence": conf.get("confluence") or 0, + "confluence_kind": conf.get("confluence_kind") or "none", + "confluence_css": conf.get("confluence_css") or "none", + "is_split": bool(conf.get("is_split")), + "split_detail": conf.get("split_detail") or "", + "timeframes": tf_map, + "tf_detail": { + tf: { + "direction": (tf_hits.get(tf) or {}).get("direction"), + "open_time_ms": (tf_hits.get(tf) or {}).get("open_time_ms"), + "bars_ago": (tf_hits.get(tf) or {}).get("bars_ago"), + "freshness": freshness_label(tf, (tf_hits.get(tf) or {}).get("bars_ago")), + } + for tf in SCAN_TIMEFRAMES + }, + } + + +def filter_tab_items(items: Sequence[Mapping[str, Any]], tab: str) -> list[dict[str, Any]]: + tab = (tab or "").strip().lower() + if tab not in SCAN_TIMEFRAMES: + return [dict(x) for x in items] + out: list[dict[str, Any]] = [] + for row in items: + tf = (row.get("tf_detail") or {}).get(tab) or {} + if tf.get("direction") not in ("bull", "bear"): + continue + item = dict(row) + item["tab_timeframe"] = tab + item["tab_direction"] = tf.get("direction") + item["tab_direction_label"] = "底背离" if tf.get("direction") == "bull" else "顶背离" + item["tab_freshness"] = tf.get("freshness") or "" + item["tab_open_time_ms"] = tf.get("open_time_ms") + out.append(item) + out.sort( + key=lambda x: ( + -1 if x.get("is_split") else int(x.get("confluence") or 0), + int(x.get("rank") or 999), + ), + reverse=True, + ) + return out + + +def load_scan_cache(path: Path | None = None) -> dict[str, Any]: + p = path or default_cache_path() + if not p.is_file(): + return {"version": SCAN_CACHE_VERSION, "exchanges": {}} + try: + data = json.loads(p.read_text(encoding="utf-8")) + if not isinstance(data, dict): + return {"version": SCAN_CACHE_VERSION, "exchanges": {}} + if int(data.get("version") or 0) < SCAN_CACHE_VERSION: + return {"version": SCAN_CACHE_VERSION, "exchanges": {}} + data.setdefault("version", SCAN_CACHE_VERSION) + data.setdefault("exchanges", {}) + return data + except Exception: + return {"version": SCAN_CACHE_VERSION, "exchanges": {}} + + +def save_scan_cache(data: dict[str, Any], path: Path | None = None) -> None: + p = path or default_cache_path() + p.parent.mkdir(parents=True, exist_ok=True) + payload = dict(data) + payload["version"] = SCAN_CACHE_VERSION + payload["updated_at"] = datetime.now(volume_rank_timezone()).isoformat(timespec="seconds") + p.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + +def merge_exchange_scan( + cache: dict[str, Any], + exchange_key: str, + *, + rank_date: str | None, + items: list[dict[str, Any]], + error: str | None = None, +) -> dict[str, Any]: + ex_k = str(exchange_key or "").strip().lower() + exchanges = dict(cache.get("exchanges") or {}) + exchanges[ex_k] = { + "rank_date": rank_date, + "items": items, + "error": error, + "scanned_at": datetime.now(volume_rank_timezone()).isoformat(timespec="seconds"), + } + out = dict(cache) + out["exchanges"] = exchanges + return out + + +def get_cached_scan( + cache: dict[str, Any], + exchange_key: str, + *, + tab: str = "4h", +) -> dict[str, Any]: + ex_k = str(exchange_key or "").strip().lower() + ex_data = (cache.get("exchanges") or {}).get(ex_k) or {} + all_items = list(ex_data.get("items") or []) + tab_key = (tab or "4h").strip().lower() + items = filter_tab_items(all_items, tab_key) if tab_key in SCAN_TIMEFRAMES else all_items + return { + "ok": True, + "exchange_key": ex_k, + "tab": tab_key, + "rank_date": ex_data.get("rank_date"), + "updated_at": cache.get("updated_at"), + "scanned_at": ex_data.get("scanned_at"), + "items": items, + "item_count": len(items), + "error": ex_data.get("error"), + } + + +def scan_top_symbols( + rank_items: Sequence[Mapping[str, Any]], + fetch_bars: Callable[[str, str], Sequence[Mapping[str, Any]]], +) -> list[dict[str, Any]]: + """对 Top N 币种扫描三周期背离.fetch_bars(symbol, timeframe) -> OHLCV rows.""" + out: list[dict[str, Any]] = [] + for row in rank_items: + symbol = str(row.get("symbol") or "").strip().upper() + if not symbol: + continue + tf_hits: dict[str, dict[str, Any]] = {} + for tf in SCAN_TIMEFRAMES: + try: + bars = fetch_bars(symbol, tf) + tf_hits[tf] = analyze_ohlcv_bars(bars) + except Exception: + tf_hits[tf] = {"direction": None} + out.append( + build_symbol_scan_row( + rank=int(row.get("rank") or 0), + symbol=symbol, + volume_label=str(row.get("volume_label") or row.get("volume_quote") or ""), + tf_hits=tf_hits, + ) + ) + return out + + +def cache_is_stale( + cache: dict[str, Any], + exchange_key: str, + *, + rank_date: str | None, + max_age_sec: float = 3600.0, +) -> bool: + ex_k = str(exchange_key or "").strip().lower() + ex_data = (cache.get("exchanges") or {}).get(ex_k) or {} + if not ex_data.get("items") and not ex_data.get("error"): + return True + if rank_date and ex_data.get("rank_date") != rank_date: + return True + updated = cache.get("updated_at") or ex_data.get("scanned_at") + if not updated: + return True + try: + dt = datetime.fromisoformat(str(updated)) + age = (datetime.now(dt.tzinfo) - dt).total_seconds() + return age > max_age_sec + except Exception: + return True diff --git a/lib/hub/hub_entry_plan_lib.py b/lib/hub/hub_entry_plan_lib.py new file mode 100644 index 0000000..ae635fb --- /dev/null +++ b/lib/hub/hub_entry_plan_lib.py @@ -0,0 +1,453 @@ +"""中控开仓计划:进行中 / 历史归档 / 胜率统计.""" + +from __future__ import annotations + +import os +import sqlite3 +import time +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any +from zoneinfo import ZoneInfo + +PLAN_TYPES = { + "trend": "趋势单", + "swing": "波段单", + "intraday": "日内短线", +} +TREND_TIMEFRAMES = ("5m", "15m", "30m", "1h", "4h", "1d") +ENTRY_TIMEFRAMES = ("1m", "5m", "15m", "30m", "1h") +DIRECTIONS = {"long": "多", "short": "空"} +ENTRY_SCHEMES = { + "breakout": "突破方案", + "false_breakout": "假突破突破方案", + "box_inflection": "箱体拐点方案", +} +RESULTS = {"win": "盈", "loss": "亏"} +STAT_DIMENSIONS = ("symbol", "trend_tf", "entry_scheme") + +DISPLAY_TZ = ZoneInfo( + (os.getenv("HUB_ENTRY_PLAN_TZ") or os.getenv("HUB_VOLUME_RANK_TZ") or "Asia/Shanghai").strip() + or "Asia/Shanghai" +) + + +def default_db_path() -> Path: + raw = (os.getenv("HUB_ENTRY_PLAN_DB_PATH") or "").strip() + if raw: + return Path(raw) + from lib.paths import hub_data_dir + + return hub_data_dir() / "hub_entry_plans.db" + + +def _now_ms() -> int: + return int(time.time() * 1000) + + +def _connect(db_path: Path | None = None) -> sqlite3.Connection: + path = db_path or default_db_path() + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(path), timeout=30, isolation_level=None) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA synchronous=NORMAL") + return conn + + +def init_db(db_path: Path | None = None) -> None: + conn = _connect(db_path) + try: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS entry_plans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plan_date TEXT NOT NULL, + exchange_key TEXT NOT NULL, + symbol TEXT NOT NULL, + plan_type TEXT NOT NULL, + trend_timeframe TEXT NOT NULL, + entry_timeframe TEXT NOT NULL, + direction TEXT NOT NULL, + target_level TEXT NOT NULL DEFAULT '', + current_range TEXT NOT NULL DEFAULT '', + entry_scheme TEXT NOT NULL, + result TEXT, + pnl_amount REAL, + note TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + archived_at INTEGER + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_entry_plans_status_date + ON entry_plans (status, plan_date DESC, id DESC) + """ + ) + finally: + conn.close() + + +def normalize_plan_symbol(raw: str) -> str: + s = str(raw or "").strip().upper() + if not s: + raise ValueError("缺少币种") + if ":" in s: + s = s.split(":", 1)[0] + if "/" in s: + base, quote = s.split("/", 1) + base = base.strip() + quote = (quote or "USDT").strip() or "USDT" + if not base: + raise ValueError("币种无效") + return f"{base}/{quote}" + if s.endswith("USDT") and len(s) > 4: + return f"{s[:-4]}/{s[-4:]}" + return f"{s}/USDT" + + +def _validate_choice(value: str, allowed: dict[str, str] | tuple[str, ...], field: str) -> str: + key = str(value or "").strip().lower() + if isinstance(allowed, dict): + if key not in allowed: + raise ValueError(f"{field} 无效") + return key + if key not in allowed: + raise ValueError(f"{field} 无效") + return key + + +def _row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None: + if row is None: + return None + d = dict(row) + d["plan_type_label"] = PLAN_TYPES.get(d.get("plan_type") or "", d.get("plan_type") or "") + d["direction_label"] = DIRECTIONS.get(d.get("direction") or "", d.get("direction") or "") + d["entry_scheme_label"] = ENTRY_SCHEMES.get( + d.get("entry_scheme") or "", d.get("entry_scheme") or "" + ) or "待填写" + res = d.get("result") + d["result_label"] = RESULTS.get(res, "") if res else "" + return d + + +def _parse_optional_pnl(raw: Any) -> float | None: + if raw is None or raw == "": + return None + try: + return round(float(raw), 4) + except (TypeError, ValueError) as e: + raise ValueError("盈亏金额无效") from e + + +def create_entry_plan(payload: dict[str, Any], *, db_path: Path | None = None) -> dict[str, Any]: + init_db(db_path) + plan_date = str(payload.get("plan_date") or "").strip()[:10] + if not plan_date: + raise ValueError("缺少 plan_date") + exchange_key = str(payload.get("exchange_key") or "").strip().lower() + if not exchange_key: + raise ValueError("缺少 exchange_key") + symbol = normalize_plan_symbol(payload.get("symbol") or "") + plan_type = _validate_choice(payload.get("plan_type"), PLAN_TYPES, "类型") + trend_tf = _validate_choice(payload.get("trend_timeframe"), TREND_TIMEFRAMES, "趋势周期") + entry_tf = _validate_choice(payload.get("entry_timeframe"), ENTRY_TIMEFRAMES, "入场周期") + direction = _validate_choice(payload.get("direction"), DIRECTIONS, "方向") + entry_scheme = "" + if payload.get("entry_scheme"): + entry_scheme = _validate_choice(payload.get("entry_scheme"), ENTRY_SCHEMES, "入场方案") + target_level = str(payload.get("target_level") or "").strip() + current_range = str(payload.get("current_range") or "").strip() + note = str(payload.get("note") or "").strip() + now = _now_ms() + conn = _connect(db_path) + try: + cur = conn.execute( + """ + INSERT INTO entry_plans ( + plan_date, exchange_key, symbol, plan_type, trend_timeframe, entry_timeframe, + direction, target_level, current_range, entry_scheme, note, status, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?) + """, + ( + plan_date, + exchange_key, + symbol, + plan_type, + trend_tf, + entry_tf, + direction, + target_level, + current_range, + entry_scheme, + note, + now, + now, + ), + ) + row = conn.execute( + "SELECT * FROM entry_plans WHERE id=?", + (int(cur.lastrowid),), + ).fetchone() + return _row_to_dict(row) or {} + finally: + conn.close() + + +def list_entry_plans( + *, + status: str = "active", + db_path: Path | None = None, +) -> list[dict[str, Any]]: + init_db(db_path) + st = (status or "active").strip().lower() + if st not in ("active", "archived"): + raise ValueError("status 无效") + conn = _connect(db_path) + try: + rows = conn.execute( + """ + SELECT * FROM entry_plans + WHERE status=? + ORDER BY plan_date DESC, id DESC + """, + (st,), + ).fetchall() + return [_row_to_dict(r) for r in rows if r] + finally: + conn.close() + + +def get_entry_plan(plan_id: int, *, db_path: Path | None = None) -> dict[str, Any] | None: + init_db(db_path) + conn = _connect(db_path) + try: + row = conn.execute("SELECT * FROM entry_plans WHERE id=?", (int(plan_id),)).fetchone() + return _row_to_dict(row) + finally: + conn.close() + + +def update_entry_plan( + plan_id: int, + payload: dict[str, Any], + *, + db_path: Path | None = None, +) -> dict[str, Any] | None: + init_db(db_path) + conn = _connect(db_path) + try: + row = conn.execute("SELECT * FROM entry_plans WHERE id=?", (int(plan_id),)).fetchone() + if not row: + return None + if row["status"] == "archived": + raise ValueError("已归档计划不可修改") + fields: dict[str, Any] = {} + if "plan_date" in payload: + qd = str(payload.get("plan_date") or "").strip()[:10] + if not qd: + raise ValueError("缺少 plan_date") + fields["plan_date"] = qd + if "exchange_key" in payload: + ex = str(payload.get("exchange_key") or "").strip().lower() + if not ex: + raise ValueError("缺少 exchange_key") + fields["exchange_key"] = ex + if "symbol" in payload: + fields["symbol"] = normalize_plan_symbol(payload.get("symbol") or "") + if "plan_type" in payload: + fields["plan_type"] = _validate_choice(payload.get("plan_type"), PLAN_TYPES, "类型") + if "trend_timeframe" in payload: + fields["trend_timeframe"] = _validate_choice( + payload.get("trend_timeframe"), TREND_TIMEFRAMES, "趋势周期" + ) + if "entry_timeframe" in payload: + fields["entry_timeframe"] = _validate_choice( + payload.get("entry_timeframe"), ENTRY_TIMEFRAMES, "入场周期" + ) + if "direction" in payload: + fields["direction"] = _validate_choice(payload.get("direction"), DIRECTIONS, "方向") + if "entry_scheme" in payload: + fields["entry_scheme"] = _validate_choice( + payload.get("entry_scheme"), ENTRY_SCHEMES, "入场方案" + ) + if "target_level" in payload: + fields["target_level"] = str(payload.get("target_level") or "").strip() + if "current_range" in payload: + fields["current_range"] = str(payload.get("current_range") or "").strip() + if "note" in payload: + fields["note"] = str(payload.get("note") or "").strip() + if "pnl_amount" in payload: + fields["pnl_amount"] = _parse_optional_pnl(payload.get("pnl_amount")) + archive_now = False + if "result" in payload: + res_raw = payload.get("result") + if res_raw is None or str(res_raw).strip() == "": + fields["result"] = None + else: + fields["result"] = _validate_choice(res_raw, RESULTS, "结果") + archive_now = True + if not fields: + return _row_to_dict(row) + now = _now_ms() + fields["updated_at"] = now + if archive_now: + scheme_val = fields.get("entry_scheme", row["entry_scheme"]) + if not str(scheme_val or "").strip(): + raise ValueError("归档前请在进行中计划里选择入场方案") + fields["status"] = "archived" + fields["archived_at"] = now + sets = ", ".join(f"{k}=?" for k in fields) + conn.execute( + f"UPDATE entry_plans SET {sets} WHERE id=?", + (*fields.values(), int(plan_id)), + ) + updated = conn.execute("SELECT * FROM entry_plans WHERE id=?", (int(plan_id),)).fetchone() + return _row_to_dict(updated) + finally: + conn.close() + + +def delete_entry_plan(plan_id: int, *, db_path: Path | None = None) -> bool: + init_db(db_path) + conn = _connect(db_path) + try: + row = conn.execute("SELECT status FROM entry_plans WHERE id=?", (int(plan_id),)).fetchone() + if not row: + return False + if row["status"] != "active": + raise ValueError("仅进行中的计划可删除") + cur = conn.execute("DELETE FROM entry_plans WHERE id=? AND status='active'", (int(plan_id),)) + return int(cur.rowcount or 0) > 0 + finally: + conn.close() + + +def _today_iso() -> str: + return datetime.now(DISPLAY_TZ).strftime("%Y-%m-%d") + + +def resolve_stats_date_bounds( + *, + period: str = "all", + date_from: str = "", + date_to: str = "", +) -> tuple[str | None, str | None, str]: + """返回 (date_from, date_to, label);all 时 bounds 为 None.""" + p = (period or "all").strip().lower() or "all" + today = _today_iso() + if p == "all": + return None, None, "全部历史" + if p == "week": + day_dt = datetime.strptime(today, "%Y-%m-%d") + monday = (day_dt - timedelta(days=day_dt.weekday())).strftime("%Y-%m-%d") + return monday, today, f"本周 {monday}~{today}" + if p == "month": + day_dt = datetime.strptime(today, "%Y-%m-%d") + first = day_dt.replace(day=1).strftime("%Y-%m-%d") + return first, today, f"本月 {first}~{today}" + if p == "range": + df = (date_from or "").strip()[:10] or today + dt = (date_to or "").strip()[:10] or df + if df > dt: + df, dt = dt, df + label = f"区间 {df}~{dt}" if df != dt else f"区间 {df}" + return df, dt, label + return None, None, "全部历史" + + +def compute_entry_plan_stats( + *, + dimension: str = "symbol", + period: str = "all", + date_from: str = "", + date_to: str = "", + db_path: Path | None = None, +) -> dict[str, Any]: + init_db(db_path) + dim = (dimension or "symbol").strip().lower() + if dim not in STAT_DIMENSIONS: + raise ValueError("dimension 无效") + df_bound, dt_bound, period_label = resolve_stats_date_bounds( + period=period, date_from=date_from, date_to=date_to + ) + col_map = { + "symbol": "symbol", + "trend_tf": "trend_timeframe", + "entry_scheme": "entry_scheme", + } + col = col_map[dim] + conn = _connect(db_path) + try: + where = "status='archived' AND result IN ('win','loss')" + params: list[Any] = [] + if df_bound: + where += " AND plan_date >= ? AND plan_date <= ?" + params.extend([df_bound, dt_bound]) + rows = conn.execute( + f""" + SELECT {col} AS dim_key, + COUNT(*) AS total, + SUM(CASE WHEN result='win' THEN 1 ELSE 0 END) AS win_count, + SUM(CASE WHEN result='loss' THEN 1 ELSE 0 END) AS loss_count + FROM entry_plans + WHERE {where} + GROUP BY {col} + ORDER BY total DESC, dim_key ASC + """, + params, + ).fetchall() + items = [] + for r in rows: + total = int(r["total"] or 0) + wins = int(r["win_count"] or 0) + losses = int(r["loss_count"] or 0) + key = str(r["dim_key"] or "") + label = key + if dim == "entry_scheme": + label = ENTRY_SCHEMES.get(key, key) + elif dim == "trend_tf": + label = key + win_rate = round(wins / total * 100, 1) if total else None + items.append( + { + "key": key, + "label": label, + "total": total, + "win_count": wins, + "loss_count": losses, + "win_rate": win_rate, + } + ) + return { + "dimension": dim, + "period": period, + "period_label": period_label, + "date_from": df_bound, + "date_to": dt_bound, + "items": items, + } + finally: + conn.close() + + +def meta_payload(exchanges: list[dict[str, Any]] | None = None) -> dict[str, Any]: + return { + "plan_types": [{"value": k, "label": v} for k, v in PLAN_TYPES.items()], + "trend_timeframes": list(TREND_TIMEFRAMES), + "entry_timeframes": list(ENTRY_TIMEFRAMES), + "directions": [{"value": k, "label": v} for k, v in DIRECTIONS.items()], + "entry_schemes": [{"value": k, "label": v} for k, v in ENTRY_SCHEMES.items()], + "results": [{"value": k, "label": v} for k, v in RESULTS.items()], + "stat_dimensions": [ + {"value": "symbol", "label": "币种"}, + {"value": "trend_tf", "label": "趋势周期"}, + {"value": "entry_scheme", "label": "入场方案"}, + ], + "exchanges": exchanges or [], + } diff --git a/lib/hub/hub_fund_history_lib.py b/lib/hub/hub_fund_history_lib.py new file mode 100644 index 0000000..f38a3af --- /dev/null +++ b/lib/hub/hub_fund_history_lib.py @@ -0,0 +1,469 @@ +"""中控资金概况:分户日快照(180 交易日),总资金曲线与回撤.""" +from __future__ import annotations + +import json +import os +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any, Optional + +from lib.hub.hub_trades_lib import current_trading_day +from lib.hub.hub_options_funds_lib import merge_board_row_balances + +from lib.paths import manual_trading_hub_dir + +HUB_DIR = manual_trading_hub_dir() +FUND_HISTORY_PATH = HUB_DIR / "hub_fund_history.json" +LEGACY_FUND_HISTORY_PATH = HUB_DIR / "hub_ai_fund_history.json" + +try: + FUND_HISTORY_DAYS = max(30, int(os.getenv("HUB_FUND_HISTORY_DAYS", "180") or "180")) +except ValueError: + FUND_HISTORY_DAYS = 180 + +FUND_HISTORY_START_DAY = (os.getenv("HUB_FUND_HISTORY_START_DAY") or "2026-06-09").strip()[:10] + + +def fund_history_start_day() -> str: + return FUND_HISTORY_START_DAY or "2026-06-09" + + +def _now_str() -> str: + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def _safe_float(value: Any) -> Optional[float]: + try: + v = float(value) + return v if v >= 0 else None + except (TypeError, ValueError): + return None + + +def account_total_usdt(funding: Any, trading: Any) -> Optional[float]: + """资金户 + 交易户;任一侧缺失则不计入(返回 None).""" + fu = _safe_float(funding) + tu = _safe_float(trading) + if fu is None or tu is None: + return None + return round(fu + tu, 4) + + +def compute_drawdown(values: list[float]) -> dict[str, Any]: + """基于资金权益序列计算峰值回撤(U 与 %).""" + peak = 0.0 + max_dd_u = 0.0 + peak_at_end = 0.0 + for v in values: + if not isinstance(v, (int, float)): + continue + fv = float(v) + if fv > peak: + peak = fv + dd = peak - fv + if dd > max_dd_u: + max_dd_u = dd + peak_at_end = peak + max_dd_u = round(max_dd_u, 4) + peak_at_end = round(peak_at_end, 4) + max_dd_pct = round((max_dd_u / peak_at_end) * 100, 2) if peak_at_end > 0 else None + return { + "peak_usdt": peak_at_end, + "max_drawdown_u": max_dd_u, + "max_drawdown_pct": max_dd_pct, + } + + +def compute_period_delta(series: list[dict]) -> dict[str, Any]: + """相对曲线起点的资金变动(U 与 %);含出入金影响,口径同权益曲线.""" + pts = [ + p + for p in (series or []) + if isinstance(p, dict) and isinstance(p.get("total_usdt"), (int, float)) + ] + if not pts: + return { + "start_usdt": None, + "period_delta_usdt": None, + "period_delta_pct": None, + } + start = round(float(pts[0]["total_usdt"]), 4) + end = round(float(pts[-1]["total_usdt"]), 4) + delta = round(end - start, 4) + pct = round((delta / start) * 100, 2) if start > 0 else None + return { + "start_usdt": start, + "period_delta_usdt": delta, + "period_delta_pct": pct, + } + + +def _atomic_write(path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + os.replace(tmp, path) + + +def _prune_days( + days: dict, + *, + keep_days: int, + anchor_day: str, + start_day: Optional[str] = None, +) -> dict: + try: + anchor = datetime.strptime(anchor_day[:10], "%Y-%m-%d") + except ValueError: + anchor = datetime.now() + rolling_cutoff = (anchor - timedelta(days=max(1, keep_days) - 1)).strftime("%Y-%m-%d") + start = (start_day or fund_history_start_day()).strip()[:10] + cutoff = max(rolling_cutoff, start) if start else rolling_cutoff + return {k: v for k, v in (days or {}).items() if str(k) >= cutoff} + + +def _migrate_legacy_store(days: dict) -> dict: + if not LEGACY_FUND_HISTORY_PATH.is_file(): + return days + try: + loaded = json.loads(LEGACY_FUND_HISTORY_PATH.read_text(encoding="utf-8")) + legacy_days = loaded.get("days") if isinstance(loaded, dict) else {} + if not isinstance(legacy_days, dict): + return days + merged = dict(days) + for day, block in legacy_days.items(): + if day in merged: + continue + if isinstance(block, dict) and block.get("accounts"): + merged[day] = block + return merged + except Exception: + return days + + +def _load_store() -> dict: + if not FUND_HISTORY_PATH.is_file(): + store = {"version": 1, "days": _migrate_legacy_store({})} + if store["days"]: + _atomic_write(FUND_HISTORY_PATH, store) + return store + try: + loaded = json.loads(FUND_HISTORY_PATH.read_text(encoding="utf-8")) + if isinstance(loaded, dict): + loaded.setdefault("version", 1) + days = dict(loaded.get("days") or {}) + loaded["days"] = _migrate_legacy_store(days) + return loaded + except Exception: + pass + return {"version": 1, "days": {}} + + +def record_fund_snapshot( + trading_day: str, + accounts: list[dict], + *, + keep_days: int = FUND_HISTORY_DAYS, + reset_hour: int = 8, +) -> dict[str, Any]: + """写入当日各户资金账户/交易账户余额,并裁剪历史.""" + day = (trading_day or "").strip()[:10] or current_trading_day(reset_hour=reset_hour) + start = fund_history_start_day() + if start and day < start: + return _load_store().get("days") or {} + store = _load_store() + days = dict(store.get("days") or {}) + row_accounts: dict[str, dict] = {} + for ac in accounts or []: + key = str(ac.get("key") or ac.get("id") or "").strip() + if not key: + continue + if not ac.get("monitored"): + continue + fu = _safe_float(ac.get("funding_usdt")) + tu = _safe_float(ac.get("trading_usdt")) + total = account_total_usdt(fu, tu) + if total is None: + continue + entry: dict[str, Any] = { + "name": ac.get("name"), + "funding_usdt": fu, + "trading_usdt": tu, + "total_usdt": total, + "recorded_at": _now_str(), + } + ofu = _safe_float(ac.get("options_funding_usdt")) + otu = _safe_float(ac.get("options_trading_usdt")) + if ofu is not None: + entry["options_funding_usdt"] = ofu + if otu is not None: + entry["options_trading_usdt"] = otu + row_accounts[key] = entry + if row_accounts: + days[day] = {"accounts": row_accounts, "updated_at": _now_str()} + days = _prune_days( + days, keep_days=keep_days, anchor_day=day, start_day=fund_history_start_day() + ) + _atomic_write(FUND_HISTORY_PATH, {"version": 1, "days": days}) + return days + + +def record_fund_snapshot_from_board( + rows: list[dict], + *, + keep_days: int = FUND_HISTORY_DAYS, + reset_hour: int = 8, +) -> dict[str, Any]: + """监控板行写入当日快照(仅 account_ok 且资金/交易户齐全).""" + day = current_trading_day(reset_hour=reset_hour) + accounts = [] + for row in rows or []: + if not isinstance(row, dict): + continue + if not row.get("account_ok") and not ( + "options" in (row.get("capabilities") or []) + and isinstance(row.get("options"), dict) + and row.get("options", {}).get("ok") + ): + continue + merged = merge_board_row_balances(row) + if not merged.get("data_ok"): + continue + accounts.append( + { + "key": row.get("key") or row.get("id"), + "name": row.get("name"), + "funding_usdt": merged.get("funding_usdt"), + "trading_usdt": merged.get("trading_usdt"), + "options_funding_usdt": merged.get("options_funding_usdt"), + "options_trading_usdt": merged.get("options_trading_usdt"), + "monitored": True, + } + ) + return record_fund_snapshot(day, accounts, keep_days=keep_days, reset_hour=reset_hour) + + +def get_fund_history(*, anchor_day: str, keep_days: int = FUND_HISTORY_DAYS) -> dict[str, dict]: + store = _load_store() + return _prune_days( + dict(store.get("days") or {}), + keep_days=keep_days, + anchor_day=anchor_day, + start_day=fund_history_start_day(), + ) + + +def _exchange_monitored(ex: dict) -> bool: + return bool(ex.get("enabled")) and not bool(ex.get("env_disabled")) + + +def _live_row_for_exchange(ex: dict, rows_by_key: dict[str, dict]) -> Optional[dict]: + key = str(ex.get("key") or "").strip() + if not key: + return None + return rows_by_key.get(key) + + +def _series_from_history( + history: dict[str, dict], + account_keys: list[str], +) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for day in sorted(history.keys()): + block = history.get(day) or {} + ac_map = block.get("accounts") or {} + total = 0.0 + n = 0 + for key in account_keys: + ac = ac_map.get(key) or {} + t = account_total_usdt(ac.get("funding_usdt"), ac.get("trading_usdt")) + if t is None: + t = _safe_float(ac.get("total_usdt")) + if t is None: + continue + total += t + n += 1 + if n > 0: + out.append({"day": day, "total_usdt": round(total, 4)}) + return out + + +def _account_series(history: dict[str, dict], key: str) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for day in sorted(history.keys()): + ac = (history.get(day) or {}).get("accounts", {}).get(key) or {} + t = account_total_usdt(ac.get("funding_usdt"), ac.get("trading_usdt")) + if t is None: + t = _safe_float(ac.get("total_usdt")) + if t is None: + continue + out.append( + { + "day": day, + "total_usdt": t, + "funding_usdt": _safe_float(ac.get("funding_usdt")), + "trading_usdt": _safe_float(ac.get("trading_usdt")), + } + ) + return out + + +def build_fund_overview( + exchanges: list[dict], + *, + board_rows: Optional[list[dict]] = None, + trading_day: Optional[str] = None, + keep_days: int = FUND_HISTORY_DAYS, + reset_hour: int = 8, + updated_at: Optional[str] = None, +) -> dict[str, Any]: + day = (trading_day or "").strip()[:10] or current_trading_day(reset_hour=reset_hour) + history = get_fund_history(anchor_day=day, keep_days=keep_days) + rows_by_key: dict[str, dict] = {} + for row in board_rows or []: + if isinstance(row, dict): + k = str(row.get("key") or "").strip() + if k: + rows_by_key[k] = row + + monitored_keys: list[str] = [] + accounts_out: list[dict[str, Any]] = [] + live_total = 0.0 + live_known = 0 + + for ex in exchanges or []: + if not _exchange_monitored(ex): + continue + key = str(ex.get("key") or "").strip() + monitored = True + row = _live_row_for_exchange(ex, rows_by_key) + fu = tu = total = None + pf = pt = ofu = otu = None + data_ok = False + caps = ex.get("capabilities") or [] + if row: + merged = merge_board_row_balances({**row, "capabilities": caps}) + if merged.get("data_ok"): + fu = merged.get("funding_usdt") + tu = merged.get("trading_usdt") + total = merged.get("total_usdt") + pf = merged.get("perpetual_funding_usdt") + pt = merged.get("perpetual_trading_usdt") + ofu = merged.get("options_funding_usdt") + otu = merged.get("options_trading_usdt") + data_ok = True + live_total += float(total) + live_known += 1 + + series = _account_series(history, key) if key else [] + dd = compute_drawdown([p["total_usdt"] for p in series]) if series else { + "peak_usdt": None, + "max_drawdown_u": None, + "max_drawdown_pct": None, + } + day_delta = None + if series: + if len(series) >= 2: + day_delta = round(series[-1]["total_usdt"] - series[-2]["total_usdt"], 4) + elif data_ok and total is not None: + day_delta = round(total - series[-1]["total_usdt"], 4) + period = compute_period_delta(series) + + accounts_out.append( + { + "id": ex.get("id"), + "key": key, + "name": ex.get("name") or key, + "monitored": monitored, + "data_ok": data_ok, + "funding_usdt": fu, + "trading_usdt": tu, + "perpetual_funding_usdt": pf, + "perpetual_trading_usdt": pt, + "options_funding_usdt": ofu, + "options_trading_usdt": otu, + "total_usdt": total, + "series": series, + "drawdown": dd, + "day_delta_usdt": day_delta, + "start_usdt": period["start_usdt"], + "period_delta_usdt": period["period_delta_usdt"], + "period_delta_pct": period["period_delta_pct"], + } + ) + if key: + monitored_keys.append(key) + + total_series = _series_from_history(history, monitored_keys) + if live_known > 0: + last_day = total_series[-1]["day"] if total_series else None + live_point = round(live_total, 4) + if last_day == day and total_series: + total_series[-1]["total_usdt"] = live_point + total_series[-1]["live"] = True + else: + total_series.append({"day": day, "total_usdt": live_point, "live": True}) + + total_dd = compute_drawdown([p["total_usdt"] for p in total_series]) if total_series else { + "peak_usdt": None, + "max_drawdown_u": None, + "max_drawdown_pct": None, + } + total_day_delta = None + if total_series: + if len(total_series) >= 2: + total_day_delta = round( + total_series[-1]["total_usdt"] - total_series[-2]["total_usdt"], 4 + ) + total_period = compute_period_delta(total_series) + + return { + "ok": True, + "trading_day": day, + "reset_hour": reset_hour, + "keep_days": keep_days, + "history_start_day": fund_history_start_day(), + "updated_at": updated_at, + "totals": { + "monitored_count": len(monitored_keys), + "live_known_count": live_known, + "total_usdt": round(live_total, 4) if live_known > 0 else None, + "day_delta_usdt": total_day_delta, + "start_usdt": total_period["start_usdt"], + "period_delta_usdt": total_period["period_delta_usdt"], + "period_delta_pct": total_period["period_delta_pct"], + "series": total_series, + "drawdown": total_dd, + }, + "accounts": accounts_out, + } + + +def format_fund_history_text( + history: dict[str, dict], + *, + account_names: Optional[dict[str, str]] = None, +) -> str: + if not history: + return "(暂无资金历史快照)" + names = account_names or {} + lines = ["【资金快照(资金账户 + 交易账户 USDT,含期权 USDC≈USDT)】"] + for day in sorted(history.keys()): + block = history.get(day) or {} + ac_map = block.get("accounts") or {} + if not ac_map: + continue + parts = [] + for key, ac in ac_map.items(): + label = names.get(key) or ac.get("name") or key + fu = ac.get("funding_usdt") + tu = ac.get("trading_usdt") + tot = ac.get("total_usdt") + if tot is None: + tot = account_total_usdt(fu, tu) + fu_txt = f"{fu}U" if fu is not None else "未知" + tu_txt = f"{tu}U" if tu is not None else "未知" + tot_txt = f"{tot}U" if tot is not None else "未知" + parts.append(f"{label}: 合计{tot_txt}(资金{fu_txt}/交易{tu_txt})") + lines.append(f"- {day}: " + ";".join(parts)) + return "\n".join(lines) if len(lines) > 1 else "(暂无资金历史快照)" diff --git a/lib/hub/hub_help_lib.py b/lib/hub/hub_help_lib.py new file mode 100644 index 0000000..9456b61 --- /dev/null +++ b/lib/hub/hub_help_lib.py @@ -0,0 +1,53 @@ +"""中控「使用说明」:读取 manual_trading_hub/docs/help 下的 MD.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from lib.hub.hub_strategy_lib import render_markdown_html +from lib.paths import REPO_ROOT + +HELP_SECTIONS: tuple[dict[str, str], ...] = ( + {"key": "quickstart", "label": "快速开始", "title": "快速开始", "md_file": "01-quickstart.md"}, + {"key": "hub-nav", "label": "中控导航", "title": "中控导航说明", "md_file": "02-hub-nav.md"}, + {"key": "monitor", "label": "监控区", "title": "监控区与实例入口", "md_file": "03-monitor.md"}, + {"key": "instance", "label": "实例页面", "title": "实例页导航说明", "md_file": "04-instance.md"}, + {"key": "settings", "label": "设置说明", "title": "设置与配置说明", "md_file": "05-settings.md"}, +) + + +def _help_dir() -> Path: + return REPO_ROOT / "manual_trading_hub" / "docs" / "help" + + +def _section_meta(key: str) -> dict[str, str]: + k = (key or "").strip().lower() + for item in HELP_SECTIONS: + if item["key"] == k: + return item + raise KeyError(key) + + +def _md_path(section_key: str) -> Path: + return _help_dir() / _section_meta(section_key)["md_file"] + + +def help_meta_payload() -> dict[str, Any]: + sections = [{"key": s["key"], "label": s["label"], "title": s["title"]} for s in HELP_SECTIONS] + return {"ok": True, "sections": sections} + + +def load_help_payload(section_key: str) -> dict[str, Any]: + key = (section_key or "").strip().lower() + meta = _section_meta(key) + md_path = _md_path(key) + md_text = md_path.read_text(encoding="utf-8") if md_path.is_file() else "" + return { + "ok": True, + "section_key": key, + "label": meta["label"], + "title": meta["title"], + "md_source": str(md_path.relative_to(REPO_ROOT)).replace("\\", "/"), + "content_html": render_markdown_html(md_text), + } diff --git a/lib/hub/hub_host_status_lib.py b/lib/hub/hub_host_status_lib.py new file mode 100644 index 0000000..11dace1 --- /dev/null +++ b/lib/hub/hub_host_status_lib.py @@ -0,0 +1,98 @@ +"""中控:本机 CPU / 内存 / 磁盘 / 网络快照(监控区服务器状态条).""" +from __future__ import annotations + +import os +import socket +import time +from typing import Any + +_state: dict[str, Any] = { + "primed": False, + "net_ts": 0.0, + "net_sent": 0, + "net_recv": 0, +} + + +def _disk_path() -> str: + raw = (os.getenv("HUB_HOST_DISK_PATH") or "").strip() + if raw: + return raw + if os.name == "nt": + drive = (os.environ.get("SystemDrive") or "C:").strip() + return drive if drive.endswith(("\\", "/")) else drive + "\\" + return "/" + + +def _safe_int(value: Any) -> int: + try: + return int(value) + except (TypeError, ValueError): + return 0 + + +def get_host_status() -> dict[str, Any]: + try: + import psutil + except ImportError: + return { + "ok": False, + "msg": "未安装 psutil,请在 manual-trading-hub 环境执行 pip install psutil", + } + + now = time.time() + if not _state["primed"]: + psutil.cpu_percent(interval=None) + _state["primed"] = True + + cpu_pct = float(psutil.cpu_percent(interval=None)) + cpu_count = int(psutil.cpu_count(logical=True) or 0) + + vm = psutil.virtual_memory() + disk_path = _disk_path() + du = psutil.disk_usage(disk_path) + + net = psutil.net_io_counters() + sent_rate = 0.0 + recv_rate = 0.0 + if net is not None and _state["net_ts"] > 0: + dt = max(0.001, now - float(_state["net_ts"])) + sent_rate = max(0.0, (net.bytes_sent - int(_state["net_sent"])) / dt) + recv_rate = max(0.0, (net.bytes_recv - int(_state["net_recv"])) / dt) + if net is not None: + _state["net_ts"] = now + _state["net_sent"] = int(net.bytes_sent) + _state["net_recv"] = int(net.bytes_recv) + + disk_total = _safe_int(du.total) + disk_used = _safe_int(du.used) + disk_pct = round(disk_used / disk_total * 100, 1) if disk_total > 0 else 0.0 + + boot = float(psutil.boot_time()) + return { + "ok": True, + "hostname": socket.gethostname(), + "uptime_sec": max(0, int(now - boot)), + "cpu": { + "percent": round(cpu_pct, 1), + "count": cpu_count, + }, + "memory": { + "total_bytes": _safe_int(vm.total), + "used_bytes": _safe_int(vm.used), + "percent": round(float(vm.percent), 1), + }, + "disk": { + "path": disk_path, + "total_bytes": disk_total, + "used_bytes": disk_used, + "percent": disk_pct, + }, + "network": { + "bytes_sent": _safe_int(net.bytes_sent if net else 0), + "bytes_recv": _safe_int(net.bytes_recv if net else 0), + "sent_rate_bps": round(sent_rate, 1), + "recv_rate_bps": round(recv_rate, 1), + }, + "updated_at": time.strftime("%Y-%m-%d %H:%M:%S"), + } diff --git a/lib/hub/hub_kline_store.py b/lib/hub/hub_kline_store.py new file mode 100644 index 0000000..e1a6e6f --- /dev/null +++ b/lib/hub/hub_kline_store.py @@ -0,0 +1,881 @@ +"""中控 K 线 SQLite:分周期保留,交易所直拉,分页读取.""" + +from __future__ import annotations + +import os +import sqlite3 +import time +from pathlib import Path +from typing import Any, Callable, Optional + +from lib.hub.hub_ohlcv_lib import ( + HUB_KLINE_1M_MAX_BARS, + HUB_KLINE_5M_1H_RETENTION_DAYS, + TIMEFRAME_MS, + YEAR_ROLLING_STORED, + chart_chunk_limit, + chart_initial_limit, + chart_memory_cap, + history_cutoff_ms_for_storage, + normalize_chart_timeframe, + normalize_price_tick, + format_price_by_tick, + last_closed_bar_open_ms, + retention_policy_meta, + round_ohlcv_bars_to_tick, + seed_bar_target, +) + +HUB_KLINE_MIN_BARS_BEFORE_TAIL = 200 +HUB_KLINE_REMOTE_FETCH_CAP = 1500 + +_DEFAULT_RETENTION_DAYS = 15 + + +def retention_days() -> int: + """兼容旧配置;新策略见 retention_policy_meta.""" + try: + return max(1, int(os.getenv("HUB_KLINE_RETENTION_DAYS", str(_DEFAULT_RETENTION_DAYS)))) + except ValueError: + return _DEFAULT_RETENTION_DAYS + + +def default_db_path() -> Path: + raw = (os.getenv("HUB_KLINE_DB_PATH") or "").strip() + if raw: + return Path(raw) + from lib.paths import hub_data_dir + + return hub_data_dir() / "hub_kline.db" + + +def _connect(db_path: Path | None = None) -> sqlite3.Connection: + path = db_path or default_db_path() + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(path), timeout=30, isolation_level=None) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA synchronous=NORMAL") + return conn + + +def init_db(db_path: Path | None = None) -> None: + conn = _connect(db_path) + try: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS ohlcv_bars ( + exchange_key TEXT NOT NULL, + symbol TEXT NOT NULL, + timeframe TEXT NOT NULL, + open_time_ms INTEGER NOT NULL, + open REAL NOT NULL, + high REAL NOT NULL, + low REAL NOT NULL, + close REAL NOT NULL, + volume REAL NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL, + PRIMARY KEY (exchange_key, symbol, timeframe, open_time_ms) + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_ohlcv_series + ON ohlcv_bars (exchange_key, symbol, timeframe, open_time_ms) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS ohlcv_symbol_meta ( + exchange_key TEXT NOT NULL, + symbol TEXT NOT NULL, + price_tick REAL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (exchange_key, symbol) + ) + """ + ) + finally: + conn.close() + + +def save_symbol_price_tick( + exchange_key: str, + symbol: str, + price_tick: float | None, + db_path: Path | None = None, +) -> None: + tick = price_tick + if tick is None: + return + try: + t = float(tick) + except (TypeError, ValueError): + return + if t <= 0: + return + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + conn = _connect(db_path) + try: + conn.execute( + """ + INSERT INTO ohlcv_symbol_meta (exchange_key, symbol, price_tick, updated_at) + VALUES (?,?,?,?) + ON CONFLICT(exchange_key, symbol) DO UPDATE SET + price_tick=excluded.price_tick, + updated_at=excluded.updated_at + """, + (ex_k, sym, t, int(time.time())), + ) + finally: + conn.close() + + +def load_symbol_price_tick( + exchange_key: str, + symbol: str, + db_path: Path | None = None, +) -> float | None: + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + conn = _connect(db_path) + try: + row = conn.execute( + "SELECT price_tick FROM ohlcv_symbol_meta WHERE exchange_key=? AND symbol=?", + (ex_k, sym), + ).fetchone() + if not row or row["price_tick"] is None: + return None + return float(row["price_tick"]) + except (TypeError, ValueError): + return None + finally: + conn.close() + + +def purge_timeframe_by_days( + timeframe: str, + days: int, + db_path: Path | None = None, +) -> int: + cutoff = int(time.time() * 1000) - max(1, int(days)) * 86400000 + tf = normalize_chart_timeframe(timeframe) + conn = _connect(db_path) + try: + cur = conn.execute( + "DELETE FROM ohlcv_bars WHERE timeframe=? AND open_time_ms < ?", + (tf, cutoff), + ) + return int(cur.rowcount or 0) + finally: + conn.close() + + +def purge_1m_bar_cap(db_path: Path | None = None, *, max_bars: int | None = None) -> int: + cap = max(100, int(max_bars or HUB_KLINE_1M_MAX_BARS)) + conn = _connect(db_path) + try: + cur = conn.execute( + """ + DELETE FROM ohlcv_bars + WHERE timeframe='1m' AND rowid IN ( + SELECT rowid FROM ( + SELECT rowid, + ROW_NUMBER() OVER ( + PARTITION BY exchange_key, symbol + ORDER BY open_time_ms DESC + ) AS rn + FROM ohlcv_bars + WHERE timeframe='1m' + ) WHERE rn > ? + ) + """, + (cap,), + ) + return int(cur.rowcount or 0) + finally: + conn.close() + + +def clear_series_bars( + exchange_key: str, + symbol: str, + timeframe: str | None = None, + db_path: Path | None = None, +) -> int: + """删除某交易所+币种 K 线(可指定周期);用于清库后全量重拉.""" + init_db(db_path) + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + if not ex_k or not sym: + return 0 + conn = _connect(db_path) + try: + if timeframe: + tf = normalize_chart_timeframe(timeframe) + cur = conn.execute( + "DELETE FROM ohlcv_bars WHERE exchange_key=? AND symbol=? AND timeframe=?", + (ex_k, sym, tf), + ) + else: + cur = conn.execute( + "DELETE FROM ohlcv_bars WHERE exchange_key=? AND symbol=?", + (ex_k, sym), + ) + return int(cur.rowcount or 0) + finally: + conn.close() + + +def clear_all_bars(db_path: Path | None = None) -> int: + """清空 hub K 线库全部 OHLCV 行.""" + init_db(db_path) + conn = _connect(db_path) + try: + cur = conn.execute("DELETE FROM ohlcv_bars") + return int(cur.rowcount or 0) + finally: + conn.close() + + +def purge_retention(db_path: Path | None = None) -> int: + """按周期策略清理:5m/15m/1h/2h/4h 一年;1m 保留最近 N 根;1d/1w 不删.""" + n = 0 + for tf in sorted(YEAR_ROLLING_STORED): + n += purge_timeframe_by_days(tf, HUB_KLINE_5M_1H_RETENTION_DAYS, db_path) + n += purge_1m_bar_cap(db_path) + return n + + +def upsert_bars( + exchange_key: str, + symbol: str, + timeframe: str, + bars: list[dict[str, Any]], + db_path: Path | None = None, +) -> int: + if not bars: + return 0 + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + tf = normalize_chart_timeframe(timeframe) + now = int(time.time()) + conn = _connect(db_path) + n = 0 + try: + for b in bars: + try: + oms = int(b["open_time_ms"]) + conn.execute( + """ + INSERT INTO ohlcv_bars + (exchange_key, symbol, timeframe, open_time_ms, open, high, low, close, volume, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(exchange_key, symbol, timeframe, open_time_ms) DO UPDATE SET + open=excluded.open, + high=excluded.high, + low=excluded.low, + close=excluded.close, + volume=excluded.volume, + updated_at=excluded.updated_at + """, + ( + ex_k, + sym, + tf, + oms, + float(b["open"]), + float(b["high"]), + float(b["low"]), + float(b["close"]), + float(b.get("volume") or 0), + now, + ), + ) + n += 1 + except (KeyError, TypeError, ValueError): + continue + finally: + conn.close() + return n + + +def load_bars_range( + exchange_key: str, + symbol: str, + timeframe: str, + start_ms: int, + end_ms: int, + db_path: Path | None = None, +) -> list[dict[str, Any]]: + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + tf = normalize_chart_timeframe(timeframe) + conn = _connect(db_path) + try: + rows = conn.execute( + """ + SELECT open_time_ms, open, high, low, close, volume + FROM ohlcv_bars + WHERE exchange_key=? AND symbol=? AND timeframe=? + AND open_time_ms >= ? AND open_time_ms <= ? + ORDER BY open_time_ms ASC + """, + (ex_k, sym, tf, int(start_ms), int(end_ms)), + ).fetchall() + return _rows_to_bars(rows) + finally: + conn.close() + + +def count_series_bars( + exchange_key: str, + symbol: str, + timeframe: str, + db_path: Path | None = None, +) -> int: + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + tf = normalize_chart_timeframe(timeframe) + conn = _connect(db_path) + try: + row = conn.execute( + """ + SELECT COUNT(*) AS c FROM ohlcv_bars + WHERE exchange_key=? AND symbol=? AND timeframe=? + """, + (ex_k, sym, tf), + ).fetchone() + return int(row["c"] or 0) if row else 0 + finally: + conn.close() + + +def _remote_fetch_limit( + *, + need: int, + force_refresh: bool, + storage_tf: str, + tail_only: bool, +) -> int: + if tail_only: + return min(need + 20, 300) + cap = HUB_KLINE_REMOTE_FETCH_CAP + if force_refresh: + return min(seed_bar_target(storage_tf), cap) + return min(max(need + 20, 1), cap) + + +def _since_ms_for_span( + *, + now_ms: int, + period_ms: int, + span_bars: int, + cutoff_ms: int, +) -> int: + """拉取窗口起点:跨度必须与 fetch_limit 一致,保证数据能铺到最近.""" + span = max(1, int(span_bars)) + return max(int(cutoff_ms), int(now_ms) - int(period_ms) * span) + + +def load_bars_latest( + exchange_key: str, + symbol: str, + timeframe: str, + limit: int, + db_path: Path | None = None, +) -> list[dict[str, Any]]: + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + tf = normalize_chart_timeframe(timeframe) + lim = max(1, int(limit)) + conn = _connect(db_path) + try: + rows = conn.execute( + """ + SELECT open_time_ms, open, high, low, close, volume + FROM ohlcv_bars + WHERE exchange_key=? AND symbol=? AND timeframe=? + ORDER BY open_time_ms DESC + LIMIT ? + """, + (ex_k, sym, tf, lim), + ).fetchall() + return list(reversed(_rows_to_bars(rows))) + finally: + conn.close() + + +def load_bars_before( + exchange_key: str, + symbol: str, + timeframe: str, + before_ms: int, + limit: int, + db_path: Path | None = None, +) -> list[dict[str, Any]]: + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + tf = normalize_chart_timeframe(timeframe) + lim = max(1, int(limit)) + bms = int(before_ms) + conn = _connect(db_path) + try: + rows = conn.execute( + """ + SELECT open_time_ms, open, high, low, close, volume + FROM ohlcv_bars + WHERE exchange_key=? AND symbol=? AND timeframe=? + AND open_time_ms < ? + ORDER BY open_time_ms DESC + LIMIT ? + """, + (ex_k, sym, tf, bms, lim), + ).fetchall() + return list(reversed(_rows_to_bars(rows))) + finally: + conn.close() + + +def trim_contiguous_tail( + bars: list[dict[str, Any]], + period_ms: int, + *, + max_gap_factor: float = 3.0, +) -> tuple[list[dict[str, Any]], int]: + """只保留最近一段连续 K 线,丢弃左侧与主段断开的孤立数据.""" + if len(bars) <= 1: + return list(bars), 0 + try: + period = max(1, int(period_ms)) + except (TypeError, ValueError): + period = 60_000 + max_gap = int(period * max_gap_factor) + split = 0 + for i in range(len(bars) - 1, 0, -1): + gap = int(bars[i]["open_time_ms"]) - int(bars[i - 1]["open_time_ms"]) + if gap > max_gap: + split = i + break + return bars[split:], split + + +def normalize_contiguous_db_rows( + bars: list[dict[str, Any]], + *, + period_ms: int, + exchange_key: str, + symbol: str, + timeframe: str, + db_path: Path | None = None, + purge_orphans: bool = True, +) -> list[dict[str, Any]]: + """去掉与主段断开的孤立前缀;可选同步清理库内孤立数据.""" + if len(bars) <= 1: + return list(bars) + trimmed, split_at = trim_contiguous_tail(bars, period_ms) + if split_at > 0 and purge_orphans: + purge_bars_open_before( + exchange_key, + symbol, + timeframe, + int(trimmed[0]["open_time_ms"]), + db_path, + ) + return trimmed + + +def purge_bars_open_before( + exchange_key: str, + symbol: str, + timeframe: str, + open_time_ms: int, + db_path: Path | None = None, +) -> int: + """删除某品种周期下早于 open_time_ms 的 K 线(清理与主段断开的孤立历史).""" + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + tf = normalize_chart_timeframe(timeframe) + conn = _connect(db_path) + try: + cur = conn.execute( + """ + DELETE FROM ohlcv_bars + WHERE exchange_key=? AND symbol=? AND timeframe=? AND open_time_ms < ? + """, + (ex_k, sym, tf, int(open_time_ms)), + ) + return int(cur.rowcount or 0) + finally: + conn.close() + + +def _rows_to_bars(rows) -> list[dict[str, Any]]: + return [ + { + "open_time_ms": int(r["open_time_ms"]), + "open": float(r["open"]), + "high": float(r["high"]), + "low": float(r["low"]), + "close": float(r["close"]), + "volume": float(r["volume"] or 0), + } + for r in rows + ] + + +def _to_chart_candles(bars: list[dict[str, Any]]) -> list[dict[str, Any]]: + out = [] + for b in bars: + try: + out.append( + { + "time": int(b["open_time_ms"] // 1000), + "open": float(b["open"]), + "high": float(b["high"]), + "low": float(b["low"]), + "close": float(b["close"]), + "volume": float(b.get("volume") or 0), + } + ) + except (KeyError, TypeError, ValueError): + continue + return out + + +def _trim_display_bars( + bars: list[dict[str, Any]], + *, + need: int, + before_ms: int | None, +) -> list[dict[str, Any]]: + if not bars: + return [] + if before_ms is not None and int(before_ms) > 0: + bms = int(before_ms) + bars = [b for b in bars if int(b["open_time_ms"]) < bms] + if len(bars) > need: + bars = bars[-need:] + return bars + if len(bars) > need: + bars = bars[-need:] + return bars + + +def resolve_chart_bars( + exchange_key: str, + symbol: str, + timeframe: str, + remote_fetch: Callable[..., dict[str, Any]], + *, + db_path: Path | None = None, + force_refresh: bool = False, + tail_refresh: bool = False, + clear_db: bool = False, + limit: int | None = None, + before_ms: int | None = None, +) -> dict[str, Any]: + """ + 分页读库:首屏 / 左拖 before_ms / 尾部 tail_refresh. + 各展示周期均直读交易所同步入库的同名 K 线. + """ + init_db(db_path) + purged = purge_retention(db_path) + cleared = 0 + + sym = (symbol or "").strip().upper() + ex_k = (exchange_key or "").strip().lower() + display_tf = normalize_chart_timeframe(timeframe) + if not sym or not ex_k: + return {"ok": False, "msg": "缺少 exchange 或 symbol"} + + storage_tf = display_tf + is_history = before_ms is not None and int(before_ms) > 0 + need = int( + limit + or (chart_chunk_limit(display_tf) if is_history else chart_initial_limit(display_tf)) + ) + need = max(1, min(need, chart_memory_cap(display_tf))) + + now_ms = int(time.time() * 1000) + period_display = TIMEFRAME_MS[display_tf] + period_storage = TIMEFRAME_MS[storage_tf] + series_bar_count = ( + count_series_bars(ex_k, sym, storage_tf, db_path) if not is_history else 0 + ) + if tail_refresh and not is_history: + min_seed = min(chart_initial_limit(display_tf) // 5, HUB_KLINE_MIN_BARS_BEFORE_TAIL) + if series_bar_count < max(1, min_seed): + tail_refresh = False + else: + need = min(need, 30) + cutoff = history_cutoff_ms_for_storage(storage_tf, now_ms) + + if clear_db and not is_history and not tail_refresh: + cleared = clear_series_bars(ex_k, sym, storage_tf, db_path) + + def load_display_rows() -> list[dict[str, Any]]: + if is_history: + rows = load_bars_before(ex_k, sym, storage_tf, int(before_ms), need, db_path) + return _trim_display_bars(rows, need=need, before_ms=int(before_ms)) + return load_bars_latest(ex_k, sym, storage_tf, need, db_path) + + db_rows: list[dict[str, Any]] = [] + if not force_refresh: + db_rows = load_display_rows() + if not is_history and db_rows: + db_rows = normalize_contiguous_db_rows( + db_rows, + period_ms=period_display, + exchange_key=ex_k, + symbol=sym, + timeframe=storage_tf, + db_path=db_path, + ) + + last_closed = last_closed_bar_open_ms(display_tf, now_ms) + newest_db = db_rows[-1]["open_time_ms"] if db_rows else None + if is_history: + newest_ok = True + else: + newest_ok = newest_db is not None and int(newest_db) >= int(last_closed) - period_display + + need_fetch = force_refresh or ( + not is_history and (len(db_rows) < need or not newest_ok) + ) + if is_history and len(db_rows) < need: + need_fetch = True + + tail_only = False + if tail_refresh and not is_history and db_rows and not force_refresh and not need_fetch: + need_fetch = True + tail_only = True + + fetched = 0 + price_tick: Optional[float] = None + remote_err: Optional[str] = None + + if need_fetch: + if is_history: + bms = int(before_ms) + anchor = bms - period_display + since = max(cutoff, anchor - period_storage * need) + fetch_limit = min(need + 20, 1500) + elif tail_only: + anchor_ms = int(newest_db) if newest_db is not None else now_ms + fetch_limit = _remote_fetch_limit( + need=need, force_refresh=False, storage_tf=storage_tf, tail_only=True + ) + since = _since_ms_for_span( + now_ms=anchor_ms, + period_ms=period_storage, + span_bars=5, + cutoff_ms=cutoff, + ) + else: + fetch_limit = _remote_fetch_limit( + need=need, + force_refresh=force_refresh, + storage_tf=storage_tf, + tail_only=False, + ) + since = _since_ms_for_span( + now_ms=now_ms, + period_ms=period_storage, + span_bars=fetch_limit, + cutoff_ms=cutoff, + ) + + remote = remote_fetch( + symbol=sym, + timeframe=storage_tf, + since_ms=since, + limit=fetch_limit, + ) + if remote.get("ok") and remote.get("bars"): + fetched = upsert_bars(ex_k, sym, storage_tf, remote["bars"], db_path) + price_tick = remote.get("price_tick") + if price_tick is not None: + save_symbol_price_tick(ex_k, sym, price_tick, db_path) + db_rows = load_display_rows() + if not is_history and db_rows: + db_rows = normalize_contiguous_db_rows( + db_rows, + period_ms=period_display, + exchange_key=ex_k, + symbol=sym, + timeframe=storage_tf, + db_path=db_path, + ) + if not is_history and not tail_only and db_rows: + newest_ms = int(db_rows[-1]["open_time_ms"]) + if newest_ms < int(last_closed) - period_display: + gap_limit = min( + 500, + int((now_ms - newest_ms) // period_storage) + 10, + ) + if gap_limit > 1: + gap_remote = remote_fetch( + symbol=sym, + timeframe=storage_tf, + since_ms=newest_ms, + limit=gap_limit, + ) + if gap_remote.get("ok") and gap_remote.get("bars"): + fetched += upsert_bars( + ex_k, sym, storage_tf, gap_remote["bars"], db_path + ) + db_rows = load_display_rows() + db_rows = normalize_contiguous_db_rows( + db_rows, + period_ms=period_display, + exchange_key=ex_k, + symbol=sym, + timeframe=storage_tf, + db_path=db_path, + ) + else: + remote_err = remote.get("msg") or remote.get("error") or "实例拉取 K 线失败" + if not db_rows: + if is_history: + exhausted = True + else: + return {"ok": False, "msg": remote_err, "purged": purged} + + exhausted = False + if is_history: + if not db_rows: + exhausted = True + elif len(db_rows) < need: + oldest = int(db_rows[0]["open_time_ms"]) + if cutoff > 0 and oldest <= cutoff + period_storage: + exhausted = True + elif fetched == 0: + exhausted = True + + if price_tick is None: + price_tick = load_symbol_price_tick(ex_k, sym, db_path) + if price_tick is None and not is_history: + try: + tick_probe = remote_fetch( + symbol=sym, + timeframe=storage_tf, + since_ms=None, + limit=3, + ) + if tick_probe.get("ok"): + price_tick = tick_probe.get("price_tick") + if price_tick is not None: + save_symbol_price_tick(ex_k, sym, price_tick, db_path) + except Exception: + pass + + if not is_history and db_rows: + db_rows = normalize_contiguous_db_rows( + db_rows, + period_ms=period_display, + exchange_key=ex_k, + symbol=sym, + timeframe=storage_tf, + db_path=db_path, + ) + + if not is_history and len(db_rows) < need: + missing = need - len(db_rows) + backfill_limit = min(missing + 60, HUB_KLINE_REMOTE_FETCH_CAP) + if db_rows: + oldest = int(db_rows[0]["open_time_ms"]) + backfill_since = _since_ms_for_span( + now_ms=oldest, + period_ms=period_storage, + span_bars=backfill_limit, + cutoff_ms=cutoff, + ) + else: + backfill_since = _since_ms_for_span( + now_ms=now_ms, + period_ms=period_storage, + span_bars=backfill_limit, + cutoff_ms=cutoff, + ) + try: + remote_back = remote_fetch( + symbol=sym, + timeframe=storage_tf, + since_ms=backfill_since, + limit=backfill_limit, + ) + if remote_back.get("ok") and remote_back.get("bars"): + fetched += upsert_bars(ex_k, sym, storage_tf, remote_back["bars"], db_path) + if remote_back.get("price_tick") is not None: + price_tick = remote_back.get("price_tick") + save_symbol_price_tick(ex_k, sym, price_tick, db_path) + db_rows = load_display_rows() + db_rows = normalize_contiguous_db_rows( + db_rows, + period_ms=period_display, + exchange_key=ex_k, + symbol=sym, + timeframe=storage_tf, + db_path=db_path, + ) + elif not remote_err: + remote_err = ( + remote_back.get("msg") + or remote_back.get("error") + or "实例补拉 K 线失败" + ) + except Exception as e: + if not remote_err: + remote_err = str(e) + + price_tick = normalize_price_tick(price_tick) + if db_rows and price_tick is not None: + round_ohlcv_bars_to_tick(db_rows, price_tick) + + candles = _to_chart_candles(db_rows) + if not is_history and not candles and not exhausted: + return {"ok": False, "msg": remote_err or "无 K 线数据", "purged": purged} + + oldest_ms = int(db_rows[0]["open_time_ms"]) if db_rows else None + newest_ms = int(db_rows[-1]["open_time_ms"]) if db_rows else None + + from_cache = max(0, len(candles) - min(fetched, len(candles))) if fetched else len(candles) + + return { + "ok": True, + "symbol": sym, + "exchange_key": ex_k, + "timeframe": display_tf, + "storage_timeframe": storage_tf, + "limit": need, + "before_ms": int(before_ms) if is_history else None, + "oldest_ms": oldest_ms, + "newest_ms": newest_ms, + "exhausted": exhausted, + "source": "remote" if fetched else "db", + "retention_policy": retention_policy_meta(), + "candles": candles, + "from_cache": from_cache, + "fetched": fetched, + "cleared": cleared, + "purged": purged, + "price_tick": price_tick, + "stale": bool(remote_err), + "stale_message": remote_err if remote_err else None, + "updated_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), + } + + +def format_ohlcv_detail(bar: dict[str, Any] | None, tick: Optional[float]) -> dict[str, str]: + if not bar: + return {"open": "-", "high": "-", "low": "-", "close": "-", "volume": "-"} + return { + "open": format_price_by_tick(bar.get("open"), tick), + "high": format_price_by_tick(bar.get("high"), tick), + "low": format_price_by_tick(bar.get("low"), tick), + "close": format_price_by_tick(bar.get("close"), tick), + "volume": format_price_by_tick(bar.get("volume"), tick), + } diff --git a/lib/hub/hub_macro_calendar_lib.py b/lib/hub/hub_macro_calendar_lib.py new file mode 100644 index 0000000..6e02099 --- /dev/null +++ b/lib/hub/hub_macro_calendar_lib.py @@ -0,0 +1,311 @@ +"""中控宏观关键数据日历:手动录入 FOMC / CPI / 非农档发布时间,±1h 风控前置窗口.""" + +from __future__ import annotations + +import os +import sqlite3 +import time +from datetime import datetime +from pathlib import Path +from typing import Any +from zoneinfo import ZoneInfo + +from lib.hub.hub_symbol_archive_lib import parse_wall_clock_ms + +DISPLAY_TZ = ZoneInfo(os.getenv("APP_TIMEZONE", "Asia/Shanghai")) + +MACRO_EVENT_TYPES = ("fomc", "cpi", "employment") + +MACRO_EVENT_LABELS: dict[str, str] = { + "fomc": "FOMC 联邦基金利率", + "cpi": "美国 CPI 通胀", + "employment": "就业与劳工数据", +} + +WINDOW_BEFORE_MS = int(os.getenv("HUB_MACRO_WINDOW_BEFORE_SEC", str(3600))) * 1000 +WINDOW_AFTER_MS = int(os.getenv("HUB_MACRO_WINDOW_AFTER_SEC", str(3600))) * 1000 +IMMINENT_BEFORE_MS = int(os.getenv("HUB_MACRO_IMMINENT_BEFORE_SEC", str(1800))) * 1000 +LIST_FUTURE_DAYS = int(os.getenv("HUB_MACRO_LIST_FUTURE_DAYS", "60")) + + +def default_db_path() -> Path: + raw = (os.getenv("HUB_MACRO_CALENDAR_DB_PATH") or "").strip() + if raw: + return Path(raw) + from lib.paths import hub_data_dir + + return hub_data_dir() / "hub_macro_calendar.db" + + +def _connect(db_path: Path | None = None) -> sqlite3.Connection: + path = db_path or default_db_path() + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(path), timeout=30, isolation_level=None) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA synchronous=NORMAL") + return conn + + +def init_db(db_path: Path | None = None) -> None: + conn = _connect(db_path) + try: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS macro_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_type TEXT NOT NULL, + event_at_ms INTEGER NOT NULL, + note TEXT NOT NULL DEFAULT '', + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_macro_events_at ON macro_events(event_at_ms)" + ) + finally: + conn.close() + + +def normalize_event_type(raw: str) -> str: + key = (raw or "").strip().lower() + if key not in MACRO_EVENT_TYPES: + raise ValueError(f"事件类型须为: {', '.join(MACRO_EVENT_LABELS.values())}") + return key + + +def parse_event_at_ms(raw: Any) -> int: + ms = parse_wall_clock_ms(raw, tz=DISPLAY_TZ) + if ms is None: + raise ValueError("发布时间格式错误,请使用 YYYY-MM-DD HH:MM 或 YYYY-MM-DDTHH:MM") + return int(ms) + + +def format_event_at(ms: int) -> str: + dt = datetime.fromtimestamp(ms / 1000, tz=DISPLAY_TZ) + return dt.strftime("%Y-%m-%d %H:%M") + + +def _row_to_dict(row: sqlite3.Row) -> dict[str, Any]: + ms = int(row["event_at_ms"]) + et = str(row["event_type"]) + return { + "id": int(row["id"]), + "event_type": et, + "event_type_label": MACRO_EVENT_LABELS.get(et, et), + "event_at_ms": ms, + "event_at": format_event_at(ms), + "note": str(row["note"] or ""), + "created_at_ms": int(row["created_at_ms"]), + "updated_at_ms": int(row["updated_at_ms"]), + } + + +def _window_bounds(event_at_ms: int) -> tuple[int, int]: + start = int(event_at_ms) - WINDOW_BEFORE_MS + end = int(event_at_ms) + WINDOW_AFTER_MS + return start, end + + +def enrich_alert(row: dict[str, Any], now_ms: int | None = None) -> dict[str, Any] | None: + now = int(now_ms if now_ms is not None else time.time() * 1000) + event_at_ms = int(row["event_at_ms"]) + window_start, window_end = _window_bounds(event_at_ms) + if now < window_start or now > window_end: + return None + imminent = now >= (event_at_ms - IMMINENT_BEFORE_MS) and now <= window_end + mins_to_event = max(0, int((event_at_ms - now) / 60000)) + mins_from_event = max(0, int((now - event_at_ms) / 60000)) + return { + **row, + "window_start_ms": window_start, + "window_end_ms": window_end, + "window_start": format_event_at(window_start), + "window_end": format_event_at(window_end), + "phase": "imminent" if imminent else "window", + "phase_label": "即将发布" if imminent and now < event_at_ms else "高波动窗口", + "minutes_to_event": mins_to_event if now < event_at_ms else 0, + "minutes_from_event": mins_from_event if now >= event_at_ms else 0, + } + + +def list_events( + *, + now_ms: int | None = None, + include_expired_hours: int = 24, + db_path: Path | None = None, +) -> list[dict[str, Any]]: + init_db(db_path) + now = int(now_ms if now_ms is not None else time.time() * 1000) + horizon = now + LIST_FUTURE_DAYS * 86400 * 1000 + expired_cutoff = now - max(0, int(include_expired_hours)) * 3600 * 1000 - WINDOW_AFTER_MS + conn = _connect(db_path) + try: + rows = conn.execute( + """ + SELECT * FROM macro_events + WHERE event_at_ms >= ? AND event_at_ms <= ? + ORDER BY event_at_ms ASC, id ASC + """, + (expired_cutoff, horizon), + ).fetchall() + return [_row_to_dict(r) for r in rows] + finally: + conn.close() + + +def get_event(event_id: int, db_path: Path | None = None) -> dict[str, Any] | None: + init_db(db_path) + conn = _connect(db_path) + try: + row = conn.execute("SELECT * FROM macro_events WHERE id=?", (int(event_id),)).fetchone() + return _row_to_dict(row) if row else None + finally: + conn.close() + + +def _assert_no_duplicate( + conn: sqlite3.Connection, + event_type: str, + event_at_ms: int, + *, + exclude_id: int | None = None, +) -> None: + if exclude_id is None: + row = conn.execute( + "SELECT id FROM macro_events WHERE event_type=? AND event_at_ms=? LIMIT 1", + (event_type, int(event_at_ms)), + ).fetchone() + else: + row = conn.execute( + """ + SELECT id FROM macro_events + WHERE event_type=? AND event_at_ms=? AND id<>? + LIMIT 1 + """, + (event_type, int(event_at_ms), int(exclude_id)), + ).fetchone() + if row: + raise ValueError("同类型,同发布时间的记录已存在") + + +def create_event( + event_type: str, + event_at: Any, + *, + note: str = "", + db_path: Path | None = None, +) -> dict[str, Any]: + init_db(db_path) + et = normalize_event_type(event_type) + event_at_ms = parse_event_at_ms(event_at) + note_s = str(note or "").strip()[:500] + now_ms = int(time.time() * 1000) + conn = _connect(db_path) + try: + _assert_no_duplicate(conn, et, event_at_ms) + cur = conn.execute( + """ + INSERT INTO macro_events (event_type, event_at_ms, note, created_at_ms, updated_at_ms) + VALUES (?, ?, ?, ?, ?) + """, + (et, event_at_ms, note_s, now_ms, now_ms), + ) + eid = int(cur.lastrowid) + finally: + conn.close() + row = get_event(eid, db_path=db_path) + assert row is not None + return row + + +def update_event( + event_id: int, + *, + event_type: str | None = None, + event_at: Any | None = None, + note: str | None = None, + db_path: Path | None = None, +) -> dict[str, Any] | None: + init_db(db_path) + existing = get_event(event_id, db_path=db_path) + if not existing: + return None + et = normalize_event_type(event_type if event_type is not None else existing["event_type"]) + event_at_ms = ( + parse_event_at_ms(event_at) if event_at is not None else int(existing["event_at_ms"]) + ) + note_s = existing["note"] if note is None else str(note or "").strip()[:500] + now_ms = int(time.time() * 1000) + conn = _connect(db_path) + try: + _assert_no_duplicate(conn, et, event_at_ms, exclude_id=int(event_id)) + conn.execute( + """ + UPDATE macro_events + SET event_type=?, event_at_ms=?, note=?, updated_at_ms=? + WHERE id=? + """, + (et, event_at_ms, note_s, now_ms, int(event_id)), + ) + finally: + conn.close() + return get_event(event_id, db_path=db_path) + + +def delete_event(event_id: int, db_path: Path | None = None) -> bool: + init_db(db_path) + conn = _connect(db_path) + try: + cur = conn.execute("DELETE FROM macro_events WHERE id=?", (int(event_id),)) + return cur.rowcount > 0 + finally: + conn.close() + + +def list_active_alerts( + now_ms: int | None = None, + db_path: Path | None = None, +) -> list[dict[str, Any]]: + now = int(now_ms if now_ms is not None else time.time() * 1000) + lookback = now - WINDOW_BEFORE_MS - IMMINENT_BEFORE_MS + lookahead = now + WINDOW_AFTER_MS + init_db(db_path) + conn = _connect(db_path) + try: + rows = conn.execute( + """ + SELECT * FROM macro_events + WHERE event_at_ms >= ? AND event_at_ms <= ? + ORDER BY event_at_ms ASC, id ASC + """, + (lookback, lookahead), + ).fetchall() + finally: + conn.close() + alerts: list[dict[str, Any]] = [] + for row in rows: + item = enrich_alert(_row_to_dict(row), now_ms=now) + if item: + alerts.append(item) + return alerts + + +def build_banner_message(alert: dict[str, Any], *, has_positions: bool) -> str: + label = alert.get("event_type_label") or alert.get("event_type") or "宏观数据" + phase = alert.get("phase") or "window" + if has_positions: + if phase == "imminent" and int(alert.get("minutes_to_event") or 0) > 0: + return ( + f"「{label}」即将发布(约 {alert['minutes_to_event']} 分钟)," + "注意仓位风险:勿加仓,检查止损/减仓" + ) + return f"「{label}」高波动窗口(±1h),注意仓位风险:勿加仓,检查止损/减仓" + if phase == "imminent" and int(alert.get("minutes_to_event") or 0) > 0: + return ( + f"「{label}」即将发布(约 {alert['minutes_to_event']} 分钟)," + "建议等待,避免新开仓" + ) + return f"「{label}」高波动窗口(±1h),建议等待,避免新开仓" diff --git a/lib/hub/hub_market_info_lib.py b/lib/hub/hub_market_info_lib.py new file mode 100644 index 0000000..3dba2b7 --- /dev/null +++ b/lib/hub/hub_market_info_lib.py @@ -0,0 +1,81 @@ +"""实例 USDT 永续合约信息(与实盘 ccxt 精度一致).""" + +from __future__ import annotations + +from typing import Any, Callable, Optional, Tuple + +from lib.hub.hub_calculator_market_lib import ( + amount_decimals_from_exchange, + normalize_base_symbol, + price_decimals_from_exchange, + resolve_usdt_perp_symbol, +) +from lib.hub.hub_ohlcv_lib import normalize_price_tick, price_tick_from_market + + +def fetch_usdt_swap_market_info( + *, + base_or_symbol: str, + normalize_symbol_input: Callable[[str], str], + normalize_exchange_symbol: Callable[[str], str], + ensure_markets_loaded: Callable[[], None], + exchange: Any, + exchange_id: str = "", +) -> dict[str, Any]: + """供各实例 /api/hub/market 调用.""" + raw = str(base_or_symbol or "").strip() + if not raw: + return {"ok": False, "msg": "请输入币种,如 ETH"} + + try: + ensure_markets_loaded() + except Exception as exc: + return {"ok": False, "msg": f"加载市场失败: {exc}"} + + base_u = normalize_base_symbol(raw) + hub_sym = normalize_symbol_input(raw if base_u else raw) + try: + ex_sym = normalize_exchange_symbol(hub_sym) + except Exception: + ex_sym = hub_sym + + sym, err = resolve_usdt_perp_symbol(exchange, base_u or hub_sym) + if err and ex_sym: + markets = getattr(exchange, "markets", None) or {} + if ex_sym in markets: + sym = ex_sym + err = None + if err or not sym: + return {"ok": False, "msg": err or f"未找到 {base_u or raw}/USDT 永续合约"} + + market = exchange.market(sym) + try: + contract_size = float(market.get("contractSize") or 1.0) + except (TypeError, ValueError): + contract_size = 1.0 + if contract_size <= 0: + contract_size = 1.0 + + price_tick = normalize_price_tick(price_tick_from_market(exchange, sym)) + amt_dec = amount_decimals_from_exchange(exchange, sym) + px_dec = price_decimals_from_exchange(exchange, sym, price_tick) + min_amount = None + try: + min_amount = float((market.get("limits") or {}).get("amount", {}).get("min")) + except (TypeError, ValueError): + min_amount = None + + base_out = (market.get("base") or base_u or "").upper() or base_u + return { + "ok": True, + "exchange": (exchange_id or "").strip().lower(), + "base": base_out, + "exchange_symbol": sym, + "display_symbol": f"{base_out}/USDT" if base_out else sym, + "contract_size": contract_size, + "price_tick": price_tick, + "price_decimals": px_dec, + "amount_decimals": amt_dec, + "min_amount": min_amount, + } + diff --git a/lib/hub/hub_monitor_totals_lib.py b/lib/hub/hub_monitor_totals_lib.py new file mode 100644 index 0000000..9bba504 --- /dev/null +++ b/lib/hub/hub_monitor_totals_lib.py @@ -0,0 +1,111 @@ +"""监控区看板:三所当日统计聚合.""" +from __future__ import annotations + +from typing import Any + +from lib.hub.hub_options_funds_lib import ( + options_float_pnl_usdt, + options_open_position_count as count_options_positions, +) + + +def _coerce_float(value: Any) -> float | None: + if value is None or value == "": + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def position_unrealized_pnl(pos: dict[str, Any]) -> float: + for key in ("unrealized_pnl", "unrealizedPnl", "upnl"): + v = _coerce_float(pos.get(key)) + if v is not None: + return v + return 0.0 + + +def _open_positions(agent: dict[str, Any] | None) -> list[dict[str, Any]]: + if not isinstance(agent, dict): + return [] + positions = agent.get("positions") + if not isinstance(positions, list): + return [] + out: list[dict[str, Any]] = [] + for p in positions: + if not isinstance(p, dict): + continue + try: + c = abs(float(p.get("contracts") or 0)) + except (TypeError, ValueError): + c = 0.0 + if c > 1e-12: + out.append(p) + return out + + +def aggregate_monitor_board_totals( + rows: list[dict[str, Any]], + *, + trading_day: str, + reset_hour: int = 8, +) -> dict[str, Any]: + """汇总监控 board 各行 → 左上统计卡数据.""" + open_count = 0 + closed_count = 0 + win_count = 0 + loss_count = 0 + win_pnl_u = 0.0 + loss_pnl_u = 0.0 + open_position_count = 0 + options_open_position_count = 0 + float_pnl_u = 0.0 + options_float_pnl_u = 0.0 + + for row in rows or []: + if not isinstance(row, dict): + continue + day_stats = row.get("day_stats") if isinstance(row.get("day_stats"), dict) else {} + if day_stats.get("ok"): + open_count += int(day_stats.get("opens_today") or 0) + st = day_stats.get("trade_stats") if isinstance(day_stats.get("trade_stats"), dict) else {} + closed_count += int(st.get("closed_count") or 0) + win_count += int(st.get("win_count") or 0) + loss_count += int(st.get("loss_count") or 0) + win_pnl_u += float(st.get("win_pnl_u") or 0) + loss_pnl_u += float(st.get("loss_pnl_u") or 0) + + ag = row.get("agent") if isinstance(row.get("agent"), dict) else {} + open_pos = _open_positions(ag) + open_position_count += len(open_pos) + agent_upnl = _coerce_float(ag.get("total_unrealized_pnl")) + if agent_upnl is not None: + float_pnl_u += agent_upnl + else: + float_pnl_u += sum(position_unrealized_pnl(p) for p in open_pos) + + opt_snap = row.get("options") if "options" in (row.get("capabilities") or []) else None + opt_count = count_options_positions(opt_snap) + options_open_position_count += opt_count + open_position_count += opt_count + opt_upl = options_float_pnl_usdt(opt_snap) + if opt_upl is not None: + options_float_pnl_u += opt_upl + float_pnl_u += opt_upl + + return { + "trading_day": trading_day, + "reset_hour": int(reset_hour), + "open_count": open_count, + "closed_count": closed_count, + "win_count": win_count, + "loss_count": loss_count, + "win_pnl_u": round(win_pnl_u, 4), + "loss_pnl_u": round(loss_pnl_u, 4), + "realized_pnl_u": round(win_pnl_u + loss_pnl_u, 4), + "open_position_count": open_position_count, + "options_open_position_count": options_open_position_count, + "float_pnl_u": round(float_pnl_u, 4), + "options_float_pnl_u": round(options_float_pnl_u, 4), + } diff --git a/lib/hub/hub_ohlcv_lib.py b/lib/hub/hub_ohlcv_lib.py new file mode 100644 index 0000000..9b3473f --- /dev/null +++ b/lib/hub/hub_ohlcv_lib.py @@ -0,0 +1,692 @@ +"""中控行情区:各实例 ccxt OHLCV 拉取(hub_bridge /api/hub/ohlcv 共用).""" + +from __future__ import annotations + +import math +import os +import time +from typing import Any, Callable, Optional + +CHART_TIMEFRAMES = frozenset( + { + "1m", + "5m", + "15m", + "1h", + "2h", + "4h", + "1d", + "1w", + } +) +CHART_TIMEFRAME_ORDER = ( + "1m", + "5m", + "15m", + "1h", + "2h", + "4h", + "1d", + "1w", +) +DAILY_PLUS_TIMEFRAMES = frozenset({"1d", "1w"}) + +# 入库 / 同步真源(各周期直拉交易所,不做本地聚合) +STORED_TIMEFRAMES = frozenset(CHART_TIMEFRAMES) +PERMANENT_STORED_TIMEFRAMES = frozenset({"1d", "1w"}) +YEAR_ROLLING_STORED = frozenset({"5m", "15m", "1h", "2h", "4h"}) + +# 行情区不做展示周期聚合;保留空映射供兼容读取 +CHART_DISPLAY_AGGREGATE_FROM: dict[str, str] = {} + +SMALL_DISPLAY_TFS = frozenset({"1m", "5m", "15m"}) +MID_DISPLAY_TFS = frozenset({"1h", "2h", "4h"}) + +HUB_KLINE_1M_MAX_BARS = max(1000, int(os.getenv("HUB_KLINE_1M_MAX_BARS", "10000"))) +HUB_KLINE_5M_1H_RETENTION_DAYS = max(30, int(os.getenv("HUB_KLINE_5M_1H_RETENTION_DAYS", "365"))) +HUB_KLINE_SEED_BARS = max(100, int(os.getenv("HUB_KLINE_SEED_BARS", "500"))) + +# 交易所无原生周期时的远程拉取 fallback(行情区当前无映射) +OHLCV_AGGREGATE_FROM: dict[str, str] = {} + +TIMEFRAME_MS: dict[str, int] = { + "1m": 60_000, + "5m": 5 * 60_000, + "15m": 15 * 60_000, + "1h": 60 * 60_000, + "2h": 2 * 60 * 60_000, + "4h": 4 * 60 * 60_000, + "12h": 12 * 60 * 60_000, + "1d": 24 * 60 * 60_000, + "1w": 7 * 24 * 60 * 60_000, +} + + +def normalize_chart_timeframe(raw: str | None, default: str = "5m") -> str: + tf = (raw or default).strip().lower() + return tf if tf in CHART_TIMEFRAMES else default + + +def normalize_perpetual_symbol(symbol: str) -> str: + """BTC/USDT → BTC/USDT:USDT(与三所 ccxt swap 行情一致).""" + sym = (symbol or "").strip().upper() + if not sym: + return "" + 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 sync_timeframe_for_display(timeframe: str) -> str: + """展示周期对应的入库 / 同步周期.""" + tf = normalize_chart_timeframe(timeframe) + return CHART_DISPLAY_AGGREGATE_FROM.get(tf, tf) + + +def aggregation_source_for_display(timeframe: str) -> str | None: + tf = normalize_chart_timeframe(timeframe) + return CHART_DISPLAY_AGGREGATE_FROM.get(tf) + + +def aggregate_ratio(display_tf: str, source_tf: str) -> int: + d = normalize_chart_timeframe(display_tf) + s = normalize_chart_timeframe(source_tf) + return max(1, int(TIMEFRAME_MS[d] // TIMEFRAME_MS[s])) + + +def chart_initial_limit(timeframe: str) -> int: + tf = normalize_chart_timeframe(timeframe) + if tf in SMALL_DISPLAY_TFS: + return 2000 + if tf in MID_DISPLAY_TFS: + return 1000 + if tf in DAILY_PLUS_TIMEFRAMES: + return 500 + return 500 + + +def chart_chunk_limit(timeframe: str) -> int: + tf = normalize_chart_timeframe(timeframe) + if tf in SMALL_DISPLAY_TFS: + return 500 + if tf == "1w": + return 150 + if tf in MID_DISPLAY_TFS: + return 300 + return 200 + + +def chart_memory_cap(timeframe: str) -> int: + tf = normalize_chart_timeframe(timeframe) + if tf in SMALL_DISPLAY_TFS: + return 5000 + if tf == "1w": + return 500 + return 1000 + + +def bar_limit_for_timeframe(timeframe: str) -> int: + return chart_memory_cap(timeframe) + + +def storage_retention_days(storage_tf: str) -> int | None: + """None 表示不按天截断(1m 按根数;1d/1w 永久).""" + tf = normalize_chart_timeframe(storage_tf) + if tf in YEAR_ROLLING_STORED: + return HUB_KLINE_5M_1H_RETENTION_DAYS + return None + + +def history_cutoff_ms_for_storage(storage_tf: str, now_ms: int | None = None) -> int: + days = storage_retention_days(storage_tf) + if days is None: + return 0 + now = int(now_ms if now_ms is not None else time.time() * 1000) + return max(0, now - int(days) * 86400000) + + +def seed_bar_target(storage_tf: str) -> int: + tf = normalize_chart_timeframe(storage_tf) + if tf == "1m": + return HUB_KLINE_1M_MAX_BARS + if tf in YEAR_ROLLING_STORED: + period = TIMEFRAME_MS[tf] + return min( + int(86400000 * HUB_KLINE_5M_1H_RETENTION_DAYS / period) + 20, + 150000, + ) + return HUB_KLINE_SEED_BARS + + +def retention_policy_meta() -> dict[str, Any]: + year = {"mode": "days", "days": HUB_KLINE_5M_1H_RETENTION_DAYS} + return { + "1m": {"mode": "bars", "max_bars": HUB_KLINE_1M_MAX_BARS}, + "5m": dict(year), + "15m": dict(year), + "1h": dict(year), + "2h": dict(year), + "4h": dict(year), + "1d": {"mode": "permanent"}, + "1w": {"mode": "permanent"}, + "aggregate_from": {}, + } + + +def last_closed_bar_open_ms(timeframe: str, now_ms: int | None = None) -> int: + """上一根已收盘 K 的 open_time(毫秒 UTC).""" + tf = normalize_chart_timeframe(timeframe) + period = TIMEFRAME_MS[tf] + now = int(now_ms if now_ms is not None else time.time() * 1000) + current_open = (now // period) * period + return int(current_open - period) + + +def window_start_ms(timeframe: str, need: int, retention_days: int, now_ms: int | None = None) -> int: + """本地库清理/读库窗口:不超过 retention_days.""" + now = int(now_ms if now_ms is not None else time.time() * 1000) + period = TIMEFRAME_MS[normalize_chart_timeframe(timeframe)] + retention_cutoff = now - max(1, int(retention_days)) * 86400000 + want = now - max(1, int(need)) * period + return max(retention_cutoff, want) + + +def chart_fetch_start_ms(timeframe: str, need: int, now_ms: int | None = None) -> int: + """行情展示拉取起点:按 need 根回看(日线 500 / 日内 1000),不受 DB 保留天数限制.""" + now = int(now_ms if now_ms is not None else time.time() * 1000) + period = TIMEFRAME_MS[normalize_chart_timeframe(timeframe)] + return max(0, now - max(1, int(need)) * period) + + +def _positive_float(value: Any) -> Optional[float]: + if value in (None, ""): + return None + try: + v = float(value) + except (TypeError, ValueError): + return None + return v if v > 0 else None + + +def _price_tick_from_market_info(info: dict) -> Optional[float]: + """从 market.info 解析 tick(含币安 PRICE_FILTER.filters).""" + for key in ("tickSize", "tickSz", "price_increment", "order_price_round", "quote_increment"): + v = _positive_float(info.get(key)) + if v is not None: + return v + + for key in ("pricePrecision", "price_precision"): + raw = info.get(key) + if raw in (None, ""): + continue + try: + p = float(raw) + except (TypeError, ValueError): + continue + if p >= 1 and abs(p - round(p)) < 1e-9 and p <= 12: + return 10 ** (-int(p)) + if 0 < p < 1: + return p + + filters = info.get("filters") + if isinstance(filters, list): + for f in filters: + if not isinstance(f, dict): + continue + if str(f.get("filterType") or "").upper() != "PRICE_FILTER": + continue + v = _positive_float(f.get("tickSize")) + if v is not None: + return v + return None + + +def round_price_to_tick(value: Any, tick: Optional[float]) -> Optional[float]: + """按交易所 tick 对齐价格(K 线/标记线与坐标轴一致).""" + t = normalize_price_tick(tick) + if t is None: + return None + try: + v = float(value) + except (TypeError, ValueError): + return None + n = round(v / t) * t + d = _decimals_from_tick(t) + return float(f"{n:.{d}f}") + + +def round_ohlcv_bars_to_tick(bars: list[dict[str, Any]], tick: Optional[float]) -> None: + t = normalize_price_tick(tick) + if t is None: + return + for b in bars: + for key in ("open", "high", "low", "close"): + if key in b: + rounded = round_price_to_tick(b.get(key), t) + if rounded is not None: + b[key] = rounded + + +def price_tick_from_market(exchange, exchange_symbol: str) -> Optional[float]: + """最小价格变动单位(与交易所 tick / price_to_precision 一致).""" + try: + if not getattr(exchange, "markets", None): + exchange.load_markets() + market = exchange.market(exchange_symbol) + except Exception: + return None + + info = market.get("info") or {} + if isinstance(info, dict): + tick = _price_tick_from_market_info(info) + if tick is not None: + return tick + + limits = market.get("limits") or {} + price_limits = limits.get("price") or {} + if price_limits.get("min") not in (None, ""): + try: + v = float(price_limits["min"]) + if v > 0: + return v + except (TypeError, ValueError): + pass + + try: + sample = exchange.price_to_precision(exchange_symbol, 12345.678901234) + s = str(sample).strip() + if "." in s: + frac = s.split(".", 1)[1] + if frac: + return 10 ** (-len(frac)) + return 1.0 + except Exception: + pass + + prec = (market.get("precision") or {}).get("price") + if prec is not None: + try: + p = float(prec) + if p >= 1 and abs(p - round(p)) < 1e-9 and p <= 12: + return 10 ** (-int(p)) + if 0 < p < 1: + return p + except (TypeError, ValueError): + pass + return None + + +def normalize_price_tick(tick: Optional[float]) -> Optional[float]: + """将 tick 对齐为 10^-n,避免浮点噪声导致前端 lightweight-charts unexpected base.""" + if tick is None: + return None + try: + t = float(tick) + except (TypeError, ValueError): + return None + if t <= 0: + return None + if t >= 1: + return t + try: + exp = int(round(-math.log10(t))) + except (ValueError, OverflowError): + return None + exp = max(0, min(12, exp)) + return 10 ** (-exp) + + +def _decimals_from_tick(tick: float) -> int: + if tick >= 1: + return 0 + s = f"{tick:.12f}".rstrip("0") + if "." in s: + frac = s.split(".", 1)[1] + if frac: + return min(12, len(frac)) + return max(0, min(12, int(round(-math.log10(tick))))) + + +def format_price_by_tick(value: Any, tick: Optional[float]) -> str: + if value in (None, ""): + return "-" + try: + v = float(value) + except (TypeError, ValueError): + return str(value) + if v == 0: + return "0" + if tick and tick > 0: + return f"{v:.{_decimals_from_tick(float(tick))}f}" + av = abs(v) + if av >= 10000: + d = 2 + elif av >= 100: + d = 3 + elif av >= 1: + d = 4 + elif av >= 0.01: + d = 6 + else: + d = 8 + text = f"{v:.{d}f}" + return text.rstrip("0").rstrip(".") if "." in text else text + + +def exchange_supports_timeframe(exchange, timeframe: str) -> bool: + tf = normalize_chart_timeframe(timeframe) + tfs = getattr(exchange, "timeframes", None) or {} + if not tfs: + return True + return tf in tfs + + +def _median_bar_step_ms(bars: list[dict[str, Any]]) -> Optional[int]: + if len(bars) < 2: + return None + steps: list[int] = [] + for i in range(1, min(len(bars), 64)): + step = int(bars[i]["open_time_ms"]) - int(bars[i - 1]["open_time_ms"]) + if step > 0: + steps.append(step) + if not steps: + return None + steps.sort() + return steps[len(steps) // 2] + + +def bars_spacing_matches_timeframe( + bars: list[dict[str, Any]], timeframe: str, *, tolerance: float = 0.08 +) -> bool: + if len(bars) < 2: + return True + period = TIMEFRAME_MS[normalize_chart_timeframe(timeframe)] + step = _median_bar_step_ms(bars) + if step is None: + return False + return abs(step - period) <= period * tolerance + + +def align_bar_open_ms(open_time_ms: int, period_ms: int) -> int: + return (int(open_time_ms) // period_ms) * period_ms + + +def snap_to_bar_grid(ts_ms: int, origin_ms: int, step_ms: int) -> int: + step = max(1, int(step_ms)) + origin = int(origin_ms) + if ts_ms <= origin: + return origin + idx = (int(ts_ms) - origin + step - 1) // step + return origin + idx * step + + +def fill_missing_ohlcv_bars( + bars: list[dict[str, Any]], + period_ms: int, + start_ms: int | None = None, + end_ms: int | None = None, +) -> list[dict[str, Any]]: + """细周期缺口用上一根收盘价填平,保证聚合后 K 线时间轴连续.""" + by_ts: dict[int, dict[str, Any]] = {} + for b in bars or []: + try: + by_ts[int(b["open_time_ms"])] = b + except (KeyError, TypeError, ValueError): + continue + if not by_ts: + return [] + keys = sorted(by_ts.keys()) + step_ms = max(1, int(period_ms)) + origin = keys[0] + aligned_start = snap_to_bar_grid( + int(start_ms if start_ms is not None else keys[0]), origin, step_ms + ) + aligned_end = max( + int(end_ms if end_ms is not None else keys[-1]), + keys[-1], + ) + out: list[dict[str, Any]] = [] + last: dict[str, Any] | None = None + for ts_key in keys: + if ts_key <= aligned_start: + last = by_ts[ts_key] + ts = aligned_start + while ts <= aligned_end: + cur = by_ts.get(ts) + if cur is not None: + last = cur + out.append(cur) + elif last is not None: + c = float(last["close"]) + out.append( + { + "open_time_ms": ts, + "open": c, + "high": c, + "low": c, + "close": c, + "volume": 0.0, + "filled": True, + } + ) + ts += step_ms + return out + + +def aggregate_ohlcv_bars( + bars: list[dict[str, Any]], target_timeframe: str +) -> list[dict[str, Any]]: + """将细周期 OHLCV 聚合为目标周期(UTC 对齐 bucket).""" + tf = normalize_chart_timeframe(target_timeframe) + period = TIMEFRAME_MS[tf] + buckets: dict[int, dict[str, Any]] = {} + for b in bars or []: + try: + key = align_bar_open_ms(int(b["open_time_ms"]), period) + o = float(b["open"]) + h = float(b["high"]) + l = float(b["low"]) + c = float(b["close"]) + v = float(b.get("volume") or 0) + except (KeyError, TypeError, ValueError): + continue + cur = buckets.get(key) + if cur is None: + buckets[key] = { + "open_time_ms": key, + "open": o, + "high": h, + "low": l, + "close": c, + "volume": v, + } + continue + cur["high"] = max(float(cur["high"]), h) + cur["low"] = min(float(cur["low"]), l) + cur["close"] = c + cur["volume"] = float(cur.get("volume") or 0) + v + return [buckets[k] for k in sorted(buckets.keys())] + + +def _next_since_from_batch(batch: list, period_ms: int) -> int: + last_ts = int(batch[-1][0]) + if len(batch) >= 2: + step = int(batch[-1][0]) - int(batch[-2][0]) + if step > 0: + return last_ts + step + return last_ts + period_ms + + +def _paginate_fetch_ohlcv( + exchange, + ex_sym: str, + timeframe: str, + *, + want: int, + since_ms: int | None, + period_ms: int, + chunk_max: int = 300, +) -> list[dict[str, Any]]: + tf = normalize_chart_timeframe(timeframe) + collected: list = [] + if since_ms is not None and int(since_ms) > 0: + since = int(since_ms) + else: + since = max(0, int(time.time() * 1000) - want * period_ms) + + now_ms = int(time.time() * 1000) + guard = 0 + prev_since = None + while len(collected) < want and guard < 80: + guard += 1 + if since >= now_ms: + break + req_limit = min(chunk_max, want - len(collected)) + try: + batch = exchange.fetch_ohlcv( + ex_sym, timeframe=tf, since=since, limit=req_limit + ) + except Exception as e: + err = str(e).lower() + if collected and ( + "from" in err + and "to" in err + or "invalid request parameter" in err + ): + break + raise + if not batch: + break + collected.extend(batch) + next_since = _next_since_from_batch(batch, period_ms) + if next_since >= now_ms: + break + if prev_since is not None and next_since <= prev_since: + break + prev_since = since + since = next_since + + bars = _bars_to_dicts(collected) + uniq: dict[int, dict[str, Any]] = {} + for b in bars: + uniq[int(b["open_time_ms"])] = b + merged = [uniq[k] for k in sorted(uniq.keys())] + if len(merged) > want: + merged = merged[-want:] + return merged + + +def _bars_to_dicts(ohlcv: list) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for bar in ohlcv or []: + if not bar or len(bar) < 6: + continue + try: + out.append( + { + "open_time_ms": int(bar[0]), + "open": float(bar[1]), + "high": float(bar[2]), + "low": float(bar[3]), + "close": float(bar[4]), + "volume": float(bar[5]), + } + ) + except (TypeError, ValueError): + continue + return out + + +def fetch_ohlcv_for_hub( + *, + symbol: str, + timeframe: str, + since_ms: int | None = None, + limit: int = 500, + normalize_symbol_input: Callable[[Any], str], + normalize_exchange_symbol: Callable[[str], str], + ensure_markets_loaded: Callable[[], None], + exchange, + friendly_error: Callable[[Exception], str] | None = None, +) -> dict[str, Any]: + """从 ccxt 拉 OHLCV,供 hub_bridge /api/hub/ohlcv 返回.""" + tf = normalize_chart_timeframe(timeframe) + sym = normalize_symbol_input(symbol) + if not sym: + return {"ok": False, "msg": "symbol 不能为空"} + try: + ensure_markets_loaded() + ex_sym = normalize_exchange_symbol(sym) + want = max(1, min(int(limit or bar_limit_for_timeframe(tf)), 1500)) + period = TIMEFRAME_MS[tf] + merged: list[dict[str, Any]] = [] + src_tf = OHLCV_AGGREGATE_FROM.get(tf) + + if exchange_supports_timeframe(exchange, tf): + candidate = _paginate_fetch_ohlcv( + exchange, + ex_sym, + tf, + want=want, + since_ms=since_ms, + period_ms=period, + ) + if candidate and bars_spacing_matches_timeframe(candidate, tf): + merged = candidate + + if ( + not merged + and src_tf + and exchange_supports_timeframe(exchange, src_tf) + ): + src_period = TIMEFRAME_MS[normalize_chart_timeframe(src_tf)] + ratio = max(1, int(math.ceil(period / src_period))) + src_want = min(1500, want * ratio + ratio * 4) + src_bars = _paginate_fetch_ohlcv( + exchange, + ex_sym, + src_tf, + want=src_want, + since_ms=since_ms, + period_ms=src_period, + ) + if not src_bars or not bars_spacing_matches_timeframe(src_bars, src_tf): + return { + "ok": False, + "msg": f"无法获取 {tf} K 线(细周期 {src_tf} 数据异常)", + } + merged = aggregate_ohlcv_bars(src_bars, tf) + if len(merged) > want: + merged = merged[-want:] + + if not merged: + try: + tail = exchange.fetch_ohlcv( + ex_sym, timeframe=tf, limit=min(want, 300) + ) + merged = _bars_to_dicts(tail or []) + if len(merged) > want: + merged = merged[-want:] + except Exception: + pass + if not merged: + return {"ok": False, "msg": "交易所未返回 K 线"} + + tick = normalize_price_tick(price_tick_from_market(exchange, ex_sym)) + round_ohlcv_bars_to_tick(merged, tick) + + return { + "ok": True, + "symbol": sym, + "exchange_symbol": ex_sym, + "timeframe": tf, + "price_tick": tick, + "bars": merged, + } + except Exception as e: + msg = friendly_error(e) if friendly_error else str(e) + return {"ok": False, "msg": f"K线加载失败:{msg}"} diff --git a/lib/hub/hub_options_funds_lib.py b/lib/hub/hub_options_funds_lib.py new file mode 100644 index 0000000..23448d9 --- /dev/null +++ b/lib/hub/hub_options_funds_lib.py @@ -0,0 +1,117 @@ +"""中控资金统计:期权 USDC/USDT 按 1:1 计入 USDT 合计.""" +from __future__ import annotations + +from typing import Any, Optional + + +def _safe_float(value: Any) -> Optional[float]: + try: + if value is None or value == "": + return None + v = float(value) + return v if v >= 0 else None + except (TypeError, ValueError): + return None + + +def _account_total_usdt(funding: Any, trading: Any) -> Optional[float]: + fu = _safe_float(funding) + tu = _safe_float(trading) + if fu is None or tu is None: + return None + return round(fu + tu, 4) + + +def stablecoin_usdt_equiv(value: Any) -> Optional[float]: + """USDC / USDT 按 1:1 折算为 USDT 统计口径.""" + return _safe_float(value) + + +def _sum_optional(*values: Any) -> Optional[float]: + parts = [_safe_float(v) for v in values] + present = [p for p in parts if p is not None] + if not present: + return None + return round(sum(present), 4) + + +def options_balances_usdt_equiv(options_snap: dict[str, Any] | None) -> dict[str, Any]: + """从期权 snapshot 提取资金户/交易户 USDT 等价余额.""" + snap = options_snap if isinstance(options_snap, dict) else {} + if snap.get("enabled") is False: + return {"ok": False, "funding_usdt": None, "trading_usdt": None} + if snap.get("ok") is False: + return {"ok": False, "funding_usdt": None, "trading_usdt": None} + bal = snap.get("balances") if isinstance(snap.get("balances"), dict) else snap + funding = _sum_optional(bal.get("funding_usdt"), bal.get("funding_usdc")) + trading = _sum_optional(bal.get("trading_usdt"), bal.get("trading_usdc")) + ok = funding is not None and trading is not None + return {"ok": ok, "funding_usdt": funding, "trading_usdt": trading} + + +def options_float_pnl_usdt(options_snap: dict[str, Any] | None) -> Optional[float]: + snap = options_snap if isinstance(options_snap, dict) else {} + if snap.get("enabled") is False or snap.get("ok") is False: + return None + upl = snap.get("upl_total_usdc") + if upl is None: + return None + try: + return round(float(upl), 4) + except (TypeError, ValueError): + return None + + +def options_open_position_count(options_snap: dict[str, Any] | None) -> int: + snap = options_snap if isinstance(options_snap, dict) else {} + if snap.get("enabled") is False or snap.get("ok") is False: + return 0 + if snap.get("position_count") is not None: + try: + return max(0, int(snap.get("position_count"))) + except (TypeError, ValueError): + pass + pos = snap.get("positions") + return len(pos) if isinstance(pos, list) else 0 + + +def merge_perp_options_balances( + perpetual_funding_usdt: Any, + perpetual_trading_usdt: Any, + options_snap: dict[str, Any] | None, +) -> dict[str, Any]: + """永续 + 期权余额合并为中控 USDT 统计口径.""" + opt = options_balances_usdt_equiv(options_snap) + funding = _sum_optional(perpetual_funding_usdt, opt.get("funding_usdt")) + trading = _sum_optional(perpetual_trading_usdt, opt.get("trading_usdt")) + total = _account_total_usdt(funding, trading) + perp_total = _account_total_usdt(perpetual_funding_usdt, perpetual_trading_usdt) + opt_total = _account_total_usdt(opt.get("funding_usdt"), opt.get("trading_usdt")) + data_ok = total is not None + return { + "perpetual_funding_usdt": _safe_float(perpetual_funding_usdt), + "perpetual_trading_usdt": _safe_float(perpetual_trading_usdt), + "options_funding_usdt": opt.get("funding_usdt"), + "options_trading_usdt": opt.get("trading_usdt"), + "options_ok": bool(opt.get("ok")), + "funding_usdt": funding, + "trading_usdt": trading, + "total_usdt": total, + "perpetual_total_usdt": perp_total, + "options_total_usdt": opt_total, + "data_ok": data_ok, + } + + +def merge_board_row_balances(row: dict[str, Any]) -> dict[str, Any]: + """监控板行 → 含期权的资金统计.""" + caps = row.get("capabilities") or [] + options_snap = row.get("options") if "options" in caps else None + merged = merge_perp_options_balances( + row.get("funding_usdt") if row.get("account_ok") else None, + row.get("trading_usdt") if row.get("account_ok") else None, + options_snap, + ) + merged["options_float_pnl_u"] = options_float_pnl_usdt(options_snap) + merged["options_open_position_count"] = options_open_position_count(options_snap) + return merged diff --git a/lib/hub/hub_order_sync_lib.py b/lib/hub/hub_order_sync_lib.py new file mode 100644 index 0000000..c37b17e --- /dev/null +++ b/lib/hub/hub_order_sync_lib.py @@ -0,0 +1,114 @@ +"""中控改委托后同步实例 order_monitors 计划价(交易所已由 agent 挂单).""" +from __future__ import annotations + +from typing import Any, Callable + + +def cond_order_role(row: dict[str, Any]) -> str | None: + lbl = (row.get("label") or "").strip().lower() + if "止损" in lbl and "止盈止损" not in lbl: + return "sl" + if "止盈" in lbl: + return "tp" + return None + + +def dedupe_conditional_orders_by_role(orders: list) -> list: + """同一持仓条件单列表:每种止盈/止损只保留一条(避免 OKX OCO 拆分 + Flask 补全重复).""" + if not orders: + return [] + by_role: dict[str, dict] = {} + others: list[dict] = [] + for row in orders: + if not isinstance(row, dict): + continue + role = cond_order_role(row) + if role: + by_role[role] = row + else: + others.append(row) + out = list(others) + for role in ("tp", "sl"): + if role in by_role: + out.append(by_role[role]) + return out + + +def exchange_tpsl_from_cond_orders(cond: list) -> dict[str, Any] | None: + """从子代理条件单列表还原 exchange_tpsl 槽位.""" + slots: dict[str, Any] = {"sl": None, "tp": None} + for row in cond or []: + if not isinstance(row, dict): + continue + role = cond_order_role(row) + if role not in ("sl", "tp"): + continue + trig = row.get("trigger_price") + if trig is None: + continue + try: + trig_f = float(trig) + except (TypeError, ValueError): + continue + oid = row.get("algo_id") or row.get("id") or "" + slots[role] = { + "order_id": str(oid) if oid not in (None, "") else "", + "trigger_price": trig_f, + "trigger_display": f"{trig_f:g}", + "amount": row.get("amount"), + "type": row.get("type") or "", + } + if not slots["sl"] and not slots["tp"]: + return None + return slots + + +def sync_active_monitor_tpsl_prices( + conn, + symbol: str, + direction: str, + stop_loss: float, + take_profit: float, + *, + symbols_match: Callable[[str, str], bool], +) -> dict[str, Any]: + """按 symbol+方向更新 active 下单监控的 stop_loss / take_profit.""" + sym = (symbol or "").strip() + side = (direction or "").strip().lower() + if not sym: + return {"ok": False, "msg": "symbol 不能为空"} + if side not in ("long", "short"): + return {"ok": False, "msg": "side 须为 long 或 short"} + try: + sl = float(stop_loss) + tp = float(take_profit) + except (TypeError, ValueError): + return {"ok": False, "msg": "stop_loss / take_profit 须为数字"} + if sl <= 0 or tp <= 0: + return {"ok": False, "msg": "止损,止盈须大于 0"} + + rows = conn.execute( + "SELECT id, symbol, exchange_symbol, direction FROM order_monitors WHERE status='active'" + ).fetchall() + updated_ids: list[int] = [] + for row in rows: + r_sym = row["exchange_symbol"] if "exchange_symbol" in row.keys() else row["symbol"] + r_sym = r_sym or row["symbol"] + if not symbols_match(sym, r_sym or ""): + continue + r_dir = (row["direction"] or "").strip().lower() + if r_dir and r_dir != side: + continue + oid = int(row["id"]) + conn.execute( + "UPDATE order_monitors SET stop_loss=?, take_profit=? WHERE id=? AND status='active'", + (sl, tp, oid), + ) + updated_ids.append(oid) + return { + "ok": True, + "updated": len(updated_ids), + "order_monitor_ids": updated_ids, + "stop_loss": sl, + "take_profit": tp, + } diff --git a/lib/hub/hub_position_metrics.py b/lib/hub/hub_position_metrics.py new file mode 100644 index 0000000..f14227e --- /dev/null +++ b/lib/hub/hub_position_metrics.py @@ -0,0 +1,270 @@ +"""ccxt 持仓标记价解析(实例 price_snapshot 与中控子代理共用).""" +from __future__ import annotations + +import math +from typing import Any, Callable + + +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 _coerce_float(*values: Any) -> float | None: + for v in values: + if v is None or v == "": + continue + px = _finite_or_none(v) + if px is not None and px > 0: + return px + return None + + +CONTRACTS_QTY_DECIMALS = 2 + + +def normalize_contracts_qty(qty: Any, *, decimals: int = CONTRACTS_QTY_DECIMALS) -> float: + """张数统一精度(OKX 等线性永续默认两位小数).""" + try: + q = float(qty) + except (TypeError, ValueError): + return 0.0 + if not math.isfinite(q): + return 0.0 + return round(abs(q), decimals) + + +def contracts_qty_is_open(qty: Any, *, decimals: int = CONTRACTS_QTY_DECIMALS) -> bool: + return normalize_contracts_qty(qty, decimals=decimals) > 0 + + +def position_contracts(p: dict[str, Any]) -> float: + info = p.get("info") or {} + if not isinstance(info, dict): + info = {} + # OKX 等:info.pos 为交易所张数,优先于 ccxt contracts(加仓后后者可能滞后) + for k in ("pos", "positionAmt", "positionamt", "size"): + if k in info: + try: + v = float(info[k]) + if v != 0: + return normalize_contracts_qty(v) + except (TypeError, ValueError): + pass + raw = p.get("contracts") + if raw is not None: + try: + v = float(raw) + if v != 0: + return normalize_contracts_qty(v) + except (TypeError, ValueError): + pass + return 0.0 + + +def position_side_from_ccxt(p: dict[str, Any], contracts: float | None = None) -> str: + s = (p.get("side") or "").lower() + if s in ("long", "short"): + return s + c = contracts if contracts is not None else position_contracts(p) + if c > 0: + return "long" + if c < 0: + return "short" + return "long" + + +def parse_position_entry_price(p: dict[str, Any]) -> float | None: + """三所 ccxt 持仓开仓均价.""" + if not isinstance(p, dict): + return None + info = p.get("info") or {} + if not isinstance(info, dict): + info = {} + return _coerce_float( + p.get("entryPrice"), + p.get("entry_price"), + p.get("average"), + info.get("entryPrice"), + info.get("entry_price"), + info.get("avgPx"), + info.get("avgEntryPrice"), + info.get("avg_entry_price"), + info.get("avgPrice"), + info.get("openAvgPx"), + ) + + +def estimate_linear_swap_upnl_usdt( + side: str, + entry: float | None, + mark: float | None, + contracts: float | None, + contract_size: float | None = None, +) -> float | None: + """U 本位线性永续:浮盈 = (标记价 - 开仓价) × 张数 × contractSize(空头取反).""" + e = _finite_or_none(entry) + m = _finite_or_none(mark) + c = _finite_or_none(contracts) + if e is None or m is None or c is None or c <= 0: + return None + mult = _finite_or_none(contract_size) + if mult is None or mult <= 0: + mult = 1.0 + diff = (m - e) if (side or "long").strip().lower() == "long" else (e - m) + return round(diff * abs(c) * mult, 2) + + +def resolve_position_display_upnl( + side: str, + entry: float | None, + mark: float | None, + contracts: float | None, + contract_size: float | None, + exchange_upnl: float | None, +) -> float | None: + """展示用浮盈:优先与标记价/张数一致的推算;与交易所值偏差过大时用推算值.""" + computed = estimate_linear_swap_upnl_usdt( + side, entry, mark, contracts, contract_size + ) + if computed is None: + return exchange_upnl + if exchange_upnl is None: + return computed + ref = max(abs(computed), 1.0) + if abs(exchange_upnl - computed) / ref > 0.2: + return computed + return exchange_upnl + + +def _coerce_signed(*values: Any) -> float | None: + """解析可正可负的数值(未实现盈亏等).""" + for v in values: + if v is None or v == "": + continue + f = _finite_or_none(v) + if f is not None: + return f + return None + + +def parse_position_unrealized_pnl(p: dict[str, Any]) -> float | None: + """三所 ccxt 持仓统一解析未实现盈亏(Gate/OKX/Binance 字段名不一致).""" + if not isinstance(p, dict): + return None + info = p.get("info") or {} + if not isinstance(info, dict): + info = {} + return _coerce_signed( + p.get("unrealizedPnl"), + p.get("unrealisedPnl"), + p.get("unrealized_pnl"), + p.get("unrealised_pnl"), + info.get("unrealised_pnl"), + info.get("unrealized_pnl"), + info.get("unrealisedPnl"), + info.get("unrealizedPnl"), + info.get("upl"), + info.get("uplLast"), + ) + + +def enrich_ccxt_position_metrics_out( + position: dict[str, Any], + out: dict[str, Any], + *, + contract_size: float = 1.0, + funds_decimals: int = 2, +) -> dict[str, Any]: + """ + 三所 parse_ccxt_position_metrics 产出后统一: + - 标记价用 hub 兜底 + - 未实现盈亏 = resolve(交易所值, entry/mark/张数/contractSize 推算) + """ + if not isinstance(position, dict) or not isinstance(out, dict): + return out + mark = _finite_or_none(out.get("mark_price")) + if mark is None or mark <= 0: + mp = parse_position_mark_price(position) + if mp is not None and mp > 0: + out["mark_price"] = round(mp, 8) + mark = mp + exchange_upnl = parse_position_unrealized_pnl(position) + if exchange_upnl is None: + exchange_upnl = _coerce_signed(out.get("unrealized_pnl")) + c = position_contracts(position) + if abs(c) < 1e-12: + return out + side = position_side_from_ccxt(position, c) + entry = parse_position_entry_price(position) + if entry is not None and entry > 0: + out["entry_price"] = round(entry, 8) + cs = contract_size if contract_size and contract_size > 0 else 1.0 + upnl = resolve_position_display_upnl( + side, entry, mark, abs(c), cs, exchange_upnl + ) + if upnl is not None: + out["unrealized_pnl"] = round(upnl, funds_decimals) + return out + + +def parse_position_mark_price(p: dict[str, Any]) -> float | None: + """三所 ccxt 持仓统一解析标记价(与 crypto_monitor_* parse_ccxt_position_metrics 口径一致).""" + if not isinstance(p, dict): + return None + info = p.get("info") or {} + if not isinstance(info, dict): + info = {} + mark = _coerce_float( + p.get("markPrice"), + p.get("mark_price"), + p.get("mark"), + info.get("markPx"), + info.get("mark_price"), + info.get("markPrice"), + ) + if mark is not None: + return mark + contracts = position_contracts(p) + if abs(contracts) >= 1e-12: + notional = _finite_or_none(p.get("notional")) + if notional is not None and abs(notional) > 0: + return abs(notional) / abs(contracts) + return None + + +def build_position_marks_list( + positions: list, + *, + format_mark_display: Callable[[str, float], str] | None = None, +) -> list[dict[str, Any]]: + """从 fetch_positions 结果生成 position_marks,供 price_snapshot / 中控合并.""" + out: list[dict[str, Any]] = [] + for p in positions or []: + if not isinstance(p, dict): + continue + c = position_contracts(p) + if abs(c) < 1e-12: + continue + mark = parse_position_mark_price(p) + if mark is None or mark <= 0: + continue + sym = (p.get("symbol") or "").strip() + side = position_side_from_ccxt(p, c) + row: dict[str, Any] = { + "symbol": sym, + "side": side, + "mark_price": mark, + } + if format_mark_display and sym: + try: + row["mark_price_display"] = format_mark_display(sym, mark) + except Exception: + row["mark_price_display"] = f"{mark:g}" + else: + row["mark_price_display"] = f"{mark:g}" + out.append(row) + return out diff --git a/lib/hub/hub_reconcile_flat_lib.py b/lib/hub/hub_reconcile_flat_lib.py new file mode 100644 index 0000000..42fe455 --- /dev/null +++ b/lib/hub/hub_reconcile_flat_lib.py @@ -0,0 +1,95 @@ +"""Hub 中控市价全平后立即同步 order_monitors(三所共用).""" +from __future__ import annotations + +import time +from typing import Any, Callable + + +def reconcile_hub_external_close_impl( + conn, + symbol: str, + direction: str, + *, + exchange_configured: Callable[[], bool], + not_configured_msg: str, + symbols_match: Callable[[str, str], bool], + get_opened_at_value: Callable[[Any], str], + resolve_monitor_exchange_symbol: Callable[[Any], str], + get_live_position_contracts: Callable[[str, str], float | None], + cancel_conditional_orders: Callable[[str], None], + resolve_synced_flat_close: Callable[..., tuple], + finalize_stopped_monitor: Callable[..., None], + sync_trade_records: Callable[..., None] | None = None, + reconcile_flat_streak: dict | None = None, + to_ms_with_fallback: Callable[..., int | None] | None = None, + prefer_manual_resolve: bool = False, + order_row_monitor_type: Callable[[Any], str] | None = None, +) -> dict[str, Any]: + if not exchange_configured(): + return {"ok": False, "msg": not_configured_msg, "synced": 0} + sym_req = (symbol or "").strip() + dir_l = (direction or "").strip().lower() + if dir_l not in ("long", "short"): + return {"ok": False, "msg": "side 须为 long 或 short", "synced": 0} + synced = 0 + streak = reconcile_flat_streak if reconcile_flat_streak is not None else {} + rows = conn.execute( + "SELECT * FROM order_monitors WHERE status IN ('active', 'error')" + ).fetchall() + for r in rows: + if not symbols_match(str(r["symbol"] or ""), sym_req): + continue + if (r["direction"] or "").strip().lower() != dir_l: + continue + oid = int(r["id"]) + if r["status"] == "error": + opened_at_chk = get_opened_at_value(r) + mtype = order_row_monitor_type(r) if order_row_monitor_type else r["monitor_type"] + existing = conn.execute( + "SELECT id FROM trade_records WHERE symbol=? AND opened_at=? AND monitor_type=? LIMIT 1", + (r["symbol"], opened_at_chk, mtype), + ).fetchone() + if existing: + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (oid,)) + synced += 1 + continue + exchange_symbol = resolve_monitor_exchange_symbol(r) + live_contracts = get_live_position_contracts(exchange_symbol, r["direction"]) + if live_contracts is None: + continue + if live_contracts > 0: + time.sleep(0.6) + live_contracts = get_live_position_contracts(exchange_symbol, r["direction"]) + if live_contracts is None or live_contracts > 0: + continue + streak.pop(oid, None) + cancel_conditional_orders(exchange_symbol) + opened_at = get_opened_at_value(r) + opened_at_ms = None + if to_ms_with_fallback is not None: + keys = r.keys() if hasattr(r, "keys") else () + opened_at_ms = to_ms_with_fallback( + r["opened_at_ms"] if "opened_at_ms" in keys else None, + opened_at, + ) + resolve_kw = {"opened_at_ms": opened_at_ms} + if prefer_manual_resolve: + resolve_kw["prefer_manual"] = True + result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close( + r, opened_at, **resolve_kw + ) + finalize_stopped_monitor( + conn, + r, + result=result, + pnl_amount=pnl_amount, + closed_at=closed_at, + miss_reason=miss_reason, + ) + synced += 1 + if sync_trade_records is not None: + try: + sync_trade_records(conn, force=True) + except Exception: + pass + return {"ok": True, "synced": synced} diff --git a/lib/hub/hub_sso.py b/lib/hub/hub_sso.py new file mode 100644 index 0000000..b7bc6af --- /dev/null +++ b/lib/hub/hub_sso.py @@ -0,0 +1,166 @@ +""" +实例浏览器 SSO(复用 HUB_BRIDGE_TOKEN).无 Flask 依赖,供中控 FastAPI 与各实例共用. +""" +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import os +import secrets +import threading +import time + +HUB_SSO_TTL_SEC = int(os.getenv("HUB_SSO_TTL_SEC", "7200")) +HUB_EMBED_BOOTSTRAP_TTL_SEC = int(os.getenv("HUB_EMBED_BOOTSTRAP_TTL_SEC", "120")) + +_used_nonces: dict[str, float] = {} +_nonce_lock = threading.Lock() + + +def hub_bridge_token() -> str: + return (os.getenv("HUB_BRIDGE_TOKEN") or "").strip() + + +def safe_next_path(raw: str | None) -> str: + p = (raw or "/").strip() + if not p.startswith("/") or p.startswith("//"): + return "/" + if "://" in p: + return "/" + return p + + +def _sso_secret() -> str: + return hub_bridge_token() + + +def _b64url_encode(data: bytes) -> str: + return base64.urlsafe_b64encode(data).decode().rstrip("=") + + +def _b64url_decode(data: str) -> bytes: + pad = "=" * (-len(data) % 4) + return base64.urlsafe_b64decode(data + pad) + + +def _prune_used_nonces() -> None: + now = time.time() + with _nonce_lock: + dead = [k for k, exp in _used_nonces.items() if exp <= now] + for k in dead: + del _used_nonces[k] + + +def mint_hub_sso_token(exchange_key: str, next_path: str = "/") -> str | None: + secret = _sso_secret() + ex = (exchange_key or "").strip().lower() + if not secret or not ex: + return None + payload = { + "ex": ex, + "exp": int(time.time()) + max(60, HUB_SSO_TTL_SEC), + "nonce": secrets.token_urlsafe(16), + "next": safe_next_path(next_path), + } + body = _b64url_encode(json.dumps(payload, separators=(",", ":")).encode()) + sig = hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest() + return f"{body}.{sig}" + + +def verify_hub_sso_token( + token: str | None, expected_exchange: str +) -> tuple[bool, str, str | None]: + secret = _sso_secret() + expected = (expected_exchange or "").strip().lower() + if not secret or not expected: + return False, "/", "未配置 HUB_BRIDGE_TOKEN" + raw = (token or "").strip() + if "." not in raw: + return False, "/", "token 无效" + body, sig = raw.rsplit(".", 1) + try: + expect_sig = hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest() + if not hmac.compare_digest(expect_sig, sig): + return False, "/", "签名校验失败" + payload = json.loads(_b64url_decode(body).decode()) + except Exception: + return False, "/", "token 解析失败" + if not isinstance(payload, dict): + return False, "/", "payload 无效" + if str(payload.get("ex") or "").lower() != expected: + return False, "/", "实例不匹配" + try: + exp = int(payload.get("exp") or 0) + except (TypeError, ValueError): + return False, "/", "exp 无效" + if exp < int(time.time()): + return False, "/", "链接已过期" + nonce = str(payload.get("nonce") or "") + if not nonce: + return False, "/", "nonce 缺失" + _prune_used_nonces() + with _nonce_lock: + if nonce in _used_nonces: + return False, "/", "链接已使用" + _used_nonces[nonce] = float(exp) + return True, safe_next_path(str(payload.get("next") or "/")), None + + +def mint_hub_embed_bootstrap(exchange_key: str, next_path: str = "/") -> str | None: + """iframe 内嵌登录引导 token(短效,单次),供 /hub-embed-auth 写入 SameSite=None Cookie.""" + secret = _sso_secret() + ex = (exchange_key or "").strip().lower() + if not secret or not ex: + return None + payload = { + "kind": "embed", + "ex": ex, + "exp": int(time.time()) + max(30, HUB_EMBED_BOOTSTRAP_TTL_SEC), + "nonce": secrets.token_urlsafe(16), + "next": safe_next_path(next_path), + } + body = _b64url_encode(json.dumps(payload, separators=(",", ":")).encode()) + sig = hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest() + return f"{body}.{sig}" + + +def verify_hub_embed_bootstrap( + token: str | None, expected_exchange: str +) -> tuple[bool, str, str | None]: + secret = _sso_secret() + expected = (expected_exchange or "").strip().lower() + if not secret or not expected: + return False, "/", "未配置 HUB_BRIDGE_TOKEN" + raw = (token or "").strip() + if "." not in raw: + return False, "/", "token 无效" + body, sig = raw.rsplit(".", 1) + try: + expect_sig = hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest() + if not hmac.compare_digest(expect_sig, sig): + return False, "/", "签名校验失败" + payload = json.loads(_b64url_decode(body).decode()) + except Exception: + return False, "/", "token 解析失败" + if not isinstance(payload, dict) or payload.get("kind") != "embed": + return False, "/", "token 类型无效" + if str(payload.get("ex") or "").lower() != expected: + return False, "/", "实例不匹配" + try: + exp = int(payload.get("exp") or 0) + except (TypeError, ValueError): + return False, "/", "exp 无效" + if exp < int(time.time()): + return False, "/", "链接已过期" + nonce = str(payload.get("nonce") or "") + if not nonce: + return False, "/", "nonce 缺失" + key = f"embed:{nonce}" + _prune_used_nonces() + with _nonce_lock: + if key in _used_nonces: + return False, "/", "链接已使用" + _used_nonces[key] = float(exp) + return True, safe_next_path(str(payload.get("next") or "/")), None diff --git a/lib/hub/hub_strategy_lib.py b/lib/hub/hub_strategy_lib.py new file mode 100644 index 0000000..eaf995a --- /dev/null +++ b/lib/hub/hub_strategy_lib.py @@ -0,0 +1,475 @@ +"""中控「策略说明」:读取 docs/strategy MD + checklists JSON.""" + +from __future__ import annotations + +import json +import re +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from lib.paths import REPO_ROOT + +STRATEGY_EXCHANGES: tuple[str, ...] = ("binance", "okx", "gate") + +STRATEGY_META: dict[str, dict[str, str]] = { + "binance": { + "label": "币安", + "title": "币安·山寨多头趋势", + "md_file": "binance-alt-trend-long.md", + }, + "okx": { + "label": "OKX", + "title": "OKX·多空趋势", + "md_file": "okx-trend-both.md", + }, + "gate": { + "label": "Gate", + "title": "Gate·BTC 日内", + "md_file": "gate-intraday.md", + }, +} + + +def _strategy_dir() -> Path: + return REPO_ROOT / "docs" / "strategy" + + +def _checklist_path(exchange_key: str) -> Path: + return _strategy_dir() / "checklists" / f"{exchange_key.strip().lower()}.json" + + +def _md_path(exchange_key: str) -> Path: + meta = STRATEGY_META.get((exchange_key or "").strip().lower()) + if not meta: + raise KeyError(exchange_key) + return _strategy_dir() / meta["md_file"] + + +def _parse_version(md_text: str) -> str: + m = re.search(r">\s*\*\*状态\*\*[::]\s*(v[\d.]+)", md_text) + if m: + return m.group(1) + m = re.search(r"\|\s*v([\d.]+)\s*\|", md_text) + if m: + return f"v{m.group(1)}" + return "" + + +def render_markdown_html(md_text: str) -> str: + try: + import markdown # type: ignore + + return markdown.markdown( + md_text, + extensions=["tables", "fenced_code", "nl2br", "sane_lists"], + ) + except Exception: + return _simple_md_html(md_text) + + +def _simple_md_html(md_text: str) -> str: + from html import escape + + lines = md_text.replace("\r\n", "\n").replace("\r", "\n").splitlines() + out: list[str] = [] + i = 0 + in_code = False + code_buf: list[str] = [] + list_buf: list[str] = [] + list_ordered = False + + def flush_list() -> None: + nonlocal list_buf, list_ordered + if not list_buf: + return + tag = "ol" if list_ordered else "ul" + out.append(f"<{tag}>") + for item in list_buf: + out.append(f"
      3. {_inline_md(item)}
      4. ") + out.append(f"") + list_buf = [] + + def flush_code() -> None: + nonlocal code_buf, in_code + if not code_buf: + return + out.append(f"
        {escape(chr(10).join(code_buf))}
        ") + code_buf = [] + in_code = False + + while i < len(lines): + line = lines[i] + if line.strip().startswith("```"): + flush_list() + if in_code: + flush_code() + else: + in_code = True + i += 1 + continue + if in_code: + code_buf.append(line) + i += 1 + continue + if re.match(r"^\s*\|", line) and i + 1 < len(lines) and re.match(r"^\s*\|?\s*[-:| ]+\|", lines[i + 1]): + flush_list() + header = [c.strip() for c in line.strip().strip("|").split("|")] + i += 2 + rows: list[list[str]] = [] + while i < len(lines) and re.match(r"^\s*\|", lines[i]): + rows.append([c.strip() for c in lines[i].strip().strip("|").split("|")]) + i += 1 + out.append("" + "".join(f"" for h in header) + "") + for row in rows: + out.append("" + "".join(f"" for c in row) + "") + out.append("
        {_inline_md(h)}
        {_inline_md(c)}
        ") + continue + if re.match(r"^#{1,3}\s+", line): + flush_list() + m = re.match(r"^(#{1,3})\s+(.*)$", line) + if m: + level = len(m.group(1)) + out.append(f"{_inline_md(m.group(2))}") + i += 1 + continue + if line.strip() == "---": + flush_list() + out.append("
        ") + i += 1 + continue + if line.startswith(">"): + flush_list() + out.append(f"
        {_inline_md(line.lstrip('>').strip())}
        ") + i += 1 + continue + m = re.match(r"^(\d+)\.\s+(.*)$", line.strip()) + if m: + if list_buf and not list_ordered: + flush_list() + list_ordered = True + list_buf.append(m.group(2)) + i += 1 + continue + if re.match(r"^[-*]\s+", line.strip()): + if list_buf and list_ordered: + flush_list() + list_ordered = False + list_buf.append(re.sub(r"^[-*]\s+", "", line.strip())) + i += 1 + continue + if not line.strip(): + flush_list() + i += 1 + continue + flush_list() + out.append(f"

        {_inline_md(line.strip())}

        ") + i += 1 + flush_list() + flush_code() + return "\n".join(out) + + +def _inline_md(text: str) -> str: + from html import escape + + s = escape(text) + s = re.sub(r"`([^`]+)`", r"\1", s) + s = re.sub(r"\*\*([^*]+)\*\*", r"\1", s) + return s + + +def load_checklist(exchange_key: str) -> dict[str, Any]: + path = _checklist_path(exchange_key) + if not path.is_file(): + return {"exchange": exchange_key, "title": "开仓检查清单", "groups": []} + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + return {"exchange": exchange_key, "title": "开仓检查清单", "groups": []} + return data + + +def load_strategy_payload(exchange_key: str) -> dict[str, Any]: + key = (exchange_key or "").strip().lower() + if key not in STRATEGY_META: + raise KeyError(exchange_key) + meta = STRATEGY_META[key] + md_path = _md_path(key) + md_text = md_path.read_text(encoding="utf-8") if md_path.is_file() else "" + checklist = load_checklist(key) + version = _parse_version(md_text) or str(checklist.get("version") or "") + return { + "ok": True, + "exchange_key": key, + "label": meta["label"], + "title": meta["title"], + "version": version, + "md_source": str(md_path.relative_to(REPO_ROOT)).replace("\\", "/"), + "strategy_html": render_markdown_html(md_text), + "checklist": checklist, + } + + +def strategy_meta_payload() -> dict[str, Any]: + tabs = [ + {"key": k, "label": STRATEGY_META[k]["label"], "title": STRATEGY_META[k]["title"]} + for k in STRATEGY_EXCHANGES + ] + return {"ok": True, "exchanges": tabs} + + +def _checklist_html(checklist: dict[str, Any]) -> str: + groups = checklist.get("groups") or [] + parts = [f"

        {escape_html(str(checklist.get('title') or '开仓检查清单'))}

        "] + for grp in groups: + if not isinstance(grp, dict): + continue + gtitle = escape_html(str(grp.get("title") or "")) + parts.append(f"

        {gtitle}

          ") + for item in grp.get("items") or []: + parts.append(f"
        • {escape_html(str(item))}
        • ") + parts.append("
        ") + footnotes = checklist.get("footnotes") or [] + if footnotes: + parts.append("
          ") + for note in footnotes: + parts.append(f"
        • {escape_html(str(note))}
        • ") + parts.append("
        ") + return "\n".join(parts) + + +def _print_document_css() -> str: + return """ +body { + margin: 0; + padding: 36px 28px 48px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", + "Hiragino Sans GB", "Microsoft YaHei", sans-serif; + font-size: 14px; + line-height: 1.65; + color: #1a1a1a; + background: #fff; +} +.doc { + max-width: 720px; + margin: 0 auto; +} +.doc-head { + margin-bottom: 28px; + padding-bottom: 14px; + border-bottom: 1px solid #e5e5e5; +} +.doc-head h1 { + margin: 0 0 8px; + font-size: 1.55rem; + font-weight: 600; + line-height: 1.3; +} +.doc-meta { + margin: 0; + color: #666; + font-size: 0.85rem; +} +.doc-body h2 { + font-size: 1.12rem; + margin: 1.6em 0 0.55em; + font-weight: 600; +} +.doc-body h3 { + font-size: 1rem; + margin: 1.2em 0 0.45em; + font-weight: 600; +} +.doc-body h2:first-child, +.doc-body h3:first-child { + margin-top: 0; +} +.doc-body p { + margin: 0.65em 0; +} +.doc-body table { + border-collapse: collapse; + width: 100%; + font-size: 0.92rem; + margin: 10px 0 14px; +} +.doc-body th, +.doc-body td { + border: 1px solid #d8d8d8; + padding: 8px 10px; + text-align: left; + vertical-align: top; +} +.doc-body blockquote { + margin: 12px 0; + padding: 8px 14px; + border-left: 4px solid #c8c8c8; + color: #444; + background: #fafafa; +} +.doc-body pre, +.doc-body code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.88em; +} +.doc-body pre { + padding: 10px 12px; + background: #f6f6f6; + border: 1px solid #e8e8e8; + border-radius: 4px; + overflow-x: auto; +} +.doc-body hr { + border: none; + border-top: 1px solid #e5e5e5; + margin: 1.4em 0; +} +.checklist { + list-style: none; + margin: 0 0 16px; + padding: 0; +} +.checklist li { + margin: 7px 0; + padding: 0; +} +.box { + display: inline-block; + width: 1.05em; + margin-right: 6px; + font-size: 1.05em; + line-height: 1.2; +} +.footnotes { + margin: 18px 0 0; + padding-left: 20px; + color: #666; + font-size: 0.85rem; +} +.doc-foot { + margin-top: 28px; + padding-top: 12px; + border-top: 1px dashed #ddd; + color: #888; + font-size: 0.78rem; +} +@media print { + body { padding: 0; } + .doc { max-width: none; } + .doc-head { break-after: avoid; } + .doc-body h2, .doc-body h3 { break-after: avoid; } + .checklist li { break-inside: avoid; } +} +""" + + +def _print_auto_script() -> str: + return """""" + + +def build_print_html(exchange_key: str, part: str = "doc") -> str: + """part: doc | checklist""" + payload = load_strategy_payload(exchange_key) + key = payload["exchange_key"] + label = payload["label"] + title = payload["title"] + version = payload.get("version") or "" + checklist = payload.get("checklist") or {} + now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M") + part = (part or "doc").strip().lower() + css = _print_document_css() + + if part == "checklist": + cl_title = str(checklist.get("title") or "开仓检查清单") + page_title = f"{label} · {cl_title}" + body = f"""
        +
        +

        {escape_html(cl_title)}

        +

        {escape_html(label)} · {escape_html(version)} · 打印 {escape_html(now)}

        +
        +
        {_checklist_html(checklist)}
        +
        """ + elif part == "doc": + page_title = f"{label} · 策略说明" + source = payload.get("md_source") or "" + body = f"""
        +
        +

        {escape_html(title)}

        +

        {escape_html(label)} · {escape_html(version)} · 打印 {escape_html(now)}

        +
        +
        {payload.get("strategy_html") or ""}
        +
        文档:{escape_html(str(source))}{(" · " + escape_html(version)) if version else ""}
        +
        """ + else: + raise KeyError(part) + + return f""" + + + + +{escape_html(page_title)} + + + +{body} +{_print_auto_script()} + +""" + + +def build_export_html(exchange_key: str) -> str: + payload = load_strategy_payload(exchange_key) + key = payload["exchange_key"] + label = payload["label"] + title = payload["title"] + version = payload.get("version") or "" + checklist = payload.get("checklist") or {} + now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M") + checklist_html = _checklist_html(checklist) + + return f""" + + + +{escape_html(label)} · 策略说明 + + + +

        {escape_html(title)}

        +
        {escape_html(label)} · {escape_html(version)} · 导出 {now}
        +
        +
        {payload.get("strategy_html") or ""}
        +
        {checklist_html}
        +
        + +""" + + +def escape_html(text: str) -> str: + from html import escape + + return escape(text, quote=True) diff --git a/lib/hub/hub_symbol_archive_lib.py b/lib/hub/hub_symbol_archive_lib.py new file mode 100644 index 0000000..97d094c --- /dev/null +++ b/lib/hub/hub_symbol_archive_lib.py @@ -0,0 +1,1718 @@ +"""中控币种档案:永久 5m K 线库(建档种子 + 4h 增量),交易缓存与 overlay.""" + +from __future__ import annotations + +import json +import os +import sqlite3 +import time +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Callable, Optional +from zoneinfo import ZoneInfo + +CHART_DISPLAY_TZ = ZoneInfo(os.getenv("APP_TIMEZONE", "Asia/Shanghai")) + +from lib.hub.hub_ohlcv_lib import ( + TIMEFRAME_MS, + aggregate_ohlcv_bars, + normalize_chart_timeframe, + normalize_perpetual_symbol, +) +from lib.hub.hub_trades_lib import ( + display_entry_type_label, + effective_hold_minutes, + format_hold_minutes, +) + +ARCHIVE_TIMEFRAMES = frozenset({"5m", "15m", "1h", "4h"}) +ARCHIVE_DEFAULT_TIMEFRAME = "15m" +ARCHIVE_SEED_LOOKBACK_DAYS = 30 +ARCHIVE_VISIBLE_BARS_DEFAULT = 200 +ARCHIVE_MAX_CANDLES: dict[str, int] = { + "5m": 9000, + "15m": 15000, + "1h": 4000, + "4h": 2000, +} +ARCHIVE_SYNC_INTERVAL_SEC = int(os.getenv("HUB_ARCHIVE_SYNC_INTERVAL_SEC", str(4 * 3600))) +ARCHIVE_TRADE_DAYS = int(os.getenv("HUB_ARCHIVE_TRADE_DAYS", "365")) +ARCHIVE_TRADE_LIMIT = int(os.getenv("HUB_ARCHIVE_TRADE_LIMIT", "2000")) +ARCHIVE_QUOTES_MAX = int(os.getenv("HUB_ARCHIVE_QUOTES_MAX", "100")) +TRADING_DAY_RESET_HOUR = int(os.getenv("TRADING_DAY_RESET_HOUR", "8")) +ARCHIVE_QUOTE_MAX_LEN = 5000 + +BEHAVIOR_TAGS = frozenset({"", "sick", "emotion"}) + + +def default_db_path() -> Path: + raw = (os.getenv("HUB_ARCHIVE_DB_PATH") or "").strip() + if raw: + return Path(raw) + from lib.paths import hub_data_dir + + return hub_data_dir() / "hub_symbol_archive.db" + + +def _connect(db_path: Path | None = None) -> sqlite3.Connection: + path = db_path or default_db_path() + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(path), timeout=30, isolation_level=None) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA synchronous=NORMAL") + return conn + + +def init_db(db_path: Path | None = None) -> None: + conn = _connect(db_path) + try: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS archive_meta ( + exchange_key TEXT NOT NULL, + symbol TEXT NOT NULL, + first_trade_opened_ms INTEGER, + archive_started_at INTEGER NOT NULL, + last_kline_sync_ms INTEGER, + last_trade_sync_ms INTEGER, + seed_complete INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (exchange_key, symbol) + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS archive_bars_5m ( + exchange_key TEXT NOT NULL, + symbol TEXT NOT NULL, + open_time_ms INTEGER NOT NULL, + open REAL NOT NULL, + high REAL NOT NULL, + low REAL NOT NULL, + close REAL NOT NULL, + volume REAL NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL, + PRIMARY KEY (exchange_key, symbol, open_time_ms) + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_archive_bars_series + ON archive_bars_5m (exchange_key, symbol, open_time_ms) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS archive_trade_cache ( + exchange_key TEXT NOT NULL, + trade_id INTEGER NOT NULL, + symbol TEXT NOT NULL, + direction TEXT, + result TEXT, + pnl_amount REAL, + opened_at TEXT, + closed_at TEXT, + opened_at_ms INTEGER, + closed_at_ms INTEGER, + monitor_type TEXT, + entry_reason TEXT, + exchange_turnover_usdt REAL, + exchange_commission_usdt REAL, + payload_json TEXT, + synced_at INTEGER NOT NULL, + PRIMARY KEY (exchange_key, trade_id) + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_archive_trades_sym + ON archive_trade_cache (exchange_key, symbol, closed_at_ms) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS trade_overlay ( + exchange_key TEXT NOT NULL, + trade_id INTEGER NOT NULL, + behavior_tag TEXT NOT NULL DEFAULT '', + note TEXT NOT NULL DEFAULT '', + updated_at INTEGER NOT NULL, + PRIMARY KEY (exchange_key, trade_id) + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS archive_review_quotes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + quote_date TEXT NOT NULL UNIQUE, + content TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_archive_quotes_date + ON archive_review_quotes (quote_date DESC) + """ + ) + for ddl in ( + "ALTER TABLE archive_trade_cache ADD COLUMN exchange_turnover_usdt REAL", + "ALTER TABLE archive_trade_cache ADD COLUMN exchange_commission_usdt REAL", + ): + try: + conn.execute(ddl) + except Exception: + pass + finally: + conn.close() + + +def _now_ms() -> int: + return int(time.time() * 1000) + + +def _optional_float(raw: Any) -> float | None: + if raw in (None, ""): + return None + try: + return float(raw) + except (TypeError, ValueError): + return None + + +def parse_wall_clock_ms(raw: Any, *, tz: ZoneInfo = CHART_DISPLAY_TZ) -> int | None: + """将 YYYY-MM-DD[ HH:MM[:SS]] 按指定时区墙钟解析为 UTC 毫秒(默认 UTC+8).""" + if raw in (None, ""): + return None + try: + if isinstance(raw, (int, float)): + v = int(raw) + return v if v > 1_000_000_000_000 else v * 1000 + except (TypeError, ValueError): + pass + s = str(raw).strip().replace("Z", "").replace("T", " ") + if not s: + return None + if s.isdigit(): + v = int(s) + return v if v > 1_000_000_000_000 else v * 1000 + for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)): + try: + dt = datetime.strptime(s[:ln], fmt) + aware = dt.replace(tzinfo=tz) + return int(aware.timestamp() * 1000) + except ValueError: + continue + return None + + +def ms_to_wall_clock_str(ms: int, *, tz: ZoneInfo = CHART_DISPLAY_TZ) -> str: + dt = datetime.fromtimestamp(int(ms) / 1000.0, tz=timezone.utc).astimezone(tz) + return dt.strftime("%Y-%m-%d %H:%M:%S") + + +def _parse_dt_ms(raw: Any) -> int | None: + return parse_wall_clock_ms(raw) + + +def _trade_entry_reason_for_cache(t: dict[str, Any]) -> str: + for key in ("entry_type", "entry_reason", "reviewed_entry_reason"): + raw = t.get(key) + if raw is not None and str(raw).strip(): + return str(raw).strip() + return display_entry_type_label(t) if isinstance(t, dict) else "" + + +def purge_stale_trades_cache( + exchange_key: str, + active_trade_ids: list[int] | set[int], + *, + db_path: Path | None = None, +) -> int: + """删除该所缓存中已不在复盘/交易记录里的条目.""" + ex_k = (exchange_key or "").strip().lower() + if not ex_k: + return 0 + ids: list[int] = [] + for raw in active_trade_ids or []: + try: + ids.append(int(raw)) + except (TypeError, ValueError): + continue + conn = _connect(db_path) + try: + if not ids: + rows = conn.execute( + "SELECT trade_id FROM archive_trade_cache WHERE exchange_key=?", + (ex_k,), + ).fetchall() + stale_ids = [int(r["trade_id"]) for r in rows] + cur = conn.execute( + "DELETE FROM archive_trade_cache WHERE exchange_key=?", + (ex_k,), + ) + else: + placeholders = ",".join("?" * len(ids)) + rows = conn.execute( + f""" + SELECT trade_id FROM archive_trade_cache + WHERE exchange_key=? AND trade_id NOT IN ({placeholders}) + """, + (ex_k, *ids), + ).fetchall() + stale_ids = [int(r["trade_id"]) for r in rows] + cur = conn.execute( + f""" + DELETE FROM archive_trade_cache + WHERE exchange_key=? AND trade_id NOT IN ({placeholders}) + """, + (ex_k, *ids), + ) + removed = int(cur.rowcount or 0) + if stale_ids: + ph2 = ",".join("?" * len(stale_ids)) + conn.execute( + f""" + DELETE FROM trade_overlay + WHERE exchange_key=? AND trade_id IN ({ph2}) + """, + (ex_k, *stale_ids), + ) + return removed + finally: + conn.close() + + +def delete_trade_from_archive( + exchange_key: str, + trade_id: int, + *, + db_path: Path | None = None, +) -> bool: + ex_k = (exchange_key or "").strip().lower() + tid = int(trade_id) + conn = _connect(db_path) + try: + cur = conn.execute( + """ + DELETE FROM archive_trade_cache + WHERE exchange_key=? AND trade_id=? + """, + (ex_k, tid), + ) + conn.execute( + "DELETE FROM trade_overlay WHERE exchange_key=? AND trade_id=?", + (ex_k, tid), + ) + return int(cur.rowcount or 0) > 0 + finally: + conn.close() + + +def upsert_trades_cache( + exchange_key: str, + trades: list[dict[str, Any]], + *, + db_path: Path | None = None, + prune_missing: bool = True, +) -> dict[str, int]: + init_db(db_path) + ex_k = (exchange_key or "").strip().lower() + if not ex_k: + return {"upserted": 0, "removed": 0} + now = _now_ms() + n = 0 + active_ids: list[int] = [] + conn = _connect(db_path) + try: + for t in trades or []: + try: + tid = int(t.get("id")) + except (TypeError, ValueError): + continue + sym = (t.get("symbol") or "").strip().upper() + if not sym: + continue + active_ids.append(tid) + row = dict(t) + row["exchange_key"] = ex_k + row.pop("account_exchange_key", None) + payload = {k: row.get(k) for k in row.keys()} + entry_label = _trade_entry_reason_for_cache(t) + conn.execute( + """ + INSERT INTO archive_trade_cache ( + exchange_key, trade_id, symbol, direction, result, pnl_amount, + opened_at, closed_at, opened_at_ms, closed_at_ms, + monitor_type, entry_reason, exchange_turnover_usdt, exchange_commission_usdt, + payload_json, synced_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(exchange_key, trade_id) DO UPDATE SET + symbol=excluded.symbol, + direction=excluded.direction, + result=excluded.result, + pnl_amount=excluded.pnl_amount, + opened_at=excluded.opened_at, + closed_at=excluded.closed_at, + opened_at_ms=excluded.opened_at_ms, + closed_at_ms=excluded.closed_at_ms, + monitor_type=excluded.monitor_type, + entry_reason=excluded.entry_reason, + exchange_turnover_usdt=excluded.exchange_turnover_usdt, + exchange_commission_usdt=excluded.exchange_commission_usdt, + payload_json=excluded.payload_json, + synced_at=excluded.synced_at + """, + ( + ex_k, + tid, + sym, + t.get("direction"), + t.get("result"), + float(t.get("pnl_amount") or 0), + t.get("opened_at"), + t.get("closed_at"), + t.get("opened_at_ms") or _parse_dt_ms(t.get("opened_at")), + t.get("closed_at_ms") or _parse_dt_ms(t.get("closed_at")), + t.get("monitor_type"), + entry_label, + _optional_float(t.get("exchange_turnover_usdt")), + _optional_float(t.get("exchange_commission_usdt")), + json.dumps(payload, ensure_ascii=False, default=str), + now, + ), + ) + n += 1 + finally: + conn.close() + removed = 0 + if prune_missing: + removed = purge_stale_trades_cache(ex_k, active_ids, db_path=db_path) + return {"upserted": n, "removed": removed} + + +def _enrich_trade_display_fields(out: dict[str, Any]) -> dict[str, Any]: + """缓存行补齐复盘优先的展示字段(兼容旧同步数据).""" + opened_ms = out.get("opened_at_ms") or _parse_dt_ms(out.get("opened_at")) + closed_ms = out.get("closed_at_ms") or _parse_dt_ms(out.get("closed_at")) + if opened_ms: + out["opened_at_ms"] = int(opened_ms) + if closed_ms: + out["closed_at_ms"] = int(closed_ms) + if not out.get("opened_at") and opened_ms: + out["opened_at"] = ms_to_wall_clock_str(int(opened_ms)) + if not out.get("closed_at") and closed_ms: + out["closed_at"] = ms_to_wall_clock_str(int(closed_ms)) + entry_type = display_entry_type_label(out) + if entry_type and entry_type != "—": + out["entry_type"] = entry_type + out["entry_reason"] = entry_type + hold_m = out.get("hold_minutes") + if hold_m in (None, ""): + hold_m = effective_hold_minutes( + out, + opened_ms=out.get("opened_at_ms"), + closed_ms=out.get("closed_at_ms"), + ) + try: + hold_m = max(0, int(hold_m or 0)) + except (TypeError, ValueError): + hold_m = 0 + out["hold_minutes"] = hold_m + out["hold_minutes_text"] = out.get("hold_minutes_text") or format_hold_minutes(hold_m) + if "reviewed" not in out: + out["reviewed"] = bool( + out.get("reviewed_at") + or out.get("reviewed_result") + or out.get("reviewed_opened_at") + or out.get("reviewed_closed_at") + or out.get("reviewed_entry_reason") + or out.get("reviewed_hold_minutes") + ) + return out + + +def _trade_row_to_dict(row: sqlite3.Row, overlay: dict | None = None) -> dict[str, Any]: + d = dict(row) + payload = {} + raw = d.pop("payload_json", None) + if raw: + try: + payload = json.loads(raw) + except (json.JSONDecodeError, TypeError): + payload = {} + out = {**payload, **{k: d[k] for k in d.keys() if k not in payload}} + for key in ( + "exchange_key", + "symbol", + "trade_id", + "direction", + "result", + "pnl_amount", + "opened_at", + "closed_at", + "opened_at_ms", + "closed_at_ms", + "monitor_type", + "entry_reason", + "exchange_turnover_usdt", + "exchange_commission_usdt", + "synced_at", + ): + if key in d and d[key] not in (None, ""): + out[key] = d[key] + ov = overlay or {} + from lib.trade.account_risk_lib import parse_mood_issues + + journal_issues = parse_mood_issues( + out.get("journal_mood_issues") or payload.get("journal_mood_issues") + ) + tag_from_journal = bool( + out.get("journal_mood_sick") + or payload.get("journal_mood_sick") + or journal_issues + ) + tag = (ov.get("behavior_tag") or "").strip().lower() + if tag_from_journal: + tag = "sick" + out["behavior_tag"] = tag + out["behavior_tag_from_journal"] = tag_from_journal + if journal_issues: + out["journal_mood_issues"] = journal_issues + out["note"] = ov.get("note") or "" + out["trade_id"] = out.get("trade_id") or out.get("id") + ex_col = str(d.get("exchange_key") or "").strip().lower() + if ex_col: + out["exchange_key"] = ex_col + out.pop("account_exchange_key", None) + return _enrich_trade_display_fields(out) + + +def load_overlays( + exchange_key: str, + trade_ids: list[int] | None = None, + *, + db_path: Path | None = None, +) -> dict[int, dict[str, Any]]: + ex_k = (exchange_key or "").strip().lower() + conn = _connect(db_path) + try: + if trade_ids: + placeholders = ",".join("?" * len(trade_ids)) + rows = conn.execute( + f""" + SELECT exchange_key, trade_id, behavior_tag, note, updated_at + FROM trade_overlay + WHERE exchange_key=? AND trade_id IN ({placeholders}) + """, + (ex_k, *trade_ids), + ).fetchall() + else: + rows = conn.execute( + """ + SELECT exchange_key, trade_id, behavior_tag, note, updated_at + FROM trade_overlay WHERE exchange_key=? + """, + (ex_k,), + ).fetchall() + return { + int(r["trade_id"]): { + "behavior_tag": r["behavior_tag"] or "", + "note": r["note"] or "", + "updated_at": r["updated_at"], + } + for r in rows + } + finally: + conn.close() + + +def upsert_trade_overlay( + exchange_key: str, + trade_id: int, + *, + behavior_tag: str | None = None, + note: str | None = None, + db_path: Path | None = None, +) -> dict[str, Any]: + init_db(db_path) + ex_k = (exchange_key or "").strip().lower() + tid = int(trade_id) + tag = (behavior_tag or "").strip().lower() + if tag not in BEHAVIOR_TAGS: + tag = "" + note_text = (note or "").strip()[:2000] + now = _now_ms() + conn = _connect(db_path) + try: + conn.execute( + """ + INSERT INTO trade_overlay (exchange_key, trade_id, behavior_tag, note, updated_at) + VALUES (?,?,?,?,?) + ON CONFLICT(exchange_key, trade_id) DO UPDATE SET + behavior_tag=excluded.behavior_tag, + note=excluded.note, + updated_at=excluded.updated_at + """, + (ex_k, tid, tag, note_text, now), + ) + finally: + conn.close() + return {"exchange_key": ex_k, "trade_id": tid, "behavior_tag": tag, "note": note_text} + + +def apply_journal_behavior_overlays( + exchange_key: str, + trades: list[dict[str, Any]], + *, + db_path: Path | None = None, +) -> int: + """复盘情绪标签 → 写入 trade_overlay.behavior_tag=sick(仅正 trade_id).""" + ex_k = (exchange_key or "").strip().lower() + if not ex_k: + return 0 + n = 0 + for t in trades or []: + if not isinstance(t, dict) or not t.get("journal_mood_sick"): + continue + try: + tid = int(t.get("id")) + except (TypeError, ValueError): + continue + if tid <= 0: + continue + upsert_trade_overlay(ex_k, tid, behavior_tag="sick", db_path=db_path) + n += 1 + return n + + +def list_symbol_rows( + *, + exchange_key: str = "", + filter_profit: bool = False, + filter_loss: bool = False, + filter_sick: bool = False, + filter_emotion: bool = False, + db_path: Path | None = None, +) -> list[dict[str, Any]]: + """一所一币一行汇总.""" + init_db(db_path) + conn = _connect(db_path) + try: + params: list[Any] = [] + where = "1=1" + ex_filter = (exchange_key or "").strip().lower() + if ex_filter: + where += " AND t.exchange_key=?" + params.append(ex_filter) + + rows = conn.execute( + f""" + SELECT t.exchange_key, t.symbol, + COUNT(*) AS trade_count, + SUM(CASE WHEN t.pnl_amount > 0.0001 THEN 1 ELSE 0 END) AS win_count, + SUM(CASE WHEN t.pnl_amount < -0.0001 THEN 1 ELSE 0 END) AS loss_count, + SUM(COALESCE(t.pnl_amount, 0)) AS total_pnl, + MIN(COALESCE(t.opened_at_ms, 0)) AS first_opened_ms, + MAX(COALESCE(t.closed_at_ms, 0)) AS last_closed_ms + FROM archive_trade_cache t + WHERE {where} + GROUP BY t.exchange_key, t.symbol + ORDER BY last_closed_ms DESC + """, + params, + ).fetchall() + + overlays_by_ex: dict[str, dict[int, dict]] = {} + out: list[dict[str, Any]] = [] + for r in rows: + ex_k = r["exchange_key"] + sym = r["symbol"] + if ex_k not in overlays_by_ex: + overlays_by_ex[ex_k] = load_overlays(ex_k, db_path=db_path) + + trade_rows = conn.execute( + """ + SELECT trade_id, pnl_amount FROM archive_trade_cache + WHERE exchange_key=? AND symbol=? + """, + (ex_k, sym), + ).fetchall() + has_profit = any(float(x["pnl_amount"] or 0) > 0.0001 for x in trade_rows) + has_loss = any(float(x["pnl_amount"] or 0) < -0.0001 for x in trade_rows) + has_sick = False + has_emotion = False + ov_map = overlays_by_ex.get(ex_k) or {} + for tr in trade_rows: + ov = ov_map.get(int(tr["trade_id"])) or {} + if ov.get("behavior_tag") == "sick": + has_sick = True + if ov.get("behavior_tag") == "emotion": + has_emotion = True + + if filter_profit and not has_profit: + continue + if filter_loss and not has_loss: + continue + if filter_sick and not has_sick: + continue + if filter_emotion and not has_emotion: + continue + + meta = conn.execute( + "SELECT seed_complete, last_kline_sync_ms FROM archive_meta WHERE exchange_key=? AND symbol=?", + (ex_k, sym), + ).fetchone() + + out.append( + { + "exchange_key": ex_k, + "symbol": sym, + "trade_count": int(r["trade_count"] or 0), + "win_count": int(r["win_count"] or 0), + "loss_count": int(r["loss_count"] or 0), + "total_pnl": round(float(r["total_pnl"] or 0), 4), + "first_opened_ms": int(r["first_opened_ms"] or 0) or None, + "last_closed_ms": int(r["last_closed_ms"] or 0) or None, + "seed_complete": bool(meta["seed_complete"]) if meta else False, + "last_kline_sync_ms": int(meta["last_kline_sync_ms"] or 0) if meta else None, + } + ) + return out + finally: + conn.close() + + +def load_symbol_trades( + exchange_key: str, + symbol: str, + *, + db_path: Path | None = None, +) -> list[dict[str, Any]]: + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + conn = _connect(db_path) + try: + rows = conn.execute( + """ + SELECT * FROM archive_trade_cache + WHERE exchange_key=? AND symbol=? + ORDER BY COALESCE(closed_at_ms, 0) DESC, trade_id DESC + """, + (ex_k, sym), + ).fetchall() + ids = [int(r["trade_id"]) for r in rows] + ov = load_overlays(ex_k, ids, db_path=db_path) + return [_trade_row_to_dict(r, ov.get(int(r["trade_id"]))) for r in rows] + finally: + conn.close() + + +def upsert_bars_5m( + exchange_key: str, + symbol: str, + bars: list[dict[str, Any]], + *, + db_path: Path | None = None, +) -> int: + init_db(db_path) + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + now = _now_ms() + n = 0 + conn = _connect(db_path) + try: + for b in bars or []: + try: + conn.execute( + """ + INSERT INTO archive_bars_5m ( + exchange_key, symbol, open_time_ms, open, high, low, close, volume, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?) + ON CONFLICT(exchange_key, symbol, open_time_ms) DO UPDATE SET + open=excluded.open, + high=excluded.high, + low=excluded.low, + close=excluded.close, + volume=excluded.volume, + updated_at=excluded.updated_at + """, + ( + ex_k, + sym, + int(b["open_time_ms"]), + float(b["open"]), + float(b["high"]), + float(b["low"]), + float(b["close"]), + float(b.get("volume") or 0), + now, + ), + ) + n += 1 + except (KeyError, TypeError, ValueError): + continue + finally: + conn.close() + return n + + +def load_bars_5m_range( + exchange_key: str, + symbol: str, + start_ms: int, + end_ms: int, + *, + db_path: Path | None = None, +) -> list[dict[str, Any]]: + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + conn = _connect(db_path) + try: + rows = conn.execute( + """ + SELECT open_time_ms, open, high, low, close, volume + FROM archive_bars_5m + WHERE exchange_key=? AND symbol=? + AND open_time_ms >= ? AND open_time_ms <= ? + ORDER BY open_time_ms ASC + """, + (ex_k, sym, int(start_ms), int(end_ms)), + ).fetchall() + return [ + { + "open_time_ms": int(r["open_time_ms"]), + "open": float(r["open"]), + "high": float(r["high"]), + "low": float(r["low"]), + "close": float(r["close"]), + "volume": float(r["volume"] or 0), + } + for r in rows + ] + finally: + conn.close() + + +def _to_candles(bars: list[dict[str, Any]]) -> list[dict[str, Any]]: + out = [] + for b in bars or []: + try: + out.append( + { + "time": int(b["open_time_ms"] // 1000), + "open": float(b["open"]), + "high": float(b["high"]), + "low": float(b["low"]), + "close": float(b["close"]), + "volume": float(b.get("volume") or 0), + } + ) + except (KeyError, TypeError, ValueError): + continue + return out + + +def _snap_to_bar_grid(ts_ms: int, origin_ms: int, step_ms: int) -> int: + step = max(1, int(step_ms)) + origin = int(origin_ms) + if ts_ms <= origin: + return origin + idx = (int(ts_ms) - origin + step - 1) // step + return origin + idx * step + + +def _fill_missing_bars( + bars: list[dict[str, Any]], + period_ms: int, + start_ms: int, + end_ms: int, +) -> list[dict[str, Any]]: + """5m 缺口用上一根收盘价填平,保证聚合后 K 线时间轴连续.""" + by_ts: dict[int, dict[str, Any]] = {} + for b in bars or []: + try: + by_ts[int(b["open_time_ms"])] = b + except (KeyError, TypeError, ValueError): + continue + if not by_ts: + return [] + keys = sorted(by_ts.keys()) + step_ms = max(1, int(period_ms)) + origin = keys[0] + aligned_start = _snap_to_bar_grid(int(start_ms), origin, step_ms) + aligned_end = max(int(end_ms), keys[-1]) + out: list[dict[str, Any]] = [] + last: dict[str, Any] | None = None + for ts_key in keys: + if ts_key <= aligned_start: + last = by_ts[ts_key] + ts = aligned_start + while ts <= aligned_end: + cur = by_ts.get(ts) + if cur is not None: + last = cur + out.append(cur) + elif last is not None: + c = float(last["close"]) + out.append( + { + "open_time_ms": ts, + "open": c, + "high": c, + "low": c, + "close": c, + "volume": 0.0, + "filled": True, + } + ) + ts += step_ms + return out + + +def _archive_earliest_bar_ms( + exchange_key: str, + symbol: str, + *, + db_path: Path | None = None, +) -> int | None: + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + conn = _connect(db_path) + try: + row = conn.execute( + "SELECT MIN(open_time_ms) AS mn FROM archive_bars_5m WHERE exchange_key=? AND symbol=?", + (ex_k, sym), + ).fetchone() + if row and row["mn"] is not None: + return int(row["mn"]) + finally: + conn.close() + return None + + +def _trim_bars_for_cap( + bars: list[dict[str, Any]], + *, + end_ms: int, + max_n: int, +) -> list[dict[str, Any]]: + """超长时优先保留到平仓,再从最古老端截断.""" + if len(bars) <= max_n: + return bars + cut_end = len(bars) + for i in range(len(bars) - 1, -1, -1): + if int(bars[i]["open_time_ms"]) <= int(end_ms): + cut_end = i + 1 + break + essential = bars[:cut_end] + if len(essential) <= max_n: + return essential + return essential[len(essential) - max_n :] + + +def resolve_archive_chart( + exchange_key: str, + symbol: str, + timeframe: str = ARCHIVE_DEFAULT_TIMEFRAME, + *, + anchor_ms: int | None = None, + opened_ms: int | None = None, + closed_ms: int | None = None, + mode: str = "hold", + bars: int = ARCHIVE_VISIBLE_BARS_DEFAULT, + range_mode: str = "window", + db_path: Path | None = None, +) -> dict[str, Any]: + """从永久 5m 库聚合出档案 K 线视窗. + + range_mode=history:建档起点 → 平仓(不含「到现在」),供拖动/缩放查看建仓前全局形态. + """ + tf = normalize_chart_timeframe(timeframe, default=ARCHIVE_DEFAULT_TIMEFRAME) + if tf not in ARCHIVE_TIMEFRAMES: + return {"ok": False, "msg": f"档案仅支持 {', '.join(sorted(ARCHIVE_TIMEFRAMES))}"} + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + if not ex_k or not sym: + return {"ok": False, "msg": "缺少 exchange_key 或 symbol"} + + period = TIMEFRAME_MS[tf] + period_5m = TIMEFRAME_MS["5m"] + hold_open = int(opened_ms) if opened_ms else None + hold_close = int(closed_ms) if closed_ms else None + rm = (range_mode or "window").strip().lower() + if hold_open and hold_close and hold_close >= hold_open and rm == "history": + seed_back = max(0, hold_open - ARCHIVE_SEED_LOOKBACK_DAYS * 86400000) + earliest = _archive_earliest_bar_ms(ex_k, sym, db_path=db_path) + if earliest is not None: + start_ms = min(earliest, seed_back) + else: + start_ms = seed_back + end_ms = hold_close + max(period * 16, period_5m * 8) + anchor = hold_close if (mode or "hold").strip().lower() != "entry" else hold_open + elif hold_open and hold_close and hold_close >= hold_open: + hold_len = hold_close - hold_open + pad = max(period * 24, hold_len // 3, period_5m * 12) + start_ms = max(0, hold_open - pad) + end_ms = hold_close + pad + anchor = hold_close if (mode or "hold").strip().lower() != "entry" else hold_open + else: + visible = max(50, min(int(bars or ARCHIVE_VISIBLE_BARS_DEFAULT), 500)) + anchor = int(anchor_ms) if anchor_ms else _now_ms() + half = visible // 2 + start_ms = max(0, anchor - half * period) + end_ms = anchor + half * period + + raw_5m = load_bars_5m_range( + ex_k, + sym, + start_ms - period_5m * 6, + end_ms + period_5m * 6, + db_path=db_path, + ) + if not raw_5m: + return {"ok": False, "msg": "档案库暂无 K 线,请等待同步或手动刷新"} + + filled_5m = _fill_missing_bars(raw_5m, period_5m, start_ms - period_5m * 2, end_ms + period_5m * 2) + + if tf == "5m": + merged = [b for b in filled_5m if start_ms <= int(b["open_time_ms"]) <= end_ms] + else: + agg = aggregate_ohlcv_bars(filled_5m, tf) + merged = [b for b in agg if start_ms <= int(b["open_time_ms"]) <= end_ms] + + max_n = ARCHIVE_MAX_CANDLES.get(tf, 2000) + if rm == "history" and merged and len(merged) > max_n: + merged = merged[:max_n] + + candles = _to_candles(merged) + if not candles: + return {"ok": False, "msg": "视窗内无 K 线"} + + ex_sym = normalize_perpetual_symbol(sym) + return { + "ok": True, + "exchange_key": ex_k, + "symbol": sym, + "exchange_symbol": ex_sym, + "market_type": "swap", + "timeframe": tf, + "mode": (mode or "hold").strip().lower(), + "range_mode": rm, + "anchor_ms": anchor, + "opened_ms": hold_open, + "closed_ms": hold_close, + "window_start_ms": start_ms, + "window_end_ms": end_ms, + "candles": candles, + "bar_count": len(candles), + "gaps_filled": sum(1 for b in filled_5m if b.get("filled")), + } + + +def _ensure_meta( + exchange_key: str, + symbol: str, + first_opened_ms: int | None, + *, + db_path: Path | None = None, +) -> None: + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + now = _now_ms() + conn = _connect(db_path) + try: + row = conn.execute( + "SELECT first_trade_opened_ms FROM archive_meta WHERE exchange_key=? AND symbol=?", + (ex_k, sym), + ).fetchone() + if row: + if first_opened_ms and ( + not row["first_trade_opened_ms"] + or int(first_opened_ms) < int(row["first_trade_opened_ms"]) + ): + conn.execute( + """ + UPDATE archive_meta SET first_trade_opened_ms=? + WHERE exchange_key=? AND symbol=? + """, + (int(first_opened_ms), ex_k, sym), + ) + return + conn.execute( + """ + INSERT INTO archive_meta ( + exchange_key, symbol, first_trade_opened_ms, + archive_started_at, last_kline_sync_ms, last_trade_sync_ms, seed_complete + ) VALUES (?,?,?,?,?,?,0) + """, + (ex_k, sym, int(first_opened_ms) if first_opened_ms else None, now, None, None), + ) + finally: + conn.close() + + +def _mark_meta_sync( + exchange_key: str, + symbol: str, + *, + kline_ms: int | None = None, + trade_ms: int | None = None, + seed_complete: bool | None = None, + db_path: Path | None = None, +) -> None: + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + conn = _connect(db_path) + try: + sets = [] + params: list[Any] = [] + if kline_ms is not None: + sets.append("last_kline_sync_ms=?") + params.append(int(kline_ms)) + if trade_ms is not None: + sets.append("last_trade_sync_ms=?") + params.append(int(trade_ms)) + if seed_complete is not None: + sets.append("seed_complete=?") + params.append(1 if seed_complete else 0) + if not sets: + return + params.extend([ex_k, sym]) + conn.execute( + f"UPDATE archive_meta SET {', '.join(sets)} WHERE exchange_key=? AND symbol=?", + params, + ) + finally: + conn.close() + + +def fetch_remote_5m_range( + remote_fetch: Callable[..., dict[str, Any]], + symbol: str, + start_ms: int, + end_ms: int, +) -> list[dict[str, Any]]: + """经实例 /api/hub/ohlcv 分页拉取 5m.""" + period = TIMEFRAME_MS["5m"] + since = max(0, int(start_ms)) + end = int(end_ms) + merged: dict[int, dict[str, Any]] = {} + guard = 0 + while since < end and guard < 120: + guard += 1 + remote = remote_fetch(symbol=symbol, timeframe="5m", since_ms=since, limit=500) + if not remote.get("ok"): + break + batch = remote.get("bars") or [] + if not batch: + break + for b in batch: + try: + ts = int(b["open_time_ms"]) + merged[ts] = b + except (KeyError, TypeError, ValueError): + continue + last_ts = max(int(b["open_time_ms"]) for b in batch) + next_since = last_ts + period + if next_since <= since: + break + since = next_since + if last_ts >= end: + break + return [merged[k] for k in sorted(merged.keys()) if start_ms <= k <= end] + + +def seed_symbol_archive( + exchange_key: str, + symbol: str, + first_opened_ms: int, + remote_fetch: Callable[..., dict[str, Any]], + *, + db_path: Path | None = None, +) -> dict[str, Any]: + """建档:最早开仓向前 30 天 5m 种子.""" + init_db(db_path) + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + anchor = int(first_opened_ms) + start_ms = max(0, anchor - ARCHIVE_SEED_LOOKBACK_DAYS * 86400000) + end_ms = _now_ms() + _ensure_meta(ex_k, sym, anchor, db_path=db_path) + bars = fetch_remote_5m_range(remote_fetch, sym, start_ms, end_ms) + n = upsert_bars_5m(ex_k, sym, bars, db_path=db_path) + now = _now_ms() + _mark_meta_sync(ex_k, sym, kline_ms=now, seed_complete=True, db_path=db_path) + return {"ok": True, "seed_bars": n, "start_ms": start_ms, "end_ms": end_ms} + + +def sync_symbol_klines_incremental( + exchange_key: str, + symbol: str, + remote_fetch: Callable[..., dict[str, Any]], + *, + db_path: Path | None = None, +) -> dict[str, Any]: + """增量补 5m 至当前.""" + init_db(db_path) + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + conn = _connect(db_path) + try: + row = conn.execute( + "SELECT MAX(open_time_ms) AS mx FROM archive_bars_5m WHERE exchange_key=? AND symbol=?", + (ex_k, sym), + ).fetchone() + last_bar = int(row["mx"]) if row and row["mx"] else None + finally: + conn.close() + + period = TIMEFRAME_MS["5m"] + start_ms = max(0, (last_bar + period) if last_bar else 0) + end_ms = _now_ms() + if start_ms >= end_ms - period: + return {"ok": True, "appended": 0, "skipped": True} + bars = fetch_remote_5m_range(remote_fetch, sym, start_ms, end_ms) + n = upsert_bars_5m(ex_k, sym, bars, db_path=db_path) + now = _now_ms() + _mark_meta_sync(ex_k, sym, kline_ms=now, db_path=db_path) + return {"ok": True, "appended": n, "start_ms": start_ms, "end_ms": end_ms} + + +def sync_exchange_symbol_archives( + exchange_key: str, + trades: list[dict[str, Any]], + remote_fetch: Callable[..., dict[str, Any]], + *, + db_path: Path | None = None, +) -> dict[str, Any]: + """同步单所:交易缓存 + 各币种 K 线种子/增量.""" + ex_k = (exchange_key or "").strip().lower() + cache_stats = upsert_trades_cache(ex_k, trades, db_path=db_path, prune_missing=True) + apply_journal_behavior_overlays(ex_k, trades, db_path=db_path) + + by_sym: dict[str, int] = {} + for t in trades or []: + sym = (t.get("symbol") or "").strip().upper() + if not sym: + continue + oms = t.get("opened_at_ms") or _parse_dt_ms(t.get("opened_at")) + if oms: + cur = by_sym.get(sym) + if cur is None or int(oms) < cur: + by_sym[sym] = int(oms) + + seeded = 0 + appended = 0 + for sym, first_ms in by_sym.items(): + _ensure_meta(ex_k, sym, first_ms, db_path=db_path) + conn = _connect(db_path) + try: + meta = conn.execute( + "SELECT seed_complete FROM archive_meta WHERE exchange_key=? AND symbol=?", + (ex_k, sym), + ).fetchone() + finally: + conn.close() + if not meta or not int(meta["seed_complete"] or 0): + r = seed_symbol_archive(ex_k, sym, first_ms, remote_fetch, db_path=db_path) + seeded += int(r.get("seed_bars") or 0) + else: + r = sync_symbol_klines_incremental(ex_k, sym, remote_fetch, db_path=db_path) + appended += int(r.get("appended") or 0) + + return { + "ok": True, + "exchange_key": ex_k, + "symbols": len(by_sym), + "trades_upserted": int(cache_stats.get("upserted") or 0), + "trades_removed": int(cache_stats.get("removed") or 0), + "seed_bars": seeded, + "appended_bars": appended, + "trades": len(trades or []), + } + + +def ms_to_trading_day( + ms: int | None, + *, + reset_hour: int = TRADING_DAY_RESET_HOUR, + tz: ZoneInfo = CHART_DISPLAY_TZ, +) -> str | None: + if ms is None: + return None + try: + dt = datetime.fromtimestamp(int(ms) / 1000.0, tz=timezone.utc).astimezone(tz) + except (TypeError, ValueError, OSError): + return None + if dt.hour < reset_hour: + dt = dt - timedelta(days=1) + return dt.strftime("%Y-%m-%d") + + +def today_trading_day(*, reset_hour: int = TRADING_DAY_RESET_HOUR) -> str: + return ms_to_trading_day(_now_ms(), reset_hour=reset_hour) or datetime.now( + CHART_DISPLAY_TZ + ).strftime("%Y-%m-%d") + + +def trading_day_bounds_ms( + trading_day: str, + *, + reset_hour: int = TRADING_DAY_RESET_HOUR, + tz: ZoneInfo = CHART_DISPLAY_TZ, +) -> tuple[int, int]: + day = datetime.strptime((trading_day or "").strip()[:10], "%Y-%m-%d") + start = day.replace(hour=reset_hour, minute=0, second=0, microsecond=0, tzinfo=tz) + end = start + timedelta(days=1) + return int(start.timestamp() * 1000), int(end.timestamp() * 1000) + + +def resolve_period_bounds( + *, + period: str = "", + trading_day: str = "", + date_from: str = "", + date_to: str = "", + reset_hour: int = TRADING_DAY_RESET_HOUR, +) -> tuple[int, int, str, str, str]: + """返回 (start_ms, end_ms, date_from, date_to, period_label).""" + td = today_trading_day(reset_hour=reset_hour) + p = (period or "today").strip().lower() + if p in ("day", "today", ""): + d = (trading_day or "").strip()[:10] or td + start_ms, end_ms = trading_day_bounds_ms(d, reset_hour=reset_hour) + return start_ms, end_ms, d, d, f"本日 {d}" + if p == "week": + day_dt = datetime.strptime(td, "%Y-%m-%d") + monday = day_dt - timedelta(days=day_dt.weekday()) + df = monday.strftime("%Y-%m-%d") + start_ms, _ = trading_day_bounds_ms(df, reset_hour=reset_hour) + _, end_ms = trading_day_bounds_ms(td, reset_hour=reset_hour) + return start_ms, end_ms, df, td, f"本周 {df}~{td}" + if p == "month": + day_dt = datetime.strptime(td, "%Y-%m-%d") + first = day_dt.replace(day=1) + df = first.strftime("%Y-%m-%d") + start_ms, _ = trading_day_bounds_ms(df, reset_hour=reset_hour) + _, end_ms = trading_day_bounds_ms(td, reset_hour=reset_hour) + return start_ms, end_ms, df, td, f"本月 {df}~{td}" + if p == "range": + df = (date_from or "").strip()[:10] or td + dt = (date_to or "").strip()[:10] or df + if df > dt: + df, dt = dt, df + start_ms, _ = trading_day_bounds_ms(df, reset_hour=reset_hour) + _, end_ms = trading_day_bounds_ms(dt, reset_hour=reset_hour) + label = f"区间 {df}~{dt}" if df != dt else f"区间 {df}" + return start_ms, end_ms, df, dt, label + d = (trading_day or "").strip()[:10] or td + start_ms, end_ms = trading_day_bounds_ms(d, reset_hour=reset_hour) + return start_ms, end_ms, d, d, f"本日 {d}" + + +def _pnl_side(pnl: float) -> str: + if pnl > 0.0001: + return "win" + if pnl < -0.0001: + return "loss" + return "flat" + + +def _empty_pnl_bucket() -> dict[str, Any]: + return { + "open_count": 0, + "sick_count": 0, + "pnl_total": 0.0, + "pnl_ex_sick": 0.0, + "turnover_total": 0.0, + "commission_total": 0.0, + "win_count": 0, + "loss_count": 0, + "avg_win": None, + "avg_loss": None, + "max_win": None, + "max_loss": None, + } + + +def _finalize_pnl_bucket(bucket: dict[str, Any]) -> None: + wins = bucket.pop("_wins", []) + losses = bucket.pop("_losses", []) + open_count = int(bucket.get("open_count") or 0) + win_count = len(wins) + bucket["win_count"] = win_count + bucket["loss_count"] = len(losses) + bucket["avg_win"] = round(sum(wins) / len(wins), 4) if wins else None + avg_loss = round(sum(losses) / len(losses), 4) if losses else None + bucket["avg_loss"] = avg_loss + bucket["max_win"] = round(max(wins), 4) if wins else None + bucket["max_loss"] = round(min(losses), 4) if losses else None + bucket["pnl_total"] = round(float(bucket.get("pnl_total") or 0), 4) + bucket["pnl_ex_sick"] = round(float(bucket.get("pnl_ex_sick") or 0), 4) + bucket["turnover_total"] = round(float(bucket.get("turnover_total") or 0), 4) + bucket["commission_total"] = round(float(bucket.get("commission_total") or 0), 4) + bucket["win_rate"] = round(win_count / open_count * 100, 1) if open_count else None + avg_win = bucket["avg_win"] + if avg_win is not None and avg_loss is not None and avg_loss != 0: + bucket["profit_loss_ratio"] = round(avg_win / abs(avg_loss), 2) + else: + bucket["profit_loss_ratio"] = None + + +def _accumulate_trade_stat( + bucket: dict[str, Any], + *, + pnl: float, + is_sick: bool, + turnover: float = 0.0, + commission: float = 0.0, +) -> None: + bucket["open_count"] += 1 + bucket["pnl_total"] += pnl + bucket["turnover_total"] += turnover + bucket["commission_total"] += commission + if is_sick: + bucket["sick_count"] += 1 + else: + bucket["pnl_ex_sick"] += pnl + side = _pnl_side(pnl) + if side == "win": + bucket.setdefault("_wins", []).append(pnl) + elif side == "loss": + bucket.setdefault("_losses", []).append(pnl) + + +def _compute_period_stats(trade_rows: list[dict[str, Any]]) -> dict[str, Any]: + total_bucket = _empty_pnl_bucket() + by_ex: dict[str, dict[str, Any]] = {} + for td_row in trade_rows: + ex = str(td_row.get("exchange_key") or "?") + pnl = float(td_row.get("pnl_amount") or 0) + tag = str(td_row.get("behavior_tag") or "") + is_sick = tag == "sick" + turnover = float(td_row.get("exchange_turnover_usdt") or 0) + commission = float(td_row.get("exchange_commission_usdt") or 0) + _accumulate_trade_stat( + total_bucket, pnl=pnl, is_sick=is_sick, turnover=turnover, commission=commission + ) + if ex not in by_ex: + by_ex[ex] = _empty_pnl_bucket() + _accumulate_trade_stat( + by_ex[ex], pnl=pnl, is_sick=is_sick, turnover=turnover, commission=commission + ) + _finalize_pnl_bucket(total_bucket) + for ex in by_ex: + _finalize_pnl_bucket(by_ex[ex]) + total = int(total_bucket["open_count"] or 0) + sick = int(total_bucket["sick_count"] or 0) + sick_pct = round(sick / total * 100, 1) if total else 0.0 + return { + "open_count": total, + "sick_count": sick, + "sick_pct": sick_pct, + "pnl_total": total_bucket["pnl_total"], + "pnl_ex_sick": total_bucket["pnl_ex_sick"], + "win_count": total_bucket["win_count"], + "loss_count": total_bucket["loss_count"], + "avg_win": total_bucket["avg_win"], + "avg_loss": total_bucket["avg_loss"], + "max_win": total_bucket["max_win"], + "max_loss": total_bucket["max_loss"], + "win_rate": total_bucket["win_rate"], + "profit_loss_ratio": total_bucket["profit_loss_ratio"], + "turnover_total": total_bucket["turnover_total"], + "commission_total": total_bucket["commission_total"], + "by_exchange": by_ex, + } + + +def list_review_quotes(*, db_path: Path | None = None) -> list[dict[str, Any]]: + init_db(db_path) + conn = _connect(db_path) + try: + rows = conn.execute( + """ + SELECT id, quote_date, content, created_at, updated_at + FROM archive_review_quotes + ORDER BY quote_date DESC + LIMIT ? + """, + (ARCHIVE_QUOTES_MAX,), + ).fetchall() + return [dict(r) for r in rows] + finally: + conn.close() + + +def create_review_quote( + quote_date: str, + content: str, + *, + db_path: Path | None = None, +) -> dict[str, Any]: + init_db(db_path) + qd = (quote_date or "").strip()[:10] + if not qd: + raise ValueError("缺少 quote_date") + text = (content or "").strip() + if not text: + raise ValueError("语录内容不能为空") + if len(text) > ARCHIVE_QUOTE_MAX_LEN: + raise ValueError(f"语录最长 {ARCHIVE_QUOTE_MAX_LEN} 字") + conn = _connect(db_path) + try: + cnt = conn.execute("SELECT COUNT(*) AS c FROM archive_review_quotes").fetchone() + if int(cnt["c"] or 0) >= ARCHIVE_QUOTES_MAX: + raise ValueError(f"复盘语录最多保存 {ARCHIVE_QUOTES_MAX} 条") + now = _now_ms() + try: + cur = conn.execute( + """ + INSERT INTO archive_review_quotes (quote_date, content, created_at, updated_at) + VALUES (?,?,?,?) + """, + (qd, text, now, now), + ) + except sqlite3.IntegrityError as e: + raise ValueError("该日期已有语录,请展开编辑") from e + rid = int(cur.lastrowid) + row = conn.execute( + "SELECT id, quote_date, content, created_at, updated_at FROM archive_review_quotes WHERE id=?", + (rid,), + ).fetchone() + return dict(row) + finally: + conn.close() + + +def update_review_quote( + quote_id: int, + *, + quote_date: str | None = None, + content: str | None = None, + db_path: Path | None = None, +) -> dict[str, Any] | None: + init_db(db_path) + conn = _connect(db_path) + try: + row = conn.execute( + "SELECT id, quote_date, content FROM archive_review_quotes WHERE id=?", + (int(quote_id),), + ).fetchone() + if not row: + return None + qd = (quote_date or row["quote_date"] or "").strip()[:10] + text = (content if content is not None else row["content"] or "").strip() + if not qd or not text: + raise ValueError("日期与内容均不能为空") + if len(text) > ARCHIVE_QUOTE_MAX_LEN: + raise ValueError(f"语录最长 {ARCHIVE_QUOTE_MAX_LEN} 字") + now = _now_ms() + conn.execute( + """ + UPDATE archive_review_quotes + SET quote_date=?, content=?, updated_at=? + WHERE id=? + """, + (qd, text, now, int(quote_id)), + ) + out = conn.execute( + "SELECT id, quote_date, content, created_at, updated_at FROM archive_review_quotes WHERE id=?", + (int(quote_id),), + ).fetchone() + return dict(out) if out else None + finally: + conn.close() + + +def delete_review_quote(quote_id: int, *, db_path: Path | None = None) -> bool: + init_db(db_path) + conn = _connect(db_path) + try: + cur = conn.execute( + "DELETE FROM archive_review_quotes WHERE id=?", + (int(quote_id),), + ) + return int(cur.rowcount or 0) > 0 + finally: + conn.close() + + +def list_daily_trades( + trading_day: str = "", + *, + period: str = "", + date_from: str = "", + date_to: str = "", + exchange_key: str = "", + filter_profit: bool = False, + filter_loss: bool = False, + filter_sick: bool = False, + search: str = "", + db_path: Path | None = None, +) -> dict[str, Any]: + """按日期区间列出平仓记录(本日/本周/本月/自选,以平仓时间计),含犯病与盈亏统计.""" + init_db(db_path) + p = (period or "today").strip().lower() or "today" + start_ms, end_ms, df, dt, period_label = resolve_period_bounds( + period=p, + trading_day=trading_day, + date_from=date_from, + date_to=date_to, + ) + ex_filter = (exchange_key or "").strip().lower() + conn = _connect(db_path) + try: + params: list[Any] = [start_ms, end_ms] + where = "closed_at_ms IS NOT NULL AND closed_at_ms >= ? AND closed_at_ms < ?" + if ex_filter: + where += " AND exchange_key=?" + params.append(ex_filter) + rows = conn.execute( + f""" + SELECT * FROM archive_trade_cache + WHERE {where} + ORDER BY closed_at_ms DESC, trade_id DESC + """, + params, + ).fetchall() + overlays_by_ex: dict[str, dict[int, dict]] = {} + trades: list[dict[str, Any]] = [] + q = (search or "").strip().lower() + for r in rows: + ex_k = r["exchange_key"] + if ex_k not in overlays_by_ex: + overlays_by_ex[ex_k] = load_overlays(ex_k, db_path=db_path) + td_row = _trade_row_to_dict(r, overlays_by_ex[ex_k].get(int(r["trade_id"]))) + pnl = float(td_row.get("pnl_amount") or 0) + tag = td_row.get("behavior_tag") or "" + if filter_profit and pnl <= 0.0001: + continue + if filter_loss and pnl >= -0.0001: + continue + if filter_sick and tag != "sick": + continue + if q: + blob = " ".join( + str(td_row.get(k) or "") + for k in ( + "symbol", + "exchange_key", + "direction", + "result", + "note", + "monitor_type", + "entry_reason", + ) + ).lower() + if q not in blob: + continue + trades.append(td_row) + return { + "period": p, + "period_label": period_label, + "trading_day": dt, + "date_from": df, + "date_to": dt, + "trades": trades, + "stats": _compute_period_stats(trades), + } + finally: + conn.close() + + +def list_archive_calendar( + year: int, + month: int, + *, + exchange_key: str = "", + db_path: Path | None = None, + reset_hour: int = TRADING_DAY_RESET_HOUR, +) -> dict[str, Any]: + """按月返回每个交易日的盈亏,笔数,犯病标记(08:00 切日).""" + init_db(db_path) + y = int(year) + m = int(month) + if m < 1 or m > 12: + raise ValueError("month 无效") + first = f"{y:04d}-{m:02d}-01" + if m == 12: + next_first = datetime(y + 1, 1, 1) + else: + next_first = datetime(y, m + 1, 1) + last = (next_first - timedelta(days=1)).strftime("%Y-%m-%d") + start_ms, _ = trading_day_bounds_ms(first, reset_hour=reset_hour) + _, end_ms = trading_day_bounds_ms(last, reset_hour=reset_hour) + ex_filter = (exchange_key or "").strip().lower() + conn = _connect(db_path) + try: + params: list[Any] = [start_ms, end_ms] + where = "closed_at_ms IS NOT NULL AND closed_at_ms >= ? AND closed_at_ms < ?" + if ex_filter: + where += " AND exchange_key=?" + params.append(ex_filter) + rows = conn.execute( + f"SELECT * FROM archive_trade_cache WHERE {where}", + params, + ).fetchall() + overlays_by_ex: dict[str, dict[int, dict]] = {} + days: dict[str, dict[str, Any]] = {} + for r in rows: + ex_k = r["exchange_key"] + if ex_k not in overlays_by_ex: + overlays_by_ex[ex_k] = load_overlays(ex_k, db_path=db_path) + td_row = _trade_row_to_dict(r, overlays_by_ex[ex_k].get(int(r["trade_id"]))) + closed_ms = td_row.get("closed_at_ms") or _parse_dt_ms(td_row.get("closed_at")) + if not closed_ms: + continue + day = ms_to_trading_day(int(closed_ms), reset_hour=reset_hour) + if not day: + continue + if day < first or day > last: + continue + bucket = days.setdefault( + day, + { + "trading_day": day, + "open_count": 0, + "sick_count": 0, + "pnl_total": 0.0, + "turnover_total": 0.0, + "commission_total": 0.0, + "has_sick": False, + }, + ) + pnl = float(td_row.get("pnl_amount") or 0) + tag = str(td_row.get("behavior_tag") or "") + is_sick = tag == "sick" + bucket["open_count"] += 1 + bucket["pnl_total"] += pnl + bucket["turnover_total"] += float(td_row.get("exchange_turnover_usdt") or 0) + bucket["commission_total"] += float(td_row.get("exchange_commission_usdt") or 0) + if is_sick: + bucket["sick_count"] += 1 + bucket["has_sick"] = True + for d in days.values(): + d["pnl_total"] = round(float(d["pnl_total"]), 4) + d["turnover_total"] = round(float(d["turnover_total"]), 4) + d["commission_total"] = round(float(d["commission_total"]), 4) + month_pnl = sum(float(d["pnl_total"]) for d in days.values()) + month_count = sum(int(d["open_count"]) for d in days.values()) + return { + "year": y, + "month": m, + "date_from": first, + "date_to": last, + "days": days, + "month_pnl_total": round(month_pnl, 4), + "month_open_count": month_count, + } + finally: + conn.close() diff --git a/lib/hub/hub_symbol_lib.py b/lib/hub/hub_symbol_lib.py new file mode 100644 index 0000000..2ec4aa6 --- /dev/null +++ b/lib/hub/hub_symbol_lib.py @@ -0,0 +1,38 @@ +"""合约 symbol 匹配(持仓 vs 监控/挂单).""" + + +def _symbol_base_coin(symbol: str) -> str: + s = (symbol or "").strip().upper() + if not s: + return "" + if "-SWAP" in s: + s = s.replace("-SWAP", "") + if "-" in s: + return s.split("-", 1)[0] + if "/" in s: + return s.split("/", 1)[0] + if ":" in s: + return s.split(":", 1)[0] + return s + + +def symbols_match(position_symbol: str, order_symbol: str) -> bool: + a = (position_symbol or "").strip().upper() + b = (order_symbol or "").strip().upper() + if not a or not b: + return False + if a == b: + return True + ba, bb = _symbol_base_coin(a), _symbol_base_coin(b) + if ba and bb and ba == bb: + return True + for suf in (":USDT", "/USDT:USDT", "/USDT"): + a2 = a.replace(suf, "") + b2 = b.replace(suf, "") + if f"{a2}/USDT" == b or f"{a2}/USDT:USDT" == b: + return True + if f"{b2}/USDT" == a or f"{b2}/USDT:USDT" == a: + return True + if a2 == b2: + return True + return False diff --git a/lib/hub/hub_system_logs_lib.py b/lib/hub/hub_system_logs_lib.py new file mode 100644 index 0000000..43aba88 --- /dev/null +++ b/lib/hub/hub_system_logs_lib.py @@ -0,0 +1,172 @@ +"""中控:读取 PM2 进程 stdout/stderr 日志(系统日志页).""" +from __future__ import annotations + +import json +import os +import subprocess +import time +from pathlib import Path +from typing import Any + +LOG_TARGETS: dict[str, dict[str, str]] = { + "binance": {"label": "币安", "pm2_name": "crypto_binance"}, + "gate": {"label": "Gate", "pm2_name": "crypto_gate"}, + "okx": {"label": "OKX", "pm2_name": "crypto_okx"}, + "hub": {"label": "中控", "pm2_name": "manual-trading-hub"}, +} + +DEFAULT_LINES = 200 +MAX_LINES = 500 +DEFAULT_TAIL_BYTES = 400_000 +_PATH_CACHE_TTL_SEC = 30 + +_path_cache: dict[str, tuple[Path, Path]] = {} +_path_cache_at = 0.0 + + +def pm2_logs_dir() -> Path: + raw = (os.getenv("PM2_HOME") or "").strip() + base = Path(raw) if raw else Path.home() / ".pm2" + return base / "logs" + + +def _pm2_jlist() -> list[dict[str, Any]]: + try: + proc = subprocess.run( + ["pm2", "jlist"], + capture_output=True, + text=True, + timeout=8, + check=False, + ) + if proc.returncode != 0: + return [] + data = json.loads(proc.stdout or "[]") + return data if isinstance(data, list) else [] + except (OSError, subprocess.SubprocessError, json.JSONDecodeError, ValueError): + return [] + + +def _newest_match(pattern: str, logs_dir: Path) -> Path | None: + matches = [p for p in logs_dir.glob(pattern) if p.is_file()] + if not matches: + return None + return max(matches, key=lambda p: p.stat().st_mtime) + + +def _glob_log_paths(pm2_name: str) -> tuple[Path, Path]: + logs_dir = pm2_logs_dir() + out_path = Path() + err_path = Path() + if not logs_dir.is_dir(): + return out_path, err_path + slugs = {pm2_name, pm2_name.replace("_", "-")} + for slug in slugs: + if not out_path.is_file(): + hit = _newest_match(f"{slug}-out*.log", logs_dir) + if hit is not None: + out_path = hit + if not err_path.is_file(): + hit = _newest_match(f"{slug}-error*.log", logs_dir) + if hit is not None: + err_path = hit + return out_path, err_path + + +def resolve_log_paths(pm2_name: str) -> tuple[Path, Path]: + global _path_cache_at + now = time.time() + if now - _path_cache_at > _PATH_CACHE_TTL_SEC: + _path_cache.clear() + _path_cache_at = now + cached = _path_cache.get(pm2_name) + if cached is not None: + return cached + + out_path = Path() + err_path = Path() + for proc in _pm2_jlist(): + if proc.get("name") != pm2_name: + continue + env = proc.get("pm2_env") or {} + raw_out = (env.get("pm_out_log_path") or "").strip() + raw_err = (env.get("pm_err_log_path") or "").strip() + if raw_out: + out_path = Path(raw_out) + if raw_err: + err_path = Path(raw_err) + break + + glob_out, glob_err = _glob_log_paths(pm2_name) + if not out_path.is_file() and glob_out.is_file(): + out_path = glob_out + if not err_path.is_file() and glob_err.is_file(): + err_path = glob_err + + result = (out_path, err_path) + _path_cache[pm2_name] = result + return result + + +def log_file_paths(pm2_name: str) -> tuple[Path, Path]: + return resolve_log_paths(pm2_name) + + +def tail_lines( + path: Path, + lines: int = DEFAULT_LINES, + *, + max_bytes: int = DEFAULT_TAIL_BYTES, +) -> str: + if not path.is_file(): + return "" + try: + size = path.stat().st_size + with path.open("rb") as handle: + if size <= max_bytes: + data = handle.read() + else: + handle.seek(max(0, size - max_bytes)) + data = handle.read() + text = data.decode("utf-8", errors="replace") + parts = text.splitlines() + if len(parts) > lines: + parts = parts[-lines:] + return "\n".join(parts) + except OSError: + return "" + + +def system_logs_meta() -> dict[str, Any]: + return { + "ok": True, + "targets": [ + {"key": key, "label": cfg["label"], "pm2_name": cfg["pm2_name"]} + for key, cfg in LOG_TARGETS.items() + ], + "default_lines": DEFAULT_LINES, + "max_lines": MAX_LINES, + } + + +def load_system_logs(target: str, lines: int = DEFAULT_LINES) -> dict[str, Any]: + key = (target or "").strip().lower() + if key not in LOG_TARGETS: + raise KeyError(key) + cfg = LOG_TARGETS[key] + line_count = max(20, min(MAX_LINES, int(lines or DEFAULT_LINES))) + out_path, err_path = resolve_log_paths(cfg["pm2_name"]) + return { + "ok": True, + "key": key, + "label": cfg["label"], + "pm2_name": cfg["pm2_name"], + "lines": line_count, + "out": tail_lines(out_path, line_count), + "err": tail_lines(err_path, line_count), + "out_exists": out_path.is_file(), + "err_exists": err_path.is_file(), + "out_path": str(out_path) if str(out_path) else "", + "err_path": str(err_path) if str(err_path) else "", + "updated_at": int(time.time()), + } diff --git a/lib/hub/hub_trades_lib.py b/lib/hub/hub_trades_lib.py new file mode 100644 index 0000000..ddc4a92 --- /dev/null +++ b/lib/hub/hub_trades_lib.py @@ -0,0 +1,742 @@ +"""各实例当日平仓记录查询(供 hub_bridge /api/hub/trades/today 与中控 AI 聚合).""" +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Any, Callable, Optional + +from lib.strategy.strategy_trade_labels import ( + MONITOR_TYPE_ROLL, + MONITOR_TYPE_TREND_PULLBACK, + entry_reason_for_monitor_type, +) +from lib.trade.time_close_lib import TIME_CLOSE_RESULT +from lib.trade.entry_model_lib import format_entry_type_display + +TRADE_COMPLETED_RESULTS = ( + "止盈", + "止损", + "保本止盈", + "移动止盈", + "手动平仓", + "强制清仓", + "外部平仓", + TIME_CLOSE_RESULT, +) + + +def trading_day_from_dt(dt: datetime, reset_hour: int = 8) -> str: + """与实例 get_trading_day 一致:小时 < reset_hour 归属上一日历日.""" + if dt.hour < reset_hour: + dt = dt - timedelta(days=1) + return dt.strftime("%Y-%m-%d") + + +def current_trading_day(*, now: datetime | None = None, reset_hour: int = 8) -> str: + return trading_day_from_dt(now or datetime.now(), reset_hour) + + +def parse_dt_for_trading_day(raw: Any) -> datetime | None: + if raw is None: + return None + s = str(raw).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 trading_day_window_bounds(trading_day: str, reset_hour: int = 8) -> tuple[str, str]: + """交易日 [reset_hour, 次日 reset_hour) 对应的北京时间字符串区间(闭区间).""" + day = datetime.strptime((trading_day or "").strip()[:10], "%Y-%m-%d") + start = day.replace(hour=reset_hour, minute=0, second=0, microsecond=0) + end = start + timedelta(days=1) - timedelta(seconds=1) + return start.strftime("%Y-%m-%d %H:%M:%S"), end.strftime("%Y-%m-%d %H:%M:%S") + + +def _row_dict(row, row_to_dict: Optional[Callable] = None) -> dict: + if row is None: + return {} + if row_to_dict: + try: + return dict(row_to_dict(row)) + except Exception: + pass + try: + keys = row.keys() if hasattr(row, "keys") else () + if keys: + return {k: row[k] for k in keys} + except Exception: + pass + try: + return dict(row) + except Exception: + return {} + + +def _effective_field(d: dict, reviewed_key: str, base_key: str, default: Any = None) -> Any: + rv = d.get(reviewed_key) + if rv is not None and str(rv).strip() != "": + return rv + bv = d.get(base_key) + if bv is not None and str(bv).strip() != "": + return bv + return default + + +def format_hold_minutes(minutes: Any) -> str: + try: + total = int(minutes or 0) + except (TypeError, ValueError): + return "0分钟" + if total <= 0: + return "0分钟" + hours = total // 60 + mins = total % 60 + if hours: + return f"{hours}小时{mins}分钟" + return f"{mins}分钟" + + +def _normalize_monitor_type_label(raw: Any) -> str: + mt = str(raw or "").strip() + if mt in ("trend_pullback", "trend"): + return MONITOR_TYPE_TREND_PULLBACK + if mt in ("roll",): + return MONITOR_TYPE_ROLL + return mt + + +def effective_entry_type(d: dict) -> str: + """复盘开仓类型优先,与实例交易记录 effective_entry_reason 一致.""" + er = _effective_field(d, "reviewed_entry_reason", "entry_reason") + if er is not None and str(er).strip(): + return str(er).strip() + mt = _normalize_monitor_type_label(d.get("monitor_type")) + er2 = entry_reason_for_monitor_type(mt) + if er2: + return er2 + kst = str(d.get("key_signal_type") or "").strip() + if kst: + return kst + legacy = str(d.get("entry_type") or "").strip() + if legacy and legacy not in ("trend_pullback", "roll", "trend"): + return _normalize_monitor_type_label(legacy) or legacy + return mt + + +def display_entry_type_label(d: dict) -> str: + """档案/列表展示用开仓类型(不回落为「下单监控」若已有复盘或建档类型).""" + label = effective_entry_type(d).strip() + if not label: + return "—" + formatted = format_entry_type_display( + label, + entry_model=d.get("entry_model"), + trade_style=d.get("trade_style"), + ) + out = _normalize_monitor_type_label(formatted) or formatted + return out or "—" + + +def effective_hold_minutes( + d: dict, + *, + opened_ms: int | None = None, + closed_ms: int | None = None, +) -> int: + hm = _effective_field(d, "reviewed_hold_minutes", "hold_minutes") + if hm is not None and str(hm).strip() != "": + try: + return max(0, int(hm)) + except (TypeError, ValueError): + pass + hs = _effective_field(d, "reviewed_hold_seconds", "hold_seconds") + if hs is not None and str(hs).strip() != "": + try: + return max(0, int(int(hs) // 60)) + except (TypeError, ValueError): + pass + oms = opened_ms if opened_ms is not None else d.get("opened_at_ms") + cms = closed_ms if closed_ms is not None else d.get("closed_at_ms") + try: + oms_i = int(oms) if oms not in (None, "") else None + cms_i = int(cms) if cms not in (None, "") else None + except (TypeError, ValueError): + oms_i = cms_i = None + if oms_i and cms_i and cms_i > oms_i: + return max(0, int((cms_i - oms_i) // 60_000)) + return 0 + + +def _effective_pnl(d: dict) -> float: + reviewed = d.get("reviewed_pnl_amount") + if reviewed is not None and str(reviewed).strip() != "": + try: + return float(reviewed) + except (TypeError, ValueError): + pass + ex = d.get("exchange_realized_pnl") + if ex is not None and str(ex).strip() != "": + try: + return float(ex) + except (TypeError, ValueError): + pass + try: + return float(d.get("pnl_amount") or 0) + except (TypeError, ValueError): + return 0.0 + + +def _trade_close_dt(d: dict) -> datetime | None: + raw = _effective_field(d, "reviewed_closed_at", "closed_at") + if raw is None or str(raw).strip() == "": + raw = d.get("created_at") or d.get("opened_at") + return parse_dt_for_trading_day(raw) + + +def _normalize_trade_row( + d: dict, + *, + trading_day: str, + reset_hour: int, +) -> dict[str, Any] | None: + effective_result = str(_effective_field(d, "reviewed_result", "result") or "").strip() + if effective_result not in TRADE_COMPLETED_RESULTS: + return None + close_dt = _trade_close_dt(d) + if not close_dt: + return None + if trading_day_from_dt(close_dt, reset_hour) != trading_day: + return None + pnl = _effective_pnl(d) + closed_at = _effective_field(d, "reviewed_closed_at", "closed_at") + opened_at = _effective_field(d, "reviewed_opened_at", "opened_at") + return { + "symbol": d.get("symbol"), + "direction": d.get("direction"), + "result": effective_result, + "pnl_amount": round(pnl, 4), + "closed_at": closed_at, + "opened_at": opened_at, + "monitor_type": d.get("monitor_type"), + "actual_rr": d.get("actual_rr"), + "planned_rr": d.get("planned_rr"), + "trade_style": d.get("trade_style"), + "entry_reason": d.get("entry_reason"), + "reviewed": bool(d.get("reviewed_at") or d.get("reviewed_result")), + } + + +def fetch_trades_for_trading_day( + conn, + trading_day: str, + *, + row_to_dict_fn: Optional[Callable] = None, + reset_hour: int = 8, + limit: int = 200, +) -> list[dict[str, Any]]: + """返回指定交易日的已平仓记录(与 /records 交易记录一致,复盘字段优先).""" + day = (trading_day or "").strip()[:10] + if not day: + return [] + lim = max(1, min(int(limit or 200), 500)) + start_bj, end_bj = trading_day_window_bounds(day, reset_hour) + ts_expr = "REPLACE(COALESCE(reviewed_closed_at, closed_at, created_at, opened_at), 'T', ' ')" + rows = conn.execute( + f""" + SELECT symbol, direction, result, reviewed_result, pnl_amount, reviewed_pnl_amount, + exchange_realized_pnl, closed_at, reviewed_closed_at, opened_at, reviewed_opened_at, + created_at, monitor_type, actual_rr, planned_rr, trade_style, entry_reason, + reviewed_at + FROM trade_records + WHERE {ts_expr} >= ? AND {ts_expr} <= ? + ORDER BY {ts_expr} ASC + LIMIT ? + """, + (start_bj, end_bj, lim * 3), + ).fetchall() + out: list[dict[str, Any]] = [] + for row in rows: + d = _row_dict(row, row_to_dict_fn) + norm = _normalize_trade_row(d, trading_day=day, reset_hour=reset_hour) + if norm: + out.append(norm) + if len(out) >= lim: + break + return out + + +def _normalize_archive_trade_row( + d: dict, + *, + exchange_key: str = "", + reset_hour: int = 8, +) -> dict[str, Any] | None: + """全历史档案用:已平仓记录(不按交易日截断).""" + effective_result = str(_effective_field(d, "reviewed_result", "result") or "").strip() + if effective_result not in TRADE_COMPLETED_RESULTS: + return None + close_dt = _trade_close_dt(d) + if not close_dt: + return None + pnl = _effective_pnl(d) + closed_at = _effective_field(d, "reviewed_closed_at", "closed_at") + opened_at = _effective_field(d, "reviewed_opened_at", "opened_at") + opened_ms = d.get("opened_at_ms") + closed_ms = d.get("closed_at_ms") + if opened_ms in (None, ""): + odt = parse_dt_for_trading_day(opened_at) + opened_ms = int(odt.timestamp() * 1000) if odt else None + if closed_ms in (None, ""): + cdt = close_dt + closed_ms = int(cdt.timestamp() * 1000) if cdt else None + try: + trade_id = int(d.get("id")) + except (TypeError, ValueError): + return None + opened_ms_i = int(opened_ms) if opened_ms else None + closed_ms_i = int(closed_ms) if closed_ms else None + hold_m = effective_hold_minutes(d, opened_ms=opened_ms_i, closed_ms=closed_ms_i) + entry_type = display_entry_type_label(d) + reviewed = bool( + d.get("reviewed_at") + or d.get("reviewed_result") + or d.get("reviewed_opened_at") + or d.get("reviewed_closed_at") + or d.get("reviewed_entry_reason") + or d.get("reviewed_hold_minutes") + ) + return { + "id": trade_id, + "exchange_key": (exchange_key or "").strip().lower(), + "symbol": (d.get("symbol") or "").strip().upper(), + "direction": d.get("direction"), + "result": effective_result, + "pnl_amount": round(pnl, 4), + "closed_at": closed_at, + "opened_at": opened_at, + "opened_at_ms": opened_ms_i, + "closed_at_ms": closed_ms_i, + "monitor_type": _normalize_monitor_type_label(d.get("monitor_type")), + "entry_type": entry_type, + "entry_reason": entry_type, + "hold_minutes": hold_m, + "hold_minutes_text": format_hold_minutes(hold_m), + "actual_rr": d.get("actual_rr"), + "planned_rr": d.get("planned_rr"), + "trade_style": d.get("trade_style"), + "trigger_price": d.get("trigger_price"), + "stop_loss": _effective_field(d, "reviewed_stop_loss", "stop_loss"), + "take_profit": _effective_field(d, "reviewed_take_profit", "take_profit"), + "reviewed": reviewed, + "trading_day": trading_day_from_dt(close_dt, reset_hour), + "exchange_turnover_usdt": d.get("exchange_turnover_usdt"), + "exchange_commission_usdt": d.get("exchange_commission_usdt"), + } + + +_SNAPSHOT_STATUS_TO_RESULT = { + "stopped_sl": "止损", + "stopped_tp": "止盈", + "stopped_manual": "手动平仓", + "stopped_external": "外部平仓", +} + + +def _table_columns(conn, table: str) -> set[str]: + try: + rows = conn.execute(f"PRAGMA table_info({table})").fetchall() + except Exception: + return set() + out: set[str] = set() + for r in rows: + try: + out.add(str(r[1])) + except (IndexError, TypeError): + try: + out.add(str(r["name"])) + except Exception: + continue + return out + + +def _archive_ts_expr(cols: set[str]) -> str: + parts = [c for c in ("reviewed_closed_at", "closed_at", "created_at", "opened_at") if c in cols] + if not parts: + return "''" + return f"REPLACE(COALESCE({', '.join(parts)}), 'T', ' ')" + + +def _archive_trade_select_sql(cols: set[str]) -> str: + wanted = [ + "id", + "symbol", + "direction", + "result", + "reviewed_result", + "pnl_amount", + "reviewed_pnl_amount", + "exchange_realized_pnl", + "closed_at", + "reviewed_closed_at", + "opened_at", + "reviewed_opened_at", + "opened_at_ms", + "closed_at_ms", + "created_at", + "monitor_type", + "key_signal_type", + "actual_rr", + "planned_rr", + "trade_style", + "entry_reason", + "reviewed_entry_reason", + "hold_minutes", + "reviewed_hold_minutes", + "hold_seconds", + "reviewed_hold_seconds", + "trigger_price", + "stop_loss", + "take_profit", + "reviewed_stop_loss", + "reviewed_take_profit", + "reviewed_at", + "trend_plan_id", + "exchange_turnover_usdt", + "exchange_commission_usdt", + ] + select_cols = [c for c in wanted if c in cols] + if "id" not in select_cols: + select_cols = ["id"] + select_cols + return ", ".join(select_cols) + + +def _existing_trend_plan_ids(conn) -> set[int]: + cols = _table_columns(conn, "trade_records") + if "trend_plan_id" not in cols: + return set() + rows = conn.execute( + "SELECT DISTINCT trend_plan_id FROM trade_records WHERE trend_plan_id IS NOT NULL" + ).fetchall() + out: set[int] = set() + for row in rows: + d = _row_dict(row) + try: + out.add(int(d.get("trend_plan_id"))) + except (TypeError, ValueError): + continue + return out + + +def _normalize_snapshot_archive_row( + snap: dict, + *, + exchange_key: str = "", + reset_hour: int = 8, +) -> dict[str, Any] | None: + result = str(snap.get("result_label") or "").strip() + if not result: + result = _SNAPSHOT_STATUS_TO_RESULT.get( + str(snap.get("status_at_close") or "").strip(), "" + ) + if result not in TRADE_COMPLETED_RESULTS: + return None + closed_at = snap.get("closed_at") + close_dt = parse_dt_for_trading_day(closed_at) + if not close_dt: + return None + opened_at = snap.get("opened_at") + opened_ms = _parse_ms_from_row(snap.get("opened_at")) + closed_ms = _parse_ms_from_row(closed_at) + try: + snap_id = int(snap.get("id")) + except (TypeError, ValueError): + return None + try: + pnl = float(snap.get("pnl_amount") or 0) + except (TypeError, ValueError): + pnl = 0.0 + st = str(snap.get("strategy_type") or "").strip() + monitor_type = _normalize_monitor_type_label( + "trend_pullback" if st == "trend_pullback" else ("roll" if st == "roll" else st) + ) + hold_m = effective_hold_minutes( + {}, + opened_ms=opened_ms, + closed_ms=closed_ms, + ) + entry_type = entry_reason_for_monitor_type(monitor_type) or monitor_type + return { + "id": -snap_id, + "exchange_key": (exchange_key or "").strip().lower(), + "symbol": (snap.get("symbol") or "").strip().upper(), + "direction": snap.get("direction"), + "result": result, + "pnl_amount": round(pnl, 4), + "closed_at": closed_at, + "opened_at": opened_at, + "opened_at_ms": opened_ms, + "closed_at_ms": closed_ms, + "monitor_type": monitor_type, + "entry_type": entry_type, + "entry_reason": entry_type, + "hold_minutes": hold_m, + "hold_minutes_text": format_hold_minutes(hold_m), + "from_snapshot": True, + "snapshot_id": snap_id, + "trend_plan_id": snap.get("source_id"), + "reviewed": False, + "trading_day": trading_day_from_dt(close_dt, reset_hour), + } + + +def _parse_ms_from_row(raw: Any) -> int | None: + if raw in (None, ""): + return None + try: + if isinstance(raw, (int, float)): + v = int(raw) + return v if v > 1_000_000_000_000 else v * 1000 + except (TypeError, ValueError): + pass + dt = parse_dt_for_trading_day(raw) + return int(dt.timestamp() * 1000) if dt else None + + +def _fetch_strategy_snapshots_for_archive( + conn, + *, + exchange_key: str = "", + days: int = 365, + reset_hour: int = 8, + limit: int = 2000, + skip_plan_ids: set[int] | None = None, +) -> list[dict[str, Any]]: + cols = _table_columns(conn, "strategy_trade_snapshots") + if not cols: + return [] + lim = max(1, min(int(limit or 2000), 5000)) + day_span = max(1, min(int(days or 365), 3650)) + cutoff = datetime.now() - timedelta(days=day_span) + cutoff_s = cutoff.strftime("%Y-%m-%d %H:%M:%S") + ts_expr = "REPLACE(COALESCE(closed_at, opened_at, created_at), 'T', ' ')" + rows = conn.execute( + f""" + SELECT * FROM strategy_trade_snapshots + WHERE {ts_expr} >= ? + ORDER BY {ts_expr} DESC + LIMIT ? + """, + (cutoff_s, lim * 2), + ).fetchall() + skip = skip_plan_ids or set() + out: list[dict[str, Any]] = [] + for row in rows: + d = _row_dict(row) + try: + source_id = int(d.get("source_id") or 0) + except (TypeError, ValueError): + source_id = 0 + if source_id > 0 and source_id in skip: + continue + norm = _normalize_snapshot_archive_row( + d, exchange_key=exchange_key, reset_hour=reset_hour + ) + if norm: + out.append(norm) + if len(out) >= lim: + break + return out + + +def fetch_trades_for_archive( + conn, + *, + exchange_key: str = "", + days: int = 365, + row_to_dict_fn: Optional[Callable] = None, + reset_hour: int = 8, + limit: int = 2000, + include_strategy_snapshots: bool = True, +) -> list[dict[str, Any]]: + """返回近 N 天已平仓记录(trade_records + 未落库的 strategy 快照).""" + lim = max(1, min(int(limit or 2000), 5000)) + day_span = max(1, min(int(days or 365), 3650)) + cutoff = datetime.now() - timedelta(days=day_span) + cutoff_s = cutoff.strftime("%Y-%m-%d %H:%M:%S") + cols = _table_columns(conn, "trade_records") + if not cols: + records: list[dict[str, Any]] = [] + else: + ts_expr = _archive_ts_expr(cols) + sql = f""" + SELECT {_archive_trade_select_sql(cols)} + FROM trade_records + WHERE {ts_expr} >= ? + ORDER BY {ts_expr} DESC + LIMIT ? + """ + rows = conn.execute(sql, (cutoff_s, lim * 2)).fetchall() + records = [] + for row in rows: + d = _row_dict(row, row_to_dict_fn) + norm = _normalize_archive_trade_row( + d, exchange_key=exchange_key, reset_hour=reset_hour + ) + if norm: + records.append(norm) + if len(records) >= lim: + break + + if not include_strategy_snapshots: + return records + + skip_ids = _existing_trend_plan_ids(conn) + for rec in records: + try: + pid = int(rec.get("trend_plan_id") or 0) + except (TypeError, ValueError): + pid = 0 + if pid > 0: + skip_ids.add(pid) + + snaps = _fetch_strategy_snapshots_for_archive( + conn, + days=days, + exchange_key=exchange_key, + reset_hour=reset_hour, + limit=max(0, lim - len(records)), + skip_plan_ids=skip_ids, + ) + merged = records + snaps + merged.sort( + key=lambda x: int(x.get("closed_at_ms") or 0), + reverse=True, + ) + merged = merged[:lim] + attach_journal_mood_tags(conn, merged, cutoff_s=cutoff_s) + return merged + + +def _symbol_coin_base(symbol: str) -> str: + s = (symbol or "").strip().upper() + if "/" in s: + return s.split("/")[0] + return s + + +def _datetime_minute_key(raw: Any) -> str: + if raw is None: + return "" + s = str(raw).strip().replace("T", " ").replace("Z", "") + if len(s) >= 16: + return s[:16] + return s[:10] if len(s) >= 10 else s + + +def journal_trade_match_key(symbol: str, opened_at: Any, closed_at: Any) -> tuple[str, str, str]: + return ( + _symbol_coin_base(symbol), + _datetime_minute_key(opened_at), + _datetime_minute_key(closed_at), + ) + + +def load_journal_mood_match_index( + conn, + *, + cutoff_s: str, +) -> dict[tuple[str, str, str], list[str]]: + """复盘 mood_issues → 交易匹配键(币种 + 开/平仓分钟).""" + from lib.trade.account_risk_lib import parse_mood_issues + + cols = _table_columns(conn, "journal_entries") + if not cols or "mood_issues" not in cols: + return {} + close_expr = "REPLACE(COALESCE(close_datetime, open_datetime, created_at), 'T', ' ')" + open_expr = "REPLACE(COALESCE(open_datetime, created_at), 'T', ' ')" + rows = conn.execute( + f""" + SELECT coin, open_datetime, close_datetime, mood_issues + FROM journal_entries + WHERE {close_expr} >= ? OR {open_expr} >= ? + """, + (cutoff_s, cutoff_s), + ).fetchall() + out: dict[tuple[str, str, str], list[str]] = {} + for row in rows: + d = _row_dict(row) + issues = parse_mood_issues(d.get("mood_issues")) + if not issues: + continue + coin = str(d.get("coin") or "").strip().upper() + sym = coin if "/" in coin else (f"{coin}/USDT" if coin else "") + key = journal_trade_match_key(sym, d.get("open_datetime"), d.get("close_datetime")) + if not key[0]: + continue + out[key] = issues + return out + + +def attach_journal_mood_tags( + conn, + trades: list[dict[str, Any]], + *, + cutoff_s: str, +) -> None: + """实例复盘勾选情绪标签 → 档案交易自动标犯病(hub 同步用).""" + if not trades: + return + mood_index = load_journal_mood_match_index(conn, cutoff_s=cutoff_s) + if not mood_index: + return + for t in trades: + if not isinstance(t, dict): + continue + key = journal_trade_match_key( + str(t.get("symbol") or ""), + t.get("opened_at"), + t.get("closed_at"), + ) + issues = mood_index.get(key) + if not issues: + continue + t["journal_mood_issues"] = issues + t["journal_mood_sick"] = True + t["behavior_tag_from_journal"] = True + t["behavior_tag"] = "sick" + + +def summarize_trades(trades: list[dict]) -> dict[str, Any]: + """单笔列表 → 笔数 / 盈亏 / 胜败统计.""" + total_pnl = 0.0 + win_pnl = 0.0 + loss_pnl = 0.0 + win = loss = flat = 0 + for t in trades or []: + try: + pnl = float(t.get("pnl_amount") or 0) + except (TypeError, ValueError): + pnl = 0.0 + total_pnl += pnl + if pnl > 1e-9: + win += 1 + win_pnl += pnl + elif pnl < -1e-9: + loss += 1 + loss_pnl += pnl + else: + flat += 1 + return { + "closed_count": len(trades or []), + "win_count": win, + "loss_count": loss, + "flat_count": flat, + "total_pnl_u": round(total_pnl, 4), + "win_pnl_u": round(win_pnl, 4), + "loss_pnl_u": round(loss_pnl, 4), + } diff --git a/lib/hub/hub_volume_rank_lib.py b/lib/hub/hub_volume_rank_lib.py new file mode 100644 index 0000000..4bde011 --- /dev/null +++ b/lib/hub/hub_volume_rank_lib.py @@ -0,0 +1,595 @@ +"""行情区:各交易所 USDT 永续昨日成交额 Top N(每日 8:00 快照).""" + +from __future__ import annotations + +import json +import os +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any, Callable +from zoneinfo import ZoneInfo + +from lib.hub.hub_trades_lib import trading_day_from_dt + +TOP_N_DEFAULT = 20 +CACHE_VERSION = 3 +LIQUIDITY_RANK_CACHE_VERSION = 1 + + +def volume_rank_reset_hour() -> int: + try: + return max(0, min(23, int(os.getenv("HUB_VOLUME_RANK_RESET_HOUR", "8")))) + except ValueError: + return 8 + + +def volume_rank_timezone() -> ZoneInfo: + name = (os.getenv("HUB_VOLUME_RANK_TZ") or "Asia/Shanghai").strip() or "Asia/Shanghai" + try: + return ZoneInfo(name) + except Exception: + return ZoneInfo("Asia/Shanghai") + + +def rank_date_label(*, now: datetime | None = None, reset_hour: int | None = None) -> str: + """8 点更新后展示的「昨日」交易日(与 TRADING_DAY_RESET_HOUR 口径一致).""" + rh = volume_rank_reset_hour() if reset_hour is None else reset_hour + tz = volume_rank_timezone() + dt = now.astimezone(tz) if now else datetime.now(tz) + cur_td = trading_day_from_dt(dt.replace(tzinfo=None), rh) + cur = datetime.strptime(cur_td, "%Y-%m-%d").date() + return (cur - timedelta(days=1)).isoformat() + + +def seconds_until_next_reset( + *, + now: datetime | None = None, + reset_hour: int | None = None, +) -> float: + rh = volume_rank_reset_hour() if reset_hour is None else reset_hour + tz = volume_rank_timezone() + dt = now.astimezone(tz) if now else datetime.now(tz) + nxt = dt.replace(hour=rh, minute=0, second=0, microsecond=0) + if dt >= nxt: + nxt += timedelta(days=1) + return max(1.0, (nxt - dt).total_seconds()) + + +def default_cache_path() -> Path: + raw = (os.getenv("HUB_VOLUME_RANK_CACHE_PATH") or "").strip() + if raw: + return Path(raw) + from lib.paths import hub_data_dir + + return hub_data_dir() / "hub_volume_rank.json" + + +def _safe_float(v: Any) -> float | None: + try: + n = float(v) + return n if n == n else None + except (TypeError, ValueError): + return None + + +def _ticker_base(sym_text: str) -> str: + 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 + + +def _hub_symbol_from_base(base: str, quote: str = "USDT") -> str: + b = str(base or "").strip().upper() + q = str(quote or "USDT").strip().upper() + return f"{b}/{q}" if b else "" + + +def _hub_symbol_from_market(market: dict | None, fallback_symbol: str) -> str: + if market: + base = str(market.get("base") or "").strip().upper() + quote = str(market.get("quote") or "USDT").strip().upper() + if base: + return f"{base}/{quote}" + fb = str(fallback_symbol or "").upper().strip() + if ":" in fb: + fb = fb.split(":", 1)[0] + if "/" in fb: + return fb + base = _ticker_base(fb) + return f"{base}/USDT" if base else fb + + +def _okx_turnover_usdt(row: dict | None) -> float | None: + """OKX SWAP:成交额(USDT) ≈ volCcy24h(基础币) × last.""" + if not isinstance(row, dict): + return None + base_vol = _safe_float(row.get("volCcy24h")) + if base_vol is None or base_vol <= 0: + return None + last = _safe_float(row.get("last") or row.get("lastPx")) + if last is None or last <= 0: + return None + return float(base_vol * last) + + +def _quote_volume_from_ticker( + ticker: dict | None, + market: dict | None, + *, + exchange_id: str = "", +) -> float | None: + ex_id = str(exchange_id or "").lower() + t = ticker or {} + info = t.get("info") if isinstance(t.get("info"), dict) else {} + + if ex_id == "okx": + row = dict(info) + if row.get("last") is None: + row["last"] = t.get("last") + qv = _okx_turnover_usdt(row) + if qv is not None and qv > 0: + return qv + + qv = _safe_float(t.get("quoteVolume")) + if qv is not None and qv > 0: + return qv + + if ex_id in ("gateio", "gate"): + for key in ( + "volume_24h_quote", + "volume_24h_settle", + "quote_volume", + "vol_24h", + "turnover", + ): + qv = _safe_float(info.get(key)) + if qv is not None and qv > 0: + return qv + + for key in ("quoteVolume", "volCcy24h", "vol24h", "turnover24h", "amount24", "turnover"): + qv = _safe_float(info.get(key)) + if qv is not None and qv > 0: + if key == "volCcy24h" and ex_id == "okx": + last = _safe_float(info.get("last") or info.get("lastPx") or t.get("last")) + if last: + return qv * last + return qv + + bv = _safe_float(t.get("baseVolume")) + lp = _safe_float(t.get("last")) or _safe_float(t.get("close")) + if bv is not None and lp is not None and bv > 0 and lp > 0: + return bv * lp + + if info: + bv = _safe_float(info.get("volCcy24h") or info.get("vol24h") or info.get("volume")) + lp = _safe_float(info.get("last") or info.get("lastPx") or info.get("markPrice")) + if bv is not None and lp is not None and bv > 0 and lp > 0: + return bv * lp + + return None + + +def _is_usdt_linear_swap(market: dict | None, symbol: str) -> bool: + if not market: + su = str(symbol or "").upper() + return "USDT" in su and (":USDT" in su or "/USDT" in su or su.endswith("USDT")) + if not market.get("swap") and market.get("type") not in ("swap", "future"): + return False + if str(market.get("quote") or "").upper() != "USDT": + return False + if market.get("linear") is False: + return False + if market.get("active") is False: + return False + settle = str(market.get("settle") or "").upper() + if settle and settle != "USDT": + return False + return True + + +def _lookup_ticker(tickers: dict, sym: str, market: dict | None) -> dict | None: + if not tickers: + return None + t = tickers.get(sym) + if t: + return t + if not market: + return None + base = market.get("base") + quote = market.get("quote") or "USDT" + settle = market.get("settle") or quote + candidates = [ + sym, + f"{base}/{quote}:{settle}", + f"{base}/{quote}", + f"{base}{quote}", + market.get("id"), + ] + for key in candidates: + if not key: + continue + t = tickers.get(key) + if t: + return t + return None + + +def _merge_scores(scored: dict[str, tuple[str, float]]) -> list[tuple[str, str, float]]: + rows = [(sym, base, vol) for base, (sym, vol) in scored.items() if sym and base and vol > 0] + rows.sort(key=lambda x: x[2], reverse=True) + return rows + + +def _scores_from_okx(exchange) -> list[tuple[str, str, float]]: + by_base: dict[str, tuple[str, float]] = {} + if hasattr(exchange, "publicGetMarketTickers"): + try: + resp = exchange.publicGetMarketTickers({"instType": "SWAP"}) + for row in (resp or {}).get("data") or []: + if not isinstance(row, dict): + continue + inst = str(row.get("instId") or "").upper() + parts = inst.split("-") + if len(parts) < 3 or parts[-1] != "SWAP" or parts[1] != "USDT": + continue + base = parts[0].strip() + if not base: + continue + qv = _okx_turnover_usdt(row) + if qv is None or qv <= 0: + continue + sym = _hub_symbol_from_base(base) + prev = by_base.get(base) + if prev is None or qv > prev[1]: + by_base[base] = (sym, float(qv)) + if by_base: + return _merge_scores(by_base) + except Exception: + pass + + try: + tickers = exchange.fetch_tickers(params={"instType": "SWAP"}) + except Exception: + tickers = exchange.fetch_tickers() + return _scores_from_markets(exchange, tickers or {}, "okx") + + +def _scores_from_binance(exchange) -> list[tuple[str, str, float]]: + by_base: dict[str, tuple[str, float]] = {} + if hasattr(exchange, "fapiPublicGetTicker24hr"): + try: + rows = exchange.fapiPublicGetTicker24hr() + if isinstance(rows, list): + for row in rows: + if not isinstance(row, dict): + continue + raw = str(row.get("symbol") or "").upper() + if not raw.endswith("USDT"): + continue + base = raw[:-4] + if not base: + continue + qv = _safe_float(row.get("quoteVolume")) + if qv is None or qv <= 0: + bv = _safe_float(row.get("volume")) + lp = _safe_float(row.get("lastPrice") or row.get("weightedAvgPrice")) + if bv and lp: + qv = bv * lp + if qv is None or qv <= 0: + continue + sym = _hub_symbol_from_base(base) + prev = by_base.get(base) + if prev is None or qv > prev[1]: + by_base[base] = (sym, float(qv)) + if by_base: + return _merge_scores(by_base) + except Exception: + pass + return [] + + +def _scores_from_gate(exchange) -> list[tuple[str, str, float]]: + by_base: dict[str, tuple[str, float]] = {} + for method_name in ("publicFuturesGetSettleTickers", "publicFuturesGetUsdtTickers"): + fn = getattr(exchange, method_name, None) + if not callable(fn): + continue + try: + rows = fn({"settle": "usdt"}) + if isinstance(rows, list): + for row in rows: + if not isinstance(row, dict): + continue + contract = str(row.get("contract") or row.get("name") or "").upper() + if not contract: + continue + base = contract.replace("_USDT", "").replace("USDT", "").strip("_") + if not base: + continue + qv = _safe_float(row.get("volume_24h_quote") or row.get("volume_24h_settle")) + if qv is None or qv <= 0: + bv = _safe_float(row.get("volume_24h_base")) + lp = _safe_float(row.get("last") or row.get("mark_price")) + if bv and lp: + qv = bv * lp + if qv is None or qv <= 0: + continue + sym = _hub_symbol_from_base(base) + prev = by_base.get(base) + if prev is None or qv > prev[1]: + by_base[base] = (sym, float(qv)) + if by_base: + return _merge_scores(by_base) + except Exception: + continue + return [] + + +def _scores_from_markets( + exchange, + tickers: dict, + exchange_id: str, +) -> list[tuple[str, str, float]]: + by_base: dict[str, tuple[str, float]] = {} + markets = getattr(exchange, "markets", None) or {} + for sym, mk in markets.items(): + try: + if not _is_usdt_linear_swap(mk, sym): + continue + ticker = _lookup_ticker(tickers, sym, mk) + qv = _quote_volume_from_ticker(ticker, mk, exchange_id=exchange_id) + if qv is None or qv <= 0: + continue + hub_sym = _hub_symbol_from_market(mk, sym) + base = _ticker_base(hub_sym) + if not base: + continue + prev = by_base.get(base) + if prev is None or qv > prev[1]: + by_base[base] = (hub_sym, float(qv)) + except Exception: + continue + return _merge_scores(by_base) + + +def _collect_scores(exchange, exchange_id: str) -> list[tuple[str, str, float]]: + ex_id = str(exchange_id or "").lower() + if ex_id == "okx": + return _scores_from_okx(exchange) + if ex_id == "binance": + return _scores_from_binance(exchange) + if ex_id in ("gateio", "gate"): + return _scores_from_gate(exchange) + tickers = exchange.fetch_tickers() + return _scores_from_markets(exchange, tickers or {}, ex_id) + + +def _uses_lightweight_volume_scores(exchange_id: str) -> bool: + ex_id = str(exchange_id or "").lower() + return ex_id in ("okx", "binance", "gateio", "gate") + + +def build_usdt_swap_volume_ranks( + exchange, + ensure_markets_loaded: Callable[[], None], + *, + exchange_id: str | None = None, +) -> tuple[dict[str, int], int]: + """ + 全市场 USDT 永续 24h 成交额排名(base -> rank). + 优先各所轻量 ticker API,避免 fetch_tickers() 拉全市场(Gate/Binance 内存优化). + """ + ex_id = str(exchange_id or getattr(exchange, "id", "") or "").lower() + if not _uses_lightweight_volume_scores(ex_id): + ensure_markets_loaded() + scored = _collect_scores(exchange, ex_id) + ranks: dict[str, int] = {} + for idx, (_sym, base, _qv) in enumerate(scored, 1): + if base and base not in ranks: + ranks[base] = idx + return ranks, len(scored) + + +def resolve_daily_volume_rank( + target_base: str, + cache: dict[str, Any], + *, + now_ts: float, + ttl_sec: float, + exchange, + ensure_markets_loaded: Callable[[], None], + exchange_id: str | None = None, + cache_version: int = LIQUIDITY_RANK_CACHE_VERSION, +) -> tuple[int | None, int]: + """关键位门控:按 base 查 24h 成交额全市场排名;cache 带 TTL.""" + cached_ok = ( + cache.get("version") == cache_version + and cache.get("updated_at") + and now_ts - float(cache["updated_at"]) < ttl_sec + ) + if not cached_ok: + try: + ranks, total = build_usdt_swap_volume_ranks( + exchange, + ensure_markets_loaded, + exchange_id=exchange_id, + ) + if total > 0 and ranks: + cache["ranks"] = ranks + cache["total"] = total + cache["version"] = cache_version + cache["updated_at"] = now_ts + except Exception: + pass + ranks = cache.get("ranks") or {} + total = int(cache.get("total") or 0) + base = str(target_base or "").strip().upper() + return ranks.get(base), total + + +def fetch_usdt_swap_volume_rank( + exchange, + ensure_markets_loaded: Callable[[], None], + *, + top_n: int = TOP_N_DEFAULT, + rank_date: str | None = None, + exchange_id: str | None = None, +) -> dict[str, Any]: + """从 ccxt 拉全市场 USDT 永续 ticker,按 24h 成交额(USDT) 取 Top N.""" + top_n = max(1, min(int(top_n or TOP_N_DEFAULT), 100)) + ensure_markets_loaded() + ex_id = str(exchange_id or getattr(exchange, "id", "") or "").lower() + + try: + scored = _collect_scores(exchange, ex_id) + except Exception as e: + return {"ok": False, "msg": str(e)} + + items = [] + for idx, (hub_sym, base, qv) in enumerate(scored[:top_n], 1): + items.append( + { + "rank": idx, + "symbol": hub_sym, + "base": base, + "volume_quote": round(qv, 4), + } + ) + return { + "ok": True, + "rank_date": rank_date or rank_date_label(), + "items": items, + "total_symbols": len(scored), + "exchange_id": ex_id, + "fetched_at": datetime.now(volume_rank_timezone()).isoformat(timespec="seconds"), + } + + +def format_volume_quote(value: float | None) -> str: + n = _safe_float(value) + if n is None or n <= 0: + return "—" + if n >= 1e9: + return f"{n / 1e9:.2f}B" + if n >= 1e6: + return f"{n / 1e6:.2f}M" + if n >= 1e3: + return f"{n / 1e3:.2f}K" + return f"{n:.0f}" + + +def load_volume_rank_cache(path: Path | None = None) -> dict[str, Any]: + p = path or default_cache_path() + if not p.is_file(): + return {"version": CACHE_VERSION, "exchanges": {}} + try: + data = json.loads(p.read_text(encoding="utf-8")) + if not isinstance(data, dict): + return {"version": CACHE_VERSION, "exchanges": {}} + if int(data.get("version") or 0) < CACHE_VERSION: + return {"version": CACHE_VERSION, "exchanges": {}} + data.setdefault("version", CACHE_VERSION) + data.setdefault("exchanges", {}) + return data + except Exception: + return {"version": CACHE_VERSION, "exchanges": {}} + + +def save_volume_rank_cache(data: dict[str, Any], path: Path | None = None) -> None: + p = path or default_cache_path() + p.parent.mkdir(parents=True, exist_ok=True) + payload = dict(data) + payload["version"] = CACHE_VERSION + payload["updated_at"] = datetime.now(volume_rank_timezone()).isoformat(timespec="seconds") + p.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + +def merge_exchange_rank( + cache: dict[str, Any], + exchange_key: str, + payload: dict[str, Any], +) -> dict[str, Any]: + ex_k = str(exchange_key or "").strip().lower() + if not ex_k or not payload.get("ok"): + return cache + exchanges = dict(cache.get("exchanges") or {}) + exchanges[ex_k] = { + "rank_date": payload.get("rank_date"), + "items": payload.get("items") or [], + "total_symbols": int(payload.get("total_symbols") or 0), + "fetched_at": payload.get("fetched_at"), + "error": None, + } + out = dict(cache) + out["exchanges"] = exchanges + out["rank_date"] = payload.get("rank_date") or cache.get("rank_date") + return out + + +def _exchange_rank_row_stale(row: dict[str, Any] | None) -> bool: + if not row: + return True + items = row.get("items") or [] + if len(items) < TOP_N_DEFAULT: + return True + total = int(row.get("total_symbols") or 0) + if total > 0 and total < TOP_N_DEFAULT: + return True + return False + + +def cache_needs_refresh( + cache: dict[str, Any], + *, + expected_rank_date: str | None = None, + required_keys: list[str] | None = None, +) -> bool: + expected = expected_rank_date or rank_date_label() + if int(cache.get("version") or 0) < CACHE_VERSION: + return True + exchanges = cache.get("exchanges") or {} + if not exchanges: + return True + if str(cache.get("rank_date") or "") != expected: + return True + keys = required_keys or list(exchanges.keys()) + if not keys: + return True + for key in keys: + ex_k = str(key or "").strip().lower() + if not ex_k: + continue + if _exchange_rank_row_stale(exchanges.get(ex_k)): + return True + return False + + +def get_cached_rank( + cache: dict[str, Any], + exchange_key: str, + *, + top_n: int = TOP_N_DEFAULT, +) -> dict[str, Any]: + ex_k = str(exchange_key or "").strip().lower() + ex_data = (cache.get("exchanges") or {}).get(ex_k) or {} + items = list(ex_data.get("items") or [])[: max(1, int(top_n))] + stale = _exchange_rank_row_stale(ex_data) + return { + "ok": True, + "exchange_key": ex_k, + "rank_date": ex_data.get("rank_date") or cache.get("rank_date"), + "updated_at": cache.get("updated_at"), + "items": items, + "item_count": len(items), + "expected_count": int(top_n), + "total_symbols": int(ex_data.get("total_symbols") or 0), + "stale": stale, + "error": ex_data.get("error"), + } diff --git a/lib/hub/price_snapshot_lib.py b/lib/hub/price_snapshot_lib.py new file mode 100644 index 0000000..ccc7d50 --- /dev/null +++ b/lib/hub/price_snapshot_lib.py @@ -0,0 +1,124 @@ +"""price_snapshot 共用:订单行情价兜底,避免 get_price 失败时整单不入 order_prices.""" +from __future__ import annotations + +from typing import Any, Callable, Mapping, Optional, Sequence + +from lib.hub.hub_position_metrics import parse_position_mark_price + + +def resolve_order_snapshot_price( + symbol: str, + prices: Mapping[str, float], + *, + position_row: Optional[dict[str, Any]] = None, + order_leverage=None, + parse_position_metrics_fn: Callable[..., dict[str, Any] | None] | None = None, + get_mark_price_fn: Callable[[str], float | None] | None = None, + fallback_entry: float | None = None, +) -> float | None: + """ + 解析下单监控轮询用的现价/标记价,优先级: + 1. 已批量拉取的 ticker last + 2. get_symbol_mark_price(含 mark) + 3. 交易所持仓 mark(parse_ccxt_position_metrics / parse_position_mark_price) + 4. 计划成交价 trigger_price + """ + sym = (symbol or "").strip() + if not sym: + return None + + cached = prices.get(sym) + if cached is not None: + try: + v = float(cached) + if v > 0: + return v + except (TypeError, ValueError): + pass + + if get_mark_price_fn is not None: + try: + mp = get_mark_price_fn(sym) + if mp is not None and float(mp) > 0: + return float(mp) + except Exception: + pass + + if position_row: + mark = None + if parse_position_metrics_fn is not None: + try: + metrics = parse_position_metrics_fn( + position_row, order_leverage=order_leverage + ) + if isinstance(metrics, dict) and metrics.get("mark_price") is not None: + mark = float(metrics["mark_price"]) + except Exception: + mark = None + if mark is None or mark <= 0: + try: + mp = parse_position_mark_price(position_row) + if mp is not None and mp > 0: + mark = float(mp) + except Exception: + mark = None + if mark is not None and mark > 0: + return mark + + if fallback_entry is not None: + try: + entry = float(fallback_entry) + if entry > 0: + return entry + except (TypeError, ValueError): + pass + return None + + +def seed_prices_from_positions( + prices: dict[str, float], + order_rows: Sequence[Any], + all_positions: Sequence[dict[str, Any]], + *, + resolve_ex_sym_fn: Callable[[Any], str], +) -> None: + """用持仓标记价补全 prices 字典(symbol 与 order_monitors 行对齐).""" + if not all_positions or not order_rows: + return + try: + from lib.hub.hub_symbol_lib import symbols_match + except Exception: + symbols_match = None + for r in order_rows: + try: + sym = str(r["symbol"] or "").strip() + except (KeyError, TypeError, IndexError): + sym = "" + if not sym or sym in prices: + continue + try: + ex_sym = resolve_ex_sym_fn(r) + except Exception: + ex_sym = sym + try: + direction = str(r["direction"] or "long").lower() + except (KeyError, TypeError, IndexError): + direction = "long" + for p in all_positions: + if not isinstance(p, dict): + continue + ps = p.get("symbol") or "" + if not ps: + continue + matched = ps == sym or ps == ex_sym + if not matched and symbols_match is not None: + matched = symbols_match(sym, ps) or symbols_match(ex_sym, ps) + if not matched: + continue + side = (p.get("side") or "").lower() + if side and side != direction: + continue + mp = parse_position_mark_price(p) + if mp is not None and mp > 0: + prices[sym] = float(mp) + break diff --git a/lib/instance/__init__.py b/lib/instance/__init__.py new file mode 100644 index 0000000..ab164b5 --- /dev/null +++ b/lib/instance/__init__.py @@ -0,0 +1 @@ +"""Shared library package.""" diff --git a/lib/instance/focus_chart_lib.py b/lib/instance/focus_chart_lib.py new file mode 100644 index 0000000..0423e14 --- /dev/null +++ b/lib/instance/focus_chart_lib.py @@ -0,0 +1,187 @@ +"""实盘/关键位放大 K 线:订单元数据与交易所浮盈,价格展示精度.""" +from __future__ import annotations + +from typing import Any, Callable, Optional + +from lib.hub.hub_ohlcv_lib import ( + normalize_price_tick, + price_tick_from_market, + round_ohlcv_bars_to_tick, +) +from lib.trade.order_monitor_display_lib import ( + apply_order_live_price_display, + apply_order_price_display_fields, +) + + +def resolve_kline_price_tick( + exchange: Any, + exchange_symbol: str, + *, + ensure_markets_fn: Callable[[], None], +) -> Optional[float]: + """交易所最小价格变动单位,供 lightweight-charts 右侧刻度与标记线对齐.""" + if not exchange_symbol: + return None + try: + ensure_markets_fn() + return normalize_price_tick(price_tick_from_market(exchange, exchange_symbol)) + except Exception: + return None + + +def align_candles_to_price_tick( + candles: list[dict[str, Any]], + price_tick: Optional[float], +) -> None: + if price_tick is not None and candles: + round_ohlcv_bars_to_tick(candles, price_tick) + + +def kline_api_price_fields( + exchange: Any, + exchange_symbol: str, + candles: list[dict[str, Any]], + *, + ensure_markets_fn: Callable[[], None], +) -> dict[str, Any]: + tick = resolve_kline_price_tick( + exchange, exchange_symbol, ensure_markets_fn=ensure_markets_fn + ) + align_candles_to_price_tick(candles, tick) + return {"price_tick": tick} + + +def load_swap_positions_for_order_kline( + exchange: Any, + *, + private_configured: bool, + ensure_markets_fn: Callable[[], None], + settle: str = "usdt", +) -> list: + if not private_configured: + return [] + try: + ensure_markets_fn() + try: + return exchange.fetch_positions(None, {"settle": settle}) or [] + except Exception: + return exchange.fetch_positions() or [] + except Exception: + return [] + + +def metrics_for_order_item( + order_item: dict[str, Any], + positions: list, + *, + resolve_ex_sym_fn: Callable[[Any], str], + select_live_fn: Callable[[list, str, str], Any], + parse_metrics_fn: Callable[..., Optional[dict]], +) -> Optional[dict]: + if not positions: + return None + ex_sym = resolve_ex_sym_fn(order_item) + direction = order_item.get("direction") or "long" + prow = select_live_fn(positions, ex_sym, direction) + if not prow: + return None + lev = order_item.get("leverage") + return parse_metrics_fn(prow, order_leverage=lev) + + +def build_order_kline_order_payload( + order_item: dict[str, Any], + *, + ticker_price: Any, + format_price_fn: Callable[[Any, Any], str], + calc_pnl_fn: Callable[..., float], + calc_rr_ratio_fn: Callable[..., Optional[float]], + ex_metrics: Optional[dict] = None, +) -> dict[str, Any]: + sym = order_item.get("symbol") or "" + direction = order_item.get("direction") or "long" + 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 = 0.0 + float_pct = 0.0 + if ticker_price and entry > 0: + float_pnl = float( + calc_pnl_fn(direction, entry, ticker_price, margin, leverage) + ) + float_pct = round((float_pnl / margin * 100), 4) if margin > 0 else 0.0 + + px_for_fmt = ticker_price + mark_raw = None + if ex_metrics and ex_metrics.get("mark_price") is not None: + mark_raw = ex_metrics["mark_price"] + try: + px_for_fmt = float(mark_raw) + except (TypeError, ValueError): + pass + + if ex_metrics and ex_metrics.get("unrealized_pnl") is not None: + float_pnl = round(float(ex_metrics["unrealized_pnl"]), 2) + denom = ex_metrics.get("initial_margin") or margin + float_pct = ( + round((float_pnl / float(denom)) * 100, 4) + if denom and float(denom) > 0 + else float_pct + ) + + payload: dict[str, Any] = { + "id": order_item["id"], + "symbol": sym, + "direction": direction, + "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_fn(sym, order_item.get("trigger_price")), + "stop_loss_display": format_price_fn(sym, order_item.get("stop_loss")), + "take_profit_display": format_price_fn(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"), + "breakeven_enabled": bool(int(order_item.get("breakeven_enabled") or 0)), + "current_price": round(float(px_for_fmt), 8) if px_for_fmt is not None else None, + "float_pnl": round(float(float_pnl), 2), + "float_pct": float_pct, + } + apply_order_price_display_fields( + payload, + direction=direction, + entry_price=order_item.get("trigger_price"), + initial_stop_loss=order_item.get("initial_stop_loss"), + stop_loss=order_item.get("stop_loss"), + take_profit=order_item.get("take_profit"), + calc_rr_ratio_fn=calc_rr_ratio_fn, + ) + apply_order_live_price_display( + payload, + sym, + ticker_price, + mark_raw, + format_price_fn, + ) + payload["current_price_display"] = payload.get("price_display") or ( + format_price_fn(sym, px_for_fmt) if px_for_fmt is not None else None + ) + return payload + + +def enrich_key_kline_response( + *, + symbol: str, + current_price: Any, + key_info: Optional[dict[str, Any]], + format_price_fn: Callable[[Any, Any], str], +) -> tuple[Any, Optional[dict[str, Any]]]: + price_display = format_price_fn(symbol, current_price) if current_price is not None else None + if key_info is None: + return price_display, None + enriched = dict(key_info) + enriched["upper_display"] = format_price_fn(symbol, key_info.get("upper")) + enriched["lower_display"] = format_price_fn(symbol, key_info.get("lower")) + return price_display, enriched diff --git a/lib/instance/instance_dashboard_lib.py b/lib/instance/instance_dashboard_lib.py new file mode 100644 index 0000000..8f5b8a6 --- /dev/null +++ b/lib/instance/instance_dashboard_lib.py @@ -0,0 +1,394 @@ +"""实例数据看板:本户活跃监控 / 持仓只读聚合.""" +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Callable, Optional + + +def _row_dict(row: Any) -> dict[str, Any]: + if row is None: + return {} + if isinstance(row, dict): + return dict(row) + try: + return dict(row) + except Exception: + return {} + + +def _safe_float(v: Any) -> Optional[float]: + try: + if v is None or v == "": + return None + return float(v) + except (TypeError, ValueError): + return None + + +def _dir_label(direction: Any) -> str: + d = str(direction or "").strip().lower() + if d == "short": + return "做空" + if d == "long": + return "做多" + return str(direction or "-") + + +def _format_order_item(od: dict[str, Any]) -> dict[str, Any]: + try: + from lib.strategy.strategy_trade_labels import apply_order_monitor_source_labels + + od = apply_order_monitor_source_labels(od) + except Exception: + pass + try: + from lib.trade.entry_model_lib import enrich_entry_model_display + + enrich_entry_model_display(od) + except Exception: + pass + sym = od.get("exchange_symbol") or od.get("symbol") or "-" + direction = str(od.get("direction") or "long").lower() + mt = od.get("monitor_type_display") or od.get("monitor_type") or "" + kst = od.get("key_signal_type") or "" + title = f"{sym} {_dir_label(direction)}" + bits = [x for x in (mt, kst) if x] + subtitle = " · ".join(bits) if bits else "" + entry = _safe_float(od.get("trigger_price")) + sl = _safe_float(od.get("stop_loss")) + tp = _safe_float(od.get("take_profit")) + return { + "id": od.get("id"), + "kind": "order", + "tab": "trade", + "title": title, + "subtitle": subtitle, + "symbol": sym, + "direction": direction, + "direction_label": _dir_label(direction), + "entry": entry, + "mark_price": None, + "contracts": _safe_float(od.get("order_amount")), + "tp_profit": None, + "float_pnl": None, + "stop_loss": sl, + "take_profit": tp, + "status": od.get("status") or "active", + } + + +OPTIONS_SOURCE_LABELS = { + "option": "纯期权", + "perp_options": "永期对冲", + "options_options": "期期对冲", +} + +HEDGE_ACTIVE_STATUSES = frozenset({"opening", "active", "partial"}) + + +def _resolve_options_source(conn, inst_id: str) -> tuple[str, str]: + """根据进行中对冲计划腿判定来源;默认纯期权.""" + if not inst_id or not _table_exists(conn, "hedge_plans") or not _table_exists(conn, "hedge_plan_legs"): + return "option", OPTIONS_SOURCE_LABELS["option"] + try: + row = conn.execute( + """ + SELECT p.plan_type + FROM hedge_plans p + JOIN hedge_plan_legs l ON l.plan_id = p.id + WHERE p.status IN ('opening', 'active', 'partial') + AND l.status = 'open' + AND l.inst_id = ? + ORDER BY p.id DESC + LIMIT 1 + """, + (inst_id,), + ).fetchone() + except Exception: + return "option", OPTIONS_SOURCE_LABELS["option"] + if not row: + return "option", OPTIONS_SOURCE_LABELS["option"] + pt = str((_row_dict(row).get("plan_type") if isinstance(row, dict) else row[0]) or "").strip() + if pt in OPTIONS_SOURCE_LABELS: + return pt, OPTIONS_SOURCE_LABELS[pt] + return "option", OPTIONS_SOURCE_LABELS["option"] + + +def _format_options_target(p: dict[str, Any]) -> str: + hedge = p.get("hedge_plan_target") if isinstance(p.get("hedge_plan_target"), dict) else None + opt_type = str(p.get("opt_type") or p.get("optType") or "").upper() + if hedge: + ot = str(hedge.get("opt_type") or opt_type).upper() + side = "Put ≤" if ot == "P" else "Call ≥" + tgt = _safe_float(hedge.get("target_index")) + pid = hedge.get("plan_id") + if tgt is not None: + return f"对冲#{pid} {side} {tgt:g}" if pid is not None else f"{side} {tgt:g}" + tgt = _safe_float(p.get("target_index")) + if tgt is not None and tgt > 0: + side = "Put ≤" if opt_type == "P" else "Call ≥" + return f"{side} {tgt:g}" + return "—" + + +def _format_options_item(p: dict[str, Any], *, conn=None) -> dict[str, Any]: + inst = str(p.get("inst_id") or p.get("instId") or "-").strip() or "-" + opt_type = str(p.get("opt_type") or p.get("optType") or "").upper() + label = "Call" if opt_type == "C" else "Put" if opt_type == "P" else (opt_type or "OPT") + upl = _safe_float(p.get("upl")) + net = None + try: + from lib.options.options_positions_lib import net_pnl_from_display_row + + net = net_pnl_from_display_row(p) + except Exception: + net = None + pnl = net if net is not None else upl + pos = _safe_float(p.get("pos")) + exp_ms = p.get("exp_time_ms") + if exp_ms is None: + exp_ms = p.get("exp_time") + try: + exp_ms = int(float(exp_ms)) if exp_ms not in (None, "") else None + except (TypeError, ValueError): + exp_ms = None + source_key, source_label = ( + _resolve_options_source(conn, inst) if conn is not None else ("option", OPTIONS_SOURCE_LABELS["option"]) + ) + return { + "id": inst, + "kind": "options", + "tab": "options", + "title": f"{inst} {label}", + "subtitle": f"张数 {pos if pos is not None else '-'}", + "inst_id": inst, + "opt_type": opt_type, + "opt_type_label": label, + "source": source_key, + "source_label": source_label, + "pos": pos, + "exp_time_ms": exp_ms, + "target_monitor": _format_options_target(p), + "pnl": round(pnl, 4) if pnl is not None else None, + } + + +def _format_hedge_item(plan: dict[str, Any]) -> dict[str, Any]: + pid = plan.get("id") + underlying = plan.get("underlying") or "-" + plan_type = plan.get("plan_type") or "" + status = str(plan.get("status") or "") + summary = plan.get("contracts_summary") or "" + plan_type_label = OPTIONS_SOURCE_LABELS.get(plan_type, plan_type) + active = status in HEDGE_ACTIVE_STATUSES + status_label = "进行中" if active else (status or "—") + return { + "id": pid, + "kind": "hedge_plan", + "tab": "hedge_plan", + "title": f"对冲 #{pid} {underlying}", + "subtitle": " · ".join(x for x in (plan_type_label, status_label, summary) if x), + "underlying": underlying, + "plan_type": plan_type, + "plan_type_label": plan_type_label, + "status": status, + "status_label": status_label, + "status_active": active, + "contracts_summary": summary, + } + + +def _format_key_item(kd: dict[str, Any]) -> dict[str, Any]: + sym = kd.get("exchange_symbol") or kd.get("symbol") or "-" + direction = str(kd.get("direction") or "long").lower() + signal = kd.get("signal_type") or kd.get("key_signal_type") or kd.get("monitor_type") or "" + upper = _safe_float(kd.get("upper")) + lower = _safe_float(kd.get("lower")) + subtitle_parts = [] + if signal: + subtitle_parts.append(str(signal)) + if upper is not None or lower is not None: + subtitle_parts.append( + f"上{upper if upper is not None else '-'} / 下{lower if lower is not None else '-'}" + ) + return { + "id": kd.get("id"), + "kind": "key", + "tab": "key_monitor", + "title": f"{sym} {_dir_label(direction)}", + "subtitle": " · ".join(subtitle_parts), + "symbol": sym, + "direction": direction, + "direction_label": _dir_label(direction), + "upper": upper, + "lower": lower, + "status": kd.get("status") or "active", + } + + +def _format_trend_item(td: dict[str, Any]) -> dict[str, Any]: + sym = td.get("exchange_symbol") or td.get("symbol") or "-" + direction = str(td.get("direction") or "long").lower() + status = td.get("status") or "active" + entry = _safe_float(td.get("entry_price") or td.get("trigger_price")) + return { + "id": td.get("id"), + "kind": "trend", + "tab": "strategy", + "title": f"趋势回调 {sym} {_dir_label(direction)}", + "subtitle": f"状态 {status}", + "symbol": sym, + "direction": direction, + "direction_label": _dir_label(direction), + "entry": entry, + "status": status, + } + + +def _format_roll_item(rd: dict[str, Any]) -> dict[str, Any]: + sym = rd.get("exchange_symbol") or rd.get("symbol") or "-" + direction = str(rd.get("direction") or "long").lower() + status = rd.get("status") or "active" + return { + "id": rd.get("id"), + "kind": "roll", + "tab": "strategy", + "title": f"顺势加仓 {sym} {_dir_label(direction)}", + "subtitle": f"状态 {status}", + "symbol": sym, + "direction": direction, + "direction_label": _dir_label(direction), + "status": status, + } + + +def _table_exists(conn, name: str) -> bool: + try: + row = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1", + (name,), + ).fetchone() + return bool(row) + except Exception: + return False + + +def collect_orders(conn) -> list[dict[str, Any]]: + if not _table_exists(conn, "order_monitors"): + return [] + rows = conn.execute( + "SELECT * FROM order_monitors WHERE status='active' ORDER BY id DESC" + ).fetchall() + return [_format_order_item(_row_dict(r)) for r in rows] + + +def collect_keys(conn) -> list[dict[str, Any]]: + if not _table_exists(conn, "key_monitors"): + return [] + rows = conn.execute("SELECT * FROM key_monitors ORDER BY id DESC").fetchall() + return [_format_key_item(_row_dict(r)) for r in rows] + + +def collect_trends(conn) -> list[dict[str, Any]]: + if not _table_exists(conn, "trend_pullback_plans"): + return [] + try: + rows = conn.execute( + "SELECT * FROM trend_pullback_plans WHERE status='active' ORDER BY id DESC" + ).fetchall() + except Exception: + return [] + return [_format_trend_item(_row_dict(r)) for r in rows] + + +def collect_rolls(conn) -> list[dict[str, Any]]: + if not _table_exists(conn, "roll_groups") or not _table_exists(conn, "order_monitors"): + return [] + try: + rows = conn.execute( + """SELECT g.* FROM roll_groups g + INNER JOIN order_monitors m ON m.id = g.order_monitor_id AND m.status='active' + WHERE g.status='active' ORDER BY g.id DESC""" + ).fetchall() + except Exception: + return [] + return [_format_roll_item(_row_dict(r)) for r in rows] + + +def collect_hedge_plans(conn) -> list[dict[str, Any]]: + if not _table_exists(conn, "hedge_plans"): + return [] + try: + from lib.hedge_plan.hedge_plan_db import attach_legs_to_plans, list_plans + + rows: list[dict[str, Any]] = [] + for status in ("opening", "active", "partial"): + rows.extend(list_plans(conn, status=status, limit=80)) + rows.sort(key=lambda row: int(row.get("id") or 0), reverse=True) + plans = attach_legs_to_plans(conn, rows) + return [_format_hedge_item(p) for p in plans] + except Exception: + return [] + + +def collect_options_items( + fetch_options_positions: Optional[Callable[[], list[dict[str, Any]]]] = None, + *, + conn=None, +) -> list[dict[str, Any]]: + if not callable(fetch_options_positions): + return [] + try: + raw = fetch_options_positions() or [] + except Exception: + return [] + out: list[dict[str, Any]] = [] + for p in raw: + if not isinstance(p, dict): + continue + out.append(_format_options_item(p, conn=conn)) + return out + + +def build_instance_dashboard_payload( + conn, + *, + fetch_options_positions: Optional[Callable[[], list[dict[str, Any]]]] = None, + hedge_enabled: bool = False, +) -> dict[str, Any]: + orders = collect_orders(conn) + keys = collect_keys(conn) + trends = collect_trends(conn) + rolls = collect_rolls(conn) + strategy_items = trends + rolls + options_items = collect_options_items(fetch_options_positions, conn=conn) + hedge_items = collect_hedge_plans(conn) if hedge_enabled else [] + now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S") + return { + "ok": True, + "updated_at": now, + "orders": {"title": "实盘下单", "count": len(orders), "items": orders, "tab": "trade"}, + "keys": {"title": "关键位监控", "count": len(keys), "items": keys, "tab": "key_monitor"}, + "strategy": { + "title": "策略交易", + "count": len(strategy_items), + "items": strategy_items, + "trends": trends, + "rolls": rolls, + "tab": "strategy", + }, + "options": { + "title": "期权持仓", + "count": len(options_items), + "items": options_items, + "visible": len(options_items) > 0, + "tab": "options", + }, + "hedge_plan": { + "title": "对冲计划", + "count": len(hedge_items), + "items": hedge_items, + "visible": len(hedge_items) > 0, + "tab": "hedge_plan", + }, + } diff --git a/lib/instance/instance_dashboard_register.py b/lib/instance/instance_dashboard_register.py new file mode 100644 index 0000000..d38142a --- /dev/null +++ b/lib/instance/instance_dashboard_register.py @@ -0,0 +1,31 @@ +"""注册 GET /api/instance/dashboard(三所共用).""" +from __future__ import annotations + +from typing import Any, Callable, Optional + +from flask import Flask, jsonify + + +def register_instance_dashboard_routes( + app: Flask, + *, + login_required: Callable, + get_db: Callable, + fetch_options_positions: Optional[Callable[[], list[dict[str, Any]]]] = None, + hedge_enabled: bool = False, +) -> None: + from lib.instance.instance_dashboard_lib import build_instance_dashboard_payload + + @app.route("/api/instance/dashboard") + @login_required + def api_instance_dashboard(): + conn = get_db() + try: + payload = build_instance_dashboard_payload( + conn, + fetch_options_positions=fetch_options_positions, + hedge_enabled=bool(hedge_enabled), + ) + return jsonify(payload) + finally: + conn.close() diff --git a/lib/instance/instance_display_prefs_lib.py b/lib/instance/instance_display_prefs_lib.py new file mode 100644 index 0000000..f75b985 --- /dev/null +++ b/lib/instance/instance_display_prefs_lib.py @@ -0,0 +1,130 @@ +"""实例顶栏 / 系统设置区块显示开关(存 SQLite,即时生效).""" +from __future__ import annotations + +from typing import Any, Callable, Optional + +from lib.instance.runtime_settings_lib import runtime_get_prefix, runtime_set_many, with_db + +DISPLAY_RUNTIME_PREFIX = "display." + +DEFAULT_INSTANCE_DISPLAY: dict[str, bool] = { + "show_nav_dashboard": False, + "show_nav_strategy": True, + "show_nav_strategy_records": True, + "show_nav_records": True, + "show_nav_stats": True, + "show_nav_risk_policy": True, + "show_nav_env_config": True, + "show_nav_options": True, + "show_nav_options_review": True, + "show_nav_hedge_plan": True, + "show_settings_transfer": True, + "show_settings_export": True, + "show_settings_password": True, + "show_settings_options_swap": True, + "show_settings_options_transfer": True, +} + +DISPLAY_LABELS: dict[str, str] = { + "show_nav_dashboard": "数据看板", + "show_nav_strategy": "策略交易", + "show_nav_strategy_records": "策略交易记录", + "show_nav_records": "交易记录与复盘", + "show_nav_stats": "统计分析", + "show_nav_risk_policy": "风控说明", + "show_nav_env_config": "env配置", + "show_nav_options": "期权", + "show_nav_options_review": "期权复盘", + "show_nav_hedge_plan": "对冲计划", + "show_settings_transfer": "资金划转", + "show_settings_export": "数据导出", + "show_settings_password": "账户密码修改", + "show_settings_options_swap": "期权币种兑换", + "show_settings_options_transfer": "期权资金划转", +} + +NAV_TAB_ALLOWED: dict[str, str] = { + "dashboard": "show_nav_dashboard", + "strategy": "show_nav_strategy", + "strategy_records": "show_nav_strategy_records", + "records": "show_nav_records", + "stats": "show_nav_stats", + "risk_policy": "show_nav_risk_policy", + "env_config": "show_nav_env_config", + "options": "show_nav_options", + "options_review": "show_nav_options_review", + "hedge_plan": "show_nav_hedge_plan", +} + + +def normalize_display_prefs(raw: dict | None) -> dict[str, bool]: + out = dict(DEFAULT_INSTANCE_DISPLAY) + if isinstance(raw, dict): + for key in DEFAULT_INSTANCE_DISPLAY: + if key in raw: + out[key] = bool(raw[key]) + return out + + +def _load_from_conn(conn) -> dict[str, bool]: + stored = runtime_get_prefix(conn, DISPLAY_RUNTIME_PREFIX) + merged: dict[str, Any] = {} + for key in DEFAULT_INSTANCE_DISPLAY: + sk = key + if sk in stored: + merged[key] = stored[sk].strip().lower() in ("1", "true", "yes", "on") + return normalize_display_prefs(merged) + + +def get_display_prefs(get_db: Callable) -> dict[str, bool]: + return with_db(get_db, _load_from_conn) + + +def save_display_prefs(get_db: Callable, prefs: dict) -> dict[str, bool]: + normalized = normalize_display_prefs(prefs) + + def _save(conn): + mapping = {DISPLAY_RUNTIME_PREFIX + k: ("1" if v else "0") for k, v in normalized.items()} + runtime_set_many(conn, mapping) + return normalized + + return with_db(get_db, _save) + + +def display_prefs_template_context(get_db: Callable) -> dict[str, Any]: + prefs = get_display_prefs(get_db) + return {"display": prefs, "display_meta": display_meta_for_ui()} + + +def tab_allowed(tab: str, display: Optional[dict[str, bool]] = None) -> bool: + prefs = normalize_display_prefs(display or {}) + key = NAV_TAB_ALLOWED.get((tab or "").strip()) + if not key: + return True + return bool(prefs.get(key, True)) + + +def display_meta_for_ui() -> list[dict[str, Any]]: + nav_keys = [ + "show_nav_dashboard", + "show_nav_strategy", + "show_nav_strategy_records", + "show_nav_records", + "show_nav_stats", + "show_nav_risk_policy", + "show_nav_env_config", + "show_nav_options", + "show_nav_options_review", + "show_nav_hedge_plan", + ] + settings_keys = [ + "show_settings_transfer", + "show_settings_export", + "show_settings_password", + "show_settings_options_swap", + "show_settings_options_transfer", + ] + return [ + {"group": "顶栏导航", "entries": [{"key": k, "label": DISPLAY_LABELS[k]} for k in nav_keys]}, + {"group": "系统设置区块", "entries": [{"key": k, "label": DISPLAY_LABELS[k]} for k in settings_keys]}, + ] diff --git a/lib/instance/instance_embed_context_lib.py b/lib/instance/instance_embed_context_lib.py new file mode 100644 index 0000000..22599e5 --- /dev/null +++ b/lib/instance/instance_embed_context_lib.py @@ -0,0 +1,177 @@ +"""embed 壳/片段:按 tab 裁剪 render_main_page 的数据加载,降内存与 API 压力.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +EMBED_STRATEGY_PAGES = frozenset({"strategy", "strategy_trend", "strategy_roll", "strategy_records"}) + +_WIN_EPS = 1e-9 + + +@dataclass(frozen=True) +class EmbedRenderPlan: + exchange_capitals: bool + records_rows: bool + records_summary: bool + key_history: bool + key_list: bool + orders: bool + stats_bundle: bool + strategy: bool + orphan_live: bool + + +def embed_render_plan(page: str, embed_mode: str | None) -> EmbedRenderPlan: + if embed_mode not in ("fragment", "shell"): + return EmbedRenderPlan( + exchange_capitals=True, + records_rows=True, + records_summary=False, + key_history=True, + key_list=True, + orders=True, + stats_bundle=True, + strategy=True, + orphan_live=True, + ) + is_shell = embed_mode == "shell" + is_strategy = page in EMBED_STRATEGY_PAGES + return EmbedRenderPlan( + exchange_capitals=is_shell, + records_rows=page == "records", + # 顶栏常驻:设置/风控/env 也要统计,否则首屏 SSR 为 0 后软切 tab 不会重绘顶栏 + records_summary=is_shell and page != "records", + key_history=page == "key_monitor", + key_list=page in ("key_monitor", "trade") or is_strategy, + orders=page == "trade" or is_strategy, + stats_bundle=page == "stats", + strategy=is_strategy, + orphan_live=page == "trade" and is_shell, + ) + + +def profit_loss_ratio_from_averages(avg_win: float | None, avg_loss: float | None) -> float | None: + """盈亏比 = 平均盈利 / |平均亏损|.""" + if avg_win is None or avg_loss is None: + return None + try: + aw = float(avg_win) + al = float(avg_loss) + except (TypeError, ValueError): + return None + if al == 0: + return None + return round(aw / abs(al), 2) + + +def profit_loss_ratio_from_trades(trades: list[dict[str, Any]] | None) -> float | None: + wins: list[float] = [] + losses: list[float] = [] + for row in trades or []: + if not isinstance(row, dict): + continue + try: + pnl = float(row.get("effective_pnl_amount") or row.get("pnl_amount") or 0) + except (TypeError, ValueError): + continue + if pnl > _WIN_EPS: + wins.append(pnl) + elif pnl < -_WIN_EPS: + losses.append(pnl) + avg_win = sum(wins) / len(wins) if wins else None + avg_loss = sum(losses) / len(losses) if losses else None + return profit_loss_ratio_from_averages(avg_win, avg_loss) + + +def options_funding_label( + funding_usdc: float | None, + funding_usdt: float | None = None, +) -> str: + parts: list[str] = [] + if funding_usdc is not None and float(funding_usdc) > 0: + parts.append(f"{float(funding_usdc):.2f} USDC") + if funding_usdt is not None and float(funding_usdt) > 0: + parts.append(f"{float(funding_usdt):.2f} USDT") + return " · ".join(parts) if parts else "—" + + +def total_funds_usdt( + funding_usdt: float | None, + trading_usdt: float | None, + options_trading_usdc: float | None = None, + options_funding_usdc: float | None = None, + options_funding_usdt: float | None = None, + options_trading_usdt: float | None = None, +) -> float | None: + parts = [ + funding_usdt, + trading_usdt, + options_funding_usdc, + options_funding_usdt, + options_trading_usdc, + options_trading_usdt, + ] + if all(v is None for v in parts): + return None + try: + total = 0.0 + for v in parts: + if v is not None: + total += float(v) + return round(total, 2) + except (TypeError, ValueError): + return None + + +def trade_records_summary(conn, start_bj: str, end_bj: str, tr_ts: str) -> dict[str, Any]: + """顶栏统计用 COUNT,避免 embed 壳拉 1000 行交易记录.""" + from lib.trade.trade_result_lib import sql_effective_pnl_expr + + pnl_sql = sql_effective_pnl_expr() + row = conn.execute( + f""" + SELECT + COUNT(*) AS total, + SUM(CASE WHEN {pnl_sql} > 0 THEN 1 ELSE 0 END) AS wins, + AVG(CASE WHEN {pnl_sql} > 0 THEN {pnl_sql} END) AS avg_win, + AVG(CASE WHEN {pnl_sql} < 0 THEN {pnl_sql} END) AS avg_loss + FROM trade_records + WHERE {tr_ts} >= ? AND {tr_ts} <= ? + AND COALESCE(result, '') != '错过' + AND COALESCE(reviewed_result, '') != '错过' + """, + (start_bj, end_bj), + ).fetchone() + total = int(row["total"] or 0) if row else 0 + wins = int(row["wins"] or 0) if row else 0 + rate = round(wins / total * 100, 2) if total else 0 + avg_win = float(row["avg_win"]) if row and row["avg_win"] is not None else None + avg_loss = float(row["avg_loss"]) if row and row["avg_loss"] is not None else None + return { + "records": [], + "total": total, + "rate": rate, + "profit_loss_ratio": profit_loss_ratio_from_averages(avg_win, avg_loss), + } + + +def header_trade_stats_for_window(conn, list_window: dict[str, Any], app_tz) -> dict[str, Any]: + """account_snapshot / 顶栏刷新:按当前列表窗返回总交易/胜率/盈亏比.""" + from lib.common.history_window_lib import sql_list_time_field, utc_window_to_bj_sql_strings + + start_bj, end_bj = utc_window_to_bj_sql_strings( + list_window["start_utc"], list_window["end_utc"], app_tz + ) + tr_ts = sql_list_time_field("closed_at", "created_at", "opened_at") + summary = trade_records_summary(conn, start_bj, end_bj, tr_ts) + return { + "total": summary["total"], + "rate": summary["rate"], + "profit_loss_ratio": summary.get("profit_loss_ratio"), + } + + +def minimal_stats_bundle(reset_hour: int) -> dict[str, Any]: + return {"stats_reset_hour": reset_hour, "segments": []} diff --git a/lib/instance/instance_embed_lib.py b/lib/instance/instance_embed_lib.py new file mode 100644 index 0000000..0753ee7 --- /dev/null +++ b/lib/instance/instance_embed_lib.py @@ -0,0 +1,203 @@ +"""中控 iframe:壳常驻 + tab 内容 API(/embed,/api/embed/page/).""" +from __future__ import annotations + +from lib.paths import embed_templates_dir + +import os +from typing import Callable +from urllib.parse import parse_qsl, urlencode, urlsplit + +from flask import Flask, Response, jsonify, redirect, request, session +from jinja2 import ChoiceLoader, FileSystemLoader + +EMBED_TABS: tuple[str, ...] = ( + "dashboard", + "key_monitor", + "trade", + "strategy", + "strategy_records", + "options", + "options_review", + "hedge_plan", + "records", + "stats", + "risk_policy", + "env_config", + "settings", +) + +PATH_TO_EMBED_TAB: dict[str, str] = { + "/": "trade", + "/trade": "trade", + "/dashboard": "dashboard", + "/key_monitor": "key_monitor", + "/strategy": "strategy", + "/strategy/trend": "strategy", + "/strategy/roll": "strategy", + "/strategy/records": "strategy_records", + "/options": "options", + "/options/review": "options_review", + "/hedge-plan": "hedge_plan", + "/records": "records", + "/stats": "stats", + "/risk_policy": "risk_policy", + "/env_config": "env_config", + "/settings": "settings", +} + +ORDER_RULE_TIPS_BY_EXCHANGE: dict[str, str] = { + "gate": "order_monitor_rule_tips_gate.html", + "binance": "order_monitor_rule_tips_binance.html", + "okx": "order_monitor_rule_tips_okx.html", +} + + +def order_rule_tips_template(exchange_key: str) -> str: + ex = (exchange_key or "").strip().lower() + return ORDER_RULE_TIPS_BY_EXCHANGE.get(ex, "order_monitor_rule_tips_gate.html") + + +def include_transfer_block(exchange_key: str) -> bool: + """三所 standalone / embed 壳均在顶栏展示划转区块.""" + return (exchange_key or "").strip().lower() in ORDER_RULE_TIPS_BY_EXCHANGE + + +def ui_open_guard_enabled(exchange_key: str) -> bool: + return (exchange_key or "").strip().lower() == "okx" + + +def ui_orphan_recovery_enabled(exchange_key: str) -> bool: + return (exchange_key or "").strip().lower() == "binance" + + +def path_to_embed_tab(path: str) -> str | None: + p = (path or "/").strip() + if not p.startswith("/"): + p = "/" + p + base = urlsplit(p).path.rstrip("/") or "/" + return PATH_TO_EMBED_TAB.get(base) + + +def embed_shell_enabled() -> bool: + return (os.getenv("HUB_EMBED_SHELL") or "1").strip().lower() in ("1", "true", "yes", "on") + + +def redirect_to_embed_shell_if_enabled(page: str): + """直连 /trade 等整页路由时,重定向到 embed 壳(顶栏常驻,tab 软切换).""" + if not embed_shell_enabled(): + return None + if (request.args.get("embed") or "").strip() == "1": + return None + if (request.path or "").rstrip("/") == "/embed": + return None + q = {k: v for k, v in request.args.items()} + q["tab"] = page + q["embed"] = "1" + return redirect("/embed?" + urlencode(q)) + + +def rewrite_embed_dest(path: str, hub_theme: str | None = None) -> str: + """embed=1 打开时:/trade → /embed?tab=trade&embed=1""" + if not embed_shell_enabled(): + split = urlsplit(path or "/") + q = dict(parse_qsl(split.query, keep_blank_values=True)) + q["embed"] = "1" + ht = (hub_theme or q.get("hub_theme") or "").strip().lower() + if ht in ("light", "dark"): + q["hub_theme"] = ht + dest = split.path or "/" + if q: + return f"{dest}?{urlencode(q)}" + return dest + "?embed=1" + split = urlsplit(path or "/") + tab = path_to_embed_tab(split.path) + q = dict(parse_qsl(split.query, keep_blank_values=True)) + if tab: + q["tab"] = tab + q["embed"] = "1" + ht = (hub_theme or q.get("hub_theme") or "").strip().lower() + if ht in ("light", "dark"): + q["hub_theme"] = ht + return f"/embed?{urlencode(q)}" + q["embed"] = "1" + ht = (hub_theme or q.get("hub_theme") or "").strip().lower() + if ht in ("light", "dark"): + q["hub_theme"] = ht + dest = split.path or "/" + if split.query: + dest += "?" + split.query + if "embed=1" not in dest: + sep = "&" if "?" in dest else "?" + dest += f"{sep}embed=1" + if ht in ("light", "dark") and "hub_theme=" not in dest: + sep = "&" if "?" in dest else "?" + dest += f"{sep}hub_theme={ht}" + return dest + + +def attach_embed_templates(app: Flask, repo_root: str) -> None: + embed_dir = embed_templates_dir(repo_root) + if not os.path.isdir(embed_dir): + return + existing = app.jinja_loader + loaders = [FileSystemLoader(embed_dir)] + if existing is not None: + if isinstance(existing, ChoiceLoader): + loaders = list(existing.loaders) + loaders + else: + loaders.insert(0, existing) + app.jinja_loader = ChoiceLoader(loaders) + + +def register_embed_routes( + app: Flask, + login_required: Callable, + render_main_page_fn: Callable, +) -> None: + from lib.instance.instance_live_push_lib import register_instance_live_routes + + app.config["RENDER_MAIN_PAGE_FN"] = render_main_page_fn + register_instance_live_routes(app, login_required) + + @login_required + @app.route("/embed") + def embed_shell_page(): + tab = (request.args.get("tab") or "trade").strip() + if tab not in EMBED_TABS: + tab = "trade" + session["hub_embed_shell"] = True + return render_main_page_fn(tab, embed_mode="shell") + + @login_required + @app.route("/api/embed/page/") + def api_embed_page(tab: str): + tab = (tab or "").strip() + if tab not in EMBED_TABS: + return jsonify({"ok": False, "msg": "unknown tab"}), 404 + allowed_fn = app.config.get("INSTANCE_TAB_ALLOWED_FN") + if callable(allowed_fn) and not allowed_fn(tab): + return jsonify({"ok": False, "msg": "tab disabled"}), 403 + html = render_main_page_fn(tab, embed_mode="fragment") + if isinstance(html, Response): + html = html.get_data(as_text=True) + return jsonify({"ok": True, "page": tab, "html": html}) + + +def pwa_app_name(exchange_key: str) -> str: + """安装 App / 主屏幕显示名(各所独立标识).""" + ex = (exchange_key or "").strip().lower() + return { + "binance": "Binance 交易系统", + "okx": "OKX 交易系统", + "gate": "Gate 交易系统", + }.get(ex, "交易系统") + + +def embed_context_extras(exchange_key: str) -> dict: + return { + "order_rule_tips_tpl": order_rule_tips_template(exchange_key), + "include_transfer_block": include_transfer_block(exchange_key), + "ui_open_guard_enabled": ui_open_guard_enabled(exchange_key), + "ui_orphan_recovery_enabled": ui_orphan_recovery_enabled(exchange_key), + "pwa_app_name": pwa_app_name(exchange_key), + } diff --git a/lib/instance/instance_live_pnl_lib.py b/lib/instance/instance_live_pnl_lib.py new file mode 100644 index 0000000..17fa9c4 --- /dev/null +++ b/lib/instance/instance_live_pnl_lib.py @@ -0,0 +1,127 @@ +"""实例页:持仓未实现盈亏(实时盈亏)汇总.""" +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from lib.hub.hub_position_metrics import parse_position_unrealized_pnl + + +def position_row_contracts(pos: dict[str, Any]) -> float: + """持仓张数:与三所 app 内 _position_row_effective_contracts 规则一致.""" + if not isinstance(pos, dict): + return 0.0 + info = pos.get("info") or {} + if not isinstance(info, dict): + info = {} + for val in ( + pos.get("contracts"), + info.get("positionAmt"), + info.get("size"), + info.get("pos"), + info.get("availPos"), + ): + 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 sum_unrealized_pnl_from_positions(positions: list[dict[str, Any]] | None) -> float | None: + total = 0.0 + found = False + for p in positions or []: + if not isinstance(p, dict): + continue + if position_row_contracts(p) <= 1e-12: + continue + upnl = parse_position_unrealized_pnl(p) + if upnl is None: + continue + found = True + total += float(upnl) + return round(total, 2) if found else None + + +def _row_field(row: Any, key: str, default: str = "") -> str: + if row is None: + return default + try: + if hasattr(row, "keys") and key in row.keys(): + val = row[key] + elif isinstance(row, dict): + val = row.get(key) + else: + val = None + except Exception: + val = None + return str(val or default).strip() + + +def sum_unrealized_pnl_from_metrics( + rows: list[dict[str, Any]] | list[Any], + get_metrics_fn: Callable[[str, str], dict[str, Any] | None], +) -> float | None: + """按活跃监控单逐笔拉交易所 metrics 汇总(与持仓卡浮盈亏一致).""" + total = 0.0 + found = False + for row in rows or []: + ex_sym = _row_field(row, "exchange_symbol") + sym = _row_field(row, "symbol") + direction = _row_field(row, "direction", "long").lower() or "long" + target = ex_sym or sym + if not target: + continue + metrics = get_metrics_fn(target, direction) + if not isinstance(metrics, dict): + continue + upnl = metrics.get("unrealized_pnl") + if upnl is None: + continue + try: + total += float(upnl) + found = True + except (TypeError, ValueError): + continue + return round(total, 2) if found else None + + +def fetch_unrealized_pnl(fetch_positions_fn: Callable[[], list[dict[str, Any]] | None]) -> float | None: + try: + return sum_unrealized_pnl_from_positions(fetch_positions_fn() or []) + except Exception: + return None + + +def resolve_instance_unrealized_pnl( + fetch_positions_fn: Callable[[], list[dict[str, Any]] | None], + active_rows: list[Any] | None, + get_metrics_fn: Callable[[str, str], dict[str, Any] | None] | None, +) -> float | None: + """先全量持仓汇总,失败或无数据时回退到活跃监控单 metrics.""" + total = fetch_unrealized_pnl(fetch_positions_fn) + if total is not None: + return total + if active_rows and get_metrics_fn: + return sum_unrealized_pnl_from_metrics(active_rows, get_metrics_fn) + return None + + +def merge_unrealized_pnl_components(*parts: float | None) -> float | None: + """合并永续与期权等多路未实现盈亏(任一路有值即参与合计).""" + total = 0.0 + found = False + for part in parts: + if part is None: + continue + try: + total += float(part) + found = True + except (TypeError, ValueError): + continue + return round(total, 2) if found else None diff --git a/lib/instance/instance_live_push_lib.py b/lib/instance/instance_live_push_lib.py new file mode 100644 index 0000000..ca93a67 --- /dev/null +++ b/lib/instance/instance_live_push_lib.py @@ -0,0 +1,122 @@ +"""实例 embed 壳:后台定时 tick + SSE 通知前端拉 JSON 快照(对齐中控 dashboard).""" +from __future__ import annotations + +import json +import os +import queue +import threading +from collections.abc import Iterator +from typing import Any, Callable + +from flask import Flask, Response, stream_with_context + +INSTANCE_LIVE_TICK_SEC = float(os.getenv("INSTANCE_LIVE_TICK_SEC", "5")) +INSTANCE_SSE_HEARTBEAT_SEC = float(os.getenv("INSTANCE_SSE_HEARTBEAT_SEC", "25")) + + +class InstanceLivePush: + def __init__(self) -> None: + self._lock = threading.Lock() + self.version = 0 + self._subscribers: list[queue.Queue[str | None]] = [] + self._stop = threading.Event() + self._thread: threading.Thread | None = None + + def start(self) -> None: + if self._thread and self._thread.is_alive(): + return + self._stop.clear() + self._thread = threading.Thread(target=self._loop, daemon=True, name="instance-live-push") + self._thread.start() + + def stop(self) -> None: + self._stop.set() + self._broadcast(close=True) + + def tick(self, reason: str = "poll") -> int: + with self._lock: + self.version += 1 + ver = self.version + payload = json.dumps({"live_version": ver, "reason": reason}, ensure_ascii=False) + self._broadcast(payload) + return ver + + def event_dict(self) -> dict[str, Any]: + return {"live_version": self.version, "tick_sec": INSTANCE_LIVE_TICK_SEC} + + def _loop(self) -> None: + while not self._stop.is_set(): + self.tick("poll") + if self._stop.wait(INSTANCE_LIVE_TICK_SEC): + break + + def _broadcast(self, event: str | None = None, *, close: bool = False) -> None: + with self._lock: + subs = list(self._subscribers) + dead: list[queue.Queue[str | None]] = [] + for q in subs: + try: + q.put_nowait(None if close else event) + except Exception: + dead.append(q) + if dead: + with self._lock: + for q in dead: + if q in self._subscribers: + self._subscribers.remove(q) + + def _subscribe(self) -> queue.Queue[str | None]: + q: queue.Queue[str | None] = queue.Queue(maxsize=16) + with self._lock: + self._subscribers.append(q) + return q + + def _unsubscribe(self, q: queue.Queue[str | None]) -> None: + with self._lock: + if q in self._subscribers: + self._subscribers.remove(q) + + def iter_sse(self) -> Iterator[str]: + q = self._subscribe() + try: + yield self._format_event(self.event_dict() | {"reason": "connect"}) + while True: + try: + raw = q.get(timeout=INSTANCE_SSE_HEARTBEAT_SEC) + except queue.Empty: + yield ": heartbeat\n\n" + continue + if raw is None: + break + yield f"event: live\ndata: {raw}\n\n" + finally: + self._unsubscribe(q) + + @staticmethod + def _format_event(data: dict[str, Any]) -> str: + return "event: live\ndata: " + json.dumps(data, ensure_ascii=False) + "\n\n" + + +instance_live_push = InstanceLivePush() + + +def notify_instance_balance_changed() -> int: + """划转/兑换后通知 embed 壳拉最新资金快照.""" + return instance_live_push.tick("balance") + + +def register_instance_live_routes(app: Flask, login_required: Callable) -> None: + instance_live_push.start() + + @login_required + @app.route("/api/instance/live/stream") + def api_instance_live_stream(): + return Response( + stream_with_context(instance_live_push.iter_sse()), + mimetype="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) diff --git a/lib/instance/instance_nav_lib.py b/lib/instance/instance_nav_lib.py new file mode 100644 index 0000000..844cf98 --- /dev/null +++ b/lib/instance/instance_nav_lib.py @@ -0,0 +1,19 @@ +"""中控 iframe 内软导航:服务端跳过重型同步,避免切 tab 等待数秒.""" + +from __future__ import annotations + +from flask import Request + + +def request_is_hub_soft_nav(req: Request | None = None) -> bool: + """embed=1 且带 X-Instance-Soft-Nav 头:实例页内 fetch 换页,非整页刷新.""" + try: + from flask import request as flask_request + + r = req or flask_request + if str(r.args.get("embed") or "").strip() != "1": + return False + flag = (r.headers.get("X-Instance-Soft-Nav") or "").strip().lower() + return flag in ("1", "true", "yes") + except Exception: + return False diff --git a/lib/instance/instance_pm2_lib.py b/lib/instance/instance_pm2_lib.py new file mode 100644 index 0000000..215eec2 --- /dev/null +++ b/lib/instance/instance_pm2_lib.py @@ -0,0 +1,74 @@ +"""PM2 重启当前实例(仅 Linux 部署环境).""" +from __future__ import annotations + +import os +import shlex +import subprocess +import sys +from typing import Any + + +def default_pm2_app_name(exchange_key: str) -> str: + mapping = { + "okx": "crypto_okx", + "binance": "crypto_binance", + "gate": "crypto_gate", + } + return mapping.get((exchange_key or "").strip().lower(), "crypto_okx") + + +def resolve_pm2_app_name(exchange_key: str) -> str: + explicit = (os.getenv("PM2_APP_NAME") or "").strip() + if explicit: + return explicit + return default_pm2_app_name(exchange_key) + + +def schedule_pm2_restart(app_name: str, *, delay_seconds: float = 1.0) -> dict[str, Any]: + """延迟触发 PM2 重启,便于 HTTP 响应先返回(避免重启当前进程导致请求中断).""" + if not sys.platform.startswith("linux"): + return {"ok": False, "msg": "仅 Linux 服务器支持 PM2 重启", "app": app_name} + if not (app_name or "").strip(): + return {"ok": False, "msg": "未指定 PM2 应用名", "app": app_name} + app_name = app_name.strip() + try: + cmd = f"sleep {delay_seconds} && exec pm2 restart {shlex.quote(app_name)} --update-env" + subprocess.Popen( + ["bash", "-c", cmd], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + return {"ok": True, "app": app_name, "msg": "重启已触发", "deferred": True} + except FileNotFoundError: + return {"ok": False, "msg": "未找到 bash 或 pm2 命令", "app": app_name} + except Exception as e: + return {"ok": False, "msg": str(e), "app": app_name} + + +def restart_instance_pm2(exchange_key: str, *, defer: bool = False) -> dict[str, Any]: + if not sys.platform.startswith("linux"): + return {"ok": False, "msg": "仅 Linux 服务器支持 PM2 重启", "app": None} + app_name = resolve_pm2_app_name(exchange_key) + if defer: + return schedule_pm2_restart(app_name) + try: + proc = subprocess.run( + ["pm2", "restart", app_name, "--update-env"], + capture_output=True, + text=True, + timeout=120, + ) + ok = proc.returncode == 0 + return { + "ok": ok, + "app": app_name, + "msg": (proc.stdout or proc.stderr or "").strip()[:500], + "returncode": proc.returncode, + } + except FileNotFoundError: + return {"ok": False, "msg": "未找到 pm2 命令", "app": app_name} + except subprocess.TimeoutExpired: + return {"ok": False, "msg": "pm2 restart 超时", "app": app_name} + except Exception as e: + return {"ok": False, "msg": str(e), "app": app_name} diff --git a/lib/instance/instance_settings_lib.py b/lib/instance/instance_settings_lib.py new file mode 100644 index 0000000..eea0f6b --- /dev/null +++ b/lib/instance/instance_settings_lib.py @@ -0,0 +1,218 @@ +"""实例「系统设置」页:从 .env 汇总风控说明(三所共用).""" +from __future__ import annotations + +import os +from typing import Any, Optional + +from lib.key_monitor.key_auto_order_lib import load_key_auto_order_enabled +from lib.trade.account_risk_lib import ( + cooling_hours_manual, + cooling_hours_manual_journal, + manual_close_daily_limit, + max_active_positions_from_env, + mood_issues_daily_freeze_enabled, + risk_control_enabled, +) +from lib.trade.position_sizing_lib import is_full_margin_mode, load_position_sizing_mode, mode_label_zh +from lib.trade.trade_policy_lib import TradePolicy + + +def _env_bool(key: str, default: bool = False) -> bool: + raw = (os.getenv(key) or "").strip().lower() + if not raw: + return default + return raw in ("1", "true", "yes", "on") + + +def _env_float(key: str, default: float) -> float: + try: + return float(os.getenv(key, str(default))) + except (TypeError, ValueError): + return default + + +def _env_int(key: str, default: int) -> int: + try: + return int(os.getenv(key, str(default))) + except (TypeError, ValueError): + return default + + +def _row(label: str, value: str, note: str = "") -> dict[str, str]: + return {"label": label, "value": value, "note": note} + + +def _on_off(enabled: bool) -> str: + return "开启" if enabled else "关闭" + + +def build_instance_settings_view( + *, + exchange_key: str, + exchange_display: str, + risk_status: Optional[dict[str, Any]] = None, + trade_policy: Optional[TradePolicy] = None, + data_export_version: int = 3, +) -> dict[str, Any]: + rs = risk_status or {} + sizing_mode = load_position_sizing_mode() + key_auto = load_key_auto_order_enabled() + reset_hour = _env_int("TRADING_DAY_RESET_HOUR", 8) + hard_limit = _env_int("DAILY_OPEN_HARD_LIMIT", 0) + alert_threshold = _env_int("DAILY_OPEN_ALERT_THRESHOLD", 5) + force_close_on = _env_bool("FORCE_CLOSE_ENABLED", False) + force_close_hour = _env_int("FORCE_CLOSE_BJ_HOUR", 0) + auto_transfer_on = _env_bool("AUTO_TRANSFER_ENABLED", False) + + sections: list[dict[str, Any]] = [] + + sections.append( + { + "title": "交易执行", + "rows": [ + _row("最大同时持仓", str(max_active_positions_from_env())), + _row("计仓模式", mode_label_zh(sizing_mode)), + _row("以损定仓风险%", f"{_env_float('RISK_PERCENT', 2):g}%"), + _row("人工最低盈亏比", f">= {_env_float('MANUAL_MIN_PLANNED_RR', 1.4):g}:1"), + _row( + "交易日切点", + f"北京时间 {reset_hour}:00", + "新交易日统计与部分开仓限制以此为准", + ), + _row( + "单日开仓提醒", + f"第 {alert_threshold} 次", + "达次数推送企业微信,不拦单", + ), + _row( + "单日开仓硬上限", + str(hard_limit) if hard_limit > 0 else "未启用", + "达上限后禁止一切新开仓直至下一交易日" if hard_limit > 0 else "", + ), + ], + } + ) + + sections.append( + { + "title": "账户冷静期", + "rows": [ + _row("风控总开关", _on_off(risk_control_enabled())), + _row("手动平仓冷静", f"{cooling_hours_manual():g} 小时"), + _row("复盘后冷静", f"{cooling_hours_manual_journal():g} 小时", "手动平仓且填写说明后可缩短"), + _row("日手动平仓上限", f"{manual_close_daily_limit()} 次", "超限当日冻结"), + _row( + "复盘情绪日冻结", + _on_off(mood_issues_daily_freeze_enabled()), + "复盘勾选心态标签可触发当日冻结", + ), + ], + } + ) + + key_rows = [ + _row("关键位自动单", _on_off(key_auto)), + _row("关键位最低盈亏比", f"> {_env_float('KEY_AUTO_MIN_PLANNED_RR', 1.5):g}:1"), + ] + if is_full_margin_mode(sizing_mode): + key_rows.append( + _row( + "全仓模式", + "仅触价类自动单", + "箱体/斐波等自动开仓在全仓下禁用", + ) + ) + sections.append({"title": "关键位与自动单", "rows": key_rows}) + + if force_close_on or (exchange_key or "").strip().lower() == "gate": + sections.append( + { + "title": "整点强制清仓", + "rows": [ + _row("强制清仓", _on_off(force_close_on)), + _row("执行时刻", f"北京时间 {force_close_hour}:00 起 15 分钟内"), + ], + } + ) + + if (exchange_key or "").strip().lower() == "okx" and _env_bool("OKX_OPTIONS_ENABLED", False): + opt_key = (os.getenv("OKX_OPTIONS_API_KEY") or "").strip() + sections.append( + { + "title": "期权设置", + "rows": [ + _row("期权模块", "已启用"), + _row( + "期权 API", + f"已配置(…{opt_key[-4:]})" if len(opt_key) >= 4 else "未配置", + ), + _row( + "子账户", + (os.getenv("OKX_SUB_ACCOUNT_NAME") or "").strip() or "未配置 OKX_SUB_ACCOUNT_NAME", + "主/子账户划转用", + ), + _row( + "说明", + "币种兑换与账户划转到右侧「期权设置」卡片操作", + ), + ], + } + ) + + policy_note = "" + if trade_policy and getattr(trade_policy, "badge_text", ""): + policy_note = str(trade_policy.badge_text) + + return { + "exchange_display": exchange_display, + "risk_status_label": str(rs.get("status_label") or "正常"), + "risk_status_reason": str(rs.get("reason") or "").strip(), + "can_trade": bool(rs.get("can_trade", True)), + "trade_policy_note": policy_note, + "sections": sections, + "data_export_version": int(data_export_version), + "show_transfer": (exchange_key or "").strip().lower() in ("gate", "binance", "okx"), + "options_settings_enabled": (exchange_key or "").strip().lower() == "okx" + and _env_bool("OKX_OPTIONS_ENABLED", False), + "options_sub_account": (os.getenv("OKX_SUB_ACCOUNT_NAME") or "").strip(), + "auto_transfer_enabled": auto_transfer_on, + "auto_transfer_bj_hour": _env_int("AUTO_TRANSFER_BJ_HOUR", 8), + "auto_transfer_amount": _env_float("AUTO_TRANSFER_AMOUNT", 30), + "auto_transfer_from": (os.getenv("AUTO_TRANSFER_FROM") or "funding").strip(), + "auto_transfer_to": (os.getenv("AUTO_TRANSFER_TO") or "swap").strip(), + } + + +def build_settings_tabs(display: dict[str, Any] | None, instance_settings: dict[str, Any]) -> list[dict[str, str]]: + disp = display or {} + inst = instance_settings or {} + tabs: list[dict[str, str]] = [{"key": "nav", "title": "导航显示"}] + if disp.get("show_settings_password", True): + tabs.append({"key": "password", "title": "账户密码"}) + if inst.get("show_transfer") and disp.get("show_settings_transfer", True): + tabs.append({"key": "transfer", "title": "永续划转"}) + if disp.get("show_settings_export", True): + tabs.append({"key": "export", "title": "数据导出"}) + if inst.get("options_settings_enabled") and disp.get("show_settings_options_swap", True): + tabs.append({"key": "options_swap", "title": "币种兑换"}) + if inst.get("options_settings_enabled") and disp.get("show_settings_options_transfer", True): + tabs.append({"key": "options_transfer", "title": "期权划转"}) + return tabs + + +def settings_page_context(page: str, *, instance_base_dir: str | None = None, **kwargs: Any) -> dict[str, Any]: + p = (page or "").strip() + if p not in ("settings", "risk_policy", "env_config"): + return {} + display = kwargs.pop("display", None) + ctx: dict[str, Any] = {"instance_settings": build_instance_settings_view(**kwargs)} + if p == "settings": + ctx["settings_tabs"] = build_settings_tabs(display, ctx["instance_settings"]) + if p == "env_config" and instance_base_dir: + from lib.env.env_ui_manifest import build_env_ui_payload + + exchange_key = str(kwargs.get("exchange_key") or "") + env_path = os.path.join(instance_base_dir, ".env") + example_path = os.path.join(instance_base_dir, ".env.example") + ctx["env_config_groups"] = build_env_ui_payload(exchange_key, example_path, env_path) + return ctx diff --git a/lib/instance/instance_settings_register.py b/lib/instance/instance_settings_register.py new file mode 100644 index 0000000..f55666a --- /dev/null +++ b/lib/instance/instance_settings_register.py @@ -0,0 +1,174 @@ +"""实例系统设置 API:导航开关,env 读写,改密,PM2 重启.""" +from __future__ import annotations + +import os +from functools import wraps +from typing import Any, Callable + +from flask import jsonify, request, session + +from lib.env.env_file_lib import apply_env_updates, env_get, read_env_lines +from lib.env.env_ui_manifest import ( + build_env_ui_payload, + filter_updates_for_ui, + validate_env_ui_updates, +) +from lib.env.env_schema import parse_env_example_schema +from lib.instance.instance_display_prefs_lib import ( + display_meta_for_ui, + get_display_prefs, + normalize_display_prefs, + save_display_prefs, + tab_allowed, +) +from lib.instance.instance_pm2_lib import restart_instance_pm2 +from lib.instance.runtime_config_lib import apply_env_reload + + +def _api_login_required(hub_token_write_allowed: bool = False): + def decorator(f): + @wraps(f) + def wrapped(*args, **kwargs): + from lib.hub.hub_auth import request_allowed as hub_request_allowed + + logged_in = bool(session.get("logged_in")) + auth_disabled = (os.getenv("APP_AUTH_DISABLED") or "").strip().lower() in ( + "1", + "true", + "yes", + "on", + ) + hub_hdr = (request.headers.get("X-Hub-Token") or "").strip() + bridge = (os.getenv("HUB_BRIDGE_TOKEN") or "").strip() + if hub_hdr and bridge and hub_hdr == bridge and not hub_token_write_allowed: + return jsonify({"ok": False, "msg": "Hub Token 不可修改实例设置"}), 403 + if hub_request_allowed(logged_in, auth_disabled): + return f(*args, **kwargs) + return jsonify({"ok": False, "msg": "未登录"}), 401 + + return wrapped + + return decorator + + +def register_instance_settings_routes( + app, + *, + get_db: Callable, + login_required_fn: Callable, + base_dir: str, + exchange_key: str, + username: str, + password: str, +) -> None: + env_path = os.path.join(base_dir, ".env") + example_path = os.path.join(base_dir, ".env.example") + api_auth = _api_login_required() + + @app.route("/api/settings/display", methods=["GET", "POST"]) + @api_auth + def api_settings_display(): + if request.method == "GET": + prefs = get_display_prefs(get_db) + return jsonify( + { + "ok": True, + "display": prefs, + "meta": display_meta_for_ui(), + } + ) + body = request.get_json(silent=True) or {} + raw = body.get("display") if isinstance(body.get("display"), dict) else body + saved = save_display_prefs(get_db, raw) + return jsonify({"ok": True, "display": saved}) + + @app.route("/api/settings/env/meta", methods=["GET"]) + @api_auth + def api_env_meta(): + groups = build_env_ui_payload(exchange_key, example_path, env_path) + return jsonify({"ok": True, "groups": groups}) + + @app.route("/api/settings/env", methods=["GET", "POST"]) + @api_auth + def api_settings_env(): + if request.method == "GET": + groups = build_env_ui_payload(exchange_key, example_path, env_path) + return jsonify({"ok": True, "groups": groups}) + body = request.get_json(silent=True) or {} + updates = body.get("values") if isinstance(body.get("values"), dict) else body + if not isinstance(updates, dict): + return jsonify({"ok": False, "msg": "无效请求体"}), 400 + updates = filter_updates_for_ui(exchange_key, updates) + clean, errors = validate_env_ui_updates(exchange_key, example_path, updates) + if errors: + return jsonify({"ok": False, "msg": "; ".join(errors)}), 400 + if not clean: + return jsonify({"ok": True, "changed_keys": [], "restart_required": False}) + changed = apply_env_updates(env_path, clean) + groups = parse_env_example_schema(example_path) + reload_info = apply_env_reload(env_path, get_db, changed, groups) + return jsonify( + { + "ok": True, + "changed_keys": changed, + "restart_required": reload_info.get("restart_required", False), + } + ) + + @app.route("/api/settings/password", methods=["POST"]) + @api_auth + def api_change_password(): + body = request.get_json(silent=True) or {} + old_password = str(body.get("old_password") or "") + new_username = str(body.get("new_username") or "").strip() + new_password = str(body.get("new_password") or "") + confirm = str(body.get("confirm_password") or "") + if not old_password or old_password != password: + return jsonify({"ok": False, "msg": "当前密码错误"}), 400 + if len(new_password) < 6: + return jsonify({"ok": False, "msg": "新密码至少 6 位"}), 400 + if new_password != confirm: + return jsonify({"ok": False, "msg": "两次输入的新密码不一致"}), 400 + updates: dict[str, str] = {"APP_PASSWORD": new_password} + if new_username: + updates["APP_USERNAME"] = new_username + changed = apply_env_updates(env_path, updates) + groups = parse_env_example_schema(example_path) + apply_env_reload(env_path, get_db, changed, groups) + return jsonify({"ok": True, "restart_required": True, "changed_keys": changed}) + + @app.route("/api/admin/restart", methods=["POST"]) + @api_auth + def api_admin_restart(): + result = restart_instance_pm2(exchange_key, defer=True) + code = 200 if result.get("ok") else 500 + return jsonify({"ok": bool(result.get("ok")), **result}), code + + @app.route("/api/admin/health", methods=["GET"]) + def api_admin_health(): + return jsonify({"ok": True, "status": "up"}) + + def tab_allowed_fn(tab: str) -> bool: + prefs = get_display_prefs(get_db) + return tab_allowed(tab, prefs) + + app.config["INSTANCE_GET_DB"] = get_db + app.config["INSTANCE_TAB_ALLOWED_FN"] = tab_allowed_fn + + @app.route("/api/embed/tab_allowed/", methods=["GET"]) + @api_auth + def api_tab_allowed(tab: str): + prefs = get_display_prefs(get_db) + return jsonify({"ok": True, "tab": tab, "allowed": tab_allowed(tab, prefs)}) + + +def merge_ui_template_context(page: str, get_db: Callable, **settings_kwargs: Any) -> dict[str, Any]: + from lib.instance.instance_settings_lib import settings_page_context + + prefs = get_display_prefs(get_db) + ctx = { + "display": prefs, + "display_meta": display_meta_for_ui(), + **settings_page_context(page, display=prefs, **settings_kwargs), + } + return ctx diff --git a/lib/instance/journal_chart_lib.py b/lib/instance/journal_chart_lib.py new file mode 100644 index 0000000..18bd9f5 --- /dev/null +++ b/lib/instance/journal_chart_lib.py @@ -0,0 +1,452 @@ +"""交易复盘 / 订单 K 线拼图(Binance / Gate / OKX 共用).""" + +import math + +try: + from PIL import Image, ImageDraw, ImageFont +except ImportError: + Image = None # type: ignore + ImageDraw = None # type: ignore + ImageFont = None # type: ignore + +JOURNAL_CHART_TF_CHOICES = ("1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "12h", "1d") +JOURNAL_CHART_DEFAULT_TF1 = "15m" +JOURNAL_CHART_DEFAULT_TF2 = "1h" +JOURNAL_CHART_DEFAULT_LIMIT = 300 +JOURNAL_CHART_LIMIT_MIN = 50 +JOURNAL_CHART_LIMIT_MAX = 500 +JOURNAL_CHART_ANCHOR_CLOSE = "close" +JOURNAL_CHART_ANCHOR_NOW = "now" +JOURNAL_CHART_DEFAULT_ANCHOR = JOURNAL_CHART_ANCHOR_CLOSE + + +def _load_font(size): + if not ImageFont: + return None + for name in ("msyh.ttc", "Microsoft YaHei.ttf", "arial.ttf", "Arial.ttf"): + try: + return ImageFont.truetype(name, 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 marker_tag_label(tag): + t = str(tag or "").strip().upper() + if t == "ENTRY": + return "开仓" + if t == "EXIT": + return "平仓" + if t == "STOP": + 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 parse_positive_price(raw): + if raw is None: + return None + s = str(raw).strip() + if not s: + return None + try: + p = float(s) + return p if p > 0 else None + except (TypeError, ValueError): + return None + + +def parse_journal_chart_anchor(raw): + s = str(raw or "").strip().lower() + if s in (JOURNAL_CHART_ANCHOR_NOW, "current", "当前", "当前时间"): + return JOURNAL_CHART_ANCHOR_NOW + return JOURNAL_CHART_ANCHOR_CLOSE + + +def parse_journal_chart_limit(raw, fallback=None): + fb = int(fallback if fallback is not None else JOURNAL_CHART_DEFAULT_LIMIT) + try: + n = int(str(raw or "").strip() or fb) + except (TypeError, ValueError): + n = fb + return max(JOURNAL_CHART_LIMIT_MIN, min(JOURNAL_CHART_LIMIT_MAX, n)) + + +def normalize_chart_timeframe(raw): + tf = str(raw or "").strip().lower() + if tf in JOURNAL_CHART_TF_CHOICES: + return tf + return "" + + +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 _to_int_ms(value): + if value is None: + return None + try: + v = int(value) + return v if v > 0 else None + except (TypeError, ValueError): + return None + + +def trade_review_fetch_window(entry_ts_ms, exit_ts_ms, timeframe, limit, anchor=None, now_ms=None): + """ + 复盘 K 线窗口(anchor=close): + - 有开/平仓:从开仓前若干根起,到平仓 K 线止(覆盖整笔交易 + 入场前背景) + - 仅开仓:以开仓时间为终点向前 limit 根 + - 仅平仓:以平仓时间为终点向前 limit 根 + anchor=now:以当前时间为终点向前 limit 根(可看平仓后走势) + """ + period = timeframe_period_ms(timeframe) + lim = max(2, int(limit)) + entry_ms = _to_int_ms(entry_ts_ms) + exit_ms = _to_int_ms(exit_ts_ms) + anch = (anchor or JOURNAL_CHART_DEFAULT_ANCHOR).strip().lower() + + if anch == JOURNAL_CHART_ANCHOR_NOW: + end_ms = _to_int_ms(now_ms) + if not end_ms: + return None + since_ms = end_ms - period * (lim + 10) + return { + "since_ms": since_ms, + "end_ms": end_ms, + "window_start_ms": since_ms, + "fetch_limit": lim + 20, + "display_limit": lim, + } + + if entry_ms and exit_ms: + if exit_ms < entry_ms: + entry_ms, exit_ms = exit_ms, entry_ms + span_bars = max(1, (exit_ms - entry_ms) // period + 1) + pre_bars = max(40, min(120, lim // 3)) + need = span_bars + pre_bars + fetch_limit = min(JOURNAL_CHART_LIMIT_MAX, max(lim, need + 15)) + since_ms = entry_ms - period * pre_bars + return { + "since_ms": since_ms, + "end_ms": exit_ms, + "window_start_ms": since_ms, + "fetch_limit": fetch_limit, + "display_limit": lim, + } + if entry_ms: + end_ms = entry_ms + since_ms = end_ms - period * (lim + 10) + return { + "since_ms": since_ms, + "end_ms": end_ms, + "window_start_ms": since_ms, + "fetch_limit": lim + 20, + "display_limit": lim, + } + if exit_ms: + end_ms = exit_ms + since_ms = end_ms - period * (lim + 10) + return { + "since_ms": since_ms, + "end_ms": end_ms, + "window_start_ms": since_ms, + "fetch_limit": lim + 20, + "display_limit": lim, + } + return None + + +def trim_rows_for_trade_review(rows, window): + if not window: + return list(rows or []) + start_ms = int(window["window_start_ms"]) + end_ms = int(window["end_ms"]) + lim = int(window["display_limit"]) + filt = [r for r in (rows or []) if start_ms <= int(r["ts"]) <= end_ms] + if len(filt) > lim: + filt = filt[-lim:] + return filt + + +def parse_journal_chart_timeframes(tf1, tf2, fallback_tfs=None): + """复盘表单:最多两个周期,去重保序.""" + out = [] + for raw in (tf1, tf2): + tf = normalize_chart_timeframe(raw) + if tf and tf not in out: + out.append(tf) + if out: + return out[:2] + fb = [normalize_chart_timeframe(x) for x in (fallback_tfs or (JOURNAL_CHART_DEFAULT_TF1, JOURNAL_CHART_DEFAULT_TF2))] + fb = [x for x in fb if x] + return fb[:2] if fb else [JOURNAL_CHART_DEFAULT_TF1, JOURNAL_CHART_DEFAULT_TF2] + + +def marker_points_for_timeframe(rows, marker_payload): + points = [] + if not marker_payload or not rows: + return points + 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"}) + return points + + +def price_levels_from_marker_payload(marker_payload): + levels = [] + if not marker_payload: + return levels + sl = parse_positive_price(marker_payload.get("stop_loss_price")) + if sl is not None: + levels.append({"price": sl, "label": "止损", "color": (255, 152, 0)}) + return levels + + +def render_candles_subplot( + rows, + title, + width, + height, + bg_rgb=(255, 255, 255), + marker_points=None, + price_levels=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) + for pl in price_levels or []: + try: + p = float(pl.get("price")) + if p > 0: + lo = min(lo, p) + hi = max(hi, p) + except (TypeError, ValueError): + pass + 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 + + x_right = pad_l + plot_w + for pl in price_levels or []: + try: + p = float(pl.get("price")) + except (TypeError, ValueError): + continue + if p <= 0: + continue + y_sl = pad_t + int((hi - p) / (hi - lo) * plot_h) + color = tuple(pl.get("color") or (255, 152, 0)) + label = str(pl.get("label") or "止损") + for xx in range(pad_l, x_right, 10): + draw.line((xx, y_sl, min(xx + 6, x_right), y_sl), fill=color, width=2) + if font: + draw.text((x_right - 72, y_sl - 18), label, fill=color, font=small or font) + else: + draw.text((x_right - 72, y_sl - 18), label, fill=color) + + 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 compose_chart_panels(panels, layout="grid", cell_w=980, cell_h=520, gap=10): + if not panels or not Image: + return None + if layout == "vertical": + cols = 1 + rows_n = len(panels) + else: + 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 layout != "vertical" 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) + elif ImageDraw and layout == "vertical" and rows_n >= 2: + draw_out = ImageDraw.Draw(out) + line_col = (220, 225, 232) + 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) + return out diff --git a/lib/instance/journal_form_lib.py b/lib/instance/journal_form_lib.py new file mode 100644 index 0000000..1aed0a6 --- /dev/null +++ b/lib/instance/journal_form_lib.py @@ -0,0 +1,54 @@ +"""复盘表单:下单类型与开仓类型校验(三所共用).""" +from __future__ import annotations + +from typing import Optional, Sequence, Tuple + +from lib.strategy.strategy_trade_labels import ( + JOURNAL_ORDER_TYPE_OPTIONS, + STRATEGY_ENTRY_REASON_OPTIONS, + normalize_journal_order_type, +) +from lib.trade.entry_model_lib import ( + TRADE_STYLE_FALLBACK_ENTRY_REASONS, + normalize_review_entry_reason, +) + +_LEGACY_JOURNAL_ENTRY_REASONS: Tuple[str, ...] = ( + *TRADE_STYLE_FALLBACK_ENTRY_REASONS, + *STRATEGY_ENTRY_REASON_OPTIONS, +) + + +def normalize_journal_entry_reason( + raw: Optional[str], + allowed: Sequence[str], + *, + allow_legacy: bool = False, +) -> str: + s = normalize_review_entry_reason(raw, allowed) + if s: + return s + if not allow_legacy: + return "" + legacy = (raw or "").strip() + if legacy in _LEGACY_JOURNAL_ENTRY_REASONS: + return legacy + return "" + + +def journal_entry_reason_valid(raw: Optional[str], allowed: Sequence[str]) -> bool: + return bool(normalize_journal_entry_reason(raw, allowed, allow_legacy=False)) + + +def journal_order_type_valid(raw: Optional[str]) -> bool: + return bool(normalize_journal_order_type(raw)) + + +def normalize_journal_direction(raw: Optional[str]) -> str: + s = (raw or "").strip().lower() + if s in ("long", "buy", "多", "做多"): + return "long" + if s in ("short", "sell", "空", "做空"): + return "short" + # 兼容旧隐藏字段 direction_hint + return "" diff --git a/lib/instance/journal_images_lib.py b/lib/instance/journal_images_lib.py new file mode 100644 index 0000000..dbe420e --- /dev/null +++ b/lib/instance/journal_images_lib.py @@ -0,0 +1,208 @@ +"""复盘记录:多周期截图上传,存储与读取(三所共用).""" +from __future__ import annotations + +import json +import os +import re +from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence + +JOURNAL_UPLOAD_TFS: tuple[str, ...] = ("5m", "15m", "1h", "4h") +JOURNAL_UPLOAD_ALLOWED_EXT = frozenset({".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"}) +_JOURNAL_DRAFT_ID_RE = re.compile(r"^[a-f0-9]{32}$") +_JOURNAL_SLOT_FILE_RE = re.compile( + r"^journal_([a-f0-9]{32})_(5m|15m|1h|4h)\.(png|jpg|jpeg|webp|gif|bmp)$", + re.I, +) + + +def journal_upload_field_name(tf: str) -> str: + return f"screenshot_{tf}" + + +def uploaded_screenshot_field_name(tf: str) -> str: + return f"uploaded_screenshot_{tf}" + + +def normalize_journal_draft_id(raw: Any) -> Optional[str]: + s = str(raw or "").strip().lower() + if _JOURNAL_DRAFT_ID_RE.match(s): + return s + return None + + +def _safe_ext(filename: str) -> str: + ext = os.path.splitext(str(filename or ""))[1].lower() + return ext if ext in JOURNAL_UPLOAD_ALLOWED_EXT else ".png" + + +def build_journal_slot_filename( + entry_id: str, + tf: str, + ext: str, + *, + secure_filename_fn: Callable[[str], str], +) -> str: + ext = ext if ext.startswith(".") else f".{ext}" + ext = _safe_ext(f"x{ext}") + fname = secure_filename_fn(f"journal_{entry_id}_{tf}{ext}") + return fname or "" + + +def is_valid_preuploaded_journal_file(filename: str, entry_id: str, tf: str) -> bool: + fn = os.path.basename(str(filename or "").strip()) + if not fn or fn != str(filename or "").strip(): + return False + m = _JOURNAL_SLOT_FILE_RE.match(fn) + if not m: + return False + return m.group(1) == entry_id.lower() and m.group(2) == tf + + +def save_journal_slot_file( + file, + entry_id: str, + tf: str, + upload_folder: str, + *, + secure_filename_fn: Callable[[str], str], +) -> Optional[Dict[str, str]]: + if tf not in JOURNAL_UPLOAD_TFS or not entry_id or not upload_folder: + return None + if not file or not getattr(file, "filename", None): + return None + ext = _safe_ext(file.filename) + fname = build_journal_slot_filename( + entry_id, tf, ext, secure_filename_fn=secure_filename_fn + ) + if not fname: + return None + os.makedirs(upload_folder, exist_ok=True) + path = os.path.join(upload_folder, fname) + file.save(path) + return {"tf": tf, "file": fname} + + +def collect_journal_slot_images( + form, + files, + entry_id: str, + upload_folder: str, + *, + secure_filename_fn: Callable[[str], str], +) -> List[Dict[str, str]]: + """优先使用即时上传 hidden 字段;否则回退到表单 multipart.""" + saved: List[Dict[str, str]] = [] + if not entry_id or not upload_folder: + return saved + for tf in JOURNAL_UPLOAD_TFS: + pre = "" + if form is not None: + pre = str(form.get(uploaded_screenshot_field_name(tf)) or "").strip() + if pre and is_valid_preuploaded_journal_file(pre, entry_id, tf): + path = os.path.join(upload_folder, os.path.basename(pre)) + if os.path.isfile(path): + saved.append({"tf": tf, "file": os.path.basename(pre)}) + continue + f = files.get(journal_upload_field_name(tf)) if files else None + item = save_journal_slot_file( + f, + entry_id, + tf, + upload_folder, + secure_filename_fn=secure_filename_fn, + ) + if item: + saved.append(item) + return saved + + +def save_journal_slot_uploads( + files, + entry_id: str, + upload_folder: str, + *, + secure_filename_fn: Callable[[str], str], +) -> List[Dict[str, str]]: + """保存四槽位手动截图,返回 [{"tf":"5m","file":"journal_xxx_5m.png"}, ...].""" + return collect_journal_slot_images( + None, + files, + entry_id, + upload_folder, + secure_filename_fn=secure_filename_fn, + ) + + +def images_json_dumps(items: Sequence[Mapping[str, str]]) -> Optional[str]: + if not items: + return None + return json.dumps(list(items), ensure_ascii=False, separators=(",", ":")) + + +def parse_images_json(raw: Any) -> List[Dict[str, str]]: + if not raw: + return [] + if isinstance(raw, list): + data = raw + else: + try: + data = json.loads(str(raw)) + except (TypeError, ValueError, json.JSONDecodeError): + return [] + if not isinstance(data, list): + return [] + out: List[Dict[str, str]] = [] + for item in data: + if not isinstance(item, dict): + continue + tf = str(item.get("tf") or "").strip() + file = str(item.get("file") or "").strip() + if file: + out.append({"tf": tf, "file": file}) + return out + + +def primary_journal_image( + manual_images: Sequence[Mapping[str, str]], + *, + fallback: Optional[str] = None, +) -> Optional[str]: + if manual_images: + return str(manual_images[0].get("file") or "").strip() or None + return fallback + + +def enrich_journal_api_item(item: Dict[str, Any]) -> Dict[str, Any]: + """API 输出:解析 images_json,兼容旧单图 image 字段.""" + images = parse_images_json(item.get("images_json")) + if not images and item.get("image"): + images = [{"tf": "", "file": str(item["image"]).strip()}] + item["images"] = images + return item + + +def journal_image_paths(row: Any, upload_folder: str) -> List[str]: + """删除 / AI 附图:收集本条复盘所有本地图片路径(去重).""" + upload_folder = os.path.abspath(upload_folder or "") + paths: List[str] = [] + seen = set() + + def _add(name: Optional[str]) -> None: + if not name: + return + p = os.path.abspath(os.path.join(upload_folder, str(name).strip())) + if os.path.isfile(p) and p not in seen: + seen.add(p) + paths.append(p) + + try: + keys = row.keys() if hasattr(row, "keys") else () + except Exception: + keys = () + + if "images_json" in keys and row["images_json"]: + for img in parse_images_json(row["images_json"]): + _add(img.get("file")) + if "image" in keys: + _add(row["image"]) + return paths diff --git a/lib/instance/journal_upload_api_lib.py b/lib/instance/journal_upload_api_lib.py new file mode 100644 index 0000000..cb6a94c --- /dev/null +++ b/lib/instance/journal_upload_api_lib.py @@ -0,0 +1,43 @@ +"""复盘截图即时上传 API(三所共用).""" +from __future__ import annotations + +from typing import Any, Callable, Dict, Tuple + +from lib.instance.journal_images_lib import ( + JOURNAL_UPLOAD_TFS, + normalize_journal_draft_id, + save_journal_slot_file, +) + + +def handle_journal_upload_slot( + request: Any, + *, + upload_folder: str, + secure_filename_fn: Callable[[str], str], +) -> Tuple[Dict[str, Any], int]: + """POST multipart: journal_draft_id, tf, file → {ok, file}.""" + draft_id = normalize_journal_draft_id( + request.form.get("journal_draft_id") if request.form else None + ) + tf = str((request.form.get("tf") if request.form else None) or "").strip() + if not draft_id: + return {"ok": False, "error": "invalid draft_id"}, 400 + if tf not in JOURNAL_UPLOAD_TFS: + return {"ok": False, "error": "invalid tf"}, 400 + + f = request.files.get("file") if request.files else None + if not f or not getattr(f, "filename", None): + return {"ok": False, "error": "no file"}, 400 + + item = save_journal_slot_file( + f, + draft_id, + tf, + upload_folder, + secure_filename_fn=secure_filename_fn, + ) + if not item: + return {"ok": False, "error": "save failed"}, 500 + + return {"ok": True, "tf": tf, "file": item["file"]}, 200 diff --git a/lib/instance/records_api_register.py b/lib/instance/records_api_register.py new file mode 100644 index 0000000..fb7d541 --- /dev/null +++ b/lib/instance/records_api_register.py @@ -0,0 +1,54 @@ +"""注册 /api/trade_records(三所共用).""" + +from __future__ import annotations + +from typing import Any, Callable + +from flask import Flask, jsonify, request + + +def register_trade_records_api( + app: Flask, + *, + login_required: Callable, + get_db: Callable, + list_window_from_request: Callable[[], dict[str, Any]], + utc_window_to_bj_sql_strings: Callable[..., tuple[str, str]], + sql_list_time_field: Callable[..., str], + to_effective_trade_dict: Callable[[Any], dict[str, Any]], + filter_trade_records_excluding_miss: Callable[[list], list], + app_tz: Any, +) -> None: + from lib.instance.records_list_lib import list_trade_records_page + + @app.route("/api/trade_records") + @login_required + def api_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 + ) + tr_ts = sql_list_time_field("closed_at", "created_at", "opened_at") + try: + limit = int(request.args.get("limit") or 5) + except (TypeError, ValueError): + limit = 5 + try: + offset = int(request.args.get("offset") or 0) + except (TypeError, ValueError): + offset = 0 + conn = get_db() + try: + payload = list_trade_records_page( + conn, + start_bj, + end_bj, + tr_ts=tr_ts, + to_effective_fn=to_effective_trade_dict, + filter_fn=filter_trade_records_excluding_miss, + limit=limit, + offset=offset, + ) + return jsonify(payload) + finally: + conn.close() diff --git a/lib/instance/records_list_lib.py b/lib/instance/records_list_lib.py new file mode 100644 index 0000000..dbda60d --- /dev/null +++ b/lib/instance/records_list_lib.py @@ -0,0 +1,44 @@ +"""交易记录列表分页(三所 /records 共用).""" + +from __future__ import annotations + +from typing import Any, Callable + + +def list_trade_records_page( + conn: Any, + start_bj: str, + end_bj: str, + *, + tr_ts: str, + to_effective_fn: Callable[[Any], dict[str, Any]], + filter_fn: Callable[[list[dict[str, Any]]], list[dict[str, Any]]], + limit: int = 5, + offset: int = 0, + fetch_cap: int = 1000, +) -> dict[str, Any]: + """按列表窗拉取、enrich、过滤「错过」后分页.""" + limit = max(1, min(100, int(limit or 5))) + offset = max(0, int(offset or 0)) + raw_records = conn.execute( + f"SELECT * FROM trade_records WHERE {tr_ts} >= ? AND {tr_ts} <= ? " + f"ORDER BY id DESC LIMIT ?", + (start_bj, end_bj, int(fetch_cap)), + ).fetchall() + records = filter_fn([to_effective_fn(r) for r in raw_records]) + total = len(records) + pages = max(1, (total + limit - 1) // limit) if total else 1 + page = (offset // limit) + 1 if limit else 1 + if page > pages: + page = pages + offset = (page - 1) * limit + items = records[offset : offset + limit] + return { + "ok": True, + "items": items, + "total": total, + "limit": limit, + "offset": offset, + "page": page, + "pages": pages, + } diff --git a/lib/instance/runtime_config_lib.py b/lib/instance/runtime_config_lib.py new file mode 100644 index 0000000..43ea963 --- /dev/null +++ b/lib/instance/runtime_config_lib.py @@ -0,0 +1,62 @@ +"""env 运行时覆盖:热生效项优先读 SQLite,再回退 os.environ.""" +from __future__ import annotations + +import os +from typing import Callable, Optional + +from lib.env.env_file_lib import load_env_file_into_environ +from lib.instance.runtime_settings_lib import runtime_get, with_db + +ENV_OVERRIDE_PREFIX = "env." + + +def runtime_env_key(name: str) -> str: + return ENV_OVERRIDE_PREFIX + name + + +def get_config(key: str, get_db: Callable, default: Optional[str] = None) -> Optional[str]: + def _read(conn): + v = runtime_get(conn, runtime_env_key(key)) + return v + + try: + v = with_db(get_db, _read) + if v is not None: + return v + except Exception: + pass + raw = os.getenv(key) + if raw is None or raw == "": + return default + return raw + + +def set_config_overrides(get_db: Callable, mapping: dict[str, str]) -> None: + from lib.instance.runtime_settings_lib import runtime_set_many + + def _write(conn): + payload = {runtime_env_key(k): str(v) for k, v in mapping.items()} + runtime_set_many(conn, payload) + + with_db(get_db, _write) + + +def apply_env_reload(env_path: str, get_db: Callable, changed_keys: list[str], groups: list[dict]) -> dict[str, bool]: + """写盘后同步 os.environ,并将可热生效项写入 runtime 覆盖.""" + load_env_file_into_environ(env_path) + hot: dict[str, str] = {} + field_map = {} + for group in groups: + for field in group.get("fields") or []: + field_map[field["key"]] = field + for key in changed_keys: + meta = field_map.get(key) or {} + if meta.get("hot_reload") and not meta.get("restart_required"): + val = os.getenv(key) + if val is not None: + hot[key] = val + if hot: + set_config_overrides(get_db, hot) + from lib.env.env_schema import updates_need_restart + + return {"restart_required": updates_need_restart(groups, changed_keys)} diff --git a/lib/instance/runtime_settings_lib.py b/lib/instance/runtime_settings_lib.py new file mode 100644 index 0000000..36933c6 --- /dev/null +++ b/lib/instance/runtime_settings_lib.py @@ -0,0 +1,71 @@ +"""实例 SQLite 运行时配置(导航开关,env 热覆盖等).""" +from __future__ import annotations + +import sqlite3 +from datetime import datetime +from typing import Any, Callable, Optional + +RUNTIME_TABLE_SQL = """ +CREATE TABLE IF NOT EXISTS app_runtime_settings ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +) +""" + + +def ensure_runtime_settings_table(conn: sqlite3.Connection) -> None: + conn.execute(RUNTIME_TABLE_SQL) + conn.commit() + + +def runtime_get(conn: sqlite3.Connection, key: str) -> Optional[str]: + row = conn.execute( + "SELECT value FROM app_runtime_settings WHERE key=?", + (key,), + ).fetchone() + if not row: + return None + val = row["value"] if isinstance(row, sqlite3.Row) else row[0] + return None if val is None else str(val) + + +def runtime_set(conn: sqlite3.Connection, key: str, value: str) -> None: + now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + conn.execute( + "INSERT INTO app_runtime_settings(key, value, updated_at) VALUES (?,?,?) " + "ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at", + (key, value, now), + ) + conn.commit() + + +def runtime_get_prefix(conn: sqlite3.Connection, prefix: str) -> dict[str, str]: + rows = conn.execute( + "SELECT key, value FROM app_runtime_settings WHERE key LIKE ?", + (prefix + "%",), + ).fetchall() + out: dict[str, str] = {} + for row in rows: + k = row["key"] if isinstance(row, sqlite3.Row) else row[0] + v = row["value"] if isinstance(row, sqlite3.Row) else row[1] + if k.startswith(prefix): + out[k[len(prefix) :]] = v if v is not None else "" + return out + + +def runtime_set_many(conn: sqlite3.Connection, mapping: dict[str, str]) -> None: + for key, value in mapping.items(): + runtime_set(conn, key, value) + + +def with_db( + get_db: Callable[[], sqlite3.Connection], + fn: Callable[[sqlite3.Connection], Any], +) -> Any: + conn = get_db() + try: + ensure_runtime_settings_table(conn) + return fn(conn) + finally: + conn.close() diff --git a/lib/instance/templates/dashboard_panel.html b/lib/instance/templates/dashboard_panel.html new file mode 100644 index 0000000..a47bafe --- /dev/null +++ b/lib/instance/templates/dashboard_panel.html @@ -0,0 +1,15 @@ +{# 实例数据看板:只读活跃监控总览 #} +
        +
        +
        +

        数据看板

        +

        本户活跃监控总览 · 只读 · 无数据的区块不显示 · 有数据按表格展示

        +
        +
        + + +
        +
        +

        +
        +
        diff --git a/lib/instance/templates/display_prefs_panel.html b/lib/instance/templates/display_prefs_panel.html new file mode 100644 index 0000000..950b118 --- /dev/null +++ b/lib/instance/templates/display_prefs_panel.html @@ -0,0 +1,28 @@ +{# 系统设置 · 导航显示开关(SSR 预渲染,保存仍走 API) #} +
        +

        导航显示

        +

        以下开关控制顶栏导航与系统设置内区块是否显示,保存后立即生效.关键位监控,实盘下单,系统设置为固定项.

        +
        + {% if display_meta %} + {% for group in display_meta %} +
        +

        {{ group.group }}

        +
        + {% for item in group.entries %} + + {% endfor %} +
        +
        + {% endfor %} + {% else %} +
        加载中…
        + {% endif %} +
        +
        + + +
        +
        diff --git a/lib/instance/templates/embed_boot_scripts.html b/lib/instance/templates/embed_boot_scripts.html new file mode 100644 index 0000000..ecbc41f --- /dev/null +++ b/lib/instance/templates/embed_boot_scripts.html @@ -0,0 +1,1503 @@ + diff --git a/lib/instance/templates/embed_page_fragment.html b/lib/instance/templates/embed_page_fragment.html new file mode 100644 index 0000000..d21cf90 --- /dev/null +++ b/lib/instance/templates/embed_page_fragment.html @@ -0,0 +1,357 @@ +{# Hub iframe tab fragment — shared via embed_templates #} +{% macro period_stats_pane(period_key, s) %} +{% set win_pct = s.win_rate_pct if s.win_rate_pct is not none else 0 %} +{% set profit_sum = (s.net_pnl_u + s.loss_sum_u) if s.closed_count else 0 %} +{% set loss_sum = s.loss_sum_u %} +{% set pnl_total = profit_sum + loss_sum %} +{% set profit_bar_w = (profit_sum / pnl_total * 100) if pnl_total > 0 else 0 %} +{% set loss_bar_w = (loss_sum / pnl_total * 100) if pnl_total > 0 else 0 %} +{% set net_cls = 'pos-pnl-profit' if s.net_pnl_u > 0 else ('pos-pnl-loss' if s.net_pnl_u < 0 else '') %} +
        +
        {{ s.range_label }}
        +
        + {% if s.closed_count %} +
        +
        + {% if s.net_pnl_u > 0 %}+{% endif %}{{ funds_fmt(s.net_pnl_u) }}U + 净盈亏 +
        +
        +
        + {% if s.win_rate_pct is not none %}{{ win_pct|round(0)|int }}%{% else %}—{% endif %} +
        + {{ s.win_count }}胜 {{ s.loss_count }}负 +
        +
        + {{ s.opens_count }} / {{ s.closed_count }} + 开单 / 平仓 +
        +
        +
        +
        盈亏构成
        +
        +
        +
        +
        +
        + 盈利 {{ funds_fmt(profit_sum) }}U + 亏损 {{ funds_fmt(loss_sum) }}U +
        +
        +
        +
        +
        + 最大回撤 + {{ funds_fmt(s.max_drawdown_u) }}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 %} +
        +
        +
        + {% else %} +

        当前区间暂无平仓数据

        + {% endif %} +
        +
        + 详细指标 +
        +
        开单次数
        {{ 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 %} +
        + {% if page == 'dashboard' %} + {% include 'dashboard_panel.html' %} + {% elif page == 'key_monitor' %} + {% include 'key_monitor_panel.html' %} + {% elif page == 'trade' %} +
        +
        +
        +

        实盘下单监控

        + {% if focus_order_id %} + 放大查看K线(100根) + {% else %} + 暂无持仓可放大 + {% endif %} +
        + {% include order_rule_tips_tpl %} +
        + {% from 'trade_policy_fields.html' import trade_policy_symbol, trade_policy_direction with context %} + {{ trade_policy_symbol('symbol', 'order-symbol') }} + {{ trade_policy_direction('direction', 'order-direction') }} + + {% from 'order_entry_model_fields.html' import order_entry_type_fields with context %} + {{ order_entry_type_fields() }} + {% from 'order_leverage_fields.html' import order_leverage_fields with context %} + {{ order_leverage_fields() }} + {% if not intraday_discipline %} + + + + + + {% else %} + + {% endif %} + + {% from 'symbol_live_price_snippet.html' import symbol_live_price_hint %} + {{ symbol_live_price_hint('order-symbol-live-price', 'order-symbol', 'order-direction') }} + 下单成交价以交易所成交回报为准 + + + + + + +
        + {% include 'order_plan_preview_bar.html' %} +
        +
        +

        实时持仓

        + {% if ui_orphan_recovery_enabled %} + {% if not order and orphan_live_positions %} + {% set o = orphan_live_positions[0] %} +
        + 检测到交易所仍有 {{ o.symbol }} {{ '空' if o.direction == 'short' else '多' }}仓,但本地监控已中断(误同步时可能无交易记录). + {% if o.recoverable_monitor_id %} + + {% else %} + 未找到可恢复的监控记录,需在服务器数据库处理. + {% endif %} +
        + {% else %} + + {% endif %} + {% endif %} +
        + {% for o in order %} +
        +
        +
        + {{ o.exchange_symbol or o.symbol }} + {% if o.time_close_enabled %} + + 时间平仓 {{ o.time_close_hours or '' }}h + · --:--:-- + + {% endif %} + {% include 'force_close_order_badge.html' %} + {{ '做多' if o.direction == 'long' else '做空' }} +
        +
        + {% if not intraday_discipline %} + + 平仓 + {% endif %} +
        +
        +
        + 来源: {{ o.monitor_type|default('下单监控', true) }}{% if o.key_signal_type %} · {{ o.key_signal_type }}{% endif %} + {% if o.entry_model_label %}开仓: {{ o.entry_model_label }}{% elif intraday_discipline %}开仓: —{% else %}风格: {{ '波段单' if o.trade_style == 'swing' else '趋势单' }}{% endif %} + 风险: {% if position_sizing_mode == 'full_margin' %}{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U{% else %}{{ o.risk_percent or '-' }}%≈{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U{% endif %} + + + {% if intraday_discipline %} + {% elif 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) }} +
        +
        + 止损 + {{ price_fmt(o.symbol, o.stop_loss) if o.stop_loss else '—' }} +
        +
        + 止盈 + {{ price_fmt(o.symbol, o.take_profit) if o.take_profit else '—' }} +
        +
        + 盈亏比 + {% if o.rr_ratio is not none %}{{ '%g'|format(o.rr_ratio) }}:1{% else %}-:1{% endif %} +
        +
        + 张数 + {% if o.order_amount is not none %}{{ '%.2f'|format(o.order_amount) }}{% else %}—{% endif %} +
        +
        + 盈利金额 + +
        +
        + 标记价 + - +
        +
        + 浮盈亏 + - +
        +
        + +
        +
        交易所止盈止损
        +
        + 止损:加载中… + {% if not intraday_discipline %} + + {% endif %} +
        +
        + 止盈:加载中… + {% if not intraday_discipline %} + + {% endif %} +
        +
        +
        + {% else %} +
        暂无持仓
        + {% endfor %} +
        +
        + +
        +
        +

        挂止盈止损

        +

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

        +
        + +
        +
        + + +
        +
        + + +
        +
        + + +
        +
        +
        + +
        + {% elif page in ('strategy', 'strategy_trend', 'strategy_roll') %} + {% include 'strategy_trading_page.html' %} + {% elif page == 'strategy_records' %} + {% include 'strategy_records_page.html' %} + {% elif page == 'options' %} + {% include 'options_panel.html' %} + {% elif page == 'options_review' %} + {% include 'options_review_panel.html' %} + {% elif page == 'hedge_plan' %} + {% include 'hedge_plan_panel.html' %} + {% endif %} + + + + {% if page == 'records' %} + {% include 'records_panel.html' %} + {% endif %} + {% if page == 'env_config' %} + {% include 'env_config_panel.html' %} + {% endif %} + + {% if page == 'risk_policy' %} + {% include 'risk_policy_panel.html' %} + {% endif %} + + {% if page == 'settings' %} + {% include 'settings_panel.html' %} + {% endif %} + + {% if page == 'stats' %} +
        +
        +

        数据统计

        + +
        +
        +
        + 统计分析按北京时间 {{ stats_bundle.stats_reset_hour }}:00切日计入(与顶栏 UTC 列表窗无关).历史总开仓(累计): + {{ stats_bundle.total_opens_all }} 次 +
        +
        + +
        + {% for seg in stats_bundle.segments %} + + {% endfor %} +
        +
        + {% endif %} +
        diff --git a/lib/instance/templates/embed_shell.html b/lib/instance/templates/embed_shell.html new file mode 100644 index 0000000..02df580 --- /dev/null +++ b/lib/instance/templates/embed_shell.html @@ -0,0 +1,122 @@ + + + + + + + + + + + + + {{ pwa_app_name }} + + +
        +
        +

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

        +
        + + + + {% include 'instance_header_panel.html' %} + {% if initial_tab not in ('settings', 'risk_policy', 'env_config') and include_transfer_block %} + {% include 'instance_top_bar.html' %} + {% endif %} + +
        + {% include 'embed_page_fragment.html' %} +
        +
        + + +
        +
        +
        +
        详情
        +
        + + +
        +
        +
        + + +
        +
        + + + + + + + + + + + + + + +{% include 'embed_boot_scripts.html' %} + + + + + + + + + diff --git a/lib/instance/templates/env_config_panel.html b/lib/instance/templates/env_config_panel.html new file mode 100644 index 0000000..40e6088 --- /dev/null +++ b/lib/instance/templates/env_config_panel.html @@ -0,0 +1,83 @@ +{# env配置:CSS Tab(无需 JS)+ 双列表单 #} +
        +
        +
        +
        +

        env 配置

        +

        按分类修改,改完点保存.含「需重启」的项请用「保存并重启」.AI 配置请在中控 → 系统设置 → AI 配置统一维护.

        +
        +
        + + + +
        +
        + +
        + + {% if env_config_groups %} +
        + {% for group in env_config_groups %} + + {% endfor %} +
        + {% for group in env_config_groups %} + + {% endfor %} +
        +
        + {% for group in env_config_groups %} +
        + {% if group.has_restart %} +

        本组含需重启项,修改后请点「保存并重启」.

        + {% endif %} +
        + {% for field in group.fields %} +
        + + {% if field.note %} +
        {{ field.note }}
        + {% endif %} + {% if field.type == 'bool' %} + + {% elif field.sensitive %} + {% if field.has_value %} +
        已配置 {{ field.masked }}
        + {% endif %} + + {% else %} + + {% endif %} +
        + {% endfor %} +
        +
        + {% endfor %} +
        +
        + {% else %} +
        +
        加载配置中…
        +
        + {% endif %} +
        diff --git a/lib/instance/templates/force_close_header_badge.html b/lib/instance/templates/force_close_header_badge.html new file mode 100644 index 0000000..3442516 --- /dev/null +++ b/lib/instance/templates/force_close_header_badge.html @@ -0,0 +1,8 @@ +{% if force_close.enabled %} + + {{ force_close.label }} 已开启 · {{ force_close.countdown or '--:--:--' }} + +{% endif %} diff --git a/lib/instance/templates/force_close_order_badge.html b/lib/instance/templates/force_close_order_badge.html new file mode 100644 index 0000000..e82f63f --- /dev/null +++ b/lib/instance/templates/force_close_order_badge.html @@ -0,0 +1,8 @@ +{% if force_close.enabled %} + + {{ o.force_close_label or force_close.label }} + · {{ o.force_close_countdown or force_close.countdown or '--:--:--' }} + +{% endif %} diff --git a/lib/instance/templates/index.html b/lib/instance/templates/index.html new file mode 100644 index 0000000..b4989d2 --- /dev/null +++ b/lib/instance/templates/index.html @@ -0,0 +1,2025 @@ +{# 三所共用 standalone 主页 — 由 scripts/build_unified_index.py 生成,勿手改三所副本 #} + + + + + + + + + + + + + + + + + {{ pwa_app_name }} + + + + + +{% macro period_stats_pane(period_key, s) %} +{% set win_pct = s.win_rate_pct if s.win_rate_pct is not none else 0 %} +{% set profit_sum = (s.net_pnl_u + s.loss_sum_u) if s.closed_count else 0 %} +{% set loss_sum = s.loss_sum_u %} +{% set pnl_total = profit_sum + loss_sum %} +{% set profit_bar_w = (profit_sum / pnl_total * 100) if pnl_total > 0 else 0 %} +{% set loss_bar_w = (loss_sum / pnl_total * 100) if pnl_total > 0 else 0 %} +{% set net_cls = 'pos-pnl-profit' if s.net_pnl_u > 0 else ('pos-pnl-loss' if s.net_pnl_u < 0 else '') %} +
        +
        {{ s.range_label }}
        +
        + {% if s.closed_count %} +
        +
        + {% if s.net_pnl_u > 0 %}+{% endif %}{{ funds_fmt(s.net_pnl_u) }}U + 净盈亏 +
        +
        +
        + {% if s.win_rate_pct is not none %}{{ win_pct|round(0)|int }}%{% else %}—{% endif %} +
        + {{ s.win_count }}胜 {{ s.loss_count }}负 +
        +
        + {{ s.opens_count }} / {{ s.closed_count }} + 开单 / 平仓 +
        +
        +
        +
        盈亏构成
        +
        +
        +
        +
        +
        + 盈利 {{ funds_fmt(profit_sum) }}U + 亏损 {{ funds_fmt(loss_sum) }}U +
        +
        +
        +
        +
        + 最大回撤 + {{ funds_fmt(s.max_drawdown_u) }}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 %} +
        +
        +
        + {% else %} +

        当前区间暂无平仓数据

        + {% endif %} +
        +
        + 详细指标 +
        +
        开单次数
        {{ 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复盘一体化

        +
        +
        + + 关键位监控 + 实盘下单 + {% if not intraday_discipline and display.show_nav_strategy %} + 策略交易 + {% endif %} + {% if not intraday_discipline and display.show_nav_strategy_records %} + 策略交易记录 + {% endif %} + {% if display.show_nav_records %} + 交易记录与复盘 + {% endif %} + {% if display.show_nav_stats %} + 统计分析 + {% endif %} + {% if options_nav_visible and display.show_nav_options %} + 期权 + {% endif %} + {% if options_nav_visible and display.show_nav_options_review %} + 期权复盘 + {% endif %} + {% if hedge_plan_nav_visible and display.show_nav_hedge_plan %} + 对冲计划 + {% endif %} + {% if display.show_nav_risk_policy %} + 风控说明 + {% endif %} + {% if display.show_nav_env_config %} + env配置 + {% endif %} + 系统设置 +
        + {% with msg=get_flashed_messages() %}{% if msg %}
        {{ msg[0] }}
        {% endif %}{% endwith %} + + {% include 'instance_header_panel.html' %} + {% if page not in ('settings', 'risk_policy', 'env_config', 'options', 'options_review', 'hedge_plan') %} + {% include 'instance_top_bar.html' %} + {% endif %} + +
        + {% if page == 'dashboard' %} + {% include 'dashboard_panel.html' %} + {% elif page == 'key_monitor' %} + {% include 'key_monitor_panel.html' %} + {% elif page == 'trade' %} +
        +
        +
        +

        实盘下单监控

        + {% if focus_order_id %} + 放大查看K线(100根) + {% else %} + 暂无持仓可放大 + {% endif %} +
        + {% include order_rule_tips_tpl %} +
        + {% from 'trade_policy_fields.html' import trade_policy_symbol, trade_policy_direction with context %} + {{ trade_policy_symbol('symbol', 'order-symbol') }} + {{ trade_policy_direction('direction', 'order-direction') }} + + {% from 'order_entry_model_fields.html' import order_entry_type_fields with context %} + {{ order_entry_type_fields() }} + {% from 'order_leverage_fields.html' import order_leverage_fields with context %} + {{ order_leverage_fields() }} + {% if not intraday_discipline %} + + + + + + {% else %} + + {% endif %} + + {% from 'symbol_live_price_snippet.html' import symbol_live_price_hint %} + {{ symbol_live_price_hint('order-symbol-live-price', 'order-symbol', 'order-direction') }} + 下单成交价以交易所成交回报为准 + + + + + + +
        + {% include 'order_plan_preview_bar.html' %} +
        +
        +

        实时持仓

        + {% if ui_orphan_recovery_enabled %} + {% if not order and orphan_live_positions %} + {% set o = orphan_live_positions[0] %} +
        + 检测到交易所仍有 {{ o.symbol }} {{ '空' if o.direction == 'short' else '多' }}仓,但本地监控已中断(误同步时可能无交易记录). + {% if o.recoverable_monitor_id %} + + {% else %} + 未找到可恢复的监控记录,需在服务器数据库处理. + {% endif %} +
        + {% else %} + + {% endif %} + {% endif %} +
        + {% for o in order %} +
        +
        +
        + {{ o.exchange_symbol or o.symbol }} + {% if o.time_close_enabled %} + + 时间平仓 {{ o.time_close_hours or '' }}h + · --:--:-- + + {% endif %} + {% include 'force_close_order_badge.html' %} + {{ '做多' if o.direction == 'long' else '做空' }} +
        +
        + {% if not intraday_discipline %} + + 平仓 + {% endif %} +
        +
        +
        + 来源: {{ o.monitor_type|default('下单监控', true) }}{% if o.key_signal_type %} · {{ o.key_signal_type }}{% endif %} + {% if o.entry_model_label %}开仓: {{ o.entry_model_label }}{% elif intraday_discipline %}开仓: —{% else %}风格: {{ '波段单' if o.trade_style == 'swing' else '趋势单' }}{% endif %} + 风险: {% if position_sizing_mode == 'full_margin' %}{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U{% else %}{{ o.risk_percent or '-' }}%≈{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U{% endif %} + + + {% if intraday_discipline %} + {% elif 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) }} +
        +
        + 止损 + {{ price_fmt(o.symbol, o.stop_loss) if o.stop_loss else '—' }} +
        +
        + 止盈 + {{ price_fmt(o.symbol, o.take_profit) if o.take_profit else '—' }} +
        +
        + 盈亏比 + {% if o.rr_ratio is not none %}{{ '%g'|format(o.rr_ratio) }}:1{% else %}-:1{% endif %} +
        +
        + 张数 + {% if o.order_amount is not none %}{{ '%.2f'|format(o.order_amount) }}{% else %}—{% endif %} +
        +
        + 盈利金额 + +
        +
        + 标记价 + - +
        +
        + 浮盈亏 + - +
        +
        + +
        +
        交易所止盈止损
        +
        + 止损:加载中… + {% if not intraday_discipline %} + + {% endif %} +
        +
        + 止盈:加载中… + {% if not intraday_discipline %} + + {% endif %} +
        +
        +
        + {% else %} +
        暂无持仓
        + {% endfor %} +
        +
        + +
        +
        +

        挂止盈止损

        +

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

        +
        + +
        +
        + + +
        +
        + + +
        +
        + + +
        +
        +
        + +
        + {% elif page in ('strategy', 'strategy_trend', 'strategy_roll') %} + {% include 'strategy_trading_page.html' %} + {% elif page == 'strategy_records' %} + {% include 'strategy_records_page.html' %} + {% elif page == 'options' %} + {% include 'options_panel.html' %} + {% elif page == 'options_review' %} + {% include 'options_review_panel.html' %} + {% elif page == 'hedge_plan' %} + {% include 'hedge_plan_panel.html' %} + {% endif %} + + + + {% if page == 'records' %} + {% include 'records_panel.html' %} + {% endif %} + + {% if page == 'env_config' %} + {% include 'env_config_panel.html' %} + {% endif %} + + {% if page == 'risk_policy' %} + {% include 'risk_policy_panel.html' %} + {% endif %} + + {% if page == 'settings' %} + {% include 'settings_panel.html' %} + {% endif %} + + {% if page == 'stats' %} +
        +
        +

        数据统计

        + +
        +
        +
        + 统计分析按北京时间 {{ 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/lib/instance/templates/instance_header_panel.html b/lib/instance/templates/instance_header_panel.html new file mode 100644 index 0000000..842ae61 --- /dev/null +++ b/lib/instance/templates/instance_header_panel.html @@ -0,0 +1,40 @@ +{# 统一顶栏:状态 + 筛选(上)· 统计条(下) #} +
        +
        +
        + UTC {{ list_window.label }} + + + + + + + 统计切日 {{ stats_bundle.stats_reset_hour|default(reset_hour) }}:00 +
        +
        +
        +
        {{ exchange_display }}
        + {% if trade_policy.badge_text %} + {{ trade_policy.badge_text }} + {% endif %} + {% include 'force_close_header_badge.html' %} + {{ risk_status.status_label|default('正常') }} +
        + {% include 'instance_theme_toggle.html' %} +
        +
        +
        + {% include 'instance_header_stats.html' %} +
        +
        diff --git a/lib/instance/templates/instance_header_stats.html b/lib/instance/templates/instance_header_stats.html new file mode 100644 index 0000000..86b3416 --- /dev/null +++ b/lib/instance/templates/instance_header_stats.html @@ -0,0 +1,49 @@ +{# 资金与统计条(顶栏 / 系统设置共用,单行展示) #} +
        +
        +
        交易所
        +
        {{ exchange_display }}
        +
        +
        +
        交易日
        +
        {{ trading_day }}
        +
        +
        +
        总交易
        +
        {{ total }}
        +
        +
        +
        胜率
        +
        {{ rate }}%
        +
        +
        +
        盈亏比
        +
        {% if profit_loss_ratio is not none %}{{ profit_loss_ratio }}{% else %}—{% endif %}
        +
        +
        +
        总资金
        +
        {% if total_funds is not none %}{{ funds_fmt(total_funds) }}U{% else %}—{% endif %}
        +
        +
        +
        资金账户
        +
        {% if funding_usdt is not none %}{{ funds_fmt(funding_usdt) }}U{% else %}—{% endif %}
        +
        +
        +
        交易账户
        +
        {{ funds_fmt(current_capital) }}U
        +
        + {% if options_enabled %} +
        +
        期权资金账户
        +
        {{ options_funding_label(options_funding_usdc, options_funding_usdt) }}
        +
        +
        +
        期权交易账户
        +
        {{ options_funding_label(options_trading_usdc, options_trading_usdt) }}
        +
        + {% endif %} +
        +
        实时盈亏
        +
        +
        +
        diff --git a/lib/instance/templates/instance_theme_toggle.html b/lib/instance/templates/instance_theme_toggle.html new file mode 100644 index 0000000..5ed8615 --- /dev/null +++ b/lib/instance/templates/instance_theme_toggle.html @@ -0,0 +1,12 @@ +
        + + +
        diff --git a/lib/instance/templates/instance_top_bar.html b/lib/instance/templates/instance_top_bar.html new file mode 100644 index 0000000..97749ae --- /dev/null +++ b/lib/instance/templates/instance_top_bar.html @@ -0,0 +1,15 @@ +{# 三所统一顶栏:实时价 + 可选整点前开仓开关(划转已移至系统设置) #} +
        + 实时价格更新:--(北京时间 UTC+8) +
        +{% if ui_open_guard_enabled %} +
        + + + {% if open_guard_enabled %}已限制:{{ reset_hour }}:00 前不可开仓{% else %}已放开:{{ reset_hour }}:00 前允许开仓{% endif %} + +
        +{% endif %} diff --git a/lib/instance/templates/instance_transfer_panel.html b/lib/instance/templates/instance_transfer_panel.html new file mode 100644 index 0000000..2b7411f --- /dev/null +++ b/lib/instance/templates/instance_transfer_panel.html @@ -0,0 +1,25 @@ +{# 系统设置 · 资金划转(三所共用) #} +
        +

        + 自动划转 {{ '开启' if auto_transfer_enabled else '关闭' }}: + 每天北京时间 {{ auto_transfer_bj_hour }}:00 起该整点小时内尝试; + 账簿按 UTC 自然日 去重; + 将 {{ auto_transfer_to }} 调整至 {{ transfer_amount_fmt|default(funds_fmt(auto_transfer_amount)) }}U: + 不足从 {{ auto_transfer_from }} 划入,超出划回 {{ auto_transfer_from }}; + 持仓中不划转并微信通知. +

        +
        + + + + +
        +
        diff --git a/lib/instance/templates/login.html b/lib/instance/templates/login.html new file mode 100644 index 0000000..ef81ec9 --- /dev/null +++ b/lib/instance/templates/login.html @@ -0,0 +1,150 @@ + + + + + + + + + + + + + 登录 · {{ pwa_app_name }} + + + + + + + + diff --git a/lib/instance/templates/order_entry_model_fields.html b/lib/instance/templates/order_entry_model_fields.html new file mode 100644 index 0000000..0e42f9d --- /dev/null +++ b/lib/instance/templates/order_entry_model_fields.html @@ -0,0 +1,72 @@ +{# 趋势户:两级开仓类型 → 自动 trade_style;日内户:假破 / 结构突破 #} + +{% macro order_entry_type_fields() -%} + +{% if order_entry_profile == 'trend_div' %} + +
        + + + + + + + + 趋势单 + +
        + +{% elif order_entry_profile == 'intraday' %} + + + + + +{% else %} + + + +{% endif %} + +{%- endmacro %} + diff --git a/lib/instance/templates/order_leverage_fields.html b/lib/instance/templates/order_leverage_fields.html new file mode 100644 index 0000000..d59d22a --- /dev/null +++ b/lib/instance/templates/order_leverage_fields.html @@ -0,0 +1,7 @@ +{# 以损定仓:杠杆按币种默认(BTC/ETH 10x,其它 5x),不可选手输 #} +{% macro order_leverage_fields() -%} +{% if position_sizing_mode != 'full_margin' %} + +杠杆 — +{% endif %} +{%- endmacro %} diff --git a/lib/instance/templates/password_settings_panel.html b/lib/instance/templates/password_settings_panel.html new file mode 100644 index 0000000..80c72ec --- /dev/null +++ b/lib/instance/templates/password_settings_panel.html @@ -0,0 +1,13 @@ +{# 系统设置 · 账户密码(外层 card 由 settings_panel 提供) #} +

        账户密码修改

        +

        修改网页登录账号密码,写入 .env 后需重启实例生效.

        +
        + + + + +
        +
        + + +
        diff --git a/lib/instance/templates/records_panel.html b/lib/instance/templates/records_panel.html new file mode 100644 index 0000000..b7bed02 --- /dev/null +++ b/lib/instance/templates/records_panel.html @@ -0,0 +1,153 @@ +{# 三所共用:交易记录(5/页) → 填入复盘出表单 → 交易复盘记录 / AI历史复盘 #} + +
        +
        +

        交易记录

        +

        每页5条.点「填入复盘」打开下方复盘表单.

        +
        + +
        +
        + + + + + + + + + + + +
        品种下单类型开仓类型方向成交止损(开仓)止盈基数杠杆持仓分钟开仓时间(北京)平仓时间(北京)盈亏U结果操作
        加载中…
        +
        +
        + + 第 1 / 1 页 + +
        +
        + + + +
        +
        +

        AI复盘(按交易记录)

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

        交易复盘记录

        +

        已保存的复盘(每页5条).

        +
        +
        +
        +
        + + 第 1 / 1 页 + +
        +
        + +
        +

        AI历史复盘

        +

        日/周 AI 复盘历史(每页5条).

        +
        +
        +
        +
        + + 第 1 / 1 页 + +
        +
        +
        diff --git a/lib/instance/templates/risk_policy_panel.html b/lib/instance/templates/risk_policy_panel.html new file mode 100644 index 0000000..5f5d415 --- /dev/null +++ b/lib/instance/templates/risk_policy_panel.html @@ -0,0 +1,39 @@ +{# 风控说明:只读展示 .env 风控参数与当前账户状态 #} +
        +
        +

        风控说明

        +

        + 当前账户状态: + + {{ instance_settings.risk_status_label }} + + {% if instance_settings.risk_status_reason %} + {{ instance_settings.risk_status_reason }} + {% endif %} +

        + {% if instance_settings.trade_policy_note %} +

        账户限制:{{ instance_settings.trade_policy_note }}

        + {% endif %} +

        以下参数读取自本实例 .env,修改后需重启进程生效.

        +
        + {% for section in instance_settings.sections %} +
        +

        {{ section.title }}

        +
        + {% for row in section.rows %} +
        +
        {{ row.label }}
        +
        + {{ row.value }} + {% if row.note %} + {{ row.note }} + {% endif %} +
        +
        + {% endfor %} +
        +
        + {% endfor %} +
        +
        +
        diff --git a/lib/instance/templates/settings_panel.html b/lib/instance/templates/settings_panel.html new file mode 100644 index 0000000..c6e80a9 --- /dev/null +++ b/lib/instance/templates/settings_panel.html @@ -0,0 +1,54 @@ +{# 系统设置:CSS Tab(与 env 配置同方案) #} +
        +
        +

        系统设置

        +

        各区块说明见 docs/系统设置说明.md.

        +
        + + {% if settings_tabs %} +
        + {% for tab in settings_tabs %} + + {% endfor %} +
        + {% for tab in settings_tabs %} + + {% endfor %} +
        +
        + {% for tab in settings_tabs %} +
        + {% if tab.key == 'nav' %} + {% include 'display_prefs_panel.html' %} + {% elif tab.key == 'password' %} + {% include 'password_settings_panel.html' %} + {% elif tab.key == 'transfer' %} +

        永续资金划转

        +

        子账户永续:资金账户与交易账户之间划转 USDT.

        + {% include 'instance_transfer_panel.html' %} + {% elif tab.key == 'export' %} +

        数据导出

        +

        CSV · v{{ instance_settings.data_export_version }}

        + + {% elif tab.key == 'options_swap' %} +

        币种兑换

        + {% include 'options_settings_swap.html' %} + {% elif tab.key == 'options_transfer' %} +

        期权资金划转

        + {% include 'options_settings_transfer.html' %} + {% endif %} +
        + {% endfor %} +
        +
        + {% endif %} + + {% if instance_settings.options_settings_enabled %} + {% include 'options_settings_panel.html' %} + {% endif %} +
        diff --git a/lib/key_monitor/__init__.py b/lib/key_monitor/__init__.py new file mode 100644 index 0000000..ab164b5 --- /dev/null +++ b/lib/key_monitor/__init__.py @@ -0,0 +1 @@ +"""Shared library package.""" diff --git a/lib/key_monitor/false_breakout_key_monitor_lib.py b/lib/key_monitor/false_breakout_key_monitor_lib.py new file mode 100644 index 0000000..f6c2152 --- /dev/null +++ b/lib/key_monitor/false_breakout_key_monitor_lib.py @@ -0,0 +1,145 @@ +"""假突破关键位监控:BTC/ETH 限价挂单(共享计算与校验).""" +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Any, Optional + +FALSE_BREAKOUT_MONITOR_TYPE = "假突破" +FALSE_BREAKOUT_SYMBOLS = frozenset({"BTC/USDT", "ETH/USDT"}) +FALSE_BREAKOUT_OFFSET_PCT = 0.1 +FALSE_BREAKOUT_SL_PCT = 0.5 +FALSE_BREAKOUT_RR = 1.5 +FALSE_BREAKOUT_VALIDITY_HOURS = 24 + + +def is_false_breakout_key_monitor_type(monitor_type: Optional[str]) -> bool: + return (monitor_type or "").strip() == FALSE_BREAKOUT_MONITOR_TYPE + + +def is_limit_key_monitor_type(monitor_type: Optional[str]) -> bool: + from lib.key_monitor.fib_key_monitor_lib import is_fib_key_monitor_type + + return is_fib_key_monitor_type(monitor_type) or is_false_breakout_key_monitor_type(monitor_type) + + +def normalize_false_breakout_symbol(symbol: Optional[str]) -> Optional[str]: + s = (symbol or "").strip().upper() + if not s: + return None + if "/" not in s: + s = f"{s}/USDT" + return s if s in FALSE_BREAKOUT_SYMBOLS else None + + +def storage_bounds_from_key_price(direction: str, key_price: float) -> tuple[float, float]: + k = float(key_price) + if k <= 0: + raise ValueError("关键价位须为正数") + d = (direction or "long").strip().lower() + if d == "short": + return k, k * 0.9999 + if d == "long": + return k * 1.0001, k + raise ValueError("方向须为 long 或 short") + + +def key_price_from_row(direction: str, upper: Any, lower: Any) -> Optional[float]: + d = (direction or "long").strip().lower() + try: + if d == "short": + v = float(upper) + else: + v = float(lower) + except (TypeError, ValueError): + return None + return v if v > 0 else None + + +def calc_false_breakout_plan(direction: str, key_price: float) -> Optional[tuple[float, float, float]]: + try: + k = float(key_price) + except (TypeError, ValueError): + return None + if k <= 0: + return None + d = (direction or "long").strip().lower() + off = FALSE_BREAKOUT_OFFSET_PCT / 100.0 + sl_pct = FALSE_BREAKOUT_SL_PCT / 100.0 + rr = float(FALSE_BREAKOUT_RR) + if d == "short": + entry = k * (1 + off) + sl = entry * (1 + sl_pct) + risk = sl - entry + if risk <= 0: + return None + tp = entry - risk * rr + return entry, sl, tp + if d == "long": + entry = k * (1 - off) + sl = entry * (1 - sl_pct) + risk = entry - sl + if risk <= 0: + return None + tp = entry + risk * rr + return entry, sl, tp + return None + + +def _parse_created_at(raw: Any) -> Optional[datetime]: + s = str(raw or "").strip() + if not s: + return None + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S"): + try: + return datetime.strptime(s[:26], fmt) + except ValueError: + continue + try: + return datetime.fromisoformat(s.replace("Z", "+00:00")[:32]) + except ValueError: + return None + + +def is_false_breakout_expired( + created_at: Any, + now: datetime, + *, + hours: int = FALSE_BREAKOUT_VALIDITY_HOURS, +) -> bool: + dt = _parse_created_at(created_at) + if dt is None: + return False + return now >= dt + timedelta(hours=hours) + + +def expires_at_text(created_at: Any, *, hours: int = FALSE_BREAKOUT_VALIDITY_HOURS) -> str: + dt = _parse_created_at(created_at) + if dt is None: + return "—" + return (dt + timedelta(hours=hours)).strftime("%Y-%m-%d %H:%M:%S") + + +def false_breakout_gate_preview( + *, + entry_display: str, + limit_order_id: Any = None, + created_at: Any = None, + now: Optional[datetime] = None, + hours: int = FALSE_BREAKOUT_VALIDITY_HOURS, +) -> dict[str, Any]: + """假突破门控预览:限价挂单状态,不使用箱体/收敛的量破幅二确门控.""" + now_dt = now or datetime.now() + expired = is_false_breakout_expired(created_at, now_dt, hours=hours) + exp_txt = expires_at_text(created_at, hours=hours) + status = "已过期" if expired else "等待成交" + metrics_parts: list[str] = [] + oid = str(limit_order_id or "").strip() + if oid: + metrics_parts.append(f"限价单:{oid}") + if exp_txt != "—": + metrics_parts.append(f"截至:{exp_txt}") + return { + "summary": f"假突破 挂E={entry_display} {status}", + "metrics": " ".join(metrics_parts), + "gate_ok": not expired, + } diff --git a/lib/key_monitor/fib_key_monitor_lib.py b/lib/key_monitor/fib_key_monitor_lib.py new file mode 100644 index 0000000..2c0d1ff --- /dev/null +++ b/lib/key_monitor/fib_key_monitor_lib.py @@ -0,0 +1,140 @@ +"""斐波关键位监控:纯计算与类型判断(Gate / Binance 主站共用).""" + +from lib.key_monitor.key_monitor_lib import KEY_MONITOR_AUTO_TYPES + +FIB_KEY_MONITOR_TYPES = frozenset({"斐波回调0.618", "斐波回调0.786"}) +KEY_MONITOR_TRADE_TYPE = "关键位监控" + +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 + if mt in ("假突破", "回调触价开仓", "突破触价开仓", "触价开仓"): + return mt if mt != "触价开仓" else "回调触价开仓" + if mt in KEY_MONITOR_AUTO_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 kst in ("假突破", "回调触价开仓", "突破触价开仓", "触价开仓"): + return kst if kst != "触价开仓" else "回调触价开仓" + if box_auto_types and kst in box_auto_types: + return kst + return None + + +def backfill_missing_key_signal_types(conn, *, monitor_type: str = KEY_MONITOR_TRADE_TYPE) -> int: + """补全历史 trade_records / order_monitors 中缺失的箱体/收敛 key_signal_type.""" + mt = (monitor_type or KEY_MONITOR_TRADE_TYPE).strip() + updated = 0 + for signal in KEY_MONITOR_AUTO_TYPES: + entry_reason = KEY_ENTRY_REASON_BY_SIGNAL.get(signal) + if entry_reason: + cur = conn.execute( + """UPDATE trade_records SET key_signal_type=? + WHERE monitor_type=? AND (key_signal_type IS NULL OR TRIM(key_signal_type)='') + AND TRIM(COALESCE(entry_reason, ''))=?""", + (signal, mt, entry_reason), + ) + updated += int(cur.rowcount or 0) + rows = conn.execute( + """SELECT id, symbol, opened_at FROM trade_records + WHERE monitor_type=? AND (key_signal_type IS NULL OR TRIM(key_signal_type)='')""", + (mt,), + ).fetchall() + for row in rows: + # init_db 连接未设 row_factory,结果为 tuple + rid, sym, opened_at = row[0], row[1], row[2] + opened = (opened_at or "").strip() + for signal in KEY_MONITOR_AUTO_TYPES: + hist = conn.execute( + """SELECT monitor_type FROM key_monitor_history + WHERE symbol=? AND monitor_type=? AND close_reason='auto_opened' + AND (?='' OR closed_at <= ?) + ORDER BY closed_at DESC LIMIT 1""", + (sym, signal, opened, opened), + ).fetchone() + if not hist: + continue + conn.execute( + "UPDATE trade_records SET key_signal_type=? WHERE id=?", + (signal, rid), + ) + updated += 1 + break + return updated + + +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/lib/key_monitor/key_auto_order_lib.py b/lib/key_monitor/key_auto_order_lib.py new file mode 100644 index 0000000..9d46cb5 --- /dev/null +++ b/lib/key_monitor/key_auto_order_lib.py @@ -0,0 +1,164 @@ +"""关键位程序自动下单开关(三所共用,与 POSITION_SIZING_MODE 联动).""" +from __future__ import annotations + +import os +from typing import Any, Optional, Sequence, Tuple + +from lib.key_monitor.fib_key_monitor_lib import is_fib_key_monitor_type +from lib.key_monitor.false_breakout_key_monitor_lib import is_false_breakout_key_monitor_type +from lib.key_monitor.key_monitor_full_margin_lib import monitor_type_disallowed_in_full_margin +from lib.key_monitor.key_monitor_lib import KEY_MONITOR_AUTO_TYPES, KEY_MONITOR_RS_TYPES +from lib.key_monitor.trigger_entry_key_monitor_lib import is_trigger_entry_key_monitor_type +from lib.trade.position_sizing_lib import is_full_margin_mode + +KEY_ENTRY_REASON_OPTIONS: Tuple[str, ...] = ( + "关键位箱体突破", + "关键位收敛突破", + "关键位斐波0.618", + "关键位斐波0.786", + "关键位假突破", + "关键位回调触价开仓", + "关键位突破触价开仓", +) + +KEY_ENTRY_REASON_TRIGGER_OPTIONS: frozenset[str] = frozenset( + { + "关键位回调触价开仓", + "关键位突破触价开仓", + } +) + +KEY_STATS_SEGMENT_KEYS: frozenset[str] = frozenset( + { + "key_box", + "key_conv", + "key_fib618", + "key_fib786", + "key_false_breakout", + "key_trigger", + } +) + +KEY_STATS_TRIGGER_ONLY: frozenset[str] = frozenset({"key_trigger"}) + +TREND_MANUAL_ENTRY_REASON_COUNT = 5 + + +def _env_bool(raw: Optional[str], default: bool = False) -> bool: + if raw is None: + return default + return (raw or "").strip().lower() in ("1", "true", "yes", "on") + + +def load_key_auto_order_enabled(env: Optional[dict] = None) -> bool: + e = env if env is not None else os.environ + return _env_bool(e.get("KEY_AUTO_ORDER_ENABLED"), default=False) + + +def is_key_level_entry_reason(reason: str) -> bool: + return (reason or "").strip() in KEY_ENTRY_REASON_OPTIONS + + +def visible_key_entry_reasons(sizing_mode: str, key_auto_enabled: bool) -> Tuple[str, ...]: + if not key_auto_enabled: + return () + if is_full_margin_mode(sizing_mode): + return tuple(x for x in KEY_ENTRY_REASON_OPTIONS if x in KEY_ENTRY_REASON_TRIGGER_OPTIONS) + return KEY_ENTRY_REASON_OPTIONS + + +def effective_entry_reason_options( + all_options: Sequence[str], + sizing_mode: str, + key_auto_enabled: bool, + *, + trend_manual_count: int = TREND_MANUAL_ENTRY_REASON_COUNT, +) -> Tuple[str, ...]: + """复盘/表单下拉:按开关与计仓模式裁剪关键位开仓类型.""" + opts = list(all_options) + if len(opts) <= trend_manual_count: + return tuple(opts) + key_visible = set(visible_key_entry_reasons(sizing_mode, key_auto_enabled)) + out: list[str] = [] + for i, item in enumerate(opts): + if i < trend_manual_count: + out.append(item) + elif is_key_level_entry_reason(item): + if item in key_visible: + out.append(item) + else: + out.append(item) + return tuple(out) + + +def effective_stats_segment_defs( + segment_defs: Sequence[Tuple[str, str, Any]], + sizing_mode: str, + key_auto_enabled: bool, +) -> Tuple[Tuple[str, str, Any], ...]: + if not key_auto_enabled: + hidden = KEY_STATS_SEGMENT_KEYS + elif is_full_margin_mode(sizing_mode): + hidden = KEY_STATS_SEGMENT_KEYS - KEY_STATS_TRIGGER_ONLY + else: + hidden = frozenset() + return tuple(x for x in segment_defs if x[0] not in hidden) + + +def is_key_auto_monitor_type(monitor_type: str) -> bool: + mt = (monitor_type or "").strip() + if mt in KEY_MONITOR_AUTO_TYPES: + return True + if is_fib_key_monitor_type(mt): + return True + if is_false_breakout_key_monitor_type(mt): + return True + if is_trigger_entry_key_monitor_type(mt): + return True + return False + + +def is_rs_key_monitor_type(monitor_type: str) -> bool: + return (monitor_type or "").strip() in KEY_MONITOR_RS_TYPES + + +def check_monitor_type_add_allowed( + monitor_type: str, + sizing_mode: str, + key_auto_enabled: bool, +) -> Tuple[bool, str]: + mt = (monitor_type or "").strip() + if is_rs_key_monitor_type(mt): + return True, "" + if not key_auto_enabled: + return False, ( + "已关闭关键位程序自动单(KEY_AUTO_ORDER_ENABLED=false);" + "仅可添加「关键支撑阻力」(微信提醒,不下单)." + ) + if is_full_margin_mode(sizing_mode): + if monitor_type_disallowed_in_full_margin(mt): + return False, ( + "全仓杠杆模式下不可添加箱体/收敛突破,斐波或假突破监控;" + "可使用「回调/突破触价开仓」或关键支撑阻力(仅提醒)." + ) + if not is_key_auto_monitor_type(mt) and not is_rs_key_monitor_type(mt): + return False, "监控类型无效" + return True, "" + + +def key_auto_order_env_comment_lines() -> Tuple[str, ...]: + return ( + "# 关键位程序自动下单(与 POSITION_SIZING_MODE 联动,修改后须重启 PM2)", + "# 默认 false = 关闭所有关键位程序自动单(箱体/收敛/斐波/假突破/触价)", + "#", + "# POSITION_SIZING_MODE=risk(以损定仓)", + "# false → 不执行任何关键位自动单;支撑/阻力提醒,人工下单,顺势加仓不受影响", + "# true → 允许关键位全套自动(含触价)", + "#", + "# POSITION_SIZING_MODE=full_margin(全仓杠杆,须无仓切换)", + "# false → 不执行触价自动单", + "# true → 仅回调/突破触价可程序自动开仓;箱体/斐波等仍禁止", + "#", + "# 顺势加仓,趋势回调不受本开关控制;全仓模式下策略自动仍禁止.", + "KEY_AUTO_ORDER_ENABLED=false", + ) diff --git a/lib/key_monitor/key_monitor_full_margin_lib.py b/lib/key_monitor/key_monitor_full_margin_lib.py new file mode 100644 index 0000000..724ea78 --- /dev/null +++ b/lib/key_monitor/key_monitor_full_margin_lib.py @@ -0,0 +1,61 @@ +""" +全仓杠杆模式下:撤销已添加的箱体/收敛/斐波关键位监控并微信说明. +""" +from __future__ import annotations + +from typing import Any, Callable, Iterable, Optional + +from lib.key_monitor.fib_key_monitor_lib import FIB_KEY_MONITOR_TYPES, is_fib_key_monitor_type +from lib.key_monitor.false_breakout_key_monitor_lib import is_false_breakout_key_monitor_type +from lib.key_monitor.key_monitor_lib import KEY_MONITOR_AUTO_TYPES +from lib.trade.position_sizing_lib import is_full_margin_mode, mode_label_zh + + +def monitor_type_disallowed_in_full_margin(monitor_type: str) -> bool: + mt = (monitor_type or "").strip() + if mt in KEY_MONITOR_AUTO_TYPES: + return True + if is_fib_key_monitor_type(mt): + return True + return is_false_breakout_key_monitor_type(mt) + + +def purge_disallowed_key_monitors( + conn: Any, + *, + sizing_mode: str, + select_rows: Callable[[Any], Iterable[Any]], + cancel_fib_limit: Callable[[Any], None], + delete_monitor: Callable[[Any, int], None], + send_wechat: Callable[[str], None], + row_symbol: Callable[[Any], str] = lambda r: str(r["symbol"] or ""), + row_monitor_type: Callable[[Any], str] = lambda r: str(r["monitor_type"] or ""), + row_id: Callable[[Any], int] = lambda r: int(r["id"]), +) -> int: + if not is_full_margin_mode(sizing_mode): + return 0 + removed = [] + for row in select_rows(conn): + mt = row_monitor_type(row) + if not monitor_type_disallowed_in_full_margin(mt): + continue + sym = row_symbol(row) + kid = row_id(row) + if is_fib_key_monitor_type(mt) or is_false_breakout_key_monitor_type(mt): + try: + cancel_fib_limit(row) + except Exception: + pass + delete_monitor(conn, kid) + removed.append((sym, mt, kid)) + if removed: + lines = [f"· {s} {t} (#{i})" for s, t, i in removed[:12]] + if len(removed) > 12: + lines.append(f"… 共 {len(removed)} 条") + send_wechat( + "# ⚠️ 全仓杠杆模式:已自动撤销关键位监控\n" + f"计仓模式:{mode_label_zh(sizing_mode)}(仅 env 可切换,须无仓)\n" + "已撤销:箱体突破 / 收敛突破 / 斐波回调 / 假突破监控(不可与全仓杠杆并存)\n" + + "\n".join(lines) + ) + return len(removed) diff --git a/lib/key_monitor/key_monitor_lib.py b/lib/key_monitor/key_monitor_lib.py new file mode 100644 index 0000000..17ab5f2 --- /dev/null +++ b/lib/key_monitor/key_monitor_lib.py @@ -0,0 +1,390 @@ +""" +关键位监控:阻力/支撑双向提醒与箱体/收敛自动门控的共享逻辑. +""" +from __future__ import annotations + +from datetime import datetime +from typing import Any, Optional + +KEY_MONITOR_AUTO_TYPES = frozenset({"箱体突破", "收敛突破"}) +KEY_MONITOR_RS_TYPE = "关键支撑阻力" +KEY_MONITOR_RS_LEGACY_TYPES = frozenset({"关键阻力位", "关键支撑位"}) +KEY_MONITOR_RS_TYPES = frozenset({KEY_MONITOR_RS_TYPE}) | KEY_MONITOR_RS_LEGACY_TYPES +KEY_MONITOR_ALERT_ONLY_TYPES = frozenset({KEY_MONITOR_RS_TYPE}) | KEY_MONITOR_RS_LEGACY_TYPES +KEY_DIRECTION_WATCH = "watch" + + +def is_rs_key_monitor_type(monitor_type: str) -> bool: + return (monitor_type or "").strip() in KEY_MONITOR_RS_TYPES + + +def rs_monitor_type_label(monitor_type: str) -> str: + """展示用:旧库里的阻力/支撑合并为「关键支撑阻力」.""" + if is_rs_key_monitor_type(monitor_type): + return KEY_MONITOR_RS_TYPE + return (monitor_type or "").strip() + + +def rs_monitor_type_for_storage(monitor_type: str) -> str: + if is_rs_key_monitor_type(monitor_type): + return KEY_MONITOR_RS_TYPE + return (monitor_type or "").strip() + + +def calc_breakout_breach_pct(direction: str, close: float, upper: float, lower: float) -> float: + """突破 K 收盘相对关键位的越过幅度(%).未越过对应边界时返回 0.""" + direction = (direction or "long").strip().lower() + c = float(close) + if direction == "long": + u = float(upper) + if u <= 0 or c <= u: + return 0.0 + return (c - u) / u * 100.0 + lo = float(lower) + if lo <= 0 or c >= lo: + return 0.0 + return (lo - c) / lo * 100.0 + + +def auto_amp_ok( + direction: str, + close_b: float, + upper: float, + lower: float, + min_pct: float, +) -> tuple[bool, float]: + breach = calc_breakout_breach_pct(direction, close_b, upper, lower) + return breach > float(min_pct), breach + + +def auto_confirm_ok(direction: str, cfm_close: float, upper: float, lower: float) -> bool: + """确认 K 收盘须在箱体外(不得回到 [lower, upper] 内).""" + direction = (direction or "long").strip().lower() + c = float(cfm_close) + if direction == "long": + return c > float(upper) + return c < float(lower) + + +BOX_BREAKOUT_CLOSE_OPPOSITE = "box_opposite_break" + + +def box_breakout_invalidate_by_mark( + direction: str, mark_price: float, upper: float, lower: float +) -> bool: + """箱体/收敛:标记价先突破反向边界则失效.多:mark<=L;空:mark>=H.""" + try: + m = float(mark_price) + h = float(upper) + lo = float(lower) + except (TypeError, ValueError): + return False + direction = (direction or "long").strip().lower() + if direction == "short": + return m >= h + return m <= lo + + +def box_breakout_invalidate_edge_label(direction: str) -> str: + direction = (direction or "long").strip().lower() + return "下沿" if direction == "long" else "上沿" + + +def detect_rs_box_break(close: float, upper: float, lower: float) -> Optional[dict[str, Any]]: + """ + 阻力/支撑人工盯盘:最近 5m 收盘突破上沿或下沿(严格 > / <). + 上沿优先:同一根 K 不可能同时满足两者. + """ + u, lo, c = float(upper), float(lower), float(close) + if c > u: + return { + "break_side": "upper", + "direction": "long", + "edge_price": u, + "key_price": u, + "break_label": "向上突破上沿", + } + if c < lo: + return { + "break_side": "lower", + "direction": "short", + "edge_price": lo, + "key_price": lo, + "break_label": "向下突破下沿", + } + return None + + +def rs_break_from_direction(direction: str, upper: float, lower: float) -> Optional[dict[str, Any]]: + """已触发后根据入库方向还原突破边(long=上沿,short=下沿).""" + d = (direction or "").strip().lower() + if d == "long": + return { + "break_side": "upper", + "direction": "long", + "edge_price": float(upper), + "key_price": float(upper), + "break_label": "向上突破上沿", + } + if d == "short": + return { + "break_side": "lower", + "direction": "short", + "edge_price": float(lower), + "key_price": float(lower), + "break_label": "向下突破下沿", + } + return None + + +def rs_break_infer_from_close(close: float, upper: float, lower: float) -> dict[str, Any]: + """ + 续发提醒时价格已回到箱体内:按收盘价相对箱体中线推断首次突破边, + 保证第 2/3 次企业微信提醒仍能发出. + """ + mid = (float(upper) + float(lower)) / 2.0 + if float(close) >= mid: + br = rs_break_from_direction("long", upper, lower) + else: + br = rs_break_from_direction("short", upper, lower) + if br: + return br + return { + "break_side": "upper", + "direction": "long", + "edge_price": float(upper), + "key_price": float(upper), + "break_label": "向上突破上沿", + } + + +def _parse_notify_datetime(raw: Optional[str]) -> Optional[datetime]: + s = str(raw or "").strip() + if not s: + return None + try: + dt = datetime.fromisoformat(s.replace("Z", "+00:00")) + if dt.tzinfo is not None: + dt = dt.replace(tzinfo=None) + return dt + except Exception: + pass + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"): + try: + return datetime.strptime(s[:19], fmt) + except Exception: + continue + return None + + +def claim_rs_level_notify( + conn: Any, + monitor_id: int, + notify_index: int, + direction: str, + notified_at: str, + bar_ts: Optional[int], + *, + prior_count: Optional[int] = None, +) -> bool: + """ + 原子占位:仅在 notification_count 仍为 prior_count 时推进到 notify_index. + 须在发送企业微信之前调用并 commit,避免 (2/3) 重复刷屏. + """ + prior = int(prior_count if prior_count is not None else notify_index - 1) + if prior < 0 or notify_index != prior + 1: + return False + bar_val: Optional[int] = None + if bar_ts is not None: + try: + bar_val = int(bar_ts) + except (TypeError, ValueError): + bar_val = None + cur = conn.execute( + "UPDATE key_monitors SET notification_count=?, direction=?, last_notified_at=?, last_rs_bar_ts=? " + "WHERE id=? AND COALESCE(notification_count,0)=?", + (notify_index, direction, notified_at, bar_val, int(monitor_id), prior), + ) + return int(cur.rowcount or 0) > 0 + + +def parse_last_rs_bar_ts(row: Any) -> Optional[int]: + if row is None: + return None + try: + keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + keys = [] + raw = row["last_rs_bar_ts"] if "last_rs_bar_ts" in keys else None + if raw is None: + return None + try: + return int(raw) + except (TypeError, ValueError): + return None + + +def run_rs_level_alert_tick( + row: Any, + close: float, + bar_ts: Optional[int], + now_dt: datetime, + *, + default_max_notify: int, + default_interval_min: int, +) -> Optional[dict[str, Any]]: + """ + 判定本轮回合是否应推送阻力/支撑提醒. + 首条:仅在新闭合 K 越线时触发;发送前须 claim_rs_level_notify 占位防轮询/多进程重复. + """ + up, lo = float(row["upper"]), float(row["lower"]) + if up <= lo: + return None + count = int(row["notification_count"] or 0) + max_n = max(1, int(row["max_notify"] or default_max_notify)) + interval = max(1, int(row["notify_interval_min"] or default_interval_min)) + if count >= max_n: + return None + + bar_ts_i: Optional[int] = None + if bar_ts is not None: + try: + bar_ts_i = int(bar_ts) + except (TypeError, ValueError): + bar_ts_i = None + last_bar_i = parse_last_rs_bar_ts(row) + + if count == 0: + br = detect_rs_box_break(close, up, lo) + if not br: + return None + if bar_ts_i is not None and last_bar_i is not None and bar_ts_i == last_bar_i: + return None + return { + "break_info": br, + "notify_index": 1, + "prior_count": 0, + "notify_max": max_n, + "interval_min": interval, + "bar_ts": bar_ts_i, + } + + if not notify_interval_elapsed(row["last_notified_at"], interval, now_dt): + return None + br = resolve_rs_break_for_alert(count, row["direction"], close, up, lo) + if not br: + return None + return { + "break_info": br, + "notify_index": count + 1, + "prior_count": count, + "notify_max": max_n, + "interval_min": interval, + "bar_ts": bar_ts_i, + } + + +def resolve_rs_break_for_alert( + notification_count: int, + direction: Optional[str], + close: float, + upper: float, + lower: float, +) -> Optional[dict[str, Any]]: + """ + 阻力/支撑提醒:首次用 5m 收盘越线判定;后续用已存方向,兼容 direction=watch. + """ + count = int(notification_count or 0) + up, lo, c = float(upper), float(lower), float(close) + if count <= 0: + return detect_rs_box_break(c, up, lo) + br = rs_break_from_direction(direction, up, lo) + if br: + return br + d = (direction or "").strip().lower() + if d not in ("", KEY_DIRECTION_WATCH): + return None + br = detect_rs_box_break(c, up, lo) + if br: + return br + return rs_break_infer_from_close(c, up, lo) + + +def notify_interval_elapsed( + last_notified_at: Optional[str], + interval_min: int, + now_dt: datetime, +) -> bool: + if not last_notified_at: + return False + last_dt = _parse_notify_datetime(last_notified_at) + if last_dt is None: + return False + return (now_dt - last_dt).total_seconds() >= max(1, int(interval_min)) * 60 + + +def format_auto_amp_line(amp_ok: bool, amp_pct: float, min_pct: float) -> str: + return ( + f"突破越过幅度:{'通过' if amp_ok else '不通过'}" + f"({round(float(amp_pct), 4)}%,要求 > {min_pct}%)" + ) + + +def format_auto_confirm_line(confirm_ok: bool, cfm_close, edge_price, direction: str) -> str: + side = "箱外上方" if (direction or "").lower() == "long" else "箱外下方" + return ( + f"第二根确认:{'通过' if confirm_ok else '不通过'}" + f"(确认收盘 {cfm_close},须收于{side},关键位 {edge_price})" + ) + + +def key_monitor_rule_template_context( + *, + kline_timeframe: str, + key_breakout_amp_min_pct: float, + key_volume_ma_bars: int, + key_volume_ratio_min: float, + key_auto_min_planned_rr: float, + key_daily_volume_rank_max: int, + key_confirm_breakout_bar: int, + key_confirm_bar: int, + key_alert_max_times: int, + key_alert_interval_minutes: int, + key_stop_outside_breakout_pct: float, + key_trend_stop_outside_pct: float, + false_breakout_validity_hours: int, + trigger_entry_validity_hours: int | None = None, +) -> dict[str, Any]: + """关键位监控页规则说明表格(Jinja key_rule_ctx).""" + from lib.key_monitor.false_breakout_key_monitor_lib import ( + FALSE_BREAKOUT_OFFSET_PCT, + FALSE_BREAKOUT_RR, + FALSE_BREAKOUT_SL_PCT, + ) + from lib.key_monitor.trigger_entry_key_monitor_lib import TRIGGER_ENTRY_VALIDITY_HOURS + + te_hours = ( + int(trigger_entry_validity_hours) + if trigger_entry_validity_hours is not None + else TRIGGER_ENTRY_VALIDITY_HOURS + ) + + return { + "tf": (kline_timeframe or "5m").strip(), + "amp_min_pct": key_breakout_amp_min_pct, + "vol_ma_bars": key_volume_ma_bars, + "vol_ratio_min": key_volume_ratio_min, + "min_rr": key_auto_min_planned_rr, + "vol_rank_max": key_daily_volume_rank_max, + "breakout_bar": key_confirm_breakout_bar, + "confirm_bar": key_confirm_bar, + "alert_max": key_alert_max_times, + "alert_interval_min": key_alert_interval_minutes, + "stop_outside_pct": key_stop_outside_breakout_pct, + "trend_stop_outside_pct": key_trend_stop_outside_pct, + "fb_offset_pct": FALSE_BREAKOUT_OFFSET_PCT, + "fb_sl_pct": FALSE_BREAKOUT_SL_PCT, + "fb_rr": FALSE_BREAKOUT_RR, + "fb_valid_hours": false_breakout_validity_hours, + "trigger_entry_validity_hours": te_hours, + } diff --git a/lib/key_monitor/key_monitor_schema_lib.py b/lib/key_monitor/key_monitor_schema_lib.py new file mode 100644 index 0000000..9e64637 --- /dev/null +++ b/lib/key_monitor/key_monitor_schema_lib.py @@ -0,0 +1,15 @@ +"""关键位监控表结构迁移(三所共用).""" +from __future__ import annotations + +from typing import Any + + +def ensure_key_monitor_schema(conn: Any) -> None: + for sql in ( + "ALTER TABLE key_monitors ADD COLUMN last_mark_price REAL", + "ALTER TABLE key_monitors ADD COLUMN last_alert_message TEXT", + ): + try: + conn.execute(sql) + except Exception: + pass diff --git a/lib/key_monitor/key_sl_tp_lib.py b/lib/key_monitor/key_sl_tp_lib.py new file mode 100644 index 0000000..0704694 --- /dev/null +++ b/lib/key_monitor/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/lib/key_monitor/trigger_entry_key_monitor_lib.py b/lib/key_monitor/trigger_entry_key_monitor_lib.py new file mode 100644 index 0000000..7c67aa7 --- /dev/null +++ b/lib/key_monitor/trigger_entry_key_monitor_lib.py @@ -0,0 +1,324 @@ +"""回调/突破触价开仓关键位监控:程序盯价,触达计划入场后市价成交(三所共用逻辑).""" +from __future__ import annotations + +from datetime import datetime +from typing import Any, Callable, Optional + +from lib.key_monitor.false_breakout_key_monitor_lib import ( + _parse_created_at, + expires_at_text, + is_false_breakout_expired, +) +from lib.strategy.strategy_trend_lib import trend_dca_level_reached + +# 回调触价(原「触价开仓」) +CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE = "回调触价开仓" +LEGACY_TRIGGER_ENTRY_MONITOR_TYPE = "触价开仓" + +# 突破触价:标记价穿越 E 后立即市价开仓 +BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE = "突破触价开仓" + +TRIGGER_ENTRY_MONITOR_TYPES = frozenset( + { + CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE, + BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE, + LEGACY_TRIGGER_ENTRY_MONITOR_TYPE, + } +) + +TRIGGER_ENTRY_VALIDITY_HOURS = 24 +TRIGGER_ENTRY_CLOSE_FILLED = "trigger_entry_filled" +TRIGGER_ENTRY_CLOSE_TP_INVALIDATE = "trigger_tp_invalidate" +TRIGGER_ENTRY_CLOSE_SL_INVALIDATE = "trigger_sl_invalidate" +TRIGGER_ENTRY_CLOSE_EXPIRED = "trigger_entry_expired" +TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED = "trigger_exchange_failed" + +KEY_ENTRY_REASON_CALLBACK = "关键位回调触价开仓" +KEY_ENTRY_REASON_BREAKOUT = "关键位突破触价开仓" +KEY_ENTRY_REASON_TRIGGER_LEGACY = "关键位触价开仓" + +TRIGGER_ENTRY_IN_FLIGHT_OID = "__trigger_entry_in_flight__" + + +def is_trigger_entry_in_flight_row(row: Any) -> bool: + if row is None: + return False + try: + v = row["fib_limit_order_id"] + except (KeyError, IndexError, TypeError): + v = getattr(row, "fib_limit_order_id", None) + return (v or "").strip() == TRIGGER_ENTRY_IN_FLIGHT_OID + + +def acquire_trigger_entry_exec_lock(conn: Any, monitor_id: int) -> bool: + cur = conn.execute( + "UPDATE key_monitors SET fib_limit_order_id=? WHERE id=? " + "AND (fib_limit_order_id IS NULL OR fib_limit_order_id='')", + (TRIGGER_ENTRY_IN_FLIGHT_OID, int(monitor_id)), + ) + return int(cur.rowcount or 0) == 1 + + +def release_trigger_entry_exec_lock(conn: Any, monitor_id: int) -> None: + conn.execute( + "UPDATE key_monitors SET fib_limit_order_id=NULL WHERE id=? AND fib_limit_order_id=?", + (int(monitor_id), TRIGGER_ENTRY_IN_FLIGHT_OID), + ) + + +def normalize_trigger_entry_monitor_type(monitor_type: Optional[str]) -> str: + mt = (monitor_type or "").strip() + if mt == LEGACY_TRIGGER_ENTRY_MONITOR_TYPE: + return CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE + return mt + + +def is_trigger_entry_key_monitor_type(monitor_type: Optional[str]) -> bool: + return (monitor_type or "").strip() in TRIGGER_ENTRY_MONITOR_TYPES + + +def is_callback_trigger_entry_key_monitor_type(monitor_type: Optional[str]) -> bool: + mt = normalize_trigger_entry_monitor_type(monitor_type) + return mt == CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE + + +def is_breakout_trigger_entry_key_monitor_type(monitor_type: Optional[str]) -> bool: + return (monitor_type or "").strip() == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE + + +def key_entry_reason_for_monitor_type(monitor_type: Optional[str]) -> str: + if is_breakout_trigger_entry_key_monitor_type(monitor_type): + return KEY_ENTRY_REASON_BREAKOUT + if is_trigger_entry_key_monitor_type(monitor_type): + return KEY_ENTRY_REASON_CALLBACK + return KEY_ENTRY_REASON_TRIGGER_LEGACY + + +def trigger_entry_reached(direction: str, mark_price: float, entry: float) -> bool: + """回调触价:多=价跌至 E;空=价涨至 E.""" + return trend_dca_level_reached(direction, mark_price, entry) + + +def breakout_trigger_entry_crossed( + direction: str, + prev_mark: Optional[float], + mark: float, + entry: float, +) -> bool: + """突破触价:多=向上穿越 E;空=向下穿越 E.""" + try: + m = float(mark) + e = float(entry) + pm = float(prev_mark) if prev_mark is not None else None + except (TypeError, ValueError): + return False + direction = (direction or "long").strip().lower() + if direction == "long": + if pm is None: + return m > e + return pm <= e and m > e + if pm is None: + return m < e + return pm >= e and m < e + + +def trigger_should_fire( + monitor_type: Optional[str], + direction: str, + mark: float, + entry: float, + prev_mark: Optional[float] = None, +) -> bool: + if is_breakout_trigger_entry_key_monitor_type(monitor_type): + return breakout_trigger_entry_crossed(direction, prev_mark, mark, entry) + return trigger_entry_reached(direction, mark, entry) + + +def trigger_entry_invalidate_by_tp(direction: str, mark_price: float, take_profit: float) -> bool: + """未开仓前标记价先触达止盈侧则失效.""" + try: + m = float(mark_price) + tp = float(take_profit) + except (TypeError, ValueError): + return False + d = (direction or "long").strip().lower() + if d == "short": + return m <= tp + return m >= tp + + +def trigger_entry_invalidate_by_sl(direction: str, mark_price: float, stop_loss: float) -> bool: + """突破触价:未到 E 先触达止损侧则失效.""" + try: + m = float(mark_price) + sl = float(stop_loss) + except (TypeError, ValueError): + return False + d = (direction or "long").strip().lower() + if d == "long": + return m <= sl + return m >= sl + + +def trigger_entry_invalidate( + monitor_type: Optional[str], + direction: str, + mark: float, + stop_loss: float, + take_profit: float, +) -> Optional[str]: + if trigger_entry_invalidate_by_tp(direction, mark, take_profit): + return "tp" + if is_breakout_trigger_entry_key_monitor_type(monitor_type): + if trigger_entry_invalidate_by_sl(direction, mark, stop_loss): + return "sl" + return None + + +def validate_trigger_entry_geometry( + direction: str, + entry: float, + stop_loss: float, + take_profit: float, + mark_at_add: Optional[float] = None, + *, + monitor_type: Optional[str] = None, +) -> Optional[str]: + """返回错误文案;合法则 None.""" + try: + e = float(entry) + sl = float(stop_loss) + tp = float(take_profit) + except (TypeError, ValueError): + return "入场价,止损,止盈须为有效数字" + if e <= 0 or sl <= 0 or tp <= 0: + return "入场价,止损,止盈须大于 0" + d = (direction or "long").strip().lower() + mt = normalize_trigger_entry_monitor_type(monitor_type) + label = "突破触价开仓" if mt == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE else "回调触价开仓" + if d == "long": + if not (sl < e < tp): + return "做多:须满足 止损 < 入场价 < 止盈" + if mark_at_add is not None: + m = float(mark_at_add) + if m >= tp: + return f"做多:当前价已不低于止盈,无法添加{label}" + if mt == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE and m >= e: + return "做多:当前价须低于入场价(等待向上突破)" + elif d == "short": + if not (tp < e < sl): + return "做空:须满足 止盈 < 入场价 < 止损" + if mark_at_add is not None: + m = float(mark_at_add) + if m <= tp: + return f"做空:当前价已不高于止盈,无法添加{label}" + if mt == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE and m <= e: + return "做空:当前价须高于入场价(等待向下跌破)" + else: + return "方向须为 long 或 short" + return None + + +def validate_trigger_entry_rr( + direction: str, + entry: float, + stop_loss: float, + take_profit: float, + min_rr: float, + calc_rr_ratio: Callable[..., Optional[float]], +) -> Optional[str]: + rr = calc_rr_ratio(direction, entry, stop_loss, take_profit) + if rr is None or rr <= float(min_rr): + fmt = f"{rr:.4f}" if rr is not None else "无法计算" + return f"计划盈亏比 {fmt}:1 未达要求(>{float(min_rr)}:1)" + return None + + +def is_trigger_entry_expired( + created_at: Any, + now: datetime, + *, + hours: int = TRIGGER_ENTRY_VALIDITY_HOURS, +) -> bool: + return is_false_breakout_expired(created_at, now, hours=hours) + + +def trigger_entry_expires_at_text( + created_at: Any, + *, + hours: int = TRIGGER_ENTRY_VALIDITY_HOURS, +) -> str: + return expires_at_text(created_at, hours=hours) + + +def count_pending_trigger_entries(conn: Any, trading_day: str) -> int: + td = (trading_day or "").strip() + if not td: + return 0 + placeholders = ",".join("?" * len(TRIGGER_ENTRY_MONITOR_TYPES)) + row = conn.execute( + f"SELECT COUNT(*) FROM key_monitors WHERE monitor_type IN ({placeholders}) AND session_date=?", + (*TRIGGER_ENTRY_MONITOR_TYPES, td), + ).fetchone() + return int(row[0] if row else 0) + + +def check_trigger_entry_intent_limit( + conn: Any, + trading_day: str, + opens_today: int, + hard_limit: int, +) -> tuple[bool, str]: + """当日开仓意图:已成交次数 + 待触发触价条数.""" + if int(hard_limit) <= 0: + return True, "" + pending = count_pending_trigger_entries(conn, trading_day) + total = int(opens_today) + pending + if total >= int(hard_limit): + return ( + False, + f"本交易日开仓意图已达上限(已开 {int(opens_today)} + 待触发 {pending} / 硬上限 {int(hard_limit)})", + ) + return True, "" + + +def trigger_entry_gate_preview( + *, + monitor_type: Optional[str] = None, + entry_display: str, + take_profit_display: str, + created_at: Any = None, + now: Optional[datetime] = None, + expired: bool = False, + tp_invalidated: bool = False, + sl_invalidated: bool = False, + hours: int = TRIGGER_ENTRY_VALIDITY_HOURS, +) -> dict[str, Any]: + now_dt = now or datetime.now() + is_exp = expired or is_trigger_entry_expired(created_at, now_dt, hours=hours) + exp_txt = trigger_entry_expires_at_text(created_at, hours=hours) + mt = normalize_trigger_entry_monitor_type(monitor_type) + if tp_invalidated: + status = "止盈侧失效" + elif sl_invalidated: + status = "止损侧失效" + elif is_exp: + status = "已过期" + elif mt == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE: + status = "突破待触发" + else: + status = "回调待触发" + mode = "突破" if mt == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE else "回调" + metrics_parts: list[str] = [f"TP:{take_profit_display}"] + if exp_txt != "—": + metrics_parts.append(f"截至:{exp_txt}") + return { + "summary": f"{mode}触价 E={entry_display} {status}", + "metrics": " ".join(metrics_parts), + "gate_ok": not is_exp and not tp_invalidated and not sl_invalidated, + } + + +# 兼容旧 import +TRIGGER_ENTRY_MONITOR_TYPE = CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE +KEY_ENTRY_REASON_TRIGGER = KEY_ENTRY_REASON_CALLBACK diff --git a/lib/options/__init__.py b/lib/options/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lib/options/options_close_exec_lib.py b/lib/options/options_close_exec_lib.py new file mode 100644 index 0000000..59e2cd0 --- /dev/null +++ b/lib/options/options_close_exec_lib.py @@ -0,0 +1,380 @@ +"""期权平仓执行:只锁买一限价卖出;永不市价.""" +from __future__ import annotations + +import time +from typing import Any + +from lib.options.options_close_gate_lib import ( + clear_close_gate, + is_close_gate_passed, + mark_close_gate_passed, + update_close_gate, +) +from lib.options.options_pricing_lib import ( + estimate_close_by_bids, + fetch_option_mark_px, + is_stub_bid_px, + total_premium, +) + + +def _safe_float(v: Any) -> float | None: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def _open_premium_paid(cfg: dict[str, Any], inst_id: str) -> float | None: + try: + conn = cfg["get_db"]() + try: + from lib.options.options_db import init_options_tables, sum_open_premium_paid + + init_options_tables(conn) + return sum_open_premium_paid(conn, inst_id) + finally: + conn.close() + except Exception: + pass + return None + + +def _pos_close_refs(ex: Any, pos: dict[str, Any], quote: dict[str, Any] | None = None) -> tuple[float | None, float | None]: + from lib.exchange.okx_options_lib import option_fields_from_inst_id + from lib.options.options_pricing_lib import close_ref_prices + + inst_id = str(pos.get("instId") or pos.get("inst_id") or "") + mark = _safe_float(pos.get("markPx")) or _safe_float((quote or {}).get("mark_px") or (quote or {}).get("mark")) + if mark is None: + mark = fetch_option_mark_px(ex, inst_id) + opt_type = pos.get("optType") or (quote or {}).get("opt_type") + strike = _safe_float(pos.get("stk")) or _safe_float((quote or {}).get("strike")) + if not opt_type or strike is None: + pt, ps = option_fields_from_inst_id(inst_id) + opt_type = opt_type or pt + if strike is None: + strike = ps + idx = _safe_float(pos.get("idxPx")) or _safe_float((quote or {}).get("index_px")) + return close_ref_prices(mark_px=mark, opt_type=str(opt_type or ""), strike=strike, index_px=idx) + + +def _avail_sheets(pos: dict[str, Any]) -> int: + avail = _safe_float(pos.get("availPos")) + if avail is None or avail <= 0: + avail = abs(_safe_float(pos.get("pos")) or 0) + return max(0, int(avail or 0)) + + +def _cancel_sell_pending(ex: Any, inst_id: str) -> None: + try: + pending = ex.private_get_trade_orders_pending({"instType": "OPTION", "instId": inst_id}) or {} + for o in pending.get("data") or []: + if str(o.get("side") or "").lower() != "sell": + continue + oid = o.get("ordId") + if not oid: + continue + try: + ex.private_post_trade_cancel_order({"instId": inst_id, "ordId": oid}) + except Exception: + pass + except Exception: + pass + + +def close_option_by_bid1( + cfg: dict[str, Any], + ex: Any, + inst_id: str, + *, + sheets: int | None = None, + require_recycle_gate: bool = False, + signal_note: str | None = None, +) -> dict[str, Any]: + """ + 本轮只吃买一深度: + - 本批张数 = min(请求张数, 持仓, 买一深度) + - 限价 = 校验通过时锁定的买一价 + - 永不市价 + - 始终校验有效流动性(残档买一禁止) + - require_recycle_gate=True 时:首次还需可回收≥2×权利金并持续 hold 秒; + 一旦通过后对同仓续批只验流动性 + """ + from lib.exchange.okx_options_lib import ( + _pos_side_from_position, + invalidate_option_positions_cache, + ) + + inst_id = (inst_id or "").strip() + if not inst_id: + return {"ok": False, "msg": "缺少 inst_id"} + + q = cfg["quote_option_contract"](ex, inst_id) + if not q.get("ok"): + return {"ok": False, "msg": q.get("msg") or "报价失败"} + tick_sz = q.get("tick_sz") + ct_mult = float(q.get("ct_mult") or 0.01) + + raw_positions = cfg["fetch_option_positions"](ex) + if raw_positions is None: + return {"ok": False, "msg": "获取期权持仓失败"} + pos = next((p for p in raw_positions if str(p.get("instId")) == inst_id), None) + if not pos: + clear_close_gate(inst_id) + return {"ok": False, "msg": "未找到持仓", "already_flat": True} + + avail = _avail_sheets(pos) + want = int(sheets) if sheets else avail + want = min(want, avail) + if want < 1: + clear_close_gate(inst_id) + return {"ok": False, "msg": "可平张数不足", "already_flat": True} + + td_mode = str(pos.get("mgnMode") or cfg.get("td_mode") or "isolated") + pos_side = _pos_side_from_position(pos) or "net" + mark_px, intrinsic_px = _pos_close_refs(ex, pos, q) + premium_paid = _open_premium_paid(cfg, inst_id) + if premium_paid is None: + premium_paid = _safe_float(pos.get("premium_paid")) + + # 已有未成交卖平单:等成交,不撤不重挂 + try: + pending = ex.private_get_trade_orders_pending({"instType": "OPTION", "instId": inst_id}) or {} + sell_pending = [ + o + for o in (pending.get("data") or []) + if str(o.get("side") or "").lower() == "sell" and o.get("ordId") + ] + if sell_pending: + time.sleep(0.5) + invalidate_option_positions_cache() + raw_positions = cfg["fetch_option_positions"](ex) + if raw_positions is None: + return {"ok": False, "msg": "获取期权持仓失败"} + pos = next((p for p in raw_positions if str(p.get("instId")) == inst_id), None) + if not pos or _avail_sheets(pos) < 1: + clear_close_gate(inst_id) + return { + "ok": True, + "already_flat": True, + "msg": "已有限价卖单成交", + "close_ord_id": ",".join(str(o.get("ordId")) for o in sell_pending), + "fully_closed": True, + "submitted_sheets": want, + "remaining_sheets": 0, + "mode": "bid1", + } + return { + "ok": False, + "msg": "等待已有买一限价卖单成交", + "stopped_reason": "pending_close_order", + "close_ord_id": ",".join(str(o.get("ordId")) for o in sell_pending), + } + except Exception: + pass + + book = cfg["fetch_option_book_depth"](ex, inst_id, 1) + preview = estimate_close_by_bids( + book.get("bids") or [], + want, + ct_mult=ct_mult, + premium_paid=premium_paid, + mark_px=mark_px, + intrinsic_px=intrinsic_px, + max_levels=1, + ) + if preview.get("bid_invalid") or preview.get("auto_close_blocked"): + _cancel_sell_pending(ex, inst_id) + update_close_gate(inst_id, recycle_usdc=None, premium_paid=premium_paid) + return { + "ok": False, + "msg": preview.get("bid_invalid_reason") or "暂无有效买盘,禁止平仓", + "stopped_reason": "stub_bid", + "auto_close_blocked": True, + "liquidity_blocked": True, + } + + levels = preview.get("levels") or [] + if not levels: + bid_px = _safe_float(q.get("bid")) + stub, stub_reason = is_stub_bid_px(bid_px, mark_px=mark_px, intrinsic_px=intrinsic_px) + if stub or bid_px is None or bid_px <= 0: + update_close_gate(inst_id, recycle_usdc=None, premium_paid=premium_paid) + return { + "ok": False, + "msg": stub_reason or "暂无买一,无法限价平仓", + "stopped_reason": "stub_bid" if stub else "no_bid", + "auto_close_blocked": True, + "liquidity_blocked": True, + } + return { + "ok": False, + "msg": "暂无买一深度,无法平仓", + "stopped_reason": "no_bid_depth", + "liquidity_blocked": True, + } + + level = levels[0] + level_sheets = int(level.get("sheets") or 0) + level_px = float(level.get("px") or 0) + if level_sheets <= 0 or level_px <= 0: + return {"ok": False, "msg": "买一深度无效", "stopped_reason": "invalid_bid_depth"} + + stub_lv, stub_lv_reason = is_stub_bid_px(level_px, mark_px=mark_px, intrinsic_px=intrinsic_px) + if stub_lv: + update_close_gate(inst_id, recycle_usdc=None, premium_paid=premium_paid) + return { + "ok": False, + "msg": stub_lv_reason or "暂无有效买盘,禁止平仓", + "stopped_reason": "stub_bid", + "auto_close_blocked": True, + "liquidity_blocked": True, + } + + # 自动平仓:2×权利金门控(首次);通过后同仓续批只验流动性 + gate = update_close_gate( + inst_id, + recycle_usdc=_safe_float(preview.get("total_received")), + premium_paid=premium_paid, + ) + if require_recycle_gate and not is_close_gate_passed(inst_id) and not gate.get("ready"): + return { + "ok": False, + "msg": gate.get("msg") or "平仓门控未就绪(需可回收≥2×权利金并持续一段时间)", + "stopped_reason": "close_gate", + "auto_close_blocked": True, + "close_gate": gate, + } + if gate.get("ready"): + mark_close_gate_passed(inst_id) + + locked_bid_px = level_px + before_avail = avail + order = cfg["place_option_limit_order"]( + ex, + inst_id=inst_id, + side="sell", + sheets=level_sheets, + price=locked_bid_px, + td_mode=td_mode, + tick_sz=tick_sz, + reduce_only=True, + pos_side=pos_side, + ) + if not order.get("ok"): + return { + "ok": False, + "msg": order.get("msg") or "买一限价平仓失败", + "stopped_reason": "order_failed", + "locked_bid_px": locked_bid_px, + "batch_sheets": level_sheets, + } + + px = float(order.get("px", locked_bid_px)) + oid = str((order.get("data") or {}).get("ordId") or "") + prem_recv = round(total_premium(px, level_sheets * ct_mult), 4) + time.sleep(0.6) + invalidate_option_positions_cache() + raw2 = cfg["fetch_option_positions"](ex) + after_avail = 0 + if raw2 is not None: + after_pos = next((p for p in raw2 if str(p.get("instId")) == inst_id), None) + after_avail = _avail_sheets(after_pos) if after_pos else 0 + reduced = max(0, before_avail - after_avail) if raw2 is not None else 0 + remaining_pos = after_avail if raw2 is not None else max(0, before_avail - level_sheets) + fully_closed = remaining_pos < 1 + + if fully_closed: + clear_close_gate(inst_id) + conn = cfg["get_db"]() + try: + from lib.options.options_db import init_options_tables + + init_options_tables(conn) + open_rows = conn.execute( + """ + SELECT id, premium_paid FROM options_trades + WHERE inst_id = ? AND status = 'open' + ORDER BY id ASC + """, + (inst_id,), + ).fetchall() + total_paid = sum(float(r["premium_paid"] or 0) for r in open_rows) + allocated = 0.0 + for i, row in enumerate(open_rows): + paid = float(row["premium_paid"] or 0) + if i == len(open_rows) - 1: + recv = round(prem_recv - allocated, 4) + elif total_paid > 0: + recv = round(prem_recv * (paid / total_paid), 4) + allocated += recv + else: + recv = round(prem_recv / len(open_rows), 4) + allocated += recv + pnl = round(recv - paid, 4) + note_sql = "" + params: list[Any] = [px, recv, pnl, oid or None] + if signal_note and i == len(open_rows) - 1: + note_sql = """, + signal_note = CASE + WHEN signal_note IS NULL OR TRIM(signal_note) = '' THEN ? + ELSE signal_note + END""" + params.append(signal_note) + params.append(int(row["id"])) + conn.execute( + f""" + UPDATE options_trades + SET status = 'closed', close_quote = ?, premium_received = ?, + realized_pnl = ?, close_ord_id = ?, closed_at = CURRENT_TIMESTAMP + {note_sql} + WHERE id = ? + """, + tuple(params), + ) + conn.commit() + finally: + conn.close() + elif require_recycle_gate: + # 自动平已挂过单:同仓续批只验流动性 + mark_close_gate_passed(inst_id) + + return { + "ok": True, + "mode": "bid1", + "orders": [{"order": order, "px": px, "sheets": level_sheets}], + "bid": px, + "locked_bid_px": locked_bid_px, + "submitted_sheets": level_sheets, + "filled_or_reduced_sheets": min(reduced, level_sheets) if reduced else 0, + "remaining_sheets": remaining_pos, + "premium_received": prem_recv, + "stopped_reason": None if fully_closed else ("partial_bid1" if reduced > 0 else "order_not_filled"), + "close_ord_id": oid or None, + "fully_closed": fully_closed, + "msg": ( + f"已按买一 {locked_bid_px:g} 提交 {level_sheets} 张" + + ("" if fully_closed else f",剩余 {remaining_pos} 张待下次平仓") + ), + } + + +# 兼容旧名 +def close_option_by_bid_depth( + cfg: dict[str, Any], + ex: Any, + inst_id: str, + *, + sheets: int | None = None, +) -> dict[str, Any]: + return close_option_by_bid1( + cfg, + ex, + inst_id, + sheets=sheets, + require_recycle_gate=True, + signal_note="目标位平仓", + ) diff --git a/lib/options/options_close_gate_lib.py b/lib/options/options_close_gate_lib.py new file mode 100644 index 0000000..d263db2 --- /dev/null +++ b/lib/options/options_close_gate_lib.py @@ -0,0 +1,184 @@ +"""期权按买盘平仓门控:可回收需 ≥ N×权利金,并持续持有一段时间后才允许平仓.""" +from __future__ import annotations + +import os +import threading +import time +from typing import Any + + +def _env_float(key: str, default: float) -> float: + try: + return float(os.getenv(key, str(default))) + except (TypeError, ValueError): + return default + + +# 可回收 ≥ 权利金 × 倍数,且该状态持续满 hold_seconds 才允许按买盘平仓 +CLOSE_RECYCLE_MIN_MULT = _env_float("OKX_OPTIONS_CLOSE_RECYCLE_MULT", 2.0) +CLOSE_RECYCLE_HOLD_SECONDS = _env_float("OKX_OPTIONS_CLOSE_HOLD_SECONDS", 120.0) + +_lock = threading.Lock() +# inst_id -> {"ok_since": float|None, "recycle": float, "premium": float, "updated": float} +_gates: dict[str, dict[str, Any]] = {} + + +def _safe_float(v: Any) -> float | None: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def clear_close_gate(inst_id: str | None = None) -> None: + with _lock: + if inst_id: + _gates.pop(str(inst_id).strip(), None) + else: + _gates.clear() + + +def mark_close_gate_passed(inst_id: str) -> None: + """标记同仓已通过 2× 门控,续批平仓只验流动性.""" + inst = (inst_id or "").strip() + if not inst: + return + with _lock: + st = _gates.get(inst) or {} + st["passed"] = True + st["updated"] = time.time() + _gates[inst] = st + + +def is_close_gate_passed(inst_id: str) -> bool: + inst = (inst_id or "").strip() + if not inst: + return False + with _lock: + return bool((_gates.get(inst) or {}).get("passed")) + + +def update_close_gate( + inst_id: str, + *, + recycle_usdc: float | None, + premium_paid: float | None, + now: float | None = None, + min_mult: float | None = None, + hold_seconds: float | None = None, +) -> dict[str, Any]: + """ + 根据当前买盘可回收金额刷新门控. + 条件不满足时重置计时;满足时从首次满足起累计持续时间. + """ + inst = (inst_id or "").strip() + if not inst: + return { + "ok": False, + "ready": False, + "recycle_ok": False, + "msg": "缺少合约", + } + ts = float(now if now is not None else time.time()) + mult = float(min_mult if min_mult is not None else CLOSE_RECYCLE_MIN_MULT) + hold = float(hold_seconds if hold_seconds is not None else CLOSE_RECYCLE_HOLD_SECONDS) + if mult <= 0: + mult = 2.0 + if hold < 0: + hold = 0.0 + + prem = _safe_float(premium_paid) + recv = _safe_float(recycle_usdc) + need = round(prem * mult, 4) if prem is not None and prem > 0 else None + recycle_ok = bool( + prem is not None and prem > 0 and recv is not None and need is not None and recv + 1e-12 >= need + ) + + with _lock: + prev = _gates.get(inst) or {} + ok_since = prev.get("ok_since") + if recycle_ok: + if ok_since is None: + ok_since = ts + else: + ok_since = None + held = (ts - float(ok_since)) if ok_since is not None else 0.0 + ready = bool(recycle_ok and held + 1e-9 >= hold) + prev_passed = bool(prev.get("passed")) + passed = prev_passed or ready + state = { + "ok_since": ok_since, + "recycle": recv, + "premium": prem, + "need": need, + "updated": ts, + "min_mult": mult, + "hold_seconds": hold, + "passed": passed, + } + _gates[inst] = state + + remain = max(0.0, hold - held) if recycle_ok and not ready else None + if prem is None or prem <= 0: + msg = "缺少权利金,无法校验平仓门控" + elif recv is None: + msg = "暂无有效买盘可回收金额" + elif not recycle_ok: + msg = f"可回收 {recv:.4f} USDC < 权利金×{mult:g}({need:.4f}),目标平仓门控未过" + elif not ready: + msg = ( + f"可回收已达×{mult:g}({recv:.4f}/{need:.4f})," + f"需再持续 {remain:.0f}s(已 {held:.0f}/{hold:.0f}s)门控才通过" + ) + else: + msg = f"可回收已达×{mult:g}且持续≥{hold:.0f}s,目标触达后可按买一平仓" + + auto_blocked = not (ready or passed) + return { + "ok": True, + "ready": ready, + "passed": passed, + "recycle_ok": recycle_ok, + "recycle_usdc": recv, + "premium_paid": prem, + "need_recycle_usdc": need, + "min_mult": mult, + "hold_seconds": hold, + "held_seconds": round(held, 1) if recycle_ok else 0.0, + "remain_seconds": round(remain, 1) if remain is not None else None, + "ok_since": ok_since, + "msg": msg, + "auto_close_blocked": auto_blocked, + "close_gate_blocked": auto_blocked, + } + + +def check_close_gate( + inst_id: str, + *, + recycle_usdc: float | None = None, + premium_paid: float | None = None, + refresh: bool = True, +) -> dict[str, Any]: + """检查是否允许平仓;默认先用最新回收/权利金刷新.""" + inst = (inst_id or "").strip() + if refresh: + if recycle_usdc is None or premium_paid is None: + with _lock: + prev = _gates.get(inst) or {} + if recycle_usdc is None: + recycle_usdc = prev.get("recycle") + if premium_paid is None: + premium_paid = prev.get("premium") + return update_close_gate(inst, recycle_usdc=recycle_usdc, premium_paid=premium_paid) + with _lock: + prev = _gates.get(inst) + if not prev: + return update_close_gate(inst, recycle_usdc=recycle_usdc, premium_paid=premium_paid) + return update_close_gate( + inst, + recycle_usdc=recycle_usdc if recycle_usdc is not None else prev.get("recycle"), + premium_paid=premium_paid if premium_paid is not None else prev.get("premium"), + ) diff --git a/lib/options/options_db.py b/lib/options/options_db.py new file mode 100644 index 0000000..2d68110 --- /dev/null +++ b/lib/options/options_db.py @@ -0,0 +1,134 @@ +"""期权模块 SQLite 表.""" +from __future__ import annotations + +import sqlite3 + + +def init_options_tables(conn: sqlite3.Connection) -> None: + from lib.options.options_review_db import init_options_review_tables + + conn.execute( + """ + CREATE TABLE IF NOT EXISTS options_trades ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + inst_id TEXT NOT NULL, + underlying TEXT NOT NULL, + opt_type TEXT NOT NULL, + strike REAL, + exp_time TEXT, + sheets INTEGER NOT NULL, + eth_amount REAL NOT NULL, + open_quote REAL, + premium_paid REAL, + status TEXT DEFAULT 'open', + close_quote REAL, + premium_received REAL, + realized_pnl REAL, + profit_alert_sent INTEGER DEFAULT 0, + signal_note TEXT, + exchange_ord_id TEXT, + close_ord_id TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + closed_at TIMESTAMP + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS options_convert_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + from_ccy TEXT, + to_ccy TEXT, + rfq_sz REAL, + received_sz REAL, + quote_id TEXT, + status TEXT, + message TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS options_history_hidden ( + history_key TEXT PRIMARY KEY, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS options_transfer_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ccy TEXT, + amount REAL, + from_account TEXT, + to_account TEXT, + status TEXT, + message TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS options_target_monitors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + inst_id TEXT NOT NULL, + underlying TEXT, + opt_type TEXT, + target_index REAL NOT NULL, + trade_id INTEGER, + sheets INTEGER, + status TEXT DEFAULT 'active', + trigger_idx REAL, + close_ord_id TEXT, + message TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + triggered_at TIMESTAMP + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_options_target_monitors_status + ON options_target_monitors(status) + """ + ) + init_options_review_tables(conn) + + +def sum_open_premium_paid(conn: sqlite3.Connection, inst_id: str) -> float | None: + """同合约所有 open 腿权利金合计(加仓后显示/门控用).""" + inst = (inst_id or "").strip() + if not inst: + return None + row = conn.execute( + """ + SELECT SUM(premium_paid) AS total, COUNT(*) AS n + FROM options_trades + WHERE inst_id = ? AND status = 'open' AND premium_paid IS NOT NULL + """, + (inst,), + ).fetchone() + if not row or int(row["n"] or 0) < 1: + return None + return round(float(row["total"] or 0), 4) + + +def sum_open_sheets(conn: sqlite3.Connection, inst_id: str) -> int | None: + """同合约所有 open 腿张数合计.""" + inst = (inst_id or "").strip() + if not inst: + return None + row = conn.execute( + """ + SELECT SUM(sheets) AS total, COUNT(*) AS n + FROM options_trades + WHERE inst_id = ? AND status = 'open' + """, + (inst,), + ).fetchone() + if not row or int(row["n"] or 0) < 1: + return None + return int(row["total"] or 0) diff --git a/lib/options/options_history_lib.py b/lib/options/options_history_lib.py new file mode 100644 index 0000000..e54d868 --- /dev/null +++ b/lib/options/options_history_lib.py @@ -0,0 +1,86 @@ +"""期权历史列表(交易所 positions-history + 当前持仓).""" +from __future__ import annotations + +from typing import Any + +from lib.options.options_db import init_options_tables, sum_open_premium_paid + + +def enrich_position_row_display( + cfg: dict[str, Any], + ex: Any, + raw_pos: dict[str, Any], + *, + meta_cache: dict[str, dict[str, Any] | None] | None = None, + premium_override: float | None = None, +) -> dict[str, Any]: + from lib.exchange.okx_options_lib import format_position_row, format_usdc_amount, tick_sz_and_ct_mult + + inst_id = str(raw_pos.get("instId") or "").strip() + tick_sz, ct_mult = tick_sz_and_ct_mult(ex, inst_id, meta_cache) + row = format_position_row(raw_pos, ct_mult=ct_mult, tick_sz=tick_sz) + if premium_override is not None: + row["premium_paid"] = premium_override + row["premium_paid_fmt"] = format_usdc_amount(premium_override) + return row + + +def load_options_history(ex: Any, cfg: dict[str, Any]) -> list[dict[str, Any]]: + """与期权历史页相同的数据源:交易所全平记录 + 当前持仓,排除本地隐藏项.""" + from lib.exchange.okx_options_lib import ( + fetch_all_option_positions_history, + format_live_option_history_row, + format_option_history_row, + tick_sz_and_ct_mult, + ) + + meta_cache: dict[str, dict[str, Any] | None] = {} + items: list[dict[str, Any]] = [] + + raw_live = cfg["fetch_option_positions"](ex) + if raw_live is None: + return [] + + conn = cfg["get_db"]() + try: + init_options_tables(conn) + hidden_keys = { + str(r["history_key"]) + for r in conn.execute("SELECT history_key FROM options_history_hidden").fetchall() + } + for p in raw_live: + inst = str(p.get("instId") or "").strip() + premium_override = sum_open_premium_paid(conn, inst) if inst else None + row = enrich_position_row_display( + cfg, + ex, + p, + meta_cache=meta_cache, + premium_override=premium_override, + ) + open_ms = None + ctime = p.get("cTime") or (row.get("raw") or {}).get("cTime") + try: + if ctime is not None and str(ctime).strip(): + open_ms = int(float(ctime)) + except (TypeError, ValueError): + open_ms = None + items.append(format_live_option_history_row(row, open_ms=open_ms)) + finally: + conn.close() + + hist_raw = fetch_all_option_positions_history(ex, limit=200) + for raw in hist_raw: + inst_id = str(raw.get("instId") or "").strip() + tick_sz, ct_mult = tick_sz_and_ct_mult(ex, inst_id, meta_cache) + items.append(format_option_history_row(raw, tick_sz=tick_sz, ct_mult=ct_mult)) + + open_rows = [x for x in items if x.get("status") == "open"] + closed = [x for x in items if x.get("status") != "open"] + closed.sort(key=lambda x: int(x.get("close_ms") or 0), reverse=True) + open_rows.sort(key=lambda x: int(x.get("close_ms") or 0), reverse=True) + return [ + x + for x in (open_rows + closed) + if str(x.get("history_key") or "") not in hidden_keys + ] diff --git a/lib/options/options_hub_lib.py b/lib/options/options_hub_lib.py new file mode 100644 index 0000000..7854bb0 --- /dev/null +++ b/lib/options/options_hub_lib.py @@ -0,0 +1,104 @@ +"""中控只读聚合:OKX 期权持仓 / 资金 / 本地统计.""" + +from __future__ import annotations + +from typing import Any + +from lib.options.options_history_lib import load_options_history +from lib.options.options_stats_lib import compute_options_stats_from_history + + +def _compute_options_stats(ex, cfg) -> dict[str, Any]: + history = load_options_history(ex, cfg) + return compute_options_stats_from_history(history) + + +def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]: + if not cfg.get("enabled"): + return {"ok": True, "enabled": False} + ex = cfg.get("exchange_options") + ready_fn = cfg.get("options_api_ready") + if not callable(ready_fn): + return {"ok": False, "enabled": True, "msg": "期权模块未就绪"} + ok, reason = ready_fn(ex) + if not ok: + return {"ok": False, "enabled": True, "msg": reason or "期权 API 未配置"} + try: + from lib.options.options_positions_lib import build_display_option_positions + + raw = cfg["fetch_option_positions"](ex) + if raw is None: + return {"ok": False, "enabled": True, "msg": "获取期权持仓失败"} + positions = build_display_option_positions(cfg, ex, raw) + target_monitors: list[dict[str, Any]] = [] + try: + conn = cfg["get_db"]() + try: + from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst + from lib.options.options_target_lib import list_active_targets, list_closing_targets, targets_by_inst + + target_monitors = list_active_targets(conn) + list_closing_targets(conn) + tgt_map = targets_by_inst(conn) + hedge_target_map = active_options_targets_by_inst(conn) + target_monitors.extend(hedge_target_map.values()) + for p in positions: + mon = tgt_map.get(str(p.get("inst_id") or "")) + if mon: + p["target_index"] = mon.get("target_index") + p["target_monitor_id"] = mon.get("id") + p["target_monitor"] = mon + hedge_target = hedge_target_map.get(str(p.get("inst_id") or "")) + if hedge_target: + p["hedge_plan_target"] = hedge_target + if not mon: + # 中控卡片共用 target_index 只读展示;实际平仓仍由对冲计划监控处理。 + p["target_index"] = hedge_target.get("target_index") + try: + from lib.instance.instance_dashboard_lib import ( + _format_options_target, + _resolve_options_source, + ) + + inst = str(p.get("inst_id") or "") + source_key, source_label = _resolve_options_source(conn, inst) + p["source"] = source_key + p["source_label"] = source_label + p["target_monitor_text"] = _format_options_target(p) + except Exception: + p.setdefault("source_label", "—") + p.setdefault("target_monitor_text", "—") + finally: + conn.close() + except Exception: + target_monitors = [] + from lib.options.options_positions_lib import net_pnl_from_display_row + + upl_total = 0.0 + has_upl = False + for p in positions: + # 与持仓卡「净盈亏」一致(买一回收−权利金);不用交易所标记价 upl + net = net_pnl_from_display_row(p) + if net is None: + continue + has_upl = True + upl_total += float(net) + bal = cfg["fetch_options_balances"](ex) + stats = _compute_options_stats(ex, cfg) + return { + "ok": True, + "enabled": True, + "positions": positions, + "position_count": len(positions), + "target_monitors": target_monitors, + "upl_total_usdc": round(upl_total, 4) if has_upl else None, + "balances": bal, + "funding_usdc": bal.get("funding_usdc"), + "funding_usdt": bal.get("funding_usdt"), + "trading_usdc": bal.get("trading_usdc"), + "trading_usdt": bal.get("trading_usdt"), + "stats": stats, + "trade_budget": cfg.get("trade_budget"), + "account_label": cfg.get("account_label") or "OKX期权", + } + except Exception as e: + return {"ok": False, "enabled": True, "msg": str(e)} diff --git a/lib/options/options_monitor_lib.py b/lib/options/options_monitor_lib.py new file mode 100644 index 0000000..55b3b17 --- /dev/null +++ b/lib/options/options_monitor_lib.py @@ -0,0 +1,334 @@ +"""期权持仓监控:浮盈翻倍微信提醒 + 平仓/到期状态同步.""" +from __future__ import annotations + +import sqlite3 +import time +from datetime import datetime, timezone +from typing import Any, Callable + +from lib.exchange.okx_options_lib import normalize_option_exp_ms, resolve_option_close_from_history + + +def _safe_float(v: Any) -> float | None: + if v is None: + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def build_profit_alert_message( + *, + account_label: str, + inst_id: str, + premium_paid: float, + upl: float, + upl_ratio: float | None, + bid: float | None, +) -> str: + pct = f"{upl_ratio * 100:.1f}%" if upl_ratio is not None else "—" + bid_txt = f"{bid:.4f}" if bid is not None else "—" + return "\n".join( + [ + "【OKX期权·翻倍提醒】", + f"账户:{account_label}", + f"合约:{inst_id}", + f"已付权利金:{premium_paid:.4f} USDC", + f"未实现盈亏:{upl:+.4f} USDC({pct})", + f"当前买一:{bid_txt}(可考虑限价平仓锁利)", + ] + ) + + +def run_options_profit_alerts( + conn: sqlite3.Connection, + positions: list[dict[str, Any]], + *, + profit_ratio: float, + send_wechat: Callable[[str], None], + account_label: str, + ticker_bid_fn: Callable[[str], float | None], +) -> int: + """ + 对比 DB 中 open 记录与交易所持仓;达到阈值发微信. + 返回发送条数. + """ + sent = 0 + pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions} + rows = conn.execute( + """ + SELECT id, inst_id, premium_paid, profit_alert_sent + FROM options_trades + WHERE status = 'open' + ORDER BY id ASC + """ + ).fetchall() + # 同合约多腿加仓:按合约汇总权利金,整仓只告警一次 + by_inst: dict[str, dict[str, Any]] = {} + for row in rows: + inst_id = str(row["inst_id"] or "") + if not inst_id: + continue + bucket = by_inst.setdefault( + inst_id, + {"ids": [], "premium": 0.0, "all_sent": True, "has_prem": False}, + ) + bucket["ids"].append(int(row["id"])) + prem = _safe_float(row["premium_paid"]) + if prem is not None: + bucket["premium"] += float(prem) + bucket["has_prem"] = True + if not int(row["profit_alert_sent"] or 0): + bucket["all_sent"] = False + + for inst_id, bucket in by_inst.items(): + if bucket["all_sent"] or not bucket["has_prem"] or bucket["premium"] <= 0: + continue + pos = pos_by_inst.get(inst_id) + if not pos: + continue + prem = float(bucket["premium"]) + upl = _safe_float(pos.get("upl")) + upl_ratio = _safe_float(pos.get("upl_ratio_pct")) + if upl_ratio is not None: + ratio = upl_ratio / 100.0 + elif upl is not None: + ratio = upl / prem + else: + continue + if ratio < float(profit_ratio): + continue + bid = ticker_bid_fn(inst_id) + msg = build_profit_alert_message( + account_label=account_label, + inst_id=inst_id, + premium_paid=prem, + upl=upl or 0.0, + upl_ratio=ratio, + bid=bid, + ) + try: + send_wechat(msg) + conn.execute( + f"UPDATE options_trades SET profit_alert_sent = 1 WHERE id IN ({','.join('?' * len(bucket['ids']))})", + tuple(bucket["ids"]), + ) + sent += 1 + except Exception: + pass + return sent + + +def _created_at_ms(created_at: Any) -> int | None: + if not created_at: + return None + raw = str(created_at).strip() + if not raw: + return None + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M:%f"): + try: + dt = datetime.strptime(raw[:26], fmt).replace(tzinfo=timezone.utc) + return int(dt.timestamp() * 1000) + except ValueError: + continue + return None + + +def sync_open_options_trades( + conn: sqlite3.Connection, + *, + live_inst_ids: set[str], + fetch_history_fn: Callable[[str], list[dict[str, Any]]], +) -> int: + """ + 交易所已无持仓时,将本地 open 记录同步为 closed. + 优先用 positions-history 回填盈亏;否则到期后按归零处理. + """ + rows = conn.execute( + """ + SELECT id, inst_id, premium_paid, exp_time, created_at + FROM options_trades + WHERE status = 'open' + """ + ).fetchall() + updated = 0 + now_ms = int(time.time() * 1000) + for row in rows: + inst_id = str(row["inst_id"] or "") + if not inst_id or inst_id in live_inst_ids: + continue + paid = _safe_float(row["premium_paid"]) or 0.0 + open_ms = _created_at_ms(row["created_at"]) + exp_ms = normalize_option_exp_ms(row["exp_time"], inst_id) + close_quote: float | None = None + prem_recv: float | None = None + realized_pnl: float | None = None + close_ord_id: str | None = None + closed_at: str | None = None + close_reason = "exchange" + + close_info = resolve_option_close_from_history( + fetch_history_fn(inst_id), + open_ms=open_ms, + ) + if close_info: + close_quote = close_info.get("close_quote") + realized_pnl = close_info.get("realized_pnl") + close_ord_id = close_info.get("pos_id") + if realized_pnl is not None: + prem_recv = round(paid + float(realized_pnl), 4) + close_ms = close_info.get("close_ms") + if close_ms: + closed_at = datetime.fromtimestamp(int(close_ms) / 1000, tz=timezone.utc).strftime( + "%Y-%m-%d %H:%M:%S" + ) + elif exp_ms is not None and now_ms >= int(exp_ms): + close_reason = "expired" + close_quote = 0.0 + prem_recv = 0.0 + realized_pnl = round(-paid, 4) + if exp_ms: + closed_at = datetime.fromtimestamp(int(exp_ms) / 1000, tz=timezone.utc).strftime( + "%Y-%m-%d %H:%M:%S" + ) + else: + continue + + conn.execute( + """ + UPDATE options_trades + SET status = 'closed', + close_quote = ?, + premium_received = ?, + realized_pnl = ?, + close_ord_id = COALESCE(?, close_ord_id), + closed_at = COALESCE(?, closed_at, CURRENT_TIMESTAMP), + signal_note = CASE + WHEN ? = 'expired' AND (signal_note IS NULL OR TRIM(signal_note) = '') + THEN '到期结算' + ELSE signal_note + END + WHERE id = ? + """, + ( + close_quote, + prem_recv, + realized_pnl, + close_ord_id, + closed_at, + close_reason, + int(row["id"]), + ), + ) + updated += 1 + return updated + + +def reconcile_live_open_trades( + conn: sqlite3.Connection, + *, + live_inst_ids: set[str], +) -> int: + """交易所有持仓但本地误标 closed 时恢复为 open.""" + fixed = 0 + for inst_id in live_inst_ids: + if not inst_id: + continue + open_row = conn.execute( + "SELECT id FROM options_trades WHERE inst_id = ? AND status = 'open' LIMIT 1", + (inst_id,), + ).fetchone() + if open_row: + continue + row = conn.execute( + """ + SELECT id, close_ord_id, realized_pnl + FROM options_trades + WHERE inst_id = ? AND status = 'closed' + ORDER BY id DESC LIMIT 1 + """, + (inst_id,), + ).fetchone() + if not row: + continue + if row["close_ord_id"]: + continue + if row["realized_pnl"] is not None: + continue + conn.execute( + """ + UPDATE options_trades + SET status = 'open', + close_quote = NULL, + premium_received = NULL, + realized_pnl = NULL, + closed_at = NULL, + signal_note = CASE + WHEN signal_note = '到期结算' THEN NULL + ELSE signal_note + END + WHERE id = ? + """, + (int(row["id"]),), + ) + fixed += 1 + return fixed + + +def options_monitor_loop( + *, + enabled: bool, + poll_seconds: float, + get_db: Callable[[], sqlite3.Connection], + fetch_positions: Callable[[], list[dict[str, Any]]], + ticker_bid_fn: Callable[[str], float | None], + send_wechat: Callable[[str], None], + account_label: str, + profit_ratio: float, + sync_trades_fn: Callable[[sqlite3.Connection], int] | None = None, + target_close_fn: Callable[[str], dict[str, Any]] | None = None, + stale_pending_fn: Callable[[], dict[str, Any]] | None = None, + stop_event: Any = None, +) -> None: + if not enabled: + return + while True: + if stop_event is not None and getattr(stop_event, "is_set", lambda: False)(): + break + try: + conn = get_db() + try: + positions = fetch_positions() + run_options_profit_alerts( + conn, + positions, + profit_ratio=profit_ratio, + send_wechat=send_wechat, + account_label=account_label, + ticker_bid_fn=ticker_bid_fn, + ) + if target_close_fn is not None: + from lib.options.options_target_lib import run_options_target_closes + + run_options_target_closes( + conn, + positions, + close_fn=target_close_fn, + send_wechat=send_wechat, + account_label=account_label, + ) + if sync_trades_fn is not None: + sync_trades_fn(conn) + conn.commit() + finally: + conn.close() + # 平仓限价挂单超时撤单(独立于 DB 事务) + if stale_pending_fn is not None: + try: + stale_pending_fn() + except Exception: + pass + except Exception: + pass + time.sleep(max(5.0, float(poll_seconds))) diff --git a/lib/options/options_pending_lib.py b/lib/options/options_pending_lib.py new file mode 100644 index 0000000..392df02 --- /dev/null +++ b/lib/options/options_pending_lib.py @@ -0,0 +1,124 @@ +"""期权限价挂单:展示 enrichment + 超时自动撤单.""" +from __future__ import annotations + +import time +from typing import Any + + +def _safe_float(v: Any) -> float | None: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def order_age_seconds(order: dict[str, Any], *, now_ms: float | None = None) -> float | None: + """根据交易所 cTime(ms) 估算挂单时长(秒).""" + ct = _safe_float(order.get("c_time") or order.get("cTime")) + if ct is None or ct <= 0: + return None + # OKX 一般为毫秒时间戳 + if ct < 1e12: + ct *= 1000.0 + now = float(now_ms if now_ms is not None else time.time() * 1000.0) + age = (now - ct) / 1000.0 + return age if age >= 0 else 0.0 + + +def is_close_pending_order(order: dict[str, Any]) -> bool: + """平仓向限价挂单:卖出 / reduceOnly.""" + side = str(order.get("side") or "").lower() + if side == "sell": + return True + return bool(order.get("reduce_only")) + + +def enrich_pending_orders( + orders: list[dict[str, Any]] | None, + *, + ttl_seconds: float = 600.0, + now_ms: float | None = None, +) -> list[dict[str, Any]]: + """为 UI 附加挂单时长与自动撤倒计时.""" + ttl = max(0.0, float(ttl_seconds or 0)) + now = float(now_ms if now_ms is not None else time.time() * 1000.0) + out: list[dict[str, Any]] = [] + for raw in orders or []: + o = dict(raw) + age = order_age_seconds(o, now_ms=now) + is_close = is_close_pending_order(o) + o["age_sec"] = round(age, 1) if age is not None else None + o["is_close_order"] = is_close + o["auto_cancel_enabled"] = bool(is_close and ttl > 0) + if age is not None and is_close and ttl > 0: + remain = max(0.0, ttl - age) + o["ttl_seconds"] = ttl + o["expire_in_sec"] = round(remain, 1) + o["stale"] = remain <= 0 + else: + o["ttl_seconds"] = ttl if is_close else None + o["expire_in_sec"] = None + o["stale"] = False + out.append(o) + return out + + +def cancel_stale_close_pending_orders( + *, + fetch_pending: Any, + cancel_order: Any, + ttl_seconds: float = 600.0, + now_ms: float | None = None, + ex: Any = None, +) -> dict[str, Any]: + """ + 平仓限价挂单超过 ttl 自动撤销. + fetch_pending(ex) -> list; cancel_order(ex, inst_id=..., ord_id=...). + """ + ttl = float(ttl_seconds or 0) + if ttl <= 0: + return {"ok": True, "cancelled": 0, "checked": 0, "skipped": "ttl_disabled"} + try: + orders = fetch_pending(ex) if ex is not None else fetch_pending() + except TypeError: + orders = fetch_pending(ex) + except Exception as e: + return {"ok": False, "msg": str(e), "cancelled": 0, "checked": 0} + enriched = enrich_pending_orders(orders or [], ttl_seconds=ttl, now_ms=now_ms) + cancelled: list[dict[str, Any]] = [] + errors: list[str] = [] + checked = 0 + for o in enriched: + if not o.get("is_close_order"): + continue + checked += 1 + if not o.get("stale"): + continue + inst = str(o.get("inst_id") or "").strip() + oid = str(o.get("ord_id") or "").strip() + if not inst or not oid: + continue + try: + if ex is not None: + res = cancel_order(ex, inst_id=inst, ord_id=oid) + else: + res = cancel_order(inst_id=inst, ord_id=oid) + except TypeError: + res = cancel_order(ex, inst_id=inst, ord_id=oid) + except Exception as e: + errors.append(f"{oid}:{e}") + continue + if res.get("ok"): + cancelled.append({"inst_id": inst, "ord_id": oid, "age_sec": o.get("age_sec")}) + else: + errors.append(f"{oid}:{res.get('msg') or 'cancel_failed'}") + return { + "ok": True, + "cancelled": len(cancelled), + "checked": checked, + "orders": cancelled, + "errors": errors, + "ttl_seconds": ttl, + } diff --git a/lib/options/options_positions_lib.py b/lib/options/options_positions_lib.py new file mode 100644 index 0000000..e94ef25 --- /dev/null +++ b/lib/options/options_positions_lib.py @@ -0,0 +1,155 @@ +"""期权持仓展示(实例页 / 中控快照共用).""" +from __future__ import annotations + +from typing import Any + +from lib.options.options_db import init_options_tables, sum_open_premium_paid +from lib.options.options_history_lib import enrich_position_row_display +from lib.options.options_close_gate_lib import clear_close_gate, is_close_gate_passed, update_close_gate +from lib.options.options_pricing_lib import estimate_close_by_bids, intrinsic_px_per_unit + + +def _safe_float(v: Any) -> float | None: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def attach_close_preview( + cfg: dict[str, Any], + ex: Any, + row: dict[str, Any], + *, + sheets: int | None = None, + premium_paid: float | None = None, +) -> dict[str, Any]: + inst_id = str(row.get("inst_id") or row.get("instId") or "").strip() + if not inst_id: + return row + ct_mult = float(row.get("ct_mult") or 0.01) + target_sheets = int(sheets) if sheets is not None else int(abs(_safe_float(row.get("pos")) or 0)) + paid = premium_paid if premium_paid is not None else _safe_float(row.get("premium_paid")) + book = cfg["fetch_option_book_depth"](ex, inst_id, 5) + row["bid_depth"] = book.get("bids") or [] + row["ask_depth"] = book.get("asks") or [] + mark_px = _safe_float(row.get("mark_px") or row.get("markPx")) + intrinsic = intrinsic_px_per_unit( + row.get("opt_type") or row.get("optType"), + _safe_float(row.get("strike") or row.get("stk")), + _safe_float(row.get("idx_px") or row.get("idxPx")), + ) + # 与实盘一致:只按买一估算本轮可平 + preview = estimate_close_by_bids( + row["bid_depth"], + target_sheets, + ct_mult=ct_mult, + premium_paid=paid, + mark_px=mark_px, + intrinsic_px=intrinsic, + max_levels=1, + ) + # 残档时不累计 2×门控;有效买一时刷新计时(仅自动平仓需要) + if preview.get("bid_invalid") or preview.get("auto_close_blocked"): + gate = update_close_gate(inst_id, recycle_usdc=None, premium_paid=paid) + preview["close_gate"] = gate + preview["close_gate_blocked"] = True + preview["close_gate_msg"] = preview.get("bid_invalid_reason") or gate.get("msg") + preview["manual_close_blocked"] = True + preview["liquidity_ok"] = False + else: + gate = update_close_gate( + inst_id, + recycle_usdc=_safe_float(preview.get("total_received")), + premium_paid=paid, + ) + passed = bool(gate.get("passed") or is_close_gate_passed(inst_id) or gate.get("ready")) + preview["close_gate"] = gate + preview["close_gate_blocked"] = not passed + preview["close_gate_msg"] = gate.get("msg") + preview["manual_close_blocked"] = False + preview["liquidity_ok"] = True + if not passed: + preview["auto_close_blocked"] = True + row["close_preview"] = preview + return row + + +def forget_close_gate_for_inst(inst_id: str) -> None: + clear_close_gate(inst_id) + + +def net_pnl_from_display_row(row: dict[str, Any]) -> float | None: + """与持仓卡「净盈亏」同口径:买一可回收 − 权利金;残档买一则无净值.""" + preview = row.get("close_preview") if isinstance(row.get("close_preview"), dict) else {} + if preview.get("bid_invalid"): + return None + net = preview.get("estimated_pnl") + if net is not None: + try: + return float(net) + except (TypeError, ValueError): + pass + recv = _safe_float(preview.get("total_received")) + paid = _safe_float(row.get("premium_paid")) + if recv is not None and paid is not None: + return round(recv - paid, 4) + return None + + +def sum_options_net_pnl_usdc( + cfg: dict[str, Any], + ex: Any, + raw_positions: list[dict[str, Any]] | None = None, +) -> float | None: + """ + 期权浮盈合计(USDC),与顶栏实时盈亏/中控口径对齐为「净盈亏」: + 各仓买一可回收 − 权利金之和.获取失败返回 None;无持仓返回 0. + """ + raw = raw_positions + if raw is None: + raw = cfg["fetch_option_positions"](ex) + if raw is None: + return None + if not raw: + return 0.0 + positions = build_display_option_positions(cfg, ex, raw) + total = 0.0 + found = False + for p in positions: + net = net_pnl_from_display_row(p) + if net is None: + continue + found = True + total += float(net) + return round(total, 4) if found else (0.0 if not positions else None) + + +def build_display_option_positions( + cfg: dict[str, Any], + ex: Any, + raw_positions: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """与实例 /api/options/positions 相同 enrichment + close_preview.""" + meta_cache: dict[str, dict[str, Any] | None] = {} + rows: list[dict[str, Any]] = [] + conn = cfg["get_db"]() + try: + init_options_tables(conn) + for p in raw_positions: + inst = str(p.get("instId") or "").strip() + premium_override = sum_open_premium_paid(conn, inst) if inst else None + row = enrich_position_row_display( + cfg, + ex, + p, + meta_cache=meta_cache, + premium_override=premium_override, + ) + attach_close_preview(cfg, ex, row, premium_paid=_safe_float(row.get("premium_paid"))) + rows.append(row) + finally: + conn.close() + return rows diff --git a/lib/options/options_pricing_lib.py b/lib/options/options_pricing_lib.py new file mode 100644 index 0000000..b86117d --- /dev/null +++ b/lib/options/options_pricing_lib.py @@ -0,0 +1,543 @@ +"""OKX USDⓈ 期权:张数与权利金计算.""" +from __future__ import annotations + +import math +from typing import Any + + +def ct_mult_from_meta(meta: dict[str, Any] | None) -> float: + if not meta: + return 0.01 + try: + return float(meta.get("ctMult") or 0.01) + except (TypeError, ValueError): + return 0.01 + + +def min_sz_from_meta(meta: dict[str, Any] | None) -> int: + if not meta: + return 1 + try: + return max(1, int(float(meta.get("minSz") or 1))) + except (TypeError, ValueError): + return 1 + + +def premium_per_sheet(quote_per_unit: float, ct_mult: float = 0.01) -> float: + """报价为每 1 ETH/BTC;每张权利金 = 报价 × ctMult.""" + return float(quote_per_unit) * float(ct_mult) + + +def format_quote_liquidity(px: float | None, sz: float | None, *, px_decimals: int = 4) -> str | None: + """盘口展示:价格/张数,如 17.2/150.""" + if px is None: + return None + try: + price = f"{float(px):.{px_decimals}f}".rstrip("0").rstrip(".") + except (TypeError, ValueError): + return None + if sz is None: + return price + try: + s = float(sz) + size = str(int(s)) if abs(s - int(s)) < 1e-9 else str(s).rstrip("0").rstrip(".") + except (TypeError, ValueError): + return price + return f"{price}/{size}" + + +def total_premium(quote_per_unit: float, eth_amount: float, ct_mult: float = 0.01) -> float: + return float(quote_per_unit) * float(eth_amount) + + +# 买一相对标记价/内在价值低于该比例 → 视为残档,禁止按买盘自动/多档平仓 +BID_CLOSE_MIN_RATIO = 0.3 + + +def _safe_px(v: Any) -> float | None: + if v is None or v == "": + return None + try: + x = float(v) + except (TypeError, ValueError): + return None + return x if x > 0 else None + + +def intrinsic_px_per_unit(opt_type: str | None, strike: float | None, index_px: float | None) -> float | None: + o = (opt_type or "").strip().upper() + if strike is None or index_px is None: + return None + try: + k = float(strike) + idx = float(index_px) + except (TypeError, ValueError): + return None + if o == "C" and idx > k: + return idx - k + if o == "P" and idx < k: + return k - idx + return None + + +def is_stub_bid_px( + bid_px: float | None, + *, + mark_px: float | None = None, + intrinsic_px: float | None = None, + min_ratio: float = BID_CLOSE_MIN_RATIO, +) -> tuple[bool, str]: + """ + 判断买一是否为无效残档(如标记 42、买一 0.2). + 返回 (is_stub, reason). + """ + bid = _safe_px(bid_px) + if bid is None: + return True, "无买一" + ref = _safe_px(mark_px) + ref_name = "标记价" + intrinsic = _safe_px(intrinsic_px) + if intrinsic is not None and (ref is None or intrinsic > ref): + ref = intrinsic + ref_name = "内在价值" + if ref is None: + return False, "" + ratio = float(min_ratio) if min_ratio and min_ratio > 0 else BID_CLOSE_MIN_RATIO + if bid < ref * ratio: + return True, f"买一{bid:g}远低于{ref_name}{ref:g},属无效残档,禁止按买盘自动平仓" + return False, "" + + +def fetch_option_mark_px(ex: Any, inst_id: str) -> float | None: + """优先 mark-price 接口,失败则 None.""" + inst_id = (inst_id or "").strip() + if not inst_id or ex is None: + return None + try: + rows = ex.public_get_public_mark_price({"instType": "OPTION", "instId": inst_id}).get("data") or [] + if rows: + return _safe_px(rows[0].get("markPx")) + except Exception: + pass + return None + + +def close_ref_prices( + *, + mark_px: float | None = None, + opt_type: str | None = None, + strike: float | None = None, + index_px: float | None = None, +) -> tuple[float | None, float | None]: + """返回 (mark_px, intrinsic_px) 供残档判断.""" + return _safe_px(mark_px), intrinsic_px_per_unit(opt_type, strike, index_px) + + +def filter_bids_for_close( + bids: list[dict[str, Any]] | None, + *, + mark_px: float | None = None, + intrinsic_px: float | None = None, + min_ratio: float = BID_CLOSE_MIN_RATIO, +) -> tuple[list[dict[str, Any]], bool, str]: + """过滤不可用于平仓的残档买盘.返回 (usable_bids, had_stub_only, reason).""" + raw = list(bids or []) + usable: list[dict[str, Any]] = [] + stub_reason = "" + for level in raw: + px = _safe_px(level.get("px") if isinstance(level, dict) else None) + stub, reason = is_stub_bid_px(px, mark_px=mark_px, intrinsic_px=intrinsic_px, min_ratio=min_ratio) + if stub: + if not stub_reason: + stub_reason = reason or "买一无效" + continue + usable.append(level) + if raw and not usable: + return [], True, stub_reason or "暂无有效买盘" + return usable, False, "" + + +def estimate_close_by_bids( + bids: list[dict[str, Any]] | None, + sheets: int | float, + *, + ct_mult: float = 0.01, + premium_paid: float | None = None, + mark_px: float | None = None, + intrinsic_px: float | None = None, + min_bid_ratio: float = BID_CLOSE_MIN_RATIO, + max_levels: int = 1, +) -> dict[str, Any]: + """按买盘估算限价卖出可收回金额;默认只估算买一(与实盘平仓一致);残档不参与.""" + target = max(0, int(float(sheets or 0))) + remaining = target + total_received = 0.0 + levels: list[dict[str, Any]] = [] + max_lv = max(1, int(max_levels or 1)) + empty = { + "levels": [], + "covered_sheets": 0, + "uncovered_sheets": target, + "total_received": 0.0, + "avg_px": None, + "estimated_pnl": None, + "estimated_pnl_ratio_pct": None, + "bid_invalid": False, + "bid_invalid_reason": None, + "auto_close_blocked": False, + "max_levels": max_lv, + } + if target <= 0 or ct_mult <= 0: + return empty + usable, stub_only, stub_reason = filter_bids_for_close( + bids, mark_px=mark_px, intrinsic_px=intrinsic_px, min_ratio=min_bid_ratio + ) + if stub_only: + out = dict(empty) + out["bid_invalid"] = True + out["bid_invalid_reason"] = stub_reason + out["auto_close_blocked"] = True + out["raw_bid_px"] = _safe_px((bids or [{}])[0].get("px")) if bids else None + return out + for i, level in enumerate(usable[:max_lv], start=1): + if remaining <= 0: + break + try: + px = float(level.get("px")) + sz = int(float(level.get("sz"))) + except (AttributeError, TypeError, ValueError): + continue + if px <= 0 or sz <= 0: + continue + take = min(remaining, sz) + eth_amount = eth_amount_from_sheets(take, ct_mult) + received = total_premium(px, eth_amount) + levels.append( + { + "level": i, + "px": px, + "available_sheets": sz, + "sheets": take, + "eth_amount": eth_amount, + "received": round(received, 4), + } + ) + total_received += received + remaining -= take + covered = target - remaining + avg_px = (total_received / eth_amount_from_sheets(covered, ct_mult)) if covered > 0 else None + # 净盈亏 = 本轮买盘可回收 − 全部权利金(买一不够时剩余张数计入 uncovered) + estimated_pnl = None + estimated_pnl_ratio_pct = None + if premium_paid is not None and covered > 0: + paid = float(premium_paid) + estimated_pnl = round(total_received - paid, 4) + if paid > 0: + estimated_pnl_ratio_pct = round(estimated_pnl / paid * 100.0, 2) + return { + "levels": levels, + "covered_sheets": covered, + "uncovered_sheets": remaining, + "total_received": round(total_received, 4), + "avg_px": round(avg_px, 4) if avg_px is not None else None, + "estimated_pnl": estimated_pnl, + "estimated_pnl_ratio_pct": estimated_pnl_ratio_pct, + "bid_invalid": False, + "bid_invalid_reason": None, + "auto_close_blocked": False, + "max_levels": max_lv, + } + + +def sheets_from_eth_amount(eth_amount: float, ct_mult: float = 0.01) -> int: + if eth_amount <= 0 or ct_mult <= 0: + return 0 + return int(math.floor(eth_amount / ct_mult + 1e-12)) + + +def eth_amount_from_sheets(sheets: int, ct_mult: float = 0.01) -> float: + return round(int(sheets) * float(ct_mult), 8) + + +def calc_order_size( + *, + quote_per_unit: float, + ct_mult: float, + min_sz: int, + budget_usdc: float | None = None, + budget_buffer: float = 0.95, + eth_amount: float | None = None, + sheets: int | None = None, + budget_cap: float | None = None, +) -> dict[str, Any]: + """ + 返回 sheets, eth_amount, total_premium. + mode: budget_full / eth_amount / sheets. + """ + if quote_per_unit <= 0: + return {"ok": False, "msg": "卖一价无效", "sheets": 0, "eth_amount": 0.0, "total_premium": 0.0} + + if sheets is not None and int(sheets) > 0: + sheets = int(sheets) + elif eth_amount is not None and eth_amount > 0: + sheets = sheets_from_eth_amount(eth_amount, ct_mult) + elif budget_usdc is not None and budget_usdc > 0: + eff = float(budget_usdc) * float(budget_buffer) + per_sheet = premium_per_sheet(quote_per_unit, ct_mult) + if per_sheet <= 0: + return {"ok": False, "msg": "无法计算单张权利金", "sheets": 0, "eth_amount": 0.0, "total_premium": 0.0} + sheets = int(math.floor(eff / per_sheet)) + else: + return {"ok": False, "msg": "请指定预算,币数量或张数", "sheets": 0, "eth_amount": 0.0, "total_premium": 0.0} + + if sheets < min_sz: + per = premium_per_sheet(quote_per_unit, ct_mult) + return { + "ok": False, + "msg": f"预算不足,无法买入 {min_sz} 张(单张约 {per:.4f} USDC)", + "sheets": sheets, + "eth_amount": eth_amount_from_sheets(sheets, ct_mult), + "total_premium": total_premium(quote_per_unit, eth_amount_from_sheets(sheets, ct_mult)), + } + + eth = eth_amount_from_sheets(sheets, ct_mult) + prem = total_premium(quote_per_unit, eth) + if budget_cap is not None and prem > float(budget_cap) + 1e-9: + return { + "ok": False, + "msg": f"权利金 {prem:.4f} 超过单笔上限 {budget_cap} USDC", + "sheets": sheets, + "eth_amount": eth, + "total_premium": prem, + } + return {"ok": True, "msg": "", "sheets": sheets, "eth_amount": eth, "total_premium": prem} + + +def is_shallow_itm( + *, + opt_type: str, + strike: float, + index_px: float, + max_dist_usd: float, +) -> bool: + o = (opt_type or "").upper() + if o == "C": + if strike >= index_px: + return False + return (index_px - strike) <= max_dist_usd + if o == "P": + if strike <= index_px: + return False + return (strike - index_px) <= max_dist_usd + return False + + +def option_moneyness(*, opt_type: str, strike: float, index_px: float) -> str: + """返回 itm / otm / atm.""" + o = (opt_type or "").upper() + if strike is None or index_px is None or index_px <= 0: + return "unknown" + atm_band = max(index_px * 0.002, 2.0) + if abs(strike - index_px) <= atm_band: + return "atm" + if o == "C": + return "itm" if strike < index_px else "otm" + if o == "P": + return "itm" if strike > index_px else "otm" + return "unknown" + + +def option_moneyness_label(moneyness: str) -> str: + return {"itm": "实值", "otm": "虚值", "atm": "平值"}.get((moneyness or "").lower(), "") + + +def expiry_breakeven_from_ask( + *, + opt_type: str, + strike: float | None, + ask_px: float | None, + mark_px: float | None = None, +) -> float | None: + """买入前预估到期平衡:权利金按卖一;无卖一时回退标记价.""" + prem = ask_px if ask_px is not None and ask_px > 0 else mark_px + return expiry_breakeven_px(opt_type=opt_type, strike=strike, avg_px=prem) + + +def expiry_breakeven_px( + *, + opt_type: str, + strike: float | None, + avg_px: float | None, + be_px_api: float | None = None, +) -> float | None: + """到期平衡点:持有至到期时标的指数盈亏为 0 的价格.优先 OKX bePx.""" + if be_px_api is not None and be_px_api > 0: + return round(float(be_px_api), 2) + if strike is None or avg_px is None: + return None + o = (opt_type or "").upper() + if o == "C": + return round(strike + avg_px, 2) + if o == "P": + return round(strike - avg_px, 2) + return None + + +def close_breakeven_idx( + *, + opt_type: str, + idx_px: float | None, + mark_px: float | None, + avg_px: float | None, + delta_pa: float | None = None, + pos: float = 0, + ct_mult: float = 0.01, +) -> float | None: + """ + 平掉回本:标的指数达到该价位时,按标记价平仓近似盈亏为 0. + 优先用 deltaPA 线性外推,否则用时间价值近似(适合短期轻度实值). + """ + if idx_px is None or mark_px is None or avg_px is None: + return None + eth_amt = abs(float(pos)) * float(ct_mult) + if eth_amt > 1e-12 and delta_pa is not None and abs(float(delta_pa)) > 1e-12: + slope = float(delta_pa) / eth_amt + return round(float(idx_px) + (float(avg_px) - float(mark_px)) / slope, 2) + o = (opt_type or "").upper() + if o == "C": + return round(float(idx_px) + float(avg_px) - float(mark_px), 2) + if o == "P": + return round(float(idx_px) + float(mark_px) - float(avg_px), 2) + return None + + +def idx_distance_to_be(idx_px: float | None, be_px: float | None) -> float | None: + """指数距平衡点(正=指数需上涨才到平衡点).""" + if idx_px is None or be_px is None: + return None + return round(float(be_px) - float(idx_px), 2) + + +def format_options_breakeven_line( + *, + expiry_be_px: float | None, + close_be_px: float | None, + idx_px: float | None = None, +) -> str: + """持仓摘要行:到期平衡 / 平掉回本.""" + parts: list[str] = [] + if expiry_be_px is not None: + parts.append(f"到期平衡{expiry_be_px:.0f}") + if close_be_px is not None: + parts.append(f"平掉回本{close_be_px:.0f}") + if idx_px is not None and parts: + return " ".join(parts) + f"(指数{idx_px:.0f})" + return " ".join(parts) + + +def estimate_expiry_value_at_index( + *, + opt_type: str, + strike: float | None, + target_idx: float | None, + eth_amount: float | None, +) -> float | None: + """到期测算:目标指数价下期权内在价值总额(不含已付权利金).""" + if strike is None or target_idx is None or eth_amount is None: + return None + if eth_amount <= 0: + return None + o = (opt_type or "").upper() + if o == "C": + intrinsic = max(0.0, float(target_idx) - float(strike)) + elif o == "P": + intrinsic = max(0.0, float(strike) - float(target_idx)) + else: + return None + return round(intrinsic * float(eth_amount), 2) + + +def estimate_expiry_profit_at_index( + *, + opt_type: str, + strike: float | None, + target_idx: float | None, + entry_px: float | None, + eth_amount: float | None, + total_premium: float | None = None, +) -> float | None: + """到期测算:目标指数价下净盈利 = 预计价值 − 权利金.""" + value = estimate_expiry_value_at_index( + opt_type=opt_type, + strike=strike, + target_idx=target_idx, + eth_amount=eth_amount, + ) + if value is None: + return None + prem = total_premium + if prem is None and entry_px is not None and eth_amount is not None: + prem = float(entry_px) * float(eth_amount) + if prem is None: + return None + return round(float(value) - float(prem), 2) + + +def equivalent_contract_leverage( + *, + index_px: float | None, + eth_amount: float | None, + total_premium: float | None, +) -> float | None: + """名义价值 / 权利金,近似相当于永续合约杠杆倍数(测算用).""" + if index_px is None or eth_amount is None or total_premium is None: + return None + if eth_amount <= 0 or total_premium <= 0: + return None + return round(float(index_px) * float(eth_amount) / float(total_premium), 1) + + +def straddle_ask_per_unit( + call_ask: float | None, + put_ask: float | None, +) -> float | None: + """跨式双买:每 1 标的币的卖一报价之和.""" + if call_ask is None or put_ask is None: + return None + if float(call_ask) <= 0 or float(put_ask) <= 0: + return None + return round(float(call_ask) + float(put_ask), 4) + + +def straddle_premium_total( + call_ask: float | None, + put_ask: float | None, + eth_amount: float | None, +) -> float | None: + """跨式双买权利金总额(USDC).""" + per = straddle_ask_per_unit(call_ask, put_ask) + if per is None or eth_amount is None or float(eth_amount) <= 0: + return None + return round(per * float(eth_amount), 2) + + +def straddle_breakeven_band( + strike: float | None, + combined_ask_per_unit: float | None, +) -> tuple[float | None, float | None]: + """跨式到期平衡带:下平衡 ~ 上平衡(按双卖一报价和).""" + if strike is None or combined_ask_per_unit is None: + return None, None + k = float(strike) + d = float(combined_ask_per_unit) + return round(k - d, 2), round(k + d, 2) + + +def format_straddle_band( + strike: float | None, + combined_ask_per_unit: float | None, +) -> str: + lo, hi = straddle_breakeven_band(strike, combined_ask_per_unit) + if lo is None or hi is None: + return "" + return f"{lo:.0f} ~ {hi:.0f}" diff --git a/lib/options/options_register.py b/lib/options/options_register.py new file mode 100644 index 0000000..408516a --- /dev/null +++ b/lib/options/options_register.py @@ -0,0 +1,1283 @@ +"""OKX 期权模块:Flask 路由注册.""" +from __future__ import annotations + +import os +import threading +import time +from typing import Any + +from flask import Flask, jsonify, redirect, request, url_for +from jinja2 import ChoiceLoader, FileSystemLoader + +from lib.options.options_db import init_options_tables, sum_open_premium_paid, sum_open_sheets +from lib.options.options_monitor_lib import options_monitor_loop +from lib.options.options_pricing_lib import ( + calc_order_size, + ct_mult_from_meta, + min_sz_from_meta, + premium_per_sheet, +) +from lib.exchange.okx_options_lib import ( + _safe_float, + cap_option_buy_sheets_to_ask_depth, + option_buy_liquidity_ok, + td_mode_for_option_buy, +) + + +def _env_bool(key: str, default: bool = False) -> bool: + raw = (os.getenv(key) or "").strip().lower() + if not raw: + return default + return raw in ("1", "true", "yes", "on") + + +def _env_float(key: str, default: float) -> float: + try: + return float(os.getenv(key, str(default))) + except (TypeError, ValueError): + return default + + +def attach_options_templates(app: Flask, repo_root: str) -> None: + tpl_dir = os.path.join(repo_root, "lib", "options", "templates") + if not os.path.isdir(tpl_dir): + return + existing = app.jinja_loader + loaders = [FileSystemLoader(tpl_dir)] + if existing is not None: + if isinstance(existing, ChoiceLoader): + loaders = list(existing.loaders) + loaders + else: + loaders.insert(0, existing) + app.jinja_loader = ChoiceLoader(loaders) + + +def install_options_trading(app: Flask, repo_root: str, app_module: Any) -> None: + enabled = _env_bool("OKX_OPTIONS_ENABLED", False) + attach_options_templates(app, repo_root) + cfg = _build_cfg(app_module) + app.extensions["options_cfg"] = cfg + register_options_routes(app, cfg) + _register_options_hub_bridge(app, cfg) + if enabled: + _start_monitor_thread(app, cfg) + + +def _register_options_hub_bridge(app: Flask, cfg: dict[str, Any]) -> None: + from lib.options.options_hub_lib import build_options_hub_snapshot + + def snapshot_fn(): + return build_options_hub_snapshot(cfg) + + hub_ctx = dict(app.config.get("HUB_CTX") or {}) + hub_ctx["options_snapshot_fn"] = snapshot_fn + app.config["HUB_CTX"] = hub_ctx + + +def _build_cfg(app_module: Any) -> dict[str, Any]: + from lib.exchange.okx_options_lib import ( + build_option_chain, + estimate_usdt_to_usdc, + execute_convert, + fetch_option_book_depth, + fetch_option_positions, + fetch_options_balances, + format_position_row, + options_api_ready, + cancel_option_order, + fetch_option_pending_orders, + place_option_limit_order, + place_option_market_order, + quote_option_contract, + spot_market_swap_usdt_usdc, + transfer_ccy, + transfer_main_sub_account, + ) + + return { + "enabled": _env_bool("OKX_OPTIONS_ENABLED", False), + "sub_account_name": (os.getenv("OKX_SUB_ACCOUNT_NAME") or "").strip(), + "get_db": app_module.get_db, + "login_required": app_module.login_required, + "exchange_options": getattr(app_module, "exchange_options", None), + "send_wechat": app_module.send_wechat_msg, + "render_main_page": app_module.render_main_page, + "trade_budget": _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", 10.0), + "budget_buffer": _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95), + "default_underly": (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper(), + "max_dte_days": _env_float("OKX_OPTIONS_MAX_DTE_DAYS", 2.0), + "chain_max_dte_days": _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", 14.0), + "itm_max_dist": _env_float("OKX_OPTIONS_ITM_MAX_DIST_USD", 30.0), + "td_mode": (os.getenv("OKX_OPTIONS_TD_MODE") or "isolated").strip(), + # 市价平仓已硬关闭(忽略 env),仅买一限价 + "allow_market_close": False, + # 平仓限价挂单超时自动撤单(秒);默认 600=10 分钟,联调可设 60 + "pending_ttl_seconds": _env_float("OKX_OPTIONS_PENDING_TTL_SECONDS", 600.0), + "profit_ratio": _env_float("OKX_OPTIONS_PROFIT_ALERT_RATIO", 1.0), + "poll_seconds": _env_float("OKX_OPTIONS_POLL_SECONDS", 15.0), + "account_label": (os.getenv("OKX_OPTIONS_ACCOUNT_LABEL") or "OKX期权").strip(), + "build_option_chain": build_option_chain, + "quote_option_contract": quote_option_contract, + "fetch_option_book_depth": fetch_option_book_depth, + "place_option_limit_order": place_option_limit_order, + "place_option_market_order": place_option_market_order, + "fetch_option_pending_orders": fetch_option_pending_orders, + "cancel_option_order": cancel_option_order, + "fetch_option_positions": fetch_option_positions, + "fetch_options_balances": fetch_options_balances, + "format_position_row": format_position_row, + "estimate_usdt_to_usdc": estimate_usdt_to_usdc, + "execute_convert": execute_convert, + "transfer_ccy": transfer_ccy, + "spot_market_swap_usdt_usdc": spot_market_swap_usdt_usdc, + "transfer_main_sub_account": transfer_main_sub_account, + "options_api_ready": options_api_ready, + "app_module": app_module, + } + + +def _mark_balances_stale(cfg: dict[str, Any]) -> None: + from lib.exchange.okx_options_lib import invalidate_options_balance_cache + from lib.instance.instance_live_push_lib import notify_instance_balance_changed + + invalidate_options_balance_cache() + app_mod = cfg.get("app_module") + if app_mod is not None and hasattr(app_mod, "invalidate_account_balance_cache"): + app_mod.invalidate_account_balance_cache() + try: + notify_instance_balance_changed() + except Exception: + pass + + +def _require_options_ex(cfg: dict[str, Any]): + if not cfg.get("enabled"): + return None, "期权模块未启用,请在 .env 设置 OKX_OPTIONS_ENABLED=true 并重启 PM2" + ex = cfg.get("exchange_options") + ok, reason = cfg["options_api_ready"](ex) + if not ok: + return None, reason or "期权 API 未配置" + return ex, "" + + +def _budget_full_usdc(cfg: dict[str, Any], ex: Any) -> tuple[float | None, str]: + """交易账户 USDC 可用余额(由 calc_order_size 再乘 budget_buffer 留余量).""" + from lib.exchange.okx_options_lib import fetch_options_trading_usdc + + raw = fetch_options_trading_usdc(ex) + if raw is None or float(raw) <= 0: + return None, "交易账户 USDC 可用余额不足" + return float(raw), "" + + +def _open_premium_paid(cfg: dict[str, Any], inst_id: str) -> float | None: + conn = cfg["get_db"]() + try: + init_options_tables(conn) + return sum_open_premium_paid(conn, inst_id) + finally: + conn.close() + + +def _position_avail_sheets(pos: dict[str, Any]) -> int: + avail = _safe_float(pos.get("availPos")) + if avail is None or avail <= 0: + avail = abs(_safe_float(pos.get("pos")) or 0) + return max(0, int(avail or 0)) + + +def _find_position(rows: list[dict[str, Any]] | None, inst_id: str) -> dict[str, Any] | None: + return next((p for p in rows or [] if str(p.get("instId")) == inst_id), None) + + +def _refresh_position_avail(cfg: dict[str, Any], ex: Any, inst_id: str) -> int | None: + from lib.exchange.okx_options_lib import invalidate_option_positions_cache + + invalidate_option_positions_cache() + raw = cfg["fetch_option_positions"](ex) + if raw is None: + return None + pos = _find_position(raw, inst_id) + if not pos: + return 0 + return _position_avail_sheets(pos) + + +def _enrich_position_row_display( + cfg: dict[str, Any], + ex: Any, + raw_pos: dict[str, Any], + *, + meta_cache: dict[str, dict[str, Any] | None] | None = None, + premium_override: float | None = None, +) -> dict[str, Any]: + from lib.options.options_history_lib import enrich_position_row_display + + return enrich_position_row_display( + cfg, + ex, + raw_pos, + meta_cache=meta_cache, + premium_override=premium_override, + ) + + +def _attach_close_preview( + cfg: dict[str, Any], + ex: Any, + row: dict[str, Any], + *, + sheets: int | None = None, + premium_paid: float | None = None, +) -> dict[str, Any]: + from lib.options.options_positions_lib import attach_close_preview + + return attach_close_preview( + cfg, + ex, + row, + sheets=sheets, + premium_paid=premium_paid, + ) + + +_OPTIONS_SYNC_LOCK = threading.Lock() +_OPTIONS_SYNC_LAST_AT = 0.0 +_OPTIONS_SYNC_INTERVAL_SEC = 15.0 + + +def _sync_options_trades( + cfg: dict[str, Any], + *, + raw_positions: list[dict[str, Any]] | None = None, + force: bool = False, +) -> None: + global _OPTIONS_SYNC_LAST_AT + ex = cfg.get("exchange_options") + if ex is None: + return + now = time.time() + with _OPTIONS_SYNC_LOCK: + if not force and now - _OPTIONS_SYNC_LAST_AT < _OPTIONS_SYNC_INTERVAL_SEC: + return + _OPTIONS_SYNC_LAST_AT = now + from lib.exchange.okx_options_lib import fetch_option_position_history + from lib.options.options_monitor_lib import reconcile_live_open_trades, sync_open_options_trades + + if raw_positions is None: + raw = cfg["fetch_option_positions"](ex) + if raw is None: + return + else: + raw = raw_positions + live_ids = {str(p.get("instId") or "") for p in raw if str(p.get("instId") or "")} + + def _hist(inst_id: str): + return fetch_option_position_history(ex, inst_id) + + conn = cfg["get_db"]() + try: + init_options_tables(conn) + reconcile_live_open_trades(conn, live_inst_ids=live_ids) + sync_open_options_trades(conn, live_inst_ids=live_ids, fetch_history_fn=_hist) + conn.commit() + finally: + conn.close() + + +def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: + lr = cfg["login_required"] + + @app.route("/options/guide") + @lr + def options_trade_guide(): + """期权开平仓与监控说明(独立页).""" + from pathlib import Path + + from flask import render_template_string + + from lib.hub.hub_strategy_lib import render_markdown_html + + md_path = Path(__file__).resolve().parents[2] / "docs" / "期权开平仓与监控说明.md" + try: + md_text = md_path.read_text(encoding="utf-8") + except OSError: + md_text = "# 说明文档缺失\n\n未找到 `docs/期权开平仓与监控说明.md`." + body = render_markdown_html(md_text) + return render_template_string( + """ + + + + + + 期权开平仓与监控说明 + + + +

        ← 返回期权 · 对冲计划

        + {{ body|safe }} + + + """, + body=body, + ) + + @app.route("/api/options/balances") + @lr + def api_options_balances(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes") + scope = (request.args.get("scope") or "main").strip().lower() + bal = cfg["fetch_options_balances"]( + ex, + force=force, + scope=scope, + sub_acct=cfg.get("sub_account_name") or "", + ) + return jsonify({"ok": True, **bal, "trade_budget": cfg["trade_budget"]}) + + @app.route("/api/options/chain") + @lr + def api_options_chain(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + u = (request.args.get("underlying") or cfg["default_underly"]).upper() + try: + chain = cfg["build_option_chain"]( + ex, + u, + max_dte_days=cfg["chain_max_dte_days"], + itm_only=False, + itm_max_dist_usd=cfg["itm_max_dist"], + ) + except Exception as e: + return jsonify({"ok": False, "msg": f"加载期权链失败: {e}"}) + expiries = chain.get("expiries") or [] + chain_err = chain.get("chain_error") + if not expiries: + return jsonify( + { + "ok": False, + "msg": chain_err or "暂无到期日,请稍后点「刷新链」", + **chain, + "chain_max_dte_days": cfg["chain_max_dte_days"], + } + ) + return jsonify({"ok": True, **chain, "chain_max_dte_days": cfg["chain_max_dte_days"]}) + + @app.route("/api/options/quote") + @lr + def api_options_quote(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + inst_id = (request.args.get("inst_id") or "").strip() + if not inst_id: + return jsonify({"ok": False, "msg": "缺少 inst_id"}) + q = cfg["quote_option_contract"](ex, inst_id) + if not q.get("ok"): + return jsonify(q) + ask = q.get("ask") + ct_mult = q.get("ct_mult") or 0.01 + min_sz = q.get("min_sz") or 1 + mode = (request.args.get("mode") or "budget_full").strip() + sheet_count = None + try: + if request.args.get("sheets"): + sheet_count = int(request.args.get("sheets")) + except (TypeError, ValueError): + pass + if mode == "close_preview": + paid = _open_premium_paid(cfg, inst_id) + target = sheet_count if sheet_count is not None else 0 + return jsonify(_attach_close_preview(cfg, ex, {**q, "pos": target, "premium_paid": paid}, sheets=target, premium_paid=paid)) + budget = cfg["trade_budget"] + budget_cap = cfg["trade_budget"] + available_usdc = None + if mode == "budget_full": + budget, budget_err = _budget_full_usdc(cfg, ex) + if budget is None: + return jsonify({"ok": False, "msg": budget_err}) + budget_cap = budget + from lib.exchange.okx_options_lib import fetch_options_trading_usdc + + available_usdc = fetch_options_trading_usdc(ex) + eth_amount = None + try: + if request.args.get("eth_amount"): + eth_amount = float(request.args.get("eth_amount")) + except (TypeError, ValueError): + pass + ask = q.get("ask") + ask_sz = q.get("ask_sz") + can_open, block_msg = option_buy_liquidity_ok(ask, ask_sz) + if not can_open: + # 合约可报价,但不可开仓:返回参考标记价供展示 + return jsonify( + { + **q, + "ok": True, + "can_open": False, + "msg": block_msg or q.get("open_block_msg") or "暂无卖一深度,无法买入", + "quote_per_unit": None, + "premium_per_sheet": None, + "sizing": { + "ok": False, + "msg": block_msg or "暂无卖一深度,无法买入", + "sheets": 0, + "eth_amount": 0.0, + "total_premium": 0.0, + }, + "available_usdc": available_usdc, + "budget_full_usdc": budget if mode == "budget_full" else None, + } + ) + sizing = calc_order_size( + quote_per_unit=float(ask), + ct_mult=float(ct_mult), + min_sz=int(min_sz), + budget_usdc=budget if mode == "budget_full" else None, + budget_buffer=cfg["budget_buffer"], + eth_amount=eth_amount if mode == "eth_amount" else None, + sheets=sheet_count if mode == "sheets" else None, + budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None, + ) + if sizing.get("ok"): + capped, cap_msg = cap_option_buy_sheets_to_ask_depth( + int(sizing.get("sheets") or 0), + ask_sz, + min_sz=int(min_sz), + ) + if capped is None: + sizing = { + "ok": False, + "msg": cap_msg, + "sheets": 0, + "eth_amount": 0.0, + "total_premium": 0.0, + } + elif capped < int(sizing.get("sheets") or 0): + sizing = calc_order_size( + quote_per_unit=float(ask), + ct_mult=float(ct_mult), + min_sz=int(min_sz), + sheets=capped, + budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None, + ) + if sizing.get("ok"): + sizing["ask_depth_capped"] = True + sizing["ask_sz"] = ask_sz + sizing["msg"] = f"已按卖一深度限制为 {capped} 张" + q = _attach_close_preview( + cfg, + ex, + q, + sheets=int(sizing.get("sheets") or sheet_count or 0), + premium_paid=_open_premium_paid(cfg, inst_id), + ) + return jsonify( + { + **q, + "can_open": True, + "quote_per_unit": ask, + "premium_per_sheet": premium_per_sheet(float(ask), float(ct_mult)), + "sizing": sizing, + "available_usdc": available_usdc, + "budget_full_usdc": budget if mode == "budget_full" else None, + } + ) + + @app.route("/api/options/open", methods=["POST"]) + @lr + def api_options_open(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + data = request.get_json(silent=True) or {} + inst_id = (data.get("inst_id") or "").strip() + mode = (data.get("mode") or "budget_full").strip() + signal_note = (data.get("signal_note") or "").strip() + target_index = None + raw_target = data.get("target_index") + if raw_target is not None and str(raw_target).strip() != "": + try: + target_index = float(raw_target) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "目标位无效"}) + if target_index <= 0: + return jsonify({"ok": False, "msg": "目标位无效"}) + if not inst_id: + return jsonify({"ok": False, "msg": "缺少 inst_id"}) + q = cfg["quote_option_contract"](ex, inst_id) + if not q.get("ok"): + return jsonify(q) + ask = q.get("ask") + ask_sz = q.get("ask_sz") + can_open, block_msg = option_buy_liquidity_ok(ask, ask_sz) + if not can_open: + return jsonify( + { + "ok": False, + "msg": block_msg or q.get("open_block_msg") or "暂无卖一深度,无法买入", + "can_open": False, + "mark": q.get("mark"), + "ref_ask": q.get("ref_ask"), + } + ) + ct_mult = float(q.get("ct_mult") or 0.01) + min_sz = int(q.get("min_sz") or 1) + eth_amount = None + sheet_count = None + if mode == "eth_amount": + try: + eth_amount = float(data.get("eth_amount")) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "ETH 数量无效"}) + elif mode == "sheets": + try: + sheet_count = int(data.get("sheets")) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "张数无效"}) + budget = cfg["trade_budget"] + budget_cap = cfg["trade_budget"] + if mode == "budget_full": + budget, budget_err = _budget_full_usdc(cfg, ex) + if budget is None: + return jsonify({"ok": False, "msg": budget_err}) + budget_cap = budget + sizing = calc_order_size( + quote_per_unit=float(ask), + ct_mult=ct_mult, + min_sz=min_sz, + budget_usdc=budget if mode == "budget_full" else None, + budget_buffer=cfg["budget_buffer"], + eth_amount=eth_amount, + sheets=sheet_count, + budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None, + ) + if not sizing.get("ok"): + return jsonify({"ok": False, "msg": sizing.get("msg") or "张数计算失败", "sizing": sizing}) + sheets = int(sizing["sheets"]) + capped, cap_msg = cap_option_buy_sheets_to_ask_depth(sheets, ask_sz, min_sz=min_sz) + if capped is None: + return jsonify({"ok": False, "msg": cap_msg or "卖一深度不足,无法买入"}) + if capped < sheets: + sizing = calc_order_size( + quote_per_unit=float(ask), + ct_mult=ct_mult, + min_sz=min_sz, + sheets=capped, + budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None, + ) + if not sizing.get("ok"): + return jsonify({"ok": False, "msg": sizing.get("msg") or "张数计算失败", "sizing": sizing}) + sheets = int(sizing["sheets"]) + tick_sz = q.get("tick_sz") + order = cfg["place_option_limit_order"]( + ex, + inst_id=inst_id, + side="buy", + sheets=sheets, + price=float(ask), + td_mode=td_mode_for_option_buy(cfg["td_mode"]), + tick_sz=tick_sz, + ) + if not order.get("ok"): + return jsonify(order) + conn = cfg["get_db"]() + trade_id = None + target_mon = None + try: + init_options_tables(conn) + meta = q.get("meta") or {} + u = str(meta.get("uly") or inst_id).split("-")[0] + opt_type = meta.get("optType") + cur = conn.execute( + """ + INSERT INTO options_trades + (inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount, + open_quote, premium_paid, status, signal_note, exchange_ord_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?) + """, + ( + inst_id, + u, + opt_type, + q.get("strike"), + str(q.get("exp_time") or ""), + sheets, + sizing["eth_amount"], + float(ask), + sizing["total_premium"], + signal_note, + (order.get("data") or {}).get("ordId"), + ), + ) + trade_id = int(cur.lastrowid) + if target_index is not None: + from lib.options.options_target_lib import upsert_target_monitor + + target_mon = upsert_target_monitor( + conn, + inst_id=inst_id, + target_index=target_index, + underlying=u, + opt_type=str(opt_type) if opt_type else None, + trade_id=trade_id, + sheets=sheets, + ) + conn.commit() + finally: + conn.close() + from lib.exchange.okx_options_lib import invalidate_option_positions_cache + + invalidate_option_positions_cache() + _sync_options_trades(cfg, force=True) + return jsonify( + { + "ok": True, + "order": order, + "sizing": sizing, + "trade_id": trade_id, + "target_monitor": target_mon, + } + ) + + @app.route("/api/options/orders/pending") + @lr + def api_options_orders_pending(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + inst_id = (request.args.get("inst_id") or "").strip() or None + try: + orders = cfg["fetch_option_pending_orders"](ex, inst_id) + except Exception as e: + return jsonify({"ok": False, "msg": f"获取委托失败: {e}"}) + from lib.options.options_pending_lib import enrich_pending_orders + + ttl = float(cfg.get("pending_ttl_seconds") or 600.0) + enriched = enrich_pending_orders(orders, ttl_seconds=ttl) + return jsonify( + { + "ok": True, + "orders": enriched, + "count": len(enriched), + "pending_ttl_seconds": ttl, + } + ) + + @app.route("/api/options/orders/cancel", methods=["POST"]) + @lr + def api_options_orders_cancel(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + data = request.get_json(silent=True) or {} + inst_id = (data.get("inst_id") or "").strip() + ord_id = (data.get("ord_id") or "").strip() + if not inst_id or not ord_id: + return jsonify({"ok": False, "msg": "缺少 inst_id 或 ord_id"}) + out = cfg["cancel_option_order"](ex, inst_id=inst_id, ord_id=ord_id) + if out.get("ok"): + from lib.exchange.okx_options_lib import invalidate_option_positions_cache + + invalidate_option_positions_cache() + # 本地未成交开仓记录标记取消,避免假 open + try: + conn = cfg["get_db"]() + try: + init_options_tables(conn) + conn.execute( + """ + UPDATE options_trades + SET status = 'cancelled', + signal_note = CASE + WHEN signal_note IS NULL OR TRIM(signal_note) = '' THEN '委托撤销' + ELSE signal_note + END, + closed_at = CURRENT_TIMESTAMP + WHERE inst_id = ? AND exchange_ord_id = ? AND status = 'open' + """, + (inst_id, ord_id), + ) + conn.commit() + finally: + conn.close() + except Exception: + pass + _sync_options_trades(cfg, force=True) + return jsonify(out), (200 if out.get("ok") else 400) + + @app.route("/api/options/positions") + @lr + def api_options_positions(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + raw = cfg["fetch_option_positions"](ex) + if raw is None: + return jsonify({"ok": False, "msg": "获取期权持仓失败"}) + _sync_options_trades(cfg, raw_positions=raw) + meta_cache: dict[str, dict[str, Any] | None] = {} + conn = cfg["get_db"]() + try: + from lib.options.options_target_lib import targets_by_inst + from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst + + tgt_map = targets_by_inst(conn) + hedge_target_map = active_options_targets_by_inst(conn) + rows = [] + for p in raw: + inst = str(p.get("instId") or "").strip() + premium_override = sum_open_premium_paid(conn, inst) if inst else None + row = _enrich_position_row_display( + cfg, + ex, + p, + meta_cache=meta_cache, + premium_override=premium_override, + ) + _attach_close_preview(cfg, ex, row, premium_paid=_safe_float(row.get("premium_paid"))) + mon = tgt_map.get(inst) + if mon: + row["target_index"] = mon.get("target_index") + row["target_monitor_id"] = mon.get("id") + row["target_monitor"] = mon + hedge_target = hedge_target_map.get(inst) + if hedge_target: + row["hedge_plan_target"] = hedge_target + rows.append(row) + finally: + conn.close() + return jsonify({"ok": True, "positions": rows}) + + @app.route("/api/options/targets") + @lr + def api_options_targets(): + conn = cfg["get_db"]() + try: + from lib.options.options_target_lib import list_active_targets, list_closing_targets + + return jsonify({"ok": True, "targets": list_active_targets(conn) + list_closing_targets(conn)}) + finally: + conn.close() + + @app.route("/api/options/target", methods=["POST"]) + @lr + def api_options_target_set(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + data = request.get_json(silent=True) or {} + inst_id = (data.get("inst_id") or "").strip() + if not inst_id: + return jsonify({"ok": False, "msg": "缺少 inst_id"}) + try: + target_index = float(data.get("target_index")) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "目标位无效"}) + if target_index <= 0: + return jsonify({"ok": False, "msg": "目标位无效"}) + raw = cfg["fetch_option_positions"](ex) + if raw is None: + return jsonify({"ok": False, "msg": "获取期权持仓失败"}) + pos = _find_position(raw, inst_id) + if not pos: + return jsonify({"ok": False, "msg": "未找到持仓"}) + from lib.options.options_target_lib import upsert_target_monitor + + fmt = cfg["format_position_row"](pos) + conn = cfg["get_db"]() + try: + trade = conn.execute( + """ + SELECT id, opt_type, underlying FROM options_trades + WHERE inst_id = ? AND status = 'open' + ORDER BY id DESC LIMIT 1 + """, + (inst_id,), + ).fetchone() + trade_id = int(trade["id"]) if trade else None + sheets_sum = sum_open_sheets(conn, inst_id) + sheets = sheets_sum if sheets_sum is not None else int(fmt.get("pos") or 0) + opt_type = (trade["opt_type"] if trade else None) or fmt.get("opt_type") + underlying = (trade["underlying"] if trade else None) or fmt.get("underlying") + out = upsert_target_monitor( + conn, + inst_id=inst_id, + target_index=target_index, + underlying=str(underlying) if underlying else None, + opt_type=str(opt_type) if opt_type else None, + trade_id=trade_id, + sheets=sheets, + ) + conn.commit() + return jsonify(out) + finally: + conn.close() + + @app.route("/api/options/target/cancel", methods=["POST"]) + @lr + def api_options_target_cancel(): + data = request.get_json(silent=True) or {} + inst_id = (data.get("inst_id") or "").strip() or None + monitor_id = data.get("id") + try: + mid = int(monitor_id) if monitor_id is not None and str(monitor_id).strip() != "" else None + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "监控 id 无效"}) + if not inst_id and mid is None: + return jsonify({"ok": False, "msg": "缺少 inst_id 或 id"}) + from lib.options.options_target_lib import cancel_target_monitor + + conn = cfg["get_db"]() + try: + n = cancel_target_monitor(conn, inst_id=inst_id, monitor_id=mid) + conn.commit() + return jsonify({"ok": True, "cancelled": n}) + finally: + conn.close() + + @app.route("/api/options/close", methods=["POST"]) + @lr + def api_options_close(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + data = request.get_json(silent=True) or {} + inst_id = (data.get("inst_id") or "").strip() + if not inst_id: + return jsonify({"ok": False, "msg": "缺少 inst_id"}) + if data.get("market"): + return jsonify({"ok": False, "msg": "已禁用市价平仓,仅支持买一限价"}) + sheets = data.get("sheets") + try: + sheets_i = int(sheets) if sheets is not None and str(sheets).strip() != "" else None + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "张数无效"}) + from lib.options.options_close_exec_lib import close_option_by_bid1 + + # 手动买一平仓:只验有效流动性;2×门控仅用于自动/目标位平仓 + result = close_option_by_bid1( + cfg, + ex, + inst_id, + sheets=sheets_i, + require_recycle_gate=False, + ) + if result.get("ok"): + from lib.exchange.okx_options_lib import invalidate_option_positions_cache + + invalidate_option_positions_cache() + _sync_options_trades(cfg, force=True) + if result.get("fully_closed"): + try: + from lib.options.options_target_lib import cancel_target_monitor + + conn2 = cfg["get_db"]() + try: + cancel_target_monitor(conn2, inst_id=inst_id) + conn2.commit() + finally: + conn2.close() + except Exception: + pass + _mark_balances_stale(cfg) + return jsonify(result) + + @app.route("/api/options/convert/quote", methods=["POST"]) + @lr + def api_options_convert_quote(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + data = request.get_json(silent=True) or {} + try: + amount = float(data.get("amount")) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "数量无效"}) + return jsonify(cfg["estimate_usdt_to_usdc"](ex, amount)) + + @app.route("/api/options/convert/execute", methods=["POST"]) + @lr + def api_options_convert_execute(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + data = request.get_json(silent=True) or {} + quote_id = (data.get("quote_id") or "").strip() + result = cfg["execute_convert"](ex, quote_id) + if result.get("ok"): + conn = cfg["get_db"]() + try: + init_options_tables(conn) + conn.execute( + """ + INSERT INTO options_convert_log (from_ccy, to_ccy, rfq_sz, received_sz, quote_id, status, message) + VALUES ('USDT', 'USDC', ?, ?, ?, 'ok', '') + """, + ( + data.get("rfq_sz"), + (result.get("data") or {}).get("baseSz"), + quote_id, + ), + ) + conn.commit() + finally: + conn.close() + return jsonify(result) + + @app.route("/api/options/transfer", methods=["POST"]) + @lr + def api_options_transfer(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + data = request.get_json(silent=True) or {} + ccy = (data.get("ccy") or "USDC").upper() + from_acct = (data.get("from") or "funding").strip() + to_acct = (data.get("to") or "trading").strip() + try: + amount = float(data.get("amount")) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "数量无效"}) + result = cfg["transfer_ccy"](ex, ccy, amount, from_acct, to_acct) + if result.get("ok"): + conn = cfg["get_db"]() + try: + init_options_tables(conn) + conn.execute( + """ + INSERT INTO options_transfer_log (ccy, amount, from_account, to_account, status, message) + VALUES (?, ?, ?, ?, 'ok', '') + """, + (ccy, amount, from_acct, to_acct), + ) + conn.commit() + finally: + conn.close() + _mark_balances_stale(cfg) + return jsonify(result) + + @app.route("/api/options/spot/swap", methods=["POST"]) + @lr + def api_options_spot_swap(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + data = request.get_json(silent=True) or {} + direction = (data.get("direction") or "usdt_to_usdc").strip() + try: + amount = float(data.get("amount")) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "数量无效"}) + result = cfg["spot_market_swap_usdt_usdc"](ex, direction=direction, amount=amount) + if result.get("ok"): + _mark_balances_stale(cfg) + return jsonify(result) + + @app.route("/api/options/cross-transfer", methods=["POST"]) + @lr + def api_options_cross_transfer(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + data = request.get_json(silent=True) or {} + ccy = (data.get("ccy") or "USDT").upper() + direction = (data.get("direction") or "sub_to_main").strip() + from_account = (data.get("from_account") or data.get("account") or "funding").strip() + to_account = (data.get("to_account") or data.get("account") or "funding").strip() + try: + amount = float(data.get("amount")) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "数量无效"}) + main_to_sub = direction == "main_to_sub" + result = cfg["transfer_main_sub_account"]( + ex, + ccy=ccy, + amount=amount, + sub_acct=cfg.get("sub_account_name") or "", + main_to_sub=main_to_sub, + from_account=from_account, + to_account=to_account, + ) + if result.get("ok"): + conn = cfg["get_db"]() + try: + init_options_tables(conn) + conn.execute( + """ + INSERT INTO options_transfer_log (ccy, amount, from_account, to_account, status, message) + VALUES (?, ?, ?, ?, 'ok', ?) + """, + ( + ccy, + amount, + ("main" if main_to_sub else "sub") + ":" + from_account, + ("sub" if main_to_sub else "main") + ":" + to_account, + "cross", + ), + ) + conn.commit() + finally: + conn.close() + _mark_balances_stale(cfg) + return jsonify(result) + + @app.route("/api/options/history") + @lr + def api_options_history(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + from lib.options.options_history_lib import load_options_history + + raw_live = cfg["fetch_option_positions"](ex) + if raw_live is None: + return jsonify({"ok": False, "msg": "获取期权持仓失败"}) + history = load_options_history(ex, cfg) + live_ids = {str(x.get("inst_id") or "") for x in history if x.get("status") == "open"} + return jsonify({"ok": True, "history": history, "live_inst_ids": sorted(live_ids)}) + + @app.route("/api/options/stats") + @lr + def api_options_stats(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + from lib.options.options_history_lib import load_options_history + from lib.options.options_positions_lib import sum_options_net_pnl_usdc + from lib.options.options_stats_lib import compute_options_stats_from_history + + raw_live = cfg["fetch_option_positions"](ex) + if raw_live is None: + return jsonify({"ok": False, "msg": "获取期权持仓失败"}) + history = load_options_history(ex, cfg) + stats = compute_options_stats_from_history(history) + open_float = sum_options_net_pnl_usdc(cfg, ex, raw_live) + net_realized = _safe_float(stats.get("net_realized_pnl")) or 0.0 + total_pnl = None + if open_float is not None: + total_pnl = round(net_realized + float(open_float), 4) + elif stats.get("total_closed"): + total_pnl = round(net_realized, 4) + return jsonify( + { + "ok": True, + **stats, + "open_float_pnl": open_float, + "total_pnl": total_pnl, + } + ) + + @app.route("/api/options/history/", methods=["DELETE"]) + @lr + def api_options_history_delete(history_key: str): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + key = (history_key or "").strip() + if not key: + return jsonify({"ok": False, "msg": "缺少 history_key"}) + data = request.get_json(silent=True) or {} + inst_id = str(data.get("inst_id") or request.args.get("inst_id") or "").strip() or None + closed_at = str(data.get("closed_at") or request.args.get("closed_at") or "").strip() or None + conn = cfg["get_db"]() + try: + init_options_tables(conn) + conn.execute( + "INSERT OR IGNORE INTO options_history_hidden (history_key) VALUES (?)", + (key,), + ) + # 同步隐藏期权复盘,避免本地已平记录刷新后又出现 + try: + from lib.options.options_review_lib import hide_review_keys + + hide_review_keys( + conn, + history_key=key, + inst_id=inst_id, + closed_at=closed_at, + ) + if inst_id: + # 去掉已导入的复盘快照(按合约+平仓时间) + if closed_at: + rows = conn.execute( + """ + SELECT id, history_key FROM options_review_trades + WHERE inst_id = ? + AND substr(COALESCE(closed_at,''),1,16) = substr(?,1,16) + """, + (inst_id, closed_at), + ).fetchall() + else: + rows = conn.execute( + """ + SELECT id, history_key FROM options_review_trades + WHERE inst_id = ? + """, + (inst_id,), + ).fetchall() + for r in rows: + conn.execute( + "DELETE FROM options_review_entries WHERE trade_id=?", + (int(r["id"]),), + ) + conn.execute( + "DELETE FROM options_review_trades WHERE id=?", + (int(r["id"]),), + ) + conn.execute( + "INSERT OR IGNORE INTO options_review_hidden(history_key, inst_id, closed_at) VALUES (?,?,?)", + (str(r["history_key"]), inst_id, (closed_at or "")[:19] or None), + ) + fps = [] + if closed_at: + fps.append(f"inst_close:{inst_id}:{closed_at[:16]}") + fps.append(f"inst:{inst_id}") + for fp in fps: + conn.execute( + "INSERT OR IGNORE INTO options_review_hidden(history_key, inst_id, closed_at) VALUES (?,?,?)", + (fp, inst_id, (closed_at or "")[:19] or None), + ) + except Exception: + pass + conn.commit() + finally: + conn.close() + return jsonify({"ok": True}) + + +def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None: + if app.extensions.get("options_monitor_started"): + return + app.extensions["options_monitor_started"] = True + + def _bid(inst_id: str) -> float | None: + ex = cfg.get("exchange_options") + if ex is None: + return None + try: + q = cfg["quote_option_contract"](ex, inst_id) + return q.get("bid") + except Exception: + return None + + def _positions(): + ex = cfg.get("exchange_options") + if ex is None: + return [] + raw = cfg["fetch_option_positions"](ex) + if raw is None: + return [] + return [cfg["format_position_row"](p) for p in raw] + + def _sync(conn): + from lib.exchange.okx_options_lib import fetch_option_position_history + from lib.options.options_monitor_lib import reconcile_live_open_trades, sync_open_options_trades + + ex = cfg.get("exchange_options") + if ex is None: + return 0 + raw = cfg["fetch_option_positions"](ex) + if raw is None: + return 0 + live_ids = {str(p.get("instId") or "") for p in raw if str(p.get("instId") or "")} + reconcile_live_open_trades(conn, live_inst_ids=live_ids) + return sync_open_options_trades( + conn, + live_inst_ids=live_ids, + fetch_history_fn=lambda inst_id: fetch_option_position_history(ex, inst_id), + ) + + def _target_close(inst_id: str) -> dict[str, Any]: + from lib.options.options_target_lib import close_option_by_bid_depth + + ex = cfg.get("exchange_options") + if ex is None: + return {"ok": False, "msg": "期权 exchange 未就绪"} + result = close_option_by_bid_depth(cfg, ex, inst_id) + if result.get("ok"): + try: + _sync_options_trades(cfg, force=True) + except Exception: + pass + try: + _mark_balances_stale(cfg) + except Exception: + pass + return result + + def _stale_pending() -> dict[str, Any]: + from lib.exchange.okx_options_lib import invalidate_option_positions_cache + from lib.options.options_pending_lib import cancel_stale_close_pending_orders + + ex = cfg.get("exchange_options") + if ex is None: + return {"ok": False, "msg": "期权 exchange 未就绪"} + ttl = float(cfg.get("pending_ttl_seconds") or 600.0) + out = cancel_stale_close_pending_orders( + fetch_pending=lambda _ex: cfg["fetch_option_pending_orders"](_ex), + cancel_order=lambda _ex, inst_id, ord_id: cfg["cancel_option_order"]( + _ex, inst_id=inst_id, ord_id=ord_id + ), + ttl_seconds=ttl, + ex=ex, + ) + if out.get("cancelled"): + try: + invalidate_option_positions_cache() + except Exception: + pass + try: + send = cfg.get("send_wechat") + if callable(send): + parts = [ + "【OKX期权·挂单超时撤销】", + f"账户:{cfg.get('account_label') or 'OKX期权'}", + f"超时:{ttl:g}s", + f"撤销:{out.get('cancelled')} 笔", + ] + for o in out.get("orders") or []: + parts.append(f"- {o.get('inst_id')} #{o.get('ord_id')}") + send("\n".join(parts)) + except Exception: + pass + return out + + t = threading.Thread( + target=options_monitor_loop, + kwargs={ + "enabled": True, + "poll_seconds": cfg["poll_seconds"], + "get_db": cfg["get_db"], + "fetch_positions": _positions, + "ticker_bid_fn": _bid, + "send_wechat": cfg["send_wechat"], + "account_label": cfg["account_label"], + "profit_ratio": cfg["profit_ratio"], + "sync_trades_fn": _sync, + "target_close_fn": _target_close, + "stale_pending_fn": _stale_pending, + }, + daemon=True, + name="options-monitor", + ) + t.start() diff --git a/lib/options/options_review_db.py b/lib/options/options_review_db.py new file mode 100644 index 0000000..7123a1d --- /dev/null +++ b/lib/options/options_review_db.py @@ -0,0 +1,143 @@ +"""期权复盘(含对冲) SQLite 表.""" +from __future__ import annotations + +import sqlite3 + + +SOURCE_OPTION = "option_spot" +SOURCE_PERP_OPTIONS = "perp_options" +SOURCE_OPTIONS_OPTIONS = "options_options" +SOURCE_TYPES = (SOURCE_OPTION, SOURCE_PERP_OPTIONS, SOURCE_OPTIONS_OPTIONS) + + +def init_options_review_tables(conn: sqlite3.Connection) -> None: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS options_review_trades ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_type TEXT NOT NULL, + history_key TEXT NOT NULL UNIQUE, + underlying TEXT, + opened_at TEXT, + closed_at TEXT, + hold_seconds INTEGER, + realized_pnl_total REAL, + status_raw TEXT, + synced_at TEXT, + -- 纯期权 + pos_id TEXT, + inst_id TEXT, + opt_type TEXT, + strike REAL, + exp_time TEXT, + sheets INTEGER, + open_avg REAL, + close_avg REAL, + premium_paid REAL, + realized_pnl REAL, + -- 对冲计划 + hedge_plan_id INTEGER, + plan_close_reason TEXT, + realized_pnl_perp REAL, + realized_pnl_options REAL, + premium_total REAL, + direction TEXT, + tp REAL, + sl REAL, + target_price REAL, + target_price_up REAL, + target_price_down REAL, + legs_json TEXT, + -- 双计防护:纯期权腿已归属对冲计划 + linked_hedge_plan_id INTEGER, + excluded_as_hedge_leg INTEGER DEFAULT 0 + ) + """ + ) + conn.execute( + """ + CREATE UNIQUE INDEX IF NOT EXISTS idx_options_review_trades_history_key + ON options_review_trades(history_key) + """ + ) + conn.execute( + """ + CREATE UNIQUE INDEX IF NOT EXISTS idx_options_review_trades_hedge_plan + ON options_review_trades(hedge_plan_id) + WHERE hedge_plan_id IS NOT NULL + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_options_review_trades_closed + ON options_review_trades(closed_at) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_options_review_trades_source + ON options_review_trades(source_type) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS options_review_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trade_id INTEGER NOT NULL UNIQUE, + strategy_tag TEXT, + direction_view TEXT, + entry_logic TEXT, + exit_reason TEXT, + followed_plan TEXT, + mistake_tags TEXT, + result_tag TEXT, + note TEXT, + images_json TEXT, + image TEXT, + reviewed_at TEXT, + updated_at TEXT, + FOREIGN KEY(trade_id) REFERENCES options_review_trades(id) + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS options_review_sync_state ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at TEXT + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS options_review_hidden ( + history_key TEXT PRIMARY KEY, + inst_id TEXT, + closed_at TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_options_review_hidden_inst + ON options_review_hidden(inst_id, closed_at) + """ + ) + _ensure_column(conn, "options_review_trades", "linked_hedge_plan_id", "INTEGER") + _ensure_column(conn, "options_review_trades", "excluded_as_hedge_leg", "INTEGER DEFAULT 0") + _ensure_column(conn, "options_review_trades", "target_price_up", "REAL") + _ensure_column(conn, "options_review_trades", "target_price_down", "REAL") + + +def _ensure_column(conn: sqlite3.Connection, table: str, col: str, typedef: str) -> None: + rows = conn.execute(f"PRAGMA table_info({table})").fetchall() + names: set[str] = set() + for r in rows: + try: + names.add(str(r["name"])) + except (TypeError, KeyError, IndexError): + names.add(str(r[1])) + if col not in names: + conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {typedef}") diff --git a/lib/options/options_review_images_lib.py b/lib/options/options_review_images_lib.py new file mode 100644 index 0000000..b1845a8 --- /dev/null +++ b/lib/options/options_review_images_lib.py @@ -0,0 +1,138 @@ +"""期权复盘截图:独立命名空间,与合约同款四周期 5m/15m/1h/4h.""" +from __future__ import annotations + +import json +import os +import re +from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence + +OPTIONS_REVIEW_UPLOAD_TFS: tuple[str, ...] = ("5m", "15m", "1h", "4h") +OPTIONS_REVIEW_ALLOWED_EXT = frozenset({".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"}) +_DRAFT_ID_RE = re.compile(r"^[a-f0-9]{32}$") +_SLOT_FILE_RE = re.compile( + r"^options_journal_([a-f0-9]{32})_(5m|15m|1h|4h)\.(png|jpg|jpeg|webp|gif|bmp)$", + re.I, +) + + +def normalize_options_review_draft_id(raw: Any) -> Optional[str]: + s = str(raw or "").strip().lower() + if _DRAFT_ID_RE.match(s): + return s + return None + + +def _safe_ext(filename: str) -> str: + ext = os.path.splitext(str(filename or ""))[1].lower() + return ext if ext in OPTIONS_REVIEW_ALLOWED_EXT else ".png" + + +def options_review_upload_dir(base_upload_folder: str) -> str: + """独立子目录 static/images/options_journal.""" + base = os.path.abspath(base_upload_folder or "") + path = os.path.join(base, "options_journal") + os.makedirs(path, exist_ok=True) + return path + + +def build_options_review_slot_filename( + draft_id: str, + tf: str, + ext: str, + *, + secure_filename_fn: Callable[[str], str], +) -> str: + ext = ext if ext.startswith(".") else f".{ext}" + ext = _safe_ext(f"x{ext}") + fname = secure_filename_fn(f"options_journal_{draft_id}_{tf}{ext}") + return fname or "" + + +def is_valid_options_review_file(filename: str, draft_id: str, tf: str) -> bool: + fn = os.path.basename(str(filename or "").strip()) + if not fn or fn != str(filename or "").strip(): + return False + m = _SLOT_FILE_RE.match(fn) + if not m: + return False + return m.group(1) == draft_id.lower() and m.group(2) == tf + + +def save_options_review_slot_file( + file, + draft_id: str, + tf: str, + upload_folder: str, + *, + secure_filename_fn: Callable[[str], str], +) -> Optional[Dict[str, str]]: + if tf not in OPTIONS_REVIEW_UPLOAD_TFS or not draft_id or not upload_folder: + return None + if not file or not getattr(file, "filename", None): + return None + ext = _safe_ext(file.filename) + fname = build_options_review_slot_filename( + draft_id, tf, ext, secure_filename_fn=secure_filename_fn + ) + if not fname: + return None + os.makedirs(upload_folder, exist_ok=True) + path = os.path.join(upload_folder, fname) + file.save(path) + return {"tf": tf, "file": fname} + + +def parse_options_review_images_json(raw: Any) -> List[Dict[str, str]]: + if not raw: + return [] + if isinstance(raw, list): + data = raw + else: + try: + data = json.loads(str(raw)) + except (TypeError, ValueError, json.JSONDecodeError): + return [] + if not isinstance(data, list): + return [] + out: List[Dict[str, str]] = [] + for item in data: + if not isinstance(item, dict): + continue + tf = str(item.get("tf") or "").strip() + file = str(item.get("file") or "").strip() + if file: + out.append({"tf": tf, "file": file}) + return out + + +def images_json_dumps(items: Sequence[Mapping[str, str]]) -> Optional[str]: + if not items: + return None + return json.dumps(list(items), ensure_ascii=False, separators=(",", ":")) + + +def options_review_image_paths(row: Any, upload_folder: str) -> List[str]: + upload_folder = os.path.abspath(upload_folder or "") + paths: List[str] = [] + seen: set[str] = set() + + def _add(name: Optional[str]) -> None: + if not name: + return + p = os.path.abspath(os.path.join(upload_folder, str(name).strip())) + if os.path.isfile(p) and p not in seen: + seen.add(p) + paths.append(p) + + try: + keys = row.keys() if hasattr(row, "keys") else () + except Exception: + keys = () + images = parse_options_review_images_json( + row["images_json"] if "images_json" in keys else getattr(row, "images_json", None) + ) + for item in images: + _add(item.get("file")) + if "image" in keys or hasattr(row, "image"): + _add(row["image"] if "image" in keys else getattr(row, "image", None)) + return paths diff --git a/lib/options/options_review_lib.py b/lib/options/options_review_lib.py new file mode 100644 index 0000000..43f4b60 --- /dev/null +++ b/lib/options/options_review_lib.py @@ -0,0 +1,971 @@ +"""期权复盘业务:OKX 已平期权导入 + 已结束对冲计划导入 + 复盘 CRUD + 统计.""" +from __future__ import annotations + +import json +import sqlite3 +from datetime import datetime +from typing import Any, Callable, Optional + +from lib.options.options_review_db import ( + SOURCE_OPTION, + SOURCE_OPTIONS_OPTIONS, + SOURCE_PERP_OPTIONS, + SOURCE_TYPES, + init_options_review_tables, +) +from lib.options.options_review_images_lib import ( + images_json_dumps, + parse_options_review_images_json, +) + +SOURCE_LABELS = { + SOURCE_OPTION: "纯期权", + SOURCE_PERP_OPTIONS: "永期对冲", + SOURCE_OPTIONS_OPTIONS: "期期对冲", +} + +HOLD_BUCKETS = ( + ("0-1h", 0, 3600), + ("1-6h", 3600, 6 * 3600), + ("6-24h", 6 * 3600, 24 * 3600), + ("1-3d", 24 * 3600, 3 * 24 * 3600), + (">3d", 3 * 24 * 3600, None), +) + + +def _now_str() -> str: + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def _parse_ts(raw: Any) -> Optional[datetime]: + if raw is None or raw == "": + return None + s = str(raw).strip().replace(" ", "T", 1) + try: + return datetime.fromisoformat(s) + except (TypeError, ValueError): + return None + + +def _hold_seconds(opened_at: Any, closed_at: Any) -> Optional[int]: + start = _parse_ts(opened_at) + end = _parse_ts(closed_at) + if start is None or end is None: + return None + sec = int((end - start).total_seconds()) + return sec if sec >= 0 else None + + +def _safe_float(v: Any) -> Optional[float]: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def get_sync_state(conn: sqlite3.Connection, key: str) -> Optional[str]: + row = conn.execute( + "SELECT value FROM options_review_sync_state WHERE key=?", (key,) + ).fetchone() + return str(row["value"]) if row and row["value"] is not None else None + + +def set_sync_state(conn: sqlite3.Connection, key: str, value: str) -> None: + conn.execute( + """ + INSERT INTO options_review_sync_state(key, value, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at + """, + (key, value, _now_str()), + ) + + +def _purge_review_trade_by_key(conn: sqlite3.Connection, history_key: str) -> bool: + """删除已导入的复盘快照(含复盘内容).""" + key = str(history_key or "").strip() + if not key: + return False + existing = conn.execute( + "SELECT id FROM options_review_trades WHERE history_key=?", (key,) + ).fetchone() + if not existing: + return False + tid = int(existing["id"]) + conn.execute("DELETE FROM options_review_entries WHERE trade_id=?", (tid,)) + conn.execute("DELETE FROM options_review_trades WHERE id=?", (tid,)) + return True + + +def upsert_option_history_row(conn: sqlite3.Connection, row: dict[str, Any]) -> str: + """幂等写入纯期权快照;不触碰 options_review_entries;已隐藏的不再导入.""" + history_key = str(row.get("history_key") or "").strip() + if not history_key: + return "skip" + if is_review_hidden( + conn, + history_key, + inst_id=str(row.get("inst_id") or "").strip() or None, + closed_at=row.get("closed_at") or row.get("created_at"), + ): + # 若此前已导入,清掉,避免列表残留 + return "purged" if _purge_review_trade_by_key(conn, history_key) else "hidden" + opened_at = row.get("created_at") or row.get("opened_at") + closed_at = row.get("closed_at") + pnl = _safe_float(row.get("realized_pnl")) + hold = _hold_seconds(opened_at, closed_at) + existing = conn.execute( + "SELECT id FROM options_review_trades WHERE history_key=?", (history_key,) + ).fetchone() + fields = { + "source_type": SOURCE_OPTION, + "history_key": history_key, + "underlying": str(row.get("underlying") or "").strip() or None, + "opened_at": opened_at, + "closed_at": closed_at, + "hold_seconds": hold, + "realized_pnl_total": pnl, + "status_raw": str(row.get("status_label") or row.get("status") or "closed"), + "synced_at": _now_str(), + "pos_id": str(row.get("pos_id") or "").strip() or None, + "inst_id": str(row.get("inst_id") or "").strip() or None, + "opt_type": str(row.get("opt_type") or "").strip() or None, + "strike": _safe_float(row.get("strike")), + "exp_time": str(row.get("exp_time") or "").strip() or None, + "sheets": int(row.get("sheets") or 0) or None, + "open_avg": _safe_float(row.get("open_avg_px") if row.get("open_avg_px") is not None else row.get("open_avg")), + "close_avg": _safe_float(row.get("close_avg_px") if row.get("close_avg_px") is not None else row.get("close_avg")), + "premium_paid": _safe_float(row.get("premium_paid")), + "realized_pnl": pnl, + } + cols = list(fields.keys()) + if existing: + sets = ", ".join(f"{c}=?" for c in cols if c != "history_key") + vals = [fields[c] for c in cols if c != "history_key"] + conn.execute( + f"UPDATE options_review_trades SET {sets} WHERE history_key=?", + [*vals, history_key], + ) + return "updated" + placeholders = ",".join(["?"] * len(cols)) + conn.execute( + f"INSERT INTO options_review_trades ({','.join(cols)}) VALUES ({placeholders})", + [fields[c] for c in cols], + ) + return "inserted" + + +def _close_fingerprint(inst_id: Any, closed_at: Any) -> str | None: + inst = str(inst_id or "").strip() + if not inst: + return None + closed = str(closed_at or "").strip() + if not closed: + return f"inst:{inst}" + # 精确到分钟,避免秒差导致漏匹配 + return f"inst_close:{inst}:{closed[:16]}" + + +def is_review_hidden( + conn: sqlite3.Connection, + history_key: str, + *, + inst_id: str | None = None, + closed_at: Any = None, +) -> bool: + init_options_review_tables(conn) + key = str(history_key or "").strip() + if key and conn.execute( + "SELECT 1 FROM options_review_hidden WHERE history_key=? LIMIT 1", (key,) + ).fetchone(): + return True + fp = _close_fingerprint(inst_id, closed_at) + if fp and conn.execute( + "SELECT 1 FROM options_review_hidden WHERE history_key=? LIMIT 1", (fp,) + ).fetchone(): + return True + # 期权历史页删除:options_history_hidden,按合约指纹或原 key + try: + if key and conn.execute( + "SELECT 1 FROM options_history_hidden WHERE history_key=? LIMIT 1", (key,) + ).fetchone(): + return True + if fp and conn.execute( + "SELECT 1 FROM options_history_hidden WHERE history_key=? LIMIT 1", (fp,) + ).fetchone(): + return True + # 仅隐藏了 ex:posId 时,用合约+平仓时间在历史隐藏表无直接命中; + # 若指纹已写入 options_review_hidden(新删除路径)上面已覆盖. + # 兼容:inst 级隐藏 + if inst_id: + inst_fp = f"inst:{str(inst_id).strip()}" + if conn.execute( + "SELECT 1 FROM options_review_hidden WHERE history_key=? LIMIT 1", + (inst_fp,), + ).fetchone(): + return True + if conn.execute( + "SELECT 1 FROM options_history_hidden WHERE history_key=? LIMIT 1", + (inst_fp,), + ).fetchone(): + return True + except Exception: + pass + return False + + +def hide_review_keys( + conn: sqlite3.Connection, + *, + history_key: str, + inst_id: str | None = None, + closed_at: Any = None, +) -> None: + init_options_review_tables(conn) + keys = [str(history_key or "").strip()] + fp = _close_fingerprint(inst_id, closed_at) + if fp: + keys.append(fp) + for k in keys: + if not k: + continue + conn.execute( + """ + INSERT OR IGNORE INTO options_review_hidden(history_key, inst_id, closed_at) + VALUES (?, ?, ?) + """, + (k, (inst_id or None), str(closed_at or "")[:19] or None), + ) + try: + conn.execute( + "INSERT OR IGNORE INTO options_history_hidden(history_key) VALUES (?)", + (k,), + ) + except Exception: + pass + + +def hide_review_trade(conn: sqlite3.Connection, trade_id: int) -> dict[str, Any]: + """从复盘列表删除并持久隐藏,刷新本地源也不会再回来.""" + init_options_review_tables(conn) + row = conn.execute( + "SELECT * FROM options_review_trades WHERE id=?", (int(trade_id),) + ).fetchone() + if not row: + return {"ok": False, "msg": "记录不存在"} + d = _row_to_dict(row) + hide_review_keys( + conn, + history_key=str(d.get("history_key") or ""), + inst_id=str(d.get("inst_id") or "").strip() or None, + closed_at=d.get("closed_at") or d.get("opened_at"), + ) + entry = conn.execute( + "SELECT * FROM options_review_entries WHERE trade_id=?", (int(trade_id),) + ).fetchone() + conn.execute("DELETE FROM options_review_entries WHERE trade_id=?", (int(trade_id),)) + conn.execute("DELETE FROM options_review_trades WHERE id=?", (int(trade_id),)) + return {"ok": True, "entry": _row_to_dict(entry) if entry else None, "history_key": d.get("history_key")} + + + +def sync_options_from_local_trades(conn: sqlite3.Connection) -> dict[str, Any]: + """从本地 options_trades 已平仓记录导入复盘快照(不访问交易所).""" + init_options_review_tables(conn) + from lib.options.options_db import init_options_tables + + init_options_tables(conn) + rows = conn.execute( + """ + SELECT id, inst_id, underlying, opt_type, strike, exp_time, sheets, + open_quote, close_quote, premium_paid, realized_pnl, + created_at, closed_at, signal_note, status + FROM options_trades + WHERE status = 'closed' + ORDER BY id DESC + LIMIT 500 + """ + ).fetchall() + inserted = updated = skipped = 0 + for r in rows: + trade_id = int(r["id"]) + history_key = f"local_opt:{trade_id}" + pnl = _safe_float(r["realized_pnl"]) + opened_at = r["created_at"] + closed_at = r["closed_at"] + action = upsert_option_history_row( + conn, + { + "history_key": history_key, + "pos_id": f"local:{trade_id}", + "inst_id": r["inst_id"], + "underlying": r["underlying"], + "opt_type": r["opt_type"], + "strike": r["strike"], + "exp_time": r["exp_time"], + "sheets": r["sheets"], + "open_avg_px": r["open_quote"], + "close_avg_px": r["close_quote"], + "premium_paid": r["premium_paid"], + "realized_pnl": pnl, + "created_at": opened_at, + "closed_at": closed_at, + "status_label": "已平", + }, + ) + if action == "inserted": + inserted += 1 + elif action == "updated": + updated += 1 + else: + skipped += 1 + set_sync_state(conn, "options_last_sync_at", _now_str()) + set_sync_state(conn, "options_last_count", str(len(rows))) + set_sync_state(conn, "options_sync_source", "local") + return { + "ok": True, + "source": "local", + "fetched": len(rows), + "inserted": inserted, + "updated": updated, + "skipped": skipped, + } + + +def sync_options_from_exchange( + conn: sqlite3.Connection, + ex: Any, + *, + limit: int = 500, + fetch_fn: Optional[Callable[..., list[dict[str, Any]]]] = None, + format_fn: Optional[Callable[..., dict[str, Any]]] = None, +) -> dict[str, Any]: + """从 OKX positions-history 导入已全平期权仓位(可选,默认不用).""" + init_options_review_tables(conn) + from lib.exchange.okx_options_lib import ( + fetch_all_option_positions_history, + format_option_history_row, + tick_sz_and_ct_mult, + ) + + fetch = fetch_fn or fetch_all_option_positions_history + fmt = format_fn or format_option_history_row + raw_rows = fetch(ex, limit=limit) + meta_cache: dict[str, dict[str, Any] | None] = {} + inserted = updated = skipped = 0 + for raw in raw_rows: + inst_id = str(raw.get("instId") or "").strip() + tick_sz, ct_mult = None, 0.01 + try: + tick_sz, ct_mult = tick_sz_and_ct_mult(ex, inst_id, meta_cache) + except Exception: + pass + formatted = fmt(raw, tick_sz=tick_sz, ct_mult=ct_mult) + action = upsert_option_history_row(conn, formatted) + if action == "inserted": + inserted += 1 + elif action == "updated": + updated += 1 + else: + skipped += 1 + set_sync_state(conn, "options_last_sync_at", _now_str()) + set_sync_state(conn, "options_last_count", str(len(raw_rows))) + set_sync_state(conn, "options_sync_source", "exchange") + return { + "ok": True, + "source": "exchange", + "fetched": len(raw_rows), + "inserted": inserted, + "updated": updated, + "skipped": skipped, + } + + +def _legs_json_from_plan(legs: list[dict[str, Any]]) -> str: + slim = [] + for leg in legs: + slim.append( + { + "id": leg.get("id"), + "leg_role": leg.get("leg_role"), + "symbol": leg.get("symbol"), + "inst_id": leg.get("inst_id"), + "opt_type": leg.get("opt_type"), + "strike": leg.get("strike"), + "side": leg.get("side"), + "size": leg.get("size"), + "avg_open": leg.get("avg_open"), + "premium": leg.get("premium"), + "status": leg.get("status"), + "realized_pnl": leg.get("realized_pnl"), + "close_reason": leg.get("close_reason"), + "opened_at": leg.get("opened_at"), + "closed_at": leg.get("closed_at"), + } + ) + return json.dumps(slim, ensure_ascii=False, separators=(",", ":")) + + +def upsert_hedge_plan_row( + conn: sqlite3.Connection, + plan: dict[str, Any], + legs: list[dict[str, Any]], +) -> str: + plan_id = int(plan["id"]) + history_key = f"hedge:{plan_id}" + plan_type = str(plan.get("plan_type") or "").strip() + if plan_type not in (SOURCE_PERP_OPTIONS, SOURCE_OPTIONS_OPTIONS): + return "skip" + opened_at = plan.get("opened_at") or plan.get("created_at") + closed_at = plan.get("closed_at") + if is_review_hidden( + conn, + history_key, + inst_id=None, + closed_at=closed_at, + ): + return "purged" if _purge_review_trade_by_key(conn, history_key) else "hidden" + total = _safe_float(plan.get("realized_pnl_total")) + hold = _hold_seconds(opened_at, closed_at) + fields = { + "source_type": plan_type, + "history_key": history_key, + "underlying": str(plan.get("underlying") or "").strip() or None, + "opened_at": opened_at, + "closed_at": closed_at, + "hold_seconds": hold, + "realized_pnl_total": total, + "status_raw": str(plan.get("status") or "closed"), + "synced_at": _now_str(), + "hedge_plan_id": plan_id, + "plan_close_reason": str(plan.get("close_reason") or "").strip() or None, + "realized_pnl_perp": _safe_float(plan.get("realized_pnl_perp")), + "realized_pnl_options": _safe_float(plan.get("realized_pnl_options")), + "premium_total": _safe_float(plan.get("premium_total")), + "direction": str(plan.get("direction") or "").strip() or None, + "tp": _safe_float(plan.get("tp")), + "sl": _safe_float(plan.get("sl")), + "target_price": _safe_float(plan.get("target_price")), + "target_price_up": _safe_float(plan.get("target_price_up")), + "target_price_down": _safe_float(plan.get("target_price_down")), + "legs_json": _legs_json_from_plan(legs), + } + existing = conn.execute( + "SELECT id FROM options_review_trades WHERE history_key=?", (history_key,) + ).fetchone() + cols = list(fields.keys()) + if existing: + sets = ", ".join(f"{c}=?" for c in cols if c != "history_key") + vals = [fields[c] for c in cols if c != "history_key"] + conn.execute( + f"UPDATE options_review_trades SET {sets} WHERE history_key=?", + [*vals, history_key], + ) + trade_id = int(existing["id"]) + action = "updated" + else: + placeholders = ",".join(["?"] * len(cols)) + cur = conn.execute( + f"INSERT INTO options_review_trades ({','.join(cols)}) VALUES ({placeholders})", + [fields[c] for c in cols], + ) + trade_id = int(cur.lastrowid) + action = "inserted" + _mark_option_legs_excluded(conn, plan_id, legs) + del trade_id + return action + + +def _mark_option_legs_excluded( + conn: sqlite3.Connection, + plan_id: int, + legs: list[dict[str, Any]], +) -> int: + """纯期权记录若 inst_id 出现在对冲腿中,标记排除以免双计.""" + inst_ids = { + str(leg.get("inst_id") or "").strip() + for leg in legs + if str(leg.get("leg_role") or "").startswith("option") and str(leg.get("inst_id") or "").strip() + } + if not inst_ids: + return 0 + n = 0 + for inst_id in inst_ids: + cur = conn.execute( + """ + UPDATE options_review_trades + SET excluded_as_hedge_leg = 1, linked_hedge_plan_id = ? + WHERE source_type = ? AND inst_id = ? AND excluded_as_hedge_leg = 0 + """, + (plan_id, SOURCE_OPTION, inst_id), + ) + n += int(cur.rowcount or 0) + return n + + +def sync_hedge_plans_closed(conn: sqlite3.Connection) -> dict[str, Any]: + """从本地 hedge_plans 导入已结束计划(计划级).""" + init_options_review_tables(conn) + from lib.hedge_plan.hedge_plan_db import get_plan_legs, init_hedge_plan_tables, list_plans + + init_hedge_plan_tables(conn) + plans = list_plans(conn, status="closed", limit=500) + inserted = updated = skipped = 0 + for plan in plans: + legs = get_plan_legs(conn, int(plan["id"])) + action = upsert_hedge_plan_row(conn, plan, legs) + if action == "inserted": + inserted += 1 + elif action == "updated": + updated += 1 + else: + skipped += 1 + last_id = max((int(p["id"]) for p in plans), default=0) + set_sync_state(conn, "hedge_last_sync_at", _now_str()) + set_sync_state(conn, "hedge_last_plan_id", str(last_id)) + return { + "ok": True, + "fetched": len(plans), + "inserted": inserted, + "updated": updated, + "skipped": skipped, + } + + +def sync_all_review_sources( + conn: sqlite3.Connection, + ex: Any | None = None, + *, + options_limit: int = 500, + from_exchange: bool = False, + fetch_fn: Optional[Callable[..., list[dict[str, Any]]]] = None, + format_fn: Optional[Callable[..., dict[str, Any]]] = None, +) -> dict[str, Any]: + """默认只读本地 options_trades + 已结束对冲计划;不访问交易所.""" + init_options_review_tables(conn) + out: dict[str, Any] = {"ok": True, "options": None, "hedge": None} + if from_exchange and ex is not None: + out["options"] = sync_options_from_exchange( + conn, ex, limit=options_limit, fetch_fn=fetch_fn, format_fn=format_fn + ) + else: + out["options"] = sync_options_from_local_trades(conn) + out["hedge"] = sync_hedge_plans_closed(conn) + return out + + +def ensure_local_review_synced(conn: sqlite3.Connection) -> dict[str, Any]: + """列表/统计前轻量刷新本地源.""" + return sync_all_review_sources(conn, from_exchange=False) + + +def _row_to_dict(row: Any) -> dict[str, Any]: + return dict(row) if row is not None else {} + + +def enrich_trade_row(row: dict[str, Any], entry: dict[str, Any] | None = None) -> dict[str, Any]: + out = dict(row) + out["source_label"] = SOURCE_LABELS.get(str(out.get("source_type") or ""), out.get("source_type")) + out["is_hedge"] = str(out.get("source_type") or "") in (SOURCE_PERP_OPTIONS, SOURCE_OPTIONS_OPTIONS) + legs = [] + if out.get("legs_json"): + try: + legs = json.loads(str(out["legs_json"])) + except (TypeError, ValueError, json.JSONDecodeError): + legs = [] + out["legs"] = legs if isinstance(legs, list) else [] + out["reviewed"] = bool(entry) + if entry: + out["entry"] = dict(entry) + out["entry"]["images"] = parse_options_review_images_json(entry.get("images_json")) + out["strategy_tag"] = entry.get("strategy_tag") + out["result_tag"] = entry.get("result_tag") + out["reviewed_at"] = entry.get("reviewed_at") or entry.get("updated_at") + else: + out["entry"] = None + out["strategy_tag"] = None + out["result_tag"] = None + out["reviewed_at"] = None + return out + + +def _review_trades_filters( + *, + source_type: str | None = None, + underlying: str | None = None, + opt_type: str | None = None, + strategy_tag: str | None = None, + reviewed: str | None = None, + include_hedge_legs: bool = False, + closed_from: str | None = None, + closed_to: str | None = None, +) -> tuple[str, list[Any]]: + wheres: list[str] = [] + args: list[Any] = [] + if source_type and source_type in SOURCE_TYPES: + wheres.append("t.source_type=?") + args.append(source_type) + if underlying: + wheres.append("UPPER(COALESCE(t.underlying,''))=?") + args.append(underlying.strip().upper()) + if opt_type: + ot = opt_type.strip().upper() + if ot in ("C", "P", "CALL", "PUT"): + if ot.startswith("C"): + ot = "C" + elif ot.startswith("P"): + ot = "P" + wheres.append( + """( + UPPER(COALESCE(t.opt_type,''))=? + OR ( + t.legs_json IS NOT NULL + AND t.legs_json LIKE '%' || '"opt_type":"' || ? || '%' + ) + )""" + ) + args.extend([ot, ot]) + if not include_hedge_legs: + wheres.append("COALESCE(t.excluded_as_hedge_leg,0)=0") + if closed_from: + wheres.append("COALESCE(t.closed_at,'')>=?") + args.append(closed_from) + if closed_to: + wheres.append("COALESCE(t.closed_at,'')<=?") + args.append(closed_to) + if strategy_tag: + wheres.append("e.strategy_tag=?") + args.append(strategy_tag) + if reviewed == "1" or reviewed == "yes": + wheres.append("e.id IS NOT NULL") + elif reviewed == "0" or reviewed == "no": + wheres.append("e.id IS NULL") + where = (" WHERE " + " AND ".join(wheres)) if wheres else "" + return where, args + + +def count_review_trades( + conn: sqlite3.Connection, + *, + source_type: str | None = None, + underlying: str | None = None, + opt_type: str | None = None, + strategy_tag: str | None = None, + reviewed: str | None = None, + include_hedge_legs: bool = False, + closed_from: str | None = None, + closed_to: str | None = None, +) -> int: + init_options_review_tables(conn) + where, args = _review_trades_filters( + source_type=source_type, + underlying=underlying, + opt_type=opt_type, + strategy_tag=strategy_tag, + reviewed=reviewed, + include_hedge_legs=include_hedge_legs, + closed_from=closed_from, + closed_to=closed_to, + ) + row = conn.execute( + f""" + SELECT COUNT(*) AS c + FROM options_review_trades t + LEFT JOIN options_review_entries e ON e.trade_id = t.id + {where} + """, + args, + ).fetchone() + return int(row["c"] if row else 0) + + +def list_review_trades( + conn: sqlite3.Connection, + *, + source_type: str | None = None, + underlying: str | None = None, + opt_type: str | None = None, + strategy_tag: str | None = None, + reviewed: str | None = None, + include_hedge_legs: bool = False, + closed_from: str | None = None, + closed_to: str | None = None, + limit: int = 200, + offset: int = 0, +) -> list[dict[str, Any]]: + init_options_review_tables(conn) + where, args = _review_trades_filters( + source_type=source_type, + underlying=underlying, + opt_type=opt_type, + strategy_tag=strategy_tag, + reviewed=reviewed, + include_hedge_legs=include_hedge_legs, + closed_from=closed_from, + closed_to=closed_to, + ) + rows = conn.execute( + f""" + SELECT t.*, e.id AS entry_id, e.strategy_tag AS e_strategy_tag, + e.direction_view, e.entry_logic, e.exit_reason, e.followed_plan, + e.mistake_tags, e.result_tag, e.note, e.images_json, e.image, + e.reviewed_at, e.updated_at + FROM options_review_trades t + LEFT JOIN options_review_entries e ON e.trade_id = t.id + {where} + ORDER BY COALESCE(t.closed_at, t.opened_at, '') DESC, t.id DESC + LIMIT ? OFFSET ? + """, + [*args, int(limit), int(offset)], + ).fetchall() + out: list[dict[str, Any]] = [] + for r in rows: + d = _row_to_dict(r) + entry = None + if d.get("entry_id"): + entry = { + "id": d.pop("entry_id", None), + "strategy_tag": d.pop("e_strategy_tag", None), + "direction_view": d.pop("direction_view", None), + "entry_logic": d.pop("entry_logic", None), + "exit_reason": d.pop("exit_reason", None), + "followed_plan": d.pop("followed_plan", None), + "mistake_tags": d.pop("mistake_tags", None), + "result_tag": d.pop("result_tag", None), + "note": d.pop("note", None), + "images_json": d.pop("images_json", None), + "image": d.pop("image", None), + "reviewed_at": d.pop("reviewed_at", None), + "updated_at": d.pop("updated_at", None), + } + else: + for k in ( + "entry_id", + "e_strategy_tag", + "direction_view", + "entry_logic", + "exit_reason", + "followed_plan", + "mistake_tags", + "result_tag", + "note", + "images_json", + "image", + "reviewed_at", + "updated_at", + ): + d.pop(k, None) + out.append(enrich_trade_row(d, entry)) + return out + + +def get_review_trade(conn: sqlite3.Connection, trade_id: int) -> dict[str, Any] | None: + init_options_review_tables(conn) + row = conn.execute( + "SELECT * FROM options_review_trades WHERE id=?", (int(trade_id),) + ).fetchone() + if not row: + return None + entry_row = conn.execute( + "SELECT * FROM options_review_entries WHERE trade_id=?", (int(trade_id),) + ).fetchone() + entry = _row_to_dict(entry_row) if entry_row else None + return enrich_trade_row(_row_to_dict(row), entry) + + +def save_review_entry( + conn: sqlite3.Connection, + trade_id: int, + payload: dict[str, Any], +) -> dict[str, Any]: + """保存/更新人工复盘;不影响 trades 快照字段.""" + init_options_review_tables(conn) + trade = conn.execute( + "SELECT id FROM options_review_trades WHERE id=?", (int(trade_id),) + ).fetchone() + if not trade: + return {"ok": False, "msg": "交易不存在"} + images = payload.get("images") + if images is None and payload.get("images_json") is not None: + images = parse_options_review_images_json(payload.get("images_json")) + if not isinstance(images, list): + images = [] + images_json = images_json_dumps(images) + primary = None + if images: + primary = str(images[0].get("file") or "").strip() or None + fields = { + "strategy_tag": str(payload.get("strategy_tag") or "").strip() or None, + "direction_view": str(payload.get("direction_view") or "").strip() or None, + "entry_logic": str(payload.get("entry_logic") or "").strip() or None, + "exit_reason": str(payload.get("exit_reason") or "").strip() or None, + "followed_plan": str(payload.get("followed_plan") or "").strip() or None, + "mistake_tags": str(payload.get("mistake_tags") or "").strip() or None, + "result_tag": str(payload.get("result_tag") or "").strip() or None, + "note": str(payload.get("note") or "").strip() or None, + "images_json": images_json, + "image": primary or (str(payload.get("image") or "").strip() or None), + "updated_at": _now_str(), + } + existing = conn.execute( + "SELECT id, reviewed_at FROM options_review_entries WHERE trade_id=?", + (int(trade_id),), + ).fetchone() + if existing: + sets = ", ".join(f"{k}=?" for k in fields) + conn.execute( + f"UPDATE options_review_entries SET {sets} WHERE trade_id=?", + [*fields.values(), int(trade_id)], + ) + else: + fields["trade_id"] = int(trade_id) + fields["reviewed_at"] = _now_str() + cols = list(fields.keys()) + conn.execute( + f"INSERT INTO options_review_entries ({','.join(cols)}) VALUES ({','.join(['?']*len(cols))})", + [fields[c] for c in cols], + ) + return {"ok": True, "trade": get_review_trade(conn, int(trade_id))} + + +def delete_review_entry(conn: sqlite3.Connection, trade_id: int) -> dict[str, Any]: + init_options_review_tables(conn) + entry = conn.execute( + "SELECT * FROM options_review_entries WHERE trade_id=?", (int(trade_id),) + ).fetchone() + if not entry: + return {"ok": False, "msg": "无复盘记录"} + conn.execute("DELETE FROM options_review_entries WHERE trade_id=?", (int(trade_id),)) + return {"ok": True, "entry": _row_to_dict(entry)} + + +def _hold_bucket(sec: Optional[int]) -> str: + if sec is None: + return "未知" + for label, lo, hi in HOLD_BUCKETS: + if sec >= lo and (hi is None or sec < hi): + return label + return "未知" + + +def _group_stats(rows: list[dict[str, Any]], key_fn) -> list[dict[str, Any]]: + buckets: dict[str, dict[str, Any]] = {} + for row in rows: + key = str(key_fn(row) or "未填") + b = buckets.setdefault( + key, + {"key": key, "count": 0, "wins": 0, "losses": 0, "pnl_sum": 0.0, "hold_sum": 0.0, "hold_n": 0}, + ) + pnl = _safe_float(row.get("realized_pnl_total")) + if pnl is None: + continue + b["count"] += 1 + b["pnl_sum"] = round(b["pnl_sum"] + pnl, 4) + if pnl > 0: + b["wins"] += 1 + elif pnl < 0: + b["losses"] += 1 + hs = row.get("hold_seconds") + if hs is not None: + try: + b["hold_sum"] += float(hs) + b["hold_n"] += 1 + except (TypeError, ValueError): + pass + out = [] + for b in buckets.values(): + c = b["count"] + out.append( + { + "key": b["key"], + "count": c, + "wins": b["wins"], + "losses": b["losses"], + "win_rate": round(b["wins"] / c * 100, 2) if c else 0, + "pnl_sum": round(b["pnl_sum"], 4), + "avg_pnl": round(b["pnl_sum"] / c, 4) if c else None, + "avg_hold_sec": round(b["hold_sum"] / b["hold_n"], 1) if b["hold_n"] else None, + } + ) + out.sort(key=lambda x: abs(float(x.get("pnl_sum") or 0)), reverse=True) + return out + + +def compute_review_stats( + conn: sqlite3.Connection, + *, + source_type: str | None = None, + underlying: str | None = None, + include_hedge_legs: bool = False, + closed_from: str | None = None, + closed_to: str | None = None, + require_strategy: bool = False, +) -> dict[str, Any]: + rows = list_review_trades( + conn, + source_type=source_type, + underlying=underlying, + include_hedge_legs=include_hedge_legs, + closed_from=closed_from, + closed_to=closed_to, + limit=5000, + offset=0, + ) + if require_strategy: + rows = [r for r in rows if str(r.get("strategy_tag") or "").strip()] + + wins = losses = reviewed = 0 + pnl_sum = 0.0 + hold_vals: list[float] = [] + for r in rows: + if r.get("reviewed"): + reviewed += 1 + pnl = _safe_float(r.get("realized_pnl_total")) + if pnl is None: + continue + pnl_sum += pnl + if pnl > 0: + wins += 1 + elif pnl < 0: + losses += 1 + if r.get("hold_seconds") is not None: + hold_vals.append(float(r["hold_seconds"])) + + total = wins + losses + kpi = { + "total": len(rows), + "pnl_count": total, + "reviewed": reviewed, + "review_rate": round(reviewed / len(rows) * 100, 2) if rows else 0, + "wins": wins, + "losses": losses, + "win_rate": round(wins / total * 100, 2) if total else 0, + "pnl_sum": round(pnl_sum, 4), + "avg_pnl": round(pnl_sum / total, 4) if total else None, + "avg_hold_sec": round(sum(hold_vals) / len(hold_vals), 1) if hold_vals else None, + } + + strategy_rows = [r for r in rows if str(r.get("strategy_tag") or "").strip()] + return { + "ok": True, + "kpi": kpi, + "by_source_type": _group_stats(rows, lambda r: SOURCE_LABELS.get(str(r.get("source_type") or ""), r.get("source_type"))), + "by_underlying": _group_stats(rows, lambda r: r.get("underlying") or "未填"), + "by_opt_type": _group_stats( + [r for r in rows if r.get("source_type") == SOURCE_OPTION], + lambda r: r.get("opt_type") or "未填", + ), + "by_strategy": _group_stats(strategy_rows, lambda r: r.get("strategy_tag")), + "by_close_reason": _group_stats( + [r for r in rows if r.get("is_hedge")], + lambda r: r.get("plan_close_reason") or "未填", + ), + "by_hold_bucket": _group_stats(rows, lambda r: _hold_bucket(r.get("hold_seconds"))), + "sync": { + "options_last_sync_at": get_sync_state(conn, "options_last_sync_at"), + "hedge_last_sync_at": get_sync_state(conn, "hedge_last_sync_at"), + "hedge_last_plan_id": get_sync_state(conn, "hedge_last_plan_id"), + }, + } diff --git a/lib/options/options_review_register.py b/lib/options/options_review_register.py new file mode 100644 index 0000000..da2fc57 --- /dev/null +++ b/lib/options/options_review_register.py @@ -0,0 +1,279 @@ +"""OKX 期权复盘模块:Flask 路由注册(含对冲计划级复盘).""" +from __future__ import annotations + +import os +from typing import Any + +from flask import Flask, jsonify, request, send_file +from jinja2 import ChoiceLoader, FileSystemLoader +from werkzeug.utils import secure_filename + +from lib.options.options_review_db import SOURCE_TYPES, init_options_review_tables +from lib.options.options_review_images_lib import ( + OPTIONS_REVIEW_UPLOAD_TFS, + normalize_options_review_draft_id, + options_review_image_paths, + options_review_upload_dir, + save_options_review_slot_file, +) +from lib.options.options_review_lib import ( + SOURCE_LABELS, + compute_review_stats, + count_review_trades, + delete_review_entry, + ensure_local_review_synced, + get_review_trade, + hide_review_trade, + list_review_trades, + save_review_entry, + sync_all_review_sources, +) + + +def attach_options_review_templates(app: Flask, repo_root: str) -> None: + tpl_dir = os.path.join(repo_root, "lib", "options", "templates") + if not os.path.isdir(tpl_dir): + return + existing = app.jinja_loader + loaders = [FileSystemLoader(tpl_dir)] + if existing is not None: + if isinstance(existing, ChoiceLoader): + loaders = list(existing.loaders) + loaders + else: + loaders.insert(0, existing) + app.jinja_loader = ChoiceLoader(loaders) + + +def install_options_review(app: Flask, repo_root: str, app_module: Any) -> None: + attach_options_review_templates(app, repo_root) + cfg = { + "get_db": app_module.get_db, + "login_required": app_module.login_required, + "exchange_options": getattr(app_module, "exchange_options", None), + "render_main_page": app_module.render_main_page, + "upload_folder": getattr(app_module, "UPLOAD_FOLDER", None) + or os.path.join(os.path.dirname(getattr(app_module, "BASE_DIR", repo_root)), "static", "images"), + "options_enabled": bool(getattr(app_module, "OKX_OPTIONS_ENABLED", False)), + "app_module": app_module, + } + app.extensions["options_review_cfg"] = cfg + register_options_review_routes(app, cfg, repo_root) + + +def _require_ex(cfg: dict[str, Any]): + from lib.exchange.okx_options_lib import options_api_ready + + if not cfg.get("options_enabled"): + return None, "期权模块未启用" + ex = cfg.get("exchange_options") + ok, reason = options_api_ready(ex) + if not ok: + return None, reason or "期权 API 未配置" + return ex, "" + + +def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: str) -> None: + lr = cfg["login_required"] + + @app.route("/options/review") + @lr + def options_review_page(): + from lib.instance.instance_embed_lib import redirect_to_embed_shell_if_enabled + + redir = redirect_to_embed_shell_if_enabled("options_review") + if redir is not None: + return redir + return cfg["render_main_page"]("options_review") + + @app.route("/static/options_review.js") + @lr + def static_options_review_js(): + path = os.path.join(repo_root, "lib", "common", "static", "options_review.js") + if not os.path.isfile(path): + return ("not found", 404) + return send_file(path, mimetype="application/javascript; charset=utf-8") + + @app.route("/static/images/options_journal/") + @lr + def static_options_review_image(filename: str): + folder = options_review_upload_dir(cfg["upload_folder"]) + safe = os.path.basename(filename or "") + path = os.path.join(folder, safe) + if not os.path.isfile(path): + return ("not found", 404) + return send_file(path) + + @app.route("/api/options/review/sync", methods=["POST"]) + @lr + def api_options_review_sync(): + """刷新本地 options_trades + 已结束对冲计划(不访问交易所).""" + conn = cfg["get_db"]() + try: + init_options_review_tables(conn) + result = sync_all_review_sources(conn, from_exchange=False) + conn.commit() + return jsonify(result) + finally: + conn.close() + + @app.route("/api/options/review/trades") + @lr + def api_options_review_trades(): + conn = cfg["get_db"]() + try: + # 翻页可跳过同步,仅刷新当前卡片列表 + do_sync = (request.args.get("sync") or "1").strip().lower() not in ( + "0", + "false", + "no", + ) + if do_sync: + ensure_local_review_synced(conn) + conn.commit() + filt = dict( + source_type=(request.args.get("source_type") or "").strip() or None, + underlying=(request.args.get("underlying") or "").strip() or None, + opt_type=(request.args.get("opt_type") or "").strip() or None, + strategy_tag=(request.args.get("strategy_tag") or "").strip() or None, + reviewed=(request.args.get("reviewed") or "").strip() or None, + include_hedge_legs=(request.args.get("include_hedge_legs") or "") + .strip() + .lower() + in ("1", "true", "yes"), + closed_from=(request.args.get("closed_from") or "").strip() or None, + closed_to=(request.args.get("closed_to") or "").strip() or None, + ) + limit = min(500, max(1, int(request.args.get("limit") or 200))) + offset = max(0, int(request.args.get("offset") or 0)) + total = count_review_trades(conn, **filt) + items = list_review_trades(conn, **filt, limit=limit, offset=offset) + pages = max(1, (total + limit - 1) // limit) if total else 1 + page = (offset // limit) + 1 if limit else 1 + return jsonify( + { + "ok": True, + "trades": items, + "source_labels": SOURCE_LABELS, + "total": total, + "limit": limit, + "offset": offset, + "page": page, + "pages": pages, + } + ) + finally: + conn.close() + + @app.route("/api/options/review/trades/") + @lr + def api_options_review_trade_detail(trade_id: int): + conn = cfg["get_db"]() + try: + item = get_review_trade(conn, trade_id) + if not item: + return jsonify({"ok": False, "msg": "未找到"}), 404 + return jsonify({"ok": True, "trade": item}) + finally: + conn.close() + + @app.route("/api/options/review/entry", methods=["POST"]) + @lr + def api_options_review_entry_save(): + data = request.get_json(silent=True) or {} + try: + trade_id = int(data.get("trade_id")) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "trade_id 无效"}), 400 + conn = cfg["get_db"]() + try: + out = save_review_entry(conn, trade_id, data) + if out.get("ok"): + conn.commit() + return jsonify(out), (200 if out.get("ok") else 400) + finally: + conn.close() + + @app.route("/api/options/review/trades/", methods=["DELETE"]) + @lr + def api_options_review_trade_hide(trade_id: int): + """从复盘列表删除并持久隐藏(刷新本地源也不会再导入).""" + conn = cfg["get_db"]() + try: + out = hide_review_trade(conn, trade_id) + if out.get("ok"): + entry = out.get("entry") or {} + folder = options_review_upload_dir(cfg["upload_folder"]) + for path in options_review_image_paths(entry, folder): + try: + os.remove(path) + except OSError: + pass + conn.commit() + return jsonify(out), (200 if out.get("ok") else 400) + finally: + conn.close() + + @app.route("/api/options/review/entry/", methods=["DELETE"]) + @lr + def api_options_review_entry_delete(trade_id: int): + conn = cfg["get_db"]() + try: + out = delete_review_entry(conn, trade_id) + if out.get("ok"): + entry = out.get("entry") or {} + folder = options_review_upload_dir(cfg["upload_folder"]) + for path in options_review_image_paths(entry, folder): + try: + os.remove(path) + except OSError: + pass + conn.commit() + return jsonify(out), (200 if out.get("ok") else 400) + finally: + conn.close() + + @app.route("/api/options/review/upload_slot", methods=["POST"]) + @lr + def api_options_review_upload_slot(): + draft_id = normalize_options_review_draft_id( + request.form.get("draft_id") if request.form else None + ) + tf = str((request.form.get("tf") if request.form else None) or "").strip() + if not draft_id: + return jsonify({"ok": False, "error": "invalid draft_id"}), 400 + if tf not in OPTIONS_REVIEW_UPLOAD_TFS: + return jsonify({"ok": False, "error": "invalid tf"}), 400 + f = request.files.get("file") if request.files else None + if not f or not getattr(f, "filename", None): + return jsonify({"ok": False, "error": "no file"}), 400 + folder = options_review_upload_dir(cfg["upload_folder"]) + item = save_options_review_slot_file( + f, draft_id, tf, folder, secure_filename_fn=secure_filename + ) + if not item: + return jsonify({"ok": False, "error": "save failed"}), 500 + return jsonify({"ok": True, "tf": tf, "file": item["file"]}) + + @app.route("/api/options/review/stats") + @lr + def api_options_review_stats(): + conn = cfg["get_db"]() + try: + ensure_local_review_synced(conn) + conn.commit() + stats = compute_review_stats( + conn, + source_type=(request.args.get("source_type") or "").strip() or None, + underlying=(request.args.get("underlying") or "").strip() or None, + include_hedge_legs=(request.args.get("include_hedge_legs") or "").strip().lower() + in ("1", "true", "yes"), + closed_from=(request.args.get("closed_from") or "").strip() or None, + closed_to=(request.args.get("closed_to") or "").strip() or None, + require_strategy=(request.args.get("require_strategy") or "").strip().lower() + in ("1", "true", "yes"), + ) + stats["source_types"] = list(SOURCE_TYPES) + stats["source_labels"] = SOURCE_LABELS + return jsonify(stats) + finally: + conn.close() diff --git a/lib/options/options_stats_lib.py b/lib/options/options_stats_lib.py new file mode 100644 index 0000000..052c43e --- /dev/null +++ b/lib/options/options_stats_lib.py @@ -0,0 +1,173 @@ +"""期权本地交易统计(胜率 / 盈亏 / 持仓时长).""" +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from lib.instance.instance_embed_context_lib import profit_loss_ratio_from_averages +from lib.options.options_db import init_options_tables + + +def _parse_ts(raw: Any) -> datetime | None: + if raw is None or raw == "": + return None + s = str(raw).strip().replace(" ", "T", 1) + try: + return datetime.fromisoformat(s) + except (TypeError, ValueError): + return None + + +def _hold_seconds(created_at: Any, closed_at: Any) -> float | None: + start = _parse_ts(created_at) + end = _parse_ts(closed_at) + if start is None or end is None: + return None + sec = (end - start).total_seconds() + return sec if sec >= 0 else None + + +def _avg_seconds(values: list[float]) -> float | None: + if not values: + return None + return round(sum(values) / len(values), 1) + + +def compute_options_stats_from_history(history: list[dict[str, Any]]) -> dict[str, Any]: + """基于期权历史列表(交易所)计算统计.""" + wins: list[float] = [] + losses: list[float] = [] + win_holds: list[float] = [] + loss_holds: list[float] = [] + all_holds: list[float] = [] + open_holds: list[float] = [] + now = datetime.now() + + for row in history: + if row.get("status") == "open": + start = _parse_ts(row.get("created_at")) + if start is not None: + sec = (now - start).total_seconds() + if sec >= 0: + open_holds.append(sec) + continue + pnl_raw = row.get("realized_pnl") + if pnl_raw is None: + continue + try: + pnl = float(pnl_raw) + except (TypeError, ValueError): + continue + hold = _hold_seconds(row.get("created_at"), row.get("closed_at")) + if hold is not None: + all_holds.append(hold) + if pnl > 0: + wins.append(pnl) + if hold is not None: + win_holds.append(hold) + elif pnl < 0: + losses.append(pnl) + if hold is not None: + loss_holds.append(hold) + + total_closed = len(wins) + len(losses) + win_rate = round(len(wins) / total_closed * 100, 2) if total_closed else 0 + avg_win = sum(wins) / len(wins) if wins else None + avg_loss = sum(losses) / len(losses) if losses else None + + total_profit = round(sum(wins), 4) if wins else 0.0 + total_loss = round(abs(sum(losses)), 4) if losses else 0.0 + net_realized = round(sum(wins) + sum(losses), 4) + return { + "total_closed": total_closed, + "win_count": len(wins), + "loss_count": len(losses), + "win_rate": win_rate, + "profit_loss_ratio": profit_loss_ratio_from_averages(avg_win, avg_loss), + "avg_win": round(avg_win, 4) if avg_win is not None else None, + "avg_loss": round(abs(avg_loss), 4) if avg_loss is not None else None, + "total_profit": total_profit, + "total_loss": total_loss, + "net_realized_pnl": net_realized, + "avg_hold_sec": _avg_seconds(all_holds), + "avg_win_hold_sec": _avg_seconds(win_holds), + "avg_loss_hold_sec": _avg_seconds(loss_holds), + "open_count": len(open_holds), + "avg_open_hold_sec": _avg_seconds(open_holds), + } + + +def compute_options_stats(get_db) -> dict[str, Any]: + conn = get_db() + try: + init_options_tables(conn) + closed_rows = conn.execute( + """ + SELECT realized_pnl, created_at, closed_at + FROM options_trades + WHERE status = 'closed' AND realized_pnl IS NOT NULL + """ + ).fetchall() + open_rows = conn.execute( + """ + SELECT created_at FROM options_trades WHERE status = 'open' + """ + ).fetchall() + finally: + conn.close() + + wins: list[float] = [] + losses: list[float] = [] + win_holds: list[float] = [] + loss_holds: list[float] = [] + all_holds: list[float] = [] + now = datetime.now() + + for row in closed_rows: + pnl = float(row["realized_pnl"]) + hold = _hold_seconds(row["created_at"], row["closed_at"]) + if hold is not None: + all_holds.append(hold) + if pnl > 0: + wins.append(pnl) + if hold is not None: + win_holds.append(hold) + elif pnl < 0: + losses.append(pnl) + if hold is not None: + loss_holds.append(hold) + + open_holds: list[float] = [] + for row in open_rows: + start = _parse_ts(row["created_at"]) + if start is None: + continue + sec = (now - start).total_seconds() + if sec >= 0: + open_holds.append(sec) + + total_closed = len(wins) + len(losses) + win_rate = round(len(wins) / total_closed * 100, 2) if total_closed else 0 + avg_win = sum(wins) / len(wins) if wins else None + avg_loss = sum(losses) / len(losses) if losses else None + + total_profit = round(sum(wins), 4) if wins else 0.0 + total_loss = round(abs(sum(losses)), 4) if losses else 0.0 + net_realized = round(sum(wins) + sum(losses), 4) + return { + "total_closed": total_closed, + "win_count": len(wins), + "loss_count": len(losses), + "win_rate": win_rate, + "profit_loss_ratio": profit_loss_ratio_from_averages(avg_win, avg_loss), + "avg_win": round(avg_win, 4) if avg_win is not None else None, + "avg_loss": round(abs(avg_loss), 4) if avg_loss is not None else None, + "total_profit": total_profit, + "total_loss": total_loss, + "net_realized_pnl": net_realized, + "avg_hold_sec": _avg_seconds(all_holds), + "avg_win_hold_sec": _avg_seconds(win_holds), + "avg_loss_hold_sec": _avg_seconds(loss_holds), + "open_count": len(open_holds), + "avg_open_hold_sec": _avg_seconds(open_holds), + } diff --git a/lib/options/options_target_lib.py b/lib/options/options_target_lib.py new file mode 100644 index 0000000..d20aef5 --- /dev/null +++ b/lib/options/options_target_lib.py @@ -0,0 +1,443 @@ +"""期权目标位委托:指数目标价仅用于监控触发;触发后按买一限价平仓(无止损,到期结算).""" +from __future__ import annotations + +import sqlite3 +import time +from typing import Any, Callable + +from lib.options.options_db import init_options_tables +from lib.options.options_pricing_lib import close_ref_prices, fetch_option_mark_px + + +def _safe_float(v: Any) -> float | None: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def _pos_close_refs(ex: Any, pos: dict[str, Any], quote: dict[str, Any] | None = None) -> tuple[float | None, float | None]: + from lib.exchange.okx_options_lib import option_fields_from_inst_id + + inst_id = str(pos.get("instId") or pos.get("inst_id") or "") + mark = _safe_float(pos.get("markPx")) or _safe_float((quote or {}).get("mark_px") or (quote or {}).get("mark")) + if mark is None: + mark = fetch_option_mark_px(ex, inst_id) + opt_type = pos.get("optType") or (quote or {}).get("opt_type") + strike = _safe_float(pos.get("stk")) or _safe_float((quote or {}).get("strike")) + if not opt_type or strike is None: + pt, ps = option_fields_from_inst_id(inst_id) + opt_type = opt_type or pt + if strike is None: + strike = ps + idx = _safe_float(pos.get("idxPx")) or _safe_float((quote or {}).get("index_px")) + return close_ref_prices(mark_px=mark, opt_type=str(opt_type or ""), strike=strike, index_px=idx) + + +def ensure_target_tables(conn: sqlite3.Connection) -> None: + init_options_tables(conn) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS options_target_monitors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + inst_id TEXT NOT NULL, + underlying TEXT, + opt_type TEXT, + target_index REAL NOT NULL, + trade_id INTEGER, + sheets INTEGER, + status TEXT DEFAULT 'active', + trigger_idx REAL, + close_ord_id TEXT, + message TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + triggered_at TIMESTAMP + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_options_target_monitors_status + ON options_target_monitors(status) + """ + ) + + +def target_hit(*, opt_type: str | None, index_px: float, target_index: float) -> bool: + """Call:指数涨到/超过目标平仓;Put:指数跌到/低于目标平仓.""" + ot = (opt_type or "").strip().upper() + if ot == "P": + return index_px <= target_index + return index_px >= target_index + + +def upsert_target_monitor( + conn: sqlite3.Connection, + *, + inst_id: str, + target_index: float, + underlying: str | None = None, + opt_type: str | None = None, + trade_id: int | None = None, + sheets: int | None = None, +) -> dict[str, Any]: + ensure_target_tables(conn) + inst_id = (inst_id or "").strip() + if not inst_id: + return {"ok": False, "msg": "缺少 inst_id"} + if target_index is None or float(target_index) <= 0: + return {"ok": False, "msg": "目标位无效"} + target_index = float(target_index) + row = conn.execute( + """ + SELECT id FROM options_target_monitors + WHERE inst_id = ? AND status IN ('active', 'closing') + ORDER BY CASE status WHEN 'active' THEN 0 WHEN 'closing' THEN 1 ELSE 2 END, id DESC + LIMIT 1 + """, + (inst_id,), + ).fetchone() + if row: + conn.execute( + """ + UPDATE options_target_monitors + SET target_index = ?, + underlying = COALESCE(?, underlying), + opt_type = COALESCE(?, opt_type), + trade_id = COALESCE(?, trade_id), + sheets = COALESCE(?, sheets), + status = 'active', + trigger_idx = NULL, + close_ord_id = NULL, + message = NULL, + triggered_at = NULL + WHERE id = ? + """, + (target_index, underlying, opt_type, trade_id, sheets, int(row["id"])), + ) + mon_id = int(row["id"]) + # 同一合约其他进行中的委托取消,避免双轨触发重复推送 + conn.execute( + """ + UPDATE options_target_monitors + SET status = 'cancelled', message = '被新目标位覆盖' + WHERE inst_id = ? AND id != ? AND status IN ('active', 'closing') + """, + (inst_id, mon_id), + ) + else: + cur = conn.execute( + """ + INSERT INTO options_target_monitors + (inst_id, underlying, opt_type, target_index, trade_id, sheets, status) + VALUES (?, ?, ?, ?, ?, ?, 'active') + """, + (inst_id, underlying, opt_type, target_index, trade_id, sheets), + ) + mon_id = int(cur.lastrowid) + return {"ok": True, "id": mon_id, "inst_id": inst_id, "target_index": target_index} + + +def cancel_target_monitor(conn: sqlite3.Connection, *, inst_id: str | None = None, monitor_id: int | None = None) -> int: + ensure_target_tables(conn) + if monitor_id is not None: + cur = conn.execute( + """ + UPDATE options_target_monitors + SET status = 'cancelled', message = '手动取消' + WHERE id = ? AND status IN ('active', 'closing') + """, + (int(monitor_id),), + ) + return int(cur.rowcount or 0) + if inst_id: + cur = conn.execute( + """ + UPDATE options_target_monitors + SET status = 'cancelled', message = '手动取消' + WHERE inst_id = ? AND status IN ('active', 'closing') + """, + (inst_id.strip(),), + ) + return int(cur.rowcount or 0) + return 0 + + +def _row_to_target(r: sqlite3.Row) -> dict[str, Any]: + return { + "id": int(r["id"]), + "inst_id": r["inst_id"], + "underlying": r["underlying"], + "opt_type": r["opt_type"], + "target_index": _safe_float(r["target_index"]), + "trade_id": r["trade_id"], + "sheets": r["sheets"], + "status": r["status"], + "message": r["message"], + "created_at": r["created_at"], + } + + +def list_active_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]: + ensure_target_tables(conn) + rows = conn.execute( + """ + SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets, + status, message, created_at + FROM options_target_monitors + WHERE status = 'active' + ORDER BY id DESC + """ + ).fetchall() + return [_row_to_target(r) for r in rows] + + +def list_closing_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]: + """已挂出平仓单、等待成交的目标(不再重复推送微信).""" + ensure_target_tables(conn) + rows = conn.execute( + """ + SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets, + status, message, created_at + FROM options_target_monitors + WHERE status = 'closing' + ORDER BY id DESC + """ + ).fetchall() + return [_row_to_target(r) for r in rows] + + +def targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]: + """UI/持仓挂载:active 与 closing 都算进行中.""" + out: dict[str, dict[str, Any]] = {} + for t in list_closing_targets(conn) + list_active_targets(conn): + inst = str(t.get("inst_id") or "") + if inst and inst not in out: + out[inst] = t + return out + + +def mark_monitor( + conn: sqlite3.Connection, + monitor_id: int, + *, + status: str, + trigger_idx: float | None = None, + close_ord_id: str | None = None, + message: str | None = None, +) -> None: + conn.execute( + """ + UPDATE options_target_monitors + SET status = ?, + trigger_idx = COALESCE(?, trigger_idx), + close_ord_id = COALESCE(?, close_ord_id), + message = COALESCE(?, message), + triggered_at = CASE + WHEN ? IN ('triggered', 'expired', 'closing') THEN COALESCE(triggered_at, CURRENT_TIMESTAMP) + ELSE triggered_at + END + WHERE id = ? + """, + (status, trigger_idx, close_ord_id, message, status, int(monitor_id)), + ) + + +def cancel_orphans_without_position( + conn: sqlite3.Connection, + *, + live_inst_ids: set[str], +) -> int: + """持仓已消失的目标委托标记为 expired(到期/已平),不挂止损.""" + ensure_target_tables(conn) + rows = list_active_targets(conn) + list_closing_targets(conn) + n = 0 + for t in rows: + inst = str(t.get("inst_id") or "") + if inst and inst not in live_inst_ids: + mark_monitor(conn, int(t["id"]), status="expired", message="持仓已平/到期,委托结束") + n += 1 + return n + + +def _commit_monitor(conn: sqlite3.Connection) -> None: + """状态变更立刻落库,避免后续 sync 异常回滚后重复触发/推送.""" + try: + conn.commit() + except Exception: + pass + + +def close_option_by_bid_depth( + cfg: dict[str, Any], + ex: Any, + inst_id: str, + *, + sheets: int | None = None, +) -> dict[str, Any]: + """目标触发后只锁买一限价卖出;需过 2×门控(通过后同仓续批只验流动性).""" + from lib.options.options_close_exec_lib import close_option_by_bid1 + + return close_option_by_bid1( + cfg, + ex, + inst_id, + sheets=sheets, + require_recycle_gate=True, + signal_note="目标位平仓", + ) + + + +def _notify_target_close( + send_wechat: Callable[[str], None] | None, + *, + account_label: str, + inst_id: str, + target: float, + idx: float, + result: dict[str, Any], +) -> None: + if not send_wechat: + return + try: + send_wechat( + "\n".join( + [ + "【OKX期权·目标位平仓】", + f"账户:{account_label}", + f"合约:{inst_id}", + f"目标指数:{target:g}", + f"触发指数:{idx:g}", + f"提交张数:{result.get('submitted_sheets') or '—'}", + f"预估收回:{result.get('premium_received') if result.get('premium_received') is not None else '—'} USDC", + ] + ) + ) + except Exception: + pass + + +def _result_fully_done(result: dict[str, Any]) -> bool: + if result.get("already_flat"): + return True + if result.get("fully_closed"): + return True + remaining = result.get("remaining_sheets") + if remaining is not None and int(remaining) <= 0 and result.get("ok"): + return True + return False + + +def run_options_target_closes( + conn: sqlite3.Connection, + positions: list[dict[str, Any]], + *, + close_fn: Callable[[str], dict[str, Any]], + index_fn: Callable[[dict[str, Any]], float | None] | None = None, + send_wechat: Callable[[str], None] | None = None, + account_label: str = "OKX期权", +) -> int: + """ + 扫描 active 目标委托;指数到位后限价平仓. + 状态先 commit 再推微信,避免 sync 失败回滚导致同一笔反复推送. + 未完全成交进入 closing,仅重试平仓不再推送. + 返回本次新触发(并推送)的条数. + """ + ensure_target_tables(conn) + pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions} + live_ids = {k for k in pos_by_inst if k} + cancel_orphans_without_position(conn, live_inst_ids=live_ids) + _commit_monitor(conn) + + # 先处理已挂单等待成交的,绝不再发微信 + for mon in list_closing_targets(conn): + inst_id = str(mon.get("inst_id") or "") + if not inst_id: + continue + if inst_id not in pos_by_inst: + mark_monitor(conn, int(mon["id"]), status="expired", message="持仓已平") + _commit_monitor(conn) + continue + result = close_fn(inst_id) + idx = _safe_float(pos_by_inst[inst_id].get("idx_px") or pos_by_inst[inst_id].get("idxPx")) + if result.get("already_flat") or _result_fully_done(result): + mark_monitor( + conn, + int(mon["id"]), + status="triggered", + trigger_idx=idx, + close_ord_id=result.get("close_ord_id"), + message="目标位限价平仓完成", + ) + _commit_monitor(conn) + continue + mark_monitor( + conn, + int(mon["id"]), + status="closing", + trigger_idx=idx, + close_ord_id=result.get("close_ord_id"), + message=str(result.get("msg") or result.get("stopped_reason") or "等待买一成交"), + ) + _commit_monitor(conn) + + triggered = 0 + for mon in list_active_targets(conn): + inst_id = str(mon.get("inst_id") or "") + target = _safe_float(mon.get("target_index")) + if not inst_id or target is None: + continue + pos = pos_by_inst.get(inst_id) + if not pos: + continue + if index_fn is not None: + idx = index_fn(pos) + else: + idx = _safe_float(pos.get("idx_px") or pos.get("idxPx")) + if idx is None: + continue + opt_type = mon.get("opt_type") or pos.get("opt_type") or pos.get("optType") + if not target_hit(opt_type=str(opt_type) if opt_type else None, index_px=idx, target_index=target): + continue + + result = close_fn(inst_id) + if result.get("already_flat"): + mark_monitor(conn, int(mon["id"]), status="expired", trigger_idx=idx, message="持仓已平") + _commit_monitor(conn) + continue + if not result.get("ok"): + mark_monitor( + conn, + int(mon["id"]), + status="active", + trigger_idx=idx, + message=str(result.get("msg") or result.get("stopped_reason") or "平仓未完成,将重试"), + ) + _commit_monitor(conn) + continue + + done = _result_fully_done(result) + status = "triggered" if done else "closing" + mark_monitor( + conn, + int(mon["id"]), + status=status, + trigger_idx=idx, + close_ord_id=result.get("close_ord_id"), + message="目标位触发限价平仓" if done else "目标位已挂买一限价,等待成交", + ) + # 关键:先落库,再推送——否则后续 sync 异常回滚会让同一笔反复推微信 + _commit_monitor(conn) + triggered += 1 + _notify_target_close( + send_wechat, + account_label=account_label, + inst_id=inst_id, + target=target, + idx=idx, + result=result, + ) + return triggered diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html new file mode 100644 index 0000000..dea3d9d --- /dev/null +++ b/lib/options/templates/options_panel.html @@ -0,0 +1,277 @@ +
        + {% if not options_enabled %} +
        期权 API 未启用:请在 crypto_monitor_okx/.env 设置 OKX_OPTIONS_ENABLED=true 及主账户 OKX_OPTIONS_API_*,然后 pm2 restart crypto_okx --update-env.
        + {% endif %} + +
        +
        +

        期权下单 开平仓与监控说明

        +

        报价单位为每 1 ETH/BTC;1 张 = 0.01.列表含卖一/买一;T 型仅卖一(买方开仓),中间为跨式双买测算.链上无卖一挂单时以标记价/内在价值估算并标 ~(仅参考).开仓只认真实卖一价且卖一深度>0;无深度时面板显示参考标记价并禁用买入.链展示近 14 日到期.T 型默认 ATM ±5 档,可展开全部.平仓仅买一限价,见说明.

        +
        + + + + + + + + + + + + + + + + +
        +
        +
        + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
        行权价类型合约卖一/张买一/张到期平衡距平衡操作
        请选择到期日
        +
        + +
        + +
        +
        +

        持仓

        + +
        +
        + + + +
        +
        +
        + +
        +
        暂无持仓
        +
        +
        +
        + 买一平仓规则说明 +
        +

        平仓前重新读盘口并校验有效流动性;市价平仓已禁用。

        +
          +
        • 本轮只锁买一:张数 = min(持仓, 买一深度),限价 = 当场买一。
        • +
        • 买一不够时只平能吃掉的部分,剩余等下次再点「买一平仓」。
        • +
        • 手动平仓只验有效买一(非残档);目标触达后才平,2×权利金只是门控(到 2× 本身不会自动平)。
        • +
        • 全程 reduceOnly 限价卖,不吃买二及以下、不走市价。
        • +
        +

        打开《期权开平仓与监控说明》

        +
        +
        +
        + + +
        +
        +
        +
        + + diff --git a/lib/options/templates/options_review_panel.html b/lib/options/templates/options_review_panel.html new file mode 100644 index 0000000..ad097a8 --- /dev/null +++ b/lib/options/templates/options_review_panel.html @@ -0,0 +1,227 @@ +{# OKX 期权复盘:交易记录(5行) → 点复盘出表单 → 复盘记录 → 统计 #} +
        + {% if not options_enabled %} +
        期权未启用:请设置 OKX_OPTIONS_ENABLED=true 后重启.
        + {% endif %} + + + + {# 1. 交易记录(含 Tab/筛选,固定约5行) #} +
        +
        +

        期权复盘

        + + +
        +
        + + + +
        +

        待复盘交易(每页5条).点「复盘」填写表单;保存后进入下方复盘记录.

        +
        + + + + + + +
        +

        期权交易记录

        +
        + + + + + + + + + + + + + + +
        类型标的/合约盈亏开/平持有操作
        加载中…
        +
        +
        + + 第 1 / 1 页 + +
        +
        + + {# 2. 复盘上传(默认隐藏,点交易「复盘」后显示) #} + + + {# 3. 已复盘记录 + 详情 #} +
        +

        复盘记录

        +

        已保存的复盘(每页5条).点一行查看详情.

        +
        + + + + + + + + + + + + + + +
        类型标的/合约盈亏策略结果复盘时间
        加载中…
        +
        +
        + + 第 1 / 1 页 + +
        + +
        + + {# 4. 统计 #} +
        +

        统计

        +
        +
        +
        +
        + + diff --git a/lib/options/templates/options_settings_panel.html b/lib/options/templates/options_settings_panel.html new file mode 100644 index 0000000..e10bdbe --- /dev/null +++ b/lib/options/templates/options_settings_panel.html @@ -0,0 +1,4 @@ +{# 期权设置脚本挂载点(卡片在 settings_panel 中拆分) #} + + diff --git a/lib/options/templates/options_settings_swap.html b/lib/options/templates/options_settings_swap.html new file mode 100644 index 0000000..ed3fb27 --- /dev/null +++ b/lib/options/templates/options_settings_swap.html @@ -0,0 +1,13 @@ +
        +

        主账户资金账户:USDT ↔ USDC 现货市价单.

        +
        + + + + +
        +
        +
        diff --git a/lib/options/templates/options_settings_transfer.html b/lib/options/templates/options_settings_transfer.html new file mode 100644 index 0000000..9d00a27 --- /dev/null +++ b/lib/options/templates/options_settings_transfer.html @@ -0,0 +1,50 @@ +
        +
        主账户内
        +
        + + + + + + +
        +
        +
        + +
        +
        + 主子账户 + ({{ instance_settings.options_sub_account or '未配置' }}) +
        +
        + + + + + + + +
        +
        +
        diff --git a/lib/paths.py b/lib/paths.py new file mode 100644 index 0000000..b9f8ad9 --- /dev/null +++ b/lib/paths.py @@ -0,0 +1,33 @@ +"""Repository path helpers for lib/ assets.""" +from __future__ import annotations + +from pathlib import Path + +LIB_DIR = Path(__file__).resolve().parent +REPO_ROOT = LIB_DIR.parent + + +def strategy_templates_dir(repo_root: str | Path | None = None) -> str: + root = Path(repo_root) if repo_root is not None else REPO_ROOT + return str(root / "lib" / "strategy" / "templates") + + +def embed_templates_dir(repo_root: str | Path | None = None) -> str: + root = Path(repo_root) if repo_root is not None else REPO_ROOT + return str(root / "lib" / "instance" / "templates") + + +def common_static_dir(repo_root: str | Path | None = None) -> str: + root = Path(repo_root) if repo_root is not None else REPO_ROOT + return str(root / "lib" / "common" / "static") + + +def manual_trading_hub_dir(repo_root: str | Path | None = None) -> Path: + root = Path(repo_root) if repo_root is not None else REPO_ROOT + return root / "manual_trading_hub" + + +def hub_data_dir(repo_root: str | Path | None = None) -> Path: + path = manual_trading_hub_dir(repo_root) / "data" + path.mkdir(parents=True, exist_ok=True) + return path diff --git a/lib/strategy/__init__.py b/lib/strategy/__init__.py new file mode 100644 index 0000000..ab164b5 --- /dev/null +++ b/lib/strategy/__init__.py @@ -0,0 +1 @@ +"""Shared library package.""" diff --git a/lib/strategy/strategy_config.py b/lib/strategy/strategy_config.py new file mode 100644 index 0000000..4be45c8 --- /dev/null +++ b/lib/strategy/strategy_config.py @@ -0,0 +1,232 @@ +"""各交易所 app 模块 → strategy_register 配置(统一工厂).""" +from __future__ import annotations + +import sys +from typing import Any + + +def resolve_trading_app_module(app_module: Any = None) -> Any: + """ + 须在 login_required 定义之后调用. + PM2 / python app.py 时 __name__ 为 __main__,请传入 sys.modules[__name__]. + """ + if app_module is None: + main = sys.modules.get("__main__") + if main is not None and hasattr(main, "login_required"): + m = main + else: + import inspect + + m = None + for fr in inspect.stack(): + g = fr.frame.f_globals + if callable(g.get("login_required")) and callable(g.get("get_db")): + m = g + break + if m is None: + raise RuntimeError( + "策略交易注册失败:请使用 install_strategy_trading(app, repo_root, app_module=sys.modules[__name__])" + ) + else: + m = app_module + if not hasattr(m, "login_required"): + raise RuntimeError( + "策略交易注册须在 login_required 定义之后执行(将 install_strategy_trading 放在 app.py 末尾)" + ) + return m + + +def build_strategy_config( + app_module: Any = None, *, trend_enabled: bool = False, trend_disabled_note: str = "" +) -> dict: + m = resolve_trading_app_module(app_module) + + def get_trading_capital_usdt(conn): + if hasattr(m, "get_exchange_capitals"): + _, tc = m.get_exchange_capitals(force=True) + if tc is not None: + return float(tc) + if hasattr(m, "get_available_trading_usdt"): + snap = m.get_available_trading_usdt() + if snap is not None: + return float(snap) + day = m.get_trading_day(m.app_now()) + row = m.ensure_session(conn, day) + return float(row["current_capital"]) + + def get_position(ex_sym, direction): + from lib.hub.hub_position_metrics import normalize_contracts_qty + + qty = m.get_live_position_contracts(ex_sym, direction) + entry = None + try: + rows = m.exchange.fetch_positions([ex_sym]) + for p in rows or []: + matcher = getattr(m, "_row_matches_monitor_direction", None) + if matcher and not matcher(direction, p): + continue + contracts = getattr(m, "_position_row_effective_contracts", lambda x: abs(float(x.get("contracts") or 0)))(p) + if contracts <= 0: + continue + coerce = getattr(m, "_coerce_float", None) + if coerce: + entry = coerce( + p.get("entryPrice"), + p.get("average"), + (p.get("info") or {}).get("entryPrice"), + ) + if entry: + break + except Exception: + pass + return {"contracts": normalize_contracts_qty(qty or 0), "entry_price": entry} + + def amount_to_precision(ex_sym, amount): + try: + return float(m.exchange.amount_to_precision(ex_sym, float(amount))) + except Exception: + return None + + def price_to_precision(ex_sym, price): + try: + return float(m.exchange.price_to_precision(ex_sym, float(price))) + except Exception: + return None + + def market_add(ex_sym, direction, amount, leverage): + return m.place_exchange_order(ex_sym, direction, amount, leverage, stop_loss=None, take_profit=None) + + def limit_add(ex_sym, direction, amount, price, leverage): + m.exchange.set_leverage(int(leverage), ex_sym) + side = "buy" if direction == "long" else "sell" + if hasattr(m, "build_okx_order_params"): + params = m.build_okx_order_params(direction, reduce_only=False) + elif hasattr(m, "build_binance_order_params"): + params = m.build_binance_order_params(direction, reduce_only=False) + elif hasattr(m, "build_gate_order_params"): + params = m.build_gate_order_params(direction, reduce_only=False) + else: + params = {} + return m.exchange.create_order( + ex_sym, "limit", side, float(amount), float(price), params if params is not None else {} + ) + + def replace_tpsl(ex_sym, direction, sl, tp, order_row): + row = order_row or {"symbol": ex_sym, "exchange_symbol": ex_sym, "direction": direction} + m.replace_active_monitor_tpsl_on_exchange(row, sl, tp) + + def count_trends(conn): + try: + return int( + conn.execute( + "SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'" + ).fetchone()[0] + ) + except Exception: + return 0 + + def friendly_error(err): + fn = getattr(m, "friendly_exchange_error", None) or getattr( + m, "friendly_okx_error", None + ) + if not callable(fn): + return str(err) + try: + snap = m.get_available_trading_usdt() + except Exception: + snap = None + try: + return fn(err, available_usdt=snap) + except TypeError: + return fn(err) + + def limit_order_status(ex_sym, order_id): + fn = getattr(m, "fib_limit_order_status", None) + if callable(fn): + return fn(ex_sym, order_id) + return "unknown" + + def cancel_limit_order(ex_sym, order_id): + fn = getattr(m, "cancel_fib_limit_order", None) + if callable(fn): + try: + return fn(ex_sym, order_id) + except Exception: + pass + if not order_id: + return False + try: + m.exchange.cancel_order(str(order_id), ex_sym) + return True + except Exception: + return False + + def get_mark_price(symbol): + fn = getattr(m, "get_symbol_mark_price", None) or getattr(m, "get_price", None) + if not callable(fn): + return None + try: + return fn(symbol) + except Exception: + return None + + def wechat_account_label(): + fn = getattr(m, "_wechat_account_label", None) + if callable(fn): + try: + return fn() + except Exception: + pass + return getattr(m, "EXCHANGE_DISPLAY_NAME", "") or "" + + def wechat_direction_text(direction): + fn = getattr(m, "_wechat_direction_text", None) + if callable(fn): + try: + return fn(direction) + except Exception: + pass + d = (direction or "long").strip().lower() + return "做多" if d == "long" else "做空" + + def send_wechat(content): + fn = getattr(m, "send_wechat_msg", None) + if callable(fn): + fn(content) + + note = trend_disabled_note or ( + "趋势回调(自动补仓)请在 Gate机器人实例使用:/strategy/trend" + ) + return { + "app_module": m, + "exchange_display": getattr(m, "EXCHANGE_DISPLAY_NAME", ""), + "trend_enabled": trend_enabled, + "trend_disabled_note": note, + "login_required": m.login_required, + "get_db": m.get_db, + "normalize_symbol_input": m.normalize_symbol_input, + "normalize_exchange_symbol": m.normalize_exchange_symbol, + "get_price": m.get_price, + "get_trading_capital_usdt": get_trading_capital_usdt, + "get_position": get_position, + "amount_to_precision": amount_to_precision, + "price_to_precision": price_to_precision, + "market_add": market_add, + "limit_add": limit_add, + "replace_tpsl": replace_tpsl, + "ensure_live_ready": m.ensure_exchange_live_ready, + "default_risk_percent": float(getattr(m, "RISK_PERCENT", 2)), + "default_leverage": m.infer_leverage, + "friendly_error": friendly_error, + "app_now_str": m.app_now_str, + "resolve_fill_price": m.resolve_order_entry_price, + "price_fmt": m.format_price_for_symbol, + "count_active_trend_plans": count_trends if trend_enabled else count_trends, + "limit_order_status": limit_order_status, + "cancel_limit_order": cancel_limit_order, + "get_mark_price": get_mark_price, + "send_wechat": send_wechat, + "format_price": getattr(m, "format_price_for_symbol", None), + "wechat_account_label": wechat_account_label, + "wechat_direction_text": wechat_direction_text, + } diff --git a/lib/strategy/strategy_db.py b/lib/strategy/strategy_db.py new file mode 100644 index 0000000..8ab3b49 --- /dev/null +++ b/lib/strategy/strategy_db.py @@ -0,0 +1,164 @@ +"""策略交易相关表结构(各所 crypto.db 共用 schema).""" + +ROLL_GROUPS_SQL = """ +CREATE TABLE IF NOT EXISTS roll_groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_monitor_id INTEGER, + symbol TEXT NOT NULL, + exchange_symbol TEXT, + direction TEXT NOT NULL, + initial_take_profit REAL, + initial_stop_loss REAL, + current_stop_loss REAL, + risk_percent REAL DEFAULT 2, + leg_count INTEGER DEFAULT 0, + status TEXT DEFAULT 'active', + created_at TEXT, + updated_at TEXT +) +""" + +ROLL_LEGS_SQL = """ +CREATE TABLE IF NOT EXISTS roll_legs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + roll_group_id INTEGER NOT NULL, + leg_index INTEGER NOT NULL, + add_mode TEXT NOT NULL, + fib_upper REAL, + fib_lower REAL, + limit_price REAL, + fill_price REAL, + amount REAL, + new_stop_loss REAL, + exchange_order_id TEXT, + status TEXT DEFAULT 'filled', + created_at TEXT, + FOREIGN KEY (roll_group_id) REFERENCES roll_groups(id) +) +""" + +TREND_PLANS_SQL = """ +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, + initial_stop_loss REAL, + breakeven_applied INTEGER DEFAULT 0, + breakeven_applied_at TEXT +) +""" + +TREND_PREVIEWS_SQL = """ +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 +) +""" + +TREND_PREVIEW_SNAPSHOTS_SQL = """ +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 +) +""" + + +def init_strategy_tables(conn) -> None: + from lib.strategy.strategy_snapshot_lib import init_strategy_snapshot_table + + conn.execute(ROLL_GROUPS_SQL) + conn.execute(ROLL_LEGS_SQL) + conn.execute(TREND_PLANS_SQL) + conn.execute(TREND_PREVIEWS_SQL) + conn.execute(TREND_PREVIEW_SNAPSHOTS_SQL) + init_strategy_snapshot_table(conn) + for ddl in ( + "ALTER TABLE trend_pullback_plans ADD COLUMN leg_amounts_json TEXT", + "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", + "ALTER TABLE trend_pullback_preview_snapshots ADD COLUMN preview_created_at TEXT", + "ALTER TABLE trend_pullback_preview_snapshots ADD COLUMN outcome TEXT DEFAULT 'open'", + "ALTER TABLE trend_pullback_preview_snapshots ADD COLUMN executed_plan_id INTEGER", + "ALTER TABLE trade_records ADD COLUMN trend_plan_id INTEGER", + "ALTER TABLE order_monitors ADD COLUMN trend_plan_id INTEGER", + "ALTER TABLE order_monitors ADD COLUMN monitor_type TEXT", + "ALTER TABLE order_monitors ADD COLUMN key_signal_type TEXT", + "ALTER TABLE trend_pullback_plans ADD COLUMN leg_fill_prices_json TEXT", + "ALTER TABLE roll_legs ADD COLUMN stop_offset_pct REAL", + "ALTER TABLE roll_legs ADD COLUMN breakthrough_price REAL", + "ALTER TABLE roll_legs ADD COLUMN last_mark_price REAL", + ): + try: + conn.execute(ddl) + except Exception: + pass diff --git a/lib/strategy/strategy_exchange_base.py b/lib/strategy/strategy_exchange_base.py new file mode 100644 index 0000000..d2c4754 --- /dev/null +++ b/lib/strategy/strategy_exchange_base.py @@ -0,0 +1,48 @@ +"""交易所策略适配器接口(各所 app 注入 ccxt 实现).""" +from __future__ import annotations + +from typing import Any, Optional, Protocol + + +class StrategyExchangeAdapter(Protocol): + exchange_key: str + + def normalize_symbol(self, raw: str) -> str: ... + + def normalize_exchange_symbol(self, symbol: str) -> str: ... + + def get_mark_price(self, symbol: str) -> Optional[float]: ... + + def get_position(self, exchange_symbol: str, direction: str) -> dict[str, Any]: + """返回 {contracts, entry_price, leverage?}.""" + ... + + def amount_to_precision(self, exchange_symbol: str, amount: float) -> Optional[float]: ... + + def price_to_precision(self, exchange_symbol: str, price: float) -> Optional[float]: ... + + def market_add( + self, exchange_symbol: str, direction: str, amount: float, leverage: int + ) -> dict[str, Any]: ... + + def limit_add( + self, + exchange_symbol: str, + direction: str, + amount: float, + price: float, + leverage: int, + ) -> dict[str, Any]: ... + + def cancel_order(self, exchange_symbol: str, order_id: str) -> None: ... + + def replace_position_tpsl( + self, + exchange_symbol: str, + direction: str, + stop_loss: float, + take_profit: float, + order_monitor_row: Any = None, + ) -> None: ... + + def ensure_live_ready(self) -> tuple[bool, str]: ... diff --git a/lib/strategy/strategy_exchange_binance.py b/lib/strategy/strategy_exchange_binance.py new file mode 100644 index 0000000..4951aca --- /dev/null +++ b/lib/strategy/strategy_exchange_binance.py @@ -0,0 +1,4 @@ +"""Binance USDT-M 永续 — 策略交易交易所适配(见 strategy_config.build_strategy_config).""" +from lib.strategy.strategy_exchange_base import StrategyExchangeAdapter + +__all__ = ["StrategyExchangeAdapter"] diff --git a/lib/strategy/strategy_exchange_gate.py b/lib/strategy/strategy_exchange_gate.py new file mode 100644 index 0000000..67ea792 --- /dev/null +++ b/lib/strategy/strategy_exchange_gate.py @@ -0,0 +1,9 @@ +""" +Gate.io USDT 永续 — 策略交易交易所侧能力. + +实现方式:各 Gate 实例 app 通过 strategy_config.build_strategy_config(app_module) 注入 +ccxt 下单,精度,换 TP/SL;本文件为文档与类型锚点,避免在各 app 重复实现滚仓公式. +""" +from lib.strategy.strategy_exchange_base import StrategyExchangeAdapter + +__all__ = ["StrategyExchangeAdapter"] diff --git a/lib/strategy/strategy_exchange_okx.py b/lib/strategy/strategy_exchange_okx.py new file mode 100644 index 0000000..5ea0963 --- /dev/null +++ b/lib/strategy/strategy_exchange_okx.py @@ -0,0 +1,4 @@ +"""OKX 永续 — 策略交易交易所适配(见 strategy_config.build_strategy_config).""" +from lib.strategy.strategy_exchange_base import StrategyExchangeAdapter + +__all__ = ["StrategyExchangeAdapter"] diff --git a/lib/strategy/strategy_records_register.py b/lib/strategy/strategy_records_register.py new file mode 100644 index 0000000..ed2b45a --- /dev/null +++ b/lib/strategy/strategy_records_register.py @@ -0,0 +1,72 @@ +"""策略交易记录页:已结束趋势 / 顺势加仓快照(三所统一).""" +from __future__ import annotations + +import json +from typing import Any + +from flask import flash, redirect, url_for + +from lib.strategy.strategy_snapshot_lib import ( + STRATEGY_SNAPSHOTS_MAX_ROWS, + dedupe_strategy_snapshots, + list_strategy_snapshots_split, +) + + +def load_strategy_records_page( + conn, *, limit: int = STRATEGY_SNAPSHOTS_MAX_ROWS +) -> dict[str, Any]: + try: + if dedupe_strategy_snapshots(conn): + conn.commit() + except Exception: + pass + trend, roll, symbols = list_strategy_snapshots_split(conn, limit=limit) + return { + "strategy_trend_records": trend, + "strategy_roll_records": roll, + "strategy_record_symbols": symbols, + "strategy_records_limit": limit, + "strategy_snapshots": trend + roll, + } + + +def register_strategy_records(app, cfg: dict[str, Any]) -> None: + login_required = cfg["login_required"] + get_db = cfg["get_db"] + + def _lr(f): + return login_required(f) + + @_lr + @app.route("/strategy/records") + def strategy_records_page(): + m = cfg.get("app_module") + fn = getattr(m, "render_main_page", None) + if not callable(fn): + flash("render_main_page 未配置") + return redirect(url_for("strategy_trading_page")) + return fn("strategy_records") + + @_lr + @app.route("/strategy/records/") + def strategy_records_detail(snap_id: int): + conn = get_db() + row = conn.execute( + "SELECT * FROM strategy_trade_snapshots WHERE id=?", + (int(snap_id),), + ).fetchone() + conn.close() + if not row: + flash("未找到该策略快照") + return redirect(url_for("strategy_records_page")) + try: + snap = json.loads(row["snapshot_json"] or "{}") + except Exception: + snap = {} + dca = snap.get("dca_levels") or [] + flash( + f"快照 #{snap_id} {row['strategy_type']} {row['symbol']} " + f"{row['result_label']} · 补仓档 {len(dca)} 项(详情见列表页)" + ) + return redirect(url_for("strategy_records_page")) diff --git a/lib/strategy/strategy_register.py b/lib/strategy/strategy_register.py new file mode 100644 index 0000000..00575fa --- /dev/null +++ b/lib/strategy/strategy_register.py @@ -0,0 +1,654 @@ +"""策略交易:Flask 路由注册(顺势加仓 + 趋势回调页).逻辑在 strategy_*_lib.""" +from __future__ import annotations + +from lib.paths import strategy_templates_dir + +import html as html_module +import os +import re +from typing import Any, Optional + +from flask import Flask, flash, jsonify, redirect, render_template, request, url_for +from jinja2 import ChoiceLoader, FileSystemLoader + +from lib.strategy.strategy_db import init_strategy_tables +from lib.strategy.strategy_roll_lib import BREAKOUT_MODE, FIB_MODES, MARKET_MODE, preview_roll +from lib.strategy.strategy_roll_monitor_lib import ( + cancel_roll_pending_leg, + count_filled_roll_legs, + count_pending_roll_legs, + sync_roll_after_external_close, +) + + +def _dedupe_strategy_snapshots_on_startup(cfg: dict[str, Any]) -> None: + """启动时清理历史重复快照(同计划同结果仅保留最新一条).""" + get_db = cfg.get("get_db") + if not callable(get_db): + return + try: + from lib.strategy.strategy_snapshot_lib import dedupe_strategy_snapshots + + conn = get_db() + try: + removed = dedupe_strategy_snapshots(conn) + if removed: + conn.commit() + print( + f"[strategy] deduped {removed} duplicate strategy_trade_snapshots", + flush=True, + ) + finally: + conn.close() + except Exception as e: + print(f"[strategy] snapshot dedupe skipped: {e}", flush=True) + + +def install_strategy_trading(app: Flask, repo_root: str, app_module: Any = None, **build_kw) -> None: + """在 app.py 末尾调用(login_required 已定义后).仅注册 POST API;页面由各 app 的 render_main_page 渲染.""" + from lib.strategy.strategy_config import build_strategy_config + + build_kw.pop("render_trend_page", None) + attach_strategy_templates(app, repo_root) + cfg = build_strategy_config(app_module, **build_kw) + register_strategy_trading(app, cfg) + from lib.strategy.strategy_records_register import register_strategy_records + + register_strategy_records(app, cfg) + app.extensions["strategy_roll_cfg"] = cfg + _dedupe_strategy_snapshots_on_startup(cfg) + + +def attach_strategy_templates(app: Flask, repo_root: str) -> None: + strat_dir = strategy_templates_dir(repo_root) + if not os.path.isdir(strat_dir): + return + existing = app.jinja_loader + loaders = [FileSystemLoader(strat_dir)] + if existing is not None: + if isinstance(existing, ChoiceLoader): + loaders = list(existing.loaders) + loaders + else: + loaders.insert(0, existing) + app.jinja_loader = ChoiceLoader(loaders) + + +def register_strategy_trading(app: Flask, cfg: dict[str, Any]) -> None: + """cfg 由各市面 app 注入回调(仅 API / DB 差异).""" + + login_required = cfg["login_required"] + + def _lr(f): + return login_required(f) + + @_lr + @app.route("/strategy/roll/preview", methods=["POST"]) + def strategy_roll_preview(): + data = request.get_json(silent=True) or request.form + err = _roll_preview_response(cfg, data, json_mode=request.is_json) + if request.is_json: + return jsonify(err) + if err.get("ok"): + p = err["preview"] + flash( + f"预览:约 {p.get('add_amount_display', '-')} 张," + f"合并均价 {p.get('avg_entry_after', '-')}," + f"打到止损约 {p.get('loss_at_sl_usdt', '-')}U" + ) + else: + flash(err.get("msg") or "预览失败") + return redirect(url_for("strategy_trading_page")) + + @_lr + @app.route("/strategy/roll/execute", methods=["POST"]) + def strategy_roll_execute(): + data = request.form + try: + ok, msg = _roll_execute(cfg, data) + except Exception as e: + fe = cfg.get("friendly_error") + msg = fe(e) if callable(fe) else str(e) + ok = False + flash(msg) + return redirect(url_for("strategy_trading_page")) + + @_lr + @app.route("/strategy/roll/cancel/", methods=["POST"]) + def strategy_roll_cancel_leg(leg_id: int): + conn = cfg["get_db"]() + try: + init_strategy_tables(conn) + ok, msg = cancel_roll_pending_leg(cfg, conn, leg_id) + finally: + conn.close() + if request.is_json: + return jsonify({"ok": ok, "msg": msg}) + flash(msg) + return redirect(url_for("strategy_trading_page")) + + @_lr + @app.route("/strategy/roll/docs") + def strategy_roll_docs(): + path = _resolve_roll_doc_path() + if not path: + flash("滚仓说明文档不存在") + return redirect(url_for("strategy_trading_page")) + with open(path, encoding="utf-8") as f: + raw = f.read() + return render_template( + "strategy_roll_docs.html", + doc_html=_roll_doc_markdown_to_html(raw), + exchange_display=cfg.get("exchange_display") or "", + ) + + +def _resolve_roll_doc_path() -> str | None: + """滚仓说明 md:优先包内,否则仓库根目录(顺势加仓滚仓说明.md).""" + here = os.path.dirname(os.path.abspath(__file__)) + name = "顺势加仓滚仓说明.md" + for path in ( + os.path.join(here, name), + os.path.normpath(os.path.join(here, "..", "..", name)), + ): + if os.path.isfile(path): + return path + return None + + +def _roll_doc_markdown_to_html(text: str) -> str: + """轻量 Markdown → HTML(仅供滚仓说明页).""" + lines = text.splitlines() + out: list[str] = [] + i = 0 + in_code = False + code_buf: list[str] = [] + + def flush_code() -> None: + nonlocal code_buf + if code_buf: + out.append( + "
        "
        +                + html_module.escape("\n".join(code_buf))
        +                + "
        " + ) + code_buf = [] + + def inline_md(s: str) -> str: + s = html_module.escape(s) + s = re.sub(r"`([^`]+)`", r"\1", s) + s = re.sub(r"\*\*([^*]+)\*\*", r"\1", s) + return s + + while i < len(lines): + line = lines[i] + if line.strip().startswith("```"): + if in_code: + in_code = False + flush_code() + else: + in_code = True + i += 1 + continue + if in_code: + code_buf.append(line) + i += 1 + continue + if line.startswith("# "): + out.append(f"

        {inline_md(line[2:].strip())}

        ") + elif line.startswith("## "): + out.append(f"

        {inline_md(line[3:].strip())}

        ") + elif line.startswith("### "): + out.append(f"

        {inline_md(line[4:].strip())}

        ") + elif line.strip() == "---": + out.append("
        ") + elif line.startswith("|") and "|" in line[1:]: + rows: list[str] = [] + while i < len(lines) and lines[i].startswith("|"): + rows.append(lines[i]) + i += 1 + if len(rows) >= 2 and re.match(r"^\|[\s\-:|]+\|$", rows[1].strip()): + out.append("") + hdr = [c.strip() for c in rows[0].strip("|").split("|")] + out.append("" + "".join(f"" for c in hdr) + "") + for row in rows[2:]: + cells = [c.strip() for c in row.strip("|").split("|")] + out.append("" + "".join(f"" for c in cells) + "") + out.append("
        {inline_md(c)}
        {inline_md(c)}
        ") + continue + elif re.match(r"^[-*]\s+", line): + out.append("
          ") + while i < len(lines) and re.match(r"^[-*]\s+", lines[i]): + item = re.sub(r"^[-*]\s+", "", lines[i]) + out.append(f"
        • {inline_md(item)}
        • ") + i += 1 + out.append("
        ") + continue + elif line.strip(): + out.append(f"

        {inline_md(line.strip())}

        ") + i += 1 + flush_code() + return "\n".join(out) + + +def _row_to_dict(row) -> dict: + if row is None: + return {} + try: + return dict(row) + except Exception: + return {} + + +def _count_active_trends(conn, cfg: dict) -> int: + fn = cfg.get("count_active_trend_plans") + if callable(fn): + return int(fn(conn) or 0) + try: + return int( + conn.execute( + "SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'" + ).fetchone()[0] + ) + except Exception: + return 0 + + +def _risk_from_monitor(mon: dict, cfg: dict) -> tuple[Optional[float], Optional[str]]: + try: + rp = float(mon.get("risk_percent") or cfg.get("default_risk_percent", 2)) + except (TypeError, ValueError): + return None, "监控单风险%无效" + if rp <= 0: + return None, "监控单风险%须大于0" + return rp, None + + +def _contract_size(cfg: dict, ex_sym: str) -> float: + get_cs = cfg.get("get_contract_size") + if callable(get_cs): + try: + return float(get_cs(ex_sym) or 1.0) + except Exception: + pass + return 1.0 + + +def _roll_context(cfg: dict, data: dict) -> tuple[Optional[dict], Optional[str]]: + m = cfg.get("app_module") + if m is not None: + try: + from lib.trade.position_sizing_lib import OPEN_SOURCE_ROLL, assert_open_source_allowed + + mode = getattr(m, "POSITION_SIZING_MODE", None) or "risk" + ok_src, src_msg = assert_open_source_allowed(mode, OPEN_SOURCE_ROLL) + if not ok_src: + return None, src_msg + except Exception: + pass + get_db = cfg["get_db"] + symbol = cfg["normalize_symbol_input"](data.get("symbol") or "") + if not symbol: + return None, "请选择或填写币种" + direction = (data.get("direction") or "long").strip().lower() + validate_fn = getattr(m, "validate_trade_policy_open", None) if m is not None else None + if callable(validate_fn): + ok_pol, pol_msg = validate_fn(symbol, direction) + if not ok_pol: + return None, pol_msg + ex_sym = cfg["normalize_exchange_symbol"](symbol) + conn = get_db() + init_strategy_tables(conn) + if _count_active_trends(conn, cfg) > 0: + conn.close() + return None, "存在运行中的趋势回调计划,请先结束后再滚仓" + mon = _get_active_monitor(conn, cfg, symbol, direction) + if not mon: + conn.close() + return None, "未找到该币种同向的下单监控持仓,请先在「实盘下单」开仓" + rg, legs_done, pending, roll_is_new = _get_or_create_roll_group_meta(conn, mon) + if pending > 0: + conn.close() + return None, "已有监控中的滚仓腿,请等待成交/失效或先删除后再提交" + conn_cap = get_db() + try: + capital = float(cfg["get_trading_capital_usdt"](conn_cap)) + finally: + conn_cap.close() + risk_pct, risk_err = _risk_from_monitor(mon, cfg) + if risk_err: + conn.close() + return None, risk_err + pos = cfg["get_position"](ex_sym, direction) + qty = float(pos.get("contracts") or 0) + if qty <= 0: + conn.close() + return None, "交易所无该方向持仓,无法滚仓" + entry = float(pos.get("entry_price") or mon.get("trigger_price") or 0) + if entry <= 0: + conn.close() + return None, "无法获取持仓均价" + mark_fn = cfg.get("get_mark_price") or cfg.get("get_price") + mark = mark_fn(symbol) if callable(mark_fn) else cfg["get_price"](symbol) + ctx = { + "conn": conn, + "mon": mon, + "rg": rg, + "legs_done": legs_done, + "symbol": symbol, + "direction": direction, + "ex_sym": ex_sym, + "qty": qty, + "entry": entry, + "mark": float(mark) if mark else None, + "capital": capital, + "risk_pct": float(risk_pct), + "tp0": float(mon.get("take_profit") or rg.get("initial_take_profit") or 0), + "contract_size": _contract_size(cfg, ex_sym), + } + return ctx, None + + +def _parse_roll_form(data: dict, ctx: dict) -> tuple[Optional[dict], Optional[str]]: + add_mode = (data.get("add_mode") or MARKET_MODE).strip().lower() + raw_sl = data.get("new_stop_loss") or data.get("sl") + if raw_sl in (None, ""): + return None, "请填写新止损价" + try: + new_sl = float(raw_sl) + except (TypeError, ValueError): + return None, "止损价格式错误" + if new_sl <= 0: + return None, "止损价须大于0" + fib_u = fib_l = bp = None + try: + if data.get("fib_upper") not in (None, ""): + fib_u = float(data.get("fib_upper")) + if data.get("fib_lower") not in (None, ""): + fib_l = float(data.get("fib_lower")) + if data.get("breakthrough_price") not in (None, ""): + bp = float(data.get("breakthrough_price")) + except (TypeError, ValueError): + return None, "价格参数格式错误" + + add_price = ctx.get("mark") + if add_mode == MARKET_MODE: + if add_price is None or add_price <= 0: + return None, "无法获取市价快照" + elif add_mode in FIB_MODES: + if fib_u is None or fib_l is None: + return None, "斐波须填写上沿 H 与下沿 L" + elif add_mode == BREAKOUT_MODE: + if bp is None: + return None, "突破加仓须填写突破价" + add_price = ctx.get("mark") + else: + return None, "加仓方式无效" + + return { + "add_mode": add_mode, + "new_stop_loss": new_sl, + "fib_upper": fib_u, + "fib_lower": fib_l, + "breakthrough_price": bp, + "add_price": add_price, + }, None + + +def _roll_preview_response(cfg: dict, data: dict, json_mode: bool = False) -> dict: + ctx, err = _roll_context(cfg, data) + if err: + return {"ok": False, "msg": err} + parsed, perr = _parse_roll_form(data, ctx) + if perr: + ctx["conn"].close() + return {"ok": False, "msg": perr} + conn = ctx["conn"] + try: + preview, perr2 = preview_roll( + direction=ctx["direction"], + symbol=ctx["symbol"], + qty_existing=ctx["qty"], + entry_existing=ctx["entry"], + initial_take_profit=ctx["tp0"], + add_mode=parsed["add_mode"], + new_stop_loss=parsed["new_stop_loss"], + risk_percent=ctx["risk_pct"], + capital_base_usdt=ctx["capital"], + add_price=parsed["add_price"], + fib_upper=parsed["fib_upper"], + fib_lower=parsed["fib_lower"], + breakthrough_price=parsed["breakthrough_price"], + legs_done=ctx["legs_done"], + contract_size=ctx["contract_size"], + ) + finally: + conn.close() + if perr2: + return {"ok": False, "msg": perr2} + amt_raw = float(preview["add_amount_raw"]) + amt_p = cfg["amount_to_precision"](ctx["ex_sym"], amt_raw) + preview["add_amount_display"] = amt_p if amt_p is not None else amt_raw + preview["risk_display"] = f"{ctx['risk_pct']:g}%≈{ctx['capital'] * ctx['risk_pct'] / 100:.2f}U" + price_fmt = cfg.get("price_fmt") + if callable(price_fmt): + preview["add_price_display"] = price_fmt(ctx["symbol"], preview["add_price"]) + preview["new_sl_display"] = price_fmt(ctx["symbol"], preview["new_stop_loss"]) + preview["tp_display"] = price_fmt(ctx["symbol"], preview["initial_take_profit"]) + return {"ok": True, "preview": preview} + + +def _roll_execute(cfg: dict, data: dict) -> tuple[bool, str]: + get_db = cfg["get_db"] + conn = None + try: + ok_live, reason = cfg["ensure_live_ready"]() + if not ok_live: + return False, reason or "实盘未就绪" + prev = _roll_preview_response(cfg, data) + if not prev.get("ok"): + return False, prev.get("msg") or "预览失败" + preview = prev["preview"] + symbol = cfg["normalize_symbol_input"](data.get("symbol") or "") + direction = preview["direction"] + ex_sym = cfg["normalize_exchange_symbol"](symbol) + add_mode = preview["add_mode"] + new_sl = float(preview["new_stop_loss"]) + tp0 = float(preview["initial_take_profit"]) + lev_fn = cfg.get("default_leverage") + if not callable(lev_fn): + lev_fn = lambda _s: 5 + leverage = int(data.get("leverage") or 0) or int(lev_fn(symbol)) + conn = get_db() + init_strategy_tables(conn) + mon = _get_active_monitor(conn, cfg, symbol, direction) + if not mon: + return False, "监控单已不存在" + rg, legs_done, pending, roll_is_new = _get_or_create_roll_group_meta(conn, mon) + if pending > 0: + return False, "已有监控中的滚仓腿,请先删除或等待结束" + if add_mode == MARKET_MODE: + amount = cfg["amount_to_precision"](ex_sym, float(preview["add_amount_raw"])) + if amount is None or amount <= 0: + return False, "加仓张数低于交易所最小精度" + order = cfg["market_add"](ex_sym, direction, amount, leverage) + fill = float( + cfg.get("resolve_fill_price", lambda o, s, p: p)( + order, ex_sym, preview["add_price"] + ) + or preview["add_price"] + ) + oid = str(order.get("id") or "") if isinstance(order, dict) else "" + cfg["replace_tpsl"](ex_sym, direction, new_sl, tp0, mon) + conn.execute( + """INSERT INTO roll_legs ( + roll_group_id, leg_index, add_mode, fib_upper, fib_lower, limit_price, + breakthrough_price, fill_price, amount, new_stop_loss, exchange_order_id, + status, created_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + rg["id"], + legs_done + 1, + preview["add_mode_label"], + preview.get("fib_upper"), + preview.get("fib_lower"), + None, + preview.get("breakthrough_price"), + fill, + amount, + new_sl, + oid, + "filled", + cfg["app_now_str"](), + ), + ) + conn.execute( + "UPDATE roll_groups SET leg_count=?, current_stop_loss=?, updated_at=? WHERE id=?", + (legs_done + 1, new_sl, cfg["app_now_str"](), rg["id"]), + ) + live_qty = float(mon.get("order_amount") or 0) + float(amount) + try: + from lib.hub.hub_position_metrics import contracts_qty_is_open, normalize_contracts_qty + + pos2 = cfg["get_position"](ex_sym, direction) or {} + q2 = normalize_contracts_qty(pos2.get("contracts") or 0) + if contracts_qty_is_open(q2): + live_qty = q2 + else: + live_qty = normalize_contracts_qty(live_qty) + except Exception: + from lib.hub.hub_position_metrics import normalize_contracts_qty + + live_qty = normalize_contracts_qty(live_qty) + conn.execute( + """UPDATE order_monitors SET stop_loss=?, order_amount=?, + breakeven_armed=0, breakeven_price=NULL WHERE id=?""", + (new_sl, live_qty, mon["id"]), + ) + conn.commit() + _maybe_notify_roll_started(cfg, rg, mon, symbol, direction, tp0, new_sl, roll_is_new=roll_is_new) + return True, f"市价加仓第 {legs_done + 1} 腿已成交,止损已更新,止盈仍为首仓" + # 程序监控:斐波 / 突破 + limit_px = None + if add_mode in FIB_MODES: + px_fn = cfg.get("price_to_precision") + limit_px = float(preview["add_price"]) + if callable(px_fn): + limit_px = float(px_fn(ex_sym, limit_px) or limit_px) + mark_fn = cfg.get("get_mark_price") or cfg.get("get_price") + last_mark = mark_fn(symbol) if callable(mark_fn) else preview["add_price"] + conn.execute( + """INSERT INTO roll_legs ( + roll_group_id, leg_index, add_mode, fib_upper, fib_lower, limit_price, + breakthrough_price, new_stop_loss, last_mark_price, status, created_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?)""", + ( + rg["id"], + legs_done + 1, + preview["add_mode_label"], + preview.get("fib_upper"), + preview.get("fib_lower"), + limit_px, + preview.get("breakthrough_price"), + new_sl, + last_mark, + "pending", + cfg["app_now_str"](), + ), + ) + conn.commit() + _maybe_notify_roll_started(cfg, rg, mon, symbol, direction, tp0, new_sl, roll_is_new=roll_is_new) + return True, f"已提交{preview['add_mode_label']}监控,触价后将市价加仓并更新止损" + except Exception as e: + fe = cfg.get("friendly_error") + return False, fe(e) if callable(fe) else str(e) + finally: + if conn is not None: + try: + conn.close() + except Exception: + pass + + +def _maybe_notify_roll_started(cfg, rg, mon, symbol, direction, tp0, new_sl, *, roll_is_new: bool) -> None: + if not roll_is_new: + return + try: + from lib.strategy.strategy_wechat_notify import notify_roll_group_started + + notify_roll_group_started( + cfg, + group_id=int(rg["id"]), + symbol=symbol, + direction=direction, + order_monitor_id=int(mon["id"]), + initial_take_profit=tp0, + initial_stop_loss=float(mon.get("stop_loss") or new_sl), + ) + except Exception: + pass + + +def _get_active_monitor(conn, cfg: dict, symbol: str, direction: str) -> Optional[dict]: + row = conn.execute( + "SELECT * FROM order_monitors WHERE status='active' AND symbol=? AND direction=? ORDER BY id DESC LIMIT 1", + (symbol, direction), + ).fetchone() + return _row_to_dict(row) if row else None + + +def _get_or_create_roll_group_meta(conn, mon: dict) -> tuple[dict, int, int, bool]: + """返回 (roll_group, filled_legs, pending_legs, is_new_group).""" + row = conn.execute( + "SELECT * FROM roll_groups WHERE order_monitor_id=? AND status='active' ORDER BY id DESC LIMIT 1", + (mon["id"],), + ).fetchone() + if row: + d = _row_to_dict(row) + gid = int(d["id"]) + filled = count_filled_roll_legs(conn, gid) + pending = count_pending_roll_legs(conn, gid) + return d, filled, pending, False + now = mon.get("created_at") or "" + cur = conn.execute( + """INSERT INTO roll_groups ( + order_monitor_id, symbol, exchange_symbol, direction, + initial_take_profit, initial_stop_loss, current_stop_loss, + risk_percent, leg_count, status, created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + mon["id"], + mon["symbol"], + mon.get("exchange_symbol"), + mon["direction"], + mon.get("take_profit"), + mon.get("stop_loss"), + mon.get("stop_loss"), + mon.get("risk_percent") or 2, + 0, + "active", + now, + now, + ), + ) + gid = int(cur.lastrowid) + return ( + { + "id": gid, + "leg_count": 0, + "initial_take_profit": mon.get("take_profit"), + "initial_stop_loss": mon.get("stop_loss"), + "symbol": mon.get("symbol"), + "direction": mon.get("direction"), + }, + 0, + 0, + True, + ) + + +def roll_sync_after_external_close(cfg: dict, conn, symbol: str, direction: str) -> dict: + """供 hub / del_order 调用的滚仓同步入口.""" + return sync_roll_after_external_close( + cfg, conn, symbol, direction, reason="手动平仓,滚仓监控已结束" + ) + diff --git a/lib/strategy/strategy_roll_lib.py b/lib/strategy/strategy_roll_lib.py new file mode 100644 index 0000000..1979e64 --- /dev/null +++ b/lib/strategy/strategy_roll_lib.py @@ -0,0 +1,392 @@ +"""顺势加仓(滚仓):纯计算.人工触发;止盈锁定首仓;程序监控触价市价成交.""" +from __future__ import annotations + +from typing import Any, Optional, Tuple + +from lib.key_monitor.fib_key_monitor_lib import calc_fib_plan, fib_invalidate_by_mark + +ROLL_MAX_LEGS_LONG = 3 +ROLL_MAX_LEGS_SHORT = 3 + +MARKET_MODE = "market" +FIB_MODES = frozenset({"fib_618", "fib_786"}) +BREAKOUT_MODE = "breakout" + +MODE_LABELS = { + MARKET_MODE: "市价加仓", + "fib_618": "斐波0.618", + "fib_786": "斐波0.786", + BREAKOUT_MODE: "突破加仓", +} + + +def fib_ratio_from_mode(mode: str) -> Optional[float]: + m = (mode or "").strip().lower() + if m in ("fib_618", "618", "0.618"): + return 0.618 + if m in ("fib_786", "786", "0.786"): + return 0.786 + return None + + +def mode_label(mode: str) -> str: + m = (mode or MARKET_MODE).strip().lower() + return MODE_LABELS.get(m, m) + + +def fib_limit_entry(direction: str, upper: float, lower: float, mode: str) -> Tuple[Optional[float], Optional[str]]: + """H/L 仅用于计算限价加仓价;多:下沿=止损侧;空:上沿=止损侧.""" + ratio = fib_ratio_from_mode(mode) + if ratio is None: + return None, "斐波档位无效" + h, l = float(upper), float(lower) + if h <= l: + return None, "上沿须大于下沿" + direction = (direction or "long").strip().lower() + if direction == "short": + plan = calc_fib_plan("short", h, l, ratio) + else: + plan = calc_fib_plan("long", h, l, ratio) + if not plan: + return None, "无法计算斐波限价" + entry, _sl, _tp = plan + return float(entry), None + + +def max_roll_legs(direction: str) -> int: + return ROLL_MAX_LEGS_LONG if (direction or "long").strip().lower() == "long" else ROLL_MAX_LEGS_SHORT + + +def avg_entry_after_add( + qty_existing: float, + entry_existing: float, + add_qty: float, + add_price: float, +) -> float: + q1 = float(qty_existing) + e1 = float(entry_existing) + q2 = float(add_qty) + e2 = float(add_price) + total = q1 + q2 + if total <= 0: + return 0.0 + return (q1 * e1 + q2 * e2) / total + + +def calc_risk_budget_usdt(capital_base_usdt: float, risk_percent: float) -> float: + return float(capital_base_usdt) * (float(risk_percent) / 100.0) + + +def solve_add_amount_for_total_risk( + direction: str, + qty_existing: float, + entry_existing: float, + add_price: float, + new_stop: float, + risk_budget_usdt: float, + contract_size: float = 1.0, +) -> Tuple[Optional[float], Optional[str]]: + """ + 合并持仓打到 new_stop 时总亏损 ≈ risk_budget(方案 C). + long: (avg - SL) * (Q1+Q2) * cs = B => Q2 = (B/cs - Q1*(E1-SL)) / (E2-SL) + short: (SL - avg) * (Q1+Q2) * cs = B => Q2 = (B/cs - Q1*(SL-E1)) / (SL-E2) + """ + try: + q1 = float(qty_existing) + e1 = float(entry_existing) + e2 = float(add_price) + sl = float(new_stop) + b = float(risk_budget_usdt) + cs = float(contract_size) if contract_size else 1.0 + except (TypeError, ValueError): + return None, "参数格式错误" + if q1 <= 0 or e1 <= 0 or e2 <= 0 or b <= 0 or cs <= 0: + return None, "持仓或风险预算无效" + direction = (direction or "long").strip().lower() + if direction == "short": + denom = sl - e2 + numer = b / cs - q1 * (sl - e1) + if denom <= 0: + return None, "做空:新止损须高于加仓价" + else: + denom = e2 - sl + numer = b / cs - q1 * (e1 - sl) + if denom <= 0: + return None, "做多:新止损须低于加仓价" + q2 = numer / denom + if q2 <= 0: + return None, "按当前新止损与风险预算,无需加仓或无法再加(已满足风险上限)" + return q2, None + + +def loss_at_stop_usdt( + direction: str, + avg: float, + qty: float, + stop: float, + contract_size: float = 1.0, +) -> float: + cs = float(contract_size or 1.0) + direction = (direction or "long").strip().lower() + if direction == "short": + return (float(stop) - float(avg)) * float(qty) * cs + return (float(avg) - float(stop)) * float(qty) * cs + + +def reward_at_tp_usdt( + direction: str, + avg: float, + take_profit: float, + qty: float, + contract_size: float = 1.0, +) -> float: + cs = float(contract_size or 1.0) + direction = (direction or "long").strip().lower() + if direction == "short": + gross = (float(avg) - float(take_profit)) * float(qty) * cs + else: + gross = (float(take_profit) - float(avg)) * float(qty) * cs + try: + from lib.trade.trade_fee_lib import net_pnl_after_fee + + net = net_pnl_after_fee(gross, avg, take_profit, qty, cs) + return float(net) if net is not None else gross + except Exception: + return gross + + +def roll_fib_trigger_crossed( + direction: str, + prev_mark: Optional[float], + mark: float, + limit_price: float, +) -> bool: + """斐波:多=向下穿越限价;空=向上穿越限价.""" + try: + m = float(mark) + lv = float(limit_price) + pm = float(prev_mark) if prev_mark is not None else None + except (TypeError, ValueError): + return False + direction = (direction or "long").strip().lower() + if direction == "long": + if pm is None: + return m <= lv + return pm > lv and m <= lv + if pm is None: + return m >= lv + return pm < lv and m >= lv + + +def roll_breakout_trigger_crossed( + direction: str, + prev_mark: Optional[float], + mark: float, + breakthrough_price: float, +) -> bool: + """突破:多=mark 在突破价之上;空=mark 在突破价之下. + + 提交时已校验 mark 在逆势侧(多低于突破价,空高于突破价),触价侧到达即成交. + 不再要求单 tick 内穿越,避免 mark 已破位但 last_mark 也落在突破价另一侧时永久漏触发. + """ + try: + m = float(mark) + bp = float(breakthrough_price) + except (TypeError, ValueError): + return False + direction = (direction or "long").strip().lower() + if direction == "long": + return m > bp + return m < bp + + +def roll_fib_invalidate(direction: str, mark: float, upper: float, lower: float) -> bool: + """斐波 pending 失效:止盈侧突破(多 mark>=H;空 mark<=L).""" + return fib_invalidate_by_mark(direction, mark, upper, lower) + + +def roll_breakout_invalidate(direction: str, mark: float, stop_loss: float) -> bool: + """突破 pending 失效:未到突破价先触达止损侧(多 mark<=S;空 mark>=S).""" + try: + m = float(mark) + sl = float(stop_loss) + except (TypeError, ValueError): + return False + direction = (direction or "long").strip().lower() + if direction == "long": + return m <= sl + return m >= sl + + +def validate_roll_geometry( + direction: str, + add_mode: str, + *, + new_stop_loss: float, + add_price: Optional[float] = None, + fib_upper: Optional[float] = None, + fib_lower: Optional[float] = None, + breakthrough_price: Optional[float] = None, + entry_existing: float = 0.0, + initial_take_profit: float = 0.0, + mark_price: Optional[float] = None, +) -> Optional[str]: + direction = (direction or "long").strip().lower() + mode = (add_mode or MARKET_MODE).strip().lower() + try: + sl = float(new_stop_loss) + tp = float(initial_take_profit) + e1 = float(entry_existing or 0) + except (TypeError, ValueError): + return "止损/止盈格式错误" + if sl <= 0 or tp <= 0: + return "止损与首仓止盈须大于0" + if direction == "long": + if e1 > 0 and tp <= e1: + return "做多:首仓止盈须高于当前持仓均价" + else: + if e1 > 0 and tp >= e1: + return "做空:首仓止盈须低于当前持仓均价" + + if mode == MARKET_MODE: + if add_price is None or float(add_price) <= 0: + return "市价加仓需要有效参考价" + entry_add = float(add_price) + elif mode in FIB_MODES: + if fib_upper is None or fib_lower is None: + return "斐波须填写上沿 H 与下沿 L" + entry_add, err = fib_limit_entry(direction, float(fib_upper), float(fib_lower), mode) + if err: + return err + if entry_add is None or entry_add <= 0: + return "无法计算斐波限价" + elif mode == BREAKOUT_MODE: + if breakthrough_price is None: + return "突破加仓须填写突破价" + try: + bp = float(breakthrough_price) + except (TypeError, ValueError): + return "突破价格式错误" + if bp <= 0: + return "突破价须大于0" + entry_add = bp + if direction == "long": + if sl >= bp: + return "做多:止损须低于突破价" + if mark_price is not None and float(mark_price) >= bp: + return "做多:当前价须低于突破价(等待向上突破)" + else: + if sl <= bp: + return "做空:止损须高于突破价" + if mark_price is not None and float(mark_price) <= bp: + return "做空:当前价须高于突破价(等待向下跌破)" + else: + return "加仓方式无效" + + if mode != BREAKOUT_MODE: + entry_add = float(entry_add) # type: ignore[arg-type] + if direction == "long": + if sl >= entry_add: + return "做多:新止损须低于加仓价" + else: + if sl <= entry_add: + return "做空:新止损须高于加仓价" + return None + + +def preview_roll( + *, + direction: str, + symbol: str, + qty_existing: float, + entry_existing: float, + initial_take_profit: float, + add_mode: str, + new_stop_loss: Optional[float] = None, + risk_percent: float, + capital_base_usdt: float, + add_price: Optional[float] = None, + fib_upper: Optional[float] = None, + fib_lower: Optional[float] = None, + breakthrough_price: Optional[float] = None, + legs_done: int = 0, + contract_size: float = 1.0, +) -> Tuple[Optional[dict[str, Any]], Optional[str]]: + direction = (direction or "long").strip().lower() + if legs_done >= max_roll_legs(direction): + return None, f"{'做多' if direction == 'long' else '做空'}滚仓已达 {max_roll_legs(direction)} 次上限" + mode = (add_mode or MARKET_MODE).strip().lower() + if new_stop_loss is None: + return None, "请填写新止损价" + try: + sl = float(new_stop_loss) + except (TypeError, ValueError): + return None, "止损价格式错误" + if sl <= 0: + return None, "止损须大于0" + + geom_err = validate_roll_geometry( + direction, + mode, + new_stop_loss=sl, + add_price=add_price, + fib_upper=fib_upper, + fib_lower=fib_lower, + breakthrough_price=breakthrough_price, + entry_existing=entry_existing, + initial_take_profit=initial_take_profit, + mark_price=add_price if mode == BREAKOUT_MODE else add_price, + ) + if geom_err: + return None, geom_err + + if mode == MARKET_MODE: + entry_add = float(add_price) # validated + elif mode in FIB_MODES: + entry_add, _ = fib_limit_entry(direction, float(fib_upper), float(fib_lower), mode) + entry_add = float(entry_add or 0) + else: + entry_add = float(breakthrough_price or 0) + + risk_budget = calc_risk_budget_usdt(capital_base_usdt, risk_percent) + q2_raw, err = solve_add_amount_for_total_risk( + direction, + qty_existing, + entry_existing, + entry_add, + sl, + risk_budget, + contract_size, + ) + if err: + return None, err + q2 = float(q2_raw) + new_qty = qty_existing + q2 + new_avg = avg_entry_after_add(qty_existing, entry_existing, q2, entry_add) + cs = float(contract_size or 1.0) + loss_sl = loss_at_stop_usdt(direction, new_avg, new_qty, sl, cs) + reward_tp = reward_at_tp_usdt(direction, new_avg, initial_take_profit, new_qty, cs) + return { + "symbol": symbol, + "direction": direction, + "add_mode": mode, + "add_mode_label": mode_label(mode), + "add_price": round(entry_add, 10), + "new_stop_loss": round(sl, 10), + "breakthrough_price": float(breakthrough_price) if breakthrough_price not in (None, "") else None, + "initial_take_profit": float(initial_take_profit), + "risk_percent": float(risk_percent), + "risk_budget_usdt": round(risk_budget, 4), + "add_amount_raw": q2, + "qty_existing": float(qty_existing), + "entry_existing": float(entry_existing), + "qty_after": new_qty, + "avg_entry_after": round(new_avg, 10), + "loss_at_sl_usdt": round(loss_sl, 4), + "reward_at_tp_usdt": round(reward_tp, 4), + "legs_done": int(legs_done), + "leg_index_next": int(legs_done) + 1, + "fib_upper": fib_upper, + "fib_lower": fib_lower, + "contract_size": cs, + }, None diff --git a/lib/strategy/strategy_roll_monitor_lib.py b/lib/strategy/strategy_roll_monitor_lib.py new file mode 100644 index 0000000..6665ebc --- /dev/null +++ b/lib/strategy/strategy_roll_monitor_lib.py @@ -0,0 +1,562 @@ +"""滚仓程序监控:斐波/突破触价市价成交,失效,外部平仓同步(各所共用).""" +from __future__ import annotations + +from typing import Any, Optional + +from lib.strategy.strategy_roll_lib import ( + BREAKOUT_MODE, + FIB_MODES, + MARKET_MODE, + mode_label, + roll_breakout_invalidate, + roll_breakout_trigger_crossed, + roll_fib_invalidate, + roll_fib_trigger_crossed, + calc_risk_budget_usdt, + max_roll_legs, + preview_roll, + solve_add_amount_for_total_risk, +) +from lib.strategy.strategy_db import init_strategy_tables + +ROLL_LEG_STATUS_LABELS = { + "pending": "监控中", + "filled": "已成交", + "cancelled": "已删除", + "invalidated": "已失效", +} + + +def roll_leg_status_label(status: Optional[str]) -> str: + s = (status or "").strip().lower() + return ROLL_LEG_STATUS_LABELS.get(s, status or "—") + + +def check_roll_monitors(cfg: dict[str, Any]) -> None: + get_db = cfg["get_db"] + conn = get_db() + try: + init_strategy_tables(conn) + _reconcile_roll_groups(conn, cfg) + _check_pending_roll_legs(conn, cfg) + conn.commit() + except Exception as e: + print(f"[roll_monitor] {e}", flush=True) + try: + conn.rollback() + except Exception: + pass + finally: + try: + conn.close() + except Exception: + pass + + +def sync_roll_after_external_close( + cfg: dict, conn, symbol: str, direction: str, *, reason: str = "持仓已平" +) -> dict[str, Any]: + """中控/实例手动平仓后:取消 pending 腿并关闭 active 滚仓组(保留 filled 历史).""" + norm = cfg.get("normalize_symbol_input") + sym = norm(symbol) if callable(norm) else (symbol or "").strip() + if not sym: + return {"ok": False, "msg": "symbol 无效", "closed_groups": 0, "cancelled_legs": 0} + direction = (direction or "long").strip().lower() + init_strategy_tables(conn) + rows = conn.execute( + """SELECT g.* FROM roll_groups g + WHERE g.status='active' AND g.symbol=? AND g.direction=?""", + (sym, direction), + ).fetchall() + closed = cancelled = 0 + for row in rows: + g = _row_dict(row) + cancelled += _cancel_pending_legs_for_group(conn, cfg, g, status="cancelled") + cur = conn.execute( + "UPDATE roll_groups SET status='closed', updated_at=? WHERE id=? AND status='active'", + (_now(cfg), int(g["id"])), + ) + if getattr(cur, "rowcount", 0): + closed += 1 + try: + from lib.strategy.strategy_wechat_notify import notify_roll_group_ended + + notify_roll_group_ended( + cfg, + group_id=int(g["id"]), + symbol=sym, + direction=direction, + reason=reason, + leg_count=int(g.get("leg_count") or 0), + ) + except Exception: + pass + try: + from lib.strategy.strategy_snapshot_lib import save_roll_group_snapshot + + save_roll_group_snapshot(cfg, conn, g, result_label="结束") + except Exception: + pass + return { + "ok": True, + "symbol": sym, + "direction": direction, + "closed_groups": closed, + "cancelled_legs": cancelled, + } + + +def cancel_roll_pending_leg(cfg: dict, conn, leg_id: int) -> tuple[bool, str]: + """用户删除 pending 滚仓腿(不可修改,仅删除).""" + init_strategy_tables(conn) + row = conn.execute( + "SELECT l.*, g.symbol, g.direction, g.status AS group_status FROM roll_legs l " + "INNER JOIN roll_groups g ON g.id = l.roll_group_id WHERE l.id=?", + (int(leg_id),), + ).fetchone() + if not row: + return False, "滚仓腿不存在" + leg = _row_dict(row) + if (leg.get("status") or "").strip().lower() != "pending": + return False, "仅监控中的腿可删除" + _cancel_roll_leg_order(cfg, {"symbol": leg.get("symbol"), "exchange_symbol": leg.get("exchange_symbol")}, leg) + conn.execute( + "UPDATE roll_legs SET status='cancelled' WHERE id=? AND status='pending'", + (int(leg_id),), + ) + conn.commit() + return True, "已删除滚仓监控" + + +def count_filled_roll_legs(conn, roll_group_id: int) -> int: + row = conn.execute( + "SELECT COUNT(*) FROM roll_legs WHERE roll_group_id=? AND status='filled'", + (int(roll_group_id),), + ).fetchone() + return int(row[0] if row else 0) + + +def count_pending_roll_legs(conn, roll_group_id: int) -> int: + row = conn.execute( + "SELECT COUNT(*) FROM roll_legs WHERE roll_group_id=? AND status='pending'", + (int(roll_group_id),), + ).fetchone() + return int(row[0] if row else 0) + + +def _row_dict(row) -> dict: + if row is None: + return {} + try: + return dict(row) + except Exception: + return {} + + +def _now(cfg: dict) -> str: + fn = cfg.get("app_now_str") + return fn() if callable(fn) else "" + + +def _cancel_pending_legs_for_group(conn, cfg: dict, group: dict, *, status: str = "cancelled") -> int: + gid = int(group["id"]) + n = 0 + for leg in conn.execute( + "SELECT * FROM roll_legs WHERE roll_group_id=? AND status='pending'", + (gid,), + ).fetchall(): + ld = _row_dict(leg) + _cancel_roll_leg_order(cfg, group, ld) + conn.execute( + "UPDATE roll_legs SET status=? WHERE id=? AND status='pending'", + (status, ld["id"]), + ) + n += 1 + return n + + +def _close_roll_group(conn, cfg: dict, group: dict, *, reason: str = "下单监控已结案或交易所无同向持仓") -> None: + gid = int(group["id"]) + _cancel_pending_legs_for_group(conn, cfg, group, status="cancelled") + cur = conn.execute( + "UPDATE roll_groups SET status='closed', updated_at=? WHERE id=? AND status='active'", + (_now(cfg), gid), + ) + if getattr(cur, "rowcount", 0): + try: + from lib.strategy.strategy_wechat_notify import notify_roll_group_ended + + notify_roll_group_ended( + cfg, + group_id=gid, + symbol=group.get("symbol") or "", + direction=group.get("direction") or "long", + reason=reason, + leg_count=int(group.get("leg_count") or 0), + ) + except Exception: + pass + try: + from lib.strategy.strategy_snapshot_lib import save_roll_group_snapshot + + save_roll_group_snapshot(cfg, conn, group, result_label="结束") + except Exception: + pass + + +def _reconcile_roll_groups(conn, cfg: dict) -> None: + from lib.hub.hub_position_metrics import contracts_qty_is_open, normalize_contracts_qty + + rows = conn.execute( + """SELECT g.*, m.status AS monitor_status + FROM roll_groups g + LEFT JOIN order_monitors m ON m.id = g.order_monitor_id + WHERE g.status='active'""" + ).fetchall() + for row in rows: + g = _row_dict(row) + symbol = g.get("symbol") or "" + direction = (g.get("direction") or "long").strip().lower() + ex_sym = g.get("exchange_symbol") or cfg["normalize_exchange_symbol"](symbol) + mon_ok = (row["monitor_status"] or "").strip().lower() == "active" + if not mon_ok: + _close_roll_group(conn, cfg, g, reason="下单监控已结案") + continue + pos = None + try: + pos = cfg["get_position"](ex_sym, direction) + except Exception: + pos = None + if pos is None: + continue + qty = normalize_contracts_qty(pos.get("contracts") or 0) + if not contracts_qty_is_open(qty): + try: + pos2 = cfg["get_position"](ex_sym, direction) or {} + qty = normalize_contracts_qty(pos2.get("contracts") or 0) + except Exception: + continue + if not contracts_qty_is_open(qty): + _close_roll_group(conn, cfg, g) + + +def _cancel_roll_leg_order(cfg: dict, group: dict, leg: dict) -> None: + oid = (leg.get("exchange_order_id") or "").strip() + if not oid: + return + symbol = group.get("symbol") or "" + ex_sym = group.get("exchange_symbol") or cfg["normalize_exchange_symbol"](symbol) + cancel = cfg.get("cancel_limit_order") + if callable(cancel): + try: + cancel(ex_sym, oid) + except Exception: + pass + + +def _contract_size(cfg: dict, ex_sym: str) -> float: + get_cs = cfg.get("get_contract_size") + if callable(get_cs): + try: + return float(get_cs(ex_sym) or 1.0) + except Exception: + pass + return 1.0 + + +def _resolve_add_mode(leg: dict) -> str: + raw = (leg.get("add_mode") or "").strip().lower() + if raw in (MARKET_MODE, "market", "市价", "市价加仓"): + return MARKET_MODE + if "786" in raw or raw == "fib_786": + return "fib_786" + if "618" in raw or raw == "fib_618": + return "fib_618" + if raw in (BREAKOUT_MODE, "突破", "突破加仓"): + return BREAKOUT_MODE + if raw.startswith("fib"): + return raw.replace(".", "_").replace("0.", "0") + return raw or MARKET_MODE + + +def _check_pending_roll_legs(conn, cfg: dict) -> None: + rows = conn.execute( + """SELECT l.*, g.symbol, g.exchange_symbol, g.direction, g.initial_take_profit, + g.order_monitor_id, g.risk_percent, g.leg_count + FROM roll_legs l + INNER JOIN roll_groups g ON g.id = l.roll_group_id AND g.status='active' + WHERE l.status='pending'""" + ).fetchall() + for row in rows: + leg = _row_dict(row) + group = { + "id": leg["roll_group_id"], + "symbol": leg["symbol"], + "exchange_symbol": leg["exchange_symbol"], + "direction": leg["direction"], + "initial_take_profit": leg["initial_take_profit"], + "order_monitor_id": leg["order_monitor_id"], + "risk_percent": leg.get("risk_percent"), + "leg_count": leg.get("leg_count"), + } + _process_pending_roll_leg(conn, cfg, group, leg) + + +def _process_pending_roll_leg(conn, cfg: dict, group: dict, leg: dict) -> None: + symbol = group.get("symbol") or "" + direction = (group.get("direction") or "long").strip().lower() + ex_sym = group.get("exchange_symbol") or cfg["normalize_exchange_symbol"](symbol) + mark_fn = cfg.get("get_mark_price") or cfg.get("get_price") + mark = mark_fn(symbol) if callable(mark_fn) else None + if mark is None: + return + mark_f = float(mark) + prev_mark = leg.get("last_mark_price") + try: + prev_f = float(prev_mark) if prev_mark not in (None, "") else None + except (TypeError, ValueError): + prev_f = None + + mode = _resolve_add_mode(leg) + sl = float(leg.get("new_stop_loss") or 0) + fib_u, fib_l = leg.get("fib_upper"), leg.get("fib_lower") + bp = leg.get("breakthrough_price") + + if mode in FIB_MODES and fib_u is not None and fib_l is not None: + if roll_fib_invalidate(direction, mark_f, float(fib_u), float(fib_l)): + _invalidate_roll_leg(conn, cfg, group, leg, mark_f, reason="止盈侧突破") + return + elif mode == BREAKOUT_MODE and sl > 0: + if roll_breakout_invalidate(direction, mark_f, sl): + _invalidate_roll_leg(conn, cfg, group, leg, mark_f, reason="止损侧突破") + return + + triggered = False + if mode in FIB_MODES: + lp = leg.get("limit_price") + if lp is not None and roll_fib_trigger_crossed(direction, prev_f, mark_f, float(lp)): + triggered = True + elif mode == BREAKOUT_MODE and bp is not None: + if roll_breakout_trigger_crossed(direction, prev_f, mark_f, float(bp)): + triggered = True + + conn.execute( + "UPDATE roll_legs SET last_mark_price=? WHERE id=? AND status='pending'", + (mark_f, int(leg["id"])), + ) + + if triggered: + _execute_pending_roll_leg(conn, cfg, group, leg, ex_sym, direction, mark_f) + return + + +def _execute_pending_roll_leg( + conn, + cfg: dict, + group: dict, + leg: dict, + ex_sym: str, + direction: str, + mark: float, +) -> None: + from lib.hub.hub_position_metrics import contracts_qty_is_open, normalize_contracts_qty + + leg_id = int(leg["id"]) + gid = int(group["roll_group_id"]) if "roll_group_id" in leg else int(group["id"]) + mon_id = group.get("order_monitor_id") + mon = None + if mon_id: + row = conn.execute("SELECT * FROM order_monitors WHERE id=?", (mon_id,)).fetchone() + mon = _row_dict(row) if row else None + if not mon or (mon.get("status") or "").strip().lower() != "active": + _invalidate_roll_leg(conn, cfg, group, leg, mark, reason="监控单已失效") + return + + pos = cfg["get_position"](ex_sym, direction) or {} + qty = normalize_contracts_qty(pos.get("contracts") or 0) + entry = float(pos.get("entry_price") or mon.get("trigger_price") or 0) + if not contracts_qty_is_open(qty) or entry <= 0: + _invalidate_roll_leg(conn, cfg, group, leg, mark, reason="无持仓") + return + + filled = count_filled_roll_legs(conn, gid) + if filled >= max_roll_legs(direction): + _invalidate_roll_leg(conn, cfg, group, leg, mark, reason="滚仓次数已满") + return + + try: + risk_pct = float(mon.get("risk_percent") or group.get("risk_percent") or 2) + except (TypeError, ValueError): + risk_pct = 2.0 + conn_cap = cfg["get_db"]() + try: + capital = float(cfg["get_trading_capital_usdt"](conn_cap)) + finally: + conn_cap.close() + + cs = _contract_size(cfg, ex_sym) + sl = float(leg.get("new_stop_loss") or 0) + tp0 = float(group.get("initial_take_profit") or mon.get("take_profit") or 0) + mode = _resolve_add_mode(leg) + + q2_raw, err = solve_add_amount_for_total_risk( + direction, qty, entry, mark, sl, calc_risk_budget_usdt(capital, risk_pct), cs + ) + if err or q2_raw is None or float(q2_raw) <= 0: + _invalidate_roll_leg(conn, cfg, group, leg, mark, reason=err or "无法计算加仓张数") + return + + amount = cfg["amount_to_precision"](ex_sym, float(q2_raw)) + if amount is None or float(amount) <= 0: + _invalidate_roll_leg(conn, cfg, group, leg, mark, reason="加仓张数低于交易所最小精度") + return + + lev_fn = cfg.get("default_leverage") + if not callable(lev_fn): + lev_fn = lambda _s: 5 + leverage = int(lev_fn(group.get("symbol") or "")) + + try: + order = cfg["market_add"](ex_sym, direction, float(amount), leverage) + fill = float( + cfg.get("resolve_fill_price", lambda o, s, p: p)(order, ex_sym, mark) or mark + ) + except Exception as e: + fe = cfg.get("friendly_error") + msg = fe(e) if callable(fe) else str(e) + _notify_roll_fail(cfg, group, leg, mark, msg) + return + + oid = str(order.get("id") or "") if isinstance(order, dict) else "" + try: + cfg["replace_tpsl"](ex_sym, direction, sl, tp0, mon) + except Exception as tpsl_err: + fe = cfg.get("friendly_error") + msg = fe(tpsl_err) if callable(fe) else str(tpsl_err) + conn.execute( + """UPDATE roll_legs SET status='error', exchange_order_id=?, fill_price=?, amount=? + WHERE id=? AND status='pending'""", + (oid, fill, float(amount), leg_id), + ) + _notify_roll_fail(cfg, group, leg, mark, f"加仓成交但止盈止损更新失败: {msg}") + return + + conn.execute( + """UPDATE roll_legs SET status='filled', fill_price=?, amount=?, exchange_order_id=?, + new_stop_loss=? WHERE id=? AND status='pending'""", + (fill, float(amount), oid, sl, leg_id), + ) + conn.execute( + "UPDATE roll_groups SET leg_count=?, current_stop_loss=?, updated_at=? WHERE id=?", + (filled + 1, sl, _now(cfg), gid), + ) + live_qty = normalize_contracts_qty(qty + float(amount)) + try: + pos2 = cfg["get_position"](ex_sym, direction) or {} + q2 = normalize_contracts_qty(pos2.get("contracts") or 0) + if contracts_qty_is_open(q2): + live_qty = q2 + except Exception: + pass + conn.execute( + """UPDATE order_monitors SET stop_loss=?, order_amount=?, + breakeven_armed=0, breakeven_price=NULL + WHERE id=? AND status='active'""", + (sl, live_qty, mon["id"]), + ) + + notify = cfg.get("send_wechat") + if callable(notify): + sym = group.get("symbol") or "" + mode_lbl = leg.get("add_mode") or mode_label(mode) + fmt = cfg.get("format_price") + px_txt = fmt(sym, fill) if callable(fmt) else str(fill) + sl_txt = fmt(sym, sl) if callable(fmt) else str(sl) + acct = _wechat_account(cfg) + dir_txt = _wechat_dir(cfg, direction) + notify( + f"# ✅ {sym} 滚仓触价成交\n" + f"**账户:{acct}**\n" + f"- 方式:{mode_lbl}|{dir_txt}\n" + f"- 成交价:{px_txt}|张数:{amount}\n" + f"- 新止损:{sl_txt}(止盈仍为首仓)\n" + ) + + +def _invalidate_roll_leg( + conn, + cfg: dict, + group: dict, + leg: dict, + mark: float, + *, + reason: str = "", +) -> None: + leg_id = int(leg["id"]) + cur = conn.execute("SELECT status FROM roll_legs WHERE id=?", (leg_id,)).fetchone() + if not cur or (cur[0] or "").strip().lower() in ("invalidated", "filled", "cancelled"): + return + _cancel_roll_leg_order(cfg, group, leg) + conn.execute( + "UPDATE roll_legs SET status='invalidated' WHERE id=? AND status='pending'", + (leg_id,), + ) + _send_roll_invalidate_wechat(cfg, group, leg, mark, reason=reason) + + +def _notify_roll_fail(cfg: dict, group: dict, leg: dict, mark: float, reason: str) -> None: + notify = cfg.get("send_wechat") + if not callable(notify): + return + sym = group.get("symbol") or "" + mode = leg.get("add_mode") or "滚仓" + acct = _wechat_account(cfg) + notify( + f"# ❌ {sym} 滚仓触价成交失败\n" + f"**账户:{acct}**\n" + f"- 方式:{mode}\n" + f"- 原因:{reason}\n" + ) + + +def _send_roll_invalidate_wechat( + cfg: dict, group: dict, leg: dict, mark: float, *, reason: str = "" +) -> None: + notify = cfg.get("send_wechat") + if not callable(notify): + return + sym = group.get("symbol") or "" + direction = (group.get("direction") or "long").strip().lower() + mode = leg.get("add_mode") or "滚仓监控" + fmt = cfg.get("format_price") + mark_txt = fmt(sym, mark) if callable(fmt) else str(mark) + acct = _wechat_account(cfg) + dir_txt = _wechat_dir(cfg, direction) + detail = reason or "条件不满足" + notify( + f"# ⚠️ {sym} 滚仓监控失效\n" + f"**账户:{acct}**\n" + f"- 方式:{mode}|{dir_txt}\n" + f"- 标记价 {mark_txt}|{detail}\n" + f"- 本条监控已结案,可重新提交\n" + ) + + +def _wechat_account(cfg: dict) -> str: + fn = cfg.get("wechat_account_label") + if callable(fn): + try: + return str(fn()) + except Exception: + pass + return str(cfg.get("exchange_display") or "") + + +def _wechat_dir(cfg: dict, direction: str) -> str: + fn = cfg.get("wechat_direction_text") + if callable(fn): + try: + return str(fn(direction)) + except Exception: + pass + return "做多" if (direction or "long").strip().lower() == "long" else "做空" diff --git a/lib/strategy/strategy_roll_ui_lib.py b/lib/strategy/strategy_roll_ui_lib.py new file mode 100644 index 0000000..cef900f --- /dev/null +++ b/lib/strategy/strategy_roll_ui_lib.py @@ -0,0 +1,434 @@ +"""顺势加仓 UI:滚仓腿合并均价与止盈盈利展示(实例页 + 中控).""" +from __future__ import annotations + +from typing import Any, Callable, Optional + +from flask import Flask + +FILLED_LEG_STATUSES = frozenset({"filled", "done", "complete"}) + + +def reward_at_tp_usdt( + direction: str, + avg_entry: float, + take_profit: float, + qty: float, + *, + contract_size: float = 1.0, +) -> Optional[float]: + """与 strategy_roll_lib.preview_roll 一致:线性合约 U 本位净盈利(扣双边 taker 费).""" + try: + avg = float(avg_entry) + tp = float(take_profit) + q = float(qty) + cs = float(contract_size or 1.0) + except (TypeError, ValueError): + return None + if avg <= 0 or tp <= 0 or q <= 0: + return None + direction = (direction or "long").strip().lower() + if direction == "short": + gross = (avg - tp) * q * cs + else: + gross = (tp - avg) * q * cs + try: + from lib.trade.trade_fee_lib import net_pnl_after_fee + + net = net_pnl_after_fee(gross, avg, tp, q, cs) + return net + except Exception: + return gross + + +def leg_fill_price(leg: dict) -> Optional[float]: + if not isinstance(leg, dict): + return None + for key in ("fill_price", "limit_price"): + try: + v = float(leg.get(key) or 0) + if v > 0: + return v + except (TypeError, ValueError): + continue + return None + + +def leg_is_filled(leg: dict) -> bool: + st = str(leg.get("status") or "").strip().lower() + return st in FILLED_LEG_STATUSES + + +def infer_initial_position( + qty_live: float, + entry_live: float, + filled_legs: list[dict], + *, + monitor: dict | None = None, +) -> tuple[Optional[float], Optional[float]]: + """由当前持仓与各腿成交价反推首仓张数/均价.""" + try: + qty_live = float(qty_live) + entry_live = float(entry_live) + except (TypeError, ValueError): + qty_live = entry_live = 0.0 + from lib.hub.hub_position_metrics import normalize_contracts_qty + + qty_live = normalize_contracts_qty(qty_live) + legs = [ + lg + for lg in filled_legs or [] + if isinstance(lg, dict) and leg_is_filled(lg) and leg_fill_price(lg) and float(lg.get("amount") or 0) > 0 + ] + add_sum = sum(normalize_contracts_qty(lg.get("amount") or 0) for lg in legs) + leg_notional = sum( + normalize_contracts_qty(lg.get("amount") or 0) * float(leg_fill_price(lg) or 0) for lg in legs + ) + q0 = qty_live - add_sum + if q0 > 1e-12 and entry_live > 0 and qty_live > 0: + e0 = (entry_live * qty_live - leg_notional) / q0 + if e0 > 0: + return q0, e0 + mon = monitor if isinstance(monitor, dict) else {} + try: + trig = float(mon.get("trigger_price") or 0) + except (TypeError, ValueError): + trig = 0.0 + try: + mon_amt = float(mon.get("order_amount") or mon.get("amount") or 0) + except (TypeError, ValueError): + mon_amt = 0.0 + if trig > 0: + q_base = q0 if q0 > 1e-12 else (mon_amt if mon_amt > 0 else max(qty_live - add_sum, 0)) + if q_base > 0: + return q_base, trig + return None, None + + +def compute_roll_chain_metrics( + group: dict, + legs: list[dict], + *, + qty_live: Optional[float] = None, + entry_live: Optional[float] = None, + monitor: dict | None = None, + contract_size: float = 1.0, +) -> tuple[dict[Any, dict], dict]: + """ + 返回 (leg_metrics_by_id, group_metrics). + leg_metrics: leg id -> {avg_entry_after, reward_at_tp_usdt} + group_metrics: 最后一腿后的 {avg_entry, reward_at_tp_usdt} + """ + per_leg: dict[Any, dict] = {} + group_out: dict[str, Any] = { + "avg_entry": None, + "reward_at_tp_usdt": None, + "initial_qty": None, + "current_qty": None, + } + if not isinstance(group, dict): + return per_leg, group_out + direction = (group.get("direction") or "long").strip().lower() + try: + tp = float(group.get("initial_take_profit") or 0) + except (TypeError, ValueError): + tp = 0.0 + sorted_legs = sorted( + [lg for lg in legs or [] if isinstance(lg, dict)], + key=lambda x: int(x.get("leg_index") or 0), + ) + filled = [lg for lg in sorted_legs if leg_is_filled(lg)] + q0 = e0 = None + if qty_live is not None and entry_live is not None: + q0, e0 = infer_initial_position(float(qty_live), float(entry_live), filled, monitor=monitor) + if q0 is None or e0 is None: + return per_leg, group_out + qty = float(q0) + avg = float(e0) + group_out["initial_qty"] = round(qty, 2) + group_out["current_qty"] = round(qty, 2) + if tp > 0: + group_out["avg_entry"] = avg + group_out["reward_at_tp_usdt"] = reward_at_tp_usdt( + direction, avg, tp, qty, contract_size=contract_size + ) + for leg in sorted_legs: + if not leg_is_filled(leg): + continue + try: + amt = float(leg.get("amount") or 0) + except (TypeError, ValueError): + continue + px = leg_fill_price(leg) + if not px or amt <= 0: + continue + prev_qty = qty + qty = prev_qty + amt + avg = (prev_qty * avg + amt * px) / qty + reward = reward_at_tp_usdt(direction, avg, tp, qty, contract_size=contract_size) if tp > 0 else None + lid = leg.get("id") + if lid is None: + lid = f"{group.get('id')}|{leg.get('leg_index')}" + per_leg[lid] = { + "avg_entry_after": round(avg, 10), + "reward_at_tp_usdt": round(reward, 4) if reward is not None else None, + } + group_out["avg_entry"] = round(avg, 10) + group_out["reward_at_tp_usdt"] = round(reward, 4) if reward is not None else None + group_out["current_qty"] = round(qty, 2) + if qty_live is not None: + try: + live_qty = float(qty_live) + if live_qty > 0: + group_out["current_qty"] = round(live_qty, 2) + except (TypeError, ValueError): + pass + return per_leg, group_out + + +def _row_to_dict(row) -> dict: + if row is None: + return {} + try: + return dict(row) + except Exception: + return {} + + +def _resolve_roll_live(cfg: dict, group: dict, monitor: dict | None) -> tuple[Optional[float], Optional[float], float]: + """读取交易所持仓张数,均价,contract_size.""" + m = cfg.get("app_module") + ex_sym = group.get("exchange_symbol") + sym = group.get("symbol") or "" + direction = (group.get("direction") or "long").strip().lower() + if not ex_sym and m is not None: + norm = getattr(m, "normalize_exchange_symbol", None) + if callable(norm): + try: + ex_sym = norm(sym) + except Exception: + ex_sym = sym + cs = 1.0 + get_cs = cfg.get("get_contract_size") + if not callable(get_cs) and m is not None: + get_cs = getattr(m, "get_contract_size", None) + if callable(get_cs): + try: + cs = float(get_cs(ex_sym or sym) or 1.0) + except Exception: + cs = 1.0 + get_pos = cfg.get("get_position") + if not callable(get_pos): + return None, None, cs + try: + pos = get_pos(ex_sym or sym, direction) or {} + from lib.hub.hub_position_metrics import normalize_contracts_qty + + qty = normalize_contracts_qty(pos.get("contracts") or 0) + entry = float(pos.get("entry_price") or 0) + if qty > 0 and entry > 0: + return qty, entry, cs + except Exception: + pass + metrics_fn = getattr(m, "get_live_position_exchange_metrics", None) if m else None + if callable(metrics_fn): + try: + met = metrics_fn(ex_sym or sym, direction) + if isinstance(met, dict): + qty = float(met.get("contracts") or met.get("size") or 0) + entry = float(met.get("entry_price") or 0) + if qty > 0 and entry > 0: + return qty, entry, cs + except Exception: + pass + if monitor: + try: + trig = float(monitor.get("trigger_price") or 0) + amt = float(monitor.get("order_amount") or monitor.get("amount") or 0) + if trig > 0 and amt > 0: + return amt, trig, cs + except (TypeError, ValueError): + pass + return None, None, cs + + +def enrich_roll_page_data(conn, page_data: dict, cfg: dict | None) -> dict: + """为 roll_groups / roll_legs 附加 avg_entry,reward_at_tp 展示字段.""" + if not isinstance(page_data, dict) or not cfg: + return page_data + groups = list(page_data.get("roll_groups") or []) + legs = list(page_data.get("roll_legs") or []) + if not groups: + return page_data + monitors_by_id: dict[int, dict] = {} + try: + for row in conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall(): + od = _row_to_dict(row) + mid = od.get("id") + if mid is not None: + monitors_by_id[int(mid)] = od + except Exception: + pass + legs_by_gid: dict[int, list] = {} + for leg in legs: + if not isinstance(leg, dict): + continue + try: + gid = int(leg.get("roll_group_id")) + except (TypeError, ValueError): + continue + legs_by_gid.setdefault(gid, []).append(leg) + price_fmt = cfg.get("price_fmt") + for g in groups: + if not isinstance(g, dict) or g.get("id") is None: + continue + gid = int(g["id"]) + mon = monitors_by_id.get(int(g.get("order_monitor_id") or 0)) + qty, entry, cs = _resolve_roll_live(cfg, g, mon) + per_leg, group_metrics = compute_roll_chain_metrics( + g, + legs_by_gid.get(gid, []), + qty_live=qty, + entry_live=entry, + monitor=mon, + contract_size=cs, + ) + g["avg_entry"] = group_metrics.get("avg_entry") + g["reward_at_tp_usdt"] = group_metrics.get("reward_at_tp_usdt") + g["initial_qty"] = group_metrics.get("initial_qty") + g["current_qty"] = group_metrics.get("current_qty") + if callable(price_fmt) and g.get("avg_entry") is not None: + try: + g["avg_entry_display"] = price_fmt(g.get("symbol"), g["avg_entry"]) + except Exception: + pass + for leg in legs_by_gid.get(gid, []): + lid = leg.get("id") + if lid is None: + lid = f"{gid}|{leg.get('leg_index')}" + metrics = per_leg.get(lid) or per_leg.get(leg.get("id")) + if not metrics: + continue + leg["avg_entry_after"] = metrics.get("avg_entry_after") + leg["reward_at_tp_usdt"] = metrics.get("reward_at_tp_usdt") + if callable(price_fmt) and leg.get("avg_entry_after") is not None: + try: + leg["avg_entry_display"] = price_fmt(g.get("symbol"), leg["avg_entry_after"]) + except Exception: + pass + page_data["roll_groups"] = groups + page_data["roll_legs"] = legs + return page_data + + +def enrich_roll_groups_for_hub(rolls: list[dict], conn, cfg: dict | None) -> list[dict]: + """中控 monitor API:每组附带当前均价,止盈盈利与最近滚仓腿.""" + if not rolls or not cfg: + return rolls + out = [] + gid_list = [] + for g in rolls: + if isinstance(g, dict) and g.get("id") is not None: + try: + gid_list.append(int(g["id"])) + except (TypeError, ValueError): + pass + legs_by_gid: dict[int, list] = {gid: [] for gid in gid_list} + if gid_list: + placeholders = ",".join("?" for _ in gid_list) + try: + rows = conn.execute( + f"SELECT * FROM roll_legs WHERE roll_group_id IN ({placeholders}) ORDER BY id DESC", + gid_list, + ).fetchall() + for row in rows: + leg = _row_to_dict(row) + try: + legs_by_gid[int(leg.get("roll_group_id"))].append(leg) + except (TypeError, ValueError): + pass + except Exception: + pass + monitors_by_id: dict[int, dict] = {} + try: + for row in conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall(): + od = _row_to_dict(row) + if od.get("id") is not None: + monitors_by_id[int(od["id"])] = od + except Exception: + pass + price_fmt = cfg.get("price_fmt") + for g in rolls: + if not isinstance(g, dict): + continue + gd = dict(g) + try: + gid = int(gd.get("id")) + except (TypeError, ValueError): + out.append(gd) + continue + mon = monitors_by_id.get(int(gd.get("order_monitor_id") or 0)) + group_legs = legs_by_gid.get(gid, []) + qty, entry, cs = _resolve_roll_live(cfg, gd, mon) + per_leg, group_metrics = compute_roll_chain_metrics( + gd, + group_legs, + qty_live=qty, + entry_live=entry, + monitor=mon, + contract_size=cs, + ) + gd.update(group_metrics) + if callable(price_fmt) and gd.get("avg_entry") is not None: + try: + gd["avg_entry_display"] = price_fmt(gd.get("symbol"), gd["avg_entry"]) + except Exception: + pass + recent = [] + for leg in sorted(group_legs, key=lambda x: int(x.get("leg_index") or 0), reverse=True)[:6]: + ld = dict(leg) + lid = ld.get("id") + if lid is None: + lid = f"{gid}|{ld.get('leg_index')}" + metrics = per_leg.get(lid) or per_leg.get(ld.get("id")) + if metrics: + ld.update(metrics) + if callable(price_fmt) and ld.get("avg_entry_after") is not None: + try: + ld["avg_entry_display"] = price_fmt(gd.get("symbol"), ld["avg_entry_after"]) + except Exception: + pass + recent.append(ld) + gd["recent_legs"] = recent + out.append(gd) + return out + + +def patch_roll_hub_enrich(app: Flask, cfg: dict) -> None: + """hub_bridge install 后:/api/hub/monitor 的 rolls 附带均价/止盈盈利.""" + ctx = dict(app.config.get("HUB_CTX") or {}) + prev: Callable | None = ctx.get("enrich_monitor") + + def enrich_monitor(keys=None, orders=None, trends=None, rolls=None): + payload: dict[str, Any] = {} + if callable(prev): + try: + prev_out = prev(keys=keys, orders=orders, trends=trends, rolls=rolls) + if isinstance(prev_out, dict): + payload.update(prev_out) + except Exception: + pass + if rolls: + get_db = cfg.get("get_db") + if callable(get_db): + conn = get_db() + try: + payload["rolls"] = enrich_roll_groups_for_hub(list(rolls), conn, cfg) + finally: + try: + conn.close() + except Exception: + pass + return payload + + ctx["enrich_monitor"] = enrich_monitor + app.config["HUB_CTX"] = ctx diff --git a/lib/strategy/strategy_snapshot_lib.py b/lib/strategy/strategy_snapshot_lib.py new file mode 100644 index 0000000..baf9392 --- /dev/null +++ b/lib/strategy/strategy_snapshot_lib.py @@ -0,0 +1,529 @@ +"""策略结束快照:趋势回调 / 顺势加仓(三所共用).""" +from __future__ import annotations + +import json +from datetime import datetime, timezone +from typing import Any, Callable, Optional + +STRATEGY_TREND = "trend_pullback" +STRATEGY_ROLL = "roll" +STRATEGY_SNAPSHOTS_MAX_ROWS = 100 +# 同一趋势计划只允许一条「结束类」快照(中控全平 + 监控止损 + 实例结束计划) +FINAL_TREND_CLOSE_RANK = { + "手动平仓": 3, + "止盈": 2, + "止损": 1, +} +FINAL_TREND_CLOSE_LABELS = tuple(FINAL_TREND_CLOSE_RANK.keys()) + +STRATEGY_SNAPSHOTS_SQL = """ +CREATE TABLE IF NOT EXISTS strategy_trade_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + strategy_type TEXT NOT NULL, + source_id INTEGER, + symbol TEXT, + exchange_symbol TEXT, + direction TEXT, + result_label TEXT, + status_at_close TEXT, + opened_at TEXT, + closed_at TEXT, + pnl_amount REAL, + snapshot_json TEXT NOT NULL, + created_at TEXT +) +""" + + +def init_strategy_snapshot_table(conn) -> None: + conn.execute(STRATEGY_SNAPSHOTS_SQL) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_strategy_snapshots_closed " + "ON strategy_trade_snapshots(closed_at DESC)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_strategy_snapshots_type " + "ON strategy_trade_snapshots(strategy_type, source_id)" + ) + + +def _row_dict(row) -> dict: + if row is None: + return {} + try: + return dict(row) + except Exception: + return {} + + +def _json_dumps(obj: Any) -> str: + return json.dumps(obj, ensure_ascii=False, separators=(",", ":")) + + +def build_trend_dca_levels(plan: dict) -> list[dict]: + """首仓 + 补仓档位列表(供策略页 / 中控).""" + out: list[dict] = [] + p = plan or {} + try: + legs_done = int(p.get("legs_done") or 0) + except (TypeError, ValueError): + legs_done = 0 + try: + dca_legs = int(p.get("dca_legs") or 0) + except (TypeError, ValueError): + dca_legs = 0 + first_done = int(p.get("first_order_done") or 0) != 0 + try: + grid = json.loads(p.get("grid_prices_json") or "[]") + if not isinstance(grid, list): + grid = [] + except Exception: + grid = [] + try: + leg_amounts = json.loads(p.get("leg_amounts_json") or "[]") + if not isinstance(leg_amounts, list): + leg_amounts = [] + except Exception: + leg_amounts = [] + + out.append( + { + "i": 0, + "leg_key": "first", + "label": "首仓", + "price": None, + "contracts": p.get("first_order_amount"), + "status": "done" if first_done else "pending", + "status_label": "已开仓" if first_done else "待开仓", + } + ) + n = max(len(grid), len(leg_amounts), dca_legs) + for idx in range(n): + leg_i = idx + 1 + price = grid[idx] if idx < len(grid) else None + contracts = leg_amounts[idx] if idx < len(leg_amounts) else None + done = leg_i <= legs_done + out.append( + { + "i": leg_i, + "leg_key": f"dca_{leg_i}", + "label": f"补仓{leg_i}", + "price": price, + "contracts": contracts, + "status": "done" if done else "pending", + "status_label": "已补仓" if done else "待补仓", + } + ) + return out + + +def attach_trend_dca_levels(plan: dict) -> dict: + from lib.strategy.strategy_trend_lib import enrich_trend_dca_levels_with_tp + + d = dict(plan or {}) + levels = build_trend_dca_levels(d) + d["dca_levels"] = enrich_trend_dca_levels_with_tp(d, levels) + return d + + +def _snapshot_key_exists( + conn, strategy_type: str, source_id: int, result_label: str +) -> bool: + if source_id <= 0: + return False + label = (result_label or "").strip() + row = conn.execute( + """SELECT 1 FROM strategy_trade_snapshots + WHERE strategy_type=? AND source_id=? AND result_label=? + LIMIT 1""", + (strategy_type, int(source_id), label), + ).fetchone() + return row is not None + + +def _final_trend_close_rank(result_label: str) -> int: + return int(FINAL_TREND_CLOSE_RANK.get((result_label or "").strip(), 0)) + + +def _purge_weaker_trend_final_snapshots( + conn, plan_id: int, result_label: str +) -> None: + """写入更高优先级结束快照时,删除同计划较弱的结束记录.""" + rank = _final_trend_close_rank(result_label) + if rank <= 0 or plan_id <= 0: + return + for label, lr in FINAL_TREND_CLOSE_RANK.items(): + if lr < rank: + conn.execute( + """DELETE FROM strategy_trade_snapshots + WHERE strategy_type=? AND source_id=? AND result_label=?""", + (STRATEGY_TREND, int(plan_id), label), + ) + + +def dedupe_strategy_snapshots(conn) -> int: + """删除重复快照:同结果去重 + 同计划仅保留最高优先级结束类记录.""" + init_strategy_snapshot_table(conn) + removed = 0 + cur = conn.execute( + """DELETE FROM strategy_trade_snapshots + WHERE id IN ( + SELECT s1.id FROM strategy_trade_snapshots s1 + INNER JOIN strategy_trade_snapshots s2 + ON s1.strategy_type = s2.strategy_type + AND s1.source_id = s2.source_id + AND s1.result_label = s2.result_label + AND s1.id < s2.id + )""" + ) + removed += int(getattr(cur, "rowcount", 0) or 0) + rows = conn.execute( + f"""SELECT id, source_id, result_label FROM strategy_trade_snapshots + WHERE strategy_type=? AND result_label IN ({",".join("?" * len(FINAL_TREND_CLOSE_LABELS))})""", + (STRATEGY_TREND, *FINAL_TREND_CLOSE_LABELS), + ).fetchall() + by_plan: dict[int, list] = {} + for row in rows: + d = _row_dict(row) + try: + pid = int(d.get("source_id") or 0) + except (TypeError, ValueError): + pid = 0 + if pid <= 0: + continue + by_plan.setdefault(pid, []).append(d) + drop_ids: list[int] = [] + for snaps in by_plan.values(): + if len(snaps) <= 1: + continue + best = max( + snaps, + key=lambda s: ( + _final_trend_close_rank(str(s.get("result_label") or "")), + int(s.get("id") or 0), + ), + ) + keep_id = int(best.get("id") or 0) + for s in snaps: + sid = int(s.get("id") or 0) + if sid and sid != keep_id: + drop_ids.append(sid) + if drop_ids: + placeholders = ",".join("?" * len(drop_ids)) + cur2 = conn.execute( + f"DELETE FROM strategy_trade_snapshots WHERE id IN ({placeholders})", + drop_ids, + ) + removed += int(getattr(cur2, "rowcount", 0) or 0) + return removed + + +def save_trend_plan_snapshot( + cfg: dict, + conn, + plan_row: Any, + *, + result_label: str, + exit_price: float | None = None, + pnl_amount: float | None = None, + closed_at: str | None = None, +) -> None: + init_strategy_snapshot_table(conn) + row = _row_dict(plan_row) + plan_id = int(row.get("id") or 0) + if plan_id <= 0: + return + label = (result_label or "").strip() + close_rank = _final_trend_close_rank(label) + if close_rank > 0: + existing = conn.execute( + f"""SELECT result_label FROM strategy_trade_snapshots + WHERE strategy_type=? AND source_id=? AND result_label IN ({",".join("?" * len(FINAL_TREND_CLOSE_LABELS))})""", + (STRATEGY_TREND, plan_id, *FINAL_TREND_CLOSE_LABELS), + ).fetchall() + for ex in existing: + ex_label = str(_row_dict(ex).get("result_label") or "") + if _final_trend_close_rank(ex_label) >= close_rank: + return + _purge_weaker_trend_final_snapshots(conn, plan_id, label) + elif _snapshot_key_exists(conn, STRATEGY_TREND, plan_id, label): + return + m = cfg.get("app_module") + close_ts = (closed_at or "").strip() or ( + m.app_now_str() + if m is not None and hasattr(m, "app_now_str") + else datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + ) + payload = attach_trend_dca_levels(row) + payload["result_label"] = result_label + payload["exit_price"] = exit_price + payload["pnl_amount"] = pnl_amount + payload["status_at_close"] = row.get("status") + conn.execute( + """INSERT INTO strategy_trade_snapshots ( + strategy_type, source_id, symbol, exchange_symbol, direction, + result_label, status_at_close, opened_at, closed_at, pnl_amount, snapshot_json, created_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + STRATEGY_TREND, + plan_id, + row.get("symbol"), + row.get("exchange_symbol"), + row.get("direction"), + result_label, + row.get("status"), + row.get("opened_at"), + close_ts, + pnl_amount, + _json_dumps(payload), + close_ts, + ), + ) + prune_strategy_snapshots(conn, keep=STRATEGY_SNAPSHOTS_MAX_ROWS) + + +def save_roll_group_snapshot( + cfg: dict, + conn, + group: dict, + *, + result_label: str = "结束", + pnl_amount: float | None = None, +) -> None: + init_strategy_snapshot_table(conn) + g = dict(group or {}) + gid = int(g.get("id") or 0) + if gid <= 0: + return + label = (result_label or "结束").strip() + if _snapshot_key_exists(conn, STRATEGY_ROLL, gid, label): + return + legs = [] + for leg in conn.execute( + "SELECT * FROM roll_legs WHERE roll_group_id=? ORDER BY leg_index ASC, id ASC", + (gid,), + ).fetchall(): + ld = _row_dict(leg) + try: + from lib.strategy.strategy_roll_monitor_lib import roll_leg_status_label + + ld["status_label"] = roll_leg_status_label(ld.get("status")) + except Exception: + ld["status_label"] = ld.get("status") or "" + legs.append(ld) + m = cfg.get("app_module") + closed_at = ( + m.app_now_str() + if m is not None and hasattr(m, "app_now_str") + else datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + ) + payload = { + "group": g, + "legs": legs, + "result_label": result_label, + "pnl_amount": pnl_amount, + } + conn.execute( + """INSERT INTO strategy_trade_snapshots ( + strategy_type, source_id, symbol, exchange_symbol, direction, + result_label, status_at_close, opened_at, closed_at, pnl_amount, snapshot_json, created_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + STRATEGY_ROLL, + gid, + g.get("symbol"), + g.get("exchange_symbol"), + g.get("direction"), + result_label, + g.get("status"), + g.get("created_at"), + closed_at, + pnl_amount, + _json_dumps(payload), + closed_at, + ), + ) + prune_strategy_snapshots(conn, keep=STRATEGY_SNAPSHOTS_MAX_ROWS) + + +def prune_strategy_snapshots(conn, *, keep: int = STRATEGY_SNAPSHOTS_MAX_ROWS) -> None: + """仅保留最近 keep 条策略快照(按 closed_at / id 倒序).""" + dedupe_strategy_snapshots(conn) + k = max(1, min(int(keep), 500)) + conn.execute( + """DELETE FROM strategy_trade_snapshots + WHERE id NOT IN ( + SELECT id FROM strategy_trade_snapshots + ORDER BY COALESCE(closed_at, created_at, '') DESC, id DESC + LIMIT ? + )""", + (k,), + ) + + +def _snapshot_pnl(row: dict, snap: dict) -> float | None: + for key in ("pnl_amount",): + v = row.get(key) + if v is not None and v != "": + try: + return float(v) + except (TypeError, ValueError): + pass + v = snap.get("pnl_amount") + if v is not None and v != "": + try: + return float(v) + except (TypeError, ValueError): + pass + return None + + +def _trend_dca_stats(snap: dict) -> dict: + levels = snap.get("dca_levels") or build_trend_dca_levels(snap) + dca_only = [ + lv + for lv in levels + if (lv.get("leg_key") or "") != "first" and (lv.get("label") or "") != "首仓" + ] + done = sum(1 for lv in dca_only if lv.get("status") == "done") + total = len(dca_only) + pending = total - done + if total <= 0: + tag = "na" + elif done <= 0: + tag = "no_dca" + elif done >= total: + tag = "dca_done" + else: + tag = "dca_partial" + return { + "dca_done": done, + "dca_total": total, + "dca_pending": pending, + "dca_tag": tag, + } + + +def _roll_leg_stats(snap: dict) -> dict: + legs = snap.get("legs") or [] + if not isinstance(legs, list): + legs = [] + filled = sum(1 for lg in legs if (lg.get("status") or "").lower() == "filled") + total = len(legs) + pending = total - filled + if total <= 0: + tag = "na" + elif filled <= 0: + tag = "no_dca" + elif filled >= total: + tag = "dca_done" + else: + tag = "dca_partial" + return { + "dca_done": filled, + "dca_total": total, + "dca_pending": pending, + "dca_tag": tag, + } + + +def enrich_strategy_snapshot_row(row: dict) -> dict: + d = dict(row or {}) + snap = d.get("snapshot") or {} + st = (d.get("strategy_type") or "").strip() + pnl = _snapshot_pnl(d, snap) + if pnl is not None: + if pnl > 1e-9: + d["filter_pnl"] = "profit" + elif pnl < -1e-9: + d["filter_pnl"] = "loss" + else: + d["filter_pnl"] = "flat" + else: + d["filter_pnl"] = "unknown" + snap_sym = "" + if isinstance(snap, dict): + snap_sym = (snap.get("symbol") or snap.get("exchange_symbol") or "").strip() + sym = (d.get("symbol") or d.get("exchange_symbol") or snap_sym or "").strip() + if sym: + d["symbol"] = d.get("symbol") or sym + d["exchange_symbol"] = d.get("exchange_symbol") or sym + d["filter_symbol"] = sym.upper().split("/")[0].split(":")[0] if sym else "" + closed = (d.get("closed_at") or d.get("created_at") or "").strip() + d["sort_ts"] = closed + if st == STRATEGY_TREND: + stats = _trend_dca_stats(snap) + d.update(stats) + legs_txt = ( + f"{stats['dca_done']}/{stats['dca_total']}" + if stats["dca_total"] > 0 + else "0/0" + ) + d["summary_dca"] = legs_txt + else: + stats = _roll_leg_stats(snap) + d.update(stats) + d["summary_dca"] = ( + f"{stats['dca_done']}/{stats['dca_total']}腿" + if stats["dca_total"] > 0 + else "—" + ) + return d + + +def list_strategy_snapshots(conn, *, limit: int = 200) -> list[dict]: + init_strategy_snapshot_table(conn) + rows = conn.execute( + "SELECT * FROM strategy_trade_snapshots ORDER BY id DESC LIMIT ?", + (max(1, min(int(limit), 500)),), + ).fetchall() + out = [] + seen: dict[tuple[str, int, str], int] = {} + for r in rows: + d = _row_dict(r) + try: + d["snapshot"] = json.loads(d.get("snapshot_json") or "{}") + except Exception: + d["snapshot"] = {} + st = (d.get("strategy_type") or "").strip() + d["strategy_label"] = "趋势回调" if st == STRATEGY_TREND else "顺势加仓" + enriched = enrich_strategy_snapshot_row(d) + try: + source_id = int(enriched.get("source_id") or 0) + except (TypeError, ValueError): + source_id = 0 + result_label = (enriched.get("result_label") or "").strip() + close_rank = _final_trend_close_rank(result_label) + if st == STRATEGY_TREND and source_id > 0 and close_rank > 0: + plan_key = (st, source_id) + snap_id = int(enriched.get("id") or 0) + prev = seen.get(plan_key) + if prev is not None: + prev_id, prev_rank = prev + if prev_rank > close_rank or (prev_rank == close_rank and prev_id >= snap_id): + continue + out = [x for x in out if int(x.get("id") or 0) != prev_id] + seen[plan_key] = (snap_id, close_rank) + out.append(enriched) + continue + key = (st, source_id, result_label) + snap_id = int(enriched.get("id") or 0) + prev = seen.get(key) + if prev is not None and prev[0] >= snap_id: + continue + if prev is not None: + out = [x for x in out if int(x.get("id") or 0) != prev[0]] + seen[key] = (snap_id, 0) + out.append(enriched) + return out + + +def list_strategy_snapshots_split( + conn, *, limit: int = STRATEGY_SNAPSHOTS_MAX_ROWS +) -> tuple[list[dict], list[dict], list[str]]: + """趋势 / 顺势分组,及筛选用币种列表.""" + all_rows = list_strategy_snapshots(conn, limit=limit) + trend = [r for r in all_rows if (r.get("strategy_type") or "") == STRATEGY_TREND] + roll = [r for r in all_rows if (r.get("strategy_type") or "") == STRATEGY_ROLL] + symbols = sorted({r.get("filter_symbol") or "" for r in all_rows if r.get("filter_symbol")}) + return trend, roll, symbols diff --git a/lib/strategy/strategy_trade_labels.py b/lib/strategy/strategy_trade_labels.py new file mode 100644 index 0000000..33bcb94 --- /dev/null +++ b/lib/strategy/strategy_trade_labels.py @@ -0,0 +1,192 @@ +"""策略交易写入 trade_records 时的类型与复盘开仓类型标注.""" +from __future__ import annotations + +from typing import Optional + +MONITOR_TYPE_TREND_PULLBACK = "趋势回调" +MONITOR_TYPE_ROLL = "顺势加仓" +ORDER_TYPE_MANUAL = "下单监控" +ORDER_TYPE_KEY = "关键位监控" + +ENTRY_REASON_TREND_PULLBACK = "趋势回调" +ENTRY_REASON_ROLL = "顺势加仓" + +JOURNAL_ORDER_TYPE_OPTIONS = ( + ORDER_TYPE_MANUAL, + ORDER_TYPE_KEY, + MONITOR_TYPE_TREND_PULLBACK, + MONITOR_TYPE_ROLL, +) + +STRATEGY_ENTRY_REASON_OPTIONS = ( + ENTRY_REASON_TREND_PULLBACK, + ENTRY_REASON_ROLL, +) + + +def normalize_journal_order_type(raw: Optional[str]) -> str: + s = (raw or "").strip() + if s in JOURNAL_ORDER_TYPE_OPTIONS: + return s + if "关键位" in s: + return ORDER_TYPE_KEY + return "" + + +def order_type_from_monitor_type( + monitor_type: Optional[str], + key_signal_type: Optional[str] = None, +) -> str: + del key_signal_type + mt = (monitor_type or "").strip() + if mt == MONITOR_TYPE_TREND_PULLBACK: + return MONITOR_TYPE_TREND_PULLBACK + if mt == MONITOR_TYPE_ROLL: + return MONITOR_TYPE_ROLL + if mt == ORDER_TYPE_KEY or "关键位" in mt: + return ORDER_TYPE_KEY + return ORDER_TYPE_MANUAL + +# 趋势回调保本移交下单监控:order_monitors.key_signal_type / 平仓备注 +TREND_HANDOFF_KEY_SIGNAL = ENTRY_REASON_TREND_PULLBACK +TREND_HANDOFF_TRADE_NOTE = "趋势回调计划" + + +def handoff_trade_miss_reason(miss_reason, row) -> Optional[str]: + """趋势保本移交的监控单平仓:交易记录备注带来源.""" + if trend_plan_id_from_monitor_row(row) is None: + return miss_reason + base = (miss_reason or "").strip() + if TREND_HANDOFF_TRADE_NOTE in base: + return base or TREND_HANDOFF_TRADE_NOTE + if base: + return f"{TREND_HANDOFF_TRADE_NOTE};{base}" + return TREND_HANDOFF_TRADE_NOTE + + +def trend_plan_id_from_monitor_row(row) -> Optional[int]: + if row is None: + return None + try: + keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + keys = [] + if "trend_plan_id" not in keys or row["trend_plan_id"] in (None, ""): + return None + try: + tid = int(row["trend_plan_id"]) + return tid if tid > 0 else None + except (TypeError, ValueError): + return None + + +def order_had_roll_fills(conn, order_monitor_id) -> bool: + try: + oid = int(order_monitor_id) + except (TypeError, ValueError): + return False + if oid <= 0: + return False + try: + row = conn.execute( + """SELECT 1 FROM roll_legs l + INNER JOIN roll_groups g ON g.id = l.roll_group_id + WHERE g.order_monitor_id=? AND l.status='filled' + LIMIT 1""", + (oid,), + ).fetchone() + return row is not None + except Exception: + return False + + +def _row_monitor_type(row, default_manual: str) -> str: + if row is None: + return default_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 default_manual + + +def _row_key_signal_type(row) -> str: + if row is None: + return "" + try: + keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + keys = [] + if "key_signal_type" not in keys: + return "" + return (row["key_signal_type"] or "").strip() + + +def order_monitor_source_type(row, *, default_manual: str = "下单监控") -> str: + """展示/平仓记录:趋势保本移交单来源为「趋势回调」,非「下单监控」.""" + if trend_plan_id_from_monitor_row(row) is not None: + return MONITOR_TYPE_TREND_PULLBACK + mt = _row_monitor_type(row, default_manual) + if mt != default_manual: + return mt + kst = _row_key_signal_type(row) + if kst in ( + MONITOR_TYPE_TREND_PULLBACK, + TREND_HANDOFF_KEY_SIGNAL, + TREND_HANDOFF_TRADE_NOTE, + ENTRY_REASON_TREND_PULLBACK, + ): + return MONITOR_TYPE_TREND_PULLBACK + return mt + + +def apply_order_monitor_source_labels(item: dict, *, default_manual: str = "下单监控") -> dict: + """实例页 / 中控 API:统一修正 order_monitors 展示用 monitor_type.""" + out = dict(item or {}) + out["monitor_type"] = order_monitor_source_type(out, default_manual=default_manual) + return out + + +def trade_record_monitor_type(conn, order_row, *, default_manual: str = "下单监控") -> str: + """平仓写入 trade_records 时:曾顺势加仓则标「顺势加仓」,否则沿用监控单来源类型.""" + oid = None + try: + keys = order_row.keys() if hasattr(order_row, "keys") else [] + if "id" in keys and order_row["id"] is not None: + oid = int(order_row["id"]) + except Exception: + oid = None + if oid and order_had_roll_fills(conn, oid): + return MONITOR_TYPE_ROLL + return order_monitor_source_type(order_row, default_manual=default_manual) + + +def entry_reason_for_monitor_type(monitor_type: str | None) -> str: + mt = (monitor_type or "").strip() + if mt == MONITOR_TYPE_TREND_PULLBACK: + return ENTRY_REASON_TREND_PULLBACK + if mt == MONITOR_TYPE_ROLL: + return ENTRY_REASON_ROLL + return "" + + +def order_monitor_excluded_from_position_limit(conn, row) -> bool: + """趋势回调不计入 MAX_ACTIVE_POSITIONS;顺势加仓在已有持仓上操作,单独放行.""" + return order_monitor_source_type(row) == MONITOR_TYPE_TREND_PULLBACK + + +def count_position_limit_active_monitors(conn) -> int: + """计入仓位上限冻结的活跃监控数(不含趋势回调,顺势加仓).""" + try: + rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() + except Exception: + return 0 + n = 0 + for row in rows: + if not order_monitor_excluded_from_position_limit(conn, row): + n += 1 + return n diff --git a/lib/strategy/strategy_trend_exchange.py b/lib/strategy/strategy_trend_exchange.py new file mode 100644 index 0000000..02be3ce --- /dev/null +++ b/lib/strategy/strategy_trend_exchange.py @@ -0,0 +1,97 @@ +"""趋势回调:各交易所止损刷新,市价加/平仓(通过 app 模块能力探测).""" +from __future__ import annotations + +import time +from typing import Any + + +def _m(cfg: dict) -> Any: + return cfg["app_module"] + + +def trend_refresh_stop_only(cfg: dict, exchange_symbol: str, direction: str, stop_loss: float) -> None: + m = _m(cfg) + if hasattr(m, "_gate_place_stop_loss_only_position"): + if hasattr(m, "cancel_gate_swap_trigger_orders"): + m.cancel_gate_swap_trigger_orders(exchange_symbol) + m._gate_place_stop_loss_only_position(exchange_symbol, direction, stop_loss) + return + if hasattr(m, "_binance_place_stop_loss_only"): + m._binance_place_stop_loss_only(exchange_symbol, direction, stop_loss) + return + if hasattr(m, "_okx_place_stop_loss_only"): + m._okx_place_stop_loss_only(exchange_symbol, direction, stop_loss) + return + raise RuntimeError("当前实例未配置趋势回调止损挂单能力") + + +def trend_market_add(cfg: dict, exchange_symbol: str, direction: str, contracts: float, leverage: int): + m = _m(cfg) + ex = m.exchange + m.ensure_markets_loaded() + ex.set_leverage(int(leverage), exchange_symbol) + side = "buy" if direction == "long" else "sell" + if hasattr(m, "build_gate_order_params"): + params = m.build_gate_order_params(direction, reduce_only=False) + elif hasattr(m, "build_binance_order_params"): + params = m.build_binance_order_params(direction, reduce_only=False) + elif hasattr(m, "build_okx_order_params"): + params = m.build_okx_order_params(direction, reduce_only=False) + else: + params = {} + order_params = params if params is not None else {} + return ex.create_order(exchange_symbol, "market", side, float(contracts), None, order_params) + + +def trend_market_close(cfg: dict, exchange_symbol: str, direction: str, pos_qty: float, leverage: int): + m = _m(cfg) + ex = m.exchange + m.ensure_markets_loaded() + ex.set_leverage(int(leverage), exchange_symbol) + side = "sell" if direction == "long" else "buy" + amt = float(ex.amount_to_precision(exchange_symbol, float(pos_qty))) + if hasattr(m, "close_exchange_order"): + row = { + "exchange_symbol": exchange_symbol, + "symbol": exchange_symbol, + "direction": direction, + "order_amount": amt, + } + return m.close_exchange_order(row) + if hasattr(m, "build_gate_order_params"): + params = m.build_gate_order_params(direction, reduce_only=True) + return ex.create_order(exchange_symbol, "market", side, amt, None, params) + if hasattr(m, "build_binance_order_params"): + for params in m._binance_market_close_param_candidates(direction): + try: + return ex.create_order(exchange_symbol, "market", side, amt, None, params) + except Exception as e: + if not m._is_binance_close_param_retryable(str(e)): + raise + raise RuntimeError("平仓失败") + if hasattr(m, "build_okx_order_params"): + params = m.build_okx_order_params(direction, reduce_only=True) + return ex.create_order(exchange_symbol, "market", side, amt, None, params) + return ex.create_order(exchange_symbol, "market", side, amt, None, {"reduceOnly": True}) + + +def trend_replace_tpsl(cfg: dict, order_row: dict, stop_loss: float, take_profit: float) -> None: + """趋势保本移交:先撤条件单再挂保本止损 + 计划止盈(与下单监控一致).""" + m = _m(cfg) + fn = getattr(m, "replace_active_monitor_tpsl_on_exchange", None) + if not callable(fn): + raise RuntimeError("当前实例未配置止盈止损同步能力") + fn(order_row, float(stop_loss), float(take_profit)) + + +def cancel_symbol_orders(cfg: dict, exchange_symbol: str) -> None: + m = _m(cfg) + if hasattr(m, "cancel_all_open_orders_for_symbol"): + m.cancel_all_open_orders_for_symbol(exchange_symbol) + return + if hasattr(m, "cancel_gate_swap_trigger_orders"): + m.cancel_gate_swap_trigger_orders(exchange_symbol) + if hasattr(m, "cancel_binance_futures_open_orders"): + m.cancel_binance_futures_open_orders(exchange_symbol) + if hasattr(m, "cancel_okx_swap_open_orders"): + m.cancel_okx_swap_open_orders(exchange_symbol) diff --git a/lib/strategy/strategy_trend_lib.py b/lib/strategy/strategy_trend_lib.py new file mode 100644 index 0000000..f0dcad2 --- /dev/null +++ b/lib/strategy/strategy_trend_lib.py @@ -0,0 +1,701 @@ +"""趋势回调策略:纯计算与校验(无 ccxt / Flask).各所 adapter 负责张数精度与下单.""" +from __future__ import annotations + +import json +from typing import Any, Callable, Optional, Tuple + +AmountPreciseFn = Callable[[str, float], Optional[float]] + + +def calc_risk_fraction(direction: str, entry_price: float, stop_loss: float) -> Optional[float]: + try: + entry = float(entry_price) + sl = float(stop_loss) + if entry <= 0 or sl <= 0: + return None + if (direction or "long").strip().lower() == "short": + risk = sl - entry + else: + risk = entry - sl + if risk <= 0: + return None + return risk / entry + except (TypeError, ValueError): + return None + + +def trend_effective_margin_capital(plan: dict) -> float: + """按已开仓张数占计划总张数比例折算保证金(首仓/部分补仓时的盈亏估算).""" + try: + plan_margin = float(plan.get("plan_margin_capital") or 0) + target = float(plan.get("target_order_amount") or 0) + open_amt = float(plan.get("order_amount_open") or 0) + except (TypeError, ValueError): + return float((plan or {}).get("plan_margin_capital") or 0) + if plan_margin <= 0: + return 0.0 + if target > 0 and open_amt > 0: + return round(plan_margin * min(1.0, open_amt / target), 8) + try: + first = float(plan.get("first_order_amount") or 0) + except (TypeError, ValueError): + first = 0.0 + if target > 0 and first > 0: + return round(plan_margin * min(1.0, first / target), 8) + return plan_margin + + +def trend_dca_level_reached(direction: str, mark_price: float, level: float) -> bool: + """做空:价升触达/越过档位即应补仓;做多:价跌触达/越过档位.""" + d = (direction or "long").strip().lower() + try: + pf = float(mark_price) + lv = float(level) + except (TypeError, ValueError): + return False + if d == "long": + return pf <= lv + return pf >= lv + + +def validate_trend_bounds(direction: str, stop_loss: float, add_upper: float) -> Optional[str]: + direction = (direction or "long").strip().lower() + if direction == "long": + if not (float(stop_loss) < float(add_upper)): + return "做多:止损价须低于补仓上沿" + else: + if not (float(stop_loss) > float(add_upper)): + return "做空:止损价须高于补仓下沿" + return None + + +def build_grid_prices(direction: str, sl: float, upper: float, n_legs: int) -> list[float]: + """在 (止损, 补仓区间远侧边界) 内生成 n_legs 个触发价(不含端点).""" + sl, upper = float(sl), float(upper) + out: list[float] = [] + if n_legs <= 0: + return out + direction = (direction or "long").strip().lower() + 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 pick_dca_legs_and_per_leg( + exchange_symbol: str, + remainder_total: float, + want_legs: int, + amount_precise: AmountPreciseFn, + min_amount: float = 0.0, +) -> Tuple[int, float]: + """按最小张数约束自动减少档位数.返回 (有效档数, 每档参考张数).""" + legs = max(1, int(want_legs)) + rem = float(remainder_total) + min_amt = float(min_amount or 0.0) + while legs >= 1: + per = rem / legs + per_p = amount_precise(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 = amount_precise(exchange_symbol, rem) + if one is None or one <= 0: + return 0, 0.0 + return 1, one + + +def build_leg_amounts_json( + exchange_symbol: str, + remainder_total: float, + want_legs: int, + amount_precise: AmountPreciseFn, + min_amount: float = 0.0, +) -> Tuple[int, str, float]: + """拆分补仓张数 JSON.返回 (档位数, json列表, 每档参考).""" + rem = amount_precise(exchange_symbol, float(remainder_total)) + if rem is None or rem <= 0: + return 0, "[]", 0.0 + n, _ = pick_dca_legs_and_per_leg(exchange_symbol, rem, want_legs, amount_precise, min_amount) + if n <= 0: + return 0, "[]", 0.0 + if n <= 1: + one = amount_precise(exchange_symbol, rem) + if one is None or one <= 0: + return 0, "[]", 0.0 + return 1, json.dumps([one]), one + unit = amount_precise(exchange_symbol, rem / n) + if unit is None or unit <= 0: + one = amount_precise(exchange_symbol, rem) + if one is None or one <= 0: + return 0, "[]", 0.0 + return 1, json.dumps([one]), one + parts: list[float] = [] + acc = 0.0 + for _ in range(n - 1): + parts.append(unit) + acc += unit + last = amount_precise(exchange_symbol, max(0.0, rem - acc)) + if last is None or last <= 0: + one = amount_precise(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 compute_trend_plan_core( + *, + direction: str, + stop_loss: float, + add_upper: float, + risk_percent: float, + snapshot_usdt: float, + leverage: int, + live_price: float, + target_order_amount: float, + exchange_symbol: str, + dca_legs: int, + amount_precise: AmountPreciseFn, + min_amount: float = 0.0, + full_margin_buffer_ratio: float = 0.95, +) -> Tuple[Optional[dict[str, Any]], Optional[str]]: + """在已有 target_order_amount 时组装预览 payload(张数由调用方 prepare_order_amount 计算).""" + rf = calc_risk_fraction(direction, add_upper, stop_loss) + if rf is None or rf <= 0: + return None, "止损与补仓区间边界组合无法计算风险比例" + risk_budget = float(snapshot_usdt) * (float(risk_percent) / 100.0) + notional = risk_budget / rf + margin_plan = notional / float(leverage) + margin_plan = min(margin_plan, float(snapshot_usdt) * float(full_margin_buffer_ratio)) + if margin_plan <= 0: + return None, "计划保证金过小" + first_amt = amount_precise(exchange_symbol, float(target_order_amount) * 0.5) + if first_amt is None or first_amt <= 0: + return None, "首仓张数过小(低于交易所最小张数),请提高风险比例或杠杆" + remainder_total = amount_precise(exchange_symbol, max(0.0, float(target_order_amount) - float(first_amt))) + if remainder_total is None: + remainder_total = 0.0 + n_legs, leg_json, per_ref = build_leg_amounts_json( + exchange_symbol, remainder_total, dca_legs, amount_precise, min_amount + ) + if n_legs <= 0: + return None, "剩余计划张数不足以拆出补仓档,请提高风险比例或放宽止损与补仓区间间距" + grid = build_grid_prices(direction, stop_loss, add_upper, n_legs) + if len(grid) != n_legs: + return None, "补仓网格生成失败" + try: + leg_list = json.loads(leg_json) + except Exception: + leg_list = [] + payload = { + "direction": direction, + "stop_loss": float(stop_loss), + "add_upper": float(add_upper), + "risk_percent": float(risk_percent), + "snapshot_available_usdt": float(snapshot_usdt), + "live_price_ref": float(live_price), + "plan_margin_capital": float(margin_plan), + "target_order_amount": float(target_order_amount), + "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 calc_planned_reward_risk_ratio( + direction: str, entry_price: float, stop_loss: float, take_profit: float +) -> Optional[float]: + """盈亏比(reward/risk),与三所 calc_rr_ratio 口径一致.""" + try: + entry = float(entry_price) + sl = float(stop_loss) + tp = float(take_profit) + if entry <= 0 or sl <= 0 or tp <= 0: + return None + direction = (direction or "long").strip().lower() + 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 (TypeError, ValueError): + return None + + +def calc_take_profit_for_rr( + direction: str, entry_price: float, stop_loss: float, reward_risk_ratio: float +) -> Optional[float]: + """按统一止损与目标 RR 反推止盈价.""" + try: + entry = float(entry_price) + sl = float(stop_loss) + rr = float(reward_risk_ratio) + if entry <= 0 or sl <= 0 or rr <= 0: + return None + direction = (direction or "long").strip().lower() + if direction == "short": + risk = sl - entry + if risk <= 0: + return None + return round(entry - rr * risk, 10) + risk = entry - sl + if risk <= 0: + return None + return round(entry + rr * risk, 10) + except (TypeError, ValueError): + return None + + +def calc_risk_budget_usdt(snapshot_usdt: float, risk_percent: float) -> Optional[float]: + """计划止损金额 U = 可用快照 × 风险比例.""" + try: + snap = float(snapshot_usdt) + rp = float(risk_percent) + if snap <= 0 or rp <= 0: + return None + return round(snap * rp / 100.0, 4) + except (TypeError, ValueError): + return None + + +def calc_money_reward_risk_ratio(profit_u: float, risk_u: float) -> Optional[float]: + """金额盈亏比 = 止盈盈利 U / 止损金额 U.""" + try: + r = float(risk_u) + p = float(profit_u) + if r <= 0: + return None + return round(p / r, 4) + except (TypeError, ValueError): + return None + + +def calc_tp_profit_usdt( + direction: str, + avg_entry: float, + take_profit_price: float, + contracts: float, + contract_size: float = 1.0, +) -> Optional[float]: + """到达止盈价时,按累计张数与加仓后均价的净盈利 U(扣双边 taker 费).""" + try: + from lib.hub.hub_position_metrics import estimate_linear_swap_upnl_usdt + from lib.trade.trade_fee_lib import net_pnl_after_fee + + gross = estimate_linear_swap_upnl_usdt( + direction, float(avg_entry), float(take_profit_price), float(contracts), float(contract_size) + ) + if gross is None: + return None + return net_pnl_after_fee( + gross, avg_entry, take_profit_price, contracts, contract_size + ) + except (TypeError, ValueError): + return None + + +def weighted_avg_entry(legs: list[tuple[float, float]]) -> Optional[float]: + """按 (成交价, 张数) 加权均价.""" + total = 0.0 + cost = 0.0 + for price, amount in legs or []: + try: + p = float(price) + a = float(amount) + except (TypeError, ValueError): + continue + if a <= 0: + continue + total += a + cost += p * a + if total <= 0: + return None + return cost / total + + +def parse_leg_fill_prices(plan: dict) -> list[float]: + """首仓 + 各档补仓实际成交价列表.""" + try: + raw = json.loads((plan or {}).get("leg_fill_prices_json") or "[]") + if not isinstance(raw, list): + return [] + out: list[float] = [] + for item in raw: + try: + out.append(float(item)) + except (TypeError, ValueError): + continue + return out + except Exception: + return [] + + +def append_leg_fill_price_json(existing_json: str | None, fill_px: float) -> str: + fills = parse_leg_fill_prices({"leg_fill_prices_json": existing_json}) + fills.append(float(fill_px)) + return json.dumps(fills, ensure_ascii=False, separators=(",", ":")) + + +def trend_leg_grid_price(plan: dict, leg_idx: int) -> Optional[float]: + """补仓 leg_idx(1..N) 的计划网格触发价;首仓返回 None.""" + if leg_idx <= 0: + return None + try: + grid = [float(x) for x in json.loads((plan or {}).get("grid_prices_json") or "[]")] + except Exception: + grid = [] + gi = leg_idx - 1 + if 0 <= gi < len(grid): + return float(grid[gi]) + return None + + +def trend_leg_display_price(plan: dict, leg_idx: int) -> Optional[float]: + """ + 三所统一:单档展示价 = leg_fill_prices_json 实际记录,否则计划网格(首仓用均价/参考价). + 禁止为凑均价反推虚构成交价. + """ + p = plan or {} + fills = parse_leg_fill_prices(p) + if len(fills) > leg_idx: + return float(fills[leg_idx]) + if leg_idx == 0: + try: + return float(p.get("avg_entry_price")) + except (TypeError, ValueError): + pass + try: + ref = p.get("live_price_ref") + if ref not in (None, ""): + return float(ref) + except (TypeError, ValueError): + pass + return None + return trend_leg_grid_price(p, leg_idx) + + +def reconcile_trend_leg_fill_prices(plan: dict) -> list[float]: + """首仓(0)+已补仓(1..legs_done) 展示价列表(三所共用 trend_leg_display_price).""" + p = plan or {} + if int(p.get("first_order_done") or 0) == 0: + return [] + try: + legs_done = int(p.get("legs_done") or 0) + except (TypeError, ValueError): + legs_done = 0 + result: list[float] = [] + for leg_idx in range(legs_done + 1): + px = trend_leg_display_price(p, leg_idx) + result.append(float(px) if px is not None else 0.0) + return result + + +def calc_trend_plan_money_metrics(plan: dict) -> dict: + """运行中计划头部:按快照风险金额计算盈亏比(止盈盈利 U / 风险 U).""" + out = {"money_rr": None, "risk_amount_u": None} + p = plan or {} + try: + direction = (p.get("direction") or "long").strip().lower() + user_tp = float(p.get("take_profit")) + avg = float(p.get("avg_entry_price")) + open_amt = float(p.get("order_amount_open") or p.get("first_order_amount") or 0) + snapshot = float(p.get("snapshot_available_usdt")) + risk_percent = float(p.get("risk_percent")) + except (TypeError, ValueError): + return out + if avg <= 0 or open_amt <= 0: + return out + risk_u = calc_risk_budget_usdt(snapshot, risk_percent) + if risk_u is None or risk_u <= 0: + return out + out["risk_amount_u"] = risk_u + try: + contract_size = float(p.get("contract_size") or 1.0) + if contract_size <= 0: + contract_size = 1.0 + except (TypeError, ValueError): + contract_size = 1.0 + profit_u = calc_tp_profit_usdt(direction, avg, user_tp, open_amt, contract_size) + out["money_rr"] = calc_money_reward_risk_ratio(profit_u, risk_u) + return out + + +def build_trend_preview_level_rows(preview: dict) -> tuple[dict, list[dict]]: + """ + 预览:表单止盈价下每档累计持仓的盈利 U;止损金额 = 快照×风险;盈亏比按金额对比. + 返回 (增强后的 preview 字段, 表格行列表,含首仓行). + """ + p = dict(preview or {}) + direction = (p.get("direction") or "long").strip().lower() + try: + ref = float(p.get("live_price_ref")) + sl = float(p.get("stop_loss")) + user_tp = float(p.get("take_profit")) + first_amt = float(p.get("first_order_amount")) + snapshot = float(p.get("snapshot_available_usdt")) + risk_percent = float(p.get("risk_percent")) + except (TypeError, ValueError): + return p, [] + + risk_u = calc_risk_budget_usdt(snapshot, risk_percent) + if risk_u is None or risk_u <= 0: + return p, [] + + try: + contract_size = float(p.get("contract_size") or 1.0) + if contract_size <= 0: + contract_size = 1.0 + except (TypeError, ValueError): + contract_size = 1.0 + + p["preview_risk_amount_u"] = risk_u + p["preview_take_profit_price"] = user_tp + p["preview_unified_stop_loss"] = sl + + try: + grid = json.loads(p.get("grid_prices_json") or "[]") + if not isinstance(grid, list): + grid = [] + except Exception: + grid = [] + try: + leg_amounts = json.loads(p.get("leg_amounts_json") or "[]") + if not isinstance(leg_amounts, list): + leg_amounts = [] + except Exception: + leg_amounts = [] + + def _row_dict( + *, + i: int, + label: str, + price: float, + leg_contracts: float, + cum_contracts: float, + avg: float, + is_first: bool, + ) -> dict: + profit_u = calc_tp_profit_usdt(direction, avg, user_tp, cum_contracts, contract_size) + rr_money = calc_money_reward_risk_ratio(profit_u, risk_u) if profit_u is not None else None + return { + "i": i, + "label": label, + "price": price, + "contracts": leg_contracts, + "cum_contracts": cum_contracts, + "avg_entry": avg, + "take_profit_price": user_tp, + "profit_u": profit_u, + "risk_u": risk_u, + "rr": rr_money, + "stop_loss_price": sl, + "take_profit": profit_u, + "stop_loss": risk_u, + "is_first": is_first, + } + + cum_contracts = first_amt + first_profit = calc_tp_profit_usdt(direction, ref, user_tp, cum_contracts, contract_size) + first_rr = calc_money_reward_risk_ratio(first_profit, risk_u) if first_profit is not None else None + p["preview_first_profit_u"] = first_profit + p["preview_target_rr"] = first_rr + p["preview_first_take_profit"] = user_tp + + rows: list[dict] = [ + _row_dict( + i=0, + label="首仓", + price=ref, + leg_contracts=first_amt, + cum_contracts=cum_contracts, + avg=ref, + is_first=True, + ) + ] + accumulated: list[tuple[float, float]] = [(ref, first_amt)] + for i, pair in enumerate(zip(grid, leg_amounts), 1): + try: + price = float(pair[0]) + leg_contracts = float(pair[1]) + except (TypeError, ValueError): + continue + accumulated.append((price, leg_contracts)) + avg = weighted_avg_entry(accumulated) + if avg is None: + continue + cum_contracts += leg_contracts + rows.append( + _row_dict( + i=i, + label=f"补仓{i}", + price=price, + leg_contracts=leg_contracts, + cum_contracts=cum_contracts, + avg=avg, + is_first=False, + ) + ) + return p, rows + + +def enrich_trend_dca_levels_with_tp(plan: dict, levels: list[dict]) -> list[dict]: + """ + 三所统一补仓表 enrich(实例策略页 + 中控 monitor 共用). + 触发价:实际成交价或计划网格;末档加仓后均价用持仓均价;禁止反推虚构成交价. + """ + if not levels: + return levels + p = plan or {} + direction = (p.get("direction") or "long").strip().lower() + try: + sl = float(p.get("stop_loss")) + user_tp = float(p.get("take_profit")) + first_amt = float(p.get("first_order_amount")) + snapshot = float(p.get("snapshot_available_usdt")) + risk_percent = float(p.get("risk_percent")) + except (TypeError, ValueError): + return levels + + risk_u = calc_risk_budget_usdt(snapshot, risk_percent) + if risk_u is None or risk_u <= 0: + return levels + + try: + legs_done = int(p.get("legs_done") or 0) + except (TypeError, ValueError): + legs_done = 0 + first_done = int(p.get("first_order_done") or 0) != 0 + try: + target_avg = float(p.get("avg_entry_price")) + except (TypeError, ValueError): + target_avg = None + + ref_raw = p.get("live_price_ref") + if ref_raw in (None, ""): + ref_raw = p.get("avg_entry_price") + try: + ref = float(ref_raw) + except (TypeError, ValueError): + return levels + + try: + contract_size = float(p.get("contract_size") or 1.0) + if contract_size <= 0: + contract_size = 1.0 + except (TypeError, ValueError): + contract_size = 1.0 + + out: list[dict] = [] + accumulated: list[tuple[float, float]] = [] + cum_contracts = 0.0 + for lv in levels: + row = dict(lv) + is_first = row.get("leg_key") == "first" or row.get("label") == "首仓" or row.get("i") == 0 + row_cum = cum_contracts + if is_first: + try: + amt_f = float(row.get("contracts") if row.get("contracts") is not None else first_amt) + except (TypeError, ValueError): + amt_f = first_amt + if first_done: + fill_px = trend_leg_display_price(p, 0) + if fill_px is None: + try: + fill_px = float(p.get("avg_entry_price") or ref) + except (TypeError, ValueError): + fill_px = ref + accumulated = [(float(fill_px), amt_f)] + cum_contracts = amt_f + row_cum = cum_contracts + row["price"] = fill_px + if target_avg is not None and legs_done == 0: + row["avg_entry"] = target_avg + else: + row["avg_entry"] = float(fill_px) + else: + accumulated = [(ref, amt_f)] + cum_contracts = amt_f + row_cum = cum_contracts + row["avg_entry"] = ref + else: + try: + leg_num = int(row.get("i") or 0) + except (TypeError, ValueError): + leg_num = 0 + grid_trigger = row.get("price") + try: + grid_trigger_f = float(grid_trigger) if grid_trigger is not None else None + except (TypeError, ValueError): + grid_trigger_f = None + try: + leg_contracts = float(row.get("contracts") or 0) + except (TypeError, ValueError): + leg_contracts = 0.0 + done = row.get("status") == "done" or (leg_num > 0 and leg_num <= legs_done) + if done and leg_contracts > 0: + fill_px = trend_leg_display_price(p, leg_num) + if fill_px is None: + fill_px = grid_trigger_f if grid_trigger_f is not None else ref + row["price"] = fill_px + accumulated.append((fill_px, leg_contracts)) + cum_contracts += leg_contracts + row_cum = cum_contracts + if leg_num == legs_done and target_avg is not None: + row["avg_entry"] = target_avg + else: + avg = weighted_avg_entry(accumulated) + if avg is not None: + row["avg_entry"] = avg + elif grid_trigger_f is not None and leg_contracts > 0: + row["price"] = grid_trigger_f + projected = accumulated + [(grid_trigger_f, leg_contracts)] + avg = weighted_avg_entry(projected) + if avg is not None: + row["avg_entry"] = avg + row_cum = cum_contracts + leg_contracts + elif grid_trigger_f is not None: + row["price"] = grid_trigger_f + + avg_entry = row.get("avg_entry") + if avg_entry is not None and row_cum > 0: + profit_u = calc_tp_profit_usdt( + direction, float(avg_entry), user_tp, row_cum, contract_size + ) + row["take_profit_price"] = user_tp + row["profit_u"] = profit_u + row["risk_u"] = risk_u + row["rr"] = calc_money_reward_risk_ratio(profit_u, risk_u) if profit_u is not None else None + row["take_profit"] = profit_u + row["stop_loss"] = risk_u + row["stop_loss_price"] = sl + out.append(row) + return out diff --git a/lib/strategy/strategy_trend_register.py b/lib/strategy/strategy_trend_register.py new file mode 100644 index 0000000..ef67e1e --- /dev/null +++ b/lib/strategy/strategy_trend_register.py @@ -0,0 +1,1972 @@ +"""趋势回调:路由,轮询,页面数据(三所共用,依赖各 app 模块交易所能力).""" +from __future__ import annotations + +import inspect +import json +import os +import time +import uuid +from typing import Any, Optional + +from flask import Flask, flash, redirect, request, url_for +from jinja2 import ChoiceLoader, FileSystemLoader + +from lib.strategy.strategy_config import resolve_trading_app_module +from lib.strategy.strategy_db import init_strategy_tables +from lib.strategy.strategy_trend_exchange import ( + cancel_symbol_orders, + trend_market_add, + trend_market_close, + trend_refresh_stop_only, + trend_replace_tpsl, +) +from lib.strategy.strategy_trend_lib import ( + build_grid_prices, + build_leg_amounts_json, + calc_risk_fraction, + trend_dca_level_reached, + trend_effective_margin_capital, + validate_trend_bounds, +) +from lib.strategy.strategy_trade_labels import ( + ENTRY_REASON_TREND_PULLBACK, + MONITOR_TYPE_TREND_PULLBACK, + TREND_HANDOFF_KEY_SIGNAL, + TREND_HANDOFF_TRADE_NOTE, +) + +MONITOR_TYPE_TREND = MONITOR_TYPE_TREND_PULLBACK + +# 趋势回调:交易所报空仓需连续 N 次轮询确认,避免 OKX 等 API 瞬时误判立即结束计划 +_TREND_FLAT_STREAK: dict[int, int] = {} +TREND_FLAT_CONFIRM_POLLS = max(1, int(os.getenv("TREND_FLAT_CONFIRM_POLLS", "5"))) +TREND_OPEN_GRACE_SEC = max(0, int(os.getenv("TREND_OPEN_GRACE_SEC", "180"))) +_TREND_LIVE_SKIP_LOG_TS = 0.0 +_TREND_POLL_STATE: dict[str, Any] = { + "updated_at": None, + "live_ok": True, + "live_reason": "", + "plans": {}, +} + + +def get_trend_poll_state() -> dict: + return dict(_TREND_POLL_STATE or {}) + + +def _log_trend_live_skip(reason: str) -> None: + global _TREND_LIVE_SKIP_LOG_TS + now = time.time() + if now - _TREND_LIVE_SKIP_LOG_TS < 60: + return + _TREND_LIVE_SKIP_LOG_TS = now + print(f"[trend_pullback] poll skipped (live not ready): {reason}", flush=True) + + +def _set_trend_poll_plan(plan_id: int, info: dict) -> None: + plans = dict(_TREND_POLL_STATE.get("plans") or {}) + plans[str(plan_id)] = info + _TREND_POLL_STATE["plans"] = plans + + +def summarize_trend_dca_probe(cfg: dict, row) -> dict: + """诊断单计划为何未补仓(供页面 / API).""" + m = _m(cfg) + d = _row(cfg, row) + plan_id = int(d.get("id") or 0) + sym = d.get("symbol") or "" + direction = (d.get("direction") or "long").lower() + ex_sym = d.get("exchange_symbol") or m.normalize_exchange_symbol(sym) + out: dict[str, Any] = { + "plan_id": plan_id, + "symbol": sym, + "mark_price": None, + "next_trigger": None, + "trigger_reached": False, + "legs_done": int(d.get("legs_done") or 0), + "first_order_done": int(d.get("first_order_done") or 0), + "block_reason": None, + } + try: + legs_done = int(d.get("legs_done") or 0) + grid = json.loads(d.get("grid_prices_json") or "[]") + if not isinstance(grid, list): + grid = [] + leg_amounts = json.loads(d.get("leg_amounts_json") or "[]") + if not isinstance(leg_amounts, list): + leg_amounts = [] + except Exception: + grid = [] + leg_amounts = [] + legs_done = 0 + pf = _trend_poll_price(m, sym, ex_sym, direction) + out["mark_price"] = pf + ok_live, live_reason = m.ensure_exchange_live_ready() + out["live_ok"] = ok_live + if not ok_live: + out["block_reason"] = live_reason or "实盘未就绪" + if not int(d.get("first_order_done") or 0): + out["block_reason"] = out["block_reason"] or "首仓未完成" + return out + if legs_done >= len(grid) or legs_done >= len(leg_amounts): + out["block_reason"] = out["block_reason"] or "补仓档已全部完成或无 grid" + return out + try: + level = float(grid[legs_done]) + except (TypeError, ValueError, IndexError): + out["block_reason"] = out["block_reason"] or "无效补仓触发价" + return out + out["next_trigger"] = level + if pf is None: + out["block_reason"] = out["block_reason"] or "无法读取标记价" + return out + reached = trend_dca_level_reached(direction, float(pf), level) + out["trigger_reached"] = reached + if reached and not ok_live: + out["block_reason"] = live_reason or "LIVE_TRADING_ENABLED=false" + elif reached and ok_live: + pos = m.get_live_position_contracts(ex_sym, direction) + try: + local_open = float(d.get("order_amount_open") or 0) + except (TypeError, ValueError): + local_open = 0.0 + if pos is None and local_open > 0: + pos = local_open + if pos is None: + out["block_reason"] = "无法读取交易所持仓" + elif float(pos) <= 0: + out["block_reason"] = "交易所无持仓" + else: + out["block_reason"] = ( + "标记价已触达,轮询应自动下单;若仍未补请确认 PM2 进程 crypto_gate " + "(或对应所 Flask 进程)在运行,并查看 pm2 logs" + ) + elif not reached: + out["block_reason"] = f"标记价 {pf} 未触达下一档 {level}" + return out + + +def trend_add_zone_label(direction: str) -> str: + return "补仓下沿" if (direction or "long").strip().lower() == "short" else "补仓上沿" + + +def install_strategy_trend(app: Flask, repo_root: str, app_module: Any = None, **build_kw) -> dict: + from lib.strategy.strategy_register import attach_strategy_templates + + attach_strategy_templates(app, repo_root) + cfg = build_trend_config(app_module, **build_kw) + app.extensions["strategy_trend_cfg"] = cfg + register_trend_routes(app, cfg) + _patch_hub_monitor_enrich(app, cfg) + roll_cfg = app.extensions.get("strategy_roll_cfg") + if isinstance(roll_cfg, dict): + from lib.strategy.strategy_roll_ui_lib import patch_roll_hub_enrich + + patch_roll_hub_enrich(app, roll_cfg) + _patch_hub_trend_views(app) + + @app.context_processor + def _trend_ctx(): + return {"trend_add_zone_label": trend_add_zone_label} + + return cfg + + +def build_trend_config(app_module: Any = None, **kw) -> dict[str, Any]: + m = resolve_trading_app_module(app_module) + dca = max(1, int(os.getenv("TREND_PULLBACK_DCA_LEGS", kw.get("dca_legs", "5")))) + preview_ttl = max(10, int(os.getenv("TREND_PULLBACK_PREVIEW_TTL_SECONDS", "120"))) + drift = float(os.getenv("TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT", "5")) + be_pct = float(os.getenv("TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT", "0.3")) + buf = float(getattr(m, "FULL_MARGIN_BUFFER_RATIO", 0.95)) + + def amount_precise(ex_sym, amt): + fn = getattr(m, "_safe_amount_to_precision", None) + if callable(fn): + return fn(ex_sym, amt) + try: + m.ensure_markets_loaded() + return float(m.exchange.amount_to_precision(ex_sym, float(amt))) + except Exception: + return None + + def send_wechat(content): + fn = getattr(m, "send_wechat_msg", None) + if callable(fn): + fn(content) + + def wechat_account_label(): + fn = getattr(m, "_wechat_account_label", None) + if callable(fn): + try: + return fn() + except Exception: + pass + return getattr(m, "EXCHANGE_DISPLAY_NAME", "") or "" + + def wechat_direction_text(direction): + fn = getattr(m, "_wechat_direction_text", None) + if callable(fn): + try: + return fn(direction) + except Exception: + pass + d = (direction or "long").strip().lower() + return "做多" if d == "long" else "做空" + + return { + "app_module": m, + "exchange_display": getattr(m, "EXCHANGE_DISPLAY_NAME", ""), + "login_required": m.login_required, + "get_db": m.get_db, + "row_to_dict": m.row_to_dict, + "dca_legs": dca, + "preview_ttl": preview_ttl, + "drift_pct": drift, + "breakeven_offset_pct": be_pct, + "margin_buffer": buf, + "amount_precise": amount_precise, + "max_active_positions": int(getattr(m, "MAX_ACTIVE_POSITIONS", 1)), + "reset_hour": int(getattr(m, "TRADING_DAY_RESET_HOUR", 8)), + "monitor_type_trend": MONITOR_TYPE_TREND, + "send_wechat": send_wechat, + "format_price": getattr(m, "format_price_for_symbol", None), + "wechat_account_label": wechat_account_label, + "wechat_direction_text": wechat_direction_text, + } + + +def _m(cfg: dict): + return cfg["app_module"] + + +def _row(cfg, row) -> dict: + return cfg["row_to_dict"](row) + + +def precheck_trend_start(cfg: dict, conn, *, symbol: str = "", direction: str = "long") -> tuple[bool, str]: + m = _m(cfg) + mode = getattr(m, "POSITION_SIZING_MODE", None) or "risk" + try: + from lib.trade.position_sizing_lib import OPEN_SOURCE_TREND, assert_open_source_allowed + + ok_src, src_msg = assert_open_source_allowed(mode, OPEN_SOURCE_TREND) + if not ok_src: + return False, src_msg + except Exception: + pass + sym = (symbol or "").strip() + dir_l = (direction or "long").strip().lower() + validate_fn = getattr(m, "validate_trade_policy_open", None) + if callable(validate_fn) and sym: + ok_pol, pol_msg = validate_fn(sym, dir_l) + if not ok_pol: + return False, pol_msg + if sym and dir_l in ("long", "short") and hasattr(m, "precheck_risk"): + ok_risk, risk_msg = m.precheck_risk(conn, sym, dir_l) + if not ok_risk: + return False, risk_msg + else: + now = m.app_now() + if not m.trading_day_reset_allows_new_open(now): + return False, f"北京时间 {cfg['reset_hour']}:00 前不允许持仓" + from lib.trade.account_risk_lib import account_risk_blocks_trading, position_limit_reached + + ok_risk, risk_reason = account_risk_blocks_trading( + conn, + trading_day=m.get_trading_day(now), + now=now, + fmt_local_ms=getattr(m, "ms_to_app_local_str", lambda _x: ""), + ) + if not ok_risk: + return False, risk_reason + reached, active_count, mx = position_limit_reached( + conn, max_active_positions=cfg["max_active_positions"] + ) + if reached: + return False, f"已达最大持仓数({active_count}/{mx})" + from lib.trade.daily_open_limit_lib import check_daily_open_hard_limit + + ok_daily, daily_reason, _opens = check_daily_open_hard_limit( + conn, + m.get_trading_day(now), + getattr(m, "DAILY_OPEN_HARD_LIMIT", 0), + cfg["reset_hour"], + ) + if not ok_daily: + return False, daily_reason + active = m.get_active_position_count(conn) + if active >= cfg["max_active_positions"]: + return ( + False, + f"已达最大持仓数({active}/{cfg['max_active_positions']})," + "请先结束「实盘下单」中的持仓,再启动趋势回调", + ) + trend_n = conn.execute( + "SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'" + ).fetchone()[0] + if int(trend_n or 0) > 0: + return False, "已存在运行中的趋势回调计划" + return True, "" + + +def _cleanup_stale_previews(conn) -> None: + 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_trend_plan(cfg: dict, form_dict) -> tuple[Optional[dict], Optional[str]]: + m = _m(cfg) + d = form_dict or {} + symbol = m.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 = m.parse_positive_float(d.get("leverage")) + leverage = int(lev_raw) if lev_raw is not None else m.infer_leverage(symbol) + except Exception: + return None, "杠杆格式错误" + if leverage <= 0 or risk_percent <= 0: + return None, "杠杆与风险比例必须大于0" + bound_err = validate_trend_bounds(direction, stop_loss, add_upper) + if bound_err: + return None, bound_err + snap = m.get_available_trading_usdt() + if snap is None or snap <= 0: + return None, "无法读取合约账户 USDT 可用余额,请检查 API 与账户类型" + live_price = m.get_price(symbol) + if live_price is None: + return None, "获取实时价格失败" + exchange_symbol = m.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) * cfg["margin_buffer"]) + if margin_plan <= 0: + return None, "计划保证金过小" + try: + target_amt, _ = m.prepare_order_amount(exchange_symbol, margin_plan, leverage, live_price) + except Exception as e: + return None, str(e) + ap = cfg["amount_precise"] + first_amt = ap(exchange_symbol, float(target_amt) * 0.5) + if first_amt is None or first_amt <= 0: + return None, "首仓张数过小(低于交易所最小张数),请提高风险比例或杠杆" + remainder_total = ap(exchange_symbol, max(0.0, float(target_amt) - float(first_amt))) + if remainder_total is None: + remainder_total = 0.0 + m.ensure_markets_loaded() + market = m.exchange.market(exchange_symbol) + min_amt = float((market.get("limits", {}).get("amount", {}) or {}).get("min") or 0) + n_legs, leg_json, per_ref = build_leg_amounts_json( + exchange_symbol, remainder_total, cfg["dca_legs"], ap, min_amt + ) + if n_legs <= 0: + return None, "剩余计划张数不足以拆出补仓档,请提高风险比例或放宽止损与补仓区间间距" + grid = build_grid_prices(direction, stop_loss, add_upper, n_legs) + if len(grid) != n_legs: + return None, "补仓网格生成失败" + opened_at = m.app_now_str() + try: + leg_list = json.loads(leg_json) + except Exception: + leg_list = [] + contract_size = float(market.get("contractSize") or 1) + return { + "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, + "contract_size": contract_size, + }, None + + +def _insert_preview_snapshot(conn, preview_id: str, created: str, exp_ms: int, pl: dict) -> None: + 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 _format_trend_price(cfg: dict, symbol: str, value) -> str: + if value in (None, ""): + return "—" + m = _m(cfg) + sym = symbol or "" + norm = getattr(m, "normalize_exchange_symbol", None) + if callable(norm): + try: + sym = norm(sym) or sym + except Exception: + pass + try: + m.ensure_markets_loaded() + return str(m.exchange.price_to_precision(sym, float(value))) + except Exception: + fn = getattr(m, "format_price_for_symbol", None) + if callable(fn): + return fn(symbol, value) + return str(value) + + +def _trend_add_leg_fields(cfg: dict, d: dict) -> dict: + """解析已补仓次数与已触达网格价(供策略页与中控 monitor 共用).""" + import json + + out = dict(d) + try: + legs_done = int(out.get("legs_done") or 0) + except (TypeError, ValueError): + legs_done = 0 + try: + dca_legs = int(out.get("dca_legs") or 0) + except (TypeError, ValueError): + dca_legs = 0 + try: + grid = json.loads(out.get("grid_prices_json") or "[]") + if not isinstance(grid, list): + grid = [] + except Exception: + grid = [] + add_prices: list[float] = [] + try: + from lib.strategy.strategy_trend_lib import trend_leg_display_price + + for i in range(1, legs_done + 1): + px = trend_leg_display_price(out, i) + if px is not None: + add_prices.append(float(px)) + except Exception: + pass + if not add_prices: + for x in grid[:legs_done]: + try: + add_prices.append(float(x)) + except (TypeError, ValueError): + pass + sym = out.get("exchange_symbol") or out.get("symbol") or "" + out["add_count"] = legs_done + out["add_count_total"] = dca_legs + out["add_prices"] = add_prices + out["add_prices_display"] = [_format_trend_price(cfg, sym, p) for p in add_prices] + for field in ("stop_loss", "take_profit", "add_upper", "avg_entry_price"): + if out.get(field) not in (None, ""): + out[f"{field}_display"] = _format_trend_price(cfg, sym, out.get(field)) + return out + + +def enrich_trend_plan_for_hub(cfg: dict, raw: dict) -> dict: + """中控 /api/hub/monitor:与策略页运行中计划卡片同字段(浮盈亏,标记价,盈亏比等).""" + d = enrich_trend_plan(cfg, dict(raw or {})) + d["monitor_source"] = "趋势回调计划" + m = _m(cfg) + try: + snap = float(d.get("snapshot_available_usdt") or 0) + margin = float(d.get("plan_margin_capital") or 0) + if snap > 0 and margin > 0: + d["position_ratio_pct"] = round(margin / snap * 100.0, 2) + except (TypeError, ValueError): + pass + return d + + +def _patch_hub_trend_views(app: Flask) -> None: + """将趋势回调路由注册进 HUB_CTX.views,供中控 /api/hub/trend/* 调用.""" + ctx = dict(app.config.get("HUB_CTX") or {}) + views = dict(ctx.get("views") or {}) + for name in ( + "preview_trend_pullback", + "execute_trend_pullback", + "stop_trend_pullback", + "trend_pullback_breakeven", + ): + vf = app.view_functions.get(name) + if vf is not None: + views[name] = vf + ctx["views"] = views + app.config["HUB_CTX"] = ctx + + +def patch_trend_hub_enrich(app: Flask, cfg: dict) -> None: + """hub_bridge install 之后调用:三所 /api/hub/monitor 趋势字段与策略页一致.""" + _patch_hub_monitor_enrich(app, cfg) + + +def _patch_hub_monitor_enrich(app: Flask, cfg: dict) -> None: + ctx = dict(app.config.get("HUB_CTX") or {}) + prev = ctx.get("enrich_monitor") + + def enrich_monitor(keys=None, orders=None, trends=None, rolls=None): + payload: dict[str, Any] = {} + if callable(prev): + try: + prev_out = prev(keys=keys, orders=orders, trends=trends, rolls=rolls) + if isinstance(prev_out, dict): + payload.update(prev_out) + except Exception: + pass + if trends: + payload["trends"] = [ + enrich_trend_plan_for_hub(cfg, t) for t in trends if isinstance(t, dict) + ] + return payload + + ctx["enrich_monitor"] = enrich_monitor + app.config["HUB_CTX"] = ctx + + +def enrich_trend_plan(cfg: dict, row) -> dict: + m = _m(cfg) + d = _row(cfg, 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 m.normalize_exchange_symbol(d.get("symbol") or "") + direction = (d.get("direction") or "long").lower() + metrics_fn = getattr(m, "get_live_position_exchange_metrics", None) + met = None + if callable(metrics_fn): + try: + lev = int(d.get("leverage") or 0) or None + except (TypeError, ValueError): + lev = None + try: + met = metrics_fn(ex_sym, direction, order_leverage=lev) + except TypeError: + met = metrics_fn(ex_sym, direction) + if met and met.get("entry_price") is not None: + try: + live_entry = float(met["entry_price"]) + if live_entry > 0: + d["avg_entry_price"] = live_entry + except (TypeError, ValueError): + pass + if met and met.get("unrealized_pnl") is not None: + d["floating_pnl"] = float(met["unrealized_pnl"]) + elif ( + met + and met.get("mark_price") is not None + and d.get("avg_entry_price") is not None + ): + try: + from lib.hub.hub_position_metrics import estimate_linear_swap_upnl_usdt + + entry = float(d["avg_entry_price"]) + mark = float(met["mark_price"]) + qty = None + cs = 1.0 + get_qty = getattr(m, "get_live_position_contracts", None) + get_cs = getattr(m, "get_contract_size", None) + if callable(get_qty): + qty = get_qty(ex_sym, direction) + if callable(get_cs): + cs = float(get_cs(ex_sym)) + upnl = estimate_linear_swap_upnl_usdt( + direction, entry, mark, qty, cs + ) + d["floating_pnl"] = float(upnl) if upnl is not None else None + except (TypeError, ValueError): + d["floating_pnl"] = None + else: + d["floating_pnl"] = None + if met and met.get("mark_price") is not None: + d["floating_mark"] = float(met["mark_price"]) + else: + d["floating_mark"] = None + else: + d["floating_pnl"] = d["floating_mark"] = None + get_cs = getattr(m, "get_contract_size", None) + if callable(get_cs): + try: + d["contract_size"] = float(get_cs(ex_sym)) + except (TypeError, ValueError): + pass + d = _trend_add_leg_fields(cfg, d) + from lib.strategy.strategy_snapshot_lib import attach_trend_dca_levels + from lib.strategy.strategy_trend_lib import calc_trend_plan_money_metrics + + d = attach_trend_dca_levels(d) + money = calc_trend_plan_money_metrics(d) + if money.get("money_rr") is not None: + d["money_rr"] = money["money_rr"] + d["planned_rr"] = money["money_rr"] + if money.get("risk_amount_u") is not None: + d["risk_amount_u"] = money["risk_amount_u"] + try: + d["breakeven_default_offset_pct"] = float(cfg.get("breakeven_offset_pct", 0.3)) + except (TypeError, ValueError): + d["breakeven_default_offset_pct"] = 0.3 + return d + + +def _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 _plan_stop_status(result_label: str) -> str: + if result_label == "止盈": + return "stopped_tp" + if result_label == "止损": + return "stopped_sl" + return "stopped_manual" + + +def _call_insert_trade_record(m, plan_id: int, kwargs: dict) -> None: + """按各所 insert_trade_record 签名过滤参数,避免未知字段导致记账失败.""" + fn = getattr(m, "insert_trade_record", None) + if not callable(fn): + raise RuntimeError("app_module 缺少 insert_trade_record") + allowed = set(inspect.signature(fn).parameters.keys()) + call = {k: v for k, v in kwargs.items() if k in allowed} + if "trend_plan_id" in allowed: + call["trend_plan_id"] = int(plan_id) + fn(**call) + + +def _best_trend_close_snapshot(conn, plan_id: int) -> dict | None: + from lib.strategy.strategy_snapshot_lib import ( + FINAL_TREND_CLOSE_LABELS, + STRATEGY_TREND, + _final_trend_close_rank, + ) + + rows = conn.execute( + f"""SELECT * FROM strategy_trade_snapshots + WHERE strategy_type=? AND source_id=? + AND result_label IN ({",".join("?" * len(FINAL_TREND_CLOSE_LABELS))})""", + (STRATEGY_TREND, int(plan_id), *FINAL_TREND_CLOSE_LABELS), + ).fetchall() + if not rows: + return None + parsed = [_row_dict(row) for row in rows] + return max( + parsed, + key=lambda d: ( + _final_trend_close_rank(str(d.get("result_label") or "")), + int(d.get("id") or 0), + ), + ) + + +def _ensure_trend_plan_trade_record( + cfg: dict, conn, plan_id: int, *, prefer_label: str = "手动平仓" +) -> bool: + """计划已结束但 trade_records 缺失时,从策略快照补录一条.""" + if _trend_plan_trade_exists(conn, plan_id): + return True + m = _m(cfg) + plan = conn.execute( + "SELECT * FROM trend_pullback_plans WHERE id=?", (int(plan_id),) + ).fetchone() + if not plan: + return False + plan_d = _row_dict(plan) + snap = _best_trend_close_snapshot(conn, plan_id) + if not snap: + return False + try: + payload = json.loads(snap.get("snapshot_json") or "{}") + except Exception: + payload = {} + sym = snap.get("symbol") or plan_d.get("symbol") or payload.get("symbol") + direction = snap.get("direction") or plan_d.get("direction") or "long" + result = (prefer_label or "").strip() or (snap.get("result_label") or "").strip() or "手动平仓" + opened_at = snap.get("opened_at") or plan_d.get("opened_at") + closed_at = snap.get("closed_at") + pnl_amount = snap.get("pnl_amount") + if pnl_amount is None: + pnl_amount = payload.get("pnl_amount") + avg_e = float(payload.get("avg_entry_price") or plan_d.get("avg_entry_price") or 0) + margin_cap = trend_effective_margin_capital(plan_d) + lev = int(plan_d.get("leverage") or 1) + hold_seconds = m.calc_hold_seconds( + opened_at or "", + m.parse_dt_for_trading_day(closed_at) or m.app_now(), + ) + res = m.normalize_result_with_pnl(result, float(pnl_amount or 0)) + risk_amt = m.calc_risk_amount_from_plan( + direction, + float(plan_d.get("add_upper") or 0), + float(plan_d.get("stop_loss") or 0), + float(plan_d.get("plan_margin_capital") or 0), + lev, + ) + planned_rr = m.calc_rr_ratio( + direction, + avg_e, + float(plan_d.get("stop_loss") or 0), + float(plan_d.get("take_profit") or 0), + ) + session_date = plan_d.get("session_date") or m.get_trading_day() + _bump_session_capital_no_commit(m, conn, session_date, float(pnl_amount or 0)) + _call_insert_trade_record( + m, + plan_id, + dict( + conn=conn, + symbol=sym, + monitor_type=MONITOR_TYPE_TREND, + direction=direction, + trigger_price=avg_e, + stop_loss=float(plan_d.get("stop_loss") or 0), + initial_stop_loss=float(plan_d.get("initial_stop_loss") or plan_d.get("stop_loss") or 0), + take_profit=float(plan_d.get("take_profit") or 0), + 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=m.calc_actual_rr(pnl_amount, risk_amt), + result=res, + opened_at=opened_at, + closed_at=closed_at, + entry_reason=ENTRY_REASON_TREND_PULLBACK, + ), + ) + conn.commit() + return True + + +def sync_trend_plans_after_external_close( + cfg: dict, conn, symbol: str, direction: str +) -> dict[str, Any]: + """中控/外部全平后:结束仍 active 的同币种同向趋势计划(避免监控再记一条止损).""" + m = _m(cfg) + sym = m.normalize_symbol_input(symbol) if hasattr(m, "normalize_symbol_input") else (symbol or "").strip() + if not sym: + return {"ok": False, "msg": "symbol 无效", "finalized": 0} + direction = (direction or "long").strip().lower() + rows = conn.execute( + "SELECT * FROM trend_pullback_plans WHERE status='active' AND symbol=? AND direction=?", + (sym, direction), + ).fetchall() + finalized = 0 + for row in rows: + px = m.get_price(row["symbol"]) + exit_p = float(px) if px is not None else 0.0 + before = _trend_plan_trade_exists(conn, int(row["id"])) + _finalize_plan(cfg, conn, row, "手动平仓", exit_p) + if not before: + finalized += 1 + return {"ok": True, "finalized": finalized, "symbol": sym, "direction": direction} + + +def _trend_plan_trade_exists(conn, plan_id: int) -> bool: + try: + return conn.execute( + "SELECT id FROM trade_records WHERE trend_plan_id=? LIMIT 1", + (int(plan_id),), + ).fetchone() is not None + except Exception: + return False + + +def _bump_session_capital_no_commit( + m, conn, session_date: str, pnl_amount: float +) -> float | None: + """更新当日资金,不单独 commit(与 _finalize_plan 同一事务).""" + try: + row = conn.execute( + "SELECT current_capital FROM trading_sessions WHERE session_date = ?", + (session_date,), + ).fetchone() + if not row: + start_cap = float(getattr(m, "DAILY_START_CAPITAL", 0) or 0) + if start_cap <= 0: + ensure = getattr(m, "ensure_session", None) + if callable(ensure): + ensured = ensure(conn, session_date) + row = ensured + else: + return None + else: + conn.execute( + "INSERT OR IGNORE INTO trading_sessions " + "(session_date, start_capital, current_capital) VALUES (?,?,?)", + (session_date, start_cap, start_cap), + ) + row = conn.execute( + "SELECT current_capital FROM trading_sessions WHERE session_date = ?", + (session_date,), + ).fetchone() + if not row: + return None + new_capital = float(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), + ) + return round(new_capital, 4) + except Exception: + return None + + +def _apply_trend_user_risk_close(cfg: dict, conn, *, trade_record_id=None, closed_at_ms=None) -> None: + m = _m(cfg) + fn = getattr(m, "hub_user_initiated_close", None) + from lib.trade.account_risk_lib import CLOSE_SOURCE_USER_TREND_STOP + + if callable(fn): + fn( + conn, + source=CLOSE_SOURCE_USER_TREND_STOP, + count=1, + trade_record_id=trade_record_id, + closed_at_ms=closed_at_ms, + ) + return + from lib.trade.account_risk_lib import on_user_initiated_close + + on_user_initiated_close( + conn, + source=CLOSE_SOURCE_USER_TREND_STOP, + trade_record_id=trade_record_id, + closed_at_ms=closed_at_ms, + trading_day=m.get_trading_day(), + now=m.app_now(), + count=1, + ) + + +def _finalize_plan(cfg: dict, conn, row, result_label: str, exit_price: float, *, user_initiated_risk: bool = False) -> None: + m = _m(cfg) + plan_id = int(row["id"]) + active = conn.execute( + "SELECT * FROM trend_pullback_plans WHERE id=? AND status='active'", + (plan_id,), + ).fetchone() + if not active: + return + row = active + sym = row["symbol"] + direction = row["direction"] or "long" + ex_sym = row["exchange_symbol"] or m.normalize_exchange_symbol(sym) + closed_at = m.app_now_str() + opened_at = row["opened_at"] or closed_at + hold_seconds = m.calc_hold_seconds(opened_at, m.parse_dt_for_trading_day(closed_at) or m.app_now()) + plan_margin = float(row["plan_margin_capital"] or 0) + margin_cap = trend_effective_margin_capital(_row(cfg, row)) + lev = int(row["leverage"] or 1) + avg_e = float(row["avg_entry_price"] or 0) + pnl_amount = m.calc_pnl(direction, avg_e, float(exit_price), margin_cap, lev) + res = m.normalize_result_with_pnl(result_label, pnl_amount) + risk_amt = m.calc_risk_amount_from_plan( + direction, float(row["add_upper"]), float(row["stop_loss"]), plan_margin, lev + ) + try: + target = float(row["target_order_amount"] or 0) + open_amt = float(row["order_amount_open"] or 0) + if risk_amt is not None and target > 0 and open_amt > 0: + risk_amt = round(float(risk_amt) * min(1.0, open_amt / target), 6) + except (TypeError, ValueError): + pass + planned_rr = m.calc_rr_ratio(direction, avg_e, float(row["stop_loss"]), float(row["take_profit"])) + st = _plan_stop_status(result_label) + cur = conn.execute( + "UPDATE trend_pullback_plans SET status=?, message=? WHERE id=? AND status='active'", + (st, res, plan_id), + ) + if not getattr(cur, "rowcount", 0): + return + try: + from lib.strategy.strategy_snapshot_lib import save_trend_plan_snapshot + + save_trend_plan_snapshot( + cfg, + conn, + row, + result_label=result_label, + exit_price=float(exit_price) if exit_price is not None else None, + pnl_amount=float(pnl_amount) if pnl_amount is not None else None, + closed_at=closed_at, + ) + except Exception: + pass + try: + cancel_symbol_orders(cfg, ex_sym) + except Exception: + pass + session_capital = None + trade_record_id = None + if not _trend_plan_trade_exists(conn, plan_id): + session_date = row["session_date"] or m.get_trading_day() + session_capital = _bump_session_capital_no_commit( + m, conn, session_date, pnl_amount + ) + _call_insert_trade_record( + m, + plan_id, + dict( + conn=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.get("initial_stop_loss") or 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=m.calc_actual_rr(pnl_amount, risk_amt), + result=res, + opened_at=opened_at, + closed_at=closed_at, + entry_reason=ENTRY_REASON_TREND_PULLBACK, + ), + ) + try: + from lib.trade.account_risk_lib import insert_trade_record_id + + trade_record_id = insert_trade_record_id(conn) + except Exception: + trade_record_id = None + if user_initiated_risk: + closed_ms = None + to_ms = getattr(m, "_to_ms_with_fallback", None) + if callable(to_ms): + try: + closed_ms = to_ms(None, closed_at) + except Exception: + closed_ms = None + _apply_trend_user_risk_close( + cfg, + conn, + trade_record_id=trade_record_id, + closed_at_ms=closed_ms, + ) + conn.commit() + try: + from lib.strategy.strategy_wechat_notify import notify_trend_plan_ended + + notify_trend_plan_ended( + cfg, + plan_id=plan_id, + symbol=sym, + direction=direction, + end_type=result_label, + result_label=res, + exit_price=float(exit_price) if exit_price is not None else None, + pnl_amount=float(pnl_amount) if pnl_amount is not None else None, + ) + except Exception: + pass + extra = getattr(m, "build_wechat_close_message", None) + send = getattr(m, "send_wechat_msg", None) + if callable(extra) and callable(send): + send( + extra( + 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, + ) + ) + + +def _trend_plan_open_age_sec(row, m) -> float: + opened_ms = None + try: + if "opened_at_ms" in row.keys() and row["opened_at_ms"]: + opened_ms = int(row["opened_at_ms"]) + except Exception: + opened_ms = None + to_ms = getattr(m, "_to_ms_with_fallback", None) + if callable(to_ms): + opened_ms = to_ms(opened_ms, row["opened_at"] if "opened_at" in row.keys() else None) + if opened_ms is None and "opened_at" in row.keys(): + opened_ms = to_ms(None, row["opened_at"]) + if not opened_ms: + return 0.0 + return max(0.0, (time.time() * 1000 - opened_ms) / 1000.0) + + +def _trend_hit_take_profit(direction: str, mark_price: float, take_profit: float, avg_entry: float) -> bool: + try: + pf = float(mark_price) + tp = float(take_profit) + entry = float(avg_entry) + except (TypeError, ValueError): + return False + if entry <= 0 or tp <= 0: + return False + direction = (direction or "long").lower() + if direction == "long": + return tp > entry and pf >= tp + return tp < entry and pf <= tp + + +def _trend_poll_price(m, sym: str, ex_sym: str, direction: str) -> Optional[float]: + """补仓/止盈判定用标记价(与页面「标记价」一致),无标记价时回退 last.""" + fn = getattr(m, "get_symbol_mark_price", None) + if callable(fn): + try: + px = fn(sym) + if px is not None and float(px) > 0: + return float(px) + except Exception: + pass + metrics_fn = getattr(m, "get_live_position_exchange_metrics", None) + if callable(metrics_fn): + try: + met = metrics_fn(ex_sym, direction) + if met and met.get("mark_price") is not None: + px = float(met["mark_price"]) + if px > 0: + return px + except Exception: + pass + px = m.get_price(sym) + try: + return float(px) if px is not None else None + except (TypeError, ValueError): + return None + + +def _should_finalize_trend_flat(row, pos, plan_id: int, m) -> bool: + """首仓后交易所报无仓:需过开仓宽限期 + 连续空仓轮询,避免误判止损.""" + if pos is None: + return False + if float(pos) > 0: + _TREND_FLAT_STREAK.pop(plan_id, None) + return False + if not int(row["first_order_done"] or 0): + return False + age = _trend_plan_open_age_sec(row, m) + if age < TREND_OPEN_GRACE_SEC: + _TREND_FLAT_STREAK.pop(plan_id, None) + return False + try: + local_open = float(row["order_amount_open"] or 0) + except (TypeError, ValueError): + local_open = 0.0 + required = TREND_FLAT_CONFIRM_POLLS + if local_open > 0 and age < TREND_OPEN_GRACE_SEC * 2: + required = max(required, TREND_FLAT_CONFIRM_POLLS * 2) + streak = int(_TREND_FLAT_STREAK.get(plan_id, 0)) + 1 + _TREND_FLAT_STREAK[plan_id] = streak + if streak >= required: + print( + f"[trend_pullback] flat finalize plan={plan_id} sym={row['symbol']} " + f"age={age:.0f}s streak={streak} local_open={local_open}", + flush=True, + ) + return True + return False + + +def check_trend_pullback_plans(cfg: dict) -> None: + m = _m(cfg) + ok_live, live_reason = m.ensure_exchange_live_ready() + _TREND_POLL_STATE["updated_at"] = time.time() + _TREND_POLL_STATE["live_ok"] = ok_live + _TREND_POLL_STATE["live_reason"] = live_reason or "" + if not ok_live: + _log_trend_live_skip(live_reason or "unknown") + conn = cfg["get_db"]() + try: + for row in conn.execute( + "SELECT * FROM trend_pullback_plans WHERE status='active'" + ).fetchall(): + probe = summarize_trend_dca_probe(cfg, row) + if probe.get("trigger_reached"): + _set_trend_poll_plan(int(row["id"]), probe) + except Exception as e: + print(f"[trend_pullback] live-skip probe error: {e}", flush=True) + finally: + conn.close() + return + conn = cfg["get_db"]() + rows = conn.execute( + "SELECT * FROM trend_pullback_plans WHERE status='active'" + ).fetchall() + for row in rows: + try: + plan_id = int(row["id"]) + sym = row["symbol"] + direction = (row["direction"] or "long").lower() + ex_sym = row["exchange_symbol"] or m.normalize_exchange_symbol(sym) + sl = float(row["stop_loss"]) + tp = float(row["take_profit"]) + lev = int(row["leverage"] or 1) + try: + local_open = float(row["order_amount_open"] or 0) + except (TypeError, ValueError): + local_open = 0.0 + pf = _trend_poll_price(m, sym, ex_sym, direction) + if pf is None: + continue + last_p = row["last_mark_price"] + last_pf = float(last_p) if last_p is not None else pf + pos = m.get_live_position_contracts(ex_sym, direction) + if pos is None: + if local_open > 0 and int(row["first_order_done"] or 0): + pos = local_open + else: + continue + elif float(pos) <= 0 and local_open > 0: + age = _trend_plan_open_age_sec(row, m) + if age < TREND_OPEN_GRACE_SEC * 2: + print( + f"[trend_pullback] pos fallback plan={plan_id} sym={sym} " + f"ex_pos=0 local_open={local_open} age={age:.0f}s", + flush=True, + ) + pos = local_open + legs_done = int(row["legs_done"] or 0) + try: + leg_amounts = [float(x) for x in json.loads(row["leg_amounts_json"] or "[]")] + except Exception: + leg_amounts = [] + try: + grid = json.loads(row["grid_prices_json"] or "[]") + except Exception: + grid = [] + avg_e = float(row["avg_entry_price"] or pf or 0) + hit_tp = _trend_hit_take_profit(direction, pf, tp, avg_e) + if hit_tp and pos > 0: + try: + close_resp = trend_market_close(cfg, ex_sym, direction, float(pos), lev) + exit_p = m.extract_trade_price_from_order(close_resp) or pf + except Exception as e: + if not m.is_no_position_error(str(e)): + continue + exit_p = pf + _finalize_plan(cfg, conn, row, "止盈", exit_p) + _TREND_FLAT_STREAK.pop(plan_id, None) + continue + if _should_finalize_trend_flat(row, pos, plan_id, m): + _finalize_plan(cfg, conn, row, "止损", pf) + _TREND_FLAT_STREAK.pop(plan_id, None) + continue + if int(row["first_order_done"] or 0) and legs_done < len(grid) and legs_done < len(leg_amounts): + while legs_done < len(grid) and legs_done < len(leg_amounts): + level = float(grid[legs_done]) + if not trend_dca_level_reached(direction, pf, level): + break + amt = float(m.exchange.amount_to_precision(ex_sym, leg_amounts[legs_done])) + if amt <= 0: + print( + f"[trend_pullback] dca skip plan={plan_id} leg={legs_done + 1} " + f"amt_precision=0 raw={leg_amounts[legs_done]}", + flush=True, + ) + break + try: + add_resp = trend_market_add(cfg, ex_sym, direction, amt, lev) + except Exception as e: + print( + f"[trend_pullback] dca order failed plan={plan_id} sym={sym} " + f"leg={legs_done + 1} level={level} mark={pf} err={e}", + flush=True, + ) + break + fill_px = m.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_avg = _weighted_avg(old_avg, old_open, fill_px, amt) + legs_done += 1 + from lib.strategy.strategy_trend_lib import append_leg_fill_price_json + + fills_json = append_leg_fill_price_json( + row["leg_fill_prices_json"] if "leg_fill_prices_json" in row.keys() else None, + fill_px, + ) + conn.execute( + "UPDATE trend_pullback_plans SET legs_done=?, avg_entry_price=?, " + "order_amount_open=?, last_mark_price=?, leg_fill_prices_json=? WHERE id=?", + (legs_done, new_avg, old_open + amt, pf, fills_json, row["id"]), + ) + row = conn.execute( + "SELECT * FROM trend_pullback_plans WHERE id=?", (row["id"],) + ).fetchone() + print( + f"[trend_pullback] dca filled plan={plan_id} leg={legs_done} " + f"fill={fill_px} avg={new_avg} open={old_open + amt}", + flush=True, + ) + try: + trend_refresh_stop_only(cfg, ex_sym, direction, sl) + except Exception: + pass + conn.execute( + "UPDATE trend_pullback_plans SET last_mark_price=? WHERE id=?", + (pf, row["id"]), + ) + probe = summarize_trend_dca_probe(cfg, row) + probe["last_poll_mark"] = pf + _set_trend_poll_plan(plan_id, probe) + if probe.get("trigger_reached") and probe.get("block_reason"): + print( + f"[trend_pullback] dca blocked plan={plan_id} sym={sym} " + f"mark={pf} next={probe.get('next_trigger')} reason={probe.get('block_reason')}", + flush=True, + ) + except Exception as e: + print( + f"[trend_pullback] poll error plan={row['id'] if row else '?'}: {e}", + flush=True, + ) + continue + conn.commit() + conn.close() + + +TREND_PLAN_STATUS_HANDOFF = "stopped_handoff" + + +def _order_monitor_manual_type(m) -> str: + return getattr(m, "ORDER_MONITOR_TYPE_MANUAL", None) or "下单监控" + + +def _insert_trend_handoff_order_monitor( + cfg: dict, + conn, + plan_row, + *, + new_sl: float, + pos_amt: float, +) -> int: + m = _m(cfg) + sym = plan_row["symbol"] + direction = (plan_row["direction"] or "long").lower() + ex_sym = plan_row["exchange_symbol"] or m.normalize_exchange_symbol(sym) + plan_id = int(plan_row["id"]) + avg_e = float(plan_row["avg_entry_price"] or 0) + tp = float(plan_row["take_profit"] or 0) + lev = int(plan_row["leverage"] or 1) + margin_cap = float(plan_row["plan_margin_capital"] or 0) + init_sl = float( + plan_row["initial_stop_loss"] + if plan_row["initial_stop_loss"] not in (None, "") + else plan_row["stop_loss"] + or 0 + ) + risk_pct = float(plan_row["risk_percent"] or 5) + risk_amt = None + calc_risk = getattr(m, "calc_risk_amount_from_plan", None) + if callable(calc_risk): + try: + risk_amt = calc_risk(direction, avg_e, init_sl, margin_cap, lev) + except Exception: + risk_amt = None + be_rr = float(getattr(m, "BREAKEVEN_RR_TRIGGER", 1) or 1) + be_off = float(getattr(m, "BREAKEVEN_OFFSET_PCT", 0.3) or 0.3) + be_step = float(getattr(m, "BREAKEVEN_STEP_R", 1) or 1) + if direction == "short": + be_price = round(avg_e * (1 - be_off / 100.0), 8) + else: + be_price = round(avg_e * (1 + be_off / 100.0), 8) + rp = getattr(m, "round_price_to_exchange", None) + if callable(rp): + try: + be_price = float(rp(ex_sym, be_price) or be_price) + except Exception: + pass + opened_at = plan_row["opened_at"] or m.app_now_str() + to_ms = getattr(m, "_to_ms_with_fallback", None) + opened_ms = to_ms(plan_row["opened_at_ms"] if "opened_at_ms" in plan_row.keys() else None, opened_at) if callable(to_ms) else None + trading_day = plan_row["session_date"] or getattr(m, "get_trading_day", lambda: None)() + if not trading_day and callable(getattr(m, "get_trading_day", None)): + trading_day = m.get_trading_day() + notional = margin_cap * lev if margin_cap and lev else None + monitor_type = MONITOR_TYPE_TREND_PULLBACK + 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, trend_plan_id) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + sym, + ex_sym, + direction, + avg_e, + new_sl, + init_sl, + tp, + margin_cap, + lev, + "trend_pullback_handoff", + risk_pct, + risk_amt, + be_rr, + be_off, + be_step, + 0, + be_price, + 0, + notional, + None, + None, + float(pos_amt), + "", + opened_at, + opened_ms, + trading_day, + monitor_type, + TREND_HANDOFF_KEY_SIGNAL, + plan_id, + ), + ) + new_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + persist = getattr(m, "try_persist_exchange_margin_for_order", None) + if callable(persist): + try: + persist(conn, new_id, ex_sym, direction, order_leverage=lev) + except Exception: + pass + return new_id + + +def apply_manual_breakeven(cfg: dict, conn, row, offset_pct=None) -> tuple[bool, Optional[str]]: + """保本:结束趋势计划,持仓移交下单监控(备注趋势回调),交易所同时挂保本止损与止盈.""" + m = _m(cfg) + 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() + sym = row["symbol"] + ex_sym = row["exchange_symbol"] or m.normalize_exchange_symbol(sym) + pos = m.get_live_position_contracts(ex_sym, direction) + if pos is None or float(pos) <= 0: + return False, "交易所当前无该方向持仓" + pos_amt = float(pos) + dup = conn.execute( + "SELECT id FROM order_monitors WHERE status='active' AND symbol=? AND direction=? LIMIT 1", + (sym, direction), + ).fetchone() + if dup: + return False, "该币种已有运行中的下单监控,请先结束后再保本移交" + be_fn = getattr(m, "calc_trend_manual_breakeven_stop", None) + if not callable(be_fn): + pct = float(offset_pct if offset_pct is not None else cfg["breakeven_offset_pct"]) + if direction == "short": + new_sl_raw = avg_e * (1.0 - pct / 100.0) + else: + new_sl_raw = avg_e * (1.0 + pct / 100.0) + else: + new_sl_raw = be_fn(direction, avg_e, offset_pct) + if new_sl_raw is None: + return False, "保本价计算失败" + new_sl = m.round_price_to_exchange(ex_sym, new_sl_raw) + if new_sl is None: + return False, "保本价经交易所精度舍入后无效" + new_sl = float(new_sl) + tp = float(row["take_profit"] or 0) + if tp <= 0: + return False, "计划止盈价无效" + 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}(空仓需下移)" + ok_live, live_reason = m.ensure_exchange_live_ready() + if not ok_live: + return False, live_reason or "实盘未就绪" + plan_id = int(row["id"]) + try: + from lib.strategy.strategy_snapshot_lib import save_trend_plan_snapshot + + save_trend_plan_snapshot( + cfg, conn, row, result_label="保本移交", exit_price=None, pnl_amount=None + ) + except Exception: + pass + handoff_row = { + "symbol": sym, + "exchange_symbol": ex_sym, + "direction": direction, + "order_amount": pos_amt, + } + try: + trend_replace_tpsl(cfg, handoff_row, new_sl, tp) + except Exception as e: + fe = getattr(m, "friendly_exchange_error", None) + return False, fe(e) if callable(fe) else str(e) + now_s = m.app_now_str() + _TREND_FLAT_STREAK.pop(plan_id, None) + cur = conn.execute( + "UPDATE trend_pullback_plans SET status=?, message=?, stop_loss=?, " + "breakeven_applied=1, breakeven_applied_at=? WHERE id=? AND status='active'", + ( + TREND_PLAN_STATUS_HANDOFF, + f"保本移交下单监控({TREND_HANDOFF_TRADE_NOTE})", + new_sl, + now_s, + plan_id, + ), + ) + if not getattr(cur, "rowcount", 0): + return False, "计划状态更新失败(可能已被其他操作结束)" + try: + mon_id = _insert_trend_handoff_order_monitor( + cfg, conn, row, new_sl=new_sl, pos_amt=pos_amt + ) + except Exception as e: + conn.execute( + "UPDATE trend_pullback_plans SET status='active', message=? WHERE id=?", + (f"移交下单监控失败:{e}", plan_id), + ) + return False, f"移交下单监控失败:{e}" + pct_used = float( + offset_pct if offset_pct is not None else cfg["breakeven_offset_pct"] + ) + extra = getattr(m, "build_wechat_close_message", None) + send = getattr(m, "send_wechat_msg", None) + pf = getattr(m, "format_price_for_symbol", None) + fmt = (lambda s, p: pf(s, p)) if callable(pf) else (lambda _s, p: str(p)) + try: + from lib.strategy.strategy_wechat_notify import notify_trend_plan_ended + + notify_trend_plan_ended( + cfg, + plan_id=plan_id, + symbol=sym, + direction=direction, + end_type="保本移交", + result_label=TREND_HANDOFF_TRADE_NOTE, + extra=f"已移交下单监控 #{mon_id};止损 {fmt(sym, new_sl)} | 止盈 {fmt(sym, tp)}", + ) + except Exception: + pass + if callable(send): + lines = [ + f"# ✅ {sym} 趋势回调保本移交", + f"- 计划 ID:**{plan_id}** → 下单监控 **#{mon_id}**", + f"- 备注:**{TREND_HANDOFF_TRADE_NOTE}**", + f"- 保本止损:{fmt(sym, new_sl)} | 止盈:{fmt(sym, tp)}", + f"- 交易所:已挂止盈止损;平仓后将写入交易记录({ENTRY_REASON_TREND_PULLBACK})", + ] + wl = getattr(m, "_wechat_account_label", None) + if callable(wl): + lines.insert(1, f"**账户:{wl()}**") + send("\n".join(lines)) + return True, None + + +def load_trend_page_context(conn, request_obj, cfg: dict) -> dict[str, Any]: + m = _m(cfg) + _cleanup_stale_previews(conn) + trend_active = int( + conn.execute( + "SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'" + ).fetchone()[0] + or 0 + ) + trend_plans = [] + trend_dca_probes = [] + raw_plans = conn.execute( + "SELECT * FROM trend_pullback_plans WHERE status='active' ORDER BY id DESC" + ).fetchall() + for r in raw_plans: + try: + enriched = enrich_trend_plan(cfg, r) + trend_plans.append(enriched) + except Exception: + enriched = _row(cfg, r) + trend_plans.append(enriched) + try: + probe = summarize_trend_dca_probe(cfg, r) + trend_dca_probes.append(probe) + if isinstance(enriched, dict): + enriched["dca_probe"] = probe + except Exception: + pass + now = m.app_now() + active_count = m.get_active_position_count(conn) + from lib.trade.daily_open_limit_lib import can_trade_new_open, count_opens_for_trading_day + + trading_day = m.get_trading_day(now) + opens_today = count_opens_for_trading_day(conn, trading_day) + hard_limit = int(getattr(m, "DAILY_OPEN_HARD_LIMIT", 0) or 0) + can_trade_trend = can_trade_new_open( + time_allows=m.trading_day_reset_allows_new_open(now), + active_count=active_count, + max_active_positions=cfg["max_active_positions"], + opens_today=opens_today, + hard_limit=hard_limit, + extra_blocks=trend_active != 0, + ) + trend_preview = None + trend_preview_levels = [] + preview_expires_ms = None + trend_preview_expired = False + pid_arg = (request_obj.args.get("preview_id") or "").strip() + if pid_arg: + pr = conn.execute( + "SELECT * FROM trend_pullback_previews WHERE id=?", (pid_arg,) + ).fetchone() + now_ms = int(time.time() * 1000) + if pr and int(pr["expires_at_ms"] or 0) >= now_ms: + from lib.strategy.strategy_trend_lib import build_trend_preview_level_rows + + trend_preview = _row(cfg, pr) + preview_expires_ms = int(pr["expires_at_ms"]) + get_cs = getattr(m, "get_contract_size", None) + if callable(get_cs) and not trend_preview.get("contract_size"): + try: + trend_preview["contract_size"] = float( + get_cs(trend_preview.get("exchange_symbol") or trend_preview.get("symbol") or "") + ) + except (TypeError, ValueError): + pass + trend_preview, trend_preview_levels = build_trend_preview_level_rows(trend_preview) + elif pr: + trend_preview_expired = True + return { + "trend_plans": trend_plans, + "trend_dca_probes": trend_dca_probes, + "trend_active": trend_active, + "can_trade_trend": can_trade_trend, + "trend_preview": trend_preview, + "trend_preview_levels": trend_preview_levels, + "preview_expires_ms": preview_expires_ms, + "trend_preview_expired": trend_preview_expired, + "trend_pullback_dca_legs": cfg["dca_legs"], + "trend_pullback_preview_ttl": cfg["preview_ttl"], + "trend_preview_max_drift_pct": cfg["drift_pct"], + "trend_manual_breakeven_offset_pct": cfg["breakeven_offset_pct"], + } + + +def register_trend_routes(app: Flask, cfg: dict) -> None: + lr = cfg["login_required"] + get_db = cfg["get_db"] + + def _redirect_trend(**kw): + return redirect(url_for("strategy_trading_page", **kw)) + + @app.route("/preview_trend_pullback", methods=["POST"]) + @lr + def preview_trend_pullback(): + conn = get_db() + init_strategy_tables(conn) + m = _m(cfg) + payload, err = parse_trend_plan(cfg, request.form) + if err: + conn.close() + flash(err) + return _redirect_trend() + okp, msg = precheck_trend_start( + cfg, + conn, + symbol=str(payload.get("symbol") or ""), + direction=str(payload.get("direction") or "long"), + ) + if not okp: + conn.close() + flash(msg) + return _redirect_trend() + ok_live, reason = m.ensure_exchange_live_ready() + if not ok_live: + conn.close() + flash(reason) + return _redirect_trend() + pid = str(uuid.uuid4()) + exp_ms = int(time.time() * 1000) + cfg["preview_ttl"] * 1000 + created = m.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_preview_snapshot(conn, pid, created, exp_ms, payload) + conn.commit() + conn.close() + flash(f"预览已生成,有效期 {cfg['preview_ttl']} 秒,请核对后点击「确认执行」.") + return _redirect_trend(preview_id=pid) + + @app.route("/execute_trend_pullback", methods=["POST"]) + @lr + def execute_trend_pullback(): + pid = (request.form.get("preview_id") or "").strip() + if not pid: + flash("缺少预览 ID") + return _redirect_trend() + conn = get_db() + init_strategy_tables(conn) + _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_trend() + okp, msg = precheck_trend_start( + cfg, + conn, + symbol=str(pr["symbol"] or ""), + direction=str(pr["direction"] or "long"), + ) + if not okp: + conn.close() + flash(msg) + return _redirect_trend(preview_id=pid) + m = _m(cfg) + ok_live, reason = m.ensure_exchange_live_ready() + if not ok_live: + conn.close() + flash(reason) + return _redirect_trend(preview_id=pid) + snap_prev = float(pr["snapshot_available_usdt"] or 0) + snap_now = m.get_available_trading_usdt() + if snap_now is None or snap_now <= 0: + conn.close() + flash("无法读取当前合约可用余额,请稍后重试") + return _redirect_trend(preview_id=pid) + drift = abs(float(snap_now) - snap_prev) / max(snap_prev, 1e-9) * 100.0 + if drift > cfg["drift_pct"]: + conn.close() + flash( + f"当前可用余额与预览快照偏差 {drift:.2f}%,超过允许 {cfg['drift_pct']}%,请重新生成预览" + ) + return _redirect_trend(preview_id=pid) + 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"]) + first_amt = float(pr["first_order_amount"] or 0) + live_price = m.get_price(symbol) + if live_price is None: + conn.close() + flash("获取实时价格失败") + return _redirect_trend(preview_id=pid) + try: + o1 = m.place_exchange_order( + exchange_symbol, direction, first_amt, leverage, stop_loss=None, take_profit=None + ) + fill1 = m.resolve_order_entry_price(o1, exchange_symbol, live_price) + try: + trend_refresh_stop_only(cfg, exchange_symbol, direction, stop_loss) + except Exception as sl_err: + from lib.strategy.strategy_trend_exchange import cancel_symbol_orders, trend_market_close + + try: + pos_qty = m.get_live_position_contracts(exchange_symbol, direction) or first_amt + trend_market_close(cfg, exchange_symbol, direction, float(pos_qty), leverage) + cancel_symbol_orders(cfg, exchange_symbol) + except Exception as close_err: + print(f"[trend_start] compensating close failed: {close_err}", flush=True) + raise sl_err + except Exception as e: + conn.close() + fe = getattr(m, "friendly_exchange_error", lambda x, **k: str(x)) + flash(fe(e, available_usdt=snap_now)) + return _redirect_trend(preview_id=pid) + trading_day = m.get_trading_day(m.app_now()) + opened_at = m.app_now_str() + opened_ms = getattr(m, "_to_ms_with_fallback", lambda a, b: None)(None, opened_at) + from lib.strategy.strategy_trend_lib import append_leg_fill_price_json + + fills_json = append_leg_fill_price_json(None, fill1) + 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,leg_fill_prices_json + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + "active", + symbol, + exchange_symbol, + direction, + leverage, + stop_loss, + stop_loss, + float(pr["add_upper"]), + float(pr["take_profit"]), + float(pr["risk_percent"] or 5), + float(snap_now), + opened_at, + float(pr["plan_margin_capital"] or 0), + float(pr["target_order_amount"] or 0), + first_amt, + float(pr["remainder_total"] or 0), + int(pr["dca_legs"] or 0), + float(pr["per_leg_amount"] or 0), + pr["grid_prices_json"] or "[]", + pr["leg_amounts_json"] or "[]", + 0, + 1, + float(live_price), + fill1, + first_amt, + opened_at, + opened_ms, + trading_day, + f"预览ID:{pid[:8]}…", + fills_json, + ), + ) + new_id = int(cur.lastrowid) + conn.execute( + "UPDATE trend_pullback_preview_snapshots SET outcome='executed', executed_plan_id=? WHERE preview_id=?", + (new_id, pid), + ) + conn.execute("DELETE FROM trend_pullback_previews WHERE id=?", (pid,)) + conn.commit() + try: + from lib.strategy.strategy_wechat_notify import notify_trend_plan_started + + notify_trend_plan_started( + cfg, + plan_id=new_id, + symbol=symbol, + direction=direction, + leverage=leverage, + stop_loss=stop_loss, + take_profit=float(pr["take_profit"]), + add_upper=float(pr["add_upper"]), + risk_percent=float(pr["risk_percent"] or 5), + dca_legs=int(pr["dca_legs"] or 0), + first_order_amount=first_amt, + avg_entry=fill1, + snapshot_usdt=float(snap_now), + ) + except Exception: + pass + conn.close() + flash("趋势回调已执行:首仓已成交并挂交易所止损,止盈由程序监控.") + return _redirect_trend() + + @app.route("/cancel_trend_pullback_preview", methods=["POST"]) + @lr + 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_trend() + + @app.route("/trend_pullback_breakeven/", methods=["POST"]) + @lr + def trend_pullback_breakeven(pid: int): + offset_pct = None + raw = (request.form.get("breakeven_offset_pct") or "").strip() + if raw: + try: + offset_pct = float(raw) + if offset_pct < 0: + raise ValueError + except ValueError: + flash("保本偏移% 格式无效") + return _redirect_trend() + 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_trend() + ok, err = apply_manual_breakeven(cfg, conn, row, offset_pct=offset_pct) + conn.commit() + conn.close() + flash( + "已保本:趋势计划已结束,持仓已移交下单监控并挂止盈止损;平仓后将写入交易记录" + if ok + else (err or "保本移交失败") + ) + return _redirect_trend() + + @app.route("/stop_trend_pullback/") + @lr + def stop_trend_pullback(pid: int): + conn = get_db() + row = conn.execute( + "SELECT * FROM trend_pullback_plans WHERE id=? AND status='active'", (pid,) + ).fetchone() + if not row: + stopped = conn.execute( + "SELECT * FROM trend_pullback_plans WHERE id=? " + "AND status IN ('stopped_sl','stopped_tp','stopped_manual')", + (pid,), + ).fetchone() + if stopped and not _trend_plan_trade_exists(conn, pid): + try: + if _ensure_trend_plan_trade_record(cfg, conn, pid, prefer_label="手动平仓"): + conn.close() + flash("计划已结束,已补录缺失的交易记录") + return _redirect_trend() + except Exception as e: + conn.close() + flash(f"补录交易记录失败:{e}") + return _redirect_trend() + conn.close() + flash("未找到运行中的趋势回调计划") + return _redirect_trend() + m = _m(cfg) + ex_sym = row["exchange_symbol"] or m.normalize_exchange_symbol(row["symbol"]) + direction = row["direction"] or "long" + lev = int(row["leverage"] or 1) + px = m.get_price(row["symbol"]) + exit_p = float(px) if px is not None else 0.0 + ok_live, _ = m.ensure_exchange_live_ready() + if ok_live: + pos = m.get_live_position_contracts(ex_sym, direction) + if pos is not None and pos > 0: + try: + close_resp = trend_market_close(cfg, ex_sym, direction, float(pos), lev) + ep = m.extract_trade_price_from_order(close_resp) + if ep: + exit_p = float(ep) + except Exception as e: + if not m.is_no_position_error(str(e)): + conn.close() + flash(f"平仓失败:{e}") + return _redirect_trend() + try: + cancel_symbol_orders(cfg, ex_sym) + except Exception: + pass + try: + _finalize_plan(cfg, conn, row, "手动平仓", exit_p, user_initiated_risk=True) + except Exception as e: + conn.execute( + "UPDATE trend_pullback_plans SET status='stopped_manual', message=? " + "WHERE id=? AND status='active'", + (f"结束异常:{e}", pid), + ) + conn.commit() + conn.close() + flash(f"计划已结束但记账可能不完整:{e}") + return _redirect_trend() + conn.close() + flash("已结束趋势回调计划") + return _redirect_trend() diff --git a/lib/strategy/strategy_ui.py b/lib/strategy/strategy_ui.py new file mode 100644 index 0000000..c6a7081 --- /dev/null +++ b/lib/strategy/strategy_ui.py @@ -0,0 +1,143 @@ +"""策略交易页:主站 index.html 所需数据(顺势加仓等).""" +from __future__ import annotations + +from typing import Any, Callable, Optional + +from lib.strategy.strategy_db import init_strategy_tables +from lib.strategy.strategy_roll_monitor_lib import roll_leg_status_label + + +def _row_to_dict(row) -> dict: + if row is None: + return {} + try: + return dict(row) + except Exception: + return {} + + +def count_active_trend_plans(conn, count_fn: Optional[Callable] = None) -> int: + if callable(count_fn): + return int(count_fn(conn) or 0) + try: + return int( + conn.execute( + "SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'" + ).fetchone()[0] + ) + except Exception: + return 0 + + +def fetch_roll_page_data( + conn, + *, + default_risk_percent: float = 2.0, + count_active_trends: Optional[Callable] = None, + roll_cfg: dict | None = None, +) -> dict[str, Any]: + init_strategy_tables(conn) + monitors = [] + for row in conn.execute( + "SELECT * FROM order_monitors WHERE status='active' ORDER BY id DESC" + ).fetchall(): + monitors.append(_row_to_dict(row)) + roll_groups = [] + for row in conn.execute( + """SELECT g.* FROM roll_groups g + INNER JOIN order_monitors m ON m.id = g.order_monitor_id AND m.status='active' + WHERE g.status='active' + ORDER BY g.id DESC""" + ).fetchall(): + roll_groups.append(_row_to_dict(row)) + active_gids = {int(g["id"]) for g in roll_groups if g.get("id") is not None} + roll_legs = [] + for row in conn.execute( + "SELECT * FROM roll_legs ORDER BY id DESC LIMIT 80" + ).fetchall(): + leg = _row_to_dict(row) + gid = leg.get("roll_group_id") + if gid is not None and int(gid) not in active_gids: + continue + leg["status_label"] = roll_leg_status_label(leg.get("status")) + roll_legs.append(leg) + roll_legs = roll_legs[:50] + out = { + "roll_monitors": monitors, + "roll_groups": roll_groups, + "roll_legs": roll_legs, + "roll_trend_active": count_active_trend_plans(conn, count_active_trends), + "default_risk_percent": default_risk_percent, + } + if roll_cfg: + from lib.strategy.strategy_roll_ui_lib import enrich_roll_page_data + + enrich_roll_page_data(conn, out, roll_cfg) + return out + + +DEFAULT_TREND_DISABLED_NOTE = ( + "趋势回调(预览,自动补仓,程序止盈)须在本实例 .env 设置 " + "`LIVE_TRADING_ENABLED=true` 并重启对应 PM2 进程(如 crypto_gate / crypto_okx / crypto_binance)." +) + + +def strategy_render_extras( + conn, + page: str, + *, + default_risk_percent: float = 2.0, + count_active_trends: Optional[Callable] = None, + trend_disabled_note: str = "", + request_obj=None, + trend_cfg: Optional[dict] = None, +) -> dict[str, Any]: + """render_main_page 策略相关页变量(含策略交易记录).""" + if page == "strategy_records": + from lib.strategy.strategy_records_register import load_strategy_records_page + + return load_strategy_records_page(conn) + return strategy_page_template_vars( + conn, + page, + default_risk_percent=default_risk_percent, + count_active_trends=count_active_trends, + trend_disabled_note=trend_disabled_note, + request_obj=request_obj, + trend_cfg=trend_cfg, + ) + + +def strategy_page_template_vars( + conn, + page: str, + *, + default_risk_percent: float = 2.0, + count_active_trends: Optional[Callable] = None, + trend_disabled_note: str = "", + request_obj=None, + trend_cfg: Optional[dict] = None, +) -> dict[str, Any]: + """render_main_page 在 conn.close() 前合并进 render_template 的变量.""" + if page not in ("strategy", "strategy_trend", "strategy_roll"): + return {} + roll_cfg = None + try: + from flask import current_app + + roll_cfg = (current_app.extensions or {}).get("strategy_roll_cfg") + except Exception: + roll_cfg = None + out = fetch_roll_page_data( + conn, + default_risk_percent=default_risk_percent, + count_active_trends=count_active_trends, + roll_cfg=roll_cfg if isinstance(roll_cfg, dict) else None, + ) + if trend_cfg and request_obj is not None: + from lib.strategy.strategy_trend_register import load_trend_page_context + + out.update(load_trend_page_context(conn, request_obj, trend_cfg)) + elif page == "strategy_trend": + out["trend_disabled_note"] = trend_disabled_note or DEFAULT_TREND_DISABLED_NOTE + return out diff --git a/lib/strategy/strategy_wechat_notify.py b/lib/strategy/strategy_wechat_notify.py new file mode 100644 index 0000000..5c45190 --- /dev/null +++ b/lib/strategy/strategy_wechat_notify.py @@ -0,0 +1,192 @@ +"""策略计划(趋势回调 / 滚仓)开始与结束 — 企业微信推送(三所共用).""" +from __future__ import annotations + +from typing import Any, Optional + +from lib.common.wechat_notify_lib import wechat_direction_label + + +def _send(cfg: dict[str, Any], content: str) -> None: + fn = cfg.get("send_wechat") + if callable(fn): + try: + fn(content) + return + except Exception: + pass + m = cfg.get("app_module") + if m is not None: + sw = getattr(m, "send_wechat_msg", None) + if callable(sw): + try: + sw(content) + except Exception: + pass + + +def _account(cfg: dict[str, Any]) -> str: + fn = cfg.get("wechat_account_label") + if callable(fn): + try: + return str(fn()).strip() or _exchange(cfg) + except Exception: + pass + return _exchange(cfg) + + +def _exchange(cfg: dict[str, Any]) -> str: + return str(cfg.get("exchange_display") or "").strip() or "交易账户" + + +def _dir_text(cfg: dict[str, Any], direction: str) -> str: + fn = cfg.get("wechat_direction_text") + if callable(fn): + try: + return str(fn(direction)) + except Exception: + pass + return wechat_direction_label(direction) + + +def _fmt_price(cfg: dict[str, Any], symbol: str, price: Any) -> str: + if price is None or price == "": + return "—" + fn = cfg.get("format_price") or cfg.get("price_fmt") + if callable(fn): + try: + return str(fn(symbol, price)) + except Exception: + pass + m = cfg.get("app_module") + pf = getattr(m, "format_price_for_symbol", None) if m else None + if callable(pf): + try: + return str(pf(symbol, price)) + except Exception: + pass + try: + return str(round(float(price), 8)) + except (TypeError, ValueError): + return str(price) + + +def _fmt_pnl(pnl: Any) -> str: + if pnl is None: + return "—" + try: + v = float(pnl) + return f"{'+' if v > 0 else ''}{round(v, 2)} U" + except (TypeError, ValueError): + return str(pnl) + + +def notify_trend_plan_started( + cfg: dict[str, Any], + *, + plan_id: int, + symbol: str, + direction: str, + leverage: int, + stop_loss: float, + take_profit: float, + add_upper: float, + risk_percent: float, + dca_legs: int, + first_order_amount: float, + avg_entry: Optional[float] = None, + snapshot_usdt: Optional[float] = None, +) -> None: + sym = symbol or "—" + lines = [ + f"# 🚀 {sym} 趋势回调计划已开始", + f"**账户:{_account(cfg)}**", + f"- 计划 ID:**{plan_id}**", + f"- 方向:{_dir_text(cfg, direction)}|杠杆 **{int(leverage or 1)}x**", + f"- 止损:{_fmt_price(cfg, sym, stop_loss)}|止盈:{_fmt_price(cfg, sym, take_profit)}", + f"- 补仓区:{_fmt_price(cfg, sym, add_upper)}|补仓档 **{int(dca_legs or 0)}** 档", + f"- 风险:**{risk_percent}%**|首仓张数:**{first_order_amount}**", + ] + if avg_entry is not None: + lines.append(f"- 首仓成交价:{_fmt_price(cfg, sym, avg_entry)}") + if snapshot_usdt is not None: + try: + lines.append(f"- 启动时合约可用:**{round(float(snapshot_usdt), 2)} U**") + except (TypeError, ValueError): + pass + lines.append("- 说明:交易所已挂止损;止盈由程序监控;结束/保本将另行推送") + _send(cfg, "\n".join(lines)) + + +def notify_trend_plan_ended( + cfg: dict[str, Any], + *, + plan_id: int, + symbol: str, + direction: str, + end_type: str, + result_label: Optional[str] = None, + exit_price: Optional[float] = None, + pnl_amount: Optional[float] = None, + extra: Optional[str] = None, +) -> None: + sym = symbol or "—" + res = (result_label or end_type or "—").strip() + lines = [ + f"# 🏁 {sym} 趋势回调计划已结束", + f"**账户:{_account(cfg)}**", + f"- 计划 ID:**{plan_id}**", + f"- 方向:{_dir_text(cfg, direction)}", + f"- 结束方式:**{end_type}**", + f"- 结果:**{res}**", + ] + if exit_price is not None: + lines.append(f"- 离场参考价:{_fmt_price(cfg, sym, exit_price)}") + if pnl_amount is not None: + lines.append(f"- 本单盈亏:**{_fmt_pnl(pnl_amount)}**") + if extra: + lines.append(f"- {extra}") + _send(cfg, "\n".join(lines)) + + +def notify_roll_group_started( + cfg: dict[str, Any], + *, + group_id: int, + symbol: str, + direction: str, + order_monitor_id: int, + initial_take_profit: Optional[float] = None, + initial_stop_loss: Optional[float] = None, +) -> None: + sym = symbol or "—" + lines = [ + f"# 🚀 {sym} 滚仓计划已开始", + f"**账户:{_account(cfg)}**", + f"- 滚仓组 ID:**{group_id}**|绑定下单监控 **#{order_monitor_id}**", + f"- 方向:{_dir_text(cfg, direction)}", + f"- 首仓止盈(锁定):{_fmt_price(cfg, sym, initial_take_profit)}", + f"- 当前止损:{_fmt_price(cfg, sym, initial_stop_loss)}", + "- 说明:顺势加仓为人工触发;组结束(无持仓/监控结案)将另行推送", + ] + _send(cfg, "\n".join(lines)) + + +def notify_roll_group_ended( + cfg: dict[str, Any], + *, + group_id: int, + symbol: str, + direction: str, + reason: str, + leg_count: int = 0, +) -> None: + sym = symbol or "—" + lines = [ + f"# 🏁 {sym} 滚仓计划已结束", + f"**账户:{_account(cfg)}**", + f"- 滚仓组 ID:**{group_id}**", + f"- 方向:{_dir_text(cfg, direction)}", + f"- 结束原因:**{reason}**", + f"- 已完成滚仓腿数:**{int(leg_count or 0)}**", + ] + _send(cfg, "\n".join(lines)) diff --git a/lib/strategy/templates/gate_transfer_block.html b/lib/strategy/templates/gate_transfer_block.html new file mode 100644 index 0000000..002009f --- /dev/null +++ b/lib/strategy/templates/gate_transfer_block.html @@ -0,0 +1,23 @@ +
        + + 实时价格更新:--(北京时间 UTC+8) + · 划转规则 + +
        + 划转:自动划转 {{ '开启' if auto_transfer_enabled else '关闭' }}(每天北京时间 {{ auto_transfer_bj_hour }}:00起该整点小时内尝试;账簿按 UTC 自然日去重;将 {{ auto_transfer_to }} 调整至 {{ transfer_amount_fmt|default(funds_fmt(auto_transfer_amount)) }}U:不足从 {{ auto_transfer_from }} 划入,超出划回 {{ auto_transfer_from }};持仓中不划转并微信通知) +
        +
        +
        + + + + +
        diff --git a/lib/strategy/templates/journal_form_fields.html b/lib/strategy/templates/journal_form_fields.html new file mode 100644 index 0000000..a0746a0 --- /dev/null +++ b/lib/strategy/templates/journal_form_fields.html @@ -0,0 +1,44 @@ +{# 复盘表单:首行按字段宽度比例;下单类型/开仓类型与离场触发同一行 #} +{% macro journal_form_fields(entry_reason_options, order_type_options) -%} +
        + + + + + + + +
        +
        + + + + + + +
        +{%- endmacro %} diff --git a/lib/strategy/templates/journal_upload_slots.html b/lib/strategy/templates/journal_upload_slots.html new file mode 100644 index 0000000..b99e470 --- /dev/null +++ b/lib/strategy/templates/journal_upload_slots.html @@ -0,0 +1,20 @@ +{# 复盘四周期截图槽位(须加载 journal_upload_slots.js) #} +{% macro journal_upload_slots() -%} + +
        + {% for tf in ['5m', '15m', '1h', '4h'] %} +
        + {{ tf }} + + + +
        + {% endfor %} +
        +

        可只传部分周期;选文件后即时上传,保存后详情页四宫格查看

        +{%- endmacro %} diff --git a/lib/strategy/templates/key_focus_v2.html b/lib/strategy/templates/key_focus_v2.html new file mode 100644 index 0000000..3110112 --- /dev/null +++ b/lib/strategy/templates/key_focus_v2.html @@ -0,0 +1,182 @@ + + + + + + {{ exchange_display }} | 关键位放大 + + + + + +{% if trade_policy is not defined %} +{% set trade_policy = {'symbol_restrict_enabled': false, 'direction_restrict_enabled': false, 'symbol_whitelist': [], 'allows_long': true, 'allows_short': true, 'badge_text': ''} %} +{% endif %} +
        +
        +
        +
        + + +
        +
        + 返回首页 + 关键位放大{% if trade_policy.symbol_restrict_enabled %}(选择币种){% else %}(可输入币种){% endif %}{{ exchange_display }} +
        +
        最近刷新:--
        +
        +
        + + {% from 'trade_policy_fields.html' import trade_policy_symbol with context %} + {{ trade_policy_symbol('symbol', 'symbol-input', default_symbol, placeholder='BTC/USDT') }} + {% from 'symbol_live_price_snippet.html' import symbol_live_price_hint %} + {{ symbol_live_price_hint('key-focus-symbol-live-price', 'symbol-input') }} + + + + + + + + +
        +
        + +
        +
        +
        交易对
        -
        +
        监控类型
        -
        +
        方向
        -
        +
        上沿/阻力
        -
        +
        下沿/支撑
        -
        +
        现价
        -
        +
        距上沿
        -
        +
        距下沿
        -
        +
        +
        + +
        +
        + + + + + + diff --git a/lib/strategy/templates/key_monitor_panel.html b/lib/strategy/templates/key_monitor_panel.html new file mode 100644 index 0000000..9e8be2f --- /dev/null +++ b/lib/strategy/templates/key_monitor_panel.html @@ -0,0 +1,330 @@ + + +{% macro key_monitor_type_label(k) -%} +{%- if k.monitor_type in ['关键阻力位','关键支撑位','关键支撑阻力'] -%}关键支撑阻力{%- else -%}{{ k.monitor_type }}{%- endif -%} +{%- endmacro %} + +{% macro key_direction_label(k) -%} +{% if k.direction == 'watch' %}双向{% elif k.direction == 'long' %}做多{% else %}做空{% endif %} +{%- endmacro %} + +{% macro key_sl_tp_mode_label(k) -%} +{% if (k.sl_tp_mode or 'standard') == 'standard' %}标准突破{% elif k.sl_tp_mode == 'box_1p5' %}箱体1R·止盈1.5H{% else %}趋势单{% endif %} +{%- endmacro %} + +{% macro key_monitor_brief(k) -%} +上{{ k.upper }} / 下{{ k.lower }} · 提醒 {{ k.notification_count or 0 }}/{{ k.max_notify or 3 }} +{%- if k.monitor_type in ['箱体突破','收敛突破'] %} · {{ key_sl_tp_mode_label(k) }}{% endif %} +{%- if k.breakeven_enabled %} · 保本开{% else %} · 保本关{% endif %} +{%- endmacro %} + +{% macro key_history_outcome_kind(h) -%} +{%- set r = (h.close_reason or '')|trim -%} +{%- if r in ['fib_filled', 'false_breakout_filled', 'trigger_entry_filled', 'key_level_alert_done', 'alerts_complete', 'auto_opened'] -%}success +{%- elif r == 'manual' -%}manual +{%- elif r -%}failed +{%- else -%}neutral +{%- endif -%} +{%- endmacro %} + +{% macro key_history_outcome_label(h) -%} +{%- set r = (h.close_reason or '')|trim -%} +{%- if r == 'fib_filled' -%}斐波成交 +{%- elif r == 'false_breakout_filled' -%}假突破成交 +{%- elif r == 'trigger_entry_filled' -%}触价成交 +{%- elif r == 'key_level_alert_done' -%}提醒完成 +{%- elif r == 'alerts_complete' -%}提醒已满 +{%- elif r == 'auto_opened' -%}自动开仓 +{%- elif r == 'manual' -%}手动删除 +{%- elif r == 'fib_invalidate' -%}斐波失效 +{%- elif r == 'box_opposite_break' -%}反向突破失效 +{%- elif r == 'trigger_tp_invalidate' -%}触价止盈失效 +{%- elif r == 'trigger_sl_invalidate' -%}触价止损失效 +{%- elif r == 'trigger_entry_expired' -%}触价过期 +{%- elif r == 'trigger_exchange_failed' -%}触价下单失败 +{%- elif r == 'false_breakout_expired' -%}假突破过期 +{%- elif r == 'fib_plan_invalid' -%}计划无效 +{%- elif r == 'rr_insufficient' -%}盈亏比不足 +{%- elif r == 'exchange_failed' -%}下单失败 +{%- else -%}{{ r or '—' }} +{%- endif -%} +{%- endmacro %} + +{% macro key_history_brief(h) -%} +{{ key_history_outcome_label(h) }} · {{ (h.closed_at or '-')[:16] }} · 上{{ h.upper }} / 下{{ h.lower }} · 提醒 {{ h.notification_count or 0 }} +{%- endmacro %} + +
        +
        +
        +

        关键位监控

        + {% if focus_key_id %} + 放大查看K线(默认200根) + {% else %} + 输入币种查看K线 + {% endif %} +
        +
        + {% from 'trade_policy_fields.html' import trade_policy_symbol, trade_policy_direction with context %} + {{ trade_policy_symbol('symbol', 'key-symbol') }} + + {{ trade_policy_direction('direction', 'key-direction') }} + {% from 'symbol_live_price_snippet.html' import symbol_live_price_hint %} + {{ symbol_live_price_hint('key-symbol-live-price', 'key-symbol', 'key-direction') }} + + + + + + + + + + + + + + +
        +
        + 关键位监控规则说明 +
        + {% include 'key_monitor_rule_tips.html' %} +
        +
        +
        + {% for k in key %} +
        + + + + {{ k.symbol }} + {% if k.time_close_enabled and k.time_close_hours %} + 时间平仓 {{ k.time_close_hours }}h + {% endif %} + {% if k.direction == 'watch' %} + 双向 + {% else %} + {{ key_direction_label(k) }} + {% endif %} + {{ key_monitor_type_label(k) }} + + 现价 — · 门控 — + + + + + +
        +
        {{ key_monitor_brief(k) }}
        +
        + 上沿: {{ k.upper }} + 下沿: {{ k.lower }} + {% if k.fib_entry_price and k.monitor_type in ['回调触价开仓','突破触价开仓','触价开仓'] %}E: {{ k.fib_entry_price }} / SL: {{ k.fib_stop_loss }} / TP: {{ k.fib_take_profit }}{% elif k.fib_entry_price %}挂E: {{ k.fib_entry_price }}{% endif %} + {% if k.monitor_type == '假突破' and k.fib_stop_loss %}SL: {{ k.fib_stop_loss }} / TP: {{ k.fib_take_profit }}{% endif %} + 已提醒: {{ k.notification_count or 0 }}/{{ k.max_notify or 3 }} + {% if k.monitor_type in ['箱体突破','收敛突破'] %} + 方案: {{ key_sl_tp_mode_label(k) }} + {% endif %} + 保本: {{ '开' if k.breakeven_enabled else '关' }} +
        +
        +
        现价-
        +
        距上沿-
        +
        距下沿-
        +
        门控-
        +
        +
        +
        +
        + {% else %} +
        暂无监控中的关键位
        + {% endfor %} +
        +
        +
        +

        关键位历史

        +
        失效或已结案的关键位 · 点击展开详情
        +
        + {% for h in key_history %} +
        + + + + {{ h.symbol }} + {{ key_direction_label(h) }} + {{ key_monitor_type_label(h) }} + {{ key_history_outcome_label(h) }} + + + + + + +
        +
        {{ key_history_brief(h) }}
        +
        + 类型: {{ key_monitor_type_label(h) }} + 结案: {{ key_history_outcome_label(h) }}{% if h.close_reason %} ({{ h.close_reason }}){% endif %} + 时间: {{ h.closed_at or '—' }} +
        +
        + 上沿: {{ h.upper }} + 下沿: {{ h.lower }} + 提醒次数: {{ h.notification_count or 0 }} +
        + {% if h.last_alert_message %} +
        {{ h.last_alert_message }}
        + {% endif %} +
        +
        + {% else %} +
        暂无历史
        + {% endfor %} +
        +
        +
        + + diff --git a/lib/strategy/templates/key_monitor_rule_tips.html b/lib/strategy/templates/key_monitor_rule_tips.html new file mode 100644 index 0000000..b652431 --- /dev/null +++ b/lib/strategy/templates/key_monitor_rule_tips.html @@ -0,0 +1,59 @@ +{% set r = key_rule_ctx %} +
        + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
        类型填写门控止盈止损执行
        箱体突破
        收敛突破
        方向必选;填 H/L
        方案:标准 / 1R·1.5H / 趋势
        可勾移动保本
        {{ r.tf }} 两根闭合 K({{ r.breakout_bar }}/{{ r.confirm_bar }})
        突破 >{{ r.amp_min_pct }}%;确认在箱外
        量 >前{{ r.vol_ma_bars }}均×{{ r.vol_ratio_min }}
        成交 Top{{ r.vol_rank_max }};RR >{{ r.min_rr }}
        标记价先破反向边界→失效
        标准:SL 极值外{{ r.stop_outside_pct }}%,TP=E±H
        1R:SL=E∓H,TP=E∓1.5H
        趋势:SL 极值外{{ r.trend_stop_outside_pct }}%,TP 自填
        门控过→市价开仓→下单监控
        满仓不可再加
        斐波回调
        0.618 / 0.786
        方向 + H/L 波段
        系统算 E/SL/TP
        多:E=H−rΔ,SL=L,TP=H
        空:E=L+rΔ,SL=H,TP=L
        RR >{{ r.min_rr }};先触 TP 侧失效
        公式固定 SL/TP
        成交后挂所
        挂限价等成交
        成交→下单监控
        假突破
        BTC / ETH
        空填高点 / 多填低点
        同币仅 1 条
        外侧 {{ r.fb_offset_pct }}% 限价
        SL {{ r.fb_sl_pct }}%;RR {{ r.fb_rr }}
        有效 {{ r.fb_valid_hours }}h
        自动 E/SL/TP
        可保本
        即挂限价
        成交/过期→历史
        回调触价开仓方向 + 入场 E / 止损 SL / 止盈 TP
        可勾移动保本,时间平仓
        RR >{{ r.min_rr }};做多 SL<E<TP
        标记价回调触 E(多≤E / 空≥E)后下一轮询市价开
        先触 TP 侧失效;有效 {{ r.trigger_entry_validity_hours }}h
        程序盯价,无交易所挂单
        成交后挂所 TP/SL → 下单监控
        占当日开仓意图
        全仓模式可用
        突破触价开仓方向 + 突破价 E / 止损 SL / 止盈 TP
        可勾移动保本,时间平仓
        RR >{{ r.min_rr }};做多 SL<E<TP
        标记价穿越 E 立即市价开(多向上 / 空向下)
        先触 TP 或 SL 侧失效;有效 {{ r.trigger_entry_validity_hours }}h
        程序盯价,无交易所挂单
        成交后挂所 TP/SL → 下单监控
        占当日开仓意图
        全仓模式可用
        关键支撑阻力双向;填上/下沿{{ r.tf }} 收盘破上沿或下沿
        上沿优先
        无(仅提醒)微信 ≤{{ r.alert_max }} 次
        间隔 ≥{{ r.alert_interval_min }} 分
        +
        +

        阈值来自 .env,修改后重启实例.

        diff --git a/lib/strategy/templates/order_focus_v2.html b/lib/strategy/templates/order_focus_v2.html new file mode 100644 index 0000000..a1d8234 --- /dev/null +++ b/lib/strategy/templates/order_focus_v2.html @@ -0,0 +1,151 @@ + + + + + + {{ exchange_display }} | 实盘下单放大 + + + + +
        +
        +
        +
        + + +
        +
        + 返回首页 + 实盘下单放大(100根K线){{ exchange_display }} +
        +
        最近刷新:--
        +
        + {% if orders %} +
        + + + + + + +
        + {% else %} +
        当前没有激活订单,无法展示放大K线.
        + {% endif %} +
        + + {% if orders %} +
        +
        +
        交易对
        -
        +
        方向
        -
        +
        成交价
        -
        +
        止损
        -
        +
        止盈
        -
        +
        盈亏比
        -
        +
        移动保本
        -
        +
        现价
        -
        +
        浮盈亏
        -
        +
        +
        +
        +
        +
        + {% endif %} +
        + +{% if orders %} + + + +{% endif %} + + diff --git a/lib/strategy/templates/order_monitor_rule_tips_binance.html b/lib/strategy/templates/order_monitor_rule_tips_binance.html new file mode 100644 index 0000000..45c75a7 --- /dev/null +++ b/lib/strategy/templates/order_monitor_rule_tips_binance.html @@ -0,0 +1,21 @@ +
        + 开仓规则说明 +
        + 规则:最多 {{ max_active_positions }} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x; + 本交易日开仓 {{ opens_today }}{% if daily_open_hard_limit > 0 %} / 硬上限 {{ daily_open_hard_limit }}{% endif %}(AI 提醒 {{ daily_open_alert_threshold }}); + {% if can_trade %}可开仓{% else %}不可开仓(持仓已满,单日开仓达上限,或未到北京时间 {{ reset_hour }}:00){% endif %}; + 人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1 +
        +
        +
        + 计仓与保本说明 +
        + 计仓模式:{{ position_sizing_mode_label }}(仅 .env POSITION_SIZING_MODE,须无仓后重启) + {% if position_sizing_mode == 'full_margin' %} + |全仓:合约可用×{{ full_margin_buffer_ratio }},BTC/ETH {{ btc_leverage }}x,其它 {{ alt_leverage }}x,单仓;张数按交易所精度 + {% else %} + |以损定仓:风险 {{ risk_percent }}% + {% endif %} + |移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}% +
        +
        diff --git a/lib/strategy/templates/order_monitor_rule_tips_gate.html b/lib/strategy/templates/order_monitor_rule_tips_gate.html new file mode 100644 index 0000000..45c75a7 --- /dev/null +++ b/lib/strategy/templates/order_monitor_rule_tips_gate.html @@ -0,0 +1,21 @@ +
        + 开仓规则说明 +
        + 规则:最多 {{ max_active_positions }} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x; + 本交易日开仓 {{ opens_today }}{% if daily_open_hard_limit > 0 %} / 硬上限 {{ daily_open_hard_limit }}{% endif %}(AI 提醒 {{ daily_open_alert_threshold }}); + {% if can_trade %}可开仓{% else %}不可开仓(持仓已满,单日开仓达上限,或未到北京时间 {{ reset_hour }}:00){% endif %}; + 人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1 +
        +
        +
        + 计仓与保本说明 +
        + 计仓模式:{{ position_sizing_mode_label }}(仅 .env POSITION_SIZING_MODE,须无仓后重启) + {% if position_sizing_mode == 'full_margin' %} + |全仓:合约可用×{{ full_margin_buffer_ratio }},BTC/ETH {{ btc_leverage }}x,其它 {{ alt_leverage }}x,单仓;张数按交易所精度 + {% else %} + |以损定仓:风险 {{ risk_percent }}% + {% endif %} + |移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}% +
        +
        diff --git a/lib/strategy/templates/order_monitor_rule_tips_gate_bot.html b/lib/strategy/templates/order_monitor_rule_tips_gate_bot.html new file mode 100644 index 0000000..ec88a25 --- /dev/null +++ b/lib/strategy/templates/order_monitor_rule_tips_gate_bot.html @@ -0,0 +1,21 @@ +
        + 开仓规则说明 +
        + 规则:最大同时持仓 {{ max_active_positions }}(当前 active {{ active_count }});与「趋势回调」计划互斥;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x; + 本交易日开仓 {{ opens_today }}{% if daily_open_hard_limit > 0 %} / 硬上限 {{ daily_open_hard_limit }}{% endif %}(AI 提醒 {{ daily_open_alert_threshold }}); + {% if can_trade %}可开仓{% else %}不可开仓(持仓达上限,单日开仓达上限,有趋势回调计划,或未到北京时间 {{ reset_hour }}:00){% endif %}; + 人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1 +
        +
        +
        + 计仓与保本说明 +
        + 计仓模式:{{ position_sizing_mode_label }}(仅 .env POSITION_SIZING_MODE,须无仓后重启) + {% if position_sizing_mode == 'full_margin' %} + |全仓:合约可用×{{ full_margin_buffer_ratio }},BTC/ETH {{ btc_leverage }}x,其它 {{ alt_leverage }}x,单仓;张数按交易所精度 + {% else %} + |以损定仓:风险 {{ risk_percent }}% + {% endif %} + |移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}% +
        +
        diff --git a/lib/strategy/templates/order_monitor_rule_tips_okx.html b/lib/strategy/templates/order_monitor_rule_tips_okx.html new file mode 100644 index 0000000..b9d695c --- /dev/null +++ b/lib/strategy/templates/order_monitor_rule_tips_okx.html @@ -0,0 +1,21 @@ +
        + 开仓规则说明 +
        + 规则:最多 {{ max_active_positions }} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x; + 本交易日开仓 {{ opens_today }}{% if daily_open_hard_limit > 0 %} / 硬上限 {{ daily_open_hard_limit }}{% endif %}(AI 提醒 {{ daily_open_alert_threshold }}); + {% if can_trade %}可开仓{% else %}不可开仓{% if active_count >= max_active_positions %}(持仓 {{ active_count }}/{{ max_active_positions }}){% endif %}{% if daily_open_hard_limit > 0 and opens_today >= daily_open_hard_limit %}(单日开仓达上限){% endif %}{% if open_guard_blocks_now %}(未到北京时间 {{ reset_hour }}:00){% endif %}{% endif %}; + 人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1 +
        +
        +
        + 计仓与保本说明 +
        + 计仓模式:{{ position_sizing_mode_label }}(仅 .env POSITION_SIZING_MODE,须无仓后重启) + {% if position_sizing_mode == 'full_margin' %} + |全仓:合约可用×{{ full_margin_buffer_ratio }},BTC/ETH {{ btc_leverage }}x,其它 {{ alt_leverage }}x,单仓;张数按交易所精度 + {% else %} + |以损定仓:风险 {{ risk_percent }}% + {% endif %} + |移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}% +
        +
        diff --git a/lib/strategy/templates/order_plan_preview_bar.html b/lib/strategy/templates/order_plan_preview_bar.html new file mode 100644 index 0000000..7fa3ffc --- /dev/null +++ b/lib/strategy/templates/order_plan_preview_bar.html @@ -0,0 +1,5 @@ +
        + 预估风险: + 预估盈利: + 预估盈亏比: +
        diff --git a/lib/strategy/templates/strategy_records_page.html b/lib/strategy/templates/strategy_records_page.html new file mode 100644 index 0000000..fb27198 --- /dev/null +++ b/lib/strategy/templates/strategy_records_page.html @@ -0,0 +1,284 @@ +{% set mf = money_fmt|default(funds_fmt) %} + +
        +

        策略交易记录

        +

        + 数据库保留最近 {{ strategy_records_limit|default(100) }} 条结束快照(按结束时间排序). + 趋势回调与顺势加仓分栏展示;点击行展开详情.结束计划,保本移交,止盈止损会自动写入. +

        + +
        + + +
        + 筛选 + + + + + +
        +
        + +
        +
        +
        + 趋势回调记录 + {{ strategy_trend_records|length }} 条 +
        +
        + {% for s in strategy_trend_records %} + {% set snap = s.snapshot or {} %} + {% set dca = snap.dca_levels if snap.dca_levels is defined else [] %} + {% set pnl = s.pnl_amount if s.pnl_amount is not none else snap.pnl_amount %} + {% set sym = s.symbol or s.exchange_symbol or snap.symbol or snap.exchange_symbol or '—' %} +
        +
        + #{{ s.id }} {{ sym }} + {{ '做多' if s.direction == 'long' else '做空' }} + {{ s.result_label or '—' }} + {% if pnl is not none %}{{ mf(pnl) }}U{% else %}—{% endif %} + 补仓 {{ s.summary_dca or '—' }} + {{ (s.closed_at or '')[:16] }} +
        +
        +
        +
        计划 ID
        {{ s.source_id or '—' }}
        +
        开仓
        {{ (s.opened_at or '')[:16] or '—' }}
        +
        结束
        {{ (s.closed_at or '')[:16] or '—' }}
        +
        均价
        {% if snap.avg_entry_price is not none %}{{ price_fmt(sym, snap.avg_entry_price) }}{% else %}—{% endif %}
        +
        止损
        {% if snap.stop_loss is not none %}{{ price_fmt(sym, snap.stop_loss) }}{% else %}—{% endif %}
        +
        止盈
        {% if snap.take_profit is not none %}{{ price_fmt(sym, snap.take_profit) }}{% else %}—{% endif %}
        +
        风险%
        {{ snap.risk_percent if snap.risk_percent is defined else '—' }}
        +
        杠杆
        {{ snap.leverage if snap.leverage is defined else '—' }}x
        +
        计划保证金
        {% if snap.plan_margin_capital is not none %}{{ mf(snap.plan_margin_capital) }}U{% else %}—{% endif %}
        +
        + {% if dca and dca|length %} + + + {% for lv in dca %} + + + + + + + {% endfor %} +
        档位触发价张数状态
        {{ lv.label or lv.leg_key }}{% if lv.price is not none %}{{ price_fmt(sym, lv.price) }}{% else %}—{% endif %}{% if lv.contracts is not none %}{{ lv.contracts }}{% else %}—{% endif %}{{ lv.status_label or '—' }}
        + {% endif %} +
        +
        + {% else %} +
        暂无趋势回调结束记录
        + {% endfor %} +
        +
        + +
        +
        + 顺势加仓记录 + {{ strategy_roll_records|length }} 条 +
        +
        + {% for s in strategy_roll_records %} + {% set snap = s.snapshot or {} %} + {% set group = snap.group if snap.group is defined else {} %} + {% set legs = snap.legs if snap.legs is defined else [] %} + {% set pnl = s.pnl_amount if s.pnl_amount is not none else snap.pnl_amount %} + {% set sym = s.symbol or s.exchange_symbol or snap.symbol or snap.exchange_symbol or '—' %} +
        +
        + #{{ s.id }} {{ sym }} + {{ '做多' if s.direction == 'long' else '做空' }} + {{ s.result_label or '—' }} + {% if pnl is not none %}{{ mf(pnl) }}U{% else %}—{% endif %} + 成交 {{ s.summary_dca or '—' }} + {{ (s.closed_at or '')[:16] }} +
        +
        +
        +
        组 ID
        {{ s.source_id or '—' }}
        +
        创建
        {{ (s.opened_at or group.created_at or '')[:16] or '—' }}
        +
        结束
        {{ (s.closed_at or '')[:16] or '—' }}
        +
        状态
        {{ s.status_at_close or group.status or '—' }}
        +
        杠杆
        {{ group.leverage if group.leverage is defined else '—' }}x
        +
        备注
        {{ group.message if group.message is defined else '—' }}
        +
        + {% if legs and legs|length %} + + + {% for leg in legs %} + + + + + + + {% endfor %} +
        腿次挂单价张数状态
        {{ leg.leg_index or loop.index }}{% if leg.limit_price is not none %}{{ price_fmt(sym, leg.limit_price) }}{% else %}—{% endif %}{% if leg.order_amount is not none %}{{ leg.order_amount }}{% else %}—{% endif %}{{ leg.status_label or leg.status or '—' }}
        + {% endif %} +
        +
        + {% else %} +
        暂无顺势加仓结束记录
        + {% endfor %} +
        +
        +
        +
        + diff --git a/lib/strategy/templates/strategy_roll.html b/lib/strategy/templates/strategy_roll.html new file mode 100644 index 0000000..e550a3b --- /dev/null +++ b/lib/strategy/templates/strategy_roll.html @@ -0,0 +1,19 @@ + + + + + + + 顺势加仓 · {{ exchange_display }} + + + + +
        + {% with messages = get_flashed_messages() %}{% if messages %}
        {{ messages[0] }}
        {% endif %}{% endwith %} + {% include 'strategy_roll_panel.html' %} +

        顺势加仓完整逻辑说明

        +
        + + + diff --git a/lib/strategy/templates/strategy_roll_docs.html b/lib/strategy/templates/strategy_roll_docs.html new file mode 100644 index 0000000..50cc01b --- /dev/null +++ b/lib/strategy/templates/strategy_roll_docs.html @@ -0,0 +1,23 @@ + + + + + + + 顺势加仓 · 详细说明 · {{ exchange_display }} + + + + +
        + +
        + {{ doc_html|safe }} +
        +
        + + diff --git a/lib/strategy/templates/strategy_roll_panel.html b/lib/strategy/templates/strategy_roll_panel.html new file mode 100644 index 0000000..faa9ac8 --- /dev/null +++ b/lib/strategy/templates/strategy_roll_panel.html @@ -0,0 +1,106 @@ +
        +

        顺势加仓

        +
        + 顺势加仓规则说明{% if roll_trend_active %} · 当前有趋势回调计划{% endif %} +
        + 仅人工提交;须先在「实盘下单」有同向持仓.仅以损定仓模式可用.
        + 做多/做空各最多滚仓 3 次(仅计已成交腿);止盈锁定首仓不变.
        + 风险比例读取所选监控单,不可手改;打到新止损时合并持仓亏损 ≈ 1 个风险单位(当前基数 × 监控 risk%).
        + 斐波/突破为程序监控(交易所 mark 价),触价后市价加仓;填写后直接点「执行滚仓」(无需预览).同时仅允许 1 条监控中腿,提交后不可修改,可删除.
        + 手动平仓后滚仓监控自动结束;已成交腿历史保留供复盘.
        + → 顺势加仓完整逻辑说明
        + {% if roll_trend_active %}当前有运行中的趋势回调计划,请先结束后再滚仓.{% endif %} +
        +
        + +
        + 当前风险:请选择持仓币种 +
        + +
        + + + + + + + + + + + + + +
        + + + +

        活跃滚仓组

        +
        + + + {% for g in roll_groups %} + + + + + + + + + + + + + + {% else %} + + {% endfor %} +
        ID币种方向腿数首仓张数加仓后张数首仓TP当前SL当前均价止盈盈利U状态
        {{ g.id }}{{ g.symbol }}{{ g.direction }}{{ g.leg_count }}{% if g.initial_qty is not none %}{{ '%.2f'|format(g.initial_qty) }}{% else %}—{% endif %}{% if g.current_qty is not none %}{{ '%.2f'|format(g.current_qty) }}{% else %}—{% endif %}{% if price_fmt %}{{ price_fmt(g.symbol, g.initial_take_profit) }}{% else %}{{ g.initial_take_profit }}{% endif %}{% if price_fmt %}{{ price_fmt(g.symbol, g.current_stop_loss) }}{% else %}{{ g.current_stop_loss }}{% endif %}{% if g.avg_entry_display %}{{ g.avg_entry_display }}{% elif g.avg_entry is not none %}{{ g.avg_entry }}{% else %}—{% endif %}{% if g.reward_at_tp_usdt is not none %}{{ '%.2f'|format(g.reward_at_tp_usdt) }}{% else %}—{% endif %}滚仓中
        暂无
        +
        + +

        最近滚仓腿

        +
        + + + {% for leg in roll_legs %} + + + + + + + + + + + {% else %} + + {% endfor %} +
        #方式张数触发/限价新SL状态操作
        {{ leg.leg_index }}{{ leg.roll_group_id }}{{ leg.add_mode }}{% if leg.amount %}{{ leg.amount }}{% else %}—{% endif %}{% if leg.limit_price %}{{ leg.limit_price }}{% elif leg.breakthrough_price %}{{ leg.breakthrough_price }}{% elif leg.fill_price %}{{ leg.fill_price }}{% else %}—{% endif %}{{ leg.new_stop_loss }}{{ leg.status_label or leg.status }} + {% if leg.status == 'pending' %} +
        + +
        + {% else %}—{% endif %} +
        暂无
        +
        +
        diff --git a/lib/strategy/templates/strategy_subnav.html b/lib/strategy/templates/strategy_subnav.html new file mode 100644 index 0000000..e91839d --- /dev/null +++ b/lib/strategy/templates/strategy_subnav.html @@ -0,0 +1,4 @@ + diff --git a/lib/strategy/templates/strategy_trading_page.html b/lib/strategy/templates/strategy_trading_page.html new file mode 100644 index 0000000..91320d9 --- /dev/null +++ b/lib/strategy/templates/strategy_trading_page.html @@ -0,0 +1,47 @@ + +
        +
        +
        + {% include 'strategy_trend_panel.html' %} +
        +
        +
        +
        + {% include 'strategy_roll_panel.html' %} +
        +
        +
        diff --git a/lib/strategy/templates/strategy_trend_disabled.html b/lib/strategy/templates/strategy_trend_disabled.html new file mode 100644 index 0000000..c56543e --- /dev/null +++ b/lib/strategy/templates/strategy_trend_disabled.html @@ -0,0 +1,20 @@ + + + + + 趋势回调 · {{ exchange_display }} + + + +

        ← 实盘下单 顺势加仓

        +
        +

        趋势回调

        +

        {{ trend_note }}

        +

        趋势回调含自动补仓档位,在三所实例(Binance / Gate / OKX)中均可启用,须配置 LIVE_TRADING_ENABLED=true.

        +
        + + diff --git a/lib/strategy/templates/strategy_trend_disabled_panel.html b/lib/strategy/templates/strategy_trend_disabled_panel.html new file mode 100644 index 0000000..5f56950 --- /dev/null +++ b/lib/strategy/templates/strategy_trend_disabled_panel.html @@ -0,0 +1,16 @@ +{% include 'strategy_subnav.html' %} +
        +

        趋势回调

        +
        + 趋势回调说明(本实例未启用) +
        + {{ trend_disabled_note }}

        + 趋势回调含自动补仓档位与预览执行,在 Binance / Gate / OKX 各实例的「策略交易 → 趋势回调」中运行. + 请访问对应实例同一菜单,或常用地址如 Gate :5000/strategy/trend. +
        +
        +

        + 返回实盘下单 + | 顺势加仓(本实例可用) +

        +
        diff --git a/lib/strategy/templates/strategy_trend_panel.html b/lib/strategy/templates/strategy_trend_panel.html new file mode 100644 index 0000000..ff09f61 --- /dev/null +++ b/lib/strategy/templates/strategy_trend_panel.html @@ -0,0 +1,207 @@ +{% set mf = money_fmt|default(funds_fmt) %} +{% macro amt_disp(sym, val) %}{% if amt_fmt is defined %}{{ amt_fmt(sym, val) }}{% else %}{{ val }}{% endif %}{% endmacro %} +
        +

        趋势回调

        +
        + 趋势回调规则说明 +
        + ① 生成预览:读取合约 USDT 可用余额快照并计算计划(不下单).预览有效期 {{ trend_pullback_preview_ttl }} 秒.
        + ② 确认执行:市价首仓 50% + 挂交易所止损;首仓后可手动保本(默认均价+{{ trend_manual_breakeven_offset_pct }}%);剩余 50% 在止损与补仓区间之间共 {{ trend_pullback_dca_legs }} 档(做多为上沿,做空为下沿;程序可能因最小张数自动减档)市价补仓;止盈由程序监控.
        + 确认执行时若当前可用余额与预览快照相对偏差 > {{ trend_preview_max_drift_pct }}% 会拒绝并要求重新预览. +
        +
        + {% if trend_dca_probes %} + {% for p in trend_dca_probes %} + {% if p.trigger_reached and p.block_reason %} +
        + 计划 #{{ p.plan_id }} 标记价 {{ p.mark_price }} 已触达补仓触发价 {{ p.next_trigger }},但未自动补仓: + {{ p.block_reason }}. + {% if not live_trading_enabled %} + 请在当前实例 .env 设置 LIVE_TRADING_ENABLED=true 后重启对应 PM2 进程(如 crypto_gate,crypto_okx,crypto_binance). + {% endif %} +
        + {% endif %} + {% endfor %} + {% endif %} +
        + {% from 'trade_policy_fields.html' import trade_policy_symbol, trade_policy_direction with context %} + {{ trade_policy_symbol('symbol', 'trend-symbol', placeholder='BTC 或 ETH/USDT') }} + {{ trade_policy_direction('direction', 'trend-direction') }} + {% from 'symbol_live_price_snippet.html' import symbol_live_price_hint %} + {{ symbol_live_price_hint('trend-symbol-live-price', 'trend-symbol', 'trend-direction') }} + + + + + + +
        + + + {% if trend_preview %} +
        +
        + 当前预览(剩余 {{ trend_pullback_preview_ttl }}s) + 倒计时加载中… +
        +
        + {{ trend_preview.symbol }} {{ '做多' if trend_preview.direction == 'long' else '做空' }} {{ trend_preview.leverage }}x | + 预览可用快照 {{ mf(trend_preview.snapshot_available_usdt) }} U | 参考价 {{ price_fmt(trend_preview.symbol, trend_preview.live_price_ref) }} | + 计划保证金≈{{ mf(trend_preview.plan_margin_capital) }} U | 总张≈{{ amt_disp(trend_preview.symbol, trend_preview.target_order_amount) }}(首仓 {{ amt_disp(trend_preview.symbol, trend_preview.first_order_amount) }} + 补仓 {{ amt_disp(trend_preview.symbol, trend_preview.remainder_total) }})
        + 止损价 {{ price_fmt(trend_preview.symbol, trend_preview.preview_unified_stop_loss or trend_preview.stop_loss) }} | 止损金额 {% if trend_preview.preview_risk_amount_u is not none %}{{ mf(trend_preview.preview_risk_amount_u) }}U{% else %}—{% endif %}(快照×风险{{ trend_preview.risk_percent }}%)| {{ 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) }} | 首仓盈亏比 {% if trend_preview.preview_target_rr is not none %}{{ '%.2f'|format(trend_preview.preview_target_rr) }}{% else %}—{% endif %} +
        +
        + + + {% for row in trend_preview_levels %} + + + + + + + + + + {% endfor %} +
        档位触发/参考价张数加仓后均价止盈盈利(U)止损(U)盈亏比
        {{ row.label or row.i }}{{ price_fmt(trend_preview.symbol, row.price) }}{{ amt_disp(trend_preview.symbol, row.contracts) }}{% if row.avg_entry is not none %}{{ price_fmt(trend_preview.symbol, row.avg_entry) }}{% else %}—{% endif %}{% if row.profit_u is not none %}{{ mf(row.profit_u) }}{% else %}—{% endif %}{% if row.risk_u is not none %}{{ mf(row.risk_u) }}{% else %}—{% endif %}{% if row.rr is not none %}{{ '%.2f'|format(row.rr) }}{% else %}—{% endif %}
        +
        +
        +
        + + +
        +
        + + +
        +
        +
        + + {% 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(pnlpct=None) %} + {% 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 t.money_rr is not none %}{{ '%.2f'|format(t.money_rr) }}:1{% elif t.planned_rr is not none %}{{ '%.2f'|format(t.planned_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 %} + {{ mf(t.floating_pnl) }}U{% if calc.pnlpct is not none %} ({{ '%+.2f'|format(calc.pnlpct) }}%){% endif %} + {% else %}—{% endif %} + +
        +
        + {% if t.dca_levels %} +
        +
        补仓计划明细
        + + + {% for lv in t.dca_levels %} + + + + + + + + + + + {% endfor %} +
        档位触发价张数加仓后均价止盈盈利(U)止损(U)盈亏比状态
        {{ lv.label }}{% if lv.price is not none %}{{ price_fmt(sym, lv.price) }}{% else %}—{% endif %}{% if lv.contracts is not none %}{{ amt_disp(sym, lv.contracts) }}{% else %}—{% endif %}{% if lv.avg_entry is not none %}{{ price_fmt(sym, lv.avg_entry) }}{% else %}—{% endif %}{% if lv.profit_u is not none %}{{ mf(lv.profit_u) }}{% else %}—{% endif %}{% if lv.risk_u is not none %}{{ mf(lv.risk_u) }}{% else %}—{% endif %}{% if lv.rr is not none %}{{ '%.2f'|format(lv.rr) }}{% else %}—{% endif %}{{ lv.status_label }}
        +
        + {% endif %} +
        +
        + + + {% if t.breakeven_applied %}已保本 {{ (t.breakeven_applied_at or '')[:16] }}{% endif %} +
        +
        +
        + 快照可用: {% if t.snapshot_available_usdt is not none %}{{ mf(t.snapshot_available_usdt) }}U{% else %}—{% endif %} + | 计划保证金≈{% if t.plan_margin_capital is not none %}{{ mf(t.plan_margin_capital) }}U{% else %}—{% endif %} + | 杠杆: {{ t.leverage }}x +
        +
        + {% else %} +
        暂无运行中的趋势回调计划
        + {% endfor %} +
        +
        +
        diff --git a/lib/strategy/templates/symbol_live_price_snippet.html b/lib/strategy/templates/symbol_live_price_snippet.html new file mode 100644 index 0000000..c746897 --- /dev/null +++ b/lib/strategy/templates/symbol_live_price_snippet.html @@ -0,0 +1,10 @@ +{# 币种输入旁实时现价(须加载 symbol_live_price.js) #} +{% macro symbol_live_price_hint(price_id, symbol_input_id, direction_input_id='') -%} +现价:— +{%- endmacro %} diff --git a/lib/strategy/templates/trade_policy_fields.html b/lib/strategy/templates/trade_policy_fields.html new file mode 100644 index 0000000..f1e1a00 --- /dev/null +++ b/lib/strategy/templates/trade_policy_fields.html @@ -0,0 +1,32 @@ +{# 方向 / 币种:env 账户级限制(三所共用宏);调用方须 with context #} +{% if trade_policy is not defined %} +{% set trade_policy = {'symbol_restrict_enabled': false, 'direction_restrict_enabled': false, 'symbol_whitelist': [], 'allows_long': true, 'allows_short': true, 'direction_mode': 'both', 'badge_text': ''} %} +{% endif %} +{% macro trade_policy_symbol(name, id, value='', required=true, placeholder='BTC 或 BTC/USDT') -%} +{% if trade_policy.symbol_restrict_enabled and trade_policy.symbol_whitelist %} + +{% else %} + +{% endif %} +{%- endmacro %} + +{% macro trade_policy_direction(name, id, required=true, include_empty=true) -%} +{% if trade_policy.direction_restrict_enabled and trade_policy.direction_mode == 'long_only' %} +做多 + +{% elif trade_policy.direction_restrict_enabled and trade_policy.direction_mode == 'short_only' %} +做空 + +{% else %} + +{% endif %} +{%- endmacro %} diff --git a/lib/trade/__init__.py b/lib/trade/__init__.py new file mode 100644 index 0000000..ab164b5 --- /dev/null +++ b/lib/trade/__init__.py @@ -0,0 +1 @@ +"""Shared library package.""" diff --git a/lib/trade/account_risk_lib.py b/lib/trade/account_risk_lib.py new file mode 100644 index 0000000..f5e4140 --- /dev/null +++ b/lib/trade/account_risk_lib.py @@ -0,0 +1,845 @@ +"""账户冷静期 / 日冻结风控(三所实例共用).""" +from __future__ import annotations + +import os +from datetime import datetime, timezone +from typing import Any, Callable, Optional + +STATUS_NORMAL = "normal" +STATUS_FREEZE_1H = "freeze_1h" +STATUS_FREEZE_4H = "freeze_4h" +STATUS_DAILY = "freeze_daily" +STATUS_FREEZE_POSITION = "freeze_position" + +STATUS_LABELS = { + STATUS_NORMAL: "正常", + STATUS_FREEZE_1H: "1h冻结", + STATUS_FREEZE_4H: "4h冻结", + STATUS_DAILY: "日冻结", + STATUS_FREEZE_POSITION: "仓位上限冻结", +} + +MOOD_ISSUE_OPTIONS = ( + "怕踏空", + "报复开仓", + "盈利飘了", + "拿不住单", + "扛单", + "重仓违规", +) + +# 仅以下来源计入「手动平仓」风控(用户主动点平仓/结束计划) +CLOSE_SOURCE_USER_INSTANCE = "user_instance" +CLOSE_SOURCE_USER_HUB = "user_hub" +CLOSE_SOURCE_USER_TREND_STOP = "user_trend_stop" + +USER_INITIATED_CLOSE_SOURCES = frozenset( + { + CLOSE_SOURCE_USER_INSTANCE, + CLOSE_SOURCE_USER_HUB, + CLOSE_SOURCE_USER_TREND_STOP, + } +) + + +def _env_bool(key: str, default: bool = True) -> bool: + raw = (os.getenv(key) or "").strip().lower() + if not raw: + return default + return raw in ("1", "true", "yes", "on") + + +def _env_hours(key: str, default: float) -> float: + try: + v = float(os.getenv(key, str(default))) + except (TypeError, ValueError): + v = default + return max(0.0, v) + + +def _app_tz(): + from zoneinfo import ZoneInfo + + name = (os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip() + try: + return ZoneInfo(name) + except Exception: + return ZoneInfo("Asia/Shanghai") + + +def risk_control_enabled() -> bool: + return _env_bool("RISK_CONTROL_ENABLED", True) + + +def cooling_hours_manual() -> float: + return _env_hours("RISK_COOLING_HOURS_MANUAL", 4.0) + + +def cooling_hours_manual_journal() -> float: + return _env_hours("RISK_COOLING_HOURS_MANUAL_JOURNAL", 1.0) + + +def manual_close_daily_limit() -> int: + try: + return max(1, int(os.getenv("RISK_MANUAL_CLOSE_DAILY_LIMIT", "2"))) + except (TypeError, ValueError): + return 2 + + +def max_active_positions_from_env(default: int = 1) -> int: + try: + return max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", str(default)))) + except (TypeError, ValueError): + return max(1, default) + + +def position_limit_reached( + conn, + *, + max_active_positions: Optional[int] = None, +) -> tuple[bool, int, int]: + """(已达上限, 计入上限的活跃数, 上限值).""" + from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors + + mx = max(1, int(max_active_positions if max_active_positions is not None else max_active_positions_from_env())) + ac = count_position_limit_active_monitors(conn) + return ac >= mx, ac, mx + + +def mood_issues_daily_freeze_enabled() -> bool: + return _env_bool("RISK_MOOD_ISSUES_DAILY_FREEZE", True) + + +def ensure_account_risk_schema(conn) -> None: + conn.execute( + """CREATE TABLE IF NOT EXISTS account_risk_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + trading_day TEXT, + manual_close_count INTEGER DEFAULT 0, + cooloff_until_ms INTEGER, + cooloff_hours INTEGER, + daily_frozen INTEGER DEFAULT 0, + pending_journal_trade_id INTEGER, + last_close_at_ms INTEGER, + updated_at TEXT + )""" + ) + row = conn.execute("SELECT id FROM account_risk_state WHERE id=1").fetchone() + if not row: + conn.execute( + "INSERT INTO account_risk_state (id, trading_day, manual_close_count, daily_frozen) VALUES (1, '', 0, 0)" + ) + + +def _row_get(row, key, default=None): + if row is None: + return default + try: + return row[key] + except (KeyError, IndexError, TypeError): + return default + + +def _now_ms(now: Optional[datetime] = None) -> int: + dt = now or datetime.now() + if dt.tzinfo is None: + dt = dt.replace(tzinfo=_app_tz()) + return int(dt.timestamp() * 1000) + + +def _normalize_epoch_ms(ms: int, ref_now_ms: Optional[int] = None) -> int: + """修正旧版把北京时间 naive 当作 UTC 写入的 epoch 毫秒.""" + tz = _app_tz() + off = datetime.now(tz).utcoffset() + if not off: + return int(ms) + offset_ms = int(off.total_seconds() * 1000) + if offset_ms == 0: + return int(ms) + ref = int(ref_now_ms) if ref_now_ms is not None else _now_ms(datetime.now(tz)) + corrected = int(ms) - offset_ms + if abs(int(ms) - ref) <= abs(corrected - ref): + return int(ms) + return corrected + + +def _sanitize_last_close_ms(last_ms: int, now_ms: int) -> Optional[int]: + """平仓时刻须不晚于当前(允许 1 分钟时钟偏差);显著未来视为无效锚点.""" + slack_ms = 60 * 1000 + if last_ms > now_ms + slack_ms: + return None + return last_ms + + +def _cooloff_duration_ms(hours: float) -> int: + return int(max(0.0, float(hours)) * 3600 * 1000) + + +def _cooloff_hours_value(row) -> float: + return float(_row_get(row, "cooloff_hours") or cooling_hours_manual()) + + +def _resolved_cooloff_until_ms(row, now_ms: int) -> Optional[int]: + """冷静期结束 = last_close + cooloff_hours;无效/已过期锚点不再重启计时.""" + hours = _cooloff_hours_value(row) + journal_h = cooling_hours_manual_journal() + duration_ms = _cooloff_duration_ms(hours) + last_raw = _row_get(row, "last_close_at_ms") + stored_raw = _cooloff_until_ms(row) + + if last_raw is not None: + try: + last_ms = _sanitize_last_close_ms( + _normalize_epoch_ms(int(last_raw), now_ms), now_ms + ) + except (TypeError, ValueError): + last_ms = None + if last_ms is not None: + end_ms = last_ms + duration_ms + if end_ms > now_ms: + return end_ms + if hours <= journal_h + 1e-6: + return None + + if stored_raw is None: + return None + stored_ms = _normalize_epoch_ms(int(stored_raw), now_ms) + return stored_ms if stored_ms > now_ms else None + + +def _clear_inactive_cooloff( + conn, + *, + now: Optional[datetime] = None, +) -> None: + """冷静期已结束或锚点无效时清库,避免重启后误读旧冻结.""" + conn.execute( + """UPDATE account_risk_state SET + cooloff_until_ms=NULL, + cooloff_hours=NULL, + last_close_at_ms=NULL, + updated_at=? + WHERE id=1""", + ((now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S"),), + ) + + +def _freeze_tier_from_remaining_ms(remaining_ms: int, hours: float) -> str: + journal_h = cooling_hours_manual_journal() + rh = remaining_ms / 3600000.0 + if rh <= journal_h + (5 / 60): + return STATUS_FREEZE_1H + return STATUS_FREEZE_4H + + +def _freeze_status_label(hours: float, status: str) -> str: + if status == STATUS_FREEZE_1H: + return STATUS_LABELS[STATUS_FREEZE_1H] + if status == STATUS_FREEZE_4H: + h = int(hours) if float(hours) == int(hours) else round(float(hours), 1) + if abs(float(hours) - 4.0) < 1e-6: + return STATUS_LABELS[STATUS_FREEZE_4H] + return f"{h}h冻结" + return STATUS_LABELS.get(status, STATUS_LABELS[STATUS_NORMAL]) + + +def _ms_to_local_str(ms: Optional[int], fmt_local: Callable[[int], str]) -> Optional[str]: + if ms is None: + return None + try: + return fmt_local(int(ms)) + except Exception: + return None + + +def _load_state(conn): + ensure_account_risk_schema(conn) + return conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone() + + +def _sync_trading_day(conn, trading_day: str, now: Optional[datetime] = None) -> Any: + row = _load_state(conn) + td = (trading_day or "").strip() + stored = str(_row_get(row, "trading_day") or "").strip() + if stored != td: + now_ms = _now_ms(now) + cooloff_active = _resolved_cooloff_until_ms(row, now_ms) + conn.execute( + """UPDATE account_risk_state SET + trading_day=?, + manual_close_count=0, + daily_frozen=0, + cooloff_until_ms=?, + cooloff_hours=?, + last_close_at_ms=?, + pending_journal_trade_id=NULL, + updated_at=? + WHERE id=1""", + ( + td, + cooloff_active, + _row_get(row, "cooloff_hours") if cooloff_active else None, + _row_get(row, "last_close_at_ms") if cooloff_active else None, + (now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S"), + ), + ) + row = _load_state(conn) + return row + + +def _set_cooloff( + conn, + *, + trading_day: str, + close_at_ms: int, + hours: float, + now: Optional[datetime] = None, +) -> None: + _sync_trading_day(conn, trading_day, now=now) + h = max(0.0, float(hours)) + until_ms = int(close_at_ms + h * 3600 * 1000) + conn.execute( + """UPDATE account_risk_state SET + cooloff_until_ms=?, + cooloff_hours=?, + last_close_at_ms=?, + updated_at=? + WHERE id=1""", + ( + until_ms, + int(h) if h == int(h) else int(round(h)), + int(close_at_ms), + (now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S"), + ), + ) + + +def _set_cooloff_until( + conn, + *, + trading_day: str, + until_ms: int, + hours: float, + now: Optional[datetime] = None, +) -> None: + _sync_trading_day(conn, trading_day, now=now) + h = max(0.0, float(hours)) + conn.execute( + """UPDATE account_risk_state SET + cooloff_until_ms=?, + cooloff_hours=?, + updated_at=? + WHERE id=1""", + ( + int(until_ms), + int(h) if h == int(h) else int(round(h)), + (now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S"), + ), + ) + + +def _ms_trading_day_label(ms: int) -> str: + dt = datetime.fromtimestamp(ms / 1000, tz=_app_tz()) + return dt.strftime("%Y-%m-%d") + + +def _parse_journal_close_ms(raw: Any) -> Optional[int]: + if raw is None: + return None + s = str(raw).strip() + if not s: + return None + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y/%m/%d %H:%M:%S", "%Y-%m-%d %H:%M"): + try: + dt = datetime.strptime(s[:19] if len(s) > 16 else s, fmt) + return _now_ms(dt) + except ValueError: + continue + return None + + +def _latest_journaled_manual_close_ms(conn, trading_day: str) -> Optional[int]: + """当日最近一条已复盘的手动平仓时刻(journal 有说明).""" + try: + rows = conn.execute( + """SELECT close_datetime FROM journal_entries + WHERE early_exit_trigger='手动平仓' + AND early_exit_note IS NOT NULL AND TRIM(early_exit_note) <> '' + ORDER BY close_datetime DESC""" + ).fetchall() + except Exception: + return None + td = (trading_day or "").strip() + best: Optional[int] = None + for row in rows: + ms = _parse_journal_close_ms(_row_get(row, "close_datetime")) + if ms is None: + continue + if td and _ms_trading_day_label(ms) != td: + continue + if best is None or ms > best: + best = ms + return best + + +def _journaled_manual_cooloff_expired( + conn, *, trading_day: str, now_ms: int, pending: Any +) -> bool: + """当日手动平仓已复盘且 1h 冷静期结束,且无待复盘的新平仓.""" + if pending is not None: + try: + if int(pending) != 0: + return False + except (TypeError, ValueError): + return False + close_ms = _latest_journaled_manual_close_ms(conn, trading_day) + if close_ms is None: + return False + journal_ms = _cooloff_duration_ms(cooling_hours_manual_journal()) + return close_ms + journal_ms <= now_ms + + +def _cooloff_until_ms(row) -> Optional[int]: + raw = _row_get(row, "cooloff_until_ms") + try: + return int(raw) if raw is not None else None + except (TypeError, ValueError): + return None + + +def _repair_stale_cooloff_row( + conn, + row, + *, + now_ms: int, + resolved_until_ms: Optional[int], + now: Optional[datetime] = None, +) -> None: + """脏数据读时写回:过期/无效则清库,否则对齐 until / last_close.""" + last_raw = _row_get(row, "last_close_at_ms") + stored_raw = _cooloff_until_ms(row) + if last_raw is None and stored_raw is None: + return + if resolved_until_ms is None: + if last_raw is not None or stored_raw is not None: + _clear_inactive_cooloff(conn, now=now) + return + dirty = False + new_last: Optional[int] = None + if last_raw is not None: + try: + norm = _normalize_epoch_ms(int(last_raw), now_ms) + sanitized = _sanitize_last_close_ms(norm, now_ms) + if sanitized is None: + dirty = True + else: + new_last = sanitized + if sanitized != int(last_raw): + dirty = True + except (TypeError, ValueError): + dirty = True + if stored_raw is not None: + stored_norm = _normalize_epoch_ms(int(stored_raw), now_ms) + if abs(stored_norm - int(resolved_until_ms)) > 60 * 1000: + dirty = True + if not dirty: + return + conn.execute( + """UPDATE account_risk_state SET + cooloff_until_ms=?, + cooloff_hours=?, + last_close_at_ms=?, + updated_at=? + WHERE id=1""", + ( + resolved_until_ms, + _row_get(row, "cooloff_hours"), + new_last, + (now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S"), + ), + ) + + +def _journal_can_reduce_cooloff(row, pending, now_ms: int) -> bool: + if int(_row_get(row, "daily_frozen") or 0) == 1: + return False + if _resolved_cooloff_until_ms(row, now_ms) is None: + return False + journal_h = cooling_hours_manual_journal() + cooloff_h = float(_row_get(row, "cooloff_hours") or cooling_hours_manual()) + if cooloff_h <= journal_h + 1e-6: + return False + if pending is not None: + try: + if int(pending) != 0: + return True + except (TypeError, ValueError): + return True + return True + + +def _journal_cooloff_until_ms(row, now_ms: int, journal_hours: float) -> int: + journal_ms = int(max(0.0, float(journal_hours)) * 3600 * 1000) + last_close_ms = _row_get(row, "last_close_at_ms") + if last_close_ms: + try: + base_ms = _sanitize_last_close_ms( + _normalize_epoch_ms(int(last_close_ms), now_ms), now_ms + ) + except (TypeError, ValueError): + base_ms = None + if base_ms is None: + base_ms = now_ms + else: + base_ms = now_ms + until_from_close = base_ms + journal_ms + if until_from_close > now_ms: + return until_from_close + return now_ms + journal_ms + + +def _set_daily_frozen(conn, *, trading_day: str, now: Optional[datetime] = None) -> None: + _sync_trading_day(conn, trading_day, now=now) + conn.execute( + """UPDATE account_risk_state SET daily_frozen=1, updated_at=? WHERE id=1""", + ((now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S"),), + ) + + +def parse_mood_issues(raw: Any) -> list[str]: + if raw is None: + return [] + if isinstance(raw, (list, tuple)): + parts = [str(x).strip() for x in raw if str(x).strip()] + else: + parts = [x.strip() for x in str(raw).split(",") if x.strip()] + return [p for p in parts if p in MOOD_ISSUE_OPTIONS] + + +def _record_one_user_initiated_close( + conn, + *, + source: str, + trade_record_id: Optional[int], + closed_at_ms: Optional[int], + trading_day: str, + now: Optional[datetime] = None, +) -> None: + row = _sync_trading_day(conn, trading_day, now=now) + count = int(_row_get(row, "manual_close_count") or 0) + 1 + close_ms = int(closed_at_ms) if closed_at_ms else _now_ms(now) + pending = int(trade_record_id) if trade_record_id else None + conn.execute( + """UPDATE account_risk_state SET + manual_close_count=?, + pending_journal_trade_id=?, + updated_at=? + WHERE id=1""", + (count, pending, (now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S")), + ) + if count >= manual_close_daily_limit(): + _set_daily_frozen(conn, trading_day=trading_day, now=now) + return + _set_cooloff( + conn, + trading_day=trading_day, + close_at_ms=close_ms, + hours=cooling_hours_manual(), + now=now, + ) + + +def on_user_initiated_close( + conn, + *, + source: str, + trade_record_id: Optional[int] = None, + closed_at_ms: Optional[int] = None, + trading_day: str, + now: Optional[datetime] = None, + count: int = 1, +) -> None: + """用户主动平仓/结束趋势计划:计入手动平仓次数与冷静期.""" + if not risk_control_enabled(): + return + src = (source or "").strip() + if src not in USER_INITIATED_CLOSE_SOURCES: + return + n = max(1, int(count or 1)) + for i in range(n): + _record_one_user_initiated_close( + conn, + source=src, + trade_record_id=trade_record_id if i == 0 else None, + closed_at_ms=closed_at_ms, + trading_day=trading_day, + now=now, + ) + row = _load_state(conn) + if int(_row_get(row, "daily_frozen") or 0) == 1: + break + + +def on_manual_close( + conn, + *, + trade_record_id: int, + closed_at_ms: Optional[int], + trading_day: str, + now: Optional[datetime] = None, +) -> None: + """兼容旧调用:等同实例页用户平仓.""" + on_user_initiated_close( + conn, + source=CLOSE_SOURCE_USER_INSTANCE, + trade_record_id=trade_record_id, + closed_at_ms=closed_at_ms, + trading_day=trading_day, + now=now, + count=1, + ) + + +def on_journal_saved( + conn, + *, + early_exit_trigger: str, + early_exit_note: str, + mood_issues_raw: Any, + trading_day: str, + now: Optional[datetime] = None, +) -> None: + if not risk_control_enabled(): + return + row = _sync_trading_day(conn, trading_day, now=now) + mood_list = parse_mood_issues(mood_issues_raw) + if mood_issues_daily_freeze_enabled() and mood_list: + _set_daily_frozen(conn, trading_day=trading_day, now=now) + conn.execute( + "UPDATE account_risk_state SET pending_journal_trade_id=NULL, updated_at=? WHERE id=1", + ((now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S"),), + ) + return + pending = _row_get(row, "pending_journal_trade_id") + trigger = (early_exit_trigger or "").strip() + note = (early_exit_note or "").strip() + now_ms = _now_ms(now) + if ( + trigger == "手动平仓" + and note + and int(_row_get(row, "daily_frozen") or 0) != 1 + and _journal_can_reduce_cooloff(row, pending, now_ms) + ): + journal_h = cooling_hours_manual_journal() + until_ms = _journal_cooloff_until_ms(row, now_ms, journal_h) + _set_cooloff_until( + conn, + trading_day=trading_day, + until_ms=until_ms, + hours=journal_h, + now=now, + ) + anchor_ms = until_ms - int(journal_h * 3600 * 1000) + conn.execute( + """UPDATE account_risk_state SET + pending_journal_trade_id=NULL, + last_close_at_ms=?, + updated_at=? + WHERE id=1""", + (int(anchor_ms), (now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S")), + ) + return + + +def apply_manual_close_journal_cooloff( + conn, + *, + early_exit_note: str, + trading_day: str, + now: Optional[datetime] = None, +) -> None: + """核对修改或复盘:手动平仓 + 说明后尝试将 4h 冷静期降为 1h.""" + note = (early_exit_note or "").strip() + if not note: + return + on_journal_saved( + conn, + early_exit_trigger="手动平仓", + early_exit_note=note, + mood_issues_raw="", + trading_day=trading_day, + now=now, + ) + + +def _next_trading_day_reset_ms(now: datetime, reset_hour: int) -> int: + from datetime import timedelta + + h = max(0, min(23, int(reset_hour))) + candidate = now.replace(hour=h, minute=0, second=0, microsecond=0) + if now >= candidate: + candidate = candidate + timedelta(days=1) + return _now_ms(candidate) + + +def enrich_risk_status_countdown( + st: dict[str, Any], + *, + now: Optional[datetime] = None, + daily_reset_hour: int = 8, +) -> dict[str, Any]: + """补充 freeze_until_ms / freeze_remaining_sec,供前端倒计时展示.""" + if not st.get("enabled", True): + return st + dt = now or datetime.now() + now_ms = _now_ms(dt) + until_ms: Optional[int] = None + if st.get("daily_frozen"): + until_ms = _next_trading_day_reset_ms(dt, daily_reset_hour) + elif st.get("cooloff_until_ms"): + try: + until_ms = int(st["cooloff_until_ms"]) + except (TypeError, ValueError): + until_ms = None + if until_ms is not None and until_ms > now_ms: + st["freeze_until_ms"] = until_ms + st["freeze_remaining_sec"] = max(0, (until_ms - now_ms) // 1000) + else: + st["freeze_until_ms"] = None + st["freeze_remaining_sec"] = 0 + return st + + +def apply_position_limit_risk( + st: dict[str, Any], + active_count: int, + *, + max_active_positions: Optional[int] = None, +) -> dict[str, Any]: + """持仓达 env MAX_ACTIVE_POSITIONS 时叠加「仓位上限冻结」(时间冻结优先展示).""" + out = dict(st or {}) + try: + mx = max(1, int(max_active_positions if max_active_positions is not None else max_active_positions_from_env())) + except (TypeError, ValueError): + mx = max_active_positions_from_env() + try: + ac = max(0, int(active_count)) + except (TypeError, ValueError): + ac = 0 + out["max_active_positions"] = mx + out["active_count"] = ac + if out.get("status") != STATUS_NORMAL: + return out + if ac >= mx: + out["status"] = STATUS_FREEZE_POSITION + out["status_label"] = STATUS_LABELS[STATUS_FREEZE_POSITION] + out["can_trade"] = False + out["can_roll"] = True + out["reason"] = f"已达最大持仓数({ac}/{mx}),新开仓已冻结,顺势加仓仍可用" + out["position_limit_frozen"] = True + out["freeze_until_ms"] = None + out["freeze_remaining_sec"] = 0 + else: + out["position_limit_frozen"] = False + out["can_roll"] = True + return out + + +def compute_account_risk_status( + conn, + *, + trading_day: str, + now: Optional[datetime] = None, + fmt_local_ms: Optional[Callable[[int], str]] = None, +) -> dict[str, Any]: + if not risk_control_enabled(): + return { + "enabled": False, + "status": STATUS_NORMAL, + "status_label": STATUS_LABELS[STATUS_NORMAL], + "can_trade": True, + "reason": "", + "cooloff_until_ms": None, + "cooloff_until": None, + "manual_close_count": 0, + "daily_frozen": False, + } + row = _sync_trading_day(conn, trading_day, now=now) + now_ms = _now_ms(now) + daily_frozen = int(_row_get(row, "daily_frozen") or 0) == 1 + pending = _row_get(row, "pending_journal_trade_id") + cooloff_until_ms = _resolved_cooloff_until_ms(row, now_ms) + if ( + not daily_frozen + and cooloff_until_ms is not None + and _journaled_manual_cooloff_expired( + conn, trading_day=trading_day, now_ms=now_ms, pending=pending + ) + ): + cooloff_until_ms = None + if not daily_frozen: + _repair_stale_cooloff_row( + conn, row, now_ms=now_ms, resolved_until_ms=cooloff_until_ms, now=now + ) + row = _load_state(conn) + cooloff_until_ms = _resolved_cooloff_until_ms(row, now_ms) + manual_close_count = int(_row_get(row, "manual_close_count") or 0) + + status = STATUS_NORMAL + reason = "" + if daily_frozen: + status = STATUS_DAILY + reason = f"账户今日已冻结(手动平仓 {manual_close_count} 次或复盘情绪标签)" + elif cooloff_until_ms is not None: + remaining_ms = cooloff_until_ms - now_ms + hours = _cooloff_hours_value(row) + status = _freeze_tier_from_remaining_ms(remaining_ms, hours) + status_label = _freeze_status_label(hours, status) + until_str = _ms_to_local_str(cooloff_until_ms, fmt_local_ms) if fmt_local_ms else None + label = status_label + reason = f"账户{label}中" + if until_str: + reason += f",至 {until_str}" + + can_trade = status == STATUS_NORMAL + freeze_remaining_sec = ( + max(0, (cooloff_until_ms - now_ms) // 1000) if cooloff_until_ms is not None else 0 + ) + return { + "enabled": True, + "status": status, + "status_label": _freeze_status_label(_cooloff_hours_value(row), status) + if status in (STATUS_FREEZE_1H, STATUS_FREEZE_4H) + else STATUS_LABELS[status], + "can_trade": can_trade, + "reason": reason, + "cooloff_until_ms": cooloff_until_ms, + "cooloff_until": _ms_to_local_str(cooloff_until_ms, fmt_local_ms) + if fmt_local_ms and cooloff_until_ms + else None, + "manual_close_count": manual_close_count, + "daily_frozen": daily_frozen, + "pending_journal_trade_id": pending, + "freeze_remaining_sec": freeze_remaining_sec if not can_trade else 0, + } + + +def account_risk_blocks_trading( + conn, + *, + trading_day: str, + now: Optional[datetime] = None, + fmt_local_ms: Optional[Callable[[int], str]] = None, +) -> tuple[bool, str]: + """返回 (允许交易, 拒绝原因).""" + st = compute_account_risk_status( + conn, trading_day=trading_day, now=now, fmt_local_ms=fmt_local_ms + ) + if st.get("can_trade"): + return True, "" + return False, str(st.get("reason") or STATUS_LABELS.get(st.get("status"), "账户冻结")) + + +def insert_trade_record_id(conn) -> int: + row = conn.execute("SELECT last_insert_rowid()").fetchone() + return int(row[0] if row else 0) diff --git a/lib/trade/compensating_close_lib.py b/lib/trade/compensating_close_lib.py new file mode 100644 index 0000000..47fbebd --- /dev/null +++ b/lib/trade/compensating_close_lib.py @@ -0,0 +1,16 @@ +"""开仓后挂 TP/SL 失败时的补偿平仓(避免裸仓).""" +from __future__ import annotations + +from typing import Callable + + +def log_compensating_close_error(prefix: str, exc: BaseException) -> None: + print(f"[{prefix}] {exc}", flush=True) + + +def run_compensating_close(close_fn: Callable[[], None], *, log_prefix: str = "compensating_close") -> None: + """执行补偿平仓;二次失败只打日志,不掩盖原始异常.""" + try: + close_fn() + except Exception as e: + log_compensating_close_error(log_prefix, e) diff --git a/lib/trade/daily_open_limit_lib.py b/lib/trade/daily_open_limit_lib.py new file mode 100644 index 0000000..2769e57 --- /dev/null +++ b/lib/trade/daily_open_limit_lib.py @@ -0,0 +1,140 @@ +"""单日开仓次数:软提醒阈值 + 硬上限(三所实例共用).""" +from __future__ import annotations + +import os +from typing import Any, Optional + + +def parse_daily_open_alert_threshold(raw: Any = None, *, default: int = 5) -> int: + """AI 克制提醒阈值;至少 1.""" + try: + v = int(raw if raw is not None and str(raw).strip() != "" else default) + except (TypeError, ValueError): + v = default + return max(1, v) + + +def parse_daily_open_hard_limit(raw: Any = None, *, default: int = 0) -> int: + """硬上限;0 表示不启用.至少 0.""" + try: + v = int(raw if raw is not None and str(raw).strip() != "" else default) + except (TypeError, ValueError): + v = default + return max(0, v) + + +def load_daily_open_limits_from_env( + env: Optional[dict[str, str]] = None, +) -> tuple[int, int]: + """从环境变量读取 (alert_threshold, hard_limit).""" + src = env if env is not None else os.environ + alert = parse_daily_open_alert_threshold(src.get("DAILY_OPEN_ALERT_THRESHOLD")) + hard = parse_daily_open_hard_limit(src.get("DAILY_OPEN_HARD_LIMIT")) + return alert, hard + + +def count_opens_for_trading_day(conn, trading_day: str) -> int: + """本交易日已成功写入 order_monitors 的开仓次数.""" + td = (trading_day or "").strip() + if not td: + return 0 + row = conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", + (td,), + ).fetchone() + return int(row[0] if row else 0) + + +def daily_open_hard_limit_blocks(opens_today: int, hard_limit: int) -> bool: + return int(hard_limit) > 0 and int(opens_today) >= int(hard_limit) + + +def hard_limit_block_reason(opens_today: int, hard_limit: int, reset_hour: int) -> str: + return ( + f"本交易日开仓次数已达上限({int(opens_today)}/{int(hard_limit)})," + f"次日北京时间 {int(reset_hour)}:00 后恢复" + ) + + +def check_daily_open_hard_limit( + conn, + trading_day: str, + hard_limit: int, + reset_hour: int, +) -> tuple[bool, str, int]: + """返回 (允许继续开仓, 拒绝原因, 当日已开次数).""" + opens_today = count_opens_for_trading_day(conn, trading_day) + if daily_open_hard_limit_blocks(opens_today, hard_limit): + return False, hard_limit_block_reason(opens_today, hard_limit, reset_hour), opens_today + return True, "", opens_today + + +def can_trade_new_open( + *, + time_allows: bool, + active_count: int, + max_active_positions: int, + opens_today: int, + hard_limit: int, + extra_blocks: bool = False, +) -> bool: + if extra_blocks: + return False + if not time_allows: + return False + if int(active_count) >= int(max_active_positions): + return False + if daily_open_hard_limit_blocks(opens_today, hard_limit): + return False + return True + + +def should_send_daily_open_alert(before: int, after: int, alert_threshold: int) -> bool: + return int(before) < int(alert_threshold) <= int(after) + + +def build_daily_open_alert_prompt( + trading_day: str, + opens_after: int, + alert_threshold: int, + *, + hard_limit: int = 0, + detail_line: str = "", +) -> str: + hard_txt = ( + f"硬上限 {hard_limit} 次(已达后将禁止新开仓直至下一交易日)." + if int(hard_limit) > 0 + else "未配置单日硬上限." + ) + extra = f" {detail_line}" if detail_line else "" + return ( + f"用户在北京时间交易日 {trading_day} 已累计开仓 {opens_after} 次" + f"(AI 提醒阈值 {alert_threshold};{hard_txt})" + f"{extra}" + f"用户自述“上头了”.请给克制提醒." + ) + + +def format_daily_open_counter_line( + opens_today: int, + alert_threshold: int, + hard_limit: int, +) -> str: + if int(hard_limit) > 0: + return ( + f"📅 当日开仓次数:{int(opens_today)} / 硬上限 {int(hard_limit)} 次" + f"(AI 提醒阈值 {int(alert_threshold)})" + ) + return ( + f"📅 当日开仓次数:{int(opens_today)} / AI 提醒阈值 {int(alert_threshold)} 次" + ) + + +def format_daily_open_summary_short( + opens_today: int, + alert_threshold: int, + hard_limit: int, +) -> str: + if int(hard_limit) > 0: + return f"本交易日累计开仓:{int(opens_today)}(硬上限 {int(hard_limit)},提醒 {int(alert_threshold)})" + return f"本交易日累计开仓:{int(opens_today)}(提醒阈值 {int(alert_threshold)})" diff --git a/lib/trade/entry_model_lib.py b/lib/trade/entry_model_lib.py new file mode 100644 index 0000000..110b704 --- /dev/null +++ b/lib/trade/entry_model_lib.py @@ -0,0 +1,509 @@ +"""趋势户开仓类型:反转·启动 / 顺势·大分歧 / 波段·小分歧;日内户单独 profile.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Mapping, Optional, Sequence, Tuple + +from lib.trade.trade_policy_lib import TradePolicy + +PROFILE_TREND_DIV = "trend_div" +PROFILE_INTRADAY = "intraday" + +ENTRY_CATEGORY_REVERSAL = "reversal" +ENTRY_CATEGORY_TREND = "trend" +ENTRY_CATEGORY_SWING = "swing" + +ENTRY_MODEL_LAUNCH_A = "launch_a" +ENTRY_MODEL_LAUNCH_B = "launch_b" +ENTRY_MODEL_BIG_DIV_A = "big_div_a" +ENTRY_MODEL_BIG_DIV_B = "big_div_b" +ENTRY_MODEL_SMALL_DIV = "small_div" + +ENTRY_CATEGORY_INTRADAY = "intraday" +ENTRY_MODEL_LIQUIDITY_FALSE_BREAK = "liquidity_false_break" +ENTRY_MODEL_STRUCTURE_BREAKOUT = "structure_breakout" + +VALID_ENTRY_MODEL_CODES = frozenset( + { + ENTRY_MODEL_LAUNCH_A, + ENTRY_MODEL_LAUNCH_B, + ENTRY_MODEL_BIG_DIV_A, + ENTRY_MODEL_BIG_DIV_B, + ENTRY_MODEL_SMALL_DIV, + } +) + +INTRADAY_ENTRY_MODEL_CODES = frozenset( + { + ENTRY_MODEL_LIQUIDITY_FALSE_BREAK, + ENTRY_MODEL_STRUCTURE_BREAKOUT, + } +) + +ALL_ENTRY_MODEL_CODES = VALID_ENTRY_MODEL_CODES | INTRADAY_ENTRY_MODEL_CODES + +ENTRY_CATEGORY_LABELS: dict[str, str] = { + ENTRY_CATEGORY_REVERSAL: "反转", + ENTRY_CATEGORY_TREND: "顺势", + ENTRY_CATEGORY_SWING: "波段", +} + +TRADE_STYLE_FALLBACK_ENTRY_REASONS: Tuple[str, ...] = ("趋势单", "波段单") + +INTRADAY_LEGACY_TREND_ENTRY_REASONS: Tuple[str, ...] = ( + "趋势多头:4h大结构突破前进场,确认条件:三次探顶,5m收敛不创新低", + "趋势空头:4h大结构突破前进场,确认条件:三次探底,5m收敛不创新高", + "趋势多头:小分歧低吸入场(左侧),确认条件:二次探底", + "趋势空头:小分歧高吸入场(左侧),确认条件:二次探顶", + "波段单:5m顺势突破,确认条件:2根k线+成交量放大+4h同向+日成交量前20", +) + +# code, label, category, trade_style, help +_ENTRY_SPECS: Tuple[Tuple[str, str, str, str, str], ...] = ( + ( + ENTRY_MODEL_LAUNCH_A, + "启动A", + ENTRY_CATEGORY_REVERSAL, + "trend", + "反转链结构内:摸参考极值前小收敛,或 B 失败后 5m N 字试仓(不单列)", + ), + ( + ENTRY_MODEL_LAUNCH_B, + "启动B", + ENTRY_CATEGORY_REVERSAL, + "trend", + "第二次到参考极值附近,无小收敛时的实体突破", + ), + ( + ENTRY_MODEL_BIG_DIV_A, + "大分歧A", + ENTRY_CATEGORY_TREND, + "trend", + "主升已确立:突破前收敛,不创新低企稳(空:不创新高)", + ), + ( + ENTRY_MODEL_BIG_DIV_B, + "大分歧B", + ENTRY_CATEGORY_TREND, + "trend", + "主升已确立:结构实体突破确认后入场", + ), + ( + ENTRY_MODEL_SMALL_DIV, + "小分歧", + ENTRY_CATEGORY_SWING, + "swing", + "主升已确立:二次探底 N 字或 5m 三均线重新多头(空:二次探顶 / 空头均线)", + ), +) + +_INTRADAY_ENTRY_SPECS: Tuple[Tuple[str, str, str, str, str], ...] = ( + ( + ENTRY_MODEL_LIQUIDITY_FALSE_BREAK, + "假破", + ENTRY_CATEGORY_INTRADAY, + "trend", + "流动性扫单 → 假突破验证 → 5m N 字 → 15m 顶/底分型", + ), + ( + ENTRY_MODEL_STRUCTURE_BREAKOUT, + "结构突破", + ENTRY_CATEGORY_INTRADAY, + "trend", + "15m 结构有效突破(收盘确认)", + ), +) + +_CODE_TO_LABEL = {code: label for code, label, _, _, _ in _ENTRY_SPECS} +_CODE_TO_LABEL.update({code: label for code, label, _, _, _ in _INTRADAY_ENTRY_SPECS}) +_CODE_TO_STYLE = {code: style for code, _, _, style, _ in _ENTRY_SPECS} +_CODE_TO_STYLE.update({code: style for code, _, _, style, _ in _INTRADAY_ENTRY_SPECS}) +_CODE_TO_CATEGORY = {code: cat for code, _, cat, _, _ in _ENTRY_SPECS} +_CODE_TO_CATEGORY.update({code: cat for code, _, cat, _, _ in _INTRADAY_ENTRY_SPECS}) +_LABEL_TO_CODE = {label: code for code, label, _, _, _ in _ENTRY_SPECS} +_LABEL_TO_CODE.update({label: code for code, label, _, _, _ in _INTRADAY_ENTRY_SPECS}) +_CODE_TO_HELP = {code: help for code, _, _, _, help in _ENTRY_SPECS} +_CODE_TO_HELP.update({code: help for code, _, _, _, help in _INTRADAY_ENTRY_SPECS}) + +_CATEGORY_ORDER: Tuple[str, ...] = ( + ENTRY_CATEGORY_REVERSAL, + ENTRY_CATEGORY_TREND, + ENTRY_CATEGORY_SWING, +) + +_INTRADAY_WHITELIST = frozenset({"BTC", "ETH"}) + + +@dataclass(frozen=True) +class EntryModelOption: + code: str + label: str + category: str + trade_style: str + help: str + + +def is_intraday_trading_profile(policy: TradePolicy) -> bool: + """日内户:启用 BTC/ETH 白名单(env 中 TRADE_SYMBOL_WHITELIST).""" + if not policy.symbol_restrict_enabled: + return False + if not policy.symbol_whitelist: + return False + return all(s in _INTRADAY_WHITELIST for s in policy.symbol_whitelist) + + +def order_entry_profile(policy: TradePolicy) -> str: + return PROFILE_INTRADAY if is_intraday_trading_profile(policy) else PROFILE_TREND_DIV + + +def entry_model_options() -> Tuple[EntryModelOption, ...]: + return tuple( + EntryModelOption(code=code, label=label, category=cat, trade_style=style, help=help) + for code, label, cat, style, help in _ENTRY_SPECS + ) + + +def intraday_entry_model_options() -> Tuple[EntryModelOption, ...]: + return tuple( + EntryModelOption(code=code, label=label, category=cat, trade_style=style, help=help) + for code, label, cat, style, help in _INTRADAY_ENTRY_SPECS + ) + + +def entry_model_categories() -> list[dict[str, Any]]: + """两级 UI:反转 / 顺势 / 波段 → 子选项.""" + opts = entry_model_options() + out: list[dict[str, Any]] = [] + for cat_key in _CATEGORY_ORDER: + children = [ + { + "code": o.code, + "label": o.label, + "trade_style": o.trade_style, + "help": o.help, + } + for o in opts + if o.category == cat_key + ] + if not children: + continue + out.append( + { + "key": cat_key, + "label": ENTRY_CATEGORY_LABELS.get(cat_key, cat_key), + "options": children, + } + ) + return out + + +def entry_model_category(code: Optional[str]) -> str: + c = normalize_entry_model_code(code) + return _CODE_TO_CATEGORY.get(c, "") + + +def normalize_entry_model_code(raw: Optional[str]) -> str: + v = (raw or "").strip().lower() + if v in ALL_ENTRY_MODEL_CODES: + return v + label = (raw or "").strip() + if label in _LABEL_TO_CODE: + return _LABEL_TO_CODE[label] + return "" + + +def entry_model_label(code: Optional[str]) -> str: + c = normalize_entry_model_code(code) + return _CODE_TO_LABEL.get(c, "") + + +def entry_category_display_prefix(category: str) -> str: + """两级展示用的一级前缀:反转 / 顺势 / 波段单(含日内).""" + cat = (category or "").strip() + if cat in (ENTRY_CATEGORY_SWING, ENTRY_CATEGORY_INTRADAY): + return "波段单" + return ENTRY_CATEGORY_LABELS.get(cat, "") + + +def entry_model_display_label(code: Optional[str]) -> str: + """两级展示:反转/启动A,顺势/大分歧A,波段单/小分歧,波段单/假破.""" + c = normalize_entry_model_code(code) + if not c: + return "" + label = entry_model_label(c) + if not label: + return "" + prefix = entry_category_display_prefix(entry_model_category(c)) + if prefix: + return f"{prefix}/{label}" + return label + + +def format_entry_type_display( + text: Optional[str] = None, + *, + entry_model: Optional[str] = None, + trade_style: Optional[str] = None, +) -> str: + """交易记录/持仓展示:已知 entry_model 或短标签 → 两级文案.""" + if entry_model: + disp = entry_model_display_label(entry_model) + if disp: + return disp + raw = (text or "").strip() + if not raw: + ts = (trade_style or "").strip().lower() + if ts in ("trend", "swing"): + return trade_style_label_zh(ts) + return "" + if "/" in raw: + return raw + code = normalize_entry_model_code(raw) + if code: + disp = entry_model_display_label(code) + if disp: + return disp + return raw + + +def trade_style_for_entry_model(code: Optional[str]) -> str: + c = normalize_entry_model_code(code) + return _CODE_TO_STYLE.get(c, "trend") + + +def trade_style_label_zh(trade_style: str) -> str: + return "波段单" if (trade_style or "").strip().lower() == "swing" else "趋势单" + + +def trend_div_entry_reason_display_options() -> Tuple[str, ...]: + return tuple(entry_model_display_label(code) for code, _, _, _, _ in _ENTRY_SPECS) + + +def intraday_entry_reason_display_options() -> Tuple[str, ...]: + return tuple(entry_model_display_label(code) for code, _, _, _, _ in _INTRADAY_ENTRY_SPECS) + + +def normalize_review_entry_reason(raw: Optional[str], allowed: Sequence[str]) -> str: + """复盘/核对开仓类型:允许两级展示名,兼容旧短标签.""" + s = (raw or "").strip() + if not s: + return "" + allowed_set = frozenset(allowed) + if s in allowed_set: + return s + disp = format_entry_type_display(s) + if disp in allowed_set: + return disp + code = normalize_entry_model_code(s) + if code: + disp2 = entry_model_display_label(code) + if disp2 in allowed_set: + return disp2 + return "" + + +# 日内复盘开仓类型:手动假破/结构突破 + 自动触价(与 KEY_ENTRY_REASON_TRIGGER_OPTIONS 一致) +_INTRADAY_JOURNAL_KEY_ENTRY_REASONS: Tuple[str, ...] = ( + "关键位回调触价开仓", + "关键位突破触价开仓", +) + + +def trend_manual_entry_reason_count(policy: TradePolicy) -> int: + if is_intraday_trading_profile(policy): + return len(intraday_entry_reason_display_options()) + return len(trend_div_entry_reason_display_options()) + + +def build_journal_entry_reason_options() -> Tuple[str, ...]: + """复盘开仓类型:仅 entry model,不含 trade_style 兜底与策略下单类型.""" + return trend_div_entry_reason_display_options() + + +def build_trend_div_entry_reason_options( + strategy_options: Sequence[str], +) -> Tuple[str, ...]: + return trend_div_entry_reason_display_options() + TRADE_STYLE_FALLBACK_ENTRY_REASONS + tuple(strategy_options) + + +def build_intraday_entry_reason_options( + key_options: Sequence[str], + strategy_options: Sequence[str], +) -> Tuple[str, ...]: + del strategy_options # 日内户无趋势回调/顺势加仓 + del key_options + return intraday_entry_reason_display_options() + _INTRADAY_JOURNAL_KEY_ENTRY_REASONS + + +def entry_reason_options_for_policy( + policy: TradePolicy, + key_options: Sequence[str], + strategy_options: Sequence[str], +) -> Tuple[str, ...]: + if is_intraday_trading_profile(policy): + return build_intraday_entry_reason_options(key_options, strategy_options) + return build_trend_div_entry_reason_options(strategy_options) + + +def parse_manual_order_style_fields( + policy: TradePolicy, + form: Mapping[str, Any], + *, + default_trade_style: str = "trend", +) -> Tuple[str, Optional[str], Optional[str]]: + """返回 (trade_style, entry_model_code|None, error_message|None).""" + if is_intraday_trading_profile(policy): + entry_model = normalize_entry_model_code(form.get("entry_model")) + if entry_model in INTRADAY_ENTRY_MODEL_CODES: + return "trend", entry_model, None + raw_style = (form.get("trade_style") or "").strip().lower() + if raw_style in ("trend", "swing"): + return raw_style, None, None + return "", None, "请选择开仓类型(假破 / 结构突破)" + + entry_model = normalize_entry_model_code(form.get("entry_model")) + if not entry_model: + return "", None, "请选择开仓类型(反转 / 顺势 / 波段)" + trade_style = trade_style_for_entry_model(entry_model) + return trade_style, entry_model, None + + +def resolve_trade_record_entry_reason( + *, + entry_reason: Optional[str] = None, + entry_model: Optional[str] = None, + key_signal_type: Optional[str] = None, + monitor_type: Optional[str] = None, + trade_style: Optional[str] = None, + entry_reason_from_key_signal=None, + entry_reason_for_monitor_type=None, +) -> str: + er = (entry_reason or "").strip() + if er: + return er + label = entry_model_display_label(entry_model) + if label: + return label + kst = (key_signal_type or "").strip() + if kst and entry_reason_from_key_signal is not None: + from_key = (entry_reason_from_key_signal(kst) or "").strip() + if from_key: + return from_key + if entry_reason_for_monitor_type is not None: + from_mt = (entry_reason_for_monitor_type(monitor_type) or "").strip() + if from_mt: + return from_mt + ts = (trade_style or "").strip().lower() + if ts in ("trend", "swing"): + return trade_style_label_zh(ts) + return "" + + +def resolve_effective_trade_entry_reason( + *, + reviewed_entry_reason: Optional[str] = None, + entry_reason: Optional[str] = None, + entry_model: Optional[str] = None, + key_signal_type: Optional[str] = None, + monitor_type: Optional[str] = None, + trade_style: Optional[str] = None, + entry_reason_from_key_signal=None, + entry_reason_for_monitor_type=None, +) -> str: + """交易记录展示/导出用:复盘优先,再回落 entry_model / 关键位 / 策略 / trade_style.""" + for raw in (reviewed_entry_reason, entry_reason): + er = (raw or "").strip() + if er: + return format_entry_type_display( + er, + entry_model=entry_model, + trade_style=trade_style, + ) + return format_entry_type_display( + resolve_trade_record_entry_reason( + entry_model=entry_model, + key_signal_type=key_signal_type, + monitor_type=monitor_type, + trade_style=trade_style, + entry_reason_from_key_signal=entry_reason_from_key_signal, + entry_reason_for_monitor_type=entry_reason_for_monitor_type, + ), + entry_model=entry_model, + trade_style=trade_style, + ) + + +def enrich_entry_model_display(item: dict) -> dict: + code = normalize_entry_model_code(item.get("entry_model")) + if code: + item["entry_model"] = code + item["entry_model_label"] = entry_model_display_label(code) + cat = entry_model_category(code) + if cat: + item["entry_model_category"] = cat + item["entry_model_category_label"] = ENTRY_CATEGORY_LABELS.get(cat, "") + else: + item.setdefault("entry_model_label", "") + return item + + +def open_position_button_label(policy: TradePolicy, sizing_mode: str) -> str: + from lib.trade.position_sizing_lib import mode_label_zh + + mode_txt = mode_label_zh(sizing_mode) + if is_intraday_trading_profile(policy): + return f"开仓(日内·{mode_txt})" + return f"开仓({mode_txt})" + + +def order_entry_template_context(policy: TradePolicy) -> dict: + profile = order_entry_profile(policy) + opts = entry_model_options() + intraday_opts = intraday_entry_model_options() + return { + "order_entry_profile": profile, + "intraday_discipline": profile == PROFILE_INTRADAY, + "intraday_entry_model_options": [ + { + "code": o.code, + "label": o.label, + "trade_style": o.trade_style, + "help": o.help, + } + for o in intraday_opts + ], + "entry_model_options": [ + { + "code": o.code, + "label": o.label, + "category": o.category, + "trade_style": o.trade_style, + "help": o.help, + } + for o in opts + ], + "entry_model_categories": entry_model_categories(), + "entry_model_trade_style_map": {o.code: o.trade_style for o in opts}, + "entry_model_code_to_category": {o.code: o.category for o in opts}, + } + + +def hub_meta_entry_context(policy: TradePolicy) -> dict: + """供 /api/hub/meta:中控按 profile 隐藏平仓/委托等.""" + profile = order_entry_profile(policy) + return { + "order_entry_profile": profile, + "intraday_discipline": profile == PROFILE_INTRADAY, + } + + +def migrate_entry_model_columns(conn) -> None: + for table in ("order_monitors", "trade_records"): + try: + conn.execute(f"ALTER TABLE {table} ADD COLUMN entry_model TEXT") + except Exception: + pass + + +# 兼容旧引用:现为两级展示文案 +TREND_DIV_ENTRY_REASON_LABELS = trend_div_entry_reason_display_options() diff --git a/lib/trade/force_close_lib.py b/lib/trade/force_close_lib.py new file mode 100644 index 0000000..46165e0 --- /dev/null +++ b/lib/trade/force_close_lib.py @@ -0,0 +1,320 @@ +"""整点强制清仓(FORCE_CLOSE_*):UI 标识与持仓倒计时.""" +from __future__ import annotations + +import os +import time +from datetime import datetime, timedelta +from typing import Any, Optional +from zoneinfo import ZoneInfo + +FORCE_CLOSE_RESULT = "强制清仓" +FORCE_CLOSE_GRACE_MINUTES = 15 + + +def app_timezone_name() -> str: + return (os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip() or "Asia/Shanghai" + + +def normalize_force_close_bj_hour(value: Any) -> int: + try: + h = int(value) + except (TypeError, ValueError): + return 0 + return max(0, min(23, h)) + + +def _now_dt(*, now_ms: Optional[int] = None, tz_name: Optional[str] = None) -> datetime: + tz = ZoneInfo(tz_name or app_timezone_name()) + if now_ms is None: + return datetime.now(tz) + return datetime.fromtimestamp(int(now_ms) / 1000, tz=tz) + + +def force_close_hour_label(bj_hour: Any) -> str: + return f"{normalize_force_close_bj_hour(bj_hour):02d}:00" + + +def force_close_label(bj_hour: Any) -> str: + return f"强制清仓 {force_close_hour_label(bj_hour)}" + + +def is_force_close_active_hour( + bj_hour: Any, + *, + now_ms: Optional[int] = None, + tz_name: Optional[str] = None, + grace_minutes: int = FORCE_CLOSE_GRACE_MINUTES, +) -> bool: + """当前是否处于整点强制清仓执行窗口(整点起 grace 分钟内).""" + return is_force_close_executing( + bj_hour, + now_ms=now_ms, + tz_name=tz_name, + grace_minutes=grace_minutes, + ) + + +def is_force_close_executing( + bj_hour: Any, + *, + now_ms: Optional[int] = None, + tz_name: Optional[str] = None, + grace_minutes: int = FORCE_CLOSE_GRACE_MINUTES, +) -> bool: + hour = normalize_force_close_bj_hour(bj_hour) + now = _now_dt(now_ms=now_ms, tz_name=tz_name) + target = now.replace(hour=hour, minute=0, second=0, microsecond=0) + if now < target: + return False + end = target + timedelta(minutes=max(1, int(grace_minutes))) + return now < end + + +def parse_closed_at_dt( + closed_at: Any, + *, + tz_name: Optional[str] = None, +) -> Optional[datetime]: + if closed_at is None: + return None + text = str(closed_at).strip() + if not text: + return None + tz = ZoneInfo(tz_name or app_timezone_name()) + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M"): + try: + return datetime.strptime(text, fmt).replace(tzinfo=tz) + except ValueError: + continue + return None + + +def is_close_at_force_close_window( + closed_at: Any, + bj_hour: Any, + *, + grace_minutes: int = FORCE_CLOSE_GRACE_MINUTES, + tz_name: Optional[str] = None, +) -> bool: + """平仓时刻是否落在北京时间整点强制清仓窗口内.""" + dt = parse_closed_at_dt(closed_at, tz_name=tz_name) + if dt is None: + return False + hour = normalize_force_close_bj_hour(bj_hour) + if dt.hour != hour: + return False + return dt.minute < max(1, int(grace_minutes)) + + +def infer_force_close_result( + closed_at: Any, + *, + enabled: bool, + bj_hour: Any, + grace_minutes: int = FORCE_CLOSE_GRACE_MINUTES, + tz_name: Optional[str] = None, +) -> Optional[str]: + if not enabled: + return None + if is_close_at_force_close_window( + closed_at, bj_hour, grace_minutes=grace_minutes, tz_name=tz_name + ): + return FORCE_CLOSE_RESULT + return None + + +def coerce_force_close_result( + result: Optional[str], + closed_at: Any, + *, + enabled: bool, + bj_hour: Any, + miss_reason: Optional[str] = None, + grace_minutes: int = FORCE_CLOSE_GRACE_MINUTES, + tz_name: Optional[str] = None, +) -> tuple[str, str]: + """同步平仓归类:整点窗口内优先记为强制清仓.""" + res = (result or "").strip() + note = (miss_reason or "").strip() + if res == FORCE_CLOSE_RESULT: + return res, note + fc = infer_force_close_result( + closed_at, + enabled=enabled, + bj_hour=bj_hour, + grace_minutes=grace_minutes, + tz_name=tz_name, + ) + if not fc: + return res, note + if not note: + note = f"北京时间 {force_close_hour_label(bj_hour)} 整点风控清仓" + return fc, note + + +def apply_force_close_display_result( + result: Optional[str], + closed_at: Any, + *, + enabled: bool, + bj_hour: Any, + grace_minutes: int = FORCE_CLOSE_GRACE_MINUTES, + tz_name: Optional[str] = None, +) -> str: + """展示层:外部平仓/手动平仓若落在整点窗口,显示为强制清仓.""" + res = (result or "").strip() + if res == FORCE_CLOSE_RESULT: + return res + fc = infer_force_close_result( + closed_at, + enabled=enabled, + bj_hour=bj_hour, + grace_minutes=grace_minutes, + tz_name=tz_name, + ) + if fc and (res in ("", "外部平仓", "手动平仓") or res.startswith("外部平仓")): + return fc + return res + + +def compute_next_force_close_at_ms( + *, + bj_hour: Any, + now_ms: Optional[int] = None, + tz_name: Optional[str] = None, +) -> Optional[int]: + """下一次强制清仓时刻(北京时间整点)的 epoch 毫秒.""" + hour = normalize_force_close_bj_hour(bj_hour) + now = _now_dt(now_ms=now_ms, tz_name=tz_name) + target = now.replace(hour=hour, minute=0, second=0, microsecond=0) + if now >= target: + target += timedelta(days=1) + return int(target.timestamp() * 1000) + + +def force_close_remaining_seconds( + close_at_ms: Any, + *, + now_ms: Optional[int] = None, +) -> Optional[int]: + try: + close_at = int(close_at_ms) + except (TypeError, ValueError): + return None + now = int(now_ms if now_ms is not None else time.time() * 1000) + return max(0, int((close_at - now) / 1000)) + + +def format_force_close_countdown(seconds: Any, *, active: bool = False) -> str: + if active: + return "执行中" + try: + sec = max(0, int(seconds)) + except (TypeError, ValueError): + return "--:--:--" + h = sec // 3600 + m = (sec % 3600) // 60 + s = sec % 60 + return f"{h:02d}:{m:02d}:{s:02d}" + + +def build_force_close_state( + enabled: bool, + bj_hour: Any, + *, + now_ms: Optional[int] = None, + tz_name: Optional[str] = None, + has_active_positions: Optional[bool] = None, +) -> dict[str, Any]: + """实例级强制清仓状态(模板 / API 共用).""" + if not enabled: + return { + "enabled": False, + "bj_hour": normalize_force_close_bj_hour(bj_hour), + "hour_label": force_close_hour_label(bj_hour), + "label": force_close_label(bj_hour), + "next_at_ms": None, + "remaining_sec": None, + "countdown": "", + "active": False, + } + hour = normalize_force_close_bj_hour(bj_hour) + executing = is_force_close_executing(hour, now_ms=now_ms, tz_name=tz_name) + active = executing and (has_active_positions is not False) + next_at_ms = compute_next_force_close_at_ms(bj_hour=hour, now_ms=now_ms, tz_name=tz_name) + rem = force_close_remaining_seconds(next_at_ms, now_ms=now_ms) if next_at_ms else None + return { + "enabled": True, + "bj_hour": hour, + "hour_label": force_close_hour_label(hour), + "label": force_close_label(hour), + "next_at_ms": next_at_ms, + "remaining_sec": rem, + "countdown": format_force_close_countdown(rem, active=active), + "active": active, + } + + +def force_close_template_context( + enabled: bool, + bj_hour: Any, + *, + now_ms: Optional[int] = None, + tz_name: Optional[str] = None, + has_active_positions: Optional[bool] = None, +) -> dict[str, dict[str, Any]]: + return { + "force_close": build_force_close_state( + enabled, + bj_hour, + now_ms=now_ms, + tz_name=tz_name, + has_active_positions=has_active_positions, + ) + } + + +def apply_force_close_to_payload( + payload: dict[str, Any], + *, + enabled: bool, + bj_hour: Any, + now_ms: Optional[int] = None, + tz_name: Optional[str] = None, +) -> None: + """为 active 持仓 JSON 附加整点强制清仓倒计时.""" + state = build_force_close_state( + enabled, + bj_hour, + now_ms=now_ms, + tz_name=tz_name, + has_active_positions=True, + ) + payload["force_close_enabled"] = bool(state["enabled"]) + payload["force_close_bj_hour"] = state["bj_hour"] + payload["force_close_at_ms"] = state["next_at_ms"] + payload["force_close_label"] = state["label"] if state["enabled"] else "" + payload["force_close_remaining_sec"] = state["remaining_sec"] + payload["force_close_countdown"] = state["countdown"] + payload["force_close_active"] = bool(state["active"]) + + +def enrich_orders_force_close( + orders: list[dict[str, Any]], + enabled: bool, + bj_hour: Any, + *, + now_ms: Optional[int] = None, + tz_name: Optional[str] = None, +) -> None: + if not enabled or not orders: + return + for item in orders: + if isinstance(item, dict): + apply_force_close_to_payload( + item, + enabled=enabled, + bj_hour=bj_hour, + now_ms=now_ms, + tz_name=tz_name, + ) diff --git a/lib/trade/manual_sltp_lib.py b/lib/trade/manual_sltp_lib.py new file mode 100644 index 0000000..41a5783 --- /dev/null +++ b/lib/trade/manual_sltp_lib.py @@ -0,0 +1,136 @@ +"""实盘人工下单:止盈止损模式(价格 / 百分比 / 固定盈亏比).""" +from __future__ import annotations + +from typing import Any, Optional, Tuple + +MANUAL_FIXED_RR_DEFAULT = 1.5 + +SLTP_MODE_PRICE = "price" +SLTP_MODE_PCT = "pct" +SLTP_MODE_FIXED_RR = "fixed_rr" + +OPEN_SLTP_MODES = frozenset({SLTP_MODE_PRICE, SLTP_MODE_PCT, SLTP_MODE_FIXED_RR}) +ENTRUST_SLTP_MODES = frozenset({SLTP_MODE_PRICE, SLTP_MODE_PCT}) + + +def normalize_open_sltp_mode(raw: Optional[str]) -> str: + mode = (raw or SLTP_MODE_FIXED_RR).strip().lower() + if mode in OPEN_SLTP_MODES: + return mode + return SLTP_MODE_PRICE + + +def normalize_entrust_sltp_mode(raw: Optional[str]) -> str: + mode = (raw or SLTP_MODE_PRICE).strip().lower() + if mode in ENTRUST_SLTP_MODES: + return mode + return SLTP_MODE_PRICE + + +def parse_fixed_rr(raw: Any, *, default: float = MANUAL_FIXED_RR_DEFAULT) -> float: + try: + v = float(raw) + if v > 0: + return v + except (TypeError, ValueError): + pass + return float(default) + + +def calc_tp_from_fixed_rr( + direction: str, + entry_price: float, + stop_loss: float, + rr_ratio: float, +) -> float: + entry = float(entry_price) + sl = float(stop_loss) + rr = float(rr_ratio) + if entry <= 0 or sl <= 0 or rr <= 0: + raise ValueError("固定盈亏比参数无效") + side = (direction or "long").strip().lower() + if side == "short": + risk = sl - entry + if risk <= 0: + raise ValueError("止损方向不合法:做空时止损须高于入场价") + return entry - risk * rr + risk = entry - sl + if risk <= 0: + raise ValueError("止损方向不合法:做多时止损须低于入场价") + return entry + risk * rr + + +def _resolve_pct_sltp(direction: str, live_price: float, data: dict[str, Any]) -> Tuple[float, float]: + 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 or "long").strip().lower() == "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) + return stop_loss, take_profit + + +def _resolve_price_sltp( + data: dict[str, Any], + *, + fallback_sl: Optional[float] = None, + fallback_tp: Optional[float] = None, + require_tp: bool = True, +) -> Tuple[float, float]: + 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 and fallback_sl is not None: + stop_loss = float(fallback_sl) + if take_profit <= 0 and fallback_tp is not None: + take_profit = float(fallback_tp) + if stop_loss <= 0: + raise ValueError("止损价格须大于 0" if require_tp else "请填写止损价格") + if require_tp and take_profit <= 0: + raise ValueError("止盈止损价格须大于 0" if fallback_tp is None else "请填写止盈价格,或保留原计划止盈") + return stop_loss, take_profit + + +def resolve_open_sltp_prices( + direction: str, + live_price: float, + sltp_mode: Optional[str], + data: dict[str, Any], +) -> Tuple[float, float]: + """新开仓 /add_order:支持 price,pct,fixed_rr.""" + mode = normalize_open_sltp_mode(sltp_mode) + if mode == SLTP_MODE_PCT: + return _resolve_pct_sltp(direction, live_price, data) + if mode == SLTP_MODE_FIXED_RR: + stop_loss, _ = _resolve_price_sltp(data, require_tp=False) + rr = parse_fixed_rr(data.get("fixed_rr")) + take_profit = calc_tp_from_fixed_rr(direction, live_price, stop_loss, rr) + return stop_loss, take_profit + return _resolve_price_sltp(data, require_tp=True) + + +def resolve_entrust_sltp_prices( + direction: str, + live_price: float, + sltp_mode: Optional[str], + data: dict[str, Any], + *, + fallback_sl: Optional[float] = None, + fallback_tp: Optional[float] = None, +) -> Tuple[float, float]: + """持仓委托弹窗:仅 price / pct,不校验盈亏比.""" + mode = normalize_entrust_sltp_mode(sltp_mode) + if mode == SLTP_MODE_PCT: + return _resolve_pct_sltp(direction, live_price, data) + return _resolve_price_sltp( + data, + fallback_sl=fallback_sl, + fallback_tp=fallback_tp, + require_tp=True, + ) diff --git a/lib/trade/order_monitor_display_lib.py b/lib/trade/order_monitor_display_lib.py new file mode 100644 index 0000000..9b23cc8 --- /dev/null +++ b/lib/trade/order_monitor_display_lib.py @@ -0,0 +1,452 @@ +"""实时持仓展示:开仓快照盈亏比,交易所止损是否已保本.""" +from __future__ import annotations + +from typing import Any, Callable, Optional + + +def _positive_float(value: Any) -> Optional[float]: + try: + v = float(value) + return v if v > 0 else None + except (TypeError, ValueError): + return None + + +def snapshot_stop_loss(initial_stop_loss: Any, stop_loss: Any) -> Optional[float]: + """展示盈亏比 / 交易记录时优先用开仓时止损快照,不用后续改单后的止损.""" + sl = _positive_float(initial_stop_loss) + if sl is not None: + return sl + return _positive_float(stop_loss) + + +def monitor_open_stop_loss(row: Any) -> Optional[float]: + """从 order_monitors 行取开仓止损快照.""" + try: + keys = row.keys() if hasattr(row, "keys") else () + except Exception: + keys = () + init = row["initial_stop_loss"] if "initial_stop_loss" in keys else None + cur = row["stop_loss"] if "stop_loss" in keys else None + if init is None and isinstance(row, dict): + init = row.get("initial_stop_loss") + cur = row.get("stop_loss") + return snapshot_stop_loss(init, cur) + + +def snapshot_rr( + calc_rr_ratio_fn: Callable[..., Optional[float]], + direction: str, + trigger_price: Any, + initial_stop_loss: Any, + stop_loss: Any, + take_profit: Any, +) -> Optional[float]: + entry = _positive_float(trigger_price) + sl = snapshot_stop_loss(initial_stop_loss, stop_loss) + tp = _positive_float(take_profit) + if entry is None or sl is None or tp is None: + return None + return calc_rr_ratio_fn(direction or "long", entry, sl, tp) + + +def tpsl_slot_trigger_price(slot: Any) -> Optional[float]: + if not isinstance(slot, dict): + return None + for key in ("trigger_price", "trigger_display"): + v = _positive_float(slot.get(key)) + if v is not None: + return v + return None + + +def stop_is_profit_protecting(direction: str, entry_price: Any, stop_loss: Any) -> bool: + """ + 止损是否已在盈利侧(保本/锁盈),不再适用「开仓盈亏比」风控. + 做空:止损 < 成交价;做多:止损 > 成交价. + """ + entry = _positive_float(entry_price) + sl = _positive_float(stop_loss) + if entry is None or sl is None: + return False + d = (direction or "long").strip().lower() + if d == "short": + return sl < entry + return sl > entry + + +def tpsl_update_passes_rr_gate( + direction: str, + entry_price: Any, + stop_loss: Any, + take_profit: Any, + min_rr: float, + calc_rr_ratio_fn: Callable[..., Optional[float]], +) -> tuple[bool, Optional[str]]: + """持仓委托改价:盈利侧止损跳过最低盈亏比;否则按开仓价几何校验.""" + if stop_is_profit_protecting(direction, entry_price, stop_loss): + return True, None + rr = calc_rr_ratio_fn(direction or "long", entry_price, stop_loss, take_profit) + if rr is not None and rr >= float(min_rr): + return True, None + rr_txt = f"{rr:.4f}" if rr is not None else "无法计算" + return False, f"计划盈亏比 {rr_txt}:1 低于最低要求 {min_rr}:1(盈利侧保本止损不受此限)" + + +def resolve_breakeven_entry_price(entry_price: Any, avg_entry_price: Any = None) -> Optional[float]: + """保本判断基准价:有持仓加权均价时优先(滚仓后),否则用首仓成交价.""" + avg = _positive_float(avg_entry_price) + if avg is not None: + return avg + return _positive_float(entry_price) + + +def stale_breakeven_armed(direction: str, entry_price: Any, stop_loss: Any, breakeven_armed: Any) -> bool: + """止损已回到亏损侧时 breakeven_armed 视为过期(如滚仓下移止损).""" + try: + armed = int(breakeven_armed or 0) != 0 + except (TypeError, ValueError): + return False + if not armed: + return False + return not stop_is_profit_protecting(direction, entry_price, stop_loss) + + +def is_sl_breakeven_secured(direction: str, entry_price: Any, exchange_sl_price: Any) -> bool: + """ + 交易所当前止损相对开仓成交价是否已保本. + 做多:止损 >= 成交价;做空:止损 <= 成交价. + """ + entry = _positive_float(entry_price) + sl = _positive_float(exchange_sl_price) + if entry is None or sl is None: + return False + d = (direction or "long").strip().lower() + if d == "short": + return sl <= entry + return sl >= entry + + +def sl_breakeven_from_exchange_tpsl( + direction: str, + entry_price: Any, + exchange_tpsl: Any, +) -> bool: + if not isinstance(exchange_tpsl, dict): + return False + sl_px = tpsl_slot_trigger_price(exchange_tpsl.get("sl")) + if sl_px is None: + return False + return is_sl_breakeven_secured(direction, entry_price, sl_px) + + +def enrich_order_display_fields(item: dict[str, Any], calc_rr_ratio_fn: Callable[..., Optional[float]]) -> dict[str, Any]: + item["rr_ratio"] = snapshot_rr( + calc_rr_ratio_fn, + item.get("direction") or "long", + item.get("trigger_price"), + item.get("initial_stop_loss"), + item.get("stop_loss"), + item.get("take_profit"), + ) + return item + + +def apply_order_live_price_display( + payload: dict[str, Any], + symbol: Any, + ticker_price: Any, + exchange_mark_price: Any, + format_price_fn: Callable[[Any, Any], str], +) -> dict[str, Any]: + """标记价/现价展示:与交易所 price_to_precision 对齐,避免前端 toFixed(8).""" + px_for_fmt = ticker_price + mark_raw = exchange_mark_price + if mark_raw is not None: + try: + px_for_fmt = float(mark_raw) + except (TypeError, ValueError): + pass + px_disp = format_price_fn(symbol, px_for_fmt) + payload["price_display"] = px_disp + if mark_raw is not None: + try: + payload["exchange_mark_price_display"] = format_price_fn(symbol, float(mark_raw)) + except (TypeError, ValueError): + payload["exchange_mark_price_display"] = px_disp + else: + payload["exchange_mark_price_display"] = None + return payload + + +def resolve_live_tpsl_prices( + plan_sl: Any, + plan_tp: Any, + exchange_tpsl: Any, +) -> tuple[Optional[float], Optional[float], Optional[float], Optional[float]]: + """返回 (展示用止损, 展示用止盈, 交易所止损, 交易所止盈).""" + ex_sl = ex_tp = None + if isinstance(exchange_tpsl, dict): + ex_sl = tpsl_slot_trigger_price(exchange_tpsl.get("sl")) + ex_tp = tpsl_slot_trigger_price(exchange_tpsl.get("tp")) + disp_sl = ex_sl if ex_sl is not None else _positive_float(plan_sl) + disp_tp = ex_tp if ex_tp is not None else _positive_float(plan_tp) + return disp_sl, disp_tp, ex_sl, ex_tp + + +def calc_risk_fraction(direction: str, entry_price: Any, stop_loss: Any) -> Optional[float]: + """|入场-止损|/入场;盈利侧止损返回 0.""" + entry = _positive_float(entry_price) + sl = _positive_float(stop_loss) + if entry is None or sl is None: + return None + d = (direction or "long").strip().lower() + if d == "short": + risk = sl - entry + else: + risk = entry - sl + if risk <= 0: + return 0.0 + return risk / entry + + +def calc_latest_risk_amount( + direction: str, + entry_price: Any, + stop_loss: Any, + *, + margin_capital: Any = None, + leverage: Any = None, + exchange_notional: Any = None, + contracts: Any = None, + contract_size: Any = None, + mark_price: Any = None, + funds_decimals: int = 2, +) -> Optional[float]: + """按当前止损与持仓名义价值估算最新风险(U).""" + rf = calc_risk_fraction(direction, entry_price, stop_loss) + if rf is None: + return None + if rf <= 0: + return 0.0 + notional = _positive_float(exchange_notional) + if notional is None: + try: + mc = float(margin_capital or 0) + lev = float(leverage or 0) + if mc > 0 and lev > 0: + notional = mc * lev + except (TypeError, ValueError): + pass + if notional is None: + try: + c = abs(float(contracts or 0)) + cs = float(contract_size or 1) + if cs <= 0: + cs = 1.0 + px = _positive_float(mark_price) or _positive_float(entry_price) + if c > 0 and px is not None: + notional = c * cs * px + except (TypeError, ValueError): + pass + if notional is None or notional <= 0: + return None + return round(notional * rf, funds_decimals) + + +def order_monitor_tpsl_needs_sync( + plan_sl: Any, + plan_tp: Any, + exchange_tpsl: Any, + *, + eps: float = 1e-12, +) -> tuple[Optional[float], Optional[float], bool]: + """若交易所 TP/SL 与库中不一致,返回应写回的 (sl, tp) 及是否需更新.""" + _, _, ex_sl, ex_tp = resolve_live_tpsl_prices(plan_sl, plan_tp, exchange_tpsl) + try: + cur_sl = float(plan_sl or 0) + cur_tp = float(plan_tp or 0) + except (TypeError, ValueError): + cur_sl, cur_tp = 0.0, 0.0 + new_sl = ex_sl if ex_sl is not None else cur_sl + new_tp = ex_tp if ex_tp is not None else cur_tp + changed = ( + (ex_sl is not None and abs(new_sl - cur_sl) > eps) + or (ex_tp is not None and abs(new_tp - cur_tp) > eps) + ) + return new_sl, new_tp, changed + + +def apply_order_price_display_fields( + payload: dict[str, Any], + *, + direction: str, + entry_price: Any, + initial_stop_loss: Any, + stop_loss: Any, + take_profit: Any, + calc_rr_ratio_fn: Callable[..., Optional[float]], + exchange_tpsl: Any = None, + format_price_fn: Optional[Callable[[Any, Any], str]] = None, + symbol: Any = None, + margin_capital: Any = None, + leverage: Any = None, + exchange_notional: Any = None, + contracts: Any = None, + contract_size: Any = None, + mark_price: Any = None, + avg_entry_price: Any = None, + funds_decimals: int = 2, +) -> dict[str, Any]: + disp_sl, disp_tp, _, _ = resolve_live_tpsl_prices(stop_loss, take_profit, exchange_tpsl) + payload["stop_loss_raw"] = _positive_float(stop_loss) + payload["take_profit_raw"] = _positive_float(take_profit) + payload["rr_ratio"] = snapshot_rr( + calc_rr_ratio_fn, + direction, + entry_price, + initial_stop_loss, + stop_loss, + take_profit, + ) + risk_entry = resolve_breakeven_entry_price(entry_price, avg_entry_price) + payload["avg_entry_price"] = risk_entry + payload["sl_breakeven_secured"] = sl_breakeven_from_exchange_tpsl( + direction, risk_entry, exchange_tpsl + ) + payload["stop_loss"] = disp_sl + payload["take_profit"] = disp_tp + if disp_sl is not None and disp_tp is not None: + payload["display_rr_ratio"] = calc_rr_ratio_fn( + direction or "long", entry_price, disp_sl, disp_tp + ) + else: + payload["display_rr_ratio"] = None + if contracts is not None: + try: + from lib.hub.hub_position_metrics import normalize_contracts_qty + + c = normalize_contracts_qty(contracts) + if c > 0: + payload["contracts"] = c + except (TypeError, ValueError): + pass + payload["latest_risk_amount"] = calc_latest_risk_amount( + direction, + risk_entry, + disp_sl if disp_sl is not None else stop_loss, + margin_capital=margin_capital, + leverage=leverage, + exchange_notional=exchange_notional, + contracts=payload.get("contracts") if payload.get("contracts") is not None else contracts, + contract_size=contract_size, + mark_price=mark_price, + funds_decimals=funds_decimals, + ) + tp_for_reward = disp_tp if disp_tp is not None else _positive_float(take_profit) + qty_for_reward = payload.get("contracts") + if qty_for_reward is None and contracts is not None: + try: + qty_for_reward = abs(float(contracts)) + except (TypeError, ValueError): + qty_for_reward = None + if risk_entry is not None and tp_for_reward is not None and qty_for_reward: + try: + from lib.strategy.strategy_roll_ui_lib import reward_at_tp_usdt + + reward = reward_at_tp_usdt( + direction, + risk_entry, + tp_for_reward, + float(qty_for_reward), + contract_size=float(contract_size or 1.0), + ) + payload["reward_at_tp_usdt"] = ( + round(reward, funds_decimals) if reward is not None else None + ) + except Exception: + payload["reward_at_tp_usdt"] = None + else: + payload["reward_at_tp_usdt"] = None + if format_price_fn is not None and symbol is not None: + payload["stop_loss_display"] = ( + format_price_fn(symbol, disp_sl) if disp_sl is not None else "—" + ) + payload["take_profit_display"] = ( + format_price_fn(symbol, disp_tp) if disp_tp is not None else "—" + ) + mark_raw = mark_price if mark_price is not None else None + if mark_raw is not None and format_price_fn is not None and symbol is not None: + try: + payload["exchange_mark_price_display"] = format_price_fn(symbol, float(mark_raw)) + except (TypeError, ValueError): + payload["exchange_mark_price_display"] = None + return payload + + +def enrich_active_monitor_tpsl_json( + row: Any, + stop_loss: Any, + take_profit: Any, + exchange_tpsl: Any, + *, + position_row: Any = None, + exchange_notional: Any = None, + contracts: Any = None, + contract_size: float = 1.0, + mark_price: Any = None, + calc_rr_ratio_fn: Callable[..., Optional[float]], + format_price_fn: Optional[Callable[[Any, Any], str]] = None, + symbol: Any = None, + funds_decimals: int = 2, +) -> dict[str, Any]: + """place_tpsl 响应:展示用 TP/SL,最新风险,当前盈亏比.""" + def _row_val(key: str, default=None): + try: + if hasattr(row, "keys") and key in row.keys(): + return row[key] + except Exception: + pass + if isinstance(row, dict): + return row.get(key, default) + return default + + direction = _row_val("direction") or "long" + entry = _row_val("trigger_price") + init_sl = _row_val("initial_stop_loss") + margin = _row_val("margin_capital") + leverage = _row_val("leverage") + if position_row is not None: + from lib.hub.hub_position_metrics import parse_position_entry_price, position_contracts + + live_c = position_contracts(position_row) + if abs(live_c) >= 1e-12: + contracts = abs(live_c) + avg_entry = parse_position_entry_price(position_row) + else: + avg_entry = None + payload: dict[str, Any] = { + "stop_loss": stop_loss, + "take_profit": take_profit, + } + apply_order_price_display_fields( + payload, + direction=direction, + entry_price=entry, + initial_stop_loss=init_sl, + stop_loss=stop_loss, + take_profit=take_profit, + calc_rr_ratio_fn=calc_rr_ratio_fn, + exchange_tpsl=exchange_tpsl, + format_price_fn=format_price_fn, + symbol=symbol or _row_val("symbol"), + margin_capital=margin, + leverage=leverage, + exchange_notional=exchange_notional, + contracts=contracts, + contract_size=contract_size, + mark_price=mark_price, + avg_entry_price=avg_entry, + funds_decimals=funds_decimals, + ) + return payload diff --git a/lib/trade/position_sizing_lib.py b/lib/trade/position_sizing_lib.py new file mode 100644 index 0000000..8f5ae28 --- /dev/null +++ b/lib/trade/position_sizing_lib.py @@ -0,0 +1,136 @@ +""" +三所共用:计仓模式 risk(以损定仓)| full_margin(全仓杠杆). +仅 env POSITION_SIZING_MODE 切换;须无持仓(由部署流程保证). +""" +from __future__ import annotations + +import os +from typing import Any, Optional, Tuple + +MODE_RISK = "risk" +MODE_FULL_MARGIN = "full_margin" +VALID_MODES = frozenset({MODE_RISK, MODE_FULL_MARGIN}) + +OPEN_SOURCE_MANUAL = "manual" +OPEN_SOURCE_KEY_AUTO = "key_auto" +OPEN_SOURCE_KEY_FIB = "key_fib" +OPEN_SOURCE_KEY_TRIGGER = "key_trigger" +OPEN_SOURCE_TREND = "trend" +OPEN_SOURCE_ROLL = "roll" + +FULL_MARGIN_BLOCKED_SOURCES = frozenset( + {OPEN_SOURCE_KEY_AUTO, OPEN_SOURCE_KEY_FIB, OPEN_SOURCE_TREND, OPEN_SOURCE_ROLL} +) + + +def normalize_position_sizing_mode(raw: Optional[str]) -> str: + v = (raw or MODE_RISK).strip().lower() + if v in ("full", "full_margin", "fullmargin", "全仓", "全仓杠杆"): + return MODE_FULL_MARGIN + return MODE_RISK if v in ("risk", "r", "以损定仓", "") else MODE_RISK + + +def load_position_sizing_mode(env: Optional[dict] = None) -> str: + e = env if env is not None else os.environ + return normalize_position_sizing_mode(e.get("POSITION_SIZING_MODE")) + + +def is_full_margin_mode(mode: str) -> bool: + return normalize_position_sizing_mode(mode) == MODE_FULL_MARGIN + + +def mode_label_zh(mode: str) -> str: + return "全仓杠杆" if is_full_margin_mode(mode) else "以损定仓" + + +def leverage_for_full_margin(symbol: str, btc_leverage: int, alt_leverage: int) -> int: + sym = (symbol or "").strip().upper() + if sym.startswith("BTC") or sym.startswith("ETH"): + return max(1, int(btc_leverage or 10)) + return max(1, int(alt_leverage or 5)) + + +def round_funds(value: float, decimals: int = 2) -> float: + return round(float(value), int(decimals)) + + +def risk_percent_for_storage(mode: str, risk_percent: float) -> Optional[float]: + """全仓杠杆:库内不写风险百分比(仅 risk_amount U).""" + if is_full_margin_mode(mode): + return None + return risk_percent + + +def format_risk_display_text( + mode: str, + risk_percent: Optional[float], + risk_amount: Optional[float], + *, + decimals: int = 2, +) -> str: + """持仓/通知「风险」文案:全仓仅 U;以损定仓为 %≈U.""" + amt: Optional[float] = None + if risk_amount is not None and risk_amount != "": + try: + amt = float(risk_amount) + except (TypeError, ValueError): + amt = None + if is_full_margin_mode(mode): + if amt is None: + return "—" + return f"{round_funds(amt, decimals)}U" + pct: Optional[float] = None + if risk_percent is not None and risk_percent != "": + try: + pct = float(risk_percent) + except (TypeError, ValueError): + pct = None + pct_txt = f"{pct:g}" if pct is not None else "—" + amt_txt = round_funds(amt, decimals) if amt is not None else "—" + return f"{pct_txt}%≈{amt_txt}U" + + +def assert_open_source_allowed(mode: str, source: str) -> Tuple[bool, str]: + if not is_full_margin_mode(mode): + return True, "" + src = (source or "").strip().lower() + if src in FULL_MARGIN_BLOCKED_SOURCES: + return False, ( + "当前为全仓杠杆模式(POSITION_SIZING_MODE=full_margin)," + "不允许关键位突破/斐波自动开仓,趋势回调与顺势加仓;" + "仅支持实盘人工下单与阻力/支撑提醒." + ) + return True, "" + + +def full_margin_requires_flat_position(active_count: int) -> Tuple[bool, str]: + if active_count > 0: + return False, "全仓杠杆模式仅允许单仓且无其它持仓,请先平仓后再开仓" + return True, "" + + +def compute_full_margin_sizing( + *, + symbol: str, + available_usdt: float, + capital_base: float, + buffer_ratio: float, + btc_leverage: int, + alt_leverage: int, + funds_decimals: int = 2, +) -> Tuple[Optional[dict[str, Any]], Optional[str]]: + if available_usdt is None or float(available_usdt) <= 0: + return None, "全仓杠杆:无法读取合约账户可用保证金" + lev = leverage_for_full_margin(symbol, btc_leverage, alt_leverage) + margin = round_funds(float(available_usdt) * float(buffer_ratio), funds_decimals) + if margin <= 0: + return None, "全仓杠杆:可用保证金不足" + notional = round_funds(margin * lev, funds_decimals) + ratio = round(margin / float(capital_base) * 100, 2) if capital_base else 0.0 + return { + "margin_capital": margin, + "leverage": lev, + "notional_value": notional, + "position_ratio": ratio, + "mode": MODE_FULL_MARGIN, + }, None diff --git a/lib/trade/time_close_lib.py b/lib/trade/time_close_lib.py new file mode 100644 index 0000000..96968a3 --- /dev/null +++ b/lib/trade/time_close_lib.py @@ -0,0 +1,150 @@ +"""持仓时间平仓:开仓后按 1h/2h/4h 定时市价平仓.""" +from __future__ import annotations + +import time +from typing import Any, Optional + +ALLOWED_TIME_CLOSE_HOURS = (1, 2, 4) +TIME_CLOSE_RESULT = "时间平仓" + + +def parse_time_close_enabled_form(form_value: Any) -> int: + return 1 if str(form_value or "").strip().lower() in ("1", "true", "on", "yes") else 0 + + +def parse_time_close_hours_form(form_value: Any, *, default: int = 4) -> Optional[int]: + raw = str(form_value or "").strip().lower().rstrip("h") + if not raw: + return None + try: + h = int(float(raw)) + except (TypeError, ValueError): + return None + if h in ALLOWED_TIME_CLOSE_HOURS: + return h + return None + + +def normalize_time_close_hours(value: Any) -> Optional[int]: + try: + h = int(value) + except (TypeError, ValueError): + return None + return h if h in ALLOWED_TIME_CLOSE_HOURS else None + + +def _row_val(row: Any, key: str, default=None): + if row is None: + return default + try: + if hasattr(row, "keys") and key in row.keys(): + return row[key] + except Exception: + pass + if isinstance(row, dict): + return row.get(key, default) + return default + + +def time_close_settings_from_row(row: Any) -> tuple[int, Optional[int], Optional[int]]: + """返回 (enabled, hours, close_at_ms).""" + enabled = int(_row_val(row, "time_close_enabled", 0) or 0) != 0 + hours = normalize_time_close_hours(_row_val(row, "time_close_hours")) + close_at = _row_val(row, "time_close_at_ms") + try: + close_at_ms = int(close_at) if close_at not in (None, "") else None + except (TypeError, ValueError): + close_at_ms = None + if enabled and hours and not close_at_ms: + opened_ms = _row_val(row, "opened_at_ms") + try: + opened_ms = int(opened_ms) if opened_ms not in (None, "") else None + except (TypeError, ValueError): + opened_ms = None + close_at_ms = compute_close_at_ms(opened_ms, hours) + return (1 if enabled and hours else 0, hours, close_at_ms) + + +def compute_close_at_ms(opened_at_ms: Any, hours: Any) -> Optional[int]: + h = normalize_time_close_hours(hours) + try: + opened = int(opened_at_ms) + except (TypeError, ValueError): + return None + if not h or opened <= 0: + return None + return opened + h * 3600 * 1000 + + +def should_trigger_time_close(row: Any, *, now_ms: Optional[int] = None) -> bool: + enabled, hours, close_at_ms = time_close_settings_from_row(row) + if not enabled or not close_at_ms: + return False + now = int(now_ms if now_ms is not None else time.time() * 1000) + return now >= int(close_at_ms) + + +def time_close_remaining_seconds(close_at_ms: Any, *, now_ms: Optional[int] = None) -> Optional[int]: + try: + close_at = int(close_at_ms) + except (TypeError, ValueError): + return None + now = int(now_ms if now_ms is not None else time.time() * 1000) + return max(0, int((close_at - now) / 1000)) + + +def format_time_close_countdown(seconds: Any) -> str: + try: + sec = max(0, int(seconds)) + except (TypeError, ValueError): + return "--:--:--" + h = sec // 3600 + m = (sec % 3600) // 60 + s = sec % 60 + return f"{h:02d}:{m:02d}:{s:02d}" + + +def time_close_label(hours: Any) -> str: + h = normalize_time_close_hours(hours) + return f"时间平仓 {h}h" if h else "时间平仓" + + +def apply_time_close_to_payload(payload: dict[str, Any], row: Any, *, now_ms: Optional[int] = None) -> None: + enabled, hours, close_at_ms = time_close_settings_from_row(row) + payload["time_close_enabled"] = bool(enabled) + payload["time_close_hours"] = hours + payload["time_close_at_ms"] = close_at_ms + payload["time_close_label"] = time_close_label(hours) if enabled else "" + if enabled and close_at_ms: + rem = time_close_remaining_seconds(close_at_ms, now_ms=now_ms) + payload["time_close_remaining_sec"] = rem + payload["time_close_countdown"] = format_time_close_countdown(rem) + else: + payload["time_close_remaining_sec"] = None + payload["time_close_countdown"] = "" + + +def ensure_time_close_schema(cursor) -> None: + ddl_list = ( + "ALTER TABLE order_monitors ADD COLUMN time_close_enabled INTEGER DEFAULT 0", + "ALTER TABLE order_monitors ADD COLUMN time_close_hours INTEGER", + "ALTER TABLE order_monitors ADD COLUMN time_close_at_ms INTEGER", + "ALTER TABLE key_monitors ADD COLUMN time_close_enabled INTEGER DEFAULT 0", + "ALTER TABLE key_monitors ADD COLUMN time_close_hours INTEGER", + ) + for ddl in ddl_list: + try: + cursor.execute(ddl) + except Exception: + pass + + +def time_close_insert_values( + enabled: int, + hours: Optional[int], + opened_at_ms: Optional[int], +) -> tuple[int, Optional[int], Optional[int]]: + en = 1 if int(enabled or 0) != 0 and hours else 0 + h = normalize_time_close_hours(hours) if en else None + close_at = compute_close_at_ms(opened_at_ms, h) if en else None + return en, h, close_at diff --git a/lib/trade/trade_exchange_stats_lib.py b/lib/trade/trade_exchange_stats_lib.py new file mode 100644 index 0000000..7158f14 --- /dev/null +++ b/lib/trade/trade_exchange_stats_lib.py @@ -0,0 +1,229 @@ +"""平仓交易:交易所口径双边成交额与手续费(三所共用聚合逻辑).""" +from __future__ import annotations + +from typing import Any, Callable, Optional + + +def _coerce_ts_ms(raw: Any) -> int | None: + if raw in (None, ""): + return None + try: + v = int(raw) + return v if v > 1_000_000_000_000 else v * 1000 + except (TypeError, ValueError): + return None + + +def quote_turnover_usdt_from_fill(trade: dict, *, contract_size: float = 1.0) -> float: + """单笔成交的报价币成交额(USDT 口径).""" + info = trade.get("info") or {} + if not isinstance(info, dict): + info = {} + for key in ("quoteQty", "quote_qty", "fillNotionalUsd", "notional"): + try: + v = float(info.get(key) or 0) + if v > 0: + return abs(v) + except (TypeError, ValueError): + continue + try: + cost = float(trade.get("cost") or 0) + if cost > 0: + return abs(cost) + except (TypeError, ValueError): + pass + try: + price = float(trade.get("price") or 0) + amount = float(trade.get("amount") or 0) * float(contract_size or 1.0) + if price > 0 and amount > 0: + return abs(price * amount) + except (TypeError, ValueError): + pass + return 0.0 + + +def commission_usdt_from_fill(trade: dict) -> float: + """单笔成交手续费(正数表示成本).""" + fee = trade.get("fee") + if isinstance(fee, dict): + try: + cost = float(fee.get("cost") or 0) + except (TypeError, ValueError): + cost = 0.0 + if cost != 0: + cur = str(fee.get("currency") or "USDT").upper() + if cur in ("USDT", "USD", "BUSD", "USDC"): + return abs(cost) + return abs(cost) + info = trade.get("info") or {} + if isinstance(info, dict): + for key in ("fee", "commission", "fillFee"): + try: + v = float(info.get(key) or 0) + if v != 0: + return abs(v) + except (TypeError, ValueError): + continue + return 0.0 + + +def aggregate_bilateral_stats( + fills: list[dict], + *, + contract_size: float = 1.0, +) -> dict[str, float] | None: + """双边成交额 = 开+平所有相关 fill 的报价币成交额之和;手续费 = fill fee 之和.""" + if not fills: + return None + turnover = 0.0 + commission = 0.0 + for t in fills: + turnover += quote_turnover_usdt_from_fill(t, contract_size=contract_size) + commission += commission_usdt_from_fill(t) + if turnover <= 0 and commission <= 0: + return None + return { + "exchange_turnover_usdt": round(turnover, 4), + "exchange_commission_usdt": round(commission, 4), + } + + +def filter_position_lifecycle_fills( + trades: list[dict], + direction: str, + open_ms: int | None, + close_ms: int | None, + *, + hedge_mode: bool = False, + close_buffer_ms: int = 15 * 60 * 1000, +) -> list[dict]: + """ + 持仓生命周期内 fill:多=开买+平卖;空=开卖+平买. + hedge_mode 时按 posSide 与 direction 过滤. + """ + direction = (direction or "long").strip().lower() + open_side = "buy" if direction == "long" else "sell" + close_side = "sell" if direction == "long" else "buy" + allowed_sides = {open_side, close_side} + upper = int(close_ms) + int(close_buffer_ms) if close_ms else None + out: list[dict] = [] + for t in trades or []: + side = (t.get("side") or "").lower() + if side not in allowed_sides: + continue + ts = _coerce_ts_ms(t.get("timestamp")) + if ts is None: + continue + if open_ms and ts < int(open_ms) - 60_000: + continue + if upper and ts > upper: + continue + if hedge_mode: + info = t.get("info") or {} + if not isinstance(info, dict): + info = {} + pos_side = (info.get("posSide") or t.get("posSide") or "").lower() + if pos_side in ("long", "short") and pos_side != direction: + continue + out.append(t) + out.sort(key=lambda x: x.get("timestamp") or 0) + return out + + +def sum_binance_commission_income(entries: list[dict], trade_ids: set[str] | None) -> float | None: + """Binance income 流水中 COMMISSION 合计(负值取绝对值为成本).""" + if not entries: + return None + total = 0.0 + found = False + for e in entries: + it = (e.get("incomeType") or e.get("income_type") or "").strip() + if it != "COMMISSION": + continue + if trade_ids: + tid = str(e.get("tradeId") or e.get("trade_id") or "").strip() + if tid and tid not in trade_ids: + continue + try: + total += float(e.get("income") or 0) + found = True + except (TypeError, ValueError): + continue + if not found: + return None + return round(abs(total), 4) + + +def trade_ids_from_fills(fills: list[dict]) -> set[str]: + out: set[str] = set() + for t in fills or []: + info = t.get("info") or {} + if not isinstance(info, dict): + info = {} + for key in ("id", "tradeId", "trade_id"): + raw = t.get(key) if key in t else info.get(key) + if raw is not None and str(raw).strip(): + out.add(str(raw).strip()) + break + return out + + +def merge_commission_prefer_income( + fill_commission: float, + income_commission: float | None, +) -> float: + if income_commission is not None and income_commission > 0: + return round(income_commission, 4) + return round(max(fill_commission, 0.0), 4) + + +def update_trade_record_stats_columns( + conn: Any, + trade_id: int, + turnover_usdt: float | None, + commission_usdt: float | None, +) -> None: + if turnover_usdt is None and commission_usdt is None: + return + conn.execute( + """ + UPDATE trade_records + SET exchange_turnover_usdt = COALESCE(?, exchange_turnover_usdt), + exchange_commission_usdt = COALESCE(?, exchange_commission_usdt) + WHERE id = ? + """, + (turnover_usdt, commission_usdt, int(trade_id)), + ) + + +def attach_exchange_stats_to_trade( + conn: Any, + trade_id: int, + *, + fetch_fills: Callable[[], list[dict]], + contract_size: float = 1.0, + income_commission: float | None = None, +) -> dict[str, float] | None: + """拉 fill 并写库;仅在新单平仓路径调用.""" + try: + fills = fetch_fills() or [] + except Exception: + fills = [] + stats = aggregate_bilateral_stats(fills, contract_size=contract_size) + if not stats and income_commission is None: + return None + turnover = stats.get("exchange_turnover_usdt") if stats else None + fill_comm = float(stats.get("exchange_commission_usdt") or 0) if stats else 0.0 + commission = merge_commission_prefer_income(fill_comm, income_commission) + update_trade_record_stats_columns( + conn, + trade_id, + turnover, + commission if commission > 0 else None, + ) + out = {} + if turnover is not None: + out["exchange_turnover_usdt"] = turnover + if commission > 0: + out["exchange_commission_usdt"] = commission + return out or None diff --git a/lib/trade/trade_fee_lib.py b/lib/trade/trade_fee_lib.py new file mode 100644 index 0000000..ed27312 --- /dev/null +++ b/lib/trade/trade_fee_lib.py @@ -0,0 +1,94 @@ +"""永续估算盈亏:固定 taker 手续费(默认单边 0.05%,开+平双边). + +浮盈亏仍读交易所;本模块只服务「盈利金额 / 止盈盈利 / 推送 / 记账 pnl_amount」等估算口径. +""" +from __future__ import annotations + +import math +import os +from typing import Optional + + +def _finite(v) -> Optional[float]: + try: + f = float(v) + return f if math.isfinite(f) else None + except (TypeError, ValueError): + return None + + +def taker_fee_rate() -> float: + """单边 taker 费率,默认 0.0005(=0.05%).""" + raw = os.getenv("PERP_TAKER_FEE_RATE", "0.0005") + rate = _finite(raw) + if rate is None or rate < 0: + return 0.0005 + return rate + + +def notional_usdt(price, qty, contract_size: float = 1.0) -> Optional[float]: + """名义价值 U = 价格 × 张数 × 合约面值.""" + p = _finite(price) + q = _finite(qty) + cs = _finite(contract_size) + if p is None or q is None or p <= 0 or q <= 0: + return None + if cs is None or cs <= 0: + cs = 1.0 + return abs(q) * p * cs + + +def estimate_roundtrip_fee_usdt( + entry_price, + exit_price, + qty=None, + contract_size: float = 1.0, + *, + open_notional: float | None = None, + rate: float | None = None, +) -> float: + """开+平双边手续费(各单边 rate). + + 优先用 价×张×面值;若无张数则用 open_notional 估开仓名义, + 平仓名义按 exit/entry 缩放. + """ + fee_rate = taker_fee_rate() if rate is None else float(rate) + if fee_rate <= 0: + return 0.0 + entry = _finite(entry_price) + exit_p = _finite(exit_price) + open_n = notional_usdt(entry, qty, contract_size) if qty is not None else None + if open_n is None: + open_n = _finite(open_notional) + if open_n is None or open_n <= 0: + return 0.0 + if entry is not None and entry > 0 and exit_p is not None and exit_p > 0: + close_n = open_n * (exit_p / entry) + else: + close_n = open_n + return round(open_n * fee_rate + close_n * fee_rate, 8) + + +def net_pnl_after_fee( + gross_pnl, + entry_price, + exit_price, + qty=None, + contract_size: float = 1.0, + *, + open_notional: float | None = None, + rate: float | None = None, +) -> Optional[float]: + """毛利扣双边手续费后的净盈亏;gross 无效则返回 None.""" + gross = _finite(gross_pnl) + if gross is None: + return None + fee = estimate_roundtrip_fee_usdt( + entry_price, + exit_price, + qty, + contract_size, + open_notional=open_notional, + rate=rate, + ) + return round(gross - fee, 4) diff --git a/lib/trade/trade_policy_app_lib.py b/lib/trade/trade_policy_app_lib.py new file mode 100644 index 0000000..3d7adf2 --- /dev/null +++ b/lib/trade/trade_policy_app_lib.py @@ -0,0 +1,52 @@ +"""Flask 实例接入 trade policy(三所 app.py 共用).""" +from __future__ import annotations + +from typing import Callable, Tuple + +from lib.trade.trade_policy_lib import ( + TradePolicy, + assert_direction_allowed, + assert_symbol_allowed, + assert_trade_policy_open, + trade_policy_to_dict, +) + + +def trade_policy_template_context(policy: TradePolicy) -> dict: + return trade_policy_to_dict(policy) + + +def default_symbol_for_policy(policy: TradePolicy, raw_default: str) -> str: + d = (raw_default or "BTC/USDT").strip() or "BTC/USDT" + if policy.symbol_restrict_enabled and policy.symbol_whitelist: + from lib.trade.trade_policy_lib import symbol_base_coin + + base = symbol_base_coin(d) + if base not in policy.symbol_whitelist: + return f"{policy.symbol_whitelist[0]}/USDT" + return d + + +def check_symbol_policy( + policy: TradePolicy, + symbol: str, + normalize_symbol_fn: Callable[[str], str], +) -> Tuple[bool, str]: + return assert_symbol_allowed( + policy, symbol, normalize_symbol_fn=normalize_symbol_fn + ) + + +def check_direction_policy(policy: TradePolicy, direction: str) -> Tuple[bool, str]: + return assert_direction_allowed(policy, direction) + + +def check_open_policy( + policy: TradePolicy, + symbol: str, + direction: str, + normalize_symbol_fn: Callable[[str], str], +) -> Tuple[bool, str]: + return assert_trade_policy_open( + policy, symbol, direction, normalize_symbol_fn=normalize_symbol_fn + ) diff --git a/lib/trade/trade_policy_lib.py b/lib/trade/trade_policy_lib.py new file mode 100644 index 0000000..f7d57b9 --- /dev/null +++ b/lib/trade/trade_policy_lib.py @@ -0,0 +1,205 @@ +""" +三所共用:账户级方向 / 币种白名单(.env 开关,默认关闭=不限制). +""" +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Callable, FrozenSet, Optional, Sequence, Tuple + +DIR_BOTH = "both" +DIR_LONG_ONLY = "long_only" +DIR_SHORT_ONLY = "short_only" +VALID_DIRECTION_MODES = frozenset({DIR_BOTH, DIR_LONG_ONLY, DIR_SHORT_ONLY}) + +_DIR_ALIASES = { + "both": DIR_BOTH, + "双向": DIR_BOTH, + "long": DIR_LONG_ONLY, + "long_only": DIR_LONG_ONLY, + "多": DIR_LONG_ONLY, + "仅多": DIR_LONG_ONLY, + "做多": DIR_LONG_ONLY, + "short": DIR_SHORT_ONLY, + "short_only": DIR_SHORT_ONLY, + "空": DIR_SHORT_ONLY, + "仅空": DIR_SHORT_ONLY, + "做空": DIR_SHORT_ONLY, +} + + +def _env_bool(raw: Optional[str], default: bool = False) -> bool: + if raw is None: + return default + return (raw or "").strip().lower() in ("1", "true", "yes", "on") + + +def normalize_direction_mode(raw: Optional[str]) -> str: + v = (raw or DIR_BOTH).strip().lower() + return _DIR_ALIASES.get(v, v if v in VALID_DIRECTION_MODES else DIR_BOTH) + + +def symbol_base_coin(symbol: str) -> str: + """BTC/USDT:USDT,BTC/USDT,BTC,btc -> BTC""" + s = (symbol or "").strip().upper() + if not s: + return "" + if ":" in s: + s = s.split(":", 1)[0] + if "/" in s: + return s.split("/", 1)[0].strip() + if s.endswith("USDT") and len(s) > 4: + return s[:-4] + return s + + +def parse_symbol_whitelist(raw: Optional[str]) -> Tuple[str, ...]: + if not raw or not str(raw).strip(): + return () + parts = [] + for piece in str(raw).replace(";", ",").split(","): + base = symbol_base_coin(piece.strip()) + if base and base not in parts: + parts.append(base) + return tuple(parts) + + +@dataclass(frozen=True) +class TradePolicy: + direction_restrict_enabled: bool + direction_mode: str + symbol_restrict_enabled: bool + symbol_whitelist: Tuple[str, ...] + + @property + def allows_long(self) -> bool: + if not self.direction_restrict_enabled: + return True + return self.direction_mode in (DIR_BOTH, DIR_LONG_ONLY) + + @property + def allows_short(self) -> bool: + if not self.direction_restrict_enabled: + return True + return self.direction_mode in (DIR_BOTH, DIR_SHORT_ONLY) + + +def load_trade_policy(env: Optional[dict] = None) -> TradePolicy: + e = env if env is not None else os.environ + direction_restrict = _env_bool(e.get("TRADE_DIRECTION_RESTRICT_ENABLED"), False) + symbol_restrict = _env_bool(e.get("TRADE_SYMBOL_RESTRICT_ENABLED"), False) + direction_mode = normalize_direction_mode(e.get("TRADE_DIRECTION")) + whitelist = parse_symbol_whitelist(e.get("TRADE_SYMBOL_WHITELIST")) + if symbol_restrict and not whitelist: + symbol_restrict = False + return TradePolicy( + direction_restrict_enabled=direction_restrict, + direction_mode=direction_mode, + symbol_restrict_enabled=symbol_restrict, + symbol_whitelist=whitelist, + ) + + +def direction_mode_label_zh(mode: str) -> str: + m = normalize_direction_mode(mode) + if m == DIR_LONG_ONLY: + return "仅多" + if m == DIR_SHORT_ONLY: + return "仅空" + return "双向" + + +def trade_policy_badge_parts(policy: TradePolicy) -> Tuple[str, ...]: + parts: list[str] = [] + if policy.direction_restrict_enabled: + if policy.direction_mode == DIR_LONG_ONLY: + parts.append("仅多") + elif policy.direction_mode == DIR_SHORT_ONLY: + parts.append("仅空") + if policy.symbol_restrict_enabled and policy.symbol_whitelist: + parts.append("/".join(policy.symbol_whitelist)) + return tuple(parts) + + +def trade_policy_to_dict(policy: TradePolicy) -> dict: + badges = trade_policy_badge_parts(policy) + return { + "direction_restrict_enabled": policy.direction_restrict_enabled, + "direction_mode": policy.direction_mode, + "direction_label_zh": ( + direction_mode_label_zh(policy.direction_mode) + if policy.direction_restrict_enabled + else "双向" + ), + "allows_long": policy.allows_long, + "allows_short": policy.allows_short, + "symbol_restrict_enabled": policy.symbol_restrict_enabled, + "symbol_whitelist": list(policy.symbol_whitelist), + "badge_parts": list(badges), + "badge_text": " · ".join(badges), + } + + +def normalize_open_direction(policy: TradePolicy, direction: str) -> str: + d = (direction or "long").strip().lower() + if d not in ("long", "short"): + d = "long" + if policy.direction_restrict_enabled: + if policy.direction_mode == DIR_LONG_ONLY: + return "long" + if policy.direction_mode == DIR_SHORT_ONLY: + return "short" + return d + + +def assert_direction_allowed(policy: TradePolicy, direction: str) -> Tuple[bool, str]: + d = (direction or "").strip().lower() + if d not in ("long", "short"): + if d in ("watch", ""): + return True, "" + return False, "方向无效,请选择做多或做空" + if d == "long" and not policy.allows_long: + return False, "当前账户配置为仅做空,不允许做多" + if d == "short" and not policy.allows_short: + return False, "当前账户配置为仅做多,不允许做空" + return True, "" + + +def assert_symbol_allowed( + policy: TradePolicy, + symbol: str, + *, + normalize_symbol_fn: Optional[Callable[[str], str]] = None, +) -> Tuple[bool, str]: + if not policy.symbol_restrict_enabled: + return True, "" + sym = (symbol or "").strip() + if not sym: + return False, "请选择币种" + if normalize_symbol_fn is not None: + sym_norm = (normalize_symbol_fn(sym) or "").strip() + else: + sym_norm = sym + base = symbol_base_coin(sym_norm or sym) + allowed: FrozenSet[str] = frozenset(policy.symbol_whitelist) + if base not in allowed: + allowed_txt = ",".join(policy.symbol_whitelist) + return False, f"当前账户仅允许 {allowed_txt},不允许 {base or sym}" + return True, "" + + +def assert_trade_policy_open( + policy: TradePolicy, + symbol: str, + direction: str, + normalize_symbol_fn: Optional[Callable[[str], str]] = None, +) -> Tuple[bool, str]: + ok_sym, msg_sym = assert_symbol_allowed( + policy, symbol, normalize_symbol_fn=normalize_symbol_fn + ) + if not ok_sym: + return False, msg_sym + ok_dir, msg_dir = assert_direction_allowed(policy, direction) + if not ok_dir: + return False, msg_dir + return True, "" diff --git a/lib/trade/trade_result_lib.py b/lib/trade/trade_result_lib.py new file mode 100644 index 0000000..641b57b --- /dev/null +++ b/lib/trade/trade_result_lib.py @@ -0,0 +1,61 @@ +"""交易结果展示与入库时的语义归一化.""" + +_WIN_EPS = 1e-9 + + +def normalize_display_result(result): + """展示用:外部平仓一律视为手动平仓.""" + res = (result or "").strip() + if res == "外部平仓" or res.startswith("外部平仓"): + return "手动平仓" + return res + + +def is_winning_pnl(pnl_amount) -> bool: + """胜率统计:盈亏为正即计为盈利单.""" + try: + return float(pnl_amount or 0) > _WIN_EPS + except (TypeError, ValueError): + return False + + +def sql_effective_pnl_expr() -> str: + """与 to_effective_trade_dict / hub_trades_lib 一致的盈亏 SQL 表达式.""" + return "COALESCE(reviewed_pnl_amount, exchange_realized_pnl, pnl_amount, 0)" + + +def count_winning_trades(trades) -> int: + return sum(1 for r in trades or [] if is_winning_pnl(r.get("effective_pnl_amount"))) + + +MISS_TRADE_RESULT = "错过" + + +def is_miss_trade_result(result) -> bool: + return (result or "").strip() == MISS_TRADE_RESULT + + +def filter_trade_records_excluding_miss(records): + """列表/统计:不展示,不计入「错过」类交易记录.""" + return [ + r + for r in (records or []) + if not is_miss_trade_result(r.get("effective_result") or r.get("result")) + ] + + +def normalize_result_with_pnl(result, pnl_amount): + """ + 非手动平仓且实际盈利时,不应记为「止损」. + 程序触发的止损类平仓若盈亏为正,归类为「移动止盈」. + """ + res = normalize_display_result(result) + if res == "手动平仓": + return res + if res == "止损": + try: + if float(pnl_amount or 0) > 0: + return "移动止盈" + except (TypeError, ValueError): + pass + return res diff --git a/lib/trade/trade_stats_calendar_lib.py b/lib/trade/trade_stats_calendar_lib.py new file mode 100644 index 0000000..18d5361 --- /dev/null +++ b/lib/trade/trade_stats_calendar_lib.py @@ -0,0 +1,115 @@ +"""按交易日聚合实例 trade_records 盈亏,供统计分析页日历 API 使用.""" +from __future__ import annotations + +import json +from datetime import datetime, timedelta +from typing import Any, Callable + + +def build_trade_stats_calendar( + pnls: list[tuple], + year: int, + month: int, + segment_key: str, + row_matches_fn: Callable[[Any, str], bool], + *, + reset_hour: int = 8, +) -> dict[str, Any]: + """pnls: _load_completed_trade_pnls 返回值 (pnl, close_dt, trading_day, row).""" + y = int(year) + m = int(month) + if m < 1 or m > 12: + raise ValueError("month 无效") + first = f"{y:04d}-{m:02d}-01" + if m == 12: + next_first = datetime(y + 1, 1, 1) + else: + next_first = datetime(y, m + 1, 1) + last = (next_first - timedelta(days=1)).strftime("%Y-%m-%d") + seg = (segment_key or "all").strip() or "all" + days: dict[str, dict[str, Any]] = {} + for pnl, _close_dt, td, row in pnls: + if not td or td < first or td > last: + continue + if not row_matches_fn(row, seg): + continue + bucket = days.setdefault( + td, + { + "trading_day": td, + "open_count": 0, + "pnl_total": 0.0, + "turnover_total": 0.0, + "commission_total": 0.0, + "has_sick": False, + "sick_count": 0, + }, + ) + bucket["open_count"] += 1 + bucket["pnl_total"] += float(pnl or 0) + try: + bucket["turnover_total"] += float(row["exchange_turnover_usdt"] or 0) + except (TypeError, ValueError, KeyError): + pass + try: + bucket["commission_total"] += float(row["exchange_commission_usdt"] or 0) + except (TypeError, ValueError, KeyError): + pass + for d in days.values(): + d["pnl_total"] = round(float(d["pnl_total"]), 4) + d["turnover_total"] = round(float(d["turnover_total"]), 4) + d["commission_total"] = round(float(d["commission_total"]), 4) + month_pnl = sum(float(d["pnl_total"]) for d in days.values()) + month_count = sum(int(d["open_count"]) for d in days.values()) + return { + "year": y, + "month": m, + "date_from": first, + "date_to": last, + "segment": seg, + "reset_hour": int(reset_hour), + "days": days, + "month_pnl_total": round(month_pnl, 4), + "month_open_count": month_count, + } + + +def build_initial_stats_calendar( + pnls: list[tuple], + now_dt: datetime, + row_matches_fn: Callable[[Any, str], bool], + *, + reset_hour: int = 8, + segment_key: str = "all", +) -> dict[str, Any]: + """统计页首屏内嵌日历(当前自然月,默认品类).""" + return build_trade_stats_calendar( + pnls, + now_dt.year, + now_dt.month, + segment_key, + row_matches_fn, + reset_hour=reset_hour, + ) + + +def build_stats_calendar_bootstrap( + pnls: list[tuple], + now_dt: datetime, + row_matches_fn: Callable[[Any, str], bool], + *, + reset_hour: int = 8, + segment_key: str = "all", +) -> tuple[dict[str, Any] | None, str | None]: + """返回 (payload, json_str);失败时 (None, None),供模板安全内嵌.""" + try: + payload = build_initial_stats_calendar( + pnls, + now_dt, + row_matches_fn, + reset_hour=reset_hour, + segment_key=segment_key, + ) + return payload, json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + except Exception: + return None, None diff --git a/manual_trading_hub/.env.example b/manual_trading_hub/.env.example new file mode 100644 index 0000000..b18f3f4 --- /dev/null +++ b/manual_trading_hub/.env.example @@ -0,0 +1,112 @@ +# ============================================================================= +# 中控 hub.py / 子代理 agent.py 环境变量模板(可提交 Git) +# 使用:cp .env.example .env 后填入真实值;启动前由 shell export 或 dotenv 加载 +# 云服务器完整说明:见 云服务器部署说明.md +# ============================================================================= + +# hub.py 监听 +HUB_HOST=0.0.0.0 +HUB_PORT=5100 +# 仅本机访问可改为 127.0.0.1,并设 HUB_TRUST_LAN=false + +# 与三实例 .env 中 HUB_BRIDGE_TOKEN 相同的长随机串 +# 中控 → 各 Flask:请求头 X-Hub-Token +# 中控 → 各子代理:请求头 X-Control-Token(与 HUB_BRIDGE_TOKEN 同值;agent 优先读 HUB_BRIDGE_TOKEN) +# 中控「打开实例」SSO 链接也复用此令牌签名(默认 2 小时内有效,单次使用) +# HUB_BRIDGE_TOKEN=your-long-random-token +# HUB_SSO_TTL_SEC=7200 + +# 逗号分隔的账户 id,强制关闭(不参与监控/全局全平;设置页对应行勾选框灰掉) +# 留空 = 不强制关闭;仅不想用 OKX 时可设 HUB_DISABLED_IDS=1 +HUB_DISABLED_IDS= + +# true=允许 RFC1918 私网访问中控页面;false=仅 127.0.0.1(反代须指向 127.0.0.1:5100) +HUB_TRUST_LAN=true + +# 云服务器用域名/HTTPS 反代访问中控时设为 true(否则公网可能看到 {"detail":"forbidden"}) +# HUB_ALLOW_PUBLIC=true + +# 中控 Web 登录(默认 admin / admin123;生产环境请在 .env 中修改) +HUB_USERNAME=admin +HUB_PASSWORD=admin123 +# 会话签名密钥(建议单独随机串;未设则用用户名+密码拼接) +# HUB_SESSION_SECRET=another-long-random-string +# HTTPS 反代时建议 true:仅 HTTPS 访问会带 Secure Cookie;http://内网IP:5100 仍可登录 +# HUB_COOKIE_SECURE=true +# 登录保持天数(默认 7) +# HUB_SESSION_DAYS=7 + +# 本地导航 / 门户 iframe 嵌入中控(默认 true) +# HUB_ALLOW_EMBED=true +# 限制可嵌入的父页来源(逗号分隔);默认 * 不限制 +# HUB_EMBED_ORIGINS=http://192.168.8.6:5070,https://hub.example.com + +# 三实例允许被中控 iframe 内嵌(各 crypto_monitor_*/.env,与 hub 同步部署) +# APP_ALLOW_HUB_EMBED=true +# HUB_EMBED_PARENT_ORIGINS=https://hub.example.com +# HTTPS 跨子域 iframe 时三实例还须 APP_COOKIE_SECURE=true(见 crypto_monitor_*/.env.example) + +# 浏览器打开的实例/复盘链接(hub_settings 里 flask_url 为 127.0.0.1 时替换为对外地址) +# 局域网:填内网 IP,见《局域网与反代部署说明.md》 +# HUB_PUBLIC_ORIGIN=http://192.168.1.100 +# 反代:各实例 flask_url 建议直接写 https 域名,可不设此项 +# HUB_PUBLIC_HOST=192.168.1.100 +# HUB_PUBLIC_SCHEME=http + +# 三实例网页登录(直链反代/IP:端口 访问时输入;中控点「打开实例」免输) +# 各 crypto_monitor_*/.env 统一:APP_USERNAME=... APP_PASSWORD=... + +# 监控区:hub 后台每 N 秒聚合一次,浏览器经 SSE 收版本号再拉快照(默认 5 秒) +# HUB_BOARD_POLL_INTERVAL=5 +# 单次聚合超时(秒,默认 agent 8 / flask 10 / board 45) +# HUB_AGENT_TIMEOUT=8 +# HUB_FLASK_TIMEOUT=10 +# HUB_BOARD_TIMEOUT=45 +# 为 false 时不拉各实例 /api/price_snapshot(关键位门控简化为「-」,首屏明显更快) +# HUB_BOARD_KEY_PRICES=true + +# ---------- 行情区 K 线库(data/hub_kline.db,默认保留 15 天)---------- +# HUB_KLINE_RETENTION_DAYS=15 +# HUB_KLINE_DB_PATH=/opt/crypto_monitor_user/manual_trading_hub/data/hub_kline.db +# 行情区后台轮询 + SSE(对齐监控区 board) +# HUB_CHART_POLL_INTERVAL=5 +# HUB_CHART_POSITION_TIMEFRAME=5m +# HUB_CHART_WATCH_TTL_SEC=45 +# HUB_CHART_MAX_SERIES_PER_TICK=24 + +# --- 子代理 agent.py(在 crypto_monitor_* 目录启动时另设 EXCHANGE / PORT)--- +# 与 HUB_BRIDGE_TOKEN 一致时可只设其一;agent 校验请求头 X-Control-Token +# CONTROL_TOKEN=your-long-random-token +# EXCHANGE=binance +# PORT=15200 +# HOST=127.0.0.1 + +# ---------- 中控 AI 教练(/ai,模块 hub_ai/,存 hub_ai_*.json)---------- +# 与三实例相同变量名;默认 OpenAI 兼容网关(改 AI_PROVIDER=ollama 可走本机 Ollama) +# 详见 manual_trading_hub/AI教练说明.md 与仓库根 AI复盘与模型配置说明.md +AI_TIMEOUT_SECONDS=120 +# AI 教练聊天(默认:输出 8192 token,续写 4 次,快照约 2 万字符,历史单条 1500 字) +# CHAT_MAX_OUTPUT_TOKENS=8192 +# CHAT_MAX_CONTINUATIONS=4 +# CHAT_CONTEXT_MAX_CHARS=20000 +# CHAT_SUMMARY_EXCERPT_MAX_CHARS=2000 +# CHAT_HISTORY_MAX_CHARS_PER_MSG=1500 +# CHAT_AI_TIMEOUT_SECONDS=300 + +# AI 提供方:openai(默认,OpenAI 兼容网关)| ollama(本机 Ollama) +AI_PROVIDER=openai +OPENAI_API_BASE=https://op.bz121.com/v1 +OPENAI_API_KEY=你的密钥 +OPENAI_MODEL=gemma4:e4b +# 本机 Ollama(AI_PROVIDER=ollama 时使用) +OLLAMA_API=http://127.0.0.1:11434/api/generate +AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest + +# 交易日切分(与三实例 TRADING_DAY_RESET_HOUR 一致,定义「今日总结」的日期) +TRADING_DAY_RESET_HOUR=8 +# 资金概况 / AI 上下文:分户资金快照保留交易日数(默认 180) +# HUB_FUND_HISTORY_DAYS=180 +# 自动备份(系统设置 → 备份与恢复;也可设 HUB_BACKUP_ROOT) +# HUB_BACKUP_ROOT=/root/backups/crypto_monitor_portal +# 资金概况:曲线与回撤统计起始交易日 +HUB_FUND_HISTORY_START_DAY=2026-06-09 diff --git a/manual_trading_hub/AI教练说明.md b/manual_trading_hub/AI教练说明.md new file mode 100644 index 0000000..12732b7 --- /dev/null +++ b/manual_trading_hub/AI教练说明.md @@ -0,0 +1,66 @@ +# 中控 AI 教练说明 + +中控 **AI 教练**(`/ai`)与三实例 `/records` 里的 **AI 复盘** 分离:模块在 `manual_trading_hub/hub_ai/`,数据存同目录 JSON. + +## 能力 + +| 功能 | 说明 | +|------|------| +| **交易教练** | 口语化陪聊;注入三户监控快照与今日总结摘要(后台自动生成,不在页面展示) | +| **普通聊天** | 不绑交易数据,适合闲聊,答疑 | +| **交易监管** | 今日长会话;手动/中控开平仓与新开仓自动推送 + 企业微信 + 可回聊(见 [交易监管说明.md](./交易监管说明.md)) | +| **会话历史** | 右侧列表:切换,删除;消息一键复制 | + +页面保留 **交易教练 / 普通聊天 / 交易监管** 与聊天区;**今日总结** 已移至 **数据看板**(`/dashboard`)纯数据展示,不再在 AI 页生成. + +## 存储 + +与 `hub_settings.json` 同目录(`manual_trading_hub/`): + +- `hub_ai_summaries.json` — 历史总结(供交易教练上下文,可选 API 仍保留) +- `hub_ai_chat.json` — 聊天会话(`active_session_id`,多会话,`bot_mode`) + +升级 / 迁移时请一并备份(见 [本地数据迁移到云端.md](./本地数据迁移到云端.md)). + +## 模型配置 + +在 **`manual_trading_hub/.env`** 配置,**变量名与三实例完全相同**;中控 `hub_ai/client.py` 共用仓库根 `ai_client.py`,**默认也是 OpenAI 兼容网关**(`AI_PROVIDER=openai`),与你在三所 `.env` 里配的那套一致即可. + +**推荐(与三实例默认一致):** + +```env +AI_PROVIDER=openai +OPENAI_API_BASE=https://op.bz121.com/v1 +OPENAI_API_KEY=你的密钥 +OPENAI_MODEL=gemma4:e4b + +# 本机 Ollama 备用(仅当 AI_PROVIDER=ollama 时生效) +OLLAMA_API=http://127.0.0.1:11434/api/generate +AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest +``` + +改走本机无限制模型时,将 `AI_PROVIDER=ollama`,并填好 `OLLAMA_API` / `AI_MODEL`;`OPENAI_*` 可保留不动. + +总结与聊天使用**同一模型**(同一套 `OPENAI_MODEL` 或 `AI_MODEL`);总结 temperature≈0.15,聊天≈0.5. + +可选:`TRADING_DAY_RESET_HOUR=8`(与实例一致,定义「今日」交易日). + +## 依赖接口 + +中控通过 HTTP 拉取各实例: + +- `GET /api/hub/monitor`(已有) +- `GET /api/hub/trades/today?trading_day=YYYY-MM-DD`(`hub_bridge` 注册,需三实例更新代码并重启) + +子代理 `GET /status` 提供持仓与余额. + +## 与实例 AI 复盘的分工 + +| | 中控 AI 教练 | 实例 AI 复盘 | +|--|-------------|-------------| +| 入口 | `/ai` | 各所 `/records` | +| 数据 | 三户聚合 | 单户 `journal_entries` | +| 语气 | 聊天搭档 | 结构化教练报告 | +| 代码 | `hub_ai/*` | `ai_review_lib` + 各 `app.py` | + +详见仓库根 [AI复盘与模型配置说明.md](../AI复盘与模型配置说明.md)(实例侧). diff --git a/manual_trading_hub/README.md b/manual_trading_hub/README.md new file mode 100644 index 0000000..d286ca5 --- /dev/null +++ b/manual_trading_hub/README.md @@ -0,0 +1,105 @@ +# 复盘系统中控(manual_trading_hub) + +> **完整说明**:[使用说明.md](./使用说明.md) · **资金概况**:[资金概况说明.md](./资金概况说明.md) · **数据看板**:[数据看板说明.md](./数据看板说明.md) · **AI 教练**:[AI教练说明.md](./AI教练说明.md) · **行情区**:[行情区说明.md](./行情区说明.md) · **部署**:[部署文档.md](./部署文档.md) · **云服务器**:[云服务器部署说明.md](./云服务器部署说明.md) · **本地→云端迁移**:[本地数据迁移到云端.md](./本地数据迁移到云端.md) · **局域网/反代**:[局域网与反代部署说明.md](./局域网与反代部署说明.md) · **故障**:[常见问题.md](./常见问题.md) + +多账户 **监控聚合 + 紧急全平**;**不在中控网页下单**.人工下单,关键位,**策略交易**(`/strategy`),复盘请在各 `crypto_monitor_*` 实例网页操作(监控卡片 **「实例」** / **「复盘」**).**增加子账户**见 [使用说明 §4.3](./使用说明.md#43-增加账户例如再挂一个-gate). + +--- + +## 当前能力 + +| 能力 | 说明 | +|------|------| +| 监控区 | 持仓,余额,关键位摘要,趋势计划,机器人单(只读) | +| 资金概况 | 总/分户资金(资金户+交易户),180 日曲线,最大回撤 | +| **数据看板** | 三户当日总览/分户/平仓明细,SSE 推送(`/dashboard`;见 [数据看板说明.md](./数据看板说明.md)) | +| 行情区 | K 线(多周期,本地缓存,技术指标,从监控跳转持仓线) | +| **AI 教练** | 交易教练 + 普通聊天,会话历史(`/ai`;见 [AI教练说明.md](./AI教练说明.md)) | +| 紧急全平 | 单户 / 全局市价减仓 | +| 系统设置 | `hub_settings.json` 管理 URL,启用,**监控关键位 / 监控趋势计划**(不控制策略交易页) | +| Web 登录 | `.env` 设 `HUB_PASSWORD` 后用户名+密码保护(反代公网**务必**配置) | +| ~~下单区~~ | **已移除**(避免与实例重复,减少故障面) | + +--- + +## 架构 + +``` +浏览器 → hub.py (:5100) 监控 / 资金概况 / **数据看板** / 行情 / **AI 教练** / 设置 / 登录 + ├→ agent.py × N (:15200~15202) 持仓,全平 + └→ 各 Flask (:5000/5001/5004) /api/hub/monitor 只读聚合 +``` + +- 账户列表:**系统设置** 或默认 `settings_store.py`(不再使用环境变量 `HUB_AGENTS`). +- 三实例须注册 **hub_bridge**(仓库根 `hub_bridge.py`);PM2 建议 `PYTHONPATH=..`. + +--- + +## 快速启动(Linux / PM2) + +```bash +cd /opt/crypto_monitor_user/manual_trading_hub +python3 -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env +# 编辑 .env:HUB_PASSWORD,HUB_BRIDGE_TOKEN,HUB_PUBLIC_ORIGIN 等 + +pm2 start ecosystem.config.cjs # 3 agent + hub +pm2 save + +bash scripts/verify_hub_deploy.sh +curl -s http://127.0.0.1:5100/api/ping +``` + +浏览器:`http://<本机IP>:5100/monitor`(行情 `/market`;已设密码则先 `/login`). + +--- + +## 中控 `.env` 要点 + +| 变量 | 说明 | +|------|------| +| `HUB_PASSWORD` / `HUB_USERNAME` | 非空密码即启用登录 | +| `HUB_BRIDGE_TOKEN` | 与三实例一致 | +| `HUB_DISABLED_IDS` | 默认 `1` 关闭 OKX | +| `HUB_PUBLIC_ORIGIN` | 其它设备打开复盘/实例外链(替换 127.0.0.1) | +| `HUB_COOKIE_SECURE` | HTTPS 反代建议 `true` | + +详见 [.env.example](./.env.example). + +--- + +## 子代理(agent) + +每所策略目录单独进程,`EXCHANGE` + `PORT`(15200~15202),密钥来自**该目录 `.env`**.PM2 经 `scripts/run_agent.sh` 启动(自动 `source .env`,去 CRLF). + +| PORT | 目录 | +|------|------| +| 15200 | crypto_monitor_binance | +| 15201 | crypto_monitor_okx | +| 15202 | crypto_monitor_gate | + +--- + +## 运维脚本 + +| 脚本 | 作用 | +|------|------| +| [scripts/fix_hub_deps.sh](./scripts/fix_hub_deps.sh) | 安装/更新 venv 依赖 | +| [scripts/verify_hub_deploy.sh](./scripts/verify_hub_deploy.sh) | 验收代码版本与 ping | +| [scripts/fix_env_crlf.sh](./scripts/fix_env_crlf.sh) | 修复 .env 的 Windows 换行 | +| [scripts/pm2_hub.sh](./scripts/pm2_hub.sh) | PM2 启停 hub+agent | +| [scripts/后台运行-Ubuntu.md](./scripts/后台运行-Ubuntu.md) | PM2 常驻 | +| [docs/ubuntu-server.md](../docs/ubuntu-server.md) | Ubuntu / Python / Node / PM2 | + +--- + +## 文档索引 + +| 文档 | 内容 | +|------|------| +| [使用说明.md](./使用说明.md) | 页面,API,环境变量,日常流程 | +| [行情区说明.md](./行情区说明.md) | K 线周期,缓存,快捷键,拉取逻辑 | +| [部署文档.md](./部署文档.md) | Ubuntu,PM2,反代,升级 | +| [常见问题.md](./常见问题.md) | 已遇到问题与处理 | +| [.env.example](./.env.example) | 环境变量模板 | diff --git a/manual_trading_hub/SNAPSHOT_ROLLBACK.md b/manual_trading_hub/SNAPSHOT_ROLLBACK.md new file mode 100644 index 0000000..4a2afde --- /dev/null +++ b/manual_trading_hub/SNAPSHOT_ROLLBACK.md @@ -0,0 +1,22 @@ +# 更新前快照(行情区 + K 线库) + +> 行情区使用说明见 [行情区说明.md](./行情区说明.md). + +更新前已打 Git 标签,回滚方式: + +```bash +cd /opt/crypto_monitor_user # 或你的仓库路径 +git fetch --tags +git checkout snapshot/pre-hub-market-20260528 +# 恢复后重启: +pm2 restart manual-trading-hub crypto_okx crypto_binance crypto_gate +``` + +回到最新主线: + +```bash +git checkout main +git pull +``` + +K 线数据库(不纳入 Git):`manual_trading_hub/data/hub_kline.db`,回滚代码不会自动删除该文件. diff --git a/manual_trading_hub/agent.py b/manual_trading_hub/agent.py new file mode 100644 index 0000000..009d215 --- /dev/null +++ b/manual_trading_hub/agent.py @@ -0,0 +1,910 @@ +""" +子账户极轻代理:GET /status,挂单/条件单查询与撤销,POST /emergency/close-all,POST /emergency/close-position,仅监听 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(GATE_*) + +环境变量: + EXCHANGE binance(默认)| okx | gate + PORT 默认 15200(与 crypto_monitor_* 的 Flask APP_PORT 错开;中控默认聚合 15200–15202) + HOST 默认 127.0.0.1 + HUB_BRIDGE_TOKEN 与中控一致;请求头 X-Control-Token(优先于已废弃的 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 sys +import time +from pathlib import Path +from typing import Any + +_REPO_ROOT = Path(__file__).resolve().parents[1] +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) +from lib.hub.hub_ohlcv_lib import format_price_by_tick, price_tick_from_market +from lib.hub.hub_position_metrics import ( + parse_position_entry_price, + parse_position_mark_price, + parse_position_unrealized_pnl, + resolve_position_display_upnl, +) + +import ccxt +from fastapi import FastAPI, Header, HTTPException, Request +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from exchange_orders import ( + attach_orders_to_positions, + cancel_order as hub_cancel_order, + cancel_orders_for_symbol, + list_open_orders, + replace_position_tpsl, + symbols_match, +) + +HOST = os.getenv("HOST", "127.0.0.1") +PORT = int(os.getenv("PORT", "15200")) +CONTROL_TOKEN = (os.getenv("HUB_BRIDGE_TOKEN") or 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", + "hedged": OKX_POS_MODE == "hedge", + }, + } + ) + _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") + from lib.exchange.gate_ccxt_lib import gate_ccxt_class + + ex = gate_ccxt_class()( + { + "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 + + +class EmergencyClosePositionBody(BaseModel): + symbol: str + side: str + + +class CancelOrderBody(BaseModel): + symbol: str + order_id: str + channel: str = "regular" + + +class CancelSymbolOrdersBody(BaseModel): + symbol: str + scope: str = "all" # all | conditional | limit + + +class PlaceTpslBody(BaseModel): + symbol: str + side: str # long | short + stop_loss: float + take_profit: float + contracts: float | None = None + + +def _close_position_market( + ex: Any, sym: str, side: str, contracts: float +) -> tuple[dict[str, Any] | None, str | None]: + """市价平掉指定合约,方向;返回 (closed_info, error_message).""" + side_n = (side or "").strip().lower() + if side_n not in ("long", "short"): + return None, f"无效方向: {side}" + close_side = "sell" if side_n == "long" else "buy" + direction = side_n + try: + amt = float(ex.amount_to_precision(sym, abs(float(contracts)))) + except Exception: + amt = abs(float(contracts)) + if amt <= 0: + return None, f"{sym}: 可平张数为 0" + 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 + return None, f"{sym}: {e}" + if order_resp is None: + return None, f"{sym}: {last_err or '下单失败'}" + _cancel_symbol_orders(ex, sym) + return ( + {"symbol": sym, "side": side_n, "amount": amt, "order_id": order_resp.get("id")}, + None, + ) + + +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 _position_price_fmt(ex: Any, symbol: str, price: float | None) -> tuple[float | None, str | None, float | None]: + """返回 (原价, 交易所精度字符串, price_tick).""" + if price is None or price <= 0 or not symbol: + return None, None, None + tick: float | None = None + try: + ex.load_markets() + unified = ex.market(symbol)["symbol"] + tick = price_tick_from_market(ex, unified) + px_str = str(ex.price_to_precision(unified, price)) + return _finite_or_none(float(px_str)), px_str, tick + except Exception: + return price, format_price_by_tick(price, tick), tick + + +def _position_entry_price(p: dict[str, Any]) -> float | None: + """三所 ccxt 持仓统一解析开仓均价(Binance/OKX/Gate 字段名不一致).""" + return parse_position_entry_price(p) + + +def _position_contract_size(ex: Any, symbol: str) -> float: + try: + market = ex.market((symbol or "").strip()) + cs = float(market.get("contractSize") or 1) + return cs if cs > 0 else 1.0 + except Exception: + return 1.0 + + +def _position_mark_price(p: dict[str, Any]) -> float | None: + """三所 ccxt 持仓统一解析标记价(与实例 parse_ccxt_position_metrics 一致).""" + return parse_position_mark_price(p) + + +def _ticker_mark_price(ex: Any, symbol: str) -> float | None: + """持仓行无 mark 时,用 ticker 补标记价(last/mark).""" + sym = (symbol or "").strip() + if not sym: + return None + try: + t = ex.fetch_ticker(sym) + except Exception: + return None + if not isinstance(t, dict): + return None + info = t.get("info") if isinstance(t.get("info"), dict) else {} + for key in ( + t.get("mark"), + t.get("last"), + t.get("close"), + info.get("markPrice"), + info.get("mark_price"), + info.get("markPx"), + ): + px = _finite_or_none(key) + if px is not None and px > 0: + return px + 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) + entry_f = _position_entry_price(p) + mark_f = _position_mark_price(p) + if mark_f is None and sym: + mark_f = _ticker_mark_price(ex, sym) + cs = _position_contract_size(ex, sym) if sym else 1.0 + exchange_upnl = parse_position_unrealized_pnl(p) + upnl_f = resolve_position_display_upnl( + side, + entry_f, + mark_f, + abs(c), + cs, + exchange_upnl, + ) + if upnl_f is None: + 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_fmt, price_tick = _position_price_fmt(ex, sym, entry_f) + _, mark_fmt, mark_tick = _position_price_fmt(ex, sym, mark_f) + if price_tick is None and mark_tick is not None: + price_tick = mark_tick + 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": entry_f, + "entry_price_fmt": entry_fmt, + "mark_price": mark_f, + "mark_price_fmt": mark_fmt, + "contract_size": _finite_or_none(cs), + "price_tick": _finite_or_none(price_tick) if price_tick is not None else None, + } + ) + + orders_fetch_error: str | None = None + try: + attach_orders_to_positions( + positions_out, + list_open_orders(ex, EXCHANGE_KIND, None), + ) + except Exception as e: + orders_fetch_error = str(e) + for p in positions_out: + p.setdefault("conditional_orders", []) + p.setdefault("regular_orders", []) + + try: + pm = _position_mode_label() + except Exception: + pm = EXCHANGE_KIND + out = { + "ok": True, + "exchange": EXCHANGE_KIND, + "balance_usdt": balance_usdt, + "positions": positions_out, + "total_unrealized_pnl": _finite_or_none(total_upnl), + "position_mode": pm, + } + if orders_fetch_error: + out["orders_fetch_error"] = orders_fetch_error + return out + + +@app.get("/open-orders") +def open_orders( + symbol: str = "", + x_control_token: str | None = Header(default=None, alias="X-Control-Token"), +): + _check_token(x_control_token) + try: + ex = get_exchange() + _ensure_markets() + sym = (symbol or "").strip() or None + orders = list_open_orders(ex, EXCHANGE_KIND, sym) + return {"ok": True, "exchange": EXCHANGE_KIND, "symbol": sym, "orders": orders} + except Exception as e: + return JSONResponse( + {"ok": False, "error": str(e), "exchange": EXCHANGE_KIND, "orders": []}, + status_code=200, + ) + + +@app.post("/orders/cancel") +def cancel_one_order( + body: CancelOrderBody, + x_control_token: str | None = Header(default=None, alias="X-Control-Token"), +): + _check_token(x_control_token) + sym = (body.symbol or "").strip() + oid = (body.order_id or "").strip() + if not sym or not oid: + raise HTTPException(status_code=400, detail="symbol 与 order_id 必填") + try: + ex = get_exchange() + _ensure_markets() + hub_cancel_order(ex, EXCHANGE_KIND, sym, oid, body.channel or "regular") + return {"ok": True, "exchange": EXCHANGE_KIND, "cancelled": {"symbol": sym, "order_id": oid}} + except Exception as e: + return JSONResponse( + {"ok": False, "error": str(e), "exchange": EXCHANGE_KIND}, + status_code=200, + ) + + +@app.post("/orders/cancel-symbol") +def cancel_symbol_orders( + body: CancelSymbolOrdersBody, + x_control_token: str | None = Header(default=None, alias="X-Control-Token"), +): + _check_token(x_control_token) + sym = (body.symbol or "").strip() + if not sym: + raise HTTPException(status_code=400, detail="symbol 必填") + scope = (body.scope or "all").strip().lower() + if scope not in ("all", "conditional", "limit"): + raise HTTPException(status_code=400, detail="scope 须为 all / conditional / limit") + try: + ex = get_exchange() + _ensure_markets() + n = cancel_orders_for_symbol(ex, EXCHANGE_KIND, sym, scope=scope) + return {"ok": True, "exchange": EXCHANGE_KIND, "cancelled_count": n, "scope": scope} + except Exception as e: + return JSONResponse( + {"ok": False, "error": str(e), "exchange": EXCHANGE_KIND, "cancelled_count": 0}, + status_code=200, + ) + + +@app.post("/orders/place-tpsl") +def place_tpsl_orders( + body: PlaceTpslBody, + x_control_token: str | None = Header(default=None, alias="X-Control-Token"), +): + """先撤该合约全部条件单,再挂止盈+止损(与三实例策略逻辑一致).""" + _check_token(x_control_token) + sym = (body.symbol or "").strip() + side = (body.side or "").strip().lower() + if not sym or side not in ("long", "short"): + raise HTTPException(status_code=400, detail="symbol 与 side(long/short) 必填") + try: + sl = float(body.stop_loss) + tp = float(body.take_profit) + except (TypeError, ValueError) as e: + raise HTTPException(status_code=400, detail="stop_loss / take_profit 须为数字") from e + try: + ex = get_exchange() + _ensure_markets() + amt = body.contracts + if amt is None or float(amt) <= 0: + raw = ex.fetch_positions() or [] + found = None + for p in raw: + psym = p.get("symbol") or "" + if not symbols_match(sym, psym): + continue + c = abs(float(p.get("contracts") or 0)) + if c <= 0: + continue + ps = (p.get("side") or "").lower() + if ps and ps != side: + continue + found = c + break + if found is None: + return JSONResponse( + {"ok": False, "error": f"未找到持仓 {sym} {side}", "exchange": EXCHANGE_KIND}, + status_code=200, + ) + amt = found + info = replace_position_tpsl(ex, EXCHANGE_KIND, sym, side, float(amt), sl, tp) + return {"ok": True, "exchange": EXCHANGE_KIND, "placed": info} + except HTTPException: + raise + except Exception as e: + return JSONResponse( + {"ok": False, "error": str(e), "exchange": EXCHANGE_KIND}, + status_code=200, + ) + + +@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) + info, err = _close_position_market(ex, sym, side, abs(c)) + if err: + errors.append(err) + elif info: + closed.append(info) + time.sleep(0.05) + + return {"ok": len(errors) == 0, "closed": closed, "errors": errors, "exchange": EXCHANGE_KIND} + + +@app.post("/emergency/close-position") +def emergency_close_position( + body: EmergencyClosePositionBody, + x_control_token: str | None = Header(default=None, alias="X-Control-Token"), +): + _check_token(x_control_token) + sym = (body.symbol or "").strip() + want_side = (body.side or "").strip().lower() + if not sym: + raise HTTPException(status_code=400, detail="symbol 不能为空") + if want_side not in ("long", "short"): + raise HTTPException(status_code=400, detail="side 须为 long 或 short") + 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": None, + "exchange": EXCHANGE_KIND, + }, + status_code=200, + ) + try: + raw = ex.fetch_positions() or [] + except Exception as e: + raise HTTPException(status_code=502, detail=f"fetch_positions: {e}") from e + + matched = None + for p in raw: + if not isinstance(p, dict): + continue + if not symbols_match(sym, (p.get("symbol") or "").strip()): + continue + c = _position_contracts(p) + if abs(c) < 1e-12: + continue + side = _position_side(p, c) + if side != want_side: + continue + matched = (sym, side, abs(c)) + break + + if not matched: + return JSONResponse( + { + "ok": False, + "error": f"未找到持仓: {sym} {want_side}", + "closed": None, + "exchange": EXCHANGE_KIND, + }, + status_code=200, + ) + + sym, side, c = matched + info, err = _close_position_market(ex, sym, side, c) + if err: + return JSONResponse( + {"ok": False, "error": err, "closed": None, "exchange": EXCHANGE_KIND}, + status_code=200, + ) + return {"ok": True, "closed": info, "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/docs/help/01-quickstart.md b/manual_trading_hub/docs/help/01-quickstart.md new file mode 100644 index 0000000..3ea2749 --- /dev/null +++ b/manual_trading_hub/docs/help/01-quickstart.md @@ -0,0 +1,34 @@ +# 快速开始 + +## 系统是什么 + +**中控**聚合三所(币安 / OKX / Gate)的持仓、委托、关键位与趋势计划,并提供资金曲线、行情、复盘与 AI 教练。**实际下单、关键位配置、策略执行、交易复盘**在各交易所**实例网页**完成。 + +``` +浏览器 → 中控(默认 :5100) + ├─ 监控区 / 资金 / 行情 / 内照明心 … + └─ 点击「下单 / 期权」→ 实例页(内嵌或新标签) +实例 Flask(币安 :5001 · Gate :5000 · OKX :5004) +``` + +## 日常推荐流程 + +1. 打开 **监控区**,查看三所持仓、浮盈亏、关键位与趋势计划。 +2. 需要操作某所时,点该卡片 **「下单」**(或 **「期权」**)进入实例页。 +3. 复盘与统计:中控 **内照明心**、**数据看板**,或实例 **交易记录与复盘**。 +4. 事前计划:中控 **开仓计划**;策略 playbook:中控 **策略说明**(与实例「策略交易」不同,见下文)。 + +## 三账户默认对应 + +| 账户 | 实例端口 | 常见能力 | +|------|----------|----------| +| 币安 | 5001 | 关键位 + 趋势 | +| OKX | 5004 | 关键位 + 趋势 + **期权**(需在设置勾选「监控期权」) | +| Gate | 5000 | 关键位 + 趋势 | + +## 两个容易混淆的名称 + +| 名称 | 在哪里 | 是什么 | +|------|--------|--------| +| **策略说明** | 中控顶栏 | 各所策略文档与开仓检查清单 | +| **策略交易** | 实例顶栏 | 趋势回调 / 顺势加仓等自动化功能 | diff --git a/manual_trading_hub/docs/help/02-hub-nav.md b/manual_trading_hub/docs/help/02-hub-nav.md new file mode 100644 index 0000000..58bf545 --- /dev/null +++ b/manual_trading_hub/docs/help/02-hub-nav.md @@ -0,0 +1,26 @@ +# 中控导航说明 + +顶栏各页面用途如下(可在 **系统设置 → 显示与导航** 中隐藏不需要的 Tab)。 + +| 导航 | 用途 | +|------|------| +| **资金概况** | 总资金曲线、分户权益、回撤与 24h 变化 | +| **开仓计划** | 事前写下计划、跟踪进行中、统计历史胜率 | +| **监控区** | **核心操作台**:三所持仓卡片、全平/撤单、关键位与趋势计划摘要 | +| **策略说明** | 三所策略 playbook + 开仓检查清单(非系统操作手册) | +| **使用说明** | 本页:中控与实例怎么用 | +| **行情区** | K 线、指标、画线;可从持仓跳转带币种 | +| **计算器** | 趋势回调 / 滚仓张数与盈亏测算(手动填价) | +| **内照明心** | 复盘语录、归档交易、永久 5m K 线 | +| **数据看板** | 当日 KPI、分户卡片、SSE 刷新 | +| **AI 教练** | 交易教练对话、监管推送(需配置密钥) | +| **系统日志** | 中控与三实例 PM2 日志(排错用,非部署说明) | +| **系统设置** | 中控密码、导航显示、交易所地址、监控能力勾选 | + +## 手机端(≤720px) + +底栏固定四项:**监控 / 行情 / 计算 / AI**;其余入口进 **更多**。电脑与平板仍用顶栏,布局不变。 + +## 默认首页 + +登录后默认进入 **监控区**(`/monitor`)。 diff --git a/manual_trading_hub/docs/help/03-monitor.md b/manual_trading_hub/docs/help/03-monitor.md new file mode 100644 index 0000000..d986534 --- /dev/null +++ b/manual_trading_hub/docs/help/03-monitor.md @@ -0,0 +1,43 @@ +# 监控区与实例入口 + +## 监控卡片里有什么 + +每张交易所卡片通常包含: + +- **资金行**:资金账户 / 交易账户 / 浮动盈亏(可在设置关闭) +- **永续持仓**:表格摘要;点击标题栏 **进入全屏** 可看完整持仓卡片 +- **OKX 期权**(勾选「监控期权」后):永续与期权分块;全屏时期权也以卡片展示(与实例期权页字段一致,只读) +- **关键位 / 下单监控 / 趋势回调 / 顺势加仓**:只读摘要(数据来自实例) + +## 全屏模式 + +点击卡片标题栏(或移动端卡片主体)进入 **全屏**: + +- 永续:每币种一张持仓卡,可 **委托 / 平仓**(非日内纪律模式) +- 期权(OKX):只读卡片,含权利金、标记价、买盘深度等 +- 下方:关键位、下单监控、趋势与滚仓区块 + +按 `Esc` 或 **返回监控** 退出全屏。 + +## 打开实例(SSO) + +监控卡片或全屏顶栏按钮: + +| 按钮 | 进入实例页 | 说明 | +|------|------------|------| +| **打开实例** | 实盘下单 | 新浏览器标签 | +| **下单** | 实盘下单 | 中控内 iframe | +| **监控位** | 关键位监控 | | +| **复盘** | 交易记录与复盘 | | +| **期权** | 期权页 | 仅 OKX 且勾选监控期权 | + +实例地址在 **系统设置 → 交易所** 配置 `flask_url`;未配置时不会出现上述按钮。 + +## 常见操作 + +| 操作 | 位置 | +|------|------| +| 紧急全平 | 卡片 **全平**(日内纪律账户可能禁用) | +| 改止盈止损 | 持仓行 **委托** 或全屏卡片 | +| 撤条件单 | 监控区条件单列表 | +| 停止趋势计划 | 趋势回调区块 **停止 / 保本** 等 | diff --git a/manual_trading_hub/docs/help/04-instance.md b/manual_trading_hub/docs/help/04-instance.md new file mode 100644 index 0000000..37baa55 --- /dev/null +++ b/manual_trading_hub/docs/help/04-instance.md @@ -0,0 +1,30 @@ +# 实例页导航说明 + +从监控区 **下单 / 打开实例** 进入后,实例顶栏常见 Tab 如下(部分可在实例 **系统设置 → 导航显示** 中隐藏)。 + +| Tab | 用途 | +|-----|------| +| **关键位监控** | 配置 5m 门禁关键位,可选自动下单 | +| **实盘下单** | 人工下单、下单监控、预估盈亏比 | +| **策略交易** | 趋势回调、顺势加仓计划(自动化) | +| **策略交易记录** | 上述策略的执行历史 | +| **交易记录与复盘** | 平仓记录、日记、AI 复盘 | +| **统计分析** | 按周期汇总盈亏 | +| **期权** | OKX 期权链、持仓、买一平仓(OKX 且已启用) | +| **风控说明** | 只读展示当前风控相关 env | +| **env配置** | 修改运行参数(中文标签) | +| **系统设置** | 实例登录密码、导航 Tab 开关等 | + +## 与中控的分工 + +| 在中控做 | 在实例做 | +|----------|----------| +| 看三所持仓汇总、全平 | 下单、改单、平仓 | +| 看关键位 / 趋势摘要 | 新建 / 修改关键位与策略 | +| 内照明心、数据看板 | 交易记录详情、日记 | +| 开仓计划 | 策略交易执行 | +| OKX 期权只读监控 | 期权开仓、买一平仓 | + +## iframe 内操作 + +在中控 iframe 打开实例时,顶栏有 **返回监控 / 刷新 / 新标签打开**,无需重复登录。 diff --git a/manual_trading_hub/docs/help/05-settings.md b/manual_trading_hub/docs/help/05-settings.md new file mode 100644 index 0000000..5892461 --- /dev/null +++ b/manual_trading_hub/docs/help/05-settings.md @@ -0,0 +1,38 @@ +# 设置与配置说明 + +## 三层配置,不要混用 + +| 层级 | 入口 | 管什么 | +|------|------|--------| +| **中控系统设置** | 中控 `/settings` | 中控密码、顶栏显示、交易所 URL、监控能力(关键位/趋势/期权)、宏观日历、备份 | +| **实例系统设置** | 实例 `/settings` | 实例登录密码、实例顶栏 Tab 显示 | +| **实例 env配置** | 实例 `/env_config` | 交易参数(止损比例、风控开关等) | + +改 env 后通常需 **重启对应实例 PM2** 才完全生效;具体字段含义见实例 env 页说明或仓库 `docs/env配置说明.md`(无需在中控内阅读)。 + +## 中控 · 显示与导航 + +可隐藏不常用的顶栏 Tab(**监控区**、**系统设置** 无法隐藏)。 + +## 中控 · 交易所 + +每项需配置: + +- **flask_url**:实例 HTTP 地址(中控聚合与打开实例用) +- **agent_url**:子代理地址(持仓与全平) +- **capabilities**:勾选 **关键位 / 趋势 / 期权** 决定监控区展示哪些块 + +## 实例 · 导航显示 + +固定保留:**关键位监控、实盘下单、系统设置**。其余 Tab 可按需开关。 + +## 使用向常见问题 + +**监控卡片没有「下单」按钮** +→ 检查该所 `flask_url` 是否填写且实例可访问。 + +**OKX 看不到期权** +→ 中控设置勾选「监控期权」,且实例已启用期权模块。 + +**策略说明 vs 策略交易** +→ 前者在中控,是文档;后者在实例,是自动化功能。 diff --git a/manual_trading_hub/ecosystem.agents.config.cjs b/manual_trading_hub/ecosystem.agents.config.cjs new file mode 100644 index 0000000..4278e03 --- /dev/null +++ b/manual_trading_hub/ecosystem.agents.config.cjs @@ -0,0 +1,11 @@ +/** + * 仅子代理(一般不单独用;默认请 pm2 start ecosystem.config.cjs 一次起 hub+agent) + * + * 若只想重启子代理,不动中控: + * pm2 restart manual-agent-binance manual-agent-gate ... + */ +const main = require("./ecosystem.config.cjs"); + +module.exports = { + apps: main.apps.filter((a) => String(a.name).startsWith("manual-agent-")), +}; diff --git a/manual_trading_hub/ecosystem.config.cjs b/manual_trading_hub/ecosystem.config.cjs new file mode 100644 index 0000000..7c215da --- /dev/null +++ b/manual_trading_hub/ecosystem.config.cjs @@ -0,0 +1,66 @@ +/** + * PM2:中控 hub + 三路子代理 agent(一次启动全部) + * + * 前置: + * cd manual_trading_hub + * source .venv/bin/activate && pip install -r requirements.txt + * cp .env.example .env + * + * 启动(hub + 全部 agent): + * pm2 start ecosystem.config.cjs + * pm2 save && pm2 startup + * + * 仅中控:pm2 start ecosystem.config.cjs --only manual-trading-hub + * 仅某 agent:pm2 start ecosystem.config.cjs --only manual-agent-binance + * + * 快捷:bash scripts/pm2_hub.sh start + */ +const path = require("path"); + +const HUB_DIR = __dirname; +const REPO_ROOT = path.join(HUB_DIR, ".."); +const RUN_HUB = path.join(HUB_DIR, "scripts", "run_hub.sh"); +const RUN_AGENT = path.join(HUB_DIR, "scripts", "run_agent.sh"); + +function agentApp(name, exchangeDir, exchange, port) { + return { + name, + cwd: path.join(REPO_ROOT, exchangeDir), + script: RUN_AGENT, + interpreter: "bash", + instances: 1, + autorestart: true, + watch: false, + max_memory_restart: "400M", + restart_delay: 3000, + max_restarts: 15, + merge_logs: true, + env: { + EXCHANGE: exchange, + PORT: String(port), + HOST: "127.0.0.1", + PYTHONPATH: REPO_ROOT, + }, + }; +} + +module.exports = { + apps: [ + agentApp("manual-agent-binance", "crypto_monitor_binance", "binance", 15200), + agentApp("manual-agent-okx", "crypto_monitor_okx", "okx", 15201), + agentApp("manual-agent-gate", "crypto_monitor_gate", "gate", 15202), + { + name: "manual-trading-hub", + cwd: HUB_DIR, + script: RUN_HUB, + interpreter: "bash", + instances: 1, + autorestart: true, + watch: false, + max_memory_restart: "512M", + env: { + PYTHONPATH: REPO_ROOT, + }, + }, + ], +}; diff --git a/manual_trading_hub/env_load.py b/manual_trading_hub/env_load.py new file mode 100644 index 0000000..138a4ed --- /dev/null +++ b/manual_trading_hub/env_load.py @@ -0,0 +1,34 @@ +"""加载 manual_trading_hub/.env(Windows 直接 python hub.py 时也需要).""" +from __future__ import annotations + +import os +from pathlib import Path + +HUB_DIR = Path(__file__).resolve().parent + + +def load_hub_dotenv() -> None: + path = HUB_DIR / ".env" + if not path.is_file(): + return + raw_bytes = path.read_bytes() + 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 diff --git a/manual_trading_hub/exchange_orders.py b/manual_trading_hub/exchange_orders.py new file mode 100644 index 0000000..2ed41fd --- /dev/null +++ b/manual_trading_hub/exchange_orders.py @@ -0,0 +1,846 @@ +""" +中控子代理:拉取交易所挂单/条件单并规范化展示;撤销单笔或按合约批量撤销;挂止盈止损(先撤条件单再挂). +""" +from __future__ import annotations + +import os +import time +from typing import Any + +from lib.exchange.okx_orders_lib import fetch_okx_all_open_orders +from lib.hub.hub_symbol_lib import symbols_match + + +def _coerce_float(*values) -> float | None: + for v in values: + if v is None or v == "": + continue + try: + return float(v) + except (TypeError, ValueError): + continue + return None + + +def _symbol_base_coin(symbol: str) -> str: + """ZEC/USDT:USDT,ZEC-USDT-SWAP 等统一为标的币 ZEC.""" + s = (symbol or "").strip().upper() + if not s: + return "" + if "-SWAP" in s: + s = s.replace("-SWAP", "") + if "-" in s: + return s.split("-", 1)[0] + if "/" in s: + return s.split("/", 1)[0] + if ":" in s: + return s.split(":", 1)[0] + return s + + +def _order_type_str(order: dict) -> str: + info = order.get("info") or {} + if isinstance(info, dict): + for key in ("orderType", "type", "origType", "algoType", "ordType"): + val = info.get(key) + if val: + return str(val).upper() + return str(order.get("type") or "").upper() + + +def _is_conditional_type(typ: str) -> bool: + t = (typ or "").upper() + if not t: + return False + keys = ("STOP", "TAKE_PROFIT", "TRAIL", "TRIGGER", "CONDITIONAL", "OCO") + return any(k in t for k in keys) + + +def _order_label(typ: str, side: str, reduce_only: bool | None) -> str: + t = (typ or "").upper() + side_l = (side or "").lower() + parts = [] + if "TAKE_PROFIT" in t: + parts.append("止盈") + elif "STOP" in t: + parts.append("止损") + elif "LIMIT" in t: + parts.append("限价") + elif "MARKET" in t: + parts.append("市价") + else: + parts.append(typ or "委托") + if side_l == "buy": + parts.append("买入") + elif side_l == "sell": + parts.append("卖出") + if reduce_only: + parts.append("·只减仓") + return " ".join(parts) + + +def _normalize_raw_order(order: dict, *, channel: str) -> dict[str, Any] | None: + if not isinstance(order, dict): + return None + info = order.get("info") or {} + if not isinstance(info, dict): + info = {} + oid = order.get("id") or info.get("algoId") or info.get("orderId") or info.get("ordId") + if oid is None: + return None + sym = str(order.get("symbol") or info.get("symbol") or info.get("instId") or "") + typ = _order_type_str(order) + side = str(order.get("side") or info.get("side") or "").lower() + reduce_only = order.get("reduceOnly") + if reduce_only is None: + reduce_only = info.get("reduceOnly") + try: + reduce_only = bool(reduce_only) if reduce_only is not None else None + except (TypeError, ValueError): + reduce_only = None + sl_trig = _coerce_float(info.get("slTriggerPx"), order.get("stopLossPrice")) + tp_trig = _coerce_float(info.get("tpTriggerPx"), order.get("takeProfitPrice")) + trig = _coerce_float( + order.get("stopPrice"), + order.get("triggerPrice"), + info.get("triggerPrice"), + info.get("stopPrice"), + info.get("triggerPx"), + sl_trig, + tp_trig, + ) + price = _coerce_float(order.get("price"), info.get("price"), info.get("ordPx")) + amt = _coerce_float(order.get("amount"), order.get("remaining"), info.get("quantity"), info.get("origQty"), info.get("sz")) + category = "conditional" if _is_conditional_type(typ) or channel == "algo" else "limit" + label = _order_label(typ, side, reduce_only) + if sl_trig is not None and tp_trig is not None: + label = f"止盈止损 SL={sl_trig:g} TP={tp_trig:g}" + elif sl_trig is not None: + label = f"止损 {sl_trig:g}" + elif tp_trig is not None: + label = f"止盈 {tp_trig:g}" + return { + "id": str(oid), + "symbol": sym, + "channel": channel, + "category": category, + "label": label, + "type": typ, + "side": side, + "amount": amt, + "trigger_price": trig, + "price": price, + "reduce_only": reduce_only, + "status": str(order.get("status") or info.get("status") or "open"), + } + + +def _okx_normalize_orders(raw: dict, channel: str) -> list[dict[str, Any]]: + """OKX 算法单常一笔同时含 SL+TP,拆成两条供中控「交易所止盈止损」展示.""" + n = _normalize_raw_order(dict(raw), channel=channel) + if not n: + return [] + info = raw.get("info") or {} + if not isinstance(info, dict): + info = {} + sl_trig = _coerce_float(info.get("slTriggerPx"), raw.get("stopLossPrice")) + tp_trig = _coerce_float(info.get("tpTriggerPx"), raw.get("takeProfitPrice")) + if sl_trig is None or tp_trig is None or sl_trig == tp_trig: + return [n] + base_id = n["id"] + rows: list[dict[str, Any]] = [] + for role, px, lbl in ( + ("sl", sl_trig, f"止损 {sl_trig:g}"), + ("tp", tp_trig, f"止盈 {tp_trig:g}"), + ): + row = dict(n) + row["id"] = f"{base_id}:{role}" + row["algo_id"] = base_id + row["label"] = lbl + row["trigger_price"] = px + row["category"] = "conditional" + row["channel"] = channel + rows.append(row) + return rows + + +def _okx_algo_order_id(order_id: str) -> str: + oid = str(order_id or "") + if ":" in oid: + return oid.split(":", 1)[0] + return oid + + +def _binance_list(ex: Any, symbol: str | None) -> list[dict]: + ex.load_markets() + out: list[dict] = [] + symbols: list[str] = [] + if symbol: + try: + symbols = [ex.market(symbol)["symbol"]] + except Exception: + symbols = [symbol] + else: + symbols = [] + try: + for p in ex.fetch_positions() or []: + sym = p.get("symbol") + if sym: + symbols.append(sym) + except Exception: + pass + if symbol and not symbols: + symbols = [symbol] + + def collect(ex_sym: str) -> None: + market = ex.market(ex_sym) + contract_id = market.get("id") + try: + for o in ex.fetch_open_orders(ex_sym) or []: + item = dict(o) + item["_channel"] = "regular" + n = _normalize_raw_order(item, channel="regular") + if n: + out.append(n) + except Exception: + pass + try: + if contract_id and hasattr(ex, "fapiPrivateGetOpenAlgoOrders"): + raw = ex.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 + wrapped = { + "id": info.get("algoId") or info.get("orderId"), + "symbol": ex_sym, + "info": info, + "type": info.get("orderType") or info.get("type"), + "side": (info.get("side") or "").lower(), + "amount": info.get("quantity") or info.get("origQty"), + "stopPrice": info.get("triggerPrice") or info.get("stopPrice"), + "reduceOnly": info.get("reduceOnly"), + } + n = _normalize_raw_order(wrapped, channel="algo") + if n: + out.append(n) + except Exception: + pass + + if symbols: + seen = set() + for s in symbols: + if s in seen: + continue + seen.add(s) + collect(s) + return out + + +def _okx_list(ex: Any, symbol: str | None) -> list[dict]: + ex.load_markets() + out: list[dict] = [] + symbols: list[str] = [] + if symbol: + try: + symbols = [ex.market(symbol)["symbol"]] + except Exception: + symbols = [symbol] + else: + try: + for p in ex.fetch_positions() or []: + sym = p.get("symbol") + if sym: + symbols.append(sym) + except Exception: + pass + if symbol and not symbols: + symbols = [symbol] + seen: set[tuple[str, str]] = set() + for sym in symbols: + try: + for o in fetch_okx_all_open_orders(ex, sym): + ch = "algo" if _is_conditional_type(_order_type_str(o)) else "regular" + for n in _okx_normalize_orders(dict(o), channel=ch): + key = (n["id"], n.get("channel") or ch) + if key in seen: + continue + seen.add(key) + out.append(n) + except Exception: + pass + return out + + +def _gate_extract_trigger_rule(info: dict) -> int | None: + if not isinstance(info, dict): + return None + trig = info.get("trigger") + if isinstance(trig, dict) and trig.get("rule") is not None: + try: + return int(trig["rule"]) + except (TypeError, ValueError): + pass + try: + return int(info.get("rule")) + except (TypeError, ValueError): + return None + + +def _gate_tpsl_role_from_rule(rule: int | None, direction: str) -> str | None: + if rule is None: + return None + d = (direction or "long").strip().lower() + if d == "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) + + +def _gate_trigger_params(ex: Any) -> dict: + p = {"type": "swap", "trigger": True} + try: + ex.load_unified_status() + if ex.options.get("unifiedAccount"): + p["unifiedAccount"] = True + except Exception: + pass + return p + + +def _gate_list(ex: Any, symbol: str | None) -> list[dict]: + ex.load_markets() + out: list[dict] = [] + symbols: list[str] = [] + if symbol: + try: + symbols = [ex.market(symbol)["symbol"]] + except Exception: + symbols = [symbol] + else: + try: + for p in ex.fetch_positions() or []: + sym = p.get("symbol") + if sym: + symbols.append(sym) + except Exception: + pass + if symbol and not symbols: + symbols = [symbol] + trig_params = _gate_trigger_params(ex) + seen = set() + for sym in symbols: + if sym in seen: + continue + seen.add(sym) + try: + for o in ex.fetch_open_orders(sym) or []: + n = _normalize_raw_order(dict(o), channel="regular") + if n: + out.append(n) + except Exception: + pass + try: + for o in ex.fetch_open_orders(sym, params=trig_params) or []: + item = dict(o) + item["type"] = item.get("type") or "trigger" + n = _normalize_raw_order(item, channel="algo") + if n: + info = o.get("info") if isinstance(o.get("info"), dict) else {} + rule = _gate_extract_trigger_rule(info) + if rule is not None: + n["gate_trigger_rule"] = rule + out.append(n) + except Exception: + pass + return out + + +def list_open_orders(ex: Any, exchange_kind: str, symbol: str | None = None) -> list[dict]: + kind = (exchange_kind or "binance").lower() + if kind == "binance": + orders = _binance_list(ex, symbol) + elif kind == "okx": + orders = _okx_list(ex, symbol) + else: + orders = _gate_list(ex, symbol) + if symbol: + orders = [o for o in orders if symbols_match(symbol, o.get("symbol") or "")] + # 去重 id+channel + seen: set[tuple[str, str]] = set() + uniq: list[dict] = [] + for o in orders: + key = (o["id"], o["channel"]) + if key in seen: + continue + seen.add(key) + uniq.append(o) + return uniq + + +def _enrich_gate_conditional_labels(cond: list[dict], side: str) -> None: + """Gate 仓位类触发单在 ccxt 中常显示为「市价·只减仓」,按 trigger.rule 标为止盈/止损.""" + direction = (side or "long").strip().lower() + for o in cond: + if not isinstance(o, dict): + continue + if (o.get("label") or "").startswith(("止盈", "止损")): + continue + role = _gate_tpsl_role_from_rule(o.get("gate_trigger_rule"), direction) + trig = o.get("trigger_price") + if not role or trig is None: + continue + try: + trig_f = float(trig) + except (TypeError, ValueError): + continue + prefix = "止损" if role == "sl" else "止盈" + o["label"] = f"{prefix} {trig_f:g}" + + +def attach_orders_to_positions(positions: list[dict], orders: list[dict]) -> None: + for p in positions: + sym = p.get("symbol") or "" + matched = [o for o in orders if symbols_match(sym, o.get("symbol") or "")] + cond = [o for o in matched if o.get("category") == "conditional"] + _enrich_gate_conditional_labels(cond, p.get("side") or "long") + from lib.hub.hub_order_sync_lib import dedupe_conditional_orders_by_role + + p["conditional_orders"] = dedupe_conditional_orders_by_role(cond) + p["regular_orders"] = [o for o in matched if o.get("category") != "conditional"] + + +def cancel_order( + ex: Any, + exchange_kind: str, + symbol: str, + order_id: str, + channel: str = "regular", +) -> None: + kind = (exchange_kind or "binance").lower() + ex.load_markets() + market = ex.market(symbol) + unified = market["symbol"] + ch = (channel or "regular").lower() + if kind == "binance" and ch == "algo": + contract_id = market.get("id") + if contract_id and hasattr(ex, "fapiPrivateDeleteAlgoOrder"): + ex.fapiPrivateDeleteAlgoOrder({"symbol": contract_id, "algoId": str(order_id)}) + return + params = None + if kind == "gate" and ch == "algo": + params = _gate_trigger_params(ex) + elif kind == "okx" and ch == "algo": + params = {"stop": True} + oid = _okx_algo_order_id(order_id) if kind == "okx" else str(order_id) + ex.cancel_order(oid, unified, params) + + +def cancel_orders_for_symbol( + ex: Any, + exchange_kind: str, + symbol: str, + *, + scope: str = "all", +) -> int: + """scope: all | conditional | limit""" + orders = list_open_orders(ex, exchange_kind, symbol) + if scope == "conditional": + orders = [o for o in orders if o.get("category") == "conditional"] + elif scope == "limit": + orders = [o for o in orders if o.get("category") != "conditional"] + n = 0 + for o in orders: + try: + cancel_order(ex, exchange_kind, symbol, o["id"], o.get("channel") or "regular") + n += 1 + except Exception as e: + print( + f"[cancel_orders_for_symbol] {exchange_kind} {symbol} id={o.get('id')}: {e}", + flush=True, + ) + return n + + +def _binance_cancel_algo_open(ex: Any, symbol: str) -> None: + try: + market = ex.market(symbol) + cid = market.get("id") + if cid and hasattr(ex, "fapiPrivateDeleteAlgoOpenOrders"): + ex.fapiPrivateDeleteAlgoOpenOrders({"symbol": cid}) + except Exception: + pass + + +def _binance_trigger_params() -> dict[str, Any]: + wt = (os.getenv("BINANCE_TRIGGER_WORKING_TYPE") or "CONTRACT_PRICE").strip().upper() + if wt not in ("CONTRACT_PRICE", "MARK_PRICE"): + wt = "CONTRACT_PRICE" + return {"workingType": wt} + + +def _binance_place_tp_sl( + ex: Any, + symbol: str, + direction: str, + amount: float, + stop_loss: float, + take_profit: float, + *, + position_mode: str = "hedge", +) -> None: + ex.load_markets() + market = ex.market(symbol) + if not market.get("swap"): + raise RuntimeError("仅支持永续合约") + close_side = "sell" if direction == "long" else "buy" + amt = float(ex.amount_to_precision(symbol, float(amount))) + if amt <= 0: + raise RuntimeError("止盈止损:可平数量经精度舍入后为 0") + sl_px = ex.price_to_precision(symbol, float(stop_loss)) + tp_px = ex.price_to_precision(symbol, float(take_profit)) + common = dict(_binance_trigger_params()) + if (position_mode or "hedge").lower() in ("hedge", "dual", "double", "hedged"): + common["positionSide"] = "LONG" if direction == "long" else "SHORT" + last_err: Exception | None = None + for attempt in range(6): + try: + ex.create_order( + symbol, "STOP_MARKET", close_side, amt, None, dict(common, stopPrice=sl_px) + ) + time.sleep(0.05) + ex.create_order( + symbol, + "TAKE_PROFIT_MARKET", + close_side, + amt, + None, + dict(common, stopPrice=tp_px), + ) + return + except Exception as e: + last_err = e + cancel_orders_for_symbol(ex, "binance", symbol, scope="conditional") + _binance_cancel_algo_open(ex, symbol) + time.sleep(0.2 * (attempt + 1)) + raise RuntimeError(f"Binance 未接受止盈/止损:{last_err}") + + +def _okx_order_params( + direction: str, + *, + reduce_only: bool, + pos_mode: str, + td_mode: str, + for_algo_tpsl: bool = False, +) -> dict: + params: dict[str, Any] = {"tdMode": td_mode or "cross"} + if (pos_mode or "hedge").lower() in ("hedge", "long_short_mode", "dual"): + ps = "long" if direction == "long" else "short" + params["posSide"] = ps + params["positionSide"] = ps + # OKX 条件/OCO 算法单勿带 reduceOnly,否则可能被当市价减仓立即成交 + if reduce_only and not for_algo_tpsl: + params["reduceOnly"] = True + return params + + +def _okx_place_tp_sl( + ex: Any, + symbol: str, + direction: str, + amount: float, + stop_loss: float, + take_profit: float, + *, + pos_mode: str = "hedge", + td_mode: str = "cross", +) -> None: + """OKX 永续:一笔 OCO 算法单挂止盈+止损(勿 reduceOnly + 分两笔 market).""" + ex.load_markets() + close_side = "sell" if direction == "long" else "buy" + amt = float(ex.amount_to_precision(symbol, float(amount))) + if amt <= 0: + raise RuntimeError("止盈止损:可平数量经精度舍入后为 0") + base = _okx_order_params( + direction, + reduce_only=False, + pos_mode=pos_mode, + td_mode=td_mode, + for_algo_tpsl=True, + ) + sl_px = ex.price_to_precision(symbol, float(stop_loss)) + tp_px = ex.price_to_precision(symbol, float(take_profit)) + order_params = { + **base, + "stopLossPrice": float(sl_px), + "takeProfitPrice": float(tp_px), + "tpOrdPx": "-1", + "slOrdPx": "-1", + } + last_err: Exception | None = None + for attempt in range(6): + try: + ex.create_order(symbol, "oco", close_side, amt, None, order_params) + return + except Exception as e: + last_err = e + cancel_orders_for_symbol(ex, "okx", symbol, scope="conditional") + time.sleep(0.2 * (attempt + 1)) + raise RuntimeError(f"OKX 未接受止盈/止损条件单:{last_err}") + + +def _gate_tpsl_env() -> tuple[bool, int, int, str]: + use_pos = (os.getenv("GATE_TPSL_USE_POSITION_ORDER") or "true").lower() in ("1", "true", "yes") + exp = int(os.getenv("GATE_TPSL_TRIGGER_EXPIRATION", str(7 * 86400))) + pt = int(os.getenv("GATE_TPSL_PRICE_TYPE", "0")) + if pt < 0 or pt > 2: + pt = 0 + pos_mode = (os.getenv("GATE_POS_MODE") or "hedge").strip().lower() + return use_pos, exp, pt, pos_mode + + +def _gate_place_tp_sl_position( + ex: Any, + symbol: str, + direction: str, + stop_loss: float, + take_profit: float, + *, + pos_mode: str, + price_type: int, + expiration: int, +) -> None: + ex.load_markets() + market = ex.market(symbol) + if not market.get("swap"): + raise RuntimeError("仅支持永续合约") + 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, tp_rule = (2, 1) if close_side == "sell" else (1, 2) + initial: dict[str, Any] = { + "contract": contract, + "size": 0, + "price": "0", + "close": True, + "reduce_only": True, + "tif": "ioc", + "text": "api", + } + if pos_mode in ("hedge", "dual", "double"): + 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 = ex.price_to_precision(symbol, float(stop_loss)) + tp_s = ex.price_to_precision(symbol, float(take_profit)) + + def _payload(trigger_price: str, rule: int) -> dict: + trig: dict[str, Any] = { + "strategy_type": 0, + "price_type": price_type, + "price": trigger_price, + "rule": rule, + } + if expiration > 0: + trig["expiration"] = expiration + return { + "settle": settle, + "initial": dict(initial), + "trigger": trig, + "order_type": order_type, + } + + last_err: Exception | None = None + for attempt in range(6): + try: + ex.privateFuturesPostSettlePriceOrders(_payload(sl_s, sl_rule)) + try: + ex.privateFuturesPostSettlePriceOrders(_payload(tp_s, tp_rule)) + except Exception: + # 保留已挂止损,仅放弃本次 TP + raise + return + except Exception as e: + last_err = e + time.sleep(0.2 * (attempt + 1)) + raise RuntimeError(f"Gate 仓位类止盈/止损未接受:{last_err}") + + +def _gate_place_tp_sl_legacy( + ex: Any, + symbol: str, + direction: str, + amount: float, + stop_loss: float, + take_profit: float, +) -> None: + ex.load_markets() + close_side = "sell" if direction == "long" else "buy" + base = {"reduceOnly": True} + last_err: Exception | None = None + for attempt in range(6): + try: + ex.create_order( + symbol, + "market", + close_side, + amount, + None, + dict(base, stopLossPrice=float(stop_loss)), + ) + ex.create_order( + symbol, + "market", + close_side, + 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"Gate 条件止盈/止损未接受:{last_err}") + + +def _gate_td_mode_cross() -> bool: + td = (os.getenv("GATE_TD_MODE") or "cross").strip().lower() + return td in ("cross", "cross_margin") + + +def _gate_last_price(ex: Any, symbol: str) -> float | None: + ex.load_markets() + unified = ex.market(symbol)["symbol"] + try: + t = ex.fetch_ticker(unified) + except Exception: + return None + if not isinstance(t, dict): + return None + info = t.get("info") if isinstance(t.get("info"), dict) else {} + for key in ("last", "mark", "close", "index_price"): + v = t.get(key) if key in t else info.get(key) + try: + f = float(v) + if f > 0: + return f + except (TypeError, ValueError): + continue + return None + + +def _gate_clamp_tpsl_prices( + ex: Any, + symbol: str, + direction: str, + stop_loss: float, + take_profit: float, +) -> tuple[float, float]: + """ + Gate price_orders:空仓止损/多仓止盈 trigger>last;空仓止盈/多仓止损 trigger= last: + tp = float(ex.price_to_precision(unified, last * (1 - gap))) + else: + if sl >= last: + sl = float(ex.price_to_precision(unified, last * (1 - gap))) + if tp <= last: + tp = float(ex.price_to_precision(unified, last * (1 + gap))) + return sl, tp + + +def _gate_place_tp_sl( + ex: Any, + symbol: str, + direction: str, + amount: float, + stop_loss: float, + take_profit: float, +) -> None: + use_pos, exp, pt, pos_mode = _gate_tpsl_env() + pos_err: Exception | None = None + if use_pos: + try: + _gate_place_tp_sl_position( + ex, symbol, direction, stop_loss, take_profit, + pos_mode=pos_mode, price_type=pt, expiration=exp, + ) + return + except Exception as e: + pos_err = e + if _gate_td_mode_cross(): + raise RuntimeError( + f"Gate 仓位类止盈/止损未接受(全仓不支持 ccxt 条件单回退):{pos_err}" + ) from e + try: + _gate_place_tp_sl_legacy(ex, symbol, direction, amount, stop_loss, take_profit) + except Exception as legacy_err: + if pos_err is not None: + raise RuntimeError( + f"Gate 仓位类止盈/止损未接受:{pos_err};条件单回退亦失败:{legacy_err}" + ) from legacy_err + raise + + +def replace_position_tpsl( + ex: Any, + exchange_kind: str, + symbol: str, + direction: str, + amount: float, + stop_loss: float, + take_profit: float, +) -> dict[str, Any]: + """ + 先撤销该合约全部条件单,再挂止盈+止损.与三实例策略页逻辑对齐(读各目录 .env 中 GATE_/BINANCE_/OKX_ 参数). + """ + kind = (exchange_kind or "binance").lower() + direction = (direction or "long").strip().lower() + if direction not in ("long", "short"): + raise ValueError("direction 须为 long 或 short") + sl = float(stop_loss) + tp = float(take_profit) + if sl <= 0 or tp <= 0: + raise ValueError("止损,止盈价格须大于 0") + ex.load_markets() + cancelled = cancel_orders_for_symbol(ex, kind, symbol, scope="conditional") + if kind == "binance": + _binance_cancel_algo_open(ex, symbol) + time.sleep(0.08) + amt = float(amount) + if amt <= 0: + raise ValueError("持仓数量无效") + if kind == "binance": + pm = (os.getenv("BINANCE_POSITION_MODE") or "hedge").strip().lower() + _binance_place_tp_sl(ex, symbol, direction, amt, sl, tp, position_mode=pm) + elif kind == "okx": + pm = (os.getenv("OKX_POS_MODE") or "hedge").strip().lower() + td = (os.getenv("OKX_TD_MODE") or "cross").strip() + _okx_place_tp_sl(ex, symbol, direction, amt, sl, tp, pos_mode=pm, td_mode=td) + else: + sl, tp = _gate_clamp_tpsl_prices(ex, symbol, direction, sl, tp) + _gate_place_tp_sl(ex, symbol, direction, amt, sl, tp) + return { + "symbol": symbol, + "direction": direction, + "amount": amt, + "stop_loss": sl, + "take_profit": tp, + "cancelled_conditional": cancelled, + } diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py new file mode 100644 index 0000000..1c885a1 --- /dev/null +++ b/manual_trading_hub/hub.py @@ -0,0 +1,3522 @@ +""" +多账户交易中控:监控区 / 系统设置. +聚合各实例监控数据与子代理 /status;下单请在各实例网页操作. +""" +from __future__ import annotations + +import asyncio +import os +import sys +from contextlib import asynccontextmanager +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parent.parent +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) + +from lib.hub.hub_monitor_totals_lib import aggregate_monitor_board_totals +from lib.hub.hub_trades_lib import current_trading_day +from lib.hub.hub_order_sync_lib import ( + cond_order_role, + dedupe_conditional_orders_by_role, + exchange_tpsl_from_cond_orders, +) +from lib.hub.hub_kline_store import format_ohlcv_detail, resolve_chart_bars, retention_days +from lib.hub.hub_ohlcv_lib import ( + CHART_TIMEFRAME_ORDER, + CHART_TIMEFRAMES, + bar_limit_for_timeframe, + chart_chunk_limit, + chart_initial_limit, + chart_memory_cap, + retention_policy_meta, +) +from lib.hub.hub_volume_rank_lib import ( + TOP_N_DEFAULT, + _exchange_rank_row_stale, + cache_needs_refresh, + format_volume_quote, + get_cached_rank, + load_volume_rank_cache, + merge_exchange_rank, + rank_date_label, + save_volume_rank_cache, + seconds_until_next_reset, + volume_rank_reset_hour, +) +from lib.hub.hub_divergence_scan_lib import ( + SCAN_TIMEFRAMES, + cache_is_stale, + chart_candles_to_bars, + get_cached_scan, + load_scan_cache, + merge_exchange_scan, + normalize_ohlcv_rows, + save_scan_cache, + scan_top_symbols, +) +from lib.hub.hub_symbol_archive_lib import ( + ARCHIVE_DEFAULT_TIMEFRAME, + ARCHIVE_QUOTES_MAX, + ARCHIVE_SEED_LOOKBACK_DAYS, + ARCHIVE_SYNC_INTERVAL_SEC, + ARCHIVE_TIMEFRAMES, + ARCHIVE_TRADE_DAYS, + ARCHIVE_TRADE_LIMIT, + ARCHIVE_VISIBLE_BARS_DEFAULT, + create_review_quote, + delete_review_quote, + init_db as init_archive_db, + list_daily_trades, + list_archive_calendar, + list_review_quotes, + list_symbol_rows, + load_symbol_trades, + parse_wall_clock_ms, + resolve_archive_chart, + sync_exchange_symbol_archives, + today_trading_day, + update_review_quote, + upsert_trade_overlay, +) +from lib.hub.hub_entry_plan_lib import ( + compute_entry_plan_stats, + create_entry_plan, + delete_entry_plan, + get_entry_plan, + init_db as init_entry_plan_db, + list_entry_plans, + meta_payload as entry_plan_meta_payload, + update_entry_plan, +) +from lib.hub.hub_help_lib import help_meta_payload, load_help_payload +from lib.hub.hub_strategy_lib import ( + build_export_html, + build_print_html, + load_strategy_payload, + strategy_meta_payload, +) +from lib.hub.hub_system_logs_lib import load_system_logs, system_logs_meta +from lib.hub.hub_macro_calendar_lib import ( + MACRO_EVENT_LABELS, + MACRO_EVENT_TYPES, + create_event as create_macro_event, + delete_event as delete_macro_event, + init_db as init_macro_calendar_db, + list_active_alerts, + list_events as list_macro_events, + update_event as update_macro_event, +) +from env_load import load_hub_dotenv + +load_hub_dotenv() + +import httpx +from fastapi import BackgroundTasks, Body, FastAPI, File, Form, HTTPException, Request, UploadFile +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel, Field + +from settings_store import ( + enabled_exchanges, + env_force_disabled_ids, + load_settings, + normalize_display_prefs, + normalize_supervisor_settings, + save_settings, +) +from lib.hub.hub_backup_lib import ( + backup_status, + normalize_backup_settings, + resolve_backup_download, + restore_backup_archive, + restore_backup_upload, + run_backup, + should_run_auto_backup, +) +from hub_web_auth import ( + SESSION_COOKIE, + SESSION_MAX_AGE_SEC, + clear_session_cookie, + cookie_secure_for_request, + create_session_token, + embed_allowed, + embed_frame_ancestors, + is_public_path, + password_required, + set_session_cookie, + validate_session_token, + expected_username, + verify_credentials, +) +from lib.hub.hub_sso import HUB_SSO_TTL_SEC, mint_hub_sso_token, safe_next_path +from url_public import browser_url, default_review_url, public_origin +from urllib.parse import urlencode + +from hub_board_cache import HUB_BOARD_POLL_INTERVAL, board_store +from hub_dashboard_cache import dashboard_store +from hub_dashboard import DASHBOARD_POLL_INTERVAL_SEC +from hub_supervisor_cache import supervisor_store +from hub_supervisor_lib import process_supervisor_tick, set_supervisor_notify_hook +from hub_ai.supervisor import make_supervisor_ai_reply_fn +from hub_ai.config import trading_day_reset_hour +from hub_chart_cache import ( + HUB_CHART_POLL_INTERVAL, + HUB_CHART_WATCH_TTL_SEC, + chart_poll_store, + parse_series_key, +) + +try: + from exchange_orders import symbols_match as _symbols_match +except ImportError: + + def _symbols_match(position_symbol: str, order_symbol: str) -> bool: + a = (position_symbol or "").strip().upper() + b = (order_symbol or "").strip().upper() + return bool(a and b and a == b) + +HUB_HOST = os.getenv("HUB_HOST", "0.0.0.0") +HUB_PORT = int(os.getenv("HUB_PORT", "5100")) +HUB_BRIDGE_TOKEN = (os.getenv("HUB_BRIDGE_TOKEN") or 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") +_allow_pub_raw = (os.getenv("HUB_ALLOW_PUBLIC") or "").strip().lower() +# 云服务器 + 域名反代时设为 true:不做 IP 限制,仅靠 HUB_PASSWORD / 登录页保护 +HUB_ALLOW_PUBLIC = _allow_pub_raw in ("1", "true", "yes", "on") +DIR = Path(__file__).resolve().parent +HUB_BUILD = "20260607-hub-archive" +_archive_sync_stop: asyncio.Event | None = None +_archive_sync_task: asyncio.Task | None = None +_last_archive_sync: dict | None = None +_volume_rank_stop: asyncio.Event | None = None +_volume_rank_task: asyncio.Task | None = None +_volume_rank_cache: dict | None = None +_divergence_scan_stop: asyncio.Event | None = None +_divergence_scan_task: asyncio.Task | None = None +_divergence_scan_cache: dict | None = None +_backup_stop: asyncio.Event | None = None +_backup_task: asyncio.Task | None = None +HUB_AGENT_TIMEOUT = float(os.getenv("HUB_AGENT_TIMEOUT", "8")) +HUB_FLASK_TIMEOUT = float(os.getenv("HUB_FLASK_TIMEOUT", "10")) +HUB_BOARD_TIMEOUT = float(os.getenv("HUB_BOARD_TIMEOUT", "45")) +_board_key_prices_raw = (os.getenv("HUB_BOARD_KEY_PRICES", "true") or "").strip().lower() +HUB_BOARD_KEY_PRICES = _board_key_prices_raw in ("1", "true", "yes", "on") + + +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 _hub_headers() -> dict[str, str]: + if not HUB_BRIDGE_TOKEN: + return {} + return {"X-Hub-Token": HUB_BRIDGE_TOKEN} + + +def _agent_headers() -> dict[str, str]: + if not HUB_BRIDGE_TOKEN: + return {} + return {"X-Control-Token": HUB_BRIDGE_TOKEN} + + +def _find_exchange(ex_id: str) -> dict | None: + for ex in load_settings().get("exchanges") or []: + if str(ex.get("id")) == str(ex_id): + return ex + return None + + +async def _run_chart_poll() -> dict: + keys = chart_poll_store.active_series_keys() + if not keys: + return {"ok": True, "series_count": 0, "polled": 0} + polled = 0 + errors: list[str] = [] + for key in keys: + parsed = parse_series_key(key) + if not parsed: + continue + ex_k, sym, tf = parsed + ex = _find_exchange_by_key(ex_k) + if not ex or not ex.get("enabled"): + continue + + ex_ref = ex + sym_ref = sym + tf_ref = tf + + def remote_fetch(**kwargs) -> dict: + tf_use = kwargs.get("timeframe") or tf_ref + return _fetch_instance_ohlcv_sync( + ex_ref, + symbol=kwargs.get("symbol") or sym_ref, + timeframe=tf_use, + since_ms=kwargs.get("since_ms"), + limit=int(kwargs.get("limit") or bar_limit_for_timeframe(tf_use)), + ) + + try: + result = await asyncio.to_thread( + resolve_chart_bars, + ex_k, + sym, + tf, + remote_fetch, + force_refresh=False, + tail_refresh=True, + ) + polled += 1 + chart_poll_store.note_series_result( + ex_k, + sym, + tf, + ok=bool(result.get("ok")), + fetched=int(result.get("fetched") or 0), + error=None if result.get("ok") else str(result.get("msg") or "poll_failed"), + candles=result.get("candles") if result.get("ok") else None, + price_tick=result.get("price_tick"), + ) + if not result.get("ok"): + errors.append(f"{key}:{result.get('msg')}") + except Exception as e: + chart_poll_store.note_series_result(ex_k, sym, tf, ok=False, error=str(e)) + errors.append(f"{key}:{e}") + out: dict = {"ok": True, "series_count": len(keys), "polled": polled} + if errors: + out["errors"] = errors[:8] + return out + + +async def _run_board_aggregate() -> dict: + try: + body = await asyncio.wait_for(_build_monitor_board_payload(), timeout=HUB_BOARD_TIMEOUT) + try: + from lib.hub.hub_fund_history_lib import record_fund_snapshot_from_board + + await asyncio.to_thread(record_fund_snapshot_from_board, body.get("rows") or []) + except Exception: + pass + # 监控聚合完成即唤醒数据看板,持仓来源与监控 5s 同步. + dashboard_store.request_refresh() + return {"ok": True, **body} + except asyncio.TimeoutError: + return { + "ok": False, + "rows": [], + "error": "board_timeout", + "msg": ( + f"监控聚合超过 {int(HUB_BOARD_TIMEOUT)} 秒." + "请检查子代理/Flask,或设 HUB_BOARD_KEY_PRICES=false,缩短 HUB_FLASK_TIMEOUT" + ), + "updated_at": __import__("datetime").datetime.now().isoformat(timespec="seconds"), + } + + +def _schedule_board_refresh() -> None: + board_store.request_refresh() + dashboard_store.request_refresh() + supervisor_store.request_refresh() + + +async def _run_archive_sync_once() -> dict: + global _last_archive_sync + init_archive_db() + settings = load_settings() + targets = enabled_exchanges(settings) + results: list[dict] = [] + for ex in targets: + ex_key = str(ex.get("key") or "").strip().lower() + if not ex_key: + continue + trades_resp = await asyncio.to_thread( + _fetch_instance_trades_archive_sync, + ex, + days=ARCHIVE_TRADE_DAYS, + limit=ARCHIVE_TRADE_LIMIT, + ) + if not trades_resp.get("ok"): + st = trades_resp.get("status") + msg = ( + trades_resp.get("msg") + or trades_resp.get("error") + or trades_resp.get("detail") + or "拉取交易失败" + ) + if st == 404: + msg = ( + "HTTP 404:该 Flask 未注册 /api/hub/trades/archive." + "请在仓库根目录 git pull 后 pm2 restart crypto_gate" + ) + results.append( + { + "exchange_key": ex_key, + "name": ex.get("name"), + "ok": False, + "status": st, + "msg": msg, + } + ) + continue + trades = trades_resp.get("trades") or [] + for t in trades: + if isinstance(t, dict): + t["exchange_key"] = ex_key + + def remote_fetch(**kwargs): + return _fetch_instance_ohlcv_sync( + ex, + symbol=kwargs.get("symbol") or "", + timeframe=kwargs.get("timeframe") or "5m", + since_ms=kwargs.get("since_ms"), + limit=int(kwargs.get("limit") or 500), + ) + + r = await asyncio.to_thread( + sync_exchange_symbol_archives, + ex_key, + trades, + remote_fetch, + ) + r["name"] = ex.get("name") + r["trade_count"] = len(trades) + results.append(r) + out = { + "ok": True, + "exchanges": len(targets), + "results": results, + "updated_at": __import__("datetime").datetime.now().isoformat(timespec="seconds"), + } + _last_archive_sync = out + return out + + +def _fetch_instance_volume_rank_sync(ex: dict, *, top_n: int = TOP_N_DEFAULT) -> dict: + base = (ex.get("flask_url") or "").rstrip("/") + if not base: + return {"ok": False, "msg": "未配置 flask_url"} + params = {"top": str(int(top_n))} + url = f"{base}/api/hub/volume-rank?{urlencode(params)}" + try: + with httpx.Client(timeout=max(HUB_FLASK_TIMEOUT, 120.0)) as client: + r = client.get(url, headers=_hub_headers()) + if r.status_code >= 400: + parsed = _parse_http_json_body(r) + parsed.setdefault("ok", False) + parsed.setdefault("status", r.status_code) + return parsed + data = r.json() if r.content else {} + return data if isinstance(data, dict) else {"ok": False, "msg": "无效 JSON"} + except Exception as e: + return {"ok": False, "msg": str(e)} + + +def _get_volume_rank_cache() -> dict: + global _volume_rank_cache + if _volume_rank_cache is None: + _volume_rank_cache = load_volume_rank_cache() + return _volume_rank_cache + + +def _refresh_volume_ranks(*, force: bool = False) -> dict: + global _volume_rank_cache + expected = rank_date_label() + cache = _get_volume_rank_cache() + targets = enabled_exchanges(load_settings()) + required_keys = [ + str(ex.get("key") or "").strip().lower() + for ex in targets + if ex.get("enabled") and str(ex.get("key") or "").strip() + ] + if not force and not cache_needs_refresh( + cache, expected_rank_date=expected, required_keys=required_keys + ): + return { + "ok": True, + "skipped": True, + "rank_date": cache.get("rank_date"), + "updated_at": cache.get("updated_at"), + } + errors: list[str] = [] + for ex in targets: + ex_key = str(ex.get("key") or "").strip().lower() + if not ex_key or not ex.get("enabled"): + continue + resp = _fetch_instance_volume_rank_sync(ex, top_n=TOP_N_DEFAULT) + if resp.get("ok") and resp.get("items"): + cache = merge_exchange_rank(cache, ex_key, resp) + else: + msg = str(resp.get("msg") or resp.get("error") or "拉取失败") + if resp.get("ok") and not resp.get("items"): + msg = msg if msg != "拉取失败" else "无有效成交额数据" + errors.append(f"{ex_key}:{msg}") + exchanges = dict(cache.get("exchanges") or {}) + prev = dict(exchanges.get(ex_key) or {}) + prev["error"] = msg + if not prev.get("items"): + prev["items"] = [] + exchanges[ex_key] = prev + cache["exchanges"] = exchanges + cache["rank_date"] = expected + save_volume_rank_cache(cache) + _volume_rank_cache = cache + out: dict = { + "ok": True, + "rank_date": expected, + "exchanges": len(targets), + "updated_at": cache.get("updated_at"), + } + if errors: + out["errors"] = errors[:8] + return out + + +def _get_divergence_scan_cache() -> dict: + global _divergence_scan_cache + if _divergence_scan_cache is None: + _divergence_scan_cache = load_scan_cache() + return _divergence_scan_cache + + +def _refresh_divergence_scans( + *, + exchange_key: str | None = None, + force: bool = False, +) -> dict: + global _divergence_scan_cache + vol_cache = _get_volume_rank_cache() + rank_date = vol_cache.get("rank_date") or rank_date_label() + cache = _get_divergence_scan_cache() + targets = enabled_exchanges(load_settings()) + if exchange_key: + ex_k = str(exchange_key).strip().lower() + targets = [ex for ex in targets if str(ex.get("key") or "").strip().lower() == ex_k] + errors: list[str] = [] + scanned_count = 0 + for ex in targets: + ex_key = str(ex.get("key") or "").strip().lower() + if not ex_key or not ex.get("enabled"): + continue + if not force and not cache_is_stale(cache, ex_key, rank_date=rank_date): + continue + rank_payload = get_cached_rank(vol_cache, ex_key, top_n=TOP_N_DEFAULT) + rank_items = [] + for row in rank_payload.get("items") or []: + rank_items.append( + { + **row, + "volume_label": format_volume_quote(row.get("volume_quote")), + } + ) + if not rank_items: + msg = str(rank_payload.get("error") or "无 Top20 排名数据") + errors.append(f"{ex_key}:{msg}") + cache = merge_exchange_scan(cache, ex_key, rank_date=rank_date, items=[], error=msg) + continue + + ex_ref = ex + + def _fetch_bars(symbol: str, timeframe: str, _ex=ex_ref, _ex_key=ex_key) -> list[dict]: + def remote_fetch(**kwargs): + tf_use = kwargs.get("timeframe") or timeframe + return _fetch_instance_ohlcv_sync( + _ex, + symbol=kwargs.get("symbol") or symbol, + timeframe=tf_use, + since_ms=kwargs.get("since_ms"), + limit=int(kwargs.get("limit") or chart_initial_limit(tf_use)), + ) + + result = resolve_chart_bars( + _ex_key, + symbol, + timeframe, + remote_fetch, + force_refresh=False, + limit=chart_initial_limit(timeframe), + ) + if not result.get("ok"): + remote = remote_fetch( + symbol=symbol, + timeframe=timeframe, + since_ms=None, + limit=chart_initial_limit(timeframe), + ) + return normalize_ohlcv_rows(remote.get("bars") or []) + return normalize_ohlcv_rows(result.get("candles") or []) + + try: + items = scan_top_symbols(rank_items, _fetch_bars) + cache = merge_exchange_scan( + cache, ex_key, rank_date=rank_date, items=items, error=None + ) + scanned_count += 1 + except Exception as e: + msg = str(e) + errors.append(f"{ex_key}:{msg}") + cache = merge_exchange_scan(cache, ex_key, rank_date=rank_date, items=[], error=msg) + save_scan_cache(cache) + _divergence_scan_cache = cache + out: dict = { + "ok": True, + "rank_date": rank_date, + "scanned_exchanges": scanned_count, + "updated_at": cache.get("updated_at"), + } + if errors: + out["errors"] = errors[:8] + return out + + +async def _volume_rank_loop() -> None: + global _volume_rank_stop + stop = _volume_rank_stop + if stop is None: + return + try: + await asyncio.to_thread(_refresh_volume_ranks, force=False) + await asyncio.to_thread(_refresh_divergence_scans, force=False) + except Exception: + pass + while not stop.is_set(): + try: + wait_sec = seconds_until_next_reset() + await asyncio.wait_for(stop.wait(), timeout=wait_sec) + break + except asyncio.TimeoutError: + pass + if stop.is_set(): + break + try: + await asyncio.to_thread(_refresh_volume_ranks, force=True) + await asyncio.to_thread(_refresh_divergence_scans, force=True) + except Exception: + pass + + +async def _divergence_scan_loop() -> None: + global _divergence_scan_stop + stop = _divergence_scan_stop + if stop is None: + return + try: + await asyncio.to_thread(_refresh_divergence_scans, force=False) + except Exception: + pass + while not stop.is_set(): + try: + await asyncio.wait_for(stop.wait(), timeout=3600.0) + break + except asyncio.TimeoutError: + pass + if stop.is_set(): + break + try: + await asyncio.to_thread(_refresh_divergence_scans, force=False) + except Exception: + pass + + +async def _archive_sync_loop() -> None: + global _archive_sync_stop + stop = _archive_sync_stop + if stop is None: + return + init_archive_db() + while not stop.is_set(): + try: + await _run_archive_sync_once() + except Exception: + pass + try: + await asyncio.wait_for(stop.wait(), timeout=float(ARCHIVE_SYNC_INTERVAL_SEC)) + except asyncio.TimeoutError: + pass + + +async def _run_supervisor_tick() -> dict: + dash = dashboard_store.snapshot_dict() + board = board_store.snapshot_dict() + settings = load_settings() + ai_fn = make_supervisor_ai_reply_fn(_all_exchanges_for_ai()) + return await asyncio.to_thread( + process_supervisor_tick, + dash if dash.get("ok") is not False else None, + board if board.get("ok") is not False else None, + settings, + reset_hour=trading_day_reset_hour(), + ai_reply_fn=ai_fn, + ) + + +async def _backup_scheduler_loop() -> None: + global _backup_stop + stop = _backup_stop + if stop is None: + return + while not stop.is_set(): + try: + settings = load_settings() + if should_run_auto_backup(settings): + await asyncio.to_thread(run_backup, trigger="auto", settings=settings) + except Exception as e: + print(f"[backup] auto backup failed: {e}", flush=True) + try: + await asyncio.wait_for(stop.wait(), timeout=60.0) + except asyncio.TimeoutError: + pass + + +@asynccontextmanager +async def _hub_lifespan(_app: FastAPI): + global _archive_sync_stop, _archive_sync_task, _volume_rank_stop, _volume_rank_task + global _backup_stop, _backup_task, _divergence_scan_stop, _divergence_scan_task + set_supervisor_notify_hook(supervisor_store.bump) + await board_store.start(_run_board_aggregate) + await dashboard_store.start(_run_dashboard_aggregate) + await supervisor_store.start(_run_supervisor_tick) + await chart_poll_store.start(_run_chart_poll) + _archive_sync_stop = asyncio.Event() + _archive_sync_task = asyncio.create_task(_archive_sync_loop(), name="hub-archive-sync") + _volume_rank_stop = asyncio.Event() + _volume_rank_task = asyncio.create_task(_volume_rank_loop(), name="hub-volume-rank") + _divergence_scan_stop = asyncio.Event() + _divergence_scan_task = asyncio.create_task(_divergence_scan_loop(), name="hub-divergence-scan") + _backup_stop = asyncio.Event() + _backup_task = asyncio.create_task(_backup_scheduler_loop(), name="hub-backup-scheduler") + try: + yield + finally: + if _backup_stop: + _backup_stop.set() + if _backup_task: + _backup_task.cancel() + try: + await _backup_task + except asyncio.CancelledError: + pass + _backup_task = None + _backup_stop = None + if _archive_sync_stop: + _archive_sync_stop.set() + if _archive_sync_task: + _archive_sync_task.cancel() + try: + await _archive_sync_task + except asyncio.CancelledError: + pass + _archive_sync_task = None + _archive_sync_stop = None + if _volume_rank_stop: + _volume_rank_stop.set() + if _volume_rank_task: + _volume_rank_task.cancel() + try: + await _volume_rank_task + except asyncio.CancelledError: + pass + _volume_rank_task = None + _volume_rank_stop = None + if _divergence_scan_stop: + _divergence_scan_stop.set() + if _divergence_scan_task: + _divergence_scan_task.cancel() + try: + await _divergence_scan_task + except asyncio.CancelledError: + pass + _divergence_scan_task = None + _divergence_scan_stop = None + await chart_poll_store.stop() + await supervisor_store.stop() + await dashboard_store.stop() + await board_store.stop() + set_supervisor_notify_hook(None) + + +app = FastAPI(title="复盘系统中控", docs_url=None, redoc_url=None, lifespan=_hub_lifespan) +STATIC_DIR = DIR / "static" +_REPO_STATIC = _REPO_ROOT / "lib" / "common" / "static" +_AI_REVIEW_RENDER_JS = _REPO_STATIC / "ai_review_render.js" +_TRADE_STATS_CALENDAR_CSS = _REPO_STATIC / "trade_stats_calendar.css" +_TRADE_STATS_CALENDAR_JS = _REPO_STATIC / "trade_stats_calendar.js" +_ACCOUNT_RISK_BADGE_CSS = _REPO_STATIC / "account_risk_badge.css" +_ACCOUNT_RISK_BADGE_JS = _REPO_STATIC / "account_risk_badge.js" +_OPTIONS_EXPIRY_COUNTDOWN_JS = _REPO_STATIC / "options_expiry_countdown.js" +_OPTIONS_POSITION_CARDS_JS = _REPO_STATIC / "options_position_cards.js" + + +@app.get("/assets/account_risk_badge.css") +def hub_account_risk_badge_css(): + """与三所实例共用仓库根 static/account_risk_badge.css.""" + if not _ACCOUNT_RISK_BADGE_CSS.is_file(): + raise HTTPException(status_code=404, detail="account_risk_badge.css not found") + return FileResponse( + str(_ACCOUNT_RISK_BADGE_CSS), + media_type="text/css; charset=utf-8", + ) + + +@app.get("/assets/account_risk_badge.js") +def hub_account_risk_badge_js(): + """与三所实例共用仓库根 static/account_risk_badge.js.""" + if not _ACCOUNT_RISK_BADGE_JS.is_file(): + raise HTTPException(status_code=404, detail="account_risk_badge.js not found") + return FileResponse( + str(_ACCOUNT_RISK_BADGE_JS), + media_type="application/javascript; charset=utf-8", + ) + + +@app.get("/assets/options_expiry_countdown.js") +def hub_options_expiry_countdown_js(): + if not _OPTIONS_EXPIRY_COUNTDOWN_JS.is_file(): + raise HTTPException(status_code=404, detail="options_expiry_countdown.js not found") + return FileResponse( + str(_OPTIONS_EXPIRY_COUNTDOWN_JS), + media_type="application/javascript; charset=utf-8", + ) + + +@app.get("/assets/options_position_cards.js") +def hub_options_position_cards_js(): + if not _OPTIONS_POSITION_CARDS_JS.is_file(): + raise HTTPException(status_code=404, detail="options_position_cards.js not found") + return FileResponse( + str(_OPTIONS_POSITION_CARDS_JS), + media_type="application/javascript; charset=utf-8", + ) + + +@app.get("/assets/ai_review_render.js") +def hub_ai_review_render_js(): + """与三所实例共用仓库根 static/ai_review_render.js(须在 /assets mount 之前注册).""" + if not _AI_REVIEW_RENDER_JS.is_file(): + raise HTTPException(status_code=404, detail="ai_review_render.js not found") + return FileResponse( + str(_AI_REVIEW_RENDER_JS), + media_type="application/javascript; charset=utf-8", + ) + + +@app.get("/assets/trade_stats_calendar.css") +def hub_trade_stats_calendar_css(): + if not _TRADE_STATS_CALENDAR_CSS.is_file(): + raise HTTPException(status_code=404, detail="trade_stats_calendar.css not found") + return FileResponse( + str(_TRADE_STATS_CALENDAR_CSS), + media_type="text/css; charset=utf-8", + ) + + +@app.get("/assets/trade_stats_calendar.js") +def hub_trade_stats_calendar_js(): + if not _TRADE_STATS_CALENDAR_JS.is_file(): + raise HTTPException(status_code=404, detail="trade_stats_calendar.js not found") + return FileResponse( + str(_TRADE_STATS_CALENDAR_JS), + media_type="application/javascript; charset=utf-8", + ) + + +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 HUB_ALLOW_PUBLIC: + return await call_next(request) + peer = request.client.host if request.client else None + if not _client_allowed(peer): + return JSONResponse({"detail": "forbidden"}, status_code=403) + return await call_next(request) + + +@app.middleware("http") +async def embed_frame_headers(request: Request, call_next): + response = await call_next(request) + if embed_allowed(): + ancestors = embed_frame_ancestors() + if ancestors == "*": + response.headers["Content-Security-Policy"] = "frame-ancestors *" + else: + response.headers["Content-Security-Policy"] = f"frame-ancestors 'self' {ancestors}" + return response + + +@app.middleware("http") +async def hub_password_gate(request: Request, call_next): + if not password_required(): + return await call_next(request) + path = request.url.path + if is_public_path(path, request.method): + return await call_next(request) + token = request.cookies.get(SESSION_COOKIE) + if validate_session_token(token): + return await call_next(request) + if path.startswith("/api/"): + return JSONResponse({"detail": "未登录", "login_required": True}, status_code=401) + from fastapi.responses import RedirectResponse + + nxt = path if path.startswith("/") else "/monitor" + return RedirectResponse(f"/login?next={nxt}", status_code=302) + + +def _shell_page(): + index = STATIC_DIR / "index.html" + if not index.is_file(): + return JSONResponse({"detail": "missing static/index.html"}, status_code=500) + return FileResponse(index) + + +def _login_page(): + login = STATIC_DIR / "login.html" + if not login.is_file(): + return JSONResponse({"detail": "missing static/login.html"}, status_code=500) + return FileResponse(login) + + +class LoginBody(BaseModel): + username: str = "" + password: str = "" + + +@app.get("/api/auth/status") +def api_auth_status(request: Request): + required = password_required() + logged_in = not required or validate_session_token(request.cookies.get(SESSION_COOKIE)) + return { + "required": required, + "logged_in": logged_in, + } + + +@app.post("/api/auth/login") +def api_auth_login(body: LoginBody, request: Request): + if not password_required(): + return {"ok": True, "auth_disabled": True} + if not verify_credentials(body.username, body.password): + raise HTTPException(status_code=401, detail="用户名或密码错误") + token = create_session_token(body.username) + embed = (request.headers.get("x-hub-embed") or "").strip() == "1" + resp = JSONResponse({"ok": True, "session_token": token, "embed": embed}) + set_session_cookie(resp, request, token, embed=embed) + return resp + + +@app.get("/embed-auth") +def embed_auth_login(request: Request, token: str = "", next: str = "/monitor"): + """ + 嵌入式打开:父页跨域 fetch 登录时 Cookie 可能写不进 iframe, + 用 session_token 在本页做一次导航,在 iframe 内写入 hub_sess. + """ + from fastapi.responses import RedirectResponse + + dest = safe_next_path(next) + if not password_required(): + return RedirectResponse(dest, status_code=302) + if not validate_session_token(token): + q = urlencode({"next": dest, "embed": "1"}) + return RedirectResponse(f"/login?{q}", status_code=302) + resp = RedirectResponse(dest, status_code=302) + set_session_cookie(resp, request, token, embed=True) + return resp + + +@app.post("/api/auth/logout") +def api_auth_logout(request: Request): + embed = (request.headers.get("x-hub-embed") or "").strip() == "1" + resp = JSONResponse({"ok": True}) + clear_session_cookie(resp, request, embed=embed) + return resp + + +@app.get("/login") +def login_page(): + return _login_page() + + +@app.get("/") +def root_redirect(): + from fastapi.responses import RedirectResponse + + return RedirectResponse("/monitor") + + +@app.get("/monitor") +@app.get("/plan") +@app.get("/calculator") +@app.get("/market") +@app.get("/archive") +@app.get("/quotes") +@app.get("/dashboard") +@app.get("/funds") +@app.get("/ai") +@app.get("/strategy") +@app.get("/help") +@app.get("/logs") +@app.get("/settings") +def shell_pages(): + return _shell_page() + + +def _all_exchanges_for_ai() -> list: + """AI 聚合用:含未启用账户(标记未监控).""" + return list(load_settings().get("exchanges") or []) + + +from hub_ai.routes import create_hub_ai_router +from hub_dashboard import build_dashboard_payload, default_trading_day + +app.include_router(create_hub_ai_router(load_all_exchanges=_all_exchanges_for_ai)) + + +async def _run_dashboard_aggregate() -> dict: + try: + return await asyncio.to_thread( + build_dashboard_payload, + enabled_exchanges(), + trading_day=default_trading_day(), + ) + except Exception as exc: + return {"ok": False, "msg": str(exc), "error": "aggregate_failed"} + + +def _schedule_dashboard_refresh() -> None: + dashboard_store.request_refresh() + supervisor_store.request_refresh() + + +@app.get("/api/dashboard/daily") +def api_dashboard_daily(trading_day: str = ""): + day = (trading_day or "").strip()[:10] or default_trading_day() + if not (trading_day or "").strip(): + return dashboard_store.snapshot_dict() + try: + payload = build_dashboard_payload( + enabled_exchanges(), + trading_day=day, + ) + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + return {**payload, "dashboard_version": dashboard_store.version} + + +@app.get("/api/dashboard/stream") +async def api_dashboard_stream(): + from fastapi.responses import StreamingResponse + + return StreamingResponse( + dashboard_store.iter_sse(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + +@app.post("/api/dashboard/refresh") +async def api_dashboard_refresh(): + _schedule_dashboard_refresh() + return {"ok": True, "dashboard_version": dashboard_store.version} + + +@app.get("/api/ai/supervisor/stream") +async def api_supervisor_stream(): + from fastapi.responses import StreamingResponse + + return StreamingResponse( + supervisor_store.iter_sse(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + +@app.post("/api/ai/supervisor/refresh") +async def api_supervisor_refresh(): + supervisor_store.request_refresh() + return {"ok": True, "supervisor_version": supervisor_store.version} + + +@app.get("/trade") +def trade_removed_redirect(): + from fastapi.responses import RedirectResponse + + return RedirectResponse("/monitor", status_code=302) + + +@app.get("/api/settings") +def api_get_settings(): + return load_settings() + + +class SettingsDisplayBody(BaseModel): + show_account_pnl: bool = True + show_nav_funds: bool = True + show_nav_dashboard: bool = True + show_nav_plan: bool = True + show_nav_archive: bool = True + show_nav_quotes: bool = True + show_nav_ai: bool = True + show_nav_calculator: bool = True + show_nav_strategy: bool = True + show_nav_help: bool = True + show_nav_logs: bool = True + + +class SupervisorSettingsBody(BaseModel): + enabled: bool = True + wechat_webhook: str = "" + wechat_link_base: str = "" + wechat_prefix: str = "【交易监管】" + wechat_on_program_tp_sl: bool = True + manual_close_daily_warn: int = 2 + interval_warn_minutes: int = 15 + freq_30m_count: int = 2 + reopen_after_close_minutes: int = 30 + + +class BackupSettingsBody(BaseModel): + auto_enabled: bool = True + auto_hour: int = Field(default=0, ge=0, le=23) + retention_days: int = Field(default=30, ge=1, le=365) + include_env: bool = True + include_exchange_images: bool = False + backup_root: str = "" + + +class SettingsBody(BaseModel): + exchanges: list[dict] = Field(default_factory=list) + display: SettingsDisplayBody | None = None + supervisor: SupervisorSettingsBody | None = None + backup: BackupSettingsBody | None = None + + +@app.post("/api/settings") +def api_save_settings(body: SettingsBody): + force_off = env_force_disabled_ids() + to_save = [] + for ex in body.exchanges: + row = dict(ex) + eid = str(row.get("id", "")).strip() + if eid in force_off: + row["enabled"] = False + row.pop("env_disabled", None) + to_save.append(row) + existing = load_settings() + display = normalize_display_prefs(existing.get("display")) + if body.display is not None: + display = normalize_display_prefs(body.display.model_dump()) + supervisor = normalize_supervisor_settings(existing.get("supervisor")) + if body.supervisor is not None: + supervisor = normalize_supervisor_settings(body.supervisor.model_dump()) + backup = normalize_backup_settings(existing.get("backup")) + if body.backup is not None: + backup = normalize_backup_settings(body.backup.model_dump()) + save_settings( + { + "version": 1, + "exchanges": to_save, + "display": display, + "supervisor": supervisor, + "backup": backup, + } + ) + return {"ok": True, "settings": load_settings()} + + +class TrendCalculatorBody(BaseModel): + direction: str = "long" + capital_usdt: float = Field(gt=0) + risk_percent: float = Field(gt=0, le=100) + leverage: int = Field(ge=1, le=125) + entry_price: float = Field(gt=0) + stop_loss: float = Field(gt=0) + add_upper: float = Field(gt=0) + take_profit: float = Field(gt=0) + dca_legs: int = Field(default=5, ge=1, le=20) + exchange_id: str = "0" + base: str = "ETH" + + +class RollAddLegBody(BaseModel): + add_price: float = Field(gt=0) + new_stop_loss: float = Field(gt=0) + + +class RollCalculatorBody(BaseModel): + direction: str = "long" + capital_usdt: float = Field(gt=0) + risk_percent: float = Field(gt=0, le=100) + entry_price: float = Field(gt=0) + stop_loss: float = Field(gt=0) + take_profit: float = Field(gt=0) + add_legs: list[RollAddLegBody] = Field(default_factory=list, max_length=3) + legs_done: int = Field(default=0, ge=0, le=3) + exchange_id: str = "0" + base: str = "ETH" + + +@app.get("/api/calculator/exchanges") +def api_calculator_exchanges(): + from lib.hub.hub_calculator_market_lib import list_calculator_exchanges + + return {"ok": True, "data": list_calculator_exchanges()} + + +@app.get("/api/calculator/market") +def api_calculator_market(exchange_id: str = "0", base: str = "ETH"): + from lib.hub.hub_calculator_market_lib import get_calculator_market + + data, err = get_calculator_market(exchange_id, base) + if err: + return JSONResponse({"ok": False, "msg": err}, status_code=400) + return {"ok": True, "data": data} + + +@app.post("/api/calculator/trend") +def api_calculator_trend(body: TrendCalculatorBody): + from lib.hub.hub_calculator_lib import calc_trend_calculator + + data, err = calc_trend_calculator( + direction=body.direction, + capital_usdt=body.capital_usdt, + risk_percent=body.risk_percent, + leverage=body.leverage, + entry_price=body.entry_price, + stop_loss=body.stop_loss, + add_upper=body.add_upper, + take_profit=body.take_profit, + dca_legs=body.dca_legs, + exchange_id=body.exchange_id, + base=body.base, + ) + if err: + return JSONResponse({"ok": False, "msg": err}, status_code=400) + return {"ok": True, "data": data} + + +@app.post("/api/calculator/roll") +def api_calculator_roll(body: RollCalculatorBody): + from lib.hub.hub_calculator_lib import calc_roll_calculator + + data, err = calc_roll_calculator( + direction=body.direction, + capital_usdt=body.capital_usdt, + risk_percent=body.risk_percent, + entry_price=body.entry_price, + stop_loss=body.stop_loss, + take_profit=body.take_profit, + add_legs=[leg.model_dump() for leg in body.add_legs], + legs_done=body.legs_done, + exchange_id=body.exchange_id, + base=body.base, + ) + if err: + return JSONResponse({"ok": False, "msg": err}, status_code=400) + return {"ok": True, "data": data} + + +def _find_exchange_by_key(exchange_key: str) -> dict | None: + key = (exchange_key or "").strip().lower() + if not key: + return None + for ex in load_settings().get("exchanges") or []: + if str(ex.get("key") or "").strip().lower() == key: + return ex + if str(ex.get("id") or "").strip() == exchange_key.strip(): + return ex + return None + + +def _fetch_instance_trades_archive_sync( + ex: dict, + *, + days: int = 365, + limit: int = 2000, +) -> dict: + base = (ex.get("flask_url") or "").rstrip("/") + if not base: + return {"ok": False, "msg": "未配置 flask_url"} + params = {"days": str(int(days)), "limit": str(int(limit))} + url = f"{base}/api/hub/trades/archive?{urlencode(params)}" + try: + with httpx.Client(timeout=HUB_FLASK_TIMEOUT) as client: + r = client.get(url, headers=_hub_headers()) + if r.status_code >= 400: + parsed = _parse_http_json_body(r) + parsed.setdefault("ok", False) + parsed.setdefault("status", r.status_code) + return parsed + data = r.json() if r.content else {} + if isinstance(data, dict): + data.setdefault("ok", True) + return data + return {"ok": False, "msg": "无效 JSON"} + except Exception as e: + return {"ok": False, "msg": str(e)} + + +def _fetch_instance_ohlcv_sync( + ex: dict, + *, + symbol: str, + timeframe: str, + since_ms: int | None, + limit: int, +) -> dict: + base = (ex.get("flask_url") or "").rstrip("/") + if not base: + return {"ok": False, "msg": "未配置 flask_url"} + params = {"symbol": symbol, "timeframe": timeframe, "limit": str(int(limit))} + if since_ms is not None and int(since_ms) > 0: + params["since_ms"] = str(int(since_ms)) + url = f"{base}/api/hub/ohlcv?{urlencode(params)}" + try: + with httpx.Client(timeout=HUB_FLASK_TIMEOUT) as client: + r = client.get(url, headers=_hub_headers()) + if r.status_code >= 400: + parsed = _parse_http_json_body(r) + parsed.setdefault("ok", False) + return parsed + data = r.json() if r.content else {} + return data if isinstance(data, dict) else {"ok": False, "msg": "无效 JSON"} + except Exception as e: + return {"ok": False, "msg": str(e)} + + +@app.get("/api/chart/meta") +def api_chart_meta(): + tfs = [tf for tf in CHART_TIMEFRAME_ORDER if tf in CHART_TIMEFRAMES] + exchanges = [] + for ex in enabled_exchanges(load_settings()): + exchanges.append( + { + "id": ex.get("id"), + "key": ex.get("key"), + "name": ex.get("name"), + } + ) + return { + "ok": True, + "timeframes": [tf for tf in tfs if tf in CHART_TIMEFRAMES], + "retention_days": retention_days(), + "retention_policy": retention_policy_meta(), + "limits": {tf: bar_limit_for_timeframe(tf) for tf in tfs if tf in CHART_TIMEFRAMES}, + "initial_limits": {tf: chart_initial_limit(tf) for tf in tfs if tf in CHART_TIMEFRAMES}, + "chunk_limits": {tf: chart_chunk_limit(tf) for tf in tfs if tf in CHART_TIMEFRAMES}, + "memory_caps": {tf: chart_memory_cap(tf) for tf in tfs if tf in CHART_TIMEFRAMES}, + "exchanges": exchanges, + "volume_rank_top_n": TOP_N_DEFAULT, + "volume_rank_reset_hour": volume_rank_reset_hour(), + "divergence_scan_tabs": list(SCAN_TIMEFRAMES), + } + + +@app.get("/api/chart/volume-rank") +def api_chart_volume_rank(exchange_key: str = "", refresh: str = ""): + force = (refresh or "").strip().lower() in ("1", "true", "yes", "on") + if force: + result = _refresh_volume_ranks(force=True) + if not result.get("ok"): + raise HTTPException(status_code=502, detail=result.get("msg") or "刷新失败") + cache = _get_volume_rank_cache() + ex_k = (exchange_key or "").strip().lower() + targets = enabled_exchanges(load_settings()) + required_keys = [ + str(ex.get("key") or "").strip().lower() + for ex in targets + if ex.get("enabled") and str(ex.get("key") or "").strip() + ] + need_keys = [ex_k] if ex_k else required_keys + if cache_needs_refresh(cache, required_keys=need_keys): + _refresh_volume_ranks(force=True) + cache = _get_volume_rank_cache() + elif ex_k: + row = (cache.get("exchanges") or {}).get(ex_k) or {} + if _exchange_rank_row_stale(row): + _refresh_volume_ranks(force=True) + cache = _get_volume_rank_cache() + if ex_k: + ex = _find_exchange_by_key(ex_k) + if not ex: + raise HTTPException(status_code=400, detail="交易所不存在") + payload = get_cached_rank(cache, ex_k, top_n=TOP_N_DEFAULT) + payload["items"] = [ + {**row, "volume_label": format_volume_quote(row.get("volume_quote"))} + for row in payload.get("items") or [] + ] + payload["reset_hour"] = volume_rank_reset_hour() + err = ((cache.get("exchanges") or {}).get(ex_k) or {}).get("error") + if err and not payload.get("items"): + payload["ok"] = False + payload["msg"] = err + return payload + exchanges_out = {} + for ex in enabled_exchanges(load_settings()): + key = str(ex.get("key") or "").strip().lower() + if not key: + continue + row = get_cached_rank(cache, key, top_n=TOP_N_DEFAULT) + row["name"] = ex.get("name") + row["items"] = [ + {**item, "volume_label": format_volume_quote(item.get("volume_quote"))} + for item in row.get("items") or [] + ] + exchanges_out[key] = row + return { + "ok": True, + "rank_date": cache.get("rank_date"), + "updated_at": cache.get("updated_at"), + "reset_hour": volume_rank_reset_hour(), + "exchanges": exchanges_out, + } + + +@app.post("/api/chart/volume-rank/refresh") +async def api_chart_volume_rank_refresh(): + result = await asyncio.to_thread(_refresh_volume_ranks, force=True) + if not result.get("ok"): + raise HTTPException(status_code=502, detail=result.get("msg") or "刷新失败") + return result + + +@app.get("/api/chart/divergence-scan") +def api_chart_divergence_scan( + exchange_key: str = "", + tab: str = "4h", + refresh: str = "", +): + force = (refresh or "").strip().lower() in ("1", "true", "yes", "on") + ex_k = (exchange_key or "").strip().lower() + if not ex_k: + raise HTTPException(status_code=400, detail="缺少 exchange_key") + tab_key = (tab or "4h").strip().lower() + if tab_key not in SCAN_TIMEFRAMES: + raise HTTPException(status_code=400, detail="tab 须为 4h / 1d / 1w") + if force: + _refresh_volume_ranks(force=False) + result = _refresh_divergence_scans(exchange_key=ex_k, force=True) + if not result.get("ok"): + raise HTTPException(status_code=502, detail=result.get("msg") or "扫描失败") + else: + vol_cache = _get_volume_rank_cache() + rank_date = vol_cache.get("rank_date") or rank_date_label() + cache = _get_divergence_scan_cache() + if cache_is_stale(cache, ex_k, rank_date=rank_date): + _refresh_divergence_scans(exchange_key=ex_k, force=True) + cache = _get_divergence_scan_cache() + payload = get_cached_scan(_get_divergence_scan_cache(), ex_k, tab=tab_key) + err = ((payload.get("error") or "") if not payload.get("items") else "") + if err and not payload.get("items"): + payload["ok"] = False + payload["msg"] = err + payload["tab_label"] = {"4h": "4h背离", "1d": "日线背离", "1w": "周线背离"}.get(tab_key, tab_key) + return payload + + +@app.post("/api/chart/divergence-scan/refresh") +async def api_chart_divergence_scan_refresh(exchange_key: str = ""): + ex_k = (exchange_key or "").strip().lower() + if not ex_k: + raise HTTPException(status_code=400, detail="缺少 exchange_key") + result = await asyncio.to_thread(_refresh_divergence_scans, exchange_key=ex_k, force=True) + if not result.get("ok"): + raise HTTPException(status_code=502, detail=result.get("msg") or "扫描失败") + return result + + +@app.get("/api/chart/ohlcv") +def api_chart_ohlcv( + exchange_key: str = "", + symbol: str = "", + timeframe: str = "1d", + refresh: str = "", + tail: str = "", + limit: int = 0, + before_ms: str = "", +): + ex = _find_exchange_by_key(exchange_key) + if not ex: + raise HTTPException(status_code=400, detail="交易所不存在") + if not ex.get("enabled"): + raise HTTPException(status_code=400, detail="该交易所未启用") + sym = (symbol or "").strip().upper() + if not sym: + raise HTTPException(status_code=400, detail="请输入币种") + ex_key = str(ex.get("key") or "").strip().lower() + force = (refresh or "").strip().lower() in ("1", "true", "yes", "on") + tail_refresh = (tail or "").strip().lower() in ("1", "true", "yes", "on") + lim = int(limit) if int(limit or 0) > 0 else None + bms_raw = (before_ms or "").strip() + bms = None + if bms_raw: + try: + bms = int(bms_raw) + except ValueError: + raise HTTPException(status_code=400, detail="before_ms 无效") + clear_db = force and not tail_refresh and bms is None + + def remote_fetch(**kwargs): + tf_use = kwargs.get("timeframe") or timeframe + return _fetch_instance_ohlcv_sync( + ex, + symbol=kwargs.get("symbol") or sym, + timeframe=tf_use, + since_ms=kwargs.get("since_ms"), + limit=int(kwargs.get("limit") or bar_limit_for_timeframe(tf_use)), + ) + + result = resolve_chart_bars( + ex_key, + sym, + timeframe, + remote_fetch, + force_refresh=force, + tail_refresh=tail_refresh, + clear_db=clear_db, + limit=lim, + before_ms=bms, + ) + if not result.get("ok"): + raise HTTPException(status_code=502, detail=result.get("msg") or "K线加载失败") + if not result.get("candles") and result.get("before_ms") is None: + raise HTTPException(status_code=502, detail=result.get("msg") or "无 K 线") + tick = result.get("price_tick") + last = result["candles"][-1] if result.get("candles") else None + result["ohlcv"] = format_ohlcv_detail( + { + "open": last.get("open") if last else None, + "high": last.get("high") if last else None, + "low": last.get("low") if last else None, + "close": last.get("close") if last else None, + "volume": last.get("volume") if last else None, + } + if last + else None, + tick, + ) + result["chart_version"] = chart_poll_store.version + result["series_version"] = chart_poll_store.series_version(ex_key, sym, timeframe) + result["chart_poll_interval_sec"] = HUB_CHART_POLL_INTERVAL + return result + + +class ChartWatchBody(BaseModel): + exchange_key: str = "" + symbol: str = "" + timeframe: str = "5m" + + +@app.post("/api/chart/watch") +async def api_chart_watch(body: ChartWatchBody = Body(...)): + ex_k = (body.exchange_key or "").strip().lower() + sym = (body.symbol or "").strip().upper() + tf = (body.timeframe or "5m").strip() + if not ex_k or not sym: + raise HTTPException(status_code=400, detail="缺少 exchange_key 或 symbol") + if tf not in CHART_TIMEFRAMES: + raise HTTPException(status_code=400, detail="不支持的周期") + key = chart_poll_store.touch_watch(ex_k, sym, tf) + chart_poll_store.request_refresh() + return { + "ok": True, + "series_key": key, + "series_version": chart_poll_store.series_version(ex_k, sym, tf), + "chart_version": chart_poll_store.version, + "watch_ttl_sec": HUB_CHART_WATCH_TTL_SEC, + } + + +@app.post("/api/chart/unwatch") +async def api_chart_unwatch(body: ChartWatchBody = Body(...)): + chart_poll_store.clear_watch(body.exchange_key, body.symbol, body.timeframe) + return {"ok": True} + + +@app.get("/api/chart/stream") +async def api_chart_stream(): + from fastapi.responses import StreamingResponse + + return StreamingResponse( + chart_poll_store.iter_sse(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + +@app.get("/api/chart/poll/meta") +async def api_chart_poll_meta(): + return chart_poll_store.event_dict() + + +@app.get("/api/backup/status") +def api_backup_status(): + return backup_status(load_settings()) + + +@app.post("/api/backup/run") +async def api_backup_run(): + result = await asyncio.to_thread(run_backup, trigger="manual", settings=load_settings()) + if not result.get("ok"): + raise HTTPException(status_code=500, detail=result.get("error") or "backup failed") + return result + + +@app.get("/api/backup/download/{name}") +def api_backup_download(name: str): + fp = resolve_backup_download(load_settings(), name) + if not fp: + raise HTTPException(status_code=404, detail="backup not found") + return FileResponse( + str(fp), + media_type="application/zip", + filename=fp.name, + ) + + +@app.post("/api/backup/restore") +async def api_backup_restore( + file: UploadFile = File(...), + confirm: str = Form(""), +): + if (confirm or "").strip().upper() != "RESTORE": + raise HTTPException(status_code=400, detail='请在 confirm 字段填写 RESTORE 以确认恢复') + content = await file.read() + result = await asyncio.to_thread( + restore_backup_upload, + content, + file.filename or "backup.zip", + settings=load_settings(), + ) + if not result.get("ok"): + raise HTTPException(status_code=500, detail=result.get("error") or "restore failed") + return result + + +@app.post("/api/backup/restore-local") +async def api_backup_restore_local(body: dict = Body(...)): + confirm = str(body.get("confirm") or "").strip().upper() + name = str(body.get("name") or "").strip() + if confirm != "RESTORE": + raise HTTPException(status_code=400, detail='请在 confirm 字段填写 RESTORE 以确认恢复') + fp = resolve_backup_download(load_settings(), name) + if not fp: + raise HTTPException(status_code=404, detail="backup not found") + result = await asyncio.to_thread(restore_backup_archive, fp, settings=load_settings()) + if not result.get("ok"): + raise HTTPException(status_code=500, detail=result.get("error") or "restore failed") + return result + + +@app.get("/api/settings/meta") +def api_settings_meta(): + po = public_origin() + return { + "env_disabled_ids": sorted(env_force_disabled_ids()), + "hub_bridge_token_set": bool(HUB_BRIDGE_TOKEN), + "capability_options": ["key", "trend", "options"], + "public_origin": f"{po[0]}://{po[1]}" if po else None, + "public_origin_hint": ( + "未设置 HUB_PUBLIC_ORIGIN 时,复盘链接若为 127.0.0.1,仅服务器本机浏览器可打开" + if not po + else "复盘/展示链接已替换为对外地址" + ), + "password_required": password_required(), + "default_username": expected_username(), + } + + +class HubPasswordBody(BaseModel): + old_password: str = "" + new_username: str = "" + new_password: str = "" + confirm_password: str = "" + + +@app.post("/api/settings/password") +def api_change_hub_password(body: HubPasswordBody): + from hub_env_lib import update_hub_credentials + + if not verify_credentials(expected_username(), body.old_password): + raise HTTPException(status_code=400, detail="当前密码错误") + if len(body.new_password or "") < 6: + raise HTTPException(status_code=400, detail="新密码至少 6 位") + if body.new_password != body.confirm_password: + raise HTTPException(status_code=400, detail="两次输入的新密码不一致") + new_user = (body.new_username or "").strip() + changed = update_hub_credentials( + new_password=body.new_password, + new_username=new_user or None, + ) + if not changed: + return {"ok": True, "changed_keys": [], "restart_required": False} + return {"ok": True, "changed_keys": changed, "restart_required": True} + + +@app.get("/api/admin/health") +def api_admin_health(): + return {"ok": True, "status": "up"} + + +@app.post("/api/admin/restart") +def api_admin_restart(): + from hub_env_lib import restart_hub_pm2 + + result = restart_hub_pm2() + if not result.get("ok"): + raise HTTPException(status_code=500, detail=result.get("msg") or "restart failed") + return result + + +class HubAiEnvBody(BaseModel): + values: dict[str, str] = Field(default_factory=dict) + restart: bool = True + + +@app.get("/api/settings/ai-env") +def api_get_ai_env(request: Request): + _require_hub_logged_in(request) + from hub_env_lib import get_hub_ai_env_payload + + return {"ok": True, **get_hub_ai_env_payload()} + + +@app.post("/api/settings/ai-env") +def api_save_ai_env(request: Request, body: HubAiEnvBody, background_tasks: BackgroundTasks): + _require_hub_logged_in(request) + from hub_env_lib import get_hub_ai_env_payload, restart_all_pm2, save_hub_ai_env + + result = save_hub_ai_env(body.values or {}) + if not result.get("ok"): + raise HTTPException(status_code=400, detail="; ".join(result.get("errors") or ["保存失败"])) + payload = get_hub_ai_env_payload() + restart_required = bool(body.restart and result.get("restart_required")) + if restart_required: + background_tasks.add_task(restart_all_pm2) + return { + "ok": True, + "changed": result.get("changed") or {}, + "restart_required": restart_required, + "sync_status": payload.get("sync_status"), + } + + +async def _fetch_agent_status(client: httpx.AsyncClient, ex: dict) -> dict: + url = f"{ex['agent_url'].rstrip('/')}/status" + try: + r = await client.get(url, headers=_agent_headers(), timeout=HUB_AGENT_TIMEOUT) + body = r.json() if r.content else {} + return { + "id": ex["id"], + "name": ex["name"], + "key": ex.get("key"), + "agent_url": ex["agent_url"], + "flask_url": ex.get("flask_url"), + "capabilities": ex.get("capabilities") or [], + "http_ok": r.status_code == 200, + "agent": body, + "error": body.get("error") if isinstance(body, dict) else None, + } + except Exception as e: + return { + "id": ex["id"], + "name": ex["name"], + "key": ex.get("key"), + "agent_url": ex["agent_url"], + "flask_url": ex.get("flask_url"), + "capabilities": ex.get("capabilities") or [], + "http_ok": False, + "error": str(e), + "agent": None, + } + + +def _parse_http_json_body(r: httpx.Response) -> dict: + text = (r.text or "").strip() + if not text: + return {"ok": False, "status": r.status_code, "text": "(empty body)"} + try: + data = r.json() + if isinstance(data, dict): + return data + return {"ok": False, "status": r.status_code, "text": text[:500]} + except Exception: + snippet = text[:500] + if snippet.lstrip().lower().startswith(" dict | None: + base = (ex.get("flask_url") or "").rstrip("/") + if not base: + return None + try: + if method == "GET": + r = await client.get( + f"{base}{path}", + headers=_hub_headers(), + timeout=HUB_FLASK_TIMEOUT, + params=params or None, + ) + else: + headers = {**_hub_headers(), "Content-Type": "application/json"} + if json_body is not None: + r = await client.post( + f"{base}{path}", headers=headers, json=json_body, timeout=120.0 + ) + else: + r = await client.post( + f"{base}{path}", headers=headers, data=data, timeout=120.0 + ) + if r.status_code >= 400: + parsed = _parse_http_json_body(r) + parsed.setdefault("ok", False) + parsed.setdefault("status", r.status_code) + return parsed + return _parse_http_json_body(r) + except Exception as e: + return {"ok": False, "error": str(e)} + + +async def _notify_instance_user_close( + client: httpx.AsyncClient, ex: dict, *, count: int = 1 +) -> dict | None: + """登记实例侧用户主动平仓风控(中控点平仓/全平).""" + if count <= 0 or not (ex.get("flask_url") or "").strip(): + return None + return await _fetch_flask_json( + client, + ex, + "/api/hub/account-risk/user-close", + method="POST", + json_body={"source": "user_hub", "count": int(count)}, + ) + + +async def _sync_flask_after_position_close( + client: httpx.AsyncClient, + ex: dict, + *, + symbol: str, + side: str, +) -> dict: + """中控/agent 平仓后同步 Flask order_monitors,趋势与滚仓状态.""" + sym = (symbol or "").strip() + side_l = (side or "").strip().lower() + out: dict = {} + if not sym or side_l not in ("long", "short"): + return out + order_sync = await _fetch_flask_json( + client, + ex, + "/api/hub/order/sync-flat", + method="POST", + json_body={"symbol": sym, "side": side_l}, + ) + if isinstance(order_sync, dict): + out["order_sync"] = order_sync + if "trend" in (ex.get("capabilities") or []): + sync_parsed = await _fetch_flask_json( + client, + ex, + "/api/hub/trend/sync-flat", + method="POST", + json_body={"symbol": sym, "side": side_l}, + ) + if isinstance(sync_parsed, dict): + out["trend_sync"] = sync_parsed + roll_sync = await _fetch_flask_json( + client, + ex, + "/api/hub/roll/sync-flat", + method="POST", + json_body={"symbol": sym, "side": side_l}, + ) + if isinstance(roll_sync, dict): + out["roll_sync"] = roll_sync + return out + + +def _flask_error_from_hub_mon(hub_mon: dict | None) -> str | None: + if not isinstance(hub_mon, dict) or hub_mon.get("ok") is not False: + return None + st = hub_mon.get("status") + if st == 404: + return ( + "HTTP 404:该 Flask 未注册 /api/hub/*(hub_bridge 未加载)." + "请在仓库根目录 git pull 后 pm2 restart crypto_binance crypto_gate," + "并查看启动日志是否含 [hub_bridge] ImportError" + ) + return ( + hub_mon.get("msg") + or hub_mon.get("error") + or (f"HTTP {st}" if st else None) + or (str(hub_mon.get("text") or "")[:120] or None) + ) + + +def _cond_order_trigger_key(price: object) -> str | None: + if price is None or price == "": + return None + try: + return f"{float(price):.12g}" + except (TypeError, ValueError): + return None + + +def _merge_conditional_orders_no_dup( + existing: list, extra: list +) -> list: + """子代理已拉到的条件单与 Flask exchange_tpsl 合成行按触发价/订单号去重,避免 Gate 显示 4 笔实为 2 笔.""" + if not extra: + return list(existing) if existing else [] + if not existing: + return list(extra) + triggers: set[str] = set() + order_ids: set[str] = set() + out: list = [] + for row in existing: + if not isinstance(row, dict): + continue + out.append(row) + k = _cond_order_trigger_key(row.get("trigger_price")) + if k: + triggers.add(k) + oid = row.get("id") + if oid not in (None, ""): + order_ids.add(str(oid)) + for row in extra: + if not isinstance(row, dict): + continue + k = _cond_order_trigger_key(row.get("trigger_price")) + oid = row.get("id") + if k and k in triggers: + continue + if oid not in (None, "") and str(oid) in order_ids: + continue + out.append(row) + if k: + triggers.add(k) + if oid not in (None, ""): + order_ids.add(str(oid)) + return out + + +def _tpsl_slots_to_conditional_orders(exchange_tpsl: dict, symbol: str) -> list[dict]: + """将实例 price_snapshot 的 exchange_tpsl 转为中控条件单结构.""" + out: list[dict] = [] + if not isinstance(exchange_tpsl, dict): + return out + for role, label in (("sl", "止损"), ("tp", "止盈")): + slot = exchange_tpsl.get(role) + if not isinstance(slot, dict): + continue + trig = slot.get("trigger_price") + if trig is None: + continue + try: + trig_f = float(trig) + except (TypeError, ValueError): + continue + oid = slot.get("order_id") + out.append( + { + "id": str(oid) if oid is not None else "", + "symbol": symbol, + "channel": "algo", + "category": "conditional", + "label": f"{label} {trig_f:g}", + "trigger_price": trig_f, + "amount": slot.get("amount"), + "status": "open", + } + ) + return out + + +def _exchange_tpsl_from_hub_order(hub_orders: list, symbol: str, side: str) -> dict | None: + """趋势保本移交后:用下单监控计划价补全 exchange_tpsl(与实例页一致).""" + side_l = (side or "").lower() + for o in hub_orders: + if not isinstance(o, dict): + continue + o_sym = o.get("exchange_symbol") or o.get("symbol") or "" + if not _symbols_match(symbol, o_sym): + continue + if (o.get("direction") or "").lower() != side_l: + continue + sl = o.get("stop_loss") + tp = o.get("take_profit") + if sl in (None, "") and tp in (None, ""): + continue + slots: dict = {"sl": None, "tp": None} + if sl not in (None, ""): + try: + slots["sl"] = {"trigger_price": float(sl), "order_id": None} + except (TypeError, ValueError): + pass + if tp not in (None, ""): + try: + slots["tp"] = {"trigger_price": float(tp), "order_id": None} + except (TypeError, ValueError): + pass + if slots["sl"] or slots["tp"]: + return slots + return None + + +def _order_price_op_indexes(order_prices: list) -> tuple[dict, list]: + """price_snapshot order_prices:id 可能为 int/str,需双键索引.""" + by_id: dict = {} + flat: list = [] + for op in order_prices: + if not isinstance(op, dict): + continue + flat.append(op) + oid = op.get("id") + if oid is None: + continue + by_id[oid] = op + by_id[str(oid)] = op + try: + by_id[int(oid)] = op + except (TypeError, ValueError): + pass + return by_id, flat + + +def _match_order_price_op( + order_row: dict, + by_id: dict, + order_prices: list, +) -> dict | None: + if not isinstance(order_row, dict): + return None + oid = order_row.get("id") + if oid is not None: + for key in (oid, str(oid)): + op = by_id.get(key) + if isinstance(op, dict): + return op + try: + op = by_id.get(int(oid)) + if isinstance(op, dict): + return op + except (TypeError, ValueError): + pass + sym = order_row.get("exchange_symbol") or order_row.get("symbol") or "" + direction = (order_row.get("direction") or "").lower() + for op in order_prices: + if not isinstance(op, dict): + continue + if not _symbols_match(sym, op.get("symbol") or ""): + continue + op_dir = (op.get("direction") or "").lower() + if direction and op_dir and direction != op_dir: + continue + return op + return None + + +_ORDER_PRICE_MERGE_KEYS = ( + "stop_loss", + "take_profit", + "stop_loss_display", + "take_profit_display", + "display_rr_ratio", + "latest_risk_amount", + "contracts", + "reward_at_tp_usdt", + "exchange_initial_margin", + "plan_margin", + "time_close_enabled", + "time_close_hours", + "time_close_at_ms", + "time_close_label", + "time_close_countdown", + "time_close_remaining_sec", + "force_close_enabled", + "force_close_bj_hour", + "force_close_at_ms", + "force_close_label", + "force_close_countdown", + "force_close_remaining_sec", + "force_close_active", +) + + +def _apply_order_price_op_fields(target: dict, op: dict) -> None: + if not isinstance(target, dict) or not isinstance(op, dict): + return + if op.get("rr_ratio") is not None: + target["rr_ratio"] = op["rr_ratio"] + if "sl_breakeven_secured" in op: + target["sl_breakeven_secured"] = bool(op["sl_breakeven_secured"]) + for key in _ORDER_PRICE_MERGE_KEYS: + if key not in op: + continue + val = op[key] + if key == "latest_risk_amount": + if val is not None and val != "": + target[key] = val + continue + if val not in (None, ""): + target[key] = val + + +def _find_exchange_tpsl_for_position( + symbol: str, + side: str, + order_prices: list, + hub_orders: list, +) -> dict | None: + side_l = (side or "").lower() + by_id, flat = _order_price_op_indexes(order_prices) + for o in hub_orders: + if not isinstance(o, dict): + continue + o_sym = o.get("exchange_symbol") or o.get("symbol") or "" + if not _symbols_match(symbol, o_sym): + continue + if (o.get("direction") or "").lower() != side_l: + continue + op = _match_order_price_op(o, by_id, flat) + if not isinstance(op, dict): + continue + et = op.get("exchange_tpsl") + if isinstance(et, dict) and (et.get("sl") or et.get("tp")): + return et + for op in flat: + if not isinstance(op, dict): + continue + if not _symbols_match(symbol, op.get("symbol") or ""): + continue + et = op.get("exchange_tpsl") + if isinstance(et, dict) and (et.get("sl") or et.get("tp")): + return et + return None + + +def _merge_flask_order_price_fields(hub_mon: dict | None, snap: dict | None) -> None: + """将 price_snapshot 中的快照盈亏比,已保本状态合并进 hub_monitor.orders.""" + if not isinstance(hub_mon, dict) or not isinstance(snap, dict): + return + order_prices = snap.get("order_prices") or [] + by_id, flat = _order_price_op_indexes(order_prices) + orders = hub_mon.get("orders") or [] + if not isinstance(orders, list): + return + for o in orders: + if not isinstance(o, dict): + continue + op = _match_order_price_op(o, by_id, flat) + if not isinstance(op, dict): + continue + _apply_order_price_op_fields(o, op) + + +def _merge_flask_position_breakeven(agent_row: dict, snap: dict | None, hub_mon: dict | None) -> None: + """将 price_snapshot 的已保本,最新风险,保证金等同步到 agent 持仓.""" + ag = agent_row.get("agent") + if not isinstance(ag, dict) or not isinstance(snap, dict): + return + positions = ag.get("positions") + if not isinstance(positions, list) or not positions: + return + order_prices = snap.get("order_prices") or [] + by_id, flat = _order_price_op_indexes(order_prices) + hub_orders = [] + if isinstance(hub_mon, dict): + hub_orders = hub_mon.get("orders") or [] + for p in positions: + if not isinstance(p, dict): + continue + sym = p.get("symbol") or "" + side = (p.get("side") or "").lower() + matched = None + for o in hub_orders: + if not isinstance(o, dict): + continue + o_sym = o.get("exchange_symbol") or o.get("symbol") or "" + if not _symbols_match(sym, o_sym): + continue + if (o.get("direction") or "").lower() != side: + continue + matched = _match_order_price_op(o, by_id, flat) + if isinstance(matched, dict): + break + if o.get("latest_risk_amount") is not None or o.get("exchange_initial_margin") is not None: + matched = o + break + if matched is None: + for op in flat: + if not isinstance(op, dict): + continue + if not _symbols_match(sym, op.get("symbol") or ""): + continue + matched = op + break + if isinstance(matched, dict): + _apply_order_price_op_fields(p, matched) + disp = matched.get("exchange_mark_price_display") + if disp is not None and str(disp).strip() not in ("", "-"): + p["mark_price_fmt"] = str(disp) + mp = matched.get("exchange_mark_price") + if mp is not None: + try: + mpf = float(mp) + if mpf > 0: + p["mark_price"] = mpf + except (TypeError, ValueError): + pass + + +def _agent_position_has_mark(p: dict) -> bool: + try: + v = float(p.get("mark_price")) + return v > 0 + except (TypeError, ValueError): + return False + + +def _apply_agent_mark_price(p: dict, mark_price: object, mark_display: object = None) -> None: + try: + mpf = float(mark_price) + except (TypeError, ValueError): + return + if mpf <= 0: + return + p["mark_price"] = mpf + disp = mark_display + if disp is not None and str(disp).strip() not in ("", "-"): + p["mark_price_fmt"] = str(disp) + + +def _find_matched_order_price_op( + p: dict, + order_prices: list, + hub_orders: list, +) -> dict | None: + sym = p.get("symbol") or "" + side = (p.get("side") or "").lower() + by_id, flat = _order_price_op_indexes(order_prices) + for o in hub_orders: + if not isinstance(o, dict): + continue + o_sym = o.get("exchange_symbol") or o.get("symbol") or "" + if not _symbols_match(sym, o_sym): + continue + if (o.get("direction") or "").lower() != side: + continue + matched = _match_order_price_op(o, by_id, flat) + if isinstance(matched, dict): + return matched + break + for op in flat: + if not isinstance(op, dict): + continue + if not _symbols_match(sym, op.get("symbol") or ""): + continue + return op + return None + + +def _merge_flask_position_mark_price( + agent_row: dict, snap: dict | None, hub_mon: dict | None +) -> None: + """子代理无标记价时,用实例 price_snapshot 的交易所标记价补全中控持仓展示.""" + ag = agent_row.get("agent") + if not isinstance(ag, dict) or not isinstance(snap, dict): + return + positions = ag.get("positions") + if not isinstance(positions, list) or not positions: + return + order_prices = snap.get("order_prices") or [] + hub_orders = [] + if isinstance(hub_mon, dict): + hub_orders = hub_mon.get("orders") or [] + for p in positions: + if not isinstance(p, dict) or _agent_position_has_mark(p): + continue + matched = _find_matched_order_price_op(p, order_prices, hub_orders) + if isinstance(matched, dict): + _apply_agent_mark_price( + p, + matched.get("exchange_mark_price"), + matched.get("exchange_mark_price_display"), + ) + position_marks = snap.get("position_marks") or [] + if not isinstance(position_marks, list): + return + for p in positions: + if not isinstance(p, dict) or _agent_position_has_mark(p): + continue + sym = p.get("symbol") or "" + side = (p.get("side") or "").lower() + for pm in position_marks: + if not isinstance(pm, dict): + continue + if not _symbols_match(sym, pm.get("symbol") or ""): + continue + if (pm.get("side") or "").lower() != side: + continue + _apply_agent_mark_price( + p, pm.get("mark_price"), pm.get("mark_price_display") + ) + break + + +def _merge_flask_exchange_tpsl(agent_row: dict, snap: dict | None, hub_mon: dict | None) -> None: + """子代理条件单优先;Flask exchange_tpsl 仅补缺失槽位,避免重复止损/止盈行.""" + ag = agent_row.get("agent") + if not isinstance(ag, dict): + return + positions = ag.get("positions") + if not isinstance(positions, list) or not positions: + return + if not isinstance(snap, dict): + snap = None + order_prices = (snap or {}).get("order_prices") or [] + hub_orders = [] + if isinstance(hub_mon, dict): + hub_orders = hub_mon.get("orders") or [] + for p in positions: + if not isinstance(p, dict): + continue + sym = p.get("symbol") or "" + side = p.get("side") or "" + cond = dedupe_conditional_orders_by_role(p.get("conditional_orders") or []) + roles_in_cond = { + r for row in cond if (r := cond_order_role(row)) in ("sl", "tp") + } + et = exchange_tpsl_from_cond_orders(cond) + if not et or roles_in_cond != {"sl", "tp"}: + flask_et = _find_exchange_tpsl_for_position(sym, side, order_prices, hub_orders) + if not flask_et: + flask_et = _exchange_tpsl_from_hub_order(hub_orders, sym, side) + if flask_et: + if et: + for role in ("sl", "tp"): + if not et.get(role) and flask_et.get(role): + et[role] = flask_et[role] + else: + et = flask_et + if not et: + p["conditional_orders"] = cond + continue + p["exchange_tpsl"] = et + merged = _tpsl_slots_to_conditional_orders(et, sym) + extra = [r for r in merged if cond_order_role(r) not in roles_in_cond] + p["conditional_orders"] = dedupe_conditional_orders_by_role( + _merge_conditional_orders_no_dup(cond, extra) + ) + + +_INTRADAY_CLOSE_BLOCK_MSG = ( + "日内账户禁止中控手动平仓/改委托,请等待计划止损/止盈或整点强制清仓" +) + + +def _meta_intraday_discipline(meta: dict | None) -> bool: + if not isinstance(meta, dict): + return False + if meta.get("intraday_discipline") is True: + return True + return meta.get("order_entry_profile") == "intraday" + + +async def _fetch_exchange_intraday_discipline( + client: httpx.AsyncClient, ex: dict +) -> bool: + data = await _fetch_flask_json(client, ex, "/api/hub/meta") + meta = (data or {}).get("meta") if isinstance(data, dict) else None + return _meta_intraday_discipline(meta if isinstance(meta, dict) else None) + + +async def _fetch_exchange_flask_bundle( + client: httpx.AsyncClient, ex: dict, *, trading_day: str | None = None +) -> tuple[dict | None, dict | None, list | None, dict | None, dict | None, dict | None]: + """单所 Flask:monitor / meta / price_snapshot / account / trades/today(有 flask_url 时)并行拉取.""" + caps = ex.get("capabilities") or [] + tasks = [ + _fetch_flask_json(client, ex, "/api/hub/monitor"), + _fetch_flask_json(client, ex, "/api/hub/meta"), + ] + has_flask = bool((ex.get("flask_url") or "").strip()) + day = (trading_day or "").strip() + if has_flask: + tasks.extend( + [ + _fetch_flask_json(client, ex, "/api/price_snapshot"), + _fetch_flask_json(client, ex, "/api/hub/account"), + ] + ) + if day: + tasks.append( + _fetch_flask_json( + client, + ex, + "/api/hub/trades/today", + params={"trading_day": day}, + ) + ) + results = await asyncio.gather(*tasks) + hub_mon = results[0] + meta = results[1] + snap = results[2] if has_flask and len(results) > 2 else None + account = results[3] if has_flask and len(results) > 3 else None + trades_today = results[4] if has_flask and day and len(results) > 4 else None + options_snap = None + if has_flask and "options" in caps: + options_snap = await _fetch_flask_json(client, ex, "/api/hub/options/snapshot") + key_prices = None + want_prices = HUB_BOARD_KEY_PRICES and "key" in caps + if want_prices and isinstance(snap, dict): + key_prices = snap.get("key_prices") + return ( + hub_mon, + meta, + key_prices, + snap if isinstance(snap, dict) else None, + account if isinstance(account, dict) else None, + trades_today if isinstance(trades_today, dict) else None, + options_snap if isinstance(options_snap, dict) else None, + ) + + +def _trading_day_reset_hour() -> int: + try: + return int(os.getenv("TRADING_DAY_RESET_HOUR", "8") or "8") + except ValueError: + return 8 + + +def _day_stats_from_trades_body(body: dict | None) -> dict: + if not isinstance(body, dict) or not body.get("ok"): + return {"ok": False} + stats = body.get("stats") if isinstance(body.get("stats"), dict) else {} + return { + "ok": True, + "trading_day": body.get("trading_day"), + "opens_today": int(body.get("opens_today") or 0), + "trade_stats": stats, + } + + +async def _assemble_board_row( + client: httpx.AsyncClient, ex: dict, agent_row: dict, *, trading_day: str +) -> dict: + hub_mon, meta, key_prices, snap, account, trades_today, options_snap = await _fetch_exchange_flask_bundle( + client, ex, trading_day=trading_day + ) + if isinstance(hub_mon, dict): + _merge_flask_order_price_fields(hub_mon, snap) + _merge_flask_exchange_tpsl(agent_row, snap, hub_mon if isinstance(hub_mon, dict) else None) + _merge_flask_position_breakeven(agent_row, snap, hub_mon if isinstance(hub_mon, dict) else None) + _merge_flask_position_mark_price(agent_row, snap, hub_mon if isinstance(hub_mon, dict) else None) + flask_ok = isinstance(hub_mon, dict) and hub_mon.get("ok") is not False + acct_ok = isinstance(account, dict) and account.get("ok") is not False + raw_review = (ex.get("review_url") or "").strip() + review_link = browser_url(raw_review) if raw_review else default_review_url( + ex.get("flask_url") + ) + return { + **agent_row, + "flask_url": ex.get("flask_url") or "", + "flask_url_browser": browser_url(ex.get("flask_url")), + "review_url": review_link, + "hub_monitor": hub_mon, + "flask_ok": flask_ok, + "flask_error": _flask_error_from_hub_mon(hub_mon if isinstance(hub_mon, dict) else None), + "meta": (meta or {}).get("meta") if isinstance(meta, dict) else meta, + "key_prices": key_prices, + "funding_usdt": account.get("funding_usdt") if acct_ok else None, + "trading_usdt": account.get("trading_usdt") if acct_ok else None, + "available_trading_usdt": account.get("available_trading_usdt") if acct_ok else None, + "account_ok": acct_ok, + "day_stats": _day_stats_from_trades_body(trades_today), + "force_close": snap.get("force_close") if isinstance(snap, dict) else None, + "options": options_snap, + } + + +async def _build_monitor_board_payload() -> dict: + exchanges = enabled_exchanges() + reset_hour = _trading_day_reset_hour() + trading_day = current_trading_day(reset_hour=reset_hour) + async with httpx.AsyncClient() as client: + agent_rows = await asyncio.gather( + *[_fetch_agent_status(client, ex) for ex in exchanges] + ) + out = await asyncio.gather( + *[ + _assemble_board_row(client, ex, agent_row, trading_day=trading_day) + for ex, agent_row in zip(exchanges, agent_rows) + ] + ) + rows = list(out) + totals = aggregate_monitor_board_totals( + rows, trading_day=trading_day, reset_hour=reset_hour + ) + return { + "rows": rows, + "totals": totals, + "updated_at": __import__("datetime").datetime.now().isoformat(timespec="seconds"), + } + + +@app.get("/api/monitor/board") +@app.get("/api/monitor/board/snapshot") +async def api_monitor_board_snapshot(): + """读后台缓存快照;完整聚合由 hub 每 HUB_BOARD_POLL_INTERVAL 秒执行.""" + return board_store.snapshot_dict() + + +@app.get("/api/monitor/board/stream") +async def api_monitor_board_stream(): + from fastapi.responses import StreamingResponse + + return StreamingResponse( + board_store.iter_sse(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + +@app.post("/api/monitor/board/refresh") +async def api_monitor_board_refresh(): + _schedule_board_refresh() + return {"ok": True, "board_version": board_store.version} + + +@app.get("/api/host/status") +async def api_host_status(): + from lib.hub.hub_host_status_lib import get_host_status + + return await asyncio.to_thread(get_host_status) + + +def _require_hub_logged_in(request: Request) -> None: + if password_required() and not validate_session_token(request.cookies.get(SESSION_COOKIE)): + raise HTTPException(status_code=401, detail="未登录中控") + + +@app.get("/api/instance/open-url") +def api_instance_open_url( + request: Request, + exchange_id: str, + next: str = "/", + embed: str = "", + hub_theme: str = "", +): + """已登录中控时生成实例 SSO 打开链接(2h 有效,单次使用,复用 HUB_BRIDGE_TOKEN).""" + _require_hub_logged_in(request) + if not HUB_BRIDGE_TOKEN: + raise HTTPException(status_code=503, detail="未配置 HUB_BRIDGE_TOKEN,无法签发实例打开链接") + ex = _find_exchange(exchange_id) + if not ex: + raise HTTPException(status_code=404, detail="未知交易所 id") + base = browser_url((ex.get("flask_url") or "").strip()).rstrip("/") + if not base: + raise HTTPException(status_code=400, detail="该账户未配置 flask_url") + ex_key = (ex.get("key") or "").strip().lower() + if not ex_key: + raise HTTPException(status_code=400, detail="该账户缺少 key(用于 SSO 校验)") + nxt = safe_next_path(next) + token = mint_hub_sso_token(ex_key, nxt) + if not token: + raise HTTPException(status_code=503, detail="签发 SSO 失败") + params = {"token": token, "next": nxt} + if (embed or "").strip().lower() in ("1", "true", "yes", "on"): + params["embed"] = "1" + ht = (hub_theme or "").strip().lower() + if ht in ("light", "dark"): + params["hub_theme"] = ht + q = urlencode(params) + return { + "ok": True, + "url": f"{base}/hub-sso?{q}", + "expires_in": HUB_SSO_TTL_SEC, + "exchange_id": exchange_id, + "exchange_key": ex_key, + } + + +class CloseAllBody(BaseModel): + exclude_ids: list[str] = Field(default_factory=list) + + +class ClosePositionBody(BaseModel): + symbol: str + side: str + + +class CancelOrderBody(BaseModel): + symbol: str + order_id: str + channel: str = "regular" + + +class CancelSymbolOrdersBody(BaseModel): + symbol: str + scope: str = "all" + + +class PlaceTpslBody(BaseModel): + symbol: str + side: str + stop_loss: float + take_profit: float + contracts: float | None = None + + +class TrendPlanActionBody(BaseModel): + plan_id: int + breakeven_offset_pct: float | None = None + + +def _flask_hub_messages(parsed: dict | None) -> tuple[bool, str]: + if not isinstance(parsed, dict): + return False, "实例返回无效" + msgs = list(parsed.get("messages") or []) + if parsed.get("msg"): + msgs.insert(0, str(parsed["msg"])) + if parsed.get("error"): + msgs.append(str(parsed["error"])) + ok = parsed.get("ok") is not False + if parsed.get("ok") is True: + ok = True + elif parsed.get("ok") is False: + ok = False + else: + for m in msgs: + if any( + k in str(m) + for k in ("失败", "错误", "无法", "缺少", "过期", "未找到", "不允许", "异常") + ): + ok = False + break + text = ";".join(str(x) for x in msgs if x) or ("成功" if ok else "操作失败") + return ok, text + + +@app.post("/api/trend/{exchange_id}/stop") +async def api_trend_plan_stop(exchange_id: str, body: TrendPlanActionBody): + ex = _find_exchange(exchange_id) + if not ex or not ex.get("enabled"): + raise HTTPException(status_code=404, detail="账户未启用") + if "trend" not in (ex.get("capabilities") or []): + raise HTTPException(status_code=400, detail="该账户未启用趋势计划监控") + pid = int(body.plan_id) + async with httpx.AsyncClient() as client: + parsed = await _fetch_flask_json( + client, ex, f"/api/hub/trend/stop/{pid}", method="POST" + ) + ok, text = _flask_hub_messages(parsed) + _schedule_board_refresh() + return {"ok": ok, "message": text, "payload": parsed} + + +@app.post("/api/trend/{exchange_id}/breakeven") +async def api_trend_plan_breakeven(exchange_id: str, body: TrendPlanActionBody): + ex = _find_exchange(exchange_id) + if not ex or not ex.get("enabled"): + raise HTTPException(status_code=404, detail="账户未启用") + if "trend" not in (ex.get("capabilities") or []): + raise HTTPException(status_code=400, detail="该账户未启用趋势计划监控") + pid = int(body.plan_id) + data = {} + if body.breakeven_offset_pct is not None: + data["breakeven_offset_pct"] = str(body.breakeven_offset_pct) + async with httpx.AsyncClient() as client: + parsed = await _fetch_flask_json( + client, + ex, + f"/api/hub/trend/breakeven/{pid}", + method="POST", + data=data, + ) + ok, text = _flask_hub_messages(parsed) + _schedule_board_refresh() + return {"ok": ok, "message": text, "payload": parsed} + + +@app.post("/api/orders/{exchange_id}/cancel") +async def api_cancel_order(exchange_id: str, body: CancelOrderBody): + ex = _find_exchange(exchange_id) + if not ex or not ex.get("enabled"): + raise HTTPException(status_code=404, detail="账户未启用") + url = f"{ex['agent_url'].rstrip('/')}/orders/cancel" + async with httpx.AsyncClient() as client: + r = await client.post( + url, + headers=_agent_headers(), + json={ + "symbol": body.symbol, + "order_id": body.order_id, + "channel": body.channel or "regular", + }, + timeout=60.0, + ) + try: + payload = r.json() + except Exception: + payload = {"raw": (r.text or "")[:2000]} + out = { + "exchange": ex, + "status_code": r.status_code, + "payload": payload, + "ok": bool(isinstance(payload, dict) and payload.get("ok")), + } + _schedule_board_refresh() + return out + + +@app.post("/api/orders/{exchange_id}/cancel-symbol") +async def api_cancel_symbol_orders(exchange_id: str, body: CancelSymbolOrdersBody): + ex = _find_exchange(exchange_id) + if not ex or not ex.get("enabled"): + raise HTTPException(status_code=404, detail="账户未启用") + url = f"{ex['agent_url'].rstrip('/')}/orders/cancel-symbol" + async with httpx.AsyncClient() as client: + r = await client.post( + url, + headers=_agent_headers(), + json={"symbol": body.symbol, "scope": body.scope or "all"}, + timeout=120.0, + ) + try: + payload = r.json() + except Exception: + payload = {"raw": (r.text or "")[:2000]} + out = { + "exchange": ex, + "status_code": r.status_code, + "payload": payload, + "ok": bool(isinstance(payload, dict) and payload.get("ok")), + } + _schedule_board_refresh() + return out + + +@app.post("/api/close/{exchange_id}/position") +async def api_close_position(exchange_id: str, body: ClosePositionBody): + ex = _find_exchange(exchange_id) + if not ex or not ex.get("enabled"): + raise HTTPException(status_code=404, detail="账户未启用") + sym = (body.symbol or "").strip() + side = (body.side or "").strip().lower() + if not sym: + raise HTTPException(status_code=400, detail="symbol 不能为空") + if side not in ("long", "short"): + raise HTTPException(status_code=400, detail="side 须为 long 或 short") + url = f"{ex['agent_url'].rstrip('/')}/emergency/close-position" + async with httpx.AsyncClient() as client: + if await _fetch_exchange_intraday_discipline(client, ex): + raise HTTPException(status_code=403, detail=_INTRADAY_CLOSE_BLOCK_MSG) + r = await client.post( + url, + headers=_agent_headers(), + json={"symbol": sym, "side": side}, + timeout=120.0, + ) + try: + payload = r.json() + except Exception: + payload = {"raw": (r.text or "")[:2000]} + out = { + "exchange": ex, + "status_code": r.status_code, + "payload": payload, + "ok": bool(isinstance(payload, dict) and payload.get("ok")), + } + if out.get("ok"): + async with httpx.AsyncClient() as flask_client: + sync_bundle = await _sync_flask_after_position_close( + flask_client, ex, symbol=sym, side=side + ) + out.update(sync_bundle) + risk_sync = await _notify_instance_user_close(flask_client, ex, count=1) + if isinstance(risk_sync, dict): + out["risk_sync"] = risk_sync + _schedule_board_refresh() + return out + + +@app.post("/api/orders/{exchange_id}/place-tpsl") +async def api_place_tpsl(exchange_id: str, body: PlaceTpslBody): + ex = _find_exchange(exchange_id) + if not ex or not ex.get("enabled"): + raise HTTPException(status_code=404, detail="账户未启用") + url = f"{ex['agent_url'].rstrip('/')}/orders/place-tpsl" + async with httpx.AsyncClient() as client: + if await _fetch_exchange_intraday_discipline(client, ex): + raise HTTPException(status_code=403, detail=_INTRADAY_CLOSE_BLOCK_MSG) + r = await client.post( + url, + headers=_agent_headers(), + json={ + "symbol": body.symbol, + "side": body.side, + "stop_loss": body.stop_loss, + "take_profit": body.take_profit, + "contracts": body.contracts, + }, + timeout=120.0, + ) + try: + payload = r.json() + except Exception: + payload = {"raw": (r.text or "")[:2000]} + out = { + "exchange": ex, + "status_code": r.status_code, + "payload": payload, + "ok": bool(isinstance(payload, dict) and payload.get("ok")), + } + if out.get("ok") and (ex.get("flask_url") or "").strip(): + placed = payload.get("placed") if isinstance(payload, dict) else None + sl_sync = body.stop_loss + tp_sync = body.take_profit + if isinstance(placed, dict): + if placed.get("stop_loss") is not None: + sl_sync = placed["stop_loss"] + if placed.get("take_profit") is not None: + tp_sync = placed["take_profit"] + async with httpx.AsyncClient() as flask_client: + sync_parsed = await _fetch_flask_json( + flask_client, + ex, + "/api/hub/order/sync-tpsl", + method="POST", + json_body={ + "symbol": body.symbol, + "side": body.side, + "stop_loss": sl_sync, + "take_profit": tp_sync, + }, + ) + if isinstance(sync_parsed, dict): + out["order_sync"] = sync_parsed + _schedule_board_refresh() + return out + + +@app.post("/api/close/{exchange_id}") +async def api_close_exchange(exchange_id: str): + ex = _find_exchange(exchange_id) + if not ex or not ex.get("enabled"): + raise HTTPException(status_code=404, detail="账户未启用") + url = f"{ex['agent_url'].rstrip('/')}/emergency/close-all" + async with httpx.AsyncClient() as client: + if await _fetch_exchange_intraday_discipline(client, ex): + raise HTTPException(status_code=403, detail=_INTRADAY_CLOSE_BLOCK_MSG) + r = await client.post(url, headers=_agent_headers(), timeout=120.0) + try: + body = r.json() + except Exception: + body = {"raw": (r.text or "")[:2000]} + ok = bool(isinstance(body, dict) and body.get("ok")) + out = {"exchange": ex, "status_code": r.status_code, "payload": body, "ok": ok} + if ok and isinstance(body, dict): + closed = body.get("closed") or [] + n = len(closed) if isinstance(closed, list) else 0 + if n > 0: + async with httpx.AsyncClient() as flask_client: + for item in closed: + if not isinstance(item, dict): + continue + sym_i = (item.get("symbol") or "").strip() + side_i = (item.get("side") or "").strip().lower() + if sym_i and side_i in ("long", "short"): + await _sync_flask_after_position_close( + flask_client, ex, symbol=sym_i, side=side_i + ) + risk_sync = await _notify_instance_user_close(flask_client, ex, count=n) + if isinstance(risk_sync, dict): + out["risk_sync"] = risk_sync + _schedule_board_refresh() + return out + + +@app.post("/api/close-all") +async def api_close_all(body: CloseAllBody | None = Body(default=None)): + excl = set(body.exclude_ids if body else []) + excl |= env_force_disabled_ids() + targets = [x for x in enabled_exchanges() if str(x["id"]) not in excl] + async with httpx.AsyncClient() as client: + + async def one(ex: dict): + if await _fetch_exchange_intraday_discipline(client, ex): + return { + "id": ex["id"], + "name": ex["name"], + "skipped": True, + "reason": _INTRADAY_CLOSE_BLOCK_MSG, + } + url = f"{ex['agent_url'].rstrip('/')}/emergency/close-all" + try: + r = await client.post(url, headers=_agent_headers(), timeout=120.0) + try: + payload = r.json() + except Exception: + payload = {"raw": (r.text or "")[:2000]} + row = {"id": ex["id"], "name": ex["name"], "status_code": r.status_code, "payload": payload} + if isinstance(payload, dict) and payload.get("ok"): + closed = payload.get("closed") or [] + n = len(closed) if isinstance(closed, list) else 0 + if n > 0: + for item in closed: + if not isinstance(item, dict): + continue + sym_i = (item.get("symbol") or "").strip() + side_i = (item.get("side") or "").strip().lower() + if sym_i and side_i in ("long", "short"): + sync_bundle = await _sync_flask_after_position_close( + client, ex, symbol=sym_i, side=side_i + ) + if sync_bundle: + row["flask_sync"] = sync_bundle + risk_sync = await _notify_instance_user_close(client, ex, count=n) + if isinstance(risk_sync, dict): + row["risk_sync"] = risk_sync + return row + except Exception as e: + return {"id": ex["id"], "name": ex["name"], "status_code": None, "error": str(e)} + + results = await asyncio.gather(*[one(ex) for ex in targets]) + _schedule_board_refresh() + return {"results": list(results)} + + +def _trade_removed_response(): + """旧版前端或缓存页面仍会请求 /api/trade/*,勿解析表单,直接返回说明.""" + return JSONResponse( + { + "ok": False, + "result": { + "ok": False, + "messages": [ + "中控已移除下单区.请在监控卡片点击「实例」," + "进入对应 crypto_monitor_* 网页添加关键位或下单." + ], + }, + "deprecated": True, + }, + status_code=410, + ) + + +def _parse_anchor_ms(at: str = "", anchor_ms: str = "") -> int | None: + raw = (anchor_ms or at or "").strip() + if not raw: + return None + return parse_wall_clock_ms(raw) + + +@app.get("/api/archive/meta") +def api_archive_meta(): + init_archive_db() + exchanges = [] + for ex in enabled_exchanges(load_settings()): + exchanges.append( + { + "id": ex.get("id"), + "key": ex.get("key"), + "name": ex.get("name"), + } + ) + return { + "ok": True, + "timeframes": sorted(ARCHIVE_TIMEFRAMES), + "default_timeframe": ARCHIVE_DEFAULT_TIMEFRAME, + "seed_lookback_days": ARCHIVE_SEED_LOOKBACK_DAYS, + "sync_interval_sec": ARCHIVE_SYNC_INTERVAL_SEC, + "visible_bars_default": ARCHIVE_VISIBLE_BARS_DEFAULT, + "exchanges": exchanges, + "last_sync": _last_archive_sync, + } + + +@app.get("/api/archive/list") +def api_archive_list( + exchange_key: str = "", + filter_profit: str = "", + filter_loss: str = "", + filter_sick: str = "", + filter_emotion: str = "", +): + init_archive_db() + rows = list_symbol_rows( + exchange_key=exchange_key, + filter_profit=(filter_profit or "").lower() in ("1", "true", "yes", "on"), + filter_loss=(filter_loss or "").lower() in ("1", "true", "yes", "on"), + filter_sick=(filter_sick or "").lower() in ("1", "true", "yes", "on"), + filter_emotion=(filter_emotion or "").lower() in ("1", "true", "yes", "on"), + ) + return {"ok": True, "rows": rows, "count": len(rows)} + + +@app.get("/api/archive/daily-trades") +def api_archive_daily_trades( + period: str = "", + trading_day: str = "", + date_from: str = "", + date_to: str = "", + exchange_key: str = "", + filter_profit: str = "", + filter_loss: str = "", + filter_sick: str = "", + search: str = "", +): + init_archive_db() + payload = list_daily_trades( + trading_day=trading_day, + period=period or "today", + date_from=date_from, + date_to=date_to, + exchange_key=exchange_key, + filter_profit=(filter_profit or "").lower() in ("1", "true", "yes", "on"), + filter_loss=(filter_loss or "").lower() in ("1", "true", "yes", "on"), + filter_sick=(filter_sick or "").lower() in ("1", "true", "yes", "on"), + search=search, + ) + return {"ok": True, **payload} + + +@app.get("/api/archive/calendar") +def api_archive_calendar( + year: int = 0, + month: int = 0, + exchange_key: str = "", +): + init_archive_db() + if year <= 0 or month <= 0: + td = today_trading_day() + parts = td.split("-") + year = int(parts[0]) + month = int(parts[1]) + try: + payload = list_archive_calendar(year, month, exchange_key=exchange_key) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + return {"ok": True, **payload} + + +@app.get("/api/archive/quotes") +def api_archive_quotes(): + init_archive_db() + rows = list_review_quotes() + return {"ok": True, "quotes": rows, "count": len(rows), "max": ARCHIVE_QUOTES_MAX} + + +class ArchiveQuoteBody(BaseModel): + quote_date: str = "" + content: str = "" + + +@app.post("/api/archive/quotes") +def api_archive_quote_create(body: ArchiveQuoteBody = Body(...)): + init_archive_db() + try: + row = create_review_quote(body.quote_date, body.content) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + return {"ok": True, "quote": row} + + +@app.patch("/api/archive/quotes/{quote_id}") +def api_archive_quote_update(quote_id: int, body: ArchiveQuoteBody = Body(...)): + init_archive_db() + try: + row = update_review_quote( + int(quote_id), + quote_date=body.quote_date or None, + content=body.content if body.content is not None else None, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + if not row: + raise HTTPException(status_code=404, detail="语录不存在") + return {"ok": True, "quote": row} + + +@app.delete("/api/archive/quotes/{quote_id}") +def api_archive_quote_delete(quote_id: int): + init_archive_db() + if not delete_review_quote(int(quote_id)): + raise HTTPException(status_code=404, detail="语录不存在") + return {"ok": True, "id": int(quote_id)} + + +class MacroEventBody(BaseModel): + event_type: str = "" + event_at: str = "" + note: str = "" + + +@app.get("/api/macro-calendar/meta") +def api_macro_calendar_meta(): + init_macro_calendar_db() + return { + "ok": True, + "event_types": [ + {"id": k, "label": MACRO_EVENT_LABELS[k]} for k in MACRO_EVENT_TYPES + ], + "window_before_minutes": 60, + "window_after_minutes": 60, + "timezone": "Asia/Shanghai", + } + + +@app.get("/api/macro-calendar/events") +def api_macro_calendar_events(): + init_macro_calendar_db() + rows = list_macro_events() + return {"ok": True, "events": rows, "count": len(rows)} + + +@app.get("/api/macro-calendar/active") +def api_macro_calendar_active(): + init_macro_calendar_db() + alerts = list_active_alerts() + return {"ok": True, "alerts": alerts, "count": len(alerts)} + + +@app.post("/api/macro-calendar/events") +def api_macro_calendar_create(body: MacroEventBody = Body(...)): + init_macro_calendar_db() + try: + row = create_macro_event(body.event_type, body.event_at, note=body.note) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + return {"ok": True, "event": row} + + +@app.patch("/api/macro-calendar/events/{event_id}") +def api_macro_calendar_update(event_id: int, body: MacroEventBody = Body(...)): + init_macro_calendar_db() + try: + row = update_macro_event( + int(event_id), + event_type=body.event_type or None, + event_at=body.event_at or None, + note=body.note, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + if not row: + raise HTTPException(status_code=404, detail="记录不存在") + return {"ok": True, "event": row} + + +@app.delete("/api/macro-calendar/events/{event_id}") +def api_macro_calendar_delete(event_id: int): + init_macro_calendar_db() + if not delete_macro_event(int(event_id)): + raise HTTPException(status_code=404, detail="记录不存在") + return {"ok": True, "id": int(event_id)} + + +@app.get("/api/archive/detail") +def api_archive_detail(exchange_key: str = "", symbol: str = ""): + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + if not ex_k or not sym: + raise HTTPException(status_code=400, detail="缺少 exchange_key 或 symbol") + init_archive_db() + trades = load_symbol_trades(ex_k, sym) + return {"ok": True, "exchange_key": ex_k, "symbol": sym, "trades": trades} + + +@app.get("/api/archive/ohlcv") +def api_archive_ohlcv( + exchange_key: str = "", + symbol: str = "", + timeframe: str = ARCHIVE_DEFAULT_TIMEFRAME, + mode: str = "hold", + anchor_ms: str = "", + opened_ms: str = "", + closed_ms: str = "", + range: str = "", + at: str = "", + bars: str = "", +): + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + if not ex_k or not sym: + raise HTTPException(status_code=400, detail="缺少 exchange_key 或 symbol") + init_archive_db() + anchor = _parse_anchor_ms(at, anchor_ms) + open_ms = _parse_anchor_ms("", opened_ms) + close_ms = _parse_anchor_ms("", closed_ms) + try: + bar_n = int(bars) if (bars or "").strip().isdigit() else ARCHIVE_VISIBLE_BARS_DEFAULT + except ValueError: + bar_n = ARCHIVE_VISIBLE_BARS_DEFAULT + result = resolve_archive_chart( + ex_k, + sym, + timeframe, + anchor_ms=anchor, + opened_ms=open_ms, + closed_ms=close_ms, + mode=mode, + bars=bar_n, + range_mode=(range or "").strip().lower() or "window", + ) + if not result.get("ok"): + raise HTTPException(status_code=404, detail=result.get("msg") or "无 K 线") + return result + + +class ArchiveOverlayBody(BaseModel): + behavior_tag: str = "" + note: str = "" + + +@app.patch("/api/archive/trade/{exchange_key}/{trade_id}") +def api_archive_trade_overlay( + exchange_key: str, + trade_id: int, + body: ArchiveOverlayBody = Body(...), +): + ex_k = (exchange_key or "").strip().lower() + if not ex_k: + raise HTTPException(status_code=400, detail="缺少 exchange_key") + init_archive_db() + out = upsert_trade_overlay( + ex_k, + int(trade_id), + behavior_tag=body.behavior_tag, + note=body.note, + ) + return {"ok": True, "overlay": out} + + +@app.delete("/api/archive/trade/{exchange_key}/{trade_id}") +def api_archive_trade_delete(exchange_key: str, trade_id: int): + from lib.hub.hub_symbol_archive_lib import delete_trade_from_archive + + ex_k = (exchange_key or "").strip().lower() + if not ex_k: + raise HTTPException(status_code=400, detail="缺少 exchange_key") + init_archive_db() + removed = delete_trade_from_archive(ex_k, int(trade_id)) + if not removed: + raise HTTPException(status_code=404, detail="档案中无该笔交易") + return {"ok": True, "exchange_key": ex_k, "trade_id": int(trade_id)} + + +@app.post("/api/archive/sync") +async def api_archive_sync(): + body = await _run_archive_sync_once() + return body + + +@app.get("/api/strategy/meta") +def api_strategy_meta(): + return strategy_meta_payload() + + +@app.get("/api/help/meta") +def api_help_meta(): + return help_meta_payload() + + +@app.get("/api/help/{section_key}") +def api_help_section(section_key: str): + try: + return load_help_payload(section_key.strip().lower()) + except KeyError: + return JSONResponse({"ok": False, "msg": "unknown section"}, status_code=404) + + +@app.get("/api/system-logs/meta") +def api_system_logs_meta(): + return system_logs_meta() + + +@app.get("/api/system-logs/{target}") +def api_system_logs(target: str, lines: int = 200): + try: + return load_system_logs(target, lines=lines) + except KeyError: + return JSONResponse({"ok": False, "msg": "unknown log target"}, status_code=404) + + +@app.get("/api/strategy/{exchange_key}") +def api_strategy_detail(exchange_key: str): + try: + return load_strategy_payload(exchange_key.strip().lower()) + except KeyError: + return JSONResponse({"ok": False, "msg": "unknown exchange"}, status_code=404) + + +@app.get("/api/strategy/{exchange_key}/export") +def api_strategy_export(exchange_key: str): + key = exchange_key.strip().lower() + try: + html = build_export_html(key) + except KeyError: + return JSONResponse({"ok": False, "msg": "unknown exchange"}, status_code=404) + filename = f"strategy-{key}.html" + return HTMLResponse( + content=html, + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@app.get("/api/strategy/{exchange_key}/print") +def api_strategy_print(exchange_key: str, part: str = "doc"): + key = exchange_key.strip().lower() + p = (part or "doc").strip().lower() + if p not in ("doc", "checklist"): + return JSONResponse({"ok": False, "msg": "part must be doc or checklist"}, status_code=400) + try: + html = build_print_html(key, p) + except KeyError: + return JSONResponse({"ok": False, "msg": "unknown exchange or part"}, status_code=404) + return HTMLResponse(content=html) + + +@app.get("/api/entry-plans/meta") +def api_entry_plans_meta(): + init_entry_plan_db() + exchanges = [] + for ex in enabled_exchanges(load_settings()): + exchanges.append( + { + "id": ex.get("id"), + "key": ex.get("key"), + "name": ex.get("name"), + } + ) + return {"ok": True, **entry_plan_meta_payload(exchanges)} + + +@app.get("/api/entry-plans") +def api_entry_plans_list(status: str = "active"): + init_entry_plan_db() + try: + rows = list_entry_plans(status=status) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + return {"ok": True, "plans": rows, "count": len(rows), "status": status.strip().lower()} + + +@app.get("/api/entry-plans/stats") +def api_entry_plan_stats( + dimension: str = "symbol", + period: str = "all", + date_from: str = "", + date_to: str = "", +): + init_entry_plan_db() + try: + stats = compute_entry_plan_stats( + dimension=dimension, + period=period, + date_from=date_from, + date_to=date_to, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + return {"ok": True, "stats": stats} + + +@app.get("/api/entry-plans/{plan_id}") +def api_entry_plan_detail(plan_id: int): + init_entry_plan_db() + row = get_entry_plan(int(plan_id)) + if not row: + raise HTTPException(status_code=404, detail="计划不存在") + return {"ok": True, "plan": row} + + +class EntryPlanBody(BaseModel): + plan_date: str = "" + exchange_key: str = "" + symbol: str = "" + plan_type: str = "" + trend_timeframe: str = "" + entry_timeframe: str = "" + direction: str = "" + target_level: str = "" + current_range: str = "" + entry_scheme: str = "" + result: str | None = None + pnl_amount: float | None = None + note: str = "" + + +@app.post("/api/entry-plans") +def api_entry_plan_create(body: EntryPlanBody = Body(...)): + init_entry_plan_db() + try: + row = create_entry_plan(body.model_dump(exclude_unset=True)) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + return {"ok": True, "plan": row} + + +@app.patch("/api/entry-plans/{plan_id}") +def api_entry_plan_update(plan_id: int, body: EntryPlanBody = Body(...)): + init_entry_plan_db() + payload = body.model_dump(exclude_unset=True) + if not payload: + raise HTTPException(status_code=400, detail="无更新字段") + try: + row = update_entry_plan(int(plan_id), payload) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + if not row: + raise HTTPException(status_code=404, detail="计划不存在") + return {"ok": True, "plan": row} + + +@app.delete("/api/entry-plans/{plan_id}") +def api_entry_plan_delete(plan_id: int): + init_entry_plan_db() + try: + ok = delete_entry_plan(int(plan_id)) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + if not ok: + raise HTTPException(status_code=404, detail="计划不存在或已归档") + return {"ok": True, "id": int(plan_id)} + + +@app.get("/api/hub/fund-overview") +def api_hub_fund_overview(): + from lib.hub.hub_fund_history_lib import build_fund_overview + from hub_ai.config import trading_day_reset_hour + + settings = load_settings() + snap = board_store.snapshot_dict() + payload = build_fund_overview( + enabled_exchanges(settings), + board_rows=snap.get("rows") or [], + reset_hour=trading_day_reset_hour(), + updated_at=snap.get("updated_at"), + ) + return payload + + +@app.get("/api/ping") +def api_ping(): + return { + "ok": True, + "service": "manual-trading-hub", + "build": HUB_BUILD, + "trade_ui": False, + "features": ["monitor", "settings", "auth", "board_sse", "dashboard_sse", "archive", "dashboard", "funds", "macro_calendar"], + "board_poll_interval_sec": HUB_BOARD_POLL_INTERVAL, + "board_version": board_store.version, + "board_aggregating": board_store.aggregating, + "board_updated_at": (board_store.payload or {}).get("updated_at") + if isinstance(board_store.payload, dict) + else None, + "board_error": board_store.last_error, + "dashboard_poll_interval_sec": DASHBOARD_POLL_INTERVAL_SEC, + "dashboard_version": dashboard_store.version, + "dashboard_aggregating": dashboard_store.aggregating, + "dashboard_updated_at": (dashboard_store.payload or {}).get("updated_at") + if isinstance(dashboard_store.payload, dict) + else None, + "dashboard_error": dashboard_store.last_error, + "password_required": password_required(), + "env_disabled_ids": sorted(env_force_disabled_ids()), + "hub_disabled_ids_raw": (os.getenv("HUB_DISABLED_IDS") or ""), + } + + +@app.post("/api/trade/order/{exchange_id}") +@app.post("/api/trade/key/{exchange_id}") +@app.post("/api/trade/trend/preview/{exchange_id}") +@app.post("/api/trade/trend/execute/{exchange_id}") +async def api_trade_removed(exchange_id: str): + return _trade_removed_response() + + +@app.get("/api/trade/meta/{exchange_id}") +@app.get("/api/trade/trend/preview/{exchange_id}/{preview_id}") +async def api_trade_removed_get(exchange_id: str, preview_id: str = ""): + return _trade_removed_response() + + +def main(): + import uvicorn + + print( + f"manual-trading-hub start build={HUB_BUILD} listen={HUB_HOST}:{HUB_PORT}", + flush=True, + ) + uvicorn.run(app, host=HUB_HOST, port=HUB_PORT, log_level="info", access_log=False) + + +if __name__ == "__main__": + main() diff --git a/manual_trading_hub/hub_ai/__init__.py b/manual_trading_hub/hub_ai/__init__.py new file mode 100644 index 0000000..720dbf3 --- /dev/null +++ b/manual_trading_hub/hub_ai/__init__.py @@ -0,0 +1 @@ +"""中控 AI 模块:今日总结 + 交易员聊天(与实例 ai_review 分离).""" diff --git a/manual_trading_hub/hub_ai/archive_quote.py b/manual_trading_hub/hub_ai/archive_quote.py new file mode 100644 index 0000000..b3c6718 --- /dev/null +++ b/manual_trading_hub/hub_ai/archive_quote.py @@ -0,0 +1,161 @@ +"""内照明心复盘语录 → 交易教练点评.""" +from __future__ import annotations + +from typing import Any + +from hub_ai.client import generate_text, model_label +from hub_ai.rolling_summary import refresh_session_rolling_summary +from hub_ai.text_util import clip_text, is_ai_error_reply +from hub_ai.config import ( + CHAT_MAX_CONTINUATIONS, + CHAT_MAX_OUTPUT_TOKENS, + CHAT_TEMPERATURE, + CHAT_USER_MESSAGE_MAX_CHARS, +) +from hub_ai.prompts import CHAT_SYSTEM, build_archive_quote_review_prompt +from hub_ai.store import ( + CHAT_BOT_TRADING, + append_chat_message, + create_new_session, + delete_chat_session, + get_active_session, + list_chat_sessions, +) +from lib.hub.hub_symbol_archive_lib import list_daily_trades + + +def _tag_label(tag: str) -> str: + t = (tag or "").strip().lower() + if t == "sick": + return "犯病" + if t == "emotion": + return "情绪化" + return t or "—" + + +def _fmt_pnl(v: Any) -> str: + try: + n = float(v or 0) + except (TypeError, ValueError): + return "—" + sign = "+" if n > 0 else "" + return f"{sign}{n:.2f}U" + + +def _fmt_pct(v: Any) -> str: + try: + n = float(v) + except (TypeError, ValueError): + return "—" + return f"{n:.1f}%" + + +def _fmt_rr(v: Any) -> str: + try: + n = float(v) + except (TypeError, ValueError): + return "—" + return f"{n:.2f}:1" + + +def format_archive_trades_for_ai(payload: dict[str, Any]) -> str: + trades = payload.get("trades") or [] + stats = payload.get("stats") or {} + lines = [ + ( + f"统计:开仓 {int(stats.get('open_count') or 0)} 笔," + f"盈利 {int(stats.get('win_count') or 0)} / 亏损 {int(stats.get('loss_count') or 0)}," + f"平均盈利 {_fmt_pnl(stats.get('avg_win'))},平均亏损 {_fmt_pnl(stats.get('avg_loss'))}," + f"胜率 {_fmt_pct(stats.get('win_rate'))},盈亏比 {_fmt_rr(stats.get('profit_loss_ratio'))}," + f"最大盈利 {_fmt_pnl(stats.get('max_win'))},最大亏损 {_fmt_pnl(stats.get('max_loss'))}," + f"犯病 {int(stats.get('sick_count') or 0)} 笔," + f"盈亏合计 {_fmt_pnl(stats.get('pnl_total'))}," + f"剔除犯病盈亏 {_fmt_pnl(stats.get('pnl_ex_sick'))}" + ) + ] + if not trades: + lines.append("(该日无交易记录)") + return "\n".join(lines) + max_rows = 50 + if len(trades) > max_rows: + lines.append(f"(共 {len(trades)} 笔,以下展示最近 {max_rows} 笔)") + for i, t in enumerate(trades[:max_rows], 1): + ex = str(t.get("exchange_key") or t.get("account_exchange_key") or "—") + sym = str(t.get("symbol") or "—") + direction = str(t.get("direction") or "—") + opened = str(t.get("opened_at") or "—") + closed = str(t.get("closed_at") or "—") + hold = str(t.get("hold_minutes_text") or t.get("hold_minutes") or "—") + result = str(t.get("result") or "—") + pnl = _fmt_pnl(t.get("pnl_amount")) + entry = str(t.get("entry_type") or t.get("entry_reason") or t.get("monitor_type") or "—") + tag = _tag_label(str(t.get("behavior_tag") or "")) + note = clip_text(str(t.get("note") or "").strip(), 80) + line = ( + f"{i}. {ex} | {sym} | {direction} | 开仓类型 {entry} | " + f"开 {opened} | 平 {closed} | 持仓 {hold} | 结果 {result} | " + f"盈亏 {pnl} | 标签 {tag}" + ) + if note: + line += f" | 备注 {note}" + lines.append(line) + return "\n".join(lines) + + +def send_archive_quote_review( + *, + quote_date: str, + content: str, +) -> dict[str, Any]: + text = (content or "").strip() + if not text: + return {"ok": False, "msg": "语录内容不能为空"} + day = (quote_date or "").strip()[:10] + if not day: + return {"ok": False, "msg": "语录日期无效"} + + session = create_new_session( + trading_day=day, + title=f"复盘 {day}", + bot_mode=CHAT_BOT_TRADING, + ) + sid = session["id"] + + archive_payload = list_daily_trades(trading_day=day, period="today") + archive_trades_text = format_archive_trades_for_ai(archive_payload) + user_for_prompt = clip_text(text, CHAT_USER_MESSAGE_MAX_CHARS) + + user_prompt = build_archive_quote_review_prompt( + quote_date=day, + archive_trades_text=archive_trades_text, + user_message=user_for_prompt, + ) + reply = generate_text( + system=CHAT_SYSTEM, + user=user_prompt, + temperature=CHAT_TEMPERATURE, + max_tokens=CHAT_MAX_OUTPUT_TOKENS, + max_continuations=CHAT_MAX_CONTINUATIONS, + ) + if is_ai_error_reply(reply): + delete_chat_session(sid) + return {"ok": False, "msg": reply} + + append_chat_message(sid, "user", text) + session = append_chat_message(sid, "assistant", reply) + refresh_session_rolling_summary( + sid, + prior_summary="", + user_text=text, + assistant_text=reply, + bot_mode=CHAT_BOT_TRADING, + ) + session = get_active_session() or session + return { + "ok": True, + "trading_day": day, + "session": session, + "sessions": list_chat_sessions(), + "reply": reply, + "model": model_label(), + } diff --git a/manual_trading_hub/hub_ai/attachments.py b/manual_trading_hub/hub_ai/attachments.py new file mode 100644 index 0000000..67dba0a --- /dev/null +++ b/manual_trading_hub/hub_ai/attachments.py @@ -0,0 +1,101 @@ +"""中控 AI 聊天附件解析.""" +from __future__ import annotations + +import base64 +from typing import Any + +from hub_ai.config import ( + CHAT_MAX_ATTACHMENTS, + CHAT_MAX_IMAGE_BYTES, + CHAT_MAX_TEXT_FILE_BYTES, +) + +IMAGE_MIMES = { + "image/jpeg", + "image/jpg", + "image/png", + "image/webp", + "image/gif", +} +TEXT_MIMES = { + "text/plain", + "text/markdown", + "application/json", +} + + +def _guess_mime(filename: str, content_type: str) -> str: + ct = (content_type or "").split(";")[0].strip().lower() + if ct: + return ct + name = (filename or "").lower() + if name.endswith(".png"): + return "image/png" + if name.endswith((".jpg", ".jpeg")): + return "image/jpeg" + if name.endswith(".webp"): + return "image/webp" + if name.endswith(".gif"): + return "image/gif" + if name.endswith((".md", ".markdown")): + return "text/markdown" + if name.endswith(".txt"): + return "text/plain" + if name.endswith(".json"): + return "application/json" + return "application/octet-stream" + + +def parse_chat_attachments(raw_files: list[dict[str, Any]]) -> dict[str, Any]: + """ + raw_files: [{filename, content_type, data: bytes}] + 返回 images_b64, attachment_note, attachment_meta, text_append + """ + images_b64: list[str] = [] + meta: list[dict] = [] + notes: list[str] = [] + text_blocks: list[str] = [] + errors: list[str] = [] + + for item in (raw_files or [])[:CHAT_MAX_ATTACHMENTS]: + name = str(item.get("filename") or "file") + data = item.get("data") or b"" + if not isinstance(data, (bytes, bytearray)): + errors.append(f"{name}: 无效数据") + continue + mime = _guess_mime(name, str(item.get("content_type") or "")) + size = len(data) + if mime in IMAGE_MIMES: + if size > CHAT_MAX_IMAGE_BYTES: + errors.append(f"{name}: 图片超过 {CHAT_MAX_IMAGE_BYTES // 1024 // 1024}MB") + continue + images_b64.append(base64.b64encode(bytes(data)).decode("ascii")) + meta.append({"name": name, "kind": "image", "mime": mime, "size": size}) + notes.append(f"图片 {name}") + continue + if mime in TEXT_MIMES or name.lower().endswith((".txt", ".md", ".markdown", ".json")): + if size > CHAT_MAX_TEXT_FILE_BYTES: + errors.append(f"{name}: 文本超过 {CHAT_MAX_TEXT_FILE_BYTES // 1024}KB") + continue + try: + text = bytes(data).decode("utf-8") + except UnicodeDecodeError: + errors.append(f"{name}: 非 UTF-8 文本") + continue + text_blocks.append(f"--- 附件 {name} ---\n{text.strip()}") + meta.append({"name": name, "kind": "text", "mime": mime, "size": size}) + notes.append(f"文档 {name}") + continue + errors.append(f"{name}: 不支持的类型(仅图片或 txt/md/json)") + + attachment_note = ";".join(notes) if notes else "" + if errors: + attachment_note = (attachment_note + ";" if attachment_note else "") + ";".join(errors) + text_append = "\n\n".join(text_blocks) + return { + "images_b64": images_b64, + "attachment_note": attachment_note, + "attachment_meta": meta, + "text_append": text_append, + "errors": errors, + } diff --git a/manual_trading_hub/hub_ai/chat.py b/manual_trading_hub/hub_ai/chat.py new file mode 100644 index 0000000..a49a213 --- /dev/null +++ b/manual_trading_hub/hub_ai/chat.py @@ -0,0 +1,275 @@ +"""中控 AI:单会话聊天(直到用户点击新开).""" +from __future__ import annotations + +import threading +from typing import Any, Optional + +from hub_ai.attachments import parse_chat_attachments +from hub_ai.client import generate_text, model_label +from hub_ai.config import ( + CHAT_CONTEXT_MAX_CHARS, + CHAT_FOLLOWUP_CONTEXT_MAX_CHARS, + CHAT_HISTORY_MAX_CHARS_PER_MSG, + CHAT_MAX_CONTINUATIONS, + CHAT_MAX_HISTORY_TURNS, + CHAT_MAX_OUTPUT_TOKENS, + CHAT_PROMPT_MAX_CHARS, + CHAT_SUMMARY_EXCERPT_MAX_CHARS, + CHAT_TEMPERATURE, + CHAT_USER_MESSAGE_MAX_CHARS, + trading_day_reset_hour, +) +from lib.hub.hub_trades_lib import current_trading_day +from hub_ai.context import ( + build_chat_context, + format_chat_context_for_chat, + format_chat_position_overview, +) +from hub_ai.prompts import ( + CHAT_GENERAL_SYSTEM, + CHAT_SYSTEM, + build_chat_user_prompt, + build_general_chat_user_prompt, +) +from hub_ai.rolling_summary import refresh_session_rolling_summary +from hub_ai.store import ( + CHAT_BOT_GENERAL, + CHAT_BOT_TRADING, + append_chat_message, + create_new_session, + delete_chat_session, + ensure_active_session, + get_active_session, + list_chat_sessions, + load_chat_store, + set_active_session, + summary_excerpt_for_chat, +) +from hub_ai.text_util import clip_text, is_ai_error_reply + + +def _is_ai_error_reply(text: str) -> bool: + return is_ai_error_reply(text) + + +def _clip_text(text: str, max_chars: int) -> str: + return clip_text(text, max_chars) + + +def _history_lines( + messages: list[dict], + max_turns: int = CHAT_MAX_HISTORY_TURNS, + *, + max_chars_per_msg: int = CHAT_HISTORY_MAX_CHARS_PER_MSG, + total_max_chars: int | None = None, +) -> str: + rows = [m for m in (messages or []) if m.get("role") in ("user", "assistant")] + rows = rows[-max_turns * 2 :] + lines = [] + for m in rows: + role = "用户" if m.get("role") == "user" else "搭档" + content = str(m.get("content") or "").strip() + if m.get("role") == "assistant" and _is_ai_error_reply(content): + continue + att = m.get("attachments") or [] + if att: + names = ",".join(str(a.get("name") or "附件") for a in att[:3]) + content = f"{content} [附件: {names}]".strip() + content = _clip_text(content, max_chars_per_msg) + if content: + lines.append(f"{role}:{content}") + if total_max_chars and total_max_chars > 0: + while lines and len("\n".join(lines)) > total_max_chars: + lines.pop(0) + return "\n".join(lines) + + +def _trading_context_bundle(ctx: dict[str, Any], *, prior_count: int) -> tuple[str, str]: + day = str(ctx.get("trading_day") or (ctx.get("totals") or {}).get("trading_day") or "") + if prior_count <= 0: + brief = format_chat_context_for_chat(ctx, max_chars=CHAT_CONTEXT_MAX_CHARS) + excerpt = summary_excerpt_for_chat(day, max_chars=CHAT_SUMMARY_EXCERPT_MAX_CHARS) + return brief, excerpt + totals = ctx.get("totals") or {} + overview = format_chat_position_overview(ctx) + slim = ( + f"【续聊快照 {day}】平仓盈亏 {totals.get('total_pnl_u')}U | " + f"笔数 {totals.get('closed_count')} | " + f"持仓 {totals.get('open_position_count', 0)} 仓 | " + f"浮盈亏 {totals.get('float_pnl_u')}U" + ) + brief = _clip_text(overview + "\n" + slim, CHAT_FOLLOWUP_CONTEXT_MAX_CHARS) + return brief, "" + + +def _history_budget(*sizes: int) -> int: + used = sum(int(s or 0) for s in sizes) + 2200 + return max(1200, CHAT_PROMPT_MAX_CHARS - used) + + +def _prompt_memory(session: dict, prior_msgs: list[dict]) -> tuple[str, str]: + """续聊优先用滚动摘要;旧会话无摘要时仅带最近 1 轮兜底.""" + rolling = str(session.get("rolling_summary") or "").strip() + if rolling: + return rolling, "" + prior_count = len([m for m in prior_msgs if m.get("role") in ("user", "assistant")]) + if prior_count <= 0: + return "", "" + tail = _history_lines( + prior_msgs, + max_turns=1, + max_chars_per_msg=CHAT_HISTORY_MAX_CHARS_PER_MSG, + ) + return "", tail + + +def get_chat_state() -> dict[str, Any]: + store = load_chat_store() + session = get_active_session() + if session: + session.setdefault("bot_mode", CHAT_BOT_TRADING) + session.setdefault("rolling_summary", "") + return { + "active_session_id": store.get("active_session_id"), + "session": session, + "sessions": list_chat_sessions(), + "model": model_label(), + } + + +def start_new_chat(*, trading_day: str, bot_mode: str = CHAT_BOT_TRADING) -> dict: + session = create_new_session(trading_day=trading_day, bot_mode=bot_mode) + return { + "ok": True, + "session": session, + "sessions": list_chat_sessions(), + "model": model_label(), + } + + +def switch_chat_session(session_id: str) -> dict[str, Any]: + session = set_active_session(session_id) + return { + "ok": True, + "session": session, + "sessions": list_chat_sessions(), + "model": model_label(), + } + + +def remove_chat_session(session_id: str) -> dict[str, Any]: + deleted, new_active = delete_chat_session(session_id) + if not deleted: + return {"ok": False, "msg": "session_not_found"} + session = get_active_session() + return { + "ok": True, + "active_session_id": new_active, + "session": session, + "sessions": list_chat_sessions(), + "model": model_label(), + } + + +def send_chat_message( + exchanges: list[dict], + message: str, + *, + trading_day: str | None = None, + raw_attachments: Optional[list[dict]] = None, +) -> dict[str, Any]: + text = (message or "").strip() + parsed = parse_chat_attachments(raw_attachments or []) + if parsed.get("errors") and not text and not parsed.get("images_b64"): + return {"ok": False, "msg": ";".join(parsed["errors"])} + if not text and not parsed.get("images_b64") and not parsed.get("text_append"): + return {"ok": False, "msg": "消息不能为空"} + + user_visible = text + if parsed.get("text_append"): + user_visible = (user_visible + "\n\n" + parsed["text_append"]).strip() + if not user_visible and parsed.get("attachment_note"): + user_visible = f"(上传了 {parsed['attachment_note']})" + + day = (trading_day or "").strip()[:10] or current_trading_day( + reset_hour=trading_day_reset_hour() + ) + session = ensure_active_session(trading_day=day) + sid = session["id"] + prior_rolling = str(session.get("rolling_summary") or "") + prior_msgs = session.get("messages") or [] + prior_count = len([m for m in prior_msgs if m.get("role") in ("user", "assistant")]) + user_for_prompt = _clip_text(text or user_visible, CHAT_USER_MESSAGE_MAX_CHARS) + rolling_summary, history_tail = _prompt_memory(session, prior_msgs) + + bot_mode = (session.get("bot_mode") or CHAT_BOT_TRADING).strip().lower() + if bot_mode == CHAT_BOT_GENERAL: + user_prompt = build_general_chat_user_prompt( + rolling_summary=rolling_summary, + history_lines=history_tail, + user_message=user_for_prompt, + attachment_note=str(parsed.get("attachment_note") or ""), + ) + if parsed.get("text_append"): + user_prompt += "\n\n【附件正文】\n" + _clip_text(parsed["text_append"], 3000) + system_prompt = CHAT_GENERAL_SYSTEM + else: + ctx = build_chat_context(exchanges, trading_day=day) + day = ctx["trading_day"] + brief_ctx, excerpt = _trading_context_bundle(ctx, prior_count=prior_count) + user_prompt = build_chat_user_prompt( + context_text=brief_ctx, + trading_day=day, + summary_excerpt=excerpt, + rolling_summary=rolling_summary, + history_lines=history_tail, + user_message=user_for_prompt, + attachment_note=str(parsed.get("attachment_note") or ""), + ) + if parsed.get("text_append"): + user_prompt += "\n\n【附件正文】\n" + _clip_text(parsed["text_append"], 3000) + system_prompt = CHAT_SYSTEM + + reply = generate_text( + system=system_prompt, + user=user_prompt, + temperature=CHAT_TEMPERATURE, + images_b64=parsed.get("images_b64") or None, + max_tokens=CHAT_MAX_OUTPUT_TOKENS, + max_continuations=CHAT_MAX_CONTINUATIONS, + ) + if _is_ai_error_reply(reply): + return {"ok": False, "msg": reply, "session_id": sid} + + append_chat_message( + sid, + "user", + user_visible, + attachments=parsed.get("attachment_meta") or [], + ) + session = append_chat_message(sid, "assistant", reply) + summary_kwargs = { + "session_id": sid, + "prior_summary": prior_rolling, + "user_text": user_visible, + "assistant_text": reply, + "bot_mode": bot_mode, + } + + def _refresh_summary_bg() -> None: + try: + refresh_session_rolling_summary(**summary_kwargs) + except Exception: + pass + + threading.Thread(target=_refresh_summary_bg, daemon=True).start() + session = get_active_session() or session + return { + "ok": True, + "trading_day": day, + "session": session, + "sessions": list_chat_sessions(), + "reply": reply, + "model": model_label(), + "attachment_warnings": parsed.get("errors") or [], + } diff --git a/manual_trading_hub/hub_ai/client.py b/manual_trading_hub/hub_ai/client.py new file mode 100644 index 0000000..90d1b78 --- /dev/null +++ b/manual_trading_hub/hub_ai/client.py @@ -0,0 +1,42 @@ +"""中控 AI 模型调用(共用 ai_client 配置,逻辑独立).""" +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Optional, Sequence + +_REPO_ROOT = Path(__file__).resolve().parents[2] +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) + +from lib.ai.ai_client import ai_generate, ai_generate_chat, ai_provider_label # noqa: E402 + + +def model_label() -> str: + return ai_provider_label() + + +def generate_text( + *, + system: str, + user: str, + temperature: float, + images_b64: Optional[Sequence[str]] = None, + max_tokens: int | None = None, + max_continuations: int = 3, +) -> str: + if max_tokens is not None and max_tokens > 0: + return ai_generate_chat( + system=system, + user=user, + temperature=temperature, + images_b64=images_b64, + max_tokens=int(max_tokens), + max_continuations=max_continuations, + ) + prompt = f"{system.strip()}\n\n---\n\n{user.strip()}" + return ai_generate( + prompt, + temperature=temperature, + images_b64=images_b64, + ) diff --git a/manual_trading_hub/hub_ai/config.py b/manual_trading_hub/hub_ai/config.py new file mode 100644 index 0000000..0918d40 --- /dev/null +++ b/manual_trading_hub/hub_ai/config.py @@ -0,0 +1,57 @@ +"""中控 AI 配置(读 hub .env,与实例同名 AI 变量).""" +from __future__ import annotations + +import os + +HUB_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def _int_env(key: str, default: int) -> int: + try: + return int(os.getenv(key, str(default)) or default) + except ValueError: + return default + + +SUMMARY_TEMPERATURE = 0.15 +CHAT_TEMPERATURE = 0.5 +CHAT_MAX_HISTORY_TURNS = _int_env("CHAT_MAX_HISTORY_TURNS", 16) +CHAT_MAX_OUTPUT_TOKENS = _int_env("CHAT_MAX_OUTPUT_TOKENS", 8192) +CHAT_MAX_CONTINUATIONS = _int_env("CHAT_MAX_CONTINUATIONS", 4) +CHAT_CONTEXT_MAX_CHARS = _int_env("CHAT_CONTEXT_MAX_CHARS", 12_000) +CHAT_FOLLOWUP_CONTEXT_MAX_CHARS = _int_env("CHAT_FOLLOWUP_CONTEXT_MAX_CHARS", 4500) +CHAT_PROMPT_MAX_CHARS = _int_env("CHAT_PROMPT_MAX_CHARS", 28_000) +CHAT_USER_MESSAGE_MAX_CHARS = _int_env("CHAT_USER_MESSAGE_MAX_CHARS", 3500) +CHAT_SUMMARY_EXCERPT_MAX_CHARS = _int_env("CHAT_SUMMARY_EXCERPT_MAX_CHARS", 1200) +CHAT_HISTORY_MAX_CHARS_PER_MSG = _int_env("CHAT_HISTORY_MAX_CHARS_PER_MSG", 900) +CHAT_ROLLING_SUMMARY_MAX_CHARS = _int_env("CHAT_ROLLING_SUMMARY_MAX_CHARS", 900) +CHAT_ROLLING_SUMMARY_GEN_MAX_TOKENS = _int_env("CHAT_ROLLING_SUMMARY_GEN_MAX_TOKENS", 512) +CHAT_ROLLING_SUMMARY_TEMPERATURE = 0.2 +SUMMARY_RETENTION_DAYS = 90 +CHAT_SESSION_RETENTION_DAYS = 60 +FUND_HISTORY_DAYS = 180 +CHAT_MAX_ATTACHMENTS = 3 +CHAT_MAX_IMAGE_BYTES = 4 * 1024 * 1024 +CHAT_MAX_TEXT_FILE_BYTES = 200 * 1024 +CHAT_CONTEXT_CACHE_TTL_SEC = _int_env("CHAT_CONTEXT_CACHE_TTL_SEC", 45) + + +def trading_day_reset_hour() -> int: + try: + return int(os.getenv("TRADING_DAY_RESET_HOUR", "8") or "8") + except ValueError: + return 8 + + +def hub_flask_timeout() -> float: + try: + return float(os.getenv("HUB_FLASK_TIMEOUT", "10") or "10") + except ValueError: + return 10.0 + + +def hub_agent_timeout() -> float: + try: + return float(os.getenv("HUB_AGENT_TIMEOUT", "8") or "8") + except ValueError: + return 8.0 diff --git a/manual_trading_hub/hub_ai/context.py b/manual_trading_hub/hub_ai/context.py new file mode 100644 index 0000000..b6f8031 --- /dev/null +++ b/manual_trading_hub/hub_ai/context.py @@ -0,0 +1,1286 @@ +"""中控 AI:三户数据聚合为结构化上下文.""" +from __future__ import annotations + +import hashlib +import json +import os +import re +import time +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timedelta +from threading import Lock +from typing import Any, Optional + +import httpx + +from hub_ai.config import ( + CHAT_CONTEXT_MAX_CHARS, + FUND_HISTORY_DAYS, + hub_agent_timeout, + hub_flask_timeout, + trading_day_reset_hour, +) +from hub_ai.fund_history import format_fund_history_text, get_fund_history, record_fund_snapshot +from lib.hub.hub_options_funds_lib import ( + merge_perp_options_balances, + options_float_pnl_usdt, + options_open_position_count, +) +from lib.hub.hub_trades_lib import current_trading_day, summarize_trades + +_CHAT_CONTEXT_CACHE: dict[str, dict[str, Any]] = {} +_CHAT_CONTEXT_CACHE_LOCK = Lock() +_HUB_TPSL_MERGE_FN: Any = None + + +def _chat_context_cache_ttl_sec() -> float: + try: + return float(os.getenv("CHAT_CONTEXT_CACHE_TTL_SEC", "45") or "45") + except ValueError: + return 45.0 + + +def _hub_token() -> str: + return (os.getenv("HUB_BRIDGE_TOKEN") or os.getenv("CONTROL_TOKEN") or "").strip() + + +def _hub_headers() -> dict[str, str]: + tok = _hub_token() + return {"X-Hub-Token": tok} if tok else {} + + +def _agent_headers() -> dict[str, str]: + tok = (os.getenv("CONTROL_TOKEN") or os.getenv("HUB_BRIDGE_TOKEN") or "").strip() + return {"X-Control-Token": tok} if tok else {} + + +def _safe_float(v: Any) -> Optional[float]: + try: + if v is None or v == "": + return None + return float(v) + except (TypeError, ValueError): + return None + + +def _position_contracts(p: dict) -> float: + for key in ("contracts", "contracts_signed", "size"): + v = p.get(key) + try: + if v is not None and v != "": + return float(v) + except (TypeError, ValueError): + continue + return 0.0 + + +def _filter_open_positions(positions: list) -> list[dict]: + out: list[dict] = [] + for p in positions or []: + if not isinstance(p, dict): + continue + if abs(_position_contracts(p)) < 1e-12: + continue + out.append(p) + return out + + +def _account_open_position_count(ac: dict) -> int: + return len(_filter_open_positions(ac.get("positions") or [])) + + +def _monitor_counts(ac: dict) -> dict[str, int]: + mon = ac.get("monitor_lines") or {} + return { + "trends": len(mon.get("trends") or []), + "rolls": len(mon.get("rolls") or []), + "keys": len(mon.get("keys") or []), + "orders": len(mon.get("orders") or []), + } + + +def _position_float_pnl(pos: dict) -> float: + for key in ("unrealized_pnl", "unrealizedPnl", "upnl"): + v = _safe_float(pos.get(key)) + if v is not None: + return v + return 0.0 + + +def _collect_open_issues( + *, + monitored: bool, + agent_ok: bool, + flask_ok: bool, + positions: list, + hub_mon: Optional[dict], + day_pnl: float, +) -> list[str]: + issues: list[str] = [] + if not monitored: + return issues + if not agent_ok: + issues.append("Agent 连接异常") + if not flask_ok: + issues.append("Flask 监控连接异常") + if day_pnl < -0.01: + issues.append(f"当日平仓亏损 {day_pnl:.2f}U") + open_positions = _filter_open_positions(positions) + float_pnl = sum(_position_float_pnl(p) for p in open_positions) + if float_pnl < -0.5: + issues.append(f"当前浮亏 {float_pnl:.2f}U") + if isinstance(hub_mon, dict) and hub_mon.get("ok") is not False: + orders = hub_mon.get("orders") or [] + trends = hub_mon.get("trends") or [] + if open_positions and not orders and not trends: + issues.append("交易所有持仓但无本地 active 监控/趋势计划") + return issues + + +def previous_trading_day(trading_day: str) -> str: + day = (trading_day or "").strip()[:10] + if not day: + return day + dt = datetime.strptime(day, "%Y-%m-%d") + return (dt - timedelta(days=1)).strftime("%Y-%m-%d") + + +def _fmt_fund(v: Any) -> str: + n = _safe_float(v) + if n is None: + return "未知" + return f"{n:.2f}U" + + +def _format_trade_line(t: dict, *, day_label: str = "") -> str: + prefix = f"[{day_label}] " if day_label else "" + return ( + f"{prefix}{t.get('symbol')} {t.get('direction')} {t.get('result')} " + f"{t.get('pnl_amount')}U @ {t.get('closed_at') or '?'}" + ) + + +def _monitor_label(item: dict, default: str = "") -> str: + for key in ("monitor_type_label", "monitor_type", "entry_reason", "source_label"): + val = item.get(key) + if val: + return str(val) + return default + + +def _format_monitor_sections(hub_mon: Optional[dict]) -> dict[str, list[str]]: + out = {"trends": [], "orders": [], "keys": [], "rolls": []} + if not isinstance(hub_mon, dict) or hub_mon.get("ok") is False: + return out + for t in hub_mon.get("trends") or []: + if not isinstance(t, dict): + continue + out["trends"].append( + f"{t.get('symbol')} {t.get('direction')} " + f"SL={t.get('stop_loss')} TP={t.get('take_profit')} " + f"补仓区[{t.get('add_lower')}~{t.get('add_upper')}] " + f"状态={t.get('status')}" + ) + for o in hub_mon.get("orders") or []: + if not isinstance(o, dict): + continue + label = _monitor_label(o, "下单监控") + out["orders"].append( + f"{label}: {o.get('symbol')} {o.get('direction')} " + f"触发={o.get('trigger_price')} SL={o.get('stop_loss')} TP={o.get('take_profit')} " + f"状态={o.get('status')}" + ) + for k in hub_mon.get("keys") or []: + if not isinstance(k, dict): + continue + out["keys"].append( + f"关键位: {k.get('symbol')} {k.get('direction')} " + f"上={k.get('upper')} 下={k.get('lower')} 类型={k.get('monitor_type')}" + ) + for r in hub_mon.get("rolls") or []: + if not isinstance(r, dict): + continue + out["rolls"].append( + f"顺势加仓: {r.get('symbol')} {r.get('direction')} " + f"腿数={r.get('leg_count')} SL={r.get('current_stop_loss') or r.get('initial_stop_loss')} " + f"状态={r.get('status')}" + ) + return out + + +_SL_TP_COMBO_RE = re.compile(r"SL=([\d.eE+-]+).*TP=([\d.eE+-]+)", re.I) + + +def _norm_symbol(sym: str) -> str: + s = (sym or "").strip().upper() + if "/" in s: + s = s.split(":")[0].split("/")[0] + return s + + +def _symbols_match(a: str, b: str) -> bool: + na, nb = _norm_symbol(a), _norm_symbol(b) + return bool(na and nb and na == nb) + + +def _pick_tpsl_from_cond(cond: list) -> tuple[Optional[float], Optional[float]]: + sl = tp = None + if not cond: + return sl, tp + sl_o = tp_o = combo = None + for o in cond: + if not isinstance(o, dict): + continue + lbl = str(o.get("label") or "") + if "止盈止损" in lbl: + combo = o + elif lbl.startswith("止损"): + sl_o = o + elif lbl.startswith("止盈"): + tp_o = o + if combo: + lbl = str(combo.get("label") or "") + m = _SL_TP_COMBO_RE.search(lbl) + if m: + sl = _safe_float(m.group(1)) + tp = _safe_float(m.group(2)) + if sl_o and sl is None: + sl = _safe_float(sl_o.get("trigger_price")) + if tp_o and tp is None: + tp = _safe_float(tp_o.get("trigger_price")) + if sl is None: + for o in cond: + if not isinstance(o, dict): + continue + lbl = str(o.get("label") or "") + if "止损" in lbl and "止盈止损" not in lbl: + sl = _safe_float(o.get("trigger_price")) + if sl is not None: + break + if tp is None: + for o in cond: + if not isinstance(o, dict): + continue + lbl = str(o.get("label") or "") + if lbl.startswith("止盈") or ("止盈" in lbl and "止盈止损" not in lbl): + tp = _safe_float(o.get("trigger_price")) + if tp is not None: + break + return sl, tp + + +def _pick_tpsl_from_exchange_tpsl(et: Any) -> tuple[Optional[float], Optional[float]]: + if not isinstance(et, dict): + return None, None + sl = tp = None + slot_sl = et.get("sl") + slot_tp = et.get("tp") + if isinstance(slot_sl, dict): + sl = _safe_float(slot_sl.get("trigger_price")) + if isinstance(slot_tp, dict): + tp = _safe_float(slot_tp.get("trigger_price")) + return sl, tp + + +def _find_plan_tpsl_for_position( + symbol: str, + side: str, + hub_mon: Optional[dict], +) -> tuple[Optional[float], Optional[float], bool]: + """匹配本地监控/趋势计划:sl, tp, tp_is_program_monitored.""" + if not isinstance(hub_mon, dict): + return None, None, False + side_l = (side or "").lower() + for o in hub_mon.get("orders") or []: + if not isinstance(o, dict): + continue + o_sym = o.get("exchange_symbol") or o.get("symbol") or "" + if not _symbols_match(symbol, o_sym): + continue + if (o.get("direction") or "").lower() != side_l: + continue + return ( + _safe_float(o.get("stop_loss")), + _safe_float(o.get("take_profit")), + False, + ) + for t in hub_mon.get("trends") or []: + if not isinstance(t, dict): + continue + if not _symbols_match(symbol, t.get("symbol") or ""): + continue + if (t.get("direction") or "").lower() != side_l: + continue + plan_tp = t.get("take_profit") + tp = _safe_float(plan_tp) if plan_tp not in (None, "") else None + return _safe_float(t.get("stop_loss")), tp, tp is None + return None, None, False + + +def _resolve_position_tpsl(pos: dict, hub_mon: Optional[dict]) -> dict[str, Any]: + cond = pos.get("conditional_orders") or [] + cond_sl, cond_tp = _pick_tpsl_from_cond(cond) + et_sl, et_tp = _pick_tpsl_from_exchange_tpsl(pos.get("exchange_tpsl")) + plan_sl, plan_tp, tp_monitored = _find_plan_tpsl_for_position( + str(pos.get("symbol") or ""), + str(pos.get("side") or ""), + hub_mon, + ) + sl = cond_sl if cond_sl is not None else et_sl if et_sl is not None else plan_sl + tp_note = "" + tp: Optional[float] = None + if tp_monitored and cond_tp is None and et_tp is None: + tp_note = "程序监控" + else: + tp = cond_tp if cond_tp is not None else et_tp if et_tp is not None else plan_tp + if sl is not None and tp is not None and sl == tp: + tp = None + return {"sl": sl, "tp": tp, "tp_note": tp_note} + + +def _format_position_detail_line(pos: dict, hub_mon: Optional[dict]) -> str: + sym = pos.get("symbol") or "?" + side = pos.get("side") or "?" + contracts = pos.get("contracts") or pos.get("size") or "?" + upnl = _position_float_pnl(pos) + entry = _safe_float(pos.get("entry_price")) + tpsl = _resolve_position_tpsl(pos, hub_mon) + parts = [f"{sym} {side} 张数{contracts}"] + if entry is not None: + parts.append(f"入场{entry:g}") + if tpsl["sl"] is not None: + parts.append(f"止损{tpsl['sl']:g}") + else: + parts.append("止损=未检测到") + if tpsl["tp_note"]: + parts.append(f"止盈={tpsl['tp_note']}") + elif tpsl["tp"] is not None: + parts.append(f"止盈{tpsl['tp']:g}") + else: + parts.append("止盈=未检测到") + parts.append(f"浮盈亏{upnl:.4f}U") + return " - " + " ".join(parts) + + +def _enrich_positions_exchange_tpsl( + positions: list, + price_snap: Optional[dict], + hub_mon: Optional[dict], +) -> None: + global _HUB_TPSL_MERGE_FN + if not positions: + return + if _HUB_TPSL_MERGE_FN is None: + try: + from hub import _merge_flask_exchange_tpsl + + _HUB_TPSL_MERGE_FN = _merge_flask_exchange_tpsl + except Exception: + _HUB_TPSL_MERGE_FN = False + if not _HUB_TPSL_MERGE_FN: + return + try: + _HUB_TPSL_MERGE_FN( + {"agent": {"positions": positions}}, + price_snap if isinstance(price_snap, dict) else None, + hub_mon if isinstance(hub_mon, dict) else None, + ) + except Exception: + pass + + +def _fetch_account_bundle( + client: httpx.Client, + ex: dict, + trading_day: str, + *, + for_chat: bool = False, +) -> dict[str, Any]: + name = ex.get("name") or ex.get("key") or ex.get("id") + key = ex.get("key") or "" + enabled = bool(ex.get("enabled")) + env_disabled = bool(ex.get("env_disabled")) + monitored = enabled and not env_disabled + + base: dict[str, Any] = { + "id": ex.get("id"), + "key": key, + "name": name, + "enabled": enabled, + "env_disabled": env_disabled, + "status": "未监控" if not monitored else "已监控", + "trades": [], + "trade_stats": summarize_trades([]), + "positions": [], + "open_position_count": 0, + "float_pnl_u": 0.0, + "balance_usdt": None, + "funding_usdt": None, + "trading_usdt": None, + "available_trading_usdt": None, + "perpetual_funding_usdt": None, + "perpetual_trading_usdt": None, + "options_funding_usdt": None, + "options_trading_usdt": None, + "options_float_pnl_u": None, + "options_open_position_count": 0, + "options_snapshot": None, + "trades_yesterday": [], + "trade_stats_yesterday": summarize_trades([]), + "monitor_lines": {"trends": [], "orders": [], "keys": [], "rolls": []}, + "issues": [], + "agent_ok": False, + "flask_ok": False, + "hub_monitor": None, + "active_orders": 0, + "active_trends": 0, + } + if not monitored: + base["issues"] = [] + return base + + agent_url = (ex.get("agent_url") or "").rstrip("/") + flask_url = (ex.get("flask_url") or "").rstrip("/") + agent_body = None + if agent_url: + try: + r = client.get( + f"{agent_url}/status", + headers=_agent_headers(), + timeout=hub_agent_timeout(), + ) + if r.status_code == 200: + agent_body = r.json() + base["agent_ok"] = True + except Exception as exc: + base["issues"].append(f"Agent: {exc}") + + if isinstance(agent_body, dict): + base["balance_usdt"] = _safe_float(agent_body.get("balance_usdt")) + positions = agent_body.get("positions") or [] + if isinstance(positions, list): + open_positions = _filter_open_positions(positions) + base["positions"] = open_positions + base["open_position_count"] = len(open_positions) + base["float_pnl_u"] = round(sum(_position_float_pnl(p) for p in open_positions), 4) + + hub_mon = None + price_snap = None + prev_day = previous_trading_day(trading_day) + if flask_url: + try: + r = client.get( + f"{flask_url}/api/hub/account", + headers=_hub_headers(), + timeout=hub_flask_timeout(), + ) + if r.status_code == 200: + acct_body = r.json() + if isinstance(acct_body, dict) and acct_body.get("ok"): + base["perpetual_funding_usdt"] = _safe_float(acct_body.get("funding_usdt")) + base["perpetual_trading_usdt"] = _safe_float(acct_body.get("trading_usdt")) + base["funding_usdt"] = base["perpetual_funding_usdt"] + base["trading_usdt"] = base["perpetual_trading_usdt"] + base["available_trading_usdt"] = _safe_float(acct_body.get("available_trading_usdt")) + base["flask_ok"] = True + except Exception as exc: + base["issues"].append(f"资金接口: {exc}") + + try: + r = client.get( + f"{flask_url}/api/hub/trades/today", + headers=_hub_headers(), + params={"trading_day": trading_day}, + timeout=hub_flask_timeout(), + ) + if r.status_code == 200: + trades_body = r.json() + if isinstance(trades_body, dict) and trades_body.get("ok"): + base["trades"] = trades_body.get("trades") or [] + base["trade_stats"] = trades_body.get("stats") or summarize_trades(base["trades"]) + base["flask_ok"] = True + except Exception as exc: + base["issues"].append(f"成交接口: {exc}") + + if prev_day and not for_chat: + try: + r = client.get( + f"{flask_url}/api/hub/trades/today", + headers=_hub_headers(), + params={"trading_day": prev_day}, + timeout=hub_flask_timeout(), + ) + if r.status_code == 200: + y_body = r.json() + if isinstance(y_body, dict) and y_body.get("ok"): + base["trades_yesterday"] = y_body.get("trades") or [] + base["trade_stats_yesterday"] = y_body.get("stats") or summarize_trades( + base["trades_yesterday"] + ) + base["flask_ok"] = True + except Exception as exc: + base["issues"].append(f"昨日成交: {exc}") + + try: + r = client.get( + f"{flask_url}/api/hub/monitor", + headers=_hub_headers(), + timeout=hub_flask_timeout(), + ) + if r.status_code == 200: + hub_mon = r.json() + if isinstance(hub_mon, dict) and hub_mon.get("ok") is not False: + base["hub_monitor"] = hub_mon + base["flask_ok"] = True + base["active_orders"] = len(hub_mon.get("orders") or []) + base["active_trends"] = len(hub_mon.get("trends") or []) + base["monitor_lines"] = _format_monitor_sections(hub_mon) + except Exception as exc: + if "成交接口" not in str(base["issues"]): + base["issues"].append(f"监控接口: {exc}") + + try: + r = client.get( + f"{flask_url}/api/price_snapshot", + headers=_hub_headers(), + timeout=hub_flask_timeout(), + ) + if r.status_code == 200: + body = r.json() + if isinstance(body, dict): + price_snap = body + base["flask_ok"] = True + except Exception: + pass + + if base["positions"]: + _enrich_positions_exchange_tpsl(base["positions"], price_snap, hub_mon) + + caps = ex.get("capabilities") or [] + if "options" in caps: + try: + r = client.get( + f"{flask_url}/api/hub/options/snapshot", + headers=_hub_headers(), + timeout=hub_flask_timeout(), + ) + if r.status_code == 200: + opt_body = r.json() + if isinstance(opt_body, dict): + base["options_snapshot"] = opt_body + if opt_body.get("ok") is not False and opt_body.get("enabled") is not False: + base["flask_ok"] = True + merged = merge_perp_options_balances( + base.get("perpetual_funding_usdt"), + base.get("perpetual_trading_usdt"), + opt_body, + ) + base["options_funding_usdt"] = merged.get("options_funding_usdt") + base["options_trading_usdt"] = merged.get("options_trading_usdt") + if merged.get("funding_usdt") is not None: + base["funding_usdt"] = merged.get("funding_usdt") + if merged.get("trading_usdt") is not None: + base["trading_usdt"] = merged.get("trading_usdt") + opt_count = options_open_position_count(opt_body) + base["options_open_position_count"] = opt_count + base["open_position_count"] += opt_count + opt_upl = options_float_pnl_usdt(opt_body) + if opt_upl is not None: + base["options_float_pnl_u"] = opt_upl + base["float_pnl_u"] = round(float(base["float_pnl_u"]) + opt_upl, 4) + except Exception as exc: + base["issues"].append(f"期权接口: {exc}") + + if monitored and not base["agent_ok"] and not base["flask_ok"]: + base["status"] = "连接异常" + elif base["issues"]: + base["status"] = "已监控·需关注" + + day_pnl = float((base.get("trade_stats") or {}).get("total_pnl_u") or 0) + base["issues"].extend( + _collect_open_issues( + monitored=monitored, + agent_ok=base["agent_ok"], + flask_ok=base["flask_ok"], + positions=base["positions"], + hub_mon=hub_mon if isinstance(hub_mon, dict) else None, + day_pnl=day_pnl, + ) + ) + base["issues"] = list(dict.fromkeys(base["issues"])) + return base + + +def _fetch_account_bundle_isolated(ex: dict, trading_day: str, *, for_chat: bool) -> dict[str, Any]: + with httpx.Client() as client: + return _fetch_account_bundle(client, ex, trading_day, for_chat=for_chat) + + +def build_daily_context( + exchanges: list[dict], + *, + trading_day: Optional[str] = None, + for_chat: bool = False, +) -> dict[str, Any]: + day = (trading_day or "").strip()[:10] or current_trading_day( + reset_hour=trading_day_reset_hour() + ) + ex_list = exchanges or [] + if for_chat and len(ex_list) > 1: + workers = min(4, len(ex_list)) + with ThreadPoolExecutor(max_workers=workers) as pool: + accounts = list( + pool.map( + lambda ex: _fetch_account_bundle_isolated(ex, day, for_chat=True), + ex_list, + ) + ) + else: + with httpx.Client() as client: + accounts = [ + _fetch_account_bundle(client, ex, day, for_chat=for_chat) for ex in ex_list + ] + + total_closed_pnl = 0.0 + total_closed = total_win = total_loss = 0 + total_float = 0.0 + total_funding = 0.0 + total_trading = 0.0 + total_open_positions = 0 + total_options_open_positions = 0 + total_options_float = 0.0 + options_float_known = 0 + funding_known = trading_known = 0 + for ac in accounts: + if ac.get("status") == "未监控": + continue + st = ac.get("trade_stats") or {} + total_closed_pnl += float(st.get("total_pnl_u") or 0) + total_closed += int(st.get("closed_count") or 0) + total_win += int(st.get("win_count") or 0) + total_loss += int(st.get("loss_count") or 0) + total_float += float(ac.get("float_pnl_u") or 0) + total_open_positions += int(ac.get("open_position_count") or _account_open_position_count(ac)) + total_options_open_positions += int(ac.get("options_open_position_count") or 0) + opt_float = _safe_float(ac.get("options_float_pnl_u")) + if opt_float is not None: + total_options_float += opt_float + options_float_known += 1 + fu = _safe_float(ac.get("funding_usdt")) + tu = _safe_float(ac.get("trading_usdt")) + if fu is not None: + total_funding += fu + funding_known += 1 + if tu is not None: + total_trading += tu + trading_known += 1 + if not funding_known: + total_funding = None + if not trading_known: + total_trading = None + + totals = { + "trading_day": day, + "prev_trading_day": previous_trading_day(day), + "total_pnl_u": round(total_closed_pnl, 4), + "closed_count": total_closed, + "win_count": total_win, + "loss_count": total_loss, + "float_pnl_u": round(total_float, 4), + "open_position_count": total_open_positions, + "options_open_position_count": total_options_open_positions, + "perpetual_open_position_count": max( + 0, int(total_open_positions) - int(total_options_open_positions) + ), + "options_float_pnl_u": round(total_options_float, 4) if options_float_known else None, + "total_funding_usdt": round(total_funding, 4) if total_funding is not None else None, + "total_trading_usdt": round(total_trading, 4) if total_trading is not None else None, + } + if for_chat: + fund_history: list = [] + fund_history_text = "" + else: + snap_accounts = [ + { + **ac, + "monitored": ac.get("status") != "未监控", + } + for ac in accounts + ] + record_fund_snapshot(day, snap_accounts, keep_days=FUND_HISTORY_DAYS) + fund_history = get_fund_history(anchor_day=day, keep_days=FUND_HISTORY_DAYS) + account_names = {str(ac.get("key") or ac.get("id")): ac.get("name") for ac in accounts} + fund_history_text = format_fund_history_text(fund_history, account_names=account_names) + payload = { + "trading_day": day, + "prev_trading_day": previous_trading_day(day), + "totals": totals, + "accounts": accounts, + "fund_history": fund_history, + "fund_history_text": fund_history_text, + } + if for_chat: + text = format_chat_context_for_chat(payload) + else: + text = format_context_text(payload) + digest = hashlib.sha256(text.encode("utf-8")).hexdigest()[:16] + return { + "trading_day": day, + "prev_trading_day": previous_trading_day(day), + "totals": totals, + "accounts": accounts, + "fund_history": fund_history, + "fund_history_text": fund_history_text, + "text": text, + "context_hash": digest, + } + + +def build_chat_context( + exchanges: list[dict], + *, + trading_day: Optional[str] = None, + force_refresh: bool = False, +) -> dict[str, Any]: + """聊天专用上下文:并行拉取,跳过资金曲线/昨日成交,短 TTL 缓存.""" + day = (trading_day or "").strip()[:10] or current_trading_day( + reset_hour=trading_day_reset_hour() + ) + ttl = _chat_context_cache_ttl_sec() + now = time.monotonic() + if not force_refresh and ttl > 0: + with _CHAT_CONTEXT_CACHE_LOCK: + hit = _CHAT_CONTEXT_CACHE.get(day) + if hit and (now - float(hit.get("ts") or 0)) < ttl: + return hit["ctx"] + ctx = build_daily_context(exchanges, trading_day=day, for_chat=True) + if ttl > 0: + with _CHAT_CONTEXT_CACHE_LOCK: + _CHAT_CONTEXT_CACHE[day] = {"ts": now, "ctx": ctx} + return ctx + + +def format_context_text(payload: dict) -> str: + lines = [] + totals = payload.get("totals") or {} + day = totals.get("trading_day") + prev_day = totals.get("prev_trading_day") or previous_trading_day(str(day or "")) + lines.append( + f"【合计·今日 {day}】平仓盈亏 {totals.get('total_pnl_u')}U | " + f"笔数 {totals.get('closed_count')}(胜{totals.get('win_count')}/负{totals.get('loss_count')})| " + f"实盘持仓 {totals.get('open_position_count', 0)} 仓 | " + f"浮盈亏 {totals.get('float_pnl_u')}U | " + f"资金账户合计 {_fmt_fund(totals.get('total_funding_usdt'))} | " + f"交易账户合计 {_fmt_fund(totals.get('total_trading_usdt'))}" + ) + lines.append( + f"【对比交易日】昨日={prev_day},今日={day}." + "「持仓」= 交易所 Agent 实盘;「趋势/关键位/监控单/加仓」= 本地计划,不等于已开仓." + ) + fund_txt = str(payload.get("fund_history_text") or "").strip() + if fund_txt: + lines.append("") + lines.append(fund_txt) + lines.append("") + for ac in payload.get("accounts") or []: + st = ac.get("trade_stats") or {} + sty = ac.get("trade_stats_yesterday") or {} + lines.append(f"--- 账户:{ac.get('name')} ({ac.get('key')}) ---") + lines.append(f"状态:{ac.get('status')}") + if ac.get("status") == "未监控": + lines.append("") + continue + lines.append( + f"资金账户 {_fmt_fund(ac.get('funding_usdt'))} | " + f"交易账户 {_fmt_fund(ac.get('trading_usdt'))} | " + f"可用 {_fmt_fund(ac.get('available_trading_usdt'))}" + ) + lines.append( + f"今日({day})平仓:{st.get('closed_count')} 笔,盈亏 {st.get('total_pnl_u')}U " + f"(胜{st.get('win_count')}/负{st.get('loss_count')})" + ) + lines.append( + f"昨日({prev_day})平仓:{sty.get('closed_count')} 笔,盈亏 {sty.get('total_pnl_u')}U " + f"(胜{sty.get('win_count')}/负{sty.get('loss_count')})" + ) + open_n = int(ac.get("open_position_count") or _account_open_position_count(ac)) + if open_n <= 0: + lines.append("当前交易所持仓:无(空仓)") + else: + lines.append( + f"当前交易所持仓:{open_n} 仓 | 浮盈亏合计 {ac.get('float_pnl_u')}U" + ) + mon = ac.get("monitor_lines") or {} + if mon.get("trends"): + lines.append("趋势回调计划(本地,非持仓):") + for row in mon["trends"][:8]: + lines.append(f" - {row}") + if mon.get("rolls"): + lines.append("顺势加仓(本地,非持仓):") + for row in mon["rolls"][:8]: + lines.append(f" - {row}") + if mon.get("keys"): + lines.append("关键位监控(本地,非持仓):") + for row in mon["keys"][:8]: + lines.append(f" - {row}") + if mon.get("orders"): + lines.append("进行中的下单监控(本地,非持仓):") + for row in mon["orders"][:8]: + lines.append(f" - {row}") + positions = ac.get("positions") or [] + hub_mon = ac.get("hub_monitor") + if positions: + lines.append("持仓明细(交易所实盘,含止盈止损若已挂):") + for p in positions[:8]: + if not isinstance(p, dict): + continue + lines.append(_format_position_detail_line(p, hub_mon)) + lines.append( + f"Agent合约余额:{ac.get('balance_usdt') if ac.get('balance_usdt') is not None else '未知'} USDT" + ) + trades_today = ac.get("trades") or [] + if trades_today: + lines.append(f"今日平仓明细:") + for t in trades_today[:15]: + lines.append(f" - {_format_trade_line(t)}") + trades_y = ac.get("trades_yesterday") or [] + if trades_y: + lines.append(f"昨日平仓明细:") + for t in trades_y[:15]: + lines.append(f" - {_format_trade_line(t)}") + if not trades_today and not trades_y: + lines.append("平仓明细:无") + issues = ac.get("issues") or [] + if issues: + lines.append("关注点:" + ";".join(issues)) + lines.append("") + return "\n".join(lines).strip() + + +def format_summary_context_text(payload: dict) -> str: + """今日总结专用:仅当日平仓/持仓/监控,不含昨日明细与资金走势.""" + lines = [] + totals = payload.get("totals") or {} + day = totals.get("trading_day") + lines.append( + f"【合计·今日 {day}】平仓盈亏 {totals.get('total_pnl_u')}U | " + f"笔数 {totals.get('closed_count')}(胜{totals.get('win_count')}/负{totals.get('loss_count')})| " + f"实盘持仓 {totals.get('open_position_count', 0)} 仓 | " + f"浮盈亏 {totals.get('float_pnl_u')}U | " + f"资金账户合计 {_fmt_fund(totals.get('total_funding_usdt'))} | " + f"交易账户合计 {_fmt_fund(totals.get('total_trading_usdt'))}" + ) + lines.append( + f"【说明】交易日={day}." + "「持仓」= 交易所 Agent 实盘;「趋势/关键位/监控单/加仓」= 本地计划,不等于已开仓." + ) + lines.append("") + for ac in payload.get("accounts") or []: + st = ac.get("trade_stats") or {} + lines.append(f"--- 账户:{ac.get('name')} ({ac.get('key')}) ---") + lines.append(f"状态:{ac.get('status')}") + if ac.get("status") == "未监控": + lines.append("") + continue + lines.append( + f"资金账户 {_fmt_fund(ac.get('funding_usdt'))} | " + f"交易账户 {_fmt_fund(ac.get('trading_usdt'))} | " + f"可用 {_fmt_fund(ac.get('available_trading_usdt'))}" + ) + lines.append( + f"今日({day})平仓:{st.get('closed_count')} 笔,盈亏 {st.get('total_pnl_u')}U " + f"(胜{st.get('win_count')}/负{st.get('loss_count')})" + ) + open_n = int(ac.get("open_position_count") or _account_open_position_count(ac)) + if open_n <= 0: + lines.append("当前交易所持仓:无(空仓)") + else: + lines.append( + f"当前交易所持仓:{open_n} 仓 | 浮盈亏合计 {ac.get('float_pnl_u')}U" + ) + mon = ac.get("monitor_lines") or {} + if mon.get("trends"): + lines.append("趋势回调计划(本地,非持仓):") + for row in mon["trends"][:8]: + lines.append(f" - {row}") + if mon.get("rolls"): + lines.append("顺势加仓(本地,非持仓):") + for row in mon["rolls"][:8]: + lines.append(f" - {row}") + if mon.get("keys"): + lines.append("关键位监控(本地,非持仓):") + for row in mon["keys"][:8]: + lines.append(f" - {row}") + if mon.get("orders"): + lines.append("进行中的下单监控(本地,非持仓):") + for row in mon["orders"][:8]: + lines.append(f" - {row}") + positions = ac.get("positions") or [] + hub_mon = ac.get("hub_monitor") + if positions: + lines.append("持仓明细(交易所实盘,含止盈止损若已挂):") + for p in positions[:8]: + if not isinstance(p, dict): + continue + lines.append(_format_position_detail_line(p, hub_mon)) + lines.append( + f"Agent合约余额:{ac.get('balance_usdt') if ac.get('balance_usdt') is not None else '未知'} USDT" + ) + trades_today = ac.get("trades") or [] + if trades_today: + lines.append("今日平仓明细:") + for t in trades_today[:15]: + lines.append(f" - {_format_trade_line(t)}") + else: + lines.append("今日平仓明细:无") + issues = ac.get("issues") or [] + if issues: + lines.append("关注点:" + ";".join(issues)) + lines.append("") + return "\n".join(lines).strip() + + +def summary_context_hash(payload: dict) -> str: + text = format_summary_context_text(payload) + return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16] + + +def format_account_remark(ac: dict) -> str: + """分户表格备注:监控摘要 + 持仓.""" + parts: list[str] = [] + mon = ac.get("monitor_lines") or {} + if mon.get("trends"): + parts.append(f"趋势{len(mon['trends'])}") + if mon.get("rolls"): + parts.append(f"加仓{len(mon['rolls'])}") + if mon.get("keys"): + parts.append(f"关键位{len(mon['keys'])}") + if mon.get("orders"): + parts.append(f"监控单{len(mon['orders'])}") + positions = ac.get("positions") or [] + if positions: + for p in positions[:2]: + if not isinstance(p, dict): + continue + sym = p.get("symbol") or "?" + side = p.get("side") or "?" + upnl = _position_float_pnl(p) + parts.append(f"{sym} {side} 浮{upnl:.2f}U") + if len(positions) > 2: + parts.append(f"+{len(positions) - 2}仓") + if not parts: + issues = ac.get("issues") or [] + if issues: + return ";".join(str(x) for x in issues[:2]) + return "无" + return ";".join(parts) + + +def _monitor_item_matches_position(item: dict, symbol: str, side: str) -> bool: + o_sym = item.get("exchange_symbol") or item.get("symbol") or "" + if not _symbols_match(symbol, o_sym): + return False + return (str(item.get("direction") or "").lower() == str(side or "").lower()) + + +def _order_monitor_source_label(order: dict) -> tuple[int, str]: + """返回 (优先级, 来源标签). 对冲=1 … 关键位=5.""" + mt = str( + order.get("monitor_type_display") + or order.get("monitor_type_label") + or order.get("monitor_type") + or "" + ).strip() + if "顺势" in mt: + return 2, "顺势加仓" + if "趋势" in mt: + return 3, "趋势回调" + if "关键位" in mt: + return 5, "关键位" + return 4, "下单监控" + + +def _hedge_matches_position(plan: dict, symbol: str, side: str) -> bool: + """进行中对冲计划是否覆盖该永续仓(方向 + 永续腿/标的).""" + direction = str(plan.get("direction") or "").lower() + if direction and direction != str(side or "").lower(): + return False + for leg in plan.get("legs") or []: + if not isinstance(leg, dict): + continue + if str(leg.get("leg_role") or "") != "perp": + continue + if str(leg.get("status") or "open") not in ("", "open"): + continue + if _symbols_match(symbol, str(leg.get("symbol") or "")): + return True + und = str(plan.get("underlying") or "").strip() + if und and _symbols_match(symbol, und): + return True + return False + + +def _hedge_source_label(plan: dict) -> str: + pt = str(plan.get("plan_type") or "").strip() + if pt == "perp_options" or str(plan.get("plan_type_label") or "") == "永期对冲": + return "永期对冲" + if pt == "options_options" or str(plan.get("plan_type_label") or "") == "期期对冲": + return "期期对冲" + return "对冲" + + +def resolve_position_monitor_source(pos: dict, hub_mon: Optional[dict]) -> str: + """仓位来源:对冲 > 顺势加仓 > 趋势回调 > 下单监控 > 关键位;对不上为 —.""" + if not isinstance(hub_mon, dict) or hub_mon.get("ok") is False: + return "—" + sym = str(pos.get("symbol") or "") + side = str(pos.get("side") or "") + if not sym: + return "—" + candidates: list[tuple[int, str]] = [] + for h in hub_mon.get("hedges") or []: + if isinstance(h, dict) and _hedge_matches_position(h, sym, side): + candidates.append((1, _hedge_source_label(h))) + for r in hub_mon.get("rolls") or []: + if isinstance(r, dict) and _monitor_item_matches_position(r, sym, side): + candidates.append((2, "顺势加仓")) + for t in hub_mon.get("trends") or []: + if isinstance(t, dict) and _monitor_item_matches_position(t, sym, side): + candidates.append((3, "趋势回调")) + for o in hub_mon.get("orders") or []: + if isinstance(o, dict) and _monitor_item_matches_position(o, sym, side): + candidates.append(_order_monitor_source_label(o)) + for k in hub_mon.get("keys") or []: + if isinstance(k, dict) and _monitor_item_matches_position(k, sym, side): + candidates.append((5, "关键位")) + if not candidates: + return "—" + candidates.sort(key=lambda x: x[0]) + return candidates[0][1] + + +def _options_source_label(p: dict) -> str: + """看板期权来源:期期/永期对冲,其余为纯期权.""" + source = str(p.get("source") or "").strip() + label = str(p.get("source_label") or "").strip() + if source == "perp_options" or label == "永期对冲": + return "永期对冲" + if source == "options_options" or label == "期期对冲": + return "期期对冲" + hedge = p.get("hedge_plan_target") if isinstance(p.get("hedge_plan_target"), dict) else None + if hedge: + return _hedge_source_label(hedge) + if label == "纯期权" or source in ("", "option"): + return "纯期权" + return label or "纯期权" + + +def _options_target_monitor_text(p: dict) -> str: + raw = p.get("target_monitor_text") + if raw not in (None, ""): + return str(raw) + try: + from lib.instance.instance_dashboard_lib import _format_options_target + + return _format_options_target(p) + except Exception: + return "—" + + +def format_dashboard_account_detail(ac: dict) -> dict[str, Any]: + """数据看板分户卡片:监控数量 + 持仓表(来源=监控匹配).""" + mon = ac.get("monitor_lines") or {} + hub_mon = ac.get("hub_monitor") if isinstance(ac.get("hub_monitor"), dict) else None + position_lines: list[dict[str, Any]] = [] + for p in _filter_open_positions(ac.get("positions") or []): + sym = p.get("symbol") or "?" + side = p.get("side") or "?" + contracts = p.get("contracts") + if contracts is None: + contracts = p.get("size") + upnl = _position_float_pnl(p) + source = resolve_position_monitor_source(p, hub_mon) + position_lines.append( + { + "kind": "position", + "source": source, + "symbol": sym, + "side": side, + "contracts": contracts, + "text": f"{sym} {side}", + "pnl": round(upnl, 4), + } + ) + opt_snap = ac.get("options_snapshot") if isinstance(ac.get("options_snapshot"), dict) else {} + options_positions: list[dict[str, Any]] = [] + if opt_snap.get("ok") is not False and opt_snap.get("enabled") is not False: + for p in opt_snap.get("positions") or []: + if not isinstance(p, dict): + continue + row = dict(p) + row["source_label"] = _options_source_label(p) + row["target_monitor_text"] = _options_target_monitor_text(p) + try: + from lib.options.options_positions_lib import net_pnl_from_display_row + + net = net_pnl_from_display_row(row) + except Exception: + net = None + row["net_pnl"] = round(float(net), 4) if net is not None else None + options_positions.append(row) + inst = row.get("inst_id") or "?" + opt_type = (row.get("opt_type") or "").upper() + label = "Call" if opt_type == "C" else "Put" if opt_type == "P" else opt_type or "OPT" + line: dict[str, Any] = { + "kind": "options", + "source": row.get("source_label") or "纯期权", + "text": f"期权 {inst} {label}", + } + if row.get("net_pnl") is not None: + line["pnl"] = row["net_pnl"] + elif row.get("upl") is not None: + try: + line["pnl"] = round(float(row["upl"]), 4) + except (TypeError, ValueError): + pass + position_lines.append(line) + issues = [str(x) for x in (ac.get("issues") or [])[:3]] + return { + "monitor_counts": { + "keys": len(mon.get("keys") or []), + "orders": len(mon.get("orders") or []), + "trends": len(mon.get("trends") or []), + "rolls": len(mon.get("rolls") or []), + }, + "position_lines": position_lines, + "options_positions": options_positions, + "issues": issues, + } + + +def collect_closed_trades_snapshot( + accounts: list[dict], + *, + today: str, + yesterday: str | None = None, +) -> list[dict]: + rows: list[dict] = [] + for ac in accounts or []: + name = ac.get("name") or ac.get("key") + if yesterday: + for t in ac.get("trades_yesterday") or []: + if not isinstance(t, dict): + continue + rows.append({**t, "account_name": name, "trading_day": yesterday}) + for t in ac.get("trades") or []: + if not isinstance(t, dict): + continue + rows.append({**t, "account_name": name, "trading_day": today}) + rows.sort(key=lambda x: str(x.get("closed_at") or x.get("opened_at") or ""), reverse=True) + return rows[:80] + + +def format_chat_position_overview(payload: dict) -> str: + totals = payload.get("totals") or {} + total_open = int(totals.get("open_position_count") or 0) + if total_open <= 0: + head = f"【实盘持仓总览】当前空仓(监控户合计 0 仓).浮盈亏 0U 表示无持仓,不是「有仓但不动」." + else: + head = ( + f"【实盘持仓总览】监控户合计 {total_open} 仓," + f"浮盈亏合计 {totals.get('float_pnl_u')}U." + ) + lines = [ + head, + "【区分】只有带「持仓明细/交易所实盘」字样的才是已开仓;趋势回调,关键位,下单监控,顺势加仓是本地计划/监控,不算持仓.持仓明细若含止损/止盈价,表示已挂条件单或监控计划中有价位.", + ] + for ac in payload.get("accounts") or []: + if ac.get("status") == "未监控": + continue + n = int(ac.get("open_position_count") or _account_open_position_count(ac)) + mc = _monitor_counts(ac) + mon_parts = [] + if mc["trends"]: + mon_parts.append(f"趋势{mc['trends']}") + if mc["rolls"]: + mon_parts.append(f"加仓{mc['rolls']}") + if mc["keys"]: + mon_parts.append(f"关键位{mc['keys']}") + if mc["orders"]: + mon_parts.append(f"监控单{mc['orders']}") + mon_txt = f";本地监控 {' '.join(mon_parts)}" if mon_parts else "" + if n <= 0: + lines.append(f"- {ac.get('name')}:空仓{mon_txt}") + else: + lines.append( + f"- {ac.get('name')}:{n}仓 浮盈亏{ac.get('float_pnl_u')}U{mon_txt}" + ) + return "\n".join(lines) + + +def format_chat_context_slim(payload: dict) -> str: + """聊天专用:不含 180 日资金曲线与昨日平仓明细,避免挤占对话上下文.""" + totals = payload.get("totals") or {} + day = totals.get("trading_day") + lines = [ + f"【今日合计 {day}】平仓盈亏 {totals.get('total_pnl_u')}U | " + f"笔数 {totals.get('closed_count')}(胜{totals.get('win_count')}/负{totals.get('loss_count')})| " + f"实盘持仓 {totals.get('open_position_count', 0)} 仓 | 浮盈亏 {totals.get('float_pnl_u')}U", + "【说明】持仓=交易所实盘;趋势/关键位/监控单=本地计划,不等于已开仓.持仓行内「止损/止盈」= 交易所条件单或监控计划价(与监控页一致).", + ] + for ac in payload.get("accounts") or []: + if ac.get("status") == "未监控": + lines.append(f"- {ac.get('name')}:未监控") + continue + st = ac.get("trade_stats") or {} + open_n = int(ac.get("open_position_count") or _account_open_position_count(ac)) + pos_txt = "空仓" if open_n <= 0 else f"{open_n}仓 浮盈亏{ac.get('float_pnl_u')}U" + mc = _monitor_counts(ac) + mon = [] + if mc["trends"]: + mon.append(f"趋势{mc['trends']}") + if mc["rolls"]: + mon.append(f"加仓{mc['rolls']}") + if mc["keys"]: + mon.append(f"关键位{mc['keys']}") + if mc["orders"]: + mon.append(f"监控单{mc['orders']}") + mon_txt = f";监控 {'/'.join(mon)}" if mon else "" + lines.append( + f"- {ac.get('name')}:{pos_txt} | 今日盈亏{st.get('total_pnl_u')}U " + f"({st.get('closed_count')}笔) | 资金{_fmt_fund(ac.get('funding_usdt'))} " + f"交易{_fmt_fund(ac.get('trading_usdt'))}{mon_txt}" + ) + trades = ac.get("trades") or [] + if trades: + for t in trades[:4]: + lines.append(f" · {_format_trade_line(t)}") + if len(trades) > 4: + lines.append(f" · …共{len(trades)}笔今日平仓") + positions = ac.get("positions") or [] + hub_mon = ac.get("hub_monitor") + for p in positions[:4]: + if not isinstance(p, dict): + continue + lines.append(f" · {_format_position_detail_line(p, hub_mon).lstrip(' - ')}") + return "\n".join(lines) + + +def format_chat_context_for_chat( + payload: dict, + max_chars: int = CHAT_CONTEXT_MAX_CHARS, +) -> str: + overview = format_chat_position_overview(payload) + body = format_chat_context_slim(payload) + text = overview + "\n\n" + body + if len(text) <= max_chars: + return text + budget = max(2000, max_chars - len(overview) - 4) + return overview + "\n\n" + body[:budget].rstrip() + "…" + + +def format_chat_context_brief( + payload: dict, + max_chars: int = CHAT_CONTEXT_MAX_CHARS, +) -> str: + return format_chat_context_for_chat(payload, max_chars=max_chars) diff --git a/manual_trading_hub/hub_ai/fund_history.py b/manual_trading_hub/hub_ai/fund_history.py new file mode 100644 index 0000000..c45d9c8 --- /dev/null +++ b/manual_trading_hub/hub_ai/fund_history.py @@ -0,0 +1,18 @@ +"""中控 AI:分户资金快照(委托 hub_fund_history_lib,保留 180 交易日).""" +from __future__ import annotations + +from typing import Any, Optional + +from lib.hub.hub_fund_history_lib import ( + FUND_HISTORY_DAYS, + format_fund_history_text, + get_fund_history, + record_fund_snapshot, +) + +__all__ = [ + "FUND_HISTORY_DAYS", + "format_fund_history_text", + "get_fund_history", + "record_fund_snapshot", +] diff --git a/manual_trading_hub/hub_ai/prompts.py b/manual_trading_hub/hub_ai/prompts.py new file mode 100644 index 0000000..2e64b90 --- /dev/null +++ b/manual_trading_hub/hub_ai/prompts.py @@ -0,0 +1,244 @@ +"""中控 AI 提示词(与实例 ai_review 分离).""" + +SUMMARY_SYSTEM = """ +你是多账户加密货币合约交易的台账助手.只根据用户提供的结构化数据输出中文 Markdown,语气克制,偏冷,客观,像值班记录. + +硬性规则: +- 只能陈述数据中明确出现的数字与事实;禁止编造成交,止损,扛单,行情预测. +- 上下文仅含「今日」一个交易日的平仓,持仓与监控;不得引用昨日,历史走势或数据里未出现的账户名. +- 未监控的账户必须标注「未监控」,不得臆测其盈亏. +- 连接失败或数据缺失的账户如实写明,不要猜测. +- 趋势回调计划,顺势加仓,关键位监控,进行中的下单监控:仅据数据列示,无则写「无」. +- 第1~4节保持客观台账;**第5节操作建议**可基于当日资金账户/交易账户余额,仓位与监控单,给出简短,可执行的资金与仓位安排建议(仍禁止预测涨跌,保证收益). +- 禁止输出 pipe 分隔的 Markdown 表格或「详细数据支持」附录;禁止夸张词(致命,崩溃,灾难等). + +输出格式(Markdown,标题必须一致): +**今日交易总结({trading_day})** + +**1. 总览** +- **合计盈亏(U)**:今日平仓合计 … +- **平仓笔数**:今日 …(胜 / 负 / 平) +- **当前持仓浮盈亏(U)**:… +- **资金合计**:资金账户 … / 交易账户 …(仅已监控且有数据账户) + +**2. 分户明细** +中控页面会自动渲染分户表格,本节不要输出 pipe 分隔行或 Markdown 表格;可写一句「见下表」或直接留空. + +**3. 需关注** +仅有依据时列出(亏损,浮亏,监控/趋势/关键位异常,资金缺口等);若无则写「无」. + +**4. 数据说明** +列出数据缺口(某户未启用,接口失败等). + +**5. 操作建议** +基于各户当日资金账户与交易账户余额,持仓与监控单,给出 2~5 条简短建议(如:是否需要从资金账户补充交易账户,哪户风险敞口偏高等).无依据则写「暂无」. +""".strip() + + +CHAT_SYSTEM = """ +你是和用户一起盯盘的老搭档交易员,熟悉他多个交易所账户的分工.用中文,口语化,短句交流. + +语气要求: +- 先理解对方的压力和情绪,再轻轻帮他把事想清楚(安慰,体贴). +- 可以指出执行或心态上的偏差点,但用商量,陪伴的口吻,绝不用教育,训诫,上课,列清单式说教. +- 不要「第1点第2点你应该…」;不要「作为你的教练我必须…」. +- 不预测涨跌,不保证收益,不替用户做决定. +- 只能依据提供的监控与交易数据说话;看不到的就说「我这边看不到,你可以去 xx 实例页确认」. +- **持仓判定**:只有快照里「实盘持仓总览 / 持仓明细 / 交易所实盘」才算已开仓;「空仓 / 0 仓」就是没仓位.浮盈亏 0U 且空仓时,不要说「还有仓」「卡着不动」. +- **监控单 ≠ 持仓**:趋势回调,关键位,顺势加仓,下单监控是本地计划或挂单监控,用户说已平仓时,即使还有这些监控,也不要当成手里还有仓. +- 用户口述与快照冲突时,以快照为准并口语说明「我这边看到是空仓/有N仓」. +- 若附带「今日总结摘要」,那是较早生成的缓存,**实盘持仓以【当前多账户快照】里的「实盘持仓总览」为准**,摘要里若提到持仓可能已过时. +- 若用户上传图片,可结合图中可见信息讨论,看不清的明确说看不清. +- **优先接住【用户现在说】和【对话核心摘要】**:用户聊心态,悔单,某笔操作时,先顺着这个话题回应,不要每句都复述账户资金数字. +- **接续对话**:有【对话核心摘要】时须接着聊,不要重复开场白;整段回复必须写完,以句号/问号/感叹号收尾,不得停在半句话;编号列表每条单独一行. +- **止盈止损**:持仓明细若出现「止损xxx / 止盈xxx」,表示交易所条件单或监控计划里已有价位,勿再暗示用户「没挂止损/没设止盈」.仅当明细写「止损=未检测到」且无对应监控 SL 时,才可讨论补止损.趋势持仓「止盈=程序监控」表示由程序盯止盈,不是没止盈. +- 快照里的盈亏/资金仅在需要核对事实时引用;用户口述与快照冲突时,以快照为准并口语说明. +""".strip() + + +def build_summary_user_prompt(context_text: str, trading_day: str) -> str: + return f""" +交易日(今日):{trading_day} + +以下为中控聚合的多账户数据(仅今日平仓,持仓,趋势回调/顺势加仓/关键位/监控单): + +{context_text} +""".strip() + + +CHAT_GENERAL_SYSTEM = """ +你是简洁,友好的中文助手,陪用户闲聊,答疑,整理思路. + +规则: +- 口语化,自然,不要列清单式说教,不要「作为 AI 我必须…」. +- 用户未主动聊交易时,不要主动扯合约,仓位,盈亏,盯盘. +- 你没有接入用户的交易账户数据;不要编造持仓,资金或监控状态.若被问到交易事实,说明这边看不到实盘,建议去中控监控区或实例页查看. +- 若用户上传图片或文档,结合可见内容回应;看不清的直说. +- 接续【对话核心摘要】,不要重复开场白;回复须写完整,以句号/问号/感叹号收尾. +""".strip() + + +ROLLING_SUMMARY_TRADING_SYSTEM = """ +你是交易教练的对话记录员.把「此前摘要」与「本轮用户+教练回复」压成一条极短中文摘要. + +要求: +- 120~280 字,纯文本一段,不要标题,不要列表,不要寒暄. +- 只保留:用户情绪/困扰,涉及的交易事实,教练核心建议,已达成的共识,待跟进事项. +- 禁止编造未出现的信息;数字与账户名须来自原文. +""".strip() + + +ROLLING_SUMMARY_GENERAL_SYSTEM = """ +你是对话记录员.把「此前摘要」与「本轮用户+助手回复」压成一条极短中文摘要. + +要求: +- 100~240 字,纯文本一段,不要标题,不要列表. +- 只保留:话题,用户诉求,助手给出的关键信息,待跟进事项. +""".strip() + + +def build_rolling_summary_user_prompt( + *, + prior_summary: str, + user_text: str, + assistant_text: str, +) -> str: + parts: list[str] = [] + if prior_summary.strip(): + parts.extend(["【此前摘要】", prior_summary.strip()]) + parts.extend([ + "【本轮用户】", + user_text.strip() or "(空)", + "【本轮教练/助手】", + assistant_text.strip() or "(空)", + "请输出更新后的对话核心摘要:", + ]) + return "\n\n".join(parts) + + +def build_general_chat_user_prompt( + *, + rolling_summary: str = "", + history_lines: str = "", + user_message: str, + attachment_note: str = "", +) -> str: + parts: list[str] = [] + if rolling_summary.strip(): + parts.extend(["【对话核心摘要(须接续,勿重复开场)】", rolling_summary.strip()]) + elif history_lines.strip(): + parts.extend(["【最近对话】", history_lines.strip()]) + if attachment_note.strip(): + parts.extend(["【用户附件说明】", attachment_note.strip()]) + parts.extend(["【用户现在说(优先回应这一条)】", user_message.strip()]) + return "\n\n".join(parts) + + +def build_chat_user_prompt( + *, + context_text: str, + trading_day: str, + summary_excerpt: str, + rolling_summary: str = "", + history_lines: str = "", + user_message: str, + attachment_note: str = "", +) -> str: + parts = [f"【交易日】{trading_day}"] + if rolling_summary.strip(): + parts.extend(["【对话核心摘要(须接续,勿重复开场)】", rolling_summary.strip()]) + elif history_lines.strip(): + parts.extend(["【最近对话】", history_lines.strip()]) + parts.extend([ + "【当前多账户快照(事实参考;持仓以「实盘持仓总览」为准)】", + context_text.strip() or "(无监控数据)", + ]) + if summary_excerpt.strip(): + parts.extend([ + "【今日总结摘要(可能滞后,持仓以快照为准)】", + summary_excerpt.strip(), + ]) + if attachment_note.strip(): + parts.extend(["【用户附件说明】", attachment_note.strip()]) + parts.extend(["【用户现在说(优先回应这一条)】", user_message.strip()]) + return "\n\n".join(parts) + + +ARCHIVE_QUOTE_REVIEW_INSTRUCTION = """ +【任务】用户从内照明心提交了一条复盘语录,并附上该交易日的档案交易记录(界面「复盘语录」下方也会展示当日已平仓明细). +请结合语录与交易记录: +1) 帮他核对自述与操作事实是否一致; +2) 指出心态,纪律,执行上的偏差点(若有); +3) 给出可落地的改进建议. +语气沿用交易教练:体贴,口语,短句,不用说教式清单;不预测涨跌,不保证收益. +""".strip() + + +SUPERVISOR_SYSTEM = """ +你是交易监管值班员,职责是防止过度交易与频繁手动操作.用中文,短句,克制语气. + +规则: +- 只依据提供的结构化事件与账户快照说话;禁止预测涨跌,保证收益. +- **手动平仓,中控平仓,新开仓**:指出频率,间隔,是否偏急;提醒休息,不训斥. +- **程序止盈/程序止损**:肯定按计划执行,鼓励保持纪律,提醒别立刻反手再开. +- 不替用户做决定,不暗示绕过实例冷静期/日冻结. +- 每次 1~3 句,必须写完整;禁止长清单和「第1点第2点」. +- 实例已进入冷静期/日冻结时,明确说明状态,建议暂停手动开平. +""".strip() + + +def build_supervisor_ai_prompt( + *, + context_text: str, + trading_day: str, + event: dict, + warnings: list[dict], +) -> str: + warn_lines = "\n".join(f"- {w.get('message')}" for w in (warnings or []) if w.get("message")) + parts = [ + f"【交易日】{trading_day}", + "【监管事件】", + str(event or {}), + "【当前多账户快照】", + (context_text or "(无)").strip(), + ] + if warn_lines.strip(): + parts.extend(["【已触发频率警告】", warn_lines.strip()]) + parts.append("请给出 1~3 句监管评语:") + return "\n\n".join(parts) + + +def build_supervisor_chat_prompt( + *, + context_text: str, + trading_day: str, + history_lines: str, + user_message: str, +) -> str: + parts = [f"【交易日】{trading_day}"] + if history_lines.strip(): + parts.extend(["【今日监管对话】", history_lines.strip()]) + parts.extend([ + "【当前多账户快照】", + (context_text or "(无)").strip(), + "【用户现在说】", + user_message.strip(), + ]) + return "\n\n".join(parts) + + +def build_archive_quote_review_prompt( + *, + quote_date: str, + archive_trades_text: str, + user_message: str, +) -> str: + parts = [ + f"【复盘交易日】{quote_date}", + ARCHIVE_QUOTE_REVIEW_INSTRUCTION, + "【该日交易记录(内照明心档案,与界面「当日已平仓」一致)】", + (archive_trades_text or "(该日无交易记录)").strip(), + "【用户复盘语录(对话框已展示,请优先回应)】", + user_message.strip(), + ] + return "\n\n".join(parts) diff --git a/manual_trading_hub/hub_ai/rolling_summary.py b/manual_trading_hub/hub_ai/rolling_summary.py new file mode 100644 index 0000000..a40bb12 --- /dev/null +++ b/manual_trading_hub/hub_ai/rolling_summary.py @@ -0,0 +1,69 @@ +"""聊天滚动摘要:每轮后压缩历史,续聊只带摘要 + 当前消息.""" +from __future__ import annotations + +from hub_ai.text_util import clip_text, is_ai_error_reply +from hub_ai.client import generate_text +from hub_ai.config import ( + CHAT_ROLLING_SUMMARY_GEN_MAX_TOKENS, + CHAT_ROLLING_SUMMARY_MAX_CHARS, + CHAT_ROLLING_SUMMARY_TEMPERATURE, +) +from hub_ai.prompts import ( + ROLLING_SUMMARY_GENERAL_SYSTEM, + ROLLING_SUMMARY_TRADING_SYSTEM, + build_rolling_summary_user_prompt, +) +from hub_ai.store import CHAT_BOT_GENERAL, update_session_rolling_summary + + +def refresh_session_rolling_summary( + session_id: str, + *, + prior_summary: str, + user_text: str, + assistant_text: str, + bot_mode: str, +) -> str: + """合并旧摘要与本轮对话,生成新的短摘要并写入会话.""" + user_clip = clip_text(user_text, 1200) + assistant_clip = clip_text(assistant_text, 1800) + if not user_clip and not assistant_clip: + summary = clip_text(prior_summary, CHAT_ROLLING_SUMMARY_MAX_CHARS) + update_session_rolling_summary(session_id, summary) + return summary + + system = ( + ROLLING_SUMMARY_GENERAL_SYSTEM + if (bot_mode or "").strip().lower() == CHAT_BOT_GENERAL + else ROLLING_SUMMARY_TRADING_SYSTEM + ) + raw = generate_text( + system=system, + user=build_rolling_summary_user_prompt( + prior_summary=prior_summary, + user_text=user_clip, + assistant_text=assistant_clip, + ), + temperature=CHAT_ROLLING_SUMMARY_TEMPERATURE, + max_tokens=CHAT_ROLLING_SUMMARY_GEN_MAX_TOKENS, + max_continuations=1, + ) + if is_ai_error_reply(raw): + fallback = _fallback_summary(prior_summary, user_clip, assistant_clip) + update_session_rolling_summary(session_id, fallback) + return fallback + + summary = clip_text(raw, CHAT_ROLLING_SUMMARY_MAX_CHARS) + update_session_rolling_summary(session_id, summary) + return summary + + +def _fallback_summary(prior: str, user_text: str, assistant_text: str) -> str: + parts: list[str] = [] + if prior.strip(): + parts.append(prior.strip()) + if user_text.strip(): + parts.append(f"用户:{clip_text(user_text, 200)}") + if assistant_text.strip(): + parts.append(f"教练:{clip_text(assistant_text, 280)}") + return clip_text("\n".join(parts), CHAT_ROLLING_SUMMARY_MAX_CHARS) diff --git a/manual_trading_hub/hub_ai/routes.py b/manual_trading_hub/hub_ai/routes.py new file mode 100644 index 0000000..9ca7f0a --- /dev/null +++ b/manual_trading_hub/hub_ai/routes.py @@ -0,0 +1,200 @@ +"""中控 AI FastAPI 路由.""" +from __future__ import annotations + +import asyncio +from typing import Callable + +from fastapi import APIRouter, Body, File, Form, HTTPException, UploadFile +from pydantic import BaseModel, Field + +from hub_ai.archive_quote import send_archive_quote_review +from hub_ai.chat import ( + get_chat_state, + remove_chat_session, + send_chat_message, + start_new_chat, + switch_chat_session, +) +from hub_ai.client import model_label +from hub_ai.config import trading_day_reset_hour +from hub_ai.context import build_daily_context +from hub_ai.store import get_latest_summary, list_summaries +from hub_ai.supervisor import send_supervisor_chat +from hub_ai.supervisor_store import get_supervisor_session_state +from hub_ai.summary import generate_daily_summary +from lib.hub.hub_trades_lib import current_trading_day +from settings_store import normalize_supervisor_settings + + +class ChatSendBody(BaseModel): + message: str = "" + trading_day: str = "" + + +class SummaryGenerateBody(BaseModel): + trading_day: str = "" + force: bool = False + + +class ChatNewBody(BaseModel): + trading_day: str = "" + bot_mode: str = "trading" + + +class ChatSwitchBody(BaseModel): + session_id: str = Field(..., min_length=1) + + +class ArchiveQuoteChatBody(BaseModel): + quote_date: str = "" + content: str = "" + + +class SupervisorChatBody(BaseModel): + message: str = "" + trading_day: str = "" + + +def create_hub_ai_router(*, load_all_exchanges: Callable[[], list]) -> APIRouter: + router = APIRouter(prefix="/api/ai", tags=["hub-ai"]) + + def _day(raw: str = "") -> str: + d = (raw or "").strip()[:10] + return d or current_trading_day(reset_hour=trading_day_reset_hour()) + + @router.get("/meta") + def api_ai_meta(): + return { + "ok": True, + "model": model_label(), + "trading_day_reset_hour": trading_day_reset_hour(), + "trading_day": current_trading_day(reset_hour=trading_day_reset_hour()), + "storage": { + "summaries": "hub_ai_summaries.json", + "chat": "hub_ai_chat.json", + }, + } + + @router.get("/context") + def api_ai_context(trading_day: str = ""): + exchanges = load_all_exchanges() + ctx = build_daily_context(exchanges, trading_day=_day(trading_day)) + return {"ok": True, **ctx} + + @router.get("/summary") + def api_ai_summary_list(trading_day: str = ""): + day = _day(trading_day) if trading_day.strip() else "" + items = list_summaries(trading_day=day or None, limit=20) + latest = get_latest_summary(_day(trading_day)) if trading_day.strip() else ( + items[0] if items else None + ) + return { + "ok": True, + "trading_day": _day(trading_day) if trading_day.strip() else None, + "summaries": items, + "latest": latest, + "model": model_label(), + } + + @router.post("/summary/generate") + def api_ai_summary_generate(body: SummaryGenerateBody = SummaryGenerateBody()): + exchanges = load_all_exchanges() + result = generate_daily_summary( + exchanges, + trading_day=_day(body.trading_day) if body.trading_day.strip() else None, + force=bool(body.force), + ) + if not result.get("ok"): + raise HTTPException(status_code=502, detail=result.get("msg") or "生成失败") + result.pop("context", None) + return result + + @router.get("/chat/session") + def api_ai_chat_session(): + state = get_chat_state() + return {"ok": True, **state, "model": model_label()} + + @router.post("/chat/new") + def api_ai_chat_new(body: ChatNewBody = ChatNewBody()): + day = _day(body.trading_day) + return start_new_chat(trading_day=day, bot_mode=body.bot_mode or "trading") + + @router.post("/chat/switch") + def api_ai_chat_switch(body: ChatSwitchBody): + try: + return switch_chat_session(body.session_id.strip()) + except KeyError: + raise HTTPException(status_code=404, detail="会话不存在") + + @router.delete("/chat/session/{session_id}") + def api_ai_chat_delete(session_id: str): + result = remove_chat_session(session_id.strip()) + if not result.get("ok"): + raise HTTPException(status_code=404, detail="会话不存在") + return result + + @router.post("/chat/archive-quote") + def api_ai_chat_archive_quote(body: ArchiveQuoteChatBody = Body(...)): + result = send_archive_quote_review( + quote_date=body.quote_date, + content=body.content, + ) + if not result.get("ok"): + raise HTTPException(status_code=502, detail=result.get("msg") or "发送失败") + return result + + @router.post("/chat/send") + async def api_ai_chat_send( + message: str = Form(""), + trading_day: str = Form(""), + files: list[UploadFile] = File(default=[]), + ): + exchanges = load_all_exchanges() + raw_attachments = [] + for f in files or []: + if not f or not f.filename: + continue + data = await f.read() + raw_attachments.append( + { + "filename": f.filename, + "content_type": f.content_type or "", + "data": data, + } + ) + result = await asyncio.to_thread( + send_chat_message, + exchanges, + message, + trading_day=_day(trading_day) if trading_day.strip() else None, + raw_attachments=raw_attachments, + ) + if not result.get("ok"): + raise HTTPException(status_code=502, detail=result.get("msg") or "发送失败") + return result + + @router.get("/supervisor/session") + def api_ai_supervisor_session(trading_day: str = ""): + day = _day(trading_day) + return get_supervisor_session_state(day) + + @router.get("/supervisor/rules") + def api_ai_supervisor_rules(): + from settings_store import load_settings + + cfg = normalize_supervisor_settings(load_settings().get("supervisor")) + return {"ok": True, "supervisor": cfg} + + @router.post("/supervisor/chat/send") + def api_ai_supervisor_chat_send(body: SupervisorChatBody = SupervisorChatBody()): + exchanges = load_all_exchanges() + result = send_supervisor_chat( + exchanges, + body.message, + trading_day=_day(body.trading_day) if body.trading_day.strip() else None, + ) + if not result.get("ok"): + raise HTTPException(status_code=502, detail=result.get("msg") or "发送失败") + return result + + return router diff --git a/manual_trading_hub/hub_ai/store.py b/manual_trading_hub/hub_ai/store.py new file mode 100644 index 0000000..c7ded23 --- /dev/null +++ b/manual_trading_hub/hub_ai/store.py @@ -0,0 +1,302 @@ +"""中控 AI:JSON 持久化(与 hub_settings.json 同目录).""" +from __future__ import annotations + +import json +import os +import uuid +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any, Optional + +from hub_ai.config import CHAT_SESSION_RETENTION_DAYS, SUMMARY_RETENTION_DAYS + +HUB_DIR = Path(__file__).resolve().parent.parent +SUMMARIES_PATH = HUB_DIR / "hub_ai_summaries.json" +CHAT_PATH = HUB_DIR / "hub_ai_chat.json" + + +def _now_str() -> str: + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def _atomic_write(path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + os.replace(tmp, path) + + +def _load_json(path: Path, default: dict) -> dict: + if not path.is_file(): + return dict(default) + try: + loaded = json.loads(path.read_text(encoding="utf-8")) + if isinstance(loaded, dict): + return loaded + except Exception: + pass + return dict(default) + + +def _prune_summaries(items: list, *, keep_days: int) -> list: + cutoff = (datetime.now() - timedelta(days=max(1, keep_days))).strftime("%Y-%m-%d") + out = [x for x in items if str(x.get("trading_day") or "") >= cutoff] + return out[-500:] + + +def _prune_chat_sessions(sessions: list, *, keep_days: int) -> list: + cutoff_dt = datetime.now() - timedelta(days=max(1, keep_days)) + out = [] + for s in sessions: + ts = str(s.get("updated_at") or s.get("created_at") or "") + try: + dt = datetime.strptime(ts[:19], "%Y-%m-%d %H:%M:%S") + except ValueError: + out.append(s) + continue + if dt >= cutoff_dt: + out.append(s) + return out[-50:] + + +def load_summaries_store() -> dict: + return _load_json(SUMMARIES_PATH, {"version": 1, "summaries": []}) + + +def save_summaries_store(data: dict) -> None: + summaries = _prune_summaries( + list(data.get("summaries") or []), + keep_days=SUMMARY_RETENTION_DAYS, + ) + _atomic_write(SUMMARIES_PATH, {"version": 1, "summaries": summaries}) + + +def append_summary( + *, + trading_day: str, + content_md: str, + model: str, + context_hash: str, + stats_snapshot: dict, +) -> dict: + store = load_summaries_store() + row = { + "id": uuid.uuid4().hex, + "trading_day": trading_day, + "generated_at": _now_str(), + "model": model, + "context_hash": context_hash, + "content_md": content_md, + "stats_snapshot": stats_snapshot, + } + store.setdefault("summaries", []).append(row) + save_summaries_store(store) + return row + + +def list_summaries(*, trading_day: Optional[str] = None, limit: int = 30) -> list[dict]: + store = load_summaries_store() + items = list(store.get("summaries") or []) + if trading_day: + items = [x for x in items if str(x.get("trading_day")) == trading_day] + items.sort(key=lambda x: str(x.get("generated_at") or ""), reverse=True) + return items[: max(1, min(limit, 100))] + + +def get_latest_summary(trading_day: str) -> Optional[dict]: + rows = list_summaries(trading_day=trading_day, limit=1) + return rows[0] if rows else None + + +def load_chat_store() -> dict: + default = {"version": 1, "sessions": [], "active_session_id": None} + data = _load_json(CHAT_PATH, default) + data.setdefault("version", 1) + data.setdefault("sessions", []) + return data + + +def save_chat_store(data: dict) -> None: + sessions = _prune_chat_sessions( + list(data.get("sessions") or []), + keep_days=CHAT_SESSION_RETENTION_DAYS, + ) + active = data.get("active_session_id") + ids = {str(s.get("id")) for s in sessions} + if active and str(active) not in ids: + active = sessions[-1]["id"] if sessions else None + _atomic_write( + CHAT_PATH, + {"version": 1, "sessions": sessions, "active_session_id": active}, + ) + + +def get_active_session() -> Optional[dict]: + store = load_chat_store() + sid = store.get("active_session_id") + for s in store.get("sessions") or []: + if str(s.get("id")) == str(sid): + return s + return None + + +CHAT_BOT_TRADING = "trading" +CHAT_BOT_GENERAL = "general" +CHAT_BOT_SUPERVISOR = "supervisor" +CHAT_BOT_MODES = frozenset({CHAT_BOT_TRADING, CHAT_BOT_GENERAL, CHAT_BOT_SUPERVISOR}) + + +def _normalize_bot_mode(raw: Any) -> str: + mode = (raw or CHAT_BOT_TRADING).strip().lower() + return mode if mode in CHAT_BOT_MODES else CHAT_BOT_TRADING + + +def create_new_session( + *, + trading_day: str, + title: str = "新对话", + bot_mode: str = CHAT_BOT_TRADING, +) -> dict: + store = load_chat_store() + session = { + "id": uuid.uuid4().hex, + "trading_day": trading_day, + "title": title, + "bot_mode": _normalize_bot_mode(bot_mode), + "created_at": _now_str(), + "updated_at": _now_str(), + "messages": [], + "rolling_summary": "", + } + store.setdefault("sessions", []).append(session) + store["active_session_id"] = session["id"] + save_chat_store(store) + return session + + +def ensure_active_session(*, trading_day: str) -> dict: + active = get_active_session() + if active: + return active + return create_new_session(trading_day=trading_day) + + +def update_session_rolling_summary(session_id: str, summary: str) -> dict: + store = load_chat_store() + target = None + for s in store.get("sessions") or []: + if str(s.get("id")) == str(session_id): + target = s + break + if not target: + raise KeyError("session_not_found") + target["rolling_summary"] = str(summary or "").strip() + target["updated_at"] = _now_str() + store["active_session_id"] = target["id"] + save_chat_store(store) + return target + + +def append_chat_message( + session_id: str, + role: str, + content: str, + *, + attachments: Optional[list] = None, +) -> dict: + store = load_chat_store() + sessions = store.get("sessions") or [] + target = None + for s in sessions: + if str(s.get("id")) == str(session_id): + target = s + break + if not target: + raise KeyError("session_not_found") + msg = {"role": role, "content": content.strip(), "at": _now_str()} + if attachments: + msg["attachments"] = list(attachments) + target.setdefault("messages", []).append(msg) + target["updated_at"] = _now_str() + if role == "user" and (target.get("title") in (None, "", "新对话")): + title = content.strip().replace("\n", " ")[:24] + if title: + target["title"] = title + store["active_session_id"] = target["id"] + save_chat_store(store) + return target + + +def _session_list_item(s: dict, *, active_id: Optional[str]) -> dict: + msgs = s.get("messages") or [] + preview = "" + for m in reversed(msgs): + if m.get("role") == "user": + preview = str(m.get("content") or "").replace("\n", " ")[:48] + break + if not preview and msgs: + last = msgs[-1] + preview = str(last.get("content") or "").replace("\n", " ")[:48] + sid = str(s.get("id") or "") + return { + "id": sid, + "title": s.get("title") or "新对话", + "bot_mode": _normalize_bot_mode(s.get("bot_mode")), + "trading_day": s.get("trading_day"), + "created_at": s.get("created_at"), + "updated_at": s.get("updated_at"), + "message_count": len(msgs), + "preview": preview, + "is_active": sid and sid == str(active_id or ""), + } + + +def list_chat_sessions(*, limit: int = 50) -> list[dict]: + store = load_chat_store() + active_id = store.get("active_session_id") + sessions = list(store.get("sessions") or []) + for s in sessions: + s.setdefault("bot_mode", CHAT_BOT_TRADING) + sessions.sort(key=lambda x: str(x.get("updated_at") or ""), reverse=True) + return [_session_list_item(s, active_id=active_id) for s in sessions[: max(1, min(limit, 100))]] + + +def set_active_session(session_id: str) -> dict: + store = load_chat_store() + target = None + for s in store.get("sessions") or []: + if str(s.get("id")) == str(session_id): + target = s + break + if not target: + raise KeyError("session_not_found") + target.setdefault("bot_mode", CHAT_BOT_TRADING) + store["active_session_id"] = target["id"] + save_chat_store(store) + return target + + +def delete_chat_session(session_id: str) -> tuple[bool, Optional[str]]: + store = load_chat_store() + sessions = list(store.get("sessions") or []) + new_sessions = [s for s in sessions if str(s.get("id")) != str(session_id)] + if len(new_sessions) == len(sessions): + return False, None + active = store.get("active_session_id") + new_active = active + if str(active) == str(session_id): + new_active = new_sessions[0]["id"] if new_sessions else None + store["sessions"] = new_sessions + store["active_session_id"] = new_active + save_chat_store(store) + return True, new_active + + +def summary_excerpt_for_chat(trading_day: str, max_chars: int = 600) -> str: + latest = get_latest_summary(trading_day) + if not latest: + return "" + text = str(latest.get("content_md") or "").strip() + if len(text) <= max_chars: + return text + return text[: max_chars - 3].rstrip() + "..." diff --git a/manual_trading_hub/hub_ai/summary.py b/manual_trading_hub/hub_ai/summary.py new file mode 100644 index 0000000..ff00185 --- /dev/null +++ b/manual_trading_hub/hub_ai/summary.py @@ -0,0 +1,95 @@ +"""中控 AI:今日总结生成.""" +from __future__ import annotations + +from typing import Any + +from hub_ai.client import generate_text, model_label +from hub_ai.context import ( + build_daily_context, + collect_closed_trades_snapshot, + format_account_remark, + format_summary_context_text, + summary_context_hash, +) +from hub_ai.prompts import SUMMARY_SYSTEM, build_summary_user_prompt +from hub_ai.store import append_summary, get_latest_summary, list_summaries + + +def _stats_snapshot_from_ctx(ctx: dict) -> dict: + day = ctx.get("trading_day") + accounts = ctx.get("accounts") or [] + return { + "totals": ctx.get("totals"), + "closed_trades": collect_closed_trades_snapshot(accounts, today=day), + "by_account": { + str(ac.get("key") or ac.get("id")): { + "key": ac.get("key"), + "name": ac.get("name"), + "status": ac.get("status"), + "funding_usdt": ac.get("funding_usdt"), + "trading_usdt": ac.get("trading_usdt"), + "available_trading_usdt": ac.get("available_trading_usdt"), + "pnl_u": (ac.get("trade_stats") or {}).get("total_pnl_u"), + "closed_count": (ac.get("trade_stats") or {}).get("closed_count"), + "float_pnl_u": ac.get("float_pnl_u"), + "remark": format_account_remark(ac), + "monitor_lines": ac.get("monitor_lines") or {}, + "issues": ac.get("issues") or [], + } + for ac in accounts + }, + } + + +def generate_daily_summary( + exchanges: list[dict], + *, + trading_day: str | None = None, + force: bool = False, +) -> dict[str, Any]: + ctx = build_daily_context(exchanges, trading_day=trading_day) + day = ctx["trading_day"] + summary_payload = { + "trading_day": day, + "totals": ctx.get("totals"), + "accounts": ctx.get("accounts"), + } + summary_text = format_summary_context_text(summary_payload) + digest = summary_context_hash(summary_payload) + if not force: + latest = get_latest_summary(day) + if latest and latest.get("context_hash") == digest: + return { + "ok": True, + "cached": True, + "trading_day": day, + "summary": latest, + "model": latest.get("model") or model_label(), + } + + system = SUMMARY_SYSTEM.replace("{trading_day}", day) + user = build_summary_user_prompt(summary_text, day) + content = generate_text(system=system, user=user, temperature=0.15) + if content.startswith("AI 调用失败"): + return {"ok": False, "msg": content, "trading_day": day} + + stats_snapshot = _stats_snapshot_from_ctx(ctx) + row = append_summary( + trading_day=day, + content_md=content, + model=model_label(), + context_hash=digest, + stats_snapshot=stats_snapshot, + ) + return { + "ok": True, + "cached": False, + "trading_day": day, + "summary": row, + "model": model_label(), + "context": ctx, + } + + +def summary_list(trading_day: str | None = None) -> list[dict]: + return list_summaries(trading_day=trading_day) diff --git a/manual_trading_hub/hub_ai/supervisor.py b/manual_trading_hub/hub_ai/supervisor.py new file mode 100644 index 0000000..e2ef31f --- /dev/null +++ b/manual_trading_hub/hub_ai/supervisor.py @@ -0,0 +1,125 @@ +"""交易监管:AI 评语与用户回聊.""" +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any, Optional + +_REPO_ROOT = Path(__file__).resolve().parents[2] +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) + +from lib.ai.ai_client import ai_generate # noqa: E402 + +from hub_ai.client import generate_text, model_label +from hub_ai.config import ( + CHAT_MAX_OUTPUT_TOKENS, + CHAT_TEMPERATURE, + trading_day_reset_hour, +) +from hub_ai.context import build_chat_context, format_chat_context_for_chat, format_chat_position_overview +from hub_ai.prompts import SUPERVISOR_SYSTEM, build_supervisor_ai_prompt, build_supervisor_chat_prompt +from hub_ai.supervisor_store import ( + append_supervisor_ai_message, + ensure_supervisor_session, + get_supervisor_session_state, +) +from hub_ai.store import append_chat_message +from hub_ai.text_util import is_ai_error_reply +from hub_supervisor_lib import build_supervisor_fallback_reply +from lib.hub.hub_trades_lib import current_trading_day + +SUPERVISOR_AI_MAX_TOKENS = 320 + + +def generate_supervisor_ai_reply( + *, + event: dict, + warnings: list[dict], + trading_day: str, + session_id: str, + exchanges: list[dict], +) -> str: + ctx = build_chat_context(exchanges, trading_day=trading_day) + brief = format_chat_position_overview(ctx) + "\n" + format_chat_context_for_chat( + ctx, max_chars=2400 + ) + user_prompt = build_supervisor_ai_prompt( + context_text=brief, + trading_day=trading_day, + event=event, + warnings=warnings, + ) + prompt = f"{SUPERVISOR_SYSTEM.strip()}\n\n---\n\n{user_prompt.strip()}" + text = ai_generate(prompt, temperature=0.35, max_tokens=SUPERVISOR_AI_MAX_TOKENS) + text = str(text or "").strip() + if not text or is_ai_error_reply(text): + return build_supervisor_fallback_reply(event, warnings) + return text + + +def make_supervisor_ai_reply_fn(exchanges: list[dict]): + def _fn(*, event: dict, warnings: list[dict], trading_day: str, session_id: str) -> str: + return generate_supervisor_ai_reply( + event=event, + warnings=warnings or [], + trading_day=trading_day, + session_id=session_id, + exchanges=exchanges, + ) + + return _fn + + +def send_supervisor_chat( + exchanges: list[dict], + message: str, + *, + trading_day: str | None = None, +) -> dict[str, Any]: + text = (message or "").strip() + if not text: + return {"ok": False, "msg": "消息不能为空"} + day = (trading_day or "").strip()[:10] or current_trading_day( + reset_hour=trading_day_reset_hour() + ) + session = ensure_supervisor_session(day) + sid = str(session.get("id") or "") + prior = session.get("messages") or [] + ctx = build_chat_context(exchanges, trading_day=day) + brief = format_chat_context_for_chat(ctx, max_chars=6000) + recent = [] + for m in prior[-8:]: + role = m.get("role") + if role not in ("user", "assistant", "system"): + continue + label = {"user": "用户", "assistant": "监管", "system": "系统"}.get(role, role) + recent.append(f"{label}:{str(m.get('content') or '').strip()}") + user_prompt = build_supervisor_chat_prompt( + context_text=brief, + trading_day=day, + history_lines="\n".join(recent), + user_message=text, + ) + reply = generate_text( + system=SUPERVISOR_SYSTEM, + user=user_prompt, + temperature=min(0.4, CHAT_TEMPERATURE), + max_tokens=min(768, CHAT_MAX_OUTPUT_TOKENS), + max_continuations=1, + ) + reply = str(reply or "").strip() + if not reply or is_ai_error_reply(reply): + return {"ok": False, "msg": "AI 暂时不可用,请稍后再试", "session_id": sid} + append_chat_message(sid, "user", text) + session = append_supervisor_ai_message(sid, reply) + state = get_supervisor_session_state(day) + return { + "ok": True, + "trading_day": day, + "session": session, + "reply": reply, + "model": model_label(), + "message_count": state.get("message_count"), + "unread_system": state.get("unread_system"), + } diff --git a/manual_trading_hub/hub_ai/supervisor_store.py b/manual_trading_hub/hub_ai/supervisor_store.py new file mode 100644 index 0000000..49632ef --- /dev/null +++ b/manual_trading_hub/hub_ai/supervisor_store.py @@ -0,0 +1,101 @@ +"""交易监管专用会话(今日长会话,bot_mode=supervisor).""" +from __future__ import annotations + +from typing import Any, Optional + +from hub_ai.store import ( + CHAT_BOT_SUPERVISOR, + append_chat_message, + load_chat_store, + save_chat_store, +) + + +def _supervisor_title(trading_day: str) -> str: + return f"今日监管 {trading_day}" + + +def find_supervisor_session(trading_day: str) -> Optional[dict]: + day = (trading_day or "").strip()[:10] + store = load_chat_store() + for s in store.get("sessions") or []: + if str(s.get("bot_mode") or "") != CHAT_BOT_SUPERVISOR: + continue + if str(s.get("trading_day") or "") == day: + return s + return None + + +def ensure_supervisor_session(trading_day: str) -> dict: + day = (trading_day or "").strip()[:10] + existing = find_supervisor_session(day) + if existing: + return existing + store = load_chat_store() + from datetime import datetime + import uuid + + session = { + "id": uuid.uuid4().hex, + "trading_day": day, + "title": _supervisor_title(day), + "bot_mode": CHAT_BOT_SUPERVISOR, + "created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "messages": [], + "rolling_summary": "", + "supervisor_locked": True, + } + store.setdefault("sessions", []).append(session) + save_chat_store(store) + return session + + +def append_supervisor_system_message( + session_id: str, + content: str, + *, + event_type: str = "", + level: str = "info", +) -> dict: + store = load_chat_store() + target = None + for s in store.get("sessions") or []: + if str(s.get("id")) == str(session_id): + target = s + break + if not target: + raise KeyError("session_not_found") + from datetime import datetime + + msg = { + "role": "system", + "content": (content or "").strip(), + "at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "event_type": event_type, + "level": level, + } + target.setdefault("messages", []).append(msg) + target["updated_at"] = msg["at"] + save_chat_store(store) + return target + + +def append_supervisor_ai_message(session_id: str, content: str) -> dict: + return append_chat_message(session_id, "assistant", content) + + +def get_supervisor_session_state(trading_day: str) -> dict[str, Any]: + from hub_ai.client import model_label + + session = ensure_supervisor_session(trading_day) + msgs = session.get("messages") or [] + unread = sum(1 for m in msgs if m.get("role") == "system" and not m.get("read")) + return { + "ok": True, + "session": session, + "trading_day": trading_day, + "message_count": len(msgs), + "unread_system": unread, + "model": model_label(), + } diff --git a/manual_trading_hub/hub_ai/text_util.py b/manual_trading_hub/hub_ai/text_util.py new file mode 100644 index 0000000..5620320 --- /dev/null +++ b/manual_trading_hub/hub_ai/text_util.py @@ -0,0 +1,14 @@ +"""中控 AI 文本小工具.""" + + +def is_ai_error_reply(text: str) -> bool: + t = (text or "").strip() + return t.startswith("AI 调用失败") or t.startswith("AI 生成失败") + + +def clip_text(text: str, max_chars: int) -> str: + s = str(text or "").strip() + limit = max(200, int(max_chars or 0)) + if len(s) <= limit: + return s + return s[: limit - 1].rstrip() + "…" diff --git a/manual_trading_hub/hub_board_cache.py b/manual_trading_hub/hub_board_cache.py new file mode 100644 index 0000000..9593f79 --- /dev/null +++ b/manual_trading_hub/hub_board_cache.py @@ -0,0 +1,165 @@ +"""监控区 board:后台定时聚合,内存快照,SSE 版本通知.""" + +from __future__ import annotations + +import asyncio +import json +import os +from collections.abc import AsyncIterator, Awaitable, Callable +from typing import Any + +HUB_BOARD_POLL_INTERVAL = float(os.getenv("HUB_BOARD_POLL_INTERVAL", "5")) +HUB_BOARD_SSE_HEARTBEAT_SEC = float(os.getenv("HUB_BOARD_SSE_HEARTBEAT_SEC", "25")) + +BuildFn = Callable[[], Awaitable[dict[str, Any]]] + + +class MonitorBoardStore: + def __init__(self) -> None: + self._lock = asyncio.Lock() + self.version = 0 + self.payload: dict[str, Any] | None = None + self.aggregating = False + self.last_error: str | None = None + self._subscribers: list[asyncio.Queue[str | None]] = [] + self._task: asyncio.Task | None = None + self._stop = asyncio.Event() + self._refresh = asyncio.Event() + self._build_fn: BuildFn | None = None + + async def start(self, build_fn: BuildFn) -> None: + if self._task and not self._task.done(): + return + self._build_fn = build_fn + self._stop.clear() + self._task = asyncio.create_task(self._loop(), name="hub-board-poll") + + async def stop(self) -> None: + self._stop.set() + self._refresh.set() + if self._task: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + self._broadcast(close=True) + + def request_refresh(self) -> None: + self._refresh.set() + + def snapshot_dict(self) -> dict[str, Any]: + p = self.payload or {} + rows = p.get("rows") + if not isinstance(rows, list): + rows = [] + return { + "ok": p.get("ok", True) if self.payload else False, + "board_version": self.version, + "rows": rows, + "totals": p.get("totals") if isinstance(p.get("totals"), dict) else None, + "updated_at": p.get("updated_at"), + "aggregating": self.aggregating, + "error": self.last_error or p.get("error"), + "msg": p.get("msg"), + "poll_interval_sec": HUB_BOARD_POLL_INTERVAL, + } + + def event_dict(self) -> dict[str, Any]: + p = self.payload or {} + return { + "board_version": self.version, + "updated_at": p.get("updated_at"), + "aggregating": self.aggregating, + "ok": p.get("ok", True) if self.payload else False, + "error": self.last_error or p.get("error"), + } + + async def _loop(self) -> None: + assert self._build_fn is not None + while not self._stop.is_set(): + await self._aggregate_once(self._build_fn) + if self._stop.is_set(): + break + self._refresh.clear() + sleep_task = asyncio.create_task(asyncio.sleep(HUB_BOARD_POLL_INTERVAL)) + refresh_task = asyncio.create_task(self._refresh.wait()) + done, pending = await asyncio.wait( + {sleep_task, refresh_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + for t in pending: + t.cancel() + + async def _aggregate_once(self, build_fn: BuildFn) -> None: + async with self._lock: + self.aggregating = True + self._broadcast() + try: + result = await build_fn() + if not isinstance(result, dict): + result = {"ok": False, "msg": "聚合返回无效", "rows": []} + except Exception as e: + result = {"ok": False, "msg": str(e), "rows": [], "error": "aggregate_failed"} + async with self._lock: + self.version += 1 + prev_rows = (self.payload or {}).get("rows") if isinstance(self.payload, dict) else None + if result.get("ok") is False and isinstance(prev_rows, list) and prev_rows: + result = {**result, "rows": prev_rows} + self.payload = result + self.last_error = None if result.get("ok") is not False else ( + str(result.get("msg") or result.get("error") or "aggregate_failed") + ) + self.aggregating = False + self._broadcast() + + def _broadcast(self, *, close: bool = False) -> None: + dead: list[asyncio.Queue[str | None]] = [] + for q in self._subscribers: + try: + q.put_nowait(None if close else json.dumps(self.event_dict(), ensure_ascii=False)) + except asyncio.QueueFull: + try: + q.get_nowait() + except asyncio.QueueEmpty: + pass + try: + q.put_nowait(json.dumps(self.event_dict(), ensure_ascii=False)) + except asyncio.QueueFull: + dead.append(q) + except Exception: + dead.append(q) + for q in dead: + if q in self._subscribers: + self._subscribers.remove(q) + + async def iter_sse(self) -> AsyncIterator[str]: + q: asyncio.Queue[str | None] = asyncio.Queue(maxsize=32) + self._subscribers.append(q) + try: + yield _sse_frame(self.event_dict()) + while True: + try: + raw = await asyncio.wait_for(q.get(), timeout=HUB_BOARD_SSE_HEARTBEAT_SEC) + except asyncio.TimeoutError: + yield ": heartbeat\n\n" + continue + if raw is None: + break + try: + data = json.loads(raw) + except Exception: + data = self.event_dict() + yield _sse_frame(data) + finally: + if q in self._subscribers: + self._subscribers.remove(q) + + +def _sse_frame(data: dict[str, Any]) -> str: + body = json.dumps(data, ensure_ascii=False) + return f"event: board\ndata: {body}\n\n" + + +board_store = MonitorBoardStore() diff --git a/manual_trading_hub/hub_chart_cache.py b/manual_trading_hub/hub_chart_cache.py new file mode 100644 index 0000000..14a5f0e --- /dev/null +++ b/manual_trading_hub/hub_chart_cache.py @@ -0,0 +1,280 @@ +"""行情区 K 线:后台轮询订阅 + SSE 推送尾部 K 线(对齐监控区 board).""" + +from __future__ import annotations + +import asyncio +import json +import os +import time +from collections.abc import AsyncIterator, Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from hub_board_cache import board_store + +HUB_CHART_POLL_INTERVAL = float(os.getenv("HUB_CHART_POLL_INTERVAL", "5")) +HUB_CHART_SSE_HEARTBEAT_SEC = float(os.getenv("HUB_CHART_SSE_HEARTBEAT_SEC", "25")) +HUB_CHART_WATCH_TTL_SEC = float(os.getenv("HUB_CHART_WATCH_TTL_SEC", "45")) +HUB_CHART_POSITION_TIMEFRAME = (os.getenv("HUB_CHART_POSITION_TIMEFRAME", "5m") or "5m").strip() +HUB_CHART_MAX_SERIES_PER_TICK = max(1, int(os.getenv("HUB_CHART_MAX_SERIES_PER_TICK", "24"))) +HUB_CHART_SSE_TAIL_BARS = max(5, min(int(os.getenv("HUB_CHART_SSE_TAIL_BARS", "30")), 120)) + +PollFn = Callable[[], Awaitable[dict[str, Any]]] + + +def series_key(exchange_key: str, symbol: str, timeframe: str) -> str: + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + tf = (timeframe or "").strip() + return f"{ex_k}|{sym}|{tf}" + + +def parse_series_key(key: str) -> tuple[str, str, str] | None: + parts = (key or "").split("|") + if len(parts) != 3: + return None + ex_k, sym, tf = parts[0].strip().lower(), parts[1].strip().upper(), parts[2].strip() + if not ex_k or not sym or not tf: + return None + return ex_k, sym, tf + + +@dataclass +class SeriesState: + version: int = 0 + updated_at: str | None = None + fetched: int = 0 + error: str | None = None + + +class ChartPollStore: + def __init__(self) -> None: + self._lock = asyncio.Lock() + self.version = 0 + self.updated_at: str | None = None + self.polling = False + self.last_error: str | None = None + self._watch_until: dict[str, float] = {} + self._position_keys: set[str] = set() + self._series: dict[str, SeriesState] = {} + self._push_tails: dict[str, dict[str, Any]] = {} + self._subscribers: list[asyncio.Queue[str | None]] = [] + self._task: asyncio.Task | None = None + self._stop = asyncio.Event() + self._refresh = asyncio.Event() + self._poll_fn: PollFn | None = None + + async def start(self, poll_fn: PollFn) -> None: + if self._task and not self._task.done(): + return + self._poll_fn = poll_fn + self._stop.clear() + self._task = asyncio.create_task(self._loop(), name="hub-chart-poll") + + async def stop(self) -> None: + self._stop.set() + self._refresh.set() + if self._task: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + self._broadcast(close=True) + + def request_refresh(self) -> None: + self._refresh.set() + + def touch_watch(self, exchange_key: str, symbol: str, timeframe: str) -> str: + key = series_key(exchange_key, symbol, timeframe) + self._watch_until[key] = time.monotonic() + HUB_CHART_WATCH_TTL_SEC + return key + + def clear_watch(self, exchange_key: str, symbol: str, timeframe: str) -> None: + key = series_key(exchange_key, symbol, timeframe) + self._watch_until.pop(key, None) + + def sync_positions_from_rows(self, rows: list[Any]) -> None: + keys: set[str] = set() + tf = HUB_CHART_POSITION_TIMEFRAME + for row in rows or []: + if not isinstance(row, dict): + continue + ex_key = str(row.get("key") or row.get("exchange_key") or "").strip().lower() + if not ex_key: + ex_id = str(row.get("id") or "").strip() + if ex_id: + ex_key = ex_id.lower() + if not ex_key: + continue + ag = row.get("agent") if isinstance(row.get("agent"), dict) else {} + if ag.get("ok") is False: + continue + for pos in ag.get("positions") or []: + if not isinstance(pos, dict): + continue + sym = str(pos.get("symbol") or "").strip().upper() + if sym: + keys.add(series_key(ex_key, sym, tf)) + self._position_keys = keys + + def active_series_keys(self) -> list[str]: + now = time.monotonic() + watch = {k for k, until in self._watch_until.items() if until > now} + merged = self._position_keys | watch + return sorted(merged)[:HUB_CHART_MAX_SERIES_PER_TICK] + + def series_event_dict(self) -> dict[str, Any]: + out: dict[str, Any] = {} + for key, st in self._series.items(): + out[key] = { + "series_version": st.version, + "updated_at": st.updated_at, + "fetched": st.fetched, + "error": st.error, + } + return out + + def event_dict(self, *, tails: dict[str, dict[str, Any]] | None = None) -> dict[str, Any]: + out: dict[str, Any] = { + "chart_version": self.version, + "updated_at": self.updated_at, + "polling": self.polling, + "ok": self.last_error is None, + "error": self.last_error, + "series": self.series_event_dict(), + "poll_interval_sec": HUB_CHART_POLL_INTERVAL, + "position_timeframe": HUB_CHART_POSITION_TIMEFRAME, + "push_tails": True, + } + tail_map = tails if tails is not None else self._push_tails + if tail_map: + out["tails"] = tail_map + return out + + def series_version(self, exchange_key: str, symbol: str, timeframe: str) -> int: + key = series_key(exchange_key, symbol, timeframe) + st = self._series.get(key) + return st.version if st else 0 + + async def _loop(self) -> None: + assert self._poll_fn is not None + while not self._stop.is_set(): + await self._poll_once(self._poll_fn) + if self._stop.is_set(): + break + self._refresh.clear() + sleep_task = asyncio.create_task(asyncio.sleep(HUB_CHART_POLL_INTERVAL)) + refresh_task = asyncio.create_task(self._refresh.wait()) + done, pending = await asyncio.wait( + {sleep_task, refresh_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + for t in pending: + t.cancel() + + async def _poll_once(self, poll_fn: PollFn) -> None: + async with self._lock: + self.polling = True + self._broadcast() + try: + snap = board_store.snapshot_dict() + rows = snap.get("rows") if isinstance(snap, dict) else [] + if isinstance(rows, list): + self.sync_positions_from_rows(rows) + result = await poll_fn() + if not isinstance(result, dict): + result = {"ok": False, "msg": "chart poll 返回无效"} + except Exception as e: + result = {"ok": False, "msg": str(e), "error": "chart_poll_failed"} + async with self._lock: + self.version += 1 + self.updated_at = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + self.last_error = None if result.get("ok") is not False else ( + str(result.get("msg") or result.get("error") or "chart_poll_failed") + ) + self.polling = False + self._broadcast() + + def note_series_result( + self, + exchange_key: str, + symbol: str, + timeframe: str, + *, + ok: bool, + fetched: int = 0, + error: str | None = None, + candles: list[dict[str, Any]] | None = None, + price_tick: Any = None, + ) -> None: + key = series_key(exchange_key, symbol, timeframe) + st = self._series.setdefault(key, SeriesState()) + st.version += 1 + st.updated_at = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + st.fetched = int(fetched or 0) + st.error = error if not ok else None + if ok and candles: + tail = list(candles[-HUB_CHART_SSE_TAIL_BARS :]) + if tail: + self._push_tails[key] = { + "series_version": st.version, + "updated_at": st.updated_at, + "fetched": st.fetched, + "candles": tail, + "price_tick": price_tick, + } + + def _broadcast(self, *, close: bool = False) -> None: + dead: list[asyncio.Queue[str | None]] = [] + tails_snap = dict(self._push_tails) + self._push_tails.clear() + payload = None if close else json.dumps(self.event_dict(tails=tails_snap), ensure_ascii=False) + for q in self._subscribers: + try: + q.put_nowait(payload) + except asyncio.QueueFull: + try: + q.get_nowait() + except asyncio.QueueEmpty: + pass + try: + q.put_nowait(payload) + except asyncio.QueueFull: + dead.append(q) + except Exception: + dead.append(q) + for q in dead: + if q in self._subscribers: + self._subscribers.remove(q) + + async def iter_sse(self) -> AsyncIterator[str]: + q: asyncio.Queue[str | None] = asyncio.Queue(maxsize=32) + self._subscribers.append(q) + try: + yield _sse_frame(self.event_dict()) + while True: + try: + raw = await asyncio.wait_for(q.get(), timeout=HUB_CHART_SSE_HEARTBEAT_SEC) + except asyncio.TimeoutError: + yield ": heartbeat\n\n" + continue + if raw is None: + break + try: + data = json.loads(raw) + except Exception: + data = self.event_dict() + yield _sse_frame(data) + finally: + if q in self._subscribers: + self._subscribers.remove(q) + + +def _sse_frame(data: dict[str, Any]) -> str: + body = json.dumps(data, ensure_ascii=False) + return f"event: chart\ndata: {body}\n\n" + + +chart_poll_store = ChartPollStore() diff --git a/manual_trading_hub/hub_dashboard.py b/manual_trading_hub/hub_dashboard.py new file mode 100644 index 0000000..9a40e46 --- /dev/null +++ b/manual_trading_hub/hub_dashboard.py @@ -0,0 +1,140 @@ +"""中控数据看板:三户当日总览(无 AI,纯数据聚合).""" +from __future__ import annotations + +import os +from datetime import datetime, timezone +from typing import Any, Optional + +from hub_ai.context import ( + build_daily_context, + collect_closed_trades_snapshot, + format_account_remark, + format_dashboard_account_detail, +) +from hub_ai.config import trading_day_reset_hour +from lib.hub.hub_trades_lib import current_trading_day + +LOSS_ALERT_PCT = 5.0 +# 与监控区 board 默认 5s 对齐,看板持仓来源跟监控同步. +DASHBOARD_POLL_INTERVAL_SEC = float(os.getenv("DASHBOARD_POLL_INTERVAL_SEC", "5")) + + +def _safe_float(v: Any) -> Optional[float]: + try: + if v is None or v == "": + return None + return float(v) + except (TypeError, ValueError): + return None + + +def _account_capital_base(ac: dict) -> Optional[float]: + funding = _safe_float(ac.get("funding_usdt")) + trading = _safe_float(ac.get("trading_usdt")) + if funding is not None and trading is not None: + return funding + trading + if funding is not None: + return funding + if trading is not None: + return trading + return None + + +def _options_layout_enabled(ac: dict) -> bool: + if str(ac.get("key") or "").lower() != "okx": + return False + snap = ac.get("options_snapshot") + if isinstance(snap, dict) and snap.get("enabled") is not False: + return True + return ac.get("options_funding_usdt") is not None or ac.get("options_trading_usdt") is not None + + +def _perpetual_float_pnl_u(ac: dict) -> Optional[float]: + try: + total = float(ac.get("float_pnl_u") or 0) + except (TypeError, ValueError): + total = 0.0 + opt = ac.get("options_float_pnl_u") + if opt is None: + return round(total, 4) + try: + return round(total - float(opt), 4) + except (TypeError, ValueError): + return round(total, 4) + + +def _enrich_account_row(ac: dict) -> dict: + st = ac.get("trade_stats") or {} + capital = _account_capital_base(ac) + day_pnl = float(st.get("total_pnl_u") or 0) + loss_pct: Optional[float] = None + loss_alert = False + if capital is not None and capital > 0 and day_pnl < -1e-9: + loss_pct = round(abs(day_pnl) / capital * 100.0, 2) + loss_alert = loss_pct >= LOSS_ALERT_PCT + return { + "id": ac.get("id"), + "key": ac.get("key"), + "name": ac.get("name"), + "status": ac.get("status"), + "monitored": ac.get("status") != "未监控", + "funding_usdt": ac.get("funding_usdt"), + "trading_usdt": ac.get("trading_usdt"), + "perpetual_funding_usdt": ac.get("perpetual_funding_usdt"), + "perpetual_trading_usdt": ac.get("perpetual_trading_usdt"), + "options_funding_usdt": ac.get("options_funding_usdt"), + "options_trading_usdt": ac.get("options_trading_usdt"), + "options_float_pnl_u": ac.get("options_float_pnl_u"), + "options_open_position_count": ac.get("options_open_position_count"), + "options_layout": _options_layout_enabled(ac), + "perpetual_float_pnl_u": _perpetual_float_pnl_u(ac), + "capital_total_usdt": round(capital, 4) if capital is not None else None, + "available_trading_usdt": ac.get("available_trading_usdt"), + "pnl_u": st.get("total_pnl_u"), + "closed_count": st.get("closed_count"), + "win_count": st.get("win_count"), + "loss_count": st.get("loss_count"), + "float_pnl_u": ac.get("float_pnl_u"), + "open_position_count": ac.get("open_position_count"), + "remark": format_account_remark(ac), + **format_dashboard_account_detail(ac), + "issues": ac.get("issues") or [], + "daily_loss_pct": loss_pct, + "loss_alert": loss_alert, + } + + +def build_dashboard_payload( + exchanges: list[dict], + *, + trading_day: str | None = None, +) -> dict[str, Any]: + ctx = build_daily_context(exchanges, trading_day=trading_day) + day = ctx["trading_day"] + accounts_raw = ctx.get("accounts") or [] + accounts = [ + _enrich_account_row(ac) + for ac in accounts_raw + if ac.get("status") != "未监控" + ] + closed_trades = collect_closed_trades_snapshot( + [ac for ac in accounts_raw if ac.get("status") != "未监控"], + today=day, + ) + loss_alert_count = sum(1 for ac in accounts if ac.get("loss_alert")) + now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S") + return { + "ok": True, + "updated_at": now, + "trading_day": day, + "totals": ctx.get("totals"), + "accounts": accounts, + "closed_trades": closed_trades, + "loss_alert_pct_threshold": LOSS_ALERT_PCT, + "loss_alert_count": loss_alert_count, + "poll_interval_sec": DASHBOARD_POLL_INTERVAL_SEC, + } + + +def default_trading_day() -> str: + return current_trading_day(reset_hour=trading_day_reset_hour()) diff --git a/manual_trading_hub/hub_dashboard_cache.py b/manual_trading_hub/hub_dashboard_cache.py new file mode 100644 index 0000000..fafd212 --- /dev/null +++ b/manual_trading_hub/hub_dashboard_cache.py @@ -0,0 +1,169 @@ +"""数据看板:后台定时聚合,内存快照,SSE 版本通知.""" + +from __future__ import annotations + +import asyncio +import json +import os +from collections.abc import AsyncIterator, Awaitable, Callable +from typing import Any + +from hub_dashboard import DASHBOARD_POLL_INTERVAL_SEC + +HUB_DASHBOARD_SSE_HEARTBEAT_SEC = float(os.getenv("HUB_DASHBOARD_SSE_HEARTBEAT_SEC", "25")) + +BuildFn = Callable[[], Awaitable[dict[str, Any]]] + + +class DashboardStore: + def __init__(self) -> None: + self._lock = asyncio.Lock() + self.version = 0 + self.payload: dict[str, Any] | None = None + self.aggregating = False + self.last_error: str | None = None + self._subscribers: list[asyncio.Queue[str | None]] = [] + self._task: asyncio.Task | None = None + self._stop = asyncio.Event() + self._refresh = asyncio.Event() + self._build_fn: BuildFn | None = None + + async def start(self, build_fn: BuildFn) -> None: + if self._task and not self._task.done(): + return + self._build_fn = build_fn + self._stop.clear() + self._task = asyncio.create_task(self._loop(), name="hub-dashboard-poll") + + async def stop(self) -> None: + self._stop.set() + self._refresh.set() + if self._task: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + self._broadcast(close=True) + + def request_refresh(self) -> None: + self._refresh.set() + + def snapshot_dict(self) -> dict[str, Any]: + p = dict(self.payload or {}) + if not p: + return { + "ok": False, + "dashboard_version": self.version, + "aggregating": self.aggregating, + "error": self.last_error, + "poll_interval_sec": DASHBOARD_POLL_INTERVAL_SEC, + } + return { + **p, + "dashboard_version": self.version, + "aggregating": self.aggregating, + "error": self.last_error or p.get("error"), + "poll_interval_sec": DASHBOARD_POLL_INTERVAL_SEC, + } + + def event_dict(self) -> dict[str, Any]: + p = self.payload or {} + return { + "dashboard_version": self.version, + "updated_at": p.get("updated_at"), + "aggregating": self.aggregating, + "ok": p.get("ok", True) if self.payload else False, + "error": self.last_error or p.get("error"), + } + + async def _loop(self) -> None: + assert self._build_fn is not None + while not self._stop.is_set(): + await self._aggregate_once(self._build_fn) + if self._stop.is_set(): + break + self._refresh.clear() + sleep_task = asyncio.create_task(asyncio.sleep(DASHBOARD_POLL_INTERVAL_SEC)) + refresh_task = asyncio.create_task(self._refresh.wait()) + done, pending = await asyncio.wait( + {sleep_task, refresh_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + for t in pending: + t.cancel() + + async def _aggregate_once(self, build_fn: BuildFn) -> None: + async with self._lock: + self.aggregating = True + self._broadcast() + try: + result = await build_fn() + if not isinstance(result, dict): + result = {"ok": False, "msg": "聚合返回无效"} + except Exception as e: + result = {"ok": False, "msg": str(e), "error": "aggregate_failed"} + async with self._lock: + self.version += 1 + prev = self.payload if isinstance(self.payload, dict) else None + if result.get("ok") is False and prev and prev.get("ok"): + self.payload = prev + self.last_error = str(result.get("msg") or result.get("error") or "aggregate_failed") + else: + self.payload = result + self.last_error = None if result.get("ok") is not False else ( + str(result.get("msg") or result.get("error") or "aggregate_failed") + ) + self.aggregating = False + self._broadcast() + + def _broadcast(self, *, close: bool = False) -> None: + dead: list[asyncio.Queue[str | None]] = [] + for q in self._subscribers: + try: + q.put_nowait(None if close else json.dumps(self.event_dict(), ensure_ascii=False)) + except asyncio.QueueFull: + try: + q.get_nowait() + except asyncio.QueueEmpty: + pass + try: + q.put_nowait(json.dumps(self.event_dict(), ensure_ascii=False)) + except asyncio.QueueFull: + dead.append(q) + except Exception: + dead.append(q) + for q in dead: + if q in self._subscribers: + self._subscribers.remove(q) + + async def iter_sse(self) -> AsyncIterator[str]: + q: asyncio.Queue[str | None] = asyncio.Queue(maxsize=32) + self._subscribers.append(q) + try: + yield _sse_frame(self.event_dict()) + while True: + try: + raw = await asyncio.wait_for(q.get(), timeout=HUB_DASHBOARD_SSE_HEARTBEAT_SEC) + except asyncio.TimeoutError: + yield ": heartbeat\n\n" + continue + if raw is None: + break + try: + data = json.loads(raw) + except Exception: + data = self.event_dict() + yield _sse_frame(data) + finally: + if q in self._subscribers: + self._subscribers.remove(q) + + +def _sse_frame(data: dict[str, Any]) -> str: + body = json.dumps(data, ensure_ascii=False) + return f"event: dashboard\ndata: {body}\n\n" + + +dashboard_store = DashboardStore() diff --git a/manual_trading_hub/hub_env_lib.py b/manual_trading_hub/hub_env_lib.py new file mode 100644 index 0000000..b1bc9aa --- /dev/null +++ b/manual_trading_hub/hub_env_lib.py @@ -0,0 +1,48 @@ +"""中控 .env 读写与 PM2 重启.""" +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +from lib.env.env_file_lib import apply_env_updates, load_env_file_into_environ +from lib.env.shared_env_lib import ( + apply_ai_env_to_all, + build_ai_env_payload, + restart_instances_then_hub_pm2, +) +from lib.instance.instance_pm2_lib import schedule_pm2_restart + +HUB_DIR = Path(__file__).resolve().parent + + +def hub_env_path() -> str: + return str(HUB_DIR / ".env") + + +def update_hub_credentials(*, new_password: str, new_username: str | None = None) -> list[str]: + updates: dict[str, str] = {"HUB_PASSWORD": new_password} + if new_username: + updates["HUB_USERNAME"] = new_username + path = hub_env_path() + changed = apply_env_updates(path, updates) + if changed: + load_env_file_into_environ(path) + return changed + + +def get_hub_ai_env_payload() -> dict[str, Any]: + return build_ai_env_payload(hub_env_path()) + + +def save_hub_ai_env(updates: dict[str, str]) -> dict[str, Any]: + return apply_ai_env_to_all(updates) + + +def restart_all_pm2() -> dict[str, Any]: + return restart_instances_then_hub_pm2() + + +def restart_hub_pm2() -> dict[str, Any]: + app_name = (os.getenv("PM2_APP_NAME") or "").strip() or "manual-trading-hub" + return schedule_pm2_restart(app_name) diff --git a/manual_trading_hub/hub_supervisor_cache.py b/manual_trading_hub/hub_supervisor_cache.py new file mode 100644 index 0000000..02e8fa5 --- /dev/null +++ b/manual_trading_hub/hub_supervisor_cache.py @@ -0,0 +1,148 @@ +"""交易监管:后台扫描 + SSE 版本通知.""" +from __future__ import annotations + +import asyncio +import json +import os +from collections.abc import AsyncIterator, Awaitable, Callable +from typing import Any + +SUPERVISOR_POLL_INTERVAL_SEC = float(os.getenv("SUPERVISOR_POLL_INTERVAL_SEC", "30")) +SUPERVISOR_SSE_HEARTBEAT_SEC = float(os.getenv("SUPERVISOR_SSE_HEARTBEAT_SEC", "25")) + +TickFn = Callable[[], Awaitable[dict[str, Any]]] + + +class SupervisorStore: + def __init__(self) -> None: + self._lock = asyncio.Lock() + self.version = 0 + self.last_result: dict[str, Any] | None = None + self.last_error: str | None = None + self._subscribers: list[asyncio.Queue[str | None]] = [] + self._task: asyncio.Task | None = None + self._stop = asyncio.Event() + self._refresh = asyncio.Event() + self._tick_fn: TickFn | None = None + + async def start(self, tick_fn: TickFn) -> None: + if self._task and not self._task.done(): + return + self._tick_fn = tick_fn + self._stop.clear() + self._task = asyncio.create_task(self._loop(), name="hub-supervisor-poll") + + async def stop(self) -> None: + self._stop.set() + self._refresh.set() + if self._task: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + self._broadcast(close=True) + + def request_refresh(self) -> None: + self._refresh.set() + + def bump(self) -> None: + self.version += 1 + self._broadcast() + + def event_dict(self) -> dict[str, Any]: + r = self.last_result or {} + return { + "supervisor_version": self.version, + "ok": r.get("ok", True), + "events": r.get("events", 0), + "trading_day": r.get("trading_day"), + "session_id": r.get("session_id"), + "error": self.last_error, + } + + async def _loop(self) -> None: + assert self._tick_fn is not None + while not self._stop.is_set(): + await self._tick_once(self._tick_fn) + if self._stop.is_set(): + break + self._refresh.clear() + sleep_task = asyncio.create_task(asyncio.sleep(SUPERVISOR_POLL_INTERVAL_SEC)) + refresh_task = asyncio.create_task(self._refresh.wait()) + done, pending = await asyncio.wait( + {sleep_task, refresh_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + for t in pending: + t.cancel() + + async def _tick_once(self, tick_fn: TickFn) -> None: + async with self._lock: + try: + result = await tick_fn() + if not isinstance(result, dict): + result = {"ok": False, "msg": "invalid_tick"} + except Exception as e: + result = {"ok": False, "msg": str(e)} + self.last_error = str(e) + else: + self.last_error = None if result.get("ok") is not False else str( + result.get("msg") or "tick_failed" + ) + self.last_result = result + if int(result.get("events") or 0) > 0: + self.version += 1 + self._broadcast() + + def _broadcast(self, *, close: bool = False) -> None: + dead: list[asyncio.Queue[str | None]] = [] + payload = None if close else json.dumps(self.event_dict(), ensure_ascii=False) + for q in self._subscribers: + try: + q.put_nowait(payload) + except asyncio.QueueFull: + try: + q.get_nowait() + except asyncio.QueueEmpty: + pass + try: + q.put_nowait(payload) + except asyncio.QueueFull: + dead.append(q) + except Exception: + dead.append(q) + for q in dead: + if q in self._subscribers: + self._subscribers.remove(q) + + async def iter_sse(self) -> AsyncIterator[str]: + q: asyncio.Queue[str | None] = asyncio.Queue(maxsize=32) + self._subscribers.append(q) + try: + yield _sse_frame(self.event_dict()) + while True: + try: + raw = await asyncio.wait_for(q.get(), timeout=SUPERVISOR_SSE_HEARTBEAT_SEC) + except asyncio.TimeoutError: + yield ": heartbeat\n\n" + continue + if raw is None: + break + try: + data = json.loads(raw) + except Exception: + data = self.event_dict() + yield _sse_frame(data) + finally: + if q in self._subscribers: + self._subscribers.remove(q) + + +def _sse_frame(data: dict[str, Any]) -> str: + body = json.dumps(data, ensure_ascii=False) + return f"event: supervisor\ndata: {body}\n\n" + + +supervisor_store = SupervisorStore() diff --git a/manual_trading_hub/hub_supervisor_lib.py b/manual_trading_hub/hub_supervisor_lib.py new file mode 100644 index 0000000..b0931be --- /dev/null +++ b/manual_trading_hub/hub_supervisor_lib.py @@ -0,0 +1,757 @@ +"""交易监管:事件分类,频率规则,会话消息与企业微信推送.""" +from __future__ import annotations + +import json +import os +import threading +import uuid +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any, Callable, Optional + +from lib.hub.hub_trades_lib import current_trading_day, parse_dt_for_trading_day + +HUB_DIR = Path(__file__).resolve().parent +STATE_PATH = HUB_DIR / "hub_supervisor_state.json" + +PROGRAM_RESULTS = frozenset({"止盈", "止损", "保本止盈", "移动止盈"}) +MANUAL_CLOSE_RESULTS = frozenset({"手动平仓"}) +HUB_CLOSE_RESULTS = frozenset({"强制清仓"}) +WEAK_RESULTS = frozenset({"外部平仓", "时间平仓"}) + +EVENT_OPEN = "open" +EVENT_MANUAL_CLOSE = "manual_close" +EVENT_HUB_CLOSE = "hub_close" +EVENT_PROGRAM_TP = "program_tp" +EVENT_PROGRAM_SL = "program_sl" +EVENT_EXTERNAL = "external" +EVENT_FREQ_WARN = "freq_warn" + +DEFAULT_SUPERVISOR = { + "enabled": True, + "wechat_webhook": "", + "wechat_link_base": "http://127.0.0.1:5100/ai?mode=supervisor", + "wechat_prefix": "【交易监管】", + "wechat_on_program_tp_sl": True, + "manual_close_daily_warn": 2, + "interval_warn_minutes": 15, + "freq_30m_count": 2, + "reopen_after_close_minutes": 30, +} + + +def _now_str() -> str: + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def _atomic_write(path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + os.replace(tmp, path) + + +def _load_json(path: Path, default: dict) -> dict: + if not path.is_file(): + return dict(default) + try: + loaded = json.loads(path.read_text(encoding="utf-8")) + if isinstance(loaded, dict): + return loaded + except Exception: + pass + return dict(default) + + +def normalize_supervisor_settings(raw: dict | None) -> dict: + out = dict(DEFAULT_SUPERVISOR) + env_webhook = (os.getenv("SUPERVISOR_WECHAT_WEBHOOK") or "").strip() + env_link = (os.getenv("SUPERVISOR_WECHAT_LINK") or "").strip() + if env_webhook: + out["wechat_webhook"] = env_webhook + if env_link: + out["wechat_link_base"] = env_link + if not isinstance(raw, dict): + return out + for key in DEFAULT_SUPERVISOR: + if key not in raw: + continue + val = raw.get(key) + if key == "enabled" or key == "wechat_on_program_tp_sl": + out[key] = bool(val) + elif key in ("manual_close_daily_warn", "freq_30m_count"): + try: + out[key] = max(1, int(val)) + except (TypeError, ValueError): + pass + elif key in ("interval_warn_minutes", "reopen_after_close_minutes"): + try: + out[key] = max(1, int(val)) + except (TypeError, ValueError): + pass + elif isinstance(val, str): + out[key] = val.strip() + return out + + +def load_supervisor_state() -> dict: + data = _load_json(STATE_PATH, {"version": 1, "trading_day": "", "processed": [], "positions": {}, "stats": {}}) + data.setdefault("version", 1) + data.setdefault("processed", []) + data.setdefault("positions", {}) + data.setdefault("stats", {}) + return data + + +def save_supervisor_state(data: dict) -> None: + processed = list(data.get("processed") or []) + if len(processed) > 500: + processed = processed[-500:] + data["processed"] = processed + _atomic_write(STATE_PATH, data) + + +def _trade_event_id(trade: dict) -> str: + return "|".join( + [ + str(trade.get("account_name") or trade.get("account_key") or ""), + str(trade.get("symbol") or ""), + str(trade.get("closed_at") or ""), + str(trade.get("result") or ""), + str(trade.get("pnl_amount") or ""), + ] + ) + + +def classify_close_result(result: str) -> str: + r = (result or "").strip() + if r in PROGRAM_RESULTS: + if r == "止损": + return EVENT_PROGRAM_SL + return EVENT_PROGRAM_TP + if r in MANUAL_CLOSE_RESULTS: + return EVENT_MANUAL_CLOSE + if r in HUB_CLOSE_RESULTS: + return EVENT_HUB_CLOSE + if r in WEAK_RESULTS: + return EVENT_EXTERNAL + return EVENT_EXTERNAL + + +def is_supervised_event(event_type: str) -> bool: + return event_type in (EVENT_OPEN, EVENT_MANUAL_CLOSE, EVENT_HUB_CLOSE) + + +def is_program_event(event_type: str) -> bool: + return event_type in (EVENT_PROGRAM_TP, EVENT_PROGRAM_SL) + + +def _normalize_position_symbol(sym: str) -> str: + """统一合约名,避免 ZEC/USDT 与 ZEC/USDT:USDT 被当成两笔持仓.""" + s = (sym or "").strip().upper() + if not s: + return "" + if s.endswith(":USDT") and "/" in s: + return s.rsplit(":", 1)[0] + return s + + +def _position_key(exchange_id: str, symbol: str, side: str) -> str: + sym = _normalize_position_symbol(symbol) + sd = (side or "long").strip().lower() or "long" + return f"{exchange_id}|{sym}|{sd}" + + +def _position_contracts(pos: dict) -> float: + for key in ("contracts", "contracts_signed", "size"): + try: + v = pos.get(key) + if v is not None and v != "": + return abs(float(v)) + except (TypeError, ValueError): + continue + return 0.0 + + +def collect_position_keys(board_payload: dict | None) -> dict[str, dict]: + out: dict[str, dict] = {} + rows = (board_payload or {}).get("rows") or [] + for row in rows: + if not isinstance(row, dict): + continue + ex_id = str(row.get("id") or row.get("key") or "") + ex_name = str(row.get("name") or row.get("key") or ex_id) + ag = row.get("agent") or {} + for p in ag.get("positions") or []: + if not isinstance(p, dict): + continue + if _position_contracts(p) < 1e-12: + continue + sym = str(p.get("symbol") or "") + side = str(p.get("side") or "").lower() or "long" + key = _position_key(ex_id, sym, side) + out[key] = { + "exchange_id": ex_id, + "exchange_name": ex_name, + "symbol": sym, + "side": side, + "contracts": _position_contracts(p), + } + return out + + +def _board_agent_snapshot_ready(board_payload: dict | None) -> bool: + """监控板各启用账户 agent 快照已就绪(避免空板先入库导致后续持仓误判为新开).""" + if not isinstance(board_payload, dict) or board_payload.get("ok") is False: + return False + rows = board_payload.get("rows") or [] + if not rows: + return False + seen = 0 + for row in rows: + if not isinstance(row, dict): + continue + if row.get("enabled") is False: + continue + ag = row.get("agent") + if not isinstance(ag, dict): + return False + seen += 1 + return seen > 0 + + +def _entry_contracts(entry: dict | None) -> float: + if not isinstance(entry, dict): + return 0.0 + try: + return float(entry.get("contracts") or 0) + except (TypeError, ValueError): + return 0.0 + + +def detect_new_opens( + prev_positions: dict[str, dict], + curr_positions: dict[str, dict], +) -> list[dict]: + """仅当某合约从空仓变为有仓时视为新开(已有持仓不加仓不算).""" + events = [] + for key, info in curr_positions.items(): + curr_c = _entry_contracts(info) + if curr_c < 1e-12: + continue + prev_c = _entry_contracts(prev_positions.get(key)) + if prev_c >= 1e-12: + continue + events.append({"event_type": EVENT_OPEN, "event_id": f"open:{key}:{_now_str()[:16]}", **info}) + return events + + +def detect_new_closes( + prev_processed: set[str], + closed_trades: list[dict], +) -> list[dict]: + events = [] + for trade in closed_trades or []: + if not isinstance(trade, dict): + continue + eid = _trade_event_id(trade) + if eid in prev_processed: + continue + event_type = classify_close_result(str(trade.get("result") or "")) + events.append( + { + "event_type": event_type, + "event_id": f"close:{eid}", + "account_name": trade.get("account_name"), + "symbol": trade.get("symbol"), + "direction": trade.get("direction"), + "result": trade.get("result"), + "pnl_amount": trade.get("pnl_amount"), + "closed_at": trade.get("closed_at"), + } + ) + return events + + +def _parse_event_dt(raw: Any) -> Optional[datetime]: + return parse_dt_for_trading_day(raw) + + +def _supervised_close_times(stats: dict, trading_day: str) -> list[datetime]: + rows = (stats.get(trading_day) or {}).get("supervised_closes") or [] + out = [] + for item in rows: + if isinstance(item, dict): + dt = _parse_event_dt(item.get("closed_at") or item.get("at")) + else: + dt = _parse_event_dt(item) + if dt: + out.append(dt) + out.sort() + return out + + +def _record_supervised_event(stats: dict, trading_day: str, event: dict) -> None: + day_stats = stats.setdefault(trading_day, {}) + et = str(event.get("event_type") or "") + if et == EVENT_OPEN: + opens = list(day_stats.get("supervised_opens") or []) + opens.append({"at": _now_str(), "symbol": event.get("symbol")}) + day_stats["supervised_opens"] = opens[-50:] + return + if et not in (EVENT_MANUAL_CLOSE, EVENT_HUB_CLOSE): + return + closes = list(day_stats.get("supervised_closes") or []) + closes.append( + { + "at": _now_str(), + "closed_at": event.get("closed_at"), + "event_type": et, + "pnl_amount": event.get("pnl_amount"), + } + ) + day_stats["supervised_closes"] = closes[-50:] + + +def evaluate_frequency_warnings( + *, + trading_day: str, + event: dict, + stats: dict, + settings: dict, +) -> list[dict]: + if not is_supervised_event(str(event.get("event_type") or "")): + return [] + warnings: list[dict] = [] + day_stats = stats.setdefault(trading_day, {}) + closes = _supervised_close_times(stats, trading_day) + now = datetime.now() + if event.get("event_type") in (EVENT_MANUAL_CLOSE, EVENT_HUB_CLOSE): + evt_dt = _parse_event_dt(event.get("closed_at")) or now + closes = closes + [evt_dt] + closes.sort() + open_count = len(day_stats.get("supervised_opens") or []) + close_count = len(day_stats.get("supervised_closes") or []) + if event.get("event_type") == EVENT_OPEN: + open_count += 1 + elif event.get("event_type") in (EVENT_MANUAL_CLOSE, EVENT_HUB_CLOSE): + close_count += 1 + + interval_min = int(settings.get("interval_warn_minutes") or 15) + daily_warn = int(settings.get("manual_close_daily_warn") or 2) + freq_30m = int(settings.get("freq_30m_count") or 2) + reopen_min = int(settings.get("reopen_after_close_minutes") or 30) + + if event.get("event_type") in (EVENT_MANUAL_CLOSE, EVENT_HUB_CLOSE) and len(closes) >= 2: + prev = closes[-2] + cur = closes[-1] + gap = (cur - prev).total_seconds() / 60.0 + if gap < interval_min: + warnings.append( + { + "rule": "INTERVAL_SHORT", + "message": f"两笔手动/中控平间隔仅 {int(gap)} 分钟(阈值 {interval_min} 分钟)", + } + ) + + recent_closes = [t for t in closes if (now - t).total_seconds() <= 30 * 60] + if event.get("event_type") in (EVENT_MANUAL_CLOSE, EVENT_HUB_CLOSE) and len(recent_closes) >= freq_30m: + warnings.append( + { + "rule": "FREQ_30M", + "message": f"30 分钟内手动/中控平已达 {len(recent_closes)} 笔(阈值 {freq_30m} 笔)", + } + ) + + supervised_total = open_count + close_count + if supervised_total >= daily_warn and event.get("event_type") in ( + EVENT_MANUAL_CLOSE, + EVENT_HUB_CLOSE, + EVENT_OPEN, + ): + if close_count >= daily_warn: + warnings.append( + { + "rule": "DAILY_COUNT", + "message": f"今日手动/中控平 {close_count} 笔(阈值 {daily_warn} 笔),注意过度交易", + } + ) + + if event.get("event_type") == EVENT_OPEN and closes: + last_close = closes[-1] + gap_open = (now - last_close).total_seconds() / 60.0 + if gap_open < reopen_min: + warnings.append( + { + "rule": "REOPEN_FAST", + "message": f"距上一笔手动/中控平仅 {int(gap_open)} 分钟又新开仓(阈值 {reopen_min} 分钟)", + } + ) + + loss_streak = 0 + for item in reversed((stats.get(trading_day) or {}).get("supervised_closes") or []): + try: + pnl = float((item or {}).get("pnl_amount") or 0) + except (TypeError, ValueError): + pnl = 0.0 + if pnl < 0: + loss_streak += 1 + else: + break + if event.get("event_type") in (EVENT_MANUAL_CLOSE, EVENT_HUB_CLOSE): + try: + pnl = float(event.get("pnl_amount") or 0) + except (TypeError, ValueError): + pnl = 0.0 + if pnl < 0: + loss_streak += 1 + else: + loss_streak = 0 + if loss_streak >= 2 and event.get("event_type") in (EVENT_MANUAL_CLOSE, EVENT_HUB_CLOSE): + warnings.append( + { + "rule": "LOSS_STREAK", + "message": f"连续 {loss_streak} 笔手动/中控亏损,先停一停", + } + ) + + deduped = [] + seen = set() + for w in warnings: + key = w.get("rule") + if key in seen: + continue + seen.add(key) + deduped.append(w) + return deduped + + +def event_tag(event_type: str) -> str: + return { + EVENT_OPEN: "监管·开仓", + EVENT_MANUAL_CLOSE: "监管·手动平", + EVENT_HUB_CLOSE: "监管·中控平", + EVENT_PROGRAM_TP: "监管·程序止盈", + EVENT_PROGRAM_SL: "监管·程序止损", + EVENT_EXTERNAL: "监管·外部平", + EVENT_FREQ_WARN: "监管·频率", + }.get(event_type, "监管") + + +def _fmt_pnl_u(pnl: Any) -> str: + try: + v = float(pnl) + sign = "+" if v > 0 else "" + return f"{sign}{v:.4f}".rstrip("0").rstrip(".") + "U" + except (TypeError, ValueError): + return "" + + +def build_supervisor_fallback_reply(event: dict, warnings: list[dict] | None = None) -> str: + """AI 不可用或返回空时的短评语(不展示错误文案).""" + et = str(event.get("event_type") or "") + sym = str(event.get("symbol") or "—") + ex = str(event.get("exchange_name") or event.get("account_name") or "").strip() + pnl_txt = _fmt_pnl_u(event.get("pnl_amount")) + warn = (warnings or [])[:1] + warn_txt = str(warn[0].get("message") or "").strip() if warn else "" + + if et == EVENT_PROGRAM_SL: + base = f"{sym} 程序止损" + if pnl_txt: + base += f"({pnl_txt})" + base += ",按计划出场是纪律.先歇一会儿,别急着马上再开." + elif et == EVENT_PROGRAM_TP: + base = f"{sym} 程序止盈" + if pnl_txt: + base += f"({pnl_txt})" + base += ",执行不错.保持节奏,别立刻反手再开一单." + elif et == EVENT_OPEN: + who = f"{ex} " if ex else "" + base = f"看到 {who}新开 {sym}.动手前确认是不是计划内,别因为上一笔情绪再开." + elif et == EVENT_HUB_CLOSE: + base = f"中控平了 {sym}" + if pnl_txt: + base += f"({pnl_txt})" + base += "." + base += f" {warn_txt}" if warn_txt else " 停一停,别连着手痒." + elif et == EVENT_MANUAL_CLOSE: + base = f"手动平了 {sym}" + if pnl_txt: + base += f"({pnl_txt})" + base += "." + base += f" {warn_txt}" if warn_txt else " 想好再开下一单." + elif et == EVENT_FREQ_WARN: + base = warn_txt or "今日操作偏频繁,先休息一会儿." + else: + base = "收到.确认是否按计划执行,别连续加码." + return base.strip()[:320] + + +def build_system_message(event: dict, *, trading_day: str, warnings: list[dict] | None = None) -> str: + tag = event_tag(str(event.get("event_type") or "")) + ex = event.get("exchange_name") or event.get("account_name") or "—" + sym = event.get("symbol") or "—" + lines = [f"[{tag}] {ex} · {sym}"] + et = event.get("event_type") + if et == EVENT_OPEN: + side = event.get("side") or event.get("direction") or "" + if side: + lines.append(f"方向:{side}") + elif et in (EVENT_MANUAL_CLOSE, EVENT_HUB_CLOSE, EVENT_PROGRAM_TP, EVENT_PROGRAM_SL, EVENT_EXTERNAL): + res = event.get("result") or "" + pnl = event.get("pnl_amount") + if pnl is not None: + lines.append(f"结果 {res} · 盈亏 {pnl}U") + else: + lines.append(f"结果 {res}") + if event.get("closed_at"): + lines.append(f"平仓时间 {event.get('closed_at')}") + for w in warnings or []: + lines.append(f"⚠ {w.get('message')}") + lines.append(f"交易日 {trading_day}") + return "\n".join(lines) + + +def build_wechat_body( + event: dict, + *, + trading_day: str, + link_base: str, + system_text: str, +) -> str: + link = (link_base or "").strip() + if link: + sep = "&" if "?" in link else "?" + link = f"{link}{sep}day={trading_day}" + body = system_text.replace("\n", "\n") + if link: + body += f"\n详情:{link}" + return body + + +def should_send_wechat(event: dict, settings: dict) -> bool: + if not settings.get("enabled", True): + return False + webhook = (settings.get("wechat_webhook") or "").strip() + if not webhook or "replace-me" in webhook.lower(): + return False + et = str(event.get("event_type") or "") + if is_program_event(et): + return bool(settings.get("wechat_on_program_tp_sl", True)) + if et == EVENT_EXTERNAL: + return False + return True + + +def send_supervisor_wechat( + event: dict, + *, + trading_day: str, + settings: dict, + system_text: str, +) -> bool: + if not should_send_wechat(event, settings): + return False + from lib.common.wechat_notify_lib import send_wechat_webhook + + prefix = (settings.get("wechat_prefix") or "【交易监管】").strip() + body = build_wechat_body( + event, + trading_day=trading_day, + link_base=str(settings.get("wechat_link_base") or ""), + system_text=system_text, + ) + return bool( + send_wechat_webhook( + str(settings.get("wechat_webhook") or ""), + body, + prefix=prefix, + ) + ) + + +_notify_hook: Optional[Callable[[], None]] = None + + +def set_supervisor_notify_hook(fn: Optional[Callable[[], None]]) -> None: + global _notify_hook + _notify_hook = fn + + +def _fire_notify() -> None: + if _notify_hook: + try: + _notify_hook() + except Exception: + pass + + +def process_supervisor_tick( + dashboard_payload: dict | None, + board_payload: dict | None, + settings_root: dict | None, + *, + reset_hour: int = 8, + ai_reply_fn: Optional[Callable[..., str]] = None, +) -> dict[str, Any]: + """单次监管扫描:对比快照,写会话,推微信,可选 AI 评语.""" + from hub_ai.supervisor_store import ( + append_supervisor_ai_message, + append_supervisor_system_message, + ensure_supervisor_session, + ) + + sup_cfg = normalize_supervisor_settings((settings_root or {}).get("supervisor")) + if not sup_cfg.get("enabled", True): + return {"ok": True, "skipped": True, "reason": "disabled"} + + dash = dashboard_payload or {} + trading_day = str(dash.get("trading_day") or current_trading_day(reset_hour=reset_hour)) + state = load_supervisor_state() + if str(state.get("trading_day") or "") != trading_day: + state = { + "version": 1, + "trading_day": trading_day, + "processed": [], + "positions": {}, + "stats": {trading_day: state.get("stats", {}).get(trading_day, {})}, + "positions_baseline_ready": False, + } + + processed = set(str(x) for x in (state.get("processed") or [])) + stats = dict(state.get("stats") or {}) + prev_positions = dict(state.get("positions") or {}) + curr_positions = collect_position_keys(board_payload) + closed_trades = dash.get("closed_trades") or [] + board_ready = _board_agent_snapshot_ready(board_payload) + + if not state.get("positions_baseline_ready"): + for trade in closed_trades: + if isinstance(trade, dict): + processed.add(f"close:{_trade_event_id(trade)}") + if not board_ready: + state["trading_day"] = trading_day + state["processed"] = list(processed) + save_supervisor_state(state) + return {"ok": True, "events": 0, "waiting_board": True, "trading_day": trading_day} + state["trading_day"] = trading_day + state["processed"] = list(processed) + state["positions"] = curr_positions + state["positions_baseline_ready"] = True + state["initialized"] = True + save_supervisor_state(state) + return { + "ok": True, + "events": 0, + "seeded": True, + "trading_day": trading_day, + "positions": len(curr_positions), + } + + raw_events = detect_new_opens(prev_positions, curr_positions) + detect_new_closes( + processed, closed_trades + ) + if not raw_events: + state["positions"] = curr_positions + save_supervisor_state(state) + return {"ok": True, "events": 0} + + session = ensure_supervisor_session(trading_day) + session_id = str(session.get("id") or "") + handled = 0 + + for event in raw_events: + eid = str(event.get("event_id") or uuid.uuid4().hex) + if eid in processed: + continue + et = str(event.get("event_type") or "") + if et == EVENT_EXTERNAL: + processed.add(eid) + continue + + warnings = evaluate_frequency_warnings( + trading_day=trading_day, + event=event, + stats=stats, + settings=sup_cfg, + ) + if is_supervised_event(et): + _record_supervised_event(stats, trading_day, event) + + system_text = build_system_message(event, trading_day=trading_day, warnings=warnings) + append_supervisor_system_message( + session_id, + system_text, + event_type=et, + level="warn" if warnings else "info", + ) + send_supervisor_wechat( + event, + trading_day=trading_day, + settings=sup_cfg, + system_text=system_text, + ) + for w in warnings: + warn_event = { + "event_type": EVENT_FREQ_WARN, + "event_id": f"warn:{eid}:{w.get('rule')}", + **event, + "warn_message": w.get("message"), + } + warn_text = f"[{event_tag(EVENT_FREQ_WARN)}] {w.get('message')}" + append_supervisor_system_message( + session_id, + warn_text, + event_type=EVENT_FREQ_WARN, + level="warn", + ) + send_supervisor_wechat( + warn_event, + trading_day=trading_day, + settings=sup_cfg, + system_text=warn_text, + ) + + if ai_reply_fn and et != EVENT_EXTERNAL: + evt_snapshot = dict(event) + evt_warnings = list(warnings) + + def _ai_bg() -> None: + try: + reply = ai_reply_fn( + event=evt_snapshot, + warnings=evt_warnings, + trading_day=trading_day, + session_id=session_id, + ) + from hub_ai.text_util import is_ai_error_reply + + text = str(reply or "").strip() + if not text or is_ai_error_reply(text): + text = build_supervisor_fallback_reply(evt_snapshot, evt_warnings) + if text: + append_supervisor_ai_message(session_id, text) + _fire_notify() + except Exception: + try: + fb = build_supervisor_fallback_reply(evt_snapshot, evt_warnings) + if fb: + append_supervisor_ai_message(session_id, fb) + _fire_notify() + except Exception: + pass + + threading.Thread(target=_ai_bg, daemon=True).start() + + processed.add(eid) + handled += 1 + + state["trading_day"] = trading_day + state["processed"] = list(processed) + state["positions"] = curr_positions + state["stats"] = stats + save_supervisor_state(state) + if handled: + _fire_notify() + return {"ok": True, "events": handled, "trading_day": trading_day, "session_id": session_id} diff --git a/manual_trading_hub/hub_web_auth.py b/manual_trading_hub/hub_web_auth.py new file mode 100644 index 0000000..0714d7c --- /dev/null +++ b/manual_trading_hub/hub_web_auth.py @@ -0,0 +1,182 @@ +"""中控 Web 登录:HUB_USERNAME + HUB_PASSWORD 配置后启用会话 Cookie.""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import os +import time +from secrets import compare_digest + +SESSION_COOKIE = "hub_sess" +SESSION_MAX_AGE_SEC = max(3600, int(os.getenv("HUB_SESSION_DAYS", "7")) * 86400) +DEFAULT_USERNAME = "admin" +DEFAULT_PASSWORD = "admin123" + + +def _env_username() -> str: + return (os.getenv("HUB_USERNAME") or "").strip() + + +def _env_password() -> str: + raw = (os.getenv("HUB_PASSWORD") or "").strip() + return raw or DEFAULT_PASSWORD + + +def password_required() -> bool: + """默认启用登录(admin / admin123,可通过 .env 覆盖).""" + return True + + +def expected_username() -> str: + return _env_username() or DEFAULT_USERNAME + + +def verify_credentials(username: str, password: str) -> bool: + u_ok = compare_digest(expected_username(), (username or "").strip()) + p_ok = compare_digest(_env_password(), (password or "").strip()) + return u_ok and p_ok + + +def verify_password(password: str) -> bool: + """兼容旧调用:仅校验密码,用户名用默认值.""" + return verify_credentials(expected_username(), password) + + +def _secret() -> bytes: + raw = (os.getenv("HUB_SESSION_SECRET") or "").strip() + if not raw: + raw = "|".join(p for p in [_env_username(), _env_password()] if p) or "hub-dev-insecure" + return raw.encode("utf-8") + + +def _b64url_encode(data: bytes) -> str: + return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=") + + +def _b64url_decode(text: str) -> bytes: + pad = "=" * (-len(text) % 4) + return base64.urlsafe_b64decode(text + pad) + + +def create_session_token(username: str | None = None) -> str: + payload = { + "exp": int(time.time()) + SESSION_MAX_AGE_SEC, + "v": 2, + "u": (username or expected_username()).strip(), + } + body = _b64url_encode(json.dumps(payload, separators=(",", ":")).encode("utf-8")) + sig = hmac.new(_secret(), body.encode("ascii"), hashlib.sha256).hexdigest() + return f"{body}.{sig}" + + +def validate_session_token(token: str | None) -> bool: + if not token or "." not in token: + return False + body, sig = token.rsplit(".", 1) + expected = hmac.new(_secret(), body.encode("ascii"), hashlib.sha256).hexdigest() + if not compare_digest(expected, sig): + return False + try: + payload = json.loads(_b64url_decode(body)) + except Exception: + return False + exp = int(payload.get("exp") or 0) + if exp <= int(time.time()): + return False + sess_user = (payload.get("u") or "").strip() + if sess_user and not compare_digest(sess_user, expected_username()): + return False + return True + + +def cookie_secure_env_enabled() -> bool: + """是否在 .env 中启用「HTTPS 时带 Secure Cookie」策略.""" + return (os.getenv("HUB_COOKIE_SECURE") or "").strip().lower() in ( + "1", + "true", + "yes", + "on", + ) + + +def cookie_secure_for_request(request) -> bool: + """ + 仅在实际 HTTPS 访问时设置 Secure Cookie. + 这样可同时支持:域名 HTTPS 反代 + 内网 http://IP:5100 登录. + """ + if not cookie_secure_env_enabled(): + return False + proto = ( + (request.headers.get("x-forwarded-proto") or request.url.scheme or "http") + .split(",")[0] + .strip() + .lower() + ) + return proto == "https" + + +def embed_allowed() -> bool: + """允许被本地导航等页面 iframe 嵌入(默认开启,内网场景).""" + return (os.getenv("HUB_ALLOW_EMBED") or "true").strip().lower() in ( + "1", + "true", + "yes", + "on", + ) + + +def embed_frame_ancestors() -> str: + """CSP frame-ancestors;默认 *,可设 HUB_EMBED_ORIGINS=http://192.168.8.6:5070""" + raw = (os.getenv("HUB_EMBED_ORIGINS") or "*").strip() + if raw == "*": + return "*" + origins = [o.strip() for o in raw.split(",") if o.strip()] + return " ".join(origins) if origins else "*" + + +def set_session_cookie(response, request, token: str, *, embed: bool = False) -> None: + """ + embed=True:LocalNav 等跨站 iframe 嵌入时须 SameSite=None + Secure(仅 HTTPS 有效). + """ + secure = cookie_secure_for_request(request) + samesite = "lax" + if embed: + secure = True + samesite = "none" + response.set_cookie( + SESSION_COOKIE, + token, + httponly=True, + samesite=samesite, + path="/", + max_age=SESSION_MAX_AGE_SEC, + secure=secure, + ) + + +def clear_session_cookie(response, request, *, embed: bool = False) -> None: + secure = cookie_secure_for_request(request) + samesite = "lax" + if embed: + secure = True + samesite = "none" + response.delete_cookie( + SESSION_COOKIE, + path="/", + secure=secure, + samesite=samesite, + ) + + +def is_public_path(path: str, method: str) -> bool: + p = (path or "").split("?")[0].rstrip("/") or "/" + if p.startswith("/assets"): + return True + if p in ("/login", "/embed-auth", "/api/auth/login", "/api/auth/status", "/api/ping"): + return True + if p == "/api/auth/logout" and method.upper() == "POST": + return True + return False diff --git a/manual_trading_hub/okx_orders_lib.py b/manual_trading_hub/okx_orders_lib.py new file mode 100644 index 0000000..cc86548 --- /dev/null +++ b/manual_trading_hub/okx_orders_lib.py @@ -0,0 +1,53 @@ +""" +OKX 挂单聚合(子代理本地副本,避免依赖仓库根 PYTHONPATH). +普通委托 + 算法单 conditional / oco / trigger. +""" +from __future__ import annotations + +from typing import Any + + +def _order_dedupe_key(order: dict) -> str: + info = order.get("info") or {} + if not isinstance(info, dict): + info = {} + return str(order.get("id") or info.get("algoId") or info.get("ordId") or "") + + +def fetch_okx_all_open_orders(ex, exchange_symbol: str) -> list[dict]: + """合并 OKX 普通挂单与算法挂单(去重).""" + if not exchange_symbol: + return [] + ex.load_markets() + sym = exchange_symbol + try: + sym = ex.market(exchange_symbol)["symbol"] + except Exception: + pass + seen: set[str] = set() + out: list[dict] = [] + + def add_batch(batch: list | None) -> None: + for o in batch or []: + if not isinstance(o, dict): + continue + k = _order_dedupe_key(o) + if not k or k in seen: + continue + seen.add(k) + out.append(o) + + try: + add_batch(ex.fetch_open_orders(sym)) + except Exception: + pass + for params in ( + {"ordType": "conditional"}, + {"ordType": "oco"}, + {"trigger": True}, + ): + try: + add_batch(ex.fetch_open_orders(sym, params=dict(params))) + except Exception: + pass + return out diff --git a/manual_trading_hub/requirements.txt b/manual_trading_hub/requirements.txt new file mode 100644 index 0000000..397efc6 --- /dev/null +++ b/manual_trading_hub/requirements.txt @@ -0,0 +1,8 @@ +fastapi>=0.110,<1 +uvicorn[standard]>=0.27,<1 +python-multipart>=0.0.9,<1 +httpx>=0.27,<1 +ccxt>=4.2,<5 +PySocks>=1.7,<2 +psutil>=5.9,<8 +# 可选:服务端 pip install markdown 后渲染更完整;无则使用内置轻量渲染 diff --git a/manual_trading_hub/scripts/check_agents.sh b/manual_trading_hub/scripts/check_agents.sh new file mode 100644 index 0000000..9954e66 --- /dev/null +++ b/manual_trading_hub/scripts/check_agents.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# 检查三路子代理端口与 /status(在服务器上运行) +set -e + +check_one() { + local name="$1" port="$2" + echo "=== ${name} :${port} ===" + if command -v ss >/dev/null 2>&1; then + ss -tlnp 2>/dev/null | grep ":${port} " && echo " 端口: 已被占用" || echo " 端口: 空闲" + fi + if command -v curl >/dev/null 2>&1; then + local body + body=$(curl -sf --max-time 8 "http://127.0.0.1:${port}/status" 2>/dev/null) || { + echo " /status: 无法连接(agent 未启动或崩溃)" + return + } + echo " /status: ${body:0:200}" + if echo "${body}" | grep -q '"ok":true'; then + echo " 结果: OK" + else + echo " 结果: ok=false,见上 JSON" + fi + else + echo " (未安装 curl,跳过 HTTP 检测)" + fi + echo +} + +check_one "binance" 15200 +check_one "okx" 15201 +check_one "gate" 15202 + +echo "PM2 状态:" +pm2 status 2>/dev/null | grep -E 'manual-agent|manual-trading' || true diff --git a/manual_trading_hub/scripts/fix_env_crlf.sh b/manual_trading_hub/scripts/fix_env_crlf.sh new file mode 100644 index 0000000..bb1fa9a --- /dev/null +++ b/manual_trading_hub/scripts/fix_env_crlf.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# 去掉各目录 .env 的 Windows 换行符(解决 PM2 agent errored: $'\r': command not found) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +dirs=( + "${REPO}/manual_trading_hub" + "${REPO}/crypto_monitor_binance" + "${REPO}/crypto_monitor_okx" + "${REPO}/crypto_monitor_gate" +) + +fixed=0 +for d in "${dirs[@]}"; do + f="${d}/.env" + if [[ ! -f "${f}" ]]; then + echo "跳过(无文件): ${f}" + continue + fi + if grep -q $'\r' "${f}" 2>/dev/null; then + sed -i 's/\r$//' "${f}" + echo "已修复 CRLF: ${f}" + fixed=$((fixed + 1)) + else + echo "已是 LF: ${f}" + fi +done + +echo "完成,共修复 ${fixed} 个 .env." +echo "请重启子代理: cd ${REPO}/manual_trading_hub && pm2 restart manual-agent-gate manual-agent-binance manual-agent-okx" +echo "或: bash scripts/pm2_hub.sh restart" diff --git a/manual_trading_hub/scripts/fix_hub_deps.sh b/manual_trading_hub/scripts/fix_hub_deps.sh new file mode 100644 index 0000000..bbc5e4c --- /dev/null +++ b/manual_trading_hub/scripts/fix_hub_deps.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# 修复中控缺 python-multipart 等问题 +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +if [[ ! -d .venv ]]; then + python3 -m venv .venv +fi +# shellcheck source=/dev/null +source .venv/bin/activate +pip install -U pip +pip install -r requirements.txt +echo "OK: $(python -c 'import multipart; print("python-multipart", multipart.__version__)' 2>/dev/null || pip show python-multipart | head -1)" +echo "Hub ping (需 hub 已启动): curl -s http://127.0.0.1:5100/api/ping" diff --git a/manual_trading_hub/scripts/lib_load_dotenv.sh b/manual_trading_hub/scripts/lib_load_dotenv.sh new file mode 100644 index 0000000..ae76a0c --- /dev/null +++ b/manual_trading_hub/scripts/lib_load_dotenv.sh @@ -0,0 +1,16 @@ +# shellcheck shell=bash +# 供 run_agent.sh / run_hub.sh source:加载 .env 并去掉 Windows CRLF($'\r') +load_dotenv_file() { + local f="$1" + if [[ ! -f "${f}" ]]; then + return 1 + fi + set -a + set +e + # shellcheck disable=SC1090 + . <(sed 's/\r$//' "${f}") + local rc=$? + set -e + set +a + return "${rc}" +} diff --git a/manual_trading_hub/scripts/pm2_agents.sh b/manual_trading_hub/scripts/pm2_agents.sh new file mode 100644 index 0000000..5e1fb7f --- /dev/null +++ b/manual_trading_hub/scripts/pm2_agents.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# 仅子代理 PM2(中控请用 scripts/pm2_hub.sh 或 ecosystem.config.cjs 一次起全部) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HUB_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +ECO="${HUB_DIR}/ecosystem.agents.config.cjs" + +usage() { + cat <<'EOF' +用法: bash scripts/pm2_agents.sh + + 一般请用: bash scripts/pm2_hub.sh start (hub + agent 一起) + + 本脚本仅操作 3 路子代理(不含中控) + +仅启动币安: pm2 start ecosystem.agents.config.cjs --only manual-agent-binance +EOF +} + +cmd="${1:-}" + +if ! command -v pm2 >/dev/null 2>&1; then + echo "未找到 pm2,请先: npm install -g pm2" >&2 + exit 1 +fi + +cd "${HUB_DIR}" + +case "${cmd}" in + start) + pm2 start "${ECO}" + pm2 save 2>/dev/null || true + ;; + stop) + pm2 stop manual-agent-binance manual-agent-okx manual-agent-gate 2>/dev/null || true + ;; + restart) + pm2 restart manual-agent-binance manual-agent-okx manual-agent-gate 2>/dev/null \ + || pm2 start "${ECO}" + ;; + status) + pm2 status + ;; + logs) + pm2 logs manual-agent-binance manual-agent-okx manual-agent-gate --lines 100 + ;; + delete) + pm2 delete manual-agent-binance manual-agent-okx manual-agent-gate 2>/dev/null || true + ;; + *) + usage + exit 1 + ;; +esac diff --git a/manual_trading_hub/scripts/pm2_hub.sh b/manual_trading_hub/scripts/pm2_hub.sh new file mode 100644 index 0000000..58a1f6e --- /dev/null +++ b/manual_trading_hub/scripts/pm2_hub.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# 中控 + 子代理 统一 PM2 快捷脚本 +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HUB_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +ECO="${HUB_DIR}/ecosystem.config.cjs" + +# 与 ecosystem.config.cjs 中 name 一致 +PM2_NAMES=( + manual-agent-binance + manual-agent-okx + manual-agent-gate + manual-trading-hub +) + +usage() { + cat <<'EOF' +用法: bash scripts/pm2_hub.sh + + start 启动 ecosystem.config.cjs(3 路子代理 + 中控,已存在则 restart 全部) + stop 停止全部 + restart 重启全部 + status pm2 status + logs 全部相关进程日志 + delete 从 PM2 列表移除全部 + +仅中控: pm2 start ecosystem.config.cjs --only manual-trading-hub +EOF +} + +cmd="${1:-}" + +if ! command -v pm2 >/dev/null 2>&1; then + echo "未找到 pm2,请先: npm install -g pm2" >&2 + exit 1 +fi + +if [[ ! -f "${ECO}" ]]; then + echo "未找到 ${ECO}" >&2 + exit 1 +fi + +cd "${HUB_DIR}" + +_any_running() { + local n + for n in "${PM2_NAMES[@]}"; do + if pm2 describe "${n}" >/dev/null 2>&1; then + return 0 + fi + done + return 1 +} + +case "${cmd}" in + start) + if _any_running; then + pm2 restart "${ECO}" + echo "已重启:hub + 全部 agent" + else + pm2 start "${ECO}" + echo "已启动:hub + 全部 agent(共 ${#PM2_NAMES[@]} 个进程)" + fi + pm2 save 2>/dev/null || true + ;; + stop) + pm2 stop "${PM2_NAMES[@]}" 2>/dev/null || echo "部分或全部进程未在运行" + ;; + restart) + if _any_running; then + pm2 restart "${ECO}" + else + pm2 start "${ECO}" + fi + ;; + status) + pm2 status + ;; + logs) + pm2 logs "${PM2_NAMES[@]}" --lines 100 + ;; + delete) + pm2 delete "${PM2_NAMES[@]}" 2>/dev/null || echo "部分或全部进程不存在" + ;; + *) + usage + exit 1 + ;; +esac diff --git a/manual_trading_hub/scripts/pm2_restart_agents.sh b/manual_trading_hub/scripts/pm2_restart_agents.sh new file mode 100644 index 0000000..9e50cb0 --- /dev/null +++ b/manual_trading_hub/scripts/pm2_restart_agents.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# 仅重启失败的子代理(保留 hub / 已 online 的 agent) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HUB_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +ECO="${HUB_DIR}/ecosystem.config.cjs" + +cd "${HUB_DIR}" +chmod +x scripts/run_agent.sh scripts/run_hub.sh 2>/dev/null || true + +AGENTS=(manual-agent-binance manual-agent-okx manual-agent-gate) + +for n in "${AGENTS[@]}"; do + pm2 delete "${n}" 2>/dev/null || true +done + +pm2 start "${ECO}" --only manual-agent-binance +pm2 start "${ECO}" --only manual-agent-okx +pm2 start "${ECO}" --only manual-agent-gate + +pm2 save 2>/dev/null || true +echo "已重建 binance / okx / gate 子代理,请执行: bash scripts/check_agents.sh" diff --git a/manual_trading_hub/scripts/run_agent.sh b/manual_trading_hub/scripts/run_agent.sh new file mode 100644 index 0000000..88c5079 --- /dev/null +++ b/manual_trading_hub/scripts/run_agent.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# PM2 子代理入口:在策略目录(cwd)加载 .env 后启动 agent.py +set -e +set -o pipefail + +HUB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REPO_ROOT="$(cd "${HUB_DIR}/.." && pwd)" +export PYTHONPATH="${REPO_ROOT}${PYTHONPATH:+:${PYTHONPATH}}" +# shellcheck source=lib_load_dotenv.sh +source "${HUB_DIR}/scripts/lib_load_dotenv.sh" + +VENV_PY="${HUB_DIR}/.venv/bin/python" +AGENT_PY="${HUB_DIR}/agent.py" + +_PM2_EXCHANGE="${EXCHANGE:-}" +_PM2_PORT="${PORT:-}" +_PM2_HOST="${HOST:-}" + +if [[ ! -x "${VENV_PY}" ]]; then + echo "未找到 ${VENV_PY},请先在 manual_trading_hub: python3 -m venv .venv && pip install -r requirements.txt" >&2 + exit 1 +fi + +if [[ -f .env ]]; then + if grep -q $'\r' .env 2>/dev/null; then + echo "警告: $(pwd)/.env 含 Windows 换行(CRLF),请在仓库根执行: bash manual_trading_hub/scripts/fix_env_crlf.sh" >&2 + fi + if ! load_dotenv_file ".env"; then + echo "错误: $(pwd)/.env 加载失败" >&2 + exit 1 + fi +else + echo "警告: $(pwd) 下无 .env,agent 可能缺少 API 密钥" >&2 +fi + +[[ -n "${_PM2_EXCHANGE}" ]] && export EXCHANGE="${_PM2_EXCHANGE}" +[[ -n "${_PM2_PORT}" ]] && export PORT="${_PM2_PORT}" +[[ -n "${_PM2_HOST}" ]] && export HOST="${_PM2_HOST}" + +if command -v ss >/dev/null 2>&1 && [[ -n "${PORT:-}" ]]; then + if ss -tln 2>/dev/null | grep -q ":${PORT} "; then + echo "错误: 端口 ${PORT} 已被占用,agent 无法监听(exchange=${EXCHANGE:-?})" >&2 + exit 1 + fi +fi + +echo "agent start: exchange=${EXCHANGE:-?} port=${PORT:-?} cwd=$(pwd)" >&2 +exec "${VENV_PY}" "${AGENT_PY}" diff --git a/manual_trading_hub/scripts/run_hub.sh b/manual_trading_hub/scripts/run_hub.sh new file mode 100644 index 0000000..3c124be --- /dev/null +++ b/manual_trading_hub/scripts/run_hub.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# PM2 / 手动启动入口:加载 manual_trading_hub/.env 后运行 hub.py +set -e +set -o pipefail + +HUB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REPO_ROOT="$(cd "${HUB_DIR}/.." && pwd)" +export PYTHONPATH="${REPO_ROOT}${PYTHONPATH:+:${PYTHONPATH}}" +cd "${HUB_DIR}" + +# shellcheck source=lib_load_dotenv.sh +source "${HUB_DIR}/scripts/lib_load_dotenv.sh" + +VENV_PY="${HUB_DIR}/.venv/bin/python" +if [[ ! -x "${VENV_PY}" ]]; then + echo "未找到 ${VENV_PY},请先: python3 -m venv .venv && pip install -r requirements.txt" >&2 + exit 1 +fi + +if [[ -f "${HUB_DIR}/.env" ]]; then + load_dotenv_file "${HUB_DIR}/.env" || { + echo "错误: ${HUB_DIR}/.env 加载失败" >&2 + exit 1 + } +fi + +echo "run_hub: python=${VENV_PY} cwd=${HUB_DIR} PYTHONPATH=${PYTHONPATH}" >&2 +exec "${VENV_PY}" -u "${HUB_DIR}/hub.py" diff --git a/manual_trading_hub/scripts/verify_hub_deploy.sh b/manual_trading_hub/scripts/verify_hub_deploy.sh new file mode 100644 index 0000000..46a87c5 --- /dev/null +++ b/manual_trading_hub/scripts/verify_hub_deploy.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# 在服务器上检查中控是否为最新代码(无 api_trade_key,已装 multipart,进程可访问) +set -euo pipefail +HUB_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$HUB_DIR" + +HUB_PORT=5100 +if [[ -f .env ]]; then + p=$(grep -E '^HUB_PORT=' .env 2>/dev/null | tail -1 | cut -d= -f2- | tr -d '\r" ') + [[ -n "${p}" ]] && HUB_PORT="${p}" +fi +PING_URL="http://127.0.0.1:${HUB_PORT}/api/ping" + +echo "=== hub.py 检查 ===" +if grep -n 'def api_trade_key' hub.py 2>/dev/null; then + echo "FAIL: 仍是旧版 hub.py(含 api_trade_key),请 git pull" + exit 1 +fi +if ! grep -q 'HUB_BUILD' hub.py; then + echo "FAIL: hub.py 缺少 HUB_BUILD 标记" + exit 1 +fi +echo "OK: 无 api_trade_key,含 HUB_BUILD" + +echo "=== python-multipart ===" +# shellcheck source=/dev/null +source .venv/bin/activate +python -c "import multipart; print('OK:', multipart.__version__)" + +echo "=== 端口 ${HUB_PORT} ===" +if command -v ss >/dev/null 2>&1; then + ss -ltn | grep -E ":${HUB_PORT}\\b" || echo "WARN: 未监听 ${HUB_PORT},请 pm2 restart manual-trading-hub" +elif command -v netstat >/dev/null 2>&1; then + netstat -ltn | grep -E ":${HUB_PORT}\\b" || echo "WARN: 未监听 ${HUB_PORT}" +else + echo "(跳过端口检查)" +fi + +echo "=== PM2 manual-trading-hub ===" +if command -v pm2 >/dev/null 2>&1; then + pm2 describe manual-trading-hub 2>/dev/null | grep -E 'status|restarts|uptime|script path' || pm2 list | grep -i hub || true +fi + +echo "=== GET ${PING_URL} ===" +HTTP_CODE=$(curl -sS -o /tmp/hub_ping_body.txt -w "%{http_code}" "${PING_URL}" || echo "000") +echo "HTTP ${HTTP_CODE}" +cat /tmp/hub_ping_body.txt +echo "" +if [[ "${HTTP_CODE}" == "200" ]]; then + python -m json.tool /tmp/hub_ping_body.txt + if grep -q '20260521-no-trade-ui' /tmp/hub_ping_body.txt; then + echo "OK: build 正确" + else + echo "WARN: build 字段不是 20260521-no-trade-ui,请 pm2 restart manual-trading-hub" + fi +else + echo "FAIL: ping 未返回 200.常见原因:进程未启动或崩溃." + echo " 执行: pm2 restart manual-trading-hub && sleep 2 && bash scripts/verify_hub_deploy.sh" + exit 1 +fi diff --git a/manual_trading_hub/scripts/后台运行-Ubuntu.md b/manual_trading_hub/scripts/后台运行-Ubuntu.md new file mode 100644 index 0000000..0d76b69 --- /dev/null +++ b/manual_trading_hub/scripts/后台运行-Ubuntu.md @@ -0,0 +1,42 @@ +# 中控与子代理 · 后台常驻(Ubuntu) + +**唯一推荐方式:PM2.** + +请仅使用 PM2 托管 `hub.py` 与 `agent.py`,勿与 nohup 等方式重复启动同一端口. + +--- + +## 启动 + +```bash +cd /opt/crypto_monitor_user/manual_trading_hub +source .venv/bin/activate +pip install -r requirements.txt +cp -n .env.example .env # 首次 + +pm2 start ecosystem.config.cjs +pm2 save +pm2 list +``` + +一条 `ecosystem.config.cjs` 会拉起 **4 个子代理 + 1 个 hub**. + +--- + +## 常用命令 + +```bash +pm2 logs manual-trading-hub +pm2 restart manual-trading-hub +pm2 restart all +bash scripts/verify_hub_deploy.sh +``` + +--- + +## 详细说明 + +| 文档 | 内容 | +|------|------| +| [../部署文档.md](../部署文档.md) | 端口,反代,故障排查 | +| [../../docs/ubuntu-server.md](../../docs/ubuntu-server.md) | Python / Node / PM2 版本与三所启动顺序 | diff --git a/manual_trading_hub/settings_store.py b/manual_trading_hub/settings_store.py new file mode 100644 index 0000000..df57760 --- /dev/null +++ b/manual_trading_hub/settings_store.py @@ -0,0 +1,136 @@ +"""中控交易所配置(hub_settings.json).""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +DIR = Path(__file__).resolve().parent +SETTINGS_PATH = DIR / "hub_settings.json" +_REPO_ROOT = DIR.parent + +import sys + +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) +if str(DIR) not in sys.path: + sys.path.insert(0, str(DIR)) + +from hub_supervisor_lib import DEFAULT_SUPERVISOR, normalize_supervisor_settings +from lib.hub.hub_backup_lib import normalize_backup_settings + +DEFAULT_DISPLAY = { + "show_account_pnl": True, + "show_nav_funds": True, + "show_nav_dashboard": True, + "show_nav_plan": True, + "show_nav_archive": True, + "show_nav_quotes": True, + "show_nav_ai": True, + "show_nav_calculator": True, + "show_nav_strategy": True, + "show_nav_help": True, + "show_nav_logs": True, +} + +DEFAULT_EXCHANGES = [ + { + "id": "0", + "key": "binance", + "name": "币安 · crypto_monitor_binance", + "agent_url": "http://127.0.0.1:15200", + "flask_url": "http://127.0.0.1:5001", + "review_url": "http://127.0.0.1:5001/records", + "enabled": True, + "capabilities": ["key", "trend"], + }, + { + "id": "1", + "key": "okx", + "name": "OKX · crypto_monitor_okx", + "agent_url": "http://127.0.0.1:15201", + "flask_url": "http://127.0.0.1:5004", + "review_url": "http://127.0.0.1:5004/records", + "enabled": True, + "capabilities": ["key", "trend", "options"], + }, + { + "id": "2", + "key": "gate", + "name": "Gate · crypto_monitor_gate", + "agent_url": "http://127.0.0.1:15202", + "flask_url": "http://127.0.0.1:5000", + "review_url": "http://127.0.0.1:5000/records", + "enabled": True, + "capabilities": ["key", "trend"], + }, +] + + +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 env_force_disabled_ids() -> set[str]: + # 未设置时默认不强制关闭任何账户;要用旧行为可设 HUB_DISABLED_IDS=1 + raw = (os.getenv("HUB_DISABLED_IDS") or "").strip() + return _ids_from_csv(raw) + + +def normalize_display_prefs(raw: dict | None) -> dict: + out = dict(DEFAULT_DISPLAY) + if isinstance(raw, dict): + for key in DEFAULT_DISPLAY: + if key in raw: + out[key] = bool(raw.get(key)) + return out + + +def load_settings() -> dict: + data = { + "exchanges": [dict(x) for x in DEFAULT_EXCHANGES], + "version": 1, + "display": dict(DEFAULT_DISPLAY), + } + if SETTINGS_PATH.is_file(): + try: + loaded = json.loads(SETTINGS_PATH.read_text(encoding="utf-8")) + if isinstance(loaded, dict) and isinstance(loaded.get("exchanges"), list): + data = loaded + except Exception: + pass + data["display"] = normalize_display_prefs(data.get("display")) + data["supervisor"] = normalize_supervisor_settings(data.get("supervisor")) + data["backup"] = normalize_backup_settings(data.get("backup")) + force_off = env_force_disabled_ids() + for ex in data.get("exchanges") or []: + if str(ex.get("id")) in force_off: + ex["enabled"] = False + ex["env_disabled"] = True + else: + ex.setdefault("env_disabled", False) + if ex.get("key") == "okx": + caps = list(ex.get("capabilities") or []) + if "options" not in caps: + caps.append("options") + ex["capabilities"] = caps + return data + + +def save_settings(data: dict) -> None: + payload = dict(data) + payload["display"] = normalize_display_prefs(payload.get("display")) + payload["supervisor"] = normalize_supervisor_settings(payload.get("supervisor")) + payload["backup"] = normalize_backup_settings(payload.get("backup")) + SETTINGS_PATH.write_text( + json.dumps(payload, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + +def enabled_exchanges(data: dict | None = None) -> list[dict]: + data = data or load_settings() + return [x for x in data.get("exchanges") or [] if x.get("enabled")] diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css new file mode 100644 index 0000000..a070160 --- /dev/null +++ b/manual_trading_hub/static/app.css @@ -0,0 +1,10586 @@ +:root, +html[data-theme="dark"] { + --bg: #050810; + --bg-elevated: #0a1018; + --panel: rgba(12, 20, 32, 0.82); + --panel-hover: rgba(18, 28, 44, 0.9); + --panel-solid: #141a2a; + --panel-solid-border: #2a3150; + --nav-bg: rgba(0, 0, 0, 0.35); + --overlay: rgba(0, 0, 0, 0.45); + --chart-surface: #0a1018; + --chart-bar-bg: rgba(8, 14, 24, 0.96); + --inset-surface: rgba(0, 0, 0, 0.32); + --inset-surface-strong: rgba(0, 0, 0, 0.42); + --section-surface: rgba(0, 0, 0, 0.22); + --pos-card-bg: rgba(10, 16, 28, 0.95); + --fs-scrim: rgba(2, 6, 12, 0.92); + --btn-surface: rgba(0, 0, 0, 0.4); + --text: #e8f4ff; + --muted: #6b8aa8; + --border: rgba(0, 212, 255, 0.22); + --border-soft: rgba(0, 212, 255, 0.1); + --green: #00ff9d; + --red: #ff4d6d; + --accent: #00d4ff; + --accent-2: #7b61ff; + --accent-dim: rgba(0, 212, 255, 0.12); + --glow: 0 0 24px rgba(0, 212, 255, 0.15); + --radius: 10px; + --shadow: 0 8px 32px rgba(0, 0, 0, 0.45); + --plan-title: #f0f2ff; + --plan-meta: #8892b0; + --plan-meta-accent: #6ab8ff; + --plan-lbl: #8b95b8; + --plan-val: #f0f2ff; + --plan-val-neutral: #cfd3ef; + --plan-border-dash: #2a3558; + --plan-col-divider: #243050; + --plan-dca-th: #6a7598; + --plan-close-bg: #5c1e2a; + --plan-close-fg: #ffb4b4; + --plan-be-label: #cfd3ef; + --plan-be-input-bg: #0f1424; + --plan-be-input-border: #304164; + --plan-be-btn-bg: #1f4a3a; + --primary-btn-bg: linear-gradient(135deg, rgba(0, 212, 255, 0.38), rgba(123, 97, 255, 0.28)); + --primary-btn-fg: #ffffff; + --primary-btn-border: var(--accent); + --status-done: #4cd97f; + --status-pending: #9aa3c4; + --ai-sum-heading: #9adbff; + --ai-sum-heading-bg: rgba(0, 212, 255, 0.07); + --ai-sum-heading-border: rgba(0, 212, 255, 0.38); + --ai-sum-name: #d4ecff; + --font: "JetBrains Mono", ui-monospace, Consolas, monospace; + --display: "Orbitron", var(--font); + --mono: var(--font); + --layout-max: 1520px; + --archive-chart-bg: rgba(0, 0, 0, 0.12); + --archive-axis-fg: rgba(255, 255, 255, 0.42); + --archive-grid-stroke: rgba(255, 255, 255, 0.06); + --archive-grid-zero-stroke: rgba(255, 255, 255, 0.16); + --archive-viz-surface: rgba(0, 0, 0, 0.12); + --archive-viz-surface-border: rgba(255, 255, 255, 0.05); + --archive-viz-kpi-bg: rgba(255, 255, 255, 0.04); + --archive-viz-kpi-border: rgba(255, 255, 255, 0.06); + --archive-viz-track-bg: rgba(255, 255, 255, 0.08); + --archive-viz-mid-line: rgba(255, 255, 255, 0.2); + --archive-profit: #4cd97f; + --archive-loss: #ff6b6b; + --nav-link-idle: var(--muted); + --nav-link-hover-bg: var(--panel-hover); + --nav-link-active-fg: var(--accent); + color-scheme: dark; +} + +html[data-theme="light"] { + --bg: #d4dde8; + --bg-elevated: #f6f9fc; + --panel: rgba(255, 255, 255, 0.94); + --panel-hover: rgba(248, 252, 255, 0.98); + --panel-solid: #ffffff; + --panel-solid-border: #b8c8d8; + --nav-bg: rgba(255, 255, 255, 0.92); + --overlay: rgba(15, 35, 60, 0.28); + --chart-surface: #f0f4f9; + --chart-bar-bg: #e8eef5; + --inset-surface: rgba(255, 255, 255, 0.9); + --inset-surface-strong: #eef3f8; + --section-surface: rgba(255, 255, 255, 0.82); + --pos-card-bg: rgba(255, 255, 255, 0.96); + --fs-scrim: rgba(212, 221, 232, 0.94); + --btn-surface: rgba(255, 255, 255, 0.85); + --text: #142232; + --muted: #4a6078; + --border: rgba(0, 95, 140, 0.26); + --border-soft: rgba(0, 75, 115, 0.14); + --green: #0a8f5c; + --red: #c93552; + --accent: #006e9a; + --accent-2: #5b4fc7; + --accent-dim: rgba(0, 110, 154, 0.12); + --glow: 0 0 16px rgba(0, 110, 154, 0.1); + --shadow: 0 6px 24px rgba(30, 60, 100, 0.1); + --plan-title: var(--text); + --plan-meta: var(--muted); + --plan-meta-accent: var(--accent); + --plan-lbl: var(--muted); + --plan-val: var(--text); + --plan-val-neutral: #5a6f85; + --plan-border-dash: rgba(0, 75, 115, 0.2); + --plan-col-divider: rgba(0, 75, 115, 0.16); + --plan-dca-th: var(--muted); + --plan-close-bg: rgba(201, 53, 82, 0.12); + --plan-close-fg: var(--red); + --plan-be-label: var(--muted); + --plan-be-input-bg: var(--bg-elevated); + --plan-be-input-border: var(--border-soft); + --plan-be-btn-bg: rgba(10, 143, 92, 0.14); + --primary-btn-bg: #006e9a; + --primary-btn-fg: #ffffff; + --primary-btn-border: #005a82; + --status-done: #087a50; + --status-pending: #3d556d; + --ai-sum-heading: #9e1e38; + --ai-sum-heading-bg: rgba(201, 53, 82, 0.08); + --ai-sum-heading-border: rgba(201, 53, 82, 0.42); + --ai-sum-name: #7a182c; + --archive-chart-bg: var(--chart-surface); + --archive-axis-fg: var(--muted); + --archive-grid-stroke: rgba(20, 34, 50, 0.1); + --archive-grid-zero-stroke: rgba(20, 34, 50, 0.28); + --archive-viz-surface: var(--inset-surface-strong); + --archive-viz-surface-border: var(--border-soft); + --archive-viz-kpi-bg: var(--bg-elevated); + --archive-viz-kpi-border: var(--border-soft); + --archive-viz-track-bg: var(--chart-bar-bg); + --archive-viz-mid-line: rgba(20, 34, 50, 0.22); + --archive-profit: var(--green); + --archive-loss: var(--red); + --nav-link-idle: #3a5068; + --nav-link-hover-bg: rgba(0, 110, 154, 0.08); + --nav-link-active-fg: #004d6e; + color-scheme: light; +} + +* { + box-sizing: border-box; +} + +body { + font-family: var(--font); + background: var(--bg); + color: var(--text); + margin: 0; + font-size: 13px; + line-height: 1.55; + min-height: 100vh; +} + +a { + color: var(--accent); + text-decoration: none; +} +a:hover { + text-decoration: underline; + text-shadow: 0 0 12px rgba(0, 212, 255, 0.4); +} + +.app-bg, +.login-bg { + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background: + linear-gradient(rgba(0, 212, 255, 0.03) 1px, transparent 1px), + linear-gradient(90deg, rgba(0, 212, 255, 0.03) 1px, transparent 1px), + radial-gradient(ellipse 80% 50% at 50% -20%, rgba(0, 212, 255, 0.12), transparent), + radial-gradient(ellipse 60% 40% at 100% 100%, rgba(123, 97, 255, 0.08), transparent); + background-size: 48px 48px, 48px 48px, auto, auto; +} + +.app-bg::after, +.login-bg::after { + content: ""; + position: absolute; + inset: 0; + background: repeating-linear-gradient( + 0deg, + transparent, + transparent 2px, + rgba(0, 0, 0, 0.03) 2px, + rgba(0, 0, 0, 0.03) 4px + ); + opacity: 0.4; +} + +.app-shell { + position: relative; + z-index: 1; + width: 100%; + max-width: var(--layout-max); + margin-left: auto; + margin-right: auto; + padding: 0 24px 48px; +} + +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 18px 0; + border-bottom: 1px solid var(--border-soft); + margin-bottom: 8px; + flex-wrap: wrap; +} + +.brand { + display: flex; + align-items: center; + gap: 12px; +} + +.brand-mark { + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 12px var(--accent), 0 0 24px rgba(0, 212, 255, 0.5); + animation: pulse-dot 2s ease-in-out infinite; +} + +@keyframes pulse-dot { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.7; + transform: scale(0.92); + } +} + +.brand-title { + font-family: var(--display); + font-size: 15px; + font-weight: 600; + letter-spacing: 0.08em; + color: var(--text); +} + +.brand-sub { + font-size: 10px; + color: var(--muted); + letter-spacing: 0.14em; + margin-top: 2px; +} + +.header-right { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.sys-pill { + font-size: 10px; + letter-spacing: 0.12em; + padding: 5px 10px; + border-radius: 999px; + border: 1px solid var(--border); + color: var(--accent); + background: var(--accent-dim); + font-family: var(--display); +} + +.sys-pill.warn { + color: var(--red); + border-color: rgba(255, 77, 109, 0.4); + background: rgba(255, 77, 109, 0.1); +} + +.sys-pill.syncing { + opacity: 0.85; + animation: sys-pill-pulse 1.2s ease-in-out infinite; +} + +@keyframes sys-pill-pulse { + 50% { + opacity: 0.55; + } +} + +.theme-toggle { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 3px; + border-radius: var(--radius); + border: 1px solid var(--border-soft); + background: var(--nav-bg); + backdrop-filter: blur(8px); +} + +.theme-toggle-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 32px; + padding: 0; + border: none; + border-radius: 7px; + background: transparent; + color: var(--muted); + cursor: pointer; + transition: background 0.15s, color 0.15s, box-shadow 0.15s; +} + +.theme-toggle-btn:hover { + color: var(--text); + background: var(--panel-hover); +} + +.theme-toggle-btn.is-active { + color: var(--accent); + background: var(--accent-dim); + box-shadow: inset 0 0 0 1px var(--border); +} + +.theme-toggle-btn .theme-icon { + display: block; +} + +.top-nav { + display: flex; + gap: 4px; + background: var(--nav-bg); + padding: 4px; + border-radius: var(--radius); + border: 1px solid var(--border-soft); + backdrop-filter: blur(8px); +} + +.top-nav a { + padding: 8px 16px; + border-radius: 7px; + text-decoration: none; + color: var(--nav-link-idle); + font-size: 12px; + font-weight: 500; + letter-spacing: 0.04em; + transition: background 0.15s, color 0.15s, box-shadow 0.15s; + border: 1px solid transparent; +} + +.top-nav a.nav-hidden { + display: none !important; +} + +.top-nav a:hover { + color: var(--text); + background: var(--nav-link-hover-bg); + text-decoration: none; +} + +.top-nav a.active { + background: linear-gradient(135deg, rgba(0, 212, 255, 0.2), rgba(123, 97, 255, 0.15)); + color: var(--nav-link-active-fg); + border-color: var(--border); + box-shadow: var(--glow); + font-weight: 600; +} + +button.ghost { + background: transparent; + border: 1px solid var(--border-soft); + color: var(--muted); + font-size: 11px; + padding: 7px 12px; +} + +button.ghost:hover:not(:disabled) { + color: var(--text); + border-color: var(--border); +} + +.page.hidden { + display: none; +} + +.page-head { + margin: 24px 0 16px; +} + +.page-head h1 { + margin: 0 0 6px; + font-family: var(--display); + font-size: 20px; + font-weight: 600; + letter-spacing: 0.06em; + display: flex; + align-items: center; + gap: 10px; +} + +.head-tag { + font-size: 11px; + padding: 3px 8px; + border-radius: 4px; + background: var(--accent-dim); + border: 1px solid var(--border); + color: var(--accent); +} + +.page-desc { + margin: 0; + font-size: 12px; + color: var(--muted); +} + +.hint-box { + margin-bottom: 16px; + border: 1px solid var(--border-soft); + border-radius: var(--radius); + background: var(--panel); + backdrop-filter: blur(10px); + overflow: hidden; +} + +.hint-box summary { + padding: 10px 14px; + cursor: pointer; + font-size: 12px; + color: var(--muted); + user-select: none; + list-style: none; +} +.hint-box summary::-webkit-details-marker { + display: none; +} +.hint-box summary::before { + content: "▸ "; + color: var(--accent); +} +.hint-box[open] summary::before { + content: "▾ "; +} + +.hint-box .hint-body { + padding: 0 14px 12px; + font-size: 11px; + color: var(--muted); + line-height: 1.65; + border-top: 1px solid var(--border-soft); +} +.hint-box .hint-body code { + font-family: var(--mono); + font-size: 10px; + background: rgba(0, 212, 255, 0.08); + padding: 1px 5px; + border-radius: 4px; + color: var(--accent); + border: 1px solid var(--border-soft); +} + +.toolbar { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + padding: 12px 14px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: 16px; + backdrop-filter: blur(10px); + box-shadow: var(--glow); +} + +.toolbar-spacer { + flex: 1; + min-width: 8px; +} + +.toolbar-meta { + font-size: 11px; + color: var(--muted); + font-family: var(--mono); +} + +button, +.btn { + background: var(--btn-surface); + color: var(--text); + border: 1px solid var(--border); + border-radius: 8px; + padding: 8px 16px; + cursor: pointer; + font-size: 12px; + font-family: var(--font); + font-weight: 500; + letter-spacing: 0.03em; + transition: border-color 0.15s, background 0.15s, box-shadow 0.15s; +} + +button:hover:not(:disabled) { + border-color: var(--accent); + background: var(--panel-hover); + box-shadow: 0 0 16px rgba(0, 212, 255, 0.12); +} + +button.primary { + background: var(--primary-btn-bg); + border-color: var(--primary-btn-border); + color: var(--primary-btn-fg); + font-weight: 600; + text-shadow: none; +} + +button.danger { + border-color: rgba(255, 77, 109, 0.5); + color: var(--red); + background: rgba(255, 77, 109, 0.08); +} + +button.danger:hover:not(:disabled) { + background: rgba(255, 77, 109, 0.15); + border-color: var(--red); + box-shadow: 0 0 16px rgba(255, 77, 109, 0.2); +} + +button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.btn-link { + background: transparent; + border: 1px solid var(--border-soft); + color: var(--accent); + padding: 5px 10px; + font-size: 11px; + border-radius: 6px; +} +.btn-link:hover { + background: var(--accent-dim); + text-decoration: none; + box-shadow: var(--glow); +} + +.btn-close-pos.btn-sm { + white-space: nowrap; +} + +.data-table .td-actions { + text-align: right; + width: 1%; + white-space: nowrap; +} + +.chk-label { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--muted); + cursor: pointer; +} + +.card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + backdrop-filter: blur(12px); + transition: border-color 0.2s, box-shadow 0.2s; + position: relative; +} + +.card::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, transparent, var(--accent), transparent); + opacity: 0.5; +} + +.card.card-online { + border-color: rgba(0, 255, 157, 0.35); +} +.card.card-online::before { + background: linear-gradient(90deg, transparent, var(--green), transparent); + opacity: 0.8; +} + +.card.card-offline { + border-color: rgba(255, 77, 109, 0.3); +} +.card.card-offline::before { + background: linear-gradient(90deg, transparent, var(--red), transparent); +} + +.card:hover { + border-color: rgba(0, 212, 255, 0.45); + box-shadow: var(--glow); +} + +.card-head { + padding: 14px 16px; + border-bottom: 1px solid var(--border-soft); + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; +} + +.card-title-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} +.status-dot.ok { + background: var(--green); + box-shadow: 0 0 8px var(--green); +} +.status-dot.bad { + background: var(--red); + box-shadow: 0 0 8px var(--red); +} + +.status-dot.warn { + background: #ffb020; + box-shadow: 0 0 8px rgba(255, 176, 32, 0.45); +} + +/* —— 手机监控总览瓦片 —— */ +.monitor-alert-summary { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 6px 10px; + margin: 0 0 10px; + padding: 10px 12px; + border-radius: var(--radius); + border: 1px solid var(--border-soft); + background: var(--panel); + font-size: 12px; +} + +.monitor-alert-summary.hidden { + display: none !important; +} + +.mas-item.mas-ok { + color: var(--green); +} + +.mas-item.mas-warn { + color: #ffb020; +} + +.mas-item.mas-err { + color: var(--red); +} + +.mas-sep { + color: var(--muted); +} + +.monitor-macro-banner { + margin: 0 0 12px; + padding: 12px 14px; + border-radius: var(--radius); + border: 1px solid rgba(255, 176, 32, 0.45); + background: linear-gradient(90deg, rgba(255, 176, 32, 0.12), rgba(255, 120, 80, 0.08)); +} + +.monitor-macro-banner.hidden { + display: none !important; +} + +.monitor-macro-banner-inner { + display: flex; + align-items: flex-start; + gap: 10px; + flex-wrap: wrap; +} + +.monitor-macro-badge { + flex: 0 0 auto; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + padding: 4px 10px; + border-radius: 999px; + color: #ffb020; + border: 1px solid rgba(255, 176, 32, 0.5); + background: rgba(255, 176, 32, 0.12); +} + +.monitor-macro-text { + flex: 1 1 240px; + font-size: 13px; + line-height: 1.5; + color: var(--text); +} + +.monitor-macro-banner.phase-imminent { + border-color: rgba(255, 120, 80, 0.55); + background: linear-gradient(90deg, rgba(255, 120, 80, 0.14), rgba(255, 176, 32, 0.1)); +} + +.macro-event-form { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + margin: 12px 0 14px; + align-items: end; +} + +.macro-event-field { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 12px; + color: var(--muted); +} + +.macro-event-field-wide { + grid-column: 1 / -1; +} + +.macro-event-field input, +.macro-event-field select { + background: var(--bg-elevated); + border: 1px solid var(--border); + color: var(--text); + border-radius: 8px; + padding: 9px 11px; + font-size: 12px; + font-family: var(--mono); +} + +.macro-event-actions { + display: flex; + gap: 8px; + align-items: center; +} + +.macro-event-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.macro-event-row { + display: grid; + grid-template-columns: minmax(140px, 1.2fr) minmax(150px, 1fr) minmax(120px, 1fr) auto; + gap: 10px; + align-items: center; + padding: 10px 12px; + border: 1px solid var(--border-soft); + border-radius: var(--radius); + background: var(--panel); + font-size: 12px; +} + +.macro-event-row.is-active { + border-color: rgba(255, 176, 32, 0.45); + box-shadow: inset 0 0 0 1px rgba(255, 176, 32, 0.12); +} + +.macro-event-row-title { + font-weight: 600; + color: var(--text); +} + +.macro-event-row-meta { + color: var(--muted); + font-family: var(--mono); + font-size: 11px; +} + +.macro-event-row-actions { + display: flex; + gap: 6px; + justify-content: flex-end; +} + +.macro-event-empty { + padding: 14px; + text-align: center; + color: var(--muted); + font-size: 12px; + border: 1px dashed var(--border-soft); + border-radius: var(--radius); +} + +.host-status-panel { + margin: 0 0 12px; + border-radius: var(--radius); + border: 1px solid var(--border-soft); + background: var(--panel); + font-size: 12px; +} + +.host-status-panel.hidden { + display: none !important; +} + +.host-status-summary { + display: flex; + align-items: center; + gap: 8px 12px; + padding: 10px 12px; + cursor: pointer; + list-style: none; + user-select: none; +} + +.host-status-summary::-webkit-details-marker { + display: none; +} + +.host-status-summary::before { + content: "▸"; + color: var(--muted); + font-size: 11px; + transition: transform 0.15s ease; + flex-shrink: 0; +} + +.host-status-panel[open] > .host-status-summary::before { + transform: rotate(90deg); +} + +.host-status-summary-title { + font-weight: 600; + color: var(--text); + white-space: nowrap; +} + +.host-status-summary-text { + font-size: 11px; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1 1 auto; +} + +.host-status-summary-text.bad { + color: var(--red); +} + +.host-summary-host, +.host-summary-sep { + color: var(--muted); +} + +.host-metric-tone.ok, +.host-metric-val.ok { + color: var(--green); + font-weight: 600; +} + +.host-metric-tone.bad, +.host-metric-val.bad { + color: var(--red); + font-weight: 600; +} + +.host-status-bar { + display: flex; + flex-direction: column; + gap: 12px; + padding: 0 12px 12px; + border-top: 1px solid var(--border-soft); + margin-top: 0; + padding-top: 12px; + border-radius: 0; + border-left: none; + border-right: none; + border-bottom: none; + background: transparent; +} + +.host-status-top { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px 16px; +} + +.host-status-head { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1 1 220px; +} + +.host-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + background: var(--muted); +} + +.host-status-dot.ok { + background: var(--green); + box-shadow: 0 0 8px var(--green); +} + +.host-status-dot.warn { + background: #ffb020; + box-shadow: 0 0 8px rgba(255, 176, 32, 0.45); +} + +.host-status-dot.bad { + background: var(--red); + box-shadow: 0 0 8px var(--red); +} + +.host-status-name { + font-weight: 600; + color: var(--text); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.host-status-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 6px 14px; + color: var(--muted); + font-size: 11px; + flex: 0 1 auto; +} + +.host-status-uptime, +.host-status-updated { + white-space: nowrap; +} + +.host-status-metrics { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.host-metric-card { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--border-soft); + background: rgba(0, 0, 0, 0.14); +} + +html[data-theme="light"] .host-metric-card { + background: rgba(0, 0, 0, 0.03); +} + +.host-metric-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; +} + +.host-metric-label { + color: var(--muted); + font-size: 11px; + white-space: nowrap; +} + +.host-metric-bar { + height: 7px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.06); + overflow: hidden; +} + +html[data-theme="light"] .host-metric-bar { + background: rgba(0, 0, 0, 0.06); +} + +.host-metric-fill { + display: block; + height: 100%; + width: 0%; + border-radius: inherit; + background: #22c55e; + transition: width 0.35s ease, background 0.2s ease; +} + +.host-metric-fill.ok { + background: #22c55e; +} + +.host-metric-fill.warn { + background: #ffb020; +} + +.host-metric-fill.bad { + background: var(--red); +} + +.host-metric-val { + color: var(--text); + font-variant-numeric: tabular-nums; + white-space: nowrap; + font-size: 13px; + font-weight: 600; +} + +.host-metric-val-net { + font-size: 11px; + font-weight: 500; + color: var(--muted); +} + +.host-metric-sub, +.host-net-line { + color: var(--muted); + font-size: 11px; + font-variant-numeric: tabular-nums; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.host-net-lines { + display: flex; + flex-direction: column; + gap: 4px; +} + +@media (max-width: 1080px) { + .host-status-metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.grid-monitor.grid-monitor-tiles { + grid-template-columns: repeat(2, minmax(0, 1fr)) !important; + gap: 10px; + align-content: start; +} + +.hub-tile { + margin: 0; + padding: 0; + min-height: 118px; + overflow: hidden; +} + +.hub-tile .hub-tile-body { + cursor: pointer; + padding: 12px 12px 10px; + display: flex; + flex-direction: column; + gap: 6px; + min-height: 118px; +} + +.hub-tile-top { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex-wrap: wrap; +} + +.hub-tile-name { + font-family: var(--display); + font-size: 13px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.hub-tile-pnl { + font-size: 20px; + font-weight: 600; + line-height: 1.2; +} + +.hub-tile-pnl small { + font-size: 11px; + font-weight: 500; + color: var(--muted); +} + +.hub-tile-meta { + font-size: 11px; + color: var(--muted); + line-height: 1.35; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.hub-tile-foot { + margin-top: auto; + font-size: 10px; + color: var(--muted); +} + +.hub-tile-error { + border-color: rgba(255, 77, 109, 0.45); + box-shadow: 0 0 0 1px rgba(255, 77, 109, 0.12); +} + +.hub-tile-warn { + border-color: rgba(255, 176, 32, 0.45); + box-shadow: 0 0 0 1px rgba(255, 176, 32, 0.1); +} + +.hub-tile-ok { + border-color: var(--border-soft); +} + +.hub-tile-body:hover .hub-tile-name { + color: var(--accent); +} + +.card-title { + font-family: var(--display); + font-size: 13px; + font-weight: 600; + letter-spacing: 0.05em; + margin: 0 0 4px; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +.card-sub { + font-size: 10px; + color: var(--muted); + font-family: var(--mono); + word-break: break-all; +} + +.card-actions { + display: flex; + gap: 6px; + align-items: center; + flex-shrink: 0; +} + +.card-body { + padding: 14px 16px; +} + +.grid-monitor { + display: grid; + gap: 16px; + /* 列数由 app.js syncMonitorGridColumns 按卡片数量设置 */ + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.grid-monitor.grid-monitor-2x2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.grid-monitor.grid-monitor-with-stats { + grid-template-columns: 1fr; +} + +.grid-monitor.grid-monitor-options-split { + display: flex; + flex-direction: column; + gap: 16px; +} + +.grid-monitor.grid-monitor-options-split .monitor-stats-card { + width: 100%; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 0; + padding: 8px 14px; +} + +.grid-monitor.grid-monitor-options-split .monitor-stats-card .card-head, +.monitor-stats-card .monitor-stats-head { + padding: 0; + border-bottom: none; + flex: 0 0 auto; + width: 100%; + align-items: center; +} + +.monitor-stats-head-main { + min-width: 0; + flex: 1 1 auto; +} + +.monitor-stats-float-summary { + flex: 0 0 auto; + text-align: right; + padding: 2px 4px 2px 12px; +} + +.monitor-stats-float-summary .monitor-stat-label { + margin-bottom: 0; + font-size: 11px; +} + +.monitor-stats-float-value { + font-size: 18px; + font-weight: 700; + line-height: 1.2; +} + +.btn-monitor-stats-toggle { + font-size: 11px; + padding: 2px 8px; + min-height: 0; + line-height: 1.3; + color: var(--accent); + border-color: var(--border-soft); +} + +.monitor-stats-card.is-collapsed .monitor-stats-detail { + display: none; +} + +.monitor-stats-card:not(.is-collapsed) .monitor-stats-float-summary { + display: none; +} + +.grid-monitor.grid-monitor-options-split .monitor-stats-card .card-body { + padding: 8px 0 0; + flex: 1; + min-width: 0; +} + +.grid-monitor.grid-monitor-options-split .monitor-stats-card .card-title { + font-size: 12px; + margin: 0; +} + +.grid-monitor.grid-monitor-options-split .monitor-stats-card .card-sub { + font-size: 10px; + margin-top: 2px; +} + +.grid-monitor.grid-monitor-options-split .monitor-stats-grid { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 10px; + width: 100%; +} + +.grid-monitor.grid-monitor-options-split .monitor-stat-cell { + padding: 10px 8px 8px; + border-radius: 8px; + min-width: 0; + text-align: center; +} + +.grid-monitor.grid-monitor-options-split .monitor-stat-label { + font-size: 11px; + margin-bottom: 4px; +} + +.grid-monitor.grid-monitor-options-split .monitor-stat-value { + font-size: 18px; + line-height: 1.25; + font-weight: 700; +} + +.grid-monitor.grid-monitor-options-split .monitor-stat-sub { + margin-top: 3px; + font-size: 11px; + line-height: 1.25; +} + +.monitor-split-body { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 16px; + align-items: stretch; + min-height: 0; +} + +/* 四卡平铺 2×2:左右等宽;同行同高;多仓时该行 min-content 变高,左右一起长 */ +.monitor-split-body.monitor-split-2x2 { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + grid-template-rows: minmax(min-content, 1fr) minmax(min-content, 1fr); + gap: 12px; + flex: 1 1 auto; + min-height: 0; +} + +.monitor-split-2x2 > .card { + min-width: 0; + min-height: 0; + height: 100%; + overflow: hidden; +} + +.monitor-split-2x2 > .card-monitor-placeholder { + visibility: hidden; + pointer-events: none; + border: none; + background: transparent; + box-shadow: none; +} + +.monitor-split-left, +.monitor-split-right { + min-width: 0; + min-height: 0; + display: grid; + grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); + gap: 16px; + align-content: stretch; + overflow: hidden; +} + +.monitor-split-left > .card, +.monitor-split-right > .card { + height: 100%; + min-height: 0; + overflow: hidden; +} + +.card-monitor-okx-split, +.card-monitor-split-side { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.card-monitor-okx-split .card-body, +.card-monitor-split-side .card-body { + flex: 1; + display: flex; + flex-direction: column; + overflow: auto; + min-height: 0; +} + +.hub-slot-pos { + min-height: 2.4em; + flex: 0 0 auto; +} + +.hub-slot-pos[data-pos-count="0"] .empty-hint { + margin: 0; + padding: 6px 0; +} + +.hub-opt-target-cell.is-on { + color: var(--green); + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.hub-inner-cards { + flex: 1; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 12px; + min-height: 0; +} + +.hub-inner-card { + display: flex; + flex-direction: column; + min-height: 0; + border: 1px solid var(--border-soft); + border-radius: 10px; + background: color-mix(in srgb, var(--inset-surface) 92%, transparent); + overflow: visible; +} + +.hub-inner-card-head { + flex: 0 0 auto; + padding: 8px 12px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.05em; + color: var(--text); + border-bottom: 1px solid var(--border-soft); +} + +.hub-inner-card-options .hub-inner-card-head { + color: var(--accent); +} + +.hub-inner-card-body { + flex: 1; + padding: 10px 12px; + overflow: visible; + min-height: 0; +} + +.grid-monitor-options-split .hub-options-table-wrap, +.grid-monitor-options-split .pos-table-wrap, +.grid-monitor-options-split .table-scroll { + overflow: visible; + max-height: none; +} + +@media (max-width: 1100px) { + .grid-monitor.grid-monitor-options-split .monitor-stats-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + } + + .grid-monitor.grid-monitor-options-split .monitor-stat-cell { + padding: 4px 4px 3px; + } +} + +@media (max-width: 960px) { + .monitor-split-body, + .monitor-split-body.monitor-split-2x2 { + grid-template-columns: 1fr; + grid-template-rows: none; + } + + .monitor-split-left, + .monitor-split-right { + grid-template-rows: auto auto; + } +} + +.monitor-stats-card .card-head { + padding-bottom: 0; +} + +.monitor-stats-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px 14px; +} + +.monitor-stat-cell { + min-width: 0; + padding: 10px 10px 8px; + border-radius: 10px; + border: 1px solid var(--border-soft); + background: color-mix(in srgb, var(--panel-solid) 88%, transparent); +} + +.monitor-stat-label { + font-size: 11px; + color: var(--muted); + margin-bottom: 4px; +} + +.monitor-stats-card .card-title { + font-family: var(--font); + letter-spacing: 0.01em; +} + +.monitor-stat-value { + font-family: var(--font); + font-size: 17px; + font-weight: 600; + font-variant-numeric: tabular-nums; + line-height: 1.3; + letter-spacing: 0; + color: var(--text); +} + +.monitor-stat-value.pnl-pos, +.monitor-stat-value.pnl-neg { + text-shadow: none; +} + +.monitor-stat-sub { + margin-top: 4px; + font-size: 11px; + color: var(--muted); + line-height: 1.3; +} + +@media (max-width: 720px) { + .monitor-stats-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.card-expand-zone { + cursor: pointer; +} + +.card-expand-zone:hover .card-title { + color: var(--accent); +} + +body.hub-fullscreen-open { + overflow: hidden; +} + +body.hub-instance-frame-open { + overflow: hidden; +} + +body.market-chart-fs-open { + overflow: hidden; +} + +.instance-frame-shell { + position: fixed; + inset: 0; + z-index: 200; + display: flex; + flex-direction: column; + background: var(--bg, #0a0e14); + isolation: isolate; +} + +.instance-frame-shell.hidden { + display: none !important; +} + +.instance-frame-shell.is-instance-nav-loading .instance-frame { + pointer-events: none; +} + +.instance-frame-loading { + display: none; + position: absolute; + left: 0; + right: 0; + bottom: 0; + top: 49px; + z-index: 2; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--bg, #0a0e14) 72%, transparent); + color: var(--muted, #8892b0); + font-size: 0.9rem; + pointer-events: none; +} + +.instance-frame-shell.is-instance-nav-loading .instance-frame-loading { + display: flex; +} + +.instance-frame-loading-inner { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + border-radius: 999px; + border: 1px solid var(--border-soft); + background: color-mix(in srgb, var(--panel-solid) 88%, transparent); +} + +.instance-frame-spinner { + width: 16px; + height: 16px; + border-radius: 50%; + border: 2px solid color-mix(in srgb, var(--muted, #8892b0) 35%, transparent); + border-top-color: var(--accent, #6eb5ff); + animation: instance-frame-spin 0.75s linear infinite; +} + +@keyframes instance-frame-spin { + to { + transform: rotate(360deg); + } +} + +.instance-frame-toolbar { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 12px; + padding: 10px 16px; + border-bottom: 1px solid var(--border-soft); + background: var(--panel-solid); +} + +.instance-frame-title { + flex: 1; + font-weight: 600; + color: var(--text, #dbe4ff); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.instance-frame-actions { + display: flex; + gap: 8px; + flex-shrink: 0; +} + +.instance-frame { + flex: 1 1 auto; + width: 100%; + border: none; + background: var(--bg); +} + +.exchange-fullscreen { + position: fixed; + inset: 0; + z-index: 150; + background: var(--fs-scrim); + backdrop-filter: blur(6px); + overflow: auto; + padding: 16px 20px 24px; +} + +.exchange-fullscreen.hidden { + display: none !important; +} + +.exchange-fullscreen-backdrop { + position: fixed; + inset: 0; + z-index: 0; + border: none; + padding: 0; + margin: 0; + background: transparent; + cursor: pointer; +} + +.exchange-fullscreen-panel { + position: relative; + z-index: 1; + max-width: min(1800px, 98vw); + margin: 0 auto; +} + +.fs-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border-soft); +} + +.fs-title { + margin: 0; + font-family: var(--display); + font-size: 18px; + letter-spacing: 0.04em; +} + +.fs-sub { + font-size: 11px; + color: var(--muted); + margin-top: 4px; + word-break: break-all; +} + +.fs-head-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; +} + +.fs-head-actions .btn-open-trade { + border-color: var(--accent); + color: var(--accent); + background: color-mix(in srgb, var(--accent) 10%, transparent); + font-weight: 600; +} + +.fs-head-actions .btn-open-trade:hover { + background: color-mix(in srgb, var(--accent) 18%, transparent); +} + +.card-actions .btn-open-trade { + border-color: var(--accent); + color: var(--accent); + font-weight: 600; +} + +.card-expand-hint { + margin-top: 12px; + padding: 8px 10px; + font-size: 11px; + color: var(--muted); + text-align: center; + border: 1px dashed var(--border-soft); + border-radius: 8px; + background: rgba(0, 212, 255, 0.03); +} + +/* 分栏卡:提示条贴卡片最底部 */ +.card-monitor-split-side .card-body, +.card-monitor-okx-split .card-body { + display: flex; + flex-direction: column; +} + +.card-monitor-split-side .card-expand-hint, +.card-monitor-okx-split .card-expand-hint { + margin-top: auto; + flex-shrink: 0; +} + +.compact-pos-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.compact-pos-line { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + font-size: 12px; + padding: 6px 8px; + background: var(--inset-surface); + border-radius: 6px; + border: 1px solid var(--border-soft); +} + +.hub-pos-list { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 14px; +} + +/* 全屏放大:持仓卡片横向排列,列数随仓位数量自适应 */ +.exchange-fullscreen .hub-pos-list { + display: grid; + gap: 14px; + align-items: stretch; + width: 100%; +} + +.exchange-fullscreen .hub-pos-list.count-1 { + grid-template-columns: minmax(0, 1fr); +} + +.exchange-fullscreen .hub-pos-list.count-1 .hub-pos-card.pos-card { + max-width: min(960px, 100%); + margin-inline: auto; + width: 100%; +} + +.exchange-fullscreen .hub-pos-list.count-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.exchange-fullscreen .hub-pos-list.count-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.exchange-fullscreen .hub-pos-list.count-4 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.exchange-fullscreen .hub-pos-list.count-5, +.exchange-fullscreen .hub-pos-list.count-6 { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.exchange-fullscreen .hub-pos-list.count-many { + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); +} + +.exchange-fullscreen .hub-pos-card.pos-card { + min-width: 0; + height: 100%; +} + +@media (max-width: 1100px) { + .exchange-fullscreen .hub-pos-list.count-3, + .exchange-fullscreen .hub-pos-list.count-5, + .exchange-fullscreen .hub-pos-list.count-6 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .exchange-fullscreen .hub-pos-list.count-many { + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + } +} + +@media (max-width: 640px) { + .exchange-fullscreen .hub-pos-list.count-2, + .exchange-fullscreen .hub-pos-list.count-3, + .exchange-fullscreen .hub-pos-list.count-4, + .exchange-fullscreen .hub-pos-list.count-5, + .exchange-fullscreen .hub-pos-list.count-6, + .exchange-fullscreen .hub-pos-list.count-many { + grid-template-columns: minmax(0, 1fr); + } + .exchange-fullscreen .hub-pos-list.count-1 .hub-pos-card.pos-card { + max-width: 100%; + } +} + +/* 平板横屏:持仓与区块双列 */ +@media (min-width: 641px) and (max-width: 1200px) and (orientation: landscape) { + .exchange-fullscreen .hub-pos-list.count-2, + .exchange-fullscreen .hub-pos-list.count-3, + .exchange-fullscreen .hub-pos-list.count-4, + .exchange-fullscreen .hub-pos-list.count-many { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .exchange-fullscreen .hub-section-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .hub-fs-sections-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + align-items: start; + } +} + +/* 手机竖屏:全屏顶栏与持仓单列 */ +@media (max-width: 720px), (max-width: 900px) and (orientation: portrait) { + .exchange-fullscreen .hub-pos-list { + grid-template-columns: minmax(0, 1fr) !important; + } +} + +.hub-fs-sections-grid { + display: flex; + flex-direction: column; + gap: 12px; +} + +@media (max-width: 720px), (max-width: 900px) and (orientation: portrait) { + .hub-fs-sections-grid { + display: flex; + flex-direction: column; + } +} + +/* 对齐实盘「实时持仓」pos-card */ +.hub-pos-card.pos-card { + background: var(--pos-card-bg); + border: 1px solid var(--border-soft); + border-radius: 10px; + padding: 12px 14px; +} + +.hub-pos-card .pos-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; +} + +.hub-pos-card .pos-card-symbol { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + min-width: 0; +} + +.hub-pos-card .pos-symbol-time-close, +.hub-mini-title .pos-symbol-time-close, +.td-symbol .pos-symbol-time-close { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.72rem; + font-weight: 500; + color: #8fc8ff; + padding: 1px 6px; + border-radius: 4px; + background: rgba(143, 200, 255, 0.1); + white-space: nowrap; + vertical-align: middle; +} +.hub-pos-card .pos-symbol-time-close .pos-time-close-cd, +.hub-mini-title .pos-symbol-time-close .pos-time-close-cd, +.td-symbol .pos-symbol-time-close .pos-time-close-cd { + font-variant-numeric: tabular-nums; + letter-spacing: 0.03em; +} +.hub-pos-card .pos-symbol-force-close, +.hub-mini-title .pos-symbol-force-close, +.td-symbol .pos-symbol-force-close { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.72rem; + font-weight: 500; + color: #ffc870; + padding: 1px 6px; + border-radius: 4px; + background: rgba(255, 200, 112, 0.12); + white-space: nowrap; + vertical-align: middle; +} +.hub-pos-card .pos-symbol-force-close .pos-force-close-cd, +.hub-mini-title .pos-symbol-force-close .pos-force-close-cd, +.td-symbol .pos-symbol-force-close .pos-force-close-cd { + font-variant-numeric: tabular-nums; + letter-spacing: 0.03em; +} +.hub-pos-card .pos-card-symbol strong { + font-size: 14px; + color: var(--text); + font-weight: 600; +} + +.hub-pos-card .pos-side-badge { + padding: 3px 8px; + border-radius: 6px; + font-size: 11px; + font-weight: 500; +} + +.hub-pos-card .pos-side-long, +.hub-pos-card .pos-side-badge.side-long { + background: rgba(0, 255, 157, 0.12); + color: var(--green); + border: 1px solid rgba(0, 255, 157, 0.35); +} + +.hub-pos-card .pos-side-short, +.hub-pos-card .pos-side-badge.side-short { + background: rgba(255, 77, 109, 0.12); + color: var(--red); + border: 1px solid rgba(255, 77, 109, 0.35); +} + +.side-long { + color: var(--green); + font-weight: 600; + text-shadow: 0 0 10px rgba(0, 255, 157, 0.25); +} + +.side-short { + color: var(--red); + font-weight: 600; + text-shadow: 0 0 10px rgba(255, 77, 109, 0.25); +} + +.data-table td.side-long, +.data-table td.side-short { + font-weight: 600; +} + +.hub-pos-card .pos-head-actions { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.hub-pos-card .pos-entrust-btn { + padding: 6px 12px; + background: rgba(42, 74, 122, 0.9); + color: #8fc8ff; + border: 1px solid rgba(0, 212, 255, 0.25); + border-radius: 8px; + font-size: 12px; + cursor: pointer; + white-space: nowrap; +} + +.hub-pos-card .pos-close-btn { + padding: 6px 14px; + background: rgba(196, 84, 84, 0.95); + color: #fff; + border: none; + border-radius: 8px; + font-size: 12px; + cursor: pointer; + white-space: nowrap; +} + +.hub-pos-card .pos-meta { + font-size: 11px; + color: var(--muted); + line-height: 1.45; + margin-bottom: 12px; + display: flex; + flex-wrap: wrap; + gap: 4px 0; +} + +.hub-pos-card .pos-meta-item:not(:last-child)::after { + content: "|"; + margin: 0 8px; + color: var(--border-soft); +} + +.hub-pos-card .pos-meta-on { + color: #6eb5ff; +} + +.hub-pos-card .pos-meta-off { + color: var(--muted); +} + +.hub-pos-card .pos-breakeven-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 6px; + font-size: 11px; + font-weight: 600; + background: #1a3d2e; + color: #4cd97f; +} + +.pos-breakeven-badge { + display: inline-flex; + align-items: center; + margin-left: 6px; + padding: 2px 8px; + border-radius: 6px; + font-size: 11px; + font-weight: 600; + background: #1a3d2e; + color: #4cd97f; + vertical-align: middle; + white-space: nowrap; +} + +.data-table .td-symbol { + white-space: nowrap; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px 6px; +} + +.hub-pos-card .pos-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px 14px; + margin-bottom: 12px; +} + +.exchange-fullscreen .hub-opt-pos-list { + margin-bottom: 14px; +} + +.hub-opt-pos-card .pos-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.hub-opt-pos-card .opt-pos-cell--depth { + grid-column: span 2; +} + +.hub-opt-pos-card .opt-bid-plain { + color: #dbe6ff; + font-variant-numeric: tabular-nums; + line-height: 1.35; + white-space: normal; +} + +.hub-opt-pos-card .opt-close-value { + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.hub-opt-pos-card .pos-card-symbol strong { + font-size: 0.72rem; + word-break: break-all; +} + +.hub-pos-card .pos-cell { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.hub-pos-card .pos-label { + font-size: 10px; + color: var(--muted); + letter-spacing: 0.04em; +} + +.hub-pos-card .pos-value { + font-size: 13px; + color: var(--text); + font-weight: 500; +} + +.hub-pos-card .pos-value.pnl-pos { + color: var(--green); + font-weight: 600; + text-shadow: 0 0 12px rgba(0, 255, 157, 0.25); +} + +.hub-pos-card .pos-value.pnl-neg { + color: var(--red); + font-weight: 600; +} + +.hub-pos-card .pos-tp-profit { + color: #4cd97f; + font-weight: 600; +} + +html[data-theme="light"] .hub-pos-card .pos-label { + color: #0f172a; + font-weight: 500; +} + +html[data-theme="light"] .hub-pos-card .pos-meta, +html[data-theme="light"] .hub-pos-card .pos-meta-item { + color: #1e293b; +} + +html[data-theme="light"] .hub-pos-card .pos-value { + color: #020617; + font-weight: 600; +} + +html[data-theme="light"] .hub-pos-card .pos-side-long, +html[data-theme="light"] .hub-pos-card .pos-side-badge.side-long { + background: #006e9a; + color: #fff; + border-color: #005a82; +} + +html[data-theme="light"] .hub-pos-card .pos-side-short, +html[data-theme="light"] .hub-pos-card .pos-side-badge.side-short { + background: #b03030; + color: #fff; + border-color: #8a2424; +} + +html[data-theme="light"] .hub-opt-pos-card .opt-bid-plain { + color: #0f172a; +} + +html[data-theme="light"] .hub-opt-pos-card .opt-close-value { + color: #9f1239; +} + +html[data-theme="light"] .hub-pos-card .pos-tp-profit { + color: #1a8f4a; +} + +.hub-pos-card .pos-footer { + display: flex; + flex-wrap: wrap; + gap: 12px 16px; + font-size: 11px; + color: var(--muted); + margin-bottom: 4px; +} + +.hub-pos-card .pos-ex-orders { + margin-top: 10px; + padding-top: 10px; + border-top: 1px dashed var(--border-soft); +} + +.hub-pos-card .pos-ex-orders-title { + font-size: 11px; + color: var(--muted); + margin-bottom: 6px; +} + +.hub-pos-card .pos-ex-order-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 12px; + margin-top: 5px; +} + +.hub-pos-card .pos-ex-order-main { + flex: 1; + min-width: 0; +} + +.hub-pos-card .pos-ex-cancel-btn { + padding: 3px 10px; + background: rgba(58, 48, 72, 0.9); + color: #d4b8ff; + border: 1px solid rgba(123, 97, 255, 0.35); + border-radius: 6px; + font-size: 11px; + cursor: pointer; + flex-shrink: 0; +} + +.hub-pos-card .pos-orders-collapse { + margin-top: 10px; +} + +.hub-section-card { + margin-top: 14px; + padding: 12px 14px; + background: var(--section-surface); + border: 1px solid var(--border-soft); + border-radius: 10px; +} + +.hub-section-head { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--accent); + margin-bottom: 10px; +} + +.hub-section-body { + display: flex; + flex-direction: column; + gap: 8px; +} + +.hub-key-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +/* 全屏放大:关键位 3 列网格 */ +.exchange-fullscreen .hub-key-list { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + align-items: stretch; +} + +.exchange-fullscreen .hub-key-list .hub-mini-card { + min-width: 0; + height: 100%; +} + +@media (max-width: 1100px) { + .exchange-fullscreen .hub-key-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 640px) { + .exchange-fullscreen .hub-key-list { + grid-template-columns: minmax(0, 1fr); + } +} + +.hub-mini-card { + padding: 10px 12px; + background: var(--inset-surface); + border: 1px solid var(--border-soft); + border-radius: 8px; +} + +.hub-mini-card.hub-key-pending, +.list-line.hub-key-pending { + border-color: rgba(0, 212, 255, 0.55); + background: rgba(0, 212, 255, 0.08); + box-shadow: 0 0 16px rgba(0, 212, 255, 0.12); +} + +.hub-key-pending-tag { + display: inline-block; + margin-left: 6px; + padding: 1px 7px; + font-size: 10px; + font-weight: 600; + color: var(--accent); + background: rgba(0, 212, 255, 0.15); + border: 1px solid rgba(0, 212, 255, 0.45); + border-radius: 4px; + vertical-align: middle; +} + +.hub-key-pending .hub-key-status-line, +.list-line.hub-key-pending { + color: var(--text); +} + +.hub-mini-title { + font-size: 12px; + font-weight: 600; + color: var(--text); + margin-bottom: 4px; +} + +.hub-mini-line { + font-size: 11px; + color: var(--muted); + line-height: 1.45; +} + +.pos-empty { + padding: 18px; + text-align: center; + color: var(--muted); + font-size: 12px; + border: 1px dashed var(--border-soft); + border-radius: 10px; +} + +@media (max-width: 520px) { + .hub-pos-card .pos-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +.settings-grid-wrap { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.stat-row { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 12px; +} + +.stat-box { + background: var(--inset-surface); + border: 1px solid var(--border-soft); + border-radius: 8px; + padding: 10px 12px; +} + +.stat-label { + font-size: 10px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.08em; + margin-bottom: 4px; +} + +.stat-value { + font-size: 17px; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--text); +} + +.section-title { + font-size: 10px; + font-weight: 600; + color: var(--accent); + text-transform: uppercase; + letter-spacing: 0.1em; + margin: 14px 0 8px; + padding-bottom: 6px; + border-bottom: 1px solid var(--border-soft); +} + +.section-title:first-child { + margin-top: 0; +} + +.pos-block { + margin-bottom: 14px; + padding-bottom: 10px; + border-bottom: 1px dashed var(--border-soft); +} + +.pos-block:last-child { + border-bottom: none; + margin-bottom: 0; +} + +.pos-table-wrap { + margin-bottom: 8px; +} + +.data-table-positions tbody tr:not(:last-child) td { + border-bottom: 1px dashed var(--border-soft); +} + +.card-strategy-stats { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 10px 0 4px; + padding-top: 8px; + border-top: 1px dashed var(--border-soft); +} + +.card-stat-chip { + display: inline-flex; + align-items: center; + padding: 3px 8px; + border-radius: 6px; + font-size: 11px; + line-height: 1.3; + border: 1px solid transparent; +} + +/* 突破 + 斐波 */ +.card-stat-chip.card-stat-key-breakout { + color: var(--accent); + background: rgba(0, 212, 255, 0.14); + border-color: rgba(0, 212, 255, 0.38); +} + +/* 关键位监控(阻力/支撑等) */ +.card-stat-chip.card-stat-key-watch { + color: #b8a0ff; + background: rgba(123, 97, 255, 0.18); + border-color: rgba(123, 97, 255, 0.42); +} + +/* 趋势回调 */ +.card-stat-chip.card-stat-trend { + color: var(--green); + background: rgba(0, 255, 157, 0.1); + border-color: rgba(0, 255, 157, 0.38); +} + +/* 趋势回调:与三所实例 strategy_trend_panel 同款卡片 */ +.hub-trend-running-title { + margin: 0 0 10px; + font-size: 0.95rem; + color: var(--accent); + font-weight: 600; +} + +.hub-trend-plan-list.running-plans-stack { + display: flex; + flex-direction: column; + gap: 12px; +} + +.hub-trend-plan-card.plan-position-card { + background: var(--panel-solid); + border: 1px solid var(--panel-solid-border); + border-radius: 12px; + padding: 12px 14px; +} + +.hub-trend-plan-card .plan-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 8px; +} + +.hub-trend-plan-card .plan-card-title { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + font-size: 1rem; + font-weight: 700; + color: var(--plan-title); +} + +.hub-trend-plan-card .plan-card-meta { + font-size: 0.76rem; + color: var(--plan-meta); + line-height: 1.55; + margin-bottom: 10px; +} + +.hub-trend-plan-card .plan-card-meta .accent { + color: var(--plan-meta-accent); +} + +.hub-trend-plan-card .plan-card-meta strong { + color: var(--accent); +} + +.hub-trend-plan-body-cols { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 14px 18px; + align-items: start; + margin-bottom: 10px; + padding-bottom: 10px; + border-bottom: 1px dashed var(--plan-border-dash); +} + +.hub-trend-plan-col-left .plan-card-meta { + margin-bottom: 10px; +} + +.hub-trend-plan-col-left .plan-card-grid { + margin-bottom: 0; +} + +.hub-trend-plan-card .plan-card-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px 14px; +} + +.hub-trend-plan-card .plan-cell { + display: flex; + flex-direction: column; + gap: 3px; +} + +.hub-trend-plan-card .plan-cell .lbl { + font-size: 0.72rem; + color: var(--plan-lbl); +} + +.hub-trend-plan-card .plan-cell .val { + color: var(--plan-val); + font-size: 0.88rem; + font-weight: 500; +} + +.hub-trend-plan-card .plan-cell .val.pnl-profit { + color: #4cd97f; +} + +.hub-trend-plan-card .plan-cell .val.pnl-loss { + color: #ff6666; +} + +.hub-trend-plan-card .plan-cell .val.pnl-neutral { + color: var(--plan-val-neutral); +} + +.hub-trend-plan-card .btn-close-plan { + padding: 7px 14px; + background: var(--plan-close-bg); + color: var(--plan-close-fg); + border: none; + border-radius: 8px; + cursor: pointer; + font-size: 0.82rem; + font-weight: 600; + text-decoration: none; + white-space: nowrap; + display: inline-block; +} + +.hub-trend-plan-card .btn-close-plan:hover { + filter: brightness(1.08); +} + +.hub-trend-plan-card .plan-dca-block--side { + margin-top: 0; + padding-top: 0; + border-top: none; + height: 100%; +} + +.hub-trend-plan-col-right { + min-width: 0; + border-left: 1px solid var(--plan-col-divider); + padding-left: 14px; +} + +.hub-dca-empty { + font-size: 0.76rem; + color: var(--plan-meta); + padding: 8px 0; +} + +.hub-trend-plan-foot { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 4px; +} + +.hub-trend-plan-foot .hub-plan-breakeven-row { + margin-top: 0; +} + +.hub-trend-plan-foot .hub-plan-account-foot { + margin-bottom: 0; +} + +.hub-trend-plan-card .plan-dca-title { + font-size: 0.74rem; + color: var(--plan-lbl); + margin-bottom: 8px; +} + +.hub-trend-plan-card .plan-dca-table { + width: 100%; + border-collapse: collapse; + font-size: 0.76rem; +} + +.hub-trend-plan-card .plan-dca-table th, +.hub-trend-plan-card .plan-dca-table td { + padding: 6px 8px; + border-bottom: 1px solid var(--plan-col-divider); + text-align: left; + font-weight: 500; +} + +.hub-trend-plan-card .plan-dca-table td { + color: var(--text); +} + +.hub-trend-plan-card .plan-dca-table th { + color: var(--plan-dca-th); + font-weight: 600; +} + +.hub-trend-plan-card .plan-dca-table .st-done { + color: var(--status-done); + font-weight: 700; +} + +.hub-trend-plan-card .plan-dca-table .st-pending { + color: var(--status-pending); + font-weight: 600; +} + +.hub-trend-plan-card .hub-plan-breakeven-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + margin-top: 8px; +} + +.hub-trend-plan-card .hub-plan-be-label { + font-size: 0.78rem; + color: var(--plan-be-label); + display: flex; + align-items: center; + gap: 6px; +} + +.hub-trend-plan-card .hub-plan-be-input { + width: 72px; + padding: 4px 8px; + border-radius: 6px; + border: 1px solid var(--plan-be-input-border); + background: var(--plan-be-input-bg); + color: var(--plan-val); + opacity: 0.92; +} + +.hub-trend-plan-card .hub-plan-be-btn { + padding: 6px 12px; + background: var(--plan-be-btn-bg); + color: var(--accent); + border: 1px solid var(--plan-be-input-border); + border-radius: 8px; + font-size: 0.78rem; + text-decoration: none; + cursor: pointer; + white-space: nowrap; +} + +.hub-trend-plan-card button.hub-plan-be-btn { + font-family: inherit; +} + +.hub-trend-plan-card .hub-plan-be-input:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.hub-trend-plan-card .hub-plan-be-btn--static { + cursor: default; +} + +.hub-trend-plan-card .hub-plan-be-done { + color: #6ab88a; + font-size: 0.75rem; +} + +.hub-trend-plan-card .hub-plan-account-foot { + margin-bottom: 0; +} + +.hub-trend-plan-card .badge.direction-long { + color: #4cd97f; + border-color: rgba(76, 217, 127, 0.45); +} + +.hub-trend-plan-card .badge.direction-short { + color: #ff6666; + border-color: rgba(255, 102, 102, 0.45); +} + +.exchange-fullscreen .hub-trend-plan-card.plan-position-card { + width: 100%; + max-width: 100%; +} + +@media (max-width: 900px) { + .hub-trend-plan-body-cols { + grid-template-columns: 1fr; + } + + .hub-trend-plan-col-right { + border-left: none; + padding-left: 0; + padding-top: 10px; + border-top: 1px dashed var(--plan-border-dash); + } +} + +@media (max-width: 720px) { + .hub-trend-plan-card .plan-card-grid { + grid-template-columns: 1fr; + } +} + +/* 顺势加仓 */ +.card-stat-chip.card-stat-roll { + color: #ffb020; + background: rgba(255, 176, 32, 0.14); + border-color: rgba(255, 176, 32, 0.42); +} + +.hub-tile .card-strategy-stats { + margin: 4px 0 0; + padding-top: 6px; + border-top: none; + gap: 4px; +} + +.hub-tile .card-stat-chip { + font-size: 10px; + padding: 2px 6px; +} + +.pos-action-group { + display: inline-flex; + flex-direction: row; + align-items: center; + justify-content: flex-end; + gap: 6px; + flex-wrap: nowrap; + white-space: nowrap; +} + +.data-table .td-actions .btn-sm { + margin: 0; + vertical-align: middle; +} + +button.btn-sm { + padding: 4px 11px; + font-size: 11px; + line-height: 1.35; + border-radius: 6px; + min-width: 48px; +} + +.btn-place-tpsl.btn-sm { + border-color: rgba(0, 212, 255, 0.35); + color: var(--accent); +} + +.pos-orders-collapse { + margin: 10px 0 0; + padding: 0; + background: var(--inset-surface); + border: 1px solid var(--border-soft); + border-radius: 8px; + overflow: hidden; +} + +.pos-orders-collapse-summary { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + cursor: pointer; + list-style: none; + user-select: none; + background: rgba(0, 212, 255, 0.04); + border-bottom: 1px solid transparent; +} + +.pos-orders-collapse[open] > .pos-orders-collapse-summary { + border-bottom-color: var(--border-soft); +} + +.pos-orders-collapse-summary::-webkit-details-marker { + display: none; +} + +.pos-orders-collapse-summary::before { + content: "▸"; + flex-shrink: 0; + color: var(--accent); + font-size: 11px; + width: 12px; + transition: transform 0.15s ease; +} + +.pos-orders-collapse[open] > .pos-orders-collapse-summary::before { + transform: rotate(90deg); +} + +.pos-orders-collapse-label { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + color: var(--text); +} + +.pos-orders-collapse-label em { + font-style: normal; + color: var(--accent); + margin-left: 2px; +} + +.pos-orders-collapse-meta { + flex: 1; + font-size: 10px; + color: var(--muted); + min-width: 0; +} + +.pos-orders-collapse-summary .btn-cancel-cond-all { + flex-shrink: 0; + margin-left: auto; +} + +.pos-orders-collapse-body { + padding: 8px 10px 10px; +} + +.orders-section + .orders-section { + margin-top: 10px; + padding-top: 10px; + border-top: 1px dashed var(--border-soft); +} + +.orders-section-head { + font-size: 10px; + color: var(--muted); + letter-spacing: 0.08em; + text-transform: uppercase; + margin-bottom: 6px; +} + +.data-table-sub { + font-size: 10px; +} + +.data-table-sub th, +.data-table-sub td { + padding: 5px 6px; +} + +.order-empty { + font-size: 11px; + color: var(--muted); + padding: 6px 4px 8px; +} + +.modal { + position: fixed; + inset: 0; + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; +} + +.modal.hidden { + display: none; +} + +.modal-backdrop { + position: absolute; + inset: 0; + background: var(--overlay); +} + +.modal-panel, +.modal-card { + position: relative; + z-index: 1; + width: 100%; + max-width: 380px; + padding: 20px 22px; + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); +} + +.modal-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.modal-head h3 { + margin: 0; + font-family: var(--display); + font-size: 14px; + letter-spacing: 0.06em; +} + +.plan-modal-close { + flex-shrink: 0; + min-width: 32px; + padding: 4px 8px; + font-size: 18px; + line-height: 1; +} + +.modal-panel h3 { + margin: 0 0 8px; + font-family: var(--display); + font-size: 14px; + letter-spacing: 0.06em; +} + +.modal-meta { + margin: 0 0 14px; + font-size: 12px; + color: var(--muted); +} + +.modal-field { + margin-bottom: 12px; +} + +.modal-field label { + display: block; + font-size: 10px; + color: var(--muted); + margin-bottom: 4px; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.modal-field input { + width: 100%; + padding: 8px 10px; + background: var(--bg-elevated); + border: 1px solid var(--border-soft); + border-radius: 6px; + color: var(--text); + font-family: var(--font); + font-size: 13px; +} + +.modal-hint { + font-size: 11px; + color: var(--muted); + margin: 0 0 14px; + line-height: 1.5; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.table-scroll { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + max-width: 100%; +} + +.data-table { + width: 100%; + min-width: 300px; + border-collapse: collapse; + font-size: 11px; +} + +.data-table th { + color: var(--muted); + font-weight: 500; + font-size: 10px; + padding: 6px 8px; + text-align: left; + border-bottom: 1px solid var(--border-soft); +} + +.data-table td { + padding: 8px; + border-bottom: 1px solid var(--border-soft); + font-variant-numeric: tabular-nums; +} + +.data-table tr:last-child td { + border-bottom: none; +} + +.list-line { + font-size: 11px; + color: var(--muted); + padding: 6px 0; + border-bottom: 1px dashed var(--border-soft); + line-height: 1.45; +} +.list-line:last-child { + border-bottom: none; +} + +.empty-hint { + font-size: 11px; + color: var(--muted); + padding: 8px 0; +} + +.board-loading-sub { + margin: 12px 0 0; + font-size: 12px; + line-height: 1.5; + color: var(--muted); + max-width: 36rem; +} + +.board-loading { + grid-column: 1 / -1; + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + min-height: 120px; + padding: 24px; + color: var(--muted); + font-size: 13px; + border: 1px dashed var(--border-soft); + border-radius: var(--radius); + background: rgba(0, 0, 0, 0.25); +} + +.board-loading-spin { + width: 18px; + height: 18px; + border: 2px solid var(--border-soft); + border-top-color: var(--accent); + border-radius: 50%; + animation: hub-spin 0.8s linear infinite; +} + +@keyframes hub-spin { + to { + transform: rotate(360deg); + } +} + +.pnl-pos { + color: var(--green); + text-shadow: 0 0 12px rgba(0, 255, 157, 0.3); +} +.pnl-neg { + color: var(--red); +} + +.data-table td.pnl-pos { + color: var(--green); + font-weight: 600; +} + +.data-table td.pnl-neg { + color: var(--red); + font-weight: 600; +} +.err { + color: var(--red); + font-size: 12px; +} + +.badge { + font-size: 9px; + padding: 2px 8px; + border-radius: 999px; + background: var(--accent-dim); + color: var(--accent); + border: 1px solid var(--border); + white-space: nowrap; + letter-spacing: 0.06em; +} + +.settings-meta-line { + font-size: 11px; + color: var(--muted); + padding: 10px 14px; + background: var(--panel); + border-left: 3px solid var(--accent); + border-radius: 0 var(--radius) var(--radius) 0; + margin-bottom: 16px; + line-height: 1.55; + border: 1px solid var(--border-soft); + border-left-width: 3px; +} + +.field { + display: flex; + flex-direction: column; + gap: 5px; +} + +.field label, +.field > span { + font-size: 10px; + color: var(--muted); + font-weight: 500; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.field-wide { + grid-column: 1 / -1; +} + +.field input, +.field select, +.form-row input, +.form-row select { + background: var(--bg-elevated); + border: 1px solid var(--border); + color: var(--text); + border-radius: 8px; + padding: 9px 11px; + font-size: 12px; + font-family: var(--mono); + width: 100%; +} + +.field input:focus, +.field select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(0, 212, 255, 0.2), var(--glow); +} + +.field-check { + flex-direction: row; + align-items: center; + gap: 8px; + padding-top: 20px; +} + +.field-check label { + font-size: 12px; + color: var(--text); + cursor: pointer; + text-transform: none; +} + +.settings-display-panel, +.settings-macro-panel, +.settings-supervisor-panel { + margin-bottom: 0; +} + +/* 中控系统设置 · CSS Tab(与实例 env 配置同方案) */ +.hub-config-body { + margin-bottom: 16px; +} + +.hub-tab-radio { + position: absolute; + opacity: 0; + pointer-events: none; + width: 0; + height: 0; + border: 0; +} + +.hub-config-tabs { + display: flex; + flex-wrap: nowrap; + gap: 0; + overflow-x: auto; + border-bottom: 1px solid var(--border-soft); + padding: 0 8px; + scrollbar-width: thin; +} + +.hub-tab-btn { + flex: 0 0 auto; + display: inline-block; + border: none; + background: transparent; + color: var(--muted); + font-size: 0.8rem; + padding: 10px 14px; + cursor: pointer; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + white-space: nowrap; + transition: color 0.15s, border-color 0.15s; + user-select: none; +} + +.hub-tab-btn:hover { + color: color-mix(in srgb, var(--text) 80%, var(--muted)); +} + +.hub-config-panels .hub-panel { + display: none; +} + +#hub-sec-0:checked ~ .hub-config-tabs label[for="hub-sec-0"], +#hub-sec-1:checked ~ .hub-config-tabs label[for="hub-sec-1"], +#hub-sec-2:checked ~ .hub-config-tabs label[for="hub-sec-2"], +#hub-sec-3:checked ~ .hub-config-tabs label[for="hub-sec-3"], +#hub-sec-4:checked ~ .hub-config-tabs label[for="hub-sec-4"], +#hub-sec-5:checked ~ .hub-config-tabs label[for="hub-sec-5"], +#hub-sec-6:checked ~ .hub-config-tabs label[for="hub-sec-6"] { + color: var(--text); + border-bottom-color: var(--accent); + font-weight: 600; +} + +#hub-sec-0:checked ~ .hub-config-panels .hub-panel--0, +#hub-sec-1:checked ~ .hub-config-panels .hub-panel--1, +#hub-sec-2:checked ~ .hub-config-panels .hub-panel--2, +#hub-sec-3:checked ~ .hub-config-panels .hub-panel--3, +#hub-sec-4:checked ~ .hub-config-panels .hub-panel--4, +#hub-sec-5:checked ~ .hub-config-panels .hub-panel--5, +#hub-sec-6:checked ~ .hub-config-panels .hub-panel--6 { + display: block; +} + +.hub-config-panels { + padding: 14px 16px 16px; +} + +.hub-settings-tab-panel { + margin: 0; +} + +.hub-settings-tab-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 12px; + flex-wrap: wrap; +} + +.hub-settings-tab-title { + margin: 0; + font-size: 1rem; + font-weight: 600; +} + +.hub-settings-tab-head-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.hub-settings-tab-actions { + display: flex; + align-items: center; + gap: 10px; + margin-top: 12px; + flex-wrap: wrap; +} + +.hub-password-grid { + margin-top: 8px; +} + +.hub-ai-env-grid .field-wide { + grid-column: 1 / -1; +} + +.settings-status-line { + font-size: 0.8rem; + color: var(--muted); +} + +.settings-status-line.is-err { + color: var(--danger, #f87171); +} + +.settings-section { + margin-bottom: 16px; +} + +.settings-section-head { + display: flex; + align-items: center; + gap: 10px; + padding: 14px 16px; + border-bottom: 1px solid var(--border-soft); +} + +.settings-section.is-collapsed .settings-section-head { + border-bottom-color: transparent; +} + +.settings-section-head .settings-display-title { + flex: 1; + margin: 0; + min-width: 0; +} + +.settings-section-head-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.settings-section-fold { + flex-shrink: 0; + width: 28px; + height: 28px; + padding: 0; + border: 1px solid var(--border-soft); + border-radius: 6px; + background: color-mix(in srgb, var(--panel) 90%, var(--accent) 10%); + color: var(--accent); + cursor: pointer; + font-size: 0; + line-height: 1; + transition: transform 0.15s ease, border-color 0.15s ease; + position: relative; +} + +.settings-section-fold::before { + content: "▾"; + font-size: 0.85rem; + line-height: 28px; + display: block; + text-align: center; +} + +.settings-section-fold:hover { + border-color: color-mix(in srgb, var(--accent) 50%, var(--border-soft)); +} + +.settings-section.is-collapsed .settings-section-fold::before { + content: "▸"; +} + +.settings-section-save { + flex-shrink: 0; + font-size: 0.82rem; + padding: 6px 14px; +} + +.settings-section-body { + padding: 14px 16px; +} + +.settings-section.is-collapsed .settings-section-body { + display: none; +} + +.settings-page-toolbar { + margin-top: 4px; +} + +.settings-card-topbar { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + padding-bottom: 10px; + border-bottom: 1px dashed var(--border-soft); +} + +.settings-card-fold { + flex-shrink: 0; + width: 26px; + height: 26px; + padding: 0; + border: 1px solid var(--border-soft); + border-radius: 6px; + background: transparent; + color: var(--muted); + cursor: pointer; + font-size: 0; + line-height: 1; + transition: color 0.15s ease, border-color 0.15s ease; + position: relative; +} + +.settings-card-fold::before { + content: "▾"; + font-size: 0.8rem; + line-height: 26px; + display: block; + text-align: center; +} + +.settings-card-fold:hover { + color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 40%, var(--border-soft)); +} + +.settings-card.is-collapsed .settings-card-fold::before { + content: "▸"; +} + +.settings-card-title { + flex: 1; + min-width: 0; + font-size: 0.92rem; + font-weight: 600; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.settings-card-save { + flex-shrink: 0; + font-size: 0.78rem; + padding: 5px 12px; +} + +.settings-card-body { + display: block; +} + +.settings-card.is-collapsed .settings-card-body { + display: none; +} + +@media (max-width: 720px) { + .settings-section-head { + flex-wrap: wrap; + } + + .settings-section-head-actions { + width: 100%; + justify-content: flex-end; + } + + .settings-card-topbar { + flex-wrap: wrap; + } +} + +.settings-display-title { + margin: 0 0 10px; + font-size: 0.95rem; + color: var(--text); +} + +.settings-display-chk { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.88rem; +} + +.settings-display-chk + .settings-display-chk { + margin-top: 8px; +} + +.settings-display-hint { + margin: 8px 0 0; + font-size: 0.78rem; + color: var(--muted); + line-height: 1.45; +} + +.backup-settings-grid { + margin-top: 12px; +} + +.backup-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + margin-top: 16px; +} + +.backup-status-line { + font-size: 0.82rem; + color: var(--muted); +} + +.backup-status-line.err { + color: var(--danger, #f87171); +} + +.backup-restore-upload { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 12px; + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--border); +} + +.backup-upload-label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 0.82rem; + color: var(--muted); +} + +.backup-list { + margin-top: 16px; +} + +.backup-meta { + font-size: 0.78rem; + color: var(--muted); + line-height: 1.5; + margin-bottom: 10px; +} + +.backup-meta code { + font-size: 0.76rem; +} + +.backup-empty { + font-size: 0.82rem; + color: var(--muted); +} + +.backup-table { + width: 100%; + border-collapse: collapse; + font-size: 0.82rem; +} + +.backup-table th, +.backup-table td { + padding: 8px 10px; + border-bottom: 1px solid var(--border); + text-align: left; +} + +.backup-row-actions { + white-space: nowrap; +} + +.backup-row-actions .ghost, +.backup-row-actions .danger { + font-size: 0.78rem; + padding: 4px 8px; +} + +.settings-card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px; + backdrop-filter: blur(10px); +} + +.settings-card-head { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 14px; + flex-wrap: wrap; +} + +.settings-card-head .ex-name { + flex: 1; + min-width: 160px; + font-size: 14px; + font-weight: 600; + font-family: var(--display); + background: transparent; + border: none; + border-bottom: 1px dashed var(--border); + color: var(--text); + padding: 4px 0; +} + +.settings-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 12px; +} + +.settings-grid .field input { + font-size: 11px; +} + +.cap-chips { + display: flex; + gap: 10px; + flex-wrap: wrap; + padding: 8px 0; +} + +.cap-chips label { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--text); + cursor: pointer; + padding: 6px 12px; + background: rgba(0, 0, 0, 0.35); + border-radius: 999px; + border: 1px solid var(--border-soft); +} + +.settings-card-foot { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border-soft); +} + +.settings-card-foot .field { + max-width: 80px; +} + +#toast { + position: fixed; + bottom: 20px; + right: 20px; + max-width: min(420px, 92vw); + background: var(--panel); + border: 1px solid var(--accent); + padding: 12px 16px; + border-radius: var(--radius); + display: none; + z-index: 50; + white-space: pre-wrap; + font-size: 12px; + box-shadow: var(--glow); + backdrop-filter: blur(12px); +} + +#toast.show { + display: block; +} + +/* —— 登录页 —— */ +body.login-page { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 24px; +} + +.login-theme-bar { + position: relative; + z-index: 2; + width: 100%; + max-width: 400px; + display: flex; + justify-content: flex-end; + margin-bottom: 10px; +} + +.login-panel { + position: relative; + z-index: 1; + width: 100%; + max-width: 400px; + padding: 28px 26px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 12px; + backdrop-filter: blur(16px); + box-shadow: var(--shadow), var(--glow); +} + +.login-brand { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 24px; +} + +.login-title { + font-family: var(--display); + font-size: 16px; + font-weight: 600; + letter-spacing: 0.08em; +} + +.login-sub { + font-size: 10px; + color: var(--muted); + letter-spacing: 0.16em; + margin-top: 4px; +} + +.login-form .field { + margin-bottom: 16px; +} + +.login-submit { + width: 100%; + padding: 12px; +} + +.login-err { + color: var(--red); + font-size: 12px; + margin: 10px 0 0; +} + +.login-foot { + margin: 20px 0 0; + font-size: 10px; + color: var(--muted); + line-height: 1.5; +} +.login-foot code { + color: var(--accent); + font-size: 10px; +} + +/* —— 手机 / 窄屏自适应 —— */ +@media (max-width: 720px) { + .app-shell { + padding: 0 max(12px, env(safe-area-inset-right)) max(28px, env(safe-area-inset-bottom)) + max(12px, env(safe-area-inset-left)); + } + + .app-header { + flex-direction: column; + align-items: stretch; + gap: 12px; + padding: 14px 0; + } + + .brand-sub { + display: none; + } + + .app-header { + padding: 10px 0; + margin-bottom: 4px; + } + + .header-right { + width: 100%; + display: grid; + grid-template-columns: 1fr auto auto; + grid-template-rows: auto auto; + align-items: center; + gap: 8px; + } + + .header-right .theme-toggle { + grid-column: 1; + justify-self: start; + } + + .sys-pill { + grid-column: 2; + align-self: center; + } + + button.ghost#btn-logout { + grid-column: 3; + width: auto; + min-height: 36px; + padding: 6px 12px; + justify-self: end; + } + + .top-nav { + grid-column: 1 / -1; + width: 100%; + display: flex; + flex-wrap: nowrap; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + gap: 6px; + padding-bottom: 2px; + } + + .top-nav::-webkit-scrollbar { + display: none; + } + + .top-nav a { + flex: 0 0 auto; + text-align: center; + padding: 8px 14px; + min-height: 40px; + display: inline-flex; + align-items: center; + justify-content: center; + white-space: nowrap; + } + + .page-desc { + display: none; + } + + .market-toolbar { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + align-items: end; + } + + .market-field { + min-width: 0; + } + + .market-field select, + .market-field input { + width: 100%; + min-width: 0; + } + + .market-field-symbol { + grid-column: 1 / -1; + } + + .market-toolbar .toolbar-spacer { + display: none; + } + + .market-toolbar #market-load { + grid-column: 1; + } + + .market-toolbar #market-refresh { + grid-column: 2; + } + + .market-toolbar .toolbar-meta { + grid-column: 1 / -1; + text-align: left; + font-size: 0.72rem; + } + + .market-chart-wrap { + min-height: 260px; + height: min(52vh, 420px); + } + + .archive-toolbar { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px 10px; + align-items: center; + } + + .archive-toolbar .archive-field { + grid-column: 1 / -1; + } + + .archive-toolbar .chk-label { + margin: 0; + min-height: 36px; + justify-content: flex-start; + } + + .archive-toolbar #archive-btn-refresh { + grid-column: 1; + } + + .archive-toolbar #archive-btn-sync { + grid-column: 2; + } + + .archive-toolbar .toolbar-meta { + grid-column: 1 / -1; + text-align: left; + } + + body.hub-page-ai .page-head { + margin: 4px 0 6px; + } + + body.hub-page-ai .page-head h1 { + margin: 0; + font-size: 15px; + } + + .page-head { + margin: 16px 0 12px; + } + + .page-head h1 { + font-size: 17px; + flex-wrap: wrap; + } + + .toolbar { + flex-direction: column; + align-items: stretch; + gap: 8px; + } + + .toolbar-spacer { + display: none; + } + + .toolbar-meta { + text-align: center; + order: 10; + } + + .toolbar button, + .toolbar .chk-label { + width: 100%; + justify-content: center; + min-height: 44px; + } + + .grid-monitor:not(.grid-monitor-tiles), + .settings-grid-wrap { + grid-template-columns: minmax(0, 1fr) !important; + gap: 12px; + } + + .grid-monitor.grid-monitor-tiles { + /* 手机监控单列,避免统计与交易所卡并排挤字 */ + grid-template-columns: 1fr !important; + gap: 10px; + } + + #page-monitor .page-head { + margin-bottom: 8px; + } + + #page-monitor .page-head h1 { + margin-bottom: 0; + } + + .monitor-alert-summary { + margin-bottom: 8px; + } + + .host-status-panel { + margin-bottom: 10px; + } + + .host-status-summary { + flex-wrap: wrap; + padding: 8px 10px; + } + + .host-status-bar { + padding: 10px; + } + + .host-status-top { + flex-direction: column; + align-items: stretch; + } + + .host-status-meta { + justify-content: flex-start; + } + + .host-status-metrics { + grid-template-columns: minmax(0, 1fr); + gap: 8px; + } + + .card-head { + flex-direction: column; + align-items: stretch; + gap: 10px; + } + + .card-actions { + flex-wrap: wrap; + width: 100%; + gap: 8px; + } + + .card-actions .btn-link, + .card-actions button { + flex: 1 1 calc(50% - 4px); + min-height: 40px; + text-align: center; + justify-content: center; + } + + .card-body { + padding: 12px; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + .stat-row { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + } + + .stat-value { + font-size: 14px; + } + + .stat-label { + font-size: 9px; + } + + .instance-frame-toolbar { + flex-wrap: wrap; + gap: 8px; + padding: 8px 10px; + } + + .instance-frame-title { + flex: 1 1 100%; + order: -1; + font-size: 0.82rem; + } + + .instance-frame-actions { + flex: 1 1 auto; + justify-content: flex-end; + } + + .instance-frame { + height: calc(100dvh - 96px); + } + + .exchange-fullscreen { + padding: max(10px, env(safe-area-inset-top)) max(10px, env(safe-area-inset-right)) + max(16px, env(safe-area-inset-bottom)) max(10px, env(safe-area-inset-left)); + } + + .exchange-fullscreen-panel { + max-width: 100%; + } + + .fs-head { + flex-direction: column; + align-items: stretch; + gap: 12px; + } + + .fs-head-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + width: 100%; + } + + .fs-head-actions .btn-expand-back { + grid-column: 1 / -1; + } + + .fs-head-actions .btn-open-trade { + grid-column: 1 / -1; + } + + .fs-head-actions .btn-link, + .fs-head-actions button { + min-height: 44px; + text-align: center; + justify-content: center; + } + + .hub-pos-card .pos-card-head { + flex-direction: column; + align-items: stretch; + } + + .hub-pos-card .pos-head-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + width: 100%; + } + + .hub-pos-card .pos-entrust-btn, + .hub-pos-card .pos-close-btn { + width: 100%; + min-height: 44px; + text-align: center; + } + + .hub-pos-card .pos-ex-order-row { + flex-direction: column; + align-items: stretch; + gap: 6px; + } + + .settings-grid { + grid-template-columns: 1fr; + } + + .settings-card-foot { + flex-direction: column; + align-items: stretch; + gap: 10px; + } + + .settings-card-foot .field { + max-width: none; + } + + .modal { + padding: max(12px, env(safe-area-inset-top)) 12px max(12px, env(safe-area-inset-bottom)); + align-items: flex-end; + } + + .modal-panel { + max-width: none; + width: 100%; + border-radius: 12px 12px 0 0; + max-height: 90vh; + overflow-y: auto; + } + + .modal-actions { + flex-direction: column-reverse; + } + + .modal-actions button { + width: 100%; + min-height: 44px; + } + + #toast { + left: 12px; + right: 12px; + bottom: max(12px, env(safe-area-inset-bottom)); + max-width: none; + } + + body.login-page { + padding: max(16px, env(safe-area-inset-top)) 16px max(16px, env(safe-area-inset-bottom)); + } + + .login-panel { + padding: 22px 18px; + } +} + +/* —— 可折叠条:桌面/平板始终展开(隐藏 summary) —— */ +.hub-m-fold { + margin: 0 0 12px; + border: none; + background: transparent; + padding: 0; +} + +.hub-m-fold > .hub-m-fold-summary { + display: none; + list-style: none; +} + +.hub-m-fold > .hub-m-fold-summary::-webkit-details-marker { + display: none; +} + +.calc-mobile-tabs { + display: none; +} + +.card-stat-chip.card-stat-options { + border-color: rgba(0, 212, 255, 0.35); + color: var(--accent, #6ea8ff); + background: rgba(0, 212, 255, 0.08); +} + +.hub-tile-opt { + color: var(--accent, #6ea8ff) !important; + font-weight: 500; +} + +/* —— 手机壳:底栏四件套(仅 ≤720px;桌面/平板完全不进此规则) —— */ +.hub-mobile-tabbar, +.hub-mobile-more { + display: none; +} + +@media (max-width: 720px) { + :root { + --hub-m-tabbar-h: 56px; + } + + body.hub-phone .app-header .top-nav { + display: none !important; + } + + /* 顶栏一行:省纵向空间,底栏已标当前页 */ + body.hub-phone .app-header { + flex-direction: row; + align-items: center; + gap: 8px; + padding: 6px 0; + margin-bottom: 4px; + } + + body.hub-phone .brand-sub { + display: none; + } + + body.hub-phone .brand-title { + font-size: 12px; + letter-spacing: 0.02em; + } + + body.hub-phone .header-right { + display: flex; + flex-wrap: nowrap; + align-items: center; + justify-content: flex-end; + gap: 6px; + width: auto; + margin-left: auto; + grid-template-columns: none; + grid-template-rows: none; + } + + body.hub-phone .header-right .theme-toggle { + transform: scale(0.92); + transform-origin: center right; + } + + body.hub-phone button.ghost#btn-logout { + min-height: 32px; + padding: 4px 10px; + font-size: 12px; + } + + body.hub-phone .app-shell { + padding-bottom: calc(var(--hub-m-tabbar-h) + max(10px, env(safe-area-inset-bottom))); + } + + /* 四页标题与底栏重复,手机隐藏 */ + body.hub-phone #page-monitor > .page-head, + body.hub-phone #page-market > .page-head, + body.hub-phone #page-calculator > .page-head, + body.hub-phone #page-ai > .page-head { + display: none; + } + + /* —— 监控 —— */ + body.hub-phone .grid-monitor.grid-monitor-tiles { + grid-template-columns: 1fr !important; + gap: 10px; + } + + body.hub-phone .hub-tile { + min-height: 0; + } + + body.hub-phone .hub-tile .hub-tile-body { + min-height: 0; + padding: 12px 14px; + } + + body.hub-phone .hub-tile-pnl { + font-size: 22px; + } + + body.hub-phone .monitor-stats-card .monitor-stats-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + } + + body.hub-phone .monitor-stat-cell { + padding: 8px 6px; + } + + body.hub-phone .monitor-stat-label { + font-size: 10px; + } + + body.hub-phone .monitor-stat-value { + font-size: 14px; + } + + body.hub-phone #page-monitor .host-status-panel { + margin-bottom: 8px; + } + + body.hub-phone #page-monitor .host-status-summary { + padding: 8px 10px; + gap: 6px 8px; + } + + body.hub-phone #page-monitor .host-status-summary-text { + font-size: 11px; + } + + body.hub-phone #page-monitor .toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 8px 10px; + margin-bottom: 10px; + } + + body.hub-phone #page-monitor .toolbar .primary, + body.hub-phone #page-monitor .toolbar .danger { + flex: 1 1 auto; + min-height: 36px; + padding: 6px 10px; + font-size: 13px; + } + + body.hub-phone #page-monitor .toolbar .chk-label { + order: 3; + flex: 1 1 100%; + margin: 0; + font-size: 11px; + color: var(--muted); + } + + body.hub-phone #page-monitor .toolbar .toolbar-spacer { + display: none; + } + + body.hub-phone #page-monitor .toolbar .toolbar-meta { + order: 4; + flex: 1 1 100%; + font-size: 10px; + opacity: 0.85; + } + + /* —— 行情:控件收短,图表优先 —— */ + body.hub-phone #page-market .market-toolbar { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px 8px; + align-items: end; + padding: 8px 10px; + margin-bottom: 8px; + } + + body.hub-phone #page-market .market-field > span { + font-size: 10px; + margin-bottom: 2px; + } + + body.hub-phone #page-market .market-field select, + body.hub-phone #page-market .market-field input { + min-height: 34px; + padding: 4px 8px; + font-size: 13px; + } + + body.hub-phone #page-market .market-field-symbol { + grid-column: 1 / -1; + } + + body.hub-phone #page-market .market-field-symbol .market-symbol-wrap { + flex-direction: column; + gap: 6px; + } + + body.hub-phone #page-market .market-scan-tabs { + width: 100%; + flex-wrap: nowrap; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + padding-bottom: 1px; + } + + body.hub-phone #page-market .market-scan-tabs::-webkit-scrollbar { + display: none; + } + + body.hub-phone #page-market .market-scan-tab { + min-height: 30px; + padding: 0 10px; + font-size: 11px; + } + + body.hub-phone #page-market .market-toolbar #market-load { + grid-column: 1; + min-height: 34px; + padding: 6px 10px; + } + + body.hub-phone #page-market .market-toolbar #market-refresh { + grid-column: 2; + min-height: 34px; + padding: 6px 8px; + font-size: 11px; + } + + body.hub-phone #page-market .market-toolbar .toolbar-meta { + display: none; + } + + body.hub-phone #page-market #market-status { + display: none; + } + + body.hub-phone #page-market #market-status.err, + body.hub-phone #page-market #market-status.warn { + display: block; + font-size: 11px; + margin: 0 0 6px; + } + + body.hub-phone #page-market .market-vol-rank-anchor { + margin: 0 0 6px; + } + + body.hub-phone #page-market .market-chart-wrap { + min-height: min(58vh, 480px); + height: min(58vh, 480px); + } + + body.hub-phone #page-market .market-ohlcv-bar { + padding: 8px 10px 6px; + } + + body.hub-phone #page-market .market-ohlcv-title { + gap: 6px; + margin-bottom: 2px; + } + + /* 交易所/币种已在顶栏出现,图表内重复标签隐藏 */ + body.hub-phone #page-market .mkt-exchange-tag, + body.hub-phone #page-market #mkt-symbol-label, + body.hub-phone #page-market #mkt-tf-label { + display: none; + } + + body.hub-phone #page-market .market-day-split-opt { + display: none; + } + + body.hub-phone #page-market .market-chart-actions { + margin-left: 0; + width: 100%; + justify-content: flex-start; + } + + body.hub-phone #page-market .market-ohlcv-row { + gap: 4px 10px; + font-size: 12px; + } + + /* —— 计算器 —— */ + body.hub-phone #page-calculator .toolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + margin-bottom: 8px; + } + + body.hub-phone #page-calculator .toolbar .primary { + min-height: 32px; + padding: 4px 12px; + font-size: 12px; + } + + body.hub-phone #page-calculator .toolbar .toolbar-meta { + font-size: 10px; + } + + body.hub-phone #page-calculator .calc-layout { + gap: 10px; + } + + body.hub-phone #page-calculator .calc-card { + padding: 12px 12px 14px; + } + + body.hub-phone #page-calculator .calc-card h2 { + margin: 0 0 6px; + font-size: 14px; + } + + body.hub-phone #page-calculator .calc-hint { + display: none; + } + + body.hub-phone #page-calculator .calc-form-grid { + grid-template-columns: 1fr 1fr; + gap: 8px 10px; + } + + body.hub-phone #page-calculator .calc-field-span2 { + grid-column: 1 / -1; + } + + body.hub-phone #page-calculator .calc-market-info { + font-size: 10px; + line-height: 1.35; + padding: 6px 8px; + max-height: 3.2em; + overflow: hidden; + } + + body.hub-phone #page-calculator .calc-field span { + font-size: 10px; + } + + body.hub-phone #page-calculator .calc-field input, + body.hub-phone #page-calculator .calc-field select { + min-height: 34px; + padding: 4px 8px; + font-size: 13px; + } + + /* 手机折叠条 */ + body.hub-phone .hub-m-fold { + margin: 0 0 10px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--panel); + overflow: hidden; + } + + body.hub-phone .hub-m-fold > .hub-m-fold-summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 12px; + cursor: pointer; + font-size: 13px; + font-weight: 600; + color: var(--text); + -webkit-tap-highlight-color: transparent; + } + + body.hub-phone .hub-m-fold > .hub-m-fold-summary::after { + content: "▾"; + color: var(--muted); + font-size: 12px; + transition: transform 0.15s; + } + + body.hub-phone .hub-m-fold[open] > .hub-m-fold-summary::after { + transform: rotate(180deg); + } + + body.hub-phone .hub-m-fold-meta { + flex: 1 1 auto; + min-width: 0; + text-align: right; + font-size: 10px; + font-weight: 500; + font-family: var(--mono); + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + body.hub-phone .hub-m-fold .toolbar, + body.hub-phone .hub-m-fold .market-toolbar { + margin-bottom: 0; + border: none; + border-top: 1px solid var(--border-soft); + border-radius: 0; + box-shadow: none; + } + + /* 计算器手机 Tab */ + body.hub-phone .calc-mobile-tabs { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; + margin: 0 0 10px; + } + + body.hub-phone .calc-m-tab { + min-height: 38px; + border: 1px solid var(--border-soft); + border-radius: 10px; + background: var(--nav-bg, rgba(255, 255, 255, 0.03)); + color: var(--muted); + font: inherit; + font-size: 13px; + font-weight: 600; + cursor: pointer; + } + + body.hub-phone .calc-m-tab.is-active { + color: var(--accent, #6ea8ff); + border-color: color-mix(in srgb, var(--accent, #6ea8ff) 45%, var(--border-soft)); + background: var(--accent-dim, rgba(110, 168, 255, 0.12)); + } + + body.hub-phone .calc-layout[data-calc-tab="trend"] [data-calc-pane="roll"], + body.hub-phone .calc-layout[data-calc-tab="roll"] [data-calc-pane="trend"] { + display: none; + } + + body.hub-phone .calc-layout { + grid-template-columns: 1fr; + } + + body.hub-phone #page-calculator .calc-card h2 { + display: none; + } + + /* 行情全屏:竖屏提示转横;横屏吃满 */ + @media (orientation: portrait) { + body.hub-phone.market-chart-fs-open .market-chart-wrap.is-fullscreen::before { + content: "全屏看图请横持手机"; + position: absolute; + top: max(10px, env(safe-area-inset-top)); + left: 50%; + transform: translateX(-50%); + z-index: 5; + padding: 6px 12px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.72); + color: #fff; + font-size: 12px; + pointer-events: none; + } + } + + @media (orientation: landscape) { + body.hub-phone.market-chart-fs-open .app-header, + body.hub-phone.market-chart-fs-open .hub-mobile-tabbar { + display: none !important; + } + } + + .hub-mobile-tabbar { + display: flex; + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 80; + height: calc(var(--hub-m-tabbar-h) + env(safe-area-inset-bottom)); + padding: 0 max(8px, env(safe-area-inset-right)) env(safe-area-inset-bottom) + max(8px, env(safe-area-inset-left)); + align-items: stretch; + justify-content: space-around; + gap: 2px; + background: color-mix(in srgb, var(--panel, #12161f) 92%, transparent); + border-top: 1px solid var(--border-soft); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + box-sizing: border-box; + } + + .hub-mobile-tabbar .hub-m-tab.nav-hidden { + display: none !important; + } + + .hub-m-tab { + flex: 1 1 0; + min-width: 0; + display: inline-flex; + align-items: center; + justify-content: center; + margin: 6px 2px; + padding: 0 4px; + border: none; + border-radius: 10px; + background: transparent; + color: var(--nav-link-idle, var(--muted)); + font: inherit; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.04em; + text-decoration: none; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + } + + .hub-m-tab:hover, + .hub-m-tab:focus-visible { + color: var(--text); + background: var(--nav-link-hover-bg, rgba(255, 255, 255, 0.04)); + outline: none; + } + + .hub-m-tab.active { + color: var(--accent, #6ea8ff); + background: var(--accent-dim, rgba(110, 168, 255, 0.12)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent, #6ea8ff) 35%, transparent); + } + + body.hub-phone #toast { + bottom: calc(var(--hub-m-tabbar-h) + max(12px, env(safe-area-inset-bottom))); + } + + /* 更多抽屉 */ + body.hub-mobile-more-open .hub-mobile-more { + display: block; + } + + .hub-mobile-more { + position: fixed; + inset: 0; + z-index: 90; + } + + .hub-mobile-more-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.45); + } + + .hub-mobile-more-sheet { + position: absolute; + left: 0; + right: 0; + bottom: 0; + max-height: min(78vh, 560px); + overflow: auto; + padding: 10px 16px calc(16px + env(safe-area-inset-bottom)); + border-radius: 16px 16px 0 0; + background: var(--panel, #12161f); + border: 1px solid var(--border-soft); + border-bottom: none; + box-shadow: 0 -12px 40px rgba(0, 0, 0, 0.35); + } + + .hub-mobile-more-handle { + width: 36px; + height: 4px; + margin: 2px auto 12px; + border-radius: 999px; + background: var(--border); + } + + .hub-mobile-more-title { + margin: 0 0 4px; + font-size: 1rem; + } + + .hub-mobile-more-hint { + margin: 0 0 14px; + font-size: 11px; + color: var(--muted); + } + + .hub-mobile-more-nav { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + } + + .hub-mobile-more-nav a { + display: flex; + align-items: center; + justify-content: center; + min-height: 44px; + padding: 10px 8px; + border-radius: 10px; + border: 1px solid var(--border-soft); + background: var(--nav-bg, rgba(255, 255, 255, 0.03)); + color: var(--text); + text-decoration: none; + font-size: 13px; + font-weight: 500; + } + + .hub-mobile-more-nav a.nav-hidden { + display: none !important; + } + + .hub-mobile-more-nav a.active { + border-color: color-mix(in srgb, var(--accent, #6ea8ff) 45%, var(--border-soft)); + background: var(--accent-dim, rgba(110, 168, 255, 0.12)); + color: var(--accent, #6ea8ff); + } + + .hub-mobile-more-close { + width: 100%; + margin-top: 14px; + min-height: 44px; + } + + /* AI 页为底栏让位;键盘打开时藏底栏 */ + body.hub-page-ai.hub-phone .app-shell { + padding-bottom: calc(var(--hub-m-tabbar-h) + max(8px, env(safe-area-inset-bottom))); + } + + body.hub-page-ai.hub-phone.hub-ai-keyboard-open .hub-mobile-tabbar { + display: none; + } + + body.hub-page-ai.hub-phone.hub-ai-keyboard-open .app-shell { + padding-bottom: max(8px, env(safe-area-inset-bottom)); + } + + body.hub-page-ai.hub-phone .app-header .top-nav { + display: none !important; + } +} + +@media (max-width: 480px) { + body { + font-size: 12px; + } + + .brand-title { + font-size: 13px; + } + + .stat-row { + grid-template-columns: 1fr; + } + + .card-actions .btn-link, + .card-actions button { + flex: 1 1 100%; + } + + .fs-head-actions { + grid-template-columns: 1fr; + } + + .pos-action-group { + flex-direction: column; + align-items: stretch; + width: 100%; + } + + .pos-action-group .btn-sm { + width: 100%; + min-height: 44px; + } + + .data-table .td-actions { + white-space: normal; + } +} + +/* ---------- 行情区 ---------- */ +.market-toolbar { + flex-wrap: wrap; + gap: 10px; + align-items: flex-end; +} + +.market-field { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 0.72rem; + color: var(--muted); +} + +.market-field select, +.market-field input { + min-width: 120px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--border-soft); + background: var(--bg-elevated); + color: var(--text); + font-family: var(--font); +} + +.market-status { + font-size: 0.8rem; + color: var(--muted); + margin: 0 0 10px; +} + +.market-status.err { + color: var(--red); +} + +.market-status.warn { + color: #ffb84d; +} + +.market-countdown { + color: var(--accent); + font-variant-numeric: tabular-nums; +} + +.market-countdown.market-tf-key-hint { + color: #ffb84d; +} + +.market-chart-wrap { + display: flex; + flex-direction: column; + height: min(76vh, 680px); + min-height: 380px; + border: 1px solid var(--border-soft); + border-radius: var(--radius); + background: var(--chart-surface); + overflow: hidden; +} + +.market-chart-wrap.has-pos-panel { + height: min(80vh, 740px); + min-height: 440px; +} + +.market-chart-wrap.is-fullscreen { + position: fixed; + inset: 0; + z-index: 8500; + width: 100vw; + height: 100vh !important; + max-height: none; + min-height: 0; + border-radius: 0; + border: none; +} + +.market-chart-wrap.is-fullscreen.has-pos-panel { + height: 100vh !important; +} + +.market-chart-actions { + margin-left: auto; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 10px; +} + +.market-day-split-opt { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.72rem; + color: var(--muted); + cursor: pointer; + user-select: none; + padding: 2px 8px; + border-radius: 4px; + border: 1px solid var(--border-soft); + white-space: nowrap; +} + +.market-day-split-opt:hover { + color: var(--text); + border-color: var(--border); +} + +.market-day-split-opt input { + accent-color: #3b82f6; +} + +.market-day-split-opt:has(input:checked) { + color: #3b82f6; + border-color: rgba(59, 130, 246, 0.45); +} + +.market-ind-menu { + position: relative; + font-size: 0.72rem; +} + +.market-ind-menu summary { + cursor: pointer; + list-style: none; + padding: 2px 10px; + border-radius: 4px; + border: 1px solid var(--border-soft); + color: var(--muted); + user-select: none; +} + +.market-ind-menu summary::-webkit-details-marker { + display: none; +} + +.market-ind-menu[open] summary { + color: var(--accent); + border-color: rgba(0, 255, 157, 0.35); +} + +.market-ind-options { + position: absolute; + right: 0; + top: calc(100% + 4px); + z-index: 20; + min-width: 168px; + padding: 8px 10px; + border-radius: 6px; + border: 1px solid var(--border-soft); + background: var(--panel-solid); + box-shadow: var(--shadow); + display: flex; + flex-direction: column; + gap: 6px; +} + +.market-ind-opt { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + color: var(--text); + white-space: nowrap; +} + +.market-ind-opt input { + accent-color: var(--accent); +} + +.market-fs-btn, +.market-fs-exit { + font-size: 0.72rem; + padding: 2px 10px; +} + +.market-fs-exit { + position: absolute; + top: 8px; + left: 8px; + z-index: 12; +} + +.market-chart-wrap.is-fullscreen .market-fs-exit:not(.hidden) { + display: inline-flex !important; +} + +.market-chart-wrap.is-fullscreen .market-fs-btn { + display: none; +} + +.market-fs-toolbar { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 8px 12px; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--border-soft); +} + +.market-fs-toolbar.hidden { + display: none; +} + +.market-fs-field.market-field-symbol .market-symbol-wrap { + min-width: 180px; +} + +.market-fs-field span { + font-size: 0.68rem; + color: var(--muted); +} + +.market-fs-field select, +.market-fs-field input { + font-size: 0.78rem; + min-width: 100px; +} + +.market-div-legend { + margin-top: 4px; + font-size: 0.72rem; + color: #ffb84d; + line-height: 1.4; +} + +.market-div-legend.hidden { + display: none; +} + +.market-ohlcv-bar { + flex: 0 0 auto; + padding: 8px 12px; + border-bottom: 1px solid var(--border-soft); + background: var(--chart-bar-bg); + font-size: 0.78rem; +} + +.market-chart-body { + flex: 1; + display: flex; + flex-direction: row; + min-height: 0; + position: relative; +} + +.market-draw-toolbar { + flex: 0 0 40px; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 6px 4px; + border-right: 1px solid var(--border-soft); + background: var(--chart-bar-bg); + z-index: 4; + overflow-y: auto; +} + +.market-draw-btn { + width: 32px; + height: 32px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: var(--muted); + cursor: pointer; + flex-shrink: 0; +} + +.market-draw-btn svg { + width: 18px; + height: 18px; +} + +.market-draw-btn-text { + font-size: 0.82rem; + font-weight: 700; + font-family: var(--font); +} + +.market-draw-btn:hover { + color: var(--text); + background: var(--inset-surface); + border-color: var(--border-soft); +} + +.market-draw-btn.is-active { + color: var(--accent); + background: rgba(0, 255, 157, 0.1); + border-color: rgba(0, 255, 157, 0.35); +} + +.market-draw-sep { + width: 22px; + height: 1px; + background: var(--border-soft); + margin: 2px 0; +} + +.market-chart-main { + flex: 1; + min-width: 0; + height: 100%; + position: relative; + display: flex; +} + +.market-chart-host { + flex: 1; + min-width: 0; + height: 100%; + position: relative; + overflow: hidden; +} + +.market-draw-canvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 20; + pointer-events: none; + touch-action: none; +} + +.market-draw-canvas.is-drawing { + cursor: crosshair; + pointer-events: auto; +} + +.market-field-symbol .market-symbol-wrap { + display: flex; + align-items: stretch; + gap: 6px; + min-width: 0; +} + +.market-field-symbol .market-symbol-wrap > input { + flex: 1; + min-width: 120px; +} + +.market-vol-rank-btn { + flex: 0 0 auto; + min-height: 34px; + padding: 0 10px; + border: 1px solid var(--border-soft); + border-radius: 6px; + background: var(--inset-surface); + color: var(--accent); + font-size: 0.78rem; + font-weight: 600; + font-family: var(--font); + white-space: nowrap; + cursor: pointer; +} + +.market-scan-tabs { + display: flex; + flex-wrap: wrap; + gap: 4px; + flex: 0 0 auto; +} + +.market-scan-tab { + flex: 0 0 auto; + min-height: 34px; + padding: 0 8px; + border: 1px solid var(--border-soft); + border-radius: 6px; + background: var(--inset-surface); + color: var(--muted); + font-size: 0.72rem; + font-weight: 600; + font-family: var(--font); + white-space: nowrap; + cursor: pointer; +} + +.market-scan-tab:hover { + border-color: rgba(0, 255, 157, 0.35); + color: var(--text); +} + +.market-scan-tab.is-active { + border-color: rgba(0, 255, 157, 0.45); + background: rgba(0, 255, 157, 0.12); + color: var(--accent); +} + +.market-vol-rank-btn:hover { + border-color: rgba(0, 255, 157, 0.35); + background: rgba(0, 255, 157, 0.08); +} + +.market-vol-rank-btn.is-active { + border-color: rgba(0, 255, 157, 0.45); + background: rgba(0, 255, 157, 0.12); + color: var(--accent); +} + +.market-vol-rank-anchor { + margin: -6px 0 12px; +} + +.market-vol-rank-anchor:empty, +.market-vol-rank-anchor-fs:empty { + display: none; +} + +.market-vol-rank-sheet { + padding: 10px 12px 8px; + border: 1px solid var(--border-soft); + border-radius: var(--radius); + background: var(--panel); + box-shadow: var(--glow); +} + +.market-chart-wrap .market-vol-rank-sheet { + margin: 0; + border-radius: 0; + border-left: none; + border-right: none; + box-shadow: none; +} + +.market-chart-wrap.is-fullscreen .market-vol-rank-sheet { + background: var(--chart-bar-bg); +} + +.market-vol-rank-sheet.hidden { + display: none; +} + +.market-vol-rank-meta { + padding: 0 10px 6px; + font-size: 0.68rem; + color: var(--muted); + line-height: 1.35; +} + +.market-vol-rank-list { + margin: 0; + padding: 0; + list-style: none; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); + gap: 2px 12px; + max-height: 200px; + overflow: auto; +} + +.market-vol-rank-list.is-div-scan-list { + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 8px; + max-height: min(52vh, 420px); + padding: 4px 2px 8px; +} + +.market-vol-rank-li { + list-style: none; + min-width: 0; +} + +.market-div-scan-meta-title, +.div-scan-meta-title { + font-size: 0.78rem; + color: var(--text); + font-weight: 600; +} + +.market-div-scan-meta-sub, +.div-scan-meta-sub { + margin-top: 2px; + font-size: 0.68rem; + color: var(--muted); +} + +.market-vol-rank-item.is-div-scan { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 8px; + padding: 10px 12px; + border: 1px solid var(--border-soft); + border-radius: 8px; + background: var(--inset-surface); + min-height: 4.5rem; +} + +.market-vol-rank-item.is-div-scan:hover { + border-color: rgba(0, 255, 157, 0.28); + background: rgba(0, 255, 157, 0.06); +} + +.market-vol-rank-item.is-div-scan.is-active { + border-color: rgba(0, 255, 157, 0.45); + background: rgba(0, 255, 157, 0.1); +} + +.div-scan-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + min-width: 0; +} + +.div-scan-head { + gap: 10px; +} + +.div-scan-head .market-vol-rank-sym { + font-size: 0.92rem; + font-weight: 700; + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.div-scan-head .market-vol-rank-no { + font-size: 0.72rem; +} + +.div-scan-head .market-vol-rank-badge { + font-size: 0.7rem; + padding: 2px 8px; +} + +.div-scan-sub { + gap: 10px; + padding-left: 2px; +} + +.div-scan-dir { + font-size: 0.8rem; + font-weight: 600; +} + +.div-scan-dir.is-bull { + color: #4cd97f; +} + +.div-scan-dir.is-bear { + color: #ff7a9a; +} + +.div-scan-dir.is-split { + color: #b8bcc4; +} + +.div-scan-fresh { + font-size: 0.72rem; + color: var(--muted); +} + +.div-scan-split-detail { + font-size: 0.72rem; + color: #b8bcc4; +} + +.div-scan-tf-pills { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-left: auto; +} + +.div-scan-tf-pill { + padding: 1px 6px; + border-radius: 4px; + font-size: 0.65rem; + background: rgba(255, 255, 255, 0.06); + color: var(--muted); +} + +.market-vol-rank-item { + width: 100%; + display: grid; + grid-template-columns: 28px 1fr auto; + gap: 6px; + align-items: center; + padding: 6px 10px; + border: 0; + background: transparent; + color: var(--text); + font-size: 0.8rem; + font-family: var(--font); + text-align: left; + cursor: pointer; +} + +.market-vol-rank-item:hover { + background: var(--inset-surface); +} + +.market-vol-rank-item.is-active { + background: rgba(0, 255, 157, 0.1); + color: var(--accent); +} + +.market-vol-rank-item.confluence-c1 { + border-left: 3px solid #6b8cae; +} + +.market-vol-rank-item.confluence-c2 { + border-left: 3px solid #e6a23c; +} + +.market-vol-rank-item.confluence-c3 { + border-left: 3px solid #ff4d8d; +} + +.market-vol-rank-item.confluence-split { + border-left: 3px solid #8a8f98; +} + +.market-vol-rank-item.is-div-scan.confluence-c1, +.market-vol-rank-item.is-div-scan.confluence-c2, +.market-vol-rank-item.is-div-scan.confluence-c3, +.market-vol-rank-item.is-div-scan.confluence-split { + border-left-width: 4px; +} + +.market-vol-rank-badge { + padding: 1px 6px; + border-radius: 4px; + font-size: 0.62rem; + font-weight: 700; + white-space: nowrap; +} + +.market-vol-rank-badge.confluence-c1 { + background: rgba(107, 140, 174, 0.22); + color: #9eb8d4; +} + +.market-vol-rank-badge.confluence-c2 { + background: rgba(230, 162, 60, 0.2); + color: #f0c070; +} + +.market-vol-rank-badge.confluence-c3 { + background: rgba(255, 77, 141, 0.18); + color: #ff8cb8; +} + +.market-vol-rank-badge.confluence-split { + background: rgba(138, 143, 152, 0.22); + color: #b8bcc4; +} + +.market-vol-rank-div { + font-size: 0.68rem; + color: var(--muted); + white-space: nowrap; +} + +.market-vol-rank-no { + color: var(--muted); + font-variant-numeric: tabular-nums; +} + +.market-vol-rank-sym { + font-weight: 600; +} + +.market-vol-rank-vol { + color: var(--muted); + font-size: 0.72rem; + font-variant-numeric: tabular-nums; +} + +.market-draw-menu { + position: fixed; + z-index: 1200; + min-width: 168px; + padding: 4px 0; + border: 1px solid var(--border-soft); + border-radius: 8px; + background: var(--panel-bg, #1a1f2e); + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45); +} + +.market-draw-menu.hidden { + display: none; +} + +.market-draw-menu-head { + padding: 6px 12px 4px; + font-size: 0.72rem; + font-weight: 600; + color: var(--muted); + text-transform: none; +} + +.market-draw-menu-item { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 7px 12px; + border: 0; + background: transparent; + color: var(--text); + font-size: 0.82rem; + font-family: var(--font); + text-align: left; + cursor: pointer; +} + +.market-draw-menu-item:hover:not(:disabled) { + background: var(--inset-surface); +} + +.market-draw-menu-item:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.market-draw-menu-item.is-danger { + color: #f87171; +} + +.market-draw-menu-sep { + border: 0; + border-top: 1px solid var(--border-soft); + margin: 4px 0; +} + +.market-draw-menu-kbd { + margin-left: 12px; + padding: 1px 5px; + border-radius: 4px; + background: var(--inset-surface); + color: var(--muted); + font-size: 0.68rem; +} + +.market-exchange-badge { + position: absolute; + left: 50%; + top: 50%; + z-index: 1; + transform: translate(-50%, -50%) rotate(-90deg); + transform-origin: center center; + font-family: var(--font-display, var(--font)); + font-size: 0.95rem; + font-weight: 600; + letter-spacing: 0.12em; + color: var(--muted); + opacity: 0.22; + pointer-events: none; + white-space: nowrap; + user-select: none; +} + +.market-exchange-badge:empty { + display: none; +} + +.market-ohlcv-title { + font-weight: 600; + color: var(--accent); + margin-bottom: 4px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 10px; +} + +.mkt-exchange-tag { + padding: 1px 8px; + border-radius: 4px; + background: rgba(0, 255, 157, 0.12); + border: 1px solid rgba(0, 255, 157, 0.35); + color: var(--green); + font-size: 0.72rem; + font-weight: 600; +} + +.mkt-exchange-tag:empty { + display: none; +} + +.market-ohlcv-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 14px; + font-weight: 600; +} + +.market-ohlcv-row .ohlcv-item { + white-space: nowrap; +} + +.market-ohlcv-row .k { + color: var(--muted); + margin-right: 4px; +} + +.market-pos-panel { + flex: 0 0 auto; + padding: 8px 12px 10px; + border-bottom: 1px solid var(--border-soft); + background: var(--chart-bar-bg); + color: var(--text); + font-size: 0.8rem; +} + +.market-pos-panel.hidden { + display: none; +} + +.market-pos-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 14px; +} + +.market-pos-side { + padding: 1px 8px; + border-radius: 4px; + font-size: 0.72rem; + font-weight: 600; +} + +.market-pos-side.side-long { + background: rgba(0, 255, 157, 0.12); + border: 1px solid rgba(0, 255, 157, 0.35); + color: var(--green); +} + +.market-pos-side.side-short { + background: rgba(255, 77, 109, 0.12); + border: 1px solid rgba(255, 77, 109, 0.35); + color: var(--red); +} + +.market-pos-clear { + margin-left: auto; + font-size: 0.72rem; + padding: 2px 8px; +} + +.market-pos-pnl { + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.market-pos-pnl.pnl-up { + color: #3ddc84; +} + +.market-pos-pnl.pnl-down { + color: #ff7070; +} + +.market-pos-panel .ohlcv-item { + font-weight: 600; + color: var(--text); +} + +.market-pos-panel .ohlcv-item .k { + font-weight: 600; + color: var(--muted); +} + +.market-pos-orders { + display: flex; + flex-wrap: wrap; + gap: 4px 10px; + margin-top: 6px; + color: var(--text); + font-weight: 500; +} + +.market-pos-orders-empty { + font-size: 0.72rem; + opacity: 0.75; +} + +.market-pos-order { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 4px; + background: var(--inset-surface); + border: 1px solid var(--border-soft); + white-space: nowrap; + font-weight: 500; +} + +.market-pos-order-kind { + color: var(--accent); + font-size: 0.68rem; +} + +.market-pos-order-label { + color: var(--text); +} + +.market-pos-order-price { + color: #c98a20; + font-family: var(--font-mono, monospace); + font-weight: 600; +} + +.market-pos-order-amt { + color: var(--muted); + font-size: 0.68rem; +} + +.market-pos-tp-monitored { + color: var(--accent); + font-size: 0.72rem; + font-weight: 600; +} + +.sym-link { + background: none; + border: none; + padding: 0; + margin: 0; + font: inherit; + color: var(--accent); + cursor: pointer; + text-align: left; + text-decoration: underline; + text-underline-offset: 2px; +} + +.sym-link:hover { + color: #00ff9d; +} + +.pos-symbol-link { + display: inline; +} + +.pos-symbol-link strong { + font-weight: inherit; +} + +.market-price-tag { + position: absolute; + right: 0; + z-index: 5; + pointer-events: none; + padding: 4px 8px; + border-radius: 4px 0 0 4px; + font-family: var(--font); + font-size: 0.72rem; + font-weight: 600; + line-height: 1.25; + text-align: center; + transform: translateY(-50%); + min-width: 72px; + box-shadow: 0 1px 6px rgba(0, 0, 0, 0.35); +} + +.market-price-tag-head { + display: flex; + flex-direction: row; + align-items: baseline; + justify-content: center; + gap: 4px; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.market-price-tag-label { + font-size: 0.62rem; + font-weight: 500; + opacity: 0.9; + line-height: 1; +} + +.market-price-tag.is-up .market-price-tag-label { + color: rgba(10, 16, 24, 0.75); +} + +.market-price-tag.is-down .market-price-tag-label { + color: rgba(255, 255, 255, 0.85); +} + +.market-price-tag.hidden { + display: none; +} + +.market-price-tag.is-up { + background: #00ff9d; + color: #0a1018; +} + +.market-price-tag.is-down { + background: #ff4d6d; + color: #fff; +} + +.market-price-tag-value { + font-variant-numeric: tabular-nums; +} + +.market-price-tag-time { + margin-top: 3px; + font-size: 0.68rem; + font-weight: 500; + font-variant-numeric: tabular-nums; + line-height: 1; + opacity: 0.95; +} + +.market-price-auto { + position: absolute; + right: 8px; + bottom: 10px; + z-index: 5; + width: auto; + padding: 4px 8px; + font-size: 0.68rem; + font-family: var(--font); + border-radius: 6px; + border: 1px solid var(--border-soft); + background: var(--chart-bar-bg); + color: var(--muted); + cursor: pointer; + line-height: 1.2; +} + +.market-price-auto:hover { + border-color: var(--accent); + color: var(--text); +} + +.market-price-auto.is-on { + color: var(--green); + border-color: rgba(0, 255, 157, 0.45); + background: rgba(0, 255, 157, 0.1); +} + +.market-chart-wrap.is-fullscreen { + background: var(--bg); +} + +.market-chart-wrap.is-fullscreen .market-ohlcv-bar, +.market-chart-wrap.is-fullscreen .market-fs-toolbar { + background: var(--chart-bar-bg); +} + +/* —— 亮色主题:对比度与全屏/放大 —— */ +html[data-theme="light"] .app-bg, +html[data-theme="light"] .login-bg { + background: + linear-gradient(rgba(0, 90, 130, 0.07) 1px, transparent 1px), + linear-gradient(90deg, rgba(0, 90, 130, 0.07) 1px, transparent 1px), + radial-gradient(ellipse 80% 50% at 50% -20%, rgba(0, 120, 180, 0.08), transparent), + radial-gradient(ellipse 60% 40% at 100% 100%, rgba(80, 70, 180, 0.05), transparent); + background-size: 48px 48px, 48px 48px, auto, auto; +} + +html[data-theme="light"] .app-bg::after, +html[data-theme="light"] .login-bg::after { + opacity: 0.12; +} + +html[data-theme="light"] a:hover { + text-shadow: none; +} + +html[data-theme="light"] .side-long, +html[data-theme="light"] .side-short { + text-shadow: none; +} + +html[data-theme="light"] .top-nav { + background: rgba(255, 255, 255, 0.96); + border-color: rgba(0, 75, 115, 0.18); + box-shadow: 0 1px 4px rgba(30, 60, 100, 0.08); +} + +html[data-theme="light"] .top-nav a.active { + background: linear-gradient(135deg, rgba(0, 110, 154, 0.14), rgba(91, 79, 199, 0.08)); + color: var(--nav-link-active-fg); + border-color: rgba(0, 95, 140, 0.28); + box-shadow: none; +} + +html[data-theme="light"] .theme-toggle { + background: rgba(255, 255, 255, 0.96); + border-color: rgba(0, 75, 115, 0.18); +} + +html[data-theme="light"] .theme-toggle-btn.is-active { + color: var(--nav-link-active-fg); + background: rgba(0, 110, 154, 0.12); +} + +html[data-theme="light"] .header-right #btn-logout { + color: var(--nav-link-idle); + border-color: rgba(0, 75, 115, 0.18); +} + +html[data-theme="light"] .instance-frame-toolbar { + background: #fff; +} + +html[data-theme="light"] .instance-frame-toolbar .ghost { + color: var(--nav-link-idle); + border-color: rgba(0, 75, 115, 0.18); +} + +html[data-theme="light"] .instance-frame-title { + color: var(--text); +} + +html[data-theme="light"] .mkt-exchange-tag { + background: rgba(10, 143, 92, 0.1); + border-color: rgba(10, 143, 92, 0.32); +} + +html[data-theme="light"] .market-ind-menu[open] summary { + border-color: rgba(10, 143, 92, 0.35); +} + +html[data-theme="light"] .market-price-auto.is-on { + border-color: rgba(10, 143, 92, 0.4); +} + +html[data-theme="light"] .market-price-tag.is-up { + background: var(--green); + color: #fff; +} + +html[data-theme="light"] .market-price-tag.is-up .market-price-tag-label { + color: rgba(255, 255, 255, 0.9); +} + +html[data-theme="light"] .market-price-tag { + box-shadow: 0 1px 4px rgba(30, 60, 100, 0.15); +} + +html[data-theme="light"] .stat-box { + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65); +} + +html[data-theme="light"] .card-stat-chip.card-stat-key-breakout { + background: rgba(0, 110, 154, 0.1); + border-color: rgba(0, 110, 154, 0.28); +} + +html[data-theme="light"] .card-stat-chip.card-stat-trend { + background: rgba(10, 143, 92, 0.1); + border-color: rgba(10, 143, 92, 0.28); +} + +html[data-theme="light"] .card-stat-chip.card-stat-key-watch { + background: rgba(91, 79, 199, 0.1); + border-color: rgba(91, 79, 199, 0.28); +} + +html[data-theme="light"] .hub-pos-card .pos-entrust-btn { + background: rgba(0, 110, 154, 0.1); + color: var(--accent); + border-color: var(--border-soft); +} + +html[data-theme="light"] .hub-pos-card .pos-value.pnl-pos { + text-shadow: none; +} + +html[data-theme="light"] .exchange-fullscreen-panel, +html[data-theme="light"] .modal-panel { + box-shadow: var(--shadow); +} + +html[data-theme="light"] input, +html[data-theme="light"] select, +html[data-theme="light"] textarea { + background: var(--bg-elevated); + color: var(--text); + border-color: var(--border-soft); +} + +html[data-theme="light"] .hub-tile, +html[data-theme="light"] .card, +html[data-theme="light"] .hub-pos-card, +html[data-theme="light"] .hub-trend-plan-card, +html[data-theme="light"] .settings-row { + box-shadow: 0 2px 10px rgba(30, 60, 100, 0.08); +} + +html[data-theme="light"] button.primary, +html[data-theme="light"] .market-toolbar button.primary, +html[data-theme="light"] #market-load, +html[data-theme="light"] #market-fs-load, +html[data-theme="light"] #btn-monitor-refresh { + background: #006e9a; + border-color: #005a82; + color: #fff; + font-weight: 700; + box-shadow: 0 2px 8px rgba(0, 95, 140, 0.28); +} + +html[data-theme="light"] button.primary:hover:not(:disabled), +html[data-theme="light"] #market-load:hover:not(:disabled), +html[data-theme="light"] #market-fs-load:hover:not(:disabled), +html[data-theme="light"] #btn-monitor-refresh:hover:not(:disabled) { + background: #0088b8; + color: #fff; + box-shadow: 0 3px 12px rgba(0, 95, 140, 0.35); +} + +html[data-theme="light"] .market-pos-panel { + background: var(--chart-bar-bg); + color: var(--text); +} + +html[data-theme="light"] .market-pos-side.side-long { + background: rgba(10, 143, 92, 0.12); + border-color: rgba(10, 143, 92, 0.35); +} + +html[data-theme="light"] .market-pos-side.side-short { + background: rgba(201, 53, 82, 0.1); + border-color: rgba(201, 53, 82, 0.35); +} + +html[data-theme="light"] .market-pos-order { + background: var(--inset-surface-strong); +} + +html[data-theme="light"] .market-pos-order-price { + color: #9a6b10; +} + +html[data-theme="light"] .market-pos-pnl.pnl-up { + color: #0a7a3d; +} + +html[data-theme="light"] .market-pos-pnl.pnl-down { + color: #c62828; +} + +html[data-theme="light"] .market-pos-clear { + font-weight: 600; + color: var(--text); + border-color: var(--border); + background: var(--bg-elevated); +} + +html[data-theme="light"] .market-status { + font-weight: 600; + color: var(--text); + opacity: 0.88; +} + +html[data-theme="light"] .toolbar-meta { + font-weight: 600; + color: var(--text); + opacity: 0.85; +} + +html[data-theme="light"] .chk-label { + font-weight: 600; + color: var(--text); +} + +html[data-theme="light"] .hub-trend-plan-card .plan-dca-table { + font-size: 0.8rem; +} + +html[data-theme="light"] .hub-trend-plan-card .plan-dca-table th { + font-weight: 700; + color: var(--text); +} + +html[data-theme="light"] button.danger { + font-weight: 600; + background: rgba(201, 53, 82, 0.1); + border-color: rgba(201, 53, 82, 0.45); +} + +/* --- Hub AI 教练(整页一屏,内容区内滚动)--- */ +body.hub-page-ai { + overflow: hidden; + height: 100dvh; + max-height: 100dvh; +} +body.hub-page-ai .app-shell { + padding-bottom: 12px; + height: 100dvh; + max-height: 100dvh; + overflow: hidden; + display: flex; + flex-direction: column; + box-sizing: border-box; +} +body.hub-page-ai .app-shell > #page-ai { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} +body.hub-page-ai .app-header { + flex-shrink: 0; + margin-bottom: 4px; +} +body.hub-page-ai #page-ai { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} +#page-ai .page-head { + flex-shrink: 0; + margin: 8px 0 10px; +} +#page-ai .page-head h1 { + margin-bottom: 4px; + font-size: 18px; +} +#page-ai .page-desc { + margin: 0; + font-size: 0.78rem; + line-height: 1.35; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.ai-layout { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + align-items: stretch; + overflow: hidden; +} +.ai-layout .ai-chat-panel { + flex: 1 1 auto; + min-height: 0; +} +.ai-mobile-tabs { + display: none; +} + +/* 手机 AI:须在 .ai-layout 双列定义之后,避免被覆盖成半屏 */ +@media (max-width: 720px), ((display-mode: standalone) and (max-width: 960px)) { + html:has(body.hub-page-ai) { + height: 100%; + overflow: hidden; + } + + body.hub-page-ai .app-shell { + padding-bottom: max(8px, env(safe-area-inset-bottom)); + height: var(--hub-vvh, 100dvh); + max-height: var(--hub-vvh, 100dvh); + overflow: hidden; + width: 100%; + max-width: none; + box-sizing: border-box; + will-change: transform, height; + } + + body.hub-page-ai { + position: fixed; + inset: 0; + width: 100%; + overflow: hidden; + background: var(--bg); + overscroll-behavior: none; + } + + body.hub-page-ai .app-header { + padding: 6px 0; + margin-bottom: 2px; + gap: 8px; + } + + body.hub-page-ai .top-nav a { + min-height: 34px; + padding: 6px 10px; + font-size: 11px; + } + + body.hub-page-ai .app-header .brand { + display: none; + } + + body.hub-page-ai .header-right { + grid-template-columns: 1fr auto auto; + grid-template-rows: auto; + } + + body.hub-page-ai .header-right .top-nav { + grid-column: 1 / -1; + order: 2; + } + + body.hub-page-ai.hub-ai-keyboard-open .app-header .theme-toggle, + body.hub-page-ai.hub-ai-keyboard-open .app-header .sys-pill, + body.hub-page-ai.hub-ai-keyboard-open .app-header #btn-logout { + display: none; + } + + body.hub-page-ai.hub-ai-keyboard-open .app-header { + padding: 4px 0; + margin-bottom: 0; + } + + body.hub-page-ai.hub-ai-keyboard-open .top-nav a { + min-height: 30px; + padding: 4px 8px; + font-size: 10px; + } + + body.hub-page-ai #page-ai { + overflow: hidden; + width: 100%; + min-width: 0; + } + + body.hub-page-ai .ai-mobile-tabs { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 6px; + margin-bottom: 6px; + flex-shrink: 0; + width: 100%; + position: sticky; + top: 0; + z-index: 12; + padding: 4px 0 2px; + background: var(--bg); + } + + body.hub-page-ai .ai-mobile-tab { + min-height: 38px; + padding: 6px 4px; + border-radius: 8px; + border: 1px solid var(--border-soft); + background: var(--inset-surface); + color: var(--muted); + font-family: var(--font); + font-size: 0.7rem; + font-weight: 600; + cursor: pointer; + line-height: 1.2; + text-align: center; + } + + body.hub-page-ai .ai-mobile-tab-action { + color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 35%, var(--border-soft)); + } + + body.hub-page-ai .ai-mobile-tab.is-active { + color: var(--text); + border-color: var(--accent); + background: var(--accent-dim); + box-shadow: none; + } + + body.hub-page-ai #page-ai .page-head { + display: none; + } + + body.hub-page-ai .ai-layout { + display: flex; + flex-direction: column; + width: 100%; + min-width: 0; + flex: 1 1 auto; + min-height: 0; + gap: 0; + overflow: hidden; + } + + body.hub-page-ai .ai-layout[data-ai-mobile-tab="trading"] .ai-chat-panel, + body.hub-page-ai .ai-layout[data-ai-mobile-tab="general"] .ai-chat-panel, + body.hub-page-ai .ai-layout[data-ai-mobile-tab="supervisor"] .ai-chat-panel, + body.hub-page-ai .ai-layout[data-ai-mobile-tab="history"] .ai-chat-panel { + display: flex; + flex: 1 1 auto; + width: 100%; + max-width: 100%; + min-height: 0; + min-width: 0; + } + + body.hub-page-ai .ai-layout[data-ai-mobile-tab="trading"] .ai-chat-history-panel, + body.hub-page-ai .ai-layout[data-ai-mobile-tab="general"] .ai-chat-history-panel, + body.hub-page-ai .ai-layout[data-ai-mobile-tab="supervisor"] .ai-chat-history-panel { + display: none !important; + } + + body.hub-page-ai .ai-layout[data-ai-mobile-tab="trading"] .ai-chat-main, + body.hub-page-ai .ai-layout[data-ai-mobile-tab="general"] .ai-chat-main, + body.hub-page-ai .ai-layout[data-ai-mobile-tab="supervisor"] .ai-chat-main { + display: flex; + flex: 1 1 auto; + min-height: 0; + flex-direction: column; + } + + body.hub-page-ai .ai-layout[data-ai-mobile-tab="history"] .ai-chat-main, + body.hub-page-ai .ai-layout[data-ai-mobile-tab="history"] .ai-chat-topbar { + display: none !important; + } + + body.hub-page-ai .ai-layout[data-ai-mobile-tab="history"] .ai-chat-history-panel { + display: flex; + flex: 1 1 auto; + min-height: 0; + border-left: none; + width: 100%; + } + + body.hub-page-ai .ai-panel { + width: 100%; + max-width: 100%; + min-width: 0; + box-sizing: border-box; + padding: 8px 10px; + gap: 6px; + } + + body.hub-page-ai .ai-chat-panel { + padding-bottom: 0; + display: flex; + flex-direction: column; + overflow: hidden; + border: none; + background: transparent; + } + + body.hub-page-ai .ai-chat-topbar { + display: none; + } + + body.hub-page-ai .ai-bot-tab { + min-height: 34px; + padding: 5px 8px; + font-size: 0.76rem; + } + + body.hub-page-ai .ai-bot-tab.is-active { + box-shadow: none; + } + + body.hub-page-ai .ai-chat-new-btn { + min-height: 34px; + padding: 5px 10px; + font-size: 0.76rem; + } + + body.hub-page-ai .ai-chat-split { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + border: none; + border-radius: 0; + } + + body.hub-page-ai .ai-chat-main { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + } + + body.hub-page-ai .ai-chat-session-head { + display: none; + } + + body.hub-page-ai .ai-chat-messages { + flex: 1 1 auto; + min-height: 0; + max-height: none; + padding: 4px 2px 8px; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + } + + body.hub-page-ai .ai-layout[data-ai-mobile-tab="history"] .ai-chat-history-list { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + } + + body.hub-page-ai .ai-chat-form { + position: relative; + flex-shrink: 0; + z-index: 3; + width: 100%; + margin: 0; + padding: 8px 0 max(8px, env(safe-area-inset-bottom)); + background: var(--panel); + border-top: 1px solid var(--border-soft); + box-shadow: 0 -4px 14px rgba(0, 0, 0, 0.12); + } + + body.hub-page-ai .ai-chat-compose { + gap: 6px; + } + + body.hub-page-ai .ai-chat-form textarea { + min-height: 40px; + max-height: 96px; + font-size: 16px; + width: 100%; + padding: 8px 10px; + } + + body.hub-page-ai .ai-chat-compose-actions { + display: flex; + gap: 8px; + align-items: center; + width: 100%; + } + + body.hub-page-ai .ai-chat-pending-list { + width: 100%; + } + + body.hub-page-ai .ai-chat-upload-btn, + body.hub-page-ai #btn-ai-chat-send { + min-height: 40px; + flex-shrink: 0; + } + + body.hub-page-ai #btn-ai-chat-send { + min-width: 72px; + margin-left: auto; + font-weight: 600; + } + + body.hub-page-ai .ai-msg-row-user { + max-width: 90%; + } + + body.hub-page-ai .ai-msg-row-coach { + max-width: 100%; + } + + body.hub-page-ai .ai-bubble { + font-size: 0.86rem; + padding: 9px 11px; + } + + body.hub-page-ai .ai-msg-role { + font-size: 0.68rem; + } + + body.hub-page-ai .ai-chat-history-list { + padding: 6px 4px; + } + + body.hub-page-ai .ai-chat-history-item { + padding: 10px 12px; + } +} + +.ai-panel { + background: var(--panel); + border: 1px solid var(--border-soft); + border-radius: var(--radius); + padding: 12px 14px; + min-height: 0; + max-height: 100%; + height: 100%; + display: flex; + flex-direction: column; + gap: 10px; + overflow: hidden; +} +.ai-panel-head { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 8px; + flex-shrink: 0; +} +.ai-panel-head h2 { + margin: 0; + font-size: 1rem; + font-family: var(--display); + letter-spacing: 0.04em; +} +.ai-panel-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 8px; + max-width: 100%; +} +.ai-meta-line { + max-width: min(420px, 100%); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.72rem; +} +.ai-panel-scroll { + flex: 1 1 auto; + min-height: 0; + max-height: 100%; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} +.ai-panel-scroll::-webkit-scrollbar { + width: 6px; +} +.ai-panel-scroll::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--muted) 45%, transparent); + border-radius: 999px; +} +.ai-stats-row { + display: flex; + flex-wrap: wrap; + gap: 8px 14px; + font-size: 0.82rem; + color: var(--muted); + flex-shrink: 0; +} +.ai-stat-chip { + padding: 4px 8px; + border-radius: 6px; + background: var(--inset-surface); + border: 1px solid var(--border-soft); +} +.ai-stat-chip strong { + color: var(--text); + margin-right: 4px; +} +.ai-stat-chip.pos, +.ai-stat-val.pos { + color: var(--green); + border-color: color-mix(in srgb, var(--green) 35%, transparent); +} +.ai-stat-chip.neg, +.ai-stat-val.neg { + color: var(--red); + border-color: color-mix(in srgb, var(--red) 35%, transparent); +} +.ai-stat-chip.pos strong, +.ai-stat-chip.neg strong { + color: inherit; + opacity: 0.85; +} +.ai-md-body { + padding: 12px; + border-radius: 8px; + background: var(--inset-surface); + border: 1px solid var(--border-soft); + font-size: 0.86rem; + line-height: 1.55; + color: var(--text); + overflow-wrap: anywhere; + word-break: break-word; +} +.ai-md-body.ai-result-md, +.ai-bubble-assistant.ai-result-md { + white-space: normal; +} +.ai-result-md p { + margin: 6px 0; + color: var(--text); +} +.ai-result-md ul, +.ai-result-md ol { + margin: 6px 0 8px 1.25em; + padding: 0 0 0 0.25em; + list-style-position: outside; +} +.ai-result-md ul { + list-style-type: disc; +} +.ai-result-md ol { + list-style-type: decimal; +} +.ai-result-md li { + margin: 5px 0; + line-height: 1.5; + display: list-item; +} +.ai-result-md strong { + color: var(--text); + font-weight: 600; +} +.ai-md-body.ai-result-md h2 { + font-size: 1.02rem; + color: var(--ai-sum-heading); + font-weight: 700; + margin: 14px 0 8px; + padding: 6px 0 6px 10px; + border-left: 3px solid var(--ai-sum-heading-border); + border-bottom: 1px solid var(--border-soft); + background: var(--ai-sum-heading-bg); + border-radius: 0 4px 4px 0; +} +.ai-md-body.ai-result-md h2:first-child { + margin-top: 0; +} +.ai-md-body.ai-result-md h3 { + font-size: 0.92rem; + color: var(--ai-sum-heading); + font-weight: 700; + margin: 16px 0 8px; + padding: 5px 0 5px 10px; + border-left: 3px solid var(--ai-sum-heading-border); + border-bottom: 1px solid var(--border-soft); + background: var(--ai-sum-heading-bg); + border-radius: 0 4px 4px 0; +} +.ai-md-body.ai-result-md h3:first-of-type { + margin-top: 4px; +} +.ai-md-body.ai-result-md h4 { + font-size: 0.92rem; + color: var(--ai-sum-heading); + font-weight: 700; + margin: 10px 0 6px; + padding: 4px 0 4px 8px; + border-left: 2px solid var(--ai-sum-heading-border); + background: var(--ai-sum-heading-bg); + border-radius: 0 4px 4px 0; +} +.ai-result-md h2 { + font-size: 1.02rem; + color: var(--accent-2, var(--accent)); + margin: 14px 0 8px; + padding-bottom: 4px; + border-bottom: 1px solid var(--border-soft); +} +.ai-result-md h3, +.ai-result-md h4 { + font-size: 0.92rem; + color: var(--accent-2, var(--accent)); + margin: 10px 0 6px; +} +.ai-result-md code { + background: color-mix(in srgb, var(--inset-surface) 70%, var(--border-soft)); + padding: 1px 4px; + border-radius: 4px; + font-size: 0.82em; +} +.ai-result-md .md-raw-block-title { + margin-top: 14px; + padding-top: 10px; + border-top: 1px dashed var(--border-soft); + color: var(--muted); + font-weight: 600; +} +.ai-bubble-assistant.ai-result-md p { + margin: 4px 0; +} +.ai-bubble-assistant.ai-result-md h2, +.ai-bubble-assistant.ai-result-md h3, +.ai-bubble-assistant.ai-result-md h4 { + margin: 8px 0 4px; + font-size: 0.92rem; + color: var(--accent); + border-bottom: none; + padding-bottom: 0; +} +.ai-bubble-assistant.ai-result-md strong { + color: var(--accent); +} +.ai-bubble-assistant.ai-result-md ul, +.ai-bubble-assistant.ai-result-md ol { + margin: 4px 0 6px 1.15em; + padding-left: 0.25em; + list-style-position: outside; +} +.ai-bubble-assistant.ai-result-md ul { + list-style-type: disc; +} +.ai-bubble-assistant.ai-result-md ol { + list-style-type: decimal; +} +.ai-bubble-assistant.ai-result-md li { + display: list-item; +} +.ai-ac-table-wrap { + margin: 8px 0 12px; + overflow-x: auto; + border: 1px solid var(--border-soft); + border-radius: 8px; + background: color-mix(in srgb, var(--inset-surface) 88%, transparent); +} +.ai-ac-table { + width: 100%; + border-collapse: collapse; + font-size: 0.78rem; + line-height: 1.45; +} +.ai-ac-table th, +.ai-ac-table td { + padding: 8px 10px; + text-align: left; + vertical-align: top; + border-bottom: 1px solid var(--border-soft); +} +.ai-ac-table th { + font-size: 0.72rem; + font-weight: 600; + color: var(--muted); + background: color-mix(in srgb, var(--inset-surface) 60%, transparent); + white-space: nowrap; +} +.ai-ac-table tbody tr:last-child td { + border-bottom: none; +} +.ai-ac-table tbody tr:hover td { + background: color-mix(in srgb, var(--accent-dim) 35%, transparent); +} +.ai-ac-name { + min-width: 9rem; + font-weight: 600; + color: var(--ai-sum-name); +} +.ai-ac-remark { + color: var(--muted); + font-size: 0.74rem; + max-width: 16rem; +} +.ai-ac-unmon { + color: var(--muted); +} +.ai-ac-err { + color: var(--red); +} +.ai-ac-warn { + color: var(--amber, #d4a017); +} +.ai-ac-table .ai-stat-val.pos { + color: var(--green); + font-weight: 600; +} +.ai-ac-table .ai-stat-val.neg { + color: var(--red); + font-weight: 600; +} +.ai-placeholder { + color: var(--muted); + margin: 0; +} +.ai-chat-panel { + gap: 8px; +} +.ai-chat-panel .ai-chat-split { + flex: 1 1 auto; +} +.ai-chat-topbar { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} +.ai-chat-topbar .ai-bot-bar { + flex: 1 1 auto; + min-width: 0; +} +.ai-chat-new-btn { + flex-shrink: 0; + white-space: nowrap; +} +.ai-chat-session-head { + padding-bottom: 2px; +} +.ai-chat-session-head h2 { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ai-bot-bar { + display: flex; + gap: 8px; + flex-shrink: 0; + padding-bottom: 0; +} +.ai-bot-tab { + flex: 1; + min-height: 36px; + padding: 6px 12px; + border-radius: 8px; + border: 1px solid var(--border-soft); + background: var(--inset-surface); + color: var(--muted); + font-family: var(--font); + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: border-color 0.15s, color 0.15s, background 0.15s; +} +.ai-bot-tab:hover { + border-color: var(--accent); + color: var(--text); +} +.ai-bot-tab.is-active { + color: var(--text); + border-color: var(--accent); + background: var(--accent-dim); + box-shadow: var(--glow); +} +.ai-chat-split { + flex: 1 1 auto; + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(360px, 440px); + gap: 0; + overflow: hidden; + border: 1px solid var(--border-soft); + border-radius: 8px; +} +.ai-chat-main { + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; + overflow: hidden; +} +.ai-chat-history-panel { + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; + border-left: 1px solid var(--border-soft); + background: color-mix(in srgb, var(--inset-surface) 65%, var(--panel)); +} +.ai-chat-history-head { + flex-shrink: 0; + padding: 10px 12px 6px; + border-bottom: 1px solid var(--border-soft); +} +.ai-chat-history-head h3 { + margin: 0; + font-size: 0.82rem; + font-weight: 700; + color: var(--muted); + letter-spacing: 0.04em; +} +.ai-chat-history-list { + flex: 1 1 auto; + min-height: 0; + padding: 8px; + display: flex; + flex-direction: column; + gap: 6px; +} +.ai-chat-history-item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 4px 8px; + align-items: start; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--border-soft); + background: var(--panel); + cursor: pointer; + text-align: left; + transition: border-color 0.15s, background 0.15s; +} +.ai-chat-history-item:hover { + border-color: var(--accent); +} +.ai-chat-history-item.is-active { + border-color: var(--accent); + background: var(--accent-dim); + box-shadow: var(--glow); +} +.ai-chat-history-item-main { + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; +} +.ai-chat-history-item-title { + font-size: 0.8rem; + font-weight: 600; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ai-chat-history-item-preview { + font-size: 0.72rem; + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ai-chat-history-item-meta { + font-size: 0.68rem; + color: var(--muted); + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} +.ai-chat-history-badge { + display: inline-flex; + padding: 1px 6px; + border-radius: 999px; + font-size: 0.62rem; + font-weight: 600; + border: 1px solid var(--border-soft); + color: var(--muted); +} +.ai-chat-history-badge.trading { + color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 40%, var(--border-soft)); +} +.ai-chat-history-badge.supervisor { + color: #c27803; + border-color: color-mix(in srgb, #c27803 45%, var(--border-soft)); +} +.ai-msg-row-system { + justify-content: flex-start; +} +.ai-bubble-system { + background: color-mix(in srgb, var(--surface-2) 88%, #c27803 12%); + border: 1px solid color-mix(in srgb, var(--border-soft) 70%, #c27803 30%); + font-size: 0.92rem; + white-space: pre-wrap; +} +.ai-bubble-warn { + border-color: color-mix(in srgb, var(--danger) 45%, var(--border-soft)); +} +.ai-chat-history-panel.hidden { + display: none !important; +} +.ai-chat-new-btn.hidden { + display: none !important; +} +.supervisor-settings-grid { + margin-top: 0.75rem; + padding-top: 0.25rem; +} +.ai-chat-history-del { + min-width: 28px; + min-height: 28px; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + color: var(--muted); + font-size: 1rem; + line-height: 1; + cursor: pointer; +} +.ai-chat-history-del:hover { + color: var(--red); + background: color-mix(in srgb, var(--red) 12%, transparent); +} +.ai-msg-actions { + display: flex; + gap: 6px; + padding: 0 4px; +} +.ai-msg-copy-btn { + min-height: 24px; + padding: 2px 8px; + border-radius: 6px; + border: 1px solid var(--border-soft); + background: var(--panel); + color: var(--muted); + font-size: 0.68rem; + font-weight: 600; + cursor: pointer; +} +.ai-msg-copy-btn:hover { + border-color: var(--accent); + color: var(--accent); +} +.ai-chat-messages { + display: flex; + flex-direction: column; + gap: 12px; + padding: 8px 4px 4px; +} +.ai-msg-row { + display: flex; + flex-direction: column; + gap: 4px; + max-width: 100%; +} +.ai-msg-row-user { + align-self: flex-end; + align-items: flex-end; + max-width: 88%; +} +.ai-msg-row-coach { + align-self: flex-start; + align-items: flex-start; + max-width: 92%; +} +.ai-msg-role { + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.04em; + color: var(--muted); + padding: 0 4px; +} +.ai-msg-row-user .ai-msg-role { + color: var(--accent); +} +.ai-msg-row-coach .ai-msg-role { + color: var(--accent); +} +.ai-bubble { + width: 100%; + padding: 10px 12px; + border-radius: 10px; + font-size: 0.88rem; + line-height: 1.5; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; +} +.ai-bubble-user { + background: var(--accent-dim); + border: 1px solid var(--border); +} +.ai-bubble-assistant { + background: var(--inset-surface); + border: 1px solid var(--border-soft); +} +.ai-bubble-thinking { + color: var(--muted); + font-style: italic; + animation: ai-think-pulse 1.2s ease-in-out infinite; +} +.ai-bubble-error { + border-color: color-mix(in srgb, var(--red) 55%, var(--border-soft)); + color: var(--red); +} +@keyframes ai-think-pulse { + 0%, + 100% { + opacity: 0.55; + } + 50% { + opacity: 1; + } +} +.ai-closed-trades-wrap { + margin: 0 0 12px; +} +.ai-closed-trades-title { + margin: 0 0 6px; + font-size: 0.82rem; + font-weight: 700; + color: var(--ai-sum-heading); + padding: 4px 0 4px 8px; + border-left: 2px solid var(--ai-sum-heading-border); + background: var(--ai-sum-heading-bg); + border-radius: 0 4px 4px 0; +} +.ai-msg-attachments { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 0 4px; +} +.ai-attach-chip { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.72rem; + color: var(--muted); + background: var(--inset-surface); + border: 1px solid var(--border-soft); +} +.ai-chat-form { + flex-shrink: 0; + padding-top: 4px; + border-top: 1px solid var(--border-soft); +} +.ai-chat-compose { + display: flex; + flex-direction: column; + gap: 8px; +} +.ai-chat-compose-actions { + display: flex; + align-items: center; + gap: 8px; + justify-content: flex-end; +} +.ai-chat-upload-btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 36px; + padding: 0 12px; + border-radius: 8px; + border: 1px solid var(--border-soft); + background: var(--inset-surface); + color: var(--text); + font-size: 0.82rem; + cursor: pointer; +} +.ai-chat-upload-btn:hover { + border-color: var(--accent); + color: var(--accent); +} +.ai-chat-pending-list { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.ai-chat-pending-list[hidden] { + display: none; +} +.ai-chat-pending-chip { + display: inline-flex; + align-items: center; + gap: 4px; + max-width: 100%; + padding: 2px 4px 2px 8px; + border-radius: 999px; + font-size: 0.72rem; + color: var(--text); + background: var(--inset-surface); + border: 1px solid var(--border-soft); +} +.ai-chat-pending-kind { + flex-shrink: 0; + font-size: 0.65rem; + color: var(--muted); +} +.ai-chat-pending-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ai-chat-pending-del { + flex-shrink: 0; + min-width: 22px; + min-height: 22px; + padding: 0; + border: none; + border-radius: 999px; + background: transparent; + color: var(--muted); + font-size: 0.95rem; + line-height: 1; + cursor: pointer; +} +.ai-chat-pending-del:hover { + color: var(--red); + background: color-mix(in srgb, var(--red) 12%, transparent); +} +.ai-chat-pending-del:disabled { + opacity: 0.45; + cursor: not-allowed; +} +.ai-chat-form textarea { + width: 100%; + resize: none; + min-height: 52px; + max-height: 88px; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--border-soft); + background: var(--inset-surface); + color: var(--text); + font-family: var(--font); + font-size: 0.88rem; +} +.ai-chat-form textarea:focus { + outline: none; + border-color: var(--accent); +} +.ai-chat-form textarea:disabled { + opacity: 0.65; +} + +/* —— 资金概况(科技感 HUD)—— */ +body.hub-page-funds .app-bg { + background: + linear-gradient(rgba(0, 212, 255, 0.045) 1px, transparent 1px), + linear-gradient(90deg, rgba(0, 212, 255, 0.045) 1px, transparent 1px), + radial-gradient(ellipse 70% 45% at 12% 0%, rgba(0, 212, 255, 0.16), transparent 58%), + radial-gradient(ellipse 55% 40% at 92% 18%, rgba(123, 97, 255, 0.14), transparent 55%), + radial-gradient(ellipse 50% 35% at 50% 100%, rgba(0, 255, 157, 0.06), transparent 60%); + background-size: 28px 28px, 28px 28px, auto, auto, auto; +} +html[data-theme="light"] body.hub-page-funds .app-bg { + background: + linear-gradient(rgba(0, 110, 154, 0.06) 1px, transparent 1px), + linear-gradient(90deg, rgba(0, 110, 154, 0.06) 1px, transparent 1px), + radial-gradient(ellipse 70% 45% at 12% 0%, rgba(0, 110, 154, 0.1), transparent 58%), + radial-gradient(ellipse 55% 40% at 92% 18%, rgba(91, 79, 199, 0.08), transparent 55%); + background-size: 28px 28px, 28px 28px, auto, auto; +} +body.hub-page-funds #page-funds { + position: relative; +} +.funds-stage { + position: relative; + border-radius: calc(var(--radius) + 4px); + border: 1px solid var(--border-soft); + background: linear-gradient(165deg, rgba(12, 20, 32, 0.72), rgba(8, 14, 26, 0.88)); + box-shadow: var(--glow), var(--shadow); + overflow: hidden; +} +html[data-theme="light"] .funds-stage { + background: linear-gradient(165deg, rgba(255, 255, 255, 0.92), rgba(240, 246, 252, 0.96)); + box-shadow: var(--shadow); +} +.funds-stage-grid, +.funds-stage-glow { + position: absolute; + inset: 0; + pointer-events: none; +} +.funds-stage-grid { + opacity: 0.35; + background: + linear-gradient(rgba(0, 212, 255, 0.07) 1px, transparent 1px), + linear-gradient(90deg, rgba(0, 212, 255, 0.07) 1px, transparent 1px); + background-size: 24px 24px; + mask-image: linear-gradient(180deg, black 0%, transparent 92%); +} +.funds-stage-glow { + background: + radial-gradient(circle at 18% 12%, rgba(0, 212, 255, 0.12), transparent 42%), + radial-gradient(circle at 82% 8%, rgba(123, 97, 255, 0.1), transparent 38%); +} +.funds-stage-inner { + position: relative; + z-index: 1; + padding: 14px 16px 18px; +} +.funds-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + margin-bottom: 12px !important; +} +.funds-head h1 { + font-family: var(--display); + letter-spacing: 0.06em; +} +.funds-tag { + background: linear-gradient(135deg, rgba(0, 212, 255, 0.22), rgba(123, 97, 255, 0.18)); + border-color: rgba(0, 212, 255, 0.45); + box-shadow: 0 0 18px rgba(0, 212, 255, 0.2); +} +.funds-desc-fold { + margin: 4px 0 0; + max-width: 100%; +} +.funds-desc-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.72rem; + color: var(--accent); + cursor: pointer; + list-style: none; + user-select: none; + letter-spacing: 0.04em; +} +.funds-desc-toggle::-webkit-details-marker { + display: none; +} +.funds-desc-toggle::before { + content: "▸"; + font-size: 0.68rem; + transition: transform 0.15s ease; +} +.funds-desc-fold[open] .funds-desc-toggle::before { + transform: rotate(90deg); +} +.funds-desc { + margin: 8px 0 0; + color: color-mix(in srgb, var(--muted) 88%, var(--accent)); + letter-spacing: 0.02em; + line-height: 1.45; + font-size: 0.78rem; +} +.funds-live-pill { + flex-shrink: 0; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + border-radius: 999px; + border: 1px solid rgba(0, 212, 255, 0.35); + background: rgba(0, 212, 255, 0.08); + font-family: var(--display); + font-size: 0.62rem; + letter-spacing: 0.14em; + color: var(--accent); +} +.funds-live-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--green); + box-shadow: 0 0 10px var(--green); + animation: funds-pulse 2s ease-in-out infinite; +} +@keyframes funds-pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.55; transform: scale(0.88); } +} +.funds-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 12px; + margin-bottom: 14px; +} +.funds-btn-refresh { + font-family: var(--display); + letter-spacing: 0.06em; + font-size: 0.72rem; +} +.funds-pnl-banner { + flex: 1 1 240px; + min-width: 0; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px 16px; + padding: 10px 14px; + border-radius: var(--radius); + border: 1px solid var(--border-soft); + background: rgba(0, 0, 0, 0.28); +} +html[data-theme="light"] .funds-pnl-banner { + background: rgba(255, 255, 255, 0.82); +} +.funds-pnl-banner.is-pos { + border-color: color-mix(in srgb, var(--green) 45%, var(--border-soft)); + background: color-mix(in srgb, var(--green) 10%, rgba(0, 0, 0, 0.28)); + box-shadow: inset 0 0 28px color-mix(in srgb, var(--green) 8%, transparent); +} +.funds-pnl-banner.is-neg { + border-color: color-mix(in srgb, var(--red) 45%, var(--border-soft)); + background: color-mix(in srgb, var(--red) 10%, rgba(0, 0, 0, 0.28)); + box-shadow: inset 0 0 28px color-mix(in srgb, var(--red) 8%, transparent); +} +html[data-theme="light"] .funds-pnl-banner.is-pos { + background: color-mix(in srgb, var(--green) 8%, rgba(255, 255, 255, 0.9)); +} +html[data-theme="light"] .funds-pnl-banner.is-neg { + background: color-mix(in srgb, var(--red) 8%, rgba(255, 255, 255, 0.9)); +} +.funds-pnl-main { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 8px 12px; + min-width: 0; +} +.funds-pnl-label { + font-family: var(--display); + font-size: 0.62rem; + letter-spacing: 0.12em; + color: var(--muted); + text-transform: uppercase; +} +.funds-pnl-value { + font-family: var(--font); + font-variant-numeric: tabular-nums; + font-size: 1.35rem; + font-weight: 700; + letter-spacing: 0.01em; + line-height: 1.1; +} +.funds-pnl-pct { + font-family: var(--mono); + font-size: 0.85rem; + font-weight: 600; + opacity: 0.92; +} +.funds-pnl-value.pos, +.funds-pnl-pct.pos, +.funds-pnl-side-val.pos { + color: var(--green); +} +.funds-pnl-value.neg, +.funds-pnl-pct.neg, +.funds-pnl-side-val.neg { + color: var(--red); +} +.funds-pnl-side { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; + padding-left: 12px; + border-left: 1px solid var(--border-soft); +} +.funds-pnl-side-label { + font-family: var(--display); + font-size: 0.58rem; + letter-spacing: 0.1em; + color: var(--muted); + text-transform: uppercase; +} +.funds-pnl-side-val { + font-family: var(--font); + font-variant-numeric: tabular-nums; + font-size: 0.95rem; + font-weight: 600; +} +.funds-status.err { + color: var(--red); +} +.funds-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 12px; + margin-bottom: 12px; +} +.funds-stat-card-pnl .funds-stat-val { + font-size: 1.35rem; +} +.funds-stat-sub { + margin-top: 4px; + font-size: 0.72rem; + font-family: var(--mono); + color: var(--muted); +} +.funds-stat-card { + position: relative; + background: rgba(0, 0, 0, 0.28); + border: 1px solid var(--border-soft); + border-radius: var(--radius); + padding: 14px 16px; + overflow: hidden; +} +html[data-theme="light"] .funds-stat-card { + background: rgba(255, 255, 255, 0.82); +} +.funds-stat-card::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, transparent, var(--accent), var(--accent-2), transparent); + opacity: 0.75; +} +.funds-stat-card-primary { + border-color: rgba(0, 212, 255, 0.32); + box-shadow: inset 0 0 24px rgba(0, 212, 255, 0.06); +} +.funds-stat-card-primary .funds-stat-value { + font-size: 1.6rem; +} +.funds-stat-label { + font-family: var(--display); + font-size: 0.62rem; + letter-spacing: 0.12em; + color: var(--muted); + margin-bottom: 6px; + text-transform: uppercase; +} +.funds-stat-value, +.funds-stat-val, +.funds-ac-total .v, +.funds-ac-stats .v, +.funds-fs-stat .v { + font-family: var(--font); + font-variant-numeric: tabular-nums; + letter-spacing: 0.01em; +} +.funds-stat-value { + font-size: 1.35rem; + font-weight: 600; +} +.funds-stat-val { + font-size: 1.15rem; + font-weight: 600; +} +.funds-stat-val.pos { + color: var(--green); +} +.funds-stat-val.neg { + color: var(--red); +} +.funds-dd-pct { + font-size: 0.82rem; + color: var(--muted); + font-weight: 500; +} +.funds-meta { + font-size: 0.72rem; + font-family: var(--mono); + color: color-mix(in srgb, var(--muted) 90%, var(--accent)); + margin: 0 0 14px; + padding: 8px 12px; + border-radius: 8px; + border: 1px dashed var(--border-soft); + background: rgba(0, 0, 0, 0.2); + letter-spacing: 0.03em; +} +html[data-theme="light"] .funds-meta { + background: rgba(255, 255, 255, 0.65); +} +.funds-chart-panel { + margin-bottom: 20px; + border: 1px solid rgba(0, 212, 255, 0.22); + border-radius: calc(var(--radius) + 2px); + background: rgba(0, 0, 0, 0.22); + box-shadow: inset 0 0 32px rgba(0, 212, 255, 0.04), 0 0 24px rgba(0, 212, 255, 0.06); + overflow: hidden; +} +html[data-theme="light"] .funds-chart-panel { + background: rgba(255, 255, 255, 0.7); + box-shadow: inset 0 0 20px rgba(0, 110, 154, 0.04); +} +.funds-chart-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 12px; + border-bottom: 1px solid var(--border-soft); + background: linear-gradient(90deg, rgba(0, 212, 255, 0.08), transparent); +} +.funds-chart-tag { + font-family: var(--display); + font-size: 0.68rem; + letter-spacing: 0.16em; + color: var(--accent); +} +.funds-chart-sub { + font-size: 0.62rem; + letter-spacing: 0.1em; + color: var(--muted); +} +.funds-chart-host { + height: 300px; + min-height: 240px; + background: var(--chart-surface, var(--panel)); + overflow: hidden; +} +.funds-section-head { + margin-bottom: 12px; +} +.funds-section-title { + margin: 0 0 4px; + font-family: var(--display); + font-size: 0.88rem; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; +} +.funds-section-mark { + color: var(--accent-2); + margin-right: 6px; +} +.funds-section-hint { + margin: 0; + font-size: 0.72rem; + color: var(--muted); + letter-spacing: 0.02em; +} +.funds-accounts { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); + gap: 12px; + padding: 4px 0 12px; +} +.funds-ac-card { + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; + padding: 14px 16px; + border: 1px solid var(--border-soft); + border-radius: var(--radius); + background: linear-gradient(160deg, rgba(0, 0, 0, 0.34), rgba(12, 20, 32, 0.55)); + text-align: left; + cursor: pointer; + transition: border-color 0.15s, box-shadow 0.15s, transform 0.12s; + position: relative; + overflow: hidden; +} +html[data-theme="light"] .funds-ac-card { + background: linear-gradient(160deg, rgba(255, 255, 255, 0.95), rgba(236, 244, 252, 0.9)); +} +.funds-ac-card::before { + content: ""; + position: absolute; + inset: 0 auto auto 0; + width: 3px; + height: 100%; + background: linear-gradient(180deg, var(--accent), var(--accent-2)); + opacity: 0.55; +} +.funds-ac-card:hover:not(:disabled) { + border-color: rgba(0, 212, 255, 0.45); + box-shadow: 0 0 22px rgba(0, 212, 255, 0.12), 0 0 0 1px var(--accent-dim); + transform: translateY(-2px); +} +.funds-ac-card:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.funds-ac-card.is-off { + opacity: 0.68; + cursor: default; +} +.funds-ac-card.is-off:hover { + transform: none; + box-shadow: none; + border-color: var(--border-soft); +} +.funds-ac-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +} +.funds-ac-name { + margin: 0; + font-family: var(--font); + font-size: 0.84rem; + font-weight: 600; + letter-spacing: 0.01em; + line-height: 1.35; + flex: 1; + min-width: 0; + word-break: break-all; +} +.funds-ac-badge { + flex-shrink: 0; + font-size: 0.66rem; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid var(--border-soft); + color: var(--muted); + background: var(--inset-surface); + white-space: nowrap; +} +.funds-ac-badge.is-ok { + color: var(--green); + border-color: rgba(0, 255, 157, 0.28); + background: rgba(0, 255, 157, 0.08); +} +html[data-theme="light"] .funds-ac-badge.is-ok { + border-color: rgba(10, 143, 92, 0.28); + background: rgba(10, 143, 92, 0.08); +} +.funds-ac-total { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + padding: 8px 10px; + border-radius: 8px; + background: var(--inset-surface); + border: 1px solid var(--border-soft); +} +.funds-ac-total .k { + font-size: 0.72rem; + color: var(--muted); +} +.funds-ac-total .v { + font-size: 1.12rem; + font-weight: 700; + color: var(--text); +} +.funds-ac-stats { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px 12px; + font-size: 0.78rem; +} +.funds-ac-stats .k { + display: block; + color: var(--muted); + font-size: 0.68rem; + margin-bottom: 2px; +} +.funds-ac-stats .v { + font-variant-numeric: tabular-nums; + font-weight: 500; +} +.funds-ac-stats .v.pos { + color: var(--green); +} +.funds-ac-stats .v.neg { + color: var(--red); +} +.funds-ac-foot { + margin-top: 2px; + padding-top: 10px; + border-top: 1px dashed var(--border-soft); + font-size: 0.72rem; + color: var(--muted); + text-align: center; +} +.funds-empty { + color: var(--muted); + font-size: 0.85rem; + padding: 12px 0; +} + +.funds-fullscreen { + position: fixed; + inset: 0; + z-index: 160; + background: var(--fs-scrim); + backdrop-filter: blur(6px); + overflow: auto; + padding: 16px 20px 24px; +} +.funds-fullscreen.hidden { + display: none !important; +} +.funds-fs-backdrop { + position: fixed; + inset: 0; + z-index: 0; + border: none; + padding: 0; + margin: 0; + background: transparent; + cursor: pointer; +} +.funds-fs-panel { + position: relative; + z-index: 1; + max-width: min(1200px, 96vw); + margin: 0 auto; + background: linear-gradient(165deg, rgba(12, 20, 32, 0.95), rgba(6, 10, 18, 0.98)); + border: 1px solid rgba(0, 212, 255, 0.28); + border-radius: calc(var(--radius) + 2px); + padding: 16px 18px 20px; + box-shadow: 0 0 40px rgba(0, 212, 255, 0.12), 0 12px 40px rgba(0, 0, 0, 0.35); +} +html[data-theme="light"] .funds-fs-panel { + background: linear-gradient(165deg, rgba(255, 255, 255, 0.98), rgba(240, 246, 252, 0.98)); + box-shadow: var(--shadow); +} +.funds-fs-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border-soft); +} +.funds-fs-title { + margin: 0; + font-family: var(--display); + font-size: 1.1rem; + font-weight: 600; + letter-spacing: 0.06em; +} +.funds-fs-sub { + margin: 4px 0 0; + font-size: 0.76rem; + color: var(--muted); +} +.funds-fs-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 10px; + margin-bottom: 14px; +} +.funds-fs-stat { + background: var(--inset-surface); + border: 1px solid var(--border-soft); + border-radius: 8px; + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 4px; +} +.funds-fs-stat .k { + font-size: 0.72rem; + color: var(--muted); +} +.funds-fs-stat .v { + font-size: 1rem; + font-weight: 600; + font-variant-numeric: tabular-nums; +} +.funds-fs-stat .v.pos { + color: var(--green); +} +.funds-fs-stat .v.neg { + color: var(--red); +} +.funds-fs-chart-host { + height: min(52vh, 420px); + min-height: 260px; + border: 1px solid var(--border-soft); + border-radius: var(--radius); + background: var(--chart-surface, var(--panel)); + overflow: hidden; +} +body.funds-fullscreen-open { + overflow: hidden; +} +@media (max-width: 720px) { + .funds-accounts { + grid-template-columns: minmax(0, 1fr); + } + .funds-head { + flex-direction: column; + align-items: stretch; + } + .funds-live-pill { + align-self: flex-start; + } + .funds-stage-inner { + padding: 12px 12px 14px; + } + .funds-chart-host { + height: 240px; + min-height: 200px; + } +} + +/* —— 内照明心 —— */ +.archive-toolbar { + flex-wrap: wrap; + gap: 10px 14px; + margin-bottom: 10px; +} +.archive-search-field input { + min-width: 160px; +} +#archive-btn-chart-toggle.is-active { + color: var(--accent); + border-color: var(--accent); + background: var(--accent-dim); +} +.archive-field { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.82rem; + color: var(--muted); +} +.archive-field select, +.archive-field input { + min-width: 120px; + padding: 6px 8px; + border-radius: 8px; + border: 1px solid var(--border-soft); + background: var(--inset-surface); + color: var(--text); + font-family: var(--font); +} +#page-archive .archive-toolbar { + margin-bottom: 12px; +} +.archive-layout { + display: flex; + flex-direction: column; + gap: 12px; + align-items: stretch; +} +.archive-content-tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.archive-content-tab { + border: 1px solid var(--border-soft); + background: transparent; + color: inherit; + padding: 7px 14px; + border-radius: 8px; + cursor: pointer; + font-family: var(--font); + font-size: 0.84rem; +} +.archive-content-tab.is-active { + background: rgba(59, 130, 246, 0.22); + border-color: rgba(59, 130, 246, 0.55); + color: var(--text); +} +.archive-quotes-panel, +.archive-main-panel, +.archive-viz-panel, +.archive-calendar-panel { + background: var(--panel); + border: 1px solid var(--border-soft); + border-radius: var(--radius); + min-width: 0; +} +.archive-tab-panel { + display: none; +} +.archive-tab-panel.is-active { + display: flex; + flex-direction: column; +} +.archive-quotes-panel.is-active { + gap: 10px; + padding: 12px; + overflow: visible; +} +.archive-viz-panel.is-active, +.archive-calendar-panel.is-active { + gap: 10px; + padding: 12px; + overflow: visible; +} +.archive-main-panel.is-active { + gap: 10px; + padding: 12px; + min-height: 0; +} +.archive-panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.archive-panel-head h2 { + margin: 0; + font-size: 0.95rem; +} +.archive-panel-meta { + font-size: 0.72rem; + color: var(--muted); +} +.archive-quote-form { + display: flex; + flex-direction: column; + gap: 8px; +} +.archive-quote-form input[type="date"], +.archive-quote-form textarea { + width: 100%; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--border-soft); + background: var(--inset-surface); + color: var(--text); + font-family: var(--font); + font-size: 0.82rem; + resize: vertical; +} +.archive-quote-form textarea { + min-height: 110px; +} +.archive-quote-day-trades { + border: 1px solid var(--border-soft); + border-radius: 8px; + background: var(--inset-surface); + padding: 10px; + display: flex; + flex-direction: column; + gap: 8px; +} +.archive-quote-day-trades-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.archive-quote-day-trades-head h3 { + margin: 0; + font-size: 0.84rem; + font-weight: 600; +} +.archive-quote-day-trades-body { + overflow: auto; + max-height: min(280px, 40vh); +} +.archive-quote-day-trades-table { + width: 100%; + border-collapse: collapse; + font-size: 0.78rem; +} +.archive-quote-day-trades-table th, +.archive-quote-day-trades-table td { + padding: 6px 8px; + border-bottom: 1px solid var(--border-soft); + text-align: left; + white-space: nowrap; +} +.archive-quote-day-trades-table th { + color: var(--muted); + font-weight: 500; +} +.archive-quotes-list { + flex: 0 1 auto; + max-height: min(420px, 50vh); + overflow: auto; + display: flex; + flex-direction: column; + gap: 8px; +} + +/* —— 语录博客流 —— */ +#page-quotes .quotes-toolbar { + margin-bottom: 14px; + gap: 10px; + align-items: center; +} +#page-quotes .quotes-link-archive { + text-decoration: none; + padding: 6px 12px; + border-radius: 8px; + border: 1px solid var(--border-soft); +} +.quotes-feed { + display: flex; + flex-direction: column; + gap: 18px; + max-width: 820px; +} +.quotes-empty { + margin: 0; + color: var(--muted); + font-size: 0.88rem; +} +.quotes-day-group { + display: flex; + flex-direction: column; + gap: 10px; +} +.quotes-day-head { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 8px 14px; + padding-bottom: 6px; + border-bottom: 1px solid var(--border-soft); +} +.quotes-day-title { + margin: 0; + font-size: 1.05rem; + font-weight: 650; + letter-spacing: 0.02em; +} +.quotes-day-summary { + font-size: 0.8rem; + color: var(--muted); +} +.quotes-day-cards { + display: flex; + flex-direction: column; + gap: 10px; +} +.quotes-card { + background: var(--panel); + border: 1px solid var(--border-soft); + border-radius: var(--radius); + padding: 14px 16px; + display: flex; + flex-direction: column; + gap: 10px; +} +.quotes-card-body { + margin: 0; + font-size: 0.9rem; + line-height: 1.65; + color: var(--text); + white-space: pre-wrap; + word-break: break-word; +} +.quotes-card-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} +.quotes-ai-btn { + color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 35%, var(--border-soft)); +} +@media (max-width: 720px) { + .quotes-feed { + max-width: none; + } + .quotes-day-head { + flex-direction: column; + align-items: flex-start; + } +} +.archive-quote-block { + display: flex; + flex-direction: column; + gap: 0; + border: 1px solid var(--border-soft); + border-radius: 8px; + background: var(--inset-surface); + overflow: hidden; +} +.archive-quote-block.is-open { + border-color: var(--accent); +} +.archive-quote-item { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 8px; + align-items: center; + width: 100%; + padding: 8px 10px; + border: 0; + border-radius: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} +.archive-quote-item:hover { + background: color-mix(in srgb, var(--accent) 8%, transparent); +} +.archive-quote-item.is-selected { + background: color-mix(in srgb, var(--accent) 12%, var(--inset-surface)); +} +.archive-quote-open-hint { + font-size: 0.7rem; + color: var(--accent); + white-space: nowrap; +} +.archive-quote-detail { + display: flex; + flex-direction: column; + gap: 8px; + padding: 0 10px 10px; + border-top: 1px solid var(--border-soft); +} +.archive-quote-detail .archive-quote-full { + min-height: 120px; + max-height: none; + overflow: visible; +} +.archive-quote-date { + font-weight: 600; + font-size: 0.78rem; + color: var(--accent); + white-space: nowrap; +} +.archive-quote-preview { + font-size: 0.74rem; + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.archive-quote-full { + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--border-soft); + background: var(--panel); + color: var(--text); + font-size: 0.82rem; + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; + max-height: none; +} +.archive-quote-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.archive-quote-ai-btn { + color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 35%, var(--border-soft)); +} +.archive-period-bar { + flex-wrap: wrap; +} +.archive-period-tabs { + display: inline-flex; + gap: 4px; +} +.archive-period-btn { + padding: 5px 10px; + border-radius: 8px; + border: 1px solid var(--border-soft); + background: var(--inset-surface); + color: var(--muted); + cursor: pointer; + font-family: var(--font); + font-size: 0.8rem; +} +.archive-period-btn.is-active { + color: var(--text); + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, transparent); +} +.archive-period-range { + display: inline-flex; + align-items: center; + gap: 4px; +} +.archive-period-range.hidden, +.archive-period-day-input.hidden { + display: none; +} +.archive-period-sep { + color: var(--muted); + font-size: 0.82rem; +} +.archive-stats-card { + margin-bottom: 14px; + padding: 12px; + background: var(--panel); + border: 1px solid var(--border-soft); + border-radius: var(--radius); +} +.archive-stats-card-head h2 { + margin: 0 0 10px; + font-size: 0.95rem; +} +.archive-stats-card .archive-stats-bar { + border: none; + background: transparent; + overflow: auto; +} +.archive-overview-panel { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} +.archive-overview-panel > .archive-panel-head { + display: none; +} +.archive-stats-bar { + padding: 0; + border-radius: 8px; + border: 1px solid var(--border-soft); + background: var(--inset-surface); + font-size: 0.82rem; + color: var(--text); + line-height: 1.45; + overflow: auto; +} +.archive-stats-table { + width: 100%; + border-collapse: collapse; + font-size: 0.8rem; +} +.archive-stats-table th, +.archive-stats-table td { + padding: 7px 10px; + border-bottom: 1px solid var(--border-soft); + text-align: left; + white-space: nowrap; +} +.archive-stats-table th { + color: var(--muted); + font-weight: 500; + background: var(--inset-surface); +} +.archive-stats-table tr:last-child td { + border-bottom: none; +} +.archive-stats-table tr.archive-stats-total td { + background: color-mix(in srgb, var(--accent) 6%, transparent); +} +.archive-stats-table .pnl-neg { + color: var(--loss); +} +.archive-stats-viz-section { + margin-top: 10px; +} +.archive-stats-charts { + display: flex; + flex-direction: column; + gap: 12px; + padding-top: 4px; +} +.archive-viz-kpis { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} +.archive-viz-kpi { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + padding: 10px 8px; + border-radius: 10px; + background: var(--archive-viz-kpi-bg); + border: 1px solid var(--archive-viz-kpi-border); + min-width: 0; +} +.archive-viz-kpi-val { + font-size: 1.05rem; + font-weight: 700; + font-variant-numeric: tabular-nums; + line-height: 1.1; +} +.archive-viz-kpi--pnl .archive-viz-kpi-val { + font-size: 1.2rem; +} +.archive-viz-kpi-lbl { + font-size: 0.66rem; + color: var(--muted); + text-align: center; +} +.archive-viz-kpi--win .archive-viz-ring { + width: 52px; + height: 52px; +} +.archive-viz-kpi--win .archive-viz-ring::before { + inset: 6px; +} +.archive-viz-ring { + --win-pct: 0; + width: 68px; + height: 68px; + border-radius: 50%; + background: conic-gradient( + var(--archive-profit) 0 calc(var(--win-pct) * 1%), + var(--archive-loss) calc(var(--win-pct) * 1%) 100% + ); + display: flex; + align-items: center; + justify-content: center; + position: relative; +} +.archive-viz-ring::before { + content: ""; + position: absolute; + inset: 8px; + border-radius: 50%; + background: var(--inset-surface); +} +.archive-viz-ring-label { + position: relative; + z-index: 1; + font-size: 0.82rem; + font-weight: 700; + font-variant-numeric: tabular-nums; +} +.archive-viz-block-title { + font-size: 0.68rem; + color: var(--muted); + margin-bottom: 6px; + font-weight: 600; +} +.archive-viz-block { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + padding: 10px; + border-radius: 10px; + background: var(--archive-viz-surface); + border: 1px solid var(--archive-viz-surface-border); +} +.archive-viz-stacked { + display: flex; + height: 22px; + border-radius: 6px; + overflow: hidden; + background: var(--archive-viz-track-bg); +} +.archive-viz-stacked-seg { + display: flex; + align-items: center; + justify-content: center; + min-width: 2px; + font-size: 0.62rem; + font-weight: 700; + color: rgba(255, 255, 255, 0.92); + white-space: nowrap; + overflow: hidden; +} +.archive-viz-stacked-seg--profit { + background: linear-gradient(90deg, color-mix(in srgb, var(--archive-profit) 72%, #000), var(--archive-profit)); +} +.archive-viz-stacked-seg--loss { + background: linear-gradient(90deg, color-mix(in srgb, var(--archive-loss) 72%, #000), var(--archive-loss)); +} +.archive-viz-stacked-meta { + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + font-size: 0.68rem; +} +.archive-viz-legend::before { + content: ""; + display: inline-block; + width: 8px; + height: 8px; + border-radius: 2px; + margin-right: 4px; + vertical-align: middle; +} +.archive-viz-legend--profit::before { + background: var(--archive-profit); +} +.archive-viz-legend--loss::before { + background: var(--archive-loss); +} +.archive-viz-legend--net { + font-weight: 700; +} +.archive-viz-div-row { + display: grid; + grid-template-columns: minmax(4.5em, 7.5em) 1fr auto; + gap: 8px; + align-items: center; + font-size: 0.72rem; +} +.archive-viz-div-row .k { + opacity: 0.9; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.archive-viz-div-row .v { + font-size: 0.72rem; + font-weight: 700; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} +.archive-viz-div-track { + position: relative; + height: 12px; + border-radius: 4px; + background: var(--archive-viz-track-bg); + overflow: hidden; +} +.archive-viz-div-mid { + position: absolute; + left: 50%; + top: 0; + bottom: 0; + width: 1px; + background: var(--archive-viz-mid-line); + transform: translateX(-50%); + z-index: 1; +} +.archive-viz-div-fill { + position: absolute; + top: 0; + bottom: 0; + min-width: 2px; + border-radius: 2px; +} +.archive-viz-div-fill--profit { + background: linear-gradient(90deg, color-mix(in srgb, var(--archive-profit) 72%, #000), var(--archive-profit)); +} +.archive-viz-div-fill--loss { + background: linear-gradient(270deg, color-mix(in srgb, var(--archive-loss) 72%, #000), var(--archive-loss)); +} +.archive-viz-hold-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} +.archive-viz-hold-card { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px; + border-radius: 8px; + background: var(--archive-viz-kpi-bg); + border: 1px solid var(--archive-viz-kpi-border); +} +.archive-viz-hold-val { + font-size: 0.95rem; + font-weight: 700; + font-variant-numeric: tabular-nums; +} +.archive-viz-hold-card--win .archive-viz-hold-val { + color: var(--archive-profit); +} +.archive-viz-hold-card--loss .archive-viz-hold-val { + color: var(--archive-loss); +} +.archive-viz-hold-lbl { + font-size: 0.64rem; + color: var(--muted); +} +.archive-viz-bar-track { + height: 10px; + border-radius: 999px; + background: var(--archive-viz-track-bg); + overflow: hidden; +} +.archive-viz-bar-fill { + height: 100%; + width: 0; + border-radius: 999px; + transition: width 0.25s ease; +} +.archive-viz-bar-fill--profit { + background: linear-gradient(90deg, color-mix(in srgb, var(--archive-profit) 72%, #000), var(--archive-profit)); +} +.archive-viz-bar-fill--loss { + background: linear-gradient(90deg, color-mix(in srgb, var(--archive-loss) 72%, #000), var(--archive-loss)); +} +.archive-viz-empty { + margin: 0; + font-size: 0.72rem; +} +.archive-cum-wrap { + display: flex; + flex-direction: column; + gap: 6px; +} +.archive-cum-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.archive-cum-end { + font-size: 0.78rem; + font-weight: 700; + font-variant-numeric: tabular-nums; +} +.archive-cum-body { + display: grid; + grid-template-columns: 3.2em 1fr; + gap: 6px; + align-items: stretch; +} +.archive-cum-yaxis { + display: flex; + flex-direction: column; + justify-content: space-between; + font-size: 0.64rem; + color: var(--archive-axis-fg); + font-variant-numeric: tabular-nums; + padding: 2px 0 14px; + text-align: right; +} +.archive-cum-plot { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +.archive-cum-chart { + width: 100%; + height: 96px; + display: block; + border-radius: 8px; + background: var(--archive-chart-bg); + border: 1px solid var(--archive-viz-surface-border); +} +.archive-cum-xaxis { + display: flex; + justify-content: space-between; + font-size: 0.64rem; + color: var(--archive-axis-fg); + font-variant-numeric: tabular-nums; + padding: 0 2px; +} +.archive-cum-foot { + font-size: 0.66rem; + line-height: 1.35; + color: var(--archive-axis-fg); +} +.archive-cum-grid { + stroke: var(--archive-grid-stroke); + stroke-width: 1; +} +.archive-cum-grid--zero { + stroke: var(--archive-grid-zero-stroke); + stroke-dasharray: 4 4; +} +.archive-cum-line { + fill: none; + stroke-width: 2; + stroke-linejoin: round; + stroke-linecap: round; +} +.archive-cum-line--up { + stroke: var(--archive-profit); +} +.archive-cum-line--down { + stroke: var(--archive-loss); +} +.archive-cum-dot { + stroke: color-mix(in srgb, var(--text) 22%, transparent); + stroke-width: 1; +} +.archive-cum-dot--up { + fill: var(--archive-profit); +} +.archive-cum-dot--down { + fill: var(--archive-loss); +} +.archive-cum-dot--last { + stroke: color-mix(in srgb, var(--text) 28%, transparent); + stroke-width: 1.5; +} +@media (max-width: 520px) { + .archive-viz-kpis { + grid-template-columns: 1fr; + } + .archive-viz-div-row { + grid-template-columns: minmax(3.5em, 5em) 1fr auto; + gap: 4px; + } +} +.archive-calendar-section { + margin-bottom: 10px; +} +.archive-calendar-section .trade-cal-wrap { + margin-top: 8px; +} +.archive-stats-table .pnl-pos { + color: #22c55e; +} +.archive-stats-table .pnl-neg { + color: #ef4444; +} +.archive-acc-section { + border: 1px solid var(--border-soft); + border-radius: var(--radius); + background: var(--inset-surface); + overflow: hidden; +} +.archive-acc-summary { + padding: 10px 12px; + font-weight: 600; + font-size: 0.86rem; + cursor: pointer; + list-style: none; + display: flex; + align-items: center; + gap: 8px; +} +.archive-acc-summary::-webkit-details-marker { + display: none; +} +.archive-acc-sub { + font-weight: 400; + font-size: 0.76rem; + color: var(--muted); +} +.archive-calendar-day-trades { + margin-top: 12px; + border-top: 1px solid var(--border-soft); + padding-top: 12px; + display: flex; + flex-direction: column; + gap: 8px; +} +.archive-calendar-day-trades .archive-panel-head h3 { + margin: 0; + font-size: 0.9rem; +} +.archive-calendar-day-trades-body { + overflow: auto; + max-height: min(360px, 45vh); +} +.archive-chart-section { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px; + border: 1px solid var(--border-soft); + border-radius: 8px; + background: var(--inset-surface); + margin-bottom: 10px; +} +.archive-chart-section[hidden] { + display: none !important; +} +.archive-chart-title-row { + font-size: 0.78rem; + color: var(--muted); +} +.archive-chart-section > :not(summary) { + padding: 0; +} +.archive-trades-section { + flex: 0 0 auto; + min-height: 0; + display: flex; + flex-direction: column; +} +.archive-trades-section > .archive-trades { + border: none; + border-radius: 0; + flex: 0 0 auto; + min-height: 0; + max-height: none; +} +#page-archive.is-chart-open .archive-trades-section > .archive-trades { + flex: 0 0 auto; +} +.archive-chart-toolbar { + flex-wrap: wrap; +} +.archive-tf-tabs { + display: inline-flex; + gap: 4px; +} +.archive-tf-btn { + padding: 5px 10px; + border-radius: 8px; + border: 1px solid var(--border-soft); + background: var(--inset-surface); + color: var(--muted); + cursor: pointer; + font-family: var(--font); + font-size: 0.8rem; +} +.archive-tf-btn.is-active { + color: var(--text); + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, transparent); +} +.archive-chart-wrap { + position: relative; +} +.archive-chart-host { + height: 360px; + min-height: 280px; + border: 1px solid var(--border-soft); + border-radius: var(--radius); + background: var(--panel); + overflow: hidden; +} +.archive-mark-auto { + position: absolute; + right: 8px; + bottom: 10px; + z-index: 5; + padding: 4px 10px; + font-size: 0.72rem; + font-family: var(--font); + border-radius: 6px; + border: 1px solid var(--border-soft); + background: var(--chart-bar-bg, var(--inset-surface)); + color: var(--muted); + cursor: pointer; + line-height: 1.2; +} +.archive-mark-auto:hover { + border-color: var(--accent); + color: var(--text); +} +.archive-mark-auto.is-on { + color: #22c55e; + border-color: rgba(34, 197, 94, 0.45); + background: rgba(34, 197, 94, 0.1); +} +.archive-trades { + overflow: auto; + border: 1px solid var(--border-soft); + border-radius: var(--radius); + background: var(--panel); + overscroll-behavior: contain; +} +.archive-trades-pager { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; + font-size: 0.74rem; + color: var(--muted); +} +.archive-trades-pager[hidden] { + display: none !important; +} +.archive-trades-pager .ghost { + font-size: 0.72rem; + padding: 2px 8px; +} +.archive-trades-pager .ghost:disabled { + opacity: 0.45; + cursor: not-allowed; +} +.archive-trades-table { + width: 100%; + min-width: 1000px; + border-collapse: collapse; + font-size: 0.78rem; +} +.archive-trades-table .archive-dt { + white-space: nowrap; + font-variant-numeric: tabular-nums; +} +.archive-trades-table .archive-hold { + white-space: nowrap; +} +.archive-trades-table .archive-symbol { + white-space: nowrap; + font-weight: 500; +} +.archive-review-mark { + display: inline-block; + margin-right: 4px; + padding: 0 4px; + border-radius: 4px; + font-size: 0.62rem; + line-height: 1.4; + color: #6ab88a; + background: rgba(106, 184, 138, 0.12); + vertical-align: middle; +} +.archive-trades-table th, +.archive-trades-table td { + padding: 6px 8px; + border-bottom: 1px solid var(--border-soft); + text-align: left; +} +.archive-trades-table th { + color: var(--muted); + font-weight: 500; + position: sticky; + top: 0; + background: var(--panel); +} +.archive-trade-row { + cursor: default; +} +#page-archive.is-chart-open .archive-trade-row { + cursor: pointer; +} +.archive-trade-row.is-active { + background: color-mix(in srgb, var(--accent) 16%, var(--inset-surface)); + box-shadow: inset 3px 0 0 var(--accent); +} +.archive-trade-row.archive-trade-sick td { + color: var(--red); +} +.archive-trade-row.archive-trade-sick.is-active { + background: color-mix(in srgb, var(--accent) 12%, color-mix(in srgb, var(--red) 8%, var(--panel))); + box-shadow: inset 3px 0 0 var(--accent); +} +.archive-trade-row.archive-trade-sick .archive-tag-select, +.archive-trade-row.archive-trade-sick .archive-note-input { + color: var(--red); + border-color: color-mix(in srgb, var(--red) 40%, var(--border-soft)); +} +.archive-trade-row.archive-trade-sick td.pos, +.archive-trade-row.archive-trade-sick td.neg { + color: var(--red); +} +.archive-actions-cell { + white-space: nowrap; +} +.archive-actions-cell .archive-chart-btn, +.archive-actions-cell .archive-del-btn { + margin-right: 6px; +} +.archive-chart-btn { + padding: 3px 8px; + font-size: 0.72rem; + border-radius: 6px; +} +.archive-trades-table td.pos { + color: #22c55e; +} +.archive-trades-table td.neg { + color: #ef4444; +} +.archive-del-btn { + padding: 3px 8px; + font-size: 0.72rem; + border-radius: 6px; + border: 1px solid rgba(239, 68, 68, 0.35); + background: rgba(239, 68, 68, 0.08); + color: #f87171; + cursor: pointer; +} +.archive-del-btn:hover { + background: rgba(239, 68, 68, 0.16); +} +.archive-tag-select, +.archive-note-input { + width: 100%; + max-width: 140px; + padding: 4px 6px; + border-radius: 6px; + border: 1px solid var(--border-soft); + background: var(--inset-surface); + color: var(--text); + font-size: 0.75rem; +} +.archive-tag-fixed { + display: inline-block; + padding: 4px 8px; + border-radius: 6px; + font-size: 0.75rem; + font-weight: 600; +} +.archive-tag-fixed.is-tag-sick { + color: var(--red); + border: 1px solid color-mix(in srgb, var(--red) 45%, var(--border-soft)); + background: color-mix(in srgb, var(--red) 14%, var(--inset-surface)); +} +.archive-tag-select.is-tag-sick { + color: var(--red); + border-color: color-mix(in srgb, var(--red) 45%, var(--border-soft)); + background: color-mix(in srgb, var(--red) 14%, var(--inset-surface)); +} +.archive-tag-select.is-tag-emotion { + color: #60a5fa; + border-color: color-mix(in srgb, #60a5fa 45%, var(--border-soft)); + background: color-mix(in srgb, #60a5fa 14%, var(--inset-surface)); +} +.archive-trade-row.archive-trade-sick .archive-tag-select.is-tag-sick { + color: var(--red); + border-color: color-mix(in srgb, var(--red) 50%, var(--border-soft)); + background: color-mix(in srgb, var(--red) 18%, var(--inset-surface)); +} +.archive-empty { + padding: 16px; + color: var(--muted); + font-size: 0.85rem; +} +@media (max-width: 900px) { + #page-archive .page-desc { + display: none; + } + #page-archive .archive-toolbar-desktop, + #page-archive .archive-panel-desktop { + display: none !important; + } + #page-archive .archive-toolbar { + margin-bottom: 10px; + } + #page-archive .archive-layout { + display: flex; + flex-direction: column; + gap: 12px; + min-height: 0; + } + #page-archive .archive-quotes-panel, + #page-archive .archive-main-panel, + #page-archive .archive-viz-panel, + #page-archive .archive-calendar-panel { + flex: 0 0 auto; + min-height: 0; + max-height: none; + overflow: visible; + } + #page-archive .archive-stats-card { + margin-bottom: 10px; + } + #page-archive .archive-quotes-list { + min-height: 120px; + max-height: 42vh; + } + #page-archive .archive-quote-day-trades-body { + max-height: 32vh; + } + #page-archive .archive-stats-table th, + #page-archive .archive-stats-table td { + padding: 6px 8px; + font-size: 0.74rem; + } +} + +/* —— 开仓计划 —— */ +#page-plan .plan-layout { + display: grid; + grid-template-columns: minmax(320px, 420px) minmax(0, 1fr); + gap: 14px; + align-items: start; +} +.plan-left-panel, +.plan-right-panel { + display: flex; + flex-direction: column; + gap: 14px; + min-width: 0; +} +.plan-form-section, +.plan-active-section, +.plan-history-section, +.plan-stats-section { + background: var(--panel); + border: 1px solid var(--border-soft); + border-radius: var(--radius); + padding: 12px; +} +.plan-panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 10px; +} +.plan-panel-head h2 { + margin: 0; + font-size: 0.95rem; +} +.plan-panel-meta { + font-size: 0.72rem; + color: var(--muted); +} +.plan-form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px 10px; +} +.plan-field { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 0.78rem; +} +.plan-field-full { + grid-column: 1 / -1; +} +.plan-field span { + color: var(--muted); +} +.plan-field input, +.plan-field select, +.plan-field textarea { + width: 100%; + padding: 7px 9px; + border-radius: 8px; + border: 1px solid var(--border-soft); + background: var(--inset-surface); + color: var(--text); + font-family: var(--font); + font-size: 0.82rem; +} +.plan-field-inline { + flex-direction: row; + align-items: center; + gap: 6px; +} +.plan-field-inline span { + white-space: nowrap; +} +.plan-field-inline input, +.plan-field-inline select { + width: auto; + min-width: 88px; +} +.plan-radio-row { + display: flex; + flex-wrap: wrap; + gap: 10px; +} +.plan-radio-label { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.82rem; +} +.plan-submit-btn { + margin-top: 10px; + width: 100%; +} +.plan-active-list, +.plan-history-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 48vh; + overflow: auto; +} +.plan-empty { + margin: 0; + padding: 12px 4px; + color: var(--muted); + font-size: 0.82rem; +} +.plan-active-card { + border: 1px solid var(--border-soft); + border-radius: 8px; + background: var(--inset-surface); + padding: 10px; + display: flex; + flex-direction: column; + gap: 6px; +} +.plan-active-head { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: flex-start; +} +.plan-active-title { + font-size: 0.84rem; + font-weight: 600; +} +.plan-active-actions { + display: flex; + gap: 4px; + flex-shrink: 0; +} +.plan-active-meta, +.plan-active-levels { + font-size: 0.74rem; + color: var(--muted); +} +.plan-active-note { + font-size: 0.78rem; + color: var(--text); + opacity: 0.9; +} +.plan-scheme-row { + margin-top: 6px; +} +.plan-field-scheme select { + min-width: 160px; +} +.plan-close-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: flex-end; + margin-top: 4px; + padding-top: 8px; + border-top: 1px dashed var(--border-soft); +} +.plan-history-row { + display: grid; + grid-template-columns: 92px minmax(0, 1fr) auto auto; + gap: 8px; + align-items: center; + width: 100%; + text-align: left; + padding: 9px 10px; + border: 1px solid var(--border-soft); + border-radius: 8px; + background: var(--inset-surface); + color: var(--text); + font-family: var(--font); + font-size: 0.8rem; + cursor: pointer; +} +.plan-history-row:hover { + border-color: var(--accent); +} +.plan-history-date { + color: var(--muted); + font-size: 0.74rem; +} +.plan-history-result.plan-res-win { + color: var(--pos); +} +.plan-history-result.plan-res-loss { + color: var(--neg); +} +.plan-stats-toolbar { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + margin-bottom: 10px; +} +.plan-period-tabs, +.plan-dim-tabs { + display: inline-flex; + flex-wrap: wrap; + gap: 4px; +} +.plan-period-btn, +.plan-dim-btn { + padding: 5px 10px; + border-radius: 999px; + border: 1px solid var(--border-soft); + background: transparent; + color: var(--muted); + font-size: 0.74rem; + cursor: pointer; +} +.plan-period-btn.is-active, +.plan-dim-btn.is-active { + border-color: var(--accent); + color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, transparent); +} +.plan-stats-range.hidden { + display: none; +} +.plan-period-sep { + color: var(--muted); + font-size: 0.78rem; +} +.plan-stats-table { + width: 100%; + border-collapse: collapse; + font-size: 0.8rem; +} +.plan-stats-table th, +.plan-stats-table td { + padding: 7px 10px; + border-bottom: 1px solid var(--border-soft); + text-align: left; +} +.plan-stats-table th { + color: var(--muted); + font-weight: 500; +} +.plan-detail-body { + display: flex; + flex-direction: column; + gap: 8px; + padding: 4px 0 8px; +} +.plan-detail-row { + display: grid; + grid-template-columns: 88px minmax(0, 1fr); + gap: 8px; + font-size: 0.82rem; +} +.plan-detail-k { + color: var(--muted); +} +.plan-detail-v { + color: var(--text); + word-break: break-word; +} +.plan-detail-card { + width: min(480px, 94vw); +} +.plan-edit-card { + width: min(520px, 94vw); +} +@media (max-width: 960px) { + #page-plan .plan-layout { + grid-template-columns: 1fr; + } + .plan-active-list, + .plan-history-list { + max-height: none; + } + .plan-history-row { + grid-template-columns: 1fr; + gap: 4px; + } +} + +/* ── 策略计算器 ── */ +.calc-layout { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + align-items: stretch; +} + +.calc-card { + padding: 16px 18px; + height: 100%; + display: flex; + flex-direction: column; +} + +.calc-form { + flex: 1; + display: flex; + flex-direction: column; +} + +.calc-card h2 { + margin: 0 0 8px; + font-size: 1rem; + color: var(--text); +} + +.calc-hint { + margin: 0 0 14px; + font-size: 0.78rem; + color: var(--muted); + line-height: 1.5; +} + +.calc-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 12px; +} + +.calc-field { + display: flex; + flex-direction: column; + gap: 5px; + font-size: 0.78rem; + color: var(--muted); +} + +.calc-field input, +.calc-field select { + width: 100%; + box-sizing: border-box; + background: var(--bg-elevated); + border: 1px solid var(--border); + color: var(--text); + border-radius: 8px; + padding: 8px 10px; + font-size: 0.82rem; + font-family: var(--mono); +} + +.calc-field-span2 { + grid-column: 1 / -1; +} + +.calc-market-info { + padding: 0.55rem 0.55rem 0.55rem 0.75rem; + border-radius: 8px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + font-size: 0.82rem; + line-height: 1.45; + color: var(--muted, #9aa4b2); +} + +.calc-market-info strong { + color: var(--text, #e8ecf1); +} + +.calc-market-err { + color: #f87171; +} + +.calc-actions { + margin-top: auto; + padding-top: 12px; +} + +.calc-result { + margin-top: 14px; + padding-top: 12px; + border-top: 1px solid var(--border-soft); +} + +.calc-result.hidden { + display: none !important; +} + +.calc-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 8px 12px; + margin-bottom: 12px; +} + +.calc-summary div { + background: var(--bg-elevated); + border: 1px solid var(--border-soft); + border-radius: 8px; + padding: 8px 10px; +} + +.calc-summary span { + display: block; + font-size: 0.72rem; + color: var(--muted); + margin-bottom: 4px; +} + +.calc-summary strong { + font-family: var(--mono); + font-size: 0.86rem; + color: var(--text); +} + +.calc-pnl-profit { + color: var(--green) !important; +} + +.calc-pnl-loss { + color: var(--red) !important; +} + +.calc-table-wrap { + overflow: auto; +} + +.calc-table { + width: 100%; + border-collapse: collapse; + font-size: 0.78rem; +} + +.calc-table th, +.calc-table td { + padding: 7px 8px; + border-bottom: 1px solid var(--border-soft); + text-align: left; + white-space: nowrap; +} + +.calc-table th { + color: var(--muted); + font-weight: 600; +} + +.calc-error { + color: var(--red); + font-size: 0.82rem; + margin: 0; +} + +.calc-empty { + color: var(--muted); + font-size: 0.82rem; + margin: 0; +} + +.calc-roll-legs-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin: 14px 0 8px; + font-size: 0.82rem; + color: var(--text); +} + +.calc-roll-legs-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.calc-roll-leg { + border: 1px solid var(--border-soft); + border-radius: 8px; + padding: 10px 12px; + background: var(--bg-elevated); +} + +.calc-roll-leg-title { + font-size: 0.8rem; + font-weight: 600; + color: var(--muted); + margin-bottom: 8px; +} + +.calc-roll-leg-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.calc-roll-leg-remove { + margin-top: 8px; + font-size: 0.78rem; +} + +.calc-done-tag { + display: inline-block; + margin-left: 6px; + padding: 1px 6px; + border-radius: 999px; + font-size: 0.68rem; + color: var(--muted); + border: 1px solid var(--border-soft); +} + +@media (max-width: 960px) { + .calc-layout { + grid-template-columns: 1fr; + } + .calc-form-grid { + grid-template-columns: 1fr; + } +} + +/* 电脑端计算器改为单页 Tab;手机继续使用原有紧凑 Tab 样式 */ +.calc-tab-label-desktop { + display: none; +} + +body:not(.hub-phone) #page-calculator .calc-workspace { + display: grid; + grid-template-columns: 180px minmax(0, 1fr); + gap: 14px; + align-items: start; +} + +body:not(.hub-phone) #page-calculator .calc-mobile-tabs { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 8px; + max-width: none; + margin: 0; + padding: 5px; + border: 1px solid var(--border-soft); + border-radius: 12px; + background: var(--nav-bg); +} + +body:not(.hub-phone) #page-calculator .calc-m-tab { + min-height: 42px; + border: 1px solid transparent; + border-radius: 9px; + background: transparent; + color: var(--muted); + font: inherit; + font-size: 13px; + font-weight: 600; + cursor: pointer; + text-align: left; + padding: 9px 12px; +} + +body:not(.hub-phone) #page-calculator .calc-m-tab:hover { + color: var(--text); + border-color: var(--border-soft); +} + +body:not(.hub-phone) #page-calculator .calc-m-tab.is-active { + color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 45%, var(--border-soft)); + background: var(--accent-dim); + box-shadow: inset 0 0 18px color-mix(in srgb, var(--accent) 8%, transparent); +} + +body:not(.hub-phone) #page-calculator .calc-tab-label-mobile { + display: none; +} + +body:not(.hub-phone) #page-calculator .calc-tab-label-desktop { + display: inline; +} + +body:not(.hub-phone) #page-calculator .calc-layout { + grid-template-columns: minmax(0, 1fr); + min-width: 0; +} + +body:not(.hub-phone) #page-calculator .calc-layout[data-calc-tab="trend"] [data-calc-pane="roll"], +body:not(.hub-phone) #page-calculator .calc-layout[data-calc-tab="roll"] [data-calc-pane="trend"] { + display: none; +} + +@media (min-width: 1200px) { + body:not(.hub-phone) #page-calculator .calc-form-grid { + grid-template-columns: repeat(5, minmax(0, 1fr)); + } +} + +/* --- 策略说明 --- */ +.strategy-page-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} +.strategy-page-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.strategy-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 12px; +} +.strategy-tabs { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.strategy-tab { + padding: 6px 14px; + border-radius: 999px; + border: 1px solid var(--border-soft); + background: var(--surface-2); + color: var(--text-soft); + cursor: pointer; + font-size: 0.88rem; +} +.strategy-tab.is-active { + background: var(--accent-soft); + border-color: var(--accent); + color: var(--text); +} +.strategy-layout { + display: grid; + grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr); + gap: 16px; + align-items: start; +} +.strategy-doc-card, +.strategy-checklist-card { + padding: 18px 20px 20px; + display: flex; + flex-direction: column; + min-width: 0; +} +.strategy-doc-card { + overflow: hidden; +} +.strategy-col-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 14px; +} +.strategy-col-head .strategy-col-title { + margin: 0; +} +.strategy-col-print { + flex-shrink: 0; + padding: 4px 10px; + font-size: 0.78rem; +} +.strategy-col-title { + margin: 0 0 14px; + font-size: 0.95rem; + color: var(--text-soft); +} +.strategy-doc-body { + flex: 1 1 auto; + min-height: 0; + max-height: 100%; + font-size: 0.88rem; + line-height: 1.55; + color: var(--text); + overflow-y: auto; + overflow-x: hidden; + padding: 2px 10px 2px 4px; + scrollbar-gutter: stable; +} +.strategy-doc-body h2 { + font-size: 1rem; + margin: 0.6em 0 0.5em; + color: var(--text); +} +.strategy-doc-body h2:first-child { + margin-top: 0; +} +.strategy-doc-body h3 { + font-size: 0.92rem; + margin: 1em 0 0.4em; +} +.strategy-doc-body table { + width: 100%; + border-collapse: collapse; + font-size: 0.82rem; + margin: 8px 0; +} +.strategy-doc-body th, +.strategy-doc-body td { + border: 1px solid var(--border-soft); + padding: 8px 10px; + text-align: left; +} +.strategy-doc-body blockquote { + margin: 8px 0; + padding: 8px 12px; + border-left: 3px solid var(--accent); + color: var(--text-soft); + background: var(--surface-2); +} +.strategy-doc-body pre, +.strategy-doc-body code { + font-size: 0.8rem; +} +.strategy-doc-source { + margin: 14px 0 0; + padding-top: 12px; + border-top: 1px dashed var(--border-soft); + font-size: 0.75rem; + color: var(--muted); +} +.strategy-checklist-body { + flex: 0 0 auto; + overflow: visible; + padding: 2px 10px 2px 4px; +} +.strategy-checklist-body ul { + list-style: none; + margin: 0; + padding: 0; +} +.strategy-check-group { + margin-bottom: 14px; +} +.strategy-check-group h4 { + margin: 0 0 8px; + font-size: 0.88rem; + color: var(--accent); +} +.strategy-check-group li { + margin: 6px 0; + font-size: 0.86rem; + line-height: 1.45; + display: flex; + gap: 8px; + align-items: flex-start; +} +.strategy-check-box { + flex: 0 0 auto; + font-size: 1rem; + line-height: 1.2; +} +.strategy-checklist-footnotes { + margin: 14px 0 0; + padding: 12px 0 0 22px; + border-top: 1px dashed var(--border-soft); + font-size: 0.78rem; + color: var(--muted); +} +.strategy-checklist-footnotes.hidden { + display: none; +} +.strategy-empty { + color: var(--muted); + font-size: 0.85rem; +} + +@media (max-width: 960px) { + .strategy-layout { + grid-template-columns: 1fr; + } + .strategy-doc-card, + .strategy-checklist-card { + align-self: stretch; + } + .strategy-doc-body { + max-height: min(50vh, 520px); + } +} + +/* ── 使用说明 ── */ +.help-toolbar { + margin-bottom: 12px; + min-height: 1.2em; +} +.help-layout { + display: grid; + grid-template-columns: minmax(180px, 220px) minmax(0, 1fr); + gap: 16px; + align-items: start; +} +.help-toc-card { + padding: 16px 14px 18px; + position: sticky; + top: 72px; +} +.help-toc-title { + margin: 0 0 12px; + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.04em; + color: var(--text-soft); + text-transform: uppercase; +} +.help-toc-nav { + display: flex; + flex-direction: column; + gap: 4px; +} +.help-toc-item { + display: block; + width: 100%; + text-align: left; + padding: 8px 10px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--text-soft); + font-size: 0.88rem; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} +.help-toc-item:hover { + background: var(--surface-2, rgba(255, 255, 255, 0.04)); + color: var(--text); +} +.help-toc-item.is-active { + background: var(--accent-soft, rgba(59, 130, 246, 0.12)); + color: var(--accent, #3b82f6); + font-weight: 600; +} +.help-doc-card { + padding: 18px 20px 20px; + min-width: 0; +} + +@media (max-width: 960px) { + .help-layout { + grid-template-columns: 1fr; + } + .help-toc-card { + position: static; + } + .help-toc-nav { + flex-direction: row; + flex-wrap: wrap; + } + .help-toc-item { + width: auto; + flex: 1 1 auto; + } + .help-doc-card .strategy-doc-body { + max-height: none; + } +} + +/* ── 监控页:永续 / 期权分块 ── */ +.hub-monitor-block { + margin-bottom: 4px; +} + +.hub-monitor-block-label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + color: var(--text); + margin: 0 0 8px; +} + +.hub-monitor-perp .section-title:first-of-type { + margin-top: 0; +} + +.hub-monitor-options { + margin-top: 14px; + padding-top: 12px; + border-top: 1px dashed var(--border-soft); +} + +.hub-monitor-options .hub-monitor-block-label { + color: var(--accent); +} + +.hub-monitor-options .section-title.hub-options-title { + margin-top: 0; +} + +html[data-theme="light"] .hub-monitor-block-label { + color: #142232; +} + +html[data-theme="light"] .hub-monitor-options .hub-monitor-block-label { + color: #006e9a; +} + +/* ── 监控页:OKX 期权只读聚合 ── */ +.hub-options-title { + margin-top: 14px; +} + +.hub-opt-target-list { + display: flex; + flex-direction: column; + gap: 6px; + margin: 0 0 10px; +} + +.hub-opt-target-item { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + font-size: 12px; + color: var(--muted, #9aa8c7); +} + +.hub-opt-target-item code { + font-size: 11px; + color: #dbe6ff; +} + +.hub-opt-pos-card .opt-target-row--ro { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid rgba(67, 82, 118, 0.4); + font-size: 12px; +} + +.hub-options-summary { + display: flex; + flex-wrap: wrap; + gap: 8px 14px; + font-size: 0.78rem; + color: var(--muted); + margin: 0 0 8px; +} + +.hub-options-summary strong { + color: var(--text); + font-weight: 600; +} + +.hub-options-table-wrap { + margin-bottom: 8px; +} + +.hub-options-table { + width: 100%; + border-collapse: collapse; + font-size: 0.76rem; +} + +.hub-options-table th, +.hub-options-table td { + padding: 6px 8px; + text-align: left; + border-bottom: 1px solid var(--border); +} + +.hub-options-table th { + color: var(--muted); + font-weight: 600; + font-size: 0.72rem; +} + +.hub-options-inst { + font-size: 0.7rem; + word-break: break-all; +} + +.hub-options-actions { + margin-top: 6px; +} + +.opt-expiry-cd { + font-variant-numeric: tabular-nums; + font-weight: 600; + white-space: nowrap; +} + +.opt-expiry-cd--urgent { + color: #ffb347; +} + +.opt-expiry-cd--expired { + color: var(--muted); +} + +html[data-theme="light"] .hub-options-table th { + color: #3a5068; +} + +html[data-theme="light"] .hub-options-summary { + color: #3a5068; +} + +html[data-theme="light"] .hub-options-summary strong { + color: #142232; +} + +/* ── 系统日志页 ── */ +.hub-logs-page-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} +.hub-logs-page-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.hub-logs-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 12px; +} +.hub-logs-tabs { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.hub-logs-tab { + padding: 6px 14px; + border-radius: 999px; + border: 1px solid var(--border-soft); + background: var(--inset-surface); + color: var(--muted); + cursor: pointer; + font-size: 0.88rem; +} +.hub-logs-tab:hover { + color: var(--text); + background: var(--nav-link-hover-bg, var(--panel-hover)); +} +.hub-logs-tab.is-active { + background: var(--accent-dim); + border-color: var(--accent); + color: var(--text); + font-weight: 600; +} +#hub-logs-status.is-err { + color: var(--danger, #f87171); +} +.hub-logs-layout { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + align-items: stretch; +} +.hub-logs-card { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 420px; + max-height: calc(100vh - 220px); + padding: 16px 18px 18px; +} +.hub-logs-card-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; +} +.hub-logs-card-title { + margin: 0; + font-size: 0.95rem; +} +.hub-logs-card-hint { + font-size: 0.72rem; + color: var(--muted, #8892b0); +} +.hub-logs-pre { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + margin: 0; + padding: 12px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.74rem; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; + background: var(--log-pre-bg, #0d1018); + border-radius: 8px; + border: 1px solid var(--log-pre-border, var(--border-soft)); + color: var(--log-pre-fg, #d8deef); +} +html[data-theme="light"] .hub-logs-pre { + --log-pre-bg: #f0f4f9; + --log-pre-border: rgba(0, 75, 115, 0.18); + --log-pre-fg: #1a2838; +} +html[data-theme="light"] .hub-logs-card-title { + color: var(--text); +} +html[data-theme="light"] .hub-logs-card-hint { + color: var(--muted); +} +@media (max-width: 960px) { + .hub-logs-layout { + grid-template-columns: minmax(0, 1fr); + } + .hub-logs-card { + max-height: none; + min-height: 280px; + } +} + +/* ── 一屏适配:桌面;手机端 .hub-phone 另走 ── */ +@media (min-width: 721px) and (min-height: 650px) { + /* + * 监控/行情:壳子拉满视口宽 + 固定 100dvh,滚动条贴窗口右缘。 + * 资金概况单独走 body 滚动(见下),避免内层滚动条缩进。 + */ + body.hub-page-monitor:not(.hub-phone) .app-shell, + body.hub-page-market:not(.hub-phone) .app-shell { + max-width: none; + width: 100%; + margin-left: 0; + margin-right: 0; + height: 100dvh; + max-height: 100dvh; + padding-top: 0; + padding-bottom: 8px; + padding-left: max(48px, env(safe-area-inset-left), calc((100vw - 1680px) / 2)); + padding-right: max(48px, env(safe-area-inset-right), calc((100vw - 1680px) / 2)); + display: flex; + flex-direction: column; + overflow-x: hidden; + overflow-y: auto; + } + + /* 资金概况:由 body 滚动,滚动条贴窗口最右边 */ + body.hub-page-funds:not(.hub-phone) { + height: 100dvh; + max-height: 100dvh; + overflow-x: hidden; + overflow-y: auto; + } + + body.hub-page-funds:not(.hub-phone) .app-shell { + max-width: none; + width: 100%; + margin-left: 0; + margin-right: 0; + height: auto; + min-height: 100%; + max-height: none; + padding-top: 0; + padding-bottom: 16px; + padding-left: max(48px, env(safe-area-inset-left), calc((100vw - 1680px) / 2)); + padding-right: max(48px, env(safe-area-inset-right), calc((100vw - 1680px) / 2)); + display: flex; + flex-direction: column; + overflow: visible; + } +} + +@media (min-width: 2001px) and (min-height: 650px) { + body.hub-page-monitor:not(.hub-phone) .app-shell, + body.hub-page-market:not(.hub-phone) .app-shell, + body.hub-page-funds:not(.hub-phone) .app-shell { + padding-left: max(24px, env(safe-area-inset-left), calc((100vw - 1860px) / 2)); + padding-right: max(24px, env(safe-area-inset-right), calc((100vw - 1860px) / 2)); + } +} + +/* 其它桌面页(计算器/设置/内照明心等):壳子同样拉满宽度,页面滚动条贴右缘 */ +@media (min-width: 721px) { + body:not(.hub-phone):not(.hub-page-monitor):not(.hub-page-market):not(.hub-page-funds):not(.hub-page-ai) .app-shell { + max-width: none; + width: 100%; + margin-left: 0; + margin-right: 0; + padding-left: max(24px, env(safe-area-inset-left), calc((100vw - 1680px) / 2)); + padding-right: max(24px, env(safe-area-inset-right), calc((100vw - 1680px) / 2)); + } +} + +@media (min-width: 721px) and (min-height: 650px) { + body.hub-page-monitor:not(.hub-phone) .app-header, + body.hub-page-market:not(.hub-phone) .app-header, + body.hub-page-funds:not(.hub-phone) .app-header { + flex: 0 0 auto; + padding: 10px 0 8px; + margin-bottom: 0; + } + + /* —— 监控区 —— */ + body.hub-page-monitor:not(.hub-phone) #page-monitor { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + overflow: visible; + } + + body.hub-page-monitor:not(.hub-phone) #page-monitor > .page-head { + flex: 0 0 auto; + margin: 6px 0 4px; + } + + body.hub-page-monitor:not(.hub-phone) #page-monitor > .page-head h1 { + font-size: 16px; + } + + body.hub-page-monitor:not(.hub-phone) #page-monitor .host-status-panel, + body.hub-page-monitor:not(.hub-phone) #page-monitor .monitor-macro-banner, + body.hub-page-monitor:not(.hub-phone) #page-monitor .monitor-alert-summary, + body.hub-page-monitor:not(.hub-phone) #page-monitor .hub-m-fold { + flex: 0 0 auto; + margin-bottom: 6px; + } + + body.hub-page-monitor:not(.hub-phone) #page-monitor .host-status-summary { + padding: 6px 10px; + } + + body.hub-page-monitor:not(.hub-phone) #page-monitor .host-status-bar { + padding: 8px 10px 10px; + } + + body.hub-page-monitor:not(.hub-phone) #page-monitor .host-status-metrics { + gap: 6px; + } + + body.hub-page-monitor:not(.hub-phone) #page-monitor .host-metric-card { + padding: 6px 8px; + } + + body.hub-page-monitor:not(.hub-phone) #page-monitor .grid-monitor { + flex: 1 1 auto; + min-height: 0; + gap: 8px; + overflow: visible; + } + + body.hub-page-monitor:not(.hub-phone) .grid-monitor.grid-monitor-options-split { + display: flex; + flex-direction: column; + height: 100%; + gap: 8px; + } + + body.hub-page-monitor:not(.hub-phone) .grid-monitor-options-split .monitor-stats-card { + flex: 0 0 auto; + padding: 8px 14px; + } + + body.hub-page-monitor:not(.hub-phone) .grid-monitor-options-split .monitor-stats-grid { + gap: 10px; + } + + body.hub-page-monitor:not(.hub-phone) .grid-monitor-options-split .monitor-stat-cell { + padding: 10px 8px 8px; + } + + body.hub-page-monitor:not(.hub-phone) .grid-monitor-options-split .monitor-stat-value { + font-size: 18px; + } + + body.hub-page-monitor:not(.hub-phone) .grid-monitor-options-split .monitor-split-body { + flex: 1 1 auto; + min-height: 0; + gap: 8px; + height: auto; + } + + body.hub-page-monitor:not(.hub-phone) .monitor-split-body.monitor-split-2x2 { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + grid-template-rows: minmax(min-content, 1fr) minmax(min-content, 1fr); + gap: 8px; + } + + body.hub-page-monitor:not(.hub-phone) .monitor-split-left, + body.hub-page-monitor:not(.hub-phone) .monitor-split-right { + gap: 8px; + grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); + align-content: stretch; + overflow: hidden; + } + + body.hub-page-monitor:not(.hub-phone) .monitor-split-left > .card, + body.hub-page-monitor:not(.hub-phone) .monitor-split-right > .card, + body.hub-page-monitor:not(.hub-phone) .monitor-split-2x2 > .card { + height: 100%; + min-height: 0; + } + + body.hub-page-monitor:not(.hub-phone) .card-monitor-okx-split, + body.hub-page-monitor:not(.hub-phone) .card-monitor-split-side { + overflow: hidden; + } + + body.hub-page-monitor:not(.hub-phone) .card-monitor-okx-split .card-head, + body.hub-page-monitor:not(.hub-phone) .card-monitor-split-side .card-head { + padding: 12px 14px 10px; + } + + body.hub-page-monitor:not(.hub-phone) .card-monitor-okx-split .card-body, + body.hub-page-monitor:not(.hub-phone) .card-monitor-split-side .card-body { + padding: 8px 10px; + overflow: auto; + } + + body.hub-page-monitor:not(.hub-phone) .hub-inner-cards { + gap: 6px; + grid-template-rows: auto minmax(0, 1fr); + } + + body.hub-page-monitor:not(.hub-phone) .hub-inner-card { + overflow: hidden; + } + + body.hub-page-monitor:not(.hub-phone) .hub-inner-card-head { + padding: 5px 8px; + font-size: 10px; + } + + body.hub-page-monitor:not(.hub-phone) .hub-inner-card-body { + padding: 6px 8px; + overflow: auto; + } + + body.hub-page-monitor:not(.hub-phone) .grid-monitor-options-split .hub-options-table-wrap, + body.hub-page-monitor:not(.hub-phone) .grid-monitor-options-split .pos-table-wrap, + body.hub-page-monitor:not(.hub-phone) .grid-monitor-options-split .table-scroll { + overflow: auto; + max-height: 100%; + } + + body.hub-page-monitor:not(.hub-phone) .data-table th, + body.hub-page-monitor:not(.hub-phone) .data-table td { + padding: 3px 5px; + font-size: 11px; + line-height: 1.25; + } + + body.hub-page-monitor:not(.hub-phone) .section-title { + margin: 4px 0 4px; + font-size: 11px; + } + + body.hub-page-monitor:not(.hub-phone) .hub-monitor-options { + margin-top: 6px; + padding-top: 6px; + } + + body.hub-page-monitor:not(.hub-phone) .empty-hint { + padding: 4px 0; + font-size: 11px; + } + + /* —— 行情区 —— */ + body.hub-page-market:not(.hub-phone) #page-market { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + } + + body.hub-page-market:not(.hub-phone) #page-market > .page-head { + flex: 0 0 auto; + margin: 6px 0 4px; + } + + body.hub-page-market:not(.hub-phone) #page-market > .page-head h1 { + font-size: 16px; + } + + body.hub-page-market:not(.hub-phone) #page-market .hub-m-fold, + body.hub-page-market:not(.hub-phone) #page-market .market-vol-rank-anchor, + body.hub-page-market:not(.hub-phone) #page-market .market-status { + flex: 0 0 auto; + } + + body.hub-page-market:not(.hub-phone) #page-market .market-status { + margin: 2px 0 6px; + } + + body.hub-page-market:not(.hub-phone) #page-market .market-chart-wrap, + body.hub-page-market:not(.hub-phone) #page-market .market-chart-wrap.has-pos-panel { + flex: 1 1 auto; + height: auto !important; + min-height: 0; + max-height: none; + } + + body.hub-page-market:not(.hub-phone) #page-market .market-ohlcv-bar { + padding: 6px 10px; + } + + /* —— 资金概况 —— */ + body.hub-page-funds:not(.hub-phone) #page-funds { + flex: 0 0 auto; + min-height: 0; + height: auto; + display: flex; + flex-direction: column; + overflow: visible; + } + + body.hub-page-funds:not(.hub-phone) .funds-stage { + flex: 0 0 auto; + min-height: 0; + height: auto; + display: flex; + flex-direction: column; + overflow: visible; + } + + /* 不在内层滚动,改由 body 贴边滚动 */ + body.hub-page-funds:not(.hub-phone) .funds-stage-inner { + flex: 0 0 auto; + min-height: 0; + height: auto; + display: flex; + flex-direction: column; + padding: 8px 12px 16px; + overflow: visible; + } + + body.hub-page-funds:not(.hub-phone) .funds-head { + flex: 0 0 auto; + margin-bottom: 4px !important; + } + + body.hub-page-funds:not(.hub-phone) .funds-head h1 { + font-size: 16px; + } + + body.hub-page-funds:not(.hub-phone) .funds-toolbar { + flex: 0 0 auto; + margin-bottom: 6px; + gap: 8px; + } + + body.hub-page-funds:not(.hub-phone) .funds-summary { + flex: 0 0 auto; + margin-bottom: 6px; + gap: 8px; + } + + body.hub-page-funds:not(.hub-phone) .funds-stat-card { + padding: 8px 10px; + } + + body.hub-page-funds:not(.hub-phone) .funds-stat-value { + font-size: 1.15rem; + } + + body.hub-page-funds:not(.hub-phone) .funds-meta { + flex: 0 0 auto; + margin: 0 0 6px; + padding: 4px 8px; + } + + body.hub-page-funds:not(.hub-phone) .funds-chart-panel { + flex: 0 0 auto; + height: min(420px, 46vh); + min-height: 300px; + max-height: none; + margin-bottom: 10px; + display: flex; + flex-direction: column; + overflow: hidden; + } + + body.hub-page-funds:not(.hub-phone) .funds-chart-host { + flex: 1 1 auto; + height: auto; + min-height: 0; + /* 给 Lightweight Charts 时间轴留出空间,避免日期被裁切 */ + padding-bottom: 2px; + box-sizing: border-box; + } + + body.hub-page-funds:not(.hub-phone) .funds-section-head { + flex: 0 0 auto; + margin-bottom: 6px; + } + + body.hub-page-funds:not(.hub-phone) .funds-accounts { + flex: 0 0 auto; + max-height: none; + overflow: visible; + gap: 8px; + padding: 0 0 8px; + } + + body.hub-page-funds:not(.hub-phone) .funds-ac-card { + padding: 8px 10px; + gap: 6px; + } +} + +/* 1920×1080:略压 KPI,曲线固定高度,分户完整可见可滚到底 */ +@media (min-width: 1600px) and (min-height: 900px) and (max-height: 1100px) { + body.hub-page-funds:not(.hub-phone) .funds-head { + margin-bottom: 2px !important; + } + + body.hub-page-funds:not(.hub-phone) .funds-toolbar { + margin-bottom: 4px; + } + + body.hub-page-funds:not(.hub-phone) .funds-summary { + margin-bottom: 4px; + gap: 6px; + } + + body.hub-page-funds:not(.hub-phone) .funds-stat-card { + padding: 6px 8px; + } + + body.hub-page-funds:not(.hub-phone) .funds-meta { + margin-bottom: 4px; + padding: 3px 8px; + font-size: 0.7rem; + } + + body.hub-page-funds:not(.hub-phone) .funds-chart-panel { + height: 360px; + min-height: 360px; + } +} diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js new file mode 100644 index 0000000..5d475f4 --- /dev/null +++ b/manual_trading_hub/static/app.js @@ -0,0 +1,5949 @@ +(function () { + const toast = document.getElementById("toast"); + let settingsCache = null; + let authState = { required: false, logged_in: true }; + + function displayPref(key, defaultOn) { + const d = settingsCache && settingsCache.display; + if (!d || d[key] === undefined) return defaultOn !== false; + return !!d[key]; + } + + function showAccountPnlPref() { + return displayPref("show_account_pnl", true); + } + + function showNavFundsPref() { + return displayPref("show_nav_funds", true); + } + + function showNavDashboardPref() { + return displayPref("show_nav_dashboard", true); + } + + function showNavPlanPref() { + return displayPref("show_nav_plan", true); + } + + function showNavArchivePref() { + return displayPref("show_nav_archive", true); + } + + function showNavQuotesPref() { + return displayPref("show_nav_quotes", true); + } + + function showNavAiPref() { + return displayPref("show_nav_ai", true); + } + + function showNavCalculatorPref() { + return displayPref("show_nav_calculator", true); + } + + function showNavStrategyPref() { + return displayPref("show_nav_strategy", true); + } + + function showNavHelpPref() { + return displayPref("show_nav_help", true); + } + + function showNavLogsPref() { + return displayPref("show_nav_logs", true); + } + + function syncNavVisibility(data) { + const d = (data && data.display) || {}; + const pairs = [ + ["nav-funds", "m-nav-funds", d.show_nav_funds === false], + ["nav-dashboard", "m-nav-dashboard", d.show_nav_dashboard === false], + ["nav-plan", "m-nav-plan", d.show_nav_plan === false], + ["nav-archive", "m-nav-archive", d.show_nav_archive === false], + ["nav-quotes", "m-nav-quotes", d.show_nav_quotes === false], + ["nav-ai", "m-tab-ai", d.show_nav_ai === false], + ["nav-calculator", "m-tab-calculator", d.show_nav_calculator === false], + ["nav-strategy", "m-nav-strategy", d.show_nav_strategy === false], + ["nav-help", "m-nav-help", d.show_nav_help === false], + ["nav-logs", "m-nav-logs", d.show_nav_logs === false], + ]; + pairs.forEach(([desktopId, mobileId, hide]) => { + const a = document.getElementById(desktopId); + const b = document.getElementById(mobileId); + if (a) a.classList.toggle("nav-hidden", hide); + if (b) b.classList.toggle("nav-hidden", hide); + }); + } + + const HUB_PHONE_PRIMARY = { monitor: 1, market: 1, calculator: 1, ai: 1 }; + + /** 窄屏布局:仅按视口宽度,监控区/行情等共用;手机端 UI 不改 */ + function isMobileLayout() { + return window.matchMedia("(max-width: 720px)").matches; + } + + function syncHubPhoneShellClass() { + document.body.classList.toggle("hub-phone", isMobileLayout()); + document.body.classList.remove("hub-tablet"); + } + + function closeHubMobileMore() { + document.body.classList.remove("hub-mobile-more-open"); + const more = document.getElementById("hub-mobile-more"); + const btn = document.getElementById("m-tab-more"); + if (more) more.setAttribute("aria-hidden", "true"); + if (btn) btn.setAttribute("aria-expanded", "false"); + syncHubMobileTabActive(currentPage()); + } + + function openHubMobileMore() { + if (!isMobileLayout()) return; + document.body.classList.add("hub-mobile-more-open"); + const more = document.getElementById("hub-mobile-more"); + const btn = document.getElementById("m-tab-more"); + if (more) more.setAttribute("aria-hidden", "false"); + if (btn) btn.setAttribute("aria-expanded", "true"); + syncHubMobileTabActive(currentPage()); + } + + function toggleHubMobileMore() { + if (document.body.classList.contains("hub-mobile-more-open")) closeHubMobileMore(); + else openHubMobileMore(); + } + + function syncHubMobileTabActive(page) { + const primary = !!HUB_PHONE_PRIMARY[page]; + const moreOpen = document.body.classList.contains("hub-mobile-more-open"); + document.querySelectorAll(".hub-mobile-tabbar .hub-m-tab").forEach((el) => { + const tab = el.getAttribute("data-hub-tab") || ""; + let on = false; + if (tab === "more") on = moreOpen || !primary; + else on = !moreOpen && tab === page; + el.classList.toggle("active", on); + }); + document.querySelectorAll(".hub-mobile-more-nav a").forEach((a) => { + const href = (a.getAttribute("href") || "").split("?")[0]; + a.classList.toggle("active", href === "/" + page); + }); + } + + function pageNavAllowed(page) { + if (page === "funds") return showNavFundsPref(); + if (page === "dashboard") return showNavDashboardPref(); + if (page === "plan") return showNavPlanPref(); + if (page === "archive") return showNavArchivePref(); + if (page === "quotes") return showNavQuotesPref(); + if (page === "ai") return showNavAiPref(); + if (page === "calculator") return showNavCalculatorPref(); + if (page === "strategy") return showNavStrategyPref(); + if (page === "help") return showNavHelpPref(); + if (page === "logs") return showNavLogsPref(); + return true; + } + + function syncDisplayPrefsUI(data) { + const d = (data && data.display) || {}; + const pnlCb = document.getElementById("pref-show-account-pnl"); + const fundsCb = document.getElementById("pref-show-nav-funds"); + const dashCb = document.getElementById("pref-show-nav-dashboard"); + const planCb = document.getElementById("pref-show-nav-plan"); + const archiveCb = document.getElementById("pref-show-nav-archive"); + const quotesCb = document.getElementById("pref-show-nav-quotes"); + const aiCb = document.getElementById("pref-show-nav-ai"); + const calcCb = document.getElementById("pref-show-nav-calculator"); + const strategyCb = document.getElementById("pref-show-nav-strategy"); + const helpCb = document.getElementById("pref-show-nav-help"); + const logsCb = document.getElementById("pref-show-nav-logs"); + if (pnlCb) pnlCb.checked = d.show_account_pnl !== false; + if (fundsCb) fundsCb.checked = d.show_nav_funds !== false; + if (dashCb) dashCb.checked = d.show_nav_dashboard !== false; + if (planCb) planCb.checked = d.show_nav_plan !== false; + if (archiveCb) archiveCb.checked = d.show_nav_archive !== false; + if (quotesCb) quotesCb.checked = d.show_nav_quotes !== false; + if (aiCb) aiCb.checked = d.show_nav_ai !== false; + if (calcCb) calcCb.checked = d.show_nav_calculator !== false; + if (strategyCb) strategyCb.checked = d.show_nav_strategy !== false; + if (helpCb) helpCb.checked = d.show_nav_help !== false; + if (logsCb) logsCb.checked = d.show_nav_logs !== false; + syncNavVisibility(data); + } + + function syncSupervisorSettingsUI(data) { + const s = (data && data.supervisor) || {}; + const enabled = document.getElementById("supervisor-enabled"); + const prog = document.getElementById("supervisor-wechat-program"); + const webhook = document.getElementById("supervisor-wechat-webhook"); + const link = document.getElementById("supervisor-wechat-link"); + const prefix = document.getElementById("supervisor-wechat-prefix"); + const daily = document.getElementById("supervisor-daily-warn"); + const interval = document.getElementById("supervisor-interval-warn"); + const freq30 = document.getElementById("supervisor-freq-30m"); + const reopen = document.getElementById("supervisor-reopen-min"); + if (enabled) enabled.checked = s.enabled !== false; + if (prog) prog.checked = s.wechat_on_program_tp_sl !== false; + if (webhook) webhook.value = s.wechat_webhook || ""; + if (link) link.value = s.wechat_link_base || ""; + if (prefix) prefix.value = s.wechat_prefix || "【交易监管】"; + if (daily) daily.value = Number(s.manual_close_daily_warn) || 2; + if (interval) interval.value = Number(s.interval_warn_minutes) || 15; + if (freq30) freq30.value = Number(s.freq_30m_count) || 2; + if (reopen) reopen.value = Number(s.reopen_after_close_minutes) || 30; + } + + function positionTableHeadHtml(compact) { + const pnlTh = showAccountPnlPref() ? "浮盈" : ""; + const cls = compact ? " data-table data-table-positions" : ""; + return `${pnlTh}`; + } + let tpslPending = null; + let lastMonitorRows = []; + let monitorGridOptionsSplit = false; + let lastMonitorTotals = null; + let expandedExchangeId = sessionStorage.getItem("hub_expanded_ex") || ""; + const HUB_MONITOR_BOARD_CACHE_KEY = "hub_monitor_board_v1"; + const HUB_MONITOR_CACHE_MAX_AGE_MS = 6 * 60 * 60 * 1000; + const MONITOR_BOARD_SNAPSHOT_URL = "/api/monitor/board/snapshot"; + const HUB_MONITOR_SNAPSHOT_TIMEOUT_MS = 15000; + /** 关注:浮亏超过交易账户余额的比例(10%) */ + const HUB_ALERT_FLOAT_LOSS_RATIO = 0.1; + let lastMonitorBoardUpdatedAt = ""; + let localBoardVersion = 0; + let monitorBoardInFlight = false; + let monitorBoardFetchPending = false; + let monitorBoardSlowHintTimer = null; + let boardEventSource = null; + let sseReconnectTimer = null; + let hostStatusTimer = null; + const HOST_STATUS_POLL_MS = 5000; + const HOST_STATUS_OPEN_KEY = "hub-host-status-open"; + const HOST_RESOURCE_ALERT_THRESHOLD = 85; + const hostResourceAlertLatch = { cpu: false, mem: false }; + + function loadBoolPref(key, defaultValue) { + try { + const raw = localStorage.getItem(key); + if (raw === "1" || raw === "true") return true; + if (raw === "0" || raw === "false") return false; + } catch (_) {} + return !!defaultValue; + } + + function saveBoolPref(key, on) { + try { + localStorage.setItem(key, on ? "1" : "0"); + } catch (_) {} + } + + function fmtHostBytes(n) { + const v = Number(n); + if (!Number.isFinite(v)) return "—"; + const abs = Math.abs(v); + if (abs >= 1e12) return (v / 1e12).toFixed(2) + " TB"; + if (abs >= 1e9) return (v / 1e9).toFixed(2) + " GB"; + if (abs >= 1e6) return (v / 1e6).toFixed(2) + " MB"; + if (abs >= 1e3) return (v / 1e3).toFixed(1) + " KB"; + return v.toFixed(0) + " B"; + } + + function fmtHostUptime(sec) { + const s = Math.max(0, Number(sec) || 0); + const d = Math.floor(s / 86400); + const h = Math.floor((s % 86400) / 3600); + const m = Math.floor((s % 3600) / 60); + if (d > 0) return d + "天" + h + "时"; + if (h > 0) return h + "时" + m + "分"; + return m + "分"; + } + + function hostMetricLevel(percent) { + const p = Number(percent); + if (!Number.isFinite(p)) return "ok"; + if (p >= HOST_RESOURCE_ALERT_THRESHOLD) return "bad"; + return "ok"; + } + + function hostOverallLevel(cpu, mem, disk) { + const vals = [cpu && cpu.percent, mem && mem.percent, disk && disk.percent]; + for (let i = 0; i < vals.length; i++) { + const p = Number(vals[i]); + if (Number.isFinite(p) && p >= HOST_RESOURCE_ALERT_THRESHOLD) return "bad"; + } + return "ok"; + } + + function setHostMetricBar(fillEl, percent) { + if (!fillEl) return; + const p = Math.max(0, Math.min(100, Number(percent) || 0)); + const level = hostMetricLevel(p); + fillEl.style.width = p + "%"; + fillEl.classList.remove("warn", "bad", "ok"); + fillEl.classList.add(level === "bad" ? "bad" : "ok"); + } + + function checkHostResourceAlert(cpu, mem) { + const msgs = []; + const cpuP = Number(cpu && cpu.percent); + if (Number.isFinite(cpuP) && cpuP >= HOST_RESOURCE_ALERT_THRESHOLD) { + if (!hostResourceAlertLatch.cpu) { + msgs.push("CPU 使用率 " + cpuP + "%"); + hostResourceAlertLatch.cpu = true; + } + } else { + hostResourceAlertLatch.cpu = false; + } + const memP = Number(mem && mem.percent); + if (Number.isFinite(memP) && memP >= HOST_RESOURCE_ALERT_THRESHOLD) { + if (!hostResourceAlertLatch.mem) { + msgs.push("内存使用率 " + memP + "%"); + hostResourceAlertLatch.mem = true; + } + } else { + hostResourceAlertLatch.mem = false; + } + if (msgs.length) { + window.alert( + "服务器资源告警\n\n" + msgs.join("\n") + "\n\n请及时关注中控服务器负载." + ); + } + } + + function hostMetricSummaryHtml(label, percent) { + const p = Number(percent); + if (!Number.isFinite(p)) { + return esc(label) + " —"; + } + const tone = hostMetricLevel(p); + return ( + esc(label) + + ' ' + + p + + "%" + ); + } + + function renderHostStatusSummary(data, el) { + if (!el) return; + if (!data || !data.ok) { + el.className = "host-status-summary-text bad"; + el.textContent = (data && data.msg) || "状态不可用"; + return; + } + const cpu = data.cpu || {}; + const mem = data.memory || {}; + const disk = data.disk || {}; + const parts = []; + const host = String(data.hostname || "").trim(); + if (host) { + parts.push('' + esc(host) + ""); + } + if (cpu.percent != null) parts.push(hostMetricSummaryHtml("CPU", cpu.percent)); + if (mem.percent != null) parts.push(hostMetricSummaryHtml("内存", mem.percent)); + if (disk.percent != null) parts.push(hostMetricSummaryHtml("硬盘", disk.percent)); + el.className = "host-status-summary-text"; + el.innerHTML = parts.length + ? parts.join(' · ') + : "—"; + } + + function setHostMetricVal(el, percent) { + if (!el) return; + const p = Number(percent); + el.classList.remove("ok", "bad"); + if (!Number.isFinite(p)) { + el.textContent = "—"; + return; + } + el.textContent = p + "%"; + el.classList.add(hostMetricLevel(p)); + } + + let hostStatusPanelInited = false; + + function initHostStatusPanel() { + const panel = document.getElementById("host-status-panel"); + if (!panel) return; + panel.classList.remove("hidden"); + if (!hostStatusPanelInited) { + panel.open = loadBoolPref(HOST_STATUS_OPEN_KEY, false); + panel.addEventListener("toggle", function () { + saveBoolPref(HOST_STATUS_OPEN_KEY, !!panel.open); + }); + hostStatusPanelInited = true; + } + } + + function renderHostStatusBar(data) { + const panel = document.getElementById("host-status-panel"); + const summaryText = document.getElementById("host-status-summary-text"); + const bar = document.getElementById("host-status-bar"); + if (!panel || !bar) return; + const dot = document.getElementById("host-status-dot"); + const name = document.getElementById("host-status-name"); + const uptime = document.getElementById("host-status-uptime"); + const updated = document.getElementById("host-status-updated"); + const cpuVal = document.getElementById("host-cpu-val"); + const cpuSub = document.getElementById("host-cpu-sub"); + const memVal = document.getElementById("host-mem-val"); + const memSub = document.getElementById("host-mem-sub"); + const diskVal = document.getElementById("host-disk-val"); + const diskSub = document.getElementById("host-disk-sub"); + const netUp = document.getElementById("host-net-up"); + const netDown = document.getElementById("host-net-down"); + panel.classList.remove("hidden"); + renderHostStatusSummary(data, summaryText); + if (!data || !data.ok) { + if (dot) dot.className = "host-status-dot bad"; + if (name) { + name.textContent = "服务器"; + name.title = ""; + } + if (uptime) uptime.textContent = (data && data.msg) || "状态不可用"; + if (updated) updated.textContent = ""; + if (cpuVal) cpuVal.textContent = "—"; + if (cpuSub) cpuSub.textContent = ""; + if (memVal) memVal.textContent = "—"; + if (memSub) memSub.textContent = ""; + if (diskVal) diskVal.textContent = "—"; + if (diskSub) diskSub.textContent = ""; + if (netUp) netUp.textContent = "↑ —"; + if (netDown) netDown.textContent = "↓ —"; + return; + } + const cpu = data.cpu || {}; + const mem = data.memory || {}; + const disk = data.disk || {}; + const net = data.network || {}; + checkHostResourceAlert(cpu, mem); + const overall = hostOverallLevel(cpu, mem, disk); + if (dot) dot.className = "host-status-dot " + overall; + const hostname = data.hostname || "服务器"; + if (name) { + name.textContent = hostname; + name.title = hostname; + } + if (uptime) uptime.textContent = "运行 " + fmtHostUptime(data.uptime_sec); + if (updated) updated.textContent = data.updated_at ? "更新 " + data.updated_at : ""; + setHostMetricBar(document.getElementById("host-cpu-fill"), cpu.percent); + setHostMetricBar(document.getElementById("host-mem-fill"), mem.percent); + setHostMetricBar(document.getElementById("host-disk-fill"), disk.percent); + setHostMetricVal(cpuVal, cpu.percent); + setHostMetricVal(memVal, mem.percent); + setHostMetricVal(diskVal, disk.percent); + if (cpuSub) cpuSub.textContent = cpu.count ? cpu.count + " 核" : ""; + if (memSub) { + memSub.textContent = + fmtHostBytes(mem.used_bytes) + " / " + fmtHostBytes(mem.total_bytes); + } + if (diskSub) { + diskSub.textContent = + fmtHostBytes(disk.used_bytes) + " / " + fmtHostBytes(disk.total_bytes); + } + if (netUp) netUp.textContent = "↑ " + fmtHostBytes(net.sent_rate_bps) + "/s"; + if (netDown) netDown.textContent = "↓ " + fmtHostBytes(net.recv_rate_bps) + "/s"; + } + + async function fetchHostStatus() { + if (currentPage() !== "monitor") return; + try { + const r = await apiFetch("/api/host/status", { credentials: "same-origin" }); + const data = await r.json(); + renderHostStatusBar(data); + } catch (err) { + renderHostStatusBar({ ok: false, msg: String(err && err.message ? err.message : err) }); + } + } + + function stopHostStatusPoll() { + if (hostStatusTimer) { + clearInterval(hostStatusTimer); + hostStatusTimer = null; + } + } + + function startHostStatusPoll() { + stopHostStatusPoll(); + initHostStatusPanel(); + void fetchHostStatus(); + hostStatusTimer = setInterval(fetchHostStatus, HOST_STATUS_POLL_MS); + } + + async function apiFetch(url, opts) { + const r = await fetch(url, opts); + if (r.status === 401) { + const next = encodeURIComponent(location.pathname + location.search); + location.href = "/login?next=" + next; + throw new Error("未登录"); + } + return r; + } + + let instanceFrameUrl = ""; + /** @type {{ exchangeId: string, nextPath: string, title: string } | null} */ + let instanceFrameCtx = null; + + function isHubEmbedded() { + try { + return window.self !== window.top; + } catch (_) { + return true; + } + } + + /** 在 LocalNav 等父页 iframe 内:直接替换本 iframe 地址,避免 postMessage / 三层嵌套 */ + function openInstanceInParentFrame(url) { + try { + window.location.assign(url); + return true; + } catch (_) { + return false; + } + } + + async function fetchInstanceOpenUrl(exchangeId, nextPath, opts) { + const options = opts || {}; + const next = nextPath || "/"; + const q = new URLSearchParams({ exchange_id: String(exchangeId), next }); + if (options.embed) q.set("embed", "1"); + if (options.embed && globalThis.HubTheme && typeof HubTheme.get === "function") { + q.set("hub_theme", HubTheme.get()); + } + const r = await apiFetch("/api/instance/open-url?" + q.toString()); + const j = await r.json(); + if (!j.ok || !j.url) { + throw new Error(j.detail || "无法生成打开链接"); + } + return j.url; + } + + /** @type {number | null} */ + let instanceFrameNavLoadingTimer = null; + + function setInstanceFrameNavLoading(loading) { + const shell = document.getElementById("instance-frame-shell"); + if (!shell) return; + if (instanceFrameNavLoadingTimer != null) { + clearTimeout(instanceFrameNavLoadingTimer); + instanceFrameNavLoadingTimer = null; + } + if (loading) { + instanceFrameNavLoadingTimer = window.setTimeout(() => { + shell.classList.add("is-instance-nav-loading"); + instanceFrameNavLoadingTimer = null; + }, 140); + return; + } + shell.classList.remove("is-instance-nav-loading"); + } + + async function openInstance(exchangeId, nextPath, opts) { + const options = opts || {}; + const newTab = !!options.newTab; + const next = nextPath || "/"; + try { + const embedded = isHubEmbedded(); + const url = await fetchInstanceOpenUrl(exchangeId, next, { + embed: !newTab, + }); + if (newTab) { + window.open(url, "_blank", "noopener"); + return; + } + const row = lastMonitorRows.find((x) => String(x.id) === String(exchangeId)); + const title = row ? row.name : exchangeId; + instanceFrameCtx = { exchangeId: String(exchangeId), nextPath: next, title }; + if (embedded) { + try { + window.parent.postMessage( + { + type: "hub:open-instance-nav", + exchangeId: String(exchangeId), + nextPath: next, + title, + }, + "*" + ); + } catch (_) {} + if (openInstanceInParentFrame(url)) return; + } + openInstanceFrame(url, title); + } catch (e) { + showToast(String(e), true); + } + } + + async function refreshInstanceFrame() { + if (!instanceFrameCtx) { + if (instanceFrameUrl) { + const frame = document.getElementById("instance-frame"); + if (frame) frame.src = instanceFrameUrl; + } + return; + } + try { + const url = await fetchInstanceOpenUrl( + instanceFrameCtx.exchangeId, + instanceFrameCtx.nextPath, + { embed: true } + ); + instanceFrameUrl = url; + const frame = document.getElementById("instance-frame"); + if (frame) { + setInstanceFrameNavLoading(true); + frame.src = url; + } + } catch (e) { + showToast(String(e), true); + } + } + + function openInstanceFrame(url, title) { + const shell = document.getElementById("instance-frame-shell"); + const frame = document.getElementById("instance-frame"); + const titleEl = document.getElementById("instance-frame-title"); + if (!shell || !frame) { + window.open(url, "_blank", "noopener"); + return; + } + closeExchangeFullscreen(); + instanceFrameUrl = url; + if (titleEl) titleEl.textContent = title || "实例"; + setInstanceFrameNavLoading(true); + frame.src = url; + shell.classList.remove("hidden"); + shell.setAttribute("aria-hidden", "false"); + document.body.classList.add("hub-instance-frame-open"); + if (frame.dataset.themeSyncBound !== "1") { + frame.dataset.themeSyncBound = "1"; + frame.addEventListener("load", function syncInstanceFrameTheme() { + requestAnimationFrame(() => { + try { + if (globalThis.HubTheme && typeof HubTheme.get === "function" && frame.contentWindow) { + frame.contentWindow.postMessage( + { type: "hub-theme-sync", theme: HubTheme.get() }, + "*" + ); + } + } catch (_) {} + }); + }); + } + } + + function closeInstanceFrame() { + const shell = document.getElementById("instance-frame-shell"); + const frame = document.getElementById("instance-frame"); + instanceFrameUrl = ""; + instanceFrameCtx = null; + if (frame) frame.src = "about:blank"; + if (shell) { + shell.classList.add("hidden"); + shell.setAttribute("aria-hidden", "true"); + shell.classList.remove("is-instance-nav-loading"); + } + document.body.classList.remove("hub-instance-frame-open"); + } + + /** @deprecated use openInstance */ + async function openInstanceInBrowser(exchangeId, nextPath) { + return openInstance(exchangeId, nextPath, { newTab: false }); + } + + async function initAuth() { + try { + const r = await fetch("/api/auth/status"); + authState = await r.json(); + const btn = document.getElementById("btn-logout"); + if (btn) btn.style.display = authState.required ? "" : "none"; + if (authState.required && !authState.logged_in) { + location.href = + "/login?next=" + encodeURIComponent(location.pathname + location.search); + return false; + } + return true; + } catch (_) { + return true; + } + } + + function showToast(msg, isErr) { + toast.textContent = msg; + toast.style.borderColor = isErr ? "var(--red)" : "var(--border)"; + toast.classList.add("show"); + clearTimeout(showToast._t); + showToast._t = setTimeout(() => toast.classList.remove("show"), 7000); + } + + function esc(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function formatRiskStatusBadge(riskStatus) { + if (!riskStatus || typeof riskStatus !== "object") return ""; + if (window.AccountRiskBadge) return AccountRiskBadge.formatBadgeHtml(riskStatus, esc); + const st = riskStatus.status || "normal"; + const label = esc(riskStatus.status_label || "正常"); + const title = esc(riskStatus.reason || ""); + return `${label}`; + } + + function fmt(n, d) { + if (n === null || n === undefined || Number.isNaN(Number(n))) return "—"; + return Number(n).toLocaleString(undefined, { maximumFractionDigits: d }); + } + + /** 交易所持仓开仓价(三所子代理 entry_price) */ + function positionEntryPrice(pos) { + if (!pos) return null; + const n = Number(pos.entry_price); + if (!Number.isFinite(n) || n <= 0) return null; + return n; + } + + function symbolPriceKey(sym) { + return (sym || "").trim().toUpperCase(); + } + + function buildPriceTickMap(row) { + const map = Object.create(null); + const put = (sym, tick) => { + const k = symbolPriceKey(sym); + if (!k || tick == null || !Number.isFinite(Number(tick))) return; + if (map[k] == null) map[k] = Number(tick); + }; + ((row && row.agent && row.agent.positions) || []).forEach((p) => put(p.symbol, p.price_tick)); + const hm = (row && row.hub_monitor) || {}; + (hm.trends || []).forEach((t) => put(t.exchange_symbol || t.symbol, t.price_tick)); + (hm.orders || []).forEach((o) => put(o.exchange_symbol || o.symbol, o.price_tick)); + return map; + } + + function lookupPriceTick(symbol, tickMap) { + if (!tickMap || !symbol) return null; + const k = symbolPriceKey(symbol); + if (tickMap[k] != null) return tickMap[k]; + const base = normSym(symbol); + if (base && tickMap[base] != null) return tickMap[base]; + return null; + } + + function decimalsFromTick(tick) { + if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) return null; + const t = Number(tick); + if (t >= 1) return 0; + const s = t.toFixed(12).replace(/0+$/, ""); + const frac = s.split(".")[1]; + return frac ? Math.min(12, frac.length) : 0; + } + + function defaultPriceDecimals(value) { + const n = Number(value); + if (!Number.isFinite(n)) return 4; + const av = Math.abs(n); + if (av >= 10000) return 2; + if (av >= 100) return 3; + if (av >= 1) return 4; + if (av >= 0.01) return 6; + return 8; + } + + /** 按交易所 tick(子代理/Flask 下发)格式化价格 */ + function fmtSymbolPrice(value, symbol, tickMap, displayFallback) { + if (displayFallback != null && displayFallback !== "") return String(displayFallback); + if (value == null || value === "") return "—"; + const n = Number(value); + if (!Number.isFinite(n)) return "—"; + const tick = lookupPriceTick(symbol, tickMap); + const d = decimalsFromTick(tick); + return fmt(n, d != null ? d : defaultPriceDecimals(n)); + } + + function fmtEntryPrice(pos, tickMap) { + if (pos && pos.entry_price_fmt) return String(pos.entry_price_fmt); + return fmtSymbolPrice(positionEntryPrice(pos), pos && pos.symbol, tickMap); + } + + function positionMarkPrice(pos) { + if (!pos) return null; + const n = Number(pos.mark_price); + if (!Number.isFinite(n) || n <= 0) return null; + return n; + } + + function fmtMarkPrice(pos, tickMap) { + if (pos && pos.mark_price_fmt) return String(pos.mark_price_fmt); + return fmtSymbolPrice(positionMarkPrice(pos), pos && pos.symbol, tickMap); + } + + function resolveTrendPositionRatioPct(trendPlan) { + const t = trendPlan || {}; + if (t.position_ratio_pct != null && t.position_ratio_pct !== "") { + const n = Number(t.position_ratio_pct); + if (Number.isFinite(n)) return n; + } + const snap = Number(t.snapshot_available_usdt); + const margin = Number(t.plan_margin_capital); + if (Number.isFinite(snap) && snap > 0 && Number.isFinite(margin) && margin > 0) { + return Math.round((margin / snap) * 10000) / 100; + } + return null; + } + + function resolveTrendSizingFooter(mo, trendPlan, isTrend, pos) { + const m = mo || {}; + const p = pos || {}; + if (!isTrend || !trendPlan || !trendPlan.id) { + return { + margin: + m.exchange_initial_margin ?? + p.exchange_initial_margin ?? + m.plan_margin ?? + p.plan_margin ?? + null, + leverage: m.leverage, + planBase: m.margin_capital, + positionRatio: m.position_ratio, + }; + } + const base = + trendPlan.snapshot_available_usdt != null && trendPlan.snapshot_available_usdt !== "" + ? trendPlan.snapshot_available_usdt + : trendPlan.plan_margin_capital; + return { + margin: m.exchange_initial_margin ?? trendPlan.plan_margin_capital ?? null, + leverage: trendPlan.leverage, + planBase: base, + positionRatio: resolveTrendPositionRatioPct(trendPlan), + }; + } + + function resolvePositionOpenMeta(mo, trendPlan, isTrend) { + const useTrend = isTrend && trendPlan && trendPlan.id; + const src = useTrend ? trendPlan : mo || {}; + let ms = Number(src.opened_at_ms); + if (!Number.isFinite(ms) || ms <= 0) { + const s = String(src.opened_at || "").trim(); + if (s) { + const parsed = Date.parse(s.replace(" ", "T")); + ms = Number.isFinite(parsed) ? parsed : null; + } else { + ms = null; + } + } else { + ms = Math.round(ms); + } + let display = "—"; + if (src.opened_at) { + display = String(src.opened_at).replace("T", " ").slice(0, 16); + } else if (ms) { + display = new Date(ms).toISOString().slice(0, 16).replace("T", " "); + } + return { openedAtMs: ms, openedAtDisplay: display }; + } + + function formatLiveHoldDuration(openedMs, nowMs) { + if (openedMs == null || !Number.isFinite(Number(openedMs))) return "—"; + const ms = Number(openedMs); + const now = nowMs != null ? nowMs : Date.now(); + let sec = Math.floor((now - ms) / 1000); + if (sec < 0) sec = 0; + if (sec <= 0) return "0分钟"; + const d = Math.floor(sec / 86400); + sec %= 86400; + const h = Math.floor(sec / 3600); + sec %= 3600; + const m = Math.floor(sec / 60); + const parts = []; + if (d) parts.push(`${d}天`); + if (h) parts.push(`${h}小时`); + if (m || !parts.length) parts.push(`${m}分钟`); + return parts.join(""); + } + + let hubHoldDurationTimer = null; + + function tickHubHoldDurations() { + const now = Date.now(); + document.querySelectorAll(".pos-hold-duration[data-opened-ms]").forEach((el) => { + const ms = Number(el.getAttribute("data-opened-ms")); + if (!Number.isFinite(ms) || ms <= 0) return; + el.textContent = formatLiveHoldDuration(ms, now); + }); + } + + function ensureHubHoldDurationTimer() { + tickHubHoldDurations(); + if (hubHoldDurationTimer) return; + hubHoldDurationTimer = setInterval(tickHubHoldDurations, 1000); + } + + function estimateLatestRiskUsdt(side, entry, sl, pos, mo) { + const e = Number(entry); + const s = Number(sl); + if (!Number.isFinite(e) || !Number.isFinite(s) || e <= 0) return null; + const sd = (side || "long").toLowerCase(); + const rf = sd === "short" ? (s - e) / e : (e - s) / e; + if (!Number.isFinite(rf)) return null; + if (rf <= 0) return 0; + const m = mo || {}; + const p = pos || {}; + let notional = Number(p.notional_usdt); + if (!Number.isFinite(notional) || notional <= 0) { + notional = Number(m.exchange_notional); + } + if (!Number.isFinite(notional) || notional <= 0) { + const mc = Number(m.margin_capital); + const lev = Number(m.leverage); + if (Number.isFinite(mc) && mc > 0 && Number.isFinite(lev) && lev > 0) { + notional = mc * lev; + } + } + if (!Number.isFinite(notional) || notional <= 0) { + const c = Math.abs(Number(p.contracts)); + const cs = Number(p.contract_size); + const mult = Number.isFinite(cs) && cs > 0 ? cs : 1; + const px = Number(p.mark_price); + const mark = Number.isFinite(px) && px > 0 ? px : e; + if (Number.isFinite(c) && c > 0) notional = c * mult * mark; + } + if (!Number.isFinite(notional) || notional <= 0) return null; + return Math.round(notional * rf * 100) / 100; + } + + function formatLatestRiskMeta(mo, trendPlan, pos, tpsl) { + const m = mo || {}; + const t = trendPlan || {}; + let v = + m.latest_risk_amount != null && m.latest_risk_amount !== "" + ? Number(m.latest_risk_amount) + : pos && pos.latest_risk_amount != null && pos.latest_risk_amount !== "" + ? Number(pos.latest_risk_amount) + : t.latest_risk_amount != null && t.latest_risk_amount !== "" + ? Number(t.latest_risk_amount) + : null; + if ((v == null || !Number.isFinite(v)) && tpsl && pos) { + v = estimateLatestRiskUsdt( + pos.side || m.direction, + tpsl.entry, + tpsl.sl, + pos, + m + ); + } + if (v != null && Number.isFinite(v)) { + return `最新风险: ${fmt(v, 2)}U`; + } + return null; + } + + function resolveTpProfitUsdt(mo, pos) { + const m = mo || {}; + const p = pos || {}; + const raw = + m.reward_at_tp_usdt != null && m.reward_at_tp_usdt !== "" + ? m.reward_at_tp_usdt + : p.reward_at_tp_usdt != null && p.reward_at_tp_usdt !== "" + ? p.reward_at_tp_usdt + : null; + if (raw == null || raw === "") return null; + const n = Number(raw); + return Number.isFinite(n) ? n : null; + } + + function formatTpProfitCell(mo, pos) { + const n = resolveTpProfitUsdt(mo, pos); + if (n == null) return "—"; + return `${fmt(n, 2)}U`; + } + + function formatMonitorRiskMeta(mo, trendPlan) { + const m = mo || {}; + const t = trendPlan || {}; + const amt = + m.risk_amount != null && m.risk_amount !== "" + ? Number(m.risk_amount) + : t.risk_amount != null && t.risk_amount !== "" + ? Number(t.risk_amount) + : null; + const pctRaw = + m.risk_percent != null && m.risk_percent !== "" + ? m.risk_percent + : t.risk_percent != null && t.risk_percent !== "" + ? t.risk_percent + : null; + if (pctRaw == null || pctRaw === "") { + if (amt != null && Number.isFinite(amt)) { + return `风险: ${fmt(amt, 2)}U`; + } + return null; + } + const pct = esc(pctRaw); + if (amt != null && Number.isFinite(amt)) { + return `风险: ${pct}%≈${fmt(amt, 2)}U`; + } + return `风险: ${pct}%`; + } + + function resolveTrendMarkPrice(pos, trendPlan, symbol, tickMap) { + const fromPos = fmtMarkPrice(pos, tickMap); + if (fromPos && fromPos !== "—") return fromPos; + const t = trendPlan || {}; + const sym = symbol || (pos && pos.symbol) || t.exchange_symbol || t.symbol || ""; + if (t.floating_mark != null && t.floating_mark !== "") { + return fmtSymbolPrice(t.floating_mark, sym, tickMap); + } + if (t.last_mark_price != null && t.last_mark_price !== "") { + return fmtSymbolPrice(t.last_mark_price, sym, tickMap); + } + return "—"; + } + + function estimateLinearSwapUpnl(side, entry, mark, contracts, contractSize) { + const e = Number(entry); + const m = Number(mark); + const c = Math.abs(Number(contracts)); + let mult = Number(contractSize); + if (!Number.isFinite(mult) || mult <= 0) mult = 1; + if (!Number.isFinite(e) || !Number.isFinite(m) || !Number.isFinite(c) || c <= 0) { + return null; + } + const diff = + (side || "long").toLowerCase() === "long" ? m - e : e - m; + return Math.round(diff * c * mult * 100) / 100; + } + + /** 展示浮盈:子代理 unrealized_pnl;与 entry/mark/张数 推算偏差 >20% 时用推算值 */ + function resolvePositionUpnlUsdt(pos, trendPlan, markOverride) { + const p = pos || {}; + const t = trendPlan || {}; + let exchange = + p.unrealized_pnl != null && p.unrealized_pnl !== "" + ? Number(p.unrealized_pnl) + : null; + if (exchange != null && !Number.isFinite(exchange)) exchange = null; + const entry = + t.avg_entry_price != null && t.avg_entry_price !== "" + ? Number(t.avg_entry_price) + : p.entry_price != null && p.entry_price !== "" + ? Number(p.entry_price) + : t.trigger_price != null + ? Number(t.trigger_price) + : null; + let mark = + markOverride != null && Number.isFinite(Number(markOverride)) + ? Number(markOverride) + : p.mark_price != null && p.mark_price !== "" + ? Number(p.mark_price) + : t.floating_mark != null + ? Number(t.floating_mark) + : t.last_mark_price != null + ? Number(t.last_mark_price) + : null; + const contracts = p.contracts; + const cs = + p.contract_size != null && p.contract_size !== "" + ? Number(p.contract_size) + : 1; + const computed = estimateLinearSwapUpnl( + p.side || t.direction, + entry, + mark, + contracts, + cs + ); + if (computed == null) { + if (exchange != null) return exchange; + if (t.floating_pnl != null && t.floating_pnl !== "") { + const n = Number(t.floating_pnl); + if (Number.isFinite(n)) return n; + } + return null; + } + if (exchange == null) return computed; + const ref = Math.max(Math.abs(computed), 1); + if (Math.abs(exchange - computed) / ref > 0.2) return computed; + return exchange; + } + + function resolveTrendFloatingPnl(pos, trendPlan, markOverride) { + return resolvePositionUpnlUsdt(pos, trendPlan, markOverride); + } + + function formatFloatingPnlText(upnl, notionalUsdt) { + if (upnl == null || !Number.isFinite(Number(upnl))) return { text: "—", cls: "" }; + let pnlText = fmt(upnl, 2) + "U"; + const notional = Number(notionalUsdt); + if (Number.isFinite(notional) && Math.abs(notional) > 1e-8) { + const pct = (Number(upnl) / Math.abs(notional)) * 100; + pnlText += ` (${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%)`; + } + return { text: pnlText, cls: pnlCls(upnl) }; + } + + /** 与实例策略页一致:浮盈亏 % = 浮盈亏 / 计划保证金 */ + function formatTrendPlanFloatingPnl(upnl, planMargin) { + if (upnl == null || !Number.isFinite(Number(upnl))) { + return { text: "—", cls: "" }; + } + let pnlText = fmt(upnl, 2) + "U"; + const margin = Number(planMargin); + if (Number.isFinite(margin) && margin > 0) { + const pct = (Number(upnl) / margin) * 100; + pnlText += ` (${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%)`; + } + const n = Number(upnl); + let cls = "pnl-neutral"; + if (n > 0) cls = "pnl-profit"; + else if (n < 0) cls = "pnl-loss"; + return { text: pnlText, cls }; + } + + function renderDirectionBadge(side) { + const s = normSide(side); + const label = sideDirLabel(side); + const cls = s === "long" ? "direction-long" : s === "short" ? "direction-short" : ""; + if (!cls) return esc(String(label)); + return `${esc(label)}`; + } + + function resolveTrendDcaLevels(t) { + if (Array.isArray(t.dca_levels) && t.dca_levels.length) return t.dca_levels; + const plan = t || {}; + let grid = []; + let legAmounts = []; + try { + grid = JSON.parse(plan.grid_prices_json || "[]"); + if (!Array.isArray(grid)) grid = []; + } catch (_e) { + grid = []; + } + try { + legAmounts = JSON.parse(plan.leg_amounts_json || "[]"); + if (!Array.isArray(legAmounts)) legAmounts = []; + } catch (_e2) { + legAmounts = []; + } + const legsDone = Number(plan.legs_done) || 0; + const dcaLegs = Number(plan.dca_legs) || 0; + const firstDone = Number(plan.first_order_done) !== 0; + const out = [ + { + label: "首仓", + price: null, + contracts: plan.first_order_amount, + status: firstDone ? "done" : "pending", + status_label: firstDone ? "已开仓" : "待开仓", + }, + ]; + const n = Math.max(grid.length, legAmounts.length, dcaLegs); + for (let idx = 0; idx < n; idx += 1) { + const legI = idx + 1; + const done = legI <= legsDone; + out.push({ + label: `补仓${legI}`, + price: idx < grid.length ? grid[idx] : null, + contracts: idx < legAmounts.length ? legAmounts[idx] : null, + status: done ? "done" : "pending", + status_label: done ? "已补仓" : "待补仓", + }); + } + return out; + } + + function pnlCls(v) { + const n = Number(v); + if (!Number.isFinite(n) || n === 0) return ""; + return n > 0 ? "pnl-pos" : "pnl-neg"; + } + + function normSide(side) { + const s = (side || "").toLowerCase(); + if (s === "buy") return "long"; + if (s === "sell") return "short"; + return s; + } + + function sideDirCls(side) { + const s = normSide(side); + if (s === "long") return "side-long"; + if (s === "short") return "side-short"; + return ""; + } + + function sideDirLabel(side) { + const s = normSide(side); + if (s === "long") return "做多"; + if (s === "short") return "做空"; + return side || "—"; + } + + function isTrendHandoffOrder(monitorOrder) { + const mo = monitorOrder || {}; + return String(mo.trade_style || "").toLowerCase() === "trend_pullback_handoff"; + } + + function isTrendContext(monitorOrder, trendPlan) { + const mo = monitorOrder || {}; + const tp = trendPlan || {}; + if (tp.id != null && Number(tp.id) > 0) return true; + const tid = Number(mo.trend_plan_id); + if (Number.isFinite(tid) && tid > 0) return true; + const mt = String(mo.monitor_type || "").trim(); + if (mt === "趋势回调") return true; + const kst = String(mo.key_signal_type || "").trim(); + return kst === "趋势回调" || kst === "趋势回调计划"; + } + + function trendAddZoneLabel(direction) { + return (direction || "long").toLowerCase() === "short" ? "补仓下沿" : "补仓上沿"; + } + + function monitorOrderSourceLabel(mo, trendPlan) { + if (isTrendContext(mo, trendPlan)) return "趋势回调计划"; + const o = mo || {}; + const mt = String(o.monitor_type || "").trim(); + return mt || "下单监控"; + } + + function monitorEntryStyleHtml(mo, intradayDiscipline) { + const o = mo || {}; + if (o.entry_model_label) return `开仓: ${esc(o.entry_model_label)}`; + if (intradayDiscipline) return "开仓: —"; + const ts = String(o.trade_style || "").toLowerCase(); + if (ts === "swing") return "风格: 波段单"; + if (ts === "trend") return "风格: 趋势单"; + if (o.trade_style) return `风格: ${esc(o.trade_style)}`; + return "风格: —"; + } + + function monitorOrderSourceHtml(mo, trendPlan) { + if (isTrendContext(mo, trendPlan)) { + return `来源: ${esc(monitorOrderSourceLabel(mo, trendPlan))}`; + } + const src = monitorOrderSourceLabel(mo, trendPlan); + const kst = String((mo && mo.key_signal_type) || "").trim(); + let text = src; + if (kst && kst !== src && !text.includes(kst)) { + text += " · " + kst; + } + return `来源: ${esc(text)}`; + } + + function renderDirectionHtml(side) { + const cls = sideDirCls(side); + const label = sideDirLabel(side); + if (!cls) return esc(String(label)); + return `${esc(label)}`; + } + + function keyHasPendingOrder(keyRow, keyPrice) { + const kp = keyPrice || {}; + const oid = keyRow.fib_limit_order_id; + if (oid != null && String(oid).trim() !== "") return true; + const gm = String(kp.gate_metrics || ""); + if (gm.includes("限价单") || gm.includes("挂单")) return true; + const gs = String(kp.gate_summary || ""); + if (/挂|限价|等待成交/.test(gs)) return true; + return false; + } + + function fmtKeyOrderAmount(keyRow) { + const raw = keyRow.fib_order_amount; + if (raw == null || raw === "") return ""; + const n = Number(raw); + if (!Number.isFinite(n) || n <= 0) return ""; + return `${fmt(n, 4)} 张`; + } + + /** 全屏持仓区:按仓位数量附加布局 class(1~6 固定列数,7+ 自动填充) */ + function hubPosListCountClass(n) { + const c = Math.max(0, parseInt(n, 10) || 0); + if (c <= 0) return "count-0"; + if (c <= 6) return `count-${c}`; + return "count-many"; + } + + function currentPage() { + const p = window.location.pathname.replace(/\/$/, "") || "/monitor"; + if (p.includes("settings")) return "settings"; + if (p.includes("archive")) return "archive"; + if (p.includes("quotes")) return "quotes"; + if (p.includes("dashboard")) return "dashboard"; + if (p.includes("funds")) return "funds"; + if (p.includes("plan")) return "plan"; + if (p.includes("calculator")) return "calculator"; + if (p.includes("help")) return "help"; + if (p.includes("strategy")) return "strategy"; + if (p.includes("logs")) return "logs"; + if (p.includes("market")) return "market"; + if (p.includes("/ai")) return "ai"; + return "monitor"; + } + + function pageElementId(page) { + if (page === "settings") return "page-settings"; + if (page === "archive") return "page-archive"; + if (page === "quotes") return "page-quotes"; + if (page === "dashboard") return "page-dashboard"; + if (page === "funds") return "page-funds"; + if (page === "plan") return "page-plan"; + if (page === "calculator") return "page-calculator"; + if (page === "help") return "page-help"; + if (page === "strategy") return "page-strategy"; + if (page === "logs") return "page-logs"; + if (page === "market") return "page-market"; + if (page === "ai") return "page-ai"; + return "page-monitor"; + } + + function setActiveNav() { + let page = currentPage(); + if (!pageNavAllowed(page)) { + history.replaceState({}, "", "/monitor"); + page = "monitor"; + } + const pageId = pageElementId(page); + document.querySelectorAll(".top-nav a").forEach((a) => { + const href = (a.getAttribute("href") || "").split("?")[0]; + a.classList.toggle( + "active", + href === "/" + page || (page === "monitor" && (href === "/" || href === "/monitor")) + ); + }); + document.querySelectorAll(".page").forEach((el) => { + el.classList.toggle("hidden", el.id !== pageId); + }); + document.body.classList.toggle("hub-page-ai", page === "ai"); + document.body.classList.toggle("hub-page-funds", page === "funds"); + document.body.classList.toggle("hub-page-dashboard", page === "dashboard"); + document.body.classList.toggle("hub-page-monitor", page === "monitor"); + document.body.classList.toggle("hub-page-market", page === "market"); + document.body.classList.toggle("hub-page-calculator", page === "calculator"); + document.body.classList.toggle("hub-page-settings", page === "settings"); + document.body.classList.toggle("hub-page-archive", page === "archive"); + document.body.classList.toggle("hub-page-quotes", page === "quotes"); + document.body.classList.toggle("hub-page-plan", page === "plan"); + document.body.classList.toggle("hub-page-strategy", page === "strategy"); + document.body.classList.toggle("hub-page-logs", page === "logs"); + document.body.classList.toggle("hub-page-help", page === "help"); + syncHubPhoneShellClass(); + if (HUB_PHONE_PRIMARY[page]) closeHubMobileMore(); + syncHubMobileTabActive(page); + syncHubAiMobileViewport(); + if (page === "monitor") startMonitorPoll(); + else stopMonitorPoll(); + if (page !== "ai") closeSupervisorStream(); + if (page === "dashboard" && window.hubDashboardPage) { + window.hubDashboardPage.init(); + } else if (window.hubDashboardPage && window.hubDashboardPage.destroy) { + window.hubDashboardPage.destroy(); + } + if (page === "settings") loadSettingsUI(); + if (page === "ai") loadAiPage(); + if (page === "archive" && window.hubArchivePage) { + window.hubArchivePage.init(); + } else if (window.hubArchivePage && window.hubArchivePage.destroy) { + window.hubArchivePage.destroy(); + } + if (page === "quotes" && window.hubQuotesPage) { + window.hubQuotesPage.init(); + } else if (window.hubQuotesPage && window.hubQuotesPage.destroy) { + window.hubQuotesPage.destroy(); + } + if (page === "plan" && window.hubPlanPage) { + window.hubPlanPage.init(); + } else if (window.hubPlanPage && window.hubPlanPage.destroy) { + window.hubPlanPage.destroy(); + } + if (page === "calculator" && window.hubCalculatorPage) { + window.hubCalculatorPage.init(); + } + if (page === "funds" && window.hubFundsPage) { + window.hubFundsPage.init(); + } else if (window.hubFundsPage && window.hubFundsPage.destroy) { + window.hubFundsPage.destroy(); + } + if (page === "strategy" && window.hubStrategyPage) { + window.hubStrategyPage.init(); + } else if (window.hubStrategyPage && window.hubStrategyPage.destroy) { + window.hubStrategyPage.destroy(); + } + if (page === "help" && window.hubHelpPage) { + window.hubHelpPage.init(); + } else if (window.hubHelpPage && window.hubHelpPage.destroy) { + window.hubHelpPage.destroy(); + } + if (page === "logs" && window.hubLogsPage) { + window.hubLogsPage.init(); + } else if (window.hubLogsPage && window.hubLogsPage.destroy) { + window.hubLogsPage.destroy(); + } + if (page === "market" && window.hubMarketChart) { + window.hubMarketChart.init(); + } else if (window.hubMarketChart) { + if (window.hubMarketChart.stopChartLive) window.hubMarketChart.stopChartLive(); + else { + if (window.hubMarketChart.stopAutoRefresh) window.hubMarketChart.stopAutoRefresh(); + } + if (window.hubMarketChart.stopPriceTagTimer) window.hubMarketChart.stopPriceTagTimer(); + } + } + + function stopMonitorPoll() { + closeMonitorBoardStream(); + stopHostStatusPoll(); + stopMacroBannerPoll(); + if (sseReconnectTimer) { + clearTimeout(sseReconnectTimer); + sseReconnectTimer = null; + } + } + + function closeMonitorBoardStream() { + if (boardEventSource) { + boardEventSource.close(); + boardEventSource = null; + } + } + + function connectMonitorBoardStream() { + closeMonitorBoardStream(); + if (!document.getElementById("auto-monitor")?.checked) return; + if (currentPage() !== "monitor") return; + boardEventSource = new EventSource("/api/monitor/board/stream"); + boardEventSource.addEventListener("board", (ev) => { + try { + const st = JSON.parse(ev.data || "{}"); + const ver = Number(st.board_version) || 0; + if (ver !== localBoardVersion) { + void fetchMonitorBoardSnapshot({ background: true }); + } else if (st.aggregating && lastMonitorRows.length) { + applyMonitorBoardUi(lastMonitorRows, st.updated_at || lastMonitorBoardUpdatedAt, { + stale: true, + }); + } + } catch (_) {} + }); + boardEventSource.onerror = () => { + closeMonitorBoardStream(); + if (sseReconnectTimer) clearTimeout(sseReconnectTimer); + sseReconnectTimer = setTimeout(() => { + if (currentPage() === "monitor" && document.getElementById("auto-monitor")?.checked) { + connectMonitorBoardStream(); + void fetchMonitorBoardSnapshot({ background: true }); + } + }, 8000); + }; + } + + async function requestMonitorBoardRefresh() { + await apiFetch("/api/monitor/board/refresh", { method: "POST" }); + } + + function clearMonitorBoardSlowHint() { + if (monitorBoardSlowHintTimer) { + clearTimeout(monitorBoardSlowHintTimer); + monitorBoardSlowHintTimer = null; + } + } + + function scheduleMonitorBoardSlowHint(box) { + clearMonitorBoardSlowHint(); + if (!box) return; + monitorBoardSlowHintTimer = setTimeout(() => { + if (lastMonitorRows.length) return; + const el = box.querySelector(".board-loading"); + if (!el) return; + const sub = el.querySelector(".board-loading-sub"); + if (sub) { + sub.textContent = + "后台首次聚合较慢(三所子代理 + Flask).可检查 PM2,或设 HUB_BOARD_KEY_PRICES=false 加速."; + } + }, 12000); + } + + function saveMonitorBoardCache(rows, updatedAt, boardVersion, totals) { + try { + sessionStorage.setItem( + HUB_MONITOR_BOARD_CACHE_KEY, + JSON.stringify({ + version: 2, + board_version: boardVersion != null ? boardVersion : localBoardVersion, + updated_at: updatedAt || "", + rows: rows || [], + totals: totals || null, + saved_at: Date.now(), + }) + ); + } catch (_) {} + } + + function loadMonitorBoardFromCache() { + try { + const raw = sessionStorage.getItem(HUB_MONITOR_BOARD_CACHE_KEY); + if (!raw) return null; + const data = JSON.parse(raw); + if (!data || !Array.isArray(data.rows) || !data.rows.length) return null; + const age = Date.now() - Number(data.saved_at || 0); + if (!Number.isFinite(age) || age > HUB_MONITOR_CACHE_MAX_AGE_MS) { + sessionStorage.removeItem(HUB_MONITOR_BOARD_CACHE_KEY); + return null; + } + return data; + } catch (_) { + return null; + } + } + + function restoreMonitorBoardFromCache() { + const cached = loadMonitorBoardFromCache(); + if (!cached) return false; + lastMonitorRows = cached.rows; + lastMonitorTotals = cached.totals || null; + lastMonitorBoardUpdatedAt = cached.updated_at || ""; + localBoardVersion = 0; + applyMonitorBoardUi(cached.rows, lastMonitorBoardUpdatedAt, { stale: true }); + return true; + } + + function applyMonitorBoardUi(rows, updatedAt, opts) { + const options = opts || {}; + const tsRaw = updatedAt || lastMonitorBoardUpdatedAt || ""; + if (updatedAt) lastMonitorBoardUpdatedAt = updatedAt; + const online = (rows || []).filter((x) => x.http_ok && (x.agent || {}).ok !== false).length; + const pill = document.getElementById("sys-status"); + if (pill) { + pill.textContent = rows.length ? `LINK ${online}/${rows.length}` : "NO DATA"; + pill.classList.toggle("warn", rows.length && online < rows.length); + if (options.stale) pill.classList.add("syncing"); + else pill.classList.remove("syncing"); + } + const upd = document.getElementById("monitor-updated"); + const updSum = document.getElementById("monitor-updated-summary"); + if (upd || updSum) { + const ts = tsRaw.replace("T", " "); + const txt = options.stale + ? ts + ? `缓存 ${ts} · 后台聚合中…` + : "后台聚合中…" + : ts + ? `UPD ${ts}` + : ""; + if (upd) upd.textContent = txt; + if (updSum) updSum.textContent = txt; + } + updateMonitorAlertSummary(rows || []); + void refreshMacroRiskBanner(rows || []); + renderMonitorGrid(rows || []); + } + + let macroBannerTimer = null; + let macroCalendarEditId = null; + + function monitorHasOpenPositions(rows) { + return (rows || []).some((row) => { + const pos = (row.agent && row.agent.positions) || []; + return Array.isArray(pos) && pos.length > 0; + }); + } + + function macroAlertMessage(alert, hasPositions) { + const label = alert.event_type_label || alert.event_type || "宏观数据"; + const phase = alert.phase || "window"; + const mins = Number(alert.minutes_to_event || 0); + if (hasPositions) { + if (phase === "imminent" && mins > 0) { + return ( + `「${label}」即将发布(约 ${mins} 分钟),` + + "注意仓位风险:勿加仓,检查止损/减仓" + ); + } + return `「${label}」高波动窗口(±1h),注意仓位风险:勿加仓,检查止损/减仓`; + } + if (phase === "imminent" && mins > 0) { + return `「${label}」即将发布(约 ${mins} 分钟),建议等待,避免新开仓`; + } + return `「${label}」高波动窗口(±1h),建议等待,避免新开仓`; + } + + async function refreshMacroRiskBanner(rows) { + if (currentPage() !== "monitor") return; + const el = document.getElementById("monitor-macro-banner"); + const textEl = document.getElementById("monitor-macro-banner-text"); + if (!el || !textEl) return; + try { + const r = await apiFetch("/api/macro-calendar/active"); + const j = await r.json(); + const alerts = (j.ok && j.alerts) || []; + if (!alerts.length) { + el.classList.add("hidden"); + el.classList.remove("phase-imminent"); + textEl.textContent = ""; + return; + } + const alert = alerts[0]; + const hasPos = monitorHasOpenPositions(rows || lastMonitorRows); + textEl.textContent = macroAlertMessage(alert, hasPos); + el.classList.toggle("phase-imminent", alert.phase === "imminent"); + el.classList.remove("hidden"); + } catch (_) { + el.classList.add("hidden"); + } + } + + function startMacroBannerPoll() { + stopMacroBannerPoll(); + if (currentPage() !== "monitor") return; + void refreshMacroRiskBanner(lastMonitorRows); + macroBannerTimer = setInterval(() => { + if (currentPage() === "monitor") void refreshMacroRiskBanner(lastMonitorRows); + }, 30000); + } + + function stopMacroBannerPoll() { + if (macroBannerTimer) { + clearInterval(macroBannerTimer); + macroBannerTimer = null; + } + } + + function startMonitorPoll() { + const hadCache = restoreMonitorBoardFromCache(); + void fetchMonitorBoardSnapshot({ showLoading: !hadCache }); + connectMonitorBoardStream(); + startHostStatusPoll(); + startMacroBannerPoll(); + } + + async function loadSettings() { + const r = await apiFetch("/api/settings"); + settingsCache = await r.json(); + syncNavVisibility(settingsCache); + return settingsCache; + } + + function enabledAccounts() { + return (settingsCache?.exchanges || []).filter((x) => x.enabled); + } + + /** AI 教练手机布局:窄屏或手机 PWA(桌面安装的 App 仍走桌面布局) */ + function isMobileAiLayout() { + if (isMobileLayout()) return true; + if ( + window.matchMedia("(display-mode: standalone)").matches && + window.matchMedia("(max-width: 960px)").matches + ) { + return true; + } + if (window.navigator && window.navigator.standalone === true) return true; + return false; + } + + function positionHasContracts(p) { + const c = Number(p && p.contracts); + return Number.isFinite(c) && Math.abs(c) >= 1e-12; + } + + function exchangeNeedsFlask(row) { + const caps = row.capabilities || []; + return caps.includes("key") || caps.includes("trend"); + } + + function positionMissingStopLoss(pos, orders, trends) { + if (!positionHasContracts(pos)) return false; + const mo = findMonitorOrder(orders, pos.symbol, pos.side); + const tp = findTrendPlan(trends, pos.symbol, pos.side); + const tpsl = resolvePositionTpsl(pos, mo, tp); + const sl = tpsl.sl; + if (sl !== "" && sl != null && Number.isFinite(Number(sl))) return false; + const cond = condOrdersFromPosition(pos); + const picked = pickExTpslOrders(cond); + if (picked.sl && picked.sl.trigger_price != null) return false; + const et = pos.exchange_tpsl; + if (et && et.sl) return false; + return true; + } + + function analyzeExchangeAlert(row) { + const ag = row.agent || {}; + const hm = row.hub_monitor || {}; + const pos = Array.isArray(ag.positions) ? ag.positions : []; + const flaskOk = row.flask_ok !== false && hm.ok !== false; + const upnl = Number(ag.total_unrealized_pnl); + const tradingBal = Number(row.trading_usdt); + const balance = + Number.isFinite(tradingBal) && tradingBal > 0 + ? tradingBal + : Number(ag.balance_usdt); + const sortUpnl = Number.isFinite(upnl) ? upnl : 0; + + if (!row.http_ok) { + return { level: "error", summary: "子代理离线", sortUpnl: 0 }; + } + if (ag.ok === false) { + return { + level: "error", + summary: (ag.error || row.error || "子代理异常").slice(0, 24), + sortUpnl: 0, + }; + } + if (exchangeNeedsFlask(row) && !flaskOk) { + const fe = row.flask_error || hm.error || hm.msg || "Flask未连通"; + return { level: "error", summary: String(fe).slice(0, 24), sortUpnl }; + } + + const orders = flaskOk ? hm.orders || [] : []; + const trends = flaskOk ? hm.trends || [] : []; + let missingSl = false; + for (const p of pos) { + if (positionMissingStopLoss(p, orders, trends)) { + missingSl = true; + break; + } + } + + if (Number.isFinite(upnl) && upnl < 0 && Number.isFinite(balance) && balance > 0) { + const lossPct = (Math.abs(upnl) / balance) * 100; + if (lossPct >= HUB_ALERT_FLOAT_LOSS_RATIO * 100) { + return { + level: "warn", + summary: `浮亏超10% · ${fmt(upnl, 2)}U`, + sortUpnl, + }; + } + } + if (missingSl) { + return { level: "warn", summary: "缺止损", sortUpnl }; + } + + const openCount = pos.filter(positionHasContracts).length; + return { + level: "ok", + summary: openCount ? "正常" : "空仓", + sortUpnl, + }; + } + + function sortRowsForMobileDashboard(rows) { + const levelOrder = { error: 0, warn: 1, ok: 2 }; + return rows + .map((r) => ({ r, a: analyzeExchangeAlert(r) })) + .sort((x, y) => { + const ld = levelOrder[x.a.level] - levelOrder[y.a.level]; + if (ld !== 0) return ld; + return (x.a.sortUpnl || 0) - (y.a.sortUpnl || 0); + }) + .map((x) => x.r); + } + + function updateMonitorAlertSummary(rows) { + const el = document.getElementById("monitor-alert-summary"); + if (!el) return; + if (!isMobileLayout() || !rows.length) { + el.classList.add("hidden"); + el.innerHTML = ""; + return; + } + let err = 0; + let warn = 0; + let ok = 0; + rows.forEach((r) => { + const lv = analyzeExchangeAlert(r).level; + if (lv === "error") err += 1; + else if (lv === "warn") warn += 1; + else ok += 1; + }); + el.classList.remove("hidden"); + el.innerHTML = `正常 ${ok}·关注 ${warn}·异常 ${err}`; + } + + /** 监控卡片列数:桌面 2×2(统计+三所);期权分栏时由 CSS 控制 */ + function syncMonitorGridColumns(gridEl, itemCount, opts) { + if (!gridEl) return; + const options = opts || {}; + if (options.optionsSplit) { + gridEl.style.gridTemplateColumns = ""; + return; + } + if (isMobileLayout()) { + // 手机一律单列:统计卡与三所卡不再并排挤字 + gridEl.style.gridTemplateColumns = "1fr"; + return; + } + if (options.statsFirst) { + gridEl.style.gridTemplateColumns = "repeat(2, minmax(0, 1fr))"; + return; + } + let cols = 3; + if (itemCount <= 1) cols = 1; + else if (itemCount === 2) cols = 2; + else if (itemCount === 3) cols = 3; + else if (itemCount === 4) cols = 2; + else cols = 3; + gridEl.style.gridTemplateColumns = `repeat(${cols}, minmax(0, 1fr))`; + } + + const AI_MOBILE_TAB_KEY = "hub_ai_mobile_tab"; + const AI_MOBILE_CHAT_TABS = new Set(["trading", "general", "supervisor"]); + let aiSupervisorSessionCache = null; + let supervisorEventSource = null; + let localSupervisorVersion = 0; + let supervisorReconnectTimer = null; + + function isSupervisorMode() { + return aiSelectedBotMode === "supervisor"; + } + + function normalizeAiBotMode(mode) { + const m = (mode || "").trim().toLowerCase(); + if (m === "general") return "general"; + if (m === "supervisor") return "supervisor"; + return "trading"; + } + + function normalizeAiMobileTab(tab) { + const raw = (tab || "").trim().toLowerCase(); + if (raw === "chat") return "trading"; + if (AI_MOBILE_CHAT_TABS.has(raw) || raw === "history") return raw; + return "trading"; + } + + function applyAiMobileTab(tab) { + const layout = document.querySelector(".ai-layout"); + const tabs = document.querySelectorAll(".ai-mobile-tab"); + if (!layout) return; + const mobile = isMobileAiLayout(); + if (!mobile) { + delete layout.dataset.aiMobileTab; + tabs.forEach((btn) => { + btn.classList.remove("is-active"); + btn.setAttribute("aria-selected", "false"); + }); + return; + } + const active = normalizeAiMobileTab( + tab || localStorage.getItem(AI_MOBILE_TAB_KEY) || "trading" + ); + layout.dataset.aiMobileTab = active; + tabs.forEach((btn) => { + const t = btn.dataset.aiTab || ""; + const on = t === active; + btn.classList.toggle("is-active", on); + btn.setAttribute("aria-selected", on ? "true" : "false"); + }); + if (AI_MOBILE_CHAT_TABS.has(active)) { + updateAiBotTabs(active); + if (active === "supervisor") { + void loadAiSupervisorSession().then(() => connectSupervisorStream()); + } else { + closeSupervisorStream(); + } + scrollAiChatToEnd(); + } + if (active === "history") { + const hist = document.getElementById("ai-chat-history-list"); + if (hist) hist.scrollTop = 0; + } + } + + function initAiMobileTabs() { + const tabs = document.querySelectorAll(".ai-mobile-tab"); + if (!tabs.length) return; + tabs.forEach((btn) => { + btn.addEventListener("click", () => { + const tab = btn.dataset.aiTab || "trading"; + if (tab === "new") { + const prev = normalizeAiMobileTab(localStorage.getItem(AI_MOBILE_TAB_KEY) || "trading"); + const botMode = prev === "general" ? "general" : prev === "supervisor" ? "supervisor" : "trading"; + if (botMode === "supervisor") { + void switchToSupervisorMode(); + } else { + void newAiChat(botMode); + } + return; + } + if (tab === "supervisor") { + void switchToSupervisorMode(); + return; + } + localStorage.setItem(AI_MOBILE_TAB_KEY, tab); + applyAiMobileTab(tab); + if (AI_MOBILE_CHAT_TABS.has(tab)) { + const input = document.getElementById("ai-chat-input"); + if (input && isMobileAiLayout()) input.focus(); + } + }); + }); + window.addEventListener("resize", () => applyAiMobileTab()); + applyAiMobileTab(); + } + + let syncHubAiMobileViewport = () => {}; + + function initHubAiMobileViewport() { + const shell = document.querySelector(".app-shell"); + const chatInput = document.getElementById("ai-chat-input"); + if (!shell || !window.visualViewport) { + syncHubAiMobileViewport = () => {}; + return; + } + + let baselineInnerH = Math.max(window.innerHeight, window.visualViewport.height || 0); + + const scrollChatToEnd = () => { + const box = document.getElementById("ai-chat-messages"); + if (box) requestAnimationFrame(() => { box.scrollTop = box.scrollHeight; }); + }; + + syncHubAiMobileViewport = () => { + const onAi = document.body.classList.contains("hub-page-ai"); + if (!onAi || !isMobileAiLayout()) { + shell.style.removeProperty("height"); + shell.style.removeProperty("max-height"); + shell.style.removeProperty("width"); + shell.style.removeProperty("transform"); + document.documentElement.style.removeProperty("--hub-vvh"); + document.body.classList.remove("hub-ai-keyboard-open"); + return; + } + const vv = window.visualViewport; + const h = Math.max(240, Math.round(vv.height)); + const top = Math.round(vv.offsetTop || 0); + const left = Math.round(vv.offsetLeft || 0); + const inputFocused = !!(chatInput && document.activeElement === chatInput); + if (!inputFocused) { + baselineInnerH = Math.max(baselineInnerH, window.innerHeight, h); + } + document.documentElement.style.setProperty("--hub-vvh", `${h}px`); + shell.style.height = `${h}px`; + shell.style.maxHeight = `${h}px`; + shell.style.width = `${Math.round(vv.width)}px`; + shell.style.transform = + top > 0 || left > 0 ? `translate(${left}px, ${top}px)` : ""; + const viewportShrunk = h < baselineInnerH * 0.72; + const keyboardLikely = inputFocused && (viewportShrunk || top > 48); + document.body.classList.toggle("hub-ai-keyboard-open", keyboardLikely); + }; + + window.visualViewport.addEventListener("resize", syncHubAiMobileViewport); + window.visualViewport.addEventListener("scroll", syncHubAiMobileViewport); + window.addEventListener("resize", syncHubAiMobileViewport); + window.addEventListener("orientationchange", () => { + setTimeout(syncHubAiMobileViewport, 80); + }); + + if (chatInput) { + chatInput.addEventListener("focus", () => { + syncHubAiMobileViewport(); + scrollChatToEnd(); + setTimeout(syncHubAiMobileViewport, 50); + setTimeout(syncHubAiMobileViewport, 280); + }); + chatInput.addEventListener("blur", () => { + setTimeout(syncHubAiMobileViewport, 80); + setTimeout(syncHubAiMobileViewport, 320); + }); + } + syncHubAiMobileViewport(); + } + + function syncHubPhoneFolds() { + const phone = isMobileLayout(); + ["monitor-ops-fold", "market-toolbar-fold"].forEach((id) => { + const el = document.getElementById(id); + if (!el) return; + if (!phone) { + el.open = true; + return; + } + // 手机默认折叠;用户手动点开后本会话不再强关 + if (el.dataset.userToggled === "1") return; + el.open = false; + }); + } + + function bindHubPhoneFolds() { + ["monitor-ops-fold", "market-toolbar-fold"].forEach((id) => { + const el = document.getElementById(id); + if (!el || el.dataset.boundFold === "1") return; + el.dataset.boundFold = "1"; + el.addEventListener("toggle", () => { + if (!isMobileLayout()) return; + el.dataset.userToggled = "1"; + }); + }); + const sym = document.getElementById("market-symbol"); + const tf = document.getElementById("market-timeframe"); + const syncMarketFoldMeta = () => { + const meta = document.getElementById("market-fold-summary"); + if (!meta) return; + const s = sym ? String(sym.value || "").trim() : ""; + const t = tf ? String(tf.value || "").trim() : ""; + meta.textContent = [s || "—", t || "—"].join(" · "); + }; + if (sym) sym.addEventListener("change", syncMarketFoldMeta); + if (sym) sym.addEventListener("input", syncMarketFoldMeta); + if (tf) tf.addEventListener("change", syncMarketFoldMeta); + syncMarketFoldMeta(); + } + + function initHubMobileChrome() { + const moreBtn = document.getElementById("m-tab-more"); + const backdrop = document.getElementById("hub-mobile-more-backdrop"); + const closeBtn = document.getElementById("hub-mobile-more-close"); + if (moreBtn) { + moreBtn.addEventListener("click", (ev) => { + ev.preventDefault(); + toggleHubMobileMore(); + }); + } + if (backdrop) backdrop.addEventListener("click", closeHubMobileMore); + if (closeBtn) closeBtn.addEventListener("click", closeHubMobileMore); + document.addEventListener("keydown", (ev) => { + if (ev.key === "Escape" && document.body.classList.contains("hub-mobile-more-open")) { + closeHubMobileMore(); + } + }); + bindHubPhoneFolds(); + syncHubPhoneShellClass(); + syncHubPhoneFolds(); + } + + function bindHubSpaNavLinks(selector) { + document.querySelectorAll(selector).forEach((a) => { + a.addEventListener("click", (ev) => { + const href = a.getAttribute("href"); + if (!href || ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) return; + ev.preventDefault(); + closeHubMobileMore(); + const path = href.split("?")[0]; + if (path === window.location.pathname) { + setActiveNav(); + return; + } + history.pushState({}, "", href); + setActiveNav(); + }); + }); + } + + function initMobileLayout() { + initAiMobileTabs(); + initHubAiMobileViewport(); + initHubMobileChrome(); + let resizeTimer = null; + let wasMobile = isMobileLayout(); + window.addEventListener("resize", () => { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => { + const nowMobile = isMobileLayout(); + syncHubPhoneShellClass(); + syncHubPhoneFolds(); + if (!nowMobile) closeHubMobileMore(); + if (lastMonitorRows.length && nowMobile !== wasMobile) { + wasMobile = nowMobile; + renderMonitorGrid(lastMonitorRows); + updateMonitorAlertSummary(lastMonitorRows); + syncHubMobileTabActive(currentPage()); + return; + } + wasMobile = nowMobile; + const box = document.getElementById("monitor-grid"); + if (box && lastMonitorRows.length) { + const split = monitorOptionsSplitActive(lastMonitorRows); + syncMonitorGridColumns(box, lastMonitorRows.length + (lastMonitorTotals ? 1 : 0), { + statsFirst: !!lastMonitorTotals && !split, + optionsSplit: split, + }); + updateMonitorAlertSummary(lastMonitorRows); + } + syncHubMobileTabActive(currentPage()); + }, 120); + }); + } + + function normSym(s) { + return String(s || "") + .toUpperCase() + .replace(/:USDT$/i, "") + .replace(/\/USDT:USDT$/i, "") + .replace(/\/USDT$/i, ""); + } + + function symbolsMatchHub(a, b) { + const x = normSym(a); + const y = normSym(b); + if (!x || !y) return false; + return x === y; + } + + function ordersCollapseKey(exchangeId, symbol) { + const sym = normSym(symbol) || "unknown"; + return `hub_orders_${exchangeId}_${sym}`; + } + + function isOrdersCollapseOpen(exchangeId, symbol) { + return localStorage.getItem(ordersCollapseKey(exchangeId, symbol)) === "1"; + } + + function condOrderRole(o) { + const lb = (o && o.label) || ""; + if (/止盈止损/.test(lb)) return null; + if (/止损/.test(lb)) return "sl"; + if (/止盈/.test(lb)) return "tp"; + return null; + } + + function dedupeCondOrdersByRole(orders) { + const list = Array.isArray(orders) ? orders : []; + const byRole = {}; + const others = []; + for (const o of list) { + const role = condOrderRole(o); + if (role) byRole[role] = o; + else others.push(o); + } + const out = others.slice(); + if (byRole.tp) out.push(byRole.tp); + if (byRole.sl) out.push(byRole.sl); + return out; + } + + function dedupeCondOrdersByTrigger(orders) { + const list = Array.isArray(orders) ? orders : []; + const seen = new Set(); + const out = []; + for (const o of list) { + const px = orderTriggerOrPrice(o); + const key = + px != null + ? "t:" + String(px) + : o && o.id + ? "id:" + String(o.id) + : null; + if (key && seen.has(key)) continue; + if (key) seen.add(key); + out.push(o); + } + return out; + } + + function upsertExTpslCondOrder(cond, role, slot) { + if (!slot || slot.trigger_price == null || slot.trigger_price === "") return; + const label = role === "sl" ? "止损" : "止盈"; + const item = { + label: label, + trigger_price: Number(slot.trigger_price), + amount: slot.amount != null ? slot.amount : null, + id: slot.order_id || "", + channel: "algo", + }; + const idx = cond.findIndex(function (o) { + const lb = o.label || ""; + return role === "sl" ? /^止损\b/.test(lb) || lb.includes("止损") : /^止盈\b/.test(lb) || lb.includes("止盈"); + }); + if (idx >= 0) cond[idx] = Object.assign({}, cond[idx], item); + else cond.push(item); + } + + function condOrdersFromPosition(pos) { + let cond = dedupeCondOrdersByRole( + Array.isArray(pos.conditional_orders) ? pos.conditional_orders : [] + ); + cond = dedupeCondOrdersByTrigger(cond); + const et = pos.exchange_tpsl; + if (!et) return cond; + upsertExTpslCondOrder(cond, "sl", et.sl); + upsertExTpslCondOrder(cond, "tp", et.tp); + return cond; + } + + function findMonitorOrder(orders, symbol, side) { + const want = (side || "").toLowerCase(); + for (const o of orders || []) { + const sym = o.exchange_symbol || o.symbol || ""; + if (!symbolsMatchHub(sym, symbol)) continue; + const d = (o.direction || "").toLowerCase(); + if (!d || d === want) return o; + } + return null; + } + + function calcRrRatio(side, entry, sl, tp) { + const e = Number(entry); + const s = Number(sl); + const t = Number(tp); + if (![e, s, t].every((n) => Number.isFinite(n) && n > 0)) return null; + if ((side || "long").toLowerCase() === "short") { + const risk = s - e; + const reward = e - t; + if (risk <= 0 || reward <= 0) return null; + return reward / risk; + } + const risk = e - s; + const reward = t - e; + if (risk <= 0 || reward <= 0) return null; + return reward / risk; + } + + function resolveTrendPlanRr(trendPlan, side, entry, sl, tp) { + const t = trendPlan || {}; + if (t.money_rr != null && t.money_rr !== "") { + const n = Number(t.money_rr); + if (Number.isFinite(n) && n > 0) return n; + } + if (t.planned_rr != null && t.planned_rr !== "") { + const n = Number(t.planned_rr); + if (Number.isFinite(n) && n > 0) return n; + } + const e = t.avg_entry_price != null && t.avg_entry_price !== "" ? t.avg_entry_price : entry; + const s = t.stop_loss != null && t.stop_loss !== "" ? t.stop_loss : sl; + const p = t.take_profit != null && t.take_profit !== "" ? t.take_profit : tp; + return calcRrRatio(side, e, s, p); + } + + function resolveSnapshotRr(mo, side, entry, sl, tp, tpMonitored, trendPlan) { + if (tpMonitored && isTrendContext(mo, trendPlan)) { + const rr = resolveTrendPlanRr(trendPlan, side, entry, sl, tp); + if (rr != null) return rr; + } + if (tpMonitored) return null; + const snap = mo && mo.rr_ratio; + if (snap != null && snap !== "") { + const n = Number(snap); + if (Number.isFinite(n)) return n; + } + const initSl = mo && (mo.initial_stop_loss != null ? mo.initial_stop_loss : mo.stop_loss); + return calcRrRatio(side, entry, initSl || sl, tp); + } + + function formatTpCellValue(tp, tpMonitored, symbol, tickMap) { + if (tpMonitored) { + if (tp != null && tp !== "") { + return `程序监控 · ${fmtSymbolPrice(tp, symbol, tickMap)}`; + } + return "程序监控"; + } + if (tp != null && tp !== "") return fmtSymbolPrice(tp, symbol, tickMap); + return "—"; + } + + function isBreakevenSecured(side, entry, monitorOrder, cond, pos) { + const mo = monitorOrder || {}; + const p = pos || {}; + const { sl } = pickExTpslOrders(cond); + const trig = sl && sl.trigger_price != null ? Number(sl.trigger_price) : NaN; + const liveEntry = + p.entry_price != null && p.entry_price !== "" + ? Number(p.entry_price) + : mo.avg_entry_price != null && mo.avg_entry_price !== "" + ? Number(mo.avg_entry_price) + : Number(entry); + if (Number.isFinite(trig) && Number.isFinite(liveEntry)) { + if ((side || "long").toLowerCase() === "short") return trig <= liveEntry; + return trig >= liveEntry; + } + if (mo.sl_breakeven_secured === true || mo.sl_breakeven_secured === 1) return true; + if (p.sl_breakeven_secured === true || p.sl_breakeven_secured === 1) return true; + return false; + } + + function breakevenBadgeHtml() { + return `已保本`; + } + + async function fetchMonitorBoardSnapshot(opts) { + const options = opts || {}; + const background = !!options.background; + const showLoading = !!options.showLoading && !lastMonitorRows.length; + const box = document.getElementById("monitor-grid"); + if (monitorBoardInFlight) { + if (background) monitorBoardFetchPending = true; + else return; + } + if (showLoading && box) { + box.innerHTML = + '
        正在加载监控快照…

        '; + scheduleMonitorBoardSlowHint(box); + } else if (background && lastMonitorRows.length) { + applyMonitorBoardUi(lastMonitorRows, null, { stale: true }); + } + monitorBoardInFlight = true; + const ctrl = new AbortController(); + const fetchTimer = setTimeout(() => ctrl.abort(), HUB_MONITOR_SNAPSHOT_TIMEOUT_MS); + try { + const r = await apiFetch(MONITOR_BOARD_SNAPSHOT_URL, { signal: ctrl.signal }); + const data = await r.json(); + if (!r.ok) { + throw new Error(data.msg || data.detail || `HTTP ${r.status}`); + } + const ver = Number(data.board_version) || 0; + const rows = data.rows || []; + const waitingFirst = data.aggregating && !rows.length && ver <= localBoardVersion; + if (waitingFirst && showLoading) { + if (box) { + const sub = box.querySelector(".board-loading-sub"); + if (sub) sub.textContent = "后台正在首次聚合三所数据(约 5~15 秒)…"; + } + return; + } + const ts = data.updated_at || ""; + const versionChanged = ver !== localBoardVersion; + const timeChanged = ts && ts !== lastMonitorBoardUpdatedAt; + if (versionChanged || timeChanged || !lastMonitorRows.length) { + localBoardVersion = ver; + lastMonitorRows = rows; + lastMonitorTotals = data.totals || null; + saveMonitorBoardCache(lastMonitorRows, ts, ver, lastMonitorTotals); + applyMonitorBoardUi(lastMonitorRows, ts, { + stale: !!data.aggregating, + }); + } else if (data.aggregating && lastMonitorRows.length) { + applyMonitorBoardUi(lastMonitorRows, data.updated_at || lastMonitorBoardUpdatedAt, { + stale: true, + }); + } + if (data.ok === false && data.msg && !background) { + showToast(String(data.msg), true); + } + } catch (e) { + const msg = + e && e.name === "AbortError" ? "读取监控快照超时,请检查中控是否运行" : String(e); + if (background && lastMonitorRows.length) { + showToast("快照读取失败,仍显示上次数据", true); + applyMonitorBoardUi(lastMonitorRows, null, { stale: false }); + return; + } + if (box) box.innerHTML = `
        ${esc(msg)}
        `; + } finally { + clearTimeout(fetchTimer); + clearMonitorBoardSlowHint(); + monitorBoardInFlight = false; + if (monitorBoardFetchPending) { + monitorBoardFetchPending = false; + void fetchMonitorBoardSnapshot({ background: true }); + } + } + } + + async function refreshMonitorBoardNow() { + if (lastMonitorRows.length) { + applyMonitorBoardUi(lastMonitorRows, lastMonitorBoardUpdatedAt, { stale: true }); + } + try { + await requestMonitorBoardRefresh(); + await fetchMonitorBoardSnapshot({ background: false }); + } catch (e) { + showToast(String(e), true); + } + } + + function closeExchangeFullscreen() { + expandedExchangeId = ""; + sessionStorage.removeItem("hub_expanded_ex"); + const fs = document.getElementById("exchange-fullscreen"); + if (fs) { + fs.classList.add("hidden"); + fs.setAttribute("aria-hidden", "true"); + } + document.body.classList.remove("hub-fullscreen-open"); + } + + function openExchangeFullscreen(exId) { + expandedExchangeId = String(exId); + sessionStorage.setItem("hub_expanded_ex", expandedExchangeId); + renderMonitorGrid(lastMonitorRows); + } + + function pnlSigned(v, decimals) { + const n = Number(v); + const d = decimals == null ? 2 : decimals; + if (!Number.isFinite(n)) return "—"; + if (Math.abs(n) < 1e-12) return fmt(0, d); + const abs = fmt(Math.abs(n), d); + return (n > 0 ? "+" : "-") + abs; + } + + const MONITOR_STATS_FOLD_KEY = "hub_monitor_stats_collapsed"; + + function isMonitorStatsCollapsed() { + try { + const v = localStorage.getItem(MONITOR_STATS_FOLD_KEY); + if (v === null || v === "") return true; + return v === "1"; + } catch (_e) { + return true; + } + } + + function setMonitorStatsCollapsed(collapsed) { + try { + localStorage.setItem(MONITOR_STATS_FOLD_KEY, collapsed ? "1" : "0"); + } catch (_e) { + /* ignore */ + } + } + + function renderMonitorStatsCard(totals) { + const t = totals || {}; + const day = t.trading_day || "—"; + const resetH = t.reset_hour != null ? t.reset_hour : 8; + const winN = Number(t.win_count) || 0; + const lossN = Number(t.loss_count) || 0; + const floatVal = Number(t.float_pnl_u); + const collapsed = isMonitorStatsCollapsed(); + function cell(label, main, sub, valCls) { + return `
        +
        ${esc(label)}
        +
        ${main}
        + ${sub ? `
        ${sub}
        ` : ""} +
        `; + } + const winSub = + winN > 0 && Number.isFinite(Number(t.win_pnl_u)) + ? `${esc(pnlSigned(t.win_pnl_u, 2))}U` + : "—"; + const lossSub = + lossN > 0 && Number.isFinite(Number(t.loss_pnl_u)) + ? `${esc(pnlSigned(t.loss_pnl_u, 2))}U` + : "—"; + const floatMain = esc(pnlSigned(floatVal, 2)) + "U"; + const floatCls = Math.abs(floatVal) > 1e-9 ? pnlCls(floatVal) : ""; + const foldLabel = collapsed ? "展开明细" : "收起"; + return `
        +
        +
        +
        +
        今日统计
        + +
        +
        交易日 ${esc(day)} · 北京时间 ${esc(String(resetH))}:00 切日
        +
        +
        +
        总浮盈亏
        +
        ${floatMain}
        +
        +
        +
        +
        + ${cell("今日开仓", String(Number(t.open_count) || 0), "含未平", "")} + ${cell("今日平仓", String(Number(t.closed_count) || 0), "", "")} + ${cell("持有仓位", String(Number(t.open_position_count) || 0), "", "")} + ${cell("盈利", String(winN), winSub, winN > 0 ? "pnl-pos" : "")} + ${cell("亏损", String(lossN), lossSub, lossN > 0 ? "pnl-neg" : "")} + ${cell("总浮盈亏", floatMain, "", floatCls)} +
        +
        +
        `; + } + + function renderMonitorGrid(rows) { + const box = document.getElementById("monitor-grid"); + const fs = document.getElementById("exchange-fullscreen"); + const fsInner = document.getElementById("exchange-fullscreen-inner"); + if (!box) return; + if (expandedExchangeId && !rows.some((r) => String(r.id) === String(expandedExchangeId))) { + closeExchangeFullscreen(); + } + const mobileTiles = isMobileLayout() && !expandedExchangeId; + const displayRows = mobileTiles ? sortRowsForMobileDashboard(rows) : rows; + const optionsSplit = monitorOptionsSplitActive(displayRows); + monitorGridOptionsSplit = optionsSplit; + const showStatsCard = !expandedExchangeId; + box.classList.toggle("grid-monitor-tiles", mobileTiles); + box.classList.toggle("grid-monitor-2x2", showStatsCard && !mobileTiles && !optionsSplit); + box.classList.toggle("grid-monitor-with-stats", showStatsCard && mobileTiles); + box.classList.toggle("grid-monitor-options-split", optionsSplit && showStatsCard); + try { + const statsHtml = showStatsCard ? renderMonitorStatsCard(lastMonitorTotals) : ""; + let cardsHtml = ""; + if (optionsSplit) { + const okxRow = displayRows.find((r) => rowHasOptionsLayout(r)); + const otherRows = displayRows.filter((r) => !rowHasOptionsLayout(r)); + const ph = + ''; + /* 平铺 2×2 顺序:永续|币安 / 期权|Gate —— 同行左右同高,多仓时该行一起长高 */ + const cells = [ + okxRow ? renderMonitorCard(okxRow, { okxPart: "perp", splitSide: true }) : ph, + otherRows[0] ? renderMonitorCard(otherRows[0], { splitSide: true }) : ph, + okxRow ? renderMonitorCard(okxRow, { okxPart: "options", splitSide: true }) : ph, + otherRows[1] ? renderMonitorCard(otherRows[1], { splitSide: true }) : ph, + ]; + for (let i = 2; i < otherRows.length; i++) { + cells.push(renderMonitorCard(otherRows[i], { splitSide: true })); + } + cardsHtml = `
        ${cells.join("")}
        `; + } else { + cardsHtml = + displayRows + .map((r) => (mobileTiles ? renderMonitorTile(r) : renderMonitorCard(r))) + .join("") || (showStatsCard ? "" : '
        无已启用账户
        '); + } + box.innerHTML = statsHtml + cardsHtml; + if (showStatsCard && !cardsHtml && !statsHtml) { + box.innerHTML = '
        无已启用账户
        '; + } + } catch (err) { + console.error("renderMonitorGrid", err); + box.innerHTML = `
        监控区渲染失败:${esc(String(err && err.message ? err.message : err))}
        `; + } + syncMonitorGridColumns(box, displayRows.length + (showStatsCard ? 1 : 0), { + statsFirst: showStatsCard && !optionsSplit, + optionsSplit: optionsSplit && showStatsCard, + }); + bindMonitorInteractions(box); + if (window.TimeCloseUI && TimeCloseUI.tickLocalCountdowns) { + TimeCloseUI.tickLocalCountdowns(); + } + ensureHubHoldDurationTimer(); + if (window.OptionsExpiryCountdown && OptionsExpiryCountdown.ensureTimer) { + OptionsExpiryCountdown.ensureTimer(); + } + + if (expandedExchangeId && fs && fsInner) { + const row = rows.find((r) => String(r.id) === String(expandedExchangeId)); + if (row) { + try { + fsInner.innerHTML = renderFullscreenExchange(row); + fs.classList.remove("hidden"); + fs.setAttribute("aria-hidden", "false"); + document.body.classList.add("hub-fullscreen-open"); + bindMonitorInteractions(fsInner); + if (window.TimeCloseUI && TimeCloseUI.tickLocalCountdowns) { + TimeCloseUI.tickLocalCountdowns(); + } + ensureHubHoldDurationTimer(); + if (window.OptionsExpiryCountdown && OptionsExpiryCountdown.ensureTimer) { + OptionsExpiryCountdown.ensureTimer(); + } + fsInner.querySelectorAll(".btn-expand-back").forEach((btn) => { + btn.onclick = (ev) => { + ev.stopPropagation(); + closeExchangeFullscreen(); + renderMonitorGrid(lastMonitorRows); + }; + }); + } catch (err) { + console.error("renderFullscreenExchange", err); + closeExchangeFullscreen(); + showToast("全屏渲染失败: " + err, true); + } + } else { + closeExchangeFullscreen(); + } + } else { + closeExchangeFullscreen(); + } + } + + function normalizeMarketSymbol(raw) { + let s = (raw || "").trim().toUpperCase(); + if (!s) return ""; + if (s.includes(":")) { + const base = s.split(":")[0]; + if (base.includes("/")) return base; + } + return s; + } + + function resolveExchangeKey(exchangeId) { + const row = (lastMonitorRows || []).find((r) => String(r.id) === String(exchangeId)); + return (row && (row.key || row.id)) || exchangeId; + } + + function findTrendPlan(trends, symbol, side) { + const want = (side || "").toLowerCase(); + for (const t of trends || []) { + const sym = t.symbol || t.exchange_symbol || ""; + if (!symbolsMatchHub(sym, symbol)) continue; + const d = (t.direction || "").toLowerCase(); + if (!d || d === want) return t; + } + return null; + } + + function orderTriggerOrPrice(o) { + if (!o) return null; + if (o.trigger_price != null && o.trigger_price !== "") { + const t = Number(o.trigger_price); + if (Number.isFinite(t) && t > 0) return t; + } + if (o.price != null && o.price !== "") { + const p = Number(o.price); + if (Number.isFinite(p) && p > 0) return p; + } + return null; + } + + function inferTpslFromCondOrders(side, cond, entry) { + const picked = pickExTpslOrders(cond); + let sl = picked.sl ? orderTriggerOrPrice(picked.sl) : ""; + let tp = picked.tp ? orderTriggerOrPrice(picked.tp) : ""; + if (sl !== "" && sl != null) sl = Number(sl); + if (tp !== "" && tp != null) tp = Number(tp); + if (sl !== "" && tp !== "" && Number(sl) !== Number(tp)) { + return { sl, tp }; + } + + const triggers = (cond || []) + .map(function (o) { + const px = orderTriggerOrPrice(o); + return px == null ? null : { price: px, label: o.label || "" }; + }) + .filter(function (o) { + return o != null; + }); + if (!triggers.length) return { sl: sl || "", tp: tp || "" }; + + const s = (side || "long").toLowerCase(); + const e = entry != null && Number.isFinite(Number(entry)) ? Number(entry) : null; + + if (e != null) { + const below = triggers.filter(function (t) { + return t.price < e; + }); + const above = triggers.filter(function (t) { + return t.price > e; + }); + if (s === "long") { + if (sl === "" && below.length) { + sl = Math.max.apply( + null, + below.map(function (t) { + return t.price; + }) + ); + } + if (tp === "" && above.length) { + tp = Math.min.apply( + null, + above.map(function (t) { + return t.price; + }) + ); + } + } else { + if (sl === "" && above.length) { + sl = Math.min.apply( + null, + above.map(function (t) { + return t.price; + }) + ); + } + if (tp === "" && below.length) { + tp = Math.max.apply( + null, + below.map(function (t) { + return t.price; + }) + ); + } + } + } + + if (triggers.length === 1 && sl === "" && tp === "") { + const one = triggers[0]; + const p = one.price; + const lbl = one.label; + if (e != null) { + if (s === "long") { + if (p < e) sl = p; + else if (p > e) tp = p; + } else if (p > e) sl = p; + else if (p < e) tp = p; + } else if (/止损/.test(lbl)) sl = p; + else if (/止盈/.test(lbl) && !/止盈止损/.test(lbl)) tp = p; + } + + if (sl !== "" && tp !== "" && Number(sl) === Number(tp)) tp = ""; + return { sl: sl || "", tp: tp || "" }; + } + + function resolvePositionTpsl(pos, monitorOrder, trendPlan) { + const mo = monitorOrder || {}; + const tp = trendPlan || {}; + const cond = condOrdersFromPosition(pos); + const entryRaw = + pos.entry_price != null + ? pos.entry_price + : mo.trigger_price != null + ? mo.trigger_price + : tp.avg_entry_price; + const entryN = entryRaw != null && entryRaw !== "" ? Number(entryRaw) : null; + const isTrend = isTrendContext(mo, trendPlan); + const handoff = isTrendHandoffOrder(mo); + + let sl = mo.stop_loss != null && mo.stop_loss !== "" ? mo.stop_loss : ""; + let takeProfit = mo.take_profit != null && mo.take_profit !== "" ? mo.take_profit : ""; + let tpMonitored = false; + + if (handoff) { + tpMonitored = false; + } else if (isTrend) { + tpMonitored = true; + if (trendPlan && trendPlan.stop_loss != null && trendPlan.stop_loss !== "") { + sl = trendPlan.stop_loss; + } + if (trendPlan && trendPlan.take_profit != null && trendPlan.take_profit !== "") { + takeProfit = trendPlan.take_profit; + } else { + takeProfit = ""; + } + } + + const inferred = inferTpslFromCondOrders(pos.side, cond, entryN); + if (inferred.sl !== "" && inferred.sl != null) { + sl = inferred.sl; + } else if (sl === "" || sl == null) { + sl = inferred.sl; + } + if (!tpMonitored) { + if (inferred.tp !== "" && inferred.tp != null) { + takeProfit = inferred.tp; + } else if (takeProfit === "" || takeProfit == null) { + takeProfit = inferred.tp; + } + } + + if (sl !== "" && takeProfit !== "" && Number(sl) === Number(takeProfit)) { + takeProfit = ""; + } + + return { + entry: entryRaw, + sl, + tp: takeProfit, + tp_monitored: tpMonitored, + is_trend: isTrend, + is_handoff: handoff, + }; + } + + function buildPositionMarketContext(pos, monitorOrder, trendPlan, exchangeId) { + const mo = monitorOrder || {}; + const tpsl = resolvePositionTpsl(pos, monitorOrder, trendPlan); + const cond = condOrdersFromPosition(pos); + const reg = Array.isArray(pos.regular_orders) ? pos.regular_orders : []; + const num = function (v) { + if (v == null || v === "") return null; + const n = Number(v); + return Number.isFinite(n) ? n : null; + }; + const orders = []; + cond.forEach(function (o) { + orders.push({ + kind: "条件", + label: o.label || "条件单", + price: num(o.trigger_price), + amount: num(o.amount), + }); + }); + reg.forEach(function (o) { + orders.push({ + kind: "普通", + label: o.label || o.type || "委托", + price: num(o.price != null ? o.price : o.trigger_price), + amount: num(o.amount), + }); + }); + const entryPx = num(pos.entry_price != null ? pos.entry_price : tpsl.entry); + const markPx = num(pos.mark_price); + const contractSize = num(pos.contract_size); + const upnl = resolvePositionUpnlUsdt(pos, trendPlan, markPx); + const planMargin = + trendPlan && trendPlan.plan_margin_capital != null + ? num(trendPlan.plan_margin_capital) + : mo.margin_capital != null + ? num(mo.margin_capital) + : null; + const leverage = + trendPlan && trendPlan.leverage != null + ? num(trendPlan.leverage) + : mo.leverage != null + ? num(mo.leverage) + : null; + return { + exchange_id: exchangeId || null, + symbol: (pos.symbol || "").trim(), + side: (pos.side || "long").toLowerCase(), + entry: entryPx, + mark_price: markPx, + stop_loss: num(tpsl.sl), + take_profit: num(tpsl.tp), + tp_monitored: !!tpsl.tp_monitored, + is_trend: !!tpsl.is_trend, + contracts: num(pos.contracts), + contract_size: contractSize != null ? contractSize : 1, + unrealized_pnl: upnl != null ? Number(upnl) : null, + notional_usdt: num(pos.notional_usdt), + plan_margin: planMargin, + leverage: leverage, + orders: orders, + }; + } + + const HUB_MARKET_POS_CTX_KEY = "hubMarketPosContext"; + + function encodePosCtx(ctx) { + try { + return btoa(unescape(encodeURIComponent(JSON.stringify(ctx)))); + } catch (e) { + return ""; + } + } + + function decodePosCtx(raw) { + if (!raw) return null; + try { + return JSON.parse(decodeURIComponent(escape(atob(raw)))); + } catch (e) { + return null; + } + } + + function marketOpenBtnAttrs(exchangeId, exchangeKey, symbol, pos, monitorOrder, trendPlan) { + const symAttr = esc(symbol || "").replace(/"/g, """); + const exKeyAttr = esc(exchangeKey || exchangeId || "").replace(/"/g, """); + const ctxEnc = esc( + encodePosCtx(buildPositionMarketContext(pos, monitorOrder, trendPlan, exchangeId)) + ).replace( + /"/g, + """ + ); + return ( + 'data-ex-id="' + + esc(exchangeId) + + '" data-ex-key="' + + exKeyAttr + + '" data-symbol="' + + symAttr + + '" data-pos-ctx="' + + ctxEnc + + '"' + ); + } + + function openMarketForPosition(exchangeId, symbol, exchangeKey, posCtxRaw) { + const exKey = exchangeKey || resolveExchangeKey(exchangeId); + const sym = normalizeMarketSymbol(symbol); + if (!exKey || !sym) { + showToast("无法打开行情:缺少交易所或合约", true); + return; + } + const ctx = decodePosCtx(posCtxRaw); + if (ctx) { + ctx.symbol = sym; + ctx.exchange_key = exKey; + sessionStorage.setItem(HUB_MARKET_POS_CTX_KEY, JSON.stringify(ctx)); + } else { + sessionStorage.removeItem(HUB_MARKET_POS_CTX_KEY); + } + if (expandedExchangeId) { + closeExchangeFullscreen(); + } + const qs = new URLSearchParams({ exchange_key: exKey, symbol: sym }); + history.pushState({}, "", "/market?" + qs.toString()); + setActiveNav(); + if (window.hubMarketChart && window.hubMarketChart.openWith) { + window.hubMarketChart.openWith(exKey, sym); + } + } + + function bindMonitorInteractions(box) { + box.querySelectorAll(".btn-monitor-stats-toggle").forEach((btn) => { + btn.onclick = (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + const next = !isMonitorStatsCollapsed(); + setMonitorStatsCollapsed(next); + if (lastMonitorRows) renderMonitorGrid(lastMonitorRows); + }; + }); + box.querySelectorAll(".btn-open-market").forEach((btn) => { + btn.onclick = (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + openMarketForPosition(btn.dataset.exId, btn.dataset.symbol, btn.dataset.exKey, btn.dataset.posCtx); + }; + }); + box.querySelectorAll(".btn-open-instance").forEach((btn) => { + btn.onclick = (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + const msg = (btn.dataset.confirm || "").trim(); + if (msg && !confirm(msg)) return; + openInstance(btn.dataset.exId, btn.dataset.next || "/", { + newTab: btn.dataset.newTab === "1" || ev.ctrlKey || ev.metaKey, + }); + }; + }); + box.querySelectorAll(".btn-hub-trend-stop").forEach((btn) => { + btn.onclick = (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + hubTrendPlanStop(btn.dataset.exId, btn.dataset.planId); + }; + }); + box.querySelectorAll(".btn-hub-trend-be").forEach((btn) => { + btn.onclick = (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + const card = btn.closest(".hub-trend-plan-card"); + const inp = card ? card.querySelector(".hub-plan-be-input") : null; + hubTrendPlanBreakeven(btn.dataset.exId, btn.dataset.planId, inp); + }; + }); + box.querySelectorAll(".btn-close-ex").forEach((btn) => { + btn.onclick = () => closeOne(btn.dataset.id); + }); + box.querySelectorAll(".btn-close-pos").forEach((btn) => { + btn.onclick = (ev) => { + ev.stopPropagation(); + closeOnePosition(btn.dataset.exId, btn.dataset.symbol, btn.dataset.side); + }; + }); + box.querySelectorAll(".btn-cancel-order").forEach((btn) => { + btn.onclick = (ev) => { + ev.stopPropagation(); + cancelOneOrder( + btn.dataset.exId, + btn.dataset.symbol, + btn.dataset.orderId, + btn.dataset.channel + ); + }; + }); + box.querySelectorAll(".btn-cancel-cond-all").forEach((btn) => { + btn.onclick = (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + cancelSymbolOrders(btn.dataset.exId, btn.dataset.symbol, "conditional"); + }; + }); + box.querySelectorAll(".btn-place-tpsl").forEach((btn) => { + btn.onclick = (ev) => { + ev.stopPropagation(); + openTpslModal( + btn.dataset.exId, + btn.dataset.symbol, + btn.dataset.side, + btn.dataset.contracts, + btn.dataset.sl || "", + btn.dataset.tp || "" + ); + }; + }); + box.querySelectorAll(".card-expand-zone").forEach((zone) => { + zone.onclick = (ev) => { + if (ev.target.closest("a, button, input, summary, details, .card-actions")) return; + const id = zone.closest(".card")?.dataset.exId; + if (id) openExchangeFullscreen(id); + }; + }); + box.querySelectorAll("details.pos-orders-collapse[data-collapse-key]").forEach((el) => { + el.addEventListener("toggle", () => { + const k = el.dataset.collapseKey; + if (k) localStorage.setItem(k, el.open ? "1" : "0"); + }); + }); + } + + function renderOrderRows(exchangeId, symbol, orders, kind, tickMap) { + if (!orders || !orders.length) { + const hint = + kind === "conditional" + ? "暂无条件单(止盈/止损等)" + : "暂无普通委托"; + return `
        ${hint}
        `; + } + const symAttr = esc(symbol || "").replace(/"/g, """); + const rows = orders + .map((o) => { + const oidAttr = esc(o.id || "").replace(/"/g, """); + const chAttr = esc(o.channel || "regular").replace(/"/g, """); + const trig = + o.trigger_price != null + ? fmtSymbolPrice(o.trigger_price, symbol, tickMap) + : o.price != null + ? fmtSymbolPrice(o.price, symbol, tickMap) + : "—"; + return ` + + + + + `; + }) + .join(""); + return `
        合约方向开仓价标记价张数盈利金额操作
        ${esc(o.label || o.type || "委托")}${fmt(o.amount, 4)}${trig}
        ${rows}
        类型数量触发/价格操作
        `; + } + + function guessTpslFromCondOrders(side, cond, entry) { + return inferTpslFromCondOrders(side, cond, entry); + } + + function renderOrdersCollapse(exchangeId, symbol, cond, reg, tickMap) { + const symAttr = esc(symbol || "").replace(/"/g, """); + const orderTotal = cond.length + reg.length; + const collapseKey = ordersCollapseKey(exchangeId, symbol); + const openAttr = isOrdersCollapseOpen(exchangeId, symbol) ? " open" : ""; + const condAllBtn = + cond.length > 0 + ? `` + : ""; + const condBody = renderOrderRows(exchangeId, symbol, cond, "conditional", tickMap); + const regBody = renderOrderRows(exchangeId, symbol, reg, "limit", tickMap); + return `
        + + 委托单 ${orderTotal} + 条件 ${cond.length} · 普通 ${reg.length} + ${condAllBtn} + +
        +
        +
        条件单
        + ${condBody} +
        +
        +
        普通委托
        + ${regBody} +
        +
        +
        `; + } + + function syntheticExTpslOrder(role, price, amount) { + if (price == null || price === "" || !Number.isFinite(Number(price))) return null; + return { + label: role === "sl" ? "止损" : "止盈", + trigger_price: Number(price), + price: Number(price), + amount: amount != null ? amount : null, + id: "", + channel: "plan", + }; + } + + function pickExTpslOrders(cond) { + let sl = cond.find((o) => /^止损\b/.test(o.label || "")); + let tp = cond.find((o) => /^止盈\b/.test(o.label || "") && !(o.label || "").includes("止盈止损")); + if (!sl || !tp) { + const combo = cond.find((o) => (o.label || "").includes("止盈止损")); + if (combo) { + const m = (combo.label || "").match(/SL=([\d.eE+-]+).*TP=([\d.eE+-]+)/i); + if (m) { + if (!sl) sl = { ...combo, label: "止损", trigger_price: Number(m[1]) }; + if (!tp) tp = { ...combo, label: "止盈", trigger_price: Number(m[2]) }; + } + } + } + if (!sl) sl = cond.find((o) => (o.label || "").includes("止损")); + if (!tp) tp = cond.find((o) => (o.label || "").includes("止盈") && o !== sl); + return { sl, tp }; + } + + function renderExTpslRows(exchangeId, symbol, cond, tickMap, resolvedTpsl, contracts, intradayDiscipline) { + const symAttr = esc(symbol || "").replace(/"/g, """); + const intraday = !!intradayDiscipline; + let { sl, tp } = pickExTpslOrders(cond); + const plan = resolvedTpsl || {}; + if (!sl && plan.sl != null && plan.sl !== "") { + sl = syntheticExTpslOrder("sl", plan.sl, contracts); + } + if (!tp && plan.tp != null && plan.tp !== "") { + tp = syntheticExTpslOrder("tp", plan.tp, contracts); + } + function row(label, o) { + if (!o) { + return `
        ${label}:—
        `; + } + const oid = esc(o.id || "").replace(/"/g, """); + const ch = esc(o.channel || "regular").replace(/"/g, """); + const px = orderTriggerOrPrice(o); + const trig = px != null ? fmtSymbolPrice(px, symbol, tickMap) : "—"; + const cancelBtn = + !intraday && oid && o.channel !== "plan" + ? `` + : ""; + const planHint = o.channel === "plan" ? '(下单监控)' : ""; + return `
        + ${label}:触发 ${trig} · 数量 ${fmt(o.amount, 4)}${planHint} + ${cancelBtn} +
        `; + } + return row("止损", sl) + row("止盈", tp); + } + + function trendAddSummaryHtml(t, tickMap) { + const done = t.add_count != null ? t.add_count : t.legs_done; + const total = t.add_count_total != null ? t.add_count_total : t.dca_legs; + const sym = t.exchange_symbol || t.symbol || ""; + let html = ""; + if (done != null && Number(done) >= 0) { + html += total != null ? ` · 补仓 ${esc(done)}/${esc(total)}` : ` · 补仓 ${esc(done)} 次`; + const pxs = t.add_prices_display; + if (Array.isArray(pxs) && pxs.length) { + html += ` · 加仓价 ${pxs.map((p) => esc(p)).join(" / ")}`; + } else if (Array.isArray(t.add_prices) && t.add_prices.length) { + html += ` · 加仓价 ${t.add_prices.map((p) => esc(fmtSymbolPrice(p, sym, tickMap))).join(" / ")}`; + } else if (Number(done) === 0) { + html += " · 加仓价 —"; + } + } + return html; + } + + function timeCloseSymbolBadgeHtml(item) { + if (!item || !item.time_close_enabled) return ""; + const tcLabel = item.time_close_label || `时间平仓 ${item.time_close_hours || ""}h`; + const tcCd = item.time_close_countdown || "--:--:--"; + const tcAt = item.time_close_at_ms != null ? String(item.time_close_at_ms) : ""; + return ( + `` + + `${esc(tcLabel)} · ${esc(tcCd)}` + ); + } + + function forceCloseSymbolBadgeHtml(item) { + if (!item || !item.force_close_enabled) return ""; + const fcLabel = item.force_close_label || "强制清仓"; + const fcCd = item.force_close_countdown || "--:--:--"; + const fcAt = item.force_close_at_ms != null ? String(item.force_close_at_ms) : ""; + const fcActive = item.force_close_active ? "1" : "0"; + return ( + `` + + `${esc(fcLabel)} · ${esc(fcCd)}` + ); + } + + function isIntradayDisciplineRow(row) { + const m = row && row.meta; + if (!m || typeof m !== "object") return false; + if (m.intraday_discipline === true) return true; + return m.order_entry_profile === "intraday"; + } + + function forceCloseHeadBadgeHtml(state) { + if (!state || !state.enabled) return ""; + return forceCloseSymbolBadgeHtml({ + force_close_enabled: true, + force_close_label: state.label || "强制清仓", + force_close_countdown: state.countdown || "--:--:--", + force_close_at_ms: state.next_at_ms, + force_close_active: state.active, + }); + } + + function renderTrendDcaTable(t, tickMap) { + const levels = resolveTrendDcaLevels(t); + if (!levels.length) return ""; + const sym = t.exchange_symbol || t.symbol || ""; + const rows = levels + .map((lv) => { + const price = + lv.price != null && lv.price !== "" + ? fmtSymbolPrice(lv.price, sym, tickMap) + : "—"; + const amt = + lv.contracts != null && lv.contracts !== "" ? esc(String(lv.contracts)) : "—"; + const avg = + lv.avg_entry != null && lv.avg_entry !== "" + ? fmtSymbolPrice(lv.avg_entry, sym, tickMap) + : "—"; + const profitU = + lv.profit_u != null && lv.profit_u !== "" ? fmt(lv.profit_u, 2) : "—"; + const riskU = lv.risk_u != null && lv.risk_u !== "" ? fmt(lv.risk_u, 2) : "—"; + const rr = lv.rr != null && lv.rr !== "" ? `${fmt(lv.rr, 2)}:1` : "—"; + const stCls = lv.status === "done" ? "st-done" : "st-pending"; + const label = lv.status_label || (lv.status === "done" ? "已补仓" : "待补仓"); + return ` + ${esc(lv.label || lv.leg_key || "—")} + ${esc(price)} + ${amt} + ${esc(avg)} + ${esc(profitU)} + ${esc(riskU)} + ${esc(rr)} + ${esc(label)} + `; + }) + .join(""); + return `
        +
        补仓计划明细
        + + + ${rows} +
        档位触发价张数加仓后均价止盈盈利(U)止损(U)盈亏比状态
        +
        `; + } + + function renderTrendPlanCard(t, tickMap, pos, exchangeRow) { + const sym = t.exchange_symbol || t.symbol || ""; + const side = (t.direction || "long").toLowerCase(); + const sl = t.stop_loss_display || fmtSymbolPrice(t.stop_loss, sym, tickMap); + const tp = t.take_profit_display || fmtSymbolPrice(t.take_profit, sym, tickMap); + const avg = t.avg_entry_price_display || fmtSymbolPrice(t.avg_entry_price, sym, tickMap); + const addZone = + t.add_upper_display || fmtSymbolPrice(t.add_upper, sym, tickMap) || "—"; + const rr = resolveTrendPlanRr(t, side, t.avg_entry_price, t.stop_loss, t.take_profit); + const rrTxt = rr != null ? `${fmt(rr, 2)}:1` : "—"; + const mark = resolveTrendMarkPrice(pos, t, sym, tickMap); + const legsDone = t.add_count != null ? t.add_count : t.legs_done; + const legsTotal = t.add_count_total != null ? t.add_count_total : t.dca_legs; + const legsTxt = + legsDone != null && legsTotal != null + ? `${esc(legsDone)}/${esc(legsTotal)}` + : legsDone != null + ? esc(legsDone) + : "—"; + const upnlTrend = resolveTrendFloatingPnl(pos, t); + const pnlFmt = formatTrendPlanFloatingPnl(upnlTrend, t.plan_margin_capital); + const pnlVal = + pnlFmt.text === "—" + ? "—" + : `${esc(pnlFmt.text)}`; + const riskTxt = + t.risk_percent != null && t.risk_percent !== "" ? `${esc(t.risk_percent)}%` : "—"; + const snapTxt = + t.snapshot_available_usdt != null && t.snapshot_available_usdt !== "" + ? `${fmt(t.snapshot_available_usdt, 2)}U` + : "—"; + const marginTxt = + t.plan_margin_capital != null && t.plan_margin_capital !== "" + ? `≈${fmt(t.plan_margin_capital, 2)}U` + : "—"; + const levTxt = t.leverage != null && t.leverage !== "" ? `${esc(t.leverage)}x` : "—"; + const bePctDefault = + t.breakeven_default_offset_pct != null && t.breakeven_default_offset_pct !== "" + ? t.breakeven_default_offset_pct + : t.breakeven_offset_pct != null && t.breakeven_offset_pct !== "" + ? t.breakeven_offset_pct + : "0.3"; + const exId = exchangeRow && exchangeRow.id != null ? esc(exchangeRow.id) : ""; + const planId = esc(t.id); + const caps = (exchangeRow && exchangeRow.capabilities) || []; + const flaskOk = + exchangeRow && exchangeRow.flask_ok !== false && (exchangeRow.hub_monitor || {}).ok !== false; + const canHubTrend = !!(flaskOk && caps.includes("trend") && exId && planId); + const beAppliedFlag = !!t.breakeven_applied; + const endBtn = canHubTrend + ? `` + : ""; + const beBtn = canHubTrend && !beAppliedFlag + ? `` + : beAppliedFlag + ? "" + : `保本移交下单监控`; + const beApplied = + t.breakeven_applied + ? `已保本 ${esc(String(t.breakeven_applied_at || "").slice(0, 16))}` + : ""; + const dcaHtml = renderTrendDcaTable(t, tickMap); + const dcaCol = dcaHtml + ? `
        ${dcaHtml}
        ` + : `
        补仓计划明细
        暂无补仓档位
        `; + return `
        +
        +
        + #${esc(t.id)} ${esc(sym)} + ${renderDirectionBadge(t.direction)} +
        + ${endBtn} +
        +
        +
        +
        + 来源: 趋势回调计划 | 风险: ${riskTxt} + | ${esc(trendAddZoneLabel(t.direction))} ${esc(addZone)} + | 已补仓 ${legsTxt} +
        +
        +
        均价${esc(avg)}
        +
        止损${esc(sl)}
        +
        止盈${esc(tp)}
        +
        盈亏比${esc(rrTxt)}
        +
        标记价${esc(mark)}
        +
        浮盈亏${pnlVal}
        +
        +
        + ${dcaCol} +
        +
        +
        + + ${beBtn} + ${beApplied} +
        + +
        +
        `; + } + + function renderTrendSection(trends, tickMap, positions, exchangeRow) { + if (!trends || !trends.length) return ""; + const posList = Array.isArray(positions) ? positions : []; + const cards = trends + .map((t) => { + const sym = t.exchange_symbol || t.symbol || ""; + const side = (t.direction || "long").toLowerCase(); + let matched = null; + for (const p of posList) { + if (!symbolsMatchHub(p.symbol, sym)) continue; + const ps = (p.side || "").toLowerCase(); + if (!ps || ps === side) { + matched = p; + break; + } + } + return renderTrendPlanCard(t, tickMap, matched, exchangeRow); + }) + .join(""); + return `
        +
        运行中的计划
        +
        ${cards}
        +
        `; + } + + function renderLivePositionCard(exchangeId, exchangeKey, pos, monitorOrder, trendPlan, tickMap, intradayDiscipline) { + const symbol = pos.symbol || ""; + const exKeyAttr = esc(exchangeKey || exchangeId || "").replace(/"/g, """); + const side = (pos.side || "long").toLowerCase(); + const sideCn = sideDirLabel(side); + const sideCls = sideDirCls(side) || "side-long"; + const mo = monitorOrder || {}; + const cond = condOrdersFromPosition(pos); + const reg = Array.isArray(pos.regular_orders) ? pos.regular_orders : []; + const tpsl = resolvePositionTpsl(pos, mo, trendPlan); + const symAttr = esc(symbol).replace(/"/g, """); + const sideAttr = esc(side).replace(/"/g, """); + const contractsAttr = esc(String(pos.contracts != null ? pos.contracts : "")).replace(/"/g, """); + const slAttr = esc(String(tpsl.sl)).replace(/"/g, """); + const tpAttr = esc(String(tpsl.tp)).replace(/"/g, """); + const entry = tpsl.entry; + const sl = tpsl.sl; + const tp = tpsl.tp; + const tpMonitored = tpsl.tp_monitored; + const isTrend = isTrendContext(mo, trendPlan); + const intraday = !!intradayDiscipline; + const rr = resolveSnapshotRr(mo, side, entry, sl, tp, tpMonitored, trendPlan); + const beSecured = isBreakevenSecured(side, entry, mo, cond, pos); + const upnl = resolveTrendFloatingPnl(pos, trendPlan); + const pnlFmt = formatFloatingPnlText(upnl, pos.notional_usdt); + const pnlText = pnlFmt.text; + const sizingFoot = resolveTrendSizingFooter(mo, trendPlan, isTrend, pos); + const openMeta = resolvePositionOpenMeta(mo, trendPlan, isTrend); + const marginText = + sizingFoot.margin != null && sizingFoot.margin !== "" && Number.isFinite(Number(sizingFoot.margin)) + ? fmt(Number(sizingFoot.margin), 2) + "U" + : "—"; + const holdMsAttr = + openMeta.openedAtMs != null && Number.isFinite(openMeta.openedAtMs) + ? String(openMeta.openedAtMs) + : ""; + const markDisplay = isTrend + ? resolveTrendMarkPrice(pos, trendPlan, symbol, tickMap) + : fmtMarkPrice(pos, tickMap); + const meta = []; + if (isTrend) { + meta.push(monitorOrderSourceHtml(mo, trendPlan)); + const riskLine = formatMonitorRiskMeta(mo, trendPlan); + if (riskLine) meta.push(riskLine); + const latestRiskLine = formatLatestRiskMeta(mo, trendPlan, pos, tpsl); + if (latestRiskLine) meta.push(latestRiskLine); + if (trendPlan && trendPlan.id) { + const zone = + trendPlan.add_upper_display || + fmtSymbolPrice(trendPlan.add_upper, symbol, tickMap) || + "—"; + meta.push( + `${esc(trendAddZoneLabel(trendPlan.direction))} ${esc(zone)}` + ); + const addSum = trendAddSummaryHtml(trendPlan, tickMap); + if (addSum) meta.push(addSum.replace(/^ · /, "")); + } + meta.push(`移动保本:关`); + } else if (mo.monitor_type || mo.key_signal_type || mo.trend_plan_id) { + meta.push(monitorOrderSourceHtml(mo, trendPlan)); + meta.push(monitorEntryStyleHtml(mo, intraday)); + const riskLine = formatMonitorRiskMeta(mo, trendPlan); + if (riskLine) meta.push(riskLine); + const latestRiskLine = formatLatestRiskMeta(mo, trendPlan, pos, tpsl); + if (latestRiskLine) meta.push(latestRiskLine); + if (!intraday) { + const beOn = mo.breakeven_enabled === 1 || mo.breakeven_enabled === true; + meta.push( + `移动保本:${beOn ? "开" : "关"}` + ); + } + } else { + meta.push("来源: 交易所持仓"); + meta.push("风格: —"); + if (!intraday) meta.push(`移动保本:关`); + } + const symBeBadge = beSecured ? ` ${breakevenBadgeHtml()}` : ""; + const tcSymBadge = !isTrend && mo.time_close_enabled ? timeCloseSymbolBadgeHtml(mo) : ""; + const fcSymBadge = !isTrend && mo.force_close_enabled ? forceCloseSymbolBadgeHtml(mo) : ""; + const mktAttrs = marketOpenBtnAttrs(exchangeId, exchangeKey, symbol, pos, monitorOrder, trendPlan); + const headActions = intraday + ? "" + : `
        + + +
        `; + return `
        +
        +
        + ${tcSymBadge}${fcSymBadge}${symBeBadge} + ${sideCn} +
        + ${headActions} +
        +
        ${meta.map((m) => `${m}`).join("")}
        +
        +
        开仓价${fmtEntryPrice(pos, tickMap)}
        +
        标记价${markDisplay}
        +
        止损${sl != null && sl !== "" ? fmtSymbolPrice(sl, symbol, tickMap) : "—"}
        +
        止盈${formatTpCellValue(tp, tpMonitored, symbol, tickMap)}
        +
        盈亏比${rr != null ? fmt(rr, 2) + ":1" : "—"}
        +
        张数${fmt(pos.contracts, 2)}
        +
        盈利金额${formatTpProfitCell(mo, pos)}
        + ${ + showAccountPnlPref() + ? `
        浮盈亏${pnlText}
        ` + : "" + } +
        + +
        +
        交易所止盈止损
        + ${renderExTpslRows(exchangeId, symbol, cond, tickMap, tpsl, pos.contracts, intraday)} +
        + ${renderOrdersCollapse(exchangeId, symbol, cond, reg, tickMap)} +
        `; + } + + function renderHubSectionCard(title, bodyHtml, emptyHint) { + const inner = bodyHtml || `
        ${esc(emptyHint || "暂无")}
        `; + return `
        +
        ${esc(title)}
        +
        ${inner}
        +
        `; + } + + function renderKeySection(keys, kmap) { + if (!keys.length) return ""; + const cards = keys + .map((k) => { + const kp = kmap[k.id] || kmap[String(k.id)] || {}; + const mt = k.monitor_type || k.type || ""; + const pending = keyHasPendingOrder(k, kp); + const cardCls = pending ? "hub-mini-card hub-key-pending" : "hub-mini-card"; + const dir = k.direction ? ` · ${renderDirectionHtml(k.direction)}` : ""; + const pendingTag = pending + ? `挂单中` + : ""; + const amtTxt = fmtKeyOrderAmount(k); + const amtLine = amtTxt + ? `
        挂单数量 ${esc(amtTxt)}
        ` + : ""; + const keyTc = + k.time_close_enabled && k.time_close_at_ms + ? timeCloseSymbolBadgeHtml(k) + : k.time_close_enabled && k.time_close_hours + ? `时间平仓 ${esc(k.time_close_hours)}h` + : ""; + return `
        +
        ${esc(k.symbol)} ${keyTc} · ${esc(mt)}${dir} ${pendingTag}
        +
        上沿 ${esc(k.upper)} / 下沿 ${esc(k.lower)}
        + ${amtLine} +
        ${esc(kp.gate_summary || kp.price_display || kp.price || "—")}${kp.gate_metrics ? ` · ${esc(kp.gate_metrics)}` : ""}
        +
        `; + }) + .join(""); + return `
        ${cards}
        `; + } + + function renderOrderMonitorSection(orders, tickMap) { + if (!orders || !orders.length) return ""; + return orders + .map((o) => { + const sym = o.exchange_symbol || o.symbol || ""; + const tcBadge = o.time_close_enabled ? timeCloseSymbolBadgeHtml(o) : ""; + const fcBadge = o.force_close_enabled ? forceCloseSymbolBadgeHtml(o) : ""; + return `
        +
        #${esc(o.id)} · ${esc(o.symbol || o.exchange_symbol)} ${tcBadge}${fcBadge} · ${renderDirectionHtml(o.direction)}
        +
        触发 ${fmtSymbolPrice(o.trigger_price, sym, tickMap)} · SL ${fmtSymbolPrice(o.stop_loss, sym, tickMap)} · TP ${fmtSymbolPrice(o.take_profit, sym, tickMap)} · ${esc(o.entry_model_label || o.trade_style || o.monitor_type || "下单监控")}
        +
        `; + }) + .join(""); + } + + function renderRollSection(rolls, tickMap) { + if (!rolls || !rolls.length) return ""; + return rolls + .map((g) => { + const sym = g.symbol || g.exchange_symbol || ""; + const avg = + g.avg_entry_display || fmtSymbolPrice(g.avg_entry, sym, tickMap) || "—"; + const tpProfit = + g.reward_at_tp_usdt != null && g.reward_at_tp_usdt !== "" + ? `${fmt(g.reward_at_tp_usdt, 2)}U` + : "—"; + const legs = Array.isArray(g.recent_legs) ? g.recent_legs : []; + const legRows = legs + .map((leg) => { + const legAvg = + leg.avg_entry_display || + fmtSymbolPrice(leg.avg_entry_after, sym, tickMap) || + "—"; + const legProfit = + leg.reward_at_tp_usdt != null && leg.reward_at_tp_usdt !== "" + ? `${fmt(leg.reward_at_tp_usdt, 2)}U` + : "—"; + return `
        腿 #${esc(leg.leg_index)} ${esc(leg.add_mode || "")} · 张 ${esc(leg.amount != null ? leg.amount : "—")} · 均价 ${legAvg} · 止盈 ${legProfit}
        `; + }) + .join(""); + return `
        +
        组 #${esc(g.id)} · ${esc(g.symbol || "")} ${renderDirectionHtml(g.direction)} · 监控 #${esc(g.order_monitor_id || "—")}
        +
        腿数 ${esc(g.leg_count != null ? g.leg_count : "—")} · SL ${fmtSymbolPrice(g.current_stop_loss, sym, tickMap)} · 首仓TP ${fmtSymbolPrice(g.initial_take_profit, sym, tickMap)}
        +
        当前均价 ${avg} · 止盈盈利 ${tpProfit}
        + ${legRows} +
        `; + }) + .join(""); + } + + function renderPositionTableRow( + exchangeId, + exchangeKey, + x, + monitorOrder, + trendPlan, + tickMap, + opts + ) { + const options = opts || {}; + const symAttr = esc(x.symbol || "").replace(/"/g, """); + const sideAttr = esc((x.side || "").toLowerCase()).replace(/"/g, """); + const side = sideAttr || "long"; + const contractsAttr = esc(String(x.contracts != null ? x.contracts : "")).replace( + /"/g, + """ + ); + const cond = condOrdersFromPosition(x); + const tpsl = resolvePositionTpsl(x, monitorOrder, trendPlan); + const beSecured = isBreakevenSecured(side, tpsl.entry, monitorOrder, cond, x); + const slAttr = esc(String(tpsl.sl)).replace(/"/g, """); + const tpAttr = esc(String(tpsl.tp)).replace(/"/g, """); + const mktAttrs = marketOpenBtnAttrs(exchangeId, exchangeKey, x.symbol, x, monitorOrder, trendPlan); + const symBeBadge = beSecured ? ` ${breakevenBadgeHtml()}` : ""; + const mo = monitorOrder || {}; + const tcBadge = + !isTrendContext(mo, trendPlan) && mo.time_close_enabled ? timeCloseSymbolBadgeHtml(mo) : ""; + const fcBadge = + !isTrendContext(mo, trendPlan) && mo.force_close_enabled ? forceCloseSymbolBadgeHtml(mo) : ""; + const intraday = !!options.intradayDiscipline; + const actionCell = intraday + ? "" + : `
        + + +
        `; + const pnlTd = showAccountPnlPref() + ? `${fmt(x.unrealized_pnl, 2)}` + : ""; + const tpProfitTd = `${formatTpProfitCell(monitorOrder, x)}`; + return ` + ${tcBadge}${fcBadge}${symBeBadge} + ${renderDirectionHtml(x.side)} + ${fmtEntryPrice(x, tickMap)} + ${fmtMarkPrice(x, tickMap)} + ${fmt(x.contracts, 2)} + ${tpProfitTd} + ${pnlTd} + ${actionCell} + `; + } + + function renderPositionBlock(exchangeId, exchangeKey, x, monitorOrder, trendPlan, tickMap, opts) { + const options = opts || {}; + const compact = !!options.compact; + const reg = Array.isArray(x.regular_orders) ? x.regular_orders : []; + const cond = condOrdersFromPosition(x); + const ordersBlock = compact + ? "" + : renderOrdersCollapse(exchangeId, x.symbol, cond, reg, tickMap); + const rowHtml = renderPositionTableRow( + exchangeId, + exchangeKey, + x, + monitorOrder, + trendPlan, + tickMap, + opts + ); + return `
        +
        + ${positionTableHeadHtml(false)} + ${rowHtml} + +
        + ${ordersBlock} +
        `; + } + + const KEY_BUCKET_FIB_TYPES = new Set([ + "斐波回调0.618", + "斐波回调0.786", + "关键位斐波0.618", + "关键位斐波0.786", + ]); + const KEY_BUCKET_BREAKOUT_TYPES = new Set([ + "箱体突破", + "收敛突破", + "关键位箱体突破", + "关键位收敛突破", + "关键位收敛结构", + ]); + const KEY_BUCKET_WATCH_TYPES = new Set([ + "关键支撑阻力", + "关键阻力位", + "关键支撑位", + "关键位监控", + ]); + + function classifyKeyMonitorBucket(monitorType) { + const t = String(monitorType || "").trim(); + if (!t) return "watch"; + if (KEY_BUCKET_FIB_TYPES.has(t) || /斐波/.test(t)) return "fib"; + if (KEY_BUCKET_BREAKOUT_TYPES.has(t) || /突破/.test(t)) return "breakout"; + if (KEY_BUCKET_WATCH_TYPES.has(t) || /阻力|支撑/.test(t)) return "watch"; + return "watch"; + } + + function countKeyMonitorsByBucket(keys) { + const counts = { breakout: 0, fib: 0, watch: 0 }; + (keys || []).forEach((k) => { + if (!k || typeof k !== "object") return; + const bucket = classifyKeyMonitorBucket(k.monitor_type || k.type); + if (bucket === "breakout") counts.breakout += 1; + else if (bucket === "fib") counts.fib += 1; + else counts.watch += 1; + }); + return counts; + } + + function renderCardStrategyStats(row, hm, flaskOk, opts) { + const options = opts || {}; + const caps = row.capabilities || []; + const chips = []; + if (flaskOk && hm && typeof hm === "object") { + if (caps.includes("key") && !options.hideKeyChips) { + const kc = countKeyMonitorsByBucket(hm.keys || []); + if (kc.breakout > 0) chips.push({ kind: "key-breakout", label: `突破 ${kc.breakout}` }); + if (kc.fib > 0) chips.push({ kind: "key-breakout", label: `斐波 ${kc.fib}` }); + if (kc.watch > 0) chips.push({ kind: "key-watch", label: `监控 ${kc.watch}` }); + } + if (caps.includes("trend")) { + const trendN = Array.isArray(hm.trends) ? hm.trends.length : 0; + if (trendN > 0) chips.push({ kind: "trend", label: `趋势回调 ${trendN}` }); + } + const rollN = Array.isArray(hm.rolls) ? hm.rolls.length : 0; + if (rollN > 0) chips.push({ kind: "roll", label: `顺势加仓 ${rollN}` }); + } + // 永续独立卡不再显示「期权 N仓」(期权只看期权卡);手机磁贴仍可显示 + if (!options.hideOptions && (caps.includes("options") || rowHasOptionsLayout(row))) { + const opt = row.options || {}; + if (opt.enabled === false) { + chips.push({ kind: "options", label: "期权 · 未启用" }); + } else if (opt.ok === false) { + chips.push({ kind: "options", label: "期权 · 异常" }); + } else { + const n = Number( + opt.position_count != null ? opt.position_count : (opt.positions || []).length + ); + const nSafe = Number.isFinite(n) ? n : 0; + chips.push({ kind: "options", label: nSafe > 0 ? `期权 ${nSafe}仓` : "期权" }); + } + } + if (!chips.length) return ""; + return `
        ${chips + .map( + (c) => + `${esc(c.label)}` + ) + .join("")}
        `; + } + + function renderGridPositionsTable(exchangeId, exchangeKey, positions, orders, trends, tickMap, intradayDiscipline) { + const intraday = !!intradayDiscipline; + const rows = positions + .map((p) => + renderPositionTableRow( + exchangeId, + exchangeKey, + p, + findMonitorOrder(orders, p.symbol, p.side), + findTrendPlan(trends, p.symbol, p.side), + tickMap, + { compact: true, intradayDiscipline: intraday } + ) + ) + .join(""); + return `
        + ${positionTableHeadHtml(true)} + ${rows} + +
        `; + } + + function rowHasOptionsLayout(row) { + if (!(row.capabilities || []).includes("options")) return false; + const opt = row.options || {}; + return opt.enabled !== false; + } + + function sumUsdtEquiv(a, b) { + const vals = [a, b] + .map((v) => (v == null || v === "" ? null : Number(v))) + .filter((v) => v != null && Number.isFinite(v)); + if (!vals.length) return null; + return vals.reduce((s, v) => s + v, 0); + } + + function optionsBalanceFields(opt) { + const bal = (opt && opt.balances) || opt || {}; + return { + funding: sumUsdtEquiv(bal.funding_usdt, bal.funding_usdc), + trading: sumUsdtEquiv(bal.trading_usdt, bal.trading_usdc), + upl: opt && opt.upl_total_usdc != null && Number.isFinite(Number(opt.upl_total_usdc)) + ? Number(opt.upl_total_usdc) + : null, + }; + } + + function renderStatRow(funding, trading, upnl) { + if (!showAccountPnlPref()) return ""; + return `
        +
        资金账户
        ${fmt(funding, 2)} U
        +
        交易账户
        ${fmt(trading, 2)} U
        +
        浮盈合计
        ${fmt(upnl, 2)}
        +
        `; + } + + function renderAccountStatRow(row, ag) { + return renderStatRow(row.funding_usdt, row.trading_usdt, ag.total_unrealized_pnl); + } + + function shortOptionsInst(instId) { + const s = String(instId || ""); + if (s.length <= 22) return s; + return s.slice(0, 10) + "…" + s.slice(-8); + } + + function optionsExpiryCdHtml(expMs) { + const ms = expMs != null && expMs !== "" ? String(expMs) : ""; + if (!ms) return "—"; + return ``; + } + + function findOptionsTargetForInst(targets, instId) { + const want = String(instId || "").trim(); + if (!want) return null; + const list = Array.isArray(targets) ? targets : []; + return ( + list.find((t) => String((t && t.inst_id) || "").trim() === want) || + list.find((t) => String((t && t.instId) || "").trim() === want) || + null + ); + } + + function renderOptionsTargetCell(target) { + if (!target) return "—"; + const side = String(target.opt_type || "").toUpperCase() === "P" ? "Put≤" : "Call≥"; + const px = target.target_index != null ? fmt(target.target_index, 1) : "—"; + if (target.managed_by === "hedge_plan") { + return `对冲#${esc(target.plan_id)} ${esc(side)} ${esc(px)}`; + } + return `${esc(side)} ${esc(px)}`; + } + + function renderOptionsPositionsTable(pos, targets) { + if (!pos.length) return '
        暂无期权持仓
        '; + let html = '
        '; + html += + ""; + html += ""; + pos.forEach((p) => { + const optType = + (p.opt_type || "").toUpperCase() === "C" + ? "Call" + : (p.opt_type || "").toUpperCase() === "P" + ? "Put" + : p.opt_type || "—"; + const preview = p.close_preview || {}; + let net = preview.estimated_pnl; + if (net == null && preview.total_received != null && p.premium_paid != null) { + net = Number(preview.total_received) - Number(p.premium_paid); + } + let roi = preview.estimated_pnl_ratio_pct; + if (roi == null && net != null && Number(p.premium_paid) > 0) { + roi = (Number(net) / Number(p.premium_paid)) * 100; + } + const target = findOptionsTargetForInst(targets, p.inst_id); + html += ` + + + + + ${renderOptionsTargetCell(target)} + + + `; + }); + html += "
        合约类型张数到期倒计时目标监控净盈亏收益率
        ${esc(shortOptionsInst(p.inst_id))}${esc(optType)}${esc(p.pos)}${optionsExpiryCdHtml(p.exp_time_ms != null ? p.exp_time_ms : p.exp_time)}${net == null ? "—" : fmt(net, 2)}${roi == null ? "—" : esc(Number(roi).toFixed(2)) + "%"}
        "; + return html; + } + + function monitorOptionsSplitActive(rows) { + if (isMobileLayout() || expandedExchangeId) return false; + return (rows || []).some((r) => rowHasOptionsLayout(r)); + } + + function renderPerpetualInnerCard(row, ag, pos, orders, trends, tickMap, intraday) { + let html = '
        '; + html += '
        永续账户
        '; + html += '
        '; + html += renderAccountStatRow(row, ag); + html += renderPerpetualPositionsSection(row, ag, pos, orders, trends, tickMap, intraday); + html += "
        "; + return html; + } + + function renderOptionsPositionsCards(pos) { + if (!pos.length) return '
        暂无期权持仓
        '; + if (!globalThis.OptionsPositionCards || !OptionsPositionCards.renderCard) { + return renderOptionsPositionsTable(pos); + } + const cls = hubPosListCountClass(pos.length); + let html = `
        `; + pos.forEach((p) => { + html += OptionsPositionCards.renderCard(p, { readOnly: true, hub: true }); + }); + html += "
        "; + return html; + } + + function renderOptionsSectionBody(row, opts) { + const options = opts || {}; + const layout = options.layout || "table"; + const opt = row.options || {}; + let html = ""; + if (opt.enabled === false) { + html += renderStatRow(null, null, null); + html += '
        期权持仓
        '; + html += '
        期权未启用(OKX_OPTIONS_ENABLED)
        '; + } else if (opt.ok === false) { + html += renderStatRow(null, null, null); + html += '
        期权持仓
        '; + html += `
        ${esc(opt.msg || "期权数据不可用")}
        `; + } else { + const pos = Array.isArray(opt.positions) ? opt.positions : []; + const targets = Array.isArray(opt.target_monitors) ? opt.target_monitors : []; + const bal = optionsBalanceFields(opt); + html += renderStatRow(bal.funding, bal.trading, bal.upl); + html += `
        期权持仓 · ${pos.length} 仓
        `; + html += + layout === "cards" + ? renderOptionsPositionsCards(pos) + : renderOptionsPositionsTable(pos, targets); + } + return html; + } + + function renderOptionsInnerCard(row) { + let html = '
        '; + html += '
        期权账户
        '; + html += '
        '; + html += renderOptionsSectionBody(row); + html += "
        "; + return html; + } + + function renderOptionsMonitorSection(row, opts) { + if (!rowHasOptionsLayout(row)) return ""; + let html = '
        '; + html += '
        期权账户
        '; + html += renderOptionsSectionBody(row, opts); + html += "
        "; + return html; + } + + function renderPerpetualPositionsSection(row, ag, pos, orders, trends, tickMap, intraday) { + const n = Array.isArray(pos) ? pos.length : 0; + let html = ""; + html += `
        交易所持仓 · ${n} 仓
        `; + html += `
        `; + if (n) { + html += renderGridPositionsTable( + row.id, + row.key || row.id, + pos, + orders, + trends, + tickMap, + intraday + ); + } else { + html += '
        无持仓
        '; + } + html += "
        "; + return html; + } + + const HUB_EXPAND_HINT = "点击标题栏进入全屏 · 委托 / 关键位 / 下单监控 / 趋势回调 / 顺势加仓"; + + function renderGridBody(row, ag, pos, hm, flaskOk, keys, orders, trends, rolls, kmap, layoutOpts) { + const layout = layoutOpts || {}; + const tickMap = buildPriceTickMap(row); + const intraday = isIntradayDisciplineRow(row); + const expandHint = `
        ${esc(HUB_EXPAND_HINT)}
        `; + let inner = ""; + if (layout.okxPart === "perp" || layout.splitSide) { + /* 桌面分栏:仓位预留 + 原关键位/趋势/顺势芯片 + 底栏提示;不显示期权 N仓 */ + if (layout.okxPart === "options") { + inner += renderOptionsSectionBody(row); + inner += expandHint; + return inner; + } + if (layout.okxPart === "perp" || !rowHasOptionsLayout(row)) { + inner += renderAccountStatRow(row, ag); + inner += renderPerpetualPositionsSection(row, ag, pos, orders, trends, tickMap, intraday); + inner += renderCardStrategyStats(row, hm, flaskOk, { hideOptions: true }); + if (intraday) { + inner += `
        日内纪律:禁手动平仓/改委托 · 整点强制清仓${row.force_close && row.force_close.enabled ? " · " + esc(row.force_close.label || "强制清仓") : ""}
        `; + } else { + inner += expandHint; + } + return inner; + } + } + if (layout.okxPart === "options") { + inner += renderOptionsSectionBody(row); + inner += expandHint; + return inner; + } + if (rowHasOptionsLayout(row)) { + if (monitorGridOptionsSplit) { + inner += '
        '; + inner += renderPerpetualInnerCard(row, ag, pos, orders, trends, tickMap, intraday); + inner += renderOptionsInnerCard(row); + inner += "
        "; + } else { + inner += '
        '; + inner += '
        永续账户
        '; + inner += renderAccountStatRow(row, ag); + inner += renderPerpetualPositionsSection(row, ag, pos, orders, trends, tickMap, intraday); + inner += "
        "; + inner += renderOptionsMonitorSection(row); + } + } else { + inner += renderAccountStatRow(row, ag); + inner += renderPerpetualPositionsSection(row, ag, pos, orders, trends, tickMap, intraday); + } + inner += renderCardStrategyStats(row, hm, flaskOk); + inner += intraday + ? `
        日内纪律:禁手动平仓/改委托 · 整点强制清仓${row.force_close && row.force_close.enabled ? " · " + esc(row.force_close.label || "强制清仓") : ""}
        ` + : expandHint; + return inner; + } + + function renderFullscreenExchange(row) { + const tickMap = buildPriceTickMap(row); + const ag = row.agent || {}; + const pos = Array.isArray(ag.positions) ? ag.positions : []; + const hm = row.hub_monitor || {}; + const flaskOk = row.flask_ok !== false && hm.ok !== false; + const keys = flaskOk ? hm.keys || [] : []; + const orders = flaskOk ? hm.orders || [] : []; + const trends = flaskOk ? hm.trends || [] : []; + const rolls = flaskOk ? hm.rolls || [] : []; + const kmap = {}; + (row.key_prices || []).forEach((k) => { + kmap[k.id] = k; + }); + const flaskOpen = row.flask_url_browser || row.flask_url; + const intraday = isIntradayDisciplineRow(row); + const fcHeadBadge = intraday ? forceCloseHeadBadgeHtml(row.force_close) : ""; + let html = `
        +
        +

        ${esc(row.name)}${fcHeadBadge ? " " + fcHeadBadge : ""}

        +
        ${esc(flaskOpen || "")}
        +
        +
        + + ${flaskOpen ? `打开实例` : ""} + ${flaskOpen ? `下单` : ""} + ${flaskOpen ? `监控位` : ""} + ${flaskOpen ? `复盘` : ""} + ${flaskOpen && (row.capabilities || []).includes("options") ? `期权` : ""} + ${intraday ? "" : ``} +
        +
        `; + if (!row.http_ok || ag.ok === false) { + html += `
        ${esc(row.error || ag.error || "子代理不可用")}
        `; + return html; + } + const posCount = pos.length; + const posListCls = hubPosListCountClass(posCount); + if (rowHasOptionsLayout(row)) { + html += '
        '; + html += '
        永续账户
        '; + } + html += renderAccountStatRow(row, ag); + html += `
        交易所持仓(${posCount} 仓 · 每币种一卡)
        `; + html += `
        `; + if (posCount) { + pos.forEach((p) => { + html += renderLivePositionCard( + row.id, + row.key || row.id, + p, + findMonitorOrder(orders, p.symbol, p.side), + findTrendPlan(trends, p.symbol, p.side), + tickMap, + intraday + ); + }); + } else { + html += '
        暂无持仓
        '; + } + html += "
        "; + if (rowHasOptionsLayout(row)) { + html += "
        "; + } + html += renderOptionsMonitorSection(row, { layout: "cards" }); + html += '
        '; + if ((row.capabilities || []).includes("key")) { + if (!flaskOk) { + html += renderHubSectionCard("关键位", `
        ${esc(row.flask_error || hm.error || "Flask 未连通")}
        `, ""); + } else { + html += renderHubSectionCard( + `关键位 · ${keys.length}`, + renderKeySection(keys, kmap), + "当前无关键位记录" + ); + } + } + html += renderHubSectionCard("下单监控", renderOrderMonitorSection(orders, tickMap), "暂无运行中的下单监控"); + if ((row.capabilities || []).includes("trend")) { + html += renderHubSectionCard( + "趋势回调", + renderTrendSection(trends, tickMap, pos, row), + "暂无运行中的趋势回调计划" + ); + } + html += renderHubSectionCard("顺势加仓", renderRollSection(rolls, tickMap), "暂无运行中的顺势加仓组"); + html += "
        "; + return html; + } + + function openTpslModal(exchangeId, symbol, side, contracts, slHint, tpHint) { + tpslPending = { + exchangeId, + symbol, + side: (side || "long").toLowerCase(), + contracts: parseFloat(contracts), + }; + const modal = document.getElementById("tpsl-modal"); + const meta = document.getElementById("tpsl-modal-meta"); + const slIn = document.getElementById("tpsl-sl"); + const tpIn = document.getElementById("tpsl-tp"); + if (!modal || !meta || !slIn || !tpIn) return; + meta.textContent = `${symbol} · ${side} · ${contracts} 张`; + slIn.value = slHint !== "" && slHint != null ? String(slHint) : ""; + tpIn.value = tpHint !== "" && tpHint != null ? String(tpHint) : ""; + modal.classList.remove("hidden"); + modal.setAttribute("aria-hidden", "false"); + slIn.focus(); + } + + function closeTpslModal() { + tpslPending = null; + const modal = document.getElementById("tpsl-modal"); + if (modal) { + modal.classList.add("hidden"); + modal.setAttribute("aria-hidden", "true"); + } + } + + async function submitTpslModal() { + if (!tpslPending) return; + const slIn = document.getElementById("tpsl-sl"); + const tpIn = document.getElementById("tpsl-tp"); + const sl = parseFloat(slIn && slIn.value); + const tp = parseFloat(tpIn && tpIn.value); + if (!sl || sl <= 0 || !tp || tp <= 0) { + showToast("请填写有效的止损价与止盈价", true); + return; + } + const { exchangeId, symbol, side, contracts } = tpslPending; + if ( + !confirm( + `确认 ${symbol} ${side}\n先撤销全部条件单,再挂止损 ${sl},止盈 ${tp}?` + ) + ) { + return; + } + const btn = document.getElementById("tpsl-submit"); + if (btn) btn.disabled = true; + try { + const r = await apiFetch( + "/api/orders/" + encodeURIComponent(exchangeId) + "/place-tpsl", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + symbol, + side, + stop_loss: sl, + take_profit: tp, + contracts: contracts > 0 ? contracts : null, + }), + } + ); + const j = await r.json(); + const pl = j.payload || {}; + const ok = j.ok && pl.ok !== false; + const n = pl.placed && pl.placed.cancelled_conditional; + showToast( + ok + ? `已挂单(已撤 ${n != null ? n : "?"} 笔旧条件单)` + : pl.error || JSON.stringify(j), + !ok + ); + if (ok) { + closeTpslModal(); + refreshMonitorBoardNow(); + } + } catch (e) { + showToast(String(e), true); + } finally { + if (btn) btn.disabled = false; + } + } + + function initInstanceFrame() { + const back = document.getElementById("instance-frame-back"); + const refresh = document.getElementById("instance-frame-refresh"); + const newTab = document.getElementById("instance-frame-newtab"); + const frame = document.getElementById("instance-frame"); + if (frame && frame.dataset.hubNavBound !== "1") { + frame.dataset.hubNavBound = "1"; + frame.addEventListener("load", () => setInstanceFrameNavLoading(false)); + } + if (!window.__hubInstanceFrameMsgBound) { + window.__hubInstanceFrameMsgBound = true; + window.addEventListener("message", (ev) => { + const d = ev.data; + if (!d || typeof d !== "object") return; + if (d.type === "instance-frame-navigating") { + if (d.embedShellTab) return; + setInstanceFrameNavLoading(true); + } else if (d.type === "instance-frame-ready") { + setInstanceFrameNavLoading(false); + } + }); + } + if (back) back.onclick = () => closeInstanceFrame(); + if (refresh) refresh.onclick = () => refreshInstanceFrame(); + if (newTab) { + newTab.onclick = () => { + if (instanceFrameCtx) { + openInstance(instanceFrameCtx.exchangeId, instanceFrameCtx.nextPath, { + newTab: true, + }); + return; + } + if (instanceFrameUrl) window.open(instanceFrameUrl, "_blank", "noopener"); + }; + } + } + + function initFullscreen() { + const backdrop = document.getElementById("exchange-fullscreen-backdrop"); + if (backdrop) { + backdrop.onclick = () => { + closeExchangeFullscreen(); + renderMonitorGrid(lastMonitorRows); + }; + } + const fs = document.getElementById("exchange-fullscreen"); + if (fs && !expandedExchangeId) { + fs.classList.add("hidden"); + fs.setAttribute("aria-hidden", "true"); + } + } + + function initTpslModal() { + const backdrop = document.getElementById("tpsl-modal-backdrop"); + const cancel = document.getElementById("tpsl-cancel"); + const submit = document.getElementById("tpsl-submit"); + if (backdrop) backdrop.onclick = closeTpslModal; + if (cancel) cancel.onclick = closeTpslModal; + if (submit) submit.onclick = () => submitTpslModal(); + document.addEventListener("keydown", (ev) => { + if (ev.key === "Escape") { + closeTpslModal(); + const shell = document.getElementById("instance-frame-shell"); + if (shell && !shell.classList.contains("hidden")) { + closeInstanceFrame(); + return; + } + if (expandedExchangeId) { + closeExchangeFullscreen(); + renderMonitorGrid(lastMonitorRows); + } + } + }); + } + + async function cancelOneOrder(exchangeId, symbol, orderId, channel) { + if (!confirm(`撤销委托 ${symbol} #${orderId}?`)) return; + try { + const r = await apiFetch("/api/orders/" + encodeURIComponent(exchangeId) + "/cancel", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ symbol, order_id: orderId, channel: channel || "regular" }), + }); + const j = await r.json(); + const pl = j.payload || {}; + const ok = j.ok && pl.ok !== false; + showToast(ok ? "已撤单" : pl.error || JSON.stringify(j), !ok); + refreshMonitorBoardNow(); + } catch (e) { + showToast(String(e), true); + } + } + + async function cancelSymbolOrders(exchangeId, symbol, scope) { + const label = scope === "conditional" ? "全部条件单" : "全部委托"; + if (!confirm(`确认撤销 ${symbol} 的${label}?`)) return; + try { + const r = await apiFetch( + "/api/orders/" + encodeURIComponent(exchangeId) + "/cancel-symbol", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ symbol, scope }), + } + ); + const j = await r.json(); + const pl = j.payload || {}; + const ok = j.ok && pl.ok !== false; + const n = pl.cancelled_count != null ? pl.cancelled_count : "?"; + showToast(ok ? `已撤销 ${n} 笔` : pl.error || JSON.stringify(j), !ok); + refreshMonitorBoardNow(); + } catch (e) { + showToast(String(e), true); + } + } + + function renderMonitorTile(row) { + const ag = row.agent || {}; + const pos = Array.isArray(ag.positions) ? ag.positions : []; + const alert = analyzeExchangeAlert(row); + const upnl = ag.total_unrealized_pnl; + const openCount = pos.filter(positionHasContracts).length; + const dotCls = + alert.level === "error" ? "bad" : alert.level === "warn" ? "warn" : "ok"; + const tileCls = + alert.level === "error" + ? "hub-tile-error" + : alert.level === "warn" + ? "hub-tile-warn" + : "hub-tile-ok"; + const ts = (lastMonitorBoardUpdatedAt || "").replace("T", " "); + const tsShort = ts ? ts.slice(-8) : "—"; + const posLine = + openCount > 0 ? `${openCount}仓 · ${alert.summary}` : alert.summary; + const opt = row.options || {}; + const hasOptCap = (row.capabilities || []).includes("options") || rowHasOptionsLayout(row); + let optLine = ""; + let pnlShow = upnl; + let pnlSuffix = ""; + if (hasOptCap) { + if (opt.enabled === false) { + optLine = "期权未启用"; + } else if (opt.ok === false) { + optLine = "期权异常"; + } else { + const optCount = Number( + opt.position_count != null ? opt.position_count : (opt.positions || []).length + ); + const n = Number.isFinite(optCount) ? optCount : 0; + const bal = typeof optionsBalanceFields === "function" ? optionsBalanceFields(opt) : {}; + const optUpl = bal && bal.upl != null ? bal.upl : null; + optLine = n > 0 ? `期权 ${n}仓` : "期权 空仓"; + if (optUpl != null && Number.isFinite(Number(optUpl))) { + optLine += ` · 浮盈 ${fmt(optUpl, 2)}U`; + // 永续空仓时主数字优先展示期权浮盈,避免一直显示 0U + if (openCount === 0) { + pnlShow = optUpl; + pnlSuffix = "期权"; + } + } + } + } + const hm = row.hub_monitor || {}; + const flaskOk = row.flask_ok !== false && hm.ok !== false; + const strategyStats = renderCardStrategyStats(row, hm, flaskOk); + return `
        +
        +
        + + ${esc(row.name)} + ${formatRiskStatusBadge(hm.risk_status)} +
        + ${ + showAccountPnlPref() + ? `
        ${fmt(pnlShow, 2)} U${ + pnlSuffix ? " · " + pnlSuffix : "" + }
        ` + : "" + } +
        ${esc(posLine)}
        + ${optLine ? `
        ${esc(optLine)}
        ` : ""} + ${strategyStats} +
        UPD ${esc(tsShort)}
        +
        +
        `; + } + + function renderMonitorCard(row, layoutOpts) { + const opts = layoutOpts || {}; + const ag = row.agent || {}; + const pos = Array.isArray(ag.positions) ? ag.positions : []; + const hm = row.hub_monitor || {}; + const flaskOk = row.flask_ok !== false && hm.ok !== false; + const keys = flaskOk ? hm.keys || [] : []; + const orders = flaskOk ? hm.orders || [] : []; + const trends = flaskOk ? hm.trends || [] : []; + const rolls = flaskOk ? hm.rolls || [] : []; + const kmap = {}; + (row.key_prices || []).forEach((k) => { + kmap[k.id] = k; + }); + let inner = ""; + const agOk = ag.ok !== false; + const agErr = ag.error || row.error || ""; + if (!row.http_ok) { + inner = `
        ${esc(row.error || "子代理不可用")}
        `; + } else if (!agOk) { + inner = `
        ${esc(agErr || "子代理返回失败")}
        `; + inner += `
        请检查 PM2 子代理与 ${esc(row.agent_url || "")}/status
        `; + } else { + inner = renderGridBody(row, ag, pos, hm, flaskOk, keys, orders, trends, rolls, kmap, opts); + } + const online = row.http_ok && agOk; + const cardCls = online ? "card-online" : "card-offline"; + const dotCls = online ? "ok" : "bad"; + const flaskOpen = row.flask_url_browser || row.flask_url; + const okxPart = opts.okxPart || ""; + const isOkxOptionsCard = okxPart === "options"; + const isOkxPerpCard = okxPart === "perp"; + const titleSuffix = isOkxPerpCard ? " · 永续" : isOkxOptionsCard ? " · 期权" : ""; + const openFlask = flaskOpen + ? `打开实例` + : ""; + const openTrade = + flaskOpen && !isOkxOptionsCard + ? `下单` + : ""; + const openKey = + flaskOpen && !isOkxOptionsCard + ? `监控位` + : ""; + const openReview = flaskOpen + ? `复盘` + : ""; + const openOptions = + flaskOpen && ((row.capabilities || []).includes("options") || isOkxOptionsCard) + ? `期权` + : ""; + const intraday = isIntradayDisciplineRow(row); + const fcHeadBadge = intraday && !isOkxOptionsCard ? forceCloseHeadBadgeHtml(row.force_close) : ""; + const showCloseAll = !intraday && !isOkxOptionsCard; + const layoutCls = opts.okxSplit + ? " card-monitor-okx-split" + : opts.splitSide || okxPart + ? " card-monitor-split-side" + : ""; + const partAttr = okxPart ? ` data-okx-part="${esc(okxPart)}"` : ""; + return `
        +
        +
        +
        + +
        ${esc(row.name)}${esc(titleSuffix)}${fcHeadBadge}${formatRiskStatusBadge(hm.risk_status)}
        +
        +
        ${esc(flaskOpen || "")}
        +
        +
        + ${openFlask} + ${openTrade} + ${openKey} + ${openOptions} + ${openReview} + ${showCloseAll ? `` : ""} +
        +
        +
        ${inner}
        +
        `; + } + + async function hubTrendPlanStop(exchangeId, planId) { + if (!exchangeId || !planId) { + showToast("缺少交易所或计划 ID", true); + return; + } + if (!confirm("结束计划:市价平仓并撤掉该合约全部挂单,确定?")) return; + try { + const r = await apiFetch("/api/trend/" + encodeURIComponent(exchangeId) + "/stop", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ plan_id: Number(planId) }), + }); + const j = await r.json(); + showToast(j.message || (j.ok ? "已结束趋势回调计划" : "结束失败"), !j.ok); + if (j.ok) refreshMonitorBoardNow(); + } catch (e) { + showToast(String(e), true); + } + } + + async function hubTrendPlanBreakeven(exchangeId, planId, inputEl) { + if (!exchangeId || !planId) { + showToast("缺少交易所或计划 ID", true); + return; + } + const raw = inputEl ? String(inputEl.value || "").trim() : ""; + let pct = null; + if (raw !== "") { + pct = Number(raw); + if (!Number.isFinite(pct) || pct < 0) { + showToast("保本偏移% 须为非负数", true); + return; + } + } + if ( + !confirm( + "确认保本?将结束本趋势计划,持仓移交「下单监控」,并在交易所挂保本止损与计划止盈;后续平仓写入交易记录." + ) + ) { + return; + } + try { + const body = { plan_id: Number(planId) }; + if (pct != null) body.breakeven_offset_pct = pct; + const r = await apiFetch("/api/trend/" + encodeURIComponent(exchangeId) + "/breakeven", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const j = await r.json(); + showToast(j.message || (j.ok ? "保本移交成功" : "保本移交失败"), !j.ok); + if (j.ok) refreshMonitorBoardNow(); + } catch (e) { + showToast(String(e), true); + } + } + + async function closeOnePosition(exchangeId, symbol, side) { + const label = `${symbol} · ${side}`; + if (!confirm(`确认对该账户市价平仓:${label}?`)) return; + try { + const r = await apiFetch( + "/api/close/" + encodeURIComponent(exchangeId) + "/position", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ symbol, side }), + } + ); + const j = await r.json(); + const pl = j.payload || {}; + const ok = j.ok && pl.ok !== false; + const msg = + (ok && pl.closed + ? `已平仓 ${pl.closed.symbol} ${pl.closed.side} · 张数 ${pl.closed.amount}` + : pl.error) || JSON.stringify(j, null, 2); + showToast(msg, !ok); + refreshMonitorBoardNow(); + } catch (e) { + showToast(String(e), true); + } + } + + async function closeOne(id) { + if (!confirm("确认对该账户市价全平?")) return; + try { + const r = await apiFetch("/api/close/" + encodeURIComponent(id), { method: "POST" }); + const j = await r.json(); + showToast(JSON.stringify(j, null, 2), !r.ok); + refreshMonitorBoardNow(); + } catch (e) { + showToast(String(e), true); + } + } + + async function closeAll() { + const n = enabledAccounts().length; + if (!confirm(`对 ${n} 个已启用账户执行紧急全平?`)) return; + try { + const r = await apiFetch("/api/close-all", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ exclude_ids: [] }), + }); + const j = await r.json(); + showToast(JSON.stringify(j, null, 2), !r.ok); + refreshMonitorBoardNow(); + } catch (e) { + showToast(String(e), true); + } + } + + async function loadSettingsMetaLine() { + try { + const r = await apiFetch("/api/settings/meta"); + const m = await r.json(); + const el = document.getElementById("settings-meta-line"); + if (!el) return; + const parts = []; + if (m.password_required) parts.push("已启用用户名+密码登录"); + else parts.push("未设 HUB_PASSWORD(反代公网暴露时建议设置 HUB_USERNAME + HUB_PASSWORD)"); + const userEl = document.getElementById("hub-pwd-current-user"); + if (userEl && m.default_username) userEl.textContent = m.default_username; + if (m.hub_bridge_token_set) parts.push("中控已配置 HUB_BRIDGE_TOKEN"); + else parts.push("中控未设 HUB_BRIDGE_TOKEN(实例需 APP_AUTH_DISABLED 或同令牌)"); + if (m.public_origin) parts.push("浏览器外链基址: " + m.public_origin); + else parts.push("未设 HUB_PUBLIC_ORIGIN(复盘链接仅本机可开)"); + if ((m.env_disabled_ids || []).length) { + parts.push("环境强制关闭 id: " + m.env_disabled_ids.join(", ") + "(改 .env 后须重启 hub)"); + } else { + parts.push("HUB_DISABLED_IDS 未强制关闭任何账户"); + } + el.textContent = parts.join(" · "); + } catch (_) {} + } + + function renderSettingsList(data) { + const list = document.getElementById("settings-list"); + if (!list) return; + list.innerHTML = (data.exchanges || []) + .map((ex, idx) => renderSettingsCard(ex, idx)) + .join(""); + list.querySelectorAll(".btn-del-ex").forEach((btn) => { + btn.onclick = () => { + const i = Number(btn.dataset.idx); + data.exchanges.splice(i, 1); + settingsCache = data; + renderSettingsList(data); + }; + }); + bindSettingsCardFolds(list); + list.querySelectorAll(".settings-card-save").forEach((btn) => { + btn.addEventListener("click", () => { + void saveSettingsSection("exchange", { label: btn.dataset.label || "账户" }); + }); + }); + } + + const SETTINGS_FOLD_KEY = "hub_settings_section_fold"; + + function settingsFoldStorageKey(section, cardKey) { + return cardKey ? `${SETTINGS_FOLD_KEY}_${section}_${cardKey}` : `${SETTINGS_FOLD_KEY}_${section}`; + } + + function getSettingsFoldState(section, cardKey) { + try { + return localStorage.getItem(settingsFoldStorageKey(section, cardKey)) === "1"; + } catch (_) { + return false; + } + } + + function setSettingsFoldState(section, collapsed, cardKey) { + try { + localStorage.setItem(settingsFoldStorageKey(section, cardKey), collapsed ? "1" : "0"); + } catch (_) {} + } + + function applySettingsSectionFold(el) { + const section = el.dataset.settingsSection; + if (!section) return; + const collapsed = getSettingsFoldState(section); + el.classList.toggle("is-collapsed", collapsed); + const btn = el.querySelector(":scope > .settings-section-head > .settings-section-fold"); + if (btn) btn.setAttribute("aria-expanded", collapsed ? "false" : "true"); + } + + function applySettingsCardFold(card) { + const key = card.dataset.key || card.dataset.idx || ""; + const collapsed = getSettingsFoldState("exchange", String(key)); + card.classList.toggle("is-collapsed", collapsed); + const btn = card.querySelector(".settings-card-fold"); + if (btn) btn.setAttribute("aria-expanded", collapsed ? "false" : "true"); + } + + function bindSettingsCardFolds(root) { + (root || document).querySelectorAll(".settings-card").forEach((card) => { + if (card.dataset.foldBound === "1") return; + card.dataset.foldBound = "1"; + applySettingsCardFold(card); + const foldBtn = card.querySelector(".settings-card-fold"); + if (!foldBtn) return; + foldBtn.addEventListener("click", () => { + const key = card.dataset.key || card.dataset.idx || ""; + const collapsed = !card.classList.contains("is-collapsed"); + card.classList.toggle("is-collapsed", collapsed); + foldBtn.setAttribute("aria-expanded", collapsed ? "false" : "true"); + setSettingsFoldState("exchange", collapsed, String(key)); + }); + }); + } + + function initSettingsSectionFolds() { + document.querySelectorAll(".settings-section-save").forEach((btn) => { + if (btn.dataset.saveBound === "1") return; + btn.dataset.saveBound = "1"; + btn.addEventListener("click", () => { + const section = btn.dataset.settingsSection || ""; + if (section === "macro") { + const form = document.getElementById("macro-event-form"); + if (form) form.requestSubmit(); + return; + } + const label = + section === "display" + ? "显示与导航" + : section === "supervisor" + ? "交易监管" + : section === "exchanges" + ? "交易所账户" + : section === "backup" + ? "备份设置" + : "设置"; + if (section === "backup") return; + void saveSettingsSection(section, { label }); + }); + }); + } + + async function waitHubHealth() { + const deadline = Date.now() + 90000; + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 2000)); + try { + const r = await fetch("/api/admin/health", { credentials: "same-origin" }); + if (r.ok) return; + } catch (_) {} + } + throw new Error("重启后中控未在预期时间内恢复"); + } + + async function saveHubPassword() { + const status = document.getElementById("hub-pwd-save-status"); + const setStatus = (msg, err) => { + if (!status) return; + status.textContent = msg || ""; + status.classList.toggle("is-err", !!err); + }; + setStatus("保存中…"); + try { + const r = await apiFetch("/api/settings/password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + old_password: (document.getElementById("hub-pwd-old") || {}).value || "", + new_username: (document.getElementById("hub-pwd-new-username") || {}).value || "", + new_password: (document.getElementById("hub-pwd-new") || {}).value || "", + confirm_password: (document.getElementById("hub-pwd-confirm") || {}).value || "", + }), + }); + const j = await r.json(); + if (!r.ok) throw new Error(j.detail || j.msg || "保存失败"); + if (!j.restart_required) { + setStatus("未修改(与当前配置相同)"); + showToast("未修改"); + return; + } + setStatus("密码已保存,正在重启中控…"); + await apiFetch("/api/admin/restart", { method: "POST" }); + await waitHubHealth(); + setStatus("密码已更新,请用新密码重新登录"); + showToast("密码已更新,请重新登录"); + setTimeout(() => { + location.href = "/login"; + }, 1200); + } catch (e) { + setStatus(String(e.message || e), true); + showToast(String(e.message || e), true); + } + } + + function initHubPasswordSettings() { + const btn = document.getElementById("hub-pwd-save-btn"); + if (!btn || btn.dataset.bound === "1") return; + btn.dataset.bound = "1"; + btn.addEventListener("click", () => { + void saveHubPassword(); + }); + } + + function renderHubAiSyncStatus(sync) { + const el = document.getElementById("hub-ai-sync-status"); + if (!el) return; + if (!sync) { + el.textContent = ""; + return; + } + if (sync.all_synced) { + el.textContent = "三所实例 AI 配置已与中控同步"; + el.classList.remove("is-err"); + return; + } + const parts = []; + const inst = sync.instances || {}; + Object.keys(inst).forEach((ex) => { + const row = inst[ex]; + if (row && !row.ok) parts.push(`${ex} 不一致`); + }); + el.textContent = parts.length ? `未完全同步:${parts.join(",")}` : "同步状态未知"; + el.classList.add("is-err"); + } + + function renderHubAiEnvFields(fields) { + const box = document.getElementById("hub-ai-env-fields"); + if (!box) return; + box.innerHTML = (fields || []) + .map((f) => { + const wide = f.key === "OPENAI_API_BASE" || f.key === "OLLAMA_API" ? " field-wide" : ""; + const inputType = f.sensitive ? "password" : f.type === "int" ? "number" : "text"; + const placeholder = f.sensitive && f.has_value ? f.masked || "****" : f.note || ""; + const value = f.sensitive ? "" : esc(f.current || f.default || ""); + const note = f.note ? `${esc(f.note)}` : ""; + return `
        + + +
        `; + }) + .join(""); + } + + async function loadHubAiEnvSettings() { + const box = document.getElementById("hub-ai-env-fields"); + if (!box) return; + try { + const r = await apiFetch("/api/settings/ai-env"); + const j = await r.json(); + if (!r.ok) throw new Error(j.detail || "加载失败"); + renderHubAiEnvFields(j.fields || []); + renderHubAiSyncStatus(j.sync_status); + } catch (e) { + box.innerHTML = `${esc(String(e.message || e))}`; + } + } + + function collectHubAiEnvValues() { + const values = {}; + document.querySelectorAll(".hub-ai-field[data-ai-key]").forEach((el) => { + values[el.dataset.aiKey] = el.value; + }); + return values; + } + + async function parseApiJson(r) { + const ct = (r.headers.get("content-type") || "").toLowerCase(); + if (!ct.includes("application/json")) { + const text = await r.text(); + const snippet = (text || "").replace(/\s+/g, " ").trim().slice(0, 120); + throw new Error(snippet ? `服务返回非 JSON:${snippet}` : `HTTP ${r.status}`); + } + return r.json(); + } + + async function saveHubAiEnv() { + const status = document.getElementById("hub-ai-env-save-status"); + const setStatus = (msg, err) => { + if (!status) return; + status.textContent = msg || ""; + status.classList.toggle("is-err", !!err); + }; + setStatus("保存并同步中…"); + try { + const r = await apiFetch("/api/settings/ai-env", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ values: collectHubAiEnvValues(), restart: true }), + }); + const j = await parseApiJson(r); + if (!r.ok) throw new Error(j.detail || j.msg || "保存失败"); + if (!j.changed || !Object.keys(j.changed).length) { + setStatus("未修改(与当前配置相同)"); + showToast("未修改"); + return; + } + if (j.restart_required) { + setStatus("已同步三所,服务重启中…"); + await waitHubHealth(); + } + setStatus("AI 配置已保存并同步至三所实例"); + showToast("AI 配置已保存并同步"); + await loadHubAiEnvSettings(); + } catch (e) { + setStatus(String(e.message || e), true); + showToast(String(e.message || e), true); + } + } + + function initHubAiEnvSettings() { + const btn = document.getElementById("hub-ai-env-save-btn"); + if (btn && btn.dataset.bound !== "1") { + btn.dataset.bound = "1"; + btn.addEventListener("click", () => { + void saveHubAiEnv(); + }); + } + void loadHubAiEnvSettings(); + } + + function macroDatetimeLocalToApi(v) { + if (!v) return ""; + return String(v).trim().replace("T", " ").slice(0, 16); + } + + function macroApiToDatetimeLocal(s) { + if (!s) return ""; + return String(s).trim().replace(" ", "T").slice(0, 16); + } + + function resetMacroEventForm() { + macroCalendarEditId = null; + const form = document.getElementById("macro-event-form"); + const cancel = document.getElementById("macro-event-cancel"); + const submit = document.getElementById("macro-event-submit"); + if (form) form.reset(); + if (cancel) cancel.classList.add("hidden"); + if (submit) submit.textContent = "添加"; + } + + function renderMacroEventList(events) { + const box = document.getElementById("macro-event-list"); + if (!box) return; + const rows = events || []; + if (!rows.length) { + box.innerHTML = '
        暂无已录入的关键数据.请在上方添加 FOMC / CPI / 就业发布时间.
        '; + return; + } + const now = Date.now(); + box.innerHTML = rows + .map((ev) => { + const start = Number(ev.event_at_ms) - 3600000; + const end = Number(ev.event_at_ms) + 3600000; + const active = now >= start && now <= end; + const note = ev.note ? `
        ${esc(ev.note)}
        ` : ""; + return `
        +
        +
        ${esc(ev.event_type_label || ev.event_type)}
        + ${note} +
        +
        ${esc(ev.event_at || "")}
        +
        ${active ? "窗口内" : "待触发"} · ±1h
        +
        + + +
        +
        `; + }) + .join(""); + box.querySelectorAll(".macro-event-edit").forEach((btn) => { + btn.addEventListener("click", () => { + const id = Number(btn.getAttribute("data-id")); + const row = rows.find((x) => Number(x.id) === id); + if (!row) return; + macroCalendarEditId = id; + const typeEl = document.getElementById("macro-event-type"); + const atEl = document.getElementById("macro-event-at"); + const noteEl = document.getElementById("macro-event-note"); + const cancel = document.getElementById("macro-event-cancel"); + const submit = document.getElementById("macro-event-submit"); + if (typeEl) typeEl.value = row.event_type || "fomc"; + if (atEl) atEl.value = macroApiToDatetimeLocal(row.event_at || ""); + if (noteEl) noteEl.value = row.note || ""; + if (cancel) cancel.classList.remove("hidden"); + if (submit) submit.textContent = "保存"; + }); + }); + box.querySelectorAll(".macro-event-del").forEach((btn) => { + btn.addEventListener("click", async () => { + const id = btn.getAttribute("data-id"); + if (!id || !confirm("确定删除这条宏观关键数据?")) return; + try { + const r = await apiFetch(`/api/macro-calendar/events/${id}`, { method: "DELETE" }); + const j = await r.json(); + if (!j.ok) throw new Error(j.detail || "删除失败"); + showToast("已删除"); + resetMacroEventForm(); + await loadMacroCalendarUI(); + void refreshMacroRiskBanner(lastMonitorRows); + } catch (e) { + showToast(String(e), true); + } + }); + }); + } + + async function loadMacroCalendarUI() { + const box = document.getElementById("macro-event-list"); + if (!box) return; + try { + const r = await apiFetch("/api/macro-calendar/events"); + const j = await r.json(); + renderMacroEventList((j.ok && j.events) || []); + } catch (e) { + box.innerHTML = `
        ${esc(String(e))}
        `; + } + } + + function initMacroCalendarSettings() { + const form = document.getElementById("macro-event-form"); + const cancel = document.getElementById("macro-event-cancel"); + if (cancel) { + cancel.addEventListener("click", () => resetMacroEventForm()); + } + if (!form || form.dataset.bound === "1") return; + form.dataset.bound = "1"; + form.addEventListener("submit", async (ev) => { + ev.preventDefault(); + const typeEl = document.getElementById("macro-event-type"); + const atEl = document.getElementById("macro-event-at"); + const noteEl = document.getElementById("macro-event-note"); + const payload = { + event_type: typeEl ? typeEl.value : "", + event_at: macroDatetimeLocalToApi(atEl ? atEl.value : ""), + note: noteEl ? noteEl.value : "", + }; + try { + const editing = macroCalendarEditId != null; + const r = await apiFetch( + editing + ? `/api/macro-calendar/events/${macroCalendarEditId}` + : "/api/macro-calendar/events", + { + method: editing ? "PATCH" : "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + } + ); + const j = await r.json(); + if (!r.ok || !j.ok) throw new Error(j.detail || "保存失败"); + showToast(editing ? "已更新" : "已添加"); + resetMacroEventForm(); + await loadMacroCalendarUI(); + void refreshMacroRiskBanner(lastMonitorRows); + } catch (e) { + showToast(String(e), true); + } + }); + } + + function loadSettingsUI() { + loadSettingsMetaLine(); + initMacroCalendarSettings(); + initHubPasswordSettings(); + initHubAiEnvSettings(); + loadMacroCalendarUI(); + loadSettings().then((data) => { + syncDisplayPrefsUI(data); + syncSupervisorSettingsUI(data); + renderSettingsList(data); + initSettingsSectionFolds(); + if (typeof initBackupSettingsUI === "function") void initBackupSettingsUI(); + }); + } + + function renderSettingsCard(ex, idx) { + const caps = ex.capabilities || []; + const envOff = ex.env_disabled + ? '环境变量强制关' + : ""; + const cardKey = esc(ex.key || ex.id || String(idx)); + const cardTitle = esc(ex.name || ex.key || `账户 ${idx + 1}`); + return `
        +
        + + ${cardTitle} + +
        +
        +
        + + ${envOff} + +
        +
        +
        +
        +
        +
        +
        + + + +
        +
        +
        + +
        +
        +
        `; + } + + function collectSettingsFromUI() { + const rows = [...document.querySelectorAll("#settings-list .settings-card")]; + const pnlCb = document.getElementById("pref-show-account-pnl"); + const fundsCb = document.getElementById("pref-show-nav-funds"); + const dashCb = document.getElementById("pref-show-nav-dashboard"); + const planCb = document.getElementById("pref-show-nav-plan"); + const archiveCb = document.getElementById("pref-show-nav-archive"); + const quotesCb = document.getElementById("pref-show-nav-quotes"); + const aiCb = document.getElementById("pref-show-nav-ai"); + const calcCb = document.getElementById("pref-show-nav-calculator"); + const strategyCb = document.getElementById("pref-show-nav-strategy"); + const helpCb = document.getElementById("pref-show-nav-help"); + const logsCb = document.getElementById("pref-show-nav-logs"); + const supEnabled = document.getElementById("supervisor-enabled"); + const supProg = document.getElementById("supervisor-wechat-program"); + const supWebhook = document.getElementById("supervisor-wechat-webhook"); + const supLink = document.getElementById("supervisor-wechat-link"); + const supPrefix = document.getElementById("supervisor-wechat-prefix"); + const supDaily = document.getElementById("supervisor-daily-warn"); + const supInterval = document.getElementById("supervisor-interval-warn"); + const supFreq30 = document.getElementById("supervisor-freq-30m"); + const supReopen = document.getElementById("supervisor-reopen-min"); + return { + version: 1, + display: { + show_account_pnl: pnlCb ? !!pnlCb.checked : true, + show_nav_funds: fundsCb ? !!fundsCb.checked : true, + show_nav_dashboard: dashCb ? !!dashCb.checked : true, + show_nav_plan: planCb ? !!planCb.checked : true, + show_nav_archive: archiveCb ? !!archiveCb.checked : true, + show_nav_quotes: quotesCb ? !!quotesCb.checked : true, + show_nav_ai: aiCb ? !!aiCb.checked : true, + show_nav_calculator: calcCb ? !!calcCb.checked : true, + show_nav_strategy: strategyCb ? !!strategyCb.checked : true, + show_nav_help: helpCb ? !!helpCb.checked : true, + show_nav_logs: logsCb ? !!logsCb.checked : true, + }, + supervisor: { + enabled: supEnabled ? !!supEnabled.checked : true, + wechat_webhook: supWebhook ? supWebhook.value.trim() : "", + wechat_link_base: supLink ? supLink.value.trim() : "", + wechat_prefix: supPrefix ? supPrefix.value.trim() : "【交易监管】", + wechat_on_program_tp_sl: supProg ? !!supProg.checked : true, + manual_close_daily_warn: supDaily ? Number(supDaily.value) || 2 : 2, + interval_warn_minutes: supInterval ? Number(supInterval.value) || 15 : 15, + freq_30m_count: supFreq30 ? Number(supFreq30.value) || 2 : 2, + reopen_after_close_minutes: supReopen ? Number(supReopen.value) || 30 : 30, + }, + exchanges: rows.map((card) => { + const caps = []; + if (card.querySelector(".cap-key").checked) caps.push("key"); + if (card.querySelector(".cap-trend").checked) caps.push("trend"); + if (card.querySelector(".cap-options") && card.querySelector(".cap-options").checked) caps.push("options"); + const id = card.querySelector(".ex-id").value.trim(); + const stableKey = (card.dataset.key || id).trim(); + return { + id: id, + key: stableKey, + name: card.querySelector(".ex-name").value.trim(), + flask_url: card.querySelector(".ex-flask").value.trim(), + agent_url: card.querySelector(".ex-agent").value.trim(), + review_url: card.querySelector(".ex-review").value.trim(), + enabled: card.querySelector(".ex-enabled").checked, + capabilities: caps, + }; + }), + }; + } + + async function saveSettingsSection(section, opts) { + const options = opts || {}; + const body = collectSettingsFromUI(); + try { + const r = await apiFetch("/api/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const j = await r.json(); + if (!j.ok) { + showToast("保存失败", true); + return; + } + const label = options.label || "设置"; + showToast(`${label}已保存`); + if (j.settings) { + settingsCache = j.settings; + syncDisplayPrefsUI(j.settings); + syncSupervisorSettingsUI(j.settings); + renderSettingsList(j.settings); + loadSettingsMetaLine(); + } + if (lastMonitorRows.length) renderMonitorGrid(lastMonitorRows); + if (!pageNavAllowed(currentPage())) { + history.replaceState({}, "", "/monitor"); + setActiveNav(); + } + } catch (e) { + showToast(String(e), true); + } + } + + document.getElementById("btn-logout").onclick = async () => { + try { + await fetch("/api/auth/logout", { method: "POST" }); + } catch (_) {} + location.href = "/login"; + }; + + document.getElementById("btn-monitor-refresh").onclick = () => refreshMonitorBoardNow(); + document.getElementById("auto-monitor").onchange = () => { + if (document.getElementById("auto-monitor").checked) { + connectMonitorBoardStream(); + } else { + closeMonitorBoardStream(); + } + }; + document.getElementById("btn-close-all").onclick = closeAll; + document.getElementById("btn-settings-add").onclick = () => { + const data = settingsCache || { exchanges: [] }; + const nid = String(Date.now() % 100000); + data.exchanges.push({ + id: nid, + key: "custom_" + nid, + name: "新交易所", + flask_url: "http://127.0.0.1:5000", + agent_url: "http://127.0.0.1:15200", + review_url: "", + enabled: false, + capabilities: ["key"], + }); + settingsCache = data; + renderSettingsList(data); + showToast("已添加一行,请填写 URL 后点「保存设置」"); + }; + + let aiChatLoading = false; + let aiChatSessionCache = null; + let aiChatSessionsCache = []; + let aiSelectedBotMode = "trading"; + const AI_CHAT_MAX_ATTACHMENTS = 3; + let aiChatPendingFiles = []; + const aiChatMdCache = new Map(); + const AI_CHAT_MD_CACHE_MAX = 120; + + function aiChatFileKind(file) { + return file && file.type && file.type.startsWith("image/") ? "image" : "text"; + } + + function isValidAiChatFile(file) { + if (!file) return false; + if (file.type && file.type.startsWith("image/")) return true; + const mime = (file.type || "").toLowerCase(); + if (["text/plain", "text/markdown", "application/json"].includes(mime)) return true; + const name = (file.name || "").toLowerCase(); + return ( + name.endsWith(".txt") || + name.endsWith(".md") || + name.endsWith(".markdown") || + name.endsWith(".json") + ); + } + + function syncAiChatFileInput() { + const fileInput = document.getElementById("ai-chat-files"); + if (!fileInput || typeof DataTransfer === "undefined") return; + const dt = new DataTransfer(); + aiChatPendingFiles.forEach((f) => dt.items.add(f)); + fileInput.files = dt.files; + } + + function renderAiChatPendingAttachments() { + const box = document.getElementById("ai-chat-pending"); + if (!box) return; + if (!aiChatPendingFiles.length) { + box.innerHTML = ""; + box.hidden = true; + return; + } + box.hidden = false; + box.innerHTML = aiChatPendingFiles + .map((f, idx) => { + const kind = aiChatFileKind(f); + const icon = kind === "image" ? "图" : "文"; + return ( + `` + + `${icon}` + + `${esc(f.name || "附件")}` + + `` + + `` + ); + }) + .join(""); + } + + function addAiChatPendingFiles(files) { + const incoming = Array.isArray(files) ? files : []; + if (!incoming.length) return; + let added = 0; + for (const file of incoming) { + if (aiChatPendingFiles.length >= AI_CHAT_MAX_ATTACHMENTS) { + showToast(`最多 ${AI_CHAT_MAX_ATTACHMENTS} 个附件`, true); + break; + } + if (!isValidAiChatFile(file)) { + showToast(`${file.name || "文件"}: 不支持的类型(仅图片或 txt/md/json)`, true); + continue; + } + aiChatPendingFiles.push(file); + added += 1; + } + if (!added) return; + syncAiChatFileInput(); + renderAiChatPendingAttachments(); + } + + function removeAiChatPendingFile(index) { + if (index < 0 || index >= aiChatPendingFiles.length) return; + aiChatPendingFiles.splice(index, 1); + syncAiChatFileInput(); + renderAiChatPendingAttachments(); + } + + function clearAiChatPendingFiles() { + aiChatPendingFiles = []; + syncAiChatFileInput(); + renderAiChatPendingAttachments(); + } + + function handleAiChatPaste(ev) { + if (aiChatLoading) return; + const clipboard = ev.clipboardData; + if (!clipboard || !clipboard.items) return; + const imageFiles = []; + for (const item of clipboard.items) { + if (!item.type || !item.type.startsWith("image/")) continue; + const blob = item.getAsFile(); + if (!blob) continue; + const sub = (item.type.split("/")[1] || "png").toLowerCase(); + const ext = sub === "jpeg" ? "jpg" : sub; + const name = `screenshot-${Date.now()}.${ext}`; + imageFiles.push(new File([blob], name, { type: item.type })); + } + if (!imageFiles.length) return; + ev.preventDefault(); + addAiChatPendingFiles(imageFiles); + } + + function renderHubMarkdown(text, cacheKey) { + const raw = String(text || ""); + if (cacheKey && aiChatMdCache.has(cacheKey)) { + return aiChatMdCache.get(cacheKey); + } + let html; + if (typeof window !== "undefined" && window.AiReviewRender && window.AiReviewRender.renderMarkdown) { + html = window.AiReviewRender.renderMarkdown(raw); + } else { + html = esc(raw) + .replace(/\*\*(.+?)\*\*/g, "$1") + .replace(/\n/g, "
        "); + } + if (cacheKey) { + if (aiChatMdCache.size >= AI_CHAT_MD_CACHE_MAX) { + const firstKey = aiChatMdCache.keys().next().value; + if (firstKey != null) aiChatMdCache.delete(firstKey); + } + aiChatMdCache.set(cacheKey, html); + } + return html; + } + + function scrollAiChatToEnd() { + const box = document.getElementById("ai-chat-messages"); + if (!box) return; + const run = () => { + box.scrollTop = box.scrollHeight; + const rows = box.querySelectorAll(".ai-msg-row"); + const last = rows[rows.length - 1]; + if (last && last.scrollIntoView) { + try { + last.scrollIntoView({ block: "end", behavior: "auto" }); + } catch (_) { + /* ignore */ + } + } + }; + requestAnimationFrame(() => requestAnimationFrame(run)); + } + + function updateAiBotTabs(mode) { + const m = normalizeAiBotMode(mode); + aiSelectedBotMode = m; + document.querySelectorAll(".ai-bot-tab").forEach((btn) => { + const on = normalizeAiBotMode(btn.dataset.bot || "trading") === m; + btn.classList.toggle("is-active", on); + btn.setAttribute("aria-selected", on ? "true" : "false"); + }); + const newBtn = document.getElementById("btn-ai-chat-new"); + if (newBtn) newBtn.classList.toggle("hidden", m === "supervisor"); + const histPanel = document.querySelector(".ai-chat-history-panel"); + if (histPanel) histPanel.classList.toggle("hidden", m === "supervisor"); + const input = document.getElementById("ai-chat-input"); + if (input) { + if (m === "general") { + input.placeholder = "随便聊点什么,不绑交易数据…可直接 Ctrl+V 粘贴截图"; + } else if (m === "supervisor") { + input.placeholder = "回应监管提醒,说说为什么又开了一单…"; + } else { + input.placeholder = "聊聊行情,心态,纪律,执行…;可直接 Ctrl+V 粘贴截图"; + } + } + } + + function renderAiChatHistory(sessions) { + const list = document.getElementById("ai-chat-history-list"); + if (!list) return; + const items = Array.isArray(sessions) ? sessions : []; + if (!items.length) { + list.innerHTML = '

        暂无历史,发送消息后会出现在这里.

        '; + return; + } + list.innerHTML = items + .map((s) => { + const mode = s.bot_mode === "general" ? "general" : "trading"; + const badge = mode === "general" ? "普通" : "交易"; + const badgeCls = mode === "general" ? "" : " trading"; + const active = s.is_active ? " is-active" : ""; + const time = esc((s.updated_at || s.created_at || "").slice(0, 16)); + const title = esc(s.title || "新对话"); + const preview = esc(s.preview || "(空会话)"); + const sid = esc(s.id || ""); + return ( + `
        ` + + `
        ` + + `${title}` + + `${preview}` + + `` + + `${time}` + + `${badge}` + + `${Number(s.message_count) || 0} 条` + + `` + + `
        ` + + `` + + `
        ` + ); + }) + .join(""); + } + + function renderAiChatRow(role, content, extraClass, attachments, rowOpts) { + const opts = rowOpts || {}; + const botMode = normalizeAiBotMode(opts.botMode || aiSelectedBotMode); + const isUser = role === "user"; + const isSystem = role === "system"; + let label = "主人"; + if (isSystem) label = "监管"; + else if (!isUser) label = botMode === "general" ? "助手" : botMode === "supervisor" ? "监管AI" : "交易教练"; + const rowCls = isUser + ? "ai-msg-row-user" + : isSystem + ? "ai-msg-row-system" + : "ai-msg-row-coach"; + const bubbleCls = isUser + ? "ai-bubble-user" + : isSystem + ? "ai-bubble-system" + : "ai-bubble-assistant"; + const isThinking = extraClass && String(extraClass).includes("ai-bubble-thinking"); + const isError = + !isUser && + !isSystem && + !isThinking && + /^(AI 调用失败|AI 生成失败)/.test(String(content || "").trim()); + const mdKey = + !isUser && !isSystem && !isThinking && opts.cacheKey ? String(opts.cacheKey) : ""; + const bubbleInner = + isUser || isThinking || isSystem ? esc(content || "") : renderHubMarkdown(content || "", mdKey); + const mdCls = !isUser && !isSystem && !isThinking ? " ai-result-md" : ""; + const attList = Array.isArray(attachments) ? attachments : []; + const attHtml = attList.length + ? `
        ${attList + .map((a) => `${esc(a.name || "附件")}`) + .join("")}
        ` + : ""; + const canCopy = !isThinking && String(content || "").trim(); + const copyHtml = canCopy + ? `
        ` + : ""; + return ( + `
        ` + + `${label}` + + `${attHtml}` + + `
        ${bubbleInner}
        ` + + `${copyHtml}` + + `
        ` + ); + } + + function renderAiChatMessages(session, opts) { + const options = opts || {}; + const box = document.getElementById("ai-chat-messages"); + const title = document.getElementById("ai-chat-title"); + if (!box) return; + const activeSession = isSupervisorMode() ? aiSupervisorSessionCache || session : session; + const msgs = (activeSession && activeSession.messages) || []; + const botMode = normalizeAiBotMode((activeSession && activeSession.bot_mode) || aiSelectedBotMode); + if (title) { + const modeLabel = + botMode === "general" ? "普通聊天" : botMode === "supervisor" ? "交易监管" : "交易教练"; + const sessionTitle = activeSession && activeSession.title ? String(activeSession.title) : ""; + if (isMobileAiLayout()) { + title.textContent = + botMode === "supervisor" + ? sessionTitle || "今日监管" + : sessionTitle && sessionTitle !== "新对话" + ? sessionTitle + : modeLabel; + } else { + title.textContent = sessionTitle + ? `${modeLabel} · ${sessionTitle}` + : modeLabel; + } + } + const showPlaceholder = + !msgs.length && !options.pendingUser && !options.thinking; + if (showPlaceholder) { + const hint = + botMode === "general" + ? "普通聊天不注入交易快照;发消息后可点气泡下方「复制」.可粘贴截图或上传附件." + : botMode === "supervisor" + ? "今日监管为长会话:手动/中控开平仓与新开仓会自动推送;程序止盈止损会鼓励性提醒.可直接回复继续聊." + : "交易教练会结合三户监控数据陪聊;发消息后可点气泡下方「复制」.可粘贴截图或点「附件」上传图片/文档."; + box.innerHTML = `

        ${hint}

        `; + return; + } + const sessionId = activeSession && activeSession.id ? String(activeSession.id) : "local"; + let html = msgs + .map((m, idx) => { + const role = m.role === "user" ? "user" : m.role === "system" ? "system" : "assistant"; + return renderAiChatRow( + role, + m.content || "", + m.level === "warn" ? "ai-bubble-warn" : null, + m.attachments, + { botMode, msgIdx: idx, cacheKey: sessionId + ":" + idx } + ); + }) + .join(""); + if (options.pendingUser) { + html += renderAiChatRow("user", options.pendingUser, null, options.pendingAttachments); + } + if (options.thinking) { + html += renderAiChatRow("assistant", "正在思考…", "ai-bubble-thinking"); + } + box.innerHTML = html; + scrollAiChatToEnd(); + } + + function setAiChatBusy(busy) { + aiChatLoading = !!busy; + const btn = document.getElementById("btn-ai-chat-send"); + const input = document.getElementById("ai-chat-input"); + if (btn) btn.disabled = busy; + if (input) input.disabled = busy; + document.querySelectorAll(".ai-chat-pending-del").forEach((el) => { + el.disabled = busy; + }); + } + + async function loadAiSupervisorSession() { + const r = await apiFetch("/api/ai/supervisor/session"); + const j = await r.json(); + aiSupervisorSessionCache = j.session || null; + if (isSupervisorMode()) { + renderAiChatMessages(aiSupervisorSessionCache); + } + updateAiBotTabs("supervisor"); + return j; + } + + async function switchToSupervisorMode() { + updateAiBotTabs("supervisor"); + if (isMobileAiLayout()) { + localStorage.setItem(AI_MOBILE_TAB_KEY, "supervisor"); + applyAiMobileTab("supervisor"); + } + try { + await loadAiSupervisorSession(); + connectSupervisorStream(); + scrollAiChatToEnd(); + } catch (e) { + showToast(String(e), true); + } + } + + function closeSupervisorStream() { + if (supervisorEventSource) { + supervisorEventSource.close(); + supervisorEventSource = null; + } + if (supervisorReconnectTimer) { + clearTimeout(supervisorReconnectTimer); + supervisorReconnectTimer = null; + } + } + + function connectSupervisorStream() { + closeSupervisorStream(); + if (currentPage() !== "ai" || !isSupervisorMode()) return; + supervisorEventSource = new EventSource("/api/ai/supervisor/stream"); + supervisorEventSource.addEventListener("supervisor", (ev) => { + try { + const st = JSON.parse(ev.data || "{}"); + const ver = Number(st.supervisor_version) || 0; + if (ver !== localSupervisorVersion) { + localSupervisorVersion = ver; + void loadAiSupervisorSession(); + } + } catch (_) {} + }); + supervisorEventSource.onerror = () => { + closeSupervisorStream(); + if (supervisorReconnectTimer) clearTimeout(supervisorReconnectTimer); + supervisorReconnectTimer = setTimeout(() => { + if (currentPage() === "ai" && isSupervisorMode()) connectSupervisorStream(); + }, 8000); + }; + } + + async function loadAiChatSession() { + const r = await apiFetch("/api/ai/chat/session"); + const j = await r.json(); + aiChatSessionCache = j.session || null; + aiChatSessionsCache = j.sessions || []; + renderAiChatMessages(aiChatSessionCache); + renderAiChatHistory(aiChatSessionsCache); + updateAiBotTabs((aiChatSessionCache && aiChatSessionCache.bot_mode) || aiSelectedBotMode); + } + + async function switchAiChatSession(sessionId) { + if (!sessionId || aiChatLoading) return; + try { + const r = await apiFetch("/api/ai/chat/switch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_id: sessionId }), + }); + const j = await r.json(); + if (!r.ok) throw new Error(j.detail || j.msg || "切换失败"); + aiChatSessionCache = j.session || null; + aiChatSessionsCache = j.sessions || []; + renderAiChatMessages(aiChatSessionCache); + renderAiChatHistory(aiChatSessionsCache); + const mode = + (aiChatSessionCache && aiChatSessionCache.bot_mode) === "general" ? "general" : "trading"; + updateAiBotTabs(mode); + if (isMobileAiLayout()) { + localStorage.setItem(AI_MOBILE_TAB_KEY, mode); + applyAiMobileTab(mode); + } + scrollAiChatToEnd(); + } catch (e) { + showToast(String(e), true); + } + } + + async function deleteAiChatSession(sessionId) { + if (!sessionId) return; + if (!confirm("确定删除这条聊天历史?")) return; + try { + const r = await apiFetch(`/api/ai/chat/session/${encodeURIComponent(sessionId)}`, { + method: "DELETE", + }); + const j = await r.json(); + if (!r.ok) throw new Error(j.detail || j.msg || "删除失败"); + aiChatSessionCache = j.session || null; + aiChatSessionsCache = j.sessions || []; + renderAiChatMessages(aiChatSessionCache); + renderAiChatHistory(aiChatSessionsCache); + updateAiBotTabs( + (aiChatSessionCache && aiChatSessionCache.bot_mode) || aiSelectedBotMode || "trading" + ); + showToast("已删除"); + } catch (e) { + showToast(String(e), true); + } + } + + const ARCHIVE_QUOTE_AI_KEY = "hub_archive_quote_ai"; + let archiveQuoteAiPending = false; + + async function consumeArchiveQuoteAiPending() { + if (archiveQuoteAiPending || aiChatLoading) return; + let raw = ""; + try { + raw = sessionStorage.getItem(ARCHIVE_QUOTE_AI_KEY) || ""; + } catch (_) { + return; + } + if (!raw) return; + sessionStorage.removeItem(ARCHIVE_QUOTE_AI_KEY); + let payload; + try { + payload = JSON.parse(raw); + } catch (_) { + return; + } + const content = String((payload && payload.content) || "").trim(); + const quoteDate = String((payload && payload.quote_date) || "").trim(); + if (!content) return; + + const input = document.getElementById("ai-chat-input"); + if (input) input.value = content; + updateAiBotTabs("trading"); + if (isMobileAiLayout()) { + localStorage.setItem(AI_MOBILE_TAB_KEY, "trading"); + applyAiMobileTab("trading"); + } + + archiveQuoteAiPending = true; + setAiChatBusy(true); + renderAiChatMessages(aiChatSessionCache, { + pendingUser: content, + thinking: true, + }); + try { + const r = await apiFetch("/api/ai/chat/archive-quote", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ quote_date: quoteDate, content }), + }); + const j = await r.json(); + if (!r.ok) throw new Error(j.detail || j.msg || "发送失败"); + aiChatSessionCache = j.session || null; + aiChatSessionsCache = j.sessions || aiChatSessionsCache; + renderAiChatMessages(aiChatSessionCache); + renderAiChatHistory(aiChatSessionsCache); + if (input) input.value = ""; + showToast("复盘语录已发送给交易教练"); + } catch (e) { + showToast(String(e), true); + if (input) input.value = content; + try { + await loadAiChatSession(); + } catch (_) { + renderAiChatMessages(aiChatSessionCache); + } + } finally { + archiveQuoteAiPending = false; + setAiChatBusy(false); + } + } + + async function loadAiPage() { + applyAiMobileTab(); + const params = new URLSearchParams(window.location.search || ""); + const modeParam = (params.get("mode") || "").trim().toLowerCase(); + if (modeParam === "supervisor") { + await switchToSupervisorMode(); + } else { + closeSupervisorStream(); + await loadAiChatSession(); + await consumeArchiveQuoteAiPending(); + } + const mobTab = normalizeAiMobileTab(localStorage.getItem(AI_MOBILE_TAB_KEY) || "trading"); + if (isMobileAiLayout() && AI_MOBILE_CHAT_TABS.has(mobTab)) { + const input = document.getElementById("ai-chat-input"); + if (input && !aiChatLoading) { + setTimeout(() => input.focus(), 80); + } + } + } + + async function newAiChat(botMode) { + const mode = normalizeAiBotMode(botMode); + if (mode !== "supervisor") closeSupervisorStream(); + try { + const r = await apiFetch("/api/ai/chat/new", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ bot_mode: mode }), + }); + const j = await r.json(); + aiChatSessionCache = j.session || null; + aiChatSessionsCache = j.sessions || []; + renderAiChatMessages(aiChatSessionCache); + renderAiChatHistory(aiChatSessionsCache); + updateAiBotTabs(mode); + if (isMobileAiLayout()) { + localStorage.setItem(AI_MOBILE_TAB_KEY, mode); + applyAiMobileTab(mode); + } + showToast( + mode === "general" + ? "已开始普通聊天" + : mode === "supervisor" + ? "已打开今日监管" + : "已开始交易教练对话" + ); + } catch (e) { + showToast(String(e), true); + } + } + + async function sendAiChat(ev) { + if (ev) ev.preventDefault(); + if (aiChatLoading) return; + const input = document.getElementById("ai-chat-input"); + const text = (input && input.value || "").trim(); + if (isSupervisorMode()) { + if (!text) return; + const savedText = text; + if (input) input.value = ""; + setAiChatBusy(true); + renderAiChatMessages(aiSupervisorSessionCache, { + pendingUser: text, + thinking: true, + }); + try { + const r = await apiFetch("/api/ai/supervisor/chat/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: text }), + }); + const j = await r.json(); + if (!r.ok) throw new Error(j.detail || j.msg || "发送失败"); + aiSupervisorSessionCache = j.session || null; + renderAiChatMessages(aiSupervisorSessionCache); + } catch (e) { + showToast(String(e), true); + if (input && savedText) input.value = savedText; + try { + await loadAiSupervisorSession(); + } catch (_) { + renderAiChatMessages(aiSupervisorSessionCache); + } + } finally { + setAiChatBusy(false); + } + return; + } + const files = aiChatPendingFiles.slice(); + if (!text && !files.length) return; + const pendingAttachments = files.map((f) => ({ + name: f.name, + kind: aiChatFileKind(f), + })); + const savedText = text; + if (input) input.value = ""; + setAiChatBusy(true); + renderAiChatMessages(aiChatSessionCache, { + pendingUser: text || (files.length ? `(上传 ${files.length} 个附件)` : ""), + pendingAttachments, + thinking: true, + }); + try { + const fd = new FormData(); + fd.append("message", text); + files.forEach((f) => fd.append("files", f, f.name)); + const r = await apiFetch("/api/ai/chat/send", { method: "POST", body: fd }); + const j = await r.json(); + if (!r.ok) throw new Error(j.detail || j.msg || "发送失败"); + aiChatSessionCache = j.session || null; + aiChatSessionsCache = j.sessions || aiChatSessionsCache; + renderAiChatMessages(aiChatSessionCache); + renderAiChatHistory(aiChatSessionsCache); + clearAiChatPendingFiles(); + if (j.attachment_warnings && j.attachment_warnings.length) { + showToast(j.attachment_warnings.join(";"), true); + } + } catch (e) { + showToast(String(e), true); + if (input && savedText) input.value = savedText; + try { + await loadAiChatSession(); + } catch (_) { + renderAiChatMessages(aiChatSessionCache); + } + } finally { + setAiChatBusy(false); + } + } + + const aiChatFiles = document.getElementById("ai-chat-files"); + if (aiChatFiles) { + aiChatFiles.addEventListener("change", () => { + const picked = aiChatFiles.files ? Array.from(aiChatFiles.files) : []; + addAiChatPendingFiles(picked); + aiChatFiles.value = ""; + }); + } + const aiChatInput = document.getElementById("ai-chat-input"); + if (aiChatInput) { + aiChatInput.addEventListener("paste", handleAiChatPaste); + } + const aiChatPending = document.getElementById("ai-chat-pending"); + if (aiChatPending) { + aiChatPending.addEventListener("click", (ev) => { + const btn = ev.target.closest("[data-pending-del]"); + if (!btn || aiChatLoading) return; + ev.preventDefault(); + const idx = Number(btn.getAttribute("data-pending-del")); + if (!Number.isNaN(idx)) removeAiChatPendingFile(idx); + }); + } + + const aiChatNewBtn = document.getElementById("btn-ai-chat-new"); + if (aiChatNewBtn) aiChatNewBtn.onclick = () => newAiChat(aiSelectedBotMode); + const aiChatForm = document.getElementById("ai-chat-form"); + if (aiChatForm) aiChatForm.addEventListener("submit", sendAiChat); + + function initAiChatInteractions() { + const hist = document.getElementById("ai-chat-history-list"); + if (hist && !hist._aiBound) { + hist._aiBound = true; + hist.addEventListener("click", (ev) => { + const delBtn = ev.target.closest(".ai-chat-history-del"); + if (delBtn) { + ev.stopPropagation(); + const sid = delBtn.getAttribute("data-delete-session"); + if (sid) deleteAiChatSession(sid); + return; + } + const item = ev.target.closest(".ai-chat-history-item"); + if (!item) return; + const sid = item.getAttribute("data-session-id"); + if (sid) switchAiChatSession(sid); + }); + } + const box = document.getElementById("ai-chat-messages"); + if (box && !box._aiCopyBound) { + box._aiCopyBound = true; + box.addEventListener("click", async (ev) => { + const btn = ev.target.closest(".ai-msg-copy-btn"); + if (!btn) return; + const idx = Number(btn.getAttribute("data-msg-idx")); + const msgs = (aiChatSessionCache && aiChatSessionCache.messages) || []; + const text = msgs[idx] && msgs[idx].content ? String(msgs[idx].content) : ""; + if (!text) return; + try { + await navigator.clipboard.writeText(text); + showToast("已复制"); + } catch (_) { + showToast("复制失败", true); + } + }); + } + document.querySelectorAll(".ai-bot-tab").forEach((btn) => { + if (btn._aiBotBound) return; + btn._aiBotBound = true; + btn.addEventListener("click", () => { + const mode = normalizeAiBotMode(btn.getAttribute("data-bot") || "trading"); + if (mode === "supervisor") { + void switchToSupervisorMode(); + return; + } + closeSupervisorStream(); + newAiChat(mode); + }); + }); + } + initAiChatInteractions(); + + initTpslModal(); + initInstanceFrame(); + initFullscreen(); + initMobileLayout(); + if (globalThis.HubTheme && typeof HubTheme.initToggleUI === "function") { + HubTheme.initToggleUI(); + } + + function initShellNav() { + bindHubSpaNavLinks(".top-nav a[href^='/']"); + bindHubSpaNavLinks(".hub-mobile-tabbar a.hub-m-tab[href^='/']"); + bindHubSpaNavLinks(".hub-mobile-more-nav a[href^='/']"); + window.addEventListener("popstate", setActiveNav); + } + + window.hubNavigateTo = function hubNavigateTo(path) { + const href = String(path || "/").split("?")[0] || "/"; + if (href === window.location.pathname) { + setActiveNav(); + return; + } + history.pushState({}, "", href); + setActiveNav(); + }; + + window.hubOpenMonitorExpand = function hubOpenMonitorExpand(exId) { + const id = String(exId || "").trim(); + if (!id) return; + expandedExchangeId = id; + sessionStorage.setItem("hub_expanded_ex", id); + if (currentPage() !== "monitor") { + history.pushState({}, "", "/monitor"); + setActiveNav(); + } + if (lastMonitorRows.length) { + openExchangeFullscreen(id); + } else { + void fetchMonitorBoardSnapshot({ showLoading: true }); + } + }; + + initAuth().then((ok) => { + if (!ok) return; + initShellNav(); + loadSettings() + .then((data) => { + syncDisplayPrefsUI(data); + }) + .catch(() => {}) + .finally(() => { + setActiveNav(); + }); + }); + if (window.AccountRiskBadge) AccountRiskBadge.startTicker(); +})(); diff --git a/manual_trading_hub/static/archive.js b/manual_trading_hub/static/archive.js new file mode 100644 index 0000000..2231372 --- /dev/null +++ b/manual_trading_hub/static/archive.js @@ -0,0 +1,2265 @@ +/** + * 内照明心:复盘语录 + 当日交易记录 + 按需 K 线. + */ +(function () { + const page = document.getElementById("page-archive"); + if (!page) return; + + const elExchange = document.getElementById("archive-exchange"); + const elFilterProfit = document.getElementById("archive-filter-profit"); + const elFilterLoss = document.getElementById("archive-filter-loss"); + const elFilterSick = document.getElementById("archive-filter-sick"); + const elPeriodTabs = document.getElementById("archive-period-tabs"); + const elTradingDay = document.getElementById("archive-trading-day"); + const elPeriodRangeWrap = document.getElementById("archive-period-range-wrap"); + const elDateFrom = document.getElementById("archive-date-from"); + const elDateTo = document.getElementById("archive-date-to"); + const elSearch = document.getElementById("archive-search"); + const elBtnChartToggle = document.getElementById("archive-btn-chart-toggle"); + const elBtnRefresh = document.getElementById("archive-btn-refresh"); + const elBtnSync = document.getElementById("archive-btn-sync"); + const elStatus = document.getElementById("archive-status"); + const elStats = document.getElementById("archive-stats"); + const elStatsCharts = document.getElementById("archive-stats-charts"); + const elStatsVizSub = document.getElementById("archive-stats-viz-sub"); + const elCalSummarySub = document.getElementById("archive-cal-summary-sub"); + const elCalendarWrap = document.getElementById("archive-calendar-wrap"); + const elCalendar = document.getElementById("archive-calendar"); + const elCalTitle = document.getElementById("archive-cal-title"); + const elCalPrev = document.getElementById("archive-cal-prev"); + const elCalNext = document.getElementById("archive-cal-next"); + const elQuotesList = document.getElementById("archive-quotes-list"); + const elQuotesCount = document.getElementById("archive-quotes-count"); + const elQuoteForm = document.getElementById("archive-quote-form"); + const elQuoteDate = document.getElementById("archive-quote-date"); + const elQuoteContent = document.getElementById("archive-quote-content"); + const elQuoteSubmit = document.getElementById("archive-quote-submit"); + const elContentTabs = document.getElementById("archive-content-tabs"); + const elPanelViz = document.getElementById("archive-panel-viz"); + const elPanelCalendar = document.getElementById("archive-panel-calendar"); + const elPanelTrades = document.getElementById("archive-panel-trades"); + const elPanelQuotes = document.getElementById("archive-panel-quotes"); + const elQuoteDayTradesMeta = document.getElementById("archive-quote-day-trades-meta"); + const elQuoteDayTradesBody = document.getElementById("archive-quote-day-trades-body"); + const elChartSection = document.getElementById("archive-chart-section"); + const elChartTitle = document.getElementById("archive-chart-title"); + const elBtnChartClose = document.getElementById("archive-btn-chart-close"); + const elCalDayTradesMeta = document.getElementById("archive-calendar-day-trades-meta"); + const elCalDayTradesBody = document.getElementById("archive-calendar-day-trades-body"); + const elTfTabs = document.getElementById("archive-tf-tabs"); + const elViewMode = document.getElementById("archive-view-mode"); + const elJumpAt = document.getElementById("archive-jump-at"); + const elBtnJump = document.getElementById("archive-btn-jump"); + const elBtnReloadChart = document.getElementById("archive-btn-reload-chart"); + const elChartHost = document.getElementById("archive-chart"); + const elMarkAuto = document.getElementById("archive-mark-auto"); + const elTrades = document.getElementById("archive-trades"); + const elTradesSection = document.getElementById("archive-trades-section"); + const elTradesPager = document.getElementById("archive-trades-pager"); + const elTradesPrev = document.getElementById("archive-trades-prev"); + const elTradesNext = document.getElementById("archive-trades-next"); + const elTradesPageLabel = document.getElementById("archive-trades-page-label"); + const ARCHIVE_MARK_AUTO_KEY = "hubArchiveMarkAuto"; + const TRADES_PAGE_SIZE = 5; + const TRADES_VISIBLE_ROWS_CHART_OPEN = 5; + + const TF_MS = { + "5m": 5 * 60_000, + "15m": 15 * 60_000, + "1h": 60 * 60_000, + "4h": 4 * 60 * 60_000, + }; + const CHART_TZ_OFFSET_SEC = 8 * 60 * 60; + + let meta = null; + let quotes = []; + let selectedQuoteId = null; + let editingQuoteId = null; + let archiveContentTab = "trades"; + let quoteDayTrades = []; + let quoteDayTradesDay = ""; + let quoteDayTradesReq = 0; + let dailyTrades = []; + let tradesPage = 0; + let dailyStats = { open_count: 0, by_exchange: {} }; + let periodMode = "today"; + let periodLabel = ""; + let dateFrom = ""; + let dateTo = ""; + let tradingDay = ""; + let selected = null; + let trades = []; + let selectedTradeKey = null; + let timeframe = "15m"; + let chart = null; + let candleSeries = null; + let volumeSeries = null; + let inited = false; + let markAuto = true; + let lastCandles = []; + let chartExchangeSymbol = ""; + let chartMarketType = "swap"; + let searchTimer = null; + let calendarWidget = null; + let selectedCalendarDay = ""; + + function esc(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function loadMarkAutoPref() { + try { + const raw = localStorage.getItem(ARCHIVE_MARK_AUTO_KEY); + if (raw === "0" || raw === "false") markAuto = false; + else if (raw === "1" || raw === "true") markAuto = true; + } catch (_) {} + syncMarkAutoBtn(); + } + + function syncMarkAutoBtn() { + if (!elMarkAuto) return; + elMarkAuto.classList.toggle("is-on", markAuto); + elMarkAuto.setAttribute("aria-pressed", markAuto ? "true" : "false"); + } + + function saveMarkAutoPref() { + try { + localStorage.setItem(ARCHIVE_MARK_AUTO_KEY, markAuto ? "1" : "0"); + } catch (_) {} + } + + function tradeHistoryBounds(tradeList) { + let minOpen = null; + let maxClose = null; + (tradeList || []).forEach(function (tr) { + const o = tradeOpenMs(tr); + const c = tradeCloseMs(tr); + if (o != null) minOpen = minOpen == null ? o : Math.min(minOpen, o); + if (c != null) maxClose = maxClose == null ? c : Math.max(maxClose, c); + }); + return { minOpen: minOpen, maxClose: maxClose }; + } + + function fmt(n, d) { + if (n == null || n === "" || !Number.isFinite(Number(n))) return "—"; + return Number(n).toFixed(d == null ? 2 : d); + } + + function fmtPnl(v) { + const n = Number(v); + if (!Number.isFinite(n)) return "—"; + return (n >= 0 ? "+" : "") + n.toFixed(2); + } + + function pad2(n) { + return n < 10 ? "0" + n : String(n); + } + + function utcSecToBjDate(utcSec) { + return new Date((Number(utcSec) + CHART_TZ_OFFSET_SEC) * 1000); + } + + function formatChartTimeBj(utcSec, withDate) { + const d = utcSecToBjDate(utcSec); + const h = pad2(d.getUTCHours()); + const mi = pad2(d.getUTCMinutes()); + if (!withDate) return h + ":" + mi; + return ( + d.getUTCFullYear() + + "-" + + pad2(d.getUTCMonth() + 1) + + "-" + + pad2(d.getUTCDate()) + + " " + + h + + ":" + + mi + ); + } + + function chartLocalizationBj() { + return { + locale: "zh-CN", + dateFormat: "yyyy-MM-dd", + timeFormatter: function (time) { + if (typeof time === "number") return formatChartTimeBj(time, true); + if (time && typeof time === "object" && time.year) { + return time.year + "-" + pad2(time.month) + "-" + pad2(time.day); + } + return ""; + }, + tickMarkFormatter: function (time, tickMarkType) { + if (typeof time !== "number") { + if (time && typeof time === "object" && time.year) { + return time.year + "-" + pad2(time.month) + "-" + pad2(time.day); + } + return ""; + } + const d = utcSecToBjDate(time); + if (tickMarkType === 0) return String(d.getUTCFullYear()); + if (tickMarkType === 1) return pad2(d.getUTCMonth() + 1); + if (tickMarkType === 2) return pad2(d.getUTCDate()); + return formatChartTimeBj(time, false); + }, + }; + } + + function fmtDt(raw) { + if (raw == null || raw === "") return "—"; + return String(raw).replace("T", " ").slice(0, 16); + } + + function fmtHoldMinutes(tr) { + if (!tr) return "—"; + const text = tr.hold_minutes_text; + if (text) return text; + const n = Number(tr.hold_minutes); + if (!Number.isFinite(n) || n <= 0) return "0分钟"; + const hours = Math.floor(n / 60); + const mins = Math.floor(n % 60); + if (hours) return hours + "小时" + mins + "分钟"; + return mins + "分钟"; + } + + const ENTRY_TYPE_LABELS = { + trend_pullback: "趋势回调", + roll: "顺势加仓", + trend: "趋势回调", + }; + + function fmtEntryType(tr) { + if (!tr) return "—"; + const raw = String( + tr.entry_type || tr.entry_reason || tr.reviewed_entry_reason || "" + ).trim(); + if (raw) return ENTRY_TYPE_LABELS[raw] || raw; + const mt = String(tr.monitor_type || "").trim(); + if (mt && mt !== "下单监控") return ENTRY_TYPE_LABELS[mt] || mt; + return mt || "—"; + } + + function reviewMark(tr) { + return tr && tr.reviewed ? "复" : ""; + } + + function pnlClass(v) { + const n = Number(v); + if (!Number.isFinite(n) || Math.abs(n) < 1e-6) return ""; + return n > 0 ? "pos" : "neg"; + } + + function setStatus(text) { + if (elStatus) elStatus.textContent = text || ""; + } + + function tradeRowExchange(tr) { + if (!tr) return "—"; + const exKey = String(tr.exchange_key || "").toLowerCase(); + return exKey ? exchangeLabel(exKey) : "—"; + } + + function tradeRowKey(tr) { + if (!tr) return ""; + const exKey = String(tr.exchange_key || "").toLowerCase(); + const tid = tr.trade_id != null ? tr.trade_id : tr.id; + if (!exKey || tid == null || tid === "") return ""; + return exKey + ":" + String(tid); + } + + function findTradeByKey(key) { + if (!key) return null; + return ( + dailyTrades.find(function (t) { + return tradeRowKey(t) === String(key); + }) || null + ); + } + + function applyTagSelectStyle(sel) { + if (!sel) return; + const v = sel.value || ""; + sel.classList.remove("is-tag-empty", "is-tag-sick", "is-tag-emotion"); + if (v === "sick") sel.classList.add("is-tag-sick"); + else if (v === "emotion") sel.classList.add("is-tag-emotion"); + else sel.classList.add("is-tag-empty"); + } + + function exchangeLabel(exKey) { + const key = String(exKey || "").toLowerCase(); + if (!key) return "—"; + const hit = (meta && meta.exchanges || []).find(function (ex) { + return String(ex.key || "").toLowerCase() === key; + }); + return hit ? hit.name || hit.key : exKey; + } + + function scheduleChartResize() { + requestAnimationFrame(function () { + if (chart && elChartHost) { + const w = elChartHost.clientWidth; + const h = elChartHost.clientHeight; + if (w > 0 && h > 0) chart.applyOptions({ width: w, height: h }); + } + requestAnimationFrame(function () { + if (chart && elChartHost) { + const w = elChartHost.clientWidth; + const h = elChartHost.clientHeight; + if (w > 0 && h > 0) chart.applyOptions({ width: w, height: h }); + } + }); + }); + } + + async function ensureChartSelection() { + if (selected && selected.exchange_key && selected.symbol) return; + if (!dailyTrades.length) return; + const tr = dailyTrades.find(function (t) { + return t.exchange_key && t.symbol; + }); + if (!tr) return; + selected = { exchange_key: tr.exchange_key, symbol: tr.symbol }; + selectedTradeKey = tradeRowKey(tr); + await loadSymbolTradesForChart(tr.exchange_key, tr.symbol); + } + + function isChartOpen() { + return !!(elChartSection && !elChartSection.hidden); + } + + function syncTradesLayout() { + const open = isChartOpen(); + if (page) page.classList.toggle("is-chart-open", open); + if (elTradesSection) elTradesSection.classList.toggle("is-chart-open", open); + if (!elTrades) return; + if (open) { + const head = elTrades.querySelector("thead tr"); + const row = elTrades.querySelector("tbody tr"); + if (head && row) { + const h = head.offsetHeight + row.offsetHeight * TRADES_VISIBLE_ROWS_CHART_OPEN; + elTrades.style.maxHeight = h + "px"; + } + } else { + elTrades.style.maxHeight = ""; + } + } + + function setChartOpen(on) { + if (!elChartSection) return; + const want = !!on; + elChartSection.hidden = !want; + if (elBtnChartToggle) { + elBtnChartToggle.classList.toggle("is-active", want); + } + if (want && archiveContentTab !== "trades") { + setArchiveContentTab("trades"); + } + syncTradesLayout(); + if (!want) { + destroyChart(); + return; + } + scheduleChartResize(); + } + + function formatChartContractLabel(sym, exchangeSymbol, marketType) { + const base = String(sym || "—"); + const mt = String(marketType || "").toLowerCase(); + if (mt === "swap" || (exchangeSymbol && String(exchangeSymbol).indexOf(":") >= 0)) { + return base + " 永续"; + } + return base; + } + + function updateChartTitle() { + if (!elChartTitle) return; + if (!selected) { + elChartTitle.textContent = "—"; + return; + } + const label = formatChartContractLabel( + selected.symbol, + chartExchangeSymbol, + chartMarketType + ); + elChartTitle.textContent = label + " · " + exchangeLabel(selected.exchange_key); + } + + async function apiFetch(url, opts) { + const r = await fetch(url, opts); + if (r.status === 401) { + location.href = "/login?next=" + encodeURIComponent(location.pathname); + throw new Error("未登录"); + } + return r; + } + + function syncPeriodUI() { + if (elPeriodTabs) { + elPeriodTabs.querySelectorAll(".archive-period-btn").forEach(function (btn) { + btn.classList.toggle("is-active", btn.getAttribute("data-period") === periodMode); + }); + } + if (elTradingDay) { + elTradingDay.classList.toggle("hidden", periodMode !== "today"); + } + if (elPeriodRangeWrap) { + elPeriodRangeWrap.classList.toggle("hidden", periodMode !== "range"); + } + } + + function setPeriodMode(mode) { + periodMode = mode || "today"; + syncPeriodUI(); + } + + function queryDailyParams() { + const q = new URLSearchParams(); + q.set("period", periodMode); + if (periodMode === "today" && elTradingDay && elTradingDay.value) { + q.set("trading_day", elTradingDay.value); + } + if (periodMode === "range") { + if (elDateFrom && elDateFrom.value) q.set("date_from", elDateFrom.value); + if (elDateTo && elDateTo.value) q.set("date_to", elDateTo.value); + } + const ex = (elExchange && elExchange.value) || ""; + if (ex) q.set("exchange_key", ex); + if (elFilterProfit && elFilterProfit.checked) q.set("filter_profit", "1"); + if (elFilterLoss && elFilterLoss.checked) q.set("filter_loss", "1"); + if (elFilterSick && elFilterSick.checked) q.set("filter_sick", "1"); + if (elSearch && elSearch.value.trim()) q.set("search", elSearch.value.trim()); + return q.toString(); + } + + function fmtVolStat(v) { + const n = Number(v); + if (!Number.isFinite(n) || n <= 0) return "—"; + if (n >= 10000) return (n / 1000).toFixed(1) + "k"; + return n.toFixed(0) + "U"; + } + + function fmtFeeStat(v) { + const n = Number(v); + if (!Number.isFinite(n) || n <= 0) return "—"; + return n.toFixed(2) + "U"; + } + + function fmtPnlStat(v) { + const n = Number(v); + if (!Number.isFinite(n)) return "—"; + const cls = n >= 0 ? "pnl-pos" : "pnl-neg"; + const text = (n >= 0 ? "+" : "") + n.toFixed(2) + "U"; + return '' + text + ""; + } + + function renderExchangeOptions() { + if (!elExchange || !meta) return; + const cur = elExchange.value; + elExchange.innerHTML = ''; + (meta.exchanges || []).forEach(function (ex) { + const opt = document.createElement("option"); + opt.value = ex.key || ""; + opt.textContent = (ex.name || ex.key || "") + " (" + (ex.key || "") + ")"; + elExchange.appendChild(opt); + }); + if (cur) elExchange.value = cur; + } + + function fmtPnlStatOptional(v) { + if (v == null || v === "") return "—"; + return fmtPnlStat(v); + } + + function fmtWinRate(v, openN, winN) { + if (v != null && v !== "") return Number(v).toFixed(1) + "%"; + if (openN) return (Math.round(((winN || 0) / openN) * 1000) / 10) + "%"; + return "—"; + } + + function fmtProfitLossRatio(v) { + if (v == null || v === "") return "—"; + const n = Number(v); + if (!Number.isFinite(n)) return "—"; + return n.toFixed(2) + ":1"; + } + + function renderStatsRow(label, e, isTotal) { + const openN = e.open_count || 0; + const sickN = e.sick_count || 0; + const sickShare = e.sick_pct != null ? e.sick_pct : openN ? Math.round((sickN / openN) * 1000) / 10 : 0; + const rowCls = isTotal ? ' class="archive-stats-total"' : ""; + return ( + "" + + (isTotal ? "" + esc(label) + "" : esc(label)) + + "" + + openN + + "" + + (e.win_count || 0) + + "" + + (e.loss_count || 0) + + "" + + fmtWinRate(e.win_rate, openN, e.win_count) + + "" + + fmtPnlStatOptional(e.avg_win) + + "" + + fmtPnlStatOptional(e.avg_loss) + + "" + + fmtProfitLossRatio(e.profit_loss_ratio) + + "" + + fmtPnlStatOptional(e.max_win) + + "" + + fmtPnlStatOptional(e.max_loss) + + "" + + sickN + + "" + + sickShare + + "%" + + fmtPnlStat(e.pnl_total) + + "" + + fmtPnlStat(e.pnl_ex_sick) + + "" + + fmtVolStat(e.turnover_total) + + "" + + fmtFeeStat(e.commission_total) + + "" + ); + } + + function calendarRefDate() { + let ref = tradingDay || (elTradingDay && elTradingDay.value) || ""; + if (!ref && dateFrom) ref = dateFrom; + return ref || new Date(); + } + + function ensureCalendarWidget() { + if (calendarWidget || !window.TradeStatsCalendar || !elCalendar) return calendarWidget; + calendarWidget = new TradeStatsCalendar({ + gridEl: elCalendar, + titleEl: elCalTitle, + prevBtn: elCalPrev, + nextBtn: elCalNext, + showSick: true, + buildQuery: function (year, month) { + const q = new URLSearchParams(); + q.set("year", String(year)); + q.set("month", String(month)); + const ex = (elExchange && elExchange.value) || ""; + if (ex) q.set("exchange_key", ex); + return q; + }, + fetchFn: async function (q) { + const r = await apiFetch("/api/archive/calendar?" + q.toString()); + return r.json(); + }, + parseResponse: function (data) { + if (!data || !data.ok) return {}; + return data.days || {}; + }, + onDayClick: function (day) { + selectedCalendarDay = day; + setPeriodMode("today"); + if (elTradingDay) elTradingDay.value = day; + if (elQuoteDate) elQuoteDate.value = day; + if (elFilterSick) elFilterSick.checked = false; + syncPeriodUI(); + void loadDailyTrades(); + }, + }); + calendarWidget.ensureMonth(calendarRefDate()); + return calendarWidget; + } + + async function loadCalendar() { + const cal = ensureCalendarWidget(); + if (!cal) return; + cal.selectedDay = selectedCalendarDay; + await cal.load(); + if (elCalSummarySub && cal.monthPnlTotal != null) { + const pnl = Number(cal.monthPnlTotal) || 0; + const sign = pnl > 0 ? "+" : ""; + elCalSummarySub.textContent = cal.year + "年" + cal.month + "月 " + sign + pnl.toFixed(2) + "U"; + } + } + + function renderStats() { + if (!elStats) return; + const st = dailyStats || { open_count: 0, by_exchange: {} }; + const label = periodLabel || "本日"; + const byEx = st.by_exchange || {}; + const exKeys = Object.keys(byEx).sort(); + let rows = + renderStatsRow( + label, + { + open_count: st.open_count, + sick_count: st.sick_count, + sick_pct: st.sick_pct, + pnl_total: st.pnl_total, + pnl_ex_sick: st.pnl_ex_sick, + win_count: st.win_count, + loss_count: st.loss_count, + avg_win: st.avg_win, + avg_loss: st.avg_loss, + win_rate: st.win_rate, + profit_loss_ratio: st.profit_loss_ratio, + max_win: st.max_win, + max_loss: st.max_loss, + turnover_total: st.turnover_total, + commission_total: st.commission_total, + }, + true + ) + + exKeys + .map(function (ex) { + return renderStatsRow(exchangeLabel(ex), byEx[ex] || {}, false); + }) + .join(""); + elStats.innerHTML = + '' + + "" + + "" + + rows + + "
        范围开仓盈利单亏损单胜率平均盈利平均亏损盈亏比最大盈利最大亏损犯病犯病占比盈亏剔除犯病盈亏成交额手续费
        "; + renderStatsCharts(); + } + + function fmtDurationMinutes(minutes) { + if (minutes == null || minutes === "" || Number.isNaN(Number(minutes))) return "—"; + let m = Math.max(0, Math.round(Number(minutes))); + if (m < 60) return m + "分"; + const h = Math.floor(m / 60); + const rm = m % 60; + if (h < 24) return rm ? h + "时" + rm + "分" : h + "时"; + const d = Math.floor(h / 24); + const rh = h % 24; + return rh ? d + "天" + rh + "时" : d + "天"; + } + + function sumTradePnlSides(trades) { + let profit = 0; + let loss = 0; + (trades || []).forEach(function (t) { + const pnl = Number(t.pnl_amount); + if (!Number.isFinite(pnl)) return; + if (pnl > 0.0001) profit += pnl; + else if (pnl < -0.0001) loss += Math.abs(pnl); + }); + return { profit: profit, loss: loss }; + } + + function avgHoldMinutes(trades, side) { + const vals = []; + (trades || []).forEach(function (t) { + const pnl = Number(t.pnl_amount); + const hold = Number(t.hold_minutes); + if (!Number.isFinite(hold) || hold < 0) return; + if (side === "win" && pnl > 0.0001) vals.push(hold); + if (side === "loss" && pnl < -0.0001) vals.push(hold); + }); + if (!vals.length) return null; + return Math.round(vals.reduce(function (a, b) { return a + b; }, 0) / vals.length); + } + + function buildCumulativeSeries(trades) { + const byDay = {}; + (trades || []).forEach(function (t) { + const raw = t.closed_at || ""; + const day = String(raw).slice(0, 10); + if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) return; + byDay[day] = (byDay[day] || 0) + Number(t.pnl_amount || 0); + }); + const days = Object.keys(byDay).sort(); + let cum = 0; + return days.map(function (day) { + cum += byDay[day]; + return { day: day, pnl: byDay[day], cum: cum }; + }); + } + + function renderCumulativeChart(series) { + if (!series.length) { + return '

        当前区间暂无平仓数据

        '; + } + const w = 320; + const h = 96; + const padL = 6; + const padR = 6; + const padT = 10; + const padB = 10; + const vals = series.map(function (s) { return s.cum; }); + const rawMin = Math.min.apply(null, vals); + const rawMax = Math.max.apply(null, vals); + let minV = Math.min(0, rawMin); + let maxV = Math.max(0, rawMax); + const span = maxV - minV || Math.max(Math.abs(rawMax), Math.abs(rawMin), 1); + const yPad = span * 0.14; + minV -= yPad; + maxV += yPad; + const range = maxV - minV || 1; + const innerW = w - padL - padR; + const innerH = h - padT - padB; + const yOf = function (v) { + return padT + innerH - ((v - minV) / range) * innerH; + }; + const zeroY = yOf(0); + const showZero = rawMin < -0.0001 || rawMax > 0.0001; + const pts = series.map(function (s, i) { + const x = padL + (i / Math.max(series.length - 1, 1)) * innerW; + const y = yOf(s.cum); + return { x: x, y: y, day: s.day, pnl: s.pnl, cum: s.cum }; + }); + const linePts = pts.map(function (p) { return p.x.toFixed(1) + "," + p.y.toFixed(1); }).join(" "); + const last = series[series.length - 1]; + const lastCls = last.cum >= 0 ? "pnl-pos" : "pnl-neg"; + const lineCls = last.cum >= 0 ? "archive-cum-line--up" : "archive-cum-line--down"; + const sign = last.cum > 0 ? "+" : ""; + const fmtAxis = function (v) { + const a = Math.abs(v); + if (a >= 100) return (v > 0 ? "+" : "") + v.toFixed(0) + "U"; + if (a >= 10) return (v > 0 ? "+" : "") + v.toFixed(1) + "U"; + return (v > 0 ? "+" : "") + v.toFixed(2) + "U"; + }; + const yTop = fmtAxis(maxV); + const yMid = showZero ? "0" : fmtAxis((maxV + minV) / 2); + const yBot = fmtAxis(minV); + const gridLines = [maxV, showZero ? 0 : null, minV] + .filter(function (v, i, arr) { + return v != null && arr.indexOf(v) === i; + }) + .map(function (v) { + const y = yOf(v).toFixed(1); + const cls = v === 0 ? "archive-cum-grid archive-cum-grid--zero" : "archive-cum-grid"; + return ''; + }) + .join(""); + const dots = pts + .map(function (p, i) { + const isLast = i === pts.length - 1; + const dotCls = p.cum >= 0 ? "archive-cum-dot--up" : "archive-cum-dot--down"; + const r = isLast ? 4.2 : 2.6; + const dayPnl = p.pnl > 0 ? "+" + p.pnl.toFixed(2) : p.pnl.toFixed(2); + const cumPnl = p.cum > 0 ? "+" + p.cum.toFixed(2) : p.cum.toFixed(2); + return ( + '' + + '' + esc(p.day.slice(5)) + " 当日" + dayPnl + "U · 累计" + cumPnl + "U" + + "" + ); + }) + .join(""); + const dayCount = series.length; + const peak = fmtAxis(rawMax); + const trough = fmtAxis(rawMin); + const xStart = esc(series[0].day.slice(5)); + const xEnd = esc(series[series.length - 1].day.slice(5)); + return ( + '
        ' + + '
        ' + + '累计盈亏' + + '' + sign + last.cum.toFixed(2) + "U" + + "
        " + + '
        ' + + '" + + '
        ' + + '" + + '" + + "
        " + + "
        " + + '
        ' + + dayCount + " 个交易日 · 区间高 " + esc(peak) + " · 低 " + esc(trough) + + "
        " + + "
        " + ); + } + + function barRow(label, valueLabel, pct, fillCls) { + const w = Math.max(0, Math.min(100, pct)); + return ( + '
        ' + + '' + esc(label) + "" + + '
        ' + + '' + esc(valueLabel) + "" + + "
        " + ); + } + + function stackedPnlBar(profit, loss) { + const total = profit + loss; + if (total <= 0) { + return '

        暂无盈亏数据

        '; + } + const profitPct = (profit / total) * 100; + const lossPct = 100 - profitPct; + const net = profit - loss; + const netCls = net >= 0 ? "pnl-pos" : "pnl-neg"; + const netSign = net > 0 ? "+" : ""; + return ( + '
        ' + + '
        ' + + (profitPct >= 18 ? profit.toFixed(2) + "U" : "") + + "
        " + + '
        ' + + (lossPct >= 18 ? loss.toFixed(2) + "U" : "") + + "
        " + + "
        " + + '
        ' + + '盈利 ' + profit.toFixed(2) + "U" + + '亏损 ' + loss.toFixed(2) + "U" + + '净 ' + netSign + net.toFixed(2) + "U" + + "
        " + ); + } + + function divergingBarRow(label, pnl, maxAbs) { + const absPct = (Math.abs(pnl) / maxAbs) * 50; + const cls = pnl >= 0 ? "archive-viz-div-fill--profit" : "archive-viz-div-fill--loss"; + const vCls = pnl >= 0 ? "pnl-pos" : "pnl-neg"; + const sign = pnl > 0 ? "+" : ""; + const style = + pnl >= 0 + ? "left:50%;width:" + absPct.toFixed(1) + "%" + : "right:50%;width:" + absPct.toFixed(1) + "%"; + return ( + '
        ' + + '' + esc(label) + "" + + '
        ' + + '' + sign + pnl.toFixed(2) + "U" + + "
        " + ); + } + + function renderStatsCharts() { + if (!elStatsCharts) return; + const st = dailyStats || { open_count: 0, by_exchange: {} }; + const openN = st.open_count || 0; + const winN = st.win_count || 0; + const lossN = st.loss_count || 0; + const winRate = openN ? Number(st.win_rate) || 0 : 0; + const sides = sumTradePnlSides(dailyTrades); + const pnlTotal = sides.profit + sides.loss; + const netPnl = Number(st.pnl_total) || 0; + const sickN = st.sick_count || 0; + const sickPct = openN ? (sickN / openN) * 100 : 0; + const winHold = avgHoldMinutes(dailyTrades, "win"); + const lossHold = avgHoldMinutes(dailyTrades, "loss"); + const holdMax = Math.max(winHold || 0, lossHold || 0); + const byEx = st.by_exchange || {}; + const exKeys = Object.keys(byEx).sort(); + + if (elStatsVizSub) { + const sign = netPnl > 0 ? "+" : ""; + elStatsVizSub.textContent = openN + ? "胜率 " + winRate.toFixed(0) + "% · " + sign + netPnl.toFixed(2) + "U" + : "暂无平仓"; + } + + if (!openN) { + elStatsCharts.innerHTML = '

        当前区间暂无平仓数据

        '; + return; + } + + const netCls = netPnl >= 0 ? "pnl-pos" : "pnl-neg"; + const netSign = netPnl > 0 ? "+" : ""; + + let exBars = ""; + if (exKeys.length) { + const maxAbs = Math.max.apply( + null, + exKeys.map(function (ex) { + return Math.abs(Number(byEx[ex].pnl_total) || 0); + }).concat([0.0001]) + ); + exBars = exKeys + .map(function (ex) { + const pnl = Number(byEx[ex].pnl_total) || 0; + return divergingBarRow(exchangeLabel(ex), pnl, maxAbs); + }) + .join(""); + } else { + exBars = '

        暂无分策略数据

        '; + } + + const cumSeries = buildCumulativeSeries(dailyTrades); + elStatsCharts.innerHTML = + '
        ' + + '
        ' + + '' + netSign + netPnl.toFixed(2) + "U" + + '净盈亏' + + "
        " + + '
        ' + + '
        ' + + '' + winRate.toFixed(0) + "%" + + "
        " + + '' + winN + "胜 " + lossN + "负" + + "
        " + + '
        ' + + '' + sickN + " 笔" + + '犯病 ' + sickPct.toFixed(0) + '%' + + "
        " + + "
        " + + '
        ' + + '
        盈亏构成
        ' + + (pnlTotal > 0 ? stackedPnlBar(sides.profit, sides.loss) : '

        暂无盈亏数据

        ') + + "
        " + + '
        ' + + '
        分策略盈亏
        ' + + exBars + + "
        " + + (holdMax > 0 + ? '
        ' + + '
        持仓时长对比
        ' + + '
        ' + + '
        ' + + '' + esc(fmtDurationMinutes(winHold)) + "" + + '盈单均持仓' + + '
        ' + + "
        " + + '
        ' + + '' + esc(fmtDurationMinutes(lossHold)) + "" + + '亏单均持仓' + + '
        ' + + "
        " + + "
        " + : "") + + '
        ' + + renderCumulativeChart(cumSeries) + + "
        "; + } + + function quotePreview(text) { + const s = String(text || "").replace(/\s+/g, " ").trim(); + if (!s) return "(空)"; + return s.length > 36 ? s.slice(0, 36) + "…" : s; + } + + function findQuote(id) { + if (id == null || id === "") return null; + return ( + quotes.find(function (q) { + return String(q.id) === String(id); + }) || null + ); + } + + function updateQuoteSubmitBtn() { + if (!elQuoteSubmit) return; + elQuoteSubmit.textContent = editingQuoteId ? "修改保存" : "添加语录"; + } + + function syncQuoteDateFromTradingDay(force) { + if (!elQuoteDate) return; + const day = + (elTradingDay && elTradingDay.value) || + selectedCalendarDay || + tradingDay || + ""; + if (!day) return; + if (force || !elQuoteDate.value) { + elQuoteDate.value = day; + } + } + + function setArchiveContentTab(tab) { + const allowed = { viz: 1, calendar: 1, trades: 1, quotes: 1 }; + const next = allowed[tab] ? tab : "trades"; + archiveContentTab = next; + if (elContentTabs) { + elContentTabs.querySelectorAll(".archive-content-tab").forEach(function (btn) { + const on = btn.getAttribute("data-archive-tab") === next; + btn.classList.toggle("is-active", on); + btn.setAttribute("aria-selected", on ? "true" : "false"); + }); + } + const panels = [ + [elPanelViz, "viz"], + [elPanelCalendar, "calendar"], + [elPanelTrades, "trades"], + [elPanelQuotes, "quotes"], + ]; + panels.forEach(function (pair) { + const el = pair[0]; + const key = pair[1]; + if (!el) return; + const on = key === next; + el.classList.toggle("is-active", on); + el.hidden = !on; + }); + if (next === "quotes") { + syncQuoteDateFromTradingDay(false); + void loadQuoteDayTrades(); + } else if (next === "calendar") { + void loadCalendar(); + renderCalendarDayTrades(); + } else if (next === "trades") { + requestAnimationFrame(syncTradesLayout); + } + } + + function quoteTagLabel(t) { + if (!t) return "—"; + if (t.behavior_tag_from_journal || t.behavior_tag === "sick") return "犯病"; + if (t.behavior_tag === "emotion") return "情绪"; + return "—"; + } + + function renderQuoteDayTrades() { + if (!elQuoteDayTradesBody) return; + const day = quoteDayTradesDay || (elQuoteDate && elQuoteDate.value) || ""; + if (elQuoteDayTradesMeta) { + elQuoteDayTradesMeta.textContent = day + ? day + " · " + quoteDayTrades.length + " 笔" + : ""; + } + if (!day) { + elQuoteDayTradesBody.innerHTML = + '

        选择日期后自动带上该日已平仓记录.

        '; + return; + } + if (!quoteDayTrades.length) { + elQuoteDayTradesBody.innerHTML = + '

        该日暂无已平仓记录.

        '; + return; + } + elQuoteDayTradesBody.innerHTML = + '' + + "" + + "" + + quoteDayTrades + .map(function (t) { + return ( + "" + + "" + + "" + + "" + + '" + + '" + + '" + + "" + ); + }) + .join("") + + "
        交易所合约方向开仓平仓盈亏标签
        " + + esc(tradeRowExchange(t)) + + "" + + esc(t.symbol || "—") + + "" + + esc(t.direction || "—") + + "' + + fmtDt(t.opened_at) + + "' + + fmtDt(t.closed_at) + + "' + + fmtPnl(t.pnl_amount) + + "" + + esc(quoteTagLabel(t)) + + "
        "; + } + + async function loadQuoteDayTrades() { + if (!elQuoteDayTradesBody) return; + const day = elQuoteDate && String(elQuoteDate.value || "").trim(); + if (!day) { + quoteDayTrades = []; + quoteDayTradesDay = ""; + renderQuoteDayTrades(); + return; + } + const req = ++quoteDayTradesReq; + const q = new URLSearchParams(); + q.set("period", "today"); + q.set("trading_day", day); + elQuoteDayTradesBody.innerHTML = '

        加载当日已平仓…

        '; + if (elQuoteDayTradesMeta) elQuoteDayTradesMeta.textContent = day; + try { + const r = await apiFetch("/api/archive/daily-trades?" + q.toString()); + const j = await r.json(); + if (req !== quoteDayTradesReq) return; + if (!r.ok) { + quoteDayTrades = []; + quoteDayTradesDay = day; + elQuoteDayTradesBody.innerHTML = + '

        ' + esc(j.detail || "加载失败") + "

        "; + return; + } + quoteDayTrades = j.trades || []; + quoteDayTradesDay = day; + renderQuoteDayTrades(); + } catch (_) { + if (req !== quoteDayTradesReq) return; + quoteDayTrades = []; + quoteDayTradesDay = day; + elQuoteDayTradesBody.innerHTML = + '

        加载失败

        '; + } + } + + function resetQuoteForm() { + editingQuoteId = null; + if (elQuoteContent) elQuoteContent.value = ""; + updateQuoteSubmitBtn(); + } + + function startEditQuote() { + const q = findQuote(selectedQuoteId); + if (!q) return; + editingQuoteId = q.id; + if (elQuoteDate) elQuoteDate.value = q.quote_date || ""; + if (elQuoteContent) { + elQuoteContent.value = q.content || ""; + elQuoteContent.focus(); + } + updateQuoteSubmitBtn(); + void loadQuoteDayTrades(); + } + + function selectQuote(id) { + const nextId = String(id); + const same = selectedQuoteId != null && String(selectedQuoteId) === nextId; + if (editingQuoteId != null && String(editingQuoteId) !== nextId) { + resetQuoteForm(); + } + selectedQuoteId = same ? null : id; + renderQuotes(); + } + + function renderQuotes() { + if (!elQuotesList) return; + if (elQuotesCount) { + elQuotesCount.textContent = quotes.length ? quotes.length + " 条" : ""; + } + if (!quotes.length) { + elQuotesList.innerHTML = '

        暂无复盘语录,可在上方添加.

        '; + return; + } + elQuotesList.innerHTML = quotes + .map(function (q) { + const selected = String(q.id) === String(selectedQuoteId); + return ( + '
        ' + + '" + + (selected + ? '
        ' + + '
        ' + + esc(q.content || "(空)") + + "
        " + + '
        ' + + '' + + '' + + '' + + "
        " + : "") + + "
        " + ); + }) + .join(""); + + elQuotesList.querySelectorAll(".archive-quote-item").forEach(function (btn) { + btn.addEventListener("click", function () { + selectQuote(btn.getAttribute("data-id")); + }); + }); + elQuotesList.querySelectorAll(".archive-quote-edit-btn").forEach(function (btn) { + btn.addEventListener("click", function (ev) { + ev.stopPropagation(); + selectedQuoteId = btn.getAttribute("data-id"); + startEditQuote(); + }); + }); + elQuotesList.querySelectorAll(".archive-quote-del-btn").forEach(function (btn) { + btn.addEventListener("click", function (ev) { + ev.stopPropagation(); + void deleteQuote(btn.getAttribute("data-id")); + }); + }); + elQuotesList.querySelectorAll(".archive-quote-ai-btn").forEach(function (btn) { + btn.addEventListener("click", function (ev) { + ev.stopPropagation(); + startQuoteAiChat(btn.getAttribute("data-id")); + }); + }); + } + + async function loadQuotes() { + const r = await apiFetch("/api/archive/quotes"); + const j = await r.json(); + quotes = j.quotes || []; + if (!findQuote(selectedQuoteId)) { + selectedQuoteId = null; + } + renderQuotes(); + } + + async function submitQuoteForm(ev) { + if (ev) ev.preventDefault(); + const date = elQuoteDate && elQuoteDate.value; + const content = elQuoteContent && elQuoteContent.value.trim(); + if (!date || !content) return; + if (editingQuoteId) { + await saveQuote(editingQuoteId, date, content); + return; + } + const r = await apiFetch("/api/archive/quotes", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ quote_date: date, content: content }), + }); + const j = await r.json(); + if (!r.ok) { + setStatus(j.detail || "添加失败"); + return; + } + resetQuoteForm(); + selectedQuoteId = null; + await loadQuotes(); + setStatus("语录已添加"); + } + + async function saveQuote(id, quoteDate, content) { + const r = await apiFetch("/api/archive/quotes/" + id, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ quote_date: String(quoteDate || "").trim(), content: content }), + }); + const j = await r.json(); + if (!r.ok) { + setStatus(j.detail || "保存失败"); + return; + } + resetQuoteForm(); + selectedQuoteId = null; + await loadQuotes(); + setStatus("语录已保存"); + } + + const ARCHIVE_QUOTE_AI_KEY = "hub_archive_quote_ai"; + + function startQuoteAiChat(quoteId) { + const q = findQuote(quoteId); + const content = q && String(q.content || "").trim(); + if (!q || !content) { + setStatus("语录内容为空,无法发起 AI 对话"); + return; + } + try { + sessionStorage.setItem( + ARCHIVE_QUOTE_AI_KEY, + JSON.stringify({ + quote_date: q.quote_date || "", + content: content, + }) + ); + } catch (_) { + setStatus("无法保存跳转数据"); + return; + } + if (typeof window.hubNavigateTo === "function") { + window.hubNavigateTo("/ai"); + return; + } + location.href = "/ai"; + } + + async function deleteQuote(id) { + if (!id || !window.confirm("确定删除这条复盘语录?")) return; + const r = await apiFetch("/api/archive/quotes/" + id, { method: "DELETE" }); + if (!r.ok) { + const j = await r.json().catch(function () { + return {}; + }); + setStatus(j.detail || "删除失败"); + return; + } + if (String(id) === String(editingQuoteId)) resetQuoteForm(); + if (String(id) === String(selectedQuoteId)) selectedQuoteId = null; + await loadQuotes(); + setStatus("语录已删除"); + } + + function pickAnchorTrade() { + if (!trades.length) return null; + if (selectedTradeKey) { + const hit = trades.find(function (t) { + return tradeRowKey(t) === selectedTradeKey; + }); + if (hit) return hit; + } + return trades[0]; + } + + function parseTimeMs(raw) { + if (raw == null || raw === "") return null; + if (typeof raw === "number" && Number.isFinite(raw)) { + const v = Math.trunc(raw); + return v > 1e12 ? v : v * 1000; + } + const s = String(raw).trim().replace("Z", "").replace("T", " "); + if (!s) return null; + const m = s.match(/^(\d{4})-(\d{2})-(\d{2})(?: (\d{2}):(\d{2})(?::(\d{2}))?)?/); + if (!m) return null; + const ms = + Date.UTC( + Number(m[1]), + Number(m[2]) - 1, + Number(m[3]), + Number(m[4] || 0), + Number(m[5] || 0), + Number(m[6] || 0) + ) - + CHART_TZ_OFFSET_SEC * 1000; + return Number.isFinite(ms) ? ms : null; + } + + function tradeOpenMs(tr) { + if (!tr) return null; + return tr.opened_at_ms || parseTimeMs(tr.opened_at); + } + + function tradeCloseMs(tr) { + if (!tr) return null; + return tr.closed_at_ms || parseTimeMs(tr.closed_at); + } + + function anchorMsForTrade(tr) { + if (!tr) return null; + const mode = (elViewMode && elViewMode.value) || "hold"; + if (mode === "entry") return tradeOpenMs(tr); + return tradeCloseMs(tr) || tradeOpenMs(tr); + } + + function msToBarTime(ms, tf) { + const period = TF_MS[tf] || TF_MS["15m"]; + const aligned = Math.floor(Number(ms) / period) * period; + return Math.floor(aligned / 1000); + } + + function snapToCandleTime(targetSec, candles) { + if (!candles || !candles.length) return targetSec; + let best = candles[0].time; + let bestDiff = Math.abs(candles[0].time - targetSec); + for (let i = 0; i < candles.length; i++) { + const d = Math.abs(candles[i].time - targetSec); + if (d < bestDiff) { + bestDiff = d; + best = candles[i].time; + } + } + return best; + } + + const OPEN_ARROW_LONG = "#22c55e"; + const OPEN_ARROW_SHORT = "#ef4444"; + const OPEN_ARROW_LONG_HI = "#4ade80"; + const OPEN_ARROW_SHORT_HI = "#f87171"; + + function isLongDirection(dir) { + const d = String(dir || "").trim().toLowerCase(); + if (d === "short" || d === "空" || d === "sell" || d === "做空" || d === "shorts") return false; + if (d === "long" || d === "多" || d === "buy" || d === "做多" || d === "longs") return true; + return true; + } + + function openArrowColor(long, highlight) { + if (long) return highlight ? OPEN_ARROW_LONG_HI : OPEN_ARROW_LONG; + return highlight ? OPEN_ARROW_SHORT_HI : OPEN_ARROW_SHORT; + } + + function buildTradeMarkers(tr, candles, tf, opts) { + if (!tr || !candles.length) return []; + const options = opts || {}; + const suffix = options.labelSuffix ? String(options.labelSuffix) : ""; + const highlight = !!options.highlight; + const long = isLongDirection(tr.direction); + const openMs = tradeOpenMs(tr); + const closeMs = tradeCloseMs(tr); + const openColor = openArrowColor(long, highlight); + let closeColor = highlight ? "#fbbf24" : "#f59e0b"; + const pnl = Number(tr.pnl_amount); + if (!highlight && Number.isFinite(pnl) && pnl < -0.0001) closeColor = "#a855f7"; + const markers = []; + if (openMs) { + markers.push({ + time: snapToCandleTime(msToBarTime(openMs, tf), candles), + position: long ? "belowBar" : "aboveBar", + color: openColor, + shape: long ? "arrowUp" : "arrowDown", + text: "开" + suffix, + }); + } + if (closeMs) { + markers.push({ + time: snapToCandleTime(msToBarTime(closeMs, tf), candles), + position: long ? "aboveBar" : "belowBar", + color: closeColor, + shape: long ? "arrowDown" : "arrowUp", + text: "平" + suffix, + }); + } + return markers; + } + + function buildChartMarkers(candles, tf) { + if (!candles.length) return []; + const tr = pickAnchorTrade(); + if (!markAuto || !trades.length) { + return buildTradeMarkers(tr, candles, tf, { highlight: true }); + } + const sorted = trades.slice().sort(function (a, b) { + return (tradeOpenMs(a) || 0) - (tradeOpenMs(b) || 0); + }); + const multi = sorted.length > 1; + const out = []; + sorted.forEach(function (row, idx) { + const rowKey = tradeRowKey(row); + const parts = buildTradeMarkers(row, candles, tf, { + labelSuffix: multi ? String(idx + 1) : "", + highlight: rowKey === selectedTradeKey, + }); + out.push.apply(out, parts); + }); + return out.sort(function (a, b) { + return a.time > b.time ? 1 : a.time < b.time ? -1 : 0; + }); + } + + function applyChartMarkers() { + if (!candleSeries || !candleSeries.setMarkers || !lastCandles.length) return; + candleSeries.setMarkers(buildChartMarkers(lastCandles, timeframe)); + } + + function focusInitialTradeView(candles, tr, tf) { + if (!chart || !candles.length || !tr) return; + const mode = (elViewMode && elViewMode.value) || "hold"; + const openSec = tradeOpenMs(tr) ? msToBarTime(tradeOpenMs(tr), tf) : null; + const closeSec = tradeCloseMs(tr) ? msToBarTime(tradeCloseMs(tr), tf) : null; + let openIdx = 0; + let closeIdx = candles.length - 1; + if (openSec != null) { + for (let i = 0; i < candles.length; i++) { + if (candles[i].time >= openSec) { + openIdx = i; + break; + } + } + } + if (closeSec != null) { + for (let i = candles.length - 1; i >= 0; i--) { + if (candles[i].time <= closeSec) { + closeIdx = i; + break; + } + } + } + const span = Math.max(24, closeIdx - openIdx + 20); + let fromIdx; + let toIdx; + if (mode === "entry") { + fromIdx = Math.max(0, openIdx - Math.floor(span * 0.35)); + toIdx = Math.min(candles.length - 1, openIdx + Math.floor(span * 0.65)); + } else { + fromIdx = Math.max(0, openIdx - 10); + toIdx = Math.min(candles.length - 1, closeIdx + 14); + } + if (toIdx <= fromIdx) toIdx = Math.min(candles.length - 1, fromIdx + 80); + chart.timeScale().setVisibleLogicalRange({ from: fromIdx, to: toIdx + 4 }); + } + + function destroyChart() { + if (chart) { + chart.remove(); + chart = null; + candleSeries = null; + volumeSeries = null; + } + if (elChartHost) elChartHost.innerHTML = ""; + } + + function ensureChart() { + if (!elChartHost || !window.LightweightCharts) return; + if (chart) return; + const isDark = document.documentElement.getAttribute("data-theme") !== "light"; + chart = LightweightCharts.createChart(elChartHost, { + layout: { + background: { color: isDark ? "#0b0e18" : "#f8f9fc" }, + textColor: isDark ? "#9aa4b8" : "#4a5568", + }, + grid: { + vertLines: { color: isDark ? "#1a2030" : "#e8ecf2" }, + horzLines: { color: isDark ? "#1a2030" : "#e8ecf2" }, + }, + rightPriceScale: { borderColor: isDark ? "#2a3348" : "#d0d7e2", autoScale: true }, + localization: chartLocalizationBj(), + timeScale: { + borderColor: isDark ? "#2a3348" : "#d0d7e2", + timeVisible: true, + secondsVisible: false, + }, + crosshair: { mode: LightweightCharts.CrosshairMode.Normal }, + handleScroll: { + mouseWheel: true, + pressedMouseMove: true, + horzTouchDrag: true, + vertTouchDrag: false, + }, + handleScale: { + axisPressedMouseMove: true, + mouseWheel: true, + pinch: true, + }, + }); + candleSeries = chart.addCandlestickSeries({ + upColor: "#22c55e", + downColor: "#ef4444", + borderVisible: false, + wickUpColor: "#22c55e", + wickDownColor: "#ef4444", + }); + volumeSeries = chart.addHistogramSeries({ + color: "#3b82f680", + priceFormat: { type: "volume" }, + priceScaleId: "", + }); + volumeSeries.priceScale().applyOptions({ scaleMargins: { top: 0.82, bottom: 0 } }); + new ResizeObserver(function () { + if (chart && elChartHost) { + chart.applyOptions({ width: elChartHost.clientWidth, height: elChartHost.clientHeight }); + } + }).observe(elChartHost); + chart.applyOptions({ width: elChartHost.clientWidth, height: elChartHost.clientHeight }); + } + + async function loadSymbolTradesForChart(exKey, sym) { + const r = await apiFetch( + "/api/archive/detail?exchange_key=" + + encodeURIComponent(exKey) + + "&symbol=" + + encodeURIComponent(sym) + ); + const j = await r.json(); + trades = j.trades || []; + } + + async function loadChart() { + if (!selected || !isChartOpen()) return; + const tr = pickAnchorTrade(); + const jump = (elJumpAt && elJumpAt.value) || ""; + let openMs = null; + let closeMs = null; + if (markAuto && trades.length) { + const bounds = tradeHistoryBounds(trades); + openMs = bounds.minOpen; + closeMs = bounds.maxClose; + } else if (tr) { + openMs = tradeOpenMs(tr); + closeMs = tradeCloseMs(tr); + } + const params = new URLSearchParams({ + exchange_key: selected.exchange_key, + symbol: selected.symbol, + timeframe: timeframe, + mode: (elViewMode && elViewMode.value) || "hold", + }); + if (openMs && closeMs) { + params.set("range", "history"); + params.set("opened_ms", String(openMs)); + params.set("closed_ms", String(closeMs)); + } else { + params.set("bars", "200"); + const anchor = anchorMsForTrade(tr); + if (jump.trim()) params.set("at", jump.trim()); + else if (anchor) params.set("anchor_ms", String(anchor)); + } + setStatus("加载 K 线…"); + const r = await apiFetch("/api/archive/ohlcv?" + params.toString()); + const j = await r.json(); + if (!r.ok) { + setStatus(j.detail || "K 线加载失败"); + return; + } + chartExchangeSymbol = j.exchange_symbol || ""; + chartMarketType = j.market_type || "swap"; + if (chart) { + destroyChart(); + } + ensureChart(); + scheduleChartResize(); + const candles = j.candles || []; + lastCandles = candles; + candleSeries.setData( + candles.map(function (c) { + return { time: c.time, open: c.open, high: c.high, low: c.low, close: c.close }; + }) + ); + volumeSeries.setData( + candles.map(function (c) { + return { + time: c.time, + value: c.volume || 0, + color: c.close >= c.open ? "#22c55e55" : "#ef444455", + }; + }) + ); + applyChartMarkers(); + if (tr && tradeOpenMs(tr) && tradeCloseMs(tr)) { + focusInitialTradeView(candles, tr, timeframe); + } else if (candles.length > 10) { + chart.timeScale().setVisibleLogicalRange({ from: candles.length - 120, to: candles.length + 5 }); + } + updateChartTitle(); + scheduleChartResize(); + setStatus( + "K 线 " + + candles.length + + " 根 · " + + timeframe + + " · " + + formatChartContractLabel(selected.symbol, chartExchangeSymbol, chartMarketType) + ); + } + + function isTradeRowInteractiveTarget(el) { + return !!( + el && + el.closest && + el.closest("button, select, input, textarea, a, label, .archive-actions-cell") + ); + } + + function ensureTradePageVisible(tr) { + if (!tr || !dailyTrades.length) return; + const key = tradeRowKey(tr); + const idx = dailyTrades.findIndex(function (t) { + return tradeRowKey(t) === key; + }); + if (idx < 0) return; + tradesPage = Math.floor(idx / TRADES_PAGE_SIZE); + } + + async function switchToTrade(tr) { + if (!tr) return; + const exKey = String(tr.exchange_key || "").toLowerCase(); + const sym = tr.symbol || ""; + if (!exKey || !sym) { + setStatus("该笔交易缺少交易所或合约,无法切换"); + return; + } + const key = tradeRowKey(tr); + const prevEx = selected && selected.exchange_key; + const prevSym = selected && selected.symbol; + if (key === selectedTradeKey && prevEx === exKey && prevSym === sym) return; + + selected = { exchange_key: exKey, symbol: sym }; + selectedTradeKey = key; + ensureTradePageVisible(tr); + renderTrades(); + + const needSymbolReload = prevEx !== exKey || prevSym !== sym; + if (needSymbolReload) { + await loadSymbolTradesForChart(exKey, sym); + } + if (!isChartOpen()) return; + + if (needSymbolReload) { + await loadChart(); + return; + } + applyChartMarkers(); + const anchor = pickAnchorTrade(); + if (anchor && lastCandles.length) { + focusInitialTradeView(lastCandles, anchor, timeframe); + } + updateChartTitle(); + setStatus("已切换至 " + sym + " · " + exchangeLabel(exKey)); + } + + async function openTradeChart(tr) { + if (!tr) return; + const exKey = String(tr.exchange_key || "").toLowerCase(); + const sym = tr.symbol || ""; + if (!exKey || !sym) { + setStatus("该笔交易缺少交易所或合约,无法加载图表"); + return; + } + setChartOpen(true); + await switchToTrade(tr); + } + + function renderCalendarDayTrades() { + if (!elCalDayTradesBody) return; + const day = + selectedCalendarDay || + (periodMode === "today" && tradingDay) || + (elTradingDay && elTradingDay.value) || + ""; + if (elCalDayTradesMeta) { + elCalDayTradesMeta.textContent = + periodMode === "today" && day ? day + " · " + dailyTrades.length + " 笔" : ""; + } + if (periodMode !== "today" || !day) { + elCalDayTradesBody.innerHTML = + '

        点击日历中的日期,在下方查看当日交易记录.

        '; + return; + } + if (!dailyTrades.length) { + elCalDayTradesBody.innerHTML = + '

        该日暂无交易记录.

        '; + return; + } + elCalDayTradesBody.innerHTML = + '' + + "" + + "" + + dailyTrades + .map(function (t) { + const rowKey = tradeRowKey(t); + const tag = quoteTagLabel(t); + return ( + "" + + "" + + "" + + "" + + '" + + '" + + '" + + "" + + '' + ); + }) + .join("") + + "
        交易所合约方向开仓平仓盈亏标签
        " + + esc(tradeRowExchange(t)) + + "" + + esc(t.symbol || "—") + + "" + + esc(t.direction || "—") + + "' + + fmtDt(t.opened_at) + + "' + + fmtDt(t.closed_at) + + "' + + fmtPnl(t.pnl_amount) + + "" + + esc(tag) + + "
        "; + elCalDayTradesBody.querySelectorAll(".archive-cal-chart-btn").forEach(function (btn) { + btn.addEventListener("click", function () { + const tr = findTradeByKey(btn.getAttribute("data-key")); + if (tr) void openTradeChart(tr); + }); + }); + } + + function tradesPageCount() { + return Math.max(1, Math.ceil((dailyTrades.length || 0) / TRADES_PAGE_SIZE)); + } + + function clampTradesPage() { + const pages = tradesPageCount(); + if (tradesPage >= pages) tradesPage = pages - 1; + if (tradesPage < 0) tradesPage = 0; + } + + function updateTradesPager() { + clampTradesPage(); + const pages = tradesPageCount(); + const show = dailyTrades.length > TRADES_PAGE_SIZE; + if (elTradesPager) elTradesPager.hidden = !show; + if (elTradesPageLabel) { + elTradesPageLabel.textContent = "第 " + (tradesPage + 1) + " / " + pages + " 页"; + } + if (elTradesPrev) elTradesPrev.disabled = tradesPage <= 0; + if (elTradesNext) elTradesNext.disabled = tradesPage + 1 >= pages; + } + + function pagedDailyTrades() { + clampTradesPage(); + const start = tradesPage * TRADES_PAGE_SIZE; + return dailyTrades.slice(start, start + TRADES_PAGE_SIZE); + } + + function renderTrades() { + if (!elTrades) return; + if (!dailyTrades.length) { + elTrades.innerHTML = + '

        该日暂无交易记录.可调整日期或点击「同步」拉取数据.

        '; + updateTradesPager(); + return; + } + const pageRows = pagedDailyTrades(); + elTrades.innerHTML = + '' + + "" + + "" + + "" + + pageRows + .map(function (t) { + const tid = t.trade_id || t.id; + const exKey = String(t.exchange_key || "").toLowerCase(); + const rowKey = tradeRowKey(t); + const journalSick = !!t.behavior_tag_from_journal; + const tag = journalSick ? "sick" : (t.behavior_tag || ""); + const sick = tag === "sick"; + const active = rowKey && rowKey === selectedTradeKey ? " is-active" : ""; + const rev = reviewMark(t); + return ( + '' + + "" + + '" + + "" + + '" + + '" + + '" + + "" + + "" + + '" + + "" + + "" + + (journalSick + ? '' + : '") + + '' + + '" + ); + }) + .join("") + + "
        交易所合约开仓类型开仓时间平仓时间持仓时长方向结果盈亏成交额手续费标签备注操作
        " + + esc(tradeRowExchange(t)) + + "' + + esc(t.symbol || "—") + + "" + + (rev ? '' + rev + "" : "") + + esc(fmtEntryType(t)) + + "' + + fmtDt(t.opened_at) + + "' + + fmtDt(t.closed_at) + + "' + + fmtHoldMinutes(t) + + "" + + esc(t.direction || "—") + + "" + + esc(t.result || "—") + + "' + + fmtPnl(t.pnl_amount) + + "" + + fmtVolStat(t.exchange_turnover_usdt) + + "" + + fmtFeeStat(t.exchange_commission_usdt) + + "犯病' + + '' + + '' + + "
        "; + + updateTradesPager(); + + elTrades.querySelectorAll(".archive-del-btn").forEach(function (btn) { + btn.addEventListener("click", function (ev) { + ev.stopPropagation(); + const row = btn.closest(".archive-trade-row"); + void deleteTrade(btn.getAttribute("data-id"), row && row.getAttribute("data-ex")); + }); + }); + elTrades.querySelectorAll(".archive-chart-btn").forEach(function (btn) { + btn.addEventListener("click", function (ev) { + ev.stopPropagation(); + const row = btn.closest(".archive-trade-row"); + const rowKey = row && row.getAttribute("data-key"); + const tr = findTradeByKey(rowKey); + if (tr) void openTradeChart(tr); + else if (rowKey) { + selectedTradeKey = rowKey; + renderTrades(); + } + }); + }); + elTrades.querySelectorAll(".archive-trade-row").forEach(function (row) { + row.addEventListener("click", function (ev) { + if (!isChartOpen()) return; + if (isTradeRowInteractiveTarget(ev.target)) return; + const tr = findTradeByKey(row.getAttribute("data-key")); + if (tr) void switchToTrade(tr); + }); + }); + elTrades.querySelectorAll(".archive-tag-select").forEach(function (sel) { + applyTagSelectStyle(sel); + sel.addEventListener("mousedown", function (ev) { + ev.stopPropagation(); + }); + sel.addEventListener("change", function () { + applyTagSelectStyle(sel); + saveOverlay(sel.getAttribute("data-id"), sel.getAttribute("data-ex"), sel.value, null); + }); + }); + elTrades.querySelectorAll(".archive-note-input").forEach(function (inp) { + inp.addEventListener("mousedown", function (ev) { + ev.stopPropagation(); + }); + inp.addEventListener("click", function (ev) { + ev.stopPropagation(); + }); + inp.addEventListener("change", function () { + const row = inp.closest(".archive-trade-row"); + const tagSel = row && row.querySelector(".archive-tag-select"); + const tr = findTradeByKey(row && row.getAttribute("data-key")); + const tag = + tr && tr.behavior_tag_from_journal + ? "sick" + : tagSel + ? tagSel.value + : ""; + saveOverlay( + inp.getAttribute("data-id"), + inp.getAttribute("data-ex"), + tag, + inp.value + ); + }); + }); + requestAnimationFrame(syncTradesLayout); + } + + async function deleteTrade(tradeId, exchangeKey) { + const exKey = exchangeKey || (selected && selected.exchange_key); + if (!exKey || tradeId == null) return; + if (!window.confirm("从档案移除该笔交易?(不影响交易所实例里的复盘记录)")) return; + const r = await apiFetch("/api/archive/trade/" + exKey + "/" + tradeId, { method: "DELETE" }); + if (!r.ok) { + const j = await r.json().catch(function () { + return {}; + }); + setStatus(j.detail || j.msg || "删除失败"); + return; + } + const deletedKey = String(exchangeKey || "").toLowerCase() + ":" + String(tradeId); + if (selectedTradeKey === deletedKey) selectedTradeKey = null; + await loadDailyTrades(); + setStatus("已移除 1 笔档案记录"); + } + + async function saveOverlay(tradeId, exchangeKey, tag, note) { + const exKey = exchangeKey || (selected && selected.exchange_key); + if (!exKey) return; + const tr = dailyTrades.find(function (t) { + return ( + String(t.trade_id || t.id) === String(tradeId) && + String(t.exchange_key || "").toLowerCase() === String(exKey).toLowerCase() + ); + }); + if (tr && tr.behavior_tag_from_journal && tag != null && String(tag) !== "sick") { + return; + } + const body = { + behavior_tag: tr && tr.behavior_tag_from_journal ? "sick" : tag || "", + note: note != null ? note : undefined, + }; + if (note == null) { + const row = elTrades.querySelector( + '.archive-trade-row[data-id="' + tradeId + '"][data-ex="' + exKey + '"]' + ); + const inp = row && row.querySelector(".archive-note-input"); + body.note = inp ? inp.value : ""; + } + await apiFetch("/api/archive/trade/" + exKey + "/" + tradeId, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (tr) { + tr.behavior_tag = body.behavior_tag; + tr.note = body.note; + } + renderTrades(); + } + + async function loadDailyTrades() { + setStatus("加载交易记录…"); + const r = await apiFetch("/api/archive/daily-trades?" + queryDailyParams()); + const j = await r.json(); + if (!r.ok) { + setStatus(j.detail || "加载失败"); + return; + } + periodMode = j.period || periodMode || "today"; + periodLabel = j.period_label || periodLabel || ""; + dateFrom = j.date_from || dateFrom || ""; + dateTo = j.date_to || dateTo || ""; + tradingDay = j.trading_day || tradingDay; + if (elTradingDay && tradingDay) elTradingDay.value = tradingDay; + if (elDateFrom && dateFrom) elDateFrom.value = dateFrom; + if (elDateTo && dateTo) elDateTo.value = dateTo; + if (elQuoteDate && tradingDay && !elQuoteDate.value) elQuoteDate.value = tradingDay; + syncPeriodUI(); + dailyTrades = j.trades || []; + tradesPage = 0; + dailyStats = j.stats || { open_count: 0, by_exchange: {} }; + if (periodMode === "today" && tradingDay) { + selectedCalendarDay = tradingDay; + if (calendarWidget) calendarWidget.selectedDay = tradingDay; + } + renderStats(); + renderTrades(); + renderCalendarDayTrades(); + void loadCalendar(); + if (archiveContentTab === "quotes") void loadQuoteDayTrades(); + setStatus( + (periodLabel || tradingDay || "当日") + + " · 列表 " + + dailyTrades.length + + " 笔 · " + + new Date().toLocaleTimeString() + ); + } + + async function loadMeta() { + const r = await apiFetch("/api/archive/meta"); + meta = await r.json(); + timeframe = (meta && meta.default_timeframe) || "15m"; + if (meta && meta.last_sync && elStatus && !elStatus.textContent) { + setStatus(formatSyncSummary(meta.last_sync)); + } + renderExchangeOptions(); + if (elTfTabs) { + elTfTabs.querySelectorAll(".archive-tf-btn").forEach(function (btn) { + btn.classList.toggle("is-active", btn.getAttribute("data-tf") === timeframe); + }); + } + } + + function formatSyncSummary(j) { + const results = j.results || []; + const okN = results.filter(function (x) { + return x.ok !== false; + }).length; + const parts = ["同步完成 · " + okN + "/" + (j.exchanges || 0) + " 所"]; + results.forEach(function (row) { + const label = row.exchange_key || row.name || "?"; + if (row.ok === false) parts.push(label + " 失败: " + (row.msg || "未知错误")); + else { + let line = label + " " + (row.trade_count != null ? row.trade_count : row.trades || 0) + " 笔"; + if (row.trades_removed > 0) line += " 清" + row.trades_removed; + parts.push(line); + } + }); + return parts.join(" · "); + } + + async function syncAll() { + setStatus("同步中(可能需数分钟)…"); + if (elBtnSync) elBtnSync.disabled = true; + try { + const r = await apiFetch("/api/archive/sync", { method: "POST" }); + const j = await r.json(); + setStatus(formatSyncSummary(j)); + await loadDailyTrades(); + await loadCalendar(); + await loadQuotes(); + if (isChartOpen() && selected) await loadChart(); + } catch (e) { + setStatus(String(e)); + } finally { + if (elBtnSync) elBtnSync.disabled = false; + } + } + + function bindEvents() { + if (elBtnRefresh) elBtnRefresh.addEventListener("click", loadDailyTrades); + if (elBtnSync) elBtnSync.addEventListener("click", syncAll); + if (elTradesPrev) { + elTradesPrev.addEventListener("click", function () { + if (tradesPage <= 0) return; + tradesPage -= 1; + renderTrades(); + }); + } + if (elTradesNext) { + elTradesNext.addEventListener("click", function () { + if (tradesPage + 1 >= tradesPageCount()) return; + tradesPage += 1; + renderTrades(); + }); + } + if (elExchange) { + elExchange.addEventListener("change", function () { + void loadDailyTrades(); + void loadCalendar(); + }); + } + if (elPeriodTabs) { + elPeriodTabs.addEventListener("click", function (ev) { + const btn = ev.target.closest(".archive-period-btn"); + if (!btn) return; + const next = btn.getAttribute("data-period") || "today"; + if (next === periodMode) return; + setPeriodMode(next); + loadDailyTrades(); + }); + } + if (elTradingDay) { + elTradingDay.addEventListener("change", function () { + if (elQuoteDate && elTradingDay.value) elQuoteDate.value = elTradingDay.value; + void loadDailyTrades(); + }); + } + if (elDateFrom) elDateFrom.addEventListener("change", loadDailyTrades); + if (elDateTo) elDateTo.addEventListener("change", loadDailyTrades); + [elFilterProfit, elFilterLoss, elFilterSick].forEach(function (el) { + if (el) el.addEventListener("change", loadDailyTrades); + }); + if (elSearch) { + elSearch.addEventListener("input", function () { + clearTimeout(searchTimer); + searchTimer = setTimeout(loadDailyTrades, 320); + }); + } + if (elBtnChartToggle) { + elBtnChartToggle.addEventListener("click", async function () { + const next = !isChartOpen(); + setChartOpen(next); + if (next) { + await ensureChartSelection(); + void loadChart(); + } + }); + } + if (elBtnChartClose) { + elBtnChartClose.addEventListener("click", function () { + setChartOpen(false); + }); + } + if (elQuoteForm) elQuoteForm.addEventListener("submit", submitQuoteForm); + if (elQuoteDate) { + elQuoteDate.addEventListener("change", function () { + void loadQuoteDayTrades(); + }); + } + if (elContentTabs) { + elContentTabs.addEventListener("click", function (ev) { + const btn = ev.target.closest(".archive-content-tab"); + if (!btn) return; + setArchiveContentTab(btn.getAttribute("data-archive-tab") || "trades"); + }); + } + if (elTfTabs) { + elTfTabs.addEventListener("click", function (ev) { + const btn = ev.target.closest(".archive-tf-btn"); + if (!btn) return; + timeframe = btn.getAttribute("data-tf") || "15m"; + elTfTabs.querySelectorAll(".archive-tf-btn").forEach(function (b) { + b.classList.toggle("is-active", b === btn); + }); + loadChart(); + }); + } + if (elViewMode) elViewMode.addEventListener("change", loadChart); + if (elBtnReloadChart) elBtnReloadChart.addEventListener("click", loadChart); + if (elMarkAuto) { + elMarkAuto.addEventListener("click", function () { + markAuto = !markAuto; + syncMarkAutoBtn(); + saveMarkAutoPref(); + loadChart(); + }); + } + if (elBtnJump) elBtnJump.addEventListener("click", loadChart); + } + + async function init() { + if (!page || page.classList.contains("hidden")) return; + if (!inited) { + loadMarkAutoPref(); + setChartOpen(false); + syncPeriodUI(); + syncTradesLayout(); + bindEvents(); + setArchiveContentTab("trades"); + inited = true; + } + await loadMeta(); + await loadQuotes(); + await loadDailyTrades(); + } + + function destroy() { + destroyChart(); + } + + window.hubArchivePage = { init: init, destroy: destroy }; +})(); diff --git a/manual_trading_hub/static/backup.js b/manual_trading_hub/static/backup.js new file mode 100644 index 0000000..51423ae --- /dev/null +++ b/manual_trading_hub/static/backup.js @@ -0,0 +1,250 @@ +/** + * 系统设置 · 备份与恢复 + */ +(function () { + const page = document.getElementById("page-settings"); + if (!page) return; + + const elAuto = document.getElementById("backup-auto-enabled"); + const elHour = document.getElementById("backup-auto-hour"); + const elRetention = document.getElementById("backup-retention-days"); + const elIncludeEnv = document.getElementById("backup-include-env"); + const elIncludeImages = document.getElementById("backup-include-images"); + const elRoot = document.getElementById("backup-root"); + const elStatus = document.getElementById("backup-status-line"); + const elList = document.getElementById("backup-list"); + const elRun = document.getElementById("backup-run-now"); + const elRestoreFile = document.getElementById("backup-restore-file"); + const elRestoreBtn = document.getElementById("backup-restore-upload-btn"); + + let settingsCache = null; + let statusCache = null; + + function fmtBytes(n) { + const v = Number(n); + if (!Number.isFinite(v) || v < 0) return "—"; + if (v < 1024) return v + " B"; + if (v < 1024 * 1024) return (v / 1024).toFixed(1) + " KB"; + return (v / (1024 * 1024)).toFixed(2) + " MB"; + } + + function setStatus(msg, isErr) { + if (!elStatus) return; + elStatus.textContent = msg || ""; + elStatus.className = "backup-status-line" + (isErr ? " err" : ""); + } + + function collectBackupFromUI() { + return { + auto_enabled: !!(elAuto && elAuto.checked), + auto_hour: Math.max(0, Math.min(23, parseInt(elHour && elHour.value, 10) || 0)), + retention_days: Math.max(1, Math.min(365, parseInt(elRetention && elRetention.value, 10) || 30)), + include_env: !!(elIncludeEnv && elIncludeEnv.checked), + include_exchange_images: !!(elIncludeImages && elIncludeImages.checked), + backup_root: (elRoot && elRoot.value || "").trim(), + }; + } + + function syncBackupUI(data) { + const b = (data && data.backup) || {}; + if (elAuto) elAuto.checked = b.auto_enabled !== false; + if (elHour) elHour.value = b.auto_hour != null ? b.auto_hour : 0; + if (elRetention) elRetention.value = b.retention_days != null ? b.retention_days : 30; + if (elIncludeEnv) elIncludeEnv.checked = b.include_env !== false; + if (elIncludeImages) elIncludeImages.checked = !!b.include_exchange_images; + if (elRoot) elRoot.value = b.backup_root || ""; + } + + function renderBackupList(status) { + if (!elList) return; + const rows = (status && status.backups) || []; + const state = (status && status.state) || {}; + const root = (status && status.backup_root) || ""; + let html = '
        '; + html += '
        目录:' + esc(root) + '
        '; + if (state.last_backup_at) { + html += '
        上次备份:' + esc(state.last_backup_at) + '(' + esc(state.last_trigger || "") + ")
        "; + } + if (state.last_auto_at) { + html += '
        上次自动:' + esc(state.last_auto_at) + "
        "; + } + if (state.last_restore_at) { + html += '
        上次恢复:' + esc(state.last_restore_at) + " ← " + esc(state.last_restore_from || "") + "
        "; + } + html += "
        "; + if (!rows.length) { + html += '

        暂无备份文件

        '; + elList.innerHTML = html; + return; + } + html += ''; + rows.forEach(function (row) { + html += + "'; + }); + html += "
        文件大小时间
        " + + esc(row.name) + + "" + + fmtBytes(row.size) + + "" + + esc(row.modified_at || "") + + '' + + '下载 ' + + '
        "; + elList.innerHTML = html; + elList.querySelectorAll(".backup-restore-local").forEach(function (btn) { + btn.addEventListener("click", function () { + restoreLocal(btn.getAttribute("data-name")); + }); + }); + } + + function esc(s) { + return String(s || "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\"/g, """); + } + + function num(id) { + const el = $(id); + if (!el) return null; + const n = Number(el.value); + return Number.isFinite(n) ? n : null; + } + + function text(id) { + const el = $(id); + if (!el) return ""; + return String(el.value || "").trim(); + } + + function fmt(v, digits) { + if (v == null || v === "") return "—"; + const n = Number(v); + if (!Number.isFinite(n)) return esc(v); + if (digits != null) return n.toFixed(digits); + return String(n); + } + + /** 去掉尾部多余 0,用于乘数/精度展示 */ + function fmtTrim(v, maxDigits) { + if (v == null || v === "") return "—"; + const n = Number(v); + if (!Number.isFinite(n)) return esc(v); + let s = maxDigits != null ? n.toFixed(maxDigits) : String(n); + if (s.includes(".")) s = s.replace(/\.?0+$/, ""); + return s; + } + + function fmtU(v) { + if (v == null || v === "") return "—"; + const n = Number(v); + if (!Number.isFinite(n)) return "—"; + return (n >= 0 ? "+" : "") + n.toFixed(2) + "U"; + } + + function pnlClass(v) { + const n = Number(v); + if (!Number.isFinite(n) || n === 0) return ""; + return n > 0 ? "calc-pnl-profit" : "calc-pnl-loss"; + } + + function decimalsFromMarket(data) { + if (!data || !data.market) return { price: 4, amount: 4 }; + return { + price: Number(data.market.price_decimals), + amount: Number(data.market.amount_decimals), + }; + } + + function fmtMarketInfo(market, err) { + if (err) { + return '' + esc(err) + ""; + } + if (!market) return "—"; + const inst = market.exchange_name ? esc(market.exchange_name) + " · " : ""; + const parts = [ + inst + "" + esc(market.display_symbol || market.base || "") + " 永续", + "合约 " + esc(market.exchange_symbol || ""), + "乘数 " + fmtTrim(market.contract_size, 8), + "价格精度 " + fmtTrim(market.price_tick != null ? market.price_tick : Math.pow(10, -(market.price_decimals || 0))), + "张数精度 " + fmtTrim(Math.pow(10, -(market.amount_decimals || 0))), + ]; + if (market.min_amount != null) { + parts.push("最小张数 " + fmtTrim(market.min_amount, market.amount_decimals)); + } + return parts.join(" · "); + } + + function applyMarketSteps(prefix, market) { + const pxStep = + market && market.price_tick != null && Number(market.price_tick) > 0 + ? String(market.price_tick) + : market && market.price_decimals != null + ? String(Math.pow(10, -Number(market.price_decimals))) + : "any"; + const amtStep = + market && market.amount_decimals != null + ? String(Math.pow(10, -Number(market.amount_decimals))) + : "any"; + page.querySelectorAll("#" + prefix + "-form input[type='number']").forEach(function (el) { + if (el.classList.contains("calc-roll-leg-add") || el.classList.contains("calc-roll-leg-stop")) { + el.step = pxStep; + return; + } + if (el.id === prefix + "-capital" || el.id === prefix + "-risk" || el.id === prefix + "-leverage") { + return; + } + if (el.id === prefix + "-dca-legs" || el.id === prefix + "-legs-done") { + return; + } + el.step = pxStep; + }); + page.querySelectorAll(".calc-roll-leg-add, .calc-roll-leg-stop").forEach(function (el) { + el.step = pxStep; + }); + void amtStep; + } + + async function refreshMarket(prefix) { + const exchangeEl = $(prefix + "-exchange"); + const baseEl = $(prefix + "-base"); + const infoEl = $(prefix + "-market-info"); + if (!exchangeEl || !baseEl || !infoEl) return null; + const exchangeId = exchangeEl.value || (calculatorExchanges[0] && calculatorExchanges[0].id) || "0"; + const base = text(prefix + "-base") || "ETH"; + const cacheKey = exchangeId + ":" + base.toUpperCase(); + infoEl.innerHTML = "加载合约信息…"; + try { + const r = await fetch( + "/api/calculator/market?exchange_id=" + + encodeURIComponent(exchangeId) + + "&base=" + + encodeURIComponent(base), + { credentials: "same-origin" } + ); + const j = await r.json(); + if (!j.ok) { + infoEl.innerHTML = fmtMarketInfo(null, j.msg || "加载失败"); + marketCache[prefix] = null; + return null; + } + marketCache[prefix] = j.data; + marketCache[cacheKey] = j.data; + infoEl.innerHTML = fmtMarketInfo(j.data, null); + applyMarketSteps(prefix, j.data); + return j.data; + } catch (err) { + infoEl.innerHTML = fmtMarketInfo(null, String(err)); + marketCache[prefix] = null; + return null; + } + } + + function fillExchangeSelect(selectEl, selectedId) { + if (!selectEl) return; + selectEl.innerHTML = ""; + if (!calculatorExchanges.length) { + selectEl.innerHTML = ''; + return; + } + calculatorExchanges.forEach(function (ex) { + const opt = document.createElement("option"); + opt.value = String(ex.id); + opt.textContent = ex.name || ex.key || ex.id; + selectEl.appendChild(opt); + }); + const want = selectedId != null ? String(selectedId) : String(calculatorExchanges[0].id); + if ([].some.call(selectEl.options, function (o) { return o.value === want; })) { + selectEl.value = want; + } + } + + async function loadCalculatorExchanges() { + try { + const r = await fetch("/api/calculator/exchanges", { credentials: "same-origin" }); + const j = await r.json(); + calculatorExchanges = (j.ok && j.data) || []; + } catch (_err) { + calculatorExchanges = []; + } + fillExchangeSelect($("calc-trend-exchange")); + fillExchangeSelect($("calc-roll-exchange")); + } + + function fmtRefreshTime() { + const d = new Date(); + const h = String(d.getHours()).padStart(2, "0"); + const m = String(d.getMinutes()).padStart(2, "0"); + const s = String(d.getSeconds()).padStart(2, "0"); + return h + ":" + m + ":" + s; + } + + async function refreshPage() { + const btn = $("calc-btn-refresh"); + const status = $("calc-refresh-status"); + const trendId = $("calc-trend-exchange") && $("calc-trend-exchange").value; + const rollId = $("calc-roll-exchange") && $("calc-roll-exchange").value; + if (btn) btn.disabled = true; + if (status) status.textContent = "刷新中…"; + Object.keys(marketCache).forEach(function (k) { + delete marketCache[k]; + }); + try { + await loadCalculatorExchanges(); + fillExchangeSelect($("calc-trend-exchange"), trendId); + fillExchangeSelect($("calc-roll-exchange"), rollId); + await Promise.all([refreshMarket("calc-trend"), refreshMarket("calc-roll")]); + if (status) status.textContent = "已刷新 " + fmtRefreshTime(); + } catch (err) { + if (status) status.textContent = "刷新失败"; + } finally { + if (btn) btn.disabled = false; + } + } + + function bindMarket(prefix) { + const exchangeEl = $(prefix + "-exchange"); + const baseEl = $(prefix + "-base"); + if (!exchangeEl || !baseEl) return; + const run = function () { + void refreshMarket(prefix); + }; + if (!exchangeEl._calcMarketBound) { + exchangeEl._calcMarketBound = true; + exchangeEl.addEventListener("change", run); + } + if (!baseEl._calcMarketBound) { + baseEl._calcMarketBound = true; + baseEl.addEventListener("change", run); + baseEl.addEventListener("blur", run); + } + run(); + } + + function syncTrendAddLabel() { + const dir = ($("calc-trend-direction") && $("calc-trend-direction").value) || "long"; + const lab = $("calc-trend-add-label"); + if (lab) lab.textContent = dir === "short" ? "补仓下沿价" : "补仓上沿价"; + } + + function renderTrendTable(rows, dec) { + if (!rows || !rows.length) { + return '

        无档位数据

        '; + } + const px = dec.price != null ? dec.price : 4; + const amt = dec.amount != null ? dec.amount : 4; + let html = + '
        ' + + "" + + ""; + rows.forEach(function (r) { + html += + "" + + "" + + "" + + "" + + "" + + '" + + "" + + "" + + ""; + }); + html += "
        档位触发价张数加仓后均价止盈盈利止损金额盈亏比
        " + + esc(r.label) + + "" + + fmt(r.price, px) + + "" + + fmt(r.contracts, amt) + + "" + + fmt(r.avg_entry, px) + + "' + + fmtU(r.profit_u) + + "" + + fmtU(r.risk_u) + + "" + + (r.rr != null ? fmt(r.rr, 2) + ":1" : "—") + + "
        "; + return html; + } + + function renderTrendResult(data) { + const box = $("calc-trend-result"); + if (!box) return; + const dec = decimalsFromMarket(data); + box.classList.remove("hidden"); + box.innerHTML = + '
        ' + + "
        合约" + + esc((data.market && data.market.display_symbol) || "—") + + "
        " + + "
        计划保证金" + + fmt(data.plan_margin_u, 2) + + "U
        " + + "
        止损预算" + + fmt(data.risk_budget_u, 2) + + "U
        " + + "
        总张数" + + fmt(data.target_contracts, dec.amount) + + "
        " + + "
        首仓张数" + + fmt(data.first_contracts, dec.amount) + + "
        " + + '
        首仓止盈盈利' + + fmtU(data.first_profit_u) + + "
        " + + "
        首仓盈亏比" + + (data.first_rr != null ? fmt(data.first_rr, 2) + ":1" : "—") + + "
        " + + "
        " + + renderTrendTable(data.rows, dec); + } + + function renderRollResult(data) { + const box = $("calc-roll-result"); + if (!box) return; + const dec = decimalsFromMarket(data); + const px = dec.price != null ? dec.price : 4; + const amt = dec.amount != null ? dec.amount : 4; + box.classList.remove("hidden"); + let table = + '
        ' + + "" + + ""; + (data.rows || []).forEach(function (r) { + const tag = r.already_done ? ' 已完成' : ""; + table += + "" + + "" + + "" + + "" + + "" + + "" + + "" + + '" + + '" + + "" + + ""; + }); + table += "
        阶段入场/加仓价统一止损本次张数累计张数均价打到止损总亏止盈盈利盈亏比
        " + + esc(r.label) + + tag + + "" + + fmt(r.entry_or_add_price, px) + + "" + + fmt(r.stop_loss, px) + + "" + + fmt(r.add_contracts, amt) + + "" + + fmt(r.total_contracts, amt) + + "" + + fmt(r.avg_entry, px) + + "' + + fmtU(-Math.abs(Number(r.loss_at_sl_u) || 0)) + + "' + + fmtU(r.profit_at_tp_u) + + "" + + (r.rr != null ? fmt(r.rr, 2) + ":1" : "—") + + "
        "; + box.innerHTML = + '
        ' + + "
        合约" + + esc((data.market && data.market.display_symbol) || "—") + + "
        " + + "
        单次风险预算" + + fmt(data.risk_budget_u, 2) + + "U
        " + + "
        首仓张数(自动)" + + fmt(data.first_contracts, amt) + + "
        " + + "
        最终累计张数" + + fmt(data.final_contracts, amt) + + "
        " + + "
        最终均价" + + fmt(data.final_avg_entry, px) + + "
        " + + '
        最终止盈盈利' + + fmtU(data.final_profit_at_tp_u) + + "
        " + + "
        最终盈亏比" + + (data.final_rr != null ? fmt(data.final_rr, 2) + ":1" : "—") + + "
        " + + "
        " + + table; + } + + const MAX_ROLL_LEGS = 3; + let rollLegCount = 0; + + function maxRollLegsAllowed() { + const done = num("calc-roll-legs-done") || 0; + return Math.max(0, MAX_ROLL_LEGS - done); + } + + function syncRollAddBtn() { + const btn = $("calc-roll-add-leg"); + if (!btn) return; + btn.disabled = rollLegCount >= maxRollLegsAllowed(); + } + + function rollLegRowHtml(index) { + const step = (marketCache["calc-roll"] && marketCache["calc-roll"].price_tick) || "any"; + return ( + '
        ' + + '
        滚仓 ' + + index + + "
        " + + '
        ' + + '' + + '' + + "
        " + + '' + + "
        " + ); + } + + function renumberRollLegs() { + const list = $("calc-roll-legs-list"); + if (!list) return; + const rows = list.querySelectorAll(".calc-roll-leg"); + rollLegCount = rows.length; + rows.forEach(function (row, i) { + row.setAttribute("data-leg-index", String(i + 1)); + const title = row.querySelector(".calc-roll-leg-title"); + if (title) title.textContent = "滚仓 " + (i + 1); + }); + syncRollAddBtn(); + } + + function addRollLegRow() { + if (rollLegCount >= maxRollLegsAllowed()) return; + const list = $("calc-roll-legs-list"); + if (!list) return; + list.insertAdjacentHTML("beforeend", rollLegRowHtml(rollLegCount + 1)); + rollLegCount += 1; + syncRollAddBtn(); + } + + function collectRollLegs() { + const legs = []; + document.querySelectorAll(".calc-roll-leg").forEach(function (row) { + const addEl = row.querySelector(".calc-roll-leg-add"); + const stopEl = row.querySelector(".calc-roll-leg-stop"); + const ap = addEl && addEl.value !== "" ? Number(addEl.value) : null; + const sl = stopEl && stopEl.value !== "" ? Number(stopEl.value) : null; + if (ap == null || sl == null || !Number.isFinite(ap) || !Number.isFinite(sl)) return; + legs.push({ add_price: ap, new_stop_loss: sl }); + }); + return legs; + } + + function bindRollLegsUI() { + const addBtn = $("calc-roll-add-leg"); + const list = $("calc-roll-legs-list"); + const doneInput = $("calc-roll-legs-done"); + if (addBtn && !addBtn._bound) { + addBtn._bound = true; + addBtn.addEventListener("click", addRollLegRow); + } + if (list && !list._bound) { + list._bound = true; + list.addEventListener("click", function (e) { + const btn = e.target.closest(".calc-roll-leg-remove"); + if (!btn) return; + const row = btn.closest(".calc-roll-leg"); + if (row) row.remove(); + renumberRollLegs(); + }); + } + if (doneInput && !doneInput._bound) { + doneInput._bound = true; + doneInput.addEventListener("change", function () { + while (rollLegCount > maxRollLegsAllowed()) { + const rows = list && list.querySelectorAll(".calc-roll-leg"); + if (rows && rows.length) rows[rows.length - 1].remove(); + rollLegCount = list ? list.querySelectorAll(".calc-roll-leg").length : 0; + } + syncRollAddBtn(); + }); + } + syncRollAddBtn(); + } + + function showErr(boxId, msg) { + const box = $(boxId); + if (!box) return; + box.classList.remove("hidden"); + box.innerHTML = '

        ' + esc(msg || "计算失败") + "

        "; + } + + async function submitTrend(e) { + e.preventDefault(); + const body = { + direction: ($("calc-trend-direction") && $("calc-trend-direction").value) || "long", + exchange_id: ($("calc-trend-exchange") && $("calc-trend-exchange").value) || "0", + base: text("calc-trend-base") || "ETH", + capital_usdt: num("calc-trend-capital"), + risk_percent: num("calc-trend-risk"), + leverage: num("calc-trend-leverage"), + entry_price: num("calc-trend-entry"), + stop_loss: num("calc-trend-sl"), + add_upper: num("calc-trend-add-upper"), + take_profit: num("calc-trend-tp"), + dca_legs: num("calc-trend-dca-legs") || 5, + }; + try { + const r = await fetch("/api/calculator/trend", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const j = await r.json(); + if (!j.ok) { + showErr("calc-trend-result", j.msg || "计算失败"); + return; + } + renderTrendResult(j.data); + } catch (err) { + showErr("calc-trend-result", String(err)); + } + } + + async function submitRoll(e) { + e.preventDefault(); + const body = { + direction: ($("calc-roll-direction") && $("calc-roll-direction").value) || "long", + exchange_id: ($("calc-roll-exchange") && $("calc-roll-exchange").value) || "0", + base: text("calc-roll-base") || "ETH", + capital_usdt: num("calc-roll-capital"), + risk_percent: num("calc-roll-risk"), + entry_price: num("calc-roll-entry"), + stop_loss: num("calc-roll-sl"), + take_profit: num("calc-roll-tp"), + add_legs: collectRollLegs(), + legs_done: num("calc-roll-legs-done") || 0, + }; + try { + const r = await fetch("/api/calculator/roll", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const j = await r.json(); + if (!j.ok) { + showErr("calc-roll-result", j.msg || "计算失败"); + return; + } + renderRollResult(j.data); + } catch (err) { + showErr("calc-roll-result", String(err)); + } + } + + function applyCalcTab(tab) { + const t = tab === "roll" ? "roll" : "trend"; + const layout = page.querySelector(".calc-layout"); + if (layout) layout.setAttribute("data-calc-tab", t); + page.querySelectorAll(".calc-m-tab").forEach(function (btn) { + const on = (btn.getAttribute("data-calc-tab") || "") === t; + btn.classList.toggle("is-active", on); + btn.setAttribute("aria-selected", on ? "true" : "false"); + }); + try { + sessionStorage.setItem("hub_calc_tab", t); + } catch (e) { + /* ignore */ + } + } + + function bindCalcTabs() { + page.querySelectorAll(".calc-m-tab").forEach(function (btn) { + btn.addEventListener("click", function () { + applyCalcTab(btn.getAttribute("data-calc-tab") || "trend"); + }); + }); + let saved = "trend"; + try { + saved = sessionStorage.getItem("hub_calc_tab") || "trend"; + } catch (e) { + saved = "trend"; + } + applyCalcTab(saved); + } + + async function bindOnce() { + if (inited) return; + inited = true; + await loadCalculatorExchanges(); + const trendForm = $("calc-trend-form"); + const rollForm = $("calc-roll-form"); + const dirSel = $("calc-trend-direction"); + if (trendForm) trendForm.addEventListener("submit", submitTrend); + if (rollForm) rollForm.addEventListener("submit", submitRoll); + if (dirSel) { + dirSel.addEventListener("change", syncTrendAddLabel); + syncTrendAddLabel(); + } + bindRollLegsUI(); + bindMarket("calc-trend"); + bindMarket("calc-roll"); + bindCalcTabs(); + const refreshBtn = $("calc-btn-refresh"); + if (refreshBtn) { + refreshBtn.addEventListener("click", function () { + void refreshPage(); + }); + } + } + + window.hubCalculatorPage = { + init: function () { + if (inited) { + void refreshPage(); + return; + } + void bindOnce(); + }, + refresh: refreshPage, + destroy: function () {}, + }; +})(); diff --git a/manual_trading_hub/static/chart.js b/manual_trading_hub/static/chart.js new file mode 100644 index 0000000..debebd1 --- /dev/null +++ b/manual_trading_hub/static/chart.js @@ -0,0 +1,3576 @@ +/** + * 中控行情区:K 线 + 成交量;Hub 后台轮询 + SSE 直推尾部 K 线;「自动」控制价格轴与视口跟随. + */ +(function () { + const CHART_WATCH_HEARTBEAT_MS = 25000; + const CHART_SSE_FALLBACK_MS = 15000; + const DEFAULT_VISIBLE_BARS = 200; + const CHART_LOAD_LEFT_THRESHOLD = 25; + const CHART_INITIAL_LIMITS = { + "1m": 2000, + "5m": 2000, + "15m": 2000, + "1h": 1000, + "2h": 1000, + "4h": 1000, + "1d": 500, + "1w": 500, + }; + const CHART_CHUNK_LIMITS = { + "1m": 500, + "5m": 500, + "15m": 500, + "1h": 300, + "2h": 300, + "4h": 300, + "1d": 200, + "1w": 150, + }; + const CHART_MEMORY_CAPS = { + "1m": 5000, + "5m": 5000, + "15m": 5000, + "1h": 1000, + "2h": 1000, + "4h": 1000, + "1d": 1000, + "1w": 500, + }; + const RIGHT_OFFSET_BARS = 10; + const CANDLE_SCALE_BOTTOM = 0.26; + const VOLUME_SCALE_TOP = 0.73; + const VOLUME_SCALE_BOTTOM = 0.06; + const PANEL_VOL_H = 0.12; + const PANEL_MACD_H = 0.14; + const PANEL_RSI_H = 0.14; + const SWING_LOOKBACK = 4; + const MAX_DIV_MARKERS = 4; + const TF_MS = { + "1m": 60_000, + "5m": 5 * 60_000, + "15m": 15 * 60_000, + "1h": 60 * 60_000, + "2h": 2 * 60 * 60_000, + "4h": 4 * 60 * 60_000, + "1d": 24 * 60 * 60_000, + "1w": 7 * 24 * 60 * 60_000, + }; + const TF_BY_MINUTES = { + "1": "1m", + "5": "5m", + "15": "15m", + "60": "1h", + "120": "2h", + "240": "4h", + "1440": "1d", + "10080": "1w", + }; + const TF_MINUTE_KEYS = Object.keys(TF_BY_MINUTES).sort(function (a, b) { + return b.length - a.length; + }); + const TF_CN_LABEL = { + "1m": "1分钟", + "5m": "5分钟", + "15m": "15分钟", + "1h": "1小时", + "2h": "2小时", + "4h": "4小时", + "1d": "日线", + "1w": "周线", + }; + const TF_DIGIT_TIMEOUT_MS = 650; + const CHART_TZ_OFFSET_SEC = 8 * 60 * 60; + + function pad2(n) { + return n < 10 ? "0" + n : String(n); + } + + function utcSecToBjDate(utcSec) { + return new Date((Number(utcSec) + CHART_TZ_OFFSET_SEC) * 1000); + } + + function formatChartTimeBj(utcSec, withDate) { + const d = utcSecToBjDate(utcSec); + const h = pad2(d.getUTCHours()); + const mi = pad2(d.getUTCMinutes()); + if (!withDate) return h + ":" + mi; + return ( + d.getUTCFullYear() + + "-" + + pad2(d.getUTCMonth() + 1) + + "-" + + pad2(d.getUTCDate()) + + " " + + h + + ":" + + mi + ); + } + + function chartLocalizationBj() { + return { + locale: "zh-CN", + dateFormat: "yyyy-MM-dd", + timeFormatter: function (time) { + if (typeof time === "number") return formatChartTimeBj(time, true); + if (time && typeof time === "object" && time.year) { + return time.year + "-" + pad2(time.month) + "-" + pad2(time.day); + } + return ""; + }, + tickMarkFormatter: function (time, tickMarkType) { + if (typeof time !== "number") { + if (time && typeof time === "object" && time.year) { + return time.year + "-" + pad2(time.month) + "-" + pad2(time.day); + } + return ""; + } + const d = utcSecToBjDate(time); + if (tickMarkType === 0) return String(d.getUTCFullYear()); + if (tickMarkType === 1) return pad2(d.getUTCMonth() + 1); + if (tickMarkType === 2) return pad2(d.getUTCDate()); + return formatChartTimeBj(time, false); + }, + }; + } + + function buildChartLocalization() { + const loc = chartLocalizationBj(); + loc.priceFormatter = function (p) { + return fmtPrice(p); + }; + return loc; + } + + const chartHost = document.getElementById("market-chart"); + if (!chartHost) return; + + const elDrawToolbar = document.getElementById("market-draw-toolbar"); + const elDrawCanvas = document.getElementById("market-draw-canvas"); + const elChartMain = chartHost.closest(".market-chart-main"); + let drawAttached = false; + + const elExchange = document.getElementById("market-exchange"); + const elSymbol = document.getElementById("market-symbol"); + const elVolRankMeta = document.getElementById("market-vol-rank-meta"); + const elVolRankList = document.getElementById("market-vol-rank-list"); + const elVolRankSheet = document.getElementById("market-vol-rank-sheet"); + const elVolRankAnchor = document.getElementById("market-vol-rank-anchor"); + const elVolRankAnchorFs = document.getElementById("market-vol-rank-anchor-fs"); + let activeScanTab = "top20"; + let scanSheetOpen = false; + const elTf = document.getElementById("market-timeframe"); + const elRefresh = document.getElementById("market-refresh"); + const elStatus = document.getElementById("market-status"); + const elUpdated = document.getElementById("market-updated"); + const elBarCountdown = document.getElementById("market-bar-countdown"); + const elO = document.getElementById("mkt-o"); + const elH = document.getElementById("mkt-h"); + const elL = document.getElementById("mkt-l"); + const elC = document.getElementById("mkt-c"); + const elV = document.getElementById("mkt-v"); + const elAmp = document.getElementById("mkt-amp"); + const elPriceTag = document.getElementById("market-price-tag"); + const elPriceTagValue = document.getElementById("market-price-tag-value"); + const elPriceTagTime = document.getElementById("market-price-tag-time"); + const elExLabel = document.getElementById("mkt-exchange-label"); + const elExBadge = document.getElementById("market-exchange-badge"); + const elSymLabel = document.getElementById("mkt-symbol-label"); + const elTfLabel = document.getElementById("mkt-tf-label"); + const elPriceAuto = document.getElementById("market-price-auto"); + const elPosPanel = document.getElementById("market-pos-panel"); + const elPosSide = document.getElementById("mkt-pos-side"); + const elPosEntry = document.getElementById("mkt-pos-entry"); + const elPosSl = document.getElementById("mkt-pos-sl"); + const elPosTp = document.getElementById("mkt-pos-tp"); + const elPosSize = document.getElementById("mkt-pos-size"); + const elPosPnl = document.getElementById("mkt-pos-pnl"); + const elPosOrders = document.getElementById("market-pos-orders"); + const elPosClear = document.getElementById("market-pos-clear"); + const elChartWrap = document.getElementById("market-chart-wrap"); + const elFsBtn = document.getElementById("market-chart-fullscreen"); + const elFsExit = document.getElementById("market-chart-fs-exit"); + const elIndEma = document.getElementById("market-ind-ema"); + const elIndEma144 = document.getElementById("market-ind-ema144"); + const elIndMacd = document.getElementById("market-ind-macd"); + const elIndRsi = document.getElementById("market-ind-rsi"); + const elPrevCloseLine = document.getElementById("market-prev-close-line"); + const elPrevHlLines = document.getElementById("market-prev-hl-lines"); + const elDaySplit = document.getElementById("market-day-split"); + const PREV_CLOSE_LINE_STORAGE_KEY = "hub-market-prev-close-line"; + const PREV_HL_LINES_STORAGE_KEY = "hub-market-prev-hl-lines"; + const DAY_SPLIT_STORAGE_KEY = "hub-market-day-split"; + const BJ_OFFSET_SEC = 8 * 60 * 60; + const elFsToolbar = document.getElementById("market-fs-toolbar"); + const elFsExchange = document.getElementById("market-fs-exchange"); + const elFsSymbol = document.getElementById("market-fs-symbol"); + const elFsTf = document.getElementById("market-fs-timeframe"); + const elFsLoad = document.getElementById("market-fs-load"); + const elDivLegend = document.getElementById("market-div-legend"); + + const HUB_MARKET_POS_CTX_KEY = "hubMarketPosContext"; + const EMA_FAST = 21; + const EMA_SLOW = 55; + const EMA_TREND = 144; + + let chartFullscreen = false; + const indicatorState = { ema: false, ema144: false, macd: false, rsi: false }; + const indSeries = { + ema21: null, + ema55: null, + ema144: null, + macdLine: null, + macdSignal: null, + macdHist: null, + rsi: null, + rsi30: null, + rsi70: null, + }; + let divergenceMarkers = []; + + let chart = null; + let candleSeries = null; + let volumeSeries = null; + let priceTick = null; + let priceAutoScale = true; + let rangeMarkers = []; + let yesterdayPriceLines = []; + let positionLines = []; + let posContext = null; + let posPnlTimer = null; + const SL_DRAG_HIT_PX = 12; + let slDrag = null; + let currentPriceLine = null; + let lastCandles = []; + let candleByTime = {}; + let chartMeta = null; + let loadToken = 0; + let marketInited = false; + let refreshTimer = null; + let chartWatchTimer = null; + let chartEventSource = null; + let chartSseReconnectTimer = null; + let localChartVersion = 0; + let localSeriesVersion = 0; + let lastViewKey = ""; + let currentTf = "1d"; + let exhaustedLeft = false; + let loadingLeft = false; + let chartDataLoading = false; + let chartViewEpoch = 0; + let rangeUiTimer = null; + let loadOlderTimer = null; + let chartRangeUserLocked = false; + let chartRangeLockTimer = null; + let suppressRangeUserLock = false; + const CHART_TAIL_REFRESH_LIMIT = 30; + let priceTagTimer = null; + let tfDigitBuf = ""; + let tfDigitTimer = null; + let tfHintTimer = null; + + function escHtml(s) { + return String(s || "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function normalizeMarketSymbol(sym) { + const s = String(sym || "").trim().toUpperCase(); + const m = s.match(/^([A-Z0-9]+)\/([A-Z0-9]+)(?::([A-Z0-9]+))?$/); + if (!m) return s; + return m[1] + "/" + m[2]; + } + + function loadPosContextFromStorage() { + try { + const raw = sessionStorage.getItem(HUB_MARKET_POS_CTX_KEY); + if (!raw) return null; + return JSON.parse(raw); + } catch (e) { + return null; + } + } + + function posContextMatches(ctx, exKey, sym) { + if (!ctx) return false; + const ctxSym = normalizeMarketSymbol(ctx.symbol || ""); + const ctxEx = String(ctx.exchange_key || "").trim(); + return ctxSym === normalizeMarketSymbol(sym) && ctxEx === String(exKey || "").trim(); + } + + function clearPosPanel() { + if (elPosPanel) elPosPanel.classList.add("hidden"); + if (elPosSide) { + elPosSide.textContent = ""; + elPosSide.className = "market-pos-side"; + } + ["entry", "sl", "tp", "size"].forEach(function (k) { + const el = { entry: elPosEntry, sl: elPosSl, tp: elPosTp, size: elPosSize }[k]; + if (el) el.textContent = "—"; + }); + if (elPosPnl) { + elPosPnl.textContent = "—"; + elPosPnl.className = "market-pos-pnl"; + } + if (elPosOrders) elPosOrders.innerHTML = ""; + syncChartWrapLayout(); + } + + function loadBoolPref(key, defaultValue) { + try { + const raw = localStorage.getItem(key); + if (raw === "1" || raw === "true") return true; + if (raw === "0" || raw === "false") return false; + } catch (_) {} + return !!defaultValue; + } + + function saveBoolPref(key, on) { + try { + localStorage.setItem(key, on ? "1" : "0"); + } catch (_) {} + } + + function loadDaySplitPref() { + return loadBoolPref(DAY_SPLIT_STORAGE_KEY, false); + } + + function saveDaySplitPref(on) { + saveBoolPref(DAY_SPLIT_STORAGE_KEY, on); + } + + function loadPrevCloseLinePref() { + return loadBoolPref(PREV_CLOSE_LINE_STORAGE_KEY, false); + } + + function savePrevCloseLinePref(on) { + saveBoolPref(PREV_CLOSE_LINE_STORAGE_KEY, on); + } + + function loadPrevHlLinesPref() { + return loadBoolPref(PREV_HL_LINES_STORAGE_KEY, false); + } + + function savePrevHlLinesPref(on) { + saveBoolPref(PREV_HL_LINES_STORAGE_KEY, on); + } + + function chartResetHour() { + return chartMeta && chartMeta.volume_rank_reset_hour != null + ? Number(chartMeta.volume_rank_reset_hour) + : 8; + } + + function utcSecToBjParts(utcSec) { + const d = new Date((Number(utcSec) + BJ_OFFSET_SEC) * 1000); + return { + y: d.getUTCFullYear(), + m: d.getUTCMonth(), + d: d.getUTCDate(), + h: d.getUTCHours(), + }; + } + + function tradingDayKeyFromUtcSec(utcSec, resetHour) { + const p = utcSecToBjParts(utcSec); + let y = p.y; + let m = p.m; + let d = p.d; + if (p.h < resetHour) { + const prev = new Date(Date.UTC(y, m, d) - 86400000); + y = prev.getUTCFullYear(); + m = prev.getUTCMonth(); + d = prev.getUTCDate(); + } + return ( + y + + "-" + + String(m + 1).padStart(2, "0") + + "-" + + String(d).padStart(2, "0") + ); + } + + function prevTradingDayKey(tdKey) { + const parts = String(tdKey || "").split("-"); + if (parts.length !== 3) return ""; + const dt = new Date(Date.UTC(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]))); + const prev = new Date(dt.getTime() - 86400000); + return ( + prev.getUTCFullYear() + + "-" + + String(prev.getUTCMonth() + 1).padStart(2, "0") + + "-" + + String(prev.getUTCDate()).padStart(2, "0") + ); + } + + function computePrevTradingDayOhlc(candles, resetHour) { + if (!candles || !candles.length) return null; + const curTd = tradingDayKeyFromUtcSec(candles[candles.length - 1].time, resetHour); + const prevTd = prevTradingDayKey(curTd); + if (!prevTd) return null; + const dayCandles = candles + .filter(function (c) { + return c && tradingDayKeyFromUtcSec(c.time, resetHour) === prevTd; + }) + .sort(function (a, b) { + return a.time - b.time; + }); + if (!dayCandles.length) return null; + let hi = null; + let lo = null; + dayCandles.forEach(function (c) { + if (!hi || c.high > hi) hi = c.high; + if (!lo || c.low < lo) lo = c.low; + }); + const last = dayCandles[dayCandles.length - 1]; + return { + close: last.close, + high: hi, + low: lo, + tradingDay: prevTd, + }; + } + + function syncPrevDayLineUi() { + const closeOn = !!(elPrevCloseLine && elPrevCloseLine.checked); + const hlOn = !!(elPrevHlLines && elPrevHlLines.checked); + savePrevCloseLinePref(closeOn); + savePrevHlLinesPref(hlOn); + updateYesterdayPriceLines(); + } + + function applyTradingDaySplit(enabled) { + if (window.HubChartDraw && typeof window.HubChartDraw.setTradingDaySplit === "function") { + window.HubChartDraw.setTradingDaySplit(enabled); + } + } + + function syncTradingDaySplitUi() { + const on = !!(elDaySplit && elDaySplit.checked); + saveDaySplitPref(on); + applyTradingDaySplit(on); + } + + function ensureDrawLayer() { + if (drawAttached || !window.HubChartDraw || !chart || !candleSeries) return; + window.HubChartDraw.attach({ + chart: chart, + series: candleSeries, + hostEl: chartHost, + mainEl: elChartMain, + canvasEl: elDrawCanvas, + toolbarEl: elDrawToolbar, + getCandles: function () { + return lastCandles; + }, + }); + window.HubChartDraw.setViewKey(currentChartViewKey()); + applyTradingDaySplit(elDaySplit ? elDaySplit.checked : loadDaySplitPref()); + drawAttached = true; + } + + function syncDrawViewKey() { + if (window.HubChartDraw && drawAttached) { + window.HubChartDraw.setViewKey(currentChartViewKey()); + } + } + + function resizeChart() { + if (!chart || !chartHost) return; + chart.applyOptions({ width: chartHost.clientWidth, height: chartHost.clientHeight }); + updatePriceTag(); + if (window.HubChartDraw && drawAttached) { + window.HubChartDraw.resize(); + } + } + + let resizeChartRaf = 0; + function scheduleChartResize() { + if (resizeChartRaf) cancelAnimationFrame(resizeChartRaf); + resizeChartRaf = requestAnimationFrame(function () { + resizeChartRaf = 0; + syncChartWrapLayout(); + }); + } + + function syncChartWrapLayout() { + const wrap = elChartWrap || (chartHost && chartHost.closest(".market-chart-wrap")); + if (wrap && elPosPanel && !chartFullscreen) { + wrap.classList.toggle("has-pos-panel", !elPosPanel.classList.contains("hidden")); + } + resizeChart(); + } + + function readIndicatorState() { + indicatorState.ema = !!(elIndEma && elIndEma.checked); + indicatorState.ema144 = !!(elIndEma144 && elIndEma144.checked); + indicatorState.macd = !!(elIndMacd && elIndMacd.checked); + indicatorState.rsi = !!(elIndRsi && elIndRsi.checked); + } + + function emaArray(values, period) { + const result = new Array(values.length).fill(null); + const k = 2 / (period + 1); + let ema = null; + for (let i = 0; i < values.length; i++) { + const v = values[i]; + if (v == null || !Number.isFinite(v)) continue; + if (ema == null) { + if (i < period - 1) continue; + let sum = 0; + let ok = true; + for (let j = i - period + 1; j <= i; j++) { + const x = values[j]; + if (x == null || !Number.isFinite(x)) { + ok = false; + break; + } + sum += x; + } + if (!ok) continue; + ema = sum / period; + } else { + ema = v * k + ema * (1 - k); + } + result[i] = ema; + } + return result; + } + + function buildEmaSeries(candles, period) { + const closes = candles.map(function (c) { + return Number(c.close); + }); + const vals = emaArray(closes, period); + const out = []; + for (let i = 0; i < candles.length; i++) { + if (vals[i] == null) continue; + out.push({ time: candles[i].time, value: vals[i] }); + } + return out; + } + + function buildMacdData(candles) { + const closes = candles.map(function (c) { + return Number(c.close); + }); + const ema12 = emaArray(closes, 12); + const ema26 = emaArray(closes, 26); + const macd = new Array(closes.length).fill(null); + for (let i = 0; i < closes.length; i++) { + if (ema12[i] == null || ema26[i] == null) continue; + macd[i] = ema12[i] - ema26[i]; + } + const signal = emaArray(macd, 9); + const macdLine = []; + const signalLine = []; + const histData = []; + for (let i = 0; i < candles.length; i++) { + const t = candles[i].time; + if (macd[i] != null) macdLine.push({ time: t, value: macd[i] }); + if (signal[i] != null) signalLine.push({ time: t, value: signal[i] }); + if (macd[i] != null && signal[i] != null) { + const h = macd[i] - signal[i]; + histData.push({ + time: t, + value: h, + color: h >= 0 ? "rgba(0, 255, 157, 0.55)" : "rgba(255, 77, 109, 0.55)", + }); + } + } + return { macdLine, signalLine, histData }; + } + + function buildRsiSeries(candles, period) { + const out = []; + if (!candles || candles.length < period + 1) return out; + let avgGain = 0; + let avgLoss = 0; + for (let i = 1; i <= period; i++) { + const ch = Number(candles[i].close) - Number(candles[i - 1].close); + if (ch >= 0) avgGain += ch; + else avgLoss -= ch; + } + avgGain /= period; + avgLoss /= period; + let rsi = 50; + if (avgLoss <= 0) rsi = 100; + else if (avgGain <= 0) rsi = 0; + else rsi = 100 - 100 / (1 + avgGain / avgLoss); + out.push({ time: candles[period].time, value: rsi }); + + for (let i = period + 1; i < candles.length; i++) { + const ch = Number(candles[i].close) - Number(candles[i - 1].close); + const gain = ch > 0 ? ch : 0; + const loss = ch < 0 ? -ch : 0; + avgGain = (avgGain * (period - 1) + gain) / period; + avgLoss = (avgLoss * (period - 1) + loss) / period; + if (avgLoss <= 0) rsi = 100; + else if (avgGain <= 0) rsi = 0; + else rsi = 100 - 100 / (1 + avgGain / avgLoss); + out.push({ time: candles[i].time, value: rsi }); + } + return out; + } + + function createLineSeries(opts) { + if (!chart) return null; + const base = { + lineWidth: 1, + priceLineVisible: false, + lastValueVisible: false, + }; + const o = Object.assign(base, opts || {}); + if (typeof chart.addLineSeries === "function") return chart.addLineSeries(o); + if ( + typeof chart.addSeries === "function" && + window.LightweightCharts && + window.LightweightCharts.LineSeries + ) { + return chart.addSeries(window.LightweightCharts.LineSeries, o); + } + return null; + } + + function createHistSeries(opts) { + if (!chart) return null; + const base = { priceLineVisible: false, lastValueVisible: false }; + const o = Object.assign(base, opts || {}); + if (typeof chart.addHistogramSeries === "function") return chart.addHistogramSeries(o); + if ( + typeof chart.addSeries === "function" && + window.LightweightCharts && + window.LightweightCharts.HistogramSeries + ) { + return chart.addSeries(window.LightweightCharts.HistogramSeries, o); + } + return null; + } + + function clearIndicatorSeries() { + if (!chart) return; + [indSeries.rsi30, indSeries.rsi70].forEach(function (pl) { + if (pl && indSeries.rsi) { + try { + indSeries.rsi.removePriceLine(pl); + } catch (e) {} + } + }); + indSeries.rsi30 = null; + indSeries.rsi70 = null; + Object.keys(indSeries).forEach(function (k) { + if (k === "rsi30" || k === "rsi70") return; + if (indSeries[k]) { + try { + chart.removeSeries(indSeries[k]); + } catch (e) {} + indSeries[k] = null; + } + }); + } + + function findSwings(values, lookback) { + const lows = []; + const highs = []; + const lb = lookback || SWING_LOOKBACK; + for (let i = lb; i < values.length - lb; i++) { + const v = values[i]; + if (v == null || !Number.isFinite(v)) continue; + let isLow = true; + let isHigh = true; + for (let j = 1; j <= lb; j++) { + const lv = values[i - j]; + const rv = values[i + j]; + if (lv == null || rv == null || v > lv || v > rv) isLow = false; + if (lv == null || rv == null || v < lv || v < rv) isHigh = false; + } + if (isLow) lows.push({ i: i, v: v }); + if (isHigh) highs.push({ i: i, v: v }); + } + return { lows, highs }; + } + + function detectDivergences(candles, indicatorByIndex, sourceLabel) { + const markers = []; + if (!candles.length || !indicatorByIndex.length) return markers; + + const closes = candles.map(function (c) { + return Number(c.close); + }); + const priceSw = findSwings(closes, SWING_LOOKBACK); + const indSw = findSwings(indicatorByIndex, SWING_LOOKBACK); + + function pushMarker(idx, kind, label) { + const c = candles[idx]; + if (!c || c.time == null) return; + const bull = kind === "bull"; + markers.push({ + time: c.time, + position: bull ? "belowBar" : "aboveBar", + color: bull ? "#00ff9d" : "#ff4d6d", + shape: bull ? "arrowUp" : "arrowDown", + text: label, + }); + } + + const pLows = priceSw.lows; + const iLows = indSw.lows; + if (pLows.length >= 2 && iLows.length >= 2) { + const p1 = pLows[pLows.length - 2]; + const p2 = pLows[pLows.length - 1]; + const i1 = iLows[iLows.length - 2]; + const i2 = iLows[iLows.length - 1]; + if (Math.abs(p1.i - i1.i) < 30 && Math.abs(p2.i - i2.i) < 30) { + if (p2.v < p1.v && i2.v > i1.v) { + pushMarker(p2.i, "bull", sourceLabel + "底背离"); + } + } + } + + const pHighs = priceSw.highs; + const iHighs = indSw.highs; + if (pHighs.length >= 2 && iHighs.length >= 2) { + const p1 = pHighs[pHighs.length - 2]; + const p2 = pHighs[pHighs.length - 1]; + const i1 = iHighs[iHighs.length - 2]; + const i2 = iHighs[iHighs.length - 1]; + if (Math.abs(p1.i - i1.i) < 30 && Math.abs(p2.i - i2.i) < 30) { + if (p2.v > p1.v && i2.v < i1.v) { + pushMarker(p2.i, "bear", sourceLabel + "顶背离"); + } + } + } + + return markers.slice(-MAX_DIV_MARKERS); + } + + function buildRsiByIndex(candles, period) { + const series = buildRsiSeries(candles, period); + const byIdx = new Array(candles.length).fill(null); + let si = 0; + for (let i = 0; i < candles.length; i++) { + if (si < series.length && series[si].time === candles[i].time) { + byIdx[i] = series[si].value; + si++; + } + } + return { series, byIdx }; + } + + function buildMacdByIndex(candles) { + const closes = candles.map(function (c) { + return Number(c.close); + }); + const ema12 = emaArray(closes, 12); + const ema26 = emaArray(closes, 26); + const macd = new Array(closes.length).fill(null); + for (let i = 0; i < closes.length; i++) { + if (ema12[i] == null || ema26[i] == null) continue; + macd[i] = ema12[i] - ema26[i]; + } + return macd; + } + + function panelLayout() { + const rsiOn = indicatorState.rsi; + const macdOn = indicatorState.macd; + if (!rsiOn && !macdOn) { + return { + candle: { top: 0.06, bottom: CANDLE_SCALE_BOTTOM }, + volume: { top: VOLUME_SCALE_TOP, bottom: VOLUME_SCALE_BOTTOM }, + macd: null, + rsi: null, + }; + } + + const gap = 0.02; + let stackBottom = gap; + let rsiMargins = null; + let macdMargins = null; + + if (rsiOn) { + rsiMargins = { + top: 1 - stackBottom - PANEL_RSI_H, + bottom: stackBottom, + }; + stackBottom += PANEL_RSI_H; + } + if (macdOn) { + macdMargins = { + top: 1 - stackBottom - PANEL_MACD_H, + bottom: stackBottom, + }; + stackBottom += PANEL_MACD_H; + } + + const volBottom = stackBottom; + const volTop = 1 - volBottom - PANEL_VOL_H; + const candleBottom = Math.max(CANDLE_SCALE_BOTTOM, 1 - volTop + 0.01); + + return { + candle: { top: 0.06, bottom: candleBottom }, + volume: { top: volTop, bottom: volBottom }, + macd: macdMargins, + rsi: rsiMargins, + }; + } + + function applyScaleLayout() { + if (!chart) return; + const L = panelLayout(); + chart.priceScale("right").applyOptions({ + scaleMargins: L.candle, + }); + if (volumeSeries && volumeSeries.priceScale) { + volumeSeries.priceScale().applyOptions({ + scaleMargins: L.volume, + borderColor: "#2a4058", + }); + } + if (indSeries.macdLine && indSeries.macdLine.priceScale) { + indSeries.macdLine.priceScale().applyOptions({ + scaleMargins: L.macd, + borderColor: "#2a4058", + autoScale: true, + }); + } + if (indSeries.rsi && indSeries.rsi.priceScale) { + indSeries.rsi.priceScale().applyOptions({ + scaleMargins: L.rsi, + borderColor: "#2a4058", + autoScale: true, + }); + } + } + + function updateDivergenceLegend(rsiDiv, macdDiv) { + if (!elDivLegend) return; + const parts = []; + if (indicatorState.rsi && rsiDiv.length) { + parts.push("RSI " + rsiDiv.map(function (m) { return m.text; }).join(" · ")); + } + if (indicatorState.macd && macdDiv.length) { + parts.push("MACD " + macdDiv.map(function (m) { return m.text; }).join(" · ")); + } + if (!parts.length) { + elDivLegend.textContent = ""; + elDivLegend.classList.add("hidden"); + return; + } + elDivLegend.textContent = parts.join(" | "); + elDivLegend.classList.remove("hidden"); + } + + function applyCandleDivergenceMarkers() { + if (!candleSeries || !candleSeries.setMarkers) return; + const sorted = divergenceMarkers + .slice() + .sort(function (a, b) { + return a.time > b.time ? 1 : a.time < b.time ? -1 : 0; + }); + candleSeries.setMarkers(sorted); + } + + function updateIndicators() { + if (!chart || !lastCandles.length) return; + readIndicatorState(); + clearIndicatorSeries(); + divergenceMarkers = []; + + if (indicatorState.ema) { + const pf = tickToPriceFormat(priceTick); + indSeries.ema21 = createLineSeries({ + color: "#f0c040", + title: "EMA21", + priceScaleId: "right", + priceFormat: pf, + }); + indSeries.ema55 = createLineSeries({ + color: "#c878ff", + title: "EMA55", + priceScaleId: "right", + priceFormat: pf, + }); + if (indSeries.ema21) indSeries.ema21.setData(buildEmaSeries(lastCandles, EMA_FAST)); + if (indSeries.ema55) indSeries.ema55.setData(buildEmaSeries(lastCandles, EMA_SLOW)); + } + + if (indicatorState.ema144) { + const pf144 = tickToPriceFormat(priceTick); + indSeries.ema144 = createLineSeries({ + color: "#5ce0b8", + title: "EMA144", + priceScaleId: "right", + priceFormat: pf144, + }); + if (indSeries.ema144) indSeries.ema144.setData(buildEmaSeries(lastCandles, EMA_TREND)); + } + + let rsiDiv = []; + let macdDiv = []; + + if (indicatorState.macd) { + const macd = buildMacdData(lastCandles); + const macdByIdx = buildMacdByIndex(lastCandles); + indSeries.macdLine = createLineSeries({ + color: "#5b9cf5", + title: "MACD", + priceScaleId: "macd", + priceLineVisible: false, + lastValueVisible: false, + }); + indSeries.macdSignal = createLineSeries({ + color: "#ffb84d", + title: "Signal", + priceScaleId: "macd", + priceLineVisible: false, + lastValueVisible: false, + }); + indSeries.macdHist = createHistSeries({ + priceScaleId: "macd", + priceLineVisible: false, + lastValueVisible: false, + }); + if (indSeries.macdLine) indSeries.macdLine.setData(macd.macdLine); + if (indSeries.macdSignal) indSeries.macdSignal.setData(macd.signalLine); + if (indSeries.macdHist) indSeries.macdHist.setData(macd.histData); + macdDiv = detectDivergences(lastCandles, macdByIdx, "MACD"); + divergenceMarkers = divergenceMarkers.concat(macdDiv); + } + + if (indicatorState.rsi) { + const rsiPack = buildRsiByIndex(lastCandles, 14); + indSeries.rsi = createLineSeries({ + color: "#8fc8ff", + title: "RSI(14)", + priceScaleId: "rsi", + priceFormat: { type: "price", precision: 1, minMove: 0.1 }, + priceLineVisible: false, + lastValueVisible: true, + }); + if (indSeries.rsi) { + indSeries.rsi.setData(rsiPack.series); + try { + indSeries.rsi30 = indSeries.rsi.createPriceLine({ + price: 30, + color: "rgba(255, 77, 109, 0.75)", + lineWidth: 1, + lineStyle: 2, + axisLabelVisible: true, + title: "30", + }); + indSeries.rsi70 = indSeries.rsi.createPriceLine({ + price: 70, + color: "rgba(0, 255, 157, 0.75)", + lineWidth: 1, + lineStyle: 2, + axisLabelVisible: true, + title: "70", + }); + } catch (e) {} + } + rsiDiv = detectDivergences(lastCandles, rsiPack.byIdx, "RSI"); + divergenceMarkers = divergenceMarkers.concat(rsiDiv); + } + + updateDivergenceLegend(rsiDiv, macdDiv); + applyCandleDivergenceMarkers(); + applyScaleLayout(); + scheduleChartResize(); + } + + function syncFsToolbarFromMain() { + if (!chartFullscreen) return; + if (elFsExchange && elExchange) elFsExchange.value = elExchange.value; + if (elFsSymbol && elSymbol) elFsSymbol.value = elSymbol.value; + if (elFsTf && elTf) elFsTf.value = elTf.value; + } + + function syncMainFromFsToolbar() { + if (elExchange && elFsExchange) elExchange.value = elFsExchange.value; + if (elSymbol && elFsSymbol) elSymbol.value = elFsSymbol.value.trim().toUpperCase(); + if (elTf && elFsTf) elTf.value = elFsTf.value; + updateExchangeDisplay(); + updateHeaderLabels(elSymbol && elSymbol.value, elTf && elTf.value); + } + + function isMarketPageActive() { + const page = document.getElementById("page-market"); + return !!(page && !page.classList.contains("hidden")); + } + + function isTypingInField(target) { + if (!target) return false; + const tag = (target.tagName || "").toLowerCase(); + if (tag === "input" || tag === "textarea" || tag === "select") return true; + return !!target.isContentEditable; + } + + function canUseTfKeyboard(e) { + if (!isMarketPageActive()) return false; + if (e.altKey || e.ctrlKey || e.metaKey) return false; + if (isTypingInField(e.target)) return false; + return true; + } + + function canExtendTfDigitBuffer(buf) { + if (!buf) return false; + return TF_MINUTE_KEYS.some(function (k) { + return k.length > buf.length && k.indexOf(buf) === 0; + }); + } + + function shouldCommitTfBufferNow(buf) { + const tf = resolveTfFromDigitBuffer(buf); + if (!tf) return false; + return !canExtendTfDigitBuffer(buf); + } + + function resolveTfFromDigitBuffer(buf) { + if (!buf) return null; + return TF_BY_MINUTES[buf] || null; + } + + function flashTfSwitchHint(tf) { + const label = TF_CN_LABEL[tf] || tf; + const text = "周期 → " + label + "(" + tf + ")"; + if (elTfLabel) elTfLabel.textContent = tf; + if (elBarCountdown) { + if (tfHintTimer) clearTimeout(tfHintTimer); + elBarCountdown.textContent = text; + elBarCountdown.classList.add("market-tf-key-hint"); + tfHintTimer = setTimeout(function () { + tfHintTimer = null; + elBarCountdown.classList.remove("market-tf-key-hint"); + tickLiveClock(); + }, 1200); + return; + } + if (elStatus) { + if (tfHintTimer) clearTimeout(tfHintTimer); + const prevClass = elStatus.className; + const prevText = elStatus.textContent; + elStatus.className = "market-status"; + elStatus.textContent = text; + tfHintTimer = setTimeout(function () { + tfHintTimer = null; + elStatus.className = prevClass; + elStatus.textContent = prevText; + }, 1200); + } + } + + function applyTimeframe(tf, fromKeyboard) { + if (!tf || !TF_MS[tf]) return false; + const cur = (elTf && elTf.value) || currentTf; + if (cur === tf) return false; + if (elTf) elTf.value = tf; + if (elFsTf) elFsTf.value = tf; + currentTf = tf; + lastViewKey = ""; + tickLiveClock(); + updateHeaderLabels( + elSymbol && elSymbol.value.trim().toUpperCase(), + tf + ); + syncFsToolbarFromMain(); + if (fromKeyboard) flashTfSwitchHint(tf); + loadChart(false); + return true; + } + + function commitTfDigitBuffer() { + const buf = tfDigitBuf; + tfDigitBuf = ""; + if (tfDigitTimer) { + clearTimeout(tfDigitTimer); + tfDigitTimer = null; + } + const tf = resolveTfFromDigitBuffer(buf); + if (tf) applyTimeframe(tf, true); + } + + function handleTfDigitKey(digit) { + if (!digit) return; + if (tfDigitBuf && !canExtendTfDigitBuffer(tfDigitBuf)) { + tfDigitBuf = ""; + } + tfDigitBuf += digit; + if (shouldCommitTfBufferNow(tfDigitBuf)) { + commitTfDigitBuffer(); + return; + } + if (!canExtendTfDigitBuffer(tfDigitBuf)) { + tfDigitBuf = digit; + if (shouldCommitTfBufferNow(tfDigitBuf)) { + commitTfDigitBuffer(); + return; + } + } + if (tfDigitTimer) clearTimeout(tfDigitTimer); + tfDigitTimer = setTimeout(commitTfDigitBuffer, TF_DIGIT_TIMEOUT_MS); + } + + function isChartFullscreenKey(e) { + if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return false; + return e.code === "KeyF" || e.key === "f" || e.key === "F"; + } + + function onChartFullscreenKey(e) { + if (!isMarketPageActive() || !isChartFullscreenKey(e)) return; + if (isTypingInField(e.target)) return; + e.preventDefault(); + e.stopImmediatePropagation(); + toggleChartFullscreen(); + } + + function focusMarketChartArea() { + const wrap = elChartWrap; + if (!wrap) return; + if (!wrap.hasAttribute("tabindex")) wrap.setAttribute("tabindex", "-1"); + try { + wrap.focus({ preventScroll: true }); + } catch (err) { + /* ignore */ + } + } + + function onMarketKeydown(e) { + if (!isMarketPageActive()) return; + + if (e.key === "Escape" && chartFullscreen) { + e.preventDefault(); + e.stopPropagation(); + setChartFullscreen(false); + return; + } + + if (!canUseTfKeyboard(e)) return; + if (e.key >= "0" && e.key <= "9") { + e.preventDefault(); + handleTfDigitKey(e.key); + return; + } + if (e.key === "Enter" && tfDigitBuf) { + e.preventDefault(); + commitTfDigitBuffer(); + } + } + + function populateFsExchangeOptions() { + if (!elFsExchange || !elExchange) return; + elFsExchange.innerHTML = elExchange.innerHTML; + elFsExchange.value = elExchange.value; + } + + function unlockMarketFsOrientation() { + try { + if (screen.orientation && typeof screen.orientation.unlock === "function") { + screen.orientation.unlock(); + } + } catch (e) { + /* ignore */ + } + document.body.classList.remove("market-fs-want-landscape"); + } + + function lockMarketFsLandscape() { + document.body.classList.add("market-fs-want-landscape"); + const orient = screen.orientation; + if (!orient || typeof orient.lock !== "function") return; + Promise.resolve(orient.lock("landscape")) + .catch(function () { + return orient.lock("landscape-primary"); + }) + .catch(function () { + /* 浏览器可能拒绝;CSS 会提示转横屏 */ + }); + } + + function setChartFullscreen(on) { + chartFullscreen = !!on; + const wrap = elChartWrap || (chartHost && chartHost.closest(".market-chart-wrap")); + if (wrap) wrap.classList.toggle("is-fullscreen", chartFullscreen); + document.body.classList.toggle("market-chart-fs-open", chartFullscreen); + if (elFsToolbar) elFsToolbar.classList.toggle("hidden", !chartFullscreen); + if (elFsBtn) elFsBtn.textContent = chartFullscreen ? "退出全屏" : "全屏"; + if (elFsExit) { + if (chartFullscreen) elFsExit.classList.remove("hidden"); + else elFsExit.classList.add("hidden"); + } + mountVolRankSheet(chartFullscreen); + if (chartFullscreen) { + populateFsExchangeOptions(); + syncFsToolbarFromMain(); + // 手机全屏优先横屏看图;电脑/平板不强制 + if (window.matchMedia && window.matchMedia("(max-width: 720px)").matches) { + lockMarketFsLandscape(); + } + } else { + unlockMarketFsOrientation(); + } + scheduleChartResize(); + } + + function toggleChartFullscreen() { + setChartFullscreen(!chartFullscreen); + } + + function showHubToast(msg, isErr) { + const t = document.getElementById("toast"); + if (!t) return; + t.textContent = msg; + t.classList.toggle("err", !!isErr); + t.classList.add("show"); + clearTimeout(showHubToast._hideTimer); + showHubToast._hideTimer = setTimeout(function () { + t.classList.remove("show"); + }, 3500); + } + + function estimateLinearSwapUpnl(side, entry, mark, contracts, contractSize) { + const e = Number(entry); + const m = Number(mark); + const c = Math.abs(Number(contracts)); + let mult = Number(contractSize); + if (!Number.isFinite(mult) || mult <= 0) mult = 1; + if (!Number.isFinite(e) || !Number.isFinite(m) || !Number.isFinite(c) || c <= 0) { + return null; + } + const diff = + (side || "long").toLowerCase() === "long" ? m - e : e - m; + return Math.round(diff * c * mult * 100) / 100; + } + + function formatPosPnlText(ctx) { + const upnl = ctx && ctx.unrealized_pnl; + if (upnl == null || !Number.isFinite(Number(upnl))) return { text: "—", cls: "" }; + const n = Number(upnl); + let text = (n >= 0 ? "+" : "") + n.toFixed(2) + "U"; + const notional = ctx.notional_usdt; + const entry = Number(ctx.entry); + const contracts = Math.abs(Number(ctx.contracts)); + const cs = + ctx.contract_size != null && Number(ctx.contract_size) > 0 + ? Number(ctx.contract_size) + : 1; + let pctBase = null; + if (notional != null && Math.abs(Number(notional)) > 1e-8) { + pctBase = Math.abs(Number(notional)); + } else if ( + Number.isFinite(entry) && + entry > 0 && + Number.isFinite(contracts) && + contracts > 0 + ) { + pctBase = entry * contracts * cs; + } + if (pctBase != null && pctBase > 1e-8) { + const pct = (n / pctBase) * 100; + text += " (" + (pct >= 0 ? "+" : "") + pct.toFixed(2) + "%)"; + } else if (ctx.plan_margin != null && Number(ctx.plan_margin) > 1e-8) { + const pct = (n / Number(ctx.plan_margin)) * 100; + text += " (" + (pct >= 0 ? "+" : "") + pct.toFixed(2) + "%)"; + } + return { text: text, cls: n > 0 ? "pnl-up" : n < 0 ? "pnl-down" : "" }; + } + + function findTrendFloatingPnl(row, sym, side) { + const hm = row.hub_monitor; + if (!hm || !Array.isArray(hm.trends)) return null; + for (let i = 0; i < hm.trends.length; i++) { + const t = hm.trends[i]; + const ts = normalizeMarketSymbol(t.exchange_symbol || t.symbol || ""); + if (ts !== sym) continue; + if ((t.direction || "").toLowerCase() !== side) continue; + const fp = t.floating_pnl; + if (fp != null && Number.isFinite(Number(fp))) return Number(fp); + if (t.plan_margin_capital != null && Number(t.plan_margin_capital) > 0) { + /* 保留 plan_margin 供百分比 */ + } + } + return null; + } + + function findTrendPlan(row, sym, side) { + const hm = row.hub_monitor; + if (!hm || !Array.isArray(hm.trends)) return null; + for (let i = 0; i < hm.trends.length; i++) { + const t = hm.trends[i]; + const ts = normalizeMarketSymbol(t.exchange_symbol || t.symbol || ""); + if (ts !== sym) continue; + if ((t.direction || "").toLowerCase() !== side) continue; + return t; + } + return null; + } + + function applyTrendPlanFields(row, sym, side) { + if (!posContext) return; + const t = findTrendPlan(row, sym, side); + if (!t) return; + const m = t.plan_margin_capital; + if (m != null && Number.isFinite(Number(m)) && Number(m) > 0) { + posContext.plan_margin = Number(m); + } + const lev = t.leverage; + if (lev != null && Number.isFinite(Number(lev)) && Number(lev) > 0) { + posContext.leverage = Number(lev); + } + } + + /** U 本位线性永续:(标记价-开仓价)×张数×contractSize(空头取反) */ + function calcContractsUpnl(ctx, markPx) { + if (!ctx || markPx == null || !Number.isFinite(Number(markPx))) return null; + return estimateLinearSwapUpnl( + ctx.side, + ctx.entry, + markPx, + ctx.contracts, + ctx.contract_size + ); + } + + function latestChartMarkPrice() { + if (!lastCandles || !lastCandles.length) return null; + const bar = lastCandles[lastCandles.length - 1]; + const c = bar && bar.close != null ? Number(bar.close) : null; + return c != null && Number.isFinite(c) && c > 0 ? c : null; + } + + function updateLivePosPnl(markOverride) { + if (!posContext) return false; + const mark = + markOverride != null && Number.isFinite(Number(markOverride)) + ? Number(markOverride) + : latestChartMarkPrice() || + (posContext.mark_price != null && Number.isFinite(Number(posContext.mark_price)) + ? Number(posContext.mark_price) + : null); + if (mark == null) return false; + const live = calcContractsUpnl(posContext, mark); + if (live != null) { + posContext.unrealized_pnl = live; + posContext.mark_price = mark; + renderPosPnlDisplay(posContext); + return true; + } + if ( + posContext.unrealized_pnl != null && + Number.isFinite(Number(posContext.unrealized_pnl)) + ) { + posContext.mark_price = mark; + renderPosPnlDisplay(posContext); + return true; + } + return false; + } + + function syncPosTpslFromAgentPosition(p) { + if (!posContext || !p) return; + const et = p.exchange_tpsl; + if (et && typeof et === "object") { + if (et.sl && et.sl.trigger_price != null) { + posContext.stop_loss = Number(et.sl.trigger_price); + } + if (et.tp && et.tp.trigger_price != null) { + posContext.take_profit = Number(et.tp.trigger_price); + posContext.tp_monitored = false; + } + } + const cond = Array.isArray(p.conditional_orders) ? p.conditional_orders : []; + for (let i = 0; i < cond.length; i++) { + const o = cond[i]; + const lbl = String(o.label || ""); + const px = + o.trigger_price != null && Number.isFinite(Number(o.trigger_price)) + ? Number(o.trigger_price) + : null; + if (px == null) continue; + if (/^止损/.test(lbl)) posContext.stop_loss = px; + else if (/^止盈/.test(lbl) && !/止盈止损/.test(lbl)) { + posContext.take_profit = px; + posContext.tp_monitored = false; + } + } + } + + function renderPosPnlDisplay(ctx) { + if (!elPosPnl) return; + const p = formatPosPnlText(ctx); + elPosPnl.textContent = p.text; + elPosPnl.className = "market-pos-pnl " + p.cls; + } + + function paintPosPnl(ctx) { + if (ctx === posContext && updateLivePosPnl()) return; + renderPosPnlDisplay(ctx); + } + + function stopPosPnlPoll() { + if (posPnlTimer) { + clearInterval(posPnlTimer); + posPnlTimer = null; + } + } + + function startPosPnlPoll() { + stopPosPnlPoll(); + if (!posContext || !posContext.exchange_id) return; + refreshPosPnlFromBoard(); + posPnlTimer = setInterval(function () { + if (!updateLivePosPnl()) refreshPosPnlFromBoard(); + }, 2000); + } + + async function refreshPosPnlFromBoard() { + if (!posContext || !posContext.exchange_id) return; + try { + const r = await fetch("/api/monitor/board/snapshot", { credentials: "same-origin" }); + if (!r.ok) return; + const data = await r.json(); + const rows = data.rows || []; + const sym = normalizeMarketSymbol(posContext.symbol || ""); + const side = (posContext.side || "long").toLowerCase(); + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + const ex = row.exchange || {}; + if (ex.id !== posContext.exchange_id) continue; + applyTrendPlanFields(row, sym, side); + const positions = (row.agent && row.agent.positions) || []; + for (let j = 0; j < positions.length; j++) { + const p = positions[j]; + if ((p.side || "").toLowerCase() !== side) continue; + if (normalizeMarketSymbol(p.symbol || "") !== sym) continue; + if (p.entry_price != null && Number.isFinite(Number(p.entry_price))) { + posContext.entry = Number(p.entry_price); + } + if (p.contract_size != null && Number.isFinite(Number(p.contract_size))) { + posContext.contract_size = Number(p.contract_size); + } + if (p.contracts != null && Number.isFinite(Number(p.contracts))) { + posContext.contracts = Number(p.contracts); + } + if (p.mark_price != null && Number.isFinite(Number(p.mark_price))) { + posContext.mark_price = Number(p.mark_price); + } + if (p.notional_usdt != null && Number.isFinite(Number(p.notional_usdt))) { + posContext.notional_usdt = Number(p.notional_usdt); + } + syncPosTpslFromAgentPosition(p); + if (elPosSl && posContext.stop_loss != null) { + elPosSl.textContent = fmtPrice(posContext.stop_loss); + } + if (elPosTp && posContext.take_profit != null && !posContext.tp_monitored) { + elPosTp.textContent = fmtPrice(posContext.take_profit); + } + const markForPnl = + latestChartMarkPrice() || + (p.mark_price != null && Number.isFinite(Number(p.mark_price)) + ? Number(p.mark_price) + : null); + if (!updateLivePosPnl(markForPnl)) { + let upnl = + p.unrealized_pnl != null && Number.isFinite(Number(p.unrealized_pnl)) + ? Number(p.unrealized_pnl) + : findTrendFloatingPnl(row, sym, side); + if (upnl != null) { + posContext.unrealized_pnl = upnl; + renderPosPnlDisplay(posContext); + } + } + updatePositionLines(); + try { + sessionStorage.setItem(HUB_MARKET_POS_CTX_KEY, JSON.stringify(posContext)); + } catch (_) {} + return; + } + applyTrendPlanFields(row, sym, side); + if (!updateLivePosPnl()) { + const trendUpnl = findTrendFloatingPnl(row, sym, side); + if (trendUpnl != null) { + posContext.unrealized_pnl = trendUpnl; + renderPosPnlDisplay(posContext); + } + } + try { + sessionStorage.setItem(HUB_MARKET_POS_CTX_KEY, JSON.stringify(posContext)); + } catch (_) {} + return; + } + } catch (_) {} + } + + function resolveTpForPlace(ctx) { + if (!ctx) return null; + const tp = ctx.take_profit; + if (tp != null && Number(tp) > 0) return Number(tp); + const orders = ctx.orders || []; + for (let i = 0; i < orders.length; i++) { + const o = orders[i]; + const lbl = String(o.label || ""); + if (/止盈/.test(lbl) && o.price != null && Number(o.price) > 0) return Number(o.price); + } + return null; + } + + async function placeTpslFromChart(newSl) { + if (!posContext || !posContext.exchange_id) { + showHubToast("缺少交易所信息,无法挂单", true); + return; + } + const sl = roundToTick(newSl); + if (sl == null || !Number.isFinite(sl) || sl <= 0) { + showHubToast("止损价无效", true); + return; + } + const tp = resolveTpForPlace(posContext); + if (tp == null || tp <= 0) { + showHubToast("未找到有效止盈价,请先在监控区用「委托」填写止盈", true); + return; + } + const sym = normalizeMarketSymbol(posContext.symbol || ""); + const side = posContext.side || "long"; + const contracts = posContext.contracts; + const oldSl = posContext.stop_loss; + if ( + !confirm( + "确认 " + + sym + + " " + + side + + "\n先撤销全部条件单,再挂止损 " + + fmtPrice(sl) + + ",止盈 " + + fmtPrice(tp) + + (oldSl != null ? "\n(原止损 " + fmtPrice(oldSl) + ")" : "") + ) + ) { + return; + } + try { + const r = await fetch( + "/api/orders/" + encodeURIComponent(posContext.exchange_id) + "/place-tpsl", + { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + symbol: sym, + side: side, + stop_loss: sl, + take_profit: tp, + contracts: contracts > 0 ? contracts : null, + }), + } + ); + const j = await r.json(); + const pl = j.payload || {}; + const ok = j.ok && pl.ok !== false; + showHubToast( + ok ? "止损已更新(已撤旧条件单并重新挂单)" : pl.error || JSON.stringify(j), + !ok + ); + if (ok) { + posContext.stop_loss = sl; + try { + sessionStorage.setItem(HUB_MARKET_POS_CTX_KEY, JSON.stringify(posContext)); + } catch (_) {} + if (elPosSl) elPosSl.textContent = fmtPrice(sl); + updatePositionLines(); + fetch("/api/monitor/board/refresh", { method: "POST", credentials: "same-origin" }); + } + } catch (e) { + showHubToast(String(e.message || e), true); + } + } + + function slLineCoordinate() { + if (!candleSeries || !posContext) return null; + const px = + slDrag && slDrag.active && slDrag.previewSl != null + ? slDrag.previewSl + : posContext.stop_loss; + if (px == null || !Number.isFinite(Number(px))) return null; + return candleSeries.priceToCoordinate(roundToTick(px)); + } + + function clientYToChartPrice(clientY) { + if (!candleSeries || !chartHost) return null; + const rect = chartHost.getBoundingClientRect(); + const y = clientY - rect.top; + const p = candleSeries.coordinateToPrice(y); + if (p == null || !Number.isFinite(Number(p))) return null; + return roundToTick(p); + } + + function isPointerNearSlLine(clientY) { + const coord = slLineCoordinate(); + if (coord == null || !chartHost) return false; + const rect = chartHost.getBoundingClientRect(); + return Math.abs(clientY - rect.top - coord) <= SL_DRAG_HIT_PX; + } + + function onSlLineHover(e) { + if (!chartHost || (slDrag && slDrag.active)) return; + if (!posContext || posContext.stop_loss == null) { + chartHost.style.cursor = ""; + return; + } + chartHost.style.cursor = isPointerNearSlLine(e.clientY) ? "ns-resize" : ""; + } + + function onSlDragStart(e) { + if (!posContext || posContext.stop_loss == null || !candleSeries) return; + if (e.button !== 0) return; + if (!isPointerNearSlLine(e.clientY)) return; + e.preventDefault(); + slDrag = { + active: true, + moved: false, + startSl: Number(posContext.stop_loss), + previewSl: Number(posContext.stop_loss), + }; + if (chartHost) chartHost.style.cursor = "ns-resize"; + updatePositionLines(); + } + + function onSlDragMove(e) { + if (!slDrag || !slDrag.active) return; + const p = clientYToChartPrice(e.clientY); + if (p == null || p <= 0) return; + slDrag.previewSl = p; + if (Math.abs(p - slDrag.startSl) > 1e-12) slDrag.moved = true; + if (elPosSl) elPosSl.textContent = fmtPrice(p); + updatePositionLines(); + } + + function onSlDragEnd() { + if (!slDrag || !slDrag.active) { + slDrag = null; + if (chartHost) chartHost.style.cursor = ""; + return; + } + const preview = slDrag.previewSl; + const moved = slDrag.moved; + slDrag = null; + if (chartHost) chartHost.style.cursor = ""; + updatePositionLines(); + if (!moved || preview == null) return; + placeTpslFromChart(preview); + } + + function bindSlDrag() { + if (!chartHost) return; + chartHost.addEventListener("mousedown", onSlDragStart); + chartHost.addEventListener("mousemove", onSlLineHover); + document.addEventListener("mousemove", onSlDragMove); + document.addEventListener("mouseup", onSlDragEnd); + } + + function renderPosPanel(ctx) { + if (!elPosPanel || !ctx) { + clearPosPanel(); + return; + } + elPosPanel.classList.remove("hidden"); + if (elPosSide) { + const isShort = (ctx.side || "").toLowerCase() === "short"; + elPosSide.textContent = isShort ? "空" : "多"; + elPosSide.className = "market-pos-side " + (isShort ? "side-short" : "side-long"); + } + if (elPosEntry) elPosEntry.textContent = ctx.entry != null ? fmtPrice(ctx.entry) : "—"; + if (elPosSl) elPosSl.textContent = ctx.stop_loss != null ? fmtPrice(ctx.stop_loss) : "—"; + if (elPosTp) { + if (ctx.tp_monitored) { + elPosTp.textContent = + ctx.take_profit != null + ? "程序监控 · " + fmtPrice(ctx.take_profit) + : "程序监控"; + elPosTp.classList.add("market-pos-tp-monitored"); + } else { + elPosTp.textContent = ctx.take_profit != null ? fmtPrice(ctx.take_profit) : "—"; + elPosTp.classList.remove("market-pos-tp-monitored"); + } + } + if (elPosSize) elPosSize.textContent = ctx.contracts != null ? String(ctx.contracts) : "—"; + paintPosPnl(ctx); + if (elPosOrders) { + const orders = Array.isArray(ctx.orders) ? ctx.orders : []; + if (!orders.length) { + elPosOrders.innerHTML = '暂无委托单'; + } else { + elPosOrders.innerHTML = orders + .map(function (o) { + const price = o.price != null ? fmtPrice(o.price) : "—"; + const amt = o.amount != null ? String(o.amount) : ""; + return ( + '' + + '' + + escHtml(o.kind || "") + + "" + + '' + + escHtml(o.label || "") + + "" + + '' + + price + + "" + + (amt ? '×' + escHtml(amt) + "" : "") + + "" + ); + }) + .join(""); + } + } + scheduleChartResize(); + } + + function clearPositionLines() { + positionLines.forEach(function (m) { + try { + candleSeries.removePriceLine(m); + } catch (e) {} + }); + positionLines = []; + } + + function updatePositionLines() { + clearPositionLines(); + if (!candleSeries || !posContext) return; + const slPrice = + slDrag && slDrag.active && slDrag.previewSl != null + ? slDrag.previewSl + : posContext.stop_loss; + const slTitle = + slDrag && slDrag.active + ? "止损 " + fmtPrice(slPrice) + : slPrice != null + ? "止损 ⟷" + : "止损"; + const specs = [ + { price: posContext.entry, color: "#5b9cf5", title: "入场", lineWidth: 1 }, + { + price: slPrice, + color: "#ff4d6d", + title: slTitle, + lineWidth: slPrice != null ? 2 : 1, + }, + ]; + if (posContext.take_profit != null) { + specs.push({ + price: posContext.take_profit, + color: "#00ff9d", + title: posContext.tp_monitored ? "止盈(程序)" : "止盈", + }); + } + specs.forEach(function (s) { + if (s.price == null || !Number.isFinite(Number(s.price))) return; + const px = roundToTick(s.price); + if (px == null || !Number.isFinite(Number(px))) return; + positionLines.push( + candleSeries.createPriceLine({ + price: Number(px), + color: s.color, + lineWidth: s.lineWidth != null ? s.lineWidth : 1, + lineStyle: 2, + axisLabelVisible: true, + title: s.title, + }) + ); + }); + } + + function clearPosContext() { + posContext = null; + slDrag = null; + stopPosPnlPoll(); + try { + sessionStorage.removeItem(HUB_MARKET_POS_CTX_KEY); + } catch (e) {} + clearPosPanel(); + clearPositionLines(); + if (chartHost) chartHost.style.cursor = ""; + } + + function applyPosContext(ctx) { + posContext = ctx; + renderPosPanel(ctx); + updatePositionLines(); + startPosPnlPoll(); + } + + function syncPosContextForView(exKey, sym) { + const stored = loadPosContextFromStorage(); + if (stored && posContextMatches(stored, exKey, sym)) { + applyPosContext(stored); + return; + } + clearPosContext(); + } + + function fmtVol(v) { + if (v == null || Number.isNaN(Number(v))) return "-"; + const n = Number(v); + if (n >= 1e9) return (n / 1e9).toFixed(2) + "B"; + if (n >= 1e6) return (n / 1e6).toFixed(2) + "M"; + if (n >= 1e3) return (n / 1e3).toFixed(2) + "K"; + return n.toFixed(2); + } + + function decimalsFromTick(tick) { + if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) return null; + const minMove = Number(tick); + if (minMove >= 1) return 0; + const raw = String(minMove); + const sci = raw.match(/e-(\d+)/i); + if (sci) return Math.min(12, parseInt(sci[1], 10)); + const fixed = minMove.toFixed(12); + const frac = fixed.split(".")[1] || ""; + const trimmed = frac.replace(/0+$/, ""); + if (trimmed.length) return Math.min(12, trimmed.length); + return Math.max(0, Math.min(12, Math.round(-Math.log10(minMove)))); + } + + const SAFE_PRICE_FORMAT = { type: "price", precision: 4, minMove: 0.0001 }; + + function tickToPriceFormat(tick) { + try { + if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) { + return { type: "price", precision: 2, minMove: 0.01 }; + } + const minMove = Number(tick); + let prec = decimalsFromTick(minMove); + if (prec == null || prec < 0) prec = 4; + prec = Math.min(12, Math.max(0, Math.floor(prec))); + return { type: "price", precision: prec, minMove: minMove }; + } catch (e) { + return SAFE_PRICE_FORMAT; + } + } + + function roundToTick(v) { + if (v == null || Number.isNaN(Number(v))) return v; + const n = Number(v); + const tick = priceTick; + if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) return n; + const t = Number(tick); + const rounded = Math.round(n / t) * t; + const dec = decimalsFromTick(t); + if (dec == null) return rounded; + return parseFloat(rounded.toFixed(dec)); + } + + function alignCandlesToTick(candles) { + if (!Array.isArray(candles) || !candles.length) return candles || []; + if (priceTick == null || !Number.isFinite(Number(priceTick)) || Number(priceTick) <= 0) { + return candles; + } + return candles.map(function (c) { + return { + time: c.time, + open: roundToTick(c.open), + high: roundToTick(c.high), + low: roundToTick(c.low), + close: roundToTick(c.close), + volume: c.volume, + }; + }); + } + + function applyPriceFormatToSeries(series, pf) { + if (!series || !series.applyOptions) return; + try { + series.applyOptions({ priceFormat: pf }); + } catch (e) { + series.applyOptions({ priceFormat: SAFE_PRICE_FORMAT }); + } + } + + function applyChartPriceFormat() { + let pf = SAFE_PRICE_FORMAT; + try { + pf = tickToPriceFormat(priceTick); + } catch (e) { + pf = SAFE_PRICE_FORMAT; + } + applyPriceFormatToSeries(candleSeries, pf); + applyPriceFormatToSeries(indSeries.ema21, pf); + applyPriceFormatToSeries(indSeries.ema55, pf); + if (chart) { + chart.applyOptions({ + localization: buildChartLocalization(), + }); + } + } + + function fmtPrice(v) { + if (v == null || Number.isNaN(Number(v))) return "-"; + const aligned = roundToTick(v); + const n = Number(aligned); + if (n === 0) return "0"; + const dec = decimalsFromTick(priceTick); + if (dec != null) return n.toFixed(dec); + const av = Math.abs(n); + let d = 8; + if (av >= 10000) d = 2; + else if (av >= 100) d = 3; + else if (av >= 1) d = 4; + else if (av >= 0.01) d = 6; + let text = n.toFixed(d); + if (text.indexOf(".") >= 0) text = text.replace(/\.?0+$/, ""); + return text; + } + + function exchangeLabel() { + if (!elExchange) return ""; + const opt = elExchange.options[elExchange.selectedIndex]; + if (opt && opt.textContent) return opt.textContent.trim(); + return (elExchange.value || "").trim().toUpperCase(); + } + + function updateExchangeDisplay() { + const label = exchangeLabel(); + if (elExLabel) elExLabel.textContent = label; + if (elExBadge) { + elExBadge.textContent = label; + elExBadge.setAttribute("aria-hidden", label ? "false" : "true"); + } + } + + function updateHeaderLabels(sym, tf) { + if (elSymLabel) elSymLabel.textContent = sym || "—"; + if (elTfLabel) elTfLabel.textContent = tf || "—"; + updateExchangeDisplay(); + } + + function fmtAmplitude(bar) { + if (!bar) return "-"; + const o = Number(bar.open); + const h = Number(bar.high); + const l = Number(bar.low); + if (!o || o <= 0 || !Number.isFinite(h) || !Number.isFinite(l)) return "-"; + return (((h - l) / o) * 100).toFixed(2) + "%"; + } + + function barRemainMs(tf) { + const period = TF_MS[tf] || TF_MS["1d"]; + const now = Date.now(); + const barOpen = Math.floor(now / period) * period; + return Math.max(0, barOpen + period - now); + } + + function fmtBarCountdown(ms) { + const total = Math.max(0, Math.floor(ms / 1000)); + const h = Math.floor(total / 3600); + const m = Math.floor((total % 3600) / 60); + const s = total % 60; + const pad = function (n) { + return n < 10 ? "0" + n : String(n); + }; + if (h > 0) return h + ":" + pad(m) + ":" + pad(s); + return pad(m) + ":" + pad(s); + } + + function paintOhlcv(bar) { + if (!bar) { + ["o", "h", "l", "c", "v", "amp"].forEach(function (k) { + const el = { o: elO, h: elH, l: elL, c: elC, v: elV, amp: elAmp }[k]; + if (el) el.textContent = "-"; + }); + return; + } + if (elO) elO.textContent = fmtPrice(bar.open); + if (elH) elH.textContent = fmtPrice(bar.high); + if (elL) elL.textContent = fmtPrice(bar.low); + if (elC) elC.textContent = fmtPrice(bar.close); + if (elV) elV.textContent = fmtVol(bar.volume); + if (elAmp) elAmp.textContent = fmtAmplitude(bar); + } + + function latestCandle() { + return lastCandles.length ? lastCandles[lastCandles.length - 1] : null; + } + + function showLatestOhlcv() { + paintOhlcv(latestCandle()); + updateCurrentPriceLine(); + updatePriceTag(); + } + + function clearCurrentPriceLine() { + if (currentPriceLine && candleSeries) { + try { + candleSeries.removePriceLine(currentPriceLine); + } catch (e) {} + } + currentPriceLine = null; + } + + function updateCurrentPriceLine() { + clearCurrentPriceLine(); + if (!candleSeries) return; + const bar = latestCandle(); + if (!bar || bar.close == null) return; + const up = Number(bar.close) >= Number(bar.open); + currentPriceLine = candleSeries.createPriceLine({ + price: Number(roundToTick(bar.close)), + color: up ? "#00ff9d" : "#ff4d6d", + lineWidth: 1, + lineStyle: 2, + axisLabelVisible: false, + title: "", + }); + } + + function tickLiveClock() { + const cd = fmtBarCountdown(barRemainMs(currentTf)); + if (elPriceTagTime && elPriceTag && !elPriceTag.classList.contains("hidden")) { + elPriceTagTime.textContent = cd; + } + if (elBarCountdown) elBarCountdown.textContent = "距收盘 " + cd; + } + + function updatePriceTag() { + if (!elPriceTag || !candleSeries || !chart) return; + try { + tickLiveClock(); + const bar = latestCandle(); + if (!bar || bar.close == null) { + elPriceTag.classList.add("hidden"); + elPriceTag.setAttribute("aria-hidden", "true"); + return; + } + let y = null; + try { + y = candleSeries.priceToCoordinate(Number(bar.close)); + } catch (e) { + y = null; + } + const hostH = chartHost.clientHeight || 0; + if (y == null || y < 8 || y > hostH - 8) { + elPriceTag.classList.add("hidden"); + elPriceTag.setAttribute("aria-hidden", "true"); + return; + } + const up = Number(bar.close) >= Number(bar.open); + elPriceTag.classList.remove("hidden", "is-up", "is-down"); + elPriceTag.classList.add(up ? "is-up" : "is-down"); + elPriceTag.setAttribute("aria-hidden", "false"); + elPriceTag.style.left = "auto"; + elPriceTag.style.right = "0"; + elPriceTag.style.top = y + "px"; + if (elPriceTagValue) elPriceTagValue.textContent = fmtPrice(bar.close); + } catch (e) { + elPriceTag.classList.add("hidden"); + elPriceTag.setAttribute("aria-hidden", "true"); + } + } + + function startPriceTagTimer() { + stopPriceTagTimer(); + tickLiveClock(); + priceTagTimer = setInterval(tickLiveClock, 1000); + } + + function stopPriceTagTimer() { + if (priceTagTimer) clearInterval(priceTagTimer); + priceTagTimer = null; + } + + function applyPriceAutoScale() { + if (!chart) return; + chart.priceScale("right").applyOptions({ autoScale: priceAutoScale }); + if (elPriceAuto) elPriceAuto.classList.toggle("is-on", priceAutoScale); + } + + function indexCandles(candles) { + candleByTime = {}; + (candles || []).forEach(function (c) { + if (c && c.time != null) candleByTime[c.time] = c; + }); + } + + function candleAtTime(t) { + if (t == null) return null; + return candleByTime[t] || null; + } + + function chartThemePalette() { + const light = document.documentElement.getAttribute("data-theme") === "light"; + return light + ? { + bg: "#f0f4f9", + text: "#4a6078", + border: "#b8c8d8", + up: "#0a8f5c", + down: "#c93552", + volUp: "rgba(10, 143, 92, 0.45)", + volDown: "rgba(201, 53, 82, 0.45)", + } + : { + bg: "#0a1018", + text: "#b8d4e8", + border: "#2a4058", + up: "#00ff9d", + down: "#ff4d6d", + volUp: "rgba(0, 255, 157, 0.5)", + volDown: "rgba(255, 77, 109, 0.5)", + }; + } + + function applyChartTheme() { + if (!chart) return; + const p = chartThemePalette(); + chart.applyOptions({ + layout: { background: { color: p.bg }, textColor: p.text }, + rightPriceScale: { borderColor: p.border }, + timeScale: { borderColor: p.border }, + }); + if (candleSeries) { + candleSeries.applyOptions({ + upColor: p.up, + downColor: p.down, + wickUpColor: p.up, + wickDownColor: p.down, + }); + } + if (volumeSeries && lastCandles.length) { + volumeSeries.setData(buildVolumeData(lastCandles)); + } + } + + function buildVolumeData(candles) { + const p = chartThemePalette(); + return (candles || []).map(function (c) { + const up = Number(c.close) >= Number(c.open); + return { + time: c.time, + value: Number(c.volume) || 0, + color: up ? p.volUp : p.volDown, + }; + }); + } + + function buildVolumeBar(candle) { + const p = chartThemePalette(); + const up = Number(candle.close) >= Number(candle.open); + return { + time: candle.time, + value: Number(candle.volume) || 0, + color: up ? p.volUp : p.volDown, + }; + } + + function ensureChart() { + if (chart && candleSeries && volumeSeries) return true; + if (!window.LightweightCharts) { + if (elStatus) { + elStatus.className = "market-status err"; + elStatus.textContent = "图表库加载失败"; + } + return false; + } + const tp = chartThemePalette(); + chart = LightweightCharts.createChart(chartHost, { + layout: { background: { color: tp.bg }, textColor: tp.text }, + grid: { + vertLines: { visible: false }, + horzLines: { visible: false }, + }, + rightPriceScale: { borderColor: tp.border, autoScale: true }, + localization: buildChartLocalization(), + timeScale: { + borderColor: tp.border, + timeVisible: true, + secondsVisible: false, + rightOffset: RIGHT_OFFSET_BARS, + }, + crosshair: { + mode: LightweightCharts.CrosshairMode + ? LightweightCharts.CrosshairMode.Normal + : 0, + }, + }); + + const candleOpts = { + upColor: tp.up, + downColor: tp.down, + borderVisible: false, + wickUpColor: tp.up, + wickDownColor: tp.down, + lastValueVisible: false, + priceLineVisible: false, + priceFormat: SAFE_PRICE_FORMAT, + }; + + if (typeof chart.addCandlestickSeries === "function") { + candleSeries = chart.addCandlestickSeries(candleOpts); + } else if ( + typeof chart.addSeries === "function" && + window.LightweightCharts && + window.LightweightCharts.CandlestickSeries + ) { + candleSeries = chart.addSeries(window.LightweightCharts.CandlestickSeries, candleOpts); + } + if (!candleSeries) return false; + + const volOpts = { + priceFormat: { type: "volume" }, + priceScaleId: "", + lastValueVisible: false, + }; + if (typeof chart.addHistogramSeries === "function") { + volumeSeries = chart.addHistogramSeries(volOpts); + } else if ( + typeof chart.addSeries === "function" && + window.LightweightCharts && + window.LightweightCharts.HistogramSeries + ) { + volumeSeries = chart.addSeries(window.LightweightCharts.HistogramSeries, volOpts); + } + if (!volumeSeries) return false; + + applyScaleLayout(); + applyChartPriceFormat(); + applyPriceAutoScale(); + + chart.subscribeCrosshairMove(function (param) { + if (!param || param.time == null) { + showLatestOhlcv(); + return; + } + const bar = candleAtTime(param.time); + if (!bar) { + showLatestOhlcv(); + return; + } + paintOhlcv(bar); + }); + + chart.timeScale().subscribeVisibleLogicalRangeChange(function (range) { + if (!chartDataLoading && range && !suppressRangeUserLock) { + markChartRangeUserAdjusted(); + } + scheduleRangeUiUpdate(); + if ( + !range || + chartDataLoading || + loadingLeft || + exhaustedLeft || + !lastCandles.length || + !lastViewKey + ) { + return; + } + if (currentChartViewKey() !== lastViewKey) return; + scheduleLoadOlderOnRange(range); + }); + + window.addEventListener("resize", function () { + scheduleChartResize(); + }); + scheduleChartResize(); + ensureDrawLayer(); + return true; + } + + function clearMarkers() { + rangeMarkers.forEach(function (m) { + try { + candleSeries.removePriceLine(m); + } catch (e) {} + }); + rangeMarkers = []; + } + + function clearYesterdayPriceLines() { + if (candleSeries) { + yesterdayPriceLines.forEach(function (m) { + try { + candleSeries.removePriceLine(m); + } catch (e) {} + }); + } + yesterdayPriceLines = []; + } + + function updateYesterdayPriceLines() { + clearYesterdayPriceLines(); + if (!candleSeries || !lastCandles.length) return; + const showClose = !!(elPrevCloseLine && elPrevCloseLine.checked); + const showHl = !!(elPrevHlLines && elPrevHlLines.checked); + if (!showClose && !showHl) return; + const stats = computePrevTradingDayOhlc(lastCandles, chartResetHour()); + if (!stats) return; + if (showClose && stats.close != null && Number.isFinite(Number(stats.close))) { + const px = Number(roundToTick(stats.close)); + if (Number.isFinite(px)) { + yesterdayPriceLines.push( + candleSeries.createPriceLine({ + price: px, + color: "#a78bfa", + lineWidth: 1, + lineStyle: 2, + axisLabelVisible: true, + title: "昨收", + }) + ); + } + } + if (showHl) { + if (stats.high != null && Number.isFinite(Number(stats.high))) { + const hiPx = Number(roundToTick(stats.high)); + if (Number.isFinite(hiPx)) { + yesterdayPriceLines.push( + candleSeries.createPriceLine({ + price: hiPx, + color: "#ffb84d", + lineWidth: 1, + lineStyle: 2, + axisLabelVisible: true, + title: "昨高", + }) + ); + } + } + if (stats.low != null && Number.isFinite(Number(stats.low))) { + const loPx = Number(roundToTick(stats.low)); + if (Number.isFinite(loPx)) { + yesterdayPriceLines.push( + candleSeries.createPriceLine({ + price: loPx, + color: "#4cd97f", + lineWidth: 1, + lineStyle: 2, + axisLabelVisible: true, + title: "昨低", + }) + ); + } + } + } + } + + function viewKey(exKey, sym, tf) { + const ex = String(exKey || "").trim().toLowerCase(); + const s = normalizeMarketSymbol(sym); + const t = String(tf || "").trim(); + return ex + "|" + s + "|" + t; + } + + function lookupSeriesMapEntry(map, vKey) { + if (!map || !vKey) return null; + if (map[vKey]) return map[vKey]; + const parts = String(vKey).split("|"); + if (parts.length === 3) { + const norm = viewKey(parts[0], parts[1], parts[2]); + if (norm !== vKey && map[norm]) return map[norm]; + } + return null; + } + + function chartInitialLimit(tf) { + return CHART_INITIAL_LIMITS[tf] || 200; + } + + function chartChunkLimit(tf) { + return CHART_CHUNK_LIMITS[tf] || 200; + } + + function chartMemoryCap(tf) { + return CHART_MEMORY_CAPS[tf] || 1000; + } + + function resetChartHistoryState() { + exhaustedLeft = false; + loadingLeft = false; + } + + function currentChartViewKey() { + const exKey = (elExchange && elExchange.value) || ""; + const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || ""; + const tf = (elTf && elTf.value) || currentTf || "1d"; + if (!exKey || !sym) return ""; + return viewKey(exKey, sym, tf); + } + + function isVisibleRangeValidForCandles(range, candleCount) { + if (!range || candleCount <= 0) return false; + const maxTo = candleCount - 1 + RIGHT_OFFSET_BARS; + if (range.from < -2 || range.to < 0) return false; + if (range.to > maxTo + 8) return false; + if (range.from > candleCount - 1) return false; + return true; + } + + function markChartRangeUserAdjusted() { + chartRangeUserLocked = true; + if (chartRangeLockTimer) clearTimeout(chartRangeLockTimer); + chartRangeLockTimer = setTimeout(function () { + chartRangeLockTimer = null; + chartRangeUserLocked = false; + }, 30000); + } + + function clampVisibleLogicalRange(range, candleCount) { + if (!range || candleCount <= 0) return null; + const maxTo = candleCount - 1 + RIGHT_OFFSET_BARS; + const from = Math.max(-2, Math.min(range.from, candleCount - 1)); + const to = Math.max(0, Math.min(range.to, maxTo + 8)); + if (to <= from) return null; + return { from: from, to: to }; + } + + function restoreVisibleLogicalRange(range, candleCount) { + const clamped = clampVisibleLogicalRange(range, candleCount); + if (!chart || !clamped || !isVisibleRangeValidForCandles(clamped, candleCount)) return false; + suppressRangeUserLock = true; + chart.timeScale().setVisibleLogicalRange(clamped); + suppressRangeUserLock = false; + return true; + } + + function applyPreservedVisibleRange(range, candleCount) { + if (!chart || !range || !candleCount) return; + function applyOnce() { + if (!chart || !lastCandles.length) return; + applyChartRightGap(); + restoreVisibleLogicalRange(range, lastCandles.length); + updateVisibleRangeMarkers(); + updateYesterdayPriceLines(); + } + applyOnce(); + requestAnimationFrame(applyOnce); + setTimeout(applyOnce, 0); + } + + function shouldLoadOlderOnRange(range) { + if (!range || !lastCandles.length) return false; + const n = lastCandles.length; + const maxTo = n - 1 + RIGHT_OFFSET_BARS; + if (range.from >= CHART_LOAD_LEFT_THRESHOLD) return false; + // 缩小图表时 from 会变小,但 to 仍靠近最新 — 不应触发左拖补历史 + if (range.to >= maxTo - 30) return false; + return true; + } + + function scheduleRangeUiUpdate() { + if (rangeUiTimer) clearTimeout(rangeUiTimer); + rangeUiTimer = setTimeout(function () { + rangeUiTimer = null; + updateVisibleRangeMarkers(); + updatePriceTag(); + }, 120); + } + + function scheduleLoadOlderOnRange(range) { + if (!shouldLoadOlderOnRange(range)) return; + if (loadOlderTimer) clearTimeout(loadOlderTimer); + loadOlderTimer = setTimeout(function () { + loadOlderTimer = null; + if (!chart) return; + const cur = chart.timeScale().getVisibleLogicalRange(); + if (!shouldLoadOlderOnRange(cur)) return; + void loadOlderCandles(); + }, 280); + } + + function tailVisibleLogicalRange(candleCount) { + const n = Math.max(0, Number(candleCount) || 0); + if (n <= 0) return null; + const visible = Math.min(DEFAULT_VISIBLE_BARS, n); + return { + from: Math.max(0, n - visible), + to: n - 1 + RIGHT_OFFSET_BARS, + }; + } + + function clearChartSeriesData() { + lastCandles = []; + candleByTime = {}; + clearYesterdayPriceLines(); + if (candleSeries) candleSeries.setData([]); + if (volumeSeries) volumeSeries.setData([]); + } + + function mergeCandles(existing, incoming, opts) { + opts = opts || {}; + const prepend = !!opts.prepend; + const byTime = {}; + (existing || []).forEach(function (c) { + if (c && c.time != null) byTime[c.time] = c; + }); + (incoming || []).forEach(function (c) { + if (c && c.time != null) byTime[c.time] = c; + }); + let merged = Object.keys(byTime) + .map(function (t) { + return Number(t); + }) + .sort(function (a, b) { + return a - b; + }) + .map(function (t) { + return byTime[t]; + }); + const cap = chartMemoryCap(currentTf); + if (merged.length > cap) { + merged = prepend ? merged.slice(0, cap) : merged.slice(-cap); + } + return merged; + } + + /** 尾部静默刷新:仅 update 变更 K 线,不 setData,避免视口跳动 */ + function applyTailCandlePatch(incoming) { + if (!candleSeries || !volumeSeries || !incoming || !incoming.length) return false; + const aligned = alignCandlesToTick(incoming); + const prevLen = lastCandles.length; + const oldestTime = prevLen ? lastCandles[0].time : null; + const prevLastTime = prevLen ? lastCandles[prevLen - 1].time : null; + const merged = mergeCandles(lastCandles, aligned, { prepend: false }); + if ( + prevLen > 0 && + merged.length > 0 && + merged[0].time !== oldestTime && + merged.length <= prevLen + ) { + return false; + } + let patchStart = 0; + if (prevLastTime != null) { + patchStart = merged.findIndex(function (b) { + return b.time >= prevLastTime; + }); + if (patchStart < 0) return false; + } + try { + for (let i = patchStart; i < merged.length; i++) { + const bar = merged[i]; + candleSeries.update(bar); + volumeSeries.update(buildVolumeBar(bar)); + } + } catch (_) { + return false; + } + lastCandles = merged; + indexCandles(lastCandles); + readIndicatorState(); + if (indicatorState.ema || indicatorState.ema144 || indicatorState.macd || indicatorState.rsi) { + try { + updateIndicators(); + } catch (indErr) {} + } + updateVisibleRangeMarkers(); + updateYesterdayPriceLines(); + showLatestOhlcv(); + return true; + } + + function applyCandlesToChart(candles, rangeShift, opts) { + opts = opts || {}; + let savedRange = null; + if (opts.preserveRange && chart) { + savedRange = chart.timeScale().getVisibleLogicalRange(); + } + lastCandles = alignCandlesToTick(candles); + indexCandles(lastCandles); + candleSeries.setData(lastCandles); + volumeSeries.setData(buildVolumeData(lastCandles)); + if (!opts.skipRightGap) { + applyChartRightGap(); + } + if (rangeShift && chart) { + const range = chart.timeScale().getVisibleLogicalRange(); + if (range) { + suppressRangeUserLock = true; + chart.timeScale().setVisibleLogicalRange({ + from: range.from + rangeShift, + to: range.to + rangeShift, + }); + suppressRangeUserLock = false; + } + } else if (savedRange) { + restoreVisibleLogicalRange(savedRange, lastCandles.length); + } + if (!opts.skipAutoScale) { + applyPriceAutoScale(); + } + updateVisibleRangeMarkers(); + updateYesterdayPriceLines(); + try { + updateIndicators(); + } catch (indErr) {} + showLatestOhlcv(); + } + + async function fetchChartChunk(params) { + const qs = new URLSearchParams({ + exchange_key: params.exchange_key, + symbol: params.symbol, + timeframe: params.timeframe, + limit: String(params.limit), + }); + if (params.before_ms) qs.set("before_ms", String(params.before_ms)); + if (params.refresh) qs.set("refresh", "1"); + if (params.tail) qs.set("tail", "1"); + const r = await fetch("/api/chart/ohlcv?" + qs.toString(), { credentials: "same-origin" }); + const data = await r.json(); + if (!r.ok) { + throw new Error(data.detail || data.msg || "请求失败"); + } + return data; + } + + async function loadOlderCandles() { + if (chartDataLoading || loadingLeft || exhaustedLeft || !lastCandles.length) return; + const exKey = (elExchange && elExchange.value) || ""; + const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || ""; + const tf = (elTf && elTf.value) || "1d"; + if (!exKey || !sym) return; + const vKey = viewKey(exKey, sym, tf); + if (!lastViewKey || vKey !== lastViewKey) return; + loadingLeft = true; + const beforeMs = Number(lastCandles[0].time) * 1000; + try { + const data = await fetchChartChunk({ + exchange_key: exKey, + symbol: sym, + timeframe: tf, + limit: chartChunkLimit(tf), + before_ms: beforeMs, + }); + if (data.exhausted) exhaustedLeft = true; + const incoming = alignCandlesToTick(data.candles || []); + if (!incoming.length) return; + const prevLen = lastCandles.length; + const merged = mergeCandles(lastCandles, incoming, { prepend: true }); + const shift = merged.length - prevLen; + applyCandlesToChart(merged, shift); + if (elStatus && !elStatus.classList.contains("err")) { + elStatus.textContent = + "已加载 " + + lastCandles.length + + " 根(向左 +" + + incoming.length + + (exhaustedLeft ? " · 已到最早" : "") + + ")"; + } + } catch (e) { + if (elStatus) { + elStatus.className = "market-status warn"; + elStatus.textContent = "加载更早 K 线失败:" + String(e.message || e); + } + } finally { + loadingLeft = false; + } + } + + function applyIncomingTailCandles(incoming, meta) { + meta = meta || {}; + const vKey = currentViewSeriesKey(); + if (!vKey || !lastCandles.length || chartDataLoading) return false; + if (!lastViewKey || vKey !== lastViewKey) return false; + const epochAtStart = chartViewEpoch; + const autoFollow = priceAutoScale; + let savedRange = null; + if (chart) savedRange = chart.timeScale().getVisibleLogicalRange(); + if (!incoming || !incoming.length) return false; + if (meta.price_tick != null) { + priceTick = meta.price_tick; + try { + applyChartPriceFormat(); + } catch (fmtErr) { + priceTick = null; + applyChartPriceFormat(); + } + } + const aligned = alignCandlesToTick(incoming); + let tailPatched = false; + if (!autoFollow) { + try { + tailPatched = applyTailCandlePatch(aligned); + } catch (_) { + tailPatched = false; + } + } + if (!autoFollow && tailPatched) { + /* 手动模式:增量 update,不触碰时间轴 */ + } else { + const merged = mergeCandles(lastCandles, aligned, { prepend: false }); + applyCandlesToChart(merged, 0, { + preserveRange: false, + skipAutoScale: !autoFollow, + skipRightGap: !autoFollow, + }); + if (epochAtStart !== chartViewEpoch) return false; + const n = lastCandles.length; + if (autoFollow) { + applyDefaultVisibleRange(); + } else if (savedRange) { + applyPreservedVisibleRange(savedRange, n); + } + } + if (epochAtStart !== chartViewEpoch) return false; + scheduleRangeUiUpdate(); + if (posContext) { + updateLivePosPnl(); + refreshPosPnlFromBoard(); + } + if (meta.series_version != null) { + localSeriesVersion = Number(meta.series_version) || localSeriesVersion; + } + if (meta.chart_version != null) { + localChartVersion = Number(meta.chart_version) || localChartVersion; + } + if (elUpdated) elUpdated.textContent = "数据 " + (meta.updated_at || "--"); + tickLiveClock(); + if (window.HubChartDraw && drawAttached) window.HubChartDraw.redraw(); + return true; + } + + async function refreshChartTail() { + const exKey = (elExchange && elExchange.value) || ""; + const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || ""; + const tf = (elTf && elTf.value) || "1d"; + const vKey = viewKey(exKey, sym, tf); + if (!exKey || !sym || !lastCandles.length || chartDataLoading) return; + if (!lastViewKey || vKey !== lastViewKey) return; + const myToken = loadToken; + const epochAtStart = chartViewEpoch; + try { + const data = await fetchChartChunk({ + exchange_key: exKey, + symbol: sym, + timeframe: tf, + limit: CHART_TAIL_REFRESH_LIMIT, + tail: true, + }); + if (myToken !== loadToken) return; + if (vKey !== lastViewKey) return; + if (epochAtStart !== chartViewEpoch) return; + if (!data.ok || !data.candles || !data.candles.length) return; + applyIncomingTailCandles(data.candles, { + price_tick: data.price_tick, + series_version: data.series_version, + chart_version: data.chart_version, + updated_at: data.updated_at, + }); + } catch (_) {} + } + + function applyChartRightGap() { + if (!chart) return; + chart.timeScale().applyOptions({ + rightOffset: RIGHT_OFFSET_BARS, + fixRightEdge: false, + }); + } + + function applyDefaultVisibleRange() { + if (!chart || !lastCandles.length) return; + function applyOnce() { + if (!chart || !lastCandles.length) return; + const r = tailVisibleLogicalRange(lastCandles.length); + if (!r) return; + applyChartRightGap(); + restoreVisibleLogicalRange(r, lastCandles.length); + updateVisibleRangeMarkers(); + } + applyOnce(); + requestAnimationFrame(applyOnce); + setTimeout(applyOnce, 0); + } + + function updateVisibleRangeMarkers() { + clearMarkers(); + if (!candleSeries || !chart || !lastCandles.length) return; + + const range = chart.timeScale().getVisibleLogicalRange(); + if (!range) return; + + const from = Math.max(0, Math.floor(range.from)); + const to = Math.min(lastCandles.length - 1, Math.ceil(range.to)); + if (to < from) return; + + let hi = null; + let lo = null; + for (let i = from; i <= to; i++) { + const c = lastCandles[i]; + if (!c) continue; + if (!hi || c.high > hi.high) hi = c; + if (!lo || c.low < lo.low) lo = c; + } + if (!hi || !lo) return; + + rangeMarkers.push( + candleSeries.createPriceLine({ + price: Number(roundToTick(hi.high)), + color: "#ffb84d", + lineWidth: 1, + lineStyle: 2, + axisLabelVisible: true, + title: "高点", + }) + ); + rangeMarkers.push( + candleSeries.createPriceLine({ + price: Number(roundToTick(lo.low)), + color: "#4cd97f", + lineWidth: 1, + lineStyle: 2, + axisLabelVisible: true, + title: "低点", + }) + ); + } + + function readQuery() { + const qs = new URLSearchParams(window.location.search); + const ex = qs.get("exchange_key") || qs.get("exchange") || ""; + const sym = qs.get("symbol") || ""; + const tf = qs.get("timeframe") || ""; + if (ex && elExchange) elExchange.value = ex; + if (sym && elSymbol) elSymbol.value = sym; + if (tf && elTf) elTf.value = tf; + } + + function applyDefaults() { + if (elSymbol && !elSymbol.value.trim()) elSymbol.value = "BTC/USDT"; + if (elTf && !elTf.value) elTf.value = "1d"; + } + + function currentViewSeriesKey() { + const exKey = (elExchange && elExchange.value) || ""; + const sym = (elSymbol && elSymbol.value.trim()) || ""; + const tf = (elTf && elTf.value) || "1d"; + if (!exKey || !sym) return ""; + return viewKey(exKey, sym, tf); + } + + function postChartWatch() { + const exKey = (elExchange && elExchange.value) || ""; + const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || ""; + const tf = (elTf && elTf.value) || "1d"; + if (!exKey || !sym) return Promise.resolve(); + return fetch("/api/chart/watch", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ exchange_key: exKey, symbol: sym, timeframe: tf }), + }).catch(function () {}); + } + + function postChartUnwatch() { + const exKey = (elExchange && elExchange.value) || ""; + const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || ""; + const tf = (elTf && elTf.value) || "1d"; + if (!exKey || !sym) return Promise.resolve(); + return fetch("/api/chart/unwatch", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ exchange_key: exKey, symbol: sym, timeframe: tf }), + }).catch(function () {}); + } + + function closeChartStream() { + if (chartEventSource) { + chartEventSource.close(); + chartEventSource = null; + } + } + + function handleChartStreamEvent(st) { + if (!st || st.polling) return; + const vKey = currentViewSeriesKey(); + if (!vKey) return; + const tails = st.tails || {}; + const series = st.series || {}; + const tailPack = lookupSeriesMapEntry(tails, vKey); + if (tailPack && tailPack.candles && tailPack.candles.length) { + if ( + applyIncomingTailCandles(tailPack.candles, { + price_tick: tailPack.price_tick, + series_version: tailPack.series_version, + chart_version: st.chart_version, + updated_at: tailPack.updated_at || st.updated_at, + }) + ) { + return; + } + } + const seriesEntry = lookupSeriesMapEntry(series, vKey); + const sVer = seriesEntry ? Number(seriesEntry.series_version) || 0 : 0; + const seriesChanged = sVer > 0 && sVer !== localSeriesVersion; + if (seriesChanged) { + if (lastCandles.length && vKey === lastViewKey) { + void refreshChartTail(); + } else if (!lastCandles.length && !chartDataLoading) { + void loadChart(false); + } + return; + } + if (tailPack && lastCandles.length && vKey === lastViewKey && !chartDataLoading) { + void refreshChartTail(); + return; + } + if (posContext) updateLivePosPnl(); + const ver = Number(st.chart_version) || 0; + if (ver && ver !== localChartVersion) { + localChartVersion = ver; + if (lastCandles.length && vKey === lastViewKey && !chartDataLoading) { + void refreshChartTail(); + } + } + } + + function connectChartStream() { + closeChartStream(); + const page = document.getElementById("page-market"); + if (!page || page.classList.contains("hidden")) return; + chartEventSource = new EventSource("/api/chart/stream"); + chartEventSource.addEventListener("chart", function (ev) { + try { + handleChartStreamEvent(JSON.parse(ev.data || "{}")); + } catch (_) {} + }); + chartEventSource.onerror = function () { + closeChartStream(); + if (chartSseReconnectTimer) clearTimeout(chartSseReconnectTimer); + chartSseReconnectTimer = setTimeout(function () { + const p = document.getElementById("page-market"); + if (p && !p.classList.contains("hidden")) connectChartStream(); + }, 8000); + }; + } + + function startChartWatchHeartbeat() { + stopChartWatchHeartbeat(); + void postChartWatch(); + chartWatchTimer = setInterval(function () { + const page = document.getElementById("page-market"); + if (!page || page.classList.contains("hidden")) return; + void postChartWatch(); + }, CHART_WATCH_HEARTBEAT_MS); + } + + function stopChartWatchHeartbeat() { + if (chartWatchTimer) clearInterval(chartWatchTimer); + chartWatchTimer = null; + } + + function startAutoRefresh() { + stopAutoRefresh(); + const tick = function () { + const page = document.getElementById("page-market"); + if (!page || page.classList.contains("hidden")) return; + if (lastCandles.length) { + void refreshChartTail(); + } else if (!chartDataLoading) { + void loadChart(false); + } + }; + refreshTimer = setInterval(tick, CHART_SSE_FALLBACK_MS); + tick(); + } + + function stopAutoRefresh() { + if (refreshTimer) clearInterval(refreshTimer); + refreshTimer = null; + if (chartSseReconnectTimer) { + clearTimeout(chartSseReconnectTimer); + chartSseReconnectTimer = null; + } + } + + function stopChartLive() { + stopAutoRefresh(); + stopChartWatchHeartbeat(); + closeChartStream(); + void postChartUnwatch(); + } + + function allScanTabButtons() { + return Array.prototype.slice.call(document.querySelectorAll(".market-scan-tab")); + } + + function setActiveScanTab(tab) { + activeScanTab = tab || "top20"; + allScanTabButtons().forEach(function (btn) { + const on = btn.getAttribute("data-scan-tab") === activeScanTab; + btn.classList.toggle("is-active", on); + btn.setAttribute("aria-selected", on ? "true" : "false"); + }); + } + + function mountVolRankSheet(forFullscreen) { + if (!elVolRankSheet) return; + const anchor = forFullscreen ? elVolRankAnchorFs : elVolRankAnchor; + if (!anchor || elVolRankSheet.parentElement === anchor) return; + anchor.appendChild(elVolRankSheet); + } + + function setScanSheetOpen(open, tab) { + const on = !!open; + scanSheetOpen = on; + if (tab) setActiveScanTab(tab); + if (elVolRankSheet) { + elVolRankSheet.classList.toggle("hidden", !on); + elVolRankSheet.setAttribute("aria-hidden", on ? "false" : "true"); + } + if (on) void loadScanPanel(false); + } + + function loadScanPanel(forceRefresh) { + if (activeScanTab === "top20") { + void loadVolumeRank(forceRefresh); + return; + } + void loadDivergenceScan(activeScanTab, forceRefresh); + } + + function bindVolRankPanel() { + allScanTabButtons().forEach(function (btn) { + btn.addEventListener("click", function () { + const tab = btn.getAttribute("data-scan-tab") || "top20"; + if (scanSheetOpen && activeScanTab === tab) { + setScanSheetOpen(false); + return; + } + setScanSheetOpen(true, tab); + }); + }); + document.addEventListener("pointerdown", function (ev) { + if (!elVolRankSheet || elVolRankSheet.classList.contains("hidden")) return; + const t = ev.target; + if (elVolRankSheet.contains(t)) return; + if (t && t.closest && t.closest(".market-scan-tabs")) return; + setScanSheetOpen(false); + }); + } + + function applyScanSymbolSelection(symbol, tabTf) { + if (!symbol) return; + if (elSymbol) elSymbol.value = symbol; + if (elFsSymbol) elFsSymbol.value = symbol; + if (tabTf && tabTf !== "top20") { + if (elTf) elTf.value = tabTf; + if (elFsTf) elFsTf.value = tabTf; + if (elIndMacd) elIndMacd.checked = true; + indicatorState.macd = true; + } + setScanSheetOpen(false); + loadChart(false); + } + + function renderDivergenceScan(data) { + if (!elVolRankMeta || !elVolRankList) return; + elVolRankList.innerHTML = ""; + elVolRankList.classList.add("is-div-scan-list"); + const tabLabel = (data && data.tab_label) || "背离"; + if (!data || !data.ok || !data.items || !data.items.length) { + elVolRankMeta.textContent = + (data && data.msg) || + tabLabel + ":Top20 内暂无 MACD 背离(可点「清库重拉」后重试扫描)"; + return; + } + const rankDate = data.rank_date || "—"; + const updated = data.scanned_at || data.updated_at || "—"; + elVolRankMeta.innerHTML = + "
        " + + tabLabel + + " · Top20 · MACD 档A
        " + + "
        交易日 " + + rankDate + + " · 扫描 " + + updated + + " · 共 " + + data.items.length + + " 条
        "; + const curSym = (elSymbol && elSymbol.value.trim().toUpperCase()) || ""; + const tabTf = data.tab || activeScanTab; + const tfShort = { "4h": "4h", "1d": "日线", "1w": "周线" }; + data.items.forEach(function (row) { + const li = document.createElement("li"); + li.className = "market-vol-rank-li"; + const btn = document.createElement("button"); + btn.type = "button"; + const css = row.confluence_css || "none"; + btn.className = "market-vol-rank-item is-div-scan confluence-" + css; + if (row.symbol && row.symbol.toUpperCase() === curSym) { + btn.classList.add("is-active"); + } + btn.dataset.symbol = row.symbol || ""; + const dirLabel = row.is_split + ? "分歧" + : row.tab_direction_label || row.direction_label || ""; + const confLabel = row.is_split ? "分歧" : row.confluence_kind || "—"; + const fresh = row.tab_freshness || ""; + const dirClass = + row.tab_direction === "bull" + ? "is-bull" + : row.tab_direction === "bear" + ? "is-bear" + : row.is_split + ? "is-split" + : ""; + const tfPills = []; + const tfs = row.timeframes || {}; + ["4h", "1d", "1w"].forEach(function (tf) { + if (!tfs[tf]) return; + const pillDir = tfs[tf] === "bull" ? "底" : "顶"; + tfPills.push( + '' + (tfShort[tf] || tf) + pillDir + "" + ); + }); + const subExtra = row.is_split && row.split_detail + ? '' + row.split_detail + "" + : tfPills.length + ? '' + tfPills.join("") + "" + : ""; + btn.innerHTML = + '
        ' + + '' + + confLabel + + '#' + + (row.rank || "—") + + '' + + (row.symbol || "") + + "
        " + + '
        ' + + '' + + dirLabel + + "" + + (fresh ? '' + fresh + "" : "") + + subExtra + + "
        "; + btn.addEventListener("click", function () { + applyScanSymbolSelection(row.symbol, tabTf); + }); + li.appendChild(btn); + elVolRankList.appendChild(li); + }); + } + + async function loadDivergenceScan(tab, forceRefresh) { + const exKey = (elExchange && elExchange.value) || ""; + if (!exKey || !elVolRankMeta) return; + elVolRankMeta.textContent = "扫描背离…"; + if (elVolRankList) elVolRankList.innerHTML = ""; + try { + let url = + "/api/chart/divergence-scan?exchange_key=" + + encodeURIComponent(exKey) + + "&tab=" + + encodeURIComponent(tab || "4h"); + if (forceRefresh) url += "&refresh=1"; + const r = await fetch(url, { credentials: "same-origin" }); + const data = await r.json(); + if (!r.ok) { + throw new Error((data && data.detail) || (data && data.msg) || "加载失败"); + } + renderDivergenceScan(data); + } catch (e) { + renderDivergenceScan({ ok: false, msg: String(e.message || e), tab_label: tab }); + } + } + + function renderVolumeRank(data) { + if (!elVolRankMeta || !elVolRankList) return; + elVolRankList.innerHTML = ""; + elVolRankList.classList.remove("is-div-scan-list"); + if (!data || !data.ok || !data.items || !data.items.length) { + elVolRankMeta.textContent = + (data && data.msg) || + "暂无排名数据(请 pm2 restart 三实例与 manual-trading-hub 后重试)"; + return; + } + const resetHour = data.reset_hour != null ? data.reset_hour : 8; + const rankDate = data.rank_date || "—"; + const updated = data.updated_at || "—"; + const total = data.total_symbols != null ? data.total_symbols : ""; + const count = data.items.length; + const expect = data.expected_count != null ? data.expected_count : 20; + let meta = + "昨日成交 Top" + + expect + + " · 交易日 " + + rankDate + + " · 每早 " + + resetHour + + ":00 更新 · 显示 " + + count + + "/" + + expect + + " 条"; + if (total) meta += " · 全市场 " + total + " 个"; + if (data.stale) meta += " · 数据不完整,正在重拉…"; + meta += " · " + updated; + elVolRankMeta.textContent = meta; + const curSym = (elSymbol && elSymbol.value.trim().toUpperCase()) || ""; + data.items.forEach(function (row) { + const li = document.createElement("li"); + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "market-vol-rank-item"; + if (row.symbol && row.symbol.toUpperCase() === curSym) { + btn.classList.add("is-active"); + } + btn.dataset.symbol = row.symbol || ""; + btn.innerHTML = + '' + + (row.rank || "") + + '' + + (row.symbol || "") + + '' + + (row.volume_label || "") + + ""; + btn.addEventListener("click", function () { + applyScanSymbolSelection(row.symbol, "top20"); + }); + li.appendChild(btn); + elVolRankList.appendChild(li); + }); + } + + async function loadVolumeRank(forceRefresh) { + const exKey = (elExchange && elExchange.value) || ""; + if (!exKey || !elVolRankMeta) return; + elVolRankMeta.textContent = "加载排名…"; + if (elVolRankList) elVolRankList.innerHTML = ""; + try { + let url = "/api/chart/volume-rank?exchange_key=" + encodeURIComponent(exKey); + if (forceRefresh) url += "&refresh=1"; + const r = await fetch(url, { credentials: "same-origin" }); + const data = await r.json(); + if (!r.ok) { + throw new Error((data && data.detail) || (data && data.msg) || "加载失败"); + } + renderVolumeRank(data); + const expect = data.expected_count != null ? data.expected_count : 20; + if (!forceRefresh && data.ok && data.items && data.items.length < expect) { + void loadVolumeRank(true); + } + } catch (e) { + renderVolumeRank({ ok: false, msg: String(e.message || e) }); + } + } + + async function loadMeta() { + const r = await fetch("/api/chart/meta", { credentials: "same-origin" }); + chartMeta = await r.json(); + if (!elExchange || !chartMeta.exchanges) return; + elExchange.innerHTML = ""; + chartMeta.exchanges.forEach(function (ex) { + const opt = document.createElement("option"); + opt.value = ex.key || ex.id; + opt.textContent = ex.name || ex.key; + elExchange.appendChild(opt); + }); + populateFsExchangeOptions(); + readQuery(); + applyDefaults(); + updateExchangeDisplay(); + } + + async function loadChart(force, options) { + options = options || {}; + const autoTick = !!options.autoTick; + if (autoTick) { + return refreshChartTail(); + } + localSeriesVersion = 0; + void postChartWatch(); + if (!ensureChart()) return; + const exKey = (elExchange && elExchange.value) || ""; + const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || ""; + const tf = (elTf && elTf.value) || "1d"; + currentTf = tf; + if (!exKey || !sym) { + if (elStatus) { + elStatus.className = "market-status err"; + elStatus.textContent = "请选择交易所并输入币种"; + } + return; + } + const myToken = ++loadToken; + const vKey = viewKey(exKey, sym, tf); + const resetView = !!force || vKey !== lastViewKey; + chartDataLoading = true; + if (resetView) { + chartViewEpoch += 1; + chartRangeUserLocked = false; + if (chartRangeLockTimer) { + clearTimeout(chartRangeLockTimer); + chartRangeLockTimer = null; + } + resetChartHistoryState(); + lastViewKey = ""; + clearChartSeriesData(); + } + if (elStatus) { + elStatus.className = "market-status"; + elStatus.textContent = "加载中…"; + } + updateHeaderLabels(sym, tf); + + try { + const data = await fetchChartChunk({ + exchange_key: exKey, + symbol: sym, + timeframe: tf, + limit: chartInitialLimit(tf), + refresh: !!force, + }); + if (myToken !== loadToken) return; + if (!data.ok || !data.candles || !data.candles.length) { + throw new Error(data.msg || "无 K 线"); + } + + priceTick = data.price_tick; + try { + applyChartPriceFormat(); + } catch (fmtErr) { + priceTick = null; + applyChartPriceFormat(); + } + applyCandlesToChart(alignCandlesToTick(data.candles), 0); + lastViewKey = vKey; + ensureDrawLayer(); + syncDrawViewKey(); + if (resetView) { + applyDefaultVisibleRange(); + } + syncPosContextForView(exKey, sym); + if (posContext) { + updateLivePosPnl(); + refreshPosPnlFromBoard(); + } + scheduleChartResize(); + + const limit = data.limit || lastCandles.length; + let hint = + "已加载 " + + lastCandles.length + + " 根(首屏 " + + limit + + ")· 库 " + + (data.from_cache || 0) + + " / 新拉 " + + (data.fetched || 0) + + (data.cleared ? " · 清库 " + data.cleared : "") + + " · 左拖加载更多 · 后台 " + + (data.chart_poll_interval_sec || 5) + + "s"; + if (data.stale && data.stale_message) { + hint += " · 缓存:" + data.stale_message; + } + if (elStatus) { + elStatus.className = data.stale ? "market-status warn" : "market-status"; + elStatus.textContent = hint; + } + if (elUpdated) elUpdated.textContent = "数据 " + (data.updated_at || "--"); + if (data.series_version != null) localSeriesVersion = Number(data.series_version) || localSeriesVersion; + if (data.chart_version != null) localChartVersion = Number(data.chart_version) || localChartVersion; + tickLiveClock(); + } catch (e) { + if (myToken !== loadToken) return; + if (elStatus) { + elStatus.className = "market-status err"; + elStatus.textContent = String(e.message || e); + } + } finally { + if (myToken === loadToken) chartDataLoading = false; + } + } + + function bind() { + bindSlDrag(); + bindVolRankPanel(); + if (elRefresh) { + elRefresh.addEventListener("click", function () { + loadChart(true); + }); + } + if (elTf) { + elTf.addEventListener("change", function () { + tfDigitBuf = ""; + if (tfDigitTimer) { + clearTimeout(tfDigitTimer); + tfDigitTimer = null; + } + currentTf = (elTf && elTf.value) || "1d"; + lastViewKey = ""; + tickLiveClock(); + syncFsToolbarFromMain(); + loadChart(false); + }); + } + if (elExchange) { + elExchange.addEventListener("change", function () { + updateExchangeDisplay(); + syncFsToolbarFromMain(); + lastViewKey = ""; + if (elVolRankSheet && !elVolRankSheet.classList.contains("hidden")) { + void loadVolumeRank(); + } + loadChart(false); + }); + } + if (elSymbol) { + elSymbol.addEventListener("keydown", function (e) { + if (e.key === "Enter") loadChart(false); + }); + elSymbol.addEventListener("change", function () { + loadChart(false); + }); + } + const btnLoad = document.getElementById("market-load"); + if (btnLoad) { + btnLoad.addEventListener("click", function () { + loadChart(false); + }); + } + if (elPriceAuto) { + elPriceAuto.addEventListener("click", function () { + priceAutoScale = !priceAutoScale; + applyPriceAutoScale(); + if (priceAutoScale) applyDefaultVisibleRange(); + }); + } + if (elPosClear) { + elPosClear.addEventListener("click", function () { + clearPosContext(); + }); + } + if (elFsBtn) { + elFsBtn.addEventListener("click", function () { + toggleChartFullscreen(); + }); + } + if (elFsExit) { + elFsExit.addEventListener("click", function () { + setChartFullscreen(false); + }); + } + [elIndEma, elIndEma144, elIndMacd, elIndRsi].forEach(function (el) { + if (!el) return; + el.addEventListener("change", function () { + updateIndicators(); + }); + }); + if (elPrevCloseLine) { + elPrevCloseLine.checked = loadPrevCloseLinePref(); + elPrevCloseLine.addEventListener("change", syncPrevDayLineUi); + } + if (elPrevHlLines) { + elPrevHlLines.checked = loadPrevHlLinesPref(); + elPrevHlLines.addEventListener("change", syncPrevDayLineUi); + } + if (elDaySplit) { + elDaySplit.checked = loadDaySplitPref(); + elDaySplit.addEventListener("change", syncTradingDaySplitUi); + applyTradingDaySplit(elDaySplit.checked); + } + const pageMarket = document.getElementById("page-market"); + const fsKeyTargets = [window, pageMarket, elChartWrap, chartHost].filter(Boolean); + fsKeyTargets.forEach(function (el) { + el.addEventListener("keydown", onChartFullscreenKey, true); + }); + window.addEventListener("keydown", onMarketKeydown, true); + if (elChartWrap) { + if (!elChartWrap.hasAttribute("tabindex")) elChartWrap.setAttribute("tabindex", "-1"); + elChartWrap.addEventListener("mousedown", focusMarketChartArea); + } + if (elFsExchange) { + elFsExchange.addEventListener("change", function () { + syncMainFromFsToolbar(); + loadChart(false); + }); + } + if (elFsTf) { + elFsTf.addEventListener("change", function () { + currentTf = elFsTf.value || "1d"; + lastViewKey = ""; + syncMainFromFsToolbar(); + tickLiveClock(); + loadChart(false); + }); + } + if (elFsSymbol) { + elFsSymbol.addEventListener("keydown", function (e) { + if (e.key === "Enter") { + syncMainFromFsToolbar(); + loadChart(false); + } + }); + } + if (elFsLoad) { + elFsLoad.addEventListener("click", function () { + syncMainFromFsToolbar(); + loadChart(false); + }); + } + } + + window.hubMarketChart = { + init: async function () { + if (!marketInited) { + marketInited = true; + await loadMeta(); + bind(); + } else { + readQuery(); + } + focusMarketChartArea(); + connectChartStream(); + startChartWatchHeartbeat(); + startAutoRefresh(); + await loadChart(false); + startPriceTagTimer(); + }, + openWith: async function (exKey, sym, tf) { + if (!marketInited) { + await this.init(); + } + if (elExchange && exKey) elExchange.value = exKey; + if (elSymbol && sym) elSymbol.value = String(sym).trim().toUpperCase(); + if (tf && elTf) elTf.value = tf; + lastViewKey = ""; + localSeriesVersion = 0; + updateExchangeDisplay(); + connectChartStream(); + startChartWatchHeartbeat(); + startAutoRefresh(); + await loadChart(false); + startPriceTagTimer(); + }, + reload: function (force) { + loadChart(!!force); + }, + startAutoRefresh: startAutoRefresh, + stopAutoRefresh: stopAutoRefresh, + stopChartLive: stopChartLive, + stopPriceTagTimer: stopPriceTagTimer, + }; + + document.addEventListener("hub-theme-change", function () { + applyChartTheme(); + }); + + if ( + document.getElementById("page-market") && + !document.getElementById("page-market").classList.contains("hidden") + ) { + window.hubMarketChart.init(); + } +})(); diff --git a/manual_trading_hub/static/chart_draw.js b/manual_trading_hub/static/chart_draw.js new file mode 100644 index 0000000..6832f35 --- /dev/null +++ b/manual_trading_hub/static/chart_draw.js @@ -0,0 +1,1462 @@ +/** + * 行情区左侧画线工具(canvas 叠加层,坐标与 Lightweight Charts 对齐). + */ +(function () { + const STORAGE_PREFIX = "hubMarketDraw:"; + const HIT_PX = 8; + const FIB_LEVELS = [0, 0.236, 0.382, 0.5, 0.618, 0.786, 0.886, 1]; + const FIB_LINE_COLORS = { + 0: "#787b86", + 0.236: "#f23645", + 0.382: "#e6b422", + 0.5: "#5d606b", + 0.618: "#d97706", + 0.786: "#26a69a", + 0.886: "#9c27b0", + 1: "#11734b", + }; + const FIB_ZONE_FILLS = [ + { top: 1, bot: 0.886, fill: "rgba(156, 39, 176, 0.14)" }, + { top: 0.886, bot: 0.786, fill: "rgba(38, 166, 154, 0.14)" }, + { top: 0.786, bot: 0.618, fill: "rgba(0, 188, 212, 0.14)" }, + { top: 0.618, bot: 0.5, fill: "rgba(244, 143, 177, 0.16)" }, + { top: 0.5, bot: 0.382, fill: "rgba(120, 123, 134, 0.12)" }, + { top: 0.382, bot: 0.236, fill: "rgba(255, 183, 77, 0.16)" }, + { top: 0.236, bot: 0, fill: "rgba(242, 54, 69, 0.12)" }, + ]; + const DRAG_TOOLS = new Set(["trend", "rect", "range", "fib"]); + const ONE_SHOT_TOOLS = new Set([ + "hline", "cross", "channel", "rect", "brush", "range", "text", "fib", "trend", "path", "erase", + ]); + const MIN_DRAG_PX = 6; + + const TOOL_LABELS = { + cursor: "光标", + hline: "水平线", + cross: "十字线", + channel: "平行通道", + rect: "矩形", + brush: "画笔", + range: "价格测距", + text: "文字", + fib: "斐波那契", + trend: "趋势线", + path: "折线", + erase: "删除选中", + clear: "清除全部", + }; + + let chart = null; + let series = null; + let hostEl = null; + let mainEl = null; + let canvasEl = null; + let toolbarEl = null; + let viewKey = ""; + let activeTool = "cursor"; + let drawings = []; + let draft = null; + let selectedId = null; + let redrawRaf = 0; + let unsubRange = null; + let getCandlesFn = null; + let brushPointerId = null; + let dragActive = false; + let dragStartPx = null; + let pathPreviewPt = null; + let menuEl = null; + let unsubClick = null; + let mainBound = false; + let tradingDaySplitEnabled = false; + const BJ_OFFSET_SEC = 8 * 60 * 60; + + function uid() { + return "d" + Date.now().toString(36) + Math.random().toString(36).slice(2, 7); + } + + function storageKey() { + return STORAGE_PREFIX + (viewKey || "default"); + } + + function loadDrawings() { + try { + const raw = localStorage.getItem(storageKey()); + if (!raw) return []; + const arr = JSON.parse(raw); + return Array.isArray(arr) ? arr : []; + } catch (_) { + return []; + } + } + + function saveDrawings() { + try { + localStorage.setItem(storageKey(), JSON.stringify(drawings)); + } catch (_) {} + } + + function setChartInteraction(enabled) { + if (!chart) return; + const on = !!enabled; + chart.applyOptions({ + handleScroll: { + mouseWheel: on, + pressedMouseMove: on, + horzTouchDrag: on, + vertTouchDrag: false, + }, + handleScale: { + axisPressedMouseMove: on, + mouseWheel: on, + pinch: on, + }, + }); + } + + function syncCanvasSize() { + if (!canvasEl || !hostEl) return; + const w = hostEl.clientWidth; + const h = hostEl.clientHeight; + if (w < 1 || h < 1) return; + const dpr = window.devicePixelRatio || 1; + canvasEl.width = Math.floor(w * dpr); + canvasEl.height = Math.floor(h * dpr); + canvasEl.style.width = w + "px"; + canvasEl.style.height = h + "px"; + const ctx = canvasEl.getContext("2d"); + if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + } + + function getCandles() { + if (typeof getCandlesFn === "function") { + const rows = getCandlesFn(); + return Array.isArray(rows) ? rows : []; + } + return []; + } + + function timeToX(time) { + if (!chart || time == null) return null; + try { + const x = chart.timeScale().timeToCoordinate(time); + return x == null || !Number.isFinite(x) ? null : x; + } catch (_) { + return null; + } + } + + function priceToY(price) { + if (!series || price == null || !Number.isFinite(Number(price))) return null; + try { + const y = series.priceToCoordinate(Number(price)); + return y == null || !Number.isFinite(y) ? null : y; + } catch (_) { + return null; + } + } + + function xToTime(x) { + if (!chart) return null; + try { + const direct = chart.timeScale().coordinateToTime(x); + if (direct != null) return direct; + } catch (_) {} + const candles = getCandles(); + if (!candles.length) return null; + let bestTime = candles[0].time; + let bestDist = Infinity; + candles.forEach(function (c) { + const cx = timeToX(c.time); + if (cx == null) return; + const d = Math.abs(cx - x); + if (d < bestDist) { + bestDist = d; + bestTime = c.time; + } + }); + return bestTime; + } + + function yToPrice(y) { + if (!series) return null; + try { + const direct = series.coordinateToPrice(y); + if (direct != null && Number.isFinite(Number(direct))) return Number(direct); + } catch (_) {} + const candles = getCandles(); + if (!candles.length) return null; + let lo = null; + let hi = null; + candles.forEach(function (c) { + const vals = [c.low, c.high, c.open, c.close]; + vals.forEach(function (v) { + const n = Number(v); + if (!Number.isFinite(n)) return; + if (lo == null || n < lo) lo = n; + if (hi == null || n > hi) hi = n; + }); + }); + if (lo == null || hi == null) return null; + const yLo = priceToY(lo); + const yHi = priceToY(hi); + if (yLo == null || yHi == null || Math.abs(yHi - yLo) < 1e-6) return (lo + hi) / 2; + const ratio = (y - yLo) / (yHi - yLo); + return lo + (hi - lo) * ratio; + } + + function xyToPoint(x, y) { + if (!chart || !series) return null; + const time = xToTime(x); + const price = yToPrice(y); + if (time == null || price == null || !Number.isFinite(price)) return null; + return { time: time, price: price }; + } + + function clientToLocal(ev) { + const rect = (hostEl || canvasEl).getBoundingClientRect(); + return { x: ev.clientX - rect.left, y: ev.clientY - rect.top }; + } + + function mountCanvasOverlay() { + if (!canvasEl || !hostEl) return; + if (canvasEl.parentElement !== hostEl) { + hostEl.appendChild(canvasEl); + } + canvasEl.style.position = "absolute"; + canvasEl.style.top = "0"; + canvasEl.style.left = "0"; + canvasEl.style.width = "100%"; + canvasEl.style.height = "100%"; + } + + function scheduleRedraw() { + if (redrawRaf) cancelAnimationFrame(redrawRaf); + redrawRaf = requestAnimationFrame(function () { + redrawRaf = 0; + redraw(); + }); + } + + function strokeStyle(selected) { + return selected ? "#f59e0b" : "#60a5fa"; + } + + function formatPrice(p) { + const n = Number(p); + if (!Number.isFinite(n)) return "—"; + const a = Math.abs(n); + if (a >= 10000) return n.toFixed(2); + if (a >= 1) return n.toFixed(4); + return n.toFixed(6); + } + + function signedPrice(n) { + const s = formatPrice(Math.abs(n)); + return n < 0 ? "-" + s : s; + } + + function estimateTickSize(price) { + const a = Math.abs(Number(price)) || 1; + if (a >= 10000) return 0.01; + if (a >= 100) return 0.01; + if (a >= 1) return 0.0001; + if (a >= 0.01) return 0.000001; + return 0.0000001; + } + + function tickCount(diff, refPrice) { + const step = estimateTickSize(refPrice); + if (!step) return 0; + return Math.round(diff / step); + } + + function fibPriceAt(top, bot, lv) { + return bot + (top - bot) * (1 - lv); + } + + function drawHandle(ctx, x, y, large, color) { + if (x == null || y == null) return; + const r = large ? 6 : 4.5; + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + ctx.fillStyle = "#ffffff"; + ctx.fill(); + ctx.strokeStyle = color || "#2962ff"; + ctx.lineWidth = large ? 2 : 1.5; + ctx.stroke(); + } + + function roundBadge(ctx, x, y, text) { + ctx.font = "11px sans-serif"; + const padX = 8; + const padY = 5; + const tw = ctx.measureText(text).width; + const bw = tw + padX * 2; + const bh = 20; + const left = x - bw / 2; + const top = y - bh / 2; + ctx.fillStyle = "rgba(30, 58, 138, 0.92)"; + ctx.beginPath(); + const r = 4; + ctx.moveTo(left + r, top); + ctx.lineTo(left + bw - r, top); + ctx.quadraticCurveTo(left + bw, top, left + bw, top + r); + ctx.lineTo(left + bw, top + bh - r); + ctx.quadraticCurveTo(left + bw, top + bh, left + bw - r, top + bh); + ctx.lineTo(left + r, top + bh); + ctx.quadraticCurveTo(left, top + bh, left, top + bh - r); + ctx.lineTo(left, top + r); + ctx.quadraticCurveTo(left, top, left + r, top); + ctx.closePath(); + ctx.fill(); + ctx.fillStyle = "#f8fafc"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(text, x, y + 1); + ctx.textAlign = "left"; + ctx.textBaseline = "alphabetic"; + } + + function isDragTool(tool) { + return DRAG_TOOLS.has(tool); + } + + function cancelDraft() { + draft = null; + dragActive = false; + dragStartPx = null; + pathPreviewPt = null; + scheduleRedraw(); + } + + function returnToCursorIfOneShot() { + if (ONE_SHOT_TOOLS.has(activeTool)) { + setActiveTool("cursor"); + } + } + + function drawLine(ctx, x1, y1, x2, y2, selected) { + if (x1 == null || y1 == null || x2 == null || y2 == null) return; + ctx.beginPath(); + ctx.strokeStyle = strokeStyle(selected); + ctx.lineWidth = selected ? 2 : 1.5; + ctx.setLineDash([]); + ctx.moveTo(x1, y1); + ctx.lineTo(x2, y2); + ctx.stroke(); + } + + function drawHLine(ctx, y, w, selected) { + if (y == null) return; + drawLine(ctx, 0, y, w, y, selected); + } + + function drawVLine(ctx, x, h, selected) { + if (x == null) return; + drawLine(ctx, x, 0, x, h, selected); + } + + function utcSecToBjParts(utcSec) { + const d = new Date((Number(utcSec) + BJ_OFFSET_SEC) * 1000); + return { + y: d.getUTCFullYear(), + m: d.getUTCMonth(), + d: d.getUTCDate(), + h: d.getUTCHours(), + }; + } + + function collectTradingDayBoundaries(candles) { + if (!candles.length) return []; + const minT = Number(candles[0].time); + const maxT = Number(candles[candles.length - 1].time); + const minP = utcSecToBjParts(minT); + const maxP = utcSecToBjParts(maxT); + const out = []; + let curMs = Date.UTC(minP.y, minP.m, minP.d) - 86400000; + const endMs = Date.UTC(maxP.y, maxP.m, maxP.d) + 2 * 86400000; + while (curMs <= endMs) { + const boundary = Math.floor(curMs / 1000); + if (boundary >= minT - 3600 && boundary <= maxT + 3600) { + if (!out.length || out[out.length - 1] !== boundary) { + out.push(boundary); + } + } + curMs += 86400000; + } + return out; + } + + function drawTradingDaySplits(ctx, w, h) { + if (!tradingDaySplitEnabled || !chart) return; + const candles = getCandles(); + if (!candles.length) return; + const boundaries = collectTradingDayBoundaries(candles); + if (!boundaries.length) return; + ctx.save(); + ctx.strokeStyle = "#3b82f6"; + ctx.lineWidth = 1; + ctx.setLineDash([5, 4]); + boundaries.forEach(function (t) { + const x = timeToX(t); + if (x == null || !Number.isFinite(x) || x < -2 || x > w + 2) return; + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, h); + ctx.stroke(); + }); + ctx.setLineDash([]); + ctx.restore(); + } + + function drawRect(ctx, x1, y1, x2, y2, selected) { + if (x1 == null || y1 == null || x2 == null || y2 == null) return; + const l = Math.min(x1, x2); + const t = Math.min(y1, y2); + const rw = Math.abs(x2 - x1); + const rh = Math.abs(y2 - y1); + ctx.strokeStyle = strokeStyle(selected); + ctx.lineWidth = selected ? 2 : 1.5; + ctx.setLineDash([]); + ctx.strokeRect(l, t, rw, rh); + ctx.fillStyle = selected ? "rgba(245,158,11,0.08)" : "rgba(96,165,250,0.06)"; + ctx.fillRect(l, t, rw, rh); + } + + function drawBrush(ctx, pts, selected) { + if (!pts || pts.length < 2) return; + ctx.beginPath(); + ctx.strokeStyle = strokeStyle(selected); + ctx.lineWidth = selected ? 2.5 : 2; + ctx.lineJoin = "round"; + ctx.lineCap = "round"; + let started = false; + pts.forEach(function (p) { + const x = timeToX(p.time); + const y = priceToY(p.price); + if (x == null || y == null) return; + if (!started) { + ctx.moveTo(x, y); + started = true; + } else { + ctx.lineTo(x, y); + } + }); + if (started) ctx.stroke(); + } + + function drawFib(ctx, p1, p2, w, selected) { + if (!p1 || !p2) return; + const top = Math.max(p1.price, p2.price); + const bot = Math.min(p1.price, p2.price); + const x1 = timeToX(p1.time); + const x2 = timeToX(p2.time); + const y1 = priceToY(p1.price); + const y2 = priceToY(p2.price); + const yTop = priceToY(top); + const yBot = priceToY(bot); + if (x1 == null || x2 == null || yTop == null || yBot == null || y1 == null || y2 == null) return; + const left = Math.min(x1, x2); + const right = Math.max(x1, x2); + const span = Math.max(right - left, 48); + const drawRight = left + span; + + FIB_ZONE_FILLS.forEach(function (zone) { + const yA = priceToY(fibPriceAt(top, bot, zone.top)); + const yB = priceToY(fibPriceAt(top, bot, zone.bot)); + if (yA == null || yB == null) return; + const zt = Math.min(yA, yB); + const zb = Math.max(yA, yB); + ctx.fillStyle = zone.fill; + ctx.fillRect(left, zt, drawRight - left, Math.max(zb - zt, 1)); + }); + + ctx.beginPath(); + ctx.strokeStyle = "rgba(120, 123, 134, 0.7)"; + ctx.lineWidth = 1; + ctx.setLineDash([4, 4]); + ctx.moveTo(x1, y1); + ctx.lineTo(x2, y2); + ctx.stroke(); + ctx.setLineDash([]); + + ctx.strokeStyle = selected ? "#f59e0b" : "#787b86"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(left, yTop); + ctx.lineTo(left, yBot); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(drawRight, yTop); + ctx.lineTo(drawRight, yBot); + ctx.stroke(); + + let lastLabelY = -9999; + FIB_LEVELS.forEach(function (lv) { + const price = fibPriceAt(top, bot, lv); + const y = priceToY(price); + if (y == null) return; + const lineColor = FIB_LINE_COLORS[lv] || "#787b86"; + ctx.beginPath(); + ctx.strokeStyle = lineColor; + ctx.lineWidth = 1; + ctx.setLineDash(lv === 0 || lv === 1 ? [] : [5, 4]); + ctx.moveTo(left, y); + ctx.lineTo(drawRight, y); + ctx.stroke(); + if (Math.abs(y - lastLabelY) < 13) return; + lastLabelY = y; + const lvLabel = lv === 1 || lv === 0 ? String(lv) : String(lv); + ctx.fillStyle = lineColor; + ctx.font = "10px sans-serif"; + ctx.fillText(lvLabel + " (" + formatPrice(price) + ")", drawRight + 6, y + 3); + }); + ctx.setLineDash([]); + if (selected) { + drawHandle(ctx, x1, y1, false, "#2962ff"); + drawHandle(ctx, x2, y2, false, "#2962ff"); + } + } + + function drawRange(ctx, p1, p2, selected) { + const x1 = timeToX(p1.time); + const x2 = timeToX(p2.time); + const y1 = priceToY(p1.price); + const y2 = priceToY(p2.price); + if (x1 == null || x2 == null || y1 == null || y2 == null) return; + const left = Math.min(x1, x2); + const right = Math.max(x1, x2); + const top = Math.min(y1, y2); + const bot = Math.max(y1, y2); + const boxW = Math.max(right - left, 10); + const boxH = Math.max(bot - top, 6); + const borderColor = selected ? "#f59e0b" : "#1e293b"; + + ctx.fillStyle = selected ? "rgba(245,158,11,0.18)" : "rgba(45, 212, 191, 0.22)"; + ctx.strokeStyle = borderColor; + ctx.lineWidth = 1; + ctx.setLineDash([]); + ctx.fillRect(left, top, boxW, boxH); + ctx.strokeRect(left, top, boxW, boxH); + + const midX = left + boxW / 2; + ctx.beginPath(); + ctx.strokeStyle = borderColor; + ctx.lineWidth = 1; + ctx.moveTo(midX, top); + ctx.lineTo(midX, bot); + ctx.stroke(); + const arrowY = bot + 2; + ctx.beginPath(); + ctx.fillStyle = borderColor; + ctx.moveTo(midX, arrowY + 7); + ctx.lineTo(midX - 5, arrowY); + ctx.lineTo(midX + 5, arrowY); + ctx.closePath(); + ctx.fill(); + + const diff = p2.price - p1.price; + const pct = p1.price ? (diff / p1.price) * 100 : 0; + const ticks = tickCount(diff, p1.price); + const tickStr = ticks < 0 ? String(ticks) : ticks > 0 ? "+" + ticks : "0"; + const badgeText = + signedPrice(diff) + " (" + (pct >= 0 ? "+" : "") + pct.toFixed(2) + "%) " + tickStr; + roundBadge(ctx, midX, bot + 22, badgeText); + + drawHandle(ctx, left, top, false, "#2962ff"); + drawHandle(ctx, right, bot, false, "#2962ff"); + } + + function drawText(ctx, p, text, selected) { + const x = timeToX(p.time); + const y = priceToY(p.price); + if (x == null || y == null) return; + ctx.font = "12px sans-serif"; + ctx.fillStyle = strokeStyle(selected); + ctx.fillText(String(text || ""), x + 4, y - 4); + } + + function parallelOffset(p1, p2, p3) { + const x1 = timeToX(p1.time); + const y1 = priceToY(p1.price); + const x2 = timeToX(p2.time); + const y2 = priceToY(p2.price); + const x3 = timeToX(p3.time); + const y3 = priceToY(p3.price); + if (x1 == null || y1 == null || x2 == null || y2 == null || x3 == null || y3 == null) { + return 0; + } + const dx = x2 - x1; + const dy = y2 - y1; + const len = Math.hypot(dx, dy) || 1; + const nx = -dy / len; + const ny = dx / len; + return (x3 - x1) * nx + (y3 - y1) * ny; + } + + function drawChannel(ctx, p1, p2, offset, w, selected) { + const x1 = timeToX(p1.time); + const y1 = priceToY(p1.price); + const x2 = timeToX(p2.time); + const y2 = priceToY(p2.price); + if (x1 == null || y1 == null || x2 == null || y2 == null) return; + const dx = x2 - x1; + const dy = y2 - y1; + const len = Math.hypot(dx, dy) || 1; + const nx = -dy / len; + const ny = dx / len; + const ox = nx * offset; + const oy = ny * offset; + drawLine(ctx, x1, y1, x2, y2, selected); + drawLine(ctx, x1 + ox, y1 + oy, x2 + ox, y2 + oy, selected); + ctx.fillStyle = selected ? "rgba(245,158,11,0.06)" : "rgba(96,165,250,0.05)"; + ctx.beginPath(); + ctx.moveTo(x1, y1); + ctx.lineTo(x2, y2); + ctx.lineTo(x2 + ox, y2 + oy); + ctx.lineTo(x1 + ox, y1 + oy); + ctx.closePath(); + ctx.fill(); + } + + function drawPath(ctx, pts, selected, previewPt) { + if (!pts || !pts.length) return; + const color = strokeStyle(selected); + const coords = []; + pts.forEach(function (p) { + const x = timeToX(p.time); + const y = priceToY(p.price); + if (x != null && y != null) coords.push({ x: x, y: y }); + }); + if (coords.length < 1) return; + + ctx.beginPath(); + ctx.strokeStyle = color; + ctx.lineWidth = selected ? 2 : 1.5; + ctx.lineJoin = "round"; + ctx.lineCap = "round"; + ctx.setLineDash([]); + ctx.moveTo(coords[0].x, coords[0].y); + for (let i = 1; i < coords.length; i++) { + ctx.lineTo(coords[i].x, coords[i].y); + } + if (coords.length > 1) ctx.stroke(); + + if (previewPt) { + const px = timeToX(previewPt.time); + const py = priceToY(previewPt.price); + const last = coords[coords.length - 1]; + if (px != null && py != null && last) { + ctx.beginPath(); + ctx.strokeStyle = color; + ctx.lineWidth = 1.5; + ctx.setLineDash([5, 4]); + ctx.moveTo(last.x, last.y); + ctx.lineTo(px, py); + ctx.stroke(); + ctx.setLineDash([]); + drawHandle(ctx, px, py, true, color); + } + } + + coords.forEach(function (c, idx) { + const isLast = idx === coords.length - 1; + drawHandle(ctx, c.x, c.y, isLast && !!previewPt, color); + }); + } + + function renderDrawing(ctx, d, w, h, selected, previewPt) { + const pts = d.points || []; + if (!pts.length) return; + switch (d.type) { + case "hline": + drawHLine(ctx, priceToY(pts[0].price), w, selected); + break; + case "cross": + drawHLine(ctx, priceToY(pts[0].price), w, selected); + drawVLine(ctx, timeToX(pts[0].time), h, selected); + break; + case "trend": + if (pts.length >= 2) { + drawLine( + ctx, + timeToX(pts[0].time), + priceToY(pts[0].price), + timeToX(pts[1].time), + priceToY(pts[1].price), + selected + ); + } + break; + case "channel": + if (pts.length >= 3) { + drawChannel(ctx, pts[0], pts[1], d.offset || 0, w, selected); + } else if (pts.length === 2) { + drawLine( + ctx, + timeToX(pts[0].time), + priceToY(pts[0].price), + timeToX(pts[1].time), + priceToY(pts[1].price), + selected + ); + } + break; + case "rect": + if (pts.length >= 2) { + drawRect( + ctx, + timeToX(pts[0].time), + priceToY(pts[0].price), + timeToX(pts[1].time), + priceToY(pts[1].price), + selected + ); + } + break; + case "brush": + drawBrush(ctx, pts, selected); + break; + case "range": + if (pts.length >= 2) drawRange(ctx, pts[0], pts[1], selected); + break; + case "text": + drawText(ctx, pts[0], d.text, selected); + break; + case "fib": + if (pts.length >= 2) drawFib(ctx, pts[0], pts[1], w, selected); + break; + case "path": + drawPath(ctx, pts, selected, previewPt || null); + break; + default: + break; + } + } + + function redraw() { + if (!canvasEl) return; + syncCanvasSize(); + const ctx = canvasEl.getContext("2d"); + if (!ctx) return; + const w = hostEl.clientWidth; + const h = hostEl.clientHeight; + ctx.clearRect(0, 0, w, h); + drawTradingDaySplits(ctx, w, h); + drawings.forEach(function (d) { + if (d.hidden) ctx.globalAlpha = 0.14; + renderDrawing(ctx, d, w, h, d.id === selectedId); + if (d.hidden) ctx.globalAlpha = 1; + }); + if (draft) { + const preview = draft.type === "path" ? pathPreviewPt : null; + renderDrawing(ctx, draft, w, h, true, preview); + } + if (activeTool === "cursor" && selectedId) { + const sel = getDrawingById(selectedId); + if (sel) drawSelectionOverlay(ctx, sel); + } + } + + function commitDrawing(d) { + if (!d || !d.type) return; + d.id = uid(); + drawings.push(d); + selectedId = d.id; + draft = null; + pathPreviewPt = null; + saveDrawings(); + scheduleRedraw(); + returnToCursorIfOneShot(); + } + + function finishPath() { + if (draft && draft.type === "path" && draft.points.length > 1) { + commitDrawing({ type: "path", points: draft.points.slice() }); + } else { + cancelDraft(); + } + } + + function pointsNeeded(tool) { + if (tool === "hline" || tool === "cross" || tool === "text") return 1; + if (tool === "trend" || tool === "rect" || tool === "range" || tool === "fib") return 2; + if (tool === "channel") return 3; + return 0; + } + + function getDrawingById(id) { + for (let i = 0; i < drawings.length; i++) { + if (drawings[i].id === id) return drawings[i]; + } + return null; + } + + function pickDrawingAt(x, y) { + for (let i = drawings.length - 1; i >= 0; i--) { + if (hitTestDrawing(drawings[i], x, y)) return drawings[i]; + } + return null; + } + + function selectDrawing(id) { + selectedId = id || null; + scheduleRedraw(); + } + + function deselectDrawing() { + if (!selectedId) return; + selectedId = null; + hideContextMenu(); + scheduleRedraw(); + } + + function removeDrawing(id, opts) { + if (!id) return; + const force = !!(opts && opts.force); + const d = getDrawingById(id); + if (!d) return; + if (d.locked && !force) return; + drawings = drawings.filter(function (item) { + return item.id !== id; + }); + if (selectedId === id) selectedId = null; + hideContextMenu(); + saveDrawings(); + scheduleRedraw(); + } + + function removeSelectedDrawing() { + if (!selectedId) return; + removeDrawing(selectedId); + } + + function cloneDrawing(id) { + const src = getDrawingById(id); + if (!src || src.locked) return; + const copy = JSON.parse(JSON.stringify(src)); + copy.id = uid(); + copy.locked = false; + const candles = getCandles(); + const timeStep = candles.length > 1 ? Math.abs(candles[1].time - candles[0].time) : 60; + copy.points = (copy.points || []).map(function (p, idx) { + return { + time: p.time + timeStep * (idx + 1), + price: p.price * 1.001, + }; + }); + if (copy.text) copy.text = String(copy.text); + drawings.push(copy); + selectedId = copy.id; + saveDrawings(); + scheduleRedraw(); + } + + function toggleDrawingLock(id) { + const d = getDrawingById(id); + if (!d) return; + d.locked = !d.locked; + saveDrawings(); + scheduleRedraw(); + } + + function toggleDrawingHide(id) { + const d = getDrawingById(id); + if (!d) return; + d.hidden = !d.hidden; + if (d.hidden && selectedId === id) selectedId = null; + hideContextMenu(); + saveDrawings(); + scheduleRedraw(); + } + + function isTypingTarget(el) { + if (!el) return false; + const tag = (el.tagName || "").toUpperCase(); + return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || !!el.isContentEditable; + } + + function ensureContextMenu() { + if (menuEl) return menuEl; + menuEl = document.createElement("div"); + menuEl.id = "market-draw-menu"; + menuEl.className = "market-draw-menu hidden"; + menuEl.setAttribute("role", "menu"); + document.body.appendChild(menuEl); + menuEl.addEventListener("click", function (ev) { + const btn = ev.target.closest("[data-action]"); + if (!btn || btn.disabled) return; + const action = btn.getAttribute("data-action"); + const id = menuEl._targetId; + if (!id) return; + ev.preventDefault(); + ev.stopPropagation(); + if (action === "clone") cloneDrawing(id); + else if (action === "toggle-lock") toggleDrawingLock(id); + else if (action === "toggle-hide") toggleDrawingHide(id); + else if (action === "remove") removeDrawing(id, { force: true }); + if (action !== "remove" && action !== "toggle-hide") { + const d = getDrawingById(id); + if (d) showContextMenu(menuEl._clientX, menuEl._clientY, d); + else hideContextMenu(); + } + }); + document.addEventListener("pointerdown", function (ev) { + if (!menuEl || menuEl.classList.contains("hidden")) return; + if (!menuEl.contains(ev.target)) hideContextMenu(); + }); + return menuEl; + } + + function hideContextMenu() { + if (!menuEl) return; + menuEl.classList.add("hidden"); + menuEl._targetId = null; + } + + function showContextMenu(clientX, clientY, d) { + if (!d) return; + const menu = ensureContextMenu(); + const label = TOOL_LABELS[d.type] || d.type; + const locked = !!d.locked; + menu.innerHTML = + '
        ' + + label + + "
        " + + '" + + '" + + '" + + '
        ' + + ''; + menu.classList.remove("hidden"); + menu._targetId = d.id; + menu._clientX = clientX; + menu._clientY = clientY; + menu.style.visibility = "hidden"; + menu.style.left = "0px"; + menu.style.top = "0px"; + const mw = menu.offsetWidth; + const mh = menu.offsetHeight; + const pad = 8; + let left = clientX; + let top = clientY; + if (left + mw > window.innerWidth - pad) left = window.innerWidth - mw - pad; + if (top + mh > window.innerHeight - pad) top = window.innerHeight - mh - pad; + if (left < pad) left = pad; + if (top < pad) top = pad; + menu.style.left = left + "px"; + menu.style.top = top + "px"; + menu.style.visibility = ""; + } + + function drawSelectionOverlay(ctx, d) { + if (!d) return; + const pts = d.points || []; + const handleColor = d.locked ? "#94a3b8" : "#2962ff"; + pts.forEach(function (p, idx) { + const x = timeToX(p.time); + const y = priceToY(p.price); + if (x == null || y == null) return; + const large = d.type === "path" && idx === pts.length - 1; + drawHandle(ctx, x, y, large, handleColor); + }); + if ((d.type === "trend" || d.type === "channel") && pts.length >= 2) { + const x1 = timeToX(pts[0].time); + const y1 = priceToY(pts[0].price); + const x2 = timeToX(pts[1].time); + const y2 = priceToY(pts[1].price); + if (x1 != null && y1 != null && x2 != null && y2 != null) { + const angle = (Math.atan2(y2 - y1, x2 - x1) * 180) / Math.PI; + const text = angle.toFixed(2) + "°"; + ctx.font = "11px sans-serif"; + const tw = ctx.measureText(text).width; + const bx = x2 + 10; + const by = y2 - 8; + ctx.fillStyle = "rgba(15, 23, 42, 0.88)"; + ctx.fillRect(bx - 4, by - 12, tw + 8, 18); + ctx.fillStyle = "#f8fafc"; + ctx.fillText(text, bx, by); + } + } + if (d.locked) { + const anchor = pts[0]; + if (!anchor) return; + const ax = timeToX(anchor.time); + const ay = priceToY(anchor.price); + if (ax == null || ay == null) return; + ctx.font = "10px sans-serif"; + ctx.fillStyle = "#94a3b8"; + ctx.fillText("已锁定", ax + 8, ay - 8); + } + } + + function tryEraseAt(x, y) { + const d = pickDrawingAt(x, y); + if (!d || d.locked) return false; + removeDrawing(d.id); + return true; + } + + function onPointerDown(ev) { + if (activeTool === "cursor" || activeTool === "clear") return; + if (!chart || !series || !canvasEl) return; + const loc = clientToLocal(ev); + if (activeTool === "erase") { + if (tryEraseAt(loc.x, loc.y)) { + returnToCursorIfOneShot(); + } + ev.preventDefault(); + return; + } + const pt = xyToPoint(loc.x, loc.y); + if (!pt) return; + ev.preventDefault(); + ev.stopPropagation(); + const capturePointer = activeTool === "brush" || isDragTool(activeTool); + if (capturePointer) { + try { + canvasEl.setPointerCapture(ev.pointerId); + brushPointerId = ev.pointerId; + } catch (_) {} + } + + if (activeTool === "brush") { + draft = { type: "brush", points: [pt] }; + return; + } + if (activeTool === "path") { + if (!draft || draft.type !== "path") { + draft = { type: "path", points: [pt] }; + pathPreviewPt = pt; + } else { + const last = draft.points[draft.points.length - 1]; + const lx = timeToX(last.time); + const ly = priceToY(last.price); + const cx = timeToX(pt.time); + const cy = priceToY(pt.price); + if ( + lx != null && + ly != null && + cx != null && + cy != null && + Math.hypot(cx - lx, cy - ly) > 4 + ) { + draft.points.push(pt); + } + pathPreviewPt = pt; + } + scheduleRedraw(); + return; + } + if (activeTool === "channel") { + if (!draft || draft.type !== "channel") { + draft = { type: "channel", points: [pt] }; + } else if (draft.points.length === 1) { + draft.points.push(pt); + } else if (draft.points.length === 2) { + draft.points.push(pt); + draft.offset = parallelOffset(draft.points[0], draft.points[1], draft.points[2]); + commitDrawing(draft); + } + scheduleRedraw(); + return; + } + if (isDragTool(activeTool)) { + dragActive = true; + dragStartPx = { x: loc.x, y: loc.y }; + draft = { type: activeTool, points: [pt, pt] }; + scheduleRedraw(); + return; + } + + if (activeTool === "text") { + const text = window.prompt("输入标注文字", ""); + if (text && String(text).trim()) { + commitDrawing({ type: "text", points: [pt], text: String(text).trim() }); + } + return; + } + if (pointsNeeded(activeTool) === 1) { + commitDrawing({ type: activeTool, points: [pt] }); + } + } + + function onPointerMove(ev) { + const loc = clientToLocal(ev); + if (activeTool === "brush" && draft && draft.type === "brush") { + const pt = xyToPoint(loc.x, loc.y); + if (!pt) return; + const last = draft.points[draft.points.length - 1]; + const lx = timeToX(last.time); + const ly = priceToY(last.price); + const cx = timeToX(pt.time); + const cy = priceToY(pt.price); + if (lx != null && ly != null && cx != null && cy != null && Math.hypot(cx - lx, cy - ly) > 2) { + draft.points.push(pt); + scheduleRedraw(); + } + ev.preventDefault(); + return; + } + if (dragActive && draft && isDragTool(draft.type)) { + const pt = xyToPoint(loc.x, loc.y); + if (!pt) return; + draft.points[1] = pt; + scheduleRedraw(); + ev.preventDefault(); + return; + } + if (activeTool === "path" && draft && draft.type === "path") { + const pt = xyToPoint(loc.x, loc.y); + if (!pt) return; + pathPreviewPt = pt; + scheduleRedraw(); + ev.preventDefault(); + } + } + + function onPointerUp(ev) { + const loc = clientToLocal(ev); + if (brushPointerId != null) { + try { + canvasEl.releasePointerCapture(brushPointerId); + } catch (_) {} + brushPointerId = null; + } + if (dragActive && draft && isDragTool(draft.type)) { + const dist = dragStartPx + ? Math.hypot(loc.x - dragStartPx.x, loc.y - dragStartPx.y) + : 0; + dragActive = false; + dragStartPx = null; + const p1 = draft.points[0]; + const p2 = draft.points[1]; + if ( + dist >= MIN_DRAG_PX && + p1 && + p2 && + (p1.price !== p2.price || p1.time !== p2.time) + ) { + commitDrawing({ type: draft.type, points: [p1, p2] }); + } else { + cancelDraft(); + } + return; + } + if (activeTool === "brush" && draft && draft.type === "brush" && draft.points.length > 1) { + commitDrawing(draft); + } + } + + function onDblClick(ev) { + if (activeTool === "path" && draft && draft.type === "path") { + finishPath(); + ev.preventDefault(); + } + } + + function onContextMenu(ev) { + if (activeTool === "path" && draft && draft.type === "path") { + ev.preventDefault(); + ev.stopPropagation(); + finishPath(); + } + } + + function onMainContextMenu(ev) { + if (!hostEl) return; + if (activeTool === "path" && draft && draft.type === "path") return; + if (draft || dragActive) return; + const rect = hostEl.getBoundingClientRect(); + const x = ev.clientX - rect.left; + const y = ev.clientY - rect.top; + const d = pickDrawingAt(x, y); + if (!d) { + hideContextMenu(); + return; + } + ev.preventDefault(); + selectDrawing(d.id); + showContextMenu(ev.clientX, ev.clientY, d); + } + + function distSeg(px, py, x1, y1, x2, y2) { + const dx = x2 - x1; + const dy = y2 - y1; + if (dx === 0 && dy === 0) return Math.hypot(px - x1, py - y1); + const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / (dx * dx + dy * dy))); + const nx = x1 + t * dx; + const ny = y1 + t * dy; + return Math.hypot(px - nx, py - ny); + } + + function hitTestDrawing(d, x, y) { + const pts = d.points || []; + if (!pts.length) return false; + const w = hostEl.clientWidth; + const h = hostEl.clientHeight; + switch (d.type) { + case "hline": { + const ly = priceToY(pts[0].price); + return ly != null && Math.abs(y - ly) <= HIT_PX; + } + case "cross": { + const ly = priceToY(pts[0].price); + const lx = timeToX(pts[0].time); + return ( + (ly != null && Math.abs(y - ly) <= HIT_PX) || + (lx != null && Math.abs(x - lx) <= HIT_PX) + ); + } + case "trend": + case "channel": + if (pts.length >= 2) { + const x1 = timeToX(pts[0].time); + const y1 = priceToY(pts[0].price); + const x2 = timeToX(pts[1].time); + const y2 = priceToY(pts[1].price); + if (x1 != null && y1 != null && x2 != null && y2 != null) { + if (distSeg(x, y, x1, y1, x2, y2) <= HIT_PX) return true; + } + } + return false; + case "path": + case "brush": + if (pts.length >= 2) { + for (let i = 1; i < pts.length; i++) { + const x1 = timeToX(pts[i - 1].time); + const y1 = priceToY(pts[i - 1].price); + const x2 = timeToX(pts[i].time); + const y2 = priceToY(pts[i].price); + if (x1 != null && y1 != null && x2 != null && y2 != null) { + if (distSeg(x, y, x1, y1, x2, y2) <= HIT_PX) return true; + } + } + } + return false; + case "text": { + const tx = timeToX(pts[0].time); + const ty = priceToY(pts[0].price); + return tx != null && ty != null && Math.hypot(x - tx, y - ty) <= 14; + } + case "range": + case "fib": + case "rect": + if (pts.length >= 2) { + const x1 = timeToX(pts[0].time); + const y1 = priceToY(pts[0].price); + const x2 = timeToX(pts[1].time); + const y2 = priceToY(pts[1].price); + if (x1 == null || y1 == null || x2 == null || y2 == null) return false; + const l = Math.min(x1, x2) - HIT_PX; + const r = Math.max(x1, x2) + HIT_PX; + const t = Math.min(y1, y2) - HIT_PX; + const b = Math.max(y1, y2) + HIT_PX; + return x >= l && x <= r && y >= t && y <= b; + } + return false; + default: + return false; + } + } + + function syncCanvasPointerMode() { + if (!canvasEl) return; + const drawing = activeTool !== "cursor"; + canvasEl.classList.toggle("is-drawing", drawing); + canvasEl.style.pointerEvents = drawing ? "auto" : "none"; + } + + function setActiveTool(tool) { + if (!TOOL_LABELS[tool]) return; + if (tool === "clear") { + if (drawings.length && window.confirm("清除当前图表上的全部画线?")) { + drawings = []; + selectedId = null; + draft = null; + saveDrawings(); + scheduleRedraw(); + } + return; + } + if (tool === "erase") { + if (selectedId) { + drawings = drawings.filter(function (d) { + return d.id !== selectedId; + }); + selectedId = null; + saveDrawings(); + scheduleRedraw(); + } + return; + } + activeTool = tool; + dragActive = false; + dragStartPx = null; + pathPreviewPt = null; + draft = null; + if (toolbarEl) { + toolbarEl.querySelectorAll("[data-tool]").forEach(function (btn) { + btn.classList.toggle("is-active", btn.getAttribute("data-tool") === tool); + }); + } + syncCanvasPointerMode(); + setChartInteraction(activeTool === "cursor"); + scheduleRedraw(); + } + + function bindToolbar() { + if (!toolbarEl) return; + toolbarEl.querySelectorAll("[data-tool]").forEach(function (btn) { + btn.addEventListener("click", function () { + setActiveTool(btn.getAttribute("data-tool") || "cursor"); + }); + }); + } + + let canvasBound = false; + + function bindCanvas() { + if (!canvasEl || canvasBound) return; + canvasBound = true; + canvasEl.addEventListener("pointerdown", onPointerDown); + canvasEl.addEventListener("pointermove", onPointerMove); + canvasEl.addEventListener("pointerup", onPointerUp); + canvasEl.addEventListener("pointercancel", onPointerUp); + canvasEl.addEventListener("dblclick", onDblClick); + canvasEl.addEventListener("contextmenu", onContextMenu); + document.addEventListener("keydown", onDrawKeydown); + } + + function onDrawKeydown(ev) { + const page = document.getElementById("page-market"); + if (!page || page.classList.contains("hidden")) return; + if (isTypingTarget(ev.target)) return; + if (ev.key === "Escape") { + if (draft || dragActive) { + cancelDraft(); + return; + } + if (!menuEl || menuEl.classList.contains("hidden")) { + deselectDrawing(); + } else { + hideContextMenu(); + } + return; + } + if (ev.key === "Enter" && activeTool === "path" && draft && draft.type === "path") { + finishPath(); + ev.preventDefault(); + return; + } + if ( + (ev.key === "Delete" || ev.key === "Backspace") && + activeTool === "cursor" && + selectedId + ) { + const d = getDrawingById(selectedId); + if (d && !d.locked) { + removeSelectedDrawing(); + ev.preventDefault(); + } + } + } + + function bindChartClick() { + if (!chart || typeof chart.subscribeClick !== "function") return; + if (unsubClick) { + try { + unsubClick(); + } catch (_) {} + unsubClick = null; + } + unsubClick = chart.subscribeClick(function (param) { + if (activeTool !== "cursor" || !param || !param.point) return; + hideContextMenu(); + const d = pickDrawingAt(param.point.x, param.point.y); + if (d) selectDrawing(d.id); + else deselectDrawing(); + }); + } + + function bindMainEl() { + if (!mainEl || mainBound) return; + mainBound = true; + mainEl.addEventListener("contextmenu", onMainContextMenu); + } + + function attach(opts) { + chart = opts.chart || null; + series = opts.series || null; + hostEl = opts.hostEl || null; + mainEl = opts.mainEl || null; + canvasEl = opts.canvasEl || null; + toolbarEl = opts.toolbarEl || null; + getCandlesFn = opts.getCandles || null; + mountCanvasOverlay(); + bindToolbar(); + bindCanvas(); + bindMainEl(); + bindChartClick(); + setActiveTool("cursor"); + if (chart && chart.timeScale) { + if (unsubRange) { + try { + unsubRange(); + } catch (_) {} + } + unsubRange = chart.timeScale().subscribeVisibleLogicalRangeChange(function () { + scheduleRedraw(); + }); + } + scheduleRedraw(); + } + + function setViewKey(key) { + viewKey = key || ""; + drawings = loadDrawings(); + selectedId = null; + dragActive = false; + dragStartPx = null; + pathPreviewPt = null; + draft = null; + scheduleRedraw(); + } + + function destroy() { + if (unsubRange) { + try { + unsubRange(); + } catch (_) {} + unsubRange = null; + } + if (unsubClick) { + try { + unsubClick(); + } catch (_) {} + unsubClick = null; + } + hideContextMenu(); + setChartInteraction(true); + } + + function setTradingDaySplit(enabled) { + tradingDaySplitEnabled = !!enabled; + scheduleRedraw(); + } + + window.HubChartDraw = { + attach: attach, + setViewKey: setViewKey, + setTradingDaySplit: setTradingDaySplit, + resize: scheduleRedraw, + redraw: scheduleRedraw, + destroy: destroy, + }; +})(); diff --git a/manual_trading_hub/static/dashboard.css b/manual_trading_hub/static/dashboard.css new file mode 100644 index 0000000..6579fcc --- /dev/null +++ b/manual_trading_hub/static/dashboard.css @@ -0,0 +1,730 @@ +/* 数据看板 — 随中控亮/暗主题,卡片柔光 */ +body.hub-page-dashboard { + --dash-card-bg: var(--panel); + --dash-card-border: var(--border-soft); + --dash-card-glow: 0 2px 12px rgba(0, 0, 0, 0.06); + --dash-section-bg: var(--panel); + --dash-muted: var(--muted); + --dash-text: var(--text); + --dash-accent: var(--accent); + --dash-ok: var(--green); + --dash-warn: var(--red); +} + +html[data-theme="light"] body.hub-page-dashboard { + --dash-card-glow: + 0 1px 2px rgba(15, 23, 42, 0.04), + 0 8px 24px rgba(15, 23, 42, 0.06), + inset 0 1px 0 rgba(255, 255, 255, 0.85); +} + +html[data-theme="dark"] body.hub-page-dashboard { + --dash-card-glow: + 0 4px 18px rgba(0, 0, 0, 0.28), + inset 0 1px 0 rgba(255, 255, 255, 0.04); +} + +body.hub-page-dashboard .page#page-dashboard { + position: relative; +} + +.dash-bg-grid { + position: absolute; + inset: 0; + pointer-events: none; + opacity: 0.45; + background-image: + linear-gradient(color-mix(in srgb, var(--border-soft) 55%, transparent) 1px, transparent 1px), + linear-gradient(90deg, color-mix(in srgb, var(--border-soft) 55%, transparent) 1px, transparent 1px); + background-size: 40px 40px; + mask-image: radial-gradient(ellipse 85% 65% at 50% 0%, #000 15%, transparent 72%); +} + +.dash-wrap { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + gap: 18px; + min-height: calc(100vh - 120px); +} + +.dash-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.dash-head h1 { + font-size: clamp(1.35rem, 2.5vw, 1.75rem); + font-weight: 700; + letter-spacing: 0.02em; + margin: 0; + color: var(--dash-text); +} + +.dash-head-tag { + display: inline-block; + font-family: JetBrains Mono, monospace; + font-size: 0.65rem; + color: var(--dash-accent); + border: 1px solid var(--dash-card-border); + padding: 2px 8px; + border-radius: 4px; + margin-right: 10px; + vertical-align: middle; + letter-spacing: 0.1em; +} + +.dash-head-meta { + font-family: JetBrains Mono, monospace; + font-size: 0.78rem; + color: var(--dash-muted); + text-align: right; +} + +.dash-head-meta strong { + color: var(--dash-text); + font-weight: 600; +} + +.dash-pulse-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--dash-ok); + margin-right: 6px; + box-shadow: 0 0 0 2px color-mix(in srgb, var(--dash-ok) 25%, transparent); + animation: dash-pulse 2s ease-in-out infinite; +} + +@keyframes dash-pulse { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.65; + transform: scale(0.9); + } +} + +.dash-kpi-row { + display: block; +} + +.dash-kpi-summary { + display: flex; + flex-wrap: nowrap; + align-items: stretch; + justify-content: space-between; + gap: 0; + width: 100%; + padding: 10px 4px; + border-radius: 12px; + background: var(--dash-card-bg); + border: 1px solid var(--dash-card-border); + box-shadow: var(--dash-card-glow); + overflow: hidden; +} + +.dash-kpi-item { + flex: 1 1 0; + min-width: 0; + max-width: none; + padding: 4px 8px; + position: relative; + text-align: center; +} + +.dash-kpi-item + .dash-kpi-item::before { + content: ""; + position: absolute; + left: 0; + top: 18%; + bottom: 18%; + width: 1px; + background: color-mix(in srgb, var(--dash-card-border) 85%, transparent); +} + +.dash-kpi, +.dash-section, +.dash-ac-card { + box-shadow: var(--dash-card-glow); +} + +.dash-kpi { + position: relative; + padding: 16px 18px; + border-radius: 12px; + background: var(--dash-card-bg); + border: 1px solid var(--dash-card-border); + overflow: hidden; +} + +.dash-kpi-label { + font-size: 0.65rem; + color: var(--dash-muted); + letter-spacing: 0.04em; + margin-bottom: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dash-kpi-value { + font-family: JetBrains Mono, monospace; + font-size: 0.92rem; + font-weight: 600; + line-height: 1.25; + color: var(--dash-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dash-kpi-value.pos { + color: var(--dash-ok); +} + +.dash-kpi-value.neg { + color: var(--dash-warn); +} + +.dash-kpi-sub { + margin-top: 6px; + font-size: 0.72rem; + color: var(--dash-muted); +} + +.dash-alert-banner { + display: none; + align-items: center; + gap: 12px; + padding: 12px 16px; + border-radius: 10px; + border: 1px solid color-mix(in srgb, var(--dash-warn) 45%, var(--dash-card-border)); + background: color-mix(in srgb, var(--dash-warn) 8%, var(--dash-card-bg)); + font-size: 0.85rem; + box-shadow: var(--dash-card-glow); +} + +.dash-alert-banner.is-on { + display: flex; +} + +.dash-alert-banner strong { + color: var(--dash-warn); + letter-spacing: 0.02em; +} + +.dash-section { + border-radius: 14px; + border: 1px solid var(--dash-card-border); + background: var(--dash-section-bg); + overflow: hidden; +} + +.dash-section-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--dash-card-border); + font-size: 0.82rem; + letter-spacing: 0.06em; + color: var(--dash-muted); + font-weight: 600; +} + +.dash-section-body { + padding: 0; +} + +.dash-ac-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); + gap: 12px; + padding: 14px; +} + +.dash-ac-card { + position: relative; + padding: 14px 16px; + border-radius: 10px; + border: 1px solid var(--dash-card-border); + background: var(--dash-card-bg); + transition: border-color 0.2s, box-shadow 0.2s; +} + +.dash-ac-card.is-alert { + border-color: color-mix(in srgb, var(--dash-warn) 55%, var(--dash-card-border)); + box-shadow: + var(--dash-card-glow), + 0 0 0 1px color-mix(in srgb, var(--dash-warn) 18%, transparent); +} + +.dash-ac-card.is-unmon { + opacity: 0.6; +} + +.dash-ac-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 10px; +} + +.dash-ac-name { + font-weight: 600; + font-size: 0.92rem; + color: var(--dash-text); +} + +.dash-ac-top-actions { + display: inline-flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.dash-ac-badge { + font-size: 0.65rem; + font-weight: 700; + padding: 3px 8px; + border-radius: 4px; + letter-spacing: 0.04em; + white-space: nowrap; +} + +.dash-ac-badge.alert { + color: #fff; + background: var(--dash-warn); +} + +.dash-ac-badge.ok { + color: var(--dash-accent); + border: 1px solid var(--dash-card-border); + background: color-mix(in srgb, var(--dash-accent) 8%, var(--dash-card-bg)); +} + +.dash-ac-metrics { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 8px 12px; + font-family: JetBrains Mono, monospace; + font-size: 0.76rem; +} + +.dash-ac-metrics-3col { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.dash-ac-section-label { + grid-column: 1 / -1; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.06em; + color: var(--dash-accent); + margin-top: 2px; + padding-bottom: 2px; + border-bottom: 1px dashed color-mix(in srgb, var(--dash-card-border) 80%, transparent); +} + +.dash-ac-section-label:not(:first-child) { + margin-top: 8px; +} + +.dash-ac-metric-empty { + visibility: hidden; + min-height: 0; + padding: 0; + margin: 0; +} + +.dash-ac-total-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-top: 10px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--dash-card-border); + background: color-mix(in srgb, var(--dash-accent) 6%, var(--dash-card-bg)); + font-family: JetBrains Mono, monospace; + font-size: 0.76rem; +} + +.dash-ac-total-row span { + color: var(--dash-muted); + font-size: 0.68rem; +} + +.dash-ac-total-row strong { + color: var(--dash-text); + font-size: 0.88rem; +} + +.dash-ac-card-options .dash-ac-remark { + margin-top: 8px; + padding-top: 8px; + border-top: 1px dashed color-mix(in srgb, var(--dash-card-border) 80%, transparent); +} + +.dash-options-block { + margin-top: 8px; +} + +.dash-options-block .dash-ac-section-label { + margin-bottom: 4px; +} + +.dash-options-table-wrap { + overflow-x: auto; +} + +.dash-options-table th, +.dash-options-table td { + font-size: 0.68rem; + white-space: nowrap; +} + +.dash-ac-card-pos-only { + gap: 8px; +} + +.dash-ac-pos-body { + display: flex; + flex-direction: column; + gap: 10px; +} + +.dash-pos-block .dash-ac-section-label { + margin-bottom: 4px; +} + +.dash-pos-source { + display: inline-block; + padding: 1px 6px; + border-radius: 999px; + font-size: 0.66rem; + font-weight: 600; + letter-spacing: 0.02em; +} + +.dash-pos-source.is-hedge { + color: #fbbf24; + background: rgba(245, 158, 11, 0.16); + border: 1px solid rgba(245, 158, 11, 0.4); +} + +.dash-pos-source.is-roll { + color: #6ee7b7; + background: rgba(16, 185, 129, 0.16); + border: 1px solid rgba(16, 185, 129, 0.4); +} + +.dash-pos-source.is-trend { + color: #93c5fd; + background: rgba(59, 130, 246, 0.18); + border: 1px solid rgba(59, 130, 246, 0.35); +} + +.dash-pos-source.is-order { + color: #c4b5fd; + background: rgba(139, 92, 246, 0.18); + border: 1px solid rgba(139, 92, 246, 0.35); +} + +.dash-pos-source.is-key { + color: #fdba74; + background: rgba(249, 115, 22, 0.16); + border: 1px solid rgba(249, 115, 22, 0.4); +} + +.dash-pos-source.is-none { + color: var(--dash-muted); + background: rgba(148, 163, 184, 0.12); + border: 1px solid rgba(148, 163, 184, 0.28); +} + +.dash-target-monitor { + color: var(--dash-muted); +} + +.dash-target-monitor.is-on { + color: #4ade80; + font-weight: 600; +} + +.dash-pos-source.is-perp { + color: #93c5fd; + background: rgba(59, 130, 246, 0.18); + border: 1px solid rgba(59, 130, 246, 0.35); +} + +.dash-pos-source.is-opt { + color: #c4b5fd; + background: rgba(139, 92, 246, 0.18); + border: 1px solid rgba(139, 92, 246, 0.35); +} + +.dash-pos-table th, +.dash-pos-table td { + font-size: 0.72rem; + white-space: nowrap; +} + +.dash-ac-metrics-3col .dash-ac-metric { + text-align: center; +} + +.dash-ac-metrics-3col .dash-ac-metric span, +.dash-ac-metrics-3col .dash-ac-metric strong { + text-align: center; +} + +.dash-ac-metric span { + display: block; + color: var(--dash-muted); + font-size: 0.65rem; + margin-bottom: 2px; +} + +.dash-ac-metric strong { + color: var(--dash-text); +} + +.dash-ac-metric strong.pos { + color: var(--dash-ok); +} + +.dash-ac-metric strong.neg { + color: var(--dash-warn); +} + +.dash-loss-bar { + margin-top: 10px; + height: 4px; + border-radius: 2px; + background: color-mix(in srgb, var(--dash-muted) 18%, transparent); + overflow: hidden; +} + +.dash-loss-bar i { + display: block; + height: 100%; + border-radius: 2px; + background: var(--dash-warn); + transition: width 0.6s ease; +} + +.dash-ac-remark { + margin-top: 10px; + font-size: 0.7rem; + color: var(--dash-muted); + line-height: 1.4; + word-break: break-word; + display: flex; + flex-direction: column; + gap: 6px; +} + +.dash-ac-monitor-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.dash-monitor-chip { + display: inline-flex; + align-items: center; + padding: 3px 8px; + border-radius: 6px; + font-size: 11px; + line-height: 1.3; + border: 1px solid transparent; + font-weight: 600; +} + +.dash-monitor-chip.dash-monitor-key { + color: #b8a0ff; + background: rgba(123, 97, 255, 0.18); + border-color: rgba(123, 97, 255, 0.42); +} + +.dash-monitor-chip.dash-monitor-order { + color: var(--dash-accent); + background: rgba(0, 212, 255, 0.14); + border-color: rgba(0, 212, 255, 0.38); +} + +.dash-monitor-chip.dash-monitor-trend { + color: var(--dash-ok); + background: rgba(0, 255, 157, 0.1); + border-color: rgba(0, 255, 157, 0.38); +} + +.dash-monitor-chip.dash-monitor-roll { + color: #ffb020; + background: rgba(255, 176, 32, 0.14); + border-color: rgba(255, 176, 32, 0.42); +} + +.dash-ac-expand-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border-radius: 6px; + border: 1px solid var(--dash-card-border); + background: color-mix(in srgb, var(--dash-accent) 8%, var(--dash-card-bg)); + color: var(--dash-accent); + cursor: pointer; + flex-shrink: 0; +} + +.dash-ac-expand-btn:hover { + border-color: var(--dash-accent); + background: color-mix(in srgb, var(--dash-accent) 14%, var(--dash-card-bg)); +} + +.dash-ac-positions { + display: flex; + flex-direction: column; + gap: 3px; +} + +.dash-ac-remark-line { + margin: 0; + padding: 3px 0; + border-top: 1px solid color-mix(in srgb, var(--dash-card-border) 65%, transparent); +} + +.dash-ac-remark-line:first-child { + border-top: none; + padding-top: 0; +} + +.dash-ac-remark-mon { + color: var(--dash-muted); +} + +.dash-ac-remark-pos { + color: var(--dash-text); +} + +.dash-ac-remark-pos .pos, +.dash-ac-remark-pos .neg { + font-weight: 600; +} + +.dash-ac-remark-pos .pos { + color: var(--dash-ok); +} + +.dash-ac-remark-pos .neg { + color: var(--dash-warn); +} + +.dash-ac-remark-empty { + color: var(--dash-muted); +} + +.dash-ac-remark-issue { + color: var(--dash-warn); +} + +html[data-theme="light"] .dash-monitor-chip.dash-monitor-key { + color: #5b4fc7; + background: rgba(91, 79, 199, 0.1); + border-color: rgba(91, 79, 199, 0.28); +} + +html[data-theme="light"] .dash-monitor-chip.dash-monitor-trend { + background: rgba(10, 143, 92, 0.1); + border-color: rgba(10, 143, 92, 0.28); +} + +.dash-table-wrap { + overflow: auto; + max-height: min(52vh, 480px); +} + +.dash-table { + width: 100%; + border-collapse: collapse; + font-family: JetBrains Mono, monospace; + font-size: 0.74rem; +} + +.dash-table th { + position: sticky; + top: 0; + z-index: 1; + text-align: left; + padding: 10px 12px; + background: var(--inset-surface); + color: var(--dash-muted); + font-weight: 600; + letter-spacing: 0.04em; + border-bottom: 1px solid var(--dash-card-border); +} + +.dash-table td { + padding: 9px 12px; + border-bottom: 1px solid var(--dash-card-border); + color: var(--dash-text); +} + +.dash-table tr:hover td { + background: color-mix(in srgb, var(--dash-accent) 6%, transparent); +} + +.dash-table tr.is-alert-row td { + background: color-mix(in srgb, var(--dash-warn) 10%, transparent); +} + +.dash-table .pos { + color: var(--dash-ok); +} + +.dash-table .neg { + color: var(--dash-warn); +} + +.dash-empty { + padding: 32px; + text-align: center; + color: var(--dash-muted); + font-size: 0.85rem; +} + +.dash-status { + font-family: JetBrains Mono, monospace; + font-size: 0.75rem; + color: var(--dash-muted); +} + +.dash-status.err { + color: var(--dash-warn); +} + +@media (max-width: 720px) { + .dash-ac-grid { + grid-template-columns: 1fr; + } + .dash-head-meta { + text-align: left; + width: 100%; + } +} diff --git a/manual_trading_hub/static/dashboard.js b/manual_trading_hub/static/dashboard.js new file mode 100644 index 0000000..d87064a --- /dev/null +++ b/manual_trading_hub/static/dashboard.js @@ -0,0 +1,459 @@ +/** + * 中控数据看板:后端 SSE 推送版本号,前端拉快照刷新(无轮询闪烁). + */ +(function () { + const page = document.getElementById("page-dashboard"); + if (!page) return; + + let dashEventSource = null; + let dashReconnectTimer = null; + let localDashVersion = 0; + let inited = false; + let loading = false; + + const elStatus = document.getElementById("dash-status"); + const elBanner = document.getElementById("dash-alert-banner"); + const elBannerText = document.getElementById("dash-alert-banner-text"); + const elKpi = document.getElementById("dash-kpi-row"); + const elAccounts = document.getElementById("dash-accounts"); + const elTrades = document.getElementById("dash-trades-body"); + const elUpdated = document.getElementById("dash-updated-at"); + const elDay = document.getElementById("dash-trading-day"); + const btnRefresh = document.getElementById("dash-btn-refresh"); + + function fmt(n, d) { + if (n == null || n === "" || !Number.isFinite(Number(n))) return "—"; + return Number(n).toFixed(d == null ? 2 : d); + } + + function pnlClass(v) { + const n = Number(v); + if (!Number.isFinite(n) || Math.abs(n) < 1e-9) return ""; + return n > 0 ? "pos" : "neg"; + } + + function pnlSigned(v, digits) { + const n = Number(v); + if (!Number.isFinite(n)) return "—"; + const abs = fmt(Math.abs(n), digits); + if (Math.abs(n) < 1e-9) return `${abs}U`; + return `${n > 0 ? "+" : "-"}${abs}U`; + } + + function esc(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function setStatus(msg, isErr) { + if (!elStatus) return; + elStatus.textContent = msg || ""; + elStatus.className = "dash-status" + (isErr ? " err" : ""); + } + + function renderKpi(totals) { + if (!elKpi || !totals) return; + const closed = Number(totals.total_pnl_u); + const floating = Number(totals.float_pnl_u); + const funding = totals.total_funding_usdt; + const trading = totals.total_trading_usdt; + const funds = + funding != null && trading != null ? Number(funding) + Number(trading) : NaN; + const totalPos = Number(totals.open_position_count) || 0; + const optPos = Number(totals.options_open_position_count) || 0; + const perpPos = + totals.perpetual_open_position_count != null + ? Number(totals.perpetual_open_position_count) || 0 + : Math.max(0, totalPos - optPos); + const items = [ + kpiItem("交易日", esc(totals.trading_day || "—")), + kpiItem("资金合计", Number.isFinite(funds) ? `${fmt(funds, 2)}U` : "—"), + kpiItem("总持仓数量", `${totalPos}`), + kpiItem("期权持仓", `${optPos}`), + kpiItem("永续持仓", `${perpPos}`), + kpiItem("平仓数量", `${totals.closed_count || 0}`), + kpiItem("平仓盈亏", pnlSigned(closed, 2), pnlClass(closed)), + kpiItem("浮盈亏", pnlSigned(floating, 2), pnlClass(floating)), + ]; + elKpi.innerHTML = `
        ${items.join("")}
        `; + } + + function kpiItem(label, value, valCls) { + return `
        +
        ${esc(label)}
        +
        ${value}
        +
        `; + } + + function renderMonitorCountChips(counts) { + const mc = counts || {}; + const chips = []; + const keys = Number(mc.keys) || 0; + const orders = Number(mc.orders) || 0; + const trends = Number(mc.trends) || 0; + const rolls = Number(mc.rolls) || 0; + if (keys > 0) chips.push(`关键位 ${keys}`); + if (orders > 0) { + chips.push(`下单监控 ${orders}`); + } + if (trends > 0) chips.push(`趋势回调 ${trends}`); + if (rolls > 0) chips.push(`顺势加仓 ${rolls}`); + return chips; + } + + function dashOptionsExpiryCd(expMs) { + const ms = expMs != null && expMs !== "" ? String(expMs) : ""; + if (!ms) return "—"; + return ``; + } + + function shortDashInst(instId) { + const s = String(instId || ""); + if (s.length <= 18) return s; + return s.slice(0, 8) + "…" + s.slice(-6); + } + + function accountPerpLines(ac) { + const positions = Array.isArray(ac && ac.position_lines) ? ac.position_lines : []; + if (ac && ac.options_layout) { + return positions.filter((ln) => (ln && ln.kind) !== "options"); + } + return positions; + } + + function accountHasOpenPositions(ac) { + const perp = accountPerpLines(ac); + const optionsPositions = Array.isArray(ac && ac.options_positions) ? ac.options_positions : []; + return perp.length > 0 || (ac && ac.options_layout && optionsPositions.length > 0); + } + + function sourceBadgeClass(source) { + const s = String(source || ""); + if (s.indexOf("对冲") >= 0) return "is-hedge"; + if (s.indexOf("纯期权") >= 0 || s === "期权") return "is-opt"; + if (s.indexOf("顺势") >= 0) return "is-roll"; + if (s.indexOf("趋势") >= 0) return "is-trend"; + if (s.indexOf("关键位") >= 0) return "is-key"; + if (s.indexOf("下单") >= 0) return "is-order"; + return "is-none"; + } + + function renderDashboardPerpTable(lines) { + const rows = Array.isArray(lines) ? lines : []; + if (!rows.length) return ""; + const body = rows + .map((ln) => { + const source = String((ln && ln.source) || "—"); + const symbol = esc((ln && (ln.symbol || ln.text)) || "—"); + const side = esc((ln && ln.side) || "—"); + const contracts = + ln && ln.contracts != null && ln.contracts !== "" ? esc(String(ln.contracts)) : "—"; + const pnl = ln && ln.pnl != null ? Number(ln.pnl) : NaN; + return ` + ${esc(source)} + ${symbol} + ${side} + ${contracts} + ${Number.isFinite(pnl) ? pnlSigned(pnl, 2) : "—"} + `; + }) + .join(""); + return `
        + +
        + + + + + ${body} +
        来源合约方向张数浮盈
        +
        +
        `; + } + + function optionsNetPnl(p) { + if (!p || typeof p !== "object") return null; + if (p.net_pnl != null && Number.isFinite(Number(p.net_pnl))) return Number(p.net_pnl); + const preview = p.close_preview || {}; + if (preview.estimated_pnl != null && Number.isFinite(Number(preview.estimated_pnl))) { + return Number(preview.estimated_pnl); + } + if (preview.total_received != null && p.premium_paid != null) { + const n = Number(preview.total_received) - Number(p.premium_paid); + return Number.isFinite(n) ? n : null; + } + return null; + } + + function renderDashboardOptionsTable(positions) { + const pos = Array.isArray(positions) ? positions : []; + if (!pos.length) return ""; + const rows = pos + .map((p) => { + const optType = + (p.opt_type || "").toUpperCase() === "C" + ? "Call" + : (p.opt_type || "").toUpperCase() === "P" + ? "Put" + : p.opt_type || "—"; + const source = String(p.source_label || p.source || "纯期权"); + const target = String(p.target_monitor_text || "—"); + const targetCls = target && target !== "—" ? "dash-target-monitor is-on" : "dash-target-monitor"; + const net = optionsNetPnl(p); + return ` + ${esc(source)} + ${esc(shortDashInst(p.inst_id))} + ${esc(optType)} + ${dashOptionsExpiryCd(p.exp_time_ms != null ? p.exp_time_ms : p.exp_time)} + ${p.idx_px != null ? fmt(p.idx_px, 0) : "—"} + ${esc(target)} + ${net != null ? pnlSigned(net, 2) : "—"} + `; + }) + .join(""); + return `
        + +
        + + + + + ${rows} +
        来源合约类型到期倒计时指数目标监控净盈亏
        +
        +
        `; + } + + function renderAccountPositions(ac) { + const perpLines = accountPerpLines(ac); + const optionsPositions = Array.isArray(ac && ac.options_positions) ? ac.options_positions : []; + const issues = Array.isArray(ac && ac.issues) ? ac.issues : []; + const chips = renderMonitorCountChips((ac && ac.monitor_counts) || {}); + const monitorRow = chips.length + ? `
        ${chips.join("")}
        ` + : ""; + const perpHtml = renderDashboardPerpTable(perpLines); + const optionsHtml = ac && ac.options_layout ? renderDashboardOptionsTable(optionsPositions) : ""; + const issueHtml = issues + .map((text) => `
        ${esc(text)}
        `) + .join(""); + return `${monitorRow}${perpHtml}${optionsHtml}${issueHtml}`; + } + + function bindDashboardExpand() { + if (!elAccounts) return; + elAccounts.querySelectorAll(".dash-ac-expand-btn").forEach((btn) => { + btn.addEventListener("click", (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + const id = btn.getAttribute("data-dash-ex-id"); + if (id && window.hubOpenMonitorExpand) window.hubOpenMonitorExpand(id); + }); + }); + } + + function renderAccounts(accounts, threshold) { + const rows = (Array.isArray(accounts) ? accounts : []).filter(accountHasOpenPositions); + if (!rows.length) { + elAccounts.innerHTML = '
        当前无持仓账户
        '; + return; + } + elAccounts.innerHTML = rows + .map((ac) => { + const alert = !!ac.loss_alert; + const unmon = !ac.monitored; + const lossPct = Number(ac.daily_loss_pct); + const barW = + alert && Number.isFinite(lossPct) + ? Math.min(100, (lossPct / Math.max(threshold, 1)) * 100) + : 0; + const badge = alert + ? `单日亏损 ≥${threshold}%` + : `${esc(ac.status || "—")}`; + const exId = ac && ac.id != null ? String(ac.id) : ""; + const expandBtn = exId + ? `` + : ""; + const lossBar = + alert && barW > 0 + ? `
        ` + : ""; + const cardCls = ac.options_layout ? " dash-ac-card-options" : ""; + return `
        +
        +
        ${esc(ac.name || "—")}
        +
        ${badge}${expandBtn}
        +
        + ${lossBar} +
        ${renderAccountPositions(ac)}
        +
        `; + }) + .join(""); + bindDashboardExpand(); + if (window.OptionsExpiryCountdown && OptionsExpiryCountdown.ensureTimer) { + OptionsExpiryCountdown.ensureTimer(); + } + } + + function renderTrades(trades, accounts) { + if (!elTrades) return; + const rows = Array.isArray(trades) ? trades : []; + if (!rows.length) { + elTrades.innerHTML = '
        今日暂无平仓
        '; + return; + } + const alertNames = new Set( + (accounts || []).filter((a) => a.loss_alert).map((a) => String(a.name || "")) + ); + const body = rows + .map((t) => { + const pnl = Number(t.pnl_amount); + const rowAlert = alertNames.has(String(t.account_name || "")); + return ` + ${esc(t.trading_day || "—")} + ${esc(t.account_name || "—")} + ${esc(t.symbol || "—")} + ${esc(t.direction || "—")} + ${esc(t.result || "—")} + ${pnlSigned(pnl, 2)} + ${esc(t.closed_at || "—")} + `; + }) + .join(""); + elTrades.innerHTML = `
        + + + + ${body} +
        交易日账户合约方向结果盈亏时间
        `; + } + + function renderPayload(data) { + const totals = data.totals || {}; + const threshold = Number(data.loss_alert_pct_threshold) || 5; + const alertCount = Number(data.loss_alert_count) || 0; + if (elDay) elDay.textContent = totals.trading_day || data.trading_day || "—"; + if (elUpdated) elUpdated.textContent = data.updated_at || "—"; + renderKpi(totals); + renderAccounts(data.accounts, threshold); + renderTrades(data.closed_trades, data.accounts); + if (elBanner && elBannerText) { + if (alertCount > 0) { + const names = (data.accounts || []) + .filter((a) => a.loss_alert) + .map((a) => a.name) + .join(","); + elBanner.classList.add("is-on"); + elBannerText.textContent = `${alertCount} 户单日平仓亏损超过资金合计 ${threshold}%:${names}`; + } else { + elBanner.classList.remove("is-on"); + elBannerText.textContent = ""; + } + } + } + + async function fetchDashboardSnapshot(opts) { + const options = opts || {}; + if (loading && !options.force) return; + loading = true; + if (!options.silent) setStatus("同步中…"); + try { + const r = await fetch("/api/dashboard/daily", { credentials: "same-origin" }); + if (r.status === 401) { + location.href = "/login?next=" + encodeURIComponent(location.pathname); + return; + } + const data = await r.json(); + if (!data.ok) throw new Error(data.detail || data.msg || data.error || "加载失败"); + const ver = Number(data.dashboard_version) || 0; + if (ver) localDashVersion = ver; + renderPayload(data); + const sec = Number(data.poll_interval_sec) || 5; + setStatus(options.silent ? `SSE 已连接 · 后台每 ${sec}s 聚合` : `已更新 · 后台每 ${sec}s 聚合`); + } catch (e) { + setStatus(String(e.message || e), true); + } finally { + loading = false; + } + } + + function closeDashboardStream() { + if (dashEventSource) { + dashEventSource.close(); + dashEventSource = null; + } + if (dashReconnectTimer) { + clearTimeout(dashReconnectTimer); + dashReconnectTimer = null; + } + } + + function connectDashboardStream() { + closeDashboardStream(); + dashEventSource = new EventSource("/api/dashboard/stream"); + dashEventSource.addEventListener("dashboard", (ev) => { + try { + const st = JSON.parse(ev.data || "{}"); + const ver = Number(st.dashboard_version) || 0; + if (ver && ver !== localDashVersion) { + void fetchDashboardSnapshot({ silent: true }); + } else if (st.aggregating) { + setStatus("后台聚合中…"); + } + } catch (_) { + /* ignore */ + } + }); + dashEventSource.onerror = () => { + closeDashboardStream(); + setStatus("SSE 断开,8s 后重连…", true); + dashReconnectTimer = setTimeout(() => { + if (inited) { + connectDashboardStream(); + void fetchDashboardSnapshot({ silent: true }); + } + }, 8000); + }; + } + + async function requestDashboardRefresh() { + try { + await fetch("/api/dashboard/refresh", { method: "POST", credentials: "same-origin" }); + } catch (_) { + /* ignore */ + } + } + + function startLive() { + void fetchDashboardSnapshot(); + connectDashboardStream(); + } + + function stopLive() { + closeDashboardStream(); + setStatus(""); + } + + if (btnRefresh) { + btnRefresh.addEventListener("click", () => { + void requestDashboardRefresh(); + void fetchDashboardSnapshot({ force: true }); + }); + } + + window.hubDashboardPage = { + init() { + inited = true; + startLive(); + }, + destroy() { + inited = false; + stopLive(); + }, + }; +})(); diff --git a/manual_trading_hub/static/funds.js b/manual_trading_hub/static/funds.js new file mode 100644 index 0000000..a0e1f87 --- /dev/null +++ b/manual_trading_hub/static/funds.js @@ -0,0 +1,529 @@ +/** + * 中控资金概况:总资金曲线,分户资金与回撤(资金户+交易户,不含浮盈). + */ +(function () { + const page = document.getElementById("page-funds"); + if (!page) return; + + const elStatus = document.getElementById("funds-status"); + const elTotal = document.getElementById("funds-total-usdt"); + const elDdU = document.getElementById("funds-total-dd-u"); + const elDdPct = document.getElementById("funds-total-dd-pct"); + const elDelta = document.getElementById("funds-total-delta"); + const elPeriod = document.getElementById("funds-total-period"); + const elPeriodSub = document.getElementById("funds-total-period-sub"); + const elPeriodBanner = document.getElementById("funds-period-delta"); + const elPeriodPct = document.getElementById("funds-period-pct"); + const elDayChip = document.getElementById("funds-day-chip"); + const elPnlBanner = document.getElementById("funds-pnl-banner"); + const elMeta = document.getElementById("funds-meta"); + const elDescBody = document.getElementById("funds-desc-body"); + const elChartSub = document.getElementById("funds-chart-sub"); + const elChartHost = document.getElementById("funds-chart-total"); + const elAccounts = document.getElementById("funds-accounts"); + const elBtnRefresh = document.getElementById("funds-btn-refresh"); + + const elFs = document.getElementById("funds-fullscreen"); + const elFsBackdrop = document.getElementById("funds-fs-backdrop"); + const elFsClose = document.getElementById("funds-fs-close"); + const elFsTitle = document.getElementById("funds-fs-title"); + const elFsSub = document.getElementById("funds-fs-sub"); + const elFsTotal = document.getElementById("funds-fs-total"); + const elFsFunding = document.getElementById("funds-fs-funding"); + const elFsTrading = document.getElementById("funds-fs-trading"); + const elFsDelta = document.getElementById("funds-fs-delta"); + const elFsDd = document.getElementById("funds-fs-dd"); + const elFsChartHost = document.getElementById("funds-fs-chart"); + + let chart = null; + let lineSeries = null; + let fsChart = null; + let fsLineSeries = null; + let inited = false; + let loading = false; + let lastOverview = null; + let fsAccountKey = ""; + + function fmt(n, d) { + if (n == null || n === "" || !Number.isFinite(Number(n))) return "—"; + return Number(n).toFixed(d == null ? 2 : d); + } + + function fmtDelta(n) { + if (n == null || !Number.isFinite(Number(n))) return "—"; + const v = Number(n); + const sign = v > 0 ? "+" : ""; + return sign + v.toFixed(2) + " U"; + } + + function fmtPct(n) { + if (n == null || !Number.isFinite(Number(n))) return "—"; + const v = Number(n); + const sign = v > 0 ? "+" : ""; + return sign + v.toFixed(2) + "%"; + } + + function deltaClass(n) { + if (!Number.isFinite(Number(n))) return ""; + if (Number(n) > 0) return "pos"; + if (Number(n) < 0) return "neg"; + return ""; + } + + function setStatus(msg, isErr) { + if (!elStatus) return; + elStatus.textContent = msg || ""; + elStatus.className = "funds-status" + (isErr ? " err" : ""); + } + + function seriesToChartData(series) { + return (series || []) + .filter(function (p) { + return p && p.day && Number.isFinite(Number(p.total_usdt)); + }) + .map(function (p) { + return { time: String(p.day), value: Number(p.total_usdt) }; + }); + } + + function destroyChart() { + if (chart) { + chart.remove(); + chart = null; + lineSeries = null; + } + if (elChartHost) elChartHost.innerHTML = ""; + } + + function destroyFsChart() { + if (fsChart) { + fsChart.remove(); + fsChart = null; + fsLineSeries = null; + } + if (elFsChartHost) elFsChartHost.innerHTML = ""; + } + + function chartPalette() { + const light = document.documentElement.getAttribute("data-theme") === "light"; + return light + ? { bg: "#eef4fa", text: "#4a6078", border: "#c5d4e4", line: "#006e9a", top: "#006e9a44" } + : { bg: "#060a14", text: "#6b8aa8", border: "#1a2840", line: "#00d4ff", top: "#00d4ff55" }; + } + + function createAreaChart(host) { + const p = chartPalette(); + const c = LightweightCharts.createChart(host, { + layout: { + background: { color: p.bg }, + textColor: p.text, + fontSize: 11, + }, + grid: { + vertLines: { color: p.border, visible: true }, + horzLines: { color: p.border, visible: true }, + }, + rightPriceScale: { + borderColor: p.border, + scaleMargins: { top: 0.08, bottom: 0.08 }, + }, + timeScale: { + borderColor: p.border, + timeVisible: true, + fixLeftEdge: true, + fixRightEdge: true, + }, + crosshair: { mode: LightweightCharts.CrosshairMode.Normal }, + handleScroll: { mouseWheel: true, pressedMouseMove: true }, + handleScale: { axisPressedMouseMove: true, mouseWheel: true, pinch: true }, + }); + const s = c.addAreaSeries({ + lineColor: p.line, + topColor: p.top || p.line + "44", + bottomColor: p.line + "08", + lineWidth: 2, + priceFormat: { type: "price", precision: 2, minMove: 0.01 }, + }); + function syncSize() { + if (!c || !host) return; + const w = Math.max(host.clientWidth || 0, 1); + const h = Math.max(host.clientHeight || 0, 200); + c.applyOptions({ width: w, height: h }); + } + new ResizeObserver(function () { + syncSize(); + }).observe(host); + syncSize(); + return { chart: c, series: s }; + } + + function ensureChart() { + if (!elChartHost || !window.LightweightCharts) return; + if (chart) return; + const built = createAreaChart(elChartHost); + chart = built.chart; + lineSeries = built.series; + } + + function ensureFsChart() { + if (!elFsChartHost || !window.LightweightCharts) return; + if (fsChart) return; + const built = createAreaChart(elFsChartHost); + fsChart = built.chart; + fsLineSeries = built.series; + } + + function esc(s) { + return String(s || "") + .replace(/&/g, "&") + .replace(/期权户' + + (optFunding || "—") + + " / " + + (optTrading || "—") + + "
        " + : ""; + const dd = ac.drawdown || {}; + const ddU = dd.max_drawdown_u != null ? fmt(dd.max_drawdown_u, 2) + " U" : "—"; + const ddPct = dd.max_drawdown_pct != null ? fmt(dd.max_drawdown_pct, 2) + "%" : "—"; + const deltaCls = deltaClass(ac.day_delta_usdt); + const deltaText = monitored ? fmtDelta(ac.day_delta_usdt) : "—"; + const periodCls = deltaClass(ac.period_delta_usdt); + const periodText = monitored ? fmtDelta(ac.period_delta_usdt) : "—"; + return ( + '" + ); + }) + .join(""); + + elAccounts.querySelectorAll(".funds-ac-card:not(.is-off)").forEach(function (btn) { + btn.addEventListener("click", function () { + openAccountFullscreen(btn.getAttribute("data-key")); + }); + }); + } + + function findAccount(key) { + const accounts = (lastOverview && lastOverview.accounts) || []; + return accounts.find(function (ac) { + return String(ac.key || "") === String(key || ""); + }); + } + + function closeAccountFullscreen() { + fsAccountKey = ""; + destroyFsChart(); + if (elFs) { + elFs.classList.add("hidden"); + elFs.setAttribute("aria-hidden", "true"); + } + document.body.classList.remove("funds-fullscreen-open"); + } + + function openAccountFullscreen(key) { + const ac = findAccount(key); + if (!ac || !ac.monitored) return; + fsAccountKey = String(key || ""); + const dd = ac.drawdown || {}; + const meta = lastOverview || {}; + if (elFsTitle) elFsTitle.textContent = ac.name || ac.key || "—"; + if (elFsSub) { + const parts = [ + "资金户 + 交易户 + 期权户(USDC≈USDT,不含浮盈)", + "交易日 " + (meta.trading_day || "—"), + "自 " + (meta.history_start_day || "—") + " 起", + ]; + elFsSub.textContent = parts.join(" · "); + } + if (elFsTotal) { + elFsTotal.textContent = + ac.data_ok && ac.total_usdt != null ? fmt(ac.total_usdt, 2) + " U" : "—"; + } + if (elFsFunding) { + elFsFunding.textContent = + ac.funding_usdt != null ? fmt(ac.funding_usdt, 2) + " U" : "—"; + } + if (elFsTrading) { + elFsTrading.textContent = + ac.trading_usdt != null ? fmt(ac.trading_usdt, 2) + " U" : "—"; + } + if (elFsDelta) { + elFsDelta.textContent = fmtDelta(ac.day_delta_usdt); + elFsDelta.className = "v " + deltaClass(ac.day_delta_usdt); + } + if (elFsDd) { + const ddU = dd.max_drawdown_u != null ? fmt(dd.max_drawdown_u, 2) + " U" : "—"; + const ddPct = dd.max_drawdown_pct != null ? fmt(dd.max_drawdown_pct, 2) + "%" : "—"; + elFsDd.textContent = ddU + " / " + ddPct; + } + if (elFs) { + elFs.classList.remove("hidden"); + elFs.setAttribute("aria-hidden", "false"); + document.body.classList.add("funds-fullscreen-open"); + } + destroyFsChart(); + const pts = seriesToChartData(ac.series || []); + if (pts.length) { + ensureFsChart(); + if (fsLineSeries) { + fsLineSeries.setData(pts); + fsChart.timeScale().fitContent(); + } + requestAnimationFrame(function () { + if (fsChart && elFsChartHost) { + fsChart.applyOptions({ + width: elFsChartHost.clientWidth, + height: elFsChartHost.clientHeight, + }); + fsChart.timeScale().fitContent(); + } + }); + } else if (elFsChartHost) { + elFsChartHost.innerHTML = + '

        暂无历史曲线,请保持监控板运行以积累快照

        '; + } + } + + function renderDesc(data) { + const start = (data && data.history_start_day) || "—"; + const keep = (data && data.keep_days) || 180; + const hour = data && data.reset_hour != null ? data.reset_hour : 8; + if (elDescBody) { + elDescBody.textContent = + "总资金 = 各监控户(永续资金账户 + 交易账户 + 期权账户,USDC 按 1:1 计入 USDT);自 " + + start + + " 起按北京时间 " + + hour + + ":00 交易日切日快照,最多保留 " + + keep + + " 天.起算日由环境变量 HUB_FUND_HISTORY_START_DAY 配置."; + } + if (elChartSub) { + elChartSub.textContent = keep + " TRADING DAYS"; + } + } + + function renderOverview(data) { + lastOverview = data; + renderDesc(data); + const totals = data.totals || {}; + const dd = totals.drawdown || {}; + if (elTotal) { + elTotal.textContent = + totals.total_usdt != null ? fmt(totals.total_usdt, 2) + " U" : "—"; + } + if (elDdU) elDdU.textContent = dd.max_drawdown_u != null ? fmt(dd.max_drawdown_u, 2) + " U" : "—"; + if (elDdPct) { + elDdPct.textContent = dd.max_drawdown_pct != null ? fmt(dd.max_drawdown_pct, 2) + "%" : "—"; + } + if (elDelta) { + elDelta.textContent = fmtDelta(totals.day_delta_usdt); + elDelta.className = "funds-stat-val " + deltaClass(totals.day_delta_usdt); + } + const periodCls = deltaClass(totals.period_delta_usdt); + if (elPeriod) { + elPeriod.textContent = fmtDelta(totals.period_delta_usdt); + elPeriod.className = "funds-stat-val " + periodCls; + } + if (elPeriodSub) { + const startDay = data.history_start_day || "—"; + const pct = fmtPct(totals.period_delta_pct); + elPeriodSub.textContent = + pct !== "—" + ? "自 " + startDay + " · " + pct + : "自 " + startDay + " 起相对起点"; + } + if (elPeriodBanner) { + elPeriodBanner.textContent = fmtDelta(totals.period_delta_usdt); + elPeriodBanner.className = "funds-pnl-value " + periodCls; + } + if (elPeriodPct) { + elPeriodPct.textContent = fmtPct(totals.period_delta_pct); + elPeriodPct.className = "funds-pnl-pct " + periodCls; + } + if (elDayChip) { + elDayChip.textContent = fmtDelta(totals.day_delta_usdt); + elDayChip.className = "funds-pnl-side-val " + deltaClass(totals.day_delta_usdt); + } + if (elPnlBanner) { + elPnlBanner.className = + "funds-pnl-banner" + (periodCls ? " is-" + periodCls : ""); + } + if (elMeta) { + const parts = [ + "交易日 " + (data.trading_day || "—"), + "切日 " + (data.reset_hour != null ? data.reset_hour : 8) + ":00 北京", + "自 " + (data.history_start_day || "—") + " 起", + "最多 " + (data.keep_days || 180) + " 交易日", + ]; + if (data.updated_at) parts.push("刷新 " + data.updated_at); + if (totals.live_known_count != null) { + parts.push("合计含 " + totals.live_known_count + " 户"); + } + elMeta.textContent = parts.join(" · "); + } + ensureChart(); + if (lineSeries) { + const pts = seriesToChartData(totals.series || []); + if (pts.length) { + lineSeries.setData(pts); + chart.timeScale().fitContent(); + } else { + lineSeries.setData([]); + } + } + renderAccounts(data.accounts || []); + // 分户卡片渲染后高度会变,补一次尺寸,避免 1080p 一屏布局下曲线被裁切 + if (chart && elChartHost) { + requestAnimationFrame(function () { + if (!chart || !elChartHost) return; + chart.applyOptions({ + width: Math.max(elChartHost.clientWidth || 0, 1), + height: Math.max(elChartHost.clientHeight || 0, 200), + }); + chart.timeScale().fitContent(); + }); + } + if (fsAccountKey) { + const ac = findAccount(fsAccountKey); + if (ac && ac.monitored) openAccountFullscreen(fsAccountKey); + else closeAccountFullscreen(); + } + } + + async function load() { + if (loading) return; + loading = true; + setStatus("加载中…"); + try { + const r = await fetch("/api/hub/fund-overview", { credentials: "same-origin" }); + const j = await r.json(); + if (!r.ok) { + setStatus(j.detail || j.msg || "加载失败", true); + return; + } + renderOverview(j); + setStatus(""); + } catch (e) { + setStatus(String(e.message || e), true); + } finally { + loading = false; + } + } + + function bind() { + if (elBtnRefresh) elBtnRefresh.addEventListener("click", load); + if (elFsBackdrop) elFsBackdrop.addEventListener("click", closeAccountFullscreen); + if (elFsClose) elFsClose.addEventListener("click", closeAccountFullscreen); + document.addEventListener("keydown", function (ev) { + if (ev.key === "Escape" && fsAccountKey) closeAccountFullscreen(); + }); + document.addEventListener("hub-theme-change", function () { + destroyChart(); + destroyFsChart(); + load(); + }); + } + + function init() { + if (!page || page.classList.contains("hidden")) return; + if (!inited) { + bind(); + inited = true; + } + load(); + } + + function destroy() { + closeAccountFullscreen(); + destroyChart(); + } + + window.hubFundsPage = { init: init, destroy: destroy, reload: load }; +})(); diff --git a/manual_trading_hub/static/help.js b/manual_trading_hub/static/help.js new file mode 100644 index 0000000..05a1d2f --- /dev/null +++ b/manual_trading_hub/static/help.js @@ -0,0 +1,125 @@ +/** + * 使用说明:中控 docs/help MD 章节. + */ +(function () { + const page = document.getElementById("page-help"); + if (!page) return; + + const tocEl = document.getElementById("help-toc-nav"); + const statusEl = document.getElementById("help-load-status"); + const docBody = document.getElementById("help-doc-body"); + const docSource = document.getElementById("help-doc-source"); + + let sectionsMeta = []; + let activeKey = "quickstart"; + let cache = {}; + let bound = false; + + async function apiFetch(url) { + const r = await fetch(url, { credentials: "same-origin" }); + const data = await r.json(); + if (!r.ok || !data.ok) throw new Error((data && data.msg) || r.statusText || "请求失败"); + return data; + } + + function esc(s) { + return String(s ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function sectionFromHash() { + const h = (window.location.hash || "").replace(/^#/, "").trim().toLowerCase(); + if (!h) return null; + return sectionsMeta.some((s) => s.key === h) ? h : null; + } + + function setHash(key) { + const next = `#${key}`; + if (window.location.hash !== next) { + history.replaceState(null, "", `/help${next}`); + } + } + + function renderToc() { + if (!tocEl) return; + tocEl.innerHTML = sectionsMeta + .map( + (s) => + `` + ) + .join(""); + tocEl.querySelectorAll(".help-toc-item").forEach((btn) => { + btn.addEventListener("click", () => { + const key = btn.getAttribute("data-key"); + if (!key || key === activeKey) return; + activeKey = key; + setHash(key); + renderToc(); + void loadSection(key); + }); + }); + } + + function renderSection(data) { + if (docBody) docBody.innerHTML = data.content_html || ""; + if (docSource) { + docSource.textContent = data.md_source ? `来源: ${data.md_source}` : ""; + } + if (statusEl) statusEl.textContent = ""; + } + + async function loadSection(key) { + if (cache[key]) { + renderSection(cache[key]); + return; + } + if (statusEl) statusEl.textContent = "加载中…"; + try { + const data = await apiFetch(`/api/help/${encodeURIComponent(key)}`); + cache[key] = data; + renderSection(data); + } catch (err) { + if (statusEl) statusEl.textContent = ""; + if (docBody) docBody.innerHTML = `

        ${esc(err.message || "加载失败")}

        `; + } + } + + async function loadMeta() { + const data = await apiFetch("/api/help/meta"); + sectionsMeta = data.sections || []; + const fromHash = sectionFromHash(); + if (fromHash) activeKey = fromHash; + else if (sectionsMeta.length && !sectionsMeta.some((s) => s.key === activeKey)) { + activeKey = sectionsMeta[0].key; + } + renderToc(); + await loadSection(activeKey); + if (!sectionFromHash() && activeKey) setHash(activeKey); + } + + function bindOnce() { + if (bound) return; + bound = true; + window.addEventListener("hashchange", () => { + const key = sectionFromHash(); + if (!key || key === activeKey) return; + activeKey = key; + renderToc(); + void loadSection(key); + }); + } + + window.hubHelpPage = { + init() { + bindOnce(); + void loadMeta().catch((err) => { + if (statusEl) statusEl.textContent = ""; + if (docBody) docBody.innerHTML = `

        ${esc(err.message || "加载失败")}

        `; + }); + }, + destroy() {}, + }; +})(); diff --git a/manual_trading_hub/static/icons/apple-touch-icon.png b/manual_trading_hub/static/icons/apple-touch-icon.png new file mode 100644 index 0000000..bd835ad Binary files /dev/null and b/manual_trading_hub/static/icons/apple-touch-icon.png differ diff --git a/manual_trading_hub/static/icons/favicon.ico b/manual_trading_hub/static/icons/favicon.ico new file mode 100644 index 0000000..0af9b9c Binary files /dev/null and b/manual_trading_hub/static/icons/favicon.ico differ diff --git a/manual_trading_hub/static/icons/icon-16.png b/manual_trading_hub/static/icons/icon-16.png new file mode 100644 index 0000000..b3a4ee1 Binary files /dev/null and b/manual_trading_hub/static/icons/icon-16.png differ diff --git a/manual_trading_hub/static/icons/icon-192.png b/manual_trading_hub/static/icons/icon-192.png new file mode 100644 index 0000000..92351e1 Binary files /dev/null and b/manual_trading_hub/static/icons/icon-192.png differ diff --git a/manual_trading_hub/static/icons/icon-32.png b/manual_trading_hub/static/icons/icon-32.png new file mode 100644 index 0000000..dc2186f Binary files /dev/null and b/manual_trading_hub/static/icons/icon-32.png differ diff --git a/manual_trading_hub/static/icons/icon-512.png b/manual_trading_hub/static/icons/icon-512.png new file mode 100644 index 0000000..a46fe93 Binary files /dev/null and b/manual_trading_hub/static/icons/icon-512.png differ diff --git a/manual_trading_hub/static/icons/icon.svg b/manual_trading_hub/static/icons/icon.svg new file mode 100644 index 0000000..2277788 --- /dev/null +++ b/manual_trading_hub/static/icons/icon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/manual_trading_hub/static/icons/manifest.webmanifest b/manual_trading_hub/static/icons/manifest.webmanifest new file mode 100644 index 0000000..9ba6dcb --- /dev/null +++ b/manual_trading_hub/static/icons/manifest.webmanifest @@ -0,0 +1,23 @@ +{ + "name": "复盘系统中控", + "short_name": "中控", + "description": "三所交易监控与行情中控", + "start_url": "/monitor", + "display": "standalone", + "background_color": "#0b0e18", + "theme_color": "#0b0e18", + "icons": [ + { + "src": "/assets/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/assets/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html new file mode 100644 index 0000000..4da8fd3 --- /dev/null +++ b/manual_trading_hub/static/index.html @@ -0,0 +1,1387 @@ + + + + + + + + + + + + + 复盘系统中控 + + + + + + + + + + + + +
        +
        +
        + +
        +
        复盘系统中控
        +
        MULTI-EXCHANGE · OPS
        +
        +
        + +
        + + + +
        +
        +

        MON 监控区

        +
        +
        + + + 服务器状态 + 加载中… + +
        +
        +
        + 服务器 +
        +
        + + +
        +
        +
        +
        +
        + CPU + +
        +
        + +
        +
        +
        + 内存 + +
        +
        + +
        +
        +
        + 硬盘 + +
        +
        + +
        +
        +
        + 网络 + 实时 +
        +
        + ↑ — + ↓ — +
        +
        +
        +
        + + +
        + + 操作 · 刷新 / 紧急全平 + + +
        + + + + + +
        +
        +
        +
        + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
        + + + + + + + + + + + +
        + + + + + + + + + + + + + + + + + + + + + diff --git a/manual_trading_hub/static/login.html b/manual_trading_hub/static/login.html new file mode 100644 index 0000000..1ec3276 --- /dev/null +++ b/manual_trading_hub/static/login.html @@ -0,0 +1,165 @@ + + + + + + + + + + + + + 登录 · 复盘系统中控 + + + + + + + + + diff --git a/manual_trading_hub/static/logs.js b/manual_trading_hub/static/logs.js new file mode 100644 index 0000000..fd2b0fa --- /dev/null +++ b/manual_trading_hub/static/logs.js @@ -0,0 +1,169 @@ +/** + * 系统日志:三所 + 中控 PM2 stdout/stderr. + */ +(function () { + const page = document.getElementById("page-logs"); + if (!page) return; + + const tabsEl = document.getElementById("hub-logs-tabs"); + const statusEl = document.getElementById("hub-logs-status"); + const outEl = document.getElementById("hub-logs-out"); + const errEl = document.getElementById("hub-logs-err"); + const btnRefresh = document.getElementById("hub-logs-btn-refresh"); + const btnPause = document.getElementById("hub-logs-btn-pause"); + + const POLL_MS = 4000; + let activeKey = "binance"; + let tabsMeta = []; + let pollTimer = null; + let paused = false; + let loading = false; + let bound = false; + + async function apiFetch(url) { + const r = await fetch(url, { credentials: "same-origin" }); + const ct = (r.headers.get("content-type") || "").toLowerCase(); + if (ct.includes("application/json")) { + const data = await r.json(); + if (!r.ok) throw new Error((data && (data.msg || data.detail)) || r.statusText || "请求失败"); + return data; + } + if (!r.ok) throw new Error(r.statusText || "请求失败"); + return r; + } + + function esc(s) { + return String(s ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function wasScrolledToBottom(el) { + if (!el) return true; + return el.scrollHeight - el.scrollTop - el.clientHeight < 24; + } + + function setPreText(el, text, stickBottom) { + if (!el) return; + const atBottom = stickBottom || wasScrolledToBottom(el); + el.textContent = text || "(暂无日志)"; + if (atBottom) el.scrollTop = el.scrollHeight; + } + + function setStatus(text, isErr) { + if (!statusEl) return; + statusEl.textContent = text || ""; + statusEl.classList.toggle("is-err", !!isErr); + } + + function renderTabs() { + if (!tabsEl) return; + tabsEl.innerHTML = tabsMeta + .map( + (t) => + `` + ) + .join(""); + tabsEl.querySelectorAll(".hub-logs-tab").forEach((btn) => { + btn.addEventListener("click", () => { + const key = btn.getAttribute("data-key"); + if (!key || key === activeKey) return; + activeKey = key; + renderTabs(); + void loadLogs(true); + }); + }); + } + + async function loadMeta() { + const meta = await apiFetch("/api/system-logs/meta"); + tabsMeta = Array.isArray(meta.targets) ? meta.targets : []; + if (tabsMeta.length && !tabsMeta.some((t) => t.key === activeKey)) { + activeKey = tabsMeta[0].key; + } + renderTabs(); + } + + async function loadLogs(force) { + if (loading && !force) return; + loading = true; + try { + const data = await apiFetch(`/api/system-logs/${encodeURIComponent(activeKey)}?lines=200`); + setPreText(outEl, data.out || "", true); + setPreText(errEl, data.err || "", true); + const ts = data.updated_at ? new Date(data.updated_at * 1000) : new Date(); + const hh = String(ts.getHours()).padStart(2, "0"); + const mm = String(ts.getMinutes()).padStart(2, "0"); + const ss = String(ts.getSeconds()).padStart(2, "0"); + const missing = []; + if (!data.out_exists) missing.push("实时"); + if (!data.err_exists) missing.push("报错"); + const hint = missing.length ? ` · ${missing.join("/")}日志文件暂无` : ""; + setStatus(`已更新 ${hh}:${mm}:${ss}${hint}`, false); + } catch (e) { + setStatus(e.message || "加载失败", true); + } finally { + loading = false; + } + } + + function startPoll() { + stopPoll(); + if (paused) return; + pollTimer = window.setInterval(() => { + void loadLogs(false); + }, POLL_MS); + } + + function stopPoll() { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + } + + function bindControls() { + if (bound) return; + bound = true; + if (btnRefresh) { + btnRefresh.addEventListener("click", () => { + void loadLogs(true); + }); + } + if (btnPause) { + btnPause.addEventListener("click", () => { + paused = !paused; + btnPause.textContent = paused ? "继续刷新" : "暂停刷新"; + btnPause.classList.toggle("is-paused", paused); + if (paused) stopPoll(); + else startPoll(); + }); + } + } + + async function init() { + bindControls(); + paused = false; + if (btnPause) { + btnPause.textContent = "暂停刷新"; + btnPause.classList.remove("is-paused"); + } + setStatus("加载中…", false); + try { + await loadMeta(); + await loadLogs(true); + startPoll(); + } catch (e) { + setStatus(e.message || "初始化失败", true); + } + } + + function destroy() { + stopPoll(); + setStatus("", false); + } + + window.hubLogsPage = { init, destroy }; +})(); diff --git a/manual_trading_hub/static/plan.js b/manual_trading_hub/static/plan.js new file mode 100644 index 0000000..d37a2bc --- /dev/null +++ b/manual_trading_hub/static/plan.js @@ -0,0 +1,772 @@ +/** + * 开仓计划:新建 / 进行中 / 历史 / 胜率统计 + */ +(function () { + const page = document.getElementById("page-plan"); + if (!page) return; + + let meta = null; + let activePlans = []; + let archivedPlans = []; + let statsPeriod = "all"; + let statsDim = "symbol"; + let statsDateFrom = ""; + let statsDateTo = ""; + let editingPlanId = null; + let inited = false; + + function $(id) { + return document.getElementById(id); + } + + function esc(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function toast(msg, isErr) { + const el = $("toast"); + if (!el) return; + el.textContent = msg; + el.className = isErr ? "err" : "ok"; + clearTimeout(el._t); + el._t = setTimeout(function () { + el.className = ""; + el.textContent = ""; + }, 3200); + } + + async function api(path, opts) { + const r = await fetch(path, Object.assign({ credentials: "same-origin" }, opts || {})); + let data = {}; + try { + data = await r.json(); + } catch (_e) { + data = {}; + } + if (!r.ok) { + const detail = (data && data.detail) || r.statusText || "请求失败"; + throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail)); + } + return data; + } + + function todayIso() { + const d = new Date(); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return y + "-" + m + "-" + day; + } + + function exchangeLabel(key) { + const ex = (meta && meta.exchanges) || []; + const row = ex.find(function (e) { + return String(e.key) === String(key); + }); + return (row && row.name) || key || "—"; + } + + function fmtPnl(v) { + if (v == null || v === "") return ""; + const n = Number(v); + if (!Number.isFinite(n)) return String(v); + return (n >= 0 ? "+" : "") + n.toFixed(2) + "U"; + } + + function fillSelect(el, options, valueKey, labelKey) { + if (!el) return; + el.innerHTML = ""; + (options || []).forEach(function (opt) { + const o = document.createElement("option"); + if (typeof opt === "string") { + o.value = opt; + o.textContent = opt; + } else { + o.value = opt[valueKey]; + o.textContent = opt[labelKey]; + } + el.appendChild(o); + }); + } + + function renderDirectionRadios(container, name, selected) { + if (!container || !meta) return; + container.innerHTML = ""; + (meta.directions || []).forEach(function (d) { + const label = document.createElement("label"); + label.className = "plan-radio-label"; + const input = document.createElement("input"); + input.type = "radio"; + input.name = name; + input.value = d.value; + if (d.value === selected) input.checked = true; + label.appendChild(input); + label.appendChild(document.createTextNode(" " + d.label)); + container.appendChild(label); + }); + } + + function bindMetaToCreateForm() { + fillSelect($("plan-create-exchange"), meta.exchanges, "key", "name"); + fillSelect($("plan-create-type"), meta.plan_types, "value", "label"); + fillSelect($("plan-create-trend-tf"), meta.trend_timeframes); + fillSelect($("plan-create-entry-tf"), meta.entry_timeframes); + renderDirectionRadios($("plan-create-direction"), "plan-direction", "long"); + const dateEl = $("plan-create-date"); + if (dateEl && !dateEl.value) dateEl.value = todayIso(); + } + + function planSummaryLine(p) { + return ( + esc(p.symbol) + + " · " + + esc(exchangeLabel(p.exchange_key)) + + " · " + + esc(p.direction_label || p.direction) + + " · " + + esc(p.plan_type_label || p.plan_type) + ); + } + + function schemeOptionsHtml(selected) { + let html = ''; + (meta.entry_schemes || []).forEach(function (s) { + html += + '"; + }); + return html; + } + + function renderActiveList() { + const host = $("plan-active-list"); + const cnt = $("plan-active-count"); + if (!host) return; + if (cnt) cnt.textContent = activePlans.length ? activePlans.length + " 条" : ""; + if (!activePlans.length) { + host.innerHTML = '

        暂无进行中的计划

        '; + return; + } + host.innerHTML = activePlans + .map(function (p) { + return ( + '
        ' + + '
        ' + + '
        ' + + planSummaryLine(p) + + "
        " + + '
        ' + + '' + + '' + + "
        " + + '
        ' + + esc(p.plan_date) + + " · 趋势 " + + esc(p.trend_timeframe) + + " / 入场 " + + esc(p.entry_timeframe) + + "
        " + + '
        目标 ' + + esc(p.target_level || "—") + + " · 区间 " + + esc(p.current_range || "—") + + "
        " + + (p.note ? '
        ' + esc(p.note) + "
        " : "") + + '
        ' + + '" + + "
        " + + '
        ' + + '" + + '' + + '' + + "
        " + ); + }) + .join(""); + } + + function renderHistoryList() { + const host = $("plan-history-list"); + const cnt = $("plan-history-count"); + if (!host) return; + if (cnt) cnt.textContent = archivedPlans.length ? archivedPlans.length + " 条" : ""; + if (!archivedPlans.length) { + host.innerHTML = '

        暂无历史计划

        '; + return; + } + host.innerHTML = archivedPlans + .map(function (p) { + const pnlTxt = fmtPnl(p.pnl_amount); + const resCls = p.result === "win" ? "plan-res-win" : "plan-res-loss"; + return ( + '" + ); + }) + .join(""); + } + + function renderStatsTable(stats) { + const host = $("plan-stats-table"); + const labelEl = $("plan-stats-label"); + if (labelEl) labelEl.textContent = (stats && stats.period_label) || ""; + if (!host) return; + const items = (stats && stats.items) || []; + if (!items.length) { + host.innerHTML = '

        该范围内暂无已归档且有结果的计划

        '; + return; + } + const dimLabel = + stats.dimension === "trend_tf" + ? "趋势周期" + : stats.dimension === "entry_scheme" + ? "入场方案" + : "币种"; + let rows = items + .map(function (it) { + return ( + "" + + esc(it.label || it.key) + + "" + + (it.total || 0) + + "" + + (it.win_count || 0) + + "" + + (it.loss_count || 0) + + "" + + (it.win_rate != null ? it.win_rate + "%" : "—") + + "" + ); + }) + .join(""); + host.innerHTML = + '' + + "" + + "" + + rows + + "
        " + + esc(dimLabel) + + "计划数盈利亏损胜率
        "; + } + + function statsQuery() { + const q = new URLSearchParams(); + q.set("dimension", statsDim); + q.set("period", statsPeriod); + if (statsPeriod === "range") { + if (statsDateFrom) q.set("date_from", statsDateFrom); + if (statsDateTo) q.set("date_to", statsDateTo); + } + return q.toString(); + } + + async function loadMeta() { + const data = await api("/api/entry-plans/meta"); + meta = data; + bindMetaToCreateForm(); + } + + async function loadActive() { + const data = await api("/api/entry-plans?status=active"); + activePlans = data.plans || []; + renderActiveList(); + } + + async function loadHistory() { + const data = await api("/api/entry-plans?status=archived"); + archivedPlans = data.plans || []; + renderHistoryList(); + } + + async function loadStats() { + const data = await api("/api/entry-plans/stats?" + statsQuery()); + renderStatsTable(data.stats || {}); + } + + async function refreshAll() { + await Promise.all([loadActive(), loadHistory(), loadStats()]); + } + + function fmtRefreshTime() { + const d = new Date(); + const h = String(d.getHours()).padStart(2, "0"); + const m = String(d.getMinutes()).padStart(2, "0"); + const s = String(d.getSeconds()).padStart(2, "0"); + return h + ":" + m + ":" + s; + } + + async function refreshPage() { + const btn = $("plan-btn-refresh"); + const status = $("plan-refresh-status"); + if (btn) btn.disabled = true; + if (status) status.textContent = "刷新中…"; + try { + await loadMeta(); + await refreshAll(); + if (status) status.textContent = "已刷新 " + fmtRefreshTime(); + } catch (e) { + toast(e.message || "刷新失败", true); + if (status) status.textContent = "刷新失败"; + } finally { + if (btn) btn.disabled = false; + } + } + + function readCreateForm() { + const dir = document.querySelector('input[name="plan-direction"]:checked'); + return { + plan_date: ($("plan-create-date") && $("plan-create-date").value) || "", + exchange_key: ($("plan-create-exchange") && $("plan-create-exchange").value) || "", + symbol: ($("plan-create-symbol") && $("plan-create-symbol").value) || "", + plan_type: ($("plan-create-type") && $("plan-create-type").value) || "", + trend_timeframe: ($("plan-create-trend-tf") && $("plan-create-trend-tf").value) || "", + entry_timeframe: ($("plan-create-entry-tf") && $("plan-create-entry-tf").value) || "", + direction: (dir && dir.value) || "", + target_level: ($("plan-create-target") && $("plan-create-target").value) || "", + current_range: ($("plan-create-range") && $("plan-create-range").value) || "", + note: ($("plan-create-note") && $("plan-create-note").value) || "", + }; + } + + function resetCreateForm() { + const form = $("plan-create-form"); + if (form) form.reset(); + bindMetaToCreateForm(); + if ($("plan-create-date")) $("plan-create-date").value = todayIso(); + } + + function openDetailModal(plan) { + const modal = $("plan-detail-modal"); + const body = $("plan-detail-body"); + const title = $("plan-detail-title"); + if (!modal || !body || !plan) return; + if (title) title.textContent = plan.symbol + " · " + (plan.result_label || "计划"); + const rows = [ + ["日期", plan.plan_date], + ["交易所", exchangeLabel(plan.exchange_key)], + ["币种", plan.symbol], + ["类型", plan.plan_type_label], + ["趋势周期", plan.trend_timeframe], + ["入场周期", plan.entry_timeframe], + ["方向", plan.direction_label], + ["目标位", plan.target_level || "—"], + ["当前区间", plan.current_range || "—"], + ["入场方案", plan.entry_scheme_label], + ["结果", plan.result_label || "—"], + ["盈亏", fmtPnl(plan.pnl_amount) || "—"], + ["备注", plan.note || "—"], + ]; + body.innerHTML = rows + .map(function (pair) { + return ( + '
        ' + + esc(pair[0]) + + '' + + esc(pair[1]) + + "
        " + ); + }) + .join(""); + modal.classList.remove("hidden"); + modal.setAttribute("aria-hidden", "false"); + } + + function closeDetailModal() { + const modal = $("plan-detail-modal"); + if (!modal) return; + modal.classList.add("hidden"); + modal.setAttribute("aria-hidden", "true"); + } + + function buildEditFormHtml(p) { + const dirs = (meta.directions || []) + .map(function (d) { + return ( + '" + ); + }) + .join(""); + function opts(list, key, valKey, labelKey) { + return (list || []) + .map(function (o) { + const v = typeof o === "string" ? o : o[valKey]; + const lbl = typeof o === "string" ? o : o[labelKey]; + return ( + '" + ); + }) + .join(""); + } + return ( + '
        ' + + '' + + '" + + '' + + '" + + '" + + '" + + '" + + '' + + '' + + '" + + '" + + "
        " + + '' + ); + } + + function openEditModal(plan) { + const modal = $("plan-edit-modal"); + const form = $("plan-edit-form"); + if (!modal || !form || !plan) return; + editingPlanId = plan.id; + form.innerHTML = buildEditFormHtml(plan); + modal.classList.remove("hidden"); + modal.setAttribute("aria-hidden", "false"); + } + + function closeEditModal() { + const modal = $("plan-edit-modal"); + if (!modal) return; + editingPlanId = null; + modal.classList.add("hidden"); + modal.setAttribute("aria-hidden", "true"); + } + + function readEditForm(form) { + const fd = new FormData(form); + const dir = form.querySelector('input[name="edit-direction"]:checked'); + return { + plan_date: fd.get("plan_date") || "", + exchange_key: fd.get("exchange_key") || "", + symbol: fd.get("symbol") || "", + plan_type: fd.get("plan_type") || "", + trend_timeframe: fd.get("trend_timeframe") || "", + entry_timeframe: fd.get("entry_timeframe") || "", + direction: (dir && dir.value) || "", + target_level: fd.get("target_level") || "", + current_range: fd.get("current_range") || "", + entry_scheme: fd.get("entry_scheme") || "", + note: fd.get("note") || "", + }; + } + + function bindEvents() { + const refreshBtn = $("plan-btn-refresh"); + if (refreshBtn) { + refreshBtn.addEventListener("click", function () { + void refreshPage(); + }); + } + + const createForm = $("plan-create-form"); + if (createForm) { + createForm.addEventListener("submit", function (ev) { + ev.preventDefault(); + api("/api/entry-plans", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(readCreateForm()), + }) + .then(function () { + toast("计划已加入进行中"); + resetCreateForm(); + return refreshAll(); + }) + .catch(function (e) { + toast(e.message || "保存失败", true); + }); + }); + } + + const activeList = $("plan-active-list"); + if (activeList) { + activeList.addEventListener("click", function (ev) { + const t = ev.target; + if (!(t instanceof HTMLElement)) return; + const id = t.getAttribute("data-id"); + if (!id) return; + if (t.classList.contains("plan-btn-del")) { + if (!window.confirm("确定删除该进行中的计划?")) return; + api("/api/entry-plans/" + id, { method: "DELETE" }) + .then(function () { + toast("已删除"); + return refreshAll(); + }) + .catch(function (e) { + toast(e.message || "删除失败", true); + }); + return; + } + if (t.classList.contains("plan-btn-edit")) { + const plan = activePlans.find(function (p) { + return String(p.id) === String(id); + }); + if (plan) openEditModal(plan); + return; + } + if (t.classList.contains("plan-btn-archive")) { + const card = t.closest(".plan-active-card"); + const resultEl = card && card.querySelector('.plan-close-result[data-id="' + id + '"]'); + const pnlEl = card && card.querySelector('.plan-close-pnl[data-id="' + id + '"]'); + const schemeEl = card && card.querySelector('.plan-active-scheme[data-id="' + id + '"]'); + const result = resultEl && resultEl.value; + if (!result) { + toast("请先选择结果(盈/亏)", true); + return; + } + const scheme = schemeEl && schemeEl.value; + if (!scheme) { + toast("请先选择入场方案(根据实际进场填写)", true); + return; + } + const payload = { result: result, entry_scheme: scheme }; + const pnlRaw = pnlEl && pnlEl.value; + if (pnlRaw !== "" && pnlRaw != null) payload.pnl_amount = Number(pnlRaw); + api("/api/entry-plans/" + id, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + .then(function () { + toast("已归档"); + return refreshAll(); + }) + .catch(function (e) { + toast(e.message || "归档失败", true); + }); + } + }); + + activeList.addEventListener("change", function (ev) { + const t = ev.target; + if (!(t instanceof HTMLElement) || !t.classList.contains("plan-active-scheme")) return; + const id = t.getAttribute("data-id"); + const scheme = t.value; + if (!id || !scheme) return; + api("/api/entry-plans/" + id, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ entry_scheme: scheme }), + }) + .then(function () { + toast("入场方案已保存"); + return loadActive(); + }) + .catch(function (e) { + toast(e.message || "保存失败", true); + }); + }); + } + + const historyList = $("plan-history-list"); + if (historyList) { + historyList.addEventListener("click", function (ev) { + const row = ev.target.closest(".plan-history-row"); + if (!row) return; + const id = row.getAttribute("data-id"); + const plan = archivedPlans.find(function (p) { + return String(p.id) === String(id); + }); + if (plan) openDetailModal(plan); + else { + api("/api/entry-plans/" + id).then(function (data) { + openDetailModal(data.plan); + }); + } + }); + } + + document.querySelectorAll("[data-plan-modal-close]").forEach(function (el) { + el.addEventListener("click", closeDetailModal); + }); + document.querySelectorAll("[data-plan-edit-close]").forEach(function (el) { + el.addEventListener("click", closeEditModal); + }); + + const editForm = $("plan-edit-form"); + if (editForm) { + editForm.addEventListener("submit", function (ev) { + ev.preventDefault(); + if (!editingPlanId) return; + api("/api/entry-plans/" + editingPlanId, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(readEditForm(editForm)), + }) + .then(function () { + toast("已保存"); + closeEditModal(); + return refreshAll(); + }) + .catch(function (e) { + toast(e.message || "保存失败", true); + }); + }); + } + + const periodTabs = $("plan-stats-period-tabs"); + if (periodTabs) { + periodTabs.addEventListener("click", function (ev) { + const btn = ev.target.closest(".plan-period-btn"); + if (!btn) return; + statsPeriod = btn.getAttribute("data-period") || "all"; + periodTabs.querySelectorAll(".plan-period-btn").forEach(function (b) { + b.classList.toggle("is-active", b === btn); + }); + const rangeWrap = $("plan-stats-range-wrap"); + if (rangeWrap) rangeWrap.classList.toggle("hidden", statsPeriod !== "range"); + loadStats().catch(function (e) { + toast(e.message || "统计加载失败", true); + }); + }); + } + + const dimTabs = $("plan-stats-dim-tabs"); + if (dimTabs) { + dimTabs.addEventListener("click", function (ev) { + const btn = ev.target.closest(".plan-dim-btn"); + if (!btn) return; + statsDim = btn.getAttribute("data-dim") || "symbol"; + dimTabs.querySelectorAll(".plan-dim-btn").forEach(function (b) { + b.classList.toggle("is-active", b === btn); + }); + loadStats().catch(function (e) { + toast(e.message || "统计加载失败", true); + }); + }); + } + + ["plan-stats-date-from", "plan-stats-date-to"].forEach(function (id) { + const el = $(id); + if (!el) return; + el.addEventListener("change", function () { + statsDateFrom = ($("plan-stats-date-from") && $("plan-stats-date-from").value) || ""; + statsDateTo = ($("plan-stats-date-to") && $("plan-stats-date-to").value) || ""; + if (statsPeriod === "range") { + loadStats().catch(function (e) { + toast(e.message || "统计加载失败", true); + }); + } + }); + }); + } + + async function init() { + if (inited) { + await refreshPage(); + return; + } + inited = true; + bindEvents(); + try { + await loadMeta(); + await refreshAll(); + const status = $("plan-refresh-status"); + if (status) status.textContent = "已刷新 " + fmtRefreshTime(); + } catch (e) { + toast(e.message || "加载失败", true); + } + } + + function destroy() {} + + window.hubPlanPage = { init: init, refresh: refreshPage, destroy: destroy }; +})(); diff --git a/manual_trading_hub/static/quotes.js b/manual_trading_hub/static/quotes.js new file mode 100644 index 0000000..8d95856 --- /dev/null +++ b/manual_trading_hub/static/quotes.js @@ -0,0 +1,284 @@ +/** + * 语录博客流:按交易日分组 · 截断展开 · 当日盈亏摘要 · AI 复盘跳转. + */ +(function () { + const page = document.getElementById("page-quotes"); + if (!page) return; + + const elFeed = document.getElementById("quotes-feed"); + const elStatus = document.getElementById("quotes-status"); + const elBtnRefresh = document.getElementById("quotes-btn-refresh"); + const elLinkArchive = document.getElementById("quotes-link-archive"); + + const RECENT_LIMIT = 20; + const PREVIEW_LEN = 140; + const ARCHIVE_QUOTE_AI_KEY = "hub_archive_quote_ai"; + + let quotes = []; + let dayStats = {}; + let expanded = {}; + let inited = false; + let loading = false; + + function esc(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + async function apiFetch(url, opts) { + return fetch(url, Object.assign({ credentials: "same-origin" }, opts || {})); + } + + function setStatus(text) { + if (elStatus) elStatus.textContent = text || ""; + } + + function findQuote(id) { + return ( + quotes.find(function (q) { + return String(q.id) === String(id); + }) || null + ); + } + + function fmtPnl(v) { + const n = Number(v); + if (!Number.isFinite(n)) return "—"; + return (n >= 0 ? "+" : "") + n.toFixed(2) + "U"; + } + + function pnlClass(v) { + const n = Number(v); + if (!Number.isFinite(n) || n === 0) return ""; + return n > 0 ? "pnl-pos" : "pnl-neg"; + } + + function fmtWinRate(v) { + const n = Number(v); + if (!Number.isFinite(n)) return "—"; + return n.toFixed(1) + "%"; + } + + function daySummaryHtml(day, st) { + if (!st) { + return '当日统计加载中…'; + } + const openN = Number(st.open_count) || 0; + const pnl = st.pnl_total; + return ( + '' + + openN + + " 笔 · 盈亏 ' + + esc(fmtPnl(pnl)) + + " · 胜率 " + + esc(fmtWinRate(st.win_rate)) + + "" + ); + } + + function previewText(raw) { + const text = String(raw || "").trim(); + if (text.length <= PREVIEW_LEN) return { text: text, truncated: false }; + return { text: text.slice(0, PREVIEW_LEN).trim() + "…", truncated: true }; + } + + function groupByDay(rows) { + const map = {}; + const order = []; + rows.forEach(function (q) { + const day = String(q.quote_date || "").slice(0, 10) || "—"; + if (!map[day]) { + map[day] = []; + order.push(day); + } + map[day].push(q); + }); + return { map: map, order: order }; + } + + function renderFeed() { + if (!elFeed) return; + if (!quotes.length) { + elFeed.innerHTML = + '

        暂无复盘语录.可在「内照明心 → 复盘语录」中添加.

        '; + return; + } + const grouped = groupByDay(quotes); + elFeed.innerHTML = grouped.order + .map(function (day) { + const list = grouped.map[day] || []; + const cards = list + .map(function (q) { + const id = String(q.id); + const full = String(q.content || "").trim(); + const isOpen = !!expanded[id]; + const prev = previewText(full); + const showExpand = prev.truncated; + const body = isOpen || !showExpand ? full : prev.text; + return ( + '
        ' + + '
        ' + + esc(body) + + "
        " + + '
        ' + + (showExpand + ? '" + : "") + + '' + + "
        " + ); + }) + .join(""); + return ( + '
        ' + + '
        ' + + '

        ' + + esc(day) + + "

        " + + daySummaryHtml(day, dayStats[day]) + + "
        " + + '
        ' + + cards + + "
        " + ); + }) + .join(""); + + elFeed.querySelectorAll(".quotes-expand-btn").forEach(function (btn) { + btn.addEventListener("click", function () { + const id = btn.getAttribute("data-id"); + expanded[id] = !expanded[id]; + renderFeed(); + }); + }); + elFeed.querySelectorAll(".quotes-ai-btn").forEach(function (btn) { + btn.addEventListener("click", function () { + startQuoteAiChat(btn.getAttribute("data-id")); + }); + }); + } + + function startQuoteAiChat(quoteId) { + const q = findQuote(quoteId); + const content = q && String(q.content || "").trim(); + if (!q || !content) { + setStatus("语录内容为空,无法发起 AI 对话"); + return; + } + try { + sessionStorage.setItem( + ARCHIVE_QUOTE_AI_KEY, + JSON.stringify({ + quote_date: q.quote_date || "", + content: content, + }) + ); + } catch (_) { + setStatus("无法保存跳转数据"); + return; + } + if (typeof window.hubNavigateTo === "function") { + window.hubNavigateTo("/ai"); + return; + } + location.href = "/ai"; + } + + async function loadDayStats(days) { + const uniq = []; + const seen = {}; + (days || []).forEach(function (d) { + const day = String(d || "").slice(0, 10); + if (!day || day === "—" || seen[day]) return; + seen[day] = true; + uniq.push(day); + }); + await Promise.all( + uniq.map(async function (day) { + if (dayStats[day]) return; + try { + const q = new URLSearchParams(); + q.set("period", "today"); + q.set("trading_day", day); + const r = await apiFetch("/api/archive/daily-trades?" + q.toString()); + const j = await r.json(); + if (r.ok) { + dayStats[day] = j.stats || { open_count: 0, pnl_total: 0, win_rate: null }; + } else { + dayStats[day] = { open_count: 0, pnl_total: 0, win_rate: null }; + } + } catch (_) { + dayStats[day] = { open_count: 0, pnl_total: 0, win_rate: null }; + } + }) + ); + } + + async function loadQuotes() { + if (loading) return; + loading = true; + setStatus("加载语录…"); + try { + const r = await apiFetch("/api/archive/quotes"); + const j = await r.json(); + if (!r.ok) { + setStatus(j.detail || "加载失败"); + return; + } + quotes = (j.quotes || []).slice(0, RECENT_LIMIT); + const days = quotes.map(function (q) { + return q.quote_date; + }); + renderFeed(); + await loadDayStats(days); + renderFeed(); + setStatus("最近 " + quotes.length + " 条 · " + new Date().toLocaleTimeString()); + } catch (e) { + setStatus(String(e && e.message ? e.message : e) || "加载失败"); + } finally { + loading = false; + } + } + + function bindEvents() { + if (elBtnRefresh) elBtnRefresh.addEventListener("click", loadQuotes); + if (elLinkArchive) { + elLinkArchive.addEventListener("click", function (ev) { + if (typeof window.hubNavigateTo === "function") { + ev.preventDefault(); + window.hubNavigateTo("/archive"); + } + }); + } + } + + async function init() { + if (!page || page.classList.contains("hidden")) return; + if (!inited) { + bindEvents(); + inited = true; + } + await loadQuotes(); + } + + function destroy() {} + + window.hubQuotesPage = { init: init, destroy: destroy }; +})(); diff --git a/manual_trading_hub/static/strategy.js b/manual_trading_hub/static/strategy.js new file mode 100644 index 0000000..b2e7bac --- /dev/null +++ b/manual_trading_hub/static/strategy.js @@ -0,0 +1,182 @@ +/** + * 策略说明:三所 MD + 开仓检查清单 JSON. + */ +(function () { + const page = document.getElementById("page-strategy"); + if (!page) return; + + const tabsEl = document.getElementById("strategy-tabs"); + const statusEl = document.getElementById("strategy-load-status"); + const docBody = document.getElementById("strategy-doc-body"); + const docSource = document.getElementById("strategy-doc-source"); + const docCard = page.querySelector(".strategy-doc-card"); + const checklistCard = page.querySelector(".strategy-checklist-card"); + const checklistTitle = document.getElementById("strategy-checklist-title"); + const checklistBody = document.getElementById("strategy-checklist-body"); + const footnotesEl = document.getElementById("strategy-checklist-footnotes"); + const btnPrintDoc = document.getElementById("strategy-btn-print-doc"); + const btnPrintChecklist = document.getElementById("strategy-btn-print-checklist"); + const btnDownload = document.getElementById("strategy-btn-download"); + + let activeKey = "binance"; + let tabsMeta = []; + let cache = {}; + let bound = false; + let heightSyncRaf = 0; + + async function apiFetch(url, opts) { + const r = await fetch(url, { credentials: "same-origin", ...(opts || {}) }); + const ct = (r.headers.get("content-type") || "").toLowerCase(); + if (ct.includes("application/json")) { + const data = await r.json(); + if (!r.ok) throw new Error((data && data.msg) || r.statusText || "请求失败"); + return data; + } + if (!r.ok) throw new Error(r.statusText || "请求失败"); + return r; + } + + function esc(s) { + return String(s ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function syncDocCardHeight() { + if (!docCard || !checklistCard || window.matchMedia("(max-width: 960px)").matches) { + if (docCard) docCard.style.height = ""; + return; + } + docCard.style.height = `${checklistCard.offsetHeight}px`; + } + + function scheduleHeightSync() { + if (heightSyncRaf) cancelAnimationFrame(heightSyncRaf); + heightSyncRaf = requestAnimationFrame(() => { + heightSyncRaf = 0; + syncDocCardHeight(); + }); + } + + function renderTabs() { + if (!tabsEl) return; + tabsEl.innerHTML = tabsMeta + .map( + (t) => + `` + ) + .join(""); + tabsEl.querySelectorAll(".strategy-tab").forEach((btn) => { + btn.addEventListener("click", () => { + const key = btn.getAttribute("data-key"); + if (!key || key === activeKey) return; + activeKey = key; + renderTabs(); + void loadExchange(key); + }); + }); + } + + function renderChecklist(checklist) { + const cl = checklist || {}; + const title = cl.title || "开仓检查清单"; + if (checklistTitle) checklistTitle.textContent = title; + if (!checklistBody) return; + const groups = cl.groups || []; + if (!groups.length) { + checklistBody.innerHTML = '

        暂无检查清单

        '; + } else { + checklistBody.innerHTML = groups + .map((grp) => { + const items = (grp.items || []) + .map((item) => `
      5. ${esc(item)}
      6. `) + .join(""); + return `

        ${esc(grp.title || "")}

          ${items}
        `; + }) + .join(""); + } + if (footnotesEl) { + const notes = cl.footnotes || []; + footnotesEl.innerHTML = notes.map((n) => `
      7. ${esc(n)}
      8. `).join(""); + footnotesEl.classList.toggle("hidden", !notes.length); + } + scheduleHeightSync(); + } + + function renderPayload(data) { + if (docBody) docBody.innerHTML = data.strategy_html || ""; + if (docSource) { + const ver = data.version ? ` · ${data.version}` : ""; + docSource.textContent = `文档:${data.md_source || ""}${ver}`; + } + renderChecklist(data.checklist); + scheduleHeightSync(); + } + + async function loadExchange(key) { + if (statusEl) statusEl.textContent = "加载中…"; + try { + let data = cache[key]; + if (!data) { + data = await apiFetch(`/api/strategy/${encodeURIComponent(key)}`); + cache[key] = data; + } + renderPayload(data); + if (statusEl) statusEl.textContent = ""; + } catch (e) { + if (statusEl) statusEl.textContent = String(e); + if (docBody) docBody.innerHTML = ""; + if (checklistBody) checklistBody.innerHTML = ""; + scheduleHeightSync(); + } + } + + async function loadMeta() { + const meta = await apiFetch("/api/strategy/meta"); + tabsMeta = meta.exchanges || []; + if (tabsMeta.length && !tabsMeta.some((t) => t.key === activeKey)) { + activeKey = tabsMeta[0].key; + } + renderTabs(); + } + + function printSection(mode) { + const part = mode === "checklist" ? "checklist" : "doc"; + const url = `/api/strategy/${encodeURIComponent(activeKey)}/print?part=${encodeURIComponent(part)}`; + const w = window.open(url, "_blank", "noopener,noreferrer"); + if (!w) { + if (statusEl) statusEl.textContent = "请允许弹出窗口以打开打印预览"; + } + } + + function bindActions() { + if (bound) return; + bound = true; + if (btnPrintDoc) btnPrintDoc.addEventListener("click", () => printSection("doc")); + if (btnPrintChecklist) btnPrintChecklist.addEventListener("click", () => printSection("checklist")); + if (btnDownload) { + btnDownload.addEventListener("click", () => { + window.location.href = `/api/strategy/${encodeURIComponent(activeKey)}/export`; + }); + } + window.addEventListener("resize", scheduleHeightSync); + } + + async function init() { + bindActions(); + try { + await loadMeta(); + await loadExchange(activeKey); + } catch (e) { + if (statusEl) statusEl.textContent = String(e); + } + } + + function destroy() { + window.removeEventListener("resize", scheduleHeightSync); + } + + window.hubStrategyPage = { init, destroy }; +})(); diff --git a/manual_trading_hub/static/theme.js b/manual_trading_hub/static/theme.js new file mode 100644 index 0000000..18389c8 --- /dev/null +++ b/manual_trading_hub/static/theme.js @@ -0,0 +1,71 @@ +/** 中控主题:暗色(默认)/ 亮色,localStorage hub-theme */ +(function (global) { + const KEY = "hub-theme"; + const META = { dark: "#0b0e18", light: "#d4dde8" }; + + function normalize(theme) { + return theme === "light" ? "light" : "dark"; + } + + function get() { + try { + return normalize(localStorage.getItem(KEY)); + } catch (_) { + return "dark"; + } + } + + function broadcastThemeToInstances() { + const msg = { type: "hub-theme-sync", theme: get() }; + document.querySelectorAll("iframe#instance-frame, iframe.instance-frame").forEach((frame) => { + try { + if (frame.contentWindow) frame.contentWindow.postMessage(msg, "*"); + } catch (_) {} + }); + } + + function apply(theme) { + const t = normalize(theme); + const root = document.documentElement; + root.setAttribute("data-theme", t); + try { + localStorage.setItem(KEY, t); + } catch (_) {} + const meta = document.querySelector('meta[name="theme-color"]'); + if (meta) meta.setAttribute("content", META[t]); + root.style.colorScheme = t; + document.dispatchEvent(new CustomEvent("hub-theme-change", { detail: { theme: t } })); + broadcastThemeToInstances(); + return t; + } + + function toggle() { + return apply(get() === "dark" ? "light" : "dark"); + } + + function syncToggleUI(root) { + const scope = root || document; + scope.querySelectorAll(".theme-toggle-btn[data-theme-value]").forEach((btn) => { + const on = btn.getAttribute("data-theme-value") === get(); + btn.classList.toggle("is-active", on); + btn.setAttribute("aria-pressed", on ? "true" : "false"); + }); + } + + function initToggleUI(root) { + const scope = root || document; + syncToggleUI(scope); + scope.querySelectorAll(".theme-toggle-btn[data-theme-value]").forEach((btn) => { + if (btn.dataset.themeBound === "1") return; + btn.dataset.themeBound = "1"; + btn.addEventListener("click", () => { + apply(btn.getAttribute("data-theme-value")); + syncToggleUI(scope); + }); + }); + document.addEventListener("hub-theme-change", () => syncToggleUI(scope)); + } + + apply(get()); + global.HubTheme = { KEY, get, apply, toggle, syncToggleUI, initToggleUI }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/manual_trading_hub/static/time_close_ui.js b/manual_trading_hub/static/time_close_ui.js new file mode 100644 index 0000000..7d4933e --- /dev/null +++ b/manual_trading_hub/static/time_close_ui.js @@ -0,0 +1,194 @@ +/** + * 时间平仓 + 整点强制清仓:表单开关 + 持仓/顶栏倒计时. + */ +(function (global) { + "use strict"; + + function pad2(n) { + return n < 10 ? "0" + n : String(n); + } + + function formatCountdown(sec) { + const s = Math.max(0, parseInt(sec, 10) || 0); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const r = s % 60; + return pad2(h) + ":" + pad2(m) + ":" + pad2(r); + } + + function isForceCloseActive(wrap) { + if (!wrap) return false; + const raw = + wrap.dataset.forceCloseActive || + wrap.getAttribute("data-force-close-active") || + ""; + return raw === "1" || raw === "true"; + } + + function bindTimeCloseForm(checkboxId, selectId, wrapId) { + const cb = document.getElementById(checkboxId); + const sel = document.getElementById(selectId); + const wrap = wrapId ? document.getElementById(wrapId) : null; + if (!cb || !sel) return; + function sync() { + const on = !!cb.checked; + sel.disabled = false; + sel.tabIndex = 0; + if (wrap) wrap.classList.toggle("is-disabled", !on); + } + sel.addEventListener("mousedown", function (ev) { + ev.stopPropagation(); + }); + sel.addEventListener("click", function (ev) { + ev.stopPropagation(); + }); + cb.addEventListener("change", sync); + sync(); + } + + function paintCountdownEl(cd, rem, active) { + if (!cd) return; + if (active) { + cd.textContent = "执行中"; + return; + } + cd.textContent = Number.isFinite(rem) ? formatCountdown(rem) : "--:--:--"; + } + + function paintOrderTimeClose(order) { + if (!order || order.id == null) return; + const wrap = document.getElementById("order-time-close-wrap-" + order.id); + const cd = document.getElementById("order-time-close-cd-" + order.id); + if (!wrap || !cd) return; + const enabled = !!(order.time_close_enabled || order.time_close_at_ms); + if (!enabled) { + wrap.style.display = "none"; + return; + } + wrap.style.display = ""; + const hours = order.time_close_hours; + const label = order.time_close_label || (hours ? "时间平仓 " + hours + "h" : "时间平仓"); + const labelEl = wrap.querySelector(".pos-time-close-label"); + if (labelEl) labelEl.textContent = label; + let rem = + order.time_close_remaining_sec != null + ? Number(order.time_close_remaining_sec) + : null; + if ((rem == null || !Number.isFinite(rem)) && order.time_close_at_ms) { + rem = Math.max(0, Math.floor((Number(order.time_close_at_ms) - Date.now()) / 1000)); + } + paintCountdownEl(cd, rem, false); + wrap.dataset.closeAtMs = order.time_close_at_ms ? String(order.time_close_at_ms) : ""; + } + + function paintOrderForceClose(order) { + if (!order || order.id == null) return; + const wrap = document.getElementById("order-force-close-wrap-" + order.id); + const cd = document.getElementById("order-force-close-cd-" + order.id); + if (!wrap || !cd) return; + const enabled = !!order.force_close_enabled; + if (!enabled) { + wrap.style.display = "none"; + return; + } + wrap.style.display = ""; + const label = order.force_close_label || "强制清仓"; + const labelEl = wrap.querySelector(".pos-force-close-label"); + if (labelEl) labelEl.textContent = label; + let rem = + order.force_close_remaining_sec != null + ? Number(order.force_close_remaining_sec) + : null; + const atMs = order.force_close_at_ms; + if ((rem == null || !Number.isFinite(rem)) && atMs) { + rem = Math.max(0, Math.floor((Number(atMs) - Date.now()) / 1000)); + } + const active = !!order.force_close_active; + paintCountdownEl(cd, rem, active); + wrap.dataset.forceCloseAtMs = atMs ? String(atMs) : ""; + wrap.dataset.forceCloseActive = active ? "1" : "0"; + } + + function paintForceCloseHeader(state) { + const wrap = document.getElementById("force-close-header-badge"); + if (!wrap) return; + if (!state || !state.enabled) { + wrap.style.display = "none"; + return; + } + wrap.style.display = ""; + const label = state.label || "强制清仓"; + const labelPrefix = label + " 已开启 · "; + let prefixNode = wrap.querySelector(".force-close-header-prefix"); + if (!prefixNode) { + wrap.textContent = ""; + prefixNode = document.createElement("span"); + prefixNode.className = "force-close-header-prefix"; + prefixNode.textContent = labelPrefix; + wrap.appendChild(prefixNode); + const cd = document.createElement("span"); + cd.className = "force-close-header-cd"; + wrap.appendChild(cd); + } else { + prefixNode.textContent = labelPrefix; + } + const cd = wrap.querySelector(".force-close-header-cd"); + let rem = state.remaining_sec != null ? Number(state.remaining_sec) : null; + if ((rem == null || !Number.isFinite(rem)) && state.next_at_ms) { + rem = Math.max(0, Math.floor((Number(state.next_at_ms) - Date.now()) / 1000)); + } + paintCountdownEl(cd, rem, !!state.active); + wrap.dataset.forceCloseAtMs = state.next_at_ms ? String(state.next_at_ms) : ""; + wrap.dataset.forceCloseActive = state.active ? "1" : "0"; + } + + function tickLocalCountdowns() { + document.querySelectorAll("[data-close-at-ms]").forEach(function (wrap) { + const closeAtRaw = wrap.dataset.closeAtMs || wrap.getAttribute("data-close-at-ms") || ""; + const cd = wrap.querySelector(".pos-time-close-cd"); + if (!cd) return; + const closeAt = Number(closeAtRaw); + if (!closeAt) return; + const rem = Math.max(0, Math.floor((closeAt - Date.now()) / 1000)); + cd.textContent = formatCountdown(rem); + }); + document.querySelectorAll("[data-force-close-at-ms]").forEach(function (wrap) { + const closeAtRaw = + wrap.dataset.forceCloseAtMs || wrap.getAttribute("data-force-close-at-ms") || ""; + const cd = wrap.querySelector(".pos-force-close-cd, .force-close-header-cd"); + if (!cd) return; + const closeAt = Number(closeAtRaw); + if (!closeAt) return; + const rem = Math.max(0, Math.floor((closeAt - Date.now()) / 1000)); + paintCountdownEl(cd, rem, isForceCloseActive(wrap)); + }); + } + + function paintOrders(orders) { + (orders || []).forEach(function (order) { + paintOrderTimeClose(order); + paintOrderForceClose(order); + }); + } + + function syncKeyTimeCloseVisibility(show) { + const wrap = document.getElementById("key-time-close-wrap"); + if (!wrap) return; + wrap.style.display = show ? "inline-flex" : "none"; + } + + global.TimeCloseUI = { + bindTimeCloseForm: bindTimeCloseForm, + paintOrderTimeClose: paintOrderTimeClose, + paintOrderForceClose: paintOrderForceClose, + paintForceCloseHeader: paintForceCloseHeader, + paintOrders: paintOrders, + tickLocalCountdowns: tickLocalCountdowns, + syncKeyTimeCloseVisibility: syncKeyTimeCloseVisibility, + formatCountdown: formatCountdown, + }; + + if (!global.__timeCloseCountdownTimer) { + global.__timeCloseCountdownTimer = setInterval(tickLocalCountdowns, 1000); + } +})(typeof window !== "undefined" ? window : globalThis); diff --git a/manual_trading_hub/url_public.py b/manual_trading_hub/url_public.py new file mode 100644 index 0000000..54d9ab2 --- /dev/null +++ b/manual_trading_hub/url_public.py @@ -0,0 +1,61 @@ +"""将 127.0.0.1 服务地址转为浏览器可访问的外链(内网 IP 或域名).""" + +from __future__ import annotations + +import os +from urllib.parse import urlparse, urlunparse + +_LOCAL_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"}) + + +def public_origin() -> tuple[str, str] | None: + """ + 从环境变量读取对外 Origin. + HUB_PUBLIC_ORIGIN=http://192.168.1.10 或 HUB_PUBLIC_HOST=192.168.1.10 + """ + raw = (os.getenv("HUB_PUBLIC_ORIGIN") or os.getenv("HUB_PUBLIC_HOST") or "").strip() + if not raw: + return None + if "://" in raw: + p = urlparse(raw) + scheme = (p.scheme or "http").strip() + host = (p.hostname or "").strip() + if not host: + return None + return scheme, host + scheme = (os.getenv("HUB_PUBLIC_SCHEME") or "http").strip() or "http" + host = raw.split("/")[0].split(":")[0].strip() + return (scheme, host) if host else None + + +def browser_url(internal_url: str | None) -> str: + """ + 中控本机请求仍用 internal_url;返回给前端,复盘链接用本函数. + 若未配置 HUB_PUBLIC_* 或原 URL 已是非本机地址,则原样返回. + """ + if not internal_url or not str(internal_url).strip(): + return "" + u = str(internal_url).strip() + origin = public_origin() + if not origin: + return u + scheme_pub, host_pub = origin + try: + p = urlparse(u) + except Exception: + return u + if not p.scheme or not p.netloc: + return u + host = (p.hostname or "").lower() + if host not in _LOCAL_HOSTS and not host.startswith("::ffff:127.0.0.1"): + return u + port = p.port + netloc = f"{host_pub}:{port}" if port else host_pub + return urlunparse((scheme_pub, netloc, p.path or "", p.params, p.query, p.fragment)) + + +def default_review_url(flask_url: str | None) -> str: + base = browser_url((flask_url or "").rstrip("/")) + if not base: + return "" + return f"{base}/records" diff --git a/manual_trading_hub/云服务器部署说明.md b/manual_trading_hub/云服务器部署说明.md new file mode 100644 index 0000000..b49f584 --- /dev/null +++ b/manual_trading_hub/云服务器部署说明.md @@ -0,0 +1,289 @@ +# 云服务器部署说明 + +本文说明在 **云服务器(VPS)** 上部署 `crypto_monitor` 中控与三实例的推荐配置:硬件,软件,防火墙,宝塔反代,环境变量,PM2 启动与验收. + +云上标准做法:**域名 + 宝塔/Nginx 反代 + HTTPS**;业务端口(5100,5000~5004,15200~15202)**不对公网直连**. + +相关文档: + +- **[本地数据迁移到云端.md](./本地数据迁移到云端.md)** — 备份 `crypto.db`,图片,`hub_settings` 与恢复步骤 +- [局域网与反代部署说明.md](./局域网与反代部署说明.md) — 局域网 IP:端口 与反代域名对照,SSO 行为 +- [部署文档.md](./部署文档.md) — PM2,依赖安装,日常运维 +- [使用说明.md](./使用说明.md) — 中控功能说明 +- [常见问题.md](./常见问题.md) — 故障排查 +- 环境变量模板:[.env.example](./.env.example) + +--- + +## 一,服务器硬件与系统 + +| 项目 | 建议 | +|------|------| +| 配置 | **2 核 4G** 起步;三实例 + 中控 + PM2 同时运行,**4G~8G 更稳** | +| 系统 | **Ubuntu 22.04 / 24.04**(项目文档按 Linux 编写) | +| 磁盘 | **20G+**;日志,SQLite,上传图片会占空间 | +| 网络 | 需能访问各交易所 API;若走代理,在对应 `crypto_monitor_*/.env` 配置 `OKX_SOCKS_PROXY`,`BINANCE_SOCKS_PROXY` 等 | + +--- + +## 二,软件环境 + +```bash +sudo apt update +sudo apt install -y python3 python3-venv python3-pip git curl + +# 进程守护(推荐) +sudo npm i -g pm2 +``` + +**宝塔面板(可选但推荐)**:安装 **Nginx**,用于反向代理与 **SSL**(Let’s Encrypt). + +Python 虚拟环境(分开安装,互不替代): + +| 目录 | 用途 | +|------|------| +| `manual_trading_hub/.venv` | 中控 `hub.py` + 子代理 `agent.py` | +| `crypto_monitor_binance/.venv` | 币安 Flask | +| `crypto_monitor_okx/.venv` | OKX Flask | +| `crypto_monitor_gate/.venv` | Gate Flask | +| `crypto_monitor_gate/.venv` | Gate Flask | + +各实例 `ecosystem.config.cjs` 一般已设置 **`PYTHONPATH=..`**(仓库根),以便加载 `hub_bridge.py`,`hub_auth.py` 等. + +--- + +## 三,网络与端口(云上最重要) + +**原则:公网只暴露 Nginx 的 80/443;Flask 与 agent 只监听本机.** + +| 服务 | 本机端口(示例) | 是否对公网开放 | +|------|------------------|----------------| +| 中控 hub | 5100 | **否** → 仅 `https://hub.你的域名` 反代 | +| 币安 Flask | 5001 | **否** → `https://binance.你的域名` | +| OKX Flask | 5004 | **否** → `https://okx.你的域名` | +| Gate Flask | 5000 | **否** → `https://gate.你的域名` | +| 子代理 agent | 15200~15202 | **否**,必须 **127.0.0.1** | + +### 云厂商安全组 / 系统防火墙 + +- **放行**:`80`,`443`(给宝塔/Nginx) +- **不要放行**:`5100`,`5000`~`5004`,`15200`~`15202`(除非临时本机调试,用完即关) + +--- + +## 四,域名与宝塔反代 + +为 **中控 + 每个要对外打开的实例** 各建一个站点(子域名示例): + +| 站点(浏览器访问) | 反代目标 | +|--------------------|----------| +| `https://hub.example.com` | `http://127.0.0.1:5100` | +| `https://okx.example.com` | `http://127.0.0.1:5004` | +| `https://binance.example.com` | `http://127.0.0.1:5001` | +| `https://gate.example.com` | `http://127.0.0.1:5000` | + +### 宝塔操作要点 + +1. 每个域名 → **网站** → **反向代理** → 目标 `http://127.0.0.1:对应端口`. +2. 申请 **SSL**(Let’s Encrypt),强制 HTTPS. +3. **不要**再给实例站加一层宝塔「访问密码」(会与 Flask `/login` 重复);直链鉴权用下文 **`APP_USERNAME` / `APP_PASSWORD`**. +4. Nginx 建议保留常见代理头(宝塔默认通常已带): + +```nginx +proxy_set_header Host $host; +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Forwarded-Proto $scheme; +``` + +中控请求实例 `/api/hub/*` 时会带 **`X-Hub-Token`**,一般无需额外配置. + +--- + +## 五,环境变量(必配) + +### 5.1 中控 `manual_trading_hub/.env` + +```env +HUB_HOST=0.0.0.0 +HUB_PORT=5100 + +# 与三实例 .env 完全相同(API + SSO 签名) +HUB_BRIDGE_TOKEN=请填一长串随机字符 + +# 中控网页登录(公网务必设置) +HUB_USERNAME=admin +HUB_PASSWORD=强密码 +HUB_SESSION_SECRET=另一串随机字符 + +# 中控为 HTTPS 时建议 true +HUB_COOKIE_SECURE=true + +# 公网用域名访问中控(宝塔反代)时必设其一: +# HUB_ALLOW_PUBLIC=true (推荐:反代 + 中控密码) +# 或反代目标必须是 http://127.0.0.1:5100 且可保持 HUB_TRUST_LAN=false +HUB_ALLOW_PUBLIC=true +HUB_TRUST_LAN=false + +# 从中控打开实例的 SSO 链接有效期(秒),默认 7200 = 2 小时 +HUB_SSO_TTL_SEC=7200 + +# 各实例 hub_settings 里 flask_url 已写 https 域名时,一般可不设 +# HUB_PUBLIC_ORIGIN=https://hub.example.com +``` + +完整项见 [`.env.example`](./.env.example). + +### 5.2 三个实例 `crypto_monitor_*/.env` + +每个目录都要有(**直链** `https://okx.域名` 时用这套登录网页): + +```env +# 各所 API 密钥(按交易所填写) +# APP_PORT=5004 + +# 与中控 manual_trading_hub/.env 中 HUB_BRIDGE_TOKEN 完全一致 +HUB_BRIDGE_TOKEN=与中控相同 + +# 三实例建议统一(直链登录用) +APP_USERNAME=统一用户名 +APP_PASSWORD=统一强密码 + +# 云服务器切勿开启(会跳过网页登录): +# APP_AUTH_DISABLED=true +``` + +### 5.3 子代理 + +- `CONTROL_TOKEN` 可与 `HUB_BRIDGE_TOKEN` 相同. +- 由 PM2 在对应 `crypto_monitor_*` 目录启动,`run_agent.sh` 加载该目录 `.env`. +- 只监听 **127.0.0.1:1520x**,不映射到公网. + +--- + +## 六,中控「系统设置」`hub_settings.json` + +在网页 **系统设置** 保存,或编辑 `manual_trading_hub/hub_settings.json`. + +云上 **`flask_url` 必须写浏览器能打开的 HTTPS 域名**(不要写 `127.0.0.1`,除非配合 `HUB_PUBLIC_ORIGIN` 做替换): + +| 字段 | 云上填法 | 说明 | +|------|----------|------| +| `flask_url` | `https://okx.example.com` | 用户浏览器,SSO 打开实例 | +| `agent_url` | `http://127.0.0.1:15201` | 仅中控本机访问子代理 | +| `enabled` | 按需 | 不参与监控的户可关 | +| `capabilities` | 按需 | `key` / `trend` 等 | + +**同机部署的两种写法(二选一):** + +1. **推荐**:每个实例 `flask_url` 直接写该实例的 `https://子域名`. +2. **备选**:`flask_url` 写 `http://127.0.0.1:5004`,中控 `.env` 设 `HUB_PUBLIC_ORIGIN=https://okx.example.com`(适合共用一个 IP,靠端口区分时). + +`agent_url` 始终用 **`http://127.0.0.1:1520x`**. + +--- + +## 七,PM2 启动顺序 + +代码路径示例:`/opt/crypto_monitor_user/`(按实际替换). + +```bash +cd /opt/crypto_monitor_user + +# 1)三个实例 Flask(各目录 ecosystem.config.cjs,进程名以你机器为准) +cd crypto_monitor_okx && pm2 start ecosystem.config.cjs +cd ../crypto_monitor_binance && pm2 start ecosystem.config.cjs +cd ../crypto_monitor_gate && pm2 start ecosystem.config.cjs + +# 2)中控 + 三个子代理(一条拉起 4 个进程:hub + 3 agent) +cd ../manual_trading_hub +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env # 编辑填入真实值 +chmod +x scripts/run_hub.sh scripts/run_agent.sh +pm2 start ecosystem.config.cjs +pm2 save +pm2 startup # 按提示执行 sudo 命令后再 pm2 save +``` + +或: + +```bash +cd /opt/crypto_monitor_user/manual_trading_hub +bash scripts/pm2_hub.sh start +``` + +### PM2 进程一览 + +| 进程名 | 说明 | +|--------|------| +| `manual-trading-hub` | 中控 :5100 | +| `manual-agent-binance` | :15200 | +| `manual-agent-okx` | :15201 | +| `manual-agent-gate` | :15202 | +| `crypto_*`(各目录自定) | 各 Flask `APP_PORT` | + +不用 OKX 时可在 `.env` 设 `HUB_DISABLED_IDS=1`,或 `pm2 stop manual-agent-okx`. + +--- + +## 八,访问与登录(云上行为) + +| 访问方式 | 地址示例 | 需要什么 | +|----------|----------|----------| +| 中控监控 | `https://hub.example.com/monitor` | **中控** `HUB_USERNAME` / `HUB_PASSWORD` | +| 中控点「实例 / 策略交易 / 复盘」 | 自动打开 `https://okx.example.com/hub-sso?...` | 已登中控即可;**2 小时内,单次** SSO,**免输**实例密码 | +| 浏览器直链实例 | `https://okx.example.com` | 实例 **`APP_USERNAME` / `APP_PASSWORD`**(`/login`) | + +SSO 复用 **`HUB_BRIDGE_TOKEN`** 签名,详见 [局域网与反代部署说明.md §五](./局域网与反代部署说明.md). + +--- + +## 九,安全建议(云服务器必看) + +1. **SSH**:密钥登录,关闭密码登录;必要时改 SSH 端口. +2. **`HUB_BRIDGE_TOKEN`**:足够长,随机;勿提交 Git,勿写进前端页面. +3. **交易所 API Key**:仅放在各实例 `.env`;权限尽量最小化(勿随意开提币). +4. **中控**:公网必须设 `HUB_PASSWORD`;`HUB_TRUST_LAN=false`. +5. **实例**:云上 **`APP_AUTH_DISABLED` 必须为 false**(或未设置). +6. **备份**:定期备份各实例数据库 / SQLite 与 `hub_settings.json`. +7. **`.env` 换行**:Linux 上勿用 Windows CRLF;可用 `bash scripts/fix_env_crlf.sh`. + +--- + +## 十,部署后验收清单 + +- [ ] `https://hub.你的域名` 能打开并登录中控 +- [ ] 监控卡片有持仓/余额(子代理在线) +- [ ] 已登录中控 → 点「实例」→ **无**实例登录页,直接进入 +- [ ] 隐身窗口直开 `https://okx.你的域名` → 出现 **`/login`**,统一账号密码可进 +- [ ] `pm2 status`:hub,4×agent,用到的 `crypto_*` 均为 online +- [ ] 云安全组 **未** 对公网开放 5100,5000~5004,15200~15202 +- [ ] 三实例 `.env` 与中控 `HUB_BRIDGE_TOKEN` 一致 +- [ ] 实例启动日志无长期 `[hub_bridge] ImportError` + +--- + +## 十一,常见问题速查 + +| 现象 | 处理 | +|------|------| +| 从中控打开仍要实例密码 | 见 [常见问题.md §4.3](./常见问题.md);检查 token,重启 Flask,`hub_settings` 的 `key` | +| 监控无持仓 / 子代理不可用 | `curl http://127.0.0.1:15201/status`;查 `.env` CRLF,API 密钥 | +| 复盘/实例链接是 127.0.0.1 | `flask_url` 改为 https 域名,或设 `HUB_PUBLIC_ORIGIN` | +| 仅 Gate 子代理反复重启 | `.env` CRLF:`bash manual_trading_hub/scripts/fix_env_crlf.sh` | + +--- + +## 十二,与局域网部署的区别(简要) + +| 项目 | 云服务器 | 局域网 | +|------|----------|--------| +| 对外地址 | `https://子域名` | `http://内网IP:端口` | +| `flask_url` | 写 **域名** | 写 **内网 IP:端口** | +| 防火墙 | 只开 80/443 | 内网可开 5100,500x | +| SSL | 必须(宝塔证书) | 通常 HTTP 即可 | +| `HUB_COOKIE_SECURE` | 建议 `true` | HTTP 时用 `false` | + +局域网详细步骤见 [局域网与反代部署说明.md §三](./局域网与反代部署说明.md). diff --git a/manual_trading_hub/交易监管说明.md b/manual_trading_hub/交易监管说明.md new file mode 100644 index 0000000..2190773 --- /dev/null +++ b/manual_trading_hub/交易监管说明.md @@ -0,0 +1,84 @@ +# 交易监管(AI 教练) + +中控 **交易监管** 用于防止过度交易与频繁手动操作:在 **手动/中控开平仓** 与 **新开仓** 时自动推送至 **今日监管长会话**,并可选 **企业微信** 提醒;程序止盈/止损按「正常执行」鼓励,不计入频繁交易统计. + +入口:**AI 教练**(`/ai`)→ Tab **交易监管**,或微信链接(在系统设置中配置). + +## 监管范围 + +| 类型 | 识别 | 页内推送 | 微信(P0) | 频率统计 | +|------|------|----------|------------|----------| +| 实例手动平仓 | `result = 手动平仓` | ✓ | ✓ | ✓ | +| 中控平仓 | `result = 强制清仓` 等 | ✓ | ✓ | ✓ | +| 新开仓 | 监控板持仓 diff(0→有仓 / 新合约) | ✓ | ✓ | ✓ | +| 程序止盈 | 止盈 / 保本止盈 / 移动止盈 | ✓ | 可选 | ✗ | +| 程序止损 | 止损 | ✓ | 可选 | ✗ | +| 外部平仓 | 外部平仓,时间平仓 | ✗ | ✗ | ✗ | + +频率规则(间隔过短,30 分钟笔数,日笔数,连亏,平后快开)**只对手动/中控开平** 叠加 `[监管·频率]` 警告. + +## 会话 + +- 每个交易日 **一条长会话**(`bot_mode: supervisor`,标题 `今日监管 YYYY-MM-DD`). +- 系统消息(`role: system`)+ AI 短评(`assistant`)+ 用户回复(`user`)同线程. +- 与 **交易教练 / 普通聊天** 分离;监管会话不支持「新开对话」. + +## 系统设置 + +路径:**系统设置** → **交易监管 · 企业微信**(写入 `hub_settings.json` → `supervisor`). + +| 字段 | 说明 | +|------|------| +| `enabled` | 总开关 | +| `wechat_webhook` | **监管专用** 企业微信机器人(与三所实例 `.env` 的 `WECHAT_WEBHOOK` 独立) | +| `wechat_link_base` | 微信消息末尾跳转链接(**可单独修改**,如 `https://域名/ai?mode=supervisor`) | +| `wechat_prefix` | 消息前缀,默认 `【交易监管】` | +| `wechat_on_program_tp_sl` | 程序止盈/止损是否也发微信 | +| `manual_close_daily_warn` | 日手动平警告阈值(默认 2) | +| `interval_warn_minutes` | 两笔手动/中控平最短间隔(默认 15 分钟) | +| `freq_30m_count` | 30 分钟内笔数阈值(默认 2) | +| `reopen_after_close_minutes` | 手动平后再开仓警告间隔(默认 30 分钟) | + +`.env` 兜底(设置页保存优先): + +```env +SUPERVISOR_WECHAT_WEBHOOK=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=... +SUPERVISOR_WECHAT_LINK=https://你的域名/ai?mode=supervisor +SUPERVISOR_POLL_INTERVAL_SEC=30 +``` + +## API + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/ai/supervisor/session` | 今日监管会话 | +| GET | `/api/ai/supervisor/stream` | SSE 版本推送 | +| POST | `/api/ai/supervisor/chat/send` | 用户回聊(JSON `{ "message": "..." }`) | +| GET | `/api/ai/supervisor/rules` | 当前阈值 | +| POST | `/api/ai/supervisor/refresh` | 立即扫描 | + +## 存储 + +| 文件 | 内容 | +|------|------| +| `hub_supervisor_state.json` | 已处理事件,持仓快照,频率统计 | +| `hub_ai_chat.json` | 监管会话(`bot_mode: supervisor`) | +| `hub_settings.json` | `supervisor` 配置节 | + +**首次启用** 会对当前交易日已有平仓做 **种子同步**(不补发历史推送),避免部署瞬间刷屏. + +## 与实例风控 + +实例 `account_risk_lib`(冷静期 / 日冻结)为 **硬拦截**;监管为 **软提醒 + 陪聊**,不绕过实例开仓限制. + +## 代码位置 + +| 模块 | 路径 | +|------|------| +| 规则与推送 | `hub_supervisor_lib.py` | +| 后台扫描 | `hub_supervisor_cache.py` | +| 会话 | `hub_ai/supervisor_store.py` | +| AI 评语/回聊 | `hub_ai/supervisor.py` | +| 提示词 | `hub_ai/prompts.py` → `SUPERVISOR_SYSTEM` | + +部署后重启中控:`pm2 restart manual-trading-hub`(或你的 hub 进程名). diff --git a/manual_trading_hub/使用说明.md b/manual_trading_hub/使用说明.md new file mode 100644 index 0000000..8a02b1d --- /dev/null +++ b/manual_trading_hub/使用说明.md @@ -0,0 +1,518 @@ +# 多账户交易中控 — 使用说明 + +本文档说明 **manual_trading_hub** 的架构,启动方式,界面操作与故障排查.中控聚合三所 **持仓/条件单/余额/关键位/趋势计划监控 + 撤单/紧急全平**,并提供 **资金概况**,**行情区 K 线** 与 **内照明心(复盘语录 + 永久 K 线)**;**人工下单,关键位,策略交易(趋势回调 / 顺势加仓),交易复盘** 均在各实例网页操作(点监控卡片 **「实例」**).资金概况见 **[资金概况说明.md](./资金概况说明.md)**;行情区细则见 **[行情区说明.md](./行情区说明.md)**;内照明心见 **[docs/hub-symbol-archive-kline.md](../docs/hub-symbol-archive-kline.md)**. + +--- + +## 1. 架构总览 + +``` +浏览器 + ├─ /funds 资金概况 + ├─ /plan 开仓计划(计划录入 / 进行中 / 历史胜率) + ├─ /monitor 监控区(持仓,关键位,趋势计划,全平) + ├─ /market 行情区(K 线,技术指标,持仓价格线) + ├─ /archive 内照明心(复盘语录 + 交易记录 + 永久 5m K 线) + ├─ /funds 资金概况(总资金曲线,分户资金与回撤) + ├─ /dashboard 数据看板(三户当日总览,SSE 推送;见 [数据看板说明.md](./数据看板说明.md)) + ├─ /ai AI 教练(交易教练 / 普通聊天;见 [AI教练说明.md](./AI教练说明.md)) + └─ /settings 系统设置(hub_settings.json) + +中控 hub.py(默认 :5100) + ├─ HTTP → 子代理 agent.py × N(/status,/emergency/close-all) + └─ HTTP → 各实例 Flask(/api/hub/monitor,/api/price_snapshot 等只读聚合) +``` + +| 组件 | 职责 | 默认端口(可在设置页改) | +|------|------|-------------------------| +| **hub.py** | 聚合 UI,监控 API,全平 | `5100` | +| **agent.py** | 交易所只读状态,挂单/条件单查询与撤销 + 紧急市价全平 | 币安 `15200`,OKX `15201`,Gate `15202` | +| **crypto_monitor_*.app** | 策略库,关键位,人工单,趋势预览/执行 | 币安 `5001`,Gate `5000`,OKX `5004` | + +### 1.1 三账户默认配置 + +| id | 名称 | Flask | Agent | 监控能力(设置页勾选) | 默认启用 | +|----|------|-------|-------|------------------------|----------| +| 0 | 币安 | :5001 | :15200 | 关键位 + 趋势 | 是 | +| 1 | OKX | :5004 | :15201 | 关键位 + 趋势 | 是 | +| 2 | Gate | :5000 | :15202 | 关键位 + 趋势 | 是 | + +- **三所均已支持** 关键位,策略交易(趋势回调 + 顺势加仓);中控可同时勾 **监控关键位** + **监控趋势计划**(见 §4.2,§5). + +### 1.2 实例侧改动(最小) + +各 `crypto_monitor_*` 仅增加: + +1. `login_required` 走 `hub_auth.request_allowed`(支持请求头 `X-Hub-Token`). +2. 文件末尾 `hub_bridge.install_on_app(...)` 注册 `/api/hub/*`. + +业务逻辑,数据库,复盘页面 **未改**;复盘请打开各实例 `/records`(设置里的「复盘链接」). + +--- + +## 2. 环境准备 + +### 2.1 依赖安装 + +```bash +cd /opt/crypto_monitor_user/manual_trading_hub +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +``` + +### 2.2 鉴权令牌(推荐生产启用) + +三实例 Flask 与中控,子代理需 **同一密钥**: + +| 变量 | 作用 | +|------|------| +| `HUB_BRIDGE_TOKEN` | 中控 → Flask 使用头 `X-Hub-Token`;各实例 `hub_auth` 校验 | +| `CONTROL_TOKEN` | 可与上相同;中控 → 子代理使用头 `X-Control-Token` | + +中控 `hub.py` 会读取 `HUB_BRIDGE_TOKEN`,若无则回退 `CONTROL_TOKEN`. + +**开发本机**可临时在各实例 `.env` 设 `APP_AUTH_DISABLED=true`,则 Flask 不校验令牌(仍建议子代理设 `CONTROL_TOKEN` 防误暴露). + +### 2.3 强制关闭某账户 + +```bash +# 在 manual_trading_hub/.env 中设置,或临时: +export HUB_DISABLED_IDS=1 # 默认即关闭 OKX(id=1) +``` + +与设置页「启用」取 **与** 关系:环境变量强制关闭时,网页勾选框会灰掉且无法启用. + +### 2.4 Web 登录(反代公网强烈建议) + +在 `manual_trading_hub/.env` 中配置: + +| 变量 | 说明 | +|------|------| +| `HUB_USERNAME` | 登录用户名;未设且已设密码时默认为 `admin` | +| `HUB_PASSWORD` | **非空即启用登录**;所有页面与 API(除登录页,`/api/ping`,`/assets`)须先登录 | +| `HUB_SESSION_SECRET` | 会话签名密钥(建议单独随机串) | +| `HUB_COOKIE_SECURE` | 建议 `true`:仅 **HTTPS** 访问时 Cookie 带 Secure;**HTTP 内网 IP:5100 仍可登录** | +| `HUB_SESSION_DAYS` | 登录保持天数,默认 `7` | + +- 登录页:`http://<中控地址>:5100/login` +- 顶栏 **退出** 清除会话. +- **域名(HTTPS)** 与 **内网 IP(HTTP)** Cookie 不共用,需分别登录一次. + +更多登录/Cookie 问题见 **[常见问题.md](./常见问题.md)** 第二节. + +### 2.5 配置文件 + +- 路径:`manual_trading_hub/hub_settings.json`(在网页 **系统设置 → 保存设置** 后写入). +- 未保存前使用 `settings_store.py` 内置默认三所地址. +- 建议 **不要** 把含内网 IP 的 `hub_settings.json` 提交到公开仓库. +- 环境变量模板:`manual_trading_hub/.env.example`;三实例模板中已补充 `HUB_BRIDGE_TOKEN` 说明. + +--- + +## 3. 启动顺序(Ubuntu + PM2) + +**原则**:代码在 **`/opt/crypto_monitor_user`**,先三实例 Flask,再中控(一条 PM2 含 3 agent + hub).环境见 **[docs/ubuntu-server.md](../docs/ubuntu-server.md)**. + +```bash +# 三所 Flask(示例:币安;其余三所同理) +cd /opt/crypto_monitor_user/crypto_monitor_binance +pm2 start ecosystem.config.cjs + +# 中控 + 子代理 +cd /opt/crypto_monitor_user/manual_trading_hub +pm2 start ecosystem.config.cjs +pm2 save +``` + +浏览器(本机或反代): + +- 监控区:`http://127.0.0.1:5100/monitor` +- 行情区:`http://127.0.0.1:5100/market` +- 内照明心:`http://127.0.0.1:5100/archive` +- 资金概况:`http://127.0.0.1:5100/funds` +- 系统设置:`http://127.0.0.1:5100/settings` + +验收: + +```bash +bash /opt/crypto_monitor_user/manual_trading_hub/scripts/verify_hub_deploy.sh +curl -s http://127.0.0.1:5100/api/ping +``` + +--- + +## 4. 页面操作说明 + +Chrome **桌面快捷方式**图标来自站点 `favicon` / `manifest`(已配置统一品牌图),说明见 **[docs/shortcut-icon.md](../docs/shortcut-icon.md)**. + +### 4.1 监控区 `/monitor` + +| 功能 | 说明 | +|------|------| +| **服务器状态** | 标题下方可折叠条(**默认收起**),摘要行显示 CPU/内存/硬盘;展开见四指标卡片(`GET /api/host/status`,每 5 秒刷新).**CPU 或内存 ≥85%** 时浏览器弹窗告警(降至 85% 以下后再次超标会再提示).依赖 `manual_trading_hub/.venv` 内 **psutil**(勿用系统 `pip`,见 [部署文档.md](./部署文档.md)).可选 `HUB_HOST_DISK_PATH` 指定监控磁盘 | +| **2×2 主界面** | 三所信息**完整展示**:余额,持仓表,委托/平仓,折叠委托单,下单监控,关键位,趋势/加仓摘要 | +| **全屏放大** | **点击卡片标题栏**(非按钮区)→ 该所**全屏**:每币种一张实盘风格持仓卡(趋势持仓显示**来源: 趋势回调计划**,**风险%**,**程序监控·止盈价**,**盈亏比**,与实例策略页一致);独立卡片:**关键位**,**下单监控**,**趋势回调**(单计划 **两列**:左=币种基本信息与 3×2 指标,右=**补仓计划明细**,底=**保本偏移%** 可编辑 + **保本移交** / **结束计划**(中控直接调实例,与 `/strategy` 一致),快照可用/计划保证金/杠杆),**顺势加仓** | +| **委托单折叠** | 仅「委托单」区块默认折叠;展开状态存浏览器本地,**5 秒刷新不重置** | +| **条件单 / 委托** | 每个持仓下方展示交易所 **条件单**(默认折叠)与 **普通委托**;数据来自子代理实时拉取(币安含 Algo 通道) | +| **撤单** | 条件单区内单笔「撤单」或「撤销全部」;经中控 `POST /api/orders/{id}/cancel`,`cancel-symbol` | +| **挂止盈止损** | 持仓行 **「委托」**:弹窗填止损/止盈价 → **先撤该合约全部条件单,再挂新 TP/SL**(币安 / OKX / Gate / Gate 三所统一,逻辑与各实例 `.env` 参数一致) | +| **平仓** | 持仓行「平仓」:仅平该方向仓位(子代理市价减仓) | +| **机器人单** | 来自实例 `/api/hub/monitor` 的 `order_monitors`(active),为本地监控计划,**不等于**交易所条件单 | +| **关键位** | 仅 `capabilities` 含 `key` 的户;展示门控摘要(`/api/price_snapshot`) | +| **趋势计划** | 仅当该户勾选 **监控趋势计划** 时展示 `trend_pullback_plans`(active) | +| **实例 / 复盘** | 「实例」「策略交易」「复盘」经中控签发 **SSO 链接**(默认 2h,单次)打开,**免输**实例 `APP_USERNAME/PASSWORD`;直链实例 IP/域名仍走 `/login`.**云服务器**见 **[云服务器部署说明.md](./云服务器部署说明.md)**;局域网/反代见 **[局域网与反代部署说明.md](./局域网与反代部署说明.md)** | +| **关键位列表** | 来自 `/api/hub/monitor` + `/api/price_snapshot`;Flask 未连通时卡片提示原因;**Gate 户**无关键位块 | +| **该户全平** | `POST` 子代理 `/emergency/close-all`,仅平该 API Key 仓位 | +| **全局紧急全平** | 对所有已启用户依次全平(不含 `HUB_DISABLED_IDS` 强制关闭的 id) | +| **自动刷新** | 默认每 5 秒请求 `/api/monitor/board` | + +持仓数据以 **子代理 ccxt** 为准;关键位/趋势/机器人单以 **Flask 数据库** 为准.若 Flask 未启动,卡片仍会显示 agent 持仓,但下方策略信息可能为空或报错. + +### 4.2 行情区 `/market` + +| 功能 | 说明 | +|------|------| +| **K 线** | 选择已启用交易所 + 币种 + 周期;按需拉取,本地 `data/hub_kline.db` 缓存(默认保留 15 天) | +| **周期** | `1m` `5m` `15m` `1h` `2h` `4h` `12h` `1d` `1w` | +| **加载 / 强制刷新** | 普通加载优先缓存;强制刷新重拉并覆盖缓存 | +| **从监控跳转** | 点击持仓合约名带入品种,并显示入场/止损/止盈/委托与 K 线价格线 | +| **技术指标** | 可选 EMA 21/55,MACD,RSI | +| **快捷键** | **`F`** 全屏/退出;全屏时 **`Esc`** 退出;数字键切换周期(见 [行情区说明.md](./行情区说明.md)) | +| **自动刷新** | 约 5 秒更新最新 OHLCV | + +数据经中控 → 各实例 `GET /api/hub/ohlcv`(`hub_ohlcv_lib`).升级 hub 与三实例 Flask 后请 **强刷浏览器**;异常 K 线可点 **强制刷新**. + +### 4.2.1 内照明心 `/archive` + +| 功能 | 说明 | +|------|------| +| **复盘语录** | 左栏按日添加/编辑;最多 100 条 | +| **日期** | **本日 / 本周 / 本月 / 自选区间**(交易日 8:00 切日) | +| **区间统计** | 总开仓,犯病次数与占比,盈亏,剔除犯病盈亏,各交易所分项 | +| **筛选** | 盈利单,亏损单,犯病(仅过滤表格;统计栏不受此三项影响) | +| **交易记录** | 区间内开仓列表;犯病行红色字体;可编辑备注与犯病标签 | +| **K 线** | 默认折叠按需加载;独立库 `data/hub_symbol_archive.db`;仅存 **5m** 真源,**15m/1h/4h** 聚合 | +| **建档** | 最早开仓向前 **30 天** 5m 种子;之后每 **4h** 增量(Hub 后台 + 可点「同步」) | +| **视窗** | **持仓过程**(锚平仓)/ **进场决策**(锚开仓);支持时间输入跳转 | + +与行情区 `hub_kline.db`(15 天滚动)**分离**,建档起 **只增不删**.细则见 **[docs/hub-symbol-archive-kline.md](../docs/hub-symbol-archive-kline.md)**. + +### 4.2.2 资金概况 `/funds` + +| 功能 | 说明 | +|------|------| +| **总资金** | 已监控账户的 **资金户 + 交易户** 合计(不含浮盈) | +| **总曲线** | 自 **2026-06-09** 起,按北京时间交易日(默认 8:00 切日)每日一点,最多 **180** 天 | +| **最大回撤** | 基于总资金余额曲线(非平仓盈亏回撤) | +| **分户** | 每户资金/交易拆分,迷你曲线,分户回撤;**未监控** 不参与合计 | +| **快照** | 监控板聚合成功时写入 `hub_fund_history.json` | + +细则见 **[资金概况说明.md](./资金概况说明.md)**. + +### 4.2.3 数据看板 `/dashboard` + +| 功能 | 说明 | +|------|------| +| **总览** | 交易日,平仓盈亏,笔数,浮盈亏,资金合计,持仓数 | +| **分户** | 三户资金/交易账户,今日盈亏,浮盈亏;单日亏损 ≥ 资金合计 **5%** 高亮预警 | +| **平仓明细** | 当日平仓流水表 | +| **刷新** | 后台每 60s 聚合 + **SSE** 推送版本号;页面无整页轮询闪烁 | +| **主题** | 跟随顶栏亮/暗主题,卡片柔光样式(非霓虹背景) | + +细则见 **[数据看板说明.md](./数据看板说明.md)**. + +### 4.3 AI 教练 `/ai` + +| 功能 | 说明 | +|------|------| +| **交易教练** | 口语化陪聊;后台注入三户监控快照(不在页面展示今日总结) | +| **普通聊天** | 不绑交易数据 | +| **会话** | 多会话历史(切换/删除),消息复制;点 **「新开对话」** 清空当前上下文 | +| **模型** | 与三实例相同 `.env`(默认 `AI_PROVIDER=openai` + `OPENAI_*`;改 `ollama` 走本机),见 [AI教练说明.md](./AI教练说明.md) | +| **与实例复盘** | 深度单笔 journal 复盘仍在各所 `/records`;中控不做重复 | + +依赖三实例 `GET /api/hub/trades/today`(`hub_bridge`);升级代码后需 **重启三所 Flask**. + +### 4.4 系统设置 `/settings` + +**可用**:打开 http://127.0.0.1:5100/settings ,修改表格后点 **保存设置** 即写入 `hub_settings.json`;**重新加载** 从磁盘/默认再读(会重新套用 `HUB_DISABLED_IDS`).保存后监控区立即使用新 URL/启用状态,**无需重启 hub**. + +**显示与导航**(`hub_settings.json` → `display`): + +| 开关 | 说明 | +|------|------| +| 监控区资金/浮盈 | 关闭后监控卡片不显示资金户,交易户,浮盈亏列 | +| 顶栏「资金概况」 | 关闭后隐藏导航;直接访问 `/funds` 会跳回监控区 | +| 顶栏「数据看板」 | 关闭后隐藏导航;直接访问 `/dashboard` 会跳回监控区 | + +**下单,关键位,策略交易**:请在监控卡片点击 **「实例」** 或 **「策略交易」**(SSO),进入各 `crypto_monitor_*` 网页(`/trade`,`/key_monitor`,`/strategy`,`/strategy/records` 等).中控 **不** 提供下单区;**策略交易记录** 仅在实例顶栏查看(见 [策略交易说明.md](../策略交易说明.md) §五). + +| 列 | 含义 | +|----|------| +| 启用 | 是否参与监控与全局全平;被 `HUB_DISABLED_IDS` 锁定的无法勾选 | +| 显示名 | 监控卡片标题 | +| Flask URL | 实例根地址,如 `http://127.0.0.1:5001` | +| Agent URL | 子代理根地址,如 `http://127.0.0.1:15200` | +| 复盘链接 | 一般为 `{Flask}/records` | +| **监控关键位** | 勾选后卡片展示 **关键位** 列表 + 门控价(读 Flask `/api/price_snapshot`) | +| **监控趋势计划** | 勾选后卡片展示 **趋势回调** 运行中计划(`trend_pullback_plans` active) | +| id | 与 `HUB_DISABLED_IDS`,全平 API 路径中的 id 对应;新增户勿与已有 id 重复 | + +- **保存设置**:写入 `hub_settings.json`,重启 hub 后仍生效. +- **添加交易所**:见下文 §4.5(须先自建 Flask + agent,再在中控登记). +- **删**:从列表移除(保存后生效). + +#### 能力与「策略交易」的关系(重要) + +| 能力勾选 | 中控监控区 | 策略交易(趋势回调 / 顺势加仓) | +|----------|------------|----------------------------------| +| 监控关键位 | 显示关键位块 | **不控制**;在实例页 `/key_monitor` | +| 监控趋势计划 | 显示趋势计划块 | **不控制**;在实例页 `/strategy` 左栏操作 | +| 均未勾选 | 仅持仓,余额,机器人单 | 仍可在实例网页使用策略交易 | + +三所 Flask 均已注册 `hub_bridge` 且 **`has_trend=true`**,勾选「监控趋势计划」后才会从 `/api/hub/monitor` 拉取趋势数据.修改勾选后 **保存即可**,须 **重启对应 Flask** 仅在你刚升级了 `hub_bridge` 相关代码时. + +--- + +### 4.5 增加账户(例如再挂一个 Gate) + +中控 **不会** 自动启动进程,也 **不** 保存交易所 API Key.新增一户 = **复制/新建一套实例目录 + 独立 `.env` + 新端口 Flask/agent + 在中控登记一行**. + +#### 4.5.1 端口勿冲突(示例) + +| 用途 | 目录(示例) | Flask `APP_PORT` | Agent `PORT` | +|------|----------------|------------------|--------------| +| Gate(已有) | `crypto_monitor_gate` | 5000 | 15202 | +| **新增 Gate 子账户** | 复制为 `crypto_monitor_gate_2` 等 | **5005**(自定) | **15204**(自定) | + +`agent` 的 `PORT` 与 Flask 的 `APP_PORT` **必须不同**;且不要与币安 5001,OKX 5004,中控 5100 等占用端口相同. + +#### 4.5.2 新建实例目录 + +1. 复制整个 `crypto_monitor_gate` 到新目录(仓库内副本或 `/opt/` 下均可). +2. 在新目录:`cp .env.example .env`,至少修改: + - `APP_PORT` → 新 Flask 端口(如 5005) + - `DB_PATH` → 独立库(如 `crypto_gate2.db`),**勿**与其它实例共用 `crypto.db` + - `GATE_API_KEY` / `GATE_API_SECRET` → **该子账户** 密钥 + - `HUB_BRIDGE_TOKEN` → 与中控,其它实例 **相同** +3. 安装 venv 与依赖(`bash /opt/crypto_monitor_user/deploy/setup_env.sh --only gate` 或按 Gate 部署文档),启动: + +```bash +cd /opt/crypto_monitor_user/crypto_monitor_gate_2 +pm2 start ecosystem.config.cjs +``` + +4. 在中控 `ecosystem.config.cjs` 增加对应 agent,或单独 `run_agent.sh` 配置后 `pm2 restart`(勿与已有 agent 端口冲突). + +验收:`curl http://127.0.0.1:5005/login` 能开页;`curl http://127.0.0.1:15204/status` 返回 `ok`. + +#### 4.5.3 在中控登记 + +1. 打开 **系统设置** → **添加交易所**(或手改 `manual_trading_hub/hub_settings.json`). +2. 填写 **Flask URL**,**Agent URL**,**id**(如 `4`),**显示名**. +3. 能力建议: + - 训练/关键位户:**监控关键位** + **监控趋势计划**(若也要在中控看趋势计划); + - 纯趋势户:只勾 **监控趋势计划**. +4. 勾选 **启用** → **保存设置**. +5. 在 **监控区** 应出现新卡片;点 **实例** 进入该户网页做下单与 **策略交易**. + +PM2:仓库 `ecosystem.config.cjs` 默认只有三 agent;额外子账户需自行 `pm2 start` 或手工终端,与是否改 hub 源码无关. + +--- + +## 5. 能力矩阵(监控展示,建议勾选) + +| 账户 | 监控关键位 | 监控趋势计划 | 策略交易(实例页) | +|------|:----------:|:--------------:|:------------------:| +| 币安 | ✓ 建议 | ✓ 建议 | `/strategy` | +| OKX | ✓ 建议 | ✓ 建议 | `/strategy` | +| Gate | ✓ 建议 | ✓ 建议 | `/strategy` | +| Gate | —(通常不勾) | ✓ | `/strategy` | + +「建议」表示中控卡片展示对应块;**不勾** 仍可在该实例网页使用关键位或策略交易. + +--- + +## 6. HTTP API 摘要(中控) + +访问控制: + +- **IP**:默认允许本机与 RFC1918 私网(`HUB_TRUST_LAN=true`);公网 IP 直连返回 403. +- **登录**:设置 `HUB_PASSWORD` 后须用户名+密码登录(`HUB_USERNAME`,未设时默认 `admin`);反代到公网时**务必设置**. + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/settings` | 读取配置 | +| POST | `/api/settings` | 保存配置 | +| GET | `/api/monitor/board` | 监控聚合 | +| POST | `/api/close/{id}` | 单户全平 | +| POST | `/api/close-all` | 全局全平,body 可选 `exclude_ids` | +| GET | `/api/auth/status` | 是否需登录,是否已登录 | +| POST | `/api/auth/login` | body `{"username":"...","password":"..."}` | +| POST | `/api/auth/logout` | 退出 | +| GET | `/api/ping` | 版本与健康检查(**免登录**) | +| GET | `/api/chart/meta` | 行情区:交易所,周期,limit | +| GET | `/api/chart/ohlcv` | 行情区 K 线(`exchange_key`,`symbol`,`timeframe`,可选 `refresh=1`) | +| GET | `/api/hub/fund-overview` | 资金概况:总/分户资金,180 日曲线,回撤 | +| GET | `/api/archive/meta` | 内照明心:周期,同步间隔 | +| GET | `/api/archive/daily-trades` | 内照明心:区间交易与统计(`period` / `date_from` / `date_to`) | +| GET | `/api/archive/quotes` | 内照明心:复盘语录 | +| GET | `/api/archive/list` | 币种列表(筛选 query) | +| GET | `/api/archive/detail` | 单币种交易时间线 | +| GET | `/api/archive/ohlcv` | 档案 K 线视窗 | +| PATCH | `/api/archive/trade/{exchange_key}/{trade_id}` | 犯病/情绪标签与备注 | +| POST | `/api/archive/sync` | 立即同步三所交易与 K 线 | + +已移除的 `/api/trade/*` 若被旧缓存页面请求,返回 **410** 并提示前往各实例网页. + +实例侧(中控只读;下单/关键位/趋势在实例网页): + +| 路径 | 说明 | +|------|------| +| `/api/hub/ping` | 连通与能力 | +| `/api/hub/monitor` | 关键位,机器人单,趋势计划 | +| `/api/hub/ohlcv` | 行情区 OHLCV(ccxt 拉取,供中控聚合缓存) | +| `/api/hub/trades/archive` | 内照明心:近 N 天已平仓(`days` / `limit`) | + +--- + +## 7. 环境变量速查 + +### 中控 hub.py + +| 变量 | 默认 | 说明 | +|------|------|------| +| `HUB_HOST` | `0.0.0.0` | 监听地址 | +| `HUB_PORT` | `5100` | 监听端口 | +| `HUB_BRIDGE_TOKEN` | 空 | Flask 桥接令牌;可同 `CONTROL_TOKEN` | +| `HUB_DISABLED_IDS` | `1` | 逗号分隔,强制关闭的账户 id | +| `HUB_TRUST_LAN` | `true` | `false` 时仅本机可访问中控页面 | +| `HUB_USERNAME` | `admin` | 登录用户名(仅当已设密码时生效) | +| `HUB_PASSWORD` | (空) | 非空即启用 Web 登录 | +| `HUB_SESSION_SECRET` | 用户名+密码 | 会话 Cookie 签名密钥 | +| `HUB_COOKIE_SECURE` | `false` | HTTPS 反代建议 `true`(仅 HTTPS 发 Secure Cookie,HTTP 内网 IP 仍可登) | +| `HUB_SESSION_DAYS` | `7` | 登录保持天数 | +| `HUB_KLINE_RETENTION_DAYS` | `15` | 行情区 K 线库保留天数 | +| `HUB_KLINE_DB_PATH` | `data/hub_kline.db` | K 线 SQLite 路径 | +| `HUB_ARCHIVE_DB_PATH` | `data/hub_symbol_archive.db` | 内照明心永久 K 线库 | +| `HUB_ARCHIVE_SYNC_INTERVAL_SEC` | `14400` | 档案 K 线后台同步间隔(秒) | +| `HUB_ARCHIVE_TRADE_DAYS` | `365` | 同步交易记录回看天数 | +| `HUB_ARCHIVE_TRADE_LIMIT` | `2000` | 单所同步交易条数上限 | + +### 子代理 agent.py + +| 变量 | 说明 | +|------|------| +| `EXCHANGE` | `binance` / `okx` / `gate` | +| `PORT` / `HOST` | 监听 | +| `CONTROL_TOKEN` | 与中控一致时必填头 `X-Control-Token` | + +### 各实例 Flask + +| 变量 | 说明 | +|------|------| +| `HUB_BRIDGE_TOKEN` | 与中控一致 | +| `APP_AUTH_DISABLED` | `true` 时跳过登录与令牌(仅建议本机调试) | + +--- + +## 8. 安全与边界 + +1. **中控不下单**:开仓,关键位,趋势回调仅在各实例网页操作. +2. **全平为市价减仓**:监控区全平不可撤销,操作前二次确认. +3. **子代理建议只监听 127.0.0.1**,不要对局域网暴露 API Key 通道. +4. **公网暴露 hub**:必须设置 `HUB_USERNAME` + `HUB_PASSWORD`;HTTPS 反代建议 `HUB_COOKIE_SECURE=true`;亦可 `HUB_HOST=127.0.0.1` 仅本机监听 + 反代. +5. **复盘不在中控**:时间筛选,导出 CSV,编辑笔记仍在各实例 `/records`. +6. **OKX 默认关**:避免未部署 OKX 时监控卡片持续报错. + +--- + +## 9. 故障排查(速查) + +完整实录(含 `api_trade_key`,`multipart`,git 版本,PM2 等)见 **[常见问题.md](./常见问题.md)**. + +| 现象 | 可能原因 | 处理 | +|------|----------|------| +| 监控卡片「子代理不可用」 | agent 未启动或端口错 | 检查 Agent URL;`pm2 restart` agent | +| 无关键位/趋势信息 | Flask 未起或 hub_bridge 未加载 | 启动 `crypto_*`;`curl .../api/hub/ping` | +| 全平 401 | `CONTROL_TOKEN` 与中控不一致 | 与 `HUB_BRIDGE_TOKEN` 对齐 | +| OKX 始终灰色 | `HUB_DISABLED_IDS=1` | 改掉环境变量并在设置页启用 | +| 打开即跳转登录 | 已设 `HUB_PASSWORD` | 正常;访问 `/login` | +| 域名能登,IP:5100 不能 | Secure Cookie + HTTP | 见常见问题 §2.1;或分别登录 | +| 添加关键位报错 / SyntaxError | 旧前端或旧 hub 代码 | 强刷浏览器;`git pull` + `verify_hub_deploy.sh` | +| `curl /api/ping` 非 JSON | hub 未启动 | `pm2 restart manual-trading-hub` | +| K 线只有约 300 根 | 旧版未分页 | `git pull` 三实例 + hub,强制刷新 | +| 12h 周期异常 | 无原生 12h 或旧缓存 | 强制刷新;见 [行情区说明.md](./行情区说明.md) | + +**运维脚本**(在 `manual_trading_hub` 目录执行): + +| 脚本 | 作用 | +|------|------| +| `scripts/fix_hub_deps.sh` | 安装依赖(含 `python-multipart`) | +| `scripts/verify_hub_deploy.sh` | 检查代码版本与 ping | +| `scripts/fix_env_crlf.sh` | 修复 `.env` 的 CRLF 导致 agent 起不来 | + +手动探测实例桥接: + +```bash +curl -sS -H "X-Hub-Token: 你的令牌" http://127.0.0.1:5001/api/hub/ping +``` + +--- + +## 10. 与旧版 README 的差异 + +早期中控 **仅监控 + 全平**,使用环境变量 `HUB_AGENTS` 列表.当前版本改为: + +- **hub_settings.json**(或内置默认)管理三所 URL 与能力; +- **三页 UI**:监控 / 行情 / 设置; +- 通过 **hub_bridge** 只读聚合监控数据. + +子代理 `agent.py` 仍负责持仓与全平;`HUB_AGENTS` 环境变量在新版 hub 中 **不再使用**(以设置文件为准). + +**PM2 守护**: + +```bash +cd /opt/crypto_monitor_user/manual_trading_hub +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env +pm2 start ecosystem.config.cjs # 一次启动 3 个 agent + manual-trading-hub +pm2 save && pm2 startup +``` + +快捷:`bash scripts/pm2_hub.sh start|restart|logs`(同样 hub+agent 一起). + +更细的安装顺序,反代,验收见 **《部署文档.md》**;PM2 见 **[scripts/后台运行-Ubuntu.md](./scripts/后台运行-Ubuntu.md)**. + +--- + +## 11. 日常推荐流程 + +1. 启动三所 **agent** + **Flask**(OKX 按需). +2. 启动 **hub.py**,打开监控区确认持仓与关键位门控正常. +3. 看 K 线 → **行情区** 或监控区点击合约名跳转;异常图表点 **强制刷新**. +4. 开仓,关键位,趋势 → 点击监控卡片「实例」进入对应 Flask. +5. 复盘,导出记录 → 点击「复盘」进入 `/records`. +6. 异常行情 → 单户全平或全局紧急全平. + +增加账户步骤见 **§4.4**;无需改 `hub.py` 源码,但须该户 Flask 已 `git pull` 并 **重启**(`hub_bridge` + `has_trend` + `ohlcv`),且 agent 已部署. + +--- + +## 12. 文档索引 + +| 文档 | 内容 | +|------|------| +| [使用说明.md](./使用说明.md) | 本文 | +| [行情区说明.md](./行情区说明.md) | K 线周期,缓存,快捷键,API | +| [开仓计划说明.md](./开仓计划说明.md) | 计划录入,归档,胜率统计 | +| [docs/hub-symbol-archive-kline.md](../docs/hub-symbol-archive-kline.md) | 内照明心,区间统计,永久 5m,建档与同步 | +| [部署文档.md](./部署文档.md) | Ubuntu / PM2 / 反代 | +| [常见问题.md](./常见问题.md) | 故障实录与排障 | +| [README.md](./README.md) | 速览 | +| [.env.example](./.env.example) | 环境变量模板 | +| [scripts/后台运行-Ubuntu.md](./scripts/后台运行-Ubuntu.md) | PM2 常驻 | +| [docs/ubuntu-server.md](../docs/ubuntu-server.md) | Ubuntu 环境总览 | diff --git a/manual_trading_hub/局域网与反代部署说明.md b/manual_trading_hub/局域网与反代部署说明.md new file mode 100644 index 0000000..86bb958 --- /dev/null +++ b/manual_trading_hub/局域网与反代部署说明.md @@ -0,0 +1,226 @@ +# 中控 · 局域网与反代部署说明 + +本文说明在 **局域网(IP + 端口)** 与 **宝塔/Nginx 反代(域名)** 两种场景下,如何配置中控与各实例,并实现: + +- **从中控** 点「实例 / 策略交易 / 复盘」→ **免输入** 实例网页密码(SSO 临时链接,默认 **2 小时** 内有效,**单次使用**) +- **浏览器直链** 实例地址(反代域名或 `http://IP:端口`)→ 进入 **`/login`**,输入统一 **`APP_USERNAME` / `APP_PASSWORD`** + +SSO 签名复用 **`HUB_BRIDGE_TOKEN`**(与中控调实例 API 相同,三所 `.env` 与 `manual_trading_hub/.env` 保持一致). + +**云服务器(VPS)** 的硬件,安全组,宝塔,环境变量与验收清单见 **[云服务器部署说明.md](./云服务器部署说明.md)**. + +--- + +## 一,两种访问方式对照 + +| 项目 | 局域网 | 反代(域名) | +|------|--------|----------------| +| 中控地址 | `http://内网IP:5100` | `https://hub.你的域名.com` | +| 实例地址(浏览器) | `http://内网IP:5004` 等 | `https://okx.你的域名.com` 等 | +| `hub_settings` 里 `flask_url` | 建议写 **`http://内网IP:端口`** | 建议写 **`https://该实例域名`**(与浏览器一致) | +| 中控本机调实例 API | 可与浏览器相同;同机也可用 `http://127.0.0.1:端口` + `HUB_PUBLIC_ORIGIN` | 同机可用 `127.0.0.1:端口` 或域名(需 Nginx 转发 `X-Hub-Token`) | +| `HUB_PUBLIC_ORIGIN` | 若 `flask_url` 填 `127.0.0.1`,**必填** `http://内网IP` | 若 `flask_url` 已是完整域名,**可不设** | +| 宝塔 | 可不装反代,直连端口 | 每实例一个站点 + SSL;中控单独站点 | +| 直链登录 | 实例 `/login` | 实例 `/login` | +| 从中控打开 | `/hub-sso?token=...` 自动登录 | 同上 | + +--- + +## 二,共用环境变量(必配) + +### 2.1 中控 `manual_trading_hub/.env` + +```bash +HUB_BRIDGE_TOKEN=请填一长串随机字符 +HUB_USERNAME=admin # 中控登录(建议设置) +HUB_PASSWORD=你的中控密码 +HUB_SSO_TTL_SEC=7200 # 可选,默认 7200 = 2 小时 +``` + +### 2.2 三个实例 `crypto_monitor_*/.env` + +每个目录相同(**直链**时用这套登录实例网页): + +```bash +HUB_BRIDGE_TOKEN=与中控完全相同 +APP_USERNAME=统一用户名 +APP_PASSWORD=统一密码 +# 云上切勿 APP_AUTH_DISABLED=true +``` + +### 2.3 子代理 + +`CONTROL_TOKEN` 可与 `HUB_BRIDGE_TOKEN` 相同;子代理只监听 `127.0.0.1`,**不要**对公网暴露 `15200`~`15202`. + +--- + +## 三,局域网部署(IP + 端口) + +适用:家里/办公室内网,例如服务器 `192.168.8.6`. + +### 3.1 端口约定(示例,以你实际为准) + +| 服务 | 端口 | +|------|------| +| 中控 hub | 5100 | +| OKX Flask | 5004 | +| 币安 Flask | 5001 | +| Gate | 5000 | +| agent | 15200~15202(仅本机) | + +### 3.2 系统设置 `hub_settings.json`(网页「系统设置」保存) + +浏览器里你会打开的地址,应使用 **内网 IP**,不要用 `127.0.0.1`(否则别的电脑上的浏览器会连到你本机): + +```json +{ + "flask_url": "http://192.168.8.6:5004", + "agent_url": "http://127.0.0.1:15201" +} +``` + +说明: + +- **`flask_url`**:给浏览器用的实例页地址 → 写 **`http://192.168.8.6:端口`** +- **`agent_url`**:仅中控服务器访问 → 写 **`http://127.0.0.1:1520x`** + +各账户按上表改端口即可. + +### 3.3 可选:`flask_url` 仍写 127.0.0.1 时 + +若坚持 `flask_url` 为 `http://127.0.0.1:5004`(仅 hub 与本机 Flask 同机),在中控 `.env` 增加: + +```bash +HUB_PUBLIC_ORIGIN=http://192.168.8.6 +``` + +中控会把返回给前端的链接从 `127.0.0.1` 替换为 `192.168.8.6`(端口保留). + +### 3.4 访问方式 + +1. 中控:`http://192.168.8.6:5100` → 登录中控 → 点「实例」→ 新标签进入 OKX,**无需**再输实例密码. +2. 直链:`http://192.168.8.6:5004` → 出现登录页 → 输入 `APP_USERNAME` / `APP_PASSWORD`. + +### 3.5 防火墙 + +内网自用:放行 `5100`,各 `APP_PORT`;**不要**对公网开放 agent 端口. + +--- + +## 四,反代部署(域名 + 宝塔) + +适用:云服务器,对外用 HTTPS 域名. + +### 4.1 域名规划(示例) + +| 站点 | 反代到 | +|------|--------| +| `hub.example.com` | `127.0.0.1:5100` | +| `okx.example.com` | `127.0.0.1:5004` | +| `binance.example.com` | `127.0.0.1:5001` | +| `gate.example.com` | `127.0.0.1:5000` | + +Flask / hub 进程仍只监听 **127.0.0.1** 或 `0.0.0.0` 本机端口,由 Nginx 对外提供 HTTPS. + +### 4.2 宝塔操作要点 + +1. 每个域名 → **反向代理** → 目标 `http://127.0.0.1:对应端口`. +2. 申请 **SSL**(Let’s Encrypt). +3. **不要**再给实例站加一层宝塔「访问密码」(避免与 Flask `/login` 重复);直链鉴权用 **`APP_USERNAME` / `APP_PASSWORD`** 即可. +4. 自定义 Nginx 配置中保留 WebSocket/大 body 如需;确保代理头: + +```nginx +proxy_set_header Host $host; +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Forwarded-Proto $scheme; +``` + +中控请求实例 API 时会带 **`X-Hub-Token`**,Nginx 默认会转发请求头,一般无需额外配置. + +### 4.3 `hub_settings` 示例(反代) + +```json +{ + "flask_url": "https://okx.example.com", + "agent_url": "http://127.0.0.1:15201" +} +``` + +- 浏览器与 SSO 链接使用 **`https://okx.example.com`**. +- 中控服务器拉 `/api/hub/*` 仍走本机 `agent_url`;`flask_url` 用域名时,hub 会请求 `https://okx.example.com/api/...`(同机可通即可). + +同机部署时也可: + +- `flask_url`: `http://127.0.0.1:5004` +- `HUB_PUBLIC_ORIGIN`: `https://okx.example.com` + +仅当**所有实例共用一个对外 IP,靠端口区分**时才适合用 `HUB_PUBLIC_ORIGIN`;**每实例独立域名**时,请直接在 `flask_url` 写该实例域名. + +### 4.4 中控 `.env`(反代建议) + +```bash +HUB_BRIDGE_TOKEN=... +HUB_USERNAME=... +HUB_PASSWORD=... +HUB_COOKIE_SECURE=true # 中控为 HTTPS 时建议开启 +``` + +### 4.5 访问方式 + +1. `https://hub.example.com` 登录中控 → 点「打开实例」→ `https://okx.example.com/hub-sso?...` → 进入系统. +2. 地址栏直接输入 `https://okx.example.com` → `/login` → 实例账号密码. + +--- + +## 五,SSO 行为说明(2 小时) + +| 项 | 说明 | +|----|------| +| 有效期 | 默认 **7200 秒(2 小时)**,`HUB_SSO_TTL_SEC` 可改 | +| 单次使用 | 同一链接成功登录后 **不能再用**;需在中控重新点「打开实例」 | +| 密钥 | 复用 **`HUB_BRIDGE_TOKEN`** | +| 直链 | 无 token → 正常 **`/login`** | + +--- + +## 六,部署与重启顺序 + +```bash +cd /opt/crypto_monitor_user +# 各实例 +pm2 restart crypto_okx crypto_binance crypto_gate # 名称以你为准 + +cd manual_trading_hub +pm2 restart manual-trading-hub manual-agent-binance manual-agent-okx manual-agent-gate +``` + +改 `hub_settings` 或 `.env` 后重启 **hub + 对应实例 Flask**(`hub_bridge` 与 `/hub-sso` 在实例进程内). + +--- + +## 七,验收清单 + +- [ ] 三实例 `.env` 与中控 `HUB_BRIDGE_TOKEN` 一致 +- [ ] 三实例 `APP_USERNAME` / `APP_PASSWORD` 一致 +- [ ] 局域网:`flask_url` 为 `http://IP:端口`;反代:`flask_url` 为 `https://域名` +- [ ] 已登录中控 → 点「实例」→ **无**实例登录页 +- [ ] 隐身窗口直链实例域名/IP → **有** `/login` +- [ ] 复制「打开实例」完整 URL,用过一次后再开 → 失效并回到登录页 + +--- + +## 八,常见问题 + +**Q:从中控打开仍要登录?** +- 检查实例是否已 `git pull` 并重启(需有 `/hub-sso`). +- `HUB_BRIDGE_TOKEN` 是否三所一致. +- `hub_settings` 里该账户 `key` 是否与 `install_on_app(exchange=...)` 一致(如 `okx`,`binance`,`gate`,`gate`). + +**Q:直链也要登录中控?** +- 不应.直链只走实例 `/login`.若跳到中控,检查是否点错链接或 Nginx 配错站点. + +**Q:链接多久失效?** +- 签发后 **2 小时**内且 **未使用过**;过期或已用需在中控重新点打开. + +更多故障见 [常见问题.md](./常见问题.md),[部署文档.md](./部署文档.md). diff --git a/manual_trading_hub/常见问题.md b/manual_trading_hub/常见问题.md new file mode 100644 index 0000000..a73f922 --- /dev/null +++ b/manual_trading_hub/常见问题.md @@ -0,0 +1,354 @@ +# 中控与三实例 — 常见问题实录 + +本文档整理部署与运行 **manual_trading_hub**(复盘系统中控)及三所 `crypto_monitor_*` 时**实际遇到过**的问题与处理办法.操作步骤仍以 [使用说明.md](./使用说明.md),[部署文档.md](./部署文档.md) 为准. + +--- + +## 一,中控进程与代码版本 + +### 1.1 PM2 日志仍出现 `api_trade_key`,`python-multipart` 断言 + +**现象**:`pm2 logs` 里报错 `File "hub.py", line 324, in api_trade_key` 或 `The python-multipart library must be installed`. + +**原因**: + +- 服务器上的 `hub.py` 仍是**旧版**(含已移除的「下单区」接口),或 pull 后**未重启** PM2,日志是历史残留. +- 旧版「添加关键位」会 `request.form()`,未装 `python-multipart` 时直接 500. + +**处理**: + +```bash +cd /opt/crypto_monitor_user +git pull + +cd manual_trading_hub +bash scripts/fix_hub_deps.sh # 安装 python-multipart 等 +bash scripts/verify_hub_deploy.sh # 应显示无 api_trade_key,含 HUB_BUILD + +pm2 restart manual-trading-hub +curl -s http://127.0.0.1:5100/api/ping +``` + +**正常 ping**(无需登录)应含 `"build":"20260521-no-trade-ui"`,`"trade_ui":false`. + +**说明**:当前版本**已移除中控下单区**;添加关键位,人工下单,趋势回调请在监控卡片点 **「实例」** 进入各 Flask 网页.浏览器请 **Ctrl+F5** 强刷,避免旧前端缓存仍请求 `/api/trade/key`. + +--- + +### 1.2 `curl /api/ping` 返回 `{"detail":"未登录"}` + +**原因**:早期版本未把 `/api/ping` 列入免登录白名单(已修复). + +**处理**:`git pull` 后 `pm2 restart manual-trading-hub`;再测应直接返回 JSON,无需 Cookie. + +--- + +### 1.3 `verify_hub_deploy.sh` 报 `Expecting value: line 1 column 1` + +**原因**:5100 端口无进程监听(hub 未启动或已崩溃),`curl` 拿到空响应. + +**处理**: + +```bash +pm2 restart manual-trading-hub +sleep 2 +pm2 logs manual-trading-hub --lines 30 --nostream +ss -ltn | grep 5100 +bash scripts/verify_hub_deploy.sh +``` + +--- + +### 1.4 `bash scripts/fix_hub_deps.sh` 在仓库根目录找不到 + +**原因**:脚本在 `manual_trading_hub/scripts/` 下,不在 `/opt/crypto_monitor_user/scripts/`. + +**处理**: + +```bash +cd /opt/crypto_monitor_user/manual_trading_hub +bash scripts/fix_hub_deps.sh +``` + +--- + +## 二,登录与 Cookie(反代 / 域名 / 内网 IP) + +### 2.1 设了密码后,域名能登录,`http://内网IP:5100` 不能 + +**原因**(最常见): + +- `.env` 中 `HUB_COOKIE_SECURE=true`,且用 **HTTP** 访问 IP:5100 → 浏览器**不保存**带 `Secure` 的 Cookie,表现为登录成功后又跳回登录页. +- **域名(HTTPS)** 与 **IP:5100(HTTP)** 是不同站点,Cookie **不共用**,需在 IP 上再登一次. + +**处理**: + +- 已支持:仅在实际 **HTTPS** 请求时发 `Secure` Cookie(读 `X-Forwarded-Proto`),HTTP 内网 IP 可正常登录. +- 反代 Nginx 需传:`proxy_set_header X-Forwarded-Proto $scheme;` +- 若仍异常:HTTPS 域名与 HTTP IP **分别登录**;或内网仅用 IP 时可注释 `HUB_COOKIE_SECURE`. + +### 2.2 登录后接口仍 401 + +| 检查项 | 说明 | +|--------|------| +| 用户名密码 | `.env` 中 `HUB_USERNAME`(未设默认为 `admin`),`HUB_PASSWORD` | +| 改密后 | 需重新登录;旧 Cookie 失效 | +| 混用地址 | 不要用 A 浏览器标签登域名,B 标签指望 IP 已登录 | + +### 2.3 本地导航 iframe 嵌入:登录成功但一直「跳转中」/ 进不去 + +**原因**:父页(如 `http://192.168.8.6:5070`)跨域 `fetch` 中控 `/api/auth/login` 时,浏览器**不会**把 `Set-Cookie` 写进 iframe 里的中控站点,表现为接口 200,弹窗「登录成功」,但 iframe 仍无会话. + +**处理**(中控 `git pull` 并重启 hub 后): + +1. 登录接口会返回 `session_token`;父页应把 iframe 指向: + `http://中控地址/embed-auth?token=会话token&next=/monitor` +2. 若直接在 iframe 内打开中控 `/login` 登录,页面会自动走 `/embed-auth` 写入 Cookie. +3. 父页也可监听 `postMessage`,事件类型 `hub:login-ok`,字段含 `embed_auth_url`. + +`.env` 可选: + +```env +HUB_ALLOW_EMBED=true +HUB_EMBED_ORIGINS=http://192.168.8.6:5070 +``` + +--- + +## 三,监控区无数据 / 子代理异常 + +### 3.1 卡片「子代理不可用」或余额为 — + +| 原因 | 处理 | +|------|------| +| agent 未启动 | `pm2 restart ecosystem.config.cjs` 或 `pm2 restart manual-agent-*` | +| Agent URL 与端口不符 | 系统设置里应为 `http://127.0.0.1:15200` 等 | +| PM2 未加载策略 `.env` | 须用 `run_agent.sh` 启动(会 `source` 各目录 `.env`),勿裸跑 `agent.py` | +| `.env` 为 Windows CRLF | 日志 `$'\r': command not found` → `bash scripts/fix_env_crlf.sh` 后重启 | + +验证: + +```bash +curl -s http://127.0.0.1:15202/status | head -c 300 +``` + +应 `ok: true` 且有 `balance_usdt`. + +### 3.3 Gate 子代理「一会正常,一会连不上」(仅 Gate 两户) + +| 现象 | 说明 | +|------|------| +| 中控某所子代理红 | 本机对应 agent 端口在 PM2 重启间隙连不上 | +| 日志 `$'\r': command not found` | `crypto_monitor_gate*` 的 `.env` 为 Windows CRLF | +| `curl` 有时通有时不通 | 与 Gate 外网无关,先修 CRLF 并重建 agent | + +**修复**(服务器): + +```bash +cd /opt/crypto_monitor_user +sed -i 's/\r$//' crypto_monitor_gate/.env crypto_monitor_gate/.env +bash manual_trading_hub/scripts/fix_env_crlf.sh +cd manual_trading_hub && pm2 restart manual-agent-gate +# 仍反复重启时:pm2 delete 后按 ecosystem.config.cjs 重新 start(见部署文档 §5.6) +``` + +修好后 `pm2 describe manual-agent-gate` 的 **restarts** 应不再疯涨;`pm2 flush manual-agent-gate` 可清掉旧 CRLF 日志. + +**若子代理已绿但挂委托失败**:再查 `GATE_SOCKS_PROXY`,API 权限,止损止盈价格是否合理(与各实例策略页相同 `.env` 参数). + +### 3.2 有持仓但无关键位 / 趋势,或提示 Flask 404 + +| 原因 | 处理 | +|------|------| +| 对应 `crypto_*` Flask 未启动 | `pm2 restart crypto_gate` 等 | +| 未注册 `hub_bridge` | 启动日志勿含 `[hub_bridge] ImportError`;仓库根需在 `PYTHONPATH`(各实例 `ecosystem.config.cjs` 已配 `PYTHONPATH=..`) | +| 中控 `ModuleNotFoundError: hub_auth` | 确认仓库根存在 `/opt/crypto_monitor_user/hub_auth.py`(`git pull`);`run_hub.sh` / PM2 已设 `PYTHONPATH=仓库根`;`pm2 restart manual-trading-hub` | +| `HUB_BRIDGE_TOKEN` 不一致 | 中控 `.env` 与三实例 `.env` 设相同令牌,或实例 `APP_AUTH_DISABLED=true`(仅建议本机) | + +```bash +curl -s -H "X-Hub-Token:你的令牌" http://127.0.0.1:5000/api/hub/ping +``` + +### 3.3 中控监控区打开慢,一直转圈 + +**原因(常见)**: + +1. 首屏要等 **`/api/monitor/board`**:向 4 个子代理拉持仓/余额,并向 4 个 Flask 拉监控与(默认)关键位行情;任一实例慢或超时都会拖住整页. +2. 旧版 hub 对每所 Flask **串行**请求,3 所 × 3 接口容易累计到十几秒;新版已改为**并行**(`git pull` 后 `pm2 restart manual-trading-hub`). +3. 各实例 **`/api/price_snapshot`** 会调交易所接口(含全量持仓),最耗时;内网访问 Google 字体也会拖首屏渲染. +4. 子代理 `/status` 里 `fetch_balance` / `fetch_positions` / 挂单列表走交易所 API,网络差时单次可达数秒. + +**加快办法**: + +```env +# manual_trading_hub/.env +HUB_BOARD_KEY_PRICES=false # 不拉 price_snapshot,关键位门控显示为「-」,首屏明显更快 +HUB_AGENT_TIMEOUT=6 +HUB_FLASK_TIMEOUT=8 +``` + +并确认三所 `crypto_*` 与 `manual-agent-*` 均为 **online**,避免等满超时.浏览器 **Ctrl+F5** 强刷静态资源(版本号含 `20260525-perf`). + +--- + +## 四,云服务器 / 公网反代 + +**云服务器完整配置(安全组,宝塔,环境变量,PM2,验收)** 见 **[云服务器部署说明.md](./云服务器部署说明.md)**. + +--- + +## 五,复盘链接与公网反代 + +### 4.1 监控里点「复盘」打开的是本机 127.0.0.1 + +**原因**:未设 `HUB_PUBLIC_ORIGIN`,浏览器拿到的链接仍是 Flask 本机地址. + +**处理**:`manual_trading_hub/.env` 增加(示例): + +```env +HUB_PUBLIC_ORIGIN=http://192.168.8.6 +``` + +或 `HUB_PUBLIC_HOST=192.168.8.6`.改后 `pm2 restart manual-trading-hub`. + +**说明**:仅反代中控,三实例 Flask 仍只监听 127.0.0.1 时,其它电脑要能打开复盘,还须能访问各实例端口或单独反代. + +### 4.2 只反代中控,不反代三实例 + +**可以**.中控聚合监控与全平;复盘,下单,关键位维护进各实例网页.实例 Flask/agent 建议 `127.0.0.1` + 与中控相同的 `HUB_BRIDGE_TOKEN`. + +### 4.3 从中控「打开实例」仍要输密码 + +**完整说明**:[局域网与反代部署说明.md](./局域网与反代部署说明.md) + +**常见原因**: + +1. 三实例未重启,`/hub-sso` 未加载(启动日志勿长期 `[hub_bridge] ImportError`). +2. `HUB_BRIDGE_TOKEN` 与三实例 `.env` 不一致. +3. `hub_settings` 里该户 `key` 与实例 `install_on_app(exchange=...)` 不一致(如 `okx`,`gate`). +4. **HTTPS 跨域 iframe**:中控与实例不同域名时,三实例须 `APP_COOKIE_SECURE=true`(使 session Cookie 为 `SameSite=None`),否则 SSO 成功仍跳 `/login`. +5. **经本地导航打开中控**(LocalNav → 中控 iframe → 点实例):旧版会在中控内再嵌一层实例 iframe,Cookie 易失效.请升级 **LocalNav + 中控** 最新代码:点实例后由导航页直接打开实例,工具栏有「← 中控」;须配置 `NAV_HUB_USERNAME` / `NAV_HUB_PASSWORD`,三实例 `HUB_EMBED_PARENT_ORIGINS` 含本地导航地址(如 `http://192.168.8.6:5070`). +6. 浏览器仍用旧书签直链首页,未从中控点「实例」(直链本来就要登录). + +**直链**:`http://IP:端口` 或 `https://实例域名` → 使用各实例 **`APP_USERNAME` / `APP_PASSWORD`**(三所建议统一). + +--- + +## 六,Gate / 复盘相关(实例侧) + +### 5.1 Gate `/records` 或预览 500(`preview_created_at`) + +**原因**:数据库缺列或查询未兼容旧库. + +**处理**:`git pull` 后重启 `crypto_gate`;必要时在实例目录执行一次带 `init_db` 的启动或按该目录更新文档迁移. + +### 5.2 中控监控区 Gate「无关键位」 + +**说明**:若系统设置未勾选「监控关键位」,中控不会展示关键位区块;策略交易仍在各实例 `/strategy` 操作. + +--- + +## 七,环境与配置 + +### 6.1 OKX 默认不显示 + +`HUB_DISABLED_IDS=1`(默认关 OKX).要用 OKX:清空或改掉该变量,并在系统设置启用 id=1. + +### 6.2 公网 IP 直连中控 403 + +`HUB_TRUST_LAN=true` 时仅允许本机 + RFC1918 私网(10/172.16/192.168).公网 IP 直连 5100 会被拒;应走 **Nginx 反代到 127.0.0.1:5100**. + +### 4.4 浏览器显示 `{"detail":"forbidden"}` + +**原因**:中控 `local_only` 中间件认为访问来源 IP 不允许(常见于云上 `HUB_TRUST_LAN=false` 且反代未指向 `127.0.0.1:5100`). + +**处理**(二选一): + +1. `manual_trading_hub/.env` 增加 **`HUB_ALLOW_PUBLIC=true`**(已设 `HUB_PASSWORD` 时推荐),`pm2 restart manual-trading-hub`. +2. 宝塔反代目标改为 **`http://127.0.0.1:5100`**(不要用公网 IP:5100 作 upstream). + +改后强刷浏览器再开 `/login`. + +### 6.3 `.env` 修改不生效 + +PM2 须重启:`pm2 restart manual-trading-hub`(`run_hub.sh` 每次启动会重读 `.env`). + +### 6.4 `hub_settings.json` 与 Git + +网页「系统设置」保存生成,**一般不提交 Git**.`git pull` **不会覆盖** 该文件与 `.env`. + +--- + +## 八,功能边界(避免误用) + +| 项目 | 说明 | +|------|------| +| 中控下单区 | **已移除**;勿再在中控添加关键位/人工单/趋势预览 | +| 中控能力 | 监控聚合,单户/全局紧急全平,系统设置,登录保护 | +| 下单与关键位 | 各 `crypto_monitor_*` 原网页 | +| 复盘 | 各实例 `/records`;中控仅「复盘」外链 | +| 全平 | 市价减仓,不可撤销,操作前确认 | + +--- + +## 九,推荐排障顺序 + +1. `git pull` → `manual_trading_hub` 下 `bash scripts/fix_hub_deps.sh` → `bash scripts/verify_hub_deploy.sh` +2. `pm2 restart manual-trading-hub`(及 `ecosystem.config.cjs` 若 agent/Flask 也有问题) +3. `curl http://127.0.0.1:5100/api/ping` → 确认 `build` 与 `trade_ui:false` +4. 浏览器打开 `/login` 登录 → `/monitor` 强刷 +5. 逐项 `curl` 子代理 `/status`,Flask `/api/hub/ping` +6. 仍不行则查 `pm2 logs manual-trading-hub`,`pm2 logs crypto_gate` 最近 50 行 + +--- + +## 十,行情区 K 线 + +### 10.1 只加载约 300 根(目标 1000) + +**原因**:旧版 `hub_ohlcv_lib` 无 `since` 分页时,OKX/Gate 单次 API 常只返回 ~300 根. + +**处理**:`git pull` 后重启 **hub + 三实例 Flask**,行情区点 **强制刷新**;浏览器强刷(`chart.js` 带版本号). + +### 10.2 6h / 8h 周期错乱(已移除) + +中控行情区 **已不再提供** `6h`,`8h`(以及 `3m`/`10m`/`20m`/`30m`).若 URL 或旧缓存仍带这些周期,会回退为 `5m`.请改用 `4h` / `12h` 等当前列表,见 [行情区说明.md](./行情区说明.md). + +### 10.3 12h 数据异常 + +**原因**:部分交易所无原生 12h;或本地 `hub_kline.db` 存有升级前的错误缓存. + +**处理**:强制刷新;仍异常可停 hub 后备份并删除 `manual_trading_hub/data/hub_kline.db` 再拉取. + +### 10.4 快捷键无效 + +- 全屏请用 **`F`**(Win 下 Ctrl+空格常被输入法占用,已不作为全屏键). +- 须在 **行情区** 页面且焦点不在币种输入框. +- 升级后确认加载 `chart.js?v=...` 新版本. + +--- + +## 十一,相关脚本 + +| 脚本 | 作用 | +|------|------| +| `scripts/fix_hub_deps.sh` | 安装/更新中控 venv 依赖(含 python-multipart) | +| `scripts/verify_hub_deploy.sh` | 检查代码版本,multipart,ping,PM2 状态 | +| `scripts/fix_env_crlf.sh` | 去除各目录 `.env` 的 Windows 换行 | +| `scripts/run_hub.sh` | PM2 启动 hub(加载 `.env`) | +| `scripts/run_agent.sh` | PM2 启动 agent(加载策略目录 `.env`) | +| `scripts/pm2_hub.sh` | 启停/日志 hub+agent 一体 | + +--- + +## 十二,文档索引 + +| 文档 | 内容 | +|------|------| +| [使用说明.md](./使用说明.md) | 架构,页面,环境变量,API | +| [行情区说明.md](./行情区说明.md) | K 线周期,缓存,快捷键 | +| [部署文档.md](./部署文档.md) | Ubuntu/PM2 安装与运维 | +| [云服务器部署说明.md](./云服务器部署说明.md) | VPS 配置,安全组,宝塔,env,验收 | +| [局域网与反代部署说明.md](./局域网与反代部署说明.md) | 内网 IP:端口 / 域名反代,SSO | +| [README.md](./README.md) | 速览与快速启动 | +| [.env.example](./.env.example) | 中控环境变量模板 | diff --git a/manual_trading_hub/开仓计划说明.md b/manual_trading_hub/开仓计划说明.md new file mode 100644 index 0000000..9e46afb --- /dev/null +++ b/manual_trading_hub/开仓计划说明.md @@ -0,0 +1,85 @@ +# 开仓计划 + +中控顶栏 **开仓计划**(`/plan`)用于记录开仓前的计划,跟踪进行中条目,并在填写结果后归档;支持按币种,趋势周期,入场方案统计胜率. + +## 入口 + +- 顶栏:**资金概况** 与 **监控区** 之间 → **开仓计划** +- 路由:`/plan` + +## 页面结构 + +| 区域 | 功能 | +|------|------| +| 左侧 · 新建计划 | 填写计划字段,保存后进入「进行中」 | +| 左侧 · 进行中 | 修改,删除,填写结果并归档 | +| 右侧 · 计划历史 | 一行一条摘要,点击查看详情 | +| 右侧 · 数据统计 | 胜率表(可切换维度与时间范围) | + +## 字段说明 + +| 字段 | 说明 | +|------|------| +| 日期 | 计划日期(日期选择器,可手输 `YYYY-MM-DD`) | +| 交易所 | 三所:binance / okx / gate(来自 hub 已启用账户) | +| 币种 | 输入 `BTC` 或 `BTC/USDT`,自动规范为 `XXX/USDT` | +| 类型 | 趋势单 / 波段单 / 日内短线 | +| 趋势周期 | 5m / 15m / 30m / 1h / 4h / 1d | +| 入场周期 | 1m / 5m / 15m / 30m / 1h | +| 方向 | 多 / 空 | +| 目标位 | 文本 | +| 当前区间 | 文本 | +| 入场方案 | **仅进行中**填写:突破 / 假突破 / 箱体拐点(根据实际进场选择;归档前必选) | +| 结果 | **仅进行中**可填:盈 / 亏;**必选其一才归档** | +| 盈亏 | **可选**数字(U),不参与是否归档 | +| 备注 | 文本 | + +## 业务流程 + +1. **新建** → 状态 `active`(进行中),**不含入场方案** +2. **进行中** → 选择/修改 **入场方案**(根据实际进场填写),可改备注,价位等 +3. **删除** → 仅 **未填结果** 的进行中计划可删 +4. **归档** → 在进行中选择 **盈/亏** 并点「填写结果并归档」→ 状态 `archived`,移入计划历史 + +## 数据统计 + +- **默认**:全部历史 +- **时间**:全部 / 本周 / 本月 / 自选区间 +- **维度 Tab**:币种 | 趋势周期 | 入场方案 +- **胜率**:盈利 ÷ (盈利 + 亏损),仅统计已归档且结果=盈/亏 的计划 + +## API + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/entry-plans/meta` | 枚举项 + 交易所列表 | +| GET | `/api/entry-plans?status=active\|archived` | 列表 | +| GET | `/api/entry-plans/{id}` | 详情 | +| POST | `/api/entry-plans` | 新建 | +| PATCH | `/api/entry-plans/{id}` | 更新;写入 `result` 时自动归档 | +| DELETE | `/api/entry-plans/{id}` | 删除(仅 active) | +| GET | `/api/entry-plans/stats` | 统计;参数 `dimension`,`period`,`date_from`,`date_to` | + +## 存储 + +- SQLite:`manual_trading_hub/data/hub_entry_plans.db` +- 环境变量:`HUB_ENTRY_PLAN_DB_PATH`(可选自定义路径) + +## 部署 + +```bash +git pull +pm2 restart manual-trading-hub +``` + +浏览器访问 `/plan` 并 **Ctrl+F5** 强刷静态资源. + +## 相关代码 + +| 文件 | 说明 | +|------|------| +| `hub_entry_plan_lib.py` | 库表,CRUD,统计 | +| `manual_trading_hub/hub.py` | REST API | +| `manual_trading_hub/static/plan.js` | 前端逻辑 | +| `manual_trading_hub/static/index.html` | 页面 DOM | +| `tests/test_hub_entry_plan_lib.py` | 单元测试 | diff --git a/manual_trading_hub/数据看板说明.md b/manual_trading_hub/数据看板说明.md new file mode 100644 index 0000000..5f977a3 --- /dev/null +++ b/manual_trading_hub/数据看板说明.md @@ -0,0 +1,50 @@ +# 中控数据看板说明 + +入口:**`/dashboard`**(顶栏「数据看板」). + +## 能力 + +| 区块 | 说明 | +|------|------| +| **总览 KPI** | 交易日,平仓盈亏,笔数,浮盈亏,资金合计,实盘持仓 | +| **分户明细** | 三户资金/交易账户,今日盈亏,浮盈亏,备注;未启用显示「未监控」 | +| **平仓明细** | 当日平仓流水(合约,方向,结果,盈亏,时间) | +| **风险预警** | 单户单日平仓亏损 ≥ 资金合计 **5%** 时横幅 + 卡片高亮 | + +纯数据聚合,**不调用 AI**.交易日口径与实例一致(`TRADING_DAY_RESET_HOUR`,默认 8 点). + +## 刷新机制(SSE) + +与监控区 board 类似,采用 **后台聚合 + SSE 推送版本号**: + +1. `hub.py` 启动后 `dashboard_store` 每 **60s**(`DASHBOARD_POLL_INTERVAL_SEC`)聚合三户数据到内存快照. +2. 浏览器打开看板页后连接 `GET /api/dashboard/stream`(`event: dashboard`). +3. 收到新版本号后拉取 `GET /api/dashboard/daily` 快照并局部渲染,**无整页轮询闪烁**. +4. 监控区触发 board 刷新(全平,撤单等)时,会一并 `request_refresh` 看板,尽量与实盘同步. +5. 「立即刷新」→ `POST /api/dashboard/refresh` 触发下一轮聚合. + +可选环境变量:`HUB_DASHBOARD_SSE_HEARTBEAT_SEC`(默认 25,SSE 心跳间隔). + +## 主题与样式 + +- 跟随中控顶栏 **亮/暗主题**(`theme.js`),使用 `--panel` / `--border` / `--accent` 等变量. +- 卡片采用 **柔光阴影**(非霓虹渐变背景);亮色主题下为浅灰投影,暗色主题为轻微内高光. +- 盈亏仍用绿/红语义色,与全局一致. + +## API + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/dashboard/daily` | 当前交易日快照(含 `dashboard_version`) | +| GET | `/api/dashboard/stream` | SSE 版本推送 | +| POST | `/api/dashboard/refresh` | 请求立即重聚合 | + +`GET /api/ping` 含 `dashboard_version`,`dashboard_poll_interval_sec` 等字段. + +## 相关文件 + +- `hub_dashboard.py` — 聚合逻辑 +- `hub_dashboard_cache.py` — 后台轮询 + SSE +- `static/dashboard.js` / `dashboard.css` — 前端 + +部署后 `git pull` 并 `pm2 restart manual-trading-hub`. diff --git a/manual_trading_hub/本地数据迁移到云端.md b/manual_trading_hub/本地数据迁移到云端.md new file mode 100644 index 0000000..bd5b962 --- /dev/null +++ b/manual_trading_hub/本地数据迁移到云端.md @@ -0,0 +1,268 @@ +# 本地数据备份与迁移到云服务器 + +本文说明如何把 **本机** 上运行的 `crypto_monitor`(三实例 + 中控)的**业务数据**迁到 **云 VPS**,并正确改配置. +**不迁移** 本机 Python 虚拟环境(`.venv`),云上重新 `pip install` 即可. + +相关:[云服务器部署说明.md](./云服务器部署说明.md) · [部署文档.md](./部署文档.md) + +--- + +## 一,要迁什么,不迁什么 + +### 必须迁移(业务数据) + +| 路径(每个实例目录下) | 内容 | +|------------------------|------| +| `crypto.db`(或 `.env` 里 `DB_PATH` 指向的文件) | 监控单,关键位,交易记录,复盘,运行时开关等 **SQLite 全库** | +| `static/images/`(或 `UPLOAD_DIR`) | 上传图,复盘截图等 | +| `static/images/order_charts/`(或 `ORDER_CHART_DIR`) | 订单 K 线图(若开启) | + +三个实例 **各有一份独立库**: + +- `crypto_monitor_binance/crypto.db` +- `crypto_monitor_okx/crypto.db` +- `crypto_monitor_gate/crypto.db` +- `crypto_monitor_gate/crypto.db` + +### 中控额外迁移 + +| 路径 | 内容 | +|------|------| +| `manual_trading_hub/hub_settings.json` | 账户 URL,启用状态,能力勾选(网页「系统设置」保存的文件) | +| `manual_trading_hub/hub_ai_summaries.json` | 中控 AI 今日总结(`/ai`) | +| `manual_trading_hub/hub_ai_chat.json` | 中控 AI 聊天会话 | + +### 不要直接覆盖拷贝(需在云上重写) + +| 文件 | 说明 | +|------|------| +| 各目录 `.env` | 含 API 密钥:可在云上**手工新建**,从本机抄密钥,但须改 **`flask_url`,代理,公网相关项**(见下文) | +| `.venv/`,`__pycache__/` | 云上重建 | +| PM2 日志 | 无需迁 | + +### 可选 + +- 本机 `manual_trading_hub/.env` 里的 `HUB_BRIDGE_TOKEN`,`HUB_PASSWORD` 等:记下后在云上填入,**不要**把含密钥的 `.env` 发到公开网盘. + +--- + +## 二,迁移前准备(本地) + +### 1. 停服务(避免数据库半写入) + +```bash +# 本机:停中控与子代理 +cd manual_trading_hub +pm2 stop manual-trading-hub manual-agent-binance manual-agent-okx manual-agent-gate + +# 本机:停三个 Flask(进程名以你 pm2 list 为准) +pm2 stop crypto_okx crypto_binance crypto_gate +# 或各目录 ecosystem 里的名字 +``` + +未用 PM2 时,结束对应 Python/Flask 进程后再备份. + +### 2. 确认数据库文件位置 + +各实例目录下查看 `.env` 中 `DB_PATH`(默认 `crypto.db`).若存在 `crypto.db-wal`,`crypto.db-shm`,**必须先停服务** 再备份. + +--- + +## 三,本地备份(推荐用自带脚本) + +每个实例目录执行(会备份 **库 + static/images**): + +```bash +cd crypto_monitor_okx +bash scripts/backup_data.sh +# 默认输出到 /root/backups/crypto_monitor_okx/YYYY-MM-DD/ +# 本机可改环境变量:BACKUP_ROOT=~/crypto_backups bash scripts/backup_data.sh +``` + +对 `crypto_monitor_binance`,`crypto_monitor_gate`,`crypto_monitor_gate` **各执行一次**. + +脚本产物示例: + +```text +~/crypto_backups/crypto_monitor_okx/2026-05-21/ + crypto.db + static_images.tar.gz + manifest.txt +``` + +### 手工打包(不用脚本时) + +在仓库根目录示例: + +```bash +BACKUP=~/crypto_migrate_$(date +%Y%m%d) +mkdir -p "$BACKUP" + +for dir in crypto_monitor_okx crypto_monitor_binance crypto_monitor_gate crypto_monitor_gate; do + tar -czf "$BACKUP/${dir}.tar.gz" \ + -C "$dir" crypto.db static/images 2>/dev/null || \ + tar -czf "$BACKUP/${dir}.tar.gz" -C "$dir" crypto.db +done + +cp manual_trading_hub/hub_settings.json "$BACKUP/" 2>/dev/null || true +cp manual_trading_hub/hub_ai_summaries.json "$BACKUP/" 2>/dev/null || true +cp manual_trading_hub/hub_ai_chat.json "$BACKUP/" 2>/dev/null || true +``` + +--- + +## 四,上传到云服务器 + +在**你电脑**上(把 `USER`,`云IP` 换成实际值): + +```bash +# 打包整个备份目录 +tar -czf crypto_migrate.tar.gz -C ~ crypto_backups # 或你的 BACKUP 路径 + +scp crypto_migrate.tar.gz USER@云IP:/tmp/ +scp manual_trading_hub/hub_settings.json USER@云IP:/tmp/ # 若单独备份 +``` + +大文件可用 **rsync**(支持断点续传): + +```bash +rsync -avz --progress ~/crypto_backups/ USER@云IP:/tmp/crypto_backups/ +``` + +--- + +## 五,云上恢复数据 + +假设代码已在 `/opt/crypto_monitor_user`(`git clone` 或 `rsync` 代码均可,**代码与数据分开**). + +```bash +ssh USER@云IP +cd /opt/crypto_monitor_user + +# 解压(若用 scp 单包) +tar -xzf /tmp/crypto_migrate.tar.gz -C /tmp + +# 按实例恢复(示例:OKX) +pm2 stop crypto_okx 2>/dev/null || true +cp /tmp/crypto_backups/crypto_monitor_okx/2026-05-21/crypto.db crypto_monitor_okx/crypto.db +tar -xzf /tmp/crypto_backups/crypto_monitor_okx/2026-05-21/static_images.tar.gz -C crypto_monitor_okx/ +# 若 tar 里是 static/images 目录结构,确认解压后路径为 crypto_monitor_okx/static/images + +# 对其余三所重复同样步骤 +``` + +恢复中控设置: + +```bash +cp /tmp/hub_settings.json manual_trading_hub/hub_settings.json +# 或解压备份里带的 hub_settings.json +``` + +**权限**(避免 Flask 写库失败): + +```bash +sudo chown -R 运行用户:运行用户 /opt/crypto_monitor_user/crypto_monitor_*/crypto.db +sudo chown -R 运行用户:运行用户 /opt/crypto_monitor_user/crypto_monitor_*/static/images +``` + +--- + +## 六,云上必须改的配置(比迁移本身更重要) + +数据文件原样拷过去不够,**.env 与 hub_settings 要按云环境改**. + +### 1. 各实例 `crypto_monitor_*/.env` + +从本机**抄写** API 密钥等,并调整: + +| 项 | 本地常见 | 云上建议 | +|----|----------|----------| +| `OKX_SOCKS_PROXY` 等 | `socks5h://127.0.0.1:1080` | **留空**(直连),除非云上仍访问不了交易所 | +| `APP_AUTH_DISABLED` | 可能为 true(本机) | **false** 或未设置 | +| `APP_USERNAME` / `APP_PASSWORD` | 可有 | 设统一强密码(直链登录) | +| `HUB_BRIDGE_TOKEN` | 有 | 与中控 **完全一致** | + +### 2. `manual_trading_hub/.env` + +见 [云服务器部署说明.md](./云服务器部署说明.md):`HUB_PASSWORD`,`HUB_BRIDGE_TOKEN`,`HUB_COOKIE_SECURE=true` 等. + +### 3. `hub_settings.json` 里的 URL + +**必须**改成浏览器能打开的地址: + +| 字段 | 云上 | +|------|------| +| `flask_url` | `https://okx.你的域名.com`(每实例不同子域) | +| `agent_url` | `http://127.0.0.1:15201`(保持本机,勿写公网 IP) | + +本机若是 `http://192.168.x.x:5004` 或 `http://127.0.0.1:5004`,上云后**一定要改**,否则「打开实例」会指错地址. + +--- + +## 七,云上启动与验收 + +```bash +# 依赖(各目录 venv + manual_trading_hub) +# 见 云服务器部署说明.md,部署文档.md + +cd /opt/crypto_monitor_user +# 先三实例 Flask,再 manual_trading_hub ecosystem +pm2 start ... +pm2 save +``` + +验收: + +- [ ] 各实例网页能登录,**交易记录 / 关键位 / 监控单** 与本地一致 +- [ ] 复盘图片能显示(`static/images` 路径正确) +- [ ] 中控监控卡片能读到持仓;`hub_settings` 账户 URL 正确 +- [ ] 本机已 **停止** 或不再用同一 API Key 同时跑两套(避免重复下单) + +--- + +## 八,迁移策略建议 + +### 方案 A:一次性切换(简单) + +1. 本地停 PM2 → 备份 → 上传 → 云上恢复 → 改配置 → 只跑云端. +2. 适合能接受 **短暂停机**(几十分钟). + +### 方案 B:先云后停本地(稳一点) + +1. 云上先部署代码,空库跑通; +2. 临近切换时再备份本地**最新**库覆盖云上; +3. 切换时刻停本地,启云上. +4. 减少「备份到上线」之间的数据空窗. + +### 注意 + +- **同一交易所 API Key 不要本地和云上同时自动交易**,以免重复挂单. +- 迁移后第一次在云上打开,建议先看监控单,持仓是否与预期一致,再放开自动逻辑. + +--- + +## 九,常见问题 + +**Q:只拷 `crypto.db` 不够吗?** +- 复盘,上传相关功能还依赖 `static/images`;建议库 + 图片一起迁. + +**Q:迁移后 OKX 监控单没了?** +- 查是否拷错目录(三所各一个库),或恢复后用了空库路径(`DB_PATH` 不一致). + +**Q:图片 404?** +- 检查 `static/images` 是否解压到实例目录下;数据库里路径若为相对路径,一般与目录结构一致即可. + +**Q:本地还用 SOCKS,云上要不要?** +- 云上通常 **不需要** SSH 隧道;见 [云服务器部署说明.md](./云服务器部署说明.md) 与此前说明:直连稳定后去掉 `*_SOCKS_PROXY`. + +--- + +## 十,相关脚本 + +各实例目录: + +```bash +bash scripts/backup_data.sh +``` + +环境变量:`BACKUP_ROOT`,`BACKUP_RETENTION_DAYS`,`BACKUP_INSTANCE`(见脚本内注释). diff --git a/manual_trading_hub/行情区说明.md b/manual_trading_hub/行情区说明.md new file mode 100644 index 0000000..f945ae3 --- /dev/null +++ b/manual_trading_hub/行情区说明.md @@ -0,0 +1,130 @@ +# 行情区(K 线)说明 + +中控 **行情区** `/market` 提供多交易所 K 线查看:按需拉取,本地 SQLite 缓存,可选技术指标与持仓价格线.数据经各实例 Flask 的 `/api/hub/ohlcv`(底层 `hub_ohlcv_lib` + ccxt)获取. + +相关代码:`manual_trading_hub/static/chart.js`,`hub_kline_store.py`(仓库根目录),`hub.py` 的 `/api/chart/*`. + +--- + +## 1. 入口与导航 + +| 方式 | 说明 | +|------|------| +| 顶栏 **行情区** | 打开 `/market` | +| 监控区持仓 | 点击合约名(**打开行情区**)→ 跳转 `/market?exchange_key=...&symbol=...`,并带入入场/止损/止盈等标记(`sessionStorage`) | +| 全屏工具条 | K 线全屏时可在顶部切换交易所,币种,周期并 **加载** | + +--- + +## 2. 支持的周期 + +下拉框与后端 `CHART_TIMEFRAMES` 一致: + +| 周期 | 数字快捷键(分钟) | +|------|-------------------| +| 1m | `1`(稍停或 Enter 确认;连按 `1`→`5` 为 15m) | +| 5m | `5` | +| 15m | `15` | +| 1h | `60` | +| 2h | `120` | +| 4h | `240` | +| 12h | `720` | +| 1d | `1440` | +| 1w | `10080` | + +- 快捷键仅在行情页,且焦点不在输入框/下拉框时生效. +- **全屏**:按 **`F`** 切换;全屏时 **`Esc`** 退出. +- 无效或已移除的周期(如 URL 带 `6h`)会回退为默认 **5m**. + +--- + +## 3. 数据拉取与本地库 + +| 项 | 说明 | +|------|------| +| **策略** | 先读本地库,不足或过期则向对应实例拉取并写入库;Hub **后台轮询** 增量更新尾部 K 线 | +| **库文件** | 默认 `manual_trading_hub/data/hub_kline.db`(不纳入 Git) | +| **保留** | 默认 **15 天**(`HUB_KLINE_RETENTION_DAYS`),每次请求顺带清理更早数据 | +| **根数** | 日内周期约 **1000** 根;`1d` / `1w` 约 **500** 根 | +| **刷新** | Hub 约 **5 秒** 轮询:① 监控区**有持仓**的合约(默认周期 `5m`)② 行情页 **watch** 的交易所+币种+周期(页面打开时每 25s 续期).浏览器经 **SSE** 收 `chart_version` 后拉 `/api/chart/ohlcv`.**加载** 读库;**强制刷新** 全量重拉 | +| **分页** | OKX/Gate 等单次常限 ~300 根,中控会自动分页补全 | +| **12h** | 若交易所无原生 12h 或 K 线间隔异常,会从 **1h** 聚合生成 | + +环境变量(`manual_trading_hub/.env`): + +```bash +# HUB_KLINE_RETENTION_DAYS=15 +# HUB_KLINE_DB_PATH=/opt/crypto_monitor_user/manual_trading_hub/data/hub_kline.db +# HUB_CHART_POLL_INTERVAL=5 +# HUB_CHART_POSITION_TIMEFRAME=5m +# HUB_CHART_WATCH_TTL_SEC=45 +``` + +--- + +## 4. 图表功能 + +- **主图**:K 线 + 成交量(Lightweight Charts). +- **价格轴**:「自动」切换是否跟随最新价缩放. +- **技术指标**(可选勾选):EMA 21/55,MACD,RSI(含 30/70 参考线);副图自上而下为 MACD,RSI. +- **持仓标记**(从监控跳转时):展示入场,止损,止盈,张数,**浮盈亏**(约 5 秒随监控快照刷新),委托摘要;K 线上绘制对应价格线.趋势回调若止盈为程序监控,止盈栏显示「程序监控」且不与止损同价误显. +- **趋势保本移交**:移交到下单监控后,持仓卡止盈/止损与「交易所止盈止损」与实例 **下单监控** 计划价一致(不再清空为程序监控占位);交易所仅市价只减仓单时也会按价格推断展示. +- **拖动止损线**:鼠标靠近红色止损线(⟷)可上下拖动;松手确认后调用与监控区相同的 **挂止盈/止损** API(先撤全部条件单再挂新止损+止盈).须已有有效止盈价(交易所条件单或计划止盈);仅改止损,不改止盈时止盈价沿用当前上下文. +- **背离**:MACD/RSI 与价格简易背离标注(箭头 + 图例说明). + +--- + +## 5. HTTP API(中控) + +须登录(与监控区相同,`/api/ping` 等白名单除外). + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/chart/meta` | 已启用交易所列表,周期列表,各周期 limit,保留天数 | +| GET | `/api/chart/ohlcv` | 查询参数:`exchange_key`,`symbol`,`timeframe`,可选 `refresh=1` 强制刷新 | +| POST | `/api/chart/watch` | 行情页订阅(JSON:`exchange_key`,`symbol`,`timeframe`),45s 内需续期 | +| POST | `/api/chart/unwatch` | 离开行情页取消订阅 | +| GET | `/api/chart/stream` | SSE:`event: chart`,含 `chart_version` 与各 `series` 版本 | +| GET | `/api/chart/poll/meta` | 当前轮询状态与各 series 版本 | + +实例侧(中控转发): + +| 路径 | 说明 | +|------|------| +| GET | `/api/hub/ohlcv` | 各 `crypto_monitor_*` 经 `hub_bridge` 注册;参数 `symbol`,`timeframe`,`since_ms`,`limit` | + +--- + +## 6. 部署与升级注意 + +1. **hub** 与 **三实例 Flask** 均需 `git pull` 到含 `hub_ohlcv_lib.py`,`hub_kline_store.py` 的版本. +2. 重启:`pm2 restart manual-trading-hub` 及 `crypto_binance`,`crypto_okx`,`crypto_gate`,``(名称以你环境为准). +3. 浏览器 **强刷**(`chart.js` 带版本 query,避免旧前端缓存). +4. 周期或拉取逻辑升级后,对异常图表点一次 **强制刷新**,必要时可删 `data/hub_kline.db` 后重拉(会丢失本地缓存,不影响策略库). + +回滚标签说明见 [SNAPSHOT_ROLLBACK.md](./SNAPSHOT_ROLLBACK.md). + +--- + +## 7. 常见问题 + +| 现象 | 处理 | +|------|------| +| 只显示约 300 根 | `git pull` 实例与 hub,强制刷新;确认 `hub_ohlcv_lib` 已含分页逻辑 | +| 12h 错乱或过少 | 强制刷新;Gate 等无原生 12h 时依赖 1h 聚合,需实例 OHLCV 正常 | +| 周期下拉无某项 | 以当前 `CHART_TIMEFRAMES` 为准;已移除 3m/10m/20m/30m/6h/8h 等 | +| 快捷键无效 | 确认在行情页;全屏用 **F**;数字键勿在币种输入框内按 | +| 持仓线不显示 | 须从监控区点击合约进入;或清除标记后重新跳转 | + +更多中控共性问题见 [常见问题.md](./常见问题.md). + +--- + +## 8. 文档索引 + +| 文档 | 内容 | +|------|------| +| [使用说明.md](./使用说明.md) | 中控总览(含行情区摘要) | +| [行情区说明.md](./行情区说明.md) | 本文 | +| [部署文档.md](./部署文档.md) | PM2 / 反代 / 验收 | +| [.env.example](./.env.example) | `HUB_KLINE_*` 等变量 | diff --git a/manual_trading_hub/资金概况说明.md b/manual_trading_hub/资金概况说明.md new file mode 100644 index 0000000..aad7a0f --- /dev/null +++ b/manual_trading_hub/资金概况说明.md @@ -0,0 +1,94 @@ +# 资金概况 — 使用说明 + +中控顶栏 **资金概况**(`/funds`)汇总三所账户的 **资金账户 + 交易账户** 余额,不含浮盈亏;未监控账户不参与合计,但仍会在分户列表中灰显展示. + +--- + +## 1. 口径 + +| 项目 | 规则 | +|------|------| +| **单户总资金** | `资金账户 USDT + 交易账户 USDT` | +| **总资金** | 所有 **已启用且未被环境强制关闭** 的账户之和 | +| **未监控** | 设置页未勾选「启用」或 `HUB_DISABLED_IDS` 强制关闭 → **跳过合计** | +| **缺数据** | 资金户,交易户任一侧缺失 → 该户当日快照 **跳过**(不估,不补 0) | +| **交易日** | 北京时间 `TRADING_DAY_RESET_HOUR`(默认 **8:00**)切日,与三所统计一致 | +| **曲线粒度** | 每个交易日 **1 个点** | +| **统计起点** | 默认 **2026-06-09**(`HUB_FUND_HISTORY_START_DAY`);此前不记,不展示 | +| **历史保留** | 自起点起最多 **180** 个交易日(`HUB_FUND_HISTORY_DAYS`) | +| **最大回撤** | 基于 **总资金曲线**(分户同理),峰值到谷底的最大跌幅(U 与 %) | + +> 与实例统计页「最大回撤」不同:实例统计来自 **平仓盈亏累计**;资金概况来自 **账户余额曲线**. + +--- + +## 2. 页面说明 + +### 总览 + +- **总资金**:当前监控板最新一轮聚合的实时合计(资金户+交易户齐全才计入) +- **累计盈亏**:相对统计起点(`HUB_FUND_HISTORY_START_DAY`)首个快照的总资金变动(U / %);顶栏大字绿涨红跌,一眼可看盈亏。含出入金影响,口径同权益曲线,不含浮盈 +- **较昨日**:相对上一交易日快照点的变动(U) +- **最大回撤**:总资金历史曲线的峰值回撤(U / %) +- **总资金曲线**:近 180 交易日 + +### 分户卡片 + +每户展示:总资金,资金户,交易户,最大回撤,迷你曲线. + +- **已监控**:正常统计 +- **未监控**:显示「未参与合计」,无曲线 +- **余额未齐**:已监控但 API 未返回完整资金/交易户 + +--- + +## 3. 数据从哪来 + +``` +监控板每 5 秒聚合(board_store) + └→ 各实例 GET /api/hub/account + funding_usdt / trading_usdt + └→ 写入 hub_fund_history.json(按交易日去重更新当日) + +资金概况页 GET /api/hub/fund-overview + ├→ 实时:读 board 缓存 + └→ 曲线/回撤:读 hub_fund_history.json +``` + +- 存储文件:`manual_trading_hub/hub_fund_history.json`(不在 Git 中) +- 旧 AI 快照 `hub_ai_fund_history.json` 会在首次读取时 **自动合并** 到新文件 +- AI 教练生成上下文时也会写入同日快照(与监控板共用逻辑) + +--- + +## 4. 环境变量 + +| 变量 | 默认 | 说明 | +|------|------|------| +| `HUB_FUND_HISTORY_DAYS` | `180` | 资金快照保留交易日数(与起点取较晚边界) | +| `HUB_FUND_HISTORY_START_DAY` | `2026-06-09` | 曲线/回撤统计起始交易日 | +| `TRADING_DAY_RESET_HOUR` | `8` | 切日整点(北京),与三所 `.env` 建议一致 | +| `HUB_BOARD_POLL_INTERVAL` | `5` | 监控聚合间隔(秒),影响快照刷新频率 | + +--- + +## 5. API + +`GET /api/hub/fund-overview`(需中控登录,与监控区相同) + +返回字段概要: + +- `totals.total_usdt` — 当前总资金 +- `totals.day_delta_usdt` — 较昨日变动 +- `totals.period_delta_usdt` / `period_delta_pct` / `start_usdt` — 相对曲线起点累计盈亏 +- `totals.series[]` — `{ day, total_usdt }` 总曲线 +- `totals.drawdown` — `{ peak_usdt, max_drawdown_u, max_drawdown_pct }` +- `accounts[]` — 分户实时余额,曲线,回撤,日/累计变动,`monitored` 标记 + +--- + +## 6. 相关文档 + +- [使用说明.md](./使用说明.md) — 中控总览 +- [AI教练说明.md](./AI教练说明.md) — AI 上下文中的资金快照文本 +- [部署文档.md](./部署文档.md) — 重启 `manual-trading-hub` 后生效 diff --git a/manual_trading_hub/部署文档.md b/manual_trading_hub/部署文档.md new file mode 100644 index 0000000..ee004fa --- /dev/null +++ b/manual_trading_hub/部署文档.md @@ -0,0 +1,336 @@ +# 多账户交易中控 — 部署文档(含 PM2) + +本文档说明在 **Ubuntu / Linux** 上部署 **manual_trading_hub**(复盘系统中控:监控区,系统设置,登录保护)的推荐步骤. + +- 功能与界面:[使用说明.md](./使用说明.md) +- **云服务器(VPS)完整配置**:[云服务器部署说明.md](./云服务器部署说明.md) +- **本地备份并迁到云上**:[本地数据迁移到云端.md](./本地数据迁移到云端.md) +- **局域网 IP:端口 / 反代域名,中控打开实例免登录**:[局域网与反代部署说明.md](./局域网与反代部署说明.md) +- 故障实录:[常见问题.md](./常见问题.md) +- 环境变量模板:[.env.example](./.env.example) + +--- + +## 一,部署目标 + +| 组件 | 作用 | 默认监听 | +|------|------|----------| +| **hub.py** | 中控 Web + API | `0.0.0.0:5100` | +| **agent.py × N** | 各账户持仓 / 紧急全平 | `127.0.0.1:15200`~`15202` | +| **crypto_monitor_*.app** | 策略,关键位,下单逻辑 | 各目录 `.env` 的 `APP_PORT` | + +- 账户列表与 URL 由 **`hub_settings.json`**(网页「系统设置」保存)或内置默认维护;**不再使用** `HUB_AGENTS`. +- 三实例 Flask **无需为中控改业务代码**(已注册 `hub_bridge`);与中控并行运行. + +--- + +## 二,前置条件 + +1. **Python 3.10+**,`python3-venv`,`pip`. +2. **Node.js + npm**(用于安装 PM2):`sudo npm i -g pm2`. +3. 各 `crypto_monitor_*` 目录已 **`cp .env.example .env`** 并填好 API 密钥. +4. 端口无冲突:`5100`,`15200`~`15202`,各实例 `APP_PORT`(5000/5001/5004). +5. 建议代码路径:`/opt/crypto_monitor_user/`(下文用此示例,请按实际路径替换). + +--- + +## 三,安装中控依赖 + +```bash +cd /opt/crypto_monitor_user/manual_trading_hub +python3 -m venv .venv +source .venv/bin/activate # 激活 venv(当前终端后续 pip/python 走虚拟环境) +pip install -r requirements.txt +cp .env.example .env +# 编辑 .env:HUB_PORT,HUB_DISABLED_IDS,公网时 HUB_BRIDGE_TOKEN 等 +chmod +x scripts/run_hub.sh scripts/run_agent.sh scripts/pm2_hub.sh scripts/pm2_agents.sh +chmod +x scripts/fix_hub_deps.sh scripts/verify_hub_deploy.sh scripts/fix_env_crlf.sh +deactivate # 可选;交给 PM2 时不必保持激活 +``` + +**依赖说明**:`requirements.txt` 含 **`python-multipart`**(FastAPI 表单解析;旧版若保留下单接口时需要),**`psutil`**(监控区服务器状态条).当前中控**已移除下单区**,但仍建议安装完整依赖. + +说明: + +- **安装依赖**:必须在 **`manual_trading_hub/.venv`** 内执行,勿对系统 Python 直接 `pip install`(Debian/Ubuntu 会报 `externally-managed-environment`).`source .venv/bin/activate` 后用 `pip install` 即可;不写 `activate` 时也可用 **`.venv/bin/pip install -r requirements.txt`**(效果相同).快捷:`bash scripts/fix_hub_deps.sh`. +- **PM2 启动**:由 `scripts/run_hub.sh` 固定调用 **`.venv/bin/python`**,**不依赖**你是否在 shell 里激活过 venv. +- **监控磁盘**:可选环境变量 **`HUB_HOST_DISK_PATH`**(如 `/opt/crypto_monitor_user`),未设时 Linux 为 `/`,Windows 为系统盘. + +子代理也使用 **本目录 `.venv`** 里的 Python(与各策略 Flask 的 venv 可分开). + +--- + +## 四,推荐启动顺序 + +``` +1. 各实例 Flask(APP_PORT) ← 各 crypto_monitor_* 目录 ecosystem.config.cjs +2. 中控 + 子代理(5100 + 15200~15202) ← 本目录一条 PM2 命令同时启动 +``` + +**`ecosystem.config.cjs` 会一次拉起 3 个 agent + 1 个 hub**,无需再单独 `pm2 start` 子代理. + +仅反代中控到公网时:Flask / agent 仍只监听 **127.0.0.1**;系统设置里 URL 填 `http://127.0.0.1:端口`. + +--- + +## 五,PM2 托管(hub + agent 一起启动,推荐) + +### 5.1 一条命令启动全部 + +| 文件 | 包含进程 | +|------|----------| +| `ecosystem.config.cjs` | `manual-agent-binance` / `okx` / `gate` + **`manual-trading-hub`** | + +`run_hub.sh` 加载 **`manual_trading_hub/.env`** 后执行 `hub.py`;各 agent 经 **`run_agent.sh`** 在对应策略目录加载 **`.env`**(含 API 密钥),再执行 `agent.py`. + +```bash +cd /opt/crypto_monitor_user/manual_trading_hub +source .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env + +pm2 start ecosystem.config.cjs # 4 个进程一起起 +pm2 save + +# 或 +bash scripts/pm2_hub.sh start +``` + +### 5.2 PM2 进程一览 + +| 进程名 | 工作目录 | 端口/说明 | +|--------|----------|-----------| +| manual-agent-binance | crypto_monitor_binance | agent `15200` | +| manual-agent-okx | crypto_monitor_okx | agent `15201` | +| manual-agent-gate | crypto_monitor_gate | agent `15202` | +| manual-trading-hub | manual_trading_hub | hub `5100` | + +OKX 子代理会启动;不用 OKX 可 `pm2 stop manual-agent-okx`. + +### 5.3 常用运维命令 + +```bash +pm2 status +pm2 logs manual-trading-hub --lines 200 +pm2 restart ecosystem.config.cjs # 重启 hub + 全部 agent + +bash scripts/pm2_hub.sh restart # 同上 +bash scripts/pm2_hub.sh stop +bash scripts/pm2_hub.sh logs +``` + +仅重启中控,不动 agent: + +```bash +pm2 restart manual-trading-hub +``` + +仅重启子代理: + +```bash +pm2 restart manual-agent-binance manual-agent-gate manual-agent-okx +# 或 +bash scripts/pm2_agents.sh restart +``` + +### 5.4 开机自启 + +```bash +pm2 save +pm2 startup +# 按终端提示执行一行 sudo 命令后,再 pm2 save +``` + +### 5.5 与各实例 Flask 一起查看 + +```bash +pm2 status +# 示例同时存在: +# manual-trading-hub,manual-agent-* +# crypto_binance / crypto_gate …(各策略目录自有 ecosystem.config.cjs) +``` + +### 5.6 Gate 子代理「一会能连,一会子代理不可用」(Windows `.env` 换行) + +**现象**:Gate 卡片红字「子代理不可用」;`pm2 logs manual-agent-gate` 反复出现: + +```text +./.env: line 22: $'\r': command not found +agent start: exchange=gate port=15202 ... +``` + +**原因**:在 Windows 编辑的 `crypto_monitor_gate/.env` 为 **CRLF**,Linux 上 `source` 失败;PM2 反复重启,中控轮询时偶发连不上(**不是外网问题**). + +**处理**(在服务器仓库根执行): + +```bash +cd /opt/crypto_monitor_user +sed -i 's/\r$//' crypto_monitor_gate/.env +bash manual_trading_hub/scripts/fix_env_crlf.sh +cd manual_trading_hub +pm2 delete manual-agent-gate 2>/dev/null || true +pm2 start ecosystem.config.cjs --only manual-agent-gate +pm2 save +curl -s http://127.0.0.1:15202/status | head -c 200 # 应 ok:true +``` + +**预防**:`.env` 保存为 **LF**(勿在 Windows 记事本直接保存 CRLF).子代理须经 **`scripts/run_agent.sh`** 启动(内置去 CRLF 的 `load_dotenv_file`),勿裸跑 `python agent.py`. + +详见 [常见问题.md](./常见问题.md) **§3.1**,**§3.3**. + +--- + +## 六,手动启动(不用 PM2 时) + +需**分别**起 agent 与 hub(与 PM2 合并启动不同): + +```bash +# 子代理:由 ecosystem.config.cjs 经 run_agent.sh 启动(勿用手动多终端) +# 中控: +cd /opt/crypto_monitor_user/manual_trading_hub +bash scripts/run_hub.sh +``` + +--- + +## 七,浏览器验收 + +1. **http://127.0.0.1:5100/login** — 若 `.env` 已设 `HUB_PASSWORD`,用 `HUB_USERNAME` / `HUB_PASSWORD` 登录. +2. **http://127.0.0.1:5100/monitor** — 已启用账户显示持仓;Flask 已起时有关键位/趋势信息. +3. **http://127.0.0.1:5100/market** — 行情区可选交易所与周期拉 K 线;升级后强刷浏览器,详见 [行情区说明.md](./行情区说明.md). +4. **http://127.0.0.1:5100/ai** — AI 教练(三户今日总结 + 聊天);`manual_trading_hub/.env` 配与三实例相同的 `AI_*` 变量,见 [AI教练说明.md](./AI教练说明.md). +5. **http://127.0.0.1:5100/settings** — 保存后生成 `hub_settings.json`(增加第五户,Gate 子账户等见 [使用说明.md §4.5](./使用说明.md#45-增加账户例如再挂一个-gate)). +5. 监控卡片 **「实例」** — 在各 `crypto_monitor_*` 网页做下单,关键位,趋势;中控**不提供**下单表单. + +**命令行验收**(推荐): + +```bash +cd /opt/crypto_monitor_user/manual_trading_hub +bash scripts/verify_hub_deploy.sh +``` + +应看到:`OK: 无 api_trade_key`,`HTTP 200`,JSON 含 `"build":"20260521-no-trade-ui"`. + +```bash +curl -s http://127.0.0.1:5100/api/ping +curl -s http://127.0.0.1:15200/status | head -c 200 +``` + +--- + +## 八,仅反代中控到公网(实例不反代) + +1. Nginx/Caddy 反代到 **`127.0.0.1:5100`**,配置 **HTTPS**. +2. 反代需传递(登录 Cookie 正确识别 HTTPS): + ```nginx + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + ``` +3. `manual_trading_hub/.env` **务必设置**: + ```env + HUB_USERNAME=你的用户名 + HUB_PASSWORD=你的强密码 + HUB_SESSION_SECRET=随机长串 + HUB_COOKIE_SECURE=true + ``` +4. `hub_settings.json` 中 Flask/Agent 保持 **`http://127.0.0.1:...`**(中控本机调 API). +5. 三实例 **`APP_AUTH_DISABLED=false`** + 与中控相同 **`HUB_BRIDGE_TOKEN`**. +6. 子代理 **`HOST=127.0.0.1`**;防火墙勿对公网开放 `15200`~`15202`,各 `APP_PORT`. +7. **复盘/实例外链**:`HUB_PUBLIC_ORIGIN=https://你的域名` 或内网 IP;否则其它设备点「复盘」会跳到 `127.0.0.1`. + +**说明**:HTTPS 域名与 HTTP `内网IP:5100` Cookie **不共用**;内网访问 IP 需在 IP 地址再登录一次(见 [常见问题.md](./常见问题.md) §2.1). + +--- + +## 九,环境变量(中控 `.env`) + +| 变量 | 默认 | 说明 | +|------|------|------| +| `HUB_HOST` | `0.0.0.0` | 监听地址 | +| `HUB_PORT` | `5100` | 端口 | +| `HUB_DISABLED_IDS` | `1` | 强制关闭的账户 id(OKX) | +| `HUB_TRUST_LAN` | `true` | 私网可访问;仅本机可 `false` | +| `HUB_PUBLIC_ORIGIN` | 空 | 浏览器用复盘链接;如 `http://192.168.1.100`(**内网其它电脑访问中控时建议设置**) | +| `HUB_BRIDGE_TOKEN` | 空 | 与三实例一致;公网建议配置 | +| `HUB_USERNAME` | `admin` | Web 登录用户名 | +| `HUB_PASSWORD` | 空 | 非空即启用登录 | +| `HUB_SESSION_SECRET` | — | 会话签名 | +| `HUB_COOKIE_SECURE` | `false` | HTTPS 反代建议 `true` | +| `HUB_SESSION_DAYS` | `7` | 登录保持天数 | + +本地联调,实例 `APP_AUTH_DISABLED=true` 时可不配 `HUB_BRIDGE_TOKEN`;本机不设 `HUB_PASSWORD` 则无需登录页. + +--- + +## 十,升级与回滚 + +```bash +cd /opt/crypto_monitor_user +git pull + +cd manual_trading_hub +bash scripts/fix_hub_deps.sh +bash scripts/verify_hub_deploy.sh + +pm2 restart ecosystem.config.cjs +# 若只改了中控:pm2 restart manual-trading-hub +``` + +- **`hub_settings.json`**,**`hub_ai_summaries.json`**,**`hub_ai_chat.json`**,**`.env`** 不在 Git 中,`git pull` 不会覆盖. +- 升级前可备份:`cp hub_settings.json hub_settings.json.bak`,`cp hub_ai_*.json hub_ai_backup/`,`cp .env .env.bak`. + +**升级后自检**:`curl -s http://127.0.0.1:5100/api/ping` 须含 `"trade_ui":false`.若仍见 `api_trade_key` 报错,说明代码未更新或未重启,见 [常见问题.md](./常见问题.md) §1. + +--- + +## 十一,故障排查(速查) + +**完整实录**见 **[常见问题.md](./常见问题.md)**. + +| 现象 | 处理 | +|------|------| +| PM2 启动后立刻退出 | `pm2 logs manual-trading-hub`;检查 `.venv`,`.env`,`run_hub.sh` | +| `api_trade_key` / `python-multipart` | `git pull` → `bash scripts/fix_hub_deps.sh` → `verify_hub_deploy.sh` → 重启 hub | +| `verify` ping 解析失败 | hub 未起:`pm2 restart manual-trading-hub` | +| 余额显示 — | agent 未加载 `.env`;`fix_env_crlf.sh`;`run_agent.sh` | +| agent `$'\r': command not found` | `bash scripts/fix_env_crlf.sh` | +| 监控无持仓 | `curl http://127.0.0.1:15200/status` | +| 无关键位 / 401 | 启动 Flask;核对 `HUB_BRIDGE_TOKEN` / `hub_bridge` | +| 域名能登,IP 不能登 | 见常见问题 §2.1(Cookie / HTTP vs HTTPS) | +| 公网访问中控 403 | 反代到 `127.0.0.1:5100`,勿公网直连 5100 | +| 改 `.env` 不生效 | `pm2 restart manual-trading-hub` | + +--- + +## 十二,进程托管说明 + +中控与子代理 **仅使用 PM2**(`ecosystem.config.cjs`).勿再用 screen / systemd / nohup 启动 `hub.py` 或 `agent.py`,以免端口冲突. +环境要求见 **[docs/ubuntu-server.md](../docs/ubuntu-server.md)**. + +--- + +## 十三,安全清单 + +- [ ] 公网仅暴露反代端口,不暴露 Flask/agent 端口 +- [ ] 已设 `HUB_USERNAME` + `HUB_PASSWORD`(中控 Web 登录) +- [ ] HTTPS 反代已设 `HUB_COOKIE_SECURE=true` 且传递 `X-Forwarded-Proto` +- [ ] 公网已配置 `HUB_BRIDGE_TOKEN` + 实例关闭 `APP_AUTH_DISABLED` +- [ ] API Key 最小权限;交易所 IP 白名单 +- [ ] 已告知操作人员「全局全平」不可撤销;中控**不在网页下单** + +--- + +## 十四,文档索引 + +| 文档 | 内容 | +|------|------| +| [使用说明.md](./使用说明.md) | 功能,页面,API,环境变量 | +| [常见问题.md](./常见问题.md) | 故障实录 | +| [README.md](./README.md) | 速览 | +| [.env.example](./.env.example) | 环境变量模板 | +| [scripts/后台运行-Ubuntu.md](./scripts/后台运行-Ubuntu.md) | PM2 常驻(唯一推荐) | +| [docs/ubuntu-server.md](../docs/ubuntu-server.md) | Ubuntu / Python / Node / PM2 | +| `scripts/fix_hub_deps.sh` | 安装依赖 | +| `scripts/verify_hub_deploy.sh` | 部署验收 | +| `scripts/fix_env_crlf.sh` | 修复 .env 换行 | diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c88f68a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +# crypto_monitor 三个 Flask 子项目共用依赖(Binance / Gate / OKX) +# 安装:在各子目录 venv 内执行 pip install -r ../requirements.txt +# 共用 Python 库位于 ../lib/,启动时需将仓库根加入 PYTHONPATH(各 app.py / PM2 已配置) +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/scripts/align_okx_to_binance.py b/scripts/align_okx_to_binance.py new file mode 100644 index 0000000..3231fa4 --- /dev/null +++ b/scripts/align_okx_to_binance.py @@ -0,0 +1,566 @@ +#!/usr/bin/env python3 +"""One-shot: align crypto_monitor_okx with binance/gate patterns (OKX_* prefixes).""" +from __future__ import annotations + +import re +import shutil +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +OKX = ROOT / "crypto_monitor_okx" +BIN = ROOT / "crypto_monitor_binance" +GATE = ROOT / "crypto_monitor_gate" + + +def patch_app(): + app_path = OKX / "app.py" + text = app_path.read_text(encoding="utf-8") + + if "EXCHANGE_DISPLAY_NAME" not in text.split("OKX_POS_MODE")[0]: + text = text.replace( + 'OKX_POS_MODE = os.getenv("OKX_POS_MODE", "hedge")\n', + 'OKX_POS_MODE = os.getenv("OKX_POS_MODE", "hedge")\n' + 'EXCHANGE_DISPLAY_NAME = (os.getenv("EXCHANGE_DISPLAY_NAME") or "OKX").strip() or "OKX"\n', + ) + + if "TRADING_DAY_RESET_OPEN_GUARD_ENABLED" not in text: + text = text.replace( + "TRADING_DAY_RESET_HOUR = int(os.getenv(\"TRADING_DAY_RESET_HOUR\", \"8\"))\nAPP_TIMEZONE", + 'TRADING_DAY_RESET_HOUR = int(os.getenv("TRADING_DAY_RESET_HOUR", "8"))\n' + "TRADING_DAY_RESET_OPEN_GUARD_ENABLED = os.getenv(\n" + ' "TRADING_DAY_RESET_OPEN_GUARD_ENABLED", "true"\n' + ').lower() in ("1", "true", "yes", "on")\n' + "APP_TIMEZONE", + ) + + extra_env = """ +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_CONFIRM_BREAKOUT_BAR = int(os.getenv("KEY_CONFIRM_BREAKOUT_BAR", "-2")) +KEY_CONFIRM_BAR = int(os.getenv("KEY_CONFIRM_BAR", "-1")) +""" + if "MANUAL_MIN_PLANNED_RR = float" not in text: + text = text.replace( + "KEY_DAILY_VOLUME_RANK_MAX = int(os.getenv(\"KEY_DAILY_VOLUME_RANK_MAX\", \"30\"))\n", + "KEY_DAILY_VOLUME_RANK_MAX = max(1, int(os.getenv(\"KEY_DAILY_VOLUME_RANK_MAX\", \"30\")))\n" + + extra_env, + ) + + if "def format_funds_u" not in text: + text = text.replace( + "def format_hold_minutes(minutes):", + '''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 format_hold_minutes(minutes):''', + ) + + if "def trading_day_reset_allows_new_open" not in text: + text = text.replace( + "def precheck_risk(conn, symbol, direction):", + '''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 precheck_risk(conn, symbol, direction):''', + ) + + text = re.sub( + r"def precheck_risk\(conn, symbol, direction\):.*?return True, \"\"", + '''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, ""''', + text, + count=1, + flags=re.DOTALL, + ) + + # _key_hard_checks from gate + gate_text = (GATE / "app.py").read_text(encoding="utf-8") + m = re.search(r"def _key_hard_checks\(symbol.*?return out\n", gate_text, re.DOTALL) + if m: + kh = m.group(0).replace("normalize_exchange_symbol", "normalize_okx_symbol") + text = re.sub(r"def _key_hard_checks\(symbol.*?return out\n", kh, text, count=1, flags=re.DOTALL) + + if "def exchange_private_api_configured" not in text: + insert = ''' +def exchange_private_api_configured(): + return bool(OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE) + + +def _position_row_effective_contracts(p): + info = p.get("info", {}) or {} + 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: + return float(contracts) + except Exception: + return 0.0 + + +def _position_matches_wanted_contract(exchange_symbol, position): + if not position: + return False + sym = position.get("symbol") + return sym == exchange_symbol + + +def _select_live_position_row(rows, exchange_symbol, direction, relax_hedge=False): + 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 OKX_POS_MODE == "hedge": + if side and side != (direction or "").lower(): + continue + candidates.append((contracts, p)) + if not candidates and (not relax_hedge) and OKX_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 parse_ccxt_position_metrics(position, order_leverage=None): + if not position: + return None + p = position + info = p.get("info", {}) or {} + 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("imr"), + info.get("initial_margin"), + ) + notional = _coerce_float(p.get("notional"), p.get("notionalValue")) + if notional is None or notional <= 0: + notional = _coerce_float(info.get("notionalUsd"), info.get("notional")) + if notional is not None: + notional = abs(notional) + 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("upl"), + info.get("unrealized_pnl"), + ) + mark = _coerce_float(p.get("markPrice"), p.get("mark_price"), info.get("markPx")) + 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: + out["mark_price"] = round(mark, 8) + return out or None + + +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 _okx_tpsl_slot_from_order(order, exchange_symbol): + info = order.get("info") or {} + oid = order.get("id") or info.get("algoId") or info.get("ordId") + trig = _coerce_float( + info.get("slTriggerPx"), + info.get("tpTriggerPx"), + order.get("stopLossPrice"), + order.get("takeProfitPrice"), + ) + if trig is None: + return None + return { + "order_id": str(oid) if oid is not None else None, + "trigger_price": float(trig), + "trigger_display": format_price_for_symbol( + exchange_symbol.replace(":USDT", "").replace("/USDT:USDT", ""), + trig, + ), + "type": str(order.get("type") or info.get("ordType") 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_okx_live_ready() + if not ok: + return slots + try: + ensure_markets_loaded() + ambiguous = [] + for order in exchange.fetch_open_orders(exchange_symbol) or []: + slot = _okx_tpsl_slot_from_order(order, exchange_symbol) + if not slot or not slot.get("order_id"): + continue + trig = slot.get("trigger_price") + if plan_sl is not None and plan_tp is not None: + try: + role = "sl" if abs(trig - float(plan_sl)) <= abs(trig - float(plan_tp)) else "tp" + except Exception: + role = None + elif plan_sl is not None: + role = "sl" + elif plan_tp is not None: + role = "tp" + else: + ambiguous.append(slot) + continue + if role in ("sl", "tp") and slots[role] is None: + slots[role] = 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_okx_tpsl_slot(exchange_symbol, slot): + if not slot or not exchange_symbol: + return + oid = slot.get("order_id") + if not oid: + return + ensure_markets_loaded() + exchange.cancel_order(str(oid), exchange_symbol) + + +''' + text = text.replace( + "def replace_active_monitor_tpsl_on_exchange(order_row, stop_loss, take_profit):", + insert + "def replace_active_monitor_tpsl_on_exchange(order_row, stop_loss, take_profit):", + ) + + # render_main_page funding + template vars (gate style) + text = text.replace( + " funding_capital, trading_capital = get_exchange_capitals()\n" + " total_capital = round(funding_capital, 4) if funding_capital is not None else TOTAL_CAPITAL\n" + " current_capital = round(trading_capital, 4) if trading_capital is not None else round(local_current_capital, 4)\n", + " funding_capital, trading_capital = get_exchange_capitals()\n" + " funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None\n" + " current_capital = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else round(local_current_capital, FUNDS_DECIMALS)\n", + ) + text = text.replace( + " can_trade = now.hour >= TRADING_DAY_RESET_HOUR and active_count == 0\n" + " key_gate_rule_text = (\n" + ' f"周期 {KLINE_TIMEFRAME}|量能/突破/二确门控见箱体与收敛规则|"\n', + " can_trade = trading_day_reset_allows_new_open(now) and active_count < MAX_ACTIVE_POSITIONS\n" + " key_gate_rule_text = (\n" + ' f"周期 {KLINE_TIMEFRAME}|确认K:突破棒偏移 {KEY_CONFIRM_BREAKOUT_BAR},确认棒偏移 {KEY_CONFIRM_BAR}|"\n' + ' f"量能:突破量 > 前{KEY_VOLUME_MA_BARS}均量×{KEY_VOLUME_RATIO_MIN}|"\n', + ) + text = text.replace( + ' f"斐波:添加后立即挂限价 @ E,失效按标记价触达 H/L(未成交撤单)"\n', + ' f"箱体/收敛可选 SL/TP 方案(标准 / 箱体1R·止盈1.5H / 趋势单+自填止盈)|移动保本默认关|"\n' + ' f"斐波:限价 @ E(SL/TP 为 H/L),可选移动保本|趋势止损外侧 {KEY_TREND_STOP_OUTSIDE_PCT}%"\n', + ) + text = text.replace(" total_capital=total_capital,\n", "") + text = text.replace( + " key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR,\n **strategy_extra,", + " funds_fmt=format_funds_u,\n" + " exchange_display=EXCHANGE_DISPLAY_NAME,\n" + " max_active_positions=MAX_ACTIVE_POSITIONS,\n" + " manual_min_planned_rr=MANUAL_MIN_PLANNED_RR,\n" + " key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR,\n" + " kline_timeframe=KLINE_TIMEFRAME,\n" + " funding_usdt=funding_usdt,\n" + " **strategy_extra,", + ) + + if '@app.route("/key_monitor")' not in text: + text = text.replace( + '@app.route("/trade")\n@login_required\ndef trade_page():', + '@app.route("/key_monitor")\n@login_required\ndef key_monitor_page():\n' + ' return render_main_page("key_monitor")\n\n\n' + '@app.route("/trade")\n@login_required\ndef trade_page():', + ) + + # account_snapshot + text = re.sub( + r"@app\.route\(\"/api/account_snapshot\"\).*?return jsonify\(\{[^}]+\}\)", + '''@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, + })''', + text, + count=1, + flags=re.DOTALL, + ) + + # api_price_snapshot from gate (OKX positions) + gate_ps = re.search( + r'@app\.route\("/api/price_snapshot"\).*?return jsonify\(\{[^}]+\}\)', + gate_text, + re.DOTALL, + ) + if gate_ps: + ps = gate_ps.group(0) + ps = ps.replace("exchange_private_api_configured()", "exchange_private_api_configured()") + ps = ps.replace( + 'all_swap_positions = exchange.fetch_positions(None, {"settle": "usdt"}) or []', + 'all_swap_positions = exchange.fetch_positions(None, {"instType": OKX_POSITION_INST_TYPE}) or []', + ) + ps = ps.replace("fetch_exchange_tpsl_slots(", "fetch_exchange_tpsl_slots(") + ps = ps.replace("cancel_gate_tpsl_slot", "cancel_okx_tpsl_slot") + ps = ps.replace("ensure_exchange_live_ready", "ensure_okx_live_ready") + text = re.sub( + r'@app\.route\("/api/price_snapshot"\).*?return jsonify\(\{[^}]+\}\)', + ps, + text, + count=1, + flags=re.DOTALL, + ) + + # cancel/place tpsl routes + if 'api_order_cancel_tpsl' not in text: + bin_text = (BIN / "app.py").read_text(encoding="utf-8") + m = re.search( + r'@app\.route\("/api/order//cancel_tpsl".*?exchange_tpsl": slots,\s*\}\s*\)', + bin_text, + re.DOTALL, + ) + if m: + block = m.group(0) + block = block.replace("ensure_exchange_live_ready", "ensure_okx_live_ready") + block = block.replace("cancel_binance_tpsl_slot", "cancel_okx_tpsl_slot") + block = block.replace( + 'fetch_exchange_tpsl_slots(ex_sym, row["direction"])', + 'fetch_exchange_tpsl_slots(ex_sym, row["direction"], plan_sl=row["stop_loss"], plan_tp=row["take_profit"])', + ) + block = block.replace( + 'fetch_exchange_tpsl_slots(ex_sym, direction)', + 'fetch_exchange_tpsl_slots(ex_sym, direction, plan_sl=stop_loss, plan_tp=take_profit)', + ) + text = text.replace( + '@app.route("/add_key", methods=["POST"])', + block + '\n\n@app.route("/add_key", methods=["POST"])', + ) + + # add_order RR + redirects + if "planned_rr_manual" not in text: + text = text.replace( + " if stop_loss <= 0 or take_profit <= 0:\n" + " conn.close()\n" + " flash(\"价格参数必须大于0\")\n" + " return redirect(\"/\")\n" + " risk_fraction = calc_risk_fraction", + " if stop_loss <= 0 or take_profit <= 0:\n" + " conn.close()\n" + " flash(\"价格参数必须大于0\")\n" + " return redirect(\"/trade\")\n" + " planned_rr_manual = calc_rr_ratio(direction, live_price, stop_loss, take_profit)\n" + " if planned_rr_manual is None or planned_rr_manual < MANUAL_MIN_PLANNED_RR:\n" + " conn.close()\n" + " rr_txt = f\"{planned_rr_manual:.4f}\" if planned_rr_manual is not None else \"无法计算\"\n" + " flash(f\"风控拒绝下单:计划盈亏比 {rr_txt}:1 低于最低要求 {MANUAL_MIN_PLANNED_RR}:1\")\n" + " return redirect(\"/trade\")\n" + " risk_fraction = calc_risk_fraction", + ) + + text = text.replace( + 'if get_active_position_count(conn) > 0:\n' + ' conn.close()\n' + ' flash("当前已有持仓:无法添加「箱体突破 / 收敛突破」(请先平仓或使用阻力/支撑/斐波类型)")', + 'occupied = get_active_position_count(conn)\n' + ' if occupied >= MAX_ACTIVE_POSITIONS:\n' + ' conn.close()\n' + ' flash(\n' + ' f"当前持仓已达上限({occupied}/{MAX_ACTIVE_POSITIONS}):无法添加「箱体突破 / 收敛突破」."\n' + ' "请先平仓或使用阻力/支撑/斐波类型"\n' + ' )', + ) + + # add_key → /key_monitor (success paths in add_key only) + text = text.replace( + 'def add_key():\n d = request.form\n symbol = normalize_symbol_input(d.get("symbol"))\n if not symbol:\n flash("symbol 不能为空")\n return redirect("/")', + 'def add_key():\n d = request.form\n symbol = normalize_symbol_input(d.get("symbol"))\n if not symbol:\n flash("symbol 不能为空")\n return redirect("/key_monitor")', + ) + text = re.sub( + r'(def add_key\(\):.*?)(return redirect\("/"\))', + lambda m: m.group(1) + 'return redirect("/key_monitor")', + text, + count=0, + flags=re.DOTALL, + ) + + text = text.replace( + 'if "一次只能持有一个仓位" in reason:', + 'if "已达最大持仓数" in reason or "一次只能持有一个仓位" in reason:', + ) + + app_path.write_text(text, encoding="utf-8") + print("patched", app_path) + + +def copy_templates(): + src = BIN / "templates" / "index.html" + dst = OKX / "templates" / "index.html" + shutil.copy2(src, dst) + print("copied", dst) + + +def copy_env_example(): + bin_env = (BIN / ".env.example").read_text(encoding="utf-8") + okx_path = OKX / ".env.example" + okx = okx_path.read_text(encoding="utf-8") + # inject binance-style blocks if missing + for marker, block in [ + ( + "TRADING_DAY_RESET_OPEN_GUARD", + "\nTRADING_DAY_RESET_OPEN_GUARD_ENABLED=true\n", + ), + ("MAX_ACTIVE_POSITIONS", "\nMAX_ACTIVE_POSITIONS=1\nMANUAL_MIN_PLANNED_RR=1.4\n"), + ("KEY_CONFIRM_BREAKOUT_BAR", "\nKEY_CONFIRM_BREAKOUT_BAR=-2\nKEY_CONFIRM_BAR=-1\nKEY_VOLUME_MA_BARS=20\nKEY_VOLUME_RATIO_MIN=1.3\nKEY_BREAKOUT_AMP_MIN_PCT=0.03\nKEY_BREAKOUT_AMP_MAX_PCT=0.5\n"), + ("EXCHANGE_DISPLAY_NAME", "\nEXCHANGE_DISPLAY_NAME=OKX\nOKX_ACCOUNT_LABEL=\n"), + ("BACKUP_ROOT", "\nBACKUP_ROOT=/root/backups\nBACKUP_RETENTION_DAYS=30\nBACKUP_INSTANCE=crypto_monitor_okx\n"), + ]: + if marker not in okx: + okx += block + if "TOTAL_CAPITAL=100" in okx and "# TOTAL_CAPITAL" not in okx: + okx = okx.replace("TOTAL_CAPITAL=100", "# TOTAL_CAPITAL=100 # 已弃用,资金展示读交易所") + okx_path.write_text(okx, encoding="utf-8") + print("updated .env.example") + + +def copy_scripts_docs(): + for name in ("backup_data.sh", "install_backup_cron.sh"): + s = BIN / "scripts" / name + d = OKX / "scripts" / name + if s.is_file(): + d.parent.mkdir(parents=True, exist_ok=True) + content = s.read_text(encoding="utf-8").replace("crypto_monitor_binance", "crypto_monitor_okx") + content = content.replace("BINANCE", "OKX") + d.write_text(content, encoding="utf-8") + v = BIN / "scripts" / "verify_binance_funding.py" + if v.is_file(): + t = v.read_text(encoding="utf-8") + t = t.replace("binance", "okx").replace("BINANCE", "OKX").replace("verify_binance", "verify_okx") + (OKX / "scripts" / "verify_okx_funding.py").write_text(t, encoding="utf-8") + doc = BIN / "关键位自动下单说明.md" + if doc.is_file() and not (OKX / "关键位自动下单说明.md").exists(): + shutil.copy2(doc, OKX / "关键位自动下单说明.md") + eco = OKX / "ecosystem.config.cjs" + if eco.is_file(): + t = eco.read_text(encoding="utf-8").replace("GATE_SOCKS_PROXY", "OKX_SOCKS_PROXY") + eco.write_text(t, encoding="utf-8") + + +if __name__ == "__main__": + copy_templates() + patch_app() + copy_env_example() + copy_scripts_docs() + print("done") diff --git a/scripts/apply_time_close_patches.py b/scripts/apply_time_close_patches.py new file mode 100644 index 0000000..184acd2 --- /dev/null +++ b/scripts/apply_time_close_patches.py @@ -0,0 +1,411 @@ +#!/usr/bin/env python3 +"""对 binance/okx 应用与 gate 相同的时间平仓代码替换.""" +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +FILES = [ + ROOT / "crypto_monitor_binance" / "app.py", + ROOT / "crypto_monitor_okx" / "app.py", +] + +REPLACEMENTS: list[tuple[str, str]] = [ + ( + "def _market_open_for_key_monitor(\n conn,\n symbol,\n direction,\n exchange_symbol,\n stop_loss,\n take_profit,\n key_signal_type=None,\n breakeven_enabled=0,\n):", + "def _market_open_for_key_monitor(\n conn,\n symbol,\n direction,\n exchange_symbol,\n stop_loss,\n take_profit,\n key_signal_type=None,\n breakeven_enabled=0,\n time_close_enabled=0,\n time_close_hours=None,\n):", + ), + ( + "def _add_false_breakout_key_monitor(\n conn, symbol, direction_sel, upper_px, lower_px, key_px, breakeven_enabled=0,\n):", + "def _add_false_breakout_key_monitor(\n conn, symbol, direction_sel, upper_px, lower_px, key_px, breakeven_enabled=0,\n time_close_enabled=0, time_close_hours=None,\n):", + ), + ( + "def _add_fib_key_monitor(conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=0):", + "def _add_fib_key_monitor(\n conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=0,\n time_close_enabled=0, time_close_hours=None,\n):", + ), + ( + " key_sig = typ if typ in KEY_MONITOR_AUTO_TYPES else None\n be_on = breakeven_enabled_from_row(r, 0)\n ok_trade, trade_err, det = _market_open_for_key_monitor(\n conn,\n sym,\n direction,\n exchange_symbol,\n sl_raw,\n tp_raw,\n key_signal_type=key_sig,\n breakeven_enabled=1 if be_on else 0,\n )", + " key_sig = typ if typ in KEY_MONITOR_AUTO_TYPES else None\n be_on = breakeven_enabled_from_row(r, 0)\n tc_en, tc_h, _ = time_close_settings_from_row(r)\n ok_trade, trade_err, det = _market_open_for_key_monitor(\n conn,\n sym,\n direction,\n exchange_symbol,\n sl_raw,\n tp_raw,\n key_signal_type=key_sig,\n breakeven_enabled=1 if be_on else 0,\n time_close_enabled=tc_en,\n time_close_hours=tc_h,\n )", + ), + ( + " res = None\n # 做多\n if direction == \"long\":\n if p >= take_profit: res = \"止盈\"\n elif p <= stop_loss: res = \"止损\"\n # 做空\n elif direction == \"short\":\n if p <= take_profit: res = \"止盈\"\n elif p >= stop_loss: res = \"止损\"", + " res = None\n if should_trigger_time_close(r):\n res = TIME_CLOSE_RESULT\n # 做多\n if not res and direction == \"long\":\n if p >= take_profit: res = \"止盈\"\n elif p <= stop_loss: res = \"止损\"\n # 做空\n elif not res and direction == \"short\":\n if p <= take_profit: res = \"止盈\"\n elif p >= stop_loss: res = \"止损\"", + ), + ( + ' "SELECT id,symbol,exchange_symbol,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage FROM order_monitors WHERE status=\'active\'"', + ' "SELECT id,symbol,exchange_symbol,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage,"\n "time_close_enabled,time_close_hours,time_close_at_ms,opened_at_ms FROM order_monitors WHERE status=\'active\'"', + ), + ( + " apply_order_price_display_fields(\n payload,\n direction=r[\"direction\"],\n entry_price=entry,\n initial_stop_loss=r[\"initial_stop_loss\"],\n stop_loss=r[\"stop_loss\"],\n take_profit=r[\"take_profit\"],\n calc_rr_ratio_fn=calc_rr_ratio,\n exchange_tpsl=exchange_tpsl,\n format_price_fn=format_price_for_symbol,\n symbol=r[\"symbol\"],\n )\n new_sl, new_tp, changed = order_monitor_tpsl_needs_sync(", + " apply_order_price_display_fields(\n payload,\n direction=r[\"direction\"],\n entry_price=entry,\n initial_stop_loss=r[\"initial_stop_loss\"],\n stop_loss=r[\"stop_loss\"],\n take_profit=r[\"take_profit\"],\n calc_rr_ratio_fn=calc_rr_ratio,\n exchange_tpsl=exchange_tpsl,\n format_price_fn=format_price_for_symbol,\n symbol=r[\"symbol\"],\n )\n apply_time_close_to_payload(payload, r)\n new_sl, new_tp, changed = order_monitor_tpsl_needs_sync(", + ), + ( + " be_flag = parse_breakeven_enabled_form(d.get(\"breakeven_enabled\"))\n if is_false_breakout_key_monitor_type(mt):", + " be_flag = parse_breakeven_enabled_form(d.get(\"breakeven_enabled\"))\n tc_en = parse_time_close_enabled_form(d.get(\"time_close_enabled\"))\n tc_h = parse_time_close_hours_form(d.get(\"time_close_hours\")) if tc_en else None\n if tc_en and not tc_h:\n tc_en = 0\n if is_false_breakout_key_monitor_type(mt):", + ), + ( + " ok_fb, err_fb = _add_false_breakout_key_monitor(\n conn, symbol, direction_sel, upper_px, lower_px, key_px, breakeven_enabled=be_flag,\n )", + " ok_fb, err_fb = _add_false_breakout_key_monitor(\n conn, symbol, direction_sel, upper_px, lower_px, key_px, breakeven_enabled=be_flag,\n time_close_enabled=tc_en, time_close_hours=tc_h,\n )", + ), + ( + " f\"|有效期 {FALSE_BREAKOUT_VALIDITY_HOURS}h|移动保本:{'开' if be_flag else '关'}\"\n )", + " f\"|有效期 {FALSE_BREAKOUT_VALIDITY_HOURS}h|移动保本:{'开' if be_flag else '关'}\"\n + (f\"|{time_close_label(tc_h)}\" if tc_en else \"\")\n )", + ), + ( + " ok_fib, err_fib = _add_fib_key_monitor(\n conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=be_flag,\n )", + " ok_fib, err_fib = _add_fib_key_monitor(\n conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=be_flag,\n time_close_enabled=tc_en, time_close_hours=tc_h,\n )", + ), + ( + " f\"|移动保本:{'开' if be_flag else '关'}\"\n )\n return redirect(\"/key_monitor\")", + " f\"|移动保本:{'开' if be_flag else '关'}\"\n + (f\"|{time_close_label(tc_h)}\" if tc_en else \"\")\n )\n return redirect(\"/key_monitor\")", + ), + ( + " if mt in KEY_MONITOR_AUTO_TYPES:\n extra = f\"|方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'开' if be_flag else '关'}\"", + " if mt in KEY_MONITOR_AUTO_TYPES:\n extra = f\"|方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'开' if be_flag else '关'}\"\n if tc_en:\n extra += f\"|{time_close_label(tc_h)}\"", + ), +] + +MARKET_OPEN_OLD = """ 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), + ), + )""" + +MARKET_OPEN_NEW = """ breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw) + be_enabled = 1 if int(breakeven_enabled or 0) != 0 else 0 + tc_en, tc_h, tc_at = time_close_insert_values( + time_close_enabled, time_close_hours, opened_at_ms + ) + + 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, " + "time_close_enabled, time_close_hours, time_close_at_ms) " + "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), + tc_en, + tc_h, + tc_at, + ), + )""" + +FIB_INSERT_OLD = """ 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), + ), + )""" + +FIB_INSERT_NEW = """ opened_at_bj = app_now_str() + opened_at_ms = _to_ms_with_fallback(None, opened_at_bj) + tc_en, tc_h, _ = time_close_settings_from_row(row) + tc_en, tc_h, tc_at = time_close_insert_values(tc_en, tc_h, opened_at_ms) + 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, " + "time_close_enabled, time_close_hours, time_close_at_ms) " + "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), + tc_en, + tc_h, + tc_at, + ), + )""" + +KEY_FB_OLD = """ 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, FALSE_BREAKOUT_MONITOR_TYPE, direction_sel, upper_px, lower_px, + oid, entry, sl, tp, float(amount), margin_capital, leverage, be_flag, + ), + )""" + +KEY_FB_NEW = """ be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0 + tc_en, tc_h, _ = time_close_insert_values(time_close_enabled, time_close_hours, None) + 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, time_close_enabled, time_close_hours) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, FALSE_BREAKOUT_MONITOR_TYPE, direction_sel, upper_px, lower_px, + oid, entry, sl, tp, float(amount), margin_capital, leverage, be_flag, tc_en, tc_h, + ), + )""" + +KEY_FIB_OLD = """ 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, + ), + )""" + +KEY_FIB_NEW = """ be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0 + tc_en, tc_h, _ = time_close_insert_values(time_close_enabled, time_close_hours, None) + 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, time_close_enabled, time_close_hours) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, mt, direction_sel, upper_px, lower_px, + oid, entry, sl, tp, float(amount), margin_capital, leverage, be_flag, tc_en, tc_h, + ), + )""" + +ADD_KEY_RS_OLD = """ conn.execute( + "INSERT INTO key_monitors " + "(symbol,monitor_type,direction,upper,lower,sl_tp_mode,manual_take_profit,breakeven_enabled," + "max_notify,notify_interval_min) " + "VALUES (?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + mt, + direction_sel, + upper_px, + lower_px, + sl_tp_mode, + manual_tp, + be_flag, + KEY_ALERT_MAX_TIMES, + KEY_ALERT_INTERVAL_MINUTES, + ), + ) + else: + 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), + )""" + +ADD_KEY_RS_NEW = """ conn.execute( + "INSERT INTO key_monitors " + "(symbol,monitor_type,direction,upper,lower,sl_tp_mode,manual_take_profit,breakeven_enabled," + "max_notify,notify_interval_min,time_close_enabled,time_close_hours) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + mt, + direction_sel, + upper_px, + lower_px, + sl_tp_mode, + manual_tp, + be_flag, + KEY_ALERT_MAX_TIMES, + KEY_ALERT_INTERVAL_MINUTES, + tc_en, + tc_h, + ), + ) + else: + conn.execute( + "INSERT INTO key_monitors " + "(symbol,monitor_type,direction,upper,lower,sl_tp_mode,manual_take_profit,breakeven_enabled," + "time_close_enabled,time_close_hours) " + "VALUES (?,?,?,?,?,?,?,?,?,?)", + (symbol, mt, direction_sel, upper_px, lower_px, sl_tp_mode, manual_tp, be_flag, tc_en, tc_h), + )""" + +ADD_ORDER_OLD = """ 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_db, 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, + ) + )""" + +ADD_ORDER_NEW = """ breakeven_enabled = 1 if (d.get("breakeven_enabled") or "").strip() in ("1", "true", "on", "yes") else 0 + tc_en = parse_time_close_enabled_form(d.get("time_close_enabled")) + tc_h = parse_time_close_hours_form(d.get("time_close_hours")) if tc_en else None + if tc_en and not tc_h: + tc_en = 0 + tc_en, tc_h, tc_at = time_close_insert_values(tc_en, tc_h, opened_at_ms) + 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, time_close_enabled, time_close_hours, time_close_at_ms) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, exchange_symbol, direction, trigger_price, stop_loss, stop_loss, take_profit, + margin_capital, leverage, trade_style, risk_percent_db, 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, + tc_en, tc_h, tc_at, + ) + )""" + +BIG_BLOCKS = [ + (MARKET_OPEN_OLD, MARKET_OPEN_NEW), + (FIB_INSERT_OLD, FIB_INSERT_NEW), + (KEY_FB_OLD, KEY_FB_NEW), + (KEY_FIB_OLD, KEY_FIB_NEW), + (ADD_KEY_RS_OLD, ADD_KEY_RS_NEW), + (ADD_ORDER_OLD, ADD_ORDER_NEW), +] + + +def patch(path: Path) -> None: + text = path.read_text(encoding="utf-8") + for old, new in REPLACEMENTS + BIG_BLOCKS: + if old in text: + text = text.replace(old, new, 1) + path.write_text(text, encoding="utf-8") + print("done", path.name) + + +def main() -> None: + for f in FILES: + patch(f) + + +if __name__ == "__main__": + main() diff --git a/scripts/backfill_trend_strategy_snapshots.py b/scripts/backfill_trend_strategy_snapshots.py new file mode 100644 index 0000000..202fe8a --- /dev/null +++ b/scripts/backfill_trend_strategy_snapshots.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +"""补录缺失的趋势回调策略结束快照(strategy_trade_snapshots). + +适用:gate 等在计划结束(止盈/止损/手动)时因 strategy_trend_cfg 未注册而漏写快照的历史数据. +保本移交路径通常已有快照,本脚本默认跳过「已有任意快照」的计划. + +用法(在仓库根目录,Linux 请用 python3): + python3 scripts/backfill_trend_strategy_snapshots.py \\ + --db crypto_monitor_gate/crypto.db --dry-run + python3 scripts/backfill_trend_strategy_snapshots.py \\ + --db crypto_monitor_gate/crypto.db --apply +""" +from __future__ import annotations + +import argparse +import sqlite3 +import sys +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parents[1] +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) + +from lib.strategy.strategy_snapshot_lib import ( # noqa: E402 + STRATEGY_TREND, + init_strategy_snapshot_table, + save_trend_plan_snapshot, +) + +PLAN_STATUS_LABEL = { + "stopped_sl": "止损", + "stopped_tp": "止盈", + "stopped_manual": "手动平仓", + "stopped_handoff": "保本移交", +} + +TRADE_RESULT_LABEL = { + "止损": "止损", + "止盈": "止盈", + "手动平仓": "手动平仓", + "移动止盈": "止盈", + "保本止盈": "止盈", + "强制清仓": "手动平仓", +} + + +def _row_dict(row) -> dict: + if row is None: + return {} + try: + return dict(row) + except Exception: + return {} + + +def infer_exit_price( + direction: str, + entry: float | None, + margin: float | None, + leverage: float | None, + pnl: float | None, +) -> float | None: + """由本地 calc_pnl 口径反推平仓价(供补录快照 exit_price).""" + try: + trigger = float(entry) + margin_f = float(margin) + lev = float(leverage) + pnl_f = float(pnl) + except (TypeError, ValueError): + return None + if trigger <= 0 or margin_f <= 0 or lev <= 0: + return None + notional = margin_f * lev + if notional <= 0: + return None + ratio = pnl_f / notional + if (direction or "long").strip().lower() == "short": + return round(trigger * (1.0 - ratio), 10) + return round(trigger * (1.0 + ratio), 10) + + +def resolve_result_label(plan: dict, trade: dict | None) -> str: + status = (plan.get("status") or "").strip() + if status in PLAN_STATUS_LABEL: + return PLAN_STATUS_LABEL[status] + if trade: + res = (trade.get("result") or "").strip() + if res in TRADE_RESULT_LABEL: + return TRADE_RESULT_LABEL[res] + if res: + return res + msg = (plan.get("message") or "").strip() + if msg: + return msg[:32] + return "结束" + + +def find_missing_plans( + conn: sqlite3.Connection, + *, + plan_id: int | None = None, + since: str | None = None, +) -> list[dict]: + sql = """ + SELECT p.* + FROM trend_pullback_plans p + WHERE TRIM(COALESCE(p.status, '')) != 'active' + AND NOT EXISTS ( + SELECT 1 FROM strategy_trade_snapshots s + WHERE s.strategy_type = ? AND s.source_id = p.id + ) + """ + params: list[object] = [STRATEGY_TREND] + if plan_id is not None: + sql += " AND p.id = ?" + params.append(int(plan_id)) + if since: + sql += " AND COALESCE(p.opened_at, '') >= ?" + params.append(since.strip()) + sql += " ORDER BY p.id ASC" + rows = conn.execute(sql, params).fetchall() + return [_row_dict(r) for r in rows] + + +def fetch_trade_for_plan(conn: sqlite3.Connection, plan_id: int) -> dict | None: + row = conn.execute( + """ + SELECT * FROM trade_records + WHERE trend_plan_id = ? + ORDER BY COALESCE(closed_at_ms, 0) DESC, id DESC + LIMIT 1 + """, + (int(plan_id),), + ).fetchone() + return _row_dict(row) if row else None + + +def backfill_one(conn: sqlite3.Connection, plan: dict, *, dry_run: bool) -> dict: + plan_id = int(plan["id"]) + trade = fetch_trade_for_plan(conn, plan_id) + result_label = resolve_result_label(plan, trade) + pnl_amount = None + closed_at = None + exit_price = None + entry = plan.get("avg_entry_price") or plan.get("live_price_ref") + margin = plan.get("plan_margin_capital") + leverage = plan.get("leverage") + + if trade: + pnl_amount = trade.get("pnl_amount") + closed_at = trade.get("closed_at") + entry = trade.get("trigger_price") or entry + margin = trade.get("margin_capital") or margin + leverage = trade.get("leverage") or leverage + exit_price = infer_exit_price( + plan.get("direction") or trade.get("direction") or "long", + entry, + margin, + leverage, + pnl_amount, + ) + + info = { + "plan_id": plan_id, + "symbol": plan.get("symbol"), + "status": plan.get("status"), + "result_label": result_label, + "closed_at": closed_at, + "pnl_amount": pnl_amount, + "exit_price": exit_price, + "legs_done": plan.get("legs_done"), + "dca_legs": plan.get("dca_legs"), + "has_trade": bool(trade), + } + + if dry_run: + return info + + save_trend_plan_snapshot( + {}, + conn, + plan, + result_label=result_label, + exit_price=exit_price, + pnl_amount=float(pnl_amount) if pnl_amount is not None else None, + closed_at=closed_at, + ) + return info + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Backfill missing trend_pullback strategy_trade_snapshots rows." + ) + parser.add_argument("--db", required=True, help="Path to instance sqlite db") + parser.add_argument("--plan-id", type=int, help="Only backfill this trend plan id") + parser.add_argument( + "--since", + help="Only plans with opened_at >= YYYY-MM-DD (optional)", + ) + parser.add_argument("--dry-run", action="store_true", help="Preview only (default)") + parser.add_argument("--apply", action="store_true", help="Write snapshots") + args = parser.parse_args() + if not args.dry_run and not args.apply: + args.dry_run = True + + db_path = Path(args.db).expanduser().resolve() + if not db_path.is_file(): + print(f"[ERR] DB not found: {db_path}") + return 1 + + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + init_strategy_snapshot_table(conn) + + missing = find_missing_plans( + conn, plan_id=args.plan_id, since=args.since + ) + if not missing: + print("[INFO] No closed trend plans missing strategy snapshots.") + conn.close() + return 0 + + print(f"[INFO] Found {len(missing)} plan(s) without strategy snapshot.") + applied = 0 + for plan in missing: + info = backfill_one(conn, plan, dry_run=not args.apply) + trade_hint = "有交易记录" if info["has_trade"] else "无交易记录" + print( + f" - plan #{info['plan_id']} {info['symbol']} " + f"status={info['status']} → {info['result_label']} " + f"closed={info['closed_at'] or '—'} pnl={info['pnl_amount']} " + f"补仓 {info['legs_done']}/{info['dca_legs']} ({trade_hint})" + ) + applied += 1 + + if args.apply: + conn.commit() + print(f"[OK] Backfilled {applied} snapshot(s).") + else: + print("[DRY-RUN] No changes written. Re-run with --apply to commit.") + + conn.close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/backfill_trend_trade_records.py b/scripts/backfill_trend_trade_records.py new file mode 100644 index 0000000..06fd4b9 --- /dev/null +++ b/scripts/backfill_trend_trade_records.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""补录缺失的趋势回调 trade_records(策略快照已有,交易记录漏写). + +典型原因:gate insert_trade_record 曾不接受 entry_reason,_finalize_plan 写快照后插入失败. + +用法: + python scripts/backfill_trend_trade_records.py --db crypto_monitor_gate/crypto.db --dry-run + python scripts/backfill_trend_trade_records.py --db crypto_monitor_gate/crypto.db --apply +""" +from __future__ import annotations + +import argparse +import json +import sqlite3 +import sys +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parents[1] +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) + +from lib.strategy.strategy_snapshot_lib import STRATEGY_TREND # noqa: E402 +from lib.strategy.strategy_trade_labels import ENTRY_REASON_TREND_PULLBACK, MONITOR_TYPE_TREND_PULLBACK # noqa: E402 + +STATUS_TO_RESULT = { + "stopped_sl": "止损", + "stopped_tp": "止盈", + "stopped_manual": "手动平仓", +} + + +def _row_dict(row) -> dict: + if row is None: + return {} + try: + return dict(row) + except Exception: + return {} + + +def _hold_minutes(hold_seconds: int) -> int: + try: + return max(0, int(round(float(hold_seconds) / 60.0))) + except (TypeError, ValueError): + return 0 + + +def backfill_one(conn: sqlite3.Connection, snap: dict, *, apply: bool) -> dict: + plan_id = int(snap.get("source_id") or 0) + if plan_id <= 0: + return {"plan_id": plan_id, "skipped": True, "reason": "invalid source_id"} + exists = conn.execute( + "SELECT id FROM trade_records WHERE trend_plan_id=? LIMIT 1", (plan_id,) + ).fetchone() + if exists: + return {"plan_id": plan_id, "skipped": True, "reason": "trade_exists"} + + try: + payload = json.loads(snap.get("snapshot_json") or "{}") + except Exception: + payload = {} + + plan = conn.execute( + "SELECT * FROM trend_pullback_plans WHERE id=?", (plan_id,) + ).fetchone() + plan_d = _row_dict(plan) + + symbol = snap.get("symbol") or plan_d.get("symbol") or payload.get("symbol") + direction = snap.get("direction") or plan_d.get("direction") or payload.get("direction") or "long" + result = (snap.get("result_label") or "").strip() or STATUS_TO_RESULT.get( + plan_d.get("status") or "", "手动平仓" + ) + opened_at = snap.get("opened_at") or plan_d.get("opened_at") + closed_at = snap.get("closed_at") + pnl_amount = snap.get("pnl_amount") + if pnl_amount is None: + pnl_amount = payload.get("pnl_amount") + + trigger_price = payload.get("avg_entry_price") or plan_d.get("avg_entry_price") + stop_loss = payload.get("stop_loss") or plan_d.get("stop_loss") + take_profit = payload.get("take_profit") or plan_d.get("take_profit") + margin_capital = payload.get("plan_margin_capital") or plan_d.get("plan_margin_capital") + leverage = payload.get("leverage") or plan_d.get("leverage") + + opened_ms = plan_d.get("opened_at_ms") + closed_ms = None + + hold_seconds = 0 + if opened_at and closed_at: + try: + from datetime import datetime + + fmt = "%Y-%m-%d %H:%M:%S" + o = datetime.strptime(str(opened_at).strip()[:19], fmt) + c = datetime.strptime(str(closed_at).strip()[:19], fmt) + hold_seconds = max(0, int((c - o).total_seconds())) + except Exception: + hold_seconds = 0 + + row = { + "symbol": symbol, + "monitor_type": MONITOR_TYPE_TREND_PULLBACK, + "direction": direction, + "trigger_price": trigger_price, + "stop_loss": stop_loss, + "initial_stop_loss": plan_d.get("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": "trend_pullback", + "result": result, + "opened_at": opened_at, + "opened_at_ms": opened_ms, + "closed_at": closed_at, + "closed_at_ms": closed_ms, + "entry_reason": ENTRY_REASON_TREND_PULLBACK, + "trend_plan_id": plan_id, + } + + if not apply: + return {"plan_id": plan_id, "dry_run": True, "row": row} + + 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, + hold_minutes, opened_at, opened_at_ms, closed_at, closed_at_ms, result, + entry_reason, trend_plan_id + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + row["symbol"], + row["monitor_type"], + row["direction"], + row["trigger_price"], + row["stop_loss"], + row["initial_stop_loss"], + row["take_profit"], + row["margin_capital"], + row["leverage"], + row["pnl_amount"], + row["hold_seconds"], + row["trade_style"], + _hold_minutes(hold_seconds), + row["opened_at"], + row["opened_at_ms"], + row["closed_at"], + row["closed_at_ms"], + row["result"], + row["entry_reason"], + row["trend_plan_id"], + ), + ) + return {"plan_id": plan_id, "inserted": True} + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--db", required=True, help="实例 sqlite 路径") + ap.add_argument("--apply", action="store_true", help="写入数据库(默认 dry-run)") + args = ap.parse_args() + db_path = Path(args.db) + if not db_path.is_file(): + print(f"数据库不存在: {db_path}") + return 1 + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + snaps = conn.execute( + """SELECT * FROM strategy_trade_snapshots + WHERE strategy_type=? ORDER BY id DESC""", + (STRATEGY_TREND,), + ).fetchall() + out = [] + for s in snaps: + r = backfill_one(conn, _row_dict(s), apply=args.apply) + out.append(r) + print(r) + if args.apply: + conn.commit() + conn.close() + inserted = sum(1 for x in out if x.get("inserted")) + print(f"done: inserted={inserted} total_snapshots={len(snaps)} apply={args.apply}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/bootstrap_deploy_secrets.py b/scripts/bootstrap_deploy_secrets.py new file mode 100644 index 0000000..07c9a03 --- /dev/null +++ b/scripts/bootstrap_deploy_secrets.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""首次部署:自动生成中控通信密钥,登录会话密钥,并写入初始登录账号. + +- HUB_BRIDGE_TOKEN:中控 + 三实例(相同,仅空/占位时写入,不覆盖已有) +- FLASK_SECRET_KEY:三实例(相同) +- HUB_SESSION_SECRET:仅中控 +- APP_USERNAME=admin,APP_PASSWORD=admin123:实例(仅空时) +- HUB_USERNAME=admin,HUB_PASSWORD=admin123:中控(仅空时) + +已有非空且非占位符的值不会被覆盖(长期密钥一次生成,不轮换). +""" +from __future__ import annotations + +import argparse +import os +import secrets +import sys + +_REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _REPO not in sys.path: + sys.path.insert(0, _REPO) + +from lib.env.env_file_lib import apply_env_updates, env_get, read_env_lines + +INSTANCE_DIRS = ( + ("okx", os.path.join(_REPO, "crypto_monitor_okx")), + ("binance", os.path.join(_REPO, "crypto_monitor_binance")), + ("gate", os.path.join(_REPO, "crypto_monitor_gate")), +) +HUB_DIR = os.path.join(_REPO, "manual_trading_hub") + +FLASK_PLACEHOLDERS = frozenset( + {"", "CHANGE_TO_LONG_RANDOM_SECRET", "crypto_monitor_2026_secret_key"} +) +HUB_PLACEHOLDERS = frozenset({"", "your-long-random-token"}) +SESSION_PLACEHOLDERS = frozenset({"", "another-long-random-string", "hub-dev-insecure"}) + + +def _env_path(base: str) -> str: + return os.path.join(base, ".env") + + +def _should_set(current: str | None, placeholders: frozenset[str]) -> bool: + val = (current or "").strip() + return val in placeholders + + +def main() -> int: + parser = argparse.ArgumentParser(description="Bootstrap deploy secrets") + parser.add_argument("--dry-run", action="store_true", help="只打印将写入的项,不改文件") + args = parser.parse_args() + + hub_token = secrets.token_urlsafe(32) + flask_secret = secrets.token_urlsafe(48) + session_secret = secrets.token_urlsafe(48) + planned: list[tuple[str, dict[str, str]]] = [] + + hub_env = _env_path(HUB_DIR) + if os.path.isfile(hub_env): + hub_lines = read_env_lines(hub_env) + hub_updates: dict[str, str] = {} + if _should_set(env_get(hub_lines, "HUB_BRIDGE_TOKEN"), HUB_PLACEHOLDERS): + hub_updates["HUB_BRIDGE_TOKEN"] = hub_token + if _should_set(env_get(hub_lines, "HUB_SESSION_SECRET"), SESSION_PLACEHOLDERS): + hub_updates["HUB_SESSION_SECRET"] = session_secret + if not (env_get(hub_lines, "HUB_USERNAME") or "").strip(): + hub_updates["HUB_USERNAME"] = "admin" + if _should_set(env_get(hub_lines, "HUB_PASSWORD"), frozenset({""})): + hub_updates["HUB_PASSWORD"] = "admin123" + if hub_updates: + planned.append((hub_env, hub_updates)) + + for _name, inst_dir in INSTANCE_DIRS: + path = _env_path(inst_dir) + if not os.path.isfile(path): + continue + lines = read_env_lines(path) + updates: dict[str, str] = {} + if _should_set(env_get(lines, "HUB_BRIDGE_TOKEN"), HUB_PLACEHOLDERS): + updates["HUB_BRIDGE_TOKEN"] = hub_token + if _should_set(env_get(lines, "FLASK_SECRET_KEY"), FLASK_PLACEHOLDERS): + updates["FLASK_SECRET_KEY"] = flask_secret + if not (env_get(lines, "APP_USERNAME") or "").strip(): + updates["APP_USERNAME"] = "admin" + if _should_set(env_get(lines, "APP_PASSWORD"), frozenset({""})): + updates["APP_PASSWORD"] = "admin123" + if updates: + planned.append((path, updates)) + + if not planned: + print("无需写入:密钥与登录项均已配置.") + return 0 + + for path, updates in planned: + rel = os.path.relpath(path, _REPO) + keys = ", ".join(sorted(updates.keys())) + if args.dry_run: + print(f"[dry-run] {rel}: {keys}") + continue + apply_env_updates(path, updates) + print(f"已写入 {rel}: {keys}") + + if not args.dry_run: + print("完成.初始登录:admin / admin123(若本次写入了密码项).") + print("请 pm2 restart 中控与三实例使密钥生效.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/build_embed_fragment.py b/scripts/build_embed_fragment.py new file mode 100644 index 0000000..dbe974d --- /dev/null +++ b/scripts/build_embed_fragment.py @@ -0,0 +1,67 @@ +"""Build embed_page_fragment.html from lib/instance/templates/index.html.""" +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SRC = ROOT / "lib" / "instance" / "templates" / "index.html" +OUT = ROOT / "lib" / "instance" / "templates" / "embed_page_fragment.html" + +GRID_START = "
        " + + +def _find_line(lines: list[str], predicate, *, start: int = 0) -> int: + for idx in range(start, len(lines)): + if predicate(lines[idx]): + return idx + raise SystemExit("marker not found") + + +def main() -> None: + lines = SRC.read_text(encoding="utf-8").splitlines() + macro_start = _find_line(lines, lambda l: "macro period_stats_pane" in l) + macro_end = _find_line(lines, lambda l: l.strip() == "{% endmacro %}", start=macro_start) + macro_body = lines[macro_start : macro_end + 1] + + grid_start = _find_line(lines, lambda l: l == GRID_START) + panel_start = _find_line( + lines, lambda l: l.strip() == "{% if page == 'env_config' %}" + ) + stats_card_line = _find_line(lines, lambda l: 'id="stats-card"' in l) + stats_start = stats_card_line + while stats_start > 0 and lines[stats_start].strip() != "{% if page == 'stats' %}": + stats_start -= 1 + if lines[stats_start].strip() != "{% if page == 'stats' %}": + raise SystemExit("stats if-block not found") + stats_end = _find_line(lines, lambda l: l.strip() == "{% endif %}", start=stats_start + 1) + + grid_block = lines[grid_start + 1 : panel_start] + while grid_block and not grid_block[-1].strip(): + grid_block.pop() + if grid_block and grid_block[-1].strip() == "
        ": + grid_block.pop() + + panel_block = lines[panel_start:stats_start] + stats_block = lines[stats_start : stats_end + 1] + + out_lines = [ + "{# Hub iframe tab fragment — shared via embed_templates #}", + *macro_body, + '
        ', + *grid_block, + "
        ", + *panel_block, + *stats_block, + ] + text = "\n".join(out_lines).rstrip() + "\n" + if "order_rule_tips_tpl" not in text: + text = text.replace( + "{% include 'order_monitor_rule_tips_binance.html' %}", + "{% include order_rule_tips_tpl %}", + ) + OUT.write_text(text, encoding="utf-8") + print("wrote", OUT, "lines", len(out_lines)) + + +if __name__ == "__main__": + main() diff --git a/scripts/build_unified_index.py b/scripts/build_unified_index.py new file mode 100644 index 0000000..261fa15 --- /dev/null +++ b/scripts/build_unified_index.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +"""从 binance index.html 生成三所共用的 lib/instance/templates/index.html.""" +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SRC = ROOT / "lib" / "instance" / "templates" / "index.html" +OUT = ROOT / "lib" / "instance" / "templates" / "index.html" + +TRANSFER_BLOCK = """
        + 划转规则说明 +
        + 划转:自动划转 {{ '开启' if auto_transfer_enabled else '关闭' }}(每天北京时间 {{ auto_transfer_bj_hour }}:00起该整点小时内尝试;账簿按 UTC 自然日去重;将 {{ auto_transfer_to }} 调整至 {{ auto_transfer_amount }}U:不足从 {{ auto_transfer_from }} 划入,超出划回 {{ auto_transfer_from }};持仓中不划转并微信通知) +
        +
        +
        + + + + +
        +""" + + +def main() -> None: + text = SRC.read_text(encoding="utf-8") + + # 外链 CSS 替代内联 style + text = re.sub( + r" \n", + ' \n', + text, + count=1, + flags=re.DOTALL, + ) + + # 顶栏:划转 + 可选 open guard + text = text.replace( + '
        实时价格更新:--(北京时间 UTC+8)
        \n', + " {% include 'instance_top_bar.html' %}\n", + ) + + # 规则条动态 include + text = text.replace( + "{% include 'order_monitor_rule_tips_binance.html' %}", + "{% include order_rule_tips_tpl %}", + ) + + # 下单面板内划转块移除(已上移到顶栏) + if TRANSFER_BLOCK in text: + text = text.replace(TRANSFER_BLOCK, "", 1) + + # 孤儿仓恢复 banner + orphan_block = """ {% if not order and orphan_live_positions %} + {% set o = orphan_live_positions[0] %} +
        + 检测到交易所仍有持仓,本地无对应监控单 + {{ o.exchange_symbol or o.symbol }} · {{ '多' if o.direction == 'long' else '空' }} +
        + + + +
        +
        + {% else %} + + {% endif %}""" + wrapped = "{% if ui_orphan_recovery_enabled %}\n" + orphan_block + "\n {% endif %}" + text = text.replace(orphan_block, wrapped, 1) + + # refreshAccountSnapshot:采用 OKX 版 open_guard 逻辑 + old_can_trade = """ let canTradeText = "可开仓"; + if (!data.can_trade) { + const parts = []; + if (data.risk_status && data.risk_status.can_trade === false && data.risk_status.reason) { + parts.push(data.risk_status.reason); + } + const ac = Number(data.active_count || 0); + const max = Number(data.max_active_positions || {{ max_active_positions }}); + if (ac >= max) parts.push(`持仓 ${ac}/${max}`); + const hard = Number(data.daily_open_hard_limit != null ? data.daily_open_hard_limit : {{ daily_open_hard_limit }}); + const opens = Number(data.opens_today); + if (hard > 0 && !Number.isNaN(opens) && opens >= hard) parts.push(`本交易日开仓 ${opens}/${hard} 已达上限`); + if (!parts.length) parts.push(`未到北京时间 {{ reset_hour }}:00`); + else parts.push(`或未到北京时间 {{ reset_hour }}:00`); + canTradeText = `不可开仓(${parts.join(";")})`; + }""" + new_can_trade = """ let canTradeText = "可开仓"; + if(!data.can_trade){ + const parts = []; + if (data.risk_status && data.risk_status.can_trade === false && data.risk_status.reason) { + parts.push(data.risk_status.reason); + } + if((data.active_count||0) >= (data.max_active_positions||{{ max_active_positions }})) parts.push(`持仓 ${data.active_count}/${data.max_active_positions}`); + const hard = Number(data.daily_open_hard_limit != null ? data.daily_open_hard_limit : {{ daily_open_hard_limit }}); + const opens = Number(data.opens_today); + if (hard > 0 && !Number.isNaN(opens) && opens >= hard) parts.push(`本交易日开仓 ${opens}/${hard} 已达上限`); + if(data.open_guard_blocks_now) parts.push(`未到北京时间 ${data.reset_hour||{{ reset_hour }}}:00`); + canTradeText = parts.length ? `不可开仓(${parts.join(";")})` : "不可开仓"; + }""" + text = text.replace(old_can_trade, new_can_trade, 1) + + guard_sync = """ const allowEl = document.getElementById("allow-open-before-reset"); + const guardStatus = document.getElementById("open-guard-status"); + const resetH = data.reset_hour != null ? data.reset_hour : {{ reset_hour }}; + if(allowEl && typeof data.open_guard_enabled !== "undefined"){ + allowEl.checked = !data.open_guard_enabled; + } + if(guardStatus && typeof data.open_guard_enabled !== "undefined"){ + guardStatus.innerText = data.open_guard_enabled + ? `已限制:${resetH}:00 前不可开仓` + : `已放开:${resetH}:00 前允许开仓`; + }""" + insert_after = """ if(tip){ + tip.innerText = `规则:最多 ${data.max_active_positions || {{ max_active_positions }}} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x;${openCntTxt ? openCntTxt + ";" : ""}${canTradeText}${avail};人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1`; + } + }).catch(()=>{});""" + if guard_sync not in text: + text = text.replace( + insert_after, + insert_after.replace(" }).catch(()=>{});", guard_sync + "\n }).catch(()=>{});"), + 1, + ) + + open_guard_js = """ +const allowOpenBeforeResetEl = document.getElementById("allow-open-before-reset"); +if(allowOpenBeforeResetEl){ + allowOpenBeforeResetEl.addEventListener("change", function(){ + const allow = !!this.checked; + fetch("/api/settings/open_guard", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({enabled: !allow}), + }).then(r=>r.json()).then(data=>{ + if(!data.ok){ alert(data.msg || "保存失败"); return; } + refreshAccountSnapshot(); + }).catch(()=>alert("保存失败")); + }); +} +""" + marker = "const orderSymbolEl = document.getElementById(\"order-symbol\");" + if "allowOpenBeforeResetEl" not in text: + text = text.replace(marker, "{% if ui_open_guard_enabled %}" + open_guard_js + "{% endif %}\n" + marker, 1) + + orphan_fn_guard = "{% if ui_orphan_recovery_enabled %}\n renderOrphanRecoverBanner(data.orphan_live_positions);\n {% endif %}" + text = text.replace( + " renderOrphanRecoverBanner(data.orphan_live_positions);", + orphan_fn_guard, + ) + + header = "{# 三所共用 standalone 主页 — 由 scripts/build_unified_index.py 生成,勿手改三所副本 #}\n" + if not text.startswith("{# 三所共用"): + text = header + text + + OUT.parent.mkdir(parents=True, exist_ok=True) + OUT.write_text(text, encoding="utf-8") + print("wrote", OUT, "lines", len(text.splitlines())) + + +if __name__ == "__main__": + main() diff --git a/scripts/clear_hub_kline_db.py b/scripts/clear_hub_kline_db.py new file mode 100644 index 0000000..06b23b2 --- /dev/null +++ b/scripts/clear_hub_kline_db.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""清空中控 K 线 SQLite 缓存(hub_kline.db),便于清库后全量重拉. + +用法(Linux 云服务器,在仓库根目录): + python3 scripts/clear_hub_kline_db.py --dry-run + python3 scripts/clear_hub_kline_db.py --apply + python3 scripts/clear_hub_kline_db.py --apply --exchange binance --symbol BTC/USDT --timeframe 15m + +默认库路径:环境变量 HUB_KLINE_DB_PATH,或 manual_trading_hub/data/hub_kline.db +""" +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from lib.hub.hub_kline_store import ( # noqa: E402 + clear_all_bars, + clear_series_bars, + default_db_path, + init_db, +) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Clear manual-trading-hub K-line SQLite cache.") + parser.add_argument( + "--db", + default=os.getenv("HUB_KLINE_DB_PATH", "").strip() or str(default_db_path()), + help="hub_kline.db path", + ) + parser.add_argument("--exchange", default="", help="exchange_key, e.g. binance") + parser.add_argument("--symbol", default="", help="symbol, e.g. BTC/USDT") + parser.add_argument("--timeframe", default="", help="optional timeframe, e.g. 15m") + parser.add_argument("--dry-run", action="store_true", help="count only") + parser.add_argument("--apply", action="store_true", help="execute delete") + args = parser.parse_args() + + db_path = Path(args.db) + if not db_path.is_file(): + print(f"DB not found: {db_path}", file=sys.stderr) + return 1 + + init_db(db_path) + ex = (args.exchange or "").strip().lower() + sym = (args.symbol or "").strip().upper() + tf = (args.timeframe or "").strip().lower() or None + + if args.dry_run and not args.apply: + import sqlite3 + + conn = sqlite3.connect(str(db_path)) + try: + if ex and sym: + if tf: + n = conn.execute( + "SELECT COUNT(*) FROM ohlcv_bars WHERE exchange_key=? AND symbol=? AND timeframe=?", + (ex, sym, tf), + ).fetchone()[0] + print(f"would delete series rows: {n} ({ex} {sym} {tf})") + else: + n = conn.execute( + "SELECT COUNT(*) FROM ohlcv_bars WHERE exchange_key=? AND symbol=?", + (ex, sym), + ).fetchone()[0] + print(f"would delete symbol rows: {n} ({ex} {sym} all tf)") + else: + n = conn.execute("SELECT COUNT(*) FROM ohlcv_bars").fetchone()[0] + print(f"would delete all ohlcv_bars rows: {n}") + finally: + conn.close() + return 0 + + if not args.apply: + print("Specify --apply to delete (or --dry-run to preview).", file=sys.stderr) + return 1 + + if ex and sym: + removed = clear_series_bars(ex, sym, tf, db_path) + scope = f"{ex} {sym}" + (f" {tf}" if tf else " (all timeframes)") + print(f"cleared {removed} rows for {scope}") + else: + removed = clear_all_bars(db_path) + print(f"cleared all {removed} ohlcv_bars rows from {db_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/dedupe_strategy_snapshots.py b/scripts/dedupe_strategy_snapshots.py new file mode 100644 index 0000000..27589a5 --- /dev/null +++ b/scripts/dedupe_strategy_snapshots.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""清理 strategy_trade_snapshots 重复行(同计划 + 同结果仅保留 id 最大的一条). + +用法(在实例目录,如 crypto_monitor_gate): + python ../scripts/dedupe_strategy_snapshots.py + python ../scripts/dedupe_strategy_snapshots.py --db crypto.db +""" +from __future__ import annotations + +import argparse +import os +import sqlite3 +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from lib.strategy.strategy_snapshot_lib import dedupe_strategy_snapshots, init_strategy_snapshot_table # noqa: E402 + + +def main() -> int: + parser = argparse.ArgumentParser(description="Dedupe strategy_trade_snapshots rows.") + parser.add_argument( + "--db", + default=os.getenv("DB_PATH", "crypto.db"), + help="SQLite database path (default: DB_PATH or crypto.db)", + ) + parser.add_argument("--dry-run", action="store_true", help="Count only, do not delete") + args = parser.parse_args() + + db_path = Path(args.db) + if not db_path.is_file(): + print(f"DB not found: {db_path}", file=sys.stderr) + return 1 + + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + init_strategy_snapshot_table(conn) + before = conn.execute("SELECT COUNT(*) AS c FROM strategy_trade_snapshots").fetchone()["c"] + dup_groups = conn.execute( + """SELECT strategy_type, source_id, result_label, COUNT(*) AS n + FROM strategy_trade_snapshots + GROUP BY strategy_type, source_id, result_label + HAVING n > 1 + ORDER BY n DESC""" + ).fetchall() + extra = sum(int(r["n"]) - 1 for r in dup_groups) + print(f"snapshots total={before}, duplicate rows to remove={extra}, groups={len(dup_groups)}") + for r in dup_groups[:20]: + print( + f" {r['strategy_type']} plan={r['source_id']} " + f"{r['result_label']} x{r['n']}" + ) + if args.dry_run: + conn.close() + return 0 + removed = dedupe_strategy_snapshots(conn) + conn.commit() + after = conn.execute("SELECT COUNT(*) AS c FROM strategy_trade_snapshots").fetchone()["c"] + conn.close() + print(f"removed={removed}, remaining={after}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/diag_render_pages.py b/scripts/diag_render_pages.py new file mode 100644 index 0000000..a89e585 --- /dev/null +++ b/scripts/diag_render_pages.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Diagnose page render errors (run inside instance dir with venv).""" +from __future__ import annotations + +import os +import sys +import traceback + + +def main() -> int: + inst = sys.argv[1] if len(sys.argv) > 1 else "crypto_monitor_binance" + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + os.chdir(os.path.join(root, inst)) + sys.path.insert(0, os.getcwd()) + from app import app # noqa: WPS433 + + paths = ["/trade", "/key_monitor", "/strategy", "/login"] + with app.test_client() as client: + with client.session_transaction() as sess: + sess["logged_in"] = True + for path in paths: + try: + resp = client.get(path) + print(f"{inst} {path} -> {resp.status_code}") + if resp.status_code >= 400: + body = resp.get_data(as_text=True) + print(body[:3000]) + except Exception: + print(f"{inst} {path} -> EXCEPTION") + traceback.print_exc() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/extract_instance_page_assets.py b/scripts/extract_instance_page_assets.py new file mode 100644 index 0000000..caff54e --- /dev/null +++ b/scripts/extract_instance_page_assets.py @@ -0,0 +1,49 @@ +"""One-off: extract instance_page.css / instance_page_boot.js from gate index.html.""" +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +src = ROOT / "crypto_monitor_gate" / "templates" / "index.html" +text = src.read_text(encoding="utf-8") + +m = re.search(r"", text, re.S) +if m: + (ROOT / "lib" / "common" / "static" / "instance_page.css").write_text(m.group(1).strip() + "\n", encoding="utf-8") + +marker = '' +if marker in text: + part = text.split(marker, 1)[1] + m2 = re.search(r"\s*", part, re.S) + if m2: + boot = m2.group(1).strip() + boot = boot.replace( + "setInterval(refreshAccountSnapshot, {{ balance_refresh_seconds * 1000 }});", + "setInterval(refreshAccountSnapshot, Number(document.body.dataset.balanceRefreshMs || 30000));", + ) + boot = boot.replace( + "setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }});", + "setInterval(refreshPriceSnapshotConditional, Number(document.body.dataset.priceRefreshMs || 5000));", + ) + (ROOT / "lib" / "common" / "static" / "instance_page_boot.js").write_text(boot + "\n", encoding="utf-8") + + part2 = text.split(marker, 1)[1] + m3 = re.search(r"\s*", part2, re.S) + if m3: + boot_tpl = m3.group(1).strip() + boot_tpl = boot_tpl.replace( + "setInterval(refreshAccountSnapshot, {{ balance_refresh_seconds * 1000 }});", + "setInterval(refreshAccountSnapshot, Number(document.body.dataset.balanceRefreshMs || 30000));", + ) + boot_tpl = boot_tpl.replace( + "setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }});", + "setInterval(refreshPriceSnapshotConditional, Number(document.body.dataset.priceRefreshMs || 5000));", + ) + embed_dir = ROOT / "lib" / "instance" / "templates" + embed_dir.mkdir(exist_ok=True) + (embed_dir / "embed_boot_scripts.html").write_text( + "\n", encoding="utf-8" + ) + +print("done") diff --git a/scripts/fix_trend_handoff_monitor_type.py b/scripts/fix_trend_handoff_monitor_type.py new file mode 100644 index 0000000..258f98d --- /dev/null +++ b/scripts/fix_trend_handoff_monitor_type.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""修正趋势保本移交后 monitor_type 仍为「下单监控」的历史数据.""" +from __future__ import annotations + +import argparse +import sqlite3 +from pathlib import Path + +from lib.strategy.strategy_trade_labels import MONITOR_TYPE_TREND_PULLBACK + + +def main() -> int: + parser = argparse.ArgumentParser(description="Fix trend handoff order/trade monitor_type labels.") + parser.add_argument("--db", required=True, help="Path to instance sqlite db") + parser.add_argument("--dry-run", action="store_true", help="Preview only") + parser.add_argument("--apply", action="store_true", help="Apply updates") + args = parser.parse_args() + if not args.dry_run and not args.apply: + args.dry_run = True + + db_path = Path(args.db).expanduser().resolve() + if not db_path.is_file(): + print(f"[ERR] DB not found: {db_path}") + return 1 + + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + cur = conn.cursor() + + cur.execute( + """ + SELECT COUNT(*) AS c FROM order_monitors + WHERE trend_plan_id IS NOT NULL AND trend_plan_id > 0 + AND (monitor_type IS NULL OR TRIM(monitor_type) = '' OR monitor_type = '下单监控') + """ + ) + om_n = int(cur.fetchone()["c"]) + cur.execute( + """ + SELECT COUNT(*) AS c FROM trade_records + WHERE trend_plan_id IS NOT NULL AND trend_plan_id > 0 + AND (monitor_type IS NULL OR TRIM(monitor_type) = '' OR monitor_type = '下单监控') + """ + ) + tr_n = int(cur.fetchone()["c"]) + print(f"[INFO] order_monitors to fix: {om_n}") + print(f"[INFO] trade_records to fix: {tr_n}") + + if args.dry_run: + conn.close() + return 0 + + cur.execute( + """ + UPDATE order_monitors + SET monitor_type=? + WHERE trend_plan_id IS NOT NULL AND trend_plan_id > 0 + AND (monitor_type IS NULL OR TRIM(monitor_type) = '' OR monitor_type = '下单监控') + """, + (MONITOR_TYPE_TREND_PULLBACK,), + ) + cur.execute( + """ + UPDATE trade_records + SET monitor_type=? + WHERE trend_plan_id IS NOT NULL AND trend_plan_id > 0 + AND (monitor_type IS NULL OR TRIM(monitor_type) = '' OR monitor_type = '下单监控') + """, + (MONITOR_TYPE_TREND_PULLBACK,), + ) + conn.commit() + conn.close() + print("[OK] Applied.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/generate_brand_icons.py b/scripts/generate_brand_icons.py new file mode 100644 index 0000000..20656e8 --- /dev/null +++ b/scripts/generate_brand_icons.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +"""生成品牌 PNG/ICO(Pillow),供 Chrome 快捷方式与 manifest 使用. + +中控用通用监控图标;三所各自用交易所标识色+字标. +""" +from __future__ import annotations + +import os +import shutil + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +OUT = os.path.join(REPO, "brand", "icons") + +BG = (12, 16, 25, 255) +PANEL = (20, 27, 45, 255) +CYAN = (34, 211, 238, 255) +GREEN = (52, 211, 153, 255) +RED = (248, 113, 113, 255) + +EXCHANGES = { + "binance": { + "label": "B", + "accent": (240, 185, 11, 255), + "panel": (26, 22, 10, 255), + "svg_fill": "#F0B90B", + }, + "okx": { + "label": "OKX", + "accent": (255, 255, 255, 255), + "panel": (18, 18, 18, 255), + "svg_fill": "#FFFFFF", + }, + "gate": { + "label": "G", + "accent": (23, 230, 161, 255), + "panel": (10, 28, 24, 255), + "svg_fill": "#17E6A1", + }, +} + + +def _lerp(c1: tuple[int, ...], c2: tuple[int, ...], t: float) -> tuple[int, int, int, int]: + t = max(0.0, min(1.0, t)) + return tuple(int(c1[i] + (c2[i] - c1[i]) * t) for i in range(4)) # type: ignore + + +def _rounded_rect(draw, box, radius: int, fill) -> None: + draw.rounded_rectangle(box, radius=radius, fill=fill) + + +def _font(size: int): + from PIL import ImageFont + + candidates = [ + os.path.join(os.environ.get("WINDIR", r"C:\Windows"), "Fonts", "arialbd.ttf"), + os.path.join(os.environ.get("WINDIR", r"C:\Windows"), "Fonts", "segoeuib.ttf"), + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", + "/System/Library/Fonts/Supplemental/Arial Bold.ttf", + ] + for path in candidates: + if path and os.path.isfile(path): + try: + return ImageFont.truetype(path, size=size) + except OSError: + continue + return ImageFont.load_default() + + +def render_icon(size: int): + from PIL import Image, ImageDraw + + img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + m = max(6, size // 12) + r = max(8, size // 6) + _rounded_rect(draw, (m, m, size - m, size - m), r, BG) + inner = m + max(2, size // 28) + _rounded_rect(draw, (inner, inner, size - inner, size - inner), max(6, r - 4), PANEL) + + border = max(2, size // 42) + for i in range(border): + t0 = i / max(1, border - 1) + for x in range(inner, size - inner): + t = (x - inner) / max(1, size - 2 * inner) + col = _lerp(CYAN, GREEN, (t + t0) * 0.5) + draw.point((x, inner + i), fill=col) + draw.point((x, size - inner - 1 - i), fill=col) + for y in range(inner, size - inner): + t = (y - inner) / max(1, size - 2 * inner) + col = _lerp(CYAN, GREEN, (t + t0) * 0.5) + draw.point((inner + i, y), fill=col) + draw.point((size - inner - 1 - i, y), fill=col) + + def sx(v: float) -> int: + return int(v * size / 512) + + def sy(v: float) -> int: + return int(v * size / 512) + + pts = [(120, 320), (200, 248), (280, 272), (392, 168)] + scaled = [(sx(x), sy(y)) for x, y in pts] + draw.line(scaled, fill=CYAN, width=max(2, size // 26), joint="curve") + ex, ey = scaled[-1] + draw.ellipse( + (ex - size // 28, ey - size // 28, ex + size // 28, ey + size // 28), + fill=GREEN, + ) + + def candle(cx, top, bottom, body_top, body_bottom, color): + w = max(1, size // 64) + bh = max(2, size // 32) + draw.line((cx, top, cx, bottom), fill=color, width=w) + draw.rounded_rectangle( + (cx - bh, body_top, cx + bh, body_bottom), + radius=max(1, bh // 3), + fill=color, + ) + + candle(sx(182), sy(248), sy(340), sy(268), sy(332), RED) + candle(sx(282), sy(200), sy(340), sy(220), sy(316), GREEN) + + return img + + +def render_exchange_icon(size: int, key: str): + from PIL import Image, ImageDraw + + cfg = EXCHANGES[key] + accent = cfg["accent"] + panel = cfg["panel"] + label = cfg["label"] + + img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + m = max(6, size // 12) + r = max(8, size // 6) + _rounded_rect(draw, (m, m, size - m, size - m), r, BG) + inner = m + max(2, size // 28) + _rounded_rect(draw, (inner, inner, size - inner, size - inner), max(6, r - 4), panel) + + if size >= 32: + border = max(1, size // 48) + for i in range(border): + x0 = inner + i + y0 = inner + i + x1 = size - inner - 1 - i + y1 = size - inner - 1 - i + if x1 <= x0 or y1 <= y0: + break + draw.rounded_rectangle( + (x0, y0, x1, y1), + radius=max(2, r - 4 - i), + outline=accent, + ) + + if key == "binance": + # 币安菱形标识 + cx = cy = size // 2 + s = max(3, int(size * 0.22)) + diamond = [(cx, cy - s), (cx + s, cy), (cx, cy + s), (cx - s, cy)] + draw.polygon(diamond, fill=accent) + s2 = max(1, int(s * 0.42)) + if s2 < s: + inner_d = [(cx, cy - s2), (cx + s2, cy), (cx, cy + s2), (cx - s2, cy)] + draw.polygon(inner_d, fill=panel) + elif key == "okx": + # OKX 四格方块风格(右下留空) + gap = max(1, size // 48) + cell = max(2, int(size * 0.16)) + cx = cy = size // 2 + coords = [ + (cx - cell - gap // 2, cy - cell - gap // 2), + (cx + gap // 2, cy - cell - gap // 2), + (cx - cell - gap // 2, cy + gap // 2), + ] + for x0, y0 in coords: + draw.rectangle((x0, y0, x0 + cell, y0 + cell), fill=accent) + else: + # Gate: 大字 G + font_size = max(10, int(size * 0.42)) + font = _font(font_size) + bbox = draw.textbbox((0, 0), label, font=font) + tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1] + x = (size - tw) // 2 - bbox[0] + y = (size - th) // 2 - bbox[1] - max(0, size // 64) + draw.text((x, y), label, font=font, fill=accent) + + return img + + +def write_exchange_svg(key: str, dest_dir: str) -> None: + cfg = EXCHANGES[key] + fill = cfg["svg_fill"] + if key == "binance": + mark = ( + f'' + f'' + ) + elif key == "okx": + mark = ( + f'' + f'' + f'' + ) + else: + mark = ( + f'G' + ) + svg = f""" + + + + {mark} + +""" + with open(os.path.join(dest_dir, "icon.svg"), "w", encoding="utf-8", newline="\n") as f: + f.write(svg) + + +def _save_set(out_dir: str, render_fn) -> None: + from PIL import Image + + os.makedirs(out_dir, exist_ok=True) + sizes = [16, 32, 48, 180, 192, 512] + images: dict[int, Image.Image] = {} + for sz in sizes: + im = render_fn(sz) + images[sz] = im + name = "apple-touch-icon.png" if sz == 180 else f"icon-{sz}.png" + im.save(os.path.join(out_dir, name), format="PNG", optimize=True) + + ico_sizes = [16, 32, 48] + ico_imgs = [images[s] for s in ico_sizes] + ico_imgs[0].save( + os.path.join(out_dir, "favicon.ico"), + format="ICO", + sizes=[(s, s) for s in ico_sizes], + append_images=ico_imgs[1:], + ) + + +def main() -> None: + os.makedirs(OUT, exist_ok=True) + shutil.copy2(os.path.join(REPO, "brand", "icon.svg"), os.path.join(OUT, "icon.svg")) + _save_set(OUT, render_icon) + print(f"DONE hub {OUT}") + + for key in EXCHANGES: + dest = os.path.join(OUT, key) + os.makedirs(dest, exist_ok=True) + write_exchange_svg(key, dest) + _save_set(dest, lambda sz, k=key: render_exchange_icon(sz, k)) + print(f"DONE {key} {dest}") + + +if __name__ == "__main__": + main() diff --git a/scripts/migrate_to_lib.py b/scripts/migrate_to_lib.py new file mode 100644 index 0000000..d7b2ed3 --- /dev/null +++ b/scripts/migrate_to_lib.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 +"""One-shot: move root shared modules into lib/ and rewrite imports.""" +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + +PACKAGE_FILES: dict[str, list[str]] = { + "strategy": [ + "strategy_config.py", + "strategy_db.py", + "strategy_exchange_base.py", + "strategy_exchange_binance.py", + "strategy_exchange_gate.py", + "strategy_exchange_okx.py", + "strategy_records_register.py", + "strategy_register.py", + "strategy_roll_lib.py", + "strategy_roll_monitor_lib.py", + "strategy_roll_ui_lib.py", + "strategy_snapshot_lib.py", + "strategy_trade_labels.py", + "strategy_trend_exchange.py", + "strategy_trend_lib.py", + "strategy_trend_register.py", + "strategy_ui.py", + "strategy_wechat_notify.py", + ], + "key_monitor": [ + "key_monitor_full_margin_lib.py", + "key_monitor_lib.py", + "key_monitor_schema_lib.py", + "key_sl_tp_lib.py", + "fib_key_monitor_lib.py", + "false_breakout_key_monitor_lib.py", + "trigger_entry_key_monitor_lib.py", + ], + "trade": [ + "trade_result_lib.py", + "trade_exchange_stats_lib.py", + "trade_stats_calendar_lib.py", + "order_monitor_display_lib.py", + "position_sizing_lib.py", + "account_risk_lib.py", + "manual_sltp_lib.py", + "time_close_lib.py", + "daily_open_limit_lib.py", + ], + "hub": [ + "hub_auth.py", + "hub_bridge.py", + "hub_calculator_lib.py", + "hub_calculator_market_lib.py", + "hub_entry_plan_lib.py", + "hub_fund_history_lib.py", + "hub_host_status_lib.py", + "hub_kline_store.py", + "hub_macro_calendar_lib.py", + "hub_market_info_lib.py", + "hub_ohlcv_lib.py", + "hub_position_metrics.py", + "hub_sso.py", + "hub_symbol_archive_lib.py", + "hub_trades_lib.py", + "hub_volume_rank_lib.py", + ], + "ai": [ + "ai_client.py", + "ai_review_lib.py", + ], + "instance": [ + "instance_embed_context_lib.py", + "instance_embed_lib.py", + "instance_nav_lib.py", + "focus_chart_lib.py", + "journal_chart_lib.py", + ], + "exchange": [ + "gate_transfer_lib.py", + "gate_position_history_lib.py", + "okx_orders_lib.py", + ], + "common": [ + "form_submit_lib.py", + "history_window_lib.py", + "wechat_notify_lib.py", + "auto_transfer_daily_lib.py", + ], +} + +DIR_MOVES: list[tuple[str, str]] = [ + ("strategy_templates", "lib/strategy/templates"), + ("embed_templates", "lib/instance/templates"), + ("static", "lib/common/static"), +] + +MODULE_TO_LIB: dict[str, str] = {} +for pkg, files in PACKAGE_FILES.items(): + for fname in files: + MODULE_TO_LIB[fname[:-3]] = f"lib.{pkg}.{fname[:-3]}" + +IMPORT_FROM_RE = re.compile( + r"^(\s*)from\s+(" + "|".join(re.escape(m) for m in sorted(MODULE_TO_LIB, key=len, reverse=True)) + r")\s+import\s+", + re.MULTILINE, +) +IMPORT_BARE_RE = re.compile( + r"^(\s*)import\s+(" + "|".join(re.escape(m) for m in sorted(MODULE_TO_LIB, key=len, reverse=True)) + r")(\s|$)", + re.MULTILINE, +) + + +def git_mv(src: Path, dst: Path) -> None: + dst.parent.mkdir(parents=True, exist_ok=True) + if not src.exists(): + if dst.exists(): + return + raise FileNotFoundError(src) + subprocess.run(["git", "mv", str(src), str(dst)], cwd=ROOT, check=True) + + +def move_files() -> None: + (ROOT / "lib").mkdir(exist_ok=True) + for pkg in PACKAGE_FILES: + (ROOT / "lib" / pkg).mkdir(parents=True, exist_ok=True) + init = ROOT / "lib" / pkg / "__init__.py" + if not init.exists(): + init.write_text('"""Shared library package."""\n', encoding="utf-8") + + lib_init = ROOT / "lib" / "__init__.py" + if not lib_init.exists(): + lib_init.write_text('"""crypto_monitor shared libraries."""\n', encoding="utf-8") + + paths_py = ROOT / "lib" / "paths.py" + if not paths_py.exists(): + paths_py.write_text( + '''"""Repository path helpers for lib/ assets.""" +from __future__ import annotations + +import os +from pathlib import Path + +LIB_DIR = Path(__file__).resolve().parent +REPO_ROOT = LIB_DIR.parent + + +def strategy_templates_dir(repo_root: str | Path | None = None) -> str: + root = Path(repo_root) if repo_root is not None else REPO_ROOT + return str(root / "lib" / "strategy" / "templates") + + +def embed_templates_dir(repo_root: str | Path | None = None) -> str: + root = Path(repo_root) if repo_root is not None else REPO_ROOT + return str(root / "lib" / "instance" / "templates") + + +def common_static_dir(repo_root: str | Path | None = None) -> str: + root = Path(repo_root) if repo_root is not None else REPO_ROOT + return str(root / "lib" / "common" / "static") +''', + encoding="utf-8", + ) + + for pkg, files in PACKAGE_FILES.items(): + for fname in files: + git_mv(ROOT / fname, ROOT / "lib" / pkg / fname) + + for src_rel, dst_rel in DIR_MOVES: + git_mv(ROOT / src_rel, ROOT / dst_rel) + + +def rewrite_imports_in_text(text: str) -> str: + def from_repl(m: re.Match) -> str: + mod = m.group(2) + return f"{m.group(1)}from {MODULE_TO_LIB[mod]} import " + + def bare_repl(m: re.Match) -> str: + mod = m.group(2) + return f"{m.group(1)}import {MODULE_TO_LIB[mod]}{m.group(3)}" + + text = IMPORT_FROM_RE.sub(from_repl, text) + text = IMPORT_BARE_RE.sub(bare_repl, text) + return text + + +def patch_path_literals(text: str) -> str: + replacements = [ + ('os.path.join(repo_root, "strategy_templates")', 'strategy_templates_dir(repo_root)'), + ('os.path.join(repo_root, "embed_templates")', 'embed_templates_dir(repo_root)'), + ('os.path.join(os.path.dirname(BASE_DIR), "static")', 'common_static_dir(os.path.dirname(BASE_DIR))'), + ('_REPO_ROOT / "static"', '_REPO_ROOT / "lib" / "common" / "static"'), + ('ROOT / "strategy_templates"', 'ROOT / "lib" / "strategy" / "templates"'), + ('ROOT / "embed_templates"', 'ROOT / "lib" / "instance" / "templates"'), + ('ROOT / "static"', 'ROOT / "lib" / "common" / "static"'), + ] + for old, new in replacements: + text = text.replace(old, new) + return text + + +def ensure_paths_import(text: str, filepath: Path) -> str: + needs = [] + if "strategy_templates_dir(" in text and "from lib.paths import" not in text: + needs.append("strategy_templates_dir") + if "embed_templates_dir(" in text and "from lib.paths import" not in text: + needs.append("embed_templates_dir") + if "common_static_dir(" in text and "from lib.paths import" not in text: + needs.append("common_static_dir") + if not needs: + return text + imp = f"from lib.paths import {', '.join(sorted(set(needs)))}\n" + if text.startswith('"""') or text.startswith("'''"): + end = text.find('"""', 3) if text.startswith('"""') else text.find("'''", 3) + if end != -1: + end += 3 + return text[:end] + "\n\n" + imp + text[end + 1 :] + if text.startswith("from __future__"): + lines = text.splitlines(keepends=True) + i = 0 + while i < len(lines) and ( + lines[i].startswith("from __future__") or lines[i].strip() == "" + ): + i += 1 + return "".join(lines[:i]) + imp + "".join(lines[i:]) + return imp + text + + +def rewrite_all_py_files() -> None: + skip = {ROOT / "scripts" / "migrate_to_lib.py"} + for path in ROOT.rglob("*.py"): + if path in skip or ".venv" in path.parts or "__pycache__" in path.parts: + continue + original = path.read_text(encoding="utf-8") + updated = rewrite_imports_in_text(original) + updated = patch_path_literals(updated) + updated = ensure_paths_import(updated, path) + if updated != original: + path.write_text(updated, encoding="utf-8") + + +def main() -> int: + move_files() + rewrite_all_py_files() + print("Migration complete.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/normalize_ambiguous_unicode.py b/scripts/normalize_ambiguous_unicode.py new file mode 100644 index 0000000..fe95a0f --- /dev/null +++ b/scripts/normalize_ambiguous_unicode.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""将全角/易混淆标点规范为半角 ASCII(注释, 文档, 配置模板). + +不转换弯引号 “ ” ‘ ’,避免破坏 Python/JS 字符串字面量. +""" +from __future__ import annotations + +import argparse +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] + +SKIP_DIRS = frozenset({ + ".git", + ".venv", + "node_modules", + "__pycache__", + ".cursor", + "agent-transcripts", +}) + +SCAN_SUFFIXES = frozenset({ + ".py", + ".js", + ".html", + ".md", + ".sh", + ".css", + ".json", + ".cjs", + ".txt", + ".yml", + ".yaml", + ".example", +}) + +AMBIGUOUS_CHARS = frozenset( + "\uff08\uff09\uff1a\uff0c\uff1b\uff1f\uff01\u3002\u3001\u00a0" +) + +TRANSLATION = str.maketrans( + { + "\uff08": "(", + "\uff09": ")", + "\uff1a": ":", + "\uff0c": ",", + "\uff1b": ";", + "\uff1f": "?", + "\uff01": "!", + "\u3002": ".", + "\u3001": ",", + "\u00a0": " ", + } +) + + +def should_scan(path: Path) -> bool: + if not path.is_file(): + return False + if any(part in SKIP_DIRS for part in path.parts): + return False + if path.name == ".env.example" or path.name.endswith(".env.example"): + return True + return path.suffix in SCAN_SUFFIXES + + +def normalize_text(text: str) -> tuple[str, int]: + count = sum(1 for ch in text if ch in AMBIGUOUS_CHARS) + if not count: + return text, 0 + return text.translate(TRANSLATION), count + + +def read_text_strip_bom(path: Path) -> tuple[str, bool]: + raw = path.read_text(encoding="utf-8") + if raw.startswith("\ufeff"): + return raw.lstrip("\ufeff"), True + return raw, False + + +def iter_targets(root: Path) -> list[Path]: + return sorted(p for p in root.rglob("*") if should_scan(p)) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Normalize ambiguous Unicode punctuation") + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--root", default=str(REPO)) + args = parser.parse_args() + + root = Path(args.root) + files_changed = 0 + chars_changed = 0 + + for path in iter_targets(root): + try: + original, had_bom = read_text_strip_bom(path) + except (OSError, UnicodeDecodeError): + continue + normalized, n = normalize_text(original) + if not n and not had_bom: + continue + rel = path.relative_to(root) + if args.dry_run: + print(f"[dry-run] {rel}: {n} chars") + else: + # 保持原换行风格, 仅替换标点 + path.write_text(normalized, encoding="utf-8", newline="") + files_changed += 1 + chars_changed += n + + print(f"done: {files_changed} files, {chars_changed} replacements") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/one_shot_backup_config_before_cleanup.py b/scripts/one_shot_backup_config_before_cleanup.py new file mode 100644 index 0000000..0ca36b8 --- /dev/null +++ b/scripts/one_shot_backup_config_before_cleanup.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +""" +一次性备份:三所 .env + 中控 .env / hub_settings.json(不含图片,不含数据库). + +用途:删除 gate,清库,全新计划启动前,在仓库根目录执行一次即可: + + python scripts/one_shot_backup_config_before_cleanup.py + +输出目录默认:backups/one-shot-YYYYMMDD-HHMMSS/config/ +""" +from __future__ import annotations + +import shutil +import sys +from datetime import datetime +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] + +CONFIG_SOURCES: list[tuple[str, Path]] = [ + ("crypto_monitor_binance.env", REPO_ROOT / "crypto_monitor_binance" / ".env"), + ("crypto_monitor_okx.env", REPO_ROOT / "crypto_monitor_okx" / ".env"), + ("crypto_monitor_gate.env", REPO_ROOT / "crypto_monitor_gate" / ".env"), + ("manual_trading_hub.env", REPO_ROOT / "manual_trading_hub" / ".env"), + ("hub_settings.json", REPO_ROOT / "manual_trading_hub" / "hub_settings.json"), +] + +ENV_BACKUP_GLOBS = ( + REPO_ROOT / "crypto_monitor_binance", + REPO_ROOT / "crypto_monitor_okx", + REPO_ROOT / "crypto_monitor_gate", +) + + +def main() -> int: + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + out_dir = REPO_ROOT / "backups" / f"one-shot-{stamp}" / "config" + out_dir.mkdir(parents=True, exist_ok=True) + + copied: list[str] = [] + missing: list[str] = [] + + for dest_name, src in CONFIG_SOURCES: + if src.is_file(): + shutil.copy2(src, out_dir / dest_name) + copied.append(dest_name) + else: + missing.append(str(src.relative_to(REPO_ROOT))) + + for inst_dir in ENV_BACKUP_GLOBS: + for src in sorted(inst_dir.glob(".env.backup.*")): + dest_name = f"{inst_dir.name}.{src.name}" + shutil.copy2(src, out_dir / dest_name) + copied.append(dest_name) + + manifest = out_dir.parent / "manifest.txt" + lines = [ + f"created_at={stamp}", + f"repo={REPO_ROOT}", + "", + "copied:", + *[f" - {name}" for name in copied], + "", + "missing (skipped):", + *[f" - {p}" for p in missing], + "", + "not included: crypto.db, hub *.db, static/images, gate", + ] + manifest.write_text("\n".join(lines) + "\n", encoding="utf-8") + + print(f"Backup written to: {out_dir}") + if copied: + print("Copied:", ", ".join(copied)) + if missing: + print("Missing (ok if fresh install):", ", ".join(missing)) + print(f"Manifest: {manifest}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/patch_entry_model_instances.py b/scripts/patch_entry_model_instances.py new file mode 100644 index 0000000..b5ea64b --- /dev/null +++ b/scripts/patch_entry_model_instances.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +"""Patch binance/okx/gate app.py for entry_model support.""" +from __future__ import annotations + +import os +import re + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +IMPORT_BLOCK = """from lib.trade.entry_model_lib import ( + build_intraday_entry_reason_options, + build_trend_div_entry_reason_options, + enrich_entry_model_display, + migrate_entry_model_columns, + order_entry_template_context, + parse_manual_order_style_fields, + resolve_trade_record_entry_reason, + trend_manual_entry_reason_count, +) +""" + +KEY_IMPORT = "from lib.key_monitor.key_auto_order_lib import (\n check_monitor_type_add_allowed,\n effective_entry_reason_options,\n effective_stats_segment_defs,\n load_key_auto_order_enabled," + +KEY_IMPORT_WITH_KEY_OPTS = "from lib.key_monitor.key_auto_order_lib import (\n KEY_ENTRY_REASON_OPTIONS,\n check_monitor_type_add_allowed,\n effective_entry_reason_options,\n effective_stats_segment_defs,\n load_key_auto_order_enabled," + + +def patch_file(path: str, exchange: str) -> bool: + with open(path, "r", encoding="utf-8") as f: + text = f.read() + orig = text + + if "from lib.trade.entry_model_lib import" not in text: + text = text.replace( + "from lib.trade.trade_policy_app_lib import (", + IMPORT_BLOCK + "from lib.trade.trade_policy_app_lib import (", + 1, + ) + + if exchange == "gate": + old_er = '''# 与用户约定的固定开仓类型 +ENTRY_REASON_OPTIONS = ( + "趋势多头:4h大结构突破前进场,确认条件:三次探顶,5m收敛不创新低", + "趋势空头:4h大结构突破前进场,确认条件:三次探底,5m收敛不创新高", + "趋势多头:小分歧低吸入场(左侧),确认条件:二次探底", + "趋势空头:小分歧高吸入场(左侧),确认条件:二次探顶", + "波段单:5m顺势突破,确认条件:2根k线+成交量放大+4h同向+日成交量前20", + "关键位箱体突破", + "关键位收敛突破", + "关键位斐波0.618", + "关键位斐波0.786", + "关键位假突破", + "关键位回调触价开仓", + "关键位突破触价开仓", +) + STRATEGY_ENTRY_REASON_OPTIONS''' + new_er = """# 日内户:长句开仓类型 + 关键位 + 策略(大分歧 A/B/小分歧 仅趋势户) +ENTRY_REASON_OPTIONS = build_intraday_entry_reason_options( + KEY_ENTRY_REASON_OPTIONS, + STRATEGY_ENTRY_REASON_OPTIONS, +)""" + text = text.replace(old_er, new_er) + if "KEY_ENTRY_REASON_OPTIONS," not in text.split("load_key_auto_order_enabled")[0]: + text = text.replace(KEY_IMPORT, KEY_IMPORT_WITH_KEY_OPTS, 1) + else: + old_er = '''# 与用户约定的固定开仓类型(仅做这几类单子) +ENTRY_REASON_OPTIONS = ( + "趋势多头:4h大结构突破前进场,确认条件:三次探顶,5m收敛不创新低", + "趋势空头:4h大结构突破前进场,确认条件:三次探底,5m收敛不创新高", + "趋势多头:小分歧低吸入场(左侧),确认条件:二次探底", + "趋势空头:小分歧高吸入场(左侧),确认条件:二次探顶", + "波段单:5m顺势突破,确认条件:2根k线+成交量放大+4h同向+日成交量前20", + "关键位箱体突破", + "关键位收敛突破", + "关键位斐波0.618", + "关键位斐波0.786", + "关键位假突破", + "关键位回调触价开仓", + "关键位突破触价开仓", +) + STRATEGY_ENTRY_REASON_OPTIONS''' + new_er = """# 趋势户:大分歧A/B/小分歧 + 策略(关键位本实例关闭) +ENTRY_REASON_OPTIONS = build_trend_div_entry_reason_options(STRATEGY_ENTRY_REASON_OPTIONS)""" + text = text.replace(old_er, new_er) + + if "migrate_entry_model_columns(conn)" not in text: + text = text.replace( + " conn.commit()\n conn.close()\n\n\ndef get_db", + " migrate_entry_model_columns(conn)\n conn.commit()\n conn.close()\n\n\ndef get_db", + 1, + ) + + text = re.sub( + r" er = \(\n \(entry_reason or \"\"\)\.strip\(\)\n or entry_reason_from_key_signal\(kst\)\n or entry_reason_for_monitor_type\(monitor_type\)\n or \"\"\n \)", + """ er = resolve_trade_record_entry_reason( + entry_reason=entry_reason, + entry_model=entry_model, + key_signal_type=kst, + monitor_type=monitor_type, + entry_reason_from_key_signal=entry_reason_from_key_signal, + entry_reason_for_monitor_type=entry_reason_for_monitor_type, + )""", + text, + count=1, + ) + + if "entry_model=None," not in text: + text = text.replace( + " entry_reason=None,\n trend_plan_id=None,", + " entry_reason=None,\n entry_model=None,\n trend_plan_id=None,", + 1, + ) + + if "enrich_entry_model_display(item)" not in text: + text = text.replace( + " enrich_order_display_fields(item, calc_rr_ratio)\n try:", + " enrich_order_display_fields(item, calc_rr_ratio)\n enrich_entry_model_display(item)\n try:", + 1, + ) + + text = text.replace( + """ 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()""", + """ trade_style, entry_model, style_err = parse_manual_order_style_fields( + TRADE_POLICY, d, default_trade_style=DEFAULT_TRADE_STYLE or "trend" + ) + if style_err: + conn.close() + flash(style_err) + return redirect("/trade") + available_usdt = get_available_trading_usdt()""", + 1, + ) + + old_insert = ( + '"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, time_close_enabled, time_close_hours, time_close_at_ms) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",\n' + " (\n" + " symbol, exchange_symbol, direction, trigger_price, stop_loss, stop_loss, take_profit,\n" + " margin_capital, leverage, trade_style, risk_percent_db, risk_amount_final, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, 0, breakeven_price,\n" + " breakeven_enabled,\n" + " notional_value, position_ratio, base_amount, amount, open_order_id, opened_at_bj, opened_at_ms, trading_day,\n" + " ORDER_MONITOR_TYPE_MANUAL,\n" + " tc_en, tc_h, tc_at,\n" + " )" + ) + new_insert = ( + '"INSERT INTO order_monitors (symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, margin_capital, leverage, trade_style, entry_model, 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, time_close_enabled, time_close_hours, time_close_at_ms) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",\n' + " (\n" + " symbol, exchange_symbol, direction, trigger_price, stop_loss, stop_loss, take_profit,\n" + " margin_capital, leverage, trade_style, entry_model, risk_percent_db, risk_amount_final, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, 0, breakeven_price,\n" + " breakeven_enabled,\n" + " notional_value, position_ratio, base_amount, amount, open_order_id, opened_at_bj, opened_at_ms, trading_day,\n" + " ORDER_MONITOR_TYPE_MANUAL,\n" + " tc_en, tc_h, tc_at,\n" + " )" + ) + text = text.replace(old_insert, new_insert) + + text = text.replace( + """ effective_entry_reason_options( + ENTRY_REASON_OPTIONS, + POSITION_SIZING_MODE, + KEY_AUTO_ORDER_ENABLED, + )""", + """ effective_entry_reason_options( + ENTRY_REASON_OPTIONS, + POSITION_SIZING_MODE, + KEY_AUTO_ORDER_ENABLED, + trend_manual_count=trend_manual_entry_reason_count(TRADE_POLICY), + )""", + 1, + ) + + if "**order_entry_template_context(TRADE_POLICY)," not in text: + text = text.replace( + " trade_policy=trade_policy_template_context(TRADE_POLICY),", + " trade_policy=trade_policy_template_context(TRADE_POLICY),\n **order_entry_template_context(TRADE_POLICY),", + 1, + ) + + # insert_trade_record from order row: add entry_model + text = re.sub( + r"(insert_trade_record\(\n\s+conn,\n(?:[^\n]+\n)+?\s+trade_style=r\[\"trade_style\"\],\n)", + r"\1 entry_model=(r[\"entry_model\"] if \"entry_model\" in r.keys() else None),\n", + text, + ) + + if text != orig: + with open(path, "w", encoding="utf-8", newline="\n") as f: + f.write(text) + return True + return False + + +def main(): + for ex in ("binance", "okx", "gate"): + path = os.path.join(REPO, f"crypto_monitor_{ex}", "app.py") + changed = patch_file(path, ex) + print(f"{ex}: {'patched' if changed else 'no change'}") + + +if __name__ == "__main__": + main() diff --git a/scripts/patch_instance_theme_templates.py b/scripts/patch_instance_theme_templates.py new file mode 100644 index 0000000..d6f8351 --- /dev/null +++ b/scripts/patch_instance_theme_templates.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""为四所 templates 注入 instance_theme 脚本/样式与切换按钮.""" +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +EXCHANGES = ("crypto_monitor_binance", "crypto_monitor_okx", "crypto_monitor_gate") +FILES = ("index.html", "login.html", "key_focus_v2.html", "order_focus_v2.html") + +SCRIPT_TAG = ' \n' +CSS_LINK = ' \n' + +THEME_TOGGLE = """
        + + +
        +""" + +INDEX_HEADER_OLD = """
        +

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

        +
        {{ exchange_display }}
        +
        """ + +INDEX_HEADER_NEW = """
        +

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

        +
        +
        {{ exchange_display }}
        +""" + THEME_TOGGLE + """
        +
        """ + + +def patch_file(path: Path) -> bool: + text = path.read_text(encoding="utf-8") + orig = text + if 'data-theme="dark"' not in text: + text = text.replace('', '', 1) + if "/static/instance_theme.js" not in text: + text = text.replace( + "", + "\n" + SCRIPT_TAG.strip() + "\n", + 1, + ) + if "/static/instance_theme.css" not in text: + text = text.replace("", "\n" + CSS_LINK, 1) + if path.name == "index.html" and INDEX_HEADER_OLD in text and "instance-theme-toggle" not in text: + text = text.replace(INDEX_HEADER_OLD, INDEX_HEADER_NEW) + if path.name == "login.html" and "instance-theme-toggle" not in text: + text = text.replace( + "", + '\n", + 1, + ) + if path.name == "key_focus_v2.html" and "instance-theme-toggle" not in text: + marker = '
        ' + if marker in text: + text = text.replace( + marker, + marker + "\n " + THEME_TOGGLE.replace("\n", "\n "), + 1, + ) + if path.name == "order_focus_v2.html" and "instance-theme-toggle" not in text: + marker = '
        ' + if marker in text: + text = text.replace( + marker, + marker + "\n " + THEME_TOGGLE.replace("\n", "\n "), + 1, + ) + if text != orig: + path.write_text(text, encoding="utf-8") + return True + return False + + +def main() -> None: + n = 0 + for ex in EXCHANGES: + for fn in FILES: + p = ROOT / ex / "templates" / fn + if p.is_file() and patch_file(p): + print("patched", p.relative_to(ROOT)) + n += 1 + print("done", n, "files") + + +if __name__ == "__main__": + main() diff --git a/scripts/patch_position_sizing_to_exchanges.py b/scripts/patch_position_sizing_to_exchanges.py new file mode 100644 index 0000000..17c083c --- /dev/null +++ b/scripts/patch_position_sizing_to_exchanges.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +"""一次性:为 okx/gate 注入与 binance 一致的计仓模式补丁(已 patch 过则跳过).""" +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +IMPORT_BLOCK = '''from position_sizing_lib import ( + OPEN_SOURCE_KEY_AUTO, + OPEN_SOURCE_MANUAL, + assert_open_source_allowed, + compute_full_margin_sizing, + full_margin_requires_flat_position, + is_full_margin_mode, + leverage_for_full_margin, + load_position_sizing_mode, + mode_label_zh, +) +from lib.key_monitor.key_monitor_full_margin_lib import ( + monitor_type_disallowed_in_full_margin, + purge_disallowed_key_monitors, +) +''' + +ENV_LINE = ( + "# 计仓模式:risk=以损定仓(默认);full_margin=合约可用×比例全仓杠杆(仅 env 切换,须无仓)\n" + "POSITION_SIZING_MODE = load_position_sizing_mode()\n" +) + +PURGE_FN = ''' + +def _purge_key_monitors_if_full_margin(): + if not is_full_margin_mode(POSITION_SIZING_MODE): + return + conn = get_db() + try: + cancel = globals().get("_cancel_fib_monitor_limit") + if not callable(cancel): + cancel = lambda _row: None + purge_disallowed_key_monitors( + conn, + sizing_mode=POSITION_SIZING_MODE, + select_rows=lambda c: c.execute("SELECT * FROM key_monitors").fetchall(), + cancel_fib_limit=cancel, + delete_monitor=lambda c, kid: c.execute("DELETE FROM key_monitors WHERE id=?", (kid,)), + send_wechat=send_wechat_msg, + ) + conn.commit() + except Exception as e: + print(f"[full_margin] purge key monitors: {e}", flush=True) + finally: + conn.close() + + +''' + +MARKET_OPEN_GUARD = ''' ok_src, src_msg = assert_open_source_allowed(POSITION_SIZING_MODE, OPEN_SOURCE_KEY_AUTO) + if not ok_src: + return False, src_msg, None +''' + +ADD_KEY_GUARD = ''' if is_full_margin_mode(POSITION_SIZING_MODE) and monitor_type_disallowed_in_full_margin(mt): + flash( + "全仓杠杆模式下不可添加箱体/收敛突破或斐波监控;" + "请改用阻力/支撑(仅提醒),或切换 POSITION_SIZING_MODE=risk 并重启(须无持仓)." + ) + return redirect("/key_monitor") +''' + +TEMPLATE_RULE = '''
        + 计仓模式:{{ position_sizing_mode_label }}(仅 .env POSITION_SIZING_MODE,须无仓后重启) + {% if position_sizing_mode == 'full_margin' %} + |全仓:合约可用×{{ full_margin_buffer_ratio }},BTC/ETH {{ btc_leverage }}x,其它 {{ alt_leverage }}x,单仓;张数按交易所精度 + {% else %} + |以损定仓:风险 {{ risk_percent }}% + {% endif %} + |移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}% +
        ''' + +APPS = [ + ("crypto_monitor_okx", 4, "_market_open_for_key_monitor", True), + ("crypto_monitor_gate", 2, "_market_open_for_key_monitor", True), +] + + +def patch_app(app_dir: str, funds_dec: int, market_fn: str | None, has_fib: bool): + path = ROOT / app_dir / "app.py" + text = path.read_text(encoding="utf-8") + if "POSITION_SIZING_MODE" in text: + print(f"SKIP {app_dir}/app.py (already patched)") + return + if "from position_sizing_lib import" not in text: + anchor = "from key_monitor_lib import (" + if anchor not in text: + anchor = "from form_submit_lib import" + text = text.replace( + anchor, + IMPORT_BLOCK + "\n" + anchor, + 1, + ) + else: + text = text.replace(anchor, IMPORT_BLOCK + anchor, 1) + if "POSITION_SIZING_MODE = load_position_sizing_mode()" not in text: + text = text.replace( + "AUTO_TRANSFER_BJ_HOUR = int(os.getenv(\"AUTO_TRANSFER_BJ_HOUR\", \"8\"))\n", + "AUTO_TRANSFER_BJ_HOUR = int(os.getenv(\"AUTO_TRANSFER_BJ_HOUR\", \"8\"))\n" + ENV_LINE, + 1, + ) + if "_purge_key_monitors_if_full_margin" not in text: + text = text.replace("init_db()\n\n\ndef get_db():", "init_db()" + PURGE_FN + "\ndef get_db():", 1) + text = text.replace( + "install_strategy_trend(app,", + "_purge_key_monitors_if_full_margin()\n\ninstall_strategy_trend(app,", + 1, + ) + if market_fn and MARKET_OPEN_GUARD.strip() not in text: + text = text.replace( + f"def {market_fn}(\n", + f"def {market_fn}(\n", + 1, + ) + text = text.replace( + ' """\n 与手动', + MARKET_OPEN_GUARD + ' """\n 与手动', + 1, + ) + # fallback: after docstring closing + if MARKET_OPEN_GUARD.strip() not in text: + pat = rf"(def {market_fn}\([^)]+\):\s*\n\s*\"\"\"[^\"\"]*\"\"\"\s*\n)" + text = re.sub(pat, r"\1" + MARKET_OPEN_GUARD, text, count=1) + if has_fib and ADD_KEY_GUARD.strip() not in text: + text = text.replace( + ' if mt not in allowed_types:', + ADD_KEY_GUARD + ' if mt not in allowed_types:', + 1, + ) if "if mt not in allowed_types:" in text else text.replace( + ' rank, total = _daily_volume_rank(symbol)', + ADD_KEY_GUARD + ' rank, total = _daily_volume_rank(symbol)', + 1, + ) + # render_template risk_percent= add template vars + if "position_sizing_mode=POSITION_SIZING_MODE" not in text: + text = text.replace( + "risk_percent=RISK_PERCENT,\n", + "risk_percent=RISK_PERCENT,\n" + " position_sizing_mode=POSITION_SIZING_MODE,\n" + " position_sizing_mode_label=mode_label_zh(POSITION_SIZING_MODE),\n" + " open_position_button_label=(\n" + ' "开仓(全仓杠杆)" if is_full_margin_mode(POSITION_SIZING_MODE) else "开仓(以损定仓)"\n' + " ),\n", + 1, + ) + path.write_text(text, encoding="utf-8") + print(f"DONE {app_dir}/app.py (partial — verify add_order block manually if needed)") + + +def patch_template(app_dir: str): + tpl = ROOT / app_dir / "templates" / "index.html" + if not tpl.exists(): + return + text = tpl.read_text(encoding="utf-8") + if "position_sizing_mode_label" in text: + print(f"SKIP {tpl}") + return + old = re.search( + r'
        \s*以损定仓:风险 \{\{ risk_percent \}\}%.*?
        ', + text, + re.S, + ) + if old: + text = text[: old.start()] + TEMPLATE_RULE + text[old.end() :] + text = text.replace( + '', + '', + ) + text = text.replace( + '', + '{% if position_sizing_mode != \'full_margin\' %}\n' + ' \n' + ' {% endif %}', + 1, + ) + tpl.write_text(text, encoding="utf-8") + print(f"DONE {tpl}") + + +def main(): + for app_dir, funds, mfn, fib in APPS: + patch_app(app_dir, funds, mfn, fib) + patch_template(app_dir) + + +if __name__ == "__main__": + main() diff --git a/scripts/sync_brand_icons.py b/scripts/sync_brand_icons.py new file mode 100644 index 0000000..a5ec5e7 --- /dev/null +++ b/scripts/sync_brand_icons.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +""" +将 brand/icons 同步到中控与各所 static/icons(Chrome 快捷方式 / 标签页图标). + +用法(仓库根目录): + python scripts/generate_brand_icons.py + python scripts/sync_brand_icons.py +""" +from __future__ import annotations + +import os +import shutil + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SRC = os.path.join(REPO, "brand", "icons") + +HUB_DEST = os.path.join(REPO, "manual_trading_hub", "static", "icons") +EXCHANGES = ( + ("crypto_monitor_binance", "binance", "manifest.binance.webmanifest"), + ("crypto_monitor_okx", "okx", "manifest.okx.webmanifest"), + ("crypto_monitor_gate", "gate", "manifest.gate.webmanifest"), +) + +FILES = ( + "icon.svg", + "favicon.ico", + "icon-16.png", + "icon-32.png", + "icon-192.png", + "icon-512.png", + "apple-touch-icon.png", +) + + +def sync_dir(src_dir: str, dest: str, url_prefix: str, manifest_template: str) -> str: + if not os.path.isdir(src_dir): + return f"SKIP {dest}: 缺少 {src_dir},请先运行 python scripts/generate_brand_icons.py" + os.makedirs(dest, exist_ok=True) + for name in FILES: + src = os.path.join(src_dir, name) + if not os.path.isfile(src): + return f"SKIP {dest}: 缺少 {src}" + shutil.copy2(src, os.path.join(dest, name)) + manifest_src = os.path.join(REPO, "brand", manifest_template) + if os.path.isfile(manifest_src): + with open(manifest_src, encoding="utf-8") as f: + text = f.read().replace("__ICON_PREFIX__", url_prefix) + with open( + os.path.join(dest, "manifest.webmanifest"), + "w", + encoding="utf-8", + newline="\n", + ) as f: + f.write(text) + return f"DONE {dest}" + + +def main() -> None: + print(sync_dir(SRC, HUB_DEST, "/assets/icons", "manifest.webmanifest")) + for folder, key, manifest in EXCHANGES: + dest = os.path.join(REPO, folder, "static", "icons") + src_dir = os.path.join(SRC, key) + print(sync_dir(src_dir, dest, "/static/icons", manifest)) + + +if __name__ == "__main__": + main() diff --git a/scripts/sync_common_trading_env.py b/scripts/sync_common_trading_env.py new file mode 100644 index 0000000..9186e4e --- /dev/null +++ b/scripts/sync_common_trading_env.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +""" +将三所共用的交易/关键位/轮询 env 写入币安,OKX 的 .env(缺失则追加,不覆盖已有值). + +以 Gate .env.example 为基准;Gate 自身也可运行以补缺失项. + +用法(仓库根目录): + python scripts/sync_common_trading_env.py + python scripts/sync_common_trading_env.py --dry-run + python scripts/sync_common_trading_env.py --instances crypto_monitor_okx + +修改后须 pm2 restart 对应实例.说明见 docs/env-sync-scripts.md +""" +from __future__ import annotations + +import argparse +import os +import re + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +DEFAULT_INSTANCES = ( + "crypto_monitor_binance", + "crypto_monitor_okx", +) + +# 与 crypto_monitor_gate/.env.example 对齐(不含 GATE_* / 各所 API 密钥) +SHARED_DEFAULTS: dict[str, str] = { + "TRADING_DAY_RESET_OPEN_GUARD_ENABLED": "true", + "KEY_CONFIRM_BREAKOUT_BAR": "-2", + "KEY_CONFIRM_BAR": "-1", + "KEY_VOLUME_MA_BARS": "20", + "KEY_VOLUME_RATIO_MIN": "1.3", + "KEY_BREAKOUT_AMP_MIN_PCT": "0.03", + "KEY_BREAKOUT_AMP_MAX_PCT": "0.5", + "KEY_ALERT_MAX_TIMES": "3", + "KEY_ALERT_INTERVAL_MINUTES": "5", + "KEY_DAILY_VOLUME_RANK_MAX": "30", + "KEY_AUTO_MIN_PLANNED_RR": "1.5", + "KEY_STOP_OUTSIDE_BREAKOUT_PCT": "0.5", + "KEY_TREND_STOP_OUTSIDE_PCT": "1", + "MAX_ACTIVE_POSITIONS": "1", + "MANUAL_MIN_PLANNED_RR": "1.4", + "KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT": "true", + "DAILY_OPEN_ALERT_THRESHOLD": "5", + "DAILY_OPEN_HARD_LIMIT": "0", + "BALANCE_REFRESH_SECONDS": "60", + "PRICE_REFRESH_SECONDS": "5", + "MONITOR_POLL_SECONDS": "3", + "RECONCILE_STARTUP_GRACE_SEC": "90", + "RECONCILE_FLAT_CONFIRM_POLLS": "3", + "FULL_MARGIN_BUFFER_RATIO": "0.98", + "WECHAT_TIMEOUT_SECONDS": "10", + "AI_TIMEOUT_SECONDS": "120", +} + +# 仅当某实例 .env 缺少 FORCE_CLOSE_* 时补默认: +# Gate 默认开 0 点强制清仓;币安/OKX 默认关.已有手调值绝不覆盖. +FORCE_CLOSE_POLICY: dict[str, dict[str, str]] = { + "crypto_monitor_gate": { + "FORCE_CLOSE_ENABLED": "true", + "FORCE_CLOSE_BJ_HOUR": "0", + }, + "crypto_monitor_binance": { + "FORCE_CLOSE_ENABLED": "false", + "FORCE_CLOSE_BJ_HOUR": "0", + }, + "crypto_monitor_okx": { + "FORCE_CLOSE_ENABLED": "false", + "FORCE_CLOSE_BJ_HOUR": "0", + }, +} + + +def _parse_env(path: str) -> list[str]: + if not os.path.isfile(path): + return [] + with open(path, "r", encoding="utf-8", errors="ignore") as f: + return f.read().replace("\r\n", "\n").replace("\r", "\n").splitlines() + + +def _env_get(lines: list[str], key: str) -> str | None: + pat = re.compile(r"^\s*" + re.escape(key) + r"\s*=\s*(.*)\s*$") + for line in lines: + m = pat.match(line) + if m: + return m.group(1).strip().strip('"').strip("'") + return None + + +def _upsert(lines: list[str], key: str, value: str) -> list[str]: + pat = re.compile(r"^\s*" + re.escape(key) + r"\s*=") + out: list[str] = [] + replaced = False + for line in lines: + if pat.match(line): + if not replaced: + out.append(f"{key}={value}") + replaced = True + continue + out.append(line) + if not replaced: + if out and out[-1].strip(): + out.append("") + out.append(f"{key}={value}") + return out + + +def sync_one(dir_name: str, *, dry_run: bool, force: bool) -> bool: + path = os.path.join(REPO, dir_name, ".env") + if not os.path.isfile(path): + print(f"skip (no .env): {dir_name}") + return False + lines = _parse_env(path) + added: list[str] = [] + for key, val in SHARED_DEFAULTS.items(): + cur = _env_get(lines, key) + if cur is None or (force and cur != val): + lines = _upsert(lines, key, val) + added.append(key) + if not added: + print(f"ok (unchanged): {dir_name}") + return False + print(f"update: {dir_name}") + for key in added: + print(f" + {key}={SHARED_DEFAULTS[key]}") + if not dry_run: + text = "\n".join(lines).rstrip() + "\n" + with open(path, "w", encoding="utf-8", newline="\n") as f: + f.write(text) + return True + + +def apply_force_close_policy(*, dry_run: bool) -> bool: + """仅在 FORCE_CLOSE_* 缺失时补默认值;已有手调值绝不覆盖.""" + any_changed = False + for dir_name, values in FORCE_CLOSE_POLICY.items(): + path = os.path.join(REPO, dir_name, ".env") + if not os.path.isfile(path): + print(f"skip (no .env): {dir_name}") + continue + lines = _parse_env(path) + added_keys: list[str] = [] + for key, val in values.items(): + cur = _env_get(lines, key) + if cur is None: + lines = _upsert(lines, key, val) + added_keys.append(key) + if not added_keys: + print(f"ok (force-close unchanged): {dir_name}") + continue + any_changed = True + print(f"force-close fill-missing: {dir_name}") + for key in added_keys: + print(f" + {key}={values[key]}") + if not dry_run: + text = "\n".join(lines).rstrip() + "\n" + with open(path, "w", encoding="utf-8", newline="\n") as f: + f.write(text) + return any_changed + + +def main() -> None: + ap = argparse.ArgumentParser(description="同步币安/OKX 共用 trading env(缺失项追加)") + ap.add_argument("--dry-run", action="store_true") + ap.add_argument("--force", action="store_true", help="覆盖已有值(慎用)") + ap.add_argument( + "--apply-force-close-policy", + action="store_true", + help="仅补全缺失的 FORCE_CLOSE_* 默认值(不覆盖手调)", + ) + ap.add_argument( + "--instances", + nargs="+", + metavar="DIR", + help="默认 crypto_monitor_binance crypto_monitor_okx", + ) + args = ap.parse_args() + + instances = tuple(args.instances) if args.instances else DEFAULT_INSTANCES + any_changed = False + for inst in instances: + if sync_one(inst, dry_run=args.dry_run, force=args.force): + any_changed = True + if args.apply_force_close_policy: + if apply_force_close_policy(dry_run=args.dry_run): + any_changed = True + if args.dry_run and any_changed: + print("(dry-run, 未写入)") + + +if __name__ == "__main__": + main() diff --git a/scripts/sync_four_exchange_env.py b/scripts/sync_four_exchange_env.py new file mode 100644 index 0000000..4f8273a --- /dev/null +++ b/scripts/sync_four_exchange_env.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +""" +三所 .env 一次性同步:计仓模式 + 自动划转(调用子脚本,不覆盖已有自定义值). + +用法(仓库根目录): + python scripts/sync_four_exchange_env.py + python scripts/sync_four_exchange_env.py --dry-run + python scripts/sync_four_exchange_env.py --set-transfer-amount 50 --enable-auto-transfer + +子脚本可单独运行: + python scripts/sync_four_exchange_position_sizing_env.py + python scripts/sync_four_exchange_transfer_env.py + +完整说明见 docs/env-sync-scripts.md +""" +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +PY = sys.executable + + +def _run(script: str, extra: list[str]) -> int: + cmd = [PY, str(REPO / "scripts" / script)] + extra + print(f"\n>>> {' '.join(cmd)}") + return subprocess.call(cmd, cwd=str(REPO)) + + +def main(): + ap = argparse.ArgumentParser(description="三所 .env 统一同步(计仓 + 划转)") + ap.add_argument("--dry-run", action="store_true") + ap.add_argument("--set-mode", choices=("risk", "full_margin"), metavar="MODE") + ap.add_argument("--set-transfer-amount", metavar="U") + ap.add_argument("--enable-auto-transfer", action="store_true") + args = ap.parse_args() + + dry = ["--dry-run"] if args.dry_run else [] + code = 0 + + ps_args = list(dry) + if args.set_mode: + ps_args.extend(["--set-mode", args.set_mode]) + code |= _run("sync_four_exchange_position_sizing_env.py", ps_args) + + tr_args = list(dry) + if args.set_transfer_amount: + tr_args.extend(["--set-amount", args.set_transfer_amount]) + if args.enable_auto_transfer: + tr_args.append("--enable-auto-transfer") + code |= _run("sync_four_exchange_transfer_env.py", tr_args) + + sys.exit(code) + + +if __name__ == "__main__": + main() diff --git a/scripts/sync_four_exchange_position_sizing_env.py b/scripts/sync_four_exchange_position_sizing_env.py new file mode 100644 index 0000000..22fbb77 --- /dev/null +++ b/scripts/sync_four_exchange_position_sizing_env.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +""" +将计仓模式相关项写入三所实例 .env(已存在则保留原值,缺失则追加默认值). + +用法(仓库根目录): + python scripts/sync_four_exchange_position_sizing_env.py + python scripts/sync_four_exchange_position_sizing_env.py --dry-run + python scripts/sync_four_exchange_position_sizing_env.py --set-mode risk + python scripts/sync_four_exchange_position_sizing_env.py --set-mode full_margin + +切换 POSITION_SIZING_MODE 须在交易所无持仓后执行,并 pm2 restart 对应实例. +不修改 API 密钥与其它自定义项;若 .env 不存在则跳过(请先从 .env.example 复制). + +完整说明见 docs/env-sync-scripts.md +""" +from __future__ import annotations + +import argparse +import os +import re + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +INSTANCES = ( + "crypto_monitor_binance", + "crypto_monitor_okx", + "crypto_monitor_gate", +) + +COMMENT_POSITION_SIZING = ( + "# 计仓:risk=以损定仓(默认);full_margin=合约可用×FULL_MARGIN_BUFFER_RATIO 全仓杠杆(须无仓后重启)" +) +COMMENT_BUFFER = "# 使用可用资金时的缓冲比例(如0.98代表用98%)" + +DEFAULT_MODE = "risk" +DEFAULT_BUFFER = "0.98" +VALID_MODES = frozenset({"risk", "full_margin"}) + + +def _parse_env(path: str) -> list[str]: + if not os.path.isfile(path): + return [] + with open(path, "r", encoding="utf-8", errors="ignore") as f: + return f.read().replace("\r\n", "\n").replace("\r", "\n").splitlines() + + +def _env_get(lines: list[str], key: str) -> str | None: + pat = re.compile(r"^\s*" + re.escape(key) + r"\s*=\s*(.*)\s*$") + for line in lines: + m = pat.match(line) + if m: + return m.group(1).strip().strip('"').strip("'") + return None + + +def _upsert(lines: list[str], key: str, value: str) -> list[str]: + pat = re.compile(r"^\s*" + re.escape(key) + r"\s*=") + out = [] + replaced = False + for line in lines: + if pat.match(line): + if not replaced: + out.append(f"{key}={value}") + replaced = True + continue + out.append(line) + if not replaced: + if out and out[-1].strip(): + out.append("") + out.append(f"{key}={value}") + return out + + +def _insert_before(lines: list[str], anchor_key: str, insert: list[str]) -> list[str]: + pat = re.compile(r"^\s*" + re.escape(anchor_key) + r"\s*=") + for i, line in enumerate(lines): + if pat.match(line): + return lines[:i] + insert + lines[i:] + if lines and lines[-1].strip(): + return lines + [""] + insert + return lines + insert + + +def _ensure_position_sizing(lines: list[str], *, force_mode: str | None) -> list[str]: + if force_mode is not None: + if COMMENT_POSITION_SIZING not in lines and not _env_get(lines, "POSITION_SIZING_MODE"): + lines = _insert_before(lines, "DAILY_START_CAPITAL", [COMMENT_POSITION_SIZING]) + return _upsert(lines, "POSITION_SIZING_MODE", force_mode) + + cur = _env_get(lines, "POSITION_SIZING_MODE") + if cur is not None: + norm = cur.strip().lower() + if norm in VALID_MODES and norm != cur: + return _upsert(lines, "POSITION_SIZING_MODE", norm) + if norm not in VALID_MODES: + return _upsert(lines, "POSITION_SIZING_MODE", DEFAULT_MODE) + return lines + + block = [COMMENT_POSITION_SIZING, f"POSITION_SIZING_MODE={DEFAULT_MODE}"] + return _insert_before(lines, "DAILY_START_CAPITAL", block) + + +def _ensure_buffer_ratio(lines: list[str], *, force_buffer: str | None) -> list[str]: + if force_buffer is not None: + if COMMENT_BUFFER not in lines and _env_get(lines, "FULL_MARGIN_BUFFER_RATIO") is None: + lines = _insert_before(lines, "BALANCE_REFRESH_SECONDS", [COMMENT_BUFFER]) + return _upsert(lines, "FULL_MARGIN_BUFFER_RATIO", force_buffer) + + if _env_get(lines, "FULL_MARGIN_BUFFER_RATIO") is not None: + return lines + + block = [COMMENT_BUFFER, f"FULL_MARGIN_BUFFER_RATIO={DEFAULT_BUFFER}"] + return _insert_before(lines, "BALANCE_REFRESH_SECONDS", block) + + +def sync_one( + dir_name: str, + dry_run: bool, + *, + set_mode: str | None, + set_buffer: str | None, +) -> str: + env_path = os.path.join(REPO, dir_name, ".env") + if not os.path.isfile(env_path): + return f"SKIP {dir_name}: 无 .env(请 cp .env.example .env)" + old_lines = _parse_env(env_path) + new_lines = _ensure_buffer_ratio( + _ensure_position_sizing(list(old_lines), force_mode=set_mode), + force_buffer=set_buffer, + ) + mode = _env_get(new_lines, "POSITION_SIZING_MODE") or DEFAULT_MODE + buf = _env_get(new_lines, "FULL_MARGIN_BUFFER_RATIO") or DEFAULT_BUFFER + if new_lines == old_lines: + return f"OK {dir_name}: POSITION_SIZING_MODE={mode} FULL_MARGIN_BUFFER_RATIO={buf}" + if dry_run: + return ( + f"DRY {dir_name}: 将写入 POSITION_SIZING_MODE={mode} " + f"FULL_MARGIN_BUFFER_RATIO={buf}" + ) + with open(env_path, "w", encoding="utf-8", newline="\n") as f: + f.write("\n".join(new_lines)) + if new_lines and new_lines[-1].strip(): + f.write("\n") + return f"DONE {dir_name}: POSITION_SIZING_MODE={mode} FULL_MARGIN_BUFFER_RATIO={buf}" + + +def main(): + ap = argparse.ArgumentParser(description="三所 .env 计仓模式项同步") + ap.add_argument("--dry-run", action="store_true", help="仅打印将做的变更") + ap.add_argument( + "--set-mode", + choices=sorted(VALID_MODES), + metavar="MODE", + help="强制三所 POSITION_SIZING_MODE(须无仓后重启)", + ) + ap.add_argument( + "--set-buffer", + metavar="RATIO", + help=f"强制三所 FULL_MARGIN_BUFFER_RATIO(缺省追加为 {DEFAULT_BUFFER})", + ) + args = ap.parse_args() + if args.set_mode: + print( + f"注意:将 POSITION_SIZING_MODE 设为 {args.set_mode}," + "请确认交易所无持仓后再 restart." + ) + for name in INSTANCES: + print( + sync_one( + name, + args.dry_run, + set_mode=args.set_mode, + set_buffer=args.set_buffer, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/sync_four_exchange_transfer_env.py b/scripts/sync_four_exchange_transfer_env.py new file mode 100644 index 0000000..fafe461 --- /dev/null +++ b/scripts/sync_four_exchange_transfer_env.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +""" +将每日自动划转相关项写入三所实例 .env(已有值保留,缺失则追加;可选强制改金额/开关). + +用法(仓库根目录): + python scripts/sync_four_exchange_transfer_env.py + python scripts/sync_four_exchange_transfer_env.py --dry-run + python scripts/sync_four_exchange_transfer_env.py --set-amount 50 + python scripts/sync_four_exchange_transfer_env.py --enable-auto-transfer + +不修改 API 密钥与其它自定义项;若 .env 不存在则跳过(请先从 .env.example 复制). + +完整说明见 docs/env-sync-scripts.md +""" +from __future__ import annotations + +import argparse +import os +import re + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +INSTANCES = ( + "crypto_monitor_binance", + "crypto_monitor_okx", + "crypto_monitor_gate", +) + +COMMENT_BLOCK = ( + "# 自动划转:北京时间 AUTO_TRANSFER_BJ_HOUR 点将 swap 调整至 AUTO_TRANSFER_AMOUNT;" + "不足 funding→swap,超出 swap→funding;持仓中不划转" +) + +DEFAULTS = { + "AUTO_TRANSFER_ENABLED": "false", + "AUTO_TRANSFER_FROM": "funding", + "AUTO_TRANSFER_TO": "swap", + "TRANSFER_CCY": "USDT", + "AUTO_TRANSFER_BJ_HOUR": "8", +} + +DEFAULT_AMOUNT = "50" + +BINANCE_ONLY = { + "BINANCE_FUNDING_INCLUDE_SPOT": "false", +} + + +def _parse_env(path: str) -> list[str]: + if not os.path.isfile(path): + return [] + with open(path, "r", encoding="utf-8", errors="ignore") as f: + return f.read().replace("\r\n", "\n").replace("\r", "\n").splitlines() + + +def _env_get(lines: list[str], key: str) -> str | None: + pat = re.compile(r"^\s*" + re.escape(key) + r"\s*=\s*(.*)\s*$") + for line in lines: + m = pat.match(line) + if m: + return m.group(1).strip().strip('"').strip("'") + return None + + +def _upsert(lines: list[str], key: str, value: str) -> list[str]: + pat = re.compile(r"^\s*" + re.escape(key) + r"\s*=") + out = [] + replaced = False + for line in lines: + if pat.match(line): + if not replaced: + out.append(f"{key}={value}") + replaced = True + continue + out.append(line) + if not replaced: + if out and out[-1].strip(): + out.append("") + out.append(f"{key}={value}") + return out + + +def _insert_before(lines: list[str], anchor_key: str, insert: list[str]) -> list[str]: + pat = re.compile(r"^\s*" + re.escape(anchor_key) + r"\s*=") + for i, line in enumerate(lines): + if pat.match(line): + return lines[:i] + insert + lines[i:] + if lines and lines[-1].strip(): + return lines + [""] + insert + return lines + insert + + +def _resolve_default_amount(lines: list[str]) -> str: + amount = _env_get(lines, "AUTO_TRANSFER_AMOUNT") + if amount is not None: + return amount + daily = _env_get(lines, "DAILY_START_CAPITAL") + if daily is not None: + return daily + return DEFAULT_AMOUNT + + +def _ensure_key( + lines: list[str], + key: str, + value: str, + *, + force: bool, +) -> list[str]: + if force or _env_get(lines, key) is None: + return _upsert(lines, key, value) + return lines + + +def _ensure_transfer_block( + lines: list[str], + extra: dict[str, str], + *, + force_amount: str | None, + force_enabled: str | None, +) -> list[str]: + amount = force_amount if force_amount is not None else _resolve_default_amount(lines) + had_amount = _env_get(lines, "AUTO_TRANSFER_AMOUNT") is not None + + if not had_amount and COMMENT_BLOCK not in lines: + lines = _insert_before( + lines, + "AUTO_TRANSFER_ENABLED", + [COMMENT_BLOCK], + ) + if _env_get(lines, "AUTO_TRANSFER_ENABLED") is None: + lines = _insert_before( + lines, + "BALANCE_REFRESH_SECONDS", + [COMMENT_BLOCK], + ) + + lines = _ensure_key( + lines, + "AUTO_TRANSFER_AMOUNT", + amount, + force=force_amount is not None, + ) + for k, v in DEFAULTS.items(): + if k == "AUTO_TRANSFER_ENABLED" and force_enabled is not None: + lines = _upsert(lines, k, force_enabled) + else: + lines = _ensure_key(lines, k, v, force=False) + for k, v in extra.items(): + lines = _ensure_key(lines, k, v, force=False) + return lines + + +def sync_one( + dir_name: str, + dry_run: bool, + *, + set_amount: str | None, + enable_auto: bool | None, +) -> str: + env_path = os.path.join(REPO, dir_name, ".env") + if not os.path.isfile(env_path): + return f"SKIP {dir_name}: 无 .env(请 cp .env.example .env)" + old_lines = _parse_env(env_path) + extra = dict(BINANCE_ONLY) if dir_name == "crypto_monitor_binance" else {} + force_enabled = "true" if enable_auto is True else None + new_lines = _ensure_transfer_block( + old_lines, + extra, + force_amount=set_amount, + force_enabled=force_enabled, + ) + enabled = _env_get(new_lines, "AUTO_TRANSFER_ENABLED") or DEFAULTS["AUTO_TRANSFER_ENABLED"] + amt = _env_get(new_lines, "AUTO_TRANSFER_AMOUNT") or DEFAULT_AMOUNT + if new_lines == old_lines: + return f"OK {dir_name}: ENABLED={enabled} AMOUNT={amt}" + if dry_run: + return f"DRY {dir_name}: 将更新 ENABLED={enabled} AMOUNT={amt}" + with open(env_path, "w", encoding="utf-8", newline="\n") as f: + f.write("\n".join(new_lines)) + if new_lines and new_lines[-1].strip(): + f.write("\n") + return f"DONE {dir_name}: ENABLED={enabled} AMOUNT={amt}" + + +def main(): + ap = argparse.ArgumentParser(description="三所 .env 自动划转项同步") + ap.add_argument("--dry-run", action="store_true") + ap.add_argument( + "--set-amount", + metavar="U", + help=f"强制三所 AUTO_TRANSFER_AMOUNT(缺省补全默认 {DEFAULT_AMOUNT})", + ) + ap.add_argument( + "--enable-auto-transfer", + action="store_true", + help="强制三所 AUTO_TRANSFER_ENABLED=true", + ) + args = ap.parse_args() + for name in INSTANCES: + print( + sync_one( + name, + args.dry_run, + set_amount=args.set_amount, + enable_auto=True if args.enable_auto_transfer else None, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/sync_trade_policy_env.py b/scripts/sync_trade_policy_env.py new file mode 100644 index 0000000..6ea8ec1 --- /dev/null +++ b/scripts/sync_trade_policy_env.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +""" +将账户方向 / 币种白名单 env 写入三所 .env(缺失则追加,已存在则 --set 时覆盖). + +用法(仓库根目录): + python scripts/sync_trade_policy_env.py + python scripts/sync_trade_policy_env.py --dry-run + python scripts/sync_trade_policy_env.py --apply-account-profiles + python scripts/sync_trade_policy_env.py --set-direction binance long_only + +--apply-account-profiles:币安=仅多,Gate=BTC/ETH 白名单,OKX=默认不限制. +修改后须 pm2 restart 对应实例. +""" +from __future__ import annotations + +import argparse +import os +import re + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +INSTANCES = ( + "crypto_monitor_binance", + "crypto_monitor_okx", + "crypto_monitor_gate", +) + +COMMENT_BLOCK = [ + "# 方向限制(默认 false=双向均可;true 时按 TRADE_DIRECTION 限制,修改后须重启)", + "# TRADE_DIRECTION=long_only | short_only | both(或 多/空/双向)", + "# 币种白名单(默认 false=全币种可手输;true 时关键位/下单/策略仅下拉选择)", +] + +DEFAULTS = { + "TRADE_DIRECTION_RESTRICT_ENABLED": "false", + "TRADE_DIRECTION": "both", + "TRADE_SYMBOL_RESTRICT_ENABLED": "false", + "TRADE_SYMBOL_WHITELIST": "BTC,ETH", +} + +ACCOUNT_PROFILES = { + "crypto_monitor_binance": { + "TRADE_DIRECTION_RESTRICT_ENABLED": "true", + "TRADE_DIRECTION": "long_only", + "TRADE_SYMBOL_RESTRICT_ENABLED": "false", + "TRADE_SYMBOL_WHITELIST": "BTC,ETH", + }, + "crypto_monitor_gate": { + "TRADE_DIRECTION_RESTRICT_ENABLED": "false", + "TRADE_DIRECTION": "both", + "TRADE_SYMBOL_RESTRICT_ENABLED": "true", + "TRADE_SYMBOL_WHITELIST": "BTC,ETH", + }, + "crypto_monitor_okx": { + "TRADE_DIRECTION_RESTRICT_ENABLED": "false", + "TRADE_DIRECTION": "both", + "TRADE_SYMBOL_RESTRICT_ENABLED": "false", + "TRADE_SYMBOL_WHITELIST": "BTC,ETH", + }, +} + + +def _parse_env(path: str) -> list[str]: + if not os.path.isfile(path): + return [] + with open(path, "r", encoding="utf-8", errors="ignore") as f: + return f.read().replace("\r\n", "\n").replace("\r", "\n").splitlines() + + +def _env_get(lines: list[str], key: str) -> str | None: + pat = re.compile(r"^\s*" + re.escape(key) + r"\s*=\s*(.*)\s*$") + for line in lines: + m = pat.match(line) + if m: + return m.group(1).strip().strip('"').strip("'") + return None + + +def _upsert(lines: list[str], key: str, value: str) -> list[str]: + pat = re.compile(r"^\s*" + re.escape(key) + r"\s*=") + out: list[str] = [] + replaced = False + for line in lines: + if pat.match(line): + if not replaced: + out.append(f"{key}={value}") + replaced = True + continue + out.append(line) + if not replaced: + if out and out[-1].strip(): + out.append("") + out.append(f"{key}={value}") + return out + + +def _insert_after(lines: list[str], anchor_key: str, insert: list[str]) -> list[str]: + pat = re.compile(r"^\s*" + re.escape(anchor_key) + r"\s*=") + for i, line in enumerate(lines): + if pat.match(line): + return lines[: i + 1] + insert + lines[i + 1 :] + if lines and lines[-1].strip(): + return lines + [""] + insert + return lines + insert + + +def sync_one( + dir_name: str, + values: dict[str, str], + *, + dry_run: bool, + force: bool, +) -> bool: + path = os.path.join(REPO, dir_name, ".env") + if not os.path.isfile(path): + print(f"skip (no .env): {dir_name}") + return False + lines = _parse_env(path) + changed = False + for key, val in values.items(): + cur = _env_get(lines, key) + if cur is None: + if key == "TRADE_DIRECTION_RESTRICT_ENABLED" and _env_get( + lines, "TRADE_DIRECTION" + ) is None: + if COMMENT_BLOCK[0] not in "\n".join(lines): + lines = _insert_after(lines, "POSITION_SIZING_MODE", COMMENT_BLOCK) + lines = _upsert(lines, key, val) + changed = True + elif force or cur != val: + lines = _upsert(lines, key, val) + changed = True + if not changed: + print(f"ok (unchanged): {dir_name}") + return False + text = "\n".join(lines).rstrip() + "\n" + print(f"update: {dir_name}") + for k, v in values.items(): + print(f" {k}={v}") + if not dry_run: + with open(path, "w", encoding="utf-8", newline="\n") as f: + f.write(text) + return True + + +def main() -> None: + ap = argparse.ArgumentParser(description="同步三所 trade policy env") + ap.add_argument("--dry-run", action="store_true") + ap.add_argument( + "--apply-account-profiles", + action="store_true", + help="币安仅多,Gate BTC/ETH,OKX 默认", + ) + ap.add_argument( + "--defaults-only", + action="store_true", + help="三所均写入默认(不限制)", + ) + ap.add_argument("--force", action="store_true", help="覆盖已有值") + ap.add_argument("--set-direction", nargs=2, metavar=("INSTANCE", "MODE")) + ap.add_argument("--set-symbol-whitelist", nargs=2, metavar=("INSTANCE", "SYMS")) + args = ap.parse_args() + + if args.set_direction: + inst, mode = args.set_direction + if inst not in INSTANCES: + raise SystemExit(f"unknown instance: {inst}") + sync_one( + inst, + { + "TRADE_DIRECTION_RESTRICT_ENABLED": "true", + "TRADE_DIRECTION": mode, + }, + dry_run=args.dry_run, + force=True, + ) + return + + if args.set_symbol_whitelist: + inst, syms = args.set_symbol_whitelist + if inst not in INSTANCES: + raise SystemExit(f"unknown instance: {inst}") + sync_one( + inst, + { + "TRADE_SYMBOL_RESTRICT_ENABLED": "true", + "TRADE_SYMBOL_WHITELIST": syms, + }, + dry_run=args.dry_run, + force=True, + ) + return + + profiles = ( + {k: dict(DEFAULTS) for k in INSTANCES} + if args.defaults_only + else dict(ACCOUNT_PROFILES) + if args.apply_account_profiles + else {k: dict(DEFAULTS) for k in INSTANCES} + ) + + if not args.apply_account_profiles and not args.defaults_only: + ap.print_help() + print("\n提示:部署常用 --apply-account-profiles") + return + + any_changed = False + for inst in INSTANCES: + if sync_one(inst, profiles[inst], dry_run=args.dry_run, force=args.force): + any_changed = True + if args.dry_run and any_changed: + print("(dry-run, 未写入)") + + +if __name__ == "__main__": + main() diff --git a/scripts/verify_hub_embed_auth.py b/scripts/verify_hub_embed_auth.py new file mode 100644 index 0000000..9cd3829 --- /dev/null +++ b/scripts/verify_hub_embed_auth.py @@ -0,0 +1,48 @@ +"""验证中控 embed-auth 与 login 返回 session_token.""" +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "manual_trading_hub")) +sys.path.insert(0, str(ROOT)) + +from fastapi.testclient import TestClient + +import os + +os.environ.setdefault("HUB_PASSWORD", "test-pass") +os.environ.setdefault("HUB_USERNAME", "admin") +os.environ["HUB_ALLOW_PUBLIC"] = "true" + +import hub as hub_mod # noqa: E402 + +client = TestClient(hub_mod.app) + + +def main() -> int: + r = client.post("/api/auth/login", json={"username": "admin", "password": "test-pass"}) + assert r.status_code == 200, r.text + data = r.json() + assert data.get("ok") is True, data + token = data.get("session_token") + assert token, "login 应返回 session_token" + + r2 = client.get(f"/embed-auth?token={token}&next=/monitor", follow_redirects=False) + assert r2.status_code in (302, 307), r2.status_code + assert r2.headers.get("location", "").endswith("/monitor") + assert hub_mod.SESSION_COOKIE in r2.headers.get("set-cookie", "") + + r3 = client.get("/monitor", cookies={hub_mod.SESSION_COOKIE: token}) + assert r3.status_code == 200, r3.status_code + + csp = client.get("/login").headers.get("content-security-policy", "") + assert "frame-ancestors" in csp, csp + + print("OK: embed-auth sets session cookie; login returns session_token") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_okx_trend_sl.py b/scripts/verify_okx_trend_sl.py new file mode 100644 index 0000000..8952730 --- /dev/null +++ b/scripts/verify_okx_trend_sl.py @@ -0,0 +1,62 @@ +"""验证 OKX 趋势回调止损挂单:须为 stopLossPrice 条件单,不得为立即市价平仓.""" +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "crypto_monitor_okx")) + + +def main() -> int: + captured: list[dict] = [] + + def fake_create_order(symbol, order_type, side, amount, price, params): + captured.append( + { + "symbol": symbol, + "type": order_type, + "side": side, + "amount": amount, + "params": dict(params or {}), + } + ) + return {"id": "test-order", "average": 1.358} + + mock_exchange = MagicMock() + mock_exchange.create_order = fake_create_order + mock_exchange.amount_to_precision = lambda sym, amt: amt + mock_exchange.market = lambda sym: {"contractSize": 1, "limits": {"amount": {"min": 0.01}}} + mock_exchange.load_markets = MagicMock() + mock_exchange.price_to_precision = lambda sym, px: str(px) + + with patch.dict( + "os.environ", + {"LIVE_TRADING_ENABLED": "true", "OKX_API_KEY": "k", "OKX_API_SECRET": "s", "OKX_API_PASSPHRASE": "p"}, + clear=False, + ): + import app as okx_app + + okx_app.exchange = mock_exchange + okx_app.MARKETS_LOADED = True + + with patch.object(okx_app, "ensure_okx_live_ready", return_value=(True, "")), patch.object( + okx_app, "get_live_position_contracts", return_value=12.0 + ), patch.object(okx_app, "cancel_okx_swap_open_orders"): + okx_app._okx_place_stop_loss_only("XRP/USDT:USDT", "long", 1.1) + + assert len(captured) == 1, f"expected 1 create_order call, got {len(captured)}" + call = captured[0] + params = call["params"] + assert call["side"] == "sell", call + assert params.get("reduceOnly") is True, params + assert "stopLossPrice" in params, f"missing stopLossPrice: {params}" + assert params["stopLossPrice"] == 1.1, params + assert "stopLoss" not in params, f"nested stopLoss causes immediate close: {params}" + print("OK: _okx_place_stop_loss_only uses stopLossPrice conditional attach, not immediate close") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_account_risk_lib.py b/tests/test_account_risk_lib.py new file mode 100644 index 0000000..c63d06e --- /dev/null +++ b/tests/test_account_risk_lib.py @@ -0,0 +1,526 @@ +import os +import sqlite3 +import unittest +from datetime import datetime +from unittest import mock +from zoneinfo import ZoneInfo + +from lib.trade.account_risk_lib import ( + CLOSE_SOURCE_USER_HUB, + CLOSE_SOURCE_USER_INSTANCE, + CLOSE_SOURCE_USER_TREND_STOP, + STATUS_DAILY, + STATUS_FREEZE_1H, + STATUS_FREEZE_4H, + STATUS_FREEZE_POSITION, + STATUS_NORMAL, + account_risk_blocks_trading, + apply_position_limit_risk, + compute_account_risk_status, + enrich_risk_status_countdown, + ensure_account_risk_schema, + max_active_positions_from_env, + on_journal_saved, + on_manual_close, + on_user_initiated_close, + parse_mood_issues, +) + +APP_TZ = ZoneInfo("Asia/Shanghai") + + +def _mem_conn(): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + ensure_account_risk_schema(conn) + return conn + + +def _mem_conn_with_journal(): + conn = _mem_conn() + conn.execute( + """CREATE TABLE IF NOT EXISTS journal_entries ( + close_datetime TEXT, early_exit_trigger TEXT, early_exit_note TEXT + )""" + ) + return conn + + +def _local_ms(dt_naive: datetime) -> int: + return int(dt_naive.replace(tzinfo=APP_TZ).timestamp() * 1000) + + +class AccountRiskLibTests(unittest.TestCase): + def setUp(self): + self.env_patch = mock.patch.dict(os.environ, {}, clear=False) + self.env_patch.start() + os.environ["RISK_CONTROL_ENABLED"] = "1" + os.environ["RISK_COOLING_HOURS_MANUAL"] = "4" + os.environ["RISK_COOLING_HOURS_MANUAL_JOURNAL"] = "1" + os.environ["RISK_MANUAL_CLOSE_DAILY_LIMIT"] = "2" + os.environ["RISK_MOOD_ISSUES_DAILY_FREEZE"] = "1" + os.environ["APP_TIMEZONE"] = "Asia/Shanghai" + + def tearDown(self): + self.env_patch.stop() + + def test_user_instance_sets_4h_cooloff(self): + conn = _mem_conn() + now = datetime(2026, 6, 14, 12, 0, 0) + close_ms = _local_ms(now) + on_user_initiated_close( + conn, + source=CLOSE_SOURCE_USER_INSTANCE, + trade_record_id=101, + closed_at_ms=close_ms, + trading_day="2026-06-14", + now=now, + ) + st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now) + self.assertEqual(st["status"], STATUS_FREEZE_4H) + self.assertFalse(st["can_trade"]) + self.assertAlmostEqual(st["freeze_remaining_sec"], 4 * 3600, delta=2) + + def test_invalid_source_ignored(self): + conn = _mem_conn() + now = datetime(2026, 6, 14, 12, 0, 0) + on_user_initiated_close( + conn, + source="exchange_tpsl", + trading_day="2026-06-14", + now=now, + ) + st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now) + self.assertEqual(st["status"], STATUS_NORMAL) + + def test_second_user_close_daily_freeze(self): + conn = _mem_conn() + now = datetime(2026, 6, 14, 12, 0, 0) + close_ms = _local_ms(now) + on_user_initiated_close( + conn, source=CLOSE_SOURCE_USER_HUB, closed_at_ms=close_ms, trading_day="2026-06-14", now=now + ) + on_user_initiated_close( + conn, source=CLOSE_SOURCE_USER_HUB, closed_at_ms=close_ms + 1000, trading_day="2026-06-14", now=now + ) + st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now) + self.assertEqual(st["status"], STATUS_DAILY) + + def test_hub_close_all_count(self): + conn = _mem_conn() + now = datetime(2026, 6, 14, 12, 0, 0) + close_ms = _local_ms(now) + on_user_initiated_close( + conn, + source=CLOSE_SOURCE_USER_HUB, + closed_at_ms=close_ms, + trading_day="2026-06-14", + now=now, + count=2, + ) + st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now) + self.assertEqual(st["manual_close_count"], 2) + self.assertEqual(st["status"], STATUS_DAILY) + + def test_trend_stop_counts_as_manual(self): + conn = _mem_conn() + now = datetime(2026, 6, 14, 12, 0, 0) + on_user_initiated_close( + conn, + source=CLOSE_SOURCE_USER_TREND_STOP, + trading_day="2026-06-14", + now=now, + ) + st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now) + self.assertEqual(st["manual_close_count"], 1) + self.assertEqual(st["status"], STATUS_FREEZE_4H) + self.assertAlmostEqual(st["freeze_remaining_sec"], 4 * 3600, delta=2) + + def test_journal_manual_with_note_reduces_to_1h(self): + conn = _mem_conn() + now = datetime(2026, 6, 14, 12, 0, 0) + close_ms = _local_ms(now) + on_manual_close(conn, trade_record_id=9, closed_at_ms=close_ms, trading_day="2026-06-14", now=now) + on_journal_saved( + conn, + early_exit_trigger="手动平仓", + early_exit_note="违反计划提前离场", + mood_issues_raw="", + trading_day="2026-06-14", + now=now, + ) + st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now) + self.assertEqual(st["status"], STATUS_FREEZE_1H) + self.assertAlmostEqual(st["freeze_remaining_sec"], 3600, delta=2) + + def test_journal_hub_close_without_pending_reduces_to_1h(self): + conn = _mem_conn() + now = datetime(2026, 6, 14, 12, 0, 0) + close_ms = _local_ms(now) + on_user_initiated_close( + conn, + source=CLOSE_SOURCE_USER_HUB, + closed_at_ms=close_ms, + trading_day="2026-06-14", + now=now, + ) + on_journal_saved( + conn, + early_exit_trigger="手动平仓", + early_exit_note="中控全平后复盘说明", + mood_issues_raw="", + trading_day="2026-06-14", + now=now, + ) + st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now) + self.assertEqual(st["status"], STATUS_FREEZE_1H) + + def test_journal_reduces_when_manual_count_cleared_but_cooloff_active(self): + conn = _mem_conn() + now = datetime(2026, 6, 15, 10, 0, 0) + now_ms = _local_ms(now) + close_ms = now_ms - 3600 * 1000 + until_ms = close_ms + 4 * 3600 * 1000 + conn.execute( + """UPDATE account_risk_state SET + trading_day='2026-06-15', + manual_close_count=0, + cooloff_until_ms=?, + cooloff_hours=4, + last_close_at_ms=?, + daily_frozen=0 + WHERE id=1""", + (until_ms, close_ms), + ) + on_journal_saved( + conn, + early_exit_trigger="手动平仓", + early_exit_note="切日后补复盘", + mood_issues_raw="", + trading_day="2026-06-15", + now=now, + ) + st = compute_account_risk_status(conn, trading_day="2026-06-15", now=now) + self.assertEqual(st["status"], STATUS_FREEZE_1H) + + def test_journal_late_save_still_gets_1h_from_now(self): + conn = _mem_conn() + close_at = datetime(2026, 6, 14, 12, 0, 0) + close_ms = _local_ms(close_at) + on_user_initiated_close( + conn, + source=CLOSE_SOURCE_USER_INSTANCE, + closed_at_ms=close_ms, + trading_day="2026-06-14", + now=close_at, + ) + journal_at = datetime(2026, 6, 14, 14, 0, 0) + on_journal_saved( + conn, + early_exit_trigger="手动平仓", + early_exit_note="补写复盘说明", + mood_issues_raw="", + trading_day="2026-06-14", + now=journal_at, + ) + st = compute_account_risk_status(conn, trading_day="2026-06-14", now=journal_at) + self.assertEqual(st["status"], STATUS_FREEZE_1H) + self.assertEqual(st["cooloff_until_ms"], _local_ms(journal_at) + 3600 * 1000) + + def test_stale_4h_until_with_1h_hours_uses_shorter_end(self): + """库内 cooloff_hours=1 但 cooloff_until_ms 仍为旧 4h 时,应按 last_close+1h 倒计时.""" + conn = _mem_conn() + now = datetime(2026, 6, 14, 12, 6, 0) + now_ms = _local_ms(now) + close_ms = now_ms - 6 * 60 * 1000 + stale_until_4h = close_ms + 4 * 3600 * 1000 + conn.execute( + """UPDATE account_risk_state SET + trading_day='2026-06-14', + manual_close_count=1, + cooloff_until_ms=?, + cooloff_hours=1, + last_close_at_ms=?, + daily_frozen=0 + WHERE id=1""", + (stale_until_4h, close_ms), + ) + st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now) + self.assertEqual(st["status"], STATUS_FREEZE_1H) + self.assertAlmostEqual(st["freeze_remaining_sec"], 54 * 60, delta=3) + + def test_stale_4h_ignored_after_1h_journal_expired(self): + """复盘已降为 1h 且窗口结束后,不应再读库内旧 4h until.""" + conn = _mem_conn() + close_at = datetime(2026, 6, 18, 17, 56, 0) + now = datetime(2026, 6, 18, 21, 50, 0) + close_ms = _local_ms(close_at) + stale_4h_until = close_ms + 4 * 3600 * 1000 + conn.execute( + """UPDATE account_risk_state SET + trading_day='2026-06-18', + manual_close_count=1, + cooloff_until_ms=?, + cooloff_hours=1, + last_close_at_ms=?, + daily_frozen=0 + WHERE id=1""", + (stale_4h_until, close_ms), + ) + st = compute_account_risk_status(conn, trading_day="2026-06-18", now=now) + self.assertEqual(st["status"], STATUS_NORMAL) + self.assertTrue(st["can_trade"]) + row = conn.execute( + "SELECT cooloff_until_ms, cooloff_hours, last_close_at_ms FROM account_risk_state WHERE id=1" + ).fetchone() + self.assertIsNone(row["cooloff_until_ms"]) + self.assertIsNone(row["last_close_at_ms"]) + + def test_corrupted_anchor_cleared_when_journaled_manual_expired(self): + """上一版误把 last_close 写成近期时刻时,已复盘且 1h 已过的仍应显示正常.""" + conn = _mem_conn_with_journal() + now = datetime(2026, 6, 18, 22, 30, 0) + now_ms = _local_ms(now) + bad_last = now_ms - 60 * 1000 + conn.execute( + """UPDATE account_risk_state SET + trading_day='2026-06-18', + manual_close_count=1, + cooloff_until_ms=?, + cooloff_hours=1, + last_close_at_ms=?, + pending_journal_trade_id=NULL, + daily_frozen=0 + WHERE id=1""", + (bad_last + 3600 * 1000, bad_last), + ) + conn.execute( + "INSERT INTO journal_entries (close_datetime, early_exit_trigger, early_exit_note) VALUES (?,?,?)", + ("2026-06-18 17:56:00", "手动平仓", "按计划离场"), + ) + st = compute_account_risk_status(conn, trading_day="2026-06-18", now=now) + self.assertEqual(st["status"], STATUS_NORMAL) + self.assertTrue(st["can_trade"]) + + def test_future_last_close_does_not_restart_cooloff(self): + """脏数据 last_close 在未来时,不应重启 1h/4h 冻结.""" + conn = _mem_conn() + now = datetime(2026, 6, 18, 22, 30, 0) + now_ms = _local_ms(now) + future_close = now_ms + 49 * 60 * 1000 + conn.execute( + """UPDATE account_risk_state SET + trading_day='2026-06-18', + manual_close_count=1, + cooloff_until_ms=?, + cooloff_hours=1, + last_close_at_ms=?, + daily_frozen=0 + WHERE id=1""", + (future_close + 3600 * 1000, future_close), + ) + st = compute_account_risk_status(conn, trading_day="2026-06-18", now=now) + self.assertEqual(st["status"], STATUS_NORMAL) + self.assertTrue(st["can_trade"]) + + def test_active_4h_countdown_matches_tier(self): + conn = _mem_conn() + close_at = datetime(2026, 6, 18, 21, 46, 0) + now = datetime(2026, 6, 18, 21, 52, 0) + close_ms = _local_ms(close_at) + on_user_initiated_close( + conn, + source=CLOSE_SOURCE_USER_INSTANCE, + closed_at_ms=close_ms, + trading_day="2026-06-18", + now=close_at, + ) + st = compute_account_risk_status(conn, trading_day="2026-06-18", now=now) + self.assertEqual(st["status"], STATUS_FREEZE_4H) + self.assertAlmostEqual(st["freeze_remaining_sec"], 3 * 3600 + 54 * 60, delta=5) + + def test_trading_day_reset_clears_expired_stale_cooloff(self): + conn = _mem_conn() + close_at = datetime(2026, 6, 18, 17, 56, 0) + close_ms = _local_ms(close_at) + stale_4h_until = close_ms + 4 * 3600 * 1000 + conn.execute( + """UPDATE account_risk_state SET + trading_day='2026-06-18', + manual_close_count=1, + cooloff_until_ms=?, + cooloff_hours=1, + last_close_at_ms=?, + daily_frozen=0 + WHERE id=1""", + (stale_4h_until, close_ms), + ) + next_day = datetime(2026, 6, 19, 9, 0, 0) + st = compute_account_risk_status(conn, trading_day="2026-06-19", now=next_day) + self.assertEqual(st["status"], STATUS_NORMAL) + row = conn.execute("SELECT cooloff_until_ms FROM account_risk_state WHERE id=1").fetchone() + self.assertIsNone(row["cooloff_until_ms"]) + + def test_remaining_never_exceeds_configured_hours(self): + conn = _mem_conn() + now = datetime(2026, 6, 18, 22, 0, 0) + now_ms = _local_ms(now) + future_close = now_ms + 49 * 60 * 1000 + conn.execute( + """UPDATE account_risk_state SET + trading_day='2026-06-18', + manual_close_count=1, + cooloff_until_ms=?, + cooloff_hours=4, + last_close_at_ms=?, + daily_frozen=0 + WHERE id=1""", + (future_close + 4 * 3600 * 1000, future_close), + ) + st = compute_account_risk_status(conn, trading_day="2026-06-18", now=now) + self.assertEqual(st["status"], STATUS_NORMAL) + self.assertTrue(st["can_trade"]) + + def test_legacy_naive_utc_ms_countdown_normalized(self): + conn = _mem_conn() + now = datetime(2026, 6, 14, 12, 0, 0) + now_ms = _local_ms(now) + offset_ms = 8 * 3600 * 1000 + legacy_close = now_ms + offset_ms + legacy_until = legacy_close + 4 * 3600 * 1000 + conn.execute( + """UPDATE account_risk_state SET + trading_day='2026-06-14', + manual_close_count=1, + cooloff_until_ms=?, + cooloff_hours=4, + last_close_at_ms=?, + daily_frozen=0 + WHERE id=1""", + (legacy_until, legacy_close), + ) + st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now) + st = enrich_risk_status_countdown(st, now=now, daily_reset_hour=8) + self.assertEqual(st["status"], STATUS_FREEZE_4H) + self.assertAlmostEqual(st["freeze_remaining_sec"], 4 * 3600, delta=2) + + def test_journal_mood_issues_daily_freeze(self): + conn = _mem_conn() + now = datetime(2026, 6, 14, 12, 0, 0) + on_journal_saved( + conn, + early_exit_trigger="止损", + early_exit_note="", + mood_issues_raw=["报复开仓"], + trading_day="2026-06-14", + now=now, + ) + st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now) + self.assertEqual(st["status"], STATUS_DAILY) + + def test_cooloff_expired_returns_normal(self): + conn = _mem_conn() + start = datetime(2026, 6, 14, 8, 0, 0) + close_ms = _local_ms(start) + on_user_initiated_close( + conn, source=CLOSE_SOURCE_USER_INSTANCE, closed_at_ms=close_ms, trading_day="2026-06-14", now=start + ) + later = datetime(2026, 6, 14, 13, 0, 0) + st = compute_account_risk_status(conn, trading_day="2026-06-14", now=later) + self.assertEqual(st["status"], STATUS_NORMAL) + row = conn.execute("SELECT cooloff_until_ms FROM account_risk_state WHERE id=1").fetchone() + self.assertIsNone(row["cooloff_until_ms"]) + + def test_trading_day_reset_clears_daily_frozen(self): + conn = _mem_conn() + now = datetime(2026, 6, 14, 12, 0, 0) + on_journal_saved( + conn, + early_exit_trigger="止损", + early_exit_note="", + mood_issues_raw="扛单", + trading_day="2026-06-14", + now=now, + ) + next_day = datetime(2026, 6, 15, 8, 0, 0) + st = compute_account_risk_status(conn, trading_day="2026-06-15", now=next_day) + self.assertEqual(st["status"], STATUS_NORMAL) + + def test_parse_mood_issues_filters_unknown(self): + self.assertEqual(parse_mood_issues("怕踏空,未知标签,扛单"), ["怕踏空", "扛单"]) + + def test_enrich_countdown_for_daily_and_cooloff(self): + conn = _mem_conn() + now = datetime(2026, 6, 14, 12, 0, 0) + close_ms = _local_ms(now) + on_user_initiated_close( + conn, + source=CLOSE_SOURCE_USER_INSTANCE, + closed_at_ms=close_ms, + trading_day="2026-06-14", + now=now, + ) + st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now) + st = enrich_risk_status_countdown(st, now=now, daily_reset_hour=8) + self.assertGreater(st["freeze_remaining_sec"], 0) + self.assertEqual(st["freeze_until_ms"], st["cooloff_until_ms"]) + + on_journal_saved( + conn, + early_exit_trigger="止损", + early_exit_note="", + mood_issues_raw="扛单", + trading_day="2026-06-14", + now=now, + ) + st2 = compute_account_risk_status(conn, trading_day="2026-06-14", now=now) + st2 = enrich_risk_status_countdown(st2, now=now, daily_reset_hour=8) + self.assertTrue(st2["daily_frozen"]) + self.assertGreater(st2["freeze_remaining_sec"], 0) + self.assertIsNotNone(st2["freeze_until_ms"]) + + def test_disabled_risk_control(self): + os.environ["RISK_CONTROL_ENABLED"] = "0" + conn = _mem_conn() + now = datetime(2026, 6, 14, 12, 0, 0) + on_user_initiated_close( + conn, source=CLOSE_SOURCE_USER_INSTANCE, trading_day="2026-06-14", now=now + ) + st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now) + self.assertFalse(st["enabled"]) + self.assertTrue(st["can_trade"]) + ok, _ = account_risk_blocks_trading(conn, trading_day="2026-06-14", now=now) + self.assertTrue(ok) + + def test_position_limit_freeze_from_env(self): + os.environ["MAX_ACTIVE_POSITIONS"] = "2" + st = apply_position_limit_risk({"status": STATUS_NORMAL, "can_trade": True}, 2) + self.assertEqual(st["status"], STATUS_FREEZE_POSITION) + self.assertEqual(st["status_label"], "仓位上限冻结") + self.assertFalse(st["can_trade"]) + self.assertIn("2/2", st["reason"]) + self.assertIn("顺势加仓", st["reason"]) + self.assertTrue(st.get("can_roll")) + self.assertEqual(st["max_active_positions"], 2) + + def test_position_limit_normal_when_under_cap(self): + st = apply_position_limit_risk({"status": STATUS_NORMAL, "can_trade": True}, 0, max_active_positions=1) + self.assertEqual(st["status"], STATUS_NORMAL) + self.assertTrue(st["can_trade"]) + + def test_time_freeze_takes_priority_over_position_limit(self): + st = apply_position_limit_risk( + {"status": STATUS_FREEZE_4H, "status_label": "4h冻结", "can_trade": False}, + 5, + max_active_positions=1, + ) + self.assertEqual(st["status"], STATUS_FREEZE_4H) + self.assertEqual(st["active_count"], 5) + + def test_max_active_positions_from_env(self): + os.environ["MAX_ACTIVE_POSITIONS"] = "3" + self.assertEqual(max_active_positions_from_env(), 3) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ai_client_empty_content.py b/tests/test_ai_client_empty_content.py new file mode 100644 index 0000000..e6f391d --- /dev/null +++ b/tests/test_ai_client_empty_content.py @@ -0,0 +1,59 @@ +"""ai_client message parsing / empty-content retries.""" +from __future__ import annotations + +import sys +import unittest +from pathlib import Path +from unittest import mock + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from lib.ai.ai_client import _openai_message_text, ai_review # noqa: E402 + + +class TestOpenaiMessageText(unittest.TestCase): + def test_prefers_content(self): + self.assertEqual( + _openai_message_text({"content": "正文", "reasoning": "think"}), + "正文", + ) + + def test_falls_back_to_reasoning_content(self): + self.assertEqual( + _openai_message_text({"content": "", "reasoning_content": "备选正文"}), + "备选正文", + ) + + def test_skips_english_chain_of_thought(self): + self.assertEqual( + _openai_message_text( + { + "content": "", + "reasoning": "Here's a thinking process that leads to the answer...", + } + ), + "", + ) + + +class TestAiReviewImageCap(unittest.TestCase): + def test_caps_images_and_sets_max_tokens(self): + captured = {} + + def fake_generate(prompt, **kwargs): + captured["prompt"] = prompt + captured.update(kwargs) + return "OK_REVIEW" + + with mock.patch("lib.ai.ai_client.ai_generate", side_effect=fake_generate): + with mock.patch.dict("os.environ", {"AI_REVIEW_MAX_IMAGES": "2"}, clear=False): + out = ai_review("记录", "每日", image_paths=["a.png", "b.png", "c.png"]) + self.assertIn("OK_REVIEW", out) + self.assertEqual(captured.get("image_paths"), ["a.png", "b.png"]) + self.assertEqual(captured.get("max_tokens"), 8192) + self.assertIn("另跳过 1 张", out) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ai_review_lib.py b/tests/test_ai_review_lib.py new file mode 100644 index 0000000..eb198b4 --- /dev/null +++ b/tests/test_ai_review_lib.py @@ -0,0 +1,63 @@ +"""AI 复盘 journal 文本格式化(三所共用).""" +from __future__ import annotations + +import sqlite3 +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from lib.ai.ai_review_lib import journal_row_lines_for_ai # noqa: E402 + + +class TestAiReviewLib(unittest.TestCase): + def test_journal_row_includes_expect_and_actual_rr(self): + text = journal_row_lines_for_ai( + 1, + { + "coin": "HYPE", + "tf": "5m", + "pnl": "10.73", + "real_rr": "2.1354", + "expect_rr": "-", + "entry_reason": "趋势回调", + "exit_reason": "移动止盈", + "hold_duration": "1天 3小时", + "mood_issues": "", + "post_breakeven_stare": "否", + "new_trade_while_occupied": "否", + "note": "测试备注", + }, + ) + self.assertIn("实际RR:2.1354", text) + self.assertIn("预期RR:-", text) + self.assertIn("开仓逻辑:趋势回调", text) + self.assertIn("备注:测试备注", text) + self.assertNotIn("开仓类型", text) + + def test_journal_row_accepts_sqlite_row(self): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + conn.execute( + """CREATE TABLE journal_entries ( + coin TEXT, tf TEXT, pnl TEXT, real_rr TEXT, expect_rr TEXT, + entry_reason TEXT, exit_reason TEXT, hold_duration TEXT, + mood_issues TEXT, mood_score INTEGER, note TEXT + )""" + ) + conn.execute( + """INSERT INTO journal_entries VALUES (?,?,?,?,?,?,?,?,?,?,?)""", + ("BTC", "15m", "5", "1.2", "2.0", "突破", "止盈", "2小时", "", None, ""), + ) + row = conn.execute("SELECT * FROM journal_entries").fetchone() + conn.close() + text = journal_row_lines_for_ai(1, row) + self.assertIn("BTC 15m", text) + self.assertIn("实际RR:1.2", text) + self.assertIn("开仓逻辑:突破", text) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_archive_calendar.py b/tests/test_archive_calendar.py new file mode 100644 index 0000000..e60a579 --- /dev/null +++ b/tests/test_archive_calendar.py @@ -0,0 +1,60 @@ +import sqlite3 +import tempfile +import unittest +from datetime import datetime +from pathlib import Path +from zoneinfo import ZoneInfo + +from lib.hub.hub_symbol_archive_lib import init_db, list_archive_calendar, upsert_trades_cache, upsert_trade_overlay + + +def _bj_ms(y, m, d, hh, mm): + dt = datetime(y, m, d, hh, mm, 0, tzinfo=ZoneInfo("Asia/Shanghai")) + return int(dt.timestamp() * 1000) + + +class ArchiveCalendarTests(unittest.TestCase): + def test_calendar_groups_by_trading_day_and_sick(self): + with tempfile.TemporaryDirectory() as td: + db = Path(td) / "arch.db" + init_db(db) + upsert_trades_cache( + "binance", + [ + { + "id": 1, + "symbol": "BTC/USDT", + "direction": "long", + "result": "止盈", + "pnl_amount": 10.0, + "opened_at": "2026-06-18 09:00:00", + "closed_at": "2026-06-18 10:00:00", + "closed_at_ms": _bj_ms(2026, 6, 18, 10, 0), + "exchange_turnover_usdt": 2000.0, + "exchange_commission_usdt": 0.8, + }, + { + "id": 2, + "symbol": "ETH/USDT", + "direction": "short", + "result": "止损", + "pnl_amount": -5.0, + "opened_at": "2026-06-18 14:00:00", + "closed_at": "2026-06-18 15:00:00", + "closed_at_ms": _bj_ms(2026, 6, 18, 15, 0), + }, + ], + db_path=db, + ) + upsert_trade_overlay("binance", 2, behavior_tag="sick", db_path=db) + payload = list_archive_calendar(2026, 6, db_path=db) + self.assertEqual(payload["month"], 6) + days = payload["days"] + self.assertTrue(days) + sick_days = [d for d in days.values() if d.get("has_sick")] + self.assertTrue(sick_days) + self.assertGreaterEqual(payload["month_open_count"], 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_backfill_trend_snapshots.py b/tests/test_backfill_trend_snapshots.py new file mode 100644 index 0000000..18be2a4 --- /dev/null +++ b/tests/test_backfill_trend_snapshots.py @@ -0,0 +1,22 @@ +"""Tests for trend strategy snapshot backfill helpers.""" +from scripts.backfill_trend_strategy_snapshots import ( + infer_exit_price, + resolve_result_label, +) + + +def test_infer_exit_price_short_stop_loss(): + exit_p = infer_exit_price("short", 0.336, 4.85, 10, -2.45) + assert exit_p is not None + assert abs(exit_p - 0.353) < 0.002 + + +def test_resolve_result_label_from_plan_status(): + plan = {"status": "stopped_sl", "message": "stopped_sl"} + assert resolve_result_label(plan, None) == "止损" + + +def test_resolve_result_label_prefers_plan_status(): + plan = {"status": "stopped_sl"} + trade = {"result": "移动止盈"} + assert resolve_result_label(plan, trade) == "止损" diff --git a/tests/test_daily_open_limit_lib.py b/tests/test_daily_open_limit_lib.py new file mode 100644 index 0000000..e501967 --- /dev/null +++ b/tests/test_daily_open_limit_lib.py @@ -0,0 +1,90 @@ +import unittest + +from lib.trade.daily_open_limit_lib import ( + build_daily_open_alert_prompt, + can_trade_new_open, + check_daily_open_hard_limit, + count_opens_for_trading_day, + daily_open_hard_limit_blocks, + format_daily_open_counter_line, + hard_limit_block_reason, + load_daily_open_limits_from_env, + parse_daily_open_hard_limit, + should_send_daily_open_alert, +) + + +class _FakeConn: + def __init__(self, count: int): + self._count = count + + def execute(self, _sql, _params): + return self + + def fetchone(self): + return (self._count,) + + +class DailyOpenLimitLibTests(unittest.TestCase): + def test_parse_hard_limit_zero_disables(self): + self.assertEqual(parse_daily_open_hard_limit("0"), 0) + self.assertEqual(parse_daily_open_hard_limit(None, default=0), 0) + + def test_load_from_env(self): + alert, hard = load_daily_open_limits_from_env( + {"DAILY_OPEN_ALERT_THRESHOLD": "3", "DAILY_OPEN_HARD_LIMIT": "8"} + ) + self.assertEqual(alert, 3) + self.assertEqual(hard, 8) + + def test_hard_limit_blocks(self): + self.assertFalse(daily_open_hard_limit_blocks(4, 0)) + self.assertFalse(daily_open_hard_limit_blocks(4, 5)) + self.assertTrue(daily_open_hard_limit_blocks(5, 5)) + + def test_check_daily_open_hard_limit(self): + conn = _FakeConn(5) + ok, reason, n = check_daily_open_hard_limit(conn, "2026-06-07", 5, 8) + self.assertFalse(ok) + self.assertEqual(n, 5) + self.assertIn("已达上限", reason) + self.assertIn("8:00", reason) + + def test_count_opens(self): + self.assertEqual(count_opens_for_trading_day(_FakeConn(3), "2026-06-07"), 3) + + def test_can_trade_new_open(self): + self.assertTrue( + can_trade_new_open( + time_allows=True, + active_count=0, + max_active_positions=1, + opens_today=2, + hard_limit=5, + ) + ) + self.assertFalse( + can_trade_new_open( + time_allows=True, + active_count=0, + max_active_positions=1, + opens_today=5, + hard_limit=5, + ) + ) + + def test_alert_crossing(self): + self.assertTrue(should_send_daily_open_alert(4, 5, 5)) + self.assertFalse(should_send_daily_open_alert(5, 6, 5)) + + def test_prompt_includes_hard_limit(self): + txt = build_daily_open_alert_prompt("2026-06-07", 5, 5, hard_limit=8) + self.assertIn("硬上限 8", txt) + + def test_counter_line(self): + line = format_daily_open_counter_line(3, 5, 8) + self.assertIn("3 / 硬上限 8", line) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_dashboard_position_source.py b/tests/test_dashboard_position_source.py new file mode 100644 index 0000000..3038d26 --- /dev/null +++ b/tests/test_dashboard_position_source.py @@ -0,0 +1,64 @@ +"""数据看板仓位来源:监控匹配优先级.""" +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "manual_trading_hub")) +sys.path.insert(0, str(ROOT)) + +from hub_ai.context import ( # noqa: E402 + _options_source_label, + resolve_position_monitor_source, +) + + +class TestDashboardPositionSource(unittest.TestCase): + def test_priority_hedge_over_roll(self): + hub = { + "ok": True, + "hedges": [ + { + "plan_type": "perp_options", + "direction": "long", + "legs": [{"leg_role": "perp", "symbol": "ETH/USDT:USDT", "status": "open"}], + } + ], + "rolls": [{"symbol": "ETH/USDT:USDT", "direction": "long"}], + } + self.assertEqual( + resolve_position_monitor_source({"symbol": "ETH/USDT:USDT", "side": "long"}, hub), + "永期对冲", + ) + + def test_unmatched_is_dash(self): + hub = {"ok": True, "orders": [], "trends": [], "rolls": [], "keys": [], "hedges": []} + self.assertEqual( + resolve_position_monitor_source({"symbol": "BTC/USDT:USDT", "side": "short"}, hub), + "—", + ) + + def test_roll_beats_order(self): + hub = { + "ok": True, + "rolls": [{"symbol": "BTC/USDT:USDT", "direction": "short"}], + "orders": [{"symbol": "BTC/USDT:USDT", "direction": "short", "monitor_type": "下单监控"}], + } + self.assertEqual( + resolve_position_monitor_source({"symbol": "BTC/USDT:USDT", "side": "short"}, hub), + "顺势加仓", + ) + + def test_options_plain_is_pure(self): + self.assertEqual(_options_source_label({"source": "option", "source_label": "纯期权"}), "纯期权") + self.assertEqual(_options_source_label({"source": "option"}), "纯期权") + self.assertEqual( + _options_source_label({"source": "options_options", "source_label": "期期对冲"}), + "期期对冲", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_entry_model_lib.py b/tests/test_entry_model_lib.py new file mode 100644 index 0000000..772cc1b --- /dev/null +++ b/tests/test_entry_model_lib.py @@ -0,0 +1,182 @@ +import unittest + +from lib.trade.entry_model_lib import ( + ENTRY_MODEL_BIG_DIV_A, + ENTRY_MODEL_BIG_DIV_B, + ENTRY_MODEL_LAUNCH_A, + ENTRY_MODEL_LAUNCH_B, + ENTRY_MODEL_SMALL_DIV, + ENTRY_CATEGORY_REVERSAL, + ENTRY_CATEGORY_TREND, + build_intraday_entry_reason_options, + build_trend_div_entry_reason_options, + entry_model_categories, + entry_model_category, + entry_model_display_label, + entry_model_label, + format_entry_type_display, + hub_meta_entry_context, + intraday_entry_model_options, + is_intraday_trading_profile, + open_position_button_label, + parse_manual_order_style_fields, + resolve_trade_record_entry_reason, + trade_style_for_entry_model, + trend_manual_entry_reason_count, +) +from lib.trade.trade_policy_lib import TradePolicy, load_trade_policy + + +class TestEntryModelLib(unittest.TestCase): + def test_intraday_profile_btc_eth_whitelist(self): + policy = load_trade_policy( + { + "TRADE_SYMBOL_RESTRICT_ENABLED": "true", + "TRADE_SYMBOL_WHITELIST": "BTC,ETH", + } + ) + self.assertTrue(is_intraday_trading_profile(policy)) + self.assertEqual(trend_manual_entry_reason_count(policy), 2) + + def test_trend_div_profile_alt(self): + policy = load_trade_policy( + { + "TRADE_SYMBOL_RESTRICT_ENABLED": "false", + "TRADE_SYMBOL_WHITELIST": "BTC,ETH", + } + ) + self.assertFalse(is_intraday_trading_profile(policy)) + self.assertEqual(trend_manual_entry_reason_count(policy), 5) + + def test_entry_model_maps_trade_style(self): + self.assertEqual(trade_style_for_entry_model(ENTRY_MODEL_LAUNCH_A), "trend") + self.assertEqual(trade_style_for_entry_model(ENTRY_MODEL_BIG_DIV_A), "trend") + self.assertEqual(trade_style_for_entry_model(ENTRY_MODEL_SMALL_DIV), "swing") + self.assertEqual(entry_model_label(ENTRY_MODEL_LAUNCH_B), "启动B") + self.assertEqual(entry_model_category(ENTRY_MODEL_LAUNCH_A), ENTRY_CATEGORY_REVERSAL) + self.assertEqual(entry_model_category(ENTRY_MODEL_BIG_DIV_B), ENTRY_CATEGORY_TREND) + + def test_entry_model_categories_two_level(self): + cats = entry_model_categories() + keys = [c["key"] for c in cats] + self.assertEqual(keys, ["reversal", "trend", "swing"]) + reversal = cats[0]["options"] + self.assertEqual([o["code"] for o in reversal], ["launch_a", "launch_b"]) + self.assertEqual(len(cats[2]["options"]), 1) + + def test_parse_trend_div_requires_entry_model(self): + policy = TradePolicy(False, "both", False, ()) + style, code, err = parse_manual_order_style_fields(policy, {}) + self.assertTrue(err) + self.assertEqual(code, None) + + style, code, err = parse_manual_order_style_fields( + policy, {"entry_model": ENTRY_MODEL_SMALL_DIV} + ) + self.assertIsNone(err) + self.assertEqual(code, ENTRY_MODEL_SMALL_DIV) + self.assertEqual(style, "swing") + + style, code, err = parse_manual_order_style_fields( + policy, {"entry_model": ENTRY_MODEL_LAUNCH_A} + ) + self.assertIsNone(err) + self.assertEqual(code, ENTRY_MODEL_LAUNCH_A) + self.assertEqual(style, "trend") + + def test_hub_meta_intraday(self): + policy = load_trade_policy( + { + "TRADE_SYMBOL_RESTRICT_ENABLED": "true", + "TRADE_SYMBOL_WHITELIST": "BTC,ETH", + } + ) + ctx = hub_meta_entry_context(policy) + self.assertTrue(ctx["intraday_discipline"]) + self.assertEqual(ctx["order_entry_profile"], "intraday") + policy = TradePolicy(False, "both", True, ("BTC", "ETH")) + style, code, err = parse_manual_order_style_fields(policy, {"trade_style": "swing"}) + self.assertIsNone(err) + self.assertIsNone(code) + self.assertEqual(style, "swing") + + def test_intraday_entry_model_options(self): + opts = intraday_entry_model_options() + codes = [o.code for o in opts] + self.assertEqual(codes, ["liquidity_false_break", "structure_breakout"]) + self.assertEqual(entry_model_label("liquidity_false_break"), "假破") + + def test_parse_intraday_requires_entry_model(self): + policy = TradePolicy(True, "both", True, ("BTC", "ETH")) + style, code, err = parse_manual_order_style_fields(policy, {}) + self.assertTrue(err) + style, code, err = parse_manual_order_style_fields( + policy, {"entry_model": "structure_breakout"} + ) + self.assertIsNone(err) + self.assertEqual(code, "structure_breakout") + self.assertEqual(style, "trend") + + def test_open_position_button_intraday(self): + policy = TradePolicy(True, "both", True, ("BTC", "ETH")) + self.assertEqual( + open_position_button_label(policy, "full_margin"), + "开仓(日内·全仓杠杆)", + ) + + def test_resolve_entry_reason_from_model(self): + er = resolve_trade_record_entry_reason(entry_model=ENTRY_MODEL_BIG_DIV_B) + self.assertEqual(er, "顺势/大分歧B") + er2 = resolve_trade_record_entry_reason(entry_model=ENTRY_MODEL_LAUNCH_A) + self.assertEqual(er2, "反转/启动A") + + def test_entry_model_display_label(self): + self.assertEqual(entry_model_display_label(ENTRY_MODEL_LAUNCH_A), "反转/启动A") + self.assertEqual(entry_model_display_label(ENTRY_MODEL_SMALL_DIV), "波段单/小分歧") + self.assertEqual(entry_model_display_label("liquidity_false_break"), "波段单/假破") + self.assertEqual(format_entry_type_display("启动A"), "反转/启动A") + self.assertEqual(entry_model_label(ENTRY_MODEL_LAUNCH_B), "启动B") + + def test_resolve_entry_reason_trade_style_fallback(self): + er = resolve_trade_record_entry_reason(trade_style="swing") + self.assertEqual(er, "波段单") + er2 = resolve_trade_record_entry_reason(trade_style="trend") + self.assertEqual(er2, "趋势单") + + def test_build_trend_div_journal_options(self): + opts = build_trend_div_entry_reason_options(("趋势回调",)) + self.assertEqual(opts[:5], ("反转/启动A", "反转/启动B", "顺势/大分歧A", "顺势/大分歧B", "波段单/小分歧")) + self.assertIn("趋势单", opts) + self.assertIn("波段单", opts) + self.assertIn("趋势回调", opts) + + def test_build_intraday_journal_options_only_four(self): + opts = build_intraday_entry_reason_options( + ( + "关键位箱体突破", + "关键位回调触价开仓", + "关键位突破触价开仓", + ), + ("趋势回调", "顺势加仓"), + ) + self.assertEqual( + opts, + ( + "波段单/假破", + "波段单/结构突破", + "关键位回调触价开仓", + "关键位突破触价开仓", + ), + ) + + def test_normalize_review_entry_reason(self): + from lib.trade.entry_model_lib import normalize_review_entry_reason + + allowed = build_trend_div_entry_reason_options(()) + self.assertEqual(normalize_review_entry_reason("反转/启动A", allowed), "反转/启动A") + self.assertEqual(normalize_review_entry_reason("启动A", allowed), "反转/启动A") + self.assertEqual(normalize_review_entry_reason("趋势单", allowed), "趋势单") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_false_breakout_key_monitor_lib.py b/tests/test_false_breakout_key_monitor_lib.py new file mode 100644 index 0000000..92ccf7a --- /dev/null +++ b/tests/test_false_breakout_key_monitor_lib.py @@ -0,0 +1,76 @@ +import unittest +from datetime import datetime, timedelta + +from lib.key_monitor.false_breakout_key_monitor_lib import ( + FALSE_BREAKOUT_MONITOR_TYPE, + calc_false_breakout_plan, + false_breakout_gate_preview, + is_false_breakout_expired, + key_price_from_row, + normalize_false_breakout_symbol, + storage_bounds_from_key_price, +) + + +class FalseBreakoutKeyMonitorLibTests(unittest.TestCase): + def test_normalize_symbol(self): + self.assertEqual(normalize_false_breakout_symbol("btc"), "BTC/USDT") + self.assertEqual(normalize_false_breakout_symbol("ETH/USDT"), "ETH/USDT") + self.assertIsNone(normalize_false_breakout_symbol("SOL")) + + def test_short_plan(self): + plan = calc_false_breakout_plan("short", 100000) + self.assertIsNotNone(plan) + entry, sl, tp = plan + self.assertAlmostEqual(entry, 100100.0) + self.assertAlmostEqual(sl, 100600.5) + self.assertAlmostEqual(tp, 99349.25) + + def test_long_plan(self): + plan = calc_false_breakout_plan("long", 100000) + self.assertIsNotNone(plan) + entry, sl, tp = plan + self.assertAlmostEqual(entry, 99900.0) + self.assertAlmostEqual(sl, 99400.5) + self.assertAlmostEqual(tp, 100649.25) + + def test_storage_bounds(self): + up, low = storage_bounds_from_key_price("short", 100000) + self.assertGreater(up, low) + self.assertAlmostEqual(up, 100000.0) + self.assertAlmostEqual(low, 99990.0) + up, low = storage_bounds_from_key_price("long", 100000) + self.assertGreater(up, low) + self.assertAlmostEqual(low, 100000.0) + self.assertAlmostEqual(up, 100010.0) + + def test_key_price_from_row(self): + self.assertEqual(key_price_from_row("short", 100100, 100000), 100100) + self.assertEqual(key_price_from_row("long", 100100, 100000), 100000) + + def test_expiry(self): + now = datetime(2026, 6, 9, 12, 0, 0) + created = "2026-06-08 12:00:00" + self.assertTrue(is_false_breakout_expired(created, now)) + self.assertFalse(is_false_breakout_expired(created, now - timedelta(hours=1))) + + def test_monitor_type_constant(self): + self.assertEqual(FALSE_BREAKOUT_MONITOR_TYPE, "假突破") + + def test_gate_preview_not_box_gate(self): + now = datetime(2026, 6, 7, 12, 0, 0) + prev = false_breakout_gate_preview( + entry_display="1635.0", + limit_order_id="oid-1", + created_at="2026-06-07 10:00:00", + now=now, + ) + self.assertIn("假突破", prev["summary"]) + self.assertIn("等待成交", prev["summary"]) + self.assertNotIn("量:", prev["summary"]) + self.assertIn("限价单:oid-1", prev["metrics"]) + self.assertTrue(prev["gate_ok"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_flask_access_log_lib.py b/tests/test_flask_access_log_lib.py new file mode 100644 index 0000000..df253b2 --- /dev/null +++ b/tests/test_flask_access_log_lib.py @@ -0,0 +1,17 @@ +"""silence_werkzeug_access_log 烟雾测试.""" +import logging +import unittest + +from lib.common.flask_access_log_lib import silence_werkzeug_access_log + + +class TestSilenceAccessLog(unittest.TestCase): + def test_sets_warning_level(self): + log = logging.getLogger("werkzeug") + log.setLevel(logging.INFO) + silence_werkzeug_access_log() + self.assertGreaterEqual(log.level, logging.WARNING) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_force_close_lib.py b/tests/test_force_close_lib.py new file mode 100644 index 0000000..54d3397 --- /dev/null +++ b/tests/test_force_close_lib.py @@ -0,0 +1,109 @@ +from datetime import datetime +from zoneinfo import ZoneInfo + +from lib.trade.force_close_lib import ( + apply_force_close_display_result, + build_force_close_state, + coerce_force_close_result, + compute_next_force_close_at_ms, + force_close_label, + format_force_close_countdown, + infer_force_close_result, + is_close_at_force_close_window, + is_force_close_active_hour, + is_force_close_executing, +) + +TZ = ZoneInfo("Asia/Shanghai") + + +def _ms(y, m, d, hh, mm=0): + return int(datetime(y, m, d, hh, mm, tzinfo=TZ).timestamp() * 1000) + + +def test_force_close_label(): + assert force_close_label(0) == "强制清仓 00:00" + assert force_close_label(8) == "强制清仓 08:00" + + +def test_next_force_close_at_midnight(): + # 2026-07-05 23:30 -> next 2026-07-06 00:00 + now = _ms(2026, 7, 5, 23, 30) + assert compute_next_force_close_at_ms(bj_hour=0, now_ms=now, tz_name="Asia/Shanghai") == _ms( + 2026, 7, 6, 0, 0 + ) + + +def test_next_force_close_same_day_before_hour(): + now = _ms(2026, 7, 6, 15, 0) + assert compute_next_force_close_at_ms(bj_hour=0, now_ms=now, tz_name="Asia/Shanghai") == _ms( + 2026, 7, 7, 0, 0 + ) + + +def test_next_force_close_after_trigger_same_day(): + now = _ms(2026, 7, 7, 0, 18) + assert compute_next_force_close_at_ms(bj_hour=0, now_ms=now, tz_name="Asia/Shanghai") == _ms( + 2026, 7, 8, 0, 0 + ) + + +def test_executing_window_and_countdown(): + now = _ms(2026, 7, 6, 0, 14) + assert is_force_close_executing(0, now_ms=now, tz_name="Asia/Shanghai") + assert is_force_close_active_hour(0, now_ms=now, tz_name="Asia/Shanghai") + state = build_force_close_state( + True, 0, now_ms=now, tz_name="Asia/Shanghai", has_active_positions=True + ) + assert state["enabled"] is True + assert state["active"] is True + assert state["countdown"] == "执行中" + assert state["next_at_ms"] == _ms(2026, 7, 7, 0, 0) + + +def test_not_executing_after_grace_without_positions(): + now = _ms(2026, 7, 7, 0, 18) + assert not is_force_close_executing(0, now_ms=now, tz_name="Asia/Shanghai") + state = build_force_close_state( + True, 0, now_ms=now, tz_name="Asia/Shanghai", has_active_positions=False + ) + assert state["active"] is False + assert state["countdown"] != "执行中" + assert state["next_at_ms"] == _ms(2026, 7, 8, 0, 0) + + +def test_disabled_state(): + state = build_force_close_state(False, 0) + assert state["enabled"] is False + assert state["next_at_ms"] is None + + +def test_format_countdown(): + assert format_force_close_countdown(3661) == "01:01:01" + assert format_force_close_countdown(0, active=True) == "执行中" + + +def test_infer_force_close_from_closed_at(): + assert is_close_at_force_close_window("2026-07-07 00:00", 0) + assert infer_force_close_result("2026-07-07 00:00", enabled=True, bj_hour=0) == "强制清仓" + assert infer_force_close_result("2026-07-07 00:20", enabled=True, bj_hour=0) is None + + +def test_coerce_and_display_external_close_at_midnight(): + res, note = coerce_force_close_result( + "外部平仓", + "2026-07-07 00:00", + enabled=True, + bj_hour=0, + ) + assert res == "强制清仓" + assert "00:00" in note + assert ( + apply_force_close_display_result( + "手动平仓", + "2026-07-07 00:00", + enabled=True, + bj_hour=0, + ) + == "强制清仓" + ) diff --git a/tests/test_gate_position_history_lib.py b/tests/test_gate_position_history_lib.py new file mode 100644 index 0000000..919c9d0 --- /dev/null +++ b/tests/test_gate_position_history_lib.py @@ -0,0 +1,26 @@ +from lib.exchange.gate_position_history_lib import pick_gate_position_close, unified_symbol_for_match + + +def test_unified_symbol_strips_settle_suffix(): + assert unified_symbol_for_match("BTC/USDT:USDT") == "BTC/USDT" + + +def test_pick_gate_position_close_matches_symbol_side_and_time(): + hist = [ + { + "symbol_u": "SOL/USDT", + "side": "short", + "close_ms": 1_700_000_000_000, + "open_ms": 1_699_999_000_000, + "pnl": -1.25, + "sync_key": "SOL_USDT|1|short", + } + ] + hit = pick_gate_position_close( + hist, + "SOL/USDT:USDT", + "short", + opened_at_ms=1_699_999_500_000, + ) + assert hit is not None + assert hit["pnl"] == -1.25 diff --git a/tests/test_gate_transfer_lib.py b/tests/test_gate_transfer_lib.py new file mode 100644 index 0000000..75ea084 --- /dev/null +++ b/tests/test_gate_transfer_lib.py @@ -0,0 +1,44 @@ +"""gate_transfer_lib 单元测试.""" +from __future__ import annotations + +import sqlite3 +import unittest + +from lib.exchange.gate_transfer_lib import count_auto_transfer_blockers + + +class GateTransferLibTest(unittest.TestCase): + def test_counts_order_monitors_first(self): + conn = sqlite3.connect(":memory:") + conn.execute("CREATE TABLE order_monitors (status TEXT)") + conn.execute("CREATE TABLE trend_pullback_plans (status TEXT, first_order_done INTEGER)") + conn.execute("INSERT INTO order_monitors VALUES ('active')") + conn.execute("INSERT INTO trend_pullback_plans VALUES ('active', 1)") + conn.commit() + n = count_auto_transfer_blockers(conn, count_order_monitors=lambda c: 1) + self.assertEqual(n, 1) + conn.close() + + def test_counts_trend_plan_when_no_order_monitors(self): + conn = sqlite3.connect(":memory:") + conn.execute("CREATE TABLE order_monitors (status TEXT)") + conn.execute("CREATE TABLE trend_pullback_plans (status TEXT, first_order_done INTEGER)") + conn.execute("INSERT INTO trend_pullback_plans VALUES ('active', 1)") + conn.commit() + n = count_auto_transfer_blockers(conn, count_order_monitors=lambda c: 0) + self.assertEqual(n, 1) + conn.close() + + def test_ignores_trend_plan_without_first_order(self): + conn = sqlite3.connect(":memory:") + conn.execute("CREATE TABLE order_monitors (status TEXT)") + conn.execute("CREATE TABLE trend_pullback_plans (status TEXT, first_order_done INTEGER)") + conn.execute("INSERT INTO trend_pullback_plans VALUES ('active', 0)") + conn.commit() + n = count_auto_transfer_blockers(conn, count_order_monitors=lambda c: 0) + self.assertEqual(n, 0) + conn.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hedge_plan_calc.py b/tests/test_hedge_plan_calc.py new file mode 100644 index 0000000..60a89e3 --- /dev/null +++ b/tests/test_hedge_plan_calc.py @@ -0,0 +1,108 @@ +"""对冲计划 P0 测算口径单测.""" +import unittest + +from lib.hedge_plan.hedge_plan_calc_lib import ( + build_options_options_preview, + build_perp_options_preview, + floor_contracts_to_precision, + gate_status, + option_expiry_pnl, + option_premium_total, + perp_pnl, +) + + +class TestHedgePlanCalc(unittest.TestCase): + def test_perp_tp_accounting_is_profit_minus_premium(self): + p = build_perp_options_preview( + direction="long", + entry=3200, + tp=3400, + sl=3000, + contracts=50, + contract_size=0.01, + opt_type="P", + strike=3100, + sheets=10, + ct_mult=0.01, + premium_paid=8, + index_px=3200, + ) + self.assertEqual(p["summary"]["tp_total"], 92.0) + self.assertEqual(p["scenarios"][0]["options_pnl"], -8.0) + + def test_perp_sl_accounting_is_option_plus_perp_signed(self): + p = build_perp_options_preview( + direction="long", + entry=3200, + tp=3400, + sl=3000, + contracts=50, + contract_size=0.01, + opt_type="P", + strike=3100, + sheets=10, + ct_mult=0.01, + premium_paid=8, + index_px=3200, + ) + self.assertEqual(p["summary"]["sl_total"], -98.0) + self.assertEqual(p["summary"]["hedge_ratio_at_sl"], 2.0) + + def test_option_premium_and_expiry(self): + self.assertEqual(option_premium_total(ask=80, sheets=1, ct_mult=0.01), 0.8) + self.assertEqual( + option_expiry_pnl( + opt_type="P", strike=3100, spot=3000, sheets=10, ct_mult=0.01, premium_paid=8 + ), + 2.0, + ) + + def test_gate_perp_requires_full_margin_for_start_message(self): + g = gate_status( + hedge_enabled=True, + sizing_mode="risk", + plan_type="perp_options", + options_enabled=True, + ) + self.assertTrue(g["can_preview"]) + self.assertFalse(g["can_start"]) + self.assertTrue(any("全仓" in r for r in g["reasons"])) + + def test_oo_expiry_loss_flag(self): + a = {"opt_type": "C", "strike": 3300, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5} + b = {"opt_type": "P", "strike": 3100, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5} + p = build_options_options_preview( + target_price_up=3500, + target_price_down=3000, + index_px=3200, + leg_a=a, + leg_b=b, + ) + self.assertEqual(p["summary"]["premium_paid"], 10) + self.assertTrue(p["summary"]["expiry_is_loss"]) + self.assertEqual(len(p["scenarios"]), 4) + self.assertEqual(p["scenarios"][0]["id"], "target_up") + self.assertEqual(p["scenarios"][1]["id"], "target_down") + + def test_oo_legacy_single_target_still_works(self): + a = {"opt_type": "C", "strike": 3300, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5} + b = {"opt_type": "P", "strike": 3100, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5} + p = build_options_options_preview(target_price=3500, index_px=3200, leg_a=a, leg_b=b) + self.assertEqual(p["target_price_up"], 3500) + self.assertEqual(p["target_price_down"], 3500) + + def test_perp_short_pnl(self): + self.assertEqual( + perp_pnl(direction="short", entry=100, exit_px=90, contracts=1, contract_size=1), + 10, + ) + + def test_floor_contracts_to_precision(self): + self.assertEqual(floor_contracts_to_precision(4.569713, 4), 4.5697) + self.assertEqual(floor_contracts_to_precision(4.569713, 0), 4.0) + self.assertEqual(floor_contracts_to_precision(0, 4), 0.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hedge_plan_history_stats.py b/tests/test_hedge_plan_history_stats.py new file mode 100644 index 0000000..ea451b0 --- /dev/null +++ b/tests/test_hedge_plan_history_stats.py @@ -0,0 +1,160 @@ +"""对冲计划历史删除与分类型统计.""" +import sqlite3 +import unittest + +from lib.hedge_plan.hedge_plan_db import ( + _metrics_from_pnls, + active_options_targets_by_inst, + delete_plan, + init_hedge_plan_tables, + insert_leg, + insert_plan, + legs_contract_summary, + stats_summary, +) + + +def _mem(): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + init_hedge_plan_tables(conn) + return conn + + +class TestHedgeHistoryStats(unittest.TestCase): + def test_metrics_win_rate_pf_dd(self): + rows = [ + {"realized_pnl_total": 10, "closed_at": "2026-01-01", "premium_total": 1}, + {"realized_pnl_total": -4, "closed_at": "2026-01-02", "premium_total": 1}, + {"realized_pnl_total": 6, "closed_at": "2026-01-03", "premium_total": 1}, + {"realized_pnl_total": -12, "closed_at": "2026-01-04", "premium_total": 1}, + ] + m = _metrics_from_pnls(rows) + self.assertEqual(m["count"], 4) + self.assertEqual(m["wins"], 2) + self.assertAlmostEqual(m["win_rate"], 0.5) + # gross win 16 / gross loss 16 = 1 + self.assertAlmostEqual(m["profit_factor"], 1.0) + self.assertAlmostEqual(m["max_profit"], 10) + self.assertAlmostEqual(m["max_loss"], -12) + # equity: 10 → 6 → 12 → 0; peak 12, dd to 0 = 12 + self.assertAlmostEqual(m["max_drawdown"], 12) + + def test_stats_by_type_and_delete(self): + conn = _mem() + po = insert_plan( + conn, + { + "plan_type": "perp_options", + "status": "closed", + "underlying": "ETH", + "realized_pnl_total": 5, + "premium_total": 1, + "close_reason": "perp_tp", + "closed_at": "2026-07-01 10:00:00", + }, + ) + insert_leg( + conn, + { + "plan_id": po, + "leg_role": "perp", + "symbol": "ETH/USDT:USDT", + "status": "closed", + }, + ) + oo = insert_plan( + conn, + { + "plan_type": "options_options", + "status": "closed", + "underlying": "ETH", + "realized_pnl_total": -2, + "premium_total": 0.02, + "close_reason": "oo_expiry_loss", + "closed_at": "2026-07-02 10:00:00", + }, + ) + insert_leg( + conn, + { + "plan_id": oo, + "leg_role": "option_a", + "inst_id": "ETH-USD_UM-260715-1900-C", + "status": "closed", + }, + ) + active = insert_plan( + conn, + { + "plan_type": "perp_options", + "status": "active", + "underlying": "BTC", + "realized_pnl_total": None, + }, + ) + s = stats_summary(conn) + self.assertEqual(s["closed_count"], 2) + self.assertEqual(s["by_type"]["perp_options"]["count"], 1) + self.assertEqual(s["by_type"]["options_options"]["count"], 1) + self.assertAlmostEqual(s["by_type"]["perp_options"]["win_rate"], 1.0) + self.assertAlmostEqual(s["by_type"]["options_options"]["max_loss"], -2) + + bad = delete_plan(conn, active) + self.assertFalse(bad["ok"]) + ok = delete_plan(conn, oo) + self.assertTrue(ok["ok"]) + s2 = stats_summary(conn) + self.assertEqual(s2["closed_count"], 1) + + def test_contract_summary(self): + s = legs_contract_summary( + [ + {"leg_role": "perp", "symbol": "ETH/USDT:USDT"}, + {"leg_role": "option_hedge", "inst_id": "ETH-USD_UM-260715-1790-P"}, + ] + ) + self.assertIn("永续 ETH/USDT:USDT", s) + self.assertIn("ETH-USD_UM-260715-1790-P", s) + + def test_active_options_targets_are_read_only_plan_targets(self): + conn = _mem() + pid = insert_plan( + conn, + { + "plan_type": "options_options", + "status": "active", + "underlying": "ETH", + "target_price_up": 1950, + "target_price_down": 1800, + }, + ) + insert_leg( + conn, + { + "plan_id": pid, + "leg_role": "option_a", + "inst_id": "ETH-USD_UM-260719-1890-C", + "opt_type": "C", + "status": "open", + }, + ) + insert_leg( + conn, + { + "plan_id": pid, + "leg_role": "option_b", + "inst_id": "ETH-USD_UM-260719-1850-P", + "opt_type": "P", + "status": "open", + }, + ) + + targets = active_options_targets_by_inst(conn) + self.assertEqual(targets["ETH-USD_UM-260719-1890-C"]["target_index"], 1950) + self.assertEqual(targets["ETH-USD_UM-260719-1850-P"]["target_index"], 1800) + self.assertEqual(targets["ETH-USD_UM-260719-1890-C"]["managed_by"], "hedge_plan") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hedge_plan_notify_settle.py b/tests/test_hedge_plan_notify_settle.py new file mode 100644 index 0000000..a6e8bbb --- /dev/null +++ b/tests/test_hedge_plan_notify_settle.py @@ -0,0 +1,220 @@ +"""对冲计划微信文案与到期结算.""" +import sqlite3 +import time +import unittest +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock + +from lib.exchange.okx_options_lib import expiry_ms_from_inst_id +from lib.hedge_plan.hedge_plan_db import get_plan, init_hedge_plan_tables, insert_leg, insert_plan +from lib.hedge_plan.hedge_plan_monitor_lib import _tick_oo_expiry, _settle_orphaned_after_tp +from lib.hedge_plan.hedge_plan_notify_lib import ( + build_hedge_end_message, + build_hedge_start_message, + notify_plan_end, + notify_plan_start, +) +from lib.hedge_plan.hedge_plan_settle_lib import leg_is_expired, settle_option_leg_at_spot + + +def _mem_db(): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + init_hedge_plan_tables(conn) + return conn + + +class TestHedgeNotify(unittest.TestCase): + def test_start_end_copy(self): + plan = { + "id": 7, + "plan_type": "perp_options", + "underlying": "ETH", + "direction": "long", + "entry_mark": 1800, + "tp": 1900, + "sl": 1700, + "perp_size": 2, + "leverage": 10, + "premium_total": 1.5, + "close_reason": "perp_tp", + "realized_pnl_total": 12.3, + "realized_pnl_perp": 15, + "realized_pnl_options": -1.5, + "opened_at": "2026-07-01 10:00:00", + "closed_at": "2026-07-01 12:00:00", + } + s = build_hedge_start_message(plan) + self.assertIn("启动 #7", s) + self.assertIn("永期", s) + e = build_hedge_end_message(plan) + self.assertIn("结束 #7", e) + self.assertIn("止盈", e) + + def test_idempotent_flags(self): + conn = _mem_db() + sent = [] + cfg = {"send_wechat": lambda c: sent.append(c)} + pid = insert_plan( + conn, + { + "plan_type": "options_options", + "status": "active", + "underlying": "ETH", + "target_price": 2000, + "premium_total": 2.0, + "opened_at": "t0", + }, + ) + plan = get_plan(conn, pid) + self.assertTrue(notify_plan_start(cfg, conn, plan, [])) + plan = get_plan(conn, pid) + self.assertEqual(int(plan["wechat_start_sent"]), 1) + self.assertFalse(notify_plan_start(cfg, conn, plan, [])) + self.assertEqual(len(sent), 1) + + plan["status"] = "closed" + plan["close_reason"] = "oo_expiry_loss" + plan["realized_pnl_total"] = -2 + plan["closed_at"] = "t1" + self.assertTrue(notify_plan_end(cfg, conn, plan)) + plan = get_plan(conn, pid) + self.assertEqual(int(plan["wechat_end_sent"]), 1) + self.assertFalse(notify_plan_end(cfg, conn, plan)) + self.assertEqual(len(sent), 2) + + +class TestHedgeSettle(unittest.TestCase): + def test_put_expiry_otm(self): + pnl = settle_option_leg_at_spot( + {"opt_type": "P", "strike": 1700, "size": 2, "premium": 1.2, "ct_mult": 0.01}, + spot=1800, + ) + self.assertAlmostEqual(pnl, -1.2) + + def test_call_expiry_itm(self): + # intrinsic (1900-1800)*2*0.01 - 0.5 = 2 - 0.5 + pnl = settle_option_leg_at_spot( + {"opt_type": "C", "strike": 1800, "size": 2, "premium": 0.5, "ct_mult": 0.01}, + spot=1900, + ) + self.assertAlmostEqual(pnl, 1.5) + + def test_leg_expired_from_inst(self): + # past date in inst_id + past = datetime.now(timezone.utc) - timedelta(days=3) + yy = past.year % 100 + tag = f"ETH-USD-{yy:02d}{past.month:02d}{past.day:02d}-1800-P" + self.assertTrue(leg_is_expired({"inst_id": tag})) + future = datetime.now(timezone.utc) + timedelta(days=10) + tag2 = f"ETH-USD-{future.year % 100:02d}{future.month:02d}{future.day:02d}-1800-P" + self.assertFalse(leg_is_expired({"inst_id": tag2})) + self.assertIsNotNone(expiry_ms_from_inst_id(tag)) + + +class TestHedgeMonitorExpiry(unittest.TestCase): + def test_oo_expiry_loss_closes_plan(self): + conn = _mem_db() + past = datetime.now(timezone.utc) - timedelta(days=1) + tag = f"ETH-USD-{past.year % 100:02d}{past.month:02d}{past.day:02d}-1800-P" + pid = insert_plan( + conn, + { + "plan_type": "options_options", + "status": "active", + "underlying": "ETH", + "target_price": 2000, + "premium_total": 2.0, + }, + ) + insert_leg( + conn, + { + "plan_id": pid, + "leg_role": "option_a", + "inst_id": tag, + "opt_type": "P", + "strike": 1800, + "size": 1, + "premium": 1.0, + "status": "open", + }, + ) + insert_leg( + conn, + { + "plan_id": pid, + "leg_role": "option_b", + "inst_id": tag.replace("-P", "-C").replace("1800", "1900"), + "opt_type": "C", + "strike": 1900, + "size": 1, + "premium": 1.0, + "status": "open", + }, + ) + sent = [] + cfg = { + "send_wechat": lambda c: sent.append(c), + "fetch_index_price": lambda ex, u: 1850.0, + "exchange_options": object(), + } + plan = get_plan(conn, pid) + legs = [ + dict(r) + for r in conn.execute("SELECT * FROM hedge_plan_legs WHERE plan_id=?", (pid,)).fetchall() + ] + r = _tick_oo_expiry(cfg, conn, plan, legs) + self.assertIsNotNone(r) + self.assertEqual(r["close_reason"], "oo_expiry_loss") + plan2 = get_plan(conn, pid) + self.assertEqual(plan2["status"], "closed") + self.assertLessEqual(float(plan2["realized_pnl_total"]), 0) + self.assertTrue(any("结束" in x for x in sent)) + + def test_orphaned_option_does_not_rewrite_plan_total(self): + conn = _mem_db() + past = datetime.now(timezone.utc) - timedelta(days=1) + tag = f"ETH-USD-{past.year % 100:02d}{past.month:02d}{past.day:02d}-1700-P" + pid = insert_plan( + conn, + { + "plan_type": "perp_options", + "status": "closed", + "underlying": "ETH", + "direction": "long", + "close_reason": "perp_tp", + "realized_pnl_total": 10.0, + "realized_pnl_options": -1.0, + "stats_bucket": "tp", + }, + ) + insert_leg( + conn, + { + "plan_id": pid, + "leg_role": "option_hedge", + "inst_id": tag, + "opt_type": "P", + "strike": 1700, + "size": 1, + "premium": 1.0, + "status": "hold_to_expiry", + "close_reason": "orphaned_after_tp", + }, + ) + cfg = { + "fetch_index_price": lambda ex, u: 1800.0, + "exchange_options": object(), + } + acted = _settle_orphaned_after_tp(cfg, conn) + self.assertEqual(len(acted), 1) + plan = get_plan(conn, pid) + self.assertAlmostEqual(float(plan["realized_pnl_total"]), 10.0) + leg = conn.execute("SELECT * FROM hedge_plan_legs WHERE plan_id=?", (pid,)).fetchone() + self.assertEqual(leg["status"], "closed") + self.assertEqual(leg["close_reason"], "expiry") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hedge_plan_orders.py b/tests/test_hedge_plan_orders.py new file mode 100644 index 0000000..0605ef7 --- /dev/null +++ b/tests/test_hedge_plan_orders.py @@ -0,0 +1,215 @@ +"""对冲计划下单路径校验(dry_run + 门禁).""" +import unittest +from unittest.mock import MagicMock + +from lib.hedge_plan.hedge_plan_calc_lib import gate_status +from lib.hedge_plan.hedge_plan_orders_lib import ( + build_oo_path_plan, + build_po_path_plan, + execute_options_options_start, + execute_perp_options_start, + validate_start_body, +) + + +class TestHedgePlanOrderPath(unittest.TestCase): + def test_po_path_options_first(self): + body = { + "opt_inst_id": "ETH-USD-260731-1800-P", + "sheets": 2, + "exchange_symbol": "ETH/USDT:USDT", + "direction": "long", + "contracts": 4.5, + "tp": 1900, + "sl": 1700, + } + path = build_po_path_plan(body) + self.assertEqual(path[0]["step"], "options_buy_limit") + self.assertEqual(path[0]["account"], "options") + self.assertEqual(path[1]["step"], "perp_market_open") + self.assertEqual(path[1]["account"], "swap") + self.assertTrue(path[1]["attach_tpsl"]) + + def test_oo_path_two_option_buys(self): + body = { + "leg_a": {"inst_id": "ETH-USD-260731-1800-C", "sheets": 1}, + "leg_b": {"inst_id": "ETH-USD-260731-1700-P", "sheets": 3}, + } + path = build_oo_path_plan(body) + self.assertEqual(len(path), 2) + self.assertEqual(path[0]["leg"], "a") + self.assertEqual(path[1]["sheets"], 3) + + def test_validate_body(self): + self.assertIsNotNone(validate_start_body("perp_options", {})) + ok = validate_start_body( + "perp_options", + { + "direction": "long", + "entry": 1800, + "tp": 1900, + "sl": 1700, + "contracts": 1, + "opt_inst_id": "X", + "sheets": 1, + "exchange_symbol": "ETH/USDT:USDT", + }, + ) + self.assertIsNone(ok) + + def test_gate_can_start_when_live(self): + g = gate_status( + hedge_enabled=True, + sizing_mode="full_margin", + plan_type="perp_options", + options_enabled=True, + live_order=True, + live_trading=True, + active_count=0, + max_active=1, + ) + self.assertTrue(g["can_start"]) + self.assertEqual(g["reasons"], []) + + def test_gate_oo_without_live_trading(self): + g = gate_status( + hedge_enabled=True, + sizing_mode="risk", + plan_type="options_options", + options_enabled=True, + live_order=True, + live_trading=False, + active_count=0, + max_active=1, + ) + self.assertTrue(g["can_start"]) + + def test_dry_run_po_calls_quote_not_place(self): + quote = MagicMock( + return_value={ + "ok": True, + "ask": 12.5, + "ask_sz": 10, + "can_open": True, + "ct_mult": 0.01, + "tick_sz": "0.1", + "strike": 1800, + "exp_time": 1, + "meta": {"optType": "P"}, + } + ) + place_opt = MagicMock() + place_perp = MagicMock() + cfg = { + "exchange_options": object(), + "exchange": object(), + "quote_option_contract": quote, + "place_option_limit_order": place_opt, + "place_exchange_order": place_perp, + "td_mode_for_option_buy": lambda x: "isolated", + "amount_to_precision": lambda s, a: a, + "ensure_okx_live_ready": lambda: (True, ""), + } + body = { + "direction": "long", + "entry": 1800, + "tp": 1900, + "sl": 1700, + "contracts": 4.5, + "opt_inst_id": "ETH-USD-260731-1800-P", + "sheets": 2, + "exchange_symbol": "ETH/USDT:USDT", + "leverage": 10, + "underlying": "ETH", + } + out = execute_perp_options_start(cfg, body, dry_run=True) + self.assertTrue(out["ok"]) + self.assertTrue(out["dry_run"]) + place_opt.assert_not_called() + place_perp.assert_not_called() + quote.assert_called() + self.assertEqual(out["path"][0]["account"], "options") + self.assertEqual(out["path"][1]["account"], "swap") + + def test_dry_run_oo(self): + quote = MagicMock( + return_value={ + "ok": True, + "ask": 10, + "ask_sz": 5, + "can_open": True, + "ct_mult": 0.01, + "tick_sz": "0.1", + "strike": 1800, + "meta": {"optType": "C"}, + } + ) + cfg = { + "exchange_options": object(), + "quote_option_contract": quote, + "place_option_limit_order": MagicMock(), + "td_mode_for_option_buy": lambda x: "isolated", + } + body = { + "target_price": 1900, + "target_price_up": 1950, + "target_price_down": 1750, + "leg_a": {"inst_id": "A", "sheets": 1}, + "leg_b": {"inst_id": "B", "sheets": 1}, + } + out = execute_options_options_start(cfg, body, dry_run=True) + self.assertTrue(out["ok"]) + self.assertEqual(len(out["results"]), 2) + + def test_buy_rejects_without_ask_depth(self): + from lib.hedge_plan.hedge_plan_orders_lib import _buy_option + + quote = MagicMock( + return_value={ + "ok": True, + "ask": None, + "ask_sz": None, + "mark": 11.2, + "ref_ask": 11.2, + "can_open": False, + "open_block_msg": "暂无卖一深度,无法买入", + "ct_mult": 0.01, + } + ) + cfg = { + "exchange_options": object(), + "quote_option_contract": quote, + "place_option_limit_order": MagicMock(), + } + out = _buy_option(cfg, inst_id="ETH-USD_UM-260717-1900-C", sheets=1, dry_run=True) + self.assertFalse(out["ok"]) + self.assertIn("卖一", out["msg"]) + cfg["place_option_limit_order"].assert_not_called() + + def test_buy_caps_sheets_to_ask_depth(self): + from lib.hedge_plan.hedge_plan_orders_lib import _buy_option + + quote = MagicMock( + return_value={ + "ok": True, + "ask": 10, + "ask_sz": 2, + "can_open": True, + "ct_mult": 0.01, + "tick_sz": "0.1", + "meta": {"optType": "C"}, + } + ) + cfg = { + "exchange_options": object(), + "quote_option_contract": quote, + "place_option_limit_order": MagicMock(), + "td_mode_for_option_buy": lambda x: "isolated", + } + out = _buy_option(cfg, inst_id="X", sheets=9, dry_run=True) + self.assertTrue(out["ok"]) + self.assertEqual(out["sheets"], 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_history_window_lib.py b/tests/test_history_window_lib.py new file mode 100644 index 0000000..726e7fd --- /dev/null +++ b/tests/test_history_window_lib.py @@ -0,0 +1,47 @@ +"""history_window_lib 单元测试.""" +from __future__ import annotations + +import unittest +from datetime import datetime, timezone +from unittest.mock import patch + +from lib.common.history_window_lib import ( + PRESET_ALL, + PRESET_DEFAULT, + PRESET_UTC_LAST3M, + PRESET_UTC_THIS_MONTH, + resolve_window, +) + + +class TestHistoryWindowLib(unittest.TestCase): + def test_default_is_this_month(self): + self.assertEqual(PRESET_DEFAULT, PRESET_UTC_THIS_MONTH) + + def test_resolve_this_month(self): + now = datetime(2026, 7, 8, 12, 0, 0, tzinfo=timezone.utc) + with patch("lib.common.history_window_lib.utc_now", return_value=now): + win = resolve_window({"win_preset": PRESET_UTC_THIS_MONTH}) + self.assertEqual(win["preset"], PRESET_UTC_THIS_MONTH) + self.assertIn("本月", win["label"]) + self.assertEqual(win["start_utc"].month, 7) + self.assertEqual(win["start_utc"].day, 1) + + def test_resolve_last3m_and_all(self): + now = datetime(2026, 7, 8, 12, 0, 0, tzinfo=timezone.utc) + with patch("lib.common.history_window_lib.utc_now", return_value=now): + w3 = resolve_window({"win_preset": PRESET_UTC_LAST3M}) + wall = resolve_window({"win_preset": PRESET_ALL}) + self.assertEqual(w3["label"], "近3月") + self.assertEqual(wall["label"], "全部") + self.assertLess(wall["start_utc"].year, 2020) + + def test_empty_preset_uses_default_month(self): + now = datetime(2026, 7, 8, 12, 0, 0, tzinfo=timezone.utc) + with patch("lib.common.history_window_lib.utc_now", return_value=now): + win = resolve_window({}) + self.assertEqual(win["preset"], PRESET_UTC_THIS_MONTH) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_agent_entry_price.py b/tests/test_hub_agent_entry_price.py new file mode 100644 index 0000000..2f033ca --- /dev/null +++ b/tests/test_hub_agent_entry_price.py @@ -0,0 +1,32 @@ +"""子代理持仓:三所开仓价字段统一解析.""" +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "manual_trading_hub")) + +from agent import _position_entry_price # noqa: E402 + + +class TestHubAgentEntryPrice(unittest.TestCase): + def test_binance_entry_price(self): + px = _position_entry_price({"entryPrice": 65851.6, "info": {}}) + self.assertAlmostEqual(px, 65851.6) + + def test_okx_avg_px(self): + px = _position_entry_price({"info": {"avgPx": "72.731"}}) + self.assertAlmostEqual(px, 72.731) + + def test_gate_info_entry(self): + px = _position_entry_price({"info": {"entry_price": "0.2232"}}) + self.assertAlmostEqual(px, 0.2232) + + def test_missing_returns_none(self): + self.assertIsNone(_position_entry_price({"info": {}})) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_agent_mark_price.py b/tests/test_hub_agent_mark_price.py new file mode 100644 index 0000000..5f5cb9c --- /dev/null +++ b/tests/test_hub_agent_mark_price.py @@ -0,0 +1,94 @@ +"""子代理持仓:三所标记价字段统一解析.""" +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "manual_trading_hub")) + +from agent import _position_mark_price, _ticker_mark_price # noqa: E402 + +sys.path.insert(0, str(ROOT)) +from lib.hub.hub_position_metrics import ( # noqa: E402 + enrich_ccxt_position_metrics_out, + estimate_linear_swap_upnl_usdt, + parse_position_unrealized_pnl, + resolve_position_display_upnl, +) + + +class TestHubAgentMarkPrice(unittest.TestCase): + def test_binance_mark_price(self): + px = _position_mark_price({"markPrice": 65880.1, "info": {}}) + self.assertAlmostEqual(px, 65880.1) + + def test_okx_mark_px(self): + px = _position_mark_price({"info": {"markPx": "72.85"}}) + self.assertAlmostEqual(px, 72.85) + + def test_gate_info_mark(self): + px = _position_mark_price({"info": {"mark_price": "0.2241"}}) + self.assertAlmostEqual(px, 0.2241) + + def test_missing_returns_none(self): + self.assertIsNone(_position_mark_price({"info": {}})) + + def test_infer_from_notional_and_contracts(self): + p = {"notional": 1000, "contracts": 10, "info": {}} + px = _position_mark_price(p) + self.assertAlmostEqual(px, 100.0) + + def test_ticker_fallback(self): + class _Ex: + def fetch_ticker(self, sym): + return {"mark": 99.5, "info": {}} + + self.assertAlmostEqual(_ticker_mark_price(_Ex(), "BTC/USDT:USDT"), 99.5) + + def test_gate_unrealised_pnl_in_info(self): + pnl = parse_position_unrealized_pnl( + {"info": {"unrealised_pnl": "6.81"}, "unrealizedPnl": None} + ) + self.assertAlmostEqual(pnl, 6.81) + + def test_okx_upl_signed(self): + pnl = parse_position_unrealized_pnl( + {"info": {"upl": "-2.15"}, "unrealizedPnl": None} + ) + self.assertAlmostEqual(pnl, -2.15) + + def test_enrich_aligns_short_gate_metrics(self): + pos = { + "side": "short", + "contracts": 11, + "entryPrice": 73.187, + "markPrice": 66.038, + "info": {"unrealised_pnl": "7.86"}, + } + out = {"unrealized_pnl": 7.86, "mark_price": 66.038} + enrich_ccxt_position_metrics_out(pos, out, contract_size=1.0, funds_decimals=2) + self.assertGreater(out["unrealized_pnl"], 70.0) + + def test_estimate_short_hype_contract_size(self): + upnl = estimate_linear_swap_upnl_usdt( + "short", 73.187, 66.038, 11, 0.1 + ) + self.assertAlmostEqual(upnl, 7.86, places=1) + + def test_resolve_prefers_computed_when_exchange_off(self): + shown = resolve_position_display_upnl( + "short", 73.187, 66.038, 11, 1.0, 7.86 + ) + self.assertAlmostEqual(shown, 78.64, places=1) + + def test_resolve_keeps_exchange_when_aligned(self): + shown = resolve_position_display_upnl( + "short", 73.187, 66.038, 11, 0.1, 7.86 + ) + self.assertAlmostEqual(shown, 7.86, places=2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_backup_lib.py b/tests/test_hub_backup_lib.py new file mode 100644 index 0000000..63dda2a --- /dev/null +++ b/tests/test_hub_backup_lib.py @@ -0,0 +1,65 @@ +"""hub_backup_lib 单元测试.""" +from __future__ import annotations + +import json +import sys +import tempfile +import unittest +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from lib.hub import hub_backup_lib as backup + + +class HubBackupLibTest(unittest.TestCase): + def test_normalize_backup_settings(self): + cfg = backup.normalize_backup_settings({"auto_hour": 99, "retention_days": 0}) + self.assertEqual(cfg["auto_hour"], 23) + self.assertEqual(cfg["retention_days"], 1) + + def test_safe_archive_name(self): + self.assertTrue(backup._safe_archive_name("backup_2026-07-02_163045.zip")) + self.assertFalse(backup._safe_archive_name("../evil.zip")) + + def test_run_and_restore_roundtrip(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) / "portal" + root.mkdir(parents=True) + settings = { + "backup": { + "auto_enabled": False, + "backup_root": str(root), + "include_env": False, + "include_exchange_images": False, + } + } + hub_settings = backup.HUB_DIR / "hub_settings.json" + had = hub_settings.is_file() + old = hub_settings.read_text(encoding="utf-8") if had else None + try: + if not had: + hub_settings.write_text('{"version":1,"exchanges":[]}', encoding="utf-8") + result = backup.run_backup(trigger="manual", settings=settings) + self.assertTrue(result.get("ok"), result) + archive = Path(result["path"]) + self.assertTrue(archive.is_file()) + with zipfile.ZipFile(archive, "r") as zf: + names = zf.namelist() + self.assertIn("manifest.json", names) + manifest = json.loads( + zipfile.ZipFile(archive, "r").read("manifest.json").decode("utf-8") + ) + self.assertEqual(manifest.get("trigger"), "manual") + finally: + if had and old is not None: + hub_settings.write_text(old, encoding="utf-8") + elif not had and hub_settings.is_file(): + hub_settings.unlink(missing_ok=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_board_store.py b/tests/test_hub_board_store.py new file mode 100644 index 0000000..4db0207 --- /dev/null +++ b/tests/test_hub_board_store.py @@ -0,0 +1,44 @@ +"""后台 board 缓存:版本递增与快照.""" +from __future__ import annotations + +import asyncio +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "manual_trading_hub")) + +from hub_board_cache import MonitorBoardStore # noqa: E402 + + +class TestHubBoardStore(unittest.TestCase): + def test_snapshot_and_version(self) -> None: + store = MonitorBoardStore() + store.version = 2 + store.payload = {"ok": True, "rows": [{"id": "0"}], "updated_at": "2026-01-01T00:00:00"} + snap = store.snapshot_dict() + self.assertEqual(snap["board_version"], 2) + self.assertEqual(len(snap["rows"]), 1) + + def test_aggregate_increments_version(self) -> None: + async def run() -> None: + store = MonitorBoardStore() + n = 0 + + async def build(): + nonlocal n + n += 1 + return {"ok": True, "rows": [{"n": n}], "updated_at": "t"} + + await store.start(build) + await asyncio.sleep(0.05) + self.assertGreaterEqual(store.version, 1) + await store.stop() + + asyncio.run(run()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_calculator_lib.py b/tests/test_hub_calculator_lib.py new file mode 100644 index 0000000..cc5bb86 --- /dev/null +++ b/tests/test_hub_calculator_lib.py @@ -0,0 +1,163 @@ +"""hub_calculator_lib 测算逻辑.""" + +import unittest +from unittest.mock import patch + +from lib.hub.hub_calculator_lib import ( + calc_initial_roll_qty, + calc_roll_calculator, + calc_trend_calculator, + solve_add_amount_for_total_risk, +) + +MOCK_MARKET = { + "exchange_id": "0", + "exchange_key": "binance", + "exchange_name": "币安 · crypto_monitor_binance", + "exchange_label": "币安 · crypto_monitor_binance", + "base": "ETH", + "exchange_symbol": "ETH/USDT:USDT", + "display_symbol": "ETH/USDT", + "contract_size": 1.0, + "price_tick": 0.01, + "price_decimals": 2, + "amount_decimals": 3, + "min_amount": 0.001, +} + + +def _mock_resolve(_exchange="binance", _base="ETH"): + return MOCK_MARKET, lambda amount: round(float(amount), 3), None + + +class HubCalculatorLibTests(unittest.TestCase): + @patch("lib.hub.hub_calculator_lib._resolve_market", return_value=_mock_resolve()) + def test_trend_calculator_long_basic(self, _mock): + data, err = calc_trend_calculator( + direction="long", + capital_usdt=1000, + risk_percent=5, + leverage=5, + entry_price=100, + stop_loss=95, + add_upper=110, + take_profit=120, + dca_legs=3, + exchange_id="0", + base="ETH", + ) + self.assertIsNone(err) + self.assertIsNotNone(data) + assert data is not None + self.assertEqual(data["risk_budget_u"], 50.0) + self.assertGreaterEqual(len(data["rows"]), 2) + self.assertEqual(data["rows"][0]["label"], "首仓") + self.assertEqual(data["market"]["display_symbol"], "ETH/USDT") + + @patch("lib.hub.hub_calculator_lib._resolve_market", return_value=_mock_resolve()) + def test_trend_calculator_short_rejects_bad_bounds(self, _mock): + data, err = calc_trend_calculator( + direction="short", + capital_usdt=1000, + risk_percent=5, + leverage=5, + entry_price=100, + stop_loss=90, + add_upper=110, + take_profit=80, + dca_legs=3, + ) + self.assertIsNone(data) + self.assertIsNotNone(err) + + @patch("lib.hub.hub_calculator_lib._resolve_market", return_value=_mock_resolve()) + def test_roll_calculator_first_leg_auto(self, _mock): + data, err = calc_roll_calculator( + direction="long", + capital_usdt=1000, + risk_percent=5, + entry_price=100, + stop_loss=95, + take_profit=120, + add_legs=[], + legs_done=0, + ) + self.assertIsNone(err) + self.assertIsNotNone(data) + assert data is not None + self.assertEqual(data["first_contracts"], 10.0) + self.assertEqual(len(data["rows"]), 1) + self.assertEqual(data["rows"][0]["loss_at_sl_u"], 50.0) + # 毛利 200 − 双边费 (1000+1200)*0.0005=1.1 → 198.9 + self.assertEqual(data["rows"][0]["profit_at_tp_u"], 198.9) + + @patch("lib.hub.hub_calculator_lib._resolve_market", return_value=_mock_resolve()) + def test_roll_calculator_chain_two_legs(self, _mock): + data, err = calc_roll_calculator( + direction="long", + capital_usdt=1000, + risk_percent=5, + entry_price=100, + stop_loss=95, + take_profit=120, + add_legs=[ + {"add_price": 105, "new_stop_loss": 98}, + {"add_price": 108, "new_stop_loss": 101}, + ], + legs_done=0, + ) + self.assertIsNone(err) + self.assertIsNotNone(data) + assert data is not None + self.assertEqual(len(data["rows"]), 3) + self.assertEqual(data["rows"][1]["label"], "滚仓1") + self.assertGreater(float(data["final_contracts"]), float(data["first_contracts"])) + + @patch("lib.hub.hub_calculator_lib._resolve_market", return_value=_mock_resolve()) + def test_roll_calculator_rejects_too_many_legs(self, _mock): + data, err = calc_roll_calculator( + direction="long", + capital_usdt=1000, + risk_percent=5, + entry_price=100, + stop_loss=95, + take_profit=120, + add_legs=[ + {"add_price": 105, "new_stop_loss": 98}, + {"add_price": 108, "new_stop_loss": 101}, + {"add_price": 110, "new_stop_loss": 103}, + {"add_price": 112, "new_stop_loss": 105}, + ], + legs_done=0, + ) + self.assertIsNone(data) + self.assertIsNotNone(err) + + def test_initial_roll_qty(self): + qty, err = calc_initial_roll_qty("long", 100, 95, 50, 1.0) + self.assertIsNone(err) + self.assertEqual(qty, 10.0) + + def test_initial_roll_qty_with_contract_size(self): + qty, err = calc_initial_roll_qty("long", 100, 95, 50, 0.1) + self.assertIsNone(err) + self.assertEqual(qty, 100.0) + + def test_solve_add_with_contract_size(self): + q2, err = solve_add_amount_for_total_risk( + "long", + qty_existing=10.0, + entry_existing=100.0, + add_price=105.0, + new_stop=98.0, + risk_budget_usdt=50.0, + contract_size=1.0, + ) + self.assertIsNone(err) + self.assertIsNotNone(q2) + assert q2 is not None + self.assertGreater(q2, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_calculator_market_lib.py b/tests/test_hub_calculator_market_lib.py new file mode 100644 index 0000000..6db908d --- /dev/null +++ b/tests/test_hub_calculator_market_lib.py @@ -0,0 +1,113 @@ +"""hub_calculator_market_lib 合约解析.""" + +import unittest +from unittest.mock import patch + +from lib.hub.hub_calculator_market_lib import ( + amount_decimals_from_exchange, + find_exchange, + get_calculator_market, + list_calculator_exchanges, + make_amount_precise_fn_from_market, + normalize_base_symbol, + resolve_usdt_perp_symbol, +) + + +class FakeExchange: + def __init__(self, markets: dict): + self.markets = markets + + def market(self, symbol: str): + return self.markets[symbol] + + def amount_to_precision(self, symbol: str, amount: float) -> str: + return f"{float(amount):.3f}" + + +class HubCalculatorMarketLibTests(unittest.TestCase): + def test_normalize_base_symbol(self): + self.assertEqual(normalize_base_symbol("eth"), "ETH") + self.assertEqual(normalize_base_symbol("ETH/USDT:USDT"), "ETH") + self.assertEqual(normalize_base_symbol("ETHUSDT"), "ETH") + + def test_resolve_usdt_perp_symbol(self): + ex = FakeExchange( + { + "ETH/USDT:USDT": { + "base": "ETH", + "quote": "USDT", + "swap": True, + "active": True, + "contractSize": 1.0, + "limits": {"amount": {"min": 0.001}}, + "precision": {"price": 2, "amount": 3}, + } + } + ) + sym, err = resolve_usdt_perp_symbol(ex, "ETH") + self.assertIsNone(err) + self.assertEqual(sym, "ETH/USDT:USDT") + + def test_amount_decimals_from_exchange(self): + ex = FakeExchange({}) + self.assertEqual(amount_decimals_from_exchange(ex, "ETH/USDT:USDT"), 3) + + def test_make_amount_precise_fn_from_market(self): + fn = make_amount_precise_fn_from_market({"amount_decimals": 3, "min_amount": 0.001}) + self.assertEqual(fn(1.23456), 1.234) + self.assertIsNone(fn(0.0001)) + + @patch.dict("os.environ", {"HUB_BRIDGE_TOKEN": "test-token"}, clear=False) + def test_hub_headers_use_x_hub_token(self): + from lib.hub.hub_calculator_market_lib import _hub_headers + + self.assertEqual(_hub_headers(), {"X-Hub-Token": "test-token"}) + + @patch("lib.hub.hub_calculator_market_lib.fetch_instance_market_sync") + def test_get_calculator_market_from_instance(self, fetch_mock): + fetch_mock.return_value = { + "ok": True, + "base": "ETH", + "exchange_symbol": "ETH/USDT:USDT", + "display_symbol": "ETH/USDT", + "contract_size": 0.01, + "price_tick": 0.01, + "price_decimals": 2, + "amount_decimals": 2, + "min_amount": 0.01, + } + ex = { + "id": "0", + "key": "binance", + "name": "币安 · crypto_monitor_binance", + "enabled": True, + "flask_url": "http://127.0.0.1:5001", + } + data, err = get_calculator_market("0", "ETH", ex=ex) + self.assertIsNone(err) + self.assertIsNotNone(data) + assert data is not None + self.assertEqual(data["exchange_id"], "0") + self.assertEqual(data["exchange_name"], "币安 · crypto_monitor_binance") + self.assertEqual(data["contract_size"], 0.01) + + @patch("lib.hub.hub_calculator_market_lib.enabled_exchanges") + def test_list_calculator_exchanges(self, enabled_mock): + enabled_mock.return_value = [ + {"id": "0", "key": "binance", "name": "币安", "enabled": True}, + ] + rows = list_calculator_exchanges() + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["id"], "0") + + def test_find_exchange_by_id(self): + with patch( + "lib.hub.hub_calculator_market_lib.load_settings", + return_value={"exchanges": [{"id": "2", "key": "gate", "name": "Gate"}]}, + ): + self.assertEqual(find_exchange("2")["key"], "gate") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_chart_cache.py b/tests/test_hub_chart_cache.py new file mode 100644 index 0000000..fd417c3 --- /dev/null +++ b/tests/test_hub_chart_cache.py @@ -0,0 +1,95 @@ +"""行情区 chart 后台轮询订阅.""" +from __future__ import annotations + +import asyncio +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "manual_trading_hub")) + +from hub_chart_cache import ChartPollStore, series_key # noqa: E402 + + +class TestHubChartCache(unittest.TestCase): + def test_series_key(self) -> None: + self.assertEqual(series_key("Gate_X", "hype/usdt", "5m"), "gate_x|HYPE/USDT|5m") + + def test_position_and_watch_keys(self) -> None: + store = ChartPollStore() + store.sync_positions_from_rows( + [ + { + "key": "okx_auto", + "agent": { + "ok": True, + "positions": [{"symbol": "BTC/USDT"}, {"symbol": "ETH/USDT"}], + }, + } + ] + ) + store.touch_watch("gate_trend", "HYPE/USDT", "5m") + keys = store.active_series_keys() + self.assertIn(series_key("okx_auto", "BTC/USDT", "5m"), keys) + self.assertIn(series_key("gate_trend", "HYPE/USDT", "5m"), keys) + + def test_note_series_result_pushes_tail_candles(self) -> None: + store = ChartPollStore() + key = series_key("binance", "BTC/USDT", "15m") + candles = [ + {"time": 1_700_000_000 + i * 900, "open": 1, "high": 2, "low": 0.5, "close": 1.5, "volume": 10} + for i in range(40) + ] + store.note_series_result( + "binance", + "BTC/USDT", + "15m", + ok=True, + fetched=3, + candles=candles, + price_tick=0.01, + ) + ev = store.event_dict() + self.assertIn("tails", ev) + self.assertIn(key, ev["tails"]) + tail = ev["tails"][key] + self.assertEqual(len(tail["candles"]), 30) + self.assertEqual(tail["price_tick"], 0.01) + self.assertGreater(tail["series_version"], 0) + + def test_broadcast_clears_pending_tails(self) -> None: + store = ChartPollStore() + store.note_series_result( + "gate", + "ONDO/USDT", + "5m", + ok=True, + candles=[{"time": 100, "open": 1, "high": 1, "low": 1, "close": 1, "volume": 1}], + ) + store._broadcast() + ev = store.event_dict() + self.assertNotIn("tails", ev) + + def test_poll_increments_version(self) -> None: + async def run() -> None: + store = ChartPollStore() + n = 0 + + async def poll(): + nonlocal n + n += 1 + store.touch_watch("binance", "BTC/USDT", "1d") + return {"ok": True, "n": n} + + await store.start(poll) + await asyncio.sleep(0.05) + self.assertGreaterEqual(store.version, 1) + await store.stop() + + asyncio.run(run()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_cond_orders_dedupe.py b/tests/test_hub_cond_orders_dedupe.py new file mode 100644 index 0000000..fead43d --- /dev/null +++ b/tests/test_hub_cond_orders_dedupe.py @@ -0,0 +1,67 @@ +"""中控条件单列表:子代理与 Flask exchange_tpsl 合并去重.""" + +from manual_trading_hub.hub import _merge_conditional_orders_no_dup, _merge_flask_exchange_tpsl + + +def test_merge_skips_duplicate_trigger_prices(): + existing = [ + { + "id": "100", + "label": "市价 买入 ·只减仓", + "trigger_price": 57, + "amount": 11, + }, + { + "id": "101", + "label": "市价 买入 ·只减仓", + "trigger_price": 71, + "amount": 11, + }, + ] + extra = [ + {"id": "", "label": "止损 57", "trigger_price": 57, "amount": 11}, + {"id": "", "label": "止盈 71", "trigger_price": 71, "amount": 11}, + ] + merged = _merge_conditional_orders_no_dup(existing, extra) + assert len(merged) == 2 + assert {round(o["trigger_price"]) for o in merged} == {57, 71} + + +def test_merge_uses_extra_when_existing_empty(): + extra = [{"id": "1", "label": "止损 57", "trigger_price": 57}] + assert _merge_conditional_orders_no_dup([], extra) == extra + + +def test_merge_flask_skips_duplicate_sl_when_agent_has_both(): + agent_row = { + "agent": { + "positions": [ + { + "symbol": "SOL/USDT:USDT", + "side": "short", + "conditional_orders": [ + {"label": "止盈 76", "trigger_price": 76, "algo_id": "1"}, + {"label": "止损 84.1", "trigger_price": 84.1, "algo_id": "1"}, + {"label": "止损", "trigger_price": 84.1}, + ], + } + ] + } + } + snap = { + "order_prices": [ + { + "symbol": "SOL/USDT:USDT", + "side": "short", + "exchange_tpsl": { + "sl": {"trigger_price": 84.1, "order_id": "old"}, + "tp": {"trigger_price": 76, "order_id": "old"}, + }, + } + ] + } + _merge_flask_exchange_tpsl(agent_row, snap, None) + cond = agent_row["agent"]["positions"][0]["conditional_orders"] + sl_rows = [o for o in cond if "止损" in (o.get("label") or "")] + assert len(sl_rows) == 1 + assert len(cond) == 2 diff --git a/tests/test_hub_divergence_scan_lib.py b/tests/test_hub_divergence_scan_lib.py new file mode 100644 index 0000000..3ef83c5 --- /dev/null +++ b/tests/test_hub_divergence_scan_lib.py @@ -0,0 +1,101 @@ +import unittest + +from lib.hub.hub_divergence_scan_lib import ( + analyze_ohlcv_bars, + build_symbol_scan_row, + compute_confluence, + detect_latest_macd_divergence, + filter_tab_items, +) + + +def _synthetic_bull_div_closes(n: int = 120) -> list[float]: + """价格双底 + MACD 抬高 → 底背离.""" + closes = [100.0] * n + # 下跌 + for i in range(20, 40): + closes[i] = 100 - (i - 20) * 0.8 + # 反弹 + for i in range(40, 55): + closes[i] = closes[39] + (i - 40) * 0.5 + # 再跌略破前低 + for i in range(55, 75): + closes[i] = closes[54] - (i - 55) * 0.35 + # 末尾企稳略抬 + for i in range(75, n): + closes[i] = closes[74] + (i - 75) * 0.02 + return closes + + +class TestHubDivergenceScanLib(unittest.TestCase): + def test_compute_confluence_three_same(self): + tf = { + "4h": {"direction": "bull"}, + "1d": {"direction": "bull"}, + "1w": {"direction": "bull"}, + } + c = compute_confluence(tf) + self.assertEqual(c["confluence"], 3) + self.assertEqual(c["confluence_css"], "c3") + self.assertFalse(c["is_split"]) + + def test_compute_confluence_split(self): + tf = { + "4h": {"direction": "bull"}, + "1d": {"direction": "bear"}, + "1w": {"direction": None}, + } + c = compute_confluence(tf) + self.assertTrue(c["is_split"]) + self.assertEqual(c["confluence_kind"], "分歧") + self.assertEqual(c["confluence_css"], "split") + self.assertIn("4h底", c["split_detail"]) + + def test_filter_tab_items_only_matching_tf(self): + items = [ + build_symbol_scan_row( + rank=1, + symbol="AAA/USDT", + volume_label="1M", + tf_hits={ + "4h": {"direction": "bull", "bars_ago": 2, "open_time_ms": 1}, + "1d": {"direction": None}, + "1w": {"direction": None}, + }, + ), + build_symbol_scan_row( + rank=2, + symbol="BBB/USDT", + volume_label="2M", + tf_hits={ + "4h": {"direction": None}, + "1d": {"direction": "bear", "bars_ago": 1, "open_time_ms": 2}, + "1w": {"direction": None}, + }, + ), + ] + f4 = filter_tab_items(items, "4h") + self.assertEqual(len(f4), 1) + self.assertEqual(f4[0]["symbol"], "AAA/USDT") + f1d = filter_tab_items(items, "1d") + self.assertEqual(len(f1d), 1) + self.assertEqual(f1d[0]["symbol"], "BBB/USDT") + + def test_detect_macd_divergence_may_hit_on_synthetic(self): + closes = _synthetic_bull_div_closes() + hit = detect_latest_macd_divergence(closes) + # 合成数据不保证必中,但函数应正常返回 + self.assertIn(hit.get("direction"), (None, "bull", "bear")) + + def test_analyze_ohlcv_bars_from_rows(self): + closes = [float(100 + i * 0.1) for i in range(80)] + bars = [ + {"open_time_ms": i * 3600000, "close": c, "open": c, "high": c, "low": c} + for i, c in enumerate(closes) + ] + out = analyze_ohlcv_bars(bars) + self.assertIn("direction", out) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_entry_plan_lib.py b/tests/test_hub_entry_plan_lib.py new file mode 100644 index 0000000..e8791be --- /dev/null +++ b/tests/test_hub_entry_plan_lib.py @@ -0,0 +1,157 @@ +"""开仓计划库:CRUD 与胜率统计.""" + +from __future__ import annotations + +import tempfile +from pathlib import Path + +from lib.hub.hub_entry_plan_lib import ( + compute_entry_plan_stats, + create_entry_plan, + delete_entry_plan, + init_db, + list_entry_plans, + normalize_plan_symbol, + resolve_stats_date_bounds, + update_entry_plan, +) + + +def _base_payload(**overrides): + data = { + "plan_date": "2026-06-14", + "exchange_key": "binance", + "symbol": "BTC", + "plan_type": "trend", + "trend_timeframe": "4h", + "entry_timeframe": "15m", + "direction": "long", + "target_level": "70000", + "current_range": "68000-69000", + "entry_scheme": "breakout", + "note": "test", + } + data.update(overrides) + return data + + +def test_normalize_plan_symbol(): + assert normalize_plan_symbol("btc") == "BTC/USDT" + assert normalize_plan_symbol("ETH/USDT") == "ETH/USDT" + + +def test_create_without_entry_scheme(): + with tempfile.TemporaryDirectory() as td: + db = Path(td) / "plans.db" + payload = _base_payload() + del payload["entry_scheme"] + row = create_entry_plan(payload, db_path=db) + assert row["entry_scheme"] == "" + assert row["entry_scheme_label"] == "待填写" + + +def test_archive_requires_entry_scheme(): + with tempfile.TemporaryDirectory() as td: + db = Path(td) / "plans.db" + payload = _base_payload() + del payload["entry_scheme"] + row = create_entry_plan(payload, db_path=db) + try: + update_entry_plan(int(row["id"]), {"result": "win"}, db_path=db) + assert False, "expected ValueError" + except ValueError as e: + assert "入场方案" in str(e) + updated = update_entry_plan( + int(row["id"]), + {"entry_scheme": "breakout", "result": "win"}, + db_path=db, + ) + assert updated["status"] == "archived" + + +def test_create_list_delete_active_plan(): + with tempfile.TemporaryDirectory() as td: + db = Path(td) / "plans.db" + row = create_entry_plan(_base_payload(), db_path=db) + assert row["status"] == "active" + assert row["symbol"] == "BTC/USDT" + active = list_entry_plans(status="active", db_path=db) + assert len(active) == 1 + assert delete_entry_plan(int(row["id"]), db_path=db) is True + assert list_entry_plans(status="active", db_path=db) == [] + + +def test_archive_on_result(): + with tempfile.TemporaryDirectory() as td: + db = Path(td) / "plans.db" + row = create_entry_plan(_base_payload(symbol="SOL"), db_path=db) + updated = update_entry_plan( + int(row["id"]), + {"result": "win", "pnl_amount": 12.5}, + db_path=db, + ) + assert updated["status"] == "archived" + assert updated["result"] == "win" + assert updated["pnl_amount"] == 12.5 + assert list_entry_plans(status="active", db_path=db) == [] + archived = list_entry_plans(status="archived", db_path=db) + assert len(archived) == 1 + + +def test_archive_without_pnl_amount(): + with tempfile.TemporaryDirectory() as td: + db = Path(td) / "plans.db" + row = create_entry_plan(_base_payload(symbol="DOGE"), db_path=db) + updated = update_entry_plan(int(row["id"]), {"result": "loss"}, db_path=db) + assert updated["status"] == "archived" + assert updated["pnl_amount"] is None + + +def test_cannot_delete_archived(): + with tempfile.TemporaryDirectory() as td: + db = Path(td) / "plans.db" + row = create_entry_plan(_base_payload(), db_path=db) + update_entry_plan(int(row["id"]), {"result": "win"}, db_path=db) + try: + delete_entry_plan(int(row["id"]), db_path=db) + assert False, "expected ValueError" + except ValueError as e: + assert "仅进行中" in str(e) + + +def test_compute_stats_by_symbol(): + with tempfile.TemporaryDirectory() as td: + db = Path(td) / "plans.db" + for sym, res in (("BTC", "win"), ("BTC", "loss"), ("ETH", "win")): + row = create_entry_plan(_base_payload(symbol=sym), db_path=db) + update_entry_plan(int(row["id"]), {"result": res}, db_path=db) + stats = compute_entry_plan_stats(dimension="symbol", period="all", db_path=db) + by_sym = {it["key"]: it for it in stats["items"]} + assert by_sym["BTC/USDT"]["win_count"] == 1 + assert by_sym["BTC/USDT"]["loss_count"] == 1 + assert by_sym["BTC/USDT"]["win_rate"] == 50.0 + assert by_sym["ETH/USDT"]["win_count"] == 1 + + +def test_stats_period_range_filter(): + with tempfile.TemporaryDirectory() as td: + db = Path(td) / "plans.db" + row1 = create_entry_plan(_base_payload(plan_date="2026-06-01"), db_path=db) + row2 = create_entry_plan(_base_payload(plan_date="2026-06-20", symbol="ETH"), db_path=db) + update_entry_plan(int(row1["id"]), {"result": "win"}, db_path=db) + update_entry_plan(int(row2["id"]), {"result": "loss"}, db_path=db) + stats = compute_entry_plan_stats( + dimension="symbol", + period="range", + date_from="2026-06-01", + date_to="2026-06-10", + db_path=db, + ) + assert len(stats["items"]) == 1 + assert stats["items"][0]["key"] == "BTC/USDT" + + +def test_resolve_stats_date_bounds(): + df, dt, label = resolve_stats_date_bounds(period="all") + assert df is None and dt is None + assert "全部" in label diff --git a/tests/test_hub_exchange_orders_okx.py b/tests/test_hub_exchange_orders_okx.py new file mode 100644 index 0000000..84ef21e --- /dev/null +++ b/tests/test_hub_exchange_orders_okx.py @@ -0,0 +1,67 @@ +"""OKX 中控委托:须为 OCO 条件单,不得带 reduceOnly 或分两笔 market.""" +from __future__ import annotations + +import sys +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "manual_trading_hub")) + +from exchange_orders import _okx_place_tp_sl # noqa: E402 + + +class TestHubOkxPlaceTpsl(unittest.TestCase): + def test_okx_place_tpsl_single_oco_without_reduce_only(self): + captured: list[dict] = [] + + def fake_create_order(symbol, order_type, side, amount, price, params): + captured.append( + { + "symbol": symbol, + "type": order_type, + "side": side, + "amount": amount, + "params": dict(params or {}), + } + ) + return {"id": "algo-1"} + + ex = MagicMock() + ex.create_order = fake_create_order + ex.load_markets = MagicMock() + ex.amount_to_precision = lambda sym, amt: str(amt) + ex.price_to_precision = lambda sym, px: str(px) + + with patch.dict( + "os.environ", + {"OKX_POS_MODE": "hedge", "OKX_TD_MODE": "cross"}, + clear=False, + ): + _okx_place_tp_sl( + ex, + "HYPE/USDT:USDT", + "short", + 6.0, + 75.5, + 70.2, + ) + + self.assertEqual(len(captured), 1, captured) + call = captured[0] + self.assertEqual(call["type"], "oco") + self.assertEqual(call["side"], "buy") + params = call["params"] + self.assertNotIn("reduceOnly", params) + self.assertEqual(params.get("posSide"), "short") + self.assertEqual(params.get("positionSide"), "short") + self.assertEqual(params.get("stopLossPrice"), 75.5) + self.assertEqual(params.get("takeProfitPrice"), 70.2) + self.assertEqual(params.get("tpOrdPx"), "-1") + self.assertEqual(params.get("slOrdPx"), "-1") + self.assertNotIn("stopLoss", params) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_fund_history_lib.py b/tests/test_hub_fund_history_lib.py new file mode 100644 index 0000000..94c0596 --- /dev/null +++ b/tests/test_hub_fund_history_lib.py @@ -0,0 +1,128 @@ +"""hub_fund_history_lib:总资金,回撤与日快照.""" +from __future__ import annotations + +from lib.hub.hub_fund_history_lib import ( + account_total_usdt, + build_fund_overview, + compute_drawdown, + compute_period_delta, + get_fund_history, + record_fund_snapshot, +) + + +def test_account_total_requires_both_sides(): + assert account_total_usdt(10, 20) == 30.0 + assert account_total_usdt(10, None) is None + assert account_total_usdt(None, 5) is None + + +def test_compute_drawdown(): + dd = compute_drawdown([100, 120, 90, 110]) + assert dd["peak_usdt"] == 120.0 + assert dd["max_drawdown_u"] == 30.0 + assert dd["max_drawdown_pct"] == 25.0 + + +def test_compute_period_delta(): + out = compute_period_delta( + [ + {"day": "2026-06-09", "total_usdt": 100}, + {"day": "2026-06-10", "total_usdt": 112.5}, + ] + ) + assert out["start_usdt"] == 100.0 + assert out["period_delta_usdt"] == 12.5 + assert out["period_delta_pct"] == 12.5 + empty = compute_period_delta([]) + assert empty["period_delta_usdt"] is None + + +def test_build_fund_overview_skips_unmonitored(tmp_path, monkeypatch): + hist_path = tmp_path / "hub_fund_history.json" + monkeypatch.setattr("hub_fund_history_lib.FUND_HISTORY_PATH", hist_path) + record_fund_snapshot( + "2026-06-01", + [ + { + "key": "binance", + "name": "Binance", + "funding_usdt": 10, + "trading_usdt": 20, + "monitored": True, + } + ], + keep_days=180, + ) + record_fund_snapshot( + "2026-06-02", + [ + { + "key": "binance", + "name": "Binance", + "funding_usdt": 12, + "trading_usdt": 18, + "monitored": True, + } + ], + keep_days=180, + ) + exchanges = [ + {"id": "0", "key": "binance", "name": "Binance", "enabled": True}, + {"id": "2", "key": "gate", "name": "Gate", "enabled": False}, + ] + board_rows = [ + { + "key": "binance", + "name": "Binance", + "account_ok": True, + "funding_usdt": 15, + "trading_usdt": 25, + } + ] + out = build_fund_overview( + exchanges, + board_rows=board_rows, + trading_day="2026-06-02", + keep_days=180, + ) + assert out["totals"]["total_usdt"] == 40.0 + assert out["totals"]["monitored_count"] == 1 + assert len(out["accounts"]) == 1 + assert all(a["monitored"] for a in out["accounts"]) + assert out["totals"]["drawdown"]["max_drawdown_u"] == 0.0 + + +def test_history_start_day_filters_older(tmp_path, monkeypatch): + hist_path = tmp_path / "hub_fund_history.json" + monkeypatch.setattr("hub_fund_history_lib.FUND_HISTORY_PATH", hist_path) + monkeypatch.setattr("hub_fund_history_lib.FUND_HISTORY_START_DAY", "2026-06-09") + record_fund_snapshot( + "2026-06-01", + [ + { + "key": "binance", + "name": "Binance", + "funding_usdt": 1, + "trading_usdt": 1, + "monitored": True, + } + ], + keep_days=180, + ) + record_fund_snapshot( + "2026-06-09", + [ + { + "key": "binance", + "name": "Binance", + "funding_usdt": 10, + "trading_usdt": 20, + "monitored": True, + } + ], + keep_days=180, + ) + hist = get_fund_history(anchor_day="2026-06-10", keep_days=180) + assert "2026-06-01" not in hist + assert "2026-06-09" in hist diff --git a/tests/test_hub_host_status_lib.py b/tests/test_hub_host_status_lib.py new file mode 100644 index 0000000..5c5226c --- /dev/null +++ b/tests/test_hub_host_status_lib.py @@ -0,0 +1,58 @@ +"""hub_host_status_lib 单元测试.""" +from __future__ import annotations + +import sys +import unittest +from unittest.mock import MagicMock, patch + +from lib.hub.hub_host_status_lib import _disk_path, _state, get_host_status + + +class HubHostStatusLibTest(unittest.TestCase): + def setUp(self): + _state["primed"] = False + _state["net_ts"] = 0.0 + _state["net_sent"] = 0 + _state["net_recv"] = 0 + + def test_disk_path_env_override(self): + with patch.dict("os.environ", {"HUB_HOST_DISK_PATH": "/data"}, clear=False): + self.assertEqual(_disk_path(), "/data") + + def test_get_host_status_without_psutil(self): + import builtins + + real_import = builtins.__import__ + + def fake_import(name, globals=None, locals=None, fromlist=(), level=0): + if name == "psutil": + raise ImportError("no psutil") + return real_import(name, globals, locals, fromlist, level) + + with patch("builtins.__import__", side_effect=fake_import): + out = get_host_status() + self.assertFalse(out.get("ok")) + self.assertIn("psutil", out.get("msg", "")) + + def test_get_host_status_payload(self): + fake_vm = MagicMock(total=8_000_000_000, used=3_200_000_000, percent=40.0) + fake_du = MagicMock(total=100_000_000_000, used=50_000_000_000) + fake_net = MagicMock(bytes_sent=1_000_000, bytes_recv=2_000_000) + fake_psutil = MagicMock() + fake_psutil.cpu_percent.return_value = 12.5 + fake_psutil.cpu_count.return_value = 4 + fake_psutil.virtual_memory.return_value = fake_vm + fake_psutil.disk_usage.return_value = fake_du + fake_psutil.net_io_counters.return_value = fake_net + fake_psutil.boot_time.return_value = 1_700_000_000.0 + with patch.dict(sys.modules, {"psutil": fake_psutil}): + out = get_host_status() + self.assertTrue(out.get("ok")) + self.assertEqual(out["cpu"]["percent"], 12.5) + self.assertEqual(out["memory"]["percent"], 40.0) + self.assertEqual(out["disk"]["percent"], 50.0) + self.assertIn("network", out) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_kline_store.py b/tests/test_hub_kline_store.py new file mode 100644 index 0000000..8e0b2da --- /dev/null +++ b/tests/test_hub_kline_store.py @@ -0,0 +1,466 @@ +"""中控 K 线库:分周期保留,聚合与分页读取.""" +from __future__ import annotations + +import tempfile +import time +import unittest +from pathlib import Path + +from lib.hub.hub_kline_store import ( + HUB_KLINE_REMOTE_FETCH_CAP, + _since_ms_for_span, + clear_series_bars, + init_db, + load_bars_before, + load_bars_latest, + purge_retention, + purge_timeframe_by_days, + resolve_chart_bars, + retention_days, + trim_contiguous_tail, + upsert_bars, +) +from lib.hub.hub_ohlcv_lib import ( + TIMEFRAME_MS, + bar_limit_for_timeframe, + chart_fetch_start_ms, + chart_initial_limit, + last_closed_bar_open_ms, + window_start_ms, +) + + +class TestHubKlineStore(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.db = Path(self.tmp.name) / "test_hub_kline.db" + + def tearDown(self): + self.tmp.cleanup() + + def test_bar_limits(self): + self.assertEqual(bar_limit_for_timeframe("5m"), 5000) + self.assertEqual(bar_limit_for_timeframe("1h"), 1000) + self.assertEqual(bar_limit_for_timeframe("1d"), 1000) + self.assertEqual(bar_limit_for_timeframe("1w"), 500) + self.assertEqual(chart_initial_limit("5m"), 2000) + self.assertEqual(chart_initial_limit("1h"), 1000) + self.assertEqual(chart_initial_limit("1d"), 500) + + def test_chart_fetch_window_exceeds_retention(self): + now = int(time.time() * 1000) + need = bar_limit_for_timeframe("1d") + fetch_start = chart_fetch_start_ms("1d", need, now) + db_start = window_start_ms("1d", need, retention_days(), now) + self.assertLess(fetch_start, db_start) + + def test_purge_retention_5m_one_year(self): + init_db(self.db) + old_ms = int(time.time() * 1000) - 400 * 86400000 + upsert_bars( + "okx", + "BTC/USDT", + "5m", + [ + { + "open_time_ms": old_ms, + "open": 1, + "high": 2, + "low": 0.5, + "close": 1.5, + "volume": 10, + } + ], + self.db, + ) + n = purge_timeframe_by_days("5m", 365, self.db) + self.assertGreaterEqual(n, 1) + rows = load_bars_latest("okx", "BTC/USDT", "5m", 10, self.db) + self.assertEqual(len(rows), 0) + + def test_purge_retention_keeps_1d(self): + init_db(self.db) + old_ms = int(time.time() * 1000) - 400 * 86400000 + upsert_bars( + "okx", + "BTC/USDT", + "1d", + [ + { + "open_time_ms": old_ms, + "open": 1, + "high": 2, + "low": 0.5, + "close": 1.5, + "volume": 10, + } + ], + self.db, + ) + purge_retention(self.db) + rows = load_bars_latest("okx", "BTC/USDT", "1d", 10, self.db) + self.assertEqual(len(rows), 1) + + def test_resolve_uses_cache_without_remote(self): + init_db(self.db) + now = int(time.time() * 1000) + tf = "5m" + period = TIMEFRAME_MS[tf] + last_closed = last_closed_bar_open_ms(tf, now) + bars = [] + for i in range(400): + oms = last_closed - (399 - i) * period + bars.append( + { + "open_time_ms": oms, + "open": 100 + i, + "high": 101 + i, + "low": 99 + i, + "close": 100.5 + i, + "volume": 1000 + i, + } + ) + upsert_bars("okx", "ETH/USDT", tf, bars, self.db) + + def remote_fetch(**kwargs): + self.fail("不应请求交易所") + + out = resolve_chart_bars( + "okx", + "ETH/USDT", + tf, + remote_fetch, + db_path=self.db, + limit=300, + ) + self.assertTrue(out.get("ok")) + self.assertEqual(len(out.get("candles") or []), 300) + + def test_resolve_15m_reads_native_bars(self): + init_db(self.db) + now = int(time.time() * 1000) + period = TIMEFRAME_MS["15m"] + last_closed = last_closed_bar_open_ms("15m", now) + bars = [] + for i in range(12): + oms = last_closed - (11 - i) * period + bars.append( + { + "open_time_ms": oms, + "open": 1.0 + i, + "high": 2.0 + i, + "low": 0.5 + i, + "close": 1.5 + i, + "volume": 10.0, + } + ) + upsert_bars("okx", "ETH/USDT", "15m", bars, self.db) + + def remote_fetch(**kwargs): + self.fail("不应请求交易所") + + out = resolve_chart_bars( + "okx", + "ETH/USDT", + "15m", + remote_fetch, + db_path=self.db, + limit=10, + ) + self.assertTrue(out.get("ok")) + self.assertEqual(out.get("source"), "db") + self.assertEqual(out.get("storage_timeframe"), "15m") + self.assertGreaterEqual(len(out.get("candles") or []), 10) + + def test_load_bars_before(self): + init_db(self.db) + period = TIMEFRAME_MS["1h"] + base = 1_700_000_000_000 + bars = [] + for i in range(5): + bars.append( + { + "open_time_ms": base + i * period, + "open": 1, + "high": 2, + "low": 0.5, + "close": 1.5, + "volume": 1, + } + ) + upsert_bars("okx", "BTC/USDT", "1h", bars, self.db) + before = base + 3 * period + got = load_bars_before("okx", "BTC/USDT", "1h", before, 2, self.db) + self.assertEqual(len(got), 2) + self.assertEqual(got[-1]["open_time_ms"], base + 2 * period) + + def test_trim_contiguous_tail_drops_orphan_prefix(self): + period = TIMEFRAME_MS["15m"] + base_old = 1_700_000_000_000 + base_new = base_old + period * 500 + bars = [] + for i in range(3): + bars.append( + { + "open_time_ms": base_old + i * period, + "open": 1, + "high": 2, + "low": 0.5, + "close": 1.5, + "volume": 1, + } + ) + for i in range(5): + bars.append( + { + "open_time_ms": base_new + i * period, + "open": 2, + "high": 3, + "low": 1.5, + "close": 2.5, + "volume": 2, + } + ) + trimmed, split = trim_contiguous_tail(bars, period) + self.assertEqual(split, 3) + self.assertEqual(len(trimmed), 5) + self.assertEqual(trimmed[0]["open_time_ms"], base_new) + + def test_resolve_drops_discontinuous_orphans(self): + init_db(self.db) + period = TIMEFRAME_MS["15m"] + now = int(time.time() * 1000) + old_ms = now - period * 800 + upsert_bars( + "okx", + "ONDO/USDT", + "15m", + [ + { + "open_time_ms": old_ms, + "open": 0.33, + "high": 0.34, + "low": 0.32, + "close": 0.335, + "volume": 100, + } + ], + self.db, + ) + recent = [] + start = now - period * 20 + for i in range(20): + recent.append( + { + "open_time_ms": start + i * period, + "open": 0.35, + "high": 0.36, + "low": 0.34, + "close": 0.355, + "volume": 50, + } + ) + + def remote_fetch(**kwargs): + return {"ok": True, "bars": recent, "price_tick": 0.0001} + + out = resolve_chart_bars( + "okx", + "ONDO/USDT", + "15m", + remote_fetch, + db_path=self.db, + limit=50, + ) + self.assertTrue(out.get("ok")) + candles = out.get("candles") or [] + self.assertGreaterEqual(len(candles), 19) + if len(candles) >= 2: + for i in range(1, len(candles)): + gap = candles[i]["time"] - candles[i - 1]["time"] + self.assertLessEqual(gap, int(period / 1000 * 3.0)) + + def test_resolve_refetches_when_db_has_discontinuous_full_count(self): + init_db(self.db) + period = TIMEFRAME_MS["15m"] + now = int(time.time() * 1000) + old_start = now - period * 3000 + recent_start = now - period * 25 + old_bars = [ + { + "open_time_ms": old_start + i * period, + "open": 62000, + "high": 62100, + "low": 61900, + "close": 62050, + "volume": 10, + } + for i in range(500) + ] + recent = [ + { + "open_time_ms": recent_start + i * period, + "open": 104000, + "high": 104100, + "low": 103900, + "close": 104050, + "volume": 20, + } + for i in range(30) + ] + upsert_bars("binance", "BTC/USDT", "15m", old_bars, self.db) + upsert_bars("binance", "BTC/USDT", "15m", recent, self.db) + fetch_calls = [] + + def remote_fetch(**kwargs): + fetch_calls.append(dict(kwargs)) + full = [] + start = now - period * 120 + for i in range(120): + full.append( + { + "open_time_ms": start + i * period, + "open": 104000 + i, + "high": 104100 + i, + "low": 103900 + i, + "close": 104050 + i, + "volume": 30, + } + ) + return {"ok": True, "bars": full, "price_tick": 0.01} + + out = resolve_chart_bars( + "binance", + "BTC/USDT", + "15m", + remote_fetch, + db_path=self.db, + limit=2000, + ) + self.assertTrue(out.get("ok")) + self.assertGreater(len(fetch_calls), 0) + self.assertGreaterEqual(len(out.get("candles") or []), 100) + self.assertGreater(int(out.get("fetched") or 0), 0) + + def test_clear_series_and_force_refetch(self): + init_db(self.db) + period = TIMEFRAME_MS["5m"] + now = int(time.time() * 1000) + stale = [ + { + "open_time_ms": now - period * (i + 100), + "open": 1, + "high": 2, + "low": 0.5, + "close": 1.5, + "volume": 1, + } + for i in range(40) + ] + upsert_bars("binance", "BTC/USDT", "5m", stale, self.db) + self.assertEqual(len(load_bars_latest("binance", "BTC/USDT", "5m", 100, self.db)), 40) + removed = clear_series_bars("binance", "BTC/USDT", "5m", self.db) + self.assertEqual(removed, 40) + self.assertEqual(len(load_bars_latest("binance", "BTC/USDT", "5m", 100, self.db)), 0) + + fresh = [ + { + "open_time_ms": now - period * (20 - i), + "open": 10, + "high": 11, + "low": 9, + "close": 10.5, + "volume": 2, + } + for i in range(20) + ] + + def remote_fetch(**kwargs): + return {"ok": True, "bars": fresh, "price_tick": 0.01} + + out = resolve_chart_bars( + "binance", + "BTC/USDT", + "5m", + remote_fetch, + db_path=self.db, + force_refresh=True, + clear_db=True, + limit=50, + ) + self.assertTrue(out.get("ok")) + self.assertGreaterEqual(int(out.get("cleared") or 0), 0) + self.assertGreater(int(out.get("fetched") or 0), 0) + self.assertGreaterEqual(len(out.get("candles") or []), 19) + + def test_since_span_matches_fetch_limit_not_need(self): + period = TIMEFRAME_MS["15m"] + now_ms = 1_800_000_000_000 + fetch_limit = HUB_KLINE_REMOTE_FETCH_CAP + since = _since_ms_for_span( + now_ms=now_ms, + period_ms=period, + span_bars=fetch_limit, + cutoff_ms=0, + ) + self.assertEqual(since, now_ms - period * fetch_limit) + wrong_since = now_ms - period * chart_initial_limit("15m") + self.assertGreater(since, wrong_since) + + def test_thin_series_tail_refresh_fetches_full_window(self): + init_db(self.db) + period = TIMEFRAME_MS["15m"] + now = int(time.time() * 1000) + last_closed = last_closed_bar_open_ms("15m", now) + bars = [ + { + "open_time_ms": last_closed - period * (150 - i), + "open": 100000, + "high": 100100, + "low": 99900, + "close": 100050, + "volume": 1, + } + for i in range(150) + ] + fetch_calls: list[dict] = [] + + def remote_fetch(**kwargs): + fetch_calls.append(dict(kwargs)) + return {"ok": True, "bars": bars, "price_tick": 0.01} + + out = resolve_chart_bars( + "binance", + "BTC/USDT", + "15m", + remote_fetch, + db_path=self.db, + tail_refresh=True, + ) + self.assertTrue(out.get("ok")) + self.assertGreaterEqual(len(out.get("candles") or []), 100) + self.assertGreater(int(out.get("fetched") or 0), 0) + self.assertTrue(any(int(c.get("limit") or 0) > 30 for c in fetch_calls)) + + def test_resolve_before_ms_exhausted(self): + init_db(self.db) + + def remote_fetch(**kwargs): + return {"ok": False, "msg": "no remote"} + + out = resolve_chart_bars( + "okx", + "BTC/USDT", + "5m", + remote_fetch, + db_path=self.db, + limit=100, + before_ms=int(time.time() * 1000), + ) + self.assertTrue(out.get("ok")) + self.assertEqual(out.get("candles"), []) + self.assertTrue(out.get("exhausted")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_macro_calendar_lib.py b/tests/test_hub_macro_calendar_lib.py new file mode 100644 index 0000000..c4c2f32 --- /dev/null +++ b/tests/test_hub_macro_calendar_lib.py @@ -0,0 +1,73 @@ +import os +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from lib.hub.hub_macro_calendar_lib import ( + build_banner_message, + create_event, + delete_event, + enrich_alert, + init_db, + list_active_alerts, + list_events, + update_event, +) + + +class HubMacroCalendarLibTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.db_path = Path(self.tmp.name) / "macro.db" + init_db(self.db_path) + + def tearDown(self): + self.tmp.cleanup() + + def test_create_and_list(self): + row = create_event("cpi", "2026-06-18 20:30", note="核心CPI", db_path=self.db_path) + self.assertEqual(row["event_type"], "cpi") + self.assertEqual(row["event_at"], "2026-06-18 20:30") + rows = list_events(now_ms=row["event_at_ms"] - 86400000, db_path=self.db_path) + self.assertEqual(len(rows), 1) + + def test_duplicate_rejected(self): + create_event("fomc", "2026-07-01 02:00", db_path=self.db_path) + with self.assertRaises(ValueError): + create_event("fomc", "2026-07-01 02:00", db_path=self.db_path) + + def test_active_window_and_messages(self): + row = create_event("employment", "2026-06-18 20:30", db_path=self.db_path) + t0 = int(row["event_at_ms"]) + inside = enrich_alert(row, now_ms=t0 - 30 * 60 * 1000) + self.assertIsNotNone(inside) + self.assertEqual(inside["phase"], "imminent") + outside = enrich_alert(row, now_ms=t0 - 2 * 3600 * 1000) + self.assertIsNone(outside) + alerts = list_active_alerts(now_ms=t0 + 15 * 60 * 1000, db_path=self.db_path) + self.assertEqual(len(alerts), 1) + msg_pos = build_banner_message(alerts[0], has_positions=True) + msg_flat = build_banner_message(alerts[0], has_positions=False) + self.assertIn("注意仓位风险", msg_pos) + self.assertIn("建议等待", msg_flat) + + def test_update_and_delete(self): + row = create_event("cpi", "2026-06-18 20:30", db_path=self.db_path) + updated = update_event( + row["id"], + event_at="2026-06-18 21:00", + note="修正时间", + db_path=self.db_path, + ) + self.assertEqual(updated["event_at"], "2026-06-18 21:00") + self.assertTrue(delete_event(row["id"], db_path=self.db_path)) + self.assertEqual(len(list_events(now_ms=updated["event_at_ms"], db_path=self.db_path)), 0) + + def test_invalid_type(self): + with self.assertRaises(ValueError): + create_event("nfp", "2026-06-18 20:30", db_path=self.db_path) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_monitor_payload.py b/tests/test_hub_monitor_payload.py new file mode 100644 index 0000000..3612558 --- /dev/null +++ b/tests/test_hub_monitor_payload.py @@ -0,0 +1,40 @@ +"""hub /api/hub/monitor:enrich 局部返回时须保留 keys.""" +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from lib.hub.hub_bridge import build_hub_monitor_payload # noqa: E402 + + +class TestHubMonitorPayload(unittest.TestCase): + def test_partial_enrich_keeps_keys(self): + keys = [{"id": 7, "symbol": "BTC/USDT"}] + orders = [{"id": 1}] + trends = [{"id": 9, "symbol": "ETH/USDT"}] + rolls = [] + + def enrich_only_trends(**_kw): + return {"trends": [{"id": 9, "add_count": 2}]} + + out = build_hub_monitor_payload( + keys=keys, + orders=orders, + trends=trends, + rolls=rolls, + enrich=enrich_only_trends, + ) + self.assertTrue(out["ok"]) + self.assertEqual(out["keys"], keys) + self.assertEqual(out["orders"], orders) + self.assertEqual(out["rolls"], rolls) + self.assertEqual(out["hedges"], []) + self.assertEqual(out["trends"][0]["add_count"], 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_monitor_totals_lib.py b/tests/test_hub_monitor_totals_lib.py new file mode 100644 index 0000000..dc7d6e5 --- /dev/null +++ b/tests/test_hub_monitor_totals_lib.py @@ -0,0 +1,75 @@ +from lib.hub.hub_monitor_totals_lib import aggregate_monitor_board_totals + + +def test_aggregate_monitor_board_totals_sums_rows(): + rows = [ + { + "day_stats": { + "ok": True, + "opens_today": 2, + "trade_stats": { + "closed_count": 1, + "win_count": 1, + "loss_count": 0, + "win_pnl_u": 5.5, + "loss_pnl_u": 0, + }, + }, + "agent": {"positions": [{"contracts": 1}], "total_unrealized_pnl": 1.2}, + }, + { + "day_stats": { + "ok": True, + "opens_today": 1, + "trade_stats": { + "closed_count": 2, + "win_count": 0, + "loss_count": 2, + "win_pnl_u": 0, + "loss_pnl_u": -3.0, + }, + }, + "agent": {"positions": [], "total_unrealized_pnl": 0}, + }, + ] + out = aggregate_monitor_board_totals(rows, trading_day="2026-07-04", reset_hour=8) + assert out["open_count"] == 3 + assert out["closed_count"] == 3 + assert out["win_count"] == 1 + assert out["loss_count"] == 2 + assert out["win_pnl_u"] == 5.5 + assert out["loss_pnl_u"] == -3.0 + assert out["open_position_count"] == 1 + assert out["float_pnl_u"] == 1.2 + + +def test_aggregate_monitor_board_totals_includes_options(): + rows = [ + { + "capabilities": ["options"], + "options": { + "ok": True, + "enabled": True, + "positions": [{"inst_id": "X"}, {"inst_id": "Y"}], + "upl_total_usdc": 1.5, + }, + "agent": {"positions": [], "total_unrealized_pnl": 0}, + } + ] + out = aggregate_monitor_board_totals(rows, trading_day="2026-07-04", reset_hour=8) + assert out["options_open_position_count"] == 2 + assert out["open_position_count"] == 2 + assert out["options_float_pnl_u"] == 1.5 + assert out["float_pnl_u"] == 1.5 + + +def test_summarize_trades_win_loss_amounts(): + from lib.hub.hub_trades_lib import summarize_trades + + stats = summarize_trades( + [{"pnl_amount": 2.5}, {"pnl_amount": -1.0}, {"pnl_amount": 0}] + ) + assert stats["win_count"] == 1 + assert stats["loss_count"] == 1 + assert stats["win_pnl_u"] == 2.5 + assert stats["loss_pnl_u"] == -1.0 diff --git a/tests/test_hub_ohlcv_lib.py b/tests/test_hub_ohlcv_lib.py new file mode 100644 index 0000000..da14049 --- /dev/null +++ b/tests/test_hub_ohlcv_lib.py @@ -0,0 +1,222 @@ +"""hub_ohlcv_lib:分页拉取(Gate 等单次不足 chunk 时仍继续).""" +from __future__ import annotations + +import unittest + +from lib.hub.hub_ohlcv_lib import ( + aggregate_ohlcv_bars, + bars_spacing_matches_timeframe, + fetch_ohlcv_for_hub, + normalize_price_tick, + price_tick_from_market, +) + + +class _FakeExchange: + def __init__(self, pages, *, timeframes=None): + self.pages = list(pages) + self.calls = [] + self.markets = {} + self.timeframes = timeframes if timeframes is not None else {} + + def fetch_ohlcv(self, symbol, timeframe=None, since=None, limit=None): + self.calls.append( + {"symbol": symbol, "since": since, "limit": limit, "timeframe": timeframe} + ) + if not self.pages: + return [] + page = self.pages.pop(0) + if since is None: + return page + return [b for b in page if b[0] >= since] + + +class TestHubOhlcvLib(unittest.TestCase): + def test_normalize_price_tick_snaps_powers_of_ten(self): + self.assertAlmostEqual(normalize_price_tick(0.00001), 0.00001) + self.assertAlmostEqual(normalize_price_tick(0.001), 0.001) + self.assertIsNone(normalize_price_tick(0)) + + def test_price_tick_from_decimal_precision(self): + class _Ex: + markets = {"BTC/USDT:USDT": {"precision": {"price": 2}, "info": {}, "limits": {}}} + + def load_markets(self): + return self.markets + + def market(self, sym): + return self.markets[sym] + + def price_to_precision(self, sym, price): + return "12345.67" + + tick = price_tick_from_market(_Ex(), "BTC/USDT:USDT") + self.assertAlmostEqual(tick, 0.01) + + def test_price_tick_from_binance_price_filter(self): + class _Ex: + markets = { + "BTC/USDT:USDT": { + "precision": {"price": 2}, + "info": { + "filters": [ + {"filterType": "PRICE_FILTER", "tickSize": "0.10"}, + {"filterType": "LOT_SIZE", "stepSize": "0.001"}, + ] + }, + "limits": {}, + } + } + + def load_markets(self): + return self.markets + + def market(self, sym): + return self.markets[sym] + + def price_to_precision(self, sym, price): + return "12345.6" + + from lib.hub.hub_ohlcv_lib import price_tick_from_market + + tick = price_tick_from_market(_Ex(), "BTC/USDT:USDT") + self.assertAlmostEqual(tick, 0.10) + + def test_price_tick_from_info_tick_size(self): + class _Ex: + markets = { + "INJ/USDT:USDT": { + "precision": {"price": 4}, + "info": {"tickSize": "0.001"}, + "limits": {}, + } + } + + def load_markets(self): + return self.markets + + def market(self, sym): + return self.markets[sym] + + def price_to_precision(self, sym, price): + return "7.123" + + from lib.hub.hub_ohlcv_lib import price_tick_from_market + + tick = price_tick_from_market(_Ex(), "INJ/USDT:USDT") + self.assertAlmostEqual(tick, 0.001) + + def test_full_fetch_without_since_paginates_okx_style(self): + """OKX 等无 since 单次约 300 根,须分页至 limit.""" + from lib.hub.hub_ohlcv_lib import TIMEFRAME_MS + + step = TIMEFRAME_MS["1h"] + want = 1000 + base = max(0, int(__import__("time").time() * 1000) - want * step) + pages = [ + [[base + i * step, 1.0, 1.1, 0.9, 1.05, 100.0] for i in range(300)], + [[base + (300 + i) * step, 2.0, 2.1, 1.9, 2.05, 200.0] for i in range(300)], + [[base + (600 + i) * step, 3.0, 3.1, 2.9, 3.05, 300.0] for i in range(300)], + [[base + (900 + i) * step, 4.0, 4.1, 3.9, 4.05, 400.0] for i in range(100)], + ] + ex = _FakeExchange(pages) + + out = fetch_ohlcv_for_hub( + symbol="ONDO/USDT", + timeframe="1h", + since_ms=None, + limit=want, + normalize_symbol_input=lambda s: str(s).strip().upper(), + normalize_exchange_symbol=lambda s: f"{s}:USDT" if ":" not in s else s, + ensure_markets_loaded=lambda: None, + exchange=ex, + ) + self.assertTrue(out.get("ok")) + self.assertEqual(len(out.get("bars") or []), 1000) + self.assertGreaterEqual(len(ex.calls), 4) + self.assertAlmostEqual(out["bars"][-1]["close"], 4.05) + + def test_pagination_continues_when_page_smaller_than_chunk(self): + """Gate 等常返回 299 根/次,不应误判为已到末尾.""" + base = 1_700_000_000_000 + step = 4 * 60 * 60 * 1000 + page1 = [ + [base + i * step, 1.0, 1.1, 0.9, 1.05, 100.0] for i in range(299) + ] + page2 = [ + [base + (299 + i) * step, 2.0, 2.1, 1.9, 2.05, 200.0] for i in range(299) + ] + page3 = [ + [base + (598 + i) * step, 3.0, 3.1, 2.9, 3.05, 300.0] for i in range(50) + ] + ex = _FakeExchange([page1, page2, page3]) + + out = fetch_ohlcv_for_hub( + symbol="INJ/USDT", + timeframe="4h", + since_ms=base, + limit=600, + normalize_symbol_input=lambda s: str(s).strip().upper(), + normalize_exchange_symbol=lambda s: f"{s}:USDT" if ":" not in s else s, + ensure_markets_loaded=lambda: None, + exchange=ex, + ) + self.assertTrue(out.get("ok")) + self.assertEqual(len(out.get("bars") or []), 600) + self.assertGreaterEqual(len(ex.calls), 3) + self.assertAlmostEqual(out["bars"][-1]["close"], 3.05) + + def test_pagination_stops_when_next_since_reaches_now(self): + """Gate 等:分页 since 不得越过当前时间,避免 from>to.""" + from lib.hub.hub_ohlcv_lib import TIMEFRAME_MS + + step = TIMEFRAME_MS["1d"] + now_ms = int(__import__("time").time() * 1000) + # 最后一页最后一根 K 的 next_since 将 >= now_ms,应停止不再请求 + last_open = ((now_ms // step) - 2) * step + page = [ + [last_open - step, 1.0, 1.1, 0.9, 1.0, 10.0], + [last_open, 1.1, 1.2, 1.0, 1.1, 11.0], + ] + ex = _FakeExchange([page]) + + out = fetch_ohlcv_for_hub( + symbol="ONDO/USDT", + timeframe="1d", + since_ms=last_open - step * 5, + limit=10, + normalize_symbol_input=lambda s: str(s).strip().upper(), + normalize_exchange_symbol=lambda s: f"{s}:USDT" if ":" not in s else s, + ensure_markets_loaded=lambda: None, + exchange=ex, + ) + self.assertTrue(out.get("ok")) + self.assertGreaterEqual(len(out.get("bars") or []), 2) + self.assertLessEqual(len(ex.calls), 4) + + def test_aggregate_ohlcv_bars_buckets(self): + from lib.hub.hub_ohlcv_lib import TIMEFRAME_MS + + h1 = TIMEFRAME_MS["1h"] + h4 = TIMEFRAME_MS["4h"] + base = (1_700_000_000_000 // h4) * h4 + src = [ + { + "open_time_ms": base + i * h1, + "open": 1.0, + "high": 2.0, + "low": 0.5, + "close": 1.5, + "volume": 1.0, + } + for i in range(4) + ] + out = aggregate_ohlcv_bars(src, "4h") + self.assertEqual(len(out), 1) + self.assertEqual(out[0]["volume"], 4.0) + self.assertEqual(out[0]["high"], 2.0) + self.assertEqual(out[0]["low"], 0.5) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_options_funds_lib.py b/tests/test_hub_options_funds_lib.py new file mode 100644 index 0000000..8f28fa3 --- /dev/null +++ b/tests/test_hub_options_funds_lib.py @@ -0,0 +1,53 @@ +from unittest import TestCase + +from lib.hub.hub_options_funds_lib import ( + merge_board_row_balances, + merge_perp_options_balances, + options_balances_usdt_equiv, +) + + +class HubOptionsFundsLibTests(TestCase): + def test_options_balances_usdt_equiv(self): + snap = { + "ok": True, + "enabled": True, + "balances": {"funding_usdc": 10, "trading_usdt": 5, "trading_usdc": 2}, + } + out = options_balances_usdt_equiv(snap) + self.assertTrue(out["ok"]) + self.assertEqual(out["funding_usdt"], 10.0) + self.assertEqual(out["trading_usdt"], 7.0) + + def test_merge_perp_options_balances(self): + out = merge_perp_options_balances( + 100, + 50, + { + "ok": True, + "enabled": True, + "balances": {"funding_usdc": 8, "trading_usdc": 4}, + }, + ) + self.assertEqual(out["funding_usdt"], 108.0) + self.assertEqual(out["trading_usdt"], 54.0) + self.assertEqual(out["total_usdt"], 162.0) + + def test_merge_board_row_balances(self): + row = { + "account_ok": True, + "funding_usdt": 20, + "trading_usdt": 30, + "capabilities": ["options"], + "options": { + "ok": True, + "enabled": True, + "balances": {"funding_usdc": 1, "trading_usdc": 2}, + "positions": [{"inst_id": "X"}], + "upl_total_usdc": 0.5, + }, + } + out = merge_board_row_balances(row) + self.assertEqual(out["total_usdt"], 53.0) + self.assertEqual(out["options_open_position_count"], 1) + self.assertEqual(out["options_float_pnl_u"], 0.5) diff --git a/tests/test_hub_order_sync_lib.py b/tests/test_hub_order_sync_lib.py new file mode 100644 index 0000000..52122ba --- /dev/null +++ b/tests/test_hub_order_sync_lib.py @@ -0,0 +1,74 @@ +"""中控改委托同步与条件单按角色去重.""" + +from lib.hub.hub_order_sync_lib import ( + cond_order_role, + dedupe_conditional_orders_by_role, + exchange_tpsl_from_cond_orders, + sync_active_monitor_tpsl_prices, +) +from lib.hub.hub_symbol_lib import symbols_match + + +def test_cond_order_role(): + assert cond_order_role({"label": "止损 84.1"}) == "sl" + assert cond_order_role({"label": "止盈 76"}) == "tp" + assert cond_order_role({"label": "市价 买入"}) is None + + +def test_dedupe_conditional_orders_by_role_keeps_one_sl(): + rows = [ + {"label": "止盈 76", "trigger_price": 76}, + {"label": "止损", "trigger_price": 84.1}, + {"label": "止损 84.1", "trigger_price": 84.1, "id": "x:sl"}, + ] + out = dedupe_conditional_orders_by_role(rows) + assert len(out) == 2 + sl_rows = [r for r in out if cond_order_role(r) == "sl"] + assert len(sl_rows) == 1 + assert sl_rows[0]["label"] == "止损 84.1" + + +def test_exchange_tpsl_from_cond_orders(): + cond = [ + {"label": "止损 84.1", "trigger_price": 84.1, "algo_id": "1"}, + {"label": "止盈 76", "trigger_price": 76, "algo_id": "1"}, + ] + et = exchange_tpsl_from_cond_orders(cond) + assert et["sl"]["trigger_price"] == 84.1 + assert et["tp"]["trigger_price"] == 76 + + +def test_sync_active_monitor_tpsl_prices_updates_matching_order(): + class Row(dict): + def __getitem__(self, key): + return dict.get(self, key) + + class Conn: + def __init__(self): + self.rows = [ + Row( + id=5, + symbol="SOL/USDT:USDT", + exchange_symbol="SOL/USDT:USDT", + direction="short", + ) + ] + self.updates = [] + + def execute(self, sql, params=None): + if "SELECT" in sql: + return self + if "UPDATE" in sql and params: + self.updates.append(params) + return self + + def fetchall(self): + return self.rows + + conn = Conn() + out = sync_active_monitor_tpsl_prices( + conn, "SOL/USDT:USDT", "short", 85.0, 75.0, symbols_match=symbols_match + ) + assert out["ok"] is True + assert out["updated"] == 1 + assert conn.updates == [(85.0, 75.0, 5)] diff --git a/tests/test_hub_position_metrics.py b/tests/test_hub_position_metrics.py new file mode 100644 index 0000000..679ecf5 --- /dev/null +++ b/tests/test_hub_position_metrics.py @@ -0,0 +1,15 @@ +from lib.hub.hub_position_metrics import position_contracts + + +def test_position_contracts_prefers_okx_info_pos_over_stale_ccxt(): + p = { + "contracts": 0.81, + "side": "short", + "info": {"pos": "-1.62", "posSide": "short"}, + } + assert position_contracts(p) == 1.62 + + +def test_position_contracts_falls_back_to_ccxt_contracts(): + p = {"contracts": 2.5, "info": {}} + assert position_contracts(p) == 2.5 diff --git a/tests/test_hub_strategy_lib.py b/tests/test_hub_strategy_lib.py new file mode 100644 index 0000000..3f3ab79 --- /dev/null +++ b/tests/test_hub_strategy_lib.py @@ -0,0 +1,49 @@ +import json +import unittest +from pathlib import Path + +from lib.hub.hub_strategy_lib import ( + load_checklist, + load_strategy_payload, + strategy_meta_payload, + build_export_html, + build_print_html, +) + + +class TestHubStrategyLib(unittest.TestCase): + def test_meta_has_three_exchanges(self): + meta = strategy_meta_payload() + keys = [x["key"] for x in meta["exchanges"]] + self.assertEqual(keys, ["binance", "okx", "gate"]) + + def test_load_binance_payload(self): + p = load_strategy_payload("binance") + self.assertTrue(p["ok"]) + self.assertIn("strategy_html", p) + self.assertIn("groups", p["checklist"]) + self.assertIn(" None: + bars = [] + price = 1.0 + for i in range(count): + o = start_ms + i * step + price += 0.001 + bars.append( + { + "open_time_ms": o, + "open": price, + "high": price + 0.002, + "low": price - 0.001, + "close": price + 0.001, + "volume": 100 + i, + } + ) + upsert_bars_5m(ex, sym, bars, db_path=db) + + +def test_aggregate_15m_from_5m(): + start = 1_700_000_000_000 + bars = [] + for i in range(6): + t = start + i * 300_000 + bars.append( + { + "open_time_ms": t, + "open": 1.0, + "high": 1.1, + "low": 0.9, + "close": 1.05, + "volume": 10, + } + ) + agg = aggregate_ohlcv_bars(bars, "15m") + assert len(agg) >= 1 + assert agg[-1]["close"] == bars[-1]["close"] + assert agg[0]["open_time_ms"] <= agg[1]["open_time_ms"] + + +def test_resolve_archive_chart_15m(): + with tempfile.TemporaryDirectory() as td: + db = Path(td) / "archive.db" + init_db(db) + anchor = 1_700_000_000_000 + _seed_5m_bars(db, anchor - 50 * 300_000, 120) + out = resolve_archive_chart( + "gate", + "ONDO", + "15m", + anchor_ms=anchor, + mode="hold", + bars=40, + db_path=db, + ) + assert out["ok"] is True + assert out["timeframe"] == "15m" + assert len(out["candles"]) >= 10 + + +def test_fill_missing_bars_continuity(): + period = 300_000 + start = (1_700_000_000_000 // period) * period + bars = [ + { + "open_time_ms": start, + "open": 1.0, + "high": 1.1, + "low": 0.9, + "close": 1.05, + "volume": 10, + }, + { + "open_time_ms": start + period * 2, + "open": 1.05, + "high": 1.15, + "low": 1.0, + "close": 1.1, + "volume": 8, + }, + ] + filled = _fill_missing_bars(bars, period, start, start + period * 2) + assert len(filled) >= 3 + assert any(b.get("filled") for b in filled) + + +def test_resolve_archive_chart_history_range(): + with tempfile.TemporaryDirectory() as td: + db = Path(td) / "archive.db" + init_db(db) + open_ms = 1_700_000_000_000 + close_ms = open_ms + 6 * 3600_000 + _seed_5m_bars(db, open_ms - 20 * 300_000, 200, ex="gate", sym="BNB/USDT") + out = resolve_archive_chart( + "gate", + "BNB/USDT", + "15m", + opened_ms=open_ms, + closed_ms=close_ms, + mode="hold", + range_mode="history", + db_path=db, + ) + assert out["ok"] is True + assert out.get("range_mode") == "history" + assert out.get("window_end_ms") <= close_ms + 4 * 3600_000 + assert len(out["candles"]) >= 40 + + +def test_sync_prunes_missing_trades(): + with tempfile.TemporaryDirectory() as td: + db = Path(td) / "archive.db" + init_db(db) + upsert_trades_cache( + "gate", + [ + {"id": 1, "symbol": "BNB/USDT", "result": "止损", "pnl_amount": -1}, + {"id": 2, "symbol": "BNB/USDT", "result": "止盈", "pnl_amount": 1}, + ], + db_path=db, + prune_missing=False, + ) + stats = upsert_trades_cache( + "gate", + [{"id": 1, "symbol": "BNB/USDT", "result": "止损", "pnl_amount": -1}], + db_path=db, + prune_missing=True, + ) + rows = load_symbol_trades("gate", "BNB/USDT", db_path=db) + assert len(rows) == 1 + assert rows[0]["trade_id"] == 1 + assert stats["removed"] == 1 + + +def test_list_with_overlay_filters(): + with tempfile.TemporaryDirectory() as td: + db = Path(td) / "archive.db" + init_db(db) + upsert_trades_cache( + "gate", + [ + { + "id": 1, + "symbol": "ONDO", + "direction": "long", + "result": "止盈", + "pnl_amount": 12.5, + "opened_at": "2026-01-01 10:00:00", + "closed_at": "2026-01-01 12:00:00", + "opened_at_ms": 1_700_000_000_000, + "closed_at_ms": 1_700_007_200_000, + }, + { + "id": 2, + "symbol": "ONDO", + "direction": "short", + "result": "止损", + "pnl_amount": -3.2, + "opened_at": "2026-01-02 10:00:00", + "closed_at": "2026-01-02 11:00:00", + "opened_at_ms": 1_700_086_400_000, + "closed_at_ms": 1_700_090_000_000, + }, + ], + db_path=db, + ) + upsert_trade_overlay("gate", 2, behavior_tag="sick", note="追高", db_path=db) + rows = list_symbol_rows(db_path=db) + assert len(rows) == 1 + assert rows[0]["trade_count"] == 2 + sick_only = list_symbol_rows(filter_sick=True, db_path=db) + assert len(sick_only) == 1 + profit_only = list_symbol_rows(filter_profit=True, db_path=db) + assert len(profit_only) == 1 + + +def test_parse_wall_clock_ms_uses_utc_plus_8(): + ms = parse_wall_clock_ms("2026-06-07 20:30:00") + assert ms is not None + dt_utc = datetime.fromtimestamp(ms / 1000.0, tz=timezone.utc) + dt_bj = dt_utc.astimezone(CHART_DISPLAY_TZ) + assert dt_bj.strftime("%Y-%m-%d %H:%M:%S") == "2026-06-07 20:30:00" + assert ms_to_wall_clock_str(ms) == "2026-06-07 20:30:00" + assert parse_wall_clock_ms("2026-06-07 20:30") == ms + + +def test_parse_wall_clock_ms_accepts_epoch_strings(): + ms = 1_700_000_000_000 + assert parse_wall_clock_ms(str(ms)) == ms + assert parse_wall_clock_ms(str(ms // 1000)) == ms + + +def test_resolve_archive_chart_history_uses_trade_span_not_200_bars(): + with tempfile.TemporaryDirectory() as td: + db = Path(td) / "archive.db" + init_db(db) + opened = 1_700_000_000_000 + closed = opened + 20 * 24 * 3600_000 + _seed_5m_bars(db, opened - 35 * 24 * 3600_000, 40 * 24 * 12) + out = resolve_archive_chart( + "gate", + "ONDO", + "15m", + opened_ms=opened, + closed_ms=closed, + mode="hold", + bars=200, + range_mode="history", + db_path=db, + ) + assert out["ok"] is True + assert out["range_mode"] == "history" + assert out["bar_count"] > 200 + + +def test_upsert_forces_sync_exchange_key(): + with tempfile.TemporaryDirectory() as td: + db = Path(td) / "archive.db" + init_db(db) + upsert_trades_cache( + "gate", + [ + { + "id": 77, + "exchange_key": "gate", + "account_exchange_key": "gate", + "symbol": "ETH/USDT", + "result": "止损", + "pnl_amount": -1, + "opened_at_ms": 1_700_000_000_000, + "closed_at_ms": 1_700_007_200_000, + } + ], + db_path=db, + ) + rows = load_symbol_trades("gate", "ETH/USDT", db_path=db) + assert len(rows) == 1 + assert rows[0]["exchange_key"] == "gate" + assert "account_exchange_key" not in rows[0] + + +def test_compute_period_stats_win_loss_metrics(): + rows = [ + {"exchange_key": "binance", "pnl_amount": 10.0, "behavior_tag": ""}, + {"exchange_key": "binance", "pnl_amount": 4.0, "behavior_tag": ""}, + {"exchange_key": "okx", "pnl_amount": -3.0, "behavior_tag": "sick"}, + {"exchange_key": "okx", "pnl_amount": -6.0, "behavior_tag": ""}, + ] + st = _compute_period_stats(rows) + assert st["open_count"] == 4 + assert st["win_count"] == 2 + assert st["loss_count"] == 2 + assert st["avg_win"] == 7.0 + assert st["avg_loss"] == -4.5 + assert st["max_win"] == 10.0 + assert st["max_loss"] == -6.0 + assert st["win_rate"] == 50.0 + assert st["profit_loss_ratio"] == round(7.0 / 4.5, 2) + assert st["sick_count"] == 1 + assert st["pnl_total"] == 5.0 + assert st["pnl_ex_sick"] == 8.0 + assert st["by_exchange"]["binance"]["win_count"] == 2 + assert st["by_exchange"]["binance"]["win_rate"] == 100.0 + assert st["by_exchange"]["binance"]["profit_loss_ratio"] is None + + +def test_list_daily_trades_search_filters_stats(): + with tempfile.TemporaryDirectory() as td: + db = Path(td) / "archive.db" + init_db(db) + day = "2023-11-15" + start_ms, _ = trading_day_bounds_ms(day) + btc_close = start_ms + 3_600_000 + eth_close = start_ms + 7_200_000 + upsert_trades_cache( + "gate", + [ + { + "id": 1, + "symbol": "BTC/USDT", + "result": "止盈", + "pnl_amount": 5.0, + "opened_at_ms": start_ms, + "closed_at_ms": btc_close, + }, + { + "id": 2, + "symbol": "ETH/USDT", + "result": "止损", + "pnl_amount": -2.0, + "opened_at_ms": btc_close, + "closed_at_ms": eth_close, + }, + ], + db_path=db, + ) + payload = list_daily_trades( + period="range", + date_from=day, + date_to=day, + search="btc", + db_path=db, + ) + assert len(payload["trades"]) == 1 + assert payload["trades"][0]["symbol"] == "BTC/USDT" + st = payload["stats"] + assert st["open_count"] == 1 + assert st["win_count"] == 1 + assert st["loss_count"] == 0 + assert st["max_win"] == 5.0 + assert st["pnl_total"] == 5.0 diff --git a/tests/test_hub_system_logs_lib.py b/tests/test_hub_system_logs_lib.py new file mode 100644 index 0000000..cead99e --- /dev/null +++ b/tests/test_hub_system_logs_lib.py @@ -0,0 +1,96 @@ +"""hub_system_logs_lib 单元测试.""" +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from lib.hub import hub_system_logs_lib as logs_lib +from lib.hub.hub_system_logs_lib import ( + load_system_logs, + resolve_log_paths, + system_logs_meta, + tail_lines, +) + + +class HubSystemLogsLibTest(unittest.TestCase): + def setUp(self): + logs_lib._path_cache.clear() + logs_lib._path_cache_at = 0.0 + + def test_system_logs_meta(self): + meta = system_logs_meta() + self.assertTrue(meta["ok"]) + keys = [t["key"] for t in meta["targets"]] + self.assertEqual(keys, ["binance", "gate", "okx", "hub"]) + + def test_tail_lines_reads_last_lines(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "demo-out.log" + path.write_text("\n".join(f"line-{i}" for i in range(1, 11)), encoding="utf-8") + out = tail_lines(path, lines=3) + self.assertEqual(out.splitlines(), ["line-8", "line-9", "line-10"]) + + def test_resolve_log_paths_from_pm2_jlist(self): + with tempfile.TemporaryDirectory() as tmp: + logs_dir = Path(tmp) + out_file = logs_dir / "crypto-binance-out-0.log" + err_file = logs_dir / "crypto-binance-error-0.log" + out_file.write_text("stdout line", encoding="utf-8") + err_file.write_text("stderr line", encoding="utf-8") + payload = [ + { + "name": "crypto_binance", + "pm2_env": { + "pm_out_log_path": str(out_file), + "pm_err_log_path": str(err_file), + }, + } + ] + with patch.object(logs_lib, "_pm2_jlist", return_value=payload): + out_path, err_path = resolve_log_paths("crypto_binance") + self.assertEqual(out_path, out_file) + self.assertEqual(err_path, err_file) + + def test_resolve_log_paths_glob_fallback(self): + with tempfile.TemporaryDirectory() as tmp: + logs_dir = Path(tmp) + out_file = logs_dir / "crypto-gate-out-1.log" + err_file = logs_dir / "crypto-gate-error-1.log" + out_file.write_text("gate out", encoding="utf-8") + err_file.write_text("gate err", encoding="utf-8") + with patch.object(logs_lib, "pm2_logs_dir", return_value=logs_dir): + with patch.object(logs_lib, "_pm2_jlist", return_value=[]): + out_path, err_path = resolve_log_paths("crypto_gate") + self.assertEqual(out_path, out_file) + self.assertEqual(err_path, err_file) + + def test_load_system_logs_unknown(self): + with self.assertRaises(KeyError): + load_system_logs("unknown") + + def test_load_system_logs_with_resolved_paths(self): + with tempfile.TemporaryDirectory() as tmp: + logs_dir = Path(tmp) + out_file = logs_dir / "manual-trading-hub-out-6.log" + err_file = logs_dir / "manual-trading-hub-error-6.log" + out_file.write_text("hub stdout", encoding="utf-8") + err_file.write_text("hub stderr", encoding="utf-8") + payload = [ + { + "name": "manual-trading-hub", + "pm2_env": { + "pm_out_log_path": str(out_file), + "pm_err_log_path": str(err_file), + }, + } + ] + with patch.object(logs_lib, "_pm2_jlist", return_value=payload): + data = load_system_logs("hub", lines=50) + self.assertTrue(data["ok"]) + self.assertIn("hub stdout", data["out"]) + self.assertIn("hub stderr", data["err"]) + self.assertTrue(data["out_exists"]) + self.assertTrue(data["err_exists"]) diff --git a/tests/test_hub_trades_archive_merge.py b/tests/test_hub_trades_archive_merge.py new file mode 100644 index 0000000..34370be --- /dev/null +++ b/tests/test_hub_trades_archive_merge.py @@ -0,0 +1,102 @@ +"""档案交易:strategy_trade_snapshots 补全 gate 漏记.""" + +from __future__ import annotations + +import sqlite3 +import tempfile +from datetime import datetime, timedelta +from pathlib import Path + +from lib.hub.hub_trades_lib import fetch_trades_for_archive + + +def _init_db(path: Path) -> sqlite3.Connection: + conn = sqlite3.connect(str(path)) + conn.row_factory = sqlite3.Row + conn.execute( + """ + CREATE TABLE trade_records ( + id INTEGER PRIMARY KEY, + symbol TEXT, + direction TEXT, + result TEXT, + pnl_amount REAL, + opened_at TEXT, + closed_at TEXT, + opened_at_ms INTEGER, + closed_at_ms INTEGER, + created_at TEXT, + trend_plan_id INTEGER + ) + """ + ) + conn.execute( + """ + CREATE TABLE strategy_trade_snapshots ( + id INTEGER PRIMARY KEY, + strategy_type TEXT, + source_id INTEGER, + symbol TEXT, + direction TEXT, + result_label TEXT, + status_at_close TEXT, + opened_at TEXT, + closed_at TEXT, + pnl_amount REAL, + snapshot_json TEXT, + created_at TEXT + ) + """ + ) + return conn + + +def test_merge_snapshot_when_trade_record_missing(): + with tempfile.TemporaryDirectory() as td: + conn = _init_db(Path(td) / "t.db") + closed = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S") + conn.execute( + """ + INSERT INTO strategy_trade_snapshots ( + id, strategy_type, source_id, symbol, direction, + result_label, opened_at, closed_at, pnl_amount, snapshot_json, created_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?) + """, + (7, "trend_pullback", 42, "ONDO/USDT", "long", "止损", closed, closed, -1.2, "{}", closed), + ) + conn.commit() + trades = fetch_trades_for_archive(conn, days=30, limit=50) + conn.close() + assert len(trades) == 1 + assert trades[0]["symbol"] == "ONDO/USDT" + assert trades[0]["id"] == -7 + assert trades[0].get("from_snapshot") is True + + +def test_skip_snapshot_when_trade_record_exists(): + with tempfile.TemporaryDirectory() as td: + conn = _init_db(Path(td) / "t.db") + closed = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S") + conn.execute( + """ + INSERT INTO trade_records ( + id, symbol, direction, result, pnl_amount, + opened_at, closed_at, opened_at_ms, closed_at_ms, created_at, trend_plan_id + ) VALUES (?,?,?,?,?,?,?,?,?,?,?) + """, + (1, "ONDO/USDT", "long", "止损", -1.2, closed, closed, 1, 2, closed, 42), + ) + conn.execute( + """ + INSERT INTO strategy_trade_snapshots ( + id, strategy_type, source_id, symbol, direction, + result_label, opened_at, closed_at, pnl_amount, snapshot_json, created_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?) + """, + (7, "trend_pullback", 42, "ONDO/USDT", "long", "止损", closed, closed, -1.2, "{}", closed), + ) + conn.commit() + trades = fetch_trades_for_archive(conn, days=30, limit=50) + conn.close() + assert len(trades) == 1 + assert trades[0]["id"] == 1 diff --git a/tests/test_hub_trades_lib.py b/tests/test_hub_trades_lib.py new file mode 100644 index 0000000..b176be2 --- /dev/null +++ b/tests/test_hub_trades_lib.py @@ -0,0 +1,229 @@ +"""hub_trades_lib 单元测试.""" +from __future__ import annotations + +import sqlite3 +import unittest +from datetime import datetime + +from lib.hub.hub_trades_lib import ( + attach_journal_mood_tags, + fetch_trades_for_trading_day, + journal_trade_match_key, + summarize_trades, + trading_day_from_dt, + trading_day_window_bounds, +) + + +class HubTradesLibTest(unittest.TestCase): + def test_trading_day_reset(self): + dt = datetime(2026, 6, 6, 7, 30, 0) + self.assertEqual(trading_day_from_dt(dt, 8), "2026-06-05") + dt2 = datetime(2026, 6, 6, 8, 0, 0) + self.assertEqual(trading_day_from_dt(dt2, 8), "2026-06-06") + + def test_trading_day_window_bounds(self): + start, end = trading_day_window_bounds("2026-06-06", 8) + self.assertEqual(start, "2026-06-06 08:00:00") + self.assertEqual(end, "2026-06-07 07:59:59") + + def test_fetch_and_summarize(self): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + conn.execute( + """CREATE TABLE trade_records ( + symbol TEXT, direction TEXT, result TEXT, reviewed_result TEXT, + pnl_amount REAL, reviewed_pnl_amount REAL, exchange_realized_pnl REAL, + closed_at TEXT, reviewed_closed_at TEXT, opened_at TEXT, reviewed_opened_at TEXT, + created_at TEXT, monitor_type TEXT, actual_rr REAL, planned_rr REAL, + trade_style TEXT, entry_reason TEXT, reviewed_at TEXT + )""" + ) + conn.execute( + "INSERT INTO trade_records VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + "ONDO/USDT", + "short", + "止损", + None, + -0.5, + None, + None, + "2026-06-06 10:00:00", + None, + "2026-06-06 09:00:00", + None, + "2026-06-06 10:00:00", + "趋势回调", + None, + None, + "trend", + "", + None, + ), + ) + conn.commit() + rows = fetch_trades_for_trading_day(conn, "2026-06-06") + self.assertEqual(len(rows), 1) + stats = summarize_trades(rows) + self.assertEqual(stats["closed_count"], 1) + self.assertEqual(stats["loss_count"], 1) + self.assertAlmostEqual(stats["total_pnl_u"], -0.5) + conn.close() + + def test_early_morning_belongs_prev_trading_day(self): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + conn.execute( + """CREATE TABLE trade_records ( + symbol TEXT, direction TEXT, result TEXT, reviewed_result TEXT, + pnl_amount REAL, reviewed_pnl_amount REAL, exchange_realized_pnl REAL, + closed_at TEXT, reviewed_closed_at TEXT, opened_at TEXT, reviewed_opened_at TEXT, + created_at TEXT, monitor_type TEXT, actual_rr REAL, planned_rr REAL, + trade_style TEXT, entry_reason TEXT, reviewed_at TEXT + )""" + ) + conn.execute( + "INSERT INTO trade_records VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + "BTC/USDT", + "long", + "止盈", + None, + 1.2, + None, + None, + "2026-06-07 07:30:00", + None, + "2026-06-07 06:00:00", + None, + "2026-06-07 07:30:00", + "关键位", + None, + None, + "trend", + "", + None, + ), + ) + conn.commit() + self.assertEqual(len(fetch_trades_for_trading_day(conn, "2026-06-07")), 0) + self.assertEqual(len(fetch_trades_for_trading_day(conn, "2026-06-06")), 1) + conn.close() + + def test_reviewed_fields_preferred(self): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + conn.execute( + """CREATE TABLE trade_records ( + symbol TEXT, direction TEXT, result TEXT, reviewed_result TEXT, + pnl_amount REAL, reviewed_pnl_amount REAL, exchange_realized_pnl REAL, + closed_at TEXT, reviewed_closed_at TEXT, opened_at TEXT, reviewed_opened_at TEXT, + created_at TEXT, monitor_type TEXT, actual_rr REAL, planned_rr REAL, + trade_style TEXT, entry_reason TEXT, reviewed_at TEXT + )""" + ) + conn.execute( + "INSERT INTO trade_records VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + "ETH/USDT", + "long", + "止损", + "止盈", + -0.5, + 2.0, + None, + "2026-06-06 09:00:00", + "2026-06-06 11:00:00", + "2026-06-06 08:00:00", + None, + "2026-06-06 11:00:00", + "趋势回调", + None, + None, + "trend", + "", + "2026-06-06 12:00:00", + ), + ) + conn.commit() + rows = fetch_trades_for_trading_day(conn, "2026-06-06") + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["result"], "止盈") + self.assertAlmostEqual(rows[0]["pnl_amount"], 2.0) + self.assertTrue(rows[0]["reviewed"]) + conn.close() + + def test_time_close_result_included(self): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + conn.execute( + """CREATE TABLE trade_records ( + symbol TEXT, direction TEXT, result TEXT, reviewed_result TEXT, + pnl_amount REAL, reviewed_pnl_amount REAL, exchange_realized_pnl REAL, + closed_at TEXT, reviewed_closed_at TEXT, opened_at TEXT, reviewed_opened_at TEXT, + created_at TEXT, monitor_type TEXT, actual_rr REAL, planned_rr REAL, + trade_style TEXT, entry_reason TEXT, reviewed_at TEXT + )""" + ) + conn.execute( + "INSERT INTO trade_records VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + "BTC/USDT", + "long", + "时间平仓", + None, + 1.2, + None, + None, + "2026-06-06 12:00:00", + None, + "2026-06-06 08:00:00", + None, + "2026-06-06 12:00:00", + "趋势回调", + None, + None, + "trend", + "", + None, + ), + ) + conn.commit() + rows = fetch_trades_for_trading_day(conn, "2026-06-06") + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["result"], "时间平仓") + conn.close() + + def test_attach_journal_mood_tags_marks_sick(self): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + conn.execute( + """CREATE TABLE journal_entries ( + coin TEXT, open_datetime TEXT, close_datetime TEXT, mood_issues TEXT, created_at TEXT + )""" + ) + conn.execute( + "INSERT INTO journal_entries VALUES (?,?,?,?,?)", + ("ETH", "2026-07-06 21:51", "2026-07-07 00:00", "报复开仓,扛单", "2026-07-07 00:05"), + ) + conn.commit() + trades = [ + { + "id": 42, + "symbol": "ETH/USDT", + "opened_at": "2026-07-06 21:51:00", + "closed_at": "2026-07-07 00:00:00", + } + ] + attach_journal_mood_tags(conn, trades, cutoff_s="2026-01-01 00:00:00") + self.assertTrue(trades[0]["journal_mood_sick"]) + self.assertEqual(trades[0]["behavior_tag"], "sick") + self.assertEqual(trades[0]["journal_mood_issues"], ["报复开仓", "扛单"]) + key = journal_trade_match_key("ETH/USDT", "2026-07-06 21:51:00", "2026-07-07 00:00:00") + self.assertEqual(key, ("ETH", "2026-07-06 21:51", "2026-07-07 00:00")) + conn.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_trades_review_fields.py b/tests/test_hub_trades_review_fields.py new file mode 100644 index 0000000..77b0999 --- /dev/null +++ b/tests/test_hub_trades_review_fields.py @@ -0,0 +1,115 @@ +"""档案交易:复盘字段优先(开仓类型,持仓时长,开平仓时间).""" + +from __future__ import annotations + +import tempfile +import unittest +from datetime import datetime, timedelta +from pathlib import Path + +from lib.hub.hub_symbol_archive_lib import init_db, load_symbol_trades, upsert_trades_cache +from lib.hub.hub_trades_lib import ( + _normalize_archive_trade_row, + display_entry_type_label, + effective_entry_type, + effective_hold_minutes, +) + + +class TestHubTradesReviewFields(unittest.TestCase): + def test_display_entry_type_for_manual_monitor_review(self): + d = { + "monitor_type": "下单监控", + "entry_reason": "", + "reviewed_entry_reason": "突破回踩", + "reviewed_at": "2026-06-08 10:00:00", + } + self.assertEqual(display_entry_type_label(d), "突破回踩") + + def test_effective_entry_type_prefers_reviewed(self): + d = { + "entry_reason": "突破回踩", + "reviewed_entry_reason": "趋势回调", + "monitor_type": "下单监控", + } + self.assertEqual(effective_entry_type(d), "趋势回调") + + def test_effective_hold_minutes_prefers_reviewed(self): + d = { + "hold_minutes": 30, + "reviewed_hold_minutes": 95, + "opened_at_ms": 1_700_000_000_000, + "closed_at_ms": 1_700_001_800_000, + } + self.assertEqual(effective_hold_minutes(d), 95) + + def test_normalize_archive_trade_row_review_fields(self): + closed = (datetime.now() - timedelta(days=2)).strftime("%Y-%m-%d %H:%M:%S") + opened = (datetime.now() - timedelta(days=2, hours=2)).strftime("%Y-%m-%d %H:%M:%S") + row = _normalize_archive_trade_row( + { + "id": 9, + "symbol": "ONDO/USDT", + "direction": "short", + "result": "止损", + "reviewed_result": "手动平仓", + "pnl_amount": -2.5, + "reviewed_pnl_amount": -2.58, + "opened_at": opened, + "reviewed_opened_at": "2026-06-07 14:30:00", + "closed_at": closed, + "reviewed_closed_at": "2026-06-08 08:44:21", + "opened_at_ms": 1_700_000_000_000, + "closed_at_ms": 1_700_007_200_000, + "entry_reason": "突破回踩", + "reviewed_entry_reason": "趋势回调", + "hold_minutes": 30, + "reviewed_hold_minutes": 1080, + "monitor_type": "趋势回调", + "reviewed_at": closed, + }, + exchange_key="gate", + ) + self.assertIsNotNone(row) + assert row is not None + self.assertEqual(row["entry_type"], "趋势回调") + self.assertEqual(row["hold_minutes"], 1080) + self.assertEqual(row["opened_at"], "2026-06-07 14:30:00") + self.assertEqual(row["closed_at"], "2026-06-08 08:44:21") + self.assertTrue(row["reviewed"]) + + def test_archive_cache_enriches_review_display_fields(self): + with tempfile.TemporaryDirectory() as td: + db = Path(td) / "archive.db" + init_db(db) + upsert_trades_cache( + "gate", + [ + { + "id": 3, + "symbol": "ONDO/USDT", + "direction": "short", + "result": "手动平仓", + "pnl_amount": -2.58, + "opened_at": "2026-06-07 14:30:00", + "closed_at": "2026-06-08 08:44:21", + "opened_at_ms": 1_781_000_000_000, + "closed_at_ms": 1_781_065_000_000, + "entry_type": "趋势回调", + "hold_minutes": 1080, + "hold_minutes_text": "18小时0分钟", + "reviewed": True, + } + ], + db_path=db, + ) + rows = load_symbol_trades("gate", "ONDO/USDT", db_path=db) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["entry_type"], "趋势回调") + self.assertEqual(rows[0]["hold_minutes"], 1080) + self.assertTrue(rows[0]["opened_at"].startswith("2026-06-07")) + self.assertTrue(rows[0]["closed_at"].startswith("2026-06-08")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_volume_rank_lib.py b/tests/test_hub_volume_rank_lib.py new file mode 100644 index 0000000..a5422bc --- /dev/null +++ b/tests/test_hub_volume_rank_lib.py @@ -0,0 +1,184 @@ +from datetime import datetime +from unittest.mock import MagicMock + +from lib.hub.hub_volume_rank_lib import ( + CACHE_VERSION, + LIQUIDITY_RANK_CACHE_VERSION, + TOP_N_DEFAULT, + _exchange_rank_row_stale, + _okx_turnover_usdt, + _scores_from_binance, + _scores_from_gate, + build_usdt_swap_volume_ranks, + cache_needs_refresh, + format_volume_quote, + merge_exchange_rank, + rank_date_label, + resolve_daily_volume_rank, +) + + +def test_rank_date_label_after_reset(): + # 2026-06-08 09:00 北京时间 → 昨日交易日 2026-06-07 + dt = datetime(2026, 6, 8, 9, 0, 0) + assert rank_date_label(now=dt, reset_hour=8) == "2026-06-07" + + +def test_rank_date_label_before_reset(): + # 2026-06-08 07:00 → 当前交易日仍算 2026-06-07,昨日为 2026-06-06 + dt = datetime(2026, 6, 8, 7, 0, 0) + assert rank_date_label(now=dt, reset_hour=8) == "2026-06-06" + + +def test_format_volume_quote(): + assert format_volume_quote(1_500_000_000) == "1.50B" + assert format_volume_quote(2_300_000) == "2.30M" + assert format_volume_quote(4500) == "4.50K" + + +def test_okx_turnover_usdt(): + qv = _okx_turnover_usdt({"volCcy24h": "100", "last": "50"}) + assert qv == 5000.0 + + +def test_cache_needs_refresh_and_merge(): + cache = {"rank_date": "2026-06-05", "exchanges": {}} + assert cache_needs_refresh(cache, expected_rank_date="2026-06-07") is True + merged = merge_exchange_rank( + cache, + "binance", + { + "ok": True, + "rank_date": "2026-06-07", + "items": [{"rank": 1, "symbol": "BTC/USDT", "volume_quote": 1.0}], + "total_symbols": 100, + }, + ) + assert merged["exchanges"]["binance"]["items"][0]["symbol"] == "BTC/USDT" + assert merged["rank_date"] == "2026-06-07" + + +def test_stale_cache_version_forces_refresh(): + cache = {"version": CACHE_VERSION - 1, "rank_date": "2026-06-07", "exchanges": {"okx": {"items": [{}]}}} + assert cache_needs_refresh(cache) is True + + +def test_short_item_list_is_stale(): + items = [{"rank": i, "symbol": f"S{i}/USDT"} for i in range(1, 13)] + row = {"items": items, "total_symbols": 12} + assert _exchange_rank_row_stale(row) is True + full = {"items": items + [{"rank": i, "symbol": f"X{i}/USDT"} for i in range(13, TOP_N_DEFAULT + 1)], "total_symbols": 300} + assert _exchange_rank_row_stale(full) is False + + +def test_scores_from_binance_uses_fapi_lightweight_api(): + ex = MagicMock() + ex.id = "binance" + ex.fapiPublicGetTicker24hr.return_value = [ + {"symbol": "BTCUSDT", "quoteVolume": "9000000"}, + {"symbol": "ETHUSDT", "quoteVolume": "5000000"}, + ] + scored = _scores_from_binance(ex) + assert scored[0][1] == "BTC" + assert scored[0][2] == 9000000.0 + ex.fetch_tickers.assert_not_called() + + +def test_scores_from_binance_skips_fetch_tickers_on_api_error(): + ex = MagicMock() + ex.id = "binance" + ex.fapiPublicGetTicker24hr.side_effect = RuntimeError("network") + scored = _scores_from_binance(ex) + assert scored == [] + ex.fetch_tickers.assert_not_called() + + +def test_scores_from_gate_uses_futures_tickers_api(): + ex = MagicMock() + ex.id = "gateio" + ex.publicFuturesGetSettleTickers.return_value = [ + {"contract": "BTC_USDT", "volume_24h_quote": "8000000"}, + {"contract": "ETH_USDT", "volume_24h_quote": "4000000"}, + ] + scored = _scores_from_gate(ex) + assert scored[0][1] == "BTC" + ex.fetch_tickers.assert_not_called() + + +def test_scores_from_gate_skips_fetch_tickers_on_api_error(): + ex = MagicMock() + ex.id = "gateio" + ex.publicFuturesGetSettleTickers.side_effect = RuntimeError("network") + scored = _scores_from_gate(ex) + assert scored == [] + ex.fetch_tickers.assert_not_called() + + +def test_resolve_daily_volume_rank_caches_result(): + cache = {"version": 0, "updated_at": 0.0, "ranks": {}, "total": 0} + ex = MagicMock() + ex.id = "binance" + ex.fapiPublicGetTicker24hr.return_value = [ + {"symbol": "BTCUSDT", "quoteVolume": "100"}, + {"symbol": "ETHUSDT", "quoteVolume": "50"}, + ] + + rank, total = resolve_daily_volume_rank( + "BTC", + cache, + now_ts=1000.0, + ttl_sec=60.0, + exchange=ex, + ensure_markets_loaded=lambda: None, + ) + assert rank == 1 + assert total == 2 + assert cache["version"] == LIQUIDITY_RANK_CACHE_VERSION + calls = ex.fapiPublicGetTicker24hr.call_count + + rank2, _ = resolve_daily_volume_rank( + "BTC", + cache, + now_ts=1010.0, + ttl_sec=60.0, + exchange=ex, + ensure_markets_loaded=lambda: None, + ) + assert rank2 == 1 + assert ex.fapiPublicGetTicker24hr.call_count == calls + + +def test_resolve_daily_volume_rank_keeps_stale_cache_when_refresh_empty(): + cache = { + "version": LIQUIDITY_RANK_CACHE_VERSION, + "updated_at": 900.0, + "ranks": {"BTC": 1}, + "total": 100, + } + ex = MagicMock() + ex.id = "binance" + ex.fapiPublicGetTicker24hr.return_value = [] + + rank, total = resolve_daily_volume_rank( + "BTC", + cache, + now_ts=2000.0, + ttl_sec=60.0, + exchange=ex, + ensure_markets_loaded=lambda: None, + ) + assert rank == 1 + assert total == 100 + assert cache["updated_at"] == 900.0 + ex.fetch_tickers.assert_not_called() + + +def test_build_usdt_swap_volume_ranks(): + ex = MagicMock() + ex.id = "binance" + ex.fapiPublicGetTicker24hr.return_value = [ + {"symbol": "SOLUSDT", "quoteVolume": "200"}, + ] + ranks, total = build_usdt_swap_volume_ranks(ex, lambda: None) + assert ranks["SOL"] == 1 + assert total == 1 diff --git a/tests/test_instance_dashboard_lib.py b/tests/test_instance_dashboard_lib.py new file mode 100644 index 0000000..15cdb1a --- /dev/null +++ b/tests/test_instance_dashboard_lib.py @@ -0,0 +1,172 @@ +"""instance_dashboard_lib 单元测试.""" +from __future__ import annotations + +import sqlite3 +import unittest + +from lib.instance.instance_dashboard_lib import build_instance_dashboard_payload + + +def _mem_conn() -> sqlite3.Connection: + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + conn.executescript( + """ + CREATE TABLE order_monitors ( + id INTEGER PRIMARY KEY, + symbol TEXT, + exchange_symbol TEXT, + direction TEXT, + status TEXT, + monitor_type TEXT, + key_signal_type TEXT, + trigger_price REAL, + stop_loss REAL, + take_profit REAL + ); + CREATE TABLE key_monitors ( + id INTEGER PRIMARY KEY, + symbol TEXT, + exchange_symbol TEXT, + direction TEXT, + signal_type TEXT, + upper REAL, + lower REAL, + status TEXT + ); + CREATE TABLE trend_pullback_plans ( + id INTEGER PRIMARY KEY, + symbol TEXT, + exchange_symbol TEXT, + direction TEXT, + status TEXT, + entry_price REAL + ); + CREATE TABLE roll_groups ( + id INTEGER PRIMARY KEY, + order_monitor_id INTEGER, + symbol TEXT, + exchange_symbol TEXT, + direction TEXT, + status TEXT + ); + """ + ) + return conn + + +class TestInstanceDashboardLib(unittest.TestCase): + def test_empty_sections_and_conditional_hidden(self): + conn = _mem_conn() + payload = build_instance_dashboard_payload(conn, hedge_enabled=True) + self.assertTrue(payload["ok"]) + self.assertEqual(payload["orders"]["count"], 0) + self.assertEqual(payload["keys"]["count"], 0) + self.assertEqual(payload["strategy"]["count"], 0) + self.assertFalse(payload["options"]["visible"]) + self.assertFalse(payload["hedge_plan"]["visible"]) + conn.close() + + def test_orders_keys_strategy_and_options_visible(self): + conn = _mem_conn() + conn.execute( + "INSERT INTO order_monitors (symbol, exchange_symbol, direction, status, monitor_type) " + "VALUES ('BTC/USDT', 'BTC/USDT:USDT', 'long', 'active', 'manual')" + ) + conn.execute( + "INSERT INTO key_monitors (symbol, direction, signal_type, upper, lower, status) " + "VALUES ('ETH/USDT', 'short', '箱体突破', 3000, 2800, 'active')" + ) + conn.execute( + "INSERT INTO trend_pullback_plans (symbol, direction, status, entry_price) " + "VALUES ('SOL/USDT', 'long', 'active', 100)" + ) + conn.execute( + "INSERT INTO order_monitors (id, symbol, direction, status) VALUES (9, 'XRP/USDT', 'short', 'active')" + ) + conn.execute( + "INSERT INTO roll_groups (order_monitor_id, symbol, direction, status) " + "VALUES (9, 'XRP/USDT', 'short', 'active')" + ) + conn.commit() + + def fetch_opts(): + return [{"inst_id": "ETH-USD-260731-3000-C", "opt_type": "C", "pos": 1, "upl": 1.5}] + + payload = build_instance_dashboard_payload( + conn, + fetch_options_positions=fetch_opts, + hedge_enabled=False, + ) + self.assertEqual(payload["orders"]["count"], 2) + self.assertEqual(payload["keys"]["count"], 1) + self.assertEqual(payload["strategy"]["count"], 2) + self.assertTrue(payload["options"]["visible"]) + self.assertEqual(payload["options"]["count"], 1) + self.assertEqual(payload["options"]["items"][0]["source_label"], "纯期权") + self.assertFalse(payload["hedge_plan"]["visible"]) + conn.close() + + def test_hedge_status_label_active(self): + conn = _mem_conn() + conn.executescript( + """ + CREATE TABLE hedge_plans ( + id INTEGER PRIMARY KEY, + underlying TEXT, + plan_type TEXT, + status TEXT + ); + CREATE TABLE hedge_plan_legs ( + id INTEGER PRIMARY KEY, + plan_id INTEGER, + leg_role TEXT, + symbol TEXT, + inst_id TEXT, + opt_type TEXT, + status TEXT + ); + """ + ) + conn.execute( + "INSERT INTO hedge_plans (id, underlying, plan_type, status) " + "VALUES (2, 'ETH', 'options_options', 'active')" + ) + conn.execute( + "INSERT INTO hedge_plan_legs (plan_id, leg_role, inst_id, opt_type, status) " + "VALUES (2, 'option', 'ETH-USD-260719-1850-P', 'P', 'open')" + ) + conn.commit() + + def fetch_opts(): + return [ + { + "inst_id": "ETH-USD-260719-1850-P", + "opt_type": "P", + "pos": 40, + "upl": 1.2, + "exp_time_ms": 1784505600000, + "hedge_plan_target": { + "plan_id": 2, + "opt_type": "P", + "target_index": 1800, + }, + } + ] + + payload = build_instance_dashboard_payload( + conn, + fetch_options_positions=fetch_opts, + hedge_enabled=True, + ) + self.assertTrue(payload["hedge_plan"]["visible"]) + self.assertEqual(payload["hedge_plan"]["items"][0]["status_label"], "进行中") + self.assertTrue(payload["hedge_plan"]["items"][0]["status_active"]) + opt = payload["options"]["items"][0] + self.assertEqual(opt["source_label"], "期期对冲") + self.assertIn("对冲#2", opt["target_monitor"]) + conn.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_instance_display_env_settings.py b/tests/test_instance_display_env_settings.py new file mode 100644 index 0000000..d185e40 --- /dev/null +++ b/tests/test_instance_display_env_settings.py @@ -0,0 +1,64 @@ +"""instance_display_prefs_lib 与 env_file_lib 单元测试.""" +from __future__ import annotations + +import os +import tempfile +import unittest + +from lib.env.env_file_lib import apply_env_updates, env_get, read_env_lines +from lib.env.env_schema import parse_env_example_schema, validate_env_updates +from lib.instance.instance_display_prefs_lib import normalize_display_prefs, tab_allowed + + +class TestInstanceDisplayPrefs(unittest.TestCase): + def test_normalize_defaults_all_on(self): + prefs = normalize_display_prefs({}) + self.assertTrue(prefs["show_nav_env_config"]) + self.assertTrue(prefs["show_settings_password"]) + self.assertFalse(prefs["show_nav_dashboard"]) + + def test_tab_allowed_respects_prefs(self): + prefs = normalize_display_prefs({"show_nav_stats": False}) + self.assertFalse(tab_allowed("stats", prefs)) + self.assertTrue(tab_allowed("trade", prefs)) + + def test_dashboard_nav_default_off(self): + prefs = normalize_display_prefs({}) + self.assertFalse(tab_allowed("dashboard", prefs)) + on = normalize_display_prefs({"show_nav_dashboard": True}) + self.assertTrue(tab_allowed("dashboard", on)) + + +class TestEnvFileLib(unittest.TestCase): + def test_upsert_and_read(self): + with tempfile.TemporaryDirectory() as td: + path = os.path.join(td, ".env") + with open(path, "w", encoding="utf-8") as f: + f.write("FOO=1\n") + changed = apply_env_updates(path, {"FOO": "2", "BAR": "x"}) + self.assertIn("FOO", changed) + self.assertIn("BAR", changed) + lines = read_env_lines(path) + self.assertEqual(env_get(lines, "FOO"), "2") + self.assertEqual(env_get(lines, "BAR"), "x") + + +class TestEnvSchema(unittest.TestCase): + def test_parse_okx_example(self): + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + example = os.path.join(root, "crypto_monitor_okx", ".env.example") + if not os.path.isfile(example): + self.skipTest("missing okx .env.example") + groups = parse_env_example_schema(example) + keys = [f["key"] for g in groups for f in g.get("fields", [])] + self.assertIn("OKX_API_KEY", keys) + self.assertIn("MAX_ACTIVE_POSITIONS", keys) + + def test_validate_unknown_key(self): + groups = [{"title": "t", "fields": [{"key": "A", "type": "text", "sensitive": False}]}] + clean, errors = validate_env_updates(groups, {"B": "1"}) + self.assertTrue(errors) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_instance_embed_context_lib.py b/tests/test_instance_embed_context_lib.py new file mode 100644 index 0000000..b9b18cc --- /dev/null +++ b/tests/test_instance_embed_context_lib.py @@ -0,0 +1,36 @@ +from lib.instance.instance_embed_context_lib import embed_render_plan, trade_records_summary + + +def test_embed_fragment_trade_is_light(): + plan = embed_render_plan("trade", "fragment") + assert plan.exchange_capitals is False + assert plan.records_rows is False + assert plan.records_summary is False + assert plan.orders is True + assert plan.key_history is False + + +def test_embed_shell_trade_summary_only(): + plan = embed_render_plan("trade", "shell") + assert plan.exchange_capitals is True + assert plan.records_summary is True + assert plan.records_rows is False + + +def test_embed_shell_settings_still_loads_header_summary(): + plan = embed_render_plan("settings", "shell") + assert plan.records_summary is True + assert plan.records_rows is False + plan_risk = embed_render_plan("risk_policy", "shell") + assert plan_risk.records_summary is True + + +def test_embed_records_page_loads_rows(): + plan = embed_render_plan("records", "fragment") + assert plan.records_rows is True + + +def test_full_page_unchanged(): + plan = embed_render_plan("trade", None) + assert plan.records_rows is True + assert plan.exchange_capitals is True diff --git a/tests/test_instance_embed_lib.py b/tests/test_instance_embed_lib.py new file mode 100644 index 0000000..9ec5eab --- /dev/null +++ b/tests/test_instance_embed_lib.py @@ -0,0 +1,48 @@ +from lib.instance.instance_embed_lib import ( + EMBED_TABS, + embed_context_extras, + include_transfer_block, + path_to_embed_tab, + rewrite_embed_dest, + ui_open_guard_enabled, + ui_orphan_recovery_enabled, +) + + +def test_path_to_embed_tab(): + assert path_to_embed_tab("/trade") == "trade" + assert path_to_embed_tab("/key_monitor") == "key_monitor" + assert path_to_embed_tab("/strategy/records") == "strategy_records" + assert path_to_embed_tab("/unknown") is None + + +def test_rewrite_embed_dest(): + url = rewrite_embed_dest("/trade", hub_theme="dark") + assert url.startswith("/embed?") + assert "tab=trade" in url + assert "embed=1" in url + assert "hub_theme=dark" in url + + +def test_embed_tabs_cover_main_nav(): + assert "trade" in EMBED_TABS + assert "key_monitor" in EMBED_TABS + assert "records" in EMBED_TABS + assert "env_config" in EMBED_TABS + assert "risk_policy" in EMBED_TABS + assert "settings" in EMBED_TABS + assert path_to_embed_tab("/env_config") == "env_config" + assert path_to_embed_tab("/risk_policy") == "risk_policy" + assert path_to_embed_tab("/settings") == "settings" + + +def test_embed_context_extras_unified_ui_flags(): + for ex in ("binance", "okx", "gate"): + assert include_transfer_block(ex) is True + assert ui_open_guard_enabled("okx") is True + assert ui_open_guard_enabled("binance") is False + assert ui_orphan_recovery_enabled("binance") is True + assert ui_orphan_recovery_enabled("gate") is False + ctx = embed_context_extras("gate") + assert ctx["order_rule_tips_tpl"] == "order_monitor_rule_tips_gate.html" + assert ctx["include_transfer_block"] is True diff --git a/tests/test_instance_header_stats_lib.py b/tests/test_instance_header_stats_lib.py new file mode 100644 index 0000000..6b7db32 --- /dev/null +++ b/tests/test_instance_header_stats_lib.py @@ -0,0 +1,44 @@ +"""instance_embed_context_lib 顶栏统计.""" +from __future__ import annotations + +import unittest + +from lib.instance.instance_embed_context_lib import ( + options_funding_label, + profit_loss_ratio_from_averages, + profit_loss_ratio_from_trades, + total_funds_usdt, +) + + +class TestHeaderStatsLib(unittest.TestCase): + def test_profit_loss_ratio_from_averages(self): + self.assertEqual(profit_loss_ratio_from_averages(9.0, -3.0), 3.0) + self.assertIsNone(profit_loss_ratio_from_averages(9.0, 0)) + + def test_profit_loss_ratio_from_trades(self): + trades = [ + {"effective_pnl_amount": 10}, + {"effective_pnl_amount": 8}, + {"effective_pnl_amount": -4}, + {"effective_pnl_amount": -2}, + ] + self.assertEqual(profit_loss_ratio_from_trades(trades), 3.0) + + def test_total_funds_usdt(self): + self.assertEqual(total_funds_usdt(100.5, 59.27), 159.77) + self.assertIsNone(total_funds_usdt(None, 10)) + self.assertEqual( + total_funds_usdt(100, 50, options_trading_usdc=0.2, options_trading_usdt=10), + 160.2, + ) + + def test_options_funding_label(self): + self.assertEqual(options_funding_label(1.5, 10), "1.50 USDC · 10.00 USDT") + self.assertEqual(options_funding_label(10.19, 0), "10.19 USDC") + self.assertEqual(options_funding_label(None, 10), "10.00 USDT") + self.assertEqual(options_funding_label(None, None), "—") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_instance_live_pnl_lib.py b/tests/test_instance_live_pnl_lib.py new file mode 100644 index 0000000..512a0c8 --- /dev/null +++ b/tests/test_instance_live_pnl_lib.py @@ -0,0 +1,65 @@ +"""instance_live_pnl_lib 单元测试.""" +from __future__ import annotations + +import unittest + +from lib.instance.instance_live_pnl_lib import ( + merge_unrealized_pnl_components, + position_row_contracts, + resolve_instance_unrealized_pnl, + sum_unrealized_pnl_from_metrics, + sum_unrealized_pnl_from_positions, +) + + +class TestInstanceLivePnlLib(unittest.TestCase): + def test_position_row_contracts_from_info(self): + pos = {"contracts": 0, "info": {"positionAmt": "12.5"}} + self.assertAlmostEqual(position_row_contracts(pos), 12.5) + + def test_sum_from_positions_binance_style(self): + positions = [ + {"unrealizedPnl": -0.14, "info": {"positionAmt": "100"}}, + ] + self.assertEqual(sum_unrealized_pnl_from_positions(positions), -0.14) + + def test_sum_from_metrics_fallback(self): + rows = [{"exchange_symbol": "DOGE/USDT:USDT", "symbol": "DOGE/USDT", "direction": "long"}] + + def _metrics(ex_sym, direction): + self.assertEqual(direction, "long") + return {"unrealized_pnl": -0.14} + + self.assertEqual(sum_unrealized_pnl_from_metrics(rows, _metrics), -0.14) + + def test_resolve_prefers_bulk_positions(self): + def _fetch(): + return [{"unrealizedPnl": 1.2, "contracts": 1}] + + def _metrics(_ex, _d): + raise AssertionError("should not call metrics when bulk works") + + total = resolve_instance_unrealized_pnl(_fetch, [], _metrics) + self.assertEqual(total, 1.2) + + def test_resolve_falls_back_to_metrics(self): + def _fetch(): + raise RuntimeError("api down") + + rows = [{"exchange_symbol": "BTC/USDT:USDT", "symbol": "BTC/USDT", "direction": "short"}] + + def _metrics(_ex, direction): + return {"unrealized_pnl": -2.5} if direction == "short" else None + + total = resolve_instance_unrealized_pnl(_fetch, rows, _metrics) + self.assertEqual(total, -2.5) + + def test_merge_unrealized_pnl_components(self): + self.assertEqual(merge_unrealized_pnl_components(-0.11, 0.02), -0.09) + self.assertEqual(merge_unrealized_pnl_components(None, 0.02), 0.02) + self.assertEqual(merge_unrealized_pnl_components(-0.11, None), -0.11) + self.assertIsNone(merge_unrealized_pnl_components(None, None)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_instance_live_push_lib.py b/tests/test_instance_live_push_lib.py new file mode 100644 index 0000000..b1ff7d5 --- /dev/null +++ b/tests/test_instance_live_push_lib.py @@ -0,0 +1,20 @@ +"""instance_live_push_lib 单元测试.""" +from __future__ import annotations + +import json + +from lib.instance.instance_live_push_lib import InstanceLivePush + + +def test_tick_increments_version_and_connect_event() -> None: + push = InstanceLivePush() + v1 = push.tick("test") + v2 = push.tick("test") + assert v1 == 1 + assert v2 == 2 + gen = push.iter_sse() + first = next(gen) + assert first.startswith("event: live") + data = json.loads(first.split("data: ", 1)[1].strip()) + assert data["live_version"] == 2 + push.stop() diff --git a/tests/test_instance_nav_lib.py b/tests/test_instance_nav_lib.py new file mode 100644 index 0000000..1181ccc --- /dev/null +++ b/tests/test_instance_nav_lib.py @@ -0,0 +1,21 @@ +from lib.instance.instance_nav_lib import request_is_hub_soft_nav + + +def test_request_is_hub_soft_nav(): + class Req: + args = {"embed": "1"} + headers = {"X-Instance-Soft-Nav": "1"} + + assert request_is_hub_soft_nav(Req()) is True + + class Req2: + args = {"embed": "1"} + headers = {} + + assert request_is_hub_soft_nav(Req2()) is False + + class Req3: + args = {} + headers = {"X-Instance-Soft-Nav": "1"} + + assert request_is_hub_soft_nav(Req3()) is False diff --git a/tests/test_instance_pm2_lib.py b/tests/test_instance_pm2_lib.py new file mode 100644 index 0000000..bd4c5b2 --- /dev/null +++ b/tests/test_instance_pm2_lib.py @@ -0,0 +1,35 @@ +from unittest.mock import MagicMock, patch + +from lib.instance.instance_pm2_lib import restart_instance_pm2, schedule_pm2_restart + + +@patch("lib.instance.instance_pm2_lib.sys.platform", "linux") +@patch("lib.instance.instance_pm2_lib.subprocess.Popen") +def test_schedule_pm2_restart_returns_before_pm2(mock_popen): + result = schedule_pm2_restart("crypto_okx") + + assert result["ok"] is True + assert result["deferred"] is True + mock_popen.assert_called_once() + + +@patch("lib.instance.instance_pm2_lib.sys.platform", "linux") +@patch("lib.instance.instance_pm2_lib.schedule_pm2_restart") +def test_restart_instance_pm2_defer_uses_schedule(mock_schedule): + mock_schedule.return_value = {"ok": True, "app": "crypto_okx", "deferred": True} + + result = restart_instance_pm2("okx", defer=True) + + mock_schedule.assert_called_once_with("crypto_okx") + assert result["deferred"] is True + + +@patch("lib.instance.instance_pm2_lib.sys.platform", "linux") +@patch("lib.instance.instance_pm2_lib.subprocess.run") +def test_restart_instance_pm2_sync_runs_pm2(mock_run): + mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="") + + result = restart_instance_pm2("okx", defer=False) + + mock_run.assert_called_once() + assert result["ok"] is True diff --git a/tests/test_instance_settings_lib.py b/tests/test_instance_settings_lib.py new file mode 100644 index 0000000..1fd2993 --- /dev/null +++ b/tests/test_instance_settings_lib.py @@ -0,0 +1,43 @@ +"""instance_settings_lib 单元测试.""" +from __future__ import annotations + +import os +import unittest + +from lib.instance.instance_settings_lib import build_instance_settings_view + + +class InstanceSettingsLibTest(unittest.TestCase): + def test_build_settings_view_sections(self): + view = build_instance_settings_view( + exchange_key="gate", + exchange_display="Gate.io", + risk_status={"status_label": "正常", "can_trade": True}, + data_export_version=3, + ) + titles = [s["title"] for s in view["sections"]] + self.assertIn("交易执行", titles) + self.assertIn("账户冷静期", titles) + self.assertEqual(view["data_export_version"], 3) + self.assertTrue(view["show_transfer"]) + + def test_force_close_section_when_enabled(self): + old = os.environ.get("FORCE_CLOSE_ENABLED") + try: + os.environ["FORCE_CLOSE_ENABLED"] = "true" + view = build_instance_settings_view( + exchange_key="gate", + exchange_display="Gate.io", + risk_status={}, + ) + titles = [s["title"] for s in view["sections"]] + self.assertIn("整点强制清仓", titles) + finally: + if old is None: + os.environ.pop("FORCE_CLOSE_ENABLED", None) + else: + os.environ["FORCE_CLOSE_ENABLED"] = old + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_journal_form_lib.py b/tests/test_journal_form_lib.py new file mode 100644 index 0000000..ebaf560 --- /dev/null +++ b/tests/test_journal_form_lib.py @@ -0,0 +1,71 @@ +"""journal_form_lib / strategy_trade_labels 下单类型与开仓类型拆分.""" +from __future__ import annotations + +import unittest + +from lib.instance.journal_form_lib import ( + journal_entry_reason_valid, + normalize_journal_direction, + normalize_journal_entry_reason, +) +from lib.strategy.strategy_trade_labels import ( + JOURNAL_ORDER_TYPE_OPTIONS, + normalize_journal_order_type, + order_type_from_monitor_type, +) +from lib.trade.entry_model_lib import build_journal_entry_reason_options + + +class JournalFormLibTests(unittest.TestCase): + def test_journal_entry_reason_excludes_legacy_style_and_strategy(self): + opts = build_journal_entry_reason_options() + self.assertIn("反转/启动A", opts) + self.assertNotIn("趋势单", opts) + self.assertNotIn("波段单", opts) + self.assertNotIn("趋势回调", opts) + self.assertNotIn("顺势加仓", opts) + + def test_normalize_journal_entry_reason_rejects_legacy_for_new_submit(self): + opts = build_journal_entry_reason_options() + self.assertEqual( + normalize_journal_entry_reason("趋势单", opts, allow_legacy=False), + "", + ) + self.assertEqual( + normalize_journal_entry_reason("趋势回调", opts, allow_legacy=False), + "", + ) + + def test_normalize_journal_entry_reason_accepts_legacy_when_allowed(self): + opts = build_journal_entry_reason_options() + self.assertEqual( + normalize_journal_entry_reason("趋势单", opts, allow_legacy=True), + "趋势单", + ) + + def test_order_type_from_monitor_type(self): + self.assertEqual(order_type_from_monitor_type("下单监控"), "下单监控") + self.assertEqual(order_type_from_monitor_type("关键位监控"), "关键位监控") + self.assertEqual(order_type_from_monitor_type("趋势回调"), "趋势回调") + self.assertEqual(order_type_from_monitor_type("顺势加仓"), "顺势加仓") + + def test_normalize_journal_order_type(self): + self.assertEqual(normalize_journal_order_type("顺势加仓"), "顺势加仓") + self.assertEqual(normalize_journal_order_type(""), "") + self.assertEqual(len(JOURNAL_ORDER_TYPE_OPTIONS), 4) + + def test_journal_entry_reason_valid(self): + opts = build_journal_entry_reason_options() + self.assertTrue(journal_entry_reason_valid("顺势/大分歧A", opts)) + self.assertFalse(journal_entry_reason_valid("趋势单", opts)) + + def test_normalize_journal_direction(self): + self.assertEqual(normalize_journal_direction("short"), "short") + self.assertEqual(normalize_journal_direction("做空"), "short") + self.assertEqual(normalize_journal_direction("long"), "long") + self.assertEqual(normalize_journal_direction("做多"), "long") + self.assertEqual(normalize_journal_direction(""), "") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_journal_images_lib.py b/tests/test_journal_images_lib.py new file mode 100644 index 0000000..6694e92 --- /dev/null +++ b/tests/test_journal_images_lib.py @@ -0,0 +1,156 @@ +"""journal_images_lib / journal_upload_api_lib 单元测试.""" +import json +import os +import tempfile +import unittest +from io import BytesIO + +from lib.instance.journal_images_lib import ( + JOURNAL_UPLOAD_TFS, + collect_journal_slot_images, + enrich_journal_api_item, + images_json_dumps, + is_valid_preuploaded_journal_file, + journal_image_paths, + journal_upload_field_name, + normalize_journal_draft_id, + parse_images_json, + primary_journal_image, + save_journal_slot_uploads, + uploaded_screenshot_field_name, +) +from lib.instance.journal_upload_api_lib import handle_journal_upload_slot + + +class _FakeFile: + def __init__(self, filename: str, data: bytes): + self.filename = filename + self._data = data + + def save(self, path: str) -> None: + with open(path, "wb") as f: + f.write(self._data) + + +class _FakeFiles: + def __init__(self, mapping): + self._mapping = mapping + + def get(self, key): + return self._mapping.get(key) + + +class _FakeForm: + def __init__(self, mapping): + self._mapping = mapping + + def get(self, key, default=None): + return self._mapping.get(key, default) + + +class _FakeRequest: + def __init__(self, form=None, files=None): + self.form = form + self.files = files + + +class JournalImagesLibTest(unittest.TestCase): + def test_field_names(self): + self.assertEqual(journal_upload_field_name("5m"), "screenshot_5m") + self.assertEqual(uploaded_screenshot_field_name("5m"), "uploaded_screenshot_5m") + + def test_normalize_draft_id(self): + good = "a" * 32 + self.assertEqual(normalize_journal_draft_id(good), good) + self.assertIsNone(normalize_journal_draft_id("bad")) + + def test_save_slot_uploads_partial(self): + with tempfile.TemporaryDirectory() as tmp: + files = _FakeFiles( + { + "screenshot_5m": _FakeFile("a.png", b"png5"), + "screenshot_1h": _FakeFile("b.jpg", b"jpg1"), + } + ) + saved = save_journal_slot_uploads( + files, + "abc123" + "0" * 26, + tmp, + secure_filename_fn=lambda x: x, + ) + self.assertEqual(len(saved), 2) + self.assertEqual(saved[0]["tf"], "5m") + self.assertTrue(os.path.isfile(os.path.join(tmp, saved[0]["file"]))) + self.assertEqual(saved[1]["tf"], "1h") + + def test_collect_preuploaded(self): + entry_id = "abc123" + "0" * 26 + fname = f"journal_{entry_id}_5m.png" + with tempfile.TemporaryDirectory() as tmp: + with open(os.path.join(tmp, fname), "wb") as f: + f.write(b"x") + form = _FakeForm({uploaded_screenshot_field_name("5m"): fname}) + saved = collect_journal_slot_images( + form, + _FakeFiles({}), + entry_id, + tmp, + secure_filename_fn=lambda x: x, + ) + self.assertEqual(saved, [{"tf": "5m", "file": fname}]) + + def test_is_valid_preuploaded_journal_file(self): + entry_id = "abc123" + "0" * 26 + fname = f"journal_{entry_id}_5m.png" + self.assertTrue(is_valid_preuploaded_journal_file(fname, entry_id, "5m")) + self.assertFalse(is_valid_preuploaded_journal_file("../evil.png", entry_id, "5m")) + self.assertFalse(is_valid_preuploaded_journal_file(fname, "b" * 32, "5m")) + + def test_parse_and_enrich(self): + raw = images_json_dumps([{"tf": "5m", "file": "journal_x_5m.png"}]) + item = enrich_journal_api_item({"images_json": raw, "image": "legacy.png"}) + self.assertEqual(len(item["images"]), 1) + self.assertEqual(item["images"][0]["tf"], "5m") + + legacy = enrich_journal_api_item({"image": "only.png"}) + self.assertEqual(legacy["images"][0]["file"], "only.png") + + def test_journal_image_paths_dedupe(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "same.png") + with open(path, "wb") as f: + f.write(b"x") + row = { + "image": "same.png", + "images_json": json.dumps([{"tf": "5m", "file": "same.png"}]), + } + paths = journal_image_paths(row, tmp) + self.assertEqual(len(paths), 1) + + def test_primary_journal_image(self): + self.assertEqual( + primary_journal_image([{"tf": "5m", "file": "a.png"}]), + "a.png", + ) + self.assertIsNone(primary_journal_image([])) + + def test_handle_journal_upload_slot(self): + entry_id = "abc123" + "0" * 26 + with tempfile.TemporaryDirectory() as tmp: + req = _FakeRequest( + form=_FakeForm({"journal_draft_id": entry_id, "tf": "5m"}), + files=_FakeFiles({"file": _FakeFile("local.png", b"data")}), + ) + payload, code = handle_journal_upload_slot( + req, + upload_folder=tmp, + secure_filename_fn=lambda x: x, + ) + self.assertEqual(code, 200) + self.assertTrue(payload["ok"]) + self.assertEqual(payload["tf"], "5m") + self.assertTrue(os.path.isfile(os.path.join(tmp, payload["file"]))) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_key_auto_order_lib.py b/tests/test_key_auto_order_lib.py new file mode 100644 index 0000000..b2b8582 --- /dev/null +++ b/tests/test_key_auto_order_lib.py @@ -0,0 +1,75 @@ +"""key_auto_order_lib 单元测试.""" +import unittest + +from lib.key_monitor.key_auto_order_lib import ( + check_monitor_type_add_allowed, + effective_entry_reason_options, + effective_stats_segment_defs, + load_key_auto_order_enabled, +) +from lib.trade.position_sizing_lib import MODE_FULL_MARGIN, MODE_RISK + +FULL_OPTS = ( + "趋势A", + "趋势B", + "趋势C", + "趋势D", + "趋势E", + "关键位箱体突破", + "关键位收敛突破", + "关键位斐波0.618", + "关键位斐波0.786", + "关键位假突破", + "关键位回调触价开仓", + "关键位突破触价开仓", + "趋势回调", + "顺势加仓", +) + +STATS_DEFS = ( + ("all", "全部", {}), + ("key_box", "箱体", {}), + ("key_trigger", "触价", {}), +) + + +class KeyAutoOrderLibTest(unittest.TestCase): + def test_load_default_false(self): + self.assertFalse(load_key_auto_order_enabled({"KEY_AUTO_ORDER_ENABLED": "false"})) + self.assertFalse(load_key_auto_order_enabled({})) + self.assertTrue(load_key_auto_order_enabled({"KEY_AUTO_ORDER_ENABLED": "true"})) + + def test_entry_reason_off(self): + out = effective_entry_reason_options(FULL_OPTS, MODE_RISK, False) + self.assertNotIn("关键位箱体突破", out) + self.assertNotIn("关键位回调触价开仓", out) + self.assertIn("顺势加仓", out) + + def test_entry_reason_risk_on(self): + out = effective_entry_reason_options(FULL_OPTS, MODE_RISK, True) + self.assertIn("关键位箱体突破", out) + self.assertIn("关键位回调触价开仓", out) + + def test_entry_reason_full_margin_on(self): + out = effective_entry_reason_options(FULL_OPTS, MODE_FULL_MARGIN, True) + self.assertNotIn("关键位箱体突破", out) + self.assertIn("关键位回调触价开仓", out) + + def test_stats_segments_off(self): + segs = effective_stats_segment_defs(STATS_DEFS, MODE_RISK, False) + keys = {x[0] for x in segs} + self.assertIn("all", keys) + self.assertNotIn("key_box", keys) + + def test_add_key_rs_always(self): + ok, _ = check_monitor_type_add_allowed("关键支撑阻力", MODE_RISK, False) + self.assertTrue(ok) + + def test_add_key_trigger_off(self): + ok, msg = check_monitor_type_add_allowed("回调触价开仓", MODE_RISK, False) + self.assertFalse(ok) + self.assertIn("KEY_AUTO_ORDER_ENABLED", msg) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_key_monitor_box_invalidate.py b/tests/test_key_monitor_box_invalidate.py new file mode 100644 index 0000000..20302c4 --- /dev/null +++ b/tests/test_key_monitor_box_invalidate.py @@ -0,0 +1,34 @@ +import unittest + +from lib.key_monitor.key_monitor_lib import ( + BOX_BREAKOUT_CLOSE_OPPOSITE, + box_breakout_invalidate_by_mark, + box_breakout_invalidate_edge_label, +) + + +class BoxBreakoutInvalidateTests(unittest.TestCase): + def test_short_invalidates_above_upper(self): + self.assertTrue(box_breakout_invalidate_by_mark("short", 62.511, 61.746, 60.569)) + + def test_short_stays_valid_inside_or_below(self): + self.assertFalse(box_breakout_invalidate_by_mark("short", 61.0, 61.746, 60.569)) + self.assertFalse(box_breakout_invalidate_by_mark("short", 60.0, 61.746, 60.569)) + + def test_long_invalidates_below_lower(self): + self.assertTrue(box_breakout_invalidate_by_mark("long", 94.0, 100.0, 95.0)) + + def test_long_stays_valid_inside_or_above(self): + self.assertFalse(box_breakout_invalidate_by_mark("long", 98.0, 100.0, 95.0)) + self.assertFalse(box_breakout_invalidate_by_mark("long", 101.0, 100.0, 95.0)) + + def test_edge_label(self): + self.assertEqual(box_breakout_invalidate_edge_label("long"), "下沿") + self.assertEqual(box_breakout_invalidate_edge_label("short"), "上沿") + + def test_close_reason_constant(self): + self.assertEqual(BOX_BREAKOUT_CLOSE_OPPOSITE, "box_opposite_break") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_key_monitor_rs_alert.py b/tests/test_key_monitor_rs_alert.py new file mode 100644 index 0000000..5f72a16 --- /dev/null +++ b/tests/test_key_monitor_rs_alert.py @@ -0,0 +1,86 @@ +"""阻力/支撑提醒:占位与间隔防重复推送.""" +from __future__ import annotations + +import sqlite3 +import unittest +from datetime import datetime, timedelta + +from lib.key_monitor.key_monitor_lib import ( + claim_rs_level_notify, + notify_interval_elapsed, + run_rs_level_alert_tick, +) + + +def _row(**kwargs): + base = { + "upper": 2.174, + "lower": 1.694, + "notification_count": 0, + "max_notify": 3, + "notify_interval_min": 5, + "direction": "watch", + "last_notified_at": None, + "last_rs_bar_ts": None, + } + base.update(kwargs) + return base + + +class TestRsLevelAlertClaim(unittest.TestCase): + def setUp(self): + self.conn = sqlite3.connect(":memory:") + self.conn.execute( + "CREATE TABLE key_monitors (" + "id INTEGER PRIMARY KEY, notification_count INTEGER DEFAULT 0, " + "direction TEXT, last_notified_at TEXT, last_rs_bar_ts INTEGER)" + ) + self.conn.execute( + "INSERT INTO key_monitors (id, notification_count, direction) VALUES (1, 0, 'watch')" + ) + self.conn.commit() + + def test_claim_advances_once_per_index(self): + ok1 = claim_rs_level_notify( + self.conn, 1, 1, "long", "2026-06-02 00:25:00", 1000, prior_count=0 + ) + self.conn.commit() + self.assertTrue(ok1) + ok_dup = claim_rs_level_notify( + self.conn, 1, 1, "long", "2026-06-02 00:25:03", 1000, prior_count=0 + ) + self.assertFalse(ok_dup) + ok2 = claim_rs_level_notify( + self.conn, 1, 2, "long", "2026-06-02 00:30:00", 1000, prior_count=1 + ) + self.conn.commit() + self.assertTrue(ok2) + row = self.conn.execute( + "SELECT notification_count FROM key_monitors WHERE id=1" + ).fetchone() + self.assertEqual(row[0], 2) + + def test_second_push_requires_interval(self): + now = datetime(2026, 6, 2, 0, 26, 0) + row = _row( + notification_count=1, + direction="long", + last_notified_at="2026-06-02 00:25:00", + ) + tick = run_rs_level_alert_tick(row, 2.18, 1000, now, default_max_notify=3, default_interval_min=5) + self.assertIsNone(tick) + later = datetime(2026, 6, 2, 0, 30, 1) + tick2 = run_rs_level_alert_tick( + row, 2.18, 1000, later, default_max_notify=3, default_interval_min=5 + ) + self.assertIsNotNone(tick2) + self.assertEqual(tick2["notify_index"], 2) + self.assertEqual(tick2["prior_count"], 1) + + def test_notify_interval_invalid_timestamp_does_not_spam(self): + now = datetime(2026, 6, 2, 1, 0, 0) + self.assertFalse(notify_interval_elapsed("not-a-date", 5, now)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_key_monitor_rs_type.py b/tests/test_key_monitor_rs_type.py new file mode 100644 index 0000000..5c1ea65 --- /dev/null +++ b/tests/test_key_monitor_rs_type.py @@ -0,0 +1,27 @@ +import unittest + +from lib.key_monitor.key_monitor_lib import ( + KEY_MONITOR_RS_TYPE, + is_rs_key_monitor_type, + rs_monitor_type_for_storage, + rs_monitor_type_label, +) + + +class KeyMonitorRsTypeTests(unittest.TestCase): + def test_legacy_types_still_recognized(self): + self.assertTrue(is_rs_key_monitor_type("关键阻力位")) + self.assertTrue(is_rs_key_monitor_type("关键支撑位")) + + def test_storage_normalizes_to_unified_type(self): + self.assertEqual(rs_monitor_type_for_storage("关键阻力位"), KEY_MONITOR_RS_TYPE) + self.assertEqual(rs_monitor_type_for_storage("关键支撑位"), KEY_MONITOR_RS_TYPE) + self.assertEqual(rs_monitor_type_for_storage(KEY_MONITOR_RS_TYPE), KEY_MONITOR_RS_TYPE) + + def test_label_merges_legacy_display(self): + self.assertEqual(rs_monitor_type_label("关键阻力位"), KEY_MONITOR_RS_TYPE) + self.assertEqual(rs_monitor_type_label("箱体突破"), "箱体突破") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_manual_order_rr_preview.py b/tests/test_manual_order_rr_preview.py new file mode 100644 index 0000000..a72bc15 --- /dev/null +++ b/tests/test_manual_order_rr_preview.py @@ -0,0 +1,66 @@ +"""预估盈亏比(前端 manual_order_rr_preview.js)公式与后端 calc_rr_ratio 口径一致.""" + + +def _calc_rr(direction: str, entry: float, sl: float, tp: float): + 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) + + +def _calc_rr_from_pct(sl_pct: float, tp_pct: float): + if sl_pct <= 0 or tp_pct <= 0: + return None + return tp_pct / sl_pct + + +def test_long_price_mode_rr(): + assert _calc_rr("long", 100.0, 95.0, 110.0) == 2.0 + + +def test_short_price_mode_rr(): + assert _calc_rr("short", 100.0, 105.0, 90.0) == 2.0 + + +def test_invalid_geometry_returns_none(): + assert _calc_rr("long", 100.0, 105.0, 110.0) is None + assert _calc_rr("short", 100.0, 95.0, 98.0) is None + + +def test_pct_mode_rr(): + assert _calc_rr_from_pct(2.0, 4.0) == 2.0 + assert _calc_rr_from_pct(1.5, 3.0) == 2.0 + + +def _calc_risk_fraction(direction: str, entry: float, sl: float): + 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 + + +def _full_margin_risk_u(available: float, buffer: float, leverage: int, direction: str, entry: float, sl: float): + rf = _calc_risk_fraction(direction, entry, sl) + if rf is None: + return None + margin = round(available * buffer, 2) + return round(margin * leverage * rf, 2) + + +def test_full_margin_risk_short_hype(): + # 可用约 23.06U × 0.9 缓冲 × 5x,入场 62.5,止损 63.6 + risk = _full_margin_risk_u(23.06, 0.9, 5, "short", 62.5, 63.6) + assert risk is not None + assert 1.5 <= risk <= 2.5 diff --git a/tests/test_manual_sltp_lib.py b/tests/test_manual_sltp_lib.py new file mode 100644 index 0000000..9b1eba4 --- /dev/null +++ b/tests/test_manual_sltp_lib.py @@ -0,0 +1,32 @@ +from lib.trade.manual_sltp_lib import ( + MANUAL_FIXED_RR_DEFAULT, + calc_tp_from_fixed_rr, + parse_fixed_rr, + resolve_open_sltp_prices, +) + + +def test_calc_tp_from_fixed_rr_long(): + tp = calc_tp_from_fixed_rr("long", 100.0, 95.0, 1.5) + assert tp == 107.5 + + +def test_calc_tp_from_fixed_rr_short(): + tp = calc_tp_from_fixed_rr("short", 100.0, 105.0, 1.5) + assert tp == 92.5 + + +def test_resolve_open_fixed_rr_mode(): + sl, tp = resolve_open_sltp_prices( + "long", + 100.0, + "fixed_rr", + {"sl": "95", "fixed_rr": "1.5"}, + ) + assert sl == 95.0 + assert tp == 107.5 + + +def test_parse_fixed_rr_default(): + assert parse_fixed_rr(None) == MANUAL_FIXED_RR_DEFAULT + assert parse_fixed_rr("2") == 2.0 diff --git a/tests/test_okx_funding_balances.py b/tests/test_okx_funding_balances.py new file mode 100644 index 0000000..b148e19 --- /dev/null +++ b/tests/test_okx_funding_balances.py @@ -0,0 +1,39 @@ +"""OKX 资金账户余额(asset/balances).""" +from __future__ import annotations + +import unittest +from unittest.mock import MagicMock + +from lib.exchange.okx_options_lib import ( + fetch_funding_balances_via_asset_api, + fetch_options_balances, +) + + +class TestOkxFundingBalances(unittest.TestCase): + def test_asset_balances_parsed(self): + ex = MagicMock() + ex.private_get_asset_balances.return_value = { + "data": [ + {"ccy": "USDT", "availBal": "25.5", "bal": "30"}, + {"ccy": "USDC", "availBal": "10", "bal": "10"}, + ] + } + total, avail = fetch_funding_balances_via_asset_api(ex) + self.assertEqual(avail["USDT"], 25.5) + self.assertEqual(total["USDT"], 30.0) + self.assertEqual(avail["USDC"], 10.0) + + def test_fetch_options_balances_merges_asset_api(self): + ex = MagicMock() + ex.fetch_balance.return_value = {"free": {}, "total": {}} + ex.private_get_asset_balances.return_value = { + "data": [{"ccy": "USDT", "availBal": "18.2", "bal": "18.2"}] + } + bal = fetch_options_balances(ex, force=True) + self.assertEqual(bal["funding_usdt_avail"], 18.2) + self.assertEqual(bal["funding_usdt"], 18.2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_okx_position_metrics.py b/tests/test_okx_position_metrics.py new file mode 100644 index 0000000..6e83f58 --- /dev/null +++ b/tests/test_okx_position_metrics.py @@ -0,0 +1,38 @@ +"""OKX 持仓指标解析:未实现盈亏须支持负数.""" +from __future__ import annotations + +import unittest + + +class TestOkxPositionMetrics(unittest.TestCase): + def test_parse_unrealized_pnl_negative(self): + from crypto_monitor_okx.app import parse_ccxt_position_metrics + + pos = { + "side": "long", + "contracts": 10, + "markPrice": 0.43, + "unrealizedPnl": -1.25, + "info": {"upl": "-1.25", "markPx": "0.43"}, + } + out = parse_ccxt_position_metrics(pos, order_leverage=5) + self.assertIsNotNone(out) + self.assertAlmostEqual(out["unrealized_pnl"], -1.25) + self.assertAlmostEqual(out["mark_price"], 0.43) + + def test_parse_unrealized_pnl_zero(self): + from crypto_monitor_okx.app import parse_ccxt_position_metrics + + pos = { + "side": "long", + "contracts": 1, + "unrealizedPnl": 0, + "info": {"upl": "0"}, + } + out = parse_ccxt_position_metrics(pos) + self.assertIsNotNone(out) + self.assertEqual(out["unrealized_pnl"], 0.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_okx_spot_swap.py b/tests/test_okx_spot_swap.py new file mode 100644 index 0000000..9de2608 --- /dev/null +++ b/tests/test_okx_spot_swap.py @@ -0,0 +1,45 @@ +"""OKX USDT/USDC 现货市价兑换参数.""" +from __future__ import annotations + +import unittest +from unittest.mock import MagicMock + +from lib.exchange.okx_options_lib import spot_market_swap_usdt_usdc + + +class TestOkxSpotSwap(unittest.TestCase): + def test_usdt_to_usdc_uses_quote_ccy(self): + ex = MagicMock() + ex.private_post_trade_order.return_value = { + "data": [{"sCode": "0", "ordId": "1"}], + } + result = spot_market_swap_usdt_usdc(ex, direction="usdt_to_usdc", amount=10) + self.assertTrue(result["ok"]) + body = ex.private_post_trade_order.call_args[0][0] + self.assertEqual(body["tgtCcy"], "quote_ccy") + self.assertEqual(body["side"], "buy") + + def test_usdc_to_usdt_uses_base_ccy(self): + ex = MagicMock() + ex.private_post_trade_order.return_value = { + "data": [{"sCode": "0", "ordId": "2"}], + } + result = spot_market_swap_usdt_usdc(ex, direction="usdc_to_usdt", amount=5) + self.assertTrue(result["ok"]) + body = ex.private_post_trade_order.call_args[0][0] + self.assertEqual(body["tgtCcy"], "base_ccy") + self.assertEqual(body["side"], "sell") + + def test_insufficient_balance_returns_chinese_message(self): + ex = MagicMock() + ex.private_post_trade_order.side_effect = Exception( + 'okx {"code":"1","data":[{"sCode":"51008","sMsg":"Order failed. Your available USDT balance is insufficient."}]}' + ) + result = spot_market_swap_usdt_usdc(ex, direction="usdt_to_usdc", amount=20) + self.assertFalse(result["ok"]) + self.assertEqual(result["msg"], "资金账户 USDT 可用余额不足") + self.assertNotIn("{", result["msg"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_option_buy_liquidity.py b/tests/test_option_buy_liquidity.py new file mode 100644 index 0000000..cc10c24 --- /dev/null +++ b/tests/test_option_buy_liquidity.py @@ -0,0 +1,22 @@ +"""期权买入流动性门禁:真实卖一价+深度.""" +from lib.exchange.okx_options_lib import ( + cap_option_buy_sheets_to_ask_depth, + option_buy_liquidity_ok, +) + + +def test_option_buy_liquidity_ok_requires_ask_and_depth(): + assert option_buy_liquidity_ok(10, 1)[0] is True + assert option_buy_liquidity_ok(10, 0)[0] is False + assert option_buy_liquidity_ok(10, None)[0] is False + assert option_buy_liquidity_ok(None, 5)[0] is False + assert option_buy_liquidity_ok(0, 5)[0] is False + + +def test_cap_option_buy_sheets_to_ask_depth(): + capped, msg = cap_option_buy_sheets_to_ask_depth(9, 2.8, min_sz=1) + assert capped == 2 + assert msg == "" + capped, msg = cap_option_buy_sheets_to_ask_depth(1, 0.4, min_sz=1) + assert capped is None + assert "深度不足" in msg diff --git a/tests/test_options_add_premium.py b/tests/test_options_add_premium.py new file mode 100644 index 0000000..a4e3f27 --- /dev/null +++ b/tests/test_options_add_premium.py @@ -0,0 +1,69 @@ +"""期权加仓后权利金汇总.""" +from __future__ import annotations + +import sqlite3 + +from lib.options.options_db import init_options_tables, sum_open_premium_paid, sum_open_sheets + + +def _mem_db() -> sqlite3.Connection: + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + init_options_tables(conn) + return conn + + +def test_sum_open_premium_after_add(): + conn = _mem_db() + inst = "BTC-USD_UM-260717-65500-C" + conn.execute( + """ + INSERT INTO options_trades + (inst_id, underlying, opt_type, sheets, eth_amount, open_quote, premium_paid, status) + VALUES (?, 'BTC-USD_UM', 'C', 1, 0.01, 530, 5.3, 'open') + """, + (inst,), + ) + conn.execute( + """ + INSERT INTO options_trades + (inst_id, underlying, opt_type, sheets, eth_amount, open_quote, premium_paid, status) + VALUES (?, 'BTC-USD_UM', 'C', 1, 0.01, 140, 1.4, 'open') + """, + (inst,), + ) + conn.commit() + assert sum_open_premium_paid(conn, inst) == 6.7 + assert sum_open_sheets(conn, inst) == 2 + # 最新一笔单独是 1.4,汇总不能只取最新 + latest = conn.execute( + "SELECT premium_paid FROM options_trades WHERE inst_id=? AND status='open' ORDER BY id DESC LIMIT 1", + (inst,), + ).fetchone() + assert float(latest["premium_paid"]) == 1.4 + conn.close() + + +def test_sum_open_premium_ignores_closed(): + conn = _mem_db() + inst = "ETH-USD_UM-260101-2000-C" + conn.execute( + """ + INSERT INTO options_trades + (inst_id, underlying, opt_type, sheets, eth_amount, premium_paid, status) + VALUES (?, 'ETH-USD_UM', 'C', 1, 0.01, 2.0, 'closed') + """, + (inst,), + ) + conn.execute( + """ + INSERT INTO options_trades + (inst_id, underlying, opt_type, sheets, eth_amount, premium_paid, status) + VALUES (?, 'ETH-USD_UM', 'C', 2, 0.02, 3.5, 'open') + """, + (inst,), + ) + conn.commit() + assert sum_open_premium_paid(conn, inst) == 3.5 + assert sum_open_sheets(conn, inst) == 2 + conn.close() diff --git a/tests/test_options_close_gate_lib.py b/tests/test_options_close_gate_lib.py new file mode 100644 index 0000000..1d28bdd --- /dev/null +++ b/tests/test_options_close_gate_lib.py @@ -0,0 +1,64 @@ +"""期权平仓门控:可回收≥2×权利金且持续持有.""" +from __future__ import annotations + +import unittest + +from lib.options.options_close_gate_lib import ( + clear_close_gate, + is_close_gate_passed, + mark_close_gate_passed, + update_close_gate, +) + + +class OptionsCloseGateTests(unittest.TestCase): + def setUp(self): + clear_close_gate() + + def tearDown(self): + clear_close_gate() + + def test_below_2x_not_ready(self): + g = update_close_gate("ETH-X", recycle_usdc=15.0, premium_paid=10.0, now=1000.0) + self.assertFalse(g["recycle_ok"]) + self.assertFalse(g["ready"]) + + def test_meets_2x_needs_hold(self): + g1 = update_close_gate("ETH-X", recycle_usdc=20.0, premium_paid=10.0, now=1000.0) + self.assertTrue(g1["recycle_ok"]) + self.assertFalse(g1["ready"]) + self.assertAlmostEqual(g1["remain_seconds"], 120.0) + + g2 = update_close_gate("ETH-X", recycle_usdc=21.0, premium_paid=10.0, now=1120.0) + self.assertTrue(g2["ready"]) + self.assertGreaterEqual(g2["held_seconds"], 120.0) + + def test_break_resets_timer(self): + update_close_gate("ETH-X", recycle_usdc=20.0, premium_paid=10.0, now=1000.0) + update_close_gate("ETH-X", recycle_usdc=21.0, premium_paid=10.0, now=1100.0) + g_break = update_close_gate("ETH-X", recycle_usdc=12.0, premium_paid=10.0, now=1110.0) + self.assertFalse(g_break["recycle_ok"]) + g_again = update_close_gate("ETH-X", recycle_usdc=22.0, premium_paid=10.0, now=1111.0) + self.assertTrue(g_again["recycle_ok"]) + self.assertFalse(g_again["ready"]) + self.assertAlmostEqual(g_again["held_seconds"], 0.0) + + def test_passed_latches_after_ready(self): + update_close_gate("ETH-Y", recycle_usdc=20.0, premium_paid=10.0, now=1000.0) + g_ready = update_close_gate("ETH-Y", recycle_usdc=21.0, premium_paid=10.0, now=1120.0) + self.assertTrue(g_ready["ready"]) + self.assertTrue(g_ready["passed"]) + self.assertTrue(is_close_gate_passed("ETH-Y")) + # 后续回收跌破 2×:计时重置,但 passed 仍保留供续批只验流动性 + g_drop = update_close_gate("ETH-Y", recycle_usdc=5.0, premium_paid=10.0, now=1130.0) + self.assertFalse(g_drop["recycle_ok"]) + self.assertTrue(g_drop["passed"]) + self.assertFalse(g_drop["auto_close_blocked"]) + + def test_mark_passed_manual(self): + mark_close_gate_passed("ETH-Z") + self.assertTrue(is_close_gate_passed("ETH-Z")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_options_hub_lib.py b/tests/test_options_hub_lib.py new file mode 100644 index 0000000..7c3fb15 --- /dev/null +++ b/tests/test_options_hub_lib.py @@ -0,0 +1,47 @@ +from unittest import TestCase +from unittest.mock import MagicMock, patch + +from lib.options.options_hub_lib import build_options_hub_snapshot + + +class OptionsHubLibTests(TestCase): + def test_build_options_hub_snapshot_disabled(self): + out = build_options_hub_snapshot({"enabled": False}) + self.assertFalse(out["enabled"]) + self.assertTrue(out["ok"]) + + @patch("lib.options.options_hub_lib._compute_options_stats", return_value={}) + @patch("lib.options.options_positions_lib.build_display_option_positions") + def test_build_options_hub_snapshot_positions(self, mock_positions, _mock_stats): + mock_positions.return_value = [ + { + "inst_id": "ETH-USD_UM-260703-1800-C", + "pos": 2, + "upl": 9.9, + "mark_px": 0.1, + "close_preview": {"estimated_pnl": 1.5}, + } + ] + conn = MagicMock() + conn.__enter__ = MagicMock(return_value=conn) + conn.__exit__ = MagicMock(return_value=False) + cfg = { + "enabled": True, + "exchange_options": object(), + "options_api_ready": lambda ex: (True, ""), + "fetch_option_positions": lambda ex: [ + {"instId": "ETH-USD_UM-260703-1800-C", "pos": "2", "upl": "1.5", "markPx": "0.1"} + ], + "fetch_options_balances": lambda ex: {"trading_usdc": 9.5, "funding_usdc": 12.0}, + "get_db": MagicMock(return_value=conn), + "trade_budget": 10, + "account_label": "OKX期权", + } + with patch("lib.options.options_target_lib.list_active_targets", return_value=[]): + with patch("lib.options.options_target_lib.targets_by_inst", return_value={}): + out = build_options_hub_snapshot(cfg) + self.assertTrue(out["ok"], out.get("msg")) + self.assertEqual(out["position_count"], 1) + self.assertEqual(out["upl_total_usdc"], 1.5) + self.assertEqual(out["trading_usdc"], 9.5) + self.assertEqual(out.get("target_monitors"), []) diff --git a/tests/test_options_net_pnl_sum.py b/tests/test_options_net_pnl_sum.py new file mode 100644 index 0000000..e3ad5a7 --- /dev/null +++ b/tests/test_options_net_pnl_sum.py @@ -0,0 +1,32 @@ +"""期权净盈亏汇总与持仓卡口径一致.""" +from unittest import TestCase +from unittest.mock import patch + +from lib.options.options_positions_lib import net_pnl_from_display_row, sum_options_net_pnl_usdc + + +class OptionsNetPnlSumTests(TestCase): + def test_net_pnl_from_display_row(self): + self.assertEqual( + net_pnl_from_display_row({"close_preview": {"estimated_pnl": -2.8}, "premium_paid": 4.95}), + -2.8, + ) + self.assertIsNone( + net_pnl_from_display_row({"close_preview": {"bid_invalid": True, "estimated_pnl": -1}}) + ) + self.assertEqual( + net_pnl_from_display_row( + {"close_preview": {"total_received": 2.15}, "premium_paid": 4.95} + ), + round(2.15 - 4.95, 4), + ) + + @patch("lib.options.options_positions_lib.build_display_option_positions") + def test_sum_options_net_pnl_usdc(self, mock_build): + mock_build.return_value = [ + {"close_preview": {"estimated_pnl": -2.8}}, + {"close_preview": {"estimated_pnl": 1.0}}, + {"close_preview": {"bid_invalid": True, "estimated_pnl": 9}}, + ] + cfg = {"fetch_option_positions": lambda ex: [{"instId": "X"}]} + self.assertEqual(sum_options_net_pnl_usdc(cfg, object()), -1.8) diff --git a/tests/test_options_pending_lib.py b/tests/test_options_pending_lib.py new file mode 100644 index 0000000..d393ddd --- /dev/null +++ b/tests/test_options_pending_lib.py @@ -0,0 +1,63 @@ +"""期权挂单超时撤单单测.""" +from unittest import TestCase + +from lib.options.options_pending_lib import ( + cancel_stale_close_pending_orders, + enrich_pending_orders, + is_close_pending_order, + order_age_seconds, +) + + +class OptionsPendingLibTests(TestCase): + def test_order_age_and_close_detect(self): + now = 1_700_000_600_000 + age = order_age_seconds({"c_time": now - 90_000}, now_ms=now) + self.assertAlmostEqual(age, 90.0, places=3) + self.assertTrue(is_close_pending_order({"side": "sell"})) + self.assertTrue(is_close_pending_order({"side": "buy", "reduce_only": True})) + self.assertFalse(is_close_pending_order({"side": "buy"})) + + def test_enrich_expire(self): + now = 1_700_000_600_000 + rows = enrich_pending_orders( + [ + {"ord_id": "1", "inst_id": "A", "side": "sell", "c_time": now - 700_000}, + {"ord_id": "2", "inst_id": "B", "side": "buy", "c_time": now - 700_000}, + {"ord_id": "3", "inst_id": "C", "side": "sell", "c_time": now - 30_000}, + ], + ttl_seconds=600, + now_ms=now, + ) + by_id = {r["ord_id"]: r for r in rows} + self.assertTrue(by_id["1"]["stale"]) + self.assertTrue(by_id["1"]["auto_cancel_enabled"]) + self.assertFalse(by_id["2"]["auto_cancel_enabled"]) + self.assertFalse(by_id["3"]["stale"]) + self.assertAlmostEqual(by_id["3"]["expire_in_sec"], 570.0, places=0) + + def test_cancel_stale_only_close(self): + now = 1_700_000_600_000 + pending = [ + {"ord_id": "s1", "inst_id": "A", "side": "sell", "c_time": now - 700_000}, + {"ord_id": "b1", "inst_id": "B", "side": "buy", "c_time": now - 700_000}, + {"ord_id": "s2", "inst_id": "C", "side": "sell", "c_time": now - 10_000}, + ] + cancelled = [] + + def fetch(_ex=None): + return pending + + def cancel(_ex=None, inst_id=None, ord_id=None): + cancelled.append((inst_id, ord_id)) + return {"ok": True} + + out = cancel_stale_close_pending_orders( + fetch_pending=fetch, + cancel_order=cancel, + ttl_seconds=60, + now_ms=now, + ex=object(), + ) + self.assertEqual(out["cancelled"], 1) + self.assertEqual(cancelled, [("A", "s1")]) diff --git a/tests/test_options_pricing.py b/tests/test_options_pricing.py new file mode 100644 index 0000000..28ff4eb --- /dev/null +++ b/tests/test_options_pricing.py @@ -0,0 +1,397 @@ +"""期权定价单测.""" +from lib.options.options_pricing_lib import ( + calc_order_size, + premium_per_sheet, + sheets_from_eth_amount, + total_premium, +) +from lib.exchange.okx_options_lib import format_option_px, inst_family_from_inst_id, round_option_px + + +def test_inst_family_from_inst_id(): + assert inst_family_from_inst_id("ETH-USD_UM-260707-1790-C") == "ETH-USD_UM" + assert inst_family_from_inst_id("BTC-USD-260925-60000-C") == "BTC-USD" + + +def test_round_option_px(): + assert round_option_px(14.9184, "0.2", "sell") == 14.8 + assert round_option_px(14.81, "0.2", "buy") == 15.0 + assert format_option_px(14.8, "0.2") == "14.8" + # BTC 期权 tickSz=5: 整数末尾 0 必须保留 (1370 不能显成 137) + assert format_option_px(1370, "5") == "1370" + assert format_option_px(1160, 5) == "1160" + assert format_option_px(1000, "5") == "1000" + # 无 tick 时不得透出浮点毛刺 + assert format_option_px(482.4881990066513, None) == "482.4882" + + +def test_premium_per_sheet(): + assert abs(premium_per_sheet(15.6, 0.01) - 0.156) < 1e-9 + + +def test_total_premium_half_eth(): + assert abs(total_premium(15.6, 0.5) - 7.8) < 1e-9 + + +def test_sheets_from_eth(): + assert sheets_from_eth_amount(0.5, 0.01) == 50 + + +def test_calc_order_size_budget(): + r = calc_order_size( + quote_per_unit=15.6, + ct_mult=0.01, + min_sz=1, + budget_usdc=10, + budget_buffer=0.95, + budget_cap=10, + ) + assert r["ok"] is True + assert r["sheets"] >= 1 + assert r["total_premium"] <= 10 + + +def test_calc_order_size_sheets(): + r = calc_order_size( + quote_per_unit=15.6, + ct_mult=0.01, + min_sz=1, + sheets=3, + budget_cap=10, + ) + assert r["ok"] is True + assert r["sheets"] == 3 + assert abs(r["total_premium"] - 0.468) < 1e-9 + + +def test_option_moneyness(): + from lib.options.options_pricing_lib import option_moneyness, option_moneyness_label + + assert option_moneyness(opt_type="C", strike=1700, index_px=1800) == "itm" + assert option_moneyness(opt_type="C", strike=1900, index_px=1800) == "otm" + assert option_moneyness_label("itm") == "实值" + assert option_moneyness_label("otm") == "虚值" + + +def test_equivalent_contract_leverage(): + from lib.options.options_pricing_lib import equivalent_contract_leverage + + # index 1768, 0.2 ETH, premium 2.44 -> ~144.9x + lev = equivalent_contract_leverage(index_px=1768, eth_amount=0.2, total_premium=2.44) + assert lev == 144.9 + + +def test_straddle_pricing(): + from lib.options.options_pricing_lib import ( + format_straddle_band, + straddle_ask_per_unit, + straddle_breakeven_band, + straddle_premium_total, + ) + + assert straddle_ask_per_unit(0.148, 16.2) == 16.348 + assert straddle_premium_total(0.148, 16.2, 1.0) == 16.35 + lo, hi = straddle_breakeven_band(1800, 16.348) + assert lo == 1783.65 + assert hi == 1816.35 + assert format_straddle_band(1800, 16.348) == "1784 ~ 1816" + assert straddle_ask_per_unit(0.148, None) is None + + +def test_estimate_expiry_value_and_profit_at_index(): + from lib.options.options_pricing_lib import ( + estimate_expiry_profit_at_index, + estimate_expiry_value_at_index, + ) + + value = estimate_expiry_value_at_index( + opt_type="C", strike=1800, target_idx=2000, eth_amount=1.0 + ) + assert value == 200.0 + + profit = estimate_expiry_profit_at_index( + opt_type="C", + strike=1800, + target_idx=2000, + entry_px=0.148, + eth_amount=1.0, + total_premium=14.8, + ) + assert profit == 185.2 + + # Call 1780, ask 12.2, 0.01 ETH, target 1793 -> value 0.13, profit 0.01 + v = estimate_expiry_value_at_index( + opt_type="C", strike=1780, target_idx=1793, eth_amount=0.01 + ) + assert v == 0.13 + p = estimate_expiry_profit_at_index( + opt_type="C", + strike=1780, + target_idx=1793, + entry_px=12.2, + eth_amount=0.01, + total_premium=0.122, + ) + assert p == 0.01 + # OTM call loses premium + p2 = estimate_expiry_profit_at_index( + opt_type="C", + strike=1780, + target_idx=1770, + entry_px=12.2, + eth_amount=0.01, + total_premium=0.122, + ) + assert p2 == -0.12 + + +def test_resolve_chain_quote_otm_no_quote(): + from lib.exchange.okx_options_lib import _resolve_chain_quote + + q = _resolve_chain_quote( + ticker={}, + meta={"tickSz": "0.2"}, + opt_type="C", + strike=1800, + index_px=1776, + ) + assert q["ask"] is None + assert q["bid"] is None + assert q["ask_estimated"] is False + + +def test_resolve_chain_quote_estimated_ask(): + from lib.exchange.okx_options_lib import _resolve_chain_quote + + q = _resolve_chain_quote( + ticker={"bidPx": "0.2", "bidSz": "3500"}, + meta={"tickSz": "0.2"}, + opt_type="C", + strike=1650, + index_px=1776, + ) + assert q["ask_estimated"] is True + assert q["ask"] is not None + assert q["ask"] >= 120 + + +def test_format_quote_liquidity(): + from lib.options.options_pricing_lib import format_quote_liquidity + + assert format_quote_liquidity(17.2, 150) == "17.2/150" + assert format_quote_liquidity(817.6, 11) == "817.6/11" + assert format_quote_liquidity(15.6, None) == "15.6" + assert format_quote_liquidity(None, 10) is None + + +def test_estimate_close_by_bids_full_depth(): + from lib.options.options_pricing_lib import estimate_close_by_bids + + # 多档估算需显式 max_levels;默认只估买一 + out = estimate_close_by_bids( + [{"px": 12.3, "sz": 2}, {"px": 12.1, "sz": 3}], + 4, + ct_mult=0.01, + premium_paid=0.4, + max_levels=5, + ) + assert out["covered_sheets"] == 4 + assert out["uncovered_sheets"] == 0 + assert out["total_received"] == 0.488 + assert out["avg_px"] == 12.2 + assert out["estimated_pnl"] == 0.088 + assert out["estimated_pnl_ratio_pct"] == 22.0 + assert [x["sheets"] for x in out["levels"]] == [2, 2] + + bid1 = estimate_close_by_bids( + [{"px": 12.3, "sz": 2}, {"px": 12.1, "sz": 3}], + 4, + ct_mult=0.01, + premium_paid=0.4, + ) + assert bid1["covered_sheets"] == 2 + assert bid1["uncovered_sheets"] == 2 + assert [x["sheets"] for x in bid1["levels"]] == [2] + + +def test_estimate_close_by_bids_partial_depth(): + from lib.options.options_pricing_lib import estimate_close_by_bids + + out = estimate_close_by_bids([{"px": 10, "sz": 1}], 3, ct_mult=0.01, premium_paid=0.6) + assert out["covered_sheets"] == 1 + assert out["uncovered_sheets"] == 2 + assert out["total_received"] == 0.1 + # 净盈亏 = 回收 − 全部权利金(不按覆盖比例摊薄) + assert out["estimated_pnl"] == -0.5 + assert out["estimated_pnl_ratio_pct"] == round(-0.5 / 0.6 * 100, 2) + + +def test_estimate_close_by_bids_empty(): + from lib.options.options_pricing_lib import estimate_close_by_bids + + out = estimate_close_by_bids([], 2) + assert out["covered_sheets"] == 0 + assert out["uncovered_sheets"] == 2 + assert out["avg_px"] is None + + +def test_stub_bid_blocks_auto_close_estimate(): + from lib.options.options_pricing_lib import estimate_close_by_bids, is_stub_bid_px + + stub, reason = is_stub_bid_px(0.2, mark_px=42.0) + assert stub is True + assert "残档" in reason or "无效" in reason or "远低于" in reason + + out = estimate_close_by_bids( + [{"px": 0.2, "sz": 3500}], + 66, + ct_mult=0.01, + premium_paid=9.37, + mark_px=42.0, + ) + assert out["auto_close_blocked"] is True + assert out["bid_invalid"] is True + assert out["estimated_pnl"] is None + assert out["levels"] == [] + + ok, _ = is_stub_bid_px(30.0, mark_px=42.0) + assert ok is False + good = estimate_close_by_bids( + [{"px": 30.0, "sz": 100}], + 10, + ct_mult=0.01, + premium_paid=1.0, + mark_px=42.0, + ) + assert good["auto_close_blocked"] is False + assert good["covered_sheets"] == 10 + + +def test_expiry_breakeven_from_ask(): + from lib.options.options_pricing_lib import expiry_breakeven_from_ask + + assert expiry_breakeven_from_ask(opt_type="C", strike=1760, ask_px=15.6) == 1775.6 + assert expiry_breakeven_from_ask(opt_type="P", strike=1760, ask_px=15.6) == 1744.4 + assert expiry_breakeven_from_ask(opt_type="C", strike=1760, ask_px=None, mark_px=14.2) == 1774.2 + + +def test_calc_order_size_too_small(): + r = calc_order_size( + quote_per_unit=2000.0, + ct_mult=0.01, + min_sz=1, + budget_usdc=10, + budget_buffer=0.95, + budget_cap=10, + ) + assert r["ok"] is False + + +def test_expiry_breakeven_from_api(): + from lib.options.options_pricing_lib import expiry_breakeven_px + + assert expiry_breakeven_px( + opt_type="C", strike=3500, avg_px=15.6, be_px_api=3516.2 + ) == 3516.2 + + +def test_expiry_breakeven_call_put(): + from lib.options.options_pricing_lib import expiry_breakeven_px + + assert expiry_breakeven_px(opt_type="C", strike=3500, avg_px=15.6) == 3515.6 + assert expiry_breakeven_px(opt_type="P", strike=3500, avg_px=15.6) == 3484.4 + + +def test_close_breakeven_at_mark_equals_avg(): + from lib.options.options_pricing_lib import close_breakeven_idx + + assert close_breakeven_idx( + opt_type="C", idx_px=3480, mark_px=15.6, avg_px=15.6 + ) == 3480.0 + assert close_breakeven_idx( + opt_type="P", idx_px=3480, mark_px=15.6, avg_px=15.6 + ) == 3480.0 + + +def test_close_breakeven_with_delta(): + from lib.options.options_pricing_lib import close_breakeven_idx + + # mark below avg, delta 0.5 ETH on 0.5 ETH position -> slope 1 + be = close_breakeven_idx( + opt_type="C", + idx_px=3480, + mark_px=14.6, + avg_px=15.6, + delta_pa=0.5, + pos=50, + ct_mult=0.01, + ) + assert be == 3481.0 + + +def test_format_options_breakeven_line(): + from lib.options.options_pricing_lib import format_options_breakeven_line + + s = format_options_breakeven_line( + expiry_be_px=3515.6, close_be_px=3498.0, idx_px=3480.0 + ) + assert "到期平衡3516" in s + assert "平掉回本3498" in s + assert "指数3480" in s + + +def test_format_position_row_premium_and_inst_parse(): + from lib.exchange.okx_options_lib import format_position_row + + row = format_position_row( + { + "instId": "ETH-USD_UM-260709-1700-P", + "pos": "20", + "avgPx": "6.2", + "markPx": "6.3241", + "idxPx": "1746", + "upl": "0.0248", + "uplRatio": "0.02", + } + ) + assert row["opt_type"] == "P" + assert row["strike"] == 1700.0 + assert row["premium_paid"] == 1.24 + assert row["exp_time_ms"] is not None + assert row["exp_time_ms"] > 0 + + +def test_expiry_ms_from_inst_id(): + from lib.exchange.okx_options_lib import expiry_ms_from_inst_id, normalize_option_exp_ms + + ms = expiry_ms_from_inst_id("ETH-USD_UM-260709-1700-P") + assert ms is not None + from datetime import datetime, timezone + + dt = datetime.fromtimestamp(ms / 1000, tz=timezone.utc) + assert dt.year == 2026 and dt.month == 7 and dt.day == 9 and dt.hour == 8 + assert normalize_option_exp_ms(None, "ETH-USD_UM-260709-1700-P") == ms + + +def test_format_position_row_breakeven(): + from lib.exchange.okx_options_lib import format_position_row + + row = format_position_row( + { + "instId": "ETH-USD_UM-260703-1800-C", + "pos": "50", + "avgPx": "15.6", + "markPx": "16.2", + "idxPx": "3480", + "bePx": "3515.6", + "optType": "C", + "stk": "3500", + "deltaPA": "0.45", + "upl": "0.3", + "uplRatio": "0.02", + } + ) + assert row["expiry_be_px"] == 3515.6 + assert row["idx_px"] == 3480.0 + assert row["close_be_px"] is not None + assert row["dist_expiry_be"] == 35.6 diff --git a/tests/test_options_review_lib.py b/tests/test_options_review_lib.py new file mode 100644 index 0000000..268a64d --- /dev/null +++ b/tests/test_options_review_lib.py @@ -0,0 +1,342 @@ +"""期权复盘(含对冲)单元测试:导入去重、双计防护、复盘不被覆盖、统计.""" +from __future__ import annotations + +import sqlite3 +import tempfile +import unittest +from pathlib import Path + +from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables, insert_leg, insert_plan +from lib.options.options_review_db import SOURCE_OPTION, SOURCE_PERP_OPTIONS, init_options_review_tables +from lib.options.options_review_images_lib import ( + build_options_review_slot_filename, + is_valid_options_review_file, + options_review_upload_dir, + save_options_review_slot_file, +) +from lib.options.options_review_lib import ( + compute_review_stats, + list_review_trades, + save_review_entry, + sync_hedge_plans_closed, + sync_options_from_exchange, + upsert_option_history_row, +) + + +def _conn() -> sqlite3.Connection: + c = sqlite3.connect(":memory:") + c.row_factory = sqlite3.Row + init_options_review_tables(c) + init_hedge_plan_tables(c) + return c + + +class _FakeFile: + def __init__(self, name: str, data: bytes = b"img"): + self.filename = name + self._data = data + + def save(self, path: str) -> None: + Path(path).write_bytes(self._data) + + +class OptionsReviewTests(unittest.TestCase): + def test_option_upsert_idempotent(self): + conn = _conn() + row = { + "history_key": "ex:pos1", + "pos_id": "pos1", + "inst_id": "ETH-USD-260328-2000-C", + "underlying": "ETH", + "opt_type": "C", + "strike": 2000, + "sheets": 10, + "open_avg_px": 0.01, + "close_avg_px": 0.02, + "premium_paid": 1.0, + "realized_pnl": 5.5, + "created_at": "2026-03-01 10:00:00", + "closed_at": "2026-03-01 12:00:00", + "status_label": "已平", + } + self.assertEqual(upsert_option_history_row(conn, row), "inserted") + row["realized_pnl"] = 6.0 + self.assertEqual(upsert_option_history_row(conn, row), "updated") + n = conn.execute("SELECT COUNT(*) AS c FROM options_review_trades").fetchone()["c"] + self.assertEqual(n, 1) + pnl = conn.execute( + "SELECT realized_pnl_total FROM options_review_trades WHERE history_key='ex:pos1'" + ).fetchone()["realized_pnl_total"] + self.assertEqual(float(pnl), 6.0) + + def test_entry_not_overwritten_by_resync(self): + conn = _conn() + upsert_option_history_row( + conn, + { + "history_key": "ex:p2", + "inst_id": "ETH-USD-260328-1800-P", + "underlying": "ETH", + "opt_type": "P", + "realized_pnl": 1.0, + "created_at": "2026-03-02 10:00:00", + "closed_at": "2026-03-02 11:00:00", + }, + ) + tid = conn.execute("SELECT id FROM options_review_trades").fetchone()["id"] + save_review_entry( + conn, + tid, + {"strategy_tag": "突破追涨", "note": "keep-me", "images": []}, + ) + upsert_option_history_row( + conn, + { + "history_key": "ex:p2", + "inst_id": "ETH-USD-260328-1800-P", + "underlying": "ETH", + "opt_type": "P", + "realized_pnl": 2.0, + "created_at": "2026-03-02 10:00:00", + "closed_at": "2026-03-02 11:00:00", + }, + ) + note = conn.execute( + "SELECT note, strategy_tag FROM options_review_entries WHERE trade_id=?", + (tid,), + ).fetchone() + self.assertEqual(note["note"], "keep-me") + self.assertEqual(note["strategy_tag"], "突破追涨") + pnl = conn.execute( + "SELECT realized_pnl_total FROM options_review_trades WHERE id=?", (tid,) + ).fetchone()["realized_pnl_total"] + self.assertEqual(float(pnl), 2.0) + + def test_hedge_import_and_double_count_guard(self): + conn = _conn() + upsert_option_history_row( + conn, + { + "history_key": "ex:leg1", + "inst_id": "ETH-USD-260328-2000-C", + "underlying": "ETH", + "opt_type": "C", + "realized_pnl": -3.0, + "created_at": "2026-03-03 09:00:00", + "closed_at": "2026-03-03 18:00:00", + }, + ) + plan_id = insert_plan( + conn, + { + "plan_type": SOURCE_PERP_OPTIONS, + "status": "closed", + "underlying": "ETH", + "direction": "long", + "realized_pnl_perp": 20.0, + "realized_pnl_options": -3.0, + "realized_pnl_total": 17.0, + "close_reason": "tp", + "opened_at": "2026-03-03 09:00:00", + "closed_at": "2026-03-03 18:00:00", + "premium_total": 3.0, + }, + ) + insert_leg( + conn, + { + "plan_id": plan_id, + "leg_role": "perp", + "symbol": "ETH-USDT-SWAP", + "status": "closed", + "realized_pnl": 20.0, + }, + ) + insert_leg( + conn, + { + "plan_id": plan_id, + "leg_role": "option_hedge", + "inst_id": "ETH-USD-260328-2000-C", + "opt_type": "C", + "status": "closed", + "realized_pnl": -3.0, + }, + ) + out = sync_hedge_plans_closed(conn) + self.assertTrue(out["ok"]) + self.assertEqual(out["inserted"], 1) + + listed = list_review_trades(conn, include_hedge_legs=False) + types = {r["source_type"] for r in listed} + self.assertIn(SOURCE_PERP_OPTIONS, types) + self.assertNotIn(SOURCE_OPTION, types) + + listed_all = list_review_trades(conn, include_hedge_legs=True) + self.assertEqual(len(listed_all), 2) + + stats = compute_review_stats(conn, include_hedge_legs=False) + self.assertEqual(stats["kpi"]["total"], 1) + self.assertEqual(stats["kpi"]["pnl_sum"], 17.0) + + def test_sync_options_from_mock_exchange(self): + conn = _conn() + + def fetch(_ex, limit=500): + return [ + { + "instId": "ETH-USD-260328-2100-C", + "posId": "mock1", + "openAvgPx": "0.01", + "closeAvgPx": "0.02", + "closeTotalPos": "5", + "realizedPnl": "1.23", + "type": "2", + "cTime": "1700000000000", + "uTime": "1700003600000", + "uly": "ETH-USD", + } + ] + + def fmt(raw, tick_sz=None, ct_mult=0.01): + return { + "history_key": f"ex:{raw['posId']}", + "pos_id": raw["posId"], + "inst_id": raw["instId"], + "underlying": "ETH", + "opt_type": "C", + "sheets": 5, + "open_avg_px": 0.01, + "close_avg_px": 0.02, + "premium_paid": 0.5, + "realized_pnl": float(raw["realizedPnl"]), + "created_at": "2026-01-01 00:00:00", + "closed_at": "2026-01-01 01:00:00", + "status_label": "已平", + } + + result = sync_options_from_exchange( + conn, object(), limit=10, fetch_fn=fetch, format_fn=fmt + ) + self.assertTrue(result["ok"]) + self.assertEqual(result["inserted"], 1) + row = conn.execute( + "SELECT realized_pnl_total FROM options_review_trades WHERE history_key='ex:mock1'" + ).fetchone() + self.assertEqual(float(row["realized_pnl_total"]), 1.23) + + def test_hide_trade_persists_across_local_sync(self): + conn = _conn() + from lib.options.options_db import init_options_tables + from lib.options.options_review_lib import ( + hide_review_trade, + sync_options_from_local_trades, + ) + + init_options_tables(conn) + conn.execute( + """ + INSERT INTO options_trades + (inst_id, underlying, opt_type, strike, sheets, eth_amount, + open_quote, premium_paid, status, realized_pnl, created_at, closed_at) + VALUES ('ETH-USD-1-C','ETH','C',2000,1,0.01,0.01,0.2,'closed',1.0, + '2026-03-01 10:00:00','2026-03-01 11:00:00') + """ + ) + sync_options_from_local_trades(conn) + tid = conn.execute("SELECT id FROM options_review_trades").fetchone()["id"] + out = hide_review_trade(conn, tid) + self.assertTrue(out["ok"]) + self.assertEqual( + conn.execute("SELECT COUNT(*) AS c FROM options_review_trades").fetchone()["c"], + 0, + ) + sync_options_from_local_trades(conn) + self.assertEqual( + conn.execute("SELECT COUNT(*) AS c FROM options_review_trades").fetchone()["c"], + 0, + ) + + def test_local_options_trades_import(self): + conn = _conn() + from lib.options.options_db import init_options_tables + + init_options_tables(conn) + conn.execute( + """ + INSERT INTO options_trades + (inst_id, underlying, opt_type, strike, sheets, eth_amount, + open_quote, premium_paid, status, realized_pnl, created_at, closed_at) + VALUES ('ETH-USD-260328-2000-C','ETH','C',2000,2,0.02,0.01,0.5,'closed',3.2, + '2026-03-01 10:00:00','2026-03-01 12:00:00') + """ + ) + from lib.options.options_review_lib import sync_options_from_local_trades + + out = sync_options_from_local_trades(conn) + self.assertTrue(out["ok"]) + self.assertEqual(out["inserted"], 1) + row = conn.execute( + "SELECT history_key, realized_pnl_total, source_type FROM options_review_trades" + ).fetchone() + self.assertTrue(str(row["history_key"]).startswith("local_opt:")) + self.assertEqual(float(row["realized_pnl_total"]), 3.2) + self.assertEqual(row["source_type"], SOURCE_OPTION) + + def test_image_namespace(self): + with tempfile.TemporaryDirectory() as tmp: + folder = options_review_upload_dir(tmp) + fname = build_options_review_slot_filename( + "a" * 32, "5m", ".png", secure_filename_fn=lambda x: x + ) + self.assertTrue(fname.startswith("options_journal_")) + self.assertTrue(is_valid_options_review_file(fname, "a" * 32, "5m")) + item = save_options_review_slot_file( + _FakeFile("x.png"), + "a" * 32, + "5m", + folder, + secure_filename_fn=lambda x: x, + ) + self.assertIsNotNone(item) + self.assertTrue((Path(folder) / item["file"]).is_file()) + + def test_strategy_stats_only_tagged(self): + conn = _conn() + upsert_option_history_row( + conn, + { + "history_key": "ex:a", + "inst_id": "ETH-USD-1-C", + "underlying": "ETH", + "opt_type": "C", + "realized_pnl": 10, + "created_at": "2026-01-01 00:00:00", + "closed_at": "2026-01-01 02:00:00", + }, + ) + upsert_option_history_row( + conn, + { + "history_key": "ex:b", + "inst_id": "ETH-USD-2-P", + "underlying": "ETH", + "opt_type": "P", + "realized_pnl": -4, + "created_at": "2026-01-01 00:00:00", + "closed_at": "2026-01-01 05:00:00", + }, + ) + tid = conn.execute( + "SELECT id FROM options_review_trades WHERE history_key='ex:a'" + ).fetchone()["id"] + save_review_entry(conn, tid, {"strategy_tag": "假破", "images": []}) + stats = compute_review_stats(conn) + self.assertEqual(len(stats["by_strategy"]), 1) + self.assertEqual(stats["by_strategy"][0]["key"], "假破") + self.assertEqual(stats["kpi"]["total"], 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_options_stats_lib.py b/tests/test_options_stats_lib.py new file mode 100644 index 0000000..62ab1cf --- /dev/null +++ b/tests/test_options_stats_lib.py @@ -0,0 +1,86 @@ +"""期权统计单测.""" +import sqlite3 +from datetime import datetime, timedelta +from unittest import TestCase + +from lib.options.options_db import init_options_tables +from lib.options.options_stats_lib import compute_options_stats, compute_options_stats_from_history + + +class OptionsStatsLibTests(TestCase): + def _conn(self): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + init_options_tables(conn) + return conn + + def test_compute_options_stats_empty(self): + conn = self._conn() + out = compute_options_stats(lambda: conn) + self.assertEqual(out["total_closed"], 0) + self.assertEqual(out["open_count"], 0) + self.assertIsNone(out["avg_hold_sec"]) + + def test_compute_options_stats_hold_times(self): + conn = self._conn() + now = datetime.now() + win_open = (now - timedelta(hours=2)).strftime("%Y-%m-%d %H:%M:%S") + win_close = (now - timedelta(hours=1)).strftime("%Y-%m-%d %H:%M:%S") + loss_open = (now - timedelta(hours=4)).strftime("%Y-%m-%d %H:%M:%S") + loss_close = (now - timedelta(hours=1)).strftime("%Y-%m-%d %H:%M:%S") + open_at = (now - timedelta(minutes=30)).strftime("%Y-%m-%d %H:%M:%S") + conn.execute( + """ + INSERT INTO options_trades + (inst_id, underlying, opt_type, strike, sheets, eth_amount, status, + realized_pnl, created_at, closed_at) + VALUES ('A', 'ETH', 'C', 1800, 1, 0.01, 'closed', 1.2, ?, ?) + """, + (win_open, win_close), + ) + conn.execute( + """ + INSERT INTO options_trades + (inst_id, underlying, opt_type, strike, sheets, eth_amount, status, + realized_pnl, created_at, closed_at) + VALUES ('B', 'ETH', 'P', 1700, 1, 0.01, 'closed', -1.0, ?, ?) + """, + (loss_open, loss_close), + ) + conn.execute( + """ + INSERT INTO options_trades + (inst_id, underlying, opt_type, strike, sheets, eth_amount, status, created_at) + VALUES ('C', 'BTC', 'C', 62000, 1, 0.01, 'open', ?) + """, + (open_at,), + ) + conn.commit() + out = compute_options_stats(lambda: conn) + self.assertEqual(out["total_closed"], 2) + self.assertEqual(out["win_count"], 1) + self.assertEqual(out["loss_count"], 1) + self.assertEqual(out["win_rate"], 50.0) + self.assertAlmostEqual(out["avg_win"], 1.2, places=4) + self.assertAlmostEqual(out["avg_loss"], 1.0, places=4) + self.assertAlmostEqual(out["avg_win_hold_sec"], 3600.0, delta=5.0) + self.assertAlmostEqual(out["avg_loss_hold_sec"], 3 * 3600.0, delta=5.0) + self.assertEqual(out["open_count"], 1) + self.assertGreater(out["avg_open_hold_sec"], 1700.0) + + def test_compute_options_stats_from_history_exchange_rows(self): + history = [ + {"status": "open", "created_at": "2026-07-11 08:08:38"}, + {"status": "closed", "realized_pnl": -3.99, "created_at": "2026-07-09 14:11:46", "closed_at": "2026-07-10 16:00:35"}, + {"status": "closed", "realized_pnl": 0.87, "created_at": "2026-07-09 14:11:46", "closed_at": "2026-07-10 09:55:34"}, + {"status": "closed", "realized_pnl": -1.33, "created_at": "2026-07-08 02:32:44", "closed_at": "2026-07-09 16:00:26"}, + ] + out = compute_options_stats_from_history(history) + self.assertEqual(out["total_closed"], 3) + self.assertEqual(out["win_count"], 1) + self.assertEqual(out["loss_count"], 2) + self.assertAlmostEqual(out["avg_win"], 0.87, places=4) + self.assertAlmostEqual(out["avg_loss"], 2.66, places=2) + self.assertAlmostEqual(out["profit_loss_ratio"], 0.33, places=2) + self.assertEqual(out["open_count"], 1) + self.assertAlmostEqual(out["net_realized_pnl"], round(0.87 - 3.99 - 1.33, 4), places=4) diff --git a/tests/test_options_sync.py b/tests/test_options_sync.py new file mode 100644 index 0000000..358ebe5 --- /dev/null +++ b/tests/test_options_sync.py @@ -0,0 +1,175 @@ +"""期权平仓/到期状态同步单测.""" +import sqlite3 + +from lib.exchange.okx_options_lib import ( + format_option_history_row, + format_usdc_amount, + is_option_full_close_history, + resolve_option_close_from_history, +) +from lib.options.options_db import init_options_tables +from lib.options.options_monitor_lib import sync_open_options_trades + + +def test_format_usdc_amount(): + assert format_usdc_amount(4.896) == "4.90" + assert format_usdc_amount(4.9) == "4.90" + assert format_usdc_amount(4.0) == "4.00" + + +def test_is_option_full_close_history(): + assert is_option_full_close_history({"type": "2"}) + assert is_option_full_close_history({"type": "3"}) + assert not is_option_full_close_history({"type": "1"}) + assert not is_option_full_close_history({"type": "5"}) + + +def test_format_option_history_row(): + raw = { + "instId": "BTC-USD_UM-260710-62000-P", + "openAvgPx": "380", + "closeAvgPx": "0", + "closeTotalPos": "1", + "openMaxPos": "1", + "realizedPnl": "-3.99", + "pnlRatio": "-1.049", + "type": "2", + "cTime": "1784000000000", + "uTime": "1784088035000", + "posId": "pos-btc", + } + row = format_option_history_row(raw, tick_sz="0.1", ct_mult=0.01) + assert row["inst_id"] == "BTC-USD_UM-260710-62000-P" + assert row["sheets"] == 1 + assert row["realized_pnl"] == -3.99 + assert row["status_label"] == "已平" + assert row["open_avg_px_fmt"] == "380" + assert row["premium_paid_fmt"] == "3.80" + assert row["history_key"] == "ex:pos-btc" + + +def test_resolve_option_close_from_history_picks_latest(): + rows = [ + {"instId": "ETH-USD_UM-260709-1700-P", "uTime": "1000", "realizedPnl": "-1.0", "closeAvgPx": "0"}, + {"instId": "ETH-USD_UM-260709-1700-P", "uTime": "2000", "realizedPnl": "-1.24", "closeAvgPx": "0", "posId": "9"}, + ] + got = resolve_option_close_from_history(rows, open_ms=500) + assert got is not None + assert got["realized_pnl"] == -1.24 + assert got["pos_id"] == "9" + + +def test_sync_open_options_trades_marks_expired_closed(): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + init_options_tables(conn) + conn.execute( + """ + INSERT INTO options_trades + (inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount, + open_quote, premium_paid, status) + VALUES (?, 'ETH', 'P', 1700, '', 20, 0.2, 6.2, 1.24, 'open') + """, + ("ETH-USD_UM-260709-1700-P",), + ) + conn.commit() + + n = sync_open_options_trades( + conn, + live_inst_ids=set(), + fetch_history_fn=lambda _inst: [], + ) + assert n == 1 + row = conn.execute("SELECT status, premium_received, realized_pnl, signal_note FROM options_trades").fetchone() + assert row["status"] == "closed" + assert row["premium_received"] == 0.0 + assert row["realized_pnl"] == -1.24 + assert "到期结算" in (row["signal_note"] or "") + + +def test_sync_open_options_trades_skips_without_close_evidence(): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + init_options_tables(conn) + conn.execute( + """ + INSERT INTO options_trades + (inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount, + open_quote, premium_paid, status, created_at) + VALUES (?, 'BTC', 'P', 62000, '', 1, 0.01, 380.0, 3.8, 'open', '2026-07-09 08:00:00') + """, + ("BTC-USD_UM-260710-62000-P",), + ) + conn.commit() + + n = sync_open_options_trades( + conn, + live_inst_ids=set(), + fetch_history_fn=lambda _inst: [], + ) + assert n == 0 + row = conn.execute("SELECT status FROM options_trades").fetchone() + assert row["status"] == "open" + + +def test_reconcile_live_open_trades_reopens_sync_artifact(): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + init_options_tables(conn) + conn.execute( + """ + INSERT INTO options_trades + (inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount, + open_quote, premium_paid, status, closed_at) + VALUES (?, 'BTC', 'P', 62000, '', 1, 0.01, 380.0, 3.8, 'closed', '2026-07-09 09:10:34') + """, + ("BTC-USD_UM-260710-62000-P",), + ) + conn.commit() + + from lib.options.options_monitor_lib import reconcile_live_open_trades + + n = reconcile_live_open_trades(conn, live_inst_ids={"BTC-USD_UM-260710-62000-P"}) + assert n == 1 + row = conn.execute("SELECT status, closed_at FROM options_trades").fetchone() + assert row["status"] == "open" + assert row["closed_at"] is None + + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + init_options_tables(conn) + conn.execute( + """ + INSERT INTO options_trades + (inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount, + open_quote, premium_paid, status, created_at) + VALUES (?, 'ETH', 'P', 1700, '', 20, 0.2, 6.2, 1.24, 'open', '2026-07-08 02:32:44') + """, + ("ETH-USD_UM-260709-1700-P",), + ) + conn.commit() + + def _hist(_inst): + return [ + { + "instId": "ETH-USD_UM-260709-1700-P", + "uTime": "1784000000000", + "realizedPnl": "-0.5", + "closeAvgPx": "0.1", + "posId": "pos-1", + } + ] + + n = sync_open_options_trades( + conn, + live_inst_ids=set(), + fetch_history_fn=_hist, + ) + assert n == 1 + row = conn.execute( + "SELECT status, premium_received, realized_pnl, close_ord_id FROM options_trades" + ).fetchone() + assert row["status"] == "closed" + assert row["realized_pnl"] == -0.5 + assert row["premium_received"] == 0.74 + assert row["close_ord_id"] == "pos-1" diff --git a/tests/test_options_target_lib.py b/tests/test_options_target_lib.py new file mode 100644 index 0000000..2962f1d --- /dev/null +++ b/tests/test_options_target_lib.py @@ -0,0 +1,169 @@ +"""期权目标位委托单元测试.""" +from __future__ import annotations + +import sqlite3 +import unittest + +from lib.options.options_target_lib import ( + ensure_target_tables, + list_active_targets, + list_closing_targets, + run_options_target_closes, + target_hit, + upsert_target_monitor, +) + + +class OptionsTargetLibTests(unittest.TestCase): + def test_target_hit_call_put(self): + self.assertTrue(target_hit(opt_type="C", index_px=2000, target_index=1950)) + self.assertFalse(target_hit(opt_type="C", index_px=1900, target_index=1950)) + self.assertTrue(target_hit(opt_type="P", index_px=1800, target_index=1850)) + self.assertFalse(target_hit(opt_type="P", index_px=1900, target_index=1850)) + + def test_upsert_and_trigger_close(self): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + ensure_target_tables(conn) + out = upsert_target_monitor( + conn, + inst_id="ETH-USD_UM-260717-1900-C", + target_index=1880, + opt_type="C", + sheets=1, + ) + self.assertTrue(out["ok"]) + self.assertEqual(len(list_active_targets(conn)), 1) + + closed = [] + + def close_fn(inst_id: str): + closed.append(inst_id) + return { + "ok": True, + "submitted_sheets": 1, + "premium_received": 1.2, + "close_ord_id": "oid1", + "fully_closed": True, + "remaining_sheets": 0, + } + + n = run_options_target_closes( + conn, + [{"inst_id": "ETH-USD_UM-260717-1900-C", "idx_px": 1885, "opt_type": "C"}], + close_fn=close_fn, + ) + self.assertEqual(n, 1) + self.assertEqual(closed, ["ETH-USD_UM-260717-1900-C"]) + self.assertEqual(len(list_active_targets(conn)), 0) + + def test_partial_fill_notifies_once_then_closing_retry_silent(self): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + ensure_target_tables(conn) + upsert_target_monitor( + conn, + inst_id="ETH-USD_UM-260715-1870-P", + target_index=1872, + opt_type="P", + sheets=1, + ) + conn.commit() + notices: list[str] = [] + calls = {"n": 0} + + def close_fn(inst_id: str): + calls["n"] += 1 + if calls["n"] == 1: + return { + "ok": True, + "submitted_sheets": 1, + "premium_received": 0.032, + "close_ord_id": "oid-a", + "fully_closed": False, + "remaining_sheets": 1, + "stopped_reason": "order_not_filled", + } + return { + "ok": True, + "submitted_sheets": 1, + "premium_received": 0.032, + "close_ord_id": "oid-b", + "fully_closed": True, + "remaining_sheets": 0, + "already_flat": True, + } + + pos = [{"inst_id": "ETH-USD_UM-260715-1870-P", "idx_px": 1867.5, "opt_type": "P"}] + n1 = run_options_target_closes( + conn, + pos, + close_fn=close_fn, + send_wechat=notices.append, + account_label="主账户·期权", + ) + self.assertEqual(n1, 1) + self.assertEqual(len(notices), 1) + self.assertEqual(len(list_active_targets(conn)), 0) + self.assertEqual(len(list_closing_targets(conn)), 1) + + # 模拟后续 sync 异常也不会再推:closing 重试静默 + n2 = run_options_target_closes( + conn, + pos, + close_fn=close_fn, + send_wechat=notices.append, + account_label="主账户·期权", + ) + self.assertEqual(n2, 0) + self.assertEqual(len(notices), 1) + self.assertEqual(len(list_closing_targets(conn)), 0) + + def test_commit_before_wechat_survives_later_rollback(self): + """状态在推送前已 commit,外层异常回滚不应让委托回到 active.""" + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + ensure_target_tables(conn) + upsert_target_monitor( + conn, + inst_id="ETH-USD_UM-260715-1870-P", + target_index=1872, + opt_type="P", + ) + conn.commit() + notices: list[str] = [] + + def close_fn(inst_id: str): + return { + "ok": True, + "submitted_sheets": 1, + "premium_received": 0.03, + "close_ord_id": "oid1", + "fully_closed": True, + "remaining_sheets": 0, + } + + run_options_target_closes( + conn, + [{"inst_id": "ETH-USD_UM-260715-1870-P", "idx_px": 1860, "opt_type": "P"}], + close_fn=close_fn, + send_wechat=notices.append, + ) + # 模拟 loop 后续 sync 抛错后 close 未再 commit —— 但 status 已提前 commit + conn.rollback() + self.assertEqual(len(notices), 1) + self.assertEqual(len(list_active_targets(conn)), 0) + + # 下一轮不应再次触发推送 + n2 = run_options_target_closes( + conn, + [{"inst_id": "ETH-USD_UM-260715-1870-P", "idx_px": 1860, "opt_type": "P"}], + close_fn=close_fn, + send_wechat=notices.append, + ) + self.assertEqual(n2, 0) + self.assertEqual(len(notices), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_order_monitor_display_lib.py b/tests/test_order_monitor_display_lib.py new file mode 100644 index 0000000..8700b23 --- /dev/null +++ b/tests/test_order_monitor_display_lib.py @@ -0,0 +1,181 @@ +from lib.trade.order_monitor_display_lib import ( + apply_order_price_display_fields, + calc_latest_risk_amount, + calc_risk_fraction, + is_sl_breakeven_secured, + monitor_open_stop_loss, + order_monitor_tpsl_needs_sync, + resolve_breakeven_entry_price, + resolve_live_tpsl_prices, + sl_breakeven_from_exchange_tpsl, + snapshot_rr, + snapshot_stop_loss, + stale_breakeven_armed, +) + + +def _calc_rr(direction, entry, sl, tp): + if direction == "long": + risk = entry - sl + reward = tp - entry + else: + risk = sl - entry + reward = entry - tp + if risk <= 0 or reward <= 0: + return None + return round(reward / risk, 4) + + +def test_snapshot_stop_loss_prefers_initial(): + assert snapshot_stop_loss(2.45, 2.6) == 2.45 + assert snapshot_stop_loss(None, 2.6) == 2.6 + + +def test_monitor_open_stop_loss_prefers_initial_snapshot(): + row = {"initial_stop_loss": 64000, "stop_loss": 63200} + assert monitor_open_stop_loss(row) == 64000 + + +def test_snapshot_rr_ignores_current_stop_after_manual_move(): + rr = snapshot_rr(_calc_rr, "long", 2.726, 2.45, 2.65, 3.3) + assert rr is not None + assert rr > 2.0 + + +def test_breakeven_long(): + assert is_sl_breakeven_secured("long", 2.726, 2.726) is True + assert is_sl_breakeven_secured("long", 2.726, 2.75) is True + assert is_sl_breakeven_secured("long", 2.726, 2.45) is False + + +def test_breakeven_short(): + assert is_sl_breakeven_secured("short", 72.73, 72.73) is True + assert is_sl_breakeven_secured("short", 72.73, 72.0) is True + assert is_sl_breakeven_secured("short", 72.73, 74.0) is False + + +def test_sl_breakeven_from_exchange_tpsl(): + ok = sl_breakeven_from_exchange_tpsl( + "long", + 2.726, + {"sl": {"trigger_price": 2.735}, "tp": {"trigger_price": 3.3}}, + ) + assert ok is True + + +def test_resolve_live_tpsl_prefers_exchange(): + disp_sl, disp_tp, ex_sl, ex_tp = resolve_live_tpsl_prices( + 1674, + 1647.65, + {"sl": {"trigger_price": 1661}, "tp": {"trigger_price": 1647.65}}, + ) + assert disp_sl == 1661 + assert disp_tp == 1647.65 + assert ex_sl == 1661 + assert ex_tp == 1647.65 + + +def test_order_monitor_tpsl_needs_sync_detects_sl_change(): + new_sl, new_tp, changed = order_monitor_tpsl_needs_sync( + 1674, + 1647.65, + {"sl": {"trigger_price": 1661}, "tp": {"trigger_price": 1647.65}}, + ) + assert changed is True + assert new_sl == 1661 + assert new_tp == 1647.65 + + +def test_apply_order_price_display_fields_live_sl(): + payload = {} + apply_order_price_display_fields( + payload, + direction="short", + entry_price=1663.45, + initial_stop_loss=1674, + stop_loss=1674, + take_profit=1647.65, + calc_rr_ratio_fn=_calc_rr, + exchange_tpsl={"sl": {"trigger_price": 1661}, "tp": {"trigger_price": 1647.65}}, + format_price_fn=lambda _s, v: f"{v:.2f}", + symbol="ETH/USDT:USDT", + margin_capital=100, + leverage=10, + exchange_notional=1000, + contracts=2.0, + contract_size=1.0, + avg_entry_price=1660.0, + ) + assert payload["stop_loss"] == 1661 + assert payload["stop_loss_display"] == "1661.00" + assert payload["sl_breakeven_secured"] is False + assert payload["rr_ratio"] is not None + assert payload["latest_risk_amount"] is not None + assert payload["latest_risk_amount"] >= 0 + assert payload["contracts"] == 2.0 + assert payload["reward_at_tp_usdt"] is not None + assert payload["reward_at_tp_usdt"] > 0 + + +def test_apply_order_price_display_fields_gate_contract_size(): + payload = {} + apply_order_price_display_fields( + payload, + direction="short", + entry_price=62063.4, + initial_stop_loss=62650, + stop_loss=62650, + take_profit=61200, + calc_rr_ratio_fn=_calc_rr, + exchange_tpsl={}, + symbol="BTC/USDT:USDT", + margin_capital=48, + leverage=10, + contracts=78.0, + contract_size=0.0001, + avg_entry_price=62063.4, + ) + assert payload["reward_at_tp_usdt"] is not None + # 毛利约 6.73, 扣双边 0.05% 后约 6.25 + assert abs(payload["reward_at_tp_usdt"] - 6.25) < 0.1 + + +def test_calc_latest_risk_amount_long(): + rf = calc_risk_fraction("long", 100, 95) + assert rf is not None and abs(rf - 0.05) < 1e-9 + risk = calc_latest_risk_amount( + "long", 100, 95, exchange_notional=1000, funds_decimals=2 + ) + assert risk == 50.0 + + +def test_calc_latest_risk_amount_profit_side_stop(): + risk = calc_latest_risk_amount("long", 100, 101, exchange_notional=1000) + assert risk == 0.0 + + +def test_resolve_breakeven_entry_price_prefers_avg(): + assert resolve_breakeven_entry_price(1777.39, 1777.2) == 1777.2 + assert resolve_breakeven_entry_price(1777.39, None) == 1777.39 + + +def test_roll_long_not_breakeven_with_avg_entry(): + payload = {} + apply_order_price_display_fields( + payload, + direction="long", + entry_price=1777.39, + initial_stop_loss=1750, + stop_loss=1767, + take_profit=1833, + calc_rr_ratio_fn=_calc_rr, + exchange_tpsl={"sl": {"trigger_price": 1767}, "tp": {"trigger_price": 1833}}, + avg_entry_price=1777.2, + ) + assert payload["sl_breakeven_secured"] is False + + +def test_stale_breakeven_armed_after_roll_down(): + assert stale_breakeven_armed("long", 1777.39, 1767, 1) is True + assert stale_breakeven_armed("long", 1777.39, 1778, 1) is False + assert stale_breakeven_armed("long", 1777.39, 1767, 0) is False diff --git a/tests/test_position_limit_count.py b/tests/test_position_limit_count.py new file mode 100644 index 0000000..f3afd43 --- /dev/null +++ b/tests/test_position_limit_count.py @@ -0,0 +1,78 @@ +import sqlite3 +import unittest + +from lib.strategy.strategy_db import init_strategy_tables +from lib.strategy.strategy_trade_labels import ( + MONITOR_TYPE_TREND_PULLBACK, + count_position_limit_active_monitors, +) + + +def _mem_conn(): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + conn.execute( + """CREATE TABLE order_monitors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + symbol TEXT, + direction TEXT, + status TEXT, + monitor_type TEXT, + key_signal_type TEXT, + trend_plan_id INTEGER + )""" + ) + init_strategy_tables(conn) + return conn + + +class PositionLimitCountTests(unittest.TestCase): + def test_regular_monitor_counts(self): + conn = _mem_conn() + conn.execute( + "INSERT INTO order_monitors (symbol, status, monitor_type) VALUES ('ETH/USDT', 'active', '下单监控')" + ) + conn.commit() + self.assertEqual(count_position_limit_active_monitors(conn), 1) + + def test_trend_pullback_excluded(self): + conn = _mem_conn() + conn.execute( + """INSERT INTO order_monitors + (symbol, status, monitor_type, trend_plan_id) + VALUES ('ETH/USDT', 'active', ?, 12)""", + (MONITOR_TYPE_TREND_PULLBACK,), + ) + conn.commit() + self.assertEqual(count_position_limit_active_monitors(conn), 0) + + def test_active_roll_group_still_counts_regular_monitor(self): + conn = _mem_conn() + conn.execute( + "INSERT INTO order_monitors (id, symbol, status, monitor_type) VALUES (1, 'ETH/USDT', 'active', '下单监控')" + ) + conn.execute( + """INSERT INTO roll_groups + (order_monitor_id, symbol, direction, status) + VALUES (1, 'ETH/USDT', 'long', 'active')""" + ) + conn.commit() + self.assertEqual(count_position_limit_active_monitors(conn), 1) + + def test_mixed_monitors(self): + conn = _mem_conn() + conn.execute( + "INSERT INTO order_monitors (symbol, status, monitor_type) VALUES ('BTC/USDT', 'active', '下单监控')" + ) + conn.execute( + """INSERT INTO order_monitors + (symbol, status, monitor_type, trend_plan_id) + VALUES ('ETH/USDT', 'active', ?, 3)""", + (MONITOR_TYPE_TREND_PULLBACK,), + ) + conn.commit() + self.assertEqual(count_position_limit_active_monitors(conn), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_position_sizing_risk_display.py b/tests/test_position_sizing_risk_display.py new file mode 100644 index 0000000..d4473c0 --- /dev/null +++ b/tests/test_position_sizing_risk_display.py @@ -0,0 +1,34 @@ +"""全仓 / 以损定仓 风险展示文案.""" +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from lib.trade.position_sizing_lib import ( # noqa: E402 + format_risk_display_text, + risk_percent_for_storage, +) + + +class TestPositionSizingRiskDisplay(unittest.TestCase): + def test_full_margin_shows_amount_only(self): + self.assertEqual( + format_risk_display_text("full_margin", 1.0, 2.58, decimals=2), + "2.58U", + ) + self.assertIsNone(risk_percent_for_storage("full_margin", 1.0)) + + def test_risk_mode_shows_percent_and_amount(self): + self.assertEqual( + format_risk_display_text("risk", 2.0, 10.5, decimals=2), + "2%≈10.5U", + ) + self.assertEqual(risk_percent_for_storage("risk", 2.0), 2.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_price_snapshot_lib.py b/tests/test_price_snapshot_lib.py new file mode 100644 index 0000000..5d022a2 --- /dev/null +++ b/tests/test_price_snapshot_lib.py @@ -0,0 +1,35 @@ +import unittest + +from lib.hub.price_snapshot_lib import resolve_order_snapshot_price + + +class TestPriceSnapshotLib(unittest.TestCase): + def test_resolve_from_cached_prices(self): + px = resolve_order_snapshot_price("ETH/USDT", {"ETH/USDT": 1750.5}) + self.assertEqual(px, 1750.5) + + def test_resolve_from_position_mark(self): + prow = {"info": {"mark_price": 1760.0}, "contracts": 1} + px = resolve_order_snapshot_price("ETH/USDT", {}, position_row=prow) + self.assertEqual(px, 1760.0) + + def test_resolve_mark_fn_before_entry(self): + px = resolve_order_snapshot_price( + "ETH/USDT", + {}, + get_mark_price_fn=lambda s: 1755.0, + fallback_entry=1700.0, + ) + self.assertEqual(px, 1755.0) + + def test_resolve_fallback_entry(self): + px = resolve_order_snapshot_price( + "ETH/USDT", + {}, + fallback_entry=1700.0, + ) + self.assertEqual(px, 1700.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_records_list_lib.py b/tests/test_records_list_lib.py new file mode 100644 index 0000000..bde81b0 --- /dev/null +++ b/tests/test_records_list_lib.py @@ -0,0 +1,83 @@ +"""records_list_lib pagination.""" + +from __future__ import annotations + +import sqlite3 +import unittest + +from lib.instance.records_list_lib import list_trade_records_page +from lib.trade.trade_result_lib import filter_trade_records_excluding_miss + + +def _to_effective(row): + d = dict(row) + d["effective_result"] = d.get("result") + d["effective_pnl_amount"] = d.get("pnl_amount") + return d + + +class RecordsListLibTest(unittest.TestCase): + def setUp(self): + self.conn = sqlite3.connect(":memory:") + self.conn.row_factory = sqlite3.Row + self.conn.execute( + """ + CREATE TABLE trade_records ( + id INTEGER PRIMARY KEY, + closed_at TEXT, + created_at TEXT, + opened_at TEXT, + result TEXT, + pnl_amount REAL + ) + """ + ) + for i in range(12): + self.conn.execute( + "INSERT INTO trade_records(id, closed_at, created_at, opened_at, result, pnl_amount) " + "VALUES (?,?,?,?,?,?)", + (i + 1, f"2026-07-1{i % 9}-10:00:00", None, None, "止盈", 1.0), + ) + self.conn.execute( + "INSERT INTO trade_records(id, closed_at, created_at, opened_at, result, pnl_amount) " + "VALUES (?,?,?,?,?,?)", + (99, "2026-07-15-10:00:00", None, None, "错过", 0), + ) + self.conn.commit() + + def tearDown(self): + self.conn.close() + + def test_pages_exclude_miss(self): + out = list_trade_records_page( + self.conn, + "2026-07-01", + "2026-07-31", + tr_ts="COALESCE(closed_at, created_at, opened_at)", + to_effective_fn=_to_effective, + filter_fn=filter_trade_records_excluding_miss, + limit=5, + offset=0, + ) + self.assertTrue(out["ok"]) + self.assertEqual(out["total"], 12) + self.assertEqual(out["pages"], 3) + self.assertEqual(len(out["items"]), 5) + + def test_second_page(self): + out = list_trade_records_page( + self.conn, + "2026-07-01", + "2026-07-31", + tr_ts="COALESCE(closed_at, created_at, opened_at)", + to_effective_fn=_to_effective, + filter_fn=filter_trade_records_excluding_miss, + limit=5, + offset=5, + ) + self.assertEqual(out["page"], 2) + self.assertEqual(len(out["items"]), 5) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sanitize_hub_settings.py b/tests/test_sanitize_hub_settings.py new file mode 100644 index 0000000..0a5d517 --- /dev/null +++ b/tests/test_sanitize_hub_settings.py @@ -0,0 +1,41 @@ +"""deploy/sanitize_hub_settings.py 单元测试.""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO / "deploy")) + +from sanitize_hub_settings import sanitize_settings # noqa: E402 + + +def test_drops_gate_bot_and_keeps_gate(): + raw = { + "exchanges": [ + {"id": "0", "key": "binance", "name": "币安", "agent_url": "http://127.0.0.1:15200"}, + {"id": "3", "key": "gate_bot", "name": "Gate bot", "agent_url": "http://127.0.0.1:15203"}, + {"id": "2", "key": "gate", "name": "Gate", "flask_url": "http://127.0.0.1:5000"}, + ] + } + cleaned, removed = sanitize_settings(raw) + keys = [x["key"] for x in cleaned["exchanges"]] + assert keys == ["binance", "gate"] + assert len(removed) == 1 + + +def test_drops_port_5002_legacy(): + raw = { + "exchanges": [ + { + "id": "3", + "key": "legacy", + "name": "crypto_monitor_gate_bot", + "flask_url": "http://127.0.0.1:5002", + }, + ] + } + cleaned, removed = sanitize_settings(raw) + assert cleaned["exchanges"] == [] + assert removed diff --git a/tests/test_shared_env_lib.py b/tests/test_shared_env_lib.py new file mode 100644 index 0000000..35eaefa --- /dev/null +++ b/tests/test_shared_env_lib.py @@ -0,0 +1,69 @@ +"""shared_env_lib:AI 字段与四文件同步.""" +from __future__ import annotations + +import os +import tempfile +import unittest + +from lib.env.env_file_lib import apply_env_updates, read_env_lines +from lib.env.shared_env_lib import ( + AI_ENV_KEYS, + apply_ai_env_to_all, + build_ai_env_payload, + validate_ai_env_updates, +) + + +class TestSharedEnvLib(unittest.TestCase): + def test_ai_keys_frozen(self) -> None: + self.assertIn("OPENAI_API_KEY", AI_ENV_KEYS) + self.assertIn("AI_PROVIDER", AI_ENV_KEYS) + + def test_validate_rejects_unknown(self) -> None: + clean, errors = validate_ai_env_updates({"NOT_A_KEY": "x"}) + self.assertEqual(clean, {}) + self.assertTrue(any("未知" in e for e in errors)) + + def test_validate_skips_masked_secret(self) -> None: + clean, errors = validate_ai_env_updates({"OPENAI_API_KEY": "****abcd"}) + self.assertEqual(errors, []) + self.assertNotIn("OPENAI_API_KEY", clean) + + def test_apply_syncs_hub_and_instances(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + hub = os.path.join(tmp, "manual_trading_hub") + okx = os.path.join(tmp, "crypto_monitor_okx") + os.makedirs(hub) + os.makedirs(okx) + hub_env = os.path.join(hub, ".env") + okx_env = os.path.join(okx, ".env") + example = os.path.join(hub, ".env.example") + with open(example, "w", encoding="utf-8") as f: + f.write("AI_PROVIDER=openai\nOPENAI_API_KEY=\n") + with open(hub_env, "w", encoding="utf-8") as f: + f.write("AI_PROVIDER=openai\n") + with open(okx_env, "w", encoding="utf-8") as f: + f.write("AI_PROVIDER=ollama\n") + + import lib.env.shared_env_lib as mod + + orig_hub = mod.hub_env_path + orig_dirs = dict(mod.INSTANCE_ENV_DIRS) + try: + mod.hub_env_path = lambda: hub_env # type: ignore[method-assign] + mod.hub_example_path = lambda: example # type: ignore[method-assign] + mod.INSTANCE_ENV_DIRS = {"okx": __import__("pathlib").Path(okx)} # type: ignore[misc] + + result = apply_ai_env_to_all({"AI_PROVIDER": "openai", "OPENAI_MODEL": "gpt-test"}) + self.assertTrue(result["ok"]) + self.assertEqual(read_env_lines(hub_env)[0], "AI_PROVIDER=openai") + okx_lines = read_env_lines(okx_env) + self.assertIn("AI_PROVIDER=openai", okx_lines) + self.assertIn("OPENAI_MODEL=gpt-test", okx_lines) + finally: + mod.hub_env_path = orig_hub # type: ignore[method-assign] + mod.INSTANCE_ENV_DIRS = orig_dirs # type: ignore[misc] + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_strategy_roll_lib.py b/tests/test_strategy_roll_lib.py new file mode 100644 index 0000000..e09050d --- /dev/null +++ b/tests/test_strategy_roll_lib.py @@ -0,0 +1,120 @@ +from lib.strategy.strategy_roll_lib import ( + preview_roll, + roll_breakout_invalidate, + roll_breakout_trigger_crossed, + roll_fib_invalidate, + roll_fib_trigger_crossed, + solve_add_amount_for_total_risk, + validate_roll_geometry, +) + + +def test_solve_add_amount_long_one_risk(): + q2, err = solve_add_amount_for_total_risk( + "long", 1.0, 3000.0, 3100.0, 2950.0, 200.0, 1.0 + ) + assert err is None + avg = (1 * 3000 + q2 * 3100) / (1 + q2) + loss = (avg - 2950) * (1 + q2) + assert abs(loss - 200.0) < 0.01 + + +def test_preview_roll_market_short(): + preview, err = preview_roll( + direction="short", + symbol="HYPE/USDT", + qty_existing=3.0, + entry_existing=65.0, + initial_take_profit=60.0, + add_mode="market", + new_stop_loss=66.5, + risk_percent=2.0, + capital_base_usdt=1000.0, + add_price=64.0, + legs_done=1, + ) + assert err is None + assert preview["add_mode_label"] == "市价加仓" + sl = preview["new_stop_loss"] + avg = preview["avg_entry_after"] + qty = preview["qty_after"] + loss = (sl - avg) * qty + assert abs(loss - 20.0) < 0.01 + + +def test_fib_cross_long_down(): + assert roll_fib_trigger_crossed("long", 101.0, 100.0, 100.5) is True + assert roll_fib_trigger_crossed("long", 100.6, 100.6, 100.5) is False + + +def test_breakout_cross_long_up(): + assert roll_breakout_trigger_crossed("long", 99.0, 100.5, 100.0) is True + assert roll_breakout_trigger_crossed("long", 99.0, 100.0, 100.0) is False + assert roll_breakout_invalidate("long", 98.0, 99.0) is True + assert roll_fib_invalidate("long", 110.0, 105.0, 95.0) is True + + +def test_breakout_short_below_breakthrough(): + assert roll_breakout_trigger_crossed("short", 81.0, 80.57, 80.65) is True + assert roll_breakout_trigger_crossed("short", 80.64, 80.57, 80.65) is True + assert roll_breakout_trigger_crossed("short", 80.57, 80.57, 80.65) is True + assert roll_breakout_trigger_crossed("short", 81.0, 80.70, 80.65) is False + + +def test_preview_breakout_mode_label(): + preview, err = preview_roll( + direction="long", + symbol="ETH/USDT", + qty_existing=1.0, + entry_existing=3000.0, + initial_take_profit=3500.0, + add_mode="breakout", + new_stop_loss=2980.0, + breakthrough_price=3100.0, + risk_percent=10.0, + capital_base_usdt=1000.0, + add_price=3050.0, + ) + assert err is None + assert preview["add_mode_label"] == "突破加仓" + + +def test_breakout_geometry_short_mark_above_breakout(): + err = validate_roll_geometry( + "short", + "breakout", + new_stop_loss=568.0, + breakthrough_price=551.0, + entry_existing=560.0, + initial_take_profit=540.0, + mark_price=560.0, + ) + assert err is None + + +def test_breakout_geometry_short_rejects_mark_at_or_below_breakout(): + err = validate_roll_geometry( + "short", + "breakout", + new_stop_loss=568.0, + breakthrough_price=551.0, + entry_existing=560.0, + initial_take_profit=540.0, + mark_price=551.0, + ) + assert err is not None + assert "高于突破价" in err + + +def test_breakout_geometry_long_rejects_mark_at_or_above_breakout(): + err = validate_roll_geometry( + "long", + "breakout", + new_stop_loss=2980.0, + breakthrough_price=3100.0, + entry_existing=3000.0, + initial_take_profit=3500.0, + mark_price=3100.0, + ) + assert err is not None + assert "低于突破价" in err diff --git a/tests/test_strategy_roll_ui_lib.py b/tests/test_strategy_roll_ui_lib.py new file mode 100644 index 0000000..80f29e6 --- /dev/null +++ b/tests/test_strategy_roll_ui_lib.py @@ -0,0 +1,47 @@ +"""strategy_roll_ui_lib 单元测试.""" +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +import lib.strategy.strategy_roll_ui_lib as roll_ui + + +def test_compute_roll_chain_metrics_short(): + group = { + "id": 1, + "direction": "short", + "initial_take_profit": 60.0, + } + legs = [ + {"id": 10, "leg_index": 1, "amount": 3.0, "fill_price": 65.0, "status": "filled"}, + {"id": 11, "leg_index": 2, "amount": 5.0, "fill_price": 64.0, "status": "filled"}, + ] + per_leg, group_metrics = roll_ui.compute_roll_chain_metrics( + group, + legs, + qty_live=8.0, + entry_live=63.5, + monitor={"trigger_price": 66.0, "order_amount": 3.0}, + ) + assert per_leg[10]["avg_entry_after"] is not None + assert per_leg[11]["avg_entry_after"] is not None + assert group_metrics["reward_at_tp_usdt"] is not None + assert group_metrics["initial_qty"] == 3.0 + assert group_metrics["current_qty"] == 8.0 + assert per_leg[11]["reward_at_tp_usdt"] >= per_leg[10]["reward_at_tp_usdt"] + + +def test_infer_initial_position_from_live(): + legs = [{"amount": 2.0, "fill_price": 64.0, "status": "filled"}] + q0, e0 = roll_ui.infer_initial_position(5.0, 63.0, legs) + assert q0 == 3.0 + assert abs(e0 - 62.3333333333) < 0.001 + + +def test_reward_at_tp_long(): + # 毛利 20, 双边费 (200+220)*0.0005=0.21 → 净 19.79 + assert abs(roll_ui.reward_at_tp_usdt("long", 100.0, 110.0, 2.0) - 19.79) < 1e-6 diff --git a/tests/test_strategy_snapshot_dedup.py b/tests/test_strategy_snapshot_dedup.py new file mode 100644 index 0000000..8c0ad14 --- /dev/null +++ b/tests/test_strategy_snapshot_dedup.py @@ -0,0 +1,183 @@ +"""策略快照:同一计划同结果不重复写入.""" +from __future__ import annotations + +import json +import sqlite3 +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from lib.strategy.strategy_snapshot_lib import ( # noqa: E402 + STRATEGY_TREND, + dedupe_strategy_snapshots, + init_strategy_snapshot_table, + list_strategy_snapshots, + save_trend_plan_snapshot, +) + + +def _mem_conn() -> sqlite3.Connection: + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + init_strategy_snapshot_table(conn) + return conn + + +def test_save_trend_plan_snapshot_skips_duplicate_result(): + conn = _mem_conn() + plan = { + "id": 42, + "symbol": "ONDO/USDT", + "exchange_symbol": "ONDO/USDT:USDT", + "direction": "short", + "status": "active", + "opened_at": "2026-06-08 08:00:00", + "legs_done": 4, + "dca_legs": 4, + "first_order_done": 1, + "grid_prices_json": "[]", + "leg_amounts_json": "[]", + } + cfg = {"app_module": type("M", (), {"app_now_str": staticmethod(lambda: "2026-06-08 08:41:00")})()} + save_trend_plan_snapshot(cfg, conn, plan, result_label="止损", pnl_amount=-2.3) + save_trend_plan_snapshot(cfg, conn, plan, result_label="止损", pnl_amount=-2.4) + conn.commit() + rows = conn.execute( + "SELECT COUNT(*) AS c FROM strategy_trade_snapshots WHERE source_id=? AND result_label=?", + (42, "止损"), + ).fetchone() + assert int(rows["c"]) == 1 + + +def test_dedupe_strategy_snapshots_handles_many_duplicates(): + conn = _mem_conn() + payload = json.dumps({"symbol": "ONDO/USDT"}, ensure_ascii=False) + for snap_id in range(1, 46): + conn.execute( + """INSERT INTO strategy_trade_snapshots ( + id, strategy_type, source_id, symbol, result_label, snapshot_json, closed_at, created_at, pnl_amount + ) VALUES (?,?,?,?,?,?,?,?,?)""", + ( + snap_id, + STRATEGY_TREND, + 99, + "ONDO/USDT", + "止损", + payload, + "2026-06-08 08:41:00", + "2026-06-08 08:41:00", + -2.2, + ), + ) + conn.commit() + removed = dedupe_strategy_snapshots(conn) + conn.commit() + assert removed == 44 + row = conn.execute( + "SELECT COUNT(*) AS c FROM strategy_trade_snapshots WHERE source_id=?", + (99,), + ).fetchone() + assert int(row["c"]) == 1 + + +def test_dedupe_strategy_snapshots_keeps_latest_id(): + conn = _mem_conn() + payload = json.dumps({"symbol": "ONDO/USDT"}, ensure_ascii=False) + for snap_id, pnl in ((1, -2.23), (2, -2.31), (3, -2.38)): + conn.execute( + """INSERT INTO strategy_trade_snapshots ( + id, strategy_type, source_id, symbol, result_label, snapshot_json, closed_at, created_at, pnl_amount + ) VALUES (?,?,?,?,?,?,?,?,?)""", + ( + snap_id, + STRATEGY_TREND, + 5, + "ONDO/USDT", + "止损", + payload, + "2026-06-08 08:41:00", + "2026-06-08 08:41:00", + pnl, + ), + ) + conn.commit() + removed = dedupe_strategy_snapshots(conn) + conn.commit() + assert removed == 2 + row = conn.execute( + "SELECT id, pnl_amount FROM strategy_trade_snapshots WHERE source_id=?", + (5,), + ).fetchone() + assert int(row["id"]) == 3 + assert abs(float(row["pnl_amount"]) - (-2.38)) < 1e-6 + + +def test_list_strategy_snapshots_hides_duplicate_keys(): + conn = _mem_conn() + payload = json.dumps({"symbol": "ONDO/USDT", "dca_levels": []}, ensure_ascii=False) + for snap_id in (10, 11, 12): + conn.execute( + """INSERT INTO strategy_trade_snapshots ( + id, strategy_type, source_id, symbol, direction, result_label, + snapshot_json, closed_at, created_at, pnl_amount + ) VALUES (?,?,?,?,?,?,?,?,?,?)""", + ( + snap_id, + STRATEGY_TREND, + 7, + "ONDO/USDT", + "short", + "止损", + payload, + "2026-06-08 08:41:00", + "2026-06-08 08:41:00", + -2.2, + ), + ) + conn.commit() + rows = list_strategy_snapshots(conn, limit=50) + stop_rows = [r for r in rows if int(r.get("source_id") or 0) == 7] + assert len(stop_rows) == 1 + assert int(stop_rows[0]["id"]) == 12 + + +def test_dedupe_keeps_manual_over_stop_loss(): + conn = _mem_conn() + payload = json.dumps({"symbol": "ONDO/USDT"}, ensure_ascii=False) + for snap_id, label in ((10, "止损"), (11, "手动平仓")): + conn.execute( + """INSERT INTO strategy_trade_snapshots ( + id, strategy_type, source_id, symbol, result_label, snapshot_json, closed_at, created_at, pnl_amount + ) VALUES (?,?,?,?,?,?,?,?,?)""", + ( + snap_id, + STRATEGY_TREND, + 7, + "ONDO/USDT", + label, + payload, + "2026-06-08 08:44:00", + "2026-06-08 08:44:00", + -2.23, + ), + ) + conn.commit() + removed = dedupe_strategy_snapshots(conn) + conn.commit() + assert removed == 1 + row = conn.execute( + "SELECT result_label FROM strategy_trade_snapshots WHERE source_id=?", + (7,), + ).fetchone() + assert row["result_label"] == "手动平仓" + + +if __name__ == "__main__": + test_save_trend_plan_snapshot_skips_duplicate_result() + test_dedupe_strategy_snapshots_handles_many_duplicates() + test_dedupe_strategy_snapshots_keeps_latest_id() + test_list_strategy_snapshots_hides_duplicate_keys() + test_dedupe_keeps_manual_over_stop_loss() + print("all ok") diff --git a/tests/test_sync_force_close_policy.py b/tests/test_sync_force_close_policy.py new file mode 100644 index 0000000..5d8b548 --- /dev/null +++ b/tests/test_sync_force_close_policy.py @@ -0,0 +1,41 @@ +"""FORCE_CLOSE 部署策略:只补缺失,不覆盖手调.""" +import os +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from scripts import sync_common_trading_env as sync + + +class TestForceCloseFillMissing(unittest.TestCase): + def test_does_not_overwrite_existing(self): + with tempfile.TemporaryDirectory() as td: + gate = Path(td) / "crypto_monitor_gate" + gate.mkdir() + (gate / ".env").write_text( + "FORCE_CLOSE_ENABLED=false\nFORCE_CLOSE_BJ_HOUR=1\n", + encoding="utf-8", + ) + with mock.patch.object(sync, "REPO", td): + changed = sync.apply_force_close_policy(dry_run=False) + self.assertFalse(changed) + text = (gate / ".env").read_text(encoding="utf-8") + self.assertIn("FORCE_CLOSE_ENABLED=false", text) + self.assertIn("FORCE_CLOSE_BJ_HOUR=1", text) + + def test_fills_missing_keys(self): + with tempfile.TemporaryDirectory() as td: + gate = Path(td) / "crypto_monitor_gate" + gate.mkdir() + (gate / ".env").write_text("APP_USERNAME=x\n", encoding="utf-8") + with mock.patch.object(sync, "REPO", td): + changed = sync.apply_force_close_policy(dry_run=False) + self.assertTrue(changed) + text = (gate / ".env").read_text(encoding="utf-8") + self.assertIn("FORCE_CLOSE_ENABLED=true", text) + self.assertIn("FORCE_CLOSE_BJ_HOUR=0", text) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_trade_exchange_stats_lib.py b/tests/test_trade_exchange_stats_lib.py new file mode 100644 index 0000000..1b335ee --- /dev/null +++ b/tests/test_trade_exchange_stats_lib.py @@ -0,0 +1,48 @@ +import unittest + +from lib.trade.trade_exchange_stats_lib import ( + aggregate_bilateral_stats, + commission_usdt_from_fill, + filter_position_lifecycle_fills, + merge_commission_prefer_income, + quote_turnover_usdt_from_fill, +) + + +class TradeExchangeStatsTests(unittest.TestCase): + def test_turnover_from_cost(self): + t = {"cost": 1000.0, "price": 50, "amount": 20} + self.assertEqual(quote_turnover_usdt_from_fill(t), 1000.0) + + def test_commission_from_fee(self): + t = {"fee": {"cost": -0.42, "currency": "USDT"}} + self.assertEqual(commission_usdt_from_fill(t), 0.42) + + def test_bilateral_aggregate(self): + fills = [ + {"side": "buy", "cost": 500, "fee": {"cost": -0.2, "currency": "USDT"}, "timestamp": 1000}, + {"side": "sell", "cost": 520, "fee": {"cost": -0.21, "currency": "USDT"}, "timestamp": 2000}, + ] + stats = aggregate_bilateral_stats(fills) + self.assertIsNotNone(stats) + self.assertEqual(stats["exchange_turnover_usdt"], 1020.0) + self.assertEqual(stats["exchange_commission_usdt"], 0.41) + + def test_filter_long_lifecycle(self): + base = 1_700_000_000_000 + trades = [ + {"side": "buy", "timestamp": base, "cost": 100}, + {"side": "sell", "timestamp": base + 60_000, "cost": 110}, + {"side": "buy", "timestamp": base + 120_000, "cost": 999}, + ] + got = filter_position_lifecycle_fills( + trades, "long", base - 1000, base + 90_000, close_buffer_ms=0 + ) + self.assertEqual(len(got), 2) + + def test_prefer_income_commission(self): + self.assertEqual(merge_commission_prefer_income(0.3, 0.45), 0.45) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_trade_fee_lib.py b/tests/test_trade_fee_lib.py new file mode 100644 index 0000000..ba70530 --- /dev/null +++ b/tests/test_trade_fee_lib.py @@ -0,0 +1,48 @@ +"""永续固定费率净盈亏.""" +from __future__ import annotations + +import os +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from lib.trade.trade_fee_lib import ( # noqa: E402 + estimate_roundtrip_fee_usdt, + net_pnl_after_fee, + notional_usdt, + taker_fee_rate, +) + + +class TestTradeFeeLib(unittest.TestCase): + def test_default_rate(self): + os.environ.pop("PERP_TAKER_FEE_RATE", None) + self.assertAlmostEqual(taker_fee_rate(), 0.0005) + + def test_notional(self): + self.assertAlmostEqual(notional_usdt(100, 2, 1.0), 200.0) + self.assertAlmostEqual(notional_usdt(62000, 78, 0.0001), 483.6, places=2) + + def test_roundtrip_fee_qty(self): + # 开 100*2=200, 平 110*2=220, 费=(200+220)*0.0005=0.21 + fee = estimate_roundtrip_fee_usdt(100, 110, 2.0, 1.0, rate=0.0005) + self.assertAlmostEqual(fee, 0.21, places=6) + + def test_net_long_matches_checklist(self): + # 毛利 20, 费 0.21 → 净 19.79 + net = net_pnl_after_fee(20.0, 100, 110, 2.0, 1.0, rate=0.0005) + self.assertAlmostEqual(net, 19.79, places=4) + + def test_open_notional_fallback(self): + # 无张数:开名义 1000, 出场 110/100 → 平 1100, 费=1.05 + fee = estimate_roundtrip_fee_usdt(100, 110, open_notional=1000, rate=0.0005) + self.assertAlmostEqual(fee, 1.05, places=6) + net = net_pnl_after_fee(50.0, 100, 110, open_notional=1000, rate=0.0005) + self.assertAlmostEqual(net, 48.95, places=4) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_trade_policy_lib.py b/tests/test_trade_policy_lib.py new file mode 100644 index 0000000..617c980 --- /dev/null +++ b/tests/test_trade_policy_lib.py @@ -0,0 +1,90 @@ +"""账户方向 / 币种白名单 env 策略.""" +from lib.trade.trade_policy_lib import ( + assert_direction_allowed, + assert_symbol_allowed, + assert_trade_policy_open, + load_trade_policy, + parse_symbol_whitelist, + symbol_base_coin, + trade_policy_badge_parts, +) + + +def test_default_policy_unrestricted(): + p = load_trade_policy({}) + assert not p.direction_restrict_enabled + assert not p.symbol_restrict_enabled + assert p.allows_long and p.allows_short + + +def test_long_only_blocks_short(): + p = load_trade_policy( + { + "TRADE_DIRECTION_RESTRICT_ENABLED": "true", + "TRADE_DIRECTION": "long_only", + } + ) + ok, msg = assert_direction_allowed(p, "short") + assert not ok + assert "仅做多" in msg + ok2, _ = assert_direction_allowed(p, "long") + assert ok2 + + +def test_symbol_whitelist_btc_eth(): + p = load_trade_policy( + { + "TRADE_SYMBOL_RESTRICT_ENABLED": "true", + "TRADE_SYMBOL_WHITELIST": "BTC,ETH", + } + ) + ok, _ = assert_symbol_allowed(p, "BTC/USDT") + assert ok + ok2, msg = assert_symbol_allowed(p, "SOL") + assert not ok2 + assert "SOL" in msg + + +def test_symbol_whitelist_without_list_disables_restrict(): + p = load_trade_policy( + { + "TRADE_SYMBOL_RESTRICT_ENABLED": "true", + "TRADE_SYMBOL_WHITELIST": "", + } + ) + assert not p.symbol_restrict_enabled + + +def test_combined_open_validation(): + p = load_trade_policy( + { + "TRADE_DIRECTION_RESTRICT_ENABLED": "1", + "TRADE_DIRECTION": "多", + "TRADE_SYMBOL_RESTRICT_ENABLED": "yes", + "TRADE_SYMBOL_WHITELIST": "BTC,ETH", + } + ) + ok, _ = assert_trade_policy_open(p, "ETH", "long") + assert ok + ok2, msg = assert_trade_policy_open(p, "ETH", "short") + assert not ok2 + ok3, msg3 = assert_trade_policy_open(p, "BNB", "long") + assert not ok3 + assert "BNB" in msg3 + + +def test_parse_whitelist_and_base_coin(): + assert parse_symbol_whitelist("btc, eth") == ("BTC", "ETH") + assert symbol_base_coin("btc/usdt:usdt") == "BTC" + + +def test_badge_parts(): + p = load_trade_policy( + { + "TRADE_DIRECTION_RESTRICT_ENABLED": "true", + "TRADE_DIRECTION": "long_only", + "TRADE_SYMBOL_RESTRICT_ENABLED": "true", + "TRADE_SYMBOL_WHITELIST": "BTC,ETH", + } + ) + assert trade_policy_badge_parts(p) == ("仅多", "BTC/ETH") diff --git a/tests/test_trade_result_lib.py b/tests/test_trade_result_lib.py new file mode 100644 index 0000000..8e54b49 --- /dev/null +++ b/tests/test_trade_result_lib.py @@ -0,0 +1,30 @@ +from lib.trade.trade_result_lib import normalize_result_with_pnl, normalize_display_result, is_winning_pnl + + +def test_stop_loss_with_profit_becomes_trailing_tp(): + assert normalize_result_with_pnl("止损", 4.33) == "移动止盈" + + +def test_manual_close_unchanged_even_with_profit(): + assert normalize_result_with_pnl("手动平仓", 10) == "手动平仓" + + +def test_stop_loss_with_loss_unchanged(): + assert normalize_result_with_pnl("止损", -2.5) == "止损" + + +def test_take_profit_unchanged(): + assert normalize_result_with_pnl("止盈", 5) == "止盈" + + +def test_external_close_becomes_manual_close(): + assert normalize_display_result("外部平仓") == "手动平仓" + assert normalize_result_with_pnl("外部平仓", 2.5) == "手动平仓" + assert normalize_result_with_pnl("外部平仓(自动同步)", -1) == "手动平仓" + + +def test_winning_pnl_positive_only(): + assert is_winning_pnl(2.96) is True + assert is_winning_pnl(0) is False + assert is_winning_pnl(-1.05) is False + assert is_winning_pnl(None) is False diff --git a/tests/test_trade_result_miss_filter.py b/tests/test_trade_result_miss_filter.py new file mode 100644 index 0000000..4e1c594 --- /dev/null +++ b/tests/test_trade_result_miss_filter.py @@ -0,0 +1,26 @@ +"""trade_result_lib:过滤「错过」记录.""" +import unittest + +from lib.trade.trade_result_lib import ( + filter_trade_records_excluding_miss, + is_miss_trade_result, +) + + +class TradeResultMissFilterTest(unittest.TestCase): + def test_is_miss_trade_result(self): + self.assertTrue(is_miss_trade_result("错过")) + self.assertFalse(is_miss_trade_result("止盈")) + + def test_filter_excludes_miss(self): + rows = [ + {"effective_result": "止盈", "id": 1}, + {"effective_result": "错过", "id": 2}, + {"result": "错过", "id": 3}, + ] + out = filter_trade_records_excluding_miss(rows) + self.assertEqual([r["id"] for r in out], [1]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_trade_stats_calendar_lib.py b/tests/test_trade_stats_calendar_lib.py new file mode 100644 index 0000000..ab33704 --- /dev/null +++ b/tests/test_trade_stats_calendar_lib.py @@ -0,0 +1,90 @@ +import unittest +from types import SimpleNamespace + +from datetime import datetime + +from lib.trade.trade_stats_calendar_lib import ( + build_initial_stats_calendar, + build_stats_calendar_bootstrap, + build_trade_stats_calendar, +) + + +def _row(**kwargs): + base = { + "monitor_type": "", + "key_signal_type": "", + "exchange_turnover_usdt": None, + "exchange_commission_usdt": None, + } + base.update(kwargs) + return SimpleNamespace(**base) + + +def _matches_all(row, segment_key): + return segment_key == "all" + + +def _matches_manual(row, segment_key): + if segment_key == "all": + return True + if segment_key == "manual": + return (row.monitor_type or "").strip() == "手动" and not (row.key_signal_type or "").strip() + return False + + +class TradeStatsCalendarLibTests(unittest.TestCase): + def test_groups_by_trading_day_and_segment(self): + pnls = [ + (10.0, None, "2026-06-18", _row(monitor_type="手动")), + (-3.0, None, "2026-06-18", _row(monitor_type="手动")), + (5.0, None, "2026-06-19", _row(monitor_type="自动", key_signal_type="箱体突破")), + ] + payload = build_trade_stats_calendar( + pnls, + 2026, + 6, + "manual", + _matches_manual, + reset_hour=8, + ) + self.assertEqual(payload["month"], 6) + self.assertEqual(payload["month_open_count"], 2) + days = payload["days"] + self.assertIn("2026-06-18", days) + self.assertNotIn("2026-06-19", days) + self.assertEqual(days["2026-06-18"]["open_count"], 2) + self.assertAlmostEqual(days["2026-06-18"]["pnl_total"], 7.0) + + def test_invalid_month_raises(self): + with self.assertRaises(ValueError): + build_trade_stats_calendar([], 2026, 13, "all", _matches_all) + + def test_initial_calendar_uses_current_month(self): + pnls = [(2.5, None, "2026-06-20", _row())] + payload = build_initial_stats_calendar( + pnls, + datetime(2026, 6, 26, 12, 0), + _matches_all, + reset_hour=8, + ) + self.assertEqual(payload["year"], 2026) + self.assertEqual(payload["month"], 6) + self.assertEqual(payload["month_open_count"], 1) + self.assertIn("2026-06-20", payload["days"]) + + def test_bootstrap_json_roundtrip(self): + pnls = [(2.5, None, "2026-06-20", _row())] + payload, raw = build_stats_calendar_bootstrap( + pnls, + datetime(2026, 6, 26, 12, 0), + _matches_all, + reset_hour=8, + ) + self.assertIsNotNone(payload) + self.assertIsNotNone(raw) + self.assertIn('"month_open_count":1', raw.replace(" ", "")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_trend_dca_enrich_fills.py b/tests/test_trend_dca_enrich_fills.py new file mode 100644 index 0000000..b2a305e --- /dev/null +++ b/tests/test_trend_dca_enrich_fills.py @@ -0,0 +1,101 @@ +"""趋势回调运行中计划:实际成交价重算补仓表与金额盈亏比.""" +from __future__ import annotations + +import json +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from lib.strategy.strategy_snapshot_lib import attach_trend_dca_levels # noqa: E402 +from lib.strategy.strategy_trend_lib import ( # noqa: E402 + calc_trend_plan_money_metrics, + trend_leg_display_price, +) + + +class TestTrendDcaEnrichFills(unittest.TestCase): + def _base_plan(self, **overrides): + plan = { + "direction": "long", + "stop_loss": 0.329, + "take_profit": 0.476, + "first_order_amount": 115, + "snapshot_available_usdt": 97.98, + "risk_percent": 5, + "contract_size": 1.0, + "grid_prices_json": json.dumps([0.3465, 0.343, 0.3395, 0.336, 0.3325]), + "leg_amounts_json": json.dumps([23, 23, 23, 23, 23]), + "dca_legs": 5, + "first_order_done": 1, + "legs_done": 0, + "avg_entry_price": 0.3537, + "order_amount_open": 115, + "target_order_amount": 230, + "leg_fill_prices_json": json.dumps([0.3537]), + } + plan.update(overrides) + return plan + + def test_header_money_rr_not_price_rr(self): + plan = self._base_plan() + metrics = calc_trend_plan_money_metrics(plan) + self.assertAlmostEqual(metrics["risk_amount_u"], 4.899, places=2) + self.assertIsNotNone(metrics["money_rr"]) + self.assertLess(metrics["money_rr"], 4.0) + + def test_done_dca_uses_actual_fill_price(self): + plan = self._base_plan( + legs_done=1, + avg_entry_price=0.3512, + order_amount_open=138, + leg_fill_prices_json=json.dumps([0.3537, 0.3458]), + ) + enriched = attach_trend_dca_levels(plan) + levels = enriched["dca_levels"] + self.assertEqual(len(levels), 6) + dca1 = levels[1] + self.assertEqual(dca1["status"], "done") + self.assertAlmostEqual(dca1["price"], 0.3458, places=4) + self.assertIsNotNone(dca1["avg_entry"]) + self.assertIsNotNone(dca1["rr"]) + dca2 = levels[2] + self.assertEqual(dca2["status"], "pending") + self.assertAlmostEqual(dca2["price"], 0.343, places=4) + + def test_missing_dca_fills_use_grid_trigger_not_inferred_price(self): + """缺补仓成交价时:触发价用计划网格,末档均价对齐头部,禁止反推离谱成交价.""" + plan = self._base_plan( + legs_done=2, + avg_entry_price=0.3507, + order_amount_open=161, + leg_fill_prices_json=json.dumps([0.3436]), + grid_prices_json=json.dumps([0.343, 0.343, 0.3395, 0.336, 0.3325]), + ) + enriched = attach_trend_dca_levels(plan) + levels = enriched["dca_levels"] + dca1 = levels[1] + dca2 = levels[2] + self.assertEqual(dca1["status"], "done") + self.assertAlmostEqual(dca1["price"], 0.343, places=4) + self.assertEqual(dca2["status"], "done") + self.assertAlmostEqual(dca2["price"], 0.343, places=4) + self.assertAlmostEqual(dca2["avg_entry"], 0.3507, places=4) + self.assertLess(dca2["price"], 0.36) + + def test_display_price_never_infers_from_target_avg(self): + """三所共用:缺记录时只用网格,不因均价反推离谱触发价.""" + plan = self._base_plan( + legs_done=2, + avg_entry_price=0.3507, + leg_fill_prices_json=json.dumps([0.3436]), + grid_prices_json=json.dumps([0.343, 0.343, 0.3395, 0.336, 0.3325]), + ) + self.assertAlmostEqual(trend_leg_display_price(plan, 2), 0.343, places=4) + self.assertLess(trend_leg_display_price(plan, 2), 0.36) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_trend_dca_pnl.py b/tests/test_trend_dca_pnl.py new file mode 100644 index 0000000..39ff4ed --- /dev/null +++ b/tests/test_trend_dca_pnl.py @@ -0,0 +1,43 @@ +"""趋势回调:补仓触达与有效保证金估算.""" +from lib.strategy.strategy_trend_lib import trend_dca_level_reached, trend_effective_margin_capital + + +def test_trend_dca_short_monotonic_up_fills_missed_legs(): + """做空价升:旧逻辑需 last `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** | 门控通过 → **自动市价开仓**(与 Gate/Binance 相同;须 `LIVE_TRADING_ENABLED=true`) | + +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**:门控通过且 RR 达标时应自动市价开仓;失败时微信说明 `exchange_failed` / `rr_insufficient`. +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()`(三所共用自动开仓逻辑) | +| 斐波添加 | `_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..e65a0c6 --- /dev/null +++ b/备份与恢复.md @@ -0,0 +1,267 @@ +# 备份与恢复(Ubuntu 服务器) + +本文档面向 **VPS / Ubuntu**,项目统一放在 **`/opt/crypto_monitor_user`**,数据备份统一放在 **`/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_okx`. + +--- + +## 一,首次安装:三个实例自动备份 + 试跑 + +整段复制到 SSH 终端执行(需 **root** 或对该目录有写权限): + +```bash +apt install -y sqlite3 2>/dev/null || true + +for dir in crypto_monitor_binance crypto_monitor_gate crypto_monitor_okx; do + cd "/opt/crypto_monitor_user/${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_okx.log` + +--- + +## 二,仅安装某一个实例的自动备份 + +把 `INSTANCE` 改成目录名后整段执行: + +```bash +INSTANCE=crypto_monitor_binance +cd "/opt/crypto_monitor_user/${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_okx` + +--- + +## 三,手动立即备份(数据库 + 图片,三个实例) + +不等到 0 点,立刻各备份一次: + +```bash +for dir in crypto_monitor_binance crypto_monitor_gate crypto_monitor_okx; do + echo ">>> ${dir}" + bash "/opt/crypto_monitor_user/${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_okx.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_okx; do + src="/opt/crypto_monitor_user/${dir}/.env" + dst="/opt/crypto_monitor_user/${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_okx; do + src="/opt/crypto_monitor_user/${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_user/${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_user/${INSTANCE}/.env" +echo "restored from /root/backups/env/${INSTANCE}.env.${DATE}" +``` + +恢复后重启对应 PM2 进程,例如: + +```bash +pm2 restart crypto-monitor-binance +pm2 restart crypto-monitor-gate +``` + +(进程名以你 `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_user/${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 / 将 `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_okx; do + PROJ="/opt/crypto_monitor_user/${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_user +git pull + +for dir in crypto_monitor_binance crypto_monitor_gate crypto_monitor_okx; 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.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/ + 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_okx; do + SCRIPT="/opt/crypto_monitor_user/${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/部署文档.md](./crypto_monitor_gate/部署文档.md) | Gate 部署 | diff --git a/策略交易说明.md b/策略交易说明.md new file mode 100644 index 0000000..988e720 --- /dev/null +++ b/策略交易说明.md @@ -0,0 +1,160 @@ +# 策略交易说明 + +本文档说明仓库根目录 **共用策略逻辑** 与三个 `crypto_monitor_*` 实例中的 **策略交易** 入口(顶栏「策略交易」,页内子 Tab:趋势回调 / 顺势加仓). + +--- + +## 一,架构(精简共用) + +``` +strategy_trend_lib.py # 趋势回调:网格价,补仓拆分,边界校验(纯计算) +strategy_roll_lib.py # 顺势加仓:总风险反推,斐波限价,最多 3 腿(纯计算) +strategy_db.py # roll_groups / roll_legs 表结构 +strategy_config.py # 各所 app → 统一回调配置(交易所 API) +strategy_register.py # Flask POST:/strategy/roll/preview,/strategy/roll/execute +strategy_ui.py # 主站 index 页数据(滚仓组,持仓列表等) +strategy_exchange_*.py # 适配器说明(实际下单仍走各所 app 的 ccxt) +strategy_templates/ # strategy_trading_page.html(双栏),trend/roll 面板,records 页 +strategy_snapshot_lib.py # 结束快照表 strategy_trade_snapshots(最近 100 条) +strategy_records_register.py # /strategy/records 路由与列表数据 +``` + +| 层级 | 职责 | +|------|------| +| **lib** | 不算 ccxt,不写库 | +| **config** | 把 `place_exchange_order`,`replace_active_monitor_tpsl_on_exchange` 等接到统一 cfg | +| **各所 app** | `.env`,DB,`init_db`,PM2,微信,监控轮询 | + +部署时各实例 `PYTHONPATH` 需包含仓库根目录(`ecosystem.config.cjs` 中 `PYTHONPATH=..`). + +--- + +## 二,导航与页面 + +顶栏:**策略交易** → `/strategy`(趋势回调 | 顺势加仓 左右并列);**策略交易记录** → `/strategy/records`(已结束快照).旧链接 `/strategy/trend`,`/strategy/roll` 会跳转到 `/strategy`. + +| 区域 | 说明 | +|------|------| +| 左栏 · 趋势回调 | **三所均可**(预览,执行,自动补仓,程序止盈);运行中计划卡含 **补仓计划明细** 表 | +| 右栏 · 顺势加仓 | 须已有同向持仓;滚仓组/历史表在右栏内滚动 | +| **策略交易记录** | 趋势回调 / 顺势加仓 **分两栏**;每条约一行摘要,点击展开详情;库内保留最近 **100** 条 | +| `/trade` | 实盘下单 | 首仓,以损定仓,移动保本(不变) | + +各所 `app.py` 注册 `@app.route("/strategy/trend|roll")` → `render_main_page(...)`;`install_strategy_trading` 仅注册滚仓 POST API. + +--- + +## 三,趋势回调 + +- **位置**:各所顶栏 **策略交易 → 趋势回调**(共用 `strategy_trend_register.py` + 各所交易所 API). +- **行为**:与《[docs/trend-pullback-strategy.md](./docs/trend-pullback-strategy.md)》一致——预览 → 确认执行 → 首仓 50% + 交易所止损 + 多档 **自动** 市价补仓 + 程序监控止盈. +- **共用代码**:`parse_and_compute_trend_pullback_plan` 中网格/拆档已改为调用 `strategy_trend_lib`. +- **互斥**:与「机器人下单监控」持仓上限,运行中趋势计划互斥(逻辑未改). + +各所使用自己的 API 密钥与 `crypto.db`,互不影响. + +--- + +## 四,顺势加仓(滚仓,仅人工) + +> **详细说明**(计仓公式,四种方式,程序监控,生命周期):仓库 [`顺势加仓滚仓说明.md`](./顺势加仓滚仓说明.md);各实例策略页 **[`/strategy/roll/docs`](/strategy/roll/docs)** 可在线阅读. + +### 4.1 原则 + +- **禁止自动加仓**;仅页面按钮「执行滚仓」或挂限价单(无价格穿越自动下单). +- **全币种**(与各所合约列表一致). +- **止盈**:全程使用 **首仓** `order_monitors.take_profit`,滚仓不改止盈. +- **止损**:每次人工填写 **新统一止损**;成交后调用各所 **先撤后挂** TP/SL(止盈仍为首仓). +- **总风险%**:按「合并持仓 + 新止损」反推本次加仓张数,使触及新止损时亏损约 **账户基数 × 风险%**(默认 2%,可在表单修改). +- **做多**最多滚仓 **3** 次(首仓不计入,仅计 `roll_legs` 已成交次数);做空默认同样 3 次(见 `strategy_roll_lib.ROLL_MAX_LEGS_SHORT`). + +### 4.2 斐波限价 + +- 填写 **上沿 H,下沿 L**(H > L),仅用于计算限价加仓价(与 `fib_key_monitor_lib.calc_fib_plan` 的 **entry** 一致). +- **做多**:下沿 = 结构止损侧;**做空**:上沿 = 结构止损侧. +- 可选 **0.618** 或 **0.786**;与关键位自动单的 TP(H/L 对侧)**不同**,滚仓 TP 锁定首仓. + +### 4.3 前置条件 + +1. 在 **实盘下单** 已有同 symbol,同方向 **active** `order_monitors`. +2. 交易所有同向持仓(读 `get_live_position_contracts`). +3. 无 **active** `trend_pullback_plans`(与趋势回调互斥). + +### 4.4 数据表(各所 `crypto.db`) + +- `roll_groups`:绑定 `order_monitor_id`,首仓 TP/SL,当前 SL,已滚仓次数. +- `roll_legs`:每腿方式(市价 / 斐波0.618 / 斐波0.786),张数,新 SL,状态(`filled` / `pending`). + +`init_db()` 时自动 `CREATE TABLE IF NOT EXISTS`(`strategy_db.init_strategy_tables`). + +### 4.5 操作步骤 + +1. 打开顶栏 **策略交易** `/strategy`,在 **右栏·顺势加仓** 操作. +2. 选择持仓币种,方向,加仓方式,填写 H/L(斐波时),**新统一止损**,总风险%. +3. 点击 **执行滚仓**(市价立即加仓并更新止损;限价则挂委托,成交后需再处理止损——当前版本限价 pending 后提示手动同步). +4. 查看页底 **滚仓腿历史**. + +可选:对表单字段 POST `/strategy/roll/preview`(JSON)查看 `strategy_roll_lib.preview_roll` 结果. + +--- + +## 五,策略交易记录(三所统一) + +- **入口**:顶栏 **策略交易记录** → `/strategy/records`(`strategy_records_register.register_strategy_records`). +- **写入时机**:趋势计划结束(止盈 / 止损 / 手动结束),**保本移交**,顺势加仓组结案时,写入表 **`strategy_trade_snapshots`**(`strategy_snapshot_lib`). +- **与交易记录区别**:策略记录写 **`strategy_trade_snapshots`**;顶栏 **交易记录与复盘** 写 **`trade_records`**.中控手动结束计划时 **两者都应写入**(详见 [docs/trend-hub-close-and-trade-records.md](./docs/trend-hub-close-and-trade-records.md)). +- **保留条数**:每次写入后自动修剪,仅保留按 **`closed_at` 倒序** 的最近 **100** 条. +- **页面布局**: + - **左栏卡片**:趋势回调记录;**右栏卡片**:顺势加仓记录. + - 每条默认 **一行简略**(品种,方向,结果,盈亏,补仓进度,结束时间);**点击行**展开均价/止损/止盈/补仓档位表或滚仓腿表. + - **筛选**:币种,时间排序(最新/最早),芯片 **盈利 / 亏损 / 未补仓 / 补仓**(前端过滤,数据来自服务端 enrich 字段 `filter_pnl`,`dca_tag`,`dca_done`). +- **共用模板**:`strategy_templates/strategy_records_page.html`(三所 `index.html` include). + +--- + +## 六,中控全屏 · 趋势回调展示 + +各所 Flask 经 `hub_bridge` + `enrich_trend_plan_for_hub` 向中控提供 active 计划(含 `dca_levels`).在 **manual_trading_hub** 全屏 **趋势回调** 区,单所通常仅 **一仓**,计划卡为 **横向两列**(与实例字段一致,操作在实例完成): + +| 区域 | 内容 | +|------|------| +| 顶栏 | `#ID 品种`,方向徽章,**结束计划**(SSO 打开实例并确认) | +| **左列** | 来源/风险/补仓边界/已补仓;均价,止损,止盈,盈亏比,标记价,浮盈亏(% 按 **计划保证金**) | +| **右列** | **补仓计划明细** 表(首仓 + 各档;未成交显示 **待补仓**) | +| **底栏** | 保本移交(偏移%,跳转实例策略页),**快照可用 / 计划保证金 / 杠杆** | + +静态资源版本见 `manual_trading_hub/static/index.html` 中 `app.js` / `app.css` 的 `?v=` 参数;改 UI 后请 **强刷** 中控页. + +--- + +## 七,升级与重启 + +```bash +cd /opt/crypto_monitor_user +git pull +pm2 restart crypto-monitor-binance crypto-monitor-okx crypto-monitor-gate manual-trading-hub +pm2 save +``` + +部署约定:**Ubuntu + root + /opt/crypto_monitor_user + PM2** → [docs/ubuntu-server.md](./docs/ubuntu-server.md). + +--- + +## 八,相关文档 + +| 文档 | 内容 | +|------|------| +| [docs/trend-pullback-strategy.md](./docs/trend-pullback-strategy.md) | 趋势回调细则(三所共用逻辑) | +| [AI复盘与模型配置说明.md](./AI复盘与模型配置说明.md) | 复盘页 AI(与策略无关) | +| [manual_trading_hub/使用说明.md](./manual_trading_hub/使用说明.md) | 中控监控,全屏趋势卡两列布局 | +| [docs/trend-hub-close-and-trade-records.md](./docs/trend-hub-close-and-trade-records.md) | 中控平仓,交易记录写入,补仓展示统一,漏记补录 | +| [docs/hub-symbol-archive-kline.md](./docs/hub-symbol-archive-kline.md) | 币种档案,永久 5m K 线,建档与 4h 增量同步 | +| [docs/ubuntu-server.md](./docs/ubuntu-server.md) | Ubuntu / root /opt / PM2 部署 | +| [fib_key_monitor_lib.py](./fib_key_monitor_lib.py) | 斐波公式共用 | + +--- + +## 九,后续可增强(未实现) + +- 滚仓页内嵌预览按钮,限价成交后一键同步止损. +- 策略交易记录按 UTC 时间窗与顶栏「列表筛选」联动. diff --git a/顺势加仓滚仓说明.md b/顺势加仓滚仓说明.md new file mode 100644 index 0000000..ce49365 --- /dev/null +++ b/顺势加仓滚仓说明.md @@ -0,0 +1,174 @@ +# 顺势加仓(滚仓)详细说明 + +本文档描述 **顺势加仓 / 滚仓** 的完整业务逻辑,计仓公式,四种加仓方式,程序监控与生命周期规则.实现代码见 `strategy_roll_lib.py`,`strategy_roll_monitor_lib.py`,`strategy_register.py`. + +--- + +## 1. 适用范围与前置条件 + +| 项目 | 规则 | +|------|------| +| 计仓模式 | **仅「以损定仓」**(`POSITION_SIZING_MODE=risk`);全仓杠杆模式禁止滚仓 | +| 持仓 | 须先在「实盘下单」存在 **active** 的 `order_monitors`,且交易所有同向持仓 | +| 趋势互斥 | 存在 **active** 趋势回调计划时不可滚仓 | +| 腿数上限 | 做多 / 做空各最多 **3 次**滚仓(仅计 **已成交** 的 `roll_legs`) | +| 同时监控 | **同一滚仓组** 最多 **1 条 pending** 腿;成交或删除/失效后再提交下一腿 | +| 止盈 | 全程使用 **首仓** `order_monitors.take_profit`,滚仓不改止盈 | +| 止损 | 每次提交填写 **新统一止损价 S**;成交后交易所 TP/SL 同步(止盈仍为首仓) | + +--- + +## 2. 风险预算(不可手改) + +- 读取所选监控单:`order_monitors.risk_percent` +- 风险预算:**B = 当前交易基数 × risk%**(`get_trading_capital_usdt()` × 监控 risk%) +- 页面规则区展示当前 risk%,表单 **不提供** 风险% 输入框 + +**方案 C(定稿)**:加仓后若价格打到 **新止损 S**,合并持仓的总亏损 **≤ B**(约等于 1 个风险单位).浮盈通过 **触发时刻的 mark 价,当时持仓均价与张数** 进入公式,不在提交时固定张数. + +--- + +## 3. 计仓公式 + +变量: + +- `Q1, E1`:触发时现有持仓张数,均价 +- `E2`:加仓成交价(市价腿 ≈ 当时 mark;程序监控腿在 **穿越触发时** 用当时 mark 重算) +- `S`:提交时填写的统一止损价 +- `B`:风险预算(U) +- `cs`:合约 `contractSize`(U 本位线性永续) + +**做多**(须 `S < E2`): + +```text +(Q1 + Q2) × (avg − S) × cs = B +avg = (Q1·E1 + Q2·E2) / (Q1 + Q2) + +=> Q2 = (B/cs − Q1·(E1 − S)) / (E2 − S) +``` + +**做空**(须 `S > E2`): + +```text +=> Q2 = (B/cs − Q1·(S − E1)) / (S − E2) +``` + +若 `Q2 ≤ 0`:不加仓 / 监控腿 **失效**,提示「已满足风险上限或无法再加」. + +预览与市价执行前用当前 mark 估算;**斐波 / 突破** 在 **触发瞬间** 按当时持仓与 mark **重新计算** 张数后再市价下单. + +--- + +## 4. 四种加仓方式 + +### 4.1 市价加仓 + +| 输入 | 仅 **新止损价 S** | +| 执行 | 预览 → **10 秒确认** → 立即市价成交 → 更新止损 | +| 显示 | `市价加仓` | + +### 4.2 斐波 0.618 / 0.786 + +| 输入 | 上沿 H,下沿 L,新止损 S | +| 限价 | 由 H/L 按斐波算 **加仓价 P**(不打交易所限价单) | +| 触发 | 程序监控 **mark**:
        • **多**:mark **向下穿越** P → 市价加
        • **空**:mark **向上穿越** P → 市价加 | +| 失效 | **止盈侧**:多 mark≥H;空 mark≤L | +| 显示 | `斐波0.618` / `斐波0.786` | + +### 4.3 突破加仓 + +| 输入 | **突破价 B**,新止损 S | +| 触发 | 程序监控 **mark**:
        • **多**:mark **向上穿越** B → 市价加
        • **空**:mark **向下穿越** B → 市价加 | +| 失效 | **止损侧**:多 mark≤S;空 mark≥S(未突破先向止损侧) | +| 显示 | `突破加仓` | + +几何校验(做多示例): + +- 斐波:S < P < 当前价(回调加仓) +- 突破:S < B < 当前价(向上突破再加) + +--- + +## 5. 程序监控技术要点 + +- **监控价**:统一使用 **标记价 mark**(`get_mark_price` 或 `get_price`) +- **穿越判定**:比较 `last_mark_price`(上一 tick 存库)与当前 mark,避免重复触发 + - 例:做多斐波:`prev > P` 且 `mark ≤ P` +- **轮询**:各所后台任务调用 `check_roll_monitors(cfg)` +- **成交后**:`replace_tpsl` 更新交易所止损;`order_monitors.stop_loss` 同步为 S + +--- + +## 6. 生命周期与权限 + +```text +提交 pending → [监控中] ──穿越触发──→ filled → 可提交下一腿 + │ + ├── 用户删除 → cancelled(不可修改,仅删除) + ├── 失效规则 → invalidated + └── 手动平仓 / 监控结案 → roll_group closed,pending 清除 +``` + +| 规则 | 说明 | +|------|------| +| 提交后不可改 | pending 腿参数不可编辑,只能 **删除** | +| 手动平仓 | 实例页删单/平仓,中控持仓平仓 → 调用 `roll_sync_after_external_close` | +| 历史保留 | **filled** 腿写入库与策略复盘快照;关组后 pending 清除,已成交腿仍可在「策略交易记录」中查看 | + +API: + +- `POST /strategy/roll/preview` — JSON 预览 +- `POST /strategy/roll/execute` — 提交市价或监控计划 +- `POST /strategy/roll/cancel/` — 删除 pending 腿 +- `POST /api/hub/roll/sync-flat` — 中控平仓后同步(内部) + +--- + +## 7. 数据表 + +**roll_groups**(绑定 `order_monitor_id`) + +- 首仓 TP/SL,`current_stop_loss`,`leg_count`(**已成交**次数),`risk_percent` 快照 + +**roll_legs** + +| 字段 | 说明 | +|------|------| +| add_mode | 市价加仓 / 斐波0.618 / 斐波0.786 / 突破加仓 | +| limit_price | 斐波限价 P | +| breakthrough_price | 突破价 B | +| new_stop_loss | 统一止损 S | +| last_mark_price | 上一 tick mark(穿越检测) | +| status | pending / filled / cancelled / invalidated | + +--- + +## 8. 操作流程(建议) + +1. 在「实盘下单」已有同向持仓与监控单 +2. 打开 **策略交易 → 顺势加仓**,选择币种(方向自动锁定) +3. 选择加仓方式,填写对应价格字段 → **预览** +4. 市价:等待 10 秒 → **执行滚仓**;斐波/突破:确认后提交监控 +5. 监控中可在「最近滚仓腿」**删除**;成交后再提交下一腿(最多 3 次) + +--- + +## 9. 相关文件 + +| 文件 | 职责 | +|------|------| +| `strategy_roll_lib.py` | 计仓,校验,穿越/失效纯函数 | +| `strategy_roll_monitor_lib.py` | 定时监控,触价成交,外部平仓同步 | +| `strategy_register.py` | 预览/执行/删除路由 | +| `static/strategy_roll.js` | 方向锁定,字段显隐,预览与 10 秒确认 | +| `strategy_templates/strategy_roll_panel.html` | 右栏 UI | + +--- + +## 10. 与旧版差异摘要 + +- 风险% 从监控单读取,不再手填 +- 止损为 **绝对价格**,不再使用「止损偏移%」 +- 斐波/突破改为 **程序盯 mark + 触价市价**,不再挂交易所限价单 +- 新增 **突破加仓** +- pending **不可改,可删**;手动平仓自动结束滚仓监控