From 9f67de3677e6f801c7185c1318df5318aa56c0d0 Mon Sep 17 00:00:00 2001 From: dekun Date: Sat, 4 Jul 2026 22:00:08 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E7=A7=BB=E9=99=A4=20gate=5Fbot?= =?UTF-8?q?=EF=BC=8C=E7=BB=9F=E4=B8=80=E4=B8=BA=E4=B8=89=E6=89=80=E6=9E=B6?= =?UTF-8?q?=E6=9E=84=E5=B9=B6=E6=9B=B4=E6=96=B0=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除 crypto_monitor_gate_bot 目录,中控与子代理改为 binance/okx/gate 三账户; 文档与 UI 文案「四所」改为「三所」;新增清库前一次性配置备份脚本。 Co-authored-by: Cursor --- .gitignore | 1 + AI复盘与模型配置说明.md | 140 +- README.md | 11 +- brand/manifest.webmanifest | 46 +- crypto_monitor_binance/更新文档.md | 2 +- crypto_monitor_binance/部署文档.md | 6 +- crypto_monitor_gate/更新文档.md | 2 +- crypto_monitor_gate/部署文档.md | 2 +- crypto_monitor_gate_bot/.env.example | 210 - crypto_monitor_gate_bot/README.md | 90 - crypto_monitor_gate_bot/app.py | 9627 ---------- crypto_monitor_gate_bot/ecosystem.config.cjs | 34 - .../scripts/backup_data.sh | 109 - .../scripts/backup_db_now.py | 69 - .../scripts/fix_breakeven_labels.py | 108 - .../scripts/install_backup_cron.sh | 38 - .../scripts/verify_gate_funding.py | 93 - .../static/icons/apple-touch-icon.png | Bin 1743 -> 0 bytes .../static/icons/favicon.ico | Bin 181 -> 0 bytes .../static/icons/icon-16.png | Bin 162 -> 0 bytes .../static/icons/icon-192.png | Bin 1796 -> 0 bytes .../static/icons/icon-32.png | Bin 497 -> 0 bytes .../static/icons/icon-512.png | Bin 6059 -> 0 bytes crypto_monitor_gate_bot/static/icons/icon.svg | 17 - .../static/icons/manifest.webmanifest | 23 - crypto_monitor_gate_bot/templates/index.html | 2175 --- .../templates/key_focus.html | 1 - crypto_monitor_gate_bot/templates/login.html | 136 - .../templates/order_focus.html | 194 - crypto_monitor_gate_bot/使用说明.md | 147 - crypto_monitor_gate_bot/关键位自动下单说明.md | 143 - crypto_monitor_gate_bot/更新文档.md | 148 - crypto_monitor_gate_bot/部署文档.md | 339 - crypto_monitor_okx/部署文档.md | 2 +- deploy/README.md | 6 +- deploy/setup_env.sh | 3 +- docs/account-risk-cooldown.md | 260 +- docs/auto-transfer-daily.md | 90 +- docs/daily-open-limit.md | 162 +- docs/env-sync-scripts.md | 232 +- docs/hub-symbol-archive-kline.md | 270 +- docs/lib-structure.md | 294 +- docs/manual-order-rr-preview.md | 62 +- docs/position-sizing-mode.md | 114 +- docs/shortcut-icon.md | 82 +- docs/trend-hub-close-and-trade-records.md | 368 +- .../trend-pullback-strategy.md | 258 +- docs/ubuntu-server.md | 318 +- lib/ai/ai_review_lib.py | 4 +- lib/common/static/account_risk_badge.css | 300 +- lib/common/static/account_risk_badge.js | 240 +- lib/common/static/instance_theme.css | 3148 ++-- lib/common/static/instance_theme.js | 1144 +- lib/common/static/instance_ui.js | 538 +- lib/common/static/key_monitor_form.js | 320 +- lib/common/static/trade_stats_calendar.css | 320 +- lib/common/static/trade_stats_calendar.js | 628 +- lib/exchange/gate_transfer_lib.py | 2 +- lib/hub/hub_backup_lib.py | 3 +- lib/hub/hub_bridge.py | 4 +- lib/hub/hub_ohlcv_lib.py | 1384 +- lib/hub/hub_position_metrics.py | 504 +- lib/hub/hub_volume_rank_lib.py | 4 +- lib/instance/instance_embed_lib.py | 3 +- lib/key_monitor/key_monitor_schema_lib.py | 28 +- .../trigger_entry_key_monitor_lib.py | 2 +- lib/strategy/strategy_config.py | 460 +- lib/strategy/strategy_exchange_gate.py | 2 +- lib/strategy/strategy_records_register.py | 2 +- lib/strategy/strategy_snapshot_lib.py | 2 +- lib/strategy/strategy_trend_lib.py | 8 +- lib/strategy/strategy_trend_register.py | 8 +- lib/strategy/strategy_ui.py | 5 +- lib/strategy/strategy_wechat_notify.py | 2 +- .../templates/strategy_trend_disabled.html | 2 +- .../strategy_trend_disabled_panel.html | 4 +- .../templates/strategy_trend_panel.html | 2 +- lib/trade/account_risk_lib.py | 2 +- lib/trade/daily_open_limit_lib.py | 280 +- lib/trade/position_sizing_lib.py | 272 +- lib/trade/trade_exchange_stats_lib.py | 458 +- manual_trading_hub/.env.example | 12 +- manual_trading_hub/AI教练说明.md | 132 +- manual_trading_hub/README.md | 211 +- manual_trading_hub/SNAPSHOT_ROLLBACK.md | 44 +- manual_trading_hub/agent.py | 12 +- manual_trading_hub/ecosystem.config.cjs | 3 +- manual_trading_hub/exchange_orders.py | 2 +- manual_trading_hub/hub.py | 12 +- manual_trading_hub/hub_ai/context.py | 2 +- manual_trading_hub/hub_dashboard.py | 2 +- manual_trading_hub/scripts/check_agents.sh | 3 +- manual_trading_hub/scripts/fix_env_crlf.sh | 3 +- manual_trading_hub/scripts/pm2_agents.sh | 10 +- manual_trading_hub/scripts/pm2_hub.sh | 3 +- .../scripts/pm2_restart_agents.sh | 9 +- manual_trading_hub/scripts/后台运行-Ubuntu.md | 2 +- manual_trading_hub/settings_store.py | 12 +- manual_trading_hub/static/app.css | 14790 ++++++++-------- manual_trading_hub/static/app.js | 10060 +++++------ manual_trading_hub/static/chart.js | 6772 +++---- .../static/icons/manifest.webmanifest | 46 +- manual_trading_hub/static/index.html | 2242 +-- manual_trading_hub/云服务器部署说明.md | 582 +- manual_trading_hub/交易监管说明.md | 168 +- manual_trading_hub/使用说明.md | 1040 +- manual_trading_hub/局域网与反代部署说明.md | 454 +- manual_trading_hub/常见问题.md | 708 +- manual_trading_hub/开仓计划说明.md | 2 +- manual_trading_hub/数据看板说明.md | 100 +- manual_trading_hub/本地数据迁移到云端.md | 536 +- manual_trading_hub/行情区说明.md | 260 +- manual_trading_hub/资金概况说明.md | 6 +- manual_trading_hub/部署文档.md | 38 +- requirements.txt | 2 +- scripts/apply_time_close_patches.py | 3 +- scripts/backfill_trend_strategy_snapshots.py | 6 +- scripts/backfill_trend_trade_records.py | 6 +- scripts/dedupe_strategy_snapshots.py | 2 +- .../one_shot_backup_config_before_cleanup.py | 81 + scripts/patch_instance_theme_templates.py | 2 +- scripts/patch_position_sizing_to_exchanges.py | 3 +- scripts/sync_brand_icons.py | 1 - scripts/sync_four_exchange_env.py | 120 +- .../sync_four_exchange_position_sizing_env.py | 359 +- scripts/sync_four_exchange_transfer_env.py | 425 +- tests/test_ai_review_lib.py | 2 +- tests/test_hub_agent_entry_price.py | 64 +- tests/test_hub_agent_mark_price.py | 2 +- tests/test_hub_fund_history_lib.py | 2 +- tests/test_hub_symbol_archive_lib.py | 6 +- tests/test_hub_trades_archive_merge.py | 2 +- tests/test_trend_dca_enrich_fills.py | 2 +- tests/test_trend_finalize_trade_record.py | 10 +- tests/test_trend_hub_enrich_unified.py | 2 +- 关键位止盈止损与移动保本更新说明.md | 2 +- 备份与恢复.md | 535 +- 策略交易说明.md | 18 +- 138 files changed, 26395 insertions(+), 40057 deletions(-) delete mode 100644 crypto_monitor_gate_bot/.env.example delete mode 100644 crypto_monitor_gate_bot/README.md delete mode 100644 crypto_monitor_gate_bot/app.py delete mode 100644 crypto_monitor_gate_bot/ecosystem.config.cjs delete mode 100644 crypto_monitor_gate_bot/scripts/backup_data.sh delete mode 100644 crypto_monitor_gate_bot/scripts/backup_db_now.py delete mode 100644 crypto_monitor_gate_bot/scripts/fix_breakeven_labels.py delete mode 100644 crypto_monitor_gate_bot/scripts/install_backup_cron.sh delete mode 100644 crypto_monitor_gate_bot/scripts/verify_gate_funding.py delete mode 100644 crypto_monitor_gate_bot/static/icons/apple-touch-icon.png delete mode 100644 crypto_monitor_gate_bot/static/icons/favicon.ico delete mode 100644 crypto_monitor_gate_bot/static/icons/icon-16.png delete mode 100644 crypto_monitor_gate_bot/static/icons/icon-192.png delete mode 100644 crypto_monitor_gate_bot/static/icons/icon-32.png delete mode 100644 crypto_monitor_gate_bot/static/icons/icon-512.png delete mode 100644 crypto_monitor_gate_bot/static/icons/icon.svg delete mode 100644 crypto_monitor_gate_bot/static/icons/manifest.webmanifest delete mode 100644 crypto_monitor_gate_bot/templates/index.html delete mode 100644 crypto_monitor_gate_bot/templates/key_focus.html delete mode 100644 crypto_monitor_gate_bot/templates/login.html delete mode 100644 crypto_monitor_gate_bot/templates/order_focus.html delete mode 100644 crypto_monitor_gate_bot/使用说明.md delete mode 100644 crypto_monitor_gate_bot/关键位自动下单说明.md delete mode 100644 crypto_monitor_gate_bot/更新文档.md delete mode 100644 crypto_monitor_gate_bot/部署文档.md rename crypto_monitor_gate_bot/趋势回调策略说明.md => docs/trend-pullback-strategy.md (93%) create mode 100644 scripts/one_shot_backup_config_before_cleanup.py diff --git a/.gitignore b/.gitignore index 9a4cbc9..6b82791 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ manual_trading_hub/hub_ai_summaries.json manual_trading_hub/hub_ai_chat.json manual_trading_hub/hub_ai_fund_history.json manual_trading_hub/data/ +backups/ # 数据库与上传(运行时生成) **/*.sqlite diff --git a/AI复盘与模型配置说明.md b/AI复盘与模型配置说明.md index 1e45896..d4dde0f 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 1bc7685..81f97a1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 复盘交易系统(crypto_monitor) -多交易所 **USDT 永续** 的下单监控、**关键位**、**策略交易**、**止盈止损 / 移动保本** 与 **AI 复盘**,四所独立部署 + 可选 **中控** 聚合监控。 +多交易所 **USDT 永续** 的下单监控、**关键位**、**策略交易**、**止盈止损 / 移动保本** 与 **AI 复盘**,三所独立部署 + 可选 **中控** 聚合监控。 **远程仓库**:[https://git.bz121.com/dekun/crypto_monitor.git](https://git.bz121.com/dekun/crypto_monitor.git) @@ -35,7 +35,7 @@ bash deploy/setup_env.sh --install-system-deps |------|------|------| | **关键位监控** | 箱体/收敛自动开仓、阻力支撑提醒、斐波限价;止盈止损方案与 **移动保本** 开关 | 各所 [关键位自动下单说明.md](./crypto_monitor_binance/关键位自动下单说明.md)(Gate/OKX 目录内同名);方案细则 **[关键位止盈止损与移动保本更新说明.md](./关键位止盈止损与移动保本更新说明.md)** | | **实盘下单 / 下单监控** | 首仓、以损定仓;监控内 **止盈 / 止损**、**移动保本**(步进 R、偏移%) | 各所 [使用说明.md](./crypto_monitor_binance/使用说明.md) · 顶栏「实盘下单」`/trade` | -| **策略交易** | **趋势回调** + **顺势加仓**(`/strategy` 双栏) | **[策略交易说明.md](./策略交易说明.md)** · 趋势细则 [crypto_monitor_gate_bot/趋势回调策略说明.md](./crypto_monitor_gate_bot/趋势回调策略说明.md) | +| **策略交易** | **趋势回调** + **顺势加仓**(`/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) | @@ -49,8 +49,7 @@ bash deploy/setup_env.sh --install-system-deps | 目录 | 交易所 / 角色 | 部署文档 | |------|----------------|----------| | `crypto_monitor_binance/` | Binance U 本位永续 | [部署文档.md](./crypto_monitor_binance/部署文档.md) | -| `crypto_monitor_gate/` | Gate 主号 | [部署文档.md](./crypto_monitor_gate/部署文档.md) | -| `crypto_monitor_gate_bot/` | Gate 机器人 / 趋势户 | [部署文档.md](./crypto_monitor_gate_bot/部署文档.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)** | @@ -64,7 +63,7 @@ bash deploy/setup_env.sh --install-system-deps ## 技术要点 - **Python 3.10+**、Flask、ccxt、SQLite(`crypto.db`) -- 四所 `.env` 前缀不同(`BINANCE_*` / `GATE_*` / `OKX_*`),**不可混用** +- 三所 `.env` 前缀不同(`BINANCE_*` / `GATE_*` / `OKX_*`),**不可混用** - 实盘须 `LIVE_TRADING_ENABLED=true` 且理解 API 权限与 IP 白名单风险 - 经 **SOCKS** 访问交易所时配置各所 `*_SOCKS_PROXY` 并安装 PySocks @@ -72,7 +71,7 @@ bash deploy/setup_env.sh --install-system-deps ## 推荐阅读顺序 -1. [docs/ubuntu-server.md](./docs/ubuntu-server.md) — 装 Python / Node / PM2,PM2 启动四所 + 中控 +1. [docs/ubuntu-server.md](./docs/ubuntu-server.md) — 装 Python / Node / PM2,PM2 启动三所 + 中控 2. 各所 **`.env`**(从 `.env.example` 复制) 3. 所用功能对应上表 **功能导航** 文档 4. [备份与恢复.md](./备份与恢复.md) — 生产机备份习惯 diff --git a/brand/manifest.webmanifest b/brand/manifest.webmanifest index a55c73f..f63dbd9 100644 --- a/brand/manifest.webmanifest +++ b/brand/manifest.webmanifest @@ -1,23 +1,23 @@ -{ - "name": "复盘系统中控", - "short_name": "中控", - "description": "四所交易监控与行情中控", - "start_url": "/monitor", - "display": "standalone", - "background_color": "#0b0e18", - "theme_color": "#0b0e18", - "icons": [ - { - "src": "__ICON_PREFIX__/icon-192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "any" - }, - { - "src": "__ICON_PREFIX__/icon-512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "any maskable" - } - ] -} +{ + "name": "复盘系统中控", + "short_name": "中控", + "description": "三所交易监控与行情中控", + "start_url": "/monitor", + "display": "standalone", + "background_color": "#0b0e18", + "theme_color": "#0b0e18", + "icons": [ + { + "src": "__ICON_PREFIX__/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "__ICON_PREFIX__/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/crypto_monitor_binance/更新文档.md b/crypto_monitor_binance/更新文档.md index 300a93f..02e63cc 100644 --- a/crypto_monitor_binance/更新文档.md +++ b/crypto_monitor_binance/更新文档.md @@ -140,7 +140,7 @@ ## 升级步骤 1. `git pull` 后对比 `.env.example`,把新增变量合并进本地 `.env`。 -2. 在 VPS 上为 Binance / Gate / Gate Bot **各执行一次** `bash scripts/install_backup_cron.sh`(若尚未安装)。 +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 切日」说明。 diff --git a/crypto_monitor_binance/部署文档.md b/crypto_monitor_binance/部署文档.md index d18e3fe..894a30e 100644 --- a/crypto_monitor_binance/部署文档.md +++ b/crypto_monitor_binance/部署文档.md @@ -149,7 +149,7 @@ cp .env .env.backup.$(date +%Y%m%d) ### 5.3 AI 复盘与模型(可选) -四所共用仓库根目录 **`ai_client.py`**(PM2 的 **`PYTHONPATH=..`** 须包含仓库根)。在 `.env` 中配置 **`AI_PROVIDER`**: +三所共用仓库根目录 **`ai_client.py`**(PM2 的 **`PYTHONPATH=..`** 须包含仓库根)。在 `.env` 中配置 **`AI_PROVIDER`**: | 模式 | 主要变量 | |------|----------| @@ -191,10 +191,10 @@ cd /opt/crypto_monitor/crypto_monitor_gate bash scripts/install_backup_cron.sh ``` -Gate Bot 实例(趋势回调等): +实例(趋势回调等): ```bash -cd /opt/crypto_monitor/crypto_monitor_gate_bot +cd /opt/crypto_monitor/crypto_monitor_gate bash scripts/install_backup_cron.sh ``` diff --git a/crypto_monitor_gate/更新文档.md b/crypto_monitor_gate/更新文档.md index 880a159..297c5ef 100644 --- a/crypto_monitor_gate/更新文档.md +++ b/crypto_monitor_gate/更新文档.md @@ -141,7 +141,7 @@ ## 升级步骤 1. `git pull` 后对比 `.env.example`,把新增变量合并进本地 `.env`。 -2. 在 VPS 上为 Binance / Gate / Gate Bot **各执行一次** `bash scripts/install_backup_cron.sh`(若尚未安装)。 +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 切日」说明。 diff --git a/crypto_monitor_gate/部署文档.md b/crypto_monitor_gate/部署文档.md index 20210a1..5cfe907 100644 --- a/crypto_monitor_gate/部署文档.md +++ b/crypto_monitor_gate/部署文档.md @@ -157,7 +157,7 @@ bash scripts/backup_data.sh # 试跑 备份目录:`/root/backups/crypto_monitor_gate/YYYY-MM-DD/`。详见 Binance 项目 `部署文档.md` 第 5.4 节(恢复步骤、可选 `.env` 变量相同)。 -若还部署了 **`crypto_monitor_gate_bot`**,请在该目录同样执行 `bash scripts/install_backup_cron.sh`。 +若还部署了 **`crypto_monitor_okx`**,请在该目录同样执行 `bash scripts/install_backup_cron.sh`。 ### 5.5 必填项检查(Gate + 代理) diff --git a/crypto_monitor_gate_bot/.env.example b/crypto_monitor_gate_bot/.env.example deleted file mode 100644 index 6c0170c..0000000 --- a/crypto_monitor_gate_bot/.env.example +++ /dev/null @@ -1,210 +0,0 @@ -# ============================================================================= -# 环境配置模板(可提交 Git)。程序运行时只读取同目录下的 .env。 -# -# 首次部署 / 新机: -# cp .env.example .env -# nano .env # 填入真实密钥、端口、代理等 -# -# 升级代码(git pull)前建议备份(.env 不在 Git 中,pull 不会覆盖): -# cp .env .env.backup.$(date +%Y%m%d) -# -# 从备份恢复: -# cp .env.backup.YYYYMMDD .env -# ============================================================================= - -APP_ENV=production -# 服务监听地址(云服务器通常用 0.0.0.0) -APP_HOST=0.0.0.0 -# 服务端口 -APP_PORT=5002 -# 是否开启调试模式(生产建议 false) -APP_DEBUG=false - -# 登录账号 -APP_USERNAME=admin -# 登录密码(请改成你自己的强密码) -APP_PASSWORD=admin123 -# 是否关闭登录校验(局域网可设 true;公网务必 false) -APP_AUTH_DISABLED=true -# --- 多账户交易中控 manual_trading_hub --- -# 中控请求本实例 /api/hub/* 时携带请求头 X-Hub-Token,须与中控启动环境变量 HUB_BRIDGE_TOKEN 一致 -# 未设置且 APP_AUTH_DISABLED=false 时,仅网页登录后可访问;本机联调可保持 APP_AUTH_DISABLED=true -# HUB_BRIDGE_TOKEN=your-long-random-token -# Flask 会话密钥(必须替换为长随机字符串) -FLASK_SECRET_KEY=CHANGE_TO_LONG_RANDOM_SECRET - -# 企业微信机器人 Webhook(用于行情/风控推送) -WECHAT_WEBHOOK=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=REPLACE_WITH_REAL_KEY - -# 数据库文件路径(相对路径会自动按项目目录解析) -DB_PATH=crypto.db -# 交易截图上传目录 -UPLOAD_DIR=static/images - -# 自动备份(scripts/backup_data.sh + cron,可选;默认即可) -# BACKUP_ROOT=/root/backups -# BACKUP_RETENTION_DAYS=30 -# BACKUP_INSTANCE=crypto_monitor_gate_bot - -# 已废弃:资金账户仅显示交易所 funding 余额,不再读取此变量 -# TOTAL_CAPITAL=100 -# 计仓:risk=以损定仓(默认);full_margin=合约可用×FULL_MARGIN_BUFFER_RATIO 全仓杠杆(须无仓后重启) -POSITION_SIZING_MODE=risk -# 每天起始基数(U) -DAILY_START_CAPITAL=30 -# 日内回撤后基数(U) -DAILY_LOSS_CAPITAL=20 -# 日内盈利后基数(U) -DAILY_PROFIT_CAPITAL=50 -# BTC 默认杠杆倍数 -BTC_LEVERAGE=10 -# 山寨币默认杠杆倍数 -ALT_LEVERAGE=5 -# 交易日重置小时(北京时间) -TRADING_DAY_RESET_HOUR=8 -# 整点前禁止新开仓:true=启用(默认),false=关闭(仍可保留 8 点作为交易日划分) -TRADING_DAY_RESET_OPEN_GUARD_ENABLED=true - -# 是否开启 Gate 实盘下单(false=只做本地流程,true=真实下单) -LIVE_TRADING_ENABLED=true -# Gate API Key(实盘) -GATE_API_KEY=REPLACE_WITH_GATE_API_KEY -# Gate API Secret(实盘) -GATE_API_SECRET=REPLACE_WITH_GATE_API_SECRET -# 保证金模式:cross=全仓,isolated=逐仓 -GATE_TD_MODE=cross -# 持仓筛选:hedge=双向持仓下按多空腿过滤;其它值(如 single)不按腿过滤 -GATE_POS_MODE=hedge -# 永续止盈止损:是否优先用官方仓位类触发单(POST price_orders,close-*-position);false=仅用旧版两张 ccxt 条件单 -GATE_TPSL_USE_POSITION_ORDER=true -# 触发单超时(秒),默认 604800=7 天;设为 0 或负数则不向 API 传 expiration -GATE_TPSL_TRIGGER_EXPIRATION=604800 -# 触发参考价:0=最新成交 1=标记价 2=指数价(非法值按 0) -GATE_TPSL_PRICE_TYPE=0 -# 仓位类 TP/SL 相对现价的最小间距(%),避免 Gate 1026「触发价须高于/低于现价」 -GATE_TPSL_LAST_PRICE_GAP_PCT=0.05 -# 页面与浏览器标签展示的交易所名称(多环境区分时可改成例如 Gate·模拟) -# EXCHANGE_DISPLAY_NAME=Gate.io - -# ============================================================================= -# 关键位门控(页面「关键位监控」规则条与 _key_hard_checks 共用) -# ============================================================================= -# 【周期】门控 K 线周期,如 5m、15m -KLINE_TIMEFRAME=5m -# 【确认K】闭合 K 序列中的棒偏移:突破棒默认 -2,确认棒默认 -1 -KEY_CONFIRM_BREAKOUT_BAR=-2 -KEY_CONFIRM_BAR=-1 -# 【量能】突破棒成交量 > 前 N 根均量 × 倍数 -KEY_VOLUME_MA_BARS=20 -KEY_VOLUME_RATIO_MIN=1.3 -# 【突破K实体幅度】占开盘价百分比区间 -# 【箱体/收敛】突破K收盘越过关键位下限%;无上限(过猛由计划RR过滤) -KEY_BREAKOUT_AMP_MIN_PCT=0.03 -KEY_BREAKOUT_AMP_MAX_PCT=0.5 -# 【阻力/支撑】突破后微信提醒 -KEY_ALERT_MAX_TIMES=3 -KEY_ALERT_INTERVAL_MINUTES=5 -# 【日成交量排名】品种须在该排名前 N 名 -KEY_DAILY_VOLUME_RANK_MAX=30 -# 【关键位自动开仓盈亏比】严格大于该值才市价开仓 -KEY_AUTO_MIN_PLANNED_RR=1.5 -# 止损:突破 K 极值向外缓冲的百分比(默认 0.5 即 0.5%) -KEY_STOP_OUTSIDE_BREAKOUT_PCT=0.5 -# 趋势单方案:止损在突破 K 极值外侧的百分比(默认 1 即 1%) -KEY_TREND_STOP_OUTSIDE_PCT=1 -KEY_ALERT_MAX_TIMES=3 -KEY_ALERT_INTERVAL_MINUTES=5 - -# ============================================================================= -# 交易执行 / 人工风控(页面「实盘下单」) -# ============================================================================= -# 【最大同时持仓】默认 1=单仓 -MAX_ACTIVE_POSITIONS=1 -# 【人工下单最低盈亏比】低于该值前后端均拒绝(默认 1.4,即须 >=1.4:1) -MANUAL_MIN_PLANNED_RR=1.4 -# 【关键位连开计仓】已有持仓时按无仓时资金快照算基数 -KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT=true -# 【单日开仓 AI 提醒】本交易日开仓达到该次数时推送企业微信 AI 克制提醒(不拦单) -DAILY_OPEN_ALERT_THRESHOLD=5 -# 【单日开仓硬上限】本交易日开仓次数>=该值后禁止一切新开仓直至下一交易日(北京时间 TRADING_DAY_RESET_HOUR 切日);0=不启用 -DAILY_OPEN_HARD_LIMIT=0 - -# ============================================================================= -# 账户冷静期 / 日冻结风控(手动平仓、外部平仓、复盘情绪标签) -# 详见 docs/account-risk-cooldown.md -# ============================================================================= -# RISK_CONTROL_ENABLED=true -# RISK_COOLING_HOURS_MANUAL=4 -# RISK_COOLING_HOURS_MANUAL_JOURNAL=1 -# RISK_MANUAL_CLOSE_DAILY_LIMIT=2 -# RISK_MOOD_ISSUES_DAILY_FREEZE=true - -# 资金与仓位刷新周期(秒) -BALANCE_REFRESH_SECONDS=60 -# 前端价格快照轮询(秒) -PRICE_REFRESH_SECONDS=5 -# 后台监控轮询周期(秒) -MONITOR_POLL_SECONDS=3 -# 重启后多少秒内不做「外部平仓」同步(避免 API 未就绪误判) -RECONCILE_STARTUP_GRACE_SEC=90 -# 连续多少次轮询确认交易所空仓后,才记为外部平仓(默认 3 次 ≈ 9 秒) -RECONCILE_FLAT_CONFIRM_POLLS=3 -# 使用可用资金时的缓冲比例(如0.98代表用98%) -FULL_MARGIN_BUFFER_RATIO=0.98 - -# ============================================================================= -# 自动划转(页顶「将 swap 补足到 XU」;与 DAILY_START_CAPITAL 独立,需一致时请设为相同值) -# ============================================================================= -AUTO_TRANSFER_ENABLED=false -# 交易账户(swap)目标余额 U:每日 8 点(北京)自动划入或划出至 funding;持仓中不划转 -AUTO_TRANSFER_AMOUNT=30 -AUTO_TRANSFER_FROM=funding -AUTO_TRANSFER_TO=swap -TRANSFER_CCY=USDT -# 北京时间该整点小时内尝试;账簿按 UTC 自然日去重 -AUTO_TRANSFER_BJ_HOUR=8 -# 强制清仓整点(北京时间,默认 0=凌晨00点) -FORCE_CLOSE_BJ_HOUR=0 -# 是否启用强制清仓(默认关闭,true 才会在整点执行) -FORCE_CLOSE_ENABLED=false - -# 推送与AI超时(秒) -WECHAT_TIMEOUT_SECONDS=10 -AI_TIMEOUT_SECONDS=120 - -# AI 提供方:openai(默认)| ollama -AI_PROVIDER=openai -OPENAI_API_BASE=https://op.bz121.com/v1 -OPENAI_API_KEY=你的密钥 -OPENAI_MODEL=gemma4:e4b -OLLAMA_API=http://127.0.0.1:11434/api/generate -AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest - -# Gate 代理(可选):本机网络不稳定时通过 SSH 动态转发 SOCKS5 出口 -# 1) 先在本机建立隧道(示例): -# ssh -N -D 127.0.0.1:1080 root@你的VPS_IP -o ServerAliveInterval=30 -o ExitOnForwardFailure=yes -# 2) 再启用下面这一行(推荐 socks5h,让远端解析域名): -# GATE_SOCKS_PROXY=socks5h://127.0.0.1:1080 -# -# 如你更偏向 HTTP 代理(VPS 上跑 tinyproxy 之类),可用: -# GATE_HTTP_PROXY=http://127.0.0.1:3128 -# GATE_HTTPS_PROXY=http://127.0.0.1:3128 - -# 开仓多周期K线图(可选) -# ORDER_CHART_ENABLED=true -# ORDER_CHART_TFS=4h,1h,15m,5m -# ORDER_CHART_LIMIT=100 -# ORDER_CHART_DIR=static/images/order_charts -# 详见 DAILY_OPEN_ALERT_THRESHOLD / DAILY_OPEN_HARD_LIMIT;说明文档 docs/daily-open-limit.md -# 以损定仓(按交易账户资金的百分比) -# RISK_PERCENT=2 -# 移动保本触发(达到多少R触发)与偏移(百分比) -# BREAKEVEN_RR_TRIGGER=1.0 -# 移动保本阶梯(每多少R继续上移一次,默认1R) -# BREAKEVEN_STEP_R=1.0 -# BREAKEVEN_OFFSET_PCT=0.02 -# 开单风格默认值:trend / swing -# DEFAULT_TRADE_STYLE=trend - -APP_TIMEZONE=Asia/Shanghai -# TRADING_DAY_RESET_HOUR 现在表示「北京时间」整点,默认 8 点起算新交易日;开仓整点限制见 TRADING_DAY_RESET_OPEN_GUARD_ENABLED diff --git a/crypto_monitor_gate_bot/README.md b/crypto_monitor_gate_bot/README.md deleted file mode 100644 index f87d643..0000000 --- a/crypto_monitor_gate_bot/README.md +++ /dev/null @@ -1,90 +0,0 @@ -# crypto_monitor_gate - -基于 **Flask** 的加密货币 **下单监控 / 关键位监控 / 交易复盘** 小系统,行情与实盘接口统一走 **Gate.io USDT 永续**,通过 **ccxt** 访问。 - -## 文档导航 - -| 文档 | 说明 | -|------|------| -| **[使用说明.md](./使用说明.md)** | 日常怎么用:登录、关键位四类、手工开仓、单仓与微信等 | -| **[关键位自动下单说明.md](./关键位自动下单说明.md)** | 关键位自动开仓的 RR、止盈止损、结案原因与 `.env` | -| **[部署文档.md](./部署文档.md)** | Ubuntu、PM2、**SSH SOCKS** 访问 Gate API 等 | - -另:**Binance U 本位** 对等实现见同级的 **`crypto_monitor_binance`** 仓库。 - ---- - -## 功能概要 - -- **关键位监控**:5m 收线硬条件、企业微信推送;**箱体 / 收敛** 在 RR 达标时可 **自动市价开仓**(见专门文档);**阻力 / 支撑** 仅单次提醒结案 -- **下单监控**:本地风控(含移动保本)、止盈/止损触达后轮询尝试平仓并记账 -- **实盘(可选)**:`LIVE_TRADING_ENABLED=true` 且配置 **`GATE_API_KEY` / `GATE_API_SECRET`** 时,支持开仓、挂单 TP/SL、余额与划转(权限依账户而定) -- **止盈止损(Gate)**:市价成交后经 **`_gate_place_tp_sl_orders`** 挂单;优先 **仓位类 `price_orders`**(受 `GATE_TPSL_USE_POSITION_ORDER`、`GATE_TPSL_PRICE_TYPE`、`GATE_POS_MODE` 等影响) - ---- - -## 环境要求 - -- Python 3.10+(建议) -- 依赖:`flask`、`requests`、`ccxt`、`werkzeug`、`PySocks`(经 SOCKS 代理时);`Pillow`(K 线导出等可选用) - -安装示例: - -```bash -cd /opt/crypto_monitor/crypto_monitor_gate -source .venv/bin/activate -pip install -r ../requirements.txt -``` - -## 配置(`.env.example` → `.env`) - -- **`.env.example`**:模板(可提交 Git);首次:`cp .env.example .env` 后编辑。 -- **`.env`**:本机真实配置(勿提交);`git pull` 不覆盖;升级前建议备份(见《部署文档》§5.2)。 - -项目启动时加载**仓库根目录**下的 `.env`。常用项: - -| 变量 | 说明 | -|------|------| -| `GATE_API_KEY` / `GATE_API_SECRET` | Gate API(需合约与对应权限) | -| `LIVE_TRADING_ENABLED` | `true` 允许真实下单;`false` 仅本地与推送逻辑 | -| `GATE_MARGIN_MODE` / `GATE_POS_MODE` | 保证金与持仓模式 | -| `GATE_TPSL_USE_POSITION_ORDER` / `GATE_TPSL_PRICE_TYPE` 等 | 条件止盈止损行为 | -| `GATE_SOCKS_PROXY` | 可选;直连不稳时 SSH 动态转发(详见部署文档) | -| `APP_PASSWORD` / `FLASK_SECRET_KEY` | Web 登录与 Session | -| `WECHAT_WEBHOOK` | 企业微信机器人 | -| `EXCHANGE_DISPLAY_NAME` / `GATE_ACCOUNT_LABEL` | 页面与推送展示的账户文案 | - -其余见 **`.env.example` 内注释** 或 **`app.py` 顶部默认值**。 - -## 运行 - -生产使用 **PM2**(`ecosystem.config.cjs`)。调试: - -```bash -source .venv/bin/activate && python app.py -``` - -见 [docs/ubuntu-server.md](../docs/ubuntu-server.md)。 - -端口由 **`APP_PORT`** 控制(未设置默认 **5000**)。浏览器登录 **`/login`**,口令为 **`APP_PASSWORD`**。 - -## 部署(Linux / PM2 / SSH SOCKS) - -见 **[部署文档.md](./部署文档.md)**。 - -## 自检脚本 - -```bash -python scripts/verify_gate_funding.py -``` - -用于核对密钥前缀(不落 Secret)、资金/合约可读性等(需网络与权限)。 - -## 数据与脚本 - -- 默认 SQLite:由 **`DB_PATH`** 指定(常见为项目下 `crypto.db`) -- `scripts/fix_breakeven_labels.py`:修正「止损」但盈亏为正的记录标签(参见部署文档说明) - -## 风险与合规 - -实盘有亏损风险。请确认 API 权限、IP 白名单、杠杆与保证金模式与 **Gate.io** 后台一致,并遵守当地法律法规与交易所用户协议。 diff --git a/crypto_monitor_gate_bot/app.py b/crypto_monitor_gate_bot/app.py deleted file mode 100644 index 9b78732..0000000 --- a/crypto_monitor_gate_bot/app.py +++ /dev/null @@ -1,9627 +0,0 @@ -from flask import Flask, render_template, request, redirect, url_for, flash, session, jsonify, Response, send_file -import sqlite3 -import csv -from io import StringIO -import time -import threading -import requests -import os -import re -import base64 -import json -import math -from datetime import datetime, timedelta, timezone - -try: - from zoneinfo import ZoneInfo -except ImportError: - ZoneInfo = None # type: ignore -from functools import wraps -import uuid -import ccxt -from werkzeug.utils import secure_filename - -try: - from PIL import Image, ImageDraw, ImageFont -except ImportError: - Image = None # type: ignore - ImageDraw = None # type: ignore - ImageFont = None # type: ignore - -BASE_DIR = os.path.dirname(os.path.abspath(__file__)) -_REPO_ROOT = os.path.dirname(BASE_DIR) -import sys - -if _REPO_ROOT not in sys.path: - sys.path.insert(0, _REPO_ROOT) -from lib.paths import common_static_dir -from lib.ai.ai_client import ai_generate, ai_review, ai_short_advice -from lib.ai.ai_review_lib import ( - build_journal_ai_chart_path, - collect_images_for_ai_review, - journal_row_lines_for_ai, -) -from lib.common.form_submit_lib import check_duplicate_submit, submit_scope_add_key, submit_scope_add_order -from lib.key_monitor.fib_key_monitor_lib import ( - FIB_KEY_MONITOR_TYPES, - KEY_ENTRY_REASON_BY_SIGNAL, - backfill_missing_key_signal_types, - calc_fib_plan, - entry_reason_from_key_signal, - fib_invalidate_by_mark, - fib_ratio_from_type, - is_fib_key_monitor_type, - key_signal_type_for_trade_record, - stored_key_signal_type, -) -from lib.key_monitor.false_breakout_key_monitor_lib import ( - FALSE_BREAKOUT_MONITOR_TYPE, - FALSE_BREAKOUT_VALIDITY_HOURS, - calc_false_breakout_plan, - expires_at_text, - false_breakout_gate_preview, - is_false_breakout_expired, - is_false_breakout_key_monitor_type, - is_limit_key_monitor_type, - key_price_from_row, - normalize_false_breakout_symbol, - storage_bounds_from_key_price, -) -from lib.strategy.strategy_trade_labels import ( - STRATEGY_ENTRY_REASON_OPTIONS, - apply_order_monitor_source_labels, - entry_reason_for_monitor_type, - handoff_trade_miss_reason, - order_monitor_source_type, - trade_record_monitor_type as resolve_trade_record_monitor_type, - trend_plan_id_from_monitor_row, -) -from lib.instance.journal_chart_lib import ( - JOURNAL_CHART_DEFAULT_LIMIT, - JOURNAL_CHART_DEFAULT_TF1, - JOURNAL_CHART_DEFAULT_TF2, - JOURNAL_CHART_TF_CHOICES, - compose_chart_panels, - marker_points_for_timeframe, - parse_journal_chart_anchor, - parse_journal_chart_limit, - parse_journal_chart_timeframes, - JOURNAL_CHART_DEFAULT_ANCHOR, - price_levels_from_marker_payload, - render_candles_subplot, - trade_review_fetch_window, - trim_rows_for_trade_review, -) -from lib.key_monitor.key_sl_tp_lib import ( - breakeven_enabled_from_row, - normalize_sl_tp_mode, - parse_breakeven_enabled_form, - plan_key_sl_tp, - sl_tp_mode_from_row, - sl_tp_mode_label, - sl_tp_plan_summary_text, -) -from lib.trade.time_close_lib import ( - TIME_CLOSE_RESULT, - apply_time_close_to_payload, - ensure_time_close_schema, - parse_time_close_enabled_form, - parse_time_close_hours_form, - should_trigger_time_close, - time_close_insert_values, - time_close_label, - time_close_settings_from_row, -) -from lib.trade.manual_sltp_lib import ( - normalize_open_sltp_mode, - resolve_entrust_sltp_prices, - resolve_open_sltp_prices, -) -from lib.key_monitor.key_monitor_schema_lib import ensure_key_monitor_schema -from lib.key_monitor.trigger_entry_key_monitor_lib import ( - BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE, - CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE, - TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED, - TRIGGER_ENTRY_CLOSE_EXPIRED, - TRIGGER_ENTRY_CLOSE_FILLED, - TRIGGER_ENTRY_CLOSE_SL_INVALIDATE, - TRIGGER_ENTRY_CLOSE_TP_INVALIDATE, - TRIGGER_ENTRY_MONITOR_TYPE, - TRIGGER_ENTRY_MONITOR_TYPES, - TRIGGER_ENTRY_VALIDITY_HOURS, - check_trigger_entry_intent_limit, - count_pending_trigger_entries, - is_breakout_trigger_entry_key_monitor_type, - is_trigger_entry_expired, - is_trigger_entry_key_monitor_type, - trigger_entry_expires_at_text, - trigger_entry_gate_preview, - trigger_entry_invalidate, - trigger_should_fire, - validate_trigger_entry_geometry, - validate_trigger_entry_rr, -) -from lib.trade.position_sizing_lib import ( - OPEN_SOURCE_KEY_AUTO, - OPEN_SOURCE_KEY_TRIGGER, - OPEN_SOURCE_MANUAL, - assert_open_source_allowed, - compute_full_margin_sizing, - format_risk_display_text, - full_margin_requires_flat_position, - is_full_margin_mode, - leverage_for_full_margin, - load_position_sizing_mode, - mode_label_zh, - risk_percent_for_storage, -) -from lib.key_monitor.key_monitor_full_margin_lib import ( - monitor_type_disallowed_in_full_margin, - purge_disallowed_key_monitors, -) -from lib.common.auto_transfer_daily_lib import run_auto_transfer_once_per_day -from lib.key_monitor.key_monitor_lib import ( - KEY_DIRECTION_WATCH, - KEY_MONITOR_ALERT_ONLY_TYPES, - KEY_MONITOR_AUTO_TYPES, - KEY_MONITOR_RS_TYPE, - KEY_MONITOR_RS_TYPES, - auto_amp_ok, - auto_confirm_ok, - box_breakout_invalidate_by_mark, - box_breakout_invalidate_edge_label, - claim_rs_level_notify, - detect_rs_box_break, - format_auto_amp_line, - format_auto_confirm_line, - key_monitor_rule_template_context, - notify_interval_elapsed, - resolve_rs_break_for_alert, - rs_break_from_direction, - run_rs_level_alert_tick, -) -from lib.trade.order_monitor_display_lib import ( - apply_order_price_display_fields, - enrich_order_display_fields, - order_monitor_tpsl_needs_sync, -) -from lib.common.wechat_notify_lib import build_wechat_rs_level_message, send_wechat_webhook -from lib.hub.hub_auth import request_allowed as hub_request_allowed -from lib.instance.instance_nav_lib import request_is_hub_soft_nav -from lib.hub.hub_volume_rank_lib import resolve_daily_volume_rank -from lib.common.history_window_lib import ( - PRESET_CUSTOM, - PRESET_UTC_LAST24H, - PRESET_UTC_LAST7D, - PRESET_UTC_TODAY, - list_window_redirect_query, - normalize_bj_datetime_storage, - resolve_list_window, - resolve_window, - sql_list_time_field, - utc_window_to_bj_sql_strings, - utc_window_to_utc_sql_strings, -) -from lib.trade.trade_result_lib import count_winning_trades, normalize_result_with_pnl -from lib.trade.trade_exchange_stats_lib import attach_exchange_stats_to_trade, filter_position_lifecycle_fills - - -def load_env_file(path): - if not os.path.exists(path): - return - raw_bytes = open(path, "rb").read() - text = "" - for enc in ("utf-8-sig", "utf-16", "utf-16-le", "utf-16-be"): - try: - text = raw_bytes.decode(enc) - break - except Exception: - continue - if not text: - text = raw_bytes.decode("utf-8", errors="ignore") - text = text.replace("\x00", "") - for line in text.splitlines(): - raw = line.strip() - if not raw or raw.startswith("#") or "=" not in raw: - continue - key, value = raw.split("=", 1) - clean_key = key.strip().lstrip("\ufeff") - if not clean_key.replace("_", "").isalnum(): - continue - clean_value = value.strip().strip('"').strip("'") - os.environ[clean_key] = clean_value - -load_env_file(os.path.join(BASE_DIR, ".env")) - - -def resolve_path(path_value): - if os.path.isabs(path_value): - return path_value - return os.path.join(BASE_DIR, path_value) - -app = Flask(__name__) -app.secret_key = os.getenv("FLASK_SECRET_KEY", "crypto_monitor_2026_secret_key") - -# ====================== 登录配置 ====================== -USERNAME = os.getenv("APP_USERNAME", "dekun") -PASSWORD = os.getenv("APP_PASSWORD", "Woaini88@") -AUTH_DISABLED = os.getenv("APP_AUTH_DISABLED", "false").lower() in ("1", "true", "yes", "on") - -# 企业微信机器人Webhook -WECHAT_WEBHOOK = os.getenv("WECHAT_WEBHOOK", "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=replace-me") -SYSTEM_TYPE = "CRYPTO" -HOST = os.getenv("APP_HOST", "0.0.0.0") -PORT = int(os.getenv("APP_PORT", "5000")) -DEBUG = os.getenv("APP_DEBUG", "false").lower() == "true" -DB_PATH = resolve_path(os.getenv("DB_PATH", "crypto.db")) - -# 训练参数(可由 .env 覆盖) -DAILY_START_CAPITAL = float(os.getenv("DAILY_START_CAPITAL", "30")) -DAILY_LOSS_CAPITAL = float(os.getenv("DAILY_LOSS_CAPITAL", "20")) -DAILY_PROFIT_CAPITAL = float(os.getenv("DAILY_PROFIT_CAPITAL", "50")) -BTC_LEVERAGE = int(os.getenv("BTC_LEVERAGE", "10")) -ALT_LEVERAGE = int(os.getenv("ALT_LEVERAGE", "5")) -# 交易日滚动与「可开仓」整点:按应用本地时区 wall clock(默认北京时间 UTC+8) -TRADING_DAY_RESET_HOUR = int(os.getenv("TRADING_DAY_RESET_HOUR", "8")) -# false 时关闭「整点前禁止新开仓」守卫(交易日划分仍用 TRADING_DAY_RESET_HOUR) -TRADING_DAY_RESET_OPEN_GUARD_ENABLED = os.getenv( - "TRADING_DAY_RESET_OPEN_GUARD_ENABLED", "true" -).lower() in ("1", "true", "yes", "on") -APP_TIMEZONE = os.getenv("APP_TIMEZONE", "Asia/Shanghai") - - -def _resolve_app_tz(): - if ZoneInfo is not None: - try: - return ZoneInfo((APP_TIMEZONE or "Asia/Shanghai").strip()) - except Exception: - pass - return timezone(timedelta(hours=8)) - - -APP_TZ = _resolve_app_tz() -LIVE_TRADING_ENABLED = os.getenv("LIVE_TRADING_ENABLED", "false").lower() == "true" -GATE_API_KEY = (os.getenv("GATE_API_KEY") or "").strip() -GATE_API_SECRET = (os.getenv("GATE_API_SECRET") or "").strip() -GATE_TD_MODE = (os.getenv("GATE_TD_MODE") or "cross").strip().lower() -GATE_POS_MODE = (os.getenv("GATE_POS_MODE") or "hedge").strip().lower() -# 永续仓位止盈止损触发单:POST /futures/{settle}/price_orders,order_type=close-*-position(全平) -GATE_TPSL_TRIGGER_EXPIRATION = int(os.getenv("GATE_TPSL_TRIGGER_EXPIRATION", str(7 * 86400))) -GATE_TPSL_PRICE_TYPE = int(os.getenv("GATE_TPSL_PRICE_TYPE", "0")) -if GATE_TPSL_PRICE_TYPE < 0 or GATE_TPSL_PRICE_TYPE > 2: - GATE_TPSL_PRICE_TYPE = 0 -GATE_TPSL_USE_POSITION_ORDER = os.getenv("GATE_TPSL_USE_POSITION_ORDER", "true").lower() in ("1", "true", "yes") -# 仓位类触发单相对 mark/last 的最小间距(%),避免 Gate 1026 AUTO_TRIGGER_PRICE_*_LAST -GATE_TPSL_LAST_PRICE_GAP_PCT = float(os.getenv("GATE_TPSL_LAST_PRICE_GAP_PCT", "0.05")) -# 页面展示的交易所名称(多实例/多环境时可按需区分) -EXCHANGE_DISPLAY_NAME = (os.getenv("EXCHANGE_DISPLAY_NAME") or "Gate.io").strip() or "Gate.io" -_GATE_DEFAULT_MARGIN_MODE = "cross" if GATE_TD_MODE in ("cross", "cross_margin") else "isolated" -BALANCE_REFRESH_SECONDS = int(os.getenv("BALANCE_REFRESH_SECONDS", "60")) -PRICE_REFRESH_SECONDS = int(os.getenv("PRICE_REFRESH_SECONDS", "5")) -KEY_ALERT_MAX_TIMES = int(os.getenv("KEY_ALERT_MAX_TIMES", "3")) -KEY_ALERT_INTERVAL_MINUTES = int(os.getenv("KEY_ALERT_INTERVAL_MINUTES", "5")) -KEY_AUTO_MIN_PLANNED_RR = float(os.getenv("KEY_AUTO_MIN_PLANNED_RR", "1.5")) -KEY_STOP_OUTSIDE_BREAKOUT_PCT = float(os.getenv("KEY_STOP_OUTSIDE_BREAKOUT_PCT", "0.5")) -KEY_TREND_STOP_OUTSIDE_PCT = float(os.getenv("KEY_TREND_STOP_OUTSIDE_PCT", "1")) -MANUAL_MIN_PLANNED_RR = float(os.getenv("MANUAL_MIN_PLANNED_RR", "1.4")) -MAX_ACTIVE_POSITIONS = max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", "1"))) -KEY_VOLUME_MA_BARS = max(1, int(os.getenv("KEY_VOLUME_MA_BARS", "20"))) -KEY_VOLUME_RATIO_MIN = float(os.getenv("KEY_VOLUME_RATIO_MIN", "1.3")) -KEY_BREAKOUT_AMP_MIN_PCT = float(os.getenv("KEY_BREAKOUT_AMP_MIN_PCT", "0.03")) -KEY_BREAKOUT_AMP_MAX_PCT = float(os.getenv("KEY_BREAKOUT_AMP_MAX_PCT", "0.5")) -KEY_DAILY_VOLUME_RANK_MAX = max(1, int(os.getenv("KEY_DAILY_VOLUME_RANK_MAX", "30"))) -KEY_CONFIRM_BREAKOUT_BAR = int(os.getenv("KEY_CONFIRM_BREAKOUT_BAR", "-2")) -KEY_CONFIRM_BAR = int(os.getenv("KEY_CONFIRM_BAR", "-1")) -KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT = os.getenv("KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT", "true").lower() == "true" -ORDER_MONITOR_TYPE_MANUAL = "下单监控" -ORDER_MONITOR_TYPE_KEY_AUTO = "关键位监控" -EXCHANGE_POSITION_SYNC_FROM_BJ = (os.getenv("EXCHANGE_POSITION_SYNC_FROM_BJ") or "").strip() -EXCHANGE_POSITION_HISTORY_LIMIT = max(50, min(1000, int(os.getenv("EXCHANGE_POSITION_HISTORY_LIMIT", "200")))) -_LAST_EXCHANGE_PNL_SYNC_AT = 0.0 - -# KEY_MONITOR_AUTO_TYPES / KEY_MONITOR_ALERT_ONLY_TYPES:见 key_monitor_lib -AUTO_TRANSFER_ENABLED = os.getenv("AUTO_TRANSFER_ENABLED", "false").lower() == "true" -AUTO_TRANSFER_AMOUNT = float(os.getenv("AUTO_TRANSFER_AMOUNT", "30")) -AUTO_TRANSFER_FROM = os.getenv("AUTO_TRANSFER_FROM", "funding") -AUTO_TRANSFER_TO = os.getenv("AUTO_TRANSFER_TO", "swap") -FORCE_CLOSE_ENABLED = os.getenv("FORCE_CLOSE_ENABLED", "false").lower() == "true" -FORCE_CLOSE_BJ_HOUR = int(os.getenv("FORCE_CLOSE_BJ_HOUR", "0")) -# 自动划转:仅在北京时间该整点「小时」内尝试;transfer_logs.transfer_day 存 UTC 自然日便于对账 -AUTO_TRANSFER_BJ_HOUR = int(os.getenv("AUTO_TRANSFER_BJ_HOUR", "8")) -POSITION_SIZING_MODE = load_position_sizing_mode() -WECHAT_TIMEOUT_SECONDS = int(os.getenv("WECHAT_TIMEOUT_SECONDS", "10")) -AI_TIMEOUT_SECONDS = int(os.getenv("AI_TIMEOUT_SECONDS", "120")) -MONITOR_POLL_SECONDS = int(os.getenv("MONITOR_POLL_SECONDS", "3")) -RECONCILE_STARTUP_GRACE_SEC = int(os.getenv("RECONCILE_STARTUP_GRACE_SEC", "90")) -RECONCILE_FLAT_CONFIRM_POLLS = max(1, int(os.getenv("RECONCILE_FLAT_CONFIRM_POLLS", "3"))) -KLINE_TIMEFRAME = os.getenv("KLINE_TIMEFRAME", "5m") -_APP_STARTED_AT = time.time() -_RECONCILE_FLAT_STREAK = {} -FULL_MARGIN_BUFFER_RATIO = float(os.getenv("FULL_MARGIN_BUFFER_RATIO", "0.98")) -TRANSFER_CCY = os.getenv("TRANSFER_CCY", "USDT") -UPLOAD_FOLDER = resolve_path(os.getenv("UPLOAD_DIR", "static/images")) -ORDER_CHART_ENABLED = os.getenv("ORDER_CHART_ENABLED", "true").lower() == "true" -ORDER_CHART_TFS = [x.strip() for x in (os.getenv("ORDER_CHART_TFS", "4h,1h,15m,5m") or "").split(",") if x.strip()] -ORDER_CHART_LIMIT = int(os.getenv("ORDER_CHART_LIMIT", "100")) -ORDER_CHART_DIR = resolve_path(os.getenv("ORDER_CHART_DIR", "static/images/order_charts")) -from lib.trade.daily_open_limit_lib import ( - build_daily_open_alert_prompt, - can_trade_new_open, - check_daily_open_hard_limit, - count_opens_for_trading_day, - format_daily_open_counter_line, - format_daily_open_summary_short, - load_daily_open_limits_from_env, - should_send_daily_open_alert, -) - -DAILY_OPEN_ALERT_THRESHOLD, DAILY_OPEN_HARD_LIMIT = load_daily_open_limits_from_env() -RISK_PERCENT = float(os.getenv("RISK_PERCENT", "2")) -BREAKEVEN_RR_TRIGGER = float(os.getenv("BREAKEVEN_RR_TRIGGER", "1.0")) -BREAKEVEN_OFFSET_PCT = float(os.getenv("BREAKEVEN_OFFSET_PCT", "0.02")) -BREAKEVEN_STEP_R = float(os.getenv("BREAKEVEN_STEP_R", "1.0")) -DEFAULT_TRADE_STYLE = (os.getenv("DEFAULT_TRADE_STYLE", "trend") or "trend").strip().lower() - -GATE_SOCKS_PROXY = (os.getenv("GATE_SOCKS_PROXY") or "").strip() -GATE_HTTP_PROXY = (os.getenv("GATE_HTTP_PROXY") or "").strip() -GATE_HTTPS_PROXY = (os.getenv("GATE_HTTPS_PROXY") or "").strip() - - -def build_gate_ccxt_proxies(): - """ - 为 ccxt 配置代理(常用于本机网络不稳定时通过 SSH 动态转发 SOCKS5 出口)。 - - 推荐: - - 本机:ssh -N -D 127.0.0.1:1080 user@vps - - .env:GATE_SOCKS_PROXY=socks5h://127.0.0.1:1080 - - 说明: - - socks5h 让代理端解析域名(避免本机 DNS/策略差异);若你明确要本机解析可用 socks5:// - """ - socks = GATE_SOCKS_PROXY.strip() - http = GATE_HTTP_PROXY.strip() - https = GATE_HTTPS_PROXY.strip() or http - if socks: - return {"http": socks, "https": socks} - if http or https: - return {"http": http, "https": https} - return None - - -GATE_CCXT_PROXIES = build_gate_ccxt_proxies() - -os.makedirs(UPLOAD_FOLDER, exist_ok=True) -os.makedirs(ORDER_CHART_DIR, exist_ok=True) -app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER - -# Gate.io USDT 永续(swap) -exchange = ccxt.gateio({ - "enableRateLimit": True, - "options": { - "defaultType": "swap", - "defaultMarginMode": _GATE_DEFAULT_MARGIN_MODE, - }, -}) -if GATE_CCXT_PROXIES: - exchange.proxies = GATE_CCXT_PROXIES -if GATE_API_KEY and GATE_API_SECRET: - exchange.apiKey = GATE_API_KEY - exchange.secret = GATE_API_SECRET -MARKETS_LOADED = False -ACCOUNT_BALANCE_CACHE = { - "updated_at": 0.0, - "funding_usdt": None, - "trading_usdt": None -} -LIQUIDITY_RANK_CACHE = { - "updated_at": 0.0, - "version": 0, - "ranks": {}, - "total": 0, -} - -# 企业微信推送 -def send_wechat_msg(content): - send_wechat_webhook( - WECHAT_WEBHOOK, content, timeout=WECHAT_TIMEOUT_SECONDS - ) - - -_BREAKEVEN_EXCHANGE_WARNED_IDS = set() - - -def _send_breakeven_exchange_warn_once(order_id, message): - """移动保本同步交易所失败:同一笔监控单只推送一次,避免轮询刷屏。""" - oid = int(order_id) - if oid in _BREAKEVEN_EXCHANGE_WARNED_IDS: - return - _BREAKEVEN_EXCHANGE_WARNED_IDS.add(oid) - send_wechat_msg(message) - - -def _clear_breakeven_exchange_warn(order_id): - _BREAKEVEN_EXCHANGE_WARNED_IDS.discard(int(order_id)) - - -def _wechat_account_label(): - return (os.getenv("GATE_ACCOUNT_LABEL") or "gate实盘账户").strip() - - -def _wechat_direction_text(direction): - d = (direction or "").lower() - return "多头(long)" if d == "long" else "空头(short)" - - -def _wechat_trading_capital_text(fallback=None): - try: - _, trading_capital = get_exchange_capitals(force=True) - except Exception: - trading_capital = None - if trading_capital is not None: - return f"{round(float(trading_capital), 2)}U" - if fallback is not None: - try: - return f"{round(float(fallback), 2)}U" - except Exception: - pass - return "-" - - -def build_wechat_close_message( - symbol, - direction, - result, - pnl_amount, - hold_seconds=None, - trigger_price=None, - current_price=None, - stop_loss=None, - take_profit=None, - close_order_id=None, - extra_note=None, - session_capital_fallback=None, -): - hold_txt = format_hold_minutes(calc_hold_minutes(hold_seconds)) if hold_seconds is not None else "-" - ep = format_price_for_symbol(symbol, trigger_price) - cp = format_price_for_symbol(symbol, current_price) - tp = format_price_for_symbol(symbol, take_profit) - sl = format_wechat_scalar_2dp(stop_loss) - cap_txt = _wechat_trading_capital_text(session_capital_fallback) - try: - if pnl_amount is not None: - pv = float(pnl_amount) - pnl_disp = f"{'+' if pv > 0 else ''}{round(pv, 2)} U" - else: - pnl_disp = "-" - except (TypeError, ValueError): - pnl_disp = "-" - - lines = [ - f"📉 {symbol} 平仓完成", - f"💼 账户:{_wechat_account_label()}", - "", - "🧾 平仓概要", - f"🔖 平仓单号:{close_order_id or '-'}", - f"📌 方向:{_wechat_direction_text(direction)}", - f"📌 平仓结果:{result or '-'}", - f"💰 本单盈亏:{pnl_disp}", - f"⏱ 持仓时长:{hold_txt}", - f"💵 交易账户资金:{cap_txt}", - "", - "🎯 价位(计划)", - f"开仓成交价:{ep}", - f"离场参考价:{cp}", - f"止盈价位:{tp}", - f"止损价位:{sl}", - ] - if extra_note: - lines.extend(["", "📎 备注", extra_note]) - return "\n".join(lines) - - -def build_wechat_breakeven_message(symbol, direction, arm_txt, now_rr, locked_r, new_sl): - sl_fmt = format_wechat_scalar_2dp(new_sl) - return "\n".join( - [ - f"# 🛡️ {symbol} 保护位更新", - f"**账户:{_wechat_account_label()}**", - "", - "---", - "", - "### 移动保本/止盈", - f"- 方向:**{_wechat_direction_text(direction)}**", - f"- 类型:**{arm_txt}**", - f"- 当前RR:`{round(float(now_rr), 2)}R`", - f"- 锁定RR:`{round(float(locked_r), 2)}R`", - f"- 新保护位:`{sl_fmt}`", - ] - ) - - -def build_wechat_monitor_error_message(symbol, direction, scene, error_text): - return "\n".join( - [ - f"# ⚠️ {symbol} 下单监控异常", - f"**账户:{_wechat_account_label()}**", - "", - "---", - "", - "### 异常信息", - f"- 方向:**{_wechat_direction_text(direction)}**", - f"- 场景:{scene}", - f"- 错误:{str(error_text)}", - ] - ) - - -def build_wechat_key_monitor_message( - symbol, - direction, - monitor_type, - trigger_time, - key_price, - confirm_close, - hard_lines, - btc8h_status, - coin4h_status, - swing4h_pct, - op_lines, - risk_tip=None, -): - lines = [ - f"# 🎯 {symbol} 关键位确认推送", - f"**账户:{_wechat_account_label()}**", - "", - "---", - "", - "### 交易对 / 触发时间", - f"- 交易对:**{symbol}**", - f"- 触发时间:`{trigger_time}`", - "", - "### 方向与确认K", - f"- 方向:**{_wechat_direction_text(direction)}**", - "- 确认K:第二根5m收盘完成", - "", - "### 关键价位", - f"- 类型:**{monitor_type}**", - f"- 箱体关键位:`{key_price}`", - f"- 第二根确认收盘价:`{confirm_close}`", - "", - "### 硬条件校验结果", - ] - lines.extend([f"- {x}" for x in hard_lines]) - lines.extend( - [ - "", - "### 市场状态说明", - f"- BTC 8h 状态:**{btc8h_status}**", - f"- 本币 4h(EMA55) 状态:**{coin4h_status}**", - f"- 4h震荡幅度(5m近48根):`{round(float(swing4h_pct), 3)}%`", - "", - "### 操作提示", - ] - ) - lines.extend([f"- {x}" for x in op_lines]) - if risk_tip: - lines.extend(["", f"### 逆势风险提醒", f"- {risk_tip}"]) - return "\n".join(lines) - - -def _read_image_base64(image_path): - try: - with open(image_path, "rb") as f: - return base64.b64encode(f.read()).decode("utf-8") - except Exception: - return None - - -def _extract_json_object(text): - if not text: - return None - clean = text.strip() - if clean.startswith("```"): - clean = clean.replace("```json", "").replace("```", "").strip() - try: - return json.loads(clean) - except Exception: - pass - match = re.search(r"\{[\s\S]*\}", clean) - if not match: - return None - try: - return json.loads(match.group(0)) - except Exception: - return None - - -def _load_font(size): - if not ImageFont: - return None - candidates = [ - "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", - "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", - "C:\\Windows\\Fonts\\msyh.ttc", - "C:\\Windows\\Fonts\\arial.ttf", - ] - for path in candidates: - if path and os.path.exists(path): - try: - return ImageFont.truetype(path, size) - except Exception: - continue - try: - return ImageFont.load_default() - except Exception: - return None - - -def _ohlcv_to_rows(ohlcv): - rows = [] - for bar in ohlcv or []: - if not bar or len(bar) < 6: - continue - try: - rows.append( - { - "ts": int(bar[0]), - "o": float(bar[1]), - "h": float(bar[2]), - "l": float(bar[3]), - "c": float(bar[4]), - "v": float(bar[5]), - } - ) - except Exception: - continue - return rows - - -def _local_input_datetime_to_ms(dt_text): - raw = str(dt_text or "").strip() - if not raw: - return None - raw = raw.replace("T", " ") - for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"): - try: - dt = datetime.strptime(raw, fmt) - aware = dt.replace(tzinfo=APP_TZ) - return int(aware.timestamp() * 1000) - except Exception: - continue - return None - - -def _marker_tag_label(tag): - t = str(tag or "").strip().upper() - if t == "ENTRY": - return "开仓" - if t == "EXIT": - return "平仓" - return str(tag or "") - - -def _pick_marker_point(rows, target_ts_ms, target_price=None): - if not rows or target_ts_ms is None: - return None, None - idx = min(range(len(rows)), key=lambda i: abs(int(rows[i]["ts"]) - int(target_ts_ms))) - if target_price is not None: - try: - p = float(target_price) - if p > 0: - return idx, p - except Exception: - pass - return idx, float(rows[idx]["c"]) - - -def _render_candles_subplot(rows, title, width, height, bg_rgb=(255, 255, 255), marker_points=None): - if not Image or not ImageDraw: - raise RuntimeError("缺少依赖:Pillow(pip install Pillow)") - img = Image.new("RGB", (width, height), bg_rgb) - draw = ImageDraw.Draw(img) - font = _load_font(14) - small = _load_font(12) - - pad_l, pad_r, pad_t, pad_b = 46, 12, 26, 28 - plot_w = max(10, width - pad_l - pad_r) - plot_h = max(10, height - pad_t - pad_b) - - header_bg = (245, 247, 250) - draw.rectangle((0, 0, width, pad_t), fill=header_bg) - if font: - draw.text((10, 6), title, fill=(25, 35, 60), font=font) - else: - draw.text((10, 6), title, fill=(25, 35, 60)) - - if not rows: - if small: - draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120), font=small) - else: - draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120)) - return img - - lo = min(r["l"] for r in rows) - hi = max(r["h"] for r in rows) - if hi <= lo: - hi = lo + 1e-12 - - n = len(rows) - marker_by_idx = {} - for mp in marker_points or []: - try: - idx = int(mp.get("idx")) - except Exception: - continue - if idx < 0 or idx >= n: - continue - marker_by_idx.setdefault(idx, []).append(mp) - - x0 = pad_l - for i, r in enumerate(rows): - x1 = pad_l + int((i + 1) * plot_w / n) - x_mid = (x0 + x1) // 2 - wick_x = x_mid - y_high = pad_t + int((hi - r["h"]) / (hi - lo) * plot_h) - y_low = pad_t + int((hi - r["l"]) / (hi - lo) * plot_h) - y_open = pad_t + int((hi - r["o"]) / (hi - lo) * plot_h) - y_close = pad_t + int((hi - r["c"]) / (hi - lo) * plot_h) - top = min(y_open, y_close) - bot = max(y_open, y_close) - up = r["c"] >= r["o"] - wick_color = (120, 120, 120) - edge_color = (20, 20, 20) - draw.line((wick_x, y_high, wick_x, y_low), fill=wick_color) - body_w = max(1, (x1 - x0) - 2) - left = x0 + 1 - if bot - top < 2: - mid = (top + bot) // 2 - draw.rectangle((left, mid, left + body_w, mid + 1), fill=edge_color) - else: - if up: - draw.rectangle((left, top, left + body_w, bot), fill=(255, 255, 255), outline=edge_color, width=1) - else: - draw.rectangle((left, top, left + body_w, bot), fill=edge_color, outline=edge_color, width=1) - for j, mp in enumerate(marker_by_idx.get(i, [])): - tag = str(mp.get("tag") or "") - label = _marker_tag_label(tag) - m_price = float(mp.get("price") or r["c"]) - y_m = pad_t + int((hi - m_price) / (hi - lo) * plot_h) - y_m = max(pad_t + 4, min(pad_t + plot_h - 4, y_m)) - x_off = (j - (len(marker_by_idx[i]) - 1) / 2.0) * 14 - x_draw = int(x_mid + x_off) - if tag == "ENTRY": - m_color = (0, 195, 95) - tri = [(x_draw, y_m - 20), (x_draw - 9, y_m - 4), (x_draw + 9, y_m - 4)] - text_y = y_m - 36 - else: - m_color = (235, 65, 65) - tri = [(x_draw, y_m + 20), (x_draw - 9, y_m + 4), (x_draw + 9, y_m + 4)] - text_y = y_m + 12 - draw.ellipse((x_draw - 5, y_m - 5, x_draw + 5, y_m + 5), fill=m_color, outline=(255, 255, 255), width=1) - draw.polygon(tri, fill=m_color) - draw.line((x_draw, y_m, x_draw, y_m - 16 if tag == "ENTRY" else y_m + 16), fill=m_color, width=3) - if font: - draw.text((x_draw + 8, text_y), label, fill=m_color, font=font) - else: - draw.text((x_draw + 8, text_y), label, fill=m_color) - x0 = x1 - - if len(marker_points or []) >= 2: - try: - entry = next((m for m in marker_points if m.get("tag") == "ENTRY"), None) - exitp = next((m for m in marker_points if m.get("tag") == "EXIT"), None) - if entry is not None and exitp is not None: - ex_i, ex_p = int(entry["idx"]), float(entry["price"]) - xx_i, xx_p = int(exitp["idx"]), float(exitp["price"]) - x_ex = pad_l + int((ex_i + 0.5) * plot_w / n) - x_xx = pad_l + int((xx_i + 0.5) * plot_w / n) - y_ex = pad_t + int((hi - ex_p) / (hi - lo) * plot_h) - y_xx = pad_t + int((hi - xx_p) / (hi - lo) * plot_h) - draw.line((x_ex, y_ex, x_xx, y_xx), fill=(35, 135, 255), width=3) - except Exception: - pass - - # 极简风格:不画网格与坐标轴,仅保留右下角轻量区间信息 - if small: - draw.text((width - 210, height - 22), f"L={lo:.6g} H={hi:.6g}", fill=(120, 125, 135), font=small) - return img - - -def _timeframe_period_ms(tf): - s = (tf or "").strip().lower() - if s.endswith("m"): - try: - return int(s[:-1]) * 60 * 1000 - except ValueError: - pass - if s.endswith("h"): - try: - return int(s[:-1]) * 3600 * 1000 - except ValueError: - pass - if s.endswith("d"): - try: - return int(s[:-1]) * 86400 * 1000 - except ValueError: - pass - return 300000 - - -def _ohlcv_dict_rows_to_lists(rows, lim): - if not rows: - return [] - pick = rows[-lim:] if len(rows) >= lim else rows - return [[r["ts"], r["o"], r["h"], r["l"], r["c"], r.get("v", 0)] for r in pick] - - -def _fetch_ohlcv_ending_at(exchange_symbol, timeframe, limit, end_ts_ms): - """以 end_ts_ms 为终点向前取 K 线(无 end 则拉最近 limit 根)。""" - lim = max(2, int(limit or ORDER_CHART_LIMIT)) - try: - if not end_ts_ms: - ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=lim) - else: - period = _timeframe_period_ms(timeframe) - since = int(end_ts_ms) - period * (lim + 10) - ohlcv = exchange.fetch_ohlcv( - exchange_symbol, timeframe=timeframe, since=max(0, since), limit=lim + 20 - ) - except Exception: - return [] - rows = _ohlcv_to_rows(ohlcv) - if not rows: - return [] - if not end_ts_ms: - return _ohlcv_dict_rows_to_lists(rows, lim) - filtered = [r for r in rows if int(r["ts"]) <= int(end_ts_ms)] - if len(filtered) >= 2: - return _ohlcv_dict_rows_to_lists(filtered, lim) - return _ohlcv_dict_rows_to_lists(rows, lim) - - -def generate_multi_timeframe_chart_png( - exchange_symbol, - title_prefix, - timeframes=None, - limit=None, - out_dir=None, - filename=None, - filename_prefix="chart", - marker_payload=None, - marker_timeframes=None, - layout="grid", -): - if not ORDER_CHART_ENABLED: - return None - if not Image: - return None - requested = list(timeframes or ORDER_CHART_TFS) - limit = limit or ORDER_CHART_LIMIT - if layout == "vertical": - timeframes = requested[:2] if requested else [JOURNAL_CHART_DEFAULT_TF1, JOURNAL_CHART_DEFAULT_TF2] - else: - preferred_layout = ["5m", "15m", "1h", "4h"] - requested_set = set(requested or []) - ordered = [tf for tf in preferred_layout if tf in requested_set] - for tf in requested: - if tf not in ordered: - ordered.append(tf) - timeframes = ordered[:4] if ordered else preferred_layout - - ensure_markets_loaded() - panels = [] - cell_w, cell_h = 980, 520 - end_ts_ms = None - if marker_payload: - try: - end_ts_ms = int(marker_payload.get("exit_ts_ms") or marker_payload.get("entry_ts_ms") or 0) or None - except (TypeError, ValueError): - end_ts_ms = None - default_marker_tfs = {str(t).strip().lower() for t in timeframes} - price_levels = price_levels_from_marker_payload(marker_payload) - for tf in timeframes: - rows = [] - try: - if layout == "vertical" and marker_payload: - win = trade_review_fetch_window( - marker_payload.get("entry_ts_ms"), - marker_payload.get("exit_ts_ms"), - tf, - limit, - anchor=marker_payload.get("chart_anchor"), - now_ms=marker_payload.get("now_ts_ms"), - ) - if win: - ohlcv = exchange.fetch_ohlcv( - exchange_symbol, - timeframe=tf, - since=max(0, int(win["since_ms"])), - limit=int(win["fetch_limit"]), - ) - rows = trim_rows_for_trade_review(_ohlcv_to_rows(ohlcv), win) - if not rows: - ohlcv = _fetch_ohlcv_ending_at(exchange_symbol, tf, limit, end_ts_ms) - if not ohlcv and end_ts_ms: - ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=tf, limit=limit) - rows = _ohlcv_to_rows(ohlcv)[-limit:] - except Exception: - rows = [] - title = f"{title_prefix} | {tf} x{len(rows)}" - tf_key = str(tf).strip().lower() - if marker_payload: - if marker_timeframes: - marker_tfs = {str(x).strip().lower() for x in marker_timeframes if str(x).strip()} - else: - marker_tfs = default_marker_tfs - else: - marker_tfs = set() - points = ( - marker_points_for_timeframe(rows, marker_payload) - if marker_payload and tf_key in marker_tfs - else [] - ) - panels.append( - render_candles_subplot( - rows, - title, - width=cell_w, - height=cell_h, - bg_rgb=(255, 255, 255), - marker_points=points, - price_levels=price_levels, - ) - ) - - if not panels: - return None - - out = compose_chart_panels(panels, layout=layout, cell_w=cell_w, cell_h=cell_h, gap=10) - if out is None: - return None - - target_dir = out_dir or ORDER_CHART_DIR - os.makedirs(target_dir, exist_ok=True) - fname = filename or f"{filename_prefix}_{uuid.uuid4().hex}.png" - out_path = os.path.join(target_dir, fname) - out.save(out_path, format="PNG") - return fname - - -def generate_order_open_chart( - exchange_symbol, - title_prefix, - timeframes=None, - limit=None, - opened_at_ms=None, - entry_price=None, -): - marker_payload = None - if opened_at_ms: - marker_payload = { - "entry_ts_ms": opened_at_ms, - "exit_ts_ms": None, - "entry_price": entry_price, - "exit_price": None, - } - marker_tfs = ( - {x.strip().lower() for x in (timeframes or ORDER_CHART_TFS) if x and str(x).strip()} - or {"5m", "15m", "1h", "4h"} - ) - return generate_multi_timeframe_chart_png( - exchange_symbol, - title_prefix, - timeframes=timeframes, - limit=limit, - out_dir=ORDER_CHART_DIR, - filename=None, - filename_prefix="order", - marker_payload=marker_payload, - marker_timeframes=marker_tfs, - ) - - -def journal_coin_from_symbol(symbol): - sym = (symbol or "").strip().upper() - if not sym: - return "" - if "/" in sym: - return sym.split("/")[0].strip() - if "-" in sym: - return sym.split("-")[0].strip() - if sym.endswith("USDT"): - return sym[:-4].strip() - return sym - - -EARLY_EXIT_TRIGGERS = ( - "", - "止盈", - "保本止盈", - "移动止盈", - TIME_CLOSE_RESULT, - "手动平仓", - "止损", - "其他", -) - -# 与用户约定的固定开仓类型 -ENTRY_REASON_OPTIONS = ( - "趋势多头:4h大结构突破前进场,确认条件:三次探顶,5m收敛不创新低", - "趋势空头:4h大结构突破前进场,确认条件:三次探底,5m收敛不创新高", - "趋势多头:小分歧低吸入场(左侧),确认条件:二次探底", - "趋势空头:小分歧高吸入场(左侧),确认条件:二次探顶", - "波段单:5m顺势突破,确认条件:2根k线+成交量放大+4h同向+日成交量前20", - "关键位箱体突破", - "关键位收敛突破", - "关键位斐波0.618", - "关键位斐波0.786", - "关键位假突破", - "关键位回调触价开仓", - "关键位突破触价开仓", -) + STRATEGY_ENTRY_REASON_OPTIONS - -STATS_SEGMENT_DEFS = ( - ("all", "全部交易", {"segment": "all"}), - ("manual", "下单监控", {"segment": "manual"}), - ("key_box", "关键位箱体突破", {"segment": "key_box"}), - ("key_conv", "关键位收敛结构", {"segment": "key_conv"}), - ("key_fib618", "关键位斐波0.618", {"segment": "key_fib618"}), - ("key_fib786", "关键位斐波0.786", {"segment": "key_fib786"}), - ("key_false_breakout", "关键位假突破", {"segment": "key_false_breakout"}), - ("key_trigger", "关键位触价开仓", {"segment": "key_trigger"}), -) -# 复盘表单「其他」选项的 value(非入库值;自定义文本走 entry_reason_custom) -ENTRY_REASON_OTHER = "__OTHER__" - - -def normalize_entry_reason(raw, custom_text=None): - v = str(raw or "").strip() - if v == ENTRY_REASON_OTHER: - c = str(custom_text or "").strip() - return c[:2000] if c else "" - return v if v in ENTRY_REASON_OPTIONS else "" - - -def entry_reason_valid_for_storage(s): - """允许五种固定整句、或自定义短文本(不含未解析的 __OTHER__ 占位)。""" - t = str(s or "").strip() - if not t: - return True - if t == ENTRY_REASON_OTHER: - return False - if t in ENTRY_REASON_OPTIONS: - return True - return 1 <= len(t) <= 2000 - - -def normalize_early_exit_trigger(raw): - v = str(raw or "").strip() - return v if v in EARLY_EXIT_TRIGGERS else "" - - -def compose_early_exit_reason_saved(trigger, note): - """Readable single-line string stored in early_exit_reason for legacy consumers.""" - t = normalize_early_exit_trigger(trigger) - n = str(note or "").strip() - if t and n: - return f"{t}|{n}" - return t or n - - -def journal_exit_reason_stored(trigger, note): - """exit_reason 列与表单「一处」对齐:非手工=触发类型;手工=离场说明全文。""" - t = normalize_early_exit_trigger(trigger) - n = str(note or "").strip() - if t == "手动平仓": - return n - return t - - -def ai_extract_journal_from_image(image_b64): - prompt = """ -你是交易复盘信息提取助手。请从截图中提取可识别字段,并只输出 JSON(不要 markdown,不要解释)。 -要求: -1) 仅输出一个 JSON 对象。 -2) 时间输出为 YYYY-MM-DDTHH:MM(用于 HTML datetime-local),无法识别填空字符串。 -3) 不要猜测主观原因;early_exit_note(仅手工平仓)、note 默认留空,除非图中明确写出。 -4) 允许字段为空。 -5) entry_reason:优先从下列完整字符串中选一个(一字不差);若无法归类则可将简述写入 entry_reason(保存时也可选表单「其他」手写): - - 趋势多头:4h大结构突破前进场,确认条件:三次探顶,5m收敛不创新低 - - 趋势空头:4h大结构突破前进场,确认条件:三次探底,5m收敛不创新高 - - 趋势多头:小分歧低吸入场(左侧),确认条件:二次探底 - - 趋势空头:小分歧高吸入场(左侧),确认条件:二次探顶 - - 波段单:5m顺势突破,确认条件:2根k线+成交量放大+4h同向+日成交量前20 -6) early_exit_trigger 只能从下列取值中选一个(无法识别则填空字符串):止盈、保本止盈、移动止盈、时间平仓、手动平仓、止损、其他。 -7) 若触发为「手动平仓」,early_exit_note 必须写出图中可见的补充说明;其他触发类型 early_exit_note 留空。 -8) 若图中有无法归类的离场说明原文,可放进 early_exit_note,early_exit_trigger 填「其他」或留空。 - -JSON 字段: -{ - "open_datetime": "", - "close_datetime": "", - "coin": "", - "tf": "", - "pnl": "", - "expect_rr": "", - "real_rr": "", - "entry_reason": "", - "early_exit_trigger": "", - "early_exit_note": "", - "early_exit_reason": "", - "note": "" -} -""".strip() - try: - raw = ai_generate(prompt, images_b64=[image_b64], temperature=0.1) - if raw.startswith("AI 调用失败"): - return {} - data = _extract_json_object(raw) or {} - if not isinstance(data, dict): - data = {} - trig_in = data.get("early_exit_trigger") - note_in = data.get("early_exit_note") - legacy_reason = str(data.get("early_exit_reason") or "").strip() - out = { - "open_datetime": str(data.get("open_datetime") or "").strip(), - "close_datetime": str(data.get("close_datetime") or "").strip(), - "coin": str(data.get("coin") or "").strip(), - "tf": str(data.get("tf") or "").strip(), - "pnl": str(data.get("pnl") or "").strip(), - "expect_rr": str(data.get("expect_rr") or "").strip(), - "real_rr": str(data.get("real_rr") or "").strip(), - "entry_reason": normalize_entry_reason(data.get("entry_reason")), - "early_exit_trigger": normalize_early_exit_trigger(trig_in), - "early_exit_note": str(note_in or "").strip(), - "early_exit_reason": legacy_reason, - "note": str(data.get("note") or "").strip(), - } - if not out["early_exit_trigger"] and not out["early_exit_note"] and legacy_reason: - out["early_exit_note"] = legacy_reason - if out["early_exit_trigger"] == "手动平仓" and not out["early_exit_note"] and legacy_reason: - out["early_exit_note"] = legacy_reason - if out["early_exit_trigger"] != "手动平仓": - out["early_exit_note"] = "" - out["exit_reason"] = journal_exit_reason_stored(out["early_exit_trigger"], out["early_exit_note"]) - return out - except Exception: - return None - -# 初始化数据库(支持多空方向) -def init_db(): - conn = sqlite3.connect(DB_PATH) - c = conn.cursor() - - # 关键位监控 - c.execute('''CREATE TABLE IF NOT EXISTS key_monitors - (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT, - direction TEXT DEFAULT "long", upper REAL, lower REAL, - notification_count INTEGER DEFAULT 0, last_notified_at TEXT, - max_notify INTEGER DEFAULT 3, notify_interval_min INTEGER DEFAULT 5, - breakout_limit_pct REAL DEFAULT 1.5, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') - - # 订单监控(核心:加 direction 方向字段) - c.execute('''CREATE TABLE IF NOT EXISTS order_monitors - (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, direction TEXT DEFAULT "long", - exchange_symbol TEXT, - trigger_price REAL, stop_loss REAL, initial_stop_loss REAL, take_profit REAL, - margin_capital REAL DEFAULT 30, leverage INTEGER DEFAULT 5, - trade_style TEXT DEFAULT "trend", - risk_percent REAL, risk_amount REAL, - breakeven_rr_trigger REAL, breakeven_offset_pct REAL, breakeven_step_r REAL, - breakeven_armed INTEGER DEFAULT 0, breakeven_price REAL, - notional_value REAL, position_ratio REAL, base_amount REAL, - order_amount REAL, exchange_order_id TEXT, exchange_close_order_id TEXT, - exchange_margin_usdt REAL, - opened_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, opened_at_ms INTEGER, session_date TEXT, - status TEXT DEFAULT "active")''') - - # 交易记录(必须存多空) - c.execute('''CREATE TABLE IF NOT EXISTS trade_records - (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT, - direction TEXT DEFAULT "long", trigger_price REAL, stop_loss REAL, initial_stop_loss REAL, take_profit REAL, - margin_capital REAL, leverage INTEGER, pnl_amount REAL DEFAULT 0, hold_seconds INTEGER DEFAULT 0, - trade_style TEXT DEFAULT "trend", risk_amount REAL, planned_rr REAL, actual_rr REAL, - hold_minutes INTEGER DEFAULT 0, opened_at TEXT, opened_at_ms INTEGER, closed_at TEXT, closed_at_ms INTEGER, - result TEXT, miss_reason TEXT, exchange_trade_id TEXT, - reviewed_opened_at TEXT, reviewed_closed_at TEXT, reviewed_stop_loss REAL, reviewed_take_profit REAL, reviewed_pnl_amount REAL, - reviewed_result TEXT, reviewed_miss_reason TEXT, reviewed_hold_seconds INTEGER, reviewed_hold_minutes INTEGER, - reviewed_at TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') - - c.execute('''CREATE TABLE IF NOT EXISTS trading_sessions - (session_date TEXT PRIMARY KEY, start_capital REAL, current_capital REAL, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') - - c.execute('''CREATE TABLE IF NOT EXISTS journal_entries - (id TEXT PRIMARY KEY, open_datetime TEXT, close_datetime TEXT, hold_duration TEXT, - coin TEXT, tf TEXT, pnl TEXT, entry_reason TEXT, exit_reason TEXT, - expect_rr TEXT, real_rr TEXT, early_exit TEXT, early_exit_reason TEXT, - early_exit_trigger TEXT, early_exit_note TEXT, - mood_score INTEGER, mood_ai_score INTEGER, mood_ai_comment TEXT, mood_issues TEXT, post_breakeven_stare TEXT, - new_trade_while_occupied TEXT, note TEXT, image TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') - - c.execute('''CREATE TABLE IF NOT EXISTS ai_reviews - (id TEXT PRIMARY KEY, review_type TEXT, target_date TEXT, content TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') - - c.execute('''CREATE TABLE IF NOT EXISTS transfer_logs - (id INTEGER PRIMARY KEY AUTOINCREMENT, transfer_type TEXT, transfer_day TEXT, - amount REAL, from_account TEXT, to_account TEXT, status TEXT, message TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') - c.execute('''DROP INDEX IF EXISTS idx_transfer_logs_unique_day''') - c.execute('''CREATE UNIQUE INDEX IF NOT EXISTS idx_transfer_logs_auto_daily_unique - ON transfer_logs(transfer_type, transfer_day) - WHERE transfer_type = 'auto_daily' ''') - - # 给旧表加 direction 字段(兼容老数据,不报错) - try: - c.execute("ALTER TABLE order_monitors ADD COLUMN direction TEXT DEFAULT 'long'") - except: pass - try: - c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_symbol TEXT") - except: pass - try: - c.execute("ALTER TABLE order_monitors ADD COLUMN margin_capital REAL DEFAULT 30") - except: pass - try: - c.execute("ALTER TABLE order_monitors ADD COLUMN leverage INTEGER DEFAULT 5") - except: pass - try: - c.execute("ALTER TABLE order_monitors ADD COLUMN trade_style TEXT DEFAULT 'trend'") - except: pass - try: - c.execute("ALTER TABLE order_monitors ADD COLUMN risk_percent REAL") - except: pass - try: - c.execute("ALTER TABLE order_monitors ADD COLUMN risk_amount REAL") - except: pass - try: - c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_rr_trigger REAL") - except: pass - try: - c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_offset_pct REAL") - except: pass - try: - c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_step_r REAL") - except: pass - try: - c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_armed INTEGER DEFAULT 0") - except: pass - try: - c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_price REAL") - except: pass - try: - c.execute("ALTER TABLE order_monitors ADD COLUMN initial_stop_loss REAL") - except: pass - try: - c.execute("ALTER TABLE order_monitors ADD COLUMN notional_value REAL") - except: pass - try: - c.execute("ALTER TABLE order_monitors ADD COLUMN position_ratio REAL") - except: pass - try: - c.execute("ALTER TABLE order_monitors ADD COLUMN base_amount REAL") - except: pass - try: - c.execute("ALTER TABLE order_monitors ADD COLUMN order_amount REAL") - except: pass - try: - c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_order_id TEXT") - except: pass - try: - c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_close_order_id TEXT") - except: pass - try: - c.execute("ALTER TABLE order_monitors ADD COLUMN opened_at TEXT") - except: pass - try: - c.execute("ALTER TABLE order_monitors ADD COLUMN opened_at_ms INTEGER") - except: pass - try: - c.execute("ALTER TABLE order_monitors ADD COLUMN session_date TEXT") - except: pass - try: - c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 1") - except Exception: - pass - try: - c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_margin_usdt REAL") - except Exception: - pass - try: - c.execute(f"ALTER TABLE order_monitors ADD COLUMN monitor_type TEXT DEFAULT '{ORDER_MONITOR_TYPE_MANUAL}'") - except Exception: - pass - try: - c.execute( - "UPDATE order_monitors SET monitor_type=? WHERE monitor_type IS NULL OR TRIM(monitor_type)=''", - (ORDER_MONITOR_TYPE_MANUAL,), - ) - except Exception: - pass - try: - c.execute("UPDATE order_monitors SET opened_at = datetime('now') WHERE opened_at IS NULL OR opened_at = ''") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN direction TEXT DEFAULT 'long'") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN margin_capital REAL") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN leverage INTEGER") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN pnl_amount REAL DEFAULT 0") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN hold_seconds INTEGER DEFAULT 0") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN hold_minutes INTEGER DEFAULT 0") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN trade_style TEXT DEFAULT 'trend'") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN risk_amount REAL") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN planned_rr REAL") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN actual_rr REAL") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN initial_stop_loss REAL") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN exchange_trade_id TEXT") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN opened_at TEXT") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN opened_at_ms INTEGER") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN closed_at TEXT") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN closed_at_ms INTEGER") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_opened_at TEXT") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_closed_at TEXT") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_stop_loss REAL") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_take_profit REAL") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_pnl_amount REAL") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_result TEXT") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_miss_reason TEXT") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_hold_seconds INTEGER") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_hold_minutes INTEGER") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_at TEXT") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN entry_reason TEXT") - except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_entry_reason TEXT") - except: pass - try: - c.execute("ALTER TABLE journal_entries ADD COLUMN mood_ai_score INTEGER") - except: pass - try: - c.execute("ALTER TABLE journal_entries ADD COLUMN mood_ai_comment TEXT") - except: pass - try: - c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_trigger TEXT") - except: pass - try: - c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_note TEXT") - except: pass - try: - c.execute("ALTER TABLE key_monitors ADD COLUMN direction TEXT DEFAULT 'long'") - except: pass - try: - c.execute("ALTER TABLE key_monitors ADD COLUMN notification_count INTEGER DEFAULT 0") - except: pass - try: - c.execute("ALTER TABLE key_monitors ADD COLUMN last_notified_at TEXT") - except: pass - try: - c.execute("ALTER TABLE key_monitors ADD COLUMN max_notify INTEGER DEFAULT 3") - except: pass - try: - c.execute("ALTER TABLE key_monitors ADD COLUMN notify_interval_min INTEGER DEFAULT 5") - except: pass - try: - c.execute("ALTER TABLE key_monitors ADD COLUMN breakout_limit_pct REAL DEFAULT 1.5") - except: pass - for ddl in ( - "ALTER TABLE key_monitors ADD COLUMN fib_limit_order_id TEXT", - "ALTER TABLE key_monitors ADD COLUMN fib_entry_price REAL", - "ALTER TABLE key_monitors ADD COLUMN fib_stop_loss REAL", - "ALTER TABLE key_monitors ADD COLUMN fib_take_profit REAL", - "ALTER TABLE key_monitors ADD COLUMN fib_order_amount REAL", - "ALTER TABLE key_monitors ADD COLUMN fib_margin_capital REAL", - "ALTER TABLE key_monitors ADD COLUMN fib_leverage INTEGER", - "ALTER TABLE key_monitors ADD COLUMN sl_tp_mode TEXT DEFAULT 'standard'", - "ALTER TABLE key_monitors ADD COLUMN manual_take_profit REAL", - "ALTER TABLE key_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 0", - "ALTER TABLE key_monitors ADD COLUMN last_rs_bar_ts INTEGER", - "ALTER TABLE key_monitors ADD COLUMN session_date TEXT", - ): - try: - c.execute(ddl) - except Exception: - pass - ensure_time_close_schema(c) - ensure_key_monitor_schema(c) - try: - c.execute("ALTER TABLE trading_sessions ADD COLUMN key_sizing_capital_snapshot REAL") - except Exception: - pass - try: - c.execute("ALTER TABLE order_monitors ADD COLUMN key_signal_type TEXT") - except Exception: - pass - for ddl in ( - "ALTER TABLE trade_records ADD COLUMN key_signal_type TEXT", - "ALTER TABLE trade_records ADD COLUMN exchange_realized_pnl REAL", - "ALTER TABLE trade_records ADD COLUMN exchange_opened_at TEXT", - "ALTER TABLE trade_records ADD COLUMN exchange_closed_at TEXT", - "ALTER TABLE trade_records ADD COLUMN exchange_sync_key TEXT", - "ALTER TABLE trade_records ADD COLUMN exchange_turnover_usdt REAL", - "ALTER TABLE trade_records ADD COLUMN exchange_commission_usdt REAL", - ): - try: - c.execute(ddl) - except Exception: - pass - - c.execute( - """CREATE TABLE IF NOT EXISTS key_monitor_history - (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT, direction TEXT, - upper REAL, lower REAL, notification_count INTEGER, last_alert_message TEXT, - close_reason TEXT, closed_at TEXT)""" - ) - - from lib.strategy.strategy_db import init_strategy_tables - - init_strategy_tables(conn) - from lib.trade.account_risk_lib import ensure_account_risk_schema - - ensure_account_risk_schema(conn) - backfill_missing_key_signal_types(conn, monitor_type=ORDER_MONITOR_TYPE_KEY_AUTO) - conn.commit() - conn.close() - -init_db() - - -def _purge_key_monitors_if_full_margin(): - if not is_full_margin_mode(POSITION_SIZING_MODE): - return - conn = get_db() - try: - purge_disallowed_key_monitors( - conn, - sizing_mode=POSITION_SIZING_MODE, - select_rows=lambda c: c.execute("SELECT * FROM key_monitors").fetchall(), - cancel_fib_limit=_cancel_fib_monitor_limit, - delete_monitor=lambda c, kid: c.execute("DELETE FROM key_monitors WHERE id=?", (kid,)), - send_wechat=send_wechat_msg, - ) - conn.commit() - except Exception as e: - print(f"[full_margin] purge key monitors: {e}", flush=True) - finally: - conn.close() - - -def get_db(): - conn = sqlite3.connect(DB_PATH) - conn.row_factory = sqlite3.Row - return conn - - -def hub_account_risk_status(conn): - from lib.trade.account_risk_lib import ( - apply_position_limit_risk, - compute_account_risk_status, - enrich_risk_status_countdown, - ensure_account_risk_schema, - ) - - ensure_account_risk_schema(conn) - now = app_now() - st = compute_account_risk_status( - conn, - trading_day=get_trading_day(), - now=now, - fmt_local_ms=ms_to_app_local_str, - ) - st = enrich_risk_status_countdown(st, now=now, daily_reset_hour=TRADING_DAY_RESET_HOUR) - from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors - - return apply_position_limit_risk( - st, - count_position_limit_active_monitors(conn), - max_active_positions=MAX_ACTIVE_POSITIONS, - ) - - -def hub_user_initiated_close( - conn, - *, - source, - count=1, - trade_record_id=None, - closed_at_ms=None, -): - from lib.trade.account_risk_lib import CLOSE_SOURCE_USER_HUB, on_user_initiated_close - - src = (source or "").strip() or CLOSE_SOURCE_USER_HUB - on_user_initiated_close( - conn, - source=src, - trade_record_id=trade_record_id, - closed_at_ms=closed_at_ms, - trading_day=get_trading_day(), - now=app_now(), - count=count, - ) - - -def app_now(): - """应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较)。""" - return datetime.now(APP_TZ).replace(tzinfo=None) - - -def app_now_str(): - return app_now().strftime("%Y-%m-%d %H:%M:%S") - - -def utc_now_dt(): - """当前时刻(UTC,aware)。""" - return datetime.now(timezone.utc) - - -def utc_calendar_date_str(): - """UTC 自然日 YYYY-MM-DD(用于自动划转去重等与交易所日界对齐的计算)。""" - return utc_now_dt().strftime("%Y-%m-%d") - - -def get_trading_day(now=None): - """交易日字符串:本地时钟下若小时 < TRADING_DAY_RESET_HOUR 则归属「上一日历日」。""" - now = now or app_now() - if getattr(now, "tzinfo", None): - now = now.astimezone(APP_TZ).replace(tzinfo=None) - if now.hour < TRADING_DAY_RESET_HOUR: - return (now - timedelta(days=1)).strftime("%Y-%m-%d") - return now.strftime("%Y-%m-%d") - - -TRADE_COMPLETED_RESULTS = ( - "止盈", - "止损", - "保本止盈", - "移动止盈", - "手动平仓", - "强制清仓", - "外部平仓", - TIME_CLOSE_RESULT, -) - -REVIEW_RESULT_OPTIONS = ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", TIME_CLOSE_RESULT) - - -def parse_dt_for_trading_day(s): - if not s: - return None - s = str(s).strip().replace("Z", "").replace("T", " ") - if not s: - return None - for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)): - try: - return datetime.strptime(s[:ln], fmt) - except ValueError: - continue - return None - - -def insert_key_monitor_history(conn, row, notification_count, last_msg, close_reason): - conn.execute( - """INSERT INTO key_monitor_history - (symbol, monitor_type, direction, upper, lower, notification_count, last_alert_message, close_reason, closed_at) - VALUES (?,?,?,?,?,?,?,?,?)""", - ( - row["symbol"], - row["monitor_type"], - row["direction"] or "long", - row["upper"], - row["lower"], - int(notification_count or 0), - (last_msg or "")[:800] if last_msg else None, - close_reason, - app_now_str(), - ), - ) - - -def _session_week_bounds(trading_day_str): - end = datetime.strptime(trading_day_str, "%Y-%m-%d").date() - start = end - timedelta(days=6) - return start.strftime("%Y-%m-%d"), trading_day_str - - -def _calendar_month_bounds(local_dt): - y, m = local_dt.year, local_dt.month - start = f"{y:04d}-{m:02d}-01" - if m == 12: - end_d = datetime(y, 12, 31).date() - else: - end_d = (datetime(y, m + 1, 1) - timedelta(days=1)).date() - return start, end_d.strftime("%Y-%m-%d") - - -def _count_opens_between(conn, start_td, end_td): - return conn.execute( - "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ?", - (start_td, end_td), - ).fetchone()[0] - - -def _list_window_from_request(): - return resolve_list_window(request.args, session, default_preset=PRESET_UTC_TODAY) - - -def _redirect_records(): - qs = list_window_redirect_query(session) - return redirect(f"/records?{qs}" if qs else "/records") - - -def _pnl_row_matches_segment(row, segment_key): - try: - mt = (row["monitor_type"] or "").strip() - kst = (row["key_signal_type"] or "").strip() - except Exception: - return False - if segment_key == "all": - return True - if segment_key == "manual": - return mt == ORDER_MONITOR_TYPE_MANUAL and not kst - if segment_key == "key_box": - return kst == "箱体突破" - if segment_key == "key_conv": - return kst == "收敛突破" - if segment_key == "key_fib618": - return kst == "斐波回调0.618" - if segment_key == "key_fib786": - return kst == "斐波回调0.786" - if segment_key == "key_false_breakout": - return kst == FALSE_BREAKOUT_MONITOR_TYPE - if segment_key == "key_trigger": - return kst in TRIGGER_ENTRY_MONITOR_TYPES - return False - - -def _count_opens_for_segment(conn, start_td, end_td, segment_key): - if segment_key == "manual": - return conn.execute( - "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ? " - "AND (monitor_type IS NULL OR monitor_type=? OR TRIM(monitor_type)='') " - "AND (key_signal_type IS NULL OR TRIM(key_signal_type)='')", - (start_td, end_td, ORDER_MONITOR_TYPE_MANUAL), - ).fetchone()[0] - kst_map = { - "key_box": "箱体突破", - "key_conv": "收敛突破", - "key_fib618": "斐波回调0.618", - "key_fib786": "斐波回调0.786", - "key_false_breakout": FALSE_BREAKOUT_MONITOR_TYPE, - "key_trigger": None, # 见 _count_opens_for_segment 多类型 - } - if segment_key == "key_trigger": - placeholders = ",".join("?" * len(TRIGGER_ENTRY_MONITOR_TYPES)) - return conn.execute( - f"SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ? " - f"AND key_signal_type IN ({placeholders})", - (start_td, end_td, *TRIGGER_ENTRY_MONITOR_TYPES), - ).fetchone()[0] - kst = kst_map.get(segment_key) - if kst: - return conn.execute( - "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ? AND key_signal_type=?", - (start_td, end_td, kst), - ).fetchone()[0] - return conn.execute( - "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ?", - (start_td, end_td), - ).fetchone()[0] - - -def _load_completed_trade_pnls(conn): - q = """SELECT pnl_amount, reviewed_pnl_amount, closed_at, reviewed_closed_at, created_at, opened_at, - result, reviewed_result, monitor_type, key_signal_type - FROM trade_records - ORDER BY COALESCE(closed_at, created_at, opened_at) ASC, id ASC""" - rows = conn.execute(q).fetchall() - out = [] - for r in rows: - effective_result = (r["reviewed_result"] or r["result"] or "").strip() - if effective_result not in TRADE_COMPLETED_RESULTS: - continue - try: - p = float(r["reviewed_pnl_amount"] if r["reviewed_pnl_amount"] is not None else (r["pnl_amount"] or 0)) - except (TypeError, ValueError): - p = 0.0 - t = parse_dt_for_trading_day(r["reviewed_closed_at"]) or parse_dt_for_trading_day(r["closed_at"]) or parse_dt_for_trading_day(r["created_at"]) - td = get_trading_day(t) if t else None - out.append((p, t, td, r)) - return out - - -def _compute_period_metrics(trades): - """trades: list of (pnl, close_dt, close_trading_day)""" - trades = [(p, t, td) for p, t, td in trades if t is not None] - trades.sort(key=lambda x: x[1]) - closed = len(trades) - wins = sum(1 for p, _, _ in trades if p > 0) - losses = sum(1 for p, _, _ in trades if p < 0) - net = round(sum(p for p, _, _ in trades), 2) - loss_sum_raw = sum(p for p, _, _ in trades if p < 0) - loss_sum_u = round(abs(loss_sum_raw), 2) if loss_sum_raw < 0 else 0.0 - neg_pnls = [p for p, _, _ in trades if p < 0] - pos_pnls = [p for p, _, _ in trades if p > 0] - max_single_loss = round(min(neg_pnls), 2) if neg_pnls else None - max_single_profit = round(max(pos_pnls), 2) if pos_pnls else None - cum = peak = max_dd = 0.0 - for p, _, _ in trades: - cum += p - peak = max(peak, cum) - max_dd = max(max_dd, peak - cum) - max_dd = round(max_dd, 2) - streak = 0 - for p, _, _ in reversed(trades): - if p < 0: - streak += 1 - else: - break - daily = {} - for p, _, td in trades: - if td: - daily[td] = daily.get(td, 0.0) + p - max_loss_streak_days = 0 - worst_day = None - worst_day_pnl = None - if daily: - sorted_days = sorted(daily.keys()) - run = 0 - for d in sorted_days: - if daily[d] < 0: - run += 1 - max_loss_streak_days = max(max_loss_streak_days, run) - else: - run = 0 - worst_day = min(daily.keys(), key=lambda x: daily[x]) - worst_day_pnl = round(daily[worst_day], 2) - win_rate_pct = round(wins / (wins + losses) * 100, 2) if (wins + losses) else None - return { - "closed_count": closed, - "win_count": wins, - "loss_count": losses, - "win_rate_pct": win_rate_pct, - "net_pnl_u": net, - "loss_sum_u": loss_sum_u, - "max_single_loss": max_single_loss, - "max_single_profit": max_single_profit, - "max_drawdown_u": max_dd, - "consecutive_losses": streak, - "max_loss_streak_days": max_loss_streak_days, - "worst_day": worst_day, - "worst_day_pnl": worst_day_pnl, - "opens_count": 0, - "range_label": "", - } - - -def compute_stats_bundle(conn, trading_day, now_dt=None): - """日 / 周 / 月 统计:平仓按北京时间交易日(默认 8:00 切日)计入。""" - now_dt = now_dt or app_now() - pnls = _load_completed_trade_pnls(conn) - total_opens_all = conn.execute("SELECT COUNT(*) FROM order_monitors").fetchone()[0] - w_start, w_end = _session_week_bounds(trading_day) - m_start, m_end = _calendar_month_bounds(now_dt) - - def in_week(tr): - return tr[2] and w_start <= tr[2] <= w_end - - def in_month(tr): - return tr[2] and m_start <= tr[2] <= m_end - - def slice_metrics(seg_key): - seg_rows = [tr for tr in pnls if _pnl_row_matches_segment(tr[3], seg_key)] - day_tr = [(p, t, td) for p, t, td, _r in seg_rows if td == trading_day] - week_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and w_start <= td <= w_end] - month_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and m_start <= td <= m_end] - dm = _compute_period_metrics(day_tr) - wm = _compute_period_metrics(week_tr) - mm = _compute_period_metrics(month_tr) - dm["opens_count"] = _count_opens_for_segment(conn, trading_day, trading_day, seg_key) - wm["opens_count"] = _count_opens_for_segment(conn, w_start, w_end, seg_key) - mm["opens_count"] = _count_opens_for_segment(conn, m_start, m_end, seg_key) - dm["range_label"] = f"北京时间交易日 {trading_day}({TRADING_DAY_RESET_HOUR}:00 切日)" - wm["range_label"] = f"{w_start} ~ {w_end}(北京日期,近7天)" - mm["range_label"] = f"{m_start} ~ {m_end}(北京自然月)" - return dm, wm, mm - - segments = [] - for seg_key, seg_title, _meta in STATS_SEGMENT_DEFS: - dm, wm, mm = slice_metrics(seg_key) - segments.append({"key": seg_key, "title": seg_title, "day": dm, "week": wm, "month": mm}) - - dm, wm, mm = slice_metrics("all") - - return { - "trading_day": trading_day, - "total_opens_all": total_opens_all, - "day": dm, - "week": wm, - "month": mm, - "segments": segments, - "stats_reset_hour": TRADING_DAY_RESET_HOUR, - } - - -def infer_leverage(symbol): - sym = (symbol or "").strip().upper() - if sym.startswith("BTC") or sym.startswith("ETH"): - return BTC_LEVERAGE - return ALT_LEVERAGE - - -def normalize_exchange_symbol(symbol): - sym = symbol.strip().upper() - if ":" in sym: - return sym - if "/" in sym: - base, quote = sym.split("/", 1) - quote_clean = quote.split(":")[0] - return f"{base}/{quote_clean}:{quote_clean}" - return sym - - -def resolve_monitor_exchange_symbol(row): - """将监控行上的 symbol / exchange_symbol 统一到 ccxt 永续合约 symbol,便于与 fetch_positions 结果比对。""" - raw = "" - try: - if row["exchange_symbol"]: - raw = str(row["exchange_symbol"]).strip() - except (KeyError, IndexError, TypeError): - raw = "" - if not raw: - try: - raw = str(row["symbol"] or "").strip() - except (KeyError, IndexError, TypeError): - raw = "" - return normalize_exchange_symbol(raw) if raw else "" - - -def _position_contract_symbol_match(position_symbol, wanted_exchange_symbol): - if not position_symbol or not wanted_exchange_symbol: - return False - a = normalize_exchange_symbol(str(position_symbol).strip()) - b = normalize_exchange_symbol(str(wanted_exchange_symbol).strip()) - return a == b - - -def _position_matches_wanted_contract(wanted_unified_sym, position_dict): - """统一 symbol 比对;不一致时用 Gate 原始 contract 与 ccxt market.id 对齐(兼容 1000PEPE 等命名差异)。""" - if not wanted_unified_sym or not position_dict: - return False - ps = position_dict.get("symbol") - if _position_contract_symbol_match(ps, wanted_unified_sym): - return True - try: - ensure_markets_loaded() - mid = (exchange.market(wanted_unified_sym).get("id") or "").strip().upper() - info = position_dict.get("info") or {} - c_raw = str(info.get("contract") or "").strip().upper() - if mid and c_raw and mid == c_raw: - return True - except Exception: - pass - return False - - -def _position_row_effective_contracts(p): - """张数:优先 ccxt contracts,否则用 Gate 原始 size/pos(避免统一层为 0 时被误判空仓)。""" - if not p: - return 0.0 - info = p.get("info") or {} - for val in (p.get("contracts"), info.get("size"), info.get("pos")): - if val is None or val == "": - continue - try: - x = abs(float(val)) - if x > 0: - return x - except (TypeError, ValueError): - continue - return 0.0 - - -def normalize_symbol_input(symbol): - sym = (symbol or "").strip().upper() - if not sym: - return "" - if "/" in sym: - return sym - if ":" in sym: - sym = sym.split(":")[0] - return f"{sym}/USDT" - - -def normalize_kline_limit(limit_raw, default=200): - try: - n = int(limit_raw) - except Exception: - return default - return 200 if n >= 200 else 100 - - -def get_recommended_capital(current_capital): - if current_capital <= DAILY_LOSS_CAPITAL: - return DAILY_LOSS_CAPITAL - if current_capital >= DAILY_PROFIT_CAPITAL: - return DAILY_PROFIT_CAPITAL - return DAILY_START_CAPITAL - - -def ensure_session(conn, session_date): - row = conn.execute( - "SELECT * FROM trading_sessions WHERE session_date = ?", - (session_date,) - ).fetchone() - if row: - return row - conn.execute( - "INSERT INTO trading_sessions (session_date, start_capital, current_capital) VALUES (?,?,?)", - (session_date, DAILY_START_CAPITAL, DAILY_START_CAPITAL) - ) - conn.commit() - return conn.execute( - "SELECT * FROM trading_sessions WHERE session_date = ?", - (session_date,) - ).fetchone() - - -def update_session_capital(conn, session_date, pnl_amount): - session_row = ensure_session(conn, session_date) - new_capital = float(session_row["current_capital"]) + float(pnl_amount) - conn.execute( - "UPDATE trading_sessions SET current_capital = ?, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?", - (round(new_capital, 4), session_date) - ) - conn.commit() - return round(new_capital, 4) - - -def calc_hold_seconds(opened_at_str, closed_at_dt): - try: - opened_at = datetime.strptime(opened_at_str, "%Y-%m-%d %H:%M:%S") - return int((closed_at_dt - opened_at).total_seconds()) - except Exception: - return 0 - - -def calc_hold_minutes(seconds): - if not seconds or seconds <= 0: - return 0 - return max(1, int(seconds // 60)) - - -def get_opened_at_value(row): - try: - keys = row.keys() if hasattr(row, "keys") else [] - except Exception: - keys = [] - if "opened_at" in keys: - value = row["opened_at"] - if value: - return value - return app_now_str() - - -def get_effective_trade_field(row, reviewed_key, base_key, default=None): - try: - keys = row.keys() if hasattr(row, "keys") else row.keys() - except Exception: - keys = [] - if reviewed_key in keys: - v = row[reviewed_key] - if v is not None and str(v).strip() != "": - return v - if base_key in keys: - v = row[base_key] - if v is not None and str(v).strip() != "": - return v - return default - - -def to_effective_trade_dict(row): - item = row_to_dict(row) - from lib.trade.order_monitor_display_lib import snapshot_stop_loss - - open_stop = snapshot_stop_loss(item.get("initial_stop_loss"), item.get("stop_loss")) - item["display_open_stop_loss"] = open_stop - item["effective_opened_at"] = get_effective_trade_field(row, "reviewed_opened_at", "opened_at", item.get("opened_at")) - item["effective_closed_at"] = get_effective_trade_field(row, "reviewed_closed_at", "closed_at", item.get("closed_at")) - item["effective_stop_loss"] = get_effective_trade_field(row, "reviewed_stop_loss", "stop_loss", open_stop) - item["effective_take_profit"] = get_effective_trade_field(row, "reviewed_take_profit", "take_profit", item.get("take_profit")) - item["effective_result"] = get_effective_trade_field(row, "reviewed_result", "result", item.get("result")) - item["effective_miss_reason"] = get_effective_trade_field(row, "reviewed_miss_reason", "miss_reason", item.get("miss_reason")) - item["effective_pnl_amount"] = get_effective_trade_field(row, "reviewed_pnl_amount", "pnl_amount", item.get("pnl_amount")) - item["effective_hold_minutes"] = get_effective_trade_field(row, "reviewed_hold_minutes", "hold_minutes", item.get("hold_minutes")) - item["effective_hold_seconds"] = get_effective_trade_field(row, "reviewed_hold_seconds", "hold_seconds", item.get("hold_seconds")) - er_eff = get_effective_trade_field(row, "reviewed_entry_reason", "entry_reason", item.get("entry_reason")) - item["effective_entry_reason"] = (str(er_eff).strip() if er_eff is not None else "") or "" - try: - _keys = row.keys() if hasattr(row, "keys") else [] - except Exception: - _keys = [] - _reviewed_pnl_raw = row["reviewed_pnl_amount"] if "reviewed_pnl_amount" in _keys else None - has_reviewed_pnl = _reviewed_pnl_raw is not None and str(_reviewed_pnl_raw).strip() != "" - ex_pnl = item.get("exchange_realized_pnl") - if not has_reviewed_pnl and ex_pnl is not None and str(ex_pnl).strip() != "": - try: - item["effective_pnl_amount"] = round(float(ex_pnl), 2) - item["display_pnl_source"] = "exchange" - ex_open = (str(item.get("exchange_opened_at") or "").strip() or None) - ex_close = (str(item.get("exchange_closed_at") or "").strip() or None) - if ex_open: - item["effective_opened_at"] = ex_open - if ex_close: - item["effective_closed_at"] = ex_close - except (TypeError, ValueError): - item["display_pnl_source"] = "local" - elif has_reviewed_pnl: - item["display_pnl_source"] = "reviewed" - else: - item["display_pnl_source"] = "local" - item["effective_result"] = normalize_result_with_pnl( - item.get("effective_result"), - item.get("effective_pnl_amount"), - ) - return item - - -def format_price_magnitude_fallback(value): - """无 markets 或解析失败时的价格展示兜底(按量级)。""" - try: - v = float(value) - except Exception: - return str(value) - if v == 0: - return "0" - av = abs(v) - if av >= 10000: - d = 2 - elif av >= 100: - d = 3 - elif av >= 1: - d = 4 - elif av >= 0.01: - d = 6 - elif av >= 0.0001: - d = 8 - else: - d = 10 - text = f"{v:.{d}f}" - return text.rstrip("0").rstrip(".") if "." in text else text - - -def resolve_ccxt_price_symbol(symbol): - """将界面/库中的品种名转为 ccxt 永续合约 id(如 BTC/USDT -> BTC/USDT:USDT)。""" - s = (symbol or "").strip() - if not s: - return "" - if "/" not in s and ":" not in s: - s = f"{s.upper()}/USDT" - else: - s = s.upper() - return normalize_exchange_symbol(s) - - -def round_price_to_exchange(exchange_symbol, price): - """与交易所 tick 对齐后的 float,供入库与计算;失败时退回 float(price)。""" - if price in (None, ""): - return None - try: - v = float(price) - except (TypeError, ValueError): - return None - if not exchange_symbol: - return v - try: - ensure_markets_loaded() - s = exchange.price_to_precision(exchange_symbol, v) - return float(s) - except Exception: - return v - - -def format_price_for_symbol(symbol, value): - """价格展示:与交易所 price_to_precision 一致(与入库 round_price_to_exchange 对齐)。""" - if value in (None, ""): - return "-" - try: - v = float(value) - except Exception: - return str(value) - ex = resolve_ccxt_price_symbol(symbol) - if not ex: - return format_price_magnitude_fallback(v) - try: - ensure_markets_loaded() - return exchange.price_to_precision(ex, v) - except Exception: - return format_price_magnitude_fallback(v) - - -def format_usdt(value): - """USDT 资金类展示:固定两位小数。""" - if value in (None, ""): - return "-" - try: - return f"{float(value):.2f}" - except (TypeError, ValueError): - return str(value) - - -def format_signed_usdt(value): - """USDT 盈亏等可正可负:+1.23 / -0.50 / 0.00""" - if value in (None, ""): - return "-" - try: - v = float(value) - except (TypeError, ValueError): - return str(value) - if v == 0: - return "0.00" - sign = "+" if v > 0 else "" - return f"{sign}{v:.2f}" - - -def format_wechat_scalar_2dp(value): - """企业微信推送:数值统一两位小数(与交易所 tick 无关)。""" - if value in (None, ""): - return "-" - try: - return f"{float(value):.2f}" - except (TypeError, ValueError): - return str(value) - - -def format_hold_minutes(minutes): - if not minutes: - return "0分钟" - total = int(minutes) - hours = total // 60 - mins = total % 60 - if hours: - return f"{hours}小时{mins}分钟" - return f"{mins}分钟" - - -def calc_pnl(direction, trigger_price, exit_price, margin_capital, leverage): - try: - trigger = float(trigger_price) - exit_p = float(exit_price) - margin = float(margin_capital) - lev = float(leverage) - if trigger <= 0: - return 0.0 - if direction == "short": - pnl_ratio = (trigger - exit_p) / trigger - else: - pnl_ratio = (exit_p - trigger) / trigger - return round(margin * lev * pnl_ratio, 4) - except Exception: - return 0.0 - - -def calc_rr_ratio(direction, entry_price, stop_loss, take_profit): - try: - entry = float(entry_price) - sl = float(stop_loss) - tp = float(take_profit) - if entry <= 0 or sl <= 0 or tp <= 0: - return None - if direction == "short": - risk = sl - entry - reward = entry - tp - else: - risk = entry - sl - reward = tp - entry - if risk <= 0 or reward <= 0: - return None - return round(reward / risk, 4) - except Exception: - return None - - -def calc_risk_fraction(direction, entry_price, stop_loss): - try: - entry = float(entry_price) - sl = float(stop_loss) - if entry <= 0 or sl <= 0: - return None - if direction == "short": - risk = sl - entry - else: - risk = entry - sl - if risk <= 0: - return None - return risk / entry - except Exception: - return None - - -def calc_risk_amount_from_plan(direction, entry_price, stop_loss, margin_capital, leverage): - rf = calc_risk_fraction(direction, entry_price, stop_loss) - if rf is None: - return None - try: - notional = float(margin_capital) * float(leverage) - if notional <= 0: - return None - return round(notional * rf, 6) - except Exception: - return None - - -def calc_actual_rr(pnl_amount, risk_amount): - try: - r = float(risk_amount or 0) - if r <= 0: - return None - return round(float(pnl_amount or 0) / r, 4) - except Exception: - return None - - -def calc_breakeven_stop(direction, entry_price, risk_fraction, locked_r, offset_pct): - """ - 按“已锁定R”计算目标止损位: - - long: entry + locked_r * (entry*risk_fraction) + offset - - short: entry - locked_r * (entry*risk_fraction) - offset - """ - try: - entry = float(entry_price) - rf = float(risk_fraction) - lr = float(locked_r) - off = float(offset_pct) / 100.0 - if entry <= 0 or rf <= 0 or lr < 0: - return None - base_move = entry * rf * lr - offset_move = entry * off - if direction == "short": - return round(entry - base_move - offset_move, 8) - return round(entry + base_move + offset_move, 8) - except Exception: - return None - - -def insert_trade_record( - conn, - symbol, - monitor_type, - direction, - trigger_price, - stop_loss, - initial_stop_loss=None, - take_profit=None, - margin_capital=None, - leverage=None, - pnl_amount=0, - hold_seconds=0, - trade_style=None, - risk_amount=None, - planned_rr=None, - actual_rr=None, - result="", - miss_reason=None, - opened_at=None, - opened_at_ms=None, - closed_at=None, - closed_at_ms=None, - exchange_trade_id=None, - key_signal_type=None, - entry_reason=None, - trend_plan_id=None, - exchange_symbol=None, - attach_exchange_stats=True, -): - hold_minutes = calc_hold_minutes(hold_seconds) - open_ts = opened_at or app_now_str() - close_ts = closed_at or app_now_str() - open_ts_ms = _to_ms_with_fallback(opened_at_ms, open_ts) - close_ts_ms = _to_ms_with_fallback(closed_at_ms, close_ts) - kst = key_signal_type_for_trade_record(key_signal_type, KEY_MONITOR_AUTO_TYPES) - from lib.trade.order_monitor_display_lib import snapshot_stop_loss - - snap_sl = snapshot_stop_loss(initial_stop_loss, stop_loss) - er = ( - (entry_reason or "").strip() - or entry_reason_from_key_signal(kst) - or entry_reason_for_monitor_type(monitor_type) - or "" - ) - cur = conn.execute( - "INSERT INTO trade_records (symbol,monitor_type,key_signal_type,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage,pnl_amount,hold_seconds,trade_style,risk_amount,planned_rr,actual_rr,hold_minutes,opened_at,opened_at_ms,closed_at,closed_at_ms,result,miss_reason,exchange_trade_id,entry_reason,trend_plan_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", - ( - symbol, monitor_type, kst, direction, trigger_price, snap_sl, snap_sl, take_profit, - margin_capital, leverage, pnl_amount, hold_seconds, - trade_style, risk_amount, planned_rr, actual_rr, hold_minutes, - open_ts, open_ts_ms, close_ts, close_ts_ms, result, miss_reason, exchange_trade_id, er or None, - trend_plan_id, - ) - ) - tid = int(cur.lastrowid or 0) - if attach_exchange_stats and tid: - ex_sym = (exchange_symbol or "").strip() or normalize_exchange_symbol(symbol) - _attach_gate_trade_exchange_stats( - conn, - tid, - exchange_symbol=ex_sym, - direction=direction, - opened_at_str=open_ts, - closed_at_str=close_ts, - opened_at_ms=open_ts_ms, - closed_at_ms=close_ts_ms, - ) - return tid - - -def calc_duration_text(open_str, close_str): - try: - fmt = "%Y-%m-%dT%H:%M" - o = datetime.strptime(open_str, fmt) - c = datetime.strptime(close_str, fmt) - delta = c - o - seconds = int(delta.total_seconds()) - if seconds <= 0: - return "0分钟" - d = seconds // 86400 - h = (seconds % 86400) // 3600 - m = (seconds % 3600) // 60 - parts = [] - if d: - parts.append(f"{d}天") - if h: - parts.append(f"{h}小时") - if m or not parts: - parts.append(f"{m}分钟") - return " ".join(parts) - except Exception: - return "计算失败" - - -def row_to_dict(row): - return {k: row[k] for k in row.keys()} - - -def enrich_order_item(raw_item, current_capital): - item = dict(raw_item or {}) - margin = float(item.get("margin_capital") or 0) - lev = float(item.get("leverage") or 0) - notional = item.get("notional_value") - ratio = item.get("position_ratio") - if notional is None: - notional = round(margin * lev, 2) if margin and lev else 0 - if ratio is None: - ratio = round(margin / current_capital * 100, 2) if current_capital else 0 - item["notional_value"] = notional - item["position_ratio"] = ratio - enrich_order_display_fields(item, calc_rr_ratio) - try: - be = item.get("breakeven_enabled") - item["breakeven_enabled"] = 0 if be is not None and int(be) == 0 else 1 - except Exception: - item["breakeven_enabled"] = 1 - return apply_order_monitor_source_labels(item, default_manual=ORDER_MONITOR_TYPE_MANUAL) - - -def ensure_exchange_live_ready(): - if not LIVE_TRADING_ENABLED: - return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)" - if not (GATE_API_KEY and GATE_API_SECRET): - return False, "缺少 Gate API 密钥配置(GATE_API_KEY / GATE_API_SECRET)" - return True, "" - - -def order_row_monitor_type(row): - return order_monitor_source_type(row, default_manual=ORDER_MONITOR_TYPE_MANUAL) - - -def trade_record_monitor_type(conn, row): - return resolve_trade_record_monitor_type( - conn, row, default_manual=ORDER_MONITOR_TYPE_MANUAL - ) - - -def order_row_key_signal_type(row): - if row is None: - return None - try: - keys = row.keys() if hasattr(row, "keys") else [] - except Exception: - keys = [] - if "key_signal_type" not in keys: - return None - kst = (row["key_signal_type"] or "").strip() - if kst in KEY_MONITOR_AUTO_TYPES or is_fib_key_monitor_type(kst) or is_false_breakout_key_monitor_type(kst): - return kst - return None - - -def exchange_private_api_configured(): - """仅表示已配置密钥;与是否允许下单(LIVE_TRADING_ENABLED)无关,用于只读拉仓等。""" - return bool(GATE_API_KEY and GATE_API_SECRET) - - -def _extract_usdt_total(balance): - usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {} - total_map = balance.get("total", {}) if isinstance(balance, dict) else {} - free_map = balance.get("free", {}) if isinstance(balance, dict) else {} - total = usdt_info.get("total") - if total is None: - total = usdt_info.get("equity") - if total is None: - total = total_map.get("USDT") - if total is None: - total = usdt_info.get("free") - if total is None: - total = free_map.get("USDT") - try: - return float(total) if total is not None else None - except Exception: - return None - - -def _extract_usdt_free(balance): - usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {} - free_map = balance.get("free", {}) if isinstance(balance, dict) else {} - free = usdt_info.get("free") - if free is None: - free = free_map.get("USDT") - try: - return float(free) if free is not None else None - except Exception: - return None - - -def _parse_usdt_from_gate_unified_accounts_body(data): - """ - 解析 Gate GET /unified/accounts 响应体中的 USDT(dict 或 list 形态的 balances 均支持)。 - ccxt fetch_balance(unifiedAccount) 在 balances 为数组时会访问 .keys() 崩溃,故资金兜底走此解析。 - """ - if not isinstance(data, dict): - return None - raw_fd = data.get("funding") - if isinstance(raw_fd, (int, float)): - return float(raw_fd) - if isinstance(raw_fd, str) and raw_fd.strip(): - try: - return float(raw_fd) - except Exception: - pass - if isinstance(raw_fd, dict): - u = raw_fd.get("USDT") or raw_fd.get("usdt") - if isinstance(u, dict): - for k in ("equity", "available", "total", "amount"): - v = u.get(k) - if v is not None: - try: - return float(v) - except Exception: - pass - - balances = data.get("balances") - if isinstance(balances, list): - for row in balances: - if not isinstance(row, dict): - continue - sym = str(row.get("currency") or row.get("asset") or row.get("name") or "").upper() - if sym != "USDT": - continue - for k in ("equity", "balance", "available", "total", "amount"): - v = row.get(k) - if v is not None: - try: - return float(v) - except Exception: - pass - elif isinstance(balances, dict): - u = balances.get("USDT") or balances.get("usdt") - if isinstance(u, dict): - for k in ("equity", "available", "total", "amount"): - v = u.get(k) - if v is not None: - try: - return float(v) - except Exception: - pass - - tb = data.get("total_balance") - if isinstance(tb, dict): - u = tb.get("USDT") or tb.get("usdt") - if isinstance(u, (int, float, str)): - try: - return float(u) - except Exception: - pass - if isinstance(u, dict): - for k in ("equity", "available", "amount", "total"): - val = u.get(k) - if val is not None: - try: - return float(val) - except Exception: - pass - return None - - -def _parse_gate_spot_accounts_response_usdt(response): - """解析 GET /spot/accounts 列表中的 USDT(与 fetch_balance spot 同源,ccxt 解析失败时可兜底)。""" - rows = None - if isinstance(response, list): - rows = response - elif isinstance(response, dict): - inner = response.get("result") - if isinstance(inner, list): - rows = inner - elif isinstance(inner, dict) and isinstance(inner.get("list"), list): - rows = inner["list"] - if not rows: - return None - for row in rows: - if not isinstance(row, dict): - continue - if str(row.get("currency") or "").upper() != "USDT": - continue - ts = row.get("total") - if ts is not None and str(ts).strip() != "": - try: - return float(ts) - except Exception: - pass - try: - return float(row.get("available") or 0) + float(row.get("locked") or 0) - except Exception: - pass - return None - - -def _fetch_usdt_by_types(type_candidates): - """统一只用 ccxt.fetch_balance;spot 必须带 marginMode=spot,否则会随 defaultMarginMode 误走 cross_margin。""" - for t in type_candidates: - try: - params = {"type": t} - if t == "spot": - params["marginMode"] = "spot" - bal = exchange.fetch_balance(params=params) - val = _extract_usdt_total(bal) - if val is not None: - return val - except Exception: - continue - return None - - -def _fetch_gate_funding_usdt(): - """ - Gate「资金账户」: - 1) fetch_balance(type=spot, marginMode=spot) — 避免 defaultMarginMode=cross 误走 cross_margin; - 2) privateSpotGetAccounts — 与 1 同源,ccxt 聚合异常或解析不到 USDT 时再试原始列表; - 3) privateUnifiedGetAccounts + 自解析 — 统一账户 balances 常为数组,ccxt unified fetch_balance 会崩。 - """ - spot_seen_ok = False - try: - ensure_markets_loaded() - bal = exchange.fetch_balance(params={"type": "spot", "marginMode": "spot"}) - spot_seen_ok = True - val = _extract_usdt_total(bal) - if val is not None: - return float(val) - except Exception: - pass - - try: - resp = exchange.privateSpotGetAccounts({}) - v = _parse_gate_spot_accounts_response_usdt(resp) - if v is not None: - return float(v) - except Exception: - pass - - try: - raw = exchange.privateUnifiedGetAccounts({}) - body = raw - if isinstance(body, dict) and isinstance(body.get("result"), dict): - body = body["result"] - v = _parse_usdt_from_gate_unified_accounts_body(body) if isinstance(body, dict) else None - if v is not None: - return float(v) - except Exception: - pass - - if spot_seen_ok: - return 0.0 - return None - - -def get_available_trading_usdt(): - ok_live, _ = ensure_exchange_live_ready() - if not ok_live: - return None - for t in ["swap", "spot"]: - try: - params = {"type": t} - if t == "spot": - params["marginMode"] = "spot" - bal = exchange.fetch_balance(params=params) - free_val = _extract_usdt_free(bal) - if free_val is not None: - return free_val - except Exception: - continue - return None - - -def get_synced_leverage(exchange_symbol, direction): - ensure_markets_loaded() - try: - positions = exchange.fetch_positions([exchange_symbol]) - for p in positions: - if not _position_matches_wanted_contract(exchange_symbol, p): - continue - info = p.get("info", {}) or {} - side = (p.get("side") or info.get("posSide") or "").lower() - if GATE_POS_MODE == "hedge" and side and side != direction: - continue - lev = p.get("leverage") - if lev is None or lev == 0 or str(lev) == "0": - lev = info.get("cross_leverage_limit") or info.get("leverage") - if lev: - try: - return int(float(lev)) - except Exception: - pass - except Exception: - pass - return None - - -def friendly_exchange_error(err, available_usdt=None): - msg = str(err) - low = msg.lower() - if ( - "51008" in msg - or "insufficient" in low - or "margin" in low and ("not enough" in low or "不足" in msg) - or "balance" in low and "insufficient" in low - ): - tail = f"(当前交易账户可用约 {round(available_usdt, 2)}U)" if available_usdt is not None else "" - return f"交易所下单失败:保证金不足 {tail}。请降低保证金/杠杆,或先划转USDT到合约账户。" - clean = re.sub(r"\s+", " ", msg).strip() - return f"交易所下单失败:{clean}" - - -def get_exchange_capitals(force=False): - ok_live, _ = ensure_exchange_live_ready() - if not ok_live: - return None, None - now_ts = time.time() - if (not force) and ACCOUNT_BALANCE_CACHE["updated_at"] and now_ts - ACCOUNT_BALANCE_CACHE["updated_at"] < BALANCE_REFRESH_SECONDS: - return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"] - try: - ACCOUNT_BALANCE_CACHE["funding_usdt"] = _fetch_gate_funding_usdt() - except Exception: - ACCOUNT_BALANCE_CACHE["funding_usdt"] = None - try: - ACCOUNT_BALANCE_CACHE["trading_usdt"] = _fetch_usdt_by_types(["swap", "spot"]) - except Exception: - # 勿保留上一次成功请求的旧值:鉴权失败时否则会误以为「合约余额仍能读」 - ACCOUNT_BALANCE_CACHE["trading_usdt"] = None - ACCOUNT_BALANCE_CACHE["updated_at"] = now_ts - return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"] - - -def execute_transfer_usdt(amount, from_account, to_account): - from lib.exchange.gate_transfer_lib import execute_transfer_usdt as _gate_execute_transfer_usdt - - return _gate_execute_transfer_usdt( - exchange, - amount, - from_account, - to_account, - transfer_ccy=TRANSFER_CCY, - ensure_live_ready=ensure_exchange_live_ready, - ensure_markets_loaded=ensure_markets_loaded, - ) - - -def get_account_usdt_total(account_type): - """读取各账户 USDT。funding 走 _fetch_gate_funding_usdt;spot 同样 marginMode=spot,一律 ccxt。""" - raw = (account_type or "").strip().lower() - if raw == "funding": - return _fetch_gate_funding_usdt() - at = raw - try: - params = {"type": at} - if at == "spot": - params["marginMode"] = "spot" - bal = exchange.fetch_balance(params=params) - val = _extract_usdt_total(bal) - if val is not None: - return val - return 0.0 if at == "spot" else None - except Exception: - return None - - -def _auto_transfer_active_count(conn): - from lib.exchange.gate_transfer_lib import count_auto_transfer_blockers - - return count_auto_transfer_blockers(conn, count_order_monitors=get_active_position_count) - - -def auto_transfer_once_per_day(): - run_auto_transfer_once_per_day( - enabled=AUTO_TRANSFER_ENABLED, - bj_hour=AUTO_TRANSFER_BJ_HOUR, - target_amount=AUTO_TRANSFER_AMOUNT, - from_account=AUTO_TRANSFER_FROM, - to_account=AUTO_TRANSFER_TO, - funds_decimals=2, - get_db=get_db, - get_active_position_count=_auto_transfer_active_count, - get_account_usdt_total=get_account_usdt_total, - execute_transfer_usdt=execute_transfer_usdt, - send_wechat_msg=send_wechat_msg, - utc_now_dt=utc_now_dt, - app_tz=APP_TZ, - utc_calendar_date_str=utc_calendar_date_str, - app_now_str=app_now_str, - ) - - -def trading_day_reset_allows_new_open(now): - """是否允许在满足其它风控的前提下于当前时刻新开仓(仅「整点前禁开」守卫)。""" - if not TRADING_DAY_RESET_OPEN_GUARD_ENABLED: - return True - return now.hour >= TRADING_DAY_RESET_HOUR - - -def get_active_position_count(conn): - return int(conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0]) - - -def clear_key_sizing_snapshot_if_flat(conn, session_date): - if get_active_position_count(conn) > 0: - return - conn.execute( - "UPDATE trading_sessions SET key_sizing_capital_snapshot = NULL, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?", - (session_date,), - ) - conn.commit() - - -def get_key_sizing_capital_snapshot(conn, session_date): - row = ensure_session(conn, session_date) - try: - val = row["key_sizing_capital_snapshot"] - except (KeyError, IndexError): - return None - if val is None: - return None - try: - return float(val) - except (TypeError, ValueError): - return None - - -def set_key_sizing_capital_snapshot(conn, session_date, capital): - ensure_session(conn, session_date) - conn.execute( - "UPDATE trading_sessions SET key_sizing_capital_snapshot = ?, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?", - (round(float(capital), 2), session_date), - ) - conn.commit() - - -def resolve_capital_base_for_key_open(conn, trading_day, live_capital): - live = float(live_capital) - active = get_active_position_count(conn) - if active <= 0: - set_key_sizing_capital_snapshot(conn, trading_day, live) - return live - if KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT: - snap = get_key_sizing_capital_snapshot(conn, trading_day) - if snap is not None and snap > 0: - return snap - return live - - -def precheck_risk(conn, symbol, direction): - now = app_now() - from lib.trade.account_risk_lib import account_risk_blocks_trading - - ok_risk, risk_reason = account_risk_blocks_trading( - conn, - trading_day=get_trading_day(now), - now=now, - fmt_local_ms=ms_to_app_local_str, - ) - if not ok_risk: - return False, risk_reason - if not trading_day_reset_allows_new_open(now): - return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓" - from lib.trade.account_risk_lib import position_limit_reached - - reached, active_count, mx = position_limit_reached(conn, max_active_positions=MAX_ACTIVE_POSITIONS) - if reached: - return False, f"已达最大持仓数({active_count}/{mx})" - ok_daily, daily_reason, _opens = check_daily_open_hard_limit( - conn, get_trading_day(now), DAILY_OPEN_HARD_LIMIT, TRADING_DAY_RESET_HOUR - ) - if not ok_daily: - return False, daily_reason - if direction not in ("long", "short"): - return False, "方向必须为 long 或 short" - if symbol.upper().startswith("BTC") or symbol.upper().startswith("ETH"): - expected = BTC_LEVERAGE - else: - expected = ALT_LEVERAGE - if expected <= 0: - return False, "杠杆配置异常" - return True, "" - - -def prepare_order_amount(exchange_symbol, margin_capital, leverage, fallback_price): - ensure_markets_loaded() - notional = float(margin_capital) * float(leverage) - ticker = exchange.fetch_ticker(exchange_symbol) - price = float(ticker.get("last") or fallback_price) - if price <= 0: - raise ValueError("触发价必须大于 0") - market = exchange.market(exchange_symbol) - contract_size = float(market.get("contractSize") or 1) - if market.get("contract"): - # 合约 amount 按张数/合约乘数解析;ccxt 会再做精度与符号处理 - amount = notional / (price * contract_size) - else: - amount = notional / price - min_amount = (market.get("limits", {}).get("amount", {}) or {}).get("min") - if min_amount and amount < float(min_amount): - raise ValueError(f"下单数量过小,最小数量为 {min_amount}") - amount_precise = float(exchange.amount_to_precision(exchange_symbol, amount)) - if amount_precise <= 0: - raise ValueError("下单数量精度后为 0,请提高基数或降低价格") - return amount_precise, price - - -def _to_positive_float(value): - try: - n = float(value) - return n if n > 0 else None - except Exception: - return None - - -def _extract_order_price_value(order_obj): - if not isinstance(order_obj, dict): - return None - for key in ("average", "price"): - v = _to_positive_float(order_obj.get(key)) - if v is not None: - return v - cost = _to_positive_float(order_obj.get("cost")) - filled = _to_positive_float(order_obj.get("filled")) - if cost is not None and filled is not None and filled > 0: - return cost / filled - info = order_obj.get("info") if isinstance(order_obj.get("info"), dict) else {} - for key in ("avgPx", "fillPx", "avgPrice", "fillPrice", "px"): - v = _to_positive_float(info.get(key)) - if v is not None: - return v - return None - - -def resolve_order_entry_price(order_resp, exchange_symbol, fallback_price): - price = _extract_order_price_value(order_resp) - if price is not None: - return round(price, 8) - order_id = (order_resp or {}).get("id") - if order_id: - try: - fetched = exchange.fetch_order(order_id, exchange_symbol) - fetched_price = _extract_order_price_value(fetched) - if fetched_price is not None: - return round(fetched_price, 8) - except Exception: - pass - fallback = _to_positive_float(fallback_price) - return round(fallback, 8) if fallback is not None else 0.0 - - -def get_contract_size(exchange_symbol): - ensure_markets_loaded() - market = exchange.market(exchange_symbol) - return float(market.get("contractSize") or 1) - - -def parse_positive_float(value): - if value is None: - return None - raw = str(value).strip() - if not raw: - return None - num = float(raw) - if num <= 0: - raise ValueError("数值必须大于0") - return num - - -def build_gate_order_params(direction, reduce_only=False): - params = {} - if reduce_only: - params["reduceOnly"] = True - return params - - -def _gate_contracts_amount_for_tpsl(order, fallback_amount): - for key in ("filled", "amount"): - v = order.get(key) - try: - fv = float(v) - if fv > 0: - return fv - except Exception: - pass - return float(fallback_amount) - - -def _gate_clamp_tpsl_to_last_price(exchange_symbol, direction, stop_loss, take_profit, *, sl_only=False): - """ - Gate price_orders 规则:空仓止损/多仓止盈 trigger>last;空仓止盈/多仓止损 trigger= last: - tp = float(exchange.price_to_precision(exchange_symbol, last * (1 - gap))) - notes.append(f"止盈触发价须低于现价 {last},已调整为 {tp}") - else: - if sl >= last: - sl = float(exchange.price_to_precision(exchange_symbol, last * (1 - gap))) - notes.append(f"止损触发价须低于现价 {last},已调整为 {sl}") - if not sl_only and tp <= last: - tp = float(exchange.price_to_precision(exchange_symbol, last * (1 + gap))) - notes.append(f"止盈触发价须高于现价 {last},已调整为 {tp}") - return sl, tp, (";".join(notes) if notes else None) - - -def _gate_place_tp_sl_orders_legacy_conditional(exchange_symbol, direction, contracts_amount, stop_loss, take_profit): - """ccxt 市价减仓条件单(两张单分别带 stopLossPrice / takeProfitPrice),与官方仓位类触发单等价逻辑不同路径。""" - ensure_markets_loaded() - close_side = "sell" if direction == "long" else "buy" - base = {"reduceOnly": True} - last_err = None - for attempt in range(8): - try: - exchange.create_order( - exchange_symbol, "market", close_side, contracts_amount, None, - dict(base, stopLossPrice=float(stop_loss)), - ) - exchange.create_order( - exchange_symbol, "market", close_side, contracts_amount, None, - dict(base, takeProfitPrice=float(take_profit)), - ) - return - except Exception as e: - last_err = e - time.sleep(0.2 * (attempt + 1)) - raise RuntimeError(f"交易所未接受条件止盈/止损委托参数:{last_err}") - - -def _gate_place_tp_sl_orders_position_price_orders(exchange_symbol, direction, stop_loss, take_profit): - """ - Gate 永续官方仓位类触发单:POST futures/{settle}/price_orders, - order_type=close-long-position / close-short-position,单向全平 close+size=0;双向需 auto_size。 - 与 App 内展示的「条件委托」一致,平仓后仍需 cancel_gate_swap_trigger_orders 避免残留。 - """ - stop_loss, take_profit, _ = _gate_clamp_tpsl_to_last_price( - exchange_symbol, direction, stop_loss, take_profit - ) - ensure_markets_loaded() - market = exchange.market(exchange_symbol) - if not market.get("swap"): - raise RuntimeError("仅支持永续合约 symbol") - settle = market["settleId"] - contract = market["id"] - order_type = "close-long-position" if direction == "long" else "close-short-position" - close_side = "sell" if direction == "long" else "buy" - if close_side == "sell": - sl_rule, tp_rule = 2, 1 - else: - sl_rule, tp_rule = 1, 2 - initial = { - "contract": contract, - "size": 0, - "price": "0", - "close": True, - "reduce_only": True, - "tif": "ioc", - "text": "api", - } - if GATE_POS_MODE == "hedge": - initial["auto_size"] = "close_long" if direction == "long" else "close_short" - # Gate API 1018:auto_size=close_long|close_short 时 initial.close 须为 false - initial["close"] = False - sl_s = exchange.price_to_precision(exchange_symbol, float(stop_loss)) - tp_s = exchange.price_to_precision(exchange_symbol, float(take_profit)) - - def _payload(trigger_price, rule): - trig = { - "strategy_type": 0, - "price_type": GATE_TPSL_PRICE_TYPE, - "price": trigger_price, - "rule": rule, - } - if GATE_TPSL_TRIGGER_EXPIRATION > 0: - trig["expiration"] = GATE_TPSL_TRIGGER_EXPIRATION - return { - "settle": settle, - "initial": dict(initial), - "trigger": trig, - "order_type": order_type, - } - - last_err = None - for attempt in range(8): - try: - exchange.privateFuturesPostSettlePriceOrders(_payload(sl_s, sl_rule)) - try: - exchange.privateFuturesPostSettlePriceOrders(_payload(tp_s, tp_rule)) - except Exception: - cancel_gate_swap_trigger_orders(exchange_symbol) - raise - return - except Exception as e: - last_err = e - time.sleep(0.2 * (attempt + 1)) - raise RuntimeError(f"交易所未接受仓位类条件止盈/止损:{last_err}") - - -def _gate_td_mode_is_cross(): - return _GATE_DEFAULT_MARGIN_MODE == "cross" - - -def _gate_place_tp_sl_orders(exchange_symbol, direction, contracts_amount, stop_loss, take_profit): - pos_err = None - if GATE_TPSL_USE_POSITION_ORDER: - try: - _gate_place_tp_sl_orders_position_price_orders(exchange_symbol, direction, stop_loss, take_profit) - return - except Exception as e: - pos_err = e - if _gate_td_mode_is_cross(): - raise RuntimeError( - f"交易所未接受仓位类条件止盈/止损(全仓不支持 ccxt 条件单回退):{pos_err}" - ) from e - try: - _gate_place_tp_sl_orders_legacy_conditional( - exchange_symbol, direction, contracts_amount, stop_loss, take_profit, - ) - except Exception as legacy_err: - if pos_err is not None: - raise RuntimeError( - f"交易所未接受仓位类条件止盈/止损:{pos_err};条件单回退亦失败:{legacy_err}" - ) from legacy_err - raise - - -def _gate_place_stop_loss_only_position(exchange_symbol, direction, stop_loss): - """Gate 永续:仅挂仓位类止损触发单(趋势回调用)。""" - stop_loss, _, _ = _gate_clamp_tpsl_to_last_price( - exchange_symbol, direction, stop_loss, stop_loss, sl_only=True - ) - ensure_markets_loaded() - market = exchange.market(exchange_symbol) - if not market.get("swap"): - raise RuntimeError("仅支持永续合约 symbol") - settle = market["settleId"] - contract = market["id"] - order_type = "close-long-position" if direction == "long" else "close-short-position" - close_side = "sell" if direction == "long" else "buy" - sl_rule = 2 if close_side == "sell" else 1 - initial = { - "contract": contract, - "size": 0, - "price": "0", - "close": True, - "reduce_only": True, - "tif": "ioc", - "text": "api", - } - if GATE_POS_MODE == "hedge": - initial["auto_size"] = "close_long" if direction == "long" else "close_short" - initial["close"] = False - sl_s = exchange.price_to_precision(exchange_symbol, float(stop_loss)) - - def _payload(trigger_price, rule): - trig = { - "strategy_type": 0, - "price_type": GATE_TPSL_PRICE_TYPE, - "price": trigger_price, - "rule": rule, - } - if GATE_TPSL_TRIGGER_EXPIRATION > 0: - trig["expiration"] = GATE_TPSL_TRIGGER_EXPIRATION - return { - "settle": settle, - "initial": dict(initial), - "trigger": trig, - "order_type": order_type, - } - - last_err = None - for attempt in range(8): - try: - exchange.privateFuturesPostSettlePriceOrders(_payload(sl_s, sl_rule)) - return - except Exception as e: - last_err = e - time.sleep(0.2 * (attempt + 1)) - raise RuntimeError(f"交易所未接受仅止损仓位触发单:{last_err}") - - -def calc_trend_manual_breakeven_stop(direction, entry_price, offset_pct=None): - try: - e = float(entry_price) - pct = float( - offset_pct - if offset_pct is not None - else float(os.getenv("TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT", "0.3")) - ) - except (TypeError, ValueError): - return None - if e <= 0: - return None - direction = (direction or "long").strip().lower() - if direction == "short": - return e * (1.0 - pct / 100.0) - return e * (1.0 + pct / 100.0) - - -def ensure_markets_loaded(force=False): - global MARKETS_LOADED - if force or not MARKETS_LOADED: - exchange.load_markets(reload=force) - MARKETS_LOADED = True - - -def place_exchange_order(exchange_symbol, direction, amount, leverage, stop_loss=None, take_profit=None): - ensure_markets_loaded() - exchange.set_leverage(leverage, exchange_symbol) - side = "buy" if direction == "long" else "sell" - params = build_gate_order_params(direction, reduce_only=False) - order = exchange.create_order(exchange_symbol, "market", side, amount, None, params) - order.setdefault("tpsl_attached", False) - if stop_loss and take_profit: - try: - contracts_amt = _gate_contracts_amount_for_tpsl(order, amount) - _gate_place_tp_sl_orders(exchange_symbol, direction, contracts_amt, stop_loss, take_profit) - order["tpsl_attached"] = True - except RuntimeError: - raise - except Exception as e: - raise RuntimeError(f"交易所未接受条件止盈/止损委托,已拒绝开仓:{str(e)}") from e - return order - - -def close_exchange_order(order_row): - ensure_markets_loaded() - exchange_symbol = order_row["exchange_symbol"] or normalize_exchange_symbol(order_row["symbol"]) - amount = float(order_row["order_amount"] or 0) - if amount <= 0: - raise ValueError("平仓失败:缺少有效下单数量") - direction = order_row["direction"] - side = "sell" if direction == "long" else "buy" - params = build_gate_order_params(direction, reduce_only=True) - return exchange.create_order(exchange_symbol, "market", side, amount, None, params) - - -def _gate_swap_trigger_order_params(): - """永续条件单(止盈/止损触发委托)查询/撤销用的 ccxt 参数。""" - p = {"type": "swap", "trigger": True} - try: - exchange.load_unified_status() - if exchange.options.get("unifiedAccount"): - p["unifiedAccount"] = True - except Exception: - pass - return p - - -def cancel_gate_swap_trigger_orders(exchange_symbol): - """ - 仓位已平时撤销该合约下剩余的永续条件委托(trigger / price_orders),避免孤儿单残留。 - 与 App 内「仓位附带止盈止损」不同,本系统挂的是独立触发单,平仓后交易所未必自动撤。 - """ - ok, _ = ensure_exchange_live_ready() - if not ok or not exchange_symbol: - return - ensure_markets_loaded() - params = _gate_swap_trigger_order_params() - sym = exchange_symbol - try: - exchange.cancel_all_orders(sym, params) - return - except Exception: - pass - try: - pending = exchange.fetch_open_orders(sym, params=params) - except Exception: - return - for o in pending or []: - oid = o.get("id") - if oid is None: - continue - try: - exchange.cancel_order(str(oid), sym, params) - except Exception: - pass - - -def _gate_list_trigger_open_orders(exchange_symbol): - params = _gate_swap_trigger_order_params() - try: - return exchange.fetch_open_orders(exchange_symbol, params=params) or [] - except Exception: - return [] - - -def _gate_order_trigger_price(order): - for key in ("stopPrice", "triggerPrice", "price"): - try: - v = float(order.get(key) or 0) - if v > 0: - return v - except Exception: - pass - info = order.get("info") or {} - if isinstance(info, dict): - trig = info.get("trigger") - if isinstance(trig, dict): - try: - v = float(trig.get("price") or 0) - if v > 0: - return v - except Exception: - pass - for key in ("trigger_price", "triggerPrice", "stopPrice", "price"): - try: - v = float(info.get(key) or 0) - if v > 0: - return v - except Exception: - pass - return None - - -def _gate_tpsl_role_from_order(order, direction): - info = order.get("info") or {} - if not isinstance(info, dict): - info = {} - ot = str(info.get("order_type") or info.get("orderType") or order.get("type") or "").lower() - if "take" in ot and "profit" in ot: - return "tp" - if "stop" in ot and "loss" in ot: - return "sl" - trig = info.get("trigger") - rule = None - if isinstance(trig, dict) and trig.get("rule") is not None: - try: - rule = int(trig["rule"]) - except Exception: - rule = None - if rule is None: - try: - rule = int(info.get("rule")) - except Exception: - rule = None - if rule is not None: - if direction == "long": - return "sl" if rule == 2 else ("tp" if rule == 1 else None) - return "sl" if rule == 1 else ("tp" if rule == 2 else None) - if order.get("stopLossPrice"): - return "sl" - if order.get("takeProfitPrice"): - return "tp" - typ = str(order.get("type") or "").upper() - if "TAKE" in typ: - return "tp" - if "STOP" in typ: - return "sl" - return None - - -def _gate_tpsl_slot_from_order(order, exchange_symbol): - trig = _gate_order_trigger_price(order) - try: - amt = float(order.get("amount") or order.get("remaining") or 0) - except Exception: - amt = None - if amt is not None and amt <= 0: - amt = None - oid = order.get("id") - if oid is None and isinstance(order.get("info"), dict): - oid = order["info"].get("id") or order["info"].get("order_id") - disp = format_price_for_symbol(exchange_symbol, trig) if trig else "-" - return { - "order_id": str(oid) if oid is not None else "", - "channel": "gate_trigger", - "trigger_price": trig, - "trigger_display": disp, - "amount": amt, - "type": str(order.get("type") or ""), - } - - -def fetch_exchange_tpsl_slots(exchange_symbol, direction, plan_sl=None, plan_tp=None): - slots = {"sl": None, "tp": None} - if not exchange_symbol: - return slots - ok, _ = ensure_exchange_live_ready() - if not ok: - return slots - try: - ensure_markets_loaded() - ambiguous = [] - for order in _gate_list_trigger_open_orders(exchange_symbol): - role = _gate_tpsl_role_from_order(order, direction) - slot = _gate_tpsl_slot_from_order(order, exchange_symbol) - if role in ("sl", "tp"): - if slots[role] is None: - slots[role] = slot - continue - ambiguous.append(slot) - for slot in ambiguous: - trig = slot.get("trigger_price") - if trig is None: - continue - try: - plan_sl_f = float(plan_sl) if plan_sl is not None else None - plan_tp_f = float(plan_tp) if plan_tp is not None else None - except Exception: - plan_sl_f = plan_tp_f = None - if plan_sl_f is not None and plan_tp_f is not None: - role = "sl" if abs(trig - plan_sl_f) <= abs(trig - plan_tp_f) else "tp" - elif plan_sl_f is not None: - role = "sl" - elif plan_tp_f is not None: - role = "tp" - else: - continue - if slots[role] is None: - slots[role] = slot - except Exception: - pass - return slots - - -def cancel_gate_tpsl_slot(exchange_symbol, slot): - if not slot or not exchange_symbol: - return - ensure_markets_loaded() - oid = slot.get("order_id") - if not oid: - return - params = _gate_swap_trigger_order_params() - exchange.cancel_order(str(oid), exchange_symbol, params) - - -def _resolve_tpsl_prices_for_manual(direction, live_price, sltp_mode, data): - return resolve_entrust_sltp_prices(direction, live_price, sltp_mode, data) - - -def replace_active_monitor_tpsl_on_exchange(order_row, stop_loss, take_profit): - ok, reason = ensure_exchange_live_ready() - if not ok: - raise RuntimeError(reason or "实盘未就绪") - ex_sym = resolve_monitor_exchange_symbol(order_row) - direction = order_row["direction"] - sl, tp, adjust_note = _gate_clamp_tpsl_to_last_price( - ex_sym, direction, float(stop_loss), float(take_profit) - ) - cancel_gate_swap_trigger_orders(ex_sym) - contracts = get_live_position_contracts(ex_sym, direction) - if contracts is None or float(contracts) <= 0: - raise ValueError("交易所当前无该方向持仓,无法挂止盈止损") - amt = float(contracts) - if amt <= 0: - try: - amt = float(order_row["order_amount"] or 0) - except Exception: - amt = 0 - if amt <= 0: - raise ValueError("无法确定平仓数量") - _gate_place_tp_sl_orders(ex_sym, direction, amt, sl, tp) - - -def extract_trade_price_from_order(order): - if not order: - return None - for k in ("average", "avgPrice", "price"): - try: - v = float(order.get(k) or 0) - if v > 0: - return v - except Exception: - pass - try: - info = order.get("info") or {} - if isinstance(info, dict): - for k in ("fillPx", "avgPx", "fill_price"): - v = float(info.get(k) or 0) - if v > 0: - return v - except Exception: - pass - return None - - -def is_no_position_error(err_msg): - msg = (err_msg or "").lower() - keywords = [ - "no position", "position does not exist", "position not exist", - "pos size is 0", "nothing to close", "reduceonly", "51008", - "empty position", "increase_position", - ] - return any(k in msg for k in keywords) - - -def _gate_fetch_position_rows(exchange_symbol): - """优先拉 USDT 本位全量持仓(与页面一致),避免单合约查询在重启后返回空列表误判空仓。""" - try: - ensure_markets_loaded() - except Exception: - return None - try: - return exchange.fetch_positions(None, {"settle": "usdt"}) or [] - except Exception: - pass - if not exchange_symbol: - return None - try: - return exchange.fetch_positions([exchange_symbol]) or [] - except Exception: - return None - - -def _sum_live_position_contracts(rows, exchange_symbol, direction, relax_direction=False): - total = 0.0 - if not rows: - return total - direction = (direction or "long").strip().lower() - for p in rows: - if not _position_matches_wanted_contract(exchange_symbol, p): - continue - contracts = _position_row_effective_contracts(p) - if contracts <= 0: - continue - if (not relax_direction) and GATE_POS_MODE == "hedge": - info = p.get("info", {}) or {} - side = (p.get("side") or info.get("posSide") or "").lower() - if side and side != direction: - continue - total += contracts - return total - - -def get_live_position_contracts(exchange_symbol, direction): - rows = _gate_fetch_position_rows(exchange_symbol) - if rows is None: - return None - total = _sum_live_position_contracts(rows, exchange_symbol, direction, relax_direction=False) - if total <= 0 and GATE_POS_MODE == "hedge": - total = _sum_live_position_contracts(rows, exchange_symbol, direction, relax_direction=True) - return total - - -def _select_live_position_row(rows, exchange_symbol, direction, relax_hedge=False): - """在 fetch_positions 结果中取与当前监控方向一致、张数最大的一条(与 get_live_position_contracts 过滤规则一致)。""" - if not rows: - return None - candidates = [] - for p in rows: - if not _position_matches_wanted_contract(exchange_symbol, p): - continue - info = p.get("info", {}) or {} - side = (p.get("side") or info.get("posSide") or "").lower() - contracts = _position_row_effective_contracts(p) - if contracts <= 0: - continue - if (not relax_hedge) and GATE_POS_MODE == "hedge": - if side and side != (direction or "").lower(): - continue - candidates.append((contracts, p)) - if not candidates and (not relax_hedge) and GATE_POS_MODE == "hedge": - return _select_live_position_row(rows, exchange_symbol, direction, relax_hedge=True) - if not candidates: - return None - candidates.sort(key=lambda x: x[0], reverse=True) - return candidates[0][1] - - -def _coerce_float(*values): - for v in values: - if v is None or v == "": - continue - try: - return float(v) - except (TypeError, ValueError): - continue - return None - - -def parse_ccxt_position_metrics(position, order_leverage=None): - """ - 从 ccxt 统一持仓结构解析保证金/名义/未实现盈亏(Gate 等所字段略有差异,做多键兜底)。 - 与 App「仓位保证金」对齐时优先用 initialMargin;缺失时再尝试 info 内字段。 - """ - if not position: - return None - p = position - info = p.get("info", {}) or {} - # Gate 全仓:ccxt 的 initialMargin 常为空;collateral 来自 API 的 margin,与 App「保证金」一致 - initial = _coerce_float(p.get("collateral"), p.get("initialMargin"), p.get("margin")) - if initial is None or initial <= 0: - initial = _coerce_float( - info.get("margin"), - info.get("cross_margin"), - info.get("iso_margin"), - info.get("initial_margin"), - info.get("position_margin"), - info.get("initialMargin"), - ) - notional = _coerce_float(p.get("notional"), p.get("notionalValue")) - if notional is None or notional <= 0: - notional = _coerce_float(info.get("value")) - if notional is not None: - notional = abs(notional) - # 全仓且 API margin 为 0 时:用名义/杠杆粗算展示(与交易所「约占用」接近) - if (initial is None or initial <= 0) and notional and notional > 0 and order_leverage: - try: - lev = float(order_leverage) - if lev > 0: - approx = notional / lev - if approx > 0: - initial = approx - except (TypeError, ValueError): - pass - unrealized = _coerce_float( - p.get("unrealizedPnl"), - info.get("unrealised_pnl"), - info.get("unrealized_pnl"), - ) - mark = _coerce_float(p.get("markPrice"), p.get("mark_price"), info.get("mark_price"), info.get("markPrice")) - out = {} - if initial is not None and initial > 0: - out["initial_margin"] = round(initial, 2) - if notional is not None and notional > 0: - out["notional"] = round(notional, 2) - if unrealized is not None: - out["unrealized_pnl"] = round(unrealized, 2) - if mark is not None and mark > 0: - out["mark_price"] = round(mark, 8) - if out: - sym = (p.get("symbol") or "").strip() - try: - cs = float(get_contract_size(sym)) if sym else 1.0 - except Exception: - cs = 1.0 - from lib.hub.hub_position_metrics import enrich_ccxt_position_metrics_out - - enrich_ccxt_position_metrics_out(p, out, contract_size=cs, funds_decimals=2) - return out or None - - -def get_live_position_exchange_metrics(exchange_symbol, direction, order_leverage=None): - ensure_markets_loaded() - if not exchange_private_api_configured() or not exchange_symbol: - return None - try: - rows = exchange.fetch_positions(None, {"settle": "usdt"}) or [] - except Exception: - try: - rows = exchange.fetch_positions([exchange_symbol]) or [] - except Exception: - return None - p = _select_live_position_row(rows, exchange_symbol, direction) - return parse_ccxt_position_metrics(p, order_leverage=order_leverage) - - -def _order_row_exchange_margin_usdt(row): - if not row: - return None - try: - keys = row.keys() - except Exception: - return None - if "exchange_margin_usdt" not in keys: - return None - v = row["exchange_margin_usdt"] - if v is None: - return None - try: - x = float(v) - except (TypeError, ValueError): - return None - return x if x > 0 else None - - -def margin_capital_for_trade_record(order_row): - """trade_records.基数:优先交易所持仓保证金快照,旧数据无快照时回退计划保证金。""" - ex = _order_row_exchange_margin_usdt(order_row) - if ex is not None: - return round(ex, 2) - if not order_row: - return None - try: - v = order_row["margin_capital"] - except (TypeError, KeyError, IndexError): - return None - if v is None: - return None - try: - return float(v) - except (TypeError, ValueError): - return None - - -def try_persist_exchange_margin_for_order(conn, order_id, exchange_symbol, direction, order_leverage=None, max_attempts=6, sleep_s=0.45): - """开仓成功后持仓可见时拉取交易所保证金并写入 order_monitors(平仓后无法再取)。""" - if not conn or not order_id or not exchange_private_api_configured(): - return False - direction = (direction or "long").lower() - ex_sym = (exchange_symbol or "").strip() - if not ex_sym: - return False - n = max(1, int(max_attempts)) - delay = max(0.05, float(sleep_s)) - for _ in range(n): - pm = get_live_position_exchange_metrics(ex_sym, direction, order_leverage=order_leverage) - if pm and pm.get("initial_margin") is not None: - try: - v = float(pm["initial_margin"]) - except (TypeError, ValueError): - v = 0.0 - if v > 0: - conn.execute( - "UPDATE order_monitors SET exchange_margin_usdt=? WHERE id=?", - (round(v, 4), int(order_id)), - ) - return True - time.sleep(delay) - return False - - -def opened_at_str_to_ms(opened_at_str): - if not opened_at_str: - return None - dt = parse_dt_for_trading_day(opened_at_str) - if dt is None: - return None - try: - aware = dt.replace(tzinfo=APP_TZ) - return int(aware.timestamp() * 1000) - except Exception: - return None - - -def _to_ms_with_fallback(ms_value, dt_str): - try: - if ms_value is not None and str(ms_value).strip() != "": - v = int(float(ms_value)) - if v > 0: - return v - except Exception: - pass - return opened_at_str_to_ms(dt_str) - - -def ms_to_app_local_str(ms): - if ms is None: - return app_now_str() - try: - dt = datetime.fromtimestamp(ms / 1000.0, tz=timezone.utc).astimezone(APP_TZ) - return dt.replace(tzinfo=None).strftime("%Y-%m-%d %H:%M:%S") - except Exception: - return app_now_str() - - -def classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_price): - """根据成交价相对止盈/止损位归类;无法可靠归类时返回 None。""" - try: - tp = float(take_profit) - sl = float(stop_loss) - ex = float(exit_price) - trig = float(trigger_price) - except (TypeError, ValueError): - return None - band = max(abs(trig) * 0.0008, abs(tp - sl) * 0.003, 1e-12) - if direction == "long": - if ex >= tp - band: - return "止盈" - if ex <= sl + band: - return "止损" - else: - if ex <= tp + band: - return "止盈" - if ex >= sl - band: - return "止损" - return None - - -def fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=None): - """取开仓以来最近一笔减仓成交(与方向一致);失败返回 None。""" - if not (GATE_API_KEY and GATE_API_SECRET): - return None - ensure_markets_loaded() - since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) - close_side = "sell" if direction == "long" else "buy" - - def pick_from_trades(trades): - if not trades: - return None - candidates = [] - for t in trades: - if (t.get("side") or "").lower() != close_side: - continue - info = t.get("info") or {} - if not isinstance(info, dict): - info = {} - pos_side = (info.get("posSide") or t.get("posSide") or "").lower() - if GATE_POS_MODE == "hedge": - if pos_side in ("long", "short") and pos_side != direction: - continue - ts = t.get("timestamp") - if ts is None: - continue - candidates.append(t) - if not candidates: - return None - return max(candidates, key=lambda x: x.get("timestamp") or 0) - - try: - trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=100) - hit = pick_from_trades(trades) - if hit is None and since_ms: - trades = exchange.fetch_my_trades(exchange_symbol, since=None, limit=100) - hit = pick_from_trades(trades) - if hit is not None: - return hit - except Exception: - pass - try: - from lib.exchange.gate_position_history_lib import pick_gate_position_close - - pos = pick_gate_position_close( - fetch_gate_positions_close_history(), - exchange_symbol, - direction, - opened_at_ms=since_ms, - ) - if pos: - return { - "price": None, - "timestamp": pos["close_ms"], - "side": close_side, - "_from_position_history": True, - "_realized_pnl": pos.get("pnl"), - "_sync_key": pos.get("sync_key"), - "_open_ms": pos.get("open_ms"), - } - except Exception: - pass - return None - - -def fetch_closing_fills_for_record(exchange_symbol, direction, opened_at_str, closed_at_str=None, opened_at_ms=None, closed_at_ms=None): - """ - 拉取某条历史记录对应的减仓成交(用于按 id 回填)。 - 返回按时间排序的成交列表。 - """ - if not (GATE_API_KEY and GATE_API_SECRET): - return [] - ensure_markets_loaded() - since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) - close_side = "sell" if direction == "long" else "buy" - closed_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str) if (closed_at_str or closed_at_ms is not None) else None - # 历史记录回填给一点缓冲,兼容成交落在记录时间附近的情况 - if closed_ms is not None: - closed_ms += 6 * 60 * 60 * 1000 - candidates = [] - all_side_candidates = [] - try: - trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=200) - except Exception: - trades = [] - if not trades and since_ms: - try: - trades = exchange.fetch_my_trades(exchange_symbol, since=None, limit=200) - except Exception: - trades = [] - for t in trades or []: - if (t.get("side") or "").lower() != close_side: - continue - ts = t.get("timestamp") - if ts is None: - continue - try: - ts = int(ts) - except Exception: - continue - if since_ms and ts < since_ms: - continue - if closed_ms and ts > closed_ms: - continue - info = t.get("info") or {} - if not isinstance(info, dict): - info = {} - pos_side = (info.get("posSide") or t.get("posSide") or "").lower() - if GATE_POS_MODE == "hedge": - if pos_side in ("long", "short") and pos_side != direction: - continue - all_side_candidates.append(t) - if since_ms and ts < since_ms: - continue - if closed_ms and ts > closed_ms: - continue - candidates.append(t) - candidates.sort(key=lambda x: x.get("timestamp") or 0) - if candidates: - return candidates - - # 严格窗口为空时,降级为“按平仓时间就近匹配”,降低时区/时间误差导致的回填失败。 - all_side_candidates.sort(key=lambda x: x.get("timestamp") or 0) - if not all_side_candidates: - return [] - if not closed_ms: - return all_side_candidates[-20:] - near = [] - for t in all_side_candidates: - ts = t.get("timestamp") - if ts is None: - continue - try: - delta = abs(int(ts) - int(closed_ms)) - except Exception: - continue - # 放宽到前后 7 天 - if delta <= 7 * 24 * 60 * 60 * 1000: - near.append((delta, t)) - if near: - near.sort(key=lambda x: x[0]) - picked = [x[1] for x in near[:20]] - picked.sort(key=lambda x: x.get("timestamp") or 0) - return picked - return all_side_candidates[-20:] - - -def fetch_all_position_fills_for_record( - exchange_symbol, direction, opened_at_str, closed_at_str=None, opened_at_ms=None, closed_at_ms=None -): - if not exchange_private_api_configured(): - return [] - ensure_markets_loaded() - since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) - closed_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str) if (closed_at_str or closed_at_ms is not None) else None - if closed_ms is not None: - closed_ms += 6 * 60 * 60 * 1000 - try: - trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=200) - except Exception: - trades = [] - if not trades and since_ms: - try: - trades = exchange.fetch_my_trades(exchange_symbol, since=None, limit=200) - except Exception: - trades = [] - return filter_position_lifecycle_fills( - trades or [], - direction, - since_ms, - closed_ms, - hedge_mode=(GATE_POS_MODE == "hedge"), - close_buffer_ms=0, - ) - - -def _attach_gate_trade_exchange_stats( - conn, trade_id, *, exchange_symbol, direction, opened_at_str, closed_at_str, opened_at_ms=None, closed_at_ms=None -): - if not exchange_private_api_configured(): - return - open_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) - close_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str) - contract_size = 1.0 - try: - ensure_markets_loaded() - contract_size = float(exchange.market(exchange_symbol).get("contractSize") or 1) - except Exception: - pass - - def _fetch(): - return fetch_all_position_fills_for_record( - exchange_symbol, direction, opened_at_str, closed_at_str, opened_at_ms=open_ms, closed_at_ms=close_ms - ) - - try: - attach_exchange_stats_to_trade(conn, trade_id, fetch_fills=_fetch, contract_size=contract_size) - except Exception: - pass - - -def calc_weighted_exit_price(trades): - if not trades: - return None - total_amount = 0.0 - weighted_sum = 0.0 - for t in trades: - try: - price = float(t.get("price") or 0) - amount = float(t.get("amount") or 0) - except Exception: - continue - if price <= 0: - continue - if amount <= 0: - amount = 1.0 - weighted_sum += price * amount - total_amount += amount - if total_amount <= 0: - return None - return weighted_sum / total_amount - - -def resolve_synced_flat_close(row, opened_at_str, opened_at_ms=None, *, prefer_manual=False): - """ - 交易所已无仓、本地仍为 active 时,推断平仓类型/时间/盈亏。 - 返回 (result, pnl_amount, closed_at_str, miss_reason)。 - """ - direction = row["direction"] - sym = row["symbol"] - trigger_price = row["trigger_price"] - stop_loss = row["stop_loss"] - take_profit = row["take_profit"] - margin_capital = row["margin_capital"] or DAILY_START_CAPITAL - leverage = row["leverage"] or infer_leverage(sym) - exchange_symbol = row["exchange_symbol"] or normalize_exchange_symbol(sym) - - trade = fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=opened_at_ms) - exit_px = None - closed_at_str = app_now_str() - if trade: - try: - exit_px = float(trade.get("price") or 0) or None - except (TypeError, ValueError): - exit_px = None - ts = trade.get("timestamp") - if ts: - closed_at_str = ms_to_app_local_str(int(ts)) - if trade.get("_from_position_history"): - pnl_hist = trade.get("_realized_pnl") - if pnl_hist is not None: - note = "中控平仓后按 Gate 平仓历史同步盈亏" if prefer_manual else "按 Gate 平仓历史同步盈亏" - res = "手动平仓" if prefer_manual else "外部平仓" - return (res, float(pnl_hist), closed_at_str, note) - - if exit_px is None or exit_px <= 0: - p = get_price(sym) - if p: - guessed = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, p) - if guessed: - pnl = calc_pnl(direction, trigger_price, p, margin_capital, leverage) - return ( - normalize_result_with_pnl(guessed, pnl), - pnl, - closed_at_str, - "未能拉取成交明细,按当前市价与止盈/止损位近似归类(建议核对交易所账单)", - ) - return ( - "外部平仓", - 0.0, - closed_at_str, - "检测到交易所仓位已关闭,且无法从成交记录还原平仓价", - ) - - result = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_px) - pnl = calc_pnl(direction, trigger_price, exit_px, margin_capital, leverage) - if prefer_manual: - return ( - "手动平仓", - pnl, - closed_at_str, - "中控平仓后按交易所成交记录同步", - ) - if result: - return ( - normalize_result_with_pnl(result, pnl), - pnl, - closed_at_str, - "按交易所成交记录同步为止盈/止损平仓", - ) - return ( - "外部平仓", - pnl, - closed_at_str, - "交易所已平仓,成交价不在计划止盈/止损带内(可能为手动或其他类型平仓)", - ) - - -def reconcile_hub_external_close(conn, symbol, direction): - """中控市价全平后:立即同步匹配 order_monitor,并读 Gate 平仓历史。""" - if not exchange_private_api_configured(): - return {"ok": False, "msg": "未配置 GATE_API_KEY / GATE_API_SECRET", "synced": 0} - from lib.exchange.gate_position_history_lib import unified_symbol_for_match - - sym_u = unified_symbol_for_match(symbol) - dir_l = (direction or "").strip().lower() - if dir_l not in ("long", "short"): - return {"ok": False, "msg": "side 须为 long 或 short", "synced": 0} - synced = 0 - rows = conn.execute( - "SELECT * FROM order_monitors WHERE status IN ('active', 'error')" - ).fetchall() - for r in rows: - if unified_symbol_for_match(r["symbol"]) != sym_u: - 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) - existing = conn.execute( - "SELECT id FROM trade_records WHERE symbol=? AND opened_at=? AND monitor_type=? LIMIT 1", - (r["symbol"], opened_at_chk, order_row_monitor_type(r)), - ).fetchone() - if existing: - conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (oid,)) - synced += 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 - global _RECONCILE_FLAT_STREAK - _RECONCILE_FLAT_STREAK.pop(oid, None) - cancel_gate_swap_trigger_orders(exchange_symbol) - opened_at = get_opened_at_value(r) - opened_at_ms = _to_ms_with_fallback(r["opened_at_ms"] if "opened_at_ms" in r.keys() else None, opened_at) - result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close( - r, opened_at, opened_at_ms=opened_at_ms, prefer_manual=True - ) - closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now() - hold_seconds = calc_hold_seconds(opened_at, closed_at_dt) - session_date = r["session_date"] or get_trading_day(closed_at_dt) - update_session_capital(conn, session_date, pnl_amount) - insert_trade_record( - conn, - symbol=r["symbol"], - monitor_type=trade_record_monitor_type(conn, r), - trend_plan_id=trend_plan_id_from_monitor_row(r), - key_signal_type=order_row_key_signal_type(r), - direction=r["direction"], - trigger_price=r["trigger_price"], - stop_loss=r["stop_loss"], - initial_stop_loss=r["initial_stop_loss"] or r["stop_loss"], - take_profit=r["take_profit"], - margin_capital=margin_capital_for_trade_record(r), - leverage=r["leverage"], - pnl_amount=pnl_amount, - hold_seconds=hold_seconds, - trade_style=r["trade_style"], - risk_amount=r["risk_amount"], - planned_rr=calc_rr_ratio(r["direction"], r["trigger_price"], r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]), - actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), - result=result, - miss_reason=handoff_trade_miss_reason(miss_reason, r), - opened_at=opened_at, - closed_at=closed_at, - ) - conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (r["id"],)) - clear_key_sizing_snapshot_if_flat(conn, r["session_date"] or get_trading_day()) - synced += 1 - try: - sync_trade_records_from_exchange(conn, force=True) - except Exception: - pass - return {"ok": True, "synced": synced} - - -def reconcile_external_closes(conn, days=None): - global _RECONCILE_FLAT_STREAK - if not exchange_private_api_configured(): - return 0 - if time.time() - _APP_STARTED_AT < RECONCILE_STARTUP_GRACE_SEC: - return 0 - synced_count = 0 - cutoff_ms = None - if days is not None: - try: - d = int(days) - if d > 0: - cutoff_ms = int((app_now() - timedelta(days=d)).timestamp() * 1000) - except Exception: - cutoff_ms = None - rows = conn.execute( - "SELECT * FROM order_monitors WHERE status IN ('active', 'error')" - ).fetchall() - for r in rows: - if cutoff_ms is not None: - opened_at_v = get_opened_at_value(r) - opened_ms = _to_ms_with_fallback(r["opened_at_ms"] if "opened_at_ms" in r.keys() else None, opened_at_v) - # 手动同步按最近 N 天过滤,避免把更早历史单误同步进来 - if opened_ms is None or opened_ms < cutoff_ms: - continue - oid = int(r["id"]) - if r["status"] == "error": - opened_at_chk = get_opened_at_value(r) - existing = conn.execute( - "SELECT id FROM trade_records WHERE symbol=? AND opened_at=? AND monitor_type=? LIMIT 1", - (r["symbol"], opened_at_chk, order_row_monitor_type(r)), - ).fetchone() - if existing: - conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (oid,)) - synced_count += 1 - continue - exchange_symbol = resolve_monitor_exchange_symbol(r) - live_contracts = get_live_position_contracts(exchange_symbol, r["direction"]) - if live_contracts is None: - _RECONCILE_FLAT_STREAK.pop(oid, None) - continue - if live_contracts > 0: - _RECONCILE_FLAT_STREAK.pop(oid, None) - continue - if r["status"] != "error": - streak = int(_RECONCILE_FLAT_STREAK.get(oid, 0)) + 1 - _RECONCILE_FLAT_STREAK[oid] = streak - if streak < RECONCILE_FLAT_CONFIRM_POLLS: - continue - _RECONCILE_FLAT_STREAK.pop(oid, None) - print( - f"[reconcile_external_closes] {r['symbol']} id={oid} " - f"flat x{streak} polls -> sync close" - ) - else: - _RECONCILE_FLAT_STREAK.pop(oid, None) - print( - f"[reconcile_external_closes] error recovery {r['symbol']} id={oid} flat -> sync close" - ) - cancel_gate_swap_trigger_orders(exchange_symbol) - opened_at = get_opened_at_value(r) - opened_at_ms = _to_ms_with_fallback(r["opened_at_ms"] if "opened_at_ms" in r.keys() else None, opened_at) - result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close(r, opened_at, opened_at_ms=opened_at_ms) - closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now() - hold_seconds = calc_hold_seconds(opened_at, closed_at_dt) - session_date = r["session_date"] or get_trading_day(closed_at_dt) - update_session_capital(conn, session_date, pnl_amount) - insert_trade_record( - conn, - symbol=r["symbol"], - monitor_type=trade_record_monitor_type(conn, r), - trend_plan_id=trend_plan_id_from_monitor_row(r), - key_signal_type=order_row_key_signal_type(r), - direction=r["direction"], - trigger_price=r["trigger_price"], - stop_loss=r["stop_loss"], - initial_stop_loss=r["initial_stop_loss"] or r["stop_loss"], - take_profit=r["take_profit"], - margin_capital=margin_capital_for_trade_record(r), - leverage=r["leverage"], - pnl_amount=pnl_amount, - hold_seconds=hold_seconds, - trade_style=r["trade_style"], - risk_amount=r["risk_amount"], - planned_rr=calc_rr_ratio(r["direction"], r["trigger_price"], r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]), - actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), - result=result, - miss_reason=handoff_trade_miss_reason(miss_reason, r), - opened_at=opened_at, - closed_at=closed_at, - ) - conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (r["id"],)) - clear_key_sizing_snapshot_if_flat(conn, r["session_date"] or get_trading_day()) - if result in ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓"): - send_wechat_msg( - build_wechat_close_message( - symbol=r["symbol"], - direction=r["direction"], - result=f"{result}(自动同步)", - pnl_amount=pnl_amount, - hold_seconds=hold_seconds, - trigger_price=r["trigger_price"], - current_price="-", - stop_loss=r["stop_loss"], - take_profit=r["take_profit"], - close_order_id="-", - extra_note=miss_reason, - ) - ) - else: - send_wechat_msg( - build_wechat_close_message( - symbol=r["symbol"], - direction=r["direction"], - result="外部平仓(自动同步)", - pnl_amount=pnl_amount, - hold_seconds=hold_seconds, - trigger_price=r["trigger_price"], - current_price="-", - stop_loss=r["stop_loss"], - take_profit=r["take_profit"], - close_order_id="-", - extra_note=miss_reason, - ) - ) - synced_count += 1 - return synced_count - -# 获取实时价格 -def get_price(symbol): - try: - ensure_markets_loaded() - return exchange.fetch_ticker(normalize_exchange_symbol(symbol))["last"] - except: - return None - -# 获取5分钟K线收盘价 -def get_5m_close(symbol): - try: - ensure_markets_loaded() - ohlcv = exchange.fetch_ohlcv(normalize_exchange_symbol(symbol), KLINE_TIMEFRAME, limit=1) - return ohlcv[-1][4] if ohlcv else None - except: - return None - - -def _safe_float(v): - try: - return float(v) - except Exception: - return None - - -def _compute_ema(values, period=55): - arr = [float(x) for x in values if x is not None] - if len(arr) < period: - return None - k = 2.0 / (period + 1.0) - ema = arr[0] - for val in arr[1:]: - ema = val * k + ema * (1 - k) - return ema - - -def _status_by_ema55(symbol, timeframe): - try: - bars = exchange.fetch_ohlcv(normalize_exchange_symbol(symbol), timeframe=timeframe, limit=80) - if not bars or len(bars) < 56: - return "横盘", None, None - closes = [float(x[4]) for x in bars if x and len(x) >= 5] - ema55 = _compute_ema(closes, 55) - last_close = closes[-1] - if ema55 is None or last_close <= 0: - return "横盘", last_close, ema55 - diff_pct = (last_close - ema55) / ema55 * 100.0 - if abs(diff_pct) < 0.1: - return "横盘", last_close, ema55 - return ("多头" if diff_pct > 0 else "空头"), last_close, ema55 - except Exception: - return "横盘", None, None - - -def _daily_volume_rank(symbol): - """ - 返回(symbol_rank, total_count),按 USDT 永续 24h 成交额降序。 - 走 hub_volume_rank_lib 轻量 ticker API,避免 fetch_tickers() 全市场拉取。 - """ - sym_norm = normalize_symbol_input(symbol) - target_base = journal_coin_from_symbol(sym_norm) - return resolve_daily_volume_rank( - target_base, - LIQUIDITY_RANK_CACHE, - now_ts=time.time(), - ttl_sec=max(30, BALANCE_REFRESH_SECONDS), - exchange=exchange, - ensure_markets_loaded=ensure_markets_loaded, - ) - - -def _key_hard_checks(symbol, direction, upper, lower, monitor_type): - """ - 关键位门控:量能、突破幅度、第二根确认、日成交量前30。 - 使用最近闭合K:breakout=倒数第2根,confirm=倒数第1根。 - """ - out = {"ok": False} - ex_sym = normalize_exchange_symbol(symbol) - bars = exchange.fetch_ohlcv(ex_sym, timeframe=KLINE_TIMEFRAME, limit=80) or [] - if len(bars) < 24: - out["reason"] = "5m K线数量不足" - return out - closed = bars[:-1] if len(bars) >= 3 else bars - min_closed = KEY_VOLUME_MA_BARS + 3 - if len(closed) < min_closed: - out["reason"] = f"{KLINE_TIMEFRAME} 闭合K线不足" - return out - try: - breakout = closed[KEY_CONFIRM_BREAKOUT_BAR] - confirm = closed[KEY_CONFIRM_BAR] - except IndexError: - out["reason"] = "确认K索引超出范围,请检查 KEY_CONFIRM_* 配置" - return out - prev_vol = closed[KEY_CONFIRM_BREAKOUT_BAR - KEY_VOLUME_MA_BARS : KEY_CONFIRM_BREAKOUT_BAR] - avg20 = sum(float(x[5]) for x in prev_vol) / max(len(prev_vol), 1) - vol_break = float(breakout[5]) - vol_ok = vol_break > avg20 * KEY_VOLUME_RATIO_MIN if avg20 > 0 else False - close_b = float(breakout[4]) - high_b = float(breakout[2]) - low_b = float(breakout[3]) - cfm_close = float(confirm[4]) - edge = float(upper) if direction == "long" else float(lower) - breakout_ok = (close_b > float(upper)) if direction == "long" else (close_b < float(lower)) - amp_ok, amp_pct = auto_amp_ok( - direction, close_b, float(upper), float(lower), KEY_BREAKOUT_AMP_MIN_PCT - ) - amp_ok = amp_ok and breakout_ok - confirm_ok_raw = auto_confirm_ok(direction, cfm_close, float(upper), float(lower)) - confirm_ok = confirm_ok_raw and breakout_ok - rank, total = _daily_volume_rank(symbol) - rank_ok = (rank is not None) and (rank <= KEY_DAILY_VOLUME_RANK_MAX) - swing4h_pct = 0.0 - try: - seg48 = closed[-48:] if len(closed) >= 48 else closed - hh = max(float(x[2]) for x in seg48) - ll = min(float(x[3]) for x in seg48) - swing4h_pct = ((hh - ll) / ll * 100.0) if ll > 0 else 0.0 - except Exception: - swing4h_pct = 0.0 - out.update( - { - "ok": all([vol_ok, amp_ok, breakout_ok, confirm_ok, rank_ok]), - "vol_ok": vol_ok, - "avg20": avg20, - "vol_break": vol_break, - "amp_ok": amp_ok, - "amp_pct": amp_pct, - "breakout_ok": breakout_ok, - "breakout_close": close_b, - "confirm_ok": confirm_ok, - "confirm_close": cfm_close, - "edge_price": edge, - "rank": rank, - "rank_total": total, - "rank_ok": rank_ok, - "breakout_high": high_b, - "breakout_low": low_b, - "breakout_ts": breakout[0], - "confirm_ts": confirm[0], - "swing4h_pct": swing4h_pct, - "monitor_type": monitor_type, - "direction": direction, - } - ) - return out - - -def calc_price_diff_pct(current_price, target_price): - try: - if target_price is None: - return None, None - t = float(target_price) - if t == 0: - return None, None - c = float(current_price) - diff = c - t - pct = diff / t * 100 - return round(diff, 6), round(pct, 4) - except Exception: - return None, None - - -def _finalize_key_monitor_one_shot(conn, row, last_msg, close_reason): - """本条关键位一次性结案:写历史并从当前表删除。""" - n = int(row["notification_count"] or 0) + 1 - insert_key_monitor_history(conn, row, n, last_msg, close_reason) - conn.execute("DELETE FROM key_monitors WHERE id=?", (row["id"],)) - - -def _fetch_last_closed_bar(symbol): - """最近一根闭合 K:[ts, o, h, l, c, v] 或 None。""" - ex_sym = normalize_exchange_symbol(symbol) - bars = exchange.fetch_ohlcv(ex_sym, timeframe=KLINE_TIMEFRAME, limit=5) or [] - if len(bars) < 2: - return None - closed = bars[:-1] - return closed[-1] if closed else None - - -def _key_rs_gate_preview(symbol, upper, lower): - """页面门控预览:阻力/支撑仅显示距上/下沿与是否已越线。""" - bar = _fetch_last_closed_bar(symbol) - if not bar: - return {"summary": "5m数据不足", "metrics": ""} - close = float(bar[4]) - br = detect_rs_box_break(close, upper, lower) - if br: - return { - "summary": f"已越线:{br['break_label']}", - "metrics": f"收盘:{format_price_for_symbol(symbol, close)}", - } - return { - "summary": "待突破", - "metrics": f"收盘:{format_price_for_symbol(symbol, close)}", - } - - -def _process_key_rs_level_alert(conn, row): - """关键阻力位/支撑位:5m 收盘越上沿或下沿后,按间隔推送最多 KEY_ALERT_MAX_TIMES 次。""" - sym = row["symbol"] - typ = (row["monitor_type"] or "").strip() - up, low = float(row["upper"]), float(row["lower"]) - if up <= low: - return - bar = _fetch_last_closed_bar(sym) - if not bar: - return - close = float(bar[4]) - ts = bar[0] - now_dt = app_now() - tick = run_rs_level_alert_tick( - row, - close, - ts, - now_dt, - default_max_notify=KEY_ALERT_MAX_TIMES, - default_interval_min=KEY_ALERT_INTERVAL_MINUTES, - ) - if not tick: - return - - br = tick["break_info"] - notify_index = int(tick["notify_index"]) - max_n = int(tick["notify_max"]) - interval = int(tick["interval_min"]) - bar_ts = tick.get("bar_ts") - prior_count = int(tick.get("prior_count", notify_index - 1)) - - notified_at = app_now_str() - if not claim_rs_level_notify( - conn, - row["id"], - notify_index, - br["direction"], - notified_at, - bar_ts, - prior_count=prior_count, - ): - return - conn.commit() - - trigger_time = ms_to_app_local_str(int(ts)) if ts else app_now_str() - msg = build_wechat_rs_level_message( - symbol=sym, - monitor_type=typ, - account_label=_wechat_account_label(), - trigger_time=trigger_time, - upper_txt=format_price_for_symbol(sym, up), - lower_txt=format_price_for_symbol(sym, low), - close_txt=format_price_for_symbol(sym, close), - edge_txt=format_price_for_symbol(sym, br["edge_price"]), - break_label=br["break_label"], - direction=br["direction"], - notify_index=notify_index, - notify_max=max_n, - interval_min=interval, - ) - send_wechat_msg(msg) - conn.execute( - "UPDATE key_monitors SET last_alert_message=? WHERE id=?", - (msg, row["id"]), - ) - conn.commit() - if notify_index >= max_n: - hist_row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (row["id"],)).fetchone() - if hist_row: - insert_key_monitor_history(conn, hist_row, notify_index, msg, "key_level_alert_done") - conn.execute("DELETE FROM key_monitors WHERE id=?", (row["id"],)) - conn.commit() - - -def _key_hard_lines_from_checks(checks): - direction = (checks.get("direction") or "long").lower() - return [ - f"量能:{'通过' if checks['vol_ok'] else '不通过'}(突破K量 {round(checks['vol_break'], 4)} / 前20均量 {round(checks['avg20'], 4)},阈值1.3x)", - f"突破价位:{'通过' if checks['breakout_ok'] else '不通过'}(突破K收盘 {round(float(checks['breakout_close']), 8)},关键位 {checks['edge_price']})", - format_auto_amp_line(checks["amp_ok"], checks["amp_pct"], KEY_BREAKOUT_AMP_MIN_PCT), - format_auto_confirm_line( - checks["confirm_ok"], checks["confirm_close"], checks["edge_price"], direction - ), - f"日成交量排名:{'通过' if checks['rank_ok'] else '不通过'}({checks['rank']}/{checks['rank_total']},要求前{KEY_DAILY_VOLUME_RANK_MAX})", - ] - - -def _key_plan_sl_tp_for_row(row, direction, upper, lower, checks): - """按 key_monitors 录入的方案计算计划 SL/TP。""" - mode = sl_tp_mode_from_row(row, "standard") - manual_tp = _sqlite_row_val(row, "manual_take_profit") - planned = plan_key_sl_tp( - mode, - direction, - upper, - lower, - checks, - outside_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT, - trend_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT, - manual_take_profit=manual_tp, - ) - return planned, mode - - -def _market_open_for_key_monitor( - conn, - symbol, - direction, - exchange_symbol, - stop_loss, - take_profit, - key_signal_type=None, - breakeven_enabled=0, - time_close_enabled=0, - time_close_hours=None, -): - """ - 与手动「实盘下单」对齐的市价开仓与 order_monitors 写入。 - 返回 (ok: bool, err_msg: Optional[str], detail: Optional[dict]) - """ - ok_src, src_msg = assert_open_source_allowed(POSITION_SIZING_MODE, OPEN_SOURCE_KEY_AUTO) - if not ok_src: - return False, src_msg, None - now = app_now() - ok, reason = precheck_risk(conn, symbol, direction) - if not ok: - return False, f"风控拒绝下单:{reason}", None - ok_live, reason_live = ensure_exchange_live_ready() - if not ok_live: - return False, reason_live, None - - default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) - leverage = int(default_leverage) if default_leverage else 5 - if leverage <= 0: - leverage = 5 - - trading_day = get_trading_day(now) - opens_today_before = conn.execute( - "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", - (trading_day,), - ).fetchone()[0] - session_row = ensure_session(conn, trading_day) - _, trading_capital_live = get_exchange_capitals(force=True) - live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"]) - capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital) - - trade_style = (DEFAULT_TRADE_STYLE or "trend").strip().lower() - if trade_style not in ("trend", "swing"): - trade_style = "trend" - - available_usdt = get_available_trading_usdt() - live_price = get_price(symbol) - if live_price is None: - return False, "获取交易所实时价格失败(以损定仓需要当前价)", None - try: - ensure_markets_loaded() - except Exception: - pass - lp_r = round_price_to_exchange(exchange_symbol, live_price) - if lp_r is not None: - live_price = lp_r - - sl_adj = round_price_to_exchange(exchange_symbol, float(stop_loss)) - tp_adj = round_price_to_exchange(exchange_symbol, float(take_profit)) - if sl_adj is not None: - stop_loss = float(sl_adj) - if tp_adj is not None: - take_profit = float(tp_adj) - - risk_fraction = calc_risk_fraction(direction, live_price, stop_loss) - if risk_fraction is None: - return False, "止损方向不合法(相对当前市价);请核对上下沿与方向", None - risk_percent = max(0.01, float(RISK_PERCENT)) - risk_amount = round(capital_base * risk_percent / 100.0, 4) - notional_value = round(risk_amount / risk_fraction, 4) - margin_capital = round(notional_value / leverage, 4) - - if capital_base and margin_capital > capital_base: - return False, "以损定仓后保证金超过当前交易资金", None - - if available_usdt is not None: - max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4) - if margin_capital > max_margin: - return ( - False, - f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", - None, - ) - - position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0 - - try: - amount, quote_price = prepare_order_amount(exchange_symbol, margin_capital, leverage, live_price) - contract_size = get_contract_size(exchange_symbol) - base_amount = round(float(amount) * contract_size, 8) - order_resp = place_exchange_order( - exchange_symbol, direction, amount, leverage, - stop_loss=stop_loss, take_profit=take_profit, - ) - open_order_id = order_resp.get("id", "") - tpsl_attached = bool(order_resp.get("tpsl_attached")) - trigger_price = resolve_order_entry_price(order_resp, exchange_symbol, quote_price) - except Exception as e: - return False, friendly_exchange_error(e, available_usdt=available_usdt), None - - trigger_price = round_price_to_exchange(exchange_symbol, trigger_price) - stop_loss = round_price_to_exchange(exchange_symbol, stop_loss) - take_profit = round_price_to_exchange(exchange_symbol, take_profit) - - opened_at_bj = app_now_str() - opened_at_ms = _to_ms_with_fallback(None, opened_at_bj) - - planned_rr = calc_rr_ratio(direction, trigger_price, stop_loss, take_profit) - breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER) - breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT) - breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0 - risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage) - if risk_amount_final is None: - risk_amount_final = risk_amount - else: - try: - risk_amount_final = round(float(risk_amount_final), 4) - except (TypeError, ValueError): - risk_amount_final = risk_amount - - if direction == "short": - breakeven_raw = float(trigger_price) * (1 - breakeven_offset_pct / 100.0) - else: - breakeven_raw = float(trigger_price) * (1 + breakeven_offset_pct / 100.0) - breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw) - be_enabled = 1 if int(breakeven_enabled or 0) != 0 else 0 - tc_en, tc_h, tc_at = time_close_insert_values( - time_close_enabled, time_close_hours, opened_at_ms - ) - - conn.execute( - "INSERT INTO order_monitors " - "(symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, " - "margin_capital, leverage, trade_style, risk_percent, risk_amount, " - "breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, " - "notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type, key_signal_type, " - "time_close_enabled, time_close_hours, time_close_at_ms) " - "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", - ( - symbol, - exchange_symbol, - direction, - trigger_price, - stop_loss, - stop_loss, - take_profit, - margin_capital, - leverage, - trade_style, - risk_percent, - risk_amount_final, - breakeven_rr_trigger, - breakeven_offset_pct, - breakeven_step_r, - 0, - breakeven_price, - be_enabled, - notional_value, - position_ratio, - base_amount, - amount, - open_order_id, - opened_at_bj, - opened_at_ms, - trading_day, - ORDER_MONITOR_TYPE_KEY_AUTO, - stored_key_signal_type(key_signal_type), - tc_en, - tc_h, - tc_at, - ), - ) - new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) - try_persist_exchange_margin_for_order(conn, new_order_id, exchange_symbol, direction, order_leverage=leverage) - opens_today_after = conn.execute( - "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", - (trading_day,), - ).fetchone()[0] - - return True, None, { - "new_order_id": new_order_id, - "open_order_id": open_order_id, - "trigger_price": trigger_price, - "planned_rr_fill": planned_rr, - "risk_amount_final": risk_amount_final, - "margin_capital": margin_capital, - "leverage": leverage, - "amount": amount, - "base_amount": base_amount, - "notional_value": notional_value, - "position_ratio": position_ratio, - "tpsl_attached": tpsl_attached, - "opens_today_before": opens_today_before, - "opens_today_after": opens_today_after, - "trading_day": trading_day, - "risk_percent": risk_percent, - "breakeven_rr_trigger": breakeven_rr_trigger, - "breakeven_price": breakeven_price, - "capital_base_at_open": capital_base, - } - - -def _sqlite_row_val(row, key, default=None): - try: - v = row[key] - return default if v is None else v - except (KeyError, IndexError, TypeError): - return default - - -def get_symbol_mark_price(symbol): - """斐波失效判定用标记价。""" - ex_sym = normalize_exchange_symbol(symbol) - try: - ensure_markets_loaded() - ticker = exchange.fetch_ticker(ex_sym) - m = _coerce_float(ticker.get("mark"), ticker.get("last")) - if m is None: - info = ticker.get("info") or {} - m = _coerce_float(info.get("mark_price"), info.get("last")) - if m is not None and m > 0: - return float(m) - except Exception: - pass - p = get_price(symbol) - return float(p) if p is not None else None - - -def cancel_fib_limit_order(exchange_symbol, order_id): - """仅撤销本条斐波限价单,不用 cancel_all。""" - if not order_id: - return False - ok_live, _ = ensure_exchange_live_ready() - if not ok_live: - return False - ensure_markets_loaded() - oid = str(order_id) - try: - exchange.cancel_order(oid, exchange_symbol) - return True - except Exception: - pass - try: - for o in exchange.fetch_open_orders(exchange_symbol) or []: - if str(o.get("id")) == oid: - exchange.cancel_order(oid, exchange_symbol) - return True - except Exception: - pass - return False - - -def fib_limit_order_status(exchange_symbol, order_id): - if not order_id: - return "missing" - ensure_markets_loaded() - oid = str(order_id) - try: - o = exchange.fetch_order(oid, exchange_symbol) - st = (o.get("status") or "").lower() - if st in ("closed", "filled"): - filled = float(o.get("filled") or 0) - if filled > 0 or st == "filled": - return "filled" - if st in ("canceled", "cancelled", "expired", "rejected"): - return "canceled" - if st in ("open", "new", "partially_filled"): - return "open" - except Exception: - pass - try: - for o in exchange.fetch_open_orders(exchange_symbol) or []: - if str(o.get("id")) == oid: - return "open" - except Exception: - pass - return "unknown" - - -def place_fib_limit_order(exchange_symbol, direction, amount, leverage, limit_price): - ensure_markets_loaded() - exchange.set_leverage(leverage, exchange_symbol) - side = "buy" if direction == "long" else "sell" - price = round_price_to_exchange(exchange_symbol, float(limit_price)) - if price is None or price <= 0: - raise ValueError("挂单价无效") - params = build_gate_order_params(direction, reduce_only=False) - return exchange.create_order(exchange_symbol, "limit", side, amount, price, params) - - -def _fib_key_exists_for_symbol(conn, symbol): - ph = ",".join("?" * len(FIB_KEY_MONITOR_TYPES)) - row = conn.execute( - f"SELECT id FROM key_monitors WHERE symbol=? AND monitor_type IN ({ph})", - (symbol, *tuple(FIB_KEY_MONITOR_TYPES)), - ).fetchone() - return row is not None - - -def _fib_plan_for_row(row): - typ = (row["monitor_type"] or "").strip() - ratio = fib_ratio_from_type(typ) - if ratio is None: - return None - return calc_fib_plan(row["direction"], row["upper"], row["lower"], ratio) - - -def _limit_key_plan_for_row(row): - typ = (row["monitor_type"] or "").strip() - if is_fib_key_monitor_type(typ): - return _fib_plan_for_row(row) - if is_false_breakout_key_monitor_type(typ): - direction = (row["direction"] or "long").lower() - key_px = key_price_from_row(direction, row["upper"], row["lower"]) - if key_px is None: - return None - return calc_false_breakout_plan(direction, key_px) - return None - - -def _cancel_fib_monitor_limit(row): - ex_sym = normalize_exchange_symbol(row["symbol"]) - oid = _sqlite_row_val(row, "fib_limit_order_id") - if oid: - cancel_fib_limit_order(ex_sym, oid) - - -def _fib_has_live_position(exchange_symbol, direction): - live = get_live_position_contracts(exchange_symbol, direction) - return live is not None and float(live) > 0 - - -def _insert_order_monitor_from_fib_fill( - conn, row, trigger_price, stop_loss, take_profit, amount, leverage, margin_capital, - notional_value, position_ratio, base_amount, exchange_order_id, tpsl_attached, -): - symbol = row["symbol"] - direction = (row["direction"] or "long").lower() - exchange_symbol = normalize_exchange_symbol(symbol) - typ = (row["monitor_type"] or "").strip() - now = app_now() - trading_day = get_trading_day(now) - trade_style = (DEFAULT_TRADE_STYLE or "trend").strip().lower() - if trade_style not in ("trend", "swing"): - trade_style = "trend" - risk_percent = max(0.01, float(RISK_PERCENT)) - risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage) - if risk_amount_final is None: - risk_amount_final = round(float(margin_capital) * risk_percent / 100.0, 4) - breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER) - breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT) - breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0 - if direction == "short": - breakeven_raw = float(trigger_price) * (1 - breakeven_offset_pct / 100.0) - else: - breakeven_raw = float(trigger_price) * (1 + breakeven_offset_pct / 100.0) - breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw) - opened_at_bj = app_now_str() - opened_at_ms = _to_ms_with_fallback(None, opened_at_bj) - tc_en, tc_h, _ = time_close_settings_from_row(row) - tc_en, tc_h, tc_at = time_close_insert_values(tc_en, tc_h, opened_at_ms) - conn.execute( - "INSERT INTO order_monitors " - "(symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, " - "margin_capital, leverage, trade_style, risk_percent, risk_amount, " - "breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, " - "notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type, key_signal_type, " - "time_close_enabled, time_close_hours, time_close_at_ms) " - "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", - ( - symbol, - exchange_symbol, - direction, - trigger_price, - stop_loss, - stop_loss, - take_profit, - margin_capital, - leverage, - trade_style, - risk_percent, - risk_amount_final, - breakeven_rr_trigger, - breakeven_offset_pct, - breakeven_step_r, - 0, - breakeven_price, - 1 if breakeven_enabled_from_row(row, 0) else 0, - notional_value, - position_ratio, - base_amount, - amount, - exchange_order_id or "", - opened_at_bj, - opened_at_ms, - trading_day, - ORDER_MONITOR_TYPE_KEY_AUTO, - stored_key_signal_type(typ), - tc_en, - tc_h, - tc_at, - ), - ) - new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) - try_persist_exchange_margin_for_order(conn, new_order_id, exchange_symbol, direction, order_leverage=leverage) - return new_order_id - - -def _finalize_fib_key_fill(conn, row): - symbol = row["symbol"] - direction = (row["direction"] or "long").lower() - typ = (row["monitor_type"] or "").strip() - kind = "假突破" if is_false_breakout_key_monitor_type(typ) else "斐波" - ex_sym = normalize_exchange_symbol(symbol) - plan = _limit_key_plan_for_row(row) - if not plan: - _finalize_key_monitor_one_shot(conn, row, f"{kind}计划无效", "fib_plan_invalid") - return - entry_plan, sl_plan, tp_plan = plan - sl = float(_sqlite_row_val(row, "fib_stop_loss", sl_plan) or sl_plan) - tp = float(_sqlite_row_val(row, "fib_take_profit", tp_plan) or tp_plan) - sl_adj = round_price_to_exchange(ex_sym, sl) - tp_adj = round_price_to_exchange(ex_sym, tp) - if sl_adj is not None: - sl = float(sl_adj) - if tp_adj is not None: - tp = float(tp_adj) - amount = float(_sqlite_row_val(row, "fib_order_amount") or 0) - leverage = int(_sqlite_row_val(row, "fib_leverage") or infer_leverage(symbol) or 5) - margin_capital = float(_sqlite_row_val(row, "fib_margin_capital") or 0) - oid = _sqlite_row_val(row, "fib_limit_order_id") - entry_px = float(_sqlite_row_val(row, "fib_entry_price", entry_plan) or entry_plan) - trigger_price = entry_px - if oid: - try: - o = exchange.fetch_order(str(oid), ex_sym) - trigger_price = resolve_order_entry_price(o, ex_sym, entry_px) - except Exception: - pass - tr_adj = round_price_to_exchange(ex_sym, trigger_price) - if tr_adj is not None: - trigger_price = float(tr_adj) - if amount <= 0: - live_amt = get_live_position_contracts(ex_sym, direction) - amount = float(live_amt or 0) - if amount <= 0: - send_wechat_msg( - f"# ❌ {symbol} {kind}成交后处理失败\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 无法取得持仓/下单数量,未挂 TP/SL\n" - ) - return - ok, reason = precheck_risk(conn, symbol, direction) - if not ok: - send_wechat_msg( - f"# ❌ {symbol} {kind}成交后风控拒绝\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 类型:{typ}\n" - f"- 原因:{reason}\n" - f"- 请手动处理仓位与挂单\n" - ) - return - tpsl_attached = False - try: - _gate_place_tp_sl_orders(ex_sym, direction, amount, sl, tp) - tpsl_attached = True - except Exception as e: - send_wechat_msg( - f"# ❌ {symbol} {kind}成交后挂 TP/SL 失败\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 错误:{friendly_exchange_error(e)}\n" - f"- 请手动补挂止盈止损\n" - ) - return - contract_size = get_contract_size(ex_sym) - base_amount = round(float(amount) * contract_size, 8) - notional_value = round(float(margin_capital) * leverage, 4) if margin_capital else 0 - session_row = ensure_session(conn, get_trading_day(app_now())) - capital_base = float(session_row["current_capital"] or 0) - position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base and margin_capital else 0 - planned_rr = calc_rr_ratio(direction, trigger_price, sl, tp) - new_order_id = _insert_order_monitor_from_fib_fill( - conn, row, trigger_price, sl, tp, amount, leverage, margin_capital, - notional_value, position_ratio, base_amount, oid, tpsl_attached, - ) - rr_txt = format_wechat_scalar_2dp(planned_rr) if planned_rr is not None else "-" - close_reason = "false_breakout_filled" if is_false_breakout_key_monitor_type(typ) else "fib_filled" - succ = ( - f"# ✅ {symbol} {kind}限价成交\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 来源:{ORDER_MONITOR_TYPE_KEY_AUTO}(限价 @ E)\n" - f"- 类型:{typ}|{_wechat_direction_text(direction)}\n" - f"- 订单 ID:**{new_order_id}**\n" - f"- 成交价:{format_price_for_symbol(symbol, trigger_price)}\n" - f"- 止损:{format_wechat_scalar_2dp(sl)}|止盈:{format_price_for_symbol(symbol, tp)}\n" - f"- 计划 RR:{rr_txt}:1\n" - f"- {'已挂交易所 TP/SL' if tpsl_attached else 'TP/SL 未挂上'}\n" - ) - send_wechat_msg(succ) - _finalize_key_monitor_one_shot(conn, row, succ, close_reason) - - -def _trigger_entry_exists_for_symbol(conn, symbol): - placeholders = ",".join("?" * len(TRIGGER_ENTRY_MONITOR_TYPES)) - row = conn.execute( - f"SELECT id FROM key_monitors WHERE symbol=? AND monitor_type IN ({placeholders})", - (symbol, *TRIGGER_ENTRY_MONITOR_TYPES), - ).fetchone() - return row is not None - - -def _add_trigger_entry_key_monitor( - conn, - symbol, - direction_sel, - entry, - sl, - tp, - monitor_type=CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE, - breakeven_enabled=0, - time_close_enabled=0, - time_close_hours=None, -): - mt = (monitor_type or CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE).strip() - if mt not in TRIGGER_ENTRY_MONITOR_TYPES: - mt = CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE - if _trigger_entry_exists_for_symbol(conn, symbol): - return False, f"{symbol} 已有触价开仓监控(同币仅允许一条)" - ex_sym = normalize_exchange_symbol(symbol) - mark = get_symbol_mark_price(symbol) - geom_err = validate_trigger_entry_geometry( - direction_sel, entry, sl, tp, mark_at_add=mark, monitor_type=mt - ) - if geom_err: - return False, geom_err - rr_err = validate_trigger_entry_rr( - direction_sel, entry, sl, tp, KEY_AUTO_MIN_PLANNED_RR, calc_rr_ratio - ) - if rr_err: - return False, rr_err - entry = float(round_price_to_exchange(ex_sym, entry) or entry) - sl = float(round_price_to_exchange(ex_sym, sl) or sl) - tp = float(round_price_to_exchange(ex_sym, tp) or tp) - geom_err = validate_trigger_entry_geometry( - direction_sel, entry, sl, tp, mark_at_add=mark, monitor_type=mt - ) - if geom_err: - return False, geom_err - rr_err = validate_trigger_entry_rr( - direction_sel, entry, sl, tp, KEY_AUTO_MIN_PLANNED_RR, calc_rr_ratio - ) - if rr_err: - return False, rr_err - ok_live, reason_live = ensure_exchange_live_ready() - if not ok_live: - return False, reason_live - now = app_now() - trading_day = get_trading_day(now) - opens_today = count_opens_for_trading_day(conn, trading_day) - ok_intent, intent_msg = check_trigger_entry_intent_limit( - conn, trading_day, opens_today, DAILY_OPEN_HARD_LIMIT - ) - if not ok_intent: - return False, intent_msg - if is_full_margin_mode(POSITION_SIZING_MODE): - ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn)) - if not ok_flat: - return False, flat_msg - if count_pending_trigger_entries(conn, trading_day) > 0: - return False, "全仓杠杆模式下仅允许一条待触发触价监控" - session_row = ensure_session(conn, trading_day) - _, trading_capital_live = get_exchange_capitals(force=True) - live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"]) - capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital) - available_usdt = get_available_trading_usdt() - if is_full_margin_mode(POSITION_SIZING_MODE): - leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE) - sizing, sizing_err = compute_full_margin_sizing( - symbol=symbol, - available_usdt=available_usdt if available_usdt is not None else 0.0, - capital_base=capital_base, - buffer_ratio=FULL_MARGIN_BUFFER_RATIO, - btc_leverage=BTC_LEVERAGE, - alt_leverage=ALT_LEVERAGE, - funds_decimals=2, - ) - if sizing_err: - return False, sizing_err - margin_capital = float(sizing["margin_capital"]) - amount_plan = None - else: - default_leverage = get_synced_leverage(ex_sym, direction_sel) or infer_leverage(symbol) - leverage = int(default_leverage) if default_leverage else 5 - if leverage <= 0: - leverage = 5 - risk_fraction = calc_risk_fraction(direction_sel, entry, sl) - if risk_fraction is None: - return False, "止损方向不合法(相对计划入场价)" - risk_percent = max(0.01, float(RISK_PERCENT)) - risk_amount = round(capital_base * risk_percent / 100.0, 4) - notional_value = round(risk_amount / risk_fraction, 4) - margin_capital = round(notional_value / leverage, 4) - if capital_base and margin_capital > capital_base: - return False, "以损定仓后保证金超过当前交易资金" - if available_usdt is not None: - max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4) - if margin_capital > max_margin: - return ( - False, - f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", - ) - try: - amount_plan, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry) - except Exception as e: - return False, friendly_exchange_error(e, available_usdt=available_usdt) - upper_px = round_price_to_exchange(ex_sym, max(entry, tp)) - lower_px = round_price_to_exchange(ex_sym, min(entry, sl)) - if upper_px is None or lower_px is None or float(upper_px) <= float(lower_px): - upper_px, lower_px = float(max(entry, tp, sl)), float(min(entry, tp, sl)) - if upper_px <= lower_px: - lower_px = upper_px * 0.9999 - be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0 - tc_en, tc_h, _ = time_close_insert_values(time_close_enabled, time_close_hours, None) - conn.execute( - "INSERT INTO key_monitors " - "(symbol, monitor_type, direction, upper, lower, " - "fib_entry_price, fib_stop_loss, fib_take_profit, " - "fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled, " - "time_close_enabled, time_close_hours, session_date) " - "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", - ( - symbol, - mt, - direction_sel, - float(upper_px), - float(lower_px), - entry, - sl, - tp, - float(amount_plan) if amount_plan is not None else None, - margin_capital, - leverage, - be_flag, - tc_en, - tc_h, - trading_day, - ), - ) - return True, None - - -def _market_open_for_trigger_entry( - conn, - symbol, - direction, - exchange_symbol, - entry_price, - stop_loss, - take_profit, - monitor_type=CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE, - breakeven_enabled=0, - time_close_enabled=0, - time_close_hours=None, -): - """触价触发后市价开仓,计仓规则与实盘下单/关键位 RR 门槛一致。""" - ok_src, src_msg = assert_open_source_allowed(POSITION_SIZING_MODE, OPEN_SOURCE_KEY_TRIGGER) - if not ok_src: - return False, src_msg, None - now = app_now() - ok, reason = precheck_risk(conn, symbol, direction) - if not ok: - return False, f"风控拒绝下单:{reason}", None - ok_live, reason_live = ensure_exchange_live_ready() - if not ok_live: - return False, reason_live, None - - trading_day = get_trading_day(now) - opens_today_before = count_opens_for_trading_day(conn, trading_day) - session_row = ensure_session(conn, trading_day) - _, trading_capital_live = get_exchange_capitals(force=True) - live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"]) - capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital) - - trade_style = (DEFAULT_TRADE_STYLE or "trend").strip().lower() - if trade_style not in ("trend", "swing"): - trade_style = "trend" - - available_usdt = get_available_trading_usdt() - live_price = get_symbol_mark_price(symbol) or get_price(symbol) - if live_price is None: - return False, "获取标记价/实时价失败", None - try: - ensure_markets_loaded() - except Exception: - pass - lp_r = round_price_to_exchange(exchange_symbol, live_price) - if lp_r is not None: - live_price = float(lp_r) - - entry_price = float(entry_price) - sl_adj = round_price_to_exchange(exchange_symbol, float(stop_loss)) - tp_adj = round_price_to_exchange(exchange_symbol, float(take_profit)) - if sl_adj is not None: - stop_loss = float(sl_adj) - if tp_adj is not None: - take_profit = float(tp_adj) - - planned_rr = calc_rr_ratio(direction, entry_price, stop_loss, take_profit) - if planned_rr is None or planned_rr <= KEY_AUTO_MIN_PLANNED_RR: - rr_txt = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算" - return False, f"计划盈亏比 {rr_txt}:1 未达要求(>{KEY_AUTO_MIN_PLANNED_RR}:1)", None - - risk_percent = max(0.01, float(RISK_PERCENT)) - if is_full_margin_mode(POSITION_SIZING_MODE): - ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn)) - if not ok_flat: - return False, flat_msg, None - leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE) - sizing, sizing_err = compute_full_margin_sizing( - symbol=symbol, - available_usdt=available_usdt if available_usdt is not None else 0.0, - capital_base=capital_base, - buffer_ratio=FULL_MARGIN_BUFFER_RATIO, - btc_leverage=BTC_LEVERAGE, - alt_leverage=ALT_LEVERAGE, - funds_decimals=2, - ) - if sizing_err: - return False, sizing_err, None - margin_capital = float(sizing["margin_capital"]) - notional_value = float(sizing["notional_value"]) - position_ratio = float(sizing["position_ratio"]) - risk_amount = margin_capital - else: - default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) - leverage = int(default_leverage) if default_leverage else 5 - if leverage <= 0: - leverage = 5 - risk_fraction = calc_risk_fraction(direction, entry_price, stop_loss) - if risk_fraction is None: - return False, "止损方向不合法(相对计划入场价)", None - risk_amount = round(capital_base * risk_percent / 100.0, 4) - notional_value = round(risk_amount / risk_fraction, 4) - margin_capital = round(notional_value / leverage, 4) - if capital_base and margin_capital > capital_base: - return False, "以损定仓后保证金超过当前交易资金", None - if available_usdt is not None: - max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4) - if margin_capital > max_margin: - return ( - False, - f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", - None, - ) - position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0 - - try: - amount, quote_price = prepare_order_amount(exchange_symbol, margin_capital, leverage, live_price) - contract_size = get_contract_size(exchange_symbol) - base_amount = round(float(amount) * contract_size, 8) - order_resp = place_exchange_order( - exchange_symbol, direction, amount, leverage, - stop_loss=stop_loss, take_profit=take_profit, - ) - open_order_id = order_resp.get("id", "") - tpsl_attached = bool(order_resp.get("tpsl_attached")) - trigger_price = resolve_order_entry_price(order_resp, exchange_symbol, quote_price) - except Exception as e: - return False, friendly_exchange_error(e, available_usdt=available_usdt), None - - trigger_price = round_price_to_exchange(exchange_symbol, trigger_price) - stop_loss = round_price_to_exchange(exchange_symbol, stop_loss) - take_profit = round_price_to_exchange(exchange_symbol, take_profit) - - opened_at_bj = app_now_str() - opened_at_ms = _to_ms_with_fallback(None, opened_at_bj) - planned_rr_fill = calc_rr_ratio(direction, trigger_price, stop_loss, take_profit) - breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER) - breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT) - breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0 - risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage) - if risk_amount_final is None: - risk_amount_final = risk_amount - else: - try: - risk_amount_final = round(float(risk_amount_final), 4) - except (TypeError, ValueError): - risk_amount_final = risk_amount - - if direction == "short": - breakeven_raw = float(trigger_price) * (1 - breakeven_offset_pct / 100.0) - else: - breakeven_raw = float(trigger_price) * (1 + breakeven_offset_pct / 100.0) - breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw) - be_enabled = 1 if int(breakeven_enabled or 0) != 0 else 0 - tc_en, tc_h, tc_at = time_close_insert_values(time_close_enabled, time_close_hours, opened_at_ms) - risk_percent_db = risk_percent_for_storage(POSITION_SIZING_MODE, risk_percent) - - conn.execute( - "INSERT INTO order_monitors " - "(symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, " - "margin_capital, leverage, trade_style, risk_percent, risk_amount, " - "breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, " - "notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type, key_signal_type, " - "time_close_enabled, time_close_hours, time_close_at_ms) " - "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", - ( - symbol, - exchange_symbol, - direction, - trigger_price, - stop_loss, - stop_loss, - take_profit, - margin_capital, - leverage, - trade_style, - risk_percent_db, - risk_amount_final, - breakeven_rr_trigger, - breakeven_offset_pct, - breakeven_step_r, - 0, - breakeven_price, - be_enabled, - notional_value, - position_ratio, - base_amount, - amount, - open_order_id, - opened_at_bj, - opened_at_ms, - trading_day, - ORDER_MONITOR_TYPE_KEY_AUTO, - stored_key_signal_type(monitor_type), - tc_en, - tc_h, - tc_at, - ), - ) - new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) - try_persist_exchange_margin_for_order(conn, new_order_id, exchange_symbol, direction, order_leverage=leverage) - opens_today_after = count_opens_for_trading_day(conn, trading_day) - - return True, None, { - "new_order_id": new_order_id, - "open_order_id": open_order_id, - "trigger_price": trigger_price, - "planned_rr_fill": planned_rr_fill, - "risk_amount_final": risk_amount_final, - "margin_capital": margin_capital, - "leverage": leverage, - "amount": amount, - "tpsl_attached": tpsl_attached, - "opens_today_before": opens_today_before, - "opens_today_after": opens_today_after, - "trading_day": trading_day, - "stop_loss": stop_loss, - "take_profit": take_profit, - } - - -def _execute_trigger_entry_cross(conn, row): - """标记价触达计划入场:先删监控行防重复触发,再市价开仓。""" - symbol = row["symbol"] - direction = (row["direction"] or "long").lower() - ex_sym = normalize_exchange_symbol(symbol) - entry = float(_sqlite_row_val(row, "fib_entry_price") or 0) - sl = float(_sqlite_row_val(row, "fib_stop_loss") or 0) - tp = float(_sqlite_row_val(row, "fib_take_profit") or 0) - be_en = breakeven_enabled_from_row(row, 0) - tc_en, tc_h, _ = time_close_settings_from_row(row) - - kid = int(row["id"]) - conn.execute("DELETE FROM key_monitors WHERE id=?", (kid,)) - conn.commit() - - try: - ok, err, det = _market_open_for_trigger_entry( - conn, - symbol, - direction, - ex_sym, - entry, - sl, - tp, - monitor_type=(row["monitor_type"] or CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE), - breakeven_enabled=be_en, - time_close_enabled=tc_en, - time_close_hours=tc_h, - ) - except Exception as e: - fail_msg = friendly_exchange_error(e) - send_wechat_msg( - f"# ❌ {symbol} 触价开仓异常\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 计划入场:{format_price_for_symbol(symbol, entry)}\n" - f"- 原因:{fail_msg}\n" - ) - insert_key_monitor_history(conn, row, 0, fail_msg, TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED) - return False, fail_msg - - if ok and det: - rr_txt = format_wechat_scalar_2dp(det.get("planned_rr_fill")) if det.get("planned_rr_fill") is not None else "-" - msg = ( - f"# ✅ {symbol} 触价开仓成交\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 来源:{ORDER_MONITOR_TYPE_KEY_AUTO}(程序触价 @ E)\n" - f"- 类型:{TRIGGER_ENTRY_MONITOR_TYPE}|{_wechat_direction_text(direction)}\n" - f"- 订单 ID:**{det.get('new_order_id')}**\n" - f"- 计划入场:{format_price_for_symbol(symbol, entry)}\n" - f"- 成交价:{format_price_for_symbol(symbol, det.get('trigger_price'))}\n" - f"- 止损:{format_wechat_scalar_2dp(det.get('stop_loss'))}|止盈:{format_price_for_symbol(symbol, det.get('take_profit'))}\n" - f"- 计划 RR:{rr_txt}:1\n" - f"- {'已挂交易所 TP/SL' if det.get('tpsl_attached') else 'TP/SL 未挂上'}\n" - ) - send_wechat_msg(msg) - insert_key_monitor_history(conn, row, 0, msg, TRIGGER_ENTRY_CLOSE_FILLED) - return True, None - fail_msg = err or "触价触发后开仓失败" - send_wechat_msg( - f"# ❌ {symbol} 触价开仓失败\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 计划入场:{format_price_for_symbol(symbol, entry)}\n" - f"- 原因:{fail_msg}\n" - ) - insert_key_monitor_history(conn, row, 0, fail_msg, TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED) - return False, fail_msg - - -def check_trigger_entry_key_monitors(): - conn = get_db() - placeholders = ",".join("?" * len(TRIGGER_ENTRY_MONITOR_TYPES)) - rows = conn.execute( - f"SELECT * FROM key_monitors WHERE monitor_type IN ({placeholders})", - tuple(TRIGGER_ENTRY_MONITOR_TYPES), - ).fetchall() - now_dt = app_now() - for r in rows: - symbol = r["symbol"] - direction = (r["direction"] or "long").lower() - mt = (r["monitor_type"] or CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE).strip() - entry = float(_sqlite_row_val(r, "fib_entry_price") or 0) - sl = float(_sqlite_row_val(r, "fib_stop_loss") or 0) - tp = float(_sqlite_row_val(r, "fib_take_profit") or 0) - kid = int(r["id"]) - if entry <= 0 or sl <= 0 or tp <= 0: - _finalize_key_monitor_one_shot(conn, r, "触价计划价位无效", "fib_plan_invalid") - continue - mark = get_symbol_mark_price(symbol) - if mark is None: - continue - prev_mark = _sqlite_row_val(r, "last_mark_price") - prev_mark_f = float(prev_mark) if prev_mark not in (None, "") else None - if is_trigger_entry_expired(r["created_at"], now_dt, hours=TRIGGER_ENTRY_VALIDITY_HOURS): - exp_txt = trigger_entry_expires_at_text(r["created_at"], hours=TRIGGER_ENTRY_VALIDITY_HOURS) - msg = ( - f"# ⚠️ {symbol} 触价开仓已过期\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 类型:{mt}|{_wechat_direction_text(direction)}\n" - f"- 有效期 {TRIGGER_ENTRY_VALIDITY_HOURS}h(应于 {exp_txt} 前触发)\n" - ) - send_wechat_msg(msg) - _finalize_key_monitor_one_shot(conn, r, msg, TRIGGER_ENTRY_CLOSE_EXPIRED) - continue - inv = trigger_entry_invalidate(mt, direction, mark, sl, tp) - if inv == "tp": - msg = ( - f"# ⚠️ {symbol} 触价开仓失效\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 类型:{mt}|标记价 {format_price_for_symbol(symbol, mark)} 已触达止盈侧(未成交)\n" - ) - send_wechat_msg(msg) - _finalize_key_monitor_one_shot(conn, r, msg, TRIGGER_ENTRY_CLOSE_TP_INVALIDATE) - continue - if inv == "sl": - msg = ( - f"# ⚠️ {symbol} 触价开仓失效\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 类型:{mt}|标记价 {format_price_for_symbol(symbol, mark)} 已触达止损侧(未突破)\n" - ) - send_wechat_msg(msg) - _finalize_key_monitor_one_shot(conn, r, msg, TRIGGER_ENTRY_CLOSE_SL_INVALIDATE) - continue - if trigger_should_fire(mt, direction, mark, entry, prev_mark_f): - _execute_trigger_entry_cross(conn, r) - continue - conn.execute("UPDATE key_monitors SET last_mark_price=? WHERE id=?", (float(mark), kid)) - conn.commit() - conn.close() - - -def check_fib_key_monitors(): - conn = get_db() - rows = conn.execute("SELECT * FROM key_monitors").fetchall() - for r in rows: - typ = (r["monitor_type"] or "").strip() - if not is_limit_key_monitor_type(typ): - continue - symbol = r["symbol"] - direction = (r["direction"] or "long").lower() - ex_sym = normalize_exchange_symbol(symbol) - up, low = float(r["upper"]), float(r["lower"]) - oid = _sqlite_row_val(r, "fib_limit_order_id") - if is_false_breakout_key_monitor_type(typ): - now_dt = app_now() - if is_false_breakout_expired(r["created_at"], now_dt): - _cancel_fib_monitor_limit(r) - exp_txt = expires_at_text(r["created_at"]) - msg = ( - f"# ⚠️ {symbol} 假突破监控已过期\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 类型:{typ}|{_wechat_direction_text(direction)}\n" - f"- 有效期 {FALSE_BREAKOUT_VALIDITY_HOURS}h(应于 {exp_txt} 前成交)\n" - f"- 已撤销限价单\n" - ) - send_wechat_msg(msg) - _finalize_key_monitor_one_shot(conn, r, msg, "false_breakout_expired") - continue - mark = get_symbol_mark_price(symbol) - if mark is None: - continue - status = fib_limit_order_status(ex_sym, oid) if oid else "missing" - if status == "filled" or (status != "open" and _fib_has_live_position(ex_sym, direction)): - _finalize_fib_key_fill(conn, r) - continue - if is_fib_key_monitor_type(typ) and status == "open": - if fib_invalidate_by_mark(direction, mark, up, low): - _cancel_fib_monitor_limit(r) - msg = ( - f"# ⚠️ {symbol} 斐波监控失效\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 类型:{typ}|{_wechat_direction_text(direction)}\n" - f"- 标记价 {format_price_for_symbol(symbol, mark)} 已触达止盈侧(未成交),已撤限价单\n" - ) - send_wechat_msg(msg) - _finalize_key_monitor_one_shot(conn, r, msg, "fib_invalidate") - continue - if is_fib_key_monitor_type(typ) and status in ("canceled", "missing", "unknown") and fib_invalidate_by_mark(direction, mark, up, low): - msg = ( - f"# ⚠️ {symbol} 斐波监控失效(限价已不在挂单)\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 标记价触达止盈侧,本条已结案\n" - ) - send_wechat_msg(msg) - _finalize_key_monitor_one_shot(conn, r, msg, "fib_invalidate") - conn.commit() - conn.close() - - -def _false_breakout_exists_for_symbol(conn, symbol): - row = conn.execute( - "SELECT id FROM key_monitors WHERE symbol=? AND monitor_type=?", - (symbol, FALSE_BREAKOUT_MONITOR_TYPE), - ).fetchone() - return row is not None - - -def _add_false_breakout_key_monitor( - conn, symbol, direction_sel, upper_px, lower_px, key_px, breakeven_enabled=0, - time_close_enabled=0, time_close_hours=None, -): - if _false_breakout_exists_for_symbol(conn, symbol): - return False, f"{symbol} 已有假突破监控(同币仅允许一条)" - plan = calc_false_breakout_plan(direction_sel, key_px) - if not plan: - return False, "假突破价位无效,请核对方向与关键价位" - entry, sl, tp = plan - ex_sym = normalize_exchange_symbol(symbol) - entry = round_price_to_exchange(ex_sym, entry) - sl = round_price_to_exchange(ex_sym, sl) - tp = round_price_to_exchange(ex_sym, tp) - if entry is None or sl is None or tp is None: - return False, "假突破价位经交易所精度舍入后无效" - entry, sl, tp = float(entry), float(sl), float(tp) - ok, reason = precheck_risk(conn, symbol, direction_sel) - if not ok: - return False, reason - ok_live, reason_live = ensure_exchange_live_ready() - if not ok_live: - return False, reason_live - now = app_now() - trading_day = get_trading_day(now) - session_row = ensure_session(conn, trading_day) - _, trading_capital_live = get_exchange_capitals(force=True) - live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"]) - capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital) - default_leverage = get_synced_leverage(ex_sym, direction_sel) or infer_leverage(symbol) - leverage = int(default_leverage) if default_leverage else 5 - if leverage <= 0: - leverage = 5 - available_usdt = get_available_trading_usdt() - risk_fraction = calc_risk_fraction(direction_sel, entry, sl) - if risk_fraction is None: - return False, "止损方向不合法(相对挂单价);请核对方向与关键价位" - risk_percent = max(0.01, float(RISK_PERCENT)) - risk_amount = round(capital_base * risk_percent / 100.0, 4) - notional_value = round(risk_amount / risk_fraction, 4) - margin_capital = round(notional_value / leverage, 4) - if capital_base and margin_capital > capital_base: - return False, "以损定仓后保证金超过当前交易资金" - if available_usdt is not None: - max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4) - if margin_capital > max_margin: - return ( - False, - f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", - ) - try: - amount, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry) - order_resp = place_fib_limit_order(ex_sym, direction_sel, amount, leverage, entry) - oid = str(order_resp.get("id") or "") - if not oid: - return False, "交易所未返回限价单 ID" - except Exception as e: - return False, friendly_exchange_error(e, available_usdt=available_usdt) - be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0 - tc_en, tc_h, _ = time_close_insert_values(time_close_enabled, time_close_hours, None) - conn.execute( - "INSERT INTO key_monitors " - "(symbol, monitor_type, direction, upper, lower, " - "fib_limit_order_id, fib_entry_price, fib_stop_loss, fib_take_profit, " - "fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled, time_close_enabled, time_close_hours) " - "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", - ( - symbol, FALSE_BREAKOUT_MONITOR_TYPE, direction_sel, upper_px, lower_px, - oid, entry, sl, tp, float(amount), margin_capital, leverage, be_flag, tc_en, tc_h, - ), - ) - return True, None - - -def _add_fib_key_monitor( - conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=0, - time_close_enabled=0, time_close_hours=None, -): - if _fib_key_exists_for_symbol(conn, symbol): - return False, f"{symbol} 已有斐波监控(同币仅允许一条 0.618/0.786)" - ratio = fib_ratio_from_type(mt) - plan = calc_fib_plan(direction_sel, upper_px, lower_px, ratio) - if not plan: - return False, "斐波上下沿无效(需上沿 H > 下沿 L)" - entry, sl, tp = plan - ex_sym = normalize_exchange_symbol(symbol) - entry = round_price_to_exchange(ex_sym, entry) - sl = round_price_to_exchange(ex_sym, sl) - tp = round_price_to_exchange(ex_sym, tp) - if entry is None or sl is None or tp is None: - return False, "斐波价位经交易所精度舍入后无效" - entry, sl, tp = float(entry), float(sl), float(tp) - planned_rr = calc_rr_ratio(direction_sel, entry, sl, tp) - if planned_rr is None or planned_rr <= KEY_AUTO_MIN_PLANNED_RR: - fmt_rr = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算" - return False, f"斐波计划盈亏比 {fmt_rr}:1 未达要求(>{KEY_AUTO_MIN_PLANNED_RR}:1)" - ok, reason = precheck_risk(conn, symbol, direction_sel) - if not ok: - return False, reason - ok_live, reason_live = ensure_exchange_live_ready() - if not ok_live: - return False, reason_live - now = app_now() - trading_day = get_trading_day(now) - session_row = ensure_session(conn, trading_day) - _, trading_capital_live = get_exchange_capitals(force=True) - live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"]) - capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital) - default_leverage = get_synced_leverage(ex_sym, direction_sel) or infer_leverage(symbol) - leverage = int(default_leverage) if default_leverage else 5 - if leverage <= 0: - leverage = 5 - available_usdt = get_available_trading_usdt() - risk_fraction = calc_risk_fraction(direction_sel, entry, sl) - if risk_fraction is None: - return False, "止损方向不合法(相对挂单价 E);请核对上下沿与方向" - risk_percent = max(0.01, float(RISK_PERCENT)) - risk_amount = round(capital_base * risk_percent / 100.0, 4) - notional_value = round(risk_amount / risk_fraction, 4) - margin_capital = round(notional_value / leverage, 4) - if capital_base and margin_capital > capital_base: - return False, "以损定仓后保证金超过当前交易资金" - if available_usdt is not None: - max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4) - if margin_capital > max_margin: - return ( - False, - f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", - ) - try: - amount, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry) - order_resp = place_fib_limit_order(ex_sym, direction_sel, amount, leverage, entry) - oid = str(order_resp.get("id") or "") - if not oid: - return False, "交易所未返回限价单 ID" - except Exception as e: - return False, friendly_exchange_error(e, available_usdt=available_usdt) - be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0 - tc_en, tc_h, _ = time_close_insert_values(time_close_enabled, time_close_hours, None) - conn.execute( - "INSERT INTO key_monitors " - "(symbol, monitor_type, direction, upper, lower, " - "fib_limit_order_id, fib_entry_price, fib_stop_loss, fib_take_profit, " - "fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled, time_close_enabled, time_close_hours) " - "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", - ( - symbol, mt, direction_sel, upper_px, lower_px, - oid, entry, sl, tp, float(amount), margin_capital, leverage, be_flag, tc_en, tc_h, - ), - ) - return True, None - - -# 关键位监控(箱体/收敛可自动开仓;阻力/支撑为双向 5m 收盘突破 + 三次提醒) -def check_key_monitors(): - conn = get_db() - rows = conn.execute("SELECT * FROM key_monitors").fetchall() - for r in rows: - sym, typ_raw, up, low = r["symbol"], r["monitor_type"], r["upper"], r["lower"] - typ = (typ_raw or "").strip() - if is_limit_key_monitor_type(typ): - continue - if typ in KEY_MONITOR_RS_TYPES: - try: - _process_key_rs_level_alert(conn, r) - except Exception as e: - print(f"[key_rs_level_alert] {sym} id={r['id']}: {e}") - continue - - direction = (r["direction"] or "long").lower() - if direction == KEY_DIRECTION_WATCH: - continue - if typ in KEY_MONITOR_AUTO_TYPES: - mark = get_symbol_mark_price(sym) - if mark is not None and box_breakout_invalidate_by_mark(direction, mark, up, low): - edge = float(low) if direction == "long" else float(up) - edge_label = box_breakout_invalidate_edge_label(direction) - msg = ( - f"# ⚠️ {sym} 关键位监控失效\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 类型:{typ}|{_wechat_direction_text(direction)}\n" - f"- 标记价 {format_price_for_symbol(sym, mark)} 已突破反向{edge_label} " - f"{format_price_for_symbol(sym, edge)}(设置失效)\n" - ) - send_wechat_msg(msg) - _finalize_key_monitor_one_shot(conn, r, msg, "box_opposite_break") - continue - try: - checks = _key_hard_checks(sym, direction, up, low, typ) - except Exception: - checks = {"ok": False} - if not checks.get("ok"): - continue - - btc8h_status, _, _ = _status_by_ema55("BTC/USDT", "8h") - coin4h_status, _, _ = _status_by_ema55(sym, "4h") - risk_tip = None - if (direction == "long" and coin4h_status == "空头") or (direction == "short" and coin4h_status == "多头"): - risk_tip = "当前信号与本币4h(EMA55)主趋势逆势,建议降低仓位并严格执行止损。" - - key_price = float(low) if direction == "long" else float(up) - hard_lines = _key_hard_lines_from_checks(checks) - trigger_time = ms_to_app_local_str(int(checks["confirm_ts"])) if checks.get("confirm_ts") else app_now_str() - - if typ not in KEY_MONITOR_AUTO_TYPES: - continue - - plan_tuple, sl_tp_mode = _key_plan_sl_tp_for_row(r, direction, up, low, checks) - if not plan_tuple: - fmt_rr = "无法计算(止损/止盈与确认价几何关系无效)" - rr_msg = ( - f"# ⚠️ {sym} 关键位自动单:计划无效\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 类型:{typ}|方案:{sl_tp_mode_label(sl_tp_mode)}\n" - f"- 方向:**{_wechat_direction_text(direction)}**\n" - f"- 触发时间:`{trigger_time}`\n" - f"- 确认K收盘(E):`{format_price_for_symbol(sym, checks.get('confirm_close'))}`\n" - f"- **{fmt_rr}**(未开仓)\n" - "---\n" - "### 硬条件\n" - + "\n".join(f"- {x}" for x in hard_lines) - ) - if risk_tip: - rr_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}" - send_wechat_msg(rr_msg) - _finalize_key_monitor_one_shot(conn, r, rr_msg, "rr_insufficient") - continue - E, sl_raw, tp_raw, box_h = plan_tuple - exchange_symbol = normalize_exchange_symbol(sym) - try: - ensure_markets_loaded() - except Exception: - pass - sl_px = round_price_to_exchange(exchange_symbol, sl_raw) - tp_px = round_price_to_exchange(exchange_symbol, tp_raw) - if sl_px is not None: - sl_raw = float(sl_px) - if tp_px is not None: - tp_raw = float(tp_px) - - planned_rr = calc_rr_ratio(direction, E, sl_raw, tp_raw) - rr_ok = planned_rr is not None and planned_rr > KEY_AUTO_MIN_PLANNED_RR - - if not rr_ok: - fmt_rr = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算(止损/止盈与确认价几何关系无效)" - plan_line = sl_tp_plan_summary_text( - sl_tp_mode, direction, E, sl_raw, tp_raw, box_h, - outside_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT, - trend_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT, - ) - rr_msg = ( - f"# ⚠️ {sym} 关键位自动单:计划 RR 未达标\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 类型:{typ}|{plan_line}\n" - f"- 方向:**{_wechat_direction_text(direction)}**\n" - f"- 触发时间:`{trigger_time}`\n" - f"- 确认K收盘(E):`{format_price_for_symbol(sym, E)}`\n" - f"- 箱体高 H:`{format_price_for_symbol(sym, box_h)}`\n" - f"- 计划止损:`{format_wechat_scalar_2dp(sl_raw)}`\n" - f"- 计划止盈:`{format_price_for_symbol(sym, tp_raw)}`\n" - f"- **计划 RR(按确认收盘 E):{fmt_rr} : 1**(要求 **>{KEY_AUTO_MIN_PLANNED_RR}:1**,未开仓)\n" - "---\n" - "### 硬条件\n" - + "\n".join(f"- {x}" for x in hard_lines) - ) - if risk_tip: - rr_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}" - send_wechat_msg(rr_msg) - _finalize_key_monitor_one_shot(conn, r, rr_msg, "rr_insufficient") - continue - - key_sig = typ if typ in KEY_MONITOR_AUTO_TYPES else None - be_on = breakeven_enabled_from_row(r, 0) - tc_en, tc_h, _ = time_close_settings_from_row(r) - ok_trade, trade_err, det = _market_open_for_key_monitor( - conn, - sym, - direction, - exchange_symbol, - sl_raw, - tp_raw, - key_signal_type=key_sig, - breakeven_enabled=1 if be_on else 0, - time_close_enabled=tc_en, - time_close_hours=tc_h, - ) - planned_rr_txt = ( - format_wechat_scalar_2dp(planned_rr) if planned_rr is not None else "-" - ) - if not ok_trade: - fail_msg = ( - f"# ❌ {sym} 关键位自动单失败\n" - f"**账户:{_wechat_account_label()}**\n" - f"- 类型:{typ}\n" - f"- 方向:**{_wechat_direction_text(direction)}**\n" - f"- 触发时间:`{trigger_time}`\n" - f"- 确认K收盘(E):`{format_price_for_symbol(sym, E)}`\n" - f"- 计划止损:`{format_wechat_scalar_2dp(sl_raw)}`\n" - f"- 计划止盈:`{format_price_for_symbol(sym, tp_raw)}`\n" - f"- **计划 RR(按 E):{planned_rr_txt} : 1**(已通过 RR 阈值)\n" - f"- **失败原因:{trade_err}**\n" - "---\n" - "### 硬条件\n" - + "\n".join(f"- {x}" for x in hard_lines) - ) - if risk_tip: - fail_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}" - send_wechat_msg(fail_msg) - _finalize_key_monitor_one_shot(conn, r, fail_msg, "exchange_failed") - continue - - tpsl_txt = ( - "已在交易所挂条件委托(止盈、止损触发单)" - if det.get("tpsl_attached") - else "⚠️ 条件委托挂接状态异常或未挂上" - ) - rr_fill = det.get("planned_rr_fill") - rr_fill_txt = format_wechat_scalar_2dp(rr_fill) if rr_fill is not None else "-" - - succ_msg_lines = [ - f"# ✅ {sym} 关键位自动开仓成功", - f"**账户:{_wechat_account_label()}**", - f"- **来源:**{ORDER_MONITOR_TYPE_KEY_AUTO}(市价)", - f"- 页面订单 ID:**{det['new_order_id']}**", - f"- 交易所订单 ID:`{det.get('open_order_id') or '-'}`", - f"- 类型:{typ}|方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'开' if be_on else '关'}", - f"- 方向:**{_wechat_direction_text(direction)}**", - f"- 触发时间:`{trigger_time}`", - f"- 确认K收盘(E):{format_price_for_symbol(sym, E)}(RR 阈值按此计价)", - f"- **计划 RR(E):{planned_rr_txt}:1**", - f"- 开仓成交价:**{format_price_for_symbol(sym, det['trigger_price'])}**", - f"- **成交价侧计划 RR:**{rr_fill_txt}:1", - f"- 止损:{format_wechat_scalar_2dp(sl_raw)}", - f"- 止盈:{format_price_for_symbol(sym, tp_raw)}", - f"- 风险:{det.get('risk_percent')}%≈{format_wechat_scalar_2dp(det.get('risk_amount_final'))}U|基数 {format_wechat_scalar_2dp(det.get('margin_capital'))}U|杠杆 {det.get('leverage')}x", - f"- 名义 {format_wechat_scalar_2dp(det.get('notional_value'))}U|张数 {format_wechat_scalar_2dp(det.get('amount'))}|折算标的 {det.get('base_amount')}", - f"- **{tpsl_txt}**", - f"- 保本触发:{det.get('breakeven_rr_trigger')}R→{format_price_for_symbol(sym, det.get('breakeven_price'))}", - f"- {format_daily_open_summary_short(det.get('opens_today_after'), DAILY_OPEN_ALERT_THRESHOLD, DAILY_OPEN_HARD_LIMIT)}", - ] - succ_msg_lines.extend(["---", "### 硬条件"] + [f"- {x}" for x in hard_lines]) - if risk_tip: - succ_msg_lines.extend(["---", "### 逆势风险提示", f"- {risk_tip}"]) - succ_msg = "\n".join(succ_msg_lines) - send_wechat_msg(succ_msg) - _finalize_key_monitor_one_shot(conn, r, succ_msg, "auto_opened") - - if should_send_daily_open_alert( - det.get("opens_today_before", 0), - det.get("opens_today_after", 0), - DAILY_OPEN_ALERT_THRESHOLD, - ): - advice = ai_short_advice( - build_daily_open_alert_prompt( - det["trading_day"], - det.get("opens_today_after", 0), - DAILY_OPEN_ALERT_THRESHOLD, - hard_limit=DAILY_OPEN_HARD_LIMIT, - detail_line=f"最新一笔来源为关键位自动单:{sym} {direction},杠杆{det['leverage']}x。", - ) - ) - if advice: - send_wechat_msg(f"【AI提醒】今日开仓次数已达 {det['opens_today_after']}\n{advice[:800]}") - conn.commit() - conn.close() - -# 止盈止损监控(已修复:严格区分多空,无默认做多) -def check_order_monitors(): - conn = get_db() - rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() - for r in rows: - pid, sym, direction, trigger_price, stop_loss, take_profit = r["id"], r["symbol"], r["direction"], r["trigger_price"], r["stop_loss"], r["take_profit"] - margin_capital = r["margin_capital"] or DAILY_START_CAPITAL - leverage = r["leverage"] or infer_leverage(sym) - trade_basis_row = row_to_dict(r) - ex_sym = r["exchange_symbol"] or normalize_exchange_symbol(sym) - if _order_row_exchange_margin_usdt(r) is None and exchange_private_api_configured(): - pm = get_live_position_exchange_metrics(ex_sym, direction, order_leverage=leverage) - if pm and pm.get("initial_margin") is not None: - try: - mv = float(pm["initial_margin"]) - if mv > 0: - conn.execute( - "UPDATE order_monitors SET exchange_margin_usdt=? WHERE id=?", - (round(mv, 4), pid), - ) - trade_basis_row["exchange_margin_usdt"] = round(mv, 4) - except (TypeError, ValueError): - pass - session_date = r["session_date"] or get_trading_day() - p = get_price(sym) - if not p: continue - - # 到达设定 R 倍后,按阶梯持续上移止损(本地风控层) - risk_amount = float(r["risk_amount"] or 0) - breakeven_armed = int(r["breakeven_armed"] or 0) - trigger_rr = float(r["breakeven_rr_trigger"] or BREAKEVEN_RR_TRIGGER) - step_r = float(r["breakeven_step_r"] or BREAKEVEN_STEP_R or 1.0) - step_r = 1.0 if step_r <= 0 else step_r - breakeven_enabled = True - try: - if "breakeven_enabled" in r.keys(): - breakeven_enabled = int(r["breakeven_enabled"] or 0) != 0 - except Exception: - breakeven_enabled = True - if breakeven_enabled and risk_amount > 0 and trigger_rr > 0: - now_pnl = calc_pnl(direction, trigger_price, p, margin_capital, leverage) - now_rr = now_pnl / risk_amount - if now_rr >= trigger_rr: - steps = int((now_rr - trigger_rr) // step_r) - locked_r = max(0.0, steps * step_r) - notional = float(margin_capital or 0) * float(leverage or 0) - risk_frac = (risk_amount / notional) if notional > 0 else None - if risk_frac and risk_frac > 0: - new_sl = calc_breakeven_stop( - direction, - trigger_price, - risk_frac, - locked_r=locked_r, - offset_pct=float(r["breakeven_offset_pct"] or BREAKEVEN_OFFSET_PCT), - ) - if new_sl is not None: - should_move = (direction == "short" and new_sl < float(stop_loss)) or ( - direction == "long" and new_sl > float(stop_loss) - ) - if should_move: - was_armed = breakeven_armed - ex_sym = resolve_monitor_exchange_symbol(r) - new_sl = round_price_to_exchange(ex_sym, new_sl) - tp_ex = float(take_profit or 0) - ok_live, _live_reason = ensure_exchange_live_ready() - synced_ex = not ok_live - if ok_live and tp_ex > 0: - try: - replace_active_monitor_tpsl_on_exchange(r, new_sl, tp_ex) - synced_ex = True - _clear_breakeven_exchange_warn(pid) - except Exception as e: - print( - f"[breakeven] exchange tpsl replace failed order={pid} {sym}: {e}", - flush=True, - ) - _send_breakeven_exchange_warn_once( - pid, - f"⚠️ {sym} 移动保本止损未同步交易所:{friendly_exchange_error(e)}", - ) - elif ok_live: - print( - f"[breakeven] skip exchange order={pid} {sym}: invalid take_profit", - flush=True, - ) - if synced_ex: - conn.execute( - "UPDATE order_monitors SET stop_loss=?, breakeven_armed=1, breakeven_price=? WHERE id=?", - (new_sl, new_sl, pid), - ) - stop_loss = new_sl - breakeven_armed = 1 - if not was_armed: - arm_txt = "保本止盈" - be_msg = build_wechat_breakeven_message( - sym, - direction, - arm_txt, - now_rr, - locked_r, - new_sl, - ) - if ok_live: - be_msg += "\n- 交易所:已先撤后挂止盈止损" - send_wechat_msg(be_msg) - - res = None - if should_trigger_time_close(r): - res = TIME_CLOSE_RESULT - # 做多 - if not res and direction == "long": - if p >= take_profit: res = "止盈" - elif p <= stop_loss: res = "止损" - # 做空 - elif not res and direction == "short": - if p <= take_profit: res = "止盈" - elif p >= stop_loss: res = "止损" - - if res: - now = app_now() - opened_at = get_opened_at_value(r) - opened_at_ms = (r["opened_at_ms"] if "opened_at_ms" in r.keys() else None) - closed_at = now.strftime("%Y-%m-%d %H:%M:%S") - hold_seconds = calc_hold_seconds(opened_at, now) - pnl_amount = calc_pnl(direction, trigger_price, p, margin_capital, leverage) - if res == "止损" and float(pnl_amount or 0) > 0: - res = normalize_result_with_pnl("止损", pnl_amount) - else: - res = normalize_result_with_pnl(res, pnl_amount) - close_order_id = "" - try: - close_resp = close_exchange_order(r) - close_order_id = close_resp.get("id", "") - # 平仓入库优先使用交易所返回成交价;拿不到再回退拉成交明细。 - exit_p = extract_trade_price_from_order(close_resp) - if exit_p and exit_p > 0: - pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) - guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) - if guessed_res: - res = normalize_result_with_pnl(guessed_res, pnl_amount) - else: - res = normalize_result_with_pnl(res, pnl_amount) - else: - ex_sym = r["exchange_symbol"] or normalize_exchange_symbol(sym) - tr = fetch_latest_closing_fill( - ex_sym, - direction, - opened_at, - opened_at_ms=opened_at_ms, - ) - if tr and tr.get("price"): - try: - exit_p = float(tr["price"]) - pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) - guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) - if guessed_res: - if guessed_res == "止损" and float(pnl_amount or 0) > 0: - res = normalize_result_with_pnl("止损", pnl_amount) - else: - res = normalize_result_with_pnl(guessed_res, pnl_amount) - else: - res = normalize_result_with_pnl(res, pnl_amount) - except (TypeError, ValueError): - pass - ts = tr.get("timestamp") - if ts: - closed_at = ms_to_app_local_str(int(ts)) - hold_seconds = calc_hold_seconds( - opened_at, parse_dt_for_trading_day(closed_at) or now - ) - except Exception as e: - if is_no_position_error(str(e)): - ex_sym = r["exchange_symbol"] or normalize_exchange_symbol(sym) - cancel_gate_swap_trigger_orders(ex_sym) - tr = fetch_latest_closing_fill( - ex_sym, - direction, - opened_at, - opened_at_ms=opened_at_ms, - ) - if tr and tr.get("price"): - try: - exit_p = float(tr["price"]) - pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) - # 交易所已返回真实成交价时,以真实成交结果为准,避免本地轮询竞态导致误判。 - guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) - if guessed_res: - if guessed_res == "止损" and float(pnl_amount or 0) > 0: - res = normalize_result_with_pnl("止损", pnl_amount) - else: - res = normalize_result_with_pnl(guessed_res, pnl_amount) - else: - res = normalize_result_with_pnl(res, pnl_amount) - except (TypeError, ValueError): - pass - ts = tr.get("timestamp") - if ts: - closed_at = ms_to_app_local_str(int(ts)) - hold_seconds = calc_hold_seconds( - opened_at, parse_dt_for_trading_day(closed_at) or now - ) - insert_trade_record( - conn, - symbol=sym, - monitor_type=trade_record_monitor_type(conn, r), - trend_plan_id=trend_plan_id_from_monitor_row(r), - key_signal_type=order_row_key_signal_type(r), - direction=direction, - trigger_price=trigger_price, - stop_loss=stop_loss, - initial_stop_loss=r["initial_stop_loss"] or stop_loss, - take_profit=take_profit, - margin_capital=margin_capital_for_trade_record(trade_basis_row), - leverage=leverage, - pnl_amount=pnl_amount, - hold_seconds=hold_seconds, - trade_style=r["trade_style"], - risk_amount=r["risk_amount"], - planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or stop_loss, take_profit), - actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), - result=res, - miss_reason=handoff_trade_miss_reason( - "触发价已触达,仓位已由交易所止盈/止损或其他方式平掉(本地补记)", - r, - ), - opened_at=opened_at, - closed_at=closed_at, - ) - session_capital = update_session_capital(conn, session_date, pnl_amount) - send_wechat_msg( - build_wechat_close_message( - symbol=sym, - direction=direction, - result=f"{res}(交易所已先行平仓)", - pnl_amount=pnl_amount, - hold_seconds=hold_seconds, - trigger_price=trigger_price, - current_price=p, - stop_loss=stop_loss, - take_profit=take_profit, - close_order_id="-", - extra_note="本地补记:仓位由交易所止盈/止损或其他方式先行平掉", - session_capital_fallback=session_capital, - ) - ) - conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (pid,)) - conn.commit() - continue - ex_sym_fail = r["exchange_symbol"] or normalize_exchange_symbol(sym) - cancel_gate_swap_trigger_orders(ex_sym_fail) - live_contracts = get_live_position_contracts(ex_sym_fail, direction) - if live_contracts is not None and live_contracts <= 0: - record_res, record_pnl, record_closed, sync_miss = resolve_synced_flat_close( - r, opened_at, opened_at_ms=opened_at_ms - ) - record_miss = f"{sync_miss};本地触发{res}时平仓API失败:{e}" - monitor_status = "stopped" - else: - record_res, record_pnl, record_closed = res, pnl_amount, closed_at - record_miss = f"触发{res}后交易所平仓失败(请核对交易所仓位):{e}" - monitor_status = "error" - record_hold = calc_hold_seconds( - opened_at, parse_dt_for_trading_day(record_closed) or now - ) - insert_trade_record( - conn, - symbol=sym, - monitor_type=trade_record_monitor_type(conn, r), - trend_plan_id=trend_plan_id_from_monitor_row(r), - key_signal_type=order_row_key_signal_type(r), - direction=direction, - trigger_price=trigger_price, - stop_loss=stop_loss, - initial_stop_loss=r["initial_stop_loss"] or stop_loss, - take_profit=take_profit, - margin_capital=margin_capital_for_trade_record(trade_basis_row), - leverage=leverage, - pnl_amount=record_pnl, - hold_seconds=record_hold, - trade_style=r["trade_style"], - risk_amount=r["risk_amount"], - planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or stop_loss, take_profit), - actual_rr=calc_actual_rr(record_pnl, r["risk_amount"]), - result=record_res, - miss_reason=handoff_trade_miss_reason(record_miss, r), - opened_at=opened_at, - closed_at=record_closed, - ) - session_capital = update_session_capital(conn, session_date, record_pnl) - conn.execute("UPDATE order_monitors SET status=? WHERE id=?", (monitor_status, pid)) - conn.commit() - send_wechat_msg( - build_wechat_monitor_error_message( - symbol=sym, - direction=direction, - scene=f"触发{res}后交易所平仓失败", - error_text=str(e), - ) - ) - if monitor_status == "stopped": - send_wechat_msg( - build_wechat_close_message( - symbol=sym, - direction=direction, - result=f"{record_res}(已补记入交易记录)", - pnl_amount=record_pnl, - hold_seconds=record_hold, - trigger_price=trigger_price, - current_price=p, - stop_loss=stop_loss, - take_profit=take_profit, - close_order_id="-", - extra_note=record_miss, - session_capital_fallback=session_capital, - ) - ) - continue - cancel_gate_swap_trigger_orders(r["exchange_symbol"] or normalize_exchange_symbol(sym)) - session_capital = update_session_capital(conn, session_date, pnl_amount) - send_wechat_msg( - build_wechat_close_message( - symbol=sym, - direction=direction, - result=res, - pnl_amount=pnl_amount, - hold_seconds=hold_seconds, - trigger_price=trigger_price, - current_price=p, - stop_loss=stop_loss, - take_profit=take_profit, - close_order_id=close_order_id or "-", - session_capital_fallback=session_capital, - ) - ) - insert_trade_record( - conn, - symbol=sym, - monitor_type=trade_record_monitor_type(conn, r), - trend_plan_id=trend_plan_id_from_monitor_row(r), - key_signal_type=order_row_key_signal_type(r), - direction=direction, - trigger_price=trigger_price, - stop_loss=stop_loss, - initial_stop_loss=r["initial_stop_loss"] or stop_loss, - take_profit=take_profit, - margin_capital=margin_capital_for_trade_record(trade_basis_row), - leverage=leverage, - pnl_amount=pnl_amount, - hold_seconds=hold_seconds, - trade_style=r["trade_style"], - risk_amount=r["risk_amount"], - planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or stop_loss, take_profit), - actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), - result=res, - miss_reason=handoff_trade_miss_reason(None, r), - opened_at=opened_at, - closed_at=closed_at, - ) - conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, pid)) - clear_key_sizing_snapshot_if_flat(conn, get_trading_day()) - conn.commit() - conn.close() - - -def force_close_before_reset(): - if not FORCE_CLOSE_ENABLED: - return - now = app_now() - # 每天北京时间指定整点小时内执行一次性兜底清仓(默认 00:xx) - if now.hour != FORCE_CLOSE_BJ_HOUR: - return - conn = get_db() - rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() - for r in rows: - p = get_price(r["symbol"]) - if not p: - continue - direction = r["direction"] - trigger_price = r["trigger_price"] - margin_capital = r["margin_capital"] or DAILY_START_CAPITAL - leverage = r["leverage"] or infer_leverage(r["symbol"]) - session_date = r["session_date"] or get_trading_day(now) - opened_at = get_opened_at_value(r) - closed_at = now.strftime("%Y-%m-%d %H:%M:%S") - hold_seconds = calc_hold_seconds(opened_at, now) - pnl_amount = calc_pnl(direction, trigger_price, p, margin_capital, leverage) - try: - close_resp = close_exchange_order(r) - close_order_id = close_resp.get("id", "") - cancel_gate_swap_trigger_orders(r["exchange_symbol"] or normalize_exchange_symbol(r["symbol"])) - except Exception as e: - conn.execute("UPDATE order_monitors SET status='error' WHERE id=?", (r["id"],)) - conn.commit() - send_wechat_msg( - build_wechat_monitor_error_message( - symbol=r["symbol"], - direction=direction, - scene="强制清仓失败", - error_text=str(e), - ) - ) - continue - session_capital = update_session_capital(conn, session_date, pnl_amount) - insert_trade_record( - conn, - symbol=r["symbol"], - monitor_type=trade_record_monitor_type(conn, r), - trend_plan_id=trend_plan_id_from_monitor_row(r), - key_signal_type=order_row_key_signal_type(r), - direction=direction, - trigger_price=trigger_price, - stop_loss=r["stop_loss"], - initial_stop_loss=r["initial_stop_loss"] or r["stop_loss"], - take_profit=r["take_profit"], - margin_capital=margin_capital_for_trade_record(r), - leverage=leverage, - pnl_amount=pnl_amount, - hold_seconds=hold_seconds, - trade_style=r["trade_style"], - risk_amount=r["risk_amount"], - planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]), - actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), - result="强制清仓", - miss_reason=handoff_trade_miss_reason( - f"北京时间 {FORCE_CLOSE_BJ_HOUR}:00 整点风控清仓", - r, - ), - opened_at=opened_at, - closed_at=closed_at, - ) - conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, r["id"])) - send_wechat_msg( - build_wechat_close_message( - symbol=r["symbol"], - direction=direction, - result="强制清仓", - pnl_amount=pnl_amount, - hold_seconds=hold_seconds, - trigger_price=trigger_price, - current_price=p, - stop_loss=r["stop_loss"], - take_profit=r["take_profit"], - close_order_id=close_order_id or "-", - extra_note=f"北京时间 {FORCE_CLOSE_BJ_HOUR}:00 整点风控清仓", - session_capital_fallback=session_capital, - ) - ) - conn.commit() - conn.close() - -# 后台线程 -def background_task(): - while True: - try: - auto_transfer_once_per_day() - conn = get_db() - reconcile_external_closes(conn) - conn.commit() - conn.close() - force_close_before_reset() - check_fib_key_monitors() - check_trigger_entry_key_monitors() - _roll_cfg = app.extensions.get("strategy_roll_cfg") - if _roll_cfg: - from lib.strategy.strategy_roll_monitor_lib import check_roll_monitors - - check_roll_monitors(_roll_cfg) - check_key_monitors() - check_order_monitors() - cfg = app.extensions.get("strategy_trend_cfg") - if cfg: - from lib.strategy.strategy_trend_register import check_trend_pullback_plans - - check_trend_pullback_plans(cfg) - except: - pass - time.sleep(MONITOR_POLL_SECONDS) - - -# ====================== 登录路由 ====================== -@app.route("/login", methods=["GET", "POST"]) -def login(): - if AUTH_DISABLED: - session["logged_in"] = True - return redirect("/") - if request.method == "POST": - username = request.form.get("username") - password = request.form.get("password") - if username == USERNAME and password == PASSWORD: - session["logged_in"] = True - return redirect("/") - else: - flash("账号或密码错误") - return render_template("login.html", exchange_display=EXCHANGE_DISPLAY_NAME) - -@app.route("/logout") -def logout(): - session.clear() - return redirect("/" if AUTH_DISABLED else "/login") - -# 登录校验装饰器 -def login_required(f): - @wraps(f) - def decorated(*args, **kwargs): - if hub_request_allowed(bool(session.get("logged_in")), AUTH_DISABLED): - return f(*args, **kwargs) - return redirect("/login") - return decorated - - -@app.route("/sync_positions") -@login_required -def sync_positions(): - days_raw = (request.args.get("days") or "").strip() - sync_days = None - if days_raw: - try: - sync_days = max(1, min(365, int(days_raw))) - except Exception: - sync_days = None - conn = get_db() - synced = reconcile_external_closes(conn, days=sync_days) - conn.commit() - conn.close() - if sync_days is not None: - flash(f"同步完成:最近 {sync_days} 天内 {synced} 笔持仓已按交易所状态更新") - else: - flash(f"同步完成:{synced} 笔持仓已按交易所状态更新") - return redirect("/") - - -@app.route("/api/sync_positions", methods=["POST"]) -@login_required -def api_sync_positions(): - payload = request.get_json(silent=True) or {} - days_raw = str(payload.get("days", "")).strip() - if not days_raw: - return jsonify({"ok": False, "msg": "请填写天数"}), 400 - try: - days = int(days_raw) - except Exception: - return jsonify({"ok": False, "msg": "天数必须是整数"}), 400 - if days < 1 or days > 365: - return jsonify({"ok": False, "msg": "天数范围 1-365"}), 400 - conn = get_db() - synced = reconcile_external_closes(conn, days=days) - conn.commit() - conn.close() - return jsonify({"ok": True, "days": days, "synced": int(synced)}) - - -def _coerce_ts_ms(val): - if val is None or val == "": - return None - try: - v = float(val) - except (TypeError, ValueError): - return None - if v > 1e12: - return int(v) - if v > 1e9: - return int(v * 1000.0) - return int(v * 1000.0) - - -def _unified_symbol_for_match(symbol_str): - """统一 ETH/USDT:USDT、ETH_USDT、ETH/USDT 便于与 trade_records 比对。""" - s = (symbol_str or "").strip().upper() - if not s: - return "" - if ":" in s: - s = s.split(":")[0] - if "_" in s and "/" not in s: - s = s.replace("_", "/") - if s.endswith("USDT") and "/" not in s and len(s) > 4: - s = f"{s[:-4]}/USDT" - return s - - -def exchange_position_sync_since_ms(): - s = EXCHANGE_POSITION_SYNC_FROM_BJ - if s: - for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d", 10)): - try: - chunk = s[:ln] if len(s) >= ln else s[:10] - dt = datetime.strptime(chunk, fmt) - aware = dt.replace(tzinfo=APP_TZ) - return int(aware.timestamp() * 1000) - except Exception: - continue - dt0 = app_now() - timedelta(days=90) - try: - aware0 = datetime(dt0.year, dt0.month, dt0.day, 0, 0, 0, tzinfo=APP_TZ) - except Exception: - aware0 = datetime.now(APP_TZ) - return int(aware0.timestamp() * 1000) - - -def _normalize_gate_position_history_entry(p): - if not p or not isinstance(p, dict): - return None - info = p.get("info") or {} - sym = p.get("symbol") or "" - if not sym: - c_alt = str(info.get("contract") or "").strip() - if c_alt: - sym = c_alt.replace("_", "/") - side = (p.get("side") or info.get("side") or "").strip().lower() - if side not in ("long", "short"): - sz = info.get("accum_size") if info.get("accum_size") is not None else info.get("size") - try: - szf = float(sz) - if szf > 0: - side = "long" - elif szf < 0: - side = "short" - except (TypeError, ValueError): - side = "" - rp = p.get("realizedPnl") - if rp is None: - rp = info.get("pnl") - try: - rp_f = float(rp) if rp is not None and str(rp).strip() != "" else None - except (TypeError, ValueError): - rp_f = None - close_ms = _coerce_ts_ms(p.get("lastUpdateTimestamp")) - if close_ms is None: - close_ms = _coerce_ts_ms(info.get("time")) - open_ms = _coerce_ts_ms(p.get("timestamp")) - if open_ms is None: - open_ms = _coerce_ts_ms(info.get("first_open_time")) - c_raw = str(info.get("contract") or "").strip() - t_raw = info.get("time") - sync_key = f"{c_raw}|{t_raw}|{side}" - return { - "symbol_u": _unified_symbol_for_match(sym), - "side": side, - "close_ms": close_ms, - "open_ms": open_ms, - "pnl": rp_f, - "sync_key": sync_key, - } - - -def fetch_gate_positions_close_history(): - if not exchange_private_api_configured(): - return [] - ensure_markets_loaded() - since_ms = exchange_position_sync_since_ms() - until_ms = int(time.time() * 1000) - out = [] - offset = 0 - page_limit = min(100, int(EXCHANGE_POSITION_HISTORY_LIMIT)) - max_total = int(EXCHANGE_POSITION_HISTORY_LIMIT) - - def _pull(params_extra): - nonlocal offset - offset = 0 - while len(out) < max_total: - params = dict(params_extra) - params["offset"] = offset - params["until"] = until_ms - try: - rows = exchange.fetch_positions_history( - None, - since=int(since_ms), - limit=page_limit, - params=params, - ) - except Exception: - return False - if not rows: - break - for p in rows: - h = _normalize_gate_position_history_entry(p) - if h and h["close_ms"] and h["side"] in ("long", "short") and h["symbol_u"]: - out.append(h) - offset += len(rows) - if len(rows) < page_limit: - break - return True - - if not _pull({"settle": "usdt"}): - _pull({}) - return out[:max_total] - - -def sync_trade_records_from_exchange(conn, force=False): - """为未同步的 trade_records 回填 Gate 平仓历史中的已实现盈亏。返回统计 dict。""" - global _LAST_EXCHANGE_PNL_SYNC_AT - stats = {"ok": False, "hist_count": 0, "matched": 0, "pending": 0, "skipped": False} - if not exchange_private_api_configured(): - stats["reason"] = "未配置 GATE_API_KEY / GATE_API_SECRET" - return stats - now = time.time() - if not force and now - _LAST_EXCHANGE_PNL_SYNC_AT < 25.0: - stats["ok"] = True - stats["skipped"] = True - return stats - try: - hist = fetch_gate_positions_close_history() - except Exception as e: - stats["reason"] = str(e) - return stats - stats["hist_count"] = len(hist) - if not hist: - stats["ok"] = True - stats["reason"] = "交易所平仓历史为空(请检查 API 权限或 EXCHANGE_POSITION_SYNC_FROM_BJ)" - return stats - candidates = conn.execute( - """ - SELECT id, symbol, direction, closed_at, closed_at_ms, opened_at, opened_at_ms - FROM trade_records - WHERE (exchange_sync_key IS NULL OR TRIM(exchange_sync_key) = '') - OR exchange_realized_pnl IS NULL - ORDER BY id DESC - LIMIT 200 - """ - ).fetchall() - stats["pending"] = len(candidates) - if not candidates: - stats["ok"] = True - _LAST_EXCHANGE_PNL_SYNC_AT = now - return stats - used = set() - matched = 0 - for tr in candidates: - close_ms_trade = _to_ms_with_fallback( - tr["closed_at_ms"] if "closed_at_ms" in tr.keys() else None, tr["closed_at"] - ) or opened_at_str_to_ms(tr["closed_at"]) - open_ms_trade = _to_ms_with_fallback( - tr["opened_at_ms"] if "opened_at_ms" in tr.keys() else None, tr["opened_at"] - ) or opened_at_str_to_ms(tr["opened_at"]) - if close_ms_trade is None: - continue - best = None - best_d = None - for h in hist: - sk = h["sync_key"] - if not sk or sk in used: - continue - if h["symbol_u"] != _unified_symbol_for_match(tr["symbol"]): - continue - if h["side"] != (tr["direction"] or "long").strip().lower(): - continue - cm = h["close_ms"] - if cm is None: - continue - if open_ms_trade is not None: - if cm < open_ms_trade - 15 * 60 * 1000: - continue - if cm > open_ms_trade + 15 * 86400 * 1000: - continue - else: - if abs(cm - close_ms_trade) > 3 * 86400 * 1000: - continue - d = abs(cm - close_ms_trade) - if best_d is None or d < best_d: - best_d = d - best = h - if best is None or best_d is None or best_d > 90 * 60 * 1000: - continue - sk = best["sync_key"] - if sk in used: - continue - eo = ms_to_app_local_str(best["open_ms"]) if best.get("open_ms") else None - ec = ms_to_app_local_str(best["close_ms"]) if best.get("close_ms") else None - pnl_val = best.get("pnl") - if pnl_val is None: - pnl_val = 0.0 - conn.execute( - """ - UPDATE trade_records - SET exchange_realized_pnl = ?, exchange_opened_at = ?, exchange_closed_at = ?, exchange_sync_key = ? - WHERE id = ? - """, - (float(pnl_val), eo, ec, sk, int(tr["id"])), - ) - used.add(sk) - matched += 1 - stats["matched"] = matched - stats["ok"] = True - _LAST_EXCHANGE_PNL_SYNC_AT = now - try: - conn.commit() - except Exception: - pass - return stats - - -# ====================== 主页面 ====================== -def render_main_page(page="trade", embed_mode=None): - now = app_now() - trading_day = get_trading_day(now) - list_window = _list_window_from_request() - start_bj, end_bj = utc_window_to_bj_sql_strings(list_window["start_utc"], list_window["end_utc"], APP_TZ) - conn = get_db() - session_row = ensure_session(conn, trading_day) - local_current_capital = float(session_row["current_capital"]) - from lib.instance.instance_embed_context_lib import ( - embed_render_plan, - minimal_stats_bundle, - trade_records_summary, - ) - - plan = embed_render_plan(page, embed_mode) - if plan.exchange_capitals: - funding_capital, trading_capital = get_exchange_capitals() - else: - funding_capital, trading_capital = None, None - # 资金账户:仅展示交易所读取结果(含 0)。不可用 TOTAL_CAPITAL 兜底,否则会与实盘不符。 - funding_usdt = round(funding_capital, 2) if funding_capital is not None else None - current_capital = round(trading_capital, 2) if trading_capital is not None else round(local_current_capital, 2) - recommended_capital = round(float(get_recommended_capital(current_capital)), 2) - key_list = ( - conn.execute("SELECT * FROM key_monitors").fetchall() if plan.key_list else [] - ) - key_history = ( - conn.execute( - "SELECT * FROM key_monitor_history WHERE closed_at >= ? AND closed_at <= ? ORDER BY id DESC LIMIT 500", - (start_bj, end_bj), - ).fetchall() - if plan.key_history - else [] - ) - stats_bundle = ( - compute_stats_bundle(conn, trading_day, now) - if plan.stats_bundle - else minimal_stats_bundle(TRADING_DAY_RESET_HOUR) - ) - order_list = [] - if plan.orders: - raw_order_list = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() - for o in raw_order_list: - order_list.append(enrich_order_item(row_to_dict(o), current_capital)) - exchange_pnl_sync = {} - if exchange_private_api_configured() and not request_is_hub_soft_nav() and embed_mode not in ( - "fragment", - "shell", - ): - try: - exchange_pnl_sync = sync_trade_records_from_exchange(conn) or {} - except Exception as e: - exchange_pnl_sync = {"ok": False, "reason": str(e)} - tr_ts = sql_list_time_field("closed_at", "created_at", "opened_at") - if plan.records_rows: - raw_records = conn.execute( - f"SELECT * FROM trade_records WHERE {tr_ts} >= ? AND {tr_ts} <= ? ORDER BY id DESC LIMIT 1000", - (start_bj, end_bj), - ).fetchall() - records = [to_effective_trade_dict(r) for r in raw_records] - total = len(records) - miss_count = sum(1 for r in records if (r.get("effective_result") or "") == "错过") - win = count_winning_trades(records) - occupied_miss_total = sum( - 1 - for r in records - if (r.get("effective_result") or "") == "错过" - and ("持仓占用" in str(r.get("effective_miss_reason") or "")) - ) - rate = round(win / total * 100, 2) if total else 0 - elif plan.records_summary: - summary = trade_records_summary(conn, start_bj, end_bj, tr_ts) - records = summary["records"] - total = summary["total"] - miss_count = summary["miss_count"] - rate = summary["rate"] - occupied_miss_total = summary["occupied_miss_total"] - else: - records = [] - total = miss_count = rate = occupied_miss_total = 0 - active_count = len(order_list) - from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors - - position_limit_count = count_position_limit_active_monitors(conn) - opens_today = count_opens_for_trading_day(conn, trading_day) - risk_status = hub_account_risk_status(conn) - can_trade = can_trade_new_open( - time_allows=trading_day_reset_allows_new_open(now), - active_count=position_limit_count, - max_active_positions=MAX_ACTIVE_POSITIONS, - opens_today=opens_today, - hard_limit=DAILY_OPEN_HARD_LIMIT, - extra_blocks=not risk_status.get("can_trade", True), - ) - key_rule_ctx = key_monitor_rule_template_context( - kline_timeframe=KLINE_TIMEFRAME, - key_breakout_amp_min_pct=KEY_BREAKOUT_AMP_MIN_PCT, - key_volume_ma_bars=KEY_VOLUME_MA_BARS, - key_volume_ratio_min=KEY_VOLUME_RATIO_MIN, - key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR, - key_daily_volume_rank_max=KEY_DAILY_VOLUME_RANK_MAX, - key_confirm_breakout_bar=KEY_CONFIRM_BREAKOUT_BAR, - key_confirm_bar=KEY_CONFIRM_BAR, - key_alert_max_times=KEY_ALERT_MAX_TIMES, - key_alert_interval_minutes=KEY_ALERT_INTERVAL_MINUTES, - key_stop_outside_breakout_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT, - key_trend_stop_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT, - false_breakout_validity_hours=FALSE_BREAKOUT_VALIDITY_HOURS, - trigger_entry_validity_hours=TRIGGER_ENTRY_VALIDITY_HOURS, - ) - strategy_extra = {} - if plan.strategy: - from lib.strategy.strategy_ui import strategy_render_extras - - strategy_extra = strategy_render_extras( - conn, - page, - default_risk_percent=float(RISK_PERCENT), - request_obj=request, - trend_cfg=app.extensions.get("strategy_trend_cfg"), - ) - conn.close() - from lib.instance.instance_embed_lib import embed_context_extras - - template_ctx = dict( - page=page, - key=key_list, - key_history=key_history, - stats_bundle=stats_bundle, - order=order_list, - record=records, - total=total, - miss_count=miss_count, - rate=rate, - trading_day=trading_day, - funding_usdt=funding_usdt, - daily_start_capital=DAILY_START_CAPITAL, - current_capital=current_capital, - recommended_capital=recommended_capital, - btc_leverage=BTC_LEVERAGE, - alt_leverage=ALT_LEVERAGE, - reset_hour=TRADING_DAY_RESET_HOUR, - balance_refresh_seconds=BALANCE_REFRESH_SECONDS, - auto_transfer_enabled=AUTO_TRANSFER_ENABLED, - auto_transfer_amount=AUTO_TRANSFER_AMOUNT, - auto_transfer_from=AUTO_TRANSFER_FROM, - auto_transfer_to=AUTO_TRANSFER_TO, - auto_transfer_bj_hour=AUTO_TRANSFER_BJ_HOUR, - transfer_amount_fmt=format_usdt(AUTO_TRANSFER_AMOUNT), - full_margin_buffer_ratio=FULL_MARGIN_BUFFER_RATIO, - price_refresh_seconds=PRICE_REFRESH_SECONDS, - active_count=position_limit_count, - can_trade=can_trade, - opens_today=opens_today, - daily_open_hard_limit=DAILY_OPEN_HARD_LIMIT, - daily_open_alert_threshold=DAILY_OPEN_ALERT_THRESHOLD, - focus_key_id=(key_list[0]["id"] if key_list else None), - focus_order_id=(order_list[0]["id"] if order_list else None), - data_export_version=3, - list_window=list_window, - list_window_presets={ - "utc_today": PRESET_UTC_TODAY, - "utc_last24h": PRESET_UTC_LAST24H, - "utc_last7d": PRESET_UTC_LAST7D, - "custom": PRESET_CUSTOM, - }, - key_alert_max_times=KEY_ALERT_MAX_TIMES, - risk_percent=RISK_PERCENT, - position_sizing_mode=POSITION_SIZING_MODE, - position_sizing_mode_label=mode_label_zh(POSITION_SIZING_MODE), - open_position_button_label=( - "开仓(全仓杠杆)" if is_full_margin_mode(POSITION_SIZING_MODE) else "开仓(以损定仓)" - ), - breakeven_rr_trigger=BREAKEVEN_RR_TRIGGER, - breakeven_offset_pct=BREAKEVEN_OFFSET_PCT, - occupied_miss_total=occupied_miss_total, - price_fmt=format_price_for_symbol, - funds_fmt=format_usdt, - usdt_fmt=format_usdt, - signed_usdt_fmt=format_signed_usdt, - entry_reason_options=list(ENTRY_REASON_OPTIONS), - entry_reason_other_value=ENTRY_REASON_OTHER, - journal_chart_tf_choices=JOURNAL_CHART_TF_CHOICES, - journal_chart_default_tf1=JOURNAL_CHART_DEFAULT_TF1, - journal_chart_default_tf2=JOURNAL_CHART_DEFAULT_TF2, - journal_chart_default_limit=JOURNAL_CHART_DEFAULT_LIMIT, - journal_chart_default_anchor=JOURNAL_CHART_DEFAULT_ANCHOR, - exchange_display=EXCHANGE_DISPLAY_NAME, - risk_status=risk_status, - max_active_positions=MAX_ACTIVE_POSITIONS, - manual_min_planned_rr=MANUAL_MIN_PLANNED_RR, - key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR, - key_rule_ctx=key_rule_ctx, - kline_timeframe=KLINE_TIMEFRAME, - exchange_pnl_sync=exchange_pnl_sync, - **strategy_extra, - **embed_context_extras("gate_bot"), - ) - if embed_mode == "fragment": - return render_template("embed_page_fragment.html", **template_ctx) - if embed_mode == "shell": - return render_template("embed_shell.html", initial_tab=page, **template_ctx) - return render_template("index.html", **template_ctx) - - -@app.route("/api/sync_exchange_pnl") -@login_required -def api_sync_exchange_pnl(): - conn = get_db() - stats = sync_trade_records_from_exchange(conn, force=True) - try: - conn.commit() - except Exception: - pass - conn.close() - return jsonify(stats) - - -@app.route("/") -@login_required -def index(): - return redirect("/trade") - - -@app.route("/key_monitor") -@login_required -def key_monitor_page(): - return render_main_page("key_monitor") - - -@app.route("/trade") -@login_required -def trade_page(): - return render_main_page("trade") - - -@app.route("/records") -@login_required -def records_page(): - return render_main_page("records") - - -@app.route("/stats") -@login_required -def stats_page(): - return render_main_page("stats") - - -@app.route("/api/account_snapshot") -@login_required -def api_account_snapshot(): - now = app_now() - trading_day = get_trading_day(now) - conn = get_db() - session_row = ensure_session(conn, trading_day) - local_current_capital = float(session_row["current_capital"]) - funding_capital, trading_capital = get_exchange_capitals(force=True) - funding_usdt = round(funding_capital, 2) if funding_capital is not None else None - current_capital = round(trading_capital, 2) if trading_capital is not None else round(local_current_capital, 2) - recommended_capital = round(float(get_recommended_capital(current_capital)), 2) - from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors - - position_limit_count = count_position_limit_active_monitors(conn) - opens_today = count_opens_for_trading_day(conn, trading_day) - risk_status = hub_account_risk_status(conn) - conn.close() - can_trade = can_trade_new_open( - time_allows=trading_day_reset_allows_new_open(now), - active_count=position_limit_count, - max_active_positions=MAX_ACTIVE_POSITIONS, - opens_today=opens_today, - hard_limit=DAILY_OPEN_HARD_LIMIT, - extra_blocks=not risk_status.get("can_trade", True), - ) - available_trading_usdt = get_available_trading_usdt() - return jsonify({ - "funding_usdt": funding_usdt, - "current_capital": current_capital, - "available_trading_usdt": round(available_trading_usdt, 2) if available_trading_usdt is not None else None, - "recommended_capital": recommended_capital, - "active_count": position_limit_count, - "max_active_positions": MAX_ACTIVE_POSITIONS, - "can_trade": can_trade, - "opens_today": opens_today, - "daily_open_hard_limit": DAILY_OPEN_HARD_LIMIT, - "daily_open_alert_threshold": DAILY_OPEN_ALERT_THRESHOLD, - "manual_min_planned_rr": MANUAL_MIN_PLANNED_RR, - "trading_day": trading_day, - "risk_status": risk_status, - }) - - -@app.route("/api/price_snapshot") -@login_required -def api_price_snapshot(): - conn = get_db() - key_rows = conn.execute( - "SELECT id,symbol,monitor_type,direction,upper,lower,fib_entry_price,fib_stop_loss,fib_take_profit,fib_limit_order_id,created_at FROM key_monitors" - ).fetchall() - order_rows = conn.execute( - "SELECT id,symbol,exchange_symbol,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage," - "time_close_enabled,time_close_hours,time_close_at_ms,opened_at_ms FROM order_monitors WHERE status='active'" - ).fetchall() - - try: - ensure_markets_loaded() - except Exception: - pass - - symbol_set = set() - for r in key_rows: - symbol_set.add(r["symbol"]) - for r in order_rows: - symbol_set.add(r["symbol"]) - - prices = {} - for s in symbol_set: - p = get_price(s) - if p is not None: - prices[s] = float(p) - - all_swap_positions = [] - if exchange_private_api_configured(): - try: - ensure_markets_loaded() - # 显式 USDT 本位;不传 symbols 拉全量,再在本地按合约对齐 - all_swap_positions = exchange.fetch_positions(None, {"settle": "usdt"}) or [] - except Exception: - try: - all_swap_positions = exchange.fetch_positions() or [] - except Exception: - all_swap_positions = [] - - key_prices = [] - for r in key_rows: - is_fib = is_fib_key_monitor_type(r["monitor_type"]) - is_fb = is_false_breakout_key_monitor_type(r["monitor_type"]) - is_te = is_trigger_entry_key_monitor_type(r["monitor_type"]) - if is_fib or is_fb or is_te: - price = get_symbol_mark_price(r["symbol"]) - else: - price = prices.get(r["symbol"]) - if price is None: - continue - upper_diff, upper_pct = calc_price_diff_pct(price, r["upper"]) - lower_diff, lower_pct = calc_price_diff_pct(price, r["lower"]) - gate = None - gate_summary = "-" - gate_metrics = "" - fib_gate_ok = True - fb_gate_ok = True - te_gate_ok = True - box_gate_ok = True - if is_fib: - direction = (r["direction"] or "long").lower() - inval = fib_invalidate_by_mark(direction, price, r["upper"], r["lower"]) - fib_gate_ok = not inval - entry = _sqlite_row_val(r, "fib_entry_price") - entry_txt = format_price_for_symbol(r["symbol"], entry) if entry else "-" - gate_summary = f"斐波 挂E={entry_txt} {'标记价将失效' if inval else '等待成交'}" - if _sqlite_row_val(r, "fib_limit_order_id"): - gate_metrics = f"限价单:{_sqlite_row_val(r, 'fib_limit_order_id')}" - elif is_fb: - entry = _sqlite_row_val(r, "fib_entry_price") - entry_txt = format_price_for_symbol(r["symbol"], entry) if entry else "-" - prev = false_breakout_gate_preview( - entry_display=entry_txt, - limit_order_id=_sqlite_row_val(r, "fib_limit_order_id"), - created_at=_sqlite_row_val(r, "created_at"), - now=app_now(), - ) - gate_summary = prev.get("summary") or "-" - gate_metrics = prev.get("metrics") or "" - fb_gate_ok = bool(prev.get("gate_ok")) - elif is_te: - direction = (r["direction"] or "long").lower() - entry = _sqlite_row_val(r, "fib_entry_price") - tp_v = _sqlite_row_val(r, "fib_take_profit") - entry_txt = format_price_for_symbol(r["symbol"], entry) if entry else "-" - tp_txt = format_price_for_symbol(r["symbol"], tp_v) if tp_v else "-" - sl_v = _sqlite_row_val(r, "fib_stop_loss") - inv = ( - trigger_entry_invalidate( - r["monitor_type"], direction, price, float(sl_v or 0), float(tp_v or 0) - ) - if tp_v - else None - ) - prev = trigger_entry_gate_preview( - monitor_type=r["monitor_type"], - entry_display=entry_txt, - take_profit_display=tp_txt, - created_at=_sqlite_row_val(r, "created_at"), - now=app_now(), - tp_invalidated=inv == "tp", - sl_invalidated=inv == "sl", - hours=TRIGGER_ENTRY_VALIDITY_HOURS, - ) - gate_summary = prev.get("summary") or "-" - gate_metrics = prev.get("metrics") or "" - te_gate_ok = bool(prev.get("gate_ok")) - elif (r["monitor_type"] or "").strip() in KEY_MONITOR_RS_TYPES: - try: - prev = _key_rs_gate_preview(r["symbol"], r["upper"], r["lower"]) - gate_summary = prev.get("summary") or "-" - gate_metrics = prev.get("metrics") or "" - except Exception: - gate_summary = "-" - elif (r["monitor_type"] or "").strip() in KEY_MONITOR_AUTO_TYPES: - direction = (r["direction"] or "long").lower() - if box_breakout_invalidate_by_mark(direction, price, r["upper"], r["lower"]): - edge_label = box_breakout_invalidate_edge_label(direction) - gate_summary = f"反向突破{edge_label}·将撤销" - box_gate_ok = False - else: - try: - gate = _key_hard_checks( - r["symbol"], - direction, - r["upper"], - r["lower"], - r["monitor_type"], - ) - except Exception: - gate = None - if gate: - rank_seg = "ERR" if int(gate.get("rank_total") or 0) <= 0 else f"{gate.get('rank')}/{gate.get('rank_total')}" - gate_summary = ( - f"量:{'Y' if gate.get('vol_ok') else 'N'} " - f"破:{'Y' if gate.get('breakout_ok') else 'N'} " - f"幅:{'Y' if gate.get('amp_ok') else 'N'} " - f"二确:{'Y' if gate.get('confirm_ok') else 'N'} " - f"排:{'Y' if gate.get('rank_ok') else 'N'}({rank_seg})" - ) - if gate.get("breakout_ok"): - try: - vol_now = round(float(gate.get("vol_break") or 0), 4) - vol_avg = round(float(gate.get("avg20") or 0), 4) - amp_pct = round(float(gate.get("amp_pct") or 0), 4) - cfm_close = round(float(gate.get("confirm_close") or 0), 8) - edge = round(float(gate.get("edge_price") or 0), 8) - gate_metrics = ( - f"量值:{vol_now}/{vol_avg} " - f"幅值:{amp_pct}% " - f"二确值:{cfm_close}@{edge}" - ) - except Exception: - gate_metrics = "" - px_disp = format_price_for_symbol(r["symbol"], price) - try: - price_num = float(px_disp) if px_disp != "-" else float(price) - except Exception: - price_num = float(price) - key_prices.append({ - "id": r["id"], - "symbol": r["symbol"], - "price": price_num, - "price_display": px_disp, - "upper_diff": upper_diff, - "upper_pct": upper_pct, - "lower_diff": lower_diff, - "lower_pct": lower_pct, - "gate_summary": gate_summary, - "gate_ok": ( - fib_gate_ok if is_fib - else fb_gate_ok if is_fb - else te_gate_ok if is_te - else box_gate_ok and bool(gate and gate.get("ok")) - ), - "gate_metrics": gate_metrics, - }) - - order_prices = [] - for r in order_rows: - price = prices.get(r["symbol"]) - if price is None: - continue - margin = float(r["margin_capital"] or 0) - leverage = float(r["leverage"] or 0) - entry = float(r["trigger_price"] or 0) - pnl = calc_pnl(r["direction"], entry, price, margin, leverage) if entry > 0 else 0 - pnl_pct = round((pnl / margin * 100), 4) if margin > 0 else 0 - exchange_tpsl = {"sl": None, "tp": None} - ex_sym = resolve_monitor_exchange_symbol(r) - prow = _select_live_position_row(all_swap_positions, ex_sym, r["direction"]) - lev_row = r["leverage"] if "leverage" in r.keys() else None - ex_metrics = parse_ccxt_position_metrics(prow, order_leverage=lev_row) if prow else None - payload = { - "id": r["id"], - "symbol": r["symbol"], - "float_pnl": round(pnl, 2), - "float_pct": pnl_pct, - "plan_margin": round(margin, 2) if margin else None, - "exchange_initial_margin": None, - "exchange_notional": None, - "exchange_mark_price": None, - "pnl_source": "plan", - } - if ex_metrics: - if ex_metrics.get("initial_margin") is not None: - payload["exchange_initial_margin"] = ex_metrics["initial_margin"] - if ex_metrics.get("notional") is not None: - payload["exchange_notional"] = ex_metrics["notional"] - if ex_metrics.get("mark_price") is not None: - payload["exchange_mark_price"] = ex_metrics["mark_price"] - if ex_metrics.get("unrealized_pnl") is not None: - payload["float_pnl"] = round(float(ex_metrics["unrealized_pnl"]), 2) - payload["pnl_source"] = "exchange" - denom = ex_metrics.get("initial_margin") or margin - payload["float_pct"] = ( - round((payload["float_pnl"] / float(denom)) * 100, 4) if denom and float(denom) > 0 else pnl_pct - ) - px_for_fmt = float(price) - if ex_metrics and ex_metrics.get("mark_price") is not None: - try: - px_for_fmt = float(ex_metrics["mark_price"]) - except (TypeError, ValueError): - pass - px_disp = format_price_for_symbol(r["symbol"], px_for_fmt) - try: - payload["price"] = float(px_disp) if px_disp != "-" else px_for_fmt - except Exception: - payload["price"] = px_for_fmt - payload["price_display"] = px_disp - if exchange_private_api_configured(): - try: - exchange_tpsl = fetch_exchange_tpsl_slots( - ex_sym, - r["direction"], - plan_sl=r["stop_loss"], - plan_tp=r["take_profit"], - ) - except Exception: - exchange_tpsl = {"sl": None, "tp": None} - payload["exchange_tpsl"] = exchange_tpsl - apply_order_price_display_fields( - payload, - direction=r["direction"], - entry_price=entry, - initial_stop_loss=r["initial_stop_loss"], - stop_loss=r["stop_loss"], - take_profit=r["take_profit"], - calc_rr_ratio_fn=calc_rr_ratio, - exchange_tpsl=exchange_tpsl, - format_price_fn=format_price_for_symbol, - symbol=r["symbol"], - margin_capital=margin, - leverage=leverage, - exchange_notional=ex_metrics.get("notional") if ex_metrics else None, - contracts=abs(_position_row_effective_contracts(prow)) if prow else None, - contract_size=float(get_contract_size(r["symbol"])) if r["symbol"] else 1.0, - mark_price=ex_metrics.get("mark_price") if ex_metrics else price, - funds_decimals=FUNDS_DECIMALS, - ) - apply_time_close_to_payload(payload, r) - payload["opened_at"] = r["opened_at"] if "opened_at" in r.keys() else None - open_ms = r["opened_at_ms"] if "opened_at_ms" in r.keys() else None - payload["opened_at_ms"] = int(open_ms) if open_ms not in (None, "") else None - new_sl, new_tp, changed = order_monitor_tpsl_needs_sync( - r["stop_loss"], r["take_profit"], exchange_tpsl - ) - if changed: - try: - conn.execute( - "UPDATE order_monitors SET stop_loss=?, take_profit=? WHERE id=?", - (new_sl, new_tp, int(r["id"])), - ) - except Exception: - pass - order_prices.append(payload) - - try: - conn.commit() - except Exception: - pass - conn.close() - - from lib.hub.hub_position_metrics import build_position_marks_list - - position_marks = build_position_marks_list( - all_swap_positions, - format_mark_display=lambda sym, px: format_price_for_symbol(sym, px), - ) - - return jsonify({ - "updated_at": app_now_str(), - "key_prices": key_prices, - "order_prices": order_prices, - "position_marks": position_marks, - "positions_raw_count": len(all_swap_positions), - }) - - -@app.route("/api/order//cancel_tpsl", methods=["POST"]) -@login_required -def api_order_cancel_tpsl(order_id): - data = request.get_json(silent=True) or {} - role = (data.get("role") or "").strip().lower() - if role not in ("sl", "tp"): - return jsonify({"ok": False, "msg": "role 须为 sl 或 tp"}), 400 - conn = get_db() - row = conn.execute( - "SELECT * FROM order_monitors WHERE id=? AND status='active'", - (order_id,), - ).fetchone() - conn.close() - if not row: - return jsonify({"ok": False, "msg": "持仓不存在或已结束"}), 404 - ok, reason = ensure_exchange_live_ready() - if not ok: - return jsonify({"ok": False, "msg": reason}), 400 - ex_sym = resolve_monitor_exchange_symbol(row) - slots = fetch_exchange_tpsl_slots( - ex_sym, row["direction"], plan_sl=row["stop_loss"], plan_tp=row["take_profit"] - ) - slot = slots.get(role) - if not slot: - return jsonify({"ok": False, "msg": f"交易所未找到{'止损' if role == 'sl' else '止盈'}委托"}), 404 - try: - cancel_gate_tpsl_slot(ex_sym, slot) - slots = fetch_exchange_tpsl_slots( - ex_sym, row["direction"], plan_sl=row["stop_loss"], plan_tp=row["take_profit"] - ) - return jsonify({"ok": True, "msg": "已撤单", "exchange_tpsl": slots}) - except Exception as e: - return jsonify({"ok": False, "msg": friendly_exchange_error(e)}), 400 - - -@app.route("/api/order//place_tpsl", methods=["POST"]) -@login_required -def api_order_place_tpsl(order_id): - data = request.get_json(silent=True) or {} - conn = get_db() - row = conn.execute( - "SELECT * FROM order_monitors WHERE id=? AND status='active'", - (order_id,), - ).fetchone() - if not row: - conn.close() - return jsonify({"ok": False, "msg": "持仓不存在或已结束"}), 404 - symbol = row["symbol"] - direction = row["direction"] - live_price = get_price(symbol) - if live_price is None: - conn.close() - return jsonify({"ok": False, "msg": "获取交易所实时价格失败"}), 400 - try: - sltp_mode = (data.get("sltp_mode") or "price").strip().lower() - stop_loss, take_profit = _resolve_tpsl_prices_for_manual(direction, live_price, sltp_mode, data) - except Exception as e: - conn.close() - return jsonify({"ok": False, "msg": str(e)}), 400 - planned_rr = calc_rr_ratio(direction, live_price, stop_loss, take_profit) - if planned_rr is None or planned_rr < MANUAL_MIN_PLANNED_RR: - conn.close() - rr_txt = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算" - return jsonify( - { - "ok": False, - "msg": f"计划盈亏比 {rr_txt}:1 低于最低要求 {MANUAL_MIN_PLANNED_RR}:1", - } - ), 400 - try: - replace_active_monitor_tpsl_on_exchange(row, stop_loss, take_profit) - except Exception as e: - conn.close() - return jsonify({"ok": False, "msg": friendly_exchange_error(e)}), 400 - conn.execute( - "UPDATE order_monitors SET stop_loss=?, take_profit=? WHERE id=?", - (stop_loss, take_profit, order_id), - ) - conn.commit() - ex_sym = resolve_monitor_exchange_symbol(row) - slots = fetch_exchange_tpsl_slots(ex_sym, direction, plan_sl=stop_loss, plan_tp=take_profit) - prow = None - ex_metrics = None - if exchange_private_api_configured(): - try: - rows = exchange.fetch_positions([ex_sym]) or exchange.fetch_positions() or [] - prow = _select_live_position_row(rows, ex_sym, direction) - if prow: - ex_metrics = parse_ccxt_position_metrics(prow, order_leverage=row["leverage"]) - except Exception: - pass - from lib.trade.order_monitor_display_lib import enrich_active_monitor_tpsl_json - - display_extra = enrich_active_monitor_tpsl_json( - row, - stop_loss, - take_profit, - slots, - position_row=prow, - exchange_notional=ex_metrics.get("notional") if ex_metrics else None, - contract_size=float(get_contract_size(symbol)) if symbol else 1.0, - mark_price=live_price, - calc_rr_ratio_fn=calc_rr_ratio, - format_price_fn=format_price_for_symbol, - symbol=symbol, - funds_decimals=FUNDS_DECIMALS, - ) - conn.close() - return jsonify( - { - "ok": True, - "msg": "已先撤后挂止盈止损", - "stop_loss": stop_loss, - "take_profit": take_profit, - "planned_rr": planned_rr, - "exchange_tpsl": slots, - **display_extra, - } - ) - - -@app.route("/api/symbol_liquidity_rank") -@login_required -def api_symbol_liquidity_rank(): - symbol = normalize_symbol_input(request.args.get("symbol")) - if not symbol: - return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400 - rank, total = _daily_volume_rank(symbol) - if total <= 0: - return jsonify({"ok": False, "msg": "日成交量排名读取失败"}), 502 - if rank is None: - return jsonify({"ok": True, "symbol": symbol, "rank": None, "total": int(total), "in_top30": False}) - return jsonify( - { - "ok": True, - "symbol": symbol, - "rank": int(rank), - "total": int(total), - "in_top30": bool(rank <= KEY_DAILY_VOLUME_RANK_MAX), - "rank_max": KEY_DAILY_VOLUME_RANK_MAX, - } - ) - - -@app.route("/api/order_defaults") -@login_required -def api_order_defaults(): - symbol = normalize_symbol_input(request.args.get("symbol")) - direction = (request.args.get("direction") or "long").strip().lower() - if not symbol: - return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400 - if direction not in ("long", "short"): - direction = "long" - exchange_symbol = normalize_exchange_symbol(symbol) - leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) - available = get_available_trading_usdt() - last_price = get_price(symbol) - return jsonify({ - "ok": True, - "symbol": symbol, - "exchange_symbol": exchange_symbol, - "direction": direction, - "leverage": leverage, - "available_trading_usdt": round(available, 2) if available is not None else None, - "last_price": round(float(last_price), 8) if last_price is not None else None, - }) - - -@app.route("/order_focus") -@login_required -def order_focus(): - now = app_now() - trading_day = get_trading_day(now) - conn = get_db() - session_row = ensure_session(conn, trading_day) - local_current_capital = float(session_row["current_capital"]) - _, trading_capital_live = get_exchange_capitals() - current_capital = round(trading_capital_live, 2) if trading_capital_live is not None else round(local_current_capital, 2) - raw_orders = conn.execute("SELECT * FROM order_monitors WHERE status='active' ORDER BY id DESC").fetchall() - conn.close() - orders = [enrich_order_item(row_to_dict(r), current_capital) for r in raw_orders] - picked_id = request.args.get("order_id", "").strip() - selected = None - if picked_id.isdigit(): - selected = next((o for o in orders if int(o["id"]) == int(picked_id)), None) - if selected is None and orders: - selected = orders[0] - return render_template( - "order_focus_v2.html", - orders=orders, - selected_order=selected, - default_timeframe=KLINE_TIMEFRAME, - price_refresh_seconds=PRICE_REFRESH_SECONDS, - exchange_display=EXCHANGE_DISPLAY_NAME, - ) - - -@app.route("/api/order_kline") -@login_required -def api_order_kline(): - order_id_raw = (request.args.get("order_id") or "").strip() - if not order_id_raw.isdigit(): - return jsonify({"ok": False, "msg": "order_id 无效"}), 400 - order_id = int(order_id_raw) - timeframe = (request.args.get("timeframe") or KLINE_TIMEFRAME).strip() - allowed_tfs = {"1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d"} - if timeframe not in allowed_tfs: - timeframe = KLINE_TIMEFRAME - limit = 100 - - now = app_now() - trading_day = get_trading_day(now) - conn = get_db() - session_row = ensure_session(conn, trading_day) - local_current_capital = float(session_row["current_capital"]) - _, trading_capital_live = get_exchange_capitals() - current_capital = round(trading_capital_live, 2) if trading_capital_live is not None else round(local_current_capital, 2) - row = conn.execute("SELECT * FROM order_monitors WHERE id=? AND status='active'", (order_id,)).fetchone() - conn.close() - if not row: - return jsonify({"ok": False, "msg": "订单不存在或已结束"}), 404 - - order_item = enrich_order_item(row_to_dict(row), current_capital) - exchange_symbol = order_item.get("exchange_symbol") or normalize_exchange_symbol(order_item["symbol"]) - try: - ensure_markets_loaded() - ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=limit) - except Exception as e: - return jsonify({"ok": False, "msg": f"K线加载失败:{friendly_exchange_error(e)}"}), 500 - - candles = [] - for bar in ohlcv or []: - if not bar or len(bar) < 6: - continue - ts = int(bar[0] // 1000) - candles.append({ - "time": ts, - "open": float(bar[1]), - "high": float(bar[2]), - "low": float(bar[3]), - "close": float(bar[4]), - "volume": float(bar[5]), - }) - - from lib.instance.focus_chart_lib import ( - build_order_kline_order_payload, - load_swap_positions_for_order_kline, - metrics_for_order_item, - ) - - current_price = get_price(order_item["symbol"]) - positions = load_swap_positions_for_order_kline( - exchange, - private_configured=exchange_private_api_configured(), - ensure_markets_fn=ensure_markets_loaded, - ) - ex_metrics = metrics_for_order_item( - order_item, - positions, - resolve_ex_sym_fn=resolve_monitor_exchange_symbol, - select_live_fn=_select_live_position_row, - parse_metrics_fn=parse_ccxt_position_metrics, - ) - order_payload = build_order_kline_order_payload( - order_item, - ticker_price=current_price, - format_price_fn=format_price_for_symbol, - calc_pnl_fn=calc_pnl, - calc_rr_ratio_fn=calc_rr_ratio, - ex_metrics=ex_metrics, - ) - - from lib.instance.focus_chart_lib import kline_api_price_fields - - price_fields = kline_api_price_fields( - exchange, - exchange_symbol, - candles, - ensure_markets_fn=ensure_markets_loaded, - ) - - return jsonify({ - "ok": True, - "timeframe": timeframe, - "limit": limit, - "order": order_payload, - "candles": candles, - "updated_at": app_now_str(), - **price_fields, - }) - - -@app.route("/key_focus") -@login_required -def key_focus(): - conn = get_db() - key_rows = conn.execute("SELECT * FROM key_monitors ORDER BY id DESC").fetchall() - conn.close() - key_list = [row_to_dict(r) for r in key_rows] - - key_id_raw = (request.args.get("key_id") or "").strip() - symbol_query = normalize_symbol_input(request.args.get("symbol")) - selected_key = None - if key_id_raw.isdigit(): - selected_key = next((k for k in key_list if int(k["id"]) == int(key_id_raw)), None) - if selected_key is None and symbol_query: - selected_key = next((k for k in key_list if (k.get("symbol") or "").upper() == symbol_query), None) - if selected_key is None and key_list: - selected_key = key_list[0] - default_symbol = symbol_query or ((selected_key or {}).get("symbol")) or "BTC/USDT" - return render_template( - "key_focus_v2.html", - key_list=key_list, - selected_key=selected_key, - default_symbol=default_symbol, - default_timeframe=KLINE_TIMEFRAME, - default_kline_limit=200, - price_refresh_seconds=PRICE_REFRESH_SECONDS, - exchange_display=EXCHANGE_DISPLAY_NAME, - ) - - -@app.route("/api/key_kline") -@login_required -def api_key_kline(): - key_id_raw = (request.args.get("key_id") or "").strip() - symbol_input = normalize_symbol_input(request.args.get("symbol")) - timeframe = (request.args.get("timeframe") or KLINE_TIMEFRAME).strip() - if timeframe not in {"1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d"}: - timeframe = KLINE_TIMEFRAME - limit = normalize_kline_limit(request.args.get("limit"), default=200) - - conn = get_db() - key_row = None - if key_id_raw.isdigit(): - key_row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (int(key_id_raw),)).fetchone() - if key_row is None and symbol_input: - key_row = conn.execute( - "SELECT * FROM key_monitors WHERE upper(symbol)=? ORDER BY id DESC LIMIT 1", - (symbol_input,), - ).fetchone() - if key_row is not None: - symbol = (key_row["symbol"] or "").upper() - else: - symbol = symbol_input - conn.close() - if not symbol: - return jsonify({"ok": False, "msg": "请先输入币种或选择关键位"}), 400 - - exchange_symbol = normalize_exchange_symbol(symbol) - try: - ensure_markets_loaded() - ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=limit) - except Exception as e: - return jsonify({"ok": False, "msg": f"K线加载失败:{friendly_exchange_error(e)}"}), 500 - - candles = [] - for bar in ohlcv or []: - if not bar or len(bar) < 6: - continue - candles.append({ - "time": int(bar[0] // 1000), - "open": float(bar[1]), - "high": float(bar[2]), - "low": float(bar[3]), - "close": float(bar[4]), - "volume": float(bar[5]), - }) - - current_price = get_price(symbol) - key_info = None - if key_row is not None: - upper = float(key_row["upper"]) if key_row["upper"] is not None else None - lower = float(key_row["lower"]) if key_row["lower"] is not None else None - upper_diff, upper_pct = calc_price_diff_pct(current_price, upper) if current_price else (None, None) - lower_diff, lower_pct = calc_price_diff_pct(current_price, lower) if current_price else (None, None) - key_info = { - "id": key_row["id"], - "monitor_type": key_row["monitor_type"], - "direction": key_row["direction"] or "long", - "upper": upper, - "lower": lower, - "notification_count": int(key_row["notification_count"] or 0), - "upper_diff": upper_diff, - "upper_pct": upper_pct, - "lower_diff": lower_diff, - "lower_pct": lower_pct, - } - - from lib.instance.focus_chart_lib import enrich_key_kline_response - - price_display, key_info = enrich_key_kline_response( - symbol=symbol, - current_price=current_price, - key_info=key_info, - format_price_fn=format_price_for_symbol, - ) - - from lib.instance.focus_chart_lib import kline_api_price_fields - - price_fields = kline_api_price_fields( - exchange, - exchange_symbol, - candles, - ensure_markets_fn=ensure_markets_loaded, - ) - - return jsonify({ - "ok": True, - "symbol": symbol, - "timeframe": timeframe, - "limit": limit, - "current_price": round(float(current_price), 8) if current_price is not None else None, - "current_price_display": price_display, - "key_monitor": key_info, - "candles": candles, - "updated_at": app_now_str(), - **price_fields, - }) - - -@app.route("/add_key", methods=["POST"]) -@login_required -def add_key(): - conn = None - try: - d = request.form - symbol = normalize_symbol_input(d.get("symbol")) - if not symbol: - flash("symbol 不能为空") - return redirect("/key_monitor") - mt = (d.get("type") or "").strip() - direction_pre = (d.get("direction") or "").strip().lower() - dup_msg = check_duplicate_submit( - session, submit_scope_add_key(symbol, mt, direction_pre or "watch") - ) - if dup_msg: - flash(dup_msg) - return redirect("/key_monitor") - direction_sel = (d.get("direction") or "").strip().lower() - if mt in KEY_MONITOR_RS_TYPES: - direction_sel = KEY_DIRECTION_WATCH - mt = KEY_MONITOR_RS_TYPE - elif direction_sel not in ("long", "short"): - flash("箱体/收敛突破请选择做多或做空") - return redirect("/key_monitor") - allowed_types = ( - tuple(KEY_MONITOR_AUTO_TYPES) - + tuple(KEY_MONITOR_ALERT_ONLY_TYPES) - + tuple(FIB_KEY_MONITOR_TYPES) - + (FALSE_BREAKOUT_MONITOR_TYPE,) - + tuple(TRIGGER_ENTRY_MONITOR_TYPES) - ) - if mt not in allowed_types: - flash("监控类型无效") - return redirect("/key_monitor") - if is_full_margin_mode(POSITION_SIZING_MODE) and monitor_type_disallowed_in_full_margin(mt): - flash( - "全仓杠杆模式下不可添加箱体/收敛突破、斐波或假突破监控;" - "可使用「回调/突破触价开仓」或阻力/支撑(仅提醒),或切换 POSITION_SIZING_MODE=risk 并重启(须无持仓)。" - ) - return redirect("/key_monitor") - skip_volume_rank = is_false_breakout_key_monitor_type(mt) - rank, total = None, None - if not skip_volume_rank: - rank, total = _daily_volume_rank(symbol) - if rank is None: - flash("日成交量排名读取失败,请稍后重试") - return redirect("/key_monitor") - if rank > KEY_DAILY_VOLUME_RANK_MAX: - flash( - f"{symbol} 当前日成交量排名为 {rank}/{total},不在前{KEY_DAILY_VOLUME_RANK_MAX},已拒绝添加关键位" - ) - return redirect("/key_monitor") - conn = get_db() - if mt in KEY_MONITOR_AUTO_TYPES: - occupied = get_active_position_count(conn) - if occupied >= MAX_ACTIVE_POSITIONS: - conn.close() - conn = None - flash( - f"当前持仓已达上限({occupied}/{MAX_ACTIVE_POSITIONS}):无法添加「箱体突破 / 收敛突破」。" - "请平仓后再试,或使用「关键支撑阻力」(仅提醒)。" - ) - return redirect("/key_monitor") - ex_sym_key = normalize_exchange_symbol(symbol) - try: - ensure_markets_loaded() - except Exception: - pass - be_flag = parse_breakeven_enabled_form(d.get("breakeven_enabled")) - tc_en = parse_time_close_enabled_form(d.get("time_close_enabled")) - tc_h = parse_time_close_hours_form(d.get("time_close_hours")) if tc_en else None - if tc_en and not tc_h: - tc_en = 0 - if is_trigger_entry_key_monitor_type(mt): - if direction_sel not in ("long", "short"): - conn.close() - conn = None - flash("触价请选择做多或做空") - return redirect("/key_monitor") - try: - entry_px = float(d.get("trigger_entry") or 0) - sl_px = float(d.get("trigger_sl") or 0) - tp_px = float(d.get("trigger_tp") or 0) - except (TypeError, ValueError): - entry_px = sl_px = tp_px = 0 - if entry_px <= 0 or sl_px <= 0 or tp_px <= 0: - conn.close() - conn = None - flash("触价须填写有效的入场价、止损价、止盈价") - return redirect("/key_monitor") - ok_te, err_te = _add_trigger_entry_key_monitor( - conn, - symbol, - direction_sel, - entry_px, - sl_px, - tp_px, - monitor_type=mt, - breakeven_enabled=be_flag, - time_close_enabled=tc_en, - time_close_hours=tc_h, - ) - conn.commit() - conn.close() - conn = None - if not ok_te: - flash(err_te or "触价开仓监控添加失败") - return redirect("/key_monitor") - trigger_hint = ( - "标记价穿越入场价后立即市价开仓" - if is_breakout_trigger_entry_key_monitor_type(mt) - else "标记价回调触达入场价后下一轮询市价开仓" - ) - flash( - f"{mt}已添加({symbol} 日成交量排名 {rank}/{total})" - f"|有效期 {TRIGGER_ENTRY_VALIDITY_HOURS}h" - f"|{trigger_hint}" - f"|移动保本:{'开' if be_flag else '关'}" - + (f"|{time_close_label(tc_h)}" if tc_en else "") - ) - return redirect("/key_monitor") - if is_false_breakout_key_monitor_type(mt): - fb_sym = normalize_false_breakout_symbol(symbol) - if not fb_sym: - conn.close() - conn = None - flash("假突破仅支持 BTC / ETH") - return redirect("/key_monitor") - symbol = fb_sym - if direction_sel not in ("long", "short"): - conn.close() - conn = None - flash("假突破请选择做多或做空") - return redirect("/key_monitor") - try: - key_px = float(d.get("key_price") or 0) - except (TypeError, ValueError): - key_px = 0 - if key_px <= 0: - conn.close() - conn = None - flash("请填写关键价位(做空填高点,做多填低点)") - return redirect("/key_monitor") - ex_sym_key = normalize_exchange_symbol(symbol) - key_adj = round_price_to_exchange(ex_sym_key, key_px) - key_px = float(key_adj) if key_adj is not None else float(key_px) - try: - upper_px, lower_px = storage_bounds_from_key_price(direction_sel, key_px) - except ValueError as e: - conn.close() - conn = None - flash(str(e)) - return redirect("/key_monitor") - ok_fb, err_fb = _add_false_breakout_key_monitor( - conn, symbol, direction_sel, upper_px, lower_px, key_px, breakeven_enabled=be_flag, - time_close_enabled=tc_en, time_close_hours=tc_h, - ) - conn.commit() - conn.close() - conn = None - if not ok_fb: - flash(err_fb or "假突破监控添加失败") - return redirect("/key_monitor") - flash( - f"假突破监控已添加,限价单已挂出({symbol})" - f"|有效期 {FALSE_BREAKOUT_VALIDITY_HOURS}h|移动保本:{'开' if be_flag else '关'}" - + (f"|{time_close_label(tc_h)}" if tc_en else "") - ) - return redirect("/key_monitor") - try: - upper_raw = float(d.get("upper") or 0) - lower_raw = float(d.get("lower") or 0) - except (TypeError, ValueError): - conn.close() - conn = None - flash("上下沿须为有效数字") - return redirect("/key_monitor") - upper_px = round_price_to_exchange(ex_sym_key, upper_raw) - lower_px = round_price_to_exchange(ex_sym_key, lower_raw) - if float(upper_px) <= float(lower_px): - conn.close() - conn = None - flash("上沿必须大于下沿") - return redirect("/key_monitor") - if is_fib_key_monitor_type(mt): - ok_fib, err_fib = _add_fib_key_monitor( - conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=be_flag, - time_close_enabled=tc_en, time_close_hours=tc_h, - ) - conn.commit() - conn.close() - conn = None - if not ok_fib: - flash(err_fib or "斐波监控添加失败") - return redirect("/key_monitor") - flash( - f"斐波监控已添加,限价单已挂出({symbol} 日成交量排名 {rank}/{total})" - f"|移动保本:{'开' if be_flag else '关'}" - + (f"|{time_close_label(tc_h)}" if tc_en else "") - ) - return redirect("/key_monitor") - sl_tp_mode = "standard" - manual_tp = None - if mt in KEY_MONITOR_AUTO_TYPES: - sl_tp_mode = normalize_sl_tp_mode(d.get("sl_tp_mode")) - if sl_tp_mode == "trend_manual": - try: - manual_tp = float(d.get("manual_take_profit") or 0) - except (TypeError, ValueError): - manual_tp = 0 - if manual_tp <= 0: - conn.close() - conn = None - flash("趋势单方案须填写有效止盈价") - return redirect("/key_monitor") - if direction_sel == "long" and manual_tp <= upper_px: - conn.close() - conn = None - flash("做多趋势单:止盈价应高于上沿(阻力)") - return redirect("/key_monitor") - if direction_sel == "short" and manual_tp >= lower_px: - conn.close() - conn = None - flash("做空趋势单:止盈价应低于下沿(支撑)") - return redirect("/key_monitor") - mtpx = round_price_to_exchange(ex_sym_key, manual_tp) - if mtpx is not None: - manual_tp = float(mtpx) - if mt in KEY_MONITOR_RS_TYPES: - conn.execute( - "INSERT INTO key_monitors " - "(symbol,monitor_type,direction,upper,lower,sl_tp_mode,manual_take_profit,breakeven_enabled," - "max_notify,notify_interval_min,time_close_enabled,time_close_hours) " - "VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", - ( - symbol, - mt, - direction_sel, - upper_px, - lower_px, - sl_tp_mode, - manual_tp, - be_flag, - KEY_ALERT_MAX_TIMES, - KEY_ALERT_INTERVAL_MINUTES, - tc_en, - tc_h, - ), - ) - else: - conn.execute( - "INSERT INTO key_monitors " - "(symbol,monitor_type,direction,upper,lower,sl_tp_mode,manual_take_profit,breakeven_enabled," - "time_close_enabled,time_close_hours) " - "VALUES (?,?,?,?,?,?,?,?,?,?)", - (symbol, mt, direction_sel, upper_px, lower_px, sl_tp_mode, manual_tp, be_flag, tc_en, tc_h), - ) - conn.commit() - conn.close() - conn = None - ctr = False - try: - coin4h_status, _, _ = _status_by_ema55(symbol, "4h") - ctr = (direction_sel == "long" and coin4h_status == "空头") or ( - direction_sel == "short" and coin4h_status == "多头" - ) - except Exception: - pass - extra = "" - if mt in KEY_MONITOR_AUTO_TYPES: - extra = f"|方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'开' if be_flag else '关'}" - if tc_en: - extra += f"|{time_close_label(tc_h)}" - if mt in KEY_MONITOR_RS_TYPES: - flash( - f"添加成功({symbol} 日成交量排名 {rank}/{total})|关键支撑阻力:双向监控上/下沿," - f"5m 收盘突破后微信提醒 {KEY_ALERT_MAX_TIMES} 次(间隔 {KEY_ALERT_INTERVAL_MINUTES} 分钟)" - ) - else: - flash(f"添加成功({symbol} 日成交量排名 {rank}/{total}){extra}") - if ctr: - flash( - "⚠️ 4h EMA55 提示:当前与所选方向逆势;「箱体突破/收敛突破」在条件满足时仍会按计划自动市价开仓,请注意仓位。" - ) - return redirect("/key_monitor") - except Exception as e: - if conn is not None: - try: - conn.close() - except Exception: - pass - flash(f"添加关键位失败:{e}") - return redirect("/key_monitor") - -@app.route("/add_order", methods=["POST"]) -@login_required -def add_order(): - d = request.form - now = app_now() - conn = get_db() - direction = d.get("direction", "long") - symbol = normalize_symbol_input(d.get("symbol")) - if not symbol: - conn.close() - flash("symbol 不能为空") - return redirect("/") - dup_msg = check_duplicate_submit(session, submit_scope_add_order(symbol, direction)) - if dup_msg: - conn.close() - flash(dup_msg) - return redirect("/trade") - ok, reason = precheck_risk(conn, symbol, direction) - if not ok: - if "已达最大持仓数" in reason: - try: - tp_raw = parse_positive_float(d.get("tp")) - sl_raw = parse_positive_float(d.get("sl")) - tgt_raw = parse_positive_float(d.get("tgt")) - except Exception: - tp_raw = sl_raw = tgt_raw = None - ex_miss = normalize_exchange_symbol(symbol) - try: - ensure_markets_loaded() - except Exception: - pass - insert_trade_record( - conn, - symbol=symbol, - monitor_type="下单监控", - direction=direction if direction in ("long", "short") else "long", - trigger_price=round_price_to_exchange(ex_miss, tp_raw) if tp_raw else 0, - stop_loss=round_price_to_exchange(ex_miss, sl_raw) if sl_raw else 0, - take_profit=round_price_to_exchange(ex_miss, tgt_raw) if tgt_raw else 0, - result="错过", - miss_reason=f"持仓占用:{reason}", - opened_at=app_now_str(), - closed_at=app_now_str(), - ) - conn.commit() - conn.close() - flash(f"风控拒绝下单:{reason}") - return redirect("/trade") - ok_live, reason_live = ensure_exchange_live_ready() - if not ok_live: - conn.close() - flash(f"风控拒绝下单:{reason_live}") - return redirect("/trade") - exchange_symbol = normalize_exchange_symbol(symbol) - trading_day = get_trading_day(now) - opens_today_before = conn.execute( - "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", - (trading_day,), - ).fetchone()[0] - session_row = ensure_session(conn, trading_day) - _, trading_capital_live = get_exchange_capitals(force=True) - capital_base = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"]) - trade_style = (d.get("trade_style") or DEFAULT_TRADE_STYLE or "trend").strip().lower() - if trade_style not in ("trend", "swing"): - trade_style = "trend" - available_usdt = get_available_trading_usdt() - live_price = get_price(symbol) - if live_price is None: - conn.close() - flash("获取交易所实时价格失败,请稍后重试") - return redirect("/") - try: - ensure_markets_loaded() - except Exception: - pass - lp_r = round_price_to_exchange(exchange_symbol, live_price) - if lp_r is not None: - live_price = lp_r - sltp_mode = normalize_open_sltp_mode(d.get("sltp_mode")) - try: - stop_loss, take_profit = resolve_open_sltp_prices( - direction, live_price, sltp_mode, d - ) - except ValueError as e: - conn.close() - flash(str(e) or "止盈止损参数错误") - return redirect("/") - if stop_loss <= 0 or take_profit <= 0: - conn.close() - flash("价格参数必须大于0") - return redirect("/trade") - planned_rr_manual = calc_rr_ratio(direction, live_price, stop_loss, take_profit) - if planned_rr_manual is None or planned_rr_manual < MANUAL_MIN_PLANNED_RR: - conn.close() - rr_txt = f"{planned_rr_manual:.4f}" if planned_rr_manual is not None else "无法计算" - flash(f"风控拒绝下单:计划盈亏比 {rr_txt}:1 低于最低要求 {MANUAL_MIN_PLANNED_RR}:1") - return redirect("/trade") - sl_adj = round_price_to_exchange(exchange_symbol, stop_loss) - tp_adj = round_price_to_exchange(exchange_symbol, take_profit) - if sl_adj is not None: - stop_loss = sl_adj - if tp_adj is not None: - take_profit = tp_adj - risk_fraction = calc_risk_fraction(direction, live_price, stop_loss) - if risk_fraction is None: - conn.close() - flash("止损方向不合法:请检查入场方向与止损价格关系") - return redirect("/") - risk_percent = max(0.01, float(RISK_PERCENT)) - risk_amount = round(capital_base * risk_percent / 100.0, 2) - if is_full_margin_mode(POSITION_SIZING_MODE): - ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn)) - if not ok_flat: - conn.close() - flash(flat_msg) - return redirect("/") - leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE) - sizing, sizing_err = compute_full_margin_sizing( - symbol=symbol, - available_usdt=available_usdt if available_usdt is not None else 0.0, - capital_base=capital_base, - buffer_ratio=FULL_MARGIN_BUFFER_RATIO, - btc_leverage=BTC_LEVERAGE, - alt_leverage=ALT_LEVERAGE, - funds_decimals=2, - ) - if sizing_err: - conn.close() - flash(sizing_err) - return redirect("/") - margin_capital = sizing["margin_capital"] - notional_value = sizing["notional_value"] - position_ratio = sizing["position_ratio"] - else: - default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) - try: - leverage_input = parse_positive_float(d.get("leverage")) - leverage = int(leverage_input) if leverage_input is not None else default_leverage - except Exception: - conn.close() - flash("杠杆参数格式错误") - return redirect("/") - if leverage <= 0: - conn.close() - flash("杠杆必须大于0") - return redirect("/") - notional_value = round(risk_amount / risk_fraction, 2) - margin_capital = round(notional_value / leverage, 2) - if capital_base and margin_capital > capital_base: - conn.close() - flash("以损定仓后保证金超过当前交易资金,请放宽止损或降低风险比例") - return redirect("/") - if available_usdt is not None: - max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 2) - if margin_capital > max_margin: - conn.close() - flash(f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U") - return redirect("/") - position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0 - try: - amount, quote_price = prepare_order_amount(exchange_symbol, margin_capital, leverage, live_price) - contract_size = get_contract_size(exchange_symbol) - base_amount = round(float(amount) * contract_size, 8) - order_resp = place_exchange_order(exchange_symbol, direction, amount, leverage, stop_loss=stop_loss, take_profit=take_profit) - open_order_id = order_resp.get("id", "") - tpsl_attached = bool(order_resp.get("tpsl_attached")) - trigger_price = resolve_order_entry_price(order_resp, exchange_symbol, quote_price) - except Exception as e: - conn.close() - flash(friendly_exchange_error(e, available_usdt=available_usdt)) - return redirect("/") - - trigger_price = round_price_to_exchange(exchange_symbol, trigger_price) - stop_loss = round_price_to_exchange(exchange_symbol, stop_loss) - take_profit = round_price_to_exchange(exchange_symbol, take_profit) - - make_order_chart = d.get("order_chart", "").lower() in ("1", "true", "on", "yes") - opened_at_bj = app_now_str() - opened_at_ms = _to_ms_with_fallback(None, opened_at_bj) - planned_rr = calc_rr_ratio(direction, trigger_price, stop_loss, take_profit) - breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER) - breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT) - breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0 - risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage) or risk_amount - risk_percent_db = risk_percent_for_storage(POSITION_SIZING_MODE, risk_percent) - risk_display = format_risk_display_text( - POSITION_SIZING_MODE, risk_percent, risk_amount_final, decimals=2 - ) - if direction == "short": - breakeven_raw = float(trigger_price) * (1 - breakeven_offset_pct / 100.0) - else: - breakeven_raw = float(trigger_price) * (1 + breakeven_offset_pct / 100.0) - breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw) - breakeven_enabled = 1 if (d.get("breakeven_enabled") or "").strip() in ("1", "true", "on", "yes") else 0 - tc_en = parse_time_close_enabled_form(d.get("time_close_enabled")) - tc_h = parse_time_close_hours_form(d.get("time_close_hours")) if tc_en else None - if tc_en and not tc_h: - tc_en = 0 - tc_en, tc_h, tc_at = time_close_insert_values(tc_en, tc_h, opened_at_ms) - conn.execute( - "INSERT INTO order_monitors (symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, margin_capital, leverage, trade_style, risk_percent, risk_amount, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type, time_close_enabled, time_close_hours, time_close_at_ms) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", - ( - symbol, exchange_symbol, direction, trigger_price, stop_loss, stop_loss, take_profit, - margin_capital, leverage, trade_style, risk_percent_db, risk_amount_final, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, 0, breakeven_price, - breakeven_enabled, - notional_value, position_ratio, base_amount, amount, open_order_id, opened_at_bj, opened_at_ms, trading_day, - ORDER_MONITOR_TYPE_MANUAL, - tc_en, tc_h, tc_at, - ) - ) - conn.commit() - new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) - try_persist_exchange_margin_for_order(conn, new_order_id, exchange_symbol, direction, order_leverage=leverage) - conn.commit() - opens_today_after = conn.execute( - "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", - (trading_day,), - ).fetchone()[0] - conn.close() - - chart_name = None - chart_url = None - if make_order_chart and ORDER_CHART_ENABLED: - try: - title_prefix = f"{symbol} {direction} #{new_order_id}" - chart_name = generate_order_open_chart( - exchange_symbol, - title_prefix, - opened_at_ms=opened_at_ms, - entry_price=trigger_price, - ) - if chart_name: - chart_url = f"/static/images/order_charts/{chart_name}" - except Exception: - chart_name = None - chart_url = None - - if chart_name: - try: - journal_id = f"order_{new_order_id}" - coin = journal_coin_from_symbol(symbol) - open_local = (opened_at_bj or "")[:16].replace(" ", "T") - if len(open_local) < 16: - open_local = app_now().strftime("%Y-%m-%dT%H:%M") - close_local = open_local - hold_duration = calc_duration_text(open_local, close_local) - note = ( - f"auto_from_open_order id={new_order_id} oid={open_order_id} " - f"chart={chart_name} tfs={','.join(ORDER_CHART_TFS)} limit={ORDER_CHART_LIMIT}" - ) - conn = get_db() - conn.execute( - """INSERT OR REPLACE INTO journal_entries - (id, open_datetime, close_datetime, hold_duration, coin, tf, pnl, entry_reason, exit_reason, - expect_rr, real_rr, early_exit, early_exit_reason, early_exit_trigger, early_exit_note, - mood_score, mood_ai_score, mood_ai_comment, mood_issues, post_breakeven_stare, - new_trade_while_occupied, note, image) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", - ( - journal_id, - open_local, - close_local, - hold_duration, - coin, - "multi", - "0", - "auto:open", - "待平仓", - "", - "", - "否", - "", - "", - "", - None, - None, - None, - "", - "否", - "否", - note, - chart_name, - ), - ) - conn.commit() - conn.close() - except Exception: - try: - conn.close() - except Exception: - pass - - _, trading_capital_after = get_exchange_capitals(force=True) - account_base_display = ( - round(float(trading_capital_after), 2) - if trading_capital_after is not None - else round(float(capital_base), 2) - ) - account_name = (os.getenv("GATE_ACCOUNT_LABEL") or "gate实盘账户").strip() - dir_text = "多头(long)" if direction == "long" else "空头(short)" - order_state_text = ( - "已在交易所挂条件委托(止盈、止损各一张触发单)" - if tpsl_attached - else "条件委托未挂上(已拦截)" - ) - rr_show = planned_rr if planned_rr is not None else "-" - try: - rr_show_fmt = f"{float(planned_rr):.2f}" if planned_rr is not None else None - except (TypeError, ValueError): - rr_show_fmt = None - rr_line = f"RR {rr_show_fmt} : 1" if rr_show_fmt is not None else f"RR {rr_show} : 1" - ep_wx = format_price_for_symbol(symbol, trigger_price) - sl_wx = format_wechat_scalar_2dp(stop_loss) - tp_wx = format_price_for_symbol(symbol, take_profit) - be_wx = format_price_for_symbol(symbol, breakeven_price) - style_zh = "Swing 波段" if trade_style == "swing" else "Trend 趋势" - wx_lines = [ - f"📈 {symbol} 开仓成功", - f"💼 交易类型:{dir_text}", - "🧾 订单基础信息", - f"🔖 交易所订单 ID:{open_order_id}", - f"📈 交易风格:{style_zh}", - f"⚠️ 单笔风控风险:{risk_display}", - "📊 仓位配置详情", - f"账户基数:{account_base_display} USDT", - f"合约杠杆:{leverage} 倍", - f"名义仓位:{format_wechat_scalar_2dp(notional_value)} USDT", - f"仓位占比:{position_ratio}%", - f"合约张数:{format_wechat_scalar_2dp(amount)} 张", - f"折算标的:{base_amount} {journal_coin_from_symbol(symbol)}", - "🎯 价位 & 盈亏比", - f"开仓成交价:{ep_wx}", - f"止损价位:{sl_wx}", - f"止盈价位:{tp_wx}", - f"计划盈亏比:{rr_line}", - f"移动保本位:{breakeven_rr_trigger}R → {be_wx}", - "📌 状态统计", - f"✅ 条件委托:{order_state_text}", - format_daily_open_counter_line( - opens_today_after, DAILY_OPEN_ALERT_THRESHOLD, DAILY_OPEN_HARD_LIMIT - ), - ] - if chart_url: - wx_lines.append(f"多周期K线图:{chart_url}") - send_wechat_msg("\n".join(wx_lines)) - - flash_lines = [ - f"实盘开单成功:风格 {trade_style};风险 {risk_display};基数 {round(float(margin_capital), 2)}U,杠杆 {leverage}x,名义仓位 {format_wechat_scalar_2dp(notional_value)}U,仓位占比 {position_ratio}%,合约张数 {format_wechat_scalar_2dp(amount)}(折算标的 {base_amount})," - f"计划RR {format_wechat_scalar_2dp(planned_rr) if planned_rr is not None else '-'};已在交易所挂条件止盈/止损委托(非仓位绑定型)", - format_daily_open_summary_short( - opens_today_after, DAILY_OPEN_ALERT_THRESHOLD, DAILY_OPEN_HARD_LIMIT - ), - ] - if chart_url: - flash_lines.append(f"已生成多周期K线图:{chart_url}") - flash(" ".join(flash_lines)) - - if should_send_daily_open_alert( - opens_today_before, opens_today_after, DAILY_OPEN_ALERT_THRESHOLD - ): - advice = ai_short_advice( - build_daily_open_alert_prompt( - trading_day, - opens_today_after, - DAILY_OPEN_ALERT_THRESHOLD, - hard_limit=DAILY_OPEN_HARD_LIMIT, - detail_line=f"最新一笔:{symbol} {direction},杠杆{leverage}x,基数{round(float(margin_capital), 2)}U。", - ) - ) - if advice: - send_wechat_msg(f"【AI提醒】今日开仓次数已达 {opens_today_after}\n{advice[:800]}") - flash(f"【AI提醒】今日开仓次数已达 {opens_today_after}:{advice[:300]}") - return redirect("/") - -@app.route("/delete_key_monitor/", methods=["POST"]) -@login_required -def delete_key_monitor(kid): - conn = get_db() - row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (kid,)).fetchone() - if not row: - conn.close() - return jsonify({"ok": False, "error": "not_found"}) - if is_limit_key_monitor_type(row["monitor_type"]): - _cancel_fib_monitor_limit(row) - insert_key_monitor_history(conn, row, int(row["notification_count"] or 0), None, "manual") - cur = conn.execute("DELETE FROM key_monitors WHERE id=?", (kid,)) - conn.commit() - conn.close() - return jsonify({"ok": cur.rowcount > 0}) - - -@app.route("/delete_key_history/", methods=["POST"]) -@login_required -def delete_key_history(hid): - conn = get_db() - cur = conn.execute("DELETE FROM key_monitor_history WHERE id=?", (hid,)) - conn.commit() - conn.close() - return jsonify({"ok": cur.rowcount > 0}) - - -@app.route("/del_key/") -@login_required -def del_key(id): - conn = get_db() - row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (id,)).fetchone() - if row: - if is_limit_key_monitor_type(row["monitor_type"]): - _cancel_fib_monitor_limit(row) - insert_key_monitor_history(conn, row, int(row["notification_count"] or 0), None, "manual") - conn.execute("DELETE FROM key_monitors WHERE id=?", (id,)) - conn.commit() - conn.close() - resp = redirect("/") - resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" - resp.headers["Pragma"] = "no-cache" - return resp - - -def _csv_response(filename, rows, header): - buf = StringIO() - w = csv.writer(buf) - w.writerow(header) - for row in rows: - w.writerow(row) - out = "\ufeff" + buf.getvalue() - return Response( - out, - mimetype="text/csv; charset=utf-8", - headers={ - "Content-Disposition": f'attachment; filename="{filename}"', - "Cache-Control": "no-store", - }, - ) - - -def _md_response(filename, content): - return Response( - content, - mimetype="text/markdown; charset=utf-8", - headers={ - "Content-Disposition": f'attachment; filename="{filename}"', - "Cache-Control": "no-store", - }, - ) - - -@app.route("/export/trade_records") -@login_required -def export_trade_records(): - win = _list_window_from_request() - start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ) - conn = get_db() - rows = conn.execute( - "SELECT id,symbol,monitor_type,key_signal_type,direction,trigger_price,stop_loss,initial_stop_loss,take_profit," - "margin_capital,leverage,pnl_amount,hold_seconds,hold_minutes,planned_rr,actual_rr,risk_amount," - "opened_at,closed_at,result,miss_reason,entry_reason,reviewed_entry_reason," - "exchange_realized_pnl,exchange_opened_at,exchange_closed_at,created_at " - f"FROM trade_records WHERE {sql_list_time_field('closed_at', 'created_at', 'opened_at')} >= ? " - f"AND {sql_list_time_field('closed_at', 'created_at', 'opened_at')} <= ? ORDER BY id ASC", - (start_bj, end_bj), - ).fetchall() - conn.close() - head = [ - "id", "symbol", "monitor_type", "key_signal_type", "direction", "trigger_price", - "stop_loss_open_snapshot", "initial_stop_loss", "take_profit", "margin_capital", "leverage", - "pnl_amount", "hold_seconds", "hold_minutes", "planned_rr", "actual_rr", "risk_amount", - "opened_at", "closed_at", "result", "miss_reason", "entry_reason", "reviewed_entry_reason", - "exchange_realized_pnl", "exchange_opened_at", "exchange_closed_at", "created_at", "开仓类型", - ] - data = [] - for r in rows: - er0 = (r["entry_reason"] or "").strip() if r["entry_reason"] else "" - er1 = (r["reviewed_entry_reason"] or "").strip() if r["reviewed_entry_reason"] else "" - kst = (r["key_signal_type"] or "").strip() if "key_signal_type" in r.keys() else "" - eff = er1 or er0 or entry_reason_from_key_signal(kst) or "" - snap = r["initial_stop_loss"] if r["initial_stop_loss"] not in (None, "") else r["stop_loss"] - data.append(( - r["id"], r["symbol"], r["monitor_type"], kst, r["direction"], r["trigger_price"], - snap, r["initial_stop_loss"], r["take_profit"], r["margin_capital"], r["leverage"], - r["pnl_amount"], r["hold_seconds"], r["hold_minutes"], r["planned_rr"], r["actual_rr"], r["risk_amount"], - r["opened_at"], r["closed_at"], r["result"], r["miss_reason"], r["entry_reason"], r["reviewed_entry_reason"], - r["exchange_realized_pnl"] if "exchange_realized_pnl" in r.keys() else None, - r["exchange_opened_at"] if "exchange_opened_at" in r.keys() else None, - r["exchange_closed_at"] if "exchange_closed_at" in r.keys() else None, - r["created_at"], eff, - )) - day = app_now().strftime("%Y%m%d") - return _csv_response(f"trade_records_v3_{day}.csv", data, head) - - -@app.route("/export/journal_entries") -@login_required -def export_journal_entries(): - conn = get_db() - rows = conn.execute( - "SELECT id,open_datetime,close_datetime,hold_duration,coin,tf,pnl,entry_reason,exit_reason," - "expect_rr,real_rr,early_exit,early_exit_trigger,early_exit_note,early_exit_reason,mood_issues," - "post_breakeven_stare,new_trade_while_occupied,note,image,created_at FROM journal_entries ORDER BY created_at ASC" - ).fetchall() - conn.close() - head = [ - "id", - "open_datetime", - "close_datetime", - "hold_duration", - "coin", - "tf", - "pnl", - "entry_reason", - "exit_reason", - "expect_rr", - "real_rr", - "early_exit", - "early_exit_trigger", - "early_exit_note", - "early_exit_reason", - "mood_issues", - "post_breakeven_stare", - "new_trade_while_occupied", - "note", - "image", - "created_at", - ] - data = [tuple(r[h] for h in head) for r in rows] - day = app_now().strftime("%Y%m%d") - return _csv_response(f"journal_entries_v1_{day}.csv", data, head) - - -@app.route("/export/key_monitors") -@login_required -def export_key_monitors(): - conn = get_db() - rows = conn.execute( - "SELECT id,symbol,monitor_type,direction,upper,lower,notification_count,last_notified_at,max_notify," - "notify_interval_min,breakout_limit_pct,created_at FROM key_monitors ORDER BY id ASC" - ).fetchall() - conn.close() - head = [ - "id", - "symbol", - "monitor_type", - "direction", - "upper", - "lower", - "notification_count", - "last_notified_at", - "max_notify", - "notify_interval_min", - "breakout_limit_pct", - "created_at", - ] - data = [tuple(r[h] for h in head) for r in rows] - day = app_now().strftime("%Y%m%d") - return _csv_response(f"key_monitors_active_v1_{day}.csv", data, head) - - -@app.route("/export/key_monitor_history") -@login_required -def export_key_monitor_history(): - win = _list_window_from_request() - start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ) - conn = get_db() - rows = conn.execute( - "SELECT id,symbol,monitor_type,direction,upper,lower,notification_count,last_alert_message,close_reason,closed_at " - "FROM key_monitor_history WHERE closed_at >= ? AND closed_at <= ? ORDER BY id ASC", - (start_bj, end_bj), - ).fetchall() - conn.close() - head = [ - "id", - "symbol", - "monitor_type", - "direction", - "upper", - "lower", - "notification_count", - "last_alert_message", - "close_reason", - "closed_at", - ] - data = [tuple(r[h] for h in head) for r in rows] - day = app_now().strftime("%Y%m%d") - return _csv_response(f"key_monitor_history_v1_{day}.csv", data, head) - -@app.route("/del_order/") -@login_required -def del_order(id): - conn = get_db() - row = conn.execute("SELECT * FROM order_monitors WHERE id=?", (id,)).fetchone() - if not row: - conn.close() - flash("订单不存在") - return redirect("/") - if row["status"] == "active": - try: - p = get_price(row["symbol"]) or float(row["trigger_price"]) - opened_at = get_opened_at_value(row) - closed_at = app_now_str() - hold_seconds = calc_hold_seconds(opened_at, app_now()) - pnl_amount = calc_pnl( - row["direction"], - row["trigger_price"], - p, - row["margin_capital"] or DAILY_START_CAPITAL, - row["leverage"] or infer_leverage(row["symbol"]) - ) - close_resp = close_exchange_order(row) - close_order_id = close_resp.get("id", "") - cancel_gate_swap_trigger_orders(row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"])) - session_date = row["session_date"] or get_trading_day() - session_capital = update_session_capital(conn, session_date, pnl_amount) - row_snap = conn.execute("SELECT * FROM order_monitors WHERE id=?", (id,)).fetchone() or row - insert_trade_record( - conn, - symbol=row["symbol"], - monitor_type=trade_record_monitor_type(conn, row), - trend_plan_id=trend_plan_id_from_monitor_row(row), - key_signal_type=order_row_key_signal_type(row), - direction=row["direction"], - trigger_price=row["trigger_price"], - stop_loss=row["stop_loss"], - initial_stop_loss=row["initial_stop_loss"] or row["stop_loss"], - take_profit=row["take_profit"], - margin_capital=margin_capital_for_trade_record(row_snap), - leverage=row["leverage"], - pnl_amount=pnl_amount, - hold_seconds=hold_seconds, - trade_style=row["trade_style"], - risk_amount=row["risk_amount"], - planned_rr=calc_rr_ratio(row["direction"], row["trigger_price"], row["initial_stop_loss"] or row["stop_loss"], row["take_profit"]), - actual_rr=calc_actual_rr(pnl_amount, row["risk_amount"]), - result="手动平仓", - miss_reason=handoff_trade_miss_reason("用户手动删除订单触发平仓", row), - opened_at=opened_at, - closed_at=closed_at, - ) - from lib.trade.account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close - - on_user_initiated_close( - conn, - source=CLOSE_SOURCE_USER_INSTANCE, - trade_record_id=insert_trade_record_id(conn), - closed_at_ms=_to_ms_with_fallback(None, closed_at), - trading_day=session_date, - now=app_now(), - ) - conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, id)) - try: - _rcfg = app.extensions.get("strategy_roll_cfg") - if isinstance(_rcfg, dict): - from lib.strategy.strategy_register import roll_sync_after_external_close - - roll_sync_after_external_close(_rcfg, conn, row["symbol"], row["direction"]) - except Exception: - pass - clear_key_sizing_snapshot_if_flat(conn, session_date) - conn.commit() - conn.close() - send_wechat_msg( - build_wechat_close_message( - symbol=row["symbol"], - direction=row["direction"], - result="手动平仓", - pnl_amount=pnl_amount, - hold_seconds=hold_seconds, - trigger_price=row["trigger_price"], - current_price=p, - stop_loss=row["stop_loss"], - take_profit=row["take_profit"], - close_order_id=close_order_id or "-", - extra_note="用户在页面手动平仓", - session_capital_fallback=session_capital, - ) - ) - flash("已按实盘流程手动平仓") - return redirect("/trade") - except Exception as e: - if is_no_position_error(str(e)): - cancel_gate_swap_trigger_orders(row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"])) - opened_at = get_opened_at_value(row) - opened_at_ms = _to_ms_with_fallback(row["opened_at_ms"] if "opened_at_ms" in row.keys() else None, opened_at) - result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close(row, opened_at, opened_at_ms=opened_at_ms) - miss_reason = f"手动删除时无持仓:{miss_reason}" - closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now() - hold_seconds = calc_hold_seconds(opened_at, closed_at_dt) - session_date = row["session_date"] or get_trading_day(closed_at_dt) - update_session_capital(conn, session_date, pnl_amount) - row_snap = conn.execute("SELECT * FROM order_monitors WHERE id=?", (id,)).fetchone() or row - insert_trade_record( - conn, - symbol=row["symbol"], - monitor_type=trade_record_monitor_type(conn, row), - trend_plan_id=trend_plan_id_from_monitor_row(row), - key_signal_type=order_row_key_signal_type(row), - direction=row["direction"], - trigger_price=row["trigger_price"], - stop_loss=row["stop_loss"], - initial_stop_loss=row["initial_stop_loss"] or row["stop_loss"], - take_profit=row["take_profit"], - margin_capital=margin_capital_for_trade_record(row_snap), - leverage=row["leverage"], - pnl_amount=pnl_amount, - hold_seconds=hold_seconds, - trade_style=row["trade_style"], - risk_amount=row["risk_amount"], - planned_rr=calc_rr_ratio(row["direction"], row["trigger_price"], row["initial_stop_loss"] or row["stop_loss"], row["take_profit"]), - actual_rr=calc_actual_rr(pnl_amount, row["risk_amount"]), - result=result, - miss_reason=handoff_trade_miss_reason(miss_reason, row), - opened_at=opened_at, - closed_at=closed_at, - ) - from lib.trade.account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close - - on_user_initiated_close( - conn, - source=CLOSE_SOURCE_USER_INSTANCE, - trade_record_id=insert_trade_record_id(conn), - closed_at_ms=_to_ms_with_fallback(None, closed_at), - trading_day=session_date, - now=app_now(), - ) - conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (id,)) - try: - _rcfg = app.extensions.get("strategy_roll_cfg") - if isinstance(_rcfg, dict): - from lib.strategy.strategy_register import roll_sync_after_external_close - - roll_sync_after_external_close(_rcfg, conn, row["symbol"], row["direction"]) - except Exception: - pass - conn.commit() - conn.close() - flash("该仓位在交易所已不存在,已按成交记录同步结束并记账") - return redirect("/") - conn.close() - flash(f"手动平仓失败:{str(e)}") - return redirect("/") - conn.execute("DELETE FROM order_monitors WHERE id=?",(id,)) - conn.commit() - conn.close() - return redirect("/") - -@app.route("/add_miss", methods=["POST"]) -@login_required -def add_miss(): - d = request.form - direction = d.get("direction", "long") - sym_in = normalize_symbol_input(d.get("symbol")) - ex_sym = normalize_exchange_symbol(sym_in) - try: - ensure_markets_loaded() - except Exception: - pass - try: - tp_px = round_price_to_exchange(ex_sym, float(d["tp"])) - sl_px = round_price_to_exchange(ex_sym, float(d["sl"])) - tgt_px = round_price_to_exchange(ex_sym, float(d["tgt"])) - except Exception: - flash("价格格式错误") - return _redirect_records() - conn = get_db() - insert_trade_record( - conn, - symbol=sym_in, - monitor_type=d["type"], - direction=direction, - trigger_price=tp_px, - stop_loss=sl_px, - take_profit=tgt_px, - result="错过", - miss_reason=d["reason"], - opened_at=app_now_str(), - closed_at=app_now_str(), - ) - conn.commit() - conn.close() - flash("已记录错过机会") - return _redirect_records() - - -@app.route("/add_journal", methods=["POST"]) -@login_required -def add_journal(): - d = request.form - entry_reason_norm = normalize_entry_reason(d.get("entry_reason"), d.get("entry_reason_custom")) - if not entry_reason_norm: - flash("请选择开仓类型;若选「其他」请在下方填写自定义说明") - return _redirect_records() - early_exit_trigger = normalize_early_exit_trigger(d.get("early_exit_trigger")) - early_exit_note = str(d.get("early_exit_note") or "").strip() - if not early_exit_trigger: - flash("请选择离场触发") - return _redirect_records() - if early_exit_trigger == "手动平仓" and not early_exit_note: - flash("手工平仓必须填写补充说明") - return _redirect_records() - if early_exit_trigger != "手动平仓": - early_exit_note = "" - # 兼容字段:仅「手工平仓」记为「主观提前」语义下的「是」 - early_exit_raw = "是" if early_exit_trigger == "手动平仓" else "否" - early_exit_reason_saved = compose_early_exit_reason_saved(early_exit_trigger, early_exit_note) - exit_reason_stored = journal_exit_reason_stored(early_exit_trigger, early_exit_note) - image_filename = None - uploaded_tmp = None - entry_id = uuid.uuid4().hex - file = request.files.get("screenshot") - if file and file.filename: - ext = os.path.splitext(file.filename)[1] - image_filename = f"{uuid.uuid4().hex}{ext}" - save_path = os.path.join(app.config["UPLOAD_FOLDER"], secure_filename(image_filename)) - file.save(save_path) - uploaded_tmp = image_filename - - mood_issues = ",".join(request.form.getlist("mood_issues")) - hold_duration = calc_duration_text(d.get("open_datetime", ""), d.get("close_datetime", "")) - real_rr_text = (d.get("real_rr") or "").strip() - try: - risk_amount_hint = float(d.get("risk_amount_hint") or 0) - pnl_hint = float(d.get("pnl") or 0) - # 口径统一:实际RR = 实际盈亏 / 以损定仓对应的初始风险金额 - if risk_amount_hint > 0: - real_rr_text = f"{(pnl_hint / risk_amount_hint):.4f}" - except Exception: - pass - - want_exchange_chart = d.get("journal_exchange_chart", "").lower() in ("1", "true", "on", "yes") - chart_msg = None - if want_exchange_chart and ORDER_CHART_ENABLED: - coin = (d.get("coin") or "").strip().upper() - symbol_guess = normalize_symbol_input(coin) or coin - exchange_symbol = normalize_exchange_symbol(symbol_guess) - title_prefix = f"{symbol_guess} journal {entry_id[:8]}" - journal_tfs = parse_journal_chart_timeframes( - d.get("journal_chart_tf1"), - d.get("journal_chart_tf2"), - ORDER_CHART_TFS[:2] if ORDER_CHART_TFS else None, - ) - journal_limit = parse_journal_chart_limit(d.get("journal_chart_limit"), ORDER_CHART_LIMIT) - chart_anchor = parse_journal_chart_anchor(d.get("journal_chart_anchor")) - marker_payload = { - "entry_ts_ms": _local_input_datetime_to_ms(d.get("open_datetime")), - "exit_ts_ms": _local_input_datetime_to_ms(d.get("close_datetime")), - "entry_price": d.get("entry_price_hint"), - "exit_price": d.get("exit_price_hint"), - "stop_loss_price": d.get("stop_loss_hint"), - "chart_anchor": chart_anchor, - "now_ts_ms": int(app_now().timestamp() * 1000), - } - try: - chart_fname = f"journal_{entry_id}.png" - saved = generate_multi_timeframe_chart_png( - exchange_symbol, - title_prefix, - timeframes=journal_tfs, - limit=journal_limit, - out_dir=app.config["UPLOAD_FOLDER"], - filename=chart_fname, - filename_prefix="journal", - marker_payload=marker_payload, - marker_timeframes={x.strip().lower() for x in journal_tfs}, - layout="vertical", - ) - if saved: - image_filename = saved - chart_msg = f"已生成复盘K线图({'/'.join(journal_tfs)} 各{journal_limit}根):/static/images/{saved}" - if uploaded_tmp: - try: - old_path = os.path.join(app.config["UPLOAD_FOLDER"], uploaded_tmp) - if os.path.exists(old_path): - os.remove(old_path) - except Exception: - pass - else: - chart_msg = "已勾选自动生成K线图,但生成失败(返回空)。请检查 Pillow 是否安装、Gate 网络/代理是否正常。" - except Exception as e: - image_filename = uploaded_tmp - chart_msg = f"自动生成K线图失败:{str(e)}" - - conn = get_db() - conn.execute( - """INSERT INTO journal_entries - (id, open_datetime, close_datetime, hold_duration, coin, tf, pnl, entry_reason, exit_reason, - expect_rr, real_rr, early_exit, early_exit_reason, early_exit_trigger, early_exit_note, - mood_score, mood_ai_score, mood_ai_comment, mood_issues, post_breakeven_stare, - new_trade_while_occupied, note, image) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", - ( - entry_id, - normalize_bj_datetime_storage(d.get("open_datetime")), - normalize_bj_datetime_storage(d.get("close_datetime")), - hold_duration, - d.get("coin"), - d.get("tf"), - d.get("pnl"), entry_reason_norm, exit_reason_stored, d.get("expect_rr"), real_rr_text, - early_exit_raw, early_exit_reason_saved, early_exit_trigger, early_exit_note, - None, None, None, mood_issues, - d.get("post_breakeven_stare"), d.get("new_trade_while_occupied"), d.get("note"), image_filename - ) - ) - from lib.trade.account_risk_lib import on_journal_saved - - on_journal_saved( - conn, - early_exit_trigger=early_exit_trigger, - early_exit_note=early_exit_note, - mood_issues_raw=mood_issues, - trading_day=get_trading_day(), - now=app_now(), - ) - conn.commit() - conn.close() - if chart_msg: - flash(f"交易复盘记录已保存。{chart_msg}") - else: - flash("交易复盘记录已保存") - return _redirect_records() - - -@app.route("/api/journals") -@login_required -def api_journals(): - win = _list_window_from_request() - start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ) - conn = get_db() - j_ts = sql_list_time_field("close_datetime", "created_at", "open_datetime") - rows = conn.execute( - f"SELECT * FROM journal_entries WHERE {j_ts} >= ? AND {j_ts} <= ? ORDER BY created_at DESC LIMIT 500", - (start_bj, end_bj), - ).fetchall() - conn.close() - result = [] - for r in rows: - item = row_to_dict(r) - item["mood_issues"] = [x for x in (item.get("mood_issues") or "").split(",") if x] - result.append(item) - return jsonify(result) - - -@app.route("/api/journal_prefill", methods=["POST"]) -@login_required -def api_journal_prefill(): - file = request.files.get("screenshot") - if not file or not file.filename: - return jsonify({"ok": False, "msg": "请先选择截图文件"}), 400 - try: - raw = file.read() - if not raw: - return jsonify({"ok": False, "msg": "截图为空"}), 400 - image_b64 = base64.b64encode(raw).decode("utf-8") - except Exception as e: - return jsonify({"ok": False, "msg": f"读取截图失败:{str(e)}"}), 400 - - parsed = ai_extract_journal_from_image(image_b64) - if parsed is None: - return jsonify({"ok": False, "msg": "AI 识别失败,请稍后重试"}), 500 - return jsonify({"ok": True, "data": parsed}) - - -@app.route("/delete_journal/", methods=["POST"]) -@login_required -def delete_journal(jid): - conn = get_db() - row = conn.execute("SELECT image FROM journal_entries WHERE id=?", (jid,)).fetchone() - if row and row["image"]: - img_path = os.path.join(app.config["UPLOAD_FOLDER"], row["image"]) - if os.path.exists(img_path): - os.remove(img_path) - conn.execute("DELETE FROM journal_entries WHERE id=?", (jid,)) - conn.commit() - conn.close() - return jsonify({"ok": True}) - - -@app.route("/api/reviews") -@login_required -def api_reviews(): - win = _list_window_from_request() - start_sql, end_sql = utc_window_to_utc_sql_strings(win["start_utc"], win["end_utc"]) - conn = get_db() - rows = conn.execute( - "SELECT * FROM ai_reviews WHERE created_at >= ? AND created_at <= ? ORDER BY created_at DESC LIMIT 200", - (start_sql, end_sql), - ).fetchall() - conn.close() - return jsonify([row_to_dict(r) for r in rows]) - - -_REPO_STATIC_DIR = common_static_dir(os.path.dirname(BASE_DIR)) -_AI_REVIEW_RENDER_JS = os.path.join(_REPO_STATIC_DIR, "ai_review_render.js") -_FORM_SUBMIT_GUARD_JS = os.path.join(_REPO_STATIC_DIR, "form_submit_guard.js") -_MANUAL_ORDER_RR_PREVIEW_JS = os.path.join(_REPO_STATIC_DIR, "manual_order_rr_preview.js") - - -@app.route("/static/ai_review_render.js") -def static_ai_review_render_js(): - if not os.path.isfile(_AI_REVIEW_RENDER_JS): - return Response("not found", status=404, mimetype="text/plain; charset=utf-8") - return send_file(_AI_REVIEW_RENDER_JS, mimetype="application/javascript; charset=utf-8") - - -@app.route("/static/form_submit_guard.js") -def static_form_submit_guard_js(): - if not os.path.isfile(_FORM_SUBMIT_GUARD_JS): - return Response("not found", status=404, mimetype="text/plain; charset=utf-8") - return send_file(_FORM_SUBMIT_GUARD_JS, mimetype="application/javascript; charset=utf-8") - - -@app.route("/static/manual_order_rr_preview.js") -def static_manual_order_rr_preview_js(): - if not os.path.isfile(_MANUAL_ORDER_RR_PREVIEW_JS): - return Response("not found", status=404, mimetype="text/plain; charset=utf-8") - return send_file(_MANUAL_ORDER_RR_PREVIEW_JS, mimetype="application/javascript; charset=utf-8") - - -@app.route("/export/review_md/") -@login_required -def export_review_md(rid): - conn = get_db() - row = conn.execute("SELECT * FROM ai_reviews WHERE id=?", (rid,)).fetchone() - conn.close() - if not row: - return Response("review not found", status=404, mimetype="text/plain; charset=utf-8") - - review_type = "日复盘" if row["review_type"] == "daily" else "周复盘" - target_date = row["target_date"] or "-" - created_at = row["created_at"] or app_now_str() - content = (row["content"] or "").strip() - if not content: - content = "(无内容)" - - md = ( - f"# {review_type}报告\n\n" - f"- 目标日期: {target_date}\n" - f"- 生成时间: {created_at}\n" - f"- 报告ID: {row['id']}\n\n" - f"---\n\n" - f"{content}\n" - ) - - safe_target = re.sub(r"[^0-9A-Za-z_-]+", "-", str(target_date)).strip("-") or "unknown-date" - safe_type = "daily" if row["review_type"] == "daily" else "weekly" - filename = f"ai_review_{safe_type}_{safe_target}_{row['id'][:8]}.md" - return _md_response(filename, md) - - -@app.route("/export/reviews_md_bundle") -@login_required -def export_reviews_md_bundle(): - review_type = (request.args.get("review_type") or "").strip().lower() - target_date = (request.args.get("target_date") or "").strip() - if review_type not in ("daily", "weekly"): - return Response("invalid review_type", status=400, mimetype="text/plain; charset=utf-8") - if not target_date: - return Response("target_date required", status=400, mimetype="text/plain; charset=utf-8") - - conn = get_db() - rows = conn.execute( - "SELECT * FROM ai_reviews WHERE review_type=? AND target_date=? ORDER BY created_at ASC, id ASC", - (review_type, target_date), - ).fetchall() - conn.close() - if not rows: - return Response("no reviews found", status=404, mimetype="text/plain; charset=utf-8") - - title = "日复盘" if review_type == "daily" else "周复盘" - lines = [ - f"# {title}汇总报告", - "", - f"- 目标日期: {target_date}", - f"- 条目数量: {len(rows)}", - f"- 导出时间: {app_now_str()}", - "", - "---", - "", - ] - for idx, row in enumerate(rows, 1): - created_at = row["created_at"] or "-" - content = (row["content"] or "").strip() or "(无内容)" - lines.extend( - [ - f"## 第{idx}条", - "", - f"- 报告ID: {row['id']}", - f"- 生成时间: {created_at}", - "", - content, - "", - "---", - "", - ] - ) - md = "\n".join(lines) - safe_target = re.sub(r"[^0-9A-Za-z_-]+", "-", str(target_date)).strip("-") or "unknown-date" - filename = f"ai_reviews_{review_type}_bundle_{safe_target}.md" - return _md_response(filename, md) - - -@app.route("/delete_review/", methods=["POST"]) -@login_required -def delete_review(rid): - conn = get_db() - conn.execute("DELETE FROM ai_reviews WHERE id=?", (rid,)) - conn.commit() - conn.close() - return jsonify({"ok": True}) - - -@app.route("/delete_trade_record/", methods=["POST"]) -@login_required -def delete_trade_record(rid): - conn = get_db() - cur = conn.execute("DELETE FROM trade_records WHERE id=?", (rid,)) - conn.commit() - conn.close() - return jsonify({"ok": cur.rowcount > 0, "deleted": cur.rowcount}) - - -@app.route("/api/trade_record_review_update", methods=["POST"]) -@login_required -def api_trade_record_review_update(): - payload = request.get_json(silent=True) or {} - rec_id = payload.get("id") - try: - rec_id = int(rec_id) - except Exception: - return jsonify({"ok": False, "msg": "记录ID无效"}), 400 - - reviewed_opened_at = str(payload.get("reviewed_opened_at") or "").strip() - reviewed_closed_at = str(payload.get("reviewed_closed_at") or "").strip() - reviewed_stop_loss_raw = payload.get("reviewed_stop_loss") - reviewed_take_profit_raw = payload.get("reviewed_take_profit") - reviewed_result = str(payload.get("reviewed_result") or "").strip() - reviewed_miss_reason = str(payload.get("reviewed_miss_reason") or "").strip() - reviewed_pnl_raw = payload.get("reviewed_pnl_amount") - - if reviewed_result and reviewed_result not in REVIEW_RESULT_OPTIONS: - return jsonify({"ok": False, "msg": "结果仅允许:止盈/止损/保本止盈/移动止盈/手动平仓"}), 400 - - try: - reviewed_open_dt = datetime.strptime(reviewed_opened_at[:19], "%Y-%m-%d %H:%M:%S") - reviewed_close_dt = datetime.strptime(reviewed_closed_at[:19], "%Y-%m-%d %H:%M:%S") - except Exception: - return jsonify({"ok": False, "msg": "开仓/平仓时间格式错误,需为 YYYY-MM-DD HH:MM:SS"}), 400 - if reviewed_close_dt < reviewed_open_dt: - return jsonify({"ok": False, "msg": "平仓时间不能早于开仓时间"}), 400 - hold_seconds = int((reviewed_close_dt - reviewed_open_dt).total_seconds()) - hold_minutes = calc_hold_minutes(hold_seconds) - - try: - reviewed_pnl_amount = float(reviewed_pnl_raw) - except Exception: - return jsonify({"ok": False, "msg": "盈亏必须为数字"}), 400 - reviewed_stop_loss = None - if reviewed_stop_loss_raw not in (None, ""): - try: - reviewed_stop_loss = float(reviewed_stop_loss_raw) - except Exception: - return jsonify({"ok": False, "msg": "止损必须为数字"}), 400 - reviewed_take_profit = None - if reviewed_take_profit_raw not in (None, ""): - try: - reviewed_take_profit = float(reviewed_take_profit_raw) - except Exception: - return jsonify({"ok": False, "msg": "止盈必须为数字"}), 400 - - _MISSING_ER = object() - reviewed_entry_reason_update = _MISSING_ER - if "reviewed_entry_reason" in payload: - s = str(payload.get("reviewed_entry_reason") or "").strip() - if s and not entry_reason_valid_for_storage(s): - return jsonify({"ok": False, "msg": "开仓类型须为五种固定整句之一、自定义说明(2000字内)或留空"}), 400 - reviewed_entry_reason_update = s or None - - conn = get_db() - row = conn.execute("SELECT risk_amount, symbol FROM trade_records WHERE id=?", (rec_id,)).fetchone() - if not row: - conn.close() - return jsonify({"ok": False, "msg": "记录不存在"}), 404 - risk_amount = row["risk_amount"] - ex_review = resolve_ccxt_price_symbol(row["symbol"]) - try: - ensure_markets_loaded() - except Exception: - pass - if reviewed_stop_loss is not None: - reviewed_stop_loss = round_price_to_exchange(ex_review, reviewed_stop_loss) - if reviewed_take_profit is not None: - reviewed_take_profit = round_price_to_exchange(ex_review, reviewed_take_profit) - actual_rr = calc_actual_rr(reviewed_pnl_amount, risk_amount) - base_params = [ - reviewed_opened_at, - reviewed_closed_at, - reviewed_stop_loss, - reviewed_take_profit, - round(reviewed_pnl_amount, 4), - reviewed_result or None, - reviewed_miss_reason or None, - hold_seconds, - hold_minutes, - app_now_str(), - actual_rr, - ] - if reviewed_entry_reason_update is not _MISSING_ER: - conn.execute( - """UPDATE trade_records - SET reviewed_opened_at=?, reviewed_closed_at=?, reviewed_stop_loss=?, reviewed_take_profit=?, reviewed_pnl_amount=?, - reviewed_result=?, reviewed_miss_reason=?, reviewed_hold_seconds=?, reviewed_hold_minutes=?, - reviewed_at=?, actual_rr=COALESCE(?, actual_rr), reviewed_entry_reason=? - WHERE id=?""", - tuple(base_params + [reviewed_entry_reason_update, rec_id]), - ) - else: - conn.execute( - """UPDATE trade_records - SET reviewed_opened_at=?, reviewed_closed_at=?, reviewed_stop_loss=?, reviewed_take_profit=?, reviewed_pnl_amount=?, - reviewed_result=?, reviewed_miss_reason=?, reviewed_hold_seconds=?, reviewed_hold_minutes=?, - reviewed_at=?, actual_rr=COALESCE(?, actual_rr) - WHERE id=?""", - tuple(base_params + [rec_id]), - ) - if reviewed_result == "手动平仓" and reviewed_miss_reason: - from lib.trade.account_risk_lib import apply_manual_close_journal_cooloff - - apply_manual_close_journal_cooloff( - conn, - early_exit_note=reviewed_miss_reason, - trading_day=get_trading_day(), - now=app_now(), - ) - conn.commit() - conn.close() - return jsonify({"ok": True, "id": rec_id, "actual_rr": actual_rr, "hold_minutes": hold_minutes}) - - -@app.route("/manual_transfer", methods=["POST"]) -@login_required -def manual_transfer(): - try: - amount = float(request.form.get("amount", "0")) - except Exception: - flash("划转金额格式错误") - return redirect("/") - from_account = (request.form.get("from_account") or AUTO_TRANSFER_FROM).strip() - to_account = (request.form.get("to_account") or AUTO_TRANSFER_TO).strip() - ok, msg, _ = execute_transfer_usdt(amount, from_account, to_account) - conn = get_db() - conn.execute( - "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", - ("manual", get_trading_day(), amount, from_account, to_account, "success" if ok else "failed", msg[:500]) - ) - conn.commit() - conn.close() - if ok: - flash(f"手动划转成功:{amount}U {from_account}->{to_account}") - else: - flash(f"手动划转失败:{msg}") - return redirect(request.referrer or "/trade") - - -def _journal_ai_chart_builder(row): - return build_journal_ai_chart_path( - row, - app.config["UPLOAD_FOLDER"], - order_chart_enabled=ORDER_CHART_ENABLED, - normalize_exchange_symbol_fn=lambda c: normalize_exchange_symbol(normalize_symbol_input(c)), - generate_chart_fn=generate_multi_timeframe_chart_png, - local_datetime_to_ms_fn=_local_input_datetime_to_ms, - now_ts_ms_fn=lambda: int(app_now().timestamp() * 1000), - ) - - -@app.route("/ai_daily_review", methods=["POST"]) -@login_required -def ai_daily_review(): - date = request.form.get("date", "") - conn = get_db() - rows = conn.execute( - "SELECT * FROM journal_entries WHERE substr(open_datetime, 1, 10)=? ORDER BY open_datetime ASC", - (date,) - ).fetchall() - conn.close() - if not rows: - return jsonify({"result": "该日无交易记录"}) - - text = f"【每日交易记录】{date}\n总笔数:{len(rows)}\n\n" - for idx, row in enumerate(rows, 1): - text += journal_row_lines_for_ai(idx, row) - text += "\n" - - image_paths = collect_images_for_ai_review( - rows, - app.config["UPLOAD_FOLDER"], - build_chart_if_missing=_journal_ai_chart_builder, - ) - ai_result = ai_review(text, "每日", image_paths=image_paths) - full = f"【AI日复盘 {date}】\n{ai_result}\n\n原始记录:\n{text}" - conn = get_db() - conn.execute( - "INSERT INTO ai_reviews (id, review_type, target_date, content) VALUES (?,?,?,?)", - (uuid.uuid4().hex, "daily", date, full) - ) - conn.commit() - conn.close() - return jsonify({"result": full}) - - -@app.route("/ai_weekly_review", methods=["POST"]) -@login_required -def ai_weekly_review(): - start_date = request.form.get("start_date", "") - end_date = request.form.get("end_date", "") - conn = get_db() - rows = conn.execute( - "SELECT * FROM journal_entries WHERE substr(open_datetime,1,10) >= ? AND substr(open_datetime,1,10) <= ? ORDER BY open_datetime ASC", - (start_date, end_date) - ).fetchall() - conn.close() - if not rows: - return jsonify({"result": "该时间段无交易记录"}) - - text = f"【周交易记录】{start_date}~{end_date}\n总笔数:{len(rows)}\n\n" - for idx, row in enumerate(rows, 1): - text += journal_row_lines_for_ai(idx, row) - text += "\n" - - image_paths = collect_images_for_ai_review( - rows, - app.config["UPLOAD_FOLDER"], - build_chart_if_missing=_journal_ai_chart_builder, - ) - ai_result = ai_review(text, "周度", image_paths=image_paths) - full = f"【AI周复盘 {start_date}~{end_date}】\n{ai_result}\n\n原始记录:\n{text}" - conn = get_db() - conn.execute( - "INSERT INTO ai_reviews (id, review_type, target_date, content) VALUES (?,?,?,?)", - (uuid.uuid4().hex, "weekly", f"{start_date}~{end_date}", full) - ) - conn.commit() - conn.close() - return jsonify({"result": full}) - -def _hub_meta_bundle(): - return { - "exchange_display": EXCHANGE_DISPLAY_NAME, - "key_gate_rule_text": ( - f"周期 {KLINE_TIMEFRAME}|确认K:突破棒偏移 {KEY_CONFIRM_BREAKOUT_BAR}、确认棒偏移 {KEY_CONFIRM_BAR}|" - f"量能:突破量 > 前{KEY_VOLUME_MA_BARS}均量×{KEY_VOLUME_RATIO_MIN}|" - f"自动开仓盈亏比 > {KEY_AUTO_MIN_PLANNED_RR}:1|日成交量排名前 {KEY_DAILY_VOLUME_RANK_MAX}" - ), - "manual_min_planned_rr": MANUAL_MIN_PLANNED_RR, - "max_active_positions": MAX_ACTIVE_POSITIONS, - "btc_leverage": BTC_LEVERAGE, - "alt_leverage": ALT_LEVERAGE, - } - - -def _hub_account_bundle(): - funding_capital, trading_capital = get_exchange_capitals(force=True) - funding_usdt = round(funding_capital, 2) if funding_capital is not None else None - trading_usdt = round(trading_capital, 2) if trading_capital is not None else None - available = get_available_trading_usdt() - return { - "funding_usdt": funding_usdt, - "trading_usdt": trading_usdt, - "available_trading_usdt": round(available, 2) if available is not None else None, - "trading_day": get_trading_day(app_now()), - } - - -def _hub_fetch_market(base=""): - from lib.hub.hub_market_info_lib import fetch_usdt_swap_market_info - - return fetch_usdt_swap_market_info( - base_or_symbol=base, - normalize_symbol_input=normalize_symbol_input, - normalize_exchange_symbol=normalize_exchange_symbol, - ensure_markets_loaded=ensure_markets_loaded, - exchange=exchange, - exchange_id="gate_bot", - ) - - -def _hub_fetch_ohlcv(symbol, timeframe, since_ms=None, limit=500): - from lib.hub.hub_ohlcv_lib import fetch_ohlcv_for_hub - - return fetch_ohlcv_for_hub( - symbol=symbol, - timeframe=timeframe, - since_ms=since_ms, - limit=limit, - normalize_symbol_input=normalize_symbol_input, - normalize_exchange_symbol=normalize_exchange_symbol, - ensure_markets_loaded=ensure_markets_loaded, - exchange=exchange, - friendly_error=friendly_exchange_error, - ) - - -def _hub_fetch_volume_rank(top_n=20): - from lib.hub.hub_volume_rank_lib import fetch_usdt_swap_volume_rank - - return fetch_usdt_swap_volume_rank( - exchange=exchange, - ensure_markets_loaded=ensure_markets_loaded, - top_n=top_n, - exchange_id="gateio", - ) - - -try: - import sys - from pathlib import Path - - _repo_root = Path(__file__).resolve().parent.parent - if str(_repo_root) not in sys.path: - sys.path.insert(0, str(_repo_root)) - from lib.hub.hub_bridge import install_on_app - - install_on_app( - app, - exchange="gate_bot", - capabilities=["order", "key"], - has_trend=True, - get_db=get_db, - row_to_dict=row_to_dict, - meta_fn=_hub_meta_bundle, - account_fn=_hub_account_bundle, - views={"add_order": add_order, "add_key": add_key}, - ohlcv_fn=_hub_fetch_ohlcv, - volume_rank_fn=_hub_fetch_volume_rank, - market_fn=_hub_fetch_market, - reconcile_hub_flat_fn=reconcile_hub_external_close, - risk_status_fn=hub_account_risk_status, - user_close_fn=hub_user_initiated_close, - render_main_page_fn=render_main_page, - login_required_fn=login_required, - ) -except Exception as _hub_err: - print(f"[hub_bridge] gate_bot: {_hub_err}") - - -@app.route("/strategy") -@login_required -def strategy_trading_page(): - return render_main_page("strategy") - - -@app.route("/strategy/trend") -@login_required -def strategy_trend_page(): - qs = request.query_string.decode() - return redirect(f"/strategy?{qs}" if qs else "/strategy") - - -@app.route("/strategy/roll") -@login_required -def strategy_roll_page(): - return redirect("/strategy") - - -from lib.strategy.strategy_register import install_strategy_trading -from lib.strategy.strategy_trend_register import install_strategy_trend - -install_strategy_trading(app, _REPO_ROOT, app_module=sys.modules[__name__]) -install_strategy_trend(app, _REPO_ROOT, app_module=sys.modules[__name__]) - -_purge_key_monitors_if_full_margin() - - -# 启动 -if __name__ == "__main__": - threading.Thread(target=background_task, daemon=True).start() - app.run(host=HOST, port=PORT, debug=DEBUG) diff --git a/crypto_monitor_gate_bot/ecosystem.config.cjs b/crypto_monitor_gate_bot/ecosystem.config.cjs deleted file mode 100644 index a3c340c..0000000 --- a/crypto_monitor_gate_bot/ecosystem.config.cjs +++ /dev/null @@ -1,34 +0,0 @@ -/** - * PM2 进程定义(Ubuntu / Linux)。 - * - * 仅托管 Flask 应用。**SSH SOCKS 隧道**用 `ssh -D` 常驻(可用 tmux / autossh),勿交给 PM2。 - * 与 `.env` 里 `GATE_SOCKS_PROXY` 端口一致即可;不必交给 PM2。 - * - * 使用前:项目根目录存在 `.venv`,且已安装依赖(走 SOCKS 时需 PySocks)。 - * - * 启动: - * pm2 start ecosystem.config.cjs - * 保存开机列表: - * pm2 save && pm2 startup - */ -const path = require("path"); - -const ROOT = __dirname; -const REPO_ROOT = path.join(ROOT, ".."); -const PY = path.join(ROOT, ".venv", "bin", "python"); - -module.exports = { - apps: [ - { - name: "crypto_gate_bot", - cwd: ROOT, - script: path.join(ROOT, "app.py"), - interpreter: PY, - instances: 1, - autorestart: true, - watch: false, - max_memory_restart: "800M", - env: { PYTHONPATH: REPO_ROOT }, - }, - ], -}; diff --git a/crypto_monitor_gate_bot/scripts/backup_data.sh b/crypto_monitor_gate_bot/scripts/backup_data.sh deleted file mode 100644 index 9a25287..0000000 --- a/crypto_monitor_gate_bot/scripts/backup_data.sh +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env bash -# Daily backup: SQLite DB + static/images → /root/backups/// -# Prune backup folders older than RETENTION_DAYS (default 30). -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -cd "$PROJECT_DIR" - -BACKUP_ROOT="${BACKUP_ROOT:-/root/backups}" -RETENTION_DAYS="${RETENTION_DAYS:-30}" -INSTANCE_NAME="${BACKUP_INSTANCE:-$(basename "$PROJECT_DIR")}" -TZ_NAME="${BACKUP_TZ:-Asia/Shanghai}" - -log() { - printf '[%s] %s\n' "$(TZ="$TZ_NAME" date '+%Y-%m-%d %H:%M:%S %Z')" "$*" -} - -read_env_var() { - local key="$1" - local default="$2" - local line - if [[ ! -f .env ]]; then - printf '%s' "$default" - return - fi - line="$(grep -E "^${key}=" .env 2>/dev/null | tail -1 || true)" - if [[ -z "$line" ]]; then - printf '%s' "$default" - return - fi - printf '%s' "${line#*=}" | tr -d '\r' -} - -resolve_project_path() { - local p="$1" - if [[ "$p" == /* ]]; then - printf '%s' "$p" - else - printf '%s' "$PROJECT_DIR/$p" - fi -} - -prune_old_backups() { - local base="$BACKUP_ROOT/$INSTANCE_NAME" - [[ -d "$base" ]] || return 0 - local cutoff - cutoff="$(TZ="$TZ_NAME" date -d "-${RETENTION_DAYS} days" +%Y-%m-%d 2>/dev/null || true)" - if [[ -z "$cutoff" ]]; then - find "$base" -mindepth 1 -maxdepth 1 -type d -mtime +"$RETENTION_DAYS" -print0 | - xargs -r -0 rm -rf - return 0 - fi - local dir name - for dir in "$base"/*/; do - [[ -d "$dir" ]] || continue - name="$(basename "$dir")" - [[ "$name" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || continue - if [[ "$name" < "$cutoff" ]]; then - log "prune: remove $dir (older than ${RETENTION_DAYS} days)" - rm -rf "$dir" - fi - done -} - -DB_REL="$(read_env_var DB_PATH crypto.db)" -UPLOAD_REL="$(read_env_var UPLOAD_DIR static/images)" -BACKUP_ROOT="$(read_env_var BACKUP_ROOT "$BACKUP_ROOT")" -RETENTION_DAYS="$(read_env_var BACKUP_RETENTION_DAYS "$RETENTION_DAYS")" -INSTANCE_NAME="$(read_env_var BACKUP_INSTANCE "$INSTANCE_NAME")" - -DB_PATH="$(resolve_project_path "$DB_REL")" -UPLOAD_DIR="$(resolve_project_path "$UPLOAD_REL")" -DATE_TAG="$(TZ="$TZ_NAME" date +%Y-%m-%d)" -DEST="$BACKUP_ROOT/$INSTANCE_NAME/$DATE_TAG" - -if [[ ! -f "$DB_PATH" ]]; then - log "error: database not found: $DB_PATH" - exit 1 -fi - -mkdir -p "$DEST" -log "start backup instance=$INSTANCE_NAME dest=$DEST" - -if command -v sqlite3 >/dev/null 2>&1; then - sqlite3 "$DB_PATH" ".backup '$DEST/crypto.db'" - log "db: sqlite3 backup -> $DEST/crypto.db" -else - cp -a "$DB_PATH" "$DEST/crypto.db" - log "db: cp -> $DEST/crypto.db (sqlite3 not installed)" -fi - -if [[ -d "$UPLOAD_DIR" ]]; then - tar -czf "$DEST/static_images.tar.gz" -C "$(dirname "$UPLOAD_DIR")" "$(basename "$UPLOAD_DIR")" - log "images: $UPLOAD_DIR -> $DEST/static_images.tar.gz" -else - log "warn: upload dir missing, skip images: $UPLOAD_DIR" -fi - -{ - echo "instance=$INSTANCE_NAME" - echo "project_dir=$PROJECT_DIR" - echo "backup_date=$DATE_TAG" - echo "db_path=$DB_PATH" - echo "upload_dir=$UPLOAD_DIR" -} >"$DEST/manifest.txt" - -prune_old_backups -log "done" diff --git a/crypto_monitor_gate_bot/scripts/backup_db_now.py b/crypto_monitor_gate_bot/scripts/backup_db_now.py deleted file mode 100644 index 38c6ccd..0000000 --- a/crypto_monitor_gate_bot/scripts/backup_db_now.py +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env python3 -"""One-shot SQLite backup before code deploy. Reads DB_PATH from .env (default crypto.db).""" -from __future__ import annotations - -import os -import shutil -import sqlite3 -from datetime import datetime -from pathlib import Path - -PROJECT_DIR = Path(__file__).resolve().parent.parent - - -def _read_env_db_path() -> Path: - env_file = PROJECT_DIR / ".env" - default = PROJECT_DIR / "crypto.db" - if not env_file.is_file(): - return default - for line in env_file.read_text(encoding="utf-8", errors="replace").splitlines(): - line = line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - key, val = line.split("=", 1) - if key.strip() != "DB_PATH": - continue - val = val.strip().strip('"').strip("'") - p = Path(val) - return p if p.is_absolute() else PROJECT_DIR / p - return default - - -def main() -> int: - db_path = _read_env_db_path() - if not db_path.is_file(): - print(f"error: database not found: {db_path}") - return 1 - stamp = datetime.now().strftime("%Y%m%d_%H%M%S") - dest_dir = PROJECT_DIR / "backups" / stamp - dest_dir.mkdir(parents=True, exist_ok=True) - dest = dest_dir / db_path.name - try: - src = sqlite3.connect(str(db_path)) - dst = sqlite3.connect(str(dest)) - src.backup(dst) - dst.close() - src.close() - method = "sqlite3 backup" - except Exception: - shutil.copy2(db_path, dest) - method = "file copy" - manifest = dest_dir / "manifest.txt" - manifest.write_text( - "\n".join( - [ - f"project_dir={PROJECT_DIR}", - f"source_db={db_path}", - f"backup_file={dest}", - f"method={method}", - f"created_at={stamp}", - ] - ), - encoding="utf-8", - ) - print(f"ok: {dest} ({method})") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/crypto_monitor_gate_bot/scripts/fix_breakeven_labels.py b/crypto_monitor_gate_bot/scripts/fix_breakeven_labels.py deleted file mode 100644 index 80b7d04..0000000 --- a/crypto_monitor_gate_bot/scripts/fix_breakeven_labels.py +++ /dev/null @@ -1,108 +0,0 @@ -#!/usr/bin/env python3 -""" -一次性修复历史交易记录标签: -将 trade_records 里“止损但实际盈利”的记录改为“保本止盈”。 - -默认条件(可通过参数修改): -- monitor_type = 下单监控 -- result = 止损 -- pnl_amount > 0 - -用法示例: -1) 仅预览(不落库): - python scripts/fix_breakeven_labels.py --db ./crypto.db --dry-run - -2) 执行修复: - python scripts/fix_breakeven_labels.py --db ./crypto.db --apply -""" - -from __future__ import annotations - -import argparse -import sqlite3 -import sys -from pathlib import Path - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Fix historical stop-loss records with positive pnl.") - parser.add_argument("--db", required=True, help="Path to sqlite db file, e.g. ./crypto.db") - parser.add_argument("--monitor-type", default="下单监控", help="Filter by monitor_type (default: 下单监控)") - parser.add_argument("--from-result", default="止损", help="Source result label (default: 止损)") - parser.add_argument("--to-result", default="保本止盈", help="Target result label (default: 保本止盈)") - parser.add_argument("--dry-run", action="store_true", help="Preview only, no write") - parser.add_argument("--apply", action="store_true", help="Execute update") - return parser.parse_args() - - -def main() -> int: - args = parse_args() - db_path = Path(args.db).expanduser().resolve() - if not db_path.exists(): - print(f"[ERR] DB not found: {db_path}") - return 1 - - if args.dry_run and args.apply: - print("[ERR] --dry-run and --apply are mutually exclusive.") - return 1 - if not args.dry_run and not args.apply: - print("[INFO] No mode provided, defaulting to --dry-run.") - args.dry_run = True - - conn = sqlite3.connect(str(db_path)) - conn.row_factory = sqlite3.Row - cur = conn.cursor() - - where_sql = """ - monitor_type = ? - AND result = ? - AND CAST(COALESCE(pnl_amount, 0) AS REAL) > 0 - """ - params = (args.monitor_type, args.from_result) - - cur.execute(f"SELECT COUNT(*) AS c FROM trade_records WHERE {where_sql}", params) - will_change = int(cur.fetchone()["c"]) - print(f"[INFO] Candidate rows: {will_change}") - - if will_change == 0: - print("[INFO] Nothing to update.") - conn.close() - return 0 - - cur.execute( - f""" - SELECT id, symbol, result, pnl_amount, closed_at - FROM trade_records - WHERE {where_sql} - ORDER BY id DESC - LIMIT 10 - """, - params, - ) - sample = cur.fetchall() - print("[INFO] Sample (latest 10):") - for r in sample: - print( - f" id={r['id']} symbol={r['symbol']} result={r['result']} " - f"pnl={r['pnl_amount']} closed_at={r['closed_at']}" - ) - - if args.dry_run: - print("[DRY-RUN] No write executed.") - conn.close() - return 0 - - cur.execute( - f"UPDATE trade_records SET result=? WHERE {where_sql}", - (args.to_result, *params), - ) - changed = int(cur.rowcount) - conn.commit() - conn.close() - print(f"[DONE] Updated rows: {changed}") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) - diff --git a/crypto_monitor_gate_bot/scripts/install_backup_cron.sh b/crypto_monitor_gate_bot/scripts/install_backup_cron.sh deleted file mode 100644 index 2ebe5cc..0000000 --- a/crypto_monitor_gate_bot/scripts/install_backup_cron.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env bash -# Install daily backup cron: Beijing 00:00 (CRON_TZ=Asia/Shanghai). -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -BACKUP_SCRIPT="$SCRIPT_DIR/backup_data.sh" -INSTANCE_NAME="${BACKUP_INSTANCE:-$(basename "$PROJECT_DIR")}" -LOG_FILE="${BACKUP_CRON_LOG:-/var/log/crypto-monitor-backup-${INSTANCE_NAME}.log}" - -if [[ ! -x "$BACKUP_SCRIPT" ]]; then - chmod +x "$BACKUP_SCRIPT" -fi - -TMP="$(mktemp)" -trap 'rm -f "$TMP"' EXIT - -{ - crontab -l 2>/dev/null | grep -vF "$BACKUP_SCRIPT" || true - echo "CRON_TZ=Asia/Shanghai" - echo "0 0 * * * $BACKUP_SCRIPT >> $LOG_FILE 2>&1" -} >"$TMP" - -awk ' - BEGIN { tz = 0 } - /^CRON_TZ=Asia\/Shanghai$/ { - if (tz++) next - } - { print } -' "$TMP" >"${TMP}.2" -mv "${TMP}.2" "$TMP" - -crontab "$TMP" -echo "Installed cron for $INSTANCE_NAME" -echo " Schedule : daily 00:00 Asia/Shanghai" -echo " Script : $BACKUP_SCRIPT" -echo " Log : $LOG_FILE" -crontab -l | grep -F "$BACKUP_SCRIPT" || true diff --git a/crypto_monitor_gate_bot/scripts/verify_gate_funding.py b/crypto_monitor_gate_bot/scripts/verify_gate_funding.py deleted file mode 100644 index bd410a8..0000000 --- a/crypto_monitor_gate_bot/scripts/verify_gate_funding.py +++ /dev/null @@ -1,93 +0,0 @@ -""" -在项目根目录执行(会加载根目录 .env): - python scripts/verify_gate_funding.py - -依次探测:[0] swap 余额(与 App「交易账户」同源);[1]–[3] 现货 / 统一账户资金路径。 -打印 GATE_API_KEY 前 8 位便于与 Gate 控制台核对(不含 Secret)。用于服务器自检。 -""" -from __future__ import annotations - -import importlib.util -import os -import sys - -ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -if ROOT not in sys.path: - sys.path.insert(0, ROOT) - - -def _load_app(): - path = os.path.join(ROOT, "app.py") - spec = importlib.util.spec_from_file_location("crypto_app", path) - mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) - return mod - - -def main(): - os.chdir(ROOT) - mod = _load_app() - print("LIVE_TRADING_ENABLED =", os.getenv("LIVE_TRADING_ENABLED")) - ok, reason = mod.ensure_exchange_live_ready() - print("ensure_exchange_live_ready =", ok, repr(reason)) - if not ok: - print("跳过私有接口探测") - return 1 - - mod.ensure_markets_loaded() - - k = (os.getenv("GATE_API_KEY") or "").strip() - s = (os.getenv("GATE_API_SECRET") or "").strip() - if not k or "REPLACE" in k.upper(): - print("WARN: GATE_API_KEY 为空或仍像占位符,请核对 .env") - if not s or "REPLACE" in s.upper(): - print("WARN: GATE_API_SECRET 为空或仍像占位符,请核对 .env") - print("GATE_API_KEY prefix (8 chars):", (k[:8] + "…") if len(k) > 8 else "(short)") - - # 0) swap — 与 App「交易账户」余额同源(优先看此项是否与网页一致) - try: - bal = mod.exchange.fetch_balance({"type": "swap"}) - v0 = mod._extract_usdt_total(bal) - print("[0] fetch_balance(swap) USDT total =", v0) - except Exception as e: - print("[0] fetch_balance(swap) FAILED:", type(e).__name__, e) - - # 1) fetch_balance spot + marginMode spot - try: - bal = mod.exchange.fetch_balance({"type": "spot", "marginMode": "spot"}) - v = mod._extract_usdt_total(bal) - print("[1] fetch_balance(spot,marginMode=spot) USDT total =", v) - except Exception as e: - print("[1] fetch_balance(spot) FAILED:", type(e).__name__, e) - - # 2) raw spot accounts - try: - resp = mod.exchange.privateSpotGetAccounts({}) - v2 = mod._parse_gate_spot_accounts_response_usdt(resp) - print("[2] privateSpotGetAccounts USDT =", v2) - except Exception as e: - print("[2] privateSpotGetAccounts FAILED:", type(e).__name__, e) - - # 3) unified accounts raw - try: - raw = mod.exchange.privateUnifiedGetAccounts({}) - body = raw - if isinstance(body, dict) and isinstance(body.get("result"), dict): - body = body["result"] - if isinstance(body, dict): - keys = sorted(body.keys()) - print("[3] unified top-level keys (sample):", keys[:25], "..." if len(keys) > 25 else "") - v3 = mod._parse_usdt_from_gate_unified_accounts_body(body) if isinstance(body, dict) else None - print("[3] parsed unified USDT =", v3) - except Exception as e: - print("[3] privateUnifiedGetAccounts FAILED:", type(e).__name__, e) - - fu = mod._fetch_gate_funding_usdt() - print(">>> _fetch_gate_funding_usdt() =", fu) - f, t = mod.get_exchange_capitals(force=True) - print(">>> get_exchange_capitals(force=True) funding, trading =", f, t) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/crypto_monitor_gate_bot/static/icons/apple-touch-icon.png b/crypto_monitor_gate_bot/static/icons/apple-touch-icon.png deleted file mode 100644 index bd835ad75be5d5fe23ae62a783a6517351447cc8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1743 zcmV;=1~B=FP)Dt9Bo_>JoDjT838Zw_MJ}Oa(}i!K?M?J9dJA25pXLU#=%!@R?hENIv?P#C z2(d_Lpv2RKzhqkx$3>Cipf9O=MzcFEokC-8CaJqong&5g8>_>m>itmw&#k$8j9v zn8Y|H@o#)J_0xLk=T*bsyZZU**Hw#SF8|-vk7;)N^4n9jIOgVkZuhuVEg$RE&Q(Me zC-Er`&OUC(i-&hR_irbK(BnA1_Vt7q9f~pkcDH-ns+N!S&HKFgn3K45@zq}Zo}@x2 z5_)pwj?5OC7;VLveV>Q_SM4PB&t(21S>#A8bUpPoF;c~yYK@Dq&%bK_#pg+7PMD$J z)aUtm9FzFx>+cu-{_ftPXHJFi@xzl8w#FdB^sUXBX_aWOi2=|<$T zS)<5N246h=;l}s=KKe=IKp8SG`o7^LWG8d?QtK3A!!oZYwN4=hDD(Q2TBi^YIS_`E zhz!F?fZ-$zjkodbO25B)lf`fndg9>eZ!1aNzGJ9Uh@NC_TB+A>f2VU~Cqxb+&xs#* z<4MOdr+2|{5}9W*IeK*E4X$(XVkg%zL8Os+a`Y(D?mn7!tk|+0nR}5rbP7?P%sPd! zZNovSMl*+1nRN;wW{4cvp2{q8kU0z|fy{=JRBAFCPC~JjIm1pNk)yM%0-DZFG-+jr zos-C&!>g&ex^a z&A9}(jlp1-<=P5<{sEoBtYuzSz3&eA-B`msbo4$Zn3K%tZ>+e;;Upq^TQ?B{BFE6y zq{*yPSj^5zUdY@U`s=WMSu|jZ1xf_fd!(-d_bG2Vgj9 zgSk2e)@*^db%Ws~B15MT5ILYSiyR=f$N`bVnly9@#SRqIDV*LiwQ>eAa~|5xNf0?i z4u~A8GHliegiayE4V^*|TjYSqp;ANSATmS_Y$YE*ywNFywdZ6)zkm0-5OFaHXrtN(Y+zux@qkty7p{ z0UbJp8;u;K4qd^VhLh$$ITSgt+=njWM&}j!@XIhoK^oWx1oij&xnaZKXDv&S>! zuIGMU&Ap=QrJq+VjyeClYH`f++r9Xh*|)pdF;{Q*YR4=-zWHNNt$55hj$<6h5%Dsz zd5@J(ad29FPdC>LV0W5#hDpW=4Xm9Jz%@5b|s8$Mg; zrYCo})y9Rm+J$&pnWqa}CPIG}uVz;4<_}VSfA(F7O^$gv$BAwy`g&vk&b`)uYdm?( zdkHSZ6TpoNy{^>AlfGn*O>W;zY$8WV+Zb%(&Z7MDI~b`{Y!VkGG_i?Xq|gK>FaQ7m l00000000000000;$^TX&O~@Q4VfX+5002ovPDHLkV1iCZRK)-Q diff --git a/crypto_monitor_gate_bot/static/icons/favicon.ico b/crypto_monitor_gate_bot/static/icons/favicon.ico deleted file mode 100644 index 0af9b9cd2bc073bd672a187a9416a65aa4a32271..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 181 zcmZQzU<5(|0R|vYV3-di#eldoz|WnRONtA~pZv&aA?n9I?i2j3;LG zpV{5G=tEmBbC~nY3CteaE5%kXlitS56K`jeuA)7~oe^l&MOjtf@*^*|0WD?lboFyt I=akR{0I5MZBme*a diff --git a/crypto_monitor_gate_bot/static/icons/icon-192.png b/crypto_monitor_gate_bot/static/icons/icon-192.png deleted file mode 100644 index 92351e1a28fcd565bb47991ca538d9a016c72174..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1796 zcmV+f2mAPmP)9px;r2^jv{Uhf+4#syoBH;3va;WCUO_Kg{-sJ+<*%=;v(4x%uW&z+=w`X z5+OL{V!nFXp8l#jRi{q<{?ITz)jiWy{XFNq=c#k5A^-pY000000000000000006+y zsD|`m_io&-ePzSo{kzu{0}wniH$+AB7_%8F!pE46P!T@HY=jB%W6X_UGUt8!*V}p; z2QiJqn8soJ7vIfoUEJS#>Ce@QZLz_&YRC62{~Nn~mZtl#{BP{K55qYQRn+k*_D>$y zQ2rWPtuCNn>r4q@}+*-HbLSs zfYJwTltT3|N}zS_B!09p9%!={=qv^$ezct;W@Uio17Gz&(iowwUSQ$?W6Vstb1{&g zW+tfyzf~Mhfvvun2?)eSvozP~r;RdbqSeQkdjn$t7z30LXF&|wivcdL!w4VgjnH%) zpag;vNDPYH2fnFh5=_+p81pzl3<@zQU<{xPf*6$WF)kx0fuIBugTic3We~)mT78H? zqrLgu=MNx9{fI#^>KJrJ_-M*EPk#R5cmF*2RSb%8ihtGbLkx=2#ovwz#Gn|k_}epq z7!(5)e|zPSF#sS2MTkMo1_dK1fdDZmx@Z5%vv?a1w|dWRC&k2|$WZ*JY-)mLXN1%1 zXQtK1Ho~9hZ9I&d@p~fii$RgG_}BFW?TJ5z+5;$qN*8}xv5P@50P!C_xIgFybY?*e zb`gJS;TN{GqSg7_^~nRopQnCY8K@mYMPI+FGAJ zAO;6T7?k)S25o7uJ5=#+ zbI-3`UQhG`zf0udgZnE@P(rJ}DC+t9fR!zHF=(UGM#-ZD%J2pm=Z};oApR(HJwO`$ z6cK}7c8wk=e=;h8YL^6oyF}*5wwE~B_VozY1q@jUMOj75lr)m8!28G$64laxV5|u&HAf!rv z+9-cIjX_hL$%7a)8w0F!B@QktNbsrq|2Za3=Jq(4I=Ofg4RBOy?~20F|-JR0#OPn zS_riW38}PVoEAERW6sWNzBYX)+l~o-Z5@UUss5cUXs3GWZ zp57KC35Z1F`n-BUm?{t1T~F?hl@16|Gv{_96!}rzTY1GRD>T>y;A(x})=rTHoiN>Q zk3?z5GM_G;nBRBVL&qR?;Q_7Z1!r<&>?c=fN>5khY0|Ovz~IgM=MheOLFrQfy$A=W z=Q5TX+M$`ZLM1VEfkNxkPeEGrLR7A&eog4B2?;i(uIHrMNWjsgO*dag5}3p83JJ(c50E@_dH_65RgGZLDbmD(F_ nchi*o{f_9Td%`is_;q{&otMphfAQRk00000NkvXXu0mjfZ(rJi diff --git a/crypto_monitor_gate_bot/static/icons/icon-512.png b/crypto_monitor_gate_bot/static/icons/icon-512.png deleted file mode 100644 index a46fe9341add0179596e838b3d3680fbbbe92d08..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6059 zcmb_g`9o9JwqECuKnPP*kSIff+ACL7a4KlQ98reqt>OfzD8W|Is(?6vOgSimbpmS( z)+)rIMWq6Q3M!CD#94_WC_{+IAP5OEBxE>wJM_N$-XC!DLvr@c*=w!6hHrg){}i+| zz=}DZ2>@2#1}<6wfTKqo7^diFApW%mz&`fdMGIENzwc?fd)%pJ(5z);UC0*43k;X& ztgPaaC6k<8HcB>bfG?v{j#TWgduQU_LjV2HWX2@o>xU<@>O&dlgvIQF5|5U?`1a<8 zIL+RWH^cMIKe^8oB$n@LKNB9ZCnNyr`fq=v*&imdUv)=UtWIW%!zisJuKgg83v9S- z2C#>~Hx}7R_j<|+U`}!2Ql>-Wc zy`Zr#-)Y6RGVSRSrOdNu+XnsZ5{+uNTD5+>VQNv)!J%y}nt5BgODi;U{x~^4o~pd5 zU(+0Bu}ycp?$Wi&f!ec|t{sd2B-pzQo3H#<{w!zT^nX}iKk}#G#o|_xE=2U_ z?w8lwIpz7$B$80B7q6es_;u-ze2sWLZ&mimlNEPiqZR;tGrF+pqX;)s=2Nz`K$56>ZqcE-A|~f-YZS^n6Q^kom8rU4HKj zC9w@|v;R2x>+!t`zso0si7RqD|1b^f(eB9)gf z7&~A89jQ%G*O{3BVL0yt#90pRGLL?v|Mx+gxH&ra0N}pM0;c_YZk^8elET@cR~%JFb@e_4#AP zaP@ig=e)El`Kxam?Y__J8BVk(bYG^E`GYm@47Ed5V_J9u>d6FI-MdQdl~IAM9eKIA zIz@a(Y35LsiSYhe8Kw7BG`lyOp7K97b|}qK!2P{Vv%w*G{&wuBEz2^6oSV?{DcEGH zb{)2*n6MO(bF}h;O2eRM^A%hyoNkDDa@a+zGz%?tZ%#V7@lRIqBQIUuT63>EaWXV} zBjD{gW62bxfPMvZwsi@Hzwx!3MOY;FE2+E)JO0UCpG41N!V2Uz(j^SnfH2)LXZ-74 zir2K5L{q+;J!6U65OZfBxaqT*RR1h>PtDv3Fxy*Kpid#MtLU#^yYO5682!_%#!)cs zAerzaC-~qrvM+}!a1)TT-LN;odBY>h3gFGCyTQ=D2A`?J{Og&;)!!_F&?yIzY0nw#t#)Fj*y^WZ zH31us##NPTIMcx~r|M6I`7MnZ8@Yl?#1l5qi}B)o2C9Dr6ysTcvTT`Tt~=a2~sw@d{xP#r~{k&71)hk=ll;jEuF+_=OB zOT+1*H{#xrb+K-6V!QjpBdIV`1eX}Vq(Nmk1Tmm<7$^4wQDhGj6uZttXAe$3SO8K3 z9sGc-a)O47wIOIyv7RT&-&`p~wQ{x0b^| zEGBklK|&P>x&O~`@y@o;A!5J^E^OWpEqyE)NW_TcAgr~3YaeEv^FtdcmxXlD88DOr zQmuQ#6#O8tM)kGyNh-tpKk81PATlr*mri{bE{kNNkdWn3hJXk#HN56s$m0fG=p2x9 zxgZfe;?-_vf%jNks#Yo{(>k>{xe>YY2NyzQ4;vr%=lTL)t>WNUW&w@-p1F{L{!CeD zjIJw)mCMHC11VSw>FYY5Ay#s}rr9EZ%`(LX5-@-e!`3pK2A`JAq#*~BeMf|@I5{8@ zc`*WKFfbyN;tXhCDCppM7*#5Yk%`!n1-8VZFdqwo0QNbNy-^sB4J6S3STM;KkXUgp z3wnn~!x?NOUZE54EzcDkgHKha&1p?;?it?8S9DnD-13WrQ%XS0*R%QR)+TqHOm^YE zQbuob;5W4i^y{IRMl1gkP-_n`)6#Gw1bZtQw!!!r6Gq`C;lo9C_F=>>4rC~nBTSd`|vMyGCF`n5Xn;a=_=!u z>T{7=RA76@l9L!8eVP;x`c*5eRHIkd*_dGm+qhz79jY%EaR9KXgbK(iMH$Ceck6ND z=)NJrS#>UwC6dKeezq~IVUn}A*r8TGIFdYUq&wa+NDjL_)m8!;DAG;C1_OF`g{Smr zs#70;9ggITlm}XofDsXYYw%c%)ho;_1s@LeM^t}DI$L0S8&sQbS-R)W& zEn3~J!pX`BVx^~UFcuU3R_)3(LsH@~Vjd0sreK7NQQAnFTM8=Ydz-Q)Rg=eymn7d8 zc#_bvlLaAqji4S6qCvU{)uF5Hh^*ZhU%cOyY3iZN=pOX*Vxc0!B%E2|u_&I%6UcLZ ze_Ks>S)h$t31$(-nv}>!yp-rm%8_XXoZQV|Q|Y4S=`aw+@H=Omec+X-U+u+E;8~{f zY-jJ3^7=eu-Q;NaV?(iG4()=aA)Bh2Q>d{LBqR87ENqD9`jJ73i@M!5GIp;#p;5Qb?I{Yqj(u^@Pqp^5z zn(-}B94qU5kS$;BKxry~m20*dDRZC}S_@S1=8*0^VtL-%;8CtL4-~ln)uZeljIiLw zo;8H^j7dcsvT0Bh#F=<87)CcZ(mt9^YOe;md= zKHq|H;qMVECx7qpP!|Pmz(vxz5SYd<5GwKl|FdO@IrA1kESiiLZ(=C$@~ z2048rSegWx-emCk!~lW8eDW#q$YvZrTL zE_kJZL>6S8NOdYu>oZR`JTqzgV%R~iZZRSU_pVYlC^9!m=P}@bqt#Dx-lj`o`n9RZ zzSHwGmA5rUd&W^qEbR)=I~oK7(T6^IYK;p}hY+#?Q}RVdJ7z6_Eg#J`*dCCJJ_KEx z$5)p=Cj~ph$pfVAK^9-_IlDd$?P(+lKV=wl1lsPYf}!WU4vA{wgbzKLydAMpa~e{R z=w;557UwgTww_q*AEZd=<1+D6_{ysTiOrEzmcCJwyQ66Wb6XlMY+>V?=vtPXWs0_ z7v0FX$sLPAB=41GrW~r*B8HXawijtxK%_`}5vpU!2zEkyq7RN=(OB=-TY+RUX+ z?I=7w-Z>K>@ZfJDxTDep5jGMx&m6`&^*HIvj(_Mt8Oi0M=F0PDMgp9Gkq>V)MypL2 z5bm8@Dt0(M~f%Z z$iyl{ZqNwf2m?-;<7DJQOp5qSBGhRg8mIkq4mV8hR-iBW8e5W&uNpl|P}ALlzfY9Z7kH;6*DP z=paNn#k?B;!VdI1!mhV%0gC+-~sh;tP)f!y#PK74T@pxP1k`?@me{PNuJG!u*%PZLi?S#=(y z9dxKU=LJS|_coZ5-rxv$;d?*5=e%&%nfn@exs@?4TQ+?XazbEqQrA$7zrfTKVfuBdv!67#yZf{ zvD)m^N>s-@cxsA?zjuMZ`LB`tez;T$X+Vz35G7X3vx5F+zPybEzlBrTgQkQV>dRzv zG#$|zqxVkqd=_XI!5W2P3Ql;T`k8Oj@AeRrq&W-+5-(Ee?pQM0TWi0idG=Dvl)|>Rv=woP0M9 zgWSQ|Il3KKs3|V(my9L8tOidyUjE7b!5hI#J2K(puJg#)B8jNn#bX(G&A+3@P@x5^TU~=aah}VA)8uhO0NHO6&+KyW(eNB>IkUP zEGxx}wHdB7DF1?#o;7JH=7N6ab}W?MN%@ZjPi@oCPo!mt;ZrbJTVZ~;>Fo9^A~uB+ zOCBs_Vz5NDP+};#>LN;cfsSNFCg^7D9T(2L?0+!->*cLCF7>^C&e5$&a#AD>y|{B%^&`I^RI=s?ygV#uld9bHQbk56?9v^MCcB^rKtU*GlcW$H)u2KAT7#J(qW+asBia51{3Y3B^} z%LV(=wnuL1+7T8pw0F-ns_S7z!<-uzdQQKufBSH2aKPJ!!pl}Wjy)Z7vEg0eWzW~@ zXO$K2@rPre*U?L7GkhpJ>`QKU&aJZrM+UPiI@UT}TR$tDeQo`&D~g7_&#v3?G42Z= zXe;wE6cy@Kb@}&Cs5azSOuBK+;neoJdp~-XzP%r{u{-WgyR`E}9`d8i0{%Vb`3+wH^?5r!piz`b$8`^ST zHWpqNym@dT_QsJ1$E=-tZf`bu zSJvGO4p`e*IId&)t)zxIbs7C#x#StFB}%ugI`MqjGw1s+8(RIJOxyFmaI1d#t-hPv z5-NYYp+9LAEt71F7S-M!oz*MSOr?A-^Sg2%z%JgDI@P1}o7=MI9j)2>?2N3e=k#S+ z+zqD~X;MUfoh;t1X5NXMF$>~{mmbx0#nBLup%e7>)`>x3peeP3zf6Ix3x=TGpeg>p z(NWM`{r3om{XpL3+yvWcpt)p+E^E;Oob&*Gr`DDR3qafw04y3 - - - - - - - - - - - - - - - - diff --git a/crypto_monitor_gate_bot/static/icons/manifest.webmanifest b/crypto_monitor_gate_bot/static/icons/manifest.webmanifest deleted file mode 100644 index b2ffb73..0000000 --- a/crypto_monitor_gate_bot/static/icons/manifest.webmanifest +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "交易监控复盘", - "short_name": "监控", - "description": "加密货币永续交易监控与复盘", - "start_url": "/", - "display": "standalone", - "background_color": "#0b0d14", - "theme_color": "#0b0d14", - "icons": [ - { - "src": "/static/icons/icon-192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "any" - }, - { - "src": "/static/icons/icon-512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "any maskable" - } - ] -} diff --git a/crypto_monitor_gate_bot/templates/index.html b/crypto_monitor_gate_bot/templates/index.html deleted file mode 100644 index 018a03d..0000000 --- a/crypto_monitor_gate_bot/templates/index.html +++ /dev/null @@ -1,2175 +0,0 @@ - - - - - - - - - - - - - - - - - {{ 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 %} -
- {% endif %} - - - {% if page == 'stats' %} -
-
-

数据统计

- -
-
-
-
持仓占用导致错过(累计)
{{ occupied_miss_total }}
-
-
- 统计分析按北京时间 {{ stats_bundle.stats_reset_hour }}:00切日计入(与顶栏 UTC 列表窗无关)。历史总开仓(累计): - {{ stats_bundle.total_opens_all }} 次 -
-
- -
- {% for seg in stats_bundle.segments %} - - {% endfor %} -
-
- {% endif %} - - - -
-
-
-
详情
-
- - -
-
-
- -
-
- - - - - - - - - - - \ No newline at end of file diff --git a/crypto_monitor_gate_bot/templates/key_focus.html b/crypto_monitor_gate_bot/templates/key_focus.html deleted file mode 100644 index 41a633a..0000000 --- a/crypto_monitor_gate_bot/templates/key_focus.html +++ /dev/null @@ -1 +0,0 @@ -ok2 \ No newline at end of file diff --git a/crypto_monitor_gate_bot/templates/login.html b/crypto_monitor_gate_bot/templates/login.html deleted file mode 100644 index 8b6ea41..0000000 --- a/crypto_monitor_gate_bot/templates/login.html +++ /dev/null @@ -1,136 +0,0 @@ - - - - - - - 登录 · {{ exchange_display }} - - - - - - - - - diff --git a/crypto_monitor_gate_bot/templates/order_focus.html b/crypto_monitor_gate_bot/templates/order_focus.html deleted file mode 100644 index c0992d4..0000000 --- a/crypto_monitor_gate_bot/templates/order_focus.html +++ /dev/null @@ -1,194 +0,0 @@ - - - - - 实盘下单放大 | 100根K线 - - - -
-
-
-
- 返回首页 - 实盘下单放大(100根K线) -
-
最近刷新:--
-
- {% if orders %} -
- - - - - - -
- {% else %} -
当前没有激活订单,无法展示放大K线。
- {% endif %} -
- - {% if orders %} -
-
-
交易对
-
-
方向
-
-
成交价
-
-
止损
-
-
止盈
-
-
盈亏比
-
-
现价
-
-
浮盈亏
-
-
-
- -
-
-
- {% endif %} -
- -{% if orders %} - - -{% endif %} - - diff --git a/crypto_monitor_gate_bot/使用说明.md b/crypto_monitor_gate_bot/使用说明.md deleted file mode 100644 index 9427d61..0000000 --- a/crypto_monitor_gate_bot/使用说明.md +++ /dev/null @@ -1,147 +0,0 @@ -# 使用说明 - -**本文件对应仓库:`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_bot/关键位自动下单说明.md b/crypto_monitor_gate_bot/关键位自动下单说明.md deleted file mode 100644 index af6a433..0000000 --- a/crypto_monitor_gate_bot/关键位自动下单说明.md +++ /dev/null @@ -1,143 +0,0 @@ -# 关键位监控说明(自动开仓 + 人工盯盘) - -**适用:`crypto_monitor_gate`(Gate U 本位永续)** -Binance / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `key_monitor_lib.py`。 - -本文档与 `.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 | 必选 | 限价挂单逻辑 | 见斐波说明(**不在下文展开**) | - -**添加时(所有类型):** 品种须 **日成交量排名前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30)**;上沿 **>** 下沿。 - ---- - -## 二、关键阻力位 / 关键支撑位(人工盯盘) - -### 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 次提醒** 完成 | - ---- - -## 四、环境与参数(`.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_bot/更新文档.md b/crypto_monitor_gate_bot/更新文档.md deleted file mode 100644 index 880a159..0000000 --- a/crypto_monitor_gate_bot/更新文档.md +++ /dev/null @@ -1,148 +0,0 @@ -# 界面与风控更新说明(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 / Gate Bot **各执行一次** `bash scripts/install_backup_cron.sh`(若尚未安装)。 -3. 重启 Gate 实例服务(如 `pm2 restart crypto_gate`);首次启动会自动 `ALTER TABLE` 缺列(斐波、交易所盈亏、`entry_reason` 等)。 -4. 浏览器强刷(Ctrl+F5)避免旧版 `index.html` 缓存。 -5. 打开任意页确认顶栏出现 **「列表筛选(UTC)」**;`/stats` 可见分品类统计与「北京 8:00 切日」说明。 -6. 建议在测试币上先添加一条斐波监控,确认:限价已挂出、标记价失效会撤单、成交后出现持仓监控且 TP/SL 已挂上;平仓后交易记录止损(开仓)与开仓类型是否正确。 diff --git a/crypto_monitor_gate_bot/部署文档.md b/crypto_monitor_gate_bot/部署文档.md deleted file mode 100644 index af9ed1d..0000000 --- a/crypto_monitor_gate_bot/部署文档.md +++ /dev/null @@ -1,339 +0,0 @@ -# `crypto_monitor_gate_bot` 部署指南:SSH SOCKS + Gate.io + PM2(Ubuntu) - -Ubuntu 环境总览见 **[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_bot -``` - -下文用 **`/opt/crypto_monitor/crypto_monitor_gate_bot`** 仅为示例,请换成你的实际绝对路径。 - -拉取代码后,若目录下尚无 `.env`: - -```bash -cp -n .env.example .env -``` - ---- - -## 2. 配置 SSH 私钥与 `~/.ssh/config` - -```bash -mkdir -p ~/.ssh -chmod 700 ~/.ssh -# 私钥示例:~/.ssh/vps1.pem -chmod 600 ~/.ssh/vps1.pem -``` - -编辑 `~/.ssh/config`(示例别名 **`gate-vps`**,与你手工启动 `ssh -D ... gate-vps` 一致即可): - -```sshconfig -Host gate-vps - HostName 你的_VPS_IP - User root - IdentityFile ~/.ssh/vps1.pem - IdentitiesOnly yes - ServerAliveInterval 30 - ServerAliveCountMax 3 - ExitOnForwardFailure yes - BatchMode yes -``` - -测试: - -```bash -ssh gate-vps true -``` - -> 若尚未完全改为密钥登录,可暂时注释 `BatchMode yes`,调试完成后再打开。 - ---- - -## 3. 手工验证:SSH SOCKS + Gate API - -### 3.1 本地 SOCKS(示例端口 1080) - -```bash -ssh -N -D 127.0.0.1:1080 gate-vps -``` - -保持运行,另开终端继续。 - -### 3.2 验证经 SOCKS 可访问 Gate - -```bash -curl -4 -sS --max-time 15 --proxy socks5h://127.0.0.1:1080 https://api.gateio.ws/api/v4/spot/time -``` - -应返回 JSON(含服务器时间字段)。若此处失败,**不要先启动应用**:先修隧道或 VPS 出站。 - ---- - -## 4. Python 虚拟环境 - -```bash -cd /opt/crypto_monitor/crypto_monitor_gate_bot - -python3 -m venv .venv -source .venv/bin/activate -python -m pip install -U pip -pip install flask requests ccxt werkzeug PySocks Pillow -``` - -走 SOCKS 时 **必须** 安装 **`PySocks`**,否则易出现代理相关报错。 - -可选: - -```bash -export PYTHONDONTWRITEBYTECODE=1 -``` - ---- - -## 5. 配置环境变量(`.env.example` → `.env`) - -| 文件 | 是否进 Git | 说明 | -|------|------------|------| -| **`.env.example`** | ✅ 是 | 变量模板与注释,可随 `git pull` 更新 | -| **`.env`** | ❌ 否 | 本机真实配置;`app.py` **只读此文件** | - -### 5.1 首次配置 - -```bash -cd /opt/crypto_monitor/crypto_monitor_gate_bot - -cp -n .env.example .env -nano .env -``` - -### 5.2 备份与 `git pull` - -- **`.env` 不在 Git 中**:`git pull` **不会**覆盖本地 `.env`。 -- 远端若更新 **`.env.example`**,pull 后请**手动**把新增变量补进你的 `.env`。 -- **升级前备份**:`cp .env .env.backup.$(date +%Y%m%d)`;恢复:`cp .env.backup.YYYYMMDD .env`。 -- **换机**:`scp` 复制 `.env`,或新机 `cp .env.example .env` 后重填。 - -### 5.3 AI 复盘与模型(可选) - -共用根目录 **`ai_client.py`**(`PYTHONPATH=..`)。`.env` 默认 **`AI_PROVIDER=openai`** + `OPENAI_API_BASE` / `OPENAI_API_KEY` / `OPENAI_MODEL`;或 **`ollama`** + `OLLAMA_API` / `AI_MODEL`。详见 **[AI复盘与模型配置说明.md](../AI复盘与模型配置说明.md)**。 - -### 5.4 自动备份(数据库 + 复盘图片) - -每天 **北京时间 0:00** 备份到 **`/root/backups`**,保留 **30 天**(`crypto.db` + `static/images`)。 - -```bash -cd /opt/crypto_monitor/crypto_monitor_gate_bot -chmod +x scripts/backup_data.sh scripts/install_backup_cron.sh -bash scripts/install_backup_cron.sh -bash scripts/backup_data.sh # 试跑 -``` - -备份目录:`/root/backups/crypto_monitor_gate_bot/YYYY-MM-DD/`。与 Binance / Gate 实例规则相同,详见 `crypto_monitor_binance/部署文档.md` 第 5.4 节(恢复步骤、可选 `.env` 变量)。 - -若服务器同时跑 **binance、gate、gate_bot** 三个实例,请在**各自项目目录**各执行一次 `install_backup_cron.sh`。 - -### 5.4 必填项检查(Gate + 代理) - -与交易所相关的变量必须是 **Gate** 前缀(**不要**再写 OKX 变量,否则代理不会生效、密钥也不会被识别)。至少确认: - -```env -APP_HOST=127.0.0.1 -APP_PORT=5000 - -# 实盘(按需) -LIVE_TRADING_ENABLED=false -GATE_API_KEY=你的_Key -GATE_API_SECRET=你的_Secret - -# 经本机 SSH 动态转发访问 Gate(端口与隧道一致) -GATE_SOCKS_PROXY=socks5h://127.0.0.1:1080 - -# 若不用 SOCKS,可改用 HTTP 代理(一般二选一) -# GATE_HTTP_PROXY=http://127.0.0.1:7890 -# GATE_HTTPS_PROXY=http://127.0.0.1:7890 -``` - -说明:**推荐 `socks5h://`**,由 SOCKS 端解析域名,与 `curl --proxy socks5h://...` 行为一致。 - -### 5.4 趋势回调策略(可选) - -若使用「交易执行」页的 **趋势回调** 计划: - -- 详细规则见项目根目录 **`趋势回调策略说明.md`**。 -- **两阶段**:先「生成预览」(默认 **120 秒**内有效),再「确认执行」;执行时若可用余额与预览快照偏差超过 **5%** 会拒绝(可调 `.env`)。 -- 补仓档位数默认 **5**,预览有效期与余额偏差阈值可在 `.env` 覆盖: - -```env -TREND_PULLBACK_DCA_LEGS=5 -TREND_PULLBACK_PREVIEW_TTL_SECONDS=120 -TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT=5 -``` - -- **生成预览**与**确认执行**时都会读取 **Gate 永续账户 USDT 可用余额**;请尽量使用 **单独子账户** 承载策略资金。 - -**界面与对账(与策略说明 3.4–3.5 节一致)** - -- 页顶 **计划历史**:仅 **已结束** 的趋势计划(不含未执行预览);可 **删除** 计划行,并删除 `trend_plan_id` 关联的「趋势回调」`trade_records`(新数据;旧行无 `trend_plan_id` 不级联)。 -- **运行中计划**展示交易所 **未实现盈亏**(浮盈亏)。 -- **交易记录**:趋势单在配置 API Key 后,打开「交易执行 / 交易记录」页会按节流(约 **25 秒**内同进程最多一次)拉取 Gate **平仓历史**,回填 **`exchange_realized_pnl`** 等;列表展示优先用交易所口径(见策略说明)。 - -**与交易所对齐的可选环境变量** - -```env -# 平仓历史同步起点:北京日期 YYYY-MM-DD 的 0 点(与 APP_TIMEZONE 一致);留空则从近 90 天拉取 -# EXCHANGE_POSITION_SYNC_FROM_BJ=2026-05-14 -# EXCHANGE_POSITION_HISTORY_LIMIT=200 -``` - -说明:同步 **只读** 交易所接口,**不要求** `LIVE_TRADING_ENABLED=true`;无 Key 时不拉取,界面仍可用(浮盈亏可能为「—」、交易记录仍为本地「估」)。 - -**交易记录 CSV**:导出为 **v3**,含 `trend_plan_id` 与交易所对齐列(详见策略说明数据库一节)。 - ---- - -## 6. 手工启动 Flask(验证) - -1. SOCKS 已监听 `127.0.0.1:1080` -2. 已 `source .venv/bin/activate` -3. `.env` 已含 `GATE_SOCKS_PROXY` - -```bash -cd /opt/crypto_monitor/crypto_monitor_gate_bot -source .venv/bin/activate -python app.py -``` - -浏览器访问:`http://127.0.0.1:5000`(或你在 `.env` 中的端口)。 - ---- - -## 7. 安装 PM2 - -```bash -sudo npm i -g pm2 -pm2 -v -``` - ---- - -## 8. PM2:使用仓库内 `ecosystem.config.cjs`(推荐) - -在项目根目录: - -```bash -cd /opt/crypto_monitor/crypto_monitor_gate_bot -pm2 start ecosystem.config.cjs -pm2 status -pm2 logs --lines 200 -``` - -默认只启动 **`crypto-monitor-gate`**(`.venv/bin/python app.py`)。 - -### 本机已可直连 Gate、不需要隧道时 - -`.env` 里应 **去掉或留空** `GATE_SOCKS_PROXY`(除非仍要走别的代理),再 `pm2 start ecosystem.config.cjs`。 - -### 开机自启 - -```bash -pm2 save -pm2 startup -# 按屏幕提示执行一条 sudo 命令 -``` - ---- - -## 9. 等价手工命令(不使用 ecosystem 文件时) - -### 9.1 SSH SOCKS(自行后台常驻,不推荐用 PM2) - -示例(前台调试;生产请用 **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_bot -pm2 start /opt/crypto_monitor/crypto_monitor_gate_bot/.venv/bin/python --name crypto-monitor-gate -- \ - /opt/crypto_monitor/crypto_monitor_gate_bot/app.py -``` - ---- - -## 10. 交易所「连接不上」排查清单 - -1. **`.env` 是否为 Gate 变量**:必须是 `GATE_SOCKS_PROXY` / `GATE_API_KEY` / `GATE_API_SECRET`,不是 OKX。 -2. **隧道是否在本机端口监听**(若配置了 `GATE_SOCKS_PROXY`): - ```bash - ss -lntp | grep 1080 || true - ``` -3. **curl 复测 Gate**(与第 3.2 节相同);curl 不通则应用也不会通。 -4. **PySocks**:`pip show PySocks`,缺失则 `pip install PySocks`。 -5. **SSH 隧道连不上**:检查私钥权限、`~/.ssh/config`、VPS 出站与端口是否与 `.env` 一致。 -6. **启动顺序**:先保证 SOCKS 已监听,再 `pm2 start` 应用(或重启应用)。 - ---- - -## 11. 推荐启动顺序(习惯) - -1. 若走代理:先启动并确认 SSH SOCKS 已监听,再 `curl --proxy socks5h://127.0.0.1:1080 https://api.gateio.ws/api/v4/spot/time` 成功 -2. `pm2 start ecosystem.config.cjs` -3. 再确认页面与余额等接口正常 - ---- - -## 12. 免责声明 - -交易所有合规与地区政策要求。请确保使用方式符合当地法律法规与交易所条款。本文仅描述网络与工程部署路径。 - ---- - -## 附录:数据库标签修复脚本 `scripts/fix_breakeven_labels.py` - -在 Ubuntu 上: - -1)预览(不写库): - -```bash -python scripts/fix_breakeven_labels.py --db ./crypto.db --dry-run -``` - -2)确认后执行: - -```bash -python scripts/fix_breakeven_labels.py --db ./crypto.db --apply -``` - -默认修复条件:`monitor_type='下单监控'` 且 `result='止损'` 且 `pnl_amount > 0` → 改为 `result='保本止盈'`。 diff --git a/crypto_monitor_okx/部署文档.md b/crypto_monitor_okx/部署文档.md index 1dcd531..7f8776a 100644 --- a/crypto_monitor_okx/部署文档.md +++ b/crypto_monitor_okx/部署文档.md @@ -157,7 +157,7 @@ nano .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)**。 +**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 + 代理) diff --git a/deploy/README.md b/deploy/README.md index b8e1cd6..dadcdbe 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -29,7 +29,7 @@ bash deploy/setup_env.sh --install-system-deps 常用参数: ```bash -bash deploy/setup_env.sh --only binance,gate_bot # 仅部分子项目 +bash deploy/setup_env.sh --only binance,gate # 仅部分子项目 bash deploy/setup_env.sh --recreate-venv # 重建虚拟环境 bash deploy/setup_env.sh --skip-pm2 # 不尝试安装 pm2 bash deploy/setup_env.sh --skip-env-copy # 不复制 .env.example @@ -68,11 +68,11 @@ sed -i 's/\r$//' deploy/setup_env.sh pm2 save ``` -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)**。 +- 三个监控子项目共用根目录 **[requirements.txt](../requirements.txt)**。 - 走 SOCKS 须 **PySocks**(已包含在 requirements 中)。 diff --git a/deploy/setup_env.sh b/deploy/setup_env.sh index 679b71d..634d733 100644 --- a/deploy/setup_env.sh +++ b/deploy/setup_env.sh @@ -3,7 +3,7 @@ # # 用法: # bash deploy/setup_env.sh -# bash deploy/setup_env.sh --only binance,gate_bot +# bash deploy/setup_env.sh --only binance,gate # bash deploy/setup_env.sh --skip-pm2 # bash deploy/setup_env.sh --recreate-venv # bash deploy/setup_env.sh --install-system-deps # root + apt 时安装 python*-venv @@ -244,7 +244,6 @@ ensure_venv_prereqs "${PY}" should_include binance && setup_monitor crypto_monitor_binance should_include gate && setup_monitor crypto_monitor_gate -should_include gate_bot && setup_monitor crypto_monitor_gate_bot should_include okx && setup_monitor crypto_monitor_okx should_include hub && setup_hub diff --git a/docs/account-risk-cooldown.md b/docs/account-risk-cooldown.md index ffce723..f750a9d 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 eece67d..0bda3fc 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 crypto-monitor-gate-bot -``` +# 每日自动划转(三所统一) + +## 行为 + +在 `.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 f855069..95936cb 100644 --- a/docs/daily-open-limit.md +++ b/docs/daily-open-limit.md @@ -1,81 +1,81 @@ -# 单日开仓次数限制(四所统一) - -各交易实例(Binance / OKX / Gate / Gate_bot)在 `.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_bot:`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 crypto_gate_bot -``` - -## 实现位置 - -- 共享逻辑:`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 5e4018f..f5e5265 100644 --- a/docs/env-sync-scripts.md +++ b/docs/env-sync-scripts.md @@ -1,116 +1,116 @@ -# 四所 `.env` 同步脚本说明 - -在**仓库根目录**执行。仅处理四所实例目录下的 `.env`,**不覆盖** API 密钥与已存在的自定义值;若某目录无 `.env` 会 `SKIP`(需先 `cp .env.example .env`)。 - -| 目录 | -|------| -| `crypto_monitor_binance` | -| `crypto_monitor_okx` | -| `crypto_monitor_gate` | -| `crypto_monitor_gate_bot` | - -修改 `.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` | - ---- - -## 部署后重启 - -```bash -pm2 restart crypto-monitor-binance crypto-monitor-okx crypto-monitor-gate crypto-monitor-gate-bot -``` - -## 相关文档 - -- [计仓模式](./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` | + +--- + +## 部署后重启 + +```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/hub-symbol-archive-kline.md b/docs/hub-symbol-archive-kline.md index 959e021..71499fd 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 cc7ed08..288da48 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` 查看旧布局)。 - ---- - -## 顶层目录 - -``` -crypto_monitor/ -├── crypto_monitor_binance/ # 四所:各自 app + .env + PM2 -├── crypto_monitor_gate/ -├── crypto_monitor_gate_bot/ -├── 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/manual-order-rr-preview.md b/docs/manual-order-rr-preview.md index b777066..4a90745 100644 --- a/docs/manual-order-rr-preview.md +++ b/docs/manual-order-rr-preview.md @@ -1,31 +1,31 @@ -# 实盘下单 · 预估盈亏比 - -## 功能 - -四所(Binance / OKX / Gate / 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 819df74..17a2d01 100644 --- a/docs/position-sizing-mode.md +++ b/docs/position-sizing-mode.md @@ -1,57 +1,57 @@ -# 计仓模式(四所统一) - -## 配置 - -在各实例 `.env` 中设置(**仅能通过 env 切换,修改后须重启进程**): - -```env -# risk(默认)= 以损定仓 -# full_margin = 全仓杠杆(合约可用保证金 × 比例) -POSITION_SIZING_MODE=risk -FULL_MARGIN_BUFFER_RATIO=0.98 -``` - -切换为全仓杠杆前:**交易所须无持仓**(`MAX_ACTIVE_POSITIONS` 默认 1,全仓模式会强制单仓)。 - -## 模式说明 - -| 模式 | 保证金计算 | 杠杆 | 允许入口 | -|------|------------|------|----------| -| `risk` | `RISK_PERCENT` × 交易资金,按止损距离反推 | 表单可选 / 同步交易所 | 实盘人工、关键位自动、趋势回调、顺势加仓 | -| `full_margin` | **合约账户可用 USDT × `FULL_MARGIN_BUFFER_RATIO`**(保留 2 位小数) | BTC/ETH **10x**,其它 **5x**(与 `BTC_LEVERAGE`/`ALT_LEVERAGE` 一致) | **实盘人工下单**、**关键位触价开仓**;阻力/支撑仅提醒 | - -全仓模式下: - -- 仍校验 **计划盈亏比**(实盘用 `MANUAL_MIN_PLANNED_RR`;触价开仓用 `KEY_AUTO_MIN_PLANNED_RR`)。 -- 下单张数由 `prepare_order_amount` + 交易所 `amount_to_precision` 决定。 -- `order_monitors.initial_stop_loss` 仍记录**开仓时**止损快照;交易记录复盘以该快照为准。 -- 已存在的 **箱体突破 / 收敛突破 / 斐波 / 假突破** 监控:进程启动时**自动撤销**并企业微信通知。 - -## 不允许(全仓模式) - -- 关键位:箱体突破、收敛突破、斐波、假突破(添加时拒绝;已存在则启动时撤销)。 -- 趋势回调、顺势加仓(策略入口返回明确错误)。 - -**允许:** 关键位 **回调触价开仓** / **突破触价开仓**(程序盯价、触达/穿越计划入场后市价成交,无交易所挂单;全仓下仅允许一条待触发)。 - -## 用脚本更新四所 `.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 crypto-monitor-gate-bot -``` +# 计仓模式(三所统一) + +## 配置 + +在各实例 `.env` 中设置(**仅能通过 env 切换,修改后须重启进程**): + +```env +# risk(默认)= 以损定仓 +# full_margin = 全仓杠杆(合约可用保证金 × 比例) +POSITION_SIZING_MODE=risk +FULL_MARGIN_BUFFER_RATIO=0.98 +``` + +切换为全仓杠杆前:**交易所须无持仓**(`MAX_ACTIVE_POSITIONS` 默认 1,全仓模式会强制单仓)。 + +## 模式说明 + +| 模式 | 保证金计算 | 杠杆 | 允许入口 | +|------|------------|------|----------| +| `risk` | `RISK_PERCENT` × 交易资金,按止损距离反推 | 表单可选 / 同步交易所 | 实盘人工、关键位自动、趋势回调、顺势加仓 | +| `full_margin` | **合约账户可用 USDT × `FULL_MARGIN_BUFFER_RATIO`**(保留 2 位小数) | BTC/ETH **10x**,其它 **5x**(与 `BTC_LEVERAGE`/`ALT_LEVERAGE` 一致) | **实盘人工下单**、**关键位触价开仓**;阻力/支撑仅提醒 | + +全仓模式下: + +- 仍校验 **计划盈亏比**(实盘用 `MANUAL_MIN_PLANNED_RR`;触价开仓用 `KEY_AUTO_MIN_PLANNED_RR`)。 +- 下单张数由 `prepare_order_amount` + 交易所 `amount_to_precision` 决定。 +- `order_monitors.initial_stop_loss` 仍记录**开仓时**止损快照;交易记录复盘以该快照为准。 +- 已存在的 **箱体突破 / 收敛突破 / 斐波 / 假突破** 监控:进程启动时**自动撤销**并企业微信通知。 + +## 不允许(全仓模式) + +- 关键位:箱体突破、收敛突破、斐波、假突破(添加时拒绝;已存在则启动时撤销)。 +- 趋势回调、顺势加仓(策略入口返回明确错误)。 + +**允许:** 关键位 **回调触价开仓** / **突破触价开仓**(程序盯价、触达/穿越计划入场后市价成交,无交易所挂单;全仓下仅允许一条待触发)。 + +## 用脚本更新三所 `.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 9e4ccfc..39f5ddc 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/trend-hub-close-and-trade-records.md b/docs/trend-hub-close-and-trade-records.md index db7584f..e331f88 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 / Gate / Gate Bot + `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`(四所同一套,Gate Bot 的 `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),**交易记录没有**。 - -**原因**:Gate Bot 的 `insert_trade_record` 曾 **缺少 `entry_reason` 参数**,而 `_finalize_plan` 固定传入 `entry_reason="趋势回调"`,触发: - -```text -TypeError: insert_trade_record() got an unexpected keyword argument 'entry_reason' -``` - -策略快照在异常 **之前** 已插入,交易记录插入失败,故只出现在策略记录页。 - -**修复提交**:`80226ee` - -- Gate Bot `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_bot/crypto.db --dry-run - -# 确认后写入 -python scripts/backfill_trend_trade_records.py \ - --db crypto_monitor_gate_bot/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` | - -Gate Bot 在 `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 crypto-monitor-gate-bot 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_bot/app.py` | `insert_trade_record`(含 `entry_reason`) | -| `scripts/backfill_trend_trade_records.py` | 漏记交易记录补录 | - -### 8.4 相关提交 - -| 提交 | 说明 | -|------|------| -| `6a4ec69` | 中控与四所趋势展示 enrich 统一 | -| `08082eb` | 移除补仓表反推虚构成交价 | -| `80226ee` | 修复 Gate Bot 中控平仓漏写 `trade_records` | - ---- - -## 9. 相关文档 - -| 文档 | 内容 | -|------|------| -| [策略交易说明.md](../策略交易说明.md) | 策略总览、策略交易记录页 | -| [crypto_monitor_gate_bot/趋势回调策略说明.md](../crypto_monitor_gate_bot/趋势回调策略说明.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/crypto_monitor_gate_bot/趋势回调策略说明.md b/docs/trend-pullback-strategy.md similarity index 93% rename from crypto_monitor_gate_bot/趋势回调策略说明.md rename to docs/trend-pullback-strategy.md index 0c3d407..63d379f 100644 --- a/crypto_monitor_gate_bot/趋势回调策略说明.md +++ b/docs/trend-pullback-strategy.md @@ -1,129 +1,129 @@ -# 趋势回调策略(机器人)说明 - -本文描述 **「趋势回调」** 自动交易计划的业务规则与实现口径。 - -**四所主站**(Binance / Gate / OKX / 本目录 `crypto_monitor_gate_bot`)均在顶栏 **策略交易 → `/strategy`** 左栏提供同一套逻辑(共用 `strategy_trend_register.py`);本目录侧重 **Gate 子账户 / 机器人** 实例,可与主 Gate 账户隔离部署。 - -**检阅备忘**(中控平仓、交易记录、补仓展示、漏记补录):[docs/trend-hub-close-and-trade-records.md](../docs/trend-hub-close-and-trade-records.md) - ---- - -## 1. 适用场景 - -- 单独用于跑策略的 **Gate.io USDT 永续** 子账户(建议与主资金隔离);其它交易所实例同理,使用各自 API 与 `crypto.db`。 -- 你已明确:**方向、止损价、补仓区间边界价、止盈价、杠杆**,并接受程序按风险预算拆分 **首仓 50% + 多档补仓 50%**。 - ---- - -## 2. 名词与参数 - -| 名称 | 含义 | -|------|------| -| **合约 USDT 可用余额** | **生成预览**时通过 API 读取的 **swap 账户 USDT `free`** 快照;**确认执行**时再次读取并与快照比对偏差。 | -| **风险比例** | 默认 **5%**:指「若整笔计划在 **补仓区间远侧边界**(做多=上沿、做空=下沿)这一侧的最坏价格结构下触及止损」,目标亏损上限约为 **可用余额快照 × 风险比例**(实现上用 `calc_risk_fraction` 与 `prepare_order_amount` 反推总张数,受交易所最小张数与精度约束)。 | -| **止损价** | 用户填写;开仓后挂 **交易所仓位类止损触发单**(全平)。 | -| **补仓区间边界**(库字段 `add_upper`) | 用户填写;**仅在该价位与止损价构成的区间内** 才允许程序触发剩余 50% 的市价补仓。**界面文案**:做多显示「补仓上沿」,做空显示「补仓下沿」。校验:做多 `止损 < 边界价`;做空 `止损 > 边界价`。 | -| **止盈价** | 用户填写的 **固定价格**;**不由交易所条件止盈单触发**,由应用后台 **按标记价/行情价轮询**,达到后 **市价全平**。 | -| **杠杆** | 计划内固定写入;用于 `set_leverage` 与名义换算。 | -| **补仓档位数** | 默认 **5** 档(环境变量 `TREND_PULLBACK_DCA_LEGS` 可调);程序在满足最小张数前提下可能 **自动减少档数**。 | - ---- - -## 3. 执行流程(时间顺序) - -### 3.0 列表时间窗(交易记录 / 计划历史) - -- **交易记录**、**计划历史**(含预览快照)列表与 **交易记录 CSV 导出** 支持 **UTC** 时间筛选(默认 UTC 当日;可选近 24h、近 7d、自定义起止)。 -- 查询参数:`win_preset`(`utc_today` / `utc_last24h` / `utc_last7d` / `custom`)、自定义时另传 `from_utc`、`to_utc`。 -- **统计分析**页仍按北京时间 `TRADING_DAY_RESET_HOUR` 切日,不受列表窗影响。 - -### 3.1 预览阶段(不下单) - -1. **风控**:与「机器人下单监控」**互斥**——存在活跃机器人持仓或运行中趋势计划时,不可生成预览。 -2. **读取可用余额快照** `get_available_trading_usdt()`,失败则拒绝。 -3. **计算**(写入表 `trend_pullback_previews`,并跳转带 `preview_id`): - - 在 **补仓区间边界 ↔ 止损** 区间内生成 `N` 个补仓触发价(做多从上沿向止损、做空从下沿向止损); - - 将 **剩余 50% 计划张数** 拆成 `N` 份写入 `leg_amounts_json`。 -4. **预览有效期**:默认 **120 秒**(`TREND_PULLBACK_PREVIEW_TTL_SECONDS`),超时须重新点「生成预览」。 - -### 3.2 确认执行(实盘) - -5. 再次校验:预览未过期;**当前可用余额**与预览快照相对偏差 ≤ `TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT`(默认 **5%**),否则拒绝执行并要求重新预览。 -6. **首仓**:**立即市价** 开立 **总计划张数 × 50%**(不附带交易所止盈单)。 -7. **止损**:撤销旧条件单后,挂 **仅止损** 的仓位触发单;之后每次补仓成交会 **刷新** 止损挂单。 -7b. **保本移交下单监控**(可选):首仓完成且交易所有持仓后,可点击「保本移交下单监控」——将止损移至 **持仓均价 ± 偏移%**(默认 **+0.3%** 多 / **−0.3%** 空),仅当新止损 **优于** 当前止损时生效;**本次趋势计划随即结束**,持仓写入 **下单监控**(备注 **趋势回调计划**),交易所在 **同一时刻挂保本止损 + 计划止盈**;后续无论中控平仓或交易所手动平仓,均经下单监控轮询 **`reconcile_external_closes` / `check_order_monitors`** 写入 **交易记录**(含 `trend_plan_id`、开仓类型「趋势回调」),供人工核对。 -8. **补仓**:当价格 **穿越** 下一档触发价(做多为自上向下穿越,做空为自下向上穿越)时,按该档张数 **市价加仓**;直至 `N` 档执行完毕或计划结束。 -9. **止盈监控**:后台线程若发现价格触及止盈,则 **市价全平**。 -10. **止损触发**:若仓位被交易所止损打光,本地检测到 **持仓为 0** 后记账为 **止损** 并结束计划。 -11. **计划结束**:任一结束路径(止盈 / 止损 / 用户手动结束)均会 **撤单**(条件单 + 普通挂单,尽力而为)。 - -### 3.3 取消预览 - -用户可「取消预览」删除 `trend_pullback_previews` 中对应记录;过期记录会在新预览或页面加载时清理。 - -### 3.4 界面:计划历史与运行中浮动盈亏 - -- **计划历史(页顶卡片)** - - 仅展示 **`trend_pullback_plans` 中已结束的计划**(`status != 'active'`,如止盈结束、止损结束、手动结束)。 - - **不包含**仅存在于 `trend_pullback_previews`、从未「确认执行」的预览。 - - 每行提供 **删除**:删除该计划行,并删除 `trade_records` 中 **`trend_plan_id` 与之相同** 且类型为「趋势回调」的记录(用于与计划一一对应的新数据;历史旧行若无 `trend_plan_id` 则不会随删)。 -- **运行中的计划(交易执行页)** - - 在计划摘要下方展示 **浮盈亏(交易所)**:来自 Gate 当前持仓接口的 **未实现盈亏**(及标记价,若可得);与本地按均价估算可能略有差异,以交易所为准便于对照。 - - **补仓边界**按方向显示「补仓上沿」或「补仓下沿」(数值仍为 `add_upper` 字段)。 - - **手动保本**:表单可改偏移 %(默认见 `TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT`);成功后显示「已保本」时间与原止损(若与当前不同)。 - -### 3.5 交易记录与交易所「已实现盈亏」对齐 - -- 平仓时仍会写入一条 **`trade_records`**(`monitor_type=趋势回调`),其中的 **`pnl_amount` 等为本地估算**(`calc_pnl`,不含手续费、资金费等完整账单口径)。 -- 打开 **「交易执行」或「交易记录」** 页面时,若已配置 **`GATE_API_KEY` / `GATE_API_SECRET`**(不要求 `LIVE_TRADING_ENABLED=true`,只读即可),应用会按节流策略(同进程约 **25 秒**内最多一次)调用 Gate **`fetch_positions_history`(平仓历史)**,为尚未写入 `exchange_sync_key` 的趋势回调记录 **匹配一条平仓记录**,并回填: - - **`exchange_realized_pnl`**:交易所口径已实现盈亏(与 App「历史仓位」更接近); - - **`exchange_opened_at` / `exchange_closed_at`**:换算为应用时区(默认北京)下的开、平时间字符串。 -- **交易记录表**展示列「开仓(展示) / 平仓(展示) / 盈亏U(展示)」:对「趋势回调」行,若已同步则优先显示交易所字段(界面小字 **「所」**);未同步前仍显示本地复盘字段(小字 **「估」**)。 -- 匹配规则概要:同品种、同方向、平仓时间与本地 `closed_at` 接近,并结合 **`trend_plan_id`** 对应计划的 `opened_at` 收窄时间窗;极端情况下若短时间多笔同向同品种,仍存在错配可能,可对照 `exchange_sync_key` 与交易所记录。 - ---- - -## 4. 与「机器人下单监控」的差异 - -| 项目 | 机器人下单监控 | 趋势回调 | -|------|------------------|----------| -| 开仓 | 单次市价 + 条件止盈+止损 | 首仓 50% 市价 + 多档补仓 + **仅止损在交易所** | -| 止盈 | 条件单 + 本地监控 | **仅本地监控市价止盈** | -| 仓位基数 | 以损定仓(表单/会话基数) | **可用余额快照 × 风险比例** 推导 | -| 移动保本 | 支持(按 R 自动上移) | **保本移交**(结束计划→下单监控;交易所 TP+SL;**无**自动 R 保本) | - ---- - -## 5. 风险声明(必读) - -- 市价单存在 **滑点**;极端行情下实际亏损可能 **大于** 理论 5%。 -- 补仓触发依赖应用 **轮询间隔**(`MONITOR_POLL_SECONDS`),非毫秒级高频。 -- 交易所 **最小张数 / 精度** 可能导致计划张数被截断,实际风险略低于或偏离纸面计算。 -- 请使用 **单独 API Key / 子账户**,并先在 `LIVE_TRADING_ENABLED=false` 环境验证流程(若需沙盒请自行对接测试网,本仓库默认实盘接口)。 - ---- - -## 6. 相关环境变量 - -| 变量 | 说明 | 默认 | -|------|------|------| -| `TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT` | 手动保本默认偏移(相对持仓均价,%) | `0.3` | -| `TREND_PULLBACK_DCA_LEGS` | 剩余 50% 拆档数量上限 | `5` | -| `TREND_PULLBACK_PREVIEW_TTL_SECONDS` | 预览有效时间(秒) | `120` | -| `TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT` | 确认执行时允许「当前可用 / 预览快照」最大相对偏差(%) | `5` | -| `MONITOR_POLL_SECONDS` | 监控轮询间隔(秒) | `3` | -| `LIVE_TRADING_ENABLED` | 是否允许真实下单 | `false` | -| `FULL_MARGIN_BUFFER_RATIO` | 计划保证金相对可用余额上限比例 | `0.98` | -| `APP_TIMEZONE` | 应用墙钟与「北京日期」同步起点时区(如 `Asia/Shanghai`) | `Asia/Shanghai` | -| `EXCHANGE_POSITION_SYNC_FROM_BJ` | 拉取 Gate **平仓历史** 的最早日期(`YYYY-MM-DD`,按 `APP_TIMEZONE` 当日 **00:00** 起算)。**留空**则从近 **90 天** 起拉取 | 空 | -| `EXCHANGE_POSITION_HISTORY_LIMIT` | 单次拉取平仓历史条数上限(50–1000) | `200` | - ---- - -## 7. 数据库 - -- **`trend_pullback_previews`**:未执行的预览行(含 `expires_at_ms`),执行成功或取消后删除;过期可被清理。 -- **`trend_pullback_plans`**:趋势回调计划。执行后写入一行,`status='active'` 表示运行中;止盈 / 止损 / 手动结束后变为 **`stopped_tp` / `stopped_sl` / `stopped_manual`** 等非 `active` 状态,并出现在页顶 **计划历史**。字段含快照可用余额、计划保证金、总张数、首仓张数、补仓 JSON、网格价 JSON、已补仓档数、均价、`opened_at`、`message`(结束说明)等;**`add_upper`** 存补仓区间远侧边界价(做多=上沿、做空=下沿)。 -- **`trade_records`**(`monitor_type=趋势回调`):每次计划结束插入一行;含本地估算盈亏等。新写入行带 **`trend_plan_id`** 指向 `trend_pullback_plans.id`。另含 **`exchange_realized_pnl`、`exchange_opened_at`、`exchange_closed_at`、`exchange_sync_key`**,由页面触发的交易所平仓历史同步填充(见 3.5)。 - -**CSV 导出**:交易记录导出为 **v3**,包含上述交易所对齐字段及 `trend_plan_id`。 +# 趋势回调策略说明 + +本文描述 **「趋势回调」** 自动交易计划的业务规则与实现口径。 + +**三所主站**(Binance / Gate / OKX)均在顶栏 **策略交易 → `/strategy`** 左栏提供同一套逻辑(共用 `strategy_trend_register.py`);各所使用各自 API 与 `crypto.db`。 + +**检阅备忘**(中控平仓、交易记录、补仓展示、漏记补录):[trend-hub-close-and-trade-records.md](./trend-hub-close-and-trade-records.md) + +--- + +## 1. 适用场景 + +- 各 **USDT 永续** 实例独立部署,使用各自 API 与 `crypto.db`。 +- 你已明确:**方向、止损价、补仓区间边界价、止盈价、杠杆**,并接受程序按风险预算拆分 **首仓 50% + 多档补仓 50%**。 + +--- + +## 2. 名词与参数 + +| 名称 | 含义 | +|------|------| +| **合约 USDT 可用余额** | **生成预览**时通过 API 读取的 **swap 账户 USDT `free`** 快照;**确认执行**时再次读取并与快照比对偏差。 | +| **风险比例** | 默认 **5%**:指「若整笔计划在 **补仓区间远侧边界**(做多=上沿、做空=下沿)这一侧的最坏价格结构下触及止损」,目标亏损上限约为 **可用余额快照 × 风险比例**(实现上用 `calc_risk_fraction` 与 `prepare_order_amount` 反推总张数,受交易所最小张数与精度约束)。 | +| **止损价** | 用户填写;开仓后挂 **交易所仓位类止损触发单**(全平)。 | +| **补仓区间边界**(库字段 `add_upper`) | 用户填写;**仅在该价位与止损价构成的区间内** 才允许程序触发剩余 50% 的市价补仓。**界面文案**:做多显示「补仓上沿」,做空显示「补仓下沿」。校验:做多 `止损 < 边界价`;做空 `止损 > 边界价`。 | +| **止盈价** | 用户填写的 **固定价格**;**不由交易所条件止盈单触发**,由应用后台 **按标记价/行情价轮询**,达到后 **市价全平**。 | +| **杠杆** | 计划内固定写入;用于 `set_leverage` 与名义换算。 | +| **补仓档位数** | 默认 **5** 档(环境变量 `TREND_PULLBACK_DCA_LEGS` 可调);程序在满足最小张数前提下可能 **自动减少档数**。 | + +--- + +## 3. 执行流程(时间顺序) + +### 3.0 列表时间窗(交易记录 / 计划历史) + +- **交易记录**、**计划历史**(含预览快照)列表与 **交易记录 CSV 导出** 支持 **UTC** 时间筛选(默认 UTC 当日;可选近 24h、近 7d、自定义起止)。 +- 查询参数:`win_preset`(`utc_today` / `utc_last24h` / `utc_last7d` / `custom`)、自定义时另传 `from_utc`、`to_utc`。 +- **统计分析**页仍按北京时间 `TRADING_DAY_RESET_HOUR` 切日,不受列表窗影响。 + +### 3.1 预览阶段(不下单) + +1. **风控**:与「机器人下单监控」**互斥**——存在活跃机器人持仓或运行中趋势计划时,不可生成预览。 +2. **读取可用余额快照** `get_available_trading_usdt()`,失败则拒绝。 +3. **计算**(写入表 `trend_pullback_previews`,并跳转带 `preview_id`): + - 在 **补仓区间边界 ↔ 止损** 区间内生成 `N` 个补仓触发价(做多从上沿向止损、做空从下沿向止损); + - 将 **剩余 50% 计划张数** 拆成 `N` 份写入 `leg_amounts_json`。 +4. **预览有效期**:默认 **120 秒**(`TREND_PULLBACK_PREVIEW_TTL_SECONDS`),超时须重新点「生成预览」。 + +### 3.2 确认执行(实盘) + +5. 再次校验:预览未过期;**当前可用余额**与预览快照相对偏差 ≤ `TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT`(默认 **5%**),否则拒绝执行并要求重新预览。 +6. **首仓**:**立即市价** 开立 **总计划张数 × 50%**(不附带交易所止盈单)。 +7. **止损**:撤销旧条件单后,挂 **仅止损** 的仓位触发单;之后每次补仓成交会 **刷新** 止损挂单。 +7b. **保本移交下单监控**(可选):首仓完成且交易所有持仓后,可点击「保本移交下单监控」——将止损移至 **持仓均价 ± 偏移%**(默认 **+0.3%** 多 / **−0.3%** 空),仅当新止损 **优于** 当前止损时生效;**本次趋势计划随即结束**,持仓写入 **下单监控**(备注 **趋势回调计划**),交易所在 **同一时刻挂保本止损 + 计划止盈**;后续无论中控平仓或交易所手动平仓,均经下单监控轮询 **`reconcile_external_closes` / `check_order_monitors`** 写入 **交易记录**(含 `trend_plan_id`、开仓类型「趋势回调」),供人工核对。 +8. **补仓**:当价格 **穿越** 下一档触发价(做多为自上向下穿越,做空为自下向上穿越)时,按该档张数 **市价加仓**;直至 `N` 档执行完毕或计划结束。 +9. **止盈监控**:后台线程若发现价格触及止盈,则 **市价全平**。 +10. **止损触发**:若仓位被交易所止损打光,本地检测到 **持仓为 0** 后记账为 **止损** 并结束计划。 +11. **计划结束**:任一结束路径(止盈 / 止损 / 用户手动结束)均会 **撤单**(条件单 + 普通挂单,尽力而为)。 + +### 3.3 取消预览 + +用户可「取消预览」删除 `trend_pullback_previews` 中对应记录;过期记录会在新预览或页面加载时清理。 + +### 3.4 界面:计划历史与运行中浮动盈亏 + +- **计划历史(页顶卡片)** + - 仅展示 **`trend_pullback_plans` 中已结束的计划**(`status != 'active'`,如止盈结束、止损结束、手动结束)。 + - **不包含**仅存在于 `trend_pullback_previews`、从未「确认执行」的预览。 + - 每行提供 **删除**:删除该计划行,并删除 `trade_records` 中 **`trend_plan_id` 与之相同** 且类型为「趋势回调」的记录(用于与计划一一对应的新数据;历史旧行若无 `trend_plan_id` 则不会随删)。 +- **运行中的计划(交易执行页)** + - 在计划摘要下方展示 **浮盈亏(交易所)**:来自 Gate 当前持仓接口的 **未实现盈亏**(及标记价,若可得);与本地按均价估算可能略有差异,以交易所为准便于对照。 + - **补仓边界**按方向显示「补仓上沿」或「补仓下沿」(数值仍为 `add_upper` 字段)。 + - **手动保本**:表单可改偏移 %(默认见 `TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT`);成功后显示「已保本」时间与原止损(若与当前不同)。 + +### 3.5 交易记录与交易所「已实现盈亏」对齐 + +- 平仓时仍会写入一条 **`trade_records`**(`monitor_type=趋势回调`),其中的 **`pnl_amount` 等为本地估算**(`calc_pnl`,不含手续费、资金费等完整账单口径)。 +- 打开 **「交易执行」或「交易记录」** 页面时,若已配置 **`GATE_API_KEY` / `GATE_API_SECRET`**(不要求 `LIVE_TRADING_ENABLED=true`,只读即可),应用会按节流策略(同进程约 **25 秒**内最多一次)调用 Gate **`fetch_positions_history`(平仓历史)**,为尚未写入 `exchange_sync_key` 的趋势回调记录 **匹配一条平仓记录**,并回填: + - **`exchange_realized_pnl`**:交易所口径已实现盈亏(与 App「历史仓位」更接近); + - **`exchange_opened_at` / `exchange_closed_at`**:换算为应用时区(默认北京)下的开、平时间字符串。 +- **交易记录表**展示列「开仓(展示) / 平仓(展示) / 盈亏U(展示)」:对「趋势回调」行,若已同步则优先显示交易所字段(界面小字 **「所」**);未同步前仍显示本地复盘字段(小字 **「估」**)。 +- 匹配规则概要:同品种、同方向、平仓时间与本地 `closed_at` 接近,并结合 **`trend_plan_id`** 对应计划的 `opened_at` 收窄时间窗;极端情况下若短时间多笔同向同品种,仍存在错配可能,可对照 `exchange_sync_key` 与交易所记录。 + +--- + +## 4. 与「机器人下单监控」的差异 + +| 项目 | 机器人下单监控 | 趋势回调 | +|------|------------------|----------| +| 开仓 | 单次市价 + 条件止盈+止损 | 首仓 50% 市价 + 多档补仓 + **仅止损在交易所** | +| 止盈 | 条件单 + 本地监控 | **仅本地监控市价止盈** | +| 仓位基数 | 以损定仓(表单/会话基数) | **可用余额快照 × 风险比例** 推导 | +| 移动保本 | 支持(按 R 自动上移) | **保本移交**(结束计划→下单监控;交易所 TP+SL;**无**自动 R 保本) | + +--- + +## 5. 风险声明(必读) + +- 市价单存在 **滑点**;极端行情下实际亏损可能 **大于** 理论 5%。 +- 补仓触发依赖应用 **轮询间隔**(`MONITOR_POLL_SECONDS`),非毫秒级高频。 +- 交易所 **最小张数 / 精度** 可能导致计划张数被截断,实际风险略低于或偏离纸面计算。 +- 请使用 **单独 API Key / 子账户**,并先在 `LIVE_TRADING_ENABLED=false` 环境验证流程(若需沙盒请自行对接测试网,本仓库默认实盘接口)。 + +--- + +## 6. 相关环境变量 + +| 变量 | 说明 | 默认 | +|------|------|------| +| `TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT` | 手动保本默认偏移(相对持仓均价,%) | `0.3` | +| `TREND_PULLBACK_DCA_LEGS` | 剩余 50% 拆档数量上限 | `5` | +| `TREND_PULLBACK_PREVIEW_TTL_SECONDS` | 预览有效时间(秒) | `120` | +| `TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT` | 确认执行时允许「当前可用 / 预览快照」最大相对偏差(%) | `5` | +| `MONITOR_POLL_SECONDS` | 监控轮询间隔(秒) | `3` | +| `LIVE_TRADING_ENABLED` | 是否允许真实下单 | `false` | +| `FULL_MARGIN_BUFFER_RATIO` | 计划保证金相对可用余额上限比例 | `0.98` | +| `APP_TIMEZONE` | 应用墙钟与「北京日期」同步起点时区(如 `Asia/Shanghai`) | `Asia/Shanghai` | +| `EXCHANGE_POSITION_SYNC_FROM_BJ` | 拉取 Gate **平仓历史** 的最早日期(`YYYY-MM-DD`,按 `APP_TIMEZONE` 当日 **00:00** 起算)。**留空**则从近 **90 天** 起拉取 | 空 | +| `EXCHANGE_POSITION_HISTORY_LIMIT` | 单次拉取平仓历史条数上限(50–1000) | `200` | + +--- + +## 7. 数据库 + +- **`trend_pullback_previews`**:未执行的预览行(含 `expires_at_ms`),执行成功或取消后删除;过期可被清理。 +- **`trend_pullback_plans`**:趋势回调计划。执行后写入一行,`status='active'` 表示运行中;止盈 / 止损 / 手动结束后变为 **`stopped_tp` / `stopped_sl` / `stopped_manual`** 等非 `active` 状态,并出现在页顶 **计划历史**。字段含快照可用余额、计划保证金、总张数、首仓张数、补仓 JSON、网格价 JSON、已补仓档数、均价、`opened_at`、`message`(结束说明)等;**`add_upper`** 存补仓区间远侧边界价(做多=上沿、做空=下沿)。 +- **`trade_records`**(`monitor_type=趋势回调`):每次计划结束插入一行;含本地估算盈亏等。新写入行带 **`trend_plan_id`** 指向 `trend_pullback_plans.id`。另含 **`exchange_realized_pnl`、`exchange_opened_at`、`exchange_closed_at`、`exchange_sync_key`**,由页面触发的交易所平仓历史同步填充(见 3.5)。 + +**CSV 导出**:交易记录导出为 **v3**,包含上述交易所对齐字段及 `trend_plan_id`。 diff --git a/docs/ubuntu-server.md b/docs/ubuntu-server.md index 7e24881..c7285f5 100644 --- a/docs/ubuntu-server.md +++ b/docs/ubuntu-server.md @@ -1,160 +1,158 @@ -# 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_gate_bot && pm2 start ecosystem.config.cjs -cd /opt/crypto_monitor/crypto_monitor_okx && pm2 start ecosystem.config.cjs - -# 2) 中控 + 四子代理(一条配置 5 进程) -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-monitor-binance` | -| `crypto_monitor_gate` | `crypto-monitor-gate` | -| `crypto_monitor_gate_bot` | `crypto-monitor-gate-bot` | -| `crypto_monitor_okx` | `crypto-monitor-okx` | -| `manual_trading_hub` | `manual-trading-hub`、`manual-agent-*` | - -以各目录 **`ecosystem.config.cjs`** 为准。 - ---- - -## 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-monitor-binance` | +| `crypto_monitor_gate` | `crypto-monitor-gate` | +| `crypto_monitor_okx` | `crypto-monitor-okx` | +| `manual_trading_hub` | `manual-trading-hub`、`manual-agent-*` | + +以各目录 **`ecosystem.config.cjs`** 为准。 + +--- + +## 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/lib/ai/ai_review_lib.py b/lib/ai/ai_review_lib.py index 21b51e8..037bf07 100644 --- a/lib/ai/ai_review_lib.py +++ b/lib/ai/ai_review_lib.py @@ -1,4 +1,4 @@ -"""AI 日复盘 / 周复盘:附图收集与 journal 文本格式化(四所共用)。""" +"""AI 日复盘 / 周复盘:附图收集与 journal 文本格式化(三所共用)。""" from __future__ import annotations import os @@ -46,7 +46,7 @@ def journal_row_lines_for_ai( *, include_hold_duration: bool = True, ) -> str: - """把 journal 字段拼成给 AI 的文本;四所日复盘/周复盘共用。""" + """把 journal 字段拼成给 AI 的文本;三所日复盘/周复盘共用。""" lines = [ ( f"{idx}. {_journal_nz(_row_get(row, 'coin'))} {_journal_nz(_row_get(row, 'tf'))} " diff --git a/lib/common/static/account_risk_badge.css b/lib/common/static/account_risk_badge.css index 4ac19ba..34e458e 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 595c109..0417af6 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/instance_theme.css b/lib/common/static/instance_theme.css index f117fac..abcfc3e 100644 --- a/lib/common/static/instance_theme.css +++ b/lib/common/static/instance_theme.css @@ -1,1574 +1,1574 @@ -/* 实例页手机端:与中控一致,桌面专属区块隐藏;下载仅电脑端 */ -@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; - } - - .grid { - gap: 10px; - } - - .card { - padding: 12px; - } - - .form-grid { - 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 { - 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 { - 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"] { - color-scheme: light; -} - -html[data-theme="light"] body { - background: #d8e2ec !important; - color: #1a2838 !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: #b8c8d8 !important; -} - -html[data-theme="light"] .stat-item .label, -html[data-theme="light"] .status, -html[data-theme="light"] .rule-tip { - color: #4a6078 !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: #f6f9fc !important; - color: #142232 !important; - border-color: #b8c8d8 !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: #4a6078 !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: #4a6078; -} - -.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: #006e9a; - background: rgba(0, 110, 154, 0.12); - box-shadow: inset 0 0 0 1px #b8c8d8; -} - -.header-row { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: center; - gap: 10px; - margin-top: 6px; -} - -.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; -} -.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; -} - -/* ── Gate Bot 统计分栏(机器人 / 趋势回调)── */ -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; -} - -#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; -} - +/* 实例页手机端:与中控一致,桌面专属区块隐藏;下载仅电脑端 */ +@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; + } + + .grid { + gap: 10px; + } + + .card { + padding: 12px; + } + + .form-grid { + 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 { + 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 { + 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"] { + color-scheme: light; +} + +html[data-theme="light"] body { + background: #d8e2ec !important; + color: #1a2838 !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: #b8c8d8 !important; +} + +html[data-theme="light"] .stat-item .label, +html[data-theme="light"] .status, +html[data-theme="light"] .rule-tip { + color: #4a6078 !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: #f6f9fc !important; + color: #142232 !important; + border-color: #b8c8d8 !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: #4a6078 !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: #4a6078; +} + +.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: #006e9a; + background: rgba(0, 110, 154, 0.12); + box-shadow: inset 0 0 0 1px #b8c8d8; +} + +.header-row { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 10px; + margin-top: 6px; +} + +.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; +} +.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; +} + +#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; +} + diff --git a/lib/common/static/instance_theme.js b/lib/common/static/instance_theme.js index 01188a5..fee70b0 100644 --- a/lib/common/static/instance_theme.js +++ b/lib/common/static/instance_theme.js @@ -1,572 +1,572 @@ -/** - * 四所实例主题:默认暗色;单独登录用 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: "#d8e2ec" }; - - function normalize(theme) { - return theme === "light" ? "light" : "dark"; - } - - function isHubLinked() { - try { - const p = new URLSearchParams(location.search); - if (p.get("embed") === "1") return true; - const ht = p.get("hub_theme"); - if (ht === "light" || ht === "dark") return true; - } catch (_) {} - 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: "#d8e2ec" }; + + function normalize(theme) { + return theme === "light" ? "light" : "dark"; + } + + function isHubLinked() { + try { + const p = new URLSearchParams(location.search); + if (p.get("embed") === "1") return true; + const ht = p.get("hub_theme"); + if (ht === "light" || ht === "dark") return true; + } catch (_) {} + 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_ui.js b/lib/common/static/instance_ui.js index 460302a..7e134fb 100644 --- a/lib/common/static/instance_ui.js +++ b/lib/common/static/instance_ui.js @@ -1,269 +1,269 @@ -/** - * 四所实例共用 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(o.new_trade_while_occupied || "-")}`, - `心态标签:${escapeHtml(moodTags)}`, - `备注:${escapeHtml(o.note || "无")}`, - ]; - return lines.join("
"); - } - - 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(); - const imgEl = document.getElementById("detailImage"); - if (imgEl) { - if (o.image) { - imgEl.src = `/static/images/${o.image}`; - imgEl.style.display = "block"; - } else { - imgEl.src = ""; - imgEl.style.display = "none"; - } - } - 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 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, - }; -})(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(o.new_trade_while_occupied || "-")}`, + `心态标签:${escapeHtml(moodTags)}`, + `备注:${escapeHtml(o.note || "无")}`, + ]; + return lines.join("
"); + } + + 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(); + const imgEl = document.getElementById("detailImage"); + if (imgEl) { + if (o.image) { + imgEl.src = `/static/images/${o.image}`; + imgEl.style.display = "block"; + } else { + imgEl.src = ""; + imgEl.style.display = "none"; + } + } + 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 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, + }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/key_monitor_form.js b/lib/common/static/key_monitor_form.js index 4c4aea5..ff8d4de 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/trade_stats_calendar.css b/lib/common/static/trade_stats_calendar.css index f928e3c..eba49d2 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 c899c0a..0c0063e 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/exchange/gate_transfer_lib.py b/lib/exchange/gate_transfer_lib.py index dea7e8b..177ea6b 100644 --- a/lib/exchange/gate_transfer_lib.py +++ b/lib/exchange/gate_transfer_lib.py @@ -1,4 +1,4 @@ -"""Gate.io 资金划转(crypto_monitor_gate / crypto_monitor_gate_bot 共用)。""" +"""Gate.io 资金划转(crypto_monitor_gate 共用)。""" from __future__ import annotations from typing import Any, Callable, Optional diff --git a/lib/hub/hub_backup_lib.py b/lib/hub/hub_backup_lib.py index c1919c1..f095217 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 @@ -22,7 +22,6 @@ EXCHANGE_DIRS: list[tuple[str, str]] = [ ("binance", "crypto_monitor_binance"), ("okx", "crypto_monitor_okx"), ("gate", "crypto_monitor_gate"), - ("gate_bot", "crypto_monitor_gate_bot"), ] HUB_JSON_FILES = ( diff --git a/lib/hub/hub_bridge.py b/lib/hub/hub_bridge.py index f416a5b..62a8f2a 100644 --- a/lib/hub/hub_bridge.py +++ b/lib/hub/hub_bridge.py @@ -42,7 +42,7 @@ def _merge_query_into_path(path: str, **params: str) -> str: def install_instance_theme_static(app) -> None: - """仓库 lib/common/static 下 instance_theme.* 等供四所页面共用。""" + """仓库 lib/common/static 下 instance_theme.* 等供三所页面共用。""" import os from flask import Response, send_file @@ -96,7 +96,7 @@ def register_trade_stats_calendar_route( reset_hour: int, get_db_fn=None, ): - """四所统计分析页:按月返回各交易日盈亏/笔数。""" + """三所统计分析页:按月返回各交易日盈亏/笔数。""" from flask import jsonify, request from lib.trade.trade_stats_calendar_lib import build_trade_stats_calendar diff --git a/lib/hub/hub_ohlcv_lib.py b/lib/hub/hub_ohlcv_lib.py index 6213c11..e2a0982 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_position_metrics.py b/lib/hub/hub_position_metrics.py index 3852340..1540f49 100644 --- a/lib/hub/hub_position_metrics.py +++ b/lib/hub/hub_position_metrics.py @@ -1,252 +1,252 @@ -"""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 - - -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 abs(v) - except (TypeError, ValueError): - pass - raw = p.get("contracts") - if raw is not None: - try: - v = float(raw) - if v != 0: - return abs(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 + + +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 abs(v) + except (TypeError, ValueError): + pass + raw = p.get("contracts") + if raw is not None: + try: + v = float(raw) + if v != 0: + return abs(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_volume_rank_lib.py b/lib/hub/hub_volume_rank_lib.py index 5ee3667..04919ce 100644 --- a/lib/hub/hub_volume_rank_lib.py +++ b/lib/hub/hub_volume_rank_lib.py @@ -365,7 +365,7 @@ def _collect_scores(exchange, exchange_id: str) -> list[tuple[str, str, float]]: return _scores_from_okx(exchange) if ex_id == "binance": return _scores_from_binance(exchange) - if ex_id in ("gateio", "gate", "gate_bot"): + if ex_id in ("gateio", "gate"): return _scores_from_gate(exchange) tickers = exchange.fetch_tickers() return _scores_from_markets(exchange, tickers or {}, ex_id) @@ -373,7 +373,7 @@ def _collect_scores(exchange, exchange_id: str) -> list[tuple[str, str, float]]: def _uses_lightweight_volume_scores(exchange_id: str) -> bool: ex_id = str(exchange_id or "").lower() - return ex_id in ("okx", "binance", "gateio", "gate", "gate_bot") + return ex_id in ("okx", "binance", "gateio", "gate") def build_usdt_swap_volume_ranks( diff --git a/lib/instance/instance_embed_lib.py b/lib/instance/instance_embed_lib.py index 3afc085..d5a7de6 100644 --- a/lib/instance/instance_embed_lib.py +++ b/lib/instance/instance_embed_lib.py @@ -33,7 +33,6 @@ PATH_TO_EMBED_TAB: dict[str, str] = { ORDER_RULE_TIPS_BY_EXCHANGE: dict[str, str] = { "gate": "order_monitor_rule_tips_gate.html", - "gate_bot": "order_monitor_rule_tips_gate.html", "binance": "order_monitor_rule_tips_binance.html", "okx": "order_monitor_rule_tips_okx.html", } @@ -45,7 +44,7 @@ def order_rule_tips_template(exchange_key: str) -> str: def include_transfer_block(exchange_key: str) -> bool: - return (exchange_key or "").strip().lower() in ("gate", "gate_bot") + return (exchange_key or "").strip().lower() == "gate" def path_to_embed_tab(path: str) -> str | None: diff --git a/lib/key_monitor/key_monitor_schema_lib.py b/lib/key_monitor/key_monitor_schema_lib.py index 1716815..35a13c1 100644 --- a/lib/key_monitor/key_monitor_schema_lib.py +++ b/lib/key_monitor/key_monitor_schema_lib.py @@ -1,14 +1,14 @@ -"""关键位监控表结构迁移(四所共用)。""" -from __future__ import annotations - -from typing import Any - - -def ensure_key_monitor_schema(conn: Any) -> None: - for sql in ( - "ALTER TABLE key_monitors ADD COLUMN last_mark_price REAL", - ): - try: - conn.execute(sql) - except Exception: - pass +"""关键位监控表结构迁移(三所共用)。""" +from __future__ import annotations + +from typing import Any + + +def ensure_key_monitor_schema(conn: Any) -> None: + for sql in ( + "ALTER TABLE key_monitors ADD COLUMN last_mark_price REAL", + ): + try: + conn.execute(sql) + except Exception: + pass diff --git a/lib/key_monitor/trigger_entry_key_monitor_lib.py b/lib/key_monitor/trigger_entry_key_monitor_lib.py index 5ff31bf..cffe72f 100644 --- a/lib/key_monitor/trigger_entry_key_monitor_lib.py +++ b/lib/key_monitor/trigger_entry_key_monitor_lib.py @@ -1,4 +1,4 @@ -"""回调/突破触价开仓关键位监控:程序盯价、触达计划入场后市价成交(四所共用逻辑)。""" +"""回调/突破触价开仓关键位监控:程序盯价、触达计划入场后市价成交(三所共用逻辑)。""" from __future__ import annotations from datetime import datetime diff --git a/lib/strategy/strategy_config.py b/lib/strategy/strategy_config.py index 3f0c12f..53c9838 100644 --- a/lib/strategy/strategy_config.py +++ b/lib/strategy/strategy_config.py @@ -1,230 +1,230 @@ -"""各交易所 app 模块 → strategy_register 配置(统一工厂)。""" -from __future__ import annotations - -import sys -from typing import Any - - -def resolve_trading_app_module(app_module: Any = None) -> Any: - """ - 须在 login_required 定义之后调用。 - PM2 / python app.py 时 __name__ 为 __main__,请传入 sys.modules[__name__]。 - """ - if app_module is None: - main = sys.modules.get("__main__") - if main is not None and hasattr(main, "login_required"): - m = main - else: - import inspect - - m = None - for fr in inspect.stack(): - g = fr.frame.f_globals - if callable(g.get("login_required")) and callable(g.get("get_db")): - m = g - break - if m is None: - raise RuntimeError( - "策略交易注册失败:请使用 install_strategy_trading(app, repo_root, app_module=sys.modules[__name__])" - ) - else: - m = app_module - if not hasattr(m, "login_required"): - raise RuntimeError( - "策略交易注册须在 login_required 定义之后执行(将 install_strategy_trading 放在 app.py 末尾)" - ) - return m - - -def build_strategy_config( - app_module: Any = None, *, trend_enabled: bool = False, trend_disabled_note: str = "" -) -> dict: - m = resolve_trading_app_module(app_module) - - def get_trading_capital_usdt(conn): - if hasattr(m, "get_exchange_capitals"): - _, tc = m.get_exchange_capitals(force=True) - if tc is not None: - return float(tc) - if hasattr(m, "get_available_trading_usdt"): - snap = m.get_available_trading_usdt() - if snap is not None: - return float(snap) - day = m.get_trading_day(m.app_now()) - row = m.ensure_session(conn, day) - return float(row["current_capital"]) - - def get_position(ex_sym, direction): - qty = m.get_live_position_contracts(ex_sym, direction) - entry = None - try: - rows = m.exchange.fetch_positions([ex_sym]) - for p in rows or []: - matcher = getattr(m, "_row_matches_monitor_direction", None) - if matcher and not matcher(direction, p): - continue - contracts = getattr(m, "_position_row_effective_contracts", lambda x: abs(float(x.get("contracts") or 0)))(p) - if contracts <= 0: - continue - coerce = getattr(m, "_coerce_float", None) - if coerce: - entry = coerce( - p.get("entryPrice"), - p.get("average"), - (p.get("info") or {}).get("entryPrice"), - ) - if entry: - break - except Exception: - pass - return {"contracts": float(qty or 0), "entry_price": entry} - - def amount_to_precision(ex_sym, amount): - try: - return float(m.exchange.amount_to_precision(ex_sym, float(amount))) - except Exception: - return None - - def price_to_precision(ex_sym, price): - try: - return float(m.exchange.price_to_precision(ex_sym, float(price))) - except Exception: - return None - - def market_add(ex_sym, direction, amount, leverage): - return m.place_exchange_order(ex_sym, direction, amount, leverage, stop_loss=None, take_profit=None) - - def limit_add(ex_sym, direction, amount, price, leverage): - m.exchange.set_leverage(int(leverage), ex_sym) - side = "buy" if direction == "long" else "sell" - if hasattr(m, "build_okx_order_params"): - params = m.build_okx_order_params(direction, reduce_only=False) - elif hasattr(m, "build_binance_order_params"): - params = m.build_binance_order_params(direction, reduce_only=False) - elif hasattr(m, "build_gate_order_params"): - params = m.build_gate_order_params(direction, reduce_only=False) - else: - params = {} - return m.exchange.create_order( - ex_sym, "limit", side, float(amount), float(price), params if params is not None else {} - ) - - def replace_tpsl(ex_sym, direction, sl, tp, order_row): - row = order_row or {"symbol": ex_sym, "exchange_symbol": ex_sym, "direction": direction} - m.replace_active_monitor_tpsl_on_exchange(row, sl, tp) - - def count_trends(conn): - try: - return int( - conn.execute( - "SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'" - ).fetchone()[0] - ) - except Exception: - return 0 - - def friendly_error(err): - fn = getattr(m, "friendly_exchange_error", None) or getattr( - m, "friendly_okx_error", None - ) - if not callable(fn): - return str(err) - try: - snap = m.get_available_trading_usdt() - except Exception: - snap = None - try: - return fn(err, available_usdt=snap) - except TypeError: - return fn(err) - - def limit_order_status(ex_sym, order_id): - fn = getattr(m, "fib_limit_order_status", None) - if callable(fn): - return fn(ex_sym, order_id) - return "unknown" - - def cancel_limit_order(ex_sym, order_id): - fn = getattr(m, "cancel_fib_limit_order", None) - if callable(fn): - try: - return fn(ex_sym, order_id) - except Exception: - pass - if not order_id: - return False - try: - m.exchange.cancel_order(str(order_id), ex_sym) - return True - except Exception: - return False - - def get_mark_price(symbol): - fn = getattr(m, "get_symbol_mark_price", None) or getattr(m, "get_price", None) - if not callable(fn): - return None - try: - return fn(symbol) - except Exception: - return None - - def wechat_account_label(): - fn = getattr(m, "_wechat_account_label", None) - if callable(fn): - try: - return fn() - except Exception: - pass - return getattr(m, "EXCHANGE_DISPLAY_NAME", "") or "" - - def wechat_direction_text(direction): - fn = getattr(m, "_wechat_direction_text", None) - if callable(fn): - try: - return fn(direction) - except Exception: - pass - d = (direction or "long").strip().lower() - return "做多" if d == "long" else "做空" - - def send_wechat(content): - fn = getattr(m, "send_wechat_msg", None) - if callable(fn): - fn(content) - - note = trend_disabled_note or ( - "趋势回调(自动补仓)请在 Gate 趋势机器人实例使用:/strategy/trend" - ) - return { - "app_module": m, - "exchange_display": getattr(m, "EXCHANGE_DISPLAY_NAME", ""), - "trend_enabled": trend_enabled, - "trend_disabled_note": note, - "login_required": m.login_required, - "get_db": m.get_db, - "normalize_symbol_input": m.normalize_symbol_input, - "normalize_exchange_symbol": m.normalize_exchange_symbol, - "get_price": m.get_price, - "get_trading_capital_usdt": get_trading_capital_usdt, - "get_position": get_position, - "amount_to_precision": amount_to_precision, - "price_to_precision": price_to_precision, - "market_add": market_add, - "limit_add": limit_add, - "replace_tpsl": replace_tpsl, - "ensure_live_ready": m.ensure_exchange_live_ready, - "default_risk_percent": float(getattr(m, "RISK_PERCENT", 2)), - "default_leverage": m.infer_leverage, - "friendly_error": friendly_error, - "app_now_str": m.app_now_str, - "resolve_fill_price": m.resolve_order_entry_price, - "price_fmt": m.format_price_for_symbol, - "count_active_trend_plans": count_trends if trend_enabled else count_trends, - "limit_order_status": limit_order_status, - "cancel_limit_order": cancel_limit_order, - "get_mark_price": get_mark_price, - "send_wechat": send_wechat, - "format_price": getattr(m, "format_price_for_symbol", None), - "wechat_account_label": wechat_account_label, - "wechat_direction_text": wechat_direction_text, - } +"""各交易所 app 模块 → strategy_register 配置(统一工厂)。""" +from __future__ import annotations + +import sys +from typing import Any + + +def resolve_trading_app_module(app_module: Any = None) -> Any: + """ + 须在 login_required 定义之后调用。 + PM2 / python app.py 时 __name__ 为 __main__,请传入 sys.modules[__name__]。 + """ + if app_module is None: + main = sys.modules.get("__main__") + if main is not None and hasattr(main, "login_required"): + m = main + else: + import inspect + + m = None + for fr in inspect.stack(): + g = fr.frame.f_globals + if callable(g.get("login_required")) and callable(g.get("get_db")): + m = g + break + if m is None: + raise RuntimeError( + "策略交易注册失败:请使用 install_strategy_trading(app, repo_root, app_module=sys.modules[__name__])" + ) + else: + m = app_module + if not hasattr(m, "login_required"): + raise RuntimeError( + "策略交易注册须在 login_required 定义之后执行(将 install_strategy_trading 放在 app.py 末尾)" + ) + return m + + +def build_strategy_config( + app_module: Any = None, *, trend_enabled: bool = False, trend_disabled_note: str = "" +) -> dict: + m = resolve_trading_app_module(app_module) + + def get_trading_capital_usdt(conn): + if hasattr(m, "get_exchange_capitals"): + _, tc = m.get_exchange_capitals(force=True) + if tc is not None: + return float(tc) + if hasattr(m, "get_available_trading_usdt"): + snap = m.get_available_trading_usdt() + if snap is not None: + return float(snap) + day = m.get_trading_day(m.app_now()) + row = m.ensure_session(conn, day) + return float(row["current_capital"]) + + def get_position(ex_sym, direction): + qty = m.get_live_position_contracts(ex_sym, direction) + entry = None + try: + rows = m.exchange.fetch_positions([ex_sym]) + for p in rows or []: + matcher = getattr(m, "_row_matches_monitor_direction", None) + if matcher and not matcher(direction, p): + continue + contracts = getattr(m, "_position_row_effective_contracts", lambda x: abs(float(x.get("contracts") or 0)))(p) + if contracts <= 0: + continue + coerce = getattr(m, "_coerce_float", None) + if coerce: + entry = coerce( + p.get("entryPrice"), + p.get("average"), + (p.get("info") or {}).get("entryPrice"), + ) + if entry: + break + except Exception: + pass + return {"contracts": float(qty or 0), "entry_price": entry} + + def amount_to_precision(ex_sym, amount): + try: + return float(m.exchange.amount_to_precision(ex_sym, float(amount))) + except Exception: + return None + + def price_to_precision(ex_sym, price): + try: + return float(m.exchange.price_to_precision(ex_sym, float(price))) + except Exception: + return None + + def market_add(ex_sym, direction, amount, leverage): + return m.place_exchange_order(ex_sym, direction, amount, leverage, stop_loss=None, take_profit=None) + + def limit_add(ex_sym, direction, amount, price, leverage): + m.exchange.set_leverage(int(leverage), ex_sym) + side = "buy" if direction == "long" else "sell" + if hasattr(m, "build_okx_order_params"): + params = m.build_okx_order_params(direction, reduce_only=False) + elif hasattr(m, "build_binance_order_params"): + params = m.build_binance_order_params(direction, reduce_only=False) + elif hasattr(m, "build_gate_order_params"): + params = m.build_gate_order_params(direction, reduce_only=False) + else: + params = {} + return m.exchange.create_order( + ex_sym, "limit", side, float(amount), float(price), params if params is not None else {} + ) + + def replace_tpsl(ex_sym, direction, sl, tp, order_row): + row = order_row or {"symbol": ex_sym, "exchange_symbol": ex_sym, "direction": direction} + m.replace_active_monitor_tpsl_on_exchange(row, sl, tp) + + def count_trends(conn): + try: + return int( + conn.execute( + "SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'" + ).fetchone()[0] + ) + except Exception: + return 0 + + def friendly_error(err): + fn = getattr(m, "friendly_exchange_error", None) or getattr( + m, "friendly_okx_error", None + ) + if not callable(fn): + return str(err) + try: + snap = m.get_available_trading_usdt() + except Exception: + snap = None + try: + return fn(err, available_usdt=snap) + except TypeError: + return fn(err) + + def limit_order_status(ex_sym, order_id): + fn = getattr(m, "fib_limit_order_status", None) + if callable(fn): + return fn(ex_sym, order_id) + return "unknown" + + def cancel_limit_order(ex_sym, order_id): + fn = getattr(m, "cancel_fib_limit_order", None) + if callable(fn): + try: + return fn(ex_sym, order_id) + except Exception: + pass + if not order_id: + return False + try: + m.exchange.cancel_order(str(order_id), ex_sym) + return True + except Exception: + return False + + def get_mark_price(symbol): + fn = getattr(m, "get_symbol_mark_price", None) or getattr(m, "get_price", None) + if not callable(fn): + return None + try: + return fn(symbol) + except Exception: + return None + + def wechat_account_label(): + fn = getattr(m, "_wechat_account_label", None) + if callable(fn): + try: + return fn() + except Exception: + pass + return getattr(m, "EXCHANGE_DISPLAY_NAME", "") or "" + + def wechat_direction_text(direction): + fn = getattr(m, "_wechat_direction_text", None) + if callable(fn): + try: + return fn(direction) + except Exception: + pass + d = (direction or "long").strip().lower() + return "做多" if d == "long" else "做空" + + def send_wechat(content): + fn = getattr(m, "send_wechat_msg", None) + if callable(fn): + fn(content) + + note = trend_disabled_note or ( + "趋势回调(自动补仓)请在 Gate机器人实例使用:/strategy/trend" + ) + return { + "app_module": m, + "exchange_display": getattr(m, "EXCHANGE_DISPLAY_NAME", ""), + "trend_enabled": trend_enabled, + "trend_disabled_note": note, + "login_required": m.login_required, + "get_db": m.get_db, + "normalize_symbol_input": m.normalize_symbol_input, + "normalize_exchange_symbol": m.normalize_exchange_symbol, + "get_price": m.get_price, + "get_trading_capital_usdt": get_trading_capital_usdt, + "get_position": get_position, + "amount_to_precision": amount_to_precision, + "price_to_precision": price_to_precision, + "market_add": market_add, + "limit_add": limit_add, + "replace_tpsl": replace_tpsl, + "ensure_live_ready": m.ensure_exchange_live_ready, + "default_risk_percent": float(getattr(m, "RISK_PERCENT", 2)), + "default_leverage": m.infer_leverage, + "friendly_error": friendly_error, + "app_now_str": m.app_now_str, + "resolve_fill_price": m.resolve_order_entry_price, + "price_fmt": m.format_price_for_symbol, + "count_active_trend_plans": count_trends if trend_enabled else count_trends, + "limit_order_status": limit_order_status, + "cancel_limit_order": cancel_limit_order, + "get_mark_price": get_mark_price, + "send_wechat": send_wechat, + "format_price": getattr(m, "format_price_for_symbol", None), + "wechat_account_label": wechat_account_label, + "wechat_direction_text": wechat_direction_text, + } diff --git a/lib/strategy/strategy_exchange_gate.py b/lib/strategy/strategy_exchange_gate.py index 3ac73f4..d6462a5 100644 --- a/lib/strategy/strategy_exchange_gate.py +++ b/lib/strategy/strategy_exchange_gate.py @@ -2,7 +2,7 @@ Gate.io USDT 永续 — 策略交易交易所侧能力。 实现方式:各 Gate 实例 app 通过 strategy_config.build_strategy_config(app_module) 注入 -ccxt 下单、精度、换 TP/SL;本文件为文档与类型锚点,避免在四个 app 重复实现滚仓公式。 +ccxt 下单、精度、换 TP/SL;本文件为文档与类型锚点,避免在各 app 重复实现滚仓公式。 """ from lib.strategy.strategy_exchange_base import StrategyExchangeAdapter diff --git a/lib/strategy/strategy_records_register.py b/lib/strategy/strategy_records_register.py index 1b1645d..fdf3b8a 100644 --- a/lib/strategy/strategy_records_register.py +++ b/lib/strategy/strategy_records_register.py @@ -1,4 +1,4 @@ -"""策略交易记录页:已结束趋势 / 顺势加仓快照(四所统一)。""" +"""策略交易记录页:已结束趋势 / 顺势加仓快照(三所统一)。""" from __future__ import annotations import json diff --git a/lib/strategy/strategy_snapshot_lib.py b/lib/strategy/strategy_snapshot_lib.py index a55c4e6..debb267 100644 --- a/lib/strategy/strategy_snapshot_lib.py +++ b/lib/strategy/strategy_snapshot_lib.py @@ -1,4 +1,4 @@ -"""策略结束快照:趋势回调 / 顺势加仓(四所共用)。""" +"""策略结束快照:趋势回调 / 顺势加仓(三所共用)。""" from __future__ import annotations import json diff --git a/lib/strategy/strategy_trend_lib.py b/lib/strategy/strategy_trend_lib.py index 0bbb30d..797d823 100644 --- a/lib/strategy/strategy_trend_lib.py +++ b/lib/strategy/strategy_trend_lib.py @@ -230,7 +230,7 @@ def compute_trend_plan_core( def calc_planned_reward_risk_ratio( direction: str, entry_price: float, stop_loss: float, take_profit: float ) -> Optional[float]: - """盈亏比(reward/risk),与四所 calc_rr_ratio 口径一致。""" + """盈亏比(reward/risk),与三所 calc_rr_ratio 口径一致。""" try: entry = float(entry_price) sl = float(stop_loss) @@ -375,7 +375,7 @@ def trend_leg_grid_price(plan: dict, leg_idx: int) -> Optional[float]: def trend_leg_display_price(plan: dict, leg_idx: int) -> Optional[float]: """ - 四所统一:单档展示价 = leg_fill_prices_json 实际记录,否则计划网格(首仓用均价/参考价)。 + 三所统一:单档展示价 = leg_fill_prices_json 实际记录,否则计划网格(首仓用均价/参考价)。 禁止为凑均价反推虚构成交价。 """ p = plan or {} @@ -398,7 +398,7 @@ def trend_leg_display_price(plan: dict, leg_idx: int) -> Optional[float]: def reconcile_trend_leg_fill_prices(plan: dict) -> list[float]: - """首仓(0)+已补仓(1..legs_done) 展示价列表(四所共用 trend_leg_display_price)。""" + """首仓(0)+已补仓(1..legs_done) 展示价列表(三所共用 trend_leg_display_price)。""" p = plan or {} if int(p.get("first_order_done") or 0) == 0: return [] @@ -563,7 +563,7 @@ def build_trend_preview_level_rows(preview: dict) -> tuple[dict, list[dict]]: def enrich_trend_dca_levels_with_tp(plan: dict, levels: list[dict]) -> list[dict]: """ - 四所统一补仓表 enrich(实例策略页 + 中控 monitor 共用)。 + 三所统一补仓表 enrich(实例策略页 + 中控 monitor 共用)。 触发价:实际成交价或计划网格;末档加仓后均价用持仓均价;禁止反推虚构成交价。 """ if not levels: diff --git a/lib/strategy/strategy_trend_register.py b/lib/strategy/strategy_trend_register.py index ed5856c..1dfb9c0 100644 --- a/lib/strategy/strategy_trend_register.py +++ b/lib/strategy/strategy_trend_register.py @@ -1,4 +1,4 @@ -"""趋势回调:路由、轮询、页面数据(四所共用,依赖各 app 模块交易所能力)。""" +"""趋势回调:路由、轮询、页面数据(三所共用,依赖各 app 模块交易所能力)。""" from __future__ import annotations import inspect @@ -138,8 +138,8 @@ def summarize_trend_dca_probe(cfg: dict, row) -> dict: out["block_reason"] = "交易所无持仓" else: out["block_reason"] = ( - "标记价已触达,轮询应自动下单;若仍未补请确认 PM2 进程 crypto_gate_bot " - "(非 manual-agent-gate-bot)在运行,并查看 pm2 logs crypto_gate_bot" + "标记价已触达,轮询应自动下单;若仍未补请确认 PM2 进程 crypto_gate " + "(或对应所 Flask 进程)在运行,并查看 pm2 logs" ) elif not reached: out["block_reason"] = f"标记价 {pf} 未触达下一档 {level}" @@ -520,7 +520,7 @@ def _patch_hub_trend_views(app: Flask) -> None: def patch_trend_hub_enrich(app: Flask, cfg: dict) -> None: - """hub_bridge install 之后调用:四所 /api/hub/monitor 趋势字段与策略页一致。""" + """hub_bridge install 之后调用:三所 /api/hub/monitor 趋势字段与策略页一致。""" _patch_hub_monitor_enrich(app, cfg) diff --git a/lib/strategy/strategy_ui.py b/lib/strategy/strategy_ui.py index 92ddc6f..da9cf49 100644 --- a/lib/strategy/strategy_ui.py +++ b/lib/strategy/strategy_ui.py @@ -77,9 +77,8 @@ def fetch_roll_page_data( DEFAULT_TREND_DISABLED_NOTE = ( - "趋势回调(预览、自动补仓、程序止盈)仅在 Gate 趋势机器人实例 " - "(crypto_monitor_gate_bot,常见端口 5002)中启用。" - "币安 / Gate 主站 / OKX 可使用本页「顺势加仓」;完整趋势回调请打开该实例。" + "趋势回调(预览、自动补仓、程序止盈)须在本实例 .env 设置 " + "`LIVE_TRADING_ENABLED=true` 并重启对应 PM2 进程(如 crypto_gate / crypto_okx / crypto_binance)。" ) diff --git a/lib/strategy/strategy_wechat_notify.py b/lib/strategy/strategy_wechat_notify.py index 79e5a9e..9add01c 100644 --- a/lib/strategy/strategy_wechat_notify.py +++ b/lib/strategy/strategy_wechat_notify.py @@ -1,4 +1,4 @@ -"""策略计划(趋势回调 / 滚仓)开始与结束 — 企业微信推送(四所共用)。""" +"""策略计划(趋势回调 / 滚仓)开始与结束 — 企业微信推送(三所共用)。""" from __future__ import annotations from typing import Any, Optional diff --git a/lib/strategy/templates/strategy_trend_disabled.html b/lib/strategy/templates/strategy_trend_disabled.html index 557004d..19a84c5 100644 --- a/lib/strategy/templates/strategy_trend_disabled.html +++ b/lib/strategy/templates/strategy_trend_disabled.html @@ -14,7 +14,7 @@

趋势回调

{{ trend_note }}

-

趋势回调含自动补仓档位,仅在 Gate 趋势机器人(crypto_monitor_gate_bot)实例中运行。

+

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

diff --git a/lib/strategy/templates/strategy_trend_disabled_panel.html b/lib/strategy/templates/strategy_trend_disabled_panel.html index 8b8076a..4cc808b 100644 --- a/lib/strategy/templates/strategy_trend_disabled_panel.html +++ b/lib/strategy/templates/strategy_trend_disabled_panel.html @@ -5,8 +5,8 @@ 趋势回调说明(本实例未启用)
{{ trend_disabled_note }}

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

diff --git a/lib/strategy/templates/strategy_trend_panel.html b/lib/strategy/templates/strategy_trend_panel.html index ab42b6e..64e8347 100644 --- a/lib/strategy/templates/strategy_trend_panel.html +++ b/lib/strategy/templates/strategy_trend_panel.html @@ -17,7 +17,7 @@ 计划 #{{ p.plan_id }} 标记价 {{ p.mark_price }} 已触达补仓触发价 {{ p.next_trigger }},但未自动补仓: {{ p.block_reason }}。 {% if not live_trading_enabled %} - 请在 crypto_monitor_gate_bot/.env 设置 LIVE_TRADING_ENABLED=true 后重启 PM2 进程 crypto_gate_bot(不是 manual-agent-gate-bot)。 + 请在当前实例 .env 设置 LIVE_TRADING_ENABLED=true 后重启对应 PM2 进程(如 crypto_gatecrypto_okxcrypto_binance)。 {% endif %} {% endif %} diff --git a/lib/trade/account_risk_lib.py b/lib/trade/account_risk_lib.py index 0487f65..aed484f 100644 --- a/lib/trade/account_risk_lib.py +++ b/lib/trade/account_risk_lib.py @@ -1,4 +1,4 @@ -"""账户冷静期 / 日冻结风控(四所实例共用)。""" +"""账户冷静期 / 日冻结风控(三所实例共用)。""" from __future__ import annotations import os diff --git a/lib/trade/daily_open_limit_lib.py b/lib/trade/daily_open_limit_lib.py index f90c9c9..f504881 100644 --- a/lib/trade/daily_open_limit_lib.py +++ b/lib/trade/daily_open_limit_lib.py @@ -1,140 +1,140 @@ -"""单日开仓次数:软提醒阈值 + 硬上限(四所实例共用)。""" -from __future__ import annotations - -import os -from typing import Any, Optional - - -def parse_daily_open_alert_threshold(raw: Any = None, *, default: int = 5) -> int: - """AI 克制提醒阈值;至少 1。""" - try: - v = int(raw if raw is not None and str(raw).strip() != "" else default) - except (TypeError, ValueError): - v = default - return max(1, v) - - -def parse_daily_open_hard_limit(raw: Any = None, *, default: int = 0) -> int: - """硬上限;0 表示不启用。至少 0。""" - try: - v = int(raw if raw is not None and str(raw).strip() != "" else default) - except (TypeError, ValueError): - v = default - return max(0, v) - - -def load_daily_open_limits_from_env( - env: Optional[dict[str, str]] = None, -) -> tuple[int, int]: - """从环境变量读取 (alert_threshold, hard_limit)。""" - src = env if env is not None else os.environ - alert = parse_daily_open_alert_threshold(src.get("DAILY_OPEN_ALERT_THRESHOLD")) - hard = parse_daily_open_hard_limit(src.get("DAILY_OPEN_HARD_LIMIT")) - return alert, hard - - -def count_opens_for_trading_day(conn, trading_day: str) -> int: - """本交易日已成功写入 order_monitors 的开仓次数。""" - td = (trading_day or "").strip() - if not td: - return 0 - row = conn.execute( - "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", - (td,), - ).fetchone() - return int(row[0] if row else 0) - - -def daily_open_hard_limit_blocks(opens_today: int, hard_limit: int) -> bool: - return int(hard_limit) > 0 and int(opens_today) >= int(hard_limit) - - -def hard_limit_block_reason(opens_today: int, hard_limit: int, reset_hour: int) -> str: - return ( - f"本交易日开仓次数已达上限({int(opens_today)}/{int(hard_limit)})," - f"次日北京时间 {int(reset_hour)}:00 后恢复" - ) - - -def check_daily_open_hard_limit( - conn, - trading_day: str, - hard_limit: int, - reset_hour: int, -) -> tuple[bool, str, int]: - """返回 (允许继续开仓, 拒绝原因, 当日已开次数)。""" - opens_today = count_opens_for_trading_day(conn, trading_day) - if daily_open_hard_limit_blocks(opens_today, hard_limit): - return False, hard_limit_block_reason(opens_today, hard_limit, reset_hour), opens_today - return True, "", opens_today - - -def can_trade_new_open( - *, - time_allows: bool, - active_count: int, - max_active_positions: int, - opens_today: int, - hard_limit: int, - extra_blocks: bool = False, -) -> bool: - if extra_blocks: - return False - if not time_allows: - return False - if int(active_count) >= int(max_active_positions): - return False - if daily_open_hard_limit_blocks(opens_today, hard_limit): - return False - return True - - -def should_send_daily_open_alert(before: int, after: int, alert_threshold: int) -> bool: - return int(before) < int(alert_threshold) <= int(after) - - -def build_daily_open_alert_prompt( - trading_day: str, - opens_after: int, - alert_threshold: int, - *, - hard_limit: int = 0, - detail_line: str = "", -) -> str: - hard_txt = ( - f"硬上限 {hard_limit} 次(已达后将禁止新开仓直至下一交易日)。" - if int(hard_limit) > 0 - else "未配置单日硬上限。" - ) - extra = f" {detail_line}" if detail_line else "" - return ( - f"用户在北京时间交易日 {trading_day} 已累计开仓 {opens_after} 次" - f"(AI 提醒阈值 {alert_threshold};{hard_txt})" - f"{extra}" - f"用户自述“上头了”。请给克制提醒。" - ) - - -def format_daily_open_counter_line( - opens_today: int, - alert_threshold: int, - hard_limit: int, -) -> str: - if int(hard_limit) > 0: - return ( - f"📅 当日开仓次数:{int(opens_today)} / 硬上限 {int(hard_limit)} 次" - f"(AI 提醒阈值 {int(alert_threshold)})" - ) - return ( - f"📅 当日开仓次数:{int(opens_today)} / AI 提醒阈值 {int(alert_threshold)} 次" - ) - - -def format_daily_open_summary_short( - opens_today: int, - alert_threshold: int, - hard_limit: int, -) -> str: - if int(hard_limit) > 0: - return f"本交易日累计开仓:{int(opens_today)}(硬上限 {int(hard_limit)},提醒 {int(alert_threshold)})" - return f"本交易日累计开仓:{int(opens_today)}(提醒阈值 {int(alert_threshold)})" +"""单日开仓次数:软提醒阈值 + 硬上限(三所实例共用)。""" +from __future__ import annotations + +import os +from typing import Any, Optional + + +def parse_daily_open_alert_threshold(raw: Any = None, *, default: int = 5) -> int: + """AI 克制提醒阈值;至少 1。""" + try: + v = int(raw if raw is not None and str(raw).strip() != "" else default) + except (TypeError, ValueError): + v = default + return max(1, v) + + +def parse_daily_open_hard_limit(raw: Any = None, *, default: int = 0) -> int: + """硬上限;0 表示不启用。至少 0。""" + try: + v = int(raw if raw is not None and str(raw).strip() != "" else default) + except (TypeError, ValueError): + v = default + return max(0, v) + + +def load_daily_open_limits_from_env( + env: Optional[dict[str, str]] = None, +) -> tuple[int, int]: + """从环境变量读取 (alert_threshold, hard_limit)。""" + src = env if env is not None else os.environ + alert = parse_daily_open_alert_threshold(src.get("DAILY_OPEN_ALERT_THRESHOLD")) + hard = parse_daily_open_hard_limit(src.get("DAILY_OPEN_HARD_LIMIT")) + return alert, hard + + +def count_opens_for_trading_day(conn, trading_day: str) -> int: + """本交易日已成功写入 order_monitors 的开仓次数。""" + td = (trading_day or "").strip() + if not td: + return 0 + row = conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", + (td,), + ).fetchone() + return int(row[0] if row else 0) + + +def daily_open_hard_limit_blocks(opens_today: int, hard_limit: int) -> bool: + return int(hard_limit) > 0 and int(opens_today) >= int(hard_limit) + + +def hard_limit_block_reason(opens_today: int, hard_limit: int, reset_hour: int) -> str: + return ( + f"本交易日开仓次数已达上限({int(opens_today)}/{int(hard_limit)})," + f"次日北京时间 {int(reset_hour)}:00 后恢复" + ) + + +def check_daily_open_hard_limit( + conn, + trading_day: str, + hard_limit: int, + reset_hour: int, +) -> tuple[bool, str, int]: + """返回 (允许继续开仓, 拒绝原因, 当日已开次数)。""" + opens_today = count_opens_for_trading_day(conn, trading_day) + if daily_open_hard_limit_blocks(opens_today, hard_limit): + return False, hard_limit_block_reason(opens_today, hard_limit, reset_hour), opens_today + return True, "", opens_today + + +def can_trade_new_open( + *, + time_allows: bool, + active_count: int, + max_active_positions: int, + opens_today: int, + hard_limit: int, + extra_blocks: bool = False, +) -> bool: + if extra_blocks: + return False + if not time_allows: + return False + if int(active_count) >= int(max_active_positions): + return False + if daily_open_hard_limit_blocks(opens_today, hard_limit): + return False + return True + + +def should_send_daily_open_alert(before: int, after: int, alert_threshold: int) -> bool: + return int(before) < int(alert_threshold) <= int(after) + + +def build_daily_open_alert_prompt( + trading_day: str, + opens_after: int, + alert_threshold: int, + *, + hard_limit: int = 0, + detail_line: str = "", +) -> str: + hard_txt = ( + f"硬上限 {hard_limit} 次(已达后将禁止新开仓直至下一交易日)。" + if int(hard_limit) > 0 + else "未配置单日硬上限。" + ) + extra = f" {detail_line}" if detail_line else "" + return ( + f"用户在北京时间交易日 {trading_day} 已累计开仓 {opens_after} 次" + f"(AI 提醒阈值 {alert_threshold};{hard_txt})" + f"{extra}" + f"用户自述“上头了”。请给克制提醒。" + ) + + +def format_daily_open_counter_line( + opens_today: int, + alert_threshold: int, + hard_limit: int, +) -> str: + if int(hard_limit) > 0: + return ( + f"📅 当日开仓次数:{int(opens_today)} / 硬上限 {int(hard_limit)} 次" + f"(AI 提醒阈值 {int(alert_threshold)})" + ) + return ( + f"📅 当日开仓次数:{int(opens_today)} / AI 提醒阈值 {int(alert_threshold)} 次" + ) + + +def format_daily_open_summary_short( + opens_today: int, + alert_threshold: int, + hard_limit: int, +) -> str: + if int(hard_limit) > 0: + return f"本交易日累计开仓:{int(opens_today)}(硬上限 {int(hard_limit)},提醒 {int(alert_threshold)})" + return f"本交易日累计开仓:{int(opens_today)}(提醒阈值 {int(alert_threshold)})" diff --git a/lib/trade/position_sizing_lib.py b/lib/trade/position_sizing_lib.py index 4715dc9..af615b9 100644 --- a/lib/trade/position_sizing_lib.py +++ b/lib/trade/position_sizing_lib.py @@ -1,136 +1,136 @@ -""" -四所共用:计仓模式 risk(以损定仓)| full_margin(全仓杠杆)。 -仅 env POSITION_SIZING_MODE 切换;须无持仓(由部署流程保证)。 -""" -from __future__ import annotations - -import os -from typing import Any, Optional, Tuple - -MODE_RISK = "risk" -MODE_FULL_MARGIN = "full_margin" -VALID_MODES = frozenset({MODE_RISK, MODE_FULL_MARGIN}) - -OPEN_SOURCE_MANUAL = "manual" -OPEN_SOURCE_KEY_AUTO = "key_auto" -OPEN_SOURCE_KEY_FIB = "key_fib" -OPEN_SOURCE_KEY_TRIGGER = "key_trigger" -OPEN_SOURCE_TREND = "trend" -OPEN_SOURCE_ROLL = "roll" - -FULL_MARGIN_BLOCKED_SOURCES = frozenset( - {OPEN_SOURCE_KEY_AUTO, OPEN_SOURCE_KEY_FIB, OPEN_SOURCE_TREND, OPEN_SOURCE_ROLL} -) - - -def normalize_position_sizing_mode(raw: Optional[str]) -> str: - v = (raw or MODE_RISK).strip().lower() - if v in ("full", "full_margin", "fullmargin", "全仓", "全仓杠杆"): - return MODE_FULL_MARGIN - return MODE_RISK if v in ("risk", "r", "以损定仓", "") else MODE_RISK - - -def load_position_sizing_mode(env: Optional[dict] = None) -> str: - e = env if env is not None else os.environ - return normalize_position_sizing_mode(e.get("POSITION_SIZING_MODE")) - - -def is_full_margin_mode(mode: str) -> bool: - return normalize_position_sizing_mode(mode) == MODE_FULL_MARGIN - - -def mode_label_zh(mode: str) -> str: - return "全仓杠杆" if is_full_margin_mode(mode) else "以损定仓" - - -def leverage_for_full_margin(symbol: str, btc_leverage: int, alt_leverage: int) -> int: - sym = (symbol or "").strip().upper() - if sym.startswith("BTC") or sym.startswith("ETH"): - return max(1, int(btc_leverage or 10)) - return max(1, int(alt_leverage or 5)) - - -def round_funds(value: float, decimals: int = 2) -> float: - return round(float(value), int(decimals)) - - -def risk_percent_for_storage(mode: str, risk_percent: float) -> Optional[float]: - """全仓杠杆:库内不写风险百分比(仅 risk_amount U)。""" - if is_full_margin_mode(mode): - return None - return risk_percent - - -def format_risk_display_text( - mode: str, - risk_percent: Optional[float], - risk_amount: Optional[float], - *, - decimals: int = 2, -) -> str: - """持仓/通知「风险」文案:全仓仅 U;以损定仓为 %≈U。""" - amt: Optional[float] = None - if risk_amount is not None and risk_amount != "": - try: - amt = float(risk_amount) - except (TypeError, ValueError): - amt = None - if is_full_margin_mode(mode): - if amt is None: - return "—" - return f"{round_funds(amt, decimals)}U" - pct: Optional[float] = None - if risk_percent is not None and risk_percent != "": - try: - pct = float(risk_percent) - except (TypeError, ValueError): - pct = None - pct_txt = f"{pct:g}" if pct is not None else "—" - amt_txt = round_funds(amt, decimals) if amt is not None else "—" - return f"{pct_txt}%≈{amt_txt}U" - - -def assert_open_source_allowed(mode: str, source: str) -> Tuple[bool, str]: - if not is_full_margin_mode(mode): - return True, "" - src = (source or "").strip().lower() - if src in FULL_MARGIN_BLOCKED_SOURCES: - return False, ( - "当前为全仓杠杆模式(POSITION_SIZING_MODE=full_margin)," - "不允许关键位突破/斐波自动开仓、趋势回调与顺势加仓;" - "仅支持实盘人工下单与阻力/支撑提醒。" - ) - return True, "" - - -def full_margin_requires_flat_position(active_count: int) -> Tuple[bool, str]: - if active_count > 0: - return False, "全仓杠杆模式仅允许单仓且无其它持仓,请先平仓后再开仓" - return True, "" - - -def compute_full_margin_sizing( - *, - symbol: str, - available_usdt: float, - capital_base: float, - buffer_ratio: float, - btc_leverage: int, - alt_leverage: int, - funds_decimals: int = 2, -) -> Tuple[Optional[dict[str, Any]], Optional[str]]: - if available_usdt is None or float(available_usdt) <= 0: - return None, "全仓杠杆:无法读取合约账户可用保证金" - lev = leverage_for_full_margin(symbol, btc_leverage, alt_leverage) - margin = round_funds(float(available_usdt) * float(buffer_ratio), funds_decimals) - if margin <= 0: - return None, "全仓杠杆:可用保证金不足" - notional = round_funds(margin * lev, funds_decimals) - ratio = round(margin / float(capital_base) * 100, 2) if capital_base else 0.0 - return { - "margin_capital": margin, - "leverage": lev, - "notional_value": notional, - "position_ratio": ratio, - "mode": MODE_FULL_MARGIN, - }, None +""" +三所共用:计仓模式 risk(以损定仓)| full_margin(全仓杠杆)。 +仅 env POSITION_SIZING_MODE 切换;须无持仓(由部署流程保证)。 +""" +from __future__ import annotations + +import os +from typing import Any, Optional, Tuple + +MODE_RISK = "risk" +MODE_FULL_MARGIN = "full_margin" +VALID_MODES = frozenset({MODE_RISK, MODE_FULL_MARGIN}) + +OPEN_SOURCE_MANUAL = "manual" +OPEN_SOURCE_KEY_AUTO = "key_auto" +OPEN_SOURCE_KEY_FIB = "key_fib" +OPEN_SOURCE_KEY_TRIGGER = "key_trigger" +OPEN_SOURCE_TREND = "trend" +OPEN_SOURCE_ROLL = "roll" + +FULL_MARGIN_BLOCKED_SOURCES = frozenset( + {OPEN_SOURCE_KEY_AUTO, OPEN_SOURCE_KEY_FIB, OPEN_SOURCE_TREND, OPEN_SOURCE_ROLL} +) + + +def normalize_position_sizing_mode(raw: Optional[str]) -> str: + v = (raw or MODE_RISK).strip().lower() + if v in ("full", "full_margin", "fullmargin", "全仓", "全仓杠杆"): + return MODE_FULL_MARGIN + return MODE_RISK if v in ("risk", "r", "以损定仓", "") else MODE_RISK + + +def load_position_sizing_mode(env: Optional[dict] = None) -> str: + e = env if env is not None else os.environ + return normalize_position_sizing_mode(e.get("POSITION_SIZING_MODE")) + + +def is_full_margin_mode(mode: str) -> bool: + return normalize_position_sizing_mode(mode) == MODE_FULL_MARGIN + + +def mode_label_zh(mode: str) -> str: + return "全仓杠杆" if is_full_margin_mode(mode) else "以损定仓" + + +def leverage_for_full_margin(symbol: str, btc_leverage: int, alt_leverage: int) -> int: + sym = (symbol or "").strip().upper() + if sym.startswith("BTC") or sym.startswith("ETH"): + return max(1, int(btc_leverage or 10)) + return max(1, int(alt_leverage or 5)) + + +def round_funds(value: float, decimals: int = 2) -> float: + return round(float(value), int(decimals)) + + +def risk_percent_for_storage(mode: str, risk_percent: float) -> Optional[float]: + """全仓杠杆:库内不写风险百分比(仅 risk_amount U)。""" + if is_full_margin_mode(mode): + return None + return risk_percent + + +def format_risk_display_text( + mode: str, + risk_percent: Optional[float], + risk_amount: Optional[float], + *, + decimals: int = 2, +) -> str: + """持仓/通知「风险」文案:全仓仅 U;以损定仓为 %≈U。""" + amt: Optional[float] = None + if risk_amount is not None and risk_amount != "": + try: + amt = float(risk_amount) + except (TypeError, ValueError): + amt = None + if is_full_margin_mode(mode): + if amt is None: + return "—" + return f"{round_funds(amt, decimals)}U" + pct: Optional[float] = None + if risk_percent is not None and risk_percent != "": + try: + pct = float(risk_percent) + except (TypeError, ValueError): + pct = None + pct_txt = f"{pct:g}" if pct is not None else "—" + amt_txt = round_funds(amt, decimals) if amt is not None else "—" + return f"{pct_txt}%≈{amt_txt}U" + + +def assert_open_source_allowed(mode: str, source: str) -> Tuple[bool, str]: + if not is_full_margin_mode(mode): + return True, "" + src = (source or "").strip().lower() + if src in FULL_MARGIN_BLOCKED_SOURCES: + return False, ( + "当前为全仓杠杆模式(POSITION_SIZING_MODE=full_margin)," + "不允许关键位突破/斐波自动开仓、趋势回调与顺势加仓;" + "仅支持实盘人工下单与阻力/支撑提醒。" + ) + return True, "" + + +def full_margin_requires_flat_position(active_count: int) -> Tuple[bool, str]: + if active_count > 0: + return False, "全仓杠杆模式仅允许单仓且无其它持仓,请先平仓后再开仓" + return True, "" + + +def compute_full_margin_sizing( + *, + symbol: str, + available_usdt: float, + capital_base: float, + buffer_ratio: float, + btc_leverage: int, + alt_leverage: int, + funds_decimals: int = 2, +) -> Tuple[Optional[dict[str, Any]], Optional[str]]: + if available_usdt is None or float(available_usdt) <= 0: + return None, "全仓杠杆:无法读取合约账户可用保证金" + lev = leverage_for_full_margin(symbol, btc_leverage, alt_leverage) + margin = round_funds(float(available_usdt) * float(buffer_ratio), funds_decimals) + if margin <= 0: + return None, "全仓杠杆:可用保证金不足" + notional = round_funds(margin * lev, funds_decimals) + ratio = round(margin / float(capital_base) * 100, 2) if capital_base else 0.0 + return { + "margin_capital": margin, + "leverage": lev, + "notional_value": notional, + "position_ratio": ratio, + "mode": MODE_FULL_MARGIN, + }, None diff --git a/lib/trade/trade_exchange_stats_lib.py b/lib/trade/trade_exchange_stats_lib.py index 560b478..e7b1552 100644 --- a/lib/trade/trade_exchange_stats_lib.py +++ b/lib/trade/trade_exchange_stats_lib.py @@ -1,229 +1,229 @@ -"""平仓交易:交易所口径双边成交额与手续费(四所共用聚合逻辑)。""" -from __future__ import annotations - -from typing import Any, Callable, Optional - - -def _coerce_ts_ms(raw: Any) -> int | None: - if raw in (None, ""): - return None - try: - v = int(raw) - return v if v > 1_000_000_000_000 else v * 1000 - except (TypeError, ValueError): - return None - - -def quote_turnover_usdt_from_fill(trade: dict, *, contract_size: float = 1.0) -> float: - """单笔成交的报价币成交额(USDT 口径)。""" - info = trade.get("info") or {} - if not isinstance(info, dict): - info = {} - for key in ("quoteQty", "quote_qty", "fillNotionalUsd", "notional"): - try: - v = float(info.get(key) or 0) - if v > 0: - return abs(v) - except (TypeError, ValueError): - continue - try: - cost = float(trade.get("cost") or 0) - if cost > 0: - return abs(cost) - except (TypeError, ValueError): - pass - try: - price = float(trade.get("price") or 0) - amount = float(trade.get("amount") or 0) * float(contract_size or 1.0) - if price > 0 and amount > 0: - return abs(price * amount) - except (TypeError, ValueError): - pass - return 0.0 - - -def commission_usdt_from_fill(trade: dict) -> float: - """单笔成交手续费(正数表示成本)。""" - fee = trade.get("fee") - if isinstance(fee, dict): - try: - cost = float(fee.get("cost") or 0) - except (TypeError, ValueError): - cost = 0.0 - if cost != 0: - cur = str(fee.get("currency") or "USDT").upper() - if cur in ("USDT", "USD", "BUSD", "USDC"): - return abs(cost) - return abs(cost) - info = trade.get("info") or {} - if isinstance(info, dict): - for key in ("fee", "commission", "fillFee"): - try: - v = float(info.get(key) or 0) - if v != 0: - return abs(v) - except (TypeError, ValueError): - continue - return 0.0 - - -def aggregate_bilateral_stats( - fills: list[dict], - *, - contract_size: float = 1.0, -) -> dict[str, float] | None: - """双边成交额 = 开+平所有相关 fill 的报价币成交额之和;手续费 = fill fee 之和。""" - if not fills: - return None - turnover = 0.0 - commission = 0.0 - for t in fills: - turnover += quote_turnover_usdt_from_fill(t, contract_size=contract_size) - commission += commission_usdt_from_fill(t) - if turnover <= 0 and commission <= 0: - return None - return { - "exchange_turnover_usdt": round(turnover, 4), - "exchange_commission_usdt": round(commission, 4), - } - - -def filter_position_lifecycle_fills( - trades: list[dict], - direction: str, - open_ms: int | None, - close_ms: int | None, - *, - hedge_mode: bool = False, - close_buffer_ms: int = 15 * 60 * 1000, -) -> list[dict]: - """ - 持仓生命周期内 fill:多=开买+平卖;空=开卖+平买。 - hedge_mode 时按 posSide 与 direction 过滤。 - """ - direction = (direction or "long").strip().lower() - open_side = "buy" if direction == "long" else "sell" - close_side = "sell" if direction == "long" else "buy" - allowed_sides = {open_side, close_side} - upper = int(close_ms) + int(close_buffer_ms) if close_ms else None - out: list[dict] = [] - for t in trades or []: - side = (t.get("side") or "").lower() - if side not in allowed_sides: - continue - ts = _coerce_ts_ms(t.get("timestamp")) - if ts is None: - continue - if open_ms and ts < int(open_ms) - 60_000: - continue - if upper and ts > upper: - continue - if hedge_mode: - info = t.get("info") or {} - if not isinstance(info, dict): - info = {} - pos_side = (info.get("posSide") or t.get("posSide") or "").lower() - if pos_side in ("long", "short") and pos_side != direction: - continue - out.append(t) - out.sort(key=lambda x: x.get("timestamp") or 0) - return out - - -def sum_binance_commission_income(entries: list[dict], trade_ids: set[str] | None) -> float | None: - """Binance income 流水中 COMMISSION 合计(负值取绝对值为成本)。""" - if not entries: - return None - total = 0.0 - found = False - for e in entries: - it = (e.get("incomeType") or e.get("income_type") or "").strip() - if it != "COMMISSION": - continue - if trade_ids: - tid = str(e.get("tradeId") or e.get("trade_id") or "").strip() - if tid and tid not in trade_ids: - continue - try: - total += float(e.get("income") or 0) - found = True - except (TypeError, ValueError): - continue - if not found: - return None - return round(abs(total), 4) - - -def trade_ids_from_fills(fills: list[dict]) -> set[str]: - out: set[str] = set() - for t in fills or []: - info = t.get("info") or {} - if not isinstance(info, dict): - info = {} - for key in ("id", "tradeId", "trade_id"): - raw = t.get(key) if key in t else info.get(key) - if raw is not None and str(raw).strip(): - out.add(str(raw).strip()) - break - return out - - -def merge_commission_prefer_income( - fill_commission: float, - income_commission: float | None, -) -> float: - if income_commission is not None and income_commission > 0: - return round(income_commission, 4) - return round(max(fill_commission, 0.0), 4) - - -def update_trade_record_stats_columns( - conn: Any, - trade_id: int, - turnover_usdt: float | None, - commission_usdt: float | None, -) -> None: - if turnover_usdt is None and commission_usdt is None: - return - conn.execute( - """ - UPDATE trade_records - SET exchange_turnover_usdt = COALESCE(?, exchange_turnover_usdt), - exchange_commission_usdt = COALESCE(?, exchange_commission_usdt) - WHERE id = ? - """, - (turnover_usdt, commission_usdt, int(trade_id)), - ) - - -def attach_exchange_stats_to_trade( - conn: Any, - trade_id: int, - *, - fetch_fills: Callable[[], list[dict]], - contract_size: float = 1.0, - income_commission: float | None = None, -) -> dict[str, float] | None: - """拉 fill 并写库;仅在新单平仓路径调用。""" - try: - fills = fetch_fills() or [] - except Exception: - fills = [] - stats = aggregate_bilateral_stats(fills, contract_size=contract_size) - if not stats and income_commission is None: - return None - turnover = stats.get("exchange_turnover_usdt") if stats else None - fill_comm = float(stats.get("exchange_commission_usdt") or 0) if stats else 0.0 - commission = merge_commission_prefer_income(fill_comm, income_commission) - update_trade_record_stats_columns( - conn, - trade_id, - turnover, - commission if commission > 0 else None, - ) - out = {} - if turnover is not None: - out["exchange_turnover_usdt"] = turnover - if commission > 0: - out["exchange_commission_usdt"] = commission - return out or None +"""平仓交易:交易所口径双边成交额与手续费(三所共用聚合逻辑)。""" +from __future__ import annotations + +from typing import Any, Callable, Optional + + +def _coerce_ts_ms(raw: Any) -> int | None: + if raw in (None, ""): + return None + try: + v = int(raw) + return v if v > 1_000_000_000_000 else v * 1000 + except (TypeError, ValueError): + return None + + +def quote_turnover_usdt_from_fill(trade: dict, *, contract_size: float = 1.0) -> float: + """单笔成交的报价币成交额(USDT 口径)。""" + info = trade.get("info") or {} + if not isinstance(info, dict): + info = {} + for key in ("quoteQty", "quote_qty", "fillNotionalUsd", "notional"): + try: + v = float(info.get(key) or 0) + if v > 0: + return abs(v) + except (TypeError, ValueError): + continue + try: + cost = float(trade.get("cost") or 0) + if cost > 0: + return abs(cost) + except (TypeError, ValueError): + pass + try: + price = float(trade.get("price") or 0) + amount = float(trade.get("amount") or 0) * float(contract_size or 1.0) + if price > 0 and amount > 0: + return abs(price * amount) + except (TypeError, ValueError): + pass + return 0.0 + + +def commission_usdt_from_fill(trade: dict) -> float: + """单笔成交手续费(正数表示成本)。""" + fee = trade.get("fee") + if isinstance(fee, dict): + try: + cost = float(fee.get("cost") or 0) + except (TypeError, ValueError): + cost = 0.0 + if cost != 0: + cur = str(fee.get("currency") or "USDT").upper() + if cur in ("USDT", "USD", "BUSD", "USDC"): + return abs(cost) + return abs(cost) + info = trade.get("info") or {} + if isinstance(info, dict): + for key in ("fee", "commission", "fillFee"): + try: + v = float(info.get(key) or 0) + if v != 0: + return abs(v) + except (TypeError, ValueError): + continue + return 0.0 + + +def aggregate_bilateral_stats( + fills: list[dict], + *, + contract_size: float = 1.0, +) -> dict[str, float] | None: + """双边成交额 = 开+平所有相关 fill 的报价币成交额之和;手续费 = fill fee 之和。""" + if not fills: + return None + turnover = 0.0 + commission = 0.0 + for t in fills: + turnover += quote_turnover_usdt_from_fill(t, contract_size=contract_size) + commission += commission_usdt_from_fill(t) + if turnover <= 0 and commission <= 0: + return None + return { + "exchange_turnover_usdt": round(turnover, 4), + "exchange_commission_usdt": round(commission, 4), + } + + +def filter_position_lifecycle_fills( + trades: list[dict], + direction: str, + open_ms: int | None, + close_ms: int | None, + *, + hedge_mode: bool = False, + close_buffer_ms: int = 15 * 60 * 1000, +) -> list[dict]: + """ + 持仓生命周期内 fill:多=开买+平卖;空=开卖+平买。 + hedge_mode 时按 posSide 与 direction 过滤。 + """ + direction = (direction or "long").strip().lower() + open_side = "buy" if direction == "long" else "sell" + close_side = "sell" if direction == "long" else "buy" + allowed_sides = {open_side, close_side} + upper = int(close_ms) + int(close_buffer_ms) if close_ms else None + out: list[dict] = [] + for t in trades or []: + side = (t.get("side") or "").lower() + if side not in allowed_sides: + continue + ts = _coerce_ts_ms(t.get("timestamp")) + if ts is None: + continue + if open_ms and ts < int(open_ms) - 60_000: + continue + if upper and ts > upper: + continue + if hedge_mode: + info = t.get("info") or {} + if not isinstance(info, dict): + info = {} + pos_side = (info.get("posSide") or t.get("posSide") or "").lower() + if pos_side in ("long", "short") and pos_side != direction: + continue + out.append(t) + out.sort(key=lambda x: x.get("timestamp") or 0) + return out + + +def sum_binance_commission_income(entries: list[dict], trade_ids: set[str] | None) -> float | None: + """Binance income 流水中 COMMISSION 合计(负值取绝对值为成本)。""" + if not entries: + return None + total = 0.0 + found = False + for e in entries: + it = (e.get("incomeType") or e.get("income_type") or "").strip() + if it != "COMMISSION": + continue + if trade_ids: + tid = str(e.get("tradeId") or e.get("trade_id") or "").strip() + if tid and tid not in trade_ids: + continue + try: + total += float(e.get("income") or 0) + found = True + except (TypeError, ValueError): + continue + if not found: + return None + return round(abs(total), 4) + + +def trade_ids_from_fills(fills: list[dict]) -> set[str]: + out: set[str] = set() + for t in fills or []: + info = t.get("info") or {} + if not isinstance(info, dict): + info = {} + for key in ("id", "tradeId", "trade_id"): + raw = t.get(key) if key in t else info.get(key) + if raw is not None and str(raw).strip(): + out.add(str(raw).strip()) + break + return out + + +def merge_commission_prefer_income( + fill_commission: float, + income_commission: float | None, +) -> float: + if income_commission is not None and income_commission > 0: + return round(income_commission, 4) + return round(max(fill_commission, 0.0), 4) + + +def update_trade_record_stats_columns( + conn: Any, + trade_id: int, + turnover_usdt: float | None, + commission_usdt: float | None, +) -> None: + if turnover_usdt is None and commission_usdt is None: + return + conn.execute( + """ + UPDATE trade_records + SET exchange_turnover_usdt = COALESCE(?, exchange_turnover_usdt), + exchange_commission_usdt = COALESCE(?, exchange_commission_usdt) + WHERE id = ? + """, + (turnover_usdt, commission_usdt, int(trade_id)), + ) + + +def attach_exchange_stats_to_trade( + conn: Any, + trade_id: int, + *, + fetch_fills: Callable[[], list[dict]], + contract_size: float = 1.0, + income_commission: float | None = None, +) -> dict[str, float] | None: + """拉 fill 并写库;仅在新单平仓路径调用。""" + try: + fills = fetch_fills() or [] + except Exception: + fills = [] + stats = aggregate_bilateral_stats(fills, contract_size=contract_size) + if not stats and income_commission is None: + return None + turnover = stats.get("exchange_turnover_usdt") if stats else None + fill_comm = float(stats.get("exchange_commission_usdt") or 0) if stats else 0.0 + commission = merge_commission_prefer_income(fill_comm, income_commission) + update_trade_record_stats_columns( + conn, + trade_id, + turnover, + commission if commission > 0 else None, + ) + out = {} + if turnover is not None: + out["exchange_turnover_usdt"] = turnover + if commission > 0: + out["exchange_commission_usdt"] = commission + return out or None diff --git a/manual_trading_hub/.env.example b/manual_trading_hub/.env.example index 18dc4a3..e0eacc0 100644 --- a/manual_trading_hub/.env.example +++ b/manual_trading_hub/.env.example @@ -9,7 +9,7 @@ HUB_HOST=0.0.0.0 HUB_PORT=5100 # 仅本机访问可改为 127.0.0.1,并设 HUB_TRUST_LAN=false -# 与四实例 .env 中 HUB_BRIDGE_TOKEN 相同的长随机串 +# 与三实例 .env 中 HUB_BRIDGE_TOKEN 相同的长随机串 # 中控 → 各 Flask:请求头 X-Hub-Token # 中控 → 各子代理:请求头 X-Control-Token(可与子代理 CONTROL_TOKEN 同值,hub 会用 HUB_BRIDGE_TOKEN 转发) # 中控「打开实例」SSO 链接也复用此令牌签名(默认 2 小时内有效、单次使用) @@ -41,10 +41,10 @@ HUB_PASSWORD=admin123 # 限制可嵌入的父页来源(逗号分隔);默认 * 不限制 # HUB_EMBED_ORIGINS=http://192.168.8.6:5070,https://hub.example.com -# 四实例允许被中控 iframe 内嵌(各 crypto_monitor_*/.env,与 hub 同步部署) +# 三实例允许被中控 iframe 内嵌(各 crypto_monitor_*/.env,与 hub 同步部署) # APP_ALLOW_HUB_EMBED=true # HUB_EMBED_PARENT_ORIGINS=https://hub.example.com -# HTTPS 跨子域 iframe 时四实例还须 APP_COOKIE_SECURE=true(见 crypto_monitor_*/.env.example) +# HTTPS 跨子域 iframe 时三实例还须 APP_COOKIE_SECURE=true(见 crypto_monitor_*/.env.example) # 浏览器打开的实例/复盘链接(hub_settings 里 flask_url 为 127.0.0.1 时替换为对外地址) # 局域网:填内网 IP,见《局域网与反代部署说明.md》 @@ -53,7 +53,7 @@ HUB_PASSWORD=admin123 # HUB_PUBLIC_HOST=192.168.1.100 # HUB_PUBLIC_SCHEME=http -# 四实例网页登录(直链反代/IP:端口 访问时输入;中控点「打开实例」免输) +# 三实例网页登录(直链反代/IP:端口 访问时输入;中控点「打开实例」免输) # 各 crypto_monitor_*/.env 统一:APP_USERNAME=... APP_PASSWORD=... # 监控区:hub 后台每 N 秒聚合一次,浏览器经 SSE 收版本号再拉快照(默认 5 秒) @@ -82,7 +82,7 @@ HUB_PASSWORD=admin123 # HOST=127.0.0.1 # ---------- 中控 AI 教练(/ai,模块 hub_ai/,存 hub_ai_*.json)---------- -# 与四实例相同变量名;默认 OpenAI 兼容网关(改 AI_PROVIDER=ollama 可走本机 Ollama) +# 与三实例相同变量名;默认 OpenAI 兼容网关(改 AI_PROVIDER=ollama 可走本机 Ollama) # 详见 manual_trading_hub/AI教练说明.md 与仓库根 AI复盘与模型配置说明.md AI_TIMEOUT_SECONDS=120 # AI 教练聊天(默认:输出 8192 token、续写 4 次、快照约 2 万字符、历史单条 1500 字) @@ -102,7 +102,7 @@ OPENAI_MODEL=gemma4:e4b OLLAMA_API=http://127.0.0.1:11434/api/generate AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest -# 交易日切分(与四实例 TRADING_DAY_RESET_HOUR 一致,定义「今日总结」的日期) +# 交易日切分(与三实例 TRADING_DAY_RESET_HOUR 一致,定义「今日总结」的日期) TRADING_DAY_RESET_HOUR=8 # 资金概况 / AI 上下文:分户资金快照保留交易日数(默认 180) # HUB_FUND_HISTORY_DAYS=180 diff --git a/manual_trading_hub/AI教练说明.md b/manual_trading_hub/AI教练说明.md index bf8ce13..7dc7975 100644 --- a/manual_trading_hub/AI教练说明.md +++ b/manual_trading_hub/AI教练说明.md @@ -1,66 +1,66 @@ -# 中控 AI 教练说明 - -中控 **AI 教练**(`/ai`)与四实例 `/records` 里的 **AI 复盘** 分离:模块在 `manual_trading_hub/hub_ai/`,数据存同目录 JSON。 - -## 能力 - -| 功能 | 说明 | -|------|------| -| **交易教练** | 口语化陪聊;注入四户监控快照与今日总结摘要(后台自动生成,不在页面展示) | -| **普通聊天** | 不绑交易数据,适合闲聊、答疑 | -| **交易监管** | 今日长会话;手动/中控开平仓与新开仓自动推送 + 企业微信 + 可回聊(见 [交易监管说明.md](./交易监管说明.md)) | -| **会话历史** | 右侧列表:切换、删除;消息一键复制 | - -页面保留 **交易教练 / 普通聊天 / 交易监管** 与聊天区;**今日总结** 已移至 **数据看板**(`/dashboard`)纯数据展示,不再在 AI 页生成。 - -## 存储 - -与 `hub_settings.json` 同目录(`manual_trading_hub/`): - -- `hub_ai_summaries.json` — 历史总结(供交易教练上下文,可选 API 仍保留) -- `hub_ai_chat.json` — 聊天会话(`active_session_id`、多会话、`bot_mode`) - -升级 / 迁移时请一并备份(见 [本地数据迁移到云端.md](./本地数据迁移到云端.md))。 - -## 模型配置 - -在 **`manual_trading_hub/.env`** 配置,**变量名与四实例完全相同**;中控 `hub_ai/client.py` 共用仓库根 `ai_client.py`,**默认也是 OpenAI 兼容网关**(`AI_PROVIDER=openai`),与你在四所 `.env` 里配的那套一致即可。 - -**推荐(与四实例默认一致):** - -```env -AI_PROVIDER=openai -OPENAI_API_BASE=https://op.bz121.com/v1 -OPENAI_API_KEY=你的密钥 -OPENAI_MODEL=gemma4:e4b - -# 本机 Ollama 备用(仅当 AI_PROVIDER=ollama 时生效) -OLLAMA_API=http://127.0.0.1:11434/api/generate -AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest -``` - -改走本机无限制模型时,将 `AI_PROVIDER=ollama`,并填好 `OLLAMA_API` / `AI_MODEL`;`OPENAI_*` 可保留不动。 - -总结与聊天使用**同一模型**(同一套 `OPENAI_MODEL` 或 `AI_MODEL`);总结 temperature≈0.15,聊天≈0.5。 - -可选:`TRADING_DAY_RESET_HOUR=8`(与实例一致,定义「今日」交易日)。 - -## 依赖接口 - -中控通过 HTTP 拉取各实例: - -- `GET /api/hub/monitor`(已有) -- `GET /api/hub/trades/today?trading_day=YYYY-MM-DD`(`hub_bridge` 注册,需四实例更新代码并重启) - -子代理 `GET /status` 提供持仓与余额。 - -## 与实例 AI 复盘的分工 - -| | 中控 AI 教练 | 实例 AI 复盘 | -|--|-------------|-------------| -| 入口 | `/ai` | 各所 `/records` | -| 数据 | 四户聚合 | 单户 `journal_entries` | -| 语气 | 聊天搭档 | 结构化教练报告 | -| 代码 | `hub_ai/*` | `ai_review_lib` + 各 `app.py` | - -详见仓库根 [AI复盘与模型配置说明.md](../AI复盘与模型配置说明.md)(实例侧)。 +# 中控 AI 教练说明 + +中控 **AI 教练**(`/ai`)与三实例 `/records` 里的 **AI 复盘** 分离:模块在 `manual_trading_hub/hub_ai/`,数据存同目录 JSON。 + +## 能力 + +| 功能 | 说明 | +|------|------| +| **交易教练** | 口语化陪聊;注入三户监控快照与今日总结摘要(后台自动生成,不在页面展示) | +| **普通聊天** | 不绑交易数据,适合闲聊、答疑 | +| **交易监管** | 今日长会话;手动/中控开平仓与新开仓自动推送 + 企业微信 + 可回聊(见 [交易监管说明.md](./交易监管说明.md)) | +| **会话历史** | 右侧列表:切换、删除;消息一键复制 | + +页面保留 **交易教练 / 普通聊天 / 交易监管** 与聊天区;**今日总结** 已移至 **数据看板**(`/dashboard`)纯数据展示,不再在 AI 页生成。 + +## 存储 + +与 `hub_settings.json` 同目录(`manual_trading_hub/`): + +- `hub_ai_summaries.json` — 历史总结(供交易教练上下文,可选 API 仍保留) +- `hub_ai_chat.json` — 聊天会话(`active_session_id`、多会话、`bot_mode`) + +升级 / 迁移时请一并备份(见 [本地数据迁移到云端.md](./本地数据迁移到云端.md))。 + +## 模型配置 + +在 **`manual_trading_hub/.env`** 配置,**变量名与三实例完全相同**;中控 `hub_ai/client.py` 共用仓库根 `ai_client.py`,**默认也是 OpenAI 兼容网关**(`AI_PROVIDER=openai`),与你在三所 `.env` 里配的那套一致即可。 + +**推荐(与三实例默认一致):** + +```env +AI_PROVIDER=openai +OPENAI_API_BASE=https://op.bz121.com/v1 +OPENAI_API_KEY=你的密钥 +OPENAI_MODEL=gemma4:e4b + +# 本机 Ollama 备用(仅当 AI_PROVIDER=ollama 时生效) +OLLAMA_API=http://127.0.0.1:11434/api/generate +AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest +``` + +改走本机无限制模型时,将 `AI_PROVIDER=ollama`,并填好 `OLLAMA_API` / `AI_MODEL`;`OPENAI_*` 可保留不动。 + +总结与聊天使用**同一模型**(同一套 `OPENAI_MODEL` 或 `AI_MODEL`);总结 temperature≈0.15,聊天≈0.5。 + +可选:`TRADING_DAY_RESET_HOUR=8`(与实例一致,定义「今日」交易日)。 + +## 依赖接口 + +中控通过 HTTP 拉取各实例: + +- `GET /api/hub/monitor`(已有) +- `GET /api/hub/trades/today?trading_day=YYYY-MM-DD`(`hub_bridge` 注册,需三实例更新代码并重启) + +子代理 `GET /status` 提供持仓与余额。 + +## 与实例 AI 复盘的分工 + +| | 中控 AI 教练 | 实例 AI 复盘 | +|--|-------------|-------------| +| 入口 | `/ai` | 各所 `/records` | +| 数据 | 三户聚合 | 单户 `journal_entries` | +| 语气 | 聊天搭档 | 结构化教练报告 | +| 代码 | `hub_ai/*` | `ai_review_lib` + 各 `app.py` | + +详见仓库根 [AI复盘与模型配置说明.md](../AI复盘与模型配置说明.md)(实例侧)。 diff --git a/manual_trading_hub/README.md b/manual_trading_hub/README.md index dd843c7..8d3d1fe 100644 --- a/manual_trading_hub/README.md +++ b/manual_trading_hub/README.md @@ -1,106 +1,105 @@ -# 复盘系统中控(manual_trading_hub) - -> **完整说明**:[使用说明.md](./使用说明.md) · **资金概况**:[资金概况说明.md](./资金概况说明.md) · **数据看板**:[数据看板说明.md](./数据看板说明.md) · **AI 教练**:[AI教练说明.md](./AI教练说明.md) · **行情区**:[行情区说明.md](./行情区说明.md) · **部署**:[部署文档.md](./部署文档.md) · **云服务器**:[云服务器部署说明.md](./云服务器部署说明.md) · **本地→云端迁移**:[本地数据迁移到云端.md](./本地数据迁移到云端.md) · **局域网/反代**:[局域网与反代部署说明.md](./局域网与反代部署说明.md) · **故障**:[常见问题.md](./常见问题.md) - -多账户 **监控聚合 + 紧急全平**;**不在中控网页下单**。人工下单、关键位、**策略交易**(`/strategy`)、复盘请在各 `crypto_monitor_*` 实例网页操作(监控卡片 **「实例」** / **「复盘」**)。**增加子账户**见 [使用说明 §4.3](./使用说明.md#43-增加账户例如再挂一个-gate)。 - ---- - -## 当前能力 - -| 能力 | 说明 | -|------|------| -| 监控区 | 持仓、余额、关键位摘要、趋势计划、机器人单(只读) | -| 资金概况 | 总/分户资金(资金户+交易户)、180 日曲线、最大回撤 | -| **数据看板** | 四户当日总览/分户/平仓明细,SSE 推送(`/dashboard`;见 [数据看板说明.md](./数据看板说明.md)) | -| 行情区 | K 线(多周期、本地缓存、技术指标、从监控跳转持仓线) | -| **AI 教练** | 交易教练 + 普通聊天、会话历史(`/ai`;见 [AI教练说明.md](./AI教练说明.md)) | -| 紧急全平 | 单户 / 全局市价减仓 | -| 系统设置 | `hub_settings.json` 管理 URL、启用、**监控关键位 / 监控趋势计划**(不控制策略交易页) | -| Web 登录 | `.env` 设 `HUB_PASSWORD` 后用户名+密码保护(反代公网**务必**配置) | -| ~~下单区~~ | **已移除**(避免与实例重复、减少故障面) | - ---- - -## 架构 - -``` -浏览器 → hub.py (:5100) 监控 / 资金概况 / **数据看板** / 行情 / **AI 教练** / 设置 / 登录 - ├→ agent.py × N (:15200~15203) 持仓、全平 - └→ 各 Flask (:5000/5001/5002/5004) /api/hub/monitor 只读聚合 -``` - -- 账户列表:**系统设置** 或默认 `settings_store.py`(不再使用环境变量 `HUB_AGENTS`)。 -- 四实例须注册 **hub_bridge**(仓库根 `hub_bridge.py`);PM2 建议 `PYTHONPATH=..`。 - ---- - -## 快速启动(Linux / PM2) - -```bash -cd /opt/crypto_monitor/manual_trading_hub -python3 -m venv .venv && source .venv/bin/activate -pip install -r requirements.txt -cp .env.example .env -# 编辑 .env:HUB_PASSWORD、HUB_BRIDGE_TOKEN、HUB_PUBLIC_ORIGIN 等 - -pm2 start ecosystem.config.cjs # 4 agent + hub -pm2 save - -bash scripts/verify_hub_deploy.sh -curl -s http://127.0.0.1:5100/api/ping -``` - -浏览器:`http://<本机IP>:5100/monitor`(行情 `/market`;已设密码则先 `/login`)。 - ---- - -## 中控 `.env` 要点 - -| 变量 | 说明 | -|------|------| -| `HUB_PASSWORD` / `HUB_USERNAME` | 非空密码即启用登录 | -| `HUB_BRIDGE_TOKEN` | 与四实例一致 | -| `HUB_DISABLED_IDS` | 默认 `1` 关闭 OKX | -| `HUB_PUBLIC_ORIGIN` | 其它设备打开复盘/实例外链(替换 127.0.0.1) | -| `HUB_COOKIE_SECURE` | HTTPS 反代建议 `true` | - -详见 [.env.example](./.env.example)。 - ---- - -## 子代理(agent) - -每所策略目录单独进程,`EXCHANGE` + `PORT`(15200~15203),密钥来自**该目录 `.env`**。PM2 经 `scripts/run_agent.sh` 启动(自动 `source .env`、去 CRLF)。 - -| PORT | 目录 | -|------|------| -| 15200 | crypto_monitor_binance | -| 15201 | crypto_monitor_okx | -| 15202 | crypto_monitor_gate | -| 15203 | crypto_monitor_gate_bot | - ---- - -## 运维脚本 - -| 脚本 | 作用 | -|------|------| -| [scripts/fix_hub_deps.sh](./scripts/fix_hub_deps.sh) | 安装/更新 venv 依赖 | -| [scripts/verify_hub_deploy.sh](./scripts/verify_hub_deploy.sh) | 验收代码版本与 ping | -| [scripts/fix_env_crlf.sh](./scripts/fix_env_crlf.sh) | 修复 .env 的 Windows 换行 | -| [scripts/pm2_hub.sh](./scripts/pm2_hub.sh) | PM2 启停 hub+agent | -| [scripts/后台运行-Ubuntu.md](./scripts/后台运行-Ubuntu.md) | PM2 常驻 | -| [docs/ubuntu-server.md](../docs/ubuntu-server.md) | Ubuntu / Python / Node / PM2 | - ---- - -## 文档索引 - -| 文档 | 内容 | -|------|------| -| [使用说明.md](./使用说明.md) | 页面、API、环境变量、日常流程 | -| [行情区说明.md](./行情区说明.md) | K 线周期、缓存、快捷键、拉取逻辑 | -| [部署文档.md](./部署文档.md) | Ubuntu、PM2、反代、升级 | -| [常见问题.md](./常见问题.md) | 已遇到问题与处理 | -| [.env.example](./.env.example) | 环境变量模板 | +# 复盘系统中控(manual_trading_hub) + +> **完整说明**:[使用说明.md](./使用说明.md) · **资金概况**:[资金概况说明.md](./资金概况说明.md) · **数据看板**:[数据看板说明.md](./数据看板说明.md) · **AI 教练**:[AI教练说明.md](./AI教练说明.md) · **行情区**:[行情区说明.md](./行情区说明.md) · **部署**:[部署文档.md](./部署文档.md) · **云服务器**:[云服务器部署说明.md](./云服务器部署说明.md) · **本地→云端迁移**:[本地数据迁移到云端.md](./本地数据迁移到云端.md) · **局域网/反代**:[局域网与反代部署说明.md](./局域网与反代部署说明.md) · **故障**:[常见问题.md](./常见问题.md) + +多账户 **监控聚合 + 紧急全平**;**不在中控网页下单**。人工下单、关键位、**策略交易**(`/strategy`)、复盘请在各 `crypto_monitor_*` 实例网页操作(监控卡片 **「实例」** / **「复盘」**)。**增加子账户**见 [使用说明 §4.3](./使用说明.md#43-增加账户例如再挂一个-gate)。 + +--- + +## 当前能力 + +| 能力 | 说明 | +|------|------| +| 监控区 | 持仓、余额、关键位摘要、趋势计划、机器人单(只读) | +| 资金概况 | 总/分户资金(资金户+交易户)、180 日曲线、最大回撤 | +| **数据看板** | 三户当日总览/分户/平仓明细,SSE 推送(`/dashboard`;见 [数据看板说明.md](./数据看板说明.md)) | +| 行情区 | K 线(多周期、本地缓存、技术指标、从监控跳转持仓线) | +| **AI 教练** | 交易教练 + 普通聊天、会话历史(`/ai`;见 [AI教练说明.md](./AI教练说明.md)) | +| 紧急全平 | 单户 / 全局市价减仓 | +| 系统设置 | `hub_settings.json` 管理 URL、启用、**监控关键位 / 监控趋势计划**(不控制策略交易页) | +| Web 登录 | `.env` 设 `HUB_PASSWORD` 后用户名+密码保护(反代公网**务必**配置) | +| ~~下单区~~ | **已移除**(避免与实例重复、减少故障面) | + +--- + +## 架构 + +``` +浏览器 → hub.py (:5100) 监控 / 资金概况 / **数据看板** / 行情 / **AI 教练** / 设置 / 登录 + ├→ agent.py × N (:15200~15202) 持仓、全平 + └→ 各 Flask (:5000/5001/5004) /api/hub/monitor 只读聚合 +``` + +- 账户列表:**系统设置** 或默认 `settings_store.py`(不再使用环境变量 `HUB_AGENTS`)。 +- 三实例须注册 **hub_bridge**(仓库根 `hub_bridge.py`);PM2 建议 `PYTHONPATH=..`。 + +--- + +## 快速启动(Linux / PM2) + +```bash +cd /opt/crypto_monitor/manual_trading_hub +python3 -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env +# 编辑 .env:HUB_PASSWORD、HUB_BRIDGE_TOKEN、HUB_PUBLIC_ORIGIN 等 + +pm2 start ecosystem.config.cjs # 3 agent + hub +pm2 save + +bash scripts/verify_hub_deploy.sh +curl -s http://127.0.0.1:5100/api/ping +``` + +浏览器:`http://<本机IP>:5100/monitor`(行情 `/market`;已设密码则先 `/login`)。 + +--- + +## 中控 `.env` 要点 + +| 变量 | 说明 | +|------|------| +| `HUB_PASSWORD` / `HUB_USERNAME` | 非空密码即启用登录 | +| `HUB_BRIDGE_TOKEN` | 与三实例一致 | +| `HUB_DISABLED_IDS` | 默认 `1` 关闭 OKX | +| `HUB_PUBLIC_ORIGIN` | 其它设备打开复盘/实例外链(替换 127.0.0.1) | +| `HUB_COOKIE_SECURE` | HTTPS 反代建议 `true` | + +详见 [.env.example](./.env.example)。 + +--- + +## 子代理(agent) + +每所策略目录单独进程,`EXCHANGE` + `PORT`(15200~15202),密钥来自**该目录 `.env`**。PM2 经 `scripts/run_agent.sh` 启动(自动 `source .env`、去 CRLF)。 + +| PORT | 目录 | +|------|------| +| 15200 | crypto_monitor_binance | +| 15201 | crypto_monitor_okx | +| 15202 | crypto_monitor_gate | + +--- + +## 运维脚本 + +| 脚本 | 作用 | +|------|------| +| [scripts/fix_hub_deps.sh](./scripts/fix_hub_deps.sh) | 安装/更新 venv 依赖 | +| [scripts/verify_hub_deploy.sh](./scripts/verify_hub_deploy.sh) | 验收代码版本与 ping | +| [scripts/fix_env_crlf.sh](./scripts/fix_env_crlf.sh) | 修复 .env 的 Windows 换行 | +| [scripts/pm2_hub.sh](./scripts/pm2_hub.sh) | PM2 启停 hub+agent | +| [scripts/后台运行-Ubuntu.md](./scripts/后台运行-Ubuntu.md) | PM2 常驻 | +| [docs/ubuntu-server.md](../docs/ubuntu-server.md) | Ubuntu / Python / Node / PM2 | + +--- + +## 文档索引 + +| 文档 | 内容 | +|------|------| +| [使用说明.md](./使用说明.md) | 页面、API、环境变量、日常流程 | +| [行情区说明.md](./行情区说明.md) | K 线周期、缓存、快捷键、拉取逻辑 | +| [部署文档.md](./部署文档.md) | Ubuntu、PM2、反代、升级 | +| [常见问题.md](./常见问题.md) | 已遇到问题与处理 | +| [.env.example](./.env.example) | 环境变量模板 | diff --git a/manual_trading_hub/SNAPSHOT_ROLLBACK.md b/manual_trading_hub/SNAPSHOT_ROLLBACK.md index 73a1360..f8d90ee 100644 --- a/manual_trading_hub/SNAPSHOT_ROLLBACK.md +++ b/manual_trading_hub/SNAPSHOT_ROLLBACK.md @@ -1,22 +1,22 @@ -# 更新前快照(行情区 + K 线库) - -> 行情区使用说明见 [行情区说明.md](./行情区说明.md)。 - -更新前已打 Git 标签,回滚方式: - -```bash -cd /opt/crypto_monitor # 或你的仓库路径 -git fetch --tags -git checkout snapshot/pre-hub-market-20260528 -# 恢复后重启: -pm2 restart manual-trading-hub crypto_okx crypto_binance crypto_gate crypto_gate_bot -``` - -回到最新主线: - -```bash -git checkout main -git pull -``` - -K 线数据库(不纳入 Git):`manual_trading_hub/data/hub_kline.db`,回滚代码不会自动删除该文件。 +# 更新前快照(行情区 + K 线库) + +> 行情区使用说明见 [行情区说明.md](./行情区说明.md)。 + +更新前已打 Git 标签,回滚方式: + +```bash +cd /opt/crypto_monitor # 或你的仓库路径 +git fetch --tags +git checkout snapshot/pre-hub-market-20260528 +# 恢复后重启: +pm2 restart manual-trading-hub crypto_okx crypto_binance crypto_gate +``` + +回到最新主线: + +```bash +git checkout main +git pull +``` + +K 线数据库(不纳入 Git):`manual_trading_hub/data/hub_kline.db`,回滚代码不会自动删除该文件。 diff --git a/manual_trading_hub/agent.py b/manual_trading_hub/agent.py index 1a2b8e2..ce620b9 100644 --- a/manual_trading_hub/agent.py +++ b/manual_trading_hub/agent.py @@ -1,14 +1,14 @@ """ 子账户极轻代理:GET /status、挂单/条件单查询与撤销、POST /emergency/close-all、POST /emergency/close-position,仅监听 127.0.0.1。 -与仓库内四个策略/监控目录一一对应时,典型用法(各目录自己的 .env 里已有密钥;子代理用环境变量 PORT,勿与 Flask 的 APP_PORT 相同): +与仓库内三个策略/监控目录一一对应时,典型用法(各目录自己的 .env 里已有密钥;子代理用环境变量 PORT,勿与 Flask 的 APP_PORT 相同): EXCHANGE=binance → crypto_monitor_binance(BINANCE_*) EXCHANGE=okx → crypto_monitor_okx(OKX_*) - EXCHANGE=gate → crypto_monitor_gate / crypto_monitor_gate_bot(GATE_*) + EXCHANGE=gate → crypto_monitor_gate(GATE_*) 环境变量: EXCHANGE binance(默认)| okx | gate - PORT 默认 15200(与 crypto_monitor_* 的 Flask APP_PORT 错开;中控默认聚合 15200–15203) + PORT 默认 15200(与 crypto_monitor_* 的 Flask APP_PORT 错开;中控默认聚合 15200–15202) HOST 默认 127.0.0.1 CONTROL_TOKEN 可选;请求头 X-Control-Token @@ -392,7 +392,7 @@ def _position_price_fmt(ex: Any, symbol: str, price: float | None) -> tuple[floa def _position_entry_price(p: dict[str, Any]) -> float | None: - """四所 ccxt 持仓统一解析开仓均价(Binance/OKX/Gate 字段名不一致)。""" + """三所 ccxt 持仓统一解析开仓均价(Binance/OKX/Gate 字段名不一致)。""" return parse_position_entry_price(p) @@ -406,7 +406,7 @@ def _position_contract_size(ex: Any, symbol: str) -> float: def _position_mark_price(p: dict[str, Any]) -> float | None: - """四所 ccxt 持仓统一解析标记价(与实例 parse_ccxt_position_metrics 一致)。""" + """三所 ccxt 持仓统一解析标记价(与实例 parse_ccxt_position_metrics 一致)。""" return parse_position_mark_price(p) @@ -740,7 +740,7 @@ def place_tpsl_orders( body: PlaceTpslBody, x_control_token: str | None = Header(default=None, alias="X-Control-Token"), ): - """先撤该合约全部条件单,再挂止盈+止损(与四实例策略逻辑一致)。""" + """先撤该合约全部条件单,再挂止盈+止损(与三实例策略逻辑一致)。""" _check_token(x_control_token) sym = (body.symbol or "").strip() side = (body.side or "").strip().lower() diff --git a/manual_trading_hub/ecosystem.config.cjs b/manual_trading_hub/ecosystem.config.cjs index 32bee98..7cf9105 100644 --- a/manual_trading_hub/ecosystem.config.cjs +++ b/manual_trading_hub/ecosystem.config.cjs @@ -1,5 +1,5 @@ /** - * PM2:中控 hub + 四路子代理 agent(一次启动全部) + * PM2:中控 hub + 三路子代理 agent(一次启动全部) * * 前置: * cd manual_trading_hub @@ -49,7 +49,6 @@ module.exports = { agentApp("manual-agent-binance", "crypto_monitor_binance", "binance", 15200), agentApp("manual-agent-okx", "crypto_monitor_okx", "okx", 15201), agentApp("manual-agent-gate", "crypto_monitor_gate", "gate", 15202), - agentApp("manual-agent-gate-bot", "crypto_monitor_gate_bot", "gate", 15203), { name: "manual-trading-hub", cwd: HUB_DIR, diff --git a/manual_trading_hub/exchange_orders.py b/manual_trading_hub/exchange_orders.py index 9b702e7..7e9ecae 100644 --- a/manual_trading_hub/exchange_orders.py +++ b/manual_trading_hub/exchange_orders.py @@ -805,7 +805,7 @@ def replace_position_tpsl( take_profit: float, ) -> dict[str, Any]: """ - 先撤销该合约全部条件单,再挂止盈+止损。与四实例策略页逻辑对齐(读各目录 .env 中 GATE_/BINANCE_/OKX_ 参数)。 + 先撤销该合约全部条件单,再挂止盈+止损。与三实例策略页逻辑对齐(读各目录 .env 中 GATE_/BINANCE_/OKX_ 参数)。 """ kind = (exchange_kind or "binance").lower() direction = (direction or "long").strip().lower() diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index 38620f2..377a44d 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -353,7 +353,7 @@ async def _run_archive_sync_once() -> dict: if st == 404: msg = ( "HTTP 404:该 Flask 未注册 /api/hub/trades/archive。" - "请在仓库根目录 git pull 后 pm2 restart crypto_gate crypto_gate_bot" + "请在仓库根目录 git pull 后 pm2 restart crypto_gate" ) results.append( { @@ -619,7 +619,7 @@ _ACCOUNT_RISK_BADGE_JS = _REPO_STATIC / "account_risk_badge.js" @app.get("/assets/account_risk_badge.css") def hub_account_risk_badge_css(): - """与四所实例共用仓库根 static/account_risk_badge.css。""" + """与三所实例共用仓库根 static/account_risk_badge.css。""" if not _ACCOUNT_RISK_BADGE_CSS.is_file(): raise HTTPException(status_code=404, detail="account_risk_badge.css not found") return FileResponse( @@ -630,7 +630,7 @@ def hub_account_risk_badge_css(): @app.get("/assets/account_risk_badge.js") def hub_account_risk_badge_js(): - """与四所实例共用仓库根 static/account_risk_badge.js。""" + """与三所实例共用仓库根 static/account_risk_badge.js。""" if not _ACCOUNT_RISK_BADGE_JS.is_file(): raise HTTPException(status_code=404, detail="account_risk_badge.js not found") return FileResponse( @@ -641,7 +641,7 @@ def hub_account_risk_badge_js(): @app.get("/assets/ai_review_render.js") def hub_ai_review_render_js(): - """与四所实例共用仓库根 static/ai_review_render.js(须在 /assets mount 之前注册)。""" + """与三所实例共用仓库根 static/ai_review_render.js(须在 /assets mount 之前注册)。""" if not _AI_REVIEW_RENDER_JS.is_file(): raise HTTPException(status_code=404, detail="ai_review_render.js not found") return FileResponse( @@ -1542,7 +1542,7 @@ def _flask_error_from_hub_mon(hub_mon: dict | None) -> str | None: if st == 404: return ( "HTTP 404:该 Flask 未注册 /api/hub/*(hub_bridge 未加载)。" - "请在仓库根目录 git pull 后 pm2 restart crypto_binance crypto_gate crypto_gate_bot," + "请在仓库根目录 git pull 后 pm2 restart crypto_binance crypto_gate," "并查看启动日志是否含 [hub_bridge] ImportError" ) return ( @@ -2348,7 +2348,7 @@ async def api_close_position(exchange_id: str, body: ClosePositionBody): if out.get("ok"): ex_key = (ex.get("key") or "").strip().lower() async with httpx.AsyncClient() as flask_client: - if ex_key in ("gate", "gate_bot"): + if ex_key == "gate": order_sync = await _fetch_flask_json( flask_client, ex, diff --git a/manual_trading_hub/hub_ai/context.py b/manual_trading_hub/hub_ai/context.py index 35a02e5..701da67 100644 --- a/manual_trading_hub/hub_ai/context.py +++ b/manual_trading_hub/hub_ai/context.py @@ -1,4 +1,4 @@ -"""中控 AI:四户数据聚合为结构化上下文。""" +"""中控 AI:三户数据聚合为结构化上下文。""" from __future__ import annotations import hashlib diff --git a/manual_trading_hub/hub_dashboard.py b/manual_trading_hub/hub_dashboard.py index 0b5c940..7ed615b 100644 --- a/manual_trading_hub/hub_dashboard.py +++ b/manual_trading_hub/hub_dashboard.py @@ -1,4 +1,4 @@ -"""中控数据看板:四户当日总览(无 AI,纯数据聚合)。""" +"""中控数据看板:三户当日总览(无 AI,纯数据聚合)。""" from __future__ import annotations from datetime import datetime, timezone diff --git a/manual_trading_hub/scripts/check_agents.sh b/manual_trading_hub/scripts/check_agents.sh index b3cf210..86da157 100644 --- a/manual_trading_hub/scripts/check_agents.sh +++ b/manual_trading_hub/scripts/check_agents.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# 检查四路子代理端口与 /status(在服务器上运行) +# 检查三路子代理端口与 /status(在服务器上运行) set -e check_one() { @@ -29,7 +29,6 @@ check_one() { check_one "binance" 15200 check_one "okx" 15201 check_one "gate" 15202 -check_one "gate_bot" 15203 echo "PM2 状态:" pm2 status 2>/dev/null | grep -E 'manual-agent|manual-trading' || true diff --git a/manual_trading_hub/scripts/fix_env_crlf.sh b/manual_trading_hub/scripts/fix_env_crlf.sh index 91796f3..b90490e 100644 --- a/manual_trading_hub/scripts/fix_env_crlf.sh +++ b/manual_trading_hub/scripts/fix_env_crlf.sh @@ -10,7 +10,6 @@ dirs=( "${REPO}/crypto_monitor_binance" "${REPO}/crypto_monitor_okx" "${REPO}/crypto_monitor_gate" - "${REPO}/crypto_monitor_gate_bot" ) fixed=0 @@ -30,5 +29,5 @@ for d in "${dirs[@]}"; do done echo "完成,共修复 ${fixed} 个 .env。" -echo "请重启子代理: cd ${REPO}/manual_trading_hub && pm2 restart manual-agent-gate manual-agent-gate-bot manual-agent-binance manual-agent-okx" +echo "请重启子代理: cd ${REPO}/manual_trading_hub && pm2 restart manual-agent-gate manual-agent-binance manual-agent-okx" echo "或: bash scripts/pm2_hub.sh restart" diff --git a/manual_trading_hub/scripts/pm2_agents.sh b/manual_trading_hub/scripts/pm2_agents.sh index 9fd38c7..ac43ab3 100644 --- a/manual_trading_hub/scripts/pm2_agents.sh +++ b/manual_trading_hub/scripts/pm2_agents.sh @@ -12,7 +12,7 @@ usage() { 一般请用: bash scripts/pm2_hub.sh start (hub + agent 一起) - 本脚本仅操作 4 路子代理(不含中控) + 本脚本仅操作 3 路子代理(不含中控) 仅启动币安: pm2 start ecosystem.agents.config.cjs --only manual-agent-binance EOF @@ -33,20 +33,20 @@ case "${cmd}" in pm2 save 2>/dev/null || true ;; stop) - pm2 stop manual-agent-binance manual-agent-okx manual-agent-gate manual-agent-gate-bot 2>/dev/null || true + pm2 stop manual-agent-binance manual-agent-okx manual-agent-gate 2>/dev/null || true ;; restart) - pm2 restart manual-agent-binance manual-agent-okx manual-agent-gate manual-agent-gate-bot 2>/dev/null \ + pm2 restart manual-agent-binance manual-agent-okx manual-agent-gate 2>/dev/null \ || pm2 start "${ECO}" ;; status) pm2 status ;; logs) - pm2 logs manual-agent-binance manual-agent-okx manual-agent-gate manual-agent-gate-bot --lines 100 + pm2 logs manual-agent-binance manual-agent-okx manual-agent-gate --lines 100 ;; delete) - pm2 delete manual-agent-binance manual-agent-okx manual-agent-gate manual-agent-gate-bot 2>/dev/null || true + pm2 delete manual-agent-binance manual-agent-okx manual-agent-gate 2>/dev/null || true ;; *) usage diff --git a/manual_trading_hub/scripts/pm2_hub.sh b/manual_trading_hub/scripts/pm2_hub.sh index e90529f..d826d21 100644 --- a/manual_trading_hub/scripts/pm2_hub.sh +++ b/manual_trading_hub/scripts/pm2_hub.sh @@ -11,7 +11,6 @@ PM2_NAMES=( manual-agent-binance manual-agent-okx manual-agent-gate - manual-agent-gate-bot manual-trading-hub ) @@ -19,7 +18,7 @@ usage() { cat <<'EOF' 用法: bash scripts/pm2_hub.sh - start 启动 ecosystem.config.cjs(4 路子代理 + 中控,已存在则 restart 全部) + start 启动 ecosystem.config.cjs(3 路子代理 + 中控,已存在则 restart 全部) stop 停止全部 restart 重启全部 status pm2 status diff --git a/manual_trading_hub/scripts/pm2_restart_agents.sh b/manual_trading_hub/scripts/pm2_restart_agents.sh index 52d09da..14d457e 100644 --- a/manual_trading_hub/scripts/pm2_restart_agents.sh +++ b/manual_trading_hub/scripts/pm2_restart_agents.sh @@ -9,18 +9,15 @@ ECO="${HUB_DIR}/ecosystem.config.cjs" cd "${HUB_DIR}" chmod +x scripts/run_agent.sh scripts/run_hub.sh 2>/dev/null || true -AGENTS=(manual-agent-binance manual-agent-okx manual-agent-gate manual-agent-gate-bot) +AGENTS=(manual-agent-binance manual-agent-okx manual-agent-gate) for n in "${AGENTS[@]}"; do pm2 delete "${n}" 2>/dev/null || true done pm2 start "${ECO}" --only manual-agent-binance +pm2 start "${ECO}" --only manual-agent-okx pm2 start "${ECO}" --only manual-agent-gate -pm2 start "${ECO}" --only manual-agent-gate-bot - -# OKX 若已 online 可跳过;若也挂了则: -# pm2 start "${ECO}" --only manual-agent-okx pm2 save 2>/dev/null || true -echo "已重建 binance / gate / gate-bot 子代理,请执行: bash scripts/check_agents.sh" +echo "已重建 binance / okx / gate 子代理,请执行: bash scripts/check_agents.sh" diff --git a/manual_trading_hub/scripts/后台运行-Ubuntu.md b/manual_trading_hub/scripts/后台运行-Ubuntu.md index 368c73e..4e77ab4 100644 --- a/manual_trading_hub/scripts/后台运行-Ubuntu.md +++ b/manual_trading_hub/scripts/后台运行-Ubuntu.md @@ -39,4 +39,4 @@ bash scripts/verify_hub_deploy.sh | 文档 | 内容 | |------|------| | [../部署文档.md](../部署文档.md) | 端口、反代、故障排查 | -| [../../docs/ubuntu-server.md](../../docs/ubuntu-server.md) | Python / Node / PM2 版本与四所启动顺序 | +| [../../docs/ubuntu-server.md](../../docs/ubuntu-server.md) | Python / Node / PM2 版本与三所启动顺序 | diff --git a/manual_trading_hub/settings_store.py b/manual_trading_hub/settings_store.py index fa728be..39c3c10 100644 --- a/manual_trading_hub/settings_store.py +++ b/manual_trading_hub/settings_store.py @@ -54,23 +54,13 @@ DEFAULT_EXCHANGES = [ { "id": "2", "key": "gate", - "name": "Gate训练 · crypto_monitor_gate", + "name": "Gate · crypto_monitor_gate", "agent_url": "http://127.0.0.1:15202", "flask_url": "http://127.0.0.1:5000", "review_url": "http://127.0.0.1:5000/records", "enabled": True, "capabilities": ["key", "trend"], }, - { - "id": "3", - "key": "gate_bot", - "name": "Gate趋势 · crypto_monitor_gate_bot", - "agent_url": "http://127.0.0.1:15203", - "flask_url": "http://127.0.0.1:5002", - "review_url": "http://127.0.0.1:5002/records", - "enabled": True, - "capabilities": ["key", "trend"], - }, ] diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index 996bf87..399f720 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -1,7395 +1,7395 @@ -:root, -html[data-theme="dark"] { - --bg: #050810; - --bg-elevated: #0a1018; - --panel: rgba(12, 20, 32, 0.82); - --panel-hover: rgba(18, 28, 44, 0.9); - --panel-solid: #141a2a; - --panel-solid-border: #2a3150; - --nav-bg: rgba(0, 0, 0, 0.35); - --overlay: rgba(0, 0, 0, 0.45); - --chart-surface: #0a1018; - --chart-bar-bg: rgba(8, 14, 24, 0.96); - --inset-surface: rgba(0, 0, 0, 0.32); - --inset-surface-strong: rgba(0, 0, 0, 0.42); - --section-surface: rgba(0, 0, 0, 0.22); - --pos-card-bg: rgba(10, 16, 28, 0.95); - --fs-scrim: rgba(2, 6, 12, 0.92); - --btn-surface: rgba(0, 0, 0, 0.4); - --text: #e8f4ff; - --muted: #6b8aa8; - --border: rgba(0, 212, 255, 0.22); - --border-soft: rgba(0, 212, 255, 0.1); - --green: #00ff9d; - --red: #ff4d6d; - --accent: #00d4ff; - --accent-2: #7b61ff; - --accent-dim: rgba(0, 212, 255, 0.12); - --glow: 0 0 24px rgba(0, 212, 255, 0.15); - --radius: 10px; - --shadow: 0 8px 32px rgba(0, 0, 0, 0.45); - --plan-title: #f0f2ff; - --plan-meta: #8892b0; - --plan-meta-accent: #6ab8ff; - --plan-lbl: #8b95b8; - --plan-val: #f0f2ff; - --plan-val-neutral: #cfd3ef; - --plan-border-dash: #2a3558; - --plan-col-divider: #243050; - --plan-dca-th: #6a7598; - --plan-close-bg: #5c1e2a; - --plan-close-fg: #ffb4b4; - --plan-be-label: #cfd3ef; - --plan-be-input-bg: #0f1424; - --plan-be-input-border: #304164; - --plan-be-btn-bg: #1f4a3a; - --primary-btn-bg: linear-gradient(135deg, rgba(0, 212, 255, 0.38), rgba(123, 97, 255, 0.28)); - --primary-btn-fg: #ffffff; - --primary-btn-border: var(--accent); - --status-done: #4cd97f; - --status-pending: #9aa3c4; - --ai-sum-heading: #9adbff; - --ai-sum-heading-bg: rgba(0, 212, 255, 0.07); - --ai-sum-heading-border: rgba(0, 212, 255, 0.38); - --ai-sum-name: #d4ecff; - --font: "JetBrains Mono", ui-monospace, Consolas, monospace; - --display: "Orbitron", var(--font); - --mono: var(--font); - --layout-max: 1520px; - color-scheme: dark; -} - -html[data-theme="light"] { - --bg: #d4dde8; - --bg-elevated: #f6f9fc; - --panel: rgba(255, 255, 255, 0.94); - --panel-hover: rgba(248, 252, 255, 0.98); - --panel-solid: #ffffff; - --panel-solid-border: #b8c8d8; - --nav-bg: rgba(255, 255, 255, 0.92); - --overlay: rgba(15, 35, 60, 0.28); - --chart-surface: #f0f4f9; - --chart-bar-bg: #e8eef5; - --inset-surface: rgba(255, 255, 255, 0.9); - --inset-surface-strong: #eef3f8; - --section-surface: rgba(255, 255, 255, 0.82); - --pos-card-bg: rgba(255, 255, 255, 0.96); - --fs-scrim: rgba(212, 221, 232, 0.94); - --btn-surface: rgba(255, 255, 255, 0.85); - --text: #142232; - --muted: #4a6078; - --border: rgba(0, 95, 140, 0.26); - --border-soft: rgba(0, 75, 115, 0.14); - --green: #0a8f5c; - --red: #c93552; - --accent: #006e9a; - --accent-2: #5b4fc7; - --accent-dim: rgba(0, 110, 154, 0.12); - --glow: 0 0 16px rgba(0, 110, 154, 0.1); - --shadow: 0 6px 24px rgba(30, 60, 100, 0.1); - --plan-title: var(--text); - --plan-meta: var(--muted); - --plan-meta-accent: var(--accent); - --plan-lbl: var(--muted); - --plan-val: var(--text); - --plan-val-neutral: #5a6f85; - --plan-border-dash: rgba(0, 75, 115, 0.2); - --plan-col-divider: rgba(0, 75, 115, 0.16); - --plan-dca-th: var(--muted); - --plan-close-bg: rgba(201, 53, 82, 0.12); - --plan-close-fg: var(--red); - --plan-be-label: var(--muted); - --plan-be-input-bg: var(--bg-elevated); - --plan-be-input-border: var(--border-soft); - --plan-be-btn-bg: rgba(10, 143, 92, 0.14); - --primary-btn-bg: #006e9a; - --primary-btn-fg: #ffffff; - --primary-btn-border: #005a82; - --status-done: #087a50; - --status-pending: #3d556d; - --ai-sum-heading: #9e1e38; - --ai-sum-heading-bg: rgba(201, 53, 82, 0.08); - --ai-sum-heading-border: rgba(201, 53, 82, 0.42); - --ai-sum-name: #7a182c; - color-scheme: light; -} - -* { - box-sizing: border-box; -} - -body { - font-family: var(--font); - background: var(--bg); - color: var(--text); - margin: 0; - font-size: 13px; - line-height: 1.55; - min-height: 100vh; -} - -a { - color: var(--accent); - text-decoration: none; -} -a:hover { - text-decoration: underline; - text-shadow: 0 0 12px rgba(0, 212, 255, 0.4); -} - -.app-bg, -.login-bg { - position: fixed; - inset: 0; - z-index: 0; - pointer-events: none; - background: - linear-gradient(rgba(0, 212, 255, 0.03) 1px, transparent 1px), - linear-gradient(90deg, rgba(0, 212, 255, 0.03) 1px, transparent 1px), - radial-gradient(ellipse 80% 50% at 50% -20%, rgba(0, 212, 255, 0.12), transparent), - radial-gradient(ellipse 60% 40% at 100% 100%, rgba(123, 97, 255, 0.08), transparent); - background-size: 48px 48px, 48px 48px, auto, auto; -} - -.app-bg::after, -.login-bg::after { - content: ""; - position: absolute; - inset: 0; - background: repeating-linear-gradient( - 0deg, - transparent, - transparent 2px, - rgba(0, 0, 0, 0.03) 2px, - rgba(0, 0, 0, 0.03) 4px - ); - opacity: 0.4; -} - -.app-shell { - position: relative; - z-index: 1; - width: 100%; - max-width: var(--layout-max); - margin-left: auto; - margin-right: auto; - padding: 0 24px 48px; -} - -.app-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - padding: 18px 0; - border-bottom: 1px solid var(--border-soft); - margin-bottom: 8px; - flex-wrap: wrap; -} - -.brand { - display: flex; - align-items: center; - gap: 12px; -} - -.brand-mark { - width: 12px; - height: 12px; - border-radius: 50%; - background: var(--accent); - box-shadow: 0 0 12px var(--accent), 0 0 24px rgba(0, 212, 255, 0.5); - animation: pulse-dot 2s ease-in-out infinite; -} - -@keyframes pulse-dot { - 0%, - 100% { - opacity: 1; - transform: scale(1); - } - 50% { - opacity: 0.7; - transform: scale(0.92); - } -} - -.brand-title { - font-family: var(--display); - font-size: 15px; - font-weight: 600; - letter-spacing: 0.08em; - color: var(--text); -} - -.brand-sub { - font-size: 10px; - color: var(--muted); - letter-spacing: 0.14em; - margin-top: 2px; -} - -.header-right { - display: flex; - align-items: center; - gap: 12px; - flex-wrap: wrap; -} - -.sys-pill { - font-size: 10px; - letter-spacing: 0.12em; - padding: 5px 10px; - border-radius: 999px; - border: 1px solid var(--border); - color: var(--accent); - background: var(--accent-dim); - font-family: var(--display); -} - -.sys-pill.warn { - color: var(--red); - border-color: rgba(255, 77, 109, 0.4); - background: rgba(255, 77, 109, 0.1); -} - -.sys-pill.syncing { - opacity: 0.85; - animation: sys-pill-pulse 1.2s ease-in-out infinite; -} - -@keyframes sys-pill-pulse { - 50% { - opacity: 0.55; - } -} - -.theme-toggle { - display: inline-flex; - align-items: center; - gap: 2px; - padding: 3px; - border-radius: var(--radius); - border: 1px solid var(--border-soft); - background: var(--nav-bg); - backdrop-filter: blur(8px); -} - -.theme-toggle-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 34px; - height: 32px; - padding: 0; - border: none; - border-radius: 7px; - background: transparent; - color: var(--muted); - cursor: pointer; - transition: background 0.15s, color 0.15s, box-shadow 0.15s; -} - -.theme-toggle-btn:hover { - color: var(--text); - background: var(--panel-hover); -} - -.theme-toggle-btn.is-active { - color: var(--accent); - background: var(--accent-dim); - box-shadow: inset 0 0 0 1px var(--border); -} - -.theme-toggle-btn .theme-icon { - display: block; -} - -.top-nav { - display: flex; - gap: 4px; - background: var(--nav-bg); - padding: 4px; - border-radius: var(--radius); - border: 1px solid var(--border-soft); - backdrop-filter: blur(8px); -} - -.top-nav a { - padding: 8px 16px; - border-radius: 7px; - text-decoration: none; - color: var(--muted); - font-size: 12px; - font-weight: 500; - letter-spacing: 0.04em; - transition: background 0.15s, color 0.15s, box-shadow 0.15s; -} - -.top-nav a.nav-hidden { - display: none !important; -} - -.top-nav a:hover { - color: var(--text); - background: var(--panel-hover); - text-decoration: none; -} - -.top-nav a.active { - background: linear-gradient(135deg, rgba(0, 212, 255, 0.2), rgba(123, 97, 255, 0.15)); - color: var(--accent); - border: 1px solid var(--border); - box-shadow: var(--glow); -} - -button.ghost { - background: transparent; - border: 1px solid var(--border-soft); - color: var(--muted); - font-size: 11px; - padding: 7px 12px; -} - -button.ghost:hover:not(:disabled) { - color: var(--text); - border-color: var(--border); -} - -.page.hidden { - display: none; -} - -.page-head { - margin: 24px 0 16px; -} - -.page-head h1 { - margin: 0 0 6px; - font-family: var(--display); - font-size: 20px; - font-weight: 600; - letter-spacing: 0.06em; - display: flex; - align-items: center; - gap: 10px; -} - -.head-tag { - font-size: 11px; - padding: 3px 8px; - border-radius: 4px; - background: var(--accent-dim); - border: 1px solid var(--border); - color: var(--accent); -} - -.page-desc { - margin: 0; - font-size: 12px; - color: var(--muted); -} - -.hint-box { - margin-bottom: 16px; - border: 1px solid var(--border-soft); - border-radius: var(--radius); - background: var(--panel); - backdrop-filter: blur(10px); - overflow: hidden; -} - -.hint-box summary { - padding: 10px 14px; - cursor: pointer; - font-size: 12px; - color: var(--muted); - user-select: none; - list-style: none; -} -.hint-box summary::-webkit-details-marker { - display: none; -} -.hint-box summary::before { - content: "▸ "; - color: var(--accent); -} -.hint-box[open] summary::before { - content: "▾ "; -} - -.hint-box .hint-body { - padding: 0 14px 12px; - font-size: 11px; - color: var(--muted); - line-height: 1.65; - border-top: 1px solid var(--border-soft); -} -.hint-box .hint-body code { - font-family: var(--mono); - font-size: 10px; - background: rgba(0, 212, 255, 0.08); - padding: 1px 5px; - border-radius: 4px; - color: var(--accent); - border: 1px solid var(--border-soft); -} - -.toolbar { - display: flex; - flex-wrap: wrap; - gap: 10px; - align-items: center; - padding: 12px 14px; - background: var(--panel); - border: 1px solid var(--border); - border-radius: var(--radius); - margin-bottom: 16px; - backdrop-filter: blur(10px); - box-shadow: var(--glow); -} - -.toolbar-spacer { - flex: 1; - min-width: 8px; -} - -.toolbar-meta { - font-size: 11px; - color: var(--muted); - font-family: var(--mono); -} - -button, -.btn { - background: var(--btn-surface); - color: var(--text); - border: 1px solid var(--border); - border-radius: 8px; - padding: 8px 16px; - cursor: pointer; - font-size: 12px; - font-family: var(--font); - font-weight: 500; - letter-spacing: 0.03em; - transition: border-color 0.15s, background 0.15s, box-shadow 0.15s; -} - -button:hover:not(:disabled) { - border-color: var(--accent); - background: var(--panel-hover); - box-shadow: 0 0 16px rgba(0, 212, 255, 0.12); -} - -button.primary { - background: var(--primary-btn-bg); - border-color: var(--primary-btn-border); - color: var(--primary-btn-fg); - font-weight: 600; - text-shadow: none; -} - -button.danger { - border-color: rgba(255, 77, 109, 0.5); - color: var(--red); - background: rgba(255, 77, 109, 0.08); -} - -button.danger:hover:not(:disabled) { - background: rgba(255, 77, 109, 0.15); - border-color: var(--red); - box-shadow: 0 0 16px rgba(255, 77, 109, 0.2); -} - -button:disabled { - opacity: 0.4; - cursor: not-allowed; -} - -.btn-link { - background: transparent; - border: 1px solid var(--border-soft); - color: var(--accent); - padding: 5px 10px; - font-size: 11px; - border-radius: 6px; -} -.btn-link:hover { - background: var(--accent-dim); - text-decoration: none; - box-shadow: var(--glow); -} - -.btn-close-pos.btn-sm { - white-space: nowrap; -} - -.data-table .td-actions { - text-align: right; - width: 1%; - white-space: nowrap; -} - -.chk-label { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 12px; - color: var(--muted); - cursor: pointer; -} - -.card { - background: var(--panel); - border: 1px solid var(--border); - border-radius: var(--radius); - overflow: hidden; - backdrop-filter: blur(12px); - transition: border-color 0.2s, box-shadow 0.2s; - position: relative; -} - -.card::before { - content: ""; - position: absolute; - top: 0; - left: 0; - right: 0; - height: 2px; - background: linear-gradient(90deg, transparent, var(--accent), transparent); - opacity: 0.5; -} - -.card.card-online { - border-color: rgba(0, 255, 157, 0.35); -} -.card.card-online::before { - background: linear-gradient(90deg, transparent, var(--green), transparent); - opacity: 0.8; -} - -.card.card-offline { - border-color: rgba(255, 77, 109, 0.3); -} -.card.card-offline::before { - background: linear-gradient(90deg, transparent, var(--red), transparent); -} - -.card:hover { - border-color: rgba(0, 212, 255, 0.45); - box-shadow: var(--glow); -} - -.card-head { - padding: 14px 16px; - border-bottom: 1px solid var(--border-soft); - display: flex; - justify-content: space-between; - align-items: flex-start; - gap: 12px; -} - -.card-title-row { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; -} - -.status-dot { - width: 8px; - height: 8px; - border-radius: 50%; - flex-shrink: 0; -} -.status-dot.ok { - background: var(--green); - box-shadow: 0 0 8px var(--green); -} -.status-dot.bad { - background: var(--red); - box-shadow: 0 0 8px var(--red); -} - -.status-dot.warn { - background: #ffb020; - box-shadow: 0 0 8px rgba(255, 176, 32, 0.45); -} - -/* —— 手机监控总览瓦片 —— */ -.monitor-alert-summary { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: center; - gap: 6px 10px; - margin: 0 0 10px; - padding: 10px 12px; - border-radius: var(--radius); - border: 1px solid var(--border-soft); - background: var(--panel); - font-size: 12px; -} - -.monitor-alert-summary.hidden { - display: none !important; -} - -.mas-item.mas-ok { - color: var(--green); -} - -.mas-item.mas-warn { - color: #ffb020; -} - -.mas-item.mas-err { - color: var(--red); -} - -.mas-sep { - color: var(--muted); -} - -.monitor-macro-banner { - margin: 0 0 12px; - padding: 12px 14px; - border-radius: var(--radius); - border: 1px solid rgba(255, 176, 32, 0.45); - background: linear-gradient(90deg, rgba(255, 176, 32, 0.12), rgba(255, 120, 80, 0.08)); -} - -.monitor-macro-banner.hidden { - display: none !important; -} - -.monitor-macro-banner-inner { - display: flex; - align-items: flex-start; - gap: 10px; - flex-wrap: wrap; -} - -.monitor-macro-badge { - flex: 0 0 auto; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.06em; - padding: 4px 10px; - border-radius: 999px; - color: #ffb020; - border: 1px solid rgba(255, 176, 32, 0.5); - background: rgba(255, 176, 32, 0.12); -} - -.monitor-macro-text { - flex: 1 1 240px; - font-size: 13px; - line-height: 1.5; - color: var(--text); -} - -.monitor-macro-banner.phase-imminent { - border-color: rgba(255, 120, 80, 0.55); - background: linear-gradient(90deg, rgba(255, 120, 80, 0.14), rgba(255, 176, 32, 0.1)); -} - -.macro-event-form { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 12px; - margin: 12px 0 14px; - align-items: end; -} - -.macro-event-field { - display: flex; - flex-direction: column; - gap: 6px; - font-size: 12px; - color: var(--muted); -} - -.macro-event-field-wide { - grid-column: 1 / -1; -} - -.macro-event-field input, -.macro-event-field select { - background: var(--bg-elevated); - border: 1px solid var(--border); - color: var(--text); - border-radius: 8px; - padding: 9px 11px; - font-size: 12px; - font-family: var(--mono); -} - -.macro-event-actions { - display: flex; - gap: 8px; - align-items: center; -} - -.macro-event-list { - display: flex; - flex-direction: column; - gap: 8px; -} - -.macro-event-row { - display: grid; - grid-template-columns: minmax(140px, 1.2fr) minmax(150px, 1fr) minmax(120px, 1fr) auto; - gap: 10px; - align-items: center; - padding: 10px 12px; - border: 1px solid var(--border-soft); - border-radius: var(--radius); - background: var(--panel); - font-size: 12px; -} - -.macro-event-row.is-active { - border-color: rgba(255, 176, 32, 0.45); - box-shadow: inset 0 0 0 1px rgba(255, 176, 32, 0.12); -} - -.macro-event-row-title { - font-weight: 600; - color: var(--text); -} - -.macro-event-row-meta { - color: var(--muted); - font-family: var(--mono); - font-size: 11px; -} - -.macro-event-row-actions { - display: flex; - gap: 6px; - justify-content: flex-end; -} - -.macro-event-empty { - padding: 14px; - text-align: center; - color: var(--muted); - font-size: 12px; - border: 1px dashed var(--border-soft); - border-radius: var(--radius); -} - -.host-status-panel { - margin: 0 0 12px; - border-radius: var(--radius); - border: 1px solid var(--border-soft); - background: var(--panel); - font-size: 12px; -} - -.host-status-panel.hidden { - display: none !important; -} - -.host-status-summary { - display: flex; - align-items: center; - gap: 8px 12px; - padding: 10px 12px; - cursor: pointer; - list-style: none; - user-select: none; -} - -.host-status-summary::-webkit-details-marker { - display: none; -} - -.host-status-summary::before { - content: "▸"; - color: var(--muted); - font-size: 11px; - transition: transform 0.15s ease; - flex-shrink: 0; -} - -.host-status-panel[open] > .host-status-summary::before { - transform: rotate(90deg); -} - -.host-status-summary-title { - font-weight: 600; - color: var(--text); - white-space: nowrap; -} - -.host-status-summary-text { - font-size: 11px; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - flex: 1 1 auto; -} - -.host-status-summary-text.bad { - color: var(--red); -} - -.host-summary-host, -.host-summary-sep { - color: var(--muted); -} - -.host-metric-tone.ok, -.host-metric-val.ok { - color: var(--green); - font-weight: 600; -} - -.host-metric-tone.bad, -.host-metric-val.bad { - color: var(--red); - font-weight: 600; -} - -.host-status-bar { - display: flex; - flex-direction: column; - gap: 12px; - padding: 0 12px 12px; - border-top: 1px solid var(--border-soft); - margin-top: 0; - padding-top: 12px; - border-radius: 0; - border-left: none; - border-right: none; - border-bottom: none; - background: transparent; -} - -.host-status-top { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: 8px 16px; -} - -.host-status-head { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; - flex: 1 1 220px; -} - -.host-status-dot { - width: 8px; - height: 8px; - border-radius: 50%; - flex-shrink: 0; - background: var(--muted); -} - -.host-status-dot.ok { - background: var(--green); - box-shadow: 0 0 8px var(--green); -} - -.host-status-dot.warn { - background: #ffb020; - box-shadow: 0 0 8px rgba(255, 176, 32, 0.45); -} - -.host-status-dot.bad { - background: var(--red); - box-shadow: 0 0 8px var(--red); -} - -.host-status-name { - font-weight: 600; - color: var(--text); - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.host-status-meta { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: flex-end; - gap: 6px 14px; - color: var(--muted); - font-size: 11px; - flex: 0 1 auto; -} - -.host-status-uptime, -.host-status-updated { - white-space: nowrap; -} - -.host-status-metrics { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 10px; -} - -.host-metric-card { - display: flex; - flex-direction: column; - gap: 8px; - min-width: 0; - padding: 10px 12px; - border-radius: 8px; - border: 1px solid var(--border-soft); - background: rgba(0, 0, 0, 0.14); -} - -html[data-theme="light"] .host-metric-card { - background: rgba(0, 0, 0, 0.03); -} - -.host-metric-head { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 10px; -} - -.host-metric-label { - color: var(--muted); - font-size: 11px; - white-space: nowrap; -} - -.host-metric-bar { - height: 7px; - border-radius: 999px; - background: rgba(255, 255, 255, 0.06); - overflow: hidden; -} - -html[data-theme="light"] .host-metric-bar { - background: rgba(0, 0, 0, 0.06); -} - -.host-metric-fill { - display: block; - height: 100%; - width: 0%; - border-radius: inherit; - background: #22c55e; - transition: width 0.35s ease, background 0.2s ease; -} - -.host-metric-fill.ok { - background: #22c55e; -} - -.host-metric-fill.warn { - background: #ffb020; -} - -.host-metric-fill.bad { - background: var(--red); -} - -.host-metric-val { - color: var(--text); - font-variant-numeric: tabular-nums; - white-space: nowrap; - font-size: 13px; - font-weight: 600; -} - -.host-metric-val-net { - font-size: 11px; - font-weight: 500; - color: var(--muted); -} - -.host-metric-sub, -.host-net-line { - color: var(--muted); - font-size: 11px; - font-variant-numeric: tabular-nums; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.host-net-lines { - display: flex; - flex-direction: column; - gap: 4px; -} - -@media (max-width: 1080px) { - .host-status-metrics { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } -} - -.grid-monitor.grid-monitor-tiles { - grid-template-columns: repeat(2, minmax(0, 1fr)) !important; - gap: 10px; - align-content: start; -} - -.hub-tile { - margin: 0; - padding: 0; - min-height: 118px; - overflow: hidden; -} - -.hub-tile .hub-tile-body { - cursor: pointer; - padding: 12px 12px 10px; - display: flex; - flex-direction: column; - gap: 6px; - min-height: 118px; -} - -.hub-tile-top { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; - flex-wrap: wrap; -} - -.hub-tile-name { - font-family: var(--display); - font-size: 13px; - font-weight: 600; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.hub-tile-pnl { - font-size: 20px; - font-weight: 600; - line-height: 1.2; -} - -.hub-tile-pnl small { - font-size: 11px; - font-weight: 500; - color: var(--muted); -} - -.hub-tile-meta { - font-size: 11px; - color: var(--muted); - line-height: 1.35; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.hub-tile-foot { - margin-top: auto; - font-size: 10px; - color: var(--muted); -} - -.hub-tile-error { - border-color: rgba(255, 77, 109, 0.45); - box-shadow: 0 0 0 1px rgba(255, 77, 109, 0.12); -} - -.hub-tile-warn { - border-color: rgba(255, 176, 32, 0.45); - box-shadow: 0 0 0 1px rgba(255, 176, 32, 0.1); -} - -.hub-tile-ok { - border-color: var(--border-soft); -} - -.hub-tile-body:hover .hub-tile-name { - color: var(--accent); -} - -.card-title { - font-family: var(--display); - font-size: 13px; - font-weight: 600; - letter-spacing: 0.05em; - margin: 0 0 4px; - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 8px; -} - -.card-sub { - font-size: 10px; - color: var(--muted); - font-family: var(--mono); - word-break: break-all; -} - -.card-actions { - display: flex; - gap: 6px; - align-items: center; - flex-shrink: 0; -} - -.card-body { - padding: 14px 16px; -} - -.grid-monitor { - display: grid; - gap: 16px; - /* 列数由 app.js syncMonitorGridColumns 按卡片数量设置 */ - grid-template-columns: repeat(3, minmax(0, 1fr)); -} - -.card-expand-zone { - cursor: pointer; -} - -.card-expand-zone:hover .card-title { - color: var(--accent); -} - -body.hub-fullscreen-open { - overflow: hidden; -} - -body.hub-instance-frame-open { - overflow: hidden; -} - -body.market-chart-fs-open { - overflow: hidden; -} - -.instance-frame-shell { - position: fixed; - inset: 0; - z-index: 200; - display: flex; - flex-direction: column; - background: var(--bg, #0a0e14); - isolation: isolate; -} - -.instance-frame-shell.hidden { - display: none !important; -} - -.instance-frame-shell.is-instance-nav-loading .instance-frame { - pointer-events: none; -} - -.instance-frame-loading { - display: none; - position: absolute; - left: 0; - right: 0; - bottom: 0; - top: 49px; - z-index: 2; - align-items: center; - justify-content: center; - background: color-mix(in srgb, var(--bg, #0a0e14) 72%, transparent); - color: var(--muted, #8892b0); - font-size: 0.9rem; - pointer-events: none; -} - -.instance-frame-shell.is-instance-nav-loading .instance-frame-loading { - display: flex; -} - -.instance-frame-loading-inner { - display: inline-flex; - align-items: center; - gap: 10px; - padding: 10px 16px; - border-radius: 999px; - border: 1px solid var(--border-soft); - background: color-mix(in srgb, var(--panel-solid) 88%, transparent); -} - -.instance-frame-spinner { - width: 16px; - height: 16px; - border-radius: 50%; - border: 2px solid color-mix(in srgb, var(--muted, #8892b0) 35%, transparent); - border-top-color: var(--accent, #6eb5ff); - animation: instance-frame-spin 0.75s linear infinite; -} - -@keyframes instance-frame-spin { - to { - transform: rotate(360deg); - } -} - -.instance-frame-toolbar { - flex: 0 0 auto; - display: flex; - align-items: center; - gap: 12px; - padding: 10px 16px; - border-bottom: 1px solid var(--border-soft); - background: var(--panel-solid); -} - -.instance-frame-title { - flex: 1; - font-weight: 600; - color: var(--text, #dbe4ff); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.instance-frame-actions { - display: flex; - gap: 8px; - flex-shrink: 0; -} - -.instance-frame { - flex: 1 1 auto; - width: 100%; - border: none; - background: var(--bg); -} - -.exchange-fullscreen { - position: fixed; - inset: 0; - z-index: 150; - background: var(--fs-scrim); - backdrop-filter: blur(6px); - overflow: auto; - padding: 16px 20px 24px; -} - -.exchange-fullscreen.hidden { - display: none !important; -} - -.exchange-fullscreen-backdrop { - position: fixed; - inset: 0; - z-index: 0; - border: none; - padding: 0; - margin: 0; - background: transparent; - cursor: pointer; -} - -.exchange-fullscreen-panel { - position: relative; - z-index: 1; - max-width: min(1800px, 98vw); - margin: 0 auto; -} - -.fs-head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; - margin-bottom: 16px; - padding-bottom: 12px; - border-bottom: 1px solid var(--border-soft); -} - -.fs-title { - margin: 0; - font-family: var(--display); - font-size: 18px; - letter-spacing: 0.04em; -} - -.fs-sub { - font-size: 11px; - color: var(--muted); - margin-top: 4px; - word-break: break-all; -} - -.fs-head-actions { - display: flex; - flex-wrap: wrap; - gap: 8px; - justify-content: flex-end; -} - -.fs-head-actions .btn-open-trade { - border-color: var(--accent); - color: var(--accent); - background: color-mix(in srgb, var(--accent) 10%, transparent); - font-weight: 600; -} - -.fs-head-actions .btn-open-trade:hover { - background: color-mix(in srgb, var(--accent) 18%, transparent); -} - -.card-actions .btn-open-trade { - border-color: var(--accent); - color: var(--accent); - font-weight: 600; -} - -.card-expand-hint { - margin-top: 12px; - padding: 8px 10px; - font-size: 11px; - color: var(--muted); - text-align: center; - border: 1px dashed var(--border-soft); - border-radius: 8px; - background: rgba(0, 212, 255, 0.03); -} - -.compact-pos-list { - display: flex; - flex-direction: column; - gap: 6px; -} - -.compact-pos-line { - display: flex; - justify-content: space-between; - align-items: center; - gap: 8px; - font-size: 12px; - padding: 6px 8px; - background: var(--inset-surface); - border-radius: 6px; - border: 1px solid var(--border-soft); -} - -.hub-pos-list { - display: flex; - flex-direction: column; - gap: 12px; - margin-bottom: 14px; -} - -/* 全屏放大:持仓卡片横向排列,列数随仓位数量自适应 */ -.exchange-fullscreen .hub-pos-list { - display: grid; - gap: 14px; - align-items: stretch; - width: 100%; -} - -.exchange-fullscreen .hub-pos-list.count-1 { - grid-template-columns: minmax(0, 1fr); -} - -.exchange-fullscreen .hub-pos-list.count-1 .hub-pos-card.pos-card { - max-width: min(960px, 100%); - margin-inline: auto; - width: 100%; -} - -.exchange-fullscreen .hub-pos-list.count-2 { - grid-template-columns: repeat(2, minmax(0, 1fr)); -} - -.exchange-fullscreen .hub-pos-list.count-3 { - grid-template-columns: repeat(3, minmax(0, 1fr)); -} - -.exchange-fullscreen .hub-pos-list.count-4 { - grid-template-columns: repeat(2, minmax(0, 1fr)); -} - -.exchange-fullscreen .hub-pos-list.count-5, -.exchange-fullscreen .hub-pos-list.count-6 { - grid-template-columns: repeat(3, minmax(0, 1fr)); -} - -.exchange-fullscreen .hub-pos-list.count-many { - grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); -} - -.exchange-fullscreen .hub-pos-card.pos-card { - min-width: 0; - height: 100%; -} - -@media (max-width: 1100px) { - .exchange-fullscreen .hub-pos-list.count-3, - .exchange-fullscreen .hub-pos-list.count-5, - .exchange-fullscreen .hub-pos-list.count-6 { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - .exchange-fullscreen .hub-pos-list.count-many { - grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); - } -} - -@media (max-width: 640px) { - .exchange-fullscreen .hub-pos-list.count-2, - .exchange-fullscreen .hub-pos-list.count-3, - .exchange-fullscreen .hub-pos-list.count-4, - .exchange-fullscreen .hub-pos-list.count-5, - .exchange-fullscreen .hub-pos-list.count-6, - .exchange-fullscreen .hub-pos-list.count-many { - grid-template-columns: minmax(0, 1fr); - } - .exchange-fullscreen .hub-pos-list.count-1 .hub-pos-card.pos-card { - max-width: 100%; - } -} - -/* 平板横屏:持仓与区块双列 */ -@media (min-width: 641px) and (max-width: 1200px) and (orientation: landscape) { - .exchange-fullscreen .hub-pos-list.count-2, - .exchange-fullscreen .hub-pos-list.count-3, - .exchange-fullscreen .hub-pos-list.count-4, - .exchange-fullscreen .hub-pos-list.count-many { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - .exchange-fullscreen .hub-section-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - .hub-fs-sections-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px; - align-items: start; - } -} - -/* 手机竖屏:全屏顶栏与持仓单列 */ -@media (max-width: 720px), (max-width: 900px) and (orientation: portrait) { - .exchange-fullscreen .hub-pos-list { - grid-template-columns: minmax(0, 1fr) !important; - } -} - -.hub-fs-sections-grid { - display: flex; - flex-direction: column; - gap: 12px; -} - -@media (max-width: 720px), (max-width: 900px) and (orientation: portrait) { - .hub-fs-sections-grid { - display: flex; - flex-direction: column; - } -} - -/* 对齐实盘「实时持仓」pos-card */ -.hub-pos-card.pos-card { - background: var(--pos-card-bg); - border: 1px solid var(--border-soft); - border-radius: 10px; - padding: 12px 14px; -} - -.hub-pos-card .pos-card-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - margin-bottom: 10px; -} - -.hub-pos-card .pos-card-symbol { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; - min-width: 0; -} - -.hub-pos-card .pos-symbol-time-close, -.hub-mini-title .pos-symbol-time-close, -.td-symbol .pos-symbol-time-close { - display: inline-flex; - align-items: center; - gap: 4px; - font-size: 0.72rem; - font-weight: 500; - color: #8fc8ff; - padding: 1px 6px; - border-radius: 4px; - background: rgba(143, 200, 255, 0.1); - white-space: nowrap; - vertical-align: middle; -} -.hub-pos-card .pos-symbol-time-close .pos-time-close-cd, -.hub-mini-title .pos-symbol-time-close .pos-time-close-cd, -.td-symbol .pos-symbol-time-close .pos-time-close-cd { - font-variant-numeric: tabular-nums; - letter-spacing: 0.03em; -} -.hub-pos-card .pos-card-symbol strong { - font-size: 14px; - color: var(--text); - font-weight: 600; -} - -.hub-pos-card .pos-side-badge { - padding: 3px 8px; - border-radius: 6px; - font-size: 11px; - font-weight: 500; -} - -.hub-pos-card .pos-side-long, -.hub-pos-card .pos-side-badge.side-long { - background: rgba(0, 255, 157, 0.12); - color: var(--green); - border: 1px solid rgba(0, 255, 157, 0.35); -} - -.hub-pos-card .pos-side-short, -.hub-pos-card .pos-side-badge.side-short { - background: rgba(255, 77, 109, 0.12); - color: var(--red); - border: 1px solid rgba(255, 77, 109, 0.35); -} - -.side-long { - color: var(--green); - font-weight: 600; - text-shadow: 0 0 10px rgba(0, 255, 157, 0.25); -} - -.side-short { - color: var(--red); - font-weight: 600; - text-shadow: 0 0 10px rgba(255, 77, 109, 0.25); -} - -.data-table td.side-long, -.data-table td.side-short { - font-weight: 600; -} - -.hub-pos-card .pos-head-actions { - display: flex; - align-items: center; - gap: 6px; - flex-shrink: 0; -} - -.hub-pos-card .pos-entrust-btn { - padding: 6px 12px; - background: rgba(42, 74, 122, 0.9); - color: #8fc8ff; - border: 1px solid rgba(0, 212, 255, 0.25); - border-radius: 8px; - font-size: 12px; - cursor: pointer; - white-space: nowrap; -} - -.hub-pos-card .pos-close-btn { - padding: 6px 14px; - background: rgba(196, 84, 84, 0.95); - color: #fff; - border: none; - border-radius: 8px; - font-size: 12px; - cursor: pointer; - white-space: nowrap; -} - -.hub-pos-card .pos-meta { - font-size: 11px; - color: var(--muted); - line-height: 1.45; - margin-bottom: 12px; - display: flex; - flex-wrap: wrap; - gap: 4px 0; -} - -.hub-pos-card .pos-meta-item:not(:last-child)::after { - content: "|"; - margin: 0 8px; - color: var(--border-soft); -} - -.hub-pos-card .pos-meta-on { - color: #6eb5ff; -} - -.hub-pos-card .pos-meta-off { - color: var(--muted); -} - -.hub-pos-card .pos-breakeven-badge { - display: inline-flex; - align-items: center; - padding: 2px 8px; - border-radius: 6px; - font-size: 11px; - font-weight: 600; - background: #1a3d2e; - color: #4cd97f; -} - -.pos-breakeven-badge { - display: inline-flex; - align-items: center; - margin-left: 6px; - padding: 2px 8px; - border-radius: 6px; - font-size: 11px; - font-weight: 600; - background: #1a3d2e; - color: #4cd97f; - vertical-align: middle; - white-space: nowrap; -} - -.data-table .td-symbol { - white-space: nowrap; - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 4px 6px; -} - -.hub-pos-card .pos-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 12px 14px; - margin-bottom: 12px; -} - -.hub-pos-card .pos-cell { - display: flex; - flex-direction: column; - gap: 4px; - min-width: 0; -} - -.hub-pos-card .pos-label { - font-size: 10px; - color: var(--muted); - letter-spacing: 0.04em; -} - -.hub-pos-card .pos-value { - font-size: 13px; - color: var(--text); - font-weight: 500; -} - -.hub-pos-card .pos-value.pnl-pos { - color: var(--green); - font-weight: 600; - text-shadow: 0 0 12px rgba(0, 255, 157, 0.25); -} - -.hub-pos-card .pos-value.pnl-neg { - color: var(--red); - font-weight: 600; -} - -.hub-pos-card .pos-footer { - display: flex; - flex-wrap: wrap; - gap: 12px 16px; - font-size: 11px; - color: var(--muted); - margin-bottom: 4px; -} - -.hub-pos-card .pos-ex-orders { - margin-top: 10px; - padding-top: 10px; - border-top: 1px dashed var(--border-soft); -} - -.hub-pos-card .pos-ex-orders-title { - font-size: 11px; - color: var(--muted); - margin-bottom: 6px; -} - -.hub-pos-card .pos-ex-order-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - font-size: 12px; - margin-top: 5px; -} - -.hub-pos-card .pos-ex-order-main { - flex: 1; - min-width: 0; -} - -.hub-pos-card .pos-ex-cancel-btn { - padding: 3px 10px; - background: rgba(58, 48, 72, 0.9); - color: #d4b8ff; - border: 1px solid rgba(123, 97, 255, 0.35); - border-radius: 6px; - font-size: 11px; - cursor: pointer; - flex-shrink: 0; -} - -.hub-pos-card .pos-orders-collapse { - margin-top: 10px; -} - -.hub-section-card { - margin-top: 14px; - padding: 12px 14px; - background: var(--section-surface); - border: 1px solid var(--border-soft); - border-radius: 10px; -} - -.hub-section-head { - font-size: 11px; - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--accent); - margin-bottom: 10px; -} - -.hub-section-body { - display: flex; - flex-direction: column; - gap: 8px; -} - -.hub-key-list { - display: flex; - flex-direction: column; - gap: 8px; -} - -/* 全屏放大:关键位 3 列网格 */ -.exchange-fullscreen .hub-key-list { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 12px; - align-items: stretch; -} - -.exchange-fullscreen .hub-key-list .hub-mini-card { - min-width: 0; - height: 100%; -} - -@media (max-width: 1100px) { - .exchange-fullscreen .hub-key-list { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } -} - -@media (max-width: 640px) { - .exchange-fullscreen .hub-key-list { - grid-template-columns: minmax(0, 1fr); - } -} - -.hub-mini-card { - padding: 10px 12px; - background: var(--inset-surface); - border: 1px solid var(--border-soft); - border-radius: 8px; -} - -.hub-mini-card.hub-key-pending, -.list-line.hub-key-pending { - border-color: rgba(0, 212, 255, 0.55); - background: rgba(0, 212, 255, 0.08); - box-shadow: 0 0 16px rgba(0, 212, 255, 0.12); -} - -.hub-key-pending-tag { - display: inline-block; - margin-left: 6px; - padding: 1px 7px; - font-size: 10px; - font-weight: 600; - color: var(--accent); - background: rgba(0, 212, 255, 0.15); - border: 1px solid rgba(0, 212, 255, 0.45); - border-radius: 4px; - vertical-align: middle; -} - -.hub-key-pending .hub-key-status-line, -.list-line.hub-key-pending { - color: var(--text); -} - -.hub-mini-title { - font-size: 12px; - font-weight: 600; - color: var(--text); - margin-bottom: 4px; -} - -.hub-mini-line { - font-size: 11px; - color: var(--muted); - line-height: 1.45; -} - -.pos-empty { - padding: 18px; - text-align: center; - color: var(--muted); - font-size: 12px; - border: 1px dashed var(--border-soft); - border-radius: 10px; -} - -@media (max-width: 520px) { - .hub-pos-card .pos-grid { - grid-template-columns: repeat(2, 1fr); - } -} - -.settings-grid-wrap { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 16px; -} - -.stat-row { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 10px; - margin-bottom: 12px; -} - -.stat-box { - background: var(--inset-surface); - border: 1px solid var(--border-soft); - border-radius: 8px; - padding: 10px 12px; -} - -.stat-label { - font-size: 10px; - color: var(--muted); - text-transform: uppercase; - letter-spacing: 0.08em; - margin-bottom: 4px; -} - -.stat-value { - font-size: 17px; - font-weight: 600; - font-variant-numeric: tabular-nums; - color: var(--text); -} - -.section-title { - font-size: 10px; - font-weight: 600; - color: var(--accent); - text-transform: uppercase; - letter-spacing: 0.1em; - margin: 14px 0 8px; - padding-bottom: 6px; - border-bottom: 1px solid var(--border-soft); -} - -.section-title:first-child { - margin-top: 0; -} - -.pos-block { - margin-bottom: 14px; - padding-bottom: 10px; - border-bottom: 1px dashed var(--border-soft); -} - -.pos-block:last-child { - border-bottom: none; - margin-bottom: 0; -} - -.pos-table-wrap { - margin-bottom: 8px; -} - -.data-table-positions tbody tr:not(:last-child) td { - border-bottom: 1px dashed var(--border-soft); -} - -.card-strategy-stats { - display: flex; - flex-wrap: wrap; - gap: 6px; - margin: 10px 0 4px; - padding-top: 8px; - border-top: 1px dashed var(--border-soft); -} - -.card-stat-chip { - display: inline-flex; - align-items: center; - padding: 3px 8px; - border-radius: 6px; - font-size: 11px; - line-height: 1.3; - border: 1px solid transparent; -} - -/* 突破 + 斐波 */ -.card-stat-chip.card-stat-key-breakout { - color: var(--accent); - background: rgba(0, 212, 255, 0.14); - border-color: rgba(0, 212, 255, 0.38); -} - -/* 关键位监控(阻力/支撑等) */ -.card-stat-chip.card-stat-key-watch { - color: #b8a0ff; - background: rgba(123, 97, 255, 0.18); - border-color: rgba(123, 97, 255, 0.42); -} - -/* 趋势回调 */ -.card-stat-chip.card-stat-trend { - color: var(--green); - background: rgba(0, 255, 157, 0.1); - border-color: rgba(0, 255, 157, 0.38); -} - -/* 趋势回调:与四所实例 strategy_trend_panel 同款卡片 */ -.hub-trend-running-title { - margin: 0 0 10px; - font-size: 0.95rem; - color: var(--accent); - font-weight: 600; -} - -.hub-trend-plan-list.running-plans-stack { - display: flex; - flex-direction: column; - gap: 12px; -} - -.hub-trend-plan-card.plan-position-card { - background: var(--panel-solid); - border: 1px solid var(--panel-solid-border); - border-radius: 12px; - padding: 12px 14px; -} - -.hub-trend-plan-card .plan-card-head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 10px; - flex-wrap: wrap; - margin-bottom: 8px; -} - -.hub-trend-plan-card .plan-card-title { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; - font-size: 1rem; - font-weight: 700; - color: var(--plan-title); -} - -.hub-trend-plan-card .plan-card-meta { - font-size: 0.76rem; - color: var(--plan-meta); - line-height: 1.55; - margin-bottom: 10px; -} - -.hub-trend-plan-card .plan-card-meta .accent { - color: var(--plan-meta-accent); -} - -.hub-trend-plan-card .plan-card-meta strong { - color: var(--accent); -} - -.hub-trend-plan-body-cols { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - gap: 14px 18px; - align-items: start; - margin-bottom: 10px; - padding-bottom: 10px; - border-bottom: 1px dashed var(--plan-border-dash); -} - -.hub-trend-plan-col-left .plan-card-meta { - margin-bottom: 10px; -} - -.hub-trend-plan-col-left .plan-card-grid { - margin-bottom: 0; -} - -.hub-trend-plan-card .plan-card-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 10px 14px; -} - -.hub-trend-plan-card .plan-cell { - display: flex; - flex-direction: column; - gap: 3px; -} - -.hub-trend-plan-card .plan-cell .lbl { - font-size: 0.72rem; - color: var(--plan-lbl); -} - -.hub-trend-plan-card .plan-cell .val { - color: var(--plan-val); - font-size: 0.88rem; - font-weight: 500; -} - -.hub-trend-plan-card .plan-cell .val.pnl-profit { - color: #4cd97f; -} - -.hub-trend-plan-card .plan-cell .val.pnl-loss { - color: #ff6666; -} - -.hub-trend-plan-card .plan-cell .val.pnl-neutral { - color: var(--plan-val-neutral); -} - -.hub-trend-plan-card .btn-close-plan { - padding: 7px 14px; - background: var(--plan-close-bg); - color: var(--plan-close-fg); - border: none; - border-radius: 8px; - cursor: pointer; - font-size: 0.82rem; - font-weight: 600; - text-decoration: none; - white-space: nowrap; - display: inline-block; -} - -.hub-trend-plan-card .btn-close-plan:hover { - filter: brightness(1.08); -} - -.hub-trend-plan-card .plan-dca-block--side { - margin-top: 0; - padding-top: 0; - border-top: none; - height: 100%; -} - -.hub-trend-plan-col-right { - min-width: 0; - border-left: 1px solid var(--plan-col-divider); - padding-left: 14px; -} - -.hub-dca-empty { - font-size: 0.76rem; - color: var(--plan-meta); - padding: 8px 0; -} - -.hub-trend-plan-foot { - display: flex; - flex-direction: column; - gap: 8px; - margin-top: 4px; -} - -.hub-trend-plan-foot .hub-plan-breakeven-row { - margin-top: 0; -} - -.hub-trend-plan-foot .hub-plan-account-foot { - margin-bottom: 0; -} - -.hub-trend-plan-card .plan-dca-title { - font-size: 0.74rem; - color: var(--plan-lbl); - margin-bottom: 8px; -} - -.hub-trend-plan-card .plan-dca-table { - width: 100%; - border-collapse: collapse; - font-size: 0.76rem; -} - -.hub-trend-plan-card .plan-dca-table th, -.hub-trend-plan-card .plan-dca-table td { - padding: 6px 8px; - border-bottom: 1px solid var(--plan-col-divider); - text-align: left; - font-weight: 500; -} - -.hub-trend-plan-card .plan-dca-table td { - color: var(--text); -} - -.hub-trend-plan-card .plan-dca-table th { - color: var(--plan-dca-th); - font-weight: 600; -} - -.hub-trend-plan-card .plan-dca-table .st-done { - color: var(--status-done); - font-weight: 700; -} - -.hub-trend-plan-card .plan-dca-table .st-pending { - color: var(--status-pending); - font-weight: 600; -} - -.hub-trend-plan-card .hub-plan-breakeven-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px 12px; - margin-top: 8px; -} - -.hub-trend-plan-card .hub-plan-be-label { - font-size: 0.78rem; - color: var(--plan-be-label); - display: flex; - align-items: center; - gap: 6px; -} - -.hub-trend-plan-card .hub-plan-be-input { - width: 72px; - padding: 4px 8px; - border-radius: 6px; - border: 1px solid var(--plan-be-input-border); - background: var(--plan-be-input-bg); - color: var(--plan-val); - opacity: 0.92; -} - -.hub-trend-plan-card .hub-plan-be-btn { - padding: 6px 12px; - background: var(--plan-be-btn-bg); - color: var(--accent); - border: 1px solid var(--plan-be-input-border); - border-radius: 8px; - font-size: 0.78rem; - text-decoration: none; - cursor: pointer; - white-space: nowrap; -} - -.hub-trend-plan-card button.hub-plan-be-btn { - font-family: inherit; -} - -.hub-trend-plan-card .hub-plan-be-input:disabled { - opacity: 0.55; - cursor: not-allowed; -} - -.hub-trend-plan-card .hub-plan-be-btn--static { - cursor: default; -} - -.hub-trend-plan-card .hub-plan-be-done { - color: #6ab88a; - font-size: 0.75rem; -} - -.hub-trend-plan-card .hub-plan-account-foot { - margin-bottom: 0; -} - -.hub-trend-plan-card .badge.direction-long { - color: #4cd97f; - border-color: rgba(76, 217, 127, 0.45); -} - -.hub-trend-plan-card .badge.direction-short { - color: #ff6666; - border-color: rgba(255, 102, 102, 0.45); -} - -.exchange-fullscreen .hub-trend-plan-card.plan-position-card { - width: 100%; - max-width: 100%; -} - -@media (max-width: 900px) { - .hub-trend-plan-body-cols { - grid-template-columns: 1fr; - } - - .hub-trend-plan-col-right { - border-left: none; - padding-left: 0; - padding-top: 10px; - border-top: 1px dashed var(--plan-border-dash); - } -} - -@media (max-width: 720px) { - .hub-trend-plan-card .plan-card-grid { - grid-template-columns: 1fr; - } -} - -/* 顺势加仓 */ -.card-stat-chip.card-stat-roll { - color: #ffb020; - background: rgba(255, 176, 32, 0.14); - border-color: rgba(255, 176, 32, 0.42); -} - -.hub-tile .card-strategy-stats { - margin: 4px 0 0; - padding-top: 6px; - border-top: none; - gap: 4px; -} - -.hub-tile .card-stat-chip { - font-size: 10px; - padding: 2px 6px; -} - -.pos-action-group { - display: inline-flex; - flex-direction: row; - align-items: center; - justify-content: flex-end; - gap: 6px; - flex-wrap: nowrap; - white-space: nowrap; -} - -.data-table .td-actions .btn-sm { - margin: 0; - vertical-align: middle; -} - -button.btn-sm { - padding: 4px 11px; - font-size: 11px; - line-height: 1.35; - border-radius: 6px; - min-width: 48px; -} - -.btn-place-tpsl.btn-sm { - border-color: rgba(0, 212, 255, 0.35); - color: var(--accent); -} - -.pos-orders-collapse { - margin: 10px 0 0; - padding: 0; - background: var(--inset-surface); - border: 1px solid var(--border-soft); - border-radius: 8px; - overflow: hidden; -} - -.pos-orders-collapse-summary { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 10px; - cursor: pointer; - list-style: none; - user-select: none; - background: rgba(0, 212, 255, 0.04); - border-bottom: 1px solid transparent; -} - -.pos-orders-collapse[open] > .pos-orders-collapse-summary { - border-bottom-color: var(--border-soft); -} - -.pos-orders-collapse-summary::-webkit-details-marker { - display: none; -} - -.pos-orders-collapse-summary::before { - content: "▸"; - flex-shrink: 0; - color: var(--accent); - font-size: 11px; - width: 12px; - transition: transform 0.15s ease; -} - -.pos-orders-collapse[open] > .pos-orders-collapse-summary::before { - transform: rotate(90deg); -} - -.pos-orders-collapse-label { - font-size: 11px; - font-weight: 600; - letter-spacing: 0.04em; - color: var(--text); -} - -.pos-orders-collapse-label em { - font-style: normal; - color: var(--accent); - margin-left: 2px; -} - -.pos-orders-collapse-meta { - flex: 1; - font-size: 10px; - color: var(--muted); - min-width: 0; -} - -.pos-orders-collapse-summary .btn-cancel-cond-all { - flex-shrink: 0; - margin-left: auto; -} - -.pos-orders-collapse-body { - padding: 8px 10px 10px; -} - -.orders-section + .orders-section { - margin-top: 10px; - padding-top: 10px; - border-top: 1px dashed var(--border-soft); -} - -.orders-section-head { - font-size: 10px; - color: var(--muted); - letter-spacing: 0.08em; - text-transform: uppercase; - margin-bottom: 6px; -} - -.data-table-sub { - font-size: 10px; -} - -.data-table-sub th, -.data-table-sub td { - padding: 5px 6px; -} - -.order-empty { - font-size: 11px; - color: var(--muted); - padding: 6px 4px 8px; -} - -.modal { - position: fixed; - inset: 0; - z-index: 200; - display: flex; - align-items: center; - justify-content: center; - padding: 16px; -} - -.modal.hidden { - display: none; -} - -.modal-backdrop { - position: absolute; - inset: 0; - background: var(--overlay); -} - -.modal-panel, -.modal-card { - position: relative; - z-index: 1; - width: 100%; - max-width: 380px; - padding: 20px 22px; - background: var(--bg-elevated); - border: 1px solid var(--border); - border-radius: var(--radius); - box-shadow: var(--shadow); -} - -.modal-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 12px; -} - -.modal-head h3 { - margin: 0; - font-family: var(--display); - font-size: 14px; - letter-spacing: 0.06em; -} - -.plan-modal-close { - flex-shrink: 0; - min-width: 32px; - padding: 4px 8px; - font-size: 18px; - line-height: 1; -} - -.modal-panel h3 { - margin: 0 0 8px; - font-family: var(--display); - font-size: 14px; - letter-spacing: 0.06em; -} - -.modal-meta { - margin: 0 0 14px; - font-size: 12px; - color: var(--muted); -} - -.modal-field { - margin-bottom: 12px; -} - -.modal-field label { - display: block; - font-size: 10px; - color: var(--muted); - margin-bottom: 4px; - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.modal-field input { - width: 100%; - padding: 8px 10px; - background: var(--bg-elevated); - border: 1px solid var(--border-soft); - border-radius: 6px; - color: var(--text); - font-family: var(--font); - font-size: 13px; -} - -.modal-hint { - font-size: 11px; - color: var(--muted); - margin: 0 0 14px; - line-height: 1.5; -} - -.modal-actions { - display: flex; - justify-content: flex-end; - gap: 8px; -} - -.table-scroll { - overflow-x: auto; - -webkit-overflow-scrolling: touch; - max-width: 100%; -} - -.data-table { - width: 100%; - min-width: 300px; - border-collapse: collapse; - font-size: 11px; -} - -.data-table th { - color: var(--muted); - font-weight: 500; - font-size: 10px; - padding: 6px 8px; - text-align: left; - border-bottom: 1px solid var(--border-soft); -} - -.data-table td { - padding: 8px; - border-bottom: 1px solid var(--border-soft); - font-variant-numeric: tabular-nums; -} - -.data-table tr:last-child td { - border-bottom: none; -} - -.list-line { - font-size: 11px; - color: var(--muted); - padding: 6px 0; - border-bottom: 1px dashed var(--border-soft); - line-height: 1.45; -} -.list-line:last-child { - border-bottom: none; -} - -.empty-hint { - font-size: 11px; - color: var(--muted); - padding: 8px 0; -} - -.board-loading-sub { - margin: 12px 0 0; - font-size: 12px; - line-height: 1.5; - color: var(--muted); - max-width: 36rem; -} - -.board-loading { - grid-column: 1 / -1; - display: flex; - align-items: center; - justify-content: center; - gap: 12px; - min-height: 120px; - padding: 24px; - color: var(--muted); - font-size: 13px; - border: 1px dashed var(--border-soft); - border-radius: var(--radius); - background: rgba(0, 0, 0, 0.25); -} - -.board-loading-spin { - width: 18px; - height: 18px; - border: 2px solid var(--border-soft); - border-top-color: var(--accent); - border-radius: 50%; - animation: hub-spin 0.8s linear infinite; -} - -@keyframes hub-spin { - to { - transform: rotate(360deg); - } -} - -.pnl-pos { - color: var(--green); - text-shadow: 0 0 12px rgba(0, 255, 157, 0.3); -} -.pnl-neg { - color: var(--red); -} - -.data-table td.pnl-pos { - color: var(--green); - font-weight: 600; -} - -.data-table td.pnl-neg { - color: var(--red); - font-weight: 600; -} -.err { - color: var(--red); - font-size: 12px; -} - -.badge { - font-size: 9px; - padding: 2px 8px; - border-radius: 999px; - background: var(--accent-dim); - color: var(--accent); - border: 1px solid var(--border); - white-space: nowrap; - letter-spacing: 0.06em; -} - -.settings-meta-line { - font-size: 11px; - color: var(--muted); - padding: 10px 14px; - background: var(--panel); - border-left: 3px solid var(--accent); - border-radius: 0 var(--radius) var(--radius) 0; - margin-bottom: 16px; - line-height: 1.55; - border: 1px solid var(--border-soft); - border-left-width: 3px; -} - -.field { - display: flex; - flex-direction: column; - gap: 5px; -} - -.field label, -.field > span { - font-size: 10px; - color: var(--muted); - font-weight: 500; - letter-spacing: 0.06em; - text-transform: uppercase; -} - -.field-wide { - grid-column: 1 / -1; -} - -.field input, -.field select, -.form-row input, -.form-row select { - background: var(--bg-elevated); - border: 1px solid var(--border); - color: var(--text); - border-radius: 8px; - padding: 9px 11px; - font-size: 12px; - font-family: var(--mono); - width: 100%; -} - -.field input:focus, -.field select:focus { - outline: none; - border-color: var(--accent); - box-shadow: 0 0 0 2px rgba(0, 212, 255, 0.2), var(--glow); -} - -.field-check { - flex-direction: row; - align-items: center; - gap: 8px; - padding-top: 20px; -} - -.field-check label { - font-size: 12px; - color: var(--text); - cursor: pointer; - text-transform: none; -} - -.settings-display-panel, -.settings-macro-panel, -.settings-supervisor-panel { - margin-bottom: 0; -} - -.settings-section { - margin-bottom: 16px; -} - -.settings-section-head { - display: flex; - align-items: center; - gap: 10px; - padding: 14px 16px; - border-bottom: 1px solid var(--border-soft); -} - -.settings-section.is-collapsed .settings-section-head { - border-bottom-color: transparent; -} - -.settings-section-head .settings-display-title { - flex: 1; - margin: 0; - min-width: 0; -} - -.settings-section-head-actions { - display: flex; - align-items: center; - gap: 8px; - flex-shrink: 0; -} - -.settings-section-fold { - flex-shrink: 0; - width: 28px; - height: 28px; - padding: 0; - border: 1px solid var(--border-soft); - border-radius: 6px; - background: color-mix(in srgb, var(--panel) 90%, var(--accent) 10%); - color: var(--accent); - cursor: pointer; - font-size: 0; - line-height: 1; - transition: transform 0.15s ease, border-color 0.15s ease; - position: relative; -} - -.settings-section-fold::before { - content: "▾"; - font-size: 0.85rem; - line-height: 28px; - display: block; - text-align: center; -} - -.settings-section-fold:hover { - border-color: color-mix(in srgb, var(--accent) 50%, var(--border-soft)); -} - -.settings-section.is-collapsed .settings-section-fold::before { - content: "▸"; -} - -.settings-section-save { - flex-shrink: 0; - font-size: 0.82rem; - padding: 6px 14px; -} - -.settings-section-body { - padding: 14px 16px; -} - -.settings-section.is-collapsed .settings-section-body { - display: none; -} - -.settings-page-toolbar { - margin-top: 4px; -} - -.settings-card-topbar { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 12px; - padding-bottom: 10px; - border-bottom: 1px dashed var(--border-soft); -} - -.settings-card-fold { - flex-shrink: 0; - width: 26px; - height: 26px; - padding: 0; - border: 1px solid var(--border-soft); - border-radius: 6px; - background: transparent; - color: var(--muted); - cursor: pointer; - font-size: 0; - line-height: 1; - transition: color 0.15s ease, border-color 0.15s ease; - position: relative; -} - -.settings-card-fold::before { - content: "▾"; - font-size: 0.8rem; - line-height: 26px; - display: block; - text-align: center; -} - -.settings-card-fold:hover { - color: var(--accent); - border-color: color-mix(in srgb, var(--accent) 40%, var(--border-soft)); -} - -.settings-card.is-collapsed .settings-card-fold::before { - content: "▸"; -} - -.settings-card-title { - flex: 1; - min-width: 0; - font-size: 0.92rem; - font-weight: 600; - color: var(--text); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.settings-card-save { - flex-shrink: 0; - font-size: 0.78rem; - padding: 5px 12px; -} - -.settings-card-body { - display: block; -} - -.settings-card.is-collapsed .settings-card-body { - display: none; -} - -@media (max-width: 720px) { - .settings-section-head { - flex-wrap: wrap; - } - - .settings-section-head-actions { - width: 100%; - justify-content: flex-end; - } - - .settings-card-topbar { - flex-wrap: wrap; - } -} - -.settings-display-title { - margin: 0 0 10px; - font-size: 0.95rem; - color: var(--text); -} - -.settings-display-chk { - display: flex; - align-items: center; - gap: 8px; - font-size: 0.88rem; -} - -.settings-display-chk + .settings-display-chk { - margin-top: 8px; -} - -.settings-display-hint { - margin: 8px 0 0; - font-size: 0.78rem; - color: var(--muted); - line-height: 1.45; -} - -.backup-settings-grid { - margin-top: 12px; -} - -.backup-actions { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 12px; - margin-top: 16px; -} - -.backup-status-line { - font-size: 0.82rem; - color: var(--muted); -} - -.backup-status-line.err { - color: var(--danger, #f87171); -} - -.backup-restore-upload { - display: flex; - flex-wrap: wrap; - align-items: flex-end; - gap: 12px; - margin-top: 16px; - padding-top: 16px; - border-top: 1px solid var(--border); -} - -.backup-upload-label { - display: flex; - flex-direction: column; - gap: 6px; - font-size: 0.82rem; - color: var(--muted); -} - -.backup-list { - margin-top: 16px; -} - -.backup-meta { - font-size: 0.78rem; - color: var(--muted); - line-height: 1.5; - margin-bottom: 10px; -} - -.backup-meta code { - font-size: 0.76rem; -} - -.backup-empty { - font-size: 0.82rem; - color: var(--muted); -} - -.backup-table { - width: 100%; - border-collapse: collapse; - font-size: 0.82rem; -} - -.backup-table th, -.backup-table td { - padding: 8px 10px; - border-bottom: 1px solid var(--border); - text-align: left; -} - -.backup-row-actions { - white-space: nowrap; -} - -.backup-row-actions .ghost, -.backup-row-actions .danger { - font-size: 0.78rem; - padding: 4px 8px; -} - -.settings-card { - background: var(--panel); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 16px; - backdrop-filter: blur(10px); -} - -.settings-card-head { - display: flex; - align-items: center; - gap: 12px; - margin-bottom: 14px; - flex-wrap: wrap; -} - -.settings-card-head .ex-name { - flex: 1; - min-width: 160px; - font-size: 14px; - font-weight: 600; - font-family: var(--display); - background: transparent; - border: none; - border-bottom: 1px dashed var(--border); - color: var(--text); - padding: 4px 0; -} - -.settings-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 12px; -} - -.settings-grid .field input { - font-size: 11px; -} - -.cap-chips { - display: flex; - gap: 10px; - flex-wrap: wrap; - padding: 8px 0; -} - -.cap-chips label { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 11px; - color: var(--text); - cursor: pointer; - padding: 6px 12px; - background: rgba(0, 0, 0, 0.35); - border-radius: 999px; - border: 1px solid var(--border-soft); -} - -.settings-card-foot { - display: flex; - justify-content: space-between; - align-items: center; - margin-top: 12px; - padding-top: 12px; - border-top: 1px solid var(--border-soft); -} - -.settings-card-foot .field { - max-width: 80px; -} - -#toast { - position: fixed; - bottom: 20px; - right: 20px; - max-width: min(420px, 92vw); - background: var(--panel); - border: 1px solid var(--accent); - padding: 12px 16px; - border-radius: var(--radius); - display: none; - z-index: 50; - white-space: pre-wrap; - font-size: 12px; - box-shadow: var(--glow); - backdrop-filter: blur(12px); -} - -#toast.show { - display: block; -} - -/* —— 登录页 —— */ -body.login-page { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - min-height: 100vh; - padding: 24px; -} - -.login-theme-bar { - position: relative; - z-index: 2; - width: 100%; - max-width: 400px; - display: flex; - justify-content: flex-end; - margin-bottom: 10px; -} - -.login-panel { - position: relative; - z-index: 1; - width: 100%; - max-width: 400px; - padding: 28px 26px; - background: var(--panel); - border: 1px solid var(--border); - border-radius: 12px; - backdrop-filter: blur(16px); - box-shadow: var(--shadow), var(--glow); -} - -.login-brand { - display: flex; - align-items: center; - gap: 14px; - margin-bottom: 24px; -} - -.login-title { - font-family: var(--display); - font-size: 16px; - font-weight: 600; - letter-spacing: 0.08em; -} - -.login-sub { - font-size: 10px; - color: var(--muted); - letter-spacing: 0.16em; - margin-top: 4px; -} - -.login-form .field { - margin-bottom: 16px; -} - -.login-submit { - width: 100%; - padding: 12px; -} - -.login-err { - color: var(--red); - font-size: 12px; - margin: 10px 0 0; -} - -.login-foot { - margin: 20px 0 0; - font-size: 10px; - color: var(--muted); - line-height: 1.5; -} -.login-foot code { - color: var(--accent); - font-size: 10px; -} - -/* —— 手机 / 窄屏自适应 —— */ -@media (max-width: 720px) { - .app-shell { - padding: 0 max(12px, env(safe-area-inset-right)) max(28px, env(safe-area-inset-bottom)) - max(12px, env(safe-area-inset-left)); - } - - .app-header { - flex-direction: column; - align-items: stretch; - gap: 12px; - padding: 14px 0; - } - - .brand-sub { - display: none; - } - - .app-header { - padding: 10px 0; - margin-bottom: 4px; - } - - .header-right { - width: 100%; - display: grid; - grid-template-columns: 1fr auto auto; - grid-template-rows: auto auto; - align-items: center; - gap: 8px; - } - - .header-right .theme-toggle { - grid-column: 1; - justify-self: start; - } - - .sys-pill { - grid-column: 2; - align-self: center; - } - - button.ghost#btn-logout { - grid-column: 3; - width: auto; - min-height: 36px; - padding: 6px 12px; - justify-self: end; - } - - .top-nav { - grid-column: 1 / -1; - width: 100%; - display: flex; - flex-wrap: nowrap; - overflow-x: auto; - -webkit-overflow-scrolling: touch; - scrollbar-width: none; - gap: 6px; - padding-bottom: 2px; - } - - .top-nav::-webkit-scrollbar { - display: none; - } - - .top-nav a { - flex: 0 0 auto; - text-align: center; - padding: 8px 14px; - min-height: 40px; - display: inline-flex; - align-items: center; - justify-content: center; - white-space: nowrap; - } - - .page-desc { - display: none; - } - - .market-toolbar { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px; - align-items: end; - } - - .market-field { - min-width: 0; - } - - .market-field select, - .market-field input { - width: 100%; - min-width: 0; - } - - .market-field-symbol { - grid-column: 1 / -1; - } - - .market-toolbar .toolbar-spacer { - display: none; - } - - .market-toolbar #market-load { - grid-column: 1; - } - - .market-toolbar #market-refresh { - grid-column: 2; - } - - .market-toolbar .toolbar-meta { - grid-column: 1 / -1; - text-align: left; - font-size: 0.72rem; - } - - .market-chart-wrap { - min-height: 260px; - height: min(52vh, 420px); - } - - .archive-toolbar { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px 10px; - align-items: center; - } - - .archive-toolbar .archive-field { - grid-column: 1 / -1; - } - - .archive-toolbar .chk-label { - margin: 0; - min-height: 36px; - justify-content: flex-start; - } - - .archive-toolbar #archive-btn-refresh { - grid-column: 1; - } - - .archive-toolbar #archive-btn-sync { - grid-column: 2; - } - - .archive-toolbar .toolbar-meta { - grid-column: 1 / -1; - text-align: left; - } - - body.hub-page-ai .page-head { - margin: 4px 0 6px; - } - - body.hub-page-ai .page-head h1 { - margin: 0; - font-size: 15px; - } - - .page-head { - margin: 16px 0 12px; - } - - .page-head h1 { - font-size: 17px; - flex-wrap: wrap; - } - - .toolbar { - flex-direction: column; - align-items: stretch; - gap: 8px; - } - - .toolbar-spacer { - display: none; - } - - .toolbar-meta { - text-align: center; - order: 10; - } - - .toolbar button, - .toolbar .chk-label { - width: 100%; - justify-content: center; - min-height: 44px; - } - - .grid-monitor:not(.grid-monitor-tiles), - .settings-grid-wrap { - grid-template-columns: minmax(0, 1fr) !important; - gap: 12px; - } - - .grid-monitor.grid-monitor-tiles { - grid-template-columns: repeat(2, minmax(0, 1fr)) !important; - gap: 10px; - } - - #page-monitor .page-head { - margin-bottom: 8px; - } - - #page-monitor .page-head h1 { - margin-bottom: 0; - } - - .monitor-alert-summary { - margin-bottom: 8px; - } - - .host-status-panel { - margin-bottom: 10px; - } - - .host-status-summary { - flex-wrap: wrap; - padding: 8px 10px; - } - - .host-status-bar { - padding: 10px; - } - - .host-status-top { - flex-direction: column; - align-items: stretch; - } - - .host-status-meta { - justify-content: flex-start; - } - - .host-status-metrics { - grid-template-columns: minmax(0, 1fr); - gap: 8px; - } - - .card-head { - flex-direction: column; - align-items: stretch; - gap: 10px; - } - - .card-actions { - flex-wrap: wrap; - width: 100%; - gap: 8px; - } - - .card-actions .btn-link, - .card-actions button { - flex: 1 1 calc(50% - 4px); - min-height: 40px; - text-align: center; - justify-content: center; - } - - .card-body { - padding: 12px; - overflow-x: auto; - -webkit-overflow-scrolling: touch; - } - - .stat-row { - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 6px; - } - - .stat-value { - font-size: 14px; - } - - .stat-label { - font-size: 9px; - } - - .instance-frame-toolbar { - flex-wrap: wrap; - gap: 8px; - padding: 8px 10px; - } - - .instance-frame-title { - flex: 1 1 100%; - order: -1; - font-size: 0.82rem; - } - - .instance-frame-actions { - flex: 1 1 auto; - justify-content: flex-end; - } - - .instance-frame { - height: calc(100dvh - 96px); - } - - .exchange-fullscreen { - padding: max(10px, env(safe-area-inset-top)) max(10px, env(safe-area-inset-right)) - max(16px, env(safe-area-inset-bottom)) max(10px, env(safe-area-inset-left)); - } - - .exchange-fullscreen-panel { - max-width: 100%; - } - - .fs-head { - flex-direction: column; - align-items: stretch; - gap: 12px; - } - - .fs-head-actions { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px; - width: 100%; - } - - .fs-head-actions .btn-expand-back { - grid-column: 1 / -1; - } - - .fs-head-actions .btn-open-trade { - grid-column: 1 / -1; - } - - .fs-head-actions .btn-link, - .fs-head-actions button { - min-height: 44px; - text-align: center; - justify-content: center; - } - - .hub-pos-card .pos-card-head { - flex-direction: column; - align-items: stretch; - } - - .hub-pos-card .pos-head-actions { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px; - width: 100%; - } - - .hub-pos-card .pos-entrust-btn, - .hub-pos-card .pos-close-btn { - width: 100%; - min-height: 44px; - text-align: center; - } - - .hub-pos-card .pos-ex-order-row { - flex-direction: column; - align-items: stretch; - gap: 6px; - } - - .settings-grid { - grid-template-columns: 1fr; - } - - .settings-card-foot { - flex-direction: column; - align-items: stretch; - gap: 10px; - } - - .settings-card-foot .field { - max-width: none; - } - - .modal { - padding: max(12px, env(safe-area-inset-top)) 12px max(12px, env(safe-area-inset-bottom)); - align-items: flex-end; - } - - .modal-panel { - max-width: none; - width: 100%; - border-radius: 12px 12px 0 0; - max-height: 90vh; - overflow-y: auto; - } - - .modal-actions { - flex-direction: column-reverse; - } - - .modal-actions button { - width: 100%; - min-height: 44px; - } - - #toast { - left: 12px; - right: 12px; - bottom: max(12px, env(safe-area-inset-bottom)); - max-width: none; - } - - body.login-page { - padding: max(16px, env(safe-area-inset-top)) 16px max(16px, env(safe-area-inset-bottom)); - } - - .login-panel { - padding: 22px 18px; - } -} - -@media (max-width: 480px) { - body { - font-size: 12px; - } - - .brand-title { - font-size: 13px; - } - - .stat-row { - grid-template-columns: 1fr; - } - - .card-actions .btn-link, - .card-actions button { - flex: 1 1 100%; - } - - .fs-head-actions { - grid-template-columns: 1fr; - } - - .pos-action-group { - flex-direction: column; - align-items: stretch; - width: 100%; - } - - .pos-action-group .btn-sm { - width: 100%; - min-height: 44px; - } - - .data-table .td-actions { - white-space: normal; - } -} - -/* ---------- 行情区 ---------- */ -.market-toolbar { - flex-wrap: wrap; - gap: 10px; - align-items: flex-end; -} - -.market-field { - display: flex; - flex-direction: column; - gap: 4px; - font-size: 0.72rem; - color: var(--muted); -} - -.market-field select, -.market-field input { - min-width: 120px; - padding: 8px 10px; - border-radius: 8px; - border: 1px solid var(--border-soft); - background: var(--bg-elevated); - color: var(--text); - font-family: var(--font); -} - -.market-status { - font-size: 0.8rem; - color: var(--muted); - margin: 0 0 10px; -} - -.market-status.err { - color: var(--red); -} - -.market-status.warn { - color: #ffb84d; -} - -.market-countdown { - color: var(--accent); - font-variant-numeric: tabular-nums; -} - -.market-countdown.market-tf-key-hint { - color: #ffb84d; -} - -.market-chart-wrap { - display: flex; - flex-direction: column; - height: min(76vh, 680px); - min-height: 380px; - border: 1px solid var(--border-soft); - border-radius: var(--radius); - background: var(--chart-surface); - overflow: hidden; -} - -.market-chart-wrap.has-pos-panel { - height: min(80vh, 740px); - min-height: 440px; -} - -.market-chart-wrap.is-fullscreen { - position: fixed; - inset: 0; - z-index: 8500; - width: 100vw; - height: 100vh !important; - max-height: none; - min-height: 0; - border-radius: 0; - border: none; -} - -.market-chart-wrap.is-fullscreen.has-pos-panel { - height: 100vh !important; -} - -.market-chart-actions { - margin-left: auto; - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 6px 10px; -} - -.market-day-split-opt { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 0.72rem; - color: var(--muted); - cursor: pointer; - user-select: none; - padding: 2px 8px; - border-radius: 4px; - border: 1px solid var(--border-soft); - white-space: nowrap; -} - -.market-day-split-opt:hover { - color: var(--text); - border-color: var(--border); -} - -.market-day-split-opt input { - accent-color: #3b82f6; -} - -.market-day-split-opt:has(input:checked) { - color: #3b82f6; - border-color: rgba(59, 130, 246, 0.45); -} - -.market-ind-menu { - position: relative; - font-size: 0.72rem; -} - -.market-ind-menu summary { - cursor: pointer; - list-style: none; - padding: 2px 10px; - border-radius: 4px; - border: 1px solid var(--border-soft); - color: var(--muted); - user-select: none; -} - -.market-ind-menu summary::-webkit-details-marker { - display: none; -} - -.market-ind-menu[open] summary { - color: var(--accent); - border-color: rgba(0, 255, 157, 0.35); -} - -.market-ind-options { - position: absolute; - right: 0; - top: calc(100% + 4px); - z-index: 20; - min-width: 168px; - padding: 8px 10px; - border-radius: 6px; - border: 1px solid var(--border-soft); - background: var(--panel-solid); - box-shadow: var(--shadow); - display: flex; - flex-direction: column; - gap: 6px; -} - -.market-ind-opt { - display: flex; - align-items: center; - gap: 8px; - cursor: pointer; - color: var(--text); - white-space: nowrap; -} - -.market-ind-opt input { - accent-color: var(--accent); -} - -.market-fs-btn, -.market-fs-exit { - font-size: 0.72rem; - padding: 2px 10px; -} - -.market-fs-exit { - position: absolute; - top: 8px; - left: 8px; - z-index: 12; -} - -.market-chart-wrap.is-fullscreen .market-fs-exit:not(.hidden) { - display: inline-flex !important; -} - -.market-chart-wrap.is-fullscreen .market-fs-btn { - display: none; -} - -.market-fs-toolbar { - display: flex; - flex-wrap: wrap; - align-items: flex-end; - gap: 8px 12px; - margin-top: 8px; - padding-top: 8px; - border-top: 1px solid var(--border-soft); -} - -.market-fs-toolbar.hidden { - display: none; -} - -.market-fs-field.market-field-symbol .market-symbol-wrap { - min-width: 180px; -} - -.market-fs-field span { - font-size: 0.68rem; - color: var(--muted); -} - -.market-fs-field select, -.market-fs-field input { - font-size: 0.78rem; - min-width: 100px; -} - -.market-div-legend { - margin-top: 4px; - font-size: 0.72rem; - color: #ffb84d; - line-height: 1.4; -} - -.market-div-legend.hidden { - display: none; -} - -.market-ohlcv-bar { - flex: 0 0 auto; - padding: 8px 12px; - border-bottom: 1px solid var(--border-soft); - background: var(--chart-bar-bg); - font-size: 0.78rem; -} - -.market-chart-body { - flex: 1; - display: flex; - flex-direction: row; - min-height: 0; - position: relative; -} - -.market-draw-toolbar { - flex: 0 0 40px; - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; - padding: 6px 4px; - border-right: 1px solid var(--border-soft); - background: var(--chart-bar-bg); - z-index: 4; - overflow-y: auto; -} - -.market-draw-btn { - width: 32px; - height: 32px; - padding: 0; - display: inline-flex; - align-items: center; - justify-content: center; - border: 1px solid transparent; - border-radius: 6px; - background: transparent; - color: var(--muted); - cursor: pointer; - flex-shrink: 0; -} - -.market-draw-btn svg { - width: 18px; - height: 18px; -} - -.market-draw-btn-text { - font-size: 0.82rem; - font-weight: 700; - font-family: var(--font); -} - -.market-draw-btn:hover { - color: var(--text); - background: var(--inset-surface); - border-color: var(--border-soft); -} - -.market-draw-btn.is-active { - color: var(--accent); - background: rgba(0, 255, 157, 0.1); - border-color: rgba(0, 255, 157, 0.35); -} - -.market-draw-sep { - width: 22px; - height: 1px; - background: var(--border-soft); - margin: 2px 0; -} - -.market-chart-main { - flex: 1; - min-width: 0; - height: 100%; - position: relative; - display: flex; -} - -.market-chart-host { - flex: 1; - min-width: 0; - height: 100%; - position: relative; - overflow: hidden; -} - -.market-draw-canvas { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: 20; - pointer-events: none; - touch-action: none; -} - -.market-draw-canvas.is-drawing { - cursor: crosshair; - pointer-events: auto; -} - -.market-field-symbol .market-symbol-wrap { - display: flex; - align-items: stretch; - gap: 6px; - min-width: 0; -} - -.market-field-symbol .market-symbol-wrap > input { - flex: 1; - min-width: 120px; -} - -.market-vol-rank-btn { - flex: 0 0 auto; - min-height: 34px; - padding: 0 10px; - border: 1px solid var(--border-soft); - border-radius: 6px; - background: var(--inset-surface); - color: var(--accent); - font-size: 0.78rem; - font-weight: 600; - font-family: var(--font); - white-space: nowrap; - cursor: pointer; -} - -.market-vol-rank-btn:hover { - border-color: rgba(0, 255, 157, 0.35); - background: rgba(0, 255, 157, 0.08); -} - -.market-vol-rank-btn.is-active { - border-color: rgba(0, 255, 157, 0.45); - background: rgba(0, 255, 157, 0.12); - color: var(--accent); -} - -.market-vol-rank-anchor { - margin: -6px 0 12px; -} - -.market-vol-rank-anchor:empty, -.market-vol-rank-anchor-fs:empty { - display: none; -} - -.market-vol-rank-sheet { - padding: 10px 12px 8px; - border: 1px solid var(--border-soft); - border-radius: var(--radius); - background: var(--panel); - box-shadow: var(--glow); -} - -.market-chart-wrap .market-vol-rank-sheet { - margin: 0; - border-radius: 0; - border-left: none; - border-right: none; - box-shadow: none; -} - -.market-chart-wrap.is-fullscreen .market-vol-rank-sheet { - background: var(--chart-bar-bg); -} - -.market-vol-rank-sheet.hidden { - display: none; -} - -.market-vol-rank-meta { - padding: 0 10px 6px; - font-size: 0.68rem; - color: var(--muted); - line-height: 1.35; -} - -.market-vol-rank-list { - margin: 0; - padding: 0; - list-style: none; - display: grid; - grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); - gap: 2px 12px; - max-height: 200px; - overflow: auto; -} - -.market-vol-rank-item { - width: 100%; - display: grid; - grid-template-columns: 28px 1fr auto; - gap: 6px; - align-items: center; - padding: 6px 10px; - border: 0; - background: transparent; - color: var(--text); - font-size: 0.8rem; - font-family: var(--font); - text-align: left; - cursor: pointer; -} - -.market-vol-rank-item:hover { - background: var(--inset-surface); -} - -.market-vol-rank-item.is-active { - background: rgba(0, 255, 157, 0.1); - color: var(--accent); -} - -.market-vol-rank-no { - color: var(--muted); - font-variant-numeric: tabular-nums; -} - -.market-vol-rank-sym { - font-weight: 600; -} - -.market-vol-rank-vol { - color: var(--muted); - font-size: 0.72rem; - font-variant-numeric: tabular-nums; -} - -.market-draw-menu { - position: fixed; - z-index: 1200; - min-width: 168px; - padding: 4px 0; - border: 1px solid var(--border-soft); - border-radius: 8px; - background: var(--panel-bg, #1a1f2e); - box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45); -} - -.market-draw-menu.hidden { - display: none; -} - -.market-draw-menu-head { - padding: 6px 12px 4px; - font-size: 0.72rem; - font-weight: 600; - color: var(--muted); - text-transform: none; -} - -.market-draw-menu-item { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - padding: 7px 12px; - border: 0; - background: transparent; - color: var(--text); - font-size: 0.82rem; - font-family: var(--font); - text-align: left; - cursor: pointer; -} - -.market-draw-menu-item:hover:not(:disabled) { - background: var(--inset-surface); -} - -.market-draw-menu-item:disabled { - opacity: 0.45; - cursor: not-allowed; -} - -.market-draw-menu-item.is-danger { - color: #f87171; -} - -.market-draw-menu-sep { - border: 0; - border-top: 1px solid var(--border-soft); - margin: 4px 0; -} - -.market-draw-menu-kbd { - margin-left: 12px; - padding: 1px 5px; - border-radius: 4px; - background: var(--inset-surface); - color: var(--muted); - font-size: 0.68rem; -} - -.market-exchange-badge { - position: absolute; - left: 50%; - top: 50%; - z-index: 1; - transform: translate(-50%, -50%) rotate(-90deg); - transform-origin: center center; - font-family: var(--font-display, var(--font)); - font-size: 0.95rem; - font-weight: 600; - letter-spacing: 0.12em; - color: var(--muted); - opacity: 0.22; - pointer-events: none; - white-space: nowrap; - user-select: none; -} - -.market-exchange-badge:empty { - display: none; -} - -.market-ohlcv-title { - font-weight: 600; - color: var(--accent); - margin-bottom: 4px; - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 6px 10px; -} - -.mkt-exchange-tag { - padding: 1px 8px; - border-radius: 4px; - background: rgba(0, 255, 157, 0.12); - border: 1px solid rgba(0, 255, 157, 0.35); - color: var(--green); - font-size: 0.72rem; - font-weight: 600; -} - -.mkt-exchange-tag:empty { - display: none; -} - -.market-ohlcv-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 4px 14px; - font-weight: 600; -} - -.market-ohlcv-row .ohlcv-item { - white-space: nowrap; -} - -.market-ohlcv-row .k { - color: var(--muted); - margin-right: 4px; -} - -.market-pos-panel { - flex: 0 0 auto; - padding: 8px 12px 10px; - border-bottom: 1px solid var(--border-soft); - background: var(--chart-bar-bg); - color: var(--text); - font-size: 0.8rem; -} - -.market-pos-panel.hidden { - display: none; -} - -.market-pos-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 4px 14px; -} - -.market-pos-side { - padding: 1px 8px; - border-radius: 4px; - font-size: 0.72rem; - font-weight: 600; -} - -.market-pos-side.side-long { - background: rgba(0, 255, 157, 0.12); - border: 1px solid rgba(0, 255, 157, 0.35); - color: var(--green); -} - -.market-pos-side.side-short { - background: rgba(255, 77, 109, 0.12); - border: 1px solid rgba(255, 77, 109, 0.35); - color: var(--red); -} - -.market-pos-clear { - margin-left: auto; - font-size: 0.72rem; - padding: 2px 8px; -} - -.market-pos-pnl { - font-weight: 700; - font-variant-numeric: tabular-nums; -} - -.market-pos-pnl.pnl-up { - color: #3ddc84; -} - -.market-pos-pnl.pnl-down { - color: #ff7070; -} - -.market-pos-panel .ohlcv-item { - font-weight: 600; - color: var(--text); -} - -.market-pos-panel .ohlcv-item .k { - font-weight: 600; - color: var(--muted); -} - -.market-pos-orders { - display: flex; - flex-wrap: wrap; - gap: 4px 10px; - margin-top: 6px; - color: var(--text); - font-weight: 500; -} - -.market-pos-orders-empty { - font-size: 0.72rem; - opacity: 0.75; -} - -.market-pos-order { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 2px 8px; - border-radius: 4px; - background: var(--inset-surface); - border: 1px solid var(--border-soft); - white-space: nowrap; - font-weight: 500; -} - -.market-pos-order-kind { - color: var(--accent); - font-size: 0.68rem; -} - -.market-pos-order-label { - color: var(--text); -} - -.market-pos-order-price { - color: #c98a20; - font-family: var(--font-mono, monospace); - font-weight: 600; -} - -.market-pos-order-amt { - color: var(--muted); - font-size: 0.68rem; -} - -.market-pos-tp-monitored { - color: var(--accent); - font-size: 0.72rem; - font-weight: 600; -} - -.sym-link { - background: none; - border: none; - padding: 0; - margin: 0; - font: inherit; - color: var(--accent); - cursor: pointer; - text-align: left; - text-decoration: underline; - text-underline-offset: 2px; -} - -.sym-link:hover { - color: #00ff9d; -} - -.pos-symbol-link { - display: inline; -} - -.pos-symbol-link strong { - font-weight: inherit; -} - -.market-price-tag { - position: absolute; - right: 0; - z-index: 5; - pointer-events: none; - padding: 4px 8px; - border-radius: 4px 0 0 4px; - font-family: var(--font); - font-size: 0.72rem; - font-weight: 600; - line-height: 1.25; - text-align: center; - transform: translateY(-50%); - min-width: 72px; - box-shadow: 0 1px 6px rgba(0, 0, 0, 0.35); -} - -.market-price-tag-head { - display: flex; - flex-direction: row; - align-items: baseline; - justify-content: center; - gap: 4px; - font-variant-numeric: tabular-nums; - white-space: nowrap; -} - -.market-price-tag-label { - font-size: 0.62rem; - font-weight: 500; - opacity: 0.9; - line-height: 1; -} - -.market-price-tag.is-up .market-price-tag-label { - color: rgba(10, 16, 24, 0.75); -} - -.market-price-tag.is-down .market-price-tag-label { - color: rgba(255, 255, 255, 0.85); -} - -.market-price-tag.hidden { - display: none; -} - -.market-price-tag.is-up { - background: #00ff9d; - color: #0a1018; -} - -.market-price-tag.is-down { - background: #ff4d6d; - color: #fff; -} - -.market-price-tag-value { - font-variant-numeric: tabular-nums; -} - -.market-price-tag-time { - margin-top: 3px; - font-size: 0.68rem; - font-weight: 500; - font-variant-numeric: tabular-nums; - line-height: 1; - opacity: 0.95; -} - -.market-price-auto { - position: absolute; - right: 8px; - bottom: 10px; - z-index: 5; - width: auto; - padding: 4px 8px; - font-size: 0.68rem; - font-family: var(--font); - border-radius: 6px; - border: 1px solid var(--border-soft); - background: var(--chart-bar-bg); - color: var(--muted); - cursor: pointer; - line-height: 1.2; -} - -.market-price-auto:hover { - border-color: var(--accent); - color: var(--text); -} - -.market-price-auto.is-on { - color: var(--green); - border-color: rgba(0, 255, 157, 0.45); - background: rgba(0, 255, 157, 0.1); -} - -.market-chart-wrap.is-fullscreen { - background: var(--bg); -} - -.market-chart-wrap.is-fullscreen .market-ohlcv-bar, -.market-chart-wrap.is-fullscreen .market-fs-toolbar { - background: var(--chart-bar-bg); -} - -/* —— 亮色主题:对比度与全屏/放大 —— */ -html[data-theme="light"] .app-bg, -html[data-theme="light"] .login-bg { - background: - linear-gradient(rgba(0, 90, 130, 0.07) 1px, transparent 1px), - linear-gradient(90deg, rgba(0, 90, 130, 0.07) 1px, transparent 1px), - radial-gradient(ellipse 80% 50% at 50% -20%, rgba(0, 120, 180, 0.08), transparent), - radial-gradient(ellipse 60% 40% at 100% 100%, rgba(80, 70, 180, 0.05), transparent); - background-size: 48px 48px, 48px 48px, auto, auto; -} - -html[data-theme="light"] .app-bg::after, -html[data-theme="light"] .login-bg::after { - opacity: 0.12; -} - -html[data-theme="light"] a:hover { - text-shadow: none; -} - -html[data-theme="light"] .side-long, -html[data-theme="light"] .side-short { - text-shadow: none; -} - -html[data-theme="light"] .top-nav a.active { - background: linear-gradient(135deg, rgba(0, 110, 154, 0.14), rgba(91, 79, 199, 0.08)); - box-shadow: none; -} - -html[data-theme="light"] .mkt-exchange-tag { - background: rgba(10, 143, 92, 0.1); - border-color: rgba(10, 143, 92, 0.32); -} - -html[data-theme="light"] .market-ind-menu[open] summary { - border-color: rgba(10, 143, 92, 0.35); -} - -html[data-theme="light"] .market-price-auto.is-on { - border-color: rgba(10, 143, 92, 0.4); -} - -html[data-theme="light"] .market-price-tag.is-up { - background: var(--green); - color: #fff; -} - -html[data-theme="light"] .market-price-tag.is-up .market-price-tag-label { - color: rgba(255, 255, 255, 0.9); -} - -html[data-theme="light"] .market-price-tag { - box-shadow: 0 1px 4px rgba(30, 60, 100, 0.15); -} - -html[data-theme="light"] .stat-box { - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65); -} - -html[data-theme="light"] .card-stat-chip.card-stat-key-breakout { - background: rgba(0, 110, 154, 0.1); - border-color: rgba(0, 110, 154, 0.28); -} - -html[data-theme="light"] .card-stat-chip.card-stat-trend { - background: rgba(10, 143, 92, 0.1); - border-color: rgba(10, 143, 92, 0.28); -} - -html[data-theme="light"] .card-stat-chip.card-stat-key-watch { - background: rgba(91, 79, 199, 0.1); - border-color: rgba(91, 79, 199, 0.28); -} - -html[data-theme="light"] .hub-pos-card .pos-entrust-btn { - background: rgba(0, 110, 154, 0.1); - color: var(--accent); - border-color: var(--border-soft); -} - -html[data-theme="light"] .hub-pos-card .pos-value.pnl-pos { - text-shadow: none; -} - -html[data-theme="light"] .exchange-fullscreen-panel, -html[data-theme="light"] .modal-panel { - box-shadow: var(--shadow); -} - -html[data-theme="light"] input, -html[data-theme="light"] select, -html[data-theme="light"] textarea { - background: var(--bg-elevated); - color: var(--text); - border-color: var(--border-soft); -} - -html[data-theme="light"] .hub-tile, -html[data-theme="light"] .card, -html[data-theme="light"] .hub-pos-card, -html[data-theme="light"] .hub-trend-plan-card, -html[data-theme="light"] .settings-row { - box-shadow: 0 2px 10px rgba(30, 60, 100, 0.08); -} - -html[data-theme="light"] button.primary, -html[data-theme="light"] .market-toolbar button.primary, -html[data-theme="light"] #market-load, -html[data-theme="light"] #market-fs-load, -html[data-theme="light"] #btn-monitor-refresh { - background: #006e9a; - border-color: #005a82; - color: #fff; - font-weight: 700; - box-shadow: 0 2px 8px rgba(0, 95, 140, 0.28); -} - -html[data-theme="light"] button.primary:hover:not(:disabled), -html[data-theme="light"] #market-load:hover:not(:disabled), -html[data-theme="light"] #market-fs-load:hover:not(:disabled), -html[data-theme="light"] #btn-monitor-refresh:hover:not(:disabled) { - background: #0088b8; - color: #fff; - box-shadow: 0 3px 12px rgba(0, 95, 140, 0.35); -} - -html[data-theme="light"] .market-pos-panel { - background: var(--chart-bar-bg); - color: var(--text); -} - -html[data-theme="light"] .market-pos-side.side-long { - background: rgba(10, 143, 92, 0.12); - border-color: rgba(10, 143, 92, 0.35); -} - -html[data-theme="light"] .market-pos-side.side-short { - background: rgba(201, 53, 82, 0.1); - border-color: rgba(201, 53, 82, 0.35); -} - -html[data-theme="light"] .market-pos-order { - background: var(--inset-surface-strong); -} - -html[data-theme="light"] .market-pos-order-price { - color: #9a6b10; -} - -html[data-theme="light"] .market-pos-pnl.pnl-up { - color: #0a7a3d; -} - -html[data-theme="light"] .market-pos-pnl.pnl-down { - color: #c62828; -} - -html[data-theme="light"] .market-pos-clear { - font-weight: 600; - color: var(--text); - border-color: var(--border); - background: var(--bg-elevated); -} - -html[data-theme="light"] .market-status { - font-weight: 600; - color: var(--text); - opacity: 0.88; -} - -html[data-theme="light"] .toolbar-meta { - font-weight: 600; - color: var(--text); - opacity: 0.85; -} - -html[data-theme="light"] .chk-label { - font-weight: 600; - color: var(--text); -} - -html[data-theme="light"] .hub-trend-plan-card .plan-dca-table { - font-size: 0.8rem; -} - -html[data-theme="light"] .hub-trend-plan-card .plan-dca-table th { - font-weight: 700; - color: var(--text); -} - -html[data-theme="light"] button.danger { - font-weight: 600; - background: rgba(201, 53, 82, 0.1); - border-color: rgba(201, 53, 82, 0.45); -} - -/* --- Hub AI 教练(整页一屏,内容区内滚动)--- */ -body.hub-page-ai { - overflow: hidden; - height: 100dvh; - max-height: 100dvh; -} -body.hub-page-ai .app-shell { - padding-bottom: 12px; - height: 100dvh; - max-height: 100dvh; - overflow: hidden; - display: flex; - flex-direction: column; - box-sizing: border-box; -} -body.hub-page-ai .app-shell > #page-ai { - flex: 1 1 auto; - min-height: 0; - display: flex; - flex-direction: column; - overflow: hidden; -} -body.hub-page-ai .app-header { - flex-shrink: 0; - margin-bottom: 4px; -} -body.hub-page-ai #page-ai { - flex: 1 1 auto; - min-height: 0; - display: flex; - flex-direction: column; - overflow: hidden; -} -#page-ai .page-head { - flex-shrink: 0; - margin: 8px 0 10px; -} -#page-ai .page-head h1 { - margin-bottom: 4px; - font-size: 18px; -} -#page-ai .page-desc { - margin: 0; - font-size: 0.78rem; - line-height: 1.35; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.ai-layout { - flex: 1 1 auto; - min-height: 0; - display: flex; - flex-direction: column; - align-items: stretch; - overflow: hidden; -} -.ai-layout .ai-chat-panel { - flex: 1 1 auto; - min-height: 0; -} -.ai-mobile-tabs { - display: none; -} - -/* 手机 AI:须在 .ai-layout 双列定义之后,避免被覆盖成半屏 */ -@media (max-width: 720px), ((display-mode: standalone) and (max-width: 960px)) { - html:has(body.hub-page-ai) { - height: 100%; - overflow: hidden; - } - - body.hub-page-ai .app-shell { - padding-bottom: max(8px, env(safe-area-inset-bottom)); - height: var(--hub-vvh, 100dvh); - max-height: var(--hub-vvh, 100dvh); - overflow: hidden; - width: 100%; - max-width: none; - box-sizing: border-box; - will-change: transform, height; - } - - body.hub-page-ai { - position: fixed; - inset: 0; - width: 100%; - overflow: hidden; - background: var(--bg); - overscroll-behavior: none; - } - - body.hub-page-ai .app-header { - padding: 6px 0; - margin-bottom: 2px; - gap: 8px; - } - - body.hub-page-ai .top-nav a { - min-height: 34px; - padding: 6px 10px; - font-size: 11px; - } - - body.hub-page-ai .app-header .brand { - display: none; - } - - body.hub-page-ai .header-right { - grid-template-columns: 1fr auto auto; - grid-template-rows: auto; - } - - body.hub-page-ai .header-right .top-nav { - grid-column: 1 / -1; - order: 2; - } - - body.hub-page-ai.hub-ai-keyboard-open .app-header .theme-toggle, - body.hub-page-ai.hub-ai-keyboard-open .app-header .sys-pill, - body.hub-page-ai.hub-ai-keyboard-open .app-header #btn-logout { - display: none; - } - - body.hub-page-ai.hub-ai-keyboard-open .app-header { - padding: 4px 0; - margin-bottom: 0; - } - - body.hub-page-ai.hub-ai-keyboard-open .top-nav a { - min-height: 30px; - padding: 4px 8px; - font-size: 10px; - } - - body.hub-page-ai #page-ai { - overflow: hidden; - width: 100%; - min-width: 0; - } - - body.hub-page-ai .ai-mobile-tabs { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 6px; - margin-bottom: 6px; - flex-shrink: 0; - width: 100%; - position: sticky; - top: 0; - z-index: 12; - padding: 4px 0 2px; - background: var(--bg); - } - - body.hub-page-ai .ai-mobile-tab { - min-height: 38px; - padding: 6px 4px; - border-radius: 8px; - border: 1px solid var(--border-soft); - background: var(--inset-surface); - color: var(--muted); - font-family: var(--font); - font-size: 0.7rem; - font-weight: 600; - cursor: pointer; - line-height: 1.2; - text-align: center; - } - - body.hub-page-ai .ai-mobile-tab-action { - color: var(--accent); - border-color: color-mix(in srgb, var(--accent) 35%, var(--border-soft)); - } - - body.hub-page-ai .ai-mobile-tab.is-active { - color: var(--text); - border-color: var(--accent); - background: var(--accent-dim); - box-shadow: none; - } - - body.hub-page-ai #page-ai .page-head { - display: none; - } - - body.hub-page-ai .ai-layout { - display: flex; - flex-direction: column; - width: 100%; - min-width: 0; - flex: 1 1 auto; - min-height: 0; - gap: 0; - overflow: hidden; - } - - body.hub-page-ai .ai-layout[data-ai-mobile-tab="trading"] .ai-chat-panel, - body.hub-page-ai .ai-layout[data-ai-mobile-tab="general"] .ai-chat-panel, - body.hub-page-ai .ai-layout[data-ai-mobile-tab="supervisor"] .ai-chat-panel, - body.hub-page-ai .ai-layout[data-ai-mobile-tab="history"] .ai-chat-panel { - display: flex; - flex: 1 1 auto; - width: 100%; - max-width: 100%; - min-height: 0; - min-width: 0; - } - - body.hub-page-ai .ai-layout[data-ai-mobile-tab="trading"] .ai-chat-history-panel, - body.hub-page-ai .ai-layout[data-ai-mobile-tab="general"] .ai-chat-history-panel, - body.hub-page-ai .ai-layout[data-ai-mobile-tab="supervisor"] .ai-chat-history-panel { - display: none !important; - } - - body.hub-page-ai .ai-layout[data-ai-mobile-tab="trading"] .ai-chat-main, - body.hub-page-ai .ai-layout[data-ai-mobile-tab="general"] .ai-chat-main, - body.hub-page-ai .ai-layout[data-ai-mobile-tab="supervisor"] .ai-chat-main { - display: flex; - flex: 1 1 auto; - min-height: 0; - flex-direction: column; - } - - body.hub-page-ai .ai-layout[data-ai-mobile-tab="history"] .ai-chat-main, - body.hub-page-ai .ai-layout[data-ai-mobile-tab="history"] .ai-chat-topbar { - display: none !important; - } - - body.hub-page-ai .ai-layout[data-ai-mobile-tab="history"] .ai-chat-history-panel { - display: flex; - flex: 1 1 auto; - min-height: 0; - border-left: none; - width: 100%; - } - - body.hub-page-ai .ai-panel { - width: 100%; - max-width: 100%; - min-width: 0; - box-sizing: border-box; - padding: 8px 10px; - gap: 6px; - } - - body.hub-page-ai .ai-chat-panel { - padding-bottom: 0; - display: flex; - flex-direction: column; - overflow: hidden; - border: none; - background: transparent; - } - - body.hub-page-ai .ai-chat-topbar { - display: none; - } - - body.hub-page-ai .ai-bot-tab { - min-height: 34px; - padding: 5px 8px; - font-size: 0.76rem; - } - - body.hub-page-ai .ai-bot-tab.is-active { - box-shadow: none; - } - - body.hub-page-ai .ai-chat-new-btn { - min-height: 34px; - padding: 5px 10px; - font-size: 0.76rem; - } - - body.hub-page-ai .ai-chat-split { - display: flex; - flex-direction: column; - flex: 1 1 auto; - min-height: 0; - border: none; - border-radius: 0; - } - - body.hub-page-ai .ai-chat-main { - flex: 1 1 auto; - min-height: 0; - display: flex; - flex-direction: column; - } - - body.hub-page-ai .ai-chat-session-head { - display: none; - } - - body.hub-page-ai .ai-chat-messages { - flex: 1 1 auto; - min-height: 0; - max-height: none; - padding: 4px 2px 8px; - overflow-x: hidden; - overflow-y: auto; - overscroll-behavior: contain; - -webkit-overflow-scrolling: touch; - } - - body.hub-page-ai .ai-layout[data-ai-mobile-tab="history"] .ai-chat-history-list { - flex: 1 1 auto; - min-height: 0; - overflow-y: auto; - overscroll-behavior: contain; - -webkit-overflow-scrolling: touch; - } - - body.hub-page-ai .ai-chat-form { - position: relative; - flex-shrink: 0; - z-index: 3; - width: 100%; - margin: 0; - padding: 8px 0 max(8px, env(safe-area-inset-bottom)); - background: var(--panel); - border-top: 1px solid var(--border-soft); - box-shadow: 0 -4px 14px rgba(0, 0, 0, 0.12); - } - - body.hub-page-ai .ai-chat-compose { - gap: 6px; - } - - body.hub-page-ai .ai-chat-form textarea { - min-height: 40px; - max-height: 96px; - font-size: 16px; - width: 100%; - padding: 8px 10px; - } - - body.hub-page-ai .ai-chat-compose-actions { - display: flex; - gap: 8px; - align-items: center; - width: 100%; - } - - body.hub-page-ai .ai-chat-pending-list { - width: 100%; - } - - body.hub-page-ai .ai-chat-upload-btn, - body.hub-page-ai #btn-ai-chat-send { - min-height: 40px; - flex-shrink: 0; - } - - body.hub-page-ai #btn-ai-chat-send { - min-width: 72px; - margin-left: auto; - font-weight: 600; - } - - body.hub-page-ai .ai-msg-row-user { - max-width: 90%; - } - - body.hub-page-ai .ai-msg-row-coach { - max-width: 100%; - } - - body.hub-page-ai .ai-bubble { - font-size: 0.86rem; - padding: 9px 11px; - } - - body.hub-page-ai .ai-msg-role { - font-size: 0.68rem; - } - - body.hub-page-ai .ai-chat-history-list { - padding: 6px 4px; - } - - body.hub-page-ai .ai-chat-history-item { - padding: 10px 12px; - } -} - -.ai-panel { - background: var(--panel); - border: 1px solid var(--border-soft); - border-radius: var(--radius); - padding: 12px 14px; - min-height: 0; - max-height: 100%; - height: 100%; - display: flex; - flex-direction: column; - gap: 10px; - overflow: hidden; -} -.ai-panel-head { - display: flex; - flex-wrap: wrap; - align-items: flex-start; - justify-content: space-between; - gap: 8px; - flex-shrink: 0; -} -.ai-panel-head h2 { - margin: 0; - font-size: 1rem; - font-family: var(--display); - letter-spacing: 0.04em; -} -.ai-panel-actions { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: flex-end; - gap: 8px; - max-width: 100%; -} -.ai-meta-line { - max-width: min(420px, 100%); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 0.72rem; -} -.ai-panel-scroll { - flex: 1 1 auto; - min-height: 0; - max-height: 100%; - overflow-x: hidden; - overflow-y: auto; - overscroll-behavior: contain; - scrollbar-gutter: stable; -} -.ai-panel-scroll::-webkit-scrollbar { - width: 6px; -} -.ai-panel-scroll::-webkit-scrollbar-thumb { - background: color-mix(in srgb, var(--muted) 45%, transparent); - border-radius: 999px; -} -.ai-stats-row { - display: flex; - flex-wrap: wrap; - gap: 8px 14px; - font-size: 0.82rem; - color: var(--muted); - flex-shrink: 0; -} -.ai-stat-chip { - padding: 4px 8px; - border-radius: 6px; - background: var(--inset-surface); - border: 1px solid var(--border-soft); -} -.ai-stat-chip strong { - color: var(--text); - margin-right: 4px; -} -.ai-stat-chip.pos, -.ai-stat-val.pos { - color: var(--green); - border-color: color-mix(in srgb, var(--green) 35%, transparent); -} -.ai-stat-chip.neg, -.ai-stat-val.neg { - color: var(--red); - border-color: color-mix(in srgb, var(--red) 35%, transparent); -} -.ai-stat-chip.pos strong, -.ai-stat-chip.neg strong { - color: inherit; - opacity: 0.85; -} -.ai-md-body { - padding: 12px; - border-radius: 8px; - background: var(--inset-surface); - border: 1px solid var(--border-soft); - font-size: 0.86rem; - line-height: 1.55; - color: var(--text); - overflow-wrap: anywhere; - word-break: break-word; -} -.ai-md-body.ai-result-md, -.ai-bubble-assistant.ai-result-md { - white-space: normal; -} -.ai-result-md p { - margin: 6px 0; - color: var(--text); -} -.ai-result-md ul, -.ai-result-md ol { - margin: 6px 0 8px 1.25em; - padding: 0 0 0 0.25em; - list-style-position: outside; -} -.ai-result-md ul { - list-style-type: disc; -} -.ai-result-md ol { - list-style-type: decimal; -} -.ai-result-md li { - margin: 5px 0; - line-height: 1.5; - display: list-item; -} -.ai-result-md strong { - color: var(--text); - font-weight: 600; -} -.ai-md-body.ai-result-md h2 { - font-size: 1.02rem; - color: var(--ai-sum-heading); - font-weight: 700; - margin: 14px 0 8px; - padding: 6px 0 6px 10px; - border-left: 3px solid var(--ai-sum-heading-border); - border-bottom: 1px solid var(--border-soft); - background: var(--ai-sum-heading-bg); - border-radius: 0 4px 4px 0; -} -.ai-md-body.ai-result-md h2:first-child { - margin-top: 0; -} -.ai-md-body.ai-result-md h3 { - font-size: 0.92rem; - color: var(--ai-sum-heading); - font-weight: 700; - margin: 16px 0 8px; - padding: 5px 0 5px 10px; - border-left: 3px solid var(--ai-sum-heading-border); - border-bottom: 1px solid var(--border-soft); - background: var(--ai-sum-heading-bg); - border-radius: 0 4px 4px 0; -} -.ai-md-body.ai-result-md h3:first-of-type { - margin-top: 4px; -} -.ai-md-body.ai-result-md h4 { - font-size: 0.92rem; - color: var(--ai-sum-heading); - font-weight: 700; - margin: 10px 0 6px; - padding: 4px 0 4px 8px; - border-left: 2px solid var(--ai-sum-heading-border); - background: var(--ai-sum-heading-bg); - border-radius: 0 4px 4px 0; -} -.ai-result-md h2 { - font-size: 1.02rem; - color: var(--accent-2, var(--accent)); - margin: 14px 0 8px; - padding-bottom: 4px; - border-bottom: 1px solid var(--border-soft); -} -.ai-result-md h3, -.ai-result-md h4 { - font-size: 0.92rem; - color: var(--accent-2, var(--accent)); - margin: 10px 0 6px; -} -.ai-result-md code { - background: color-mix(in srgb, var(--inset-surface) 70%, var(--border-soft)); - padding: 1px 4px; - border-radius: 4px; - font-size: 0.82em; -} -.ai-result-md .md-raw-block-title { - margin-top: 14px; - padding-top: 10px; - border-top: 1px dashed var(--border-soft); - color: var(--muted); - font-weight: 600; -} -.ai-bubble-assistant.ai-result-md p { - margin: 4px 0; -} -.ai-bubble-assistant.ai-result-md h2, -.ai-bubble-assistant.ai-result-md h3, -.ai-bubble-assistant.ai-result-md h4 { - margin: 8px 0 4px; - font-size: 0.92rem; - color: var(--accent); - border-bottom: none; - padding-bottom: 0; -} -.ai-bubble-assistant.ai-result-md strong { - color: var(--accent); -} -.ai-bubble-assistant.ai-result-md ul, -.ai-bubble-assistant.ai-result-md ol { - margin: 4px 0 6px 1.15em; - padding-left: 0.25em; - list-style-position: outside; -} -.ai-bubble-assistant.ai-result-md ul { - list-style-type: disc; -} -.ai-bubble-assistant.ai-result-md ol { - list-style-type: decimal; -} -.ai-bubble-assistant.ai-result-md li { - display: list-item; -} -.ai-ac-table-wrap { - margin: 8px 0 12px; - overflow-x: auto; - border: 1px solid var(--border-soft); - border-radius: 8px; - background: color-mix(in srgb, var(--inset-surface) 88%, transparent); -} -.ai-ac-table { - width: 100%; - border-collapse: collapse; - font-size: 0.78rem; - line-height: 1.45; -} -.ai-ac-table th, -.ai-ac-table td { - padding: 8px 10px; - text-align: left; - vertical-align: top; - border-bottom: 1px solid var(--border-soft); -} -.ai-ac-table th { - font-size: 0.72rem; - font-weight: 600; - color: var(--muted); - background: color-mix(in srgb, var(--inset-surface) 60%, transparent); - white-space: nowrap; -} -.ai-ac-table tbody tr:last-child td { - border-bottom: none; -} -.ai-ac-table tbody tr:hover td { - background: color-mix(in srgb, var(--accent-dim) 35%, transparent); -} -.ai-ac-name { - min-width: 9rem; - font-weight: 600; - color: var(--ai-sum-name); -} -.ai-ac-remark { - color: var(--muted); - font-size: 0.74rem; - max-width: 16rem; -} -.ai-ac-unmon { - color: var(--muted); -} -.ai-ac-err { - color: var(--red); -} -.ai-ac-warn { - color: var(--amber, #d4a017); -} -.ai-ac-table .ai-stat-val.pos { - color: var(--green); - font-weight: 600; -} -.ai-ac-table .ai-stat-val.neg { - color: var(--red); - font-weight: 600; -} -.ai-placeholder { - color: var(--muted); - margin: 0; -} -.ai-chat-panel { - gap: 8px; -} -.ai-chat-panel .ai-chat-split { - flex: 1 1 auto; -} -.ai-chat-topbar { - display: flex; - align-items: center; - gap: 8px; - flex-shrink: 0; -} -.ai-chat-topbar .ai-bot-bar { - flex: 1 1 auto; - min-width: 0; -} -.ai-chat-new-btn { - flex-shrink: 0; - white-space: nowrap; -} -.ai-chat-session-head { - padding-bottom: 2px; -} -.ai-chat-session-head h2 { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.ai-bot-bar { - display: flex; - gap: 8px; - flex-shrink: 0; - padding-bottom: 0; -} -.ai-bot-tab { - flex: 1; - min-height: 36px; - padding: 6px 12px; - border-radius: 8px; - border: 1px solid var(--border-soft); - background: var(--inset-surface); - color: var(--muted); - font-family: var(--font); - font-size: 0.8rem; - font-weight: 600; - cursor: pointer; - transition: border-color 0.15s, color 0.15s, background 0.15s; -} -.ai-bot-tab:hover { - border-color: var(--accent); - color: var(--text); -} -.ai-bot-tab.is-active { - color: var(--text); - border-color: var(--accent); - background: var(--accent-dim); - box-shadow: var(--glow); -} -.ai-chat-split { - flex: 1 1 auto; - min-height: 0; - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(360px, 440px); - gap: 0; - overflow: hidden; - border: 1px solid var(--border-soft); - border-radius: 8px; -} -.ai-chat-main { - display: flex; - flex-direction: column; - min-height: 0; - min-width: 0; - overflow: hidden; -} -.ai-chat-history-panel { - display: flex; - flex-direction: column; - min-height: 0; - min-width: 0; - border-left: 1px solid var(--border-soft); - background: color-mix(in srgb, var(--inset-surface) 65%, var(--panel)); -} -.ai-chat-history-head { - flex-shrink: 0; - padding: 10px 12px 6px; - border-bottom: 1px solid var(--border-soft); -} -.ai-chat-history-head h3 { - margin: 0; - font-size: 0.82rem; - font-weight: 700; - color: var(--muted); - letter-spacing: 0.04em; -} -.ai-chat-history-list { - flex: 1 1 auto; - min-height: 0; - padding: 8px; - display: flex; - flex-direction: column; - gap: 6px; -} -.ai-chat-history-item { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 4px 8px; - align-items: start; - padding: 8px 10px; - border-radius: 8px; - border: 1px solid var(--border-soft); - background: var(--panel); - cursor: pointer; - text-align: left; - transition: border-color 0.15s, background 0.15s; -} -.ai-chat-history-item:hover { - border-color: var(--accent); -} -.ai-chat-history-item.is-active { - border-color: var(--accent); - background: var(--accent-dim); - box-shadow: var(--glow); -} -.ai-chat-history-item-main { - min-width: 0; - display: flex; - flex-direction: column; - gap: 3px; -} -.ai-chat-history-item-title { - font-size: 0.8rem; - font-weight: 600; - color: var(--text); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.ai-chat-history-item-preview { - font-size: 0.72rem; - color: var(--muted); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.ai-chat-history-item-meta { - font-size: 0.68rem; - color: var(--muted); - display: flex; - flex-wrap: wrap; - gap: 6px; - align-items: center; -} -.ai-chat-history-badge { - display: inline-flex; - padding: 1px 6px; - border-radius: 999px; - font-size: 0.62rem; - font-weight: 600; - border: 1px solid var(--border-soft); - color: var(--muted); -} -.ai-chat-history-badge.trading { - color: var(--accent); - border-color: color-mix(in srgb, var(--accent) 40%, var(--border-soft)); -} -.ai-chat-history-badge.supervisor { - color: #c27803; - border-color: color-mix(in srgb, #c27803 45%, var(--border-soft)); -} -.ai-msg-row-system { - justify-content: flex-start; -} -.ai-bubble-system { - background: color-mix(in srgb, var(--surface-2) 88%, #c27803 12%); - border: 1px solid color-mix(in srgb, var(--border-soft) 70%, #c27803 30%); - font-size: 0.92rem; - white-space: pre-wrap; -} -.ai-bubble-warn { - border-color: color-mix(in srgb, var(--danger) 45%, var(--border-soft)); -} -.ai-chat-history-panel.hidden { - display: none !important; -} -.ai-chat-new-btn.hidden { - display: none !important; -} -.supervisor-settings-grid { - margin-top: 0.75rem; - padding-top: 0.25rem; -} -.ai-chat-history-del { - min-width: 28px; - min-height: 28px; - padding: 0; - border: none; - border-radius: 6px; - background: transparent; - color: var(--muted); - font-size: 1rem; - line-height: 1; - cursor: pointer; -} -.ai-chat-history-del:hover { - color: var(--red); - background: color-mix(in srgb, var(--red) 12%, transparent); -} -.ai-msg-actions { - display: flex; - gap: 6px; - padding: 0 4px; -} -.ai-msg-copy-btn { - min-height: 24px; - padding: 2px 8px; - border-radius: 6px; - border: 1px solid var(--border-soft); - background: var(--panel); - color: var(--muted); - font-size: 0.68rem; - font-weight: 600; - cursor: pointer; -} -.ai-msg-copy-btn:hover { - border-color: var(--accent); - color: var(--accent); -} -.ai-chat-messages { - display: flex; - flex-direction: column; - gap: 12px; - padding: 8px 4px 4px; -} -.ai-msg-row { - display: flex; - flex-direction: column; - gap: 4px; - max-width: 100%; -} -.ai-msg-row-user { - align-self: flex-end; - align-items: flex-end; - max-width: 88%; -} -.ai-msg-row-coach { - align-self: flex-start; - align-items: flex-start; - max-width: 92%; -} -.ai-msg-role { - font-size: 0.72rem; - font-weight: 600; - letter-spacing: 0.04em; - color: var(--muted); - padding: 0 4px; -} -.ai-msg-row-user .ai-msg-role { - color: var(--accent); -} -.ai-msg-row-coach .ai-msg-role { - color: var(--accent); -} -.ai-bubble { - width: 100%; - padding: 10px 12px; - border-radius: 10px; - font-size: 0.88rem; - line-height: 1.5; - white-space: pre-wrap; - overflow-wrap: anywhere; - word-break: break-word; -} -.ai-bubble-user { - background: var(--accent-dim); - border: 1px solid var(--border); -} -.ai-bubble-assistant { - background: var(--inset-surface); - border: 1px solid var(--border-soft); -} -.ai-bubble-thinking { - color: var(--muted); - font-style: italic; - animation: ai-think-pulse 1.2s ease-in-out infinite; -} -.ai-bubble-error { - border-color: color-mix(in srgb, var(--red) 55%, var(--border-soft)); - color: var(--red); -} -@keyframes ai-think-pulse { - 0%, - 100% { - opacity: 0.55; - } - 50% { - opacity: 1; - } -} -.ai-closed-trades-wrap { - margin: 0 0 12px; -} -.ai-closed-trades-title { - margin: 0 0 6px; - font-size: 0.82rem; - font-weight: 700; - color: var(--ai-sum-heading); - padding: 4px 0 4px 8px; - border-left: 2px solid var(--ai-sum-heading-border); - background: var(--ai-sum-heading-bg); - border-radius: 0 4px 4px 0; -} -.ai-msg-attachments { - display: flex; - flex-wrap: wrap; - gap: 6px; - padding: 0 4px; -} -.ai-attach-chip { - display: inline-flex; - align-items: center; - padding: 2px 8px; - border-radius: 999px; - font-size: 0.72rem; - color: var(--muted); - background: var(--inset-surface); - border: 1px solid var(--border-soft); -} -.ai-chat-form { - flex-shrink: 0; - padding-top: 4px; - border-top: 1px solid var(--border-soft); -} -.ai-chat-compose { - display: flex; - flex-direction: column; - gap: 8px; -} -.ai-chat-compose-actions { - display: flex; - align-items: center; - gap: 8px; - justify-content: flex-end; -} -.ai-chat-upload-btn { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 36px; - padding: 0 12px; - border-radius: 8px; - border: 1px solid var(--border-soft); - background: var(--inset-surface); - color: var(--text); - font-size: 0.82rem; - cursor: pointer; -} -.ai-chat-upload-btn:hover { - border-color: var(--accent); - color: var(--accent); -} -.ai-chat-pending-list { - display: flex; - flex-wrap: wrap; - gap: 6px; -} -.ai-chat-pending-list[hidden] { - display: none; -} -.ai-chat-pending-chip { - display: inline-flex; - align-items: center; - gap: 4px; - max-width: 100%; - padding: 2px 4px 2px 8px; - border-radius: 999px; - font-size: 0.72rem; - color: var(--text); - background: var(--inset-surface); - border: 1px solid var(--border-soft); -} -.ai-chat-pending-kind { - flex-shrink: 0; - font-size: 0.65rem; - color: var(--muted); -} -.ai-chat-pending-name { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.ai-chat-pending-del { - flex-shrink: 0; - min-width: 22px; - min-height: 22px; - padding: 0; - border: none; - border-radius: 999px; - background: transparent; - color: var(--muted); - font-size: 0.95rem; - line-height: 1; - cursor: pointer; -} -.ai-chat-pending-del:hover { - color: var(--red); - background: color-mix(in srgb, var(--red) 12%, transparent); -} -.ai-chat-pending-del:disabled { - opacity: 0.45; - cursor: not-allowed; -} -.ai-chat-form textarea { - width: 100%; - resize: none; - min-height: 52px; - max-height: 88px; - padding: 10px 12px; - border-radius: 8px; - border: 1px solid var(--border-soft); - background: var(--inset-surface); - color: var(--text); - font-family: var(--font); - font-size: 0.88rem; -} -.ai-chat-form textarea:focus { - outline: none; - border-color: var(--accent); -} -.ai-chat-form textarea:disabled { - opacity: 0.65; -} - -/* —— 资金概况(科技感 HUD)—— */ -body.hub-page-funds .app-bg { - background: - linear-gradient(rgba(0, 212, 255, 0.045) 1px, transparent 1px), - linear-gradient(90deg, rgba(0, 212, 255, 0.045) 1px, transparent 1px), - radial-gradient(ellipse 70% 45% at 12% 0%, rgba(0, 212, 255, 0.16), transparent 58%), - radial-gradient(ellipse 55% 40% at 92% 18%, rgba(123, 97, 255, 0.14), transparent 55%), - radial-gradient(ellipse 50% 35% at 50% 100%, rgba(0, 255, 157, 0.06), transparent 60%); - background-size: 28px 28px, 28px 28px, auto, auto, auto; -} -html[data-theme="light"] body.hub-page-funds .app-bg { - background: - linear-gradient(rgba(0, 110, 154, 0.06) 1px, transparent 1px), - linear-gradient(90deg, rgba(0, 110, 154, 0.06) 1px, transparent 1px), - radial-gradient(ellipse 70% 45% at 12% 0%, rgba(0, 110, 154, 0.1), transparent 58%), - radial-gradient(ellipse 55% 40% at 92% 18%, rgba(91, 79, 199, 0.08), transparent 55%); - background-size: 28px 28px, 28px 28px, auto, auto; -} -body.hub-page-funds #page-funds { - position: relative; -} -.funds-stage { - position: relative; - border-radius: calc(var(--radius) + 4px); - border: 1px solid var(--border-soft); - background: linear-gradient(165deg, rgba(12, 20, 32, 0.72), rgba(8, 14, 26, 0.88)); - box-shadow: var(--glow), var(--shadow); - overflow: hidden; -} -html[data-theme="light"] .funds-stage { - background: linear-gradient(165deg, rgba(255, 255, 255, 0.92), rgba(240, 246, 252, 0.96)); - box-shadow: var(--shadow); -} -.funds-stage-grid, -.funds-stage-glow { - position: absolute; - inset: 0; - pointer-events: none; -} -.funds-stage-grid { - opacity: 0.35; - background: - linear-gradient(rgba(0, 212, 255, 0.07) 1px, transparent 1px), - linear-gradient(90deg, rgba(0, 212, 255, 0.07) 1px, transparent 1px); - background-size: 24px 24px; - mask-image: linear-gradient(180deg, black 0%, transparent 92%); -} -.funds-stage-glow { - background: - radial-gradient(circle at 18% 12%, rgba(0, 212, 255, 0.12), transparent 42%), - radial-gradient(circle at 82% 8%, rgba(123, 97, 255, 0.1), transparent 38%); -} -.funds-stage-inner { - position: relative; - z-index: 1; - padding: 14px 16px 18px; -} -.funds-head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 14px; - margin-bottom: 12px !important; -} -.funds-head h1 { - font-family: var(--display); - letter-spacing: 0.06em; -} -.funds-tag { - background: linear-gradient(135deg, rgba(0, 212, 255, 0.22), rgba(123, 97, 255, 0.18)); - border-color: rgba(0, 212, 255, 0.45); - box-shadow: 0 0 18px rgba(0, 212, 255, 0.2); -} -.funds-desc-fold { - margin: 4px 0 0; - max-width: 100%; -} -.funds-desc-toggle { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 0.72rem; - color: var(--accent); - cursor: pointer; - list-style: none; - user-select: none; - letter-spacing: 0.04em; -} -.funds-desc-toggle::-webkit-details-marker { - display: none; -} -.funds-desc-toggle::before { - content: "▸"; - font-size: 0.68rem; - transition: transform 0.15s ease; -} -.funds-desc-fold[open] .funds-desc-toggle::before { - transform: rotate(90deg); -} -.funds-desc { - margin: 8px 0 0; - color: color-mix(in srgb, var(--muted) 88%, var(--accent)); - letter-spacing: 0.02em; - line-height: 1.45; - font-size: 0.78rem; -} -.funds-live-pill { - flex-shrink: 0; - display: inline-flex; - align-items: center; - gap: 8px; - padding: 6px 12px; - border-radius: 999px; - border: 1px solid rgba(0, 212, 255, 0.35); - background: rgba(0, 212, 255, 0.08); - font-family: var(--display); - font-size: 0.62rem; - letter-spacing: 0.14em; - color: var(--accent); -} -.funds-live-dot { - width: 7px; - height: 7px; - border-radius: 50%; - background: var(--green); - box-shadow: 0 0 10px var(--green); - animation: funds-pulse 2s ease-in-out infinite; -} -@keyframes funds-pulse { - 0%, 100% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.55; transform: scale(0.88); } -} -.funds-toolbar { - margin-bottom: 14px; -} -.funds-btn-refresh { - font-family: var(--display); - letter-spacing: 0.06em; - font-size: 0.72rem; -} -.funds-status.err { - color: var(--red); -} -.funds-summary { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 12px; - margin-bottom: 12px; -} -.funds-stat-card { - position: relative; - background: rgba(0, 0, 0, 0.28); - border: 1px solid var(--border-soft); - border-radius: var(--radius); - padding: 14px 16px; - overflow: hidden; -} -html[data-theme="light"] .funds-stat-card { - background: rgba(255, 255, 255, 0.82); -} -.funds-stat-card::before { - content: ""; - position: absolute; - top: 0; - left: 0; - right: 0; - height: 2px; - background: linear-gradient(90deg, transparent, var(--accent), var(--accent-2), transparent); - opacity: 0.75; -} -.funds-stat-card-primary { - border-color: rgba(0, 212, 255, 0.32); - box-shadow: inset 0 0 24px rgba(0, 212, 255, 0.06); -} -.funds-stat-card-primary .funds-stat-value { - font-size: 1.6rem; -} -.funds-stat-label { - font-family: var(--display); - font-size: 0.62rem; - letter-spacing: 0.12em; - color: var(--muted); - margin-bottom: 6px; - text-transform: uppercase; -} -.funds-stat-value, -.funds-stat-val, -.funds-ac-total .v, -.funds-ac-stats .v, -.funds-fs-stat .v { - font-family: var(--font); - font-variant-numeric: tabular-nums; - letter-spacing: 0.01em; -} -.funds-stat-value { - font-size: 1.35rem; - font-weight: 600; -} -.funds-stat-val { - font-size: 1.15rem; - font-weight: 600; -} -.funds-stat-val.pos { - color: var(--green); -} -.funds-stat-val.neg { - color: var(--red); -} -.funds-dd-pct { - font-size: 0.82rem; - color: var(--muted); - font-weight: 500; -} -.funds-meta { - font-size: 0.72rem; - font-family: var(--mono); - color: color-mix(in srgb, var(--muted) 90%, var(--accent)); - margin: 0 0 14px; - padding: 8px 12px; - border-radius: 8px; - border: 1px dashed var(--border-soft); - background: rgba(0, 0, 0, 0.2); - letter-spacing: 0.03em; -} -html[data-theme="light"] .funds-meta { - background: rgba(255, 255, 255, 0.65); -} -.funds-chart-panel { - margin-bottom: 20px; - border: 1px solid rgba(0, 212, 255, 0.22); - border-radius: calc(var(--radius) + 2px); - background: rgba(0, 0, 0, 0.22); - box-shadow: inset 0 0 32px rgba(0, 212, 255, 0.04), 0 0 24px rgba(0, 212, 255, 0.06); - overflow: hidden; -} -html[data-theme="light"] .funds-chart-panel { - background: rgba(255, 255, 255, 0.7); - box-shadow: inset 0 0 20px rgba(0, 110, 154, 0.04); -} -.funds-chart-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - padding: 8px 12px; - border-bottom: 1px solid var(--border-soft); - background: linear-gradient(90deg, rgba(0, 212, 255, 0.08), transparent); -} -.funds-chart-tag { - font-family: var(--display); - font-size: 0.68rem; - letter-spacing: 0.16em; - color: var(--accent); -} -.funds-chart-sub { - font-size: 0.62rem; - letter-spacing: 0.1em; - color: var(--muted); -} -.funds-chart-host { - height: 300px; - min-height: 240px; - background: var(--chart-surface, var(--panel)); - overflow: hidden; -} -.funds-section-head { - margin-bottom: 12px; -} -.funds-section-title { - margin: 0 0 4px; - font-family: var(--display); - font-size: 0.88rem; - font-weight: 600; - letter-spacing: 0.1em; - text-transform: uppercase; -} -.funds-section-mark { - color: var(--accent-2); - margin-right: 6px; -} -.funds-section-hint { - margin: 0; - font-size: 0.72rem; - color: var(--muted); - letter-spacing: 0.02em; -} -.funds-accounts { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); - gap: 12px; - padding: 4px 0 12px; -} -.funds-ac-card { - display: flex; - flex-direction: column; - gap: 10px; - width: 100%; - padding: 14px 16px; - border: 1px solid var(--border-soft); - border-radius: var(--radius); - background: linear-gradient(160deg, rgba(0, 0, 0, 0.34), rgba(12, 20, 32, 0.55)); - text-align: left; - cursor: pointer; - transition: border-color 0.15s, box-shadow 0.15s, transform 0.12s; - position: relative; - overflow: hidden; -} -html[data-theme="light"] .funds-ac-card { - background: linear-gradient(160deg, rgba(255, 255, 255, 0.95), rgba(236, 244, 252, 0.9)); -} -.funds-ac-card::before { - content: ""; - position: absolute; - inset: 0 auto auto 0; - width: 3px; - height: 100%; - background: linear-gradient(180deg, var(--accent), var(--accent-2)); - opacity: 0.55; -} -.funds-ac-card:hover:not(:disabled) { - border-color: rgba(0, 212, 255, 0.45); - box-shadow: 0 0 22px rgba(0, 212, 255, 0.12), 0 0 0 1px var(--accent-dim); - transform: translateY(-2px); -} -.funds-ac-card:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} -.funds-ac-card.is-off { - opacity: 0.68; - cursor: default; -} -.funds-ac-card.is-off:hover { - transform: none; - box-shadow: none; - border-color: var(--border-soft); -} -.funds-ac-head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 10px; -} -.funds-ac-name { - margin: 0; - font-family: var(--font); - font-size: 0.84rem; - font-weight: 600; - letter-spacing: 0.01em; - line-height: 1.35; - flex: 1; - min-width: 0; - word-break: break-all; -} -.funds-ac-badge { - flex-shrink: 0; - font-size: 0.66rem; - padding: 2px 8px; - border-radius: 999px; - border: 1px solid var(--border-soft); - color: var(--muted); - background: var(--inset-surface); - white-space: nowrap; -} -.funds-ac-badge.is-ok { - color: var(--green); - border-color: rgba(0, 255, 157, 0.28); - background: rgba(0, 255, 157, 0.08); -} -html[data-theme="light"] .funds-ac-badge.is-ok { - border-color: rgba(10, 143, 92, 0.28); - background: rgba(10, 143, 92, 0.08); -} -.funds-ac-total { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 10px; - padding: 8px 10px; - border-radius: 8px; - background: var(--inset-surface); - border: 1px solid var(--border-soft); -} -.funds-ac-total .k { - font-size: 0.72rem; - color: var(--muted); -} -.funds-ac-total .v { - font-size: 1.12rem; - font-weight: 700; - color: var(--text); -} -.funds-ac-stats { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px 12px; - font-size: 0.78rem; -} -.funds-ac-stats .k { - display: block; - color: var(--muted); - font-size: 0.68rem; - margin-bottom: 2px; -} -.funds-ac-stats .v { - font-variant-numeric: tabular-nums; - font-weight: 500; -} -.funds-ac-stats .v.pos { - color: var(--green); -} -.funds-ac-stats .v.neg { - color: var(--red); -} -.funds-ac-foot { - margin-top: 2px; - padding-top: 10px; - border-top: 1px dashed var(--border-soft); - font-size: 0.72rem; - color: var(--muted); - text-align: center; -} -.funds-empty { - color: var(--muted); - font-size: 0.85rem; - padding: 12px 0; -} - -.funds-fullscreen { - position: fixed; - inset: 0; - z-index: 160; - background: var(--fs-scrim); - backdrop-filter: blur(6px); - overflow: auto; - padding: 16px 20px 24px; -} -.funds-fullscreen.hidden { - display: none !important; -} -.funds-fs-backdrop { - position: fixed; - inset: 0; - z-index: 0; - border: none; - padding: 0; - margin: 0; - background: transparent; - cursor: pointer; -} -.funds-fs-panel { - position: relative; - z-index: 1; - max-width: min(1200px, 96vw); - margin: 0 auto; - background: linear-gradient(165deg, rgba(12, 20, 32, 0.95), rgba(6, 10, 18, 0.98)); - border: 1px solid rgba(0, 212, 255, 0.28); - border-radius: calc(var(--radius) + 2px); - padding: 16px 18px 20px; - box-shadow: 0 0 40px rgba(0, 212, 255, 0.12), 0 12px 40px rgba(0, 0, 0, 0.35); -} -html[data-theme="light"] .funds-fs-panel { - background: linear-gradient(165deg, rgba(255, 255, 255, 0.98), rgba(240, 246, 252, 0.98)); - box-shadow: var(--shadow); -} -.funds-fs-head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; - margin-bottom: 14px; - padding-bottom: 12px; - border-bottom: 1px solid var(--border-soft); -} -.funds-fs-title { - margin: 0; - font-family: var(--display); - font-size: 1.1rem; - font-weight: 600; - letter-spacing: 0.06em; -} -.funds-fs-sub { - margin: 4px 0 0; - font-size: 0.76rem; - color: var(--muted); -} -.funds-fs-summary { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); - gap: 10px; - margin-bottom: 14px; -} -.funds-fs-stat { - background: var(--inset-surface); - border: 1px solid var(--border-soft); - border-radius: 8px; - padding: 10px 12px; - display: flex; - flex-direction: column; - gap: 4px; -} -.funds-fs-stat .k { - font-size: 0.72rem; - color: var(--muted); -} -.funds-fs-stat .v { - font-size: 1rem; - font-weight: 600; - font-variant-numeric: tabular-nums; -} -.funds-fs-stat .v.pos { - color: var(--green); -} -.funds-fs-stat .v.neg { - color: var(--red); -} -.funds-fs-chart-host { - height: min(52vh, 420px); - min-height: 260px; - border: 1px solid var(--border-soft); - border-radius: var(--radius); - background: var(--chart-surface, var(--panel)); - overflow: hidden; -} -body.funds-fullscreen-open { - overflow: hidden; -} -@media (max-width: 720px) { - .funds-accounts { - grid-template-columns: minmax(0, 1fr); - } - .funds-head { - flex-direction: column; - align-items: stretch; - } - .funds-live-pill { - align-self: flex-start; - } - .funds-stage-inner { - padding: 12px 12px 14px; - } - .funds-chart-host { - height: 240px; - min-height: 200px; - } -} - -/* —— 内照明心 —— */ -.archive-toolbar { - flex-wrap: wrap; - gap: 10px 14px; - margin-bottom: 10px; -} -.archive-search-field input { - min-width: 160px; -} -#archive-btn-chart-toggle.is-active { - color: var(--accent); - border-color: var(--accent); - background: var(--accent-dim); -} -.archive-field { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 0.82rem; - color: var(--muted); -} -.archive-field select, -.archive-field input { - min-width: 120px; - padding: 6px 8px; - border-radius: 8px; - border: 1px solid var(--border-soft); - background: var(--inset-surface); - color: var(--text); - font-family: var(--font); -} -#page-archive .archive-toolbar { - margin-bottom: 12px; -} -.archive-layout { - display: grid; - grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); - gap: 14px; - min-height: calc(100vh - 240px); - align-items: stretch; -} -.archive-quotes-panel, -.archive-main-panel { - background: var(--panel); - border: 1px solid var(--border-soft); - border-radius: var(--radius); - min-width: 0; -} -.archive-quotes-panel { - display: flex; - flex-direction: column; - gap: 10px; - padding: 12px; - min-height: calc(100vh - 200px); - overflow: hidden; -} -.archive-panel-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; -} -.archive-panel-head h2 { - margin: 0; - font-size: 0.95rem; -} -.archive-panel-meta { - font-size: 0.72rem; - color: var(--muted); -} -.archive-quote-form { - display: flex; - flex-direction: column; - gap: 8px; -} -.archive-quote-form input[type="date"], -.archive-quote-form textarea { - width: 100%; - padding: 8px 10px; - border-radius: 8px; - border: 1px solid var(--border-soft); - background: var(--inset-surface); - color: var(--text); - font-family: var(--font); - font-size: 0.82rem; - resize: vertical; -} -.archive-quote-form textarea { - min-height: 110px; -} -.archive-quotes-list { - flex: 1 1 auto; - min-height: 0; - overflow: auto; - display: flex; - flex-direction: column; - gap: 8px; -} -.archive-quote-block { - display: flex; - flex-direction: column; - gap: 0; - border: 1px solid var(--border-soft); - border-radius: 8px; - background: var(--inset-surface); - overflow: hidden; -} -.archive-quote-block.is-open { - border-color: var(--accent); -} -.archive-quote-item { - display: grid; - grid-template-columns: auto 1fr auto; - gap: 8px; - align-items: center; - width: 100%; - padding: 8px 10px; - border: 0; - border-radius: 0; - background: transparent; - color: inherit; - font: inherit; - text-align: left; - cursor: pointer; -} -.archive-quote-item:hover { - background: color-mix(in srgb, var(--accent) 8%, transparent); -} -.archive-quote-item.is-selected { - background: color-mix(in srgb, var(--accent) 12%, var(--inset-surface)); -} -.archive-quote-open-hint { - font-size: 0.7rem; - color: var(--accent); - white-space: nowrap; -} -.archive-quote-detail { - display: flex; - flex-direction: column; - gap: 8px; - padding: 0 10px 10px; - border-top: 1px solid var(--border-soft); -} -.archive-quote-detail .archive-quote-full { - min-height: 120px; - max-height: none; - overflow: visible; -} -.archive-quote-date { - font-weight: 600; - font-size: 0.78rem; - color: var(--accent); - white-space: nowrap; -} -.archive-quote-preview { - font-size: 0.74rem; - color: var(--muted); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.archive-quote-full { - padding: 10px 12px; - border-radius: 8px; - border: 1px solid var(--border-soft); - background: var(--panel); - color: var(--text); - font-size: 0.82rem; - line-height: 1.55; - white-space: pre-wrap; - word-break: break-word; - max-height: none; -} -.archive-quote-actions { - display: flex; - flex-wrap: wrap; - gap: 8px; -} -.archive-quote-ai-btn { - color: var(--accent); - border-color: color-mix(in srgb, var(--accent) 35%, var(--border-soft)); -} -.archive-main-panel { - display: flex; - flex-direction: column; - gap: 10px; - padding: 12px; - min-height: 100%; - min-height: 0; -} -.archive-period-bar { - flex-wrap: wrap; -} -.archive-period-tabs { - display: inline-flex; - gap: 4px; -} -.archive-period-btn { - padding: 5px 10px; - border-radius: 8px; - border: 1px solid var(--border-soft); - background: var(--inset-surface); - color: var(--muted); - cursor: pointer; - font-family: var(--font); - font-size: 0.8rem; -} -.archive-period-btn.is-active { - color: var(--text); - border-color: var(--accent); - background: color-mix(in srgb, var(--accent) 12%, transparent); -} -.archive-period-range { - display: inline-flex; - align-items: center; - gap: 4px; -} -.archive-period-range.hidden, -.archive-period-day-input.hidden { - display: none; -} -.archive-period-sep { - color: var(--muted); - font-size: 0.82rem; -} -.archive-stats-card { - margin-bottom: 14px; - padding: 12px; - background: var(--panel); - border: 1px solid var(--border-soft); - border-radius: var(--radius); -} -.archive-stats-card-head h2 { - margin: 0 0 10px; - font-size: 0.95rem; -} -.archive-stats-card .archive-stats-bar { - border: none; - background: transparent; - overflow: auto; -} -.archive-overview-panel { - display: flex; - flex-direction: column; - gap: 8px; - min-width: 0; -} -.archive-overview-panel > .archive-panel-head { - display: none; -} -.archive-stats-bar { - padding: 0; - border-radius: 8px; - border: 1px solid var(--border-soft); - background: var(--inset-surface); - font-size: 0.82rem; - color: var(--text); - line-height: 1.45; - overflow: auto; -} -.archive-stats-table { - width: 100%; - border-collapse: collapse; - font-size: 0.8rem; -} -.archive-stats-table th, -.archive-stats-table td { - padding: 7px 10px; - border-bottom: 1px solid var(--border-soft); - text-align: left; - white-space: nowrap; -} -.archive-stats-table th { - color: var(--muted); - font-weight: 500; - background: var(--inset-surface); -} -.archive-stats-table tr:last-child td { - border-bottom: none; -} -.archive-stats-table tr.archive-stats-total td { - background: color-mix(in srgb, var(--accent) 6%, transparent); -} -.archive-stats-table .pnl-pos { - color: #22c55e; -} -.archive-stats-table .pnl-neg { - color: #ef4444; -} -.archive-acc-section { - border: 1px solid var(--border-soft); - border-radius: var(--radius); - background: var(--inset-surface); - overflow: hidden; -} -.archive-acc-summary { - padding: 10px 12px; - font-weight: 600; - font-size: 0.86rem; - cursor: pointer; - list-style: none; - display: flex; - align-items: center; - gap: 8px; -} -.archive-acc-summary::-webkit-details-marker { - display: none; -} -.archive-acc-sub { - font-weight: 400; - font-size: 0.76rem; - color: var(--muted); -} -.archive-chart-section > :not(summary) { - padding: 0 10px 10px; -} -.archive-trades-section { - flex: 1 1 auto; - min-height: 0; - display: flex; - flex-direction: column; -} -.archive-trades-section > .archive-trades { - border: none; - border-radius: 0; - flex: 1 1 auto; - min-height: 0; - max-height: none; -} -#page-archive.is-chart-open .archive-trades-section > .archive-trades { - flex: 0 0 auto; -} -#page-archive:not(.is-chart-open) .archive-trades-section > .archive-trades { - min-height: calc(100vh - 420px); -} -.archive-chart-toolbar { - flex-wrap: wrap; -} -.archive-tf-tabs { - display: inline-flex; - gap: 4px; -} -.archive-tf-btn { - padding: 5px 10px; - border-radius: 8px; - border: 1px solid var(--border-soft); - background: var(--inset-surface); - color: var(--muted); - cursor: pointer; - font-family: var(--font); - font-size: 0.8rem; -} -.archive-tf-btn.is-active { - color: var(--text); - border-color: var(--accent); - background: color-mix(in srgb, var(--accent) 12%, transparent); -} -.archive-chart-wrap { - position: relative; -} -.archive-chart-host { - height: 360px; - min-height: 280px; - border: 1px solid var(--border-soft); - border-radius: var(--radius); - background: var(--panel); - overflow: hidden; -} -.archive-mark-auto { - position: absolute; - right: 8px; - bottom: 10px; - z-index: 5; - padding: 4px 10px; - font-size: 0.72rem; - font-family: var(--font); - border-radius: 6px; - border: 1px solid var(--border-soft); - background: var(--chart-bar-bg, var(--inset-surface)); - color: var(--muted); - cursor: pointer; - line-height: 1.2; -} -.archive-mark-auto:hover { - border-color: var(--accent); - color: var(--text); -} -.archive-mark-auto.is-on { - color: #22c55e; - border-color: rgba(34, 197, 94, 0.45); - background: rgba(34, 197, 94, 0.1); -} -.archive-trades { - overflow: auto; - border: 1px solid var(--border-soft); - border-radius: var(--radius); - background: var(--panel); - overscroll-behavior: contain; -} -.archive-trades-table { - width: 100%; - min-width: 1000px; - border-collapse: collapse; - font-size: 0.78rem; -} -.archive-trades-table .archive-dt { - white-space: nowrap; - font-variant-numeric: tabular-nums; -} -.archive-trades-table .archive-hold { - white-space: nowrap; -} -.archive-trades-table .archive-symbol { - white-space: nowrap; - font-weight: 500; -} -.archive-review-mark { - display: inline-block; - margin-right: 4px; - padding: 0 4px; - border-radius: 4px; - font-size: 0.62rem; - line-height: 1.4; - color: #6ab88a; - background: rgba(106, 184, 138, 0.12); - vertical-align: middle; -} -.archive-trades-table th, -.archive-trades-table td { - padding: 6px 8px; - border-bottom: 1px solid var(--border-soft); - text-align: left; -} -.archive-trades-table th { - color: var(--muted); - font-weight: 500; - position: sticky; - top: 0; - background: var(--panel); -} -.archive-trade-row { - cursor: default; -} -#page-archive.is-chart-open .archive-trade-row { - cursor: pointer; -} -.archive-trade-row.is-active { - background: color-mix(in srgb, var(--accent) 16%, var(--inset-surface)); - box-shadow: inset 3px 0 0 var(--accent); -} -.archive-trade-row.archive-trade-sick td { - color: var(--red); -} -.archive-trade-row.archive-trade-sick.is-active { - background: color-mix(in srgb, var(--accent) 12%, color-mix(in srgb, var(--red) 8%, var(--panel))); - box-shadow: inset 3px 0 0 var(--accent); -} -.archive-trade-row.archive-trade-sick .archive-tag-select, -.archive-trade-row.archive-trade-sick .archive-note-input { - color: var(--red); - border-color: color-mix(in srgb, var(--red) 40%, var(--border-soft)); -} -.archive-trade-row.archive-trade-sick td.pos, -.archive-trade-row.archive-trade-sick td.neg { - color: var(--red); -} -.archive-actions-cell { - white-space: nowrap; -} -.archive-actions-cell .archive-chart-btn, -.archive-actions-cell .archive-del-btn { - margin-right: 6px; -} -.archive-chart-btn { - padding: 3px 8px; - font-size: 0.72rem; - border-radius: 6px; -} -.archive-trades-table td.pos { - color: #22c55e; -} -.archive-trades-table td.neg { - color: #ef4444; -} -.archive-del-btn { - padding: 3px 8px; - font-size: 0.72rem; - border-radius: 6px; - border: 1px solid rgba(239, 68, 68, 0.35); - background: rgba(239, 68, 68, 0.08); - color: #f87171; - cursor: pointer; -} -.archive-del-btn:hover { - background: rgba(239, 68, 68, 0.16); -} -.archive-tag-select, -.archive-note-input { - width: 100%; - max-width: 140px; - padding: 4px 6px; - border-radius: 6px; - border: 1px solid var(--border-soft); - background: var(--inset-surface); - color: var(--text); - font-size: 0.75rem; -} -.archive-tag-select.is-tag-sick { - color: var(--red); - border-color: color-mix(in srgb, var(--red) 45%, var(--border-soft)); - background: color-mix(in srgb, var(--red) 14%, var(--inset-surface)); -} -.archive-tag-select.is-tag-emotion { - color: #60a5fa; - border-color: color-mix(in srgb, #60a5fa 45%, var(--border-soft)); - background: color-mix(in srgb, #60a5fa 14%, var(--inset-surface)); -} -.archive-trade-row.archive-trade-sick .archive-tag-select.is-tag-sick { - color: var(--red); - border-color: color-mix(in srgb, var(--red) 50%, var(--border-soft)); - background: color-mix(in srgb, var(--red) 18%, var(--inset-surface)); -} -.archive-empty { - padding: 16px; - color: var(--muted); - font-size: 0.85rem; -} -@media (max-width: 900px) { - #page-archive .page-desc { - display: none; - } - #page-archive .archive-toolbar-desktop, - #page-archive .archive-panel-desktop { - display: none !important; - } - #page-archive .archive-toolbar { - margin-bottom: 10px; - } - #page-archive .archive-layout { - display: flex; - flex-direction: column; - gap: 12px; - min-height: 0; - } - #page-archive .archive-quotes-panel { - order: 1; - flex: 0 0 auto; - min-height: 0; - max-height: none; - overflow: visible; - } - #page-archive .archive-main-panel { - order: 2; - flex: 0 0 auto; - min-height: 0; - gap: 10px; - } - #page-archive .archive-stats-card { - margin-bottom: 10px; - } - #page-archive .archive-quotes-list { - min-height: 120px; - max-height: 42vh; - } - #page-archive .archive-stats-table th, - #page-archive .archive-stats-table td { - padding: 6px 8px; - font-size: 0.74rem; - } -} - -/* —— 开仓计划 —— */ -#page-plan .plan-layout { - display: grid; - grid-template-columns: minmax(320px, 420px) minmax(0, 1fr); - gap: 14px; - align-items: start; -} -.plan-left-panel, -.plan-right-panel { - display: flex; - flex-direction: column; - gap: 14px; - min-width: 0; -} -.plan-form-section, -.plan-active-section, -.plan-history-section, -.plan-stats-section { - background: var(--panel); - border: 1px solid var(--border-soft); - border-radius: var(--radius); - padding: 12px; -} -.plan-panel-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - margin-bottom: 10px; -} -.plan-panel-head h2 { - margin: 0; - font-size: 0.95rem; -} -.plan-panel-meta { - font-size: 0.72rem; - color: var(--muted); -} -.plan-form-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px 10px; -} -.plan-field { - display: flex; - flex-direction: column; - gap: 4px; - font-size: 0.78rem; -} -.plan-field-full { - grid-column: 1 / -1; -} -.plan-field span { - color: var(--muted); -} -.plan-field input, -.plan-field select, -.plan-field textarea { - width: 100%; - padding: 7px 9px; - border-radius: 8px; - border: 1px solid var(--border-soft); - background: var(--inset-surface); - color: var(--text); - font-family: var(--font); - font-size: 0.82rem; -} -.plan-field-inline { - flex-direction: row; - align-items: center; - gap: 6px; -} -.plan-field-inline span { - white-space: nowrap; -} -.plan-field-inline input, -.plan-field-inline select { - width: auto; - min-width: 88px; -} -.plan-radio-row { - display: flex; - flex-wrap: wrap; - gap: 10px; -} -.plan-radio-label { - display: inline-flex; - align-items: center; - gap: 4px; - font-size: 0.82rem; -} -.plan-submit-btn { - margin-top: 10px; - width: 100%; -} -.plan-active-list, -.plan-history-list { - display: flex; - flex-direction: column; - gap: 8px; - max-height: 48vh; - overflow: auto; -} -.plan-empty { - margin: 0; - padding: 12px 4px; - color: var(--muted); - font-size: 0.82rem; -} -.plan-active-card { - border: 1px solid var(--border-soft); - border-radius: 8px; - background: var(--inset-surface); - padding: 10px; - display: flex; - flex-direction: column; - gap: 6px; -} -.plan-active-head { - display: flex; - justify-content: space-between; - gap: 8px; - align-items: flex-start; -} -.plan-active-title { - font-size: 0.84rem; - font-weight: 600; -} -.plan-active-actions { - display: flex; - gap: 4px; - flex-shrink: 0; -} -.plan-active-meta, -.plan-active-levels { - font-size: 0.74rem; - color: var(--muted); -} -.plan-active-note { - font-size: 0.78rem; - color: var(--text); - opacity: 0.9; -} -.plan-scheme-row { - margin-top: 6px; -} -.plan-field-scheme select { - min-width: 160px; -} -.plan-close-row { - display: flex; - flex-wrap: wrap; - gap: 8px; - align-items: flex-end; - margin-top: 4px; - padding-top: 8px; - border-top: 1px dashed var(--border-soft); -} -.plan-history-row { - display: grid; - grid-template-columns: 92px minmax(0, 1fr) auto auto; - gap: 8px; - align-items: center; - width: 100%; - text-align: left; - padding: 9px 10px; - border: 1px solid var(--border-soft); - border-radius: 8px; - background: var(--inset-surface); - color: var(--text); - font-family: var(--font); - font-size: 0.8rem; - cursor: pointer; -} -.plan-history-row:hover { - border-color: var(--accent); -} -.plan-history-date { - color: var(--muted); - font-size: 0.74rem; -} -.plan-history-result.plan-res-win { - color: var(--pos); -} -.plan-history-result.plan-res-loss { - color: var(--neg); -} -.plan-stats-toolbar { - display: flex; - flex-wrap: wrap; - gap: 10px; - align-items: center; - margin-bottom: 10px; -} -.plan-period-tabs, -.plan-dim-tabs { - display: inline-flex; - flex-wrap: wrap; - gap: 4px; -} -.plan-period-btn, -.plan-dim-btn { - padding: 5px 10px; - border-radius: 999px; - border: 1px solid var(--border-soft); - background: transparent; - color: var(--muted); - font-size: 0.74rem; - cursor: pointer; -} -.plan-period-btn.is-active, -.plan-dim-btn.is-active { - border-color: var(--accent); - color: var(--accent); - background: color-mix(in srgb, var(--accent) 12%, transparent); -} -.plan-stats-range.hidden { - display: none; -} -.plan-period-sep { - color: var(--muted); - font-size: 0.78rem; -} -.plan-stats-table { - width: 100%; - border-collapse: collapse; - font-size: 0.8rem; -} -.plan-stats-table th, -.plan-stats-table td { - padding: 7px 10px; - border-bottom: 1px solid var(--border-soft); - text-align: left; -} -.plan-stats-table th { - color: var(--muted); - font-weight: 500; -} -.plan-detail-body { - display: flex; - flex-direction: column; - gap: 8px; - padding: 4px 0 8px; -} -.plan-detail-row { - display: grid; - grid-template-columns: 88px minmax(0, 1fr); - gap: 8px; - font-size: 0.82rem; -} -.plan-detail-k { - color: var(--muted); -} -.plan-detail-v { - color: var(--text); - word-break: break-word; -} -.plan-detail-card { - width: min(480px, 94vw); -} -.plan-edit-card { - width: min(520px, 94vw); -} -@media (max-width: 960px) { - #page-plan .plan-layout { - grid-template-columns: 1fr; - } - .plan-active-list, - .plan-history-list { - max-height: none; - } - .plan-history-row { - grid-template-columns: 1fr; - gap: 4px; - } -} - -/* ── 策略计算器 ── */ -.calc-layout { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 16px; - align-items: stretch; -} - -.calc-card { - padding: 16px 18px; - height: 100%; - display: flex; - flex-direction: column; -} - -.calc-form { - flex: 1; - display: flex; - flex-direction: column; -} - -.calc-card h2 { - margin: 0 0 8px; - font-size: 1rem; - color: var(--text); -} - -.calc-hint { - margin: 0 0 14px; - font-size: 0.78rem; - color: var(--muted); - line-height: 1.5; -} - -.calc-form-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px 12px; -} - -.calc-field { - display: flex; - flex-direction: column; - gap: 5px; - font-size: 0.78rem; - color: var(--muted); -} - -.calc-field input, -.calc-field select { - width: 100%; - box-sizing: border-box; - background: var(--bg-elevated); - border: 1px solid var(--border); - color: var(--text); - border-radius: 8px; - padding: 8px 10px; - font-size: 0.82rem; - font-family: var(--mono); -} - -.calc-field-span2 { - grid-column: 1 / -1; -} - -.calc-market-info { - padding: 0.55rem 0.55rem 0.55rem 0.75rem; - border-radius: 8px; - background: rgba(255, 255, 255, 0.04); - border: 1px solid rgba(255, 255, 255, 0.08); - font-size: 0.82rem; - line-height: 1.45; - color: var(--muted, #9aa4b2); -} - -.calc-market-info strong { - color: var(--text, #e8ecf1); -} - -.calc-market-err { - color: #f87171; -} - -.calc-actions { - margin-top: auto; - padding-top: 12px; -} - -.calc-result { - margin-top: 14px; - padding-top: 12px; - border-top: 1px solid var(--border-soft); -} - -.calc-result.hidden { - display: none !important; -} - -.calc-summary { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); - gap: 8px 12px; - margin-bottom: 12px; -} - -.calc-summary div { - background: var(--bg-elevated); - border: 1px solid var(--border-soft); - border-radius: 8px; - padding: 8px 10px; -} - -.calc-summary span { - display: block; - font-size: 0.72rem; - color: var(--muted); - margin-bottom: 4px; -} - -.calc-summary strong { - font-family: var(--mono); - font-size: 0.86rem; - color: var(--text); -} - -.calc-pnl-profit { - color: var(--green) !important; -} - -.calc-pnl-loss { - color: var(--red) !important; -} - -.calc-table-wrap { - overflow: auto; -} - -.calc-table { - width: 100%; - border-collapse: collapse; - font-size: 0.78rem; -} - -.calc-table th, -.calc-table td { - padding: 7px 8px; - border-bottom: 1px solid var(--border-soft); - text-align: left; - white-space: nowrap; -} - -.calc-table th { - color: var(--muted); - font-weight: 600; -} - -.calc-error { - color: var(--red); - font-size: 0.82rem; - margin: 0; -} - -.calc-empty { - color: var(--muted); - font-size: 0.82rem; - margin: 0; -} - -.calc-roll-legs-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - margin: 14px 0 8px; - font-size: 0.82rem; - color: var(--text); -} - -.calc-roll-legs-list { - display: flex; - flex-direction: column; - gap: 10px; -} - -.calc-roll-leg { - border: 1px solid var(--border-soft); - border-radius: 8px; - padding: 10px 12px; - background: var(--bg-elevated); -} - -.calc-roll-leg-title { - font-size: 0.8rem; - font-weight: 600; - color: var(--muted); - margin-bottom: 8px; -} - -.calc-roll-leg-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; -} - -.calc-roll-leg-remove { - margin-top: 8px; - font-size: 0.78rem; -} - -.calc-done-tag { - display: inline-block; - margin-left: 6px; - padding: 1px 6px; - border-radius: 999px; - font-size: 0.68rem; - color: var(--muted); - border: 1px solid var(--border-soft); -} - -@media (max-width: 960px) { - .calc-layout { - grid-template-columns: 1fr; - } - .calc-form-grid { - grid-template-columns: 1fr; - } -} - +:root, +html[data-theme="dark"] { + --bg: #050810; + --bg-elevated: #0a1018; + --panel: rgba(12, 20, 32, 0.82); + --panel-hover: rgba(18, 28, 44, 0.9); + --panel-solid: #141a2a; + --panel-solid-border: #2a3150; + --nav-bg: rgba(0, 0, 0, 0.35); + --overlay: rgba(0, 0, 0, 0.45); + --chart-surface: #0a1018; + --chart-bar-bg: rgba(8, 14, 24, 0.96); + --inset-surface: rgba(0, 0, 0, 0.32); + --inset-surface-strong: rgba(0, 0, 0, 0.42); + --section-surface: rgba(0, 0, 0, 0.22); + --pos-card-bg: rgba(10, 16, 28, 0.95); + --fs-scrim: rgba(2, 6, 12, 0.92); + --btn-surface: rgba(0, 0, 0, 0.4); + --text: #e8f4ff; + --muted: #6b8aa8; + --border: rgba(0, 212, 255, 0.22); + --border-soft: rgba(0, 212, 255, 0.1); + --green: #00ff9d; + --red: #ff4d6d; + --accent: #00d4ff; + --accent-2: #7b61ff; + --accent-dim: rgba(0, 212, 255, 0.12); + --glow: 0 0 24px rgba(0, 212, 255, 0.15); + --radius: 10px; + --shadow: 0 8px 32px rgba(0, 0, 0, 0.45); + --plan-title: #f0f2ff; + --plan-meta: #8892b0; + --plan-meta-accent: #6ab8ff; + --plan-lbl: #8b95b8; + --plan-val: #f0f2ff; + --plan-val-neutral: #cfd3ef; + --plan-border-dash: #2a3558; + --plan-col-divider: #243050; + --plan-dca-th: #6a7598; + --plan-close-bg: #5c1e2a; + --plan-close-fg: #ffb4b4; + --plan-be-label: #cfd3ef; + --plan-be-input-bg: #0f1424; + --plan-be-input-border: #304164; + --plan-be-btn-bg: #1f4a3a; + --primary-btn-bg: linear-gradient(135deg, rgba(0, 212, 255, 0.38), rgba(123, 97, 255, 0.28)); + --primary-btn-fg: #ffffff; + --primary-btn-border: var(--accent); + --status-done: #4cd97f; + --status-pending: #9aa3c4; + --ai-sum-heading: #9adbff; + --ai-sum-heading-bg: rgba(0, 212, 255, 0.07); + --ai-sum-heading-border: rgba(0, 212, 255, 0.38); + --ai-sum-name: #d4ecff; + --font: "JetBrains Mono", ui-monospace, Consolas, monospace; + --display: "Orbitron", var(--font); + --mono: var(--font); + --layout-max: 1520px; + color-scheme: dark; +} + +html[data-theme="light"] { + --bg: #d4dde8; + --bg-elevated: #f6f9fc; + --panel: rgba(255, 255, 255, 0.94); + --panel-hover: rgba(248, 252, 255, 0.98); + --panel-solid: #ffffff; + --panel-solid-border: #b8c8d8; + --nav-bg: rgba(255, 255, 255, 0.92); + --overlay: rgba(15, 35, 60, 0.28); + --chart-surface: #f0f4f9; + --chart-bar-bg: #e8eef5; + --inset-surface: rgba(255, 255, 255, 0.9); + --inset-surface-strong: #eef3f8; + --section-surface: rgba(255, 255, 255, 0.82); + --pos-card-bg: rgba(255, 255, 255, 0.96); + --fs-scrim: rgba(212, 221, 232, 0.94); + --btn-surface: rgba(255, 255, 255, 0.85); + --text: #142232; + --muted: #4a6078; + --border: rgba(0, 95, 140, 0.26); + --border-soft: rgba(0, 75, 115, 0.14); + --green: #0a8f5c; + --red: #c93552; + --accent: #006e9a; + --accent-2: #5b4fc7; + --accent-dim: rgba(0, 110, 154, 0.12); + --glow: 0 0 16px rgba(0, 110, 154, 0.1); + --shadow: 0 6px 24px rgba(30, 60, 100, 0.1); + --plan-title: var(--text); + --plan-meta: var(--muted); + --plan-meta-accent: var(--accent); + --plan-lbl: var(--muted); + --plan-val: var(--text); + --plan-val-neutral: #5a6f85; + --plan-border-dash: rgba(0, 75, 115, 0.2); + --plan-col-divider: rgba(0, 75, 115, 0.16); + --plan-dca-th: var(--muted); + --plan-close-bg: rgba(201, 53, 82, 0.12); + --plan-close-fg: var(--red); + --plan-be-label: var(--muted); + --plan-be-input-bg: var(--bg-elevated); + --plan-be-input-border: var(--border-soft); + --plan-be-btn-bg: rgba(10, 143, 92, 0.14); + --primary-btn-bg: #006e9a; + --primary-btn-fg: #ffffff; + --primary-btn-border: #005a82; + --status-done: #087a50; + --status-pending: #3d556d; + --ai-sum-heading: #9e1e38; + --ai-sum-heading-bg: rgba(201, 53, 82, 0.08); + --ai-sum-heading-border: rgba(201, 53, 82, 0.42); + --ai-sum-name: #7a182c; + color-scheme: light; +} + +* { + box-sizing: border-box; +} + +body { + font-family: var(--font); + background: var(--bg); + color: var(--text); + margin: 0; + font-size: 13px; + line-height: 1.55; + min-height: 100vh; +} + +a { + color: var(--accent); + text-decoration: none; +} +a:hover { + text-decoration: underline; + text-shadow: 0 0 12px rgba(0, 212, 255, 0.4); +} + +.app-bg, +.login-bg { + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background: + linear-gradient(rgba(0, 212, 255, 0.03) 1px, transparent 1px), + linear-gradient(90deg, rgba(0, 212, 255, 0.03) 1px, transparent 1px), + radial-gradient(ellipse 80% 50% at 50% -20%, rgba(0, 212, 255, 0.12), transparent), + radial-gradient(ellipse 60% 40% at 100% 100%, rgba(123, 97, 255, 0.08), transparent); + background-size: 48px 48px, 48px 48px, auto, auto; +} + +.app-bg::after, +.login-bg::after { + content: ""; + position: absolute; + inset: 0; + background: repeating-linear-gradient( + 0deg, + transparent, + transparent 2px, + rgba(0, 0, 0, 0.03) 2px, + rgba(0, 0, 0, 0.03) 4px + ); + opacity: 0.4; +} + +.app-shell { + position: relative; + z-index: 1; + width: 100%; + max-width: var(--layout-max); + margin-left: auto; + margin-right: auto; + padding: 0 24px 48px; +} + +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 18px 0; + border-bottom: 1px solid var(--border-soft); + margin-bottom: 8px; + flex-wrap: wrap; +} + +.brand { + display: flex; + align-items: center; + gap: 12px; +} + +.brand-mark { + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 12px var(--accent), 0 0 24px rgba(0, 212, 255, 0.5); + animation: pulse-dot 2s ease-in-out infinite; +} + +@keyframes pulse-dot { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.7; + transform: scale(0.92); + } +} + +.brand-title { + font-family: var(--display); + font-size: 15px; + font-weight: 600; + letter-spacing: 0.08em; + color: var(--text); +} + +.brand-sub { + font-size: 10px; + color: var(--muted); + letter-spacing: 0.14em; + margin-top: 2px; +} + +.header-right { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.sys-pill { + font-size: 10px; + letter-spacing: 0.12em; + padding: 5px 10px; + border-radius: 999px; + border: 1px solid var(--border); + color: var(--accent); + background: var(--accent-dim); + font-family: var(--display); +} + +.sys-pill.warn { + color: var(--red); + border-color: rgba(255, 77, 109, 0.4); + background: rgba(255, 77, 109, 0.1); +} + +.sys-pill.syncing { + opacity: 0.85; + animation: sys-pill-pulse 1.2s ease-in-out infinite; +} + +@keyframes sys-pill-pulse { + 50% { + opacity: 0.55; + } +} + +.theme-toggle { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 3px; + border-radius: var(--radius); + border: 1px solid var(--border-soft); + background: var(--nav-bg); + backdrop-filter: blur(8px); +} + +.theme-toggle-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 32px; + padding: 0; + border: none; + border-radius: 7px; + background: transparent; + color: var(--muted); + cursor: pointer; + transition: background 0.15s, color 0.15s, box-shadow 0.15s; +} + +.theme-toggle-btn:hover { + color: var(--text); + background: var(--panel-hover); +} + +.theme-toggle-btn.is-active { + color: var(--accent); + background: var(--accent-dim); + box-shadow: inset 0 0 0 1px var(--border); +} + +.theme-toggle-btn .theme-icon { + display: block; +} + +.top-nav { + display: flex; + gap: 4px; + background: var(--nav-bg); + padding: 4px; + border-radius: var(--radius); + border: 1px solid var(--border-soft); + backdrop-filter: blur(8px); +} + +.top-nav a { + padding: 8px 16px; + border-radius: 7px; + text-decoration: none; + color: var(--muted); + font-size: 12px; + font-weight: 500; + letter-spacing: 0.04em; + transition: background 0.15s, color 0.15s, box-shadow 0.15s; +} + +.top-nav a.nav-hidden { + display: none !important; +} + +.top-nav a:hover { + color: var(--text); + background: var(--panel-hover); + text-decoration: none; +} + +.top-nav a.active { + background: linear-gradient(135deg, rgba(0, 212, 255, 0.2), rgba(123, 97, 255, 0.15)); + color: var(--accent); + border: 1px solid var(--border); + box-shadow: var(--glow); +} + +button.ghost { + background: transparent; + border: 1px solid var(--border-soft); + color: var(--muted); + font-size: 11px; + padding: 7px 12px; +} + +button.ghost:hover:not(:disabled) { + color: var(--text); + border-color: var(--border); +} + +.page.hidden { + display: none; +} + +.page-head { + margin: 24px 0 16px; +} + +.page-head h1 { + margin: 0 0 6px; + font-family: var(--display); + font-size: 20px; + font-weight: 600; + letter-spacing: 0.06em; + display: flex; + align-items: center; + gap: 10px; +} + +.head-tag { + font-size: 11px; + padding: 3px 8px; + border-radius: 4px; + background: var(--accent-dim); + border: 1px solid var(--border); + color: var(--accent); +} + +.page-desc { + margin: 0; + font-size: 12px; + color: var(--muted); +} + +.hint-box { + margin-bottom: 16px; + border: 1px solid var(--border-soft); + border-radius: var(--radius); + background: var(--panel); + backdrop-filter: blur(10px); + overflow: hidden; +} + +.hint-box summary { + padding: 10px 14px; + cursor: pointer; + font-size: 12px; + color: var(--muted); + user-select: none; + list-style: none; +} +.hint-box summary::-webkit-details-marker { + display: none; +} +.hint-box summary::before { + content: "▸ "; + color: var(--accent); +} +.hint-box[open] summary::before { + content: "▾ "; +} + +.hint-box .hint-body { + padding: 0 14px 12px; + font-size: 11px; + color: var(--muted); + line-height: 1.65; + border-top: 1px solid var(--border-soft); +} +.hint-box .hint-body code { + font-family: var(--mono); + font-size: 10px; + background: rgba(0, 212, 255, 0.08); + padding: 1px 5px; + border-radius: 4px; + color: var(--accent); + border: 1px solid var(--border-soft); +} + +.toolbar { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + padding: 12px 14px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: 16px; + backdrop-filter: blur(10px); + box-shadow: var(--glow); +} + +.toolbar-spacer { + flex: 1; + min-width: 8px; +} + +.toolbar-meta { + font-size: 11px; + color: var(--muted); + font-family: var(--mono); +} + +button, +.btn { + background: var(--btn-surface); + color: var(--text); + border: 1px solid var(--border); + border-radius: 8px; + padding: 8px 16px; + cursor: pointer; + font-size: 12px; + font-family: var(--font); + font-weight: 500; + letter-spacing: 0.03em; + transition: border-color 0.15s, background 0.15s, box-shadow 0.15s; +} + +button:hover:not(:disabled) { + border-color: var(--accent); + background: var(--panel-hover); + box-shadow: 0 0 16px rgba(0, 212, 255, 0.12); +} + +button.primary { + background: var(--primary-btn-bg); + border-color: var(--primary-btn-border); + color: var(--primary-btn-fg); + font-weight: 600; + text-shadow: none; +} + +button.danger { + border-color: rgba(255, 77, 109, 0.5); + color: var(--red); + background: rgba(255, 77, 109, 0.08); +} + +button.danger:hover:not(:disabled) { + background: rgba(255, 77, 109, 0.15); + border-color: var(--red); + box-shadow: 0 0 16px rgba(255, 77, 109, 0.2); +} + +button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.btn-link { + background: transparent; + border: 1px solid var(--border-soft); + color: var(--accent); + padding: 5px 10px; + font-size: 11px; + border-radius: 6px; +} +.btn-link:hover { + background: var(--accent-dim); + text-decoration: none; + box-shadow: var(--glow); +} + +.btn-close-pos.btn-sm { + white-space: nowrap; +} + +.data-table .td-actions { + text-align: right; + width: 1%; + white-space: nowrap; +} + +.chk-label { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--muted); + cursor: pointer; +} + +.card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + backdrop-filter: blur(12px); + transition: border-color 0.2s, box-shadow 0.2s; + position: relative; +} + +.card::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, transparent, var(--accent), transparent); + opacity: 0.5; +} + +.card.card-online { + border-color: rgba(0, 255, 157, 0.35); +} +.card.card-online::before { + background: linear-gradient(90deg, transparent, var(--green), transparent); + opacity: 0.8; +} + +.card.card-offline { + border-color: rgba(255, 77, 109, 0.3); +} +.card.card-offline::before { + background: linear-gradient(90deg, transparent, var(--red), transparent); +} + +.card:hover { + border-color: rgba(0, 212, 255, 0.45); + box-shadow: var(--glow); +} + +.card-head { + padding: 14px 16px; + border-bottom: 1px solid var(--border-soft); + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; +} + +.card-title-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} +.status-dot.ok { + background: var(--green); + box-shadow: 0 0 8px var(--green); +} +.status-dot.bad { + background: var(--red); + box-shadow: 0 0 8px var(--red); +} + +.status-dot.warn { + background: #ffb020; + box-shadow: 0 0 8px rgba(255, 176, 32, 0.45); +} + +/* —— 手机监控总览瓦片 —— */ +.monitor-alert-summary { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 6px 10px; + margin: 0 0 10px; + padding: 10px 12px; + border-radius: var(--radius); + border: 1px solid var(--border-soft); + background: var(--panel); + font-size: 12px; +} + +.monitor-alert-summary.hidden { + display: none !important; +} + +.mas-item.mas-ok { + color: var(--green); +} + +.mas-item.mas-warn { + color: #ffb020; +} + +.mas-item.mas-err { + color: var(--red); +} + +.mas-sep { + color: var(--muted); +} + +.monitor-macro-banner { + margin: 0 0 12px; + padding: 12px 14px; + border-radius: var(--radius); + border: 1px solid rgba(255, 176, 32, 0.45); + background: linear-gradient(90deg, rgba(255, 176, 32, 0.12), rgba(255, 120, 80, 0.08)); +} + +.monitor-macro-banner.hidden { + display: none !important; +} + +.monitor-macro-banner-inner { + display: flex; + align-items: flex-start; + gap: 10px; + flex-wrap: wrap; +} + +.monitor-macro-badge { + flex: 0 0 auto; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + padding: 4px 10px; + border-radius: 999px; + color: #ffb020; + border: 1px solid rgba(255, 176, 32, 0.5); + background: rgba(255, 176, 32, 0.12); +} + +.monitor-macro-text { + flex: 1 1 240px; + font-size: 13px; + line-height: 1.5; + color: var(--text); +} + +.monitor-macro-banner.phase-imminent { + border-color: rgba(255, 120, 80, 0.55); + background: linear-gradient(90deg, rgba(255, 120, 80, 0.14), rgba(255, 176, 32, 0.1)); +} + +.macro-event-form { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + margin: 12px 0 14px; + align-items: end; +} + +.macro-event-field { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 12px; + color: var(--muted); +} + +.macro-event-field-wide { + grid-column: 1 / -1; +} + +.macro-event-field input, +.macro-event-field select { + background: var(--bg-elevated); + border: 1px solid var(--border); + color: var(--text); + border-radius: 8px; + padding: 9px 11px; + font-size: 12px; + font-family: var(--mono); +} + +.macro-event-actions { + display: flex; + gap: 8px; + align-items: center; +} + +.macro-event-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.macro-event-row { + display: grid; + grid-template-columns: minmax(140px, 1.2fr) minmax(150px, 1fr) minmax(120px, 1fr) auto; + gap: 10px; + align-items: center; + padding: 10px 12px; + border: 1px solid var(--border-soft); + border-radius: var(--radius); + background: var(--panel); + font-size: 12px; +} + +.macro-event-row.is-active { + border-color: rgba(255, 176, 32, 0.45); + box-shadow: inset 0 0 0 1px rgba(255, 176, 32, 0.12); +} + +.macro-event-row-title { + font-weight: 600; + color: var(--text); +} + +.macro-event-row-meta { + color: var(--muted); + font-family: var(--mono); + font-size: 11px; +} + +.macro-event-row-actions { + display: flex; + gap: 6px; + justify-content: flex-end; +} + +.macro-event-empty { + padding: 14px; + text-align: center; + color: var(--muted); + font-size: 12px; + border: 1px dashed var(--border-soft); + border-radius: var(--radius); +} + +.host-status-panel { + margin: 0 0 12px; + border-radius: var(--radius); + border: 1px solid var(--border-soft); + background: var(--panel); + font-size: 12px; +} + +.host-status-panel.hidden { + display: none !important; +} + +.host-status-summary { + display: flex; + align-items: center; + gap: 8px 12px; + padding: 10px 12px; + cursor: pointer; + list-style: none; + user-select: none; +} + +.host-status-summary::-webkit-details-marker { + display: none; +} + +.host-status-summary::before { + content: "▸"; + color: var(--muted); + font-size: 11px; + transition: transform 0.15s ease; + flex-shrink: 0; +} + +.host-status-panel[open] > .host-status-summary::before { + transform: rotate(90deg); +} + +.host-status-summary-title { + font-weight: 600; + color: var(--text); + white-space: nowrap; +} + +.host-status-summary-text { + font-size: 11px; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1 1 auto; +} + +.host-status-summary-text.bad { + color: var(--red); +} + +.host-summary-host, +.host-summary-sep { + color: var(--muted); +} + +.host-metric-tone.ok, +.host-metric-val.ok { + color: var(--green); + font-weight: 600; +} + +.host-metric-tone.bad, +.host-metric-val.bad { + color: var(--red); + font-weight: 600; +} + +.host-status-bar { + display: flex; + flex-direction: column; + gap: 12px; + padding: 0 12px 12px; + border-top: 1px solid var(--border-soft); + margin-top: 0; + padding-top: 12px; + border-radius: 0; + border-left: none; + border-right: none; + border-bottom: none; + background: transparent; +} + +.host-status-top { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px 16px; +} + +.host-status-head { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1 1 220px; +} + +.host-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + background: var(--muted); +} + +.host-status-dot.ok { + background: var(--green); + box-shadow: 0 0 8px var(--green); +} + +.host-status-dot.warn { + background: #ffb020; + box-shadow: 0 0 8px rgba(255, 176, 32, 0.45); +} + +.host-status-dot.bad { + background: var(--red); + box-shadow: 0 0 8px var(--red); +} + +.host-status-name { + font-weight: 600; + color: var(--text); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.host-status-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 6px 14px; + color: var(--muted); + font-size: 11px; + flex: 0 1 auto; +} + +.host-status-uptime, +.host-status-updated { + white-space: nowrap; +} + +.host-status-metrics { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.host-metric-card { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--border-soft); + background: rgba(0, 0, 0, 0.14); +} + +html[data-theme="light"] .host-metric-card { + background: rgba(0, 0, 0, 0.03); +} + +.host-metric-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; +} + +.host-metric-label { + color: var(--muted); + font-size: 11px; + white-space: nowrap; +} + +.host-metric-bar { + height: 7px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.06); + overflow: hidden; +} + +html[data-theme="light"] .host-metric-bar { + background: rgba(0, 0, 0, 0.06); +} + +.host-metric-fill { + display: block; + height: 100%; + width: 0%; + border-radius: inherit; + background: #22c55e; + transition: width 0.35s ease, background 0.2s ease; +} + +.host-metric-fill.ok { + background: #22c55e; +} + +.host-metric-fill.warn { + background: #ffb020; +} + +.host-metric-fill.bad { + background: var(--red); +} + +.host-metric-val { + color: var(--text); + font-variant-numeric: tabular-nums; + white-space: nowrap; + font-size: 13px; + font-weight: 600; +} + +.host-metric-val-net { + font-size: 11px; + font-weight: 500; + color: var(--muted); +} + +.host-metric-sub, +.host-net-line { + color: var(--muted); + font-size: 11px; + font-variant-numeric: tabular-nums; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.host-net-lines { + display: flex; + flex-direction: column; + gap: 4px; +} + +@media (max-width: 1080px) { + .host-status-metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.grid-monitor.grid-monitor-tiles { + grid-template-columns: repeat(2, minmax(0, 1fr)) !important; + gap: 10px; + align-content: start; +} + +.hub-tile { + margin: 0; + padding: 0; + min-height: 118px; + overflow: hidden; +} + +.hub-tile .hub-tile-body { + cursor: pointer; + padding: 12px 12px 10px; + display: flex; + flex-direction: column; + gap: 6px; + min-height: 118px; +} + +.hub-tile-top { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex-wrap: wrap; +} + +.hub-tile-name { + font-family: var(--display); + font-size: 13px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.hub-tile-pnl { + font-size: 20px; + font-weight: 600; + line-height: 1.2; +} + +.hub-tile-pnl small { + font-size: 11px; + font-weight: 500; + color: var(--muted); +} + +.hub-tile-meta { + font-size: 11px; + color: var(--muted); + line-height: 1.35; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.hub-tile-foot { + margin-top: auto; + font-size: 10px; + color: var(--muted); +} + +.hub-tile-error { + border-color: rgba(255, 77, 109, 0.45); + box-shadow: 0 0 0 1px rgba(255, 77, 109, 0.12); +} + +.hub-tile-warn { + border-color: rgba(255, 176, 32, 0.45); + box-shadow: 0 0 0 1px rgba(255, 176, 32, 0.1); +} + +.hub-tile-ok { + border-color: var(--border-soft); +} + +.hub-tile-body:hover .hub-tile-name { + color: var(--accent); +} + +.card-title { + font-family: var(--display); + font-size: 13px; + font-weight: 600; + letter-spacing: 0.05em; + margin: 0 0 4px; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +.card-sub { + font-size: 10px; + color: var(--muted); + font-family: var(--mono); + word-break: break-all; +} + +.card-actions { + display: flex; + gap: 6px; + align-items: center; + flex-shrink: 0; +} + +.card-body { + padding: 14px 16px; +} + +.grid-monitor { + display: grid; + gap: 16px; + /* 列数由 app.js syncMonitorGridColumns 按卡片数量设置 */ + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.card-expand-zone { + cursor: pointer; +} + +.card-expand-zone:hover .card-title { + color: var(--accent); +} + +body.hub-fullscreen-open { + overflow: hidden; +} + +body.hub-instance-frame-open { + overflow: hidden; +} + +body.market-chart-fs-open { + overflow: hidden; +} + +.instance-frame-shell { + position: fixed; + inset: 0; + z-index: 200; + display: flex; + flex-direction: column; + background: var(--bg, #0a0e14); + isolation: isolate; +} + +.instance-frame-shell.hidden { + display: none !important; +} + +.instance-frame-shell.is-instance-nav-loading .instance-frame { + pointer-events: none; +} + +.instance-frame-loading { + display: none; + position: absolute; + left: 0; + right: 0; + bottom: 0; + top: 49px; + z-index: 2; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--bg, #0a0e14) 72%, transparent); + color: var(--muted, #8892b0); + font-size: 0.9rem; + pointer-events: none; +} + +.instance-frame-shell.is-instance-nav-loading .instance-frame-loading { + display: flex; +} + +.instance-frame-loading-inner { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + border-radius: 999px; + border: 1px solid var(--border-soft); + background: color-mix(in srgb, var(--panel-solid) 88%, transparent); +} + +.instance-frame-spinner { + width: 16px; + height: 16px; + border-radius: 50%; + border: 2px solid color-mix(in srgb, var(--muted, #8892b0) 35%, transparent); + border-top-color: var(--accent, #6eb5ff); + animation: instance-frame-spin 0.75s linear infinite; +} + +@keyframes instance-frame-spin { + to { + transform: rotate(360deg); + } +} + +.instance-frame-toolbar { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 12px; + padding: 10px 16px; + border-bottom: 1px solid var(--border-soft); + background: var(--panel-solid); +} + +.instance-frame-title { + flex: 1; + font-weight: 600; + color: var(--text, #dbe4ff); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.instance-frame-actions { + display: flex; + gap: 8px; + flex-shrink: 0; +} + +.instance-frame { + flex: 1 1 auto; + width: 100%; + border: none; + background: var(--bg); +} + +.exchange-fullscreen { + position: fixed; + inset: 0; + z-index: 150; + background: var(--fs-scrim); + backdrop-filter: blur(6px); + overflow: auto; + padding: 16px 20px 24px; +} + +.exchange-fullscreen.hidden { + display: none !important; +} + +.exchange-fullscreen-backdrop { + position: fixed; + inset: 0; + z-index: 0; + border: none; + padding: 0; + margin: 0; + background: transparent; + cursor: pointer; +} + +.exchange-fullscreen-panel { + position: relative; + z-index: 1; + max-width: min(1800px, 98vw); + margin: 0 auto; +} + +.fs-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border-soft); +} + +.fs-title { + margin: 0; + font-family: var(--display); + font-size: 18px; + letter-spacing: 0.04em; +} + +.fs-sub { + font-size: 11px; + color: var(--muted); + margin-top: 4px; + word-break: break-all; +} + +.fs-head-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; +} + +.fs-head-actions .btn-open-trade { + border-color: var(--accent); + color: var(--accent); + background: color-mix(in srgb, var(--accent) 10%, transparent); + font-weight: 600; +} + +.fs-head-actions .btn-open-trade:hover { + background: color-mix(in srgb, var(--accent) 18%, transparent); +} + +.card-actions .btn-open-trade { + border-color: var(--accent); + color: var(--accent); + font-weight: 600; +} + +.card-expand-hint { + margin-top: 12px; + padding: 8px 10px; + font-size: 11px; + color: var(--muted); + text-align: center; + border: 1px dashed var(--border-soft); + border-radius: 8px; + background: rgba(0, 212, 255, 0.03); +} + +.compact-pos-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.compact-pos-line { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + font-size: 12px; + padding: 6px 8px; + background: var(--inset-surface); + border-radius: 6px; + border: 1px solid var(--border-soft); +} + +.hub-pos-list { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 14px; +} + +/* 全屏放大:持仓卡片横向排列,列数随仓位数量自适应 */ +.exchange-fullscreen .hub-pos-list { + display: grid; + gap: 14px; + align-items: stretch; + width: 100%; +} + +.exchange-fullscreen .hub-pos-list.count-1 { + grid-template-columns: minmax(0, 1fr); +} + +.exchange-fullscreen .hub-pos-list.count-1 .hub-pos-card.pos-card { + max-width: min(960px, 100%); + margin-inline: auto; + width: 100%; +} + +.exchange-fullscreen .hub-pos-list.count-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.exchange-fullscreen .hub-pos-list.count-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.exchange-fullscreen .hub-pos-list.count-4 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.exchange-fullscreen .hub-pos-list.count-5, +.exchange-fullscreen .hub-pos-list.count-6 { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.exchange-fullscreen .hub-pos-list.count-many { + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); +} + +.exchange-fullscreen .hub-pos-card.pos-card { + min-width: 0; + height: 100%; +} + +@media (max-width: 1100px) { + .exchange-fullscreen .hub-pos-list.count-3, + .exchange-fullscreen .hub-pos-list.count-5, + .exchange-fullscreen .hub-pos-list.count-6 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .exchange-fullscreen .hub-pos-list.count-many { + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + } +} + +@media (max-width: 640px) { + .exchange-fullscreen .hub-pos-list.count-2, + .exchange-fullscreen .hub-pos-list.count-3, + .exchange-fullscreen .hub-pos-list.count-4, + .exchange-fullscreen .hub-pos-list.count-5, + .exchange-fullscreen .hub-pos-list.count-6, + .exchange-fullscreen .hub-pos-list.count-many { + grid-template-columns: minmax(0, 1fr); + } + .exchange-fullscreen .hub-pos-list.count-1 .hub-pos-card.pos-card { + max-width: 100%; + } +} + +/* 平板横屏:持仓与区块双列 */ +@media (min-width: 641px) and (max-width: 1200px) and (orientation: landscape) { + .exchange-fullscreen .hub-pos-list.count-2, + .exchange-fullscreen .hub-pos-list.count-3, + .exchange-fullscreen .hub-pos-list.count-4, + .exchange-fullscreen .hub-pos-list.count-many { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .exchange-fullscreen .hub-section-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .hub-fs-sections-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + align-items: start; + } +} + +/* 手机竖屏:全屏顶栏与持仓单列 */ +@media (max-width: 720px), (max-width: 900px) and (orientation: portrait) { + .exchange-fullscreen .hub-pos-list { + grid-template-columns: minmax(0, 1fr) !important; + } +} + +.hub-fs-sections-grid { + display: flex; + flex-direction: column; + gap: 12px; +} + +@media (max-width: 720px), (max-width: 900px) and (orientation: portrait) { + .hub-fs-sections-grid { + display: flex; + flex-direction: column; + } +} + +/* 对齐实盘「实时持仓」pos-card */ +.hub-pos-card.pos-card { + background: var(--pos-card-bg); + border: 1px solid var(--border-soft); + border-radius: 10px; + padding: 12px 14px; +} + +.hub-pos-card .pos-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; +} + +.hub-pos-card .pos-card-symbol { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + min-width: 0; +} + +.hub-pos-card .pos-symbol-time-close, +.hub-mini-title .pos-symbol-time-close, +.td-symbol .pos-symbol-time-close { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.72rem; + font-weight: 500; + color: #8fc8ff; + padding: 1px 6px; + border-radius: 4px; + background: rgba(143, 200, 255, 0.1); + white-space: nowrap; + vertical-align: middle; +} +.hub-pos-card .pos-symbol-time-close .pos-time-close-cd, +.hub-mini-title .pos-symbol-time-close .pos-time-close-cd, +.td-symbol .pos-symbol-time-close .pos-time-close-cd { + font-variant-numeric: tabular-nums; + letter-spacing: 0.03em; +} +.hub-pos-card .pos-card-symbol strong { + font-size: 14px; + color: var(--text); + font-weight: 600; +} + +.hub-pos-card .pos-side-badge { + padding: 3px 8px; + border-radius: 6px; + font-size: 11px; + font-weight: 500; +} + +.hub-pos-card .pos-side-long, +.hub-pos-card .pos-side-badge.side-long { + background: rgba(0, 255, 157, 0.12); + color: var(--green); + border: 1px solid rgba(0, 255, 157, 0.35); +} + +.hub-pos-card .pos-side-short, +.hub-pos-card .pos-side-badge.side-short { + background: rgba(255, 77, 109, 0.12); + color: var(--red); + border: 1px solid rgba(255, 77, 109, 0.35); +} + +.side-long { + color: var(--green); + font-weight: 600; + text-shadow: 0 0 10px rgba(0, 255, 157, 0.25); +} + +.side-short { + color: var(--red); + font-weight: 600; + text-shadow: 0 0 10px rgba(255, 77, 109, 0.25); +} + +.data-table td.side-long, +.data-table td.side-short { + font-weight: 600; +} + +.hub-pos-card .pos-head-actions { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.hub-pos-card .pos-entrust-btn { + padding: 6px 12px; + background: rgba(42, 74, 122, 0.9); + color: #8fc8ff; + border: 1px solid rgba(0, 212, 255, 0.25); + border-radius: 8px; + font-size: 12px; + cursor: pointer; + white-space: nowrap; +} + +.hub-pos-card .pos-close-btn { + padding: 6px 14px; + background: rgba(196, 84, 84, 0.95); + color: #fff; + border: none; + border-radius: 8px; + font-size: 12px; + cursor: pointer; + white-space: nowrap; +} + +.hub-pos-card .pos-meta { + font-size: 11px; + color: var(--muted); + line-height: 1.45; + margin-bottom: 12px; + display: flex; + flex-wrap: wrap; + gap: 4px 0; +} + +.hub-pos-card .pos-meta-item:not(:last-child)::after { + content: "|"; + margin: 0 8px; + color: var(--border-soft); +} + +.hub-pos-card .pos-meta-on { + color: #6eb5ff; +} + +.hub-pos-card .pos-meta-off { + color: var(--muted); +} + +.hub-pos-card .pos-breakeven-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 6px; + font-size: 11px; + font-weight: 600; + background: #1a3d2e; + color: #4cd97f; +} + +.pos-breakeven-badge { + display: inline-flex; + align-items: center; + margin-left: 6px; + padding: 2px 8px; + border-radius: 6px; + font-size: 11px; + font-weight: 600; + background: #1a3d2e; + color: #4cd97f; + vertical-align: middle; + white-space: nowrap; +} + +.data-table .td-symbol { + white-space: nowrap; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px 6px; +} + +.hub-pos-card .pos-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px 14px; + margin-bottom: 12px; +} + +.hub-pos-card .pos-cell { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.hub-pos-card .pos-label { + font-size: 10px; + color: var(--muted); + letter-spacing: 0.04em; +} + +.hub-pos-card .pos-value { + font-size: 13px; + color: var(--text); + font-weight: 500; +} + +.hub-pos-card .pos-value.pnl-pos { + color: var(--green); + font-weight: 600; + text-shadow: 0 0 12px rgba(0, 255, 157, 0.25); +} + +.hub-pos-card .pos-value.pnl-neg { + color: var(--red); + font-weight: 600; +} + +.hub-pos-card .pos-footer { + display: flex; + flex-wrap: wrap; + gap: 12px 16px; + font-size: 11px; + color: var(--muted); + margin-bottom: 4px; +} + +.hub-pos-card .pos-ex-orders { + margin-top: 10px; + padding-top: 10px; + border-top: 1px dashed var(--border-soft); +} + +.hub-pos-card .pos-ex-orders-title { + font-size: 11px; + color: var(--muted); + margin-bottom: 6px; +} + +.hub-pos-card .pos-ex-order-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 12px; + margin-top: 5px; +} + +.hub-pos-card .pos-ex-order-main { + flex: 1; + min-width: 0; +} + +.hub-pos-card .pos-ex-cancel-btn { + padding: 3px 10px; + background: rgba(58, 48, 72, 0.9); + color: #d4b8ff; + border: 1px solid rgba(123, 97, 255, 0.35); + border-radius: 6px; + font-size: 11px; + cursor: pointer; + flex-shrink: 0; +} + +.hub-pos-card .pos-orders-collapse { + margin-top: 10px; +} + +.hub-section-card { + margin-top: 14px; + padding: 12px 14px; + background: var(--section-surface); + border: 1px solid var(--border-soft); + border-radius: 10px; +} + +.hub-section-head { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--accent); + margin-bottom: 10px; +} + +.hub-section-body { + display: flex; + flex-direction: column; + gap: 8px; +} + +.hub-key-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +/* 全屏放大:关键位 3 列网格 */ +.exchange-fullscreen .hub-key-list { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + align-items: stretch; +} + +.exchange-fullscreen .hub-key-list .hub-mini-card { + min-width: 0; + height: 100%; +} + +@media (max-width: 1100px) { + .exchange-fullscreen .hub-key-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 640px) { + .exchange-fullscreen .hub-key-list { + grid-template-columns: minmax(0, 1fr); + } +} + +.hub-mini-card { + padding: 10px 12px; + background: var(--inset-surface); + border: 1px solid var(--border-soft); + border-radius: 8px; +} + +.hub-mini-card.hub-key-pending, +.list-line.hub-key-pending { + border-color: rgba(0, 212, 255, 0.55); + background: rgba(0, 212, 255, 0.08); + box-shadow: 0 0 16px rgba(0, 212, 255, 0.12); +} + +.hub-key-pending-tag { + display: inline-block; + margin-left: 6px; + padding: 1px 7px; + font-size: 10px; + font-weight: 600; + color: var(--accent); + background: rgba(0, 212, 255, 0.15); + border: 1px solid rgba(0, 212, 255, 0.45); + border-radius: 4px; + vertical-align: middle; +} + +.hub-key-pending .hub-key-status-line, +.list-line.hub-key-pending { + color: var(--text); +} + +.hub-mini-title { + font-size: 12px; + font-weight: 600; + color: var(--text); + margin-bottom: 4px; +} + +.hub-mini-line { + font-size: 11px; + color: var(--muted); + line-height: 1.45; +} + +.pos-empty { + padding: 18px; + text-align: center; + color: var(--muted); + font-size: 12px; + border: 1px dashed var(--border-soft); + border-radius: 10px; +} + +@media (max-width: 520px) { + .hub-pos-card .pos-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +.settings-grid-wrap { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.stat-row { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 12px; +} + +.stat-box { + background: var(--inset-surface); + border: 1px solid var(--border-soft); + border-radius: 8px; + padding: 10px 12px; +} + +.stat-label { + font-size: 10px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.08em; + margin-bottom: 4px; +} + +.stat-value { + font-size: 17px; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--text); +} + +.section-title { + font-size: 10px; + font-weight: 600; + color: var(--accent); + text-transform: uppercase; + letter-spacing: 0.1em; + margin: 14px 0 8px; + padding-bottom: 6px; + border-bottom: 1px solid var(--border-soft); +} + +.section-title:first-child { + margin-top: 0; +} + +.pos-block { + margin-bottom: 14px; + padding-bottom: 10px; + border-bottom: 1px dashed var(--border-soft); +} + +.pos-block:last-child { + border-bottom: none; + margin-bottom: 0; +} + +.pos-table-wrap { + margin-bottom: 8px; +} + +.data-table-positions tbody tr:not(:last-child) td { + border-bottom: 1px dashed var(--border-soft); +} + +.card-strategy-stats { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 10px 0 4px; + padding-top: 8px; + border-top: 1px dashed var(--border-soft); +} + +.card-stat-chip { + display: inline-flex; + align-items: center; + padding: 3px 8px; + border-radius: 6px; + font-size: 11px; + line-height: 1.3; + border: 1px solid transparent; +} + +/* 突破 + 斐波 */ +.card-stat-chip.card-stat-key-breakout { + color: var(--accent); + background: rgba(0, 212, 255, 0.14); + border-color: rgba(0, 212, 255, 0.38); +} + +/* 关键位监控(阻力/支撑等) */ +.card-stat-chip.card-stat-key-watch { + color: #b8a0ff; + background: rgba(123, 97, 255, 0.18); + border-color: rgba(123, 97, 255, 0.42); +} + +/* 趋势回调 */ +.card-stat-chip.card-stat-trend { + color: var(--green); + background: rgba(0, 255, 157, 0.1); + border-color: rgba(0, 255, 157, 0.38); +} + +/* 趋势回调:与三所实例 strategy_trend_panel 同款卡片 */ +.hub-trend-running-title { + margin: 0 0 10px; + font-size: 0.95rem; + color: var(--accent); + font-weight: 600; +} + +.hub-trend-plan-list.running-plans-stack { + display: flex; + flex-direction: column; + gap: 12px; +} + +.hub-trend-plan-card.plan-position-card { + background: var(--panel-solid); + border: 1px solid var(--panel-solid-border); + border-radius: 12px; + padding: 12px 14px; +} + +.hub-trend-plan-card .plan-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 8px; +} + +.hub-trend-plan-card .plan-card-title { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + font-size: 1rem; + font-weight: 700; + color: var(--plan-title); +} + +.hub-trend-plan-card .plan-card-meta { + font-size: 0.76rem; + color: var(--plan-meta); + line-height: 1.55; + margin-bottom: 10px; +} + +.hub-trend-plan-card .plan-card-meta .accent { + color: var(--plan-meta-accent); +} + +.hub-trend-plan-card .plan-card-meta strong { + color: var(--accent); +} + +.hub-trend-plan-body-cols { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 14px 18px; + align-items: start; + margin-bottom: 10px; + padding-bottom: 10px; + border-bottom: 1px dashed var(--plan-border-dash); +} + +.hub-trend-plan-col-left .plan-card-meta { + margin-bottom: 10px; +} + +.hub-trend-plan-col-left .plan-card-grid { + margin-bottom: 0; +} + +.hub-trend-plan-card .plan-card-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px 14px; +} + +.hub-trend-plan-card .plan-cell { + display: flex; + flex-direction: column; + gap: 3px; +} + +.hub-trend-plan-card .plan-cell .lbl { + font-size: 0.72rem; + color: var(--plan-lbl); +} + +.hub-trend-plan-card .plan-cell .val { + color: var(--plan-val); + font-size: 0.88rem; + font-weight: 500; +} + +.hub-trend-plan-card .plan-cell .val.pnl-profit { + color: #4cd97f; +} + +.hub-trend-plan-card .plan-cell .val.pnl-loss { + color: #ff6666; +} + +.hub-trend-plan-card .plan-cell .val.pnl-neutral { + color: var(--plan-val-neutral); +} + +.hub-trend-plan-card .btn-close-plan { + padding: 7px 14px; + background: var(--plan-close-bg); + color: var(--plan-close-fg); + border: none; + border-radius: 8px; + cursor: pointer; + font-size: 0.82rem; + font-weight: 600; + text-decoration: none; + white-space: nowrap; + display: inline-block; +} + +.hub-trend-plan-card .btn-close-plan:hover { + filter: brightness(1.08); +} + +.hub-trend-plan-card .plan-dca-block--side { + margin-top: 0; + padding-top: 0; + border-top: none; + height: 100%; +} + +.hub-trend-plan-col-right { + min-width: 0; + border-left: 1px solid var(--plan-col-divider); + padding-left: 14px; +} + +.hub-dca-empty { + font-size: 0.76rem; + color: var(--plan-meta); + padding: 8px 0; +} + +.hub-trend-plan-foot { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 4px; +} + +.hub-trend-plan-foot .hub-plan-breakeven-row { + margin-top: 0; +} + +.hub-trend-plan-foot .hub-plan-account-foot { + margin-bottom: 0; +} + +.hub-trend-plan-card .plan-dca-title { + font-size: 0.74rem; + color: var(--plan-lbl); + margin-bottom: 8px; +} + +.hub-trend-plan-card .plan-dca-table { + width: 100%; + border-collapse: collapse; + font-size: 0.76rem; +} + +.hub-trend-plan-card .plan-dca-table th, +.hub-trend-plan-card .plan-dca-table td { + padding: 6px 8px; + border-bottom: 1px solid var(--plan-col-divider); + text-align: left; + font-weight: 500; +} + +.hub-trend-plan-card .plan-dca-table td { + color: var(--text); +} + +.hub-trend-plan-card .plan-dca-table th { + color: var(--plan-dca-th); + font-weight: 600; +} + +.hub-trend-plan-card .plan-dca-table .st-done { + color: var(--status-done); + font-weight: 700; +} + +.hub-trend-plan-card .plan-dca-table .st-pending { + color: var(--status-pending); + font-weight: 600; +} + +.hub-trend-plan-card .hub-plan-breakeven-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + margin-top: 8px; +} + +.hub-trend-plan-card .hub-plan-be-label { + font-size: 0.78rem; + color: var(--plan-be-label); + display: flex; + align-items: center; + gap: 6px; +} + +.hub-trend-plan-card .hub-plan-be-input { + width: 72px; + padding: 4px 8px; + border-radius: 6px; + border: 1px solid var(--plan-be-input-border); + background: var(--plan-be-input-bg); + color: var(--plan-val); + opacity: 0.92; +} + +.hub-trend-plan-card .hub-plan-be-btn { + padding: 6px 12px; + background: var(--plan-be-btn-bg); + color: var(--accent); + border: 1px solid var(--plan-be-input-border); + border-radius: 8px; + font-size: 0.78rem; + text-decoration: none; + cursor: pointer; + white-space: nowrap; +} + +.hub-trend-plan-card button.hub-plan-be-btn { + font-family: inherit; +} + +.hub-trend-plan-card .hub-plan-be-input:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.hub-trend-plan-card .hub-plan-be-btn--static { + cursor: default; +} + +.hub-trend-plan-card .hub-plan-be-done { + color: #6ab88a; + font-size: 0.75rem; +} + +.hub-trend-plan-card .hub-plan-account-foot { + margin-bottom: 0; +} + +.hub-trend-plan-card .badge.direction-long { + color: #4cd97f; + border-color: rgba(76, 217, 127, 0.45); +} + +.hub-trend-plan-card .badge.direction-short { + color: #ff6666; + border-color: rgba(255, 102, 102, 0.45); +} + +.exchange-fullscreen .hub-trend-plan-card.plan-position-card { + width: 100%; + max-width: 100%; +} + +@media (max-width: 900px) { + .hub-trend-plan-body-cols { + grid-template-columns: 1fr; + } + + .hub-trend-plan-col-right { + border-left: none; + padding-left: 0; + padding-top: 10px; + border-top: 1px dashed var(--plan-border-dash); + } +} + +@media (max-width: 720px) { + .hub-trend-plan-card .plan-card-grid { + grid-template-columns: 1fr; + } +} + +/* 顺势加仓 */ +.card-stat-chip.card-stat-roll { + color: #ffb020; + background: rgba(255, 176, 32, 0.14); + border-color: rgba(255, 176, 32, 0.42); +} + +.hub-tile .card-strategy-stats { + margin: 4px 0 0; + padding-top: 6px; + border-top: none; + gap: 4px; +} + +.hub-tile .card-stat-chip { + font-size: 10px; + padding: 2px 6px; +} + +.pos-action-group { + display: inline-flex; + flex-direction: row; + align-items: center; + justify-content: flex-end; + gap: 6px; + flex-wrap: nowrap; + white-space: nowrap; +} + +.data-table .td-actions .btn-sm { + margin: 0; + vertical-align: middle; +} + +button.btn-sm { + padding: 4px 11px; + font-size: 11px; + line-height: 1.35; + border-radius: 6px; + min-width: 48px; +} + +.btn-place-tpsl.btn-sm { + border-color: rgba(0, 212, 255, 0.35); + color: var(--accent); +} + +.pos-orders-collapse { + margin: 10px 0 0; + padding: 0; + background: var(--inset-surface); + border: 1px solid var(--border-soft); + border-radius: 8px; + overflow: hidden; +} + +.pos-orders-collapse-summary { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + cursor: pointer; + list-style: none; + user-select: none; + background: rgba(0, 212, 255, 0.04); + border-bottom: 1px solid transparent; +} + +.pos-orders-collapse[open] > .pos-orders-collapse-summary { + border-bottom-color: var(--border-soft); +} + +.pos-orders-collapse-summary::-webkit-details-marker { + display: none; +} + +.pos-orders-collapse-summary::before { + content: "▸"; + flex-shrink: 0; + color: var(--accent); + font-size: 11px; + width: 12px; + transition: transform 0.15s ease; +} + +.pos-orders-collapse[open] > .pos-orders-collapse-summary::before { + transform: rotate(90deg); +} + +.pos-orders-collapse-label { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + color: var(--text); +} + +.pos-orders-collapse-label em { + font-style: normal; + color: var(--accent); + margin-left: 2px; +} + +.pos-orders-collapse-meta { + flex: 1; + font-size: 10px; + color: var(--muted); + min-width: 0; +} + +.pos-orders-collapse-summary .btn-cancel-cond-all { + flex-shrink: 0; + margin-left: auto; +} + +.pos-orders-collapse-body { + padding: 8px 10px 10px; +} + +.orders-section + .orders-section { + margin-top: 10px; + padding-top: 10px; + border-top: 1px dashed var(--border-soft); +} + +.orders-section-head { + font-size: 10px; + color: var(--muted); + letter-spacing: 0.08em; + text-transform: uppercase; + margin-bottom: 6px; +} + +.data-table-sub { + font-size: 10px; +} + +.data-table-sub th, +.data-table-sub td { + padding: 5px 6px; +} + +.order-empty { + font-size: 11px; + color: var(--muted); + padding: 6px 4px 8px; +} + +.modal { + position: fixed; + inset: 0; + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; +} + +.modal.hidden { + display: none; +} + +.modal-backdrop { + position: absolute; + inset: 0; + background: var(--overlay); +} + +.modal-panel, +.modal-card { + position: relative; + z-index: 1; + width: 100%; + max-width: 380px; + padding: 20px 22px; + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); +} + +.modal-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.modal-head h3 { + margin: 0; + font-family: var(--display); + font-size: 14px; + letter-spacing: 0.06em; +} + +.plan-modal-close { + flex-shrink: 0; + min-width: 32px; + padding: 4px 8px; + font-size: 18px; + line-height: 1; +} + +.modal-panel h3 { + margin: 0 0 8px; + font-family: var(--display); + font-size: 14px; + letter-spacing: 0.06em; +} + +.modal-meta { + margin: 0 0 14px; + font-size: 12px; + color: var(--muted); +} + +.modal-field { + margin-bottom: 12px; +} + +.modal-field label { + display: block; + font-size: 10px; + color: var(--muted); + margin-bottom: 4px; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.modal-field input { + width: 100%; + padding: 8px 10px; + background: var(--bg-elevated); + border: 1px solid var(--border-soft); + border-radius: 6px; + color: var(--text); + font-family: var(--font); + font-size: 13px; +} + +.modal-hint { + font-size: 11px; + color: var(--muted); + margin: 0 0 14px; + line-height: 1.5; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.table-scroll { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + max-width: 100%; +} + +.data-table { + width: 100%; + min-width: 300px; + border-collapse: collapse; + font-size: 11px; +} + +.data-table th { + color: var(--muted); + font-weight: 500; + font-size: 10px; + padding: 6px 8px; + text-align: left; + border-bottom: 1px solid var(--border-soft); +} + +.data-table td { + padding: 8px; + border-bottom: 1px solid var(--border-soft); + font-variant-numeric: tabular-nums; +} + +.data-table tr:last-child td { + border-bottom: none; +} + +.list-line { + font-size: 11px; + color: var(--muted); + padding: 6px 0; + border-bottom: 1px dashed var(--border-soft); + line-height: 1.45; +} +.list-line:last-child { + border-bottom: none; +} + +.empty-hint { + font-size: 11px; + color: var(--muted); + padding: 8px 0; +} + +.board-loading-sub { + margin: 12px 0 0; + font-size: 12px; + line-height: 1.5; + color: var(--muted); + max-width: 36rem; +} + +.board-loading { + grid-column: 1 / -1; + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + min-height: 120px; + padding: 24px; + color: var(--muted); + font-size: 13px; + border: 1px dashed var(--border-soft); + border-radius: var(--radius); + background: rgba(0, 0, 0, 0.25); +} + +.board-loading-spin { + width: 18px; + height: 18px; + border: 2px solid var(--border-soft); + border-top-color: var(--accent); + border-radius: 50%; + animation: hub-spin 0.8s linear infinite; +} + +@keyframes hub-spin { + to { + transform: rotate(360deg); + } +} + +.pnl-pos { + color: var(--green); + text-shadow: 0 0 12px rgba(0, 255, 157, 0.3); +} +.pnl-neg { + color: var(--red); +} + +.data-table td.pnl-pos { + color: var(--green); + font-weight: 600; +} + +.data-table td.pnl-neg { + color: var(--red); + font-weight: 600; +} +.err { + color: var(--red); + font-size: 12px; +} + +.badge { + font-size: 9px; + padding: 2px 8px; + border-radius: 999px; + background: var(--accent-dim); + color: var(--accent); + border: 1px solid var(--border); + white-space: nowrap; + letter-spacing: 0.06em; +} + +.settings-meta-line { + font-size: 11px; + color: var(--muted); + padding: 10px 14px; + background: var(--panel); + border-left: 3px solid var(--accent); + border-radius: 0 var(--radius) var(--radius) 0; + margin-bottom: 16px; + line-height: 1.55; + border: 1px solid var(--border-soft); + border-left-width: 3px; +} + +.field { + display: flex; + flex-direction: column; + gap: 5px; +} + +.field label, +.field > span { + font-size: 10px; + color: var(--muted); + font-weight: 500; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.field-wide { + grid-column: 1 / -1; +} + +.field input, +.field select, +.form-row input, +.form-row select { + background: var(--bg-elevated); + border: 1px solid var(--border); + color: var(--text); + border-radius: 8px; + padding: 9px 11px; + font-size: 12px; + font-family: var(--mono); + width: 100%; +} + +.field input:focus, +.field select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(0, 212, 255, 0.2), var(--glow); +} + +.field-check { + flex-direction: row; + align-items: center; + gap: 8px; + padding-top: 20px; +} + +.field-check label { + font-size: 12px; + color: var(--text); + cursor: pointer; + text-transform: none; +} + +.settings-display-panel, +.settings-macro-panel, +.settings-supervisor-panel { + margin-bottom: 0; +} + +.settings-section { + margin-bottom: 16px; +} + +.settings-section-head { + display: flex; + align-items: center; + gap: 10px; + padding: 14px 16px; + border-bottom: 1px solid var(--border-soft); +} + +.settings-section.is-collapsed .settings-section-head { + border-bottom-color: transparent; +} + +.settings-section-head .settings-display-title { + flex: 1; + margin: 0; + min-width: 0; +} + +.settings-section-head-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.settings-section-fold { + flex-shrink: 0; + width: 28px; + height: 28px; + padding: 0; + border: 1px solid var(--border-soft); + border-radius: 6px; + background: color-mix(in srgb, var(--panel) 90%, var(--accent) 10%); + color: var(--accent); + cursor: pointer; + font-size: 0; + line-height: 1; + transition: transform 0.15s ease, border-color 0.15s ease; + position: relative; +} + +.settings-section-fold::before { + content: "▾"; + font-size: 0.85rem; + line-height: 28px; + display: block; + text-align: center; +} + +.settings-section-fold:hover { + border-color: color-mix(in srgb, var(--accent) 50%, var(--border-soft)); +} + +.settings-section.is-collapsed .settings-section-fold::before { + content: "▸"; +} + +.settings-section-save { + flex-shrink: 0; + font-size: 0.82rem; + padding: 6px 14px; +} + +.settings-section-body { + padding: 14px 16px; +} + +.settings-section.is-collapsed .settings-section-body { + display: none; +} + +.settings-page-toolbar { + margin-top: 4px; +} + +.settings-card-topbar { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + padding-bottom: 10px; + border-bottom: 1px dashed var(--border-soft); +} + +.settings-card-fold { + flex-shrink: 0; + width: 26px; + height: 26px; + padding: 0; + border: 1px solid var(--border-soft); + border-radius: 6px; + background: transparent; + color: var(--muted); + cursor: pointer; + font-size: 0; + line-height: 1; + transition: color 0.15s ease, border-color 0.15s ease; + position: relative; +} + +.settings-card-fold::before { + content: "▾"; + font-size: 0.8rem; + line-height: 26px; + display: block; + text-align: center; +} + +.settings-card-fold:hover { + color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 40%, var(--border-soft)); +} + +.settings-card.is-collapsed .settings-card-fold::before { + content: "▸"; +} + +.settings-card-title { + flex: 1; + min-width: 0; + font-size: 0.92rem; + font-weight: 600; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.settings-card-save { + flex-shrink: 0; + font-size: 0.78rem; + padding: 5px 12px; +} + +.settings-card-body { + display: block; +} + +.settings-card.is-collapsed .settings-card-body { + display: none; +} + +@media (max-width: 720px) { + .settings-section-head { + flex-wrap: wrap; + } + + .settings-section-head-actions { + width: 100%; + justify-content: flex-end; + } + + .settings-card-topbar { + flex-wrap: wrap; + } +} + +.settings-display-title { + margin: 0 0 10px; + font-size: 0.95rem; + color: var(--text); +} + +.settings-display-chk { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.88rem; +} + +.settings-display-chk + .settings-display-chk { + margin-top: 8px; +} + +.settings-display-hint { + margin: 8px 0 0; + font-size: 0.78rem; + color: var(--muted); + line-height: 1.45; +} + +.backup-settings-grid { + margin-top: 12px; +} + +.backup-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + margin-top: 16px; +} + +.backup-status-line { + font-size: 0.82rem; + color: var(--muted); +} + +.backup-status-line.err { + color: var(--danger, #f87171); +} + +.backup-restore-upload { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 12px; + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--border); +} + +.backup-upload-label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 0.82rem; + color: var(--muted); +} + +.backup-list { + margin-top: 16px; +} + +.backup-meta { + font-size: 0.78rem; + color: var(--muted); + line-height: 1.5; + margin-bottom: 10px; +} + +.backup-meta code { + font-size: 0.76rem; +} + +.backup-empty { + font-size: 0.82rem; + color: var(--muted); +} + +.backup-table { + width: 100%; + border-collapse: collapse; + font-size: 0.82rem; +} + +.backup-table th, +.backup-table td { + padding: 8px 10px; + border-bottom: 1px solid var(--border); + text-align: left; +} + +.backup-row-actions { + white-space: nowrap; +} + +.backup-row-actions .ghost, +.backup-row-actions .danger { + font-size: 0.78rem; + padding: 4px 8px; +} + +.settings-card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px; + backdrop-filter: blur(10px); +} + +.settings-card-head { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 14px; + flex-wrap: wrap; +} + +.settings-card-head .ex-name { + flex: 1; + min-width: 160px; + font-size: 14px; + font-weight: 600; + font-family: var(--display); + background: transparent; + border: none; + border-bottom: 1px dashed var(--border); + color: var(--text); + padding: 4px 0; +} + +.settings-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 12px; +} + +.settings-grid .field input { + font-size: 11px; +} + +.cap-chips { + display: flex; + gap: 10px; + flex-wrap: wrap; + padding: 8px 0; +} + +.cap-chips label { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--text); + cursor: pointer; + padding: 6px 12px; + background: rgba(0, 0, 0, 0.35); + border-radius: 999px; + border: 1px solid var(--border-soft); +} + +.settings-card-foot { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border-soft); +} + +.settings-card-foot .field { + max-width: 80px; +} + +#toast { + position: fixed; + bottom: 20px; + right: 20px; + max-width: min(420px, 92vw); + background: var(--panel); + border: 1px solid var(--accent); + padding: 12px 16px; + border-radius: var(--radius); + display: none; + z-index: 50; + white-space: pre-wrap; + font-size: 12px; + box-shadow: var(--glow); + backdrop-filter: blur(12px); +} + +#toast.show { + display: block; +} + +/* —— 登录页 —— */ +body.login-page { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 24px; +} + +.login-theme-bar { + position: relative; + z-index: 2; + width: 100%; + max-width: 400px; + display: flex; + justify-content: flex-end; + margin-bottom: 10px; +} + +.login-panel { + position: relative; + z-index: 1; + width: 100%; + max-width: 400px; + padding: 28px 26px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 12px; + backdrop-filter: blur(16px); + box-shadow: var(--shadow), var(--glow); +} + +.login-brand { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 24px; +} + +.login-title { + font-family: var(--display); + font-size: 16px; + font-weight: 600; + letter-spacing: 0.08em; +} + +.login-sub { + font-size: 10px; + color: var(--muted); + letter-spacing: 0.16em; + margin-top: 4px; +} + +.login-form .field { + margin-bottom: 16px; +} + +.login-submit { + width: 100%; + padding: 12px; +} + +.login-err { + color: var(--red); + font-size: 12px; + margin: 10px 0 0; +} + +.login-foot { + margin: 20px 0 0; + font-size: 10px; + color: var(--muted); + line-height: 1.5; +} +.login-foot code { + color: var(--accent); + font-size: 10px; +} + +/* —— 手机 / 窄屏自适应 —— */ +@media (max-width: 720px) { + .app-shell { + padding: 0 max(12px, env(safe-area-inset-right)) max(28px, env(safe-area-inset-bottom)) + max(12px, env(safe-area-inset-left)); + } + + .app-header { + flex-direction: column; + align-items: stretch; + gap: 12px; + padding: 14px 0; + } + + .brand-sub { + display: none; + } + + .app-header { + padding: 10px 0; + margin-bottom: 4px; + } + + .header-right { + width: 100%; + display: grid; + grid-template-columns: 1fr auto auto; + grid-template-rows: auto auto; + align-items: center; + gap: 8px; + } + + .header-right .theme-toggle { + grid-column: 1; + justify-self: start; + } + + .sys-pill { + grid-column: 2; + align-self: center; + } + + button.ghost#btn-logout { + grid-column: 3; + width: auto; + min-height: 36px; + padding: 6px 12px; + justify-self: end; + } + + .top-nav { + grid-column: 1 / -1; + width: 100%; + display: flex; + flex-wrap: nowrap; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + gap: 6px; + padding-bottom: 2px; + } + + .top-nav::-webkit-scrollbar { + display: none; + } + + .top-nav a { + flex: 0 0 auto; + text-align: center; + padding: 8px 14px; + min-height: 40px; + display: inline-flex; + align-items: center; + justify-content: center; + white-space: nowrap; + } + + .page-desc { + display: none; + } + + .market-toolbar { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + align-items: end; + } + + .market-field { + min-width: 0; + } + + .market-field select, + .market-field input { + width: 100%; + min-width: 0; + } + + .market-field-symbol { + grid-column: 1 / -1; + } + + .market-toolbar .toolbar-spacer { + display: none; + } + + .market-toolbar #market-load { + grid-column: 1; + } + + .market-toolbar #market-refresh { + grid-column: 2; + } + + .market-toolbar .toolbar-meta { + grid-column: 1 / -1; + text-align: left; + font-size: 0.72rem; + } + + .market-chart-wrap { + min-height: 260px; + height: min(52vh, 420px); + } + + .archive-toolbar { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px 10px; + align-items: center; + } + + .archive-toolbar .archive-field { + grid-column: 1 / -1; + } + + .archive-toolbar .chk-label { + margin: 0; + min-height: 36px; + justify-content: flex-start; + } + + .archive-toolbar #archive-btn-refresh { + grid-column: 1; + } + + .archive-toolbar #archive-btn-sync { + grid-column: 2; + } + + .archive-toolbar .toolbar-meta { + grid-column: 1 / -1; + text-align: left; + } + + body.hub-page-ai .page-head { + margin: 4px 0 6px; + } + + body.hub-page-ai .page-head h1 { + margin: 0; + font-size: 15px; + } + + .page-head { + margin: 16px 0 12px; + } + + .page-head h1 { + font-size: 17px; + flex-wrap: wrap; + } + + .toolbar { + flex-direction: column; + align-items: stretch; + gap: 8px; + } + + .toolbar-spacer { + display: none; + } + + .toolbar-meta { + text-align: center; + order: 10; + } + + .toolbar button, + .toolbar .chk-label { + width: 100%; + justify-content: center; + min-height: 44px; + } + + .grid-monitor:not(.grid-monitor-tiles), + .settings-grid-wrap { + grid-template-columns: minmax(0, 1fr) !important; + gap: 12px; + } + + .grid-monitor.grid-monitor-tiles { + grid-template-columns: repeat(2, minmax(0, 1fr)) !important; + gap: 10px; + } + + #page-monitor .page-head { + margin-bottom: 8px; + } + + #page-monitor .page-head h1 { + margin-bottom: 0; + } + + .monitor-alert-summary { + margin-bottom: 8px; + } + + .host-status-panel { + margin-bottom: 10px; + } + + .host-status-summary { + flex-wrap: wrap; + padding: 8px 10px; + } + + .host-status-bar { + padding: 10px; + } + + .host-status-top { + flex-direction: column; + align-items: stretch; + } + + .host-status-meta { + justify-content: flex-start; + } + + .host-status-metrics { + grid-template-columns: minmax(0, 1fr); + gap: 8px; + } + + .card-head { + flex-direction: column; + align-items: stretch; + gap: 10px; + } + + .card-actions { + flex-wrap: wrap; + width: 100%; + gap: 8px; + } + + .card-actions .btn-link, + .card-actions button { + flex: 1 1 calc(50% - 4px); + min-height: 40px; + text-align: center; + justify-content: center; + } + + .card-body { + padding: 12px; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + .stat-row { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + } + + .stat-value { + font-size: 14px; + } + + .stat-label { + font-size: 9px; + } + + .instance-frame-toolbar { + flex-wrap: wrap; + gap: 8px; + padding: 8px 10px; + } + + .instance-frame-title { + flex: 1 1 100%; + order: -1; + font-size: 0.82rem; + } + + .instance-frame-actions { + flex: 1 1 auto; + justify-content: flex-end; + } + + .instance-frame { + height: calc(100dvh - 96px); + } + + .exchange-fullscreen { + padding: max(10px, env(safe-area-inset-top)) max(10px, env(safe-area-inset-right)) + max(16px, env(safe-area-inset-bottom)) max(10px, env(safe-area-inset-left)); + } + + .exchange-fullscreen-panel { + max-width: 100%; + } + + .fs-head { + flex-direction: column; + align-items: stretch; + gap: 12px; + } + + .fs-head-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + width: 100%; + } + + .fs-head-actions .btn-expand-back { + grid-column: 1 / -1; + } + + .fs-head-actions .btn-open-trade { + grid-column: 1 / -1; + } + + .fs-head-actions .btn-link, + .fs-head-actions button { + min-height: 44px; + text-align: center; + justify-content: center; + } + + .hub-pos-card .pos-card-head { + flex-direction: column; + align-items: stretch; + } + + .hub-pos-card .pos-head-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + width: 100%; + } + + .hub-pos-card .pos-entrust-btn, + .hub-pos-card .pos-close-btn { + width: 100%; + min-height: 44px; + text-align: center; + } + + .hub-pos-card .pos-ex-order-row { + flex-direction: column; + align-items: stretch; + gap: 6px; + } + + .settings-grid { + grid-template-columns: 1fr; + } + + .settings-card-foot { + flex-direction: column; + align-items: stretch; + gap: 10px; + } + + .settings-card-foot .field { + max-width: none; + } + + .modal { + padding: max(12px, env(safe-area-inset-top)) 12px max(12px, env(safe-area-inset-bottom)); + align-items: flex-end; + } + + .modal-panel { + max-width: none; + width: 100%; + border-radius: 12px 12px 0 0; + max-height: 90vh; + overflow-y: auto; + } + + .modal-actions { + flex-direction: column-reverse; + } + + .modal-actions button { + width: 100%; + min-height: 44px; + } + + #toast { + left: 12px; + right: 12px; + bottom: max(12px, env(safe-area-inset-bottom)); + max-width: none; + } + + body.login-page { + padding: max(16px, env(safe-area-inset-top)) 16px max(16px, env(safe-area-inset-bottom)); + } + + .login-panel { + padding: 22px 18px; + } +} + +@media (max-width: 480px) { + body { + font-size: 12px; + } + + .brand-title { + font-size: 13px; + } + + .stat-row { + grid-template-columns: 1fr; + } + + .card-actions .btn-link, + .card-actions button { + flex: 1 1 100%; + } + + .fs-head-actions { + grid-template-columns: 1fr; + } + + .pos-action-group { + flex-direction: column; + align-items: stretch; + width: 100%; + } + + .pos-action-group .btn-sm { + width: 100%; + min-height: 44px; + } + + .data-table .td-actions { + white-space: normal; + } +} + +/* ---------- 行情区 ---------- */ +.market-toolbar { + flex-wrap: wrap; + gap: 10px; + align-items: flex-end; +} + +.market-field { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 0.72rem; + color: var(--muted); +} + +.market-field select, +.market-field input { + min-width: 120px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--border-soft); + background: var(--bg-elevated); + color: var(--text); + font-family: var(--font); +} + +.market-status { + font-size: 0.8rem; + color: var(--muted); + margin: 0 0 10px; +} + +.market-status.err { + color: var(--red); +} + +.market-status.warn { + color: #ffb84d; +} + +.market-countdown { + color: var(--accent); + font-variant-numeric: tabular-nums; +} + +.market-countdown.market-tf-key-hint { + color: #ffb84d; +} + +.market-chart-wrap { + display: flex; + flex-direction: column; + height: min(76vh, 680px); + min-height: 380px; + border: 1px solid var(--border-soft); + border-radius: var(--radius); + background: var(--chart-surface); + overflow: hidden; +} + +.market-chart-wrap.has-pos-panel { + height: min(80vh, 740px); + min-height: 440px; +} + +.market-chart-wrap.is-fullscreen { + position: fixed; + inset: 0; + z-index: 8500; + width: 100vw; + height: 100vh !important; + max-height: none; + min-height: 0; + border-radius: 0; + border: none; +} + +.market-chart-wrap.is-fullscreen.has-pos-panel { + height: 100vh !important; +} + +.market-chart-actions { + margin-left: auto; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 10px; +} + +.market-day-split-opt { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.72rem; + color: var(--muted); + cursor: pointer; + user-select: none; + padding: 2px 8px; + border-radius: 4px; + border: 1px solid var(--border-soft); + white-space: nowrap; +} + +.market-day-split-opt:hover { + color: var(--text); + border-color: var(--border); +} + +.market-day-split-opt input { + accent-color: #3b82f6; +} + +.market-day-split-opt:has(input:checked) { + color: #3b82f6; + border-color: rgba(59, 130, 246, 0.45); +} + +.market-ind-menu { + position: relative; + font-size: 0.72rem; +} + +.market-ind-menu summary { + cursor: pointer; + list-style: none; + padding: 2px 10px; + border-radius: 4px; + border: 1px solid var(--border-soft); + color: var(--muted); + user-select: none; +} + +.market-ind-menu summary::-webkit-details-marker { + display: none; +} + +.market-ind-menu[open] summary { + color: var(--accent); + border-color: rgba(0, 255, 157, 0.35); +} + +.market-ind-options { + position: absolute; + right: 0; + top: calc(100% + 4px); + z-index: 20; + min-width: 168px; + padding: 8px 10px; + border-radius: 6px; + border: 1px solid var(--border-soft); + background: var(--panel-solid); + box-shadow: var(--shadow); + display: flex; + flex-direction: column; + gap: 6px; +} + +.market-ind-opt { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + color: var(--text); + white-space: nowrap; +} + +.market-ind-opt input { + accent-color: var(--accent); +} + +.market-fs-btn, +.market-fs-exit { + font-size: 0.72rem; + padding: 2px 10px; +} + +.market-fs-exit { + position: absolute; + top: 8px; + left: 8px; + z-index: 12; +} + +.market-chart-wrap.is-fullscreen .market-fs-exit:not(.hidden) { + display: inline-flex !important; +} + +.market-chart-wrap.is-fullscreen .market-fs-btn { + display: none; +} + +.market-fs-toolbar { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 8px 12px; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--border-soft); +} + +.market-fs-toolbar.hidden { + display: none; +} + +.market-fs-field.market-field-symbol .market-symbol-wrap { + min-width: 180px; +} + +.market-fs-field span { + font-size: 0.68rem; + color: var(--muted); +} + +.market-fs-field select, +.market-fs-field input { + font-size: 0.78rem; + min-width: 100px; +} + +.market-div-legend { + margin-top: 4px; + font-size: 0.72rem; + color: #ffb84d; + line-height: 1.4; +} + +.market-div-legend.hidden { + display: none; +} + +.market-ohlcv-bar { + flex: 0 0 auto; + padding: 8px 12px; + border-bottom: 1px solid var(--border-soft); + background: var(--chart-bar-bg); + font-size: 0.78rem; +} + +.market-chart-body { + flex: 1; + display: flex; + flex-direction: row; + min-height: 0; + position: relative; +} + +.market-draw-toolbar { + flex: 0 0 40px; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 6px 4px; + border-right: 1px solid var(--border-soft); + background: var(--chart-bar-bg); + z-index: 4; + overflow-y: auto; +} + +.market-draw-btn { + width: 32px; + height: 32px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: var(--muted); + cursor: pointer; + flex-shrink: 0; +} + +.market-draw-btn svg { + width: 18px; + height: 18px; +} + +.market-draw-btn-text { + font-size: 0.82rem; + font-weight: 700; + font-family: var(--font); +} + +.market-draw-btn:hover { + color: var(--text); + background: var(--inset-surface); + border-color: var(--border-soft); +} + +.market-draw-btn.is-active { + color: var(--accent); + background: rgba(0, 255, 157, 0.1); + border-color: rgba(0, 255, 157, 0.35); +} + +.market-draw-sep { + width: 22px; + height: 1px; + background: var(--border-soft); + margin: 2px 0; +} + +.market-chart-main { + flex: 1; + min-width: 0; + height: 100%; + position: relative; + display: flex; +} + +.market-chart-host { + flex: 1; + min-width: 0; + height: 100%; + position: relative; + overflow: hidden; +} + +.market-draw-canvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 20; + pointer-events: none; + touch-action: none; +} + +.market-draw-canvas.is-drawing { + cursor: crosshair; + pointer-events: auto; +} + +.market-field-symbol .market-symbol-wrap { + display: flex; + align-items: stretch; + gap: 6px; + min-width: 0; +} + +.market-field-symbol .market-symbol-wrap > input { + flex: 1; + min-width: 120px; +} + +.market-vol-rank-btn { + flex: 0 0 auto; + min-height: 34px; + padding: 0 10px; + border: 1px solid var(--border-soft); + border-radius: 6px; + background: var(--inset-surface); + color: var(--accent); + font-size: 0.78rem; + font-weight: 600; + font-family: var(--font); + white-space: nowrap; + cursor: pointer; +} + +.market-vol-rank-btn:hover { + border-color: rgba(0, 255, 157, 0.35); + background: rgba(0, 255, 157, 0.08); +} + +.market-vol-rank-btn.is-active { + border-color: rgba(0, 255, 157, 0.45); + background: rgba(0, 255, 157, 0.12); + color: var(--accent); +} + +.market-vol-rank-anchor { + margin: -6px 0 12px; +} + +.market-vol-rank-anchor:empty, +.market-vol-rank-anchor-fs:empty { + display: none; +} + +.market-vol-rank-sheet { + padding: 10px 12px 8px; + border: 1px solid var(--border-soft); + border-radius: var(--radius); + background: var(--panel); + box-shadow: var(--glow); +} + +.market-chart-wrap .market-vol-rank-sheet { + margin: 0; + border-radius: 0; + border-left: none; + border-right: none; + box-shadow: none; +} + +.market-chart-wrap.is-fullscreen .market-vol-rank-sheet { + background: var(--chart-bar-bg); +} + +.market-vol-rank-sheet.hidden { + display: none; +} + +.market-vol-rank-meta { + padding: 0 10px 6px; + font-size: 0.68rem; + color: var(--muted); + line-height: 1.35; +} + +.market-vol-rank-list { + margin: 0; + padding: 0; + list-style: none; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); + gap: 2px 12px; + max-height: 200px; + overflow: auto; +} + +.market-vol-rank-item { + width: 100%; + display: grid; + grid-template-columns: 28px 1fr auto; + gap: 6px; + align-items: center; + padding: 6px 10px; + border: 0; + background: transparent; + color: var(--text); + font-size: 0.8rem; + font-family: var(--font); + text-align: left; + cursor: pointer; +} + +.market-vol-rank-item:hover { + background: var(--inset-surface); +} + +.market-vol-rank-item.is-active { + background: rgba(0, 255, 157, 0.1); + color: var(--accent); +} + +.market-vol-rank-no { + color: var(--muted); + font-variant-numeric: tabular-nums; +} + +.market-vol-rank-sym { + font-weight: 600; +} + +.market-vol-rank-vol { + color: var(--muted); + font-size: 0.72rem; + font-variant-numeric: tabular-nums; +} + +.market-draw-menu { + position: fixed; + z-index: 1200; + min-width: 168px; + padding: 4px 0; + border: 1px solid var(--border-soft); + border-radius: 8px; + background: var(--panel-bg, #1a1f2e); + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45); +} + +.market-draw-menu.hidden { + display: none; +} + +.market-draw-menu-head { + padding: 6px 12px 4px; + font-size: 0.72rem; + font-weight: 600; + color: var(--muted); + text-transform: none; +} + +.market-draw-menu-item { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 7px 12px; + border: 0; + background: transparent; + color: var(--text); + font-size: 0.82rem; + font-family: var(--font); + text-align: left; + cursor: pointer; +} + +.market-draw-menu-item:hover:not(:disabled) { + background: var(--inset-surface); +} + +.market-draw-menu-item:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.market-draw-menu-item.is-danger { + color: #f87171; +} + +.market-draw-menu-sep { + border: 0; + border-top: 1px solid var(--border-soft); + margin: 4px 0; +} + +.market-draw-menu-kbd { + margin-left: 12px; + padding: 1px 5px; + border-radius: 4px; + background: var(--inset-surface); + color: var(--muted); + font-size: 0.68rem; +} + +.market-exchange-badge { + position: absolute; + left: 50%; + top: 50%; + z-index: 1; + transform: translate(-50%, -50%) rotate(-90deg); + transform-origin: center center; + font-family: var(--font-display, var(--font)); + font-size: 0.95rem; + font-weight: 600; + letter-spacing: 0.12em; + color: var(--muted); + opacity: 0.22; + pointer-events: none; + white-space: nowrap; + user-select: none; +} + +.market-exchange-badge:empty { + display: none; +} + +.market-ohlcv-title { + font-weight: 600; + color: var(--accent); + margin-bottom: 4px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 10px; +} + +.mkt-exchange-tag { + padding: 1px 8px; + border-radius: 4px; + background: rgba(0, 255, 157, 0.12); + border: 1px solid rgba(0, 255, 157, 0.35); + color: var(--green); + font-size: 0.72rem; + font-weight: 600; +} + +.mkt-exchange-tag:empty { + display: none; +} + +.market-ohlcv-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 14px; + font-weight: 600; +} + +.market-ohlcv-row .ohlcv-item { + white-space: nowrap; +} + +.market-ohlcv-row .k { + color: var(--muted); + margin-right: 4px; +} + +.market-pos-panel { + flex: 0 0 auto; + padding: 8px 12px 10px; + border-bottom: 1px solid var(--border-soft); + background: var(--chart-bar-bg); + color: var(--text); + font-size: 0.8rem; +} + +.market-pos-panel.hidden { + display: none; +} + +.market-pos-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 14px; +} + +.market-pos-side { + padding: 1px 8px; + border-radius: 4px; + font-size: 0.72rem; + font-weight: 600; +} + +.market-pos-side.side-long { + background: rgba(0, 255, 157, 0.12); + border: 1px solid rgba(0, 255, 157, 0.35); + color: var(--green); +} + +.market-pos-side.side-short { + background: rgba(255, 77, 109, 0.12); + border: 1px solid rgba(255, 77, 109, 0.35); + color: var(--red); +} + +.market-pos-clear { + margin-left: auto; + font-size: 0.72rem; + padding: 2px 8px; +} + +.market-pos-pnl { + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.market-pos-pnl.pnl-up { + color: #3ddc84; +} + +.market-pos-pnl.pnl-down { + color: #ff7070; +} + +.market-pos-panel .ohlcv-item { + font-weight: 600; + color: var(--text); +} + +.market-pos-panel .ohlcv-item .k { + font-weight: 600; + color: var(--muted); +} + +.market-pos-orders { + display: flex; + flex-wrap: wrap; + gap: 4px 10px; + margin-top: 6px; + color: var(--text); + font-weight: 500; +} + +.market-pos-orders-empty { + font-size: 0.72rem; + opacity: 0.75; +} + +.market-pos-order { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 4px; + background: var(--inset-surface); + border: 1px solid var(--border-soft); + white-space: nowrap; + font-weight: 500; +} + +.market-pos-order-kind { + color: var(--accent); + font-size: 0.68rem; +} + +.market-pos-order-label { + color: var(--text); +} + +.market-pos-order-price { + color: #c98a20; + font-family: var(--font-mono, monospace); + font-weight: 600; +} + +.market-pos-order-amt { + color: var(--muted); + font-size: 0.68rem; +} + +.market-pos-tp-monitored { + color: var(--accent); + font-size: 0.72rem; + font-weight: 600; +} + +.sym-link { + background: none; + border: none; + padding: 0; + margin: 0; + font: inherit; + color: var(--accent); + cursor: pointer; + text-align: left; + text-decoration: underline; + text-underline-offset: 2px; +} + +.sym-link:hover { + color: #00ff9d; +} + +.pos-symbol-link { + display: inline; +} + +.pos-symbol-link strong { + font-weight: inherit; +} + +.market-price-tag { + position: absolute; + right: 0; + z-index: 5; + pointer-events: none; + padding: 4px 8px; + border-radius: 4px 0 0 4px; + font-family: var(--font); + font-size: 0.72rem; + font-weight: 600; + line-height: 1.25; + text-align: center; + transform: translateY(-50%); + min-width: 72px; + box-shadow: 0 1px 6px rgba(0, 0, 0, 0.35); +} + +.market-price-tag-head { + display: flex; + flex-direction: row; + align-items: baseline; + justify-content: center; + gap: 4px; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.market-price-tag-label { + font-size: 0.62rem; + font-weight: 500; + opacity: 0.9; + line-height: 1; +} + +.market-price-tag.is-up .market-price-tag-label { + color: rgba(10, 16, 24, 0.75); +} + +.market-price-tag.is-down .market-price-tag-label { + color: rgba(255, 255, 255, 0.85); +} + +.market-price-tag.hidden { + display: none; +} + +.market-price-tag.is-up { + background: #00ff9d; + color: #0a1018; +} + +.market-price-tag.is-down { + background: #ff4d6d; + color: #fff; +} + +.market-price-tag-value { + font-variant-numeric: tabular-nums; +} + +.market-price-tag-time { + margin-top: 3px; + font-size: 0.68rem; + font-weight: 500; + font-variant-numeric: tabular-nums; + line-height: 1; + opacity: 0.95; +} + +.market-price-auto { + position: absolute; + right: 8px; + bottom: 10px; + z-index: 5; + width: auto; + padding: 4px 8px; + font-size: 0.68rem; + font-family: var(--font); + border-radius: 6px; + border: 1px solid var(--border-soft); + background: var(--chart-bar-bg); + color: var(--muted); + cursor: pointer; + line-height: 1.2; +} + +.market-price-auto:hover { + border-color: var(--accent); + color: var(--text); +} + +.market-price-auto.is-on { + color: var(--green); + border-color: rgba(0, 255, 157, 0.45); + background: rgba(0, 255, 157, 0.1); +} + +.market-chart-wrap.is-fullscreen { + background: var(--bg); +} + +.market-chart-wrap.is-fullscreen .market-ohlcv-bar, +.market-chart-wrap.is-fullscreen .market-fs-toolbar { + background: var(--chart-bar-bg); +} + +/* —— 亮色主题:对比度与全屏/放大 —— */ +html[data-theme="light"] .app-bg, +html[data-theme="light"] .login-bg { + background: + linear-gradient(rgba(0, 90, 130, 0.07) 1px, transparent 1px), + linear-gradient(90deg, rgba(0, 90, 130, 0.07) 1px, transparent 1px), + radial-gradient(ellipse 80% 50% at 50% -20%, rgba(0, 120, 180, 0.08), transparent), + radial-gradient(ellipse 60% 40% at 100% 100%, rgba(80, 70, 180, 0.05), transparent); + background-size: 48px 48px, 48px 48px, auto, auto; +} + +html[data-theme="light"] .app-bg::after, +html[data-theme="light"] .login-bg::after { + opacity: 0.12; +} + +html[data-theme="light"] a:hover { + text-shadow: none; +} + +html[data-theme="light"] .side-long, +html[data-theme="light"] .side-short { + text-shadow: none; +} + +html[data-theme="light"] .top-nav a.active { + background: linear-gradient(135deg, rgba(0, 110, 154, 0.14), rgba(91, 79, 199, 0.08)); + box-shadow: none; +} + +html[data-theme="light"] .mkt-exchange-tag { + background: rgba(10, 143, 92, 0.1); + border-color: rgba(10, 143, 92, 0.32); +} + +html[data-theme="light"] .market-ind-menu[open] summary { + border-color: rgba(10, 143, 92, 0.35); +} + +html[data-theme="light"] .market-price-auto.is-on { + border-color: rgba(10, 143, 92, 0.4); +} + +html[data-theme="light"] .market-price-tag.is-up { + background: var(--green); + color: #fff; +} + +html[data-theme="light"] .market-price-tag.is-up .market-price-tag-label { + color: rgba(255, 255, 255, 0.9); +} + +html[data-theme="light"] .market-price-tag { + box-shadow: 0 1px 4px rgba(30, 60, 100, 0.15); +} + +html[data-theme="light"] .stat-box { + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65); +} + +html[data-theme="light"] .card-stat-chip.card-stat-key-breakout { + background: rgba(0, 110, 154, 0.1); + border-color: rgba(0, 110, 154, 0.28); +} + +html[data-theme="light"] .card-stat-chip.card-stat-trend { + background: rgba(10, 143, 92, 0.1); + border-color: rgba(10, 143, 92, 0.28); +} + +html[data-theme="light"] .card-stat-chip.card-stat-key-watch { + background: rgba(91, 79, 199, 0.1); + border-color: rgba(91, 79, 199, 0.28); +} + +html[data-theme="light"] .hub-pos-card .pos-entrust-btn { + background: rgba(0, 110, 154, 0.1); + color: var(--accent); + border-color: var(--border-soft); +} + +html[data-theme="light"] .hub-pos-card .pos-value.pnl-pos { + text-shadow: none; +} + +html[data-theme="light"] .exchange-fullscreen-panel, +html[data-theme="light"] .modal-panel { + box-shadow: var(--shadow); +} + +html[data-theme="light"] input, +html[data-theme="light"] select, +html[data-theme="light"] textarea { + background: var(--bg-elevated); + color: var(--text); + border-color: var(--border-soft); +} + +html[data-theme="light"] .hub-tile, +html[data-theme="light"] .card, +html[data-theme="light"] .hub-pos-card, +html[data-theme="light"] .hub-trend-plan-card, +html[data-theme="light"] .settings-row { + box-shadow: 0 2px 10px rgba(30, 60, 100, 0.08); +} + +html[data-theme="light"] button.primary, +html[data-theme="light"] .market-toolbar button.primary, +html[data-theme="light"] #market-load, +html[data-theme="light"] #market-fs-load, +html[data-theme="light"] #btn-monitor-refresh { + background: #006e9a; + border-color: #005a82; + color: #fff; + font-weight: 700; + box-shadow: 0 2px 8px rgba(0, 95, 140, 0.28); +} + +html[data-theme="light"] button.primary:hover:not(:disabled), +html[data-theme="light"] #market-load:hover:not(:disabled), +html[data-theme="light"] #market-fs-load:hover:not(:disabled), +html[data-theme="light"] #btn-monitor-refresh:hover:not(:disabled) { + background: #0088b8; + color: #fff; + box-shadow: 0 3px 12px rgba(0, 95, 140, 0.35); +} + +html[data-theme="light"] .market-pos-panel { + background: var(--chart-bar-bg); + color: var(--text); +} + +html[data-theme="light"] .market-pos-side.side-long { + background: rgba(10, 143, 92, 0.12); + border-color: rgba(10, 143, 92, 0.35); +} + +html[data-theme="light"] .market-pos-side.side-short { + background: rgba(201, 53, 82, 0.1); + border-color: rgba(201, 53, 82, 0.35); +} + +html[data-theme="light"] .market-pos-order { + background: var(--inset-surface-strong); +} + +html[data-theme="light"] .market-pos-order-price { + color: #9a6b10; +} + +html[data-theme="light"] .market-pos-pnl.pnl-up { + color: #0a7a3d; +} + +html[data-theme="light"] .market-pos-pnl.pnl-down { + color: #c62828; +} + +html[data-theme="light"] .market-pos-clear { + font-weight: 600; + color: var(--text); + border-color: var(--border); + background: var(--bg-elevated); +} + +html[data-theme="light"] .market-status { + font-weight: 600; + color: var(--text); + opacity: 0.88; +} + +html[data-theme="light"] .toolbar-meta { + font-weight: 600; + color: var(--text); + opacity: 0.85; +} + +html[data-theme="light"] .chk-label { + font-weight: 600; + color: var(--text); +} + +html[data-theme="light"] .hub-trend-plan-card .plan-dca-table { + font-size: 0.8rem; +} + +html[data-theme="light"] .hub-trend-plan-card .plan-dca-table th { + font-weight: 700; + color: var(--text); +} + +html[data-theme="light"] button.danger { + font-weight: 600; + background: rgba(201, 53, 82, 0.1); + border-color: rgba(201, 53, 82, 0.45); +} + +/* --- Hub AI 教练(整页一屏,内容区内滚动)--- */ +body.hub-page-ai { + overflow: hidden; + height: 100dvh; + max-height: 100dvh; +} +body.hub-page-ai .app-shell { + padding-bottom: 12px; + height: 100dvh; + max-height: 100dvh; + overflow: hidden; + display: flex; + flex-direction: column; + box-sizing: border-box; +} +body.hub-page-ai .app-shell > #page-ai { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} +body.hub-page-ai .app-header { + flex-shrink: 0; + margin-bottom: 4px; +} +body.hub-page-ai #page-ai { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} +#page-ai .page-head { + flex-shrink: 0; + margin: 8px 0 10px; +} +#page-ai .page-head h1 { + margin-bottom: 4px; + font-size: 18px; +} +#page-ai .page-desc { + margin: 0; + font-size: 0.78rem; + line-height: 1.35; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.ai-layout { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + align-items: stretch; + overflow: hidden; +} +.ai-layout .ai-chat-panel { + flex: 1 1 auto; + min-height: 0; +} +.ai-mobile-tabs { + display: none; +} + +/* 手机 AI:须在 .ai-layout 双列定义之后,避免被覆盖成半屏 */ +@media (max-width: 720px), ((display-mode: standalone) and (max-width: 960px)) { + html:has(body.hub-page-ai) { + height: 100%; + overflow: hidden; + } + + body.hub-page-ai .app-shell { + padding-bottom: max(8px, env(safe-area-inset-bottom)); + height: var(--hub-vvh, 100dvh); + max-height: var(--hub-vvh, 100dvh); + overflow: hidden; + width: 100%; + max-width: none; + box-sizing: border-box; + will-change: transform, height; + } + + body.hub-page-ai { + position: fixed; + inset: 0; + width: 100%; + overflow: hidden; + background: var(--bg); + overscroll-behavior: none; + } + + body.hub-page-ai .app-header { + padding: 6px 0; + margin-bottom: 2px; + gap: 8px; + } + + body.hub-page-ai .top-nav a { + min-height: 34px; + padding: 6px 10px; + font-size: 11px; + } + + body.hub-page-ai .app-header .brand { + display: none; + } + + body.hub-page-ai .header-right { + grid-template-columns: 1fr auto auto; + grid-template-rows: auto; + } + + body.hub-page-ai .header-right .top-nav { + grid-column: 1 / -1; + order: 2; + } + + body.hub-page-ai.hub-ai-keyboard-open .app-header .theme-toggle, + body.hub-page-ai.hub-ai-keyboard-open .app-header .sys-pill, + body.hub-page-ai.hub-ai-keyboard-open .app-header #btn-logout { + display: none; + } + + body.hub-page-ai.hub-ai-keyboard-open .app-header { + padding: 4px 0; + margin-bottom: 0; + } + + body.hub-page-ai.hub-ai-keyboard-open .top-nav a { + min-height: 30px; + padding: 4px 8px; + font-size: 10px; + } + + body.hub-page-ai #page-ai { + overflow: hidden; + width: 100%; + min-width: 0; + } + + body.hub-page-ai .ai-mobile-tabs { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 6px; + margin-bottom: 6px; + flex-shrink: 0; + width: 100%; + position: sticky; + top: 0; + z-index: 12; + padding: 4px 0 2px; + background: var(--bg); + } + + body.hub-page-ai .ai-mobile-tab { + min-height: 38px; + padding: 6px 4px; + border-radius: 8px; + border: 1px solid var(--border-soft); + background: var(--inset-surface); + color: var(--muted); + font-family: var(--font); + font-size: 0.7rem; + font-weight: 600; + cursor: pointer; + line-height: 1.2; + text-align: center; + } + + body.hub-page-ai .ai-mobile-tab-action { + color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 35%, var(--border-soft)); + } + + body.hub-page-ai .ai-mobile-tab.is-active { + color: var(--text); + border-color: var(--accent); + background: var(--accent-dim); + box-shadow: none; + } + + body.hub-page-ai #page-ai .page-head { + display: none; + } + + body.hub-page-ai .ai-layout { + display: flex; + flex-direction: column; + width: 100%; + min-width: 0; + flex: 1 1 auto; + min-height: 0; + gap: 0; + overflow: hidden; + } + + body.hub-page-ai .ai-layout[data-ai-mobile-tab="trading"] .ai-chat-panel, + body.hub-page-ai .ai-layout[data-ai-mobile-tab="general"] .ai-chat-panel, + body.hub-page-ai .ai-layout[data-ai-mobile-tab="supervisor"] .ai-chat-panel, + body.hub-page-ai .ai-layout[data-ai-mobile-tab="history"] .ai-chat-panel { + display: flex; + flex: 1 1 auto; + width: 100%; + max-width: 100%; + min-height: 0; + min-width: 0; + } + + body.hub-page-ai .ai-layout[data-ai-mobile-tab="trading"] .ai-chat-history-panel, + body.hub-page-ai .ai-layout[data-ai-mobile-tab="general"] .ai-chat-history-panel, + body.hub-page-ai .ai-layout[data-ai-mobile-tab="supervisor"] .ai-chat-history-panel { + display: none !important; + } + + body.hub-page-ai .ai-layout[data-ai-mobile-tab="trading"] .ai-chat-main, + body.hub-page-ai .ai-layout[data-ai-mobile-tab="general"] .ai-chat-main, + body.hub-page-ai .ai-layout[data-ai-mobile-tab="supervisor"] .ai-chat-main { + display: flex; + flex: 1 1 auto; + min-height: 0; + flex-direction: column; + } + + body.hub-page-ai .ai-layout[data-ai-mobile-tab="history"] .ai-chat-main, + body.hub-page-ai .ai-layout[data-ai-mobile-tab="history"] .ai-chat-topbar { + display: none !important; + } + + body.hub-page-ai .ai-layout[data-ai-mobile-tab="history"] .ai-chat-history-panel { + display: flex; + flex: 1 1 auto; + min-height: 0; + border-left: none; + width: 100%; + } + + body.hub-page-ai .ai-panel { + width: 100%; + max-width: 100%; + min-width: 0; + box-sizing: border-box; + padding: 8px 10px; + gap: 6px; + } + + body.hub-page-ai .ai-chat-panel { + padding-bottom: 0; + display: flex; + flex-direction: column; + overflow: hidden; + border: none; + background: transparent; + } + + body.hub-page-ai .ai-chat-topbar { + display: none; + } + + body.hub-page-ai .ai-bot-tab { + min-height: 34px; + padding: 5px 8px; + font-size: 0.76rem; + } + + body.hub-page-ai .ai-bot-tab.is-active { + box-shadow: none; + } + + body.hub-page-ai .ai-chat-new-btn { + min-height: 34px; + padding: 5px 10px; + font-size: 0.76rem; + } + + body.hub-page-ai .ai-chat-split { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + border: none; + border-radius: 0; + } + + body.hub-page-ai .ai-chat-main { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + } + + body.hub-page-ai .ai-chat-session-head { + display: none; + } + + body.hub-page-ai .ai-chat-messages { + flex: 1 1 auto; + min-height: 0; + max-height: none; + padding: 4px 2px 8px; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + } + + body.hub-page-ai .ai-layout[data-ai-mobile-tab="history"] .ai-chat-history-list { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + } + + body.hub-page-ai .ai-chat-form { + position: relative; + flex-shrink: 0; + z-index: 3; + width: 100%; + margin: 0; + padding: 8px 0 max(8px, env(safe-area-inset-bottom)); + background: var(--panel); + border-top: 1px solid var(--border-soft); + box-shadow: 0 -4px 14px rgba(0, 0, 0, 0.12); + } + + body.hub-page-ai .ai-chat-compose { + gap: 6px; + } + + body.hub-page-ai .ai-chat-form textarea { + min-height: 40px; + max-height: 96px; + font-size: 16px; + width: 100%; + padding: 8px 10px; + } + + body.hub-page-ai .ai-chat-compose-actions { + display: flex; + gap: 8px; + align-items: center; + width: 100%; + } + + body.hub-page-ai .ai-chat-pending-list { + width: 100%; + } + + body.hub-page-ai .ai-chat-upload-btn, + body.hub-page-ai #btn-ai-chat-send { + min-height: 40px; + flex-shrink: 0; + } + + body.hub-page-ai #btn-ai-chat-send { + min-width: 72px; + margin-left: auto; + font-weight: 600; + } + + body.hub-page-ai .ai-msg-row-user { + max-width: 90%; + } + + body.hub-page-ai .ai-msg-row-coach { + max-width: 100%; + } + + body.hub-page-ai .ai-bubble { + font-size: 0.86rem; + padding: 9px 11px; + } + + body.hub-page-ai .ai-msg-role { + font-size: 0.68rem; + } + + body.hub-page-ai .ai-chat-history-list { + padding: 6px 4px; + } + + body.hub-page-ai .ai-chat-history-item { + padding: 10px 12px; + } +} + +.ai-panel { + background: var(--panel); + border: 1px solid var(--border-soft); + border-radius: var(--radius); + padding: 12px 14px; + min-height: 0; + max-height: 100%; + height: 100%; + display: flex; + flex-direction: column; + gap: 10px; + overflow: hidden; +} +.ai-panel-head { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 8px; + flex-shrink: 0; +} +.ai-panel-head h2 { + margin: 0; + font-size: 1rem; + font-family: var(--display); + letter-spacing: 0.04em; +} +.ai-panel-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 8px; + max-width: 100%; +} +.ai-meta-line { + max-width: min(420px, 100%); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.72rem; +} +.ai-panel-scroll { + flex: 1 1 auto; + min-height: 0; + max-height: 100%; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} +.ai-panel-scroll::-webkit-scrollbar { + width: 6px; +} +.ai-panel-scroll::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--muted) 45%, transparent); + border-radius: 999px; +} +.ai-stats-row { + display: flex; + flex-wrap: wrap; + gap: 8px 14px; + font-size: 0.82rem; + color: var(--muted); + flex-shrink: 0; +} +.ai-stat-chip { + padding: 4px 8px; + border-radius: 6px; + background: var(--inset-surface); + border: 1px solid var(--border-soft); +} +.ai-stat-chip strong { + color: var(--text); + margin-right: 4px; +} +.ai-stat-chip.pos, +.ai-stat-val.pos { + color: var(--green); + border-color: color-mix(in srgb, var(--green) 35%, transparent); +} +.ai-stat-chip.neg, +.ai-stat-val.neg { + color: var(--red); + border-color: color-mix(in srgb, var(--red) 35%, transparent); +} +.ai-stat-chip.pos strong, +.ai-stat-chip.neg strong { + color: inherit; + opacity: 0.85; +} +.ai-md-body { + padding: 12px; + border-radius: 8px; + background: var(--inset-surface); + border: 1px solid var(--border-soft); + font-size: 0.86rem; + line-height: 1.55; + color: var(--text); + overflow-wrap: anywhere; + word-break: break-word; +} +.ai-md-body.ai-result-md, +.ai-bubble-assistant.ai-result-md { + white-space: normal; +} +.ai-result-md p { + margin: 6px 0; + color: var(--text); +} +.ai-result-md ul, +.ai-result-md ol { + margin: 6px 0 8px 1.25em; + padding: 0 0 0 0.25em; + list-style-position: outside; +} +.ai-result-md ul { + list-style-type: disc; +} +.ai-result-md ol { + list-style-type: decimal; +} +.ai-result-md li { + margin: 5px 0; + line-height: 1.5; + display: list-item; +} +.ai-result-md strong { + color: var(--text); + font-weight: 600; +} +.ai-md-body.ai-result-md h2 { + font-size: 1.02rem; + color: var(--ai-sum-heading); + font-weight: 700; + margin: 14px 0 8px; + padding: 6px 0 6px 10px; + border-left: 3px solid var(--ai-sum-heading-border); + border-bottom: 1px solid var(--border-soft); + background: var(--ai-sum-heading-bg); + border-radius: 0 4px 4px 0; +} +.ai-md-body.ai-result-md h2:first-child { + margin-top: 0; +} +.ai-md-body.ai-result-md h3 { + font-size: 0.92rem; + color: var(--ai-sum-heading); + font-weight: 700; + margin: 16px 0 8px; + padding: 5px 0 5px 10px; + border-left: 3px solid var(--ai-sum-heading-border); + border-bottom: 1px solid var(--border-soft); + background: var(--ai-sum-heading-bg); + border-radius: 0 4px 4px 0; +} +.ai-md-body.ai-result-md h3:first-of-type { + margin-top: 4px; +} +.ai-md-body.ai-result-md h4 { + font-size: 0.92rem; + color: var(--ai-sum-heading); + font-weight: 700; + margin: 10px 0 6px; + padding: 4px 0 4px 8px; + border-left: 2px solid var(--ai-sum-heading-border); + background: var(--ai-sum-heading-bg); + border-radius: 0 4px 4px 0; +} +.ai-result-md h2 { + font-size: 1.02rem; + color: var(--accent-2, var(--accent)); + margin: 14px 0 8px; + padding-bottom: 4px; + border-bottom: 1px solid var(--border-soft); +} +.ai-result-md h3, +.ai-result-md h4 { + font-size: 0.92rem; + color: var(--accent-2, var(--accent)); + margin: 10px 0 6px; +} +.ai-result-md code { + background: color-mix(in srgb, var(--inset-surface) 70%, var(--border-soft)); + padding: 1px 4px; + border-radius: 4px; + font-size: 0.82em; +} +.ai-result-md .md-raw-block-title { + margin-top: 14px; + padding-top: 10px; + border-top: 1px dashed var(--border-soft); + color: var(--muted); + font-weight: 600; +} +.ai-bubble-assistant.ai-result-md p { + margin: 4px 0; +} +.ai-bubble-assistant.ai-result-md h2, +.ai-bubble-assistant.ai-result-md h3, +.ai-bubble-assistant.ai-result-md h4 { + margin: 8px 0 4px; + font-size: 0.92rem; + color: var(--accent); + border-bottom: none; + padding-bottom: 0; +} +.ai-bubble-assistant.ai-result-md strong { + color: var(--accent); +} +.ai-bubble-assistant.ai-result-md ul, +.ai-bubble-assistant.ai-result-md ol { + margin: 4px 0 6px 1.15em; + padding-left: 0.25em; + list-style-position: outside; +} +.ai-bubble-assistant.ai-result-md ul { + list-style-type: disc; +} +.ai-bubble-assistant.ai-result-md ol { + list-style-type: decimal; +} +.ai-bubble-assistant.ai-result-md li { + display: list-item; +} +.ai-ac-table-wrap { + margin: 8px 0 12px; + overflow-x: auto; + border: 1px solid var(--border-soft); + border-radius: 8px; + background: color-mix(in srgb, var(--inset-surface) 88%, transparent); +} +.ai-ac-table { + width: 100%; + border-collapse: collapse; + font-size: 0.78rem; + line-height: 1.45; +} +.ai-ac-table th, +.ai-ac-table td { + padding: 8px 10px; + text-align: left; + vertical-align: top; + border-bottom: 1px solid var(--border-soft); +} +.ai-ac-table th { + font-size: 0.72rem; + font-weight: 600; + color: var(--muted); + background: color-mix(in srgb, var(--inset-surface) 60%, transparent); + white-space: nowrap; +} +.ai-ac-table tbody tr:last-child td { + border-bottom: none; +} +.ai-ac-table tbody tr:hover td { + background: color-mix(in srgb, var(--accent-dim) 35%, transparent); +} +.ai-ac-name { + min-width: 9rem; + font-weight: 600; + color: var(--ai-sum-name); +} +.ai-ac-remark { + color: var(--muted); + font-size: 0.74rem; + max-width: 16rem; +} +.ai-ac-unmon { + color: var(--muted); +} +.ai-ac-err { + color: var(--red); +} +.ai-ac-warn { + color: var(--amber, #d4a017); +} +.ai-ac-table .ai-stat-val.pos { + color: var(--green); + font-weight: 600; +} +.ai-ac-table .ai-stat-val.neg { + color: var(--red); + font-weight: 600; +} +.ai-placeholder { + color: var(--muted); + margin: 0; +} +.ai-chat-panel { + gap: 8px; +} +.ai-chat-panel .ai-chat-split { + flex: 1 1 auto; +} +.ai-chat-topbar { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} +.ai-chat-topbar .ai-bot-bar { + flex: 1 1 auto; + min-width: 0; +} +.ai-chat-new-btn { + flex-shrink: 0; + white-space: nowrap; +} +.ai-chat-session-head { + padding-bottom: 2px; +} +.ai-chat-session-head h2 { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ai-bot-bar { + display: flex; + gap: 8px; + flex-shrink: 0; + padding-bottom: 0; +} +.ai-bot-tab { + flex: 1; + min-height: 36px; + padding: 6px 12px; + border-radius: 8px; + border: 1px solid var(--border-soft); + background: var(--inset-surface); + color: var(--muted); + font-family: var(--font); + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: border-color 0.15s, color 0.15s, background 0.15s; +} +.ai-bot-tab:hover { + border-color: var(--accent); + color: var(--text); +} +.ai-bot-tab.is-active { + color: var(--text); + border-color: var(--accent); + background: var(--accent-dim); + box-shadow: var(--glow); +} +.ai-chat-split { + flex: 1 1 auto; + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(360px, 440px); + gap: 0; + overflow: hidden; + border: 1px solid var(--border-soft); + border-radius: 8px; +} +.ai-chat-main { + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; + overflow: hidden; +} +.ai-chat-history-panel { + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; + border-left: 1px solid var(--border-soft); + background: color-mix(in srgb, var(--inset-surface) 65%, var(--panel)); +} +.ai-chat-history-head { + flex-shrink: 0; + padding: 10px 12px 6px; + border-bottom: 1px solid var(--border-soft); +} +.ai-chat-history-head h3 { + margin: 0; + font-size: 0.82rem; + font-weight: 700; + color: var(--muted); + letter-spacing: 0.04em; +} +.ai-chat-history-list { + flex: 1 1 auto; + min-height: 0; + padding: 8px; + display: flex; + flex-direction: column; + gap: 6px; +} +.ai-chat-history-item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 4px 8px; + align-items: start; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--border-soft); + background: var(--panel); + cursor: pointer; + text-align: left; + transition: border-color 0.15s, background 0.15s; +} +.ai-chat-history-item:hover { + border-color: var(--accent); +} +.ai-chat-history-item.is-active { + border-color: var(--accent); + background: var(--accent-dim); + box-shadow: var(--glow); +} +.ai-chat-history-item-main { + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; +} +.ai-chat-history-item-title { + font-size: 0.8rem; + font-weight: 600; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ai-chat-history-item-preview { + font-size: 0.72rem; + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ai-chat-history-item-meta { + font-size: 0.68rem; + color: var(--muted); + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} +.ai-chat-history-badge { + display: inline-flex; + padding: 1px 6px; + border-radius: 999px; + font-size: 0.62rem; + font-weight: 600; + border: 1px solid var(--border-soft); + color: var(--muted); +} +.ai-chat-history-badge.trading { + color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 40%, var(--border-soft)); +} +.ai-chat-history-badge.supervisor { + color: #c27803; + border-color: color-mix(in srgb, #c27803 45%, var(--border-soft)); +} +.ai-msg-row-system { + justify-content: flex-start; +} +.ai-bubble-system { + background: color-mix(in srgb, var(--surface-2) 88%, #c27803 12%); + border: 1px solid color-mix(in srgb, var(--border-soft) 70%, #c27803 30%); + font-size: 0.92rem; + white-space: pre-wrap; +} +.ai-bubble-warn { + border-color: color-mix(in srgb, var(--danger) 45%, var(--border-soft)); +} +.ai-chat-history-panel.hidden { + display: none !important; +} +.ai-chat-new-btn.hidden { + display: none !important; +} +.supervisor-settings-grid { + margin-top: 0.75rem; + padding-top: 0.25rem; +} +.ai-chat-history-del { + min-width: 28px; + min-height: 28px; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + color: var(--muted); + font-size: 1rem; + line-height: 1; + cursor: pointer; +} +.ai-chat-history-del:hover { + color: var(--red); + background: color-mix(in srgb, var(--red) 12%, transparent); +} +.ai-msg-actions { + display: flex; + gap: 6px; + padding: 0 4px; +} +.ai-msg-copy-btn { + min-height: 24px; + padding: 2px 8px; + border-radius: 6px; + border: 1px solid var(--border-soft); + background: var(--panel); + color: var(--muted); + font-size: 0.68rem; + font-weight: 600; + cursor: pointer; +} +.ai-msg-copy-btn:hover { + border-color: var(--accent); + color: var(--accent); +} +.ai-chat-messages { + display: flex; + flex-direction: column; + gap: 12px; + padding: 8px 4px 4px; +} +.ai-msg-row { + display: flex; + flex-direction: column; + gap: 4px; + max-width: 100%; +} +.ai-msg-row-user { + align-self: flex-end; + align-items: flex-end; + max-width: 88%; +} +.ai-msg-row-coach { + align-self: flex-start; + align-items: flex-start; + max-width: 92%; +} +.ai-msg-role { + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.04em; + color: var(--muted); + padding: 0 4px; +} +.ai-msg-row-user .ai-msg-role { + color: var(--accent); +} +.ai-msg-row-coach .ai-msg-role { + color: var(--accent); +} +.ai-bubble { + width: 100%; + padding: 10px 12px; + border-radius: 10px; + font-size: 0.88rem; + line-height: 1.5; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; +} +.ai-bubble-user { + background: var(--accent-dim); + border: 1px solid var(--border); +} +.ai-bubble-assistant { + background: var(--inset-surface); + border: 1px solid var(--border-soft); +} +.ai-bubble-thinking { + color: var(--muted); + font-style: italic; + animation: ai-think-pulse 1.2s ease-in-out infinite; +} +.ai-bubble-error { + border-color: color-mix(in srgb, var(--red) 55%, var(--border-soft)); + color: var(--red); +} +@keyframes ai-think-pulse { + 0%, + 100% { + opacity: 0.55; + } + 50% { + opacity: 1; + } +} +.ai-closed-trades-wrap { + margin: 0 0 12px; +} +.ai-closed-trades-title { + margin: 0 0 6px; + font-size: 0.82rem; + font-weight: 700; + color: var(--ai-sum-heading); + padding: 4px 0 4px 8px; + border-left: 2px solid var(--ai-sum-heading-border); + background: var(--ai-sum-heading-bg); + border-radius: 0 4px 4px 0; +} +.ai-msg-attachments { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 0 4px; +} +.ai-attach-chip { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.72rem; + color: var(--muted); + background: var(--inset-surface); + border: 1px solid var(--border-soft); +} +.ai-chat-form { + flex-shrink: 0; + padding-top: 4px; + border-top: 1px solid var(--border-soft); +} +.ai-chat-compose { + display: flex; + flex-direction: column; + gap: 8px; +} +.ai-chat-compose-actions { + display: flex; + align-items: center; + gap: 8px; + justify-content: flex-end; +} +.ai-chat-upload-btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 36px; + padding: 0 12px; + border-radius: 8px; + border: 1px solid var(--border-soft); + background: var(--inset-surface); + color: var(--text); + font-size: 0.82rem; + cursor: pointer; +} +.ai-chat-upload-btn:hover { + border-color: var(--accent); + color: var(--accent); +} +.ai-chat-pending-list { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.ai-chat-pending-list[hidden] { + display: none; +} +.ai-chat-pending-chip { + display: inline-flex; + align-items: center; + gap: 4px; + max-width: 100%; + padding: 2px 4px 2px 8px; + border-radius: 999px; + font-size: 0.72rem; + color: var(--text); + background: var(--inset-surface); + border: 1px solid var(--border-soft); +} +.ai-chat-pending-kind { + flex-shrink: 0; + font-size: 0.65rem; + color: var(--muted); +} +.ai-chat-pending-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ai-chat-pending-del { + flex-shrink: 0; + min-width: 22px; + min-height: 22px; + padding: 0; + border: none; + border-radius: 999px; + background: transparent; + color: var(--muted); + font-size: 0.95rem; + line-height: 1; + cursor: pointer; +} +.ai-chat-pending-del:hover { + color: var(--red); + background: color-mix(in srgb, var(--red) 12%, transparent); +} +.ai-chat-pending-del:disabled { + opacity: 0.45; + cursor: not-allowed; +} +.ai-chat-form textarea { + width: 100%; + resize: none; + min-height: 52px; + max-height: 88px; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--border-soft); + background: var(--inset-surface); + color: var(--text); + font-family: var(--font); + font-size: 0.88rem; +} +.ai-chat-form textarea:focus { + outline: none; + border-color: var(--accent); +} +.ai-chat-form textarea:disabled { + opacity: 0.65; +} + +/* —— 资金概况(科技感 HUD)—— */ +body.hub-page-funds .app-bg { + background: + linear-gradient(rgba(0, 212, 255, 0.045) 1px, transparent 1px), + linear-gradient(90deg, rgba(0, 212, 255, 0.045) 1px, transparent 1px), + radial-gradient(ellipse 70% 45% at 12% 0%, rgba(0, 212, 255, 0.16), transparent 58%), + radial-gradient(ellipse 55% 40% at 92% 18%, rgba(123, 97, 255, 0.14), transparent 55%), + radial-gradient(ellipse 50% 35% at 50% 100%, rgba(0, 255, 157, 0.06), transparent 60%); + background-size: 28px 28px, 28px 28px, auto, auto, auto; +} +html[data-theme="light"] body.hub-page-funds .app-bg { + background: + linear-gradient(rgba(0, 110, 154, 0.06) 1px, transparent 1px), + linear-gradient(90deg, rgba(0, 110, 154, 0.06) 1px, transparent 1px), + radial-gradient(ellipse 70% 45% at 12% 0%, rgba(0, 110, 154, 0.1), transparent 58%), + radial-gradient(ellipse 55% 40% at 92% 18%, rgba(91, 79, 199, 0.08), transparent 55%); + background-size: 28px 28px, 28px 28px, auto, auto; +} +body.hub-page-funds #page-funds { + position: relative; +} +.funds-stage { + position: relative; + border-radius: calc(var(--radius) + 4px); + border: 1px solid var(--border-soft); + background: linear-gradient(165deg, rgba(12, 20, 32, 0.72), rgba(8, 14, 26, 0.88)); + box-shadow: var(--glow), var(--shadow); + overflow: hidden; +} +html[data-theme="light"] .funds-stage { + background: linear-gradient(165deg, rgba(255, 255, 255, 0.92), rgba(240, 246, 252, 0.96)); + box-shadow: var(--shadow); +} +.funds-stage-grid, +.funds-stage-glow { + position: absolute; + inset: 0; + pointer-events: none; +} +.funds-stage-grid { + opacity: 0.35; + background: + linear-gradient(rgba(0, 212, 255, 0.07) 1px, transparent 1px), + linear-gradient(90deg, rgba(0, 212, 255, 0.07) 1px, transparent 1px); + background-size: 24px 24px; + mask-image: linear-gradient(180deg, black 0%, transparent 92%); +} +.funds-stage-glow { + background: + radial-gradient(circle at 18% 12%, rgba(0, 212, 255, 0.12), transparent 42%), + radial-gradient(circle at 82% 8%, rgba(123, 97, 255, 0.1), transparent 38%); +} +.funds-stage-inner { + position: relative; + z-index: 1; + padding: 14px 16px 18px; +} +.funds-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + margin-bottom: 12px !important; +} +.funds-head h1 { + font-family: var(--display); + letter-spacing: 0.06em; +} +.funds-tag { + background: linear-gradient(135deg, rgba(0, 212, 255, 0.22), rgba(123, 97, 255, 0.18)); + border-color: rgba(0, 212, 255, 0.45); + box-shadow: 0 0 18px rgba(0, 212, 255, 0.2); +} +.funds-desc-fold { + margin: 4px 0 0; + max-width: 100%; +} +.funds-desc-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.72rem; + color: var(--accent); + cursor: pointer; + list-style: none; + user-select: none; + letter-spacing: 0.04em; +} +.funds-desc-toggle::-webkit-details-marker { + display: none; +} +.funds-desc-toggle::before { + content: "▸"; + font-size: 0.68rem; + transition: transform 0.15s ease; +} +.funds-desc-fold[open] .funds-desc-toggle::before { + transform: rotate(90deg); +} +.funds-desc { + margin: 8px 0 0; + color: color-mix(in srgb, var(--muted) 88%, var(--accent)); + letter-spacing: 0.02em; + line-height: 1.45; + font-size: 0.78rem; +} +.funds-live-pill { + flex-shrink: 0; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + border-radius: 999px; + border: 1px solid rgba(0, 212, 255, 0.35); + background: rgba(0, 212, 255, 0.08); + font-family: var(--display); + font-size: 0.62rem; + letter-spacing: 0.14em; + color: var(--accent); +} +.funds-live-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--green); + box-shadow: 0 0 10px var(--green); + animation: funds-pulse 2s ease-in-out infinite; +} +@keyframes funds-pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.55; transform: scale(0.88); } +} +.funds-toolbar { + margin-bottom: 14px; +} +.funds-btn-refresh { + font-family: var(--display); + letter-spacing: 0.06em; + font-size: 0.72rem; +} +.funds-status.err { + color: var(--red); +} +.funds-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + margin-bottom: 12px; +} +.funds-stat-card { + position: relative; + background: rgba(0, 0, 0, 0.28); + border: 1px solid var(--border-soft); + border-radius: var(--radius); + padding: 14px 16px; + overflow: hidden; +} +html[data-theme="light"] .funds-stat-card { + background: rgba(255, 255, 255, 0.82); +} +.funds-stat-card::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, transparent, var(--accent), var(--accent-2), transparent); + opacity: 0.75; +} +.funds-stat-card-primary { + border-color: rgba(0, 212, 255, 0.32); + box-shadow: inset 0 0 24px rgba(0, 212, 255, 0.06); +} +.funds-stat-card-primary .funds-stat-value { + font-size: 1.6rem; +} +.funds-stat-label { + font-family: var(--display); + font-size: 0.62rem; + letter-spacing: 0.12em; + color: var(--muted); + margin-bottom: 6px; + text-transform: uppercase; +} +.funds-stat-value, +.funds-stat-val, +.funds-ac-total .v, +.funds-ac-stats .v, +.funds-fs-stat .v { + font-family: var(--font); + font-variant-numeric: tabular-nums; + letter-spacing: 0.01em; +} +.funds-stat-value { + font-size: 1.35rem; + font-weight: 600; +} +.funds-stat-val { + font-size: 1.15rem; + font-weight: 600; +} +.funds-stat-val.pos { + color: var(--green); +} +.funds-stat-val.neg { + color: var(--red); +} +.funds-dd-pct { + font-size: 0.82rem; + color: var(--muted); + font-weight: 500; +} +.funds-meta { + font-size: 0.72rem; + font-family: var(--mono); + color: color-mix(in srgb, var(--muted) 90%, var(--accent)); + margin: 0 0 14px; + padding: 8px 12px; + border-radius: 8px; + border: 1px dashed var(--border-soft); + background: rgba(0, 0, 0, 0.2); + letter-spacing: 0.03em; +} +html[data-theme="light"] .funds-meta { + background: rgba(255, 255, 255, 0.65); +} +.funds-chart-panel { + margin-bottom: 20px; + border: 1px solid rgba(0, 212, 255, 0.22); + border-radius: calc(var(--radius) + 2px); + background: rgba(0, 0, 0, 0.22); + box-shadow: inset 0 0 32px rgba(0, 212, 255, 0.04), 0 0 24px rgba(0, 212, 255, 0.06); + overflow: hidden; +} +html[data-theme="light"] .funds-chart-panel { + background: rgba(255, 255, 255, 0.7); + box-shadow: inset 0 0 20px rgba(0, 110, 154, 0.04); +} +.funds-chart-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 12px; + border-bottom: 1px solid var(--border-soft); + background: linear-gradient(90deg, rgba(0, 212, 255, 0.08), transparent); +} +.funds-chart-tag { + font-family: var(--display); + font-size: 0.68rem; + letter-spacing: 0.16em; + color: var(--accent); +} +.funds-chart-sub { + font-size: 0.62rem; + letter-spacing: 0.1em; + color: var(--muted); +} +.funds-chart-host { + height: 300px; + min-height: 240px; + background: var(--chart-surface, var(--panel)); + overflow: hidden; +} +.funds-section-head { + margin-bottom: 12px; +} +.funds-section-title { + margin: 0 0 4px; + font-family: var(--display); + font-size: 0.88rem; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; +} +.funds-section-mark { + color: var(--accent-2); + margin-right: 6px; +} +.funds-section-hint { + margin: 0; + font-size: 0.72rem; + color: var(--muted); + letter-spacing: 0.02em; +} +.funds-accounts { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); + gap: 12px; + padding: 4px 0 12px; +} +.funds-ac-card { + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; + padding: 14px 16px; + border: 1px solid var(--border-soft); + border-radius: var(--radius); + background: linear-gradient(160deg, rgba(0, 0, 0, 0.34), rgba(12, 20, 32, 0.55)); + text-align: left; + cursor: pointer; + transition: border-color 0.15s, box-shadow 0.15s, transform 0.12s; + position: relative; + overflow: hidden; +} +html[data-theme="light"] .funds-ac-card { + background: linear-gradient(160deg, rgba(255, 255, 255, 0.95), rgba(236, 244, 252, 0.9)); +} +.funds-ac-card::before { + content: ""; + position: absolute; + inset: 0 auto auto 0; + width: 3px; + height: 100%; + background: linear-gradient(180deg, var(--accent), var(--accent-2)); + opacity: 0.55; +} +.funds-ac-card:hover:not(:disabled) { + border-color: rgba(0, 212, 255, 0.45); + box-shadow: 0 0 22px rgba(0, 212, 255, 0.12), 0 0 0 1px var(--accent-dim); + transform: translateY(-2px); +} +.funds-ac-card:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.funds-ac-card.is-off { + opacity: 0.68; + cursor: default; +} +.funds-ac-card.is-off:hover { + transform: none; + box-shadow: none; + border-color: var(--border-soft); +} +.funds-ac-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +} +.funds-ac-name { + margin: 0; + font-family: var(--font); + font-size: 0.84rem; + font-weight: 600; + letter-spacing: 0.01em; + line-height: 1.35; + flex: 1; + min-width: 0; + word-break: break-all; +} +.funds-ac-badge { + flex-shrink: 0; + font-size: 0.66rem; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid var(--border-soft); + color: var(--muted); + background: var(--inset-surface); + white-space: nowrap; +} +.funds-ac-badge.is-ok { + color: var(--green); + border-color: rgba(0, 255, 157, 0.28); + background: rgba(0, 255, 157, 0.08); +} +html[data-theme="light"] .funds-ac-badge.is-ok { + border-color: rgba(10, 143, 92, 0.28); + background: rgba(10, 143, 92, 0.08); +} +.funds-ac-total { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + padding: 8px 10px; + border-radius: 8px; + background: var(--inset-surface); + border: 1px solid var(--border-soft); +} +.funds-ac-total .k { + font-size: 0.72rem; + color: var(--muted); +} +.funds-ac-total .v { + font-size: 1.12rem; + font-weight: 700; + color: var(--text); +} +.funds-ac-stats { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px 12px; + font-size: 0.78rem; +} +.funds-ac-stats .k { + display: block; + color: var(--muted); + font-size: 0.68rem; + margin-bottom: 2px; +} +.funds-ac-stats .v { + font-variant-numeric: tabular-nums; + font-weight: 500; +} +.funds-ac-stats .v.pos { + color: var(--green); +} +.funds-ac-stats .v.neg { + color: var(--red); +} +.funds-ac-foot { + margin-top: 2px; + padding-top: 10px; + border-top: 1px dashed var(--border-soft); + font-size: 0.72rem; + color: var(--muted); + text-align: center; +} +.funds-empty { + color: var(--muted); + font-size: 0.85rem; + padding: 12px 0; +} + +.funds-fullscreen { + position: fixed; + inset: 0; + z-index: 160; + background: var(--fs-scrim); + backdrop-filter: blur(6px); + overflow: auto; + padding: 16px 20px 24px; +} +.funds-fullscreen.hidden { + display: none !important; +} +.funds-fs-backdrop { + position: fixed; + inset: 0; + z-index: 0; + border: none; + padding: 0; + margin: 0; + background: transparent; + cursor: pointer; +} +.funds-fs-panel { + position: relative; + z-index: 1; + max-width: min(1200px, 96vw); + margin: 0 auto; + background: linear-gradient(165deg, rgba(12, 20, 32, 0.95), rgba(6, 10, 18, 0.98)); + border: 1px solid rgba(0, 212, 255, 0.28); + border-radius: calc(var(--radius) + 2px); + padding: 16px 18px 20px; + box-shadow: 0 0 40px rgba(0, 212, 255, 0.12), 0 12px 40px rgba(0, 0, 0, 0.35); +} +html[data-theme="light"] .funds-fs-panel { + background: linear-gradient(165deg, rgba(255, 255, 255, 0.98), rgba(240, 246, 252, 0.98)); + box-shadow: var(--shadow); +} +.funds-fs-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border-soft); +} +.funds-fs-title { + margin: 0; + font-family: var(--display); + font-size: 1.1rem; + font-weight: 600; + letter-spacing: 0.06em; +} +.funds-fs-sub { + margin: 4px 0 0; + font-size: 0.76rem; + color: var(--muted); +} +.funds-fs-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 10px; + margin-bottom: 14px; +} +.funds-fs-stat { + background: var(--inset-surface); + border: 1px solid var(--border-soft); + border-radius: 8px; + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 4px; +} +.funds-fs-stat .k { + font-size: 0.72rem; + color: var(--muted); +} +.funds-fs-stat .v { + font-size: 1rem; + font-weight: 600; + font-variant-numeric: tabular-nums; +} +.funds-fs-stat .v.pos { + color: var(--green); +} +.funds-fs-stat .v.neg { + color: var(--red); +} +.funds-fs-chart-host { + height: min(52vh, 420px); + min-height: 260px; + border: 1px solid var(--border-soft); + border-radius: var(--radius); + background: var(--chart-surface, var(--panel)); + overflow: hidden; +} +body.funds-fullscreen-open { + overflow: hidden; +} +@media (max-width: 720px) { + .funds-accounts { + grid-template-columns: minmax(0, 1fr); + } + .funds-head { + flex-direction: column; + align-items: stretch; + } + .funds-live-pill { + align-self: flex-start; + } + .funds-stage-inner { + padding: 12px 12px 14px; + } + .funds-chart-host { + height: 240px; + min-height: 200px; + } +} + +/* —— 内照明心 —— */ +.archive-toolbar { + flex-wrap: wrap; + gap: 10px 14px; + margin-bottom: 10px; +} +.archive-search-field input { + min-width: 160px; +} +#archive-btn-chart-toggle.is-active { + color: var(--accent); + border-color: var(--accent); + background: var(--accent-dim); +} +.archive-field { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.82rem; + color: var(--muted); +} +.archive-field select, +.archive-field input { + min-width: 120px; + padding: 6px 8px; + border-radius: 8px; + border: 1px solid var(--border-soft); + background: var(--inset-surface); + color: var(--text); + font-family: var(--font); +} +#page-archive .archive-toolbar { + margin-bottom: 12px; +} +.archive-layout { + display: grid; + grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); + gap: 14px; + min-height: calc(100vh - 240px); + align-items: stretch; +} +.archive-quotes-panel, +.archive-main-panel { + background: var(--panel); + border: 1px solid var(--border-soft); + border-radius: var(--radius); + min-width: 0; +} +.archive-quotes-panel { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px; + min-height: calc(100vh - 200px); + overflow: hidden; +} +.archive-panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.archive-panel-head h2 { + margin: 0; + font-size: 0.95rem; +} +.archive-panel-meta { + font-size: 0.72rem; + color: var(--muted); +} +.archive-quote-form { + display: flex; + flex-direction: column; + gap: 8px; +} +.archive-quote-form input[type="date"], +.archive-quote-form textarea { + width: 100%; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--border-soft); + background: var(--inset-surface); + color: var(--text); + font-family: var(--font); + font-size: 0.82rem; + resize: vertical; +} +.archive-quote-form textarea { + min-height: 110px; +} +.archive-quotes-list { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + display: flex; + flex-direction: column; + gap: 8px; +} +.archive-quote-block { + display: flex; + flex-direction: column; + gap: 0; + border: 1px solid var(--border-soft); + border-radius: 8px; + background: var(--inset-surface); + overflow: hidden; +} +.archive-quote-block.is-open { + border-color: var(--accent); +} +.archive-quote-item { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 8px; + align-items: center; + width: 100%; + padding: 8px 10px; + border: 0; + border-radius: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} +.archive-quote-item:hover { + background: color-mix(in srgb, var(--accent) 8%, transparent); +} +.archive-quote-item.is-selected { + background: color-mix(in srgb, var(--accent) 12%, var(--inset-surface)); +} +.archive-quote-open-hint { + font-size: 0.7rem; + color: var(--accent); + white-space: nowrap; +} +.archive-quote-detail { + display: flex; + flex-direction: column; + gap: 8px; + padding: 0 10px 10px; + border-top: 1px solid var(--border-soft); +} +.archive-quote-detail .archive-quote-full { + min-height: 120px; + max-height: none; + overflow: visible; +} +.archive-quote-date { + font-weight: 600; + font-size: 0.78rem; + color: var(--accent); + white-space: nowrap; +} +.archive-quote-preview { + font-size: 0.74rem; + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.archive-quote-full { + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--border-soft); + background: var(--panel); + color: var(--text); + font-size: 0.82rem; + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; + max-height: none; +} +.archive-quote-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.archive-quote-ai-btn { + color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 35%, var(--border-soft)); +} +.archive-main-panel { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px; + min-height: 100%; + min-height: 0; +} +.archive-period-bar { + flex-wrap: wrap; +} +.archive-period-tabs { + display: inline-flex; + gap: 4px; +} +.archive-period-btn { + padding: 5px 10px; + border-radius: 8px; + border: 1px solid var(--border-soft); + background: var(--inset-surface); + color: var(--muted); + cursor: pointer; + font-family: var(--font); + font-size: 0.8rem; +} +.archive-period-btn.is-active { + color: var(--text); + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, transparent); +} +.archive-period-range { + display: inline-flex; + align-items: center; + gap: 4px; +} +.archive-period-range.hidden, +.archive-period-day-input.hidden { + display: none; +} +.archive-period-sep { + color: var(--muted); + font-size: 0.82rem; +} +.archive-stats-card { + margin-bottom: 14px; + padding: 12px; + background: var(--panel); + border: 1px solid var(--border-soft); + border-radius: var(--radius); +} +.archive-stats-card-head h2 { + margin: 0 0 10px; + font-size: 0.95rem; +} +.archive-stats-card .archive-stats-bar { + border: none; + background: transparent; + overflow: auto; +} +.archive-overview-panel { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} +.archive-overview-panel > .archive-panel-head { + display: none; +} +.archive-stats-bar { + padding: 0; + border-radius: 8px; + border: 1px solid var(--border-soft); + background: var(--inset-surface); + font-size: 0.82rem; + color: var(--text); + line-height: 1.45; + overflow: auto; +} +.archive-stats-table { + width: 100%; + border-collapse: collapse; + font-size: 0.8rem; +} +.archive-stats-table th, +.archive-stats-table td { + padding: 7px 10px; + border-bottom: 1px solid var(--border-soft); + text-align: left; + white-space: nowrap; +} +.archive-stats-table th { + color: var(--muted); + font-weight: 500; + background: var(--inset-surface); +} +.archive-stats-table tr:last-child td { + border-bottom: none; +} +.archive-stats-table tr.archive-stats-total td { + background: color-mix(in srgb, var(--accent) 6%, transparent); +} +.archive-stats-table .pnl-pos { + color: #22c55e; +} +.archive-stats-table .pnl-neg { + color: #ef4444; +} +.archive-acc-section { + border: 1px solid var(--border-soft); + border-radius: var(--radius); + background: var(--inset-surface); + overflow: hidden; +} +.archive-acc-summary { + padding: 10px 12px; + font-weight: 600; + font-size: 0.86rem; + cursor: pointer; + list-style: none; + display: flex; + align-items: center; + gap: 8px; +} +.archive-acc-summary::-webkit-details-marker { + display: none; +} +.archive-acc-sub { + font-weight: 400; + font-size: 0.76rem; + color: var(--muted); +} +.archive-chart-section > :not(summary) { + padding: 0 10px 10px; +} +.archive-trades-section { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; +} +.archive-trades-section > .archive-trades { + border: none; + border-radius: 0; + flex: 1 1 auto; + min-height: 0; + max-height: none; +} +#page-archive.is-chart-open .archive-trades-section > .archive-trades { + flex: 0 0 auto; +} +#page-archive:not(.is-chart-open) .archive-trades-section > .archive-trades { + min-height: calc(100vh - 420px); +} +.archive-chart-toolbar { + flex-wrap: wrap; +} +.archive-tf-tabs { + display: inline-flex; + gap: 4px; +} +.archive-tf-btn { + padding: 5px 10px; + border-radius: 8px; + border: 1px solid var(--border-soft); + background: var(--inset-surface); + color: var(--muted); + cursor: pointer; + font-family: var(--font); + font-size: 0.8rem; +} +.archive-tf-btn.is-active { + color: var(--text); + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, transparent); +} +.archive-chart-wrap { + position: relative; +} +.archive-chart-host { + height: 360px; + min-height: 280px; + border: 1px solid var(--border-soft); + border-radius: var(--radius); + background: var(--panel); + overflow: hidden; +} +.archive-mark-auto { + position: absolute; + right: 8px; + bottom: 10px; + z-index: 5; + padding: 4px 10px; + font-size: 0.72rem; + font-family: var(--font); + border-radius: 6px; + border: 1px solid var(--border-soft); + background: var(--chart-bar-bg, var(--inset-surface)); + color: var(--muted); + cursor: pointer; + line-height: 1.2; +} +.archive-mark-auto:hover { + border-color: var(--accent); + color: var(--text); +} +.archive-mark-auto.is-on { + color: #22c55e; + border-color: rgba(34, 197, 94, 0.45); + background: rgba(34, 197, 94, 0.1); +} +.archive-trades { + overflow: auto; + border: 1px solid var(--border-soft); + border-radius: var(--radius); + background: var(--panel); + overscroll-behavior: contain; +} +.archive-trades-table { + width: 100%; + min-width: 1000px; + border-collapse: collapse; + font-size: 0.78rem; +} +.archive-trades-table .archive-dt { + white-space: nowrap; + font-variant-numeric: tabular-nums; +} +.archive-trades-table .archive-hold { + white-space: nowrap; +} +.archive-trades-table .archive-symbol { + white-space: nowrap; + font-weight: 500; +} +.archive-review-mark { + display: inline-block; + margin-right: 4px; + padding: 0 4px; + border-radius: 4px; + font-size: 0.62rem; + line-height: 1.4; + color: #6ab88a; + background: rgba(106, 184, 138, 0.12); + vertical-align: middle; +} +.archive-trades-table th, +.archive-trades-table td { + padding: 6px 8px; + border-bottom: 1px solid var(--border-soft); + text-align: left; +} +.archive-trades-table th { + color: var(--muted); + font-weight: 500; + position: sticky; + top: 0; + background: var(--panel); +} +.archive-trade-row { + cursor: default; +} +#page-archive.is-chart-open .archive-trade-row { + cursor: pointer; +} +.archive-trade-row.is-active { + background: color-mix(in srgb, var(--accent) 16%, var(--inset-surface)); + box-shadow: inset 3px 0 0 var(--accent); +} +.archive-trade-row.archive-trade-sick td { + color: var(--red); +} +.archive-trade-row.archive-trade-sick.is-active { + background: color-mix(in srgb, var(--accent) 12%, color-mix(in srgb, var(--red) 8%, var(--panel))); + box-shadow: inset 3px 0 0 var(--accent); +} +.archive-trade-row.archive-trade-sick .archive-tag-select, +.archive-trade-row.archive-trade-sick .archive-note-input { + color: var(--red); + border-color: color-mix(in srgb, var(--red) 40%, var(--border-soft)); +} +.archive-trade-row.archive-trade-sick td.pos, +.archive-trade-row.archive-trade-sick td.neg { + color: var(--red); +} +.archive-actions-cell { + white-space: nowrap; +} +.archive-actions-cell .archive-chart-btn, +.archive-actions-cell .archive-del-btn { + margin-right: 6px; +} +.archive-chart-btn { + padding: 3px 8px; + font-size: 0.72rem; + border-radius: 6px; +} +.archive-trades-table td.pos { + color: #22c55e; +} +.archive-trades-table td.neg { + color: #ef4444; +} +.archive-del-btn { + padding: 3px 8px; + font-size: 0.72rem; + border-radius: 6px; + border: 1px solid rgba(239, 68, 68, 0.35); + background: rgba(239, 68, 68, 0.08); + color: #f87171; + cursor: pointer; +} +.archive-del-btn:hover { + background: rgba(239, 68, 68, 0.16); +} +.archive-tag-select, +.archive-note-input { + width: 100%; + max-width: 140px; + padding: 4px 6px; + border-radius: 6px; + border: 1px solid var(--border-soft); + background: var(--inset-surface); + color: var(--text); + font-size: 0.75rem; +} +.archive-tag-select.is-tag-sick { + color: var(--red); + border-color: color-mix(in srgb, var(--red) 45%, var(--border-soft)); + background: color-mix(in srgb, var(--red) 14%, var(--inset-surface)); +} +.archive-tag-select.is-tag-emotion { + color: #60a5fa; + border-color: color-mix(in srgb, #60a5fa 45%, var(--border-soft)); + background: color-mix(in srgb, #60a5fa 14%, var(--inset-surface)); +} +.archive-trade-row.archive-trade-sick .archive-tag-select.is-tag-sick { + color: var(--red); + border-color: color-mix(in srgb, var(--red) 50%, var(--border-soft)); + background: color-mix(in srgb, var(--red) 18%, var(--inset-surface)); +} +.archive-empty { + padding: 16px; + color: var(--muted); + font-size: 0.85rem; +} +@media (max-width: 900px) { + #page-archive .page-desc { + display: none; + } + #page-archive .archive-toolbar-desktop, + #page-archive .archive-panel-desktop { + display: none !important; + } + #page-archive .archive-toolbar { + margin-bottom: 10px; + } + #page-archive .archive-layout { + display: flex; + flex-direction: column; + gap: 12px; + min-height: 0; + } + #page-archive .archive-quotes-panel { + order: 1; + flex: 0 0 auto; + min-height: 0; + max-height: none; + overflow: visible; + } + #page-archive .archive-main-panel { + order: 2; + flex: 0 0 auto; + min-height: 0; + gap: 10px; + } + #page-archive .archive-stats-card { + margin-bottom: 10px; + } + #page-archive .archive-quotes-list { + min-height: 120px; + max-height: 42vh; + } + #page-archive .archive-stats-table th, + #page-archive .archive-stats-table td { + padding: 6px 8px; + font-size: 0.74rem; + } +} + +/* —— 开仓计划 —— */ +#page-plan .plan-layout { + display: grid; + grid-template-columns: minmax(320px, 420px) minmax(0, 1fr); + gap: 14px; + align-items: start; +} +.plan-left-panel, +.plan-right-panel { + display: flex; + flex-direction: column; + gap: 14px; + min-width: 0; +} +.plan-form-section, +.plan-active-section, +.plan-history-section, +.plan-stats-section { + background: var(--panel); + border: 1px solid var(--border-soft); + border-radius: var(--radius); + padding: 12px; +} +.plan-panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 10px; +} +.plan-panel-head h2 { + margin: 0; + font-size: 0.95rem; +} +.plan-panel-meta { + font-size: 0.72rem; + color: var(--muted); +} +.plan-form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px 10px; +} +.plan-field { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 0.78rem; +} +.plan-field-full { + grid-column: 1 / -1; +} +.plan-field span { + color: var(--muted); +} +.plan-field input, +.plan-field select, +.plan-field textarea { + width: 100%; + padding: 7px 9px; + border-radius: 8px; + border: 1px solid var(--border-soft); + background: var(--inset-surface); + color: var(--text); + font-family: var(--font); + font-size: 0.82rem; +} +.plan-field-inline { + flex-direction: row; + align-items: center; + gap: 6px; +} +.plan-field-inline span { + white-space: nowrap; +} +.plan-field-inline input, +.plan-field-inline select { + width: auto; + min-width: 88px; +} +.plan-radio-row { + display: flex; + flex-wrap: wrap; + gap: 10px; +} +.plan-radio-label { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.82rem; +} +.plan-submit-btn { + margin-top: 10px; + width: 100%; +} +.plan-active-list, +.plan-history-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 48vh; + overflow: auto; +} +.plan-empty { + margin: 0; + padding: 12px 4px; + color: var(--muted); + font-size: 0.82rem; +} +.plan-active-card { + border: 1px solid var(--border-soft); + border-radius: 8px; + background: var(--inset-surface); + padding: 10px; + display: flex; + flex-direction: column; + gap: 6px; +} +.plan-active-head { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: flex-start; +} +.plan-active-title { + font-size: 0.84rem; + font-weight: 600; +} +.plan-active-actions { + display: flex; + gap: 4px; + flex-shrink: 0; +} +.plan-active-meta, +.plan-active-levels { + font-size: 0.74rem; + color: var(--muted); +} +.plan-active-note { + font-size: 0.78rem; + color: var(--text); + opacity: 0.9; +} +.plan-scheme-row { + margin-top: 6px; +} +.plan-field-scheme select { + min-width: 160px; +} +.plan-close-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: flex-end; + margin-top: 4px; + padding-top: 8px; + border-top: 1px dashed var(--border-soft); +} +.plan-history-row { + display: grid; + grid-template-columns: 92px minmax(0, 1fr) auto auto; + gap: 8px; + align-items: center; + width: 100%; + text-align: left; + padding: 9px 10px; + border: 1px solid var(--border-soft); + border-radius: 8px; + background: var(--inset-surface); + color: var(--text); + font-family: var(--font); + font-size: 0.8rem; + cursor: pointer; +} +.plan-history-row:hover { + border-color: var(--accent); +} +.plan-history-date { + color: var(--muted); + font-size: 0.74rem; +} +.plan-history-result.plan-res-win { + color: var(--pos); +} +.plan-history-result.plan-res-loss { + color: var(--neg); +} +.plan-stats-toolbar { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + margin-bottom: 10px; +} +.plan-period-tabs, +.plan-dim-tabs { + display: inline-flex; + flex-wrap: wrap; + gap: 4px; +} +.plan-period-btn, +.plan-dim-btn { + padding: 5px 10px; + border-radius: 999px; + border: 1px solid var(--border-soft); + background: transparent; + color: var(--muted); + font-size: 0.74rem; + cursor: pointer; +} +.plan-period-btn.is-active, +.plan-dim-btn.is-active { + border-color: var(--accent); + color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, transparent); +} +.plan-stats-range.hidden { + display: none; +} +.plan-period-sep { + color: var(--muted); + font-size: 0.78rem; +} +.plan-stats-table { + width: 100%; + border-collapse: collapse; + font-size: 0.8rem; +} +.plan-stats-table th, +.plan-stats-table td { + padding: 7px 10px; + border-bottom: 1px solid var(--border-soft); + text-align: left; +} +.plan-stats-table th { + color: var(--muted); + font-weight: 500; +} +.plan-detail-body { + display: flex; + flex-direction: column; + gap: 8px; + padding: 4px 0 8px; +} +.plan-detail-row { + display: grid; + grid-template-columns: 88px minmax(0, 1fr); + gap: 8px; + font-size: 0.82rem; +} +.plan-detail-k { + color: var(--muted); +} +.plan-detail-v { + color: var(--text); + word-break: break-word; +} +.plan-detail-card { + width: min(480px, 94vw); +} +.plan-edit-card { + width: min(520px, 94vw); +} +@media (max-width: 960px) { + #page-plan .plan-layout { + grid-template-columns: 1fr; + } + .plan-active-list, + .plan-history-list { + max-height: none; + } + .plan-history-row { + grid-template-columns: 1fr; + gap: 4px; + } +} + +/* ── 策略计算器 ── */ +.calc-layout { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + align-items: stretch; +} + +.calc-card { + padding: 16px 18px; + height: 100%; + display: flex; + flex-direction: column; +} + +.calc-form { + flex: 1; + display: flex; + flex-direction: column; +} + +.calc-card h2 { + margin: 0 0 8px; + font-size: 1rem; + color: var(--text); +} + +.calc-hint { + margin: 0 0 14px; + font-size: 0.78rem; + color: var(--muted); + line-height: 1.5; +} + +.calc-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 12px; +} + +.calc-field { + display: flex; + flex-direction: column; + gap: 5px; + font-size: 0.78rem; + color: var(--muted); +} + +.calc-field input, +.calc-field select { + width: 100%; + box-sizing: border-box; + background: var(--bg-elevated); + border: 1px solid var(--border); + color: var(--text); + border-radius: 8px; + padding: 8px 10px; + font-size: 0.82rem; + font-family: var(--mono); +} + +.calc-field-span2 { + grid-column: 1 / -1; +} + +.calc-market-info { + padding: 0.55rem 0.55rem 0.55rem 0.75rem; + border-radius: 8px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + font-size: 0.82rem; + line-height: 1.45; + color: var(--muted, #9aa4b2); +} + +.calc-market-info strong { + color: var(--text, #e8ecf1); +} + +.calc-market-err { + color: #f87171; +} + +.calc-actions { + margin-top: auto; + padding-top: 12px; +} + +.calc-result { + margin-top: 14px; + padding-top: 12px; + border-top: 1px solid var(--border-soft); +} + +.calc-result.hidden { + display: none !important; +} + +.calc-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 8px 12px; + margin-bottom: 12px; +} + +.calc-summary div { + background: var(--bg-elevated); + border: 1px solid var(--border-soft); + border-radius: 8px; + padding: 8px 10px; +} + +.calc-summary span { + display: block; + font-size: 0.72rem; + color: var(--muted); + margin-bottom: 4px; +} + +.calc-summary strong { + font-family: var(--mono); + font-size: 0.86rem; + color: var(--text); +} + +.calc-pnl-profit { + color: var(--green) !important; +} + +.calc-pnl-loss { + color: var(--red) !important; +} + +.calc-table-wrap { + overflow: auto; +} + +.calc-table { + width: 100%; + border-collapse: collapse; + font-size: 0.78rem; +} + +.calc-table th, +.calc-table td { + padding: 7px 8px; + border-bottom: 1px solid var(--border-soft); + text-align: left; + white-space: nowrap; +} + +.calc-table th { + color: var(--muted); + font-weight: 600; +} + +.calc-error { + color: var(--red); + font-size: 0.82rem; + margin: 0; +} + +.calc-empty { + color: var(--muted); + font-size: 0.82rem; + margin: 0; +} + +.calc-roll-legs-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin: 14px 0 8px; + font-size: 0.82rem; + color: var(--text); +} + +.calc-roll-legs-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.calc-roll-leg { + border: 1px solid var(--border-soft); + border-radius: 8px; + padding: 10px 12px; + background: var(--bg-elevated); +} + +.calc-roll-leg-title { + font-size: 0.8rem; + font-weight: 600; + color: var(--muted); + margin-bottom: 8px; +} + +.calc-roll-leg-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.calc-roll-leg-remove { + margin-top: 8px; + font-size: 0.78rem; +} + +.calc-done-tag { + display: inline-block; + margin-left: 6px; + padding: 1px 6px; + border-radius: 999px; + font-size: 0.68rem; + color: var(--muted); + border: 1px solid var(--border-soft); +} + +@media (max-width: 960px) { + .calc-layout { + grid-template-columns: 1fr; + } + .calc-form-grid { + grid-template-columns: 1fr; + } +} + diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index 2c7c300..adcddcc 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -1,5030 +1,5030 @@ -(function () { - const toast = document.getElementById("toast"); - let settingsCache = null; - let authState = { required: false, logged_in: true }; - - function displayPref(key, defaultOn) { - const d = settingsCache && settingsCache.display; - if (!d || d[key] === undefined) return defaultOn !== false; - return !!d[key]; - } - - function showAccountPnlPref() { - return displayPref("show_account_pnl", true); - } - - function showNavFundsPref() { - return displayPref("show_nav_funds", true); - } - - function showNavDashboardPref() { - return displayPref("show_nav_dashboard", true); - } - - function showNavPlanPref() { - return displayPref("show_nav_plan", true); - } - - function showNavArchivePref() { - return displayPref("show_nav_archive", true); - } - - function showNavAiPref() { - return displayPref("show_nav_ai", true); - } - - function showNavCalculatorPref() { - return displayPref("show_nav_calculator", true); - } - - function syncNavVisibility(data) { - const d = (data && data.display) || {}; - const navFunds = document.getElementById("nav-funds"); - const navDash = document.getElementById("nav-dashboard"); - const navPlan = document.getElementById("nav-plan"); - const navArchive = document.getElementById("nav-archive"); - const navAi = document.getElementById("nav-ai"); - const navCalc = document.getElementById("nav-calculator"); - if (navFunds) navFunds.classList.toggle("nav-hidden", d.show_nav_funds === false); - if (navDash) navDash.classList.toggle("nav-hidden", d.show_nav_dashboard === false); - if (navPlan) navPlan.classList.toggle("nav-hidden", d.show_nav_plan === false); - if (navArchive) navArchive.classList.toggle("nav-hidden", d.show_nav_archive === false); - if (navAi) navAi.classList.toggle("nav-hidden", d.show_nav_ai === false); - if (navCalc) navCalc.classList.toggle("nav-hidden", d.show_nav_calculator === false); - } - - function pageNavAllowed(page) { - if (page === "funds") return showNavFundsPref(); - if (page === "dashboard") return showNavDashboardPref(); - if (page === "plan") return showNavPlanPref(); - if (page === "archive") return showNavArchivePref(); - if (page === "ai") return showNavAiPref(); - if (page === "calculator") return showNavCalculatorPref(); - return true; - } - - function syncDisplayPrefsUI(data) { - const d = (data && data.display) || {}; - const pnlCb = document.getElementById("pref-show-account-pnl"); - const fundsCb = document.getElementById("pref-show-nav-funds"); - const dashCb = document.getElementById("pref-show-nav-dashboard"); - const planCb = document.getElementById("pref-show-nav-plan"); - const archiveCb = document.getElementById("pref-show-nav-archive"); - const aiCb = document.getElementById("pref-show-nav-ai"); - const calcCb = document.getElementById("pref-show-nav-calculator"); - if (pnlCb) pnlCb.checked = d.show_account_pnl !== false; - if (fundsCb) fundsCb.checked = d.show_nav_funds !== false; - if (dashCb) dashCb.checked = d.show_nav_dashboard !== false; - if (planCb) planCb.checked = d.show_nav_plan !== false; - if (archiveCb) archiveCb.checked = d.show_nav_archive !== false; - if (aiCb) aiCb.checked = d.show_nav_ai !== false; - if (calcCb) calcCb.checked = d.show_nav_calculator !== false; - syncNavVisibility(data); - } - - function syncSupervisorSettingsUI(data) { - const s = (data && data.supervisor) || {}; - const enabled = document.getElementById("supervisor-enabled"); - const prog = document.getElementById("supervisor-wechat-program"); - const webhook = document.getElementById("supervisor-wechat-webhook"); - const link = document.getElementById("supervisor-wechat-link"); - const prefix = document.getElementById("supervisor-wechat-prefix"); - const daily = document.getElementById("supervisor-daily-warn"); - const interval = document.getElementById("supervisor-interval-warn"); - const freq30 = document.getElementById("supervisor-freq-30m"); - const reopen = document.getElementById("supervisor-reopen-min"); - if (enabled) enabled.checked = s.enabled !== false; - if (prog) prog.checked = s.wechat_on_program_tp_sl !== false; - if (webhook) webhook.value = s.wechat_webhook || ""; - if (link) link.value = s.wechat_link_base || ""; - if (prefix) prefix.value = s.wechat_prefix || "【交易监管】"; - if (daily) daily.value = Number(s.manual_close_daily_warn) || 2; - if (interval) interval.value = Number(s.interval_warn_minutes) || 15; - if (freq30) freq30.value = Number(s.freq_30m_count) || 2; - if (reopen) reopen.value = Number(s.reopen_after_close_minutes) || 30; - } - - function positionTableHeadHtml(compact) { - const pnlTh = showAccountPnlPref() ? "浮盈" : ""; - const cls = compact ? " data-table data-table-positions" : ""; - return `${pnlTh}`; - } - let tpslPending = null; - let lastMonitorRows = []; - let expandedExchangeId = sessionStorage.getItem("hub_expanded_ex") || ""; - const HUB_MONITOR_BOARD_CACHE_KEY = "hub_monitor_board_v1"; - const HUB_MONITOR_CACHE_MAX_AGE_MS = 6 * 60 * 60 * 1000; - const MONITOR_BOARD_SNAPSHOT_URL = "/api/monitor/board/snapshot"; - const HUB_MONITOR_SNAPSHOT_TIMEOUT_MS = 15000; - /** 关注:浮亏超过交易账户余额的比例(10%) */ - const HUB_ALERT_FLOAT_LOSS_RATIO = 0.1; - let lastMonitorBoardUpdatedAt = ""; - let localBoardVersion = 0; - let monitorBoardInFlight = false; - let monitorBoardFetchPending = false; - let monitorBoardSlowHintTimer = null; - let boardEventSource = null; - let sseReconnectTimer = null; - let hostStatusTimer = null; - const HOST_STATUS_POLL_MS = 5000; - const HOST_STATUS_OPEN_KEY = "hub-host-status-open"; - const HOST_RESOURCE_ALERT_THRESHOLD = 85; - const hostResourceAlertLatch = { cpu: false, mem: false }; - - function loadBoolPref(key, defaultValue) { - try { - const raw = localStorage.getItem(key); - if (raw === "1" || raw === "true") return true; - if (raw === "0" || raw === "false") return false; - } catch (_) {} - return !!defaultValue; - } - - function saveBoolPref(key, on) { - try { - localStorage.setItem(key, on ? "1" : "0"); - } catch (_) {} - } - - function fmtHostBytes(n) { - const v = Number(n); - if (!Number.isFinite(v)) return "—"; - const abs = Math.abs(v); - if (abs >= 1e12) return (v / 1e12).toFixed(2) + " TB"; - if (abs >= 1e9) return (v / 1e9).toFixed(2) + " GB"; - if (abs >= 1e6) return (v / 1e6).toFixed(2) + " MB"; - if (abs >= 1e3) return (v / 1e3).toFixed(1) + " KB"; - return v.toFixed(0) + " B"; - } - - function fmtHostUptime(sec) { - const s = Math.max(0, Number(sec) || 0); - const d = Math.floor(s / 86400); - const h = Math.floor((s % 86400) / 3600); - const m = Math.floor((s % 3600) / 60); - if (d > 0) return d + "天" + h + "时"; - if (h > 0) return h + "时" + m + "分"; - return m + "分"; - } - - function hostMetricLevel(percent) { - const p = Number(percent); - if (!Number.isFinite(p)) return "ok"; - if (p >= HOST_RESOURCE_ALERT_THRESHOLD) return "bad"; - return "ok"; - } - - function hostOverallLevel(cpu, mem, disk) { - const vals = [cpu && cpu.percent, mem && mem.percent, disk && disk.percent]; - for (let i = 0; i < vals.length; i++) { - const p = Number(vals[i]); - if (Number.isFinite(p) && p >= HOST_RESOURCE_ALERT_THRESHOLD) return "bad"; - } - return "ok"; - } - - function setHostMetricBar(fillEl, percent) { - if (!fillEl) return; - const p = Math.max(0, Math.min(100, Number(percent) || 0)); - const level = hostMetricLevel(p); - fillEl.style.width = p + "%"; - fillEl.classList.remove("warn", "bad", "ok"); - fillEl.classList.add(level === "bad" ? "bad" : "ok"); - } - - function checkHostResourceAlert(cpu, mem) { - const msgs = []; - const cpuP = Number(cpu && cpu.percent); - if (Number.isFinite(cpuP) && cpuP >= HOST_RESOURCE_ALERT_THRESHOLD) { - if (!hostResourceAlertLatch.cpu) { - msgs.push("CPU 使用率 " + cpuP + "%"); - hostResourceAlertLatch.cpu = true; - } - } else { - hostResourceAlertLatch.cpu = false; - } - const memP = Number(mem && mem.percent); - if (Number.isFinite(memP) && memP >= HOST_RESOURCE_ALERT_THRESHOLD) { - if (!hostResourceAlertLatch.mem) { - msgs.push("内存使用率 " + memP + "%"); - hostResourceAlertLatch.mem = true; - } - } else { - hostResourceAlertLatch.mem = false; - } - if (msgs.length) { - window.alert( - "服务器资源告警\n\n" + msgs.join("\n") + "\n\n请及时关注中控服务器负载。" - ); - } - } - - function hostMetricSummaryHtml(label, percent) { - const p = Number(percent); - if (!Number.isFinite(p)) { - return esc(label) + " —"; - } - const tone = hostMetricLevel(p); - return ( - esc(label) + - ' ' + - p + - "%" - ); - } - - function renderHostStatusSummary(data, el) { - if (!el) return; - if (!data || !data.ok) { - el.className = "host-status-summary-text bad"; - el.textContent = (data && data.msg) || "状态不可用"; - return; - } - const cpu = data.cpu || {}; - const mem = data.memory || {}; - const disk = data.disk || {}; - const parts = []; - const host = String(data.hostname || "").trim(); - if (host) { - parts.push('' + esc(host) + ""); - } - if (cpu.percent != null) parts.push(hostMetricSummaryHtml("CPU", cpu.percent)); - if (mem.percent != null) parts.push(hostMetricSummaryHtml("内存", mem.percent)); - if (disk.percent != null) parts.push(hostMetricSummaryHtml("硬盘", disk.percent)); - el.className = "host-status-summary-text"; - el.innerHTML = parts.length - ? parts.join(' · ') - : "—"; - } - - function setHostMetricVal(el, percent) { - if (!el) return; - const p = Number(percent); - el.classList.remove("ok", "bad"); - if (!Number.isFinite(p)) { - el.textContent = "—"; - return; - } - el.textContent = p + "%"; - el.classList.add(hostMetricLevel(p)); - } - - let hostStatusPanelInited = false; - - function initHostStatusPanel() { - const panel = document.getElementById("host-status-panel"); - if (!panel) return; - panel.classList.remove("hidden"); - if (!hostStatusPanelInited) { - panel.open = loadBoolPref(HOST_STATUS_OPEN_KEY, false); - panel.addEventListener("toggle", function () { - saveBoolPref(HOST_STATUS_OPEN_KEY, !!panel.open); - }); - hostStatusPanelInited = true; - } - } - - function renderHostStatusBar(data) { - const panel = document.getElementById("host-status-panel"); - const summaryText = document.getElementById("host-status-summary-text"); - const bar = document.getElementById("host-status-bar"); - if (!panel || !bar) return; - const dot = document.getElementById("host-status-dot"); - const name = document.getElementById("host-status-name"); - const uptime = document.getElementById("host-status-uptime"); - const updated = document.getElementById("host-status-updated"); - const cpuVal = document.getElementById("host-cpu-val"); - const cpuSub = document.getElementById("host-cpu-sub"); - const memVal = document.getElementById("host-mem-val"); - const memSub = document.getElementById("host-mem-sub"); - const diskVal = document.getElementById("host-disk-val"); - const diskSub = document.getElementById("host-disk-sub"); - const netUp = document.getElementById("host-net-up"); - const netDown = document.getElementById("host-net-down"); - panel.classList.remove("hidden"); - renderHostStatusSummary(data, summaryText); - if (!data || !data.ok) { - if (dot) dot.className = "host-status-dot bad"; - if (name) { - name.textContent = "服务器"; - name.title = ""; - } - if (uptime) uptime.textContent = (data && data.msg) || "状态不可用"; - if (updated) updated.textContent = ""; - if (cpuVal) cpuVal.textContent = "—"; - if (cpuSub) cpuSub.textContent = ""; - if (memVal) memVal.textContent = "—"; - if (memSub) memSub.textContent = ""; - if (diskVal) diskVal.textContent = "—"; - if (diskSub) diskSub.textContent = ""; - if (netUp) netUp.textContent = "↑ —"; - if (netDown) netDown.textContent = "↓ —"; - return; - } - const cpu = data.cpu || {}; - const mem = data.memory || {}; - const disk = data.disk || {}; - const net = data.network || {}; - checkHostResourceAlert(cpu, mem); - const overall = hostOverallLevel(cpu, mem, disk); - if (dot) dot.className = "host-status-dot " + overall; - const hostname = data.hostname || "服务器"; - if (name) { - name.textContent = hostname; - name.title = hostname; - } - if (uptime) uptime.textContent = "运行 " + fmtHostUptime(data.uptime_sec); - if (updated) updated.textContent = data.updated_at ? "更新 " + data.updated_at : ""; - setHostMetricBar(document.getElementById("host-cpu-fill"), cpu.percent); - setHostMetricBar(document.getElementById("host-mem-fill"), mem.percent); - setHostMetricBar(document.getElementById("host-disk-fill"), disk.percent); - setHostMetricVal(cpuVal, cpu.percent); - setHostMetricVal(memVal, mem.percent); - setHostMetricVal(diskVal, disk.percent); - if (cpuSub) cpuSub.textContent = cpu.count ? cpu.count + " 核" : ""; - if (memSub) { - memSub.textContent = - fmtHostBytes(mem.used_bytes) + " / " + fmtHostBytes(mem.total_bytes); - } - if (diskSub) { - diskSub.textContent = - fmtHostBytes(disk.used_bytes) + " / " + fmtHostBytes(disk.total_bytes); - } - if (netUp) netUp.textContent = "↑ " + fmtHostBytes(net.sent_rate_bps) + "/s"; - if (netDown) netDown.textContent = "↓ " + fmtHostBytes(net.recv_rate_bps) + "/s"; - } - - async function fetchHostStatus() { - if (currentPage() !== "monitor") return; - try { - const r = await apiFetch("/api/host/status", { credentials: "same-origin" }); - const data = await r.json(); - renderHostStatusBar(data); - } catch (err) { - renderHostStatusBar({ ok: false, msg: String(err && err.message ? err.message : err) }); - } - } - - function stopHostStatusPoll() { - if (hostStatusTimer) { - clearInterval(hostStatusTimer); - hostStatusTimer = null; - } - } - - function startHostStatusPoll() { - stopHostStatusPoll(); - initHostStatusPanel(); - void fetchHostStatus(); - hostStatusTimer = setInterval(fetchHostStatus, HOST_STATUS_POLL_MS); - } - - async function apiFetch(url, opts) { - const r = await fetch(url, opts); - if (r.status === 401) { - const next = encodeURIComponent(location.pathname + location.search); - location.href = "/login?next=" + next; - throw new Error("未登录"); - } - return r; - } - - let instanceFrameUrl = ""; - /** @type {{ exchangeId: string, nextPath: string, title: string } | null} */ - let instanceFrameCtx = null; - - function isHubEmbedded() { - try { - return window.self !== window.top; - } catch (_) { - return true; - } - } - - /** 在 LocalNav 等父页 iframe 内:直接替换本 iframe 地址,避免 postMessage / 三层嵌套 */ - function openInstanceInParentFrame(url) { - try { - window.location.assign(url); - return true; - } catch (_) { - return false; - } - } - - async function fetchInstanceOpenUrl(exchangeId, nextPath, opts) { - const options = opts || {}; - const next = nextPath || "/"; - const q = new URLSearchParams({ exchange_id: String(exchangeId), next }); - if (options.embed) q.set("embed", "1"); - if (options.embed && globalThis.HubTheme && typeof HubTheme.get === "function") { - q.set("hub_theme", HubTheme.get()); - } - const r = await apiFetch("/api/instance/open-url?" + q.toString()); - const j = await r.json(); - if (!j.ok || !j.url) { - throw new Error(j.detail || "无法生成打开链接"); - } - return j.url; - } - - /** @type {number | null} */ - let instanceFrameNavLoadingTimer = null; - - function setInstanceFrameNavLoading(loading) { - const shell = document.getElementById("instance-frame-shell"); - if (!shell) return; - if (instanceFrameNavLoadingTimer != null) { - clearTimeout(instanceFrameNavLoadingTimer); - instanceFrameNavLoadingTimer = null; - } - if (loading) { - instanceFrameNavLoadingTimer = window.setTimeout(() => { - shell.classList.add("is-instance-nav-loading"); - instanceFrameNavLoadingTimer = null; - }, 140); - return; - } - shell.classList.remove("is-instance-nav-loading"); - } - - async function openInstance(exchangeId, nextPath, opts) { - const options = opts || {}; - const newTab = !!options.newTab; - const next = nextPath || "/"; - try { - const embedded = isHubEmbedded(); - const url = await fetchInstanceOpenUrl(exchangeId, next, { - embed: !newTab, - }); - if (newTab) { - window.open(url, "_blank", "noopener"); - return; - } - const row = lastMonitorRows.find((x) => String(x.id) === String(exchangeId)); - const title = row ? row.name : exchangeId; - instanceFrameCtx = { exchangeId: String(exchangeId), nextPath: next, title }; - if (embedded) { - try { - window.parent.postMessage( - { - type: "hub:open-instance-nav", - exchangeId: String(exchangeId), - nextPath: next, - title, - }, - "*" - ); - } catch (_) {} - if (openInstanceInParentFrame(url)) return; - } - openInstanceFrame(url, title); - } catch (e) { - showToast(String(e), true); - } - } - - async function refreshInstanceFrame() { - if (!instanceFrameCtx) { - if (instanceFrameUrl) { - const frame = document.getElementById("instance-frame"); - if (frame) frame.src = instanceFrameUrl; - } - return; - } - try { - const url = await fetchInstanceOpenUrl( - instanceFrameCtx.exchangeId, - instanceFrameCtx.nextPath, - { embed: true } - ); - instanceFrameUrl = url; - const frame = document.getElementById("instance-frame"); - if (frame) { - setInstanceFrameNavLoading(true); - frame.src = url; - } - } catch (e) { - showToast(String(e), true); - } - } - - function openInstanceFrame(url, title) { - const shell = document.getElementById("instance-frame-shell"); - const frame = document.getElementById("instance-frame"); - const titleEl = document.getElementById("instance-frame-title"); - if (!shell || !frame) { - window.open(url, "_blank", "noopener"); - return; - } - closeExchangeFullscreen(); - instanceFrameUrl = url; - if (titleEl) titleEl.textContent = title || "实例"; - setInstanceFrameNavLoading(true); - frame.src = url; - shell.classList.remove("hidden"); - shell.setAttribute("aria-hidden", "false"); - document.body.classList.add("hub-instance-frame-open"); - if (frame.dataset.themeSyncBound !== "1") { - frame.dataset.themeSyncBound = "1"; - frame.addEventListener("load", function syncInstanceFrameTheme() { - requestAnimationFrame(() => { - try { - if (globalThis.HubTheme && typeof HubTheme.get === "function" && frame.contentWindow) { - frame.contentWindow.postMessage( - { type: "hub-theme-sync", theme: HubTheme.get() }, - "*" - ); - } - } catch (_) {} - }); - }); - } - } - - function closeInstanceFrame() { - const shell = document.getElementById("instance-frame-shell"); - const frame = document.getElementById("instance-frame"); - instanceFrameUrl = ""; - instanceFrameCtx = null; - if (frame) frame.src = "about:blank"; - if (shell) { - shell.classList.add("hidden"); - shell.setAttribute("aria-hidden", "true"); - shell.classList.remove("is-instance-nav-loading"); - } - document.body.classList.remove("hub-instance-frame-open"); - } - - /** @deprecated use openInstance */ - async function openInstanceInBrowser(exchangeId, nextPath) { - return openInstance(exchangeId, nextPath, { newTab: false }); - } - - async function initAuth() { - try { - const r = await fetch("/api/auth/status"); - authState = await r.json(); - const btn = document.getElementById("btn-logout"); - if (btn) btn.style.display = authState.required ? "" : "none"; - if (authState.required && !authState.logged_in) { - location.href = - "/login?next=" + encodeURIComponent(location.pathname + location.search); - return false; - } - return true; - } catch (_) { - return true; - } - } - - function showToast(msg, isErr) { - toast.textContent = msg; - toast.style.borderColor = isErr ? "var(--red)" : "var(--border)"; - toast.classList.add("show"); - clearTimeout(showToast._t); - showToast._t = setTimeout(() => toast.classList.remove("show"), 7000); - } - - function esc(s) { - return String(s) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); - } - - function formatRiskStatusBadge(riskStatus) { - if (!riskStatus || typeof riskStatus !== "object") return ""; - if (window.AccountRiskBadge) return AccountRiskBadge.formatBadgeHtml(riskStatus, esc); - const st = riskStatus.status || "normal"; - const label = esc(riskStatus.status_label || "正常"); - const title = esc(riskStatus.reason || ""); - return `${label}`; - } - - function fmt(n, d) { - if (n === null || n === undefined || Number.isNaN(Number(n))) return "—"; - return Number(n).toLocaleString(undefined, { maximumFractionDigits: d }); - } - - /** 交易所持仓开仓价(四所子代理 entry_price) */ - function positionEntryPrice(pos) { - if (!pos) return null; - const n = Number(pos.entry_price); - if (!Number.isFinite(n) || n <= 0) return null; - return n; - } - - function symbolPriceKey(sym) { - return (sym || "").trim().toUpperCase(); - } - - function buildPriceTickMap(row) { - const map = Object.create(null); - const put = (sym, tick) => { - const k = symbolPriceKey(sym); - if (!k || tick == null || !Number.isFinite(Number(tick))) return; - if (map[k] == null) map[k] = Number(tick); - }; - ((row && row.agent && row.agent.positions) || []).forEach((p) => put(p.symbol, p.price_tick)); - const hm = (row && row.hub_monitor) || {}; - (hm.trends || []).forEach((t) => put(t.exchange_symbol || t.symbol, t.price_tick)); - (hm.orders || []).forEach((o) => put(o.exchange_symbol || o.symbol, o.price_tick)); - return map; - } - - function lookupPriceTick(symbol, tickMap) { - if (!tickMap || !symbol) return null; - const k = symbolPriceKey(symbol); - if (tickMap[k] != null) return tickMap[k]; - const base = normSym(symbol); - if (base && tickMap[base] != null) return tickMap[base]; - return null; - } - - function decimalsFromTick(tick) { - if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) return null; - const t = Number(tick); - if (t >= 1) return 0; - const s = t.toFixed(12).replace(/0+$/, ""); - const frac = s.split(".")[1]; - return frac ? Math.min(12, frac.length) : 0; - } - - function defaultPriceDecimals(value) { - const n = Number(value); - if (!Number.isFinite(n)) return 4; - const av = Math.abs(n); - if (av >= 10000) return 2; - if (av >= 100) return 3; - if (av >= 1) return 4; - if (av >= 0.01) return 6; - return 8; - } - - /** 按交易所 tick(子代理/Flask 下发)格式化价格 */ - function fmtSymbolPrice(value, symbol, tickMap, displayFallback) { - if (displayFallback != null && displayFallback !== "") return String(displayFallback); - if (value == null || value === "") return "—"; - const n = Number(value); - if (!Number.isFinite(n)) return "—"; - const tick = lookupPriceTick(symbol, tickMap); - const d = decimalsFromTick(tick); - return fmt(n, d != null ? d : defaultPriceDecimals(n)); - } - - function fmtEntryPrice(pos, tickMap) { - if (pos && pos.entry_price_fmt) return String(pos.entry_price_fmt); - return fmtSymbolPrice(positionEntryPrice(pos), pos && pos.symbol, tickMap); - } - - function positionMarkPrice(pos) { - if (!pos) return null; - const n = Number(pos.mark_price); - if (!Number.isFinite(n) || n <= 0) return null; - return n; - } - - function fmtMarkPrice(pos, tickMap) { - if (pos && pos.mark_price_fmt) return String(pos.mark_price_fmt); - return fmtSymbolPrice(positionMarkPrice(pos), pos && pos.symbol, tickMap); - } - - function resolveTrendPositionRatioPct(trendPlan) { - const t = trendPlan || {}; - if (t.position_ratio_pct != null && t.position_ratio_pct !== "") { - const n = Number(t.position_ratio_pct); - if (Number.isFinite(n)) return n; - } - const snap = Number(t.snapshot_available_usdt); - const margin = Number(t.plan_margin_capital); - if (Number.isFinite(snap) && snap > 0 && Number.isFinite(margin) && margin > 0) { - return Math.round((margin / snap) * 10000) / 100; - } - return null; - } - - function resolveTrendSizingFooter(mo, trendPlan, isTrend, pos) { - const m = mo || {}; - const p = pos || {}; - if (!isTrend || !trendPlan || !trendPlan.id) { - return { - margin: - m.exchange_initial_margin ?? - p.exchange_initial_margin ?? - m.plan_margin ?? - p.plan_margin ?? - null, - leverage: m.leverage, - planBase: m.margin_capital, - positionRatio: m.position_ratio, - }; - } - const base = - trendPlan.snapshot_available_usdt != null && trendPlan.snapshot_available_usdt !== "" - ? trendPlan.snapshot_available_usdt - : trendPlan.plan_margin_capital; - return { - margin: m.exchange_initial_margin ?? trendPlan.plan_margin_capital ?? null, - leverage: trendPlan.leverage, - planBase: base, - positionRatio: resolveTrendPositionRatioPct(trendPlan), - }; - } - - function resolvePositionOpenMeta(mo, trendPlan, isTrend) { - const useTrend = isTrend && trendPlan && trendPlan.id; - const src = useTrend ? trendPlan : mo || {}; - let ms = Number(src.opened_at_ms); - if (!Number.isFinite(ms) || ms <= 0) { - const s = String(src.opened_at || "").trim(); - if (s) { - const parsed = Date.parse(s.replace(" ", "T")); - ms = Number.isFinite(parsed) ? parsed : null; - } else { - ms = null; - } - } else { - ms = Math.round(ms); - } - let display = "—"; - if (src.opened_at) { - display = String(src.opened_at).replace("T", " ").slice(0, 16); - } else if (ms) { - display = new Date(ms).toISOString().slice(0, 16).replace("T", " "); - } - return { openedAtMs: ms, openedAtDisplay: display }; - } - - function formatLiveHoldDuration(openedMs, nowMs) { - if (openedMs == null || !Number.isFinite(Number(openedMs))) return "—"; - const ms = Number(openedMs); - const now = nowMs != null ? nowMs : Date.now(); - let sec = Math.floor((now - ms) / 1000); - if (sec < 0) sec = 0; - if (sec <= 0) return "0分钟"; - const d = Math.floor(sec / 86400); - sec %= 86400; - const h = Math.floor(sec / 3600); - sec %= 3600; - const m = Math.floor(sec / 60); - const parts = []; - if (d) parts.push(`${d}天`); - if (h) parts.push(`${h}小时`); - if (m || !parts.length) parts.push(`${m}分钟`); - return parts.join(""); - } - - let hubHoldDurationTimer = null; - - function tickHubHoldDurations() { - const now = Date.now(); - document.querySelectorAll(".pos-hold-duration[data-opened-ms]").forEach((el) => { - const ms = Number(el.getAttribute("data-opened-ms")); - if (!Number.isFinite(ms) || ms <= 0) return; - el.textContent = formatLiveHoldDuration(ms, now); - }); - } - - function ensureHubHoldDurationTimer() { - tickHubHoldDurations(); - if (hubHoldDurationTimer) return; - hubHoldDurationTimer = setInterval(tickHubHoldDurations, 1000); - } - - function estimateLatestRiskUsdt(side, entry, sl, pos, mo) { - const e = Number(entry); - const s = Number(sl); - if (!Number.isFinite(e) || !Number.isFinite(s) || e <= 0) return null; - const sd = (side || "long").toLowerCase(); - const rf = sd === "short" ? (s - e) / e : (e - s) / e; - if (!Number.isFinite(rf)) return null; - if (rf <= 0) return 0; - const m = mo || {}; - const p = pos || {}; - let notional = Number(p.notional_usdt); - if (!Number.isFinite(notional) || notional <= 0) { - notional = Number(m.exchange_notional); - } - if (!Number.isFinite(notional) || notional <= 0) { - const mc = Number(m.margin_capital); - const lev = Number(m.leverage); - if (Number.isFinite(mc) && mc > 0 && Number.isFinite(lev) && lev > 0) { - notional = mc * lev; - } - } - if (!Number.isFinite(notional) || notional <= 0) { - const c = Math.abs(Number(p.contracts)); - const cs = Number(p.contract_size); - const mult = Number.isFinite(cs) && cs > 0 ? cs : 1; - const px = Number(p.mark_price); - const mark = Number.isFinite(px) && px > 0 ? px : e; - if (Number.isFinite(c) && c > 0) notional = c * mult * mark; - } - if (!Number.isFinite(notional) || notional <= 0) return null; - return Math.round(notional * rf * 100) / 100; - } - - function formatLatestRiskMeta(mo, trendPlan, pos, tpsl) { - const m = mo || {}; - const t = trendPlan || {}; - let v = - m.latest_risk_amount != null && m.latest_risk_amount !== "" - ? Number(m.latest_risk_amount) - : pos && pos.latest_risk_amount != null && pos.latest_risk_amount !== "" - ? Number(pos.latest_risk_amount) - : t.latest_risk_amount != null && t.latest_risk_amount !== "" - ? Number(t.latest_risk_amount) - : null; - if ((v == null || !Number.isFinite(v)) && tpsl && pos) { - v = estimateLatestRiskUsdt( - pos.side || m.direction, - tpsl.entry, - tpsl.sl, - pos, - m - ); - } - if (v != null && Number.isFinite(v)) { - return `最新风险: ${fmt(v, 2)}U`; - } - return null; - } - - function formatMonitorRiskMeta(mo, trendPlan) { - const m = mo || {}; - const t = trendPlan || {}; - const amt = - m.risk_amount != null && m.risk_amount !== "" - ? Number(m.risk_amount) - : t.risk_amount != null && t.risk_amount !== "" - ? Number(t.risk_amount) - : null; - const pctRaw = - m.risk_percent != null && m.risk_percent !== "" - ? m.risk_percent - : t.risk_percent != null && t.risk_percent !== "" - ? t.risk_percent - : null; - if (pctRaw == null || pctRaw === "") { - if (amt != null && Number.isFinite(amt)) { - return `风险: ${fmt(amt, 2)}U`; - } - return null; - } - const pct = esc(pctRaw); - if (amt != null && Number.isFinite(amt)) { - return `风险: ${pct}%≈${fmt(amt, 2)}U`; - } - return `风险: ${pct}%`; - } - - function resolveTrendMarkPrice(pos, trendPlan, symbol, tickMap) { - const fromPos = fmtMarkPrice(pos, tickMap); - if (fromPos && fromPos !== "—") return fromPos; - const t = trendPlan || {}; - const sym = symbol || (pos && pos.symbol) || t.exchange_symbol || t.symbol || ""; - if (t.floating_mark != null && t.floating_mark !== "") { - return fmtSymbolPrice(t.floating_mark, sym, tickMap); - } - if (t.last_mark_price != null && t.last_mark_price !== "") { - return fmtSymbolPrice(t.last_mark_price, sym, tickMap); - } - return "—"; - } - - function estimateLinearSwapUpnl(side, entry, mark, contracts, contractSize) { - const e = Number(entry); - const m = Number(mark); - const c = Math.abs(Number(contracts)); - let mult = Number(contractSize); - if (!Number.isFinite(mult) || mult <= 0) mult = 1; - if (!Number.isFinite(e) || !Number.isFinite(m) || !Number.isFinite(c) || c <= 0) { - return null; - } - const diff = - (side || "long").toLowerCase() === "long" ? m - e : e - m; - return Math.round(diff * c * mult * 100) / 100; - } - - /** 展示浮盈:子代理 unrealized_pnl;与 entry/mark/张数 推算偏差 >20% 时用推算值 */ - function resolvePositionUpnlUsdt(pos, trendPlan, markOverride) { - const p = pos || {}; - const t = trendPlan || {}; - let exchange = - p.unrealized_pnl != null && p.unrealized_pnl !== "" - ? Number(p.unrealized_pnl) - : null; - if (exchange != null && !Number.isFinite(exchange)) exchange = null; - const entry = - t.avg_entry_price != null && t.avg_entry_price !== "" - ? Number(t.avg_entry_price) - : p.entry_price != null && p.entry_price !== "" - ? Number(p.entry_price) - : t.trigger_price != null - ? Number(t.trigger_price) - : null; - let mark = - markOverride != null && Number.isFinite(Number(markOverride)) - ? Number(markOverride) - : p.mark_price != null && p.mark_price !== "" - ? Number(p.mark_price) - : t.floating_mark != null - ? Number(t.floating_mark) - : t.last_mark_price != null - ? Number(t.last_mark_price) - : null; - const contracts = p.contracts; - const cs = - p.contract_size != null && p.contract_size !== "" - ? Number(p.contract_size) - : 1; - const computed = estimateLinearSwapUpnl( - p.side || t.direction, - entry, - mark, - contracts, - cs - ); - if (computed == null) { - if (exchange != null) return exchange; - if (t.floating_pnl != null && t.floating_pnl !== "") { - const n = Number(t.floating_pnl); - if (Number.isFinite(n)) return n; - } - return null; - } - if (exchange == null) return computed; - const ref = Math.max(Math.abs(computed), 1); - if (Math.abs(exchange - computed) / ref > 0.2) return computed; - return exchange; - } - - function resolveTrendFloatingPnl(pos, trendPlan, markOverride) { - return resolvePositionUpnlUsdt(pos, trendPlan, markOverride); - } - - function formatFloatingPnlText(upnl, notionalUsdt) { - if (upnl == null || !Number.isFinite(Number(upnl))) return { text: "—", cls: "" }; - let pnlText = fmt(upnl, 2) + "U"; - const notional = Number(notionalUsdt); - if (Number.isFinite(notional) && Math.abs(notional) > 1e-8) { - const pct = (Number(upnl) / Math.abs(notional)) * 100; - pnlText += ` (${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%)`; - } - return { text: pnlText, cls: pnlCls(upnl) }; - } - - /** 与实例策略页一致:浮盈亏 % = 浮盈亏 / 计划保证金 */ - function formatTrendPlanFloatingPnl(upnl, planMargin) { - if (upnl == null || !Number.isFinite(Number(upnl))) { - return { text: "—", cls: "" }; - } - let pnlText = fmt(upnl, 2) + "U"; - const margin = Number(planMargin); - if (Number.isFinite(margin) && margin > 0) { - const pct = (Number(upnl) / margin) * 100; - pnlText += ` (${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%)`; - } - const n = Number(upnl); - let cls = "pnl-neutral"; - if (n > 0) cls = "pnl-profit"; - else if (n < 0) cls = "pnl-loss"; - return { text: pnlText, cls }; - } - - function renderDirectionBadge(side) { - const s = normSide(side); - const label = sideDirLabel(side); - const cls = s === "long" ? "direction-long" : s === "short" ? "direction-short" : ""; - if (!cls) return esc(String(label)); - return `${esc(label)}`; - } - - function resolveTrendDcaLevels(t) { - if (Array.isArray(t.dca_levels) && t.dca_levels.length) return t.dca_levels; - const plan = t || {}; - let grid = []; - let legAmounts = []; - try { - grid = JSON.parse(plan.grid_prices_json || "[]"); - if (!Array.isArray(grid)) grid = []; - } catch (_e) { - grid = []; - } - try { - legAmounts = JSON.parse(plan.leg_amounts_json || "[]"); - if (!Array.isArray(legAmounts)) legAmounts = []; - } catch (_e2) { - legAmounts = []; - } - const legsDone = Number(plan.legs_done) || 0; - const dcaLegs = Number(plan.dca_legs) || 0; - const firstDone = Number(plan.first_order_done) !== 0; - const out = [ - { - label: "首仓", - price: null, - contracts: plan.first_order_amount, - status: firstDone ? "done" : "pending", - status_label: firstDone ? "已开仓" : "待开仓", - }, - ]; - const n = Math.max(grid.length, legAmounts.length, dcaLegs); - for (let idx = 0; idx < n; idx += 1) { - const legI = idx + 1; - const done = legI <= legsDone; - out.push({ - label: `补仓${legI}`, - price: idx < grid.length ? grid[idx] : null, - contracts: idx < legAmounts.length ? legAmounts[idx] : null, - status: done ? "done" : "pending", - status_label: done ? "已补仓" : "待补仓", - }); - } - return out; - } - - function pnlCls(v) { - const n = Number(v); - if (!Number.isFinite(n) || n === 0) return ""; - return n > 0 ? "pnl-pos" : "pnl-neg"; - } - - function normSide(side) { - const s = (side || "").toLowerCase(); - if (s === "buy") return "long"; - if (s === "sell") return "short"; - return s; - } - - function sideDirCls(side) { - const s = normSide(side); - if (s === "long") return "side-long"; - if (s === "short") return "side-short"; - return ""; - } - - function sideDirLabel(side) { - const s = normSide(side); - if (s === "long") return "做多"; - if (s === "short") return "做空"; - return side || "—"; - } - - function isTrendHandoffOrder(monitorOrder) { - const mo = monitorOrder || {}; - return String(mo.trade_style || "").toLowerCase() === "trend_pullback_handoff"; - } - - function isTrendContext(monitorOrder, trendPlan) { - const mo = monitorOrder || {}; - const tp = trendPlan || {}; - if (tp.id != null && Number(tp.id) > 0) return true; - const tid = Number(mo.trend_plan_id); - if (Number.isFinite(tid) && tid > 0) return true; - const mt = String(mo.monitor_type || "").trim(); - if (mt === "趋势回调") return true; - const kst = String(mo.key_signal_type || "").trim(); - return kst === "趋势回调" || kst === "趋势回调计划"; - } - - function trendAddZoneLabel(direction) { - return (direction || "long").toLowerCase() === "short" ? "补仓下沿" : "补仓上沿"; - } - - function monitorOrderSourceLabel(mo, trendPlan) { - if (isTrendContext(mo, trendPlan)) return "趋势回调计划"; - const o = mo || {}; - const mt = String(o.monitor_type || "").trim(); - return mt || "下单监控"; - } - - function monitorOrderSourceHtml(mo, trendPlan) { - if (isTrendContext(mo, trendPlan)) { - return `来源: ${esc(monitorOrderSourceLabel(mo, trendPlan))}`; - } - const src = monitorOrderSourceLabel(mo, trendPlan); - const kst = String((mo && mo.key_signal_type) || "").trim(); - let text = src; - if (kst && kst !== src && !text.includes(kst)) { - text += " · " + kst; - } - return `来源: ${esc(text)}`; - } - - function renderDirectionHtml(side) { - const cls = sideDirCls(side); - const label = sideDirLabel(side); - if (!cls) return esc(String(label)); - return `${esc(label)}`; - } - - function keyHasPendingOrder(keyRow, keyPrice) { - const kp = keyPrice || {}; - const oid = keyRow.fib_limit_order_id; - if (oid != null && String(oid).trim() !== "") return true; - const gm = String(kp.gate_metrics || ""); - if (gm.includes("限价单") || gm.includes("挂单")) return true; - const gs = String(kp.gate_summary || ""); - if (/挂|限价|等待成交/.test(gs)) return true; - return false; - } - - function fmtKeyOrderAmount(keyRow) { - const raw = keyRow.fib_order_amount; - if (raw == null || raw === "") return ""; - const n = Number(raw); - if (!Number.isFinite(n) || n <= 0) return ""; - return `${fmt(n, 4)} 张`; - } - - /** 全屏持仓区:按仓位数量附加布局 class(1~6 固定列数,7+ 自动填充) */ - function hubPosListCountClass(n) { - const c = Math.max(0, parseInt(n, 10) || 0); - if (c <= 0) return "count-0"; - if (c <= 6) return `count-${c}`; - return "count-many"; - } - - function currentPage() { - const p = window.location.pathname.replace(/\/$/, "") || "/monitor"; - if (p.includes("settings")) return "settings"; - if (p.includes("archive")) return "archive"; - if (p.includes("dashboard")) return "dashboard"; - if (p.includes("funds")) return "funds"; - if (p.includes("plan")) return "plan"; - if (p.includes("calculator")) return "calculator"; - if (p.includes("market")) return "market"; - if (p.includes("/ai")) return "ai"; - return "monitor"; - } - - function pageElementId(page) { - if (page === "settings") return "page-settings"; - if (page === "archive") return "page-archive"; - if (page === "dashboard") return "page-dashboard"; - if (page === "funds") return "page-funds"; - if (page === "plan") return "page-plan"; - if (page === "calculator") return "page-calculator"; - if (page === "market") return "page-market"; - if (page === "ai") return "page-ai"; - return "page-monitor"; - } - - function setActiveNav() { - let page = currentPage(); - if (!pageNavAllowed(page)) { - history.replaceState({}, "", "/monitor"); - page = "monitor"; - } - const pageId = pageElementId(page); - document.querySelectorAll(".top-nav a").forEach((a) => { - const href = (a.getAttribute("href") || "").split("?")[0]; - a.classList.toggle( - "active", - href === "/" + page || (page === "monitor" && (href === "/" || href === "/monitor")) - ); - }); - document.querySelectorAll(".page").forEach((el) => { - el.classList.toggle("hidden", el.id !== pageId); - }); - document.body.classList.toggle("hub-page-ai", page === "ai"); - document.body.classList.toggle("hub-page-funds", page === "funds"); - document.body.classList.toggle("hub-page-dashboard", page === "dashboard"); - syncHubAiMobileViewport(); - if (page === "monitor") startMonitorPoll(); - else stopMonitorPoll(); - if (page !== "ai") closeSupervisorStream(); - if (page === "dashboard" && window.hubDashboardPage) { - window.hubDashboardPage.init(); - } else if (window.hubDashboardPage && window.hubDashboardPage.destroy) { - window.hubDashboardPage.destroy(); - } - if (page === "settings") loadSettingsUI(); - if (page === "ai") loadAiPage(); - if (page === "archive" && window.hubArchivePage) { - window.hubArchivePage.init(); - } else if (window.hubArchivePage && window.hubArchivePage.destroy) { - window.hubArchivePage.destroy(); - } - if (page === "plan" && window.hubPlanPage) { - window.hubPlanPage.init(); - } else if (window.hubPlanPage && window.hubPlanPage.destroy) { - window.hubPlanPage.destroy(); - } - if (page === "calculator" && window.hubCalculatorPage) { - window.hubCalculatorPage.init(); - } - if (page === "funds" && window.hubFundsPage) { - window.hubFundsPage.init(); - } else if (window.hubFundsPage && window.hubFundsPage.destroy) { - window.hubFundsPage.destroy(); - } - if (page === "market" && window.hubMarketChart) { - window.hubMarketChart.init(); - } else if (window.hubMarketChart) { - if (window.hubMarketChart.stopChartLive) window.hubMarketChart.stopChartLive(); - else { - if (window.hubMarketChart.stopAutoRefresh) window.hubMarketChart.stopAutoRefresh(); - } - if (window.hubMarketChart.stopPriceTagTimer) window.hubMarketChart.stopPriceTagTimer(); - } - } - - function stopMonitorPoll() { - closeMonitorBoardStream(); - stopHostStatusPoll(); - stopMacroBannerPoll(); - if (sseReconnectTimer) { - clearTimeout(sseReconnectTimer); - sseReconnectTimer = null; - } - } - - function closeMonitorBoardStream() { - if (boardEventSource) { - boardEventSource.close(); - boardEventSource = null; - } - } - - function connectMonitorBoardStream() { - closeMonitorBoardStream(); - if (!document.getElementById("auto-monitor")?.checked) return; - if (currentPage() !== "monitor") return; - boardEventSource = new EventSource("/api/monitor/board/stream"); - boardEventSource.addEventListener("board", (ev) => { - try { - const st = JSON.parse(ev.data || "{}"); - const ver = Number(st.board_version) || 0; - if (ver !== localBoardVersion) { - void fetchMonitorBoardSnapshot({ background: true }); - } else if (st.aggregating && lastMonitorRows.length) { - applyMonitorBoardUi(lastMonitorRows, st.updated_at || lastMonitorBoardUpdatedAt, { - stale: true, - }); - } - } catch (_) {} - }); - boardEventSource.onerror = () => { - closeMonitorBoardStream(); - if (sseReconnectTimer) clearTimeout(sseReconnectTimer); - sseReconnectTimer = setTimeout(() => { - if (currentPage() === "monitor" && document.getElementById("auto-monitor")?.checked) { - connectMonitorBoardStream(); - void fetchMonitorBoardSnapshot({ background: true }); - } - }, 8000); - }; - } - - async function requestMonitorBoardRefresh() { - await apiFetch("/api/monitor/board/refresh", { method: "POST" }); - } - - function clearMonitorBoardSlowHint() { - if (monitorBoardSlowHintTimer) { - clearTimeout(monitorBoardSlowHintTimer); - monitorBoardSlowHintTimer = null; - } - } - - function scheduleMonitorBoardSlowHint(box) { - clearMonitorBoardSlowHint(); - if (!box) return; - monitorBoardSlowHintTimer = setTimeout(() => { - if (lastMonitorRows.length) return; - const el = box.querySelector(".board-loading"); - if (!el) return; - const sub = el.querySelector(".board-loading-sub"); - if (sub) { - sub.textContent = - "后台首次聚合较慢(四所子代理 + Flask)。可检查 PM2、或设 HUB_BOARD_KEY_PRICES=false 加速。"; - } - }, 12000); - } - - function saveMonitorBoardCache(rows, updatedAt, boardVersion) { - try { - sessionStorage.setItem( - HUB_MONITOR_BOARD_CACHE_KEY, - JSON.stringify({ - version: 1, - board_version: boardVersion != null ? boardVersion : localBoardVersion, - updated_at: updatedAt || "", - rows: rows || [], - saved_at: Date.now(), - }) - ); - } catch (_) {} - } - - function loadMonitorBoardFromCache() { - try { - const raw = sessionStorage.getItem(HUB_MONITOR_BOARD_CACHE_KEY); - if (!raw) return null; - const data = JSON.parse(raw); - if (!data || !Array.isArray(data.rows) || !data.rows.length) return null; - const age = Date.now() - Number(data.saved_at || 0); - if (!Number.isFinite(age) || age > HUB_MONITOR_CACHE_MAX_AGE_MS) { - sessionStorage.removeItem(HUB_MONITOR_BOARD_CACHE_KEY); - return null; - } - return data; - } catch (_) { - return null; - } - } - - function restoreMonitorBoardFromCache() { - const cached = loadMonitorBoardFromCache(); - if (!cached) return false; - lastMonitorRows = cached.rows; - lastMonitorBoardUpdatedAt = cached.updated_at || ""; - localBoardVersion = 0; - applyMonitorBoardUi(cached.rows, lastMonitorBoardUpdatedAt, { stale: true }); - return true; - } - - function applyMonitorBoardUi(rows, updatedAt, opts) { - const options = opts || {}; - const tsRaw = updatedAt || lastMonitorBoardUpdatedAt || ""; - if (updatedAt) lastMonitorBoardUpdatedAt = updatedAt; - const online = (rows || []).filter((x) => x.http_ok && (x.agent || {}).ok !== false).length; - const pill = document.getElementById("sys-status"); - if (pill) { - pill.textContent = rows.length ? `LINK ${online}/${rows.length}` : "NO DATA"; - pill.classList.toggle("warn", rows.length && online < rows.length); - if (options.stale) pill.classList.add("syncing"); - else pill.classList.remove("syncing"); - } - const upd = document.getElementById("monitor-updated"); - if (upd) { - const ts = tsRaw.replace("T", " "); - upd.textContent = options.stale - ? ts - ? `缓存 ${ts} · 后台聚合中…` - : "后台聚合中…" - : ts - ? `UPD ${ts}` - : ""; - } - updateMonitorAlertSummary(rows || []); - void refreshMacroRiskBanner(rows || []); - renderMonitorGrid(rows || []); - } - - let macroBannerTimer = null; - let macroCalendarEditId = null; - - function monitorHasOpenPositions(rows) { - return (rows || []).some((row) => { - const pos = (row.agent && row.agent.positions) || []; - return Array.isArray(pos) && pos.length > 0; - }); - } - - function macroAlertMessage(alert, hasPositions) { - const label = alert.event_type_label || alert.event_type || "宏观数据"; - const phase = alert.phase || "window"; - const mins = Number(alert.minutes_to_event || 0); - if (hasPositions) { - if (phase === "imminent" && mins > 0) { - return ( - `「${label}」即将发布(约 ${mins} 分钟),` + - "注意仓位风险:勿加仓,检查止损/减仓" - ); - } - return `「${label}」高波动窗口(±1h),注意仓位风险:勿加仓,检查止损/减仓`; - } - if (phase === "imminent" && mins > 0) { - return `「${label}」即将发布(约 ${mins} 分钟),建议等待,避免新开仓`; - } - return `「${label}」高波动窗口(±1h),建议等待,避免新开仓`; - } - - async function refreshMacroRiskBanner(rows) { - if (currentPage() !== "monitor") return; - const el = document.getElementById("monitor-macro-banner"); - const textEl = document.getElementById("monitor-macro-banner-text"); - if (!el || !textEl) return; - try { - const r = await apiFetch("/api/macro-calendar/active"); - const j = await r.json(); - const alerts = (j.ok && j.alerts) || []; - if (!alerts.length) { - el.classList.add("hidden"); - el.classList.remove("phase-imminent"); - textEl.textContent = ""; - return; - } - const alert = alerts[0]; - const hasPos = monitorHasOpenPositions(rows || lastMonitorRows); - textEl.textContent = macroAlertMessage(alert, hasPos); - el.classList.toggle("phase-imminent", alert.phase === "imminent"); - el.classList.remove("hidden"); - } catch (_) { - el.classList.add("hidden"); - } - } - - function startMacroBannerPoll() { - stopMacroBannerPoll(); - if (currentPage() !== "monitor") return; - void refreshMacroRiskBanner(lastMonitorRows); - macroBannerTimer = setInterval(() => { - if (currentPage() === "monitor") void refreshMacroRiskBanner(lastMonitorRows); - }, 30000); - } - - function stopMacroBannerPoll() { - if (macroBannerTimer) { - clearInterval(macroBannerTimer); - macroBannerTimer = null; - } - } - - function startMonitorPoll() { - const hadCache = restoreMonitorBoardFromCache(); - void fetchMonitorBoardSnapshot({ showLoading: !hadCache }); - connectMonitorBoardStream(); - startHostStatusPoll(); - startMacroBannerPoll(); - } - - async function loadSettings() { - const r = await apiFetch("/api/settings"); - settingsCache = await r.json(); - syncNavVisibility(settingsCache); - return settingsCache; - } - - function enabledAccounts() { - return (settingsCache?.exchanges || []).filter((x) => x.enabled); - } - - /** 窄屏布局:仅按视口宽度,监控区/行情等共用 */ - function isMobileLayout() { - return window.matchMedia("(max-width: 720px)").matches; - } - - /** AI 教练手机布局:窄屏或手机 PWA(桌面安装的 App 仍走桌面布局) */ - function isMobileAiLayout() { - if (isMobileLayout()) return true; - if ( - window.matchMedia("(display-mode: standalone)").matches && - window.matchMedia("(max-width: 960px)").matches - ) { - return true; - } - if (window.navigator && window.navigator.standalone === true) return true; - return false; - } - - function positionHasContracts(p) { - const c = Number(p && p.contracts); - return Number.isFinite(c) && Math.abs(c) >= 1e-12; - } - - function exchangeNeedsFlask(row) { - const caps = row.capabilities || []; - return caps.includes("key") || caps.includes("trend"); - } - - function positionMissingStopLoss(pos, orders, trends) { - if (!positionHasContracts(pos)) return false; - const mo = findMonitorOrder(orders, pos.symbol, pos.side); - const tp = findTrendPlan(trends, pos.symbol, pos.side); - const tpsl = resolvePositionTpsl(pos, mo, tp); - const sl = tpsl.sl; - if (sl !== "" && sl != null && Number.isFinite(Number(sl))) return false; - const cond = condOrdersFromPosition(pos); - const picked = pickExTpslOrders(cond); - if (picked.sl && picked.sl.trigger_price != null) return false; - const et = pos.exchange_tpsl; - if (et && et.sl) return false; - return true; - } - - function analyzeExchangeAlert(row) { - const ag = row.agent || {}; - const hm = row.hub_monitor || {}; - const pos = Array.isArray(ag.positions) ? ag.positions : []; - const flaskOk = row.flask_ok !== false && hm.ok !== false; - const upnl = Number(ag.total_unrealized_pnl); - const tradingBal = Number(row.trading_usdt); - const balance = - Number.isFinite(tradingBal) && tradingBal > 0 - ? tradingBal - : Number(ag.balance_usdt); - const sortUpnl = Number.isFinite(upnl) ? upnl : 0; - - if (!row.http_ok) { - return { level: "error", summary: "子代理离线", sortUpnl: 0 }; - } - if (ag.ok === false) { - return { - level: "error", - summary: (ag.error || row.error || "子代理异常").slice(0, 24), - sortUpnl: 0, - }; - } - if (exchangeNeedsFlask(row) && !flaskOk) { - const fe = row.flask_error || hm.error || hm.msg || "Flask未连通"; - return { level: "error", summary: String(fe).slice(0, 24), sortUpnl }; - } - - const orders = flaskOk ? hm.orders || [] : []; - const trends = flaskOk ? hm.trends || [] : []; - let missingSl = false; - for (const p of pos) { - if (positionMissingStopLoss(p, orders, trends)) { - missingSl = true; - break; - } - } - - if (Number.isFinite(upnl) && upnl < 0 && Number.isFinite(balance) && balance > 0) { - const lossPct = (Math.abs(upnl) / balance) * 100; - if (lossPct >= HUB_ALERT_FLOAT_LOSS_RATIO * 100) { - return { - level: "warn", - summary: `浮亏超10% · ${fmt(upnl, 2)}U`, - sortUpnl, - }; - } - } - if (missingSl) { - return { level: "warn", summary: "缺止损", sortUpnl }; - } - - const openCount = pos.filter(positionHasContracts).length; - return { - level: "ok", - summary: openCount ? "正常" : "空仓", - sortUpnl, - }; - } - - function sortRowsForMobileDashboard(rows) { - const levelOrder = { error: 0, warn: 1, ok: 2 }; - return rows - .map((r) => ({ r, a: analyzeExchangeAlert(r) })) - .sort((x, y) => { - const ld = levelOrder[x.a.level] - levelOrder[y.a.level]; - if (ld !== 0) return ld; - return (x.a.sortUpnl || 0) - (y.a.sortUpnl || 0); - }) - .map((x) => x.r); - } - - function updateMonitorAlertSummary(rows) { - const el = document.getElementById("monitor-alert-summary"); - if (!el) return; - if (!isMobileLayout() || !rows.length) { - el.classList.add("hidden"); - el.innerHTML = ""; - return; - } - let err = 0; - let warn = 0; - let ok = 0; - rows.forEach((r) => { - const lv = analyzeExchangeAlert(r).level; - if (lv === "error") err += 1; - else if (lv === "warn") warn += 1; - else ok += 1; - }); - el.classList.remove("hidden"); - el.innerHTML = `正常 ${ok}·关注 ${warn}·异常 ${err}`; - } - - /** 监控卡片列数:桌面 3/2 列;手机端 2 列瓦片 */ - function syncMonitorGridColumns(gridEl, count) { - if (!gridEl) return; - if (isMobileLayout()) { - gridEl.style.gridTemplateColumns = "repeat(2, minmax(0, 1fr))"; - return; - } - let cols = 3; - if (count <= 1) cols = 1; - else if (count === 2) cols = 2; - else if (count === 3) cols = 3; - else if (count === 4) cols = 2; - else cols = 3; - gridEl.style.gridTemplateColumns = `repeat(${cols}, minmax(0, 1fr))`; - } - - const AI_MOBILE_TAB_KEY = "hub_ai_mobile_tab"; - const AI_MOBILE_CHAT_TABS = new Set(["trading", "general", "supervisor"]); - let aiSupervisorSessionCache = null; - let supervisorEventSource = null; - let localSupervisorVersion = 0; - let supervisorReconnectTimer = null; - - function isSupervisorMode() { - return aiSelectedBotMode === "supervisor"; - } - - function normalizeAiBotMode(mode) { - const m = (mode || "").trim().toLowerCase(); - if (m === "general") return "general"; - if (m === "supervisor") return "supervisor"; - return "trading"; - } - - function normalizeAiMobileTab(tab) { - const raw = (tab || "").trim().toLowerCase(); - if (raw === "chat") return "trading"; - if (AI_MOBILE_CHAT_TABS.has(raw) || raw === "history") return raw; - return "trading"; - } - - function applyAiMobileTab(tab) { - const layout = document.querySelector(".ai-layout"); - const tabs = document.querySelectorAll(".ai-mobile-tab"); - if (!layout) return; - const mobile = isMobileAiLayout(); - if (!mobile) { - delete layout.dataset.aiMobileTab; - tabs.forEach((btn) => { - btn.classList.remove("is-active"); - btn.setAttribute("aria-selected", "false"); - }); - return; - } - const active = normalizeAiMobileTab( - tab || localStorage.getItem(AI_MOBILE_TAB_KEY) || "trading" - ); - layout.dataset.aiMobileTab = active; - tabs.forEach((btn) => { - const t = btn.dataset.aiTab || ""; - const on = t === active; - btn.classList.toggle("is-active", on); - btn.setAttribute("aria-selected", on ? "true" : "false"); - }); - if (AI_MOBILE_CHAT_TABS.has(active)) { - updateAiBotTabs(active); - if (active === "supervisor") { - void loadAiSupervisorSession().then(() => connectSupervisorStream()); - } else { - closeSupervisorStream(); - } - scrollAiChatToEnd(); - } - if (active === "history") { - const hist = document.getElementById("ai-chat-history-list"); - if (hist) hist.scrollTop = 0; - } - } - - function initAiMobileTabs() { - const tabs = document.querySelectorAll(".ai-mobile-tab"); - if (!tabs.length) return; - tabs.forEach((btn) => { - btn.addEventListener("click", () => { - const tab = btn.dataset.aiTab || "trading"; - if (tab === "new") { - const prev = normalizeAiMobileTab(localStorage.getItem(AI_MOBILE_TAB_KEY) || "trading"); - const botMode = prev === "general" ? "general" : prev === "supervisor" ? "supervisor" : "trading"; - if (botMode === "supervisor") { - void switchToSupervisorMode(); - } else { - void newAiChat(botMode); - } - return; - } - if (tab === "supervisor") { - void switchToSupervisorMode(); - return; - } - localStorage.setItem(AI_MOBILE_TAB_KEY, tab); - applyAiMobileTab(tab); - if (AI_MOBILE_CHAT_TABS.has(tab)) { - const input = document.getElementById("ai-chat-input"); - if (input && isMobileAiLayout()) input.focus(); - } - }); - }); - window.addEventListener("resize", () => applyAiMobileTab()); - applyAiMobileTab(); - } - - let syncHubAiMobileViewport = () => {}; - - function initHubAiMobileViewport() { - const shell = document.querySelector(".app-shell"); - const chatInput = document.getElementById("ai-chat-input"); - if (!shell || !window.visualViewport) { - syncHubAiMobileViewport = () => {}; - return; - } - - let baselineInnerH = Math.max(window.innerHeight, window.visualViewport.height || 0); - - const scrollChatToEnd = () => { - const box = document.getElementById("ai-chat-messages"); - if (box) requestAnimationFrame(() => { box.scrollTop = box.scrollHeight; }); - }; - - syncHubAiMobileViewport = () => { - const onAi = document.body.classList.contains("hub-page-ai"); - if (!onAi || !isMobileAiLayout()) { - shell.style.removeProperty("height"); - shell.style.removeProperty("max-height"); - shell.style.removeProperty("width"); - shell.style.removeProperty("transform"); - document.documentElement.style.removeProperty("--hub-vvh"); - document.body.classList.remove("hub-ai-keyboard-open"); - return; - } - const vv = window.visualViewport; - const h = Math.max(240, Math.round(vv.height)); - const top = Math.round(vv.offsetTop || 0); - const left = Math.round(vv.offsetLeft || 0); - const inputFocused = !!(chatInput && document.activeElement === chatInput); - if (!inputFocused) { - baselineInnerH = Math.max(baselineInnerH, window.innerHeight, h); - } - document.documentElement.style.setProperty("--hub-vvh", `${h}px`); - shell.style.height = `${h}px`; - shell.style.maxHeight = `${h}px`; - shell.style.width = `${Math.round(vv.width)}px`; - shell.style.transform = - top > 0 || left > 0 ? `translate(${left}px, ${top}px)` : ""; - const viewportShrunk = h < baselineInnerH * 0.72; - const keyboardLikely = inputFocused && (viewportShrunk || top > 48); - document.body.classList.toggle("hub-ai-keyboard-open", keyboardLikely); - }; - - window.visualViewport.addEventListener("resize", syncHubAiMobileViewport); - window.visualViewport.addEventListener("scroll", syncHubAiMobileViewport); - window.addEventListener("resize", syncHubAiMobileViewport); - window.addEventListener("orientationchange", () => { - setTimeout(syncHubAiMobileViewport, 80); - }); - - if (chatInput) { - chatInput.addEventListener("focus", () => { - syncHubAiMobileViewport(); - scrollChatToEnd(); - setTimeout(syncHubAiMobileViewport, 50); - setTimeout(syncHubAiMobileViewport, 280); - }); - chatInput.addEventListener("blur", () => { - setTimeout(syncHubAiMobileViewport, 80); - setTimeout(syncHubAiMobileViewport, 320); - }); - } - syncHubAiMobileViewport(); - } - - function initMobileLayout() { - initAiMobileTabs(); - initHubAiMobileViewport(); - let resizeTimer = null; - let wasMobile = isMobileLayout(); - window.addEventListener("resize", () => { - clearTimeout(resizeTimer); - resizeTimer = setTimeout(() => { - const nowMobile = isMobileLayout(); - if (lastMonitorRows.length && nowMobile !== wasMobile) { - wasMobile = nowMobile; - renderMonitorGrid(lastMonitorRows); - updateMonitorAlertSummary(lastMonitorRows); - return; - } - wasMobile = nowMobile; - const box = document.getElementById("monitor-grid"); - if (box && lastMonitorRows.length) { - syncMonitorGridColumns(box, lastMonitorRows.length); - updateMonitorAlertSummary(lastMonitorRows); - } - }, 120); - }); - } - - function normSym(s) { - return String(s || "") - .toUpperCase() - .replace(/:USDT$/i, "") - .replace(/\/USDT:USDT$/i, "") - .replace(/\/USDT$/i, ""); - } - - function symbolsMatchHub(a, b) { - const x = normSym(a); - const y = normSym(b); - if (!x || !y) return false; - return x === y; - } - - function ordersCollapseKey(exchangeId, symbol) { - const sym = normSym(symbol) || "unknown"; - return `hub_orders_${exchangeId}_${sym}`; - } - - function isOrdersCollapseOpen(exchangeId, symbol) { - return localStorage.getItem(ordersCollapseKey(exchangeId, symbol)) === "1"; - } - - function condOrderRole(o) { - const lb = (o && o.label) || ""; - if (/止盈止损/.test(lb)) return null; - if (/止损/.test(lb)) return "sl"; - if (/止盈/.test(lb)) return "tp"; - return null; - } - - function dedupeCondOrdersByRole(orders) { - const list = Array.isArray(orders) ? orders : []; - const byRole = {}; - const others = []; - for (const o of list) { - const role = condOrderRole(o); - if (role) byRole[role] = o; - else others.push(o); - } - const out = others.slice(); - if (byRole.tp) out.push(byRole.tp); - if (byRole.sl) out.push(byRole.sl); - return out; - } - - function dedupeCondOrdersByTrigger(orders) { - const list = Array.isArray(orders) ? orders : []; - const seen = new Set(); - const out = []; - for (const o of list) { - const px = orderTriggerOrPrice(o); - const key = - px != null - ? "t:" + String(px) - : o && o.id - ? "id:" + String(o.id) - : null; - if (key && seen.has(key)) continue; - if (key) seen.add(key); - out.push(o); - } - return out; - } - - function upsertExTpslCondOrder(cond, role, slot) { - if (!slot || slot.trigger_price == null || slot.trigger_price === "") return; - const label = role === "sl" ? "止损" : "止盈"; - const item = { - label: label, - trigger_price: Number(slot.trigger_price), - amount: slot.amount != null ? slot.amount : null, - id: slot.order_id || "", - channel: "algo", - }; - const idx = cond.findIndex(function (o) { - const lb = o.label || ""; - return role === "sl" ? /^止损\b/.test(lb) || lb.includes("止损") : /^止盈\b/.test(lb) || lb.includes("止盈"); - }); - if (idx >= 0) cond[idx] = Object.assign({}, cond[idx], item); - else cond.push(item); - } - - function condOrdersFromPosition(pos) { - let cond = dedupeCondOrdersByRole( - Array.isArray(pos.conditional_orders) ? pos.conditional_orders : [] - ); - cond = dedupeCondOrdersByTrigger(cond); - const et = pos.exchange_tpsl; - if (!et) return cond; - upsertExTpslCondOrder(cond, "sl", et.sl); - upsertExTpslCondOrder(cond, "tp", et.tp); - return cond; - } - - function findMonitorOrder(orders, symbol, side) { - const want = (side || "").toLowerCase(); - for (const o of orders || []) { - const sym = o.exchange_symbol || o.symbol || ""; - if (!symbolsMatchHub(sym, symbol)) continue; - const d = (o.direction || "").toLowerCase(); - if (!d || d === want) return o; - } - return null; - } - - function calcRrRatio(side, entry, sl, tp) { - const e = Number(entry); - const s = Number(sl); - const t = Number(tp); - if (![e, s, t].every((n) => Number.isFinite(n) && n > 0)) return null; - if ((side || "long").toLowerCase() === "short") { - const risk = s - e; - const reward = e - t; - if (risk <= 0 || reward <= 0) return null; - return reward / risk; - } - const risk = e - s; - const reward = t - e; - if (risk <= 0 || reward <= 0) return null; - return reward / risk; - } - - function resolveTrendPlanRr(trendPlan, side, entry, sl, tp) { - const t = trendPlan || {}; - if (t.money_rr != null && t.money_rr !== "") { - const n = Number(t.money_rr); - if (Number.isFinite(n) && n > 0) return n; - } - if (t.planned_rr != null && t.planned_rr !== "") { - const n = Number(t.planned_rr); - if (Number.isFinite(n) && n > 0) return n; - } - const e = t.avg_entry_price != null && t.avg_entry_price !== "" ? t.avg_entry_price : entry; - const s = t.stop_loss != null && t.stop_loss !== "" ? t.stop_loss : sl; - const p = t.take_profit != null && t.take_profit !== "" ? t.take_profit : tp; - return calcRrRatio(side, e, s, p); - } - - function resolveSnapshotRr(mo, side, entry, sl, tp, tpMonitored, trendPlan) { - if (tpMonitored && isTrendContext(mo, trendPlan)) { - const rr = resolveTrendPlanRr(trendPlan, side, entry, sl, tp); - if (rr != null) return rr; - } - if (tpMonitored) return null; - const snap = mo && mo.rr_ratio; - if (snap != null && snap !== "") { - const n = Number(snap); - if (Number.isFinite(n)) return n; - } - const initSl = mo && (mo.initial_stop_loss != null ? mo.initial_stop_loss : mo.stop_loss); - return calcRrRatio(side, entry, initSl || sl, tp); - } - - function formatTpCellValue(tp, tpMonitored, symbol, tickMap) { - if (tpMonitored) { - if (tp != null && tp !== "") { - return `程序监控 · ${fmtSymbolPrice(tp, symbol, tickMap)}`; - } - return "程序监控"; - } - if (tp != null && tp !== "") return fmtSymbolPrice(tp, symbol, tickMap); - return "—"; - } - - function isBreakevenSecured(side, entry, monitorOrder, cond, pos) { - const mo = monitorOrder || {}; - const p = pos || {}; - if (mo.sl_breakeven_secured === true || mo.sl_breakeven_secured === 1) return true; - if (p.sl_breakeven_secured === true || p.sl_breakeven_secured === 1) return true; - const { sl } = pickExTpslOrders(cond); - const trig = sl && sl.trigger_price != null ? Number(sl.trigger_price) : NaN; - const e = Number(entry); - if (!Number.isFinite(trig) || !Number.isFinite(e)) return false; - if ((side || "long").toLowerCase() === "short") return trig <= e; - return trig >= e; - } - - function breakevenBadgeHtml() { - return `已保本`; - } - - async function fetchMonitorBoardSnapshot(opts) { - const options = opts || {}; - const background = !!options.background; - const showLoading = !!options.showLoading && !lastMonitorRows.length; - const box = document.getElementById("monitor-grid"); - if (monitorBoardInFlight) { - if (background) monitorBoardFetchPending = true; - else return; - } - if (showLoading && box) { - box.innerHTML = - '
正在加载监控快照…

'; - scheduleMonitorBoardSlowHint(box); - } else if (background && lastMonitorRows.length) { - applyMonitorBoardUi(lastMonitorRows, null, { stale: true }); - } - monitorBoardInFlight = true; - const ctrl = new AbortController(); - const fetchTimer = setTimeout(() => ctrl.abort(), HUB_MONITOR_SNAPSHOT_TIMEOUT_MS); - try { - const r = await apiFetch(MONITOR_BOARD_SNAPSHOT_URL, { signal: ctrl.signal }); - const data = await r.json(); - if (!r.ok) { - throw new Error(data.msg || data.detail || `HTTP ${r.status}`); - } - const ver = Number(data.board_version) || 0; - const rows = data.rows || []; - const waitingFirst = data.aggregating && !rows.length && ver <= localBoardVersion; - if (waitingFirst && showLoading) { - if (box) { - const sub = box.querySelector(".board-loading-sub"); - if (sub) sub.textContent = "后台正在首次聚合四所数据(约 5~15 秒)…"; - } - return; - } - const ts = data.updated_at || ""; - const versionChanged = ver !== localBoardVersion; - const timeChanged = ts && ts !== lastMonitorBoardUpdatedAt; - if (versionChanged || timeChanged || !lastMonitorRows.length) { - localBoardVersion = ver; - lastMonitorRows = rows; - saveMonitorBoardCache(lastMonitorRows, ts, ver); - applyMonitorBoardUi(lastMonitorRows, ts, { - stale: !!data.aggregating, - }); - } else if (data.aggregating && lastMonitorRows.length) { - applyMonitorBoardUi(lastMonitorRows, data.updated_at || lastMonitorBoardUpdatedAt, { - stale: true, - }); - } - if (data.ok === false && data.msg && !background) { - showToast(String(data.msg), true); - } - } catch (e) { - const msg = - e && e.name === "AbortError" ? "读取监控快照超时,请检查中控是否运行" : String(e); - if (background && lastMonitorRows.length) { - showToast("快照读取失败,仍显示上次数据", true); - applyMonitorBoardUi(lastMonitorRows, null, { stale: false }); - return; - } - if (box) box.innerHTML = `
${esc(msg)}
`; - } finally { - clearTimeout(fetchTimer); - clearMonitorBoardSlowHint(); - monitorBoardInFlight = false; - if (monitorBoardFetchPending) { - monitorBoardFetchPending = false; - void fetchMonitorBoardSnapshot({ background: true }); - } - } - } - - async function refreshMonitorBoardNow() { - if (lastMonitorRows.length) { - applyMonitorBoardUi(lastMonitorRows, lastMonitorBoardUpdatedAt, { stale: true }); - } - try { - await requestMonitorBoardRefresh(); - await fetchMonitorBoardSnapshot({ background: false }); - } catch (e) { - showToast(String(e), true); - } - } - - function closeExchangeFullscreen() { - expandedExchangeId = ""; - sessionStorage.removeItem("hub_expanded_ex"); - const fs = document.getElementById("exchange-fullscreen"); - if (fs) { - fs.classList.add("hidden"); - fs.setAttribute("aria-hidden", "true"); - } - document.body.classList.remove("hub-fullscreen-open"); - } - - function openExchangeFullscreen(exId) { - expandedExchangeId = String(exId); - sessionStorage.setItem("hub_expanded_ex", expandedExchangeId); - renderMonitorGrid(lastMonitorRows); - } - - function renderMonitorGrid(rows) { - const box = document.getElementById("monitor-grid"); - const fs = document.getElementById("exchange-fullscreen"); - const fsInner = document.getElementById("exchange-fullscreen-inner"); - if (!box) return; - if (expandedExchangeId && !rows.some((r) => String(r.id) === String(expandedExchangeId))) { - closeExchangeFullscreen(); - } - const mobileTiles = isMobileLayout() && !expandedExchangeId; - const displayRows = mobileTiles ? sortRowsForMobileDashboard(rows) : rows; - box.classList.toggle("grid-monitor-tiles", mobileTiles); - try { - box.innerHTML = - displayRows - .map((r) => (mobileTiles ? renderMonitorTile(r) : renderMonitorCard(r))) - .join("") || '
无已启用账户
'; - } catch (err) { - console.error("renderMonitorGrid", err); - box.innerHTML = `
监控区渲染失败:${esc(String(err && err.message ? err.message : err))}
`; - } - syncMonitorGridColumns(box, displayRows.length); - bindMonitorInteractions(box); - if (window.TimeCloseUI && TimeCloseUI.tickLocalCountdowns) { - TimeCloseUI.tickLocalCountdowns(); - } - ensureHubHoldDurationTimer(); - - if (expandedExchangeId && fs && fsInner) { - const row = rows.find((r) => String(r.id) === String(expandedExchangeId)); - if (row) { - try { - fsInner.innerHTML = renderFullscreenExchange(row); - fs.classList.remove("hidden"); - fs.setAttribute("aria-hidden", "false"); - document.body.classList.add("hub-fullscreen-open"); - bindMonitorInteractions(fsInner); - if (window.TimeCloseUI && TimeCloseUI.tickLocalCountdowns) { - TimeCloseUI.tickLocalCountdowns(); - } - ensureHubHoldDurationTimer(); - fsInner.querySelectorAll(".btn-expand-back").forEach((btn) => { - btn.onclick = (ev) => { - ev.stopPropagation(); - closeExchangeFullscreen(); - renderMonitorGrid(lastMonitorRows); - }; - }); - } catch (err) { - console.error("renderFullscreenExchange", err); - closeExchangeFullscreen(); - showToast("全屏渲染失败: " + err, true); - } - } else { - closeExchangeFullscreen(); - } - } else { - closeExchangeFullscreen(); - } - } - - function normalizeMarketSymbol(raw) { - let s = (raw || "").trim().toUpperCase(); - if (!s) return ""; - if (s.includes(":")) { - const base = s.split(":")[0]; - if (base.includes("/")) return base; - } - return s; - } - - function resolveExchangeKey(exchangeId) { - const row = (lastMonitorRows || []).find((r) => String(r.id) === String(exchangeId)); - return (row && (row.key || row.id)) || exchangeId; - } - - function findTrendPlan(trends, symbol, side) { - const want = (side || "").toLowerCase(); - for (const t of trends || []) { - const sym = t.symbol || t.exchange_symbol || ""; - if (!symbolsMatchHub(sym, symbol)) continue; - const d = (t.direction || "").toLowerCase(); - if (!d || d === want) return t; - } - return null; - } - - function orderTriggerOrPrice(o) { - if (!o) return null; - if (o.trigger_price != null && o.trigger_price !== "") { - const t = Number(o.trigger_price); - if (Number.isFinite(t) && t > 0) return t; - } - if (o.price != null && o.price !== "") { - const p = Number(o.price); - if (Number.isFinite(p) && p > 0) return p; - } - return null; - } - - function inferTpslFromCondOrders(side, cond, entry) { - const picked = pickExTpslOrders(cond); - let sl = picked.sl ? orderTriggerOrPrice(picked.sl) : ""; - let tp = picked.tp ? orderTriggerOrPrice(picked.tp) : ""; - if (sl !== "" && sl != null) sl = Number(sl); - if (tp !== "" && tp != null) tp = Number(tp); - if (sl !== "" && tp !== "" && Number(sl) !== Number(tp)) { - return { sl, tp }; - } - - const triggers = (cond || []) - .map(function (o) { - const px = orderTriggerOrPrice(o); - return px == null ? null : { price: px, label: o.label || "" }; - }) - .filter(function (o) { - return o != null; - }); - if (!triggers.length) return { sl: sl || "", tp: tp || "" }; - - const s = (side || "long").toLowerCase(); - const e = entry != null && Number.isFinite(Number(entry)) ? Number(entry) : null; - - if (e != null) { - const below = triggers.filter(function (t) { - return t.price < e; - }); - const above = triggers.filter(function (t) { - return t.price > e; - }); - if (s === "long") { - if (sl === "" && below.length) { - sl = Math.max.apply( - null, - below.map(function (t) { - return t.price; - }) - ); - } - if (tp === "" && above.length) { - tp = Math.min.apply( - null, - above.map(function (t) { - return t.price; - }) - ); - } - } else { - if (sl === "" && above.length) { - sl = Math.min.apply( - null, - above.map(function (t) { - return t.price; - }) - ); - } - if (tp === "" && below.length) { - tp = Math.max.apply( - null, - below.map(function (t) { - return t.price; - }) - ); - } - } - } - - if (triggers.length === 1 && sl === "" && tp === "") { - const one = triggers[0]; - const p = one.price; - const lbl = one.label; - if (e != null) { - if (s === "long") { - if (p < e) sl = p; - else if (p > e) tp = p; - } else if (p > e) sl = p; - else if (p < e) tp = p; - } else if (/止损/.test(lbl)) sl = p; - else if (/止盈/.test(lbl) && !/止盈止损/.test(lbl)) tp = p; - } - - if (sl !== "" && tp !== "" && Number(sl) === Number(tp)) tp = ""; - return { sl: sl || "", tp: tp || "" }; - } - - function resolvePositionTpsl(pos, monitorOrder, trendPlan) { - const mo = monitorOrder || {}; - const tp = trendPlan || {}; - const cond = condOrdersFromPosition(pos); - const entryRaw = - pos.entry_price != null - ? pos.entry_price - : mo.trigger_price != null - ? mo.trigger_price - : tp.avg_entry_price; - const entryN = entryRaw != null && entryRaw !== "" ? Number(entryRaw) : null; - const isTrend = isTrendContext(mo, trendPlan); - const handoff = isTrendHandoffOrder(mo); - - let sl = mo.stop_loss != null && mo.stop_loss !== "" ? mo.stop_loss : ""; - let takeProfit = mo.take_profit != null && mo.take_profit !== "" ? mo.take_profit : ""; - let tpMonitored = false; - - if (handoff) { - tpMonitored = false; - } else if (isTrend) { - tpMonitored = true; - if (trendPlan && trendPlan.stop_loss != null && trendPlan.stop_loss !== "") { - sl = trendPlan.stop_loss; - } - if (trendPlan && trendPlan.take_profit != null && trendPlan.take_profit !== "") { - takeProfit = trendPlan.take_profit; - } else { - takeProfit = ""; - } - } - - const inferred = inferTpslFromCondOrders(pos.side, cond, entryN); - if (inferred.sl !== "" && inferred.sl != null) { - sl = inferred.sl; - } else if (sl === "" || sl == null) { - sl = inferred.sl; - } - if (!tpMonitored) { - if (inferred.tp !== "" && inferred.tp != null) { - takeProfit = inferred.tp; - } else if (takeProfit === "" || takeProfit == null) { - takeProfit = inferred.tp; - } - } - - if (sl !== "" && takeProfit !== "" && Number(sl) === Number(takeProfit)) { - takeProfit = ""; - } - - return { - entry: entryRaw, - sl, - tp: takeProfit, - tp_monitored: tpMonitored, - is_trend: isTrend, - is_handoff: handoff, - }; - } - - function buildPositionMarketContext(pos, monitorOrder, trendPlan, exchangeId) { - const mo = monitorOrder || {}; - const tpsl = resolvePositionTpsl(pos, monitorOrder, trendPlan); - const cond = condOrdersFromPosition(pos); - const reg = Array.isArray(pos.regular_orders) ? pos.regular_orders : []; - const num = function (v) { - if (v == null || v === "") return null; - const n = Number(v); - return Number.isFinite(n) ? n : null; - }; - const orders = []; - cond.forEach(function (o) { - orders.push({ - kind: "条件", - label: o.label || "条件单", - price: num(o.trigger_price), - amount: num(o.amount), - }); - }); - reg.forEach(function (o) { - orders.push({ - kind: "普通", - label: o.label || o.type || "委托", - price: num(o.price != null ? o.price : o.trigger_price), - amount: num(o.amount), - }); - }); - const entryPx = num(pos.entry_price != null ? pos.entry_price : tpsl.entry); - const markPx = num(pos.mark_price); - const contractSize = num(pos.contract_size); - const upnl = resolvePositionUpnlUsdt(pos, trendPlan, markPx); - const planMargin = - trendPlan && trendPlan.plan_margin_capital != null - ? num(trendPlan.plan_margin_capital) - : mo.margin_capital != null - ? num(mo.margin_capital) - : null; - const leverage = - trendPlan && trendPlan.leverage != null - ? num(trendPlan.leverage) - : mo.leverage != null - ? num(mo.leverage) - : null; - return { - exchange_id: exchangeId || null, - symbol: (pos.symbol || "").trim(), - side: (pos.side || "long").toLowerCase(), - entry: entryPx, - mark_price: markPx, - stop_loss: num(tpsl.sl), - take_profit: num(tpsl.tp), - tp_monitored: !!tpsl.tp_monitored, - is_trend: !!tpsl.is_trend, - contracts: num(pos.contracts), - contract_size: contractSize != null ? contractSize : 1, - unrealized_pnl: upnl != null ? Number(upnl) : null, - notional_usdt: num(pos.notional_usdt), - plan_margin: planMargin, - leverage: leverage, - orders: orders, - }; - } - - const HUB_MARKET_POS_CTX_KEY = "hubMarketPosContext"; - - function encodePosCtx(ctx) { - try { - return btoa(unescape(encodeURIComponent(JSON.stringify(ctx)))); - } catch (e) { - return ""; - } - } - - function decodePosCtx(raw) { - if (!raw) return null; - try { - return JSON.parse(decodeURIComponent(escape(atob(raw)))); - } catch (e) { - return null; - } - } - - function marketOpenBtnAttrs(exchangeId, exchangeKey, symbol, pos, monitorOrder, trendPlan) { - const symAttr = esc(symbol || "").replace(/"/g, """); - const exKeyAttr = esc(exchangeKey || exchangeId || "").replace(/"/g, """); - const ctxEnc = esc( - encodePosCtx(buildPositionMarketContext(pos, monitorOrder, trendPlan, exchangeId)) - ).replace( - /"/g, - """ - ); - return ( - 'data-ex-id="' + - esc(exchangeId) + - '" data-ex-key="' + - exKeyAttr + - '" data-symbol="' + - symAttr + - '" data-pos-ctx="' + - ctxEnc + - '"' - ); - } - - function openMarketForPosition(exchangeId, symbol, exchangeKey, posCtxRaw) { - const exKey = exchangeKey || resolveExchangeKey(exchangeId); - const sym = normalizeMarketSymbol(symbol); - if (!exKey || !sym) { - showToast("无法打开行情:缺少交易所或合约", true); - return; - } - const ctx = decodePosCtx(posCtxRaw); - if (ctx) { - ctx.symbol = sym; - ctx.exchange_key = exKey; - sessionStorage.setItem(HUB_MARKET_POS_CTX_KEY, JSON.stringify(ctx)); - } else { - sessionStorage.removeItem(HUB_MARKET_POS_CTX_KEY); - } - if (expandedExchangeId) { - closeExchangeFullscreen(); - } - const qs = new URLSearchParams({ exchange_key: exKey, symbol: sym }); - history.pushState({}, "", "/market?" + qs.toString()); - setActiveNav(); - if (window.hubMarketChart && window.hubMarketChart.openWith) { - window.hubMarketChart.openWith(exKey, sym); - } - } - - function bindMonitorInteractions(box) { - box.querySelectorAll(".btn-open-market").forEach((btn) => { - btn.onclick = (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - openMarketForPosition(btn.dataset.exId, btn.dataset.symbol, btn.dataset.exKey, btn.dataset.posCtx); - }; - }); - box.querySelectorAll(".btn-open-instance").forEach((btn) => { - btn.onclick = (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - const msg = (btn.dataset.confirm || "").trim(); - if (msg && !confirm(msg)) return; - openInstance(btn.dataset.exId, btn.dataset.next || "/", { - newTab: btn.dataset.newTab === "1" || ev.ctrlKey || ev.metaKey, - }); - }; - }); - box.querySelectorAll(".btn-hub-trend-stop").forEach((btn) => { - btn.onclick = (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - hubTrendPlanStop(btn.dataset.exId, btn.dataset.planId); - }; - }); - box.querySelectorAll(".btn-hub-trend-be").forEach((btn) => { - btn.onclick = (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - const card = btn.closest(".hub-trend-plan-card"); - const inp = card ? card.querySelector(".hub-plan-be-input") : null; - hubTrendPlanBreakeven(btn.dataset.exId, btn.dataset.planId, inp); - }; - }); - box.querySelectorAll(".btn-close-ex").forEach((btn) => { - btn.onclick = () => closeOne(btn.dataset.id); - }); - box.querySelectorAll(".btn-close-pos").forEach((btn) => { - btn.onclick = (ev) => { - ev.stopPropagation(); - closeOnePosition(btn.dataset.exId, btn.dataset.symbol, btn.dataset.side); - }; - }); - box.querySelectorAll(".btn-cancel-order").forEach((btn) => { - btn.onclick = (ev) => { - ev.stopPropagation(); - cancelOneOrder( - btn.dataset.exId, - btn.dataset.symbol, - btn.dataset.orderId, - btn.dataset.channel - ); - }; - }); - box.querySelectorAll(".btn-cancel-cond-all").forEach((btn) => { - btn.onclick = (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - cancelSymbolOrders(btn.dataset.exId, btn.dataset.symbol, "conditional"); - }; - }); - box.querySelectorAll(".btn-place-tpsl").forEach((btn) => { - btn.onclick = (ev) => { - ev.stopPropagation(); - openTpslModal( - btn.dataset.exId, - btn.dataset.symbol, - btn.dataset.side, - btn.dataset.contracts, - btn.dataset.sl || "", - btn.dataset.tp || "" - ); - }; - }); - box.querySelectorAll(".card-expand-zone").forEach((zone) => { - zone.onclick = (ev) => { - if (ev.target.closest("a, button, input, summary, details, .card-actions")) return; - const id = zone.closest(".card")?.dataset.exId; - if (id) openExchangeFullscreen(id); - }; - }); - box.querySelectorAll("details.pos-orders-collapse[data-collapse-key]").forEach((el) => { - el.addEventListener("toggle", () => { - const k = el.dataset.collapseKey; - if (k) localStorage.setItem(k, el.open ? "1" : "0"); - }); - }); - } - - function renderOrderRows(exchangeId, symbol, orders, kind, tickMap) { - if (!orders || !orders.length) { - const hint = - kind === "conditional" - ? "暂无条件单(止盈/止损等)" - : "暂无普通委托"; - return `
${hint}
`; - } - const symAttr = esc(symbol || "").replace(/"/g, """); - const rows = orders - .map((o) => { - const oidAttr = esc(o.id || "").replace(/"/g, """); - const chAttr = esc(o.channel || "regular").replace(/"/g, """); - const trig = - o.trigger_price != null - ? fmtSymbolPrice(o.trigger_price, symbol, tickMap) - : o.price != null - ? fmtSymbolPrice(o.price, symbol, tickMap) - : "—"; - return ` - - - - - `; - }) - .join(""); - return `
合约方向开仓价标记价张数操作
${esc(o.label || o.type || "委托")}${fmt(o.amount, 4)}${trig}
${rows}
类型数量触发/价格操作
`; - } - - function guessTpslFromCondOrders(side, cond, entry) { - return inferTpslFromCondOrders(side, cond, entry); - } - - function renderOrdersCollapse(exchangeId, symbol, cond, reg, tickMap) { - const symAttr = esc(symbol || "").replace(/"/g, """); - const orderTotal = cond.length + reg.length; - const collapseKey = ordersCollapseKey(exchangeId, symbol); - const openAttr = isOrdersCollapseOpen(exchangeId, symbol) ? " open" : ""; - const condAllBtn = - cond.length > 0 - ? `` - : ""; - const condBody = renderOrderRows(exchangeId, symbol, cond, "conditional", tickMap); - const regBody = renderOrderRows(exchangeId, symbol, reg, "limit", tickMap); - return `

- - 委托单 ${orderTotal} - 条件 ${cond.length} · 普通 ${reg.length} - ${condAllBtn} - -
-
-
条件单
- ${condBody} -
-
-
普通委托
- ${regBody} -
-
-
`; - } - - function syntheticExTpslOrder(role, price, amount) { - if (price == null || price === "" || !Number.isFinite(Number(price))) return null; - return { - label: role === "sl" ? "止损" : "止盈", - trigger_price: Number(price), - price: Number(price), - amount: amount != null ? amount : null, - id: "", - channel: "plan", - }; - } - - function pickExTpslOrders(cond) { - let sl = cond.find((o) => /^止损\b/.test(o.label || "")); - let tp = cond.find((o) => /^止盈\b/.test(o.label || "") && !(o.label || "").includes("止盈止损")); - if (!sl || !tp) { - const combo = cond.find((o) => (o.label || "").includes("止盈止损")); - if (combo) { - const m = (combo.label || "").match(/SL=([\d.eE+-]+).*TP=([\d.eE+-]+)/i); - if (m) { - if (!sl) sl = { ...combo, label: "止损", trigger_price: Number(m[1]) }; - if (!tp) tp = { ...combo, label: "止盈", trigger_price: Number(m[2]) }; - } - } - } - if (!sl) sl = cond.find((o) => (o.label || "").includes("止损")); - if (!tp) tp = cond.find((o) => (o.label || "").includes("止盈") && o !== sl); - return { sl, tp }; - } - - function renderExTpslRows(exchangeId, symbol, cond, tickMap, resolvedTpsl, contracts) { - const symAttr = esc(symbol || "").replace(/"/g, """); - let { sl, tp } = pickExTpslOrders(cond); - const plan = resolvedTpsl || {}; - if (!sl && plan.sl != null && plan.sl !== "") { - sl = syntheticExTpslOrder("sl", plan.sl, contracts); - } - if (!tp && plan.tp != null && plan.tp !== "") { - tp = syntheticExTpslOrder("tp", plan.tp, contracts); - } - function row(label, o) { - if (!o) { - return `
${label}:—
`; - } - const oid = esc(o.id || "").replace(/"/g, """); - const ch = esc(o.channel || "regular").replace(/"/g, """); - const px = orderTriggerOrPrice(o); - const trig = px != null ? fmtSymbolPrice(px, symbol, tickMap) : "—"; - const cancelBtn = - oid && o.channel !== "plan" - ? `` - : ""; - const planHint = o.channel === "plan" ? '(下单监控)' : ""; - return `
- ${label}:触发 ${trig} · 数量 ${fmt(o.amount, 4)}${planHint} - ${cancelBtn} -
`; - } - return row("止损", sl) + row("止盈", tp); - } - - function trendAddSummaryHtml(t, tickMap) { - const done = t.add_count != null ? t.add_count : t.legs_done; - const total = t.add_count_total != null ? t.add_count_total : t.dca_legs; - const sym = t.exchange_symbol || t.symbol || ""; - let html = ""; - if (done != null && Number(done) >= 0) { - html += total != null ? ` · 补仓 ${esc(done)}/${esc(total)}` : ` · 补仓 ${esc(done)} 次`; - const pxs = t.add_prices_display; - if (Array.isArray(pxs) && pxs.length) { - html += ` · 加仓价 ${pxs.map((p) => esc(p)).join(" / ")}`; - } else if (Array.isArray(t.add_prices) && t.add_prices.length) { - html += ` · 加仓价 ${t.add_prices.map((p) => esc(fmtSymbolPrice(p, sym, tickMap))).join(" / ")}`; - } else if (Number(done) === 0) { - html += " · 加仓价 —"; - } - } - return html; - } - - function timeCloseSymbolBadgeHtml(item) { - if (!item || !item.time_close_enabled) return ""; - const tcLabel = item.time_close_label || `时间平仓 ${item.time_close_hours || ""}h`; - const tcCd = item.time_close_countdown || "--:--:--"; - const tcAt = item.time_close_at_ms != null ? String(item.time_close_at_ms) : ""; - return ( - `` + - `${esc(tcLabel)} · ${esc(tcCd)}` - ); - } - - function renderTrendDcaTable(t, tickMap) { - const levels = resolveTrendDcaLevels(t); - if (!levels.length) return ""; - const sym = t.exchange_symbol || t.symbol || ""; - const rows = levels - .map((lv) => { - const price = - lv.price != null && lv.price !== "" - ? fmtSymbolPrice(lv.price, sym, tickMap) - : "—"; - const amt = - lv.contracts != null && lv.contracts !== "" ? esc(String(lv.contracts)) : "—"; - const avg = - lv.avg_entry != null && lv.avg_entry !== "" - ? fmtSymbolPrice(lv.avg_entry, sym, tickMap) - : "—"; - const profitU = - lv.profit_u != null && lv.profit_u !== "" ? fmt(lv.profit_u, 2) : "—"; - const riskU = lv.risk_u != null && lv.risk_u !== "" ? fmt(lv.risk_u, 2) : "—"; - const rr = lv.rr != null && lv.rr !== "" ? `${fmt(lv.rr, 2)}:1` : "—"; - const stCls = lv.status === "done" ? "st-done" : "st-pending"; - const label = lv.status_label || (lv.status === "done" ? "已补仓" : "待补仓"); - return ` - ${esc(lv.label || lv.leg_key || "—")} - ${esc(price)} - ${amt} - ${esc(avg)} - ${esc(profitU)} - ${esc(riskU)} - ${esc(rr)} - ${esc(label)} - `; - }) - .join(""); - return `
-
补仓计划明细
- - - ${rows} -
档位触发价张数加仓后均价止盈盈利(U)止损(U)盈亏比状态
-
`; - } - - function renderTrendPlanCard(t, tickMap, pos, exchangeRow) { - const sym = t.exchange_symbol || t.symbol || ""; - const side = (t.direction || "long").toLowerCase(); - const sl = t.stop_loss_display || fmtSymbolPrice(t.stop_loss, sym, tickMap); - const tp = t.take_profit_display || fmtSymbolPrice(t.take_profit, sym, tickMap); - const avg = t.avg_entry_price_display || fmtSymbolPrice(t.avg_entry_price, sym, tickMap); - const addZone = - t.add_upper_display || fmtSymbolPrice(t.add_upper, sym, tickMap) || "—"; - const rr = resolveTrendPlanRr(t, side, t.avg_entry_price, t.stop_loss, t.take_profit); - const rrTxt = rr != null ? `${fmt(rr, 2)}:1` : "—"; - const mark = resolveTrendMarkPrice(pos, t, sym, tickMap); - const legsDone = t.add_count != null ? t.add_count : t.legs_done; - const legsTotal = t.add_count_total != null ? t.add_count_total : t.dca_legs; - const legsTxt = - legsDone != null && legsTotal != null - ? `${esc(legsDone)}/${esc(legsTotal)}` - : legsDone != null - ? esc(legsDone) - : "—"; - const upnlTrend = resolveTrendFloatingPnl(pos, t); - const pnlFmt = formatTrendPlanFloatingPnl(upnlTrend, t.plan_margin_capital); - const pnlVal = - pnlFmt.text === "—" - ? "—" - : `${esc(pnlFmt.text)}`; - const riskTxt = - t.risk_percent != null && t.risk_percent !== "" ? `${esc(t.risk_percent)}%` : "—"; - const snapTxt = - t.snapshot_available_usdt != null && t.snapshot_available_usdt !== "" - ? `${fmt(t.snapshot_available_usdt, 2)}U` - : "—"; - const marginTxt = - t.plan_margin_capital != null && t.plan_margin_capital !== "" - ? `≈${fmt(t.plan_margin_capital, 2)}U` - : "—"; - const levTxt = t.leverage != null && t.leverage !== "" ? `${esc(t.leverage)}x` : "—"; - const bePctDefault = - t.breakeven_default_offset_pct != null && t.breakeven_default_offset_pct !== "" - ? t.breakeven_default_offset_pct - : t.breakeven_offset_pct != null && t.breakeven_offset_pct !== "" - ? t.breakeven_offset_pct - : "0.3"; - const exId = exchangeRow && exchangeRow.id != null ? esc(exchangeRow.id) : ""; - const planId = esc(t.id); - const caps = (exchangeRow && exchangeRow.capabilities) || []; - const flaskOk = - exchangeRow && exchangeRow.flask_ok !== false && (exchangeRow.hub_monitor || {}).ok !== false; - const canHubTrend = !!(flaskOk && caps.includes("trend") && exId && planId); - const beAppliedFlag = !!t.breakeven_applied; - const endBtn = canHubTrend - ? `` - : ""; - const beBtn = canHubTrend && !beAppliedFlag - ? `` - : beAppliedFlag - ? "" - : `保本移交下单监控`; - const beApplied = - t.breakeven_applied - ? `已保本 ${esc(String(t.breakeven_applied_at || "").slice(0, 16))}` - : ""; - const dcaHtml = renderTrendDcaTable(t, tickMap); - const dcaCol = dcaHtml - ? `
${dcaHtml}
` - : `
补仓计划明细
暂无补仓档位
`; - return `
-
-
- #${esc(t.id)} ${esc(sym)} - ${renderDirectionBadge(t.direction)} -
- ${endBtn} -
-
-
-
- 来源: 趋势回调计划 | 风险: ${riskTxt} - | ${esc(trendAddZoneLabel(t.direction))} ${esc(addZone)} - | 已补仓 ${legsTxt} -
-
-
均价${esc(avg)}
-
止损${esc(sl)}
-
止盈${esc(tp)}
-
盈亏比${esc(rrTxt)}
-
标记价${esc(mark)}
-
浮盈亏${pnlVal}
-
-
- ${dcaCol} -
-
-
- - ${beBtn} - ${beApplied} -
- -
-
`; - } - - function renderTrendSection(trends, tickMap, positions, exchangeRow) { - if (!trends || !trends.length) return ""; - const posList = Array.isArray(positions) ? positions : []; - const cards = trends - .map((t) => { - const sym = t.exchange_symbol || t.symbol || ""; - const side = (t.direction || "long").toLowerCase(); - let matched = null; - for (const p of posList) { - if (!symbolsMatchHub(p.symbol, sym)) continue; - const ps = (p.side || "").toLowerCase(); - if (!ps || ps === side) { - matched = p; - break; - } - } - return renderTrendPlanCard(t, tickMap, matched, exchangeRow); - }) - .join(""); - return `
-
运行中的计划
-
${cards}
-
`; - } - - function renderLivePositionCard(exchangeId, exchangeKey, pos, monitorOrder, trendPlan, tickMap) { - const symbol = pos.symbol || ""; - const exKeyAttr = esc(exchangeKey || exchangeId || "").replace(/"/g, """); - const side = (pos.side || "long").toLowerCase(); - const sideCn = sideDirLabel(side); - const sideCls = sideDirCls(side) || "side-long"; - const mo = monitorOrder || {}; - const cond = condOrdersFromPosition(pos); - const reg = Array.isArray(pos.regular_orders) ? pos.regular_orders : []; - const tpsl = resolvePositionTpsl(pos, mo, trendPlan); - const symAttr = esc(symbol).replace(/"/g, """); - const sideAttr = esc(side).replace(/"/g, """); - const contractsAttr = esc(String(pos.contracts != null ? pos.contracts : "")).replace(/"/g, """); - const slAttr = esc(String(tpsl.sl)).replace(/"/g, """); - const tpAttr = esc(String(tpsl.tp)).replace(/"/g, """); - const entry = tpsl.entry; - const sl = tpsl.sl; - const tp = tpsl.tp; - const tpMonitored = tpsl.tp_monitored; - const isTrend = isTrendContext(mo, trendPlan); - const rr = resolveSnapshotRr(mo, side, entry, sl, tp, tpMonitored, trendPlan); - const beSecured = isBreakevenSecured(side, entry, mo, cond, pos); - const upnl = resolveTrendFloatingPnl(pos, trendPlan); - const pnlFmt = formatFloatingPnlText(upnl, pos.notional_usdt); - const pnlText = pnlFmt.text; - const sizingFoot = resolveTrendSizingFooter(mo, trendPlan, isTrend, pos); - const openMeta = resolvePositionOpenMeta(mo, trendPlan, isTrend); - const marginText = - sizingFoot.margin != null && sizingFoot.margin !== "" && Number.isFinite(Number(sizingFoot.margin)) - ? fmt(Number(sizingFoot.margin), 2) + "U" - : "—"; - const holdMsAttr = - openMeta.openedAtMs != null && Number.isFinite(openMeta.openedAtMs) - ? String(openMeta.openedAtMs) - : ""; - const markDisplay = isTrend - ? resolveTrendMarkPrice(pos, trendPlan, symbol, tickMap) - : fmtMarkPrice(pos, tickMap); - const meta = []; - if (isTrend) { - meta.push(monitorOrderSourceHtml(mo, trendPlan)); - const riskLine = formatMonitorRiskMeta(mo, trendPlan); - if (riskLine) meta.push(riskLine); - const latestRiskLine = formatLatestRiskMeta(mo, trendPlan, pos, tpsl); - if (latestRiskLine) meta.push(latestRiskLine); - if (trendPlan && trendPlan.id) { - const zone = - trendPlan.add_upper_display || - fmtSymbolPrice(trendPlan.add_upper, symbol, tickMap) || - "—"; - meta.push( - `${esc(trendAddZoneLabel(trendPlan.direction))} ${esc(zone)}` - ); - const addSum = trendAddSummaryHtml(trendPlan, tickMap); - if (addSum) meta.push(addSum.replace(/^ · /, "")); - } - meta.push(`移动保本:关`); - } else if (mo.monitor_type || mo.key_signal_type || mo.trend_plan_id) { - meta.push(monitorOrderSourceHtml(mo, trendPlan)); - if (mo.trade_style) meta.push(`风格: ${esc(mo.trade_style)}`); - else meta.push("风格: —"); - const riskLine = formatMonitorRiskMeta(mo, trendPlan); - if (riskLine) meta.push(riskLine); - const latestRiskLine = formatLatestRiskMeta(mo, trendPlan, pos, tpsl); - if (latestRiskLine) meta.push(latestRiskLine); - const beOn = mo.breakeven_enabled === 1 || mo.breakeven_enabled === true; - meta.push( - `移动保本:${beOn ? "开" : "关"}` - ); - } else { - meta.push("来源: 交易所持仓"); - meta.push("风格: —"); - meta.push(`移动保本:关`); - } - const symBeBadge = beSecured ? ` ${breakevenBadgeHtml()}` : ""; - const tcSymBadge = !isTrend && mo.time_close_enabled ? timeCloseSymbolBadgeHtml(mo) : ""; - const mktAttrs = marketOpenBtnAttrs(exchangeId, exchangeKey, symbol, pos, monitorOrder, trendPlan); - return `
-
-
- ${tcSymBadge}${symBeBadge} - ${sideCn} -
-
- - -
-
-
${meta.map((m) => `${m}`).join("")}
-
-
开仓价${fmtEntryPrice(pos, tickMap)}
-
标记价${markDisplay}
-
止损${sl != null && sl !== "" ? fmtSymbolPrice(sl, symbol, tickMap) : "—"}
-
止盈${formatTpCellValue(tp, tpMonitored, symbol, tickMap)}
-
盈亏比${rr != null ? fmt(rr, 2) + ":1" : "—"}
-
张数${fmt(pos.contracts, 4)}
- ${ - showAccountPnlPref() - ? `
浮盈亏${pnlText}
` - : "" - } -
- -
-
交易所止盈止损
- ${renderExTpslRows(exchangeId, symbol, cond, tickMap, tpsl, pos.contracts)} -
- ${renderOrdersCollapse(exchangeId, symbol, cond, reg, tickMap)} -
`; - } - - function renderHubSectionCard(title, bodyHtml, emptyHint) { - const inner = bodyHtml || `
${esc(emptyHint || "暂无")}
`; - return `
-
${esc(title)}
-
${inner}
-
`; - } - - function renderKeySection(keys, kmap) { - if (!keys.length) return ""; - const cards = keys - .map((k) => { - const kp = kmap[k.id] || kmap[String(k.id)] || {}; - const mt = k.monitor_type || k.type || ""; - const pending = keyHasPendingOrder(k, kp); - const cardCls = pending ? "hub-mini-card hub-key-pending" : "hub-mini-card"; - const dir = k.direction ? ` · ${renderDirectionHtml(k.direction)}` : ""; - const pendingTag = pending - ? `挂单中` - : ""; - const amtTxt = fmtKeyOrderAmount(k); - const amtLine = amtTxt - ? `
挂单数量 ${esc(amtTxt)}
` - : ""; - const keyTc = - k.time_close_enabled && k.time_close_at_ms - ? timeCloseSymbolBadgeHtml(k) - : k.time_close_enabled && k.time_close_hours - ? `时间平仓 ${esc(k.time_close_hours)}h` - : ""; - return `
-
${esc(k.symbol)} ${keyTc} · ${esc(mt)}${dir} ${pendingTag}
-
上沿 ${esc(k.upper)} / 下沿 ${esc(k.lower)}
- ${amtLine} -
${esc(kp.gate_summary || kp.price_display || kp.price || "—")}${kp.gate_metrics ? ` · ${esc(kp.gate_metrics)}` : ""}
-
`; - }) - .join(""); - return `
${cards}
`; - } - - function renderOrderMonitorSection(orders, tickMap) { - if (!orders || !orders.length) return ""; - return orders - .map((o) => { - const sym = o.exchange_symbol || o.symbol || ""; - const tcBadge = o.time_close_enabled ? timeCloseSymbolBadgeHtml(o) : ""; - return `
-
#${esc(o.id)} · ${esc(o.symbol || o.exchange_symbol)} ${tcBadge} · ${renderDirectionHtml(o.direction)}
-
触发 ${fmtSymbolPrice(o.trigger_price, sym, tickMap)} · SL ${fmtSymbolPrice(o.stop_loss, sym, tickMap)} · TP ${fmtSymbolPrice(o.take_profit, sym, tickMap)} · ${esc(o.trade_style || o.monitor_type || "下单监控")}
-
`; - }) - .join(""); - } - - function renderRollSection(rolls, tickMap) { - if (!rolls || !rolls.length) return ""; - return rolls - .map((g) => { - const sym = g.symbol || g.exchange_symbol || ""; - const avg = - g.avg_entry_display || fmtSymbolPrice(g.avg_entry, sym, tickMap) || "—"; - const tpProfit = - g.reward_at_tp_usdt != null && g.reward_at_tp_usdt !== "" - ? `${fmt(g.reward_at_tp_usdt, 2)}U` - : "—"; - const legs = Array.isArray(g.recent_legs) ? g.recent_legs : []; - const legRows = legs - .map((leg) => { - const legAvg = - leg.avg_entry_display || - fmtSymbolPrice(leg.avg_entry_after, sym, tickMap) || - "—"; - const legProfit = - leg.reward_at_tp_usdt != null && leg.reward_at_tp_usdt !== "" - ? `${fmt(leg.reward_at_tp_usdt, 2)}U` - : "—"; - return `
腿 #${esc(leg.leg_index)} ${esc(leg.add_mode || "")} · 张 ${esc(leg.amount != null ? leg.amount : "—")} · 均价 ${legAvg} · 止盈 ${legProfit}
`; - }) - .join(""); - return `
-
组 #${esc(g.id)} · ${esc(g.symbol || "")} ${renderDirectionHtml(g.direction)} · 监控 #${esc(g.order_monitor_id || "—")}
-
腿数 ${esc(g.leg_count != null ? g.leg_count : "—")} · SL ${fmtSymbolPrice(g.current_stop_loss, sym, tickMap)} · 首仓TP ${fmtSymbolPrice(g.initial_take_profit, sym, tickMap)}
-
当前均价 ${avg} · 止盈盈利 ${tpProfit}
- ${legRows} -
`; - }) - .join(""); - } - - function renderPositionTableRow( - exchangeId, - exchangeKey, - x, - monitorOrder, - trendPlan, - tickMap, - opts - ) { - const options = opts || {}; - const symAttr = esc(x.symbol || "").replace(/"/g, """); - const sideAttr = esc((x.side || "").toLowerCase()).replace(/"/g, """); - const side = sideAttr || "long"; - const contractsAttr = esc(String(x.contracts != null ? x.contracts : "")).replace( - /"/g, - """ - ); - const cond = condOrdersFromPosition(x); - const tpsl = resolvePositionTpsl(x, monitorOrder, trendPlan); - const beSecured = isBreakevenSecured(side, tpsl.entry, monitorOrder, cond, x); - const slAttr = esc(String(tpsl.sl)).replace(/"/g, """); - const tpAttr = esc(String(tpsl.tp)).replace(/"/g, """); - const mktAttrs = marketOpenBtnAttrs(exchangeId, exchangeKey, x.symbol, x, monitorOrder, trendPlan); - const symBeBadge = beSecured ? ` ${breakevenBadgeHtml()}` : ""; - const mo = monitorOrder || {}; - const tcBadge = - !isTrendContext(mo, trendPlan) && mo.time_close_enabled ? timeCloseSymbolBadgeHtml(mo) : ""; - const actionCell = `
- - -
`; - const pnlTd = showAccountPnlPref() - ? `${fmt(x.unrealized_pnl, 2)}` - : ""; - return ` - ${tcBadge}${symBeBadge} - ${renderDirectionHtml(x.side)} - ${fmtEntryPrice(x, tickMap)} - ${fmtMarkPrice(x, tickMap)} - ${fmt(x.contracts, 4)} - ${pnlTd} - ${actionCell} - `; - } - - function renderPositionBlock(exchangeId, exchangeKey, x, monitorOrder, trendPlan, tickMap, opts) { - const options = opts || {}; - const compact = !!options.compact; - const reg = Array.isArray(x.regular_orders) ? x.regular_orders : []; - const cond = condOrdersFromPosition(x); - const ordersBlock = compact - ? "" - : renderOrdersCollapse(exchangeId, x.symbol, cond, reg, tickMap); - const rowHtml = renderPositionTableRow( - exchangeId, - exchangeKey, - x, - monitorOrder, - trendPlan, - tickMap, - opts - ); - return `
-
- ${positionTableHeadHtml(false)} - ${rowHtml} - -
- ${ordersBlock} -
`; - } - - const KEY_BUCKET_FIB_TYPES = new Set([ - "斐波回调0.618", - "斐波回调0.786", - "关键位斐波0.618", - "关键位斐波0.786", - ]); - const KEY_BUCKET_BREAKOUT_TYPES = new Set([ - "箱体突破", - "收敛突破", - "关键位箱体突破", - "关键位收敛突破", - "关键位收敛结构", - ]); - const KEY_BUCKET_WATCH_TYPES = new Set([ - "关键支撑阻力", - "关键阻力位", - "关键支撑位", - "关键位监控", - ]); - - function classifyKeyMonitorBucket(monitorType) { - const t = String(monitorType || "").trim(); - if (!t) return "watch"; - if (KEY_BUCKET_FIB_TYPES.has(t) || /斐波/.test(t)) return "fib"; - if (KEY_BUCKET_BREAKOUT_TYPES.has(t) || /突破/.test(t)) return "breakout"; - if (KEY_BUCKET_WATCH_TYPES.has(t) || /阻力|支撑/.test(t)) return "watch"; - return "watch"; - } - - function countKeyMonitorsByBucket(keys) { - const counts = { breakout: 0, fib: 0, watch: 0 }; - (keys || []).forEach((k) => { - if (!k || typeof k !== "object") return; - const bucket = classifyKeyMonitorBucket(k.monitor_type || k.type); - if (bucket === "breakout") counts.breakout += 1; - else if (bucket === "fib") counts.fib += 1; - else counts.watch += 1; - }); - return counts; - } - - function renderCardStrategyStats(row, hm, flaskOk) { - if (!flaskOk || !hm || typeof hm !== "object") return ""; - const caps = row.capabilities || []; - const chips = []; - if (caps.includes("key")) { - const kc = countKeyMonitorsByBucket(hm.keys || []); - if (kc.breakout > 0) chips.push({ kind: "key-breakout", label: `突破 ${kc.breakout}` }); - if (kc.fib > 0) chips.push({ kind: "key-breakout", label: `斐波 ${kc.fib}` }); - if (kc.watch > 0) chips.push({ kind: "key-watch", label: `监控 ${kc.watch}` }); - } - if (caps.includes("trend")) { - const trendN = Array.isArray(hm.trends) ? hm.trends.length : 0; - if (trendN > 0) chips.push({ kind: "trend", label: `趋势回调 ${trendN}` }); - } - const rollN = Array.isArray(hm.rolls) ? hm.rolls.length : 0; - if (rollN > 0) chips.push({ kind: "roll", label: `顺势加仓 ${rollN}` }); - if (!chips.length) return ""; - return `
${chips - .map( - (c) => - `${esc(c.label)}` - ) - .join("")}
`; - } - - function renderGridPositionsTable(exchangeId, exchangeKey, positions, orders, trends, tickMap) { - const rows = positions - .map((p) => - renderPositionTableRow( - exchangeId, - exchangeKey, - p, - findMonitorOrder(orders, p.symbol, p.side), - findTrendPlan(trends, p.symbol, p.side), - tickMap, - { compact: true } - ) - ) - .join(""); - return `
- ${positionTableHeadHtml(true)} - ${rows} - -
`; - } - - function renderAccountStatRow(row, ag) { - if (!showAccountPnlPref()) return ""; - const upnl = ag.total_unrealized_pnl; - return `
-
资金账户
${fmt(row.funding_usdt, 2)} U
-
交易账户
${fmt(row.trading_usdt, 2)} U
-
浮盈合计
${fmt(upnl, 2)}
-
`; - } - - function renderGridBody(row, ag, pos, hm, flaskOk, keys, orders, trends, rolls, kmap) { - const tickMap = buildPriceTickMap(row); - let inner = renderAccountStatRow(row, ag); - inner += `
交易所持仓 · ${pos.length} 仓
`; - if (pos.length) { - inner += renderGridPositionsTable( - row.id, - row.key || row.id, - pos, - orders, - trends, - tickMap - ); - } else { - inner += '
无持仓
'; - } - inner += renderCardStrategyStats(row, hm, flaskOk); - inner += `
点击标题栏进入全屏 · 委托 / 关键位 / 下单监控 / 趋势回调 / 顺势加仓
`; - return inner; - } - - function renderFullscreenExchange(row) { - const tickMap = buildPriceTickMap(row); - const ag = row.agent || {}; - const pos = Array.isArray(ag.positions) ? ag.positions : []; - const hm = row.hub_monitor || {}; - const flaskOk = row.flask_ok !== false && hm.ok !== false; - const keys = flaskOk ? hm.keys || [] : []; - const orders = flaskOk ? hm.orders || [] : []; - const trends = flaskOk ? hm.trends || [] : []; - const rolls = flaskOk ? hm.rolls || [] : []; - const kmap = {}; - (row.key_prices || []).forEach((k) => { - kmap[k.id] = k; - }); - const flaskOpen = row.flask_url_browser || row.flask_url; - let html = `
-
-

${esc(row.name)}

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

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

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

${hint}

`; - return; - } - const sessionId = activeSession && activeSession.id ? String(activeSession.id) : "local"; - let html = msgs - .map((m, idx) => { - const role = m.role === "user" ? "user" : m.role === "system" ? "system" : "assistant"; - return renderAiChatRow( - role, - m.content || "", - m.level === "warn" ? "ai-bubble-warn" : null, - m.attachments, - { botMode, msgIdx: idx, cacheKey: sessionId + ":" + idx } - ); - }) - .join(""); - if (options.pendingUser) { - html += renderAiChatRow("user", options.pendingUser, null, options.pendingAttachments); - } - if (options.thinking) { - html += renderAiChatRow("assistant", "正在思考…", "ai-bubble-thinking"); - } - box.innerHTML = html; - scrollAiChatToEnd(); - } - - function setAiChatBusy(busy) { - aiChatLoading = !!busy; - const btn = document.getElementById("btn-ai-chat-send"); - const input = document.getElementById("ai-chat-input"); - if (btn) btn.disabled = busy; - if (input) input.disabled = busy; - document.querySelectorAll(".ai-chat-pending-del").forEach((el) => { - el.disabled = busy; - }); - } - - async function loadAiSupervisorSession() { - const r = await apiFetch("/api/ai/supervisor/session"); - const j = await r.json(); - aiSupervisorSessionCache = j.session || null; - if (isSupervisorMode()) { - renderAiChatMessages(aiSupervisorSessionCache); - } - updateAiBotTabs("supervisor"); - return j; - } - - async function switchToSupervisorMode() { - updateAiBotTabs("supervisor"); - if (isMobileAiLayout()) { - localStorage.setItem(AI_MOBILE_TAB_KEY, "supervisor"); - applyAiMobileTab("supervisor"); - } - try { - await loadAiSupervisorSession(); - connectSupervisorStream(); - scrollAiChatToEnd(); - } catch (e) { - showToast(String(e), true); - } - } - - function closeSupervisorStream() { - if (supervisorEventSource) { - supervisorEventSource.close(); - supervisorEventSource = null; - } - if (supervisorReconnectTimer) { - clearTimeout(supervisorReconnectTimer); - supervisorReconnectTimer = null; - } - } - - function connectSupervisorStream() { - closeSupervisorStream(); - if (currentPage() !== "ai" || !isSupervisorMode()) return; - supervisorEventSource = new EventSource("/api/ai/supervisor/stream"); - supervisorEventSource.addEventListener("supervisor", (ev) => { - try { - const st = JSON.parse(ev.data || "{}"); - const ver = Number(st.supervisor_version) || 0; - if (ver !== localSupervisorVersion) { - localSupervisorVersion = ver; - void loadAiSupervisorSession(); - } - } catch (_) {} - }); - supervisorEventSource.onerror = () => { - closeSupervisorStream(); - if (supervisorReconnectTimer) clearTimeout(supervisorReconnectTimer); - supervisorReconnectTimer = setTimeout(() => { - if (currentPage() === "ai" && isSupervisorMode()) connectSupervisorStream(); - }, 8000); - }; - } - - async function loadAiChatSession() { - const r = await apiFetch("/api/ai/chat/session"); - const j = await r.json(); - aiChatSessionCache = j.session || null; - aiChatSessionsCache = j.sessions || []; - renderAiChatMessages(aiChatSessionCache); - renderAiChatHistory(aiChatSessionsCache); - updateAiBotTabs((aiChatSessionCache && aiChatSessionCache.bot_mode) || aiSelectedBotMode); - } - - async function switchAiChatSession(sessionId) { - if (!sessionId || aiChatLoading) return; - try { - const r = await apiFetch("/api/ai/chat/switch", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ session_id: sessionId }), - }); - const j = await r.json(); - if (!r.ok) throw new Error(j.detail || j.msg || "切换失败"); - aiChatSessionCache = j.session || null; - aiChatSessionsCache = j.sessions || []; - renderAiChatMessages(aiChatSessionCache); - renderAiChatHistory(aiChatSessionsCache); - const mode = - (aiChatSessionCache && aiChatSessionCache.bot_mode) === "general" ? "general" : "trading"; - updateAiBotTabs(mode); - if (isMobileAiLayout()) { - localStorage.setItem(AI_MOBILE_TAB_KEY, mode); - applyAiMobileTab(mode); - } - scrollAiChatToEnd(); - } catch (e) { - showToast(String(e), true); - } - } - - async function deleteAiChatSession(sessionId) { - if (!sessionId) return; - if (!confirm("确定删除这条聊天历史?")) return; - try { - const r = await apiFetch(`/api/ai/chat/session/${encodeURIComponent(sessionId)}`, { - method: "DELETE", - }); - const j = await r.json(); - if (!r.ok) throw new Error(j.detail || j.msg || "删除失败"); - aiChatSessionCache = j.session || null; - aiChatSessionsCache = j.sessions || []; - renderAiChatMessages(aiChatSessionCache); - renderAiChatHistory(aiChatSessionsCache); - updateAiBotTabs( - (aiChatSessionCache && aiChatSessionCache.bot_mode) || aiSelectedBotMode || "trading" - ); - showToast("已删除"); - } catch (e) { - showToast(String(e), true); - } - } - - const ARCHIVE_QUOTE_AI_KEY = "hub_archive_quote_ai"; - let archiveQuoteAiPending = false; - - async function consumeArchiveQuoteAiPending() { - if (archiveQuoteAiPending || aiChatLoading) return; - let raw = ""; - try { - raw = sessionStorage.getItem(ARCHIVE_QUOTE_AI_KEY) || ""; - } catch (_) { - return; - } - if (!raw) return; - sessionStorage.removeItem(ARCHIVE_QUOTE_AI_KEY); - let payload; - try { - payload = JSON.parse(raw); - } catch (_) { - return; - } - const content = String((payload && payload.content) || "").trim(); - const quoteDate = String((payload && payload.quote_date) || "").trim(); - if (!content) return; - - const input = document.getElementById("ai-chat-input"); - if (input) input.value = content; - updateAiBotTabs("trading"); - if (isMobileAiLayout()) { - localStorage.setItem(AI_MOBILE_TAB_KEY, "trading"); - applyAiMobileTab("trading"); - } - - archiveQuoteAiPending = true; - setAiChatBusy(true); - renderAiChatMessages(aiChatSessionCache, { - pendingUser: content, - thinking: true, - }); - try { - const r = await apiFetch("/api/ai/chat/archive-quote", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ quote_date: quoteDate, content }), - }); - const j = await r.json(); - if (!r.ok) throw new Error(j.detail || j.msg || "发送失败"); - aiChatSessionCache = j.session || null; - aiChatSessionsCache = j.sessions || aiChatSessionsCache; - renderAiChatMessages(aiChatSessionCache); - renderAiChatHistory(aiChatSessionsCache); - if (input) input.value = ""; - showToast("复盘语录已发送给交易教练"); - } catch (e) { - showToast(String(e), true); - if (input) input.value = content; - try { - await loadAiChatSession(); - } catch (_) { - renderAiChatMessages(aiChatSessionCache); - } - } finally { - archiveQuoteAiPending = false; - setAiChatBusy(false); - } - } - - async function loadAiPage() { - applyAiMobileTab(); - const params = new URLSearchParams(window.location.search || ""); - const modeParam = (params.get("mode") || "").trim().toLowerCase(); - if (modeParam === "supervisor") { - await switchToSupervisorMode(); - } else { - closeSupervisorStream(); - await loadAiChatSession(); - await consumeArchiveQuoteAiPending(); - } - const mobTab = normalizeAiMobileTab(localStorage.getItem(AI_MOBILE_TAB_KEY) || "trading"); - if (isMobileAiLayout() && AI_MOBILE_CHAT_TABS.has(mobTab)) { - const input = document.getElementById("ai-chat-input"); - if (input && !aiChatLoading) { - setTimeout(() => input.focus(), 80); - } - } - } - - async function newAiChat(botMode) { - const mode = normalizeAiBotMode(botMode); - if (mode !== "supervisor") closeSupervisorStream(); - try { - const r = await apiFetch("/api/ai/chat/new", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ bot_mode: mode }), - }); - const j = await r.json(); - aiChatSessionCache = j.session || null; - aiChatSessionsCache = j.sessions || []; - renderAiChatMessages(aiChatSessionCache); - renderAiChatHistory(aiChatSessionsCache); - updateAiBotTabs(mode); - if (isMobileAiLayout()) { - localStorage.setItem(AI_MOBILE_TAB_KEY, mode); - applyAiMobileTab(mode); - } - showToast( - mode === "general" - ? "已开始普通聊天" - : mode === "supervisor" - ? "已打开今日监管" - : "已开始交易教练对话" - ); - } catch (e) { - showToast(String(e), true); - } - } - - async function sendAiChat(ev) { - if (ev) ev.preventDefault(); - if (aiChatLoading) return; - const input = document.getElementById("ai-chat-input"); - const text = (input && input.value || "").trim(); - if (isSupervisorMode()) { - if (!text) return; - const savedText = text; - if (input) input.value = ""; - setAiChatBusy(true); - renderAiChatMessages(aiSupervisorSessionCache, { - pendingUser: text, - thinking: true, - }); - try { - const r = await apiFetch("/api/ai/supervisor/chat/send", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: text }), - }); - const j = await r.json(); - if (!r.ok) throw new Error(j.detail || j.msg || "发送失败"); - aiSupervisorSessionCache = j.session || null; - renderAiChatMessages(aiSupervisorSessionCache); - } catch (e) { - showToast(String(e), true); - if (input && savedText) input.value = savedText; - try { - await loadAiSupervisorSession(); - } catch (_) { - renderAiChatMessages(aiSupervisorSessionCache); - } - } finally { - setAiChatBusy(false); - } - return; - } - const files = aiChatPendingFiles.slice(); - if (!text && !files.length) return; - const pendingAttachments = files.map((f) => ({ - name: f.name, - kind: aiChatFileKind(f), - })); - const savedText = text; - if (input) input.value = ""; - setAiChatBusy(true); - renderAiChatMessages(aiChatSessionCache, { - pendingUser: text || (files.length ? `(上传 ${files.length} 个附件)` : ""), - pendingAttachments, - thinking: true, - }); - try { - const fd = new FormData(); - fd.append("message", text); - files.forEach((f) => fd.append("files", f, f.name)); - const r = await apiFetch("/api/ai/chat/send", { method: "POST", body: fd }); - const j = await r.json(); - if (!r.ok) throw new Error(j.detail || j.msg || "发送失败"); - aiChatSessionCache = j.session || null; - aiChatSessionsCache = j.sessions || aiChatSessionsCache; - renderAiChatMessages(aiChatSessionCache); - renderAiChatHistory(aiChatSessionsCache); - clearAiChatPendingFiles(); - if (j.attachment_warnings && j.attachment_warnings.length) { - showToast(j.attachment_warnings.join(";"), true); - } - } catch (e) { - showToast(String(e), true); - if (input && savedText) input.value = savedText; - try { - await loadAiChatSession(); - } catch (_) { - renderAiChatMessages(aiChatSessionCache); - } - } finally { - setAiChatBusy(false); - } - } - - const aiChatFiles = document.getElementById("ai-chat-files"); - if (aiChatFiles) { - aiChatFiles.addEventListener("change", () => { - const picked = aiChatFiles.files ? Array.from(aiChatFiles.files) : []; - addAiChatPendingFiles(picked); - aiChatFiles.value = ""; - }); - } - const aiChatInput = document.getElementById("ai-chat-input"); - if (aiChatInput) { - aiChatInput.addEventListener("paste", handleAiChatPaste); - } - const aiChatPending = document.getElementById("ai-chat-pending"); - if (aiChatPending) { - aiChatPending.addEventListener("click", (ev) => { - const btn = ev.target.closest("[data-pending-del]"); - if (!btn || aiChatLoading) return; - ev.preventDefault(); - const idx = Number(btn.getAttribute("data-pending-del")); - if (!Number.isNaN(idx)) removeAiChatPendingFile(idx); - }); - } - - const aiChatNewBtn = document.getElementById("btn-ai-chat-new"); - if (aiChatNewBtn) aiChatNewBtn.onclick = () => newAiChat(aiSelectedBotMode); - const aiChatForm = document.getElementById("ai-chat-form"); - if (aiChatForm) aiChatForm.addEventListener("submit", sendAiChat); - - function initAiChatInteractions() { - const hist = document.getElementById("ai-chat-history-list"); - if (hist && !hist._aiBound) { - hist._aiBound = true; - hist.addEventListener("click", (ev) => { - const delBtn = ev.target.closest(".ai-chat-history-del"); - if (delBtn) { - ev.stopPropagation(); - const sid = delBtn.getAttribute("data-delete-session"); - if (sid) deleteAiChatSession(sid); - return; - } - const item = ev.target.closest(".ai-chat-history-item"); - if (!item) return; - const sid = item.getAttribute("data-session-id"); - if (sid) switchAiChatSession(sid); - }); - } - const box = document.getElementById("ai-chat-messages"); - if (box && !box._aiCopyBound) { - box._aiCopyBound = true; - box.addEventListener("click", async (ev) => { - const btn = ev.target.closest(".ai-msg-copy-btn"); - if (!btn) return; - const idx = Number(btn.getAttribute("data-msg-idx")); - const msgs = (aiChatSessionCache && aiChatSessionCache.messages) || []; - const text = msgs[idx] && msgs[idx].content ? String(msgs[idx].content) : ""; - if (!text) return; - try { - await navigator.clipboard.writeText(text); - showToast("已复制"); - } catch (_) { - showToast("复制失败", true); - } - }); - } - document.querySelectorAll(".ai-bot-tab").forEach((btn) => { - if (btn._aiBotBound) return; - btn._aiBotBound = true; - btn.addEventListener("click", () => { - const mode = normalizeAiBotMode(btn.getAttribute("data-bot") || "trading"); - if (mode === "supervisor") { - void switchToSupervisorMode(); - return; - } - closeSupervisorStream(); - newAiChat(mode); - }); - }); - } - initAiChatInteractions(); - - initTpslModal(); - initInstanceFrame(); - initFullscreen(); - initMobileLayout(); - if (globalThis.HubTheme && typeof HubTheme.initToggleUI === "function") { - HubTheme.initToggleUI(); - } - - function initShellNav() { - document.querySelectorAll(".top-nav a[href^='/']").forEach((a) => { - a.addEventListener("click", (ev) => { - const href = a.getAttribute("href"); - if (!href || ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) return; - ev.preventDefault(); - const path = href.split("?")[0]; - if (path === window.location.pathname) { - setActiveNav(); - return; - } - history.pushState({}, "", href); - setActiveNav(); - }); - }); - window.addEventListener("popstate", setActiveNav); - } - - window.hubNavigateTo = function hubNavigateTo(path) { - const href = String(path || "/").split("?")[0] || "/"; - if (href === window.location.pathname) { - setActiveNav(); - return; - } - history.pushState({}, "", href); - setActiveNav(); - }; - - window.hubOpenMonitorExpand = function hubOpenMonitorExpand(exId) { - const id = String(exId || "").trim(); - if (!id) return; - expandedExchangeId = id; - sessionStorage.setItem("hub_expanded_ex", id); - if (currentPage() !== "monitor") { - history.pushState({}, "", "/monitor"); - setActiveNav(); - } - if (lastMonitorRows.length) { - openExchangeFullscreen(id); - } else { - void fetchMonitorBoardSnapshot({ showLoading: true }); - } - }; - - initAuth().then((ok) => { - if (!ok) return; - initShellNav(); - loadSettings() - .then((data) => { - syncDisplayPrefsUI(data); - }) - .catch(() => {}) - .finally(() => { - setActiveNav(); - }); - }); - if (window.AccountRiskBadge) AccountRiskBadge.startTicker(); -})(); +(function () { + const toast = document.getElementById("toast"); + let settingsCache = null; + let authState = { required: false, logged_in: true }; + + function displayPref(key, defaultOn) { + const d = settingsCache && settingsCache.display; + if (!d || d[key] === undefined) return defaultOn !== false; + return !!d[key]; + } + + function showAccountPnlPref() { + return displayPref("show_account_pnl", true); + } + + function showNavFundsPref() { + return displayPref("show_nav_funds", true); + } + + function showNavDashboardPref() { + return displayPref("show_nav_dashboard", true); + } + + function showNavPlanPref() { + return displayPref("show_nav_plan", true); + } + + function showNavArchivePref() { + return displayPref("show_nav_archive", true); + } + + function showNavAiPref() { + return displayPref("show_nav_ai", true); + } + + function showNavCalculatorPref() { + return displayPref("show_nav_calculator", true); + } + + function syncNavVisibility(data) { + const d = (data && data.display) || {}; + const navFunds = document.getElementById("nav-funds"); + const navDash = document.getElementById("nav-dashboard"); + const navPlan = document.getElementById("nav-plan"); + const navArchive = document.getElementById("nav-archive"); + const navAi = document.getElementById("nav-ai"); + const navCalc = document.getElementById("nav-calculator"); + if (navFunds) navFunds.classList.toggle("nav-hidden", d.show_nav_funds === false); + if (navDash) navDash.classList.toggle("nav-hidden", d.show_nav_dashboard === false); + if (navPlan) navPlan.classList.toggle("nav-hidden", d.show_nav_plan === false); + if (navArchive) navArchive.classList.toggle("nav-hidden", d.show_nav_archive === false); + if (navAi) navAi.classList.toggle("nav-hidden", d.show_nav_ai === false); + if (navCalc) navCalc.classList.toggle("nav-hidden", d.show_nav_calculator === false); + } + + function pageNavAllowed(page) { + if (page === "funds") return showNavFundsPref(); + if (page === "dashboard") return showNavDashboardPref(); + if (page === "plan") return showNavPlanPref(); + if (page === "archive") return showNavArchivePref(); + if (page === "ai") return showNavAiPref(); + if (page === "calculator") return showNavCalculatorPref(); + return true; + } + + function syncDisplayPrefsUI(data) { + const d = (data && data.display) || {}; + const pnlCb = document.getElementById("pref-show-account-pnl"); + const fundsCb = document.getElementById("pref-show-nav-funds"); + const dashCb = document.getElementById("pref-show-nav-dashboard"); + const planCb = document.getElementById("pref-show-nav-plan"); + const archiveCb = document.getElementById("pref-show-nav-archive"); + const aiCb = document.getElementById("pref-show-nav-ai"); + const calcCb = document.getElementById("pref-show-nav-calculator"); + if (pnlCb) pnlCb.checked = d.show_account_pnl !== false; + if (fundsCb) fundsCb.checked = d.show_nav_funds !== false; + if (dashCb) dashCb.checked = d.show_nav_dashboard !== false; + if (planCb) planCb.checked = d.show_nav_plan !== false; + if (archiveCb) archiveCb.checked = d.show_nav_archive !== false; + if (aiCb) aiCb.checked = d.show_nav_ai !== false; + if (calcCb) calcCb.checked = d.show_nav_calculator !== false; + syncNavVisibility(data); + } + + function syncSupervisorSettingsUI(data) { + const s = (data && data.supervisor) || {}; + const enabled = document.getElementById("supervisor-enabled"); + const prog = document.getElementById("supervisor-wechat-program"); + const webhook = document.getElementById("supervisor-wechat-webhook"); + const link = document.getElementById("supervisor-wechat-link"); + const prefix = document.getElementById("supervisor-wechat-prefix"); + const daily = document.getElementById("supervisor-daily-warn"); + const interval = document.getElementById("supervisor-interval-warn"); + const freq30 = document.getElementById("supervisor-freq-30m"); + const reopen = document.getElementById("supervisor-reopen-min"); + if (enabled) enabled.checked = s.enabled !== false; + if (prog) prog.checked = s.wechat_on_program_tp_sl !== false; + if (webhook) webhook.value = s.wechat_webhook || ""; + if (link) link.value = s.wechat_link_base || ""; + if (prefix) prefix.value = s.wechat_prefix || "【交易监管】"; + if (daily) daily.value = Number(s.manual_close_daily_warn) || 2; + if (interval) interval.value = Number(s.interval_warn_minutes) || 15; + if (freq30) freq30.value = Number(s.freq_30m_count) || 2; + if (reopen) reopen.value = Number(s.reopen_after_close_minutes) || 30; + } + + function positionTableHeadHtml(compact) { + const pnlTh = showAccountPnlPref() ? "浮盈" : ""; + const cls = compact ? " data-table data-table-positions" : ""; + return `${pnlTh}`; + } + let tpslPending = null; + let lastMonitorRows = []; + let expandedExchangeId = sessionStorage.getItem("hub_expanded_ex") || ""; + const HUB_MONITOR_BOARD_CACHE_KEY = "hub_monitor_board_v1"; + const HUB_MONITOR_CACHE_MAX_AGE_MS = 6 * 60 * 60 * 1000; + const MONITOR_BOARD_SNAPSHOT_URL = "/api/monitor/board/snapshot"; + const HUB_MONITOR_SNAPSHOT_TIMEOUT_MS = 15000; + /** 关注:浮亏超过交易账户余额的比例(10%) */ + const HUB_ALERT_FLOAT_LOSS_RATIO = 0.1; + let lastMonitorBoardUpdatedAt = ""; + let localBoardVersion = 0; + let monitorBoardInFlight = false; + let monitorBoardFetchPending = false; + let monitorBoardSlowHintTimer = null; + let boardEventSource = null; + let sseReconnectTimer = null; + let hostStatusTimer = null; + const HOST_STATUS_POLL_MS = 5000; + const HOST_STATUS_OPEN_KEY = "hub-host-status-open"; + const HOST_RESOURCE_ALERT_THRESHOLD = 85; + const hostResourceAlertLatch = { cpu: false, mem: false }; + + function loadBoolPref(key, defaultValue) { + try { + const raw = localStorage.getItem(key); + if (raw === "1" || raw === "true") return true; + if (raw === "0" || raw === "false") return false; + } catch (_) {} + return !!defaultValue; + } + + function saveBoolPref(key, on) { + try { + localStorage.setItem(key, on ? "1" : "0"); + } catch (_) {} + } + + function fmtHostBytes(n) { + const v = Number(n); + if (!Number.isFinite(v)) return "—"; + const abs = Math.abs(v); + if (abs >= 1e12) return (v / 1e12).toFixed(2) + " TB"; + if (abs >= 1e9) return (v / 1e9).toFixed(2) + " GB"; + if (abs >= 1e6) return (v / 1e6).toFixed(2) + " MB"; + if (abs >= 1e3) return (v / 1e3).toFixed(1) + " KB"; + return v.toFixed(0) + " B"; + } + + function fmtHostUptime(sec) { + const s = Math.max(0, Number(sec) || 0); + const d = Math.floor(s / 86400); + const h = Math.floor((s % 86400) / 3600); + const m = Math.floor((s % 3600) / 60); + if (d > 0) return d + "天" + h + "时"; + if (h > 0) return h + "时" + m + "分"; + return m + "分"; + } + + function hostMetricLevel(percent) { + const p = Number(percent); + if (!Number.isFinite(p)) return "ok"; + if (p >= HOST_RESOURCE_ALERT_THRESHOLD) return "bad"; + return "ok"; + } + + function hostOverallLevel(cpu, mem, disk) { + const vals = [cpu && cpu.percent, mem && mem.percent, disk && disk.percent]; + for (let i = 0; i < vals.length; i++) { + const p = Number(vals[i]); + if (Number.isFinite(p) && p >= HOST_RESOURCE_ALERT_THRESHOLD) return "bad"; + } + return "ok"; + } + + function setHostMetricBar(fillEl, percent) { + if (!fillEl) return; + const p = Math.max(0, Math.min(100, Number(percent) || 0)); + const level = hostMetricLevel(p); + fillEl.style.width = p + "%"; + fillEl.classList.remove("warn", "bad", "ok"); + fillEl.classList.add(level === "bad" ? "bad" : "ok"); + } + + function checkHostResourceAlert(cpu, mem) { + const msgs = []; + const cpuP = Number(cpu && cpu.percent); + if (Number.isFinite(cpuP) && cpuP >= HOST_RESOURCE_ALERT_THRESHOLD) { + if (!hostResourceAlertLatch.cpu) { + msgs.push("CPU 使用率 " + cpuP + "%"); + hostResourceAlertLatch.cpu = true; + } + } else { + hostResourceAlertLatch.cpu = false; + } + const memP = Number(mem && mem.percent); + if (Number.isFinite(memP) && memP >= HOST_RESOURCE_ALERT_THRESHOLD) { + if (!hostResourceAlertLatch.mem) { + msgs.push("内存使用率 " + memP + "%"); + hostResourceAlertLatch.mem = true; + } + } else { + hostResourceAlertLatch.mem = false; + } + if (msgs.length) { + window.alert( + "服务器资源告警\n\n" + msgs.join("\n") + "\n\n请及时关注中控服务器负载。" + ); + } + } + + function hostMetricSummaryHtml(label, percent) { + const p = Number(percent); + if (!Number.isFinite(p)) { + return esc(label) + " —"; + } + const tone = hostMetricLevel(p); + return ( + esc(label) + + ' ' + + p + + "%" + ); + } + + function renderHostStatusSummary(data, el) { + if (!el) return; + if (!data || !data.ok) { + el.className = "host-status-summary-text bad"; + el.textContent = (data && data.msg) || "状态不可用"; + return; + } + const cpu = data.cpu || {}; + const mem = data.memory || {}; + const disk = data.disk || {}; + const parts = []; + const host = String(data.hostname || "").trim(); + if (host) { + parts.push('' + esc(host) + ""); + } + if (cpu.percent != null) parts.push(hostMetricSummaryHtml("CPU", cpu.percent)); + if (mem.percent != null) parts.push(hostMetricSummaryHtml("内存", mem.percent)); + if (disk.percent != null) parts.push(hostMetricSummaryHtml("硬盘", disk.percent)); + el.className = "host-status-summary-text"; + el.innerHTML = parts.length + ? parts.join(' · ') + : "—"; + } + + function setHostMetricVal(el, percent) { + if (!el) return; + const p = Number(percent); + el.classList.remove("ok", "bad"); + if (!Number.isFinite(p)) { + el.textContent = "—"; + return; + } + el.textContent = p + "%"; + el.classList.add(hostMetricLevel(p)); + } + + let hostStatusPanelInited = false; + + function initHostStatusPanel() { + const panel = document.getElementById("host-status-panel"); + if (!panel) return; + panel.classList.remove("hidden"); + if (!hostStatusPanelInited) { + panel.open = loadBoolPref(HOST_STATUS_OPEN_KEY, false); + panel.addEventListener("toggle", function () { + saveBoolPref(HOST_STATUS_OPEN_KEY, !!panel.open); + }); + hostStatusPanelInited = true; + } + } + + function renderHostStatusBar(data) { + const panel = document.getElementById("host-status-panel"); + const summaryText = document.getElementById("host-status-summary-text"); + const bar = document.getElementById("host-status-bar"); + if (!panel || !bar) return; + const dot = document.getElementById("host-status-dot"); + const name = document.getElementById("host-status-name"); + const uptime = document.getElementById("host-status-uptime"); + const updated = document.getElementById("host-status-updated"); + const cpuVal = document.getElementById("host-cpu-val"); + const cpuSub = document.getElementById("host-cpu-sub"); + const memVal = document.getElementById("host-mem-val"); + const memSub = document.getElementById("host-mem-sub"); + const diskVal = document.getElementById("host-disk-val"); + const diskSub = document.getElementById("host-disk-sub"); + const netUp = document.getElementById("host-net-up"); + const netDown = document.getElementById("host-net-down"); + panel.classList.remove("hidden"); + renderHostStatusSummary(data, summaryText); + if (!data || !data.ok) { + if (dot) dot.className = "host-status-dot bad"; + if (name) { + name.textContent = "服务器"; + name.title = ""; + } + if (uptime) uptime.textContent = (data && data.msg) || "状态不可用"; + if (updated) updated.textContent = ""; + if (cpuVal) cpuVal.textContent = "—"; + if (cpuSub) cpuSub.textContent = ""; + if (memVal) memVal.textContent = "—"; + if (memSub) memSub.textContent = ""; + if (diskVal) diskVal.textContent = "—"; + if (diskSub) diskSub.textContent = ""; + if (netUp) netUp.textContent = "↑ —"; + if (netDown) netDown.textContent = "↓ —"; + return; + } + const cpu = data.cpu || {}; + const mem = data.memory || {}; + const disk = data.disk || {}; + const net = data.network || {}; + checkHostResourceAlert(cpu, mem); + const overall = hostOverallLevel(cpu, mem, disk); + if (dot) dot.className = "host-status-dot " + overall; + const hostname = data.hostname || "服务器"; + if (name) { + name.textContent = hostname; + name.title = hostname; + } + if (uptime) uptime.textContent = "运行 " + fmtHostUptime(data.uptime_sec); + if (updated) updated.textContent = data.updated_at ? "更新 " + data.updated_at : ""; + setHostMetricBar(document.getElementById("host-cpu-fill"), cpu.percent); + setHostMetricBar(document.getElementById("host-mem-fill"), mem.percent); + setHostMetricBar(document.getElementById("host-disk-fill"), disk.percent); + setHostMetricVal(cpuVal, cpu.percent); + setHostMetricVal(memVal, mem.percent); + setHostMetricVal(diskVal, disk.percent); + if (cpuSub) cpuSub.textContent = cpu.count ? cpu.count + " 核" : ""; + if (memSub) { + memSub.textContent = + fmtHostBytes(mem.used_bytes) + " / " + fmtHostBytes(mem.total_bytes); + } + if (diskSub) { + diskSub.textContent = + fmtHostBytes(disk.used_bytes) + " / " + fmtHostBytes(disk.total_bytes); + } + if (netUp) netUp.textContent = "↑ " + fmtHostBytes(net.sent_rate_bps) + "/s"; + if (netDown) netDown.textContent = "↓ " + fmtHostBytes(net.recv_rate_bps) + "/s"; + } + + async function fetchHostStatus() { + if (currentPage() !== "monitor") return; + try { + const r = await apiFetch("/api/host/status", { credentials: "same-origin" }); + const data = await r.json(); + renderHostStatusBar(data); + } catch (err) { + renderHostStatusBar({ ok: false, msg: String(err && err.message ? err.message : err) }); + } + } + + function stopHostStatusPoll() { + if (hostStatusTimer) { + clearInterval(hostStatusTimer); + hostStatusTimer = null; + } + } + + function startHostStatusPoll() { + stopHostStatusPoll(); + initHostStatusPanel(); + void fetchHostStatus(); + hostStatusTimer = setInterval(fetchHostStatus, HOST_STATUS_POLL_MS); + } + + async function apiFetch(url, opts) { + const r = await fetch(url, opts); + if (r.status === 401) { + const next = encodeURIComponent(location.pathname + location.search); + location.href = "/login?next=" + next; + throw new Error("未登录"); + } + return r; + } + + let instanceFrameUrl = ""; + /** @type {{ exchangeId: string, nextPath: string, title: string } | null} */ + let instanceFrameCtx = null; + + function isHubEmbedded() { + try { + return window.self !== window.top; + } catch (_) { + return true; + } + } + + /** 在 LocalNav 等父页 iframe 内:直接替换本 iframe 地址,避免 postMessage / 三层嵌套 */ + function openInstanceInParentFrame(url) { + try { + window.location.assign(url); + return true; + } catch (_) { + return false; + } + } + + async function fetchInstanceOpenUrl(exchangeId, nextPath, opts) { + const options = opts || {}; + const next = nextPath || "/"; + const q = new URLSearchParams({ exchange_id: String(exchangeId), next }); + if (options.embed) q.set("embed", "1"); + if (options.embed && globalThis.HubTheme && typeof HubTheme.get === "function") { + q.set("hub_theme", HubTheme.get()); + } + const r = await apiFetch("/api/instance/open-url?" + q.toString()); + const j = await r.json(); + if (!j.ok || !j.url) { + throw new Error(j.detail || "无法生成打开链接"); + } + return j.url; + } + + /** @type {number | null} */ + let instanceFrameNavLoadingTimer = null; + + function setInstanceFrameNavLoading(loading) { + const shell = document.getElementById("instance-frame-shell"); + if (!shell) return; + if (instanceFrameNavLoadingTimer != null) { + clearTimeout(instanceFrameNavLoadingTimer); + instanceFrameNavLoadingTimer = null; + } + if (loading) { + instanceFrameNavLoadingTimer = window.setTimeout(() => { + shell.classList.add("is-instance-nav-loading"); + instanceFrameNavLoadingTimer = null; + }, 140); + return; + } + shell.classList.remove("is-instance-nav-loading"); + } + + async function openInstance(exchangeId, nextPath, opts) { + const options = opts || {}; + const newTab = !!options.newTab; + const next = nextPath || "/"; + try { + const embedded = isHubEmbedded(); + const url = await fetchInstanceOpenUrl(exchangeId, next, { + embed: !newTab, + }); + if (newTab) { + window.open(url, "_blank", "noopener"); + return; + } + const row = lastMonitorRows.find((x) => String(x.id) === String(exchangeId)); + const title = row ? row.name : exchangeId; + instanceFrameCtx = { exchangeId: String(exchangeId), nextPath: next, title }; + if (embedded) { + try { + window.parent.postMessage( + { + type: "hub:open-instance-nav", + exchangeId: String(exchangeId), + nextPath: next, + title, + }, + "*" + ); + } catch (_) {} + if (openInstanceInParentFrame(url)) return; + } + openInstanceFrame(url, title); + } catch (e) { + showToast(String(e), true); + } + } + + async function refreshInstanceFrame() { + if (!instanceFrameCtx) { + if (instanceFrameUrl) { + const frame = document.getElementById("instance-frame"); + if (frame) frame.src = instanceFrameUrl; + } + return; + } + try { + const url = await fetchInstanceOpenUrl( + instanceFrameCtx.exchangeId, + instanceFrameCtx.nextPath, + { embed: true } + ); + instanceFrameUrl = url; + const frame = document.getElementById("instance-frame"); + if (frame) { + setInstanceFrameNavLoading(true); + frame.src = url; + } + } catch (e) { + showToast(String(e), true); + } + } + + function openInstanceFrame(url, title) { + const shell = document.getElementById("instance-frame-shell"); + const frame = document.getElementById("instance-frame"); + const titleEl = document.getElementById("instance-frame-title"); + if (!shell || !frame) { + window.open(url, "_blank", "noopener"); + return; + } + closeExchangeFullscreen(); + instanceFrameUrl = url; + if (titleEl) titleEl.textContent = title || "实例"; + setInstanceFrameNavLoading(true); + frame.src = url; + shell.classList.remove("hidden"); + shell.setAttribute("aria-hidden", "false"); + document.body.classList.add("hub-instance-frame-open"); + if (frame.dataset.themeSyncBound !== "1") { + frame.dataset.themeSyncBound = "1"; + frame.addEventListener("load", function syncInstanceFrameTheme() { + requestAnimationFrame(() => { + try { + if (globalThis.HubTheme && typeof HubTheme.get === "function" && frame.contentWindow) { + frame.contentWindow.postMessage( + { type: "hub-theme-sync", theme: HubTheme.get() }, + "*" + ); + } + } catch (_) {} + }); + }); + } + } + + function closeInstanceFrame() { + const shell = document.getElementById("instance-frame-shell"); + const frame = document.getElementById("instance-frame"); + instanceFrameUrl = ""; + instanceFrameCtx = null; + if (frame) frame.src = "about:blank"; + if (shell) { + shell.classList.add("hidden"); + shell.setAttribute("aria-hidden", "true"); + shell.classList.remove("is-instance-nav-loading"); + } + document.body.classList.remove("hub-instance-frame-open"); + } + + /** @deprecated use openInstance */ + async function openInstanceInBrowser(exchangeId, nextPath) { + return openInstance(exchangeId, nextPath, { newTab: false }); + } + + async function initAuth() { + try { + const r = await fetch("/api/auth/status"); + authState = await r.json(); + const btn = document.getElementById("btn-logout"); + if (btn) btn.style.display = authState.required ? "" : "none"; + if (authState.required && !authState.logged_in) { + location.href = + "/login?next=" + encodeURIComponent(location.pathname + location.search); + return false; + } + return true; + } catch (_) { + return true; + } + } + + function showToast(msg, isErr) { + toast.textContent = msg; + toast.style.borderColor = isErr ? "var(--red)" : "var(--border)"; + toast.classList.add("show"); + clearTimeout(showToast._t); + showToast._t = setTimeout(() => toast.classList.remove("show"), 7000); + } + + function esc(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function formatRiskStatusBadge(riskStatus) { + if (!riskStatus || typeof riskStatus !== "object") return ""; + if (window.AccountRiskBadge) return AccountRiskBadge.formatBadgeHtml(riskStatus, esc); + const st = riskStatus.status || "normal"; + const label = esc(riskStatus.status_label || "正常"); + const title = esc(riskStatus.reason || ""); + return `${label}`; + } + + function fmt(n, d) { + if (n === null || n === undefined || Number.isNaN(Number(n))) return "—"; + return Number(n).toLocaleString(undefined, { maximumFractionDigits: d }); + } + + /** 交易所持仓开仓价(三所子代理 entry_price) */ + function positionEntryPrice(pos) { + if (!pos) return null; + const n = Number(pos.entry_price); + if (!Number.isFinite(n) || n <= 0) return null; + return n; + } + + function symbolPriceKey(sym) { + return (sym || "").trim().toUpperCase(); + } + + function buildPriceTickMap(row) { + const map = Object.create(null); + const put = (sym, tick) => { + const k = symbolPriceKey(sym); + if (!k || tick == null || !Number.isFinite(Number(tick))) return; + if (map[k] == null) map[k] = Number(tick); + }; + ((row && row.agent && row.agent.positions) || []).forEach((p) => put(p.symbol, p.price_tick)); + const hm = (row && row.hub_monitor) || {}; + (hm.trends || []).forEach((t) => put(t.exchange_symbol || t.symbol, t.price_tick)); + (hm.orders || []).forEach((o) => put(o.exchange_symbol || o.symbol, o.price_tick)); + return map; + } + + function lookupPriceTick(symbol, tickMap) { + if (!tickMap || !symbol) return null; + const k = symbolPriceKey(symbol); + if (tickMap[k] != null) return tickMap[k]; + const base = normSym(symbol); + if (base && tickMap[base] != null) return tickMap[base]; + return null; + } + + function decimalsFromTick(tick) { + if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) return null; + const t = Number(tick); + if (t >= 1) return 0; + const s = t.toFixed(12).replace(/0+$/, ""); + const frac = s.split(".")[1]; + return frac ? Math.min(12, frac.length) : 0; + } + + function defaultPriceDecimals(value) { + const n = Number(value); + if (!Number.isFinite(n)) return 4; + const av = Math.abs(n); + if (av >= 10000) return 2; + if (av >= 100) return 3; + if (av >= 1) return 4; + if (av >= 0.01) return 6; + return 8; + } + + /** 按交易所 tick(子代理/Flask 下发)格式化价格 */ + function fmtSymbolPrice(value, symbol, tickMap, displayFallback) { + if (displayFallback != null && displayFallback !== "") return String(displayFallback); + if (value == null || value === "") return "—"; + const n = Number(value); + if (!Number.isFinite(n)) return "—"; + const tick = lookupPriceTick(symbol, tickMap); + const d = decimalsFromTick(tick); + return fmt(n, d != null ? d : defaultPriceDecimals(n)); + } + + function fmtEntryPrice(pos, tickMap) { + if (pos && pos.entry_price_fmt) return String(pos.entry_price_fmt); + return fmtSymbolPrice(positionEntryPrice(pos), pos && pos.symbol, tickMap); + } + + function positionMarkPrice(pos) { + if (!pos) return null; + const n = Number(pos.mark_price); + if (!Number.isFinite(n) || n <= 0) return null; + return n; + } + + function fmtMarkPrice(pos, tickMap) { + if (pos && pos.mark_price_fmt) return String(pos.mark_price_fmt); + return fmtSymbolPrice(positionMarkPrice(pos), pos && pos.symbol, tickMap); + } + + function resolveTrendPositionRatioPct(trendPlan) { + const t = trendPlan || {}; + if (t.position_ratio_pct != null && t.position_ratio_pct !== "") { + const n = Number(t.position_ratio_pct); + if (Number.isFinite(n)) return n; + } + const snap = Number(t.snapshot_available_usdt); + const margin = Number(t.plan_margin_capital); + if (Number.isFinite(snap) && snap > 0 && Number.isFinite(margin) && margin > 0) { + return Math.round((margin / snap) * 10000) / 100; + } + return null; + } + + function resolveTrendSizingFooter(mo, trendPlan, isTrend, pos) { + const m = mo || {}; + const p = pos || {}; + if (!isTrend || !trendPlan || !trendPlan.id) { + return { + margin: + m.exchange_initial_margin ?? + p.exchange_initial_margin ?? + m.plan_margin ?? + p.plan_margin ?? + null, + leverage: m.leverage, + planBase: m.margin_capital, + positionRatio: m.position_ratio, + }; + } + const base = + trendPlan.snapshot_available_usdt != null && trendPlan.snapshot_available_usdt !== "" + ? trendPlan.snapshot_available_usdt + : trendPlan.plan_margin_capital; + return { + margin: m.exchange_initial_margin ?? trendPlan.plan_margin_capital ?? null, + leverage: trendPlan.leverage, + planBase: base, + positionRatio: resolveTrendPositionRatioPct(trendPlan), + }; + } + + function resolvePositionOpenMeta(mo, trendPlan, isTrend) { + const useTrend = isTrend && trendPlan && trendPlan.id; + const src = useTrend ? trendPlan : mo || {}; + let ms = Number(src.opened_at_ms); + if (!Number.isFinite(ms) || ms <= 0) { + const s = String(src.opened_at || "").trim(); + if (s) { + const parsed = Date.parse(s.replace(" ", "T")); + ms = Number.isFinite(parsed) ? parsed : null; + } else { + ms = null; + } + } else { + ms = Math.round(ms); + } + let display = "—"; + if (src.opened_at) { + display = String(src.opened_at).replace("T", " ").slice(0, 16); + } else if (ms) { + display = new Date(ms).toISOString().slice(0, 16).replace("T", " "); + } + return { openedAtMs: ms, openedAtDisplay: display }; + } + + function formatLiveHoldDuration(openedMs, nowMs) { + if (openedMs == null || !Number.isFinite(Number(openedMs))) return "—"; + const ms = Number(openedMs); + const now = nowMs != null ? nowMs : Date.now(); + let sec = Math.floor((now - ms) / 1000); + if (sec < 0) sec = 0; + if (sec <= 0) return "0分钟"; + const d = Math.floor(sec / 86400); + sec %= 86400; + const h = Math.floor(sec / 3600); + sec %= 3600; + const m = Math.floor(sec / 60); + const parts = []; + if (d) parts.push(`${d}天`); + if (h) parts.push(`${h}小时`); + if (m || !parts.length) parts.push(`${m}分钟`); + return parts.join(""); + } + + let hubHoldDurationTimer = null; + + function tickHubHoldDurations() { + const now = Date.now(); + document.querySelectorAll(".pos-hold-duration[data-opened-ms]").forEach((el) => { + const ms = Number(el.getAttribute("data-opened-ms")); + if (!Number.isFinite(ms) || ms <= 0) return; + el.textContent = formatLiveHoldDuration(ms, now); + }); + } + + function ensureHubHoldDurationTimer() { + tickHubHoldDurations(); + if (hubHoldDurationTimer) return; + hubHoldDurationTimer = setInterval(tickHubHoldDurations, 1000); + } + + function estimateLatestRiskUsdt(side, entry, sl, pos, mo) { + const e = Number(entry); + const s = Number(sl); + if (!Number.isFinite(e) || !Number.isFinite(s) || e <= 0) return null; + const sd = (side || "long").toLowerCase(); + const rf = sd === "short" ? (s - e) / e : (e - s) / e; + if (!Number.isFinite(rf)) return null; + if (rf <= 0) return 0; + const m = mo || {}; + const p = pos || {}; + let notional = Number(p.notional_usdt); + if (!Number.isFinite(notional) || notional <= 0) { + notional = Number(m.exchange_notional); + } + if (!Number.isFinite(notional) || notional <= 0) { + const mc = Number(m.margin_capital); + const lev = Number(m.leverage); + if (Number.isFinite(mc) && mc > 0 && Number.isFinite(lev) && lev > 0) { + notional = mc * lev; + } + } + if (!Number.isFinite(notional) || notional <= 0) { + const c = Math.abs(Number(p.contracts)); + const cs = Number(p.contract_size); + const mult = Number.isFinite(cs) && cs > 0 ? cs : 1; + const px = Number(p.mark_price); + const mark = Number.isFinite(px) && px > 0 ? px : e; + if (Number.isFinite(c) && c > 0) notional = c * mult * mark; + } + if (!Number.isFinite(notional) || notional <= 0) return null; + return Math.round(notional * rf * 100) / 100; + } + + function formatLatestRiskMeta(mo, trendPlan, pos, tpsl) { + const m = mo || {}; + const t = trendPlan || {}; + let v = + m.latest_risk_amount != null && m.latest_risk_amount !== "" + ? Number(m.latest_risk_amount) + : pos && pos.latest_risk_amount != null && pos.latest_risk_amount !== "" + ? Number(pos.latest_risk_amount) + : t.latest_risk_amount != null && t.latest_risk_amount !== "" + ? Number(t.latest_risk_amount) + : null; + if ((v == null || !Number.isFinite(v)) && tpsl && pos) { + v = estimateLatestRiskUsdt( + pos.side || m.direction, + tpsl.entry, + tpsl.sl, + pos, + m + ); + } + if (v != null && Number.isFinite(v)) { + return `最新风险: ${fmt(v, 2)}U`; + } + return null; + } + + function formatMonitorRiskMeta(mo, trendPlan) { + const m = mo || {}; + const t = trendPlan || {}; + const amt = + m.risk_amount != null && m.risk_amount !== "" + ? Number(m.risk_amount) + : t.risk_amount != null && t.risk_amount !== "" + ? Number(t.risk_amount) + : null; + const pctRaw = + m.risk_percent != null && m.risk_percent !== "" + ? m.risk_percent + : t.risk_percent != null && t.risk_percent !== "" + ? t.risk_percent + : null; + if (pctRaw == null || pctRaw === "") { + if (amt != null && Number.isFinite(amt)) { + return `风险: ${fmt(amt, 2)}U`; + } + return null; + } + const pct = esc(pctRaw); + if (amt != null && Number.isFinite(amt)) { + return `风险: ${pct}%≈${fmt(amt, 2)}U`; + } + return `风险: ${pct}%`; + } + + function resolveTrendMarkPrice(pos, trendPlan, symbol, tickMap) { + const fromPos = fmtMarkPrice(pos, tickMap); + if (fromPos && fromPos !== "—") return fromPos; + const t = trendPlan || {}; + const sym = symbol || (pos && pos.symbol) || t.exchange_symbol || t.symbol || ""; + if (t.floating_mark != null && t.floating_mark !== "") { + return fmtSymbolPrice(t.floating_mark, sym, tickMap); + } + if (t.last_mark_price != null && t.last_mark_price !== "") { + return fmtSymbolPrice(t.last_mark_price, sym, tickMap); + } + return "—"; + } + + function estimateLinearSwapUpnl(side, entry, mark, contracts, contractSize) { + const e = Number(entry); + const m = Number(mark); + const c = Math.abs(Number(contracts)); + let mult = Number(contractSize); + if (!Number.isFinite(mult) || mult <= 0) mult = 1; + if (!Number.isFinite(e) || !Number.isFinite(m) || !Number.isFinite(c) || c <= 0) { + return null; + } + const diff = + (side || "long").toLowerCase() === "long" ? m - e : e - m; + return Math.round(diff * c * mult * 100) / 100; + } + + /** 展示浮盈:子代理 unrealized_pnl;与 entry/mark/张数 推算偏差 >20% 时用推算值 */ + function resolvePositionUpnlUsdt(pos, trendPlan, markOverride) { + const p = pos || {}; + const t = trendPlan || {}; + let exchange = + p.unrealized_pnl != null && p.unrealized_pnl !== "" + ? Number(p.unrealized_pnl) + : null; + if (exchange != null && !Number.isFinite(exchange)) exchange = null; + const entry = + t.avg_entry_price != null && t.avg_entry_price !== "" + ? Number(t.avg_entry_price) + : p.entry_price != null && p.entry_price !== "" + ? Number(p.entry_price) + : t.trigger_price != null + ? Number(t.trigger_price) + : null; + let mark = + markOverride != null && Number.isFinite(Number(markOverride)) + ? Number(markOverride) + : p.mark_price != null && p.mark_price !== "" + ? Number(p.mark_price) + : t.floating_mark != null + ? Number(t.floating_mark) + : t.last_mark_price != null + ? Number(t.last_mark_price) + : null; + const contracts = p.contracts; + const cs = + p.contract_size != null && p.contract_size !== "" + ? Number(p.contract_size) + : 1; + const computed = estimateLinearSwapUpnl( + p.side || t.direction, + entry, + mark, + contracts, + cs + ); + if (computed == null) { + if (exchange != null) return exchange; + if (t.floating_pnl != null && t.floating_pnl !== "") { + const n = Number(t.floating_pnl); + if (Number.isFinite(n)) return n; + } + return null; + } + if (exchange == null) return computed; + const ref = Math.max(Math.abs(computed), 1); + if (Math.abs(exchange - computed) / ref > 0.2) return computed; + return exchange; + } + + function resolveTrendFloatingPnl(pos, trendPlan, markOverride) { + return resolvePositionUpnlUsdt(pos, trendPlan, markOverride); + } + + function formatFloatingPnlText(upnl, notionalUsdt) { + if (upnl == null || !Number.isFinite(Number(upnl))) return { text: "—", cls: "" }; + let pnlText = fmt(upnl, 2) + "U"; + const notional = Number(notionalUsdt); + if (Number.isFinite(notional) && Math.abs(notional) > 1e-8) { + const pct = (Number(upnl) / Math.abs(notional)) * 100; + pnlText += ` (${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%)`; + } + return { text: pnlText, cls: pnlCls(upnl) }; + } + + /** 与实例策略页一致:浮盈亏 % = 浮盈亏 / 计划保证金 */ + function formatTrendPlanFloatingPnl(upnl, planMargin) { + if (upnl == null || !Number.isFinite(Number(upnl))) { + return { text: "—", cls: "" }; + } + let pnlText = fmt(upnl, 2) + "U"; + const margin = Number(planMargin); + if (Number.isFinite(margin) && margin > 0) { + const pct = (Number(upnl) / margin) * 100; + pnlText += ` (${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%)`; + } + const n = Number(upnl); + let cls = "pnl-neutral"; + if (n > 0) cls = "pnl-profit"; + else if (n < 0) cls = "pnl-loss"; + return { text: pnlText, cls }; + } + + function renderDirectionBadge(side) { + const s = normSide(side); + const label = sideDirLabel(side); + const cls = s === "long" ? "direction-long" : s === "short" ? "direction-short" : ""; + if (!cls) return esc(String(label)); + return `${esc(label)}`; + } + + function resolveTrendDcaLevels(t) { + if (Array.isArray(t.dca_levels) && t.dca_levels.length) return t.dca_levels; + const plan = t || {}; + let grid = []; + let legAmounts = []; + try { + grid = JSON.parse(plan.grid_prices_json || "[]"); + if (!Array.isArray(grid)) grid = []; + } catch (_e) { + grid = []; + } + try { + legAmounts = JSON.parse(plan.leg_amounts_json || "[]"); + if (!Array.isArray(legAmounts)) legAmounts = []; + } catch (_e2) { + legAmounts = []; + } + const legsDone = Number(plan.legs_done) || 0; + const dcaLegs = Number(plan.dca_legs) || 0; + const firstDone = Number(plan.first_order_done) !== 0; + const out = [ + { + label: "首仓", + price: null, + contracts: plan.first_order_amount, + status: firstDone ? "done" : "pending", + status_label: firstDone ? "已开仓" : "待开仓", + }, + ]; + const n = Math.max(grid.length, legAmounts.length, dcaLegs); + for (let idx = 0; idx < n; idx += 1) { + const legI = idx + 1; + const done = legI <= legsDone; + out.push({ + label: `补仓${legI}`, + price: idx < grid.length ? grid[idx] : null, + contracts: idx < legAmounts.length ? legAmounts[idx] : null, + status: done ? "done" : "pending", + status_label: done ? "已补仓" : "待补仓", + }); + } + return out; + } + + function pnlCls(v) { + const n = Number(v); + if (!Number.isFinite(n) || n === 0) return ""; + return n > 0 ? "pnl-pos" : "pnl-neg"; + } + + function normSide(side) { + const s = (side || "").toLowerCase(); + if (s === "buy") return "long"; + if (s === "sell") return "short"; + return s; + } + + function sideDirCls(side) { + const s = normSide(side); + if (s === "long") return "side-long"; + if (s === "short") return "side-short"; + return ""; + } + + function sideDirLabel(side) { + const s = normSide(side); + if (s === "long") return "做多"; + if (s === "short") return "做空"; + return side || "—"; + } + + function isTrendHandoffOrder(monitorOrder) { + const mo = monitorOrder || {}; + return String(mo.trade_style || "").toLowerCase() === "trend_pullback_handoff"; + } + + function isTrendContext(monitorOrder, trendPlan) { + const mo = monitorOrder || {}; + const tp = trendPlan || {}; + if (tp.id != null && Number(tp.id) > 0) return true; + const tid = Number(mo.trend_plan_id); + if (Number.isFinite(tid) && tid > 0) return true; + const mt = String(mo.monitor_type || "").trim(); + if (mt === "趋势回调") return true; + const kst = String(mo.key_signal_type || "").trim(); + return kst === "趋势回调" || kst === "趋势回调计划"; + } + + function trendAddZoneLabel(direction) { + return (direction || "long").toLowerCase() === "short" ? "补仓下沿" : "补仓上沿"; + } + + function monitorOrderSourceLabel(mo, trendPlan) { + if (isTrendContext(mo, trendPlan)) return "趋势回调计划"; + const o = mo || {}; + const mt = String(o.monitor_type || "").trim(); + return mt || "下单监控"; + } + + function monitorOrderSourceHtml(mo, trendPlan) { + if (isTrendContext(mo, trendPlan)) { + return `来源: ${esc(monitorOrderSourceLabel(mo, trendPlan))}`; + } + const src = monitorOrderSourceLabel(mo, trendPlan); + const kst = String((mo && mo.key_signal_type) || "").trim(); + let text = src; + if (kst && kst !== src && !text.includes(kst)) { + text += " · " + kst; + } + return `来源: ${esc(text)}`; + } + + function renderDirectionHtml(side) { + const cls = sideDirCls(side); + const label = sideDirLabel(side); + if (!cls) return esc(String(label)); + return `${esc(label)}`; + } + + function keyHasPendingOrder(keyRow, keyPrice) { + const kp = keyPrice || {}; + const oid = keyRow.fib_limit_order_id; + if (oid != null && String(oid).trim() !== "") return true; + const gm = String(kp.gate_metrics || ""); + if (gm.includes("限价单") || gm.includes("挂单")) return true; + const gs = String(kp.gate_summary || ""); + if (/挂|限价|等待成交/.test(gs)) return true; + return false; + } + + function fmtKeyOrderAmount(keyRow) { + const raw = keyRow.fib_order_amount; + if (raw == null || raw === "") return ""; + const n = Number(raw); + if (!Number.isFinite(n) || n <= 0) return ""; + return `${fmt(n, 4)} 张`; + } + + /** 全屏持仓区:按仓位数量附加布局 class(1~6 固定列数,7+ 自动填充) */ + function hubPosListCountClass(n) { + const c = Math.max(0, parseInt(n, 10) || 0); + if (c <= 0) return "count-0"; + if (c <= 6) return `count-${c}`; + return "count-many"; + } + + function currentPage() { + const p = window.location.pathname.replace(/\/$/, "") || "/monitor"; + if (p.includes("settings")) return "settings"; + if (p.includes("archive")) return "archive"; + if (p.includes("dashboard")) return "dashboard"; + if (p.includes("funds")) return "funds"; + if (p.includes("plan")) return "plan"; + if (p.includes("calculator")) return "calculator"; + if (p.includes("market")) return "market"; + if (p.includes("/ai")) return "ai"; + return "monitor"; + } + + function pageElementId(page) { + if (page === "settings") return "page-settings"; + if (page === "archive") return "page-archive"; + if (page === "dashboard") return "page-dashboard"; + if (page === "funds") return "page-funds"; + if (page === "plan") return "page-plan"; + if (page === "calculator") return "page-calculator"; + if (page === "market") return "page-market"; + if (page === "ai") return "page-ai"; + return "page-monitor"; + } + + function setActiveNav() { + let page = currentPage(); + if (!pageNavAllowed(page)) { + history.replaceState({}, "", "/monitor"); + page = "monitor"; + } + const pageId = pageElementId(page); + document.querySelectorAll(".top-nav a").forEach((a) => { + const href = (a.getAttribute("href") || "").split("?")[0]; + a.classList.toggle( + "active", + href === "/" + page || (page === "monitor" && (href === "/" || href === "/monitor")) + ); + }); + document.querySelectorAll(".page").forEach((el) => { + el.classList.toggle("hidden", el.id !== pageId); + }); + document.body.classList.toggle("hub-page-ai", page === "ai"); + document.body.classList.toggle("hub-page-funds", page === "funds"); + document.body.classList.toggle("hub-page-dashboard", page === "dashboard"); + syncHubAiMobileViewport(); + if (page === "monitor") startMonitorPoll(); + else stopMonitorPoll(); + if (page !== "ai") closeSupervisorStream(); + if (page === "dashboard" && window.hubDashboardPage) { + window.hubDashboardPage.init(); + } else if (window.hubDashboardPage && window.hubDashboardPage.destroy) { + window.hubDashboardPage.destroy(); + } + if (page === "settings") loadSettingsUI(); + if (page === "ai") loadAiPage(); + if (page === "archive" && window.hubArchivePage) { + window.hubArchivePage.init(); + } else if (window.hubArchivePage && window.hubArchivePage.destroy) { + window.hubArchivePage.destroy(); + } + if (page === "plan" && window.hubPlanPage) { + window.hubPlanPage.init(); + } else if (window.hubPlanPage && window.hubPlanPage.destroy) { + window.hubPlanPage.destroy(); + } + if (page === "calculator" && window.hubCalculatorPage) { + window.hubCalculatorPage.init(); + } + if (page === "funds" && window.hubFundsPage) { + window.hubFundsPage.init(); + } else if (window.hubFundsPage && window.hubFundsPage.destroy) { + window.hubFundsPage.destroy(); + } + if (page === "market" && window.hubMarketChart) { + window.hubMarketChart.init(); + } else if (window.hubMarketChart) { + if (window.hubMarketChart.stopChartLive) window.hubMarketChart.stopChartLive(); + else { + if (window.hubMarketChart.stopAutoRefresh) window.hubMarketChart.stopAutoRefresh(); + } + if (window.hubMarketChart.stopPriceTagTimer) window.hubMarketChart.stopPriceTagTimer(); + } + } + + function stopMonitorPoll() { + closeMonitorBoardStream(); + stopHostStatusPoll(); + stopMacroBannerPoll(); + if (sseReconnectTimer) { + clearTimeout(sseReconnectTimer); + sseReconnectTimer = null; + } + } + + function closeMonitorBoardStream() { + if (boardEventSource) { + boardEventSource.close(); + boardEventSource = null; + } + } + + function connectMonitorBoardStream() { + closeMonitorBoardStream(); + if (!document.getElementById("auto-monitor")?.checked) return; + if (currentPage() !== "monitor") return; + boardEventSource = new EventSource("/api/monitor/board/stream"); + boardEventSource.addEventListener("board", (ev) => { + try { + const st = JSON.parse(ev.data || "{}"); + const ver = Number(st.board_version) || 0; + if (ver !== localBoardVersion) { + void fetchMonitorBoardSnapshot({ background: true }); + } else if (st.aggregating && lastMonitorRows.length) { + applyMonitorBoardUi(lastMonitorRows, st.updated_at || lastMonitorBoardUpdatedAt, { + stale: true, + }); + } + } catch (_) {} + }); + boardEventSource.onerror = () => { + closeMonitorBoardStream(); + if (sseReconnectTimer) clearTimeout(sseReconnectTimer); + sseReconnectTimer = setTimeout(() => { + if (currentPage() === "monitor" && document.getElementById("auto-monitor")?.checked) { + connectMonitorBoardStream(); + void fetchMonitorBoardSnapshot({ background: true }); + } + }, 8000); + }; + } + + async function requestMonitorBoardRefresh() { + await apiFetch("/api/monitor/board/refresh", { method: "POST" }); + } + + function clearMonitorBoardSlowHint() { + if (monitorBoardSlowHintTimer) { + clearTimeout(monitorBoardSlowHintTimer); + monitorBoardSlowHintTimer = null; + } + } + + function scheduleMonitorBoardSlowHint(box) { + clearMonitorBoardSlowHint(); + if (!box) return; + monitorBoardSlowHintTimer = setTimeout(() => { + if (lastMonitorRows.length) return; + const el = box.querySelector(".board-loading"); + if (!el) return; + const sub = el.querySelector(".board-loading-sub"); + if (sub) { + sub.textContent = + "后台首次聚合较慢(三所子代理 + Flask)。可检查 PM2、或设 HUB_BOARD_KEY_PRICES=false 加速。"; + } + }, 12000); + } + + function saveMonitorBoardCache(rows, updatedAt, boardVersion) { + try { + sessionStorage.setItem( + HUB_MONITOR_BOARD_CACHE_KEY, + JSON.stringify({ + version: 1, + board_version: boardVersion != null ? boardVersion : localBoardVersion, + updated_at: updatedAt || "", + rows: rows || [], + saved_at: Date.now(), + }) + ); + } catch (_) {} + } + + function loadMonitorBoardFromCache() { + try { + const raw = sessionStorage.getItem(HUB_MONITOR_BOARD_CACHE_KEY); + if (!raw) return null; + const data = JSON.parse(raw); + if (!data || !Array.isArray(data.rows) || !data.rows.length) return null; + const age = Date.now() - Number(data.saved_at || 0); + if (!Number.isFinite(age) || age > HUB_MONITOR_CACHE_MAX_AGE_MS) { + sessionStorage.removeItem(HUB_MONITOR_BOARD_CACHE_KEY); + return null; + } + return data; + } catch (_) { + return null; + } + } + + function restoreMonitorBoardFromCache() { + const cached = loadMonitorBoardFromCache(); + if (!cached) return false; + lastMonitorRows = cached.rows; + lastMonitorBoardUpdatedAt = cached.updated_at || ""; + localBoardVersion = 0; + applyMonitorBoardUi(cached.rows, lastMonitorBoardUpdatedAt, { stale: true }); + return true; + } + + function applyMonitorBoardUi(rows, updatedAt, opts) { + const options = opts || {}; + const tsRaw = updatedAt || lastMonitorBoardUpdatedAt || ""; + if (updatedAt) lastMonitorBoardUpdatedAt = updatedAt; + const online = (rows || []).filter((x) => x.http_ok && (x.agent || {}).ok !== false).length; + const pill = document.getElementById("sys-status"); + if (pill) { + pill.textContent = rows.length ? `LINK ${online}/${rows.length}` : "NO DATA"; + pill.classList.toggle("warn", rows.length && online < rows.length); + if (options.stale) pill.classList.add("syncing"); + else pill.classList.remove("syncing"); + } + const upd = document.getElementById("monitor-updated"); + if (upd) { + const ts = tsRaw.replace("T", " "); + upd.textContent = options.stale + ? ts + ? `缓存 ${ts} · 后台聚合中…` + : "后台聚合中…" + : ts + ? `UPD ${ts}` + : ""; + } + updateMonitorAlertSummary(rows || []); + void refreshMacroRiskBanner(rows || []); + renderMonitorGrid(rows || []); + } + + let macroBannerTimer = null; + let macroCalendarEditId = null; + + function monitorHasOpenPositions(rows) { + return (rows || []).some((row) => { + const pos = (row.agent && row.agent.positions) || []; + return Array.isArray(pos) && pos.length > 0; + }); + } + + function macroAlertMessage(alert, hasPositions) { + const label = alert.event_type_label || alert.event_type || "宏观数据"; + const phase = alert.phase || "window"; + const mins = Number(alert.minutes_to_event || 0); + if (hasPositions) { + if (phase === "imminent" && mins > 0) { + return ( + `「${label}」即将发布(约 ${mins} 分钟),` + + "注意仓位风险:勿加仓,检查止损/减仓" + ); + } + return `「${label}」高波动窗口(±1h),注意仓位风险:勿加仓,检查止损/减仓`; + } + if (phase === "imminent" && mins > 0) { + return `「${label}」即将发布(约 ${mins} 分钟),建议等待,避免新开仓`; + } + return `「${label}」高波动窗口(±1h),建议等待,避免新开仓`; + } + + async function refreshMacroRiskBanner(rows) { + if (currentPage() !== "monitor") return; + const el = document.getElementById("monitor-macro-banner"); + const textEl = document.getElementById("monitor-macro-banner-text"); + if (!el || !textEl) return; + try { + const r = await apiFetch("/api/macro-calendar/active"); + const j = await r.json(); + const alerts = (j.ok && j.alerts) || []; + if (!alerts.length) { + el.classList.add("hidden"); + el.classList.remove("phase-imminent"); + textEl.textContent = ""; + return; + } + const alert = alerts[0]; + const hasPos = monitorHasOpenPositions(rows || lastMonitorRows); + textEl.textContent = macroAlertMessage(alert, hasPos); + el.classList.toggle("phase-imminent", alert.phase === "imminent"); + el.classList.remove("hidden"); + } catch (_) { + el.classList.add("hidden"); + } + } + + function startMacroBannerPoll() { + stopMacroBannerPoll(); + if (currentPage() !== "monitor") return; + void refreshMacroRiskBanner(lastMonitorRows); + macroBannerTimer = setInterval(() => { + if (currentPage() === "monitor") void refreshMacroRiskBanner(lastMonitorRows); + }, 30000); + } + + function stopMacroBannerPoll() { + if (macroBannerTimer) { + clearInterval(macroBannerTimer); + macroBannerTimer = null; + } + } + + function startMonitorPoll() { + const hadCache = restoreMonitorBoardFromCache(); + void fetchMonitorBoardSnapshot({ showLoading: !hadCache }); + connectMonitorBoardStream(); + startHostStatusPoll(); + startMacroBannerPoll(); + } + + async function loadSettings() { + const r = await apiFetch("/api/settings"); + settingsCache = await r.json(); + syncNavVisibility(settingsCache); + return settingsCache; + } + + function enabledAccounts() { + return (settingsCache?.exchanges || []).filter((x) => x.enabled); + } + + /** 窄屏布局:仅按视口宽度,监控区/行情等共用 */ + function isMobileLayout() { + return window.matchMedia("(max-width: 720px)").matches; + } + + /** AI 教练手机布局:窄屏或手机 PWA(桌面安装的 App 仍走桌面布局) */ + function isMobileAiLayout() { + if (isMobileLayout()) return true; + if ( + window.matchMedia("(display-mode: standalone)").matches && + window.matchMedia("(max-width: 960px)").matches + ) { + return true; + } + if (window.navigator && window.navigator.standalone === true) return true; + return false; + } + + function positionHasContracts(p) { + const c = Number(p && p.contracts); + return Number.isFinite(c) && Math.abs(c) >= 1e-12; + } + + function exchangeNeedsFlask(row) { + const caps = row.capabilities || []; + return caps.includes("key") || caps.includes("trend"); + } + + function positionMissingStopLoss(pos, orders, trends) { + if (!positionHasContracts(pos)) return false; + const mo = findMonitorOrder(orders, pos.symbol, pos.side); + const tp = findTrendPlan(trends, pos.symbol, pos.side); + const tpsl = resolvePositionTpsl(pos, mo, tp); + const sl = tpsl.sl; + if (sl !== "" && sl != null && Number.isFinite(Number(sl))) return false; + const cond = condOrdersFromPosition(pos); + const picked = pickExTpslOrders(cond); + if (picked.sl && picked.sl.trigger_price != null) return false; + const et = pos.exchange_tpsl; + if (et && et.sl) return false; + return true; + } + + function analyzeExchangeAlert(row) { + const ag = row.agent || {}; + const hm = row.hub_monitor || {}; + const pos = Array.isArray(ag.positions) ? ag.positions : []; + const flaskOk = row.flask_ok !== false && hm.ok !== false; + const upnl = Number(ag.total_unrealized_pnl); + const tradingBal = Number(row.trading_usdt); + const balance = + Number.isFinite(tradingBal) && tradingBal > 0 + ? tradingBal + : Number(ag.balance_usdt); + const sortUpnl = Number.isFinite(upnl) ? upnl : 0; + + if (!row.http_ok) { + return { level: "error", summary: "子代理离线", sortUpnl: 0 }; + } + if (ag.ok === false) { + return { + level: "error", + summary: (ag.error || row.error || "子代理异常").slice(0, 24), + sortUpnl: 0, + }; + } + if (exchangeNeedsFlask(row) && !flaskOk) { + const fe = row.flask_error || hm.error || hm.msg || "Flask未连通"; + return { level: "error", summary: String(fe).slice(0, 24), sortUpnl }; + } + + const orders = flaskOk ? hm.orders || [] : []; + const trends = flaskOk ? hm.trends || [] : []; + let missingSl = false; + for (const p of pos) { + if (positionMissingStopLoss(p, orders, trends)) { + missingSl = true; + break; + } + } + + if (Number.isFinite(upnl) && upnl < 0 && Number.isFinite(balance) && balance > 0) { + const lossPct = (Math.abs(upnl) / balance) * 100; + if (lossPct >= HUB_ALERT_FLOAT_LOSS_RATIO * 100) { + return { + level: "warn", + summary: `浮亏超10% · ${fmt(upnl, 2)}U`, + sortUpnl, + }; + } + } + if (missingSl) { + return { level: "warn", summary: "缺止损", sortUpnl }; + } + + const openCount = pos.filter(positionHasContracts).length; + return { + level: "ok", + summary: openCount ? "正常" : "空仓", + sortUpnl, + }; + } + + function sortRowsForMobileDashboard(rows) { + const levelOrder = { error: 0, warn: 1, ok: 2 }; + return rows + .map((r) => ({ r, a: analyzeExchangeAlert(r) })) + .sort((x, y) => { + const ld = levelOrder[x.a.level] - levelOrder[y.a.level]; + if (ld !== 0) return ld; + return (x.a.sortUpnl || 0) - (y.a.sortUpnl || 0); + }) + .map((x) => x.r); + } + + function updateMonitorAlertSummary(rows) { + const el = document.getElementById("monitor-alert-summary"); + if (!el) return; + if (!isMobileLayout() || !rows.length) { + el.classList.add("hidden"); + el.innerHTML = ""; + return; + } + let err = 0; + let warn = 0; + let ok = 0; + rows.forEach((r) => { + const lv = analyzeExchangeAlert(r).level; + if (lv === "error") err += 1; + else if (lv === "warn") warn += 1; + else ok += 1; + }); + el.classList.remove("hidden"); + el.innerHTML = `正常 ${ok}·关注 ${warn}·异常 ${err}`; + } + + /** 监控卡片列数:桌面 3/2 列;手机端 2 列瓦片 */ + function syncMonitorGridColumns(gridEl, count) { + if (!gridEl) return; + if (isMobileLayout()) { + gridEl.style.gridTemplateColumns = "repeat(2, minmax(0, 1fr))"; + return; + } + let cols = 3; + if (count <= 1) cols = 1; + else if (count === 2) cols = 2; + else if (count === 3) cols = 3; + else if (count === 4) cols = 2; + else cols = 3; + gridEl.style.gridTemplateColumns = `repeat(${cols}, minmax(0, 1fr))`; + } + + const AI_MOBILE_TAB_KEY = "hub_ai_mobile_tab"; + const AI_MOBILE_CHAT_TABS = new Set(["trading", "general", "supervisor"]); + let aiSupervisorSessionCache = null; + let supervisorEventSource = null; + let localSupervisorVersion = 0; + let supervisorReconnectTimer = null; + + function isSupervisorMode() { + return aiSelectedBotMode === "supervisor"; + } + + function normalizeAiBotMode(mode) { + const m = (mode || "").trim().toLowerCase(); + if (m === "general") return "general"; + if (m === "supervisor") return "supervisor"; + return "trading"; + } + + function normalizeAiMobileTab(tab) { + const raw = (tab || "").trim().toLowerCase(); + if (raw === "chat") return "trading"; + if (AI_MOBILE_CHAT_TABS.has(raw) || raw === "history") return raw; + return "trading"; + } + + function applyAiMobileTab(tab) { + const layout = document.querySelector(".ai-layout"); + const tabs = document.querySelectorAll(".ai-mobile-tab"); + if (!layout) return; + const mobile = isMobileAiLayout(); + if (!mobile) { + delete layout.dataset.aiMobileTab; + tabs.forEach((btn) => { + btn.classList.remove("is-active"); + btn.setAttribute("aria-selected", "false"); + }); + return; + } + const active = normalizeAiMobileTab( + tab || localStorage.getItem(AI_MOBILE_TAB_KEY) || "trading" + ); + layout.dataset.aiMobileTab = active; + tabs.forEach((btn) => { + const t = btn.dataset.aiTab || ""; + const on = t === active; + btn.classList.toggle("is-active", on); + btn.setAttribute("aria-selected", on ? "true" : "false"); + }); + if (AI_MOBILE_CHAT_TABS.has(active)) { + updateAiBotTabs(active); + if (active === "supervisor") { + void loadAiSupervisorSession().then(() => connectSupervisorStream()); + } else { + closeSupervisorStream(); + } + scrollAiChatToEnd(); + } + if (active === "history") { + const hist = document.getElementById("ai-chat-history-list"); + if (hist) hist.scrollTop = 0; + } + } + + function initAiMobileTabs() { + const tabs = document.querySelectorAll(".ai-mobile-tab"); + if (!tabs.length) return; + tabs.forEach((btn) => { + btn.addEventListener("click", () => { + const tab = btn.dataset.aiTab || "trading"; + if (tab === "new") { + const prev = normalizeAiMobileTab(localStorage.getItem(AI_MOBILE_TAB_KEY) || "trading"); + const botMode = prev === "general" ? "general" : prev === "supervisor" ? "supervisor" : "trading"; + if (botMode === "supervisor") { + void switchToSupervisorMode(); + } else { + void newAiChat(botMode); + } + return; + } + if (tab === "supervisor") { + void switchToSupervisorMode(); + return; + } + localStorage.setItem(AI_MOBILE_TAB_KEY, tab); + applyAiMobileTab(tab); + if (AI_MOBILE_CHAT_TABS.has(tab)) { + const input = document.getElementById("ai-chat-input"); + if (input && isMobileAiLayout()) input.focus(); + } + }); + }); + window.addEventListener("resize", () => applyAiMobileTab()); + applyAiMobileTab(); + } + + let syncHubAiMobileViewport = () => {}; + + function initHubAiMobileViewport() { + const shell = document.querySelector(".app-shell"); + const chatInput = document.getElementById("ai-chat-input"); + if (!shell || !window.visualViewport) { + syncHubAiMobileViewport = () => {}; + return; + } + + let baselineInnerH = Math.max(window.innerHeight, window.visualViewport.height || 0); + + const scrollChatToEnd = () => { + const box = document.getElementById("ai-chat-messages"); + if (box) requestAnimationFrame(() => { box.scrollTop = box.scrollHeight; }); + }; + + syncHubAiMobileViewport = () => { + const onAi = document.body.classList.contains("hub-page-ai"); + if (!onAi || !isMobileAiLayout()) { + shell.style.removeProperty("height"); + shell.style.removeProperty("max-height"); + shell.style.removeProperty("width"); + shell.style.removeProperty("transform"); + document.documentElement.style.removeProperty("--hub-vvh"); + document.body.classList.remove("hub-ai-keyboard-open"); + return; + } + const vv = window.visualViewport; + const h = Math.max(240, Math.round(vv.height)); + const top = Math.round(vv.offsetTop || 0); + const left = Math.round(vv.offsetLeft || 0); + const inputFocused = !!(chatInput && document.activeElement === chatInput); + if (!inputFocused) { + baselineInnerH = Math.max(baselineInnerH, window.innerHeight, h); + } + document.documentElement.style.setProperty("--hub-vvh", `${h}px`); + shell.style.height = `${h}px`; + shell.style.maxHeight = `${h}px`; + shell.style.width = `${Math.round(vv.width)}px`; + shell.style.transform = + top > 0 || left > 0 ? `translate(${left}px, ${top}px)` : ""; + const viewportShrunk = h < baselineInnerH * 0.72; + const keyboardLikely = inputFocused && (viewportShrunk || top > 48); + document.body.classList.toggle("hub-ai-keyboard-open", keyboardLikely); + }; + + window.visualViewport.addEventListener("resize", syncHubAiMobileViewport); + window.visualViewport.addEventListener("scroll", syncHubAiMobileViewport); + window.addEventListener("resize", syncHubAiMobileViewport); + window.addEventListener("orientationchange", () => { + setTimeout(syncHubAiMobileViewport, 80); + }); + + if (chatInput) { + chatInput.addEventListener("focus", () => { + syncHubAiMobileViewport(); + scrollChatToEnd(); + setTimeout(syncHubAiMobileViewport, 50); + setTimeout(syncHubAiMobileViewport, 280); + }); + chatInput.addEventListener("blur", () => { + setTimeout(syncHubAiMobileViewport, 80); + setTimeout(syncHubAiMobileViewport, 320); + }); + } + syncHubAiMobileViewport(); + } + + function initMobileLayout() { + initAiMobileTabs(); + initHubAiMobileViewport(); + let resizeTimer = null; + let wasMobile = isMobileLayout(); + window.addEventListener("resize", () => { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => { + const nowMobile = isMobileLayout(); + if (lastMonitorRows.length && nowMobile !== wasMobile) { + wasMobile = nowMobile; + renderMonitorGrid(lastMonitorRows); + updateMonitorAlertSummary(lastMonitorRows); + return; + } + wasMobile = nowMobile; + const box = document.getElementById("monitor-grid"); + if (box && lastMonitorRows.length) { + syncMonitorGridColumns(box, lastMonitorRows.length); + updateMonitorAlertSummary(lastMonitorRows); + } + }, 120); + }); + } + + function normSym(s) { + return String(s || "") + .toUpperCase() + .replace(/:USDT$/i, "") + .replace(/\/USDT:USDT$/i, "") + .replace(/\/USDT$/i, ""); + } + + function symbolsMatchHub(a, b) { + const x = normSym(a); + const y = normSym(b); + if (!x || !y) return false; + return x === y; + } + + function ordersCollapseKey(exchangeId, symbol) { + const sym = normSym(symbol) || "unknown"; + return `hub_orders_${exchangeId}_${sym}`; + } + + function isOrdersCollapseOpen(exchangeId, symbol) { + return localStorage.getItem(ordersCollapseKey(exchangeId, symbol)) === "1"; + } + + function condOrderRole(o) { + const lb = (o && o.label) || ""; + if (/止盈止损/.test(lb)) return null; + if (/止损/.test(lb)) return "sl"; + if (/止盈/.test(lb)) return "tp"; + return null; + } + + function dedupeCondOrdersByRole(orders) { + const list = Array.isArray(orders) ? orders : []; + const byRole = {}; + const others = []; + for (const o of list) { + const role = condOrderRole(o); + if (role) byRole[role] = o; + else others.push(o); + } + const out = others.slice(); + if (byRole.tp) out.push(byRole.tp); + if (byRole.sl) out.push(byRole.sl); + return out; + } + + function dedupeCondOrdersByTrigger(orders) { + const list = Array.isArray(orders) ? orders : []; + const seen = new Set(); + const out = []; + for (const o of list) { + const px = orderTriggerOrPrice(o); + const key = + px != null + ? "t:" + String(px) + : o && o.id + ? "id:" + String(o.id) + : null; + if (key && seen.has(key)) continue; + if (key) seen.add(key); + out.push(o); + } + return out; + } + + function upsertExTpslCondOrder(cond, role, slot) { + if (!slot || slot.trigger_price == null || slot.trigger_price === "") return; + const label = role === "sl" ? "止损" : "止盈"; + const item = { + label: label, + trigger_price: Number(slot.trigger_price), + amount: slot.amount != null ? slot.amount : null, + id: slot.order_id || "", + channel: "algo", + }; + const idx = cond.findIndex(function (o) { + const lb = o.label || ""; + return role === "sl" ? /^止损\b/.test(lb) || lb.includes("止损") : /^止盈\b/.test(lb) || lb.includes("止盈"); + }); + if (idx >= 0) cond[idx] = Object.assign({}, cond[idx], item); + else cond.push(item); + } + + function condOrdersFromPosition(pos) { + let cond = dedupeCondOrdersByRole( + Array.isArray(pos.conditional_orders) ? pos.conditional_orders : [] + ); + cond = dedupeCondOrdersByTrigger(cond); + const et = pos.exchange_tpsl; + if (!et) return cond; + upsertExTpslCondOrder(cond, "sl", et.sl); + upsertExTpslCondOrder(cond, "tp", et.tp); + return cond; + } + + function findMonitorOrder(orders, symbol, side) { + const want = (side || "").toLowerCase(); + for (const o of orders || []) { + const sym = o.exchange_symbol || o.symbol || ""; + if (!symbolsMatchHub(sym, symbol)) continue; + const d = (o.direction || "").toLowerCase(); + if (!d || d === want) return o; + } + return null; + } + + function calcRrRatio(side, entry, sl, tp) { + const e = Number(entry); + const s = Number(sl); + const t = Number(tp); + if (![e, s, t].every((n) => Number.isFinite(n) && n > 0)) return null; + if ((side || "long").toLowerCase() === "short") { + const risk = s - e; + const reward = e - t; + if (risk <= 0 || reward <= 0) return null; + return reward / risk; + } + const risk = e - s; + const reward = t - e; + if (risk <= 0 || reward <= 0) return null; + return reward / risk; + } + + function resolveTrendPlanRr(trendPlan, side, entry, sl, tp) { + const t = trendPlan || {}; + if (t.money_rr != null && t.money_rr !== "") { + const n = Number(t.money_rr); + if (Number.isFinite(n) && n > 0) return n; + } + if (t.planned_rr != null && t.planned_rr !== "") { + const n = Number(t.planned_rr); + if (Number.isFinite(n) && n > 0) return n; + } + const e = t.avg_entry_price != null && t.avg_entry_price !== "" ? t.avg_entry_price : entry; + const s = t.stop_loss != null && t.stop_loss !== "" ? t.stop_loss : sl; + const p = t.take_profit != null && t.take_profit !== "" ? t.take_profit : tp; + return calcRrRatio(side, e, s, p); + } + + function resolveSnapshotRr(mo, side, entry, sl, tp, tpMonitored, trendPlan) { + if (tpMonitored && isTrendContext(mo, trendPlan)) { + const rr = resolveTrendPlanRr(trendPlan, side, entry, sl, tp); + if (rr != null) return rr; + } + if (tpMonitored) return null; + const snap = mo && mo.rr_ratio; + if (snap != null && snap !== "") { + const n = Number(snap); + if (Number.isFinite(n)) return n; + } + const initSl = mo && (mo.initial_stop_loss != null ? mo.initial_stop_loss : mo.stop_loss); + return calcRrRatio(side, entry, initSl || sl, tp); + } + + function formatTpCellValue(tp, tpMonitored, symbol, tickMap) { + if (tpMonitored) { + if (tp != null && tp !== "") { + return `程序监控 · ${fmtSymbolPrice(tp, symbol, tickMap)}`; + } + return "程序监控"; + } + if (tp != null && tp !== "") return fmtSymbolPrice(tp, symbol, tickMap); + return "—"; + } + + function isBreakevenSecured(side, entry, monitorOrder, cond, pos) { + const mo = monitorOrder || {}; + const p = pos || {}; + if (mo.sl_breakeven_secured === true || mo.sl_breakeven_secured === 1) return true; + if (p.sl_breakeven_secured === true || p.sl_breakeven_secured === 1) return true; + const { sl } = pickExTpslOrders(cond); + const trig = sl && sl.trigger_price != null ? Number(sl.trigger_price) : NaN; + const e = Number(entry); + if (!Number.isFinite(trig) || !Number.isFinite(e)) return false; + if ((side || "long").toLowerCase() === "short") return trig <= e; + return trig >= e; + } + + function breakevenBadgeHtml() { + return `已保本`; + } + + async function fetchMonitorBoardSnapshot(opts) { + const options = opts || {}; + const background = !!options.background; + const showLoading = !!options.showLoading && !lastMonitorRows.length; + const box = document.getElementById("monitor-grid"); + if (monitorBoardInFlight) { + if (background) monitorBoardFetchPending = true; + else return; + } + if (showLoading && box) { + box.innerHTML = + '
正在加载监控快照…

'; + scheduleMonitorBoardSlowHint(box); + } else if (background && lastMonitorRows.length) { + applyMonitorBoardUi(lastMonitorRows, null, { stale: true }); + } + monitorBoardInFlight = true; + const ctrl = new AbortController(); + const fetchTimer = setTimeout(() => ctrl.abort(), HUB_MONITOR_SNAPSHOT_TIMEOUT_MS); + try { + const r = await apiFetch(MONITOR_BOARD_SNAPSHOT_URL, { signal: ctrl.signal }); + const data = await r.json(); + if (!r.ok) { + throw new Error(data.msg || data.detail || `HTTP ${r.status}`); + } + const ver = Number(data.board_version) || 0; + const rows = data.rows || []; + const waitingFirst = data.aggregating && !rows.length && ver <= localBoardVersion; + if (waitingFirst && showLoading) { + if (box) { + const sub = box.querySelector(".board-loading-sub"); + if (sub) sub.textContent = "后台正在首次聚合三所数据(约 5~15 秒)…"; + } + return; + } + const ts = data.updated_at || ""; + const versionChanged = ver !== localBoardVersion; + const timeChanged = ts && ts !== lastMonitorBoardUpdatedAt; + if (versionChanged || timeChanged || !lastMonitorRows.length) { + localBoardVersion = ver; + lastMonitorRows = rows; + saveMonitorBoardCache(lastMonitorRows, ts, ver); + applyMonitorBoardUi(lastMonitorRows, ts, { + stale: !!data.aggregating, + }); + } else if (data.aggregating && lastMonitorRows.length) { + applyMonitorBoardUi(lastMonitorRows, data.updated_at || lastMonitorBoardUpdatedAt, { + stale: true, + }); + } + if (data.ok === false && data.msg && !background) { + showToast(String(data.msg), true); + } + } catch (e) { + const msg = + e && e.name === "AbortError" ? "读取监控快照超时,请检查中控是否运行" : String(e); + if (background && lastMonitorRows.length) { + showToast("快照读取失败,仍显示上次数据", true); + applyMonitorBoardUi(lastMonitorRows, null, { stale: false }); + return; + } + if (box) box.innerHTML = `
${esc(msg)}
`; + } finally { + clearTimeout(fetchTimer); + clearMonitorBoardSlowHint(); + monitorBoardInFlight = false; + if (monitorBoardFetchPending) { + monitorBoardFetchPending = false; + void fetchMonitorBoardSnapshot({ background: true }); + } + } + } + + async function refreshMonitorBoardNow() { + if (lastMonitorRows.length) { + applyMonitorBoardUi(lastMonitorRows, lastMonitorBoardUpdatedAt, { stale: true }); + } + try { + await requestMonitorBoardRefresh(); + await fetchMonitorBoardSnapshot({ background: false }); + } catch (e) { + showToast(String(e), true); + } + } + + function closeExchangeFullscreen() { + expandedExchangeId = ""; + sessionStorage.removeItem("hub_expanded_ex"); + const fs = document.getElementById("exchange-fullscreen"); + if (fs) { + fs.classList.add("hidden"); + fs.setAttribute("aria-hidden", "true"); + } + document.body.classList.remove("hub-fullscreen-open"); + } + + function openExchangeFullscreen(exId) { + expandedExchangeId = String(exId); + sessionStorage.setItem("hub_expanded_ex", expandedExchangeId); + renderMonitorGrid(lastMonitorRows); + } + + function renderMonitorGrid(rows) { + const box = document.getElementById("monitor-grid"); + const fs = document.getElementById("exchange-fullscreen"); + const fsInner = document.getElementById("exchange-fullscreen-inner"); + if (!box) return; + if (expandedExchangeId && !rows.some((r) => String(r.id) === String(expandedExchangeId))) { + closeExchangeFullscreen(); + } + const mobileTiles = isMobileLayout() && !expandedExchangeId; + const displayRows = mobileTiles ? sortRowsForMobileDashboard(rows) : rows; + box.classList.toggle("grid-monitor-tiles", mobileTiles); + try { + box.innerHTML = + displayRows + .map((r) => (mobileTiles ? renderMonitorTile(r) : renderMonitorCard(r))) + .join("") || '
无已启用账户
'; + } catch (err) { + console.error("renderMonitorGrid", err); + box.innerHTML = `
监控区渲染失败:${esc(String(err && err.message ? err.message : err))}
`; + } + syncMonitorGridColumns(box, displayRows.length); + bindMonitorInteractions(box); + if (window.TimeCloseUI && TimeCloseUI.tickLocalCountdowns) { + TimeCloseUI.tickLocalCountdowns(); + } + ensureHubHoldDurationTimer(); + + if (expandedExchangeId && fs && fsInner) { + const row = rows.find((r) => String(r.id) === String(expandedExchangeId)); + if (row) { + try { + fsInner.innerHTML = renderFullscreenExchange(row); + fs.classList.remove("hidden"); + fs.setAttribute("aria-hidden", "false"); + document.body.classList.add("hub-fullscreen-open"); + bindMonitorInteractions(fsInner); + if (window.TimeCloseUI && TimeCloseUI.tickLocalCountdowns) { + TimeCloseUI.tickLocalCountdowns(); + } + ensureHubHoldDurationTimer(); + fsInner.querySelectorAll(".btn-expand-back").forEach((btn) => { + btn.onclick = (ev) => { + ev.stopPropagation(); + closeExchangeFullscreen(); + renderMonitorGrid(lastMonitorRows); + }; + }); + } catch (err) { + console.error("renderFullscreenExchange", err); + closeExchangeFullscreen(); + showToast("全屏渲染失败: " + err, true); + } + } else { + closeExchangeFullscreen(); + } + } else { + closeExchangeFullscreen(); + } + } + + function normalizeMarketSymbol(raw) { + let s = (raw || "").trim().toUpperCase(); + if (!s) return ""; + if (s.includes(":")) { + const base = s.split(":")[0]; + if (base.includes("/")) return base; + } + return s; + } + + function resolveExchangeKey(exchangeId) { + const row = (lastMonitorRows || []).find((r) => String(r.id) === String(exchangeId)); + return (row && (row.key || row.id)) || exchangeId; + } + + function findTrendPlan(trends, symbol, side) { + const want = (side || "").toLowerCase(); + for (const t of trends || []) { + const sym = t.symbol || t.exchange_symbol || ""; + if (!symbolsMatchHub(sym, symbol)) continue; + const d = (t.direction || "").toLowerCase(); + if (!d || d === want) return t; + } + return null; + } + + function orderTriggerOrPrice(o) { + if (!o) return null; + if (o.trigger_price != null && o.trigger_price !== "") { + const t = Number(o.trigger_price); + if (Number.isFinite(t) && t > 0) return t; + } + if (o.price != null && o.price !== "") { + const p = Number(o.price); + if (Number.isFinite(p) && p > 0) return p; + } + return null; + } + + function inferTpslFromCondOrders(side, cond, entry) { + const picked = pickExTpslOrders(cond); + let sl = picked.sl ? orderTriggerOrPrice(picked.sl) : ""; + let tp = picked.tp ? orderTriggerOrPrice(picked.tp) : ""; + if (sl !== "" && sl != null) sl = Number(sl); + if (tp !== "" && tp != null) tp = Number(tp); + if (sl !== "" && tp !== "" && Number(sl) !== Number(tp)) { + return { sl, tp }; + } + + const triggers = (cond || []) + .map(function (o) { + const px = orderTriggerOrPrice(o); + return px == null ? null : { price: px, label: o.label || "" }; + }) + .filter(function (o) { + return o != null; + }); + if (!triggers.length) return { sl: sl || "", tp: tp || "" }; + + const s = (side || "long").toLowerCase(); + const e = entry != null && Number.isFinite(Number(entry)) ? Number(entry) : null; + + if (e != null) { + const below = triggers.filter(function (t) { + return t.price < e; + }); + const above = triggers.filter(function (t) { + return t.price > e; + }); + if (s === "long") { + if (sl === "" && below.length) { + sl = Math.max.apply( + null, + below.map(function (t) { + return t.price; + }) + ); + } + if (tp === "" && above.length) { + tp = Math.min.apply( + null, + above.map(function (t) { + return t.price; + }) + ); + } + } else { + if (sl === "" && above.length) { + sl = Math.min.apply( + null, + above.map(function (t) { + return t.price; + }) + ); + } + if (tp === "" && below.length) { + tp = Math.max.apply( + null, + below.map(function (t) { + return t.price; + }) + ); + } + } + } + + if (triggers.length === 1 && sl === "" && tp === "") { + const one = triggers[0]; + const p = one.price; + const lbl = one.label; + if (e != null) { + if (s === "long") { + if (p < e) sl = p; + else if (p > e) tp = p; + } else if (p > e) sl = p; + else if (p < e) tp = p; + } else if (/止损/.test(lbl)) sl = p; + else if (/止盈/.test(lbl) && !/止盈止损/.test(lbl)) tp = p; + } + + if (sl !== "" && tp !== "" && Number(sl) === Number(tp)) tp = ""; + return { sl: sl || "", tp: tp || "" }; + } + + function resolvePositionTpsl(pos, monitorOrder, trendPlan) { + const mo = monitorOrder || {}; + const tp = trendPlan || {}; + const cond = condOrdersFromPosition(pos); + const entryRaw = + pos.entry_price != null + ? pos.entry_price + : mo.trigger_price != null + ? mo.trigger_price + : tp.avg_entry_price; + const entryN = entryRaw != null && entryRaw !== "" ? Number(entryRaw) : null; + const isTrend = isTrendContext(mo, trendPlan); + const handoff = isTrendHandoffOrder(mo); + + let sl = mo.stop_loss != null && mo.stop_loss !== "" ? mo.stop_loss : ""; + let takeProfit = mo.take_profit != null && mo.take_profit !== "" ? mo.take_profit : ""; + let tpMonitored = false; + + if (handoff) { + tpMonitored = false; + } else if (isTrend) { + tpMonitored = true; + if (trendPlan && trendPlan.stop_loss != null && trendPlan.stop_loss !== "") { + sl = trendPlan.stop_loss; + } + if (trendPlan && trendPlan.take_profit != null && trendPlan.take_profit !== "") { + takeProfit = trendPlan.take_profit; + } else { + takeProfit = ""; + } + } + + const inferred = inferTpslFromCondOrders(pos.side, cond, entryN); + if (inferred.sl !== "" && inferred.sl != null) { + sl = inferred.sl; + } else if (sl === "" || sl == null) { + sl = inferred.sl; + } + if (!tpMonitored) { + if (inferred.tp !== "" && inferred.tp != null) { + takeProfit = inferred.tp; + } else if (takeProfit === "" || takeProfit == null) { + takeProfit = inferred.tp; + } + } + + if (sl !== "" && takeProfit !== "" && Number(sl) === Number(takeProfit)) { + takeProfit = ""; + } + + return { + entry: entryRaw, + sl, + tp: takeProfit, + tp_monitored: tpMonitored, + is_trend: isTrend, + is_handoff: handoff, + }; + } + + function buildPositionMarketContext(pos, monitorOrder, trendPlan, exchangeId) { + const mo = monitorOrder || {}; + const tpsl = resolvePositionTpsl(pos, monitorOrder, trendPlan); + const cond = condOrdersFromPosition(pos); + const reg = Array.isArray(pos.regular_orders) ? pos.regular_orders : []; + const num = function (v) { + if (v == null || v === "") return null; + const n = Number(v); + return Number.isFinite(n) ? n : null; + }; + const orders = []; + cond.forEach(function (o) { + orders.push({ + kind: "条件", + label: o.label || "条件单", + price: num(o.trigger_price), + amount: num(o.amount), + }); + }); + reg.forEach(function (o) { + orders.push({ + kind: "普通", + label: o.label || o.type || "委托", + price: num(o.price != null ? o.price : o.trigger_price), + amount: num(o.amount), + }); + }); + const entryPx = num(pos.entry_price != null ? pos.entry_price : tpsl.entry); + const markPx = num(pos.mark_price); + const contractSize = num(pos.contract_size); + const upnl = resolvePositionUpnlUsdt(pos, trendPlan, markPx); + const planMargin = + trendPlan && trendPlan.plan_margin_capital != null + ? num(trendPlan.plan_margin_capital) + : mo.margin_capital != null + ? num(mo.margin_capital) + : null; + const leverage = + trendPlan && trendPlan.leverage != null + ? num(trendPlan.leverage) + : mo.leverage != null + ? num(mo.leverage) + : null; + return { + exchange_id: exchangeId || null, + symbol: (pos.symbol || "").trim(), + side: (pos.side || "long").toLowerCase(), + entry: entryPx, + mark_price: markPx, + stop_loss: num(tpsl.sl), + take_profit: num(tpsl.tp), + tp_monitored: !!tpsl.tp_monitored, + is_trend: !!tpsl.is_trend, + contracts: num(pos.contracts), + contract_size: contractSize != null ? contractSize : 1, + unrealized_pnl: upnl != null ? Number(upnl) : null, + notional_usdt: num(pos.notional_usdt), + plan_margin: planMargin, + leverage: leverage, + orders: orders, + }; + } + + const HUB_MARKET_POS_CTX_KEY = "hubMarketPosContext"; + + function encodePosCtx(ctx) { + try { + return btoa(unescape(encodeURIComponent(JSON.stringify(ctx)))); + } catch (e) { + return ""; + } + } + + function decodePosCtx(raw) { + if (!raw) return null; + try { + return JSON.parse(decodeURIComponent(escape(atob(raw)))); + } catch (e) { + return null; + } + } + + function marketOpenBtnAttrs(exchangeId, exchangeKey, symbol, pos, monitorOrder, trendPlan) { + const symAttr = esc(symbol || "").replace(/"/g, """); + const exKeyAttr = esc(exchangeKey || exchangeId || "").replace(/"/g, """); + const ctxEnc = esc( + encodePosCtx(buildPositionMarketContext(pos, monitorOrder, trendPlan, exchangeId)) + ).replace( + /"/g, + """ + ); + return ( + 'data-ex-id="' + + esc(exchangeId) + + '" data-ex-key="' + + exKeyAttr + + '" data-symbol="' + + symAttr + + '" data-pos-ctx="' + + ctxEnc + + '"' + ); + } + + function openMarketForPosition(exchangeId, symbol, exchangeKey, posCtxRaw) { + const exKey = exchangeKey || resolveExchangeKey(exchangeId); + const sym = normalizeMarketSymbol(symbol); + if (!exKey || !sym) { + showToast("无法打开行情:缺少交易所或合约", true); + return; + } + const ctx = decodePosCtx(posCtxRaw); + if (ctx) { + ctx.symbol = sym; + ctx.exchange_key = exKey; + sessionStorage.setItem(HUB_MARKET_POS_CTX_KEY, JSON.stringify(ctx)); + } else { + sessionStorage.removeItem(HUB_MARKET_POS_CTX_KEY); + } + if (expandedExchangeId) { + closeExchangeFullscreen(); + } + const qs = new URLSearchParams({ exchange_key: exKey, symbol: sym }); + history.pushState({}, "", "/market?" + qs.toString()); + setActiveNav(); + if (window.hubMarketChart && window.hubMarketChart.openWith) { + window.hubMarketChart.openWith(exKey, sym); + } + } + + function bindMonitorInteractions(box) { + box.querySelectorAll(".btn-open-market").forEach((btn) => { + btn.onclick = (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + openMarketForPosition(btn.dataset.exId, btn.dataset.symbol, btn.dataset.exKey, btn.dataset.posCtx); + }; + }); + box.querySelectorAll(".btn-open-instance").forEach((btn) => { + btn.onclick = (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + const msg = (btn.dataset.confirm || "").trim(); + if (msg && !confirm(msg)) return; + openInstance(btn.dataset.exId, btn.dataset.next || "/", { + newTab: btn.dataset.newTab === "1" || ev.ctrlKey || ev.metaKey, + }); + }; + }); + box.querySelectorAll(".btn-hub-trend-stop").forEach((btn) => { + btn.onclick = (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + hubTrendPlanStop(btn.dataset.exId, btn.dataset.planId); + }; + }); + box.querySelectorAll(".btn-hub-trend-be").forEach((btn) => { + btn.onclick = (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + const card = btn.closest(".hub-trend-plan-card"); + const inp = card ? card.querySelector(".hub-plan-be-input") : null; + hubTrendPlanBreakeven(btn.dataset.exId, btn.dataset.planId, inp); + }; + }); + box.querySelectorAll(".btn-close-ex").forEach((btn) => { + btn.onclick = () => closeOne(btn.dataset.id); + }); + box.querySelectorAll(".btn-close-pos").forEach((btn) => { + btn.onclick = (ev) => { + ev.stopPropagation(); + closeOnePosition(btn.dataset.exId, btn.dataset.symbol, btn.dataset.side); + }; + }); + box.querySelectorAll(".btn-cancel-order").forEach((btn) => { + btn.onclick = (ev) => { + ev.stopPropagation(); + cancelOneOrder( + btn.dataset.exId, + btn.dataset.symbol, + btn.dataset.orderId, + btn.dataset.channel + ); + }; + }); + box.querySelectorAll(".btn-cancel-cond-all").forEach((btn) => { + btn.onclick = (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + cancelSymbolOrders(btn.dataset.exId, btn.dataset.symbol, "conditional"); + }; + }); + box.querySelectorAll(".btn-place-tpsl").forEach((btn) => { + btn.onclick = (ev) => { + ev.stopPropagation(); + openTpslModal( + btn.dataset.exId, + btn.dataset.symbol, + btn.dataset.side, + btn.dataset.contracts, + btn.dataset.sl || "", + btn.dataset.tp || "" + ); + }; + }); + box.querySelectorAll(".card-expand-zone").forEach((zone) => { + zone.onclick = (ev) => { + if (ev.target.closest("a, button, input, summary, details, .card-actions")) return; + const id = zone.closest(".card")?.dataset.exId; + if (id) openExchangeFullscreen(id); + }; + }); + box.querySelectorAll("details.pos-orders-collapse[data-collapse-key]").forEach((el) => { + el.addEventListener("toggle", () => { + const k = el.dataset.collapseKey; + if (k) localStorage.setItem(k, el.open ? "1" : "0"); + }); + }); + } + + function renderOrderRows(exchangeId, symbol, orders, kind, tickMap) { + if (!orders || !orders.length) { + const hint = + kind === "conditional" + ? "暂无条件单(止盈/止损等)" + : "暂无普通委托"; + return `
${hint}
`; + } + const symAttr = esc(symbol || "").replace(/"/g, """); + const rows = orders + .map((o) => { + const oidAttr = esc(o.id || "").replace(/"/g, """); + const chAttr = esc(o.channel || "regular").replace(/"/g, """); + const trig = + o.trigger_price != null + ? fmtSymbolPrice(o.trigger_price, symbol, tickMap) + : o.price != null + ? fmtSymbolPrice(o.price, symbol, tickMap) + : "—"; + return ` + + + + + `; + }) + .join(""); + return `
合约方向开仓价标记价张数操作
${esc(o.label || o.type || "委托")}${fmt(o.amount, 4)}${trig}
${rows}
类型数量触发/价格操作
`; + } + + function guessTpslFromCondOrders(side, cond, entry) { + return inferTpslFromCondOrders(side, cond, entry); + } + + function renderOrdersCollapse(exchangeId, symbol, cond, reg, tickMap) { + const symAttr = esc(symbol || "").replace(/"/g, """); + const orderTotal = cond.length + reg.length; + const collapseKey = ordersCollapseKey(exchangeId, symbol); + const openAttr = isOrdersCollapseOpen(exchangeId, symbol) ? " open" : ""; + const condAllBtn = + cond.length > 0 + ? `` + : ""; + const condBody = renderOrderRows(exchangeId, symbol, cond, "conditional", tickMap); + const regBody = renderOrderRows(exchangeId, symbol, reg, "limit", tickMap); + return `
+ + 委托单 ${orderTotal} + 条件 ${cond.length} · 普通 ${reg.length} + ${condAllBtn} + +
+
+
条件单
+ ${condBody} +
+
+
普通委托
+ ${regBody} +
+
+
`; + } + + function syntheticExTpslOrder(role, price, amount) { + if (price == null || price === "" || !Number.isFinite(Number(price))) return null; + return { + label: role === "sl" ? "止损" : "止盈", + trigger_price: Number(price), + price: Number(price), + amount: amount != null ? amount : null, + id: "", + channel: "plan", + }; + } + + function pickExTpslOrders(cond) { + let sl = cond.find((o) => /^止损\b/.test(o.label || "")); + let tp = cond.find((o) => /^止盈\b/.test(o.label || "") && !(o.label || "").includes("止盈止损")); + if (!sl || !tp) { + const combo = cond.find((o) => (o.label || "").includes("止盈止损")); + if (combo) { + const m = (combo.label || "").match(/SL=([\d.eE+-]+).*TP=([\d.eE+-]+)/i); + if (m) { + if (!sl) sl = { ...combo, label: "止损", trigger_price: Number(m[1]) }; + if (!tp) tp = { ...combo, label: "止盈", trigger_price: Number(m[2]) }; + } + } + } + if (!sl) sl = cond.find((o) => (o.label || "").includes("止损")); + if (!tp) tp = cond.find((o) => (o.label || "").includes("止盈") && o !== sl); + return { sl, tp }; + } + + function renderExTpslRows(exchangeId, symbol, cond, tickMap, resolvedTpsl, contracts) { + const symAttr = esc(symbol || "").replace(/"/g, """); + let { sl, tp } = pickExTpslOrders(cond); + const plan = resolvedTpsl || {}; + if (!sl && plan.sl != null && plan.sl !== "") { + sl = syntheticExTpslOrder("sl", plan.sl, contracts); + } + if (!tp && plan.tp != null && plan.tp !== "") { + tp = syntheticExTpslOrder("tp", plan.tp, contracts); + } + function row(label, o) { + if (!o) { + return `
${label}:—
`; + } + const oid = esc(o.id || "").replace(/"/g, """); + const ch = esc(o.channel || "regular").replace(/"/g, """); + const px = orderTriggerOrPrice(o); + const trig = px != null ? fmtSymbolPrice(px, symbol, tickMap) : "—"; + const cancelBtn = + oid && o.channel !== "plan" + ? `` + : ""; + const planHint = o.channel === "plan" ? '(下单监控)' : ""; + return `
+ ${label}:触发 ${trig} · 数量 ${fmt(o.amount, 4)}${planHint} + ${cancelBtn} +
`; + } + return row("止损", sl) + row("止盈", tp); + } + + function trendAddSummaryHtml(t, tickMap) { + const done = t.add_count != null ? t.add_count : t.legs_done; + const total = t.add_count_total != null ? t.add_count_total : t.dca_legs; + const sym = t.exchange_symbol || t.symbol || ""; + let html = ""; + if (done != null && Number(done) >= 0) { + html += total != null ? ` · 补仓 ${esc(done)}/${esc(total)}` : ` · 补仓 ${esc(done)} 次`; + const pxs = t.add_prices_display; + if (Array.isArray(pxs) && pxs.length) { + html += ` · 加仓价 ${pxs.map((p) => esc(p)).join(" / ")}`; + } else if (Array.isArray(t.add_prices) && t.add_prices.length) { + html += ` · 加仓价 ${t.add_prices.map((p) => esc(fmtSymbolPrice(p, sym, tickMap))).join(" / ")}`; + } else if (Number(done) === 0) { + html += " · 加仓价 —"; + } + } + return html; + } + + function timeCloseSymbolBadgeHtml(item) { + if (!item || !item.time_close_enabled) return ""; + const tcLabel = item.time_close_label || `时间平仓 ${item.time_close_hours || ""}h`; + const tcCd = item.time_close_countdown || "--:--:--"; + const tcAt = item.time_close_at_ms != null ? String(item.time_close_at_ms) : ""; + return ( + `` + + `${esc(tcLabel)} · ${esc(tcCd)}` + ); + } + + function renderTrendDcaTable(t, tickMap) { + const levels = resolveTrendDcaLevels(t); + if (!levels.length) return ""; + const sym = t.exchange_symbol || t.symbol || ""; + const rows = levels + .map((lv) => { + const price = + lv.price != null && lv.price !== "" + ? fmtSymbolPrice(lv.price, sym, tickMap) + : "—"; + const amt = + lv.contracts != null && lv.contracts !== "" ? esc(String(lv.contracts)) : "—"; + const avg = + lv.avg_entry != null && lv.avg_entry !== "" + ? fmtSymbolPrice(lv.avg_entry, sym, tickMap) + : "—"; + const profitU = + lv.profit_u != null && lv.profit_u !== "" ? fmt(lv.profit_u, 2) : "—"; + const riskU = lv.risk_u != null && lv.risk_u !== "" ? fmt(lv.risk_u, 2) : "—"; + const rr = lv.rr != null && lv.rr !== "" ? `${fmt(lv.rr, 2)}:1` : "—"; + const stCls = lv.status === "done" ? "st-done" : "st-pending"; + const label = lv.status_label || (lv.status === "done" ? "已补仓" : "待补仓"); + return ` + ${esc(lv.label || lv.leg_key || "—")} + ${esc(price)} + ${amt} + ${esc(avg)} + ${esc(profitU)} + ${esc(riskU)} + ${esc(rr)} + ${esc(label)} + `; + }) + .join(""); + return `
+
补仓计划明细
+ + + ${rows} +
档位触发价张数加仓后均价止盈盈利(U)止损(U)盈亏比状态
+
`; + } + + function renderTrendPlanCard(t, tickMap, pos, exchangeRow) { + const sym = t.exchange_symbol || t.symbol || ""; + const side = (t.direction || "long").toLowerCase(); + const sl = t.stop_loss_display || fmtSymbolPrice(t.stop_loss, sym, tickMap); + const tp = t.take_profit_display || fmtSymbolPrice(t.take_profit, sym, tickMap); + const avg = t.avg_entry_price_display || fmtSymbolPrice(t.avg_entry_price, sym, tickMap); + const addZone = + t.add_upper_display || fmtSymbolPrice(t.add_upper, sym, tickMap) || "—"; + const rr = resolveTrendPlanRr(t, side, t.avg_entry_price, t.stop_loss, t.take_profit); + const rrTxt = rr != null ? `${fmt(rr, 2)}:1` : "—"; + const mark = resolveTrendMarkPrice(pos, t, sym, tickMap); + const legsDone = t.add_count != null ? t.add_count : t.legs_done; + const legsTotal = t.add_count_total != null ? t.add_count_total : t.dca_legs; + const legsTxt = + legsDone != null && legsTotal != null + ? `${esc(legsDone)}/${esc(legsTotal)}` + : legsDone != null + ? esc(legsDone) + : "—"; + const upnlTrend = resolveTrendFloatingPnl(pos, t); + const pnlFmt = formatTrendPlanFloatingPnl(upnlTrend, t.plan_margin_capital); + const pnlVal = + pnlFmt.text === "—" + ? "—" + : `${esc(pnlFmt.text)}`; + const riskTxt = + t.risk_percent != null && t.risk_percent !== "" ? `${esc(t.risk_percent)}%` : "—"; + const snapTxt = + t.snapshot_available_usdt != null && t.snapshot_available_usdt !== "" + ? `${fmt(t.snapshot_available_usdt, 2)}U` + : "—"; + const marginTxt = + t.plan_margin_capital != null && t.plan_margin_capital !== "" + ? `≈${fmt(t.plan_margin_capital, 2)}U` + : "—"; + const levTxt = t.leverage != null && t.leverage !== "" ? `${esc(t.leverage)}x` : "—"; + const bePctDefault = + t.breakeven_default_offset_pct != null && t.breakeven_default_offset_pct !== "" + ? t.breakeven_default_offset_pct + : t.breakeven_offset_pct != null && t.breakeven_offset_pct !== "" + ? t.breakeven_offset_pct + : "0.3"; + const exId = exchangeRow && exchangeRow.id != null ? esc(exchangeRow.id) : ""; + const planId = esc(t.id); + const caps = (exchangeRow && exchangeRow.capabilities) || []; + const flaskOk = + exchangeRow && exchangeRow.flask_ok !== false && (exchangeRow.hub_monitor || {}).ok !== false; + const canHubTrend = !!(flaskOk && caps.includes("trend") && exId && planId); + const beAppliedFlag = !!t.breakeven_applied; + const endBtn = canHubTrend + ? `` + : ""; + const beBtn = canHubTrend && !beAppliedFlag + ? `` + : beAppliedFlag + ? "" + : `保本移交下单监控`; + const beApplied = + t.breakeven_applied + ? `已保本 ${esc(String(t.breakeven_applied_at || "").slice(0, 16))}` + : ""; + const dcaHtml = renderTrendDcaTable(t, tickMap); + const dcaCol = dcaHtml + ? `
${dcaHtml}
` + : `
补仓计划明细
暂无补仓档位
`; + return `
+
+
+ #${esc(t.id)} ${esc(sym)} + ${renderDirectionBadge(t.direction)} +
+ ${endBtn} +
+
+
+
+ 来源: 趋势回调计划 | 风险: ${riskTxt} + | ${esc(trendAddZoneLabel(t.direction))} ${esc(addZone)} + | 已补仓 ${legsTxt} +
+
+
均价${esc(avg)}
+
止损${esc(sl)}
+
止盈${esc(tp)}
+
盈亏比${esc(rrTxt)}
+
标记价${esc(mark)}
+
浮盈亏${pnlVal}
+
+
+ ${dcaCol} +
+
+
+ + ${beBtn} + ${beApplied} +
+ +
+
`; + } + + function renderTrendSection(trends, tickMap, positions, exchangeRow) { + if (!trends || !trends.length) return ""; + const posList = Array.isArray(positions) ? positions : []; + const cards = trends + .map((t) => { + const sym = t.exchange_symbol || t.symbol || ""; + const side = (t.direction || "long").toLowerCase(); + let matched = null; + for (const p of posList) { + if (!symbolsMatchHub(p.symbol, sym)) continue; + const ps = (p.side || "").toLowerCase(); + if (!ps || ps === side) { + matched = p; + break; + } + } + return renderTrendPlanCard(t, tickMap, matched, exchangeRow); + }) + .join(""); + return `
+
运行中的计划
+
${cards}
+
`; + } + + function renderLivePositionCard(exchangeId, exchangeKey, pos, monitorOrder, trendPlan, tickMap) { + const symbol = pos.symbol || ""; + const exKeyAttr = esc(exchangeKey || exchangeId || "").replace(/"/g, """); + const side = (pos.side || "long").toLowerCase(); + const sideCn = sideDirLabel(side); + const sideCls = sideDirCls(side) || "side-long"; + const mo = monitorOrder || {}; + const cond = condOrdersFromPosition(pos); + const reg = Array.isArray(pos.regular_orders) ? pos.regular_orders : []; + const tpsl = resolvePositionTpsl(pos, mo, trendPlan); + const symAttr = esc(symbol).replace(/"/g, """); + const sideAttr = esc(side).replace(/"/g, """); + const contractsAttr = esc(String(pos.contracts != null ? pos.contracts : "")).replace(/"/g, """); + const slAttr = esc(String(tpsl.sl)).replace(/"/g, """); + const tpAttr = esc(String(tpsl.tp)).replace(/"/g, """); + const entry = tpsl.entry; + const sl = tpsl.sl; + const tp = tpsl.tp; + const tpMonitored = tpsl.tp_monitored; + const isTrend = isTrendContext(mo, trendPlan); + const rr = resolveSnapshotRr(mo, side, entry, sl, tp, tpMonitored, trendPlan); + const beSecured = isBreakevenSecured(side, entry, mo, cond, pos); + const upnl = resolveTrendFloatingPnl(pos, trendPlan); + const pnlFmt = formatFloatingPnlText(upnl, pos.notional_usdt); + const pnlText = pnlFmt.text; + const sizingFoot = resolveTrendSizingFooter(mo, trendPlan, isTrend, pos); + const openMeta = resolvePositionOpenMeta(mo, trendPlan, isTrend); + const marginText = + sizingFoot.margin != null && sizingFoot.margin !== "" && Number.isFinite(Number(sizingFoot.margin)) + ? fmt(Number(sizingFoot.margin), 2) + "U" + : "—"; + const holdMsAttr = + openMeta.openedAtMs != null && Number.isFinite(openMeta.openedAtMs) + ? String(openMeta.openedAtMs) + : ""; + const markDisplay = isTrend + ? resolveTrendMarkPrice(pos, trendPlan, symbol, tickMap) + : fmtMarkPrice(pos, tickMap); + const meta = []; + if (isTrend) { + meta.push(monitorOrderSourceHtml(mo, trendPlan)); + const riskLine = formatMonitorRiskMeta(mo, trendPlan); + if (riskLine) meta.push(riskLine); + const latestRiskLine = formatLatestRiskMeta(mo, trendPlan, pos, tpsl); + if (latestRiskLine) meta.push(latestRiskLine); + if (trendPlan && trendPlan.id) { + const zone = + trendPlan.add_upper_display || + fmtSymbolPrice(trendPlan.add_upper, symbol, tickMap) || + "—"; + meta.push( + `${esc(trendAddZoneLabel(trendPlan.direction))} ${esc(zone)}` + ); + const addSum = trendAddSummaryHtml(trendPlan, tickMap); + if (addSum) meta.push(addSum.replace(/^ · /, "")); + } + meta.push(`移动保本:关`); + } else if (mo.monitor_type || mo.key_signal_type || mo.trend_plan_id) { + meta.push(monitorOrderSourceHtml(mo, trendPlan)); + if (mo.trade_style) meta.push(`风格: ${esc(mo.trade_style)}`); + else meta.push("风格: —"); + const riskLine = formatMonitorRiskMeta(mo, trendPlan); + if (riskLine) meta.push(riskLine); + const latestRiskLine = formatLatestRiskMeta(mo, trendPlan, pos, tpsl); + if (latestRiskLine) meta.push(latestRiskLine); + const beOn = mo.breakeven_enabled === 1 || mo.breakeven_enabled === true; + meta.push( + `移动保本:${beOn ? "开" : "关"}` + ); + } else { + meta.push("来源: 交易所持仓"); + meta.push("风格: —"); + meta.push(`移动保本:关`); + } + const symBeBadge = beSecured ? ` ${breakevenBadgeHtml()}` : ""; + const tcSymBadge = !isTrend && mo.time_close_enabled ? timeCloseSymbolBadgeHtml(mo) : ""; + const mktAttrs = marketOpenBtnAttrs(exchangeId, exchangeKey, symbol, pos, monitorOrder, trendPlan); + return `
+
+
+ ${tcSymBadge}${symBeBadge} + ${sideCn} +
+
+ + +
+
+
${meta.map((m) => `${m}`).join("")}
+
+
开仓价${fmtEntryPrice(pos, tickMap)}
+
标记价${markDisplay}
+
止损${sl != null && sl !== "" ? fmtSymbolPrice(sl, symbol, tickMap) : "—"}
+
止盈${formatTpCellValue(tp, tpMonitored, symbol, tickMap)}
+
盈亏比${rr != null ? fmt(rr, 2) + ":1" : "—"}
+
张数${fmt(pos.contracts, 4)}
+ ${ + showAccountPnlPref() + ? `
浮盈亏${pnlText}
` + : "" + } +
+ +
+
交易所止盈止损
+ ${renderExTpslRows(exchangeId, symbol, cond, tickMap, tpsl, pos.contracts)} +
+ ${renderOrdersCollapse(exchangeId, symbol, cond, reg, tickMap)} +
`; + } + + function renderHubSectionCard(title, bodyHtml, emptyHint) { + const inner = bodyHtml || `
${esc(emptyHint || "暂无")}
`; + return `
+
${esc(title)}
+
${inner}
+
`; + } + + function renderKeySection(keys, kmap) { + if (!keys.length) return ""; + const cards = keys + .map((k) => { + const kp = kmap[k.id] || kmap[String(k.id)] || {}; + const mt = k.monitor_type || k.type || ""; + const pending = keyHasPendingOrder(k, kp); + const cardCls = pending ? "hub-mini-card hub-key-pending" : "hub-mini-card"; + const dir = k.direction ? ` · ${renderDirectionHtml(k.direction)}` : ""; + const pendingTag = pending + ? `挂单中` + : ""; + const amtTxt = fmtKeyOrderAmount(k); + const amtLine = amtTxt + ? `
挂单数量 ${esc(amtTxt)}
` + : ""; + const keyTc = + k.time_close_enabled && k.time_close_at_ms + ? timeCloseSymbolBadgeHtml(k) + : k.time_close_enabled && k.time_close_hours + ? `时间平仓 ${esc(k.time_close_hours)}h` + : ""; + return `
+
${esc(k.symbol)} ${keyTc} · ${esc(mt)}${dir} ${pendingTag}
+
上沿 ${esc(k.upper)} / 下沿 ${esc(k.lower)}
+ ${amtLine} +
${esc(kp.gate_summary || kp.price_display || kp.price || "—")}${kp.gate_metrics ? ` · ${esc(kp.gate_metrics)}` : ""}
+
`; + }) + .join(""); + return `
${cards}
`; + } + + function renderOrderMonitorSection(orders, tickMap) { + if (!orders || !orders.length) return ""; + return orders + .map((o) => { + const sym = o.exchange_symbol || o.symbol || ""; + const tcBadge = o.time_close_enabled ? timeCloseSymbolBadgeHtml(o) : ""; + return `
+
#${esc(o.id)} · ${esc(o.symbol || o.exchange_symbol)} ${tcBadge} · ${renderDirectionHtml(o.direction)}
+
触发 ${fmtSymbolPrice(o.trigger_price, sym, tickMap)} · SL ${fmtSymbolPrice(o.stop_loss, sym, tickMap)} · TP ${fmtSymbolPrice(o.take_profit, sym, tickMap)} · ${esc(o.trade_style || o.monitor_type || "下单监控")}
+
`; + }) + .join(""); + } + + function renderRollSection(rolls, tickMap) { + if (!rolls || !rolls.length) return ""; + return rolls + .map((g) => { + const sym = g.symbol || g.exchange_symbol || ""; + const avg = + g.avg_entry_display || fmtSymbolPrice(g.avg_entry, sym, tickMap) || "—"; + const tpProfit = + g.reward_at_tp_usdt != null && g.reward_at_tp_usdt !== "" + ? `${fmt(g.reward_at_tp_usdt, 2)}U` + : "—"; + const legs = Array.isArray(g.recent_legs) ? g.recent_legs : []; + const legRows = legs + .map((leg) => { + const legAvg = + leg.avg_entry_display || + fmtSymbolPrice(leg.avg_entry_after, sym, tickMap) || + "—"; + const legProfit = + leg.reward_at_tp_usdt != null && leg.reward_at_tp_usdt !== "" + ? `${fmt(leg.reward_at_tp_usdt, 2)}U` + : "—"; + return `
腿 #${esc(leg.leg_index)} ${esc(leg.add_mode || "")} · 张 ${esc(leg.amount != null ? leg.amount : "—")} · 均价 ${legAvg} · 止盈 ${legProfit}
`; + }) + .join(""); + return `
+
组 #${esc(g.id)} · ${esc(g.symbol || "")} ${renderDirectionHtml(g.direction)} · 监控 #${esc(g.order_monitor_id || "—")}
+
腿数 ${esc(g.leg_count != null ? g.leg_count : "—")} · SL ${fmtSymbolPrice(g.current_stop_loss, sym, tickMap)} · 首仓TP ${fmtSymbolPrice(g.initial_take_profit, sym, tickMap)}
+
当前均价 ${avg} · 止盈盈利 ${tpProfit}
+ ${legRows} +
`; + }) + .join(""); + } + + function renderPositionTableRow( + exchangeId, + exchangeKey, + x, + monitorOrder, + trendPlan, + tickMap, + opts + ) { + const options = opts || {}; + const symAttr = esc(x.symbol || "").replace(/"/g, """); + const sideAttr = esc((x.side || "").toLowerCase()).replace(/"/g, """); + const side = sideAttr || "long"; + const contractsAttr = esc(String(x.contracts != null ? x.contracts : "")).replace( + /"/g, + """ + ); + const cond = condOrdersFromPosition(x); + const tpsl = resolvePositionTpsl(x, monitorOrder, trendPlan); + const beSecured = isBreakevenSecured(side, tpsl.entry, monitorOrder, cond, x); + const slAttr = esc(String(tpsl.sl)).replace(/"/g, """); + const tpAttr = esc(String(tpsl.tp)).replace(/"/g, """); + const mktAttrs = marketOpenBtnAttrs(exchangeId, exchangeKey, x.symbol, x, monitorOrder, trendPlan); + const symBeBadge = beSecured ? ` ${breakevenBadgeHtml()}` : ""; + const mo = monitorOrder || {}; + const tcBadge = + !isTrendContext(mo, trendPlan) && mo.time_close_enabled ? timeCloseSymbolBadgeHtml(mo) : ""; + const actionCell = `
+ + +
`; + const pnlTd = showAccountPnlPref() + ? `${fmt(x.unrealized_pnl, 2)}` + : ""; + return ` + ${tcBadge}${symBeBadge} + ${renderDirectionHtml(x.side)} + ${fmtEntryPrice(x, tickMap)} + ${fmtMarkPrice(x, tickMap)} + ${fmt(x.contracts, 4)} + ${pnlTd} + ${actionCell} + `; + } + + function renderPositionBlock(exchangeId, exchangeKey, x, monitorOrder, trendPlan, tickMap, opts) { + const options = opts || {}; + const compact = !!options.compact; + const reg = Array.isArray(x.regular_orders) ? x.regular_orders : []; + const cond = condOrdersFromPosition(x); + const ordersBlock = compact + ? "" + : renderOrdersCollapse(exchangeId, x.symbol, cond, reg, tickMap); + const rowHtml = renderPositionTableRow( + exchangeId, + exchangeKey, + x, + monitorOrder, + trendPlan, + tickMap, + opts + ); + return `
+
+ ${positionTableHeadHtml(false)} + ${rowHtml} + +
+ ${ordersBlock} +
`; + } + + const KEY_BUCKET_FIB_TYPES = new Set([ + "斐波回调0.618", + "斐波回调0.786", + "关键位斐波0.618", + "关键位斐波0.786", + ]); + const KEY_BUCKET_BREAKOUT_TYPES = new Set([ + "箱体突破", + "收敛突破", + "关键位箱体突破", + "关键位收敛突破", + "关键位收敛结构", + ]); + const KEY_BUCKET_WATCH_TYPES = new Set([ + "关键支撑阻力", + "关键阻力位", + "关键支撑位", + "关键位监控", + ]); + + function classifyKeyMonitorBucket(monitorType) { + const t = String(monitorType || "").trim(); + if (!t) return "watch"; + if (KEY_BUCKET_FIB_TYPES.has(t) || /斐波/.test(t)) return "fib"; + if (KEY_BUCKET_BREAKOUT_TYPES.has(t) || /突破/.test(t)) return "breakout"; + if (KEY_BUCKET_WATCH_TYPES.has(t) || /阻力|支撑/.test(t)) return "watch"; + return "watch"; + } + + function countKeyMonitorsByBucket(keys) { + const counts = { breakout: 0, fib: 0, watch: 0 }; + (keys || []).forEach((k) => { + if (!k || typeof k !== "object") return; + const bucket = classifyKeyMonitorBucket(k.monitor_type || k.type); + if (bucket === "breakout") counts.breakout += 1; + else if (bucket === "fib") counts.fib += 1; + else counts.watch += 1; + }); + return counts; + } + + function renderCardStrategyStats(row, hm, flaskOk) { + if (!flaskOk || !hm || typeof hm !== "object") return ""; + const caps = row.capabilities || []; + const chips = []; + if (caps.includes("key")) { + const kc = countKeyMonitorsByBucket(hm.keys || []); + if (kc.breakout > 0) chips.push({ kind: "key-breakout", label: `突破 ${kc.breakout}` }); + if (kc.fib > 0) chips.push({ kind: "key-breakout", label: `斐波 ${kc.fib}` }); + if (kc.watch > 0) chips.push({ kind: "key-watch", label: `监控 ${kc.watch}` }); + } + if (caps.includes("trend")) { + const trendN = Array.isArray(hm.trends) ? hm.trends.length : 0; + if (trendN > 0) chips.push({ kind: "trend", label: `趋势回调 ${trendN}` }); + } + const rollN = Array.isArray(hm.rolls) ? hm.rolls.length : 0; + if (rollN > 0) chips.push({ kind: "roll", label: `顺势加仓 ${rollN}` }); + if (!chips.length) return ""; + return `
${chips + .map( + (c) => + `${esc(c.label)}` + ) + .join("")}
`; + } + + function renderGridPositionsTable(exchangeId, exchangeKey, positions, orders, trends, tickMap) { + const rows = positions + .map((p) => + renderPositionTableRow( + exchangeId, + exchangeKey, + p, + findMonitorOrder(orders, p.symbol, p.side), + findTrendPlan(trends, p.symbol, p.side), + tickMap, + { compact: true } + ) + ) + .join(""); + return `
+ ${positionTableHeadHtml(true)} + ${rows} + +
`; + } + + function renderAccountStatRow(row, ag) { + if (!showAccountPnlPref()) return ""; + const upnl = ag.total_unrealized_pnl; + return `
+
资金账户
${fmt(row.funding_usdt, 2)} U
+
交易账户
${fmt(row.trading_usdt, 2)} U
+
浮盈合计
${fmt(upnl, 2)}
+
`; + } + + function renderGridBody(row, ag, pos, hm, flaskOk, keys, orders, trends, rolls, kmap) { + const tickMap = buildPriceTickMap(row); + let inner = renderAccountStatRow(row, ag); + inner += `
交易所持仓 · ${pos.length} 仓
`; + if (pos.length) { + inner += renderGridPositionsTable( + row.id, + row.key || row.id, + pos, + orders, + trends, + tickMap + ); + } else { + inner += '
无持仓
'; + } + inner += renderCardStrategyStats(row, hm, flaskOk); + inner += `
点击标题栏进入全屏 · 委托 / 关键位 / 下单监控 / 趋势回调 / 顺势加仓
`; + return inner; + } + + function renderFullscreenExchange(row) { + const tickMap = buildPriceTickMap(row); + const ag = row.agent || {}; + const pos = Array.isArray(ag.positions) ? ag.positions : []; + const hm = row.hub_monitor || {}; + const flaskOk = row.flask_ok !== false && hm.ok !== false; + const keys = flaskOk ? hm.keys || [] : []; + const orders = flaskOk ? hm.orders || [] : []; + const trends = flaskOk ? hm.trends || [] : []; + const rolls = flaskOk ? hm.rolls || [] : []; + const kmap = {}; + (row.key_prices || []).forEach((k) => { + kmap[k.id] = k; + }); + const flaskOpen = row.flask_url_browser || row.flask_url; + let html = `
+
+

${esc(row.name)}

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

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

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

${hint}

`; + return; + } + const sessionId = activeSession && activeSession.id ? String(activeSession.id) : "local"; + let html = msgs + .map((m, idx) => { + const role = m.role === "user" ? "user" : m.role === "system" ? "system" : "assistant"; + return renderAiChatRow( + role, + m.content || "", + m.level === "warn" ? "ai-bubble-warn" : null, + m.attachments, + { botMode, msgIdx: idx, cacheKey: sessionId + ":" + idx } + ); + }) + .join(""); + if (options.pendingUser) { + html += renderAiChatRow("user", options.pendingUser, null, options.pendingAttachments); + } + if (options.thinking) { + html += renderAiChatRow("assistant", "正在思考…", "ai-bubble-thinking"); + } + box.innerHTML = html; + scrollAiChatToEnd(); + } + + function setAiChatBusy(busy) { + aiChatLoading = !!busy; + const btn = document.getElementById("btn-ai-chat-send"); + const input = document.getElementById("ai-chat-input"); + if (btn) btn.disabled = busy; + if (input) input.disabled = busy; + document.querySelectorAll(".ai-chat-pending-del").forEach((el) => { + el.disabled = busy; + }); + } + + async function loadAiSupervisorSession() { + const r = await apiFetch("/api/ai/supervisor/session"); + const j = await r.json(); + aiSupervisorSessionCache = j.session || null; + if (isSupervisorMode()) { + renderAiChatMessages(aiSupervisorSessionCache); + } + updateAiBotTabs("supervisor"); + return j; + } + + async function switchToSupervisorMode() { + updateAiBotTabs("supervisor"); + if (isMobileAiLayout()) { + localStorage.setItem(AI_MOBILE_TAB_KEY, "supervisor"); + applyAiMobileTab("supervisor"); + } + try { + await loadAiSupervisorSession(); + connectSupervisorStream(); + scrollAiChatToEnd(); + } catch (e) { + showToast(String(e), true); + } + } + + function closeSupervisorStream() { + if (supervisorEventSource) { + supervisorEventSource.close(); + supervisorEventSource = null; + } + if (supervisorReconnectTimer) { + clearTimeout(supervisorReconnectTimer); + supervisorReconnectTimer = null; + } + } + + function connectSupervisorStream() { + closeSupervisorStream(); + if (currentPage() !== "ai" || !isSupervisorMode()) return; + supervisorEventSource = new EventSource("/api/ai/supervisor/stream"); + supervisorEventSource.addEventListener("supervisor", (ev) => { + try { + const st = JSON.parse(ev.data || "{}"); + const ver = Number(st.supervisor_version) || 0; + if (ver !== localSupervisorVersion) { + localSupervisorVersion = ver; + void loadAiSupervisorSession(); + } + } catch (_) {} + }); + supervisorEventSource.onerror = () => { + closeSupervisorStream(); + if (supervisorReconnectTimer) clearTimeout(supervisorReconnectTimer); + supervisorReconnectTimer = setTimeout(() => { + if (currentPage() === "ai" && isSupervisorMode()) connectSupervisorStream(); + }, 8000); + }; + } + + async function loadAiChatSession() { + const r = await apiFetch("/api/ai/chat/session"); + const j = await r.json(); + aiChatSessionCache = j.session || null; + aiChatSessionsCache = j.sessions || []; + renderAiChatMessages(aiChatSessionCache); + renderAiChatHistory(aiChatSessionsCache); + updateAiBotTabs((aiChatSessionCache && aiChatSessionCache.bot_mode) || aiSelectedBotMode); + } + + async function switchAiChatSession(sessionId) { + if (!sessionId || aiChatLoading) return; + try { + const r = await apiFetch("/api/ai/chat/switch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_id: sessionId }), + }); + const j = await r.json(); + if (!r.ok) throw new Error(j.detail || j.msg || "切换失败"); + aiChatSessionCache = j.session || null; + aiChatSessionsCache = j.sessions || []; + renderAiChatMessages(aiChatSessionCache); + renderAiChatHistory(aiChatSessionsCache); + const mode = + (aiChatSessionCache && aiChatSessionCache.bot_mode) === "general" ? "general" : "trading"; + updateAiBotTabs(mode); + if (isMobileAiLayout()) { + localStorage.setItem(AI_MOBILE_TAB_KEY, mode); + applyAiMobileTab(mode); + } + scrollAiChatToEnd(); + } catch (e) { + showToast(String(e), true); + } + } + + async function deleteAiChatSession(sessionId) { + if (!sessionId) return; + if (!confirm("确定删除这条聊天历史?")) return; + try { + const r = await apiFetch(`/api/ai/chat/session/${encodeURIComponent(sessionId)}`, { + method: "DELETE", + }); + const j = await r.json(); + if (!r.ok) throw new Error(j.detail || j.msg || "删除失败"); + aiChatSessionCache = j.session || null; + aiChatSessionsCache = j.sessions || []; + renderAiChatMessages(aiChatSessionCache); + renderAiChatHistory(aiChatSessionsCache); + updateAiBotTabs( + (aiChatSessionCache && aiChatSessionCache.bot_mode) || aiSelectedBotMode || "trading" + ); + showToast("已删除"); + } catch (e) { + showToast(String(e), true); + } + } + + const ARCHIVE_QUOTE_AI_KEY = "hub_archive_quote_ai"; + let archiveQuoteAiPending = false; + + async function consumeArchiveQuoteAiPending() { + if (archiveQuoteAiPending || aiChatLoading) return; + let raw = ""; + try { + raw = sessionStorage.getItem(ARCHIVE_QUOTE_AI_KEY) || ""; + } catch (_) { + return; + } + if (!raw) return; + sessionStorage.removeItem(ARCHIVE_QUOTE_AI_KEY); + let payload; + try { + payload = JSON.parse(raw); + } catch (_) { + return; + } + const content = String((payload && payload.content) || "").trim(); + const quoteDate = String((payload && payload.quote_date) || "").trim(); + if (!content) return; + + const input = document.getElementById("ai-chat-input"); + if (input) input.value = content; + updateAiBotTabs("trading"); + if (isMobileAiLayout()) { + localStorage.setItem(AI_MOBILE_TAB_KEY, "trading"); + applyAiMobileTab("trading"); + } + + archiveQuoteAiPending = true; + setAiChatBusy(true); + renderAiChatMessages(aiChatSessionCache, { + pendingUser: content, + thinking: true, + }); + try { + const r = await apiFetch("/api/ai/chat/archive-quote", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ quote_date: quoteDate, content }), + }); + const j = await r.json(); + if (!r.ok) throw new Error(j.detail || j.msg || "发送失败"); + aiChatSessionCache = j.session || null; + aiChatSessionsCache = j.sessions || aiChatSessionsCache; + renderAiChatMessages(aiChatSessionCache); + renderAiChatHistory(aiChatSessionsCache); + if (input) input.value = ""; + showToast("复盘语录已发送给交易教练"); + } catch (e) { + showToast(String(e), true); + if (input) input.value = content; + try { + await loadAiChatSession(); + } catch (_) { + renderAiChatMessages(aiChatSessionCache); + } + } finally { + archiveQuoteAiPending = false; + setAiChatBusy(false); + } + } + + async function loadAiPage() { + applyAiMobileTab(); + const params = new URLSearchParams(window.location.search || ""); + const modeParam = (params.get("mode") || "").trim().toLowerCase(); + if (modeParam === "supervisor") { + await switchToSupervisorMode(); + } else { + closeSupervisorStream(); + await loadAiChatSession(); + await consumeArchiveQuoteAiPending(); + } + const mobTab = normalizeAiMobileTab(localStorage.getItem(AI_MOBILE_TAB_KEY) || "trading"); + if (isMobileAiLayout() && AI_MOBILE_CHAT_TABS.has(mobTab)) { + const input = document.getElementById("ai-chat-input"); + if (input && !aiChatLoading) { + setTimeout(() => input.focus(), 80); + } + } + } + + async function newAiChat(botMode) { + const mode = normalizeAiBotMode(botMode); + if (mode !== "supervisor") closeSupervisorStream(); + try { + const r = await apiFetch("/api/ai/chat/new", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ bot_mode: mode }), + }); + const j = await r.json(); + aiChatSessionCache = j.session || null; + aiChatSessionsCache = j.sessions || []; + renderAiChatMessages(aiChatSessionCache); + renderAiChatHistory(aiChatSessionsCache); + updateAiBotTabs(mode); + if (isMobileAiLayout()) { + localStorage.setItem(AI_MOBILE_TAB_KEY, mode); + applyAiMobileTab(mode); + } + showToast( + mode === "general" + ? "已开始普通聊天" + : mode === "supervisor" + ? "已打开今日监管" + : "已开始交易教练对话" + ); + } catch (e) { + showToast(String(e), true); + } + } + + async function sendAiChat(ev) { + if (ev) ev.preventDefault(); + if (aiChatLoading) return; + const input = document.getElementById("ai-chat-input"); + const text = (input && input.value || "").trim(); + if (isSupervisorMode()) { + if (!text) return; + const savedText = text; + if (input) input.value = ""; + setAiChatBusy(true); + renderAiChatMessages(aiSupervisorSessionCache, { + pendingUser: text, + thinking: true, + }); + try { + const r = await apiFetch("/api/ai/supervisor/chat/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: text }), + }); + const j = await r.json(); + if (!r.ok) throw new Error(j.detail || j.msg || "发送失败"); + aiSupervisorSessionCache = j.session || null; + renderAiChatMessages(aiSupervisorSessionCache); + } catch (e) { + showToast(String(e), true); + if (input && savedText) input.value = savedText; + try { + await loadAiSupervisorSession(); + } catch (_) { + renderAiChatMessages(aiSupervisorSessionCache); + } + } finally { + setAiChatBusy(false); + } + return; + } + const files = aiChatPendingFiles.slice(); + if (!text && !files.length) return; + const pendingAttachments = files.map((f) => ({ + name: f.name, + kind: aiChatFileKind(f), + })); + const savedText = text; + if (input) input.value = ""; + setAiChatBusy(true); + renderAiChatMessages(aiChatSessionCache, { + pendingUser: text || (files.length ? `(上传 ${files.length} 个附件)` : ""), + pendingAttachments, + thinking: true, + }); + try { + const fd = new FormData(); + fd.append("message", text); + files.forEach((f) => fd.append("files", f, f.name)); + const r = await apiFetch("/api/ai/chat/send", { method: "POST", body: fd }); + const j = await r.json(); + if (!r.ok) throw new Error(j.detail || j.msg || "发送失败"); + aiChatSessionCache = j.session || null; + aiChatSessionsCache = j.sessions || aiChatSessionsCache; + renderAiChatMessages(aiChatSessionCache); + renderAiChatHistory(aiChatSessionsCache); + clearAiChatPendingFiles(); + if (j.attachment_warnings && j.attachment_warnings.length) { + showToast(j.attachment_warnings.join(";"), true); + } + } catch (e) { + showToast(String(e), true); + if (input && savedText) input.value = savedText; + try { + await loadAiChatSession(); + } catch (_) { + renderAiChatMessages(aiChatSessionCache); + } + } finally { + setAiChatBusy(false); + } + } + + const aiChatFiles = document.getElementById("ai-chat-files"); + if (aiChatFiles) { + aiChatFiles.addEventListener("change", () => { + const picked = aiChatFiles.files ? Array.from(aiChatFiles.files) : []; + addAiChatPendingFiles(picked); + aiChatFiles.value = ""; + }); + } + const aiChatInput = document.getElementById("ai-chat-input"); + if (aiChatInput) { + aiChatInput.addEventListener("paste", handleAiChatPaste); + } + const aiChatPending = document.getElementById("ai-chat-pending"); + if (aiChatPending) { + aiChatPending.addEventListener("click", (ev) => { + const btn = ev.target.closest("[data-pending-del]"); + if (!btn || aiChatLoading) return; + ev.preventDefault(); + const idx = Number(btn.getAttribute("data-pending-del")); + if (!Number.isNaN(idx)) removeAiChatPendingFile(idx); + }); + } + + const aiChatNewBtn = document.getElementById("btn-ai-chat-new"); + if (aiChatNewBtn) aiChatNewBtn.onclick = () => newAiChat(aiSelectedBotMode); + const aiChatForm = document.getElementById("ai-chat-form"); + if (aiChatForm) aiChatForm.addEventListener("submit", sendAiChat); + + function initAiChatInteractions() { + const hist = document.getElementById("ai-chat-history-list"); + if (hist && !hist._aiBound) { + hist._aiBound = true; + hist.addEventListener("click", (ev) => { + const delBtn = ev.target.closest(".ai-chat-history-del"); + if (delBtn) { + ev.stopPropagation(); + const sid = delBtn.getAttribute("data-delete-session"); + if (sid) deleteAiChatSession(sid); + return; + } + const item = ev.target.closest(".ai-chat-history-item"); + if (!item) return; + const sid = item.getAttribute("data-session-id"); + if (sid) switchAiChatSession(sid); + }); + } + const box = document.getElementById("ai-chat-messages"); + if (box && !box._aiCopyBound) { + box._aiCopyBound = true; + box.addEventListener("click", async (ev) => { + const btn = ev.target.closest(".ai-msg-copy-btn"); + if (!btn) return; + const idx = Number(btn.getAttribute("data-msg-idx")); + const msgs = (aiChatSessionCache && aiChatSessionCache.messages) || []; + const text = msgs[idx] && msgs[idx].content ? String(msgs[idx].content) : ""; + if (!text) return; + try { + await navigator.clipboard.writeText(text); + showToast("已复制"); + } catch (_) { + showToast("复制失败", true); + } + }); + } + document.querySelectorAll(".ai-bot-tab").forEach((btn) => { + if (btn._aiBotBound) return; + btn._aiBotBound = true; + btn.addEventListener("click", () => { + const mode = normalizeAiBotMode(btn.getAttribute("data-bot") || "trading"); + if (mode === "supervisor") { + void switchToSupervisorMode(); + return; + } + closeSupervisorStream(); + newAiChat(mode); + }); + }); + } + initAiChatInteractions(); + + initTpslModal(); + initInstanceFrame(); + initFullscreen(); + initMobileLayout(); + if (globalThis.HubTheme && typeof HubTheme.initToggleUI === "function") { + HubTheme.initToggleUI(); + } + + function initShellNav() { + document.querySelectorAll(".top-nav a[href^='/']").forEach((a) => { + a.addEventListener("click", (ev) => { + const href = a.getAttribute("href"); + if (!href || ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) return; + ev.preventDefault(); + const path = href.split("?")[0]; + if (path === window.location.pathname) { + setActiveNav(); + return; + } + history.pushState({}, "", href); + setActiveNav(); + }); + }); + window.addEventListener("popstate", setActiveNav); + } + + window.hubNavigateTo = function hubNavigateTo(path) { + const href = String(path || "/").split("?")[0] || "/"; + if (href === window.location.pathname) { + setActiveNav(); + return; + } + history.pushState({}, "", href); + setActiveNav(); + }; + + window.hubOpenMonitorExpand = function hubOpenMonitorExpand(exId) { + const id = String(exId || "").trim(); + if (!id) return; + expandedExchangeId = id; + sessionStorage.setItem("hub_expanded_ex", id); + if (currentPage() !== "monitor") { + history.pushState({}, "", "/monitor"); + setActiveNav(); + } + if (lastMonitorRows.length) { + openExchangeFullscreen(id); + } else { + void fetchMonitorBoardSnapshot({ showLoading: true }); + } + }; + + initAuth().then((ok) => { + if (!ok) return; + initShellNav(); + loadSettings() + .then((data) => { + syncDisplayPrefsUI(data); + }) + .catch(() => {}) + .finally(() => { + setActiveNav(); + }); + }); + if (window.AccountRiskBadge) AccountRiskBadge.startTicker(); +})(); diff --git a/manual_trading_hub/static/chart.js b/manual_trading_hub/static/chart.js index 1cc2bad..b71f31f 100644 --- a/manual_trading_hub/static/chart.js +++ b/manual_trading_hub/static/chart.js @@ -1,3386 +1,3386 @@ -/** - * 中控行情区:K 线 + 成交量;Hub 后台轮询 + SSE 直推尾部 K 线;「自动」控制价格轴与视口跟随。 - */ -(function () { - const CHART_WATCH_HEARTBEAT_MS = 25000; - const CHART_SSE_FALLBACK_MS = 15000; - const DEFAULT_VISIBLE_BARS = 200; - const CHART_LOAD_LEFT_THRESHOLD = 25; - const CHART_INITIAL_LIMITS = { - "1m": 2000, - "5m": 2000, - "15m": 2000, - "1h": 1000, - "2h": 1000, - "4h": 1000, - "1d": 500, - "1w": 500, - }; - const CHART_CHUNK_LIMITS = { - "1m": 500, - "5m": 500, - "15m": 500, - "1h": 300, - "2h": 300, - "4h": 300, - "1d": 200, - "1w": 150, - }; - const CHART_MEMORY_CAPS = { - "1m": 5000, - "5m": 5000, - "15m": 5000, - "1h": 1000, - "2h": 1000, - "4h": 1000, - "1d": 1000, - "1w": 500, - }; - const RIGHT_OFFSET_BARS = 10; - const CANDLE_SCALE_BOTTOM = 0.26; - const VOLUME_SCALE_TOP = 0.73; - const VOLUME_SCALE_BOTTOM = 0.06; - const PANEL_VOL_H = 0.12; - const PANEL_MACD_H = 0.14; - const PANEL_RSI_H = 0.14; - const SWING_LOOKBACK = 4; - const MAX_DIV_MARKERS = 4; - const TF_MS = { - "1m": 60_000, - "5m": 5 * 60_000, - "15m": 15 * 60_000, - "1h": 60 * 60_000, - "2h": 2 * 60 * 60_000, - "4h": 4 * 60 * 60_000, - "1d": 24 * 60 * 60_000, - "1w": 7 * 24 * 60 * 60_000, - }; - const TF_BY_MINUTES = { - "1": "1m", - "5": "5m", - "15": "15m", - "60": "1h", - "120": "2h", - "240": "4h", - "1440": "1d", - "10080": "1w", - }; - const TF_MINUTE_KEYS = Object.keys(TF_BY_MINUTES).sort(function (a, b) { - return b.length - a.length; - }); - const TF_CN_LABEL = { - "1m": "1分钟", - "5m": "5分钟", - "15m": "15分钟", - "1h": "1小时", - "2h": "2小时", - "4h": "4小时", - "1d": "日线", - "1w": "周线", - }; - const TF_DIGIT_TIMEOUT_MS = 650; - const CHART_TZ_OFFSET_SEC = 8 * 60 * 60; - - function pad2(n) { - return n < 10 ? "0" + n : String(n); - } - - function utcSecToBjDate(utcSec) { - return new Date((Number(utcSec) + CHART_TZ_OFFSET_SEC) * 1000); - } - - function formatChartTimeBj(utcSec, withDate) { - const d = utcSecToBjDate(utcSec); - const h = pad2(d.getUTCHours()); - const mi = pad2(d.getUTCMinutes()); - if (!withDate) return h + ":" + mi; - return ( - d.getUTCFullYear() + - "-" + - pad2(d.getUTCMonth() + 1) + - "-" + - pad2(d.getUTCDate()) + - " " + - h + - ":" + - mi - ); - } - - function chartLocalizationBj() { - return { - locale: "zh-CN", - dateFormat: "yyyy-MM-dd", - timeFormatter: function (time) { - if (typeof time === "number") return formatChartTimeBj(time, true); - if (time && typeof time === "object" && time.year) { - return time.year + "-" + pad2(time.month) + "-" + pad2(time.day); - } - return ""; - }, - tickMarkFormatter: function (time, tickMarkType) { - if (typeof time !== "number") { - if (time && typeof time === "object" && time.year) { - return time.year + "-" + pad2(time.month) + "-" + pad2(time.day); - } - return ""; - } - const d = utcSecToBjDate(time); - if (tickMarkType === 0) return String(d.getUTCFullYear()); - if (tickMarkType === 1) return pad2(d.getUTCMonth() + 1); - if (tickMarkType === 2) return pad2(d.getUTCDate()); - return formatChartTimeBj(time, false); - }, - }; - } - - function buildChartLocalization() { - const loc = chartLocalizationBj(); - loc.priceFormatter = function (p) { - return fmtPrice(p); - }; - return loc; - } - - const chartHost = document.getElementById("market-chart"); - if (!chartHost) return; - - const elDrawToolbar = document.getElementById("market-draw-toolbar"); - const elDrawCanvas = document.getElementById("market-draw-canvas"); - const elChartMain = chartHost.closest(".market-chart-main"); - let drawAttached = false; - - const elExchange = document.getElementById("market-exchange"); - const elSymbol = document.getElementById("market-symbol"); - const elVolRankMeta = document.getElementById("market-vol-rank-meta"); - const elVolRankList = document.getElementById("market-vol-rank-list"); - const elVolRankBtn = document.getElementById("market-vol-rank-btn"); - const elFsVolRankBtn = document.getElementById("market-fs-vol-rank-btn"); - const elVolRankSheet = document.getElementById("market-vol-rank-sheet"); - const elVolRankAnchor = document.getElementById("market-vol-rank-anchor"); - const elVolRankAnchorFs = document.getElementById("market-vol-rank-anchor-fs"); - const elTf = document.getElementById("market-timeframe"); - const elRefresh = document.getElementById("market-refresh"); - const elStatus = document.getElementById("market-status"); - const elUpdated = document.getElementById("market-updated"); - const elBarCountdown = document.getElementById("market-bar-countdown"); - const elO = document.getElementById("mkt-o"); - const elH = document.getElementById("mkt-h"); - const elL = document.getElementById("mkt-l"); - const elC = document.getElementById("mkt-c"); - const elV = document.getElementById("mkt-v"); - const elAmp = document.getElementById("mkt-amp"); - const elPriceTag = document.getElementById("market-price-tag"); - const elPriceTagValue = document.getElementById("market-price-tag-value"); - const elPriceTagTime = document.getElementById("market-price-tag-time"); - const elExLabel = document.getElementById("mkt-exchange-label"); - const elExBadge = document.getElementById("market-exchange-badge"); - const elSymLabel = document.getElementById("mkt-symbol-label"); - const elTfLabel = document.getElementById("mkt-tf-label"); - const elPriceAuto = document.getElementById("market-price-auto"); - const elPosPanel = document.getElementById("market-pos-panel"); - const elPosSide = document.getElementById("mkt-pos-side"); - const elPosEntry = document.getElementById("mkt-pos-entry"); - const elPosSl = document.getElementById("mkt-pos-sl"); - const elPosTp = document.getElementById("mkt-pos-tp"); - const elPosSize = document.getElementById("mkt-pos-size"); - const elPosPnl = document.getElementById("mkt-pos-pnl"); - const elPosOrders = document.getElementById("market-pos-orders"); - const elPosClear = document.getElementById("market-pos-clear"); - const elChartWrap = document.getElementById("market-chart-wrap"); - const elFsBtn = document.getElementById("market-chart-fullscreen"); - const elFsExit = document.getElementById("market-chart-fs-exit"); - const elIndEma = document.getElementById("market-ind-ema"); - const elIndMacd = document.getElementById("market-ind-macd"); - const elIndRsi = document.getElementById("market-ind-rsi"); - const elPrevCloseLine = document.getElementById("market-prev-close-line"); - const elPrevHlLines = document.getElementById("market-prev-hl-lines"); - const elDaySplit = document.getElementById("market-day-split"); - const PREV_CLOSE_LINE_STORAGE_KEY = "hub-market-prev-close-line"; - const PREV_HL_LINES_STORAGE_KEY = "hub-market-prev-hl-lines"; - const DAY_SPLIT_STORAGE_KEY = "hub-market-day-split"; - const BJ_OFFSET_SEC = 8 * 60 * 60; - const elFsToolbar = document.getElementById("market-fs-toolbar"); - const elFsExchange = document.getElementById("market-fs-exchange"); - const elFsSymbol = document.getElementById("market-fs-symbol"); - const elFsTf = document.getElementById("market-fs-timeframe"); - const elFsLoad = document.getElementById("market-fs-load"); - const elDivLegend = document.getElementById("market-div-legend"); - - const HUB_MARKET_POS_CTX_KEY = "hubMarketPosContext"; - const EMA_FAST = 21; - const EMA_SLOW = 55; - - let chartFullscreen = false; - const indicatorState = { ema: false, macd: false, rsi: false }; - const indSeries = { - ema21: null, - ema55: null, - macdLine: null, - macdSignal: null, - macdHist: null, - rsi: null, - rsi30: null, - rsi70: null, - }; - let divergenceMarkers = []; - - let chart = null; - let candleSeries = null; - let volumeSeries = null; - let priceTick = null; - let priceAutoScale = true; - let rangeMarkers = []; - let yesterdayPriceLines = []; - let positionLines = []; - let posContext = null; - let posPnlTimer = null; - const SL_DRAG_HIT_PX = 12; - let slDrag = null; - let currentPriceLine = null; - let lastCandles = []; - let candleByTime = {}; - let chartMeta = null; - let loadToken = 0; - let marketInited = false; - let refreshTimer = null; - let chartWatchTimer = null; - let chartEventSource = null; - let chartSseReconnectTimer = null; - let localChartVersion = 0; - let localSeriesVersion = 0; - let lastViewKey = ""; - let currentTf = "1d"; - let exhaustedLeft = false; - let loadingLeft = false; - let chartDataLoading = false; - let chartViewEpoch = 0; - let rangeUiTimer = null; - let loadOlderTimer = null; - let chartRangeUserLocked = false; - let chartRangeLockTimer = null; - let suppressRangeUserLock = false; - const CHART_TAIL_REFRESH_LIMIT = 30; - let priceTagTimer = null; - let tfDigitBuf = ""; - let tfDigitTimer = null; - let tfHintTimer = null; - - function escHtml(s) { - return String(s || "") - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); - } - - function normalizeMarketSymbol(sym) { - const s = String(sym || "").trim().toUpperCase(); - const m = s.match(/^([A-Z0-9]+)\/([A-Z0-9]+)(?::([A-Z0-9]+))?$/); - if (!m) return s; - return m[1] + "/" + m[2]; - } - - function loadPosContextFromStorage() { - try { - const raw = sessionStorage.getItem(HUB_MARKET_POS_CTX_KEY); - if (!raw) return null; - return JSON.parse(raw); - } catch (e) { - return null; - } - } - - function posContextMatches(ctx, exKey, sym) { - if (!ctx) return false; - const ctxSym = normalizeMarketSymbol(ctx.symbol || ""); - const ctxEx = String(ctx.exchange_key || "").trim(); - return ctxSym === normalizeMarketSymbol(sym) && ctxEx === String(exKey || "").trim(); - } - - function clearPosPanel() { - if (elPosPanel) elPosPanel.classList.add("hidden"); - if (elPosSide) { - elPosSide.textContent = ""; - elPosSide.className = "market-pos-side"; - } - ["entry", "sl", "tp", "size"].forEach(function (k) { - const el = { entry: elPosEntry, sl: elPosSl, tp: elPosTp, size: elPosSize }[k]; - if (el) el.textContent = "—"; - }); - if (elPosPnl) { - elPosPnl.textContent = "—"; - elPosPnl.className = "market-pos-pnl"; - } - if (elPosOrders) elPosOrders.innerHTML = ""; - syncChartWrapLayout(); - } - - function loadBoolPref(key, defaultValue) { - try { - const raw = localStorage.getItem(key); - if (raw === "1" || raw === "true") return true; - if (raw === "0" || raw === "false") return false; - } catch (_) {} - return !!defaultValue; - } - - function saveBoolPref(key, on) { - try { - localStorage.setItem(key, on ? "1" : "0"); - } catch (_) {} - } - - function loadDaySplitPref() { - return loadBoolPref(DAY_SPLIT_STORAGE_KEY, false); - } - - function saveDaySplitPref(on) { - saveBoolPref(DAY_SPLIT_STORAGE_KEY, on); - } - - function loadPrevCloseLinePref() { - return loadBoolPref(PREV_CLOSE_LINE_STORAGE_KEY, false); - } - - function savePrevCloseLinePref(on) { - saveBoolPref(PREV_CLOSE_LINE_STORAGE_KEY, on); - } - - function loadPrevHlLinesPref() { - return loadBoolPref(PREV_HL_LINES_STORAGE_KEY, false); - } - - function savePrevHlLinesPref(on) { - saveBoolPref(PREV_HL_LINES_STORAGE_KEY, on); - } - - function chartResetHour() { - return chartMeta && chartMeta.volume_rank_reset_hour != null - ? Number(chartMeta.volume_rank_reset_hour) - : 8; - } - - function utcSecToBjParts(utcSec) { - const d = new Date((Number(utcSec) + BJ_OFFSET_SEC) * 1000); - return { - y: d.getUTCFullYear(), - m: d.getUTCMonth(), - d: d.getUTCDate(), - h: d.getUTCHours(), - }; - } - - function tradingDayKeyFromUtcSec(utcSec, resetHour) { - const p = utcSecToBjParts(utcSec); - let y = p.y; - let m = p.m; - let d = p.d; - if (p.h < resetHour) { - const prev = new Date(Date.UTC(y, m, d) - 86400000); - y = prev.getUTCFullYear(); - m = prev.getUTCMonth(); - d = prev.getUTCDate(); - } - return ( - y + - "-" + - String(m + 1).padStart(2, "0") + - "-" + - String(d).padStart(2, "0") - ); - } - - function prevTradingDayKey(tdKey) { - const parts = String(tdKey || "").split("-"); - if (parts.length !== 3) return ""; - const dt = new Date(Date.UTC(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]))); - const prev = new Date(dt.getTime() - 86400000); - return ( - prev.getUTCFullYear() + - "-" + - String(prev.getUTCMonth() + 1).padStart(2, "0") + - "-" + - String(prev.getUTCDate()).padStart(2, "0") - ); - } - - function computePrevTradingDayOhlc(candles, resetHour) { - if (!candles || !candles.length) return null; - const curTd = tradingDayKeyFromUtcSec(candles[candles.length - 1].time, resetHour); - const prevTd = prevTradingDayKey(curTd); - if (!prevTd) return null; - const dayCandles = candles - .filter(function (c) { - return c && tradingDayKeyFromUtcSec(c.time, resetHour) === prevTd; - }) - .sort(function (a, b) { - return a.time - b.time; - }); - if (!dayCandles.length) return null; - let hi = null; - let lo = null; - dayCandles.forEach(function (c) { - if (!hi || c.high > hi) hi = c.high; - if (!lo || c.low < lo) lo = c.low; - }); - const last = dayCandles[dayCandles.length - 1]; - return { - close: last.close, - high: hi, - low: lo, - tradingDay: prevTd, - }; - } - - function syncPrevDayLineUi() { - const closeOn = !!(elPrevCloseLine && elPrevCloseLine.checked); - const hlOn = !!(elPrevHlLines && elPrevHlLines.checked); - savePrevCloseLinePref(closeOn); - savePrevHlLinesPref(hlOn); - updateYesterdayPriceLines(); - } - - function applyTradingDaySplit(enabled) { - if (window.HubChartDraw && typeof window.HubChartDraw.setTradingDaySplit === "function") { - window.HubChartDraw.setTradingDaySplit(enabled); - } - } - - function syncTradingDaySplitUi() { - const on = !!(elDaySplit && elDaySplit.checked); - saveDaySplitPref(on); - applyTradingDaySplit(on); - } - - function ensureDrawLayer() { - if (drawAttached || !window.HubChartDraw || !chart || !candleSeries) return; - window.HubChartDraw.attach({ - chart: chart, - series: candleSeries, - hostEl: chartHost, - mainEl: elChartMain, - canvasEl: elDrawCanvas, - toolbarEl: elDrawToolbar, - getCandles: function () { - return lastCandles; - }, - }); - window.HubChartDraw.setViewKey(currentChartViewKey()); - applyTradingDaySplit(elDaySplit ? elDaySplit.checked : loadDaySplitPref()); - drawAttached = true; - } - - function syncDrawViewKey() { - if (window.HubChartDraw && drawAttached) { - window.HubChartDraw.setViewKey(currentChartViewKey()); - } - } - - function resizeChart() { - if (!chart || !chartHost) return; - chart.applyOptions({ width: chartHost.clientWidth, height: chartHost.clientHeight }); - updatePriceTag(); - if (window.HubChartDraw && drawAttached) { - window.HubChartDraw.resize(); - } - } - - let resizeChartRaf = 0; - function scheduleChartResize() { - if (resizeChartRaf) cancelAnimationFrame(resizeChartRaf); - resizeChartRaf = requestAnimationFrame(function () { - resizeChartRaf = 0; - syncChartWrapLayout(); - }); - } - - function syncChartWrapLayout() { - const wrap = elChartWrap || (chartHost && chartHost.closest(".market-chart-wrap")); - if (wrap && elPosPanel && !chartFullscreen) { - wrap.classList.toggle("has-pos-panel", !elPosPanel.classList.contains("hidden")); - } - resizeChart(); - } - - function readIndicatorState() { - indicatorState.ema = !!(elIndEma && elIndEma.checked); - indicatorState.macd = !!(elIndMacd && elIndMacd.checked); - indicatorState.rsi = !!(elIndRsi && elIndRsi.checked); - } - - function emaArray(values, period) { - const result = new Array(values.length).fill(null); - const k = 2 / (period + 1); - let ema = null; - for (let i = 0; i < values.length; i++) { - const v = values[i]; - if (v == null || !Number.isFinite(v)) continue; - if (ema == null) { - if (i < period - 1) continue; - let sum = 0; - let ok = true; - for (let j = i - period + 1; j <= i; j++) { - const x = values[j]; - if (x == null || !Number.isFinite(x)) { - ok = false; - break; - } - sum += x; - } - if (!ok) continue; - ema = sum / period; - } else { - ema = v * k + ema * (1 - k); - } - result[i] = ema; - } - return result; - } - - function buildEmaSeries(candles, period) { - const closes = candles.map(function (c) { - return Number(c.close); - }); - const vals = emaArray(closes, period); - const out = []; - for (let i = 0; i < candles.length; i++) { - if (vals[i] == null) continue; - out.push({ time: candles[i].time, value: vals[i] }); - } - return out; - } - - function buildMacdData(candles) { - const closes = candles.map(function (c) { - return Number(c.close); - }); - const ema12 = emaArray(closes, 12); - const ema26 = emaArray(closes, 26); - const macd = new Array(closes.length).fill(null); - for (let i = 0; i < closes.length; i++) { - if (ema12[i] == null || ema26[i] == null) continue; - macd[i] = ema12[i] - ema26[i]; - } - const signal = emaArray(macd, 9); - const macdLine = []; - const signalLine = []; - const histData = []; - for (let i = 0; i < candles.length; i++) { - const t = candles[i].time; - if (macd[i] != null) macdLine.push({ time: t, value: macd[i] }); - if (signal[i] != null) signalLine.push({ time: t, value: signal[i] }); - if (macd[i] != null && signal[i] != null) { - const h = macd[i] - signal[i]; - histData.push({ - time: t, - value: h, - color: h >= 0 ? "rgba(0, 255, 157, 0.55)" : "rgba(255, 77, 109, 0.55)", - }); - } - } - return { macdLine, signalLine, histData }; - } - - function buildRsiSeries(candles, period) { - const out = []; - if (!candles || candles.length < period + 1) return out; - let avgGain = 0; - let avgLoss = 0; - for (let i = 1; i <= period; i++) { - const ch = Number(candles[i].close) - Number(candles[i - 1].close); - if (ch >= 0) avgGain += ch; - else avgLoss -= ch; - } - avgGain /= period; - avgLoss /= period; - let rsi = 50; - if (avgLoss <= 0) rsi = 100; - else if (avgGain <= 0) rsi = 0; - else rsi = 100 - 100 / (1 + avgGain / avgLoss); - out.push({ time: candles[period].time, value: rsi }); - - for (let i = period + 1; i < candles.length; i++) { - const ch = Number(candles[i].close) - Number(candles[i - 1].close); - const gain = ch > 0 ? ch : 0; - const loss = ch < 0 ? -ch : 0; - avgGain = (avgGain * (period - 1) + gain) / period; - avgLoss = (avgLoss * (period - 1) + loss) / period; - if (avgLoss <= 0) rsi = 100; - else if (avgGain <= 0) rsi = 0; - else rsi = 100 - 100 / (1 + avgGain / avgLoss); - out.push({ time: candles[i].time, value: rsi }); - } - return out; - } - - function createLineSeries(opts) { - if (!chart) return null; - const base = { - lineWidth: 1, - priceLineVisible: false, - lastValueVisible: false, - }; - const o = Object.assign(base, opts || {}); - if (typeof chart.addLineSeries === "function") return chart.addLineSeries(o); - if ( - typeof chart.addSeries === "function" && - window.LightweightCharts && - window.LightweightCharts.LineSeries - ) { - return chart.addSeries(window.LightweightCharts.LineSeries, o); - } - return null; - } - - function createHistSeries(opts) { - if (!chart) return null; - const base = { priceLineVisible: false, lastValueVisible: false }; - const o = Object.assign(base, opts || {}); - if (typeof chart.addHistogramSeries === "function") return chart.addHistogramSeries(o); - if ( - typeof chart.addSeries === "function" && - window.LightweightCharts && - window.LightweightCharts.HistogramSeries - ) { - return chart.addSeries(window.LightweightCharts.HistogramSeries, o); - } - return null; - } - - function clearIndicatorSeries() { - if (!chart) return; - [indSeries.rsi30, indSeries.rsi70].forEach(function (pl) { - if (pl && indSeries.rsi) { - try { - indSeries.rsi.removePriceLine(pl); - } catch (e) {} - } - }); - indSeries.rsi30 = null; - indSeries.rsi70 = null; - Object.keys(indSeries).forEach(function (k) { - if (k === "rsi30" || k === "rsi70") return; - if (indSeries[k]) { - try { - chart.removeSeries(indSeries[k]); - } catch (e) {} - indSeries[k] = null; - } - }); - } - - function findSwings(values, lookback) { - const lows = []; - const highs = []; - const lb = lookback || SWING_LOOKBACK; - for (let i = lb; i < values.length - lb; i++) { - const v = values[i]; - if (v == null || !Number.isFinite(v)) continue; - let isLow = true; - let isHigh = true; - for (let j = 1; j <= lb; j++) { - const lv = values[i - j]; - const rv = values[i + j]; - if (lv == null || rv == null || v > lv || v > rv) isLow = false; - if (lv == null || rv == null || v < lv || v < rv) isHigh = false; - } - if (isLow) lows.push({ i: i, v: v }); - if (isHigh) highs.push({ i: i, v: v }); - } - return { lows, highs }; - } - - function detectDivergences(candles, indicatorByIndex, sourceLabel) { - const markers = []; - if (!candles.length || !indicatorByIndex.length) return markers; - - const closes = candles.map(function (c) { - return Number(c.close); - }); - const priceSw = findSwings(closes, SWING_LOOKBACK); - const indSw = findSwings(indicatorByIndex, SWING_LOOKBACK); - - function pushMarker(idx, kind, label) { - const c = candles[idx]; - if (!c || c.time == null) return; - const bull = kind === "bull"; - markers.push({ - time: c.time, - position: bull ? "belowBar" : "aboveBar", - color: bull ? "#00ff9d" : "#ff4d6d", - shape: bull ? "arrowUp" : "arrowDown", - text: label, - }); - } - - const pLows = priceSw.lows; - const iLows = indSw.lows; - if (pLows.length >= 2 && iLows.length >= 2) { - const p1 = pLows[pLows.length - 2]; - const p2 = pLows[pLows.length - 1]; - const i1 = iLows[iLows.length - 2]; - const i2 = iLows[iLows.length - 1]; - if (Math.abs(p1.i - i1.i) < 30 && Math.abs(p2.i - i2.i) < 30) { - if (p2.v < p1.v && i2.v > i1.v) { - pushMarker(p2.i, "bull", sourceLabel + "底背离"); - } - } - } - - const pHighs = priceSw.highs; - const iHighs = indSw.highs; - if (pHighs.length >= 2 && iHighs.length >= 2) { - const p1 = pHighs[pHighs.length - 2]; - const p2 = pHighs[pHighs.length - 1]; - const i1 = iHighs[iHighs.length - 2]; - const i2 = iHighs[iHighs.length - 1]; - if (Math.abs(p1.i - i1.i) < 30 && Math.abs(p2.i - i2.i) < 30) { - if (p2.v > p1.v && i2.v < i1.v) { - pushMarker(p2.i, "bear", sourceLabel + "顶背离"); - } - } - } - - return markers.slice(-MAX_DIV_MARKERS); - } - - function buildRsiByIndex(candles, period) { - const series = buildRsiSeries(candles, period); - const byIdx = new Array(candles.length).fill(null); - let si = 0; - for (let i = 0; i < candles.length; i++) { - if (si < series.length && series[si].time === candles[i].time) { - byIdx[i] = series[si].value; - si++; - } - } - return { series, byIdx }; - } - - function buildMacdByIndex(candles) { - const closes = candles.map(function (c) { - return Number(c.close); - }); - const ema12 = emaArray(closes, 12); - const ema26 = emaArray(closes, 26); - const macd = new Array(closes.length).fill(null); - for (let i = 0; i < closes.length; i++) { - if (ema12[i] == null || ema26[i] == null) continue; - macd[i] = ema12[i] - ema26[i]; - } - return macd; - } - - function panelLayout() { - const rsiOn = indicatorState.rsi; - const macdOn = indicatorState.macd; - if (!rsiOn && !macdOn) { - return { - candle: { top: 0.06, bottom: CANDLE_SCALE_BOTTOM }, - volume: { top: VOLUME_SCALE_TOP, bottom: VOLUME_SCALE_BOTTOM }, - macd: null, - rsi: null, - }; - } - - const gap = 0.02; - let stackBottom = gap; - let rsiMargins = null; - let macdMargins = null; - - if (rsiOn) { - rsiMargins = { - top: 1 - stackBottom - PANEL_RSI_H, - bottom: stackBottom, - }; - stackBottom += PANEL_RSI_H; - } - if (macdOn) { - macdMargins = { - top: 1 - stackBottom - PANEL_MACD_H, - bottom: stackBottom, - }; - stackBottom += PANEL_MACD_H; - } - - const volBottom = stackBottom; - const volTop = 1 - volBottom - PANEL_VOL_H; - const candleBottom = Math.max(CANDLE_SCALE_BOTTOM, 1 - volTop + 0.01); - - return { - candle: { top: 0.06, bottom: candleBottom }, - volume: { top: volTop, bottom: volBottom }, - macd: macdMargins, - rsi: rsiMargins, - }; - } - - function applyScaleLayout() { - if (!chart) return; - const L = panelLayout(); - chart.priceScale("right").applyOptions({ - scaleMargins: L.candle, - }); - if (volumeSeries && volumeSeries.priceScale) { - volumeSeries.priceScale().applyOptions({ - scaleMargins: L.volume, - borderColor: "#2a4058", - }); - } - if (indSeries.macdLine && indSeries.macdLine.priceScale) { - indSeries.macdLine.priceScale().applyOptions({ - scaleMargins: L.macd, - borderColor: "#2a4058", - autoScale: true, - }); - } - if (indSeries.rsi && indSeries.rsi.priceScale) { - indSeries.rsi.priceScale().applyOptions({ - scaleMargins: L.rsi, - borderColor: "#2a4058", - autoScale: true, - }); - } - } - - function updateDivergenceLegend(rsiDiv, macdDiv) { - if (!elDivLegend) return; - const parts = []; - if (indicatorState.rsi && rsiDiv.length) { - parts.push("RSI " + rsiDiv.map(function (m) { return m.text; }).join(" · ")); - } - if (indicatorState.macd && macdDiv.length) { - parts.push("MACD " + macdDiv.map(function (m) { return m.text; }).join(" · ")); - } - if (!parts.length) { - elDivLegend.textContent = ""; - elDivLegend.classList.add("hidden"); - return; - } - elDivLegend.textContent = parts.join(" | "); - elDivLegend.classList.remove("hidden"); - } - - function applyCandleDivergenceMarkers() { - if (!candleSeries || !candleSeries.setMarkers) return; - const sorted = divergenceMarkers - .slice() - .sort(function (a, b) { - return a.time > b.time ? 1 : a.time < b.time ? -1 : 0; - }); - candleSeries.setMarkers(sorted); - } - - function updateIndicators() { - if (!chart || !lastCandles.length) return; - readIndicatorState(); - clearIndicatorSeries(); - divergenceMarkers = []; - - if (indicatorState.ema) { - const pf = tickToPriceFormat(priceTick); - indSeries.ema21 = createLineSeries({ - color: "#f0c040", - title: "EMA21", - priceScaleId: "right", - priceFormat: pf, - }); - indSeries.ema55 = createLineSeries({ - color: "#c878ff", - title: "EMA55", - priceScaleId: "right", - priceFormat: pf, - }); - if (indSeries.ema21) indSeries.ema21.setData(buildEmaSeries(lastCandles, EMA_FAST)); - if (indSeries.ema55) indSeries.ema55.setData(buildEmaSeries(lastCandles, EMA_SLOW)); - } - - let rsiDiv = []; - let macdDiv = []; - - if (indicatorState.macd) { - const macd = buildMacdData(lastCandles); - const macdByIdx = buildMacdByIndex(lastCandles); - indSeries.macdLine = createLineSeries({ - color: "#5b9cf5", - title: "MACD", - priceScaleId: "macd", - priceLineVisible: false, - lastValueVisible: false, - }); - indSeries.macdSignal = createLineSeries({ - color: "#ffb84d", - title: "Signal", - priceScaleId: "macd", - priceLineVisible: false, - lastValueVisible: false, - }); - indSeries.macdHist = createHistSeries({ - priceScaleId: "macd", - priceLineVisible: false, - lastValueVisible: false, - }); - if (indSeries.macdLine) indSeries.macdLine.setData(macd.macdLine); - if (indSeries.macdSignal) indSeries.macdSignal.setData(macd.signalLine); - if (indSeries.macdHist) indSeries.macdHist.setData(macd.histData); - macdDiv = detectDivergences(lastCandles, macdByIdx, "MACD"); - divergenceMarkers = divergenceMarkers.concat(macdDiv); - } - - if (indicatorState.rsi) { - const rsiPack = buildRsiByIndex(lastCandles, 14); - indSeries.rsi = createLineSeries({ - color: "#8fc8ff", - title: "RSI(14)", - priceScaleId: "rsi", - priceFormat: { type: "price", precision: 1, minMove: 0.1 }, - priceLineVisible: false, - lastValueVisible: true, - }); - if (indSeries.rsi) { - indSeries.rsi.setData(rsiPack.series); - try { - indSeries.rsi30 = indSeries.rsi.createPriceLine({ - price: 30, - color: "rgba(255, 77, 109, 0.75)", - lineWidth: 1, - lineStyle: 2, - axisLabelVisible: true, - title: "30", - }); - indSeries.rsi70 = indSeries.rsi.createPriceLine({ - price: 70, - color: "rgba(0, 255, 157, 0.75)", - lineWidth: 1, - lineStyle: 2, - axisLabelVisible: true, - title: "70", - }); - } catch (e) {} - } - rsiDiv = detectDivergences(lastCandles, rsiPack.byIdx, "RSI"); - divergenceMarkers = divergenceMarkers.concat(rsiDiv); - } - - updateDivergenceLegend(rsiDiv, macdDiv); - applyCandleDivergenceMarkers(); - applyScaleLayout(); - scheduleChartResize(); - } - - function syncFsToolbarFromMain() { - if (!chartFullscreen) return; - if (elFsExchange && elExchange) elFsExchange.value = elExchange.value; - if (elFsSymbol && elSymbol) elFsSymbol.value = elSymbol.value; - if (elFsTf && elTf) elFsTf.value = elTf.value; - } - - function syncMainFromFsToolbar() { - if (elExchange && elFsExchange) elExchange.value = elFsExchange.value; - if (elSymbol && elFsSymbol) elSymbol.value = elFsSymbol.value.trim().toUpperCase(); - if (elTf && elFsTf) elTf.value = elFsTf.value; - updateExchangeDisplay(); - updateHeaderLabels(elSymbol && elSymbol.value, elTf && elTf.value); - } - - function isMarketPageActive() { - const page = document.getElementById("page-market"); - return !!(page && !page.classList.contains("hidden")); - } - - function isTypingInField(target) { - if (!target) return false; - const tag = (target.tagName || "").toLowerCase(); - if (tag === "input" || tag === "textarea" || tag === "select") return true; - return !!target.isContentEditable; - } - - function canUseTfKeyboard(e) { - if (!isMarketPageActive()) return false; - if (e.altKey || e.ctrlKey || e.metaKey) return false; - if (isTypingInField(e.target)) return false; - return true; - } - - function canExtendTfDigitBuffer(buf) { - if (!buf) return false; - return TF_MINUTE_KEYS.some(function (k) { - return k.length > buf.length && k.indexOf(buf) === 0; - }); - } - - function shouldCommitTfBufferNow(buf) { - const tf = resolveTfFromDigitBuffer(buf); - if (!tf) return false; - return !canExtendTfDigitBuffer(buf); - } - - function resolveTfFromDigitBuffer(buf) { - if (!buf) return null; - return TF_BY_MINUTES[buf] || null; - } - - function flashTfSwitchHint(tf) { - const label = TF_CN_LABEL[tf] || tf; - const text = "周期 → " + label + "(" + tf + ")"; - if (elTfLabel) elTfLabel.textContent = tf; - if (elBarCountdown) { - if (tfHintTimer) clearTimeout(tfHintTimer); - elBarCountdown.textContent = text; - elBarCountdown.classList.add("market-tf-key-hint"); - tfHintTimer = setTimeout(function () { - tfHintTimer = null; - elBarCountdown.classList.remove("market-tf-key-hint"); - tickLiveClock(); - }, 1200); - return; - } - if (elStatus) { - if (tfHintTimer) clearTimeout(tfHintTimer); - const prevClass = elStatus.className; - const prevText = elStatus.textContent; - elStatus.className = "market-status"; - elStatus.textContent = text; - tfHintTimer = setTimeout(function () { - tfHintTimer = null; - elStatus.className = prevClass; - elStatus.textContent = prevText; - }, 1200); - } - } - - function applyTimeframe(tf, fromKeyboard) { - if (!tf || !TF_MS[tf]) return false; - const cur = (elTf && elTf.value) || currentTf; - if (cur === tf) return false; - if (elTf) elTf.value = tf; - if (elFsTf) elFsTf.value = tf; - currentTf = tf; - lastViewKey = ""; - tickLiveClock(); - updateHeaderLabels( - elSymbol && elSymbol.value.trim().toUpperCase(), - tf - ); - syncFsToolbarFromMain(); - if (fromKeyboard) flashTfSwitchHint(tf); - loadChart(false); - return true; - } - - function commitTfDigitBuffer() { - const buf = tfDigitBuf; - tfDigitBuf = ""; - if (tfDigitTimer) { - clearTimeout(tfDigitTimer); - tfDigitTimer = null; - } - const tf = resolveTfFromDigitBuffer(buf); - if (tf) applyTimeframe(tf, true); - } - - function handleTfDigitKey(digit) { - if (!digit) return; - if (tfDigitBuf && !canExtendTfDigitBuffer(tfDigitBuf)) { - tfDigitBuf = ""; - } - tfDigitBuf += digit; - if (shouldCommitTfBufferNow(tfDigitBuf)) { - commitTfDigitBuffer(); - return; - } - if (!canExtendTfDigitBuffer(tfDigitBuf)) { - tfDigitBuf = digit; - if (shouldCommitTfBufferNow(tfDigitBuf)) { - commitTfDigitBuffer(); - return; - } - } - if (tfDigitTimer) clearTimeout(tfDigitTimer); - tfDigitTimer = setTimeout(commitTfDigitBuffer, TF_DIGIT_TIMEOUT_MS); - } - - function isChartFullscreenKey(e) { - if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return false; - return e.code === "KeyF" || e.key === "f" || e.key === "F"; - } - - function onChartFullscreenKey(e) { - if (!isMarketPageActive() || !isChartFullscreenKey(e)) return; - if (isTypingInField(e.target)) return; - e.preventDefault(); - e.stopImmediatePropagation(); - toggleChartFullscreen(); - } - - function focusMarketChartArea() { - const wrap = elChartWrap; - if (!wrap) return; - if (!wrap.hasAttribute("tabindex")) wrap.setAttribute("tabindex", "-1"); - try { - wrap.focus({ preventScroll: true }); - } catch (err) { - /* ignore */ - } - } - - function onMarketKeydown(e) { - if (!isMarketPageActive()) return; - - if (e.key === "Escape" && chartFullscreen) { - e.preventDefault(); - e.stopPropagation(); - setChartFullscreen(false); - return; - } - - if (!canUseTfKeyboard(e)) return; - if (e.key >= "0" && e.key <= "9") { - e.preventDefault(); - handleTfDigitKey(e.key); - return; - } - if (e.key === "Enter" && tfDigitBuf) { - e.preventDefault(); - commitTfDigitBuffer(); - } - } - - function populateFsExchangeOptions() { - if (!elFsExchange || !elExchange) return; - elFsExchange.innerHTML = elExchange.innerHTML; - elFsExchange.value = elExchange.value; - } - - function setChartFullscreen(on) { - chartFullscreen = !!on; - const wrap = elChartWrap || (chartHost && chartHost.closest(".market-chart-wrap")); - if (wrap) wrap.classList.toggle("is-fullscreen", chartFullscreen); - document.body.classList.toggle("market-chart-fs-open", chartFullscreen); - if (elFsToolbar) elFsToolbar.classList.toggle("hidden", !chartFullscreen); - if (elFsBtn) elFsBtn.textContent = chartFullscreen ? "退出全屏" : "全屏"; - if (elFsExit) { - if (chartFullscreen) elFsExit.classList.remove("hidden"); - else elFsExit.classList.add("hidden"); - } - mountVolRankSheet(chartFullscreen); - if (chartFullscreen) { - populateFsExchangeOptions(); - syncFsToolbarFromMain(); - } - scheduleChartResize(); - } - - function toggleChartFullscreen() { - setChartFullscreen(!chartFullscreen); - } - - function showHubToast(msg, isErr) { - const t = document.getElementById("toast"); - if (!t) return; - t.textContent = msg; - t.classList.toggle("err", !!isErr); - t.classList.add("show"); - clearTimeout(showHubToast._hideTimer); - showHubToast._hideTimer = setTimeout(function () { - t.classList.remove("show"); - }, 3500); - } - - function estimateLinearSwapUpnl(side, entry, mark, contracts, contractSize) { - const e = Number(entry); - const m = Number(mark); - const c = Math.abs(Number(contracts)); - let mult = Number(contractSize); - if (!Number.isFinite(mult) || mult <= 0) mult = 1; - if (!Number.isFinite(e) || !Number.isFinite(m) || !Number.isFinite(c) || c <= 0) { - return null; - } - const diff = - (side || "long").toLowerCase() === "long" ? m - e : e - m; - return Math.round(diff * c * mult * 100) / 100; - } - - function formatPosPnlText(ctx) { - const upnl = ctx && ctx.unrealized_pnl; - if (upnl == null || !Number.isFinite(Number(upnl))) return { text: "—", cls: "" }; - const n = Number(upnl); - let text = (n >= 0 ? "+" : "") + n.toFixed(2) + "U"; - const notional = ctx.notional_usdt; - const entry = Number(ctx.entry); - const contracts = Math.abs(Number(ctx.contracts)); - const cs = - ctx.contract_size != null && Number(ctx.contract_size) > 0 - ? Number(ctx.contract_size) - : 1; - let pctBase = null; - if (notional != null && Math.abs(Number(notional)) > 1e-8) { - pctBase = Math.abs(Number(notional)); - } else if ( - Number.isFinite(entry) && - entry > 0 && - Number.isFinite(contracts) && - contracts > 0 - ) { - pctBase = entry * contracts * cs; - } - if (pctBase != null && pctBase > 1e-8) { - const pct = (n / pctBase) * 100; - text += " (" + (pct >= 0 ? "+" : "") + pct.toFixed(2) + "%)"; - } else if (ctx.plan_margin != null && Number(ctx.plan_margin) > 1e-8) { - const pct = (n / Number(ctx.plan_margin)) * 100; - text += " (" + (pct >= 0 ? "+" : "") + pct.toFixed(2) + "%)"; - } - return { text: text, cls: n > 0 ? "pnl-up" : n < 0 ? "pnl-down" : "" }; - } - - function findTrendFloatingPnl(row, sym, side) { - const hm = row.hub_monitor; - if (!hm || !Array.isArray(hm.trends)) return null; - for (let i = 0; i < hm.trends.length; i++) { - const t = hm.trends[i]; - const ts = normalizeMarketSymbol(t.exchange_symbol || t.symbol || ""); - if (ts !== sym) continue; - if ((t.direction || "").toLowerCase() !== side) continue; - const fp = t.floating_pnl; - if (fp != null && Number.isFinite(Number(fp))) return Number(fp); - if (t.plan_margin_capital != null && Number(t.plan_margin_capital) > 0) { - /* 保留 plan_margin 供百分比 */ - } - } - return null; - } - - function findTrendPlan(row, sym, side) { - const hm = row.hub_monitor; - if (!hm || !Array.isArray(hm.trends)) return null; - for (let i = 0; i < hm.trends.length; i++) { - const t = hm.trends[i]; - const ts = normalizeMarketSymbol(t.exchange_symbol || t.symbol || ""); - if (ts !== sym) continue; - if ((t.direction || "").toLowerCase() !== side) continue; - return t; - } - return null; - } - - function applyTrendPlanFields(row, sym, side) { - if (!posContext) return; - const t = findTrendPlan(row, sym, side); - if (!t) return; - const m = t.plan_margin_capital; - if (m != null && Number.isFinite(Number(m)) && Number(m) > 0) { - posContext.plan_margin = Number(m); - } - const lev = t.leverage; - if (lev != null && Number.isFinite(Number(lev)) && Number(lev) > 0) { - posContext.leverage = Number(lev); - } - } - - /** U 本位线性永续:(标记价-开仓价)×张数×contractSize(空头取反) */ - function calcContractsUpnl(ctx, markPx) { - if (!ctx || markPx == null || !Number.isFinite(Number(markPx))) return null; - return estimateLinearSwapUpnl( - ctx.side, - ctx.entry, - markPx, - ctx.contracts, - ctx.contract_size - ); - } - - function latestChartMarkPrice() { - if (!lastCandles || !lastCandles.length) return null; - const bar = lastCandles[lastCandles.length - 1]; - const c = bar && bar.close != null ? Number(bar.close) : null; - return c != null && Number.isFinite(c) && c > 0 ? c : null; - } - - function updateLivePosPnl(markOverride) { - if (!posContext) return false; - const mark = - markOverride != null && Number.isFinite(Number(markOverride)) - ? Number(markOverride) - : latestChartMarkPrice() || - (posContext.mark_price != null && Number.isFinite(Number(posContext.mark_price)) - ? Number(posContext.mark_price) - : null); - if (mark == null) return false; - const live = calcContractsUpnl(posContext, mark); - if (live != null) { - posContext.unrealized_pnl = live; - posContext.mark_price = mark; - renderPosPnlDisplay(posContext); - return true; - } - if ( - posContext.unrealized_pnl != null && - Number.isFinite(Number(posContext.unrealized_pnl)) - ) { - posContext.mark_price = mark; - renderPosPnlDisplay(posContext); - return true; - } - return false; - } - - function syncPosTpslFromAgentPosition(p) { - if (!posContext || !p) return; - const et = p.exchange_tpsl; - if (et && typeof et === "object") { - if (et.sl && et.sl.trigger_price != null) { - posContext.stop_loss = Number(et.sl.trigger_price); - } - if (et.tp && et.tp.trigger_price != null) { - posContext.take_profit = Number(et.tp.trigger_price); - posContext.tp_monitored = false; - } - } - const cond = Array.isArray(p.conditional_orders) ? p.conditional_orders : []; - for (let i = 0; i < cond.length; i++) { - const o = cond[i]; - const lbl = String(o.label || ""); - const px = - o.trigger_price != null && Number.isFinite(Number(o.trigger_price)) - ? Number(o.trigger_price) - : null; - if (px == null) continue; - if (/^止损/.test(lbl)) posContext.stop_loss = px; - else if (/^止盈/.test(lbl) && !/止盈止损/.test(lbl)) { - posContext.take_profit = px; - posContext.tp_monitored = false; - } - } - } - - function renderPosPnlDisplay(ctx) { - if (!elPosPnl) return; - const p = formatPosPnlText(ctx); - elPosPnl.textContent = p.text; - elPosPnl.className = "market-pos-pnl " + p.cls; - } - - function paintPosPnl(ctx) { - if (ctx === posContext && updateLivePosPnl()) return; - renderPosPnlDisplay(ctx); - } - - function stopPosPnlPoll() { - if (posPnlTimer) { - clearInterval(posPnlTimer); - posPnlTimer = null; - } - } - - function startPosPnlPoll() { - stopPosPnlPoll(); - if (!posContext || !posContext.exchange_id) return; - refreshPosPnlFromBoard(); - posPnlTimer = setInterval(function () { - if (!updateLivePosPnl()) refreshPosPnlFromBoard(); - }, 2000); - } - - async function refreshPosPnlFromBoard() { - if (!posContext || !posContext.exchange_id) return; - try { - const r = await fetch("/api/monitor/board/snapshot", { credentials: "same-origin" }); - if (!r.ok) return; - const data = await r.json(); - const rows = data.rows || []; - const sym = normalizeMarketSymbol(posContext.symbol || ""); - const side = (posContext.side || "long").toLowerCase(); - for (let i = 0; i < rows.length; i++) { - const row = rows[i]; - const ex = row.exchange || {}; - if (ex.id !== posContext.exchange_id) continue; - applyTrendPlanFields(row, sym, side); - const positions = (row.agent && row.agent.positions) || []; - for (let j = 0; j < positions.length; j++) { - const p = positions[j]; - if ((p.side || "").toLowerCase() !== side) continue; - if (normalizeMarketSymbol(p.symbol || "") !== sym) continue; - if (p.entry_price != null && Number.isFinite(Number(p.entry_price))) { - posContext.entry = Number(p.entry_price); - } - if (p.contract_size != null && Number.isFinite(Number(p.contract_size))) { - posContext.contract_size = Number(p.contract_size); - } - if (p.contracts != null && Number.isFinite(Number(p.contracts))) { - posContext.contracts = Number(p.contracts); - } - if (p.mark_price != null && Number.isFinite(Number(p.mark_price))) { - posContext.mark_price = Number(p.mark_price); - } - if (p.notional_usdt != null && Number.isFinite(Number(p.notional_usdt))) { - posContext.notional_usdt = Number(p.notional_usdt); - } - syncPosTpslFromAgentPosition(p); - if (elPosSl && posContext.stop_loss != null) { - elPosSl.textContent = fmtPrice(posContext.stop_loss); - } - if (elPosTp && posContext.take_profit != null && !posContext.tp_monitored) { - elPosTp.textContent = fmtPrice(posContext.take_profit); - } - const markForPnl = - latestChartMarkPrice() || - (p.mark_price != null && Number.isFinite(Number(p.mark_price)) - ? Number(p.mark_price) - : null); - if (!updateLivePosPnl(markForPnl)) { - let upnl = - p.unrealized_pnl != null && Number.isFinite(Number(p.unrealized_pnl)) - ? Number(p.unrealized_pnl) - : findTrendFloatingPnl(row, sym, side); - if (upnl != null) { - posContext.unrealized_pnl = upnl; - renderPosPnlDisplay(posContext); - } - } - updatePositionLines(); - try { - sessionStorage.setItem(HUB_MARKET_POS_CTX_KEY, JSON.stringify(posContext)); - } catch (_) {} - return; - } - applyTrendPlanFields(row, sym, side); - if (!updateLivePosPnl()) { - const trendUpnl = findTrendFloatingPnl(row, sym, side); - if (trendUpnl != null) { - posContext.unrealized_pnl = trendUpnl; - renderPosPnlDisplay(posContext); - } - } - try { - sessionStorage.setItem(HUB_MARKET_POS_CTX_KEY, JSON.stringify(posContext)); - } catch (_) {} - return; - } - } catch (_) {} - } - - function resolveTpForPlace(ctx) { - if (!ctx) return null; - const tp = ctx.take_profit; - if (tp != null && Number(tp) > 0) return Number(tp); - const orders = ctx.orders || []; - for (let i = 0; i < orders.length; i++) { - const o = orders[i]; - const lbl = String(o.label || ""); - if (/止盈/.test(lbl) && o.price != null && Number(o.price) > 0) return Number(o.price); - } - return null; - } - - async function placeTpslFromChart(newSl) { - if (!posContext || !posContext.exchange_id) { - showHubToast("缺少交易所信息,无法挂单", true); - return; - } - const sl = roundToTick(newSl); - if (sl == null || !Number.isFinite(sl) || sl <= 0) { - showHubToast("止损价无效", true); - return; - } - const tp = resolveTpForPlace(posContext); - if (tp == null || tp <= 0) { - showHubToast("未找到有效止盈价,请先在监控区用「委托」填写止盈", true); - return; - } - const sym = normalizeMarketSymbol(posContext.symbol || ""); - const side = posContext.side || "long"; - const contracts = posContext.contracts; - const oldSl = posContext.stop_loss; - if ( - !confirm( - "确认 " + - sym + - " " + - side + - "\n先撤销全部条件单,再挂止损 " + - fmtPrice(sl) + - "、止盈 " + - fmtPrice(tp) + - (oldSl != null ? "\n(原止损 " + fmtPrice(oldSl) + ")" : "") - ) - ) { - return; - } - try { - const r = await fetch( - "/api/orders/" + encodeURIComponent(posContext.exchange_id) + "/place-tpsl", - { - method: "POST", - credentials: "same-origin", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - symbol: sym, - side: side, - stop_loss: sl, - take_profit: tp, - contracts: contracts > 0 ? contracts : null, - }), - } - ); - const j = await r.json(); - const pl = j.payload || {}; - const ok = j.ok && pl.ok !== false; - showHubToast( - ok ? "止损已更新(已撤旧条件单并重新挂单)" : pl.error || JSON.stringify(j), - !ok - ); - if (ok) { - posContext.stop_loss = sl; - try { - sessionStorage.setItem(HUB_MARKET_POS_CTX_KEY, JSON.stringify(posContext)); - } catch (_) {} - if (elPosSl) elPosSl.textContent = fmtPrice(sl); - updatePositionLines(); - fetch("/api/monitor/board/refresh", { method: "POST", credentials: "same-origin" }); - } - } catch (e) { - showHubToast(String(e.message || e), true); - } - } - - function slLineCoordinate() { - if (!candleSeries || !posContext) return null; - const px = - slDrag && slDrag.active && slDrag.previewSl != null - ? slDrag.previewSl - : posContext.stop_loss; - if (px == null || !Number.isFinite(Number(px))) return null; - return candleSeries.priceToCoordinate(roundToTick(px)); - } - - function clientYToChartPrice(clientY) { - if (!candleSeries || !chartHost) return null; - const rect = chartHost.getBoundingClientRect(); - const y = clientY - rect.top; - const p = candleSeries.coordinateToPrice(y); - if (p == null || !Number.isFinite(Number(p))) return null; - return roundToTick(p); - } - - function isPointerNearSlLine(clientY) { - const coord = slLineCoordinate(); - if (coord == null || !chartHost) return false; - const rect = chartHost.getBoundingClientRect(); - return Math.abs(clientY - rect.top - coord) <= SL_DRAG_HIT_PX; - } - - function onSlLineHover(e) { - if (!chartHost || (slDrag && slDrag.active)) return; - if (!posContext || posContext.stop_loss == null) { - chartHost.style.cursor = ""; - return; - } - chartHost.style.cursor = isPointerNearSlLine(e.clientY) ? "ns-resize" : ""; - } - - function onSlDragStart(e) { - if (!posContext || posContext.stop_loss == null || !candleSeries) return; - if (e.button !== 0) return; - if (!isPointerNearSlLine(e.clientY)) return; - e.preventDefault(); - slDrag = { - active: true, - moved: false, - startSl: Number(posContext.stop_loss), - previewSl: Number(posContext.stop_loss), - }; - if (chartHost) chartHost.style.cursor = "ns-resize"; - updatePositionLines(); - } - - function onSlDragMove(e) { - if (!slDrag || !slDrag.active) return; - const p = clientYToChartPrice(e.clientY); - if (p == null || p <= 0) return; - slDrag.previewSl = p; - if (Math.abs(p - slDrag.startSl) > 1e-12) slDrag.moved = true; - if (elPosSl) elPosSl.textContent = fmtPrice(p); - updatePositionLines(); - } - - function onSlDragEnd() { - if (!slDrag || !slDrag.active) { - slDrag = null; - if (chartHost) chartHost.style.cursor = ""; - return; - } - const preview = slDrag.previewSl; - const moved = slDrag.moved; - slDrag = null; - if (chartHost) chartHost.style.cursor = ""; - updatePositionLines(); - if (!moved || preview == null) return; - placeTpslFromChart(preview); - } - - function bindSlDrag() { - if (!chartHost) return; - chartHost.addEventListener("mousedown", onSlDragStart); - chartHost.addEventListener("mousemove", onSlLineHover); - document.addEventListener("mousemove", onSlDragMove); - document.addEventListener("mouseup", onSlDragEnd); - } - - function renderPosPanel(ctx) { - if (!elPosPanel || !ctx) { - clearPosPanel(); - return; - } - elPosPanel.classList.remove("hidden"); - if (elPosSide) { - const isShort = (ctx.side || "").toLowerCase() === "short"; - elPosSide.textContent = isShort ? "空" : "多"; - elPosSide.className = "market-pos-side " + (isShort ? "side-short" : "side-long"); - } - if (elPosEntry) elPosEntry.textContent = ctx.entry != null ? fmtPrice(ctx.entry) : "—"; - if (elPosSl) elPosSl.textContent = ctx.stop_loss != null ? fmtPrice(ctx.stop_loss) : "—"; - if (elPosTp) { - if (ctx.tp_monitored) { - elPosTp.textContent = - ctx.take_profit != null - ? "程序监控 · " + fmtPrice(ctx.take_profit) - : "程序监控"; - elPosTp.classList.add("market-pos-tp-monitored"); - } else { - elPosTp.textContent = ctx.take_profit != null ? fmtPrice(ctx.take_profit) : "—"; - elPosTp.classList.remove("market-pos-tp-monitored"); - } - } - if (elPosSize) elPosSize.textContent = ctx.contracts != null ? String(ctx.contracts) : "—"; - paintPosPnl(ctx); - if (elPosOrders) { - const orders = Array.isArray(ctx.orders) ? ctx.orders : []; - if (!orders.length) { - elPosOrders.innerHTML = '暂无委托单'; - } else { - elPosOrders.innerHTML = orders - .map(function (o) { - const price = o.price != null ? fmtPrice(o.price) : "—"; - const amt = o.amount != null ? String(o.amount) : ""; - return ( - '' + - '' + - escHtml(o.kind || "") + - "" + - '' + - escHtml(o.label || "") + - "" + - '' + - price + - "" + - (amt ? '×' + escHtml(amt) + "" : "") + - "" - ); - }) - .join(""); - } - } - scheduleChartResize(); - } - - function clearPositionLines() { - positionLines.forEach(function (m) { - try { - candleSeries.removePriceLine(m); - } catch (e) {} - }); - positionLines = []; - } - - function updatePositionLines() { - clearPositionLines(); - if (!candleSeries || !posContext) return; - const slPrice = - slDrag && slDrag.active && slDrag.previewSl != null - ? slDrag.previewSl - : posContext.stop_loss; - const slTitle = - slDrag && slDrag.active - ? "止损 " + fmtPrice(slPrice) - : slPrice != null - ? "止损 ⟷" - : "止损"; - const specs = [ - { price: posContext.entry, color: "#5b9cf5", title: "入场", lineWidth: 1 }, - { - price: slPrice, - color: "#ff4d6d", - title: slTitle, - lineWidth: slPrice != null ? 2 : 1, - }, - ]; - if (posContext.take_profit != null) { - specs.push({ - price: posContext.take_profit, - color: "#00ff9d", - title: posContext.tp_monitored ? "止盈(程序)" : "止盈", - }); - } - specs.forEach(function (s) { - if (s.price == null || !Number.isFinite(Number(s.price))) return; - const px = roundToTick(s.price); - if (px == null || !Number.isFinite(Number(px))) return; - positionLines.push( - candleSeries.createPriceLine({ - price: Number(px), - color: s.color, - lineWidth: s.lineWidth != null ? s.lineWidth : 1, - lineStyle: 2, - axisLabelVisible: true, - title: s.title, - }) - ); - }); - } - - function clearPosContext() { - posContext = null; - slDrag = null; - stopPosPnlPoll(); - try { - sessionStorage.removeItem(HUB_MARKET_POS_CTX_KEY); - } catch (e) {} - clearPosPanel(); - clearPositionLines(); - if (chartHost) chartHost.style.cursor = ""; - } - - function applyPosContext(ctx) { - posContext = ctx; - renderPosPanel(ctx); - updatePositionLines(); - startPosPnlPoll(); - } - - function syncPosContextForView(exKey, sym) { - const stored = loadPosContextFromStorage(); - if (stored && posContextMatches(stored, exKey, sym)) { - applyPosContext(stored); - return; - } - clearPosContext(); - } - - function fmtVol(v) { - if (v == null || Number.isNaN(Number(v))) return "-"; - const n = Number(v); - if (n >= 1e9) return (n / 1e9).toFixed(2) + "B"; - if (n >= 1e6) return (n / 1e6).toFixed(2) + "M"; - if (n >= 1e3) return (n / 1e3).toFixed(2) + "K"; - return n.toFixed(2); - } - - function decimalsFromTick(tick) { - if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) return null; - const minMove = Number(tick); - if (minMove >= 1) return 0; - const raw = String(minMove); - const sci = raw.match(/e-(\d+)/i); - if (sci) return Math.min(12, parseInt(sci[1], 10)); - const fixed = minMove.toFixed(12); - const frac = fixed.split(".")[1] || ""; - const trimmed = frac.replace(/0+$/, ""); - if (trimmed.length) return Math.min(12, trimmed.length); - return Math.max(0, Math.min(12, Math.round(-Math.log10(minMove)))); - } - - const SAFE_PRICE_FORMAT = { type: "price", precision: 4, minMove: 0.0001 }; - - function tickToPriceFormat(tick) { - try { - if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) { - return { type: "price", precision: 2, minMove: 0.01 }; - } - const minMove = Number(tick); - let prec = decimalsFromTick(minMove); - if (prec == null || prec < 0) prec = 4; - prec = Math.min(12, Math.max(0, Math.floor(prec))); - return { type: "price", precision: prec, minMove: minMove }; - } catch (e) { - return SAFE_PRICE_FORMAT; - } - } - - function roundToTick(v) { - if (v == null || Number.isNaN(Number(v))) return v; - const n = Number(v); - const tick = priceTick; - if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) return n; - const t = Number(tick); - const rounded = Math.round(n / t) * t; - const dec = decimalsFromTick(t); - if (dec == null) return rounded; - return parseFloat(rounded.toFixed(dec)); - } - - function alignCandlesToTick(candles) { - if (!Array.isArray(candles) || !candles.length) return candles || []; - if (priceTick == null || !Number.isFinite(Number(priceTick)) || Number(priceTick) <= 0) { - return candles; - } - return candles.map(function (c) { - return { - time: c.time, - open: roundToTick(c.open), - high: roundToTick(c.high), - low: roundToTick(c.low), - close: roundToTick(c.close), - volume: c.volume, - }; - }); - } - - function applyPriceFormatToSeries(series, pf) { - if (!series || !series.applyOptions) return; - try { - series.applyOptions({ priceFormat: pf }); - } catch (e) { - series.applyOptions({ priceFormat: SAFE_PRICE_FORMAT }); - } - } - - function applyChartPriceFormat() { - let pf = SAFE_PRICE_FORMAT; - try { - pf = tickToPriceFormat(priceTick); - } catch (e) { - pf = SAFE_PRICE_FORMAT; - } - applyPriceFormatToSeries(candleSeries, pf); - applyPriceFormatToSeries(indSeries.ema21, pf); - applyPriceFormatToSeries(indSeries.ema55, pf); - if (chart) { - chart.applyOptions({ - localization: buildChartLocalization(), - }); - } - } - - function fmtPrice(v) { - if (v == null || Number.isNaN(Number(v))) return "-"; - const aligned = roundToTick(v); - const n = Number(aligned); - if (n === 0) return "0"; - const dec = decimalsFromTick(priceTick); - if (dec != null) return n.toFixed(dec); - const av = Math.abs(n); - let d = 8; - if (av >= 10000) d = 2; - else if (av >= 100) d = 3; - else if (av >= 1) d = 4; - else if (av >= 0.01) d = 6; - let text = n.toFixed(d); - if (text.indexOf(".") >= 0) text = text.replace(/\.?0+$/, ""); - return text; - } - - function exchangeLabel() { - if (!elExchange) return ""; - const opt = elExchange.options[elExchange.selectedIndex]; - if (opt && opt.textContent) return opt.textContent.trim(); - return (elExchange.value || "").trim().toUpperCase(); - } - - function updateExchangeDisplay() { - const label = exchangeLabel(); - if (elExLabel) elExLabel.textContent = label; - if (elExBadge) { - elExBadge.textContent = label; - elExBadge.setAttribute("aria-hidden", label ? "false" : "true"); - } - } - - function updateHeaderLabels(sym, tf) { - if (elSymLabel) elSymLabel.textContent = sym || "—"; - if (elTfLabel) elTfLabel.textContent = tf || "—"; - updateExchangeDisplay(); - } - - function fmtAmplitude(bar) { - if (!bar) return "-"; - const o = Number(bar.open); - const h = Number(bar.high); - const l = Number(bar.low); - if (!o || o <= 0 || !Number.isFinite(h) || !Number.isFinite(l)) return "-"; - return (((h - l) / o) * 100).toFixed(2) + "%"; - } - - function barRemainMs(tf) { - const period = TF_MS[tf] || TF_MS["1d"]; - const now = Date.now(); - const barOpen = Math.floor(now / period) * period; - return Math.max(0, barOpen + period - now); - } - - function fmtBarCountdown(ms) { - const total = Math.max(0, Math.floor(ms / 1000)); - const h = Math.floor(total / 3600); - const m = Math.floor((total % 3600) / 60); - const s = total % 60; - const pad = function (n) { - return n < 10 ? "0" + n : String(n); - }; - if (h > 0) return h + ":" + pad(m) + ":" + pad(s); - return pad(m) + ":" + pad(s); - } - - function paintOhlcv(bar) { - if (!bar) { - ["o", "h", "l", "c", "v", "amp"].forEach(function (k) { - const el = { o: elO, h: elH, l: elL, c: elC, v: elV, amp: elAmp }[k]; - if (el) el.textContent = "-"; - }); - return; - } - if (elO) elO.textContent = fmtPrice(bar.open); - if (elH) elH.textContent = fmtPrice(bar.high); - if (elL) elL.textContent = fmtPrice(bar.low); - if (elC) elC.textContent = fmtPrice(bar.close); - if (elV) elV.textContent = fmtVol(bar.volume); - if (elAmp) elAmp.textContent = fmtAmplitude(bar); - } - - function latestCandle() { - return lastCandles.length ? lastCandles[lastCandles.length - 1] : null; - } - - function showLatestOhlcv() { - paintOhlcv(latestCandle()); - updateCurrentPriceLine(); - updatePriceTag(); - } - - function clearCurrentPriceLine() { - if (currentPriceLine && candleSeries) { - try { - candleSeries.removePriceLine(currentPriceLine); - } catch (e) {} - } - currentPriceLine = null; - } - - function updateCurrentPriceLine() { - clearCurrentPriceLine(); - if (!candleSeries) return; - const bar = latestCandle(); - if (!bar || bar.close == null) return; - const up = Number(bar.close) >= Number(bar.open); - currentPriceLine = candleSeries.createPriceLine({ - price: Number(roundToTick(bar.close)), - color: up ? "#00ff9d" : "#ff4d6d", - lineWidth: 1, - lineStyle: 2, - axisLabelVisible: false, - title: "", - }); - } - - function tickLiveClock() { - const cd = fmtBarCountdown(barRemainMs(currentTf)); - if (elPriceTagTime && elPriceTag && !elPriceTag.classList.contains("hidden")) { - elPriceTagTime.textContent = cd; - } - if (elBarCountdown) elBarCountdown.textContent = "距收盘 " + cd; - } - - function updatePriceTag() { - if (!elPriceTag || !candleSeries || !chart) return; - try { - tickLiveClock(); - const bar = latestCandle(); - if (!bar || bar.close == null) { - elPriceTag.classList.add("hidden"); - elPriceTag.setAttribute("aria-hidden", "true"); - return; - } - let y = null; - try { - y = candleSeries.priceToCoordinate(Number(bar.close)); - } catch (e) { - y = null; - } - const hostH = chartHost.clientHeight || 0; - if (y == null || y < 8 || y > hostH - 8) { - elPriceTag.classList.add("hidden"); - elPriceTag.setAttribute("aria-hidden", "true"); - return; - } - const up = Number(bar.close) >= Number(bar.open); - elPriceTag.classList.remove("hidden", "is-up", "is-down"); - elPriceTag.classList.add(up ? "is-up" : "is-down"); - elPriceTag.setAttribute("aria-hidden", "false"); - elPriceTag.style.left = "auto"; - elPriceTag.style.right = "0"; - elPriceTag.style.top = y + "px"; - if (elPriceTagValue) elPriceTagValue.textContent = fmtPrice(bar.close); - } catch (e) { - elPriceTag.classList.add("hidden"); - elPriceTag.setAttribute("aria-hidden", "true"); - } - } - - function startPriceTagTimer() { - stopPriceTagTimer(); - tickLiveClock(); - priceTagTimer = setInterval(tickLiveClock, 1000); - } - - function stopPriceTagTimer() { - if (priceTagTimer) clearInterval(priceTagTimer); - priceTagTimer = null; - } - - function applyPriceAutoScale() { - if (!chart) return; - chart.priceScale("right").applyOptions({ autoScale: priceAutoScale }); - if (elPriceAuto) elPriceAuto.classList.toggle("is-on", priceAutoScale); - } - - function indexCandles(candles) { - candleByTime = {}; - (candles || []).forEach(function (c) { - if (c && c.time != null) candleByTime[c.time] = c; - }); - } - - function candleAtTime(t) { - if (t == null) return null; - return candleByTime[t] || null; - } - - function chartThemePalette() { - const light = document.documentElement.getAttribute("data-theme") === "light"; - return light - ? { - bg: "#f0f4f9", - text: "#4a6078", - border: "#b8c8d8", - up: "#0a8f5c", - down: "#c93552", - volUp: "rgba(10, 143, 92, 0.45)", - volDown: "rgba(201, 53, 82, 0.45)", - } - : { - bg: "#0a1018", - text: "#b8d4e8", - border: "#2a4058", - up: "#00ff9d", - down: "#ff4d6d", - volUp: "rgba(0, 255, 157, 0.5)", - volDown: "rgba(255, 77, 109, 0.5)", - }; - } - - function applyChartTheme() { - if (!chart) return; - const p = chartThemePalette(); - chart.applyOptions({ - layout: { background: { color: p.bg }, textColor: p.text }, - rightPriceScale: { borderColor: p.border }, - timeScale: { borderColor: p.border }, - }); - if (candleSeries) { - candleSeries.applyOptions({ - upColor: p.up, - downColor: p.down, - wickUpColor: p.up, - wickDownColor: p.down, - }); - } - if (volumeSeries && lastCandles.length) { - volumeSeries.setData(buildVolumeData(lastCandles)); - } - } - - function buildVolumeData(candles) { - const p = chartThemePalette(); - return (candles || []).map(function (c) { - const up = Number(c.close) >= Number(c.open); - return { - time: c.time, - value: Number(c.volume) || 0, - color: up ? p.volUp : p.volDown, - }; - }); - } - - function buildVolumeBar(candle) { - const p = chartThemePalette(); - const up = Number(candle.close) >= Number(candle.open); - return { - time: candle.time, - value: Number(candle.volume) || 0, - color: up ? p.volUp : p.volDown, - }; - } - - function ensureChart() { - if (chart && candleSeries && volumeSeries) return true; - if (!window.LightweightCharts) { - if (elStatus) { - elStatus.className = "market-status err"; - elStatus.textContent = "图表库加载失败"; - } - return false; - } - const tp = chartThemePalette(); - chart = LightweightCharts.createChart(chartHost, { - layout: { background: { color: tp.bg }, textColor: tp.text }, - grid: { - vertLines: { visible: false }, - horzLines: { visible: false }, - }, - rightPriceScale: { borderColor: tp.border, autoScale: true }, - localization: buildChartLocalization(), - timeScale: { - borderColor: tp.border, - timeVisible: true, - secondsVisible: false, - rightOffset: RIGHT_OFFSET_BARS, - }, - crosshair: { - mode: LightweightCharts.CrosshairMode - ? LightweightCharts.CrosshairMode.Normal - : 0, - }, - }); - - const candleOpts = { - upColor: tp.up, - downColor: tp.down, - borderVisible: false, - wickUpColor: tp.up, - wickDownColor: tp.down, - lastValueVisible: false, - priceLineVisible: false, - priceFormat: SAFE_PRICE_FORMAT, - }; - - if (typeof chart.addCandlestickSeries === "function") { - candleSeries = chart.addCandlestickSeries(candleOpts); - } else if ( - typeof chart.addSeries === "function" && - window.LightweightCharts && - window.LightweightCharts.CandlestickSeries - ) { - candleSeries = chart.addSeries(window.LightweightCharts.CandlestickSeries, candleOpts); - } - if (!candleSeries) return false; - - const volOpts = { - priceFormat: { type: "volume" }, - priceScaleId: "", - lastValueVisible: false, - }; - if (typeof chart.addHistogramSeries === "function") { - volumeSeries = chart.addHistogramSeries(volOpts); - } else if ( - typeof chart.addSeries === "function" && - window.LightweightCharts && - window.LightweightCharts.HistogramSeries - ) { - volumeSeries = chart.addSeries(window.LightweightCharts.HistogramSeries, volOpts); - } - if (!volumeSeries) return false; - - applyScaleLayout(); - applyChartPriceFormat(); - applyPriceAutoScale(); - - chart.subscribeCrosshairMove(function (param) { - if (!param || param.time == null) { - showLatestOhlcv(); - return; - } - const bar = candleAtTime(param.time); - if (!bar) { - showLatestOhlcv(); - return; - } - paintOhlcv(bar); - }); - - chart.timeScale().subscribeVisibleLogicalRangeChange(function (range) { - if (!chartDataLoading && range && !suppressRangeUserLock) { - markChartRangeUserAdjusted(); - } - scheduleRangeUiUpdate(); - if ( - !range || - chartDataLoading || - loadingLeft || - exhaustedLeft || - !lastCandles.length || - !lastViewKey - ) { - return; - } - if (currentChartViewKey() !== lastViewKey) return; - scheduleLoadOlderOnRange(range); - }); - - window.addEventListener("resize", function () { - scheduleChartResize(); - }); - scheduleChartResize(); - ensureDrawLayer(); - return true; - } - - function clearMarkers() { - rangeMarkers.forEach(function (m) { - try { - candleSeries.removePriceLine(m); - } catch (e) {} - }); - rangeMarkers = []; - } - - function clearYesterdayPriceLines() { - if (candleSeries) { - yesterdayPriceLines.forEach(function (m) { - try { - candleSeries.removePriceLine(m); - } catch (e) {} - }); - } - yesterdayPriceLines = []; - } - - function updateYesterdayPriceLines() { - clearYesterdayPriceLines(); - if (!candleSeries || !lastCandles.length) return; - const showClose = !!(elPrevCloseLine && elPrevCloseLine.checked); - const showHl = !!(elPrevHlLines && elPrevHlLines.checked); - if (!showClose && !showHl) return; - const stats = computePrevTradingDayOhlc(lastCandles, chartResetHour()); - if (!stats) return; - if (showClose && stats.close != null && Number.isFinite(Number(stats.close))) { - const px = Number(roundToTick(stats.close)); - if (Number.isFinite(px)) { - yesterdayPriceLines.push( - candleSeries.createPriceLine({ - price: px, - color: "#a78bfa", - lineWidth: 1, - lineStyle: 2, - axisLabelVisible: true, - title: "昨收", - }) - ); - } - } - if (showHl) { - if (stats.high != null && Number.isFinite(Number(stats.high))) { - const hiPx = Number(roundToTick(stats.high)); - if (Number.isFinite(hiPx)) { - yesterdayPriceLines.push( - candleSeries.createPriceLine({ - price: hiPx, - color: "#ffb84d", - lineWidth: 1, - lineStyle: 2, - axisLabelVisible: true, - title: "昨高", - }) - ); - } - } - if (stats.low != null && Number.isFinite(Number(stats.low))) { - const loPx = Number(roundToTick(stats.low)); - if (Number.isFinite(loPx)) { - yesterdayPriceLines.push( - candleSeries.createPriceLine({ - price: loPx, - color: "#4cd97f", - lineWidth: 1, - lineStyle: 2, - axisLabelVisible: true, - title: "昨低", - }) - ); - } - } - } - } - - function viewKey(exKey, sym, tf) { - const ex = String(exKey || "").trim().toLowerCase(); - const s = normalizeMarketSymbol(sym); - const t = String(tf || "").trim(); - return ex + "|" + s + "|" + t; - } - - function lookupSeriesMapEntry(map, vKey) { - if (!map || !vKey) return null; - if (map[vKey]) return map[vKey]; - const parts = String(vKey).split("|"); - if (parts.length === 3) { - const norm = viewKey(parts[0], parts[1], parts[2]); - if (norm !== vKey && map[norm]) return map[norm]; - } - return null; - } - - function chartInitialLimit(tf) { - return CHART_INITIAL_LIMITS[tf] || 200; - } - - function chartChunkLimit(tf) { - return CHART_CHUNK_LIMITS[tf] || 200; - } - - function chartMemoryCap(tf) { - return CHART_MEMORY_CAPS[tf] || 1000; - } - - function resetChartHistoryState() { - exhaustedLeft = false; - loadingLeft = false; - } - - function currentChartViewKey() { - const exKey = (elExchange && elExchange.value) || ""; - const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || ""; - const tf = (elTf && elTf.value) || currentTf || "1d"; - if (!exKey || !sym) return ""; - return viewKey(exKey, sym, tf); - } - - function isVisibleRangeValidForCandles(range, candleCount) { - if (!range || candleCount <= 0) return false; - const maxTo = candleCount - 1 + RIGHT_OFFSET_BARS; - if (range.from < -2 || range.to < 0) return false; - if (range.to > maxTo + 8) return false; - if (range.from > candleCount - 1) return false; - return true; - } - - function markChartRangeUserAdjusted() { - chartRangeUserLocked = true; - if (chartRangeLockTimer) clearTimeout(chartRangeLockTimer); - chartRangeLockTimer = setTimeout(function () { - chartRangeLockTimer = null; - chartRangeUserLocked = false; - }, 30000); - } - - function clampVisibleLogicalRange(range, candleCount) { - if (!range || candleCount <= 0) return null; - const maxTo = candleCount - 1 + RIGHT_OFFSET_BARS; - const from = Math.max(-2, Math.min(range.from, candleCount - 1)); - const to = Math.max(0, Math.min(range.to, maxTo + 8)); - if (to <= from) return null; - return { from: from, to: to }; - } - - function restoreVisibleLogicalRange(range, candleCount) { - const clamped = clampVisibleLogicalRange(range, candleCount); - if (!chart || !clamped || !isVisibleRangeValidForCandles(clamped, candleCount)) return false; - suppressRangeUserLock = true; - chart.timeScale().setVisibleLogicalRange(clamped); - suppressRangeUserLock = false; - return true; - } - - function applyPreservedVisibleRange(range, candleCount) { - if (!chart || !range || !candleCount) return; - function applyOnce() { - if (!chart || !lastCandles.length) return; - applyChartRightGap(); - restoreVisibleLogicalRange(range, lastCandles.length); - updateVisibleRangeMarkers(); - updateYesterdayPriceLines(); - } - applyOnce(); - requestAnimationFrame(applyOnce); - setTimeout(applyOnce, 0); - } - - function shouldLoadOlderOnRange(range) { - if (!range || !lastCandles.length) return false; - const n = lastCandles.length; - const maxTo = n - 1 + RIGHT_OFFSET_BARS; - if (range.from >= CHART_LOAD_LEFT_THRESHOLD) return false; - // 缩小图表时 from 会变小,但 to 仍靠近最新 — 不应触发左拖补历史 - if (range.to >= maxTo - 30) return false; - return true; - } - - function scheduleRangeUiUpdate() { - if (rangeUiTimer) clearTimeout(rangeUiTimer); - rangeUiTimer = setTimeout(function () { - rangeUiTimer = null; - updateVisibleRangeMarkers(); - updatePriceTag(); - }, 120); - } - - function scheduleLoadOlderOnRange(range) { - if (!shouldLoadOlderOnRange(range)) return; - if (loadOlderTimer) clearTimeout(loadOlderTimer); - loadOlderTimer = setTimeout(function () { - loadOlderTimer = null; - if (!chart) return; - const cur = chart.timeScale().getVisibleLogicalRange(); - if (!shouldLoadOlderOnRange(cur)) return; - void loadOlderCandles(); - }, 280); - } - - function tailVisibleLogicalRange(candleCount) { - const n = Math.max(0, Number(candleCount) || 0); - if (n <= 0) return null; - const visible = Math.min(DEFAULT_VISIBLE_BARS, n); - return { - from: Math.max(0, n - visible), - to: n - 1 + RIGHT_OFFSET_BARS, - }; - } - - function clearChartSeriesData() { - lastCandles = []; - candleByTime = {}; - clearYesterdayPriceLines(); - if (candleSeries) candleSeries.setData([]); - if (volumeSeries) volumeSeries.setData([]); - } - - function mergeCandles(existing, incoming, opts) { - opts = opts || {}; - const prepend = !!opts.prepend; - const byTime = {}; - (existing || []).forEach(function (c) { - if (c && c.time != null) byTime[c.time] = c; - }); - (incoming || []).forEach(function (c) { - if (c && c.time != null) byTime[c.time] = c; - }); - let merged = Object.keys(byTime) - .map(function (t) { - return Number(t); - }) - .sort(function (a, b) { - return a - b; - }) - .map(function (t) { - return byTime[t]; - }); - const cap = chartMemoryCap(currentTf); - if (merged.length > cap) { - merged = prepend ? merged.slice(0, cap) : merged.slice(-cap); - } - return merged; - } - - /** 尾部静默刷新:仅 update 变更 K 线,不 setData,避免视口跳动 */ - function applyTailCandlePatch(incoming) { - if (!candleSeries || !volumeSeries || !incoming || !incoming.length) return false; - const aligned = alignCandlesToTick(incoming); - const prevLen = lastCandles.length; - const oldestTime = prevLen ? lastCandles[0].time : null; - const prevLastTime = prevLen ? lastCandles[prevLen - 1].time : null; - const merged = mergeCandles(lastCandles, aligned, { prepend: false }); - if ( - prevLen > 0 && - merged.length > 0 && - merged[0].time !== oldestTime && - merged.length <= prevLen - ) { - return false; - } - let patchStart = 0; - if (prevLastTime != null) { - patchStart = merged.findIndex(function (b) { - return b.time >= prevLastTime; - }); - if (patchStart < 0) return false; - } - try { - for (let i = patchStart; i < merged.length; i++) { - const bar = merged[i]; - candleSeries.update(bar); - volumeSeries.update(buildVolumeBar(bar)); - } - } catch (_) { - return false; - } - lastCandles = merged; - indexCandles(lastCandles); - readIndicatorState(); - if (indicatorState.ema || indicatorState.macd || indicatorState.rsi) { - try { - updateIndicators(); - } catch (indErr) {} - } - updateVisibleRangeMarkers(); - updateYesterdayPriceLines(); - showLatestOhlcv(); - return true; - } - - function applyCandlesToChart(candles, rangeShift, opts) { - opts = opts || {}; - let savedRange = null; - if (opts.preserveRange && chart) { - savedRange = chart.timeScale().getVisibleLogicalRange(); - } - lastCandles = alignCandlesToTick(candles); - indexCandles(lastCandles); - candleSeries.setData(lastCandles); - volumeSeries.setData(buildVolumeData(lastCandles)); - if (!opts.skipRightGap) { - applyChartRightGap(); - } - if (rangeShift && chart) { - const range = chart.timeScale().getVisibleLogicalRange(); - if (range) { - suppressRangeUserLock = true; - chart.timeScale().setVisibleLogicalRange({ - from: range.from + rangeShift, - to: range.to + rangeShift, - }); - suppressRangeUserLock = false; - } - } else if (savedRange) { - restoreVisibleLogicalRange(savedRange, lastCandles.length); - } - if (!opts.skipAutoScale) { - applyPriceAutoScale(); - } - updateVisibleRangeMarkers(); - updateYesterdayPriceLines(); - try { - updateIndicators(); - } catch (indErr) {} - showLatestOhlcv(); - } - - async function fetchChartChunk(params) { - const qs = new URLSearchParams({ - exchange_key: params.exchange_key, - symbol: params.symbol, - timeframe: params.timeframe, - limit: String(params.limit), - }); - if (params.before_ms) qs.set("before_ms", String(params.before_ms)); - if (params.refresh) qs.set("refresh", "1"); - if (params.tail) qs.set("tail", "1"); - const r = await fetch("/api/chart/ohlcv?" + qs.toString(), { credentials: "same-origin" }); - const data = await r.json(); - if (!r.ok) { - throw new Error(data.detail || data.msg || "请求失败"); - } - return data; - } - - async function loadOlderCandles() { - if (chartDataLoading || loadingLeft || exhaustedLeft || !lastCandles.length) return; - const exKey = (elExchange && elExchange.value) || ""; - const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || ""; - const tf = (elTf && elTf.value) || "1d"; - if (!exKey || !sym) return; - const vKey = viewKey(exKey, sym, tf); - if (!lastViewKey || vKey !== lastViewKey) return; - loadingLeft = true; - const beforeMs = Number(lastCandles[0].time) * 1000; - try { - const data = await fetchChartChunk({ - exchange_key: exKey, - symbol: sym, - timeframe: tf, - limit: chartChunkLimit(tf), - before_ms: beforeMs, - }); - if (data.exhausted) exhaustedLeft = true; - const incoming = alignCandlesToTick(data.candles || []); - if (!incoming.length) return; - const prevLen = lastCandles.length; - const merged = mergeCandles(lastCandles, incoming, { prepend: true }); - const shift = merged.length - prevLen; - applyCandlesToChart(merged, shift); - if (elStatus && !elStatus.classList.contains("err")) { - elStatus.textContent = - "已加载 " + - lastCandles.length + - " 根(向左 +" + - incoming.length + - (exhaustedLeft ? " · 已到最早" : "") + - ")"; - } - } catch (e) { - if (elStatus) { - elStatus.className = "market-status warn"; - elStatus.textContent = "加载更早 K 线失败:" + String(e.message || e); - } - } finally { - loadingLeft = false; - } - } - - function applyIncomingTailCandles(incoming, meta) { - meta = meta || {}; - const vKey = currentViewSeriesKey(); - if (!vKey || !lastCandles.length || chartDataLoading) return false; - if (!lastViewKey || vKey !== lastViewKey) return false; - const epochAtStart = chartViewEpoch; - const autoFollow = priceAutoScale; - let savedRange = null; - if (chart) savedRange = chart.timeScale().getVisibleLogicalRange(); - if (!incoming || !incoming.length) return false; - if (meta.price_tick != null) { - priceTick = meta.price_tick; - try { - applyChartPriceFormat(); - } catch (fmtErr) { - priceTick = null; - applyChartPriceFormat(); - } - } - const aligned = alignCandlesToTick(incoming); - let tailPatched = false; - if (!autoFollow) { - try { - tailPatched = applyTailCandlePatch(aligned); - } catch (_) { - tailPatched = false; - } - } - if (!autoFollow && tailPatched) { - /* 手动模式:增量 update,不触碰时间轴 */ - } else { - const merged = mergeCandles(lastCandles, aligned, { prepend: false }); - applyCandlesToChart(merged, 0, { - preserveRange: false, - skipAutoScale: !autoFollow, - skipRightGap: !autoFollow, - }); - if (epochAtStart !== chartViewEpoch) return false; - const n = lastCandles.length; - if (autoFollow) { - applyDefaultVisibleRange(); - } else if (savedRange) { - applyPreservedVisibleRange(savedRange, n); - } - } - if (epochAtStart !== chartViewEpoch) return false; - scheduleRangeUiUpdate(); - if (posContext) { - updateLivePosPnl(); - refreshPosPnlFromBoard(); - } - if (meta.series_version != null) { - localSeriesVersion = Number(meta.series_version) || localSeriesVersion; - } - if (meta.chart_version != null) { - localChartVersion = Number(meta.chart_version) || localChartVersion; - } - if (elUpdated) elUpdated.textContent = "数据 " + (meta.updated_at || "--"); - tickLiveClock(); - if (window.HubChartDraw && drawAttached) window.HubChartDraw.redraw(); - return true; - } - - async function refreshChartTail() { - const exKey = (elExchange && elExchange.value) || ""; - const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || ""; - const tf = (elTf && elTf.value) || "1d"; - const vKey = viewKey(exKey, sym, tf); - if (!exKey || !sym || !lastCandles.length || chartDataLoading) return; - if (!lastViewKey || vKey !== lastViewKey) return; - const myToken = loadToken; - const epochAtStart = chartViewEpoch; - try { - const data = await fetchChartChunk({ - exchange_key: exKey, - symbol: sym, - timeframe: tf, - limit: CHART_TAIL_REFRESH_LIMIT, - tail: true, - }); - if (myToken !== loadToken) return; - if (vKey !== lastViewKey) return; - if (epochAtStart !== chartViewEpoch) return; - if (!data.ok || !data.candles || !data.candles.length) return; - applyIncomingTailCandles(data.candles, { - price_tick: data.price_tick, - series_version: data.series_version, - chart_version: data.chart_version, - updated_at: data.updated_at, - }); - } catch (_) {} - } - - function applyChartRightGap() { - if (!chart) return; - chart.timeScale().applyOptions({ - rightOffset: RIGHT_OFFSET_BARS, - fixRightEdge: false, - }); - } - - function applyDefaultVisibleRange() { - if (!chart || !lastCandles.length) return; - function applyOnce() { - if (!chart || !lastCandles.length) return; - const r = tailVisibleLogicalRange(lastCandles.length); - if (!r) return; - applyChartRightGap(); - restoreVisibleLogicalRange(r, lastCandles.length); - updateVisibleRangeMarkers(); - } - applyOnce(); - requestAnimationFrame(applyOnce); - setTimeout(applyOnce, 0); - } - - function updateVisibleRangeMarkers() { - clearMarkers(); - if (!candleSeries || !chart || !lastCandles.length) return; - - const range = chart.timeScale().getVisibleLogicalRange(); - if (!range) return; - - const from = Math.max(0, Math.floor(range.from)); - const to = Math.min(lastCandles.length - 1, Math.ceil(range.to)); - if (to < from) return; - - let hi = null; - let lo = null; - for (let i = from; i <= to; i++) { - const c = lastCandles[i]; - if (!c) continue; - if (!hi || c.high > hi.high) hi = c; - if (!lo || c.low < lo.low) lo = c; - } - if (!hi || !lo) return; - - rangeMarkers.push( - candleSeries.createPriceLine({ - price: Number(roundToTick(hi.high)), - color: "#ffb84d", - lineWidth: 1, - lineStyle: 2, - axisLabelVisible: true, - title: "高点", - }) - ); - rangeMarkers.push( - candleSeries.createPriceLine({ - price: Number(roundToTick(lo.low)), - color: "#4cd97f", - lineWidth: 1, - lineStyle: 2, - axisLabelVisible: true, - title: "低点", - }) - ); - } - - function readQuery() { - const qs = new URLSearchParams(window.location.search); - const ex = qs.get("exchange_key") || qs.get("exchange") || ""; - const sym = qs.get("symbol") || ""; - const tf = qs.get("timeframe") || ""; - if (ex && elExchange) elExchange.value = ex; - if (sym && elSymbol) elSymbol.value = sym; - if (tf && elTf) elTf.value = tf; - } - - function applyDefaults() { - if (elSymbol && !elSymbol.value.trim()) elSymbol.value = "BTC/USDT"; - if (elTf && !elTf.value) elTf.value = "1d"; - } - - function currentViewSeriesKey() { - const exKey = (elExchange && elExchange.value) || ""; - const sym = (elSymbol && elSymbol.value.trim()) || ""; - const tf = (elTf && elTf.value) || "1d"; - if (!exKey || !sym) return ""; - return viewKey(exKey, sym, tf); - } - - function postChartWatch() { - const exKey = (elExchange && elExchange.value) || ""; - const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || ""; - const tf = (elTf && elTf.value) || "1d"; - if (!exKey || !sym) return Promise.resolve(); - return fetch("/api/chart/watch", { - method: "POST", - credentials: "same-origin", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ exchange_key: exKey, symbol: sym, timeframe: tf }), - }).catch(function () {}); - } - - function postChartUnwatch() { - const exKey = (elExchange && elExchange.value) || ""; - const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || ""; - const tf = (elTf && elTf.value) || "1d"; - if (!exKey || !sym) return Promise.resolve(); - return fetch("/api/chart/unwatch", { - method: "POST", - credentials: "same-origin", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ exchange_key: exKey, symbol: sym, timeframe: tf }), - }).catch(function () {}); - } - - function closeChartStream() { - if (chartEventSource) { - chartEventSource.close(); - chartEventSource = null; - } - } - - function handleChartStreamEvent(st) { - if (!st || st.polling) return; - const vKey = currentViewSeriesKey(); - if (!vKey) return; - const tails = st.tails || {}; - const series = st.series || {}; - const tailPack = lookupSeriesMapEntry(tails, vKey); - if (tailPack && tailPack.candles && tailPack.candles.length) { - if ( - applyIncomingTailCandles(tailPack.candles, { - price_tick: tailPack.price_tick, - series_version: tailPack.series_version, - chart_version: st.chart_version, - updated_at: tailPack.updated_at || st.updated_at, - }) - ) { - return; - } - } - const seriesEntry = lookupSeriesMapEntry(series, vKey); - const sVer = seriesEntry ? Number(seriesEntry.series_version) || 0 : 0; - const seriesChanged = sVer > 0 && sVer !== localSeriesVersion; - if (seriesChanged) { - if (lastCandles.length && vKey === lastViewKey) { - void refreshChartTail(); - } else if (!lastCandles.length && !chartDataLoading) { - void loadChart(false); - } - return; - } - if (tailPack && lastCandles.length && vKey === lastViewKey && !chartDataLoading) { - void refreshChartTail(); - return; - } - if (posContext) updateLivePosPnl(); - const ver = Number(st.chart_version) || 0; - if (ver && ver !== localChartVersion) { - localChartVersion = ver; - if (lastCandles.length && vKey === lastViewKey && !chartDataLoading) { - void refreshChartTail(); - } - } - } - - function connectChartStream() { - closeChartStream(); - const page = document.getElementById("page-market"); - if (!page || page.classList.contains("hidden")) return; - chartEventSource = new EventSource("/api/chart/stream"); - chartEventSource.addEventListener("chart", function (ev) { - try { - handleChartStreamEvent(JSON.parse(ev.data || "{}")); - } catch (_) {} - }); - chartEventSource.onerror = function () { - closeChartStream(); - if (chartSseReconnectTimer) clearTimeout(chartSseReconnectTimer); - chartSseReconnectTimer = setTimeout(function () { - const p = document.getElementById("page-market"); - if (p && !p.classList.contains("hidden")) connectChartStream(); - }, 8000); - }; - } - - function startChartWatchHeartbeat() { - stopChartWatchHeartbeat(); - void postChartWatch(); - chartWatchTimer = setInterval(function () { - const page = document.getElementById("page-market"); - if (!page || page.classList.contains("hidden")) return; - void postChartWatch(); - }, CHART_WATCH_HEARTBEAT_MS); - } - - function stopChartWatchHeartbeat() { - if (chartWatchTimer) clearInterval(chartWatchTimer); - chartWatchTimer = null; - } - - function startAutoRefresh() { - stopAutoRefresh(); - const tick = function () { - const page = document.getElementById("page-market"); - if (!page || page.classList.contains("hidden")) return; - if (lastCandles.length) { - void refreshChartTail(); - } else if (!chartDataLoading) { - void loadChart(false); - } - }; - refreshTimer = setInterval(tick, CHART_SSE_FALLBACK_MS); - tick(); - } - - function stopAutoRefresh() { - if (refreshTimer) clearInterval(refreshTimer); - refreshTimer = null; - if (chartSseReconnectTimer) { - clearTimeout(chartSseReconnectTimer); - chartSseReconnectTimer = null; - } - } - - function stopChartLive() { - stopAutoRefresh(); - stopChartWatchHeartbeat(); - closeChartStream(); - void postChartUnwatch(); - } - - function mountVolRankSheet(forFullscreen) { - if (!elVolRankSheet) return; - const anchor = forFullscreen ? elVolRankAnchorFs : elVolRankAnchor; - if (!anchor || elVolRankSheet.parentElement === anchor) return; - anchor.appendChild(elVolRankSheet); - } - - function setVolRankBtnActive(btn, on) { - if (!btn) return; - btn.classList.toggle("is-active", on); - btn.setAttribute("aria-expanded", on ? "true" : "false"); - } - - function setVolRankSheetOpen(open) { - const on = !!open; - if (elVolRankSheet) { - elVolRankSheet.classList.toggle("hidden", !on); - elVolRankSheet.setAttribute("aria-hidden", on ? "false" : "true"); - } - setVolRankBtnActive(elVolRankBtn, on); - setVolRankBtnActive(elFsVolRankBtn, on); - if (on) void loadVolumeRank(); - } - - function bindVolRankPanel() { - function toggleVolRankSheet() { - const open = elVolRankSheet && elVolRankSheet.classList.contains("hidden"); - setVolRankSheetOpen(open); - } - if (elVolRankBtn) elVolRankBtn.addEventListener("click", toggleVolRankSheet); - if (elFsVolRankBtn) elFsVolRankBtn.addEventListener("click", toggleVolRankSheet); - document.addEventListener("pointerdown", function (ev) { - if (!elVolRankSheet || elVolRankSheet.classList.contains("hidden")) return; - const t = ev.target; - if (elVolRankSheet.contains(t)) return; - if (elVolRankBtn && elVolRankBtn.contains(t)) return; - if (elFsVolRankBtn && elFsVolRankBtn.contains(t)) return; - setVolRankSheetOpen(false); - }); - } - - function renderVolumeRank(data) { - if (!elVolRankMeta || !elVolRankList) return; - elVolRankList.innerHTML = ""; - if (!data || !data.ok || !data.items || !data.items.length) { - elVolRankMeta.textContent = - (data && data.msg) || - "暂无排名数据(请 pm2 restart 四实例与 manual-trading-hub 后重试)"; - return; - } - const resetHour = data.reset_hour != null ? data.reset_hour : 8; - const rankDate = data.rank_date || "—"; - const updated = data.updated_at || "—"; - const total = data.total_symbols != null ? data.total_symbols : ""; - const count = data.items.length; - const expect = data.expected_count != null ? data.expected_count : 20; - let meta = - "昨日成交 Top" + - expect + - " · 交易日 " + - rankDate + - " · 每早 " + - resetHour + - ":00 更新 · 显示 " + - count + - "/" + - expect + - " 条"; - if (total) meta += " · 全市场 " + total + " 个"; - if (data.stale) meta += " · 数据不完整,正在重拉…"; - meta += " · " + updated; - elVolRankMeta.textContent = meta; - const curSym = (elSymbol && elSymbol.value.trim().toUpperCase()) || ""; - data.items.forEach(function (row) { - const li = document.createElement("li"); - const btn = document.createElement("button"); - btn.type = "button"; - btn.className = "market-vol-rank-item"; - if (row.symbol && row.symbol.toUpperCase() === curSym) { - btn.classList.add("is-active"); - } - btn.dataset.symbol = row.symbol || ""; - btn.innerHTML = - '' + - (row.rank || "") + - '' + - (row.symbol || "") + - '' + - (row.volume_label || "") + - ""; - btn.addEventListener("click", function () { - if (!row.symbol) return; - if (elSymbol) elSymbol.value = row.symbol; - if (elFsSymbol) elFsSymbol.value = row.symbol; - setVolRankSheetOpen(false); - loadChart(false); - }); - li.appendChild(btn); - elVolRankList.appendChild(li); - }); - } - - async function loadVolumeRank(forceRefresh) { - const exKey = (elExchange && elExchange.value) || ""; - if (!exKey || !elVolRankMeta) return; - elVolRankMeta.textContent = "加载排名…"; - if (elVolRankList) elVolRankList.innerHTML = ""; - try { - let url = "/api/chart/volume-rank?exchange_key=" + encodeURIComponent(exKey); - if (forceRefresh) url += "&refresh=1"; - const r = await fetch(url, { credentials: "same-origin" }); - const data = await r.json(); - if (!r.ok) { - throw new Error((data && data.detail) || (data && data.msg) || "加载失败"); - } - renderVolumeRank(data); - const expect = data.expected_count != null ? data.expected_count : 20; - if (!forceRefresh && data.ok && data.items && data.items.length < expect) { - void loadVolumeRank(true); - } - } catch (e) { - renderVolumeRank({ ok: false, msg: String(e.message || e) }); - } - } - - async function loadMeta() { - const r = await fetch("/api/chart/meta", { credentials: "same-origin" }); - chartMeta = await r.json(); - if (!elExchange || !chartMeta.exchanges) return; - elExchange.innerHTML = ""; - chartMeta.exchanges.forEach(function (ex) { - const opt = document.createElement("option"); - opt.value = ex.key || ex.id; - opt.textContent = ex.name || ex.key; - elExchange.appendChild(opt); - }); - populateFsExchangeOptions(); - readQuery(); - applyDefaults(); - updateExchangeDisplay(); - } - - async function loadChart(force, options) { - options = options || {}; - const autoTick = !!options.autoTick; - if (autoTick) { - return refreshChartTail(); - } - localSeriesVersion = 0; - void postChartWatch(); - if (!ensureChart()) return; - const exKey = (elExchange && elExchange.value) || ""; - const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || ""; - const tf = (elTf && elTf.value) || "1d"; - currentTf = tf; - if (!exKey || !sym) { - if (elStatus) { - elStatus.className = "market-status err"; - elStatus.textContent = "请选择交易所并输入币种"; - } - return; - } - const myToken = ++loadToken; - const vKey = viewKey(exKey, sym, tf); - const resetView = !!force || vKey !== lastViewKey; - chartDataLoading = true; - if (resetView) { - chartViewEpoch += 1; - chartRangeUserLocked = false; - if (chartRangeLockTimer) { - clearTimeout(chartRangeLockTimer); - chartRangeLockTimer = null; - } - resetChartHistoryState(); - lastViewKey = ""; - clearChartSeriesData(); - } - if (elStatus) { - elStatus.className = "market-status"; - elStatus.textContent = "加载中…"; - } - updateHeaderLabels(sym, tf); - - try { - const data = await fetchChartChunk({ - exchange_key: exKey, - symbol: sym, - timeframe: tf, - limit: chartInitialLimit(tf), - refresh: !!force, - }); - if (myToken !== loadToken) return; - if (!data.ok || !data.candles || !data.candles.length) { - throw new Error(data.msg || "无 K 线"); - } - - priceTick = data.price_tick; - try { - applyChartPriceFormat(); - } catch (fmtErr) { - priceTick = null; - applyChartPriceFormat(); - } - applyCandlesToChart(alignCandlesToTick(data.candles), 0); - lastViewKey = vKey; - ensureDrawLayer(); - syncDrawViewKey(); - if (resetView) { - applyDefaultVisibleRange(); - } - syncPosContextForView(exKey, sym); - if (posContext) { - updateLivePosPnl(); - refreshPosPnlFromBoard(); - } - scheduleChartResize(); - - const limit = data.limit || lastCandles.length; - let hint = - "已加载 " + - lastCandles.length + - " 根(首屏 " + - limit + - ")· 库 " + - (data.from_cache || 0) + - " / 新拉 " + - (data.fetched || 0) + - (data.cleared ? " · 清库 " + data.cleared : "") + - " · 左拖加载更多 · 后台 " + - (data.chart_poll_interval_sec || 5) + - "s"; - if (data.stale && data.stale_message) { - hint += " · 缓存:" + data.stale_message; - } - if (elStatus) { - elStatus.className = data.stale ? "market-status warn" : "market-status"; - elStatus.textContent = hint; - } - if (elUpdated) elUpdated.textContent = "数据 " + (data.updated_at || "--"); - if (data.series_version != null) localSeriesVersion = Number(data.series_version) || localSeriesVersion; - if (data.chart_version != null) localChartVersion = Number(data.chart_version) || localChartVersion; - tickLiveClock(); - } catch (e) { - if (myToken !== loadToken) return; - if (elStatus) { - elStatus.className = "market-status err"; - elStatus.textContent = String(e.message || e); - } - } finally { - if (myToken === loadToken) chartDataLoading = false; - } - } - - function bind() { - bindSlDrag(); - bindVolRankPanel(); - if (elRefresh) { - elRefresh.addEventListener("click", function () { - loadChart(true); - }); - } - if (elTf) { - elTf.addEventListener("change", function () { - tfDigitBuf = ""; - if (tfDigitTimer) { - clearTimeout(tfDigitTimer); - tfDigitTimer = null; - } - currentTf = (elTf && elTf.value) || "1d"; - lastViewKey = ""; - tickLiveClock(); - syncFsToolbarFromMain(); - loadChart(false); - }); - } - if (elExchange) { - elExchange.addEventListener("change", function () { - updateExchangeDisplay(); - syncFsToolbarFromMain(); - lastViewKey = ""; - if (elVolRankSheet && !elVolRankSheet.classList.contains("hidden")) { - void loadVolumeRank(); - } - loadChart(false); - }); - } - if (elSymbol) { - elSymbol.addEventListener("keydown", function (e) { - if (e.key === "Enter") loadChart(false); - }); - elSymbol.addEventListener("change", function () { - loadChart(false); - }); - } - const btnLoad = document.getElementById("market-load"); - if (btnLoad) { - btnLoad.addEventListener("click", function () { - loadChart(false); - }); - } - if (elPriceAuto) { - elPriceAuto.addEventListener("click", function () { - priceAutoScale = !priceAutoScale; - applyPriceAutoScale(); - if (priceAutoScale) applyDefaultVisibleRange(); - }); - } - if (elPosClear) { - elPosClear.addEventListener("click", function () { - clearPosContext(); - }); - } - if (elFsBtn) { - elFsBtn.addEventListener("click", function () { - toggleChartFullscreen(); - }); - } - if (elFsExit) { - elFsExit.addEventListener("click", function () { - setChartFullscreen(false); - }); - } - [elIndEma, elIndMacd, elIndRsi].forEach(function (el) { - if (!el) return; - el.addEventListener("change", function () { - updateIndicators(); - }); - }); - if (elPrevCloseLine) { - elPrevCloseLine.checked = loadPrevCloseLinePref(); - elPrevCloseLine.addEventListener("change", syncPrevDayLineUi); - } - if (elPrevHlLines) { - elPrevHlLines.checked = loadPrevHlLinesPref(); - elPrevHlLines.addEventListener("change", syncPrevDayLineUi); - } - if (elDaySplit) { - elDaySplit.checked = loadDaySplitPref(); - elDaySplit.addEventListener("change", syncTradingDaySplitUi); - applyTradingDaySplit(elDaySplit.checked); - } - const pageMarket = document.getElementById("page-market"); - const fsKeyTargets = [window, pageMarket, elChartWrap, chartHost].filter(Boolean); - fsKeyTargets.forEach(function (el) { - el.addEventListener("keydown", onChartFullscreenKey, true); - }); - window.addEventListener("keydown", onMarketKeydown, true); - if (elChartWrap) { - if (!elChartWrap.hasAttribute("tabindex")) elChartWrap.setAttribute("tabindex", "-1"); - elChartWrap.addEventListener("mousedown", focusMarketChartArea); - } - if (elFsExchange) { - elFsExchange.addEventListener("change", function () { - syncMainFromFsToolbar(); - loadChart(false); - }); - } - if (elFsTf) { - elFsTf.addEventListener("change", function () { - currentTf = elFsTf.value || "1d"; - lastViewKey = ""; - syncMainFromFsToolbar(); - tickLiveClock(); - loadChart(false); - }); - } - if (elFsSymbol) { - elFsSymbol.addEventListener("keydown", function (e) { - if (e.key === "Enter") { - syncMainFromFsToolbar(); - loadChart(false); - } - }); - } - if (elFsLoad) { - elFsLoad.addEventListener("click", function () { - syncMainFromFsToolbar(); - loadChart(false); - }); - } - } - - window.hubMarketChart = { - init: async function () { - if (!marketInited) { - marketInited = true; - await loadMeta(); - bind(); - } else { - readQuery(); - } - focusMarketChartArea(); - connectChartStream(); - startChartWatchHeartbeat(); - startAutoRefresh(); - await loadChart(false); - startPriceTagTimer(); - }, - openWith: async function (exKey, sym, tf) { - if (!marketInited) { - await this.init(); - } - if (elExchange && exKey) elExchange.value = exKey; - if (elSymbol && sym) elSymbol.value = String(sym).trim().toUpperCase(); - if (tf && elTf) elTf.value = tf; - lastViewKey = ""; - localSeriesVersion = 0; - updateExchangeDisplay(); - connectChartStream(); - startChartWatchHeartbeat(); - startAutoRefresh(); - await loadChart(false); - startPriceTagTimer(); - }, - reload: function (force) { - loadChart(!!force); - }, - startAutoRefresh: startAutoRefresh, - stopAutoRefresh: stopAutoRefresh, - stopChartLive: stopChartLive, - stopPriceTagTimer: stopPriceTagTimer, - }; - - document.addEventListener("hub-theme-change", function () { - applyChartTheme(); - }); - - if ( - document.getElementById("page-market") && - !document.getElementById("page-market").classList.contains("hidden") - ) { - window.hubMarketChart.init(); - } -})(); +/** + * 中控行情区:K 线 + 成交量;Hub 后台轮询 + SSE 直推尾部 K 线;「自动」控制价格轴与视口跟随。 + */ +(function () { + const CHART_WATCH_HEARTBEAT_MS = 25000; + const CHART_SSE_FALLBACK_MS = 15000; + const DEFAULT_VISIBLE_BARS = 200; + const CHART_LOAD_LEFT_THRESHOLD = 25; + const CHART_INITIAL_LIMITS = { + "1m": 2000, + "5m": 2000, + "15m": 2000, + "1h": 1000, + "2h": 1000, + "4h": 1000, + "1d": 500, + "1w": 500, + }; + const CHART_CHUNK_LIMITS = { + "1m": 500, + "5m": 500, + "15m": 500, + "1h": 300, + "2h": 300, + "4h": 300, + "1d": 200, + "1w": 150, + }; + const CHART_MEMORY_CAPS = { + "1m": 5000, + "5m": 5000, + "15m": 5000, + "1h": 1000, + "2h": 1000, + "4h": 1000, + "1d": 1000, + "1w": 500, + }; + const RIGHT_OFFSET_BARS = 10; + const CANDLE_SCALE_BOTTOM = 0.26; + const VOLUME_SCALE_TOP = 0.73; + const VOLUME_SCALE_BOTTOM = 0.06; + const PANEL_VOL_H = 0.12; + const PANEL_MACD_H = 0.14; + const PANEL_RSI_H = 0.14; + const SWING_LOOKBACK = 4; + const MAX_DIV_MARKERS = 4; + const TF_MS = { + "1m": 60_000, + "5m": 5 * 60_000, + "15m": 15 * 60_000, + "1h": 60 * 60_000, + "2h": 2 * 60 * 60_000, + "4h": 4 * 60 * 60_000, + "1d": 24 * 60 * 60_000, + "1w": 7 * 24 * 60 * 60_000, + }; + const TF_BY_MINUTES = { + "1": "1m", + "5": "5m", + "15": "15m", + "60": "1h", + "120": "2h", + "240": "4h", + "1440": "1d", + "10080": "1w", + }; + const TF_MINUTE_KEYS = Object.keys(TF_BY_MINUTES).sort(function (a, b) { + return b.length - a.length; + }); + const TF_CN_LABEL = { + "1m": "1分钟", + "5m": "5分钟", + "15m": "15分钟", + "1h": "1小时", + "2h": "2小时", + "4h": "4小时", + "1d": "日线", + "1w": "周线", + }; + const TF_DIGIT_TIMEOUT_MS = 650; + const CHART_TZ_OFFSET_SEC = 8 * 60 * 60; + + function pad2(n) { + return n < 10 ? "0" + n : String(n); + } + + function utcSecToBjDate(utcSec) { + return new Date((Number(utcSec) + CHART_TZ_OFFSET_SEC) * 1000); + } + + function formatChartTimeBj(utcSec, withDate) { + const d = utcSecToBjDate(utcSec); + const h = pad2(d.getUTCHours()); + const mi = pad2(d.getUTCMinutes()); + if (!withDate) return h + ":" + mi; + return ( + d.getUTCFullYear() + + "-" + + pad2(d.getUTCMonth() + 1) + + "-" + + pad2(d.getUTCDate()) + + " " + + h + + ":" + + mi + ); + } + + function chartLocalizationBj() { + return { + locale: "zh-CN", + dateFormat: "yyyy-MM-dd", + timeFormatter: function (time) { + if (typeof time === "number") return formatChartTimeBj(time, true); + if (time && typeof time === "object" && time.year) { + return time.year + "-" + pad2(time.month) + "-" + pad2(time.day); + } + return ""; + }, + tickMarkFormatter: function (time, tickMarkType) { + if (typeof time !== "number") { + if (time && typeof time === "object" && time.year) { + return time.year + "-" + pad2(time.month) + "-" + pad2(time.day); + } + return ""; + } + const d = utcSecToBjDate(time); + if (tickMarkType === 0) return String(d.getUTCFullYear()); + if (tickMarkType === 1) return pad2(d.getUTCMonth() + 1); + if (tickMarkType === 2) return pad2(d.getUTCDate()); + return formatChartTimeBj(time, false); + }, + }; + } + + function buildChartLocalization() { + const loc = chartLocalizationBj(); + loc.priceFormatter = function (p) { + return fmtPrice(p); + }; + return loc; + } + + const chartHost = document.getElementById("market-chart"); + if (!chartHost) return; + + const elDrawToolbar = document.getElementById("market-draw-toolbar"); + const elDrawCanvas = document.getElementById("market-draw-canvas"); + const elChartMain = chartHost.closest(".market-chart-main"); + let drawAttached = false; + + const elExchange = document.getElementById("market-exchange"); + const elSymbol = document.getElementById("market-symbol"); + const elVolRankMeta = document.getElementById("market-vol-rank-meta"); + const elVolRankList = document.getElementById("market-vol-rank-list"); + const elVolRankBtn = document.getElementById("market-vol-rank-btn"); + const elFsVolRankBtn = document.getElementById("market-fs-vol-rank-btn"); + const elVolRankSheet = document.getElementById("market-vol-rank-sheet"); + const elVolRankAnchor = document.getElementById("market-vol-rank-anchor"); + const elVolRankAnchorFs = document.getElementById("market-vol-rank-anchor-fs"); + const elTf = document.getElementById("market-timeframe"); + const elRefresh = document.getElementById("market-refresh"); + const elStatus = document.getElementById("market-status"); + const elUpdated = document.getElementById("market-updated"); + const elBarCountdown = document.getElementById("market-bar-countdown"); + const elO = document.getElementById("mkt-o"); + const elH = document.getElementById("mkt-h"); + const elL = document.getElementById("mkt-l"); + const elC = document.getElementById("mkt-c"); + const elV = document.getElementById("mkt-v"); + const elAmp = document.getElementById("mkt-amp"); + const elPriceTag = document.getElementById("market-price-tag"); + const elPriceTagValue = document.getElementById("market-price-tag-value"); + const elPriceTagTime = document.getElementById("market-price-tag-time"); + const elExLabel = document.getElementById("mkt-exchange-label"); + const elExBadge = document.getElementById("market-exchange-badge"); + const elSymLabel = document.getElementById("mkt-symbol-label"); + const elTfLabel = document.getElementById("mkt-tf-label"); + const elPriceAuto = document.getElementById("market-price-auto"); + const elPosPanel = document.getElementById("market-pos-panel"); + const elPosSide = document.getElementById("mkt-pos-side"); + const elPosEntry = document.getElementById("mkt-pos-entry"); + const elPosSl = document.getElementById("mkt-pos-sl"); + const elPosTp = document.getElementById("mkt-pos-tp"); + const elPosSize = document.getElementById("mkt-pos-size"); + const elPosPnl = document.getElementById("mkt-pos-pnl"); + const elPosOrders = document.getElementById("market-pos-orders"); + const elPosClear = document.getElementById("market-pos-clear"); + const elChartWrap = document.getElementById("market-chart-wrap"); + const elFsBtn = document.getElementById("market-chart-fullscreen"); + const elFsExit = document.getElementById("market-chart-fs-exit"); + const elIndEma = document.getElementById("market-ind-ema"); + const elIndMacd = document.getElementById("market-ind-macd"); + const elIndRsi = document.getElementById("market-ind-rsi"); + const elPrevCloseLine = document.getElementById("market-prev-close-line"); + const elPrevHlLines = document.getElementById("market-prev-hl-lines"); + const elDaySplit = document.getElementById("market-day-split"); + const PREV_CLOSE_LINE_STORAGE_KEY = "hub-market-prev-close-line"; + const PREV_HL_LINES_STORAGE_KEY = "hub-market-prev-hl-lines"; + const DAY_SPLIT_STORAGE_KEY = "hub-market-day-split"; + const BJ_OFFSET_SEC = 8 * 60 * 60; + const elFsToolbar = document.getElementById("market-fs-toolbar"); + const elFsExchange = document.getElementById("market-fs-exchange"); + const elFsSymbol = document.getElementById("market-fs-symbol"); + const elFsTf = document.getElementById("market-fs-timeframe"); + const elFsLoad = document.getElementById("market-fs-load"); + const elDivLegend = document.getElementById("market-div-legend"); + + const HUB_MARKET_POS_CTX_KEY = "hubMarketPosContext"; + const EMA_FAST = 21; + const EMA_SLOW = 55; + + let chartFullscreen = false; + const indicatorState = { ema: false, macd: false, rsi: false }; + const indSeries = { + ema21: null, + ema55: null, + macdLine: null, + macdSignal: null, + macdHist: null, + rsi: null, + rsi30: null, + rsi70: null, + }; + let divergenceMarkers = []; + + let chart = null; + let candleSeries = null; + let volumeSeries = null; + let priceTick = null; + let priceAutoScale = true; + let rangeMarkers = []; + let yesterdayPriceLines = []; + let positionLines = []; + let posContext = null; + let posPnlTimer = null; + const SL_DRAG_HIT_PX = 12; + let slDrag = null; + let currentPriceLine = null; + let lastCandles = []; + let candleByTime = {}; + let chartMeta = null; + let loadToken = 0; + let marketInited = false; + let refreshTimer = null; + let chartWatchTimer = null; + let chartEventSource = null; + let chartSseReconnectTimer = null; + let localChartVersion = 0; + let localSeriesVersion = 0; + let lastViewKey = ""; + let currentTf = "1d"; + let exhaustedLeft = false; + let loadingLeft = false; + let chartDataLoading = false; + let chartViewEpoch = 0; + let rangeUiTimer = null; + let loadOlderTimer = null; + let chartRangeUserLocked = false; + let chartRangeLockTimer = null; + let suppressRangeUserLock = false; + const CHART_TAIL_REFRESH_LIMIT = 30; + let priceTagTimer = null; + let tfDigitBuf = ""; + let tfDigitTimer = null; + let tfHintTimer = null; + + function escHtml(s) { + return String(s || "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function normalizeMarketSymbol(sym) { + const s = String(sym || "").trim().toUpperCase(); + const m = s.match(/^([A-Z0-9]+)\/([A-Z0-9]+)(?::([A-Z0-9]+))?$/); + if (!m) return s; + return m[1] + "/" + m[2]; + } + + function loadPosContextFromStorage() { + try { + const raw = sessionStorage.getItem(HUB_MARKET_POS_CTX_KEY); + if (!raw) return null; + return JSON.parse(raw); + } catch (e) { + return null; + } + } + + function posContextMatches(ctx, exKey, sym) { + if (!ctx) return false; + const ctxSym = normalizeMarketSymbol(ctx.symbol || ""); + const ctxEx = String(ctx.exchange_key || "").trim(); + return ctxSym === normalizeMarketSymbol(sym) && ctxEx === String(exKey || "").trim(); + } + + function clearPosPanel() { + if (elPosPanel) elPosPanel.classList.add("hidden"); + if (elPosSide) { + elPosSide.textContent = ""; + elPosSide.className = "market-pos-side"; + } + ["entry", "sl", "tp", "size"].forEach(function (k) { + const el = { entry: elPosEntry, sl: elPosSl, tp: elPosTp, size: elPosSize }[k]; + if (el) el.textContent = "—"; + }); + if (elPosPnl) { + elPosPnl.textContent = "—"; + elPosPnl.className = "market-pos-pnl"; + } + if (elPosOrders) elPosOrders.innerHTML = ""; + syncChartWrapLayout(); + } + + function loadBoolPref(key, defaultValue) { + try { + const raw = localStorage.getItem(key); + if (raw === "1" || raw === "true") return true; + if (raw === "0" || raw === "false") return false; + } catch (_) {} + return !!defaultValue; + } + + function saveBoolPref(key, on) { + try { + localStorage.setItem(key, on ? "1" : "0"); + } catch (_) {} + } + + function loadDaySplitPref() { + return loadBoolPref(DAY_SPLIT_STORAGE_KEY, false); + } + + function saveDaySplitPref(on) { + saveBoolPref(DAY_SPLIT_STORAGE_KEY, on); + } + + function loadPrevCloseLinePref() { + return loadBoolPref(PREV_CLOSE_LINE_STORAGE_KEY, false); + } + + function savePrevCloseLinePref(on) { + saveBoolPref(PREV_CLOSE_LINE_STORAGE_KEY, on); + } + + function loadPrevHlLinesPref() { + return loadBoolPref(PREV_HL_LINES_STORAGE_KEY, false); + } + + function savePrevHlLinesPref(on) { + saveBoolPref(PREV_HL_LINES_STORAGE_KEY, on); + } + + function chartResetHour() { + return chartMeta && chartMeta.volume_rank_reset_hour != null + ? Number(chartMeta.volume_rank_reset_hour) + : 8; + } + + function utcSecToBjParts(utcSec) { + const d = new Date((Number(utcSec) + BJ_OFFSET_SEC) * 1000); + return { + y: d.getUTCFullYear(), + m: d.getUTCMonth(), + d: d.getUTCDate(), + h: d.getUTCHours(), + }; + } + + function tradingDayKeyFromUtcSec(utcSec, resetHour) { + const p = utcSecToBjParts(utcSec); + let y = p.y; + let m = p.m; + let d = p.d; + if (p.h < resetHour) { + const prev = new Date(Date.UTC(y, m, d) - 86400000); + y = prev.getUTCFullYear(); + m = prev.getUTCMonth(); + d = prev.getUTCDate(); + } + return ( + y + + "-" + + String(m + 1).padStart(2, "0") + + "-" + + String(d).padStart(2, "0") + ); + } + + function prevTradingDayKey(tdKey) { + const parts = String(tdKey || "").split("-"); + if (parts.length !== 3) return ""; + const dt = new Date(Date.UTC(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]))); + const prev = new Date(dt.getTime() - 86400000); + return ( + prev.getUTCFullYear() + + "-" + + String(prev.getUTCMonth() + 1).padStart(2, "0") + + "-" + + String(prev.getUTCDate()).padStart(2, "0") + ); + } + + function computePrevTradingDayOhlc(candles, resetHour) { + if (!candles || !candles.length) return null; + const curTd = tradingDayKeyFromUtcSec(candles[candles.length - 1].time, resetHour); + const prevTd = prevTradingDayKey(curTd); + if (!prevTd) return null; + const dayCandles = candles + .filter(function (c) { + return c && tradingDayKeyFromUtcSec(c.time, resetHour) === prevTd; + }) + .sort(function (a, b) { + return a.time - b.time; + }); + if (!dayCandles.length) return null; + let hi = null; + let lo = null; + dayCandles.forEach(function (c) { + if (!hi || c.high > hi) hi = c.high; + if (!lo || c.low < lo) lo = c.low; + }); + const last = dayCandles[dayCandles.length - 1]; + return { + close: last.close, + high: hi, + low: lo, + tradingDay: prevTd, + }; + } + + function syncPrevDayLineUi() { + const closeOn = !!(elPrevCloseLine && elPrevCloseLine.checked); + const hlOn = !!(elPrevHlLines && elPrevHlLines.checked); + savePrevCloseLinePref(closeOn); + savePrevHlLinesPref(hlOn); + updateYesterdayPriceLines(); + } + + function applyTradingDaySplit(enabled) { + if (window.HubChartDraw && typeof window.HubChartDraw.setTradingDaySplit === "function") { + window.HubChartDraw.setTradingDaySplit(enabled); + } + } + + function syncTradingDaySplitUi() { + const on = !!(elDaySplit && elDaySplit.checked); + saveDaySplitPref(on); + applyTradingDaySplit(on); + } + + function ensureDrawLayer() { + if (drawAttached || !window.HubChartDraw || !chart || !candleSeries) return; + window.HubChartDraw.attach({ + chart: chart, + series: candleSeries, + hostEl: chartHost, + mainEl: elChartMain, + canvasEl: elDrawCanvas, + toolbarEl: elDrawToolbar, + getCandles: function () { + return lastCandles; + }, + }); + window.HubChartDraw.setViewKey(currentChartViewKey()); + applyTradingDaySplit(elDaySplit ? elDaySplit.checked : loadDaySplitPref()); + drawAttached = true; + } + + function syncDrawViewKey() { + if (window.HubChartDraw && drawAttached) { + window.HubChartDraw.setViewKey(currentChartViewKey()); + } + } + + function resizeChart() { + if (!chart || !chartHost) return; + chart.applyOptions({ width: chartHost.clientWidth, height: chartHost.clientHeight }); + updatePriceTag(); + if (window.HubChartDraw && drawAttached) { + window.HubChartDraw.resize(); + } + } + + let resizeChartRaf = 0; + function scheduleChartResize() { + if (resizeChartRaf) cancelAnimationFrame(resizeChartRaf); + resizeChartRaf = requestAnimationFrame(function () { + resizeChartRaf = 0; + syncChartWrapLayout(); + }); + } + + function syncChartWrapLayout() { + const wrap = elChartWrap || (chartHost && chartHost.closest(".market-chart-wrap")); + if (wrap && elPosPanel && !chartFullscreen) { + wrap.classList.toggle("has-pos-panel", !elPosPanel.classList.contains("hidden")); + } + resizeChart(); + } + + function readIndicatorState() { + indicatorState.ema = !!(elIndEma && elIndEma.checked); + indicatorState.macd = !!(elIndMacd && elIndMacd.checked); + indicatorState.rsi = !!(elIndRsi && elIndRsi.checked); + } + + function emaArray(values, period) { + const result = new Array(values.length).fill(null); + const k = 2 / (period + 1); + let ema = null; + for (let i = 0; i < values.length; i++) { + const v = values[i]; + if (v == null || !Number.isFinite(v)) continue; + if (ema == null) { + if (i < period - 1) continue; + let sum = 0; + let ok = true; + for (let j = i - period + 1; j <= i; j++) { + const x = values[j]; + if (x == null || !Number.isFinite(x)) { + ok = false; + break; + } + sum += x; + } + if (!ok) continue; + ema = sum / period; + } else { + ema = v * k + ema * (1 - k); + } + result[i] = ema; + } + return result; + } + + function buildEmaSeries(candles, period) { + const closes = candles.map(function (c) { + return Number(c.close); + }); + const vals = emaArray(closes, period); + const out = []; + for (let i = 0; i < candles.length; i++) { + if (vals[i] == null) continue; + out.push({ time: candles[i].time, value: vals[i] }); + } + return out; + } + + function buildMacdData(candles) { + const closes = candles.map(function (c) { + return Number(c.close); + }); + const ema12 = emaArray(closes, 12); + const ema26 = emaArray(closes, 26); + const macd = new Array(closes.length).fill(null); + for (let i = 0; i < closes.length; i++) { + if (ema12[i] == null || ema26[i] == null) continue; + macd[i] = ema12[i] - ema26[i]; + } + const signal = emaArray(macd, 9); + const macdLine = []; + const signalLine = []; + const histData = []; + for (let i = 0; i < candles.length; i++) { + const t = candles[i].time; + if (macd[i] != null) macdLine.push({ time: t, value: macd[i] }); + if (signal[i] != null) signalLine.push({ time: t, value: signal[i] }); + if (macd[i] != null && signal[i] != null) { + const h = macd[i] - signal[i]; + histData.push({ + time: t, + value: h, + color: h >= 0 ? "rgba(0, 255, 157, 0.55)" : "rgba(255, 77, 109, 0.55)", + }); + } + } + return { macdLine, signalLine, histData }; + } + + function buildRsiSeries(candles, period) { + const out = []; + if (!candles || candles.length < period + 1) return out; + let avgGain = 0; + let avgLoss = 0; + for (let i = 1; i <= period; i++) { + const ch = Number(candles[i].close) - Number(candles[i - 1].close); + if (ch >= 0) avgGain += ch; + else avgLoss -= ch; + } + avgGain /= period; + avgLoss /= period; + let rsi = 50; + if (avgLoss <= 0) rsi = 100; + else if (avgGain <= 0) rsi = 0; + else rsi = 100 - 100 / (1 + avgGain / avgLoss); + out.push({ time: candles[period].time, value: rsi }); + + for (let i = period + 1; i < candles.length; i++) { + const ch = Number(candles[i].close) - Number(candles[i - 1].close); + const gain = ch > 0 ? ch : 0; + const loss = ch < 0 ? -ch : 0; + avgGain = (avgGain * (period - 1) + gain) / period; + avgLoss = (avgLoss * (period - 1) + loss) / period; + if (avgLoss <= 0) rsi = 100; + else if (avgGain <= 0) rsi = 0; + else rsi = 100 - 100 / (1 + avgGain / avgLoss); + out.push({ time: candles[i].time, value: rsi }); + } + return out; + } + + function createLineSeries(opts) { + if (!chart) return null; + const base = { + lineWidth: 1, + priceLineVisible: false, + lastValueVisible: false, + }; + const o = Object.assign(base, opts || {}); + if (typeof chart.addLineSeries === "function") return chart.addLineSeries(o); + if ( + typeof chart.addSeries === "function" && + window.LightweightCharts && + window.LightweightCharts.LineSeries + ) { + return chart.addSeries(window.LightweightCharts.LineSeries, o); + } + return null; + } + + function createHistSeries(opts) { + if (!chart) return null; + const base = { priceLineVisible: false, lastValueVisible: false }; + const o = Object.assign(base, opts || {}); + if (typeof chart.addHistogramSeries === "function") return chart.addHistogramSeries(o); + if ( + typeof chart.addSeries === "function" && + window.LightweightCharts && + window.LightweightCharts.HistogramSeries + ) { + return chart.addSeries(window.LightweightCharts.HistogramSeries, o); + } + return null; + } + + function clearIndicatorSeries() { + if (!chart) return; + [indSeries.rsi30, indSeries.rsi70].forEach(function (pl) { + if (pl && indSeries.rsi) { + try { + indSeries.rsi.removePriceLine(pl); + } catch (e) {} + } + }); + indSeries.rsi30 = null; + indSeries.rsi70 = null; + Object.keys(indSeries).forEach(function (k) { + if (k === "rsi30" || k === "rsi70") return; + if (indSeries[k]) { + try { + chart.removeSeries(indSeries[k]); + } catch (e) {} + indSeries[k] = null; + } + }); + } + + function findSwings(values, lookback) { + const lows = []; + const highs = []; + const lb = lookback || SWING_LOOKBACK; + for (let i = lb; i < values.length - lb; i++) { + const v = values[i]; + if (v == null || !Number.isFinite(v)) continue; + let isLow = true; + let isHigh = true; + for (let j = 1; j <= lb; j++) { + const lv = values[i - j]; + const rv = values[i + j]; + if (lv == null || rv == null || v > lv || v > rv) isLow = false; + if (lv == null || rv == null || v < lv || v < rv) isHigh = false; + } + if (isLow) lows.push({ i: i, v: v }); + if (isHigh) highs.push({ i: i, v: v }); + } + return { lows, highs }; + } + + function detectDivergences(candles, indicatorByIndex, sourceLabel) { + const markers = []; + if (!candles.length || !indicatorByIndex.length) return markers; + + const closes = candles.map(function (c) { + return Number(c.close); + }); + const priceSw = findSwings(closes, SWING_LOOKBACK); + const indSw = findSwings(indicatorByIndex, SWING_LOOKBACK); + + function pushMarker(idx, kind, label) { + const c = candles[idx]; + if (!c || c.time == null) return; + const bull = kind === "bull"; + markers.push({ + time: c.time, + position: bull ? "belowBar" : "aboveBar", + color: bull ? "#00ff9d" : "#ff4d6d", + shape: bull ? "arrowUp" : "arrowDown", + text: label, + }); + } + + const pLows = priceSw.lows; + const iLows = indSw.lows; + if (pLows.length >= 2 && iLows.length >= 2) { + const p1 = pLows[pLows.length - 2]; + const p2 = pLows[pLows.length - 1]; + const i1 = iLows[iLows.length - 2]; + const i2 = iLows[iLows.length - 1]; + if (Math.abs(p1.i - i1.i) < 30 && Math.abs(p2.i - i2.i) < 30) { + if (p2.v < p1.v && i2.v > i1.v) { + pushMarker(p2.i, "bull", sourceLabel + "底背离"); + } + } + } + + const pHighs = priceSw.highs; + const iHighs = indSw.highs; + if (pHighs.length >= 2 && iHighs.length >= 2) { + const p1 = pHighs[pHighs.length - 2]; + const p2 = pHighs[pHighs.length - 1]; + const i1 = iHighs[iHighs.length - 2]; + const i2 = iHighs[iHighs.length - 1]; + if (Math.abs(p1.i - i1.i) < 30 && Math.abs(p2.i - i2.i) < 30) { + if (p2.v > p1.v && i2.v < i1.v) { + pushMarker(p2.i, "bear", sourceLabel + "顶背离"); + } + } + } + + return markers.slice(-MAX_DIV_MARKERS); + } + + function buildRsiByIndex(candles, period) { + const series = buildRsiSeries(candles, period); + const byIdx = new Array(candles.length).fill(null); + let si = 0; + for (let i = 0; i < candles.length; i++) { + if (si < series.length && series[si].time === candles[i].time) { + byIdx[i] = series[si].value; + si++; + } + } + return { series, byIdx }; + } + + function buildMacdByIndex(candles) { + const closes = candles.map(function (c) { + return Number(c.close); + }); + const ema12 = emaArray(closes, 12); + const ema26 = emaArray(closes, 26); + const macd = new Array(closes.length).fill(null); + for (let i = 0; i < closes.length; i++) { + if (ema12[i] == null || ema26[i] == null) continue; + macd[i] = ema12[i] - ema26[i]; + } + return macd; + } + + function panelLayout() { + const rsiOn = indicatorState.rsi; + const macdOn = indicatorState.macd; + if (!rsiOn && !macdOn) { + return { + candle: { top: 0.06, bottom: CANDLE_SCALE_BOTTOM }, + volume: { top: VOLUME_SCALE_TOP, bottom: VOLUME_SCALE_BOTTOM }, + macd: null, + rsi: null, + }; + } + + const gap = 0.02; + let stackBottom = gap; + let rsiMargins = null; + let macdMargins = null; + + if (rsiOn) { + rsiMargins = { + top: 1 - stackBottom - PANEL_RSI_H, + bottom: stackBottom, + }; + stackBottom += PANEL_RSI_H; + } + if (macdOn) { + macdMargins = { + top: 1 - stackBottom - PANEL_MACD_H, + bottom: stackBottom, + }; + stackBottom += PANEL_MACD_H; + } + + const volBottom = stackBottom; + const volTop = 1 - volBottom - PANEL_VOL_H; + const candleBottom = Math.max(CANDLE_SCALE_BOTTOM, 1 - volTop + 0.01); + + return { + candle: { top: 0.06, bottom: candleBottom }, + volume: { top: volTop, bottom: volBottom }, + macd: macdMargins, + rsi: rsiMargins, + }; + } + + function applyScaleLayout() { + if (!chart) return; + const L = panelLayout(); + chart.priceScale("right").applyOptions({ + scaleMargins: L.candle, + }); + if (volumeSeries && volumeSeries.priceScale) { + volumeSeries.priceScale().applyOptions({ + scaleMargins: L.volume, + borderColor: "#2a4058", + }); + } + if (indSeries.macdLine && indSeries.macdLine.priceScale) { + indSeries.macdLine.priceScale().applyOptions({ + scaleMargins: L.macd, + borderColor: "#2a4058", + autoScale: true, + }); + } + if (indSeries.rsi && indSeries.rsi.priceScale) { + indSeries.rsi.priceScale().applyOptions({ + scaleMargins: L.rsi, + borderColor: "#2a4058", + autoScale: true, + }); + } + } + + function updateDivergenceLegend(rsiDiv, macdDiv) { + if (!elDivLegend) return; + const parts = []; + if (indicatorState.rsi && rsiDiv.length) { + parts.push("RSI " + rsiDiv.map(function (m) { return m.text; }).join(" · ")); + } + if (indicatorState.macd && macdDiv.length) { + parts.push("MACD " + macdDiv.map(function (m) { return m.text; }).join(" · ")); + } + if (!parts.length) { + elDivLegend.textContent = ""; + elDivLegend.classList.add("hidden"); + return; + } + elDivLegend.textContent = parts.join(" | "); + elDivLegend.classList.remove("hidden"); + } + + function applyCandleDivergenceMarkers() { + if (!candleSeries || !candleSeries.setMarkers) return; + const sorted = divergenceMarkers + .slice() + .sort(function (a, b) { + return a.time > b.time ? 1 : a.time < b.time ? -1 : 0; + }); + candleSeries.setMarkers(sorted); + } + + function updateIndicators() { + if (!chart || !lastCandles.length) return; + readIndicatorState(); + clearIndicatorSeries(); + divergenceMarkers = []; + + if (indicatorState.ema) { + const pf = tickToPriceFormat(priceTick); + indSeries.ema21 = createLineSeries({ + color: "#f0c040", + title: "EMA21", + priceScaleId: "right", + priceFormat: pf, + }); + indSeries.ema55 = createLineSeries({ + color: "#c878ff", + title: "EMA55", + priceScaleId: "right", + priceFormat: pf, + }); + if (indSeries.ema21) indSeries.ema21.setData(buildEmaSeries(lastCandles, EMA_FAST)); + if (indSeries.ema55) indSeries.ema55.setData(buildEmaSeries(lastCandles, EMA_SLOW)); + } + + let rsiDiv = []; + let macdDiv = []; + + if (indicatorState.macd) { + const macd = buildMacdData(lastCandles); + const macdByIdx = buildMacdByIndex(lastCandles); + indSeries.macdLine = createLineSeries({ + color: "#5b9cf5", + title: "MACD", + priceScaleId: "macd", + priceLineVisible: false, + lastValueVisible: false, + }); + indSeries.macdSignal = createLineSeries({ + color: "#ffb84d", + title: "Signal", + priceScaleId: "macd", + priceLineVisible: false, + lastValueVisible: false, + }); + indSeries.macdHist = createHistSeries({ + priceScaleId: "macd", + priceLineVisible: false, + lastValueVisible: false, + }); + if (indSeries.macdLine) indSeries.macdLine.setData(macd.macdLine); + if (indSeries.macdSignal) indSeries.macdSignal.setData(macd.signalLine); + if (indSeries.macdHist) indSeries.macdHist.setData(macd.histData); + macdDiv = detectDivergences(lastCandles, macdByIdx, "MACD"); + divergenceMarkers = divergenceMarkers.concat(macdDiv); + } + + if (indicatorState.rsi) { + const rsiPack = buildRsiByIndex(lastCandles, 14); + indSeries.rsi = createLineSeries({ + color: "#8fc8ff", + title: "RSI(14)", + priceScaleId: "rsi", + priceFormat: { type: "price", precision: 1, minMove: 0.1 }, + priceLineVisible: false, + lastValueVisible: true, + }); + if (indSeries.rsi) { + indSeries.rsi.setData(rsiPack.series); + try { + indSeries.rsi30 = indSeries.rsi.createPriceLine({ + price: 30, + color: "rgba(255, 77, 109, 0.75)", + lineWidth: 1, + lineStyle: 2, + axisLabelVisible: true, + title: "30", + }); + indSeries.rsi70 = indSeries.rsi.createPriceLine({ + price: 70, + color: "rgba(0, 255, 157, 0.75)", + lineWidth: 1, + lineStyle: 2, + axisLabelVisible: true, + title: "70", + }); + } catch (e) {} + } + rsiDiv = detectDivergences(lastCandles, rsiPack.byIdx, "RSI"); + divergenceMarkers = divergenceMarkers.concat(rsiDiv); + } + + updateDivergenceLegend(rsiDiv, macdDiv); + applyCandleDivergenceMarkers(); + applyScaleLayout(); + scheduleChartResize(); + } + + function syncFsToolbarFromMain() { + if (!chartFullscreen) return; + if (elFsExchange && elExchange) elFsExchange.value = elExchange.value; + if (elFsSymbol && elSymbol) elFsSymbol.value = elSymbol.value; + if (elFsTf && elTf) elFsTf.value = elTf.value; + } + + function syncMainFromFsToolbar() { + if (elExchange && elFsExchange) elExchange.value = elFsExchange.value; + if (elSymbol && elFsSymbol) elSymbol.value = elFsSymbol.value.trim().toUpperCase(); + if (elTf && elFsTf) elTf.value = elFsTf.value; + updateExchangeDisplay(); + updateHeaderLabels(elSymbol && elSymbol.value, elTf && elTf.value); + } + + function isMarketPageActive() { + const page = document.getElementById("page-market"); + return !!(page && !page.classList.contains("hidden")); + } + + function isTypingInField(target) { + if (!target) return false; + const tag = (target.tagName || "").toLowerCase(); + if (tag === "input" || tag === "textarea" || tag === "select") return true; + return !!target.isContentEditable; + } + + function canUseTfKeyboard(e) { + if (!isMarketPageActive()) return false; + if (e.altKey || e.ctrlKey || e.metaKey) return false; + if (isTypingInField(e.target)) return false; + return true; + } + + function canExtendTfDigitBuffer(buf) { + if (!buf) return false; + return TF_MINUTE_KEYS.some(function (k) { + return k.length > buf.length && k.indexOf(buf) === 0; + }); + } + + function shouldCommitTfBufferNow(buf) { + const tf = resolveTfFromDigitBuffer(buf); + if (!tf) return false; + return !canExtendTfDigitBuffer(buf); + } + + function resolveTfFromDigitBuffer(buf) { + if (!buf) return null; + return TF_BY_MINUTES[buf] || null; + } + + function flashTfSwitchHint(tf) { + const label = TF_CN_LABEL[tf] || tf; + const text = "周期 → " + label + "(" + tf + ")"; + if (elTfLabel) elTfLabel.textContent = tf; + if (elBarCountdown) { + if (tfHintTimer) clearTimeout(tfHintTimer); + elBarCountdown.textContent = text; + elBarCountdown.classList.add("market-tf-key-hint"); + tfHintTimer = setTimeout(function () { + tfHintTimer = null; + elBarCountdown.classList.remove("market-tf-key-hint"); + tickLiveClock(); + }, 1200); + return; + } + if (elStatus) { + if (tfHintTimer) clearTimeout(tfHintTimer); + const prevClass = elStatus.className; + const prevText = elStatus.textContent; + elStatus.className = "market-status"; + elStatus.textContent = text; + tfHintTimer = setTimeout(function () { + tfHintTimer = null; + elStatus.className = prevClass; + elStatus.textContent = prevText; + }, 1200); + } + } + + function applyTimeframe(tf, fromKeyboard) { + if (!tf || !TF_MS[tf]) return false; + const cur = (elTf && elTf.value) || currentTf; + if (cur === tf) return false; + if (elTf) elTf.value = tf; + if (elFsTf) elFsTf.value = tf; + currentTf = tf; + lastViewKey = ""; + tickLiveClock(); + updateHeaderLabels( + elSymbol && elSymbol.value.trim().toUpperCase(), + tf + ); + syncFsToolbarFromMain(); + if (fromKeyboard) flashTfSwitchHint(tf); + loadChart(false); + return true; + } + + function commitTfDigitBuffer() { + const buf = tfDigitBuf; + tfDigitBuf = ""; + if (tfDigitTimer) { + clearTimeout(tfDigitTimer); + tfDigitTimer = null; + } + const tf = resolveTfFromDigitBuffer(buf); + if (tf) applyTimeframe(tf, true); + } + + function handleTfDigitKey(digit) { + if (!digit) return; + if (tfDigitBuf && !canExtendTfDigitBuffer(tfDigitBuf)) { + tfDigitBuf = ""; + } + tfDigitBuf += digit; + if (shouldCommitTfBufferNow(tfDigitBuf)) { + commitTfDigitBuffer(); + return; + } + if (!canExtendTfDigitBuffer(tfDigitBuf)) { + tfDigitBuf = digit; + if (shouldCommitTfBufferNow(tfDigitBuf)) { + commitTfDigitBuffer(); + return; + } + } + if (tfDigitTimer) clearTimeout(tfDigitTimer); + tfDigitTimer = setTimeout(commitTfDigitBuffer, TF_DIGIT_TIMEOUT_MS); + } + + function isChartFullscreenKey(e) { + if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return false; + return e.code === "KeyF" || e.key === "f" || e.key === "F"; + } + + function onChartFullscreenKey(e) { + if (!isMarketPageActive() || !isChartFullscreenKey(e)) return; + if (isTypingInField(e.target)) return; + e.preventDefault(); + e.stopImmediatePropagation(); + toggleChartFullscreen(); + } + + function focusMarketChartArea() { + const wrap = elChartWrap; + if (!wrap) return; + if (!wrap.hasAttribute("tabindex")) wrap.setAttribute("tabindex", "-1"); + try { + wrap.focus({ preventScroll: true }); + } catch (err) { + /* ignore */ + } + } + + function onMarketKeydown(e) { + if (!isMarketPageActive()) return; + + if (e.key === "Escape" && chartFullscreen) { + e.preventDefault(); + e.stopPropagation(); + setChartFullscreen(false); + return; + } + + if (!canUseTfKeyboard(e)) return; + if (e.key >= "0" && e.key <= "9") { + e.preventDefault(); + handleTfDigitKey(e.key); + return; + } + if (e.key === "Enter" && tfDigitBuf) { + e.preventDefault(); + commitTfDigitBuffer(); + } + } + + function populateFsExchangeOptions() { + if (!elFsExchange || !elExchange) return; + elFsExchange.innerHTML = elExchange.innerHTML; + elFsExchange.value = elExchange.value; + } + + function setChartFullscreen(on) { + chartFullscreen = !!on; + const wrap = elChartWrap || (chartHost && chartHost.closest(".market-chart-wrap")); + if (wrap) wrap.classList.toggle("is-fullscreen", chartFullscreen); + document.body.classList.toggle("market-chart-fs-open", chartFullscreen); + if (elFsToolbar) elFsToolbar.classList.toggle("hidden", !chartFullscreen); + if (elFsBtn) elFsBtn.textContent = chartFullscreen ? "退出全屏" : "全屏"; + if (elFsExit) { + if (chartFullscreen) elFsExit.classList.remove("hidden"); + else elFsExit.classList.add("hidden"); + } + mountVolRankSheet(chartFullscreen); + if (chartFullscreen) { + populateFsExchangeOptions(); + syncFsToolbarFromMain(); + } + scheduleChartResize(); + } + + function toggleChartFullscreen() { + setChartFullscreen(!chartFullscreen); + } + + function showHubToast(msg, isErr) { + const t = document.getElementById("toast"); + if (!t) return; + t.textContent = msg; + t.classList.toggle("err", !!isErr); + t.classList.add("show"); + clearTimeout(showHubToast._hideTimer); + showHubToast._hideTimer = setTimeout(function () { + t.classList.remove("show"); + }, 3500); + } + + function estimateLinearSwapUpnl(side, entry, mark, contracts, contractSize) { + const e = Number(entry); + const m = Number(mark); + const c = Math.abs(Number(contracts)); + let mult = Number(contractSize); + if (!Number.isFinite(mult) || mult <= 0) mult = 1; + if (!Number.isFinite(e) || !Number.isFinite(m) || !Number.isFinite(c) || c <= 0) { + return null; + } + const diff = + (side || "long").toLowerCase() === "long" ? m - e : e - m; + return Math.round(diff * c * mult * 100) / 100; + } + + function formatPosPnlText(ctx) { + const upnl = ctx && ctx.unrealized_pnl; + if (upnl == null || !Number.isFinite(Number(upnl))) return { text: "—", cls: "" }; + const n = Number(upnl); + let text = (n >= 0 ? "+" : "") + n.toFixed(2) + "U"; + const notional = ctx.notional_usdt; + const entry = Number(ctx.entry); + const contracts = Math.abs(Number(ctx.contracts)); + const cs = + ctx.contract_size != null && Number(ctx.contract_size) > 0 + ? Number(ctx.contract_size) + : 1; + let pctBase = null; + if (notional != null && Math.abs(Number(notional)) > 1e-8) { + pctBase = Math.abs(Number(notional)); + } else if ( + Number.isFinite(entry) && + entry > 0 && + Number.isFinite(contracts) && + contracts > 0 + ) { + pctBase = entry * contracts * cs; + } + if (pctBase != null && pctBase > 1e-8) { + const pct = (n / pctBase) * 100; + text += " (" + (pct >= 0 ? "+" : "") + pct.toFixed(2) + "%)"; + } else if (ctx.plan_margin != null && Number(ctx.plan_margin) > 1e-8) { + const pct = (n / Number(ctx.plan_margin)) * 100; + text += " (" + (pct >= 0 ? "+" : "") + pct.toFixed(2) + "%)"; + } + return { text: text, cls: n > 0 ? "pnl-up" : n < 0 ? "pnl-down" : "" }; + } + + function findTrendFloatingPnl(row, sym, side) { + const hm = row.hub_monitor; + if (!hm || !Array.isArray(hm.trends)) return null; + for (let i = 0; i < hm.trends.length; i++) { + const t = hm.trends[i]; + const ts = normalizeMarketSymbol(t.exchange_symbol || t.symbol || ""); + if (ts !== sym) continue; + if ((t.direction || "").toLowerCase() !== side) continue; + const fp = t.floating_pnl; + if (fp != null && Number.isFinite(Number(fp))) return Number(fp); + if (t.plan_margin_capital != null && Number(t.plan_margin_capital) > 0) { + /* 保留 plan_margin 供百分比 */ + } + } + return null; + } + + function findTrendPlan(row, sym, side) { + const hm = row.hub_monitor; + if (!hm || !Array.isArray(hm.trends)) return null; + for (let i = 0; i < hm.trends.length; i++) { + const t = hm.trends[i]; + const ts = normalizeMarketSymbol(t.exchange_symbol || t.symbol || ""); + if (ts !== sym) continue; + if ((t.direction || "").toLowerCase() !== side) continue; + return t; + } + return null; + } + + function applyTrendPlanFields(row, sym, side) { + if (!posContext) return; + const t = findTrendPlan(row, sym, side); + if (!t) return; + const m = t.plan_margin_capital; + if (m != null && Number.isFinite(Number(m)) && Number(m) > 0) { + posContext.plan_margin = Number(m); + } + const lev = t.leverage; + if (lev != null && Number.isFinite(Number(lev)) && Number(lev) > 0) { + posContext.leverage = Number(lev); + } + } + + /** U 本位线性永续:(标记价-开仓价)×张数×contractSize(空头取反) */ + function calcContractsUpnl(ctx, markPx) { + if (!ctx || markPx == null || !Number.isFinite(Number(markPx))) return null; + return estimateLinearSwapUpnl( + ctx.side, + ctx.entry, + markPx, + ctx.contracts, + ctx.contract_size + ); + } + + function latestChartMarkPrice() { + if (!lastCandles || !lastCandles.length) return null; + const bar = lastCandles[lastCandles.length - 1]; + const c = bar && bar.close != null ? Number(bar.close) : null; + return c != null && Number.isFinite(c) && c > 0 ? c : null; + } + + function updateLivePosPnl(markOverride) { + if (!posContext) return false; + const mark = + markOverride != null && Number.isFinite(Number(markOverride)) + ? Number(markOverride) + : latestChartMarkPrice() || + (posContext.mark_price != null && Number.isFinite(Number(posContext.mark_price)) + ? Number(posContext.mark_price) + : null); + if (mark == null) return false; + const live = calcContractsUpnl(posContext, mark); + if (live != null) { + posContext.unrealized_pnl = live; + posContext.mark_price = mark; + renderPosPnlDisplay(posContext); + return true; + } + if ( + posContext.unrealized_pnl != null && + Number.isFinite(Number(posContext.unrealized_pnl)) + ) { + posContext.mark_price = mark; + renderPosPnlDisplay(posContext); + return true; + } + return false; + } + + function syncPosTpslFromAgentPosition(p) { + if (!posContext || !p) return; + const et = p.exchange_tpsl; + if (et && typeof et === "object") { + if (et.sl && et.sl.trigger_price != null) { + posContext.stop_loss = Number(et.sl.trigger_price); + } + if (et.tp && et.tp.trigger_price != null) { + posContext.take_profit = Number(et.tp.trigger_price); + posContext.tp_monitored = false; + } + } + const cond = Array.isArray(p.conditional_orders) ? p.conditional_orders : []; + for (let i = 0; i < cond.length; i++) { + const o = cond[i]; + const lbl = String(o.label || ""); + const px = + o.trigger_price != null && Number.isFinite(Number(o.trigger_price)) + ? Number(o.trigger_price) + : null; + if (px == null) continue; + if (/^止损/.test(lbl)) posContext.stop_loss = px; + else if (/^止盈/.test(lbl) && !/止盈止损/.test(lbl)) { + posContext.take_profit = px; + posContext.tp_monitored = false; + } + } + } + + function renderPosPnlDisplay(ctx) { + if (!elPosPnl) return; + const p = formatPosPnlText(ctx); + elPosPnl.textContent = p.text; + elPosPnl.className = "market-pos-pnl " + p.cls; + } + + function paintPosPnl(ctx) { + if (ctx === posContext && updateLivePosPnl()) return; + renderPosPnlDisplay(ctx); + } + + function stopPosPnlPoll() { + if (posPnlTimer) { + clearInterval(posPnlTimer); + posPnlTimer = null; + } + } + + function startPosPnlPoll() { + stopPosPnlPoll(); + if (!posContext || !posContext.exchange_id) return; + refreshPosPnlFromBoard(); + posPnlTimer = setInterval(function () { + if (!updateLivePosPnl()) refreshPosPnlFromBoard(); + }, 2000); + } + + async function refreshPosPnlFromBoard() { + if (!posContext || !posContext.exchange_id) return; + try { + const r = await fetch("/api/monitor/board/snapshot", { credentials: "same-origin" }); + if (!r.ok) return; + const data = await r.json(); + const rows = data.rows || []; + const sym = normalizeMarketSymbol(posContext.symbol || ""); + const side = (posContext.side || "long").toLowerCase(); + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + const ex = row.exchange || {}; + if (ex.id !== posContext.exchange_id) continue; + applyTrendPlanFields(row, sym, side); + const positions = (row.agent && row.agent.positions) || []; + for (let j = 0; j < positions.length; j++) { + const p = positions[j]; + if ((p.side || "").toLowerCase() !== side) continue; + if (normalizeMarketSymbol(p.symbol || "") !== sym) continue; + if (p.entry_price != null && Number.isFinite(Number(p.entry_price))) { + posContext.entry = Number(p.entry_price); + } + if (p.contract_size != null && Number.isFinite(Number(p.contract_size))) { + posContext.contract_size = Number(p.contract_size); + } + if (p.contracts != null && Number.isFinite(Number(p.contracts))) { + posContext.contracts = Number(p.contracts); + } + if (p.mark_price != null && Number.isFinite(Number(p.mark_price))) { + posContext.mark_price = Number(p.mark_price); + } + if (p.notional_usdt != null && Number.isFinite(Number(p.notional_usdt))) { + posContext.notional_usdt = Number(p.notional_usdt); + } + syncPosTpslFromAgentPosition(p); + if (elPosSl && posContext.stop_loss != null) { + elPosSl.textContent = fmtPrice(posContext.stop_loss); + } + if (elPosTp && posContext.take_profit != null && !posContext.tp_monitored) { + elPosTp.textContent = fmtPrice(posContext.take_profit); + } + const markForPnl = + latestChartMarkPrice() || + (p.mark_price != null && Number.isFinite(Number(p.mark_price)) + ? Number(p.mark_price) + : null); + if (!updateLivePosPnl(markForPnl)) { + let upnl = + p.unrealized_pnl != null && Number.isFinite(Number(p.unrealized_pnl)) + ? Number(p.unrealized_pnl) + : findTrendFloatingPnl(row, sym, side); + if (upnl != null) { + posContext.unrealized_pnl = upnl; + renderPosPnlDisplay(posContext); + } + } + updatePositionLines(); + try { + sessionStorage.setItem(HUB_MARKET_POS_CTX_KEY, JSON.stringify(posContext)); + } catch (_) {} + return; + } + applyTrendPlanFields(row, sym, side); + if (!updateLivePosPnl()) { + const trendUpnl = findTrendFloatingPnl(row, sym, side); + if (trendUpnl != null) { + posContext.unrealized_pnl = trendUpnl; + renderPosPnlDisplay(posContext); + } + } + try { + sessionStorage.setItem(HUB_MARKET_POS_CTX_KEY, JSON.stringify(posContext)); + } catch (_) {} + return; + } + } catch (_) {} + } + + function resolveTpForPlace(ctx) { + if (!ctx) return null; + const tp = ctx.take_profit; + if (tp != null && Number(tp) > 0) return Number(tp); + const orders = ctx.orders || []; + for (let i = 0; i < orders.length; i++) { + const o = orders[i]; + const lbl = String(o.label || ""); + if (/止盈/.test(lbl) && o.price != null && Number(o.price) > 0) return Number(o.price); + } + return null; + } + + async function placeTpslFromChart(newSl) { + if (!posContext || !posContext.exchange_id) { + showHubToast("缺少交易所信息,无法挂单", true); + return; + } + const sl = roundToTick(newSl); + if (sl == null || !Number.isFinite(sl) || sl <= 0) { + showHubToast("止损价无效", true); + return; + } + const tp = resolveTpForPlace(posContext); + if (tp == null || tp <= 0) { + showHubToast("未找到有效止盈价,请先在监控区用「委托」填写止盈", true); + return; + } + const sym = normalizeMarketSymbol(posContext.symbol || ""); + const side = posContext.side || "long"; + const contracts = posContext.contracts; + const oldSl = posContext.stop_loss; + if ( + !confirm( + "确认 " + + sym + + " " + + side + + "\n先撤销全部条件单,再挂止损 " + + fmtPrice(sl) + + "、止盈 " + + fmtPrice(tp) + + (oldSl != null ? "\n(原止损 " + fmtPrice(oldSl) + ")" : "") + ) + ) { + return; + } + try { + const r = await fetch( + "/api/orders/" + encodeURIComponent(posContext.exchange_id) + "/place-tpsl", + { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + symbol: sym, + side: side, + stop_loss: sl, + take_profit: tp, + contracts: contracts > 0 ? contracts : null, + }), + } + ); + const j = await r.json(); + const pl = j.payload || {}; + const ok = j.ok && pl.ok !== false; + showHubToast( + ok ? "止损已更新(已撤旧条件单并重新挂单)" : pl.error || JSON.stringify(j), + !ok + ); + if (ok) { + posContext.stop_loss = sl; + try { + sessionStorage.setItem(HUB_MARKET_POS_CTX_KEY, JSON.stringify(posContext)); + } catch (_) {} + if (elPosSl) elPosSl.textContent = fmtPrice(sl); + updatePositionLines(); + fetch("/api/monitor/board/refresh", { method: "POST", credentials: "same-origin" }); + } + } catch (e) { + showHubToast(String(e.message || e), true); + } + } + + function slLineCoordinate() { + if (!candleSeries || !posContext) return null; + const px = + slDrag && slDrag.active && slDrag.previewSl != null + ? slDrag.previewSl + : posContext.stop_loss; + if (px == null || !Number.isFinite(Number(px))) return null; + return candleSeries.priceToCoordinate(roundToTick(px)); + } + + function clientYToChartPrice(clientY) { + if (!candleSeries || !chartHost) return null; + const rect = chartHost.getBoundingClientRect(); + const y = clientY - rect.top; + const p = candleSeries.coordinateToPrice(y); + if (p == null || !Number.isFinite(Number(p))) return null; + return roundToTick(p); + } + + function isPointerNearSlLine(clientY) { + const coord = slLineCoordinate(); + if (coord == null || !chartHost) return false; + const rect = chartHost.getBoundingClientRect(); + return Math.abs(clientY - rect.top - coord) <= SL_DRAG_HIT_PX; + } + + function onSlLineHover(e) { + if (!chartHost || (slDrag && slDrag.active)) return; + if (!posContext || posContext.stop_loss == null) { + chartHost.style.cursor = ""; + return; + } + chartHost.style.cursor = isPointerNearSlLine(e.clientY) ? "ns-resize" : ""; + } + + function onSlDragStart(e) { + if (!posContext || posContext.stop_loss == null || !candleSeries) return; + if (e.button !== 0) return; + if (!isPointerNearSlLine(e.clientY)) return; + e.preventDefault(); + slDrag = { + active: true, + moved: false, + startSl: Number(posContext.stop_loss), + previewSl: Number(posContext.stop_loss), + }; + if (chartHost) chartHost.style.cursor = "ns-resize"; + updatePositionLines(); + } + + function onSlDragMove(e) { + if (!slDrag || !slDrag.active) return; + const p = clientYToChartPrice(e.clientY); + if (p == null || p <= 0) return; + slDrag.previewSl = p; + if (Math.abs(p - slDrag.startSl) > 1e-12) slDrag.moved = true; + if (elPosSl) elPosSl.textContent = fmtPrice(p); + updatePositionLines(); + } + + function onSlDragEnd() { + if (!slDrag || !slDrag.active) { + slDrag = null; + if (chartHost) chartHost.style.cursor = ""; + return; + } + const preview = slDrag.previewSl; + const moved = slDrag.moved; + slDrag = null; + if (chartHost) chartHost.style.cursor = ""; + updatePositionLines(); + if (!moved || preview == null) return; + placeTpslFromChart(preview); + } + + function bindSlDrag() { + if (!chartHost) return; + chartHost.addEventListener("mousedown", onSlDragStart); + chartHost.addEventListener("mousemove", onSlLineHover); + document.addEventListener("mousemove", onSlDragMove); + document.addEventListener("mouseup", onSlDragEnd); + } + + function renderPosPanel(ctx) { + if (!elPosPanel || !ctx) { + clearPosPanel(); + return; + } + elPosPanel.classList.remove("hidden"); + if (elPosSide) { + const isShort = (ctx.side || "").toLowerCase() === "short"; + elPosSide.textContent = isShort ? "空" : "多"; + elPosSide.className = "market-pos-side " + (isShort ? "side-short" : "side-long"); + } + if (elPosEntry) elPosEntry.textContent = ctx.entry != null ? fmtPrice(ctx.entry) : "—"; + if (elPosSl) elPosSl.textContent = ctx.stop_loss != null ? fmtPrice(ctx.stop_loss) : "—"; + if (elPosTp) { + if (ctx.tp_monitored) { + elPosTp.textContent = + ctx.take_profit != null + ? "程序监控 · " + fmtPrice(ctx.take_profit) + : "程序监控"; + elPosTp.classList.add("market-pos-tp-monitored"); + } else { + elPosTp.textContent = ctx.take_profit != null ? fmtPrice(ctx.take_profit) : "—"; + elPosTp.classList.remove("market-pos-tp-monitored"); + } + } + if (elPosSize) elPosSize.textContent = ctx.contracts != null ? String(ctx.contracts) : "—"; + paintPosPnl(ctx); + if (elPosOrders) { + const orders = Array.isArray(ctx.orders) ? ctx.orders : []; + if (!orders.length) { + elPosOrders.innerHTML = '暂无委托单'; + } else { + elPosOrders.innerHTML = orders + .map(function (o) { + const price = o.price != null ? fmtPrice(o.price) : "—"; + const amt = o.amount != null ? String(o.amount) : ""; + return ( + '' + + '' + + escHtml(o.kind || "") + + "" + + '' + + escHtml(o.label || "") + + "" + + '' + + price + + "" + + (amt ? '×' + escHtml(amt) + "" : "") + + "" + ); + }) + .join(""); + } + } + scheduleChartResize(); + } + + function clearPositionLines() { + positionLines.forEach(function (m) { + try { + candleSeries.removePriceLine(m); + } catch (e) {} + }); + positionLines = []; + } + + function updatePositionLines() { + clearPositionLines(); + if (!candleSeries || !posContext) return; + const slPrice = + slDrag && slDrag.active && slDrag.previewSl != null + ? slDrag.previewSl + : posContext.stop_loss; + const slTitle = + slDrag && slDrag.active + ? "止损 " + fmtPrice(slPrice) + : slPrice != null + ? "止损 ⟷" + : "止损"; + const specs = [ + { price: posContext.entry, color: "#5b9cf5", title: "入场", lineWidth: 1 }, + { + price: slPrice, + color: "#ff4d6d", + title: slTitle, + lineWidth: slPrice != null ? 2 : 1, + }, + ]; + if (posContext.take_profit != null) { + specs.push({ + price: posContext.take_profit, + color: "#00ff9d", + title: posContext.tp_monitored ? "止盈(程序)" : "止盈", + }); + } + specs.forEach(function (s) { + if (s.price == null || !Number.isFinite(Number(s.price))) return; + const px = roundToTick(s.price); + if (px == null || !Number.isFinite(Number(px))) return; + positionLines.push( + candleSeries.createPriceLine({ + price: Number(px), + color: s.color, + lineWidth: s.lineWidth != null ? s.lineWidth : 1, + lineStyle: 2, + axisLabelVisible: true, + title: s.title, + }) + ); + }); + } + + function clearPosContext() { + posContext = null; + slDrag = null; + stopPosPnlPoll(); + try { + sessionStorage.removeItem(HUB_MARKET_POS_CTX_KEY); + } catch (e) {} + clearPosPanel(); + clearPositionLines(); + if (chartHost) chartHost.style.cursor = ""; + } + + function applyPosContext(ctx) { + posContext = ctx; + renderPosPanel(ctx); + updatePositionLines(); + startPosPnlPoll(); + } + + function syncPosContextForView(exKey, sym) { + const stored = loadPosContextFromStorage(); + if (stored && posContextMatches(stored, exKey, sym)) { + applyPosContext(stored); + return; + } + clearPosContext(); + } + + function fmtVol(v) { + if (v == null || Number.isNaN(Number(v))) return "-"; + const n = Number(v); + if (n >= 1e9) return (n / 1e9).toFixed(2) + "B"; + if (n >= 1e6) return (n / 1e6).toFixed(2) + "M"; + if (n >= 1e3) return (n / 1e3).toFixed(2) + "K"; + return n.toFixed(2); + } + + function decimalsFromTick(tick) { + if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) return null; + const minMove = Number(tick); + if (minMove >= 1) return 0; + const raw = String(minMove); + const sci = raw.match(/e-(\d+)/i); + if (sci) return Math.min(12, parseInt(sci[1], 10)); + const fixed = minMove.toFixed(12); + const frac = fixed.split(".")[1] || ""; + const trimmed = frac.replace(/0+$/, ""); + if (trimmed.length) return Math.min(12, trimmed.length); + return Math.max(0, Math.min(12, Math.round(-Math.log10(minMove)))); + } + + const SAFE_PRICE_FORMAT = { type: "price", precision: 4, minMove: 0.0001 }; + + function tickToPriceFormat(tick) { + try { + if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) { + return { type: "price", precision: 2, minMove: 0.01 }; + } + const minMove = Number(tick); + let prec = decimalsFromTick(minMove); + if (prec == null || prec < 0) prec = 4; + prec = Math.min(12, Math.max(0, Math.floor(prec))); + return { type: "price", precision: prec, minMove: minMove }; + } catch (e) { + return SAFE_PRICE_FORMAT; + } + } + + function roundToTick(v) { + if (v == null || Number.isNaN(Number(v))) return v; + const n = Number(v); + const tick = priceTick; + if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) return n; + const t = Number(tick); + const rounded = Math.round(n / t) * t; + const dec = decimalsFromTick(t); + if (dec == null) return rounded; + return parseFloat(rounded.toFixed(dec)); + } + + function alignCandlesToTick(candles) { + if (!Array.isArray(candles) || !candles.length) return candles || []; + if (priceTick == null || !Number.isFinite(Number(priceTick)) || Number(priceTick) <= 0) { + return candles; + } + return candles.map(function (c) { + return { + time: c.time, + open: roundToTick(c.open), + high: roundToTick(c.high), + low: roundToTick(c.low), + close: roundToTick(c.close), + volume: c.volume, + }; + }); + } + + function applyPriceFormatToSeries(series, pf) { + if (!series || !series.applyOptions) return; + try { + series.applyOptions({ priceFormat: pf }); + } catch (e) { + series.applyOptions({ priceFormat: SAFE_PRICE_FORMAT }); + } + } + + function applyChartPriceFormat() { + let pf = SAFE_PRICE_FORMAT; + try { + pf = tickToPriceFormat(priceTick); + } catch (e) { + pf = SAFE_PRICE_FORMAT; + } + applyPriceFormatToSeries(candleSeries, pf); + applyPriceFormatToSeries(indSeries.ema21, pf); + applyPriceFormatToSeries(indSeries.ema55, pf); + if (chart) { + chart.applyOptions({ + localization: buildChartLocalization(), + }); + } + } + + function fmtPrice(v) { + if (v == null || Number.isNaN(Number(v))) return "-"; + const aligned = roundToTick(v); + const n = Number(aligned); + if (n === 0) return "0"; + const dec = decimalsFromTick(priceTick); + if (dec != null) return n.toFixed(dec); + const av = Math.abs(n); + let d = 8; + if (av >= 10000) d = 2; + else if (av >= 100) d = 3; + else if (av >= 1) d = 4; + else if (av >= 0.01) d = 6; + let text = n.toFixed(d); + if (text.indexOf(".") >= 0) text = text.replace(/\.?0+$/, ""); + return text; + } + + function exchangeLabel() { + if (!elExchange) return ""; + const opt = elExchange.options[elExchange.selectedIndex]; + if (opt && opt.textContent) return opt.textContent.trim(); + return (elExchange.value || "").trim().toUpperCase(); + } + + function updateExchangeDisplay() { + const label = exchangeLabel(); + if (elExLabel) elExLabel.textContent = label; + if (elExBadge) { + elExBadge.textContent = label; + elExBadge.setAttribute("aria-hidden", label ? "false" : "true"); + } + } + + function updateHeaderLabels(sym, tf) { + if (elSymLabel) elSymLabel.textContent = sym || "—"; + if (elTfLabel) elTfLabel.textContent = tf || "—"; + updateExchangeDisplay(); + } + + function fmtAmplitude(bar) { + if (!bar) return "-"; + const o = Number(bar.open); + const h = Number(bar.high); + const l = Number(bar.low); + if (!o || o <= 0 || !Number.isFinite(h) || !Number.isFinite(l)) return "-"; + return (((h - l) / o) * 100).toFixed(2) + "%"; + } + + function barRemainMs(tf) { + const period = TF_MS[tf] || TF_MS["1d"]; + const now = Date.now(); + const barOpen = Math.floor(now / period) * period; + return Math.max(0, barOpen + period - now); + } + + function fmtBarCountdown(ms) { + const total = Math.max(0, Math.floor(ms / 1000)); + const h = Math.floor(total / 3600); + const m = Math.floor((total % 3600) / 60); + const s = total % 60; + const pad = function (n) { + return n < 10 ? "0" + n : String(n); + }; + if (h > 0) return h + ":" + pad(m) + ":" + pad(s); + return pad(m) + ":" + pad(s); + } + + function paintOhlcv(bar) { + if (!bar) { + ["o", "h", "l", "c", "v", "amp"].forEach(function (k) { + const el = { o: elO, h: elH, l: elL, c: elC, v: elV, amp: elAmp }[k]; + if (el) el.textContent = "-"; + }); + return; + } + if (elO) elO.textContent = fmtPrice(bar.open); + if (elH) elH.textContent = fmtPrice(bar.high); + if (elL) elL.textContent = fmtPrice(bar.low); + if (elC) elC.textContent = fmtPrice(bar.close); + if (elV) elV.textContent = fmtVol(bar.volume); + if (elAmp) elAmp.textContent = fmtAmplitude(bar); + } + + function latestCandle() { + return lastCandles.length ? lastCandles[lastCandles.length - 1] : null; + } + + function showLatestOhlcv() { + paintOhlcv(latestCandle()); + updateCurrentPriceLine(); + updatePriceTag(); + } + + function clearCurrentPriceLine() { + if (currentPriceLine && candleSeries) { + try { + candleSeries.removePriceLine(currentPriceLine); + } catch (e) {} + } + currentPriceLine = null; + } + + function updateCurrentPriceLine() { + clearCurrentPriceLine(); + if (!candleSeries) return; + const bar = latestCandle(); + if (!bar || bar.close == null) return; + const up = Number(bar.close) >= Number(bar.open); + currentPriceLine = candleSeries.createPriceLine({ + price: Number(roundToTick(bar.close)), + color: up ? "#00ff9d" : "#ff4d6d", + lineWidth: 1, + lineStyle: 2, + axisLabelVisible: false, + title: "", + }); + } + + function tickLiveClock() { + const cd = fmtBarCountdown(barRemainMs(currentTf)); + if (elPriceTagTime && elPriceTag && !elPriceTag.classList.contains("hidden")) { + elPriceTagTime.textContent = cd; + } + if (elBarCountdown) elBarCountdown.textContent = "距收盘 " + cd; + } + + function updatePriceTag() { + if (!elPriceTag || !candleSeries || !chart) return; + try { + tickLiveClock(); + const bar = latestCandle(); + if (!bar || bar.close == null) { + elPriceTag.classList.add("hidden"); + elPriceTag.setAttribute("aria-hidden", "true"); + return; + } + let y = null; + try { + y = candleSeries.priceToCoordinate(Number(bar.close)); + } catch (e) { + y = null; + } + const hostH = chartHost.clientHeight || 0; + if (y == null || y < 8 || y > hostH - 8) { + elPriceTag.classList.add("hidden"); + elPriceTag.setAttribute("aria-hidden", "true"); + return; + } + const up = Number(bar.close) >= Number(bar.open); + elPriceTag.classList.remove("hidden", "is-up", "is-down"); + elPriceTag.classList.add(up ? "is-up" : "is-down"); + elPriceTag.setAttribute("aria-hidden", "false"); + elPriceTag.style.left = "auto"; + elPriceTag.style.right = "0"; + elPriceTag.style.top = y + "px"; + if (elPriceTagValue) elPriceTagValue.textContent = fmtPrice(bar.close); + } catch (e) { + elPriceTag.classList.add("hidden"); + elPriceTag.setAttribute("aria-hidden", "true"); + } + } + + function startPriceTagTimer() { + stopPriceTagTimer(); + tickLiveClock(); + priceTagTimer = setInterval(tickLiveClock, 1000); + } + + function stopPriceTagTimer() { + if (priceTagTimer) clearInterval(priceTagTimer); + priceTagTimer = null; + } + + function applyPriceAutoScale() { + if (!chart) return; + chart.priceScale("right").applyOptions({ autoScale: priceAutoScale }); + if (elPriceAuto) elPriceAuto.classList.toggle("is-on", priceAutoScale); + } + + function indexCandles(candles) { + candleByTime = {}; + (candles || []).forEach(function (c) { + if (c && c.time != null) candleByTime[c.time] = c; + }); + } + + function candleAtTime(t) { + if (t == null) return null; + return candleByTime[t] || null; + } + + function chartThemePalette() { + const light = document.documentElement.getAttribute("data-theme") === "light"; + return light + ? { + bg: "#f0f4f9", + text: "#4a6078", + border: "#b8c8d8", + up: "#0a8f5c", + down: "#c93552", + volUp: "rgba(10, 143, 92, 0.45)", + volDown: "rgba(201, 53, 82, 0.45)", + } + : { + bg: "#0a1018", + text: "#b8d4e8", + border: "#2a4058", + up: "#00ff9d", + down: "#ff4d6d", + volUp: "rgba(0, 255, 157, 0.5)", + volDown: "rgba(255, 77, 109, 0.5)", + }; + } + + function applyChartTheme() { + if (!chart) return; + const p = chartThemePalette(); + chart.applyOptions({ + layout: { background: { color: p.bg }, textColor: p.text }, + rightPriceScale: { borderColor: p.border }, + timeScale: { borderColor: p.border }, + }); + if (candleSeries) { + candleSeries.applyOptions({ + upColor: p.up, + downColor: p.down, + wickUpColor: p.up, + wickDownColor: p.down, + }); + } + if (volumeSeries && lastCandles.length) { + volumeSeries.setData(buildVolumeData(lastCandles)); + } + } + + function buildVolumeData(candles) { + const p = chartThemePalette(); + return (candles || []).map(function (c) { + const up = Number(c.close) >= Number(c.open); + return { + time: c.time, + value: Number(c.volume) || 0, + color: up ? p.volUp : p.volDown, + }; + }); + } + + function buildVolumeBar(candle) { + const p = chartThemePalette(); + const up = Number(candle.close) >= Number(candle.open); + return { + time: candle.time, + value: Number(candle.volume) || 0, + color: up ? p.volUp : p.volDown, + }; + } + + function ensureChart() { + if (chart && candleSeries && volumeSeries) return true; + if (!window.LightweightCharts) { + if (elStatus) { + elStatus.className = "market-status err"; + elStatus.textContent = "图表库加载失败"; + } + return false; + } + const tp = chartThemePalette(); + chart = LightweightCharts.createChart(chartHost, { + layout: { background: { color: tp.bg }, textColor: tp.text }, + grid: { + vertLines: { visible: false }, + horzLines: { visible: false }, + }, + rightPriceScale: { borderColor: tp.border, autoScale: true }, + localization: buildChartLocalization(), + timeScale: { + borderColor: tp.border, + timeVisible: true, + secondsVisible: false, + rightOffset: RIGHT_OFFSET_BARS, + }, + crosshair: { + mode: LightweightCharts.CrosshairMode + ? LightweightCharts.CrosshairMode.Normal + : 0, + }, + }); + + const candleOpts = { + upColor: tp.up, + downColor: tp.down, + borderVisible: false, + wickUpColor: tp.up, + wickDownColor: tp.down, + lastValueVisible: false, + priceLineVisible: false, + priceFormat: SAFE_PRICE_FORMAT, + }; + + if (typeof chart.addCandlestickSeries === "function") { + candleSeries = chart.addCandlestickSeries(candleOpts); + } else if ( + typeof chart.addSeries === "function" && + window.LightweightCharts && + window.LightweightCharts.CandlestickSeries + ) { + candleSeries = chart.addSeries(window.LightweightCharts.CandlestickSeries, candleOpts); + } + if (!candleSeries) return false; + + const volOpts = { + priceFormat: { type: "volume" }, + priceScaleId: "", + lastValueVisible: false, + }; + if (typeof chart.addHistogramSeries === "function") { + volumeSeries = chart.addHistogramSeries(volOpts); + } else if ( + typeof chart.addSeries === "function" && + window.LightweightCharts && + window.LightweightCharts.HistogramSeries + ) { + volumeSeries = chart.addSeries(window.LightweightCharts.HistogramSeries, volOpts); + } + if (!volumeSeries) return false; + + applyScaleLayout(); + applyChartPriceFormat(); + applyPriceAutoScale(); + + chart.subscribeCrosshairMove(function (param) { + if (!param || param.time == null) { + showLatestOhlcv(); + return; + } + const bar = candleAtTime(param.time); + if (!bar) { + showLatestOhlcv(); + return; + } + paintOhlcv(bar); + }); + + chart.timeScale().subscribeVisibleLogicalRangeChange(function (range) { + if (!chartDataLoading && range && !suppressRangeUserLock) { + markChartRangeUserAdjusted(); + } + scheduleRangeUiUpdate(); + if ( + !range || + chartDataLoading || + loadingLeft || + exhaustedLeft || + !lastCandles.length || + !lastViewKey + ) { + return; + } + if (currentChartViewKey() !== lastViewKey) return; + scheduleLoadOlderOnRange(range); + }); + + window.addEventListener("resize", function () { + scheduleChartResize(); + }); + scheduleChartResize(); + ensureDrawLayer(); + return true; + } + + function clearMarkers() { + rangeMarkers.forEach(function (m) { + try { + candleSeries.removePriceLine(m); + } catch (e) {} + }); + rangeMarkers = []; + } + + function clearYesterdayPriceLines() { + if (candleSeries) { + yesterdayPriceLines.forEach(function (m) { + try { + candleSeries.removePriceLine(m); + } catch (e) {} + }); + } + yesterdayPriceLines = []; + } + + function updateYesterdayPriceLines() { + clearYesterdayPriceLines(); + if (!candleSeries || !lastCandles.length) return; + const showClose = !!(elPrevCloseLine && elPrevCloseLine.checked); + const showHl = !!(elPrevHlLines && elPrevHlLines.checked); + if (!showClose && !showHl) return; + const stats = computePrevTradingDayOhlc(lastCandles, chartResetHour()); + if (!stats) return; + if (showClose && stats.close != null && Number.isFinite(Number(stats.close))) { + const px = Number(roundToTick(stats.close)); + if (Number.isFinite(px)) { + yesterdayPriceLines.push( + candleSeries.createPriceLine({ + price: px, + color: "#a78bfa", + lineWidth: 1, + lineStyle: 2, + axisLabelVisible: true, + title: "昨收", + }) + ); + } + } + if (showHl) { + if (stats.high != null && Number.isFinite(Number(stats.high))) { + const hiPx = Number(roundToTick(stats.high)); + if (Number.isFinite(hiPx)) { + yesterdayPriceLines.push( + candleSeries.createPriceLine({ + price: hiPx, + color: "#ffb84d", + lineWidth: 1, + lineStyle: 2, + axisLabelVisible: true, + title: "昨高", + }) + ); + } + } + if (stats.low != null && Number.isFinite(Number(stats.low))) { + const loPx = Number(roundToTick(stats.low)); + if (Number.isFinite(loPx)) { + yesterdayPriceLines.push( + candleSeries.createPriceLine({ + price: loPx, + color: "#4cd97f", + lineWidth: 1, + lineStyle: 2, + axisLabelVisible: true, + title: "昨低", + }) + ); + } + } + } + } + + function viewKey(exKey, sym, tf) { + const ex = String(exKey || "").trim().toLowerCase(); + const s = normalizeMarketSymbol(sym); + const t = String(tf || "").trim(); + return ex + "|" + s + "|" + t; + } + + function lookupSeriesMapEntry(map, vKey) { + if (!map || !vKey) return null; + if (map[vKey]) return map[vKey]; + const parts = String(vKey).split("|"); + if (parts.length === 3) { + const norm = viewKey(parts[0], parts[1], parts[2]); + if (norm !== vKey && map[norm]) return map[norm]; + } + return null; + } + + function chartInitialLimit(tf) { + return CHART_INITIAL_LIMITS[tf] || 200; + } + + function chartChunkLimit(tf) { + return CHART_CHUNK_LIMITS[tf] || 200; + } + + function chartMemoryCap(tf) { + return CHART_MEMORY_CAPS[tf] || 1000; + } + + function resetChartHistoryState() { + exhaustedLeft = false; + loadingLeft = false; + } + + function currentChartViewKey() { + const exKey = (elExchange && elExchange.value) || ""; + const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || ""; + const tf = (elTf && elTf.value) || currentTf || "1d"; + if (!exKey || !sym) return ""; + return viewKey(exKey, sym, tf); + } + + function isVisibleRangeValidForCandles(range, candleCount) { + if (!range || candleCount <= 0) return false; + const maxTo = candleCount - 1 + RIGHT_OFFSET_BARS; + if (range.from < -2 || range.to < 0) return false; + if (range.to > maxTo + 8) return false; + if (range.from > candleCount - 1) return false; + return true; + } + + function markChartRangeUserAdjusted() { + chartRangeUserLocked = true; + if (chartRangeLockTimer) clearTimeout(chartRangeLockTimer); + chartRangeLockTimer = setTimeout(function () { + chartRangeLockTimer = null; + chartRangeUserLocked = false; + }, 30000); + } + + function clampVisibleLogicalRange(range, candleCount) { + if (!range || candleCount <= 0) return null; + const maxTo = candleCount - 1 + RIGHT_OFFSET_BARS; + const from = Math.max(-2, Math.min(range.from, candleCount - 1)); + const to = Math.max(0, Math.min(range.to, maxTo + 8)); + if (to <= from) return null; + return { from: from, to: to }; + } + + function restoreVisibleLogicalRange(range, candleCount) { + const clamped = clampVisibleLogicalRange(range, candleCount); + if (!chart || !clamped || !isVisibleRangeValidForCandles(clamped, candleCount)) return false; + suppressRangeUserLock = true; + chart.timeScale().setVisibleLogicalRange(clamped); + suppressRangeUserLock = false; + return true; + } + + function applyPreservedVisibleRange(range, candleCount) { + if (!chart || !range || !candleCount) return; + function applyOnce() { + if (!chart || !lastCandles.length) return; + applyChartRightGap(); + restoreVisibleLogicalRange(range, lastCandles.length); + updateVisibleRangeMarkers(); + updateYesterdayPriceLines(); + } + applyOnce(); + requestAnimationFrame(applyOnce); + setTimeout(applyOnce, 0); + } + + function shouldLoadOlderOnRange(range) { + if (!range || !lastCandles.length) return false; + const n = lastCandles.length; + const maxTo = n - 1 + RIGHT_OFFSET_BARS; + if (range.from >= CHART_LOAD_LEFT_THRESHOLD) return false; + // 缩小图表时 from 会变小,但 to 仍靠近最新 — 不应触发左拖补历史 + if (range.to >= maxTo - 30) return false; + return true; + } + + function scheduleRangeUiUpdate() { + if (rangeUiTimer) clearTimeout(rangeUiTimer); + rangeUiTimer = setTimeout(function () { + rangeUiTimer = null; + updateVisibleRangeMarkers(); + updatePriceTag(); + }, 120); + } + + function scheduleLoadOlderOnRange(range) { + if (!shouldLoadOlderOnRange(range)) return; + if (loadOlderTimer) clearTimeout(loadOlderTimer); + loadOlderTimer = setTimeout(function () { + loadOlderTimer = null; + if (!chart) return; + const cur = chart.timeScale().getVisibleLogicalRange(); + if (!shouldLoadOlderOnRange(cur)) return; + void loadOlderCandles(); + }, 280); + } + + function tailVisibleLogicalRange(candleCount) { + const n = Math.max(0, Number(candleCount) || 0); + if (n <= 0) return null; + const visible = Math.min(DEFAULT_VISIBLE_BARS, n); + return { + from: Math.max(0, n - visible), + to: n - 1 + RIGHT_OFFSET_BARS, + }; + } + + function clearChartSeriesData() { + lastCandles = []; + candleByTime = {}; + clearYesterdayPriceLines(); + if (candleSeries) candleSeries.setData([]); + if (volumeSeries) volumeSeries.setData([]); + } + + function mergeCandles(existing, incoming, opts) { + opts = opts || {}; + const prepend = !!opts.prepend; + const byTime = {}; + (existing || []).forEach(function (c) { + if (c && c.time != null) byTime[c.time] = c; + }); + (incoming || []).forEach(function (c) { + if (c && c.time != null) byTime[c.time] = c; + }); + let merged = Object.keys(byTime) + .map(function (t) { + return Number(t); + }) + .sort(function (a, b) { + return a - b; + }) + .map(function (t) { + return byTime[t]; + }); + const cap = chartMemoryCap(currentTf); + if (merged.length > cap) { + merged = prepend ? merged.slice(0, cap) : merged.slice(-cap); + } + return merged; + } + + /** 尾部静默刷新:仅 update 变更 K 线,不 setData,避免视口跳动 */ + function applyTailCandlePatch(incoming) { + if (!candleSeries || !volumeSeries || !incoming || !incoming.length) return false; + const aligned = alignCandlesToTick(incoming); + const prevLen = lastCandles.length; + const oldestTime = prevLen ? lastCandles[0].time : null; + const prevLastTime = prevLen ? lastCandles[prevLen - 1].time : null; + const merged = mergeCandles(lastCandles, aligned, { prepend: false }); + if ( + prevLen > 0 && + merged.length > 0 && + merged[0].time !== oldestTime && + merged.length <= prevLen + ) { + return false; + } + let patchStart = 0; + if (prevLastTime != null) { + patchStart = merged.findIndex(function (b) { + return b.time >= prevLastTime; + }); + if (patchStart < 0) return false; + } + try { + for (let i = patchStart; i < merged.length; i++) { + const bar = merged[i]; + candleSeries.update(bar); + volumeSeries.update(buildVolumeBar(bar)); + } + } catch (_) { + return false; + } + lastCandles = merged; + indexCandles(lastCandles); + readIndicatorState(); + if (indicatorState.ema || indicatorState.macd || indicatorState.rsi) { + try { + updateIndicators(); + } catch (indErr) {} + } + updateVisibleRangeMarkers(); + updateYesterdayPriceLines(); + showLatestOhlcv(); + return true; + } + + function applyCandlesToChart(candles, rangeShift, opts) { + opts = opts || {}; + let savedRange = null; + if (opts.preserveRange && chart) { + savedRange = chart.timeScale().getVisibleLogicalRange(); + } + lastCandles = alignCandlesToTick(candles); + indexCandles(lastCandles); + candleSeries.setData(lastCandles); + volumeSeries.setData(buildVolumeData(lastCandles)); + if (!opts.skipRightGap) { + applyChartRightGap(); + } + if (rangeShift && chart) { + const range = chart.timeScale().getVisibleLogicalRange(); + if (range) { + suppressRangeUserLock = true; + chart.timeScale().setVisibleLogicalRange({ + from: range.from + rangeShift, + to: range.to + rangeShift, + }); + suppressRangeUserLock = false; + } + } else if (savedRange) { + restoreVisibleLogicalRange(savedRange, lastCandles.length); + } + if (!opts.skipAutoScale) { + applyPriceAutoScale(); + } + updateVisibleRangeMarkers(); + updateYesterdayPriceLines(); + try { + updateIndicators(); + } catch (indErr) {} + showLatestOhlcv(); + } + + async function fetchChartChunk(params) { + const qs = new URLSearchParams({ + exchange_key: params.exchange_key, + symbol: params.symbol, + timeframe: params.timeframe, + limit: String(params.limit), + }); + if (params.before_ms) qs.set("before_ms", String(params.before_ms)); + if (params.refresh) qs.set("refresh", "1"); + if (params.tail) qs.set("tail", "1"); + const r = await fetch("/api/chart/ohlcv?" + qs.toString(), { credentials: "same-origin" }); + const data = await r.json(); + if (!r.ok) { + throw new Error(data.detail || data.msg || "请求失败"); + } + return data; + } + + async function loadOlderCandles() { + if (chartDataLoading || loadingLeft || exhaustedLeft || !lastCandles.length) return; + const exKey = (elExchange && elExchange.value) || ""; + const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || ""; + const tf = (elTf && elTf.value) || "1d"; + if (!exKey || !sym) return; + const vKey = viewKey(exKey, sym, tf); + if (!lastViewKey || vKey !== lastViewKey) return; + loadingLeft = true; + const beforeMs = Number(lastCandles[0].time) * 1000; + try { + const data = await fetchChartChunk({ + exchange_key: exKey, + symbol: sym, + timeframe: tf, + limit: chartChunkLimit(tf), + before_ms: beforeMs, + }); + if (data.exhausted) exhaustedLeft = true; + const incoming = alignCandlesToTick(data.candles || []); + if (!incoming.length) return; + const prevLen = lastCandles.length; + const merged = mergeCandles(lastCandles, incoming, { prepend: true }); + const shift = merged.length - prevLen; + applyCandlesToChart(merged, shift); + if (elStatus && !elStatus.classList.contains("err")) { + elStatus.textContent = + "已加载 " + + lastCandles.length + + " 根(向左 +" + + incoming.length + + (exhaustedLeft ? " · 已到最早" : "") + + ")"; + } + } catch (e) { + if (elStatus) { + elStatus.className = "market-status warn"; + elStatus.textContent = "加载更早 K 线失败:" + String(e.message || e); + } + } finally { + loadingLeft = false; + } + } + + function applyIncomingTailCandles(incoming, meta) { + meta = meta || {}; + const vKey = currentViewSeriesKey(); + if (!vKey || !lastCandles.length || chartDataLoading) return false; + if (!lastViewKey || vKey !== lastViewKey) return false; + const epochAtStart = chartViewEpoch; + const autoFollow = priceAutoScale; + let savedRange = null; + if (chart) savedRange = chart.timeScale().getVisibleLogicalRange(); + if (!incoming || !incoming.length) return false; + if (meta.price_tick != null) { + priceTick = meta.price_tick; + try { + applyChartPriceFormat(); + } catch (fmtErr) { + priceTick = null; + applyChartPriceFormat(); + } + } + const aligned = alignCandlesToTick(incoming); + let tailPatched = false; + if (!autoFollow) { + try { + tailPatched = applyTailCandlePatch(aligned); + } catch (_) { + tailPatched = false; + } + } + if (!autoFollow && tailPatched) { + /* 手动模式:增量 update,不触碰时间轴 */ + } else { + const merged = mergeCandles(lastCandles, aligned, { prepend: false }); + applyCandlesToChart(merged, 0, { + preserveRange: false, + skipAutoScale: !autoFollow, + skipRightGap: !autoFollow, + }); + if (epochAtStart !== chartViewEpoch) return false; + const n = lastCandles.length; + if (autoFollow) { + applyDefaultVisibleRange(); + } else if (savedRange) { + applyPreservedVisibleRange(savedRange, n); + } + } + if (epochAtStart !== chartViewEpoch) return false; + scheduleRangeUiUpdate(); + if (posContext) { + updateLivePosPnl(); + refreshPosPnlFromBoard(); + } + if (meta.series_version != null) { + localSeriesVersion = Number(meta.series_version) || localSeriesVersion; + } + if (meta.chart_version != null) { + localChartVersion = Number(meta.chart_version) || localChartVersion; + } + if (elUpdated) elUpdated.textContent = "数据 " + (meta.updated_at || "--"); + tickLiveClock(); + if (window.HubChartDraw && drawAttached) window.HubChartDraw.redraw(); + return true; + } + + async function refreshChartTail() { + const exKey = (elExchange && elExchange.value) || ""; + const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || ""; + const tf = (elTf && elTf.value) || "1d"; + const vKey = viewKey(exKey, sym, tf); + if (!exKey || !sym || !lastCandles.length || chartDataLoading) return; + if (!lastViewKey || vKey !== lastViewKey) return; + const myToken = loadToken; + const epochAtStart = chartViewEpoch; + try { + const data = await fetchChartChunk({ + exchange_key: exKey, + symbol: sym, + timeframe: tf, + limit: CHART_TAIL_REFRESH_LIMIT, + tail: true, + }); + if (myToken !== loadToken) return; + if (vKey !== lastViewKey) return; + if (epochAtStart !== chartViewEpoch) return; + if (!data.ok || !data.candles || !data.candles.length) return; + applyIncomingTailCandles(data.candles, { + price_tick: data.price_tick, + series_version: data.series_version, + chart_version: data.chart_version, + updated_at: data.updated_at, + }); + } catch (_) {} + } + + function applyChartRightGap() { + if (!chart) return; + chart.timeScale().applyOptions({ + rightOffset: RIGHT_OFFSET_BARS, + fixRightEdge: false, + }); + } + + function applyDefaultVisibleRange() { + if (!chart || !lastCandles.length) return; + function applyOnce() { + if (!chart || !lastCandles.length) return; + const r = tailVisibleLogicalRange(lastCandles.length); + if (!r) return; + applyChartRightGap(); + restoreVisibleLogicalRange(r, lastCandles.length); + updateVisibleRangeMarkers(); + } + applyOnce(); + requestAnimationFrame(applyOnce); + setTimeout(applyOnce, 0); + } + + function updateVisibleRangeMarkers() { + clearMarkers(); + if (!candleSeries || !chart || !lastCandles.length) return; + + const range = chart.timeScale().getVisibleLogicalRange(); + if (!range) return; + + const from = Math.max(0, Math.floor(range.from)); + const to = Math.min(lastCandles.length - 1, Math.ceil(range.to)); + if (to < from) return; + + let hi = null; + let lo = null; + for (let i = from; i <= to; i++) { + const c = lastCandles[i]; + if (!c) continue; + if (!hi || c.high > hi.high) hi = c; + if (!lo || c.low < lo.low) lo = c; + } + if (!hi || !lo) return; + + rangeMarkers.push( + candleSeries.createPriceLine({ + price: Number(roundToTick(hi.high)), + color: "#ffb84d", + lineWidth: 1, + lineStyle: 2, + axisLabelVisible: true, + title: "高点", + }) + ); + rangeMarkers.push( + candleSeries.createPriceLine({ + price: Number(roundToTick(lo.low)), + color: "#4cd97f", + lineWidth: 1, + lineStyle: 2, + axisLabelVisible: true, + title: "低点", + }) + ); + } + + function readQuery() { + const qs = new URLSearchParams(window.location.search); + const ex = qs.get("exchange_key") || qs.get("exchange") || ""; + const sym = qs.get("symbol") || ""; + const tf = qs.get("timeframe") || ""; + if (ex && elExchange) elExchange.value = ex; + if (sym && elSymbol) elSymbol.value = sym; + if (tf && elTf) elTf.value = tf; + } + + function applyDefaults() { + if (elSymbol && !elSymbol.value.trim()) elSymbol.value = "BTC/USDT"; + if (elTf && !elTf.value) elTf.value = "1d"; + } + + function currentViewSeriesKey() { + const exKey = (elExchange && elExchange.value) || ""; + const sym = (elSymbol && elSymbol.value.trim()) || ""; + const tf = (elTf && elTf.value) || "1d"; + if (!exKey || !sym) return ""; + return viewKey(exKey, sym, tf); + } + + function postChartWatch() { + const exKey = (elExchange && elExchange.value) || ""; + const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || ""; + const tf = (elTf && elTf.value) || "1d"; + if (!exKey || !sym) return Promise.resolve(); + return fetch("/api/chart/watch", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ exchange_key: exKey, symbol: sym, timeframe: tf }), + }).catch(function () {}); + } + + function postChartUnwatch() { + const exKey = (elExchange && elExchange.value) || ""; + const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || ""; + const tf = (elTf && elTf.value) || "1d"; + if (!exKey || !sym) return Promise.resolve(); + return fetch("/api/chart/unwatch", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ exchange_key: exKey, symbol: sym, timeframe: tf }), + }).catch(function () {}); + } + + function closeChartStream() { + if (chartEventSource) { + chartEventSource.close(); + chartEventSource = null; + } + } + + function handleChartStreamEvent(st) { + if (!st || st.polling) return; + const vKey = currentViewSeriesKey(); + if (!vKey) return; + const tails = st.tails || {}; + const series = st.series || {}; + const tailPack = lookupSeriesMapEntry(tails, vKey); + if (tailPack && tailPack.candles && tailPack.candles.length) { + if ( + applyIncomingTailCandles(tailPack.candles, { + price_tick: tailPack.price_tick, + series_version: tailPack.series_version, + chart_version: st.chart_version, + updated_at: tailPack.updated_at || st.updated_at, + }) + ) { + return; + } + } + const seriesEntry = lookupSeriesMapEntry(series, vKey); + const sVer = seriesEntry ? Number(seriesEntry.series_version) || 0 : 0; + const seriesChanged = sVer > 0 && sVer !== localSeriesVersion; + if (seriesChanged) { + if (lastCandles.length && vKey === lastViewKey) { + void refreshChartTail(); + } else if (!lastCandles.length && !chartDataLoading) { + void loadChart(false); + } + return; + } + if (tailPack && lastCandles.length && vKey === lastViewKey && !chartDataLoading) { + void refreshChartTail(); + return; + } + if (posContext) updateLivePosPnl(); + const ver = Number(st.chart_version) || 0; + if (ver && ver !== localChartVersion) { + localChartVersion = ver; + if (lastCandles.length && vKey === lastViewKey && !chartDataLoading) { + void refreshChartTail(); + } + } + } + + function connectChartStream() { + closeChartStream(); + const page = document.getElementById("page-market"); + if (!page || page.classList.contains("hidden")) return; + chartEventSource = new EventSource("/api/chart/stream"); + chartEventSource.addEventListener("chart", function (ev) { + try { + handleChartStreamEvent(JSON.parse(ev.data || "{}")); + } catch (_) {} + }); + chartEventSource.onerror = function () { + closeChartStream(); + if (chartSseReconnectTimer) clearTimeout(chartSseReconnectTimer); + chartSseReconnectTimer = setTimeout(function () { + const p = document.getElementById("page-market"); + if (p && !p.classList.contains("hidden")) connectChartStream(); + }, 8000); + }; + } + + function startChartWatchHeartbeat() { + stopChartWatchHeartbeat(); + void postChartWatch(); + chartWatchTimer = setInterval(function () { + const page = document.getElementById("page-market"); + if (!page || page.classList.contains("hidden")) return; + void postChartWatch(); + }, CHART_WATCH_HEARTBEAT_MS); + } + + function stopChartWatchHeartbeat() { + if (chartWatchTimer) clearInterval(chartWatchTimer); + chartWatchTimer = null; + } + + function startAutoRefresh() { + stopAutoRefresh(); + const tick = function () { + const page = document.getElementById("page-market"); + if (!page || page.classList.contains("hidden")) return; + if (lastCandles.length) { + void refreshChartTail(); + } else if (!chartDataLoading) { + void loadChart(false); + } + }; + refreshTimer = setInterval(tick, CHART_SSE_FALLBACK_MS); + tick(); + } + + function stopAutoRefresh() { + if (refreshTimer) clearInterval(refreshTimer); + refreshTimer = null; + if (chartSseReconnectTimer) { + clearTimeout(chartSseReconnectTimer); + chartSseReconnectTimer = null; + } + } + + function stopChartLive() { + stopAutoRefresh(); + stopChartWatchHeartbeat(); + closeChartStream(); + void postChartUnwatch(); + } + + function mountVolRankSheet(forFullscreen) { + if (!elVolRankSheet) return; + const anchor = forFullscreen ? elVolRankAnchorFs : elVolRankAnchor; + if (!anchor || elVolRankSheet.parentElement === anchor) return; + anchor.appendChild(elVolRankSheet); + } + + function setVolRankBtnActive(btn, on) { + if (!btn) return; + btn.classList.toggle("is-active", on); + btn.setAttribute("aria-expanded", on ? "true" : "false"); + } + + function setVolRankSheetOpen(open) { + const on = !!open; + if (elVolRankSheet) { + elVolRankSheet.classList.toggle("hidden", !on); + elVolRankSheet.setAttribute("aria-hidden", on ? "false" : "true"); + } + setVolRankBtnActive(elVolRankBtn, on); + setVolRankBtnActive(elFsVolRankBtn, on); + if (on) void loadVolumeRank(); + } + + function bindVolRankPanel() { + function toggleVolRankSheet() { + const open = elVolRankSheet && elVolRankSheet.classList.contains("hidden"); + setVolRankSheetOpen(open); + } + if (elVolRankBtn) elVolRankBtn.addEventListener("click", toggleVolRankSheet); + if (elFsVolRankBtn) elFsVolRankBtn.addEventListener("click", toggleVolRankSheet); + document.addEventListener("pointerdown", function (ev) { + if (!elVolRankSheet || elVolRankSheet.classList.contains("hidden")) return; + const t = ev.target; + if (elVolRankSheet.contains(t)) return; + if (elVolRankBtn && elVolRankBtn.contains(t)) return; + if (elFsVolRankBtn && elFsVolRankBtn.contains(t)) return; + setVolRankSheetOpen(false); + }); + } + + function renderVolumeRank(data) { + if (!elVolRankMeta || !elVolRankList) return; + elVolRankList.innerHTML = ""; + if (!data || !data.ok || !data.items || !data.items.length) { + elVolRankMeta.textContent = + (data && data.msg) || + "暂无排名数据(请 pm2 restart 三实例与 manual-trading-hub 后重试)"; + return; + } + const resetHour = data.reset_hour != null ? data.reset_hour : 8; + const rankDate = data.rank_date || "—"; + const updated = data.updated_at || "—"; + const total = data.total_symbols != null ? data.total_symbols : ""; + const count = data.items.length; + const expect = data.expected_count != null ? data.expected_count : 20; + let meta = + "昨日成交 Top" + + expect + + " · 交易日 " + + rankDate + + " · 每早 " + + resetHour + + ":00 更新 · 显示 " + + count + + "/" + + expect + + " 条"; + if (total) meta += " · 全市场 " + total + " 个"; + if (data.stale) meta += " · 数据不完整,正在重拉…"; + meta += " · " + updated; + elVolRankMeta.textContent = meta; + const curSym = (elSymbol && elSymbol.value.trim().toUpperCase()) || ""; + data.items.forEach(function (row) { + const li = document.createElement("li"); + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "market-vol-rank-item"; + if (row.symbol && row.symbol.toUpperCase() === curSym) { + btn.classList.add("is-active"); + } + btn.dataset.symbol = row.symbol || ""; + btn.innerHTML = + '' + + (row.rank || "") + + '' + + (row.symbol || "") + + '' + + (row.volume_label || "") + + ""; + btn.addEventListener("click", function () { + if (!row.symbol) return; + if (elSymbol) elSymbol.value = row.symbol; + if (elFsSymbol) elFsSymbol.value = row.symbol; + setVolRankSheetOpen(false); + loadChart(false); + }); + li.appendChild(btn); + elVolRankList.appendChild(li); + }); + } + + async function loadVolumeRank(forceRefresh) { + const exKey = (elExchange && elExchange.value) || ""; + if (!exKey || !elVolRankMeta) return; + elVolRankMeta.textContent = "加载排名…"; + if (elVolRankList) elVolRankList.innerHTML = ""; + try { + let url = "/api/chart/volume-rank?exchange_key=" + encodeURIComponent(exKey); + if (forceRefresh) url += "&refresh=1"; + const r = await fetch(url, { credentials: "same-origin" }); + const data = await r.json(); + if (!r.ok) { + throw new Error((data && data.detail) || (data && data.msg) || "加载失败"); + } + renderVolumeRank(data); + const expect = data.expected_count != null ? data.expected_count : 20; + if (!forceRefresh && data.ok && data.items && data.items.length < expect) { + void loadVolumeRank(true); + } + } catch (e) { + renderVolumeRank({ ok: false, msg: String(e.message || e) }); + } + } + + async function loadMeta() { + const r = await fetch("/api/chart/meta", { credentials: "same-origin" }); + chartMeta = await r.json(); + if (!elExchange || !chartMeta.exchanges) return; + elExchange.innerHTML = ""; + chartMeta.exchanges.forEach(function (ex) { + const opt = document.createElement("option"); + opt.value = ex.key || ex.id; + opt.textContent = ex.name || ex.key; + elExchange.appendChild(opt); + }); + populateFsExchangeOptions(); + readQuery(); + applyDefaults(); + updateExchangeDisplay(); + } + + async function loadChart(force, options) { + options = options || {}; + const autoTick = !!options.autoTick; + if (autoTick) { + return refreshChartTail(); + } + localSeriesVersion = 0; + void postChartWatch(); + if (!ensureChart()) return; + const exKey = (elExchange && elExchange.value) || ""; + const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || ""; + const tf = (elTf && elTf.value) || "1d"; + currentTf = tf; + if (!exKey || !sym) { + if (elStatus) { + elStatus.className = "market-status err"; + elStatus.textContent = "请选择交易所并输入币种"; + } + return; + } + const myToken = ++loadToken; + const vKey = viewKey(exKey, sym, tf); + const resetView = !!force || vKey !== lastViewKey; + chartDataLoading = true; + if (resetView) { + chartViewEpoch += 1; + chartRangeUserLocked = false; + if (chartRangeLockTimer) { + clearTimeout(chartRangeLockTimer); + chartRangeLockTimer = null; + } + resetChartHistoryState(); + lastViewKey = ""; + clearChartSeriesData(); + } + if (elStatus) { + elStatus.className = "market-status"; + elStatus.textContent = "加载中…"; + } + updateHeaderLabels(sym, tf); + + try { + const data = await fetchChartChunk({ + exchange_key: exKey, + symbol: sym, + timeframe: tf, + limit: chartInitialLimit(tf), + refresh: !!force, + }); + if (myToken !== loadToken) return; + if (!data.ok || !data.candles || !data.candles.length) { + throw new Error(data.msg || "无 K 线"); + } + + priceTick = data.price_tick; + try { + applyChartPriceFormat(); + } catch (fmtErr) { + priceTick = null; + applyChartPriceFormat(); + } + applyCandlesToChart(alignCandlesToTick(data.candles), 0); + lastViewKey = vKey; + ensureDrawLayer(); + syncDrawViewKey(); + if (resetView) { + applyDefaultVisibleRange(); + } + syncPosContextForView(exKey, sym); + if (posContext) { + updateLivePosPnl(); + refreshPosPnlFromBoard(); + } + scheduleChartResize(); + + const limit = data.limit || lastCandles.length; + let hint = + "已加载 " + + lastCandles.length + + " 根(首屏 " + + limit + + ")· 库 " + + (data.from_cache || 0) + + " / 新拉 " + + (data.fetched || 0) + + (data.cleared ? " · 清库 " + data.cleared : "") + + " · 左拖加载更多 · 后台 " + + (data.chart_poll_interval_sec || 5) + + "s"; + if (data.stale && data.stale_message) { + hint += " · 缓存:" + data.stale_message; + } + if (elStatus) { + elStatus.className = data.stale ? "market-status warn" : "market-status"; + elStatus.textContent = hint; + } + if (elUpdated) elUpdated.textContent = "数据 " + (data.updated_at || "--"); + if (data.series_version != null) localSeriesVersion = Number(data.series_version) || localSeriesVersion; + if (data.chart_version != null) localChartVersion = Number(data.chart_version) || localChartVersion; + tickLiveClock(); + } catch (e) { + if (myToken !== loadToken) return; + if (elStatus) { + elStatus.className = "market-status err"; + elStatus.textContent = String(e.message || e); + } + } finally { + if (myToken === loadToken) chartDataLoading = false; + } + } + + function bind() { + bindSlDrag(); + bindVolRankPanel(); + if (elRefresh) { + elRefresh.addEventListener("click", function () { + loadChart(true); + }); + } + if (elTf) { + elTf.addEventListener("change", function () { + tfDigitBuf = ""; + if (tfDigitTimer) { + clearTimeout(tfDigitTimer); + tfDigitTimer = null; + } + currentTf = (elTf && elTf.value) || "1d"; + lastViewKey = ""; + tickLiveClock(); + syncFsToolbarFromMain(); + loadChart(false); + }); + } + if (elExchange) { + elExchange.addEventListener("change", function () { + updateExchangeDisplay(); + syncFsToolbarFromMain(); + lastViewKey = ""; + if (elVolRankSheet && !elVolRankSheet.classList.contains("hidden")) { + void loadVolumeRank(); + } + loadChart(false); + }); + } + if (elSymbol) { + elSymbol.addEventListener("keydown", function (e) { + if (e.key === "Enter") loadChart(false); + }); + elSymbol.addEventListener("change", function () { + loadChart(false); + }); + } + const btnLoad = document.getElementById("market-load"); + if (btnLoad) { + btnLoad.addEventListener("click", function () { + loadChart(false); + }); + } + if (elPriceAuto) { + elPriceAuto.addEventListener("click", function () { + priceAutoScale = !priceAutoScale; + applyPriceAutoScale(); + if (priceAutoScale) applyDefaultVisibleRange(); + }); + } + if (elPosClear) { + elPosClear.addEventListener("click", function () { + clearPosContext(); + }); + } + if (elFsBtn) { + elFsBtn.addEventListener("click", function () { + toggleChartFullscreen(); + }); + } + if (elFsExit) { + elFsExit.addEventListener("click", function () { + setChartFullscreen(false); + }); + } + [elIndEma, elIndMacd, elIndRsi].forEach(function (el) { + if (!el) return; + el.addEventListener("change", function () { + updateIndicators(); + }); + }); + if (elPrevCloseLine) { + elPrevCloseLine.checked = loadPrevCloseLinePref(); + elPrevCloseLine.addEventListener("change", syncPrevDayLineUi); + } + if (elPrevHlLines) { + elPrevHlLines.checked = loadPrevHlLinesPref(); + elPrevHlLines.addEventListener("change", syncPrevDayLineUi); + } + if (elDaySplit) { + elDaySplit.checked = loadDaySplitPref(); + elDaySplit.addEventListener("change", syncTradingDaySplitUi); + applyTradingDaySplit(elDaySplit.checked); + } + const pageMarket = document.getElementById("page-market"); + const fsKeyTargets = [window, pageMarket, elChartWrap, chartHost].filter(Boolean); + fsKeyTargets.forEach(function (el) { + el.addEventListener("keydown", onChartFullscreenKey, true); + }); + window.addEventListener("keydown", onMarketKeydown, true); + if (elChartWrap) { + if (!elChartWrap.hasAttribute("tabindex")) elChartWrap.setAttribute("tabindex", "-1"); + elChartWrap.addEventListener("mousedown", focusMarketChartArea); + } + if (elFsExchange) { + elFsExchange.addEventListener("change", function () { + syncMainFromFsToolbar(); + loadChart(false); + }); + } + if (elFsTf) { + elFsTf.addEventListener("change", function () { + currentTf = elFsTf.value || "1d"; + lastViewKey = ""; + syncMainFromFsToolbar(); + tickLiveClock(); + loadChart(false); + }); + } + if (elFsSymbol) { + elFsSymbol.addEventListener("keydown", function (e) { + if (e.key === "Enter") { + syncMainFromFsToolbar(); + loadChart(false); + } + }); + } + if (elFsLoad) { + elFsLoad.addEventListener("click", function () { + syncMainFromFsToolbar(); + loadChart(false); + }); + } + } + + window.hubMarketChart = { + init: async function () { + if (!marketInited) { + marketInited = true; + await loadMeta(); + bind(); + } else { + readQuery(); + } + focusMarketChartArea(); + connectChartStream(); + startChartWatchHeartbeat(); + startAutoRefresh(); + await loadChart(false); + startPriceTagTimer(); + }, + openWith: async function (exKey, sym, tf) { + if (!marketInited) { + await this.init(); + } + if (elExchange && exKey) elExchange.value = exKey; + if (elSymbol && sym) elSymbol.value = String(sym).trim().toUpperCase(); + if (tf && elTf) elTf.value = tf; + lastViewKey = ""; + localSeriesVersion = 0; + updateExchangeDisplay(); + connectChartStream(); + startChartWatchHeartbeat(); + startAutoRefresh(); + await loadChart(false); + startPriceTagTimer(); + }, + reload: function (force) { + loadChart(!!force); + }, + startAutoRefresh: startAutoRefresh, + stopAutoRefresh: stopAutoRefresh, + stopChartLive: stopChartLive, + stopPriceTagTimer: stopPriceTagTimer, + }; + + document.addEventListener("hub-theme-change", function () { + applyChartTheme(); + }); + + if ( + document.getElementById("page-market") && + !document.getElementById("page-market").classList.contains("hidden") + ) { + window.hubMarketChart.init(); + } +})(); diff --git a/manual_trading_hub/static/icons/manifest.webmanifest b/manual_trading_hub/static/icons/manifest.webmanifest index dd7d7f0..9ba6dcb 100644 --- a/manual_trading_hub/static/icons/manifest.webmanifest +++ b/manual_trading_hub/static/icons/manifest.webmanifest @@ -1,23 +1,23 @@ -{ - "name": "复盘系统中控", - "short_name": "中控", - "description": "四所交易监控与行情中控", - "start_url": "/monitor", - "display": "standalone", - "background_color": "#0b0e18", - "theme_color": "#0b0e18", - "icons": [ - { - "src": "/assets/icons/icon-192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "any" - }, - { - "src": "/assets/icons/icon-512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "any maskable" - } - ] -} +{ + "name": "复盘系统中控", + "short_name": "中控", + "description": "三所交易监控与行情中控", + "start_url": "/monitor", + "display": "standalone", + "background_color": "#0b0e18", + "theme_color": "#0b0e18", + "icons": [ + { + "src": "/assets/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/assets/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index ffa1f4b..0ec359a 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -1,1121 +1,1121 @@ - - - - - - - - - - - - - 复盘系统中控 - - - - - - - - - - - - -
-
-
- -
-
复盘系统中控
-
MULTI-EXCHANGE · OPS
-
-
-
-
- - -
- SYNC - - -
-
- - - -
-
-

MON 监控区

-
-
- - - 服务器状态 - 加载中… - -
-
-
- 服务器 -
-
- - -
-
-
-
-
- CPU - -
-
- -
-
-
- 内存 - -
-
- -
-
-
- 硬盘 - -
-
- -
-
-
- 网络 - 实时 -
-
- ↑ — - ↓ — -
-
-
-
- - -
- - - - - -
-
-
- - - - - - - - - - - - - - - - - - - - -
- - - - - - - -
- - - - - - - - - - - - - - - + + + + + + + + + + + + + 复盘系统中控 + + + + + + + + + + + + +
+
+
+ +
+
复盘系统中控
+
MULTI-EXCHANGE · OPS
+
+
+
+
+ + +
+ SYNC + + +
+
+ + + +
+
+

MON 监控区

+
+
+ + + 服务器状态 + 加载中… + +
+
+
+ 服务器 +
+
+ + +
+
+
+
+
+ CPU + +
+
+ +
+
+
+ 内存 + +
+
+ +
+
+
+ 硬盘 + +
+
+ +
+
+
+ 网络 + 实时 +
+
+ ↑ — + ↓ — +
+
+
+
+ + +
+ + + + + +
+
+
+ + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + + + + + + + + + + + + + + diff --git a/manual_trading_hub/云服务器部署说明.md b/manual_trading_hub/云服务器部署说明.md index bd8a286..e8113e3 100644 --- a/manual_trading_hub/云服务器部署说明.md +++ b/manual_trading_hub/云服务器部署说明.md @@ -1,293 +1,289 @@ -# 云服务器部署说明 - -本文说明在 **云服务器(VPS)** 上部署 `crypto_monitor` 中控与四实例的推荐配置:硬件、软件、防火墙、宝塔反代、环境变量、PM2 启动与验收。 - -云上标准做法:**域名 + 宝塔/Nginx 反代 + HTTPS**;业务端口(5100、5000~5004、15200~15203)**不对公网直连**。 - -相关文档: - -- **[本地数据迁移到云端.md](./本地数据迁移到云端.md)** — 备份 `crypto.db`、图片、`hub_settings` 与恢复步骤 -- [局域网与反代部署说明.md](./局域网与反代部署说明.md) — 局域网 IP:端口 与反代域名对照、SSO 行为 -- [部署文档.md](./部署文档.md) — PM2、依赖安装、日常运维 -- [使用说明.md](./使用说明.md) — 中控功能说明 -- [常见问题.md](./常见问题.md) — 故障排查 -- 环境变量模板:[.env.example](./.env.example) - ---- - -## 一、服务器硬件与系统 - -| 项目 | 建议 | -|------|------| -| 配置 | **2 核 4G** 起步;四实例 + 中控 + PM2 同时运行,**4G~8G 更稳** | -| 系统 | **Ubuntu 22.04 / 24.04**(项目文档按 Linux 编写) | -| 磁盘 | **20G+**;日志、SQLite、上传图片会占空间 | -| 网络 | 需能访问各交易所 API;若走代理,在对应 `crypto_monitor_*/.env` 配置 `OKX_SOCKS_PROXY`、`BINANCE_SOCKS_PROXY` 等 | - ---- - -## 二、软件环境 - -```bash -sudo apt update -sudo apt install -y python3 python3-venv python3-pip git curl - -# 进程守护(推荐) -sudo npm i -g pm2 -``` - -**宝塔面板(可选但推荐)**:安装 **Nginx**,用于反向代理与 **SSL**(Let’s Encrypt)。 - -Python 虚拟环境(分开安装,互不替代): - -| 目录 | 用途 | -|------|------| -| `manual_trading_hub/.venv` | 中控 `hub.py` + 子代理 `agent.py` | -| `crypto_monitor_binance/.venv` | 币安 Flask | -| `crypto_monitor_okx/.venv` | OKX Flask | -| `crypto_monitor_gate/.venv` | Gate 训练 Flask | -| `crypto_monitor_gate_bot/.venv` | Gate 趋势 Flask | - -各实例 `ecosystem.config.cjs` 一般已设置 **`PYTHONPATH=..`**(仓库根),以便加载 `hub_bridge.py`、`hub_auth.py` 等。 - ---- - -## 三、网络与端口(云上最重要) - -**原则:公网只暴露 Nginx 的 80/443;Flask 与 agent 只监听本机。** - -| 服务 | 本机端口(示例) | 是否对公网开放 | -|------|------------------|----------------| -| 中控 hub | 5100 | **否** → 仅 `https://hub.你的域名` 反代 | -| 币安 Flask | 5001 | **否** → `https://binance.你的域名` | -| OKX Flask | 5004 | **否** → `https://okx.你的域名` | -| Gate 训练 Flask | 5000 | **否** → `https://gate.你的域名` | -| Gate 趋势 Flask | 5002 | **否** → `https://gate-bot.你的域名` | -| 子代理 agent | 15200~15203 | **否**,必须 **127.0.0.1** | - -### 云厂商安全组 / 系统防火墙 - -- **放行**:`80`、`443`(给宝塔/Nginx) -- **不要放行**:`5100`、`5000`~`5004`、`15200`~`15203`(除非临时本机调试,用完即关) - ---- - -## 四、域名与宝塔反代 - -为 **中控 + 每个要对外打开的实例** 各建一个站点(子域名示例): - -| 站点(浏览器访问) | 反代目标 | -|--------------------|----------| -| `https://hub.example.com` | `http://127.0.0.1:5100` | -| `https://okx.example.com` | `http://127.0.0.1:5004` | -| `https://binance.example.com` | `http://127.0.0.1:5001` | -| `https://gate.example.com` | `http://127.0.0.1:5000` | -| `https://gate-bot.example.com` | `http://127.0.0.1:5002` | - -### 宝塔操作要点 - -1. 每个域名 → **网站** → **反向代理** → 目标 `http://127.0.0.1:对应端口`。 -2. 申请 **SSL**(Let’s Encrypt),强制 HTTPS。 -3. **不要**再给实例站加一层宝塔「访问密码」(会与 Flask `/login` 重复);直链鉴权用下文 **`APP_USERNAME` / `APP_PASSWORD`**。 -4. Nginx 建议保留常见代理头(宝塔默认通常已带): - -```nginx -proxy_set_header Host $host; -proxy_set_header X-Real-IP $remote_addr; -proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; -proxy_set_header X-Forwarded-Proto $scheme; -``` - -中控请求实例 `/api/hub/*` 时会带 **`X-Hub-Token`**,一般无需额外配置。 - ---- - -## 五、环境变量(必配) - -### 5.1 中控 `manual_trading_hub/.env` - -```env -HUB_HOST=0.0.0.0 -HUB_PORT=5100 - -# 与四实例 .env 完全相同(API + SSO 签名) -HUB_BRIDGE_TOKEN=请填一长串随机字符 - -# 中控网页登录(公网务必设置) -HUB_USERNAME=admin -HUB_PASSWORD=强密码 -HUB_SESSION_SECRET=另一串随机字符 - -# 中控为 HTTPS 时建议 true -HUB_COOKIE_SECURE=true - -# 公网用域名访问中控(宝塔反代)时必设其一: -# HUB_ALLOW_PUBLIC=true (推荐:反代 + 中控密码) -# 或反代目标必须是 http://127.0.0.1:5100 且可保持 HUB_TRUST_LAN=false -HUB_ALLOW_PUBLIC=true -HUB_TRUST_LAN=false - -# 从中控打开实例的 SSO 链接有效期(秒),默认 7200 = 2 小时 -HUB_SSO_TTL_SEC=7200 - -# 各实例 hub_settings 里 flask_url 已写 https 域名时,一般可不设 -# HUB_PUBLIC_ORIGIN=https://hub.example.com -``` - -完整项见 [`.env.example`](./.env.example)。 - -### 5.2 四个实例 `crypto_monitor_*/.env` - -每个目录都要有(**直链** `https://okx.域名` 时用这套登录网页): - -```env -# 各所 API 密钥(按交易所填写) -# APP_PORT=5004 - -# 与中控 manual_trading_hub/.env 中 HUB_BRIDGE_TOKEN 完全一致 -HUB_BRIDGE_TOKEN=与中控相同 - -# 四实例建议统一(直链登录用) -APP_USERNAME=统一用户名 -APP_PASSWORD=统一强密码 - -# 云服务器切勿开启(会跳过网页登录): -# APP_AUTH_DISABLED=true -``` - -### 5.3 子代理 - -- `CONTROL_TOKEN` 可与 `HUB_BRIDGE_TOKEN` 相同。 -- 由 PM2 在对应 `crypto_monitor_*` 目录启动,`run_agent.sh` 加载该目录 `.env`。 -- 只监听 **127.0.0.1:1520x**,不映射到公网。 - ---- - -## 六、中控「系统设置」`hub_settings.json` - -在网页 **系统设置** 保存,或编辑 `manual_trading_hub/hub_settings.json`。 - -云上 **`flask_url` 必须写浏览器能打开的 HTTPS 域名**(不要写 `127.0.0.1`,除非配合 `HUB_PUBLIC_ORIGIN` 做替换): - -| 字段 | 云上填法 | 说明 | -|------|----------|------| -| `flask_url` | `https://okx.example.com` | 用户浏览器、SSO 打开实例 | -| `agent_url` | `http://127.0.0.1:15201` | 仅中控本机访问子代理 | -| `enabled` | 按需 | 不参与监控的户可关 | -| `capabilities` | 按需 | `key` / `trend` 等 | - -**同机部署的两种写法(二选一):** - -1. **推荐**:每个实例 `flask_url` 直接写该实例的 `https://子域名`。 -2. **备选**:`flask_url` 写 `http://127.0.0.1:5004`,中控 `.env` 设 `HUB_PUBLIC_ORIGIN=https://okx.example.com`(适合共用一个 IP、靠端口区分时)。 - -`agent_url` 始终用 **`http://127.0.0.1:1520x`**。 - ---- - -## 七、PM2 启动顺序 - -代码路径示例:`/opt/crypto_monitor/`(按实际替换)。 - -```bash -cd /opt/crypto_monitor - -# 1)四个实例 Flask(各目录 ecosystem.config.cjs,进程名以你机器为准) -cd crypto_monitor_okx && pm2 start ecosystem.config.cjs -cd ../crypto_monitor_binance && pm2 start ecosystem.config.cjs -cd ../crypto_monitor_gate && pm2 start ecosystem.config.cjs -cd ../crypto_monitor_gate_bot && pm2 start ecosystem.config.cjs - -# 2)中控 + 四个子代理(一条拉起 5 个进程) -cd ../manual_trading_hub -python3 -m venv .venv -source .venv/bin/activate -pip install -r requirements.txt -cp .env.example .env # 编辑填入真实值 -chmod +x scripts/run_hub.sh scripts/run_agent.sh -pm2 start ecosystem.config.cjs -pm2 save -pm2 startup # 按提示执行 sudo 命令后再 pm2 save -``` - -或: - -```bash -cd /opt/crypto_monitor/manual_trading_hub -bash scripts/pm2_hub.sh start -``` - -### PM2 进程一览 - -| 进程名 | 说明 | -|--------|------| -| `manual-trading-hub` | 中控 :5100 | -| `manual-agent-binance` | :15200 | -| `manual-agent-okx` | :15201 | -| `manual-agent-gate` | :15202 | -| `manual-agent-gate-bot` | :15203 | -| `crypto_*`(各目录自定) | 各 Flask `APP_PORT` | - -不用 OKX 时可在 `.env` 设 `HUB_DISABLED_IDS=1`,或 `pm2 stop manual-agent-okx`。 - ---- - -## 八、访问与登录(云上行为) - -| 访问方式 | 地址示例 | 需要什么 | -|----------|----------|----------| -| 中控监控 | `https://hub.example.com/monitor` | **中控** `HUB_USERNAME` / `HUB_PASSWORD` | -| 中控点「实例 / 策略交易 / 复盘」 | 自动打开 `https://okx.example.com/hub-sso?...` | 已登中控即可;**2 小时内、单次** SSO,**免输**实例密码 | -| 浏览器直链实例 | `https://okx.example.com` | 实例 **`APP_USERNAME` / `APP_PASSWORD`**(`/login`) | - -SSO 复用 **`HUB_BRIDGE_TOKEN`** 签名,详见 [局域网与反代部署说明.md §五](./局域网与反代部署说明.md)。 - ---- - -## 九、安全建议(云服务器必看) - -1. **SSH**:密钥登录,关闭密码登录;必要时改 SSH 端口。 -2. **`HUB_BRIDGE_TOKEN`**:足够长、随机;勿提交 Git、勿写进前端页面。 -3. **交易所 API Key**:仅放在各实例 `.env`;权限尽量最小化(勿随意开提币)。 -4. **中控**:公网必须设 `HUB_PASSWORD`;`HUB_TRUST_LAN=false`。 -5. **实例**:云上 **`APP_AUTH_DISABLED` 必须为 false**(或未设置)。 -6. **备份**:定期备份各实例数据库 / SQLite 与 `hub_settings.json`。 -7. **`.env` 换行**:Linux 上勿用 Windows CRLF;可用 `bash scripts/fix_env_crlf.sh`。 - ---- - -## 十、部署后验收清单 - -- [ ] `https://hub.你的域名` 能打开并登录中控 -- [ ] 监控卡片有持仓/余额(子代理在线) -- [ ] 已登录中控 → 点「实例」→ **无**实例登录页,直接进入 -- [ ] 隐身窗口直开 `https://okx.你的域名` → 出现 **`/login`**,统一账号密码可进 -- [ ] `pm2 status`:hub、4×agent、用到的 `crypto_*` 均为 online -- [ ] 云安全组 **未** 对公网开放 5100、5000~5004、15200~15203 -- [ ] 四实例 `.env` 与中控 `HUB_BRIDGE_TOKEN` 一致 -- [ ] 实例启动日志无长期 `[hub_bridge] ImportError` - ---- - -## 十一、常见问题速查 - -| 现象 | 处理 | -|------|------| -| 从中控打开仍要实例密码 | 见 [常见问题.md §4.3](./常见问题.md);检查 token、重启 Flask、`hub_settings` 的 `key` | -| 监控无持仓 / 子代理不可用 | `curl http://127.0.0.1:15201/status`;查 `.env` CRLF、API 密钥 | -| 复盘/实例链接是 127.0.0.1 | `flask_url` 改为 https 域名,或设 `HUB_PUBLIC_ORIGIN` | -| 仅 Gate 子代理反复重启 | `.env` CRLF:`bash manual_trading_hub/scripts/fix_env_crlf.sh` | - ---- - -## 十二、与局域网部署的区别(简要) - -| 项目 | 云服务器 | 局域网 | -|------|----------|--------| -| 对外地址 | `https://子域名` | `http://内网IP:端口` | -| `flask_url` | 写 **域名** | 写 **内网 IP:端口** | -| 防火墙 | 只开 80/443 | 内网可开 5100、500x | -| SSL | 必须(宝塔证书) | 通常 HTTP 即可 | -| `HUB_COOKIE_SECURE` | 建议 `true` | HTTP 时用 `false` | - -局域网详细步骤见 [局域网与反代部署说明.md §三](./局域网与反代部署说明.md)。 +# 云服务器部署说明 + +本文说明在 **云服务器(VPS)** 上部署 `crypto_monitor` 中控与三实例的推荐配置:硬件、软件、防火墙、宝塔反代、环境变量、PM2 启动与验收。 + +云上标准做法:**域名 + 宝塔/Nginx 反代 + HTTPS**;业务端口(5100、5000~5004、15200~15202)**不对公网直连**。 + +相关文档: + +- **[本地数据迁移到云端.md](./本地数据迁移到云端.md)** — 备份 `crypto.db`、图片、`hub_settings` 与恢复步骤 +- [局域网与反代部署说明.md](./局域网与反代部署说明.md) — 局域网 IP:端口 与反代域名对照、SSO 行为 +- [部署文档.md](./部署文档.md) — PM2、依赖安装、日常运维 +- [使用说明.md](./使用说明.md) — 中控功能说明 +- [常见问题.md](./常见问题.md) — 故障排查 +- 环境变量模板:[.env.example](./.env.example) + +--- + +## 一、服务器硬件与系统 + +| 项目 | 建议 | +|------|------| +| 配置 | **2 核 4G** 起步;三实例 + 中控 + PM2 同时运行,**4G~8G 更稳** | +| 系统 | **Ubuntu 22.04 / 24.04**(项目文档按 Linux 编写) | +| 磁盘 | **20G+**;日志、SQLite、上传图片会占空间 | +| 网络 | 需能访问各交易所 API;若走代理,在对应 `crypto_monitor_*/.env` 配置 `OKX_SOCKS_PROXY`、`BINANCE_SOCKS_PROXY` 等 | + +--- + +## 二、软件环境 + +```bash +sudo apt update +sudo apt install -y python3 python3-venv python3-pip git curl + +# 进程守护(推荐) +sudo npm i -g pm2 +``` + +**宝塔面板(可选但推荐)**:安装 **Nginx**,用于反向代理与 **SSL**(Let’s Encrypt)。 + +Python 虚拟环境(分开安装,互不替代): + +| 目录 | 用途 | +|------|------| +| `manual_trading_hub/.venv` | 中控 `hub.py` + 子代理 `agent.py` | +| `crypto_monitor_binance/.venv` | 币安 Flask | +| `crypto_monitor_okx/.venv` | OKX Flask | +| `crypto_monitor_gate/.venv` | Gate Flask | +| `crypto_monitor_gate/.venv` | Gate Flask | + +各实例 `ecosystem.config.cjs` 一般已设置 **`PYTHONPATH=..`**(仓库根),以便加载 `hub_bridge.py`、`hub_auth.py` 等。 + +--- + +## 三、网络与端口(云上最重要) + +**原则:公网只暴露 Nginx 的 80/443;Flask 与 agent 只监听本机。** + +| 服务 | 本机端口(示例) | 是否对公网开放 | +|------|------------------|----------------| +| 中控 hub | 5100 | **否** → 仅 `https://hub.你的域名` 反代 | +| 币安 Flask | 5001 | **否** → `https://binance.你的域名` | +| OKX Flask | 5004 | **否** → `https://okx.你的域名` | +| Gate Flask | 5000 | **否** → `https://gate.你的域名` | +| 子代理 agent | 15200~15202 | **否**,必须 **127.0.0.1** | + +### 云厂商安全组 / 系统防火墙 + +- **放行**:`80`、`443`(给宝塔/Nginx) +- **不要放行**:`5100`、`5000`~`5004`、`15200`~`15202`(除非临时本机调试,用完即关) + +--- + +## 四、域名与宝塔反代 + +为 **中控 + 每个要对外打开的实例** 各建一个站点(子域名示例): + +| 站点(浏览器访问) | 反代目标 | +|--------------------|----------| +| `https://hub.example.com` | `http://127.0.0.1:5100` | +| `https://okx.example.com` | `http://127.0.0.1:5004` | +| `https://binance.example.com` | `http://127.0.0.1:5001` | +| `https://gate.example.com` | `http://127.0.0.1:5000` | + +### 宝塔操作要点 + +1. 每个域名 → **网站** → **反向代理** → 目标 `http://127.0.0.1:对应端口`。 +2. 申请 **SSL**(Let’s Encrypt),强制 HTTPS。 +3. **不要**再给实例站加一层宝塔「访问密码」(会与 Flask `/login` 重复);直链鉴权用下文 **`APP_USERNAME` / `APP_PASSWORD`**。 +4. Nginx 建议保留常见代理头(宝塔默认通常已带): + +```nginx +proxy_set_header Host $host; +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Forwarded-Proto $scheme; +``` + +中控请求实例 `/api/hub/*` 时会带 **`X-Hub-Token`**,一般无需额外配置。 + +--- + +## 五、环境变量(必配) + +### 5.1 中控 `manual_trading_hub/.env` + +```env +HUB_HOST=0.0.0.0 +HUB_PORT=5100 + +# 与三实例 .env 完全相同(API + SSO 签名) +HUB_BRIDGE_TOKEN=请填一长串随机字符 + +# 中控网页登录(公网务必设置) +HUB_USERNAME=admin +HUB_PASSWORD=强密码 +HUB_SESSION_SECRET=另一串随机字符 + +# 中控为 HTTPS 时建议 true +HUB_COOKIE_SECURE=true + +# 公网用域名访问中控(宝塔反代)时必设其一: +# HUB_ALLOW_PUBLIC=true (推荐:反代 + 中控密码) +# 或反代目标必须是 http://127.0.0.1:5100 且可保持 HUB_TRUST_LAN=false +HUB_ALLOW_PUBLIC=true +HUB_TRUST_LAN=false + +# 从中控打开实例的 SSO 链接有效期(秒),默认 7200 = 2 小时 +HUB_SSO_TTL_SEC=7200 + +# 各实例 hub_settings 里 flask_url 已写 https 域名时,一般可不设 +# HUB_PUBLIC_ORIGIN=https://hub.example.com +``` + +完整项见 [`.env.example`](./.env.example)。 + +### 5.2 三个实例 `crypto_monitor_*/.env` + +每个目录都要有(**直链** `https://okx.域名` 时用这套登录网页): + +```env +# 各所 API 密钥(按交易所填写) +# APP_PORT=5004 + +# 与中控 manual_trading_hub/.env 中 HUB_BRIDGE_TOKEN 完全一致 +HUB_BRIDGE_TOKEN=与中控相同 + +# 三实例建议统一(直链登录用) +APP_USERNAME=统一用户名 +APP_PASSWORD=统一强密码 + +# 云服务器切勿开启(会跳过网页登录): +# APP_AUTH_DISABLED=true +``` + +### 5.3 子代理 + +- `CONTROL_TOKEN` 可与 `HUB_BRIDGE_TOKEN` 相同。 +- 由 PM2 在对应 `crypto_monitor_*` 目录启动,`run_agent.sh` 加载该目录 `.env`。 +- 只监听 **127.0.0.1:1520x**,不映射到公网。 + +--- + +## 六、中控「系统设置」`hub_settings.json` + +在网页 **系统设置** 保存,或编辑 `manual_trading_hub/hub_settings.json`。 + +云上 **`flask_url` 必须写浏览器能打开的 HTTPS 域名**(不要写 `127.0.0.1`,除非配合 `HUB_PUBLIC_ORIGIN` 做替换): + +| 字段 | 云上填法 | 说明 | +|------|----------|------| +| `flask_url` | `https://okx.example.com` | 用户浏览器、SSO 打开实例 | +| `agent_url` | `http://127.0.0.1:15201` | 仅中控本机访问子代理 | +| `enabled` | 按需 | 不参与监控的户可关 | +| `capabilities` | 按需 | `key` / `trend` 等 | + +**同机部署的两种写法(二选一):** + +1. **推荐**:每个实例 `flask_url` 直接写该实例的 `https://子域名`。 +2. **备选**:`flask_url` 写 `http://127.0.0.1:5004`,中控 `.env` 设 `HUB_PUBLIC_ORIGIN=https://okx.example.com`(适合共用一个 IP、靠端口区分时)。 + +`agent_url` 始终用 **`http://127.0.0.1:1520x`**。 + +--- + +## 七、PM2 启动顺序 + +代码路径示例:`/opt/crypto_monitor/`(按实际替换)。 + +```bash +cd /opt/crypto_monitor + +# 1)三个实例 Flask(各目录 ecosystem.config.cjs,进程名以你机器为准) +cd crypto_monitor_okx && pm2 start ecosystem.config.cjs +cd ../crypto_monitor_binance && pm2 start ecosystem.config.cjs +cd ../crypto_monitor_gate && pm2 start ecosystem.config.cjs + +# 2)中控 + 三个子代理(一条拉起 4 个进程:hub + 3 agent) +cd ../manual_trading_hub +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env # 编辑填入真实值 +chmod +x scripts/run_hub.sh scripts/run_agent.sh +pm2 start ecosystem.config.cjs +pm2 save +pm2 startup # 按提示执行 sudo 命令后再 pm2 save +``` + +或: + +```bash +cd /opt/crypto_monitor/manual_trading_hub +bash scripts/pm2_hub.sh start +``` + +### PM2 进程一览 + +| 进程名 | 说明 | +|--------|------| +| `manual-trading-hub` | 中控 :5100 | +| `manual-agent-binance` | :15200 | +| `manual-agent-okx` | :15201 | +| `manual-agent-gate` | :15202 | +| `crypto_*`(各目录自定) | 各 Flask `APP_PORT` | + +不用 OKX 时可在 `.env` 设 `HUB_DISABLED_IDS=1`,或 `pm2 stop manual-agent-okx`。 + +--- + +## 八、访问与登录(云上行为) + +| 访问方式 | 地址示例 | 需要什么 | +|----------|----------|----------| +| 中控监控 | `https://hub.example.com/monitor` | **中控** `HUB_USERNAME` / `HUB_PASSWORD` | +| 中控点「实例 / 策略交易 / 复盘」 | 自动打开 `https://okx.example.com/hub-sso?...` | 已登中控即可;**2 小时内、单次** SSO,**免输**实例密码 | +| 浏览器直链实例 | `https://okx.example.com` | 实例 **`APP_USERNAME` / `APP_PASSWORD`**(`/login`) | + +SSO 复用 **`HUB_BRIDGE_TOKEN`** 签名,详见 [局域网与反代部署说明.md §五](./局域网与反代部署说明.md)。 + +--- + +## 九、安全建议(云服务器必看) + +1. **SSH**:密钥登录,关闭密码登录;必要时改 SSH 端口。 +2. **`HUB_BRIDGE_TOKEN`**:足够长、随机;勿提交 Git、勿写进前端页面。 +3. **交易所 API Key**:仅放在各实例 `.env`;权限尽量最小化(勿随意开提币)。 +4. **中控**:公网必须设 `HUB_PASSWORD`;`HUB_TRUST_LAN=false`。 +5. **实例**:云上 **`APP_AUTH_DISABLED` 必须为 false**(或未设置)。 +6. **备份**:定期备份各实例数据库 / SQLite 与 `hub_settings.json`。 +7. **`.env` 换行**:Linux 上勿用 Windows CRLF;可用 `bash scripts/fix_env_crlf.sh`。 + +--- + +## 十、部署后验收清单 + +- [ ] `https://hub.你的域名` 能打开并登录中控 +- [ ] 监控卡片有持仓/余额(子代理在线) +- [ ] 已登录中控 → 点「实例」→ **无**实例登录页,直接进入 +- [ ] 隐身窗口直开 `https://okx.你的域名` → 出现 **`/login`**,统一账号密码可进 +- [ ] `pm2 status`:hub、4×agent、用到的 `crypto_*` 均为 online +- [ ] 云安全组 **未** 对公网开放 5100、5000~5004、15200~15202 +- [ ] 三实例 `.env` 与中控 `HUB_BRIDGE_TOKEN` 一致 +- [ ] 实例启动日志无长期 `[hub_bridge] ImportError` + +--- + +## 十一、常见问题速查 + +| 现象 | 处理 | +|------|------| +| 从中控打开仍要实例密码 | 见 [常见问题.md §4.3](./常见问题.md);检查 token、重启 Flask、`hub_settings` 的 `key` | +| 监控无持仓 / 子代理不可用 | `curl http://127.0.0.1:15201/status`;查 `.env` CRLF、API 密钥 | +| 复盘/实例链接是 127.0.0.1 | `flask_url` 改为 https 域名,或设 `HUB_PUBLIC_ORIGIN` | +| 仅 Gate 子代理反复重启 | `.env` CRLF:`bash manual_trading_hub/scripts/fix_env_crlf.sh` | + +--- + +## 十二、与局域网部署的区别(简要) + +| 项目 | 云服务器 | 局域网 | +|------|----------|--------| +| 对外地址 | `https://子域名` | `http://内网IP:端口` | +| `flask_url` | 写 **域名** | 写 **内网 IP:端口** | +| 防火墙 | 只开 80/443 | 内网可开 5100、500x | +| SSL | 必须(宝塔证书) | 通常 HTTP 即可 | +| `HUB_COOKIE_SECURE` | 建议 `true` | HTTP 时用 `false` | + +局域网详细步骤见 [局域网与反代部署说明.md §三](./局域网与反代部署说明.md)。 diff --git a/manual_trading_hub/交易监管说明.md b/manual_trading_hub/交易监管说明.md index 058a177..cbdb66c 100644 --- a/manual_trading_hub/交易监管说明.md +++ b/manual_trading_hub/交易监管说明.md @@ -1,84 +1,84 @@ -# 交易监管(AI 教练) - -中控 **交易监管** 用于防止过度交易与频繁手动操作:在 **手动/中控开平仓** 与 **新开仓** 时自动推送至 **今日监管长会话**,并可选 **企业微信** 提醒;程序止盈/止损按「正常执行」鼓励,不计入频繁交易统计。 - -入口:**AI 教练**(`/ai`)→ Tab **交易监管**,或微信链接(在系统设置中配置)。 - -## 监管范围 - -| 类型 | 识别 | 页内推送 | 微信(P0) | 频率统计 | -|------|------|----------|------------|----------| -| 实例手动平仓 | `result = 手动平仓` | ✓ | ✓ | ✓ | -| 中控平仓 | `result = 强制清仓` 等 | ✓ | ✓ | ✓ | -| 新开仓 | 监控板持仓 diff(0→有仓 / 新合约) | ✓ | ✓ | ✓ | -| 程序止盈 | 止盈 / 保本止盈 / 移动止盈 | ✓ | 可选 | ✗ | -| 程序止损 | 止损 | ✓ | 可选 | ✗ | -| 外部平仓 | 外部平仓、时间平仓 | ✗ | ✗ | ✗ | - -频率规则(间隔过短、30 分钟笔数、日笔数、连亏、平后快开)**只对手动/中控开平** 叠加 `[监管·频率]` 警告。 - -## 会话 - -- 每个交易日 **一条长会话**(`bot_mode: supervisor`,标题 `今日监管 YYYY-MM-DD`)。 -- 系统消息(`role: system`)+ AI 短评(`assistant`)+ 用户回复(`user`)同线程。 -- 与 **交易教练 / 普通聊天** 分离;监管会话不支持「新开对话」。 - -## 系统设置 - -路径:**系统设置** → **交易监管 · 企业微信**(写入 `hub_settings.json` → `supervisor`)。 - -| 字段 | 说明 | -|------|------| -| `enabled` | 总开关 | -| `wechat_webhook` | **监管专用** 企业微信机器人(与四所实例 `.env` 的 `WECHAT_WEBHOOK` 独立) | -| `wechat_link_base` | 微信消息末尾跳转链接(**可单独修改**,如 `https://域名/ai?mode=supervisor`) | -| `wechat_prefix` | 消息前缀,默认 `【交易监管】` | -| `wechat_on_program_tp_sl` | 程序止盈/止损是否也发微信 | -| `manual_close_daily_warn` | 日手动平警告阈值(默认 2) | -| `interval_warn_minutes` | 两笔手动/中控平最短间隔(默认 15 分钟) | -| `freq_30m_count` | 30 分钟内笔数阈值(默认 2) | -| `reopen_after_close_minutes` | 手动平后再开仓警告间隔(默认 30 分钟) | - -`.env` 兜底(设置页保存优先): - -```env -SUPERVISOR_WECHAT_WEBHOOK=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=... -SUPERVISOR_WECHAT_LINK=https://你的域名/ai?mode=supervisor -SUPERVISOR_POLL_INTERVAL_SEC=30 -``` - -## API - -| 方法 | 路径 | 说明 | -|------|------|------| -| GET | `/api/ai/supervisor/session` | 今日监管会话 | -| GET | `/api/ai/supervisor/stream` | SSE 版本推送 | -| POST | `/api/ai/supervisor/chat/send` | 用户回聊(JSON `{ "message": "..." }`) | -| GET | `/api/ai/supervisor/rules` | 当前阈值 | -| POST | `/api/ai/supervisor/refresh` | 立即扫描 | - -## 存储 - -| 文件 | 内容 | -|------|------| -| `hub_supervisor_state.json` | 已处理事件、持仓快照、频率统计 | -| `hub_ai_chat.json` | 监管会话(`bot_mode: supervisor`) | -| `hub_settings.json` | `supervisor` 配置节 | - -**首次启用** 会对当前交易日已有平仓做 **种子同步**(不补发历史推送),避免部署瞬间刷屏。 - -## 与实例风控 - -实例 `account_risk_lib`(冷静期 / 日冻结)为 **硬拦截**;监管为 **软提醒 + 陪聊**,不绕过实例开仓限制。 - -## 代码位置 - -| 模块 | 路径 | -|------|------| -| 规则与推送 | `hub_supervisor_lib.py` | -| 后台扫描 | `hub_supervisor_cache.py` | -| 会话 | `hub_ai/supervisor_store.py` | -| AI 评语/回聊 | `hub_ai/supervisor.py` | -| 提示词 | `hub_ai/prompts.py` → `SUPERVISOR_SYSTEM` | - -部署后重启中控:`pm2 restart manual-trading-hub`(或你的 hub 进程名)。 +# 交易监管(AI 教练) + +中控 **交易监管** 用于防止过度交易与频繁手动操作:在 **手动/中控开平仓** 与 **新开仓** 时自动推送至 **今日监管长会话**,并可选 **企业微信** 提醒;程序止盈/止损按「正常执行」鼓励,不计入频繁交易统计。 + +入口:**AI 教练**(`/ai`)→ Tab **交易监管**,或微信链接(在系统设置中配置)。 + +## 监管范围 + +| 类型 | 识别 | 页内推送 | 微信(P0) | 频率统计 | +|------|------|----------|------------|----------| +| 实例手动平仓 | `result = 手动平仓` | ✓ | ✓ | ✓ | +| 中控平仓 | `result = 强制清仓` 等 | ✓ | ✓ | ✓ | +| 新开仓 | 监控板持仓 diff(0→有仓 / 新合约) | ✓ | ✓ | ✓ | +| 程序止盈 | 止盈 / 保本止盈 / 移动止盈 | ✓ | 可选 | ✗ | +| 程序止损 | 止损 | ✓ | 可选 | ✗ | +| 外部平仓 | 外部平仓、时间平仓 | ✗ | ✗ | ✗ | + +频率规则(间隔过短、30 分钟笔数、日笔数、连亏、平后快开)**只对手动/中控开平** 叠加 `[监管·频率]` 警告。 + +## 会话 + +- 每个交易日 **一条长会话**(`bot_mode: supervisor`,标题 `今日监管 YYYY-MM-DD`)。 +- 系统消息(`role: system`)+ AI 短评(`assistant`)+ 用户回复(`user`)同线程。 +- 与 **交易教练 / 普通聊天** 分离;监管会话不支持「新开对话」。 + +## 系统设置 + +路径:**系统设置** → **交易监管 · 企业微信**(写入 `hub_settings.json` → `supervisor`)。 + +| 字段 | 说明 | +|------|------| +| `enabled` | 总开关 | +| `wechat_webhook` | **监管专用** 企业微信机器人(与三所实例 `.env` 的 `WECHAT_WEBHOOK` 独立) | +| `wechat_link_base` | 微信消息末尾跳转链接(**可单独修改**,如 `https://域名/ai?mode=supervisor`) | +| `wechat_prefix` | 消息前缀,默认 `【交易监管】` | +| `wechat_on_program_tp_sl` | 程序止盈/止损是否也发微信 | +| `manual_close_daily_warn` | 日手动平警告阈值(默认 2) | +| `interval_warn_minutes` | 两笔手动/中控平最短间隔(默认 15 分钟) | +| `freq_30m_count` | 30 分钟内笔数阈值(默认 2) | +| `reopen_after_close_minutes` | 手动平后再开仓警告间隔(默认 30 分钟) | + +`.env` 兜底(设置页保存优先): + +```env +SUPERVISOR_WECHAT_WEBHOOK=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=... +SUPERVISOR_WECHAT_LINK=https://你的域名/ai?mode=supervisor +SUPERVISOR_POLL_INTERVAL_SEC=30 +``` + +## API + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/ai/supervisor/session` | 今日监管会话 | +| GET | `/api/ai/supervisor/stream` | SSE 版本推送 | +| POST | `/api/ai/supervisor/chat/send` | 用户回聊(JSON `{ "message": "..." }`) | +| GET | `/api/ai/supervisor/rules` | 当前阈值 | +| POST | `/api/ai/supervisor/refresh` | 立即扫描 | + +## 存储 + +| 文件 | 内容 | +|------|------| +| `hub_supervisor_state.json` | 已处理事件、持仓快照、频率统计 | +| `hub_ai_chat.json` | 监管会话(`bot_mode: supervisor`) | +| `hub_settings.json` | `supervisor` 配置节 | + +**首次启用** 会对当前交易日已有平仓做 **种子同步**(不补发历史推送),避免部署瞬间刷屏。 + +## 与实例风控 + +实例 `account_risk_lib`(冷静期 / 日冻结)为 **硬拦截**;监管为 **软提醒 + 陪聊**,不绕过实例开仓限制。 + +## 代码位置 + +| 模块 | 路径 | +|------|------| +| 规则与推送 | `hub_supervisor_lib.py` | +| 后台扫描 | `hub_supervisor_cache.py` | +| 会话 | `hub_ai/supervisor_store.py` | +| AI 评语/回聊 | `hub_ai/supervisor.py` | +| 提示词 | `hub_ai/prompts.py` → `SUPERVISOR_SYSTEM` | + +部署后重启中控:`pm2 restart manual-trading-hub`(或你的 hub 进程名)。 diff --git a/manual_trading_hub/使用说明.md b/manual_trading_hub/使用说明.md index 3c72353..f0aa78a 100644 --- a/manual_trading_hub/使用说明.md +++ b/manual_trading_hub/使用说明.md @@ -1,522 +1,518 @@ -# 多账户交易中控 — 使用说明 - -本文档说明 **manual_trading_hub** 的架构、启动方式、界面操作与故障排查。中控聚合四所 **持仓/条件单/余额/关键位/趋势计划监控 + 撤单/紧急全平**,并提供 **资金概况**、**行情区 K 线** 与 **内照明心(复盘语录 + 永久 K 线)**;**人工下单、关键位、策略交易(趋势回调 / 顺势加仓)、交易复盘** 均在各实例网页操作(点监控卡片 **「实例」**)。资金概况见 **[资金概况说明.md](./资金概况说明.md)**;行情区细则见 **[行情区说明.md](./行情区说明.md)**;内照明心见 **[docs/hub-symbol-archive-kline.md](../docs/hub-symbol-archive-kline.md)**。 - ---- - -## 1. 架构总览 - -``` -浏览器 - ├─ /funds 资金概况 - ├─ /plan 开仓计划(计划录入 / 进行中 / 历史胜率) - ├─ /monitor 监控区(持仓、关键位、趋势计划、全平) - ├─ /market 行情区(K 线、技术指标、持仓价格线) - ├─ /archive 内照明心(复盘语录 + 交易记录 + 永久 5m K 线) - ├─ /funds 资金概况(总资金曲线、分户资金与回撤) - ├─ /dashboard 数据看板(四户当日总览,SSE 推送;见 [数据看板说明.md](./数据看板说明.md)) - ├─ /ai AI 教练(交易教练 / 普通聊天;见 [AI教练说明.md](./AI教练说明.md)) - └─ /settings 系统设置(hub_settings.json) - -中控 hub.py(默认 :5100) - ├─ HTTP → 子代理 agent.py × N(/status、/emergency/close-all) - └─ HTTP → 各实例 Flask(/api/hub/monitor、/api/price_snapshot 等只读聚合) -``` - -| 组件 | 职责 | 默认端口(可在设置页改) | -|------|------|-------------------------| -| **hub.py** | 聚合 UI、监控 API、全平 | `5100` | -| **agent.py** | 交易所只读状态、挂单/条件单查询与撤销 + 紧急市价全平 | 币安 `15200`、OKX `15201`、Gate `15202`、Gate趋势 `15203` | -| **crypto_monitor_*.app** | 策略库、关键位、人工单、趋势预览/执行 | 币安 `5001`、Gate `5000`、Gate趋势 `5002`、OKX `5004` | - -### 1.1 四账户默认配置 - -| id | 名称 | Flask | Agent | 监控能力(设置页勾选) | 默认启用 | -|----|------|-------|-------|------------------------|----------| -| 0 | 币安 | :5001 | :15200 | 关键位 | 是 | -| 1 | OKX | :5004 | :15201 | 关键位 + 趋势计划(建议) | **否**(`HUB_DISABLED_IDS=1`,需用时在设置页启用) | -| 2 | Gate 训练 | :5000 | :15202 | 关键位 | 是 | -| 3 | Gate 趋势 | :5002 | :15203 | 趋势计划(默认不勾关键位) | 是 | - -- **Gate 趋势户**:默认只勾 **监控趋势计划**;一般不勾关键位(该户多用于趋势回调)。策略操作在实例 **`/strategy`**。 -- **币安 / Gate 训练 / OKX**:四所均已支持 **策略交易**;中控可同时勾 **监控关键位** + **监控趋势计划**(见 §4.2、§5)。 -- **OKX**:默认关闭;需要时在「系统设置」勾选启用,并去掉环境变量 `HUB_DISABLED_IDS` 中的 `1`。 - -### 1.2 实例侧改动(最小) - -各 `crypto_monitor_*` 仅增加: - -1. `login_required` 走 `hub_auth.request_allowed`(支持请求头 `X-Hub-Token`)。 -2. 文件末尾 `hub_bridge.install_on_app(...)` 注册 `/api/hub/*`。 - -业务逻辑、数据库、复盘页面 **未改**;复盘请打开各实例 `/records`(设置里的「复盘链接」)。 - ---- - -## 2. 环境准备 - -### 2.1 依赖安装 - -```bash -cd /opt/crypto_monitor/manual_trading_hub -python3 -m venv .venv -source .venv/bin/activate -pip install -r requirements.txt -``` - -### 2.2 鉴权令牌(推荐生产启用) - -四实例 Flask 与中控、子代理需 **同一密钥**: - -| 变量 | 作用 | -|------|------| -| `HUB_BRIDGE_TOKEN` | 中控 → Flask 使用头 `X-Hub-Token`;各实例 `hub_auth` 校验 | -| `CONTROL_TOKEN` | 可与上相同;中控 → 子代理使用头 `X-Control-Token` | - -中控 `hub.py` 会读取 `HUB_BRIDGE_TOKEN`,若无则回退 `CONTROL_TOKEN`。 - -**开发本机**可临时在各实例 `.env` 设 `APP_AUTH_DISABLED=true`,则 Flask 不校验令牌(仍建议子代理设 `CONTROL_TOKEN` 防误暴露)。 - -### 2.3 强制关闭某账户 - -```bash -# 在 manual_trading_hub/.env 中设置,或临时: -export HUB_DISABLED_IDS=1 # 默认即关闭 OKX(id=1) -``` - -与设置页「启用」取 **与** 关系:环境变量强制关闭时,网页勾选框会灰掉且无法启用。 - -### 2.4 Web 登录(反代公网强烈建议) - -在 `manual_trading_hub/.env` 中配置: - -| 变量 | 说明 | -|------|------| -| `HUB_USERNAME` | 登录用户名;未设且已设密码时默认为 `admin` | -| `HUB_PASSWORD` | **非空即启用登录**;所有页面与 API(除登录页、`/api/ping`、`/assets`)须先登录 | -| `HUB_SESSION_SECRET` | 会话签名密钥(建议单独随机串) | -| `HUB_COOKIE_SECURE` | 建议 `true`:仅 **HTTPS** 访问时 Cookie 带 Secure;**HTTP 内网 IP:5100 仍可登录** | -| `HUB_SESSION_DAYS` | 登录保持天数,默认 `7` | - -- 登录页:`http://<中控地址>:5100/login` -- 顶栏 **退出** 清除会话。 -- **域名(HTTPS)** 与 **内网 IP(HTTP)** Cookie 不共用,需分别登录一次。 - -更多登录/Cookie 问题见 **[常见问题.md](./常见问题.md)** 第二节。 - -### 2.5 配置文件 - -- 路径:`manual_trading_hub/hub_settings.json`(在网页 **系统设置 → 保存设置** 后写入)。 -- 未保存前使用 `settings_store.py` 内置默认四所地址。 -- 建议 **不要** 把含内网 IP 的 `hub_settings.json` 提交到公开仓库。 -- 环境变量模板:`manual_trading_hub/.env.example`;四实例模板中已补充 `HUB_BRIDGE_TOKEN` 说明。 - ---- - -## 3. 启动顺序(Ubuntu + PM2) - -**原则**:代码在 **`/opt/crypto_monitor`**,先四实例 Flask,再中控(一条 PM2 含 4 agent + hub)。环境见 **[docs/ubuntu-server.md](../docs/ubuntu-server.md)**。 - -```bash -# 四所 Flask(示例:币安;其余三所同理) -cd /opt/crypto_monitor/crypto_monitor_binance -pm2 start ecosystem.config.cjs - -# 中控 + 子代理 -cd /opt/crypto_monitor/manual_trading_hub -pm2 start ecosystem.config.cjs -pm2 save -``` - -浏览器(本机或反代): - -- 监控区:`http://127.0.0.1:5100/monitor` -- 行情区:`http://127.0.0.1:5100/market` -- 内照明心:`http://127.0.0.1:5100/archive` -- 资金概况:`http://127.0.0.1:5100/funds` -- 系统设置:`http://127.0.0.1:5100/settings` - -验收: - -```bash -bash /opt/crypto_monitor/manual_trading_hub/scripts/verify_hub_deploy.sh -curl -s http://127.0.0.1:5100/api/ping -``` - ---- - -## 4. 页面操作说明 - -Chrome **桌面快捷方式**图标来自站点 `favicon` / `manifest`(已配置统一品牌图),说明见 **[docs/shortcut-icon.md](../docs/shortcut-icon.md)**。 - -### 4.1 监控区 `/monitor` - -| 功能 | 说明 | -|------|------| -| **服务器状态** | 标题下方可折叠条(**默认收起**),摘要行显示 CPU/内存/硬盘;展开见四指标卡片(`GET /api/host/status`,每 5 秒刷新)。**CPU 或内存 ≥85%** 时浏览器弹窗告警(降至 85% 以下后再次超标会再提示)。依赖 `manual_trading_hub/.venv` 内 **psutil**(勿用系统 `pip`,见 [部署文档.md](./部署文档.md))。可选 `HUB_HOST_DISK_PATH` 指定监控磁盘 | -| **2×2 主界面** | 四所信息**完整展示**:余额、持仓表、委托/平仓、折叠委托单、下单监控、关键位、趋势/加仓摘要 | -| **全屏放大** | **点击卡片标题栏**(非按钮区)→ 该所**全屏**:每币种一张实盘风格持仓卡(趋势持仓显示**来源: 趋势回调计划**、**风险%**、**程序监控·止盈价**、**盈亏比**,与实例策略页一致);独立卡片:**关键位**、**下单监控**、**趋势回调**(单计划 **两列**:左=币种基本信息与 3×2 指标,右=**补仓计划明细**,底=**保本偏移%** 可编辑 + **保本移交** / **结束计划**(中控直接调实例,与 `/strategy` 一致)、快照可用/计划保证金/杠杆)、**顺势加仓** | -| **委托单折叠** | 仅「委托单」区块默认折叠;展开状态存浏览器本地,**5 秒刷新不重置** | -| **条件单 / 委托** | 每个持仓下方展示交易所 **条件单**(默认折叠)与 **普通委托**;数据来自子代理实时拉取(币安含 Algo 通道) | -| **撤单** | 条件单区内单笔「撤单」或「撤销全部」;经中控 `POST /api/orders/{id}/cancel`、`cancel-symbol` | -| **挂止盈止损** | 持仓行 **「委托」**:弹窗填止损/止盈价 → **先撤该合约全部条件单,再挂新 TP/SL**(币安 / OKX / Gate / Gate趋势 四所统一,逻辑与各实例 `.env` 参数一致) | -| **平仓** | 持仓行「平仓」:仅平该方向仓位(子代理市价减仓) | -| **机器人单** | 来自实例 `/api/hub/monitor` 的 `order_monitors`(active),为本地监控计划,**不等于**交易所条件单 | -| **关键位** | 仅 `capabilities` 含 `key` 的户;展示门控摘要(`/api/price_snapshot`) | -| **趋势计划** | 仅当该户勾选 **监控趋势计划** 时展示 `trend_pullback_plans`(active) | -| **实例 / 复盘** | 「实例」「策略交易」「复盘」经中控签发 **SSO 链接**(默认 2h、单次)打开,**免输**实例 `APP_USERNAME/PASSWORD`;直链实例 IP/域名仍走 `/login`。**云服务器**见 **[云服务器部署说明.md](./云服务器部署说明.md)**;局域网/反代见 **[局域网与反代部署说明.md](./局域网与反代部署说明.md)** | -| **关键位列表** | 来自 `/api/hub/monitor` + `/api/price_snapshot`;Flask 未连通时卡片提示原因;**Gate 趋势户**无关键位块 | -| **该户全平** | `POST` 子代理 `/emergency/close-all`,仅平该 API Key 仓位 | -| **全局紧急全平** | 对所有已启用户依次全平(不含 `HUB_DISABLED_IDS` 强制关闭的 id) | -| **自动刷新** | 默认每 5 秒请求 `/api/monitor/board` | - -持仓数据以 **子代理 ccxt** 为准;关键位/趋势/机器人单以 **Flask 数据库** 为准。若 Flask 未启动,卡片仍会显示 agent 持仓,但下方策略信息可能为空或报错。 - -### 4.2 行情区 `/market` - -| 功能 | 说明 | -|------|------| -| **K 线** | 选择已启用交易所 + 币种 + 周期;按需拉取,本地 `data/hub_kline.db` 缓存(默认保留 15 天) | -| **周期** | `1m` `5m` `15m` `1h` `2h` `4h` `12h` `1d` `1w` | -| **加载 / 强制刷新** | 普通加载优先缓存;强制刷新重拉并覆盖缓存 | -| **从监控跳转** | 点击持仓合约名带入品种,并显示入场/止损/止盈/委托与 K 线价格线 | -| **技术指标** | 可选 EMA 21/55、MACD、RSI | -| **快捷键** | **`F`** 全屏/退出;全屏时 **`Esc`** 退出;数字键切换周期(见 [行情区说明.md](./行情区说明.md)) | -| **自动刷新** | 约 5 秒更新最新 OHLCV | - -数据经中控 → 各实例 `GET /api/hub/ohlcv`(`hub_ohlcv_lib`)。升级 hub 与四实例 Flask 后请 **强刷浏览器**;异常 K 线可点 **强制刷新**。 - -### 4.2.1 内照明心 `/archive` - -| 功能 | 说明 | -|------|------| -| **复盘语录** | 左栏按日添加/编辑;最多 100 条 | -| **日期** | **本日 / 本周 / 本月 / 自选区间**(交易日 8:00 切日) | -| **区间统计** | 总开仓、犯病次数与占比、盈亏、剔除犯病盈亏、各交易所分项 | -| **筛选** | 盈利单、亏损单、犯病(仅过滤表格;统计栏不受此三项影响) | -| **交易记录** | 区间内开仓列表;犯病行红色字体;可编辑备注与犯病标签 | -| **K 线** | 默认折叠按需加载;独立库 `data/hub_symbol_archive.db`;仅存 **5m** 真源,**15m/1h/4h** 聚合 | -| **建档** | 最早开仓向前 **30 天** 5m 种子;之后每 **4h** 增量(Hub 后台 + 可点「同步」) | -| **视窗** | **持仓过程**(锚平仓)/ **进场决策**(锚开仓);支持时间输入跳转 | - -与行情区 `hub_kline.db`(15 天滚动)**分离**,建档起 **只增不删**。细则见 **[docs/hub-symbol-archive-kline.md](../docs/hub-symbol-archive-kline.md)**。 - -### 4.2.2 资金概况 `/funds` - -| 功能 | 说明 | -|------|------| -| **总资金** | 已监控账户的 **资金户 + 交易户** 合计(不含浮盈) | -| **总曲线** | 自 **2026-06-09** 起、按北京时间交易日(默认 8:00 切日)每日一点,最多 **180** 天 | -| **最大回撤** | 基于总资金余额曲线(非平仓盈亏回撤) | -| **分户** | 每户资金/交易拆分、迷你曲线、分户回撤;**未监控** 不参与合计 | -| **快照** | 监控板聚合成功时写入 `hub_fund_history.json` | - -细则见 **[资金概况说明.md](./资金概况说明.md)**。 - -### 4.2.3 数据看板 `/dashboard` - -| 功能 | 说明 | -|------|------| -| **总览** | 交易日、平仓盈亏、笔数、浮盈亏、资金合计、持仓数 | -| **分户** | 四户资金/交易账户、今日盈亏、浮盈亏;单日亏损 ≥ 资金合计 **5%** 高亮预警 | -| **平仓明细** | 当日平仓流水表 | -| **刷新** | 后台每 60s 聚合 + **SSE** 推送版本号;页面无整页轮询闪烁 | -| **主题** | 跟随顶栏亮/暗主题,卡片柔光样式(非霓虹背景) | - -细则见 **[数据看板说明.md](./数据看板说明.md)**。 - -### 4.3 AI 教练 `/ai` - -| 功能 | 说明 | -|------|------| -| **交易教练** | 口语化陪聊;后台注入四户监控快照(不在页面展示今日总结) | -| **普通聊天** | 不绑交易数据 | -| **会话** | 多会话历史(切换/删除)、消息复制;点 **「新开对话」** 清空当前上下文 | -| **模型** | 与四实例相同 `.env`(默认 `AI_PROVIDER=openai` + `OPENAI_*`;改 `ollama` 走本机),见 [AI教练说明.md](./AI教练说明.md) | -| **与实例复盘** | 深度单笔 journal 复盘仍在各所 `/records`;中控不做重复 | - -依赖四实例 `GET /api/hub/trades/today`(`hub_bridge`);升级代码后需 **重启四所 Flask**。 - -### 4.4 系统设置 `/settings` - -**可用**:打开 http://127.0.0.1:5100/settings ,修改表格后点 **保存设置** 即写入 `hub_settings.json`;**重新加载** 从磁盘/默认再读(会重新套用 `HUB_DISABLED_IDS`)。保存后监控区立即使用新 URL/启用状态,**无需重启 hub**。 - -**显示与导航**(`hub_settings.json` → `display`): - -| 开关 | 说明 | -|------|------| -| 监控区资金/浮盈 | 关闭后监控卡片不显示资金户、交易户、浮盈亏列 | -| 顶栏「资金概况」 | 关闭后隐藏导航;直接访问 `/funds` 会跳回监控区 | -| 顶栏「数据看板」 | 关闭后隐藏导航;直接访问 `/dashboard` 会跳回监控区 | - -**下单、关键位、策略交易**:请在监控卡片点击 **「实例」** 或 **「策略交易」**(SSO),进入各 `crypto_monitor_*` 网页(`/trade`、`/key_monitor`、`/strategy`、`/strategy/records` 等)。中控 **不** 提供下单区;**策略交易记录** 仅在实例顶栏查看(见 [策略交易说明.md](../策略交易说明.md) §五)。 - -| 列 | 含义 | -|----|------| -| 启用 | 是否参与监控与全局全平;被 `HUB_DISABLED_IDS` 锁定的无法勾选 | -| 显示名 | 监控卡片标题 | -| Flask URL | 实例根地址,如 `http://127.0.0.1:5001` | -| Agent URL | 子代理根地址,如 `http://127.0.0.1:15200` | -| 复盘链接 | 一般为 `{Flask}/records` | -| **监控关键位** | 勾选后卡片展示 **关键位** 列表 + 门控价(读 Flask `/api/price_snapshot`) | -| **监控趋势计划** | 勾选后卡片展示 **趋势回调** 运行中计划(`trend_pullback_plans` active) | -| id | 与 `HUB_DISABLED_IDS`、全平 API 路径中的 id 对应;新增户勿与已有 id 重复 | - -- **保存设置**:写入 `hub_settings.json`,重启 hub 后仍生效。 -- **添加交易所**:见下文 §4.5(须先自建 Flask + agent,再在中控登记)。 -- **删**:从列表移除(保存后生效)。 - -#### 能力与「策略交易」的关系(重要) - -| 能力勾选 | 中控监控区 | 策略交易(趋势回调 / 顺势加仓) | -|----------|------------|----------------------------------| -| 监控关键位 | 显示关键位块 | **不控制**;在实例页 `/key_monitor` | -| 监控趋势计划 | 显示趋势计划块 | **不控制**;在实例页 `/strategy` 左栏操作 | -| 均未勾选 | 仅持仓、余额、机器人单 | 仍可在实例网页使用策略交易 | - -四所 Flask 均已注册 `hub_bridge` 且 **`has_trend=true`**,勾选「监控趋势计划」后才会从 `/api/hub/monitor` 拉取趋势数据。修改勾选后 **保存即可**,须 **重启对应 Flask** 仅在你刚升级了 `hub_bridge` 相关代码时。 - ---- - -### 4.5 增加账户(例如再挂一个 Gate) - -中控 **不会** 自动启动进程,也 **不** 保存交易所 API Key。新增一户 = **复制/新建一套实例目录 + 独立 `.env` + 新端口 Flask/agent + 在中控登记一行**。 - -#### 4.5.1 端口勿冲突(示例) - -| 用途 | 目录(示例) | Flask `APP_PORT` | Agent `PORT` | -|------|----------------|------------------|--------------| -| Gate 训练(已有) | `crypto_monitor_gate` | 5000 | 15202 | -| Gate 趋势(已有) | `crypto_monitor_gate_bot` | 5002 | 15203 | -| **新增 Gate 子账户** | 复制为 `crypto_monitor_gate_2` 等 | **5005**(自定) | **15204**(自定) | - -`agent` 的 `PORT` 与 Flask 的 `APP_PORT` **必须不同**;且不要与币安 5001、OKX 5004、中控 5100 等占用端口相同。 - -#### 4.5.2 新建实例目录 - -1. 复制整个 `crypto_monitor_gate` 到新目录(仓库内副本或 `/opt/` 下均可)。 -2. 在新目录:`cp .env.example .env`,至少修改: - - `APP_PORT` → 新 Flask 端口(如 5005) - - `DB_PATH` → 独立库(如 `crypto_gate2.db`),**勿**与 5000/5002 共用 `crypto.db` - - `GATE_API_KEY` / `GATE_API_SECRET` → **该子账户** 密钥 - - `HUB_BRIDGE_TOKEN` → 与中控、其它实例 **相同** -3. 安装 venv 与依赖(`bash /opt/crypto_monitor/deploy/setup_env.sh --only gate` 或按 Gate 部署文档),启动: - -```bash -cd /opt/crypto_monitor/crypto_monitor_gate_2 -pm2 start ecosystem.config.cjs -``` - -4. 在中控 `ecosystem.config.cjs` 增加对应 agent,或单独 `run_agent.sh` 配置后 `pm2 restart`(勿与已有 agent 端口冲突)。 - -验收:`curl http://127.0.0.1:5005/login` 能开页;`curl http://127.0.0.1:15204/status` 返回 `ok`。 - -#### 4.5.3 在中控登记 - -1. 打开 **系统设置** → **添加交易所**(或手改 `manual_trading_hub/hub_settings.json`)。 -2. 填写 **Flask URL**、**Agent URL**、**id**(如 `4`)、**显示名**。 -3. 能力建议: - - 训练/关键位户:**监控关键位** + **监控趋势计划**(若也要在中控看趋势计划); - - 纯趋势户:只勾 **监控趋势计划**。 -4. 勾选 **启用** → **保存设置**。 -5. 在 **监控区** 应出现新卡片;点 **实例** 进入该户网页做下单与 **策略交易**。 - -PM2:仓库 `ecosystem.config.cjs` 默认只有四 agent;第五户需自行 `pm2 start` 或手工终端,与是否改 hub 源码无关。 - ---- - -## 5. 能力矩阵(监控展示,建议勾选) - -| 账户 | 监控关键位 | 监控趋势计划 | 策略交易(实例页) | -|------|:----------:|:--------------:|:------------------:| -| 币安 | ✓ 建议 | ✓ 建议 | `/strategy` | -| OKX | ✓ 建议 | ✓ 建议 | `/strategy` | -| Gate 训练 | ✓ 建议 | ✓ 建议 | `/strategy` | -| Gate 趋势 | —(通常不勾) | ✓ | `/strategy` | - -「建议」表示中控卡片展示对应块;**不勾** 仍可在该实例网页使用关键位或策略交易。 - ---- - -## 6. HTTP API 摘要(中控) - -访问控制: - -- **IP**:默认允许本机与 RFC1918 私网(`HUB_TRUST_LAN=true`);公网 IP 直连返回 403。 -- **登录**:设置 `HUB_PASSWORD` 后须用户名+密码登录(`HUB_USERNAME`,未设时默认 `admin`);反代到公网时**务必设置**。 - -| 方法 | 路径 | 说明 | -|------|------|------| -| GET | `/api/settings` | 读取配置 | -| POST | `/api/settings` | 保存配置 | -| GET | `/api/monitor/board` | 监控聚合 | -| POST | `/api/close/{id}` | 单户全平 | -| POST | `/api/close-all` | 全局全平,body 可选 `exclude_ids` | -| GET | `/api/auth/status` | 是否需登录、是否已登录 | -| POST | `/api/auth/login` | body `{"username":"...","password":"..."}` | -| POST | `/api/auth/logout` | 退出 | -| GET | `/api/ping` | 版本与健康检查(**免登录**) | -| GET | `/api/chart/meta` | 行情区:交易所、周期、limit | -| GET | `/api/chart/ohlcv` | 行情区 K 线(`exchange_key`、`symbol`、`timeframe`、可选 `refresh=1`) | -| GET | `/api/hub/fund-overview` | 资金概况:总/分户资金、180 日曲线、回撤 | -| GET | `/api/archive/meta` | 内照明心:周期、同步间隔 | -| GET | `/api/archive/daily-trades` | 内照明心:区间交易与统计(`period` / `date_from` / `date_to`) | -| GET | `/api/archive/quotes` | 内照明心:复盘语录 | -| GET | `/api/archive/list` | 币种列表(筛选 query) | -| GET | `/api/archive/detail` | 单币种交易时间线 | -| GET | `/api/archive/ohlcv` | 档案 K 线视窗 | -| PATCH | `/api/archive/trade/{exchange_key}/{trade_id}` | 犯病/情绪标签与备注 | -| POST | `/api/archive/sync` | 立即同步四所交易与 K 线 | - -已移除的 `/api/trade/*` 若被旧缓存页面请求,返回 **410** 并提示前往各实例网页。 - -实例侧(中控只读;下单/关键位/趋势在实例网页): - -| 路径 | 说明 | -|------|------| -| `/api/hub/ping` | 连通与能力 | -| `/api/hub/monitor` | 关键位、机器人单、趋势计划 | -| `/api/hub/ohlcv` | 行情区 OHLCV(ccxt 拉取,供中控聚合缓存) | -| `/api/hub/trades/archive` | 内照明心:近 N 天已平仓(`days` / `limit`) | - ---- - -## 7. 环境变量速查 - -### 中控 hub.py - -| 变量 | 默认 | 说明 | -|------|------|------| -| `HUB_HOST` | `0.0.0.0` | 监听地址 | -| `HUB_PORT` | `5100` | 监听端口 | -| `HUB_BRIDGE_TOKEN` | 空 | Flask 桥接令牌;可同 `CONTROL_TOKEN` | -| `HUB_DISABLED_IDS` | `1` | 逗号分隔,强制关闭的账户 id | -| `HUB_TRUST_LAN` | `true` | `false` 时仅本机可访问中控页面 | -| `HUB_USERNAME` | `admin` | 登录用户名(仅当已设密码时生效) | -| `HUB_PASSWORD` | (空) | 非空即启用 Web 登录 | -| `HUB_SESSION_SECRET` | 用户名+密码 | 会话 Cookie 签名密钥 | -| `HUB_COOKIE_SECURE` | `false` | HTTPS 反代建议 `true`(仅 HTTPS 发 Secure Cookie,HTTP 内网 IP 仍可登) | -| `HUB_SESSION_DAYS` | `7` | 登录保持天数 | -| `HUB_KLINE_RETENTION_DAYS` | `15` | 行情区 K 线库保留天数 | -| `HUB_KLINE_DB_PATH` | `data/hub_kline.db` | K 线 SQLite 路径 | -| `HUB_ARCHIVE_DB_PATH` | `data/hub_symbol_archive.db` | 内照明心永久 K 线库 | -| `HUB_ARCHIVE_SYNC_INTERVAL_SEC` | `14400` | 档案 K 线后台同步间隔(秒) | -| `HUB_ARCHIVE_TRADE_DAYS` | `365` | 同步交易记录回看天数 | -| `HUB_ARCHIVE_TRADE_LIMIT` | `2000` | 单所同步交易条数上限 | - -### 子代理 agent.py - -| 变量 | 说明 | -|------|------| -| `EXCHANGE` | `binance` / `okx` / `gate` | -| `PORT` / `HOST` | 监听 | -| `CONTROL_TOKEN` | 与中控一致时必填头 `X-Control-Token` | - -### 各实例 Flask - -| 变量 | 说明 | -|------|------| -| `HUB_BRIDGE_TOKEN` | 与中控一致 | -| `APP_AUTH_DISABLED` | `true` 时跳过登录与令牌(仅建议本机调试) | - ---- - -## 8. 安全与边界 - -1. **中控不下单**:开仓、关键位、趋势回调仅在各实例网页操作。 -2. **全平为市价减仓**:监控区全平不可撤销,操作前二次确认。 -3. **子代理建议只监听 127.0.0.1**,不要对局域网暴露 API Key 通道。 -4. **公网暴露 hub**:必须设置 `HUB_USERNAME` + `HUB_PASSWORD`;HTTPS 反代建议 `HUB_COOKIE_SECURE=true`;亦可 `HUB_HOST=127.0.0.1` 仅本机监听 + 反代。 -5. **复盘不在中控**:时间筛选、导出 CSV、编辑笔记仍在各实例 `/records`。 -6. **OKX 默认关**:避免未部署 OKX 时监控卡片持续报错。 - ---- - -## 9. 故障排查(速查) - -完整实录(含 `api_trade_key`、`multipart`、git 版本、PM2 等)见 **[常见问题.md](./常见问题.md)**。 - -| 现象 | 可能原因 | 处理 | -|------|----------|------| -| 监控卡片「子代理不可用」 | agent 未启动或端口错 | 检查 Agent URL;`pm2 restart` agent | -| 无关键位/趋势信息 | Flask 未起或 hub_bridge 未加载 | 启动 `crypto_*`;`curl .../api/hub/ping` | -| 全平 401 | `CONTROL_TOKEN` 与中控不一致 | 与 `HUB_BRIDGE_TOKEN` 对齐 | -| OKX 始终灰色 | `HUB_DISABLED_IDS=1` | 改掉环境变量并在设置页启用 | -| 打开即跳转登录 | 已设 `HUB_PASSWORD` | 正常;访问 `/login` | -| 域名能登、IP:5100 不能 | Secure Cookie + HTTP | 见常见问题 §2.1;或分别登录 | -| 添加关键位报错 / SyntaxError | 旧前端或旧 hub 代码 | 强刷浏览器;`git pull` + `verify_hub_deploy.sh` | -| `curl /api/ping` 非 JSON | hub 未启动 | `pm2 restart manual-trading-hub` | -| K 线只有约 300 根 | 旧版未分页 | `git pull` 四实例 + hub,强制刷新 | -| 12h 周期异常 | 无原生 12h 或旧缓存 | 强制刷新;见 [行情区说明.md](./行情区说明.md) | - -**运维脚本**(在 `manual_trading_hub` 目录执行): - -| 脚本 | 作用 | -|------|------| -| `scripts/fix_hub_deps.sh` | 安装依赖(含 `python-multipart`) | -| `scripts/verify_hub_deploy.sh` | 检查代码版本与 ping | -| `scripts/fix_env_crlf.sh` | 修复 `.env` 的 CRLF 导致 agent 起不来 | - -手动探测实例桥接: - -```bash -curl -sS -H "X-Hub-Token: 你的令牌" http://127.0.0.1:5001/api/hub/ping -``` - ---- - -## 10. 与旧版 README 的差异 - -早期中控 **仅监控 + 全平**,使用环境变量 `HUB_AGENTS` 列表。当前版本改为: - -- **hub_settings.json**(或内置默认)管理四所 URL 与能力; -- **三页 UI**:监控 / 行情 / 设置; -- 通过 **hub_bridge** 只读聚合监控数据。 - -子代理 `agent.py` 仍负责持仓与全平;`HUB_AGENTS` 环境变量在新版 hub 中 **不再使用**(以设置文件为准)。 - -**PM2 守护**: - -```bash -cd /opt/crypto_monitor/manual_trading_hub -python3 -m venv .venv -source .venv/bin/activate -pip install -r requirements.txt -cp .env.example .env -pm2 start ecosystem.config.cjs # 一次启动 4 个 agent + manual-trading-hub -pm2 save && pm2 startup -``` - -快捷:`bash scripts/pm2_hub.sh start|restart|logs`(同样 hub+agent 一起)。 - -更细的安装顺序、反代、验收见 **《部署文档.md》**;PM2 见 **[scripts/后台运行-Ubuntu.md](./scripts/后台运行-Ubuntu.md)**。 - ---- - -## 11. 日常推荐流程 - -1. 启动四所 **agent** + **Flask**(OKX 按需)。 -2. 启动 **hub.py**,打开监控区确认持仓与关键位门控正常。 -3. 看 K 线 → **行情区** 或监控区点击合约名跳转;异常图表点 **强制刷新**。 -4. 开仓、关键位、趋势 → 点击监控卡片「实例」进入对应 Flask。 -5. 复盘、导出记录 → 点击「复盘」进入 `/records`。 -6. 异常行情 → 单户全平或全局紧急全平。 - -增加账户步骤见 **§4.4**;无需改 `hub.py` 源码,但须该户 Flask 已 `git pull` 并 **重启**(`hub_bridge` + `has_trend` + `ohlcv`),且 agent 已部署。 - ---- - -## 12. 文档索引 - -| 文档 | 内容 | -|------|------| -| [使用说明.md](./使用说明.md) | 本文 | -| [行情区说明.md](./行情区说明.md) | K 线周期、缓存、快捷键、API | -| [开仓计划说明.md](./开仓计划说明.md) | 计划录入、归档、胜率统计 | -| [docs/hub-symbol-archive-kline.md](../docs/hub-symbol-archive-kline.md) | 内照明心、区间统计、永久 5m、建档与同步 | -| [部署文档.md](./部署文档.md) | Ubuntu / PM2 / 反代 | -| [常见问题.md](./常见问题.md) | 故障实录与排障 | -| [README.md](./README.md) | 速览 | -| [.env.example](./.env.example) | 环境变量模板 | -| [scripts/后台运行-Ubuntu.md](./scripts/后台运行-Ubuntu.md) | PM2 常驻 | -| [docs/ubuntu-server.md](../docs/ubuntu-server.md) | Ubuntu 环境总览 | +# 多账户交易中控 — 使用说明 + +本文档说明 **manual_trading_hub** 的架构、启动方式、界面操作与故障排查。中控聚合三所 **持仓/条件单/余额/关键位/趋势计划监控 + 撤单/紧急全平**,并提供 **资金概况**、**行情区 K 线** 与 **内照明心(复盘语录 + 永久 K 线)**;**人工下单、关键位、策略交易(趋势回调 / 顺势加仓)、交易复盘** 均在各实例网页操作(点监控卡片 **「实例」**)。资金概况见 **[资金概况说明.md](./资金概况说明.md)**;行情区细则见 **[行情区说明.md](./行情区说明.md)**;内照明心见 **[docs/hub-symbol-archive-kline.md](../docs/hub-symbol-archive-kline.md)**。 + +--- + +## 1. 架构总览 + +``` +浏览器 + ├─ /funds 资金概况 + ├─ /plan 开仓计划(计划录入 / 进行中 / 历史胜率) + ├─ /monitor 监控区(持仓、关键位、趋势计划、全平) + ├─ /market 行情区(K 线、技术指标、持仓价格线) + ├─ /archive 内照明心(复盘语录 + 交易记录 + 永久 5m K 线) + ├─ /funds 资金概况(总资金曲线、分户资金与回撤) + ├─ /dashboard 数据看板(三户当日总览,SSE 推送;见 [数据看板说明.md](./数据看板说明.md)) + ├─ /ai AI 教练(交易教练 / 普通聊天;见 [AI教练说明.md](./AI教练说明.md)) + └─ /settings 系统设置(hub_settings.json) + +中控 hub.py(默认 :5100) + ├─ HTTP → 子代理 agent.py × N(/status、/emergency/close-all) + └─ HTTP → 各实例 Flask(/api/hub/monitor、/api/price_snapshot 等只读聚合) +``` + +| 组件 | 职责 | 默认端口(可在设置页改) | +|------|------|-------------------------| +| **hub.py** | 聚合 UI、监控 API、全平 | `5100` | +| **agent.py** | 交易所只读状态、挂单/条件单查询与撤销 + 紧急市价全平 | 币安 `15200`、OKX `15201`、Gate `15202` | +| **crypto_monitor_*.app** | 策略库、关键位、人工单、趋势预览/执行 | 币安 `5001`、Gate `5000`、OKX `5004` | + +### 1.1 三账户默认配置 + +| id | 名称 | Flask | Agent | 监控能力(设置页勾选) | 默认启用 | +|----|------|-------|-------|------------------------|----------| +| 0 | 币安 | :5001 | :15200 | 关键位 + 趋势 | 是 | +| 1 | OKX | :5004 | :15201 | 关键位 + 趋势 | 是 | +| 2 | Gate | :5000 | :15202 | 关键位 + 趋势 | 是 | + +- **三所均已支持** 关键位、策略交易(趋势回调 + 顺势加仓);中控可同时勾 **监控关键位** + **监控趋势计划**(见 §4.2、§5)。 + +### 1.2 实例侧改动(最小) + +各 `crypto_monitor_*` 仅增加: + +1. `login_required` 走 `hub_auth.request_allowed`(支持请求头 `X-Hub-Token`)。 +2. 文件末尾 `hub_bridge.install_on_app(...)` 注册 `/api/hub/*`。 + +业务逻辑、数据库、复盘页面 **未改**;复盘请打开各实例 `/records`(设置里的「复盘链接」)。 + +--- + +## 2. 环境准备 + +### 2.1 依赖安装 + +```bash +cd /opt/crypto_monitor/manual_trading_hub +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +``` + +### 2.2 鉴权令牌(推荐生产启用) + +三实例 Flask 与中控、子代理需 **同一密钥**: + +| 变量 | 作用 | +|------|------| +| `HUB_BRIDGE_TOKEN` | 中控 → Flask 使用头 `X-Hub-Token`;各实例 `hub_auth` 校验 | +| `CONTROL_TOKEN` | 可与上相同;中控 → 子代理使用头 `X-Control-Token` | + +中控 `hub.py` 会读取 `HUB_BRIDGE_TOKEN`,若无则回退 `CONTROL_TOKEN`。 + +**开发本机**可临时在各实例 `.env` 设 `APP_AUTH_DISABLED=true`,则 Flask 不校验令牌(仍建议子代理设 `CONTROL_TOKEN` 防误暴露)。 + +### 2.3 强制关闭某账户 + +```bash +# 在 manual_trading_hub/.env 中设置,或临时: +export HUB_DISABLED_IDS=1 # 默认即关闭 OKX(id=1) +``` + +与设置页「启用」取 **与** 关系:环境变量强制关闭时,网页勾选框会灰掉且无法启用。 + +### 2.4 Web 登录(反代公网强烈建议) + +在 `manual_trading_hub/.env` 中配置: + +| 变量 | 说明 | +|------|------| +| `HUB_USERNAME` | 登录用户名;未设且已设密码时默认为 `admin` | +| `HUB_PASSWORD` | **非空即启用登录**;所有页面与 API(除登录页、`/api/ping`、`/assets`)须先登录 | +| `HUB_SESSION_SECRET` | 会话签名密钥(建议单独随机串) | +| `HUB_COOKIE_SECURE` | 建议 `true`:仅 **HTTPS** 访问时 Cookie 带 Secure;**HTTP 内网 IP:5100 仍可登录** | +| `HUB_SESSION_DAYS` | 登录保持天数,默认 `7` | + +- 登录页:`http://<中控地址>:5100/login` +- 顶栏 **退出** 清除会话。 +- **域名(HTTPS)** 与 **内网 IP(HTTP)** Cookie 不共用,需分别登录一次。 + +更多登录/Cookie 问题见 **[常见问题.md](./常见问题.md)** 第二节。 + +### 2.5 配置文件 + +- 路径:`manual_trading_hub/hub_settings.json`(在网页 **系统设置 → 保存设置** 后写入)。 +- 未保存前使用 `settings_store.py` 内置默认三所地址。 +- 建议 **不要** 把含内网 IP 的 `hub_settings.json` 提交到公开仓库。 +- 环境变量模板:`manual_trading_hub/.env.example`;三实例模板中已补充 `HUB_BRIDGE_TOKEN` 说明。 + +--- + +## 3. 启动顺序(Ubuntu + PM2) + +**原则**:代码在 **`/opt/crypto_monitor`**,先三实例 Flask,再中控(一条 PM2 含 3 agent + hub)。环境见 **[docs/ubuntu-server.md](../docs/ubuntu-server.md)**。 + +```bash +# 三所 Flask(示例:币安;其余三所同理) +cd /opt/crypto_monitor/crypto_monitor_binance +pm2 start ecosystem.config.cjs + +# 中控 + 子代理 +cd /opt/crypto_monitor/manual_trading_hub +pm2 start ecosystem.config.cjs +pm2 save +``` + +浏览器(本机或反代): + +- 监控区:`http://127.0.0.1:5100/monitor` +- 行情区:`http://127.0.0.1:5100/market` +- 内照明心:`http://127.0.0.1:5100/archive` +- 资金概况:`http://127.0.0.1:5100/funds` +- 系统设置:`http://127.0.0.1:5100/settings` + +验收: + +```bash +bash /opt/crypto_monitor/manual_trading_hub/scripts/verify_hub_deploy.sh +curl -s http://127.0.0.1:5100/api/ping +``` + +--- + +## 4. 页面操作说明 + +Chrome **桌面快捷方式**图标来自站点 `favicon` / `manifest`(已配置统一品牌图),说明见 **[docs/shortcut-icon.md](../docs/shortcut-icon.md)**。 + +### 4.1 监控区 `/monitor` + +| 功能 | 说明 | +|------|------| +| **服务器状态** | 标题下方可折叠条(**默认收起**),摘要行显示 CPU/内存/硬盘;展开见四指标卡片(`GET /api/host/status`,每 5 秒刷新)。**CPU 或内存 ≥85%** 时浏览器弹窗告警(降至 85% 以下后再次超标会再提示)。依赖 `manual_trading_hub/.venv` 内 **psutil**(勿用系统 `pip`,见 [部署文档.md](./部署文档.md))。可选 `HUB_HOST_DISK_PATH` 指定监控磁盘 | +| **2×2 主界面** | 三所信息**完整展示**:余额、持仓表、委托/平仓、折叠委托单、下单监控、关键位、趋势/加仓摘要 | +| **全屏放大** | **点击卡片标题栏**(非按钮区)→ 该所**全屏**:每币种一张实盘风格持仓卡(趋势持仓显示**来源: 趋势回调计划**、**风险%**、**程序监控·止盈价**、**盈亏比**,与实例策略页一致);独立卡片:**关键位**、**下单监控**、**趋势回调**(单计划 **两列**:左=币种基本信息与 3×2 指标,右=**补仓计划明细**,底=**保本偏移%** 可编辑 + **保本移交** / **结束计划**(中控直接调实例,与 `/strategy` 一致)、快照可用/计划保证金/杠杆)、**顺势加仓** | +| **委托单折叠** | 仅「委托单」区块默认折叠;展开状态存浏览器本地,**5 秒刷新不重置** | +| **条件单 / 委托** | 每个持仓下方展示交易所 **条件单**(默认折叠)与 **普通委托**;数据来自子代理实时拉取(币安含 Algo 通道) | +| **撤单** | 条件单区内单笔「撤单」或「撤销全部」;经中控 `POST /api/orders/{id}/cancel`、`cancel-symbol` | +| **挂止盈止损** | 持仓行 **「委托」**:弹窗填止损/止盈价 → **先撤该合约全部条件单,再挂新 TP/SL**(币安 / OKX / Gate / Gate 三所统一,逻辑与各实例 `.env` 参数一致) | +| **平仓** | 持仓行「平仓」:仅平该方向仓位(子代理市价减仓) | +| **机器人单** | 来自实例 `/api/hub/monitor` 的 `order_monitors`(active),为本地监控计划,**不等于**交易所条件单 | +| **关键位** | 仅 `capabilities` 含 `key` 的户;展示门控摘要(`/api/price_snapshot`) | +| **趋势计划** | 仅当该户勾选 **监控趋势计划** 时展示 `trend_pullback_plans`(active) | +| **实例 / 复盘** | 「实例」「策略交易」「复盘」经中控签发 **SSO 链接**(默认 2h、单次)打开,**免输**实例 `APP_USERNAME/PASSWORD`;直链实例 IP/域名仍走 `/login`。**云服务器**见 **[云服务器部署说明.md](./云服务器部署说明.md)**;局域网/反代见 **[局域网与反代部署说明.md](./局域网与反代部署说明.md)** | +| **关键位列表** | 来自 `/api/hub/monitor` + `/api/price_snapshot`;Flask 未连通时卡片提示原因;**Gate 户**无关键位块 | +| **该户全平** | `POST` 子代理 `/emergency/close-all`,仅平该 API Key 仓位 | +| **全局紧急全平** | 对所有已启用户依次全平(不含 `HUB_DISABLED_IDS` 强制关闭的 id) | +| **自动刷新** | 默认每 5 秒请求 `/api/monitor/board` | + +持仓数据以 **子代理 ccxt** 为准;关键位/趋势/机器人单以 **Flask 数据库** 为准。若 Flask 未启动,卡片仍会显示 agent 持仓,但下方策略信息可能为空或报错。 + +### 4.2 行情区 `/market` + +| 功能 | 说明 | +|------|------| +| **K 线** | 选择已启用交易所 + 币种 + 周期;按需拉取,本地 `data/hub_kline.db` 缓存(默认保留 15 天) | +| **周期** | `1m` `5m` `15m` `1h` `2h` `4h` `12h` `1d` `1w` | +| **加载 / 强制刷新** | 普通加载优先缓存;强制刷新重拉并覆盖缓存 | +| **从监控跳转** | 点击持仓合约名带入品种,并显示入场/止损/止盈/委托与 K 线价格线 | +| **技术指标** | 可选 EMA 21/55、MACD、RSI | +| **快捷键** | **`F`** 全屏/退出;全屏时 **`Esc`** 退出;数字键切换周期(见 [行情区说明.md](./行情区说明.md)) | +| **自动刷新** | 约 5 秒更新最新 OHLCV | + +数据经中控 → 各实例 `GET /api/hub/ohlcv`(`hub_ohlcv_lib`)。升级 hub 与三实例 Flask 后请 **强刷浏览器**;异常 K 线可点 **强制刷新**。 + +### 4.2.1 内照明心 `/archive` + +| 功能 | 说明 | +|------|------| +| **复盘语录** | 左栏按日添加/编辑;最多 100 条 | +| **日期** | **本日 / 本周 / 本月 / 自选区间**(交易日 8:00 切日) | +| **区间统计** | 总开仓、犯病次数与占比、盈亏、剔除犯病盈亏、各交易所分项 | +| **筛选** | 盈利单、亏损单、犯病(仅过滤表格;统计栏不受此三项影响) | +| **交易记录** | 区间内开仓列表;犯病行红色字体;可编辑备注与犯病标签 | +| **K 线** | 默认折叠按需加载;独立库 `data/hub_symbol_archive.db`;仅存 **5m** 真源,**15m/1h/4h** 聚合 | +| **建档** | 最早开仓向前 **30 天** 5m 种子;之后每 **4h** 增量(Hub 后台 + 可点「同步」) | +| **视窗** | **持仓过程**(锚平仓)/ **进场决策**(锚开仓);支持时间输入跳转 | + +与行情区 `hub_kline.db`(15 天滚动)**分离**,建档起 **只增不删**。细则见 **[docs/hub-symbol-archive-kline.md](../docs/hub-symbol-archive-kline.md)**。 + +### 4.2.2 资金概况 `/funds` + +| 功能 | 说明 | +|------|------| +| **总资金** | 已监控账户的 **资金户 + 交易户** 合计(不含浮盈) | +| **总曲线** | 自 **2026-06-09** 起、按北京时间交易日(默认 8:00 切日)每日一点,最多 **180** 天 | +| **最大回撤** | 基于总资金余额曲线(非平仓盈亏回撤) | +| **分户** | 每户资金/交易拆分、迷你曲线、分户回撤;**未监控** 不参与合计 | +| **快照** | 监控板聚合成功时写入 `hub_fund_history.json` | + +细则见 **[资金概况说明.md](./资金概况说明.md)**。 + +### 4.2.3 数据看板 `/dashboard` + +| 功能 | 说明 | +|------|------| +| **总览** | 交易日、平仓盈亏、笔数、浮盈亏、资金合计、持仓数 | +| **分户** | 三户资金/交易账户、今日盈亏、浮盈亏;单日亏损 ≥ 资金合计 **5%** 高亮预警 | +| **平仓明细** | 当日平仓流水表 | +| **刷新** | 后台每 60s 聚合 + **SSE** 推送版本号;页面无整页轮询闪烁 | +| **主题** | 跟随顶栏亮/暗主题,卡片柔光样式(非霓虹背景) | + +细则见 **[数据看板说明.md](./数据看板说明.md)**。 + +### 4.3 AI 教练 `/ai` + +| 功能 | 说明 | +|------|------| +| **交易教练** | 口语化陪聊;后台注入三户监控快照(不在页面展示今日总结) | +| **普通聊天** | 不绑交易数据 | +| **会话** | 多会话历史(切换/删除)、消息复制;点 **「新开对话」** 清空当前上下文 | +| **模型** | 与三实例相同 `.env`(默认 `AI_PROVIDER=openai` + `OPENAI_*`;改 `ollama` 走本机),见 [AI教练说明.md](./AI教练说明.md) | +| **与实例复盘** | 深度单笔 journal 复盘仍在各所 `/records`;中控不做重复 | + +依赖三实例 `GET /api/hub/trades/today`(`hub_bridge`);升级代码后需 **重启三所 Flask**。 + +### 4.4 系统设置 `/settings` + +**可用**:打开 http://127.0.0.1:5100/settings ,修改表格后点 **保存设置** 即写入 `hub_settings.json`;**重新加载** 从磁盘/默认再读(会重新套用 `HUB_DISABLED_IDS`)。保存后监控区立即使用新 URL/启用状态,**无需重启 hub**。 + +**显示与导航**(`hub_settings.json` → `display`): + +| 开关 | 说明 | +|------|------| +| 监控区资金/浮盈 | 关闭后监控卡片不显示资金户、交易户、浮盈亏列 | +| 顶栏「资金概况」 | 关闭后隐藏导航;直接访问 `/funds` 会跳回监控区 | +| 顶栏「数据看板」 | 关闭后隐藏导航;直接访问 `/dashboard` 会跳回监控区 | + +**下单、关键位、策略交易**:请在监控卡片点击 **「实例」** 或 **「策略交易」**(SSO),进入各 `crypto_monitor_*` 网页(`/trade`、`/key_monitor`、`/strategy`、`/strategy/records` 等)。中控 **不** 提供下单区;**策略交易记录** 仅在实例顶栏查看(见 [策略交易说明.md](../策略交易说明.md) §五)。 + +| 列 | 含义 | +|----|------| +| 启用 | 是否参与监控与全局全平;被 `HUB_DISABLED_IDS` 锁定的无法勾选 | +| 显示名 | 监控卡片标题 | +| Flask URL | 实例根地址,如 `http://127.0.0.1:5001` | +| Agent URL | 子代理根地址,如 `http://127.0.0.1:15200` | +| 复盘链接 | 一般为 `{Flask}/records` | +| **监控关键位** | 勾选后卡片展示 **关键位** 列表 + 门控价(读 Flask `/api/price_snapshot`) | +| **监控趋势计划** | 勾选后卡片展示 **趋势回调** 运行中计划(`trend_pullback_plans` active) | +| id | 与 `HUB_DISABLED_IDS`、全平 API 路径中的 id 对应;新增户勿与已有 id 重复 | + +- **保存设置**:写入 `hub_settings.json`,重启 hub 后仍生效。 +- **添加交易所**:见下文 §4.5(须先自建 Flask + agent,再在中控登记)。 +- **删**:从列表移除(保存后生效)。 + +#### 能力与「策略交易」的关系(重要) + +| 能力勾选 | 中控监控区 | 策略交易(趋势回调 / 顺势加仓) | +|----------|------------|----------------------------------| +| 监控关键位 | 显示关键位块 | **不控制**;在实例页 `/key_monitor` | +| 监控趋势计划 | 显示趋势计划块 | **不控制**;在实例页 `/strategy` 左栏操作 | +| 均未勾选 | 仅持仓、余额、机器人单 | 仍可在实例网页使用策略交易 | + +三所 Flask 均已注册 `hub_bridge` 且 **`has_trend=true`**,勾选「监控趋势计划」后才会从 `/api/hub/monitor` 拉取趋势数据。修改勾选后 **保存即可**,须 **重启对应 Flask** 仅在你刚升级了 `hub_bridge` 相关代码时。 + +--- + +### 4.5 增加账户(例如再挂一个 Gate) + +中控 **不会** 自动启动进程,也 **不** 保存交易所 API Key。新增一户 = **复制/新建一套实例目录 + 独立 `.env` + 新端口 Flask/agent + 在中控登记一行**。 + +#### 4.5.1 端口勿冲突(示例) + +| 用途 | 目录(示例) | Flask `APP_PORT` | Agent `PORT` | +|------|----------------|------------------|--------------| +| Gate(已有) | `crypto_monitor_gate` | 5000 | 15202 | +| **新增 Gate 子账户** | 复制为 `crypto_monitor_gate_2` 等 | **5005**(自定) | **15204**(自定) | + +`agent` 的 `PORT` 与 Flask 的 `APP_PORT` **必须不同**;且不要与币安 5001、OKX 5004、中控 5100 等占用端口相同。 + +#### 4.5.2 新建实例目录 + +1. 复制整个 `crypto_monitor_gate` 到新目录(仓库内副本或 `/opt/` 下均可)。 +2. 在新目录:`cp .env.example .env`,至少修改: + - `APP_PORT` → 新 Flask 端口(如 5005) + - `DB_PATH` → 独立库(如 `crypto_gate2.db`),**勿**与其它实例共用 `crypto.db` + - `GATE_API_KEY` / `GATE_API_SECRET` → **该子账户** 密钥 + - `HUB_BRIDGE_TOKEN` → 与中控、其它实例 **相同** +3. 安装 venv 与依赖(`bash /opt/crypto_monitor/deploy/setup_env.sh --only gate` 或按 Gate 部署文档),启动: + +```bash +cd /opt/crypto_monitor/crypto_monitor_gate_2 +pm2 start ecosystem.config.cjs +``` + +4. 在中控 `ecosystem.config.cjs` 增加对应 agent,或单独 `run_agent.sh` 配置后 `pm2 restart`(勿与已有 agent 端口冲突)。 + +验收:`curl http://127.0.0.1:5005/login` 能开页;`curl http://127.0.0.1:15204/status` 返回 `ok`。 + +#### 4.5.3 在中控登记 + +1. 打开 **系统设置** → **添加交易所**(或手改 `manual_trading_hub/hub_settings.json`)。 +2. 填写 **Flask URL**、**Agent URL**、**id**(如 `4`)、**显示名**。 +3. 能力建议: + - 训练/关键位户:**监控关键位** + **监控趋势计划**(若也要在中控看趋势计划); + - 纯趋势户:只勾 **监控趋势计划**。 +4. 勾选 **启用** → **保存设置**。 +5. 在 **监控区** 应出现新卡片;点 **实例** 进入该户网页做下单与 **策略交易**。 + +PM2:仓库 `ecosystem.config.cjs` 默认只有三 agent;额外子账户需自行 `pm2 start` 或手工终端,与是否改 hub 源码无关。 + +--- + +## 5. 能力矩阵(监控展示,建议勾选) + +| 账户 | 监控关键位 | 监控趋势计划 | 策略交易(实例页) | +|------|:----------:|:--------------:|:------------------:| +| 币安 | ✓ 建议 | ✓ 建议 | `/strategy` | +| OKX | ✓ 建议 | ✓ 建议 | `/strategy` | +| Gate | ✓ 建议 | ✓ 建议 | `/strategy` | +| Gate | —(通常不勾) | ✓ | `/strategy` | + +「建议」表示中控卡片展示对应块;**不勾** 仍可在该实例网页使用关键位或策略交易。 + +--- + +## 6. HTTP API 摘要(中控) + +访问控制: + +- **IP**:默认允许本机与 RFC1918 私网(`HUB_TRUST_LAN=true`);公网 IP 直连返回 403。 +- **登录**:设置 `HUB_PASSWORD` 后须用户名+密码登录(`HUB_USERNAME`,未设时默认 `admin`);反代到公网时**务必设置**。 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/settings` | 读取配置 | +| POST | `/api/settings` | 保存配置 | +| GET | `/api/monitor/board` | 监控聚合 | +| POST | `/api/close/{id}` | 单户全平 | +| POST | `/api/close-all` | 全局全平,body 可选 `exclude_ids` | +| GET | `/api/auth/status` | 是否需登录、是否已登录 | +| POST | `/api/auth/login` | body `{"username":"...","password":"..."}` | +| POST | `/api/auth/logout` | 退出 | +| GET | `/api/ping` | 版本与健康检查(**免登录**) | +| GET | `/api/chart/meta` | 行情区:交易所、周期、limit | +| GET | `/api/chart/ohlcv` | 行情区 K 线(`exchange_key`、`symbol`、`timeframe`、可选 `refresh=1`) | +| GET | `/api/hub/fund-overview` | 资金概况:总/分户资金、180 日曲线、回撤 | +| GET | `/api/archive/meta` | 内照明心:周期、同步间隔 | +| GET | `/api/archive/daily-trades` | 内照明心:区间交易与统计(`period` / `date_from` / `date_to`) | +| GET | `/api/archive/quotes` | 内照明心:复盘语录 | +| GET | `/api/archive/list` | 币种列表(筛选 query) | +| GET | `/api/archive/detail` | 单币种交易时间线 | +| GET | `/api/archive/ohlcv` | 档案 K 线视窗 | +| PATCH | `/api/archive/trade/{exchange_key}/{trade_id}` | 犯病/情绪标签与备注 | +| POST | `/api/archive/sync` | 立即同步三所交易与 K 线 | + +已移除的 `/api/trade/*` 若被旧缓存页面请求,返回 **410** 并提示前往各实例网页。 + +实例侧(中控只读;下单/关键位/趋势在实例网页): + +| 路径 | 说明 | +|------|------| +| `/api/hub/ping` | 连通与能力 | +| `/api/hub/monitor` | 关键位、机器人单、趋势计划 | +| `/api/hub/ohlcv` | 行情区 OHLCV(ccxt 拉取,供中控聚合缓存) | +| `/api/hub/trades/archive` | 内照明心:近 N 天已平仓(`days` / `limit`) | + +--- + +## 7. 环境变量速查 + +### 中控 hub.py + +| 变量 | 默认 | 说明 | +|------|------|------| +| `HUB_HOST` | `0.0.0.0` | 监听地址 | +| `HUB_PORT` | `5100` | 监听端口 | +| `HUB_BRIDGE_TOKEN` | 空 | Flask 桥接令牌;可同 `CONTROL_TOKEN` | +| `HUB_DISABLED_IDS` | `1` | 逗号分隔,强制关闭的账户 id | +| `HUB_TRUST_LAN` | `true` | `false` 时仅本机可访问中控页面 | +| `HUB_USERNAME` | `admin` | 登录用户名(仅当已设密码时生效) | +| `HUB_PASSWORD` | (空) | 非空即启用 Web 登录 | +| `HUB_SESSION_SECRET` | 用户名+密码 | 会话 Cookie 签名密钥 | +| `HUB_COOKIE_SECURE` | `false` | HTTPS 反代建议 `true`(仅 HTTPS 发 Secure Cookie,HTTP 内网 IP 仍可登) | +| `HUB_SESSION_DAYS` | `7` | 登录保持天数 | +| `HUB_KLINE_RETENTION_DAYS` | `15` | 行情区 K 线库保留天数 | +| `HUB_KLINE_DB_PATH` | `data/hub_kline.db` | K 线 SQLite 路径 | +| `HUB_ARCHIVE_DB_PATH` | `data/hub_symbol_archive.db` | 内照明心永久 K 线库 | +| `HUB_ARCHIVE_SYNC_INTERVAL_SEC` | `14400` | 档案 K 线后台同步间隔(秒) | +| `HUB_ARCHIVE_TRADE_DAYS` | `365` | 同步交易记录回看天数 | +| `HUB_ARCHIVE_TRADE_LIMIT` | `2000` | 单所同步交易条数上限 | + +### 子代理 agent.py + +| 变量 | 说明 | +|------|------| +| `EXCHANGE` | `binance` / `okx` / `gate` | +| `PORT` / `HOST` | 监听 | +| `CONTROL_TOKEN` | 与中控一致时必填头 `X-Control-Token` | + +### 各实例 Flask + +| 变量 | 说明 | +|------|------| +| `HUB_BRIDGE_TOKEN` | 与中控一致 | +| `APP_AUTH_DISABLED` | `true` 时跳过登录与令牌(仅建议本机调试) | + +--- + +## 8. 安全与边界 + +1. **中控不下单**:开仓、关键位、趋势回调仅在各实例网页操作。 +2. **全平为市价减仓**:监控区全平不可撤销,操作前二次确认。 +3. **子代理建议只监听 127.0.0.1**,不要对局域网暴露 API Key 通道。 +4. **公网暴露 hub**:必须设置 `HUB_USERNAME` + `HUB_PASSWORD`;HTTPS 反代建议 `HUB_COOKIE_SECURE=true`;亦可 `HUB_HOST=127.0.0.1` 仅本机监听 + 反代。 +5. **复盘不在中控**:时间筛选、导出 CSV、编辑笔记仍在各实例 `/records`。 +6. **OKX 默认关**:避免未部署 OKX 时监控卡片持续报错。 + +--- + +## 9. 故障排查(速查) + +完整实录(含 `api_trade_key`、`multipart`、git 版本、PM2 等)见 **[常见问题.md](./常见问题.md)**。 + +| 现象 | 可能原因 | 处理 | +|------|----------|------| +| 监控卡片「子代理不可用」 | agent 未启动或端口错 | 检查 Agent URL;`pm2 restart` agent | +| 无关键位/趋势信息 | Flask 未起或 hub_bridge 未加载 | 启动 `crypto_*`;`curl .../api/hub/ping` | +| 全平 401 | `CONTROL_TOKEN` 与中控不一致 | 与 `HUB_BRIDGE_TOKEN` 对齐 | +| OKX 始终灰色 | `HUB_DISABLED_IDS=1` | 改掉环境变量并在设置页启用 | +| 打开即跳转登录 | 已设 `HUB_PASSWORD` | 正常;访问 `/login` | +| 域名能登、IP:5100 不能 | Secure Cookie + HTTP | 见常见问题 §2.1;或分别登录 | +| 添加关键位报错 / SyntaxError | 旧前端或旧 hub 代码 | 强刷浏览器;`git pull` + `verify_hub_deploy.sh` | +| `curl /api/ping` 非 JSON | hub 未启动 | `pm2 restart manual-trading-hub` | +| K 线只有约 300 根 | 旧版未分页 | `git pull` 三实例 + hub,强制刷新 | +| 12h 周期异常 | 无原生 12h 或旧缓存 | 强制刷新;见 [行情区说明.md](./行情区说明.md) | + +**运维脚本**(在 `manual_trading_hub` 目录执行): + +| 脚本 | 作用 | +|------|------| +| `scripts/fix_hub_deps.sh` | 安装依赖(含 `python-multipart`) | +| `scripts/verify_hub_deploy.sh` | 检查代码版本与 ping | +| `scripts/fix_env_crlf.sh` | 修复 `.env` 的 CRLF 导致 agent 起不来 | + +手动探测实例桥接: + +```bash +curl -sS -H "X-Hub-Token: 你的令牌" http://127.0.0.1:5001/api/hub/ping +``` + +--- + +## 10. 与旧版 README 的差异 + +早期中控 **仅监控 + 全平**,使用环境变量 `HUB_AGENTS` 列表。当前版本改为: + +- **hub_settings.json**(或内置默认)管理三所 URL 与能力; +- **三页 UI**:监控 / 行情 / 设置; +- 通过 **hub_bridge** 只读聚合监控数据。 + +子代理 `agent.py` 仍负责持仓与全平;`HUB_AGENTS` 环境变量在新版 hub 中 **不再使用**(以设置文件为准)。 + +**PM2 守护**: + +```bash +cd /opt/crypto_monitor/manual_trading_hub +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env +pm2 start ecosystem.config.cjs # 一次启动 3 个 agent + manual-trading-hub +pm2 save && pm2 startup +``` + +快捷:`bash scripts/pm2_hub.sh start|restart|logs`(同样 hub+agent 一起)。 + +更细的安装顺序、反代、验收见 **《部署文档.md》**;PM2 见 **[scripts/后台运行-Ubuntu.md](./scripts/后台运行-Ubuntu.md)**。 + +--- + +## 11. 日常推荐流程 + +1. 启动三所 **agent** + **Flask**(OKX 按需)。 +2. 启动 **hub.py**,打开监控区确认持仓与关键位门控正常。 +3. 看 K 线 → **行情区** 或监控区点击合约名跳转;异常图表点 **强制刷新**。 +4. 开仓、关键位、趋势 → 点击监控卡片「实例」进入对应 Flask。 +5. 复盘、导出记录 → 点击「复盘」进入 `/records`。 +6. 异常行情 → 单户全平或全局紧急全平。 + +增加账户步骤见 **§4.4**;无需改 `hub.py` 源码,但须该户 Flask 已 `git pull` 并 **重启**(`hub_bridge` + `has_trend` + `ohlcv`),且 agent 已部署。 + +--- + +## 12. 文档索引 + +| 文档 | 内容 | +|------|------| +| [使用说明.md](./使用说明.md) | 本文 | +| [行情区说明.md](./行情区说明.md) | K 线周期、缓存、快捷键、API | +| [开仓计划说明.md](./开仓计划说明.md) | 计划录入、归档、胜率统计 | +| [docs/hub-symbol-archive-kline.md](../docs/hub-symbol-archive-kline.md) | 内照明心、区间统计、永久 5m、建档与同步 | +| [部署文档.md](./部署文档.md) | Ubuntu / PM2 / 反代 | +| [常见问题.md](./常见问题.md) | 故障实录与排障 | +| [README.md](./README.md) | 速览 | +| [.env.example](./.env.example) | 环境变量模板 | +| [scripts/后台运行-Ubuntu.md](./scripts/后台运行-Ubuntu.md) | PM2 常驻 | +| [docs/ubuntu-server.md](../docs/ubuntu-server.md) | Ubuntu 环境总览 | diff --git a/manual_trading_hub/局域网与反代部署说明.md b/manual_trading_hub/局域网与反代部署说明.md index c951f43..97419ca 100644 --- a/manual_trading_hub/局域网与反代部署说明.md +++ b/manual_trading_hub/局域网与反代部署说明.md @@ -1,228 +1,226 @@ -# 中控 · 局域网与反代部署说明 - -本文说明在 **局域网(IP + 端口)** 与 **宝塔/Nginx 反代(域名)** 两种场景下,如何配置中控与各实例,并实现: - -- **从中控** 点「实例 / 策略交易 / 复盘」→ **免输入** 实例网页密码(SSO 临时链接,默认 **2 小时** 内有效、**单次使用**) -- **浏览器直链** 实例地址(反代域名或 `http://IP:端口`)→ 进入 **`/login`**,输入统一 **`APP_USERNAME` / `APP_PASSWORD`** - -SSO 签名复用 **`HUB_BRIDGE_TOKEN`**(与中控调实例 API 相同,四所 `.env` 与 `manual_trading_hub/.env` 保持一致)。 - -**云服务器(VPS)** 的硬件、安全组、宝塔、环境变量与验收清单见 **[云服务器部署说明.md](./云服务器部署说明.md)**。 - ---- - -## 一、两种访问方式对照 - -| 项目 | 局域网 | 反代(域名) | -|------|--------|----------------| -| 中控地址 | `http://内网IP:5100` | `https://hub.你的域名.com` | -| 实例地址(浏览器) | `http://内网IP:5004` 等 | `https://okx.你的域名.com` 等 | -| `hub_settings` 里 `flask_url` | 建议写 **`http://内网IP:端口`** | 建议写 **`https://该实例域名`**(与浏览器一致) | -| 中控本机调实例 API | 可与浏览器相同;同机也可用 `http://127.0.0.1:端口` + `HUB_PUBLIC_ORIGIN` | 同机可用 `127.0.0.1:端口` 或域名(需 Nginx 转发 `X-Hub-Token`) | -| `HUB_PUBLIC_ORIGIN` | 若 `flask_url` 填 `127.0.0.1`,**必填** `http://内网IP` | 若 `flask_url` 已是完整域名,**可不设** | -| 宝塔 | 可不装反代,直连端口 | 每实例一个站点 + SSL;中控单独站点 | -| 直链登录 | 实例 `/login` | 实例 `/login` | -| 从中控打开 | `/hub-sso?token=...` 自动登录 | 同上 | - ---- - -## 二、共用环境变量(必配) - -### 2.1 中控 `manual_trading_hub/.env` - -```bash -HUB_BRIDGE_TOKEN=请填一长串随机字符 -HUB_USERNAME=admin # 中控登录(建议设置) -HUB_PASSWORD=你的中控密码 -HUB_SSO_TTL_SEC=7200 # 可选,默认 7200 = 2 小时 -``` - -### 2.2 四个实例 `crypto_monitor_*/.env` - -每个目录相同(**直链**时用这套登录实例网页): - -```bash -HUB_BRIDGE_TOKEN=与中控完全相同 -APP_USERNAME=统一用户名 -APP_PASSWORD=统一密码 -# 云上切勿 APP_AUTH_DISABLED=true -``` - -### 2.3 子代理 - -`CONTROL_TOKEN` 可与 `HUB_BRIDGE_TOKEN` 相同;子代理只监听 `127.0.0.1`,**不要**对公网暴露 `15200`~`15203`。 - ---- - -## 三、局域网部署(IP + 端口) - -适用:家里/办公室内网,例如服务器 `192.168.8.6`。 - -### 3.1 端口约定(示例,以你实际为准) - -| 服务 | 端口 | -|------|------| -| 中控 hub | 5100 | -| OKX Flask | 5004 | -| 币安 Flask | 5001 | -| Gate 训练 | 5000 | -| Gate 趋势 | 5002 | -| agent | 15200~15203(仅本机) | - -### 3.2 系统设置 `hub_settings.json`(网页「系统设置」保存) - -浏览器里你会打开的地址,应使用 **内网 IP**,不要用 `127.0.0.1`(否则别的电脑上的浏览器会连到你本机): - -```json -{ - "flask_url": "http://192.168.8.6:5004", - "agent_url": "http://127.0.0.1:15201" -} -``` - -说明: - -- **`flask_url`**:给浏览器用的实例页地址 → 写 **`http://192.168.8.6:端口`** -- **`agent_url`**:仅中控服务器访问 → 写 **`http://127.0.0.1:1520x`** - -各账户按上表改端口即可。 - -### 3.3 可选:`flask_url` 仍写 127.0.0.1 时 - -若坚持 `flask_url` 为 `http://127.0.0.1:5004`(仅 hub 与本机 Flask 同机),在中控 `.env` 增加: - -```bash -HUB_PUBLIC_ORIGIN=http://192.168.8.6 -``` - -中控会把返回给前端的链接从 `127.0.0.1` 替换为 `192.168.8.6`(端口保留)。 - -### 3.4 访问方式 - -1. 中控:`http://192.168.8.6:5100` → 登录中控 → 点「实例」→ 新标签进入 OKX,**无需**再输实例密码。 -2. 直链:`http://192.168.8.6:5004` → 出现登录页 → 输入 `APP_USERNAME` / `APP_PASSWORD`。 - -### 3.5 防火墙 - -内网自用:放行 `5100`、各 `APP_PORT`;**不要**对公网开放 agent 端口。 - ---- - -## 四、反代部署(域名 + 宝塔) - -适用:云服务器,对外用 HTTPS 域名。 - -### 4.1 域名规划(示例) - -| 站点 | 反代到 | -|------|--------| -| `hub.example.com` | `127.0.0.1:5100` | -| `okx.example.com` | `127.0.0.1:5004` | -| `binance.example.com` | `127.0.0.1:5001` | -| `gate.example.com` | `127.0.0.1:5000` | -| `gate-bot.example.com` | `127.0.0.1:5002` | - -Flask / hub 进程仍只监听 **127.0.0.1** 或 `0.0.0.0` 本机端口,由 Nginx 对外提供 HTTPS。 - -### 4.2 宝塔操作要点 - -1. 每个域名 → **反向代理** → 目标 `http://127.0.0.1:对应端口`。 -2. 申请 **SSL**(Let’s Encrypt)。 -3. **不要**再给实例站加一层宝塔「访问密码」(避免与 Flask `/login` 重复);直链鉴权用 **`APP_USERNAME` / `APP_PASSWORD`** 即可。 -4. 自定义 Nginx 配置中保留 WebSocket/大 body 如需;确保代理头: - -```nginx -proxy_set_header Host $host; -proxy_set_header X-Real-IP $remote_addr; -proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; -proxy_set_header X-Forwarded-Proto $scheme; -``` - -中控请求实例 API 时会带 **`X-Hub-Token`**,Nginx 默认会转发请求头,一般无需额外配置。 - -### 4.3 `hub_settings` 示例(反代) - -```json -{ - "flask_url": "https://okx.example.com", - "agent_url": "http://127.0.0.1:15201" -} -``` - -- 浏览器与 SSO 链接使用 **`https://okx.example.com`**。 -- 中控服务器拉 `/api/hub/*` 仍走本机 `agent_url`;`flask_url` 用域名时,hub 会请求 `https://okx.example.com/api/...`(同机可通即可)。 - -同机部署时也可: - -- `flask_url`: `http://127.0.0.1:5004` -- `HUB_PUBLIC_ORIGIN`: `https://okx.example.com` - -仅当**所有实例共用一个对外 IP、靠端口区分**时才适合用 `HUB_PUBLIC_ORIGIN`;**每实例独立域名**时,请直接在 `flask_url` 写该实例域名。 - -### 4.4 中控 `.env`(反代建议) - -```bash -HUB_BRIDGE_TOKEN=... -HUB_USERNAME=... -HUB_PASSWORD=... -HUB_COOKIE_SECURE=true # 中控为 HTTPS 时建议开启 -``` - -### 4.5 访问方式 - -1. `https://hub.example.com` 登录中控 → 点「打开实例」→ `https://okx.example.com/hub-sso?...` → 进入系统。 -2. 地址栏直接输入 `https://okx.example.com` → `/login` → 实例账号密码。 - ---- - -## 五、SSO 行为说明(2 小时) - -| 项 | 说明 | -|----|------| -| 有效期 | 默认 **7200 秒(2 小时)**,`HUB_SSO_TTL_SEC` 可改 | -| 单次使用 | 同一链接成功登录后 **不能再用**;需在中控重新点「打开实例」 | -| 密钥 | 复用 **`HUB_BRIDGE_TOKEN`** | -| 直链 | 无 token → 正常 **`/login`** | - ---- - -## 六、部署与重启顺序 - -```bash -cd /opt/crypto_monitor -# 各实例 -pm2 restart crypto_okx crypto_binance crypto_gate crypto_gate_bot # 名称以你为准 - -cd manual_trading_hub -pm2 restart manual-trading-hub manual-agent-binance manual-agent-okx manual-agent-gate manual-agent-gate-bot -``` - -改 `hub_settings` 或 `.env` 后重启 **hub + 对应实例 Flask**(`hub_bridge` 与 `/hub-sso` 在实例进程内)。 - ---- - -## 七、验收清单 - -- [ ] 四实例 `.env` 与中控 `HUB_BRIDGE_TOKEN` 一致 -- [ ] 四实例 `APP_USERNAME` / `APP_PASSWORD` 一致 -- [ ] 局域网:`flask_url` 为 `http://IP:端口`;反代:`flask_url` 为 `https://域名` -- [ ] 已登录中控 → 点「实例」→ **无**实例登录页 -- [ ] 隐身窗口直链实例域名/IP → **有** `/login` -- [ ] 复制「打开实例」完整 URL,用过一次后再开 → 失效并回到登录页 - ---- - -## 八、常见问题 - -**Q:从中控打开仍要登录?** -- 检查实例是否已 `git pull` 并重启(需有 `/hub-sso`)。 -- `HUB_BRIDGE_TOKEN` 是否四所一致。 -- `hub_settings` 里该账户 `key` 是否与 `install_on_app(exchange=...)` 一致(如 `okx`、`binance`、`gate`、`gate_bot`)。 - -**Q:直链也要登录中控?** -- 不应。直链只走实例 `/login`。若跳到中控,检查是否点错链接或 Nginx 配错站点。 - -**Q:链接多久失效?** -- 签发后 **2 小时**内且 **未使用过**;过期或已用需在中控重新点打开。 - -更多故障见 [常见问题.md](./常见问题.md)、[部署文档.md](./部署文档.md)。 +# 中控 · 局域网与反代部署说明 + +本文说明在 **局域网(IP + 端口)** 与 **宝塔/Nginx 反代(域名)** 两种场景下,如何配置中控与各实例,并实现: + +- **从中控** 点「实例 / 策略交易 / 复盘」→ **免输入** 实例网页密码(SSO 临时链接,默认 **2 小时** 内有效、**单次使用**) +- **浏览器直链** 实例地址(反代域名或 `http://IP:端口`)→ 进入 **`/login`**,输入统一 **`APP_USERNAME` / `APP_PASSWORD`** + +SSO 签名复用 **`HUB_BRIDGE_TOKEN`**(与中控调实例 API 相同,三所 `.env` 与 `manual_trading_hub/.env` 保持一致)。 + +**云服务器(VPS)** 的硬件、安全组、宝塔、环境变量与验收清单见 **[云服务器部署说明.md](./云服务器部署说明.md)**。 + +--- + +## 一、两种访问方式对照 + +| 项目 | 局域网 | 反代(域名) | +|------|--------|----------------| +| 中控地址 | `http://内网IP:5100` | `https://hub.你的域名.com` | +| 实例地址(浏览器) | `http://内网IP:5004` 等 | `https://okx.你的域名.com` 等 | +| `hub_settings` 里 `flask_url` | 建议写 **`http://内网IP:端口`** | 建议写 **`https://该实例域名`**(与浏览器一致) | +| 中控本机调实例 API | 可与浏览器相同;同机也可用 `http://127.0.0.1:端口` + `HUB_PUBLIC_ORIGIN` | 同机可用 `127.0.0.1:端口` 或域名(需 Nginx 转发 `X-Hub-Token`) | +| `HUB_PUBLIC_ORIGIN` | 若 `flask_url` 填 `127.0.0.1`,**必填** `http://内网IP` | 若 `flask_url` 已是完整域名,**可不设** | +| 宝塔 | 可不装反代,直连端口 | 每实例一个站点 + SSL;中控单独站点 | +| 直链登录 | 实例 `/login` | 实例 `/login` | +| 从中控打开 | `/hub-sso?token=...` 自动登录 | 同上 | + +--- + +## 二、共用环境变量(必配) + +### 2.1 中控 `manual_trading_hub/.env` + +```bash +HUB_BRIDGE_TOKEN=请填一长串随机字符 +HUB_USERNAME=admin # 中控登录(建议设置) +HUB_PASSWORD=你的中控密码 +HUB_SSO_TTL_SEC=7200 # 可选,默认 7200 = 2 小时 +``` + +### 2.2 三个实例 `crypto_monitor_*/.env` + +每个目录相同(**直链**时用这套登录实例网页): + +```bash +HUB_BRIDGE_TOKEN=与中控完全相同 +APP_USERNAME=统一用户名 +APP_PASSWORD=统一密码 +# 云上切勿 APP_AUTH_DISABLED=true +``` + +### 2.3 子代理 + +`CONTROL_TOKEN` 可与 `HUB_BRIDGE_TOKEN` 相同;子代理只监听 `127.0.0.1`,**不要**对公网暴露 `15200`~`15202`。 + +--- + +## 三、局域网部署(IP + 端口) + +适用:家里/办公室内网,例如服务器 `192.168.8.6`。 + +### 3.1 端口约定(示例,以你实际为准) + +| 服务 | 端口 | +|------|------| +| 中控 hub | 5100 | +| OKX Flask | 5004 | +| 币安 Flask | 5001 | +| Gate | 5000 | +| agent | 15200~15202(仅本机) | + +### 3.2 系统设置 `hub_settings.json`(网页「系统设置」保存) + +浏览器里你会打开的地址,应使用 **内网 IP**,不要用 `127.0.0.1`(否则别的电脑上的浏览器会连到你本机): + +```json +{ + "flask_url": "http://192.168.8.6:5004", + "agent_url": "http://127.0.0.1:15201" +} +``` + +说明: + +- **`flask_url`**:给浏览器用的实例页地址 → 写 **`http://192.168.8.6:端口`** +- **`agent_url`**:仅中控服务器访问 → 写 **`http://127.0.0.1:1520x`** + +各账户按上表改端口即可。 + +### 3.3 可选:`flask_url` 仍写 127.0.0.1 时 + +若坚持 `flask_url` 为 `http://127.0.0.1:5004`(仅 hub 与本机 Flask 同机),在中控 `.env` 增加: + +```bash +HUB_PUBLIC_ORIGIN=http://192.168.8.6 +``` + +中控会把返回给前端的链接从 `127.0.0.1` 替换为 `192.168.8.6`(端口保留)。 + +### 3.4 访问方式 + +1. 中控:`http://192.168.8.6:5100` → 登录中控 → 点「实例」→ 新标签进入 OKX,**无需**再输实例密码。 +2. 直链:`http://192.168.8.6:5004` → 出现登录页 → 输入 `APP_USERNAME` / `APP_PASSWORD`。 + +### 3.5 防火墙 + +内网自用:放行 `5100`、各 `APP_PORT`;**不要**对公网开放 agent 端口。 + +--- + +## 四、反代部署(域名 + 宝塔) + +适用:云服务器,对外用 HTTPS 域名。 + +### 4.1 域名规划(示例) + +| 站点 | 反代到 | +|------|--------| +| `hub.example.com` | `127.0.0.1:5100` | +| `okx.example.com` | `127.0.0.1:5004` | +| `binance.example.com` | `127.0.0.1:5001` | +| `gate.example.com` | `127.0.0.1:5000` | + +Flask / hub 进程仍只监听 **127.0.0.1** 或 `0.0.0.0` 本机端口,由 Nginx 对外提供 HTTPS。 + +### 4.2 宝塔操作要点 + +1. 每个域名 → **反向代理** → 目标 `http://127.0.0.1:对应端口`。 +2. 申请 **SSL**(Let’s Encrypt)。 +3. **不要**再给实例站加一层宝塔「访问密码」(避免与 Flask `/login` 重复);直链鉴权用 **`APP_USERNAME` / `APP_PASSWORD`** 即可。 +4. 自定义 Nginx 配置中保留 WebSocket/大 body 如需;确保代理头: + +```nginx +proxy_set_header Host $host; +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Forwarded-Proto $scheme; +``` + +中控请求实例 API 时会带 **`X-Hub-Token`**,Nginx 默认会转发请求头,一般无需额外配置。 + +### 4.3 `hub_settings` 示例(反代) + +```json +{ + "flask_url": "https://okx.example.com", + "agent_url": "http://127.0.0.1:15201" +} +``` + +- 浏览器与 SSO 链接使用 **`https://okx.example.com`**。 +- 中控服务器拉 `/api/hub/*` 仍走本机 `agent_url`;`flask_url` 用域名时,hub 会请求 `https://okx.example.com/api/...`(同机可通即可)。 + +同机部署时也可: + +- `flask_url`: `http://127.0.0.1:5004` +- `HUB_PUBLIC_ORIGIN`: `https://okx.example.com` + +仅当**所有实例共用一个对外 IP、靠端口区分**时才适合用 `HUB_PUBLIC_ORIGIN`;**每实例独立域名**时,请直接在 `flask_url` 写该实例域名。 + +### 4.4 中控 `.env`(反代建议) + +```bash +HUB_BRIDGE_TOKEN=... +HUB_USERNAME=... +HUB_PASSWORD=... +HUB_COOKIE_SECURE=true # 中控为 HTTPS 时建议开启 +``` + +### 4.5 访问方式 + +1. `https://hub.example.com` 登录中控 → 点「打开实例」→ `https://okx.example.com/hub-sso?...` → 进入系统。 +2. 地址栏直接输入 `https://okx.example.com` → `/login` → 实例账号密码。 + +--- + +## 五、SSO 行为说明(2 小时) + +| 项 | 说明 | +|----|------| +| 有效期 | 默认 **7200 秒(2 小时)**,`HUB_SSO_TTL_SEC` 可改 | +| 单次使用 | 同一链接成功登录后 **不能再用**;需在中控重新点「打开实例」 | +| 密钥 | 复用 **`HUB_BRIDGE_TOKEN`** | +| 直链 | 无 token → 正常 **`/login`** | + +--- + +## 六、部署与重启顺序 + +```bash +cd /opt/crypto_monitor +# 各实例 +pm2 restart crypto_okx crypto_binance crypto_gate # 名称以你为准 + +cd manual_trading_hub +pm2 restart manual-trading-hub manual-agent-binance manual-agent-okx manual-agent-gate +``` + +改 `hub_settings` 或 `.env` 后重启 **hub + 对应实例 Flask**(`hub_bridge` 与 `/hub-sso` 在实例进程内)。 + +--- + +## 七、验收清单 + +- [ ] 三实例 `.env` 与中控 `HUB_BRIDGE_TOKEN` 一致 +- [ ] 三实例 `APP_USERNAME` / `APP_PASSWORD` 一致 +- [ ] 局域网:`flask_url` 为 `http://IP:端口`;反代:`flask_url` 为 `https://域名` +- [ ] 已登录中控 → 点「实例」→ **无**实例登录页 +- [ ] 隐身窗口直链实例域名/IP → **有** `/login` +- [ ] 复制「打开实例」完整 URL,用过一次后再开 → 失效并回到登录页 + +--- + +## 八、常见问题 + +**Q:从中控打开仍要登录?** +- 检查实例是否已 `git pull` 并重启(需有 `/hub-sso`)。 +- `HUB_BRIDGE_TOKEN` 是否三所一致。 +- `hub_settings` 里该账户 `key` 是否与 `install_on_app(exchange=...)` 一致(如 `okx`、`binance`、`gate`、`gate`)。 + +**Q:直链也要登录中控?** +- 不应。直链只走实例 `/login`。若跳到中控,检查是否点错链接或 Nginx 配错站点。 + +**Q:链接多久失效?** +- 签发后 **2 小时**内且 **未使用过**;过期或已用需在中控重新点打开。 + +更多故障见 [常见问题.md](./常见问题.md)、[部署文档.md](./部署文档.md)。 diff --git a/manual_trading_hub/常见问题.md b/manual_trading_hub/常见问题.md index ae611ca..91d66e7 100644 --- a/manual_trading_hub/常见问题.md +++ b/manual_trading_hub/常见问题.md @@ -1,354 +1,354 @@ -# 中控与四实例 — 常见问题实录 - -本文档整理部署与运行 **manual_trading_hub**(复盘系统中控)及四所 `crypto_monitor_*` 时**实际遇到过**的问题与处理办法。操作步骤仍以 [使用说明.md](./使用说明.md)、[部署文档.md](./部署文档.md) 为准。 - ---- - -## 一、中控进程与代码版本 - -### 1.1 PM2 日志仍出现 `api_trade_key`、`python-multipart` 断言 - -**现象**:`pm2 logs` 里报错 `File "hub.py", line 324, in api_trade_key` 或 `The python-multipart library must be installed`。 - -**原因**: - -- 服务器上的 `hub.py` 仍是**旧版**(含已移除的「下单区」接口),或 pull 后**未重启** PM2,日志是历史残留。 -- 旧版「添加关键位」会 `request.form()`,未装 `python-multipart` 时直接 500。 - -**处理**: - -```bash -cd /opt/crypto_monitor -git pull - -cd manual_trading_hub -bash scripts/fix_hub_deps.sh # 安装 python-multipart 等 -bash scripts/verify_hub_deploy.sh # 应显示无 api_trade_key、含 HUB_BUILD - -pm2 restart manual-trading-hub -curl -s http://127.0.0.1:5100/api/ping -``` - -**正常 ping**(无需登录)应含 `"build":"20260521-no-trade-ui"`、`"trade_ui":false`。 - -**说明**:当前版本**已移除中控下单区**;添加关键位、人工下单、趋势回调请在监控卡片点 **「实例」** 进入各 Flask 网页。浏览器请 **Ctrl+F5** 强刷,避免旧前端缓存仍请求 `/api/trade/key`。 - ---- - -### 1.2 `curl /api/ping` 返回 `{"detail":"未登录"}` - -**原因**:早期版本未把 `/api/ping` 列入免登录白名单(已修复)。 - -**处理**:`git pull` 后 `pm2 restart manual-trading-hub`;再测应直接返回 JSON,无需 Cookie。 - ---- - -### 1.3 `verify_hub_deploy.sh` 报 `Expecting value: line 1 column 1` - -**原因**:5100 端口无进程监听(hub 未启动或已崩溃),`curl` 拿到空响应。 - -**处理**: - -```bash -pm2 restart manual-trading-hub -sleep 2 -pm2 logs manual-trading-hub --lines 30 --nostream -ss -ltn | grep 5100 -bash scripts/verify_hub_deploy.sh -``` - ---- - -### 1.4 `bash scripts/fix_hub_deps.sh` 在仓库根目录找不到 - -**原因**:脚本在 `manual_trading_hub/scripts/` 下,不在 `/opt/crypto_monitor/scripts/`。 - -**处理**: - -```bash -cd /opt/crypto_monitor/manual_trading_hub -bash scripts/fix_hub_deps.sh -``` - ---- - -## 二、登录与 Cookie(反代 / 域名 / 内网 IP) - -### 2.1 设了密码后,域名能登录,`http://内网IP:5100` 不能 - -**原因**(最常见): - -- `.env` 中 `HUB_COOKIE_SECURE=true`,且用 **HTTP** 访问 IP:5100 → 浏览器**不保存**带 `Secure` 的 Cookie,表现为登录成功后又跳回登录页。 -- **域名(HTTPS)** 与 **IP:5100(HTTP)** 是不同站点,Cookie **不共用**,需在 IP 上再登一次。 - -**处理**: - -- 已支持:仅在实际 **HTTPS** 请求时发 `Secure` Cookie(读 `X-Forwarded-Proto`),HTTP 内网 IP 可正常登录。 -- 反代 Nginx 需传:`proxy_set_header X-Forwarded-Proto $scheme;` -- 若仍异常:HTTPS 域名与 HTTP IP **分别登录**;或内网仅用 IP 时可注释 `HUB_COOKIE_SECURE`。 - -### 2.2 登录后接口仍 401 - -| 检查项 | 说明 | -|--------|------| -| 用户名密码 | `.env` 中 `HUB_USERNAME`(未设默认为 `admin`)、`HUB_PASSWORD` | -| 改密后 | 需重新登录;旧 Cookie 失效 | -| 混用地址 | 不要用 A 浏览器标签登域名、B 标签指望 IP 已登录 | - -### 2.3 本地导航 iframe 嵌入:登录成功但一直「跳转中」/ 进不去 - -**原因**:父页(如 `http://192.168.8.6:5070`)跨域 `fetch` 中控 `/api/auth/login` 时,浏览器**不会**把 `Set-Cookie` 写进 iframe 里的中控站点,表现为接口 200、弹窗「登录成功」,但 iframe 仍无会话。 - -**处理**(中控 `git pull` 并重启 hub 后): - -1. 登录接口会返回 `session_token`;父页应把 iframe 指向: - `http://中控地址/embed-auth?token=会话token&next=/monitor` -2. 若直接在 iframe 内打开中控 `/login` 登录,页面会自动走 `/embed-auth` 写入 Cookie。 -3. 父页也可监听 `postMessage`,事件类型 `hub:login-ok`,字段含 `embed_auth_url`。 - -`.env` 可选: - -```env -HUB_ALLOW_EMBED=true -HUB_EMBED_ORIGINS=http://192.168.8.6:5070 -``` - ---- - -## 三、监控区无数据 / 子代理异常 - -### 3.1 卡片「子代理不可用」或余额为 — - -| 原因 | 处理 | -|------|------| -| agent 未启动 | `pm2 restart ecosystem.config.cjs` 或 `pm2 restart manual-agent-*` | -| Agent URL 与端口不符 | 系统设置里应为 `http://127.0.0.1:15200` 等 | -| PM2 未加载策略 `.env` | 须用 `run_agent.sh` 启动(会 `source` 各目录 `.env`),勿裸跑 `agent.py` | -| `.env` 为 Windows CRLF | 日志 `$'\r': command not found` → `bash scripts/fix_env_crlf.sh` 后重启 | - -验证: - -```bash -curl -s http://127.0.0.1:15202/status | head -c 300 -``` - -应 `ok: true` 且有 `balance_usdt`。 - -### 3.3 Gate 子代理「一会正常、一会连不上」(仅 Gate 两户) - -| 现象 | 说明 | -|------|------| -| 中控 LINK 2/4,仅 Gate 红 | 本机 `15202`/`15203` 在 PM2 重启间隙连不上 | -| 日志 `$'\r': command not found` | `crypto_monitor_gate*` 的 `.env` 为 Windows CRLF | -| `curl` 有时通有时不通 | 与 Gate 外网无关,先修 CRLF 并重建 agent | - -**修复**(服务器): - -```bash -cd /opt/crypto_monitor -sed -i 's/\r$//' crypto_monitor_gate/.env crypto_monitor_gate_bot/.env -bash manual_trading_hub/scripts/fix_env_crlf.sh -cd manual_trading_hub && pm2 restart manual-agent-gate manual-agent-gate-bot -# 仍反复重启时:pm2 delete 后按 ecosystem.config.cjs 重新 start(见部署文档 §5.6) -``` - -修好后 `pm2 describe manual-agent-gate` 的 **restarts** 应不再疯涨;`pm2 flush manual-agent-gate` 可清掉旧 CRLF 日志。 - -**若子代理已绿但挂委托失败**:再查 `GATE_SOCKS_PROXY`、API 权限、止损止盈价格是否合理(与各实例策略页相同 `.env` 参数)。 - -### 3.2 有持仓但无关键位 / 趋势,或提示 Flask 404 - -| 原因 | 处理 | -|------|------| -| 对应 `crypto_*` Flask 未启动 | `pm2 restart crypto_gate` 等 | -| 未注册 `hub_bridge` | 启动日志勿含 `[hub_bridge] ImportError`;仓库根需在 `PYTHONPATH`(各实例 `ecosystem.config.cjs` 已配 `PYTHONPATH=..`) | -| 中控 `ModuleNotFoundError: hub_auth` | 确认仓库根存在 `/opt/crypto_monitor/hub_auth.py`(`git pull`);`run_hub.sh` / PM2 已设 `PYTHONPATH=仓库根`;`pm2 restart manual-trading-hub` | -| `HUB_BRIDGE_TOKEN` 不一致 | 中控 `.env` 与四实例 `.env` 设相同令牌,或实例 `APP_AUTH_DISABLED=true`(仅建议本机) | - -```bash -curl -s -H "X-Hub-Token:你的令牌" http://127.0.0.1:5000/api/hub/ping -``` - -### 3.3 中控监控区打开慢、一直转圈 - -**原因(常见)**: - -1. 首屏要等 **`/api/monitor/board`**:向 4 个子代理拉持仓/余额,并向 4 个 Flask 拉监控与(默认)关键位行情;任一实例慢或超时都会拖住整页。 -2. 旧版 hub 对每所 Flask **串行**请求,4 所 × 3 接口容易累计到十几秒;新版已改为**并行**(`git pull` 后 `pm2 restart manual-trading-hub`)。 -3. 各实例 **`/api/price_snapshot`** 会调交易所接口(含全量持仓),最耗时;内网访问 Google 字体也会拖首屏渲染。 -4. 子代理 `/status` 里 `fetch_balance` / `fetch_positions` / 挂单列表走交易所 API,网络差时单次可达数秒。 - -**加快办法**: - -```env -# manual_trading_hub/.env -HUB_BOARD_KEY_PRICES=false # 不拉 price_snapshot,关键位门控显示为「-」,首屏明显更快 -HUB_AGENT_TIMEOUT=6 -HUB_FLASK_TIMEOUT=8 -``` - -并确认四所 `crypto_*` 与 `manual-agent-*` 均为 **online**,避免等满超时。浏览器 **Ctrl+F5** 强刷静态资源(版本号含 `20260525-perf`)。 - ---- - -## 四、云服务器 / 公网反代 - -**云服务器完整配置(安全组、宝塔、环境变量、PM2、验收)** 见 **[云服务器部署说明.md](./云服务器部署说明.md)**。 - ---- - -## 五、复盘链接与公网反代 - -### 4.1 监控里点「复盘」打开的是本机 127.0.0.1 - -**原因**:未设 `HUB_PUBLIC_ORIGIN`,浏览器拿到的链接仍是 Flask 本机地址。 - -**处理**:`manual_trading_hub/.env` 增加(示例): - -```env -HUB_PUBLIC_ORIGIN=http://192.168.8.6 -``` - -或 `HUB_PUBLIC_HOST=192.168.8.6`。改后 `pm2 restart manual-trading-hub`。 - -**说明**:仅反代中控、四实例 Flask 仍只监听 127.0.0.1 时,其它电脑要能打开复盘,还须能访问各实例端口或单独反代。 - -### 4.2 只反代中控、不反代四实例 - -**可以**。中控聚合监控与全平;复盘、下单、关键位维护进各实例网页。实例 Flask/agent 建议 `127.0.0.1` + 与中控相同的 `HUB_BRIDGE_TOKEN`。 - -### 4.3 从中控「打开实例」仍要输密码 - -**完整说明**:[局域网与反代部署说明.md](./局域网与反代部署说明.md) - -**常见原因**: - -1. 四实例未重启,`/hub-sso` 未加载(启动日志勿长期 `[hub_bridge] ImportError`)。 -2. `HUB_BRIDGE_TOKEN` 与四实例 `.env` 不一致。 -3. `hub_settings` 里该户 `key` 与实例 `install_on_app(exchange=...)` 不一致(如 `okx`、`gate_bot`)。 -4. **HTTPS 跨域 iframe**:中控与实例不同域名时,四实例须 `APP_COOKIE_SECURE=true`(使 session Cookie 为 `SameSite=None`),否则 SSO 成功仍跳 `/login`。 -5. **经本地导航打开中控**(LocalNav → 中控 iframe → 点实例):旧版会在中控内再嵌一层实例 iframe,Cookie 易失效。请升级 **LocalNav + 中控** 最新代码:点实例后由导航页直接打开实例,工具栏有「← 中控」;须配置 `NAV_HUB_USERNAME` / `NAV_HUB_PASSWORD`,四实例 `HUB_EMBED_PARENT_ORIGINS` 含本地导航地址(如 `http://192.168.8.6:5070`)。 -6. 浏览器仍用旧书签直链首页,未从中控点「实例」(直链本来就要登录)。 - -**直链**:`http://IP:端口` 或 `https://实例域名` → 使用各实例 **`APP_USERNAME` / `APP_PASSWORD`**(四所建议统一)。 - ---- - -## 六、Gate 趋势 / 复盘相关(实例侧) - -### 5.1 Gate 趋势 `/records` 或预览 500(`preview_created_at`) - -**原因**:数据库缺列或查询未兼容旧库。 - -**处理**:`git pull` 后重启 `crypto_gate_bot`;必要时在实例目录执行一次带 `init_db` 的启动或按该目录更新文档迁移。 - -### 5.2 中控监控区 Gate 趋势户「无关键位」 - -**设计如此**:Gate 趋势户通常只勾 **监控趋势计划**,不勾关键位;关键位在 Gate 训练户。四所 **策略交易** 均在各实例 `/strategy`,与中控勾选无关。增加 Gate 子账户见 [使用说明.md](./使用说明.md) **§4.3**。 - ---- - -## 七、环境与配置 - -### 6.1 OKX 默认不显示 - -`HUB_DISABLED_IDS=1`(默认关 OKX)。要用 OKX:清空或改掉该变量,并在系统设置启用 id=1。 - -### 6.2 公网 IP 直连中控 403 - -`HUB_TRUST_LAN=true` 时仅允许本机 + RFC1918 私网(10/172.16/192.168)。公网 IP 直连 5100 会被拒;应走 **Nginx 反代到 127.0.0.1:5100**。 - -### 4.4 浏览器显示 `{"detail":"forbidden"}` - -**原因**:中控 `local_only` 中间件认为访问来源 IP 不允许(常见于云上 `HUB_TRUST_LAN=false` 且反代未指向 `127.0.0.1:5100`)。 - -**处理**(二选一): - -1. `manual_trading_hub/.env` 增加 **`HUB_ALLOW_PUBLIC=true`**(已设 `HUB_PASSWORD` 时推荐),`pm2 restart manual-trading-hub`。 -2. 宝塔反代目标改为 **`http://127.0.0.1:5100`**(不要用公网 IP:5100 作 upstream)。 - -改后强刷浏览器再开 `/login`。 - -### 6.3 `.env` 修改不生效 - -PM2 须重启:`pm2 restart manual-trading-hub`(`run_hub.sh` 每次启动会重读 `.env`)。 - -### 6.4 `hub_settings.json` 与 Git - -网页「系统设置」保存生成,**一般不提交 Git**。`git pull` **不会覆盖** 该文件与 `.env`。 - ---- - -## 八、功能边界(避免误用) - -| 项目 | 说明 | -|------|------| -| 中控下单区 | **已移除**;勿再在中控添加关键位/人工单/趋势预览 | -| 中控能力 | 监控聚合、单户/全局紧急全平、系统设置、登录保护 | -| 下单与关键位 | 各 `crypto_monitor_*` 原网页 | -| 复盘 | 各实例 `/records`;中控仅「复盘」外链 | -| 全平 | 市价减仓,不可撤销,操作前确认 | - ---- - -## 九、推荐排障顺序 - -1. `git pull` → `manual_trading_hub` 下 `bash scripts/fix_hub_deps.sh` → `bash scripts/verify_hub_deploy.sh` -2. `pm2 restart manual-trading-hub`(及 `ecosystem.config.cjs` 若 agent/Flask 也有问题) -3. `curl http://127.0.0.1:5100/api/ping` → 确认 `build` 与 `trade_ui:false` -4. 浏览器打开 `/login` 登录 → `/monitor` 强刷 -5. 逐项 `curl` 子代理 `/status`、Flask `/api/hub/ping` -6. 仍不行则查 `pm2 logs manual-trading-hub`、`pm2 logs crypto_gate` 最近 50 行 - ---- - -## 十、行情区 K 线 - -### 10.1 只加载约 300 根(目标 1000) - -**原因**:旧版 `hub_ohlcv_lib` 无 `since` 分页时,OKX/Gate 单次 API 常只返回 ~300 根。 - -**处理**:`git pull` 后重启 **hub + 四实例 Flask**,行情区点 **强制刷新**;浏览器强刷(`chart.js` 带版本号)。 - -### 10.2 6h / 8h 周期错乱(已移除) - -中控行情区 **已不再提供** `6h`、`8h`(以及 `3m`/`10m`/`20m`/`30m`)。若 URL 或旧缓存仍带这些周期,会回退为 `5m`。请改用 `4h` / `12h` 等当前列表,见 [行情区说明.md](./行情区说明.md)。 - -### 10.3 12h 数据异常 - -**原因**:部分交易所无原生 12h;或本地 `hub_kline.db` 存有升级前的错误缓存。 - -**处理**:强制刷新;仍异常可停 hub 后备份并删除 `manual_trading_hub/data/hub_kline.db` 再拉取。 - -### 10.4 快捷键无效 - -- 全屏请用 **`F`**(Win 下 Ctrl+空格常被输入法占用,已不作为全屏键)。 -- 须在 **行情区** 页面且焦点不在币种输入框。 -- 升级后确认加载 `chart.js?v=...` 新版本。 - ---- - -## 十一、相关脚本 - -| 脚本 | 作用 | -|------|------| -| `scripts/fix_hub_deps.sh` | 安装/更新中控 venv 依赖(含 python-multipart) | -| `scripts/verify_hub_deploy.sh` | 检查代码版本、multipart、ping、PM2 状态 | -| `scripts/fix_env_crlf.sh` | 去除各目录 `.env` 的 Windows 换行 | -| `scripts/run_hub.sh` | PM2 启动 hub(加载 `.env`) | -| `scripts/run_agent.sh` | PM2 启动 agent(加载策略目录 `.env`) | -| `scripts/pm2_hub.sh` | 启停/日志 hub+agent 一体 | - ---- - -## 十二、文档索引 - -| 文档 | 内容 | -|------|------| -| [使用说明.md](./使用说明.md) | 架构、页面、环境变量、API | -| [行情区说明.md](./行情区说明.md) | K 线周期、缓存、快捷键 | -| [部署文档.md](./部署文档.md) | Ubuntu/PM2 安装与运维 | -| [云服务器部署说明.md](./云服务器部署说明.md) | VPS 配置、安全组、宝塔、env、验收 | -| [局域网与反代部署说明.md](./局域网与反代部署说明.md) | 内网 IP:端口 / 域名反代、SSO | -| [README.md](./README.md) | 速览与快速启动 | -| [.env.example](./.env.example) | 中控环境变量模板 | +# 中控与三实例 — 常见问题实录 + +本文档整理部署与运行 **manual_trading_hub**(复盘系统中控)及三所 `crypto_monitor_*` 时**实际遇到过**的问题与处理办法。操作步骤仍以 [使用说明.md](./使用说明.md)、[部署文档.md](./部署文档.md) 为准。 + +--- + +## 一、中控进程与代码版本 + +### 1.1 PM2 日志仍出现 `api_trade_key`、`python-multipart` 断言 + +**现象**:`pm2 logs` 里报错 `File "hub.py", line 324, in api_trade_key` 或 `The python-multipart library must be installed`。 + +**原因**: + +- 服务器上的 `hub.py` 仍是**旧版**(含已移除的「下单区」接口),或 pull 后**未重启** PM2,日志是历史残留。 +- 旧版「添加关键位」会 `request.form()`,未装 `python-multipart` 时直接 500。 + +**处理**: + +```bash +cd /opt/crypto_monitor +git pull + +cd manual_trading_hub +bash scripts/fix_hub_deps.sh # 安装 python-multipart 等 +bash scripts/verify_hub_deploy.sh # 应显示无 api_trade_key、含 HUB_BUILD + +pm2 restart manual-trading-hub +curl -s http://127.0.0.1:5100/api/ping +``` + +**正常 ping**(无需登录)应含 `"build":"20260521-no-trade-ui"`、`"trade_ui":false`。 + +**说明**:当前版本**已移除中控下单区**;添加关键位、人工下单、趋势回调请在监控卡片点 **「实例」** 进入各 Flask 网页。浏览器请 **Ctrl+F5** 强刷,避免旧前端缓存仍请求 `/api/trade/key`。 + +--- + +### 1.2 `curl /api/ping` 返回 `{"detail":"未登录"}` + +**原因**:早期版本未把 `/api/ping` 列入免登录白名单(已修复)。 + +**处理**:`git pull` 后 `pm2 restart manual-trading-hub`;再测应直接返回 JSON,无需 Cookie。 + +--- + +### 1.3 `verify_hub_deploy.sh` 报 `Expecting value: line 1 column 1` + +**原因**:5100 端口无进程监听(hub 未启动或已崩溃),`curl` 拿到空响应。 + +**处理**: + +```bash +pm2 restart manual-trading-hub +sleep 2 +pm2 logs manual-trading-hub --lines 30 --nostream +ss -ltn | grep 5100 +bash scripts/verify_hub_deploy.sh +``` + +--- + +### 1.4 `bash scripts/fix_hub_deps.sh` 在仓库根目录找不到 + +**原因**:脚本在 `manual_trading_hub/scripts/` 下,不在 `/opt/crypto_monitor/scripts/`。 + +**处理**: + +```bash +cd /opt/crypto_monitor/manual_trading_hub +bash scripts/fix_hub_deps.sh +``` + +--- + +## 二、登录与 Cookie(反代 / 域名 / 内网 IP) + +### 2.1 设了密码后,域名能登录,`http://内网IP:5100` 不能 + +**原因**(最常见): + +- `.env` 中 `HUB_COOKIE_SECURE=true`,且用 **HTTP** 访问 IP:5100 → 浏览器**不保存**带 `Secure` 的 Cookie,表现为登录成功后又跳回登录页。 +- **域名(HTTPS)** 与 **IP:5100(HTTP)** 是不同站点,Cookie **不共用**,需在 IP 上再登一次。 + +**处理**: + +- 已支持:仅在实际 **HTTPS** 请求时发 `Secure` Cookie(读 `X-Forwarded-Proto`),HTTP 内网 IP 可正常登录。 +- 反代 Nginx 需传:`proxy_set_header X-Forwarded-Proto $scheme;` +- 若仍异常:HTTPS 域名与 HTTP IP **分别登录**;或内网仅用 IP 时可注释 `HUB_COOKIE_SECURE`。 + +### 2.2 登录后接口仍 401 + +| 检查项 | 说明 | +|--------|------| +| 用户名密码 | `.env` 中 `HUB_USERNAME`(未设默认为 `admin`)、`HUB_PASSWORD` | +| 改密后 | 需重新登录;旧 Cookie 失效 | +| 混用地址 | 不要用 A 浏览器标签登域名、B 标签指望 IP 已登录 | + +### 2.3 本地导航 iframe 嵌入:登录成功但一直「跳转中」/ 进不去 + +**原因**:父页(如 `http://192.168.8.6:5070`)跨域 `fetch` 中控 `/api/auth/login` 时,浏览器**不会**把 `Set-Cookie` 写进 iframe 里的中控站点,表现为接口 200、弹窗「登录成功」,但 iframe 仍无会话。 + +**处理**(中控 `git pull` 并重启 hub 后): + +1. 登录接口会返回 `session_token`;父页应把 iframe 指向: + `http://中控地址/embed-auth?token=会话token&next=/monitor` +2. 若直接在 iframe 内打开中控 `/login` 登录,页面会自动走 `/embed-auth` 写入 Cookie。 +3. 父页也可监听 `postMessage`,事件类型 `hub:login-ok`,字段含 `embed_auth_url`。 + +`.env` 可选: + +```env +HUB_ALLOW_EMBED=true +HUB_EMBED_ORIGINS=http://192.168.8.6:5070 +``` + +--- + +## 三、监控区无数据 / 子代理异常 + +### 3.1 卡片「子代理不可用」或余额为 — + +| 原因 | 处理 | +|------|------| +| agent 未启动 | `pm2 restart ecosystem.config.cjs` 或 `pm2 restart manual-agent-*` | +| Agent URL 与端口不符 | 系统设置里应为 `http://127.0.0.1:15200` 等 | +| PM2 未加载策略 `.env` | 须用 `run_agent.sh` 启动(会 `source` 各目录 `.env`),勿裸跑 `agent.py` | +| `.env` 为 Windows CRLF | 日志 `$'\r': command not found` → `bash scripts/fix_env_crlf.sh` 后重启 | + +验证: + +```bash +curl -s http://127.0.0.1:15202/status | head -c 300 +``` + +应 `ok: true` 且有 `balance_usdt`。 + +### 3.3 Gate 子代理「一会正常、一会连不上」(仅 Gate 两户) + +| 现象 | 说明 | +|------|------| +| 中控某所子代理红 | 本机对应 agent 端口在 PM2 重启间隙连不上 | +| 日志 `$'\r': command not found` | `crypto_monitor_gate*` 的 `.env` 为 Windows CRLF | +| `curl` 有时通有时不通 | 与 Gate 外网无关,先修 CRLF 并重建 agent | + +**修复**(服务器): + +```bash +cd /opt/crypto_monitor +sed -i 's/\r$//' crypto_monitor_gate/.env crypto_monitor_gate/.env +bash manual_trading_hub/scripts/fix_env_crlf.sh +cd manual_trading_hub && pm2 restart manual-agent-gate +# 仍反复重启时:pm2 delete 后按 ecosystem.config.cjs 重新 start(见部署文档 §5.6) +``` + +修好后 `pm2 describe manual-agent-gate` 的 **restarts** 应不再疯涨;`pm2 flush manual-agent-gate` 可清掉旧 CRLF 日志。 + +**若子代理已绿但挂委托失败**:再查 `GATE_SOCKS_PROXY`、API 权限、止损止盈价格是否合理(与各实例策略页相同 `.env` 参数)。 + +### 3.2 有持仓但无关键位 / 趋势,或提示 Flask 404 + +| 原因 | 处理 | +|------|------| +| 对应 `crypto_*` Flask 未启动 | `pm2 restart crypto_gate` 等 | +| 未注册 `hub_bridge` | 启动日志勿含 `[hub_bridge] ImportError`;仓库根需在 `PYTHONPATH`(各实例 `ecosystem.config.cjs` 已配 `PYTHONPATH=..`) | +| 中控 `ModuleNotFoundError: hub_auth` | 确认仓库根存在 `/opt/crypto_monitor/hub_auth.py`(`git pull`);`run_hub.sh` / PM2 已设 `PYTHONPATH=仓库根`;`pm2 restart manual-trading-hub` | +| `HUB_BRIDGE_TOKEN` 不一致 | 中控 `.env` 与三实例 `.env` 设相同令牌,或实例 `APP_AUTH_DISABLED=true`(仅建议本机) | + +```bash +curl -s -H "X-Hub-Token:你的令牌" http://127.0.0.1:5000/api/hub/ping +``` + +### 3.3 中控监控区打开慢、一直转圈 + +**原因(常见)**: + +1. 首屏要等 **`/api/monitor/board`**:向 4 个子代理拉持仓/余额,并向 4 个 Flask 拉监控与(默认)关键位行情;任一实例慢或超时都会拖住整页。 +2. 旧版 hub 对每所 Flask **串行**请求,3 所 × 3 接口容易累计到十几秒;新版已改为**并行**(`git pull` 后 `pm2 restart manual-trading-hub`)。 +3. 各实例 **`/api/price_snapshot`** 会调交易所接口(含全量持仓),最耗时;内网访问 Google 字体也会拖首屏渲染。 +4. 子代理 `/status` 里 `fetch_balance` / `fetch_positions` / 挂单列表走交易所 API,网络差时单次可达数秒。 + +**加快办法**: + +```env +# manual_trading_hub/.env +HUB_BOARD_KEY_PRICES=false # 不拉 price_snapshot,关键位门控显示为「-」,首屏明显更快 +HUB_AGENT_TIMEOUT=6 +HUB_FLASK_TIMEOUT=8 +``` + +并确认三所 `crypto_*` 与 `manual-agent-*` 均为 **online**,避免等满超时。浏览器 **Ctrl+F5** 强刷静态资源(版本号含 `20260525-perf`)。 + +--- + +## 四、云服务器 / 公网反代 + +**云服务器完整配置(安全组、宝塔、环境变量、PM2、验收)** 见 **[云服务器部署说明.md](./云服务器部署说明.md)**。 + +--- + +## 五、复盘链接与公网反代 + +### 4.1 监控里点「复盘」打开的是本机 127.0.0.1 + +**原因**:未设 `HUB_PUBLIC_ORIGIN`,浏览器拿到的链接仍是 Flask 本机地址。 + +**处理**:`manual_trading_hub/.env` 增加(示例): + +```env +HUB_PUBLIC_ORIGIN=http://192.168.8.6 +``` + +或 `HUB_PUBLIC_HOST=192.168.8.6`。改后 `pm2 restart manual-trading-hub`。 + +**说明**:仅反代中控、三实例 Flask 仍只监听 127.0.0.1 时,其它电脑要能打开复盘,还须能访问各实例端口或单独反代。 + +### 4.2 只反代中控、不反代三实例 + +**可以**。中控聚合监控与全平;复盘、下单、关键位维护进各实例网页。实例 Flask/agent 建议 `127.0.0.1` + 与中控相同的 `HUB_BRIDGE_TOKEN`。 + +### 4.3 从中控「打开实例」仍要输密码 + +**完整说明**:[局域网与反代部署说明.md](./局域网与反代部署说明.md) + +**常见原因**: + +1. 三实例未重启,`/hub-sso` 未加载(启动日志勿长期 `[hub_bridge] ImportError`)。 +2. `HUB_BRIDGE_TOKEN` 与三实例 `.env` 不一致。 +3. `hub_settings` 里该户 `key` 与实例 `install_on_app(exchange=...)` 不一致(如 `okx`、`gate`)。 +4. **HTTPS 跨域 iframe**:中控与实例不同域名时,三实例须 `APP_COOKIE_SECURE=true`(使 session Cookie 为 `SameSite=None`),否则 SSO 成功仍跳 `/login`。 +5. **经本地导航打开中控**(LocalNav → 中控 iframe → 点实例):旧版会在中控内再嵌一层实例 iframe,Cookie 易失效。请升级 **LocalNav + 中控** 最新代码:点实例后由导航页直接打开实例,工具栏有「← 中控」;须配置 `NAV_HUB_USERNAME` / `NAV_HUB_PASSWORD`,三实例 `HUB_EMBED_PARENT_ORIGINS` 含本地导航地址(如 `http://192.168.8.6:5070`)。 +6. 浏览器仍用旧书签直链首页,未从中控点「实例」(直链本来就要登录)。 + +**直链**:`http://IP:端口` 或 `https://实例域名` → 使用各实例 **`APP_USERNAME` / `APP_PASSWORD`**(三所建议统一)。 + +--- + +## 六、Gate / 复盘相关(实例侧) + +### 5.1 Gate `/records` 或预览 500(`preview_created_at`) + +**原因**:数据库缺列或查询未兼容旧库。 + +**处理**:`git pull` 后重启 `crypto_gate`;必要时在实例目录执行一次带 `init_db` 的启动或按该目录更新文档迁移。 + +### 5.2 中控监控区 Gate「无关键位」 + +**说明**:若系统设置未勾选「监控关键位」,中控不会展示关键位区块;策略交易仍在各实例 `/strategy` 操作。 + +--- + +## 七、环境与配置 + +### 6.1 OKX 默认不显示 + +`HUB_DISABLED_IDS=1`(默认关 OKX)。要用 OKX:清空或改掉该变量,并在系统设置启用 id=1。 + +### 6.2 公网 IP 直连中控 403 + +`HUB_TRUST_LAN=true` 时仅允许本机 + RFC1918 私网(10/172.16/192.168)。公网 IP 直连 5100 会被拒;应走 **Nginx 反代到 127.0.0.1:5100**。 + +### 4.4 浏览器显示 `{"detail":"forbidden"}` + +**原因**:中控 `local_only` 中间件认为访问来源 IP 不允许(常见于云上 `HUB_TRUST_LAN=false` 且反代未指向 `127.0.0.1:5100`)。 + +**处理**(二选一): + +1. `manual_trading_hub/.env` 增加 **`HUB_ALLOW_PUBLIC=true`**(已设 `HUB_PASSWORD` 时推荐),`pm2 restart manual-trading-hub`。 +2. 宝塔反代目标改为 **`http://127.0.0.1:5100`**(不要用公网 IP:5100 作 upstream)。 + +改后强刷浏览器再开 `/login`。 + +### 6.3 `.env` 修改不生效 + +PM2 须重启:`pm2 restart manual-trading-hub`(`run_hub.sh` 每次启动会重读 `.env`)。 + +### 6.4 `hub_settings.json` 与 Git + +网页「系统设置」保存生成,**一般不提交 Git**。`git pull` **不会覆盖** 该文件与 `.env`。 + +--- + +## 八、功能边界(避免误用) + +| 项目 | 说明 | +|------|------| +| 中控下单区 | **已移除**;勿再在中控添加关键位/人工单/趋势预览 | +| 中控能力 | 监控聚合、单户/全局紧急全平、系统设置、登录保护 | +| 下单与关键位 | 各 `crypto_monitor_*` 原网页 | +| 复盘 | 各实例 `/records`;中控仅「复盘」外链 | +| 全平 | 市价减仓,不可撤销,操作前确认 | + +--- + +## 九、推荐排障顺序 + +1. `git pull` → `manual_trading_hub` 下 `bash scripts/fix_hub_deps.sh` → `bash scripts/verify_hub_deploy.sh` +2. `pm2 restart manual-trading-hub`(及 `ecosystem.config.cjs` 若 agent/Flask 也有问题) +3. `curl http://127.0.0.1:5100/api/ping` → 确认 `build` 与 `trade_ui:false` +4. 浏览器打开 `/login` 登录 → `/monitor` 强刷 +5. 逐项 `curl` 子代理 `/status`、Flask `/api/hub/ping` +6. 仍不行则查 `pm2 logs manual-trading-hub`、`pm2 logs crypto_gate` 最近 50 行 + +--- + +## 十、行情区 K 线 + +### 10.1 只加载约 300 根(目标 1000) + +**原因**:旧版 `hub_ohlcv_lib` 无 `since` 分页时,OKX/Gate 单次 API 常只返回 ~300 根。 + +**处理**:`git pull` 后重启 **hub + 三实例 Flask**,行情区点 **强制刷新**;浏览器强刷(`chart.js` 带版本号)。 + +### 10.2 6h / 8h 周期错乱(已移除) + +中控行情区 **已不再提供** `6h`、`8h`(以及 `3m`/`10m`/`20m`/`30m`)。若 URL 或旧缓存仍带这些周期,会回退为 `5m`。请改用 `4h` / `12h` 等当前列表,见 [行情区说明.md](./行情区说明.md)。 + +### 10.3 12h 数据异常 + +**原因**:部分交易所无原生 12h;或本地 `hub_kline.db` 存有升级前的错误缓存。 + +**处理**:强制刷新;仍异常可停 hub 后备份并删除 `manual_trading_hub/data/hub_kline.db` 再拉取。 + +### 10.4 快捷键无效 + +- 全屏请用 **`F`**(Win 下 Ctrl+空格常被输入法占用,已不作为全屏键)。 +- 须在 **行情区** 页面且焦点不在币种输入框。 +- 升级后确认加载 `chart.js?v=...` 新版本。 + +--- + +## 十一、相关脚本 + +| 脚本 | 作用 | +|------|------| +| `scripts/fix_hub_deps.sh` | 安装/更新中控 venv 依赖(含 python-multipart) | +| `scripts/verify_hub_deploy.sh` | 检查代码版本、multipart、ping、PM2 状态 | +| `scripts/fix_env_crlf.sh` | 去除各目录 `.env` 的 Windows 换行 | +| `scripts/run_hub.sh` | PM2 启动 hub(加载 `.env`) | +| `scripts/run_agent.sh` | PM2 启动 agent(加载策略目录 `.env`) | +| `scripts/pm2_hub.sh` | 启停/日志 hub+agent 一体 | + +--- + +## 十二、文档索引 + +| 文档 | 内容 | +|------|------| +| [使用说明.md](./使用说明.md) | 架构、页面、环境变量、API | +| [行情区说明.md](./行情区说明.md) | K 线周期、缓存、快捷键 | +| [部署文档.md](./部署文档.md) | Ubuntu/PM2 安装与运维 | +| [云服务器部署说明.md](./云服务器部署说明.md) | VPS 配置、安全组、宝塔、env、验收 | +| [局域网与反代部署说明.md](./局域网与反代部署说明.md) | 内网 IP:端口 / 域名反代、SSO | +| [README.md](./README.md) | 速览与快速启动 | +| [.env.example](./.env.example) | 中控环境变量模板 | diff --git a/manual_trading_hub/开仓计划说明.md b/manual_trading_hub/开仓计划说明.md index dc23e1f..0439d4c 100644 --- a/manual_trading_hub/开仓计划说明.md +++ b/manual_trading_hub/开仓计划说明.md @@ -21,7 +21,7 @@ | 字段 | 说明 | |------|------| | 日期 | 计划日期(日期选择器,可手输 `YYYY-MM-DD`) | -| 交易所 | 四所:binance / okx / gate / gate_bot(来自 hub 已启用账户) | +| 交易所 | 三所:binance / okx / gate(来自 hub 已启用账户) | | 币种 | 输入 `BTC` 或 `BTC/USDT`,自动规范为 `XXX/USDT` | | 类型 | 趋势单 / 波段单 / 日内短线 | | 趋势周期 | 5m / 15m / 30m / 1h / 4h / 1d | diff --git a/manual_trading_hub/数据看板说明.md b/manual_trading_hub/数据看板说明.md index 65eb09f..b748d5d 100644 --- a/manual_trading_hub/数据看板说明.md +++ b/manual_trading_hub/数据看板说明.md @@ -1,50 +1,50 @@ -# 中控数据看板说明 - -入口:**`/dashboard`**(顶栏「数据看板」)。 - -## 能力 - -| 区块 | 说明 | -|------|------| -| **总览 KPI** | 交易日、平仓盈亏、笔数、浮盈亏、资金合计、实盘持仓 | -| **分户明细** | 四户资金/交易账户、今日盈亏、浮盈亏、备注;未启用显示「未监控」 | -| **平仓明细** | 当日平仓流水(合约、方向、结果、盈亏、时间) | -| **风险预警** | 单户单日平仓亏损 ≥ 资金合计 **5%** 时横幅 + 卡片高亮 | - -纯数据聚合,**不调用 AI**。交易日口径与实例一致(`TRADING_DAY_RESET_HOUR`,默认 8 点)。 - -## 刷新机制(SSE) - -与监控区 board 类似,采用 **后台聚合 + SSE 推送版本号**: - -1. `hub.py` 启动后 `dashboard_store` 每 **60s**(`DASHBOARD_POLL_INTERVAL_SEC`)聚合四户数据到内存快照。 -2. 浏览器打开看板页后连接 `GET /api/dashboard/stream`(`event: dashboard`)。 -3. 收到新版本号后拉取 `GET /api/dashboard/daily` 快照并局部渲染,**无整页轮询闪烁**。 -4. 监控区触发 board 刷新(全平、撤单等)时,会一并 `request_refresh` 看板,尽量与实盘同步。 -5. 「立即刷新」→ `POST /api/dashboard/refresh` 触发下一轮聚合。 - -可选环境变量:`HUB_DASHBOARD_SSE_HEARTBEAT_SEC`(默认 25,SSE 心跳间隔)。 - -## 主题与样式 - -- 跟随中控顶栏 **亮/暗主题**(`theme.js`),使用 `--panel` / `--border` / `--accent` 等变量。 -- 卡片采用 **柔光阴影**(非霓虹渐变背景);亮色主题下为浅灰投影,暗色主题为轻微内高光。 -- 盈亏仍用绿/红语义色,与全局一致。 - -## API - -| 方法 | 路径 | 说明 | -|------|------|------| -| GET | `/api/dashboard/daily` | 当前交易日快照(含 `dashboard_version`) | -| GET | `/api/dashboard/stream` | SSE 版本推送 | -| POST | `/api/dashboard/refresh` | 请求立即重聚合 | - -`GET /api/ping` 含 `dashboard_version`、`dashboard_poll_interval_sec` 等字段。 - -## 相关文件 - -- `hub_dashboard.py` — 聚合逻辑 -- `hub_dashboard_cache.py` — 后台轮询 + SSE -- `static/dashboard.js` / `dashboard.css` — 前端 - -部署后 `git pull` 并 `pm2 restart manual-trading-hub`。 +# 中控数据看板说明 + +入口:**`/dashboard`**(顶栏「数据看板」)。 + +## 能力 + +| 区块 | 说明 | +|------|------| +| **总览 KPI** | 交易日、平仓盈亏、笔数、浮盈亏、资金合计、实盘持仓 | +| **分户明细** | 三户资金/交易账户、今日盈亏、浮盈亏、备注;未启用显示「未监控」 | +| **平仓明细** | 当日平仓流水(合约、方向、结果、盈亏、时间) | +| **风险预警** | 单户单日平仓亏损 ≥ 资金合计 **5%** 时横幅 + 卡片高亮 | + +纯数据聚合,**不调用 AI**。交易日口径与实例一致(`TRADING_DAY_RESET_HOUR`,默认 8 点)。 + +## 刷新机制(SSE) + +与监控区 board 类似,采用 **后台聚合 + SSE 推送版本号**: + +1. `hub.py` 启动后 `dashboard_store` 每 **60s**(`DASHBOARD_POLL_INTERVAL_SEC`)聚合三户数据到内存快照。 +2. 浏览器打开看板页后连接 `GET /api/dashboard/stream`(`event: dashboard`)。 +3. 收到新版本号后拉取 `GET /api/dashboard/daily` 快照并局部渲染,**无整页轮询闪烁**。 +4. 监控区触发 board 刷新(全平、撤单等)时,会一并 `request_refresh` 看板,尽量与实盘同步。 +5. 「立即刷新」→ `POST /api/dashboard/refresh` 触发下一轮聚合。 + +可选环境变量:`HUB_DASHBOARD_SSE_HEARTBEAT_SEC`(默认 25,SSE 心跳间隔)。 + +## 主题与样式 + +- 跟随中控顶栏 **亮/暗主题**(`theme.js`),使用 `--panel` / `--border` / `--accent` 等变量。 +- 卡片采用 **柔光阴影**(非霓虹渐变背景);亮色主题下为浅灰投影,暗色主题为轻微内高光。 +- 盈亏仍用绿/红语义色,与全局一致。 + +## API + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/dashboard/daily` | 当前交易日快照(含 `dashboard_version`) | +| GET | `/api/dashboard/stream` | SSE 版本推送 | +| POST | `/api/dashboard/refresh` | 请求立即重聚合 | + +`GET /api/ping` 含 `dashboard_version`、`dashboard_poll_interval_sec` 等字段。 + +## 相关文件 + +- `hub_dashboard.py` — 聚合逻辑 +- `hub_dashboard_cache.py` — 后台轮询 + SSE +- `static/dashboard.js` / `dashboard.css` — 前端 + +部署后 `git pull` 并 `pm2 restart manual-trading-hub`。 diff --git a/manual_trading_hub/本地数据迁移到云端.md b/manual_trading_hub/本地数据迁移到云端.md index f94a076..0451aca 100644 --- a/manual_trading_hub/本地数据迁移到云端.md +++ b/manual_trading_hub/本地数据迁移到云端.md @@ -1,268 +1,268 @@ -# 本地数据备份与迁移到云服务器 - -本文说明如何把 **本机** 上运行的 `crypto_monitor`(四实例 + 中控)的**业务数据**迁到 **云 VPS**,并正确改配置。 -**不迁移** 本机 Python 虚拟环境(`.venv`),云上重新 `pip install` 即可。 - -相关:[云服务器部署说明.md](./云服务器部署说明.md) · [部署文档.md](./部署文档.md) - ---- - -## 一、要迁什么、不迁什么 - -### 必须迁移(业务数据) - -| 路径(每个实例目录下) | 内容 | -|------------------------|------| -| `crypto.db`(或 `.env` 里 `DB_PATH` 指向的文件) | 监控单、关键位、交易记录、复盘、运行时开关等 **SQLite 全库** | -| `static/images/`(或 `UPLOAD_DIR`) | 上传图、复盘截图等 | -| `static/images/order_charts/`(或 `ORDER_CHART_DIR`) | 订单 K 线图(若开启) | - -四个实例 **各有一份独立库**: - -- `crypto_monitor_binance/crypto.db` -- `crypto_monitor_okx/crypto.db` -- `crypto_monitor_gate/crypto.db` -- `crypto_monitor_gate_bot/crypto.db` - -### 中控额外迁移 - -| 路径 | 内容 | -|------|------| -| `manual_trading_hub/hub_settings.json` | 账户 URL、启用状态、能力勾选(网页「系统设置」保存的文件) | -| `manual_trading_hub/hub_ai_summaries.json` | 中控 AI 今日总结(`/ai`) | -| `manual_trading_hub/hub_ai_chat.json` | 中控 AI 聊天会话 | - -### 不要直接覆盖拷贝(需在云上重写) - -| 文件 | 说明 | -|------|------| -| 各目录 `.env` | 含 API 密钥:可在云上**手工新建**,从本机抄密钥,但须改 **`flask_url`、代理、公网相关项**(见下文) | -| `.venv/`、`__pycache__/` | 云上重建 | -| PM2 日志 | 无需迁 | - -### 可选 - -- 本机 `manual_trading_hub/.env` 里的 `HUB_BRIDGE_TOKEN`、`HUB_PASSWORD` 等:记下后在云上填入,**不要**把含密钥的 `.env` 发到公开网盘。 - ---- - -## 二、迁移前准备(本地) - -### 1. 停服务(避免数据库半写入) - -```bash -# 本机:停中控与子代理 -cd manual_trading_hub -pm2 stop manual-trading-hub manual-agent-binance manual-agent-okx manual-agent-gate manual-agent-gate-bot - -# 本机:停四个 Flask(进程名以你 pm2 list 为准) -pm2 stop crypto_okx crypto_binance crypto_gate crypto_gate_bot -# 或各目录 ecosystem 里的名字 -``` - -未用 PM2 时,结束对应 Python/Flask 进程后再备份。 - -### 2. 确认数据库文件位置 - -各实例目录下查看 `.env` 中 `DB_PATH`(默认 `crypto.db`)。若存在 `crypto.db-wal`、`crypto.db-shm`,**必须先停服务** 再备份。 - ---- - -## 三、本地备份(推荐用自带脚本) - -每个实例目录执行(会备份 **库 + static/images**): - -```bash -cd crypto_monitor_okx -bash scripts/backup_data.sh -# 默认输出到 /root/backups/crypto_monitor_okx/YYYY-MM-DD/ -# 本机可改环境变量:BACKUP_ROOT=~/crypto_backups bash scripts/backup_data.sh -``` - -对 `crypto_monitor_binance`、`crypto_monitor_gate`、`crypto_monitor_gate_bot` **各执行一次**。 - -脚本产物示例: - -```text -~/crypto_backups/crypto_monitor_okx/2026-05-21/ - crypto.db - static_images.tar.gz - manifest.txt -``` - -### 手工打包(不用脚本时) - -在仓库根目录示例: - -```bash -BACKUP=~/crypto_migrate_$(date +%Y%m%d) -mkdir -p "$BACKUP" - -for dir in crypto_monitor_okx crypto_monitor_binance crypto_monitor_gate crypto_monitor_gate_bot; do - tar -czf "$BACKUP/${dir}.tar.gz" \ - -C "$dir" crypto.db static/images 2>/dev/null || \ - tar -czf "$BACKUP/${dir}.tar.gz" -C "$dir" crypto.db -done - -cp manual_trading_hub/hub_settings.json "$BACKUP/" 2>/dev/null || true -cp manual_trading_hub/hub_ai_summaries.json "$BACKUP/" 2>/dev/null || true -cp manual_trading_hub/hub_ai_chat.json "$BACKUP/" 2>/dev/null || true -``` - ---- - -## 四、上传到云服务器 - -在**你电脑**上(把 `USER`、`云IP` 换成实际值): - -```bash -# 打包整个备份目录 -tar -czf crypto_migrate.tar.gz -C ~ crypto_backups # 或你的 BACKUP 路径 - -scp crypto_migrate.tar.gz USER@云IP:/tmp/ -scp manual_trading_hub/hub_settings.json USER@云IP:/tmp/ # 若单独备份 -``` - -大文件可用 **rsync**(支持断点续传): - -```bash -rsync -avz --progress ~/crypto_backups/ USER@云IP:/tmp/crypto_backups/ -``` - ---- - -## 五、云上恢复数据 - -假设代码已在 `/opt/crypto_monitor`(`git clone` 或 `rsync` 代码均可,**代码与数据分开**)。 - -```bash -ssh USER@云IP -cd /opt/crypto_monitor - -# 解压(若用 scp 单包) -tar -xzf /tmp/crypto_migrate.tar.gz -C /tmp - -# 按实例恢复(示例:OKX) -pm2 stop crypto_okx 2>/dev/null || true -cp /tmp/crypto_backups/crypto_monitor_okx/2026-05-21/crypto.db crypto_monitor_okx/crypto.db -tar -xzf /tmp/crypto_backups/crypto_monitor_okx/2026-05-21/static_images.tar.gz -C crypto_monitor_okx/ -# 若 tar 里是 static/images 目录结构,确认解压后路径为 crypto_monitor_okx/static/images - -# 对其余三所重复同样步骤 -``` - -恢复中控设置: - -```bash -cp /tmp/hub_settings.json manual_trading_hub/hub_settings.json -# 或解压备份里带的 hub_settings.json -``` - -**权限**(避免 Flask 写库失败): - -```bash -sudo chown -R 运行用户:运行用户 /opt/crypto_monitor/crypto_monitor_*/crypto.db -sudo chown -R 运行用户:运行用户 /opt/crypto_monitor/crypto_monitor_*/static/images -``` - ---- - -## 六、云上必须改的配置(比迁移本身更重要) - -数据文件原样拷过去不够,**.env 与 hub_settings 要按云环境改**。 - -### 1. 各实例 `crypto_monitor_*/.env` - -从本机**抄写** API 密钥等,并调整: - -| 项 | 本地常见 | 云上建议 | -|----|----------|----------| -| `OKX_SOCKS_PROXY` 等 | `socks5h://127.0.0.1:1080` | **留空**(直连),除非云上仍访问不了交易所 | -| `APP_AUTH_DISABLED` | 可能为 true(本机) | **false** 或未设置 | -| `APP_USERNAME` / `APP_PASSWORD` | 可有 | 设统一强密码(直链登录) | -| `HUB_BRIDGE_TOKEN` | 有 | 与中控 **完全一致** | - -### 2. `manual_trading_hub/.env` - -见 [云服务器部署说明.md](./云服务器部署说明.md):`HUB_PASSWORD`、`HUB_BRIDGE_TOKEN`、`HUB_COOKIE_SECURE=true` 等。 - -### 3. `hub_settings.json` 里的 URL - -**必须**改成浏览器能打开的地址: - -| 字段 | 云上 | -|------|------| -| `flask_url` | `https://okx.你的域名.com`(每实例不同子域) | -| `agent_url` | `http://127.0.0.1:15201`(保持本机,勿写公网 IP) | - -本机若是 `http://192.168.x.x:5004` 或 `http://127.0.0.1:5004`,上云后**一定要改**,否则「打开实例」会指错地址。 - ---- - -## 七、云上启动与验收 - -```bash -# 依赖(各目录 venv + manual_trading_hub) -# 见 云服务器部署说明.md、部署文档.md - -cd /opt/crypto_monitor -# 先四实例 Flask,再 manual_trading_hub ecosystem -pm2 start ... -pm2 save -``` - -验收: - -- [ ] 各实例网页能登录,**交易记录 / 关键位 / 监控单** 与本地一致 -- [ ] 复盘图片能显示(`static/images` 路径正确) -- [ ] 中控监控卡片能读到持仓;`hub_settings` 账户 URL 正确 -- [ ] 本机已 **停止** 或不再用同一 API Key 同时跑两套(避免重复下单) - ---- - -## 八、迁移策略建议 - -### 方案 A:一次性切换(简单) - -1. 本地停 PM2 → 备份 → 上传 → 云上恢复 → 改配置 → 只跑云端。 -2. 适合能接受 **短暂停机**(几十分钟)。 - -### 方案 B:先云后停本地(稳一点) - -1. 云上先部署代码、空库跑通; -2. 临近切换时再备份本地**最新**库覆盖云上; -3. 切换时刻停本地、启云上。 -4. 减少「备份到上线」之间的数据空窗。 - -### 注意 - -- **同一交易所 API Key 不要本地和云上同时自动交易**,以免重复挂单。 -- 迁移后第一次在云上打开,建议先看监控单、持仓是否与预期一致,再放开自动逻辑。 - ---- - -## 九、常见问题 - -**Q:只拷 `crypto.db` 不够吗?** -- 复盘、上传相关功能还依赖 `static/images`;建议库 + 图片一起迁。 - -**Q:迁移后 OKX 监控单没了?** -- 查是否拷错目录(四所各一个库)、或恢复后用了空库路径(`DB_PATH` 不一致)。 - -**Q:图片 404?** -- 检查 `static/images` 是否解压到实例目录下;数据库里路径若为相对路径,一般与目录结构一致即可。 - -**Q:本地还用 SOCKS,云上要不要?** -- 云上通常 **不需要** SSH 隧道;见 [云服务器部署说明.md](./云服务器部署说明.md) 与此前说明:直连稳定后去掉 `*_SOCKS_PROXY`。 - ---- - -## 十、相关脚本 - -各实例目录: - -```bash -bash scripts/backup_data.sh -``` - -环境变量:`BACKUP_ROOT`、`BACKUP_RETENTION_DAYS`、`BACKUP_INSTANCE`(见脚本内注释)。 +# 本地数据备份与迁移到云服务器 + +本文说明如何把 **本机** 上运行的 `crypto_monitor`(三实例 + 中控)的**业务数据**迁到 **云 VPS**,并正确改配置。 +**不迁移** 本机 Python 虚拟环境(`.venv`),云上重新 `pip install` 即可。 + +相关:[云服务器部署说明.md](./云服务器部署说明.md) · [部署文档.md](./部署文档.md) + +--- + +## 一、要迁什么、不迁什么 + +### 必须迁移(业务数据) + +| 路径(每个实例目录下) | 内容 | +|------------------------|------| +| `crypto.db`(或 `.env` 里 `DB_PATH` 指向的文件) | 监控单、关键位、交易记录、复盘、运行时开关等 **SQLite 全库** | +| `static/images/`(或 `UPLOAD_DIR`) | 上传图、复盘截图等 | +| `static/images/order_charts/`(或 `ORDER_CHART_DIR`) | 订单 K 线图(若开启) | + +三个实例 **各有一份独立库**: + +- `crypto_monitor_binance/crypto.db` +- `crypto_monitor_okx/crypto.db` +- `crypto_monitor_gate/crypto.db` +- `crypto_monitor_gate/crypto.db` + +### 中控额外迁移 + +| 路径 | 内容 | +|------|------| +| `manual_trading_hub/hub_settings.json` | 账户 URL、启用状态、能力勾选(网页「系统设置」保存的文件) | +| `manual_trading_hub/hub_ai_summaries.json` | 中控 AI 今日总结(`/ai`) | +| `manual_trading_hub/hub_ai_chat.json` | 中控 AI 聊天会话 | + +### 不要直接覆盖拷贝(需在云上重写) + +| 文件 | 说明 | +|------|------| +| 各目录 `.env` | 含 API 密钥:可在云上**手工新建**,从本机抄密钥,但须改 **`flask_url`、代理、公网相关项**(见下文) | +| `.venv/`、`__pycache__/` | 云上重建 | +| PM2 日志 | 无需迁 | + +### 可选 + +- 本机 `manual_trading_hub/.env` 里的 `HUB_BRIDGE_TOKEN`、`HUB_PASSWORD` 等:记下后在云上填入,**不要**把含密钥的 `.env` 发到公开网盘。 + +--- + +## 二、迁移前准备(本地) + +### 1. 停服务(避免数据库半写入) + +```bash +# 本机:停中控与子代理 +cd manual_trading_hub +pm2 stop manual-trading-hub manual-agent-binance manual-agent-okx manual-agent-gate + +# 本机:停三个 Flask(进程名以你 pm2 list 为准) +pm2 stop crypto_okx crypto_binance crypto_gate +# 或各目录 ecosystem 里的名字 +``` + +未用 PM2 时,结束对应 Python/Flask 进程后再备份。 + +### 2. 确认数据库文件位置 + +各实例目录下查看 `.env` 中 `DB_PATH`(默认 `crypto.db`)。若存在 `crypto.db-wal`、`crypto.db-shm`,**必须先停服务** 再备份。 + +--- + +## 三、本地备份(推荐用自带脚本) + +每个实例目录执行(会备份 **库 + static/images**): + +```bash +cd crypto_monitor_okx +bash scripts/backup_data.sh +# 默认输出到 /root/backups/crypto_monitor_okx/YYYY-MM-DD/ +# 本机可改环境变量:BACKUP_ROOT=~/crypto_backups bash scripts/backup_data.sh +``` + +对 `crypto_monitor_binance`、`crypto_monitor_gate`、`crypto_monitor_gate` **各执行一次**。 + +脚本产物示例: + +```text +~/crypto_backups/crypto_monitor_okx/2026-05-21/ + crypto.db + static_images.tar.gz + manifest.txt +``` + +### 手工打包(不用脚本时) + +在仓库根目录示例: + +```bash +BACKUP=~/crypto_migrate_$(date +%Y%m%d) +mkdir -p "$BACKUP" + +for dir in crypto_monitor_okx crypto_monitor_binance crypto_monitor_gate crypto_monitor_gate; do + tar -czf "$BACKUP/${dir}.tar.gz" \ + -C "$dir" crypto.db static/images 2>/dev/null || \ + tar -czf "$BACKUP/${dir}.tar.gz" -C "$dir" crypto.db +done + +cp manual_trading_hub/hub_settings.json "$BACKUP/" 2>/dev/null || true +cp manual_trading_hub/hub_ai_summaries.json "$BACKUP/" 2>/dev/null || true +cp manual_trading_hub/hub_ai_chat.json "$BACKUP/" 2>/dev/null || true +``` + +--- + +## 四、上传到云服务器 + +在**你电脑**上(把 `USER`、`云IP` 换成实际值): + +```bash +# 打包整个备份目录 +tar -czf crypto_migrate.tar.gz -C ~ crypto_backups # 或你的 BACKUP 路径 + +scp crypto_migrate.tar.gz USER@云IP:/tmp/ +scp manual_trading_hub/hub_settings.json USER@云IP:/tmp/ # 若单独备份 +``` + +大文件可用 **rsync**(支持断点续传): + +```bash +rsync -avz --progress ~/crypto_backups/ USER@云IP:/tmp/crypto_backups/ +``` + +--- + +## 五、云上恢复数据 + +假设代码已在 `/opt/crypto_monitor`(`git clone` 或 `rsync` 代码均可,**代码与数据分开**)。 + +```bash +ssh USER@云IP +cd /opt/crypto_monitor + +# 解压(若用 scp 单包) +tar -xzf /tmp/crypto_migrate.tar.gz -C /tmp + +# 按实例恢复(示例:OKX) +pm2 stop crypto_okx 2>/dev/null || true +cp /tmp/crypto_backups/crypto_monitor_okx/2026-05-21/crypto.db crypto_monitor_okx/crypto.db +tar -xzf /tmp/crypto_backups/crypto_monitor_okx/2026-05-21/static_images.tar.gz -C crypto_monitor_okx/ +# 若 tar 里是 static/images 目录结构,确认解压后路径为 crypto_monitor_okx/static/images + +# 对其余三所重复同样步骤 +``` + +恢复中控设置: + +```bash +cp /tmp/hub_settings.json manual_trading_hub/hub_settings.json +# 或解压备份里带的 hub_settings.json +``` + +**权限**(避免 Flask 写库失败): + +```bash +sudo chown -R 运行用户:运行用户 /opt/crypto_monitor/crypto_monitor_*/crypto.db +sudo chown -R 运行用户:运行用户 /opt/crypto_monitor/crypto_monitor_*/static/images +``` + +--- + +## 六、云上必须改的配置(比迁移本身更重要) + +数据文件原样拷过去不够,**.env 与 hub_settings 要按云环境改**。 + +### 1. 各实例 `crypto_monitor_*/.env` + +从本机**抄写** API 密钥等,并调整: + +| 项 | 本地常见 | 云上建议 | +|----|----------|----------| +| `OKX_SOCKS_PROXY` 等 | `socks5h://127.0.0.1:1080` | **留空**(直连),除非云上仍访问不了交易所 | +| `APP_AUTH_DISABLED` | 可能为 true(本机) | **false** 或未设置 | +| `APP_USERNAME` / `APP_PASSWORD` | 可有 | 设统一强密码(直链登录) | +| `HUB_BRIDGE_TOKEN` | 有 | 与中控 **完全一致** | + +### 2. `manual_trading_hub/.env` + +见 [云服务器部署说明.md](./云服务器部署说明.md):`HUB_PASSWORD`、`HUB_BRIDGE_TOKEN`、`HUB_COOKIE_SECURE=true` 等。 + +### 3. `hub_settings.json` 里的 URL + +**必须**改成浏览器能打开的地址: + +| 字段 | 云上 | +|------|------| +| `flask_url` | `https://okx.你的域名.com`(每实例不同子域) | +| `agent_url` | `http://127.0.0.1:15201`(保持本机,勿写公网 IP) | + +本机若是 `http://192.168.x.x:5004` 或 `http://127.0.0.1:5004`,上云后**一定要改**,否则「打开实例」会指错地址。 + +--- + +## 七、云上启动与验收 + +```bash +# 依赖(各目录 venv + manual_trading_hub) +# 见 云服务器部署说明.md、部署文档.md + +cd /opt/crypto_monitor +# 先三实例 Flask,再 manual_trading_hub ecosystem +pm2 start ... +pm2 save +``` + +验收: + +- [ ] 各实例网页能登录,**交易记录 / 关键位 / 监控单** 与本地一致 +- [ ] 复盘图片能显示(`static/images` 路径正确) +- [ ] 中控监控卡片能读到持仓;`hub_settings` 账户 URL 正确 +- [ ] 本机已 **停止** 或不再用同一 API Key 同时跑两套(避免重复下单) + +--- + +## 八、迁移策略建议 + +### 方案 A:一次性切换(简单) + +1. 本地停 PM2 → 备份 → 上传 → 云上恢复 → 改配置 → 只跑云端。 +2. 适合能接受 **短暂停机**(几十分钟)。 + +### 方案 B:先云后停本地(稳一点) + +1. 云上先部署代码、空库跑通; +2. 临近切换时再备份本地**最新**库覆盖云上; +3. 切换时刻停本地、启云上。 +4. 减少「备份到上线」之间的数据空窗。 + +### 注意 + +- **同一交易所 API Key 不要本地和云上同时自动交易**,以免重复挂单。 +- 迁移后第一次在云上打开,建议先看监控单、持仓是否与预期一致,再放开自动逻辑。 + +--- + +## 九、常见问题 + +**Q:只拷 `crypto.db` 不够吗?** +- 复盘、上传相关功能还依赖 `static/images`;建议库 + 图片一起迁。 + +**Q:迁移后 OKX 监控单没了?** +- 查是否拷错目录(三所各一个库)、或恢复后用了空库路径(`DB_PATH` 不一致)。 + +**Q:图片 404?** +- 检查 `static/images` 是否解压到实例目录下;数据库里路径若为相对路径,一般与目录结构一致即可。 + +**Q:本地还用 SOCKS,云上要不要?** +- 云上通常 **不需要** SSH 隧道;见 [云服务器部署说明.md](./云服务器部署说明.md) 与此前说明:直连稳定后去掉 `*_SOCKS_PROXY`。 + +--- + +## 十、相关脚本 + +各实例目录: + +```bash +bash scripts/backup_data.sh +``` + +环境变量:`BACKUP_ROOT`、`BACKUP_RETENTION_DAYS`、`BACKUP_INSTANCE`(见脚本内注释)。 diff --git a/manual_trading_hub/行情区说明.md b/manual_trading_hub/行情区说明.md index ab1ad58..e3d2fa2 100644 --- a/manual_trading_hub/行情区说明.md +++ b/manual_trading_hub/行情区说明.md @@ -1,130 +1,130 @@ -# 行情区(K 线)说明 - -中控 **行情区** `/market` 提供多交易所 K 线查看:按需拉取、本地 SQLite 缓存、可选技术指标与持仓价格线。数据经各实例 Flask 的 `/api/hub/ohlcv`(底层 `hub_ohlcv_lib` + ccxt)获取。 - -相关代码:`manual_trading_hub/static/chart.js`、`hub_kline_store.py`(仓库根目录)、`hub.py` 的 `/api/chart/*`。 - ---- - -## 1. 入口与导航 - -| 方式 | 说明 | -|------|------| -| 顶栏 **行情区** | 打开 `/market` | -| 监控区持仓 | 点击合约名(**打开行情区**)→ 跳转 `/market?exchange_key=...&symbol=...`,并带入入场/止损/止盈等标记(`sessionStorage`) | -| 全屏工具条 | K 线全屏时可在顶部切换交易所、币种、周期并 **加载** | - ---- - -## 2. 支持的周期 - -下拉框与后端 `CHART_TIMEFRAMES` 一致: - -| 周期 | 数字快捷键(分钟) | -|------|-------------------| -| 1m | `1`(稍停或 Enter 确认;连按 `1`→`5` 为 15m) | -| 5m | `5` | -| 15m | `15` | -| 1h | `60` | -| 2h | `120` | -| 4h | `240` | -| 12h | `720` | -| 1d | `1440` | -| 1w | `10080` | - -- 快捷键仅在行情页、且焦点不在输入框/下拉框时生效。 -- **全屏**:按 **`F`** 切换;全屏时 **`Esc`** 退出。 -- 无效或已移除的周期(如 URL 带 `6h`)会回退为默认 **5m**。 - ---- - -## 3. 数据拉取与本地库 - -| 项 | 说明 | -|------|------| -| **策略** | 先读本地库,不足或过期则向对应实例拉取并写入库;Hub **后台轮询** 增量更新尾部 K 线 | -| **库文件** | 默认 `manual_trading_hub/data/hub_kline.db`(不纳入 Git) | -| **保留** | 默认 **15 天**(`HUB_KLINE_RETENTION_DAYS`),每次请求顺带清理更早数据 | -| **根数** | 日内周期约 **1000** 根;`1d` / `1w` 约 **500** 根 | -| **刷新** | Hub 约 **5 秒** 轮询:① 监控区**有持仓**的合约(默认周期 `5m`)② 行情页 **watch** 的交易所+币种+周期(页面打开时每 25s 续期)。浏览器经 **SSE** 收 `chart_version` 后拉 `/api/chart/ohlcv`。**加载** 读库;**强制刷新** 全量重拉 | -| **分页** | OKX/Gate 等单次常限 ~300 根,中控会自动分页补全 | -| **12h** | 若交易所无原生 12h 或 K 线间隔异常,会从 **1h** 聚合生成 | - -环境变量(`manual_trading_hub/.env`): - -```bash -# HUB_KLINE_RETENTION_DAYS=15 -# HUB_KLINE_DB_PATH=/opt/crypto_monitor/manual_trading_hub/data/hub_kline.db -# HUB_CHART_POLL_INTERVAL=5 -# HUB_CHART_POSITION_TIMEFRAME=5m -# HUB_CHART_WATCH_TTL_SEC=45 -``` - ---- - -## 4. 图表功能 - -- **主图**:K 线 + 成交量(Lightweight Charts)。 -- **价格轴**:「自动」切换是否跟随最新价缩放。 -- **技术指标**(可选勾选):EMA 21/55、MACD、RSI(含 30/70 参考线);副图自上而下为 MACD、RSI。 -- **持仓标记**(从监控跳转时):展示入场、止损、止盈、张数、**浮盈亏**(约 5 秒随监控快照刷新)、委托摘要;K 线上绘制对应价格线。趋势回调若止盈为程序监控,止盈栏显示「程序监控」且不与止损同价误显。 -- **趋势保本移交**:移交到下单监控后,持仓卡止盈/止损与「交易所止盈止损」与实例 **下单监控** 计划价一致(不再清空为程序监控占位);交易所仅市价只减仓单时也会按价格推断展示。 -- **拖动止损线**:鼠标靠近红色止损线(⟷)可上下拖动;松手确认后调用与监控区相同的 **挂止盈/止损** API(先撤全部条件单再挂新止损+止盈)。须已有有效止盈价(交易所条件单或计划止盈);仅改止损、不改止盈时止盈价沿用当前上下文。 -- **背离**:MACD/RSI 与价格简易背离标注(箭头 + 图例说明)。 - ---- - -## 5. HTTP API(中控) - -须登录(与监控区相同,`/api/ping` 等白名单除外)。 - -| 方法 | 路径 | 说明 | -|------|------|------| -| GET | `/api/chart/meta` | 已启用交易所列表、周期列表、各周期 limit、保留天数 | -| GET | `/api/chart/ohlcv` | 查询参数:`exchange_key`、`symbol`、`timeframe`、可选 `refresh=1` 强制刷新 | -| POST | `/api/chart/watch` | 行情页订阅(JSON:`exchange_key`、`symbol`、`timeframe`),45s 内需续期 | -| POST | `/api/chart/unwatch` | 离开行情页取消订阅 | -| GET | `/api/chart/stream` | SSE:`event: chart`,含 `chart_version` 与各 `series` 版本 | -| GET | `/api/chart/poll/meta` | 当前轮询状态与各 series 版本 | - -实例侧(中控转发): - -| 路径 | 说明 | -|------|------| -| GET | `/api/hub/ohlcv` | 各 `crypto_monitor_*` 经 `hub_bridge` 注册;参数 `symbol`、`timeframe`、`since_ms`、`limit` | - ---- - -## 6. 部署与升级注意 - -1. **hub** 与 **四实例 Flask** 均需 `git pull` 到含 `hub_ohlcv_lib.py`、`hub_kline_store.py` 的版本。 -2. 重启:`pm2 restart manual-trading-hub` 及 `crypto_binance`、`crypto_okx`、`crypto_gate`、`crypto_gate_bot`(名称以你环境为准)。 -3. 浏览器 **强刷**(`chart.js` 带版本 query,避免旧前端缓存)。 -4. 周期或拉取逻辑升级后,对异常图表点一次 **强制刷新**,必要时可删 `data/hub_kline.db` 后重拉(会丢失本地缓存,不影响策略库)。 - -回滚标签说明见 [SNAPSHOT_ROLLBACK.md](./SNAPSHOT_ROLLBACK.md)。 - ---- - -## 7. 常见问题 - -| 现象 | 处理 | -|------|------| -| 只显示约 300 根 | `git pull` 实例与 hub,强制刷新;确认 `hub_ohlcv_lib` 已含分页逻辑 | -| 12h 错乱或过少 | 强制刷新;Gate 等无原生 12h 时依赖 1h 聚合,需实例 OHLCV 正常 | -| 周期下拉无某项 | 以当前 `CHART_TIMEFRAMES` 为准;已移除 3m/10m/20m/30m/6h/8h 等 | -| 快捷键无效 | 确认在行情页;全屏用 **F**;数字键勿在币种输入框内按 | -| 持仓线不显示 | 须从监控区点击合约进入;或清除标记后重新跳转 | - -更多中控共性问题见 [常见问题.md](./常见问题.md)。 - ---- - -## 8. 文档索引 - -| 文档 | 内容 | -|------|------| -| [使用说明.md](./使用说明.md) | 中控总览(含行情区摘要) | -| [行情区说明.md](./行情区说明.md) | 本文 | -| [部署文档.md](./部署文档.md) | PM2 / 反代 / 验收 | -| [.env.example](./.env.example) | `HUB_KLINE_*` 等变量 | +# 行情区(K 线)说明 + +中控 **行情区** `/market` 提供多交易所 K 线查看:按需拉取、本地 SQLite 缓存、可选技术指标与持仓价格线。数据经各实例 Flask 的 `/api/hub/ohlcv`(底层 `hub_ohlcv_lib` + ccxt)获取。 + +相关代码:`manual_trading_hub/static/chart.js`、`hub_kline_store.py`(仓库根目录)、`hub.py` 的 `/api/chart/*`。 + +--- + +## 1. 入口与导航 + +| 方式 | 说明 | +|------|------| +| 顶栏 **行情区** | 打开 `/market` | +| 监控区持仓 | 点击合约名(**打开行情区**)→ 跳转 `/market?exchange_key=...&symbol=...`,并带入入场/止损/止盈等标记(`sessionStorage`) | +| 全屏工具条 | K 线全屏时可在顶部切换交易所、币种、周期并 **加载** | + +--- + +## 2. 支持的周期 + +下拉框与后端 `CHART_TIMEFRAMES` 一致: + +| 周期 | 数字快捷键(分钟) | +|------|-------------------| +| 1m | `1`(稍停或 Enter 确认;连按 `1`→`5` 为 15m) | +| 5m | `5` | +| 15m | `15` | +| 1h | `60` | +| 2h | `120` | +| 4h | `240` | +| 12h | `720` | +| 1d | `1440` | +| 1w | `10080` | + +- 快捷键仅在行情页、且焦点不在输入框/下拉框时生效。 +- **全屏**:按 **`F`** 切换;全屏时 **`Esc`** 退出。 +- 无效或已移除的周期(如 URL 带 `6h`)会回退为默认 **5m**。 + +--- + +## 3. 数据拉取与本地库 + +| 项 | 说明 | +|------|------| +| **策略** | 先读本地库,不足或过期则向对应实例拉取并写入库;Hub **后台轮询** 增量更新尾部 K 线 | +| **库文件** | 默认 `manual_trading_hub/data/hub_kline.db`(不纳入 Git) | +| **保留** | 默认 **15 天**(`HUB_KLINE_RETENTION_DAYS`),每次请求顺带清理更早数据 | +| **根数** | 日内周期约 **1000** 根;`1d` / `1w` 约 **500** 根 | +| **刷新** | Hub 约 **5 秒** 轮询:① 监控区**有持仓**的合约(默认周期 `5m`)② 行情页 **watch** 的交易所+币种+周期(页面打开时每 25s 续期)。浏览器经 **SSE** 收 `chart_version` 后拉 `/api/chart/ohlcv`。**加载** 读库;**强制刷新** 全量重拉 | +| **分页** | OKX/Gate 等单次常限 ~300 根,中控会自动分页补全 | +| **12h** | 若交易所无原生 12h 或 K 线间隔异常,会从 **1h** 聚合生成 | + +环境变量(`manual_trading_hub/.env`): + +```bash +# HUB_KLINE_RETENTION_DAYS=15 +# HUB_KLINE_DB_PATH=/opt/crypto_monitor/manual_trading_hub/data/hub_kline.db +# HUB_CHART_POLL_INTERVAL=5 +# HUB_CHART_POSITION_TIMEFRAME=5m +# HUB_CHART_WATCH_TTL_SEC=45 +``` + +--- + +## 4. 图表功能 + +- **主图**:K 线 + 成交量(Lightweight Charts)。 +- **价格轴**:「自动」切换是否跟随最新价缩放。 +- **技术指标**(可选勾选):EMA 21/55、MACD、RSI(含 30/70 参考线);副图自上而下为 MACD、RSI。 +- **持仓标记**(从监控跳转时):展示入场、止损、止盈、张数、**浮盈亏**(约 5 秒随监控快照刷新)、委托摘要;K 线上绘制对应价格线。趋势回调若止盈为程序监控,止盈栏显示「程序监控」且不与止损同价误显。 +- **趋势保本移交**:移交到下单监控后,持仓卡止盈/止损与「交易所止盈止损」与实例 **下单监控** 计划价一致(不再清空为程序监控占位);交易所仅市价只减仓单时也会按价格推断展示。 +- **拖动止损线**:鼠标靠近红色止损线(⟷)可上下拖动;松手确认后调用与监控区相同的 **挂止盈/止损** API(先撤全部条件单再挂新止损+止盈)。须已有有效止盈价(交易所条件单或计划止盈);仅改止损、不改止盈时止盈价沿用当前上下文。 +- **背离**:MACD/RSI 与价格简易背离标注(箭头 + 图例说明)。 + +--- + +## 5. HTTP API(中控) + +须登录(与监控区相同,`/api/ping` 等白名单除外)。 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/chart/meta` | 已启用交易所列表、周期列表、各周期 limit、保留天数 | +| GET | `/api/chart/ohlcv` | 查询参数:`exchange_key`、`symbol`、`timeframe`、可选 `refresh=1` 强制刷新 | +| POST | `/api/chart/watch` | 行情页订阅(JSON:`exchange_key`、`symbol`、`timeframe`),45s 内需续期 | +| POST | `/api/chart/unwatch` | 离开行情页取消订阅 | +| GET | `/api/chart/stream` | SSE:`event: chart`,含 `chart_version` 与各 `series` 版本 | +| GET | `/api/chart/poll/meta` | 当前轮询状态与各 series 版本 | + +实例侧(中控转发): + +| 路径 | 说明 | +|------|------| +| GET | `/api/hub/ohlcv` | 各 `crypto_monitor_*` 经 `hub_bridge` 注册;参数 `symbol`、`timeframe`、`since_ms`、`limit` | + +--- + +## 6. 部署与升级注意 + +1. **hub** 与 **三实例 Flask** 均需 `git pull` 到含 `hub_ohlcv_lib.py`、`hub_kline_store.py` 的版本。 +2. 重启:`pm2 restart manual-trading-hub` 及 `crypto_binance`、`crypto_okx`、`crypto_gate`、``(名称以你环境为准)。 +3. 浏览器 **强刷**(`chart.js` 带版本 query,避免旧前端缓存)。 +4. 周期或拉取逻辑升级后,对异常图表点一次 **强制刷新**,必要时可删 `data/hub_kline.db` 后重拉(会丢失本地缓存,不影响策略库)。 + +回滚标签说明见 [SNAPSHOT_ROLLBACK.md](./SNAPSHOT_ROLLBACK.md)。 + +--- + +## 7. 常见问题 + +| 现象 | 处理 | +|------|------| +| 只显示约 300 根 | `git pull` 实例与 hub,强制刷新;确认 `hub_ohlcv_lib` 已含分页逻辑 | +| 12h 错乱或过少 | 强制刷新;Gate 等无原生 12h 时依赖 1h 聚合,需实例 OHLCV 正常 | +| 周期下拉无某项 | 以当前 `CHART_TIMEFRAMES` 为准;已移除 3m/10m/20m/30m/6h/8h 等 | +| 快捷键无效 | 确认在行情页;全屏用 **F**;数字键勿在币种输入框内按 | +| 持仓线不显示 | 须从监控区点击合约进入;或清除标记后重新跳转 | + +更多中控共性问题见 [常见问题.md](./常见问题.md)。 + +--- + +## 8. 文档索引 + +| 文档 | 内容 | +|------|------| +| [使用说明.md](./使用说明.md) | 中控总览(含行情区摘要) | +| [行情区说明.md](./行情区说明.md) | 本文 | +| [部署文档.md](./部署文档.md) | PM2 / 反代 / 验收 | +| [.env.example](./.env.example) | `HUB_KLINE_*` 等变量 | diff --git a/manual_trading_hub/资金概况说明.md b/manual_trading_hub/资金概况说明.md index f00e4bc..b0049bd 100644 --- a/manual_trading_hub/资金概况说明.md +++ b/manual_trading_hub/资金概况说明.md @@ -1,6 +1,6 @@ # 资金概况 — 使用说明 -中控顶栏 **资金概况**(`/funds`)汇总四所账户的 **资金账户 + 交易账户** 余额,不含浮盈亏;未监控账户不参与合计,但仍会在分户列表中灰显展示。 +中控顶栏 **资金概况**(`/funds`)汇总三所账户的 **资金账户 + 交易账户** 余额,不含浮盈亏;未监控账户不参与合计,但仍会在分户列表中灰显展示。 --- @@ -12,7 +12,7 @@ | **总资金** | 所有 **已启用且未被环境强制关闭** 的账户之和 | | **未监控** | 设置页未勾选「启用」或 `HUB_DISABLED_IDS` 强制关闭 → **跳过合计** | | **缺数据** | 资金户、交易户任一侧缺失 → 该户当日快照 **跳过**(不估、不补 0) | -| **交易日** | 北京时间 `TRADING_DAY_RESET_HOUR`(默认 **8:00**)切日,与四所统计一致 | +| **交易日** | 北京时间 `TRADING_DAY_RESET_HOUR`(默认 **8:00**)切日,与三所统计一致 | | **曲线粒度** | 每个交易日 **1 个点** | | **统计起点** | 默认 **2026-06-09**(`HUB_FUND_HISTORY_START_DAY`);此前不记、不展示 | | **历史保留** | 自起点起最多 **180** 个交易日(`HUB_FUND_HISTORY_DAYS`) | @@ -66,7 +66,7 @@ |------|------|------| | `HUB_FUND_HISTORY_DAYS` | `180` | 资金快照保留交易日数(与起点取较晚边界) | | `HUB_FUND_HISTORY_START_DAY` | `2026-06-09` | 曲线/回撤统计起始交易日 | -| `TRADING_DAY_RESET_HOUR` | `8` | 切日整点(北京),与四所 `.env` 建议一致 | +| `TRADING_DAY_RESET_HOUR` | `8` | 切日整点(北京),与三所 `.env` 建议一致 | | `HUB_BOARD_POLL_INTERVAL` | `5` | 监控聚合间隔(秒),影响快照刷新频率 | --- diff --git a/manual_trading_hub/部署文档.md b/manual_trading_hub/部署文档.md index 845ad8b..6b80fb0 100644 --- a/manual_trading_hub/部署文档.md +++ b/manual_trading_hub/部署文档.md @@ -16,11 +16,11 @@ | 组件 | 作用 | 默认监听 | |------|------|----------| | **hub.py** | 中控 Web + API | `0.0.0.0:5100` | -| **agent.py × N** | 各账户持仓 / 紧急全平 | `127.0.0.1:15200`~`15203` | +| **agent.py × N** | 各账户持仓 / 紧急全平 | `127.0.0.1:15200`~`15202` | | **crypto_monitor_*.app** | 策略、关键位、下单逻辑 | 各目录 `.env` 的 `APP_PORT` | - 账户列表与 URL 由 **`hub_settings.json`**(网页「系统设置」保存)或内置默认维护;**不再使用** `HUB_AGENTS`。 -- 四实例 Flask **无需为中控改业务代码**(已注册 `hub_bridge`);与中控并行运行。 +- 三实例 Flask **无需为中控改业务代码**(已注册 `hub_bridge`);与中控并行运行。 --- @@ -29,7 +29,7 @@ 1. **Python 3.10+**、`python3-venv`、`pip`。 2. **Node.js + npm**(用于安装 PM2):`sudo npm i -g pm2`。 3. 各 `crypto_monitor_*` 目录已 **`cp .env.example .env`** 并填好 API 密钥。 -4. 端口无冲突:`5100`、`15200`~`15203`、各实例 `APP_PORT`(5000/5001/5002/5004)。 +4. 端口无冲突:`5100`、`15200`~`15202`、各实例 `APP_PORT`(5000/5001/5004)。 5. 建议代码路径:`/opt/crypto_monitor/`(下文用此示例,请按实际路径替换)。 --- @@ -64,10 +64,10 @@ deactivate # 可选;交给 PM2 时不必保持激活 ``` 1. 各实例 Flask(APP_PORT) ← 各 crypto_monitor_* 目录 ecosystem.config.cjs -2. 中控 + 子代理(5100 + 15200~15203) ← 本目录一条 PM2 命令同时启动 +2. 中控 + 子代理(5100 + 15200~15202) ← 本目录一条 PM2 命令同时启动 ``` -**`ecosystem.config.cjs` 会一次拉起 4 个 agent + 1 个 hub**,无需再单独 `pm2 start` 子代理。 +**`ecosystem.config.cjs` 会一次拉起 3 个 agent + 1 个 hub**,无需再单独 `pm2 start` 子代理。 仅反代中控到公网时:Flask / agent 仍只监听 **127.0.0.1**;系统设置里 URL 填 `http://127.0.0.1:端口`。 @@ -79,7 +79,7 @@ deactivate # 可选;交给 PM2 时不必保持激活 | 文件 | 包含进程 | |------|----------| -| `ecosystem.config.cjs` | `manual-agent-binance` / `okx` / `gate` / `gate-bot` + **`manual-trading-hub`** | +| `ecosystem.config.cjs` | `manual-agent-binance` / `okx` / `gate` + **`manual-trading-hub`** | `run_hub.sh` 加载 **`manual_trading_hub/.env`** 后执行 `hub.py`;各 agent 经 **`run_agent.sh`** 在对应策略目录加载 **`.env`**(含 API 密钥),再执行 `agent.py`。 @@ -89,7 +89,7 @@ source .venv/bin/activate pip install -r requirements.txt cp .env.example .env -pm2 start ecosystem.config.cjs # 5 个进程一起起 +pm2 start ecosystem.config.cjs # 4 个进程一起起 pm2 save # 或 @@ -103,17 +103,16 @@ bash scripts/pm2_hub.sh start | manual-agent-binance | crypto_monitor_binance | agent `15200` | | manual-agent-okx | crypto_monitor_okx | agent `15201` | | manual-agent-gate | crypto_monitor_gate | agent `15202` | -| manual-agent-gate-bot | crypto_monitor_gate_bot | agent `15203` | | manual-trading-hub | manual_trading_hub | hub `5100` | -OKX 子代理会启动,但中控默认 `HUB_DISABLED_IDS=1` 不参与监控;不用 OKX 可 `pm2 stop manual-agent-okx`。 +OKX 子代理会启动;不用 OKX 可 `pm2 stop manual-agent-okx`。 ### 5.3 常用运维命令 ```bash pm2 status pm2 logs manual-trading-hub --lines 200 -pm2 restart ecosystem.config.cjs # 重启 hub + 全部 agent +pm2 restart ecosystem.config.cjs # 重启 hub + 全部 agent bash scripts/pm2_hub.sh restart # 同上 bash scripts/pm2_hub.sh stop @@ -129,7 +128,7 @@ pm2 restart manual-trading-hub 仅重启子代理: ```bash -pm2 restart manual-agent-binance manual-agent-gate manual-agent-gate-bot +pm2 restart manual-agent-binance manual-agent-gate manual-agent-okx # 或 bash scripts/pm2_agents.sh restart ``` @@ -153,25 +152,24 @@ pm2 status ### 5.6 Gate 子代理「一会能连、一会子代理不可用」(Windows `.env` 换行) -**现象**:仅 Gate 训练 / Gate 趋势卡片红字「子代理不可用」;`pm2 logs manual-agent-gate` 反复出现: +**现象**:Gate 卡片红字「子代理不可用」;`pm2 logs manual-agent-gate` 反复出现: ```text ./.env: line 22: $'\r': command not found agent start: exchange=gate port=15202 ... ``` -**原因**:在 Windows 编辑的 `crypto_monitor_gate/.env`、`crypto_monitor_gate_bot/.env` 为 **CRLF**,Linux 上 `source` 失败;PM2 反复重启,中控轮询时偶发连不上(**不是外网问题**)。 +**原因**:在 Windows 编辑的 `crypto_monitor_gate/.env` 为 **CRLF**,Linux 上 `source` 失败;PM2 反复重启,中控轮询时偶发连不上(**不是外网问题**)。 **处理**(在服务器仓库根执行): ```bash cd /opt/crypto_monitor -sed -i 's/\r$//' crypto_monitor_gate/.env crypto_monitor_gate_bot/.env +sed -i 's/\r$//' crypto_monitor_gate/.env bash manual_trading_hub/scripts/fix_env_crlf.sh cd manual_trading_hub -pm2 delete manual-agent-gate manual-agent-gate-bot 2>/dev/null || true +pm2 delete manual-agent-gate 2>/dev/null || true pm2 start ecosystem.config.cjs --only manual-agent-gate -pm2 start ecosystem.config.cjs --only manual-agent-gate-bot pm2 save curl -s http://127.0.0.1:15202/status | head -c 200 # 应 ok:true ``` @@ -200,7 +198,7 @@ bash scripts/run_hub.sh 1. **http://127.0.0.1:5100/login** — 若 `.env` 已设 `HUB_PASSWORD`,用 `HUB_USERNAME` / `HUB_PASSWORD` 登录。 2. **http://127.0.0.1:5100/monitor** — 已启用账户显示持仓;Flask 已起时有关键位/趋势信息。 3. **http://127.0.0.1:5100/market** — 行情区可选交易所与周期拉 K 线;升级后强刷浏览器,详见 [行情区说明.md](./行情区说明.md)。 -4. **http://127.0.0.1:5100/ai** — AI 教练(四户今日总结 + 聊天);`manual_trading_hub/.env` 配与四实例相同的 `AI_*` 变量,见 [AI教练说明.md](./AI教练说明.md)。 +4. **http://127.0.0.1:5100/ai** — AI 教练(三户今日总结 + 聊天);`manual_trading_hub/.env` 配与三实例相同的 `AI_*` 变量,见 [AI教练说明.md](./AI教练说明.md)。 5. **http://127.0.0.1:5100/settings** — 保存后生成 `hub_settings.json`(增加第五户、Gate 子账户等见 [使用说明.md §4.5](./使用说明.md#45-增加账户例如再挂一个-gate))。 5. 监控卡片 **「实例」** — 在各 `crypto_monitor_*` 网页做下单、关键位、趋势;中控**不提供**下单表单。 @@ -237,8 +235,8 @@ curl -s http://127.0.0.1:15200/status | head -c 200 HUB_COOKIE_SECURE=true ``` 4. `hub_settings.json` 中 Flask/Agent 保持 **`http://127.0.0.1:...`**(中控本机调 API)。 -5. 四实例 **`APP_AUTH_DISABLED=false`** + 与中控相同 **`HUB_BRIDGE_TOKEN`**。 -6. 子代理 **`HOST=127.0.0.1`**;防火墙勿对公网开放 `15200`~`15203`、各 `APP_PORT`。 +5. 三实例 **`APP_AUTH_DISABLED=false`** + 与中控相同 **`HUB_BRIDGE_TOKEN`**。 +6. 子代理 **`HOST=127.0.0.1`**;防火墙勿对公网开放 `15200`~`15202`、各 `APP_PORT`。 7. **复盘/实例外链**:`HUB_PUBLIC_ORIGIN=https://你的域名` 或内网 IP;否则其它设备点「复盘」会跳到 `127.0.0.1`。 **说明**:HTTPS 域名与 HTTP `内网IP:5100` Cookie **不共用**;内网访问 IP 需在 IP 地址再登录一次(见 [常见问题.md](./常见问题.md) §2.1)。 @@ -254,7 +252,7 @@ curl -s http://127.0.0.1:15200/status | head -c 200 | `HUB_DISABLED_IDS` | `1` | 强制关闭的账户 id(OKX) | | `HUB_TRUST_LAN` | `true` | 私网可访问;仅本机可 `false` | | `HUB_PUBLIC_ORIGIN` | 空 | 浏览器用复盘链接;如 `http://192.168.1.100`(**内网其它电脑访问中控时建议设置**) | -| `HUB_BRIDGE_TOKEN` | 空 | 与四实例一致;公网建议配置 | +| `HUB_BRIDGE_TOKEN` | 空 | 与三实例一致;公网建议配置 | | `HUB_USERNAME` | `admin` | Web 登录用户名 | | `HUB_PASSWORD` | 空 | 非空即启用登录 | | `HUB_SESSION_SECRET` | — | 会话签名 | diff --git a/requirements.txt b/requirements.txt index 929087d..30415c5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -# crypto_monitor 四个 Flask 子项目共用依赖(Binance / Gate / Gate_bot / OKX) +# crypto_monitor 三个 Flask 子项目共用依赖(Binance / Gate / OKX) # 安装:在各子目录 venv 内执行 pip install -r ../requirements.txt # 共用 Python 库位于 ../lib/,启动时需将仓库根加入 PYTHONPATH(各 app.py / PM2 已配置) flask>=3.0,<4 diff --git a/scripts/apply_time_close_patches.py b/scripts/apply_time_close_patches.py index bc9906c..4143f9f 100644 --- a/scripts/apply_time_close_patches.py +++ b/scripts/apply_time_close_patches.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""对 binance/okx/gate_bot 应用与 gate 相同的时间平仓代码替换。""" +"""对 binance/okx 应用与 gate 相同的时间平仓代码替换。""" from __future__ import annotations from pathlib import Path @@ -8,7 +8,6 @@ ROOT = Path(__file__).resolve().parents[1] FILES = [ ROOT / "crypto_monitor_binance" / "app.py", ROOT / "crypto_monitor_okx" / "app.py", - ROOT / "crypto_monitor_gate_bot" / "app.py", ] REPLACEMENTS: list[tuple[str, str]] = [ diff --git a/scripts/backfill_trend_strategy_snapshots.py b/scripts/backfill_trend_strategy_snapshots.py index 56b17b4..85e90f0 100644 --- a/scripts/backfill_trend_strategy_snapshots.py +++ b/scripts/backfill_trend_strategy_snapshots.py @@ -1,14 +1,14 @@ #!/usr/bin/env python3 """补录缺失的趋势回调策略结束快照(strategy_trade_snapshots)。 -适用:gate_bot 等在计划结束(止盈/止损/手动)时因 strategy_trend_cfg 未注册而漏写快照的历史数据。 +适用:gate 等在计划结束(止盈/止损/手动)时因 strategy_trend_cfg 未注册而漏写快照的历史数据。 保本移交路径通常已有快照,本脚本默认跳过「已有任意快照」的计划。 用法(在仓库根目录,Linux 请用 python3): python3 scripts/backfill_trend_strategy_snapshots.py \\ - --db crypto_monitor_gate_bot/crypto.db --dry-run + --db crypto_monitor_gate/crypto.db --dry-run python3 scripts/backfill_trend_strategy_snapshots.py \\ - --db crypto_monitor_gate_bot/crypto.db --apply + --db crypto_monitor_gate/crypto.db --apply """ from __future__ import annotations diff --git a/scripts/backfill_trend_trade_records.py b/scripts/backfill_trend_trade_records.py index a08e4a1..97b0220 100644 --- a/scripts/backfill_trend_trade_records.py +++ b/scripts/backfill_trend_trade_records.py @@ -1,11 +1,11 @@ #!/usr/bin/env python3 """补录缺失的趋势回调 trade_records(策略快照已有、交易记录漏写)。 -典型原因:gate_bot insert_trade_record 曾不接受 entry_reason,_finalize_plan 写快照后插入失败。 +典型原因:gate insert_trade_record 曾不接受 entry_reason,_finalize_plan 写快照后插入失败。 用法: - python scripts/backfill_trend_trade_records.py --db crypto_monitor_gate_bot/crypto.db --dry-run - python scripts/backfill_trend_trade_records.py --db crypto_monitor_gate_bot/crypto.db --apply + python scripts/backfill_trend_trade_records.py --db crypto_monitor_gate/crypto.db --dry-run + python scripts/backfill_trend_trade_records.py --db crypto_monitor_gate/crypto.db --apply """ from __future__ import annotations diff --git a/scripts/dedupe_strategy_snapshots.py b/scripts/dedupe_strategy_snapshots.py index 7a2a36e..63173c7 100644 --- a/scripts/dedupe_strategy_snapshots.py +++ b/scripts/dedupe_strategy_snapshots.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """清理 strategy_trade_snapshots 重复行(同计划 + 同结果仅保留 id 最大的一条)。 -用法(在实例目录,如 crypto_monitor_gate_bot): +用法(在实例目录,如 crypto_monitor_gate): python ../scripts/dedupe_strategy_snapshots.py python ../scripts/dedupe_strategy_snapshots.py --db crypto.db """ diff --git a/scripts/one_shot_backup_config_before_cleanup.py b/scripts/one_shot_backup_config_before_cleanup.py new file mode 100644 index 0000000..0d3b589 --- /dev/null +++ b/scripts/one_shot_backup_config_before_cleanup.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +""" +一次性备份:三所 .env + 中控 .env / hub_settings.json(不含图片、不含数据库)。 + +用途:删除 gate、清库、全新计划启动前,在仓库根目录执行一次即可: + + python scripts/one_shot_backup_config_before_cleanup.py + +输出目录默认:backups/one-shot-YYYYMMDD-HHMMSS/config/ +""" +from __future__ import annotations + +import shutil +import sys +from datetime import datetime +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] + +CONFIG_SOURCES: list[tuple[str, Path]] = [ + ("crypto_monitor_binance.env", REPO_ROOT / "crypto_monitor_binance" / ".env"), + ("crypto_monitor_okx.env", REPO_ROOT / "crypto_monitor_okx" / ".env"), + ("crypto_monitor_gate.env", REPO_ROOT / "crypto_monitor_gate" / ".env"), + ("manual_trading_hub.env", REPO_ROOT / "manual_trading_hub" / ".env"), + ("hub_settings.json", REPO_ROOT / "manual_trading_hub" / "hub_settings.json"), +] + +ENV_BACKUP_GLOBS = ( + REPO_ROOT / "crypto_monitor_binance", + REPO_ROOT / "crypto_monitor_okx", + REPO_ROOT / "crypto_monitor_gate", +) + + +def main() -> int: + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + out_dir = REPO_ROOT / "backups" / f"one-shot-{stamp}" / "config" + out_dir.mkdir(parents=True, exist_ok=True) + + copied: list[str] = [] + missing: list[str] = [] + + for dest_name, src in CONFIG_SOURCES: + if src.is_file(): + shutil.copy2(src, out_dir / dest_name) + copied.append(dest_name) + else: + missing.append(str(src.relative_to(REPO_ROOT))) + + for inst_dir in ENV_BACKUP_GLOBS: + for src in sorted(inst_dir.glob(".env.backup.*")): + dest_name = f"{inst_dir.name}.{src.name}" + shutil.copy2(src, out_dir / dest_name) + copied.append(dest_name) + + manifest = out_dir.parent / "manifest.txt" + lines = [ + f"created_at={stamp}", + f"repo={REPO_ROOT}", + "", + "copied:", + *[f" - {name}" for name in copied], + "", + "missing (skipped):", + *[f" - {p}" for p in missing], + "", + "not included: crypto.db, hub *.db, static/images, gate", + ] + manifest.write_text("\n".join(lines) + "\n", encoding="utf-8") + + print(f"Backup written to: {out_dir}") + if copied: + print("Copied:", ", ".join(copied)) + if missing: + print("Missing (ok if fresh install):", ", ".join(missing)) + print(f"Manifest: {manifest}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/patch_instance_theme_templates.py b/scripts/patch_instance_theme_templates.py index 921deae..cbb5494 100644 --- a/scripts/patch_instance_theme_templates.py +++ b/scripts/patch_instance_theme_templates.py @@ -5,7 +5,7 @@ from __future__ import annotations from pathlib import Path ROOT = Path(__file__).resolve().parents[1] -EXCHANGES = ("crypto_monitor_binance", "crypto_monitor_okx", "crypto_monitor_gate", "crypto_monitor_gate_bot") +EXCHANGES = ("crypto_monitor_binance", "crypto_monitor_okx", "crypto_monitor_gate") FILES = ("index.html", "login.html", "key_focus_v2.html", "order_focus_v2.html") SCRIPT_TAG = ' \n' diff --git a/scripts/patch_position_sizing_to_exchanges.py b/scripts/patch_position_sizing_to_exchanges.py index 8ecf60c..eb2b672 100644 --- a/scripts/patch_position_sizing_to_exchanges.py +++ b/scripts/patch_position_sizing_to_exchanges.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""一次性:为 okx/gate/gate_bot 注入与 binance 一致的计仓模式补丁(已 patch 过则跳过)。""" +"""一次性:为 okx/gate 注入与 binance 一致的计仓模式补丁(已 patch 过则跳过)。""" from __future__ import annotations import re @@ -82,7 +82,6 @@ TEMPLATE_RULE = '''
APPS = [ ("crypto_monitor_okx", 4, "_market_open_for_key_monitor", True), ("crypto_monitor_gate", 2, "_market_open_for_key_monitor", True), - ("crypto_monitor_gate_bot", 4, None, False), ] diff --git a/scripts/sync_brand_icons.py b/scripts/sync_brand_icons.py index fb981ae..3943e49 100644 --- a/scripts/sync_brand_icons.py +++ b/scripts/sync_brand_icons.py @@ -19,7 +19,6 @@ EXCHANGE_DIRS = ( "crypto_monitor_binance", "crypto_monitor_okx", "crypto_monitor_gate", - "crypto_monitor_gate_bot", ) FILES = ( diff --git a/scripts/sync_four_exchange_env.py b/scripts/sync_four_exchange_env.py index 2723f06..b508974 100644 --- a/scripts/sync_four_exchange_env.py +++ b/scripts/sync_four_exchange_env.py @@ -1,60 +1,60 @@ -#!/usr/bin/env python3 -""" -四所 .env 一次性同步:计仓模式 + 自动划转(调用子脚本,不覆盖已有自定义值)。 - -用法(仓库根目录): - python scripts/sync_four_exchange_env.py - python scripts/sync_four_exchange_env.py --dry-run - python scripts/sync_four_exchange_env.py --set-transfer-amount 50 --enable-auto-transfer - -子脚本可单独运行: - python scripts/sync_four_exchange_position_sizing_env.py - python scripts/sync_four_exchange_transfer_env.py - -完整说明见 docs/env-sync-scripts.md -""" -from __future__ import annotations - -import argparse -import subprocess -import sys -from pathlib import Path - -REPO = Path(__file__).resolve().parent.parent -PY = sys.executable - - -def _run(script: str, extra: list[str]) -> int: - cmd = [PY, str(REPO / "scripts" / script)] + extra - print(f"\n>>> {' '.join(cmd)}") - return subprocess.call(cmd, cwd=str(REPO)) - - -def main(): - ap = argparse.ArgumentParser(description="四所 .env 统一同步(计仓 + 划转)") - ap.add_argument("--dry-run", action="store_true") - ap.add_argument("--set-mode", choices=("risk", "full_margin"), metavar="MODE") - ap.add_argument("--set-transfer-amount", metavar="U") - ap.add_argument("--enable-auto-transfer", action="store_true") - args = ap.parse_args() - - dry = ["--dry-run"] if args.dry_run else [] - code = 0 - - ps_args = list(dry) - if args.set_mode: - ps_args.extend(["--set-mode", args.set_mode]) - code |= _run("sync_four_exchange_position_sizing_env.py", ps_args) - - tr_args = list(dry) - if args.set_transfer_amount: - tr_args.extend(["--set-amount", args.set_transfer_amount]) - if args.enable_auto_transfer: - tr_args.append("--enable-auto-transfer") - code |= _run("sync_four_exchange_transfer_env.py", tr_args) - - sys.exit(code) - - -if __name__ == "__main__": - main() +#!/usr/bin/env python3 +""" +三所 .env 一次性同步:计仓模式 + 自动划转(调用子脚本,不覆盖已有自定义值)。 + +用法(仓库根目录): + python scripts/sync_four_exchange_env.py + python scripts/sync_four_exchange_env.py --dry-run + python scripts/sync_four_exchange_env.py --set-transfer-amount 50 --enable-auto-transfer + +子脚本可单独运行: + python scripts/sync_four_exchange_position_sizing_env.py + python scripts/sync_four_exchange_transfer_env.py + +完整说明见 docs/env-sync-scripts.md +""" +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +PY = sys.executable + + +def _run(script: str, extra: list[str]) -> int: + cmd = [PY, str(REPO / "scripts" / script)] + extra + print(f"\n>>> {' '.join(cmd)}") + return subprocess.call(cmd, cwd=str(REPO)) + + +def main(): + ap = argparse.ArgumentParser(description="三所 .env 统一同步(计仓 + 划转)") + ap.add_argument("--dry-run", action="store_true") + ap.add_argument("--set-mode", choices=("risk", "full_margin"), metavar="MODE") + ap.add_argument("--set-transfer-amount", metavar="U") + ap.add_argument("--enable-auto-transfer", action="store_true") + args = ap.parse_args() + + dry = ["--dry-run"] if args.dry_run else [] + code = 0 + + ps_args = list(dry) + if args.set_mode: + ps_args.extend(["--set-mode", args.set_mode]) + code |= _run("sync_four_exchange_position_sizing_env.py", ps_args) + + tr_args = list(dry) + if args.set_transfer_amount: + tr_args.extend(["--set-amount", args.set_transfer_amount]) + if args.enable_auto_transfer: + tr_args.append("--enable-auto-transfer") + code |= _run("sync_four_exchange_transfer_env.py", tr_args) + + sys.exit(code) + + +if __name__ == "__main__": + main() diff --git a/scripts/sync_four_exchange_position_sizing_env.py b/scripts/sync_four_exchange_position_sizing_env.py index fe38410..073c58b 100644 --- a/scripts/sync_four_exchange_position_sizing_env.py +++ b/scripts/sync_four_exchange_position_sizing_env.py @@ -1,180 +1,179 @@ -#!/usr/bin/env python3 -""" -将计仓模式相关项写入四所实例 .env(已存在则保留原值,缺失则追加默认值)。 - -用法(仓库根目录): - python scripts/sync_four_exchange_position_sizing_env.py - python scripts/sync_four_exchange_position_sizing_env.py --dry-run - python scripts/sync_four_exchange_position_sizing_env.py --set-mode risk - python scripts/sync_four_exchange_position_sizing_env.py --set-mode full_margin - -切换 POSITION_SIZING_MODE 须在交易所无持仓后执行,并 pm2 restart 对应实例。 -不修改 API 密钥与其它自定义项;若 .env 不存在则跳过(请先从 .env.example 复制)。 - -完整说明见 docs/env-sync-scripts.md -""" -from __future__ import annotations - -import argparse -import os -import re - -REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - -INSTANCES = ( - "crypto_monitor_binance", - "crypto_monitor_okx", - "crypto_monitor_gate", - "crypto_monitor_gate_bot", -) - -COMMENT_POSITION_SIZING = ( - "# 计仓:risk=以损定仓(默认);full_margin=合约可用×FULL_MARGIN_BUFFER_RATIO 全仓杠杆(须无仓后重启)" -) -COMMENT_BUFFER = "# 使用可用资金时的缓冲比例(如0.98代表用98%)" - -DEFAULT_MODE = "risk" -DEFAULT_BUFFER = "0.98" -VALID_MODES = frozenset({"risk", "full_margin"}) - - -def _parse_env(path: str) -> list[str]: - if not os.path.isfile(path): - return [] - with open(path, "r", encoding="utf-8", errors="ignore") as f: - return f.read().replace("\r\n", "\n").replace("\r", "\n").splitlines() - - -def _env_get(lines: list[str], key: str) -> str | None: - pat = re.compile(r"^\s*" + re.escape(key) + r"\s*=\s*(.*)\s*$") - for line in lines: - m = pat.match(line) - if m: - return m.group(1).strip().strip('"').strip("'") - return None - - -def _upsert(lines: list[str], key: str, value: str) -> list[str]: - pat = re.compile(r"^\s*" + re.escape(key) + r"\s*=") - out = [] - replaced = False - for line in lines: - if pat.match(line): - if not replaced: - out.append(f"{key}={value}") - replaced = True - continue - out.append(line) - if not replaced: - if out and out[-1].strip(): - out.append("") - out.append(f"{key}={value}") - return out - - -def _insert_before(lines: list[str], anchor_key: str, insert: list[str]) -> list[str]: - pat = re.compile(r"^\s*" + re.escape(anchor_key) + r"\s*=") - for i, line in enumerate(lines): - if pat.match(line): - return lines[:i] + insert + lines[i:] - if lines and lines[-1].strip(): - return lines + [""] + insert - return lines + insert - - -def _ensure_position_sizing(lines: list[str], *, force_mode: str | None) -> list[str]: - if force_mode is not None: - if COMMENT_POSITION_SIZING not in lines and not _env_get(lines, "POSITION_SIZING_MODE"): - lines = _insert_before(lines, "DAILY_START_CAPITAL", [COMMENT_POSITION_SIZING]) - return _upsert(lines, "POSITION_SIZING_MODE", force_mode) - - cur = _env_get(lines, "POSITION_SIZING_MODE") - if cur is not None: - norm = cur.strip().lower() - if norm in VALID_MODES and norm != cur: - return _upsert(lines, "POSITION_SIZING_MODE", norm) - if norm not in VALID_MODES: - return _upsert(lines, "POSITION_SIZING_MODE", DEFAULT_MODE) - return lines - - block = [COMMENT_POSITION_SIZING, f"POSITION_SIZING_MODE={DEFAULT_MODE}"] - return _insert_before(lines, "DAILY_START_CAPITAL", block) - - -def _ensure_buffer_ratio(lines: list[str], *, force_buffer: str | None) -> list[str]: - if force_buffer is not None: - if COMMENT_BUFFER not in lines and _env_get(lines, "FULL_MARGIN_BUFFER_RATIO") is None: - lines = _insert_before(lines, "BALANCE_REFRESH_SECONDS", [COMMENT_BUFFER]) - return _upsert(lines, "FULL_MARGIN_BUFFER_RATIO", force_buffer) - - if _env_get(lines, "FULL_MARGIN_BUFFER_RATIO") is not None: - return lines - - block = [COMMENT_BUFFER, f"FULL_MARGIN_BUFFER_RATIO={DEFAULT_BUFFER}"] - return _insert_before(lines, "BALANCE_REFRESH_SECONDS", block) - - -def sync_one( - dir_name: str, - dry_run: bool, - *, - set_mode: str | None, - set_buffer: str | None, -) -> str: - env_path = os.path.join(REPO, dir_name, ".env") - if not os.path.isfile(env_path): - return f"SKIP {dir_name}: 无 .env(请 cp .env.example .env)" - old_lines = _parse_env(env_path) - new_lines = _ensure_buffer_ratio( - _ensure_position_sizing(list(old_lines), force_mode=set_mode), - force_buffer=set_buffer, - ) - mode = _env_get(new_lines, "POSITION_SIZING_MODE") or DEFAULT_MODE - buf = _env_get(new_lines, "FULL_MARGIN_BUFFER_RATIO") or DEFAULT_BUFFER - if new_lines == old_lines: - return f"OK {dir_name}: POSITION_SIZING_MODE={mode} FULL_MARGIN_BUFFER_RATIO={buf}" - if dry_run: - return ( - f"DRY {dir_name}: 将写入 POSITION_SIZING_MODE={mode} " - f"FULL_MARGIN_BUFFER_RATIO={buf}" - ) - with open(env_path, "w", encoding="utf-8", newline="\n") as f: - f.write("\n".join(new_lines)) - if new_lines and new_lines[-1].strip(): - f.write("\n") - return f"DONE {dir_name}: POSITION_SIZING_MODE={mode} FULL_MARGIN_BUFFER_RATIO={buf}" - - -def main(): - ap = argparse.ArgumentParser(description="四所 .env 计仓模式项同步") - ap.add_argument("--dry-run", action="store_true", help="仅打印将做的变更") - ap.add_argument( - "--set-mode", - choices=sorted(VALID_MODES), - metavar="MODE", - help="强制四所 POSITION_SIZING_MODE(须无仓后重启)", - ) - ap.add_argument( - "--set-buffer", - metavar="RATIO", - help=f"强制四所 FULL_MARGIN_BUFFER_RATIO(缺省追加为 {DEFAULT_BUFFER})", - ) - args = ap.parse_args() - if args.set_mode: - print( - f"注意:将 POSITION_SIZING_MODE 设为 {args.set_mode}," - "请确认交易所无持仓后再 restart。" - ) - for name in INSTANCES: - print( - sync_one( - name, - args.dry_run, - set_mode=args.set_mode, - set_buffer=args.set_buffer, - ) - ) - - -if __name__ == "__main__": - main() +#!/usr/bin/env python3 +""" +将计仓模式相关项写入三所实例 .env(已存在则保留原值,缺失则追加默认值)。 + +用法(仓库根目录): + python scripts/sync_four_exchange_position_sizing_env.py + python scripts/sync_four_exchange_position_sizing_env.py --dry-run + python scripts/sync_four_exchange_position_sizing_env.py --set-mode risk + python scripts/sync_four_exchange_position_sizing_env.py --set-mode full_margin + +切换 POSITION_SIZING_MODE 须在交易所无持仓后执行,并 pm2 restart 对应实例。 +不修改 API 密钥与其它自定义项;若 .env 不存在则跳过(请先从 .env.example 复制)。 + +完整说明见 docs/env-sync-scripts.md +""" +from __future__ import annotations + +import argparse +import os +import re + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +INSTANCES = ( + "crypto_monitor_binance", + "crypto_monitor_okx", + "crypto_monitor_gate", +) + +COMMENT_POSITION_SIZING = ( + "# 计仓:risk=以损定仓(默认);full_margin=合约可用×FULL_MARGIN_BUFFER_RATIO 全仓杠杆(须无仓后重启)" +) +COMMENT_BUFFER = "# 使用可用资金时的缓冲比例(如0.98代表用98%)" + +DEFAULT_MODE = "risk" +DEFAULT_BUFFER = "0.98" +VALID_MODES = frozenset({"risk", "full_margin"}) + + +def _parse_env(path: str) -> list[str]: + if not os.path.isfile(path): + return [] + with open(path, "r", encoding="utf-8", errors="ignore") as f: + return f.read().replace("\r\n", "\n").replace("\r", "\n").splitlines() + + +def _env_get(lines: list[str], key: str) -> str | None: + pat = re.compile(r"^\s*" + re.escape(key) + r"\s*=\s*(.*)\s*$") + for line in lines: + m = pat.match(line) + if m: + return m.group(1).strip().strip('"').strip("'") + return None + + +def _upsert(lines: list[str], key: str, value: str) -> list[str]: + pat = re.compile(r"^\s*" + re.escape(key) + r"\s*=") + out = [] + replaced = False + for line in lines: + if pat.match(line): + if not replaced: + out.append(f"{key}={value}") + replaced = True + continue + out.append(line) + if not replaced: + if out and out[-1].strip(): + out.append("") + out.append(f"{key}={value}") + return out + + +def _insert_before(lines: list[str], anchor_key: str, insert: list[str]) -> list[str]: + pat = re.compile(r"^\s*" + re.escape(anchor_key) + r"\s*=") + for i, line in enumerate(lines): + if pat.match(line): + return lines[:i] + insert + lines[i:] + if lines and lines[-1].strip(): + return lines + [""] + insert + return lines + insert + + +def _ensure_position_sizing(lines: list[str], *, force_mode: str | None) -> list[str]: + if force_mode is not None: + if COMMENT_POSITION_SIZING not in lines and not _env_get(lines, "POSITION_SIZING_MODE"): + lines = _insert_before(lines, "DAILY_START_CAPITAL", [COMMENT_POSITION_SIZING]) + return _upsert(lines, "POSITION_SIZING_MODE", force_mode) + + cur = _env_get(lines, "POSITION_SIZING_MODE") + if cur is not None: + norm = cur.strip().lower() + if norm in VALID_MODES and norm != cur: + return _upsert(lines, "POSITION_SIZING_MODE", norm) + if norm not in VALID_MODES: + return _upsert(lines, "POSITION_SIZING_MODE", DEFAULT_MODE) + return lines + + block = [COMMENT_POSITION_SIZING, f"POSITION_SIZING_MODE={DEFAULT_MODE}"] + return _insert_before(lines, "DAILY_START_CAPITAL", block) + + +def _ensure_buffer_ratio(lines: list[str], *, force_buffer: str | None) -> list[str]: + if force_buffer is not None: + if COMMENT_BUFFER not in lines and _env_get(lines, "FULL_MARGIN_BUFFER_RATIO") is None: + lines = _insert_before(lines, "BALANCE_REFRESH_SECONDS", [COMMENT_BUFFER]) + return _upsert(lines, "FULL_MARGIN_BUFFER_RATIO", force_buffer) + + if _env_get(lines, "FULL_MARGIN_BUFFER_RATIO") is not None: + return lines + + block = [COMMENT_BUFFER, f"FULL_MARGIN_BUFFER_RATIO={DEFAULT_BUFFER}"] + return _insert_before(lines, "BALANCE_REFRESH_SECONDS", block) + + +def sync_one( + dir_name: str, + dry_run: bool, + *, + set_mode: str | None, + set_buffer: str | None, +) -> str: + env_path = os.path.join(REPO, dir_name, ".env") + if not os.path.isfile(env_path): + return f"SKIP {dir_name}: 无 .env(请 cp .env.example .env)" + old_lines = _parse_env(env_path) + new_lines = _ensure_buffer_ratio( + _ensure_position_sizing(list(old_lines), force_mode=set_mode), + force_buffer=set_buffer, + ) + mode = _env_get(new_lines, "POSITION_SIZING_MODE") or DEFAULT_MODE + buf = _env_get(new_lines, "FULL_MARGIN_BUFFER_RATIO") or DEFAULT_BUFFER + if new_lines == old_lines: + return f"OK {dir_name}: POSITION_SIZING_MODE={mode} FULL_MARGIN_BUFFER_RATIO={buf}" + if dry_run: + return ( + f"DRY {dir_name}: 将写入 POSITION_SIZING_MODE={mode} " + f"FULL_MARGIN_BUFFER_RATIO={buf}" + ) + with open(env_path, "w", encoding="utf-8", newline="\n") as f: + f.write("\n".join(new_lines)) + if new_lines and new_lines[-1].strip(): + f.write("\n") + return f"DONE {dir_name}: POSITION_SIZING_MODE={mode} FULL_MARGIN_BUFFER_RATIO={buf}" + + +def main(): + ap = argparse.ArgumentParser(description="三所 .env 计仓模式项同步") + ap.add_argument("--dry-run", action="store_true", help="仅打印将做的变更") + ap.add_argument( + "--set-mode", + choices=sorted(VALID_MODES), + metavar="MODE", + help="强制三所 POSITION_SIZING_MODE(须无仓后重启)", + ) + ap.add_argument( + "--set-buffer", + metavar="RATIO", + help=f"强制三所 FULL_MARGIN_BUFFER_RATIO(缺省追加为 {DEFAULT_BUFFER})", + ) + args = ap.parse_args() + if args.set_mode: + print( + f"注意:将 POSITION_SIZING_MODE 设为 {args.set_mode}," + "请确认交易所无持仓后再 restart。" + ) + for name in INSTANCES: + print( + sync_one( + name, + args.dry_run, + set_mode=args.set_mode, + set_buffer=args.set_buffer, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/sync_four_exchange_transfer_env.py b/scripts/sync_four_exchange_transfer_env.py index 59c2cb0..3348567 100644 --- a/scripts/sync_four_exchange_transfer_env.py +++ b/scripts/sync_four_exchange_transfer_env.py @@ -1,213 +1,212 @@ -#!/usr/bin/env python3 -""" -将每日自动划转相关项写入四所实例 .env(已有值保留,缺失则追加;可选强制改金额/开关)。 - -用法(仓库根目录): - python scripts/sync_four_exchange_transfer_env.py - python scripts/sync_four_exchange_transfer_env.py --dry-run - python scripts/sync_four_exchange_transfer_env.py --set-amount 50 - python scripts/sync_four_exchange_transfer_env.py --enable-auto-transfer - -不修改 API 密钥与其它自定义项;若 .env 不存在则跳过(请先从 .env.example 复制)。 - -完整说明见 docs/env-sync-scripts.md -""" -from __future__ import annotations - -import argparse -import os -import re - -REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - -INSTANCES = ( - "crypto_monitor_binance", - "crypto_monitor_okx", - "crypto_monitor_gate", - "crypto_monitor_gate_bot", -) - -COMMENT_BLOCK = ( - "# 自动划转:北京时间 AUTO_TRANSFER_BJ_HOUR 点将 swap 调整至 AUTO_TRANSFER_AMOUNT;" - "不足 funding→swap、超出 swap→funding;持仓中不划转" -) - -DEFAULTS = { - "AUTO_TRANSFER_ENABLED": "false", - "AUTO_TRANSFER_FROM": "funding", - "AUTO_TRANSFER_TO": "swap", - "TRANSFER_CCY": "USDT", - "AUTO_TRANSFER_BJ_HOUR": "8", -} - -DEFAULT_AMOUNT = "50" - -BINANCE_ONLY = { - "BINANCE_FUNDING_INCLUDE_SPOT": "false", -} - - -def _parse_env(path: str) -> list[str]: - if not os.path.isfile(path): - return [] - with open(path, "r", encoding="utf-8", errors="ignore") as f: - return f.read().replace("\r\n", "\n").replace("\r", "\n").splitlines() - - -def _env_get(lines: list[str], key: str) -> str | None: - pat = re.compile(r"^\s*" + re.escape(key) + r"\s*=\s*(.*)\s*$") - for line in lines: - m = pat.match(line) - if m: - return m.group(1).strip().strip('"').strip("'") - return None - - -def _upsert(lines: list[str], key: str, value: str) -> list[str]: - pat = re.compile(r"^\s*" + re.escape(key) + r"\s*=") - out = [] - replaced = False - for line in lines: - if pat.match(line): - if not replaced: - out.append(f"{key}={value}") - replaced = True - continue - out.append(line) - if not replaced: - if out and out[-1].strip(): - out.append("") - out.append(f"{key}={value}") - return out - - -def _insert_before(lines: list[str], anchor_key: str, insert: list[str]) -> list[str]: - pat = re.compile(r"^\s*" + re.escape(anchor_key) + r"\s*=") - for i, line in enumerate(lines): - if pat.match(line): - return lines[:i] + insert + lines[i:] - if lines and lines[-1].strip(): - return lines + [""] + insert - return lines + insert - - -def _resolve_default_amount(lines: list[str]) -> str: - amount = _env_get(lines, "AUTO_TRANSFER_AMOUNT") - if amount is not None: - return amount - daily = _env_get(lines, "DAILY_START_CAPITAL") - if daily is not None: - return daily - return DEFAULT_AMOUNT - - -def _ensure_key( - lines: list[str], - key: str, - value: str, - *, - force: bool, -) -> list[str]: - if force or _env_get(lines, key) is None: - return _upsert(lines, key, value) - return lines - - -def _ensure_transfer_block( - lines: list[str], - extra: dict[str, str], - *, - force_amount: str | None, - force_enabled: str | None, -) -> list[str]: - amount = force_amount if force_amount is not None else _resolve_default_amount(lines) - had_amount = _env_get(lines, "AUTO_TRANSFER_AMOUNT") is not None - - if not had_amount and COMMENT_BLOCK not in lines: - lines = _insert_before( - lines, - "AUTO_TRANSFER_ENABLED", - [COMMENT_BLOCK], - ) - if _env_get(lines, "AUTO_TRANSFER_ENABLED") is None: - lines = _insert_before( - lines, - "BALANCE_REFRESH_SECONDS", - [COMMENT_BLOCK], - ) - - lines = _ensure_key( - lines, - "AUTO_TRANSFER_AMOUNT", - amount, - force=force_amount is not None, - ) - for k, v in DEFAULTS.items(): - if k == "AUTO_TRANSFER_ENABLED" and force_enabled is not None: - lines = _upsert(lines, k, force_enabled) - else: - lines = _ensure_key(lines, k, v, force=False) - for k, v in extra.items(): - lines = _ensure_key(lines, k, v, force=False) - return lines - - -def sync_one( - dir_name: str, - dry_run: bool, - *, - set_amount: str | None, - enable_auto: bool | None, -) -> str: - env_path = os.path.join(REPO, dir_name, ".env") - if not os.path.isfile(env_path): - return f"SKIP {dir_name}: 无 .env(请 cp .env.example .env)" - old_lines = _parse_env(env_path) - extra = dict(BINANCE_ONLY) if dir_name == "crypto_monitor_binance" else {} - force_enabled = "true" if enable_auto is True else None - new_lines = _ensure_transfer_block( - old_lines, - extra, - force_amount=set_amount, - force_enabled=force_enabled, - ) - enabled = _env_get(new_lines, "AUTO_TRANSFER_ENABLED") or DEFAULTS["AUTO_TRANSFER_ENABLED"] - amt = _env_get(new_lines, "AUTO_TRANSFER_AMOUNT") or DEFAULT_AMOUNT - if new_lines == old_lines: - return f"OK {dir_name}: ENABLED={enabled} AMOUNT={amt}" - if dry_run: - return f"DRY {dir_name}: 将更新 ENABLED={enabled} AMOUNT={amt}" - with open(env_path, "w", encoding="utf-8", newline="\n") as f: - f.write("\n".join(new_lines)) - if new_lines and new_lines[-1].strip(): - f.write("\n") - return f"DONE {dir_name}: ENABLED={enabled} AMOUNT={amt}" - - -def main(): - ap = argparse.ArgumentParser(description="四所 .env 自动划转项同步") - ap.add_argument("--dry-run", action="store_true") - ap.add_argument( - "--set-amount", - metavar="U", - help=f"强制四所 AUTO_TRANSFER_AMOUNT(缺省补全默认 {DEFAULT_AMOUNT})", - ) - ap.add_argument( - "--enable-auto-transfer", - action="store_true", - help="强制四所 AUTO_TRANSFER_ENABLED=true", - ) - args = ap.parse_args() - for name in INSTANCES: - print( - sync_one( - name, - args.dry_run, - set_amount=args.set_amount, - enable_auto=True if args.enable_auto_transfer else None, - ) - ) - - -if __name__ == "__main__": - main() +#!/usr/bin/env python3 +""" +将每日自动划转相关项写入三所实例 .env(已有值保留,缺失则追加;可选强制改金额/开关)。 + +用法(仓库根目录): + python scripts/sync_four_exchange_transfer_env.py + python scripts/sync_four_exchange_transfer_env.py --dry-run + python scripts/sync_four_exchange_transfer_env.py --set-amount 50 + python scripts/sync_four_exchange_transfer_env.py --enable-auto-transfer + +不修改 API 密钥与其它自定义项;若 .env 不存在则跳过(请先从 .env.example 复制)。 + +完整说明见 docs/env-sync-scripts.md +""" +from __future__ import annotations + +import argparse +import os +import re + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +INSTANCES = ( + "crypto_monitor_binance", + "crypto_monitor_okx", + "crypto_monitor_gate", +) + +COMMENT_BLOCK = ( + "# 自动划转:北京时间 AUTO_TRANSFER_BJ_HOUR 点将 swap 调整至 AUTO_TRANSFER_AMOUNT;" + "不足 funding→swap、超出 swap→funding;持仓中不划转" +) + +DEFAULTS = { + "AUTO_TRANSFER_ENABLED": "false", + "AUTO_TRANSFER_FROM": "funding", + "AUTO_TRANSFER_TO": "swap", + "TRANSFER_CCY": "USDT", + "AUTO_TRANSFER_BJ_HOUR": "8", +} + +DEFAULT_AMOUNT = "50" + +BINANCE_ONLY = { + "BINANCE_FUNDING_INCLUDE_SPOT": "false", +} + + +def _parse_env(path: str) -> list[str]: + if not os.path.isfile(path): + return [] + with open(path, "r", encoding="utf-8", errors="ignore") as f: + return f.read().replace("\r\n", "\n").replace("\r", "\n").splitlines() + + +def _env_get(lines: list[str], key: str) -> str | None: + pat = re.compile(r"^\s*" + re.escape(key) + r"\s*=\s*(.*)\s*$") + for line in lines: + m = pat.match(line) + if m: + return m.group(1).strip().strip('"').strip("'") + return None + + +def _upsert(lines: list[str], key: str, value: str) -> list[str]: + pat = re.compile(r"^\s*" + re.escape(key) + r"\s*=") + out = [] + replaced = False + for line in lines: + if pat.match(line): + if not replaced: + out.append(f"{key}={value}") + replaced = True + continue + out.append(line) + if not replaced: + if out and out[-1].strip(): + out.append("") + out.append(f"{key}={value}") + return out + + +def _insert_before(lines: list[str], anchor_key: str, insert: list[str]) -> list[str]: + pat = re.compile(r"^\s*" + re.escape(anchor_key) + r"\s*=") + for i, line in enumerate(lines): + if pat.match(line): + return lines[:i] + insert + lines[i:] + if lines and lines[-1].strip(): + return lines + [""] + insert + return lines + insert + + +def _resolve_default_amount(lines: list[str]) -> str: + amount = _env_get(lines, "AUTO_TRANSFER_AMOUNT") + if amount is not None: + return amount + daily = _env_get(lines, "DAILY_START_CAPITAL") + if daily is not None: + return daily + return DEFAULT_AMOUNT + + +def _ensure_key( + lines: list[str], + key: str, + value: str, + *, + force: bool, +) -> list[str]: + if force or _env_get(lines, key) is None: + return _upsert(lines, key, value) + return lines + + +def _ensure_transfer_block( + lines: list[str], + extra: dict[str, str], + *, + force_amount: str | None, + force_enabled: str | None, +) -> list[str]: + amount = force_amount if force_amount is not None else _resolve_default_amount(lines) + had_amount = _env_get(lines, "AUTO_TRANSFER_AMOUNT") is not None + + if not had_amount and COMMENT_BLOCK not in lines: + lines = _insert_before( + lines, + "AUTO_TRANSFER_ENABLED", + [COMMENT_BLOCK], + ) + if _env_get(lines, "AUTO_TRANSFER_ENABLED") is None: + lines = _insert_before( + lines, + "BALANCE_REFRESH_SECONDS", + [COMMENT_BLOCK], + ) + + lines = _ensure_key( + lines, + "AUTO_TRANSFER_AMOUNT", + amount, + force=force_amount is not None, + ) + for k, v in DEFAULTS.items(): + if k == "AUTO_TRANSFER_ENABLED" and force_enabled is not None: + lines = _upsert(lines, k, force_enabled) + else: + lines = _ensure_key(lines, k, v, force=False) + for k, v in extra.items(): + lines = _ensure_key(lines, k, v, force=False) + return lines + + +def sync_one( + dir_name: str, + dry_run: bool, + *, + set_amount: str | None, + enable_auto: bool | None, +) -> str: + env_path = os.path.join(REPO, dir_name, ".env") + if not os.path.isfile(env_path): + return f"SKIP {dir_name}: 无 .env(请 cp .env.example .env)" + old_lines = _parse_env(env_path) + extra = dict(BINANCE_ONLY) if dir_name == "crypto_monitor_binance" else {} + force_enabled = "true" if enable_auto is True else None + new_lines = _ensure_transfer_block( + old_lines, + extra, + force_amount=set_amount, + force_enabled=force_enabled, + ) + enabled = _env_get(new_lines, "AUTO_TRANSFER_ENABLED") or DEFAULTS["AUTO_TRANSFER_ENABLED"] + amt = _env_get(new_lines, "AUTO_TRANSFER_AMOUNT") or DEFAULT_AMOUNT + if new_lines == old_lines: + return f"OK {dir_name}: ENABLED={enabled} AMOUNT={amt}" + if dry_run: + return f"DRY {dir_name}: 将更新 ENABLED={enabled} AMOUNT={amt}" + with open(env_path, "w", encoding="utf-8", newline="\n") as f: + f.write("\n".join(new_lines)) + if new_lines and new_lines[-1].strip(): + f.write("\n") + return f"DONE {dir_name}: ENABLED={enabled} AMOUNT={amt}" + + +def main(): + ap = argparse.ArgumentParser(description="三所 .env 自动划转项同步") + ap.add_argument("--dry-run", action="store_true") + ap.add_argument( + "--set-amount", + metavar="U", + help=f"强制三所 AUTO_TRANSFER_AMOUNT(缺省补全默认 {DEFAULT_AMOUNT})", + ) + ap.add_argument( + "--enable-auto-transfer", + action="store_true", + help="强制三所 AUTO_TRANSFER_ENABLED=true", + ) + args = ap.parse_args() + for name in INSTANCES: + print( + sync_one( + name, + args.dry_run, + set_amount=args.set_amount, + enable_auto=True if args.enable_auto_transfer else None, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/tests/test_ai_review_lib.py b/tests/test_ai_review_lib.py index 1292ce6..0b52c8f 100644 --- a/tests/test_ai_review_lib.py +++ b/tests/test_ai_review_lib.py @@ -1,4 +1,4 @@ -"""AI 复盘 journal 文本格式化(四所共用)。""" +"""AI 复盘 journal 文本格式化(三所共用)。""" from __future__ import annotations import sqlite3 diff --git a/tests/test_hub_agent_entry_price.py b/tests/test_hub_agent_entry_price.py index 6102b51..a02d414 100644 --- a/tests/test_hub_agent_entry_price.py +++ b/tests/test_hub_agent_entry_price.py @@ -1,32 +1,32 @@ -"""子代理持仓:四所开仓价字段统一解析。""" -from __future__ import annotations - -import sys -import unittest -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(ROOT / "manual_trading_hub")) - -from agent import _position_entry_price # noqa: E402 - - -class TestHubAgentEntryPrice(unittest.TestCase): - def test_binance_entry_price(self): - px = _position_entry_price({"entryPrice": 65851.6, "info": {}}) - self.assertAlmostEqual(px, 65851.6) - - def test_okx_avg_px(self): - px = _position_entry_price({"info": {"avgPx": "72.731"}}) - self.assertAlmostEqual(px, 72.731) - - def test_gate_info_entry(self): - px = _position_entry_price({"info": {"entry_price": "0.2232"}}) - self.assertAlmostEqual(px, 0.2232) - - def test_missing_returns_none(self): - self.assertIsNone(_position_entry_price({"info": {}})) - - -if __name__ == "__main__": - unittest.main() +"""子代理持仓:三所开仓价字段统一解析。""" +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "manual_trading_hub")) + +from agent import _position_entry_price # noqa: E402 + + +class TestHubAgentEntryPrice(unittest.TestCase): + def test_binance_entry_price(self): + px = _position_entry_price({"entryPrice": 65851.6, "info": {}}) + self.assertAlmostEqual(px, 65851.6) + + def test_okx_avg_px(self): + px = _position_entry_price({"info": {"avgPx": "72.731"}}) + self.assertAlmostEqual(px, 72.731) + + def test_gate_info_entry(self): + px = _position_entry_price({"info": {"entry_price": "0.2232"}}) + self.assertAlmostEqual(px, 0.2232) + + def test_missing_returns_none(self): + self.assertIsNone(_position_entry_price({"info": {}})) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_agent_mark_price.py b/tests/test_hub_agent_mark_price.py index c042706..a8c8ecf 100644 --- a/tests/test_hub_agent_mark_price.py +++ b/tests/test_hub_agent_mark_price.py @@ -1,4 +1,4 @@ -"""子代理持仓:四所标记价字段统一解析。""" +"""子代理持仓:三所标记价字段统一解析。""" from __future__ import annotations import sys diff --git a/tests/test_hub_fund_history_lib.py b/tests/test_hub_fund_history_lib.py index de44015..8fa0acf 100644 --- a/tests/test_hub_fund_history_lib.py +++ b/tests/test_hub_fund_history_lib.py @@ -54,7 +54,7 @@ def test_build_fund_overview_skips_unmonitored(tmp_path, monkeypatch): ) exchanges = [ {"id": "0", "key": "binance", "name": "Binance", "enabled": True}, - {"id": "3", "key": "gate_bot", "name": "Gate Bot", "enabled": False}, + {"id": "2", "key": "gate", "name": "Gate", "enabled": False}, ] board_rows = [ { diff --git a/tests/test_hub_symbol_archive_lib.py b/tests/test_hub_symbol_archive_lib.py index b07396d..0c7bbf7 100644 --- a/tests/test_hub_symbol_archive_lib.py +++ b/tests/test_hub_symbol_archive_lib.py @@ -255,7 +255,7 @@ def test_upsert_forces_sync_exchange_key(): db = Path(td) / "archive.db" init_db(db) upsert_trades_cache( - "gate_bot", + "gate", [ { "id": 77, @@ -270,9 +270,9 @@ def test_upsert_forces_sync_exchange_key(): ], db_path=db, ) - rows = load_symbol_trades("gate_bot", "ETH/USDT", db_path=db) + rows = load_symbol_trades("gate", "ETH/USDT", db_path=db) assert len(rows) == 1 - assert rows[0]["exchange_key"] == "gate_bot" + assert rows[0]["exchange_key"] == "gate" assert "account_exchange_key" not in rows[0] diff --git a/tests/test_hub_trades_archive_merge.py b/tests/test_hub_trades_archive_merge.py index 63e83bc..3732b87 100644 --- a/tests/test_hub_trades_archive_merge.py +++ b/tests/test_hub_trades_archive_merge.py @@ -1,4 +1,4 @@ -"""档案交易:strategy_trade_snapshots 补全 gate_bot 漏记。""" +"""档案交易:strategy_trade_snapshots 补全 gate 漏记。""" from __future__ import annotations diff --git a/tests/test_trend_dca_enrich_fills.py b/tests/test_trend_dca_enrich_fills.py index 42a96bb..691cd42 100644 --- a/tests/test_trend_dca_enrich_fills.py +++ b/tests/test_trend_dca_enrich_fills.py @@ -86,7 +86,7 @@ class TestTrendDcaEnrichFills(unittest.TestCase): self.assertLess(dca2["price"], 0.36) def test_display_price_never_infers_from_target_avg(self): - """四所共用:缺记录时只用网格,不因均价反推离谱触发价。""" + """三所共用:缺记录时只用网格,不因均价反推离谱触发价。""" plan = self._base_plan( legs_done=2, avg_entry_price=0.3507, diff --git a/tests/test_trend_finalize_trade_record.py b/tests/test_trend_finalize_trade_record.py index 26cc6b0..c9d7300 100644 --- a/tests/test_trend_finalize_trade_record.py +++ b/tests/test_trend_finalize_trade_record.py @@ -1,4 +1,4 @@ -"""趋势计划结束:须写入 trade_records(四所统一)。""" +"""趋势计划结束:须写入 trade_records(三所统一)。""" from __future__ import annotations import inspect @@ -15,7 +15,7 @@ from lib.strategy.strategy_trend_register import _call_insert_trade_record # no class _GateBotLikeModule: - """模拟 gate_bot:曾有 trend_plan_id 但缺 entry_reason 参数。""" + """模拟 gate:曾有 trend_plan_id 但缺 entry_reason 参数。""" @staticmethod def insert_trade_record( @@ -80,10 +80,10 @@ class TestTrendFinalizeTradeRecord(unittest.TestCase): self.assertEqual(row[1], "趋势回调") self.assertEqual(row[2], 4) - def test_gate_bot_insert_accepts_entry_reason(self): - from crypto_monitor_gate_bot import app as gate_bot_app # noqa: E402 + def test_gate_insert_accepts_entry_reason(self): + from crypto_monitor_gate import app as gate_app # noqa: E402 - sig = inspect.signature(gate_bot_app.insert_trade_record) + sig = inspect.signature(gate_app.insert_trade_record) self.assertIn("entry_reason", sig.parameters) self.assertIn("trend_plan_id", sig.parameters) diff --git a/tests/test_trend_hub_enrich_unified.py b/tests/test_trend_hub_enrich_unified.py index 28ccaaf..b66e650 100644 --- a/tests/test_trend_hub_enrich_unified.py +++ b/tests/test_trend_hub_enrich_unified.py @@ -1,4 +1,4 @@ -"""四所趋势 enrich:实例与中控 monitor 字段一致。""" +"""三所趋势 enrich:实例与中控 monitor 字段一致。""" from __future__ import annotations import json diff --git a/关键位止盈止损与移动保本更新说明.md b/关键位止盈止损与移动保本更新说明.md index 42a9783..d72ab0c 100644 --- a/关键位止盈止损与移动保本更新说明.md +++ b/关键位止盈止损与移动保本更新说明.md @@ -159,6 +159,6 @@ OKX 用户按推送中的计划价自行下单;斐波仍为限价 + 成交后 | 计划 SL/TP | `plan_key_sl_tp()` in `key_sl_tp_lib.py` | | 按监控行计算 | `_key_plan_sl_tp_for_row()` in各 `app.py` | | 添加关键位 | `add_key()` | -| 箱体/收敛轮询 | `check_key_monitors()`(四所共用自动开仓逻辑) | +| 箱体/收敛轮询 | `check_key_monitors()`(三所共用自动开仓逻辑) | | 斐波添加 | `_add_fib_key_monitor(..., breakeven_enabled=)` | | 自动开仓写监控 | `_market_open_for_key_monitor(..., breakeven_enabled=)` | diff --git a/备份与恢复.md b/备份与恢复.md index f106e71..3efeb54 100644 --- a/备份与恢复.md +++ b/备份与恢复.md @@ -1,268 +1,267 @@ -# 备份与恢复(Ubuntu 服务器) - -本文档面向 **VPS / Ubuntu**,项目统一放在 **`/opt/crypto_monitor`**,数据备份统一放在 **`/root/backups`**。 - -| 类型 | 内容 | 存放位置 | 频率 | -|------|------|----------|------| -| **数据库 + 复盘图片** | `crypto.db`、`static/images` | `/root/backups/<实例名>/YYYY-MM-DD/` | 每天北京时间 **0:00**(cron) | -| **`.env` 配置** | API、密码、风控参数等 | 项目目录 `.env.backup.日期`;可选集中拷到 `/root/backups/env/` | **升级 / 改配置前**手动执行 | - -> `.env` **不会**被自动备份脚本包含(含密钥,请单独备份)。 -> 三个常用实例:`crypto_monitor_binance`、`crypto_monitor_gate`、`crypto_monitor_gate_bot`。 - ---- - -## 一、首次安装:三个实例自动备份 + 试跑 - -整段复制到 SSH 终端执行(需 **root** 或对该目录有写权限): - -```bash -apt install -y sqlite3 2>/dev/null || true - -for dir in crypto_monitor_binance crypto_monitor_gate crypto_monitor_gate_bot; do - cd "/opt/crypto_monitor/${dir}" || exit 1 - chmod +x scripts/backup_data.sh scripts/install_backup_cron.sh - bash scripts/install_backup_cron.sh - bash scripts/backup_data.sh -done - -echo "=== crontab ===" -crontab -l -echo "=== backup dirs ===" -ls -la /root/backups/*/ -``` - -成功后应有: - -- `crontab -l` 含一行 `CRON_TZ=Asia/Shanghai` + 三条 `0 0 * * * .../backup_data.sh` -- `/root/backups/crypto_monitor_binance/2026-05-17/`(日期为当天)等目录,内含 `crypto.db`、`static_images.tar.gz`、`manifest.txt` - -日志路径: - -- `/var/log/crypto-monitor-backup-crypto_monitor_binance.log` -- `/var/log/crypto-monitor-backup-crypto_monitor_gate.log` -- `/var/log/crypto-monitor-backup-crypto_monitor_gate_bot.log` - ---- - -## 二、仅安装某一个实例的自动备份 - -把 `INSTANCE` 改成目录名后整段执行: - -```bash -INSTANCE=crypto_monitor_binance -cd "/opt/crypto_monitor/${INSTANCE}" -chmod +x scripts/backup_data.sh scripts/install_backup_cron.sh -bash scripts/install_backup_cron.sh -bash scripts/backup_data.sh -``` - -`INSTANCE` 可选:`crypto_monitor_binance` | `crypto_monitor_gate` | `crypto_monitor_gate_bot` - ---- - -## 三、手动立即备份(数据库 + 图片,三个实例) - -不等到 0 点,立刻各备份一次: - -```bash -for dir in crypto_monitor_binance crypto_monitor_gate crypto_monitor_gate_bot; do - echo ">>> ${dir}" - bash "/opt/crypto_monitor/${dir}/scripts/backup_data.sh" -done -ls -la /root/backups/*/*/ -``` - ---- - -## 四、检查定时任务与备份是否正常 - -```bash -crontab -l -ls -la /root/backups/*/ -du -sh /root/backups/*/ -tail -n 20 /var/log/crypto-monitor-backup-crypto_monitor_binance.log -tail -n 20 /var/log/crypto-monitor-backup-crypto_monitor_gate.log -tail -n 20 /var/log/crypto-monitor-backup-crypto_monitor_gate_bot.log -``` - ---- - -## 五、`.env` 备份(升级 / git pull / 改密钥前) - -### 5.1 三个实例一次性备份到各自项目目录 - -```bash -DATE=$(TZ=Asia/Shanghai date +%Y%m%d) -for dir in crypto_monitor_binance crypto_monitor_gate crypto_monitor_gate_bot; do - src="/opt/crypto_monitor/${dir}/.env" - dst="/opt/crypto_monitor/${dir}/.env.backup.${DATE}" - if [ -f "$src" ]; then - cp -a "$src" "$dst" - echo "ok: $dst" - else - echo "skip (no .env): $src" - fi -done -``` - -### 5.2 同时集中备份到 `/root/backups/env/`(推荐) - -```bash -DATE=$(TZ=Asia/Shanghai date +%Y%m%d) -mkdir -p /root/backups/env -for dir in crypto_monitor_binance crypto_monitor_gate crypto_monitor_gate_bot; do - src="/opt/crypto_monitor/${dir}/.env" - if [ -f "$src" ]; then - cp -a "$src" "/root/backups/env/${dir}.env.${DATE}" - echo "ok: /root/backups/env/${dir}.env.${DATE}" - fi -done -ls -la /root/backups/env/ -``` - -> `/root/backups/env/` 含密钥,勿上传网盘、勿提交 Git。 - ---- - -## 六、`.env` 恢复 - -### 6.1 从项目目录内的备份恢复 - -把 `INSTANCE` 和 `DATE` 改成实际值(`DATE` 为备份当天的 `YYYYMMDD`): - -```bash -INSTANCE=crypto_monitor_binance -DATE=20260517 -cd "/opt/crypto_monitor/${INSTANCE}" -cp -a ".env.backup.${DATE}" .env -echo "restored .env from .env.backup.${DATE}" -``` - -### 6.2 从 `/root/backups/env/` 恢复 - -```bash -INSTANCE=crypto_monitor_binance -DATE=20260517 -cp -a "/root/backups/env/${INSTANCE}.env.${DATE}" "/opt/crypto_monitor/${INSTANCE}/.env" -echo "restored from /root/backups/env/${INSTANCE}.env.${DATE}" -``` - -恢复后重启对应 PM2 进程,例如: - -```bash -pm2 restart crypto-monitor-binance -pm2 restart crypto-monitor-gate -pm2 restart crypto-monitor-gate-bot -``` - -(进程名以你 `pm2 list` 为准。) - ---- - -## 七、数据库 + 复盘图片恢复 - -从自动备份目录恢复。先停服务再覆盖,避免 SQLite 写入冲突。 - -把 `INSTANCE`、`DATE`(文件夹名 `YYYY-MM-DD`)改成实际值: - -```bash -INSTANCE=crypto_monitor_binance -DATE=2026-05-17 -BK="/root/backups/${INSTANCE}/${DATE}" -PROJ="/opt/crypto_monitor/${INSTANCE}" - -test -f "${BK}/crypto.db" || { echo "backup not found: ${BK}"; exit 1; } - -pm2 stop crypto-monitor-binance 2>/dev/null || true - -cp -a "${PROJ}/crypto.db" "${PROJ}/crypto.db.before_restore.$(date +%Y%m%d%H%M)" 2>/dev/null || true -cp -a "${BK}/crypto.db" "${PROJ}/crypto.db" - -if [ -f "${BK}/static_images.tar.gz" ]; then - tar -xzf "${BK}/static_images.tar.gz" -C "${PROJ}" -fi - -pm2 start crypto-monitor-binance 2>/dev/null || true -echo "restored ${INSTANCE} from ${BK}" -``` - -Gate / Gate Bot 将 `INSTANCE`、`pm2` 名称改为对应实例即可。 - ---- - -## 八、升级代码推荐顺序(含备份) - -```bash -DATE=$(TZ=Asia/Shanghai date +%Y%m%d) -mkdir -p /root/backups/env - -for dir in crypto_monitor_binance crypto_monitor_gate crypto_monitor_gate_bot; do - PROJ="/opt/crypto_monitor/${dir}" - [ -f "${PROJ}/.env" ] && cp -a "${PROJ}/.env" "/root/backups/env/${dir}.env.${DATE}" - bash "${PROJ}/scripts/backup_data.sh" 2>/dev/null || true -done - -cd /opt/crypto_monitor -git pull - -for dir in crypto_monitor_binance crypto_monitor_gate crypto_monitor_gate_bot; do - echo ">>> merge .env.example if needed: ${dir}" - diff -u "${dir}/.env.example" "${dir}/.env" | head -30 || true -done - -pm2 restart all -``` - -`git pull` 后对照各目录 **`.env.example`**,把**新增变量名**手动补进 `.env`(不会自动合并)。 - ---- - -## 九、备份目录结构说明 - -```text -/root/backups/ - env/ # .env 集中备份(手动) - crypto_monitor_binance.env.20260517 - crypto_monitor_gate.env.20260517 - crypto_monitor_gate_bot.env.20260517 - crypto_monitor_binance/ - 2026-05-17/ - crypto.db - static_images.tar.gz - manifest.txt - crypto_monitor_gate/ - 2026-05-17/ - ... - crypto_monitor_gate_bot/ - 2026-05-17/ - ... -``` - -- **保留策略**:自动备份目录按日期文件夹保留 **30 天**,超期在下次 `backup_data.sh` 运行时删除。 -- **可选 `.env` 变量**(写在各实例 `.env` 中):`BACKUP_ROOT`、`BACKUP_RETENTION_DAYS`、`BACKUP_INSTANCE`(见各目录 `.env.example` 注释)。 - ---- - -## 十、卸载自动备份定时任务 - -仅删除三个实例的 backup 行(保留其它 cron): - -```bash -for dir in crypto_monitor_binance crypto_monitor_gate crypto_monitor_gate_bot; do - SCRIPT="/opt/crypto_monitor/${dir}/scripts/backup_data.sh" - crontab -l 2>/dev/null | grep -vF "$SCRIPT" | crontab - -done -crontab -l -``` - ---- - -## 十一、相关文档 - -| 文档 | 说明 | -|------|------| -| [README.md](./README.md) | 仓库总览 | -| [crypto_monitor_binance/部署文档.md](./crypto_monitor_binance/部署文档.md) | Binance 部署与备份细节 | -| [crypto_monitor_gate/部署文档.md](./crypto_monitor_gate/部署文档.md) | Gate 部署 | -| [crypto_monitor_gate_bot/部署文档.md](./crypto_monitor_gate_bot/部署文档.md) | Gate Bot 部署 | +# 备份与恢复(Ubuntu 服务器) + +本文档面向 **VPS / Ubuntu**,项目统一放在 **`/opt/crypto_monitor`**,数据备份统一放在 **`/root/backups`**。 + +| 类型 | 内容 | 存放位置 | 频率 | +|------|------|----------|------| +| **数据库 + 复盘图片** | `crypto.db`、`static/images` | `/root/backups/<实例名>/YYYY-MM-DD/` | 每天北京时间 **0:00**(cron) | +| **`.env` 配置** | API、密码、风控参数等 | 项目目录 `.env.backup.日期`;可选集中拷到 `/root/backups/env/` | **升级 / 改配置前**手动执行 | + +> `.env` **不会**被自动备份脚本包含(含密钥,请单独备份)。 +> 三个常用实例:`crypto_monitor_binance`、`crypto_monitor_gate`、`crypto_monitor_okx`。 + +--- + +## 一、首次安装:三个实例自动备份 + 试跑 + +整段复制到 SSH 终端执行(需 **root** 或对该目录有写权限): + +```bash +apt install -y sqlite3 2>/dev/null || true + +for dir in crypto_monitor_binance crypto_monitor_gate crypto_monitor_okx; do + cd "/opt/crypto_monitor/${dir}" || exit 1 + chmod +x scripts/backup_data.sh scripts/install_backup_cron.sh + bash scripts/install_backup_cron.sh + bash scripts/backup_data.sh +done + +echo "=== crontab ===" +crontab -l +echo "=== backup dirs ===" +ls -la /root/backups/*/ +``` + +成功后应有: + +- `crontab -l` 含一行 `CRON_TZ=Asia/Shanghai` + 三条 `0 0 * * * .../backup_data.sh` +- `/root/backups/crypto_monitor_binance/2026-05-17/`(日期为当天)等目录,内含 `crypto.db`、`static_images.tar.gz`、`manifest.txt` + +日志路径: + +- `/var/log/crypto-monitor-backup-crypto_monitor_binance.log` +- `/var/log/crypto-monitor-backup-crypto_monitor_gate.log` +- `/var/log/crypto-monitor-backup-crypto_monitor_okx.log` + +--- + +## 二、仅安装某一个实例的自动备份 + +把 `INSTANCE` 改成目录名后整段执行: + +```bash +INSTANCE=crypto_monitor_binance +cd "/opt/crypto_monitor/${INSTANCE}" +chmod +x scripts/backup_data.sh scripts/install_backup_cron.sh +bash scripts/install_backup_cron.sh +bash scripts/backup_data.sh +``` + +`INSTANCE` 可选:`crypto_monitor_binance` | `crypto_monitor_gate` | `crypto_monitor_okx` + +--- + +## 三、手动立即备份(数据库 + 图片,三个实例) + +不等到 0 点,立刻各备份一次: + +```bash +for dir in crypto_monitor_binance crypto_monitor_gate crypto_monitor_okx; do + echo ">>> ${dir}" + bash "/opt/crypto_monitor/${dir}/scripts/backup_data.sh" +done +ls -la /root/backups/*/*/ +``` + +--- + +## 四、检查定时任务与备份是否正常 + +```bash +crontab -l +ls -la /root/backups/*/ +du -sh /root/backups/*/ +tail -n 20 /var/log/crypto-monitor-backup-crypto_monitor_binance.log +tail -n 20 /var/log/crypto-monitor-backup-crypto_monitor_gate.log +tail -n 20 /var/log/crypto-monitor-backup-crypto_monitor_okx.log +``` + +--- + +## 五、`.env` 备份(升级 / git pull / 改密钥前) + +### 5.1 三个实例一次性备份到各自项目目录 + +```bash +DATE=$(TZ=Asia/Shanghai date +%Y%m%d) +for dir in crypto_monitor_binance crypto_monitor_gate crypto_monitor_okx; do + src="/opt/crypto_monitor/${dir}/.env" + dst="/opt/crypto_monitor/${dir}/.env.backup.${DATE}" + if [ -f "$src" ]; then + cp -a "$src" "$dst" + echo "ok: $dst" + else + echo "skip (no .env): $src" + fi +done +``` + +### 5.2 同时集中备份到 `/root/backups/env/`(推荐) + +```bash +DATE=$(TZ=Asia/Shanghai date +%Y%m%d) +mkdir -p /root/backups/env +for dir in crypto_monitor_binance crypto_monitor_gate crypto_monitor_okx; do + src="/opt/crypto_monitor/${dir}/.env" + if [ -f "$src" ]; then + cp -a "$src" "/root/backups/env/${dir}.env.${DATE}" + echo "ok: /root/backups/env/${dir}.env.${DATE}" + fi +done +ls -la /root/backups/env/ +``` + +> `/root/backups/env/` 含密钥,勿上传网盘、勿提交 Git。 + +--- + +## 六、`.env` 恢复 + +### 6.1 从项目目录内的备份恢复 + +把 `INSTANCE` 和 `DATE` 改成实际值(`DATE` 为备份当天的 `YYYYMMDD`): + +```bash +INSTANCE=crypto_monitor_binance +DATE=20260517 +cd "/opt/crypto_monitor/${INSTANCE}" +cp -a ".env.backup.${DATE}" .env +echo "restored .env from .env.backup.${DATE}" +``` + +### 6.2 从 `/root/backups/env/` 恢复 + +```bash +INSTANCE=crypto_monitor_binance +DATE=20260517 +cp -a "/root/backups/env/${INSTANCE}.env.${DATE}" "/opt/crypto_monitor/${INSTANCE}/.env" +echo "restored from /root/backups/env/${INSTANCE}.env.${DATE}" +``` + +恢复后重启对应 PM2 进程,例如: + +```bash +pm2 restart crypto-monitor-binance +pm2 restart crypto-monitor-gate +``` + +(进程名以你 `pm2 list` 为准。) + +--- + +## 七、数据库 + 复盘图片恢复 + +从自动备份目录恢复。先停服务再覆盖,避免 SQLite 写入冲突。 + +把 `INSTANCE`、`DATE`(文件夹名 `YYYY-MM-DD`)改成实际值: + +```bash +INSTANCE=crypto_monitor_binance +DATE=2026-05-17 +BK="/root/backups/${INSTANCE}/${DATE}" +PROJ="/opt/crypto_monitor/${INSTANCE}" + +test -f "${BK}/crypto.db" || { echo "backup not found: ${BK}"; exit 1; } + +pm2 stop crypto-monitor-binance 2>/dev/null || true + +cp -a "${PROJ}/crypto.db" "${PROJ}/crypto.db.before_restore.$(date +%Y%m%d%H%M)" 2>/dev/null || true +cp -a "${BK}/crypto.db" "${PROJ}/crypto.db" + +if [ -f "${BK}/static_images.tar.gz" ]; then + tar -xzf "${BK}/static_images.tar.gz" -C "${PROJ}" +fi + +pm2 start crypto-monitor-binance 2>/dev/null || true +echo "restored ${INSTANCE} from ${BK}" +``` + +Gate / 将 `INSTANCE`、`pm2` 名称改为对应实例即可。 + +--- + +## 八、升级代码推荐顺序(含备份) + +```bash +DATE=$(TZ=Asia/Shanghai date +%Y%m%d) +mkdir -p /root/backups/env + +for dir in crypto_monitor_binance crypto_monitor_gate crypto_monitor_okx; do + PROJ="/opt/crypto_monitor/${dir}" + [ -f "${PROJ}/.env" ] && cp -a "${PROJ}/.env" "/root/backups/env/${dir}.env.${DATE}" + bash "${PROJ}/scripts/backup_data.sh" 2>/dev/null || true +done + +cd /opt/crypto_monitor +git pull + +for dir in crypto_monitor_binance crypto_monitor_gate crypto_monitor_okx; do + echo ">>> merge .env.example if needed: ${dir}" + diff -u "${dir}/.env.example" "${dir}/.env" | head -30 || true +done + +pm2 restart all +``` + +`git pull` 后对照各目录 **`.env.example`**,把**新增变量名**手动补进 `.env`(不会自动合并)。 + +--- + +## 九、备份目录结构说明 + +```text +/root/backups/ + env/ # .env 集中备份(手动) + crypto_monitor_binance.env.20260517 + crypto_monitor_gate.env.20260517 + crypto_monitor_gate.env.20260517 + crypto_monitor_binance/ + 2026-05-17/ + crypto.db + static_images.tar.gz + manifest.txt + crypto_monitor_gate/ + 2026-05-17/ + ... + crypto_monitor_gate/ + 2026-05-17/ + ... +``` + +- **保留策略**:自动备份目录按日期文件夹保留 **30 天**,超期在下次 `backup_data.sh` 运行时删除。 +- **可选 `.env` 变量**(写在各实例 `.env` 中):`BACKUP_ROOT`、`BACKUP_RETENTION_DAYS`、`BACKUP_INSTANCE`(见各目录 `.env.example` 注释)。 + +--- + +## 十、卸载自动备份定时任务 + +仅删除三个实例的 backup 行(保留其它 cron): + +```bash +for dir in crypto_monitor_binance crypto_monitor_gate crypto_monitor_okx; do + SCRIPT="/opt/crypto_monitor/${dir}/scripts/backup_data.sh" + crontab -l 2>/dev/null | grep -vF "$SCRIPT" | crontab - +done +crontab -l +``` + +--- + +## 十一、相关文档 + +| 文档 | 说明 | +|------|------| +| [README.md](./README.md) | 仓库总览 | +| [crypto_monitor_binance/部署文档.md](./crypto_monitor_binance/部署文档.md) | Binance 部署与备份细节 | +| [crypto_monitor_gate/部署文档.md](./crypto_monitor_gate/部署文档.md) | Gate 部署 | +| [crypto_monitor_gate/部署文档.md](./crypto_monitor_gate/部署文档.md) | Gate 部署 | diff --git a/策略交易说明.md b/策略交易说明.md index ee7411a..51b982c 100644 --- a/策略交易说明.md +++ b/策略交易说明.md @@ -1,6 +1,6 @@ # 策略交易说明 -本文档说明仓库根目录 **共用策略逻辑** 与四个 `crypto_monitor_*` 实例中的 **策略交易** 入口(顶栏「策略交易」,页内子 Tab:趋势回调 / 顺势加仓)。 +本文档说明仓库根目录 **共用策略逻辑** 与三个 `crypto_monitor_*` 实例中的 **策略交易** 入口(顶栏「策略交易」,页内子 Tab:趋势回调 / 顺势加仓)。 --- @@ -35,7 +35,7 @@ strategy_records_register.py # /strategy/records 路由与列表数据 | 区域 | 说明 | |------|------| -| 左栏 · 趋势回调 | **四所均可**(预览、执行、自动补仓、程序止盈);运行中计划卡含 **补仓计划明细** 表 | +| 左栏 · 趋势回调 | **三所均可**(预览、执行、自动补仓、程序止盈);运行中计划卡含 **补仓计划明细** 表 | | 右栏 · 顺势加仓 | 须已有同向持仓;滚仓组/历史表在右栏内滚动 | | **策略交易记录** | 趋势回调 / 顺势加仓 **分两栏**;每条约一行摘要,点击展开详情;库内保留最近 **100** 条 | | `/trade` | 实盘下单 | 首仓、以损定仓、移动保本(不变) | @@ -44,14 +44,14 @@ strategy_records_register.py # /strategy/records 路由与列表数据 --- -## 三、趋势回调(延续 Gate 趋势机器人逻辑) +## 三、趋势回调 - **位置**:各所顶栏 **策略交易 → 趋势回调**(共用 `strategy_trend_register.py` + 各所交易所 API)。 -- **行为**:与《[crypto_monitor_gate_bot/趋势回调策略说明.md](./crypto_monitor_gate_bot/趋势回调策略说明.md)》一致——预览 → 确认执行 → 首仓 50% + 交易所止损 + 多档 **自动** 市价补仓 + 程序监控止盈。 +- **行为**:与《[docs/trend-pullback-strategy.md](./docs/trend-pullback-strategy.md)》一致——预览 → 确认执行 → 首仓 50% + 交易所止损 + 多档 **自动** 市价补仓 + 程序监控止盈。 - **共用代码**:`parse_and_compute_trend_pullback_plan` 中网格/拆档已改为调用 `strategy_trend_lib`。 - **互斥**:与「机器人下单监控」持仓上限、运行中趋势计划互斥(逻辑未改)。 -逻辑与 gate_bot 一致;各所使用自己的 API 密钥与 `crypto.db`,互不影响。 +各所使用自己的 API 密钥与 `crypto.db`,互不影响。 --- @@ -98,7 +98,7 @@ strategy_records_register.py # /strategy/records 路由与列表数据 --- -## 五、策略交易记录(四所统一) +## 五、策略交易记录(三所统一) - **入口**:顶栏 **策略交易记录** → `/strategy/records`(`strategy_records_register.register_strategy_records`)。 - **写入时机**:趋势计划结束(止盈 / 止损 / 手动结束)、**保本移交**、顺势加仓组结案时,写入表 **`strategy_trade_snapshots`**(`strategy_snapshot_lib`)。 @@ -108,7 +108,7 @@ strategy_records_register.py # /strategy/records 路由与列表数据 - **左栏卡片**:趋势回调记录;**右栏卡片**:顺势加仓记录。 - 每条默认 **一行简略**(品种、方向、结果、盈亏、补仓进度、结束时间);**点击行**展开均价/止损/止盈/补仓档位表或滚仓腿表。 - **筛选**:币种、时间排序(最新/最早)、芯片 **盈利 / 亏损 / 未补仓 / 补仓**(前端过滤,数据来自服务端 enrich 字段 `filter_pnl`、`dca_tag`、`dca_done`)。 -- **共用模板**:`strategy_templates/strategy_records_page.html`(四所 `index.html` include)。 +- **共用模板**:`strategy_templates/strategy_records_page.html`(三所 `index.html` include)。 --- @@ -132,7 +132,7 @@ strategy_records_register.py # /strategy/records 路由与列表数据 ```bash cd /opt/crypto_monitor git pull -pm2 restart crypto-monitor-binance crypto-monitor-okx crypto-monitor-gate crypto-monitor-gate-bot manual-trading-hub +pm2 restart crypto-monitor-binance crypto-monitor-okx crypto-monitor-gate manual-trading-hub pm2 save ``` @@ -144,7 +144,7 @@ pm2 save | 文档 | 内容 | |------|------| -| [crypto_monitor_gate_bot/趋势回调策略说明.md](./crypto_monitor_gate_bot/趋势回调策略说明.md) | 趋势回调细则(与四所共用逻辑一致) | +| [docs/trend-pullback-strategy.md](./docs/trend-pullback-strategy.md) | 趋势回调细则(三所共用逻辑) | | [AI复盘与模型配置说明.md](./AI复盘与模型配置说明.md) | 复盘页 AI(与策略无关) | | [manual_trading_hub/使用说明.md](./manual_trading_hub/使用说明.md) | 中控监控、全屏趋势卡两列布局 | | [docs/trend-hub-close-and-trade-records.md](./docs/trend-hub-close-and-trade-records.md) | 中控平仓、交易记录写入、补仓展示统一、漏记补录 |
-
-

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

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

实盘下单监控

- {% if focus_order_id %} - 放大查看K线(100根) - {% else %} - 暂无持仓可放大 - {% endif %} -
- {% include 'order_monitor_rule_tips_gate.html' %} -
- - - - - {% if position_sizing_mode != 'full_margin' %} - - {% endif %} - - - - - - - 成交价自动取交易所实时+成交回报 - - - - - - - -
- {% 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 %} - {{ '做多' if o.direction == 'long' else '做空' }} -
-
- - 平仓 -
-
-
- 来源: {{ o.monitor_type|default('下单监控', true) }}{% if o.key_signal_type %} · {{ o.key_signal_type }}{% endif %} - 风格: {{ o.trade_style or 'trend' }} - 风险: {% 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 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 %}{{ '%g'|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' %} - {% endif %} - - - - {% if page == 'records' %} -
-

交易记录 & 错过机会

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

记录错过机会

-
- - - - - - - - -
-
- -
-

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

-
- - - - - -
- - - - - - - - - - - - - - -
-
- - - - - - - - - -
-
双周期上下排列;截止=平仓时间:开仓前背景至平仓;截止=当前时间:最近 N 根至此刻(可看平仓后走势);标注开仓、平仓与止损位
-
- -
-
- - - - - - -
- - -
-
- -
-
-

AI复盘(按交易记录)

- -
-
- - - - - - - -
- - -
-
- 交易复盘记录 -
-
-
- AI历史复盘 -
-
-
-
-
-