diff --git a/AI复盘与模型配置说明.md b/AI复盘与模型配置说明.md index d4dde0f..560ea28 100644 --- a/AI复盘与模型配置说明.md +++ b/AI复盘与模型配置说明.md @@ -1,70 +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 项 | +# 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 index 81f97a1..a6abada 100644 --- a/README.md +++ b/README.md @@ -1,87 +1,87 @@ -# 复盘交易系统(crypto_monitor) - -多交易所 **USDT 永续** 的下单监控、**关键位**、**策略交易**、**止盈止损 / 移动保本** 与 **AI 复盘**,三所独立部署 + 可选 **中控** 聚合监控。 - -**远程仓库**:[https://git.bz121.com/dekun/crypto_monitor.git](https://git.bz121.com/dekun/crypto_monitor.git) - ---- - -## 部署环境(必读) - -| 项 | 约定 | -|----|------| -| 系统 | **Ubuntu 22.04 / 24.04** | -| 用户 | **root** | -| 路径 | **`/opt/crypto_monitor`** | -| 进程 | **PM2**(唯一推荐的常驻方式) | - -**环境详解**(Python 3.10+、Node、PM2 安装与启动顺序):**[docs/ubuntu-server.md](./docs/ubuntu-server.md)** -**一键 venv**:`bash deploy/setup_env.sh` → **[deploy/README.md](./deploy/README.md)** - -```bash -cd /opt -git clone https://git.bz121.com/dekun/crypto_monitor.git crypto_monitor -cd /opt/crypto_monitor -bash deploy/setup_env.sh --install-system-deps -``` - -配置与运维脚本: **[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 与本文冲突,以 **子目录《部署文档》与当前代码** 为准。 +# 复盘交易系统(crypto_monitor) + +多交易所 **USDT 永续** 的下单监控,**关键位**,**策略交易**,**止盈止损 / 移动保本** 与 **AI 复盘**,三所独立部署 + 可选 **中控** 聚合监控. + +**远程仓库**:[https://git.bz121.com/dekun/crypto_monitor.git](https://git.bz121.com/dekun/crypto_monitor.git) + +--- + +## 部署环境(必读) + +| 项 | 约定 | +|----|------| +| 系统 | **Ubuntu 22.04 / 24.04** | +| 用户 | **root** | +| 路径 | **`/opt/crypto_monitor`** | +| 进程 | **PM2**(唯一推荐的常驻方式) | + +**环境详解**(Python 3.10+,Node,PM2 安装与启动顺序):**[docs/ubuntu-server.md](./docs/ubuntu-server.md)** +**一键 venv**:`bash deploy/setup_env.sh` → **[deploy/README.md](./deploy/README.md)** + +```bash +cd /opt +git clone https://git.bz121.com/dekun/crypto_monitor.git crypto_monitor +cd /opt/crypto_monitor +bash deploy/setup_env.sh --install-system-deps +``` + +配置与运维脚本: **[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/crypto_monitor_binance/.env.example b/crypto_monitor_binance/.env.example index 5ffd4ad..8ded315 100644 --- a/crypto_monitor_binance/.env.example +++ b/crypto_monitor_binance/.env.example @@ -1,157 +1,157 @@ # ============================================================================= -# 环境配置模板(可提交 Git)。程序运行时只读取同目录下的 .env。 +# 环境配置模板(可提交 Git).程序运行时只读取同目录下的 .env. # -# 首次部署 / 新机: +# 首次部署 / 新机: # cp .env.example .env -# nano .env # 填入真实密钥、端口、代理等 +# nano .env # 填入真实密钥,端口,代理等 # -# 升级代码(git pull)前建议备份(.env 不在 Git 中,pull 不会覆盖): +# 升级代码(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) +# 服务监听地址(云服务器通常用 0.0.0.0) APP_HOST=0.0.0.0 # 服务端口 APP_PORT=5001 -# 是否开启调试模式(生产建议 false) +# 是否开启调试模式(生产建议 false) APP_DEBUG=false # 登录账号 APP_USERNAME=admin -# 登录密码(请改成你自己的强密码) +# 登录密码(请改成你自己的强密码) APP_PASSWORD=admin123 -# 是否关闭登录校验(局域网可设 true;公网务必 false) +# 是否关闭登录校验(局域网可设 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 +# 中控请求本实例 /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 会话密钥(必须替换为长随机字符串) FLASK_SECRET_KEY=CHANGE_TO_LONG_RANDOM_SECRET -# 企业微信机器人 Webhook(用于行情/风控推送) +# 企业微信机器人 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,可选;默认即可) +# 自动备份(scripts/backup_data.sh + cron,可选;默认即可) # BACKUP_ROOT=/root/backups # BACKUP_RETENTION_DAYS=30 # BACKUP_INSTANCE=crypto_monitor_binance -# 已废弃:资金账户仅显示交易所 funding 余额,不再读取此变量 +# 已废弃:资金账户仅显示交易所 funding 余额,不再读取此变量 # TOTAL_CAPITAL=100 -# 页顶「资金账户」默认仅 Binance Funding 钱包;若 USDT 主要在现货,可改为 true 合并 Spot +# 页顶「资金账户」默认仅 Binance Funding 钱包;若 USDT 主要在现货,可改为 true 合并 Spot # BINANCE_FUNDING_INCLUDE_SPOT=false -# 计仓:risk=以损定仓(默认);full_margin=合约可用×FULL_MARGIN_BUFFER_RATIO 全仓杠杆(须无仓后重启) +# 计仓:risk=以损定仓(默认);full_margin=合约可用×FULL_MARGIN_BUFFER_RATIO 全仓杠杆(须无仓后重启) POSITION_SIZING_MODE=risk -# 方向限制(默认 false=双向均可;true 时按 TRADE_DIRECTION 限制,修改后须重启) -# TRADE_DIRECTION=long_only | short_only | both(或 多/空/双向) +# 方向限制(默认 false=双向均可;true 时按 TRADE_DIRECTION 限制,修改后须重启) +# TRADE_DIRECTION=long_only | short_only | both(或 多/空/双向) TRADE_DIRECTION_RESTRICT_ENABLED=false TRADE_DIRECTION=both -# 币种白名单(默认 false=全币种可手输;true 时关键位/下单/策略仅下拉选择) +# 币种白名单(默认 false=全币种可手输;true 时关键位/下单/策略仅下拉选择) TRADE_SYMBOL_RESTRICT_ENABLED=false TRADE_SYMBOL_WHITELIST=BTC,ETH -# 每天起始基数(U) +# 每天起始基数(U) DAILY_START_CAPITAL=30 -# 日内回撤后基数(U) +# 日内回撤后基数(U) DAILY_LOSS_CAPITAL=20 -# 日内盈利后基数(U) +# 日内盈利后基数(U) DAILY_PROFIT_CAPITAL=50 # BTC 默认杠杆倍数 BTC_LEVERAGE=10 # 山寨币默认杠杆倍数 ALT_LEVERAGE=5 -# 交易日重置小时(北京时间) +# 交易日重置小时(北京时间) TRADING_DAY_RESET_HOUR=8 -# 整点前禁止新开仓:true=启用(默认),false=关闭(仍可保留 8 点作为交易日划分) +# 整点前禁止新开仓:true=启用(默认),false=关闭(仍可保留 8 点作为交易日划分) TRADING_DAY_RESET_OPEN_GUARD_ENABLED=true -# 是否开启 Binance 实盘下单(false=只做本地流程,true=真实下单) +# 是否开启 Binance 实盘下单(false=只做本地流程,true=真实下单) LIVE_TRADING_ENABLED=true -# Binance API Key(需开通合约、万向划转等权限) +# Binance API Key(需开通合约,万向划转等权限) BINANCE_API_KEY=REPLACE_WITH_BINANCE_API_KEY # Binance API Secret BINANCE_API_SECRET=REPLACE_WITH_BINANCE_API_SECRET -# 保证金模式:cross=全仓,isolated=逐仓 +# 保证金模式:cross=全仓,isolated=逐仓 BINANCE_MARGIN_MODE=cross -# 持仓模式:hedge=双向(需账户开启双向持仓,下单带 positionSide);oneway=单向 +# 持仓模式:hedge=双向(需账户开启双向持仓,下单带 positionSide);oneway=单向 BINANCE_POSITION_MODE=hedge -# 条件单触发参考价:CONTRACT_PRICE=最新成交价 MARK_PRICE=标记价(更易触发时用标记价) +# 条件单触发参考价:CONTRACT_PRICE=最新成交价 MARK_PRICE=标记价(更易触发时用标记价) BINANCE_TRIGGER_WORKING_TYPE=CONTRACT_PRICE -# 页面与浏览器标签展示的交易所名称(多环境区分时可改成例如 Binance·测试网) +# 页面与浏览器标签展示的交易所名称(多环境区分时可改成例如 Binance·测试网) # EXCHANGE_DISPLAY_NAME=Binance # 企业微信推送里展示的账户备注 # BINANCE_ACCOUNT_LABEL=binance实盘账户 -# 平仓盈亏估算:false=按仓位历史口径(已实现盈亏+手续费,不含资金费);true=含资金费 +# 平仓盈亏估算:false=按仓位历史口径(已实现盈亏+手续费,不含资金费);true=含资金费 # BINANCE_PNL_INCLUDE_FUNDING=false # ============================================================================= -# 关键位程序自动下单(与 POSITION_SIZING_MODE 联动,修改后须重启 PM2) +# 关键位程序自动下单(与 POSITION_SIZING_MODE 联动,修改后须重启 PM2) # ============================================================================= -# 默认 false = 关闭所有关键位程序自动单(箱体/收敛/斐波/假突破/触价) +# 默认 false = 关闭所有关键位程序自动单(箱体/收敛/斐波/假突破/触价) # -# POSITION_SIZING_MODE=risk(以损定仓) -# false → 不执行任何关键位自动单;支撑/阻力提醒、人工下单、顺势加仓不受影响 -# true → 允许关键位全套自动(含触价) +# POSITION_SIZING_MODE=risk(以损定仓) +# false → 不执行任何关键位自动单;支撑/阻力提醒,人工下单,顺势加仓不受影响 +# true → 允许关键位全套自动(含触价) # -# POSITION_SIZING_MODE=full_margin(全仓杠杆,须无仓切换) +# POSITION_SIZING_MODE=full_margin(全仓杠杆,须无仓切换) # false → 不执行触价自动单 -# true → 仅回调/突破触价可程序自动开仓;箱体/斐波等仍禁止 +# true → 仅回调/突破触价可程序自动开仓;箱体/斐波等仍禁止 # -# 顺势加仓、趋势回调不受本开关控制;全仓模式下策略自动仍禁止。 +# 顺势加仓,趋势回调不受本开关控制;全仓模式下策略自动仍禁止. KEY_AUTO_ORDER_ENABLED=false # ============================================================================= -# 关键位门控(页面「关键位监控」规则条与 _key_hard_checks 共用) +# 关键位门控(页面「关键位监控」规则条与 _key_hard_checks 共用) # ============================================================================= -# 【周期】门控 K 线周期,如 5m、15m;仅影响关键位硬条件,不改变顶栏分区 +# 【周期】门控 K 线周期,如 5m,15m;仅影响关键位硬条件,不改变顶栏分区 KLINE_TIMEFRAME=5m -# 【确认K】闭合 K 序列中的棒偏移:突破棒默认 -2(倒数第2根),确认棒默认 -1(倒数第1根) +# 【确认K】闭合 K 序列中的棒偏移:突破棒默认 -2(倒数第2根),确认棒默认 -1(倒数第1根) KEY_CONFIRM_BREAKOUT_BAR=-2 KEY_CONFIRM_BAR=-1 -# 【量能】突破棒成交量 > 前 N 根均量 × 倍数(默认 N=20,倍数=1.3 即放大 30%) +# 【量能】突破棒成交量 > 前 N 根均量 × 倍数(默认 N=20,倍数=1.3 即放大 30%) KEY_VOLUME_MA_BARS=20 KEY_VOLUME_RATIO_MIN=1.3 -# 【箱体/收敛】突破K收盘越过关键位(占该侧价格%)的下限;无上限(过猛由计划RR过滤) +# 【箱体/收敛】突破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 名(添加关键位与运行时门控均校验) +# 【日成交量排名】品种须在该排名前 N 名(添加关键位与运行时门控均校验) KEY_DAILY_VOLUME_RANK_MAX=30 -# 【关键位自动开仓盈亏比】按确认K收盘 E 计算,严格大于该值才市价开仓(如 1.5 表示须 >1.5:1) +# 【关键位自动开仓盈亏比】按确认K收盘 E 计算,严格大于该值才市价开仓(如 1.5 表示须 >1.5:1) KEY_AUTO_MIN_PLANNED_RR=1.5 -# 止损:突破 K 极值向外缓冲的百分比(默认 0.5 即 0.5%) +# 止损:突破 K 极值向外缓冲的百分比(默认 0.5 即 0.5%) KEY_STOP_OUTSIDE_BREAKOUT_PCT=0.5 -# 趋势单方案:止损在突破 K 极值外侧的百分比(默认 1 即 1%) +# 趋势单方案:止损在突破 K 极值外侧的百分比(默认 1 即 1%) KEY_TREND_STOP_OUTSIDE_PCT=1 # ============================================================================= -# 交易执行 / 人工风控(页面「实盘下单」) +# 交易执行 / 人工风控(页面「实盘下单」) # ============================================================================= -# 【最大同时持仓】active 订单数达到该值后禁止人工与关键位自动再加仓(默认 1=单仓) +# 【最大同时持仓】active 订单数达到该值后禁止人工与关键位自动再加仓(默认 1=单仓) MAX_ACTIVE_POSITIONS=1 -# 【人工下单最低盈亏比】按当前价与 SL/TP 计算,低于该值前后端均拒绝(默认 1.4,即须 >=1.4:1) +# 【人工下单最低盈亏比】按当前价与 SL/TP 计算,低于该值前后端均拒绝(默认 1.4,即须 >=1.4:1) MANUAL_MIN_PLANNED_RR=1.4 # 【关键位连开计仓】true=已有持仓时关键位自动单仍按「无仓时」资金快照算保证金基数 KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT=true -# 【单日开仓 AI 提醒】本交易日开仓达到该次数时推送企业微信 AI 克制提醒(不拦单) +# 【单日开仓 AI 提醒】本交易日开仓达到该次数时推送企业微信 AI 克制提醒(不拦单) DAILY_OPEN_ALERT_THRESHOLD=5 -# 【单日开仓硬上限】本交易日开仓次数>=该值后禁止一切新开仓直至下一交易日(北京时间 TRADING_DAY_RESET_HOUR 切日);0=不启用 +# 【单日开仓硬上限】本交易日开仓次数>=该值后禁止一切新开仓直至下一交易日(北京时间 TRADING_DAY_RESET_HOUR 切日);0=不启用 DAILY_OPEN_HARD_LIMIT=0 # ============================================================================= -# 账户冷静期 / 日冻结风控(手动平仓、外部平仓、复盘情绪标签) +# 账户冷静期 / 日冻结风控(手动平仓,外部平仓,复盘情绪标签) # 详见 docs/account-risk-cooldown.md # ============================================================================= RISK_CONTROL_ENABLED=true @@ -160,74 +160,74 @@ 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 未就绪误判) +# 重启后多少秒内不做「外部平仓」同步(避免 API 未就绪误判) RECONCILE_STARTUP_GRACE_SEC=90 -# 连续多少次轮询确认交易所空仓后,才记为外部平仓(默认 3 次 ≈ 9 秒) +# 连续多少次轮询确认交易所空仓后,才记为外部平仓(默认 3 次 ≈ 9 秒) RECONCILE_FLAT_CONFIRM_POLLS=3 -# 使用可用资金时的缓冲比例(如0.98代表用98%) +# 使用可用资金时的缓冲比例(如0.98代表用98%) FULL_MARGIN_BUFFER_RATIO=0.98 # ============================================================================= -# 自动划转(页顶「将 swap 补足到 XU」;与 DAILY_START_CAPITAL 独立,需一致时请设为相同值) +# 自动划转(页顶「将 swap 补足到 XU」;与 DAILY_START_CAPITAL 独立,需一致时请设为相同值) # ============================================================================= AUTO_TRANSFER_ENABLED=false -# 交易账户(swap)目标余额 U:每日 8 点(北京)自动划入或划出至 funding;持仓中不划转 +# 交易账户(swap)目标余额 U:每日 8 点(北京)自动划入或划出至 funding;持仓中不划转 AUTO_TRANSFER_AMOUNT=30 AUTO_TRANSFER_FROM=funding AUTO_TRANSFER_TO=swap TRANSFER_CCY=USDT -# 北京时间该整点小时内尝试;账簿按 UTC 自然日去重 +# 北京时间该整点小时内尝试;账簿按 UTC 自然日去重 AUTO_TRANSFER_BJ_HOUR=8 -# 强制清仓整点(北京时间,默认 0=凌晨00点) +# 强制清仓整点(北京时间,默认 0=凌晨00点) FORCE_CLOSE_BJ_HOUR=0 -# 是否启用强制清仓(默认关闭,true 才会在整点执行) +# 是否启用强制清仓(默认关闭,true 才会在整点执行) FORCE_CLOSE_ENABLED=false -# 推送与AI超时(秒) +# 推送与AI超时(秒) WECHAT_TIMEOUT_SECONDS=10 AI_TIMEOUT_SECONDS=120 -# AI 提供方:openai(默认,OpenAI 兼容网关)| ollama(本机 Ollama) +# AI 提供方:openai(默认,OpenAI 兼容网关)| ollama(本机 Ollama) AI_PROVIDER=openai -# OpenAI 兼容接口(示例:https://op.bz121.com/v1 ,账号见 gateway.json) +# 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(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) 先在本机建立隧道(示例): +# Binance 代理(可选):本机网络不稳定时通过 SSH 动态转发 SOCKS5 出口 +# 1) 先在本机建立隧道(示例): # ssh -N -D 127.0.0.1:1080 user@vps -o ServerAliveInterval=30 -o ExitOnForwardFailure=yes -# 2) 再启用下面这一行(推荐 socks5h,让远端解析域名): +# 2) 再启用下面这一行(推荐 socks5h,让远端解析域名): # BINANCE_SOCKS_PROXY=socks5h://127.0.0.1:1080 # -# 如你更偏向 HTTP 代理(VPS 上跑 tinyproxy 之类),可用: +# 如你更偏向 HTTP 代理(VPS 上跑 tinyproxy 之类),可用: # BINANCE_HTTP_PROXY=http://127.0.0.1:3128 # BINANCE_HTTPS_PROXY=http://127.0.0.1:3128 -# 开仓多周期K线图(可选) +# 开仓多周期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 -# 以损定仓(按交易账户资金的百分比) +# 详见上文 DAILY_OPEN_ALERT_THRESHOLD / DAILY_OPEN_HARD_LIMIT;说明文档 docs/daily-open-limit.md +# 以损定仓(按交易账户资金的百分比) # RISK_PERCENT=2 -# 移动保本触发(达到多少R触发)与偏移(百分比) +# 移动保本触发(达到多少R触发)与偏移(百分比) # BREAKEVEN_RR_TRIGGER=1.0 -# 移动保本阶梯(每多少R继续上移一次,默认1R) +# 移动保本阶梯(每多少R继续上移一次,默认1R) # BREAKEVEN_STEP_R=1.0 # BREAKEVEN_OFFSET_PCT=0.02 -# 开单风格默认值:trend / swing +# 开单风格默认值:trend / swing # DEFAULT_TRADE_STYLE=trend APP_TIMEZONE=Asia/Shanghai -# TRADING_DAY_RESET_HOUR 现在表示「北京时间」整点,默认 8 点起算新交易日;开仓整点限制见 TRADING_DAY_RESET_OPEN_GUARD_ENABLED +# 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 index ea7ee33..09f9046 100644 --- a/crypto_monitor_binance/README.md +++ b/crypto_monitor_binance/README.md @@ -1,22 +1,22 @@ # crypto_monitor_binance -基于 **Flask** 的加密货币 **下单监控 / 关键位监控 / 交易复盘** 小系统,行情与实盘接口统一走 **Binance(USDT-M 永续)**,通过 **ccxt** 访问。 +基于 **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` 控制(最新价 / 标记价) +- **关键位监控**:价格与硬条件校验,企业微信推送(可选) +- **下单监控**:本地风控(含移动保本逻辑),触达止盈/止损后尝试市价平仓并记账 +- **策略交易**:顶栏 `/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`** +- Python 3.10+(建议) +- 依赖:`flask`,`requests`,`ccxt`,`werkzeug`,`Pillow`(K 线图可选);经 SOCKS 代理时需 **`PySocks`** -安装示例: +安装示例: ```bash # 推荐在 /opt/crypto_monitor 执行仓库根目录 deploy/setup_env.sh @@ -25,31 +25,31 @@ source .venv/bin/activate pip install -r ../requirements.txt ``` -页面上的 **「当日资金(交易账户)」** 与 **「可开仓」可用 U** 仅统计 **Binance U 本位永续合约账户**(`fetch_balance` 的 `swap` / FAPI `assets` 中的 USDT),**不会**再用现货余额顶替。 +页面上的 **「当日资金(交易账户)」** 与 **「可开仓」可用 U** 仅统计 **Binance U 本位永续合约账户**(`fetch_balance` 的 `swap` / FAPI `assets` 中的 USDT),**不会**再用现货余额顶替. -## 配置说明(`.env.example` → `.env`) +## 配置说明(`.env.example` → `.env`) -- **`.env.example`**:模板(可提交 Git);首次:`cp .env.example .env` 后编辑。 -- **`.env`**:本机真实配置(勿提交);`app.py` 只读此文件。`git pull` 不覆盖 `.env`;升级前可 `cp .env .env.backup.$(date +%Y%m%d)`。 +- **`.env.example`**:模板(可提交 Git);首次:`cp .env.example .env` 后编辑. +- **`.env`**:本机真实配置(勿提交);`app.py` 只读此文件.`git pull` 不覆盖 `.env`;升级前可 `cp .env .env.backup.$(date +%Y%m%d)`. -与 Binance 相关的常用变量: +与 Binance 相关的常用变量: | 变量 | 说明 | |------|------| -| `BINANCE_API_KEY` / `BINANCE_API_SECRET` | 币安 API(需合约等权限) | -| `LIVE_TRADING_ENABLED` | `true` 时允许真实下单;`false` 仅本地逻辑 | +| `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_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` 顶部默认值。 +其余变量(登录,企业微信,风控参数,**`AI_PROVIDER` / `OPENAI_*` / `OLLAMA_*`**,数据库路径等)见 **`.env.example` 内注释** 或 `app.py` 顶部默认值. ## 运行 -生产环境使用 **PM2**(`ecosystem.config.cjs`)。临时调试: +生产环境使用 **PM2**(`ecosystem.config.cjs`).临时调试: ```bash cd /opt/crypto_monitor/crypto_monitor_binance @@ -57,13 +57,13 @@ source .venv/bin/activate python app.py ``` -环境说明见 [docs/ubuntu-server.md](../docs/ubuntu-server.md)。 +环境说明见 [docs/ubuntu-server.md](../docs/ubuntu-server.md). -默认监听端口由 `.env` 的 `APP_PORT` 决定(未设置时多为 `5000`)。 +默认监听端口由 `.env` 的 `APP_PORT` 决定(未设置时多为 `5000`). -## 部署(Linux / PM2 / SSH SOCKS) +## 部署(Linux / PM2 / SSH SOCKS) -详见 **[部署文档.md](./部署文档.md)**(Ubuntu + PM2 + 可选 SOCKS 访问 Binance)。 +详见 **[部署文档.md](./部署文档.md)**(Ubuntu + PM2 + 可选 SOCKS 访问 Binance). ## 自检脚本 @@ -71,13 +71,13 @@ python app.py python scripts/verify_binance_funding.py ``` -用于核对 Key 前缀(不含 Secret)并尝试读取资金钱包 / 合约钱包 USDT(需网络与 API 权限)。 +用于核对 Key 前缀(不含 Secret)并尝试读取资金钱包 / 合约钱包 USDT(需网络与 API 权限). ## 数据与脚本 -- 默认 SQLite:`crypto.db`(路径由 `DB_PATH` 指定) -- `scripts/fix_breakeven_labels.py`:批量修正「止损」但盈亏为正的记录标签(见部署文档附录) +- 默认 SQLite:`crypto.db`(路径由 `DB_PATH` 指定) +- `scripts/fix_breakeven_labels.py`:批量修正「止损」但盈亏为正的记录标签(见部署文档附录) ## 风险与合规 -实盘交易有亏损风险。请自行确认 API 权限、IP 白名单、杠杆与保证金模式与币安账户设置一致,并遵守当地法律法规与 Binance 用户协议。 +实盘交易有亏损风险.请自行确认 API 权限,IP 白名单,杠杆与保证金模式与币安账户设置一致,并遵守当地法律法规与 Binance 用户协议. diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index 6bccd58..f28eb42 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -317,15 +317,15 @@ 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 覆盖) +# 训练参数(可由 .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) +# 交易日滚动与「可开仓」整点:按应用本地时区 wall clock(默认北京时间 UTC+8) TRADING_DAY_RESET_HOUR = int(os.getenv("TRADING_DAY_RESET_HOUR", "8")) -# false 时关闭「整点前禁止新开仓」守卫(交易日划分仍用 TRADING_DAY_RESET_HOUR) +# 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") @@ -346,14 +346,14 @@ LIVE_TRADING_ENABLED = os.getenv("LIVE_TRADING_ENABLED", "false").lower() == "tr 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=单向持仓 +# 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=标记价 +# 条件单触发参考: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")) @@ -375,8 +375,8 @@ 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 重复扣) +# 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( @@ -393,9 +393,9 @@ 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 自然日便于对账 +# 自动划转:仅在北京时间该整点「小时」内尝试;transfer_logs.transfer_day 存 UTC 自然日便于对账 AUTO_TRANSFER_BJ_HOUR = int(os.getenv("AUTO_TRANSFER_BJ_HOUR", "8")) -# 计仓模式:risk=以损定仓(默认);full_margin=合约可用保证金×比例全仓杠杆(仅 env 切换,须无仓) +# 计仓模式: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() @@ -439,14 +439,14 @@ BINANCE_HTTPS_PROXY = (os.getenv("BINANCE_HTTPS_PROXY") or "").strip() def build_binance_ccxt_proxies(): """ - 为 ccxt 配置代理(常用于本机网络不稳定时通过 SSH 动态转发 SOCKS5 出口)。 + 为 ccxt 配置代理(常用于本机网络不稳定时通过 SSH 动态转发 SOCKS5 出口). - 推荐: - - 本机:ssh -N -D 127.0.0.1:1080 user@vps - - .env:BINANCE_SOCKS_PROXY=socks5h://127.0.0.1:1080 + 推荐: + - 本机:ssh -N -D 127.0.0.1:1080 user@vps + - .env:BINANCE_SOCKS_PROXY=socks5h://127.0.0.1:1080 - 说明: - - socks5h 让代理端解析域名(避免本机 DNS/策略差异);若你明确要本机解析可用 socks5:// + 说明: + - socks5h 让代理端解析域名(避免本机 DNS/策略差异);若你明确要本机解析可用 socks5:// """ socks = BINANCE_SOCKS_PROXY.strip() http = BINANCE_HTTP_PROXY.strip() @@ -459,7 +459,7 @@ def build_binance_ccxt_proxies(): BINANCE_CCXT_PROXIES = build_binance_ccxt_proxies() -# 页顶「资金账户」是否合并现货 USDT(部分用户把现货当资金仓;默认仅 Funding) +# 页顶「资金账户」是否合并现货 USDT(部分用户把现货当资金仓;默认仅 Funding) BINANCE_FUNDING_INCLUDE_SPOT = os.getenv("BINANCE_FUNDING_INCLUDE_SPOT", "false").lower() in ( "1", "true", @@ -471,7 +471,7 @@ 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) +# Binance USDT 本位永续(ccxt unified: defaultType=swap) exchange = ccxt.binance({ "enableRateLimit": True, "options": { @@ -509,7 +509,7 @@ _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 @@ -527,7 +527,7 @@ def _wechat_account_label(): def _wechat_direction_text(direction): d = (direction or "").lower() - return "多头(long)" if d == "long" else "空头(short)" + return "多头(long)" if d == "long" else "空头(short)" def _wechat_trading_capital_text(fallback=None): @@ -576,21 +576,21 @@ def build_wechat_close_message( lines = [ f"📉 {symbol} 平仓完成", - f"💼 账户:{_wechat_account_label()}", + 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"🔖 平仓单号:{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}", + "🎯 价位(计划)", + f"开仓成交价:{ep}", + f"离场参考价:{cp}", + f"止盈价位:{tp}", + f"止损价位:{sl}", ] if extra_note: lines.extend(["", "📎 备注", extra_note]) @@ -602,16 +602,16 @@ def build_wechat_breakeven_message(symbol, direction, arm_txt, now_rr, locked_r, return "\n".join( [ f"# 🛡️ {symbol} 保护位更新", - f"**账户:{_wechat_account_label()}**", + 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}`", + 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}`", ] ) @@ -620,14 +620,14 @@ def build_wechat_monitor_error_message(symbol, direction, scene, error_text): return "\n".join( [ f"# ⚠️ {symbol} 下单监控异常", - f"**账户:{_wechat_account_label()}**", + f"**账户:{_wechat_account_label()}**", "", "---", "", "### 异常信息", - f"- 方向:**{_wechat_direction_text(direction)}**", - f"- 场景:{scene}", - f"- 错误:{str(error_text)}", + f"- 方向:**{_wechat_direction_text(direction)}**", + f"- 场景:{scene}", + f"- 错误:{str(error_text)}", ] ) @@ -648,22 +648,22 @@ def build_wechat_key_monitor_message( ): lines = [ f"# 🎯 {symbol} 关键位确认推送", - f"**账户:{_wechat_account_label()}**", + f"**账户:{_wechat_account_label()}**", "", "---", "", "### 交易对 / 触发时间", - f"- 交易对:**{symbol}**", - f"- 触发时间:`{trigger_time}`", + f"- 交易对:**{symbol}**", + f"- 触发时间:`{trigger_time}`", "", "### 方向与确认K", - f"- 方向:**{_wechat_direction_text(direction)}**", - "- 确认K:第二根5m收盘完成", + f"- 方向:**{_wechat_direction_text(direction)}**", + "- 确认K:第二根5m收盘完成", "", "### 关键价位", - f"- 类型:**{monitor_type}**", - f"- 箱体关键位:`{key_price}`", - f"- 第二根确认收盘价:`{confirm_close}`", + f"- 类型:**{monitor_type}**", + f"- 箱体关键位:`{key_price}`", + f"- 第二根确认收盘价:`{confirm_close}`", "", "### 硬条件校验结果", ] @@ -672,9 +672,9 @@ def build_wechat_key_monitor_message( [ "", "### 市场状态说明", - f"- BTC 8h 状态:**{btc8h_status}**", - f"- 本币 4h(EMA55) 状态:**{coin4h_status}**", - f"- 4h震荡幅度(5m近48根):`{round(float(swing4h_pct), 3)}%`", + f"- BTC 8h 状态:**{btc8h_status}**", + f"- 本币 4h(EMA55) 状态:**{coin4h_status}**", + f"- 4h震荡幅度(5m近48根):`{round(float(swing4h_pct), 3)}%`", "", "### 操作提示", ] @@ -794,7 +794,7 @@ def _pick_marker_point(rows, target_ts_ms, target_price=None): 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)") + raise RuntimeError("缺少依赖:Pillow(pip install Pillow)") img = Image.new("RGB", (width, height), bg_rgb) draw = ImageDraw.Draw(img) font = _load_font(14) @@ -899,7 +899,7 @@ def _render_candles_subplot(rows, title, width, height, bg_rgb=(255, 255, 255), 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 @@ -933,7 +933,7 @@ def _ohlcv_dict_rows_to_lists(rows, lim): def _fetch_ohlcv_ending_at(exchange_symbol, timeframe, limit, end_ts_ms): - """以 end_ts_ms 为终点向前取 K 线(无 end 则拉最近 limit 根)。""" + """以 end_ts_ms 为终点向前取 K 线(无 end 则拉最近 limit 根).""" lim = max(2, int(limit or ORDER_CHART_LIMIT)) try: if not end_ts_ms: @@ -1123,7 +1123,7 @@ EARLY_EXIT_TRIGGERS = ( "其他", ) -# 趋势户:大分歧A/B/小分歧 + 策略(关键位本实例关闭) +# 趋势户:大分歧A/B/小分歧 + 策略(关键位本实例关闭) ENTRY_REASON_OPTIONS = build_trend_div_entry_reason_options(STRATEGY_ENTRY_REASON_OPTIONS) STATS_SEGMENT_DEFS = ( @@ -1164,7 +1164,7 @@ def compose_early_exit_reason_saved(trigger, note): def journal_exit_reason_stored(trigger, note): - """exit_reason 列与表单「一处」对齐:非手工=触发类型;手工=离场说明全文。""" + """exit_reason 列与表单「一处」对齐:非手工=触发类型;手工=离场说明全文.""" t = normalize_early_exit_trigger(trigger) n = str(note or "").strip() if t == "手动平仓": @@ -1172,7 +1172,7 @@ def journal_exit_reason_stored(trigger, note): return t -# 初始化数据库(支持多空方向) +# 初始化数据库(支持多空方向) def init_db(): conn = sqlite3.connect(DB_PATH) c = conn.cursor() @@ -1186,7 +1186,7 @@ def init_db(): breakout_limit_pct REAL DEFAULT 1.5, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') - # 订单监控(核心:加 direction 方向字段) + # 订单监控(核心:加 direction 方向字段) c.execute('''CREATE TABLE IF NOT EXISTS order_monitors (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, direction TEXT DEFAULT "long", exchange_symbol TEXT, @@ -1201,7 +1201,7 @@ def init_db(): 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, @@ -1245,7 +1245,7 @@ def init_db(): ON transfer_logs(transfer_type, transfer_day) WHERE transfer_type = 'auto_daily' ''') - # 给旧表加 direction 字段(兼容老数据,不报错) + # 给旧表加 direction 字段(兼容老数据,不报错) try: c.execute("ALTER TABLE order_monitors ADD COLUMN direction TEXT DEFAULT 'long'") except: pass @@ -1586,7 +1586,7 @@ def hub_user_initiated_close( def app_now(): - """应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较)。""" + """应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较).""" return datetime.now(APP_TZ).replace(tzinfo=None) @@ -1595,17 +1595,17 @@ def app_now_str(): def utc_now_dt(): - """当前时刻(UTC,aware)。""" + """当前时刻(UTC,aware).""" return datetime.now(timezone.utc) def utc_calendar_date_str(): - """UTC 自然日 YYYY-MM-DD(用于自动划转去重等与交易所日界对齐的计算)。""" + """UTC 自然日 YYYY-MM-DD(用于自动划转去重等与交易所日界对齐的计算).""" return utc_now_dt().strftime("%Y-%m-%d") def get_trading_day(now=None): - """交易日字符串:本地时钟下若小时 < TRADING_DAY_RESET_HOUR 则归属「上一日历日」。""" + """交易日字符串:本地时钟下若小时 < TRADING_DAY_RESET_HOUR 则归属「上一日历日」.""" now = now or app_now() if getattr(now, "tzinfo", None): now = now.astimezone(APP_TZ).replace(tzinfo=None) @@ -1836,7 +1836,7 @@ def _compute_period_metrics(trades): def compute_stats_bundle(conn, trading_day, now_dt=None): - """日 / 周 / 月 统计:平仓按北京时间交易日(默认 8:00 切日)计入。""" + """日 / 周 / 月 统计:平仓按北京时间交易日(默认 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] @@ -1854,9 +1854,9 @@ def compute_stats_bundle(conn, trading_day, now_dt=None): 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}(北京自然月)" + 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 = [] @@ -1899,7 +1899,7 @@ def normalize_exchange_symbol(symbol): def resolve_monitor_exchange_symbol(row): - """将监控行上的 symbol / exchange_symbol 统一到 ccxt 永续合约 symbol,便于与 fetch_positions 结果比对。""" + """将监控行上的 symbol / exchange_symbol 统一到 ccxt 永续合约 symbol,便于与 fetch_positions 结果比对.""" raw = "" try: if row["exchange_symbol"]: @@ -1924,9 +1924,9 @@ def _position_contract_symbol_match(position_symbol, wanted_exchange_symbol): def _row_matches_monitor_direction(direction, position_dict): """ - 判断持仓行是否属于当前监控方向。 - 币安双向持仓为 LONG/SHORT;单向持仓常为 BOTH,此时不能用 side!=direction 过滤, - 否则会把整行跳过(live 恒为 0),平仓数量错误甚至误判「无仓」。 + 判断持仓行是否属于当前监控方向. + 币安双向持仓为 LONG/SHORT;单向持仓常为 BOTH,此时不能用 side!=direction 过滤, + 否则会把整行跳过(live 恒为 0),平仓数量错误甚至误判「无仓」. """ if not position_dict: return False @@ -1966,7 +1966,7 @@ def _row_matches_monitor_direction(direction, position_dict): def _position_matches_wanted_contract(wanted_unified_sym, position_dict): - """统一 symbol 比对;不一致时用交易所原始合约代码与 ccxt market.id 对齐(兼容命名差异)。""" + """统一 symbol 比对;不一致时用交易所原始合约代码与 ccxt market.id 对齐(兼容命名差异).""" if not wanted_unified_sym or not position_dict: return False ps = position_dict.get("symbol") @@ -1985,7 +1985,7 @@ def _position_matches_wanted_contract(wanted_unified_sym, position_dict): def _position_row_effective_contracts(p): - """持仓数量:优先 ccxt contracts,否则用交易所原始 positionAmt/size/pos(避免统一层为 0 时被误判空仓)。""" + """持仓数量:优先 ccxt contracts,否则用交易所原始 positionAmt/size/pos(避免统一层为 0 时被误判空仓).""" from lib.hub.hub_position_metrics import normalize_contracts_qty if not p: @@ -2173,7 +2173,7 @@ def to_effective_trade_dict(row): return item -# USDT 等资金类:展示与入库舍入统一为 2 位小数(与交易所常见口径一致) +# USDT 等资金类:展示与入库舍入统一为 2 位小数(与交易所常见口径一致) FUNDS_DECIMALS = 2 @@ -2194,7 +2194,7 @@ def round_funds(value): def _ccxt_swap_symbol_for_precision(symbol): - """解析为 ccxt markets 中的永续 symbol,供 price_to_precision 使用。""" + """解析为 ccxt markets 中的永续 symbol,供 price_to_precision 使用.""" raw = (symbol or "").strip() if not raw: return None @@ -2232,7 +2232,7 @@ def format_price_for_symbol(symbol, value): except Exception: pass av = abs(v) - # 无法加载市场或无该合约时:按价格量级回退(尽量不阻断页面) + # 无法加载市场或无该合约时:按价格量级回退(尽量不阻断页面) if av >= 10000: d = 2 elif av >= 100: @@ -2250,7 +2250,7 @@ def format_price_for_symbol(symbol, value): def round_price_to_exchange(exchange_symbol, price): - """将价格按 U 本位永续 tick 取整;失败返回 None。""" + """将价格按 U 本位永续 tick 取整;失败返回 None.""" if price is None: return None try: @@ -2273,7 +2273,7 @@ def format_hold_minutes(minutes): def calc_pnl(direction, trigger_price, exit_price, margin_capital, leverage, notional_usdt=None): - """估算盈亏(USDT)。优先用名义价值 notional_usdt,否则 margin×leverage。""" + """估算盈亏(USDT).优先用名义价值 notional_usdt,否则 margin×leverage.""" try: trigger = float(trigger_price) exit_p = float(exit_price) @@ -2297,7 +2297,7 @@ def calc_pnl(direction, trigger_price, exit_price, margin_capital, leverage, not def get_plan_notional_usdt(row_or_dict): - """计划名义价值(USDT),与开仓 sizing 口径一致。""" + """计划名义价值(USDT),与开仓 sizing 口径一致.""" if row_or_dict is None: return None try: @@ -2331,7 +2331,7 @@ def get_plan_notional_usdt(row_or_dict): def _trade_ids_from_fills(trades): - """仅使用 Binance 原始 tradeId(与 income 流水一致),不用 ccxt 的 id。""" + """仅使用 Binance 原始 tradeId(与 income 流水一致),不用 ccxt 的 id.""" ids = set() for t in trades or []: info = t.get("info") if isinstance(t.get("info"), dict) else {} @@ -2343,7 +2343,7 @@ def _trade_ids_from_fills(trades): def _cluster_closing_trades_near_close(trades, closed_ms, spread_ms=8 * 60 * 1000): - """只保留平仓时刻附近的一簇减仓成交,避免把相邻其它仓位算进来。""" + """只保留平仓时刻附近的一簇减仓成交,避免把相邻其它仓位算进来.""" if not trades: return [] if closed_ms is None: @@ -2385,7 +2385,7 @@ def _income_entry_trade_id(entry): def calc_binance_realized_pnl_from_trades(trades): - """仅汇总成交回报中的 realizedPnl(勿再扣 commission,避免与 income 重复)。""" + """仅汇总成交回报中的 realizedPnl(勿再扣 commission,避免与 income 重复).""" if not trades: return None total = 0.0 @@ -2441,7 +2441,7 @@ def _sum_binance_income(entries, income_types, trade_ids=None): def calc_pnl_from_closing_trades(direction, entry_price, trades, exchange_symbol=None): - """按减仓成交数量×价差汇总盈亏(不含资金费;比单点标记价更接近交易所)。""" + """按减仓成交数量×价差汇总盈亏(不含资金费;比单点标记价更接近交易所).""" try: entry = float(entry_price) except (TypeError, ValueError): @@ -2485,8 +2485,8 @@ def resolve_trade_pnl_amount( closed_at_ms=None, ): """ - 平仓盈亏:优先 Binance income 净额(含手续费),其次按减仓成交汇总,最后用计划名义×涨跌。 - 返回 (pnl, exit_price, exchange_opened_at, exchange_closed_at, exchange_sync_key)。 + 平仓盈亏:优先 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") @@ -2613,7 +2613,7 @@ def calc_actual_rr(pnl_amount, risk_amount): def calc_breakeven_stop(direction, entry_price, risk_fraction, locked_r, offset_pct): """ - 按“已锁定R”计算目标止损位: + 按“已锁定R”计算目标止损位: - long: entry + locked_r * (entry*risk_fraction) + offset - short: entry - locked_r * (entry*risk_fraction) - offset """ @@ -2760,9 +2760,9 @@ def enrich_order_item(raw_item, current_capital): def ensure_exchange_live_ready(): if not LIVE_TRADING_ENABLED: - return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)" + 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 False, "缺少 Binance API 密钥配置(BINANCE_API_KEY / BINANCE_API_SECRET)" return True, "" @@ -2792,7 +2792,7 @@ def order_row_key_signal_type(row): def exchange_private_api_configured(): - """仅表示已配置密钥;与是否允许下单(LIVE_TRADING_ENABLED)无关,用于只读拉仓等。""" + """仅表示已配置密钥;与是否允许下单(LIVE_TRADING_ENABLED)无关,用于只读拉仓等.""" return bool(BINANCE_API_KEY and BINANCE_API_SECRET) @@ -2835,7 +2835,7 @@ def _extract_usdt_total(balance): def _parse_binance_funding_asset_rows(rows): - """解析 /sapi/v1/asset/get-funding-asset:USDT 总额 = free + freeze + locked + withdrawing。""" + """解析 /sapi/v1/asset/get-funding-asset:USDT 总额 = free + freeze + locked + withdrawing.""" if isinstance(rows, dict): rows = [rows] if not isinstance(rows, list): @@ -2858,7 +2858,7 @@ def _parse_binance_funding_asset_rows(rows): def _parse_binance_wallet_balance_usdt(rows, wallet_names): - """解析 /sapi/v1/asset/wallet/balance(quoteAsset=USDT):按 walletName 取折合 USDT 余额。""" + """解析 /sapi/v1/asset/wallet/balance(quoteAsset=USDT):按 walletName 取折合 USDT 余额.""" if isinstance(rows, dict): rows = [rows] if not isinstance(rows, list): @@ -2879,7 +2879,7 @@ def _parse_binance_wallet_balance_usdt(rows, wallet_names): def _fetch_binance_funding_usdt_from_wallet_overview(): - """与币安 App 资产页「资金/Funding」钱包 USDT 估值一致(wallet/balance)。""" + """与币安 App 资产页「资金/Funding」钱包 USDT 估值一致(wallet/balance).""" try: ensure_markets_loaded() raw = exchange.sapiGetAssetWalletBalance({"quoteAsset": TRANSFER_CCY}) @@ -2892,7 +2892,7 @@ def _fetch_binance_funding_usdt_from_wallet_overview(): def _fetch_binance_spot_usdt_total(): - """现货账户 USDT 总额(free+locked)。""" + """现货账户 USDT 总额(free+locked).""" try: ensure_markets_loaded() raw = exchange.sapiGetAssetWalletBalance({"quoteAsset": TRANSFER_CCY}) @@ -2925,7 +2925,7 @@ def _extract_usdt_free(balance): def _binance_futures_usdt_asset_row(balance): - """从 U 本位合约 fetch_balance 的 info.assets 中取 USDT 一行(与币安后台口径一致)。""" + """从 U 本位合约 fetch_balance 的 info.assets 中取 USDT 一行(与币安后台口径一致).""" if not isinstance(balance, dict): return None info = balance.get("info") @@ -2941,7 +2941,7 @@ def _binance_futures_usdt_asset_row(balance): def _fetch_binance_swap_usdt_total(): - """仅 U 本位永续合约账户 USDT(总额口径:优先 marginBalance / walletBalance,不回退现货)。""" + """仅 U 本位永续合约账户 USDT(总额口径:优先 marginBalance / walletBalance,不回退现货).""" try: ensure_markets_loaded() bal = exchange.fetch_balance(params={"type": "swap"}) @@ -2963,7 +2963,7 @@ def _fetch_binance_swap_usdt_total(): def _fetch_binance_swap_usdt_free(): - """U 本位合约账户 USDT 可用(开仓可用保证金口径,不回退现货)。""" + """U 本位合约账户 USDT 可用(开仓可用保证金口径,不回退现货).""" try: ensure_markets_loaded() bal = exchange.fetch_balance(params={"type": "swap"}) @@ -2984,7 +2984,7 @@ def _fetch_binance_swap_usdt_free(): def _fetch_binance_funding_usdt(): - """Binance 资金账户(Funding Wallet)USDT 总额,与 App「资金账户」一致。""" + """Binance 资金账户(Funding Wallet)USDT 总额,与 App「资金账户」一致.""" candidates = [] wallet_val = _fetch_binance_funding_usdt_from_wallet_overview() if wallet_val is not None: @@ -3063,10 +3063,10 @@ def friendly_exchange_error(err, available_usdt=None): 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到合约账户。" + 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}" + return f"交易所下单失败:{clean}" def get_exchange_capitals(force=False): @@ -3083,7 +3083,7 @@ def get_exchange_capitals(force=False): 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"] @@ -3102,14 +3102,14 @@ def execute_transfer_usdt(amount, from_account, to_account): 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 已重置或权限变更。" + ".常见原因:① BINANCE_API_SECRET 错误或 .env 里多了空格/换行;② IP 白名单未包含当前服务器出口 IP;" + "③ API Key 未勾选「允许合约」「允许万向划转」等所需权限;④ Key 已重置或权限变更." ) return False, msg, None def get_account_usdt_total(account_type): - """读取各账户 USDT。funding 走资金钱包;swap 仅合约账户;spot 仅现货。""" + """读取各账户 USDT.funding 走资金钱包;swap 仅合约账户;spot 仅现货.""" raw = (account_type or "").strip().lower() if raw == "funding": return _fetch_binance_funding_usdt() @@ -3147,7 +3147,7 @@ def auto_transfer_once_per_day(): 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 @@ -3191,7 +3191,7 @@ def set_key_sizing_capital_snapshot(conn, session_date, capital): 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: @@ -3222,7 +3222,7 @@ def precheck_risk(conn, symbol, direction): reached, active_count, mx = position_limit_reached(conn, max_active_positions=MAX_ACTIVE_POSITIONS) if reached: - return False, f"已达最大持仓数({active_count}/{mx})" + 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 ) @@ -3249,16 +3249,16 @@ def prepare_order_amount(exchange_symbol, margin_capital, leverage, fallback_pri market = exchange.market(exchange_symbol) contract_size = float(market.get("contractSize") or 1) if market.get("contract"): - # 合约 amount 按张数/合约乘数解析;ccxt 会再做精度与符号处理 + # 合约 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}") + raise ValueError(f"下单数量过小,最小数量为 {min_amount}") amount_precise = float(exchange.amount_to_precision(exchange_symbol, amount)) if amount_precise <= 0: - raise ValueError("下单数量精度后为 0,请提高基数或降低价格") + raise ValueError("下单数量精度后为 0,请提高基数或降低价格") return amount_precise, price @@ -3335,9 +3335,9 @@ def build_binance_order_params(direction, reduce_only=False): def _binance_market_close_param_candidates(direction): """ - 平仓市价单参数组合(按顺序尝试)。 - 部分币安 U 本位账户对市价减仓报 -1106「reduceOnly sent when not required」, - 与条件单一致,需再试不带 reduceOnly 的写法;另保留双向/单向 positionSide 切换。 + 平仓市价单参数组合(按顺序尝试). + 部分币安 U 本位账户对市价减仓报 -1106「reduceOnly sent when not required」, + 与条件单一致,需再试不带 reduceOnly 的写法;另保留双向/单向 positionSide 切换. """ ps = "LONG" if direction == "long" else "SHORT" hedge_ro = {"positionSide": ps, "reduceOnly": True} @@ -3383,8 +3383,8 @@ def _binance_trigger_order_params(): 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)。 + 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) @@ -3393,7 +3393,7 @@ def _binance_place_tp_sl_orders(exchange_symbol, direction, position_amount, sto 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") + 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()) @@ -3427,15 +3427,15 @@ def _binance_place_tp_sl_orders(exchange_symbol, direction, position_amount, sto except Exception: pass time.sleep(0.2 * (attempt + 1)) - raise RuntimeError(f"Binance 未接受止盈/止损触发单:{last_err}") + 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("交易所当前无持仓,无法挂止损") + raise RuntimeError("交易所当前无持仓,无法挂止损") cancel_binance_futures_open_orders(exchange_symbol) market = exchange.market(exchange_symbol) if not market.get("swap"): @@ -3537,14 +3537,14 @@ def place_exchange_order(exchange_symbol, direction, amount, leverage, stop_loss raise except Exception as e: _abort_market_open_after_tpsl_failure(exchange_symbol, direction, order, amount) - raise RuntimeError(f"交易所未接受条件止盈/止损委托,已拒绝开仓:{str(e)}") from e + raise RuntimeError(f"交易所未接受条件止盈/止损委托,已拒绝开仓:{str(e)}") from e return order def close_exchange_order(order_row): """ - 市价全平。数量优先取交易所当前持仓张数,避免仅用入库的 order_amount - 导致「只平一部分 → 撤单后委托没了但仓位还在」(加仓、精度或成交与计划不一致时常见)。 + 市价全平.数量优先取交易所当前持仓张数,避免仅用入库的 order_amount + 导致「只平一部分 → 撤单后委托没了但仓位还在」(加仓,精度或成交与计划不一致时常见). """ ensure_markets_loaded() exchange_symbol = order_row["exchange_symbol"] or normalize_exchange_symbol(order_row["symbol"]) @@ -3561,7 +3561,7 @@ def close_exchange_order(order_row): if raw_amt <= 0: if last_resp is not None: return last_resp - raise ValueError("平仓失败:缺少有效下单数量") + raise ValueError("平仓失败:缺少有效下单数量") try: amount = float(exchange.amount_to_precision(exchange_symbol, raw_amt)) except Exception: @@ -3569,7 +3569,7 @@ def close_exchange_order(order_row): if amount <= 0: if last_resp is not None: return last_resp - raise ValueError("平仓失败:数量经精度舍入后为 0") + raise ValueError("平仓失败:数量经精度舍入后为 0") order_resp = None last_close_err = None for params in _binance_market_close_param_candidates(direction): @@ -3583,7 +3583,7 @@ def close_exchange_order(order_row): continue raise if order_resp is None: - raise last_close_err if last_close_err else RuntimeError("平仓失败:交易所未返回结果") + 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: @@ -3593,9 +3593,9 @@ def close_exchange_order(order_row): def cancel_binance_futures_open_orders(exchange_symbol): """ - 平仓后撤销该合约下剩余挂单,避免孤儿单残留。 - Binance U 本位:普通挂单走 cancel_all_orders(DELETE allOpenOrders); - 止盈/止损等条件单在「Algo」通道,需再调 DELETE algoOpenOrders,否则手动平仓后仍会留在「当前委托」。 + 平仓后撤销该合约下剩余挂单,避免孤儿单残留. + Binance U 本位:普通挂单走 cancel_all_orders(DELETE allOpenOrders); + 止盈/止损等条件单在「Algo」通道,需再调 DELETE algoOpenOrders,否则手动平仓后仍会留在「当前委托」. """ ok, _ = ensure_exchange_live_ready() if not ok or not exchange_symbol: @@ -3628,7 +3628,7 @@ def cancel_binance_futures_open_orders(exchange_symbol): def _binance_list_raw_open_orders(exchange_symbol): - """普通挂单 + Algo 条件单(止盈/止损)。""" + """普通挂单 + Algo 条件单(止盈/止损).""" ensure_markets_loaded() market = exchange.market(exchange_symbol) contract_id = market.get("id") @@ -3737,7 +3737,7 @@ def _binance_tpsl_slot_from_order(order, exchange_symbol): def fetch_exchange_tpsl_slots(exchange_symbol, direction): - """返回 { sl: slot|None, tp: slot|None },供页面展示与单笔撤单。""" + """返回 { sl: slot|None, tp: slot|None },供页面展示与单笔撤单.""" slots = {"sl": None, "tp": None} if not exchange_symbol: return slots @@ -3777,7 +3777,7 @@ def _resolve_tpsl_prices_for_manual(direction, live_price, sltp_mode, data): def replace_active_monitor_tpsl_on_exchange(order_row, stop_loss, take_profit): - """先撤该合约全部 TP/SL,再按新价重挂(与交易所 App 一致)。""" + """先撤该合约全部 TP/SL,再按新价重挂(与交易所 App 一致).""" ok, reason = ensure_exchange_live_ready() if not ok: raise RuntimeError(reason or "实盘未就绪") @@ -3786,7 +3786,7 @@ def replace_active_monitor_tpsl_on_exchange(order_row, stop_loss, take_profit): 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("交易所当前无该方向持仓,无法挂止盈止损") + raise ValueError("交易所当前无该方向持仓,无法挂止盈止损") _binance_place_tp_sl_orders(ex_sym, direction, float(pos_amt), float(stop_loss), float(take_profit)) @@ -3814,8 +3814,8 @@ def extract_trade_price_from_order(order): def is_no_position_error(err_msg): msg = (err_msg or "").lower() - # 禁止匹配笼统的 reduceonly / -4061:会与参数错误、单向/双向模式不匹配混淆, - # 误判后走「已无仓」同步结束,交易所仓位却仍在。 + # 禁止匹配笼统的 reduceonly / -4061:会与参数错误,单向/双向模式不匹配混淆, + # 误判后走「已无仓」同步结束,交易所仓位却仍在. keywords = [ "no position", "position does not exist", @@ -3936,7 +3936,7 @@ def _find_inactive_monitor_for_live(conn, exchange_symbol, monitor_symbol, direc def list_orphan_live_positions(conn): - """交易所有仓、但无对应 active 监控的持仓(可尝试恢复本地监控)。""" + """交易所有仓,但无对应 active 监控的持仓(可尝试恢复本地监控).""" live_rows = _fetch_nonempty_live_position_rows() if not live_rows: return [] @@ -3991,9 +3991,9 @@ def recover_live_position_monitor(conn, monitor_id=None, place_tpsl=True): if not matched: live = get_live_position_contracts(ex_sym, direction) if live is None: - return False, "暂时无法读取交易所持仓,请稍后重试", None + return False, "暂时无法读取交易所持仓,请稍后重试", None if live <= 0: - return False, "交易所该方向已无持仓,无法恢复", None + return False, "交易所该方向已无持仓,无法恢复", None else: for o in orphans: rid = o.get("recoverable_monitor_id") @@ -4007,19 +4007,19 @@ def recover_live_position_monitor(conn, monitor_id=None, place_tpsl=True): dir_zh = "多" if o["direction"] == "long" else "空" return ( False, - f"检测到 {o['symbol']} {dir_zh}仓,但无匹配的已停监控记录(可能已被删除),需在数据库手动处理", + f"检测到 {o['symbol']} {dir_zh}仓,但无匹配的已停监控记录(可能已被删除),需在数据库手动处理", None, ) if get_active_position_count(conn) >= MAX_ACTIVE_POSITIONS: - return False, f"已达最大持仓数({MAX_ACTIVE_POSITIONS})", None + 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 + return False, "暂时无法读取交易所持仓,请稍后重试", None if live <= 0: - return False, "交易所该方向已无持仓,无法恢复监控", None + return False, "交易所该方向已无持仓,无法恢复监控", None oid = int(row["id"]) conn.execute( @@ -4034,15 +4034,15 @@ def recover_live_position_monitor(conn, monitor_id=None, place_tpsl=True): if ok_live: try: replace_active_monitor_tpsl_on_exchange(row, row["stop_loss"], row["take_profit"]) - tpsl_msg = ",并已重新挂止盈止损" + tpsl_msg = ",并已重新挂止盈止损" except Exception as e: - tpsl_msg = f"。监控已恢复,但挂止盈止损失败:{friendly_exchange_error(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 过滤规则一致)。""" + """在 fetch_positions 结果中取与当前监控方向一致,张数最大的一条(与 get_live_position_contracts 过滤规则一致).""" if not rows: return None candidates = [] @@ -4076,9 +4076,9 @@ def _coerce_float(*values): def parse_ccxt_position_metrics(position, order_leverage=None): """ - 从 ccxt 统一持仓结构解析保证金/名义/未实现盈亏。 - 「所保证金」对齐币安合约页的初始/持仓保证金:优先 initialMargin / positionInitialMargin。 - Binance 全仓下 ccxt 的 collateral 常来自 crossMargin,口径易与「名义」混淆,故不全仓优先用 collateral。 + 从 ccxt 统一持仓结构解析保证金/名义/未实现盈亏. + 「所保证金」对齐币安合约页的初始/持仓保证金:优先 initialMargin / positionInitialMargin. + Binance 全仓下 ccxt 的 collateral 常来自 crossMargin,口径易与「名义」混淆,故不全仓优先用 collateral. """ if not position: return None @@ -4107,7 +4107,7 @@ def parse_ccxt_position_metrics(position, order_leverage=None): notional = _coerce_float(info.get("value")) if notional is not None: notional = abs(notional) - # 全仓且 API margin 为 0 时:用名义/杠杆粗算展示(与交易所「约占用」接近) + # 全仓且 API margin 为 0 时:用名义/杠杆粗算展示(与交易所「约占用」接近) if (initial is None or initial <= 0) and notional and notional > 0 and order_leverage: try: lev = float(order_leverage) @@ -4205,7 +4205,7 @@ def ms_to_app_local_str(ms): def classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_price): - """根据成交价相对止盈/止损位归类;无法可靠归类时返回 None。""" + """根据成交价相对止盈/止损位归类;无法可靠归类时返回 None.""" try: tp = float(take_profit) sl = float(stop_loss) @@ -4228,7 +4228,7 @@ def classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, ex def fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=None): - """取开仓以来最近一笔减仓成交(与方向一致);失败返回 None。""" + """取开仓以来最近一笔减仓成交(与方向一致);失败返回 None.""" if not (BINANCE_API_KEY and BINANCE_API_SECRET): return None ensure_markets_loaded() @@ -4272,8 +4272,8 @@ def fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_ 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 回填)。 - 返回按时间排序的成交列表。 + 拉取某条历史记录对应的减仓成交(用于按 id 回填). + 返回按时间排序的成交列表. """ if not (BINANCE_API_KEY and BINANCE_API_SECRET): return [] @@ -4315,7 +4315,7 @@ def fetch_closing_fills_for_record(exchange_symbol, direction, opened_at_str, cl if candidates: return candidates - # 严格窗口为空时,降级为“按平仓时间就近匹配”,降低时区/时间误差导致的回填失败。 + # 严格窗口为空时,降级为“按平仓时间就近匹配”,降低时区/时间误差导致的回填失败. all_side_candidates.sort(key=lambda x: x.get("timestamp") or 0) if not all_side_candidates: return [] @@ -4345,7 +4345,7 @@ def fetch_all_position_fills_for_record( opened_at_ms=None, closed_at_ms=None, ): - """持仓生命周期内全部 fill(开+平),用于双边成交额与手续费。""" + """持仓生命周期内全部 fill(开+平),用于双边成交额与手续费.""" if not (BINANCE_API_KEY and BINANCE_API_SECRET): return [] ensure_markets_loaded() @@ -4443,8 +4443,8 @@ def calc_weighted_exit_price(trades): def resolve_synced_flat_close(row, opened_at_str, opened_at_ms=None): """ - 交易所已无仓、本地仍为 active 时,推断平仓类型/时间/盈亏。 - 返回 (result, pnl_amount, closed_at_str, miss_reason)。 + 交易所已无仓,本地仍为 active 时,推断平仓类型/时间/盈亏. + 返回 (result, pnl_amount, closed_at_str, miss_reason). """ direction = row["direction"] sym = row["symbol"] @@ -4518,13 +4518,13 @@ def resolve_synced_flat_close(row, opened_at_str, opened_at_ms=None): 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) @@ -4539,7 +4539,7 @@ def resolve_synced_flat_close(row, opened_at_str, opened_at_ms=None): "外部平仓", pnl, closed_at_str, - "交易所已平仓,成交价不在计划止盈/止损带内(可能为手动或其他类型平仓)", + "交易所已平仓,成交价不在计划止盈/止损带内(可能为手动或其他类型平仓)", ) @@ -4632,7 +4632,7 @@ def reconcile_external_closes(conn, days=None): 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 天过滤,避免把更早历史单误同步进来 + # 手动同步按最近 N 天过滤,避免把更早历史单误同步进来 if opened_ms is None or opened_ms < cutoff_ms: continue oid = int(r["id"]) @@ -4709,7 +4709,7 @@ def reconcile_external_closes(conn, days=None): build_wechat_close_message( symbol=r["symbol"], direction=r["direction"], - result=f"{result}(自动同步)", + result=f"{result}(自动同步)", pnl_amount=pnl_amount, hold_seconds=hold_seconds, trigger_price=r["trigger_price"], @@ -4725,7 +4725,7 @@ def reconcile_external_closes(conn, days=None): build_wechat_close_message( symbol=r["symbol"], direction=r["direction"], - result="外部平仓(自动同步)", + result="外部平仓(自动同步)", pnl_amount=pnl_amount, hold_seconds=hold_seconds, trigger_price=r["trigger_price"], @@ -4795,8 +4795,8 @@ def _status_by_ema55(symbol, timeframe): def _daily_volume_rank(symbol): """ - 返回(symbol_rank, total_count),按 USDT 永续 24h 成交额降序。 - 走 hub_volume_rank_lib 轻量 ticker API,避免 fetch_tickers() 全市场拉取。 + 返回(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) @@ -4812,8 +4812,8 @@ def _daily_volume_rank(symbol): def _key_hard_checks(symbol, direction, upper, lower, monitor_type): """ - 关键位门控:量能、突破幅度、第二根确认、日成交量前30。 - 使用最近闭合K:breakout=倒数第2根,confirm=倒数第1根。 + 关键位门控:量能,突破幅度,第二根确认,日成交量前30. + 使用最近闭合K:breakout=倒数第2根,confirm=倒数第1根. """ out = {"ok": False} ex_sym = normalize_exchange_symbol(symbol) @@ -4830,7 +4830,7 @@ def _key_hard_checks(symbol, direction, upper, lower, monitor_type): breakout = closed[KEY_CONFIRM_BREAKOUT_BAR] confirm = closed[KEY_CONFIRM_BAR] except IndexError: - out["reason"] = "确认K索引超出范围,请检查 KEY_CONFIRM_* 配置" + 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) @@ -4902,14 +4902,14 @@ def calc_price_diff_pct(current_price, target_price): 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。""" + """最近一根闭合 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: @@ -4919,7 +4919,7 @@ def _fetch_last_closed_bar(symbol): def _key_rs_gate_preview(symbol, upper, lower): - """页面门控预览:阻力/支撑仅显示距上/下沿与是否已越线。""" + """页面门控预览:阻力/支撑仅显示距上/下沿与是否已越线.""" bar = _fetch_last_closed_bar(symbol) if not bar: return {"summary": "5m数据不足", "metrics": ""} @@ -4937,7 +4937,7 @@ def _key_rs_gate_preview(symbol, upper, lower): def _process_key_rs_level_alert(conn, row): - """关键阻力位/支撑位:5m 收盘越上沿或下沿后,按间隔推送最多 KEY_ALERT_MAX_TIMES 次。""" + """关键阻力位/支撑位:5m 收盘越上沿或下沿后,按间隔推送最多 KEY_ALERT_MAX_TIMES 次.""" sym = row["symbol"] typ = (row["monitor_type"] or "").strip() up, low = float(row["upper"]), float(row["lower"]) @@ -5013,18 +5013,18 @@ def _process_key_rs_level_alert(conn, row): 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']})", + 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})", + 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。""" + """按 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( @@ -5053,7 +5053,7 @@ def _market_open_for_key_monitor( time_close_hours=None, ): """ - 与手动「实盘下单」对齐的市价开仓与 order_monitors 写入(Binance U 本位)。 + 与手动「实盘下单」对齐的市价开仓与 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) @@ -5062,7 +5062,7 @@ def _market_open_for_key_monitor( now = app_now() ok, reason = precheck_risk(conn, symbol, direction) if not ok: - return False, f"风控拒绝下单:{reason}", None + return False, f"风控拒绝下单:{reason}", None ok_live, reason_live = ensure_exchange_live_ready() if not ok_live: return False, reason_live, None @@ -5089,7 +5089,7 @@ def _market_open_for_key_monitor( available_usdt = get_available_trading_usdt() live_price = get_price(symbol) if live_price is None: - return False, "获取交易所实时价格失败(以损定仓需要当前价)", None + return False, "获取交易所实时价格失败(以损定仓需要当前价)", None try: ensure_markets_loaded() except Exception: @@ -5107,7 +5107,7 @@ def _market_open_for_key_monitor( risk_fraction = calc_risk_fraction(direction, live_price, stop_loss) if risk_fraction is None: - return False, "止损方向不合法(相对当前市价);请核对上下沿与方向", 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) @@ -5121,7 +5121,7 @@ def _market_open_for_key_monitor( if margin_capital > max_margin: return ( False, - f"保证金不足:交易账户可用约 {round(available_usdt, FUNDS_DECIMALS)}U,当前最多建议 {max_margin}U", + f"保证金不足:交易账户可用约 {round(available_usdt, FUNDS_DECIMALS)}U,当前最多建议 {max_margin}U", None, ) @@ -5242,7 +5242,7 @@ def _sqlite_row_val(row, key, default=None): def get_symbol_mark_price(symbol): - """斐波失效判定用标记价。""" + """斐波失效判定用标记价.""" ex_sym = normalize_exchange_symbol(symbol) try: ensure_markets_loaded() @@ -5260,7 +5260,7 @@ def get_symbol_mark_price(symbol): def cancel_fib_limit_order(exchange_symbol, order_id): - """仅撤销本条斐波限价单,不用 cancel_all。""" + """仅撤销本条斐波限价单,不用 cancel_all.""" if not order_id: return False ok_live, _ = ensure_exchange_live_ready() @@ -5478,17 +5478,17 @@ def _finalize_fib_key_fill(conn, row): if amount <= 0: send_wechat_msg( f"# ❌ {symbol} {kind}成交后处理失败\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 无法取得持仓/下单数量,未挂 TP/SL\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"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}\n" + f"- 原因:{reason}\n" f"- 请手动处理仓位与挂单\n" ) return @@ -5499,8 +5499,8 @@ def _finalize_fib_key_fill(conn, row): 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"**账户:{_wechat_account_label()}**\n" + f"- 错误:{friendly_exchange_error(e)}\n" f"- 请手动补挂止盈止损\n" ) return @@ -5519,13 +5519,13 @@ def _finalize_fib_key_fill(conn, row): 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"**账户:{_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) @@ -5557,7 +5557,7 @@ def _add_trigger_entry_key_monitor( 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} 已有触价开仓监控(同币仅允许一条)" + return False, f"{symbol} 已有触价开仓监控(同币仅允许一条)" ex_sym = normalize_exchange_symbol(symbol) mark = get_symbol_mark_price(symbol) geom_err = validate_trigger_entry_geometry( @@ -5627,7 +5627,7 @@ def _add_trigger_entry_key_monitor( leverage = 5 risk_fraction = calc_risk_fraction(direction_sel, entry, sl) if risk_fraction is None: - return False, "止损方向不合法(相对计划入场价)" + 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) @@ -5639,7 +5639,7 @@ def _add_trigger_entry_key_monitor( if margin_capital > max_margin: return ( False, - f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", + f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", ) try: amount_plan, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry) @@ -5694,14 +5694,14 @@ def _market_open_for_trigger_entry( time_close_enabled=0, time_close_hours=None, ): - """触价触发后市价开仓,计仓规则与实盘下单/关键位 RR 门槛一致。""" + """触价触发后市价开仓,计仓规则与实盘下单/关键位 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 + return False, f"风控拒绝下单:{reason}", None ok_live, reason_live = ensure_exchange_live_ready() if not ok_live: return False, reason_live, None @@ -5740,7 +5740,7 @@ def _market_open_for_trigger_entry( 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 + 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): @@ -5770,7 +5770,7 @@ def _market_open_for_trigger_entry( leverage = 5 risk_fraction = calc_risk_fraction(direction, entry_price, stop_loss) if risk_fraction is None: - return False, "止损方向不合法(相对计划入场价)", 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) @@ -5781,7 +5781,7 @@ def _market_open_for_trigger_entry( if margin_capital > max_margin: return ( False, - f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", + 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 @@ -5893,7 +5893,7 @@ def _market_open_for_trigger_entry( def _execute_trigger_entry_cross(conn, row): - """标记价触达计划入场:加锁防重复触发,成交成功后再删监控行。""" + """标记价触达计划入场:加锁防重复触发,成交成功后再删监控行.""" symbol = row["symbol"] direction = (row["direction"] or "long").lower() ex_sym = normalize_exchange_symbol(symbol) @@ -5928,9 +5928,9 @@ def _execute_trigger_entry_cross(conn, row): 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" + 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 @@ -5941,14 +5941,14 @@ def _execute_trigger_entry_cross(conn, row): 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"**账户:{_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) @@ -5959,9 +5959,9 @@ def _execute_trigger_entry_cross(conn, row): 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" + 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 @@ -5999,9 +5999,9 @@ def check_trigger_entry_key_monitors(): 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" + 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) @@ -6010,8 +6010,8 @@ def check_trigger_entry_key_monitors(): if inv == "tp": msg = ( f"# ⚠️ {symbol} 触价开仓失效\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 类型:{mt}|标记价 {format_price_for_symbol(symbol, mark)} 已触达止盈侧(未成交)\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) @@ -6019,8 +6019,8 @@ def check_trigger_entry_key_monitors(): if inv == "sl": msg = ( f"# ⚠️ {symbol} 触价开仓失效\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 类型:{mt}|标记价 {format_price_for_symbol(symbol, mark)} 已触达止损侧(未突破)\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) @@ -6054,9 +6054,9 @@ def check_fib_key_monitors(): 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"**账户:{_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) @@ -6074,18 +6074,18 @@ def check_fib_key_monitors(): _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" + 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" + f"# ⚠️ {symbol} 斐波监控失效(限价已不在挂单)\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 标记价触达止盈侧,本条已结案\n" ) send_wechat_msg(msg) _finalize_key_monitor_one_shot(conn, r, msg, "fib_invalidate") @@ -6098,11 +6098,11 @@ def _add_fib_key_monitor( time_close_enabled=0, time_close_hours=None, ): if _fib_key_exists_for_symbol(conn, symbol): - return False, f"{symbol} 已有斐波监控(同币仅允许一条 0.618/0.786)" + 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)" + return False, "斐波上下沿无效(需上沿 H > 下沿 L)" entry, sl, tp = plan ex_sym = normalize_exchange_symbol(symbol) entry = round_price_to_exchange(ex_sym, entry) @@ -6114,7 +6114,7 @@ def _add_fib_key_monitor( 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)" + 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 @@ -6134,7 +6134,7 @@ def _add_fib_key_monitor( available_usdt = get_available_trading_usdt() risk_fraction = calc_risk_fraction(direction_sel, entry, sl) if risk_fraction is None: - return False, "止损方向不合法(相对挂单价 E);请核对上下沿与方向" + 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) @@ -6146,7 +6146,7 @@ def _add_fib_key_monitor( if margin_capital > max_margin: return ( False, - f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", + f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", ) try: amount, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry) @@ -6185,10 +6185,10 @@ def _add_false_breakout_key_monitor( time_close_enabled=0, time_close_hours=None, ): if _false_breakout_exists_for_symbol(conn, symbol): - return False, f"{symbol} 已有假突破监控(同币仅允许一条)" + return False, f"{symbol} 已有假突破监控(同币仅允许一条)" plan = calc_false_breakout_plan(direction_sel, key_px) if not plan: - return False, "假突破价位无效,请核对方向与关键价位" + return False, "假突破价位无效,请核对方向与关键价位" entry, sl, tp = plan ex_sym = normalize_exchange_symbol(symbol) entry = round_price_to_exchange(ex_sym, entry) @@ -6216,7 +6216,7 @@ def _add_false_breakout_key_monitor( available_usdt = get_available_trading_usdt() risk_fraction = calc_risk_fraction(direction_sel, entry, sl) if risk_fraction is None: - return False, "止损方向不合法(相对挂单价);请核对方向与关键价位" + 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) @@ -6228,7 +6228,7 @@ def _add_false_breakout_key_monitor( if margin_capital > max_margin: return ( False, - f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", + f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", ) try: amount, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry) @@ -6254,7 +6254,7 @@ def _add_false_breakout_key_monitor( return True, None -# 关键位监控(箱体/收敛可自动开仓;阻力/支撑为双向 5m 收盘突破 + 三次提醒) +# 关键位监控(箱体/收敛可自动开仓;阻力/支撑为双向 5m 收盘突破 + 三次提醒) def check_key_monitors(): conn = get_db() rows = conn.execute("SELECT * FROM key_monitors").fetchall() @@ -6283,10 +6283,10 @@ def check_key_monitors(): 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"**账户:{_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" + f"{format_price_for_symbol(sym, edge)}(设置失效)\n" ) send_wechat_msg(msg) _finalize_key_monitor_one_shot(conn, r, msg, "box_opposite_break") @@ -6302,7 +6302,7 @@ def check_key_monitors(): 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)主趋势逆势,建议降低仓位并严格执行止损。" + risk_tip = "当前信号与本币4h(EMA55)主趋势逆势,建议降低仓位并严格执行止损." key_price = float(low) if direction == "long" else float(up) hard_lines = _key_hard_lines_from_checks(checks) @@ -6313,15 +6313,15 @@ def check_key_monitors(): plan_tuple, sl_tp_mode = _key_plan_sl_tp_for_row(r, direction, up, low, checks) if not plan_tuple: - fmt_rr = "无法计算(止损/止盈与确认价几何关系无效)" + 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" + 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) @@ -6348,23 +6348,23 @@ def check_key_monitors(): 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 "无法计算(止损/止盈与确认价几何关系无效)" + 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" + 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) @@ -6396,15 +6396,15 @@ def check_key_monitors(): 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" + 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) @@ -6416,7 +6416,7 @@ def check_key_monitors(): continue tpsl_txt = ( - "已在交易所挂止盈/止损触发单(Binance U 本位条件单)" + "已在交易所挂止盈/止损触发单(Binance U 本位条件单)" if det.get("tpsl_attached") else "⚠️ 条件单挂接状态异常或未挂上" ) @@ -6425,23 +6425,23 @@ def check_key_monitors(): 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"**账户:{_wechat_account_label()}**", + f"- **来源:**{ORDER_MONITOR_TYPE_KEY_AUTO}(市价)", + f"- 页面订单 ID:**{det['new_order_id']}**", + f"- 交易所订单 ID:`{det.get('open_order_id') or '-'}`", + f"- 类型:{typ}|方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'开' if be_on else '关'}", + f"- 方向:**{_wechat_direction_text(direction)}**", + f"- 触发时间:`{trigger_time}`", + f"- 确认K收盘(E):{format_price_for_symbol(sym, E)}(RR 阈值按此计价)", + f"- **计划 RR(E):{planned_rr_txt}:1**", + f"- 开仓成交价:**{format_price_for_symbol(sym, det['trigger_price'])}**", + f"- **成交价侧计划 RR:**{rr_fill_txt}:1", + f"- 止损:{format_wechat_scalar_2dp(sl_raw)}", + f"- 止盈:{format_price_for_symbol(sym, tp_raw)}", + f"- 风险:{det.get('risk_percent')}%≈{format_wechat_scalar_2dp(det.get('risk_amount_final'))}U|基数 {format_wechat_scalar_2dp(det.get('margin_capital'))}U|杠杆 {det.get('leverage')}x", f"- 名义 {format_wechat_scalar_2dp(det.get('notional_value'))}U|张数 {format_wechat_scalar_2dp(det.get('amount'))}|折算标的 {det.get('base_amount')}", f"- **{tpsl_txt}**", - f"- 保本触发:{det.get('breakeven_rr_trigger')}R→{format_price_for_symbol(sym, det.get('breakeven_price'))}", + f"- 保本触发:{det.get('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]) @@ -6462,7 +6462,7 @@ def check_key_monitors(): det.get("opens_today_after", 0), DAILY_OPEN_ALERT_THRESHOLD, hard_limit=DAILY_OPEN_HARD_LIMIT, - detail_line=f"最新一笔来源为关键位自动单:{sym} {direction},杠杆{det['leverage']}x。", + detail_line=f"最新一笔来源为关键位自动单:{sym} {direction},杠杆{det['leverage']}x.", ) ) if advice: @@ -6470,7 +6470,7 @@ def check_key_monitors(): conn.commit() conn.close() -# 止盈止损监控(已修复:严格区分多空,无默认做多) +# 止盈止损监控(已修复:严格区分多空,无默认做多) def check_order_monitors(): conn = get_db() rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() @@ -6482,7 +6482,7 @@ def check_order_monitors(): p = get_price(sym) if not p: continue - # 到达设定 R 倍后,按阶梯持续上移止损(本地风控层) + # 到达设定 R 倍后,按阶梯持续上移止损(本地风控层) risk_amount = float(r["risk_amount"] or 0) breakeven_armed = int(r["breakeven_armed"] or 0) trigger_rr = float(r["breakeven_rr_trigger"] or BREAKEVEN_RR_TRIGGER) @@ -6533,7 +6533,7 @@ def check_order_monitors(): ) _send_breakeven_exchange_warn_once( pid, - f"⚠️ {sym} 移动保本止损未同步交易所:{friendly_exchange_error(e)}", + f"⚠️ {sym} 移动保本止损未同步交易所:{friendly_exchange_error(e)}", ) elif ok_live: print( @@ -6558,7 +6558,7 @@ def check_order_monitors(): new_sl, ) if ok_live: - be_msg += "\n- 交易所:已先撤后挂止盈止损" + be_msg += "\n- 交易所:已先撤后挂止盈止损" send_wechat_msg(be_msg) res = None @@ -6589,7 +6589,7 @@ def check_order_monitors(): 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) @@ -6640,7 +6640,7 @@ def check_order_monitors(): 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: @@ -6689,7 +6689,7 @@ def check_order_monitors(): actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), result=res, miss_reason=handoff_trade_miss_reason( - "触发价已触达,仓位已由交易所止盈/止损或其他方式平掉(本地补记)", + "触发价已触达,仓位已由交易所止盈/止损或其他方式平掉(本地补记)", r, ), opened_at=opened_at, @@ -6700,7 +6700,7 @@ def check_order_monitors(): build_wechat_close_message( symbol=sym, direction=direction, - result=f"{res}(交易所已先行平仓)", + result=f"{res}(交易所已先行平仓)", pnl_amount=pnl_amount, hold_seconds=hold_seconds, trigger_price=trigger_price, @@ -6708,7 +6708,7 @@ def check_order_monitors(): stop_loss=stop_loss, take_profit=take_profit, close_order_id="-", - extra_note="本地补记:仓位由交易所止盈/止损或其他方式先行平掉", + extra_note="本地补记:仓位由交易所止盈/止损或其他方式先行平掉", session_capital_fallback=session_capital, ) ) @@ -6722,11 +6722,11 @@ def check_order_monitors(): 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}" + 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}" + record_miss = f"触发{res}后交易所平仓失败(请核对交易所仓位):{e}" monitor_status = "error" record_hold = calc_hold_seconds( opened_at, parse_dt_for_trading_day(record_closed) or now @@ -6772,7 +6772,7 @@ def check_order_monitors(): build_wechat_close_message( symbol=sym, direction=direction, - result=f"{record_res}(已补记入交易记录)", + result=f"{record_res}(已补记入交易记录)", pnl_amount=record_pnl, hold_seconds=record_hold, trigger_price=trigger_price, @@ -6847,7 +6847,7 @@ def force_close_before_reset(): if not FORCE_CLOSE_ENABLED: return now = app_now() - # 每天北京时间指定整点小时内执行一次性兜底清仓(默认 00:xx) + # 每天北京时间指定整点小时内执行一次性兜底清仓(默认 00:xx) if now.hour != FORCE_CLOSE_BJ_HOUR: return conn = get_db() @@ -7005,9 +7005,9 @@ def sync_positions(): conn.commit() conn.close() if sync_days is not None: - flash(f"同步完成:最近 {sync_days} 天内 {synced} 笔持仓已按交易所状态更新") + flash(f"同步完成:最近 {sync_days} 天内 {synced} 笔持仓已按交易所状态更新") else: - flash(f"同步完成:{synced} 笔持仓已按交易所状态更新") + flash(f"同步完成:{synced} 笔持仓已按交易所状态更新") return redirect("/") @@ -7156,7 +7156,7 @@ def render_main_page(page="trade", embed_mode=None): funding_capital, trading_capital = get_exchange_capitals() else: funding_capital, trading_capital = None, None - # 资金账户:仅展示交易所读取结果(含 0)。不可用 TOTAL_CAPITAL 兜底,否则会与实盘不符。 + # 资金账户:仅展示交易所读取结果(含 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) @@ -8043,7 +8043,7 @@ def api_order_kline(): 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 + return jsonify({"ok": False, "msg": f"K线加载失败:{friendly_exchange_error(e)}"}), 500 candles = [] for bar in ohlcv or []: @@ -8173,7 +8173,7 @@ def api_key_kline(): 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 + return jsonify({"ok": False, "msg": f"K线加载失败:{friendly_exchange_error(e)}"}), 500 candles = [] for bar in ohlcv or []: @@ -8293,10 +8293,10 @@ def add_key(): if not skip_volume_rank: rank, total = _daily_volume_rank(symbol) if rank is None: - flash("日成交量排名读取失败,请稍后重试") + flash("日成交量排名读取失败,请稍后重试") return redirect("/key_monitor") if rank > KEY_DAILY_VOLUME_RANK_MAX: - flash(f"{symbol} 当前日成交量排名为 {rank}/{total},不在前{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: @@ -8304,8 +8304,8 @@ def add_key(): if occupied >= MAX_ACTIVE_POSITIONS: conn.close() flash( - f"当前持仓已达上限({occupied}/{MAX_ACTIVE_POSITIONS}):无法添加「箱体突破 / 收敛突破」。" - "请平仓后再试,或使用「关键支撑阻力」(仅提醒)。" + f"当前持仓已达上限({occupied}/{MAX_ACTIVE_POSITIONS}):无法添加「箱体突破 / 收敛突破」." + "请平仓后再试,或使用「关键支撑阻力」(仅提醒)." ) return redirect("/key_monitor") ex_sym_key = normalize_exchange_symbol(symbol) @@ -8333,7 +8333,7 @@ def add_key(): if entry_px <= 0 or sl_px <= 0 or tp_px <= 0: conn.close() conn = None - flash("触价须填写有效的入场价、止损价、止盈价") + flash("触价须填写有效的入场价,止损价,止盈价") return redirect("/key_monitor") ok_te, err_te = _add_trigger_entry_key_monitor( conn, @@ -8359,10 +8359,10 @@ def add_key(): else "标记价回调触达入场价后下一轮询市价开仓" ) flash( - f"{mt}已添加({symbol} 日成交量排名 {rank}/{total})" + f"{mt}已添加({symbol} 日成交量排名 {rank}/{total})" f"|有效期 {TRIGGER_ENTRY_VALIDITY_HOURS}h" f"|{trigger_hint}" - f"|移动保本:{'开' if be_flag else '关'}" + f"|移动保本:{'开' if be_flag else '关'}" + (f"|{time_close_label(tc_h)}" if tc_en else "") ) return redirect("/key_monitor") @@ -8383,7 +8383,7 @@ def add_key(): key_px = 0 if key_px <= 0: conn.close() - flash("请填写关键价位(做空填高点,做多填低点)") + flash("请填写关键价位(做空填高点,做多填低点)") return redirect("/key_monitor") ex_sym_key = normalize_exchange_symbol(symbol) key_adj = round_price_to_exchange(ex_sym_key, key_px) @@ -8403,8 +8403,8 @@ def add_key(): flash(err_fb or "假突破监控添加失败") return redirect("/key_monitor") flash( - f"假突破监控已添加,限价单已挂出({symbol})" - f"|有效期 {FALSE_BREAKOUT_VALIDITY_HOURS}h|移动保本:{'开' if be_flag else '关'}" + 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"])) @@ -8425,8 +8425,8 @@ def add_key(): flash(err_fib or "斐波监控添加失败") return redirect("/key_monitor") flash( - f"斐波监控已添加,限价单已挂出({symbol} 日成交量排名 {rank}/{total})" - f"|移动保本:{'开' if be_flag else '关'}" + f"斐波监控已添加,限价单已挂出({symbol} 日成交量排名 {rank}/{total})" + f"|移动保本:{'开' if be_flag else '关'}" ) return redirect("/key_monitor") sl_tp_mode = "standard" @@ -8444,11 +8444,11 @@ def add_key(): return redirect("/key_monitor") if direction_sel == "long" and manual_tp <= upper_px: conn.close() - flash("做多趋势单:止盈价应高于上沿(阻力)") + flash("做多趋势单:止盈价应高于上沿(阻力)") return redirect("/key_monitor") if direction_sel == "short" and manual_tp >= lower_px: conn.close() - flash("做空趋势单:止盈价应低于下沿(支撑)") + flash("做空趋势单:止盈价应低于下沿(支撑)") return redirect("/key_monitor") mtpx = round_price_to_exchange(ex_sym_key, manual_tp) if mtpx is not None: @@ -8491,17 +8491,17 @@ def add_key(): pass extra = "" if mt in KEY_MONITOR_AUTO_TYPES: - extra = f"|方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'开' if be_flag else '关'}" + 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} 分钟)" + f"添加成功({symbol} 日成交量排名 {rank}/{total})|关键支撑阻力:双向监控上/下沿," + f"5m 收盘突破后微信提醒 {KEY_ALERT_MAX_TIMES} 次(间隔 {KEY_ALERT_INTERVAL_MINUTES} 分钟)" ) else: - flash(f"添加成功({symbol} 日成交量排名 {rank}/{total}){extra}") + flash(f"添加成功({symbol} 日成交量排名 {rank}/{total}){extra}") if ctr: flash( - "⚠️ 4h EMA55 提示:当前与所选方向逆势;「箱体突破/收敛突破」在条件满足时仍会按计划自动市价开仓,请注意仓位。" + "⚠️ 4h EMA55 提示:当前与所选方向逆势;「箱体突破/收敛突破」在条件满足时仍会按计划自动市价开仓,请注意仓位." ) return redirect("/key_monitor") @@ -8520,7 +8520,7 @@ def add_order(): ok_pol, pol_msg = validate_trade_policy_open(symbol, direction) if not ok_pol: conn.close() - flash(f"账户限制:{pol_msg}") + flash(f"账户限制:{pol_msg}") return redirect("/trade") dup_msg = check_duplicate_submit(session, submit_scope_add_order(symbol, direction)) if dup_msg: @@ -8530,12 +8530,12 @@ def add_order(): ok, reason = precheck_risk(conn, symbol, direction) if not ok: conn.close() - flash(f"风控拒绝下单:{reason}") + flash(f"风控拒绝下单:{reason}") return redirect("/trade") ok_live, reason_live = ensure_exchange_live_ready() if not ok_live: conn.close() - flash(f"风控拒绝下单:{reason_live}") + flash(f"风控拒绝下单:{reason_live}") return redirect("/") exchange_symbol = normalize_exchange_symbol(symbol) trading_day = get_trading_day(now) @@ -8557,7 +8557,7 @@ def add_order(): live_price = get_price(symbol) if live_price is None: conn.close() - flash("获取交易所实时价格失败,请稍后重试") + flash("获取交易所实时价格失败,请稍后重试") return redirect("/") sltp_mode = normalize_open_sltp_mode(d.get("sltp_mode")) try: @@ -8576,12 +8576,12 @@ def add_order(): 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") + 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("止损方向不合法:请检查入场方向与止损价格关系") + flash("止损方向不合法:请检查入场方向与止损价格关系") return redirect("/") risk_percent = max(0.01, float(RISK_PERCENT)) risk_amount = round(capital_base * risk_percent / 100.0, FUNDS_DECIMALS) @@ -8625,13 +8625,13 @@ def add_order(): margin_capital = round(notional_value / leverage, FUNDS_DECIMALS) if capital_base and margin_capital > capital_base: conn.close() - flash("以损定仓后保证金超过当前交易资金,请放宽止损或降低风险比例") + 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") + 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: @@ -8767,11 +8767,11 @@ def add_order(): 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)" + dir_text = "多头(long)" if direction == "long" else "空头(short)" order_state_text = ( - "已在交易所挂条件委托(止盈、止损各一张触发单)" + "已在交易所挂条件委托(止盈,止损各一张触发单)" if tpsl_attached - else "条件委托未挂上(已拦截)" + else "条件委托未挂上(已拦截)" ) rr_show = planned_rr if planned_rr is not None else "-" try: @@ -8786,43 +8786,43 @@ def add_order(): style_zh = "Swing 波段" if trade_style == "swing" else "Trend 趋势" wx_lines = [ f"📈 {symbol} 开仓成功", - f"💼 交易类型:{dir_text}", + f"💼 交易类型:{dir_text}", "🧾 订单基础信息", - f"🔖 交易所订单 ID:{open_order_id}", - f"📈 交易风格:{style_zh}", - f"⚠️ 单笔风控风险:{risk_display}", + 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"账户基数:{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"开仓成交价:{ep_wx}", + f"止损价位:{sl_wx}", + f"止盈价位:{tp_wx}", + f"计划盈亏比:{rr_line}", + f"移动保本位:{breakeven_rr_trigger}R → {be_wx}", "📌 状态统计", - f"✅ 条件委托:{order_state_text}", + 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}") + 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 '-'};已在交易所挂条件止盈/止损委托(非仓位绑定型)", + 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_lines.append(f"已生成多周期K线图:{chart_url}") flash(" ".join(flash_lines)) if should_send_daily_open_alert( @@ -8834,12 +8834,12 @@ def add_order(): opens_today_after, DAILY_OPEN_ALERT_THRESHOLD, hard_limit=DAILY_OPEN_HARD_LIMIT, - detail_line=f"最新一笔:{symbol} {direction},杠杆{leverage}x,基数{margin_capital}U。", + 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]}") + flash(f"【AI提醒】今日开仓次数已达 {opens_today_after}:{advice[:300]}") return redirect("/") @app.route("/delete_key_monitor/", methods=["POST"]) @@ -9190,7 +9190,7 @@ def del_order(id): 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}" + 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) @@ -9241,10 +9241,10 @@ def del_order(id): pass conn.commit() conn.close() - flash("该仓位在交易所已不存在,已按成交记录同步结束并记账") + flash("该仓位在交易所已不存在,已按成交记录同步结束并记账") return redirect("/") conn.close() - flash(f"手动平仓失败:{str(e)}") + flash(f"手动平仓失败:{str(e)}") return redirect("/") conn.execute("DELETE FROM order_monitors WHERE id=?",(id,)) conn.commit() @@ -9270,7 +9270,7 @@ def add_journal(): 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) @@ -9292,7 +9292,7 @@ def add_journal(): try: risk_amount_hint = float(d.get("risk_amount_hint") or 0) pnl_hint = float(d.get("pnl") or 0) - # 口径统一:实际RR = 实际盈亏 / 以损定仓对应的初始风险金额 + # 口径统一:实际RR = 实际盈亏 / 以损定仓对应的初始风险金额 if risk_amount_hint > 0: real_rr_text = f"{(pnl_hint / risk_amount_hint):.2f}" except Exception: @@ -9340,11 +9340,11 @@ def add_journal(): ) if saved: image_filename = saved - chart_msg = f"已生成复盘K线图({'/'.join(journal_tfs)} 各{journal_limit}根):/static/images/{saved}" + chart_msg = f"已生成复盘K线图({'/'.join(journal_tfs)} 各{journal_limit}根):/static/images/{saved}" else: - chart_msg = "已勾选自动生成K线图,但生成失败(返回空)。请检查 Pillow 是否安装、Binance 网络/代理是否正常。" + chart_msg = "已勾选自动生成K线图,但生成失败(返回空).请检查 Pillow 是否安装,Binance 网络/代理是否正常." except Exception as e: - chart_msg = f"自动生成K线图失败:{str(e)}" + chart_msg = f"自动生成K线图失败:{str(e)}" conn = get_db() conn.execute( @@ -9381,7 +9381,7 @@ def add_journal(): conn.commit() conn.close() if chart_msg: - flash(f"交易复盘记录已保存。{chart_msg}") + flash(f"交易复盘记录已保存.{chart_msg}") else: flash("交易复盘记录已保存") return _redirect_records() @@ -9494,7 +9494,7 @@ def export_review_md(rid): created_at = row["created_at"] or app_now_str() content = (row["content"] or "").strip() if not content: - content = "(无内容)" + content = "(无内容)" md = ( f"# {review_type}报告\n\n" @@ -9543,7 +9543,7 @@ def export_reviews_md_bundle(): ] for idx, row in enumerate(rows, 1): created_at = row["created_at"] or "-" - content = (row["content"] or "").strip() or "(无内容)" + content = (row["content"] or "").strip() or "(无内容)" lines.extend( [ f"## 第{idx}条", @@ -9602,13 +9602,13 @@ def api_trade_record_review_update(): 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 + 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 + 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()) @@ -9710,9 +9710,9 @@ def manual_transfer(): conn.commit() conn.close() if ok: - flash(f"手动划转成功:{amount}U {from_account}->{to_account}") + flash(f"手动划转成功:{amount}U {from_account}->{to_account}") else: - flash(f"手动划转失败:{msg}") + flash(f"手动划转失败:{msg}") return redirect("/settings") @@ -9741,7 +9741,7 @@ def ai_daily_review(): if not rows: return jsonify({"result": "该日无交易记录"}) - text = f"【每日交易记录】{date}\n总笔数:{len(rows)}\n\n" + 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" @@ -9752,7 +9752,7 @@ def ai_daily_review(): 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}" + 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 (?,?,?,?)", @@ -9777,7 +9777,7 @@ def ai_weekly_review(): if not rows: return jsonify({"result": "该时间段无交易记录"}) - text = f"【周交易记录】{start_date}~{end_date}\n总笔数:{len(rows)}\n\n" + 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" @@ -9788,7 +9788,7 @@ def ai_weekly_review(): 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}" + 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 (?,?,?,?)", @@ -9802,8 +9802,8 @@ 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"周期 {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, diff --git a/crypto_monitor_binance/ecosystem.config.cjs b/crypto_monitor_binance/ecosystem.config.cjs index a61a670..1e65906 100644 --- a/crypto_monitor_binance/ecosystem.config.cjs +++ b/crypto_monitor_binance/ecosystem.config.cjs @@ -1,14 +1,14 @@ /** - * PM2 进程定义(Ubuntu / Linux)。 + * PM2 进程定义(Ubuntu / Linux). * - * 仅托管 Flask 应用。**SSH SOCKS 隧道**用 `ssh -D` 常驻(可用 tmux / autossh),勿交给 PM2。 - * 与 `.env` 里 `BINANCE_SOCKS_PROXY` 端口一致即可;不必交给 PM2。 + * 仅托管 Flask 应用.**SSH SOCKS 隧道**用 `ssh -D` 常驻(可用 tmux / autossh),勿交给 PM2. + * 与 `.env` 里 `BINANCE_SOCKS_PROXY` 端口一致即可;不必交给 PM2. * - * 使用前:项目根目录存在 `.venv`,且已安装依赖(走 SOCKS 时需 PySocks)。 + * 使用前:项目根目录存在 `.venv`,且已安装依赖(走 SOCKS 时需 PySocks). * - * 启动: + * 启动: * pm2 start ecosystem.config.cjs - * 保存开机列表: + * 保存开机列表: * pm2 save && pm2 startup */ const path = require("path"); diff --git a/crypto_monitor_binance/scripts/fix_breakeven_labels.py b/crypto_monitor_binance/scripts/fix_breakeven_labels.py index 80b7d04..97a910a 100644 --- a/crypto_monitor_binance/scripts/fix_breakeven_labels.py +++ b/crypto_monitor_binance/scripts/fix_breakeven_labels.py @@ -1,18 +1,18 @@ #!/usr/bin/env python3 """ -一次性修复历史交易记录标签: -将 trade_records 里“止损但实际盈利”的记录改为“保本止盈”。 +一次性修复历史交易记录标签: +将 trade_records 里“止损但实际盈利”的记录改为“保本止盈”. -默认条件(可通过参数修改): +默认条件(可通过参数修改): - monitor_type = 下单监控 - result = 止损 - pnl_amount > 0 -用法示例: -1) 仅预览(不落库): +用法示例: +1) 仅预览(不落库): python scripts/fix_breakeven_labels.py --db ./crypto.db --dry-run -2) 执行修复: +2) 执行修复: python scripts/fix_breakeven_labels.py --db ./crypto.db --apply """ diff --git a/crypto_monitor_binance/scripts/patch_index_layout.py b/crypto_monitor_binance/scripts/patch_index_layout.py index 8b68b31..3f239f1 100644 --- a/crypto_monitor_binance/scripts/patch_index_layout.py +++ b/crypto_monitor_binance/scripts/patch_index_layout.py @@ -115,15 +115,15 @@ def build_section(order_loop: str) -> str: {{% endif %}} <{t} class="rule-tip" id="order-rule-tip"> - 规则:最多 {{{{ max_active_positions }}}} 仓;BTC {{{{ btc_leverage }}}}x / 山寨 {{{{ alt_leverage }}}}x; - {{% if can_trade %}}可开仓{{% else %}}不可开仓(持仓已满或未到北京时间 {{{{ reset_hour }}}}:00){{% endif %}}; + 规则:最多 {{{{ max_active_positions }}}} 仓;BTC {{{{ btc_leverage }}}}x / 山寨 {{{{ alt_leverage }}}}x; + {{% if can_trade %}}可开仓{{% else %}}不可开仓(持仓已满或未到北京时间 {{{{ reset_hour }}}}:00){{% endif %}}; 人工开仓盈亏比不得低于 {{{{ manual_min_planned_rr }}}}:1 <{t} class="rule-tip"> - 以损定仓:风险 {{{{ risk_percent }}}}% |移动保本:下单可勾选关闭;开启时 {{{{ breakeven_rr_trigger }}}}R 触发(每 1R 阶梯上移),偏移 {{{{ breakeven_offset_pct }}}}% + 以损定仓:风险 {{{{ risk_percent }}}}% |移动保本:下单可勾选关闭;开启时 {{{{ breakeven_rr_trigger }}}}R 触发(每 1R 阶梯上移),偏移 {{{{ breakeven_offset_pct }}}}% <{t} class="rule-tip"> - 划转:自动划转 {{{{ '开启' if auto_transfer_enabled else '关闭' }}}}(每天北京时间 {{{{ auto_transfer_bj_hour }}}}:00起该整点小时内尝试;账簿按 UTC 自然日去重;界面时间为北京;将 {{{{ auto_transfer_to }}}} 补足到 {{{{ auto_transfer_amount }}}}U,来自 {{{{ auto_transfer_from }}}}) + 划转:自动划转 {{{{ '开启' if auto_transfer_enabled else '关闭' }}}}(每天北京时间 {{{{ auto_transfer_bj_hour }}}}:00起该整点小时内尝试;账簿按 UTC 自然日去重;界面时间为北京;将 {{{{ auto_transfer_to }}}} 补足到 {{{{ auto_transfer_amount }}}}U,来自 {{{{ auto_transfer_from }}}})
@@ -145,8 +145,8 @@ def build_section(order_loop: str) -> str: 成交价自动取交易所实时+成交回报 - +
<{t} class="card"> @@ -218,7 +218,7 @@ if(addOrderForm){ const mode = (document.getElementById("sltp-mode")||{}).value || "price"; let sl, tp, entry; if(mode === "pct"){ - alert("百分比模式请确认盈亏比后再提交;建议使用价格模式以便校验。"); + alert("百分比模式请确认盈亏比后再提交;建议使用价格模式以便校验."); return; } sl = Number((document.getElementById("order-sl")||{}).value); @@ -231,12 +231,12 @@ if(addOrderForm){ 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,已阻止人工下单。`); + alert(`计划盈亏比 ${rr === null ? '无效' : rr.toFixed(2)}:1 低于最低要求 ${MANUAL_MIN_PLANNED_RR}:1,已阻止人工下单.`); return; } addOrderForm.submit(); }) - .catch(()=>{ ev.preventDefault(); alert("无法校验盈亏比,请稍后重试"); }); + .catch(()=>{ ev.preventDefault(); alert("无法校验盈亏比,请稍后重试"); }); ev.preventDefault(); }); } @@ -244,27 +244,27 @@ if(addOrderForm){ 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 }} 仓;", + "规则:单仓;", + "规则:最多 {{ max_active_positions }} 仓;", ) # account snapshot tip - old_tip = '`规则:单仓;BTC {{ btc_leverage }}x' + 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", + "`规则:最多 ${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)`;', + '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},已拦截", + "不在前30,已拦截", + "不在前${rankMax},已拦截", ) # conditional price refresh if "data-page" in text and "refreshPriceSnapshotConditional" not in text: diff --git a/crypto_monitor_binance/scripts/sync_gate_app.py b/crypto_monitor_binance/scripts/sync_gate_app.py index 81e467d..9668455 100644 --- a/crypto_monitor_binance/scripts/sync_gate_app.py +++ b/crypto_monitor_binance/scripts/sync_gate_app.py @@ -68,8 +68,8 @@ if "key_monitor_page" not in g: " 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"周期 {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() diff --git a/crypto_monitor_binance/scripts/verify_binance_funding.py b/crypto_monitor_binance/scripts/verify_binance_funding.py index 9788fe8..f966267 100644 --- a/crypto_monitor_binance/scripts/verify_binance_funding.py +++ b/crypto_monitor_binance/scripts/verify_binance_funding.py @@ -2,8 +2,8 @@ """ python scripts/verify_binance_funding.py -打印 BINANCE_API_KEY 前 8 位便于与 Binance 控制台核对(不含 Secret)。用于服务器自检。 -对比 App:资产 → 资金账户(Funding) / 现货账户(Spot) / U本位合约。 +打印 BINANCE_API_KEY 前 8 位便于与 Binance 控制台核对(不含 Secret).用于服务器自检. +对比 App:资产 → 资金账户(Funding) / 现货账户(Spot) / U本位合约. """ import os import sys @@ -30,9 +30,9 @@ def main(): 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") + print("WARN: BINANCE_API_KEY 为空或仍像占位符,请核对 .env") if not s or "REPLACE" in s.upper(): - print("WARN: BINANCE_API_SECRET 为空或仍像占位符,请核对 .env") + 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")) diff --git a/crypto_monitor_binance/templates/order_focus.html b/crypto_monitor_binance/templates/order_focus.html index c0992d4..cb7c8df 100644 --- a/crypto_monitor_binance/templates/order_focus.html +++ b/crypto_monitor_binance/templates/order_focus.html @@ -29,9 +29,9 @@
返回首页 - 实盘下单放大(100根K线) + 实盘下单放大(100根K线)
-
最近刷新:--
+
最近刷新:--
{% if orders %}
@@ -53,7 +53,7 @@
{% else %} -
当前没有激活订单,无法展示放大K线。
+
当前没有激活订单,无法展示放大K线.
{% endif %} diff --git a/crypto_monitor_binance/使用说明.md b/crypto_monitor_binance/使用说明.md index 3692a6d..2dff3d7 100644 --- a/crypto_monitor_binance/使用说明.md +++ b/crypto_monitor_binance/使用说明.md @@ -1,139 +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`。 +# 使用说明 + +**本文件对应仓库:`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 index d69283d..4c76756 100644 --- a/crypto_monitor_binance/关键位自动下单说明.md +++ b/crypto_monitor_binance/关键位自动下单说明.md @@ -1,192 +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` | +# 关键位监控说明(自动开仓 + 人工盯盘) + +**适用: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 index 02e63cc..5289238 100644 --- a/crypto_monitor_binance/更新文档.md +++ b/crypto_monitor_binance/更新文档.md @@ -1,147 +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 与订单监控是否正常;平仓后检查交易记录止损(开仓)与开仓类型。 +# 界面与风控更新说明(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 index 894a30e..0e18a9a 100644 --- a/crypto_monitor_binance/部署文档.md +++ b/crypto_monitor_binance/部署文档.md @@ -1,389 +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 -cd /opt/crypto_monitor -git clone https://git.bz121.com/dekun/crypto_monitor.git -cd crypto_monitor/crypto_monitor_binance -``` - -下文用 **`/opt/crypto_monitor/crypto_monitor_binance`** 仅为示例,请换成你的实际绝对路径。 - -拉取代码后,若目录下尚无 `.env`,先从模板生成(**勿**把填好密钥的 `.env` 提交 Git): - -```bash -cp -n .env.example .env # -n:已存在 .env 时不覆盖 -``` - ---- - -## 2. 配置 SSH 私钥与 `~/.ssh/config` - -```bash -mkdir -p ~/.ssh -chmod 700 ~/.ssh -# 私钥示例:~/.ssh/vps1.pem -chmod 600 ~/.ssh/vps1.pem -``` - -编辑 `~/.ssh/config`(示例别名 **`bn-vps`**,与你手工启动 `ssh -D ... bn-vps` 一致即可): - -```sshconfig -Host bn-vps - HostName 你的_VPS_IP - User root - IdentityFile ~/.ssh/vps1.pem - IdentitiesOnly yes - ServerAliveInterval 30 - ServerAliveCountMax 3 - ExitOnForwardFailure yes - BatchMode yes -``` - -测试: - -```bash -ssh bn-vps true -``` - -> 若尚未完全改为密钥登录,可暂时注释 `BatchMode yes`,调试完成后再打开。 - ---- - -## 3. 手工验证:SSH SOCKS + Binance API - -### 3.1 本地 SOCKS(示例端口 1080) - -```bash -ssh -N -D 127.0.0.1:1080 bn-vps -``` - -保持运行,另开终端继续。 - -### 3.2 验证经 SOCKS 可访问 Binance(公开接口) - -```bash -curl -4 -sS --max-time 15 --proxy socks5h://127.0.0.1:1080 https://api.binance.com/api/v3/time -``` - -应返回 JSON(含 `serverTime` 字段)。若此处失败,**不要先启动应用**:先修隧道或 VPS 出站。 - ---- - -## 4. Python 虚拟环境 - -```bash -cd /opt/crypto_monitor/crypto_monitor_binance - -python3 -m venv .venv -source .venv/bin/activate -python -m pip install -U pip -pip install flask requests ccxt werkzeug PySocks Pillow -``` - -走 SOCKS 时 **必须** 安装 **`PySocks`**,否则易出现代理相关报错。 - -可选: - -```bash -export PYTHONDONTWRITEBYTECODE=1 -``` - ---- - -## 5. 配置环境变量(`.env.example` → `.env`) - -| 文件 | 是否进 Git | 说明 | -|------|------------|------| -| **`.env.example`** | ✅ 是 | 变量模板与注释,可随 `git pull` 更新 | -| **`.env`** | ❌ 否 | 本机真实配置;`app.py` **只读此文件** | - -### 5.1 首次配置 - -```bash -cd /opt/crypto_monitor/crypto_monitor_binance - -cp -n .env.example .env # 已存在 .env 时不覆盖 -nano .env # 填入 API、登录密码、端口、代理等 -``` - -### 5.2 备份与 `git pull` - -- **`.env` 已被仓库根目录 `.gitignore` 忽略**:`git pull` **不会**覆盖或删除你本地的 `.env`。 -- 若远端更新了 **`.env.example`**(新增变量名),pull 后请对照模板,**手动把新行补进你的 `.env`**(不会自动合并进 `.env`)。 -- **建议在每次 `git pull` 或大批量改配置前备份**: - -```bash -cp .env .env.backup.$(date +%Y%m%d) -# 恢复示例:cp .env.backup.20260516 .env -``` - -- **换机 / 迁移**:用 `scp` 复制整份 `.env` 到新机器对应目录;或在新机重新 `cp .env.example .env` 后填写。 - -### 5.3 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/crypto_monitor_binance -chmod +x scripts/backup_data.sh scripts/install_backup_cron.sh -bash scripts/install_backup_cron.sh -``` - -Gate 实例: - -```bash -cd /opt/crypto_monitor/crypto_monitor_gate -bash scripts/install_backup_cron.sh -``` - -实例(趋势回调等): - -```bash -cd /opt/crypto_monitor/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/crypto_monitor_binance -source .venv/bin/activate -python scripts/verify_binance_funding.py -``` - -用于粗测资金钱包与合约钱包 USDT 读取(需有效 API 与权限)。 - ---- - -## 7. 手工启动 Flask(验证) - -1. SOCKS 已监听 `127.0.0.1:1080`(若使用代理) -2. 已 `source .venv/bin/activate` -3. `.env` 已按需配置 `BINANCE_SOCKS_PROXY` 等 - -```bash -cd /opt/crypto_monitor/crypto_monitor_binance -source .venv/bin/activate -python app.py -``` - -浏览器访问:`http://127.0.0.1:5000`(或你在 `.env` 中的端口)。 - ---- - -## 8. 安装 PM2 - -```bash -sudo npm i -g pm2 -pm2 -v -``` - ---- - -## 9. PM2:使用仓库内 `ecosystem.config.cjs`(推荐) - -在项目根目录: - -```bash -cd /opt/crypto_monitor/crypto_monitor_binance -pm2 start ecosystem.config.cjs -pm2 status -pm2 logs --lines 200 -``` - -默认只启动 **`crypto-monitor-binance`**(`.venv/bin/python app.py`)。 - -### 本机已可直连 Binance、不需要隧道时 - -`.env` 里应 **去掉或留空** `BINANCE_SOCKS_PROXY`(除非仍要走别的代理),再 `pm2 start ecosystem.config.cjs`。 - -### 开机自启 - -```bash -pm2 save -pm2 startup -# 按屏幕提示执行一条 sudo 命令 -``` - ---- - -## 10. 等价手工命令(不使用 ecosystem 文件时) - -### 10.1 SSH SOCKS(自行后台常驻,不推荐用 PM2) - -示例(前台调试;生产请用 **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/crypto_monitor_binance -pm2 start /opt/crypto_monitor/crypto_monitor_binance/.venv/bin/python --name crypto-monitor-binance -- \ - /opt/crypto_monitor/crypto_monitor_binance/app.py -``` - ---- - -## 11. 交易所「连接不上」排查清单 - -1. **`.env` 是否为 Binance 变量**:`BINANCE_SOCKS_PROXY` / `BINANCE_HTTP_PROXY` / `BINANCE_API_KEY` / `BINANCE_API_SECRET` 等前缀需与代码一致。 -2. **隧道是否在本机端口监听**(若配置了 `BINANCE_SOCKS_PROXY`): - ```bash - ss -lntp | grep 1080 || true - ``` -3. **curl 复测 Binance**(与第 3.2 节相同);curl 不通则应用也不会通。 -4. **PySocks**:`pip show PySocks`,缺失则 `pip install PySocks`。 -5. **SSH 隧道连不上**:检查私钥权限、`~/.ssh/config`、VPS 出站与端口是否与 `.env` 一致。 -6. **API 权限与 IP 白名单**:Secret 错误、权限不足、未放行当前出口 IP 时,私有接口会失败。 -7. **启动顺序**:若走代理,先保证 SOCKS 已监听,再 `pm2 start` 应用(或重启应用)。 - ---- - -## 12. 推荐启动顺序(习惯) - -1. 若走代理:先启动并确认 SSH SOCKS 已监听,再 `curl --proxy socks5h://127.0.0.1:1080 https://api.binance.com/api/v3/time` 成功 -2. `pm2 start ecosystem.config.cjs` -3. 再确认页面与余额等接口正常 - ---- - -## 13. 免责声明 - -交易所有合规与地区政策要求。请确保使用方式符合当地法律法规与交易所条款。本文仅描述网络与工程部署路径。 - ---- - -## 附录:数据库标签修复脚本 `scripts/fix_breakeven_labels.py` - -在 Ubuntu 上: - -1)预览(不写库): - -```bash -python scripts/fix_breakeven_labels.py --db ./crypto.db --dry-run -``` - -2)确认后执行: - -```bash -python scripts/fix_breakeven_labels.py --db ./crypto.db --apply -``` - -默认修复条件:`monitor_type='下单监控'` 且 `result='止损'` 且 `pnl_amount > 0` → 改为 `result='保本止盈'`。 +# `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 +cd /opt/crypto_monitor +git clone https://git.bz121.com/dekun/crypto_monitor.git +cd crypto_monitor/crypto_monitor_binance +``` + +下文用 **`/opt/crypto_monitor/crypto_monitor_binance`** 仅为示例,请换成你的实际绝对路径. + +拉取代码后,若目录下尚无 `.env`,先从模板生成(**勿**把填好密钥的 `.env` 提交 Git): + +```bash +cp -n .env.example .env # -n:已存在 .env 时不覆盖 +``` + +--- + +## 2. 配置 SSH 私钥与 `~/.ssh/config` + +```bash +mkdir -p ~/.ssh +chmod 700 ~/.ssh +# 私钥示例:~/.ssh/vps1.pem +chmod 600 ~/.ssh/vps1.pem +``` + +编辑 `~/.ssh/config`(示例别名 **`bn-vps`**,与你手工启动 `ssh -D ... bn-vps` 一致即可): + +```sshconfig +Host bn-vps + HostName 你的_VPS_IP + User root + IdentityFile ~/.ssh/vps1.pem + IdentitiesOnly yes + ServerAliveInterval 30 + ServerAliveCountMax 3 + ExitOnForwardFailure yes + BatchMode yes +``` + +测试: + +```bash +ssh bn-vps true +``` + +> 若尚未完全改为密钥登录,可暂时注释 `BatchMode yes`,调试完成后再打开. + +--- + +## 3. 手工验证:SSH SOCKS + Binance API + +### 3.1 本地 SOCKS(示例端口 1080) + +```bash +ssh -N -D 127.0.0.1:1080 bn-vps +``` + +保持运行,另开终端继续. + +### 3.2 验证经 SOCKS 可访问 Binance(公开接口) + +```bash +curl -4 -sS --max-time 15 --proxy socks5h://127.0.0.1:1080 https://api.binance.com/api/v3/time +``` + +应返回 JSON(含 `serverTime` 字段).若此处失败,**不要先启动应用**:先修隧道或 VPS 出站. + +--- + +## 4. Python 虚拟环境 + +```bash +cd /opt/crypto_monitor/crypto_monitor_binance + +python3 -m venv .venv +source .venv/bin/activate +python -m pip install -U pip +pip install flask requests ccxt werkzeug PySocks Pillow +``` + +走 SOCKS 时 **必须** 安装 **`PySocks`**,否则易出现代理相关报错. + +可选: + +```bash +export PYTHONDONTWRITEBYTECODE=1 +``` + +--- + +## 5. 配置环境变量(`.env.example` → `.env`) + +| 文件 | 是否进 Git | 说明 | +|------|------------|------| +| **`.env.example`** | ✅ 是 | 变量模板与注释,可随 `git pull` 更新 | +| **`.env`** | ❌ 否 | 本机真实配置;`app.py` **只读此文件** | + +### 5.1 首次配置 + +```bash +cd /opt/crypto_monitor/crypto_monitor_binance + +cp -n .env.example .env # 已存在 .env 时不覆盖 +nano .env # 填入 API,登录密码,端口,代理等 +``` + +### 5.2 备份与 `git pull` + +- **`.env` 已被仓库根目录 `.gitignore` 忽略**:`git pull` **不会**覆盖或删除你本地的 `.env`. +- 若远端更新了 **`.env.example`**(新增变量名),pull 后请对照模板,**手动把新行补进你的 `.env`**(不会自动合并进 `.env`). +- **建议在每次 `git pull` 或大批量改配置前备份**: + +```bash +cp .env .env.backup.$(date +%Y%m%d) +# 恢复示例:cp .env.backup.20260516 .env +``` + +- **换机 / 迁移**:用 `scp` 复制整份 `.env` 到新机器对应目录;或在新机重新 `cp .env.example .env` 后填写. + +### 5.3 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/crypto_monitor_binance +chmod +x scripts/backup_data.sh scripts/install_backup_cron.sh +bash scripts/install_backup_cron.sh +``` + +Gate 实例: + +```bash +cd /opt/crypto_monitor/crypto_monitor_gate +bash scripts/install_backup_cron.sh +``` + +实例(趋势回调等): + +```bash +cd /opt/crypto_monitor/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/crypto_monitor_binance +source .venv/bin/activate +python scripts/verify_binance_funding.py +``` + +用于粗测资金钱包与合约钱包 USDT 读取(需有效 API 与权限). + +--- + +## 7. 手工启动 Flask(验证) + +1. SOCKS 已监听 `127.0.0.1:1080`(若使用代理) +2. 已 `source .venv/bin/activate` +3. `.env` 已按需配置 `BINANCE_SOCKS_PROXY` 等 + +```bash +cd /opt/crypto_monitor/crypto_monitor_binance +source .venv/bin/activate +python app.py +``` + +浏览器访问:`http://127.0.0.1:5000`(或你在 `.env` 中的端口). + +--- + +## 8. 安装 PM2 + +```bash +sudo npm i -g pm2 +pm2 -v +``` + +--- + +## 9. PM2:使用仓库内 `ecosystem.config.cjs`(推荐) + +在项目根目录: + +```bash +cd /opt/crypto_monitor/crypto_monitor_binance +pm2 start ecosystem.config.cjs +pm2 status +pm2 logs --lines 200 +``` + +默认只启动 **`crypto-monitor-binance`**(`.venv/bin/python app.py`). + +### 本机已可直连 Binance,不需要隧道时 + +`.env` 里应 **去掉或留空** `BINANCE_SOCKS_PROXY`(除非仍要走别的代理),再 `pm2 start ecosystem.config.cjs`. + +### 开机自启 + +```bash +pm2 save +pm2 startup +# 按屏幕提示执行一条 sudo 命令 +``` + +--- + +## 10. 等价手工命令(不使用 ecosystem 文件时) + +### 10.1 SSH SOCKS(自行后台常驻,不推荐用 PM2) + +示例(前台调试;生产请用 **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/crypto_monitor_binance +pm2 start /opt/crypto_monitor/crypto_monitor_binance/.venv/bin/python --name crypto-monitor-binance -- \ + /opt/crypto_monitor/crypto_monitor_binance/app.py +``` + +--- + +## 11. 交易所「连接不上」排查清单 + +1. **`.env` 是否为 Binance 变量**:`BINANCE_SOCKS_PROXY` / `BINANCE_HTTP_PROXY` / `BINANCE_API_KEY` / `BINANCE_API_SECRET` 等前缀需与代码一致. +2. **隧道是否在本机端口监听**(若配置了 `BINANCE_SOCKS_PROXY`): + ```bash + ss -lntp | grep 1080 || true + ``` +3. **curl 复测 Binance**(与第 3.2 节相同);curl 不通则应用也不会通. +4. **PySocks**:`pip show PySocks`,缺失则 `pip install PySocks`. +5. **SSH 隧道连不上**:检查私钥权限,`~/.ssh/config`,VPS 出站与端口是否与 `.env` 一致. +6. **API 权限与 IP 白名单**:Secret 错误,权限不足,未放行当前出口 IP 时,私有接口会失败. +7. **启动顺序**:若走代理,先保证 SOCKS 已监听,再 `pm2 start` 应用(或重启应用). + +--- + +## 12. 推荐启动顺序(习惯) + +1. 若走代理:先启动并确认 SSH SOCKS 已监听,再 `curl --proxy socks5h://127.0.0.1:1080 https://api.binance.com/api/v3/time` 成功 +2. `pm2 start ecosystem.config.cjs` +3. 再确认页面与余额等接口正常 + +--- + +## 13. 免责声明 + +交易所有合规与地区政策要求.请确保使用方式符合当地法律法规与交易所条款.本文仅描述网络与工程部署路径. + +--- + +## 附录:数据库标签修复脚本 `scripts/fix_breakeven_labels.py` + +在 Ubuntu 上: + +1)预览(不写库): + +```bash +python scripts/fix_breakeven_labels.py --db ./crypto.db --dry-run +``` + +2)确认后执行: + +```bash +python scripts/fix_breakeven_labels.py --db ./crypto.db --apply +``` + +默认修复条件:`monitor_type='下单监控'` 且 `result='止损'` 且 `pnl_amount > 0` → 改为 `result='保本止盈'`. diff --git a/crypto_monitor_gate/.env.example b/crypto_monitor_gate/.env.example index 879e782..2d947d4 100644 --- a/crypto_monitor_gate/.env.example +++ b/crypto_monitor_gate/.env.example @@ -1,127 +1,127 @@ # ============================================================================= -# 环境配置模板(可提交 Git)。程序运行时只读取同目录下的 .env。 +# 环境配置模板(可提交 Git).程序运行时只读取同目录下的 .env. # -# 首次部署 / 新机: +# 首次部署 / 新机: # cp .env.example .env -# nano .env # 填入真实密钥、端口、代理等 +# nano .env # 填入真实密钥,端口,代理等 # -# 升级代码(git pull)前建议备份(.env 不在 Git 中,pull 不会覆盖): +# 升级代码(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) +# 服务监听地址(云服务器通常用 0.0.0.0) APP_HOST=0.0.0.0 # 服务端口 APP_PORT=5000 -# 是否开启调试模式(生产建议 false) +# 是否开启调试模式(生产建议 false) APP_DEBUG=false # 登录账号 APP_USERNAME=admin -# 登录密码(请改成你自己的强密码) +# 登录密码(请改成你自己的强密码) APP_PASSWORD=admin123 -# 是否关闭登录校验(局域网可设 true;公网务必 false) +# 是否关闭登录校验(局域网可设 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 +# 中控请求本实例 /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 会话密钥(必须替换为长随机字符串) FLASK_SECRET_KEY=CHANGE_TO_LONG_RANDOM_SECRET -# 企业微信机器人 Webhook(用于行情/风控推送) +# 企业微信机器人 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,可选;默认即可) +# 自动备份(scripts/backup_data.sh + cron,可选;默认即可) # BACKUP_ROOT=/root/backups # BACKUP_RETENTION_DAYS=30 # BACKUP_INSTANCE=crypto_monitor_gate -# 已废弃:资金账户仅显示交易所 funding 余额,不再读取此变量 +# 已废弃:资金账户仅显示交易所 funding 余额,不再读取此变量 # TOTAL_CAPITAL=100 -# 计仓:risk=以损定仓(默认);full_margin=合约可用×FULL_MARGIN_BUFFER_RATIO 全仓杠杆(须无仓后重启) +# 计仓:risk=以损定仓(默认);full_margin=合约可用×FULL_MARGIN_BUFFER_RATIO 全仓杠杆(须无仓后重启) POSITION_SIZING_MODE=risk -# 方向限制(默认 false=双向均可;true 时按 TRADE_DIRECTION 限制,修改后须重启) -# TRADE_DIRECTION=long_only | short_only | both(或 多/空/双向) +# 方向限制(默认 false=双向均可;true 时按 TRADE_DIRECTION 限制,修改后须重启) +# TRADE_DIRECTION=long_only | short_only | both(或 多/空/双向) TRADE_DIRECTION_RESTRICT_ENABLED=false TRADE_DIRECTION=both -# 币种白名单(默认 false=全币种可手输;true 时关键位/下单/策略仅下拉选择) +# 币种白名单(默认 false=全币种可手输;true 时关键位/下单/策略仅下拉选择) TRADE_SYMBOL_RESTRICT_ENABLED=false TRADE_SYMBOL_WHITELIST=BTC,ETH -# 每天起始基数(U) +# 每天起始基数(U) DAILY_START_CAPITAL=30 -# 日内回撤后基数(U) +# 日内回撤后基数(U) DAILY_LOSS_CAPITAL=20 -# 日内盈利后基数(U) +# 日内盈利后基数(U) DAILY_PROFIT_CAPITAL=50 # BTC 默认杠杆倍数 BTC_LEVERAGE=10 # 山寨币默认杠杆倍数 ALT_LEVERAGE=5 -# 交易日重置小时(北京时间) +# 交易日重置小时(北京时间) TRADING_DAY_RESET_HOUR=8 -# 整点前禁止新开仓:true=启用(默认),false=关闭(仍可保留 8 点作为交易日划分) +# 整点前禁止新开仓:true=启用(默认),false=关闭(仍可保留 8 点作为交易日划分) TRADING_DAY_RESET_OPEN_GUARD_ENABLED=true -# 是否开启 Gate 实盘下单(false=只做本地流程,true=真实下单) +# 是否开启 Gate 实盘下单(false=只做本地流程,true=真实下单) LIVE_TRADING_ENABLED=true -# Gate API Key(实盘) +# Gate API Key(实盘) GATE_API_KEY=REPLACE_WITH_GATE_API_KEY -# Gate API Secret(实盘) +# Gate API Secret(实盘) GATE_API_SECRET=REPLACE_WITH_GATE_API_SECRET -# 保证金模式:cross=全仓,isolated=逐仓 +# 保证金模式:cross=全仓,isolated=逐仓 GATE_TD_MODE=cross -# 持仓筛选:hedge=双向持仓下按多空腿过滤;其它值(如 single)不按腿过滤 +# 持仓筛选:hedge=双向持仓下按多空腿过滤;其它值(如 single)不按腿过滤 GATE_POS_MODE=hedge -# 永续止盈止损:是否优先用官方仓位类触发单(POST price_orders,close-*-position);false=仅用旧版两张 ccxt 条件单 +# 永续止盈止损:是否优先用官方仓位类触发单(POST price_orders,close-*-position);false=仅用旧版两张 ccxt 条件单 GATE_TPSL_USE_POSITION_ORDER=true -# 触发单超时(秒),默认 604800=7 天;设为 0 或负数则不向 API 传 expiration +# 触发单超时(秒),默认 604800=7 天;设为 0 或负数则不向 API 传 expiration GATE_TPSL_TRIGGER_EXPIRATION=604800 -# 触发参考价:0=最新成交 1=标记价 2=指数价(非法值按 0) +# 触发参考价:0=最新成交 1=标记价 2=指数价(非法值按 0) GATE_TPSL_PRICE_TYPE=0 -# 仓位类 TP/SL 相对现价的最小间距(%),避免 Gate 1026「触发价须高于/低于现价」 +# 仓位类 TP/SL 相对现价的最小间距(%),避免 Gate 1026「触发价须高于/低于现价」 GATE_TPSL_LAST_PRICE_GAP_PCT=0.05 -# 页面与浏览器标签展示的交易所名称(多环境区分时可改成例如 Gate·模拟) +# 页面与浏览器标签展示的交易所名称(多环境区分时可改成例如 Gate·模拟) # EXCHANGE_DISPLAY_NAME=Gate.io # ============================================================================= -# 关键位程序自动下单(与 POSITION_SIZING_MODE 联动,修改后须重启 PM2) +# 关键位程序自动下单(与 POSITION_SIZING_MODE 联动,修改后须重启 PM2) # ============================================================================= -# 默认 false = 关闭所有关键位程序自动单(箱体/收敛/斐波/假突破/触价) +# 默认 false = 关闭所有关键位程序自动单(箱体/收敛/斐波/假突破/触价) # -# POSITION_SIZING_MODE=risk(以损定仓) -# false → 不执行任何关键位自动单;支撑/阻力提醒、人工下单、顺势加仓不受影响 -# true → 允许关键位全套自动(含触价) +# POSITION_SIZING_MODE=risk(以损定仓) +# false → 不执行任何关键位自动单;支撑/阻力提醒,人工下单,顺势加仓不受影响 +# true → 允许关键位全套自动(含触价) # -# POSITION_SIZING_MODE=full_margin(全仓杠杆,须无仓切换) +# POSITION_SIZING_MODE=full_margin(全仓杠杆,须无仓切换) # false → 不执行触价自动单 -# true → 仅回调/突破触价可程序自动开仓;箱体/斐波等仍禁止 +# true → 仅回调/突破触价可程序自动开仓;箱体/斐波等仍禁止 # -# 顺势加仓、趋势回调不受本开关控制;全仓模式下策略自动仍禁止。 +# 顺势加仓,趋势回调不受本开关控制;全仓模式下策略自动仍禁止. KEY_AUTO_ORDER_ENABLED=false # ============================================================================= -# 关键位门控(页面「关键位监控」规则条与 _key_hard_checks 共用) +# 关键位门控(页面「关键位监控」规则条与 _key_hard_checks 共用) # ============================================================================= -# 【周期】门控 K 线周期,如 5m、15m +# 【周期】门控 K 线周期,如 5m,15m KLINE_TIMEFRAME=5m -# 【确认K】闭合 K 序列中的棒偏移:突破棒默认 -2,确认棒默认 -1 +# 【确认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过滤) +# 【箱体/收敛】突破K收盘越过关键位下限%;无上限(过猛由计划RR过滤) KEY_BREAKOUT_AMP_MIN_PCT=0.03 KEY_BREAKOUT_AMP_MAX_PCT=0.5 # 【阻力/支撑】突破后微信提醒 @@ -131,29 +131,29 @@ KEY_ALERT_INTERVAL_MINUTES=5 KEY_DAILY_VOLUME_RANK_MAX=30 # 【关键位自动开仓盈亏比】严格大于该值才市价开仓 KEY_AUTO_MIN_PLANNED_RR=1.5 -# 止损:突破 K 极值向外缓冲的百分比(默认 0.5 即 0.5%) +# 止损:突破 K 极值向外缓冲的百分比(默认 0.5 即 0.5%) KEY_STOP_OUTSIDE_BREAKOUT_PCT=0.5 -# 趋势单方案:止损在突破 K 极值外侧的百分比(默认 1 即 1%) +# 趋势单方案:止损在突破 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) +# 【人工下单最低盈亏比】低于该值前后端均拒绝(默认 1.4,即须 >=1.4:1) MANUAL_MIN_PLANNED_RR=1.4 # 【关键位连开计仓】已有持仓时按无仓时资金快照算基数 KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT=true -# 【单日开仓 AI 提醒】本交易日开仓达到该次数时推送企业微信 AI 克制提醒(不拦单) +# 【单日开仓 AI 提醒】本交易日开仓达到该次数时推送企业微信 AI 克制提醒(不拦单) DAILY_OPEN_ALERT_THRESHOLD=5 -# 【单日开仓硬上限】本交易日开仓次数>=该值后禁止一切新开仓直至下一交易日(北京时间 TRADING_DAY_RESET_HOUR 切日);0=不启用 +# 【单日开仓硬上限】本交易日开仓次数>=该值后禁止一切新开仓直至下一交易日(北京时间 TRADING_DAY_RESET_HOUR 切日);0=不启用 DAILY_OPEN_HARD_LIMIT=0 # ============================================================================= -# 账户冷静期 / 日冻结风控(手动平仓、外部平仓、复盘情绪标签) +# 账户冷静期 / 日冻结风控(手动平仓,外部平仓,复盘情绪标签) # 详见 docs/account-risk-cooldown.md # ============================================================================= RISK_CONTROL_ENABLED=true @@ -162,40 +162,40 @@ 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 未就绪误判) +# 重启后多少秒内不做「外部平仓」同步(避免 API 未就绪误判) RECONCILE_STARTUP_GRACE_SEC=90 -# 连续多少次轮询确认交易所空仓后,才记为外部平仓(默认 3 次 ≈ 9 秒) +# 连续多少次轮询确认交易所空仓后,才记为外部平仓(默认 3 次 ≈ 9 秒) RECONCILE_FLAT_CONFIRM_POLLS=3 -# 使用可用资金时的缓冲比例(如0.98代表用98%) +# 使用可用资金时的缓冲比例(如0.98代表用98%) FULL_MARGIN_BUFFER_RATIO=0.98 # ============================================================================= -# 自动划转(页顶「将 swap 补足到 XU」;与 DAILY_START_CAPITAL 独立,需一致时请设为相同值) +# 自动划转(页顶「将 swap 补足到 XU」;与 DAILY_START_CAPITAL 独立,需一致时请设为相同值) # ============================================================================= AUTO_TRANSFER_ENABLED=false -# 交易账户(swap)目标余额 U:每日 8 点(北京)自动划入或划出至 funding;持仓中不划转 +# 交易账户(swap)目标余额 U:每日 8 点(北京)自动划入或划出至 funding;持仓中不划转 AUTO_TRANSFER_AMOUNT=30 AUTO_TRANSFER_FROM=funding AUTO_TRANSFER_TO=swap TRANSFER_CCY=USDT -# 北京时间该整点小时内尝试;账簿按 UTC 自然日去重 +# 北京时间该整点小时内尝试;账簿按 UTC 自然日去重 AUTO_TRANSFER_BJ_HOUR=8 -# 强制清仓整点(北京时间,默认 0=凌晨00点) +# 强制清仓整点(北京时间,默认 0=凌晨00点) FORCE_CLOSE_BJ_HOUR=0 -# 是否启用强制清仓(默认关闭,true 才会在整点执行) +# 是否启用强制清仓(默认关闭,true 才会在整点执行) FORCE_CLOSE_ENABLED=false -# 推送与AI超时(秒) +# 推送与AI超时(秒) WECHAT_TIMEOUT_SECONDS=10 AI_TIMEOUT_SECONDS=120 -# AI 提供方:openai(默认)| ollama +# AI 提供方:openai(默认)| ollama AI_PROVIDER=openai OPENAI_API_BASE=https://op.bz121.com/v1 OPENAI_API_KEY=你的密钥 @@ -203,31 +203,31 @@ 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) 先在本机建立隧道(示例): +# Gate 代理(可选):本机网络不稳定时通过 SSH 动态转发 SOCKS5 出口 +# 1) 先在本机建立隧道(示例): # ssh -N -D 127.0.0.1:1080 root@你的VPS_IP -o ServerAliveInterval=30 -o ExitOnForwardFailure=yes -# 2) 再启用下面这一行(推荐 socks5h,让远端解析域名): +# 2) 再启用下面这一行(推荐 socks5h,让远端解析域名): # GATE_SOCKS_PROXY=socks5h://127.0.0.1:1080 # -# 如你更偏向 HTTP 代理(VPS 上跑 tinyproxy 之类),可用: +# 如你更偏向 HTTP 代理(VPS 上跑 tinyproxy 之类),可用: # GATE_HTTP_PROXY=http://127.0.0.1:3128 # GATE_HTTPS_PROXY=http://127.0.0.1:3128 -# 开仓多周期K线图(可选) +# 开仓多周期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 -# 以损定仓(按交易账户资金的百分比) +# 详见 DAILY_OPEN_ALERT_THRESHOLD / DAILY_OPEN_HARD_LIMIT;说明文档 docs/daily-open-limit.md +# 以损定仓(按交易账户资金的百分比) # RISK_PERCENT=2 -# 移动保本触发(达到多少R触发)与偏移(百分比) +# 移动保本触发(达到多少R触发)与偏移(百分比) # BREAKEVEN_RR_TRIGGER=1.0 -# 移动保本阶梯(每多少R继续上移一次,默认1R) +# 移动保本阶梯(每多少R继续上移一次,默认1R) # BREAKEVEN_STEP_R=1.0 # BREAKEVEN_OFFSET_PCT=0.02 -# 开单风格默认值:trend / swing +# 开单风格默认值:trend / swing # DEFAULT_TRADE_STYLE=trend APP_TIMEZONE=Asia/Shanghai -# TRADING_DAY_RESET_HOUR 现在表示「北京时间」整点,默认 8 点起算新交易日;开仓整点限制见 TRADING_DAY_RESET_OPEN_GUARD_ENABLED +# 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 index f87d643..b64e16a 100644 --- a/crypto_monitor_gate/README.md +++ b/crypto_monitor_gate/README.md @@ -1,34 +1,34 @@ # crypto_monitor_gate -基于 **Flask** 的加密货币 **下单监控 / 关键位监控 / 交易复盘** 小系统,行情与实盘接口统一走 **Gate.io USDT 永续**,通过 **ccxt** 访问。 +基于 **Flask** 的加密货币 **下单监控 / 关键位监控 / 交易复盘** 小系统,行情与实盘接口统一走 **Gate.io USDT 永续**,通过 **ccxt** 访问. ## 文档导航 | 文档 | 说明 | |------|------| -| **[使用说明.md](./使用说明.md)** | 日常怎么用:登录、关键位四类、手工开仓、单仓与微信等 | -| **[关键位自动下单说明.md](./关键位自动下单说明.md)** | 关键位自动开仓的 RR、止盈止损、结案原因与 `.env` | -| **[部署文档.md](./部署文档.md)** | Ubuntu、PM2、**SSH SOCKS** 访问 Gate API 等 | +| **[使用说明.md](./使用说明.md)** | 日常怎么用:登录,关键位四类,手工开仓,单仓与微信等 | +| **[关键位自动下单说明.md](./关键位自动下单说明.md)** | 关键位自动开仓的 RR,止盈止损,结案原因与 `.env` | +| **[部署文档.md](./部署文档.md)** | Ubuntu,PM2,**SSH SOCKS** 访问 Gate API 等 | -另:**Binance U 本位** 对等实现见同级的 **`crypto_monitor_binance`** 仓库。 +另:**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` 等影响) +- **关键位监控**: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 线导出等可选用) +- Python 3.10+(建议) +- 依赖:`flask`,`requests`,`ccxt`,`werkzeug`,`PySocks`(经 SOCKS 代理时);`Pillow`(K 线导出等可选用) -安装示例: +安装示例: ```bash cd /opt/crypto_monitor/crypto_monitor_gate @@ -36,41 +36,41 @@ source .venv/bin/activate pip install -r ../requirements.txt ``` -## 配置(`.env.example` → `.env`) +## 配置(`.env.example` → `.env`) -- **`.env.example`**:模板(可提交 Git);首次:`cp .env.example .env` 后编辑。 -- **`.env`**:本机真实配置(勿提交);`git pull` 不覆盖;升级前建议备份(见《部署文档》§5.2)。 +- **`.env.example`**:模板(可提交 Git);首次:`cp .env.example .env` 后编辑. +- **`.env`**:本机真实配置(勿提交);`git pull` 不覆盖;升级前建议备份(见《部署文档》§5.2). -项目启动时加载**仓库根目录**下的 `.env`。常用项: +项目启动时加载**仓库根目录**下的 `.env`.常用项: | 变量 | 说明 | |------|------| -| `GATE_API_KEY` / `GATE_API_SECRET` | Gate API(需合约与对应权限) | -| `LIVE_TRADING_ENABLED` | `true` 允许真实下单;`false` 仅本地与推送逻辑 | +| `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 动态转发(详见部署文档) | +| `GATE_SOCKS_PROXY` | 可选;直连不稳时 SSH 动态转发(详见部署文档) | | `APP_PASSWORD` / `FLASK_SECRET_KEY` | Web 登录与 Session | | `WECHAT_WEBHOOK` | 企业微信机器人 | | `EXCHANGE_DISPLAY_NAME` / `GATE_ACCOUNT_LABEL` | 页面与推送展示的账户文案 | -其余见 **`.env.example` 内注释** 或 **`app.py` 顶部默认值**。 +其余见 **`.env.example` 内注释** 或 **`app.py` 顶部默认值**. ## 运行 -生产使用 **PM2**(`ecosystem.config.cjs`)。调试: +生产使用 **PM2**(`ecosystem.config.cjs`).调试: ```bash source .venv/bin/activate && python app.py ``` -见 [docs/ubuntu-server.md](../docs/ubuntu-server.md)。 +见 [docs/ubuntu-server.md](../docs/ubuntu-server.md). -端口由 **`APP_PORT`** 控制(未设置默认 **5000**)。浏览器登录 **`/login`**,口令为 **`APP_PASSWORD`**。 +端口由 **`APP_PORT`** 控制(未设置默认 **5000**).浏览器登录 **`/login`**,口令为 **`APP_PASSWORD`**. -## 部署(Linux / PM2 / SSH SOCKS) +## 部署(Linux / PM2 / SSH SOCKS) -见 **[部署文档.md](./部署文档.md)**。 +见 **[部署文档.md](./部署文档.md)**. ## 自检脚本 @@ -78,13 +78,13 @@ source .venv/bin/activate && python app.py python scripts/verify_gate_funding.py ``` -用于核对密钥前缀(不落 Secret)、资金/合约可读性等(需网络与权限)。 +用于核对密钥前缀(不落 Secret),资金/合约可读性等(需网络与权限). ## 数据与脚本 -- 默认 SQLite:由 **`DB_PATH`** 指定(常见为项目下 `crypto.db`) -- `scripts/fix_breakeven_labels.py`:修正「止损」但盈亏为正的记录标签(参见部署文档说明) +- 默认 SQLite:由 **`DB_PATH`** 指定(常见为项目下 `crypto.db`) +- `scripts/fix_breakeven_labels.py`:修正「止损」但盈亏为正的记录标签(参见部署文档说明) ## 风险与合规 -实盘有亏损风险。请确认 API 权限、IP 白名单、杠杆与保证金模式与 **Gate.io** 后台一致,并遵守当地法律法规与交易所用户协议。 +实盘有亏损风险.请确认 API 权限,IP 白名单,杠杆与保证金模式与 **Gate.io** 后台一致,并遵守当地法律法规与交易所用户协议. diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index bbba42b..6e9aa2a 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -315,15 +315,15 @@ 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 覆盖) +# 训练参数(可由 .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) +# 交易日滚动与「可开仓」整点:按应用本地时区 wall clock(默认北京时间 UTC+8) TRADING_DAY_RESET_HOUR = int(os.getenv("TRADING_DAY_RESET_HOUR", "8")) -# false 时关闭「整点前禁止新开仓」守卫(交易日划分仍用 TRADING_DAY_RESET_HOUR) +# 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") @@ -345,15 +345,15 @@ 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(全平) +# 永续仓位止盈止损触发单: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 +# 仓位类触发单相对 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")) @@ -379,14 +379,14 @@ EXCHANGE_POSITION_SYNC_FROM_BJ = (os.getenv("EXCHANGE_POSITION_SYNC_FROM_BJ") or 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 +# 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 自然日便于对账 +# 自动划转:仅在北京时间该整点「小时」内尝试;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() @@ -432,14 +432,14 @@ GATE_HTTPS_PROXY = (os.getenv("GATE_HTTPS_PROXY") or "").strip() def build_gate_ccxt_proxies(): """ - 为 ccxt 配置代理(常用于本机网络不稳定时通过 SSH 动态转发 SOCKS5 出口)。 + 为 ccxt 配置代理(常用于本机网络不稳定时通过 SSH 动态转发 SOCKS5 出口). - 推荐: - - 本机:ssh -N -D 127.0.0.1:1080 user@vps - - .env:GATE_SOCKS_PROXY=socks5h://127.0.0.1:1080 + 推荐: + - 本机:ssh -N -D 127.0.0.1:1080 user@vps + - .env:GATE_SOCKS_PROXY=socks5h://127.0.0.1:1080 - 说明: - - socks5h 让代理端解析域名(避免本机 DNS/策略差异);若你明确要本机解析可用 socks5:// + 说明: + - socks5h 让代理端解析域名(避免本机 DNS/策略差异);若你明确要本机解析可用 socks5:// """ socks = GATE_SOCKS_PROXY.strip() http = GATE_HTTP_PROXY.strip() @@ -459,7 +459,7 @@ app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER from lib.exchange.gate_ccxt_lib import gate_ccxt_class -# Gate.io USDT 永续(swap) +# Gate.io USDT 永续(swap) exchange = gate_ccxt_class()({ "enableRateLimit": True, "options": { @@ -496,7 +496,7 @@ _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 @@ -514,7 +514,7 @@ def _wechat_account_label(): def _wechat_direction_text(direction): d = (direction or "").lower() - return "多头(long)" if d == "long" else "空头(short)" + return "多头(long)" if d == "long" else "空头(short)" def _wechat_trading_capital_text(fallback=None): @@ -563,21 +563,21 @@ def build_wechat_close_message( lines = [ f"📉 {symbol} 平仓完成", - f"💼 账户:{_wechat_account_label()}", + 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"🔖 平仓单号:{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}", + "🎯 价位(计划)", + f"开仓成交价:{ep}", + f"离场参考价:{cp}", + f"止盈价位:{tp}", + f"止损价位:{sl}", ] if extra_note: lines.extend(["", "📎 备注", extra_note]) @@ -589,16 +589,16 @@ def build_wechat_breakeven_message(symbol, direction, arm_txt, now_rr, locked_r, return "\n".join( [ f"# 🛡️ {symbol} 保护位更新", - f"**账户:{_wechat_account_label()}**", + 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}`", + 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}`", ] ) @@ -607,14 +607,14 @@ def build_wechat_monitor_error_message(symbol, direction, scene, error_text): return "\n".join( [ f"# ⚠️ {symbol} 下单监控异常", - f"**账户:{_wechat_account_label()}**", + f"**账户:{_wechat_account_label()}**", "", "---", "", "### 异常信息", - f"- 方向:**{_wechat_direction_text(direction)}**", - f"- 场景:{scene}", - f"- 错误:{str(error_text)}", + f"- 方向:**{_wechat_direction_text(direction)}**", + f"- 场景:{scene}", + f"- 错误:{str(error_text)}", ] ) @@ -635,22 +635,22 @@ def build_wechat_key_monitor_message( ): lines = [ f"# 🎯 {symbol} 关键位确认推送", - f"**账户:{_wechat_account_label()}**", + f"**账户:{_wechat_account_label()}**", "", "---", "", "### 交易对 / 触发时间", - f"- 交易对:**{symbol}**", - f"- 触发时间:`{trigger_time}`", + f"- 交易对:**{symbol}**", + f"- 触发时间:`{trigger_time}`", "", "### 方向与确认K", - f"- 方向:**{_wechat_direction_text(direction)}**", - "- 确认K:第二根5m收盘完成", + f"- 方向:**{_wechat_direction_text(direction)}**", + "- 确认K:第二根5m收盘完成", "", "### 关键价位", - f"- 类型:**{monitor_type}**", - f"- 箱体关键位:`{key_price}`", - f"- 第二根确认收盘价:`{confirm_close}`", + f"- 类型:**{monitor_type}**", + f"- 箱体关键位:`{key_price}`", + f"- 第二根确认收盘价:`{confirm_close}`", "", "### 硬条件校验结果", ] @@ -659,9 +659,9 @@ def build_wechat_key_monitor_message( [ "", "### 市场状态说明", - f"- BTC 8h 状态:**{btc8h_status}**", - f"- 本币 4h(EMA55) 状态:**{coin4h_status}**", - f"- 4h震荡幅度(5m近48根):`{round(float(swing4h_pct), 3)}%`", + f"- BTC 8h 状态:**{btc8h_status}**", + f"- 本币 4h(EMA55) 状态:**{coin4h_status}**", + f"- 4h震荡幅度(5m近48根):`{round(float(swing4h_pct), 3)}%`", "", "### 操作提示", ] @@ -781,7 +781,7 @@ def _pick_marker_point(rows, target_ts_ms, target_price=None): 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)") + raise RuntimeError("缺少依赖:Pillow(pip install Pillow)") img = Image.new("RGB", (width, height), bg_rgb) draw = ImageDraw.Draw(img) font = _load_font(14) @@ -886,7 +886,7 @@ def _render_candles_subplot(rows, title, width, height, bg_rgb=(255, 255, 255), 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 @@ -920,7 +920,7 @@ def _ohlcv_dict_rows_to_lists(rows, lim): def _fetch_ohlcv_ending_at(exchange_symbol, timeframe, limit, end_ts_ms): - """以 end_ts_ms 为终点向前取 K 线(无 end 则拉最近 limit 根)。""" + """以 end_ts_ms 为终点向前取 K 线(无 end 则拉最近 limit 根).""" lim = max(2, int(limit or ORDER_CHART_LIMIT)) try: if not end_ts_ms: @@ -1110,7 +1110,7 @@ EARLY_EXIT_TRIGGERS = ( "其他", ) -# 日内户:长句开仓类型 + 关键位 + 策略(大分歧 A/B/小分歧 仅趋势户) +# 日内户:长句开仓类型 + 关键位 + 策略(大分歧 A/B/小分歧 仅趋势户) ENTRY_REASON_OPTIONS = build_intraday_entry_reason_options( KEY_ENTRY_REASON_OPTIONS, STRATEGY_ENTRY_REASON_OPTIONS, @@ -1154,7 +1154,7 @@ def compose_early_exit_reason_saved(trigger, note): def journal_exit_reason_stored(trigger, note): - """exit_reason 列与表单「一处」对齐:非手工=触发类型;手工=离场说明全文。""" + """exit_reason 列与表单「一处」对齐:非手工=触发类型;手工=离场说明全文.""" t = normalize_early_exit_trigger(trigger) n = str(note or "").strip() if t == "手动平仓": @@ -1162,7 +1162,7 @@ def journal_exit_reason_stored(trigger, note): return t -# 初始化数据库(支持多空方向) +# 初始化数据库(支持多空方向) def init_db(): conn = sqlite3.connect(DB_PATH) c = conn.cursor() @@ -1176,7 +1176,7 @@ def init_db(): breakout_limit_pct REAL DEFAULT 1.5, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') - # 订单监控(核心:加 direction 方向字段) + # 订单监控(核心:加 direction 方向字段) c.execute('''CREATE TABLE IF NOT EXISTS order_monitors (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, direction TEXT DEFAULT "long", exchange_symbol TEXT, @@ -1192,7 +1192,7 @@ def init_db(): 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, @@ -1236,7 +1236,7 @@ def init_db(): ON transfer_logs(transfer_type, transfer_day) WHERE transfer_type = 'auto_daily' ''') - # 给旧表加 direction 字段(兼容老数据,不报错) + # 给旧表加 direction 字段(兼容老数据,不报错) try: c.execute("ALTER TABLE order_monitors ADD COLUMN direction TEXT DEFAULT 'long'") except: pass @@ -1580,7 +1580,7 @@ def hub_user_initiated_close( def app_now(): - """应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较)。""" + """应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较).""" return datetime.now(APP_TZ).replace(tzinfo=None) @@ -1589,17 +1589,17 @@ def app_now_str(): def utc_now_dt(): - """当前时刻(UTC,aware)。""" + """当前时刻(UTC,aware).""" return datetime.now(timezone.utc) def utc_calendar_date_str(): - """UTC 自然日 YYYY-MM-DD(用于自动划转去重等与交易所日界对齐的计算)。""" + """UTC 自然日 YYYY-MM-DD(用于自动划转去重等与交易所日界对齐的计算).""" return utc_now_dt().strftime("%Y-%m-%d") def get_trading_day(now=None): - """交易日字符串:本地时钟下若小时 < TRADING_DAY_RESET_HOUR 则归属「上一日历日」。""" + """交易日字符串:本地时钟下若小时 < TRADING_DAY_RESET_HOUR 则归属「上一日历日」.""" now = now or app_now() if getattr(now, "tzinfo", None): now = now.astimezone(APP_TZ).replace(tzinfo=None) @@ -1833,7 +1833,7 @@ def _compute_period_metrics(trades): def compute_stats_bundle(conn, trading_day, now_dt=None): - """日 / 周 / 月 统计:平仓按北京时间交易日(默认 8:00 切日)计入。""" + """日 / 周 / 月 统计:平仓按北京时间交易日(默认 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] @@ -1857,9 +1857,9 @@ def compute_stats_bundle(conn, trading_day, now_dt=None): 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}(北京自然月)" + 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 = [] @@ -1902,7 +1902,7 @@ def normalize_exchange_symbol(symbol): def resolve_monitor_exchange_symbol(row): - """将监控行上的 symbol / exchange_symbol 统一到 ccxt 永续合约 symbol,便于与 fetch_positions 结果比对。""" + """将监控行上的 symbol / exchange_symbol 统一到 ccxt 永续合约 symbol,便于与 fetch_positions 结果比对.""" raw = "" try: if row["exchange_symbol"]: @@ -1926,7 +1926,7 @@ def _position_contract_symbol_match(position_symbol, wanted_exchange_symbol): def _position_matches_wanted_contract(wanted_unified_sym, position_dict): - """统一 symbol 比对;不一致时用 Gate 原始 contract 与 ccxt market.id 对齐(兼容 1000PEPE 等命名差异)。""" + """统一 symbol 比对;不一致时用 Gate 原始 contract 与 ccxt market.id 对齐(兼容 1000PEPE 等命名差异).""" if not wanted_unified_sym or not position_dict: return False ps = position_dict.get("symbol") @@ -1945,7 +1945,7 @@ def _position_matches_wanted_contract(wanted_unified_sym, position_dict): def _position_row_effective_contracts(p): - """张数:优先 ccxt contracts,否则用 Gate 原始 size/pos(避免统一层为 0 时被误判空仓)。""" + """张数:优先 ccxt contracts,否则用 Gate 原始 size/pos(避免统一层为 0 时被误判空仓).""" from lib.hub.hub_position_metrics import normalize_contracts_qty if not p: @@ -2134,7 +2134,7 @@ def to_effective_trade_dict(row): def format_price_magnitude_fallback(value): - """无 markets 或解析失败时的价格展示兜底(按量级)。""" + """无 markets 或解析失败时的价格展示兜底(按量级).""" try: v = float(value) except Exception: @@ -2159,7 +2159,7 @@ def format_price_magnitude_fallback(value): def resolve_ccxt_price_symbol(symbol): - """将界面/库中的品种名转为 ccxt 永续合约 id(如 BTC/USDT -> BTC/USDT:USDT)。""" + """将界面/库中的品种名转为 ccxt 永续合约 id(如 BTC/USDT -> BTC/USDT:USDT).""" s = (symbol or "").strip() if not s: return "" @@ -2171,7 +2171,7 @@ def resolve_ccxt_price_symbol(symbol): def round_price_to_exchange(exchange_symbol, price): - """与交易所 tick 对齐后的 float,供入库与计算;失败时退回 float(price)。""" + """与交易所 tick 对齐后的 float,供入库与计算;失败时退回 float(price).""" if price in (None, ""): return None try: @@ -2189,7 +2189,7 @@ def round_price_to_exchange(exchange_symbol, price): def format_price_for_symbol(symbol, value): - """价格展示:与交易所 price_to_precision 一致(与入库 round_price_to_exchange 对齐)。""" + """价格展示:与交易所 price_to_precision 一致(与入库 round_price_to_exchange 对齐).""" if value in (None, ""): return "-" try: @@ -2207,7 +2207,7 @@ def format_price_for_symbol(symbol, value): def format_usdt(value): - """USDT 资金类展示:固定两位小数。""" + """USDT 资金类展示:固定两位小数.""" if value in (None, ""): return "-" try: @@ -2217,7 +2217,7 @@ def format_usdt(value): def format_signed_usdt(value): - """USDT 盈亏等可正可负:+1.23 / -0.50 / 0.00""" + """USDT 盈亏等可正可负:+1.23 / -0.50 / 0.00""" if value in (None, ""): return "-" try: @@ -2231,7 +2231,7 @@ def format_signed_usdt(value): def format_wechat_scalar_2dp(value): - """企业微信推送:数值统一两位小数(与交易所 tick 无关)。""" + """企业微信推送:数值统一两位小数(与交易所 tick 无关).""" if value in (None, ""): return "-" try: @@ -2330,7 +2330,7 @@ def calc_actual_rr(pnl_amount, risk_amount): def calc_breakeven_stop(direction, entry_price, risk_fraction, locked_r, offset_pct): """ - 按“已锁定R”计算目标止损位: + 按“已锁定R”计算目标止损位: - long: entry + locked_r * (entry*risk_fraction) + offset - short: entry - locked_r * (entry*risk_fraction) - offset """ @@ -2477,9 +2477,9 @@ def enrich_order_item(raw_item, current_capital): def ensure_exchange_live_ready(): if not LIVE_TRADING_ENABLED: - return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)" + 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 False, "缺少 Gate API 密钥配置(GATE_API_KEY / GATE_API_SECRET)" return True, "" @@ -2509,7 +2509,7 @@ def order_row_key_signal_type(row): def exchange_private_api_configured(): - """仅表示已配置密钥;与是否允许下单(LIVE_TRADING_ENABLED)无关,用于只读拉仓等。""" + """仅表示已配置密钥;与是否允许下单(LIVE_TRADING_ENABLED)无关,用于只读拉仓等.""" return bool(GATE_API_KEY and GATE_API_SECRET) @@ -2546,8 +2546,8 @@ def _extract_usdt_free(balance): def _parse_usdt_from_gate_unified_accounts_body(data): """ - 解析 Gate GET /unified/accounts 响应体中的 USDT(dict 或 list 形态的 balances 均支持)。 - ccxt fetch_balance(unifiedAccount) 在 balances 为数组时会访问 .keys() 崩溃,故资金兜底走此解析。 + 解析 Gate GET /unified/accounts 响应体中的 USDT(dict 或 list 形态的 balances 均支持). + ccxt fetch_balance(unifiedAccount) 在 balances 为数组时会访问 .keys() 崩溃,故资金兜底走此解析. """ if not isinstance(data, dict): return None @@ -2616,7 +2616,7 @@ def _parse_usdt_from_gate_unified_accounts_body(data): def _parse_gate_spot_accounts_response_usdt(response): - """解析 GET /spot/accounts 列表中的 USDT(与 fetch_balance spot 同源,ccxt 解析失败时可兜底)。""" + """解析 GET /spot/accounts 列表中的 USDT(与 fetch_balance spot 同源,ccxt 解析失败时可兜底).""" rows = None if isinstance(response, list): rows = response @@ -2647,7 +2647,7 @@ def _parse_gate_spot_accounts_response_usdt(response): def _fetch_usdt_by_types(type_candidates): - """统一只用 ccxt.fetch_balance;spot 必须带 marginMode=spot,否则会随 defaultMarginMode 误走 cross_margin。""" + """统一只用 ccxt.fetch_balance;spot 必须带 marginMode=spot,否则会随 defaultMarginMode 误走 cross_margin.""" for t in type_candidates: try: params = {"type": t} @@ -2664,10 +2664,10 @@ def _fetch_usdt_by_types(type_candidates): 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 会崩。 + 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: @@ -2755,10 +2755,10 @@ def friendly_exchange_error(err, available_usdt=None): 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到合约账户。" + 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}" + return f"交易所下单失败:{clean}" def get_exchange_capitals(force=False): @@ -2775,7 +2775,7 @@ def get_exchange_capitals(force=False): 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"] @@ -2796,7 +2796,7 @@ def execute_transfer_usdt(amount, from_account, to_account): def get_account_usdt_total(account_type): - """读取各账户 USDT。funding 走 _fetch_gate_funding_usdt;spot 同样 marginMode=spot,一律 ccxt。""" + """读取各账户 USDT.funding 走 _fetch_gate_funding_usdt;spot 同样 marginMode=spot,一律 ccxt.""" raw = (account_type or "").strip().lower() if raw == "funding": return _fetch_gate_funding_usdt() @@ -2841,7 +2841,7 @@ def auto_transfer_once_per_day(): 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 @@ -2915,7 +2915,7 @@ def precheck_risk(conn, symbol, direction): reached, active_count, mx = position_limit_reached(conn, max_active_positions=MAX_ACTIVE_POSITIONS) if reached: - return False, f"已达最大持仓数({active_count}/{mx})" + 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 ) @@ -2942,16 +2942,16 @@ def prepare_order_amount(exchange_symbol, margin_capital, leverage, fallback_pri market = exchange.market(exchange_symbol) contract_size = float(market.get("contractSize") or 1) if market.get("contract"): - # 合约 amount 按张数/合约乘数解析;ccxt 会再做精度与符号处理 + # 合约 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}") + raise ValueError(f"下单数量过小,最小数量为 {min_amount}") amount_precise = float(exchange.amount_to_precision(exchange_symbol, amount)) if amount_precise <= 0: - raise ValueError("下单数量精度后为 0,请提高基数或降低价格") + raise ValueError("下单数量精度后为 0,请提高基数或降低价格") return amount_precise, price @@ -3038,8 +3038,8 @@ def _gate_contracts_amount_for_tpsl(order, fallback_amount): def _gate_clamp_tpsl_to_last_price(exchange_symbol, direction, stop_loss, take_profit, *, sl_only=False): """ - Gate price_orders 规则:空仓止损/多仓止盈 trigger>last;空仓止盈/多仓止损 triggerlast;空仓止盈/多仓止损 trigger= last: tp = float(exchange.price_to_precision(exchange_symbol, last * (1 - gap))) - notes.append(f"止盈触发价须低于现价 {last},已调整为 {tp}") + 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}") + 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) + 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),与官方仓位类触发单等价逻辑不同路径。""" + """ccxt 市价减仓条件单(两张单分别带 stopLossPrice / takeProfitPrice),与官方仓位类触发单等价逻辑不同路径.""" ensure_markets_loaded() close_side = "sell" if direction == "long" else "buy" base = {"reduceOnly": True} @@ -3090,14 +3090,14 @@ def _gate_place_tp_sl_orders_legacy_conditional(exchange_symbol, direction, cont except Exception as e: last_err = e time.sleep(0.2 * (attempt + 1)) - raise RuntimeError(f"交易所未接受条件止盈/止损委托参数:{last_err}") + 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 避免残留。 + 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 @@ -3125,7 +3125,7 @@ def _gate_place_tp_sl_orders_position_price_orders(exchange_symbol, direction, s } 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 + # 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)) @@ -3153,13 +3153,13 @@ def _gate_place_tp_sl_orders_position_price_orders(exchange_symbol, direction, s try: exchange.privateFuturesPostSettlePriceOrders(_payload(tp_s, tp_rule)) except Exception: - # 保留已挂止损,仅放弃本次 TP;上层可补偿平仓或重试 + # 保留已挂止损,仅放弃本次 TP;上层可补偿平仓或重试 raise return except Exception as e: last_err = e time.sleep(0.2 * (attempt + 1)) - raise RuntimeError(f"交易所未接受仓位类条件止盈/止损:{last_err}") + raise RuntimeError(f"交易所未接受仓位类条件止盈/止损:{last_err}") def _gate_td_mode_is_cross(): @@ -3176,7 +3176,7 @@ def _gate_place_tp_sl_orders(exchange_symbol, direction, contracts_amount, stop_ pos_err = e if _gate_td_mode_is_cross(): raise RuntimeError( - f"交易所未接受仓位类条件止盈/止损(全仓不支持 ccxt 条件单回退):{pos_err}" + f"交易所未接受仓位类条件止盈/止损(全仓不支持 ccxt 条件单回退):{pos_err}" ) from e try: _gate_place_tp_sl_orders_legacy_conditional( @@ -3185,13 +3185,13 @@ def _gate_place_tp_sl_orders(exchange_symbol, direction, contracts_amount, stop_ except Exception as legacy_err: if pos_err is not None: raise RuntimeError( - f"交易所未接受仓位类条件止盈/止损:{pos_err};条件单回退亦失败:{legacy_err}" + f"交易所未接受仓位类条件止盈/止损:{pos_err};条件单回退亦失败:{legacy_err}" ) from legacy_err raise def _gate_place_stop_loss_only_position(exchange_symbol, direction, stop_loss): - """Gate 永续:仅挂仓位类止损触发单(趋势回调用)。""" + """Gate 永续:仅挂仓位类止损触发单(趋势回调用).""" stop_loss, _, _ = _gate_clamp_tpsl_to_last_price( exchange_symbol, direction, stop_loss, stop_loss, sl_only=True ) @@ -3242,7 +3242,7 @@ def _gate_place_stop_loss_only_position(exchange_symbol, direction, stop_loss): except Exception as e: last_err = e time.sleep(0.2 * (attempt + 1)) - raise RuntimeError(f"交易所未接受仅止损仓位触发单:{last_err}") + raise RuntimeError(f"交易所未接受仅止损仓位触发单:{last_err}") def calc_trend_manual_breakeven_stop(direction, entry_price, offset_pct=None): @@ -3271,7 +3271,7 @@ def ensure_markets_loaded(force=False): def _abort_market_open_after_tpsl_failure(exchange_symbol, direction, order, planned_amount): - """TP/SL 挂失败时市价平掉刚开的仓并撤残留条件单。""" + """TP/SL 挂失败时市价平掉刚开的仓并撤残留条件单.""" from lib.trade.compensating_close_lib import run_compensating_close def _close(): @@ -3308,13 +3308,13 @@ def place_exchange_order(exchange_symbol, direction, amount, leverage, stop_loss raise except Exception as e: _abort_market_open_after_tpsl_failure(exchange_symbol, direction, order, amount) - raise RuntimeError(f"交易所未接受条件止盈/止损委托,已拒绝开仓:{str(e)}") from e + raise RuntimeError(f"交易所未接受条件止盈/止损委托,已拒绝开仓:{str(e)}") from e return order def close_exchange_order(order_row): """ - 市价全平。数量优先取交易所当前持仓张数,避免仅用入库 order_amount 导致平不干净。 + 市价全平.数量优先取交易所当前持仓张数,避免仅用入库 order_amount 导致平不干净. """ ensure_markets_loaded() exchange_symbol = order_row["exchange_symbol"] or normalize_exchange_symbol(order_row["symbol"]) @@ -3331,7 +3331,7 @@ def close_exchange_order(order_row): if raw_amt <= 0: if last_resp is not None: return last_resp - raise ValueError("平仓失败:缺少有效下单数量") + raise ValueError("平仓失败:缺少有效下单数量") try: amount = float(exchange.amount_to_precision(exchange_symbol, raw_amt)) except Exception: @@ -3339,7 +3339,7 @@ def close_exchange_order(order_row): if amount <= 0: if last_resp is not None: return last_resp - raise ValueError("平仓失败:数量经精度舍入后为 0") + 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) @@ -3349,7 +3349,7 @@ def close_exchange_order(order_row): def _gate_swap_trigger_order_params(): - """永续条件单(止盈/止损触发委托)查询/撤销用的 ccxt 参数。""" + """永续条件单(止盈/止损触发委托)查询/撤销用的 ccxt 参数.""" p = {"type": "swap", "trigger": True} try: exchange.load_unified_status() @@ -3362,8 +3362,8 @@ def _gate_swap_trigger_order_params(): def cancel_gate_swap_trigger_orders(exchange_symbol): """ - 仓位已平时撤销该合约下剩余的永续条件委托(trigger / price_orders),避免孤儿单残留。 - 与 App 内「仓位附带止盈止损」不同,本系统挂的是独立触发单,平仓后交易所未必自动撤。 + 仓位已平时撤销该合约下剩余的永续条件委托(trigger / price_orders),避免孤儿单残留. + 与 App 内「仓位附带止盈止损」不同,本系统挂的是独立触发单,平仓后交易所未必自动撤. """ ok, _ = ensure_exchange_live_ready() if not ok or not exchange_symbol: @@ -3554,7 +3554,7 @@ def replace_active_monitor_tpsl_on_exchange(order_row, stop_loss, 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("交易所当前无该方向持仓,无法挂止盈止损") + raise ValueError("交易所当前无该方向持仓,无法挂止盈止损") amt = float(contracts) if amt <= 0: try: @@ -3599,7 +3599,7 @@ def is_no_position_error(err_msg): def _gate_fetch_position_rows(exchange_symbol): - """优先拉 USDT 本位全量持仓(与页面一致),避免单合约查询在重启后返回空列表误判空仓。""" + """优先拉 USDT 本位全量持仓(与页面一致),避免单合约查询在重启后返回空列表误判空仓.""" try: ensure_markets_loaded() except Exception: @@ -3647,7 +3647,7 @@ def get_live_position_contracts(exchange_symbol, direction): def _select_live_position_row(rows, exchange_symbol, direction, relax_hedge=False): - """在 fetch_positions 结果中取与当前监控方向一致、张数最大的一条(与 get_live_position_contracts 过滤规则一致)。""" + """在 fetch_positions 结果中取与当前监控方向一致,张数最大的一条(与 get_live_position_contracts 过滤规则一致).""" if not rows: return None candidates = [] @@ -3684,14 +3684,14 @@ def _coerce_float(*values): def parse_ccxt_position_metrics(position, order_leverage=None): """ - 从 ccxt 统一持仓结构解析保证金/名义/未实现盈亏(Gate 等所字段略有差异,做多键兜底)。 - 与 App「仓位保证金」对齐时优先用 initialMargin;缺失时再尝试 info 内字段。 + 从 ccxt 统一持仓结构解析保证金/名义/未实现盈亏(Gate 等所字段略有差异,做多键兜底). + 与 App「仓位保证金」对齐时优先用 initialMargin;缺失时再尝试 info 内字段. """ if not position: return None p = position info = p.get("info", {}) or {} - # Gate 全仓:ccxt 的 initialMargin 常为空;collateral 来自 API 的 margin,与 App「保证金」一致 + # 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( @@ -3707,7 +3707,7 @@ def parse_ccxt_position_metrics(position, order_leverage=None): notional = _coerce_float(info.get("value")) if notional is not None: notional = abs(notional) - # 全仓且 API margin 为 0 时:用名义/杠杆粗算展示(与交易所「约占用」接近) + # 全仓且 API margin 为 0 时:用名义/杠杆粗算展示(与交易所「约占用」接近) if (initial is None or initial <= 0) and notional and notional > 0 and order_leverage: try: lev = float(order_leverage) @@ -3779,7 +3779,7 @@ def _order_row_exchange_margin_usdt(row): def margin_capital_for_trade_record(order_row): - """trade_records.基数:优先交易所持仓保证金快照,旧数据无快照时回退计划保证金。""" + """trade_records.基数:优先交易所持仓保证金快照,旧数据无快照时回退计划保证金.""" ex = _order_row_exchange_margin_usdt(order_row) if ex is not None: return round(ex, 2) @@ -3798,7 +3798,7 @@ def margin_capital_for_trade_record(order_row): 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(平仓后无法再取)。""" + """开仓成功后持仓可见时拉取交易所保证金并写入 order_monitors(平仓后无法再取).""" if not conn or not order_id or not exchange_private_api_configured(): return False direction = (direction or "long").lower() @@ -3859,7 +3859,7 @@ def ms_to_app_local_str(ms): def classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_price): - """根据成交价相对止盈/止损位归类;无法可靠归类时返回 None。""" + """根据成交价相对止盈/止损位归类;无法可靠归类时返回 None.""" try: tp = float(take_profit) sl = float(stop_loss) @@ -3882,7 +3882,7 @@ def classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, ex def fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=None): - """取开仓以来最近一笔减仓成交(与方向一致);失败返回 None。""" + """取开仓以来最近一笔减仓成交(与方向一致);失败返回 None.""" if not (GATE_API_KEY and GATE_API_SECRET): return None ensure_markets_loaded() @@ -3947,8 +3947,8 @@ def fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_ 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 回填)。 - 返回按时间排序的成交列表。 + 拉取某条历史记录对应的减仓成交(用于按 id 回填). + 返回按时间排序的成交列表. """ if not (GATE_API_KEY and GATE_API_SECRET): return [] @@ -3956,7 +3956,7 @@ def fetch_closing_fills_for_record(exchange_symbol, direction, opened_at_str, cl 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 = [] @@ -4001,7 +4001,7 @@ def fetch_closing_fills_for_record(exchange_symbol, direction, opened_at_str, cl if candidates: return candidates - # 严格窗口为空时,降级为“按平仓时间就近匹配”,降低时区/时间误差导致的回填失败。 + # 严格窗口为空时,降级为“按平仓时间就近匹配”,降低时区/时间误差导致的回填失败. all_side_candidates.sort(key=lambda x: x.get("timestamp") or 0) if not all_side_candidates: return [] @@ -4105,8 +4105,8 @@ def calc_weighted_exit_price(trades): 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)。 + 交易所已无仓,本地仍为 active 时,推断平仓类型/时间/盈亏. + 返回 (result, pnl_amount, closed_at_str, miss_reason). """ def _finish(result, pnl_amount, closed_at_str, miss_reason): @@ -4156,13 +4156,13 @@ def resolve_synced_flat_close(row, opened_at_str, opened_at_ms=None, *, prefer_m 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) @@ -4185,7 +4185,7 @@ def resolve_synced_flat_close(row, opened_at_str, opened_at_ms=None, *, prefer_m "外部平仓", pnl, closed_at_str, - "交易所已平仓,成交价不在计划止盈/止损带内(可能为手动或其他类型平仓)", + "交易所已平仓,成交价不在计划止盈/止损带内(可能为手动或其他类型平仓)", ) @@ -4230,7 +4230,7 @@ def _finalize_hub_flat_monitor(conn, r, *, result, pnl_amount, closed_at, miss_r def reconcile_hub_external_close(conn, symbol, direction): - """中控市价全平后:立即同步匹配 order_monitor,并读 Gate 平仓历史。""" + """中控市价全平后:立即同步匹配 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 @@ -4279,7 +4279,7 @@ def reconcile_external_closes(conn, days=None): 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 天过滤,避免把更早历史单误同步进来 + # 手动同步按最近 N 天过滤,避免把更早历史单误同步进来 if opened_ms is None or opened_ms < cutoff_ms: continue oid = int(r["id"]) @@ -4356,7 +4356,7 @@ def reconcile_external_closes(conn, days=None): build_wechat_close_message( symbol=r["symbol"], direction=r["direction"], - result=f"{result}(自动同步)", + result=f"{result}(自动同步)", pnl_amount=pnl_amount, hold_seconds=hold_seconds, trigger_price=r["trigger_price"], @@ -4372,7 +4372,7 @@ def reconcile_external_closes(conn, days=None): build_wechat_close_message( symbol=r["symbol"], direction=r["direction"], - result="外部平仓(自动同步)", + result="外部平仓(自动同步)", pnl_amount=pnl_amount, hold_seconds=hold_seconds, trigger_price=r["trigger_price"], @@ -4442,8 +4442,8 @@ def _status_by_ema55(symbol, timeframe): def _daily_volume_rank(symbol): """ - 返回(symbol_rank, total_count),按 USDT 永续 24h 成交额降序。 - 走 hub_volume_rank_lib 轻量 ticker API,避免 fetch_tickers() 全市场拉取。 + 返回(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) @@ -4459,8 +4459,8 @@ def _daily_volume_rank(symbol): def _key_hard_checks(symbol, direction, upper, lower, monitor_type): """ - 关键位门控:量能、突破幅度、第二根确认、日成交量前30。 - 使用最近闭合K:breakout=倒数第2根,confirm=倒数第1根。 + 关键位门控:量能,突破幅度,第二根确认,日成交量前30. + 使用最近闭合K:breakout=倒数第2根,confirm=倒数第1根. """ out = {"ok": False} ex_sym = normalize_exchange_symbol(symbol) @@ -4477,7 +4477,7 @@ def _key_hard_checks(symbol, direction, upper, lower, monitor_type): breakout = closed[KEY_CONFIRM_BREAKOUT_BAR] confirm = closed[KEY_CONFIRM_BAR] except IndexError: - out["reason"] = "确认K索引超出范围,请检查 KEY_CONFIRM_* 配置" + 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) @@ -4549,14 +4549,14 @@ def calc_price_diff_pct(current_price, target_price): 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。""" + """最近一根闭合 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: @@ -4566,7 +4566,7 @@ def _fetch_last_closed_bar(symbol): def _key_rs_gate_preview(symbol, upper, lower): - """页面门控预览:阻力/支撑仅显示距上/下沿与是否已越线。""" + """页面门控预览:阻力/支撑仅显示距上/下沿与是否已越线.""" bar = _fetch_last_closed_bar(symbol) if not bar: return {"summary": "5m数据不足", "metrics": ""} @@ -4584,7 +4584,7 @@ def _key_rs_gate_preview(symbol, upper, lower): def _process_key_rs_level_alert(conn, row): - """关键阻力位/支撑位:5m 收盘越上沿或下沿后,按间隔推送最多 KEY_ALERT_MAX_TIMES 次。""" + """关键阻力位/支撑位:5m 收盘越上沿或下沿后,按间隔推送最多 KEY_ALERT_MAX_TIMES 次.""" sym = row["symbol"] typ = (row["monitor_type"] or "").strip() up, low = float(row["upper"]), float(row["lower"]) @@ -4660,18 +4660,18 @@ def _process_key_rs_level_alert(conn, row): 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']})", + 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})", + 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。""" + """按 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( @@ -4700,7 +4700,7 @@ def _market_open_for_key_monitor( time_close_hours=None, ): """ - 与手动「实盘下单」对齐的市价开仓与 order_monitors 写入。 + 与手动「实盘下单」对齐的市价开仓与 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) @@ -4709,7 +4709,7 @@ def _market_open_for_key_monitor( now = app_now() ok, reason = precheck_risk(conn, symbol, direction) if not ok: - return False, f"风控拒绝下单:{reason}", None + return False, f"风控拒绝下单:{reason}", None ok_live, reason_live = ensure_exchange_live_ready() if not ok_live: return False, reason_live, None @@ -4736,7 +4736,7 @@ def _market_open_for_key_monitor( available_usdt = get_available_trading_usdt() live_price = get_price(symbol) if live_price is None: - return False, "获取交易所实时价格失败(以损定仓需要当前价)", None + return False, "获取交易所实时价格失败(以损定仓需要当前价)", None try: ensure_markets_loaded() except Exception: @@ -4754,7 +4754,7 @@ def _market_open_for_key_monitor( risk_fraction = calc_risk_fraction(direction, live_price, stop_loss) if risk_fraction is None: - return False, "止损方向不合法(相对当前市价);请核对上下沿与方向", 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) @@ -4768,7 +4768,7 @@ def _market_open_for_key_monitor( if margin_capital > max_margin: return ( False, - f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", + f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", None, ) @@ -4899,7 +4899,7 @@ def _sqlite_row_val(row, key, default=None): def get_symbol_mark_price(symbol): - """斐波失效判定用标记价。""" + """斐波失效判定用标记价.""" ex_sym = normalize_exchange_symbol(symbol) try: ensure_markets_loaded() @@ -4917,7 +4917,7 @@ def get_symbol_mark_price(symbol): def cancel_fib_limit_order(exchange_symbol, order_id): - """仅撤销本条斐波限价单,不用 cancel_all。""" + """仅撤销本条斐波限价单,不用 cancel_all.""" if not order_id: return False ok_live, _ = ensure_exchange_live_ready() @@ -5136,17 +5136,17 @@ def _finalize_fib_key_fill(conn, row): if amount <= 0: send_wechat_msg( f"# ❌ {symbol} {kind}成交后处理失败\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 无法取得持仓/下单数量,未挂 TP/SL\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"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}\n" + f"- 原因:{reason}\n" f"- 请手动处理仓位与挂单\n" ) return @@ -5157,8 +5157,8 @@ def _finalize_fib_key_fill(conn, row): 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"**账户:{_wechat_account_label()}**\n" + f"- 错误:{friendly_exchange_error(e)}\n" f"- 请手动补挂止盈止损\n" ) return @@ -5177,13 +5177,13 @@ def _finalize_fib_key_fill(conn, row): 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"**账户:{_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) @@ -5215,7 +5215,7 @@ def _add_trigger_entry_key_monitor( 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} 已有触价开仓监控(同币仅允许一条)" + return False, f"{symbol} 已有触价开仓监控(同币仅允许一条)" ex_sym = normalize_exchange_symbol(symbol) mark = get_symbol_mark_price(symbol) geom_err = validate_trigger_entry_geometry( @@ -5285,7 +5285,7 @@ def _add_trigger_entry_key_monitor( leverage = 5 risk_fraction = calc_risk_fraction(direction_sel, entry, sl) if risk_fraction is None: - return False, "止损方向不合法(相对计划入场价)" + 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) @@ -5297,7 +5297,7 @@ def _add_trigger_entry_key_monitor( if margin_capital > max_margin: return ( False, - f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", + f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", ) try: amount_plan, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry) @@ -5352,14 +5352,14 @@ def _market_open_for_trigger_entry( time_close_enabled=0, time_close_hours=None, ): - """触价触发后市价开仓,计仓规则与实盘下单/关键位 RR 门槛一致。""" + """触价触发后市价开仓,计仓规则与实盘下单/关键位 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 + return False, f"风控拒绝下单:{reason}", None ok_live, reason_live = ensure_exchange_live_ready() if not ok_live: return False, reason_live, None @@ -5398,7 +5398,7 @@ def _market_open_for_trigger_entry( 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 + 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): @@ -5428,7 +5428,7 @@ def _market_open_for_trigger_entry( leverage = 5 risk_fraction = calc_risk_fraction(direction, entry_price, stop_loss) if risk_fraction is None: - return False, "止损方向不合法(相对计划入场价)", 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) @@ -5439,7 +5439,7 @@ def _market_open_for_trigger_entry( if margin_capital > max_margin: return ( False, - f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", + 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 @@ -5551,7 +5551,7 @@ def _market_open_for_trigger_entry( def _execute_trigger_entry_cross(conn, row): - """标记价触达计划入场:加锁防重复触发,成交成功后再删监控行。""" + """标记价触达计划入场:加锁防重复触发,成交成功后再删监控行.""" symbol = row["symbol"] direction = (row["direction"] or "long").lower() ex_sym = normalize_exchange_symbol(symbol) @@ -5586,9 +5586,9 @@ def _execute_trigger_entry_cross(conn, row): 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" + 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 @@ -5599,14 +5599,14 @@ def _execute_trigger_entry_cross(conn, row): 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"**账户:{_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) @@ -5617,9 +5617,9 @@ def _execute_trigger_entry_cross(conn, row): 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" + 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 @@ -5657,9 +5657,9 @@ def check_trigger_entry_key_monitors(): 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" + 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) @@ -5668,8 +5668,8 @@ def check_trigger_entry_key_monitors(): if inv == "tp": msg = ( f"# ⚠️ {symbol} 触价开仓失效\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 类型:{mt}|标记价 {format_price_for_symbol(symbol, mark)} 已触达止盈侧(未成交)\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) @@ -5677,8 +5677,8 @@ def check_trigger_entry_key_monitors(): if inv == "sl": msg = ( f"# ⚠️ {symbol} 触价开仓失效\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 类型:{mt}|标记价 {format_price_for_symbol(symbol, mark)} 已触达止损侧(未突破)\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) @@ -5712,9 +5712,9 @@ def check_fib_key_monitors(): 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"**账户:{_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) @@ -5732,18 +5732,18 @@ def check_fib_key_monitors(): _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" + 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" + f"# ⚠️ {symbol} 斐波监控失效(限价已不在挂单)\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 标记价触达止盈侧,本条已结案\n" ) send_wechat_msg(msg) _finalize_key_monitor_one_shot(conn, r, msg, "fib_invalidate") @@ -5764,10 +5764,10 @@ def _add_false_breakout_key_monitor( time_close_enabled=0, time_close_hours=None, ): if _false_breakout_exists_for_symbol(conn, symbol): - return False, f"{symbol} 已有假突破监控(同币仅允许一条)" + return False, f"{symbol} 已有假突破监控(同币仅允许一条)" plan = calc_false_breakout_plan(direction_sel, key_px) if not plan: - return False, "假突破价位无效,请核对方向与关键价位" + return False, "假突破价位无效,请核对方向与关键价位" entry, sl, tp = plan ex_sym = normalize_exchange_symbol(symbol) entry = round_price_to_exchange(ex_sym, entry) @@ -5795,7 +5795,7 @@ def _add_false_breakout_key_monitor( available_usdt = get_available_trading_usdt() risk_fraction = calc_risk_fraction(direction_sel, entry, sl) if risk_fraction is None: - return False, "止损方向不合法(相对挂单价);请核对方向与关键价位" + 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) @@ -5807,7 +5807,7 @@ def _add_false_breakout_key_monitor( if margin_capital > max_margin: return ( False, - f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", + f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", ) try: amount, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry) @@ -5838,11 +5838,11 @@ def _add_fib_key_monitor( time_close_enabled=0, time_close_hours=None, ): if _fib_key_exists_for_symbol(conn, symbol): - return False, f"{symbol} 已有斐波监控(同币仅允许一条 0.618/0.786)" + 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)" + return False, "斐波上下沿无效(需上沿 H > 下沿 L)" entry, sl, tp = plan ex_sym = normalize_exchange_symbol(symbol) entry = round_price_to_exchange(ex_sym, entry) @@ -5854,7 +5854,7 @@ def _add_fib_key_monitor( 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)" + 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 @@ -5874,7 +5874,7 @@ def _add_fib_key_monitor( available_usdt = get_available_trading_usdt() risk_fraction = calc_risk_fraction(direction_sel, entry, sl) if risk_fraction is None: - return False, "止损方向不合法(相对挂单价 E);请核对上下沿与方向" + 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) @@ -5886,7 +5886,7 @@ def _add_fib_key_monitor( if margin_capital > max_margin: return ( False, - f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", + f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", ) try: amount, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry) @@ -5912,7 +5912,7 @@ def _add_fib_key_monitor( return True, None -# 关键位监控(箱体/收敛可自动开仓;阻力/支撑为双向 5m 收盘突破 + 三次提醒) +# 关键位监控(箱体/收敛可自动开仓;阻力/支撑为双向 5m 收盘突破 + 三次提醒) def check_key_monitors(): conn = get_db() rows = conn.execute("SELECT * FROM key_monitors").fetchall() @@ -5941,10 +5941,10 @@ def check_key_monitors(): 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"**账户:{_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" + f"{format_price_for_symbol(sym, edge)}(设置失效)\n" ) send_wechat_msg(msg) _finalize_key_monitor_one_shot(conn, r, msg, "box_opposite_break") @@ -5960,7 +5960,7 @@ def check_key_monitors(): 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)主趋势逆势,建议降低仓位并严格执行止损。" + risk_tip = "当前信号与本币4h(EMA55)主趋势逆势,建议降低仓位并严格执行止损." key_price = float(low) if direction == "long" else float(up) hard_lines = _key_hard_lines_from_checks(checks) @@ -5971,15 +5971,15 @@ def check_key_monitors(): plan_tuple, sl_tp_mode = _key_plan_sl_tp_for_row(r, direction, up, low, checks) if not plan_tuple: - fmt_rr = "无法计算(止损/止盈与确认价几何关系无效)" + 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" + 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) @@ -6006,23 +6006,23 @@ def check_key_monitors(): 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 "无法计算(止损/止盈与确认价几何关系无效)" + 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" + 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) @@ -6054,15 +6054,15 @@ def check_key_monitors(): 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" + 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) @@ -6074,7 +6074,7 @@ def check_key_monitors(): continue tpsl_txt = ( - "已在交易所挂条件委托(止盈、止损触发单)" + "已在交易所挂条件委托(止盈,止损触发单)" if det.get("tpsl_attached") else "⚠️ 条件委托挂接状态异常或未挂上" ) @@ -6083,23 +6083,23 @@ def check_key_monitors(): 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"**账户:{_wechat_account_label()}**", + f"- **来源:**{ORDER_MONITOR_TYPE_KEY_AUTO}(市价)", + f"- 页面订单 ID:**{det['new_order_id']}**", + f"- 交易所订单 ID:`{det.get('open_order_id') or '-'}`", + f"- 类型:{typ}|方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'开' if be_on else '关'}", + f"- 方向:**{_wechat_direction_text(direction)}**", + f"- 触发时间:`{trigger_time}`", + f"- 确认K收盘(E):{format_price_for_symbol(sym, E)}(RR 阈值按此计价)", + f"- **计划 RR(E):{planned_rr_txt}:1**", + f"- 开仓成交价:**{format_price_for_symbol(sym, det['trigger_price'])}**", + f"- **成交价侧计划 RR:**{rr_fill_txt}:1", + f"- 止损:{format_wechat_scalar_2dp(sl_raw)}", + f"- 止盈:{format_price_for_symbol(sym, tp_raw)}", + f"- 风险:{det.get('risk_percent')}%≈{format_wechat_scalar_2dp(det.get('risk_amount_final'))}U|基数 {format_wechat_scalar_2dp(det.get('margin_capital'))}U|杠杆 {det.get('leverage')}x", f"- 名义 {format_wechat_scalar_2dp(det.get('notional_value'))}U|张数 {format_wechat_scalar_2dp(det.get('amount'))}|折算标的 {det.get('base_amount')}", f"- **{tpsl_txt}**", - f"- 保本触发:{det.get('breakeven_rr_trigger')}R→{format_price_for_symbol(sym, det.get('breakeven_price'))}", + f"- 保本触发:{det.get('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]) @@ -6120,7 +6120,7 @@ def check_key_monitors(): det.get("opens_today_after", 0), DAILY_OPEN_ALERT_THRESHOLD, hard_limit=DAILY_OPEN_HARD_LIMIT, - detail_line=f"最新一笔来源为关键位自动单:{sym} {direction},杠杆{det['leverage']}x。", + detail_line=f"最新一笔来源为关键位自动单:{sym} {direction},杠杆{det['leverage']}x.", ) ) if advice: @@ -6128,7 +6128,7 @@ def check_key_monitors(): conn.commit() conn.close() -# 止盈止损监控(已修复:严格区分多空,无默认做多) +# 止盈止损监控(已修复:严格区分多空,无默认做多) def check_order_monitors(): conn = get_db() rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() @@ -6155,7 +6155,7 @@ def check_order_monitors(): p = get_price(sym) if not p: continue - # 到达设定 R 倍后,按阶梯持续上移止损(本地风控层) + # 到达设定 R 倍后,按阶梯持续上移止损(本地风控层) risk_amount = float(r["risk_amount"] or 0) breakeven_armed = int(r["breakeven_armed"] or 0) trigger_rr = float(r["breakeven_rr_trigger"] or BREAKEVEN_RR_TRIGGER) @@ -6206,7 +6206,7 @@ def check_order_monitors(): ) _send_breakeven_exchange_warn_once( pid, - f"⚠️ {sym} 移动保本止损未同步交易所:{friendly_exchange_error(e)}", + f"⚠️ {sym} 移动保本止损未同步交易所:{friendly_exchange_error(e)}", ) elif ok_live: print( @@ -6231,7 +6231,7 @@ def check_order_monitors(): new_sl, ) if ok_live: - be_msg += "\n- 交易所:已先撤后挂止盈止损" + be_msg += "\n- 交易所:已先撤后挂止盈止损" send_wechat_msg(be_msg) res = None @@ -6261,7 +6261,7 @@ def check_order_monitors(): 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) @@ -6312,7 +6312,7 @@ def check_order_monitors(): 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: @@ -6351,7 +6351,7 @@ def check_order_monitors(): actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), result=res, miss_reason=handoff_trade_miss_reason( - "触发价已触达,仓位已由交易所止盈/止损或其他方式平掉(本地补记)", + "触发价已触达,仓位已由交易所止盈/止损或其他方式平掉(本地补记)", r, ), opened_at=opened_at, @@ -6362,7 +6362,7 @@ def check_order_monitors(): build_wechat_close_message( symbol=sym, direction=direction, - result=f"{res}(交易所已先行平仓)", + result=f"{res}(交易所已先行平仓)", pnl_amount=pnl_amount, hold_seconds=hold_seconds, trigger_price=trigger_price, @@ -6370,7 +6370,7 @@ def check_order_monitors(): stop_loss=stop_loss, take_profit=take_profit, close_order_id="-", - extra_note="本地补记:仓位由交易所止盈/止损或其他方式先行平掉", + extra_note="本地补记:仓位由交易所止盈/止损或其他方式先行平掉", session_capital_fallback=session_capital, ) ) @@ -6384,11 +6384,11 @@ def check_order_monitors(): 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}" + 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}" + record_miss = f"触发{res}后交易所平仓失败(请核对交易所仓位):{e}" monitor_status = "error" record_hold = calc_hold_seconds( opened_at, parse_dt_for_trading_day(record_closed) or now @@ -6434,7 +6434,7 @@ def check_order_monitors(): build_wechat_close_message( symbol=sym, direction=direction, - result=f"{record_res}(已补记入交易记录)", + result=f"{record_res}(已补记入交易记录)", pnl_amount=record_pnl, hold_seconds=record_hold, trigger_price=trigger_price, @@ -6499,7 +6499,7 @@ def force_close_before_reset(): if not FORCE_CLOSE_ENABLED: return now = app_now() - # 每天北京时间指定整点小时内执行一次性兜底清仓(默认 00:xx) + # 每天北京时间指定整点小时内执行一次性兜底清仓(默认 00:xx) if now.hour != FORCE_CLOSE_BJ_HOUR: return conn = get_db() @@ -6657,9 +6657,9 @@ def sync_positions(): conn.commit() conn.close() if sync_days is not None: - flash(f"同步完成:最近 {sync_days} 天内 {synced} 笔持仓已按交易所状态更新") + flash(f"同步完成:最近 {sync_days} 天内 {synced} 笔持仓已按交易所状态更新") else: - flash(f"同步完成:{synced} 笔持仓已按交易所状态更新") + flash(f"同步完成:{synced} 笔持仓已按交易所状态更新") return redirect("/") @@ -6698,7 +6698,7 @@ def _coerce_ts_ms(val): def _unified_symbol_for_match(symbol_str): - """统一 ETH/USDT:USDT、ETH_USDT、ETH/USDT 便于与 trade_records 比对。""" + """统一 ETH/USDT:USDT,ETH_USDT,ETH/USDT 便于与 trade_records 比对.""" s = (symbol_str or "").strip().upper() if not s: return "" @@ -6820,7 +6820,7 @@ def fetch_gate_positions_close_history(): def sync_trade_records_from_exchange(conn, force=False): - """为未同步的 trade_records 回填 Gate 平仓历史中的已实现盈亏。返回统计 dict。""" + """为未同步的 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(): @@ -6839,7 +6839,7 @@ def sync_trade_records_from_exchange(conn, force=False): stats["hist_count"] = len(hist) if not hist: stats["ok"] = True - stats["reason"] = "交易所平仓历史为空(请检查 API 权限或 EXCHANGE_POSITION_SYNC_FROM_BJ)" + stats["reason"] = "交易所平仓历史为空(请检查 API 权限或 EXCHANGE_POSITION_SYNC_FROM_BJ)" return stats candidates = conn.execute( """ @@ -6944,7 +6944,7 @@ def render_main_page(page="trade", embed_mode=None): funding_capital, trading_capital = get_exchange_capitals() else: funding_capital, trading_capital = None, None - # 资金账户:仅展示交易所读取结果(含 0)。不可用 TOTAL_CAPITAL 兜底,否则会与实盘不符。 + # 资金账户:仅展示交易所读取结果(含 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) @@ -7334,7 +7334,7 @@ def api_price_snapshot(): if exchange_private_api_configured(): try: ensure_markets_loaded() - # 显式 USDT 本位;不传 symbols 拉全量,再在本地按合约对齐 + # 显式 USDT 本位;不传 symbols 拉全量,再在本地按合约对齐 all_swap_positions = exchange.fetch_positions(None, {"settle": "usdt"}) or [] except Exception: try: @@ -7879,7 +7879,7 @@ def api_order_kline(): 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 + return jsonify({"ok": False, "msg": f"K线加载失败:{friendly_exchange_error(e)}"}), 500 candles = [] for bar in ohlcv or []: @@ -8009,7 +8009,7 @@ def api_key_kline(): 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 + return jsonify({"ok": False, "msg": f"K线加载失败:{friendly_exchange_error(e)}"}), 500 candles = [] for bar in ohlcv or []: @@ -8132,11 +8132,11 @@ def add_key(): if not skip_volume_rank: rank, total = _daily_volume_rank(symbol) if rank is None: - flash("日成交量排名读取失败,请稍后重试") + flash("日成交量排名读取失败,请稍后重试") return redirect("/key_monitor") if rank > KEY_DAILY_VOLUME_RANK_MAX: flash( - f"{symbol} 当前日成交量排名为 {rank}/{total},不在前{KEY_DAILY_VOLUME_RANK_MAX},已拒绝添加关键位" + f"{symbol} 当前日成交量排名为 {rank}/{total},不在前{KEY_DAILY_VOLUME_RANK_MAX},已拒绝添加关键位" ) return redirect("/key_monitor") conn = get_db() @@ -8146,8 +8146,8 @@ def add_key(): conn.close() conn = None flash( - f"当前持仓已达上限({occupied}/{MAX_ACTIVE_POSITIONS}):无法添加「箱体突破 / 收敛突破」。" - "请平仓后再试,或使用「关键支撑阻力」(仅提醒)。" + f"当前持仓已达上限({occupied}/{MAX_ACTIVE_POSITIONS}):无法添加「箱体突破 / 收敛突破」." + "请平仓后再试,或使用「关键支撑阻力」(仅提醒)." ) return redirect("/key_monitor") ex_sym_key = normalize_exchange_symbol(symbol) @@ -8175,7 +8175,7 @@ def add_key(): if entry_px <= 0 or sl_px <= 0 or tp_px <= 0: conn.close() conn = None - flash("触价须填写有效的入场价、止损价、止盈价") + flash("触价须填写有效的入场价,止损价,止盈价") return redirect("/key_monitor") ok_te, err_te = _add_trigger_entry_key_monitor( conn, @@ -8201,10 +8201,10 @@ def add_key(): else "标记价回调触达入场价后下一轮询市价开仓" ) flash( - f"{mt}已添加({symbol} 日成交量排名 {rank}/{total})" + f"{mt}已添加({symbol} 日成交量排名 {rank}/{total})" f"|有效期 {TRIGGER_ENTRY_VALIDITY_HOURS}h" f"|{trigger_hint}" - f"|移动保本:{'开' if be_flag else '关'}" + f"|移动保本:{'开' if be_flag else '关'}" + (f"|{time_close_label(tc_h)}" if tc_en else "") ) return redirect("/key_monitor") @@ -8228,7 +8228,7 @@ def add_key(): if key_px <= 0: conn.close() conn = None - flash("请填写关键价位(做空填高点,做多填低点)") + flash("请填写关键价位(做空填高点,做多填低点)") return redirect("/key_monitor") ex_sym_key = normalize_exchange_symbol(symbol) key_adj = round_price_to_exchange(ex_sym_key, key_px) @@ -8251,8 +8251,8 @@ def add_key(): flash(err_fb or "假突破监控添加失败") return redirect("/key_monitor") flash( - f"假突破监控已添加,限价单已挂出({symbol})" - f"|有效期 {FALSE_BREAKOUT_VALIDITY_HOURS}h|移动保本:{'开' if be_flag else '关'}" + 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") @@ -8283,8 +8283,8 @@ def add_key(): flash(err_fib or "斐波监控添加失败") return redirect("/key_monitor") flash( - f"斐波监控已添加,限价单已挂出({symbol} 日成交量排名 {rank}/{total})" - f"|移动保本:{'开' if be_flag else '关'}" + f"斐波监控已添加,限价单已挂出({symbol} 日成交量排名 {rank}/{total})" + f"|移动保本:{'开' if be_flag else '关'}" + (f"|{time_close_label(tc_h)}" if tc_en else "") ) return redirect("/key_monitor") @@ -8305,12 +8305,12 @@ def add_key(): if direction_sel == "long" and manual_tp <= upper_px: conn.close() conn = None - flash("做多趋势单:止盈价应高于上沿(阻力)") + flash("做多趋势单:止盈价应高于上沿(阻力)") return redirect("/key_monitor") if direction_sel == "short" and manual_tp >= lower_px: conn.close() conn = None - flash("做空趋势单:止盈价应低于下沿(支撑)") + flash("做空趋势单:止盈价应低于下沿(支撑)") return redirect("/key_monitor") mtpx = round_price_to_exchange(ex_sym_key, manual_tp) if mtpx is not None: @@ -8357,19 +8357,19 @@ def add_key(): pass extra = "" if mt in KEY_MONITOR_AUTO_TYPES: - extra = f"|方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'开' if be_flag else '关'}" + 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} 分钟)" + f"添加成功({symbol} 日成交量排名 {rank}/{total})|关键支撑阻力:双向监控上/下沿," + f"5m 收盘突破后微信提醒 {KEY_ALERT_MAX_TIMES} 次(间隔 {KEY_ALERT_INTERVAL_MINUTES} 分钟)" ) else: - flash(f"添加成功({symbol} 日成交量排名 {rank}/{total}){extra}") + flash(f"添加成功({symbol} 日成交量排名 {rank}/{total}){extra}") if ctr: flash( - "⚠️ 4h EMA55 提示:当前与所选方向逆势;「箱体突破/收敛突破」在条件满足时仍会按计划自动市价开仓,请注意仓位。" + "⚠️ 4h EMA55 提示:当前与所选方向逆势;「箱体突破/收敛突破」在条件满足时仍会按计划自动市价开仓,请注意仓位." ) return redirect("/key_monitor") except Exception as e: @@ -8378,7 +8378,7 @@ def add_key(): conn.close() except Exception: pass - flash(f"添加关键位失败:{e}") + flash(f"添加关键位失败:{e}") return redirect("/key_monitor") @app.route("/add_order", methods=["POST"]) @@ -8396,7 +8396,7 @@ def add_order(): ok_pol, pol_msg = validate_trade_policy_open(symbol, direction) if not ok_pol: conn.close() - flash(f"账户限制:{pol_msg}") + flash(f"账户限制:{pol_msg}") return redirect("/trade") dup_msg = check_duplicate_submit(session, submit_scope_add_order(symbol, direction)) if dup_msg: @@ -8406,12 +8406,12 @@ def add_order(): ok, reason = precheck_risk(conn, symbol, direction) if not ok: conn.close() - flash(f"风控拒绝下单:{reason}") + flash(f"风控拒绝下单:{reason}") return redirect("/trade") ok_live, reason_live = ensure_exchange_live_ready() if not ok_live: conn.close() - flash(f"风控拒绝下单:{reason_live}") + flash(f"风控拒绝下单:{reason_live}") return redirect("/trade") exchange_symbol = normalize_exchange_symbol(symbol) trading_day = get_trading_day(now) @@ -8435,7 +8435,7 @@ def add_order(): live_price = get_price(symbol) if live_price is None: conn.close() - flash("获取交易所实时价格失败,请稍后重试") + flash("获取交易所实时价格失败,请稍后重试") return redirect("/") try: ensure_markets_loaded() @@ -8461,7 +8461,7 @@ def add_order(): 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") + 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) @@ -8472,7 +8472,7 @@ def add_order(): risk_fraction = calc_risk_fraction(direction, live_price, stop_loss) if risk_fraction is None: conn.close() - flash("止损方向不合法:请检查入场方向与止损价格关系") + flash("止损方向不合法:请检查入场方向与止损价格关系") return redirect("/") risk_percent = max(0.01, float(RISK_PERCENT)) risk_amount = round(capital_base * risk_percent / 100.0, 2) @@ -8516,13 +8516,13 @@ def add_order(): margin_capital = round(notional_value / leverage, 2) if capital_base and margin_capital > capital_base: conn.close() - flash("以损定仓后保证金超过当前交易资金,请放宽止损或降低风险比例") + 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") + 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: @@ -8665,11 +8665,11 @@ def add_order(): else round(float(capital_base), 2) ) account_name = (os.getenv("GATE_ACCOUNT_LABEL") or "gate实盘账户").strip() - dir_text = "多头(long)" if direction == "long" else "空头(short)" + dir_text = "多头(long)" if direction == "long" else "空头(short)" order_state_text = ( - "已在交易所挂条件委托(止盈、止损各一张触发单)" + "已在交易所挂条件委托(止盈,止损各一张触发单)" if tpsl_attached - else "条件委托未挂上(已拦截)" + else "条件委托未挂上(已拦截)" ) rr_show = planned_rr if planned_rr is not None else "-" try: @@ -8684,43 +8684,43 @@ def add_order(): style_zh = "Swing 波段" if trade_style == "swing" else "Trend 趋势" wx_lines = [ f"📈 {symbol} 开仓成功", - f"💼 交易类型:{dir_text}", + f"💼 交易类型:{dir_text}", "🧾 订单基础信息", - f"🔖 交易所订单 ID:{open_order_id}", - f"📈 交易风格:{style_zh}", - f"⚠️ 单笔风控风险:{risk_display}", + 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"账户基数:{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"开仓成交价:{ep_wx}", + f"止损价位:{sl_wx}", + f"止盈价位:{tp_wx}", + f"计划盈亏比:{rr_line}", + f"移动保本位:{breakeven_rr_trigger}R → {be_wx}", "📌 状态统计", - f"✅ 条件委托:{order_state_text}", + 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}") + 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 '-'};已在交易所挂条件止盈/止损委托(非仓位绑定型)", + 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_lines.append(f"已生成多周期K线图:{chart_url}") flash(" ".join(flash_lines)) if should_send_daily_open_alert( @@ -8732,12 +8732,12 @@ def add_order(): 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。", + 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]}") + flash(f"【AI提醒】今日开仓次数已达 {opens_today_after}:{advice[:300]}") return redirect("/") @app.route("/delete_key_monitor/", methods=["POST"]) @@ -9055,7 +9055,7 @@ def del_order(id): 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}" + 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) @@ -9107,10 +9107,10 @@ def del_order(id): pass conn.commit() conn.close() - flash("该仓位在交易所已不存在,已按成交记录同步结束并记账") + flash("该仓位在交易所已不存在,已按成交记录同步结束并记账") return redirect("/") conn.close() - flash(f"手动平仓失败:{str(e)}") + flash(f"手动平仓失败:{str(e)}") return redirect("/") conn.execute("DELETE FROM order_monitors WHERE id=?",(id,)) conn.commit() @@ -9136,7 +9136,7 @@ def add_journal(): 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) @@ -9158,7 +9158,7 @@ def add_journal(): try: risk_amount_hint = float(d.get("risk_amount_hint") or 0) pnl_hint = float(d.get("pnl") or 0) - # 口径统一:实际RR = 实际盈亏 / 以损定仓对应的初始风险金额 + # 口径统一:实际RR = 实际盈亏 / 以损定仓对应的初始风险金额 if risk_amount_hint > 0: real_rr_text = f"{(pnl_hint / risk_amount_hint):.2f}" except Exception: @@ -9206,11 +9206,11 @@ def add_journal(): ) if saved: image_filename = saved - chart_msg = f"已生成复盘K线图({'/'.join(journal_tfs)} 各{journal_limit}根):/static/images/{saved}" + chart_msg = f"已生成复盘K线图({'/'.join(journal_tfs)} 各{journal_limit}根):/static/images/{saved}" else: - chart_msg = "已勾选自动生成K线图,但生成失败(返回空)。请检查 Pillow 是否安装、Gate 网络/代理是否正常。" + chart_msg = "已勾选自动生成K线图,但生成失败(返回空).请检查 Pillow 是否安装,Gate 网络/代理是否正常." except Exception as e: - chart_msg = f"自动生成K线图失败:{str(e)}" + chart_msg = f"自动生成K线图失败:{str(e)}" conn = get_db() conn.execute( @@ -9247,7 +9247,7 @@ def add_journal(): conn.commit() conn.close() if chart_msg: - flash(f"交易复盘记录已保存。{chart_msg}") + flash(f"交易复盘记录已保存.{chart_msg}") else: flash("交易复盘记录已保存") return _redirect_records() @@ -9360,7 +9360,7 @@ def export_review_md(rid): created_at = row["created_at"] or app_now_str() content = (row["content"] or "").strip() if not content: - content = "(无内容)" + content = "(无内容)" md = ( f"# {review_type}报告\n\n" @@ -9409,7 +9409,7 @@ def export_reviews_md_bundle(): ] for idx, row in enumerate(rows, 1): created_at = row["created_at"] or "-" - content = (row["content"] or "").strip() or "(无内容)" + content = (row["content"] or "").strip() or "(无内容)" lines.extend( [ f"## 第{idx}条", @@ -9468,13 +9468,13 @@ def api_trade_record_review_update(): 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 + 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 + 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()) @@ -9585,9 +9585,9 @@ def manual_transfer(): conn.commit() conn.close() if ok: - flash(f"手动划转成功:{amount}U {from_account}->{to_account}") + flash(f"手动划转成功:{amount}U {from_account}->{to_account}") else: - flash(f"手动划转失败:{msg}") + flash(f"手动划转失败:{msg}") return redirect("/settings") @@ -9616,7 +9616,7 @@ def ai_daily_review(): if not rows: return jsonify({"result": "该日无交易记录"}) - text = f"【每日交易记录】{date}\n总笔数:{len(rows)}\n\n" + 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" @@ -9627,7 +9627,7 @@ def ai_daily_review(): 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}" + 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 (?,?,?,?)", @@ -9652,7 +9652,7 @@ def ai_weekly_review(): if not rows: return jsonify({"result": "该时间段无交易记录"}) - text = f"【周交易记录】{start_date}~{end_date}\n总笔数:{len(rows)}\n\n" + 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" @@ -9663,7 +9663,7 @@ def ai_weekly_review(): 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}" + 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 (?,?,?,?)", @@ -9677,8 +9677,8 @@ 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"周期 {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, diff --git a/crypto_monitor_gate/ecosystem.config.cjs b/crypto_monitor_gate/ecosystem.config.cjs index 71b25fe..ffb5053 100644 --- a/crypto_monitor_gate/ecosystem.config.cjs +++ b/crypto_monitor_gate/ecosystem.config.cjs @@ -1,14 +1,14 @@ /** - * PM2 进程定义(Ubuntu / Linux)。 + * PM2 进程定义(Ubuntu / Linux). * - * 仅托管 Flask 应用。**SSH SOCKS 隧道**用 `ssh -D` 常驻(可用 tmux / autossh),勿交给 PM2。 - * 与 `.env` 里 `GATE_SOCKS_PROXY` 端口一致即可;不必交给 PM2。 + * 仅托管 Flask 应用.**SSH SOCKS 隧道**用 `ssh -D` 常驻(可用 tmux / autossh),勿交给 PM2. + * 与 `.env` 里 `GATE_SOCKS_PROXY` 端口一致即可;不必交给 PM2. * - * 使用前:项目根目录存在 `.venv`,且已安装依赖(走 SOCKS 时需 PySocks)。 + * 使用前:项目根目录存在 `.venv`,且已安装依赖(走 SOCKS 时需 PySocks). * - * 启动: + * 启动: * pm2 start ecosystem.config.cjs - * 保存开机列表: + * 保存开机列表: * pm2 save && pm2 startup */ const path = require("path"); diff --git a/crypto_monitor_gate/scripts/fix_breakeven_labels.py b/crypto_monitor_gate/scripts/fix_breakeven_labels.py index 80b7d04..97a910a 100644 --- a/crypto_monitor_gate/scripts/fix_breakeven_labels.py +++ b/crypto_monitor_gate/scripts/fix_breakeven_labels.py @@ -1,18 +1,18 @@ #!/usr/bin/env python3 """ -一次性修复历史交易记录标签: -将 trade_records 里“止损但实际盈利”的记录改为“保本止盈”。 +一次性修复历史交易记录标签: +将 trade_records 里“止损但实际盈利”的记录改为“保本止盈”. -默认条件(可通过参数修改): +默认条件(可通过参数修改): - monitor_type = 下单监控 - result = 止损 - pnl_amount > 0 -用法示例: -1) 仅预览(不落库): +用法示例: +1) 仅预览(不落库): python scripts/fix_breakeven_labels.py --db ./crypto.db --dry-run -2) 执行修复: +2) 执行修复: python scripts/fix_breakeven_labels.py --db ./crypto.db --apply """ diff --git a/crypto_monitor_gate/scripts/verify_gate_funding.py b/crypto_monitor_gate/scripts/verify_gate_funding.py index bd410a8..5ef52a3 100644 --- a/crypto_monitor_gate/scripts/verify_gate_funding.py +++ b/crypto_monitor_gate/scripts/verify_gate_funding.py @@ -1,9 +1,9 @@ """ -在项目根目录执行(会加载根目录 .env): +在项目根目录执行(会加载根目录 .env): python scripts/verify_gate_funding.py -依次探测:[0] swap 余额(与 App「交易账户」同源);[1]–[3] 现货 / 统一账户资金路径。 -打印 GATE_API_KEY 前 8 位便于与 Gate 控制台核对(不含 Secret)。用于服务器自检。 +依次探测:[0] swap 余额(与 App「交易账户」同源);[1]–[3] 现货 / 统一账户资金路径. +打印 GATE_API_KEY 前 8 位便于与 Gate 控制台核对(不含 Secret).用于服务器自检. """ from __future__ import annotations @@ -39,12 +39,12 @@ def main(): 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") + print("WARN: GATE_API_KEY 为空或仍像占位符,请核对 .env") if not s or "REPLACE" in s.upper(): - print("WARN: GATE_API_SECRET 为空或仍像占位符,请核对 .env") + print("WARN: GATE_API_SECRET 为空或仍像占位符,请核对 .env") print("GATE_API_KEY prefix (8 chars):", (k[:8] + "…") if len(k) > 8 else "(short)") - # 0) swap — 与 App「交易账户」余额同源(优先看此项是否与网页一致) + # 0) swap — 与 App「交易账户」余额同源(优先看此项是否与网页一致) try: bal = mod.exchange.fetch_balance({"type": "swap"}) v0 = mod._extract_usdt_total(bal) diff --git a/crypto_monitor_gate/templates/order_focus.html b/crypto_monitor_gate/templates/order_focus.html index c0992d4..cb7c8df 100644 --- a/crypto_monitor_gate/templates/order_focus.html +++ b/crypto_monitor_gate/templates/order_focus.html @@ -29,9 +29,9 @@
返回首页 - 实盘下单放大(100根K线) + 实盘下单放大(100根K线)
-
最近刷新:--
+
最近刷新:--
{% if orders %}
@@ -53,7 +53,7 @@
{% else %} -
当前没有激活订单,无法展示放大K线。
+
当前没有激活订单,无法展示放大K线.
{% endif %} diff --git a/crypto_monitor_gate/使用说明.md b/crypto_monitor_gate/使用说明.md index 9427d61..937e86e 100644 --- a/crypto_monitor_gate/使用说明.md +++ b/crypto_monitor_gate/使用说明.md @@ -1,147 +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` 即可对照使用。 +# 使用说明 + +**本文件对应仓库:`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 index 3a600ff..a8ca535 100644 --- a/crypto_monitor_gate/关键位自动下单说明.md +++ b/crypto_monitor_gate/关键位自动下单说明.md @@ -1,192 +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` | +# 关键位监控说明(自动开仓 + 人工盯盘) + +**适用: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 index 297c5ef..158e370 100644 --- a/crypto_monitor_gate/更新文档.md +++ b/crypto_monitor_gate/更新文档.md @@ -1,148 +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 已挂上;平仓后交易记录止损(开仓)与开仓类型是否正确。 +# 界面与风控更新说明(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 index 5cfe907..d6315cd 100644 --- a/crypto_monitor_gate/部署文档.md +++ b/crypto_monitor_gate/部署文档.md @@ -1,305 +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 -cd /opt/crypto_monitor -git clone https://git.bz121.com/dekun/crypto_monitor.git -cd crypto_monitor/crypto_monitor_gate -``` - -下文用 **`/opt/crypto_monitor/crypto_monitor_gate`** 仅为示例,请换成你的实际绝对路径。 - -拉取代码后,若目录下尚无 `.env`: - -```bash -cp -n .env.example .env -``` - ---- - -## 2. 配置 SSH 私钥与 `~/.ssh/config` - -```bash -mkdir -p ~/.ssh -chmod 700 ~/.ssh -# 私钥示例:~/.ssh/vps1.pem -chmod 600 ~/.ssh/vps1.pem -``` - -编辑 `~/.ssh/config`(示例别名 **`gate-vps`**,与你手工启动 `ssh -D ... gate-vps` 一致即可): - -```sshconfig -Host gate-vps - HostName 你的_VPS_IP - User root - IdentityFile ~/.ssh/vps1.pem - IdentitiesOnly yes - ServerAliveInterval 30 - ServerAliveCountMax 3 - ExitOnForwardFailure yes - BatchMode yes -``` - -测试: - -```bash -ssh gate-vps true -``` - -> 若尚未完全改为密钥登录,可暂时注释 `BatchMode yes`,调试完成后再打开。 - ---- - -## 3. 手工验证:SSH SOCKS + Gate API - -### 3.1 本地 SOCKS(示例端口 1080) - -```bash -ssh -N -D 127.0.0.1:1080 gate-vps -``` - -保持运行,另开终端继续。 - -### 3.2 验证经 SOCKS 可访问 Gate - -```bash -curl -4 -sS --max-time 15 --proxy socks5h://127.0.0.1:1080 https://api.gateio.ws/api/v4/spot/time -``` - -应返回 JSON(含服务器时间字段)。若此处失败,**不要先启动应用**:先修隧道或 VPS 出站。 - ---- - -## 4. Python 虚拟环境 - -```bash -cd /opt/crypto_monitor/crypto_monitor_gate - -python3 -m venv .venv -source .venv/bin/activate -python -m pip install -U pip -pip install flask requests ccxt werkzeug PySocks Pillow -``` - -走 SOCKS 时 **必须** 安装 **`PySocks`**,否则易出现代理相关报错。 - -可选: - -```bash -export PYTHONDONTWRITEBYTECODE=1 -``` - ---- - -## 5. 配置环境变量(`.env.example` → `.env`) - -| 文件 | 是否进 Git | 说明 | -|------|------------|------| -| **`.env.example`** | ✅ 是 | 变量模板与注释,可随 `git pull` 更新 | -| **`.env`** | ❌ 否 | 本机真实配置;`app.py` **只读此文件** | - -### 5.1 首次配置 - -```bash -cd /opt/crypto_monitor/crypto_monitor_gate - -cp -n .env.example .env -nano .env -``` - -### 5.2 备份与 `git pull` - -- **`.env` 不在 Git 中**:`git pull` **不会**覆盖本地 `.env`。 -- 远端若更新 **`.env.example`**,pull 后请**手动**把新增变量补进你的 `.env`。 -- **升级前备份**:`cp .env .env.backup.$(date +%Y%m%d)`;恢复:`cp .env.backup.YYYYMMDD .env`。 -- **换机**:`scp` 复制 `.env`,或新机 `cp .env.example .env` 后重填。 - -### 5.3 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/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/crypto_monitor_gate -source .venv/bin/activate -python app.py -``` - -浏览器访问:`http://127.0.0.1:5000`(或你在 `.env` 中的端口)。 - ---- - -## 7. 安装 PM2 - -```bash -sudo npm i -g pm2 -pm2 -v -``` - ---- - -## 8. PM2:使用仓库内 `ecosystem.config.cjs`(推荐) - -在项目根目录: - -```bash -cd /opt/crypto_monitor/crypto_monitor_gate -pm2 start ecosystem.config.cjs -pm2 status -pm2 logs --lines 200 -``` - -默认只启动 **`crypto-monitor-gate`**(`.venv/bin/python app.py`)。 - -### 本机已可直连 Gate、不需要隧道时 - -`.env` 里应 **去掉或留空** `GATE_SOCKS_PROXY`(除非仍要走别的代理),再 `pm2 start ecosystem.config.cjs`。 - -### 开机自启 - -```bash -pm2 save -pm2 startup -# 按屏幕提示执行一条 sudo 命令 -``` - ---- - -## 9. 等价手工命令(不使用 ecosystem 文件时) - -### 9.1 SSH SOCKS(自行后台常驻,不推荐用 PM2) - -示例(前台调试;生产请用 **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/crypto_monitor_gate -pm2 start /opt/crypto_monitor/crypto_monitor_gate/.venv/bin/python --name crypto-monitor-gate -- \ - /opt/crypto_monitor/crypto_monitor_gate/app.py -``` - ---- - -## 10. 交易所「连接不上」排查清单 - -1. **`.env` 是否为 Gate 变量**:必须是 `GATE_SOCKS_PROXY` / `GATE_API_KEY` / `GATE_API_SECRET`,不是 OKX。 -2. **隧道是否在本机端口监听**(若配置了 `GATE_SOCKS_PROXY`): - ```bash - ss -lntp | grep 1080 || true - ``` -3. **curl 复测 Gate**(与第 3.2 节相同);curl 不通则应用也不会通。 -4. **PySocks**:`pip show PySocks`,缺失则 `pip install PySocks`。 -5. **SSH 隧道连不上**:检查私钥权限、`~/.ssh/config`、VPS 出站与端口是否与 `.env` 一致。 -6. **启动顺序**:先保证 SOCKS 已监听,再 `pm2 start` 应用(或重启应用)。 - ---- - -## 11. 推荐启动顺序(习惯) - -1. 若走代理:先启动并确认 SSH SOCKS 已监听,再 `curl --proxy socks5h://127.0.0.1:1080 https://api.gateio.ws/api/v4/spot/time` 成功 -2. `pm2 start ecosystem.config.cjs` -3. 再确认页面与余额等接口正常 - ---- - -## 12. 免责声明 - -交易所有合规与地区政策要求。请确保使用方式符合当地法律法规与交易所条款。本文仅描述网络与工程部署路径。 - ---- - -## 附录:数据库标签修复脚本 `scripts/fix_breakeven_labels.py` - -在 Ubuntu 上: - -1)预览(不写库): - -```bash -python scripts/fix_breakeven_labels.py --db ./crypto.db --dry-run -``` - -2)确认后执行: - -```bash -python scripts/fix_breakeven_labels.py --db ./crypto.db --apply -``` - -默认修复条件:`monitor_type='下单监控'` 且 `result='止损'` 且 `pnl_amount > 0` → 改为 `result='保本止盈'`。 +# `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 +cd /opt/crypto_monitor +git clone https://git.bz121.com/dekun/crypto_monitor.git +cd crypto_monitor/crypto_monitor_gate +``` + +下文用 **`/opt/crypto_monitor/crypto_monitor_gate`** 仅为示例,请换成你的实际绝对路径. + +拉取代码后,若目录下尚无 `.env`: + +```bash +cp -n .env.example .env +``` + +--- + +## 2. 配置 SSH 私钥与 `~/.ssh/config` + +```bash +mkdir -p ~/.ssh +chmod 700 ~/.ssh +# 私钥示例:~/.ssh/vps1.pem +chmod 600 ~/.ssh/vps1.pem +``` + +编辑 `~/.ssh/config`(示例别名 **`gate-vps`**,与你手工启动 `ssh -D ... gate-vps` 一致即可): + +```sshconfig +Host gate-vps + HostName 你的_VPS_IP + User root + IdentityFile ~/.ssh/vps1.pem + IdentitiesOnly yes + ServerAliveInterval 30 + ServerAliveCountMax 3 + ExitOnForwardFailure yes + BatchMode yes +``` + +测试: + +```bash +ssh gate-vps true +``` + +> 若尚未完全改为密钥登录,可暂时注释 `BatchMode yes`,调试完成后再打开. + +--- + +## 3. 手工验证:SSH SOCKS + Gate API + +### 3.1 本地 SOCKS(示例端口 1080) + +```bash +ssh -N -D 127.0.0.1:1080 gate-vps +``` + +保持运行,另开终端继续. + +### 3.2 验证经 SOCKS 可访问 Gate + +```bash +curl -4 -sS --max-time 15 --proxy socks5h://127.0.0.1:1080 https://api.gateio.ws/api/v4/spot/time +``` + +应返回 JSON(含服务器时间字段).若此处失败,**不要先启动应用**:先修隧道或 VPS 出站. + +--- + +## 4. Python 虚拟环境 + +```bash +cd /opt/crypto_monitor/crypto_monitor_gate + +python3 -m venv .venv +source .venv/bin/activate +python -m pip install -U pip +pip install flask requests ccxt werkzeug PySocks Pillow +``` + +走 SOCKS 时 **必须** 安装 **`PySocks`**,否则易出现代理相关报错. + +可选: + +```bash +export PYTHONDONTWRITEBYTECODE=1 +``` + +--- + +## 5. 配置环境变量(`.env.example` → `.env`) + +| 文件 | 是否进 Git | 说明 | +|------|------------|------| +| **`.env.example`** | ✅ 是 | 变量模板与注释,可随 `git pull` 更新 | +| **`.env`** | ❌ 否 | 本机真实配置;`app.py` **只读此文件** | + +### 5.1 首次配置 + +```bash +cd /opt/crypto_monitor/crypto_monitor_gate + +cp -n .env.example .env +nano .env +``` + +### 5.2 备份与 `git pull` + +- **`.env` 不在 Git 中**:`git pull` **不会**覆盖本地 `.env`. +- 远端若更新 **`.env.example`**,pull 后请**手动**把新增变量补进你的 `.env`. +- **升级前备份**:`cp .env .env.backup.$(date +%Y%m%d)`;恢复:`cp .env.backup.YYYYMMDD .env`. +- **换机**:`scp` 复制 `.env`,或新机 `cp .env.example .env` 后重填. + +### 5.3 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/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/crypto_monitor_gate +source .venv/bin/activate +python app.py +``` + +浏览器访问:`http://127.0.0.1:5000`(或你在 `.env` 中的端口). + +--- + +## 7. 安装 PM2 + +```bash +sudo npm i -g pm2 +pm2 -v +``` + +--- + +## 8. PM2:使用仓库内 `ecosystem.config.cjs`(推荐) + +在项目根目录: + +```bash +cd /opt/crypto_monitor/crypto_monitor_gate +pm2 start ecosystem.config.cjs +pm2 status +pm2 logs --lines 200 +``` + +默认只启动 **`crypto-monitor-gate`**(`.venv/bin/python app.py`). + +### 本机已可直连 Gate,不需要隧道时 + +`.env` 里应 **去掉或留空** `GATE_SOCKS_PROXY`(除非仍要走别的代理),再 `pm2 start ecosystem.config.cjs`. + +### 开机自启 + +```bash +pm2 save +pm2 startup +# 按屏幕提示执行一条 sudo 命令 +``` + +--- + +## 9. 等价手工命令(不使用 ecosystem 文件时) + +### 9.1 SSH SOCKS(自行后台常驻,不推荐用 PM2) + +示例(前台调试;生产请用 **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/crypto_monitor_gate +pm2 start /opt/crypto_monitor/crypto_monitor_gate/.venv/bin/python --name crypto-monitor-gate -- \ + /opt/crypto_monitor/crypto_monitor_gate/app.py +``` + +--- + +## 10. 交易所「连接不上」排查清单 + +1. **`.env` 是否为 Gate 变量**:必须是 `GATE_SOCKS_PROXY` / `GATE_API_KEY` / `GATE_API_SECRET`,不是 OKX. +2. **隧道是否在本机端口监听**(若配置了 `GATE_SOCKS_PROXY`): + ```bash + ss -lntp | grep 1080 || true + ``` +3. **curl 复测 Gate**(与第 3.2 节相同);curl 不通则应用也不会通. +4. **PySocks**:`pip show PySocks`,缺失则 `pip install PySocks`. +5. **SSH 隧道连不上**:检查私钥权限,`~/.ssh/config`,VPS 出站与端口是否与 `.env` 一致. +6. **启动顺序**:先保证 SOCKS 已监听,再 `pm2 start` 应用(或重启应用). + +--- + +## 11. 推荐启动顺序(习惯) + +1. 若走代理:先启动并确认 SSH SOCKS 已监听,再 `curl --proxy socks5h://127.0.0.1:1080 https://api.gateio.ws/api/v4/spot/time` 成功 +2. `pm2 start ecosystem.config.cjs` +3. 再确认页面与余额等接口正常 + +--- + +## 12. 免责声明 + +交易所有合规与地区政策要求.请确保使用方式符合当地法律法规与交易所条款.本文仅描述网络与工程部署路径. + +--- + +## 附录:数据库标签修复脚本 `scripts/fix_breakeven_labels.py` + +在 Ubuntu 上: + +1)预览(不写库): + +```bash +python scripts/fix_breakeven_labels.py --db ./crypto.db --dry-run +``` + +2)确认后执行: + +```bash +python scripts/fix_breakeven_labels.py --db ./crypto.db --apply +``` + +默认修复条件:`monitor_type='下单监控'` 且 `result='止损'` 且 `pnl_amount > 0` → 改为 `result='保本止盈'`. diff --git a/crypto_monitor_okx/.env.example b/crypto_monitor_okx/.env.example index 47d29e4..62e6fe5 100644 --- a/crypto_monitor_okx/.env.example +++ b/crypto_monitor_okx/.env.example @@ -1,107 +1,107 @@ # ============================================================================= -# 环境配置模板(可提交 Git)。程序运行时只读取同目录下的 .env。 +# 环境配置模板(可提交 Git).程序运行时只读取同目录下的 .env. # -# 首次部署 / 新机: +# 首次部署 / 新机: # cp .env.example .env -# nano .env # 填入真实密钥、端口、代理等 +# nano .env # 填入真实密钥,端口,代理等 # -# 升级代码(git pull)前建议备份(.env 不在 Git 中,pull 不会覆盖): +# 升级代码(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) +# 服务监听地址(云服务器通常用 0.0.0.0) APP_HOST=0.0.0.0 # 服务端口 APP_PORT=5004 -# 是否开启调试模式(生产建议 false) +# 是否开启调试模式(生产建议 false) APP_DEBUG=false # 登录账号 APP_USERNAME=admin -# 登录密码(请改成你自己的强密码) +# 登录密码(请改成你自己的强密码) APP_PASSWORD=admin123 -# 是否关闭登录校验(局域网可设 true;公网务必 false) +# 是否关闭登录校验(局域网可设 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 +# 中控请求本实例 /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 域名一致;默认已开启) +# 允许复盘中控 iframe 内嵌本实例(与 hub 域名一致;默认已开启) # APP_ALLOW_HUB_EMBED=true # HUB_EMBED_PARENT_ORIGINS=https://hub.example.com -# HTTPS 且经 iframe 打开时建议 true;不设则 hub-sso 在 HTTPS 下也会自动尝试 SameSite=None +# HTTPS 且经 iframe 打开时建议 true;不设则 hub-sso 在 HTTPS 下也会自动尝试 SameSite=None # APP_COOKIE_SECURE=true -# Flask 会话密钥(必须替换为长随机字符串) +# Flask 会话密钥(必须替换为长随机字符串) FLASK_SECRET_KEY=CHANGE_TO_LONG_RANDOM_SECRET -# 企业微信机器人 Webhook(用于行情/风控推送) +# 企业微信机器人 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,可选;默认即可) +# 自动备份(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 全仓杠杆(须无仓后重启) +# 训练总资金(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(或 多/空/双向) +# 方向限制(默认 false=双向均可;true 时按 TRADE_DIRECTION 限制,修改后须重启) +# TRADE_DIRECTION=long_only | short_only | both(或 多/空/双向) TRADE_DIRECTION_RESTRICT_ENABLED=false TRADE_DIRECTION=both -# 币种白名单(默认 false=全币种可手输;true 时关键位/下单/策略仅下拉选择) +# 币种白名单(默认 false=全币种可手输;true 时关键位/下单/策略仅下拉选择) TRADE_SYMBOL_RESTRICT_ENABLED=false TRADE_SYMBOL_WHITELIST=BTC,ETH -# 每天起始基数(U) +# 每天起始基数(U) DAILY_START_CAPITAL=30 -# 日内回撤后基数(U) +# 日内回撤后基数(U) DAILY_LOSS_CAPITAL=20 -# 日内盈利后基数(U) +# 日内盈利后基数(U) DAILY_PROFIT_CAPITAL=50 # BTC 默认杠杆倍数 BTC_LEVERAGE=10 # 山寨币默认杠杆倍数 ALT_LEVERAGE=5 -# 交易日重置小时(北京时间) +# 交易日重置小时(北京时间) TRADING_DAY_RESET_HOUR=8 -# 整点前禁止新开仓:true=启用(默认),false=关闭(仍可保留 8 点作为交易日划分) +# 整点前禁止新开仓:true=启用(默认),false=关闭(仍可保留 8 点作为交易日划分) TRADING_DAY_RESET_OPEN_GUARD_ENABLED=true -# 是否开启 OKX 实盘下单(false=只做本地流程,true=真实下单) +# 是否开启 OKX 实盘下单(false=只做本地流程,true=真实下单) LIVE_TRADING_ENABLED=true -# OKX API Key(实盘) +# OKX API Key(实盘) OKX_API_KEY=REPLACE_WITH_OKX_API_KEY -# OKX API Secret(实盘) +# OKX API Secret(实盘) OKX_API_SECRET=REPLACE_WITH_OKX_API_SECRET -# OKX API Passphrase(实盘) +# OKX API Passphrase(实盘) OKX_API_PASSPHRASE=REPLACE_WITH_OKX_API_PASSPHRASE -# 保证金模式:cross=全仓,isolated=逐仓 +# 保证金模式:cross=全仓,isolated=逐仓 OKX_TD_MODE=cross -# 持仓模式:hedge=双向持仓,net=单向净持仓 +# 持仓模式:hedge=双向持仓,net=单向净持仓 OKX_POS_MODE=hedge -# 仓位查询 instType(OKX) +# 仓位查询 instType(OKX) OKX_POSITION_INST_TYPE=SWAP -# 从 OKX 历史仓位同步已实现盈亏(北京时间起点,空=近 90 天 0 点起) +# 从 OKX 历史仓位同步已实现盈亏(北京时间起点,空=近 90 天 0 点起) # EXCHANGE_POSITION_SYNC_FROM_BJ=2026-01-01 -# 单次拉取历史仓位条数上限(OKX 每页最多 100,程序会分页) +# 单次拉取历史仓位条数上限(OKX 每页最多 100,程序会分页) # EXCHANGE_POSITION_HISTORY_LIMIT=200 -# 页面与浏览器标签展示的交易所名称(多环境区分时可改成例如 OKX·测试网) +# 页面与浏览器标签展示的交易所名称(多环境区分时可改成例如 OKX·测试网) # EXCHANGE_DISPLAY_NAME=OKX # 企业微信推送里展示的账户备注 # OKX_ACCOUNT_LABEL= # ============================================================================= -# 期权(主账户 API,与永续子账户 OKX_API_* 分离;修改后须重启 PM2) +# 期权(主账户 API,与永续子账户 OKX_API_* 分离;修改后须重启 PM2) # 详见 docs/期权方案.md 与 docs/期权用法.md # ============================================================================= OKX_OPTIONS_ENABLED=false @@ -122,66 +122,66 @@ OKX_OPTIONS_TD_MODE=isolated OKX_OPTIONS_ALLOW_MARKET_CLOSE=false # ============================================================================= -# 关键位程序自动下单(与 POSITION_SIZING_MODE 联动,修改后须重启 PM2) +# 关键位程序自动下单(与 POSITION_SIZING_MODE 联动,修改后须重启 PM2) # ============================================================================= -# 默认 false = 关闭所有关键位程序自动单(箱体/收敛/斐波/假突破/触价) +# 默认 false = 关闭所有关键位程序自动单(箱体/收敛/斐波/假突破/触价) # -# POSITION_SIZING_MODE=risk(以损定仓) -# false → 不执行任何关键位自动单;支撑/阻力提醒、人工下单、顺势加仓不受影响 -# true → 允许关键位全套自动(含触价) +# POSITION_SIZING_MODE=risk(以损定仓) +# false → 不执行任何关键位自动单;支撑/阻力提醒,人工下单,顺势加仓不受影响 +# true → 允许关键位全套自动(含触价) # -# POSITION_SIZING_MODE=full_margin(全仓杠杆,须无仓切换) +# POSITION_SIZING_MODE=full_margin(全仓杠杆,须无仓切换) # false → 不执行触价自动单 -# true → 仅回调/突破触价可程序自动开仓;箱体/斐波等仍禁止 +# true → 仅回调/突破触价可程序自动开仓;箱体/斐波等仍禁止 # -# 顺势加仓、趋势回调不受本开关控制;全仓模式下策略自动仍禁止。 +# 顺势加仓,趋势回调不受本开关控制;全仓模式下策略自动仍禁止. KEY_AUTO_ORDER_ENABLED=false # ============================================================================= -# 关键位门控(页面「关键位监控」规则条与 _key_hard_checks 共用) +# 关键位门控(页面「关键位监控」规则条与 _key_hard_checks 共用) # ============================================================================= -# 【周期】门控 K 线周期,如 5m、15m;仅影响关键位硬条件,不改变顶栏分区 +# 【周期】门控 K 线周期,如 5m,15m;仅影响关键位硬条件,不改变顶栏分区 KLINE_TIMEFRAME=5m -# OKX 遗留:突破过滤百分比(与 KEY_BREAKOUT_AMP_* 并存,程序仍读取) +# OKX 遗留:突破过滤百分比(与 KEY_BREAKOUT_AMP_* 并存,程序仍读取) KEY_BREAKOUT_LIMIT_PCT=1.5 -# 【确认K】闭合 K 序列中的棒偏移:突破棒默认 -2(倒数第2根),确认棒默认 -1(倒数第1根) +# 【确认K】闭合 K 序列中的棒偏移:突破棒默认 -2(倒数第2根),确认棒默认 -1(倒数第1根) KEY_CONFIRM_BREAKOUT_BAR=-2 KEY_CONFIRM_BAR=-1 -# 【量能】突破棒成交量 > 前 N 根均量 × 倍数(默认 N=20,倍数=1.3 即放大 30%) +# 【量能】突破棒成交量 > 前 N 根均量 × 倍数(默认 N=20,倍数=1.3 即放大 30%) KEY_VOLUME_MA_BARS=20 KEY_VOLUME_RATIO_MIN=1.3 -# 【箱体/收敛】突破K收盘越过关键位(占该侧价格%)的下限;无上限(过猛由计划RR过滤) +# 【箱体/收敛】突破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 名(添加关键位与运行时门控均校验) +# 【日成交量排名】品种须在该排名前 N 名(添加关键位与运行时门控均校验) KEY_DAILY_VOLUME_RANK_MAX=30 -# 【关键位自动开仓盈亏比】按确认K收盘 E 计算,严格大于该值才市价开仓(如 1.5 表示须 >1.5:1) +# 【关键位自动开仓盈亏比】按确认K收盘 E 计算,严格大于该值才市价开仓(如 1.5 表示须 >1.5:1) KEY_AUTO_MIN_PLANNED_RR=1.5 -# 止损:突破 K 极值向外缓冲的百分比(默认 0.5 即 0.5%) +# 止损:突破 K 极值向外缓冲的百分比(默认 0.5 即 0.5%) KEY_STOP_OUTSIDE_BREAKOUT_PCT=0.5 -# 趋势单方案:止损在突破 K 极值外侧的百分比(默认 1 即 1%) +# 趋势单方案:止损在突破 K 极值外侧的百分比(默认 1 即 1%) KEY_TREND_STOP_OUTSIDE_PCT=1 # ============================================================================= -# 交易执行 / 人工风控(页面「实盘下单」) +# 交易执行 / 人工风控(页面「实盘下单」) # ============================================================================= -# 【最大同时持仓】active 订单数达到该值后禁止人工与关键位自动再加仓(默认 1=单仓) +# 【最大同时持仓】active 订单数达到该值后禁止人工与关键位自动再加仓(默认 1=单仓) MAX_ACTIVE_POSITIONS=1 -# 【人工下单最低盈亏比】按当前价与 SL/TP 计算,低于该值前后端均拒绝(默认 1.4,即须 >=1.4:1) +# 【人工下单最低盈亏比】按当前价与 SL/TP 计算,低于该值前后端均拒绝(默认 1.4,即须 >=1.4:1) MANUAL_MIN_PLANNED_RR=1.4 # 【关键位连开计仓】true=已有持仓时关键位自动单仍按「无仓时」资金快照算保证金基数 KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT=true -# 【单日开仓 AI 提醒】本交易日开仓达到该次数时推送企业微信 AI 克制提醒(不拦单) +# 【单日开仓 AI 提醒】本交易日开仓达到该次数时推送企业微信 AI 克制提醒(不拦单) DAILY_OPEN_ALERT_THRESHOLD=5 -# 【单日开仓硬上限】本交易日开仓次数>=该值后禁止一切新开仓直至下一交易日(北京时间 TRADING_DAY_RESET_HOUR 切日);0=不启用 +# 【单日开仓硬上限】本交易日开仓次数>=该值后禁止一切新开仓直至下一交易日(北京时间 TRADING_DAY_RESET_HOUR 切日);0=不启用 DAILY_OPEN_HARD_LIMIT=0 # ============================================================================= -# 账户冷静期 / 日冻结风控(手动平仓、外部平仓、复盘情绪标签) +# 账户冷静期 / 日冻结风控(手动平仓,外部平仓,复盘情绪标签) # 详见 docs/account-risk-cooldown.md # ============================================================================= RISK_CONTROL_ENABLED=true @@ -190,42 +190,42 @@ 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 未就绪误判) +# 重启后多少秒内不做「外部平仓」同步(避免 API 未就绪误判) RECONCILE_STARTUP_GRACE_SEC=90 -# 连续多少次轮询确认交易所空仓后,才记为外部平仓(默认 3 次 ≈ 9 秒) +# 连续多少次轮询确认交易所空仓后,才记为外部平仓(默认 3 次 ≈ 9 秒) RECONCILE_FLAT_CONFIRM_POLLS=3 -# 使用可用资金时的缓冲比例(如0.98代表用98%) +# 使用可用资金时的缓冲比例(如0.98代表用98%) FULL_MARGIN_BUFFER_RATIO=0.98 # ============================================================================= -# 自动划转(页顶「将 swap 补足到 XU」;与 DAILY_START_CAPITAL 独立,需一致时请设为相同值) +# 自动划转(页顶「将 swap 补足到 XU」;与 DAILY_START_CAPITAL 独立,需一致时请设为相同值) # ============================================================================= AUTO_TRANSFER_ENABLED=false -# 交易账户(swap)目标余额 U:每日 8 点(北京)自动划入或划出至 funding;持仓中不划转 +# 交易账户(swap)目标余额 U:每日 8 点(北京)自动划入或划出至 funding;持仓中不划转 AUTO_TRANSFER_AMOUNT=30 AUTO_TRANSFER_FROM=funding AUTO_TRANSFER_TO=swap TRANSFER_CCY=USDT -# 北京时间该整点小时内尝试;账簿按 UTC 自然日去重 +# 北京时间该整点小时内尝试;账簿按 UTC 自然日去重 AUTO_TRANSFER_BJ_HOUR=8 -# 强制清仓整点(北京时间,默认 0=凌晨00点) +# 强制清仓整点(北京时间,默认 0=凌晨00点) FORCE_CLOSE_BJ_HOUR=0 -# 是否启用强制清仓(默认关闭,true 才会在整点执行) +# 是否启用强制清仓(默认关闭,true 才会在整点执行) FORCE_CLOSE_ENABLED=false -# 推送与AI超时(秒) +# 推送与AI超时(秒) WECHAT_TIMEOUT_SECONDS=10 AI_TIMEOUT_SECONDS=120 -# AI 复盘服务地址(本机 Ollama 默认地址) +# AI 复盘服务地址(本机 Ollama 默认地址) AI_PROVIDER=openai OPENAI_API_BASE=https://op.bz121.com/v1 OPENAI_API_KEY=你的密钥 @@ -233,31 +233,31 @@ 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) 先在本机建立隧道(示例): +# OKX 代理(可选,仅本地开发网络受限时用;云服务器部署请留空,直连 OKX 即可) +# 1) 先在本机建立隧道(示例): # ssh -N -D 127.0.0.1:1080 root@你的VPS_IP -o ServerAliveInterval=30 -o ExitOnForwardFailure=yes -# 2) 再启用下面这一行(推荐 socks5h,让远端解析域名): +# 2) 再启用下面这一行(推荐 socks5h,让远端解析域名): # OKX_SOCKS_PROXY=socks5h://127.0.0.1:1080 # -# 如你更偏向 HTTP 代理(VPS 上跑 tinyproxy 之类),可用: +# 如你更偏向 HTTP 代理(VPS 上跑 tinyproxy 之类),可用: # OKX_HTTP_PROXY=http://127.0.0.1:3128 # OKX_HTTPS_PROXY=http://127.0.0.1:3128 -# 开仓多周期K线图(可选) +# 开仓多周期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 -# 以损定仓(按交易账户资金的百分比) +# 详见上文 DAILY_OPEN_ALERT_THRESHOLD / DAILY_OPEN_HARD_LIMIT;说明文档 docs/daily-open-limit.md +# 以损定仓(按交易账户资金的百分比) # RISK_PERCENT=2 -# 移动保本触发(达到多少R触发)与偏移(百分比) +# 移动保本触发(达到多少R触发)与偏移(百分比) # BREAKEVEN_RR_TRIGGER=1.0 -# 移动保本阶梯(每多少R继续上移一次,默认1R) +# 移动保本阶梯(每多少R继续上移一次,默认1R) # BREAKEVEN_STEP_R=1.0 # BREAKEVEN_OFFSET_PCT=0.02 -# 开单风格默认值:trend / swing +# 开单风格默认值:trend / swing # DEFAULT_TRADE_STYLE=trend APP_TIMEZONE=Asia/Shanghai -# TRADING_DAY_RESET_HOUR 现在表示「北京时间」整点,默认 8 点起算新交易日;开仓整点限制见 TRADING_DAY_RESET_OPEN_GUARD_ENABLED +# 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 index 87a1bc1..1c72eff 100644 --- a/crypto_monitor_okx/README.md +++ b/crypto_monitor_okx/README.md @@ -1,32 +1,32 @@ # crypto_monitor_okx -基于 **Flask** 的加密货币 **下单监控 / 关键位监控 / 交易复盘** 小系统,行情与实盘接口统一走 **OKX(USDT 永续)**,通过 **ccxt** 访问。功能与界面已与 **`crypto_monitor_binance`** 对齐(顶栏分栏、风控参数、交易所 TP/SL 管理等),差异主要在 **`.env` 的 `OKX_*` 变量** 与 OKX API(含 Passphrase)。 +基于 **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`) +- **关键位监控**:`/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` +- 依赖见仓库根 `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_TD_MODE` / `OKX_POS_MODE` | 全仓/逐仓,单向/双向 | | `OKX_SOCKS_PROXY` | 本机 SSH 动态转发时常用 | | `MAX_ACTIVE_POSITIONS` / `MANUAL_MIN_PLANNED_RR` | 与币安版一致的风控 | -| `EXCHANGE_DISPLAY_NAME` | 页面展示名,默认 `OKX` | +| `EXCHANGE_DISPLAY_NAME` | 页面展示名,默认 `OKX` | -完整模板见 **`.env.example`**。 +完整模板见 **`.env.example`**. ## 运行 @@ -36,11 +36,11 @@ source .venv/bin/activate python app.py ``` -生产使用 **PM2**;见 [docs/ubuntu-server.md](../docs/ubuntu-server.md)。默认 **`APP_PORT`** 常为 `5004`。 +生产使用 **PM2**;见 [docs/ubuntu-server.md](../docs/ubuntu-server.md).默认 **`APP_PORT`** 常为 `5004`. ## 部署 -详见 **[部署文档.md](./部署文档.md)**、**[使用说明.md](./使用说明.md)**。 +详见 **[部署文档.md](./部署文档.md)**,**[使用说明.md](./使用说明.md)**. ## 自检 @@ -50,4 +50,4 @@ python scripts/verify_okx_funding.py ## 风险与合规 -实盘风险自负;请确认 API 权限、IP 白名单与 OKX 账户设置一致。 +实盘风险自负;请确认 API 权限,IP 白名单与 OKX 账户设置一致. diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index efb3db3..f1ccb86 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -312,14 +312,14 @@ 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 覆盖) +# 训练参数(可由 .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) +# 交易日滚动与「可开仓」整点:按应用本地时区 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" @@ -363,7 +363,7 @@ 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 日界一致便于对账) +# 自动划转:仅在北京时间该整点「小时」内尝试;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() @@ -409,7 +409,7 @@ 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_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")) @@ -438,14 +438,14 @@ OKX_HTTPS_PROXY = (os.getenv("OKX_HTTPS_PROXY") or "").strip() def build_okx_ccxt_proxies(): """ - 为 ccxt 配置代理(常用于:本地网络对 OKX TLS/SNI 不稳定,通过 SSH 动态转发 SOCKS5 出口)。 + 为 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 + 推荐: + - 本机:ssh -N -D 127.0.0.1:1080 user@vps + - .env:OKX_SOCKS_PROXY=socks5h://127.0.0.1:1080 - 说明: - - socks5h 让代理端解析域名(避免本机 DNS/策略差异);若你明确要本机解析可用 socks5:// + 说明: + - socks5h 让代理端解析域名(避免本机 DNS/策略差异);若你明确要本机解析可用 socks5:// """ socks = OKX_SOCKS_PROXY.strip() http = OKX_HTTP_PROXY.strip() @@ -512,7 +512,7 @@ _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 @@ -530,7 +530,7 @@ def _wechat_account_label(): def _wechat_direction_text(direction): d = (direction or "").lower() - return "多头(long)" if d == "long" else "空头(short)" + return "多头(long)" if d == "long" else "空头(short)" def _wechat_trading_capital_text(fallback=None): @@ -549,7 +549,7 @@ def _wechat_trading_capital_text(fallback=None): def format_wechat_scalar_2dp(value): - """企业微信推送:数值统一两位小数(与交易所 tick 无关)。""" + """企业微信推送:数值统一两位小数(与交易所 tick 无关).""" if value in (None, ""): return "-" try: @@ -589,21 +589,21 @@ def build_wechat_close_message( lines = [ f"📉 {symbol} 平仓完成", - f"💼 账户:{_wechat_account_label()}", + 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"🔖 平仓单号:{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}", + "🎯 价位(计划)", + f"开仓成交价:{ep}", + f"离场参考价:{cp}", + f"止盈价位:{tp}", + f"止损价位:{sl}", ] if extra_note: lines.extend(["", "📎 备注", extra_note]) @@ -614,16 +614,16 @@ def build_wechat_breakeven_message(symbol, direction, arm_txt, now_rr, locked_r, return "\n".join( [ f"# 🛡️ {symbol} 保护位更新", - f"**账户:{_wechat_account_label()}**", + 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)}`", + 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)}`", ] ) @@ -632,14 +632,14 @@ def build_wechat_monitor_error_message(symbol, direction, scene, error_text): return "\n".join( [ f"# ⚠️ {symbol} 下单监控异常", - f"**账户:{_wechat_account_label()}**", + f"**账户:{_wechat_account_label()}**", "", "---", "", "### 异常信息", - f"- 方向:**{_wechat_direction_text(direction)}**", - f"- 场景:{scene}", - f"- 错误:{str(error_text)}", + f"- 方向:**{_wechat_direction_text(direction)}**", + f"- 场景:{scene}", + f"- 错误:{str(error_text)}", ] ) @@ -660,22 +660,22 @@ def build_wechat_key_monitor_message( ): lines = [ f"# 🎯 {symbol} 关键位确认推送", - f"**账户:{_wechat_account_label()}**", + f"**账户:{_wechat_account_label()}**", "", "---", "", "### 交易对 / 触发时间", - f"- 交易对:**{symbol}**", - f"- 触发时间:`{trigger_time}`", + f"- 交易对:**{symbol}**", + f"- 触发时间:`{trigger_time}`", "", "### 方向与确认K", - f"- 方向:**{_wechat_direction_text(direction)}**", - "- 确认K:第二根5m收盘完成", + f"- 方向:**{_wechat_direction_text(direction)}**", + "- 确认K:第二根5m收盘完成", "", "### 关键价位", - f"- 类型:**{monitor_type}**", - f"- 箱体关键位:`{key_price}`", - f"- 第二根确认收盘价:`{confirm_close}`", + f"- 类型:**{monitor_type}**", + f"- 箱体关键位:`{key_price}`", + f"- 第二根确认收盘价:`{confirm_close}`", "", "### 硬条件校验结果", ] @@ -684,9 +684,9 @@ def build_wechat_key_monitor_message( [ "", "### 市场状态说明", - f"- BTC 8h 状态:**{btc8h_status}**", - f"- 本币 4h(EMA55) 状态:**{coin4h_status}**", - f"- 4h震荡幅度(5m近48根):`{round(float(swing4h_pct), 3)}%`", + f"- BTC 8h 状态:**{btc8h_status}**", + f"- 本币 4h(EMA55) 状态:**{coin4h_status}**", + f"- 4h震荡幅度(5m近48根):`{round(float(swing4h_pct), 3)}%`", "", "### 操作提示", ] @@ -806,7 +806,7 @@ def _pick_marker_point(rows, target_ts_ms, target_price=None): 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)") + raise RuntimeError("缺少依赖:Pillow(pip install Pillow)") img = Image.new("RGB", (width, height), bg_rgb) draw = ImageDraw.Draw(img) font = _load_font(14) @@ -911,7 +911,7 @@ def _render_candles_subplot(rows, title, width, height, bg_rgb=(255, 255, 255), 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 @@ -1134,7 +1134,7 @@ EARLY_EXIT_TRIGGERS = ( "其他", ) -# 趋势户:大分歧A/B/小分歧 + 策略(关键位本实例关闭) +# 趋势户:大分歧A/B/小分歧 + 策略(关键位本实例关闭) ENTRY_REASON_OPTIONS = build_trend_div_entry_reason_options(STRATEGY_ENTRY_REASON_OPTIONS) STATS_SEGMENT_DEFS = ( @@ -1168,7 +1168,7 @@ def compose_early_exit_reason_saved(trigger, note): def journal_exit_reason_stored(trigger, note): - """exit_reason 列与表单「一处」对齐:非手工=触发类型;手工=离场说明全文。""" + """exit_reason 列与表单「一处」对齐:非手工=触发类型;手工=离场说明全文.""" t = normalize_early_exit_trigger(trigger) n = str(note or "").strip() if t == "手动平仓": @@ -1176,7 +1176,7 @@ def journal_exit_reason_stored(trigger, note): return t -# 初始化数据库(支持多空方向) +# 初始化数据库(支持多空方向) def init_db(): conn = sqlite3.connect(DB_PATH) c = conn.cursor() @@ -1190,7 +1190,7 @@ def init_db(): breakout_limit_pct REAL DEFAULT 1.5, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') - # 订单监控(核心:加 direction 方向字段) + # 订单监控(核心:加 direction 方向字段) c.execute('''CREATE TABLE IF NOT EXISTS order_monitors (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, direction TEXT DEFAULT "long", exchange_symbol TEXT, @@ -1205,7 +1205,7 @@ def init_db(): 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, @@ -1249,7 +1249,7 @@ def init_db(): ON transfer_logs(transfer_type, transfer_day) WHERE transfer_type = 'auto_daily' ''') - # 给旧表加 direction 字段(兼容老数据,不报错) + # 给旧表加 direction 字段(兼容老数据,不报错) try: c.execute("ALTER TABLE order_monitors ADD COLUMN direction TEXT DEFAULT 'long'") except: pass @@ -1583,7 +1583,7 @@ def hub_user_initiated_close( def app_now(): - """应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较)。""" + """应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较).""" return datetime.now(APP_TZ).replace(tzinfo=None) @@ -1592,17 +1592,17 @@ def app_now_str(): def utc_now_dt(): - """当前时刻(UTC,aware)。""" + """当前时刻(UTC,aware).""" return datetime.now(timezone.utc) def utc_calendar_date_str(): - """UTC 自然日 YYYY-MM-DD(用于自动划转去重等与交易所日界对齐的计算)。""" + """UTC 自然日 YYYY-MM-DD(用于自动划转去重等与交易所日界对齐的计算).""" return utc_now_dt().strftime("%Y-%m-%d") def get_trading_day(now=None): - """交易日字符串:本地时钟下若小时 < TRADING_DAY_RESET_HOUR 则归属「上一日历日」。""" + """交易日字符串:本地时钟下若小时 < TRADING_DAY_RESET_HOUR 则归属「上一日历日」.""" now = now or app_now() if getattr(now, "tzinfo", None): now = now.astimezone(APP_TZ).replace(tzinfo=None) @@ -1833,7 +1833,7 @@ def _compute_period_metrics(trades): def compute_stats_bundle(conn, trading_day, now_dt=None): - """日 / 周 / 月 统计:平仓按北京时间交易日(默认 8:00 切日)计入。""" + """日 / 周 / 月 统计:平仓按北京时间交易日(默认 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] @@ -1851,9 +1851,9 @@ def compute_stats_bundle(conn, trading_day, now_dt=None): 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}(北京自然月)" + 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 = [] @@ -2106,7 +2106,7 @@ def format_price_for_symbol(symbol, value): if v == 0: return "0" av = abs(v) - # 根据币价量级动态精度:低价币保留更多小数,高价币减少噪音位数 + # 根据币价量级动态精度:低价币保留更多小数,高价币减少噪音位数 if av >= 10000: d = 2 elif av >= 100: @@ -2165,8 +2165,8 @@ def calc_pnl(direction, trigger_price, exit_price, margin_capital, leverage): def calc_rr_ratio(direction, entry_price, stop_loss, take_profit): """ - 计划盈亏比 = 盈利空间 / 亏损空间(展示为 X:1,即 reward:risk)。 - 做多:止损须低于入场、止盈须高于入场;做空相反。 + 计划盈亏比 = 盈利空间 / 亏损空间(展示为 X:1,即 reward:risk). + 做多:止损须低于入场,止盈须高于入场;做空相反. """ try: entry = float(entry_price) @@ -2188,7 +2188,7 @@ def calc_rr_ratio(direction, entry_price, stop_loss, take_profit): def active_sl_tp_for_rr(stop_loss, initial_stop_loss, take_profit): - """展示/校验用:优先当前 stop_loss(委托改价后),否则回落 initial_stop_loss。""" + """展示/校验用:优先当前 stop_loss(委托改价后),否则回落 initial_stop_loss.""" sl = stop_loss if stop_loss not in (None, "") else initial_stop_loss return sl, take_profit @@ -2240,7 +2240,7 @@ def calc_actual_rr(pnl_amount, risk_amount): def calc_breakeven_stop(direction, entry_price, risk_fraction, locked_r, offset_pct): """ - 按“已锁定R”计算目标止损位: + 按“已锁定R”计算目标止损位: - long: entry + locked_r * (entry*risk_fraction) + offset - short: entry - locked_r * (entry*risk_fraction) - offset """ @@ -2387,7 +2387,7 @@ def enrich_order_item(raw_item, current_capital): def ensure_okx_live_ready(): if not LIVE_TRADING_ENABLED: - return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)" + 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, "" @@ -2512,10 +2512,10 @@ def get_synced_leverage(exchange_symbol, direction): 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到交易账户。" + 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}" + return f"交易所下单失败:{clean}" friendly_exchange_error = friendly_okx_error @@ -2581,7 +2581,7 @@ def auto_transfer_once_per_day(): def get_trading_day_reset_open_guard_enabled(conn=None): - """True=启用整点限制(默认 8:00 前禁止新开仓/登记监控)。""" + """True=启用整点限制(默认 8:00 前禁止新开仓/登记监控).""" owns = conn is None if owns: conn = get_db() @@ -2641,7 +2641,7 @@ def precheck_risk(conn, symbol, direction): reached, active_count, mx = position_limit_reached(conn, max_active_positions=MAX_ACTIVE_POSITIONS) if reached: - return False, f"已达最大持仓数({active_count}/{mx})" + 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 ) @@ -2668,16 +2668,16 @@ def prepare_order_amount(exchange_symbol, margin_capital, leverage, fallback_pri market = exchange.market(exchange_symbol) contract_size = float(market.get("contractSize") or 1) if market.get("contract"): - # OKX 永续 amount 是“张数”,需要按合约面值换算 + # 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}") + raise ValueError(f"下单数量过小,最小数量为 {min_amount}") amount_precise = float(exchange.amount_to_precision(exchange_symbol, amount)) if amount_precise <= 0: - raise ValueError("下单数量精度后为 0,请提高基数或降低价格") + raise ValueError("下单数量精度后为 0,请提高基数或降低价格") return amount_precise, price @@ -2760,7 +2760,7 @@ def ensure_markets_loaded(force=False): def _okx_algo_trigger_price_str(exchange_symbol, price): - """OKX attachAlgoOrds 触发价须为按合约 tick 格式化的十进制字符串;直接用 str(float) 低价币会得到科学计数法(如 8.5e-06),会报 tpTriggerPx/slTriggerPx 参数错误。""" + """OKX attachAlgoOrds 触发价须为按合约 tick 格式化的十进制字符串;直接用 str(float) 低价币会得到科学计数法(如 8.5e-06),会报 tpTriggerPx/slTriggerPx 参数错误.""" ensure_markets_loaded() return exchange.price_to_precision(exchange_symbol, float(price)) @@ -2783,13 +2783,13 @@ def place_exchange_order(exchange_symbol, direction, amount, leverage, stop_loss return order except Exception as e: if stop_loss and take_profit: - raise RuntimeError(f"交易所未接受止盈止损挂单参数,已拒绝开仓:{str(e)}") + raise RuntimeError(f"交易所未接受止盈止损挂单参数,已拒绝开仓:{str(e)}") raise def close_exchange_order(order_row): """ - 市价全平。数量优先取交易所当前持仓张数,避免仅用入库 order_amount 导致平不干净。 + 市价全平.数量优先取交易所当前持仓张数,避免仅用入库 order_amount 导致平不干净. """ ensure_markets_loaded() exchange_symbol = order_row["exchange_symbol"] or normalize_okx_symbol(order_row["symbol"]) @@ -2806,7 +2806,7 @@ def close_exchange_order(order_row): if raw_amt <= 0: if last_resp is not None: return last_resp - raise ValueError("平仓失败:缺少有效下单数量") + raise ValueError("平仓失败:缺少有效下单数量") try: amount = float(exchange.amount_to_precision(exchange_symbol, raw_amt)) except Exception: @@ -2814,7 +2814,7 @@ def close_exchange_order(order_row): if amount <= 0: if last_resp is not None: return last_resp - raise ValueError("平仓失败:数量经精度舍入后为 0") + 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) @@ -2836,14 +2836,14 @@ def cancel_okx_swap_open_orders(exchange_symbol): def _okx_place_tp_sl_orders(exchange_symbol, direction, amount, stop_loss, take_profit): """ - 为已有持仓挂条件止盈/止损(一笔 OCO 算法单)。 - 勿带 reduceOnly,勿分两笔 reduce-only 市价单,否则 OKX/ccxt 可能当成立即全平。 + 为已有持仓挂条件止盈/止损(一笔 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") + 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) @@ -2866,7 +2866,7 @@ def _okx_place_tp_sl_orders(exchange_symbol, direction, amount, stop_loss, take_ last_err = e cancel_okx_swap_open_orders(exchange_symbol) time.sleep(0.2 * (attempt + 1)) - raise RuntimeError(f"OKX 未接受止盈/止损条件单:{last_err}") + raise RuntimeError(f"OKX 未接受止盈/止损条件单:{last_err}") @@ -2875,7 +2875,7 @@ def exchange_private_api_configured(): def _position_row_effective_contracts(p): - """张数:OKX 以 info.pos 为准,再兜底 ccxt contracts 等(与 Binance/Gate 多字段一致)。""" + """张数:OKX 以 info.pos 为准,再兜底 ccxt contracts 等(与 Binance/Gate 多字段一致).""" from lib.hub.hub_position_metrics import normalize_contracts_qty if not p: @@ -2940,7 +2940,7 @@ def _okx_position_direction(position): def _fetch_okx_swap_position_rows(): - """OKX 单合约 fetch_positions([sym]) 常返回空;与 /api/prices 一致拉全量 SWAP 再本地匹配。""" + """OKX 单合约 fetch_positions([sym]) 常返回空;与 /api/prices 一致拉全量 SWAP 再本地匹配.""" ensure_markets_loaded() rows = None for fetcher in ( @@ -3061,7 +3061,7 @@ def _okx_tpsl_slot_build(exchange_symbol, order_id, trigger_price, order_type="" def _okx_tpsl_slots_from_order(order, exchange_symbol): - """从单笔 OKX 订单解析 SL/TP(算法单常同时带 slTriggerPx 与 tpTriggerPx)。""" + """从单笔 OKX 订单解析 SL/TP(算法单常同时带 slTriggerPx 与 tpTriggerPx).""" if not isinstance(order, dict): return None, None info = order.get("info") or {} @@ -3168,7 +3168,7 @@ def cancel_okx_tpsl_slot(exchange_symbol, slot): def replace_active_monitor_tpsl_on_exchange(order_row, stop_loss, take_profit): - """先撤该合约挂单/条件单,再按新价重挂 TP/SL。""" + """先撤该合约挂单/条件单,再按新价重挂 TP/SL.""" ok, reason = ensure_okx_live_ready() if not ok: raise RuntimeError(reason or "实盘未就绪") @@ -3184,25 +3184,25 @@ def replace_active_monitor_tpsl_on_exchange(order_row, stop_loss, take_profit): except (TypeError, ValueError): pos_amt = 0 if float(pos_amt or 0) <= 0: - raise ValueError("交易所当前无该方向持仓,无法挂止盈止损") + 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 永续:仅挂止损(趋势回调),止盈由程序监控。 + """OKX 永续:仅挂止损(趋势回调),止盈由程序监控. - 须用 stopLossPrice 挂条件单;勿用 reduce-only 市价单 + params['stopLoss'], - 后者会当成立即市价平仓(开仓后约 1 秒内全平)。 + 须用 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("交易所当前无持仓,无法挂止损") + 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") + raise RuntimeError("止损:可平数量经精度舍入后为 0") base = build_okx_order_params(direction, reduce_only=True) sl_px = float(stop_loss) last_err = None @@ -3221,7 +3221,7 @@ def _okx_place_stop_loss_only(exchange_symbol, direction, stop_loss): last_err = e cancel_okx_swap_open_orders(exchange_symbol) time.sleep(0.2 * (attempt + 1)) - raise RuntimeError(f"OKX 未接受止损条件单:{last_err}") + raise RuntimeError(f"OKX 未接受止损条件单:{last_err}") def calc_trend_manual_breakeven_stop(direction, entry_price, offset_pct=None): @@ -3286,7 +3286,7 @@ def get_live_position_contracts(exchange_symbol, direction): 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() @@ -3332,7 +3332,7 @@ def ms_to_app_local_str(ms): def classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_price): - """根据成交价相对止盈/止损位归类;无法可靠归类时返回 None。""" + """根据成交价相对止盈/止损位归类;无法可靠归类时返回 None.""" try: tp = float(take_profit) sl = float(stop_loss) @@ -3355,7 +3355,7 @@ def classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, ex def fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=None): - """取开仓以来最近一笔减仓成交(与方向一致);失败返回 None。""" + """取开仓以来最近一笔减仓成交(与方向一致);失败返回 None.""" if not (OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE): return None ensure_markets_loaded() @@ -3399,8 +3399,8 @@ def fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_ 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 回填)。 - 返回按时间排序的成交列表。 + 拉取某条历史记录对应的减仓成交(用于按 id 回填). + 返回按时间排序的成交列表. """ if not (OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE): return [] @@ -3408,7 +3408,7 @@ def fetch_closing_fills_for_record(exchange_symbol, direction, opened_at_str, cl 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 = [] @@ -3448,7 +3448,7 @@ def fetch_closing_fills_for_record(exchange_symbol, direction, opened_at_str, cl if candidates: return candidates - # 严格窗口为空时,降级为“按平仓时间就近匹配”,降低时区/时间误差导致的回填失败。 + # 严格窗口为空时,降级为“按平仓时间就近匹配”,降低时区/时间误差导致的回填失败. all_side_candidates.sort(key=lambda x: x.get("timestamp") or 0) if not all_side_candidates: return [] @@ -3547,8 +3547,8 @@ def calc_weighted_exit_price(trades): def resolve_synced_flat_close(row, opened_at_str, opened_at_ms=None): """ - 交易所已无仓、本地仍为 active 时,推断平仓类型/时间/盈亏。 - 返回 (result, pnl_amount, closed_at_str, miss_reason)。 + 交易所已无仓,本地仍为 active 时,推断平仓类型/时间/盈亏. + 返回 (result, pnl_amount, closed_at_str, miss_reason). """ direction = row["direction"] sym = row["symbol"] @@ -3591,13 +3591,13 @@ def resolve_synced_flat_close(row, opened_at_str, opened_at_ms=None): 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) @@ -3613,7 +3613,7 @@ def resolve_synced_flat_close(row, opened_at_str, opened_at_ms=None): "外部平仓", pnl, closed_at_str, - "交易所已平仓,成交价不在计划止盈/止损带内(可能为手动或其他类型平仓)", + "交易所已平仓,成交价不在计划止盈/止损带内(可能为手动或其他类型平仓)", ) @@ -3705,7 +3705,7 @@ def reconcile_external_closes(conn, days=None): 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 天过滤,避免把更早历史单误同步进来 + # 手动同步按最近 N 天过滤,避免把更早历史单误同步进来 if opened_ms is None or opened_ms < cutoff_ms: continue oid = int(r["id"]) @@ -3780,7 +3780,7 @@ def reconcile_external_closes(conn, days=None): build_wechat_close_message( symbol=r["symbol"], direction=r["direction"], - result=f"{result}(自动同步)", + result=f"{result}(自动同步)", pnl_amount=pnl_amount, hold_seconds=hold_seconds, trigger_price=r["trigger_price"], @@ -3796,7 +3796,7 @@ def reconcile_external_closes(conn, days=None): build_wechat_close_message( symbol=r["symbol"], direction=r["direction"], - result="外部平仓(自动同步)", + result="外部平仓(自动同步)", pnl_amount=pnl_amount, hold_seconds=hold_seconds, trigger_price=r["trigger_price"], @@ -3826,7 +3826,7 @@ def _coerce_ts_ms(val): def _unified_symbol_for_match(symbol_str): - """统一 ETH/USDT:USDT、ETH-USDT-SWAP 便于与 trade_records 比对。""" + """统一 ETH/USDT:USDT,ETH-USDT-SWAP 便于与 trade_records 比对.""" s = (symbol_str or "").strip().upper() if not s: return "" @@ -3962,7 +3962,7 @@ def fetch_okx_positions_close_history(): def sync_trade_records_from_exchange(conn, force=False): - """为未同步的 trade_records 回填 OKX 历史仓位中的已实现盈亏。返回统计 dict。""" + """为未同步的 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(): @@ -3981,7 +3981,7 @@ def sync_trade_records_from_exchange(conn, force=False): stats["hist_count"] = len(hist) if not hist: stats["ok"] = True - stats["reason"] = "交易所平仓历史为空(请检查 API 权限或 EXCHANGE_POSITION_SYNC_FROM_BJ)" + stats["reason"] = "交易所平仓历史为空(请检查 API 权限或 EXCHANGE_POSITION_SYNC_FROM_BJ)" return stats candidates = conn.execute( """ @@ -4120,7 +4120,7 @@ def _status_by_ema55(symbol, timeframe): def _daily_volume_rank(symbol): """ - 返回(symbol_rank, total_count):OKX USDT 永续 24h 成交额(USDT) 在全市场币种中的排名。 + 返回(symbol_rank, total_count):OKX USDT 永续 24h 成交额(USDT) 在全市场币种中的排名. """ sym_norm = normalize_symbol_input(symbol) target_base = journal_coin_from_symbol(sym_norm) @@ -4136,8 +4136,8 @@ def _daily_volume_rank(symbol): def _key_hard_checks(symbol, direction, upper, lower, monitor_type): """ - 关键位门控:量能、突破幅度、第二根确认、日成交量前30。 - 使用最近闭合K:breakout=倒数第2根,confirm=倒数第1根。 + 关键位门控:量能,突破幅度,第二根确认,日成交量前30. + 使用最近闭合K:breakout=倒数第2根,confirm=倒数第1根. """ out = {"ok": False} ex_sym = normalize_okx_symbol(symbol) @@ -4154,7 +4154,7 @@ def _key_hard_checks(symbol, direction, upper, lower, monitor_type): breakout = closed[KEY_CONFIRM_BREAKOUT_BAR] confirm = closed[KEY_CONFIRM_BAR] except IndexError: - out["reason"] = "确认K索引超出范围,请检查 KEY_CONFIRM_* 配置" + 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) @@ -4242,7 +4242,7 @@ def calc_price_diff_pct(current_price, target_price): def _coerce_float(*values): - """取第一个可解析且 > 0 的数(用于价格、保证金等)。""" + """取第一个可解析且 > 0 的数(用于价格,保证金等).""" for v in values: if v is None: continue @@ -4256,7 +4256,7 @@ def _coerce_float(*values): def _coerce_float_signed(*values): - """取第一个有限浮点数(含 0 与负数),用于未实现盈亏等。""" + """取第一个有限浮点数(含 0 与负数),用于未实现盈亏等.""" for v in values: if v is None or v == "": continue @@ -4425,18 +4425,18 @@ def _process_key_rs_level_alert(conn, row): 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']})", + 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})", + 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() @@ -4697,8 +4697,8 @@ def _finalize_fib_key_fill(conn, row): if amount <= 0: msg = ( f"# ❌ {symbol} {kind}成交后处理失败\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 无法取得持仓/下单数量,未挂 TP/SL\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") @@ -4707,9 +4707,9 @@ def _finalize_fib_key_fill(conn, row): if not ok: msg = ( f"# ❌ {symbol} {kind}成交后风控拒绝\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 类型:{typ}\n" - f"- 原因:{reason}\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}\n" + f"- 原因:{reason}\n" f"- 请手动处理仓位与挂单\n" ) send_wechat_msg(msg) @@ -4727,8 +4727,8 @@ def _finalize_fib_key_fill(conn, row): except Exception as e: msg = ( f"# ❌ {symbol} {kind}成交后挂 TP/SL 失败\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 错误:{friendly_okx_error(e)}\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 错误:{friendly_okx_error(e)}\n" f"- 请手动补挂止盈止损\n" ) send_wechat_msg(msg) @@ -4759,13 +4759,13 @@ def _finalize_fib_key_fill(conn, row): 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"**账户:{_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) @@ -4797,7 +4797,7 @@ def _add_trigger_entry_key_monitor( 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} 已有触价开仓监控(同币仅允许一条)" + return False, f"{symbol} 已有触价开仓监控(同币仅允许一条)" ex_sym = normalize_exchange_symbol(symbol) mark = get_symbol_mark_price(symbol) geom_err = validate_trigger_entry_geometry( @@ -4867,7 +4867,7 @@ def _add_trigger_entry_key_monitor( leverage = 5 risk_fraction = calc_risk_fraction(direction_sel, entry, sl) if risk_fraction is None: - return False, "止损方向不合法(相对计划入场价)" + 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) @@ -4879,7 +4879,7 @@ def _add_trigger_entry_key_monitor( if margin_capital > max_margin: return ( False, - f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", + f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", ) try: amount_plan, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry) @@ -4934,14 +4934,14 @@ def _market_open_for_trigger_entry( time_close_enabled=0, time_close_hours=None, ): - """触价触发后市价开仓,计仓规则与实盘下单/关键位 RR 门槛一致。""" + """触价触发后市价开仓,计仓规则与实盘下单/关键位 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 + return False, f"风控拒绝下单:{reason}", None ok_live, reason_live = ensure_exchange_live_ready() if not ok_live: return False, reason_live, None @@ -4980,7 +4980,7 @@ def _market_open_for_trigger_entry( 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 + 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): @@ -5010,7 +5010,7 @@ def _market_open_for_trigger_entry( leverage = 5 risk_fraction = calc_risk_fraction(direction, entry_price, stop_loss) if risk_fraction is None: - return False, "止损方向不合法(相对计划入场价)", 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) @@ -5021,7 +5021,7 @@ def _market_open_for_trigger_entry( if margin_capital > max_margin: return ( False, - f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", + 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 @@ -5133,7 +5133,7 @@ def _market_open_for_trigger_entry( def _execute_trigger_entry_cross(conn, row): - """标记价触达计划入场:加锁防重复触发,成交成功后再删监控行。""" + """标记价触达计划入场:加锁防重复触发,成交成功后再删监控行.""" symbol = row["symbol"] direction = (row["direction"] or "long").lower() ex_sym = normalize_exchange_symbol(symbol) @@ -5168,9 +5168,9 @@ def _execute_trigger_entry_cross(conn, row): 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" + 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 @@ -5181,14 +5181,14 @@ def _execute_trigger_entry_cross(conn, row): 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"**账户:{_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) @@ -5199,9 +5199,9 @@ def _execute_trigger_entry_cross(conn, row): 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" + 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 @@ -5239,9 +5239,9 @@ def check_trigger_entry_key_monitors(): 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" + 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) @@ -5250,8 +5250,8 @@ def check_trigger_entry_key_monitors(): if inv == "tp": msg = ( f"# ⚠️ {symbol} 触价开仓失效\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 类型:{mt}|标记价 {format_price_for_symbol(symbol, mark)} 已触达止盈侧(未成交)\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) @@ -5259,8 +5259,8 @@ def check_trigger_entry_key_monitors(): if inv == "sl": msg = ( f"# ⚠️ {symbol} 触价开仓失效\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 类型:{mt}|标记价 {format_price_for_symbol(symbol, mark)} 已触达止损侧(未突破)\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) @@ -5294,9 +5294,9 @@ def check_fib_key_monitors(): 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"**账户:{_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) @@ -5314,18 +5314,18 @@ def check_fib_key_monitors(): _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" + 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" + f"# ⚠️ {symbol} 斐波监控失效(限价已不在挂单)\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 标记价触达止盈侧,本条已结案\n" ) send_wechat_msg(msg) _finalize_key_monitor_one_shot(conn, r, msg, "fib_invalidate") @@ -5346,10 +5346,10 @@ def _add_false_breakout_key_monitor( time_close_enabled=0, time_close_hours=None, ): if _false_breakout_exists_for_symbol(conn, symbol): - return False, f"{symbol} 已有假突破监控(同币仅允许一条)" + return False, f"{symbol} 已有假突破监控(同币仅允许一条)" plan = calc_false_breakout_plan(direction_sel, key_px) if not plan: - return False, "假突破价位无效,请核对方向与关键价位" + return False, "假突破价位无效,请核对方向与关键价位" entry, sl, tp = plan ex_sym = normalize_okx_symbol(symbol) entry = round_price_to_exchange(ex_sym, entry) @@ -5377,7 +5377,7 @@ def _add_false_breakout_key_monitor( available_usdt = get_available_trading_usdt() risk_fraction = calc_risk_fraction(direction_sel, entry, sl) if risk_fraction is None: - return False, "止损方向不合法(相对挂单价);请核对方向与关键价位" + 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) @@ -5389,7 +5389,7 @@ def _add_false_breakout_key_monitor( if margin_capital > max_margin: return ( False, - f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", + f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", ) try: amount, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry) @@ -5420,11 +5420,11 @@ def _add_fib_key_monitor( time_close_enabled=0, time_close_hours=None, ): if _fib_key_exists_for_symbol(conn, symbol): - return False, f"{symbol} 已有斐波监控(同币仅允许一条 0.618/0.786)" + 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)" + return False, "斐波上下沿无效(需上沿 H > 下沿 L)" entry, sl, tp = plan ex_sym = normalize_okx_symbol(symbol) entry = round_price_to_exchange(ex_sym, entry) @@ -5436,7 +5436,7 @@ def _add_fib_key_monitor( 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)" + 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 @@ -5456,7 +5456,7 @@ def _add_fib_key_monitor( available_usdt = get_available_trading_usdt() risk_fraction = calc_risk_fraction(direction_sel, entry, sl) if risk_fraction is None: - return False, "止损方向不合法(相对挂单价 E);请核对上下沿与方向" + 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) @@ -5468,7 +5468,7 @@ def _add_fib_key_monitor( if margin_capital > max_margin: return ( False, - f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", + f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", ) try: amount, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry) @@ -5519,7 +5519,7 @@ def _market_open_for_key_monitor( time_close_hours=None, ): """ - 与手动「实盘下单」对齐的市价开仓与 order_monitors 写入(OKX 永续)。 + 与手动「实盘下单」对齐的市价开仓与 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) @@ -5528,7 +5528,7 @@ def _market_open_for_key_monitor( now = app_now() ok, reason = precheck_risk(conn, symbol, direction) if not ok: - return False, f"风控拒绝下单:{reason}", None + return False, f"风控拒绝下单:{reason}", None ok_live, reason_live = ensure_exchange_live_ready() if not ok_live: return False, reason_live, None @@ -5555,7 +5555,7 @@ def _market_open_for_key_monitor( available_usdt = get_available_trading_usdt() live_price = get_price(symbol) if live_price is None: - return False, "获取交易所实时价格失败(以损定仓需要当前价)", None + return False, "获取交易所实时价格失败(以损定仓需要当前价)", None try: ensure_markets_loaded() except Exception: @@ -5573,7 +5573,7 @@ def _market_open_for_key_monitor( risk_fraction = calc_risk_fraction(direction, live_price, stop_loss) if risk_fraction is None: - return False, "止损方向不合法(相对当前市价);请核对上下沿与方向", 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) @@ -5587,7 +5587,7 @@ def _market_open_for_key_monitor( if margin_capital > max_margin: return ( False, - f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", + f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", None, ) @@ -5733,7 +5733,7 @@ def breakout_too_far(p, edge_price, limit_pct): return False -# 关键位监控(箱体/收敛可自动开仓;阻力/支撑为双向 5m 收盘突破 + 三次提醒) +# 关键位监控(箱体/收敛可自动开仓;阻力/支撑为双向 5m 收盘突破 + 三次提醒) def check_key_monitors(): conn = get_db() rows = conn.execute("SELECT * FROM key_monitors").fetchall() @@ -5762,10 +5762,10 @@ def check_key_monitors(): 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"**账户:{_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" + f"{format_price_for_symbol(sym, edge)}(设置失效)\n" ) send_wechat_msg(msg) _finalize_key_monitor_one_shot(conn, r, msg, "box_opposite_break") @@ -5781,7 +5781,7 @@ def check_key_monitors(): 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)主趋势逆势,建议降低仓位并严格执行止损。" + risk_tip = "当前信号与本币4h(EMA55)主趋势逆势,建议降低仓位并严格执行止损." key_price = float(low) if direction == "long" else float(up) hard_lines = _key_hard_lines_from_checks(checks) @@ -5792,15 +5792,15 @@ def check_key_monitors(): plan_tuple, sl_tp_mode = _key_plan_sl_tp_for_row(r, direction, up, low, checks) if not plan_tuple: - fmt_rr = "无法计算(止损/止盈与确认价几何关系无效)" + 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" + 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) @@ -5827,23 +5827,23 @@ def check_key_monitors(): 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 "无法计算(止损/止盈与确认价几何关系无效)" + 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" + 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) @@ -5875,15 +5875,15 @@ def check_key_monitors(): 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" + 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) @@ -5895,7 +5895,7 @@ def check_key_monitors(): continue tpsl_txt = ( - "已在交易所挂止盈/止损触发单(OKX 条件单)" + "已在交易所挂止盈/止损触发单(OKX 条件单)" if det.get("tpsl_attached") else "⚠️ 条件单挂接状态异常或未挂上" ) @@ -5904,23 +5904,23 @@ def check_key_monitors(): 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"**账户:{_wechat_account_label()}**", + f"- **来源:**{ORDER_MONITOR_TYPE_KEY_AUTO}(市价)", + f"- 页面订单 ID:**{det['new_order_id']}**", + f"- 交易所订单 ID:`{det.get('open_order_id') or '-'}`", + f"- 类型:{typ}|方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'开' if be_on else '关'}", + f"- 方向:**{_wechat_direction_text(direction)}**", + f"- 触发时间:`{trigger_time}`", + f"- 确认K收盘(E):{format_price_for_symbol(sym, E)}(RR 阈值按此计价)", + f"- **计划 RR(E):{planned_rr_txt}:1**", + f"- 开仓成交价:**{format_price_for_symbol(sym, det['trigger_price'])}**", + f"- **成交价侧计划 RR:**{rr_fill_txt}:1", + f"- 止损:{format_wechat_scalar_2dp(sl_raw)}", + f"- 止盈:{format_price_for_symbol(sym, tp_raw)}", + f"- 风险:{det.get('risk_percent')}%≈{format_wechat_scalar_2dp(det.get('risk_amount_final'))}U|基数 {format_wechat_scalar_2dp(det.get('margin_capital'))}U|杠杆 {det.get('leverage')}x", f"- 名义 {format_wechat_scalar_2dp(det.get('notional_value'))}U|张数 {format_wechat_scalar_2dp(det.get('amount'))}|折算标的 {det.get('base_amount')}", f"- **{tpsl_txt}**", - f"- 保本触发:{det.get('breakeven_rr_trigger')}R→{format_price_for_symbol(sym, det.get('breakeven_price'))}", + f"- 保本触发:{det.get('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]) @@ -5941,7 +5941,7 @@ def check_key_monitors(): det.get("opens_today_after", 0), DAILY_OPEN_ALERT_THRESHOLD, hard_limit=DAILY_OPEN_HARD_LIMIT, - detail_line=f"最新一笔来源为关键位自动单:{sym} {direction},杠杆{det['leverage']}x。", + detail_line=f"最新一笔来源为关键位自动单:{sym} {direction},杠杆{det['leverage']}x.", ) ) if advice: @@ -5949,7 +5949,7 @@ def check_key_monitors(): conn.commit() conn.close() -# 止盈止损监控(已修复:严格区分多空,无默认做多) +# 止盈止损监控(已修复:严格区分多空,无默认做多) def check_order_monitors(): conn = get_db() rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() @@ -5961,7 +5961,7 @@ def check_order_monitors(): p = get_price(sym) if not p: continue - # 到达设定 R 倍后,按阶梯持续上移止损(本地风控层) + # 到达设定 R 倍后,按阶梯持续上移止损(本地风控层) risk_amount = float(r["risk_amount"] or 0) breakeven_armed = int(r["breakeven_armed"] or 0) trigger_rr = float(r["breakeven_rr_trigger"] or BREAKEVEN_RR_TRIGGER) @@ -6017,7 +6017,7 @@ def check_order_monitors(): ) _send_breakeven_exchange_warn_once( pid, - f"⚠️ {sym} 移动保本止损未同步交易所:{friendly_okx_error(e)}", + f"⚠️ {sym} 移动保本止损未同步交易所:{friendly_okx_error(e)}", ) elif ok_live: print( @@ -6042,7 +6042,7 @@ def check_order_monitors(): new_sl, ) if ok_live: - be_msg += "\n- 交易所:已先撤后挂止盈止损" + be_msg += "\n- 交易所:已先撤后挂止盈止损" send_wechat_msg(be_msg) res = None @@ -6072,7 +6072,7 @@ def check_order_monitors(): 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) @@ -6122,7 +6122,7 @@ def check_order_monitors(): 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: @@ -6161,7 +6161,7 @@ def check_order_monitors(): actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), result=res, miss_reason=handoff_trade_miss_reason( - "触发价已触达,仓位已由交易所止盈/止损或其他方式平掉(本地补记)", + "触发价已触达,仓位已由交易所止盈/止损或其他方式平掉(本地补记)", r, ), opened_at=opened_at, @@ -6172,7 +6172,7 @@ def check_order_monitors(): build_wechat_close_message( symbol=sym, direction=direction, - result=f"{res}(交易所已先行平仓)", + result=f"{res}(交易所已先行平仓)", pnl_amount=pnl_amount, hold_seconds=hold_seconds, trigger_price=trigger_price, @@ -6180,7 +6180,7 @@ def check_order_monitors(): stop_loss=stop_loss, take_profit=take_profit, close_order_id="-", - extra_note="本地补记:仓位由交易所止盈/止损或其他方式先行平掉", + extra_note="本地补记:仓位由交易所止盈/止损或其他方式先行平掉", session_capital_fallback=session_capital, ) ) @@ -6193,11 +6193,11 @@ def check_order_monitors(): 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}" + 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}" + record_miss = f"触发{res}后交易所平仓失败(请核对交易所仓位):{e}" monitor_status = "error" record_hold = calc_hold_seconds( opened_at, parse_dt_for_trading_day(record_closed) or now @@ -6243,7 +6243,7 @@ def check_order_monitors(): build_wechat_close_message( symbol=sym, direction=direction, - result=f"{record_res}(已补记入交易记录)", + result=f"{record_res}(已补记入交易记录)", pnl_amount=record_pnl, hold_seconds=record_hold, trigger_price=trigger_price, @@ -6306,7 +6306,7 @@ def force_close_before_reset(): if not FORCE_CLOSE_ENABLED: return now = app_now() - # 每天北京时间指定整点小时内执行一次性兜底清仓(默认 00:xx) + # 每天北京时间指定整点小时内执行一次性兜底清仓(默认 00:xx) if now.hour != FORCE_CLOSE_BJ_HOUR: return conn = get_db() @@ -6463,9 +6463,9 @@ def sync_positions(): conn.commit() conn.close() if sync_days is not None: - flash(f"同步完成:最近 {sync_days} 天内 {synced} 笔持仓已按交易所状态更新") + flash(f"同步完成:最近 {sync_days} 天内 {synced} 笔持仓已按交易所状态更新") else: - flash(f"同步完成:{synced} 笔持仓已按交易所状态更新") + flash(f"同步完成:{synced} 笔持仓已按交易所状态更新") return redirect("/") @@ -7045,7 +7045,7 @@ def api_price_snapshot(): if exchange_private_api_configured(): try: ensure_markets_loaded() - # 显式 USDT 本位;不传 symbols 拉全量,再在本地按合约对齐 + # 显式 USDT 本位;不传 symbols 拉全量,再在本地按合约对齐 all_swap_positions = exchange.fetch_positions(None, {"instType": OKX_POSITION_INST_TYPE}) or [] except Exception: try: @@ -7481,7 +7481,7 @@ def api_order_kline(): 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 + return jsonify({"ok": False, "msg": f"K线加载失败:{friendly_okx_error(e)}"}), 500 candles = [] for bar in ohlcv or []: @@ -7611,7 +7611,7 @@ def api_key_kline(): 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 + return jsonify({"ok": False, "msg": f"K线加载失败:{friendly_okx_error(e)}"}), 500 candles = [] for bar in ohlcv or []: @@ -7846,10 +7846,10 @@ def add_key(): if not skip_volume_rank: rank, total = _daily_volume_rank(symbol) if rank is None: - flash("日成交量排名读取失败,请稍后重试") + flash("日成交量排名读取失败,请稍后重试") return redirect("/key_monitor") if rank > KEY_DAILY_VOLUME_RANK_MAX: - flash(f"{symbol} 当前日成交量排名为 {rank}/{total},不在前{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: @@ -7857,8 +7857,8 @@ def add_key(): if occupied >= MAX_ACTIVE_POSITIONS: conn.close() flash( - f"当前持仓已达上限({occupied}/{MAX_ACTIVE_POSITIONS}):无法添加「箱体突破 / 收敛突破」。" - "请平仓后再试,或使用「关键支撑阻力」(仅提醒)。" + f"当前持仓已达上限({occupied}/{MAX_ACTIVE_POSITIONS}):无法添加「箱体突破 / 收敛突破」." + "请平仓后再试,或使用「关键支撑阻力」(仅提醒)." ) return redirect("/key_monitor") ex_sym_key = normalize_okx_symbol(symbol) @@ -7884,7 +7884,7 @@ def add_key(): entry_px = sl_px = tp_px = 0 if entry_px <= 0 or sl_px <= 0 or tp_px <= 0: conn.close() - flash("触价须填写有效的入场价、止损价、止盈价") + flash("触价须填写有效的入场价,止损价,止盈价") return redirect("/key_monitor") ok_te, err_te = _add_trigger_entry_key_monitor( conn, @@ -7909,10 +7909,10 @@ def add_key(): else "标记价回调触达入场价后下一轮询市价开仓" ) flash( - f"{mt}已添加({symbol} 日成交量排名 {rank}/{total})" + f"{mt}已添加({symbol} 日成交量排名 {rank}/{total})" f"|有效期 {TRIGGER_ENTRY_VALIDITY_HOURS}h" f"|{trigger_hint}" - f"|移动保本:{'开' if be_flag else '关'}" + f"|移动保本:{'开' if be_flag else '关'}" + (f"|{time_close_label(tc_h)}" if tc_en else "") ) return redirect("/key_monitor") @@ -7933,7 +7933,7 @@ def add_key(): key_px = 0 if key_px <= 0: conn.close() - flash("请填写关键价位(做空填高点,做多填低点)") + flash("请填写关键价位(做空填高点,做多填低点)") return redirect("/key_monitor") ex_sym_key = normalize_okx_symbol(symbol) key_adj = round_price_to_exchange(ex_sym_key, key_px) @@ -7954,8 +7954,8 @@ def add_key(): flash(err_fb or "假突破监控添加失败") return redirect("/key_monitor") flash( - f"假突破监控已添加,限价单已挂出({symbol})" - f"|有效期 {FALSE_BREAKOUT_VALIDITY_HOURS}h|移动保本:{'开' if be_flag else '关'}" + 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") @@ -7978,8 +7978,8 @@ def add_key(): flash(err_fib or "斐波监控添加失败") return redirect("/key_monitor") flash( - f"斐波监控已添加,限价单已挂出({symbol} 日成交量排名 {rank}/{total})" - f"|移动保本:{'开' if be_flag else '关'}" + f"斐波监控已添加,限价单已挂出({symbol} 日成交量排名 {rank}/{total})" + f"|移动保本:{'开' if be_flag else '关'}" + (f"|{time_close_label(tc_h)}" if tc_en else "") ) return redirect("/key_monitor") @@ -7998,11 +7998,11 @@ def add_key(): return redirect("/key_monitor") if direction_sel == "long" and manual_tp <= upper_px: conn.close() - flash("做多趋势单:止盈价应高于上沿(阻力)") + flash("做多趋势单:止盈价应高于上沿(阻力)") return redirect("/key_monitor") if direction_sel == "short" and manual_tp >= lower_px: conn.close() - flash("做空趋势单:止盈价应低于下沿(支撑)") + flash("做空趋势单:止盈价应低于下沿(支撑)") return redirect("/key_monitor") mtpx = round_price_to_exchange(ex_sym_key, manual_tp) if mtpx is not None: @@ -8040,7 +8040,7 @@ def add_key(): conn.close() extra = "" if mt in KEY_MONITOR_AUTO_TYPES: - extra = f"|方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'开' if be_flag else '关'}" + 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 @@ -8053,14 +8053,14 @@ def add_key(): pass if mt in KEY_MONITOR_RS_TYPES: flash( - f"添加成功({symbol} 日成交量排名 {rank}/{total})|关键支撑阻力:双向监控上/下沿," - f"5m 收盘突破后微信提醒 {KEY_ALERT_MAX_TIMES} 次(间隔 {KEY_ALERT_INTERVAL_MINUTES} 分钟)" + f"添加成功({symbol} 日成交量排名 {rank}/{total})|关键支撑阻力:双向监控上/下沿," + f"5m 收盘突破后微信提醒 {KEY_ALERT_MAX_TIMES} 次(间隔 {KEY_ALERT_INTERVAL_MINUTES} 分钟)" ) else: - flash(f"添加成功({symbol} 日成交量排名 {rank}/{total}){extra}") + flash(f"添加成功({symbol} 日成交量排名 {rank}/{total}){extra}") if ctr and mt in KEY_MONITOR_AUTO_TYPES: flash( - "⚠️ 4h EMA55 提示:当前与所选方向逆势;「箱体突破/收敛突破」在条件满足时仍会按计划自动市价开仓,请注意仓位。" + "⚠️ 4h EMA55 提示:当前与所选方向逆势;「箱体突破/收敛突破」在条件满足时仍会按计划自动市价开仓,请注意仓位." ) return redirect("/key_monitor") @@ -8079,7 +8079,7 @@ def add_order(): ok_pol, pol_msg = validate_trade_policy_open(symbol, direction) if not ok_pol: conn.close() - flash(f"账户限制:{pol_msg}") + flash(f"账户限制:{pol_msg}") return redirect("/trade") dup_msg = check_duplicate_submit(session, submit_scope_add_order(symbol, direction)) if dup_msg: @@ -8089,12 +8089,12 @@ def add_order(): ok, reason = precheck_risk(conn, symbol, direction) if not ok: conn.close() - flash(f"风控拒绝下单:{reason}") + flash(f"风控拒绝下单:{reason}") return redirect("/trade") ok_live, reason_live = ensure_okx_live_ready() if not ok_live: conn.close() - flash(f"风控拒绝下单:{reason_live}") + flash(f"风控拒绝下单:{reason_live}") return redirect("/trade") exchange_symbol = normalize_okx_symbol(symbol) trading_day = get_trading_day(now) @@ -8116,7 +8116,7 @@ def add_order(): live_price = get_price(symbol) if live_price is None: conn.close() - flash("获取交易所实时价格失败,请稍后重试") + flash("获取交易所实时价格失败,请稍后重试") return redirect("/trade") sltp_mode = normalize_open_sltp_mode(d.get("sltp_mode")) try: @@ -8135,12 +8135,12 @@ def add_order(): 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") + 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("止损方向不合法:请检查入场方向与止损价格关系") + flash("止损方向不合法:请检查入场方向与止损价格关系") return redirect("/trade") risk_percent = max(0.01, float(RISK_PERCENT)) risk_amount = round(capital_base * risk_percent / 100.0, FUNDS_DECIMALS) @@ -8184,13 +8184,13 @@ def add_order(): margin_capital = round(notional_value / leverage, FUNDS_DECIMALS) if capital_base and margin_capital > capital_base: conn.close() - flash("以损定仓后保证金超过当前交易资金,请放宽止损或降低风险比例") + 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") + 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: @@ -8324,11 +8324,11 @@ def add_order(): if trading_capital_after is not None else round(float(capital_base), 2) ) - dir_text = "多头(long)" if direction == "long" else "空头(short)" + dir_text = "多头(long)" if direction == "long" else "空头(short)" order_state_text = ( - "已在交易所挂条件委托(止盈、止损各一张触发单)" + "已在交易所挂条件委托(止盈,止损各一张触发单)" if tpsl_attached - else "条件委托未挂上(已拦截)" + else "条件委托未挂上(已拦截)" ) rr_show = planned_rr if planned_rr is not None else "-" try: @@ -8343,43 +8343,43 @@ def add_order(): style_zh = "Swing 波段" if trade_style == "swing" else "Trend 趋势" wx_lines = [ f"📈 {symbol} 开仓成功", - f"💼 交易类型:{dir_text}", + f"💼 交易类型:{dir_text}", "🧾 订单基础信息", - f"🔖 交易所订单 ID:{open_order_id}", - f"📈 交易风格:{style_zh}", - f"⚠️ 单笔风控风险:{risk_display}", + 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"账户基数:{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"开仓成交价:{ep_wx}", + f"止损价位:{sl_wx}", + f"止盈价位:{tp_wx}", + f"计划盈亏比:{rr_line}", + f"移动保本位:{breakeven_rr_trigger}R → {be_wx}", "📌 状态统计", - f"✅ 条件委托:{order_state_text}", + 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}") + 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 '-'};已在交易所挂条件止盈/止损委托(非仓位绑定型)", + 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_lines.append(f"已生成多周期K线图:{chart_url}") flash(" ".join(flash_lines)) if should_send_daily_open_alert( @@ -8391,12 +8391,12 @@ def add_order(): opens_today_after, DAILY_OPEN_ALERT_THRESHOLD, hard_limit=DAILY_OPEN_HARD_LIMIT, - detail_line=f"最新一笔:{symbol} {direction},杠杆{leverage}x,基数{margin_capital}U。", + 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]}") + flash(f"【AI提醒】今日开仓次数已达 {opens_today_after}:{advice[:300]}") return redirect("/trade") @app.route("/delete_key_monitor/", methods=["POST"]) @@ -8710,7 +8710,7 @@ def del_order(id): 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}" + 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) @@ -8761,10 +8761,10 @@ def del_order(id): pass conn.commit() conn.close() - flash("该仓位在交易所已不存在,已按成交记录同步结束并记账") + flash("该仓位在交易所已不存在,已按成交记录同步结束并记账") return redirect("/") conn.close() - flash(f"手动平仓失败:{str(e)}") + flash(f"手动平仓失败:{str(e)}") return redirect("/") conn.execute("DELETE FROM order_monitors WHERE id=?",(id,)) conn.commit() @@ -8790,7 +8790,7 @@ def add_journal(): 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) @@ -8812,7 +8812,7 @@ def add_journal(): try: risk_amount_hint = float(d.get("risk_amount_hint") or 0) pnl_hint = float(d.get("pnl") or 0) - # 口径统一:实际RR = 实际盈亏 / 以损定仓对应的初始风险金额 + # 口径统一:实际RR = 实际盈亏 / 以损定仓对应的初始风险金额 if risk_amount_hint > 0: real_rr_text = f"{(pnl_hint / risk_amount_hint):.2f}" except Exception: @@ -8860,11 +8860,11 @@ def add_journal(): ) if saved: image_filename = saved - chart_msg = f"已生成复盘K线图({'/'.join(journal_tfs)} 各{journal_limit}根):/static/images/{saved}" + chart_msg = f"已生成复盘K线图({'/'.join(journal_tfs)} 各{journal_limit}根):/static/images/{saved}" else: - chart_msg = "已勾选自动生成K线图,但生成失败(返回空)。请检查 Pillow 是否安装、OKX 网络/代理是否正常。" + chart_msg = "已勾选自动生成K线图,但生成失败(返回空).请检查 Pillow 是否安装,OKX 网络/代理是否正常." except Exception as e: - chart_msg = f"自动生成K线图失败:{str(e)}" + chart_msg = f"自动生成K线图失败:{str(e)}" conn = get_db() conn.execute( @@ -8901,7 +8901,7 @@ def add_journal(): conn.commit() conn.close() if chart_msg: - flash(f"交易复盘记录已保存。{chart_msg}") + flash(f"交易复盘记录已保存.{chart_msg}") else: flash("交易复盘记录已保存") return _redirect_records() @@ -9038,7 +9038,7 @@ def export_review_md(rid): created_at = row["created_at"] or app_now_str() content = (row["content"] or "").strip() if not content: - content = "(无内容)" + content = "(无内容)" md = ( f"# {review_type}报告\n\n" @@ -9087,7 +9087,7 @@ def export_reviews_md_bundle(): ] for idx, row in enumerate(rows, 1): created_at = row["created_at"] or "-" - content = (row["content"] or "").strip() or "(无内容)" + content = (row["content"] or "").strip() or "(无内容)" lines.extend( [ f"## 第{idx}条", @@ -9146,13 +9146,13 @@ def api_trade_record_review_update(): 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 + 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 + 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()) @@ -9255,9 +9255,9 @@ def manual_transfer(): conn.commit() conn.close() if ok: - flash(f"手动划转成功:{amount}U {from_account}->{to_account}") + flash(f"手动划转成功:{amount}U {from_account}->{to_account}") else: - flash(f"手动划转失败:{msg}") + flash(f"手动划转失败:{msg}") return redirect("/settings") @@ -9286,7 +9286,7 @@ def ai_daily_review(): if not rows: return jsonify({"result": "该日无交易记录"}) - text = f"【每日交易记录】{date}\n总笔数:{len(rows)}\n\n" + 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" @@ -9297,7 +9297,7 @@ def ai_daily_review(): 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}" + 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 (?,?,?,?)", @@ -9322,7 +9322,7 @@ def ai_weekly_review(): if not rows: return jsonify({"result": "该时间段无交易记录"}) - text = f"【周交易记录】{start_date}~{end_date}\n总笔数:{len(rows)}\n\n" + 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" @@ -9333,7 +9333,7 @@ def ai_weekly_review(): 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}" + 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 (?,?,?,?)", @@ -9349,8 +9349,8 @@ def _hub_meta_bundle(): "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}%" + 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, @@ -9481,7 +9481,7 @@ def strategy_roll_page(): return redirect("/strategy") -# 根目录 strategy_* 与币安/Gate 共用同一套属性名(OKX 内部仍用 normalize_okx_symbol / ensure_okx_live_ready) +# 根目录 strategy_* 与币安/Gate 共用同一套属性名(OKX 内部仍用 normalize_okx_symbol / ensure_okx_live_ready) normalize_exchange_symbol = normalize_okx_symbol ensure_exchange_live_ready = ensure_okx_live_ready diff --git a/crypto_monitor_okx/ecosystem.config.cjs b/crypto_monitor_okx/ecosystem.config.cjs index a5ce781..5abd1f6 100644 --- a/crypto_monitor_okx/ecosystem.config.cjs +++ b/crypto_monitor_okx/ecosystem.config.cjs @@ -1,34 +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 }, - }, - ], -}; +/** + * 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/fix_breakeven_labels.py b/crypto_monitor_okx/scripts/fix_breakeven_labels.py index 80b7d04..97a910a 100644 --- a/crypto_monitor_okx/scripts/fix_breakeven_labels.py +++ b/crypto_monitor_okx/scripts/fix_breakeven_labels.py @@ -1,18 +1,18 @@ #!/usr/bin/env python3 """ -一次性修复历史交易记录标签: -将 trade_records 里“止损但实际盈利”的记录改为“保本止盈”。 +一次性修复历史交易记录标签: +将 trade_records 里“止损但实际盈利”的记录改为“保本止盈”. -默认条件(可通过参数修改): +默认条件(可通过参数修改): - monitor_type = 下单监控 - result = 止损 - pnl_amount > 0 -用法示例: -1) 仅预览(不落库): +用法示例: +1) 仅预览(不落库): python scripts/fix_breakeven_labels.py --db ./crypto.db --dry-run -2) 执行修复: +2) 执行修复: python scripts/fix_breakeven_labels.py --db ./crypto.db --apply """ diff --git a/crypto_monitor_okx/scripts/verify_okx_funding.py b/crypto_monitor_okx/scripts/verify_okx_funding.py index 6037ff8..1550dc7 100644 --- a/crypto_monitor_okx/scripts/verify_okx_funding.py +++ b/crypto_monitor_okx/scripts/verify_okx_funding.py @@ -1,52 +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() +#!/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/templates/order_focus.html b/crypto_monitor_okx/templates/order_focus.html index 5bc2230..3dc7ce3 100644 --- a/crypto_monitor_okx/templates/order_focus.html +++ b/crypto_monitor_okx/templates/order_focus.html @@ -29,9 +29,9 @@
返回首页 - 实盘下单放大(100根K线) + 实盘下单放大(100根K线)
-
最近刷新:--
+
最近刷新:--
{% if orders %}
@@ -53,7 +53,7 @@
{% else %} -
当前没有激活订单,无法展示放大K线。
+
当前没有激活订单,无法展示放大K线.
{% endif %} diff --git a/crypto_monitor_okx/使用说明.md b/crypto_monitor_okx/使用说明.md index 345afb6..e27782b 100644 --- a/crypto_monitor_okx/使用说明.md +++ b/crypto_monitor_okx/使用说明.md @@ -1,138 +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`。 +# 使用说明 + +**本文件对应仓库:`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 index 3a600ff..a8ca535 100644 --- a/crypto_monitor_okx/关键位自动下单说明.md +++ b/crypto_monitor_okx/关键位自动下单说明.md @@ -1,192 +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` | +# 关键位监控说明(自动开仓 + 人工盯盘) + +**适用: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 index bbbe57f..c7dc14f 100644 --- a/crypto_monitor_okx/更新文档.md +++ b/crypto_monitor_okx/更新文档.md @@ -1,92 +1,92 @@ -# 界面与风控更新说明(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 监控进程**;旧库行不做批量回填,展示字段有则用之、无则回退。 +# 界面与风控更新说明(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 监控进程**;旧库行不做批量回填,展示字段有则用之,无则回退. diff --git a/crypto_monitor_okx/部署文档.md b/crypto_monitor_okx/部署文档.md index 7f8776a..caab727 100644 --- a/crypto_monitor_okx/部署文档.md +++ b/crypto_monitor_okx/部署文档.md @@ -1,367 +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/crypto_monitor_okx - -# 可选:清理 Python 缓存,减少小文件传输 -find . -type d -name __pycache__ -prune -exec rm -rf {} + -find . -type f -name "*.pyc" -delete - -tar -czf crypto_monitor.tgz . -``` - -下载 `crypto_monitor.tgz` 到本机后解压: - -```bash -mkdir -p /opt/crypto_monitor/crypto_monitor_okx -cd /opt/crypto_monitor -tar -xzf crypto_monitor.tgz -C crypto_monitor_okx -cd crypto_monitor_okx -cp -n .env.example .env # 若尚无 .env -``` - ---- - -## 2. 配置 SSH 私钥与 `~/.ssh/config`(推荐) - -把私钥放到本机(示例:`~/.ssh/vps1.pem`),并设置权限: - -```bash -mkdir -p ~/.ssh -chmod 700 ~/.ssh -mv ~/Downloads/vps1.pem ~/.ssh/vps1.pem -chmod 600 ~/.ssh/vps1.pem -``` - -编辑 `~/.ssh/config`(没有就创建),添加: - -```sshconfig -Host okx-vps - HostName 47.76.87.111 - User root - IdentityFile ~/.ssh/vps1.pem - IdentitiesOnly yes - ServerAliveInterval 30 - ServerAliveCountMax 3 - ExitOnForwardFailure yes - BatchMode yes -``` - -测试: - -```bash -ssh okx-vps true -``` - -> 如果你还没完全切到密钥登录(还会交互要密码),先把 `BatchMode yes` 注释掉,等密钥登录稳定后再打开。 - ---- - -## 3. 先手工验证:SSH SOCKS + OKX API - -### 3.1 开一个本地 SOCKS(1080) - -```bash -ssh -N -D 127.0.0.1:1080 okx-vps -``` - -保持该进程运行(另开终端继续下面步骤)。 - -### 3.2 验证 OKX 走 SOCKS 可用 - -```bash -curl -4 -Iv --max-time 15 --proxy socks5h://127.0.0.1:1080 https://www.okx.com/api/v5/public/time -``` - -看到 `HTTP/2 200`(或至少 TLS 握手成功且返回 JSON)即 OK。 - ---- - -## 4. Python 虚拟环境(venv) - -在本机项目目录: - -```bash -cd /opt/crypto_monitor/crypto_monitor_okx - -python3 -m venv .venv -source .venv/bin/activate - -python -m pip install -U pip -pip install flask requests ccxt werkzeug PySocks Pillow -``` - -> 说明:本仓库当前没有 `requirements.txt`。如果你希望“完全复刻云服务器依赖”,可以在云服务器项目环境里执行 `pip freeze > requirements.txt` 带回本机再 `pip install -r requirements.txt`(记得删掉明显无关/体积巨大的包)。 - -建议减少 `.pyc` 垃圾文件(可选): - -```bash -export PYTHONDONTWRITEBYTECODE=1 -``` - ---- - -## 5. 配置环境变量(`.env.example` → `.env`) - -| 文件 | 是否进 Git | 说明 | -|------|------------|------| -| **`.env.example`** | ✅ 是 | 变量模板与注释,可随 `git pull` 更新 | -| **`.env`** | ❌ 否 | 本机真实配置;`app.py` **只读此文件** | - -### 5.1 首次配置 - -```bash -cd /opt/crypto_monitor/crypto_monitor_okx - -cp -n .env.example .env # 已存在 .env 时不覆盖 -nano .env -``` - -### 5.2 备份与 `git pull` - -- **`.env` 不在 Git 中**:`git pull` **不会**覆盖本地 `.env`。 -- 远端若更新 **`.env.example`**,pull 后请**手动**把新增变量补进你的 `.env`。 -- **升级前备份**:`cp .env .env.backup.$(date +%Y%m%d)`;恢复:`cp .env.backup.YYYYMMDD .env`。 -- **换机**:`scp` 复制 `.env`,或新机 `cp .env.example .env` 后重填。 - -**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/crypto_monitor_okx -source .venv/bin/activate -python app.py -``` - -浏览器访问:`http://127.0.0.1:5000`(或你在 `.env` 配的端口)。 - ---- - -## 7. 安装 PM2(Node) - -```bash -sudo npm i -g pm2 -pm2 -v -``` - ---- - -## 8. 用 PM2 启动 SSH SOCKS 隧道(推荐:密钥免交互) - -### 8.1 启动隧道进程 - -```bash -pm2 start "ssh" --name okx-socks-tunnel -- \ - -N -D 127.0.0.1:1080 okx-vps \ - -o ServerAliveInterval=30 -o ServerAliveCountMax=3 \ - -o ExitOnForwardFailure=yes -o BatchMode=yes -``` - -查看日志: - -```bash -pm2 logs okx-socks-tunnel --lines 200 -``` - -### 8.2 仍然验证 OKX - -```bash -curl -4 -Iv --max-time 15 --proxy socks5h://127.0.0.1:1080 https://www.okx.com/api/v5/public/time -``` - -### 8.3 开机自启(可选) - -```bash -pm2 save -pm2 startup -``` - ---- - -## 9. 用 PM2 启动 Flask(`app.py`) - -`pm2` 管理 Python 的常用方式是直接启动解释器: - -```bash -cd /opt/crypto_monitor/crypto_monitor_okx - -pm2 start /opt/crypto_monitor/crypto_monitor_okx/.venv/bin/python --name crypto-monitor -- \ - /opt/crypto_monitor/crypto_monitor_okx/app.py -``` - -> 若项目目录与上文不一致,请替换为实际绝对路径;或用 `readlink -f app.py` 得到绝对路径。 - -查看日志: - -```bash -pm2 logs crypto-monitor --lines 200 -``` - -保存进程列表: - -```bash -pm2 save -``` - ---- - -## 10. 常见问题排查(高频) - -### 10.1 OKX 仍然失败:先看隧道是否在 - -```bash -ss -lntp | grep 1080 || true -pm2 status -``` - -### 10.2 `pm2` 里的 `ssh` 立刻退出 - -常见原因: - -- 私钥权限不对(`chmod 600`) -- `~/.ssh/config` 写错 `HostName/User/IdentityFile` -- 开了 `BatchMode yes` 但仍需要密码(会失败) - -### 10.3 `ccxt` SOCKS 报错 / 代理不生效 - -本机 Python 依赖通常需要: - -```bash -source .venv/bin/activate -pip install PySocks -``` - -### 10.4 `.pyc` 很多导致同步慢 - -`.pyc` 是缓存,删除不影响功能: - -```bash -find . -type d -name __pycache__ -prune -exec rm -rf {} + -find . -type f -name "*.pyc" -delete -``` - ---- - -## 11. 推荐的启动顺序(固定习惯) - -1. `pm2` 启动 `okx-socks-tunnel` -2. `curl --proxy socks5h://127.0.0.1:1080 ...` 验证 OKX -3. `pm2` 启动 `crypto-monitor` - ---- - -## 12. 免责声明 - -交易所有合规与地区政策要求。请确保你的使用方式符合当地法律法规与交易所条款。本文仅描述网络与工程部署技术路径。 - - - - -写好了,脚本路径: - -- `scripts/fix_breakeven_labels.py` - -你在 Ubuntu 上这样用: - -1) 先预览(不写库): -```bash -python scripts/fix_breakeven_labels.py --db ./crypto.db --dry-run -``` - -2) 确认后执行: -```bash -python scripts/fix_breakeven_labels.py --db ./crypto.db --apply -``` - -默认修复条件就是你要的: -- `monitor_type='下单监控'` -- `result='止损'` -- `pnl_amount > 0` -- 改成 `result='保本止盈'` - -如果你想,我还可以再给你一条“先自动备份 DB 再执行”的一键命令。 \ No newline at end of file +# `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/crypto_monitor_okx + +# 可选:清理 Python 缓存,减少小文件传输 +find . -type d -name __pycache__ -prune -exec rm -rf {} + +find . -type f -name "*.pyc" -delete + +tar -czf crypto_monitor.tgz . +``` + +下载 `crypto_monitor.tgz` 到本机后解压: + +```bash +mkdir -p /opt/crypto_monitor/crypto_monitor_okx +cd /opt/crypto_monitor +tar -xzf crypto_monitor.tgz -C crypto_monitor_okx +cd crypto_monitor_okx +cp -n .env.example .env # 若尚无 .env +``` + +--- + +## 2. 配置 SSH 私钥与 `~/.ssh/config`(推荐) + +把私钥放到本机(示例:`~/.ssh/vps1.pem`),并设置权限: + +```bash +mkdir -p ~/.ssh +chmod 700 ~/.ssh +mv ~/Downloads/vps1.pem ~/.ssh/vps1.pem +chmod 600 ~/.ssh/vps1.pem +``` + +编辑 `~/.ssh/config`(没有就创建),添加: + +```sshconfig +Host okx-vps + HostName 47.76.87.111 + User root + IdentityFile ~/.ssh/vps1.pem + IdentitiesOnly yes + ServerAliveInterval 30 + ServerAliveCountMax 3 + ExitOnForwardFailure yes + BatchMode yes +``` + +测试: + +```bash +ssh okx-vps true +``` + +> 如果你还没完全切到密钥登录(还会交互要密码),先把 `BatchMode yes` 注释掉,等密钥登录稳定后再打开. + +--- + +## 3. 先手工验证:SSH SOCKS + OKX API + +### 3.1 开一个本地 SOCKS(1080) + +```bash +ssh -N -D 127.0.0.1:1080 okx-vps +``` + +保持该进程运行(另开终端继续下面步骤). + +### 3.2 验证 OKX 走 SOCKS 可用 + +```bash +curl -4 -Iv --max-time 15 --proxy socks5h://127.0.0.1:1080 https://www.okx.com/api/v5/public/time +``` + +看到 `HTTP/2 200`(或至少 TLS 握手成功且返回 JSON)即 OK. + +--- + +## 4. Python 虚拟环境(venv) + +在本机项目目录: + +```bash +cd /opt/crypto_monitor/crypto_monitor_okx + +python3 -m venv .venv +source .venv/bin/activate + +python -m pip install -U pip +pip install flask requests ccxt werkzeug PySocks Pillow +``` + +> 说明:本仓库当前没有 `requirements.txt`.如果你希望“完全复刻云服务器依赖”,可以在云服务器项目环境里执行 `pip freeze > requirements.txt` 带回本机再 `pip install -r requirements.txt`(记得删掉明显无关/体积巨大的包). + +建议减少 `.pyc` 垃圾文件(可选): + +```bash +export PYTHONDONTWRITEBYTECODE=1 +``` + +--- + +## 5. 配置环境变量(`.env.example` → `.env`) + +| 文件 | 是否进 Git | 说明 | +|------|------------|------| +| **`.env.example`** | ✅ 是 | 变量模板与注释,可随 `git pull` 更新 | +| **`.env`** | ❌ 否 | 本机真实配置;`app.py` **只读此文件** | + +### 5.1 首次配置 + +```bash +cd /opt/crypto_monitor/crypto_monitor_okx + +cp -n .env.example .env # 已存在 .env 时不覆盖 +nano .env +``` + +### 5.2 备份与 `git pull` + +- **`.env` 不在 Git 中**:`git pull` **不会**覆盖本地 `.env`. +- 远端若更新 **`.env.example`**,pull 后请**手动**把新增变量补进你的 `.env`. +- **升级前备份**:`cp .env .env.backup.$(date +%Y%m%d)`;恢复:`cp .env.backup.YYYYMMDD .env`. +- **换机**:`scp` 复制 `.env`,或新机 `cp .env.example .env` 后重填. + +**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/crypto_monitor_okx +source .venv/bin/activate +python app.py +``` + +浏览器访问:`http://127.0.0.1:5000`(或你在 `.env` 配的端口). + +--- + +## 7. 安装 PM2(Node) + +```bash +sudo npm i -g pm2 +pm2 -v +``` + +--- + +## 8. 用 PM2 启动 SSH SOCKS 隧道(推荐:密钥免交互) + +### 8.1 启动隧道进程 + +```bash +pm2 start "ssh" --name okx-socks-tunnel -- \ + -N -D 127.0.0.1:1080 okx-vps \ + -o ServerAliveInterval=30 -o ServerAliveCountMax=3 \ + -o ExitOnForwardFailure=yes -o BatchMode=yes +``` + +查看日志: + +```bash +pm2 logs okx-socks-tunnel --lines 200 +``` + +### 8.2 仍然验证 OKX + +```bash +curl -4 -Iv --max-time 15 --proxy socks5h://127.0.0.1:1080 https://www.okx.com/api/v5/public/time +``` + +### 8.3 开机自启(可选) + +```bash +pm2 save +pm2 startup +``` + +--- + +## 9. 用 PM2 启动 Flask(`app.py`) + +`pm2` 管理 Python 的常用方式是直接启动解释器: + +```bash +cd /opt/crypto_monitor/crypto_monitor_okx + +pm2 start /opt/crypto_monitor/crypto_monitor_okx/.venv/bin/python --name crypto-monitor -- \ + /opt/crypto_monitor/crypto_monitor_okx/app.py +``` + +> 若项目目录与上文不一致,请替换为实际绝对路径;或用 `readlink -f app.py` 得到绝对路径. + +查看日志: + +```bash +pm2 logs crypto-monitor --lines 200 +``` + +保存进程列表: + +```bash +pm2 save +``` + +--- + +## 10. 常见问题排查(高频) + +### 10.1 OKX 仍然失败:先看隧道是否在 + +```bash +ss -lntp | grep 1080 || true +pm2 status +``` + +### 10.2 `pm2` 里的 `ssh` 立刻退出 + +常见原因: + +- 私钥权限不对(`chmod 600`) +- `~/.ssh/config` 写错 `HostName/User/IdentityFile` +- 开了 `BatchMode yes` 但仍需要密码(会失败) + +### 10.3 `ccxt` SOCKS 报错 / 代理不生效 + +本机 Python 依赖通常需要: + +```bash +source .venv/bin/activate +pip install PySocks +``` + +### 10.4 `.pyc` 很多导致同步慢 + +`.pyc` 是缓存,删除不影响功能: + +```bash +find . -type d -name __pycache__ -prune -exec rm -rf {} + +find . -type f -name "*.pyc" -delete +``` + +--- + +## 11. 推荐的启动顺序(固定习惯) + +1. `pm2` 启动 `okx-socks-tunnel` +2. `curl --proxy socks5h://127.0.0.1:1080 ...` 验证 OKX +3. `pm2` 启动 `crypto-monitor` + +--- + +## 12. 免责声明 + +交易所有合规与地区政策要求.请确保你的使用方式符合当地法律法规与交易所条款.本文仅描述网络与工程部署技术路径. + + + + +写好了,脚本路径: + +- `scripts/fix_breakeven_labels.py` + +你在 Ubuntu 上这样用: + +1) 先预览(不写库): +```bash +python scripts/fix_breakeven_labels.py --db ./crypto.db --dry-run +``` + +2) 确认后执行: +```bash +python scripts/fix_breakeven_labels.py --db ./crypto.db --apply +``` + +默认修复条件就是你要的: +- `monitor_type='下单监控'` +- `result='止损'` +- `pnl_amount > 0` +- 改成 `result='保本止盈'` + +如果你想,我还可以再给你一条“先自动备份 DB 再执行”的一键命令. \ No newline at end of file diff --git a/deploy/README.md b/deploy/README.md index 82dc397..9792cd1 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,15 +1,15 @@ -# 环境一键部署(Ubuntu / root /opt) +# 环境一键部署(Ubuntu / root /opt) -在 **`/opt/crypto_monitor`** 下以 **root** 为各子项目创建 Python **`.venv`**、安装依赖、从 `.env.example` 生成 `.env`(不覆盖已有),并可选安装 **PM2**。 +在 **`/opt/crypto_monitor`** 下以 **root** 为各子项目创建 Python **`.venv`**,安装依赖,从 `.env.example` 生成 `.env`(不覆盖已有),并可选安装 **PM2**. -完整系统要求(Python / Node / PM2 版本、启动顺序)见 **[docs/ubuntu-server.md](../docs/ubuntu-server.md)**。 +完整系统要求(Python / Node / PM2 版本,启动顺序)见 **[docs/ubuntu-server.md](../docs/ubuntu-server.md)**. --- ## 前置条件 -- **Ubuntu 22.04 / 24.04**,用户 **root** -- 已安装 **git**,仓库位于 **`/opt/crypto_monitor`** +- **Ubuntu 22.04 / 24.04**,用户 **root** +- 已安装 **git**,仓库位于 **`/opt/crypto_monitor`** ```bash apt update @@ -26,7 +26,7 @@ cd /opt/crypto_monitor bash deploy/setup_env.sh --install-system-deps ``` -常用参数: +常用参数: ```bash bash deploy/setup_env.sh --only binance,gate # 仅部分子项目 @@ -35,9 +35,9 @@ bash deploy/setup_env.sh --skip-pm2 # 不尝试安装 pm2 bash deploy/setup_env.sh --skip-env-copy # 不复制 .env.example ``` -**整目录重装**(保留 `.env`、清库、去脏 PM2)见 **[reinstall-plan-b.md](./reinstall-plan-b.md)**,执行 `bash deploy/reinstall.sh`。与 `setup_env.sh` 独立,不影响首次一键安装。 +**整目录重装**(保留 `.env`,清库,去脏 PM2)见 **[reinstall-plan-b.md](./reinstall-plan-b.md)**,执行 `bash deploy/reinstall.sh`.与 `setup_env.sh` 独立,不影响首次一键安装. -若在其它环境编辑过脚本后报 `pipefail` 错误,先转 LF: +若在其它环境编辑过脚本后报 `pipefail` 错误,先转 LF: ```bash sed -i 's/\r$//' deploy/setup_env.sh @@ -53,16 +53,16 @@ sed -i 's/\r$//' deploy/setup_env.sh | `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` | +| 部署密钥 | `python3 scripts/bootstrap_deploy_secrets.py`(不覆盖已有值) | +| 目录 | `static/images`,`static/images/order_charts` | | PM2 | 已装 Node 时 `npm install -g pm2` | --- ## 部署之后 -1. 编辑各子目录 **`.env`**(交易所 API、企业微信等;**AI 请在中控系统设置 → AI 配置**)。 -2. **仅用 PM2 常驻**(见 [docs/ubuntu-server.md](../docs/ubuntu-server.md) §3): +1. 编辑各子目录 **`.env`**(交易所 API,企业微信等;**AI 请在中控系统设置 → AI 配置**). +2. **仅用 PM2 常驻**(见 [docs/ubuntu-server.md](../docs/ubuntu-server.md) §3): ```bash cd /opt/crypto_monitor/crypto_monitor_binance && pm2 start ecosystem.config.cjs @@ -71,13 +71,13 @@ sed -i 's/\r$//' deploy/setup_env.sh pm2 save ``` - 或一条命令:`bash deploy/pm2_start_all.sh` + 或一条命令:`bash deploy/pm2_start_all.sh` -3. 三所 `.env` 同步脚本见 **[docs/env-sync-scripts.md](../docs/env-sync-scripts.md)**。 +3. 三所 `.env` 同步脚本见 **[docs/env-sync-scripts.md](../docs/env-sync-scripts.md)**. --- ## 依赖说明 -- 三个监控子项目共用根目录 **[requirements.txt](../requirements.txt)**。 -- 走 SOCKS 须 **PySocks**(已包含在 requirements 中)。 +- 三个监控子项目共用根目录 **[requirements.txt](../requirements.txt)**. +- 走 SOCKS 须 **PySocks**(已包含在 requirements 中). diff --git a/deploy/pm2_start_all.sh b/deploy/pm2_start_all.sh index 0a98e6e..560281b 100644 --- a/deploy/pm2_start_all.sh +++ b/deploy/pm2_start_all.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash -# 按推荐顺序启动三所 Flask + 中控 hub/三 agent(PM2)。 -# 用法(仓库根或任意目录): +# 按推荐顺序启动三所 Flask + 中控 hub/三 agent(PM2). +# 用法(仓库根或任意目录): # bash deploy/pm2_start_all.sh # -# 与 deploy/setup_env.sh 独立:setup_env 只建 venv;本脚本负责 PM2 启动。 +# 与 deploy/setup_env.sh 独立:setup_env 只建 venv;本脚本负责 PM2 启动. set -e set -u if [ -n "${BASH_VERSION:-}" ]; then @@ -26,7 +26,7 @@ start_one() { } if ! command -v pm2 >/dev/null 2>&1; then - echo "未找到 pm2,请先安装 Node.js 与 pm2(见 docs/ubuntu-server.md)" >&2 + echo "未找到 pm2,请先安装 Node.js 与 pm2(见 docs/ubuntu-server.md)" >&2 exit 1 fi @@ -37,5 +37,5 @@ start_one manual_trading_hub pm2 save 2>/dev/null || true echo "" -echo "PM2 进程:" +echo "PM2 进程:" pm2 list diff --git a/deploy/pull_and_restart.sh b/deploy/pull_and_restart.sh index 7f10ff3..15b8c66 100644 --- a/deploy/pull_and_restart.sh +++ b/deploy/pull_and_restart.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# 服务器上拉代码、同步 env、应用强制清仓策略并重启 PM2。 -# 用法(/opt/crypto_monitor 下 root): +# 服务器上拉代码,同步 env,应用强制清仓策略并重启 PM2. +# 用法(/opt/crypto_monitor 下 root): # bash deploy/pull_and_restart.sh # bash deploy/pull_and_restart.sh --dry-run set -euo pipefail diff --git a/deploy/reinstall-plan-b.md b/deploy/reinstall-plan-b.md index d7ea9d7..204c32d 100644 --- a/deploy/reinstall-plan-b.md +++ b/deploy/reinstall-plan-b.md @@ -1,32 +1,32 @@ -# Plan B:整目录重装(生产清库) +# Plan B:整目录重装(生产清库) -适用于:**保留三所 `.env` 与中控配置,丢弃旧代码、旧 SQLite、脏 PM2 名单**(例如移除 `gate_bot` 后偶发重启)。 +适用于:**保留三所 `.env` 与中控配置,丢弃旧代码,旧 SQLite,脏 PM2 名单**(例如移除 `gate_bot` 后偶发重启). -与 **[setup_env.sh](./setup_env.sh)** 的关系: +与 **[setup_env.sh](./setup_env.sh)** 的关系: | 脚本 | 用途 | |------|------| -| `setup_env.sh` | **首次安装 / 日常**:建 venv、装依赖、从 `.env.example` 复制(**不变**) | -| `reinstall.sh` | **整目录重装**:备份 → 移走旧目录 → `git clone` → 调 `setup_env.sh` → 恢复配置 → PM2 | +| `setup_env.sh` | **首次安装 / 日常**:建 venv,装依赖,从 `.env.example` 复制(**不变**) | +| `reinstall.sh` | **整目录重装**:备份 → 移走旧目录 → `git clone` → 调 `setup_env.sh` → 恢复配置 → PM2 | --- -## 一键执行(推荐) +## 一键执行(推荐) -在现有服务器安装上以 **root** 执行: +在现有服务器安装上以 **root** 执行: ```bash cd /opt/crypto_monitor bash deploy/reinstall.sh --yes ``` -交互确认(不加 `--yes`): +交互确认(不加 `--yes`): ```bash bash deploy/reinstall.sh ``` -仅预览步骤: +仅预览步骤: ```bash bash deploy/reinstall.sh --dry-run @@ -39,20 +39,20 @@ 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` 备份目录 + - `manual_trading_hub/hub_settings.json`(若有) + - 可选:仓库内 `one_shot` 备份目录 2. **`pm2 stop all` + `pm2 delete all`** 3. **`mv /opt/crypto_monitor /opt/crypto_monitor.old.时间戳`** -4. **`git clone`** 到 `/opt/crypto_monitor`(默认 `main`) +4. **`git clone`** 到 `/opt/crypto_monitor`(默认 `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` 跳过) +9. 为三所重装 **每日 0 点备份 cron**(可用 `--no-backup-cron` 跳过) -**不会备份/恢复**:`crypto.db`、hub `data/*.db`、`static/images`(符合「全新启动」)。 +**不会备份/恢复**:`crypto.db`,hub `data/*.db`,`static/images`(符合「全新启动」). -**不会动**:宝塔/Nginx 反代、SSH SOCKS 隧道(tmux 内)。 +**不会动**:宝塔/Nginx 反代,SSH SOCKS 隧道(tmux 内). --- @@ -77,13 +77,13 @@ pm2 list curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:5100/ ``` -浏览器:中控 `/monitor` 登录,三所 LINK 绿,监控区为空库。 +浏览器:中控 `/monitor` 登录,三所 LINK 绿,监控区为空库. --- ## 回滚 -旧目录默认保留为 `/opt/crypto_monitor.old.时间戳`,配置在 `/root/backups/pre-reinstall-*`: +旧目录默认保留为 `/opt/crypto_monitor.old.时间戳`,配置在 `/root/backups/pre-reinstall-*`: ```bash pm2 delete all @@ -92,7 +92,7 @@ mv /opt/crypto_monitor.old.XXXXXXXX /opt/crypto_monitor bash /opt/crypto_monitor/deploy/pm2_start_all.sh ``` -确认新环境稳定后再删 `.old.*` 目录。 +确认新环境稳定后再删 `.old.*` 目录. --- @@ -100,7 +100,7 @@ bash /opt/crypto_monitor/deploy/pm2_start_all.sh | 文件 | 说明 | |------|------| -| [pm2_start_all.sh](./pm2_start_all.sh) | 按顺序 PM2 启动三所 + hub(setup_env 之后手动用) | +| [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 条目 | --- diff --git a/deploy/reinstall.sh b/deploy/reinstall.sh index 5ac8738..4d5386e 100644 --- a/deploy/reinstall.sh +++ b/deploy/reinstall.sh @@ -1,11 +1,11 @@ #!/usr/bin/env bash -# Plan B:整目录重装 /opt/crypto_monitor(备份 .env → 移走旧目录 → git clone → setup_env → 恢复配置 → PM2) +# Plan B:整目录重装 /opt/crypto_monitor(备份 .env → 移走旧目录 → git clone → setup_env → 恢复配置 → PM2) # -# 与 deploy/setup_env.sh 分工: -# setup_env.sh — 首次 / 日常:建 venv、装依赖、复制 .env.example(一键安装,不变) -# reinstall.sh — 生产清库重装:保留密钥与 hub 配置,丢弃旧代码/旧库/脏 PM2 +# 与 deploy/setup_env.sh 分工: +# setup_env.sh — 首次 / 日常:建 venv,装依赖,复制 .env.example(一键安装,不变) +# reinstall.sh — 生产清库重装:保留密钥与 hub 配置,丢弃旧代码/旧库/脏 PM2 # -# 用法(在现有安装目录以 root 执行): +# 用法(在现有安装目录以 root 执行): # cd /opt/crypto_monitor # bash deploy/reinstall.sh # 交互确认 # bash deploy/reinstall.sh --yes # 跳过确认 @@ -109,7 +109,7 @@ backup_configs() { fi done if [[ "${copied}" -eq 0 ]]; then - echo "错误: 未备份到任何配置文件,请检查 ${src_root}" >&2 + echo "错误: 未备份到任何配置文件,请检查 ${src_root}" >&2 exit 1 fi if [[ -f "${src_root}/scripts/one_shot_backup_config_before_cleanup.py" ]]; then @@ -174,20 +174,20 @@ install_instance_backup_cron() { } verify_pm2() { - log "预期 PM2 进程(7 个): crypto_binance crypto_gate crypto_okx manual-trading-hub manual-agent-*" + 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" + log "警告: PM2 列表仍含 gate_bot 相关进程,请 pm2 delete 后 pm2 save" fi } # --- 前置检查 --- if [[ "$(id -u)" -ne 0 ]]; then - echo "请使用 root 执行(推荐路径 ${INSTALL_ROOT})" >&2 + echo "请使用 root 执行(推荐路径 ${INSTALL_ROOT})" >&2 exit 1 fi @@ -197,7 +197,7 @@ if [[ ! -f "${REPO_ROOT}/deploy/setup_env.sh" ]]; then fi if [[ "${REPO_ROOT}" != "${INSTALL_ROOT}" ]]; then - log "提示: 当前仓库 ${REPO_ROOT} 与 INSTALL_ROOT=${INSTALL_ROOT} 不一致;将备份当前仓库并克隆到 INSTALL_ROOT" + log "提示: 当前仓库 ${REPO_ROOT} 与 INSTALL_ROOT=${INSTALL_ROOT} 不一致;将备份当前仓库并克隆到 INSTALL_ROOT" fi STAMP="$(TZ="${TZ_NAME}" date +%Y%m%d-%H%M%S)" @@ -216,9 +216,9 @@ 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 / 图片。" +echo " 将停止并 delete 全部 PM2 进程;不备份 crypto.db / hub data / 图片." -if ! confirm "确认执行 Plan B 整目录重装?"; then +if ! confirm "确认执行 Plan B 整目录重装?"; then log "已取消" exit 0 fi @@ -235,7 +235,7 @@ if command -v pm2 >/dev/null 2>&1; then run pm2 stop all || true run pm2 delete all || true else - log "未安装 pm2,跳过" + log "未安装 pm2,跳过" fi # --- 3. 移走旧目录 --- @@ -248,7 +248,7 @@ if [[ -d "${INSTALL_ROOT}" ]]; then mv "${INSTALL_ROOT}" "${OLD_DIR}" fi else - log "目标目录不存在,跳过 mv" + log "目标目录不存在,跳过 mv" fi # --- 4. 克隆 --- @@ -260,7 +260,7 @@ else git clone -b "${GIT_BRANCH}" "${GIT_URL}" "${INSTALL_ROOT}" fi -# --- 5. setup_env(一键安装逻辑,不复制 .env)--- +# --- 5. setup_env(一键安装逻辑,不复制 .env)--- step "重建 Python 虚拟环境 (setup_env.sh)" if [[ "${DRY_RUN}" -eq 1 ]]; then @@ -281,10 +281,10 @@ 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" + log "未安装 pm2;请手动: bash ${INSTALL_ROOT}/deploy/pm2_start_all.sh" fi -# --- 8. 定时备份 cron(可选)--- +# --- 8. 定时备份 cron(可选)--- if [[ "${INSTALL_BACKUP_CRON}" -eq 1 ]]; then step "安装三所每日备份 cron" @@ -297,14 +297,14 @@ step "完成" verify_pm2 echo "" echo "备份: ${BACKUP_DIR}" -echo "旧目录(确认无误后可删): ${OLD_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 " 浏览器打开中控 /monitor,确认三所 LINK 正常" echo "" -echo "回滚(未删旧目录时):" +echo "回滚(未删旧目录时):" echo " pm2 delete all" echo " rm -rf ${INSTALL_ROOT}" echo " mv ${OLD_DIR} ${INSTALL_ROOT}" diff --git a/deploy/sanitize_hub_settings.py b/deploy/sanitize_hub_settings.py index 557ae91..e3bcb17 100644 --- a/deploy/sanitize_hub_settings.py +++ b/deploy/sanitize_hub_settings.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""重装后清理 hub_settings.json 中已废弃的 gate_bot / 第四账户条目。""" +"""重装后清理 hub_settings.json 中已废弃的 gate_bot / 第四账户条目.""" from __future__ import annotations import json @@ -92,7 +92,7 @@ def main(argv: list[str] | None = None) -> int: for line in removed: print(f" - {line}") else: - print("无需修改(未发现 gate_bot / 第四账户)") + print("无需修改(未发现 gate_bot / 第四账户)") return 0 diff --git a/deploy/setup_env.sh b/deploy/setup_env.sh index e5b7f2c..f8aa636 100644 --- a/deploy/setup_env.sh +++ b/deploy/setup_env.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# crypto_monitor 一键环境部署(Ubuntu / root /opt/crypto_monitor) +# crypto_monitor 一键环境部署(Ubuntu / root /opt/crypto_monitor) # # 用法: # bash deploy/setup_env.sh @@ -10,7 +10,7 @@ # set -e set -u -# 避免 Windows CRLF 导致 set -euo pipefail 一行报错;pipefail 仅 bash 支持 +# 避免 Windows CRLF 导致 set -euo pipefail 一行报错;pipefail 仅 bash 支持 if [ -n "${BASH_VERSION:-}" ]; then set -o pipefail fi @@ -69,7 +69,7 @@ find_python() { echo python return fi - echo "未找到 python3/python,请先安装 Python 3.10+" >&2 + echo "未找到 python3/python,请先安装 Python 3.10+" >&2 exit 1 } @@ -81,7 +81,7 @@ check_python_version() { major="${ver%%.*}" minor="${ver#*.}" if [[ "${major}" -lt 3 ]] || [[ "${major}" -eq 3 && "${minor}" -lt 10 ]]; then - echo "需要 Python 3.10+,当前: ${ver}" >&2 + echo "需要 Python 3.10+,当前: ${ver}" >&2 exit 1 fi echo "Python: $("${py}" --version 2>&1)" @@ -109,11 +109,11 @@ install_debian_venv_packages() { 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 + echo " 未检测到 apt-get,请手动安装 python${ver}-venv 与 python3-pip" >&2 return 1 fi if [[ "$(id -u)" -ne 0 ]]; then - echo " 需要 root 安装系统包,请执行:" >&2 + 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 @@ -131,7 +131,7 @@ ensure_venv_prereqs() { if check_venv_available "${py}"; then return 0 fi - echo " 当前 Python 无法创建 venv(缺少 ensurepip,常见于未安装 python*-venv)" >&2 + 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 @@ -171,7 +171,7 @@ setup_monitor() { local dir_name="$1" local proj="${REPO_ROOT}/${dir_name}" if [[ ! -d "${proj}" ]]; then - echo " 跳过(目录不存在): ${dir_name}" + echo " 跳过(目录不存在): ${dir_name}" return fi step "${dir_name}" @@ -188,7 +188,7 @@ setup_monitor() { elif [[ -f .env ]]; then echo " 保留已有 .env" else - echo " 无 .env.example,请手动配置 .env" + echo " 无 .env.example,请手动配置 .env" fi fi mkdir -p static/images/order_charts @@ -198,7 +198,7 @@ setup_monitor() { setup_hub() { local proj="${REPO_ROOT}/manual_trading_hub" if [[ ! -d "${proj}" ]]; then - echo " 跳过 hub(目录不存在)" + echo " 跳过 hub(目录不存在)" return fi step "manual_trading_hub" @@ -219,9 +219,9 @@ install_pm2() { if [[ "${SKIP_PM2}" -eq 1 ]]; then return fi - step "PM2(可选)" + step "PM2(可选)" if ! command -v node >/dev/null 2>&1; then - echo " 未检测到 Node.js,跳过。安装后执行: npm install -g pm2" + echo " 未检测到 Node.js,跳过.安装后执行: npm install -g pm2" return fi if command -v pm2 >/dev/null 2>&1; then @@ -249,15 +249,15 @@ should_include hub && setup_hub install_pm2 -step "部署密钥(首次自动生成,不覆盖已有)" +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)" + 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 "部署完成.下一步:" +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 index f750a9d..b34abb5 100644 --- a/docs/account-risk-cooldown.md +++ b/docs/account-risk-cooldown.md @@ -1,130 +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` +# 账户冷静期 / 日冻结风控 + +三所实例(币安 / 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 index 0bda3fc..3a6ff73 100644 --- a/docs/auto-transfer-daily.md +++ b/docs/auto-transfer-daily.md @@ -1,45 +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 -``` +# 每日自动划转(三所统一) + +## 行为 + +在 `.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 index 95936cb..0e92295 100644 --- a/docs/daily-open-limit.md +++ b/docs/daily-open-limit.md @@ -1,81 +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` +# 单日开仓次数限制(三所统一) + +各交易实例(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 index f23c496..222b0ae 100644 --- a/docs/env-sync-scripts.md +++ b/docs/env-sync-scripts.md @@ -1,139 +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) +# 三所 `.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 index 229b00a..ada543c 100644 --- a/docs/env配置说明.md +++ b/docs/env配置说明.md @@ -1,8 +1,8 @@ # env 配置页说明 -本文档描述各交易实例 Web 端 **「env 配置」** 页展示项、含义、生效方式,以及与系统设置、中控密钥的分工。 +本文档描述各交易实例 Web 端 **「env 配置」** 页展示项,含义,生效方式,以及与系统设置,中控密钥的分工. -> **不在本页展示的配置**(服务端口、数据库路径、关键位门控、轮询间隔等)仍保存在实例目录 `.env` 中,需 SSH 编辑或部署脚本维护,见文末「隐藏项」。 +> **不在本页展示的配置**(服务端口,数据库路径,关键位门控,轮询间隔等)仍保存在实例目录 `.env` 中,需 SSH 编辑或部署脚本维护,见文末「隐藏项」. --- @@ -10,66 +10,66 @@ | 原则 | 说明 | |------|------| -| **只展示运营相关项** | 不暴露全量 `.env`,避免误改基础设施 | -| **前端仅中文** | 页面只显示中文标签与说明,不显示 `APP_XXX` 等变量名 | +| **只展示运营相关项** | 不暴露全量 `.env`,避免误改基础设施 | +| **前端仅中文** | 页面只显示中文标签与说明,不显示 `APP_XXX` 等变量名 | | **账户密码不进本页** | 登录用户名/密码在 **系统设置 → 账户密码修改** 中维护 | -| **密钥自动托管** | 中控通信密钥、登录会话密钥由 **首次部署脚本自动生成并写入**(一次生成、不轮换),本页不提供编辑 | -| **AI 仅中控配置** | OpenAI / Ollama 等 AI 项已从中控 **系统设置 → AI 配置** 统一维护并同步三所,本页不再展示 | -| **保存标注** | 每项标注「保存即生效」或「需重启」;含需重启项时可用「保存并重启」 | +| **密钥自动托管** | 中控通信密钥,登录会话密钥由 **首次部署脚本自动生成并写入**(一次生成,不轮换),本页不提供编辑 | +| **AI 仅中控配置** | OpenAI / Ollama 等 AI 项已从中控 **系统设置 → AI 配置** 统一维护并同步三所,本页不再展示 | +| **保存标注** | 每项标注「保存即生效」或「需重启」;含需重启项时可用「保存并重启」 | --- -## 2. 密钥分工(自动生成,本页不可见) +## 2. 密钥分工(自动生成,本页不可见) -部署时由脚本统一生成并写入对应 `.env`(已有值则跳过,避免覆盖生产环境)。 +部署时由脚本统一生成并写入对应 `.env`(已有值则跳过,避免覆盖生产环境). | 类型 | 环境变量 | 写入位置 | 用途 | |------|----------|----------|------| -| **中控通信密钥** | `HUB_BRIDGE_TOKEN` | 中控 `manual_trading_hub/.env` + 三实例 `.env`(**相同值**) | 中控调用实例 API(`X-Hub-Token`)、iframe SSO 签发与校验 | -| **登录会话密钥** | `FLASK_SECRET_KEY` | 三实例 `.env`(**三所相同**) | Flask Session 签名;与网页登录态相关,与中控密钥 **分离** | +| **中控通信密钥** | `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` | 中控网页登录;**仅在中控系统设置改密** | +| **登录账号** | `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`。 -- 首次部署可生成随机强密码;用户日后在 **系统设置** 改密,不经过本页。 +- **长期密钥一次生成,不轮换**:`setup_env.sh` 末尾调用 `scripts/bootstrap_deploy_secrets.py`;已有非占位值不会被覆盖. +- **SSO 链接不变**:仍为中控每次签发,默认 2 小时有效,单次使用(`HUB_SSO_TTL_SEC`),与长期 `HUB_BRIDGE_TOKEN` 分离. +- 经中控 iframe / SSO 打开实例时,可免输实例密码;直链 IP/域名仍走 `/login`. +- 首次部署可生成随机强密码;用户日后在 **系统设置** 改密,不经过本页. --- -## 3. 页面布局(三列卡片) +## 3. 页面布局(三列卡片) | 列 1 | 列 2 | 列 3 | |------|------|------| | 交易所与实盘 | 企业微信 | 交易执行 | | 交易风控 | 账户冷静期 | 自动划转 | -| 当日资金 | 期权账户(仅 OKX) | | +| 当日资金 | 期权账户(仅 OKX) | | -> **AI 复盘**(OpenAI / Ollama)已移至中控 **系统设置 → AI 配置**,保存后强制同步三所 `.env`。详见 [中控AI与密钥配置.md](./中控AI与密钥配置.md)。 +> **AI 复盘**(OpenAI / Ollama)已移至中控 **系统设置 → AI 配置**,保存后强制同步三所 `.env`.详见 [中控AI与密钥配置.md](./中控AI与密钥配置.md). -Binance / Gate 无期权模块时,第三列最后一格不显示或显示「本所无期权」。 +Binance / Gate 无期权模块时,第三列最后一格不显示或显示「本所无期权」. --- -## 4. 各卡片字段(中文展示名) +## 4. 各卡片字段(中文展示名) ### 4.1 交易所与实盘 | 中文名 | 说明 | 重启 | |--------|------|------| -| 开启实盘下单 | 关闭时仅走本地流程,不向交易所发单 | 需重启 | +| 开启实盘下单 | 关闭时仅走本地流程,不向交易所发单 | 需重启 | | API Key | 永续子账户 API Key | 需重启 | | API Secret | 永续子账户 Secret | 需重启 | | API Passphrase | 仅 OKX 显示 | 需重启 | | 保证金模式 | 全仓 / 逐仓 | 需重启 | -| 持仓模式 | 双向 / 单向净持仓等(按所) | 需重启 | -| 仓位查询类型 | 仅 OKX:如 SWAP | 需重启 | +| 持仓模式 | 双向 / 单向净持仓等(按所) | 需重启 | +| 仓位查询类型 | 仅 OKX:如 SWAP | 需重启 | | 账户备注 | 企业微信推送中显示的交易所备注 | 保存即生效 | -**本卡片不包含**:网页登录账号密码、是否关闭登录校验、中控通信密钥。 +**本卡片不包含**:网页登录账号密码,是否关闭登录校验,中控通信密钥. --- @@ -77,16 +77,16 @@ Binance / Gate 无期权模块时,第三列最后一格不显示或显示「 | 中文名 | 说明 | |--------|------| -| 机器人 Webhook | 行情、风控、提醒推送地址 | -| 推送超时(秒) | 可选,默认 10 | +| 机器人 Webhook | 行情,风控,提醒推送地址 | +| 推送超时(秒) | 可选,默认 10 | --- -### 4.3 AI 复盘(已移至中控) +### 4.3 AI 复盘(已移至中控) -AI 相关环境变量(`AI_PROVIDER`、`OPENAI_*`、`OLLAMA_*`、`AI_MODEL`、`AI_TIMEOUT_SECONDS`)**不再在本页展示**。 +AI 相关环境变量(`AI_PROVIDER`,`OPENAI_*`,`OLLAMA_*`,`AI_MODEL`,`AI_TIMEOUT_SECONDS`)**不再在本页展示**. -请在中控 **系统设置 → AI 配置** 修改;保存后写入中控 `.env` 并 **强制同步** 至 OKX / Binance / Gate 三实例。详见 [中控AI与密钥配置.md](./中控AI与密钥配置.md)。 +请在中控 **系统设置 → AI 配置** 修改;保存后写入中控 `.env` 并 **强制同步** 至 OKX / Binance / Gate 三实例.详见 [中控AI与密钥配置.md](./中控AI与密钥配置.md). --- @@ -103,23 +103,23 @@ AI 相关环境变量(`AI_PROVIDER`、`OPENAI_*`、`OLLAMA_*`、`AI_MODEL`、` | 允许方向 | 多 / 空 / 双向 | | 币种白名单开关 | | | 白名单币种 | 逗号分隔 | -| 交易日切点(北京时间) | 默认 8 点 | +| 交易日切点(北京时间) | 默认 8 点 | | 切点前禁止新开仓 | | | 最大同时持仓 | | | 人工最低盈亏比 | | | 强制清仓开关 | | -| 强制清仓整点(北京) | | +| 强制清仓整点(北京) | | --- -### 4.5 交易风控(日内开仓) +### 4.5 交易风控(日内开仓) | 中文名 | 说明 | |--------|------| -| 单日开仓提醒阈值 | 达到次数后 AI 克制提醒(不拦单) | -| 单日开仓硬上限 | 0 表示不启用;达到后禁止新开仓 | +| 单日开仓提醒阈值 | 达到次数后 AI 克制提醒(不拦单) | +| 单日开仓硬上限 | 0 表示不启用;达到后禁止新开仓 | -详见 [daily-open-limit.md](./daily-open-limit.md)。 +详见 [daily-open-limit.md](./daily-open-limit.md). --- @@ -128,12 +128,12 @@ AI 相关环境变量(`AI_PROVIDER`、`OPENAI_*`、`OLLAMA_*`、`AI_MODEL`、` | 中文名 | 说明 | |--------|------| | 冷静期总开关 | | -| 手动平仓冷静(小时) | | -| 复盘情绪冷静(小时) | | +| 手动平仓冷静(小时) | | +| 复盘情绪冷静(小时) | | | 日手动平仓次数上限 | | | 情绪标签日冻结 | | -详见 [account-risk-cooldown.md](./account-risk-cooldown.md)。 +详见 [account-risk-cooldown.md](./account-risk-cooldown.md). --- @@ -142,13 +142,13 @@ AI 相关环境变量(`AI_PROVIDER`、`OPENAI_*`、`OLLAMA_*`、`AI_MODEL`、` | 中文名 | 说明 | |--------|------| | 启用自动划转 | | -| 目标余额(U) | 交易账户目标 USDT | +| 目标余额(U) | 交易账户目标 USDT | | 划出账户 | funding / swap | | 划入账户 | swap / funding | -| 执行整点(北京时间) | | +| 执行整点(北京时间) | | | 划转币种 | 默认 USDT | -详见 [auto-transfer-daily.md](./auto-transfer-daily.md)。 +详见 [auto-transfer-daily.md](./auto-transfer-daily.md). --- @@ -156,53 +156,53 @@ AI 相关环境变量(`AI_PROVIDER`、`OPENAI_*`、`OLLAMA_*`、`AI_MODEL`、` | 中文名 | 说明 | |--------|------| -| 日起始基数(U) | | -| 回撤后基数(U) | | -| 盈利后基数(U) | | +| 日起始基数(U) | | +| 回撤后基数(U) | | +| 盈利后基数(U) | | -与自动划转目标余额相互独立;若需一致请手动对齐。 +与自动划转目标余额相互独立;若需一致请手动对齐. --- -### 4.9 期权账户(仅 OKX) +### 4.9 期权账户(仅 OKX) | 中文名 | 说明 | |--------|------| | 启用期权模块 | | -| 期权 API Key / Secret / Passphrase | 主账户,与永续子账户分离 | +| 期权 API Key / Secret / Passphrase | 主账户,与永续子账户分离 | | 期权账户备注 | | -| 单笔预算(USDC) | | +| 单笔预算(USDC) | | | 预算缓冲比例 | | | 默认标的 | 如 ETH | | 最大到期天数 | 等常用策略参数 | -高级参数与完整说明见 [期权方案.md](./期权方案.md)、[期权用法.md](./期权用法.md)。 +高级参数与完整说明见 [期权方案.md](./期权方案.md),[期权用法.md](./期权用法.md). --- ## 5. 操作说明 -1. 修改后点 **保存**:即时生效项立即应用;需重启项写入 `.env` 但未重启进程。 -2. 含需重启项时点 **保存并重启**:写 `.env` 后 PM2 重启当前实例。 -3. **重新加载**:从磁盘重新读取 `.env` 刷新表单(放弃未保存修改)。 -4. 敏感项(API、密钥)显示为掩码;**留空提交表示不修改原值**。 +1. 修改后点 **保存**:即时生效项立即应用;需重启项写入 `.env` 但未重启进程. +2. 含需重启项时点 **保存并重启**:写 `.env` 后 PM2 重启当前实例. +3. **重新加载**:从磁盘重新读取 `.env` 刷新表单(放弃未保存修改). +4. 敏感项(API,密钥)显示为掩码;**留空提交表示不修改原值**. --- -## 6. 隐藏项(本页不展示) +## 6. 隐藏项(本页不展示) -以下仍存在于 `.env`,仅供运维或 SSH 修改: +以下仍存在于 `.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` +- 服务:`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` -后续若需要可增加「高级模式」折叠区,默认关闭。 +后续若需要可增加「高级模式」折叠区,默认关闭. --- @@ -211,19 +211,19 @@ AI 相关环境变量(`AI_PROVIDER`、`OPENAI_*`、`OLLAMA_*`、`AI_MODEL`、` | 能力 | env 配置 | 系统设置 | 中控系统设置 | |------|----------|----------|--------------| | 登录用户名/密码 | ❌ | ✅ 账户密码修改 | ✅ 中控账户密码 | -| 交易所 API | ✅(各所自配) | ❌ | ❌ | -| AI / OpenAI | ❌ | ❌ | ✅ AI 配置(同步三所) | +| 交易所 API | ✅(各所自配) | ❌ | ❌ | +| AI / OpenAI | ❌ | ❌ | ✅ AI 配置(同步三所) | | 导航/区块显示 | ❌ | ✅ 导航显示 | ✅ 显示与导航 | | 手动资金划转 | ❌ | ✅ 永续资金划转 | ❌ | | 数据导出 | ❌ | ✅ 数据导出 | ❌ | -| 期权兑换/划转 UI | ❌ | ✅(OKX,可开关) | ❌ | +| 期权兑换/划转 UI | ❌ | ✅(OKX,可开关) | ❌ | -系统设置说明见 [系统设置说明.md](./系统设置说明.md);中控 AI 与部署密钥见 [中控AI与密钥配置.md](./中控AI与密钥配置.md)。 +系统设置说明见 [系统设置说明.md](./系统设置说明.md);中控 AI 与部署密钥见 [中控AI与密钥配置.md](./中控AI与密钥配置.md). --- -## 8. 实现备注(开发用) +## 8. 实现备注(开发用) -- 白名单分组:`lib/env/env_ui_manifest.py`(按 `exchange_key` 过滤) -- 中文标签:`ENV_UI_LABELS` 映射,模板只渲染 `label` / `note` -- 全量校验仍基于 `.env.example`;POST 仅接受 manifest 内 key +- 白名单分组:`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 index 71499fd..c609050 100644 --- a/docs/hub-symbol-archive-kline.md +++ b/docs/hub-symbol-archive-kline.md @@ -1,135 +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) +# 内照明心与永久 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 index 288da48..4b772bd 100644 --- a/docs/lib-structure.md +++ b/docs/lib-structure.md @@ -1,147 +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 -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) +# 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 +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 index 72107e3..2803ac1 100644 --- a/docs/macro-calendar.md +++ b/docs/macro-calendar.md @@ -1,7 +1,7 @@ # 宏观关键数据 · 风控前置 -中控 **系统设置** 手动录入 FOMC / CPI / 就业数据发布时间,在 **监控区** 发布前后各 1 小时给出风险提示。 -**不看公布结果、不解读数据**,仅作波动窗口前的行为提醒;**不拦截下单**(与账户冷静期/日冻结独立)。 +中控 **系统设置** 手动录入 FOMC / CPI / 就业数据发布时间,在 **监控区** 发布前后各 1 小时给出风险提示. +**不看公布结果,不解读数据**,仅作波动窗口前的行为提醒;**不拦截下单**(与账户冷静期/日冻结独立). ## 支持的数据类型 @@ -11,15 +11,15 @@ | `cpi` | 美国 CPI 通胀 | | `employment` | 就业与劳工数据 | -每项在设置中 **名称下拉三选一**,**发布时间** 手动输入(北京时间,精确到分钟)。FOMC 只录 **一条**(决议公布时刻即可)。 +每项在设置中 **名称下拉三选一**,**发布时间** 手动输入(北京时间,精确到分钟).FOMC 只录 **一条**(决议公布时刻即可). ## 风险窗口 -- 默认:**发布时间 ±1 小时** -- 发布前 **30 分钟内**:文案加强为「即将发布」 -- 窗口结束后横幅自动消失;设置列表中过期记录逐步不再展示 +- 默认:**发布时间 ±1 小时** +- 发布前 **30 分钟内**:文案加强为「即将发布」 +- 窗口结束后横幅自动消失;设置列表中过期记录逐步不再展示 -环境变量(可选): +环境变量(可选): ```env HUB_MACRO_WINDOW_BEFORE_SEC=3600 @@ -30,34 +30,34 @@ HUB_MACRO_LIST_FUTURE_DAYS=60 ## 监控区提示文案 -读取当前监控板:**任意交易所有持仓 = 有仓**,否则 = 无仓。 +读取当前监控板:**任意交易所有持仓 = 有仓**,否则 = 无仓. | 场景 | 提示要点 | |------|----------| -| 无仓 · 窗口内 | 建议等待,避免新开仓 | -| 有仓 · 窗口内 | 注意仓位,勿加仓,检查止损/减仓 | -| 即将发布(30 分钟内) | 在上述基础上标注剩余分钟数 | +| 无仓 · 窗口内 | 建议等待,避免新开仓 | +| 有仓 · 窗口内 | 注意仓位,勿加仓,检查止损/减仓 | +| 即将发布(30 分钟内) | 在上述基础上标注剩余分钟数 | ## 存储 -- SQLite:`manual_trading_hub/data/hub_macro_calendar.db` -- 可覆盖:`HUB_MACRO_CALENDAR_DB_PATH` +- 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` -同类型 + 同一发布时间不可重复录入。 +表 `macro_events`:`event_type`, `event_at_ms`, `note`, `created_at_ms`, `updated_at_ms` +同类型 + 同一发布时间不可重复录入. -## API(均需中控登录) +## API(均需中控登录) | 方法 | 路径 | 说明 | |------|------|------| | GET | `/api/macro-calendar/meta` | 类型列表与窗口说明 | | GET | `/api/macro-calendar/events` | 设置页列表 | -| GET | `/api/macro-calendar/active` | 当前处于窗口内的事件(监控横幅) | +| GET | `/api/macro-calendar/active` | 当前处于窗口内的事件(监控横幅) | | POST | `/api/macro-calendar/events` | 新增 | | PATCH | `/api/macro-calendar/events/{id}` | 更新 | | DELETE | `/api/macro-calendar/events/{id}` | 删除 | -请求体示例: +请求体示例: ```json { @@ -69,15 +69,15 @@ HUB_MACRO_LIST_FUTURE_DAYS=60 ## 使用习惯 -1. 每月在金十/日历查看 **FOMC、CPI、非农** 公布时间 +1. 每月在金十/日历查看 **FOMC,CPI,非农** 公布时间 2. 中控 **系统设置 → 宏观关键数据** 录入 1~3 条 -3. 到点前后监控区顶栏出现 **宏观风控** 横幅;无操作则窗口结束后自动消失 +3. 到点前后监控区顶栏出现 **宏观风控** 横幅;无操作则窗口结束后自动消失 ## 与账户风控的关系 | 模块 | 时机 | 作用 | |------|------|------| -| 宏观日历 | **事前** | 已知高波动窗口,提醒等待或管仓 | +| 宏观日历 | **事前** | 已知高波动窗口,提醒等待或管仓 | | 账户冷静期/日冻结 | **事后** | 用户主动平仓后的惩罚性限制 | -宏观提醒 **不触发** 冷静期、不计入手动平仓次数。 +宏观提醒 **不触发** 冷静期,不计入手动平仓次数. diff --git a/docs/manual-order-rr-preview.md b/docs/manual-order-rr-preview.md index a479789..60c5acf 100644 --- a/docs/manual-order-rr-preview.md +++ b/docs/manual-order-rr-preview.md @@ -1,31 +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` 口径一致 +# 实盘下单 · 预估盈亏比 + +## 功能 + +三所(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 index 0fb688f..22a964f 100644 --- a/docs/position-sizing-mode.md +++ b/docs/position-sizing-mode.md @@ -1,73 +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 -``` +# 计仓模式(三所统一) + +## 配置 + +在各实例 `.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 index 39f5ddc..693f635 100644 --- a/docs/shortcut-icon.md +++ b/docs/shortcut-icon.md @@ -1,41 +1,41 @@ -# 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. 若都没有 → 灰色地球或网页标题首字 - -本仓库已在 **中控** 与 **三所监控页** 配置统一品牌图标(深色圆角底 + 青绿趋势线 + 简化的 K 线),与页面 UI 一致。PNG/ICO 由 **Pillow** 生成,避免损坏的 favicon 出现花屏。 - -## 文件位置 - -| 位置 | 访问路径 | -|------|----------| -| 源稿 | `brand/icon.svg`、`brand/icons/*.png` | -| 中控 | `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`。 +# 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. 若都没有 → 灰色地球或网页标题首字 + +本仓库已在 **中控** 与 **三所监控页** 配置统一品牌图标(深色圆角底 + 青绿趋势线 + 简化的 K 线),与页面 UI 一致.PNG/ICO 由 **Pillow** 生成,避免损坏的 favicon 出现花屏. + +## 文件位置 + +| 位置 | 访问路径 | +|------|----------| +| 源稿 | `brand/icon.svg`,`brand/icons/*.png` | +| 中控 | `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 index 9ec66a2..20f88b6 100644 --- a/docs/strategy/README.md +++ b/docs/strategy/README.md @@ -1,34 +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) +# 策略文档 + +各交易实例的人工下单策略,供 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 index bd3072d..95bad40 100644 --- a/docs/strategy/binance-alt-trend-long.md +++ b/docs/strategy/binance-alt-trend-long.md @@ -1,194 +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 | 讨论稿 | +# 币安山寨·多头趋势账户 + +> **状态**: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 index f3c6683..84560d9 100644 --- a/docs/strategy/checklists/binance.json +++ b/docs/strategy/checklists/binance.json @@ -6,63 +6,63 @@ { "title": "账户与方向", "items": [ - "本账户仅做多,不做空", - "计仓模式为以损定仓(risk),关键位自动单已关闭", - "已明确第一级:反转 / 顺势 / 波段(两级下拉已上线)" + "本账户仅做多,不做空", + "计仓模式为以损定仓(risk),关键位自动单已关闭", + "已明确第一级:反转 / 顺势 / 波段(两级下拉已上线)" ] }, { - "title": "反转 · 背离与箱体(启动 A/B 共同前置)", + "title": "反转 · 背离与箱体(启动 A/B 共同前置)", "items": [ - "4h MACD 底背离(非首次背离、非通道式下跌中的背离)", - "有明显波段高/低点,且三段及以上明显下跌后才认背离", - "背离后处于震荡箱体(时长不量化;非 V 形急跌急拉)", - "已标注参考高点(最后一个显著高点)", - "第 1 次到参考高点附近 → 不做;第 2 次到附近 → 才进入作战区" + "4h MACD 底背离(非首次背离,非通道式下跌中的背离)", + "有明显波段高/低点,且三段及以上明显下跌后才认背离", + "背离后处于震荡箱体(时长不量化;非 V 形急跌急拉)", + "已标注参考高点(最后一个显著高点)", + "第 1 次到参考高点附近 → 不做;第 2 次到附近 → 才进入作战区" ] }, { - "title": "反转 · 启动 B(实体突破)", + "title": "反转 · 启动 B(实体突破)", "items": [ - "第 2 次到参考高点附近,且未见再次摸高前的小收敛", - "实体突破参考高点/箱体上沿(非仅影线)", - "突破失败回箱止损属正常;跌破箱高一半则暂弃直至再次到高点附近" + "第 2 次到参考高点附近,且未见再次摸高前的小收敛", + "实体突破参考高点/箱体上沿(非仅影线)", + "突破失败回箱止损属正常;跌破箱高一半则暂弃直至再次到高点附近" ] }, { - "title": "反转 · 启动 A(结构内,含 A1 / A2)", + "title": "反转 · 启动 A(结构内,含 A1 / A2)", "items": [ - "性质:主升确认前;不是小分歧", - "A1:第 2 次摸高前出现小收敛 → 突破前企稳", - "A2:启动 B 止损后,未跌破箱高一半 + 5m 不创新低 + 5m N 字突破试仓", - "5m 试仓记为启动 A(不单列类型);小止损允许多次试错" + "性质:主升确认前;不是小分歧", + "A1:第 2 次摸高前出现小收敛 → 突破前企稳", + "A2:启动 B 止损后,未跌破箱高一半 + 5m 不创新低 + 5m N 字突破试仓", + "5m 试仓记为启动 A(不单列类型);小止损允许多次试错" ] }, { "title": "顺势 · 大分歧 A / B", "items": [ - "主升已确立,上方仍有空间(非跌后背离筑底阶段)", - "大分歧A:5m/15m 收敛且不创新低企稳", - "大分歧B:突破已确认,优先实体突破" + "主升已确立,上方仍有空间(非跌后背离筑底阶段)", + "大分歧A:5m/15m 收敛且不创新低企稳", + "大分歧B:突破已确认,优先实体突破" ] }, { "title": "波段 · 小分歧", "items": [ - "主升已确立;反转链进行中不做小分歧", + "主升已确立;反转链进行中不做小分歧", "第三次小分歧 → 不做新单", - "不追突破;二次探底 N 字或 5m 三均线重新多头" + "不追突破;二次探底 N 字或 5m 三均线重新多头" ] }, { "title": "杠杆与出场", "items": [ - "杠杆:BTC/ETH 10x,其它山寨 5x(可选手改但须有理由)", - "止盈止损随行情人工设定,不在此清单量化 RR" + "杠杆:BTC/ETH 10x,其它山寨 5x(可选手改但须有理由)", + "止盈止损随行情人工设定,不在此清单量化 RR" ] } ], "footnotes": [ - "v0.4:两级 UI 已实现;启动 A 含 A1 收敛与 A2(B 失败后 5m N 字)。" + "v0.4:两级 UI 已实现;启动 A 含 A1 收敛与 A2(B 失败后 5m N 字)." ] } diff --git a/docs/strategy/checklists/gate.json b/docs/strategy/checklists/gate.json index 1d71232..01a0181 100644 --- a/docs/strategy/checklists/gate.json +++ b/docs/strategy/checklists/gate.json @@ -6,33 +6,33 @@ { "title": "方向与均线过滤", "items": [ - "仅交易 BTC,同时仅 1 仓", - "15m 21/55/144 排列清晰(多或空),纠缠则不做", + "仅交易 BTC,同时仅 1 仓", + "15m 21/55/144 排列清晰(多或空),纠缠则不做", "1H 方向与 15m 不冲突", - "21 均线关系满足:回踩支撑 / 站稳上方(多)或反弹承压 / 压在下方(空)" + "21 均线关系满足:回踩支撑 / 站稳上方(多)或反弹承压 / 压在下方(空)" ] }, { "title": "开仓类型 A / B", "items": [ - "已选定:假破 或 结构突破(二选一)", - "假破:扫流动性后回到结构内,5m N 字 + 15m 顶/底分型齐全", - "结构突破:15m 收盘价站稳关键位,非仅刺破", - "止损带宽 0.4%~1.5%,超出则不做", - "下单前空间至少 1:1,不足则不做" + "已选定:假破 或 结构突破(二选一)", + "假破:扫流动性后回到结构内,5m N 字 + 15m 顶/底分型齐全", + "结构突破:15m 收盘价站稳关键位,非仅刺破", + "止损带宽 0.4%~1.5%,超出则不做", + "下单前空间至少 1:1,不足则不做" ] }, { "title": "一日节奏与笔数", "items": [ - "非周末;在早窗 / 晚窗计划时段内", - "今日笔数未达上限 3,连错未达 2 笔", - "宽幅震荡(S1)时降频或不做", - "0 点前须了结(系统强制清仓);本清单不含手动平仓" + "非周末;在早窗 / 晚窗计划时段内", + "今日笔数未达上限 3,连错未达 2 笔", + "宽幅震荡(S1)时降频或不做", + "0 点前须了结(系统强制清仓);本清单不含手动平仓" ] } ], "footnotes": [ - "系统:FORCE_CLOSE_ENABLED 开启时,北京时间 0 点自动强制清仓(result=强制清仓)" + "系统:FORCE_CLOSE_ENABLED 开启时,北京时间 0 点自动强制清仓(result=强制清仓)" ] } diff --git a/docs/strategy/checklists/okx.json b/docs/strategy/checklists/okx.json index 9058001..7680011 100644 --- a/docs/strategy/checklists/okx.json +++ b/docs/strategy/checklists/okx.json @@ -6,67 +6,67 @@ { "title": "账户与方向", "items": [ - "已选定做多或做空,且与 4H/大级别结构方向一致", - "趋势户 profile(非 Gate 日内 BTC/ETH 白名单)", - "计仓模式为以损定仓(risk),关键位自动单已关闭", + "已选定做多或做空,且与 4H/大级别结构方向一致", + "趋势户 profile(非 Gate 日内 BTC/ETH 白名单)", + "计仓模式为以损定仓(risk),关键位自动单已关闭", "同一币种无未计划的对冲叠仓", - "已明确第一级:反转 / 顺势 / 波段(两级下拉已上线)" + "已明确第一级:反转 / 顺势 / 波段(两级下拉已上线)" ] }, { - "title": "反转 · 背离与箱体(启动 A/B 共同前置)", + "title": "反转 · 背离与箱体(启动 A/B 共同前置)", "items": [ - "做多:4h MACD 底背离;做空:4h MACD 顶背离", - "非首次背离;非通道式涨跌中的背离;明显波段高低点 + 三段及以上涨/跌后才认背离", - "背离后处于震荡箱体(时长不量化;非 V 形急拉急杀)", - "做多:已标参考高点;做空:已标参考低点", - "第 1 次到参考极值附近 → 不做;第 2 次到附近 → 才进入作战区" + "做多:4h MACD 底背离;做空:4h MACD 顶背离", + "非首次背离;非通道式涨跌中的背离;明显波段高低点 + 三段及以上涨/跌后才认背离", + "背离后处于震荡箱体(时长不量化;非 V 形急拉急杀)", + "做多:已标参考高点;做空:已标参考低点", + "第 1 次到参考极值附近 → 不做;第 2 次到附近 → 才进入作战区" ] }, { - "title": "反转 · 启动 B(实体突破)", + "title": "反转 · 启动 B(实体突破)", "items": [ - "第 2 次到参考极值附近,且未见再次摸极值前的小收敛", - "做多:实体突破参考高点/箱顶;做空:实体跌破参考低点/箱底", + "第 2 次到参考极值附近,且未见再次摸极值前的小收敛", + "做多:实体突破参考高点/箱顶;做空:实体跌破参考低点/箱底", "突破失败回箱止损属正常", - "做多:跌破箱高一半暂弃;做空:涨破箱低一半暂弃;直至再次到极值附近" + "做多:跌破箱高一半暂弃;做空:涨破箱低一半暂弃;直至再次到极值附近" ] }, { - "title": "反转 · 启动 A(结构内,含 A1 / A2)", + "title": "反转 · 启动 A(结构内,含 A1 / A2)", "items": [ - "性质:主趋势确认前;不是小分歧", - "A1:第 2 次摸极值前出现小收敛 → 突破前企稳", - "A2 做多:B 止损后未跌破箱一半 + 5m 不创新低 + 5m N 字", - "A2 做空:B 止损后未涨破箱一半 + 5m 不创新高 + 5m 倒 N 字", - "5m 试仓记为启动 A(不单列);小止损允许多次试错" + "性质:主趋势确认前;不是小分歧", + "A1:第 2 次摸极值前出现小收敛 → 突破前企稳", + "A2 做多:B 止损后未跌破箱一半 + 5m 不创新低 + 5m N 字", + "A2 做空:B 止损后未涨破箱一半 + 5m 不创新高 + 5m 倒 N 字", + "5m 试仓记为启动 A(不单列);小止损允许多次试错" ] }, { "title": "顺势 · 大分歧 A / B", "items": [ - "主趋势已确立(非涨/跌后背离筑底/筑顶阶段)", - "做多大分歧A:5m/15m 收敛且不创新低;做空:不创新高", - "大分歧B:突破已确认,优先实体突破" + "主趋势已确立(非涨/跌后背离筑底/筑顶阶段)", + "做多大分歧A:5m/15m 收敛且不创新低;做空:不创新高", + "大分歧B:突破已确认,优先实体突破" ] }, { "title": "波段 · 小分歧", "items": [ - "主趋势已确立;反转链进行中不做小分歧", + "主趋势已确立;反转链进行中不做小分歧", "第三次小分歧 → 不做新单", - "做多:二次探底 N 字 / 5m 三均线多头;做空:二次探顶倒 N / 5m 空头" + "做多:二次探底 N 字 / 5m 三均线多头;做空:二次探顶倒 N / 5m 空头" ] }, { "title": "杠杆与出场", "items": [ - "杠杆:BTC/ETH 10x,其它山寨 5x(可选手改但须有理由)", - "止盈止损随行情人工设定,不在此清单量化 RR" + "杠杆:BTC/ETH 10x,其它山寨 5x(可选手改但须有理由)", + "止盈止损随行情人工设定,不在此清单量化 RR" ] } ], "footnotes": [ - "v0.4:两级 UI 反转/顺势/波段;做多细则见 binance-alt-trend-long.md §3。" + "v0.4:两级 UI 反转/顺势/波段;做多细则见 binance-alt-trend-long.md §3." ] } diff --git a/docs/strategy/gate-intraday.md b/docs/strategy/gate-intraday.md index f1dd87a..661a929 100644 --- a/docs/strategy/gate-intraday.md +++ b/docs/strategy/gate-intraday.md @@ -1,6 +1,6 @@ # Gate·BTC 日内账户 -> **状态**:v0.2(策略定稿;**0 点强平已实现**;日内 UI 隐藏平仓/委托/移动保本 **待实现**) +> **状态**:v0.2(策略定稿;**0 点强平已实现**;日内 UI 隐藏平仓/委托/移动保本 **待实现**) --- @@ -10,143 +10,143 @@ |----|------| | 交易所 | Gate 合约 | | 品种 | **仅 BTC** | -| 方向 | **多空都做**(由过滤条件决定,非手选方向) | -| 计仓 | `POSITION_SIZING_MODE=full_margin`(全仓杠杆) | -| UI profile | **日内户**(`TRADE_SYMBOL_WHITELIST=BTC,ETH` 且限制开启;本策略只交易 BTC) | -| 与趋势户关系 | **不使用** 大分歧 A/B/小分歧;**不使用**「趋势单 / 波段单」手选 | +| 方向 | **多空都做**(由过滤条件决定,非手选方向) | +| 计仓 | `POSITION_SIZING_MODE=full_margin`(全仓杠杆) | +| UI profile | **日内户**(`TRADE_SYMBOL_WHITELIST=BTC,ETH` 且限制开启;本策略只交易 BTC) | +| 与趋势户关系 | **不使用** 大分歧 A/B/小分歧;**不使用**「趋势单 / 波段单」手选 | -### 资金与杠杆(执行约定) +### 资金与杠杆(执行约定) | 项 | 说明 | |----|------| -| 账户规模 | 约 300U(测试阶段) | -| 日交易基数 | **50U**(早 8:00 重置为 50U,不延续前日阶梯) | -| 单笔阶梯 | 上一笔 **+10U / −10U** 调节下一笔基数(赢 60U / 亏 40U 等) | +| 账户规模 | 约 300U(测试阶段) | +| 日交易基数 | **50U**(早 8:00 重置为 50U,不延续前日阶梯) | +| 单笔阶梯 | 上一笔 **+10U / −10U** 调节下一笔基数(赢 60U / 亏 40U 等) | | 杠杆 | **10× 全仓** | | 一次一单 | 同时仅 **1** 个 Gate 仓位 | -| 止损带宽 | **0.4%~1.5%**(结构要求更宽则 **不做**) | +| 止损带宽 | **0.4%~1.5%**(结构要求更宽则 **不做**) | --- ## 2. 周期分层 -自上而下,**先定能不能做、再做哪一类**: +自上而下,**先定能不能做,再做哪一类**: | 层级 | 周期 | 作用 | |------|------|------| -| 方向过滤 | **1H** | 大方向;**不得与 15m 排列反向** | -| 均线 + 结构 | **15m** | 21/55/144 排列、顶底分型、结构识别、**B 类收盘突破** | -| 触发 | **5m** | **A 类**:N 字形突破(配合 15m 分型) | -| 方法 | 裸 K + 三均线 | 形态确认、入场与止损锚点 | +| 方向过滤 | **1H** | 大方向;**不得与 15m 排列反向** | +| 均线 + 结构 | **15m** | 21/55/144 排列,顶底分型,结构识别,**B 类收盘突破** | +| 触发 | **5m** | **A 类**:N 字形突破(配合 15m 分型) | +| 方法 | 裸 K + 三均线 | 形态确认,入场与止损锚点 | -**不做「趋势单」概念**:持仓以 **小时** 计,当日了结;与币安/OKX 多日趋势户区分。 +**不做「趋势单」概念**:持仓以 **小时** 计,当日了结;与币安/OKX 多日趋势户区分. --- -## 3. 方向过滤(必过) +## 3. 方向过滤(必过) -### 3.1 15m 三均线(21 / 55 / 144) +### 3.1 15m 三均线(21 / 55 / 144) | 15m 排列 | 只允许 | |----------|--------| -| **多头排列**(21 > 55 > 144) | **只做多** | -| **空头排列**(21 < 55 < 144) | **只做空** | -| 纠缠、粘合、不符合 | **不做** | +| **多头排列**(21 > 55 > 144) | **只做多** | +| **空头排列**(21 < 55 < 144) | **只做空** | +| 纠缠,粘合,不符合 | **不做** | ### 3.2 与 1H 同向 -- **做多**:15m 多头排列,且 **1H 不得为空头排列**(1H 均线不能与 15m 方向相反)。 -- **做空**:15m 空头排列,且 **1H 不得为多头排列**。 -- 1H/15m 方向冲突 → **当日该方向不做**。 +- **做多**:15m 多头排列,且 **1H 不得为空头排列**(1H 均线不能与 15m 方向相反). +- **做空**:15m 空头排列,且 **1H 不得为多头排列**. +- 1H/15m 方向冲突 → **当日该方向不做**. -### 3.3 21 均线关系(才允许开仓) +### 3.3 21 均线关系(才允许开仓) -入场须与 **21 均线** 发生有效关系,避免 distant 追单: +入场须与 **21 均线** 发生有效关系,避免 distant 追单: -| 方向 | 要求(定性) | +| 方向 | 要求(定性) | |------|----------------| -| **做多** | 多头排列下,**回踩 21 附近获支撑** 或 **站稳 21 上方** 后再按 playbook 入场 | -| **做空** | 空头排列下,**反弹 21 附近承压** 或 **压在 21 下方** 后再按 playbook 入场 | +| **做多** | 多头排列下,**回踩 21 附近获支撑** 或 **站稳 21 上方** 后再按 playbook 入场 | +| **做空** | 空头排列下,**反弹 21 附近承压** 或 **压在 21 下方** 后再按 playbook 入场 | --- -## 4. 开仓类型(仅两类) +## 4. 开仓类型(仅两类) -界面日后仅两个短标签(全称见下表 hover / 本文): +界面日后仅两个短标签(全称见下表 hover / 本文): -| 界面标签 | 存储 code(建议) | 本质 | +| 界面标签 | 存储 code(建议) | 本质 | |----------|-------------------|------| | **假破** | `liquidity_false_break` | 流动性扫单 → 假突破验证 → **5m N 字** → **15m 顶/底分型** | -| **结构突破** | `structure_breakout` | **15m 结构有效突破**(**收盘确认**) | +| **结构突破** | `structure_breakout` | **15m 结构有效突破**(**收盘确认**) | -子结构 **不单独占主下拉**,可在复盘备注或二级标签中记录。 +子结构 **不单独占主下拉**,可在复盘备注或二级标签中记录. --- -## 5. A 类:假破(流动性 / 假突破) +## 5. A 类:假破(流动性 / 假突破) -**适用**:关键位附近 **扫止损** 后价格 **回到结构内**,陷阱确认后再反向做。 +**适用**:关键位附近 **扫止损** 后价格 **回到结构内**,陷阱确认后再反向做. ### 5.1 流程 ```text 1H/15m 方向 + 21 均线过滤通过 - → 假突破出现(扫高/扫低) - → 验证为「假」(收回结构内 / 反向裸 K 确认) - → 5m 走出 N 字(二次探底/探顶后,沿允许方向突破) - → 15m 出现底分型(多)或顶分型(空) + → 假突破出现(扫高/扫低) + → 验证为「假」(收回结构内 / 反向裸 K 确认) + → 5m 走出 N 字(二次探底/探顶后,沿允许方向突破) + → 15m 出现底分型(多)或顶分型(空) → 入场 ``` -### 5.2 做多 / 做空(对称) +### 5.2 做多 / 做空(对称) | 步骤 | 做多 | 做空 | |------|------|------| | 假破 | 向下扫低后快速拉回支撑/箱上 | 向上扫高后跌回阻力/箱下 | | 5m N 字 | 扫低 → 反弹 → 不破前低 → 向上突破 | 扫高 → 回落 → 不过前高 → 向下突破 | | 15m 确认 | **底分型** | **顶分型** | -| 止损 | 假破极值或 N 字低点 **外侧**(仍须落在 0.4%~1.5%) | 对称 | -| 目标 | **最低 1:1**;之后 **随行情动态** 部分止盈、移动止损或延伸 | 对称 | +| 止损 | 假破极值或 N 字低点 **外侧**(仍须落在 0.4%~1.5%) | 对称 | +| 目标 | **最低 1:1**;之后 **随行情动态** 部分止盈,移动止损或延伸 | 对称 | ### 5.3 注意 -- **须等假破验证完成**,扫完不追。 -- **5m N + 15m 分型** 为入场必要条件,缺一不可。 -- 与大级别 **宽幅震荡(S1)** 叠加时假信号多,优先 **降频或不做**。 +- **须等假破验证完成**,扫完不追. +- **5m N + 15m 分型** 为入场必要条件,缺一不可. +- 与大级别 **宽幅震荡(S1)** 叠加时假信号多,优先 **降频或不做**. --- -## 6. B 类:结构突破 +## 6. B 类:结构突破 -**适用**:15m 上结构清晰,方向与均线排列一致,**收盘突破** 后顺势做。 +**适用**:15m 上结构清晰,方向与均线排列一致,**收盘突破** 后顺势做. -### 6.1 子结构(均属 B 类) +### 6.1 子结构(均属 B 类) -双顶、双底、头肩顶/底、收敛(三角/楔形)、箱体等——**统一记为「结构突破」**。 +双顶,双底,头肩顶/底,收敛(三角/楔形),箱体等——**统一记为「结构突破」**. ### 6.2 突破确认 -- **以 15m K 线收盘价为准** 突破关键位(颈线、箱边、收敛边界等)。 -- **仅刺破、未收盘站稳** → **不算** 有效突破,不做。 -- 可选:**收盘突破后回踩** 再进(裸 K 确认),仍须满足 21 均线关系与 **≥1:1** 空间。 +- **以 15m K 线收盘价为准** 突破关键位(颈线,箱边,收敛边界等). +- **仅刺破,未收盘站稳** → **不算** 有效突破,不做. +- 可选:**收盘突破后回踩** 再进(裸 K 确认),仍须满足 21 均线关系与 **≥1:1** 空间. ### 6.3 止损与目标 | 项 | 说明 | |----|------| -| 止损 | 结构另一侧或突破位回退点 **外侧**(0.4%~1.5%,超出则不做) | -| 目标 | 下单前 **至少 1:1**;到位后 **随行情动态** 调整,不写死固定 RR | +| 止损 | 结构另一侧或突破位回退点 **外侧**(0.4%~1.5%,超出则不做) | +| 目标 | 下单前 **至少 1:1**;到位后 **随行情动态** 调整,不写死固定 RR | | 空间不足 | 最近阻力/支撑导致 **达不到 1:1** → **不做** | --- -## 7. 行情状态(辅助过滤) +## 7. 行情状态(辅助过滤) | 状态 | 特征 | Gate 动作 | |------|------|-----------| -| **S0 趋势** | 1H/15m 排列清晰,高低点有序 | 正常:A/B 均可 | -| **S1 宽幅震荡** | 大箱横盘多日、均线反复穿 | **降频或不做** | -| **S2 末期/选边** | 贴边收敛、刚突破或假破频发 | 优先 **A 假破** 或 **B 收敛突破** | +| **S0 趋势** | 1H/15m 排列清晰,高低点有序 | 正常:A/B 均可 | +| **S1 宽幅震荡** | 大箱横盘多日,均线反复穿 | **降频或不做** | +| **S2 末期/选边** | 贴边收敛,刚突破或假破频发 | 优先 **A 假破** 或 **B 收敛突破** | --- @@ -155,19 +155,19 @@ | 项 | 规则 | |----|------| | 周末 | **不开新仓** | -| 早窗 | 约 **9:00**(8:00~12:00 内),**计划内第 1 笔** | -| 下午 | **默认不开新仓**(持仓可保留至晚窗) | -| 晚窗 | 约 **21:00**(20:00~23:00 内),**计划内第 2 笔** | -| 第 3 笔 | 仅当 **未连错 2 笔**,且 **早/晚有一笔为止损出场**,可 **补 1 笔** | +| 早窗 | 约 **9:00**(8:00~12:00 内),**计划内第 1 笔** | +| 下午 | **默认不开新仓**(持仓可保留至晚窗) | +| 晚窗 | 约 **21:00**(20:00~23:00 内),**计划内第 2 笔** | +| 第 3 笔 | 仅当 **未连错 2 笔**,且 **早/晚有一笔为止损出场**,可 **补 1 笔** | | 日上限 | **最多 3 笔** | -| **连错 2 笔** | **当日不再开新仓**(第 3 笔名额作废) | +| **连错 2 笔** | **当日不再开新仓**(第 3 笔名额作废) | -**连错计数**: +**连错计数**: | 出场 | 是否算「错 1 笔」 | |------|------------------| | **计划止损**触发 | ✅ 算 | -| **0 点强制清仓**(系统结果 `强制清仓`)且亏损 | ✅ 算 | +| **0 点强制清仓**(系统结果 `强制清仓`)且亏损 | ✅ 算 | | 止盈 / ≥1:1 按计划平 | ❌ 不算 | | 0 点强制清仓且盈利或平推 | ❌ 不算 | @@ -177,53 +177,53 @@ ### 9.1 盈亏比 -- 开仓前:**第一目标空间 ≥ 止损距离(最低 1:1)**。 -- 持仓中:目标 **随行情动态** 调整;本文档 **不量化** 固定止盈比例。 +- 开仓前:**第一目标空间 ≥ 止损距离(最低 1:1)**. +- 持仓中:目标 **随行情动态** 调整;本文档 **不量化** 固定止盈比例. ### 9.2 禁止「手动止损」 -- **亏损出场** 必须来自 **开仓时设定的计划止损**(交易所或监控等价执行)。 -- **禁止** 盘中亏着 **手点平仓** 充当止损(破坏统计与连错规则)。 -- 若违规手动平亏:**视为当日纪律失败,建议停手**;复盘结果 **不得** 记为「止损」糊弄统计。 +- **亏损出场** 必须来自 **开仓时设定的计划止损**(交易所或监控等价执行). +- **禁止** 盘中亏着 **手点平仓** 充当止损(破坏统计与连错规则). +- 若违规手动平亏:**视为当日纪律失败,建议停手**;复盘结果 **不得** 记为「止损」糊弄统计. -### 9.3 时间出场:仅 0 点(程序已实现) +### 9.3 时间出场:仅 0 点(程序已实现) -- **唯一** 时间类出场:**当日 0:00(北京时间)前必须空仓**(赚赔都平)。 -- **不使用** 下单表单里的 1h / 2h / 4h「开仓后 N 小时平」(`time_close`);与本策略无关。 -- **程序兜底**(三所共用,Gate 已启用): +- **唯一** 时间类出场:**当日 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_BJ_HOUR=0` | 北京时间 **0 点那一小时**(00:00~00:59)执行 | -- 实现:`force_close_before_reset()`(各实例 `app.py` 后台循环调用)。 -- 行为:对该小时仍 **active** 的 `order_monitors` **市价全平**,取消交易所触发单,写交易记录。 -- **系统结果字段**:`result = 强制清仓`;备注含「北京时间 0:00 整点风控清仓」。 -- **策略口语「0 点平仓」= 系统「强制清仓」**,统计连错时按 §8 盈亏判定,不按字段名区分。 +- 实现:`force_close_before_reset()`(各实例 `app.py` 后台循环调用). +- 行为:对该小时仍 **active** 的 `order_monitors` **市价全平**,取消交易所触发单,写交易记录. +- **系统结果字段**:`result = 强制清仓`;备注含「北京时间 0:00 整点风控清仓」. +- **策略口语「0 点平仓」= 系统「强制清仓」**,统计连错时按 §8 盈亏判定,不按字段名区分. -> **与 `TRADING_DAY_RESET_HOUR=8` 无关**:后者只切 **交易日**(统计、8 点前禁开等),**不会**自动平仓。 +> **与 `TRADING_DAY_RESET_HOUR=8` 无关**:后者只切 **交易日**(统计,8 点前禁开等),**不会**自动平仓. -### 9.4 允许的出场类型(统计用) +### 9.4 允许的出场类型(统计用) | 策略说法 | 系统 `result` | 说明 | |----------|---------------|------| | 止盈 | 止盈 / 移动止盈 / 保本止盈 等 | 计划止盈或 ≥1:1 后按计划/动态平 | | 止损 | 止损 | 仅 **计划止损** 触发 | -| 0 点平仓 | **强制清仓** | 整点风控兜底(§9.3) | -| ~~手动平仓~~ | 手动平仓 | **策略禁止**(除极端技术故障等,须复盘说明) | +| 0 点平仓 | **强制清仓** | 整点风控兜底(§9.3) | +| ~~手动平仓~~ | 手动平仓 | **策略禁止**(除极端技术故障等,须复盘说明) | --- -## 10. A / B 如何选择(当日) +## 10. A / B 如何选择(当日) | 盘面 | 优先 | |------|------| -| 刚扫流动性、回到箱内 | **A 假破** | -| 结构清晰、排列已顺、收敛末端 | **B 结构突破** | -| 大箱乱扫、均线粘合 | **不做** | +| 刚扫流动性,回到箱内 | **A 假破** | +| 结构清晰,排列已顺,收敛末端 | **B 结构突破** | +| 大箱乱扫,均线粘合 | **不做** | -早/晚窗 **有形态才做**,无形态 = **0 笔**,不占额度。 +早/晚窗 **有形态才做**,无形态 = **0 笔**,不占额度. --- @@ -243,13 +243,13 @@ | 项 | 说明 | |----|------| -| 日内 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` 打开,无需改代码** | +| 日内 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(节选): +Gate 当前建议 env(节选): ```env FORCE_CLOSE_ENABLED=true @@ -257,14 +257,14 @@ FORCE_CLOSE_BJ_HOUR=0 TRADING_DAY_RESET_HOUR=8 ``` -### 12.2 待实现(UI / 纪律) +### 12.2 待实现(UI / 纪律) | 项 | 说明 | |----|------| -| 开仓类型 | 界面 **`假破` / `结构突破`**(code:`liquidity_false_break` / `structure_breakout`);**无** trend/swing 手选 | +| 开仓类型 | 界面 **`假破` / `结构突破`**(code:`liquidity_false_break` / `structure_breakout`);**无** trend/swing 手选 | | 写入字段 | `trade_records.entry_model` / 复盘下拉同两项 | -| 隐藏操作 | 日内 profile 下 **隐藏** 平仓、委托、移动保本(**实例页 + 中控**,`intraday_discipline` / `order_entry_profile=intraday`) | -| 隐藏表单项 | 不展示 1h/2h/4h 时间平仓、移动保本勾选(避免与 §9.3 混用) | +| 隐藏操作 | 日内 profile 下 **隐藏** 平仓,委托,移动保本(**实例页 + 中控**,`intraday_discipline` / `order_entry_profile=intraday`) | +| 隐藏表单项 | 不展示 1h/2h/4h 时间平仓,移动保本勾选(避免与 §9.3 混用) | | 后端可选 | 严格模式下拒绝 `del_order` / 改委托 API | --- @@ -273,5 +273,5 @@ TRADING_DAY_RESET_HOUR=8 | 版本 | 日期 | 说明 | |------|------|------| -| 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 已启用说明 | +| 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 index c1579c1..c9b32ed 100644 --- a/docs/strategy/okx-trend-both.md +++ b/docs/strategy/okx-trend-both.md @@ -1,157 +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 短标签 | +# 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 index e331f88..a4edad5 100644 --- a/docs/trend-hub-close-and-trade-records.md +++ b/docs/trend-hub-close-and-trade-records.md @@ -1,184 +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 # 或本机仓库根目录 - -# 先预览 -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 -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(与对话中修复项同步)* +# 趋势回调:中控平仓与交易记录(检阅备忘) + +本文档汇总 **中控手动结束趋势计划**,**交易记录 / 策略记录** 写入规则,以及 **三所展示统一**,**补仓表计价** 相关修复,便于自行检阅与排错. + +适用仓库:`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 # 或本机仓库根目录 + +# 先预览 +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 +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 index 63d379f..f27129c 100644 --- a/docs/trend-pullback-strategy.md +++ b/docs/trend-pullback-strategy.md @@ -1,17 +1,17 @@ # 趋势回调策略说明 -本文描述 **「趋势回调」** 自动交易计划的业务规则与实现口径。 +本文描述 **「趋势回调」** 自动交易计划的业务规则与实现口径. -**三所主站**(Binance / Gate / OKX)均在顶栏 **策略交易 → `/strategy`** 左栏提供同一套逻辑(共用 `strategy_trend_register.py`);各所使用各自 API 与 `crypto.db`。 +**三所主站**(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) +**检阅备忘**(中控平仓,交易记录,补仓展示,漏记补录):[trend-hub-close-and-trade-records.md](./trend-hub-close-and-trade-records.md) --- ## 1. 适用场景 -- 各 **USDT 永续** 实例独立部署,使用各自 API 与 `crypto.db`。 -- 你已明确:**方向、止损价、补仓区间边界价、止盈价、杠杆**,并接受程序按风险预算拆分 **首仓 50% + 多档补仓 50%**。 +- 各 **USDT 永续** 实例独立部署,使用各自 API 与 `crypto.db`. +- 你已明确:**方向,止损价,补仓区间边界价,止盈价,杠杆**,并接受程序按风险预算拆分 **首仓 50% + 多档补仓 50%**. --- @@ -19,67 +19,67 @@ | 名称 | 含义 | |------|------| -| **合约 USDT 可用余额** | **生成预览**时通过 API 读取的 **swap 账户 USDT `free`** 快照;**确认执行**时再次读取并与快照比对偏差。 | -| **风险比例** | 默认 **5%**:指「若整笔计划在 **补仓区间远侧边界**(做多=上沿、做空=下沿)这一侧的最坏价格结构下触及止损」,目标亏损上限约为 **可用余额快照 × 风险比例**(实现上用 `calc_risk_fraction` 与 `prepare_order_amount` 反推总张数,受交易所最小张数与精度约束)。 | -| **止损价** | 用户填写;开仓后挂 **交易所仓位类止损触发单**(全平)。 | -| **补仓区间边界**(库字段 `add_upper`) | 用户填写;**仅在该价位与止损价构成的区间内** 才允许程序触发剩余 50% 的市价补仓。**界面文案**:做多显示「补仓上沿」,做空显示「补仓下沿」。校验:做多 `止损 < 边界价`;做空 `止损 > 边界价`。 | -| **止盈价** | 用户填写的 **固定价格**;**不由交易所条件止盈单触发**,由应用后台 **按标记价/行情价轮询**,达到后 **市价全平**。 | -| **杠杆** | 计划内固定写入;用于 `set_leverage` 与名义换算。 | -| **补仓档位数** | 默认 **5** 档(环境变量 `TREND_PULLBACK_DCA_LEGS` 可调);程序在满足最小张数前提下可能 **自动减少档数**。 | +| **合约 USDT 可用余额** | **生成预览**时通过 API 读取的 **swap 账户 USDT `free`** 快照;**确认执行**时再次读取并与快照比对偏差. | +| **风险比例** | 默认 **5%**:指「若整笔计划在 **补仓区间远侧边界**(做多=上沿,做空=下沿)这一侧的最坏价格结构下触及止损」,目标亏损上限约为 **可用余额快照 × 风险比例**(实现上用 `calc_risk_fraction` 与 `prepare_order_amount` 反推总张数,受交易所最小张数与精度约束). | +| **止损价** | 用户填写;开仓后挂 **交易所仓位类止损触发单**(全平). | +| **补仓区间边界**(库字段 `add_upper`) | 用户填写;**仅在该价位与止损价构成的区间内** 才允许程序触发剩余 50% 的市价补仓.**界面文案**:做多显示「补仓上沿」,做空显示「补仓下沿」.校验:做多 `止损 < 边界价`;做空 `止损 > 边界价`. | +| **止盈价** | 用户填写的 **固定价格**;**不由交易所条件止盈单触发**,由应用后台 **按标记价/行情价轮询**,达到后 **市价全平**. | +| **杠杆** | 计划内固定写入;用于 `set_leverage` 与名义换算. | +| **补仓档位数** | 默认 **5** 档(环境变量 `TREND_PULLBACK_DCA_LEGS` 可调);程序在满足最小张数前提下可能 **自动减少档数**. | --- -## 3. 执行流程(时间顺序) +## 3. 执行流程(时间顺序) -### 3.0 列表时间窗(交易记录 / 计划历史) +### 3.0 列表时间窗(交易记录 / 计划历史) -- **交易记录**、**计划历史**(含预览快照)列表与 **交易记录 CSV 导出** 支持 **UTC** 时间筛选(默认 UTC 当日;可选近 24h、近 7d、自定义起止)。 -- 查询参数:`win_preset`(`utc_today` / `utc_last24h` / `utc_last7d` / `custom`)、自定义时另传 `from_utc`、`to_utc`。 -- **统计分析**页仍按北京时间 `TRADING_DAY_RESET_HOUR` 切日,不受列表窗影响。 +- **交易记录**,**计划历史**(含预览快照)列表与 **交易记录 CSV 导出** 支持 **UTC** 时间筛选(默认 UTC 当日;可选近 24h,近 7d,自定义起止). +- 查询参数:`win_preset`(`utc_today` / `utc_last24h` / `utc_last7d` / `custom`),自定义时另传 `from_utc`,`to_utc`. +- **统计分析**页仍按北京时间 `TRADING_DAY_RESET_HOUR` 切日,不受列表窗影响. -### 3.1 预览阶段(不下单) +### 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`),超时须重新点「生成预览」。 +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 确认执行(实盘) +### 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. **计划结束**:任一结束路径(止盈 / 止损 / 用户手动结束)均会 **撤单**(条件单 + 普通挂单,尽力而为)。 +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` 中对应记录;过期记录会在新预览或页面加载时清理。 +用户可「取消预览」删除 `trend_pullback_previews` 中对应记录;过期记录会在新预览或页面加载时清理. -### 3.4 界面:计划历史与运行中浮动盈亏 +### 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`);成功后显示「已保本」时间与原止损(若与当前不同)。 +- **计划历史(页顶卡片)** + - 仅展示 **`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` 与交易所记录。 +- 平仓时仍会写入一条 **`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` 与交易所记录. --- @@ -89,17 +89,17 @@ |------|------------------|----------| | 开仓 | 单次市价 + 条件止盈+止损 | 首仓 50% 市价 + 多档补仓 + **仅止损在交易所** | | 止盈 | 条件单 + 本地监控 | **仅本地监控市价止盈** | -| 仓位基数 | 以损定仓(表单/会话基数) | **可用余额快照 × 风险比例** 推导 | -| 移动保本 | 支持(按 R 自动上移) | **保本移交**(结束计划→下单监控;交易所 TP+SL;**无**自动 R 保本) | +| 仓位基数 | 以损定仓(表单/会话基数) | **可用余额快照 × 风险比例** 推导 | +| 移动保本 | 支持(按 R 自动上移) | **保本移交**(结束计划→下单监控;交易所 TP+SL;**无**自动 R 保本) | --- -## 5. 风险声明(必读) +## 5. 风险声明(必读) -- 市价单存在 **滑点**;极端行情下实际亏损可能 **大于** 理论 5%。 -- 补仓触发依赖应用 **轮询间隔**(`MONITOR_POLL_SECONDS`),非毫秒级高频。 -- 交易所 **最小张数 / 精度** 可能导致计划张数被截断,实际风险略低于或偏离纸面计算。 -- 请使用 **单独 API Key / 子账户**,并先在 `LIVE_TRADING_ENABLED=false` 环境验证流程(若需沙盒请自行对接测试网,本仓库默认实盘接口)。 +- 市价单存在 **滑点**;极端行情下实际亏损可能 **大于** 理论 5%. +- 补仓触发依赖应用 **轮询间隔**(`MONITOR_POLL_SECONDS`),非毫秒级高频. +- 交易所 **最小张数 / 精度** 可能导致计划张数被截断,实际风险略低于或偏离纸面计算. +- 请使用 **单独 API Key / 子账户**,并先在 `LIVE_TRADING_ENABLED=false` 环境验证流程(若需沙盒请自行对接测试网,本仓库默认实盘接口). --- @@ -107,23 +107,23 @@ | 变量 | 说明 | 默认 | |------|------|------| -| `TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT` | 手动保本默认偏移(相对持仓均价,%) | `0.3` | +| `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` | +| `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` | +| `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)。 +- **`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`。 +**CSV 导出**:交易记录导出为 **v3**,包含上述交易所对齐字段及 `trend_plan_id`. diff --git a/docs/ubuntu-server.md b/docs/ubuntu-server.md index 016e900..0d5483b 100644 --- a/docs/ubuntu-server.md +++ b/docs/ubuntu-server.md @@ -1,169 +1,169 @@ -# Ubuntu 服务器部署与环境说明 - -本文档为 **生产环境唯一推荐路径**:**Ubuntu**、**root** 用户、代码目录 **`/opt/crypto_monitor`**、进程托管 **PM2**。不使用 Windows 部署、不使用 systemd/screen/nohup 托管应用(SSH 隧道除外)。 - ---- - -## 1. 系统要求 - -| 项 | 要求 | -|----|------| -| 操作系统 | **Ubuntu 22.04 LTS** 或 **24.04 LTS**(64 位) | -| 运行用户 | **root**(下文命令均按 root 编写) | -| 项目路径 | **`/opt/crypto_monitor`**(整仓克隆到此目录) | -| 进程管理 | **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 -cd /opt/crypto_monitor -bash deploy/setup_env.sh --install-system-deps -# 或已是 root 且已装 venv 包: -bash deploy/setup_env.sh -``` - -完成后各目录使用 **`.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/crypto_monitor_binance && pm2 start ecosystem.config.cjs -cd /opt/crypto_monitor/crypto_monitor_gate && pm2 start ecosystem.config.cjs -cd /opt/crypto_monitor/crypto_monitor_okx && pm2 start ecosystem.config.cjs - -# 2) 中控 + 三子代理(一条配置 4 进程:hub + 3 agent) -cd /opt/crypto_monitor/manual_trading_hub -pm2 start ecosystem.config.cjs - -pm2 save -pm2 list -``` - -升级代码后: - -```bash -cd /opt/crypto_monitor && 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 -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.git crypto_monitor -chown -R root:root /opt/crypto_monitor -``` - -- 数据库默认:各所 **`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/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) | `setup_env.sh` 参数说明 | -| [备份与恢复.md](../备份与恢复.md) | 数据库与 `.env` 备份 | -| 各 `crypto_monitor_*/部署文档.md` | 交易所 SOCKS、`.env`、PM2 细节 | -| [manual_trading_hub/部署文档.md](../manual_trading_hub/部署文档.md) | 中控 PM2、端口、反代 | +# Ubuntu 服务器部署与环境说明 + +本文档为 **生产环境唯一推荐路径**:**Ubuntu**,**root** 用户,代码目录 **`/opt/crypto_monitor`**,进程托管 **PM2**.不使用 Windows 部署,不使用 systemd/screen/nohup 托管应用(SSH 隧道除外). + +--- + +## 1. 系统要求 + +| 项 | 要求 | +|----|------| +| 操作系统 | **Ubuntu 22.04 LTS** 或 **24.04 LTS**(64 位) | +| 运行用户 | **root**(下文命令均按 root 编写) | +| 项目路径 | **`/opt/crypto_monitor`**(整仓克隆到此目录) | +| 进程管理 | **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 +cd /opt/crypto_monitor +bash deploy/setup_env.sh --install-system-deps +# 或已是 root 且已装 venv 包: +bash deploy/setup_env.sh +``` + +完成后各目录使用 **`.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/crypto_monitor_binance && pm2 start ecosystem.config.cjs +cd /opt/crypto_monitor/crypto_monitor_gate && pm2 start ecosystem.config.cjs +cd /opt/crypto_monitor/crypto_monitor_okx && pm2 start ecosystem.config.cjs + +# 2) 中控 + 三子代理(一条配置 4 进程:hub + 3 agent) +cd /opt/crypto_monitor/manual_trading_hub +pm2 start ecosystem.config.cjs + +pm2 save +pm2 list +``` + +升级代码后: + +```bash +cd /opt/crypto_monitor && 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 +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.git crypto_monitor +chown -R root:root /opt/crypto_monitor +``` + +- 数据库默认:各所 **`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/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) | `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 index d941261..23cf8a7 100644 --- a/docs/中控AI与密钥配置.md +++ b/docs/中控AI与密钥配置.md @@ -1,6 +1,6 @@ # 中控 AI 与部署密钥配置 -本文档说明:**长期部署密钥**(一次生成、不轮换)、**SSO 临时链接**(保持不动)、以及 **AI 配置**(中控统一维护并同步三所)。 +本文档说明:**长期部署密钥**(一次生成,不轮换),**SSO 临时链接**(保持不动),以及 **AI 配置**(中控统一维护并同步三所). --- @@ -8,19 +8,19 @@ | 类型 | 变量 / 机制 | 谁维护 | 是否自动过期 | |------|-------------|--------|--------------| -| **长期通信密钥** | `HUB_BRIDGE_TOKEN` | 部署脚本首次写入四份 `.env` | 否,不轮换 | +| **长期通信密钥** | `HUB_BRIDGE_TOKEN` | 部署脚本首次写入四份 `.env` | 否,不轮换 | | **实例 Session 签名** | `FLASK_SECRET_KEY` | 部署脚本首次写入三实例 | 否 | | **中控 Session 签名** | `HUB_SESSION_SECRET` | 部署脚本首次写入中控 | 否 | | **SSO 开门链接** | `/hub-sso?token=...` | 中控每次点「打开实例」签发 | 默认 2h + 单次 | -| **AI 配置** | `OPENAI_*`、`AI_*` 等 | 中控系统设置 → AI 配置 | 否 | +| **AI 配置** | `OPENAI_*`,`AI_*` 等 | 中控系统设置 → AI 配置 | 否 | -**SSO 保持不动**:仍为随机 nonce、默认 `HUB_SSO_TTL_SEC=7200`、成功登录一次后链接作废。长期 `HUB_BRIDGE_TOKEN` 只用于签名,不会每 2 小时变化。 +**SSO 保持不动**:仍为随机 nonce,默认 `HUB_SSO_TTL_SEC=7200`,成功登录一次后链接作废.长期 `HUB_BRIDGE_TOKEN` 只用于签名,不会每 2 小时变化. --- ## 2. 首次部署自动生成 -`bash deploy/setup_env.sh` 在复制 `.env.example` 后自动执行: +`bash deploy/setup_env.sh` 在复制 `.env.example` 后自动执行: ```bash python3 scripts/bootstrap_deploy_secrets.py @@ -28,21 +28,21 @@ python3 scripts/bootstrap_deploy_secrets.py | 写入项 | 位置 | 规则 | |--------|------|------| -| `HUB_BRIDGE_TOKEN` | 中控 + 三实例(同值) | 仅空或占位符时写入 | -| `FLASK_SECRET_KEY` | 三实例(同值) | 仅空或占位符时写入 | +| `HUB_BRIDGE_TOKEN` | 中控 + 三实例(同值) | 仅空或占位符时写入 | +| `FLASK_SECRET_KEY` | 三实例(同值) | 仅空或占位符时写入 | | `HUB_SESSION_SECRET` | 中控 | 仅空时写入 | -| `HUB_USERNAME` / `HUB_PASSWORD` | 中控 | 默认 admin / admin123(仅空时) | -| `APP_USERNAME` / `APP_PASSWORD` | 三实例 | 默认 admin / admin123(仅空时) | +| `HUB_USERNAME` / `HUB_PASSWORD` | 中控 | 默认 admin / admin123(仅空时) | +| `APP_USERNAME` / `APP_PASSWORD` | 三实例 | 默认 admin / admin123(仅空时) | -**已有非空生产值不会被覆盖**(一次生成、不轮换)。 +**已有非空生产值不会被覆盖**(一次生成,不轮换). -子代理 `agent.py` 优先读取 `HUB_BRIDGE_TOKEN` 作为 `X-Control-Token` 校验;独立配置 `CONTROL_TOKEN` 已废弃。 +子代理 `agent.py` 优先读取 `HUB_BRIDGE_TOKEN` 作为 `X-Control-Token` 校验;独立配置 `CONTROL_TOKEN` 已废弃. --- ## 3. 中控系统设置 → AI 配置 -路径:**中控 Web → 系统设置 → AI 配置** Tab。 +路径:**中控 Web → 系统设置 → AI 配置** Tab. ### 3.1 可配置项 @@ -50,34 +50,34 @@ python3 scripts/bootstrap_deploy_secrets.py |--------|----------| | AI 提供方 | `AI_PROVIDER` | | API 地址 | `OPENAI_API_BASE` | -| API 密钥 | `OPENAI_API_KEY`(掩码,留空不修改) | +| API 密钥 | `OPENAI_API_KEY`(掩码,留空不修改) | | 云端模型 | `OPENAI_MODEL` | | Ollama 地址 | `OLLAMA_API` | | Ollama 模型 | `AI_MODEL` | -| 请求超时(秒) | `AI_TIMEOUT_SECONDS` | +| 请求超时(秒) | `AI_TIMEOUT_SECONDS` | ### 3.2 保存行为 1. 写入 `manual_trading_hub/.env` 2. **强制同步** 至 `crypto_monitor_okx/binance/gate/.env` 相同键 -3. 自动 `pm2 restart` 中控 + 三实例(`--update-env`) +3. 自动 `pm2 restart` 中控 + 三实例(`--update-env`) -### 3.3 API(需已登录中控) +### 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`。 +实现:`lib/env/shared_env_lib.py`,`manual_trading_hub/hub_env_lib.py`. --- ## 4. 实例 env 配置页变更 -三所 **env 配置** 页已 **移除「AI 复盘」卡片**。交易所 API、企业微信、交易执行等仍各所自配。 +三所 **env 配置** 页已 **移除「AI 复盘」卡片**.交易所 API,企业微信,交易执行等仍各所自配. -实例侧若通过 API 提交已移除的 AI 键,会被白名单过滤,不会写入。 +实例侧若通过 API 提交已移除的 AI 键,会被白名单过滤,不会写入. --- @@ -86,18 +86,18 @@ python3 scripts/bootstrap_deploy_secrets.py | 能力 | 实例 env | 实例系统设置 | 中控系统设置 | |------|----------|--------------|--------------| | 交易所 API | ✅ | ❌ | ❌ | -| OpenAI / AI | ❌ | ❌ | ✅(同步三所) | +| OpenAI / AI | ❌ | ❌ | ✅(同步三所) | | 实例登录密码 | ❌ | ✅ | ❌ | | 中控登录密码 | ❌ | ❌ | ✅ | -| Bridge / Flask 长期密钥 | ❌(自动) | ❌ | ❌(自动) | +| Bridge / Flask 长期密钥 | ❌(自动) | ❌ | ❌(自动) | | SSO 链接 | — | — | 每次打开实例自动签发 | --- ## 6. 运维提示 -- 修改 AI 后若未自动重启成功,手动:`pm2 restart manual-trading-hub crypto_okx crypto_binance crypto_gate --update-env` -- 三所 AI 不一致时,中控 AI 配置页会提示「未完全同步」;点 **保存并同步** 即可对齐 -- 备份包可选包含各 `.env`(中控设置 → 备份恢复) +- 修改 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 说明) +相关文档:[env配置说明.md](./env配置说明.md),[系统设置说明.md](./系统设置说明.md),[manual_trading_hub/局域网与反代部署说明.md](../manual_trading_hub/局域网与反代部署说明.md)(SSO 2h 说明) diff --git a/docs/期权方案.md b/docs/期权方案.md index 4e35e08..7fe0369 100644 --- a/docs/期权方案.md +++ b/docs/期权方案.md @@ -1,37 +1,37 @@ # OKX 期权模块 — 技术方案 -> 适用范围:`crypto_monitor_okx` 实例;与永续子账户并行,不新增 PM2 进程。 +> 适用范围:`crypto_monitor_okx` 实例;与永续子账户并行,不新增 PM2 进程. ## 1. 目标 -在现有 OKX 监控实例中增加 **USDⓈ 本位期权(买方)** 能力: +在现有 OKX 监控实例中增加 **USDⓈ 本位期权(买方)** 能力: -- 永续/关键位:继续走 **子账户 API-A**(现有 `OKX_API_*`) -- 期权:走 **主账户 API-B**(`OKX_OPTIONS_API_*`) -- 资金展示对齐 OKX:**资金账户 / 交易账户**,分币种显示 USDT、USDC、USDG +- 永续/关键位:继续走 **子账户 API-A**(现有 `OKX_API_*`) +- 期权:走 **主账户 API-B**(`OKX_OPTIONS_API_*`) +- 资金展示对齐 OKX:**资金账户 / 交易账户**,分币种显示 USDT,USDC,USDG - 支持 **手动 USDT→USDC 兑换** 与 **USDC 账户划转** -- **无总资金池上限**;单笔权利金上限可配置(默认 10 USDC) +- **无总资金池上限**;单笔权利金上限可配置(默认 10 USDC) -## 2. 交易规则(硬约束) +## 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`,以接口为准) | +| 仅买方 | 开仓 `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) | -| 开仓 | 限价买单,价格 = 卖一 | -| 平仓 | 限价卖单,价格 = 买一(市价需显式开启且二次确认) | +| 权利金 | `总权利金 = 报价 × ETH数量`;`张数 = ETH数量 / 0.01` | +| 单笔预算 | `≤ OKX_OPTIONS_TRADE_BUDGET_USDC`(默认 10),算张数 × `OKX_OPTIONS_BUDGET_BUFFER`(默认 0.95) | +| 开仓 | 限价买单,价格 = 卖一 | +| 平仓 | 限价卖单,价格 = 买一(市价需显式开启且二次确认) | | 监控 | 浮盈 / 已付权利金 ≥ 100% → 企业微信推送一次 | ## 3. 架构 ``` -crypto_okx(单 PM2) +crypto_okx(单 PM2) ├── exchange (swap) ← OKX_API_* 子账户 └── exchange_options ← OKX_OPTIONS_API_* 主账户 @@ -43,25 +43,25 @@ lib/options/ └── options_register.py # 路由 + 监控线程 ``` -**隔离:** 期权模块只调用 `exchange_options`;永续逻辑只调用 `exchange`。 +**隔离:** 期权模块只调用 `exchange_options`;永续逻辑只调用 `exchange`. ## 4. 资金与兑换 -### 4.1 展示(期权页顶栏) +### 4.1 展示(期权页顶栏) | 账户 | 币种 | |------|------| -| 资金账户 | USDT、USDC(若有) | -| 交易账户 | USDT、USDC、USDG(若有) | +| 资金账户 | USDT,USDC(若有) | +| 交易账户 | USDT,USDC,USDG(若有) | -不展示「练手池」等抽象记账名称。 +不展示「练手池」等抽象记账名称. ### 4.2 推荐操作流程 ``` 资金账户 USDT - → [手动兑换 USDT→USDC](OKX Convert API,资金账户内) - → [划转到交易账户](USDC) + → [手动兑换 USDT→USDC](OKX Convert API,资金账户内) + → [划转到交易账户](USDC) → 交易账户 USDC → [限价买入期权] ``` @@ -70,12 +70,12 @@ lib/options/ | 接口 | OKX | |------|-----| -| 余额 | `fetch_balance`(funding / trading)+ `GET /api/v5/asset/balances` | +| 余额 | `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`) +## 5. 配置项(`.env`) ```bash OKX_OPTIONS_ENABLED=false @@ -95,17 +95,17 @@ OKX_OPTIONS_TD_MODE=cross OKX_OPTIONS_ALLOW_MARKET_CLOSE=false ``` -修改 `.env` 后须 `pm2 restart crypto_okx`。 +修改 `.env` 后须 `pm2 restart crypto_okx`. ## 6. 数据库 ### `options_trades` -记录本地开仓/平仓、权利金、翻倍提醒状态。 +记录本地开仓/平仓,权利金,翻倍提醒状态. ### `options_convert_log` / `options_transfer_log` -可选记录兑换与划转操作。 +可选记录兑换与划转操作. ## 7. HTTP 路由 @@ -124,20 +124,20 @@ OKX_OPTIONS_ALLOW_MARKET_CLOSE=false ## 8. 分阶段交付 -1. **基础设施**:双 API、余额、文档、设置页说明 -2. **兑换 + 划转**:资金账户 USDT→USDC、划转到交易户 -3. **交易**:链、报价、开平仓、持仓 -4. **监控**:翻倍微信提醒 +1. **基础设施**:双 API,余额,文档,设置页说明 +2. **兑换 + 划转**:资金账户 USDT→USDC,划转到交易户 +3. **交易**:链,报价,开平仓,持仓 +4. **监控**:翻倍微信提醒 ## 9. 不在一期范围 -- 卖方、组合单、RFQ +- 卖方,组合单,RFQ - 自动 USDT↔USDC - `manual-agent-okx` / 中控聚合 - 币本位期权 ## 10. 安全 -- 期权 API:**交易 + 读**,禁止提币 +- 期权 API:**交易 + 读**,禁止提币 - 日志不输出 Secret - 下单前校验 `client is exchange_options` diff --git a/docs/期权用法.md b/docs/期权用法.md index fe51041..220cb4c 100644 --- a/docs/期权用法.md +++ b/docs/期权用法.md @@ -2,8 +2,8 @@ ## 1. 前置条件 -1. OKX **主账户**已开通期权(USDⓈ 本位),且 App 中可见 `ETHUSD UM` / `BTCUSD UM`。 -2. 在 `crypto_monitor_okx/.env` 配置 **期权专用 API**(与永续子账户分开): +1. OKX **主账户**已开通期权(USDⓈ 本位),且 App 中可见 `ETHUSD UM` / `BTCUSD UM`. +2. 在 `crypto_monitor_okx/.env` 配置 **期权专用 API**(与永续子账户分开): ```bash OKX_OPTIONS_ENABLED=true @@ -12,77 +12,77 @@ OKX_OPTIONS_API_SECRET=... OKX_OPTIONS_API_PASSPHRASE=... ``` -3. 重启实例:`pm2 restart crypto_okx` +3. 重启实例:`pm2 restart crypto_okx` -> 永续仍用原有 `OKX_API_*`(子账户);期权只用 `OKX_OPTIONS_API_*`(主账户)。 +> 永续仍用原有 `OKX_API_*`(子账户);期权只用 `OKX_OPTIONS_API_*`(主账户). ## 2. 资金准备 -期权权利金使用 **USDC 或 USDG**,不能直接用 USDT 买入。 +期权权利金使用 **USDC 或 USDG**,不能直接用 USDT 买入. ### 推荐步骤 -1. 打开 **期权** 页,查看顶栏: - - **资金账户**:USDT 余额 - - **交易账户**:USDC 余额(买期权从这里扣) -2. **币种兑换**(资金账户内) +1. 打开 **期权** 页,查看顶栏: + - **资金账户**:USDT 余额 + - **交易账户**:USDC 余额(买期权从这里扣) +2. **币种兑换**(资金账户内) - 从 USDT 兑换为 USDC - - 先点 **询价**,确认预估获得量后点 **确认兑换** + - 先点 **询价**,确认预估获得量后点 **确认兑换** 3. **账户划转** - - 从:资金账户 → 到:交易账户 - - 币种:USDC + - 从:资金账户 → 到:交易账户 + - 币种:USDC - 将兑换得到的 USDC 划到交易账户 4. 确认 **交易账户 USDC** 足够支付本笔权利金 -系统 **不会** 自动兑换或划转,避免误动资金。 +系统 **不会** 自动兑换或划转,避免误动资金. ## 3. 下单流程 1. 顶栏进入 **期权** 2. 选择 **ETH** 或 **BTC** -3. 选择 **到期日**(默认仅 1~2 日) +3. 选择 **到期日**(默认仅 1~2 日) 4. 选择 **看涨 Call** 或 **看跌 Put** 5. 在行权价列表中选 **轻度实值** 合约 -6. 查看: - - **卖一价**(每 1 ETH/BTC 的报价) +6. 查看: + - **卖一价**(每 1 ETH/BTC 的报价) - **张数 / ETH 数量** - - **预估权利金**(USDC) -7. 选择 **按预算打满**(默认 10U×0.95)或 **指定 ETH 数量** -8. 点击 **限价买入**(价格 = 卖一) + - **预估权利金**(USDC) +7. 选择 **按预算打满**(默认 10U×0.95)或 **指定 ETH 数量** +8. 点击 **限价买入**(价格 = 卖一) ### 张数说明 -- **1 张 = 0.01 ETH**(或 0.01 BTC)— 与 OKX App「合约价值」一致 +- **1 张 = 0.01 ETH**(或 0.01 BTC)— 与 OKX App「合约价值」一致 - 盘口报价是 **每 1 ETH** 的价格 - 例:报价 15.6,买 0.5 ETH(50 张)→ 权利金 ≈ 15.6 × 0.5 = **7.8 USDC** + 例:报价 15.6,买 0.5 ETH(50 张)→ 权利金 ≈ 15.6 × 0.5 = **7.8 USDC** ## 4. 持仓与平仓 -持仓表字段对齐 OKX:合约、张数、开仓均价、标记价、浮盈、收益率、到期等。 +持仓表字段对齐 OKX:合约,张数,开仓均价,标记价,浮盈,收益率,到期等. -**平仓(锁利/止损):** +**平仓(锁利/止损):** 1. 在持仓行点击 **平仓** 2. 查看 **买一价** 与预估收回 -3. 确认 **限价卖出**(价格 = 买一) +3. 确认 **限价卖出**(价格 = 买一) -> 默认不使用市价平仓。若 `.env` 开启 `OKX_OPTIONS_ALLOW_MARKET_CLOSE=true`,市价按钮会出现并带风险提示。 +> 默认不使用市价平仓.若 `.env` 开启 `OKX_OPTIONS_ALLOW_MARKET_CLOSE=true`,市价按钮会出现并带风险提示. ## 5. 微信提醒 -当某笔持仓 **未实现盈亏 ≥ 已付权利金的 100%**(翻倍)时,会发 **一条** 企业微信提醒(同一笔只提醒一次)。 +当某笔持仓 **未实现盈亏 ≥ 已付权利金的 100%**(翻倍)时,会发 **一条** 企业微信提醒(同一笔只提醒一次). -需已配置 `WECHAT_WEBHOOK`。 +需已配置 `WECHAT_WEBHOOK`. ## 6. 与永续的关系 -| | 永续(子账户) | 期权(主账户) | +| | 永续(子账户) | 期权(主账户) | |--|----------------|----------------| | API | `OKX_API_*` | `OKX_OPTIONS_API_*` | | 页面 | 实盘下单 / 关键位 | 期权 | | 资金顶栏 | USDT 资金户+交易户 | 期权页单独显示 USDC 等 | -两套资金 **不合并** 显示。 +两套资金 **不合并** 显示. ## 7. 配置说明 @@ -91,24 +91,24 @@ OKX_OPTIONS_API_PASSPHRASE=... | `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_ITM_MAX_DIST_USD` | 30 | 轻度实值:价内不超过多少 USD | | `OKX_OPTIONS_PROFIT_ALERT_RATIO` | 1.0 | 浮盈/权利金 ≥ 此值推送 | ## 8. 常见问题 -**Q:为什么买不了?** +**Q:为什么买不了?** - 交易账户 USDC 不足 → 先兑换再划转 -- 卖一价过高,10U 预算买不到 1 张 → 选更便宜合约或提高 `OKX_OPTIONS_TRADE_BUDGET_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:报价 15 是每张 15U 吗?** +- 不是.15 是 **每 1 ETH** 的报价;每张(0.01 ETH)约 0.15 USDC. -**Q:子账户能开期权吗?** -- 本系统期权走主账户 API;子账户永续不受影响。 +**Q:子账户能开期权吗?** +- 本系统期权走主账户 API;子账户永续不受影响. ## 9. 风险说明 -- 买方最大亏损为 **权利金**;近期实值仍会时间衰减 +- 买方最大亏损为 **权利金**;近期实值仍会时间衰减 - 限价单可能因无流动性未成交 -- 请先在小额下验证兑换、划转、开平仓全流程 +- 请先在小额下验证兑换,划转,开平仓全流程 diff --git a/docs/系统设置说明.md b/docs/系统设置说明.md index 96b6240..3c4164a 100644 --- a/docs/系统设置说明.md +++ b/docs/系统设置说明.md @@ -1,12 +1,12 @@ # 系统设置页说明 -本文档描述各交易实例 Web 端 **「系统设置」** 页各区块功能、与 env 配置页的分工,以及导航显示开关规则。 +本文档描述各交易实例 Web 端 **「系统设置」** 页各区块功能,与 env 配置页的分工,以及导航显示开关规则. --- ## 1. 页面结构 -系统设置为 **两列卡片** 布局,各区块可在「导航显示」中单独开关(见第 2 节)。 +系统设置为 **两列卡片** 布局,各区块可在「导航显示」中单独开关(见第 2 节). | 区块 | 默认显示 | 说明 | |------|----------|------| @@ -16,9 +16,9 @@ | 数据导出 | 可关 | 下载 CSV | | 币种兑换 | 可关 | 仅 OKX 期权相关 | | 期权资金划转 | 可关 | 仅 OKX | -| 期权设置面板 | OKX 有模块时 | 较大块,占整行 | +| 期权设置面板 | OKX 有模块时 | 较大块,占整行 | -**固定不可隐藏**(顶栏):关键位监控、实盘下单、系统设置。 +**固定不可隐藏**(顶栏):关键位监控,实盘下单,系统设置. --- @@ -34,9 +34,9 @@ | 统计分析 | 统计分析 | | 风控说明 | 风控说明 | | env 配置 | env 配置 | -| 期权 | 期权(仅 OKX 等有期权模块时有效) | +| 期权 | 期权(仅 OKX 等有期权模块时有效) | -保存后 **立即生效**,无需重启。中控 iframe 内嵌导航同步生效。 +保存后 **立即生效**,无需重启.中控 iframe 内嵌导航同步生效. ### 2.2 系统设置内区块开关 @@ -54,28 +54,28 @@ ### 用途 -- 修改 **直链打开实例** 时 `/login` 使用的用户名与密码。 -- 写入本实例目录 `.env` 的 `APP_USERNAME`、`APP_PASSWORD`。 -- **三所建议使用相同账号**,便于记忆;本页仅改 **当前实例** 的 `.env`,若需三所一致请分别保存或后续做批量同步。 +- 修改 **直链打开实例** 时 `/login` 使用的用户名与密码. +- 写入本实例目录 `.env` 的 `APP_USERNAME`,`APP_PASSWORD`. +- **三所建议使用相同账号**,便于记忆;本页仅改 **当前实例** 的 `.env`,若需三所一致请分别保存或后续做批量同步. ### 与中控 / 密钥的关系 | 项目 | 是否在系统设置改 | 说明 | |------|------------------|------| | 网页登录密码 | ✅ | 本区块 | -| 中控通信密钥 `HUB_BRIDGE_TOKEN` | ❌ | 部署时自动生成,中控与实例一致 | -| 登录会话密钥 `FLASK_SECRET_KEY` | ❌ | 部署时自动生成,三所相同 | -| 交易所 API | ❌ | 在 **env 配置** 页(各所自配) | -| AI 复盘 / OpenAI | ❌ | 在中控 **系统设置 → AI 配置**(同步三所) | +| 中控通信密钥 `HUB_BRIDGE_TOKEN` | ❌ | 部署时自动生成,中控与实例一致 | +| 登录会话密钥 `FLASK_SECRET_KEY` | ❌ | 部署时自动生成,三所相同 | +| 交易所 API | ❌ | 在 **env 配置** 页(各所自配) | +| AI 复盘 / OpenAI | ❌ | 在中控 **系统设置 → AI 配置**(同步三所) | ### 操作流程 -1. 输入 **当前密码**(与 `.env` 中 `APP_PASSWORD` 一致)。 -2. 可选填 **新用户名**;不填则保持原用户名。 -3. 输入 **新密码** 与 **确认密码**(至少 6 位)。 -4. 保存后 **自动重启当前实例**(PM2),请用新密码登录。 +1. 输入 **当前密码**(与 `.env` 中 `APP_PASSWORD` 一致). +2. 可选填 **新用户名**;不填则保持原用户名. +3. 输入 **新密码** 与 **确认密码**(至少 6 位). +4. 保存后 **自动重启当前实例**(PM2),请用新密码登录. -经中控 SSO 打开实例时,通常无需输入实例密码;改密主要影响 **直链访问**。 +经中控 SSO 打开实例时,通常无需输入实例密码;改密主要影响 **直链访问**. --- @@ -83,7 +83,7 @@ ### 用途 -在 **子账户永续** 场景下,于 **资金账户(funding)** 与 **交易账户(swap)** 之间手动划转 USDT。 +在 **子账户永续** 场景下,于 **资金账户(funding)** 与 **交易账户(swap)** 之间手动划转 USDT. ### 与 env 配置的关系 @@ -92,36 +92,36 @@ | **手动**划转一笔 | ✅ 本区块 | ❌ | | **自动**每日划转规则 | ❌ | ✅「自动划转」卡片 | -自动划转规则(开关、目标余额、整点等)在 env 配置中维护,见 [env配置说明.md](./env配置说明.md)。 +自动划转规则(开关,目标余额,整点等)在 env 配置中维护,见 [env配置说明.md](./env配置说明.md). --- ## 5. 数据导出 -提供 CSV 下载(版本号见页内标注): +提供 CSV 下载(版本号见页内标注): | 链接 | 内容 | |------|------| | 交易记录 | 成交/订单相关导出 | | 复盘记录 | 复盘日记 | -| 关键位(当前) | 当前关键位列表 | +| 关键位(当前) | 当前关键位列表 | | 关键位历史 | 历史关键位 | -导出为只读操作,不修改配置。 +导出为只读操作,不修改配置. --- -## 6. 期权相关(仅 OKX) +## 6. 期权相关(仅 OKX) -当实例启用期权模块时,系统设置可能包含: +当实例启用期权模块时,系统设置可能包含: -- **币种兑换**:期权账户内币种兑换操作 -- **期权资金划转**:期权与永续/资金账户间划转 -- **期权设置面板**:页内期权参数与状态(大块区域) +- **币种兑换**:期权账户内币种兑换操作 +- **期权资金划转**:期权与永续/资金账户间划转 +- **期权设置面板**:页内期权参数与状态(大块区域) -是否在顶栏显示「期权」Tab,由 **导航显示 → 期权** 控制;是否在设置页显示兑换/划转卡片,由对应子开关控制。 +是否在顶栏显示「期权」Tab,由 **导航显示 → 期权** 控制;是否在设置页显示兑换/划转卡片,由对应子开关控制. -期权 env 参数(API、预算、策略默认值)在 **env 配置 → 期权账户** 维护,见 [期权用法.md](./期权用法.md)。 +期权 env 参数(API,预算,策略默认值)在 **env 配置 → 期权账户** 维护,见 [期权用法.md](./期权用法.md). --- @@ -129,30 +129,30 @@ | 页面 | 顶栏资金信息 | 说明 | |------|--------------|------| -| 关键位、实盘、策略等 | 显示 | 含资金、盈亏等 | -| 系统设置、风控说明、env 配置 | 隐藏资金条 | 与实盘顶栏共用组件,设置类页面简化展示 | +| 关键位,实盘,策略等 | 显示 | 含资金,盈亏等 | +| 系统设置,风控说明,env 配置 | 隐藏资金条 | 与实盘顶栏共用组件,设置类页面简化展示 | -主题切换(明/暗)在系统设置页可用(若已接入主题切换 UI)。 +主题切换(明/暗)在系统设置页可用(若已接入主题切换 UI). --- ## 8. 权限与安全 -- 所有设置 API 需 **已登录**(或部署时 `APP_AUTH_DISABLED=true` 的联调环境)。 -- 改密、env 保存、PM2 重启等写操作 **不接受** 仅带 `X-Hub-Token` 的中控请求修改(防止中控误改实例配置)。 -- 生产环境建议 `APP_AUTH_DISABLED=false`,公网务必开启登录校验。 +- 所有设置 API 需 **已登录**(或部署时 `APP_AUTH_DISABLED=true` 的联调环境). +- 改密,env 保存,PM2 重启等写操作 **不接受** 仅带 `X-Hub-Token` 的中控请求修改(防止中控误改实例配置). +- 生产环境建议 `APP_AUTH_DISABLED=false`,公网务必开启登录校验. --- -## 9. 首次部署时的账号与密钥(规划) +## 9. 首次部署时的账号与密钥(规划) -以下由 **部署脚本** 自动完成,**不在** 系统设置或 env 配置页手工填写: +以下由 **部署脚本** 自动完成,**不在** 系统设置或 env 配置页手工填写: -1. **生成 `HUB_BRIDGE_TOKEN`** → 写入中控 + 三实例 `.env`(相同)。 -2. **生成 `FLASK_SECRET_KEY`** → 写入三实例 `.env`(三所相同)。 -3. **生成初始 `APP_USERNAME=admin`、`APP_PASSWORD=admin123`** → 写入三实例(仅当尚未配置时);用户日后在 **系统设置** 改密。 +1. **生成 `HUB_BRIDGE_TOKEN`** → 写入中控 + 三实例 `.env`(相同). +2. **生成 `FLASK_SECRET_KEY`** → 写入三实例 `.env`(三所相同). +3. **生成初始 `APP_USERNAME=admin`,`APP_PASSWORD=admin123`** → 写入三实例(仅当尚未配置时);用户日后在 **系统设置** 改密. -脚本应对 **已有非空值** 跳过写入,避免覆盖生产环境。 +脚本应对 **已有非空值** 跳过写入,避免覆盖生产环境. --- diff --git a/lib/ai/ai_client.py b/lib/ai/ai_client.py index 3ab2a88..2725ed2 100644 --- a/lib/ai/ai_client.py +++ b/lib/ai/ai_client.py @@ -1,7 +1,7 @@ -"""大模型调用:OpenAI 兼容接口(默认)或本机 Ollama 二选一。 +"""大模型调用:OpenAI 兼容接口(默认)或本机 Ollama 二选一. -配置从 os.environ 惰性读取:各实例 app.py 在 import 本模块后才 load_env_file(.env), -若在 import 时缓存变量会导致 OPENAI_API_KEY 始终为空。 +配置从 os.environ 惰性读取:各实例 app.py 在 import 本模块后才 load_env_file(.env), +若在 import 时缓存变量会导致 OPENAI_API_KEY 始终为空. """ from __future__ import annotations @@ -142,7 +142,7 @@ def _openai_chat_completion( ) -> Tuple[str, str]: api_key = _openai_api_key() if not api_key: - return "AI 调用失败:未配置 OPENAI_API_KEY(请在当前实例目录 .env 中设置,修改后需重启服务)", "error" + return "AI 调用失败:未配置 OPENAI_API_KEY(请在当前实例目录 .env 中设置,修改后需重启服务)", "error" headers = { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", @@ -164,7 +164,7 @@ def _openai_chat_completion( data = r.json() choices = data.get("choices") or [] if not choices: - return "AI 生成失败:响应无 choices", "error" + return "AI 生成失败:响应无 choices", "error" choice = choices[0] or {} msg = choice.get("message") or {} text = _openai_message_text(msg) @@ -187,7 +187,7 @@ def _openai_chat_completion( if text2: return text2, str((choices2[0] or {}).get("finish_reason") or finish) if not text: - return "AI 生成失败:空内容", finish or "error" + return "AI 生成失败:空内容", finish or "error" return text, finish @@ -257,7 +257,7 @@ def ai_generate( temperature: float = 0.2, max_tokens: int | None = None, ) -> str: - """统一文本生成;失败时返回以「AI 调用失败」开头的说明。""" + """统一文本生成;失败时返回以「AI 调用失败」开头的说明.""" images = _collect_images(image_paths, images_b64) try: if _use_openai(): @@ -271,17 +271,17 @@ def ai_generate( 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)}" + 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)}" + return f"AI 调用失败({prov}):{str(e)}" _CHAT_CONTINUE_USER = ( - "你上一条回复在中途截断了。请从断点处继续写完,不要重复已写内容," - "保持同一语气;编号列表每条单独一行。" + "你上一条回复在中途截断了.请从断点处继续写完,不要重复已写内容," + "保持同一语气;编号列表每条单独一行." ) -_CHAT_END_CHARS = "。!?.!?\"」』))>】" +_CHAT_END_CHARS = ".!?.!?\"」』))>】" _INCOMPLETE_TAIL_RE = re.compile( r"(不会|不能|没有|会不会|是不是|够不够|能不能|要不要|如何|怎么|什么|哪里|多少|对吗|怎么样|" r"这个\.\.\.|这个…|\.\.\.\d+\.|\d+\.)$" @@ -300,7 +300,7 @@ def _looks_truncated(text: str) -> bool: return True if re.search(r"\d+\.\s*$", t): return True - return t[-1] not in ",、,;;::\n" + return t[-1] not in ",,,;;::\n" def _should_continue(reason: str, full_text: str) -> bool: @@ -313,16 +313,16 @@ 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"请从断点接着写完。不要重复前文;最后一句话必须以句号、问号或感叹号结束。" + f"已写到最后这几句:\n「{tail}」\n\n" + f"请从断点接着写完.不要重复前文;最后一句话必须以句号,问号或感叹号结束." ) def _chat_continue_system(system: str) -> str: return ( f"{system.strip()}\n\n" - "【续写模式】只输出断点后的剩余内容,不要重复前文;" - "列表每条单独一行;必须以句号、问号或感叹号收尾。" + "【续写模式】只输出断点后的剩余内容,不要重复前文;" + "列表每条单独一行;必须以句号,问号或感叹号收尾." ) @@ -335,7 +335,7 @@ def ai_generate_chat( max_tokens: int = 8192, max_continuations: int = 4, ) -> str: - """聊天专用:system/user 分消息;输出触顶时轻量续写(不重复巨型上下文)。""" + """聊天专用:system/user 分消息;输出触顶时轻量续写(不重复巨型上下文).""" images = _collect_images(None, images_b64) max_rounds = max(1, int(max_continuations) + 1) try: @@ -377,7 +377,7 @@ def ai_generate_chat( {"role": "assistant", "content": full}, {"role": "user", "content": _chat_continue_message(full)}, ] - return "".join(parts).strip() or "AI 生成失败:空内容" + return "".join(parts).strip() or "AI 生成失败:空内容" prompt = f"{system.strip()}\n\n---\n\n{user.strip()}" parts: list[str] = [] @@ -405,7 +405,7 @@ def ai_generate_chat( full = "".join(parts) if not _should_continue(reason, full) or attempt >= max_rounds - 1: break - return "".join(parts).strip() or "AI 生成失败:空内容" + return "".join(parts).strip() or "AI 生成失败:空内容" except requests.HTTPError as e: detail = "" try: @@ -413,46 +413,46 @@ def ai_generate_chat( 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)}" + 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)}" + return f"AI 调用失败({prov}):{str(e)}" def ai_review(trades_text: str, period_title: str, image_paths=None) -> str: n_img = len(image_paths or []) period_label = "周" if "周" in str(period_title) else "日" attach_note = ( - f"ℹ️ 【系统说明:已向模型附带 {n_img} 张复盘附图(自动K线或上传截图),请结合附图分析第5节。】\n\n" + f"ℹ️ 【系统说明:已向模型附带 {n_img} 张复盘附图(自动K线或上传截图),请结合附图分析第5节.】\n\n" if n_img - else "ℹ️ 【系统说明:本次未附带复盘附图,第5节请写明「无附图,无法看图」;保存复盘记录时可勾选「自动生成K线图」。】\n\n" + else "ℹ️ 【系统说明:本次未附带复盘附图,第5节请写明「无附图,无法看图」;保存复盘记录时可勾选「自动生成K线图」.】\n\n" ) prompt = f""" -你是一位专业交易教练。下面是用户的{period_title}交易记录,请做简洁、可执行的复盘(中文)。 +你是一位专业交易教练.下面是用户的{period_title}交易记录,请做简洁,可执行的复盘(中文). 【硬性规则 — 必须遵守】 -- 你只能根据「交易记录」里**明确出现的字段**陈述事实;禁止编造:是否触发止损、是否扛单、亏损是否扩大、图上具体结构/进出场点位等记录里**没有**的信息。 -- 「平仓/离场」只是交易员自述摘要,不是客观成交明细;若记录未写明代币是否打到止损价、是否软件平仓等,不要断言执行路径,可用「在记录有限前提下,一种可能是……」或简短写「执行路径记录不足,无法判断」。 -- 「提前离场」类结论必须优先依据记录中的「提前离场记录」字段;若该段全为「无」或未出现有效内容,不得写道「明显扛单」「拒不止损」「未执行硬止损」等。 -- 实际RR为负只说明结果相对于预期RR不利,不等同于「风控失灵」或「止损纪律崩溃」,除非记录里另有依据。 -- 禁止用语:人身攻击、夸张定性(如「致命伤」「灾难」);语气克制、对事不对人。 -- 若有截图且你能辨认,再结合图讨论;看不清或无明确定位则明确说「无法从图确认」,不得虚构 K 线故事。 +- 你只能根据「交易记录」里**明确出现的字段**陈述事实;禁止编造:是否触发止损,是否扛单,亏损是否扩大,图上具体结构/进出场点位等记录里**没有**的信息. +- 「平仓/离场」只是交易员自述摘要,不是客观成交明细;若记录未写明代币是否打到止损价,是否软件平仓等,不要断言执行路径,可用「在记录有限前提下,一种可能是……」或简短写「执行路径记录不足,无法判断」. +- 「提前离场」类结论必须优先依据记录中的「提前离场记录」字段;若该段全为「无」或未出现有效内容,不得写道「明显扛单」「拒不止损」「未执行硬止损」等. +- 实际RR为负只说明结果相对于预期RR不利,不等同于「风控失灵」或「止损纪律崩溃」,除非记录里另有依据. +- 禁止用语:人身攻击,夸张定性(如「致命伤」「灾难」);语气克制,对事不对人. +- 若有截图且你能辨认,再结合图讨论;看不清或无明确定位则明确说「无法从图确认」,不得虚构 K 线故事. -【输出格式 — Markdown,必须严格遵守】 -- 第一行:**交易复盘报告({period_label}度)** -- 五个大节标题必须**完全一致**(含 emoji,不要用其它编号或改名): +【输出格式 — Markdown,必须严格遵守】 +- 第一行:**交易复盘报告({period_label}度)** +- 五个大节标题必须**完全一致**(含 emoji,不要用其它编号或改名): **1. 📊 总体盈亏结构** **2. 🧠 心态与执行** **3. 🏷️ 行为标签** **4. ✅ 改进建议** **5. 📈 图表分析** -- 每节正文用 `- **子项名**:内容` 列表;第4节改进建议用有序列表 `1. 2. 3.` -- 第1节至少包含:**笔数/盈亏**、**风险回报比**、**总结** -- 第2节至少包含:**得分**(1–10)、**依据**(对应记录字段) -- 第5节至少包含:**趋势确认**、**执行路径**(记录不足则写明) -- 语气简洁,少形容词;不要输出代码块、不要表格 +- 每节正文用 `- **子项名**:内容` 列表;第4节改进建议用有序列表 `1. 2. 3.` +- 第1节至少包含:**笔数/盈亏**,**风险回报比**,**总结** +- 第2节至少包含:**得分**(1–10),**依据**(对应记录字段) +- 第5节至少包含:**趋势确认**,**执行路径**(记录不足则写明) +- 语气简洁,少形容词;不要输出代码块,不要表格 -交易记录: +交易记录: {trades_text} """.strip() return attach_note + ai_generate(prompt, image_paths=image_paths, temperature=0.2) @@ -460,12 +460,12 @@ def ai_review(trades_text: str, period_title: str, image_paths=None) -> str: def ai_short_advice(prompt_text: str) -> str: prompt = f""" -你是交易风控助理。请用中文给出**最多 3 条**提醒,要求: +你是交易风控助理.请用中文给出**最多 3 条**提醒,要求: - 每条不超过 25 个字 -- 语气克制、具体、可执行 -- 不要输出 Markdown,不要编号前缀以外的废话 +- 语气克制,具体,可执行 +- 不要输出 Markdown,不要编号前缀以外的废话 -场景: +场景: {prompt_text} """.strip() return ai_generate(prompt, temperature=0.2) @@ -478,7 +478,7 @@ def ai_provider_label() -> str: def ai_config_status() -> dict: - """调试用:当前进程内读到的 AI 配置(不含密钥明文)。""" + """调试用:当前进程内读到的 AI 配置(不含密钥明文).""" key = _openai_api_key() return { "provider": _ai_provider(), diff --git a/lib/ai/ai_review_lib.py b/lib/ai/ai_review_lib.py index e385e38..c81f443 100644 --- a/lib/ai/ai_review_lib.py +++ b/lib/ai/ai_review_lib.py @@ -1,178 +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 +"""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/auto_transfer_daily_lib.py b/lib/common/auto_transfer_daily_lib.py index da2050a..aaab5bf 100644 --- a/lib/common/auto_transfer_daily_lib.py +++ b/lib/common/auto_transfer_daily_lib.py @@ -1,9 +1,9 @@ """ -每日自动划转:北京时间指定整点小时内,将交易账户(AUTO_TRANSFER_TO)余额调整至目标额。 +每日自动划转:北京时间指定整点小时内,将交易账户(AUTO_TRANSFER_TO)余额调整至目标额. -- 交易账户 < 目标:从资金账户划入差额 -- 交易账户 > 目标:将多余划回资金账户 -- 有 active 持仓:不划转,写账簿并企业微信说明 +- 交易账户 < 目标:从资金账户划入差额 +- 交易账户 > 目标:将多余划回资金账户 +- 有 active 持仓:不划转,写账簿并企业微信说明 """ from __future__ import annotations @@ -65,12 +65,12 @@ def run_auto_transfer_once_per_day( active = get_active_position_count(conn) if active > 0: - msg = f"持仓中({active}笔),本次资金无划转" + 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()}" + f"自动划转:{msg}\n" + f"目标:{to_account} 调整至 {round(float(target_amount), funds_decimals)}U\n" + f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" ) return @@ -95,7 +95,7 @@ def run_auto_transfer_once_per_day( from_account, to_account, "skipped", - f"{to_account}账户已为{trade}U(目标{target}U)", + f"{to_account}账户已为{trade}U(目标{target}U)", ) return @@ -109,10 +109,10 @@ def run_auto_transfer_once_per_day( 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") + _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()}" + f"自动划转失败:{fr}余额不足,需{amount}U,当前{cur}U({action}至{to_account}目标{target}U)\n" + f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" ) return @@ -120,11 +120,11 @@ def run_auto_transfer_once_per_day( _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()}" + 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()}" + f"自动划转失败:计划{action}{amount}U {fr}->{to}(目标{target}U)\n原因:{msg}\n" + f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" ) diff --git a/lib/common/form_submit_lib.py b/lib/common/form_submit_lib.py index 43f43a9..687fccf 100644 --- a/lib/common/form_submit_lib.py +++ b/lib/common/form_submit_lib.py @@ -1,4 +1,4 @@ -"""防重复提交:Flask session 短窗口去重(下单 / 关键位等)。""" +"""防重复提交:Flask session 短窗口去重(下单 / 关键位等).""" from __future__ import annotations import time @@ -19,8 +19,8 @@ def check_duplicate_submit( ttl: float = DEFAULT_SUBMIT_GUARD_TTL, ) -> Optional[str]: """ - 同一 scope 在 ttl 秒内仅允许通过一次。 - 返回提示文案表示应拒绝;返回 None 表示可继续处理。 + 同一 scope 在 ttl 秒内仅允许通过一次. + 返回提示文案表示应拒绝;返回 None 表示可继续处理. """ scope = (scope or "").strip() if not scope: @@ -28,7 +28,7 @@ def check_duplicate_submit( now = time.time() locks = _prune_locks(session.get("_form_submit_guard") or {}, now) if scope in locks: - return "请求正在处理或刚提交过,请勿重复点击(请等待页面刷新后再试)" + return "请求正在处理或刚提交过,请勿重复点击(请等待页面刷新后再试)" locks[scope] = now + float(ttl) session["_form_submit_guard"] = locks try: diff --git a/lib/common/history_window_lib.py b/lib/common/history_window_lib.py index 36454e3..760f13a 100644 --- a/lib/common/history_window_lib.py +++ b/lib/common/history_window_lib.py @@ -1,187 +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) +"""列表/导出用 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 index 34e458e..bd47181 100644 --- a/lib/common/static/account_risk_badge.css +++ b/lib/common/static/account_risk_badge.css @@ -1,150 +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; -} +/* 账户风控状态徽章 — 三所实例 + 中控共用;兼容 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 index 0417af6..68fe7dd 100644 --- a/lib/common/static/account_risk_badge.js +++ b/lib/common/static/account_risk_badge.js @@ -1,120 +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); +/** + * 账户风控徽章倒计时 — 三所实例 + 中控共用. + */ +(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 index b32c375..8330aba 100644 --- a/lib/common/static/ai_review_render.js +++ b/lib/common/static/ai_review_render.js @@ -1,5 +1,5 @@ /** - * AI 日复盘 / 周复盘:Markdown 子集渲染 + 五节大标题图标兜底 + * AI 日复盘 / 周复盘:Markdown 子集渲染 + 五节大标题图标兜底 */ (function (global) { "use strict"; @@ -40,8 +40,8 @@ if (/^【系统说明/m.test(out) && !/^ℹ️/m.test(out)) { out = out.replace(/^【系统说明/gm, "ℹ️ 【系统说明"); } - if (/^原始记录:/m.test(out) && !/^📎/m.test(out)) { - out = out.replace(/^原始记录:/gm, "📎 **原始记录**"); + if (/^原始记录:/m.test(out) && !/^📎/m.test(out)) { + out = out.replace(/^原始记录:/gm, "📎 **原始记录**"); } return out; } @@ -53,7 +53,7 @@ return false; } - /** 编号列表项之间的空行不拆段,避免每条都从 1 重新开始 */ + /** 编号列表项之间的空行不拆段,避免每条都从 1 重新开始 */ function preprocessListBlanks(text) { var lines = String(text || "").replace(/\r\n/g, "\n").split("\n"); var out = []; @@ -141,7 +141,7 @@ return; } closeLists(); - if (/^📎\s*\*\*原始记录\*\*/.test(trimmed) || /^原始记录:/.test(trimmed)) { + if (/^📎\s*\*\*原始记录\*\*/.test(trimmed) || /^原始记录:/.test(trimmed)) { html.push('
' + parseInline(trimmed) + "
"); return; } @@ -164,7 +164,7 @@ el.classList.remove("ai-result-md"); el.classList.add("is-loading"); el.innerHTML = ""; - el.innerText = opts.message || "生成复盘中,请稍候…"; + el.innerText = opts.message || "生成复盘中,请稍候…"; } if (btn) { btn.disabled = true; diff --git a/lib/common/static/focus_chart_page.css b/lib/common/static/focus_chart_page.css index 2f3966c..608b1e9 100644 --- a/lib/common/static/focus_chart_page.css +++ b/lib/common/static/focus_chart_page.css @@ -1,4 +1,4 @@ -/* 实盘/关键位放大页:与 instance_theme 联动,高对比 meta + 主题感知图表区 */ +/* 实盘/关键位放大页:与 instance_theme 联动,高对比 meta + 主题感知图表区 */ body.focus-page { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; padding: 14px; diff --git a/lib/common/static/focus_chart_page.js b/lib/common/static/focus_chart_page.js index ded6202..8d2fc5b 100644 --- a/lib/common/static/focus_chart_page.js +++ b/lib/common/static/focus_chart_page.js @@ -1,5 +1,5 @@ /** - * 实盘/关键位放大 K 线:交易所 tick 精度、主题感知图表、高对比 meta。 + * 实盘/关键位放大 K 线:交易所 tick 精度,主题感知图表,高对比 meta. */ (function (global) { "use strict"; diff --git a/lib/common/static/form_submit_guard.js b/lib/common/static/form_submit_guard.js index a732bd3..25b56e0 100644 --- a/lib/common/static/form_submit_guard.js +++ b/lib/common/static/form_submit_guard.js @@ -1,5 +1,5 @@ /** - * 表单提交防重复:网络慢时禁用按钮并显示「提交中」。 + * 表单提交防重复:网络慢时禁用按钮并显示「提交中」. */ (function (global) { "use strict"; @@ -49,7 +49,7 @@ return !!(form && form.dataset.submitGuard === "locked"); } - /** 已锁定时仅更新按钮文案(校验通过 → 真正提交前) */ + /** 已锁定时仅更新按钮文案(校验通过 → 真正提交前) */ function setSubmitLabel(form, label) { if (!form || !label) return; submitButtons(form).forEach(function (btn) { @@ -58,7 +58,7 @@ }); } - /** 已通过前端校验,发起最终 POST(页面将跳转) */ + /** 已通过前端校验,发起最终 POST(页面将跳转) */ function nativeSubmitOnce(form, label) { if (!form) return; var text = label || "提交中…"; diff --git a/lib/common/static/instance_embed.js b/lib/common/static/instance_embed.js index e3e32e7..70c4843 100644 --- a/lib/common/static/instance_embed.js +++ b/lib/common/static/instance_embed.js @@ -1,6 +1,6 @@ /** - * 中控 iframe 壳:顶栏/统计常驻,tab 内容走 /api/embed/page/。 - * 各 tab 面板常驻 DOM,切换时 show/hide;脚本延后到首次激活,回访零请求。 + * 中控 iframe 壳:顶栏/统计常驻,tab 内容走 /api/embed/page/. + * 各 tab 面板常驻 DOM,切换时 show/hide;脚本延后到首次激活,回访零请求. */ (function (global) { const TAB_PATH = { @@ -22,7 +22,7 @@ const tabPanes = new Map(); const tabBooted = new Set(); - /** 自带校验后 form.submit() 的表单,勿在捕获阶段再 fetch 一份(会双发 POST) */ + /** 自带校验后 form.submit() 的表单,勿在捕获阶段再 fetch 一份(会双发 POST) */ const CUSTOM_SUBMIT_FORM_IDS = new Set(["add-order-form", "key-form", "roll-form"]); function isEmbedShell() { @@ -234,7 +234,7 @@ }); const ct = (r.headers.get("content-type") || "").toLowerCase(); if (!ct.includes("application/json")) { - throw new Error("加载失败(HTTP " + r.status + ")"); + throw new Error("加载失败(HTTP " + r.status + ")"); } const j = await r.json(); if (!j.ok || !j.html) throw new Error(j.msg || "加载失败"); diff --git a/lib/common/static/instance_live.js b/lib/common/static/instance_live.js index ce30606..b5d1125 100644 --- a/lib/common/static/instance_live.js +++ b/lib/common/static/instance_live.js @@ -1,5 +1,5 @@ /** - * embed 壳:SSE 收到后台 tick 后拉 JSON 快照更新 DOM,切换 tab 不再重复请求 HTML。 + * embed 壳:SSE 收到后台 tick 后拉 JSON 快照更新 DOM,切换 tab 不再重复请求 HTML. */ (function (global) { let liveEventSource = null; diff --git a/lib/common/static/instance_page.css b/lib/common/static/instance_page.css index 179206f..01e3131 100644 --- a/lib/common/static/instance_page.css +++ b/lib/common/static/instance_page.css @@ -1,240 +1,240 @@ -.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} - .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} +.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} + .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} diff --git a/lib/common/static/instance_records_mobile.js b/lib/common/static/instance_records_mobile.js index 12e8cb9..8f165d2 100644 --- a/lib/common/static/instance_records_mobile.js +++ b/lib/common/static/instance_records_mobile.js @@ -1,5 +1,5 @@ /** - * 手机端:交易记录 / 复盘记录紧凑列表(币种 · 方向 · 盈亏),点击展开详情。 + * 手机端:交易记录 / 复盘记录紧凑列表(币种 · 方向 · 盈亏),点击展开详情. */ (function (global) { "use strict"; diff --git a/lib/common/static/instance_settings_prefs.js b/lib/common/static/instance_settings_prefs.js index 22f76d1..b3290a0 100644 --- a/lib/common/static/instance_settings_prefs.js +++ b/lib/common/static/instance_settings_prefs.js @@ -1,5 +1,5 @@ /** - * 实例:导航显示、env 配置、改密、PM2 重启。 + * 实例:导航显示,env 配置,改密,PM2 重启. */ (function (global) { const DISPLAY = () => global.__INSTANCE_DISPLAY__ || {}; @@ -135,7 +135,7 @@ body: JSON.stringify({ display }), }); applyDisplayToNav(data.display || display); - setStatus(status, "已保存,导航已更新"); + setStatus(status, "已保存,导航已更新"); } catch (e) { setStatus(status, e.message || "保存失败", true); } @@ -194,7 +194,7 @@ cur.appendChild(masked); row.appendChild(cur); } - input.placeholder = field.has_value ? "修改时填写新值,留空不修改" : "请输入"; + input.placeholder = field.has_value ? "修改时填写新值,留空不修改" : "请输入"; } else { input.type = "text"; input.value = field.current || field.default || ""; @@ -239,7 +239,7 @@ if (group.has_restart) { const hint = document.createElement("p"); hint.className = "env-panel-hint"; - hint.textContent = "本组含需重启项,修改后请点「保存并重启」。"; + hint.textContent = "本组含需重启项,修改后请点「保存并重启」."; panel.appendChild(hint); } const grid = document.createElement("div"); @@ -303,12 +303,12 @@ }); const needRestart = restartAfter || data.restart_required; if (needRestart) { - setStatus(status, "已保存,正在重启实例…"); + setStatus(status, "已保存,正在重启实例…"); await restartInstance(); setStatus(status, "保存并重启完成"); await loadEnvConfig(); } else { - setStatus(status, "已保存(即时生效项已应用)"); + setStatus(status, "已保存(即时生效项已应用)"); await loadEnvConfig(); } } catch (e) { @@ -344,9 +344,9 @@ body: JSON.stringify(body), }); if (data.restart_required) { - setStatus(status, "密码已保存,正在重启…"); + setStatus(status, "密码已保存,正在重启…"); await restartInstance(); - setStatus(status, "密码已更新,请用新密码登录"); + setStatus(status, "密码已更新,请用新密码登录"); } else { setStatus(status, "密码已更新"); } diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css index b9c7e3a..37a23c8 100644 --- a/lib/common/static/instance_theme.css +++ b/lib/common/static/instance_theme.css @@ -1,3494 +1,3494 @@ -/* 实例页手机端:与中控一致,桌面专属区块隐藏;下载仅电脑端 */ -@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: #006e9a !important; - border-color: rgba(0, 95, 140, 0.22) !important; -} - -html[data-theme="light"] .top-nav a.active { - background: rgba(0, 110, 154, 0.12) !important; - color: #142232 !important; -} - -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: #9aa; - margin: 0; -} - -.instance-header-theme { - flex: 0 0 auto; -} - -.list-window-label { - color: #cfd3ef; - font-size: 0.82rem; - white-space: nowrap; -} - -.list-window-hint { - color: #8892b0; - 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: #8892b0; - margin-bottom: 6px; - white-space: nowrap; -} - -.stat-strip-item .value { - font-size: 0.88rem; - font-weight: 600; - color: #e8ecff; - 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: #142232; -} - -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 auto 10px; -} - -/* ── 交易执行 / 复盘 / 统计(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"] .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: #4a6078 !important; -} - -html[data-theme="light"] .pos-meta-item::after { - color: #b8c8d8 !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: rgba(0, 110, 154, 0.12) !important; - color: #006e9a !important; -} - -html[data-theme="light"] .pos-side-short { - background: rgba(180, 50, 50, 0.1) !important; - color: #b03030 !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; -} - -/* ── 策略交易 / 策略记录(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(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="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; -} - -.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; -} - -.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; -} - -.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-risk-sections { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; - margin-top: 10px; - flex: 1; -} - -.settings-subcard { - padding: 10px 12px; - min-width: 0; - margin: 0; -} - -.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: 8em 1fr; - padding: 4px 0; - font-size: 0.75rem; -} - -@media (max-width: 720px) { - .settings-risk-sections { - grid-template-columns: minmax(0, 1fr); - } -} - -.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); -} -.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 { - 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-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-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 { - display: none; -} -.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-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 { - flex: 1; - display: flex; - flex-direction: column; - gap: 8px; - min-height: 0; -} -.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-live-pane { - flex: 1; - min-height: 100px; - max-height: 220px; - overflow-y: auto; -} -.options-pos-stats-card { - flex-shrink: 0; -} -.options-stats-grid { - display: flex; - flex-wrap: wrap; - gap: 8px 14px; -} -.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; -} -.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 .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; - min-height: 120px; -} -@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; -} -.options-pos-tabs { - display: flex; - gap: 8px; - margin-bottom: 10px; -} -.opt-pos-tab.active { - border-color: #5b8cff; - color: #cfe0ff; - background: rgba(74, 124, 255, 0.28); -} -.options-pos-pane { - min-height: 200px; -} -.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"] .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-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-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"] .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; -} - +/* 实例页手机端:与中控一致,桌面专属区块隐藏;下载仅电脑端 */ +@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: #006e9a !important; + border-color: rgba(0, 95, 140, 0.22) !important; +} + +html[data-theme="light"] .top-nav a.active { + background: rgba(0, 110, 154, 0.12) !important; + color: #142232 !important; +} + +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: #9aa; + margin: 0; +} + +.instance-header-theme { + flex: 0 0 auto; +} + +.list-window-label { + color: #cfd3ef; + font-size: 0.82rem; + white-space: nowrap; +} + +.list-window-hint { + color: #8892b0; + 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: #8892b0; + margin-bottom: 6px; + white-space: nowrap; +} + +.stat-strip-item .value { + font-size: 0.88rem; + font-weight: 600; + color: #e8ecff; + 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: #142232; +} + +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 auto 10px; +} + +/* ── 交易执行 / 复盘 / 统计(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"] .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: #4a6078 !important; +} + +html[data-theme="light"] .pos-meta-item::after { + color: #b8c8d8 !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: rgba(0, 110, 154, 0.12) !important; + color: #006e9a !important; +} + +html[data-theme="light"] .pos-side-short { + background: rgba(180, 50, 50, 0.1) !important; + color: #b03030 !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; +} + +/* ── 策略交易 / 策略记录(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(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="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; +} + +.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; +} + +.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; +} + +.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-risk-sections { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin-top: 10px; + flex: 1; +} + +.settings-subcard { + padding: 10px 12px; + min-width: 0; + margin: 0; +} + +.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: 8em 1fr; + padding: 4px 0; + font-size: 0.75rem; +} + +@media (max-width: 720px) { + .settings-risk-sections { + grid-template-columns: minmax(0, 1fr); + } +} + +.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); +} +.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 { + 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-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-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 { + display: none; +} +.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-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 { + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; + min-height: 0; +} +.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-live-pane { + flex: 1; + min-height: 100px; + max-height: 220px; + overflow-y: auto; +} +.options-pos-stats-card { + flex-shrink: 0; +} +.options-stats-grid { + display: flex; + flex-wrap: wrap; + gap: 8px 14px; +} +.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; +} +.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 .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; + min-height: 120px; +} +@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; +} +.options-pos-tabs { + display: flex; + gap: 8px; + margin-bottom: 10px; +} +.opt-pos-tab.active { + border-color: #5b8cff; + color: #cfe0ff; + background: rgba(74, 124, 255, 0.28); +} +.options-pos-pane { + min-height: 200px; +} +.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"] .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-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-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"] .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 index 02e6efa..1522277 100644 --- a/lib/common/static/instance_theme.js +++ b/lib/common/static/instance_theme.js @@ -1,566 +1,566 @@ -/** - * 三所实例主题:默认暗色;单独登录用 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 软导航/表单恢复后不触发 change) */ - 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() { - const toggle = document.getElementById("review-mode-toggle"); - if (!toggle) return; - if (toggle.dataset.instReviewModeBound !== "1") { - toggle.dataset.instReviewModeBound = "1"; - toggle.addEventListener("input", () => { - if (typeof global.toggleReviewMode === "function") global.toggleReviewMode(); - else syncReviewEditButtons(); - }); - } - 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); +/** + * 三所实例主题:默认暗色;单独登录用 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 软导航/表单恢复后不触发 change) */ + 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() { + const toggle = document.getElementById("review-mode-toggle"); + if (!toggle) return; + if (toggle.dataset.instReviewModeBound !== "1") { + toggle.dataset.instReviewModeBound = "1"; + toggle.addEventListener("input", () => { + if (typeof global.toggleReviewMode === "function") global.toggleReviewMode(); + else syncReviewEditButtons(); + }); + } + 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 index ae02904..22d320f 100644 --- a/lib/common/static/instance_theme_early.css +++ b/lib/common/static/instance_theme_early.css @@ -1,4 +1,4 @@ -/* 紧接 instance_theme.js 之后加载,避免亮色下先闪暗色底 */ +/* 紧接 instance_theme.js 之后加载,避免亮色下先闪暗色底 */ html { background: #0b0d14; color-scheme: dark; diff --git a/lib/common/static/instance_ui.js b/lib/common/static/instance_ui.js index ae30c6c..e8e418a 100644 --- a/lib/common/static/instance_ui.js +++ b/lib/common/static/instance_ui.js @@ -1,417 +1,417 @@ -/** - * 三所实例共用 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(o.open_datetime || "-")}`, - `平仓时间:${escapeHtml(o.close_datetime || "-")}`, - `持仓时长:${escapeHtml(o.hold_duration || "-")}`, - `盈亏:${formatPnlSpan(o.pnl)}`, - `开仓类型:${escapeHtml(o.entry_reason || "无")}`, - `平仓/离场:${escapeHtml(exitText)}`, - `预期RR:${escapeHtml(o.expect_rr || "-")}`, - `实际RR:${escapeHtml(o.real_rr || "-")}`, - `保本后盯盘:${escapeHtml(o.post_breakeven_stare || "-")}`, - `心态标签:${escapeHtml(moodTags)}`, - `备注:${escapeHtml(o.note || "无")}`, - ]; - return lines.join("
"); - } - - function resolveJournalImages(o) { - if (Array.isArray(o.images) && o.images.length) return o.images; - if (o.image) return [{ tf: "", file: o.image }]; - return []; - } - - function setJournalDetailImages(o) { - const grid = document.getElementById("detailImages"); - const legacyImg = document.getElementById("detailImage"); - const images = resolveJournalImages(o || {}); - - if (grid) { - if (!images.length) { - grid.innerHTML = ""; - grid.style.display = "none"; - } else { - grid.innerHTML = images - .map(function (img) { - const tf = String(img.tf || "").trim(); - const file = String(img.file || "").trim(); - if (!file) return ""; - const label = tf ? escapeHtml(tf) : "截图"; - const src = "/static/images/" + encodeURIComponent(file).replace(/%2F/g, "/"); - return ( - '
' + - '' + - label + - "" + - '' +
-              label +
-              '' + - "
" - ); - }) - .join(""); - grid.style.display = "grid"; - } - if (legacyImg) { - legacyImg.src = ""; - legacyImg.style.display = "none"; - } - return; - } - - if (legacyImg) { - if (images.length === 1) { - legacyImg.src = "/static/images/" + images[0].file; - legacyImg.style.display = "block"; - } else { - legacyImg.src = ""; - legacyImg.style.display = "none"; - } - } - } - - function clearJournalDetailImages() { - const grid = document.getElementById("detailImages"); - if (grid) { - grid.innerHTML = ""; - grid.style.display = "none"; - } - const legacyImg = document.getElementById("detailImage"); - if (legacyImg) { - legacyImg.src = ""; - legacyImg.style.display = "none"; - } - } - - function setJournalDetailBody(o, formatExitLine) { - const body = document.getElementById("detailBody"); - if (!body) return; - body.classList.remove("md-review", "trade-record-detail-wrap"); - body.classList.add("journal-detail-meta"); - body.innerHTML = buildJournalDetailHtml(o, formatExitLine); - } - - function openJournalDetailModal(id, journalCache, formatExitLine) { - const o = journalCache && journalCache[id]; - if (!o) return; - const titleEl = document.getElementById("detailTitle"); - if (titleEl) { - titleEl.innerText = `交易复盘详情|${o.coin || "-"} ${o.tf || "-"}`; - } - setJournalDetailBody(o, formatExitLine); - clearDetailActions(); - setJournalDetailImages(o); - if (typeof setDetailModalFullscreen === "function") { - setDetailModalFullscreen(false); - } - const modal = document.getElementById("detailModal"); - if (modal) modal.style.display = "flex"; - } - - function isMobileCompactRecords() { - if (typeof window === "undefined" || !window.matchMedia) return false; - return window.matchMedia("(max-width: 720px)").matches; - } - - function inferJournalDirection(o) { - const text = String((o && o.entry_reason) || ""); - 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(); - return data - .map(function (o) { - if (mobile) { - const dir = inferJournalDirection(o); - const pnlCls = pnlClassFromValue(o.pnl); - const dirHtml = dir - ? `${escapeHtml(dir.text)}` - : `-`; - const id = escapeHtml(o.id); - return `
- - -
`; - } - const moodTags = (o.mood_issues || []).join(",") || "无"; - const id = escapeHtml(o.id); - return `
-
${escapeHtml(o.coin || "-")} ${escapeHtml(o.tf || "-")} | 盈亏:${escapeHtml(o.pnl == null || o.pnl === "" ? "-" : o.pnl)}U
-
开:${escapeHtml(o.open_datetime || "-")} 平:${escapeHtml(o.close_datetime || "-")} 持仓:${escapeHtml(o.hold_duration || "-")}
-
心态标签:${escapeHtml(moodTags)}
-
- - -
-
`; - }) - .join(""); - } - - function parseTradeRecordRow(tr) { - const cells = tr.querySelectorAll("td"); - if (cells.length < 14) return null; - const dirBadge = cells[2].querySelector(".badge"); - return { - rowId: tr.id, - symbol: cells[0].textContent.trim(), - type: cells[1].textContent.trim(), - directionHtml: (dirBadge ? dirBadge.outerHTML : cells[2].innerHTML).trim(), - directionText: cells[2].textContent.trim(), - trigger: cells[3].textContent.trim(), - stopLoss: cells[4].textContent.trim(), - takeProfit: cells[5].textContent.trim(), - margin: cells[6].textContent.trim(), - leverage: cells[7].textContent.trim(), - holdMinutes: cells[8].textContent.trim(), - openedAt: cells[9].textContent.trim(), - closedAt: cells[10].textContent.trim(), - pnlHtml: cells[11].innerHTML.trim(), - pnlText: cells[11].textContent.trim(), - resultHtml: cells[12].innerHTML.trim(), - resultText: cells[12].textContent.trim(), - actionsHtml: cells[13].innerHTML, - }; - } - - function renderMobileTradeRow(tr) { - const row = parseTradeRecordRow(tr); - if (!row) return ""; - const pnlCls = pnlClassFromValue(row.pnlText); - return ``; - } - - function tradeDetailRow(label, valueHtml) { - return `
${escapeHtml(label)}${valueHtml}
`; - } - - function buildTradeRecordDetailHtml(row) { - return `
${ - tradeDetailRow("品种", escapeHtml(row.symbol)) + - tradeDetailRow("类型", escapeHtml(row.type)) + - tradeDetailRow("方向", 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); +/** + * 三所实例共用 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(o.open_datetime || "-")}`, + `平仓时间:${escapeHtml(o.close_datetime || "-")}`, + `持仓时长:${escapeHtml(o.hold_duration || "-")}`, + `盈亏:${formatPnlSpan(o.pnl)}`, + `开仓类型:${escapeHtml(o.entry_reason || "无")}`, + `平仓/离场:${escapeHtml(exitText)}`, + `预期RR:${escapeHtml(o.expect_rr || "-")}`, + `实际RR:${escapeHtml(o.real_rr || "-")}`, + `保本后盯盘:${escapeHtml(o.post_breakeven_stare || "-")}`, + `心态标签:${escapeHtml(moodTags)}`, + `备注:${escapeHtml(o.note || "无")}`, + ]; + return lines.join("
"); + } + + function resolveJournalImages(o) { + if (Array.isArray(o.images) && o.images.length) return o.images; + if (o.image) return [{ tf: "", file: o.image }]; + return []; + } + + function setJournalDetailImages(o) { + const grid = document.getElementById("detailImages"); + const legacyImg = document.getElementById("detailImage"); + const images = resolveJournalImages(o || {}); + + if (grid) { + if (!images.length) { + grid.innerHTML = ""; + grid.style.display = "none"; + } else { + grid.innerHTML = images + .map(function (img) { + const tf = String(img.tf || "").trim(); + const file = String(img.file || "").trim(); + if (!file) return ""; + const label = tf ? escapeHtml(tf) : "截图"; + const src = "/static/images/" + encodeURIComponent(file).replace(/%2F/g, "/"); + return ( + '
' + + '' + + label + + "" + + '' +
+              label +
+              '' + + "
" + ); + }) + .join(""); + grid.style.display = "grid"; + } + if (legacyImg) { + legacyImg.src = ""; + legacyImg.style.display = "none"; + } + return; + } + + if (legacyImg) { + if (images.length === 1) { + legacyImg.src = "/static/images/" + images[0].file; + legacyImg.style.display = "block"; + } else { + legacyImg.src = ""; + legacyImg.style.display = "none"; + } + } + } + + function clearJournalDetailImages() { + const grid = document.getElementById("detailImages"); + if (grid) { + grid.innerHTML = ""; + grid.style.display = "none"; + } + const legacyImg = document.getElementById("detailImage"); + if (legacyImg) { + legacyImg.src = ""; + legacyImg.style.display = "none"; + } + } + + function setJournalDetailBody(o, formatExitLine) { + const body = document.getElementById("detailBody"); + if (!body) return; + body.classList.remove("md-review", "trade-record-detail-wrap"); + body.classList.add("journal-detail-meta"); + body.innerHTML = buildJournalDetailHtml(o, formatExitLine); + } + + function openJournalDetailModal(id, journalCache, formatExitLine) { + const o = journalCache && journalCache[id]; + if (!o) return; + const titleEl = document.getElementById("detailTitle"); + if (titleEl) { + titleEl.innerText = `交易复盘详情|${o.coin || "-"} ${o.tf || "-"}`; + } + setJournalDetailBody(o, formatExitLine); + clearDetailActions(); + setJournalDetailImages(o); + if (typeof setDetailModalFullscreen === "function") { + setDetailModalFullscreen(false); + } + const modal = document.getElementById("detailModal"); + if (modal) modal.style.display = "flex"; + } + + function isMobileCompactRecords() { + if (typeof window === "undefined" || !window.matchMedia) return false; + return window.matchMedia("(max-width: 720px)").matches; + } + + function inferJournalDirection(o) { + const text = String((o && o.entry_reason) || ""); + 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(); + return data + .map(function (o) { + if (mobile) { + const dir = inferJournalDirection(o); + const pnlCls = pnlClassFromValue(o.pnl); + const dirHtml = dir + ? `${escapeHtml(dir.text)}` + : `-`; + const id = escapeHtml(o.id); + return `
+ + +
`; + } + const moodTags = (o.mood_issues || []).join(",") || "无"; + const id = escapeHtml(o.id); + return `
+
${escapeHtml(o.coin || "-")} ${escapeHtml(o.tf || "-")} | 盈亏:${escapeHtml(o.pnl == null || o.pnl === "" ? "-" : o.pnl)}U
+
开:${escapeHtml(o.open_datetime || "-")} 平:${escapeHtml(o.close_datetime || "-")} 持仓:${escapeHtml(o.hold_duration || "-")}
+
心态标签:${escapeHtml(moodTags)}
+
+ + +
+
`; + }) + .join(""); + } + + function parseTradeRecordRow(tr) { + const cells = tr.querySelectorAll("td"); + if (cells.length < 14) return null; + const dirBadge = cells[2].querySelector(".badge"); + return { + rowId: tr.id, + symbol: cells[0].textContent.trim(), + type: cells[1].textContent.trim(), + directionHtml: (dirBadge ? dirBadge.outerHTML : cells[2].innerHTML).trim(), + directionText: cells[2].textContent.trim(), + trigger: cells[3].textContent.trim(), + stopLoss: cells[4].textContent.trim(), + takeProfit: cells[5].textContent.trim(), + margin: cells[6].textContent.trim(), + leverage: cells[7].textContent.trim(), + holdMinutes: cells[8].textContent.trim(), + openedAt: cells[9].textContent.trim(), + closedAt: cells[10].textContent.trim(), + pnlHtml: cells[11].innerHTML.trim(), + pnlText: cells[11].textContent.trim(), + resultHtml: cells[12].innerHTML.trim(), + resultText: cells[12].textContent.trim(), + actionsHtml: cells[13].innerHTML, + }; + } + + function renderMobileTradeRow(tr) { + const row = parseTradeRecordRow(tr); + if (!row) return ""; + const pnlCls = pnlClassFromValue(row.pnlText); + return ``; + } + + function tradeDetailRow(label, valueHtml) { + return `
${escapeHtml(label)}${valueHtml}
`; + } + + function buildTradeRecordDetailHtml(row) { + return `
${ + tradeDetailRow("品种", escapeHtml(row.symbol)) + + tradeDetailRow("类型", escapeHtml(row.type)) + + tradeDetailRow("方向", 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 index 4d6a08b..8e43422 100644 --- a/lib/common/static/journal_upload_slots.js +++ b/lib/common/static/journal_upload_slots.js @@ -1,145 +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); +/** + * 复盘表单:四周期截图即时上传与状态展示. + */ +(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 index ff8d4de..43f143e 100644 --- a/lib/common/static/key_monitor_form.js +++ b/lib/common/static/key_monitor_form.js @@ -1,160 +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); +/** + * 关键位监控添加表单:类型切换显隐,成交量排名校验(三所实例共用). + */ +(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 index 49dffc7..eee1856 100644 --- a/lib/common/static/manual_order_rr_preview.js +++ b/lib/common/static/manual_order_rr_preview.js @@ -1,7 +1,7 @@ /** - * 实盘下单:填完币种与止盈止损后,在表单下方显示预估风险 / 预估盈利 / 预估盈亏比。 - * 以损定仓:风险 = 当前交易基数 × risk%。 - * 全仓杠杆:风险 = 可用保证金×缓冲 × 杠杆 × |SL-入场|/入场(与开仓 calc_risk_amount_from_plan 一致)。 + * 实盘下单:填完币种与止盈止损后,在表单下方显示预估风险 / 预估盈利 / 预估盈亏比. + * 以损定仓:风险 = 当前交易基数 × risk%. + * 全仓杠杆:风险 = 可用保证金×缓冲 × 杠杆 × |SL-入场|/入场(与开仓 calc_risk_amount_from_plan 一致). */ (function (global) { "use strict"; @@ -35,7 +35,7 @@ function setMetric(el, label, valueText) { if (!el) return; - el.innerHTML = label + ":" + valueText + ""; + el.innerHTML = label + ":" + valueText + ""; } function sizingMode() { diff --git a/lib/common/static/options_expiry_countdown.js b/lib/common/static/options_expiry_countdown.js index 118aab1..db33172 100644 --- a/lib/common/static/options_expiry_countdown.js +++ b/lib/common/static/options_expiry_countdown.js @@ -1,5 +1,5 @@ /** - * 期权到期倒计时(实例期权页 + 中控监控/看板共用) + * 期权到期倒计时(实例期权页 + 中控监控/看板共用) */ (function (global) { function normalizeExpMs(v) { diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index dc81d6a..edbb64f 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -414,7 +414,7 @@ body: JSON.stringify(body), }); const msgEl = document.getElementById("opt-order-msg"); - msgEl.textContent = d.ok ? "下单已提交,可在 OKX 委托中查看" : (d.msg || "失败"); + msgEl.textContent = d.ok ? "下单已提交,可在 OKX 委托中查看" : (d.msg || "失败"); msgEl.classList.toggle("opt-error", !d.ok); if (d.ok) { refreshAllPositions(); @@ -470,10 +470,10 @@ } const bid = q.bid; if (bid == null || bid <= 0) { - alert("暂无买一价,请稍后在 OKX App 平仓或等盘口恢复"); + alert("暂无买一价,请稍后在 OKX App 平仓或等盘口恢复"); return; } - if (!confirm("限价卖出 @ 买一 " + fmt(bid, 4) + "(每 1 ETH/BTC)?\n合约:" + inst)) return; + if (!confirm("限价卖出 @ 买一 " + fmt(bid, 4) + "(每 1 ETH/BTC)?\n合约:" + inst)) return; if (btn) btn.disabled = true; try { const r = await apiJson("/api/options/close", { @@ -549,8 +549,8 @@ async function deleteHistoryRow(id, status) { const warn = status === "open" - ? "该记录仍为持仓中,仅删除本地记录,不影响交易所持仓。确认删除?" - : "确认删除该条期权历史记录?"; + ? "该记录仍为持仓中,仅删除本地记录,不影响交易所持仓.确认删除?" + : "确认删除该条期权历史记录?"; if (!confirm(warn)) return; const r = await apiJson("/api/options/history/" + encodeURIComponent(id), { method: "DELETE" }); if (!r.ok) { diff --git a/lib/common/static/strategy_roll.js b/lib/common/static/strategy_roll.js index cca05e7..388983a 100644 --- a/lib/common/static/strategy_roll.js +++ b/lib/common/static/strategy_roll.js @@ -84,14 +84,14 @@ function syncDirectionLock() { const opt = selectedOption(); if (!opt || !opt.value) { - riskBanner.textContent = "当前风险:请选择持仓币种"; + 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") || "?") + ")"; + "当前风险:" + rp + "%(来自监控单 #" + (opt.getAttribute("data-monitor-id") || "?") + ")"; } function syncSubmitButton() { @@ -144,9 +144,9 @@ p.avg_entry_after + " · 打到止损约 " + p.loss_at_sl_usdt + - "U(风险预算 " + + "U(风险预算 " + (p.risk_budget_usdt != null ? p.risk_budget_usdt : "—") + - "U)"; + "U)"; } function syncFieldVisibility() { @@ -211,7 +211,7 @@ }) .catch(function () { if (previewBtn) previewBtn.disabled = false; - showReject("预览请求失败,请稍后重试"); + showReject("预览请求失败,请稍后重试"); }); } @@ -243,7 +243,7 @@ (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)) { + if (!confirm("确认提交「" + modeLabel + "」?\n" + summary)) { return; } submitRollForm(form); @@ -254,7 +254,7 @@ submitBtn.disabled = false; submitBtn.removeAttribute("disabled"); } - showReject("校验请求失败,请稍后重试"); + showReject("校验请求失败,请稍后重试"); }); } @@ -263,7 +263,7 @@ if (submitBtn) submitBtn.disabled = true; if (countdownEl) { countdownEl.style.display = "block"; - countdownEl.textContent = "市价加仓:" + left + " 秒后可执行(修改表单将取消预览)"; + countdownEl.textContent = "市价加仓:" + left + " 秒后可执行(修改表单将取消预览)"; } countdownTimer = setInterval(function () { left -= 1; @@ -274,7 +274,7 @@ syncSubmitButton(); return; } - if (countdownEl) countdownEl.textContent = "市价加仓:" + left + " 秒后可执行"; + if (countdownEl) countdownEl.textContent = "市价加仓:" + left + " 秒后可执行"; }, 1000); } @@ -303,7 +303,7 @@ return; } const modeLabel = modeSel.options[modeSel.selectedIndex].text; - if (!confirm("确认提交「" + modeLabel + "」?")) { + if (!confirm("确认提交「" + modeLabel + "」?")) { return; } submitRollForm(form); diff --git a/lib/common/static/symbol_live_price.js b/lib/common/static/symbol_live_price.js index 5a7714f..07db9c1 100644 --- a/lib/common/static/symbol_live_price.js +++ b/lib/common/static/symbol_live_price.js @@ -1,169 +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); +/** + * 表单币种输入:防抖 + 定时刷新,展示交易所最新价(/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 index 7061f61..7d4933e 100644 --- a/lib/common/static/time_close_ui.js +++ b/lib/common/static/time_close_ui.js @@ -1,5 +1,5 @@ /** - * 时间平仓 + 整点强制清仓:表单开关 + 持仓/顶栏倒计时。 + * 时间平仓 + 整点强制清仓:表单开关 + 持仓/顶栏倒计时. */ (function (global) { "use strict"; diff --git a/lib/common/static/trade_stats_calendar.css b/lib/common/static/trade_stats_calendar.css index eba49d2..6fd603f 100644 --- a/lib/common/static/trade_stats_calendar.css +++ b/lib/common/static/trade_stats_calendar.css @@ -1,160 +1,160 @@ -/* 交易日历:内照明心 + 三所统计分析共用,随 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-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 transparent; - padding: 4px 3px; - min-height: 68px; - width: 100%; - box-shadow: none; - line-height: 1.15; - font-size: inherit; - text-align: center; -} -.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: 4px; -} -.trade-cal-cell { - min-height: 62px; - padding: 4px 3px; - border-radius: 8px; - border: 1px solid transparent; - 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: var(--section-surface, #f6f9fc); - --trade-cal-cell-hover-bg: color-mix(in srgb, var(--accent, #2563eb) 10%, #f6f9fc); - --trade-cal-selected-border: rgba(37, 99, 235, 0.75); - --trade-cal-selected-bg: color-mix(in srgb, #2563eb 12%, #f6f9fc); - --trade-cal-selected-shadow: rgba(37, 99, 235, 0.35); - --trade-cal-sick-tag-fg: #b91c1c; -} +/* 交易日历:内照明心 + 三所统计分析共用,随 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-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 transparent; + padding: 4px 3px; + min-height: 68px; + width: 100%; + box-shadow: none; + line-height: 1.15; + font-size: inherit; + text-align: center; +} +.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: 4px; +} +.trade-cal-cell { + min-height: 62px; + padding: 4px 3px; + border-radius: 8px; + border: 1px solid transparent; + 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: var(--section-surface, #f6f9fc); + --trade-cal-cell-hover-bg: color-mix(in srgb, var(--accent, #2563eb) 10%, #f6f9fc); + --trade-cal-selected-border: rgba(37, 99, 235, 0.75); + --trade-cal-selected-bg: color-mix(in srgb, #2563eb 12%, #f6f9fc); + --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 index 0c0063e..73da916 100644 --- a/lib/common/static/trade_stats_calendar.js +++ b/lib/common/static/trade_stats_calendar.js @@ -1,314 +1,314 @@ -/** - * 交易日历组件:内照明心档案 + 三所统计分析共用。 - */ -(function (global) { - "use strict"; - - var WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"]; - - function esc(s) { - return String(s == null ? "" : s) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); - } - - function monthLabel(y, m) { - return y + "年" + m + "月"; - } - - function formatCalPnl(pnl) { - var n = Number(pnl); - if (!Number.isFinite(n)) n = 0; - return (n >= 0 ? "+" : "") + n.toFixed(1) + "U"; - } - - function dayHasTrade(info) { - if (!info) return false; - var cnt = Number(info.open_count); - if (Number.isFinite(cnt) && cnt > 0) return true; - var pnl = Number(info.pnl_total); - return Number.isFinite(pnl) && Math.abs(pnl) > 0.0001; - } - - function dayOpenCount(info) { - var cnt = Number(info && info.open_count); - return Number.isFinite(cnt) && cnt > 0 ? cnt : 0; - } - - function dayPnl(info) { - return Number(info && info.pnl_total) || 0; - } - - function TradeStatsCalendar(config) { - this.gridEl = config.gridEl; - this.titleEl = config.titleEl; - this.prevBtn = config.prevBtn || null; - this.nextBtn = config.nextBtn || null; - this.apiUrl = config.apiUrl || "/api/stats/calendar"; - this.buildQuery = - config.buildQuery || - function (year, month) { - var q = new URLSearchParams(); - q.set("year", String(year)); - q.set("month", String(month)); - return q; - }; - this.parseResponse = - config.parseResponse || - function (data) { - if (data && data.ok === false) return {}; - return (data && data.days) || {}; - }; - this.fetchFn = config.fetchFn || null; - this.showSick = config.showSick !== false; - this.selectedDay = config.selectedDay || ""; - this.onDayClick = config.onDayClick || null; - this.onMonthChange = config.onMonthChange || null; - this.year = config.year || 0; - this.month = config.month || 0; - this.days = {}; - this.monthPnlTotal = 0; - this.monthOpenCount = 0; - this._navBound = false; - this._bindNav(); - } - - TradeStatsCalendar.prototype.ensureMonth = function (ref) { - if (this.year > 0 && this.month > 0) return; - var d; - if (ref instanceof Date) d = ref; - else if (typeof ref === "string" && ref.length >= 7) { - var p = ref.slice(0, 10).split("-"); - this.year = parseInt(p[0], 10) || new Date().getFullYear(); - this.month = parseInt(p[1], 10) || new Date().getMonth() + 1; - return; - } else d = new Date(); - this.year = d.getFullYear(); - this.month = d.getMonth() + 1; - }; - - TradeStatsCalendar.prototype.applyPayload = function (data) { - if (!data) return; - var y = Number(data.year); - var m = Number(data.month); - if (Number.isFinite(y) && y > 0) this.year = y; - if (Number.isFinite(m) && m > 0) this.month = m; - this.days = this.parseResponse(data) || {}; - this.monthPnlTotal = Number(data.month_pnl_total) || 0; - this.monthOpenCount = Number(data.month_open_count) || 0; - if (!this.monthOpenCount) { - var self = this; - Object.keys(this.days).forEach(function (k) { - if (dayHasTrade(self.days[k])) { - self.monthOpenCount += dayOpenCount(self.days[k]); - self.monthPnlTotal += dayPnl(self.days[k]); - } - }); - this.monthPnlTotal = Math.round(this.monthPnlTotal * 10000) / 10000; - } - }; - - function readStatsCalendarBootstrap() { - var el = document.getElementById("stats-calendar-bootstrap"); - if (!el || !el.textContent) return null; - try { - return JSON.parse(el.textContent); - } catch (e) { - console.warn("[trade calendar] bootstrap parse", e); - return null; - } - } - - TradeStatsCalendar.prototype.setSelectedDay = function (day) { - this.selectedDay = day || ""; - this.render(); - }; - - TradeStatsCalendar.prototype.render = function () { - if (!this.gridEl || !this.titleEl) return; - if (this.year <= 0 || this.month <= 0) this.ensureMonth(new Date()); - var title = monthLabel(this.year, this.month); - if (this.monthOpenCount > 0) { - title += - " · " + formatCalPnl(this.monthPnlTotal) + " · " + this.monthOpenCount + "笔"; - } - this.titleEl.textContent = title; - var first = new Date(this.year, this.month - 1, 1); - var lastDay = new Date(this.year, this.month, 0).getDate(); - var startWd = first.getDay(); - var html = - '
' + - WEEKDAYS.map(function (w) { - return '' + w + ""; - }).join("") + - '
'; - var i; - for (i = 0; i < startWd; i++) { - html += ''; - } - for (var d = 1; d <= lastDay; d++) { - var dayStr = - this.year + - "-" + - String(this.month).padStart(2, "0") + - "-" + - String(d).padStart(2, "0"); - var info = this.days[dayStr]; - var hasTrade = dayHasTrade(info); - var sick = this.showSick && info && info.has_sick; - var pnl = hasTrade ? dayPnl(info) : null; - var cnt = hasTrade ? dayOpenCount(info) : 0; - var cls = - "trade-cal-cell" + - (hasTrade ? " has-trade" : "") + - (sick ? " is-sick-day" : "") + - (this.selectedDay === dayStr ? " is-selected" : "") + - (pnl != null && pnl > 0.0001 - ? " pnl-pos" - : pnl != null && pnl < -0.0001 - ? " pnl-neg" - : ""); - var body = '' + d + ""; - if (hasTrade) { - body += - '' + - esc(formatCalPnl(pnl)) + - "" + - '' + - cnt + - "笔"; - if (sick) body += '犯病'; - } - html += - '"; - } - html += "
"; - this.gridEl.innerHTML = html; - var self = this; - this.gridEl.querySelectorAll(".trade-cal-cell[data-day]").forEach(function (btn) { - btn.addEventListener("click", function () { - var day = btn.getAttribute("data-day"); - if (!day || !self.onDayClick) return; - self.selectedDay = day; - self.render(); - self.onDayClick(day, btn.getAttribute("data-sick") === "1", self.days[day] || null); - }); - }); - }; - - TradeStatsCalendar.prototype.load = async function () { - this.ensureMonth(new Date()); - this.render(); - var q = this.buildQuery(this.year, this.month); - if (!q.has("year")) q.set("year", String(this.year)); - if (!q.has("month")) q.set("month", String(this.month)); - try { - var data; - if (this.fetchFn) { - data = await this.fetchFn(q); - } else { - var resp = await fetch(this.apiUrl + "?" + q.toString(), { - credentials: "same-origin", - }); - if (!resp.ok) { - console.warn("[trade calendar] api", resp.status); - this.render(); - return; - } - data = await resp.json(); - } - this.applyPayload(data); - this.render(); - if (this.onMonthChange) this.onMonthChange(this.year, this.month, this.days); - } catch (e) { - console.warn("[trade calendar]", e); - this.render(); - } - }; - - TradeStatsCalendar.prototype.shiftMonth = function (delta) { - this.ensureMonth(new Date()); - this.month += delta; - if (this.month > 12) { - this.month = 1; - this.year += 1; - } else if (this.month < 1) { - this.month = 12; - this.year -= 1; - } - void this.load(); - }; - - TradeStatsCalendar.prototype._bindNav = function () { - if (this._navBound) return; - var self = this; - if (this.prevBtn) { - this.prevBtn.addEventListener("click", function () { - self.shiftMonth(-1); - }); - } - if (this.nextBtn) { - this.nextBtn.addEventListener("click", function () { - self.shiftMonth(1); - }); - } - this._navBound = true; - }; - - global.TradeStatsCalendar = TradeStatsCalendar; - - global.statsCalendarWidget = null; - - global.initInstanceStatsCalendar = function () { - var grid = document.getElementById("stats-calendar"); - if (!grid || !global.TradeStatsCalendar) return null; - var bootstrap = readStatsCalendarBootstrap(); - if ( - global.statsCalendarWidget && - global.statsCalendarWidget.gridEl === grid - ) { - if (bootstrap) global.statsCalendarWidget.applyPayload(bootstrap); - global.statsCalendarWidget.render(); - void global.statsCalendarWidget.load(); - return global.statsCalendarWidget; - } - global.statsCalendarWidget = new TradeStatsCalendar({ - gridEl: grid, - titleEl: document.getElementById("stats-cal-title"), - prevBtn: document.getElementById("stats-cal-prev"), - nextBtn: document.getElementById("stats-cal-next"), - apiUrl: "/api/stats/calendar", - showSick: false, - buildQuery: function (year, month) { - var q = new URLSearchParams(); - q.set("year", String(year)); - q.set("month", String(month)); - var sel = document.getElementById("stats-segment-select"); - if (sel) q.set("segment", sel.value || "all"); - return q; - }, - parseResponse: function (data) { - if (data && data.ok === false) return {}; - return (data && data.days) || {}; - }, - }); - if (bootstrap) global.statsCalendarWidget.applyPayload(bootstrap); - global.statsCalendarWidget.render(); - void global.statsCalendarWidget.load(); - return global.statsCalendarWidget; - }; - - global.initStatsCalendarWidget = global.initInstanceStatsCalendar; -})(window); +/** + * 交易日历组件:内照明心档案 + 三所统计分析共用. + */ +(function (global) { + "use strict"; + + var WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"]; + + function esc(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function monthLabel(y, m) { + return y + "年" + m + "月"; + } + + function formatCalPnl(pnl) { + var n = Number(pnl); + if (!Number.isFinite(n)) n = 0; + return (n >= 0 ? "+" : "") + n.toFixed(1) + "U"; + } + + function dayHasTrade(info) { + if (!info) return false; + var cnt = Number(info.open_count); + if (Number.isFinite(cnt) && cnt > 0) return true; + var pnl = Number(info.pnl_total); + return Number.isFinite(pnl) && Math.abs(pnl) > 0.0001; + } + + function dayOpenCount(info) { + var cnt = Number(info && info.open_count); + return Number.isFinite(cnt) && cnt > 0 ? cnt : 0; + } + + function dayPnl(info) { + return Number(info && info.pnl_total) || 0; + } + + function TradeStatsCalendar(config) { + this.gridEl = config.gridEl; + this.titleEl = config.titleEl; + this.prevBtn = config.prevBtn || null; + this.nextBtn = config.nextBtn || null; + this.apiUrl = config.apiUrl || "/api/stats/calendar"; + this.buildQuery = + config.buildQuery || + function (year, month) { + var q = new URLSearchParams(); + q.set("year", String(year)); + q.set("month", String(month)); + return q; + }; + this.parseResponse = + config.parseResponse || + function (data) { + if (data && data.ok === false) return {}; + return (data && data.days) || {}; + }; + this.fetchFn = config.fetchFn || null; + this.showSick = config.showSick !== false; + this.selectedDay = config.selectedDay || ""; + this.onDayClick = config.onDayClick || null; + this.onMonthChange = config.onMonthChange || null; + this.year = config.year || 0; + this.month = config.month || 0; + this.days = {}; + this.monthPnlTotal = 0; + this.monthOpenCount = 0; + this._navBound = false; + this._bindNav(); + } + + TradeStatsCalendar.prototype.ensureMonth = function (ref) { + if (this.year > 0 && this.month > 0) return; + var d; + if (ref instanceof Date) d = ref; + else if (typeof ref === "string" && ref.length >= 7) { + var p = ref.slice(0, 10).split("-"); + this.year = parseInt(p[0], 10) || new Date().getFullYear(); + this.month = parseInt(p[1], 10) || new Date().getMonth() + 1; + return; + } else d = new Date(); + this.year = d.getFullYear(); + this.month = d.getMonth() + 1; + }; + + TradeStatsCalendar.prototype.applyPayload = function (data) { + if (!data) return; + var y = Number(data.year); + var m = Number(data.month); + if (Number.isFinite(y) && y > 0) this.year = y; + if (Number.isFinite(m) && m > 0) this.month = m; + this.days = this.parseResponse(data) || {}; + this.monthPnlTotal = Number(data.month_pnl_total) || 0; + this.monthOpenCount = Number(data.month_open_count) || 0; + if (!this.monthOpenCount) { + var self = this; + Object.keys(this.days).forEach(function (k) { + if (dayHasTrade(self.days[k])) { + self.monthOpenCount += dayOpenCount(self.days[k]); + self.monthPnlTotal += dayPnl(self.days[k]); + } + }); + this.monthPnlTotal = Math.round(this.monthPnlTotal * 10000) / 10000; + } + }; + + function readStatsCalendarBootstrap() { + var el = document.getElementById("stats-calendar-bootstrap"); + if (!el || !el.textContent) return null; + try { + return JSON.parse(el.textContent); + } catch (e) { + console.warn("[trade calendar] bootstrap parse", e); + return null; + } + } + + TradeStatsCalendar.prototype.setSelectedDay = function (day) { + this.selectedDay = day || ""; + this.render(); + }; + + TradeStatsCalendar.prototype.render = function () { + if (!this.gridEl || !this.titleEl) return; + if (this.year <= 0 || this.month <= 0) this.ensureMonth(new Date()); + var title = monthLabel(this.year, this.month); + if (this.monthOpenCount > 0) { + title += + " · " + formatCalPnl(this.monthPnlTotal) + " · " + this.monthOpenCount + "笔"; + } + this.titleEl.textContent = title; + var first = new Date(this.year, this.month - 1, 1); + var lastDay = new Date(this.year, this.month, 0).getDate(); + var startWd = first.getDay(); + var html = + '
' + + WEEKDAYS.map(function (w) { + return '' + w + ""; + }).join("") + + '
'; + var i; + for (i = 0; i < startWd; i++) { + html += ''; + } + for (var d = 1; d <= lastDay; d++) { + var dayStr = + this.year + + "-" + + String(this.month).padStart(2, "0") + + "-" + + String(d).padStart(2, "0"); + var info = this.days[dayStr]; + var hasTrade = dayHasTrade(info); + var sick = this.showSick && info && info.has_sick; + var pnl = hasTrade ? dayPnl(info) : null; + var cnt = hasTrade ? dayOpenCount(info) : 0; + var cls = + "trade-cal-cell" + + (hasTrade ? " has-trade" : "") + + (sick ? " is-sick-day" : "") + + (this.selectedDay === dayStr ? " is-selected" : "") + + (pnl != null && pnl > 0.0001 + ? " pnl-pos" + : pnl != null && pnl < -0.0001 + ? " pnl-neg" + : ""); + var body = '' + d + ""; + if (hasTrade) { + body += + '' + + esc(formatCalPnl(pnl)) + + "" + + '' + + cnt + + "笔"; + if (sick) body += '犯病'; + } + html += + '"; + } + html += "
"; + this.gridEl.innerHTML = html; + var self = this; + this.gridEl.querySelectorAll(".trade-cal-cell[data-day]").forEach(function (btn) { + btn.addEventListener("click", function () { + var day = btn.getAttribute("data-day"); + if (!day || !self.onDayClick) return; + self.selectedDay = day; + self.render(); + self.onDayClick(day, btn.getAttribute("data-sick") === "1", self.days[day] || null); + }); + }); + }; + + TradeStatsCalendar.prototype.load = async function () { + this.ensureMonth(new Date()); + this.render(); + var q = this.buildQuery(this.year, this.month); + if (!q.has("year")) q.set("year", String(this.year)); + if (!q.has("month")) q.set("month", String(this.month)); + try { + var data; + if (this.fetchFn) { + data = await this.fetchFn(q); + } else { + var resp = await fetch(this.apiUrl + "?" + q.toString(), { + credentials: "same-origin", + }); + if (!resp.ok) { + console.warn("[trade calendar] api", resp.status); + this.render(); + return; + } + data = await resp.json(); + } + this.applyPayload(data); + this.render(); + if (this.onMonthChange) this.onMonthChange(this.year, this.month, this.days); + } catch (e) { + console.warn("[trade calendar]", e); + this.render(); + } + }; + + TradeStatsCalendar.prototype.shiftMonth = function (delta) { + this.ensureMonth(new Date()); + this.month += delta; + if (this.month > 12) { + this.month = 1; + this.year += 1; + } else if (this.month < 1) { + this.month = 12; + this.year -= 1; + } + void this.load(); + }; + + TradeStatsCalendar.prototype._bindNav = function () { + if (this._navBound) return; + var self = this; + if (this.prevBtn) { + this.prevBtn.addEventListener("click", function () { + self.shiftMonth(-1); + }); + } + if (this.nextBtn) { + this.nextBtn.addEventListener("click", function () { + self.shiftMonth(1); + }); + } + this._navBound = true; + }; + + global.TradeStatsCalendar = TradeStatsCalendar; + + global.statsCalendarWidget = null; + + global.initInstanceStatsCalendar = function () { + var grid = document.getElementById("stats-calendar"); + if (!grid || !global.TradeStatsCalendar) return null; + var bootstrap = readStatsCalendarBootstrap(); + if ( + global.statsCalendarWidget && + global.statsCalendarWidget.gridEl === grid + ) { + if (bootstrap) global.statsCalendarWidget.applyPayload(bootstrap); + global.statsCalendarWidget.render(); + void global.statsCalendarWidget.load(); + return global.statsCalendarWidget; + } + global.statsCalendarWidget = new TradeStatsCalendar({ + gridEl: grid, + titleEl: document.getElementById("stats-cal-title"), + prevBtn: document.getElementById("stats-cal-prev"), + nextBtn: document.getElementById("stats-cal-next"), + apiUrl: "/api/stats/calendar", + showSick: false, + buildQuery: function (year, month) { + var q = new URLSearchParams(); + q.set("year", String(year)); + q.set("month", String(month)); + var sel = document.getElementById("stats-segment-select"); + if (sel) q.set("segment", sel.value || "all"); + return q; + }, + parseResponse: function (data) { + if (data && data.ok === false) return {}; + return (data && data.days) || {}; + }, + }); + if (bootstrap) global.statsCalendarWidget.applyPayload(bootstrap); + global.statsCalendarWidget.render(); + void global.statsCalendarWidget.load(); + return global.statsCalendarWidget; + }; + + global.initStatsCalendarWidget = global.initInstanceStatsCalendar; +})(window); diff --git a/lib/common/wechat_notify_lib.py b/lib/common/wechat_notify_lib.py index 9a20a79..d24be62 100644 --- a/lib/common/wechat_notify_lib.py +++ b/lib/common/wechat_notify_lib.py @@ -1,4 +1,4 @@ -"""企业微信机器人 Webhook 推送(多实例共用)。""" +"""企业微信机器人 Webhook 推送(多实例共用).""" from __future__ import annotations import re @@ -69,10 +69,10 @@ def send_wechat_webhook( def wechat_direction_label(direction: str) -> str: d = (direction or "").strip().lower() if d == "long": - return "多头(long)" + return "多头(long)" if d == "short": - return "空头(short)" - return "双向(watch)" + return "空头(short)" + return "双向(watch)" def build_wechat_rs_level_message( @@ -92,23 +92,23 @@ def build_wechat_rs_level_message( interval_min: int, extra_note: Optional[str] = None, ) -> str: - """阻力/支撑突破提醒(与开平仓推送一致的 emoji 纯文本风格)。""" + """阻力/支撑突破提醒(与开平仓推送一致的 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"{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"📌 类型:{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} 分钟)", + f"· 人工盯盘,共推送 {notify_max} 次(间隔约 {interval_min} 分钟)", "· 推送完毕后本条监控自动结案", "· 不参与自动开仓", ] diff --git a/lib/env/env_file_lib.py b/lib/env/env_file_lib.py index 25def74..e16a49c 100644 --- a/lib/env/env_file_lib.py +++ b/lib/env/env_file_lib.py @@ -1,4 +1,4 @@ -"""读写实例目录 .env(行级 upsert,原子落盘)。""" +"""读写实例目录 .env(行级 upsert,原子落盘).""" from __future__ import annotations import os diff --git a/lib/env/env_schema.py b/lib/env/env_schema.py index 21f48fd..32705b2 100644 --- a/lib/env/env_schema.py +++ b/lib/env/env_schema.py @@ -1,4 +1,4 @@ -"""从 .env.example 构建 env 配置 schema(分组、敏感、重启标注)。""" +"""从 .env.example 构建 env 配置 schema(分组,敏感,重启标注).""" from __future__ import annotations import os diff --git a/lib/env/env_ui_manifest.py b/lib/env/env_ui_manifest.py index d808c5d..4821a5b 100644 --- a/lib/env/env_ui_manifest.py +++ b/lib/env/env_ui_manifest.py @@ -1,4 +1,4 @@ -"""env 配置页 UI 白名单:中文标签、按交易所过滤。""" +"""env 配置页 UI 白名单:中文标签,按交易所过滤.""" from __future__ import annotations import os @@ -14,32 +14,32 @@ from lib.env.env_schema import ( parse_env_example_schema, ) -# 各所「交易所与实盘」字段(顺序即页面顺序) +# 各所「交易所与实盘」字段(顺序即页面顺序) _EXCHANGE_LIVE_FIELDS: dict[str, list[tuple[str, str, str]]] = { "okx": [ - ("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"), + ("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_TD_MODE", "保证金模式", "cross=全仓,isolated=逐仓"), + ("OKX_POS_MODE", "持仓模式", "hedge=双向,net=单向净持仓"), ("OKX_POSITION_INST_TYPE", "仓位查询类型", "如 SWAP"), ("OKX_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"), ], "binance": [ - ("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"), + ("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_MARGIN_MODE", "保证金模式", "cross=全仓,isolated=逐仓"), + ("BINANCE_POSITION_MODE", "持仓模式", "hedge=双向,one_way=单向"), ("BINANCE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"), ], "gate": [ - ("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"), + ("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"), ("GATE_API_KEY", "API Key", "永续子账户"), ("GATE_API_SECRET", "API Secret", "永续子账户"), - ("GATE_TD_MODE", "保证金模式", "cross=全仓,isolated=逐仓"), - ("GATE_POS_MODE", "持仓模式", "hedge=双向,single=单向"), + ("GATE_TD_MODE", "保证金模式", "cross=全仓,isolated=逐仓"), + ("GATE_POS_MODE", "持仓模式", "hedge=双向,single=单向"), ("GATE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"), ], } @@ -49,13 +49,13 @@ _SHARED_SECTIONS: list[dict[str, Any]] = [ "title": "企业微信", "fields": [ ("WECHAT_WEBHOOK", "机器人 Webhook", "行情与风控推送地址"), - ("WECHAT_TIMEOUT_SECONDS", "推送超时(秒)", "默认 10"), + ("WECHAT_TIMEOUT_SECONDS", "推送超时(秒)", "默认 10"), ], }, { "title": "交易执行", "fields": [ - ("POSITION_SIZING_MODE", "计仓模式", "risk=以损定仓,full_margin=全仓杠杆"), + ("POSITION_SIZING_MODE", "计仓模式", "risk=以损定仓,full_margin=全仓杠杆"), ("RISK_PERCENT", "以损定仓风险%", "单笔风险占资金比例"), ("FULL_MARGIN_BUFFER_RATIO", "全仓资金缓冲比例", "如 0.98"), ("BTC_LEVERAGE", "BTC 默认杠杆", ""), @@ -63,19 +63,19 @@ _SHARED_SECTIONS: list[dict[str, Any]] = [ ("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"), + ("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", "强制清仓整点(北京)", ""), + ("FORCE_CLOSE_BJ_HOUR", "强制清仓整点(北京)", ""), ], }, { "title": "交易风控", "fields": [ - ("DAILY_OPEN_ALERT_THRESHOLD", "单日开仓提醒阈值", "达次数后 AI 提醒,不拦单"), + ("DAILY_OPEN_ALERT_THRESHOLD", "单日开仓提醒阈值", "达次数后 AI 提醒,不拦单"), ("DAILY_OPEN_HARD_LIMIT", "单日开仓硬上限", "0=不启用"), ], }, @@ -83,8 +83,8 @@ _SHARED_SECTIONS: list[dict[str, Any]] = [ "title": "账户冷静期", "fields": [ ("RISK_CONTROL_ENABLED", "冷静期总开关", ""), - ("RISK_COOLING_HOURS_MANUAL", "手动平仓冷静(小时)", ""), - ("RISK_COOLING_HOURS_MANUAL_JOURNAL", "复盘情绪冷静(小时)", ""), + ("RISK_COOLING_HOURS_MANUAL", "手动平仓冷静(小时)", ""), + ("RISK_COOLING_HOURS_MANUAL_JOURNAL", "复盘情绪冷静(小时)", ""), ("RISK_MANUAL_CLOSE_DAILY_LIMIT", "日手动平仓次数上限", ""), ("RISK_MOOD_ISSUES_DAILY_FREEZE", "情绪标签日冻结", ""), ], @@ -93,19 +93,19 @@ _SHARED_SECTIONS: list[dict[str, Any]] = [ "title": "自动划转", "fields": [ ("AUTO_TRANSFER_ENABLED", "启用自动划转", ""), - ("AUTO_TRANSFER_AMOUNT", "目标余额(U)", "交易账户目标 USDT"), + ("AUTO_TRANSFER_AMOUNT", "目标余额(U)", "交易账户目标 USDT"), ("AUTO_TRANSFER_FROM", "划出账户", "funding 或 swap"), ("AUTO_TRANSFER_TO", "划入账户", "swap 或 funding"), - ("AUTO_TRANSFER_BJ_HOUR", "执行整点(北京时间)", ""), + ("AUTO_TRANSFER_BJ_HOUR", "执行整点(北京时间)", ""), ("TRANSFER_CCY", "划转币种", "默认 USDT"), ], }, { "title": "当日资金", "fields": [ - ("DAILY_START_CAPITAL", "日起始基数(U)", ""), - ("DAILY_LOSS_CAPITAL", "回撤后基数(U)", ""), - ("DAILY_PROFIT_CAPITAL", "盈利后基数(U)", ""), + ("DAILY_START_CAPITAL", "日起始基数(U)", ""), + ("DAILY_LOSS_CAPITAL", "回撤后基数(U)", ""), + ("DAILY_PROFIT_CAPITAL", "盈利后基数(U)", ""), ], }, ] @@ -115,18 +115,18 @@ _OPTIONS_SECTION: dict[str, Any] = { "exchanges": frozenset({"okx"}), "fields": [ ("OKX_OPTIONS_ENABLED", "启用期权模块", ""), - ("OKX_OPTIONS_API_KEY", "期权 API Key", "主账户,与永续子账户分离"), + ("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_TRADE_BUDGET_USDC", "单笔预算(USDC)", ""), ("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95"), ("OKX_OPTIONS_DEFAULT_UNDERLY", "默认标的", "如 ETH"), ], } -# 与运行时 os.getenv 默认一致;.env 未写明时展示实际生效值(同风控说明页) +# 与运行时 os.getenv 默认一致;.env 未写明时展示实际生效值(同风控说明页) _RUNTIME_ENV_DEFAULTS: dict[str, str] = { "RISK_CONTROL_ENABLED": "true", "RISK_COOLING_HOURS_MANUAL": "4", diff --git a/lib/env/shared_env_lib.py b/lib/env/shared_env_lib.py index af2634a..bb8efa0 100644 --- a/lib/env/shared_env_lib.py +++ b/lib/env/shared_env_lib.py @@ -1,4 +1,4 @@ -"""中控统一 AI 环境变量:字段定义、读写、同步三实例。""" +"""中控统一 AI 环境变量:字段定义,读写,同步三实例.""" from __future__ import annotations import os @@ -26,7 +26,7 @@ AI_ENV_FIELDS: list[tuple[str, str, str]] = [ ("OPENAI_MODEL", "云端模型", ""), ("OLLAMA_API", "Ollama 地址", "本地服务 URL"), ("AI_MODEL", "Ollama 模型", ""), - ("AI_TIMEOUT_SECONDS", "请求超时(秒)", "默认 120"), + ("AI_TIMEOUT_SECONDS", "请求超时(秒)", "默认 120"), ] AI_ENV_KEYS = frozenset(k for k, _l, _n in AI_ENV_FIELDS) @@ -108,7 +108,7 @@ def build_ai_env_payload(env_path: str | None = None, example_path: str | None = def ai_sync_status() -> dict[str, Any]: - """比较 hub 与三实例 AI 键是否一致(用于 UI 提示)。""" + """比较 hub 与三实例 AI 键是否一致(用于 UI 提示).""" hub_vals = env_get_all(read_env_lines(hub_env_path())) per_instance: dict[str, dict[str, Any]] = {} all_ok = True @@ -165,7 +165,7 @@ def validate_ai_env_updates(updates: dict[str, str], example_path: str | None = def apply_ai_env_to_all(updates: dict[str, str]) -> dict[str, Any]: - """写入 hub .env 并强制同步三实例相同键。""" + """写入 hub .env 并强制同步三实例相同键.""" clean, errors = validate_ai_env_updates(updates) if errors: return {"ok": False, "errors": errors, "changed": {}} @@ -196,7 +196,7 @@ def apply_ai_env_to_all(updates: dict[str, str]) -> dict[str, Any]: 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 @@ -212,7 +212,7 @@ def restart_instances_then_hub_pm2() -> dict[str, Any]: def restart_hub_and_instances_pm2() -> dict[str, Any]: - """兼容旧调用:与 restart_instances_then_hub_pm2 相同顺序。""" + """兼容旧调用:与 restart_instances_then_hub_pm2 相同顺序.""" return restart_instances_then_hub_pm2() diff --git a/lib/exchange/gate_ccxt_lib.py b/lib/exchange/gate_ccxt_lib.py index daf2bbd..0f143f6 100644 --- a/lib/exchange/gate_ccxt_lib.py +++ b/lib/exchange/gate_ccxt_lib.py @@ -1,9 +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 +"""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 index 5ed0438..6fc37ab 100644 --- a/lib/exchange/gate_position_history_lib.py +++ b/lib/exchange/gate_position_history_lib.py @@ -1,4 +1,4 @@ -"""Gate 平仓历史匹配(fetch_positions_history),供 reconcile / 中控全平同步共用。""" +"""Gate 平仓历史匹配(fetch_positions_history),供 reconcile / 中控全平同步共用.""" from __future__ import annotations @@ -21,8 +21,8 @@ def pick_gate_position_close( max_close_delta_ms: int = 25 * 60 * 1000, ) -> dict | None: """ - 从 Gate 平仓历史列表中选取与 symbol/direction/开仓时间最匹配的一条。 - 返回 normalize 后的 dict(含 close_ms、pnl、sync_key 等),无匹配则 None。 + 从 Gate 平仓历史列表中选取与 symbol/direction/开仓时间最匹配的一条. + 返回 normalize 后的 dict(含 close_ms,pnl,sync_key 等),无匹配则 None. """ if not hist: return None diff --git a/lib/exchange/gate_transfer_lib.py b/lib/exchange/gate_transfer_lib.py index 177ea6b..2adcb46 100644 --- a/lib/exchange/gate_transfer_lib.py +++ b/lib/exchange/gate_transfer_lib.py @@ -1,12 +1,12 @@ -"""Gate.io 资金划转(crypto_monitor_gate 共用)。""" +"""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 与白名单。" + ".常见原因:① GATE_API_SECRET 错误或 .env 里多了空格/换行;② IP 白名单未包含当前服务器出口 IP;" + "③ Gate「交易账户」类 API Key 若不支持钱包接口则无法走账户内划转 POST /wallet/transfers(需在官网确认该 Key 类型是否开放划转);" + "④ Key 已重置或权限变更.你已勾选现货/统一账户仍报错时,优先核对 Secret 与白名单." ) @@ -41,7 +41,7 @@ def execute_transfer_usdt( def count_auto_transfer_blockers(conn, *, count_order_monitors: Callable[[Any], int]) -> int: - """自动划转持仓守卫:order_monitors active + 趋势回调已开仓计划。""" + """自动划转持仓守卫:order_monitors active + 趋势回调已开仓计划.""" n = int(count_order_monitors(conn) or 0) if n > 0: return n diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py index 32991db..2e7b22b 100644 --- a/lib/exchange/okx_options_lib.py +++ b/lib/exchange/okx_options_lib.py @@ -1,4 +1,4 @@ -"""OKX USDⓈ 期权 API 封装(主账户 exchange_options 专用)。""" +"""OKX USDⓈ 期权 API 封装(主账户 exchange_options 专用).""" from __future__ import annotations import json @@ -19,7 +19,7 @@ from lib.options.options_pricing_lib import ( _OKX_OPTION_ERR_ZH: dict[str, str] = { "51018": "期权账户不能持有净空头头寸", - "51019": "期权买入须使用逐仓模式(全仓模式下不能持有多头净头寸)", + "51019": "期权买入须使用逐仓模式(全仓模式下不能持有多头净头寸)", } _OPTIONS_BALANCE_CACHE: dict[str, Any] = {"updated_at": 0.0, "data": None} @@ -59,7 +59,7 @@ def _okx_trade_error_message(exc: BaseException | None = None, resp: Any = None) def td_mode_for_option_buy(configured: str | None = None) -> str: - """OKX 买入期权(多头)必须使用逐仓。""" + """OKX 买入期权(多头)必须使用逐仓.""" mode = (configured or "isolated").strip().lower() return "isolated" if mode == "cross" else mode or "isolated" @@ -94,7 +94,7 @@ def _safe_float(v: Any) -> float | None: def round_option_px(px: float, tick_sz: Any, side: str) -> float: - """按 OKX tickSz 对齐:买入向上取整,卖出向下取整。""" + """按 OKX tickSz 对齐:买入向上取整,卖出向下取整.""" tick = _safe_float(tick_sz) if tick is None or tick <= 0 or px <= 0: return px @@ -132,7 +132,7 @@ def _resolve_chain_quote( 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")) @@ -209,7 +209,7 @@ def _pos_side_from_position(pos: dict[str, Any] | None) -> str | None: def inst_family_from_inst_id(inst_id: str) -> str | None: - """从 instId 解析 instFamily,如 ETH-USD_UM-260707-1790-C → ETH-USD_UM。""" + """从 instId 解析 instFamily,如 ETH-USD_UM-260707-1790-C → ETH-USD_UM.""" parts = (inst_id or "").strip().split("-") if len(parts) < 4: return None @@ -217,7 +217,7 @@ def inst_family_from_inst_id(inst_id: str) -> str | None: def option_fields_from_inst_id(inst_id: str) -> tuple[str | None, float | None]: - """从 instId 解析 optType 与 strike,如 ETH-USD_UM-260709-1700-P。""" + """从 instId 解析 optType 与 strike,如 ETH-USD_UM-260709-1700-P.""" parts = (inst_id or "").strip().split("-") if len(parts) < 2: return None, None @@ -228,7 +228,7 @@ def option_fields_from_inst_id(inst_id: str) -> tuple[str | None, float | None]: def expiry_ms_from_inst_id(inst_id: str) -> int | None: - """从 instId 日期段解析到期时刻(OKX 期权默认 08:00 UTC)。""" + """从 instId 日期段解析到期时刻(OKX 期权默认 08:00 UTC).""" parts = (inst_id or "").strip().split("-") if len(parts) < 3: return None @@ -246,7 +246,7 @@ def expiry_ms_from_inst_id(inst_id: str) -> int | None: def normalize_option_exp_ms(exp_time: Any, inst_id: str = "") -> int | None: - """统一期权到期毫秒时间戳(优先 API expTime,否则从 instId 推算)。""" + """统一期权到期毫秒时间戳(优先 API expTime,否则从 instId 推算).""" raw = _safe_float(exp_time) if raw is not None and raw > 0: ms = int(raw) @@ -345,7 +345,7 @@ def options_header_balances( *, force: bool = False, ) -> tuple[float | None, float | None, float | None]: - """顶栏三格:交易 USDC、资金 USDC、资金 USDT(单次拉取 + 缓存)。""" + """顶栏三格:交易 USDC,资金 USDC,资金 USDT(单次拉取 + 缓存).""" bal = fetch_options_balances(ex, force=force) def _round(v: Any) -> float | None: @@ -645,7 +645,7 @@ def fetch_option_positions(ex: ccxt.okx) -> list[dict[str, Any]]: def fetch_options_unrealized_pnl_usdc(ex: ccxt.okx) -> float | None: - """期权持仓未实现盈亏合计(USDC,统计口径与 USDT 1:1)。""" + """期权持仓未实现盈亏合计(USDC,统计口径与 USDT 1:1).""" total = 0.0 found = False for pos in fetch_option_positions(ex): @@ -752,7 +752,7 @@ def spot_market_swap_usdt_usdc( direction: str, amount: float, ) -> dict[str, Any]: - """现货市价兑换 USDC-USDT。direction: usdt_to_usdc | usdc_to_usdt。""" + """现货市价兑换 USDC-USDT.direction: usdt_to_usdc | usdc_to_usdt.""" if amount <= 0: return {"ok": False, "msg": "数量须大于 0"} d = (direction or "").lower() @@ -798,7 +798,7 @@ def transfer_main_sub_account( from_account: str = "funding", to_account: str = "funding", ) -> dict[str, Any]: - """主账户与子账户之间划转(须主账户 API)。""" + """主账户与子账户之间划转(须主账户 API).""" if amount <= 0: return {"ok": False, "msg": "划转金额须大于 0"} sub = (sub_acct or "").strip() diff --git a/lib/exchange/okx_orders_lib.py b/lib/exchange/okx_orders_lib.py index fa22c9b..c112128 100644 --- a/lib/exchange/okx_orders_lib.py +++ b/lib/exchange/okx_orders_lib.py @@ -1,6 +1,6 @@ """ -OKX 挂单聚合:普通委托 + 算法单(conditional / oco / trigger)。 -交易所 App「止盈止损」页多为 orders-algo-pending,仅 fetch_open_orders 默认拿不到。 +OKX 挂单聚合:普通委托 + 算法单(conditional / oco / trigger). +交易所 App「止盈止损」页多为 orders-algo-pending,仅 fetch_open_orders 默认拿不到. """ from __future__ import annotations @@ -22,7 +22,7 @@ def _okx_algo_cancel_id(order_id: str) -> str: def _okx_order_needs_stop_cancel_param(order: dict) -> bool: - """OKX 条件/算法单撤单须 params.stop=True,否则 cancel_order 走普通单接口会静默失败。""" + """OKX 条件/算法单撤单须 params.stop=True,否则 cancel_order 走普通单接口会静默失败.""" if not isinstance(order, dict): return False info = order.get("info") or {} @@ -40,7 +40,7 @@ def _okx_order_needs_stop_cancel_param(order: dict) -> bool: def fetch_okx_all_open_orders(ex, exchange_symbol: str) -> list[dict]: - """合并 OKX 普通挂单与算法挂单(去重)。""" + """合并 OKX 普通挂单与算法挂单(去重).""" if not exchange_symbol: return [] ex.load_markets() @@ -80,8 +80,8 @@ def fetch_okx_all_open_orders(ex, exchange_symbol: str) -> list[dict]: def cancel_okx_all_open_orders(ex, exchange_symbol: str) -> int: """ - 撤销某合约全部挂单(普通 + 条件/算法)。 - OKX 止盈止损在 orders-algo-pending,必须用 stop=True 才能撤掉。 + 撤销某合约全部挂单(普通 + 条件/算法). + OKX 止盈止损在 orders-algo-pending,必须用 stop=True 才能撤掉. """ if not exchange_symbol: return 0 diff --git a/lib/hub/hub_auth.py b/lib/hub/hub_auth.py index db2e586..a9015ab 100644 --- a/lib/hub/hub_auth.py +++ b/lib/hub/hub_auth.py @@ -1,36 +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 +"""中控调用实例 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 index d2b89bf..97aef76 100644 --- a/lib/hub/hub_backup_lib.py +++ b/lib/hub/hub_backup_lib.py @@ -1,4 +1,4 @@ -"""中控备份与恢复:三所 SQLite、K 线库、env、hub JSON。""" +"""中控备份与恢复:三所 SQLite,K 线库,env,hub JSON.""" from __future__ import annotations import json @@ -357,7 +357,7 @@ def restore_backup_archive( zf.extractall(extract_dir) manifest_path = extract_dir / "manifest.json" if not manifest_path.is_file(): - return {"ok": False, "error": "无效的备份包:缺少 manifest.json"} + return {"ok": False, "error": "无效的备份包:缺少 manifest.json"} for fp in extract_dir.rglob("*"): if not fp.is_file() or fp.name == "manifest.json": diff --git a/lib/hub/hub_bridge.py b/lib/hub/hub_bridge.py index a8b9d20..00e1bc5 100644 --- a/lib/hub/hub_bridge.py +++ b/lib/hub/hub_bridge.py @@ -1,1093 +1,1093 @@ -""" -各 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", - "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_live.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, - enrich=None, - risk_status=None, -) -> dict: - """合并 enrich 增量字段;enrich 只返回 trends 等局部时不得丢掉 keys/orders。""" - payload = { - "ok": True, - "keys": keys, - "orders": orders, - "trends": trends, - "rolls": rolls, - "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) - 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 - 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, - 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, - 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 +""" +各 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", + "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_live.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, + enrich=None, + risk_status=None, +) -> dict: + """合并 enrich 增量字段;enrich 只返回 trends 等局部时不得丢掉 keys/orders.""" + payload = { + "ok": True, + "keys": keys, + "orders": orders, + "trends": trends, + "rolls": rolls, + "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) + 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 + 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, + 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, + 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 index ea0ea39..287ffb0 100644 --- a/lib/hub/hub_calculator_lib.py +++ b/lib/hub/hub_calculator_lib.py @@ -1,498 +1,498 @@ -"""中控历史测算:趋势回调 / 滚仓,以损定仓(按交易所精度与张数规则)。""" -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_at_tp = (tp - new_avg) * new_qty * cs - else: - loss_at_sl = (sl - new_avg) * new_qty * cs - reward_at_tp = (new_avg - tp) * new_qty * cs - 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 = (tp - avg) * qty_f * cs - else: - first_loss = (initial_sl - avg) * qty_f * cs - first_profit = (avg - tp) * qty_f * cs - - 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 +"""中控历史测算:趋势回调 / 滚仓,以损定仓(按交易所精度与张数规则).""" +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_at_tp = (tp - new_avg) * new_qty * cs + else: + loss_at_sl = (sl - new_avg) * new_qty * cs + reward_at_tp = (new_avg - tp) * new_qty * cs + 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 = (tp - avg) * qty_f * cs + else: + first_loss = (initial_sl - avg) * qty_f * cs + first_profit = (avg - tp) * qty_f * cs + + 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 index 925c617..d1ba5cd 100644 --- a/lib/hub/hub_calculator_market_lib.py +++ b/lib/hub/hub_calculator_market_lib.py @@ -1,257 +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() +"""计算器:从已配置交易实例读取 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 index 0c488ff..53dc300 100644 --- a/lib/hub/hub_divergence_scan_lib.py +++ b/lib/hub/hub_divergence_scan_lib.py @@ -1,4 +1,4 @@ -"""行情区:Top20 内 MACD 背离扫描(档 A)+ 4h/日线/周线共振。""" +"""行情区:Top20 内 MACD 背离扫描(档 A)+ 4h/日线/周线共振.""" from __future__ import annotations import json @@ -91,7 +91,7 @@ def detect_latest_macd_divergence( align_bars: int = SWING_ALIGN_BARS, recency_bars: int = RECENCY_BARS, ) -> dict[str, Any]: - """档 A:最近一对摆动 MACD 顶/底背离(与 chart.js detectDivergences 同类)。""" + """档 A:最近一对摆动 MACD 顶/底背离(与 chart.js detectDivergences 同类).""" if len(closes) < swing_lookback * 2 + 10: return {"direction": None} macd = build_macd_by_index(closes) @@ -417,7 +417,7 @@ 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。""" + """对 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() diff --git a/lib/hub/hub_entry_plan_lib.py b/lib/hub/hub_entry_plan_lib.py index 90c70cb..ae635fb 100644 --- a/lib/hub/hub_entry_plan_lib.py +++ b/lib/hub/hub_entry_plan_lib.py @@ -1,4 +1,4 @@ -"""中控开仓计划:进行中 / 历史归档 / 胜率统计。""" +"""中控开仓计划:进行中 / 历史归档 / 胜率统计.""" from __future__ import annotations @@ -338,7 +338,7 @@ def resolve_stats_date_bounds( date_from: str = "", date_to: str = "", ) -> tuple[str | None, str | None, str]: - """返回 (date_from, date_to, label);all 时 bounds 为 None。""" + """返回 (date_from, date_to, label);all 时 bounds 为 None.""" p = (period or "all").strip().lower() or "all" today = _today_iso() if p == "all": diff --git a/lib/hub/hub_fund_history_lib.py b/lib/hub/hub_fund_history_lib.py index 7bcf20d..09d573b 100644 --- a/lib/hub/hub_fund_history_lib.py +++ b/lib/hub/hub_fund_history_lib.py @@ -1,437 +1,437 @@ -"""中控资金概况:分户日快照(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 _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) - - 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, - } - ) - 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 - ) - - 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, - "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 "(暂无资金历史快照)" +"""中控资金概况:分户日快照(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 _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) + + 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, + } + ) + 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 + ) + + 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, + "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_host_status_lib.py b/lib/hub/hub_host_status_lib.py index 47fe1a5..11dace1 100644 --- a/lib/hub/hub_host_status_lib.py +++ b/lib/hub/hub_host_status_lib.py @@ -1,4 +1,4 @@ -"""中控:本机 CPU / 内存 / 磁盘 / 网络快照(监控区服务器状态条)。""" +"""中控:本机 CPU / 内存 / 磁盘 / 网络快照(监控区服务器状态条).""" from __future__ import annotations import os @@ -37,7 +37,7 @@ def get_host_status() -> dict[str, Any]: except ImportError: return { "ok": False, - "msg": "未安装 psutil,请在 manual-trading-hub 环境执行 pip install psutil", + "msg": "未安装 psutil,请在 manual-trading-hub 环境执行 pip install psutil", } now = time.time() diff --git a/lib/hub/hub_kline_store.py b/lib/hub/hub_kline_store.py index 52b1a80..e1a6e6f 100644 --- a/lib/hub/hub_kline_store.py +++ b/lib/hub/hub_kline_store.py @@ -1,881 +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), - } +"""中控 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 index fcd40d3..6e02099 100644 --- a/lib/hub/hub_macro_calendar_lib.py +++ b/lib/hub/hub_macro_calendar_lib.py @@ -1,311 +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),建议等待,避免新开仓" +"""中控宏观关键数据日历:手动录入 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 index 4c48256..3dba2b7 100644 --- a/lib/hub/hub_market_info_lib.py +++ b/lib/hub/hub_market_info_lib.py @@ -1,81 +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, - } - +"""实例 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 index bc55a32..9bba504 100644 --- a/lib/hub/hub_monitor_totals_lib.py +++ b/lib/hub/hub_monitor_totals_lib.py @@ -1,111 +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), - } +"""监控区看板:三所当日统计聚合.""" +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 index e2a0982..9b3473f 100644 --- a/lib/hub/hub_ohlcv_lib.py +++ b/lib/hub/hub_ohlcv_lib.py @@ -1,692 +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}"} +"""中控行情区:各实例 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 index 7f15c37..23448d9 100644 --- a/lib/hub/hub_options_funds_lib.py +++ b/lib/hub/hub_options_funds_lib.py @@ -1,4 +1,4 @@ -"""中控资金统计:期权 USDC/USDT 按 1:1 计入 USDT 合计。""" +"""中控资金统计:期权 USDC/USDT 按 1:1 计入 USDT 合计.""" from __future__ import annotations from typing import Any, Optional @@ -23,7 +23,7 @@ def _account_total_usdt(funding: Any, trading: Any) -> Optional[float]: def stablecoin_usdt_equiv(value: Any) -> Optional[float]: - """USDC / USDT 按 1:1 折算为 USDT 统计口径。""" + """USDC / USDT 按 1:1 折算为 USDT 统计口径.""" return _safe_float(value) @@ -36,7 +36,7 @@ def _sum_optional(*values: Any) -> Optional[float]: def options_balances_usdt_equiv(options_snap: dict[str, Any] | None) -> dict[str, Any]: - """从期权 snapshot 提取资金户/交易户 USDT 等价余额。""" + """从期权 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} @@ -80,7 +80,7 @@ def merge_perp_options_balances( perpetual_trading_usdt: Any, options_snap: dict[str, Any] | None, ) -> dict[str, Any]: - """永续 + 期权余额合并为中控 USDT 统计口径。""" + """永续 + 期权余额合并为中控 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")) @@ -104,7 +104,7 @@ def merge_perp_options_balances( 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( diff --git a/lib/hub/hub_order_sync_lib.py b/lib/hub/hub_order_sync_lib.py index 6269661..c37b17e 100644 --- a/lib/hub/hub_order_sync_lib.py +++ b/lib/hub/hub_order_sync_lib.py @@ -1,4 +1,4 @@ -"""中控改委托后同步实例 order_monitors 计划价(交易所已由 agent 挂单)。""" +"""中控改委托后同步实例 order_monitors 计划价(交易所已由 agent 挂单).""" from __future__ import annotations from typing import Any, Callable @@ -14,7 +14,7 @@ def cond_order_role(row: dict[str, Any]) -> str | None: def dedupe_conditional_orders_by_role(orders: list) -> list: - """同一持仓条件单列表:每种止盈/止损只保留一条(避免 OKX OCO 拆分 + Flask 补全重复)。""" + """同一持仓条件单列表:每种止盈/止损只保留一条(避免 OKX OCO 拆分 + Flask 补全重复).""" if not orders: return [] by_role: dict[str, dict] = {} @@ -35,7 +35,7 @@ def dedupe_conditional_orders_by_role(orders: list) -> list: def exchange_tpsl_from_cond_orders(cond: list) -> dict[str, Any] | None: - """从子代理条件单列表还原 exchange_tpsl 槽位。""" + """从子代理条件单列表还原 exchange_tpsl 槽位.""" slots: dict[str, Any] = {"sl": None, "tp": None} for row in cond or []: if not isinstance(row, dict): @@ -72,7 +72,7 @@ def sync_active_monitor_tpsl_prices( *, symbols_match: Callable[[str, str], bool], ) -> dict[str, Any]: - """按 symbol+方向更新 active 下单监控的 stop_loss / take_profit。""" + """按 symbol+方向更新 active 下单监控的 stop_loss / take_profit.""" sym = (symbol or "").strip() side = (direction or "").strip().lower() if not sym: @@ -85,7 +85,7 @@ def sync_active_monitor_tpsl_prices( except (TypeError, ValueError): return {"ok": False, "msg": "stop_loss / take_profit 须为数字"} if sl <= 0 or tp <= 0: - return {"ok": False, "msg": "止损、止盈须大于 0"} + return {"ok": False, "msg": "止损,止盈须大于 0"} rows = conn.execute( "SELECT id, symbol, exchange_symbol, direction FROM order_monitors WHERE status='active'" diff --git a/lib/hub/hub_position_metrics.py b/lib/hub/hub_position_metrics.py index 9010d5d..f14227e 100644 --- a/lib/hub/hub_position_metrics.py +++ b/lib/hub/hub_position_metrics.py @@ -1,270 +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 +"""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 index e604789..42fe455 100644 --- a/lib/hub/hub_reconcile_flat_lib.py +++ b/lib/hub/hub_reconcile_flat_lib.py @@ -1,95 +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} +"""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 index b2cbaa1..b7bc6af 100644 --- a/lib/hub/hub_sso.py +++ b/lib/hub/hub_sso.py @@ -1,5 +1,5 @@ """ -实例浏览器 SSO(复用 HUB_BRIDGE_TOKEN)。无 Flask 依赖,供中控 FastAPI 与各实例共用。 +实例浏览器 SSO(复用 HUB_BRIDGE_TOKEN).无 Flask 依赖,供中控 FastAPI 与各实例共用. """ from __future__ import annotations @@ -109,7 +109,7 @@ def verify_hub_sso_token( def mint_hub_embed_bootstrap(exchange_key: str, next_path: str = "/") -> str | None: - """iframe 内嵌登录引导 token(短效、单次),供 /hub-embed-auth 写入 SameSite=None Cookie。""" + """iframe 内嵌登录引导 token(短效,单次),供 /hub-embed-auth 写入 SameSite=None Cookie.""" secret = _sso_secret() ex = (exchange_key or "").strip().lower() if not secret or not ex: diff --git a/lib/hub/hub_strategy_lib.py b/lib/hub/hub_strategy_lib.py index 7b2ee43..eaf995a 100644 --- a/lib/hub/hub_strategy_lib.py +++ b/lib/hub/hub_strategy_lib.py @@ -1,4 +1,4 @@ -"""中控「策略说明」:读取 docs/strategy MD + checklists JSON。""" +"""中控「策略说明」:读取 docs/strategy MD + checklists JSON.""" from __future__ import annotations @@ -47,7 +47,7 @@ def _md_path(exchange_key: str) -> Path: def _parse_version(md_text: str) -> str: - m = re.search(r">\s*\*\*状态\*\*[::]\s*(v[\d.]+)", md_text) + 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) @@ -409,7 +409,7 @@ def build_print_html(exchange_key: str, part: str = "doc") -> str:

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

{payload.get("strategy_html") or ""}
-
文档:{escape_html(str(source))}{(" · " + escape_html(version)) if version else ""}
+
文档:{escape_html(str(source))}{(" · " + escape_html(version)) if version else ""}
""" else: raise KeyError(part) diff --git a/lib/hub/hub_symbol_archive_lib.py b/lib/hub/hub_symbol_archive_lib.py index 1c1a24c..97d094c 100644 --- a/lib/hub/hub_symbol_archive_lib.py +++ b/lib/hub/hub_symbol_archive_lib.py @@ -1,1718 +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() +"""中控币种档案:永久 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 index dae6bdf..2ec4aa6 100644 --- a/lib/hub/hub_symbol_lib.py +++ b/lib/hub/hub_symbol_lib.py @@ -1,4 +1,4 @@ -"""合约 symbol 匹配(持仓 vs 监控/挂单)。""" +"""合约 symbol 匹配(持仓 vs 监控/挂单).""" def _symbol_base_coin(symbol: str) -> str: diff --git a/lib/hub/hub_trades_lib.py b/lib/hub/hub_trades_lib.py index cbb2209..ddc4a92 100644 --- a/lib/hub/hub_trades_lib.py +++ b/lib/hub/hub_trades_lib.py @@ -1,742 +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), - } +"""各实例当日平仓记录查询(供 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 index 04919ce..4bde011 100644 --- a/lib/hub/hub_volume_rank_lib.py +++ b/lib/hub/hub_volume_rank_lib.py @@ -1,595 +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"), - } +"""行情区:各交易所 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 index 1c6731c..ccc7d50 100644 --- a/lib/hub/price_snapshot_lib.py +++ b/lib/hub/price_snapshot_lib.py @@ -1,4 +1,4 @@ -"""price_snapshot 共用:订单行情价兜底,避免 get_price 失败时整单不入 order_prices。""" +"""price_snapshot 共用:订单行情价兜底,避免 get_price 失败时整单不入 order_prices.""" from __future__ import annotations from typing import Any, Callable, Mapping, Optional, Sequence @@ -17,10 +17,10 @@ def resolve_order_snapshot_price( 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) + 2. get_symbol_mark_price(含 mark) + 3. 交易所持仓 mark(parse_ccxt_position_metrics / parse_position_mark_price) 4. 计划成交价 trigger_price """ sym = (symbol or "").strip() @@ -82,7 +82,7 @@ def seed_prices_from_positions( *, resolve_ex_sym_fn: Callable[[Any], str], ) -> None: - """用持仓标记价补全 prices 字典(symbol 与 order_monitors 行对齐)。""" + """用持仓标记价补全 prices 字典(symbol 与 order_monitors 行对齐).""" if not all_positions or not order_rows: return try: diff --git a/lib/instance/focus_chart_lib.py b/lib/instance/focus_chart_lib.py index e62b847..0423e14 100644 --- a/lib/instance/focus_chart_lib.py +++ b/lib/instance/focus_chart_lib.py @@ -1,187 +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 +"""实盘/关键位放大 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_display_prefs_lib.py b/lib/instance/instance_display_prefs_lib.py index 693046c..273652b 100644 --- a/lib/instance/instance_display_prefs_lib.py +++ b/lib/instance/instance_display_prefs_lib.py @@ -1,4 +1,4 @@ -"""实例顶栏 / 系统设置区块显示开关(存 SQLite,即时生效)。""" +"""实例顶栏 / 系统设置区块显示开关(存 SQLite,即时生效).""" from __future__ import annotations from typing import Any, Callable, Optional diff --git a/lib/instance/instance_embed_context_lib.py b/lib/instance/instance_embed_context_lib.py index 3cd6bc9..9a19d38 100644 --- a/lib/instance/instance_embed_context_lib.py +++ b/lib/instance/instance_embed_context_lib.py @@ -1,155 +1,155 @@ -"""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 - is_settings_like = page in ("settings", "risk_policy", "env_config") - return EmbedRenderPlan( - exchange_capitals=is_shell, - records_rows=page == "records", - records_summary=is_shell and page != "records" and not is_settings_like, - 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: - parts.append(f"{float(funding_usdc):.2f} USDC") - if funding_usdt is not None: - 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, -) -> float | None: - if funding_usdt is None: - return None - try: - total = float(funding_usdt) + float(trading_usdt or 0) - if options_funding_usdc is not None: - total += float(options_funding_usdc) - if options_funding_usdt is not None: - total += float(options_funding_usdt) - if options_trading_usdc is not None: - total += float(options_trading_usdc) - 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 minimal_stats_bundle(reset_hour: int) -> dict[str, Any]: - return {"stats_reset_hour": reset_hour, "segments": []} +"""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 + is_settings_like = page in ("settings", "risk_policy", "env_config") + return EmbedRenderPlan( + exchange_capitals=is_shell, + records_rows=page == "records", + records_summary=is_shell and page != "records" and not is_settings_like, + 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: + parts.append(f"{float(funding_usdc):.2f} USDC") + if funding_usdt is not None: + 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, +) -> float | None: + if funding_usdt is None: + return None + try: + total = float(funding_usdt) + float(trading_usdt or 0) + if options_funding_usdc is not None: + total += float(options_funding_usdc) + if options_funding_usdt is not None: + total += float(options_funding_usdt) + if options_trading_usdc is not None: + total += float(options_trading_usdc) + 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 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 index 6504a5a..01b2f0c 100644 --- a/lib/instance/instance_embed_lib.py +++ b/lib/instance/instance_embed_lib.py @@ -1,186 +1,186 @@ -"""中控 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, ...] = ( - "key_monitor", - "trade", - "strategy", - "strategy_records", - "options", - "records", - "stats", - "risk_policy", - "env_config", - "settings", -) - -PATH_TO_EMBED_TAB: dict[str, str] = { - "/": "trade", - "/trade": "trade", - "/key_monitor": "key_monitor", - "/strategy": "strategy", - "/strategy/trend": "strategy", - "/strategy/roll": "strategy", - "/strategy/records": "strategy_records", - "/options": "options", - "/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 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), - } +"""中控 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, ...] = ( + "key_monitor", + "trade", + "strategy", + "strategy_records", + "options", + "records", + "stats", + "risk_policy", + "env_config", + "settings", +) + +PATH_TO_EMBED_TAB: dict[str, str] = { + "/": "trade", + "/trade": "trade", + "/key_monitor": "key_monitor", + "/strategy": "strategy", + "/strategy/trend": "strategy", + "/strategy/roll": "strategy", + "/strategy/records": "strategy_records", + "/options": "options", + "/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 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), + } diff --git a/lib/instance/instance_live_pnl_lib.py b/lib/instance/instance_live_pnl_lib.py index 13a9aa3..17fa9c4 100644 --- a/lib/instance/instance_live_pnl_lib.py +++ b/lib/instance/instance_live_pnl_lib.py @@ -1,4 +1,4 @@ -"""实例页:持仓未实现盈亏(实时盈亏)汇总。""" +"""实例页:持仓未实现盈亏(实时盈亏)汇总.""" from __future__ import annotations from collections.abc import Callable @@ -8,7 +8,7 @@ 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 规则一致。""" + """持仓张数:与三所 app 内 _position_row_effective_contracts 规则一致.""" if not isinstance(pos, dict): return 0.0 info = pos.get("info") or {} @@ -67,7 +67,7 @@ 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 汇总(与持仓卡浮盈亏一致)。""" + """按活跃监控单逐笔拉交易所 metrics 汇总(与持仓卡浮盈亏一致).""" total = 0.0 found = False for row in rows or []: @@ -103,7 +103,7 @@ def resolve_instance_unrealized_pnl( active_rows: list[Any] | None, get_metrics_fn: Callable[[str, str], dict[str, Any] | None] | None, ) -> float | None: - """先全量持仓汇总,失败或无数据时回退到活跃监控单 metrics。""" + """先全量持仓汇总,失败或无数据时回退到活跃监控单 metrics.""" total = fetch_unrealized_pnl(fetch_positions_fn) if total is not None: return total @@ -113,7 +113,7 @@ def resolve_instance_unrealized_pnl( def merge_unrealized_pnl_components(*parts: float | None) -> float | None: - """合并永续与期权等多路未实现盈亏(任一路有值即参与合计)。""" + """合并永续与期权等多路未实现盈亏(任一路有值即参与合计).""" total = 0.0 found = False for part in parts: diff --git a/lib/instance/instance_live_push_lib.py b/lib/instance/instance_live_push_lib.py index 0da8a9b..2baa2d8 100644 --- a/lib/instance/instance_live_push_lib.py +++ b/lib/instance/instance_live_push_lib.py @@ -1,4 +1,4 @@ -"""实例 embed 壳:后台定时 tick + SSE 通知前端拉 JSON 快照(对齐中控 dashboard)。""" +"""实例 embed 壳:后台定时 tick + SSE 通知前端拉 JSON 快照(对齐中控 dashboard).""" from __future__ import annotations import json diff --git a/lib/instance/instance_nav_lib.py b/lib/instance/instance_nav_lib.py index 244dcc5..844cf98 100644 --- a/lib/instance/instance_nav_lib.py +++ b/lib/instance/instance_nav_lib.py @@ -1,4 +1,4 @@ -"""中控 iframe 内软导航:服务端跳过重型同步,避免切 tab 等待数秒。""" +"""中控 iframe 内软导航:服务端跳过重型同步,避免切 tab 等待数秒.""" from __future__ import annotations @@ -6,7 +6,7 @@ from flask import Request def request_is_hub_soft_nav(req: Request | None = None) -> bool: - """embed=1 且带 X-Instance-Soft-Nav 头:实例页内 fetch 换页,非整页刷新。""" + """embed=1 且带 X-Instance-Soft-Nav 头:实例页内 fetch 换页,非整页刷新.""" try: from flask import request as flask_request diff --git a/lib/instance/instance_pm2_lib.py b/lib/instance/instance_pm2_lib.py index 1b73ae9..499e1a7 100644 --- a/lib/instance/instance_pm2_lib.py +++ b/lib/instance/instance_pm2_lib.py @@ -1,4 +1,4 @@ -"""PM2 重启当前实例(仅 Linux 部署环境)。""" +"""PM2 重启当前实例(仅 Linux 部署环境).""" from __future__ import annotations import os diff --git a/lib/instance/instance_settings_lib.py b/lib/instance/instance_settings_lib.py index 4d91ee6..eea0f6b 100644 --- a/lib/instance/instance_settings_lib.py +++ b/lib/instance/instance_settings_lib.py @@ -1,4 +1,4 @@ -"""实例「系统设置」页:从 .env 汇总风控说明(三所共用)。""" +"""实例「系统设置」页:从 .env 汇总风控说明(三所共用).""" from __future__ import annotations import os @@ -82,7 +82,7 @@ def build_instance_settings_view( _row( "单日开仓提醒", f"第 {alert_threshold} 次", - "达次数推送企业微信,不拦单", + "达次数推送企业微信,不拦单", ), _row( "单日开仓硬上限", @@ -144,7 +144,7 @@ def build_instance_settings_view( _row("期权模块", "已启用"), _row( "期权 API", - f"已配置(…{opt_key[-4:]})" if len(opt_key) >= 4 else "未配置", + f"已配置(…{opt_key[-4:]})" if len(opt_key) >= 4 else "未配置", ), _row( "子账户", diff --git a/lib/instance/instance_settings_register.py b/lib/instance/instance_settings_register.py index 001d83d..ef5c75e 100644 --- a/lib/instance/instance_settings_register.py +++ b/lib/instance/instance_settings_register.py @@ -1,4 +1,4 @@ -"""实例系统设置 API:导航开关、env 读写、改密、PM2 重启。""" +"""实例系统设置 API:导航开关,env 读写,改密,PM2 重启.""" from __future__ import annotations import os diff --git a/lib/instance/journal_chart_lib.py b/lib/instance/journal_chart_lib.py index 3068c18..18bd9f5 100644 --- a/lib/instance/journal_chart_lib.py +++ b/lib/instance/journal_chart_lib.py @@ -1,4 +1,4 @@ -"""交易复盘 / 订单 K 线拼图(Binance / Gate / OKX 共用)。""" +"""交易复盘 / 订单 K 线拼图(Binance / Gate / OKX 共用).""" import math @@ -148,11 +148,11 @@ def _to_int_ms(value): 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 根(可看平仓后走势) + 复盘 K 线窗口(anchor=close): + - 有开/平仓:从开仓前若干根起,到平仓 K 线止(覆盖整笔交易 + 入场前背景) + - 仅开仓:以开仓时间为终点向前 limit 根 + - 仅平仓:以平仓时间为终点向前 limit 根 + anchor=now:以当前时间为终点向前 limit 根(可看平仓后走势) """ period = timeframe_period_ms(timeframe) lim = max(2, int(limit)) @@ -224,7 +224,7 @@ def trim_rows_for_trade_review(rows, window): def parse_journal_chart_timeframes(tf1, tf2, fallback_tfs=None): - """复盘表单:最多两个周期,去重保序。""" + """复盘表单:最多两个周期,去重保序.""" out = [] for raw in (tf1, tf2): tf = normalize_chart_timeframe(raw) @@ -274,7 +274,7 @@ def render_candles_subplot( price_levels=None, ): if not Image or not ImageDraw: - raise RuntimeError("缺少依赖:Pillow(pip install Pillow)") + raise RuntimeError("缺少依赖:Pillow(pip install Pillow)") img = Image.new("RGB", (width, height), bg_rgb) draw = ImageDraw.Draw(img) font = _load_font(14) diff --git a/lib/instance/journal_images_lib.py b/lib/instance/journal_images_lib.py index 316fe5f..dbe420e 100644 --- a/lib/instance/journal_images_lib.py +++ b/lib/instance/journal_images_lib.py @@ -1,208 +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 +"""复盘记录:多周期截图上传,存储与读取(三所共用).""" +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 index 3a2a5bc..cb6a94c 100644 --- a/lib/instance/journal_upload_api_lib.py +++ b/lib/instance/journal_upload_api_lib.py @@ -1,43 +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 +"""复盘截图即时上传 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/runtime_config_lib.py b/lib/instance/runtime_config_lib.py index 7db8ee6..43ea963 100644 --- a/lib/instance/runtime_config_lib.py +++ b/lib/instance/runtime_config_lib.py @@ -1,4 +1,4 @@ -"""env 运行时覆盖:热生效项优先读 SQLite,再回退 os.environ。""" +"""env 运行时覆盖:热生效项优先读 SQLite,再回退 os.environ.""" from __future__ import annotations import os @@ -42,7 +42,7 @@ def set_config_overrides(get_db: Callable, mapping: dict[str, str]) -> None: def apply_env_reload(env_path: str, get_db: Callable, changed_keys: list[str], groups: list[dict]) -> dict[str, bool]: - """写盘后同步 os.environ,并将可热生效项写入 runtime 覆盖。""" + """写盘后同步 os.environ,并将可热生效项写入 runtime 覆盖.""" load_env_file_into_environ(env_path) hot: dict[str, str] = {} field_map = {} diff --git a/lib/instance/runtime_settings_lib.py b/lib/instance/runtime_settings_lib.py index fd6415f..36933c6 100644 --- a/lib/instance/runtime_settings_lib.py +++ b/lib/instance/runtime_settings_lib.py @@ -1,4 +1,4 @@ -"""实例 SQLite 运行时配置(导航开关、env 热覆盖等)。""" +"""实例 SQLite 运行时配置(导航开关,env 热覆盖等).""" from __future__ import annotations import sqlite3 diff --git a/lib/instance/templates/display_prefs_panel.html b/lib/instance/templates/display_prefs_panel.html index 5aab200..950b118 100644 --- a/lib/instance/templates/display_prefs_panel.html +++ b/lib/instance/templates/display_prefs_panel.html @@ -1,7 +1,7 @@ -{# 系统设置 · 导航显示开关(SSR 预渲染,保存仍走 API) #} +{# 系统设置 · 导航显示开关(SSR 预渲染,保存仍走 API) #}

导航显示

-

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

+

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

{% if display_meta %} {% for group in display_meta %} diff --git a/lib/instance/templates/embed_boot_scripts.html b/lib/instance/templates/embed_boot_scripts.html index 4a44b45..beb8ab4 100644 --- a/lib/instance/templates/embed_boot_scripts.html +++ b/lib/instance/templates/embed_boot_scripts.html @@ -1,1438 +1,1438 @@ - + diff --git a/lib/instance/templates/embed_page_fragment.html b/lib/instance/templates/embed_page_fragment.html index 1264da4..0113418 100644 --- a/lib/instance/templates/embed_page_fragment.html +++ b/lib/instance/templates/embed_page_fragment.html @@ -1,438 +1,438 @@ -{# Hub iframe tab fragment — shared via embed_templates #} -{% macro period_stats(title, s) %} -
-

{{ title }}

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

实盘下单监控

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

实时持仓

-
- {% for o in order %} -
-
-
- {{ o.exchange_symbol or o.symbol }} - {% if o.time_close_enabled %} - - 时间平仓 {{ o.time_close_hours or '' }}h - · --:--:-- - - {% endif %} - {% include 'force_close_order_badge.html' %} - {{ '做多' if o.direction == 'long' else '做空' }} -
-
- {% if not intraday_discipline %} - - 平仓 - {% endif %} -
-
-
- 来源: {{ o.monitor_type|default('下单监控', true) }}{% if o.key_signal_type %} · {{ o.key_signal_type }}{% endif %} - {% if o.entry_model_label %}开仓: {{ o.entry_model_label }}{% elif intraday_discipline %}开仓: —{% else %}风格: {{ '波段单' if o.trade_style == 'swing' else '趋势单' }}{% endif %} - 风险: {% if position_sizing_mode == 'full_margin' %}{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U{% else %}{{ o.risk_percent or '-' }}%≈{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U{% endif %} - - - {% if intraday_discipline %} - {% elif o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %} - - -
-
-
- 成交价 - {{ price_fmt(o.symbol, o.trigger_price) }} -
-
- 止损 - {{ price_fmt(o.symbol, o.stop_loss) if o.stop_loss else '—' }} -
-
- 止盈 - {{ price_fmt(o.symbol, o.take_profit) if o.take_profit else '—' }} -
-
- 盈亏比 - {% if o.rr_ratio is not none %}{{ '%g'|format(o.rr_ratio) }}:1{% else %}-:1{% endif %} -
-
- 张数 - {% if o.order_amount is not none %}{{ '%.2f'|format(o.order_amount) }}{% else %}—{% endif %} -
-
- 盈利金额 - -
-
- 标记价 - - -
-
- 浮盈亏 - - -
-
- -
-
交易所止盈止损
-
- 止损:加载中… - -
-
- 止盈:加载中… - -
-
-
- {% else %} -
暂无持仓
- {% endfor %} -
-
- -
-
-

挂止盈止损

-

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

-
- -
-
- - -
-
- - -
-
- - -
-
-
- -
- {% elif page in ('strategy', 'strategy_trend', 'strategy_roll') %} - {% include 'strategy_trading_page.html' %} - {% elif page == 'strategy_records' %} - {% include 'strategy_records_page.html' %} - {% elif page == 'options' %} - {% include 'options_panel.html' %} - {% endif %} - - - - {% if page == 'records' %} -
-

交易记录

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

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

-
- - - - - - {% from 'journal_form_fields.html' import journal_form_fields %} - {{ journal_form_fields(entry_reason_options) }} - {% from 'journal_upload_slots.html' import journal_upload_slots %} - {{ journal_upload_slots() }} -
- - - - - - - - - -
-
双周期上下排列;截止=平仓时间:开仓前背景至平仓;截止=当前时间:最近 N 根至此刻(可看平仓后走势);标注开仓、平仓与止损位
-
- - - - - - -
- - -
-
- -
-
-

AI复盘(按交易记录)

- -
-
- - - - - - - -
- - -
-
- 交易复盘记录 -
-
-
- AI历史复盘 -
-
-
-
-
-
- {% endif %} -
- {% if page == 'env_config' %} - {% include 'env_config_panel.html' %} - {% endif %} - {% if page == 'risk_policy' %} - {% include 'risk_policy_panel.html' %} - {% endif %} - {% if page == 'settings' %} - {% include 'settings_panel.html' %} - {% endif %} - {% if page == 'stats' %} -
-
-

数据统计

- -
-
-
- 统计分析按北京时间 {{ stats_bundle.stats_reset_hour }}:00切日计入(与顶栏 UTC 列表窗无关)。历史总开仓(累计): - {{ stats_bundle.total_opens_all }} 次 -
-
- -
- {% for seg in stats_bundle.segments %} - - {% endfor %} -
-
- {% endif %} +{# Hub iframe tab fragment — shared via embed_templates #} +{% macro period_stats(title, s) %} +
+

{{ title }}

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

实盘下单监控

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

实时持仓

+
+ {% for o in order %} +
+
+
+ {{ o.exchange_symbol or o.symbol }} + {% if o.time_close_enabled %} + + 时间平仓 {{ o.time_close_hours or '' }}h + · --:--:-- + + {% endif %} + {% include 'force_close_order_badge.html' %} + {{ '做多' if o.direction == 'long' else '做空' }} +
+
+ {% if not intraday_discipline %} + + 平仓 + {% endif %} +
+
+
+ 来源: {{ o.monitor_type|default('下单监控', true) }}{% if o.key_signal_type %} · {{ o.key_signal_type }}{% endif %} + {% if o.entry_model_label %}开仓: {{ o.entry_model_label }}{% elif intraday_discipline %}开仓: —{% else %}风格: {{ '波段单' if o.trade_style == 'swing' else '趋势单' }}{% endif %} + 风险: {% if position_sizing_mode == 'full_margin' %}{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U{% else %}{{ o.risk_percent or '-' }}%≈{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U{% endif %} + + + {% if intraday_discipline %} + {% elif o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %} + + +
+
+
+ 成交价 + {{ price_fmt(o.symbol, o.trigger_price) }} +
+
+ 止损 + {{ price_fmt(o.symbol, o.stop_loss) if o.stop_loss else '—' }} +
+
+ 止盈 + {{ price_fmt(o.symbol, o.take_profit) if o.take_profit else '—' }} +
+
+ 盈亏比 + {% if o.rr_ratio is not none %}{{ '%g'|format(o.rr_ratio) }}:1{% else %}-:1{% endif %} +
+
+ 张数 + {% if o.order_amount is not none %}{{ '%.2f'|format(o.order_amount) }}{% else %}—{% endif %} +
+
+ 盈利金额 + +
+
+ 标记价 + - +
+
+ 浮盈亏 + - +
+
+ +
+
交易所止盈止损
+
+ 止损:加载中… + +
+
+ 止盈:加载中… + +
+
+
+ {% else %} +
暂无持仓
+ {% endfor %} +
+
+ +
+
+

挂止盈止损

+

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

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ {% elif page in ('strategy', 'strategy_trend', 'strategy_roll') %} + {% include 'strategy_trading_page.html' %} + {% elif page == 'strategy_records' %} + {% include 'strategy_records_page.html' %} + {% elif page == 'options' %} + {% include 'options_panel.html' %} + {% endif %} + + + + {% if page == 'records' %} +
+

交易记录

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

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

+
+ + + + + + {% from 'journal_form_fields.html' import journal_form_fields %} + {{ journal_form_fields(entry_reason_options) }} + {% from 'journal_upload_slots.html' import journal_upload_slots %} + {{ journal_upload_slots() }} +
+ + + + + + + + + +
+
双周期上下排列;截止=平仓时间:开仓前背景至平仓;截止=当前时间:最近 N 根至此刻(可看平仓后走势);标注开仓,平仓与止损位
+
+ + + + + + +
+ + +
+
+ +
+
+

AI复盘(按交易记录)

+ +
+
+ + + + + + + +
+ + +
+
+ 交易复盘记录 +
+
+
+ AI历史复盘 +
+
+
+
+
+ + {% endif %} + + {% if page == 'env_config' %} + {% include 'env_config_panel.html' %} + {% endif %} + {% if page == 'risk_policy' %} + {% include 'risk_policy_panel.html' %} + {% endif %} + {% if page == 'settings' %} + {% include 'settings_panel.html' %} + {% endif %} + {% if page == 'stats' %} +
+
+

数据统计

+ +
+
+
+ 统计分析按北京时间 {{ stats_bundle.stats_reset_hour }}:00切日计入(与顶栏 UTC 列表窗无关).历史总开仓(累计): + {{ stats_bundle.total_opens_all }} 次 +
+
+ +
+ {% for seg in stats_bundle.segments %} + + {% endfor %} +
+
+ {% endif %} diff --git a/lib/instance/templates/env_config_panel.html b/lib/instance/templates/env_config_panel.html index 609b0d8..b5d204a 100644 --- a/lib/instance/templates/env_config_panel.html +++ b/lib/instance/templates/env_config_panel.html @@ -1,10 +1,10 @@ -{# env配置:CSS Tab(无需 JS)+ 双列表单 #} +{# env配置:CSS Tab(无需 JS)+ 双列表单 #}

env 配置

-

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

+

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

@@ -29,7 +29,7 @@ {% for group in env_config_groups %}
{% if group.has_restart %} -

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

+

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

{% endif %}
{% for field in group.fields %} @@ -56,7 +56,7 @@ id="env-f-{{ field.key }}" type="password" data-env-key="{{ field.key }}" - placeholder="{% if field.has_value %}修改时填写新值,留空不修改{% else %}请输入{% endif %}" + placeholder="{% if field.has_value %}修改时填写新值,留空不修改{% else %}请输入{% endif %}" autocomplete="off" > {% else %} diff --git a/lib/instance/templates/force_close_header_badge.html b/lib/instance/templates/force_close_header_badge.html index a851e5b..3442516 100644 --- a/lib/instance/templates/force_close_header_badge.html +++ b/lib/instance/templates/force_close_header_badge.html @@ -1,6 +1,6 @@ {% if force_close.enabled %} {{ force_close.label }} 已开启 · {{ force_close.countdown or '--:--:--' }} diff --git a/lib/instance/templates/index.html b/lib/instance/templates/index.html index 9447b79..dc94dc8 100644 --- a/lib/instance/templates/index.html +++ b/lib/instance/templates/index.html @@ -1,2043 +1,2043 @@ -{# 三所共用 standalone 主页 — 由 scripts/build_unified_index.py 生成,勿手改三所副本 #} - - - - - - - - - - - - - - - - - {{ exchange_display }} · 加密货币 | 交易监控复盘系统 - - - - - -{% macro period_stats(title, s) %} -
-

{{ title }}

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

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

-
-
- 关键位监控 - 实盘下单 - {% 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 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') %} - {% include 'instance_top_bar.html' %} - {% endif %} - -
- {% if page == 'key_monitor' %} - {% include 'key_monitor_panel.html' %} - {% elif page == 'trade' %} -
-
-
-

实盘下单监控

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

实时持仓

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

挂止盈止损

-

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

-
- -
-
- - -
-
- - -
-
- - -
-
-
- -
- {% elif page in ('strategy', 'strategy_trend', 'strategy_roll') %} - {% include 'strategy_trading_page.html' %} - {% elif page == 'strategy_records' %} - {% include 'strategy_records_page.html' %} - {% elif page == 'options' %} - {% include 'options_panel.html' %} - {% endif %} - - - - {% if page == 'records' %} -
-

交易记录

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

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

-
- - - - - - {% from 'journal_form_fields.html' import journal_form_fields %} - {{ journal_form_fields(entry_reason_options) }} - {% from 'journal_upload_slots.html' import journal_upload_slots %} - {{ journal_upload_slots() }} -
- - - - - - - - - -
-
双周期上下排列;截止=平仓时间:开仓前背景至平仓;截止=当前时间:最近 N 根至此刻(可看平仓后走势);标注开仓、平仓与止损位
-
- - - - - - -
- - -
-
- -
-
-

AI复盘(按交易记录)

- -
-
- - - - - - - -
- - -
-
- 交易复盘记录 -
-
-
- AI历史复盘 -
-
-
-
-
-
- {% endif %} -
- - {% if page == 'env_config' %} - {% include 'env_config_panel.html' %} - {% endif %} - - {% if page == 'risk_policy' %} - {% include 'risk_policy_panel.html' %} - {% endif %} - - {% if page == 'settings' %} - {% include 'settings_panel.html' %} - {% endif %} - - {% if page == 'stats' %} -
-
-

数据统计

- -
-
-
- 统计分析按北京时间 {{ stats_bundle.stats_reset_hour }}:00切日计入(与顶栏 UTC 列表窗无关)。历史总开仓(累计): - {{ stats_bundle.total_opens_all }} 次 -
-
- -
- {% for seg in stats_bundle.segments %} - - {% endfor %} -
-
- {% endif %} -
- - -
-
-
-
详情
-
- - -
-
-
- - -
-
- - - - - - - - - - - - - - - - +{# 三所共用 standalone 主页 — 由 scripts/build_unified_index.py 生成,勿手改三所副本 #} + + + + + + + + + + + + + + + + + {{ exchange_display }} · 加密货币 | 交易监控复盘系统 + + + + + +{% macro period_stats(title, s) %} +
+

{{ title }}

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

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

+
+
+ 关键位监控 + 实盘下单 + {% 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 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') %} + {% include 'instance_top_bar.html' %} + {% endif %} + +
+ {% if page == 'key_monitor' %} + {% include 'key_monitor_panel.html' %} + {% elif page == 'trade' %} +
+
+
+

实盘下单监控

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

实时持仓

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

挂止盈止损

+

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

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ {% elif page in ('strategy', 'strategy_trend', 'strategy_roll') %} + {% include 'strategy_trading_page.html' %} + {% elif page == 'strategy_records' %} + {% include 'strategy_records_page.html' %} + {% elif page == 'options' %} + {% include 'options_panel.html' %} + {% endif %} + + + + {% if page == 'records' %} +
+

交易记录

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

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

+
+ + + + + + {% from 'journal_form_fields.html' import journal_form_fields %} + {{ journal_form_fields(entry_reason_options) }} + {% from 'journal_upload_slots.html' import journal_upload_slots %} + {{ journal_upload_slots() }} +
+ + + + + + + + + +
+
双周期上下排列;截止=平仓时间:开仓前背景至平仓;截止=当前时间:最近 N 根至此刻(可看平仓后走势);标注开仓,平仓与止损位
+
+ + + + + + +
+ + +
+
+ +
+
+

AI复盘(按交易记录)

+ +
+
+ + + + + + + +
+ + +
+
+ 交易复盘记录 +
+
+
+ AI历史复盘 +
+
+
+
+
+
+ {% endif %} +
+ + {% if page == 'env_config' %} + {% include 'env_config_panel.html' %} + {% endif %} + + {% if page == 'risk_policy' %} + {% include 'risk_policy_panel.html' %} + {% endif %} + + {% if page == 'settings' %} + {% include 'settings_panel.html' %} + {% endif %} + + {% if page == 'stats' %} +
+
+

数据统计

+ +
+
+
+ 统计分析按北京时间 {{ stats_bundle.stats_reset_hour }}:00切日计入(与顶栏 UTC 列表窗无关).历史总开仓(累计): + {{ stats_bundle.total_opens_all }} 次 +
+
+ +
+ {% for seg in stats_bundle.segments %} + + {% endfor %} +
+
+ {% endif %} +
+ + +
+
+
+
详情
+
+ + +
+
+
+ + +
+
+ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/lib/instance/templates/instance_header_panel.html b/lib/instance/templates/instance_header_panel.html index 09229bb..842ae61 100644 --- a/lib/instance/templates/instance_header_panel.html +++ b/lib/instance/templates/instance_header_panel.html @@ -1,8 +1,8 @@ -{# 统一顶栏:状态 + 筛选(上)· 统计条(下) #} +{# 统一顶栏:状态 + 筛选(上)· 统计条(下) #}
- UTC {{ list_window.label }} + UTC {{ list_window.label }} - {% if open_guard_enabled %}已限制:{{ reset_hour }}:00 前不可开仓{% else %}已放开:{{ reset_hour }}:00 前允许开仓{% endif %} + {% 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 index 2a95145..2b7411f 100644 --- a/lib/instance/templates/instance_transfer_panel.html +++ b/lib/instance/templates/instance_transfer_panel.html @@ -1,12 +1,12 @@ -{# 系统设置 · 资金划转(三所共用) #} +{# 系统设置 · 资金划转(三所共用) #}

- 自动划转 {{ '开启' 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 }}; - 持仓中不划转并微信通知。 + 自动划转 {{ '开启' 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 index 8e1c5b7..6e85d41 100644 --- a/lib/instance/templates/login.html +++ b/lib/instance/templates/login.html @@ -114,7 +114,7 @@