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}>
+
+ <{t} class="rule-tip">{{{{ key_gate_rule_text }}}}{t}>
+ <{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}>
+ 删
+ {t}>
+ <{t} class="pos-meta">
+ 上沿: {{{{ k.upper }}}}
+ 下沿: {{{{ k.lower }}}}
+ 已提醒: {{{{ k.notification_count or 0 }}}}/{{{{ k.max_notify or 3 }}}}
+ {t}>
+ <{t} class="pos-grid">
+ <{t} class="pos-cell">现价 - {t}>
+ <{t} class="pos-cell">距上沿 - {t}>
+ <{t} class="pos-cell">距下沿 - {t}>
+ <{t} class="pos-cell">门控 - {t}>
+ {t}>
+ <{t} class="pos-meta" style="margin-top:8px"> {t}>
+ {t}>
+ {{% else %}}
+ <{t} class="pos-empty">暂无监控中的关键位{t}>
+ {{% endfor %}}
+ {t}>
+ {t}>
+ <{t} class="card">
+ 关键位历史
+ <{t} class="sub" style="font-size:.72rem;color:#8892b0;margin-bottom:8px">失效或已结案的关键位{t}>
+ <{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}>
+ 删除
+ {t}>
+ <{t} class="pos-meta">
+ {{{{ h.monitor_type }}}}
+ {{{{ h.close_reason }}}}
+ {{{{ (h.closed_at or '-')[:16] }}}}
+ {t}>
+ <{t} class="pos-meta">
+ 上: {{{{ h.upper }}}} 下: {{{{ h.lower }}}}
+ 提醒: {{{{ h.notification_count }}}}
+ {t}>
+ {{% 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 %}}{t}>{{% endif %}}
+ {t}>
+ {{% else %}}
+ <{t} class="pos-empty">暂无历史{t}>
+ {{% endfor %}}
+ {t}>
+ {t}>
+ {t}>
+ {{% 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}>
+ <{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}>
+ <{t} class="rule-tip">
+ 以损定仓:风险 {{{{ risk_percent }}}}% |移动保本:下单可勾选关闭;开启时 {{{{ breakeven_rr_trigger }}}}R 触发(每 1R 阶梯上移),偏移 {{{{ breakeven_offset_pct }}}}%
+ {t}>
+ <{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}>
+
+
+ {t}>
+ <{t} class="card">
+ 实时持仓
+ <{t} class="panel-scroll pos-list">
+ {order_loop}
+ {t}>
+ {t}>
+ {t}>
+ {{% 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 %}
+
+ 订单
+
+ {% for o in orders %}
+
+ #{{ o.id }} {{ o.symbol }} {{ '做多' if o.direction == 'long' else '做空' }}
+
+ {% endfor %}
+
+ 周期
+
+ {% for tf in ['1m','3m','5m','15m','30m','1h','4h','1d'] %}
+ {{ tf }}
+ {% endfor %}
+
+ 刷新
+
+
+ {% 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 %}
+
+ 订单
+
+ {% for o in orders %}
+
+ #{{ o.id }} {{ o.symbol }} {{ '做多' if o.direction == 'long' else '做空' }}
+
+ {% endfor %}
+
+ 周期
+
+ {% for tf in ['1m','3m','5m','15m','30m','1h','4h','1d'] %}
+ {{ tf }}
+ {% endfor %}
+
+ 刷新
+
+
+ {% 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 %}
+
+ 订单
+
+ {% for o in orders %}
+
+ #{{ o.id }} {{ o.symbol }} {{ '做多' if o.direction == 'long' else '做空' }}
+
+ {% endfor %}
+
+ 周期
+
+ {% for tf in ['1m','3m','5m','15m','30m','1h','4h','1d'] %}
+ {{ tf }}
+ {% endfor %}
+
+ 刷新
+
+
+ {% 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("" + parseInline(trimmed) + " ");
+ return;
+ }
+ var olm = trimmed.match(/^\d+\.\s+(.+)$/);
+ if (olm) {
+ if (!inOl) {
+ closeLists();
+ html.push("");
+ inOl = true;
+ }
+ html.push("" + parseInline(olm[1]) + " ");
+ 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
+ ? 'Call '
+ : "—") +
+ ' ' +
+ row.strike +
+ ' ' +
+ (put ? moneynessBadge(put) : "—") +
+ ' ' +
+ putAsk +
+ " " +
+ (put
+ ? '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 += "" + roleLabel(leg.leg_role) + " ";
+ html += "" + contract + " ";
+ html += "" + side + " ";
+ html += "" + fmt(leg.size, leg.leg_role === "perp" ? 4 : 0) + " ";
+ html += "" + fmt(leg.avg_open, 4) + " ";
+ html += "" + (leg.premium != null ? fmt(leg.premium, 4) : "—") + " ";
+ html += "" + (leg.status || "—") + " ";
+ html += "" + fmt(leg.realized_pnl, 4) + " ";
+ html += "" + (leg.exchange_ord_id || "—") + " ";
+ html += "" + reasonLabel(leg.close_reason) + " ";
+ html += " ";
+ });
+ }
+ html += "
";
+ 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 (
+ ''
+ );
+ }
+ 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 "" + escapeHtml(h) + " ";
+ })
+ .join("") +
+ " " +
+ "" +
+ rowsHtml +
+ "
"
+ );
+ }
+
+ 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 +
+ " " +
+ '
' +
+ "
"
+ );
+ })
+ .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 `
+
+ ${escapeHtml(o.coin || "-")} ${escapeHtml(o.tf || "")}
+ ${dirHtml}
+ ${escapeHtml(o.pnl == null || o.pnl === "" ? "-" : o.pnl)}U
+
+ ×
+
`;
+ })
+ .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 `
+
+ 品种 周期 方向 下单类型 开仓类型
+ 盈亏U 开仓时间 平仓时间 持仓 心态标签 操作
+
+ ${rows}
+
`;
+ }
+
+ 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 `
+ ${escapeHtml(row.symbol)}
+ ${row.directionHtml}
+ ${escapeHtml(row.pnlText || "-")}
+ `;
+ }
+
+ 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 (
+ '' +
+ '
' +
+ '' +
+ '▶ ' +
+ '' +
+ '' + inst + " " +
+ '' + optTypeLabel(p.opt_type) + " " +
+ " " +
+ '行权 ' + fmt(p.strike, 0) + " · " + fmt(p.pos, 0) + "张 " +
+ " " +
+ '' +
+ (expAttr
+ ? '到期 — '
+ : "") +
+ '' + (net == null ? "—" : fmt(net, 2) + " USDC") + " " +
+ '' +
+ (roi == null ? "—" : fmt(roi, 2) + "%") + " " +
+ " " +
+ " " +
+ '
' +
+ '
' +
+ 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 (
+ '"
+ );
+ }
+
+ 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 (
+ "" +
+ escapeHtml(leg.leg_role || "") +
+ " " +
+ escapeHtml(leg.inst_id || leg.symbol || "") +
+ " " +
+ fmtPnl(leg.realized_pnl) +
+ " " +
+ escapeHtml(leg.close_reason || "") +
+ " "
+ );
+ })
+ .join("") +
+ "
"
+ );
+ }
+ 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 +
+ " " +
+ '
' +
+ "
"
+ );
+ })
+ .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 (
+ ''
+ );
+ }
+ 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 (
+ "" +
+ escapeHtml(leg.leg_role || "") +
+ " " +
+ escapeHtml(leg.inst_id || leg.symbol || "") +
+ " " +
+ fmtPnl(leg.realized_pnl) +
+ " " +
+ escapeHtml(leg.close_reason || "") +
+ " "
+ );
+ })
+ .join("") +
+ "
";
+ } 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 +=
+ '" +
+ body +
+ " ";
+ }
+ 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 合约账户
+
+ ETH
+ BTC
+
+ 做多
+ 做空
+
+
+
加载中…
+
单位说明:价格=USDT · 张数=交易所永续合约张 (精度与 OKX 下单一致) · 盈亏=USDT
+
+ 开仓价 USDT
+ 止盈 USDT
+ 止损 USDT
+ 张数 合约张
+
+
+
+
+
+
期权(列表) · Put 期权账户
+
+ 选择到期日
+ 全部
+ 实值
+ 虚值
+ 刷新链
+
+
+
+
+
+
+ 行权价
+ 实虚值
+ 卖一/张
+ 买一/张
+ 操作
+
+
+
+ 请刷新期权链
+
+
+
+
+ 已选 —
+ 张数 期权张
+
+
+
单位说明:权利金结算币=USDC · 张数=期权张(整张) · 卖一/买一=价格/张.期权买入仅认真实卖一价且卖一深度>0;无深度不可开仓(链上~为参考估算).
+
+
+ 计算
+ 启动计划
+
+
+
+
+
情景测算
+
+
+
+
+
+ 情景
+ 现货价
+ 永续/腿盈亏
+ 期权盈亏
+ 合计≈U
+ 说明
+
+
+
+ 填写参数后点计算
+
+
+
+
+
+
+
+
+
+
期期参数 · ETH 期权账户
+
+ ETH
+ BTC
+
+
+ 上破目标
+ 下破目标
+
+
+
+
震荡突破:设上下两个目标价(USD);触达任一侧重平盈利腿。张数=期权张 · 权利金=USDC
+
+
+
腿A: 尚未选用
+
张数 期权张
+
+
+
+
+
腿B: 尚未选用
+
张数 期权张
+
+
+
+
+
+
+
+
期权 T 型报价
+
+ 选择到期日
+ 刷新链
+
+
+
+
+
+ Call
+ 行权
+ Put
+
+
+ 卖一/张 实虚值 选用
+ K
+ 实虚值 卖一/张 选用
+
+
+
+ 请刷新期权链
+
+
+
+
+ 计算
+ 启动计划
+
+
+
+
+
情景测算
+
+
+
+
+
+ 情景
+ 现货价
+ 腿盈亏
+ 期权
+ 合计≈U
+ 说明
+
+
+
+ 选用两腿并填上破/下破目标后点计算
+
+
+
+
+
+
+
+
+
进行中的计划
+
仅显示已启动但尚未结束的计划;可查看每条腿的当前记录状态。
+
+
+
+
+ ID 类型 标的 合约 状态 目标/止盈止损 开仓 操作
+
+
+
+ 加载中…
+
+
+
+
+
+
+
+
+
历史记录
+
独立对冲计划历史(与普通交易记录分离)。点「成交细节」查看合约名与成交字段。
+
+
+
+
+ ID 类型 标的 合约 状态 合计≈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"{_inline_md(item)} ")
+ out.append(f"{tag}>")
+ 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"{_inline_md(h)} " for h in header) + " ")
+ for row in rows:
+ out.append("" + "".join(f"{_inline_md(c)} " for c in row) + " ")
+ out.append("
")
+ 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("")
+ 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"""
+
+{_checklist_html(checklist)}
+ """
+ elif part == "doc":
+ page_title = f"{label} · 策略说明"
+ source = payload.get("md_source") or ""
+ body = f"""
+
+{payload.get("strategy_html") or ""}
+
+ """
+ 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) #}
+
+
导航显示
+
以下开关控制顶栏导航与系统设置内区块是否显示,保存后立即生效.关键位监控,实盘下单,系统设置为固定项.
+
+
+ 保存导航设置
+
+
+
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 %}
+
+
+ 详细指标
+
+
+
+
胜率
{% 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 %}
+
+ {% 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 %}
+ 恢复监控{% if o.plan_stop_loss and o.plan_take_profit %}并挂止盈止损{% endif %}
+ {% else %}
+ 未找到可恢复的监控记录,需在服务器数据库处理.
+ {% endif %}
+
+ {% else %}
+
+ {% endif %}
+ {% endif %}
+
+
+
+
+
+
挂止盈止损
+
将先撤销该合约已有 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 %}
+ {{ seg.title }}
+ {% endfor %}
+
+
+
+ {% for seg in stats_bundle.segments %}
+
+
+ 日统计
+ 周统计
+ 月统计
+
+ {{ period_stats_pane("day", seg.day) }}
+ {{ period_stats_pane("week", seg.week) }}
+ {{ period_stats_pane("month", seg.month) }}
+
+ {% 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 }}
+
+
+
+
+
+ 数据看板
+ 关键位监控
+ 实盘下单
+ {% 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 %}
+ 系统设置
+
+
+
+ {% 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 %}
+ {{ group.title }}
+ {% endfor %}
+
+
+ {% for group in env_config_groups %}
+
+ {% if group.has_restart %}
+ 本组含需重启项,修改后请点「保存并重启」.
+ {% endif %}
+
+
+ {% 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 %}
+
+{% 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 %}
+
+
+ 详细指标
+
+
+
+
胜率
{% 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 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 %}
+
+ {% 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 %}
+ 恢复监控{% if o.plan_stop_loss and o.plan_take_profit %}并挂止盈止损{% endif %}
+ {% else %}
+ 未找到可恢复的监控记录,需在服务器数据库处理.
+ {% endif %}
+
+ {% else %}
+
+ {% endif %}
+ {% endif %}
+
+
+
+
+
+
挂止盈止损
+
将先撤销该合约已有 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 %}
+ {{ seg.title }}
+ {% endfor %}
+
+
+
+ {% for seg in stats_bundle.segments %}
+
+
+ 日统计
+ 周统计
+ 月统计
+
+ {{ period_stats_pane("day", seg.day) }}
+ {{ period_stats_pane("week", seg.week) }}
+ {{ period_stats_pane("month", seg.month) }}
+
+ {% 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 @@
+{# 统一顶栏:状态 + 筛选(上)· 统计条(下) #}
+
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 @@
+{# 资金与统计条(顶栏 / 系统设置共用,单行展示) #}
+
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 %}
+
+
+
+ 允许北京时间 {{ reset_hour }}:00 前开仓(斐波成交登记,人工下单)
+
+
+ {% 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 }}
+
+
+
+
+
+
+
{{ pwa_app_name }}
+
登录 · {{ exchange_display }}
+ {% with messages = get_flashed_messages() %}
+ {% if messages %}
+
{{ messages[0] }}
+ {% endif %}
+ {% endwith %}
+
+
+
+
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' %}
+
+
+
+
+
+ 性质
+
+ {% for cat in entry_model_categories %}
+
+ {{ cat.label }}
+
+ {% endfor %}
+
+
+
+
+
+ 类型
+
+ {% for cat in entry_model_categories %}
+
+ {% for opt in cat.options %}
+
+ {{ opt.label }}
+
+ {% endfor %}
+
+ {% endfor %}
+
+
+
+
+
+ 趋势单
+
+
+
+{% elif order_entry_profile == 'intraday' %}
+
+
+
+ 开仓类型
+
+ {% for opt in intraday_entry_model_options %}
+
+ {{ opt.label }}
+
+ {% endfor %}
+
+
+
+
+
+{% 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 结果 操作
+
+
+
+ 加载中…
+
+
+
+
+
+
+
+
+
交易复盘记录上传(含截图)
+ 收起
+
+
已从交易记录填入,请补充主观原因后保存.
+
+
+
+
+
+
+
交易复盘记录
+
已保存的复盘(每页5条).
+
+
+
+
+
+
AI历史复盘
+
日/周 AI 复盘历史(每页5条).
+
+
+
+
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 %}
+ {{ tab.title }}
+ {% 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 档,可展开全部.平仓仅买一限价,见说明.
+
+ ETH
+ BTC
+ 选择到期日
+
+ 列表
+ T 型
+
+
+ 看涨 Call
+ 看跌 Put
+
+ 全部
+ 实值
+ 虚值
+
+ 展开全部
+
+ 刷新链
+
+
+
+
+
+
+ 行权价
+ 类型
+ 合约
+ 卖一/张
+ 买一/张
+ 到期平衡
+ 距平衡
+ 操作
+
+
+ Call
+ 跨式
+ Put
+
+
+ 卖一/张
+ 类型
+ 操作
+ 行权价
+ 双买/币
+ 平衡带
+ 类型
+ 卖一/张
+ 操作
+
+
+
+ 请选择到期日
+
+
+
+
+
+
+
+
下单
+
+
+
卖一/张 —
+
买一/张 —
+
参考标记价 —
+
张数 —
+
ETH 数量 —
+
预估权利金 —
+
合约杠杆 —
+
到期平衡 —
+
距平衡 —
+
+
+ 目标位(指数)
+
+ 预计价值
+ —
+ 盈利
+ —
+ 目标杠杆
+ —
+ 目标价=监控指数;到位后按买一限价平仓;无止损,到期即止损
+
+
+ 指定张数
+
+ 按可用余额打满
+ 指定币数量
+
+
+ 限价买入 @ 卖一
+
+
+
+
+
+
委托
+ 刷新
+
+ 平仓限价超 10 分未成交将自动撤销
+
+
+
+
+
+
+
+
+
+
持仓
+ 刷新
+
+
+ 当前持仓
+ 数据统计
+ 期权历史
+
+
+
+
+
+
+ 买一平仓规则说明
+
+
平仓前重新读盘口并校验有效流动性;市价平仓已禁用。
+
+ 本轮只锁买一 :张数 = 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条).点「复盘」填写表单;保存后进入下方复盘记录.
+
+
+ 标的:全部
+ ETH
+ BTC
+
+
+ Call/Put:全部
+ Call
+ Put
+
+
+
+
+
+ 含已归属对冲的期权腿
+
+
+
期权交易记录
+
+
+
+
+ 类型
+ 标的/合约
+ 盈亏
+ 开/平
+ 持有
+ 操作
+
+
+
+ 加载中…
+
+
+
+
+
+
+ {# 2. 复盘上传(默认隐藏,点交易「复盘」后显示) #}
+
+
复盘记录上传(含截图)
+
截图槽位与合约复盘相同(5m / 15m / 1h / 4h).
+
+
+
+ {# 3. 已复盘记录 + 详情 #}
+
+
复盘记录
+
已保存的复盘(每页5条).点一行查看详情.
+
+
+
+
+ 类型
+ 标的/合约
+ 盈亏
+ 策略
+ 结果
+ 复盘时间
+
+
+
+ 加载中…
+
+
+
+
+
+
+
复盘详情
+ 编辑
+ 收起
+
+
+
+
+
+
+
+ {# 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 现货市价单.
+
+
+ USDT → USDC
+ USDC → USDT
+
+
+ 全部兑换
+ 市价兑换
+
+
+
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 @@
+
+
主账户内
+
+
+ USDC
+ USDT
+
+
+ from: 资金
+ from: 交易
+
+
+ to: 交易
+ to: 资金
+
+
+ 全部划转
+ 划转
+
+
+
+
+
+
+ 主子账户
+ ({{ instance_settings.options_sub_account or '未配置' }})
+
+
+
+ 主 → 子
+ 子 → 主
+
+
+ USDT
+ USDC
+
+
+ from: 资金
+ from: 交易
+
+
+ to: 交易
+ to: 资金
+
+
+ 全部划转
+ 划转
+
+
+
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"{inline_md(c)} " for c in hdr) + " ")
+ for row in rows[2:]:
+ cells = [c.strip() for c in row.strip("|").split("|")]
+ out.append("" + "".join(f"{inline_md(c)} " for c in cells) + " ")
+ out.append("
")
+ 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 }};持仓中不划转 并微信通知)
+
+
+
+
+
+ from: funding
+ from: swap
+ from: spot
+
+
+ to: swap
+ to: funding
+ to: spot
+
+ 手动划转
+
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) -%}
+
+
+
+
+
+
+
+
+
+
+
+ 方向(必选)
+ 做多
+ 做空
+
+
+ 下单类型(必选)
+ {% for ot in order_type_options %}
+ {{ ot }}
+ {% endfor %}
+
+
+ 开仓类型(必选)
+ {% for er in entry_reason_options %}
+ {{ er }}
+ {% endfor %}
+
+
+ 离场触发(必选)
+ 止盈
+ 保本止盈
+ 移动止盈
+ 时间平仓
+ 强制清仓
+ 手动平仓
+ 止损
+ 其他
+
+
+ 保本后盯盘:否 保本后盯盘:是
+
+{%- 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') }}
+ 关键位
+
+ 无(仅看K线)
+ {% for k in key_list %}
+ #{{ k.id }} {{ k.symbol }} {{ k.monitor_type }} {{ '做多' if k.direction == 'long' else '做空' }}
+ {% endfor %}
+
+ 周期
+
+ {% for tf in ['1m','3m','5m','15m','30m','1h','4h','1d'] %}
+ {{ tf }}
+ {% endfor %}
+
+ K线数
+
+ 100
+ 200
+
+ 刷新
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 %}
+
+
+
+
+
关键位历史
+
失效或已结案的关键位 · 点击展开详情
+
+ {% 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 }} 分
+
+
+
+
+
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 %}
+
+ 订单
+
+ {% for o in orders %}
+
+ #{{ o.id }} {{ o.symbol }} {{ '做多' if o.direction == 'long' else '做空' }}
+
+ {% endfor %}
+
+ 周期
+
+ {% for tf in ['1m','3m','5m','15m','30m','1h','4h','1d'] %}
+ {{ tf }}
+ {% endfor %}
+
+ 刷新
+
+
+ {% 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) }} 条结束快照(按结束时间排序).
+ 趋势回调与顺势加仓分栏展示;点击行展开详情.结束计划,保本移交,止盈止损会自动写入.
+
+
+
+
币种
+
+ 全部
+ {% for sym in strategy_record_symbols %}
+ {{ sym }}
+ {% endfor %}
+
+
+
时间
+
+ 最新优先
+ 最早优先
+
+
+
+ 筛选
+ 盈利
+ 亏损
+ 未补仓
+ 补仓
+ 重置
+
+
+
+
+
+
+ 趋势回调记录
+ {{ 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 %}
+
+ {{ 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 '—' }}
+
+ {% endfor %}
+
+ {% 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 %}
+
+ {{ 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 '—' }}
+
+ {% endfor %}
+
+ {% 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 o in roll_monitors %}
+
+ {{ o.symbol }} {{ '多' if o.direction=='long' else '空' }} #{{ o.id }} · 风险{{ o.risk_percent or default_risk_percent }}%
+
+ {% endfor %}
+
+
+
+ 市价加仓
+ 斐波 0.618
+ 斐波 0.786
+ 突破加仓
+
+
+
+
+
+
+
+
+
+ 预览
+ 执行滚仓
+
+
+
+
+
活跃滚仓组
+
+
+ ID 币种 方向 腿数 首仓张数 加仓后张数 首仓TP 当前SL 当前均价 止盈盈利U 状态
+ {% for g in roll_groups %}
+
+ {{ 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 %}
+ 滚仓中
+
+ {% else %}
+ 暂无
+ {% endfor %}
+
+
+
+
最近滚仓腿
+
+
+ # 组 方式 张数 触发/限价 新SL 状态 操作
+ {% for leg in roll_legs %}
+
+ {{ 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 %}
+
+
+ {% else %}
+ 暂无
+ {% endfor %}
+
+
+
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 %}
+
+
+
+ 档位 触发/参考价 张数 加仓后均价 止盈盈利(U) 止损(U) 盈亏比
+ {% for row in trend_preview_levels %}
+
+ {{ 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 %}
+
+ {% endfor %}
+
+
+
+
+
+ 确认执行(实盘)
+
+
+
+ 取消预览
+
+
+
+
+ {% 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 %}
+
+
补仓计划明细
+
+ 档位 触发价 张数 加仓后均价 止盈盈利(U) 止损(U) 盈亏比 状态
+ {% for lv in t.dca_levels %}
+
+ {{ 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 }}
+
+ {% endfor %}
+
+
+ {% 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 %}
+
+ 选择币种
+ {% for sym in trade_policy.symbol_whitelist %}
+ {{ sym }}/USDT
+ {% endfor %}
+
+{% 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 %}
+
+ {% if include_empty %}方向 {% endif %}
+ {% if trade_policy.allows_long %}做多 {% endif %}
+ {% if trade_policy.allows_short %}做空 {% endif %}
+
+{% 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 切日
+
+
+
+
+
+ ${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 `
+ ${esc(o.label || o.type || "委托")}
+ ${fmt(o.amount, 4)}
+ ${trig}
+ 撤单
+ `;
+ })
+ .join("");
+ return ``;
+ }
+
+ 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}
+
+
+ `;
+ }
+
+ 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 `
+
补仓计划明细
+
+ 档位 触发价 张数 加仓后均价 止盈盈利(U) 止损(U) 盈亏比 状态
+ ${rows}
+
+
`;
+ }
+
+ 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}
+
+
+
`;
+ }
+
+ 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 ``;
+ }
+
+ 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 `
+
+
+ ${esc(symbol)} ${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 `
+ ${esc(x.symbol)} ${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 ``;
+ }
+
+ 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 += `
+ ${esc(shortOptionsInst(p.inst_id))}
+ ${esc(optType)}
+ ${esc(p.pos)}
+ ${optionsExpiryCdHtml(p.exp_time_ms != null ? p.exp_time_ms : p.exp_time)}
+ ${renderOptionsTargetCell(target)}
+ ${net == null ? "—" : fmt(net, 2)}
+ ${roi == null ? "—" : esc(Number(roi).toFixed(2)) + "%"}
+ `;
+ });
+ html += "
";
+ 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}
+
+
+
`;
+ }
+
+ 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 `
+ ${esc(f.label || f.key)}${note}
+
+
`;
+ })
+ .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}
+ 保存
+
+
+
`;
+ }
+
+ 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 " +
+ "
" +
+ '
' +
+ '
' +
+ '' + esc(yTop) + " " +
+ '' + esc(yMid) + " " +
+ '' + esc(yBot) + " " +
+ "
" +
+ '
' +
+ '
' +
+ gridLines +
+ ' ' +
+ dots +
+ " " +
+ '
' +
+ "" + xStart + " " +
+ (series.length > 1 ? "" + xEnd + " " : "") +
+ "
" +
+ "
" +
+ "
" +
+ '" +
+ "
"
+ );
+ }
+
+ 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 (
+ "" +
+ "" +
+ esc(tradeRowExchange(t)) +
+ " " +
+ "" +
+ esc(t.symbol || "—") +
+ " " +
+ "" +
+ esc(t.direction || "—") +
+ " " +
+ '' +
+ fmtDt(t.opened_at) +
+ " " +
+ '' +
+ fmtDt(t.closed_at) +
+ " " +
+ '' +
+ fmtPnl(t.pnl_amount) +
+ " " +
+ "" +
+ esc(quoteTagLabel(t)) +
+ " "
+ );
+ })
+ .join("") +
+ "
";
+ }
+
+ 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 (
+ '' +
+ '
' +
+ '' +
+ esc(q.quote_date) +
+ " " +
+ '' +
+ esc(quotePreview(q.content)) +
+ " " +
+ (selected ? "" : '查看 ') +
+ " " +
+ (selected
+ ? '
' +
+ '
' +
+ esc(q.content || "(空)") +
+ "
" +
+ '
' +
+ '修改 ' +
+ '删除 ' +
+ 'AI对话 ' +
+ "
"
+ : "") +
+ "
"
+ );
+ })
+ .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 (
+ "" +
+ "" +
+ esc(tradeRowExchange(t)) +
+ " " +
+ "" +
+ esc(t.symbol || "—") +
+ " " +
+ "" +
+ esc(t.direction || "—") +
+ " " +
+ '' +
+ fmtDt(t.opened_at) +
+ " " +
+ '' +
+ fmtDt(t.closed_at) +
+ " " +
+ '' +
+ fmtPnl(t.pnl_amount) +
+ " " +
+ "" +
+ esc(tag) +
+ " " +
+ '图表 '
+ );
+ })
+ .join("") +
+ "
";
+ 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 =
+ '";
+
+ 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 = '";
+ if (!rows.length) {
+ html += '暂无备份文件
';
+ elList.innerHTML = html;
+ return;
+ }
+ html += '文件 大小 时间 ';
+ rows.forEach(function (row) {
+ html +=
+ "" +
+ esc(row.name) +
+ " " +
+ fmtBytes(row.size) +
+ " " +
+ esc(row.modified_at || "") +
+ ' ' +
+ '下载 ' +
+ '恢复 ';
+ });
+ html += "
";
+ 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 +=
+ "" +
+ "" +
+ 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" : "—") +
+ " " +
+ " ";
+ });
+ html += "
";
+ 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 +=
+ "" +
+ "" +
+ 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" : "—") +
+ " " +
+ " ";
+ });
+ table += "
";
+ 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 =
+ '" +
+ '" +
+ '" +
+ '" +
+ '' +
+ '';
+ 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) => ``)
+ .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(/暂无账户配置 ';
+ return;
+ }
+ elAccounts.innerHTML = accounts
+ .map(function (ac) {
+ const monitored = !!ac.monitored;
+ const offCls = monitored ? "" : " is-off";
+ const st = accountStatus(ac);
+ const clickable = monitored ? "" : ' disabled aria-disabled="true"';
+ const name = ac.name || ac.key || "—";
+ const total =
+ monitored && ac.data_ok ? fmt(ac.total_usdt, 2) + " U" : "—";
+ const funding =
+ monitored && ac.funding_usdt != null ? fmt(ac.funding_usdt, 2) + " U" : "—";
+ const trading =
+ monitored && ac.trading_usdt != null ? fmt(ac.trading_usdt, 2) + " U" : "—";
+ const optFunding =
+ monitored && ac.options_funding_usdt != null ? fmt(ac.options_funding_usdt, 2) + " U" : "";
+ const optTrading =
+ monitored && ac.options_trading_usdt != null ? fmt(ac.options_trading_usdt, 2) + " U" : "";
+ const optLine =
+ optFunding || optTrading
+ ? '期权户 ' +
+ (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 (
+ '' +
+ '' +
+ '
' +
+ esc(name) +
+ " " +
+ '' +
+ st.text +
+ " " +
+ "" +
+ '' +
+ '总资金 ' +
+ '' +
+ total +
+ " " +
+ "
" +
+ '' +
+ '
资金户 ' +
+ funding +
+ "
" +
+ '
交易户 ' +
+ trading +
+ "
" +
+ optLine +
+ '
累计盈亏 ' +
+ periodText +
+ "
" +
+ '
较昨日 ' +
+ deltaText +
+ "
" +
+ '
最大回撤 ' +
+ ddU +
+ " / " +
+ ddPct +
+ "
" +
+ "
" +
+ (monitored
+ ? ''
+ : "") +
+ " "
+ );
+ })
+ .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) =>
+ `${esc(s.label)} `
+ )
+ .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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ 复盘系统中控
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
PLN 开仓计划
+
计划录入 · 进行中跟踪 · 历史归档与胜率统计
+
+
+ 刷新
+
+
+
+
+
+
+
+
MON 监控区
+
+
+
+
+ 服务器状态
+ 加载中…
+
+
+
+
+
+
+
+
+
+ 网络
+ 实时
+
+
+ ↑ —
+ ↓ —
+
+
+
+
+
+
+
+
+ 操作 · 刷新 / 紧急全平
+
+
+
+ 立即刷新
+
+ 后台每 5 秒聚合 · SSE 自动更新
+
+ 全局紧急全平
+
+
+
+
+
+
+
+
+
+
MKT 行情区
+
+
+
+ 选币 · 周期 · 加载
+ BTC/USDT · 1d
+
+
+
+
+
+
+
+
+
+ 开 —
+ 高 —
+ 低 —
+ 收 —
+ 量 —
+ 振幅 —
+
+
+
+
+
+
+
+
+ 入场 —
+ 止损 —
+ 止盈 —
+ 张数 —
+ 浮盈亏 —
+ 清除标记
+
+
+
+
+
+
+
+
+
+
IN 内照明心
+
交易记录 · 交易日历 · 图表概览 · 复盘语录
+
+
+
+
+
+ 交易记录
+ 交易日历
+ 图表概览
+ 复盘语录
+
+
+
+
+
+
+
+
交易日历
+ —
+
+
+
+
+
当日交易
+
+
+
+
点击日历中的日期,在下方查看当日交易记录.
+
+
+
+
+
+
+
+
+
+
+
QT 语录
+
复盘语录博客流 · 按交易日分组 · 可展开全文
+
+
+
+
+
+
+
+
+
+
+
DASH 数据看板
+
三户当日总览 · 有仓持仓表 · 平仓流水 · SSE 推送更新
+
+
+
+
+ ⚠ 风险预警
+
+
+
+
+
+ 平仓明细 · CLOSED TRADES
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
CAP 资金概况
+
+ 统计说明
+ 加载配置中…
+
+
+
+
+ EQUITY FEED
+
+
+
+ 同步快照
+
+
+
+
+
+
累计盈亏 · SINCE START
+
—
+
相对统计起点
+
+
+
+
+
+
+
+ EQUITY CURVE
+ — TRADING DAYS
+
+
+
+
+
// 分户资金
+
点击卡片放大查看资金曲线与回撤
+
+
+
+
+
+
+
+
+
+
+
+ 总资金 —
+ 资金户 —
+ 交易户 —
+ 较昨日 —
+ 最大回撤 —
+
+
+
+
+
+
+
+
AI 教练
+
交易教练 / 普通聊天 / 交易监管 · 右侧可回看历史会话
+
+
+ 交易教练
+ 普通聊天
+ 交易监管
+ 历史
+ 新开
+
+
+
+
+
+ 交易教练
+ 普通聊天
+ 交易监管
+
+
新开对话
+
+
+
+
+
聊天
+
+
+
+
+
+
+
+
+
+ 附件
+
+ 发送
+
+
+
+
+
+
+
+
+
+
+
+
+
CAL 策略计算器
+
历史行情测算 · 以损定仓 · 价格均为手动输入
+
+
+ 刷新
+
+
+
+
+
+ 趋势回调 趋势回调计算器
+
+
+ 滚仓 滚仓计算器
+
+
+
+
+ 趋势回调计算器
+ 逻辑与实例策略页一致:首仓 50% + 补仓网格;止损金额 = 资金 × 风险%.
+
+
+
+ 计算
+
+
+
+
+
+
+ 滚仓计算器
+ 首仓按「单次风险」以损定仓;每次滚仓后合并持仓打到新止损 ≈ 单次风险;止盈锁定首仓价不变.最多 3 次滚仓.
+
+
+
+ 滚仓加仓(最多 3 次)
+ + 添加滚仓
+
+
+
+ 计算
+
+
+
+
+
+
+
+
+
+
+
+
STR 策略说明
+
三所策略文档 · 开仓前检查清单 · 可打印 / 下载
+
+
+ 下载 HTML
+
+
+
+
+
+
+
+
+
+
HLP 使用说明
+
中控与实例 · 日常操作指南(不含部署说明)
+
+
+
+
+
+
+
+
+
+
+
+
LOG 系统日志
+
三所实例与中控 PM2 进程日志 · 实时输出与报错分离展示
+
+
+ 立即刷新
+ 暂停刷新
+
+
+
+
+
+
+
实时日志
+ stdout
+
+ 加载中…
+
+
+
+
报错日志
+ stderr
+
+ 加载中…
+
+
+
+
+
+
+
CFG 系统设置
+
登录账号,导航显示,交易所地址与监控能力
+
+
+ 配置说明
+
+ 交易所等配置保存后写入 hub_settings.json;登录账号密码写入 manual_trading_hub/.env.
+ Flask / Agent 填本机地址即可;复盘链接可留空(由 Flask 地址自动生成).
+ HUB_DISABLED_IDS 可强制关闭账户;HUB_BRIDGE_TOKEN 与实例一致,或实例 APP_AUTH_DISABLED=true.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 账户密码
+ 显示与导航
+ 宏观数据
+ 交易监管
+ 备份恢复
+ 交易所账户
+ AI 配置
+
+
+
+
+
账户密码
+
+ 修改中控网页登录账号密码,写入 manual_trading_hub/.env 后需重启中控生效.当前用户:—
+
+
+ 保存密码
+
+
+
+
+
+
+
+
+
+
+
+
+
备份与恢复
+ 保存
+
+
+ 打包三所 crypto.db,中控 K 线/归档等 SQLite,hub_settings.json 与 .env(可选).
+ 恢复前会自动做一次 pre-restore 快照,并尝试 pm2 restart all.
+
+
+
+ 立即备份
+
+
+
+
+ 上传备份包恢复(.zip)
+
+
+ 上传并恢复
+
+
+
+
+
+
+
交易所账户
+
+ 添加交易所
+ 全部保存
+
+
+
+
+
+
+
+
AI 复盘(统一配置)
+ 保存并同步
+
+
+ 保存后写入 manual_trading_hub/.env,并强制同步 至三所实例 .env(OKX / Binance / Gate).
+ 实例 env 配置页已不再提供 AI 项,请仅在此修改.密钥留空表示不修改原值.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 监控
+ 行情
+ 计算
+ AI
+ 更多
+
+
+
+
+
+
+
挂止盈 / 止损
+
+
+ 止损价
+
+
+
+ 止盈价
+
+
+
先撤销该合约全部条件单,再挂新止盈与止损(三所统一).
+
+ 取消
+ 确认挂单
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
登录 · 复盘系统中控
+
+
+
+
+
+
+
+
+
+
复盘系统中控
+
CRYPTO MONITOR · COMMAND
+
+
+
+
+ 用户名
+
+
+
+ 密码
+
+
+ 进入系统
+
+
+
+
+
+
+
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) =>
+ `
${esc(t.label)} `
+ )
+ .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 +=
+ '
" +
+ esc(s.label) +
+ " ";
+ });
+ 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) + "
" : "") +
+ '' +
+ '入场方案 ' +
+ '' +
+ schemeOptionsHtml(p.entry_scheme || "") +
+ " " +
+ "
" +
+ '' +
+ '结果 ' +
+ '— ' +
+ (meta.results || [])
+ .map(function (r) {
+ return (
+ '" +
+ esc(r.label) +
+ " "
+ );
+ })
+ .join("") +
+ " " +
+ '盈亏 ' +
+ ' ' +
+ '填写结果并归档 ' +
+ "
"
+ );
+ })
+ .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 (
+ '
' +
+ '' +
+ esc(p.plan_date) +
+ " " +
+ '' +
+ esc(p.symbol) +
+ " · " +
+ esc(exchangeLabel(p.exchange_key)) +
+ " " +
+ '' +
+ esc(p.entry_scheme_label || p.entry_scheme) +
+ " " +
+ '' +
+ esc(p.result_label || p.result) +
+ (pnlTxt ? " " + esc(pnlTxt) : "") +
+ " "
+ );
+ })
+ .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 =
+ '
' +
+ "" +
+ esc(dimLabel) +
+ " 计划数 盈利 亏损 胜率 " +
+ " " +
+ rows +
+ "
";
+ }
+
+ 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 (
+ '
" +
+ esc(d.label) +
+ ""
+ );
+ })
+ .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 (
+ '
" +
+ esc(lbl) +
+ " "
+ );
+ })
+ .join("");
+ }
+ return (
+ '
' +
+ '日期 ' +
+ '交易所 ' +
+ opts(meta.exchanges, "exchange_key", "key", "name") +
+ " " +
+ '币种 ' +
+ '类型 ' +
+ opts(meta.plan_types, "plan_type", "value", "label") +
+ " " +
+ '趋势周期 ' +
+ opts(meta.trend_timeframes, "trend_timeframe") +
+ " " +
+ '入场周期 ' +
+ opts(meta.entry_timeframes, "entry_timeframe") +
+ " " +
+ '方向 ' +
+ dirs +
+ " " +
+ '目标位 ' +
+ '当前区间 ' +
+ '入场方案 ' +
+ opts(meta.entry_schemes, "entry_scheme", "value", "label") +
+ " " +
+ '备注 ' +
+ esc(p.note || "") +
+ " " +
+ "
" +
+ '
取消 ' +
+ '保存修改
'
+ );
+ }
+
+ 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
+ ? '' +
+ (isOpen ? "收起" : "展开") +
+ " "
+ : "") +
+ 'AI 复盘 ' +
+ "
"
+ );
+ })
+ .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) =>
+ `
${esc(t.label)} `
+ )
+ .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) => `
☐ ${esc(item)}`)
+ .join("");
+ return `
`;
+ })
+ .join("");
+ }
+ if (footnotesEl) {
+ const notes = cl.footnotes || [];
+ footnotesEl.innerHTML = notes.map((n) => `
${esc(n)} `).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 }};持仓中不划转 并微信通知)
+
+
+
+
+
+ from: funding
+ from: swap
+ from: spot
+
+
+ to: swap
+ to: funding
+ to: spot
+
+ 手动划转
+
+"""
+
+
+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 = """ """
+
+INDEX_HEADER_NEW = """ """
+
+
+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' + THEME_TOGGLE + "
\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(
+ '
开仓(以损定仓) ',
+ '
{{ open_position_button_label }} ',
+ )
+ 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 **不可改,可删**;手动平仓自动结束滚仓监控