Compare commits
114 Commits
c95ca6ac35
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4923b32bbe | |||
| 9f67de3677 | |||
| be51eee73f | |||
| 54c1984ec7 | |||
| be7896cc25 | |||
| 394793b9d2 | |||
| 0b8f410fbe | |||
| 687a34474d | |||
| 9d9d0af31e | |||
| bfa3352122 | |||
| 55261b7812 | |||
| 5797d49d8a | |||
| 4742a0bb9d | |||
| 32079bb4c2 | |||
| 3b687d17eb | |||
| 4f784d09ac | |||
| 052dcf63bd | |||
| ac4cdceb39 | |||
| 14dbf25798 | |||
| 6b872b1f43 | |||
| 865567fbd3 | |||
| a0d57fc65e | |||
| 5b3448b52b | |||
| e51d7824a7 | |||
| 9cb63c368a | |||
| be8e4ce6c6 | |||
| 02255a3b02 | |||
| 6352fa6be3 | |||
| 5a887de6f4 | |||
| 7d03e8e93e | |||
| d467760d5c | |||
| 4aebe70611 | |||
| ee011800e1 | |||
| 5cf88818c1 | |||
| 448e88ec55 | |||
| 0a20ee7eec | |||
| cfc703ae5b | |||
| 2dadd93d91 | |||
| 924a385d6c | |||
| 61d79c4de1 | |||
| 6ffae02d30 | |||
| 9d1986d771 | |||
| 322060de31 | |||
| 3e8ecbf712 | |||
| 384d404bb3 | |||
| bced61b9d7 | |||
| 4ad335ca84 | |||
| 157d9ada21 | |||
| 813ebf0e4e | |||
| b18b2143b5 | |||
| f63f8810e6 | |||
| 7f8ae97a98 | |||
| e3559531d9 | |||
| 016c93faf2 | |||
| e03863d780 | |||
| 54ba412d1d | |||
| 65901c5577 | |||
| acc158f85d | |||
| ea5c6cddb4 | |||
| 0dedaa2b4d | |||
| bfbd6879d6 | |||
| d3d366d0ee | |||
| faa41eece1 | |||
| f4d7dec111 | |||
| b0ec291345 | |||
| f78ea1288e | |||
| 5e507d0b66 | |||
| d938bc6c59 | |||
| 253d353206 | |||
| 1ba0014fff | |||
| caf4996159 | |||
| 89909c64a3 | |||
| 21f86906da | |||
| c302c3e4ea | |||
| 8e810154ca | |||
| ed3709dddf | |||
| a837cfd14c | |||
| 091317276d | |||
| bd759c42d6 | |||
| c0f3606ecc | |||
| c05afbbedf | |||
| 073a382d41 | |||
| ce172a7cee | |||
| 9330e356fc | |||
| deb240d4eb | |||
| c73944581c | |||
| 9c778e0232 | |||
| ff8caf7f8d | |||
| f8e760961e | |||
| 97370926d6 | |||
| 0280b4f065 | |||
| f0a158686e | |||
| e470c5952f | |||
| 3d29b4f9d9 | |||
| d8dccb8606 | |||
| 6520234bd8 | |||
| be7f5d5072 | |||
| b6acbf4b2c | |||
| 850ffcd7d2 | |||
| e307eef690 | |||
| b77741ee21 | |||
| ca1e25888d | |||
| 6287ca9129 | |||
| 7fe7c2e918 | |||
| c1ee0dae25 | |||
| 58e940629a | |||
| f9257b64e4 | |||
| 869728ce10 | |||
| ad1c08a2cc | |||
| 467d160f4d | |||
| 28a23008f3 | |||
| 42c06c0f38 | |||
| 4573ccca9a | |||
| edf4bb835d |
@@ -16,10 +16,14 @@
|
|||||||
**/.env.bak
|
**/.env.bak
|
||||||
**/.env.local
|
**/.env.local
|
||||||
manual_trading_hub/hub_settings.json
|
manual_trading_hub/hub_settings.json
|
||||||
|
manual_trading_hub/hub_backup_state.json
|
||||||
|
manual_trading_hub/hub_fund_history.json
|
||||||
|
manual_trading_hub/hub_supervisor_state.json
|
||||||
manual_trading_hub/hub_ai_summaries.json
|
manual_trading_hub/hub_ai_summaries.json
|
||||||
manual_trading_hub/hub_ai_chat.json
|
manual_trading_hub/hub_ai_chat.json
|
||||||
manual_trading_hub/hub_ai_fund_history.json
|
manual_trading_hub/hub_ai_fund_history.json
|
||||||
manual_trading_hub/data/
|
manual_trading_hub/data/
|
||||||
|
backups/
|
||||||
|
|
||||||
# 数据库与上传(运行时生成)
|
# 数据库与上传(运行时生成)
|
||||||
**/*.sqlite
|
**/*.sqlite
|
||||||
|
|||||||
@@ -1,70 +1,70 @@
|
|||||||
# AI 复盘与模型配置说明
|
# AI 复盘与模型配置说明
|
||||||
|
|
||||||
四个 `crypto_monitor_*` 实例共用仓库根目录 **`ai_client.py`**(通过 `PYTHONPATH=..` 导入)。用于 **交易记录与复盘** 页的 AI 点评、短评建议,以及从复盘截图提取结构化 JSON。
|
三个 `crypto_monitor_*` 实例共用仓库根目录 **`ai_client.py`**(通过 `PYTHONPATH=..` 导入)。用于 **交易记录与复盘** 页的 AI 点评、短评建议,以及从复盘截图提取结构化 JSON。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 一、二选一:`AI_PROVIDER`
|
## 一、二选一:`AI_PROVIDER`
|
||||||
|
|
||||||
| 值 | 说明 |
|
| 值 | 说明 |
|
||||||
|----|------|
|
|----|------|
|
||||||
| **`openai`**(默认) | OpenAI 兼容 **Chat Completions** 接口 |
|
| **`openai`**(默认) | OpenAI 兼容 **Chat Completions** 接口 |
|
||||||
| **`ollama`** | 本机 Ollama **`/api/generate`**(流式 NDJSON) |
|
| **`ollama`** | 本机 Ollama **`/api/generate`**(流式 NDJSON) |
|
||||||
|
|
||||||
在对应子目录 **`.env`** 中设置(各所 `.env.example` 已含模板):
|
在对应子目录 **`.env`** 中设置(各所 `.env.example` 已含模板):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
AI_PROVIDER=openai
|
AI_PROVIDER=openai
|
||||||
AI_TIMEOUT_SECONDS=120
|
AI_TIMEOUT_SECONDS=120
|
||||||
|
|
||||||
# OpenAI 兼容网关(默认)
|
# OpenAI 兼容网关(默认)
|
||||||
OPENAI_API_BASE=https://op.bz121.com/v1
|
OPENAI_API_BASE=https://op.bz121.com/v1
|
||||||
OPENAI_API_KEY=你的密钥
|
OPENAI_API_KEY=你的密钥
|
||||||
OPENAI_MODEL=gemma4:e4b
|
OPENAI_MODEL=gemma4:e4b
|
||||||
|
|
||||||
# 本机 Ollama(仅当 AI_PROVIDER=ollama)
|
# 本机 Ollama(仅当 AI_PROVIDER=ollama)
|
||||||
OLLAMA_API=http://127.0.0.1:11434/api/generate
|
OLLAMA_API=http://127.0.0.1:11434/api/generate
|
||||||
AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest
|
AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
### OpenAI 兼容网关
|
### OpenAI 兼容网关
|
||||||
|
|
||||||
- **Base URL**:`https://op.bz121.com/v1`(请求路径为 `{base}/chat/completions`)。
|
- **Base URL**:`https://op.bz121.com/v1`(请求路径为 `{base}/chat/completions`)。
|
||||||
- **API Key**:在 [op.bz121.com](https://op.bz121.com/) 登录后,于 **`gateway.json`** 页面复制(与网关账号一致)。
|
- **API Key**:在 [op.bz121.com](https://op.bz121.com/) 登录后,于 **`gateway.json`** 页面复制(与网关账号一致)。
|
||||||
- **默认模型**:`gemma4:e4b`(可通过 `OPENAI_MODEL` 覆盖)。
|
- **默认模型**:`gemma4:e4b`(可通过 `OPENAI_MODEL` 覆盖)。
|
||||||
|
|
||||||
### Ollama
|
### Ollama
|
||||||
|
|
||||||
- 需本机已安装并拉取对应模型;`AI_PROVIDER=ollama` 时使用 `OLLAMA_API` 与 `AI_MODEL`。
|
- 需本机已安装并拉取对应模型;`AI_PROVIDER=ollama` 时使用 `OLLAMA_API` 与 `AI_MODEL`。
|
||||||
- 四所 `app.py` **不再** 直连 Ollama;统一走 `ai_client.ai_generate` / `ai_review` / `ai_short_advice`。
|
- 三所 `app.py` **不再** 直连 Ollama;统一走 `ai_client.ai_generate` / `ai_review` / `ai_short_advice`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 二、部署注意
|
## 二、部署注意
|
||||||
|
|
||||||
1. **PM2 / 手工启动**:`ecosystem.config.cjs` 中 **`PYTHONPATH=..`** 必须包含仓库根,否则无法 `from ai_client import ...`。
|
1. **PM2 / 手工启动**:`ecosystem.config.cjs` 中 **`PYTHONPATH=..`** 必须包含仓库根,否则无法 `from ai_client import ...`。
|
||||||
2. 修改 `.env` 后重启对应实例,例如:`pm2 restart crypto_binance`(名称以你机器为准)。
|
2. 修改 `.env` 后重启对应实例,例如:`pm2 restart crypto_binance`(名称以你机器为准)。
|
||||||
3. **`git pull`** 不会改 `.env`;若 `.env.example` 新增 AI 变量,请手动补进本机 `.env`。
|
3. **`git pull`** 不会改 `.env`;若 `.env.example` 新增 AI 变量,请手动补进本机 `.env`。
|
||||||
4. **勿** 将含真实 `OPENAI_API_KEY` 的 `.env` 提交 Git。
|
4. **勿** 将含真实 `OPENAI_API_KEY` 的 `.env` 提交 Git。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 三、功能入口(网页)
|
## 三、功能入口(网页)
|
||||||
|
|
||||||
登录后进入 **「交易记录与复盘」**:
|
登录后进入 **「交易记录与复盘」**:
|
||||||
|
|
||||||
- 单条记录 **AI 复盘** / **短评**(依赖上述配置)。
|
- 单条记录 **AI 复盘** / **短评**(依赖上述配置)。
|
||||||
- 上传复盘图后 **从图片提取** 字段(内部调用 `ai_generate`,与所选 provider 一致)。
|
- 上传复盘图后 **从图片提取** 字段(内部调用 `ai_generate`,与所选 provider 一致)。
|
||||||
|
|
||||||
若请求超时或返回错误,请检查:密钥是否有效、网关是否可达、`AI_TIMEOUT_SECONDS` 是否过短、Ollama 是否已启动(仅 ollama 模式)。
|
若请求超时或返回错误,请检查:密钥是否有效、网关是否可达、`AI_TIMEOUT_SECONDS` 是否过短、Ollama 是否已启动(仅 ollama 模式)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 四、相关文件
|
## 四、相关文件
|
||||||
|
|
||||||
| 路径 | 说明 |
|
| 路径 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `ai_client.py` | 统一封装 OpenAI / Ollama |
|
| `ai_client.py` | 统一封装 OpenAI / Ollama |
|
||||||
| `crypto_monitor_*/.env.example` | 各所环境变量模板 |
|
| `crypto_monitor_*/.env.example` | 各所环境变量模板 |
|
||||||
| 各所《部署文档.md》§ AI 复盘 | 与本文一致的简表 |
|
| 各所《部署文档.md》§ AI 复盘 | 与本文一致的简表 |
|
||||||
| 各所《使用说明.md》 | 运行前配置中的 AI 项 |
|
| 各所《使用说明.md》 | 运行前配置中的 AI 项 |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 复盘交易系统(crypto_monitor)
|
# 复盘交易系统(crypto_monitor)
|
||||||
|
|
||||||
多交易所 **USDT 永续** 的下单监控、**关键位**、**策略交易**、**止盈止损 / 移动保本** 与 **AI 复盘**,四所独立部署 + 可选 **中控** 聚合监控。
|
多交易所 **USDT 永续** 的下单监控、**关键位**、**策略交易**、**止盈止损 / 移动保本** 与 **AI 复盘**,三所独立部署 + 可选 **中控** 聚合监控。
|
||||||
|
|
||||||
**远程仓库**:[https://git.bz121.com/dekun/crypto_monitor.git](https://git.bz121.com/dekun/crypto_monitor.git)
|
**远程仓库**:[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)** |
|
| **关键位监控** | 箱体/收敛自动开仓、阻力支撑提醒、斐波限价;止盈止损方案与 **移动保本** 开关 | 各所 [关键位自动下单说明.md](./crypto_monitor_binance/关键位自动下单说明.md)(Gate/OKX 目录内同名);方案细则 **[关键位止盈止损与移动保本更新说明.md](./关键位止盈止损与移动保本更新说明.md)** |
|
||||||
| **实盘下单 / 下单监控** | 首仓、以损定仓;监控内 **止盈 / 止损**、**移动保本**(步进 R、偏移%) | 各所 [使用说明.md](./crypto_monitor_binance/使用说明.md) · 顶栏「实盘下单」`/trade` |
|
| **实盘下单 / 下单监控** | 首仓、以损定仓;监控内 **止盈 / 止损**、**移动保本**(步进 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` |
|
| **策略交易记录** | 已结束计划快照(最近 100 条)、筛选与展开详情 | [策略交易说明.md §五](./策略交易说明.md) · 顶栏 `/strategy/records` |
|
||||||
| **交易复盘** | 平仓记录、错过机会、图表;**AI 点评** | **[AI复盘与模型配置说明.md](./AI复盘与模型配置说明.md)** · 顶栏「交易记录与复盘」`/records` |
|
| **交易复盘** | 平仓记录、错过机会、图表;**AI 点评** | **[AI复盘与模型配置说明.md](./AI复盘与模型配置说明.md)** · 顶栏「交易记录与复盘」`/records` |
|
||||||
| **中控** | 多账户持仓/委托聚合、行情 K 线、紧急全平(**不在中控网页下单**) | [manual_trading_hub/使用说明.md](./manual_trading_hub/使用说明.md) · [部署文档.md](./manual_trading_hub/部署文档.md) |
|
| **中控** | 多账户持仓/委托聚合、行情 K 线、紧急全平(**不在中控网页下单**) | [manual_trading_hub/使用说明.md](./manual_trading_hub/使用说明.md) · [部署文档.md](./manual_trading_hub/部署文档.md) |
|
||||||
@@ -49,19 +49,21 @@ bash deploy/setup_env.sh --install-system-deps
|
|||||||
| 目录 | 交易所 / 角色 | 部署文档 |
|
| 目录 | 交易所 / 角色 | 部署文档 |
|
||||||
|------|----------------|----------|
|
|------|----------------|----------|
|
||||||
| `crypto_monitor_binance/` | Binance U 本位永续 | [部署文档.md](./crypto_monitor_binance/部署文档.md) |
|
| `crypto_monitor_binance/` | Binance U 本位永续 | [部署文档.md](./crypto_monitor_binance/部署文档.md) |
|
||||||
| `crypto_monitor_gate/` | Gate 主号 | [部署文档.md](./crypto_monitor_gate/部署文档.md) |
|
| `crypto_monitor_gate/` | Gate | [部署文档.md](./crypto_monitor_gate/部署文档.md) |
|
||||||
| `crypto_monitor_gate_bot/` | Gate 机器人 / 趋势户 | [部署文档.md](./crypto_monitor_gate_bot/部署文档.md) |
|
|
||||||
| `crypto_monitor_okx/` | OKX 永续 | [部署文档.md](./crypto_monitor_okx/部署文档.md) |
|
| `crypto_monitor_okx/` | OKX 永续 | [部署文档.md](./crypto_monitor_okx/部署文档.md) |
|
||||||
| `manual_trading_hub/` | 中控 + 子代理 | [部署文档.md](./manual_trading_hub/部署文档.md) |
|
| `manual_trading_hub/` | 中控 + 子代理 | [部署文档.md](./manual_trading_hub/部署文档.md) |
|
||||||
| 根目录 `strategy_*.py` | 策略共用库 | [策略交易说明.md](./策略交易说明.md) |
|
| `lib/` | **共用模块**(策略、关键位、交易、中控库、AI、静态与模板) | **[docs/lib-structure.md](./docs/lib-structure.md)** |
|
||||||
| 根目录 `key_*_lib.py` | 关键位 / 止盈止损共用库 | [关键位止盈止损与移动保本更新说明.md](./关键位止盈止损与移动保本更新说明.md) |
|
| `brand/` | 各所共用图标与 manifest | — |
|
||||||
|
| `docs/`、`deploy/`、`scripts/`、`tests/` | 文档、环境、脚本、单元测试 | — |
|
||||||
|
|
||||||
|
共用代码 import 示例:`from lib.strategy.strategy_db import init_strategy_tables`(各所启动时仍将仓库根加入 `PYTHONPATH`)。详见 **[docs/lib-structure.md](./docs/lib-structure.md)**。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 技术要点
|
## 技术要点
|
||||||
|
|
||||||
- **Python 3.10+**、Flask、ccxt、SQLite(`crypto.db`)
|
- **Python 3.10+**、Flask、ccxt、SQLite(`crypto.db`)
|
||||||
- 四所 `.env` 前缀不同(`BINANCE_*` / `GATE_*` / `OKX_*`),**不可混用**
|
- 三所 `.env` 前缀不同(`BINANCE_*` / `GATE_*` / `OKX_*`),**不可混用**
|
||||||
- 实盘须 `LIVE_TRADING_ENABLED=true` 且理解 API 权限与 IP 白名单风险
|
- 实盘须 `LIVE_TRADING_ENABLED=true` 且理解 API 权限与 IP 白名单风险
|
||||||
- 经 **SOCKS** 访问交易所时配置各所 `*_SOCKS_PROXY` 并安装 PySocks
|
- 经 **SOCKS** 访问交易所时配置各所 `*_SOCKS_PROXY` 并安装 PySocks
|
||||||
|
|
||||||
@@ -69,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` 复制)
|
2. 各所 **`.env`**(从 `.env.example` 复制)
|
||||||
3. 所用功能对应上表 **功能导航** 文档
|
3. 所用功能对应上表 **功能导航** 文档
|
||||||
4. [备份与恢复.md](./备份与恢复.md) — 生产机备份习惯
|
4. [备份与恢复.md](./备份与恢复.md) — 生产机备份习惯
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
{
|
{
|
||||||
"name": "复盘系统中控",
|
"name": "复盘系统中控",
|
||||||
"short_name": "中控",
|
"short_name": "中控",
|
||||||
"description": "四所交易监控与行情中控",
|
"description": "三所交易监控与行情中控",
|
||||||
"start_url": "/monitor",
|
"start_url": "/monitor",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#0b0e18",
|
"background_color": "#0b0e18",
|
||||||
"theme_color": "#0b0e18",
|
"theme_color": "#0b0e18",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "__ICON_PREFIX__/icon-192.png",
|
"src": "__ICON_PREFIX__/icon-192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "__ICON_PREFIX__/icon-512.png",
|
"src": "__ICON_PREFIX__/icon-512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any maskable"
|
"purpose": "any maskable"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ APP_PORT=5001
|
|||||||
APP_DEBUG=false
|
APP_DEBUG=false
|
||||||
|
|
||||||
# 登录账号
|
# 登录账号
|
||||||
APP_USERNAME=dekun
|
APP_USERNAME=admin
|
||||||
# 登录密码(请改成你自己的强密码)
|
# 登录密码(请改成你自己的强密码)
|
||||||
APP_PASSWORD=ChangeMe123!
|
APP_PASSWORD=admin123
|
||||||
# 是否关闭登录校验(局域网可设 true;公网务必 false)
|
# 是否关闭登录校验(局域网可设 true;公网务必 false)
|
||||||
APP_AUTH_DISABLED=true
|
APP_AUTH_DISABLED=true
|
||||||
# --- 多账户交易中控 manual_trading_hub ---
|
# --- 多账户交易中控 manual_trading_hub ---
|
||||||
@@ -127,6 +127,16 @@ DAILY_OPEN_ALERT_THRESHOLD=5
|
|||||||
# 【单日开仓硬上限】本交易日开仓次数>=该值后禁止一切新开仓直至下一交易日(北京时间 TRADING_DAY_RESET_HOUR 切日);0=不启用
|
# 【单日开仓硬上限】本交易日开仓次数>=该值后禁止一切新开仓直至下一交易日(北京时间 TRADING_DAY_RESET_HOUR 切日);0=不启用
|
||||||
DAILY_OPEN_HARD_LIMIT=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
|
BALANCE_REFRESH_SECONDS=60
|
||||||
# 前端价格快照轮询(秒)
|
# 前端价格快照轮询(秒)
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<script src="/static/instance_theme.js?v=6"></script>
|
<script src="/static/instance_theme.js?v=46"></script>
|
||||||
<link rel="stylesheet" href="/static/instance_theme_early.css?v=1">
|
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
|
||||||
|
<link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
|
||||||
|
<script src="/static/account_risk_badge.js?v=4"></script>
|
||||||
|
|
||||||
<meta name="theme-color" content="#0b0d14">
|
<meta name="theme-color" content="#0b0d14">
|
||||||
<meta name="apple-mobile-web-app-title" content="监控">
|
<meta name="apple-mobile-web-app-title" content="监控">
|
||||||
@@ -20,6 +22,7 @@
|
|||||||
.header{display:flex;flex-direction:column;align-items:center;gap:8px;margin-bottom:12px}
|
.header{display:flex;flex-direction:column;align-items:center;gap:8px;margin-bottom:12px}
|
||||||
.header h1{font-size:1.75rem;color:#dbe4ff;text-align:center;line-height:1.25}
|
.header h1{font-size:1.75rem;color:#dbe4ff;text-align:center;line-height:1.25}
|
||||||
.exchange-tag{font-size:.82rem;font-weight:600;color:#b8f5d0;background:#14241e;border:1px solid #2d6a4f;padding:5px 14px;border-radius:999px;letter-spacing:.06em}
|
.exchange-tag{font-size:.82rem;font-weight:600;color:#b8f5d0;background:#14241e;border:1px solid #2d6a4f;padding:5px 14px;border-radius:999px;letter-spacing:.06em}
|
||||||
|
.header-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:center}
|
||||||
.top-nav{display:flex;gap:8px;flex-wrap:wrap;justify-content:center;margin-bottom:12px}
|
.top-nav{display:flex;gap:8px;flex-wrap:wrap;justify-content:center;margin-bottom:12px}
|
||||||
.top-nav a{padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a;color:#8fc8ff;text-decoration:none}
|
.top-nav a{padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a;color:#8fc8ff;text-decoration:none}
|
||||||
.top-nav a.active{background:#2a3f6c;color:#dbe4ff}
|
.top-nav a.active{background:#2a3f6c;color:#dbe4ff}
|
||||||
@@ -34,6 +37,12 @@
|
|||||||
.form-row{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px;align-items:center}
|
.form-row{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px;align-items:center}
|
||||||
.form-row > input:not([type=checkbox]):not([type=radio]),.form-row > select{flex:0 1 auto;width:10rem;max-width:200px;min-width:7rem}
|
.form-row > input:not([type=checkbox]):not([type=radio]),.form-row > select{flex:0 1 auto;width:10rem;max-width:200px;min-width:7rem}
|
||||||
#add-order-form #sltp-mode{min-width:12.5rem;max-width:16rem;width:auto}
|
#add-order-form #sltp-mode{min-width:12.5rem;max-width:16rem;width:auto}
|
||||||
|
.order-plan-preview{display:flex;gap:18px;flex-wrap:wrap;align-items:center;margin:4px 0 10px;padding:10px 12px;background:#151a28;border:1px solid #2a3150;border-radius:8px;font-size:.85rem}
|
||||||
|
.order-preview-risk{color:#ff6b6b}.order-preview-risk strong{color:#ff8f8f;font-weight:600}
|
||||||
|
.order-preview-profit{color:#4cd97f}.order-preview-profit strong{color:#6ee7a0;font-weight:600}
|
||||||
|
.order-preview-rr{color:#cfd3ef}.order-preview-rr strong{font-weight:600;color:#dbe4ff}
|
||||||
|
.order-preview-rr.order-preview-rr-low strong{color:#ff8f8f}
|
||||||
|
.order-preview-rr.order-preview-rr-ok strong{color:#8fc8ff}
|
||||||
.form-row > button,.form-row > label{flex:0 0 auto}
|
.form-row > button,.form-row > label{flex:0 0 auto}
|
||||||
.form-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
|
.form-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
|
||||||
/* 复盘表单:长下拉文案需可收缩,否则会撑破四列网格 */
|
/* 复盘表单:长下拉文案需可收缩,否则会撑破四列网格 */
|
||||||
@@ -234,10 +243,17 @@
|
|||||||
.stats-period-block h3{font-size:1rem;color:#dbe4ff;margin-bottom:4px}
|
.stats-period-block h3{font-size:1rem;color:#dbe4ff;margin-bottom:4px}
|
||||||
.stats-period-block .sub{font-size:.78rem;color:#8892b0;margin-bottom:10px;line-height:1.4}
|
.stats-period-block .sub{font-size:.78rem;color:#8892b0;margin-bottom:10px;line-height:1.4}
|
||||||
</style>
|
</style>
|
||||||
<link rel="stylesheet" href="/static/instance_theme.css?v=14">
|
<link rel="stylesheet" href="/static/instance_theme.css?v=48">
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
<body data-page="{{ page }}">
|
<body
|
||||||
|
data-page="{{ page }}"
|
||||||
|
data-risk-percent="{{ risk_percent }}"
|
||||||
|
data-position-sizing-mode="{{ position_sizing_mode }}"
|
||||||
|
data-btc-leverage="{{ btc_leverage }}"
|
||||||
|
data-alt-leverage="{{ alt_leverage }}"
|
||||||
|
data-full-margin-buffer="{{ full_margin_buffer_ratio }}"
|
||||||
|
>
|
||||||
{% macro period_stats(title, s) %}
|
{% macro period_stats(title, s) %}
|
||||||
<div class="stats-period-block">
|
<div class="stats-period-block">
|
||||||
<h3>{{ title }}</h3>
|
<h3>{{ title }}</h3>
|
||||||
@@ -262,6 +278,7 @@
|
|||||||
<h1>加密货币|交易监控 + AI复盘一体化</h1>
|
<h1>加密货币|交易监控 + AI复盘一体化</h1>
|
||||||
<div class="header-row">
|
<div class="header-row">
|
||||||
<div class="exchange-tag">{{ exchange_display }}</div>
|
<div class="exchange-tag">{{ exchange_display }}</div>
|
||||||
|
<span class="risk-status-badge risk-status-{{ risk_status.status|default('normal') }}" id="account-risk-badge" role="status" title="{{ risk_status.reason|default('', true) }}" data-status-label="{{ risk_status.status_label|default('正常') }}"{% if risk_status.freeze_until_ms %} data-freeze-until-ms="{{ risk_status.freeze_until_ms }}"{% endif %}>{{ risk_status.status_label|default('正常') }}</span>
|
||||||
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
|
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
|
||||||
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
|
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
|
||||||
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
||||||
@@ -356,7 +373,7 @@
|
|||||||
</select>
|
</select>
|
||||||
<button type="submit">手动划转</button>
|
<button type="submit">手动划转</button>
|
||||||
</form>
|
</form>
|
||||||
<form id="add-order-form" action="/add_order" method="post" class="form-row">
|
<form id="add-order-form" action="/add_order" method="post" class="form-row" data-risk-percent="{{ risk_percent }}">
|
||||||
<input id="order-symbol" name="symbol" placeholder="BTC 或 BTC/USDT" required>
|
<input id="order-symbol" name="symbol" placeholder="BTC 或 BTC/USDT" required>
|
||||||
<select id="order-direction" name="direction" required>
|
<select id="order-direction" name="direction" required>
|
||||||
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
|
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
|
||||||
@@ -398,9 +415,23 @@
|
|||||||
<input id="order-tp-pct" name="tp_pct" type="number" min="0.01" step="0.01" placeholder="止盈%" style="display:none">
|
<input id="order-tp-pct" name="tp_pct" type="number" min="0.01" step="0.01" placeholder="止盈%" style="display:none">
|
||||||
<button type="submit">{{ open_position_button_label }}</button>
|
<button type="submit">{{ open_position_button_label }}</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% include 'order_plan_preview_bar.html' %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 style="margin-bottom:8px">实时持仓</h2>
|
<h2 style="margin-bottom:8px">实时持仓</h2>
|
||||||
|
{% if not order and orphan_live_positions %}
|
||||||
|
{% set o = orphan_live_positions[0] %}
|
||||||
|
<div id="orphan-position-recover" class="orphan-recover-banner" style="display:block;margin-bottom:10px;padding:10px 12px;background:#2a2210;border:1px solid #6b5420;border-radius:6px;font-size:.9rem;color:#e8d5a8">
|
||||||
|
检测到交易所仍有 <strong>{{ o.symbol }}</strong> {{ '空' if o.direction == 'short' else '多' }}仓,但本地监控已中断(误同步时可能无交易记录)。
|
||||||
|
{% if o.recoverable_monitor_id %}
|
||||||
|
<button type="button" class="pos-entrust-btn" onclick="recoverLivePosition({{ o.recoverable_monitor_id }})">恢复监控{% if o.plan_stop_loss and o.plan_take_profit %}并挂止盈止损{% endif %}</button>
|
||||||
|
{% else %}
|
||||||
|
未找到可恢复的监控记录,需在服务器数据库处理。
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div id="orphan-position-recover" class="orphan-recover-banner" style="display:none;margin-bottom:10px;padding:10px 12px;background:#2a2210;border:1px solid #6b5420;border-radius:6px;font-size:.9rem;color:#e8d5a8"></div>
|
||||||
|
{% endif %}
|
||||||
<div class="panel-scroll pos-list pos-list-live">
|
<div class="panel-scroll pos-list pos-list-live">
|
||||||
{% for o in order %}
|
{% for o in order %}
|
||||||
<div class="pos-card" id="order-row-{{ o.id }}"
|
<div class="pos-card" id="order-row-{{ o.id }}"
|
||||||
@@ -431,6 +462,7 @@
|
|||||||
<span class="pos-meta-item">来源: {{ o.monitor_type|default('下单监控', true) }}{% if o.key_signal_type %} · {{ o.key_signal_type }}{% endif %}</span>
|
<span class="pos-meta-item">来源: {{ o.monitor_type|default('下单监控', true) }}{% if o.key_signal_type %} · {{ o.key_signal_type }}{% endif %}</span>
|
||||||
<span class="pos-meta-item">风格: {{ o.trade_style or 'trend' }}</span>
|
<span class="pos-meta-item">风格: {{ o.trade_style or 'trend' }}</span>
|
||||||
<span class="pos-meta-item">风险: {% 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 %}</span>
|
<span class="pos-meta-item">风险: {% 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 %}</span>
|
||||||
|
<span class="pos-meta-item" id="order-latest-risk-wrap-{{ o.id }}" style="display:none">最新风险: —</span>
|
||||||
<span class="pos-meta-item {% if o.breakeven_enabled %}pos-meta-on{% else %}pos-meta-off{% endif %}">
|
<span class="pos-meta-item {% if o.breakeven_enabled %}pos-meta-on{% else %}pos-meta-off{% endif %}">
|
||||||
{% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %}
|
{% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %}
|
||||||
</span>
|
</span>
|
||||||
@@ -453,6 +485,10 @@
|
|||||||
<span class="pos-label">盈亏比</span>
|
<span class="pos-label">盈亏比</span>
|
||||||
<span class="pos-value" id="order-rr-{{ o.id }}">{% if o.rr_ratio is not none %}{{ '%g'|format(o.rr_ratio) }}:1{% else %}-:1{% endif %}</span>
|
<span class="pos-value" id="order-rr-{{ o.id }}">{% if o.rr_ratio is not none %}{{ '%g'|format(o.rr_ratio) }}:1{% else %}-:1{% endif %}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pos-cell">
|
||||||
|
<span class="pos-label">张数</span>
|
||||||
|
<span class="pos-value" id="order-contracts-{{ o.id }}">{% if o.order_amount is not none %}{{ '%g'|format(o.order_amount) }}{% else %}—{% endif %}</span>
|
||||||
|
</div>
|
||||||
<div class="pos-cell">
|
<div class="pos-cell">
|
||||||
<span class="pos-label">标记价</span>
|
<span class="pos-label">标记价</span>
|
||||||
<span class="pos-value" id="order-price-{{ o.id }}">-</span>
|
<span class="pos-value" id="order-price-{{ o.id }}">-</span>
|
||||||
@@ -467,6 +503,8 @@
|
|||||||
<span>计划基数: {{ funds_fmt(o.margin_capital) if o.margin_capital is not none else '-' }}U</span>
|
<span>计划基数: {{ funds_fmt(o.margin_capital) if o.margin_capital is not none else '-' }}U</span>
|
||||||
<span>杠杆: {{ o.leverage or '-' }}x</span>
|
<span>杠杆: {{ o.leverage or '-' }}x</span>
|
||||||
<span>仓位占比: {{ o.position_ratio if o.position_ratio is not none else '-' }}%</span>
|
<span>仓位占比: {{ o.position_ratio if o.position_ratio is not none else '-' }}%</span>
|
||||||
|
<span>开仓时间: {{ (o.opened_at or '-')[:16] }}</span>
|
||||||
|
<span>持仓时长: <span class="order-hold-duration" id="order-hold-duration-{{ o.id }}" data-order-opened-ms="{{ o.opened_at_ms or '' }}">—</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="pos-ex-orders">
|
<div class="pos-ex-orders">
|
||||||
<div class="pos-ex-orders-title">交易所止盈止损</div>
|
<div class="pos-ex-orders-title">交易所止盈止损</div>
|
||||||
@@ -609,8 +647,7 @@
|
|||||||
<select name="type" required>
|
<select name="type" required>
|
||||||
<option value="箱体突破">箱体突破</option>
|
<option value="箱体突破">箱体突破</option>
|
||||||
<option value="收敛突破">收敛突破</option>
|
<option value="收敛突破">收敛突破</option>
|
||||||
<option value="关键阻力位">关键阻力位</option>
|
<option value="关键支撑阻力">关键支撑阻力</option>
|
||||||
<option value="关键支撑位">关键支撑位</option>
|
|
||||||
</select>
|
</select>
|
||||||
<select name="direction" required>
|
<select name="direction" required>
|
||||||
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
|
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
|
||||||
@@ -803,10 +840,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/instance_ui.js?v=1"></script>
|
<script src="/static/instance_ui.js?v=4"></script>
|
||||||
|
<script src="/static/instance_records_mobile.js?v=2"></script>
|
||||||
<script src="/static/time_close_ui.js?v=2"></script>
|
<script src="/static/time_close_ui.js?v=2"></script>
|
||||||
<script src="/static/ai_review_render.js?v=2"></script>
|
<script src="/static/ai_review_render.js?v=2"></script>
|
||||||
<script src="/static/form_submit_guard.js?v=2"></script>
|
<script src="/static/form_submit_guard.js?v=2"></script>
|
||||||
|
<script src="/static/manual_order_rr_preview.js?v=5"></script>
|
||||||
|
<script src="/static/strategy_roll.js?v=6"></script>
|
||||||
<script>
|
<script>
|
||||||
const JOURNAL_ENTRY_REASON_OPTIONS = {{ entry_reason_options | tojson }};
|
const JOURNAL_ENTRY_REASON_OPTIONS = {{ entry_reason_options | tojson }};
|
||||||
const JOURNAL_ENTRY_REASON_OTHER = {{ entry_reason_other_value | tojson }};
|
const JOURNAL_ENTRY_REASON_OTHER = {{ entry_reason_other_value | tojson }};
|
||||||
@@ -887,8 +927,10 @@ function setDetailBodyPlain(text){
|
|||||||
body.innerText = text || "";
|
body.innerText = text || "";
|
||||||
}
|
}
|
||||||
function setDetailBodyMarkdown(text){
|
function setDetailBodyMarkdown(text){
|
||||||
|
if(window.InstanceUI && InstanceUI.clearDetailActions) InstanceUI.clearDetailActions();
|
||||||
const body = document.getElementById("detailBody");
|
const body = document.getElementById("detailBody");
|
||||||
if(!body) return;
|
if(!body) return;
|
||||||
|
body.classList.remove("trade-record-detail-wrap", "journal-detail-meta");
|
||||||
if(window.AiReviewRender && AiReviewRender.setElementMarkdown){
|
if(window.AiReviewRender && AiReviewRender.setElementMarkdown){
|
||||||
body.classList.add("md-review");
|
body.classList.add("md-review");
|
||||||
AiReviewRender.setElementMarkdown(body, text || "");
|
AiReviewRender.setElementMarkdown(body, text || "");
|
||||||
@@ -1090,22 +1132,12 @@ function loadJournals(){
|
|||||||
const qs = listWindowQueryString();
|
const qs = listWindowQueryString();
|
||||||
fetch("/api/journals" + (qs ? "?" + qs : "")).then(r=>r.json()).then(data=>{
|
fetch("/api/journals" + (qs ? "?" + qs : "")).then(r=>r.json()).then(data=>{
|
||||||
Object.keys(journalCache).forEach(k=>delete journalCache[k]);
|
Object.keys(journalCache).forEach(k=>delete journalCache[k]);
|
||||||
let html="";
|
data.forEach(o=>{ journalCache[o.id] = o; });
|
||||||
data.forEach(o=>{
|
|
||||||
journalCache[o.id] = o;
|
|
||||||
const moodTags = (o.mood_issues || []).join(",") || "无";
|
|
||||||
html += `<div class="entry">
|
|
||||||
<div><strong>${o.coin||"-"} ${o.tf||"-"}</strong> | 盈亏:${o.pnl||"-"}U</div>
|
|
||||||
<div>开:${o.open_datetime||"-"} 平:${o.close_datetime||"-"} 持仓:${o.hold_duration||"-"}</div>
|
|
||||||
<div>心态标签:${moodTags}</div>
|
|
||||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:6px">
|
|
||||||
<button type="button" class="btn-del" style="border:none;cursor:pointer;background:#1f3a5a;color:#8fc8ff" onclick="openJournalDetail('${o.id}')">查看详情</button>
|
|
||||||
<button type="button" class="btn-del" onclick="deleteJournal('${o.id}')">删除</button>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
});
|
|
||||||
const box = document.getElementById("journal-list");
|
const box = document.getElementById("journal-list");
|
||||||
if(box){ box.innerHTML = html || "<div class='entry'>暂无数据</div>"; }
|
if(box){
|
||||||
|
const html = InstanceUI.renderJournalListHtml(data);
|
||||||
|
box.innerHTML = html || "<div class='journal-empty-msg'>暂无数据</div>";
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1529,108 +1561,9 @@ if(journalForm){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 rsTypes = new Set(["关键阻力位","关键支撑位"]);
|
|
||||||
const showAuto = autoTypes.has(t);
|
|
||||||
const showFb = fbTypes.has(t);
|
|
||||||
const showBe = showAuto || fibTypes.has(t) || showFb;
|
|
||||||
const showDir = !rsTypes.has(t);
|
|
||||||
const upperEl = document.getElementById("key-upper");
|
|
||||||
const lowerEl = document.getElementById("key-lower");
|
|
||||||
const fbPriceEl = document.getElementById("key-fb-price");
|
|
||||||
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(window.TimeCloseUI) TimeCloseUI.syncKeyTimeCloseVisibility(showBe);
|
|
||||||
if(upperEl){
|
|
||||||
upperEl.style.display = showFb ? "none" : "";
|
|
||||||
upperEl.required = !showFb;
|
|
||||||
if(showFb) upperEl.value = "";
|
|
||||||
}
|
|
||||||
if(lowerEl){
|
|
||||||
lowerEl.style.display = showFb ? "none" : "";
|
|
||||||
lowerEl.required = !showFb;
|
|
||||||
if(showFb) 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") ? "低点(支撑)" : "做空填高点/做多填低点");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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(window.TimeCloseUI){
|
if(window.TimeCloseUI){
|
||||||
TimeCloseUI.bindTimeCloseForm("key-time-close-cb", "key-time-close-hours", "key-time-close-wrap");
|
|
||||||
TimeCloseUI.bindTimeCloseForm("order-time-close-cb", "order-time-close-hours", "order-time-close-wrap");
|
TimeCloseUI.bindTimeCloseForm("order-time-close-cb", "order-time-close-hours", "order-time-close-wrap");
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyForm = document.getElementById("key-form");
|
|
||||||
if(keyForm){
|
|
||||||
keyForm.addEventListener("submit", (e)=>{
|
|
||||||
e.preventDefault();
|
|
||||||
if(window.FormSubmitGuard && 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 === "假突破"){
|
|
||||||
if(window.FormSubmitGuard) FormSubmitGuard.nativeSubmitOnce(keyForm, "提交中…");
|
|
||||||
else keyForm.submit();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if(window.FormSubmitGuard) 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(window.FormSubmitGuard) FormSubmitGuard.unlock(keyForm);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const rankMax = data.rank_max || 30;
|
|
||||||
if(!data.in_top30){
|
|
||||||
alert(`${data.symbol} 当前日成交量排名 ${data.rank}/${data.total},不在前${rankMax},已拦截。`);
|
|
||||||
if(window.FormSubmitGuard) FormSubmitGuard.unlock(keyForm);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if(window.FormSubmitGuard) FormSubmitGuard.nativeSubmitOnce(keyForm, "提交中…");
|
|
||||||
else keyForm.submit();
|
|
||||||
})
|
|
||||||
.catch(()=>{
|
|
||||||
alert("日成交量排名检查失败,请稍后重试");
|
|
||||||
if(window.FormSubmitGuard) FormSubmitGuard.unlock(keyForm);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// 复盘/AI列表:初次进入页面后再异步刷新一次,避免浏览器 bfcache/重定向后仍显示旧缓存
|
// 复盘/AI列表:初次进入页面后再异步刷新一次,避免浏览器 bfcache/重定向后仍显示旧缓存
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if(document.getElementById("journal-list")) loadJournals();
|
if(document.getElementById("journal-list")) loadJournals();
|
||||||
@@ -1681,6 +1614,7 @@ function refreshOrderTpPreview(entryPx){
|
|||||||
const entry = entryPx != null && Number.isFinite(Number(entryPx)) ? Number(entryPx) : sl;
|
const entry = entryPx != null && Number.isFinite(Number(entryPx)) ? Number(entryPx) : sl;
|
||||||
const tp = calcTpFromFixedRr(direction, entry, sl, rr);
|
const tp = calcTpFromFixedRr(direction, entry, sl, rr);
|
||||||
preview.textContent = tp == null ? "预估止盈:—" : ("预估止盈:" + formatPriceForInput(tp));
|
preview.textContent = tp == null ? "预估止盈:—" : ("预估止盈:" + formatPriceForInput(tp));
|
||||||
|
if(window.ManualOrderRrPreview) ManualOrderRrPreview.schedule();
|
||||||
}
|
}
|
||||||
function calcClientRr(direction, entry, sl, tp){
|
function calcClientRr(direction, entry, sl, tp){
|
||||||
const e = Number(entry), s = Number(sl), t = Number(tp);
|
const e = Number(entry), s = Number(sl), t = Number(tp);
|
||||||
@@ -1767,6 +1701,13 @@ function submitTpslEntrust(){
|
|||||||
alert(data.msg || '已提交');
|
alert(data.msg || '已提交');
|
||||||
closeTpslEntrustModal();
|
closeTpslEntrustModal();
|
||||||
if(data.exchange_tpsl) paintExchangeTpslRow(orderId, data.exchange_tpsl);
|
if(data.exchange_tpsl) paintExchangeTpslRow(orderId, data.exchange_tpsl);
|
||||||
|
paintPlanTpslDisplay(orderId, data);
|
||||||
|
paintLatestRiskDisplay(orderId, data);
|
||||||
|
const rrEl = document.getElementById(`order-rr-${orderId}`);
|
||||||
|
if(rrEl){
|
||||||
|
const rr = data.display_rr_ratio != null && data.display_rr_ratio !== "" ? data.display_rr_ratio : data.planned_rr;
|
||||||
|
rrEl.innerText = formatRrRatio(rr);
|
||||||
|
}
|
||||||
refreshPriceSnapshotConditional();
|
refreshPriceSnapshotConditional();
|
||||||
}).catch(()=>alert('委托请求失败'));
|
}).catch(()=>alert('委托请求失败'));
|
||||||
}
|
}
|
||||||
@@ -1834,6 +1775,25 @@ function paintPlanTpslDisplay(orderId, snap){
|
|||||||
else if(tpDisp) card.setAttribute("data-plan-tp", tpDisp);
|
else if(tpDisp) card.setAttribute("data-plan-tp", tpDisp);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function paintLatestRiskDisplay(orderId, snap){
|
||||||
|
const wrap = document.getElementById(`order-latest-risk-wrap-${orderId}`);
|
||||||
|
if(!wrap) return;
|
||||||
|
const v = snap && snap.latest_risk_amount;
|
||||||
|
const n = v != null && v !== "" ? Number(v) : NaN;
|
||||||
|
if(Number.isFinite(n)){
|
||||||
|
wrap.style.display = "inline-flex";
|
||||||
|
wrap.textContent = `最新风险: ${n.toFixed(2)}U`;
|
||||||
|
} else {
|
||||||
|
wrap.style.display = "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function paintContractsDisplay(orderId, snap){
|
||||||
|
const el = document.getElementById(`order-contracts-${orderId}`);
|
||||||
|
if(!el || !snap) return;
|
||||||
|
const v = snap.contracts != null && snap.contracts !== "" ? snap.contracts : snap.order_amount;
|
||||||
|
const n = v != null && v !== "" ? Number(v) : NaN;
|
||||||
|
el.innerText = Number.isFinite(n) ? String(parseFloat(n.toFixed(4))) : "—";
|
||||||
|
}
|
||||||
|
|
||||||
function paintPriceTrend(el, key, value){
|
function paintPriceTrend(el, key, value){
|
||||||
if(!el) return;
|
if(!el) return;
|
||||||
@@ -1849,6 +1809,46 @@ function paintPriceTrend(el, key, value){
|
|||||||
lastPriceMap[key] = value;
|
lastPriceMap[key] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderOrphanRecoverBanner(orphans){
|
||||||
|
const el = document.getElementById("orphan-position-recover");
|
||||||
|
if(!el) return;
|
||||||
|
const liveCards = document.querySelectorAll(".pos-list-live .pos-card");
|
||||||
|
if(liveCards.length > 0 || !orphans || !orphans.length){
|
||||||
|
el.style.display = "none";
|
||||||
|
el.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const o = orphans[0];
|
||||||
|
const dir = o.direction === "short" ? "空" : "多";
|
||||||
|
const mid = o.recoverable_monitor_id;
|
||||||
|
let html = `检测到交易所仍有 <strong>${o.symbol}</strong> ${dir}仓,但本地监控已中断(误同步时可能无交易记录)。`;
|
||||||
|
if(mid){
|
||||||
|
const tpslHint = (o.plan_stop_loss && o.plan_take_profit) ? "并挂止盈止损" : "";
|
||||||
|
html += ` <button type="button" class="pos-entrust-btn" onclick="recoverLivePosition(${mid})">恢复监控${tpslHint}</button>`;
|
||||||
|
} else {
|
||||||
|
html += " 未找到可恢复的监控记录,需在服务器数据库处理。";
|
||||||
|
}
|
||||||
|
el.innerHTML = html;
|
||||||
|
el.style.display = "block";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recoverLivePosition(monitorId){
|
||||||
|
const withTpsl = confirm("确认恢复本地实时监控?若原计划有止盈止损,将尝试重新挂到交易所。");
|
||||||
|
if(!withTpsl) return;
|
||||||
|
try{
|
||||||
|
const res = await fetch("/api/recover_live_position", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: JSON.stringify({monitor_id: monitorId, place_tpsl: true})
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
alert(data.msg || (data.ok ? "已恢复" : "失败"));
|
||||||
|
if(data.ok) location.reload();
|
||||||
|
}catch(e){
|
||||||
|
alert("恢复失败:" + e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function refreshPriceSnapshot(){
|
function refreshPriceSnapshot(){
|
||||||
fetch("/api/price_snapshot").then(r=>r.json()).then(data=>{
|
fetch("/api/price_snapshot").then(r=>r.json()).then(data=>{
|
||||||
const updatedEl = document.getElementById("price-last-updated");
|
const updatedEl = document.getElementById("price-last-updated");
|
||||||
@@ -1917,13 +1917,17 @@ function refreshPriceSnapshot(){
|
|||||||
}
|
}
|
||||||
const rrEl = document.getElementById(`order-rr-${o.id}`);
|
const rrEl = document.getElementById(`order-rr-${o.id}`);
|
||||||
if(rrEl){
|
if(rrEl){
|
||||||
rrEl.innerText = formatRrRatio(o.rr_ratio);
|
const rr = o.display_rr_ratio != null && o.display_rr_ratio !== "" ? o.display_rr_ratio : o.rr_ratio;
|
||||||
|
rrEl.innerText = formatRrRatio(rr);
|
||||||
}
|
}
|
||||||
|
paintLatestRiskDisplay(o.id, o);
|
||||||
|
paintContractsDisplay(o.id, o);
|
||||||
paintBreakevenBadge(o.id, o.sl_breakeven_secured);
|
paintBreakevenBadge(o.id, o.sl_breakeven_secured);
|
||||||
if(o.exchange_tpsl) paintExchangeTpslRow(o.id, o.exchange_tpsl);
|
if(o.exchange_tpsl) paintExchangeTpslRow(o.id, o.exchange_tpsl);
|
||||||
paintPlanTpslDisplay(o.id, o);
|
paintPlanTpslDisplay(o.id, o);
|
||||||
if(window.TimeCloseUI) TimeCloseUI.paintOrderTimeClose(o);
|
if(window.TimeCloseUI) TimeCloseUI.paintOrderTimeClose(o);
|
||||||
});
|
});
|
||||||
|
renderOrphanRecoverBanner(data.orphan_live_positions);
|
||||||
}).catch(()=>{});
|
}).catch(()=>{});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1953,6 +1957,7 @@ function refreshOrderDefaults(){
|
|||||||
}
|
}
|
||||||
const px = data.last_price || data.price;
|
const px = data.last_price || data.price;
|
||||||
if(px) refreshOrderTpPreview(px);
|
if(px) refreshOrderTpPreview(px);
|
||||||
|
if(window.ManualOrderRrPreview) ManualOrderRrPreview.schedule();
|
||||||
}).catch(()=>{});
|
}).catch(()=>{});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1969,9 +1974,25 @@ function refreshAccountSnapshot(){
|
|||||||
if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) {
|
if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) {
|
||||||
latestAvailableUsdt = Number(data.available_trading_usdt);
|
latestAvailableUsdt = Number(data.available_trading_usdt);
|
||||||
}
|
}
|
||||||
|
if (data.risk_status) {
|
||||||
|
const badge = document.getElementById("account-risk-badge");
|
||||||
|
if (badge) {
|
||||||
|
if (window.AccountRiskBadge) {
|
||||||
|
AccountRiskBadge.applyToElement(badge, data.risk_status);
|
||||||
|
} else {
|
||||||
|
const st = data.risk_status.status || "normal";
|
||||||
|
badge.className = "risk-status-badge risk-status-" + st;
|
||||||
|
badge.innerText = data.risk_status.status_label || "正常";
|
||||||
|
badge.title = data.risk_status.reason || "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let canTradeText = "可开仓";
|
let canTradeText = "可开仓";
|
||||||
if (!data.can_trade) {
|
if (!data.can_trade) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
|
if (data.risk_status && data.risk_status.can_trade === false && data.risk_status.reason) {
|
||||||
|
parts.push(data.risk_status.reason);
|
||||||
|
}
|
||||||
const ac = Number(data.active_count || 0);
|
const ac = Number(data.active_count || 0);
|
||||||
const max = Number(data.max_active_positions || {{ max_active_positions }});
|
const max = Number(data.max_active_positions || {{ max_active_positions }});
|
||||||
if (ac >= max) parts.push(`持仓 ${ac}/${max}`);
|
if (ac >= max) parts.push(`持仓 ${ac}/${max}`);
|
||||||
@@ -2032,12 +2053,16 @@ function toggleSltpMode(){
|
|||||||
slPctEl.required = pct;
|
slPctEl.required = pct;
|
||||||
tpPctEl.required = pct;
|
tpPctEl.required = pct;
|
||||||
refreshOrderTpPreview();
|
refreshOrderTpPreview();
|
||||||
|
if(window.ManualOrderRrPreview) ManualOrderRrPreview.schedule();
|
||||||
}
|
}
|
||||||
if(sltpModeEl){
|
if(sltpModeEl){
|
||||||
sltpModeEl.addEventListener("change", toggleSltpMode);
|
sltpModeEl.addEventListener("change", toggleSltpMode);
|
||||||
loadFixedRrPref();
|
loadFixedRrPref();
|
||||||
toggleSltpMode();
|
toggleSltpMode();
|
||||||
}
|
}
|
||||||
|
if(window.ManualOrderRrPreview){
|
||||||
|
ManualOrderRrPreview.wire({ minRr: MANUAL_MIN_PLANNED_RR });
|
||||||
|
}
|
||||||
["order-sl","order-fixed-rr","order-direction"].forEach(function(id){
|
["order-sl","order-fixed-rr","order-direction"].forEach(function(id){
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if(el) el.addEventListener("input", function(){ refreshOrderTpPreview(); });
|
if(el) el.addEventListener("input", function(){ refreshOrderTpPreview(); });
|
||||||
@@ -2045,6 +2070,7 @@ if(sltpModeEl){
|
|||||||
});
|
});
|
||||||
|
|
||||||
refreshAccountSnapshot();
|
refreshAccountSnapshot();
|
||||||
|
if (window.AccountRiskBadge) AccountRiskBadge.startTicker();
|
||||||
const _journalFormEl = document.getElementById("journal-form");
|
const _journalFormEl = document.getElementById("journal-form");
|
||||||
if(_journalFormEl){
|
if(_journalFormEl){
|
||||||
_journalFormEl.addEventListener("submit", function(ev){
|
_journalFormEl.addEventListener("submit", function(ev){
|
||||||
@@ -2182,10 +2208,42 @@ function refreshPriceSnapshotConditional(){
|
|||||||
paintExchangeTpslRow(o.id, o.exchange_tpsl || {});
|
paintExchangeTpslRow(o.id, o.exchange_tpsl || {});
|
||||||
paintPlanTpslDisplay(o.id, o);
|
paintPlanTpslDisplay(o.id, o);
|
||||||
if(window.TimeCloseUI) TimeCloseUI.paintOrderTimeClose(o);
|
if(window.TimeCloseUI) TimeCloseUI.paintOrderTimeClose(o);
|
||||||
|
const holdEl = document.getElementById(`order-hold-duration-${o.id}`);
|
||||||
|
if(holdEl && o.opened_at_ms != null && o.opened_at_ms !== ""){
|
||||||
|
holdEl.setAttribute("data-order-opened-ms", String(o.opened_at_ms));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
tickOrderHoldDurations();
|
||||||
|
renderOrphanRecoverBanner(data.orphan_live_positions);
|
||||||
}
|
}
|
||||||
}).catch(()=>{});
|
}).catch(()=>{});
|
||||||
}
|
}
|
||||||
|
function formatLiveHoldDurationFromMs(openedMs, nowMs){
|
||||||
|
if(openedMs == null || openedMs === "" || !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("");
|
||||||
|
}
|
||||||
|
function tickOrderHoldDurations(){
|
||||||
|
const now = Date.now();
|
||||||
|
document.querySelectorAll(".order-hold-duration[data-order-opened-ms]").forEach(el=>{
|
||||||
|
const ms = Number(el.getAttribute("data-order-opened-ms"));
|
||||||
|
if(!Number.isFinite(ms) || ms <= 0) return;
|
||||||
|
el.textContent = formatLiveHoldDurationFromMs(ms, now);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setInterval(tickOrderHoldDurations, 1000);
|
||||||
|
tickOrderHoldDurations();
|
||||||
setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }});
|
setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -120,14 +120,14 @@
|
|||||||
<div class="flash">{{ messages[0] }}</div>
|
<div class="flash">{{ messages[0] }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
<form method="POST">
|
<form method="POST" autocomplete="off">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>账号</label>
|
<label>账号</label>
|
||||||
<input type="text" name="username" required placeholder="请输入账号">
|
<input type="text" name="username" required placeholder="请输入账号" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>密码</label>
|
<label>密码</label>
|
||||||
<input type="password" name="password" required placeholder="请输入密码">
|
<input type="password" name="password" required placeholder="请输入密码" autocomplete="new-password">
|
||||||
</div>
|
</div>
|
||||||
<button type="submit">登录</button>
|
<button type="submit">登录</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -66,9 +66,11 @@
|
|||||||
| **收敛突破** | 同上(自动开仓类)。 |
|
| **收敛突破** | 同上(自动开仓类)。 |
|
||||||
| **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 |
|
| **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 |
|
||||||
| **关键支撑位** | 同上(仅提醒)。 |
|
| **关键支撑位** | 同上(仅提醒)。 |
|
||||||
|
| **回调触价开仓** | **不挂交易所限价**;标记价回调触达 E 后 **下一轮询市价开仓**(RR 门槛同 `KEY_AUTO_MIN_PLANNED_RR`);有效期 **24h** |
|
||||||
|
| **突破触价开仓** | **不挂交易所限价**;标记价 **穿越 E 立即市价开仓**;先触 SL/TP 侧失效;有效期 **24h** |
|
||||||
|
|
||||||
3. **方向**:做多 / 做空(必选)。
|
3. **方向**:做多 / 做空(回调/突破触价、箱体 / 收敛 / 斐波必选;阻力/支撑不选)。
|
||||||
4. **上沿 / 下沿**:必填;保存时会按交易所 **价格精度** 取整。
|
4. **价位**:箱体/收敛/阻力/支撑填 **上沿 / 下沿**;触价填 **入场 E / 止损 SL / 止盈 TP**。
|
||||||
|
|
||||||
**限制:**
|
**限制:**
|
||||||
活跃持仓数达到 **`MAX_ACTIVE_POSITIONS`**(默认 1)时,**不允许**再添加「**箱体突破** / **收敛突破**」;仍可添加「**关键阻力位 / 支撑位**」。
|
活跃持仓数达到 **`MAX_ACTIVE_POSITIONS`**(默认 1)时,**不允许**再添加「**箱体突破** / **收敛突破**」;仍可添加「**关键阻力位 / 支撑位**」。
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 关键位监控说明(自动开仓 + 人工盯盘)
|
# 关键位监控说明(自动开仓 + 人工盯盘)
|
||||||
|
|
||||||
**适用:`crypto_monitor_binance`(Binance U 本位永续)**
|
**适用:`crypto_monitor_gate`(Gate U 本位永续)**
|
||||||
Gate / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `key_monitor_lib.py`。
|
Binance / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `key_monitor_lib.py`。
|
||||||
|
|
||||||
本文档与 `.env`、`check_key_monitors`、`add_key`、`_key_hard_checks`、`_process_key_rs_level_alert` 一致。
|
本文档与 `.env`、`check_key_monitors`、`add_key`、`_key_hard_checks`、`_process_key_rs_level_alert` 一致。
|
||||||
|
|
||||||
@@ -16,8 +16,10 @@ Gate / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `key_
|
|||||||
| **关键阻力位** | **不选**(`direction=watch`) | **否** | 5m 收盘突破上/下沿 → 微信 **3 次** → `key_level_alert_done` |
|
| **关键阻力位** | **不选**(`direction=watch`) | **否** | 5m 收盘突破上/下沿 → 微信 **3 次** → `key_level_alert_done` |
|
||||||
| **关键支撑位** | **不选** | **否** | 同上(与阻力位**相同规则**:填上沿+下沿,程序双向监控) |
|
| **关键支撑位** | **不选** | **否** | 同上(与阻力位**相同规则**:填上沿+下沿,程序双向监控) |
|
||||||
| 斐波回调 0.618 / 0.786 | 必选 | 限价挂单逻辑 | 见斐波说明(**不在下文展开**) |
|
| 斐波回调 0.618 / 0.786 | 必选 | 限价挂单逻辑 | 见斐波说明(**不在下文展开**) |
|
||||||
|
| **回调触价开仓** | **必选** 多/空 | **程序盯价 → 回调触 E 后市价** | 见下文 **§四** |
|
||||||
|
| **突破触价开仓** | **必选** 多/空 | **程序盯价 → 穿越 E 立即市价** | 见下文 **§四** |
|
||||||
|
|
||||||
**添加时(所有类型):** 品种须 **日成交量排名前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30)**;上沿 **>** 下沿。
|
**添加时(箱体/收敛/斐波/触价):** 品种须 **日成交量排名前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30)**;上沿 **>** 下沿(触价开仓填 E/SL/TP,上下沿仅作展示占位)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -110,6 +112,7 @@ Gate / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `key_
|
|||||||
|
|
||||||
| `close_reason` | 含义 |
|
| `close_reason` | 含义 |
|
||||||
|----------------|------|
|
|----------------|------|
|
||||||
|
| `box_opposite_break` | 标记价先突破反向边界(多:≤下沿;空:≥上沿) |
|
||||||
| `rr_insufficient` | 门控通过但 RR 不达标或 SL/TP 几何无效 |
|
| `rr_insufficient` | 门控通过但 RR 不达标或 SL/TP 几何无效 |
|
||||||
| `exchange_failed` | RR 达标但实盘/交易所等原因未开仓 |
|
| `exchange_failed` | RR 达标但实盘/交易所等原因未开仓 |
|
||||||
| `auto_opened` | RR 达标且市价开仓成功 |
|
| `auto_opened` | RR 达标且市价开仓成功 |
|
||||||
@@ -117,7 +120,37 @@ Gate / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `key_
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 四、环境与参数(`.env` 摘要)
|
## 四、回调 / 突破触价开仓(程序触价,无交易所挂单)
|
||||||
|
|
||||||
|
### 4.1 录入
|
||||||
|
|
||||||
|
- **回调触价开仓**:方向必选多/空;填写 **计划入场价 E**、**止损 SL**、**止盈 TP**(做多须 `SL < E < TP`)。
|
||||||
|
- **突破触价开仓**:同上;添加时当前价须在突破方向一侧(做多:价低于 E;做空:价高于 E)。
|
||||||
|
- 计划 RR 以 **E** 为基准,须 **严格大于** `KEY_AUTO_MIN_PLANNED_RR`(默认 1.5)。
|
||||||
|
- 可选移动保本、时间平仓;**全仓杠杆模式**下可用。
|
||||||
|
|
||||||
|
### 4.2 触发与结案
|
||||||
|
|
||||||
|
| 类型 | 触发条件(标记价) |
|
||||||
|
|------|-------------------|
|
||||||
|
| **回调触价** | 做多 `≤ E`;做空 `≥ E` → 下一轮询市价开仓 |
|
||||||
|
| **突破触价** | 做多**向上穿越** E;做空**向下穿越** E → **立即**市价开仓 |
|
||||||
|
|
||||||
|
- 未成交前标记价先触 **TP 侧** → `trigger_tp_invalidate`。
|
||||||
|
- **突破触价**另:未穿越 E 先触 **SL 侧** → `trigger_sl_invalidate`。
|
||||||
|
- **24h** 未触发 → `trigger_entry_expired`。
|
||||||
|
- 成功 → `trigger_entry_filled`;触发后开仓失败 → `trigger_exchange_failed`。
|
||||||
|
|
||||||
|
### 4.3 计仓与占位
|
||||||
|
|
||||||
|
- **以损定仓**:按 E、SL 反推保证金,触发时重算;**全仓杠杆**:可用×缓冲比例,BTC/ETH 10x、其它 5x。
|
||||||
|
- **占当日开仓意图**(已开 + 待触发),未成交不占持仓;同币仅 1 条触价监控(含回调/突破)。
|
||||||
|
|
||||||
|
共享逻辑:`trigger_entry_key_monitor_lib.py`;轮询:`check_trigger_entry_key_monitors`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、环境与参数(`.env` 摘要)
|
||||||
|
|
||||||
| 变量 | 箱体/收敛 | 阻力/支撑 |
|
| 变量 | 箱体/收敛 | 阻力/支撑 |
|
||||||
|------|-----------|-----------|
|
|------|-----------|-----------|
|
||||||
@@ -130,7 +163,7 @@ Gate / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `key_
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 五、相关代码
|
## 六、相关代码
|
||||||
|
|
||||||
| 说明 | 位置 |
|
| 说明 | 位置 |
|
||||||
|------|------|
|
|------|------|
|
||||||
|
|||||||
@@ -140,7 +140,7 @@
|
|||||||
## 升级步骤
|
## 升级步骤
|
||||||
|
|
||||||
1. `git pull` 后对比 `.env.example`,把新增变量合并进本地 `.env`。
|
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` 等)。
|
3. 重启 Binance 实例(如 `pm2 restart crypto_binance`);SQLite 会自动 `ALTER` 缺列(斐波、交易所盈亏、`entry_reason` 等)。
|
||||||
4. 浏览器强刷(Ctrl+F5)避免旧版 `index.html` 缓存。
|
4. 浏览器强刷(Ctrl+F5)避免旧版 `index.html` 缓存。
|
||||||
5. 打开任意页确认顶栏出现 **「列表筛选(UTC)」**;`/stats` 可见分品类统计与「北京 8:00 切日」说明。
|
5. 打开任意页确认顶栏出现 **「列表筛选(UTC)」**;`/stats` 可见分品类统计与「北京 8:00 切日」说明。
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ cp .env .env.backup.$(date +%Y%m%d)
|
|||||||
|
|
||||||
### 5.3 AI 复盘与模型(可选)
|
### 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
|
bash scripts/install_backup_cron.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Gate Bot 实例(趋势回调等):
|
实例(趋势回调等):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/crypto_monitor/crypto_monitor_gate_bot
|
cd /opt/crypto_monitor/crypto_monitor_gate
|
||||||
bash scripts/install_backup_cron.sh
|
bash scripts/install_backup_cron.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ APP_PORT=5000
|
|||||||
APP_DEBUG=false
|
APP_DEBUG=false
|
||||||
|
|
||||||
# 登录账号
|
# 登录账号
|
||||||
APP_USERNAME=dekun
|
APP_USERNAME=admin
|
||||||
# 登录密码(请改成你自己的强密码)
|
# 登录密码(请改成你自己的强密码)
|
||||||
APP_PASSWORD=ChangeMe123!
|
APP_PASSWORD=admin123
|
||||||
# 是否关闭登录校验(局域网可设 true;公网务必 false)
|
# 是否关闭登录校验(局域网可设 true;公网务必 false)
|
||||||
APP_AUTH_DISABLED=true
|
APP_AUTH_DISABLED=true
|
||||||
# --- 多账户交易中控 manual_trading_hub ---
|
# --- 多账户交易中控 manual_trading_hub ---
|
||||||
@@ -129,6 +129,16 @@ DAILY_OPEN_ALERT_THRESHOLD=5
|
|||||||
# 【单日开仓硬上限】本交易日开仓次数>=该值后禁止一切新开仓直至下一交易日(北京时间 TRADING_DAY_RESET_HOUR 切日);0=不启用
|
# 【单日开仓硬上限】本交易日开仓次数>=该值后禁止一切新开仓直至下一交易日(北京时间 TRADING_DAY_RESET_HOUR 切日);0=不启用
|
||||||
DAILY_OPEN_HARD_LIMIT=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
|
BALANCE_REFRESH_SECONDS=60
|
||||||
# 前端价格快照轮询(秒)
|
# 前端价格快照轮询(秒)
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<script src="/static/instance_theme.js?v=6"></script>
|
<script src="/static/instance_theme.js?v=46"></script>
|
||||||
<link rel="stylesheet" href="/static/instance_theme_early.css?v=1">
|
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
|
||||||
|
<link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
|
||||||
|
<script src="/static/account_risk_badge.js?v=4"></script>
|
||||||
|
|
||||||
<meta name="theme-color" content="#0b0d14">
|
<meta name="theme-color" content="#0b0d14">
|
||||||
<meta name="apple-mobile-web-app-title" content="监控">
|
<meta name="apple-mobile-web-app-title" content="监控">
|
||||||
@@ -20,6 +22,7 @@
|
|||||||
.header{display:flex;flex-direction:column;align-items:center;gap:8px;margin-bottom:12px}
|
.header{display:flex;flex-direction:column;align-items:center;gap:8px;margin-bottom:12px}
|
||||||
.header h1{font-size:1.75rem;color:#dbe4ff;text-align:center;line-height:1.25}
|
.header h1{font-size:1.75rem;color:#dbe4ff;text-align:center;line-height:1.25}
|
||||||
.exchange-tag{font-size:.82rem;font-weight:600;color:#b8f5d0;background:#14241e;border:1px solid #2d6a4f;padding:5px 14px;border-radius:999px;letter-spacing:.06em}
|
.exchange-tag{font-size:.82rem;font-weight:600;color:#b8f5d0;background:#14241e;border:1px solid #2d6a4f;padding:5px 14px;border-radius:999px;letter-spacing:.06em}
|
||||||
|
.header-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:center}
|
||||||
.top-nav{display:flex;gap:8px;flex-wrap:wrap;justify-content:center;margin-bottom:12px}
|
.top-nav{display:flex;gap:8px;flex-wrap:wrap;justify-content:center;margin-bottom:12px}
|
||||||
.top-nav a{padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a;color:#8fc8ff;text-decoration:none}
|
.top-nav a{padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a;color:#8fc8ff;text-decoration:none}
|
||||||
.top-nav a.active{background:#2a3f6c;color:#dbe4ff}
|
.top-nav a.active{background:#2a3f6c;color:#dbe4ff}
|
||||||
@@ -34,6 +37,12 @@
|
|||||||
.form-row{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px;align-items:center}
|
.form-row{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px;align-items:center}
|
||||||
.form-row > input:not([type=checkbox]):not([type=radio]),.form-row > select{flex:0 1 auto;width:10rem;max-width:200px;min-width:7rem}
|
.form-row > input:not([type=checkbox]):not([type=radio]),.form-row > select{flex:0 1 auto;width:10rem;max-width:200px;min-width:7rem}
|
||||||
#add-order-form #sltp-mode{min-width:12.5rem;max-width:16rem;width:auto}
|
#add-order-form #sltp-mode{min-width:12.5rem;max-width:16rem;width:auto}
|
||||||
|
.order-plan-preview{display:flex;gap:18px;flex-wrap:wrap;align-items:center;margin:4px 0 10px;padding:10px 12px;background:#151a28;border:1px solid #2a3150;border-radius:8px;font-size:.85rem}
|
||||||
|
.order-preview-risk{color:#ff6b6b}.order-preview-risk strong{color:#ff8f8f;font-weight:600}
|
||||||
|
.order-preview-profit{color:#4cd97f}.order-preview-profit strong{color:#6ee7a0;font-weight:600}
|
||||||
|
.order-preview-rr{color:#cfd3ef}.order-preview-rr strong{font-weight:600;color:#dbe4ff}
|
||||||
|
.order-preview-rr.order-preview-rr-low strong{color:#ff8f8f}
|
||||||
|
.order-preview-rr.order-preview-rr-ok strong{color:#8fc8ff}
|
||||||
.form-row > button,.form-row > label{flex:0 0 auto}
|
.form-row > button,.form-row > label{flex:0 0 auto}
|
||||||
.form-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
|
.form-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
|
||||||
/* 复盘表单:长下拉文案需可收缩,否则会撑破四列网格 */
|
/* 复盘表单:长下拉文案需可收缩,否则会撑破四列网格 */
|
||||||
@@ -234,10 +243,17 @@
|
|||||||
.stats-period-block h3{font-size:1rem;color:#dbe4ff;margin-bottom:4px}
|
.stats-period-block h3{font-size:1rem;color:#dbe4ff;margin-bottom:4px}
|
||||||
.stats-period-block .sub{font-size:.78rem;color:#8892b0;margin-bottom:10px;line-height:1.4}
|
.stats-period-block .sub{font-size:.78rem;color:#8892b0;margin-bottom:10px;line-height:1.4}
|
||||||
</style>
|
</style>
|
||||||
<link rel="stylesheet" href="/static/instance_theme.css?v=14">
|
<link rel="stylesheet" href="/static/instance_theme.css?v=48">
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
<body data-page="{{ page }}">
|
<body
|
||||||
|
data-page="{{ page }}"
|
||||||
|
data-risk-percent="{{ risk_percent }}"
|
||||||
|
data-position-sizing-mode="{{ position_sizing_mode }}"
|
||||||
|
data-btc-leverage="{{ btc_leverage }}"
|
||||||
|
data-alt-leverage="{{ alt_leverage }}"
|
||||||
|
data-full-margin-buffer="{{ full_margin_buffer_ratio }}"
|
||||||
|
>
|
||||||
{% macro period_stats(title, s) %}
|
{% macro period_stats(title, s) %}
|
||||||
<div class="stats-period-block">
|
<div class="stats-period-block">
|
||||||
<h3>{{ title }}</h3>
|
<h3>{{ title }}</h3>
|
||||||
@@ -262,6 +278,7 @@
|
|||||||
<h1>加密货币|交易监控 + AI复盘一体化</h1>
|
<h1>加密货币|交易监控 + AI复盘一体化</h1>
|
||||||
<div class="header-row">
|
<div class="header-row">
|
||||||
<div class="exchange-tag">{{ exchange_display }}</div>
|
<div class="exchange-tag">{{ exchange_display }}</div>
|
||||||
|
<span class="risk-status-badge risk-status-{{ risk_status.status|default('normal') }}" id="account-risk-badge" role="status" title="{{ risk_status.reason|default('', true) }}" data-status-label="{{ risk_status.status_label|default('正常') }}"{% if risk_status.freeze_until_ms %} data-freeze-until-ms="{{ risk_status.freeze_until_ms }}"{% endif %}>{{ risk_status.status_label|default('正常') }}</span>
|
||||||
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
|
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
|
||||||
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
|
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
|
||||||
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
||||||
@@ -336,7 +353,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% include 'order_monitor_rule_tips_gate.html' %}
|
{% include 'order_monitor_rule_tips_gate.html' %}
|
||||||
<form id="add-order-form" action="/add_order" method="post" class="form-row">
|
<form id="add-order-form" action="/add_order" method="post" class="form-row" data-risk-percent="{{ risk_percent }}">
|
||||||
<input id="order-symbol" name="symbol" placeholder="BTC 或 BTC/USDT" required>
|
<input id="order-symbol" name="symbol" placeholder="BTC 或 BTC/USDT" required>
|
||||||
<select id="order-direction" name="direction" required>
|
<select id="order-direction" name="direction" required>
|
||||||
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
|
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
|
||||||
@@ -378,6 +395,7 @@
|
|||||||
<input id="order-tp-pct" name="tp_pct" type="number" min="0.01" step="0.01" placeholder="止盈%" style="display:none">
|
<input id="order-tp-pct" name="tp_pct" type="number" min="0.01" step="0.01" placeholder="止盈%" style="display:none">
|
||||||
<button type="submit">{{ open_position_button_label }}</button>
|
<button type="submit">{{ open_position_button_label }}</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% include 'order_plan_preview_bar.html' %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 style="margin-bottom:8px">实时持仓</h2>
|
<h2 style="margin-bottom:8px">实时持仓</h2>
|
||||||
@@ -411,6 +429,7 @@
|
|||||||
<span class="pos-meta-item">来源: {{ o.monitor_type|default('下单监控', true) }}{% if o.key_signal_type %} · {{ o.key_signal_type }}{% endif %}</span>
|
<span class="pos-meta-item">来源: {{ o.monitor_type|default('下单监控', true) }}{% if o.key_signal_type %} · {{ o.key_signal_type }}{% endif %}</span>
|
||||||
<span class="pos-meta-item">风格: {{ o.trade_style or 'trend' }}</span>
|
<span class="pos-meta-item">风格: {{ o.trade_style or 'trend' }}</span>
|
||||||
<span class="pos-meta-item">风险: {% 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 %}</span>
|
<span class="pos-meta-item">风险: {% 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 %}</span>
|
||||||
|
<span class="pos-meta-item" id="order-latest-risk-wrap-{{ o.id }}" style="display:none">最新风险: —</span>
|
||||||
<span class="pos-meta-item {% if o.breakeven_enabled %}pos-meta-on{% else %}pos-meta-off{% endif %}">
|
<span class="pos-meta-item {% if o.breakeven_enabled %}pos-meta-on{% else %}pos-meta-off{% endif %}">
|
||||||
{% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %}
|
{% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %}
|
||||||
</span>
|
</span>
|
||||||
@@ -433,6 +452,10 @@
|
|||||||
<span class="pos-label">盈亏比</span>
|
<span class="pos-label">盈亏比</span>
|
||||||
<span class="pos-value" id="order-rr-{{ o.id }}">{% if o.rr_ratio is not none %}{{ '%g'|format(o.rr_ratio) }}:1{% else %}-:1{% endif %}</span>
|
<span class="pos-value" id="order-rr-{{ o.id }}">{% if o.rr_ratio is not none %}{{ '%g'|format(o.rr_ratio) }}:1{% else %}-:1{% endif %}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pos-cell">
|
||||||
|
<span class="pos-label">张数</span>
|
||||||
|
<span class="pos-value" id="order-contracts-{{ o.id }}">{% if o.order_amount is not none %}{{ '%g'|format(o.order_amount) }}{% else %}—{% endif %}</span>
|
||||||
|
</div>
|
||||||
<div class="pos-cell">
|
<div class="pos-cell">
|
||||||
<span class="pos-label">标记价</span>
|
<span class="pos-label">标记价</span>
|
||||||
<span class="pos-value" id="order-price-{{ o.id }}">-</span>
|
<span class="pos-value" id="order-price-{{ o.id }}">-</span>
|
||||||
@@ -447,6 +470,8 @@
|
|||||||
<span>计划基数: {{ funds_fmt(o.margin_capital) if o.margin_capital is not none else '-' }}U</span>
|
<span>计划基数: {{ funds_fmt(o.margin_capital) if o.margin_capital is not none else '-' }}U</span>
|
||||||
<span>杠杆: {{ o.leverage or '-' }}x</span>
|
<span>杠杆: {{ o.leverage or '-' }}x</span>
|
||||||
<span>仓位占比: {{ o.position_ratio if o.position_ratio is not none else '-' }}%</span>
|
<span>仓位占比: {{ o.position_ratio if o.position_ratio is not none else '-' }}%</span>
|
||||||
|
<span>开仓时间: {{ (o.opened_at or '-')[:16] }}</span>
|
||||||
|
<span>持仓时长: <span class="order-hold-duration" id="order-hold-duration-{{ o.id }}" data-order-opened-ms="{{ o.opened_at_ms or '' }}">—</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="pos-ex-orders">
|
<div class="pos-ex-orders">
|
||||||
<div class="pos-ex-orders-title">交易所止盈止损</div>
|
<div class="pos-ex-orders-title">交易所止盈止损</div>
|
||||||
@@ -589,8 +614,7 @@
|
|||||||
<select name="type" required>
|
<select name="type" required>
|
||||||
<option value="箱体突破">箱体突破</option>
|
<option value="箱体突破">箱体突破</option>
|
||||||
<option value="收敛突破">收敛突破</option>
|
<option value="收敛突破">收敛突破</option>
|
||||||
<option value="关键阻力位">关键阻力位</option>
|
<option value="关键支撑阻力">关键支撑阻力</option>
|
||||||
<option value="关键支撑位">关键支撑位</option>
|
|
||||||
</select>
|
</select>
|
||||||
<select name="direction" required>
|
<select name="direction" required>
|
||||||
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
|
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
|
||||||
@@ -783,10 +807,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/instance_ui.js?v=1"></script>
|
<script src="/static/instance_ui.js?v=4"></script>
|
||||||
|
<script src="/static/instance_records_mobile.js?v=2"></script>
|
||||||
<script src="/static/time_close_ui.js?v=2"></script>
|
<script src="/static/time_close_ui.js?v=2"></script>
|
||||||
<script src="/static/ai_review_render.js?v=2"></script>
|
<script src="/static/ai_review_render.js?v=2"></script>
|
||||||
<script src="/static/form_submit_guard.js?v=2"></script>
|
<script src="/static/form_submit_guard.js?v=2"></script>
|
||||||
|
<script src="/static/manual_order_rr_preview.js?v=5"></script>
|
||||||
|
<script src="/static/strategy_roll.js?v=6"></script>
|
||||||
<script>
|
<script>
|
||||||
const JOURNAL_ENTRY_REASON_OPTIONS = {{ entry_reason_options | tojson }};
|
const JOURNAL_ENTRY_REASON_OPTIONS = {{ entry_reason_options | tojson }};
|
||||||
const JOURNAL_ENTRY_REASON_OTHER = {{ entry_reason_other_value | tojson }};
|
const JOURNAL_ENTRY_REASON_OTHER = {{ entry_reason_other_value | tojson }};
|
||||||
@@ -867,8 +894,10 @@ function setDetailBodyPlain(text){
|
|||||||
body.innerText = text || "";
|
body.innerText = text || "";
|
||||||
}
|
}
|
||||||
function setDetailBodyMarkdown(text){
|
function setDetailBodyMarkdown(text){
|
||||||
|
if(window.InstanceUI && InstanceUI.clearDetailActions) InstanceUI.clearDetailActions();
|
||||||
const body = document.getElementById("detailBody");
|
const body = document.getElementById("detailBody");
|
||||||
if(!body) return;
|
if(!body) return;
|
||||||
|
body.classList.remove("trade-record-detail-wrap", "journal-detail-meta");
|
||||||
if(window.AiReviewRender && AiReviewRender.setElementMarkdown){
|
if(window.AiReviewRender && AiReviewRender.setElementMarkdown){
|
||||||
body.classList.add("md-review");
|
body.classList.add("md-review");
|
||||||
AiReviewRender.setElementMarkdown(body, text || "");
|
AiReviewRender.setElementMarkdown(body, text || "");
|
||||||
@@ -1070,22 +1099,12 @@ function loadJournals(){
|
|||||||
const qs = listWindowQueryString();
|
const qs = listWindowQueryString();
|
||||||
fetch("/api/journals" + (qs ? "?" + qs : "")).then(r=>r.json()).then(data=>{
|
fetch("/api/journals" + (qs ? "?" + qs : "")).then(r=>r.json()).then(data=>{
|
||||||
Object.keys(journalCache).forEach(k=>delete journalCache[k]);
|
Object.keys(journalCache).forEach(k=>delete journalCache[k]);
|
||||||
let html="";
|
data.forEach(o=>{ journalCache[o.id] = o; });
|
||||||
data.forEach(o=>{
|
|
||||||
journalCache[o.id] = o;
|
|
||||||
const moodTags = (o.mood_issues || []).join(",") || "无";
|
|
||||||
html += `<div class="entry">
|
|
||||||
<div><strong>${o.coin||"-"} ${o.tf||"-"}</strong> | 盈亏:${o.pnl||"-"}U</div>
|
|
||||||
<div>开:${o.open_datetime||"-"} 平:${o.close_datetime||"-"} 持仓:${o.hold_duration||"-"}</div>
|
|
||||||
<div>心态标签:${moodTags}</div>
|
|
||||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:6px">
|
|
||||||
<button type="button" class="btn-del" style="border:none;cursor:pointer;background:#1f3a5a;color:#8fc8ff" onclick="openJournalDetail('${o.id}')">查看详情</button>
|
|
||||||
<button type="button" class="btn-del" onclick="deleteJournal('${o.id}')">删除</button>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
});
|
|
||||||
const box = document.getElementById("journal-list");
|
const box = document.getElementById("journal-list");
|
||||||
if(box){ box.innerHTML = html || "<div class='entry'>暂无数据</div>"; }
|
if(box){
|
||||||
|
const html = InstanceUI.renderJournalListHtml(data);
|
||||||
|
box.innerHTML = html || "<div class='journal-empty-msg'>暂无数据</div>";
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1509,108 +1528,9 @@ if(journalForm){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 rsTypes = new Set(["关键阻力位","关键支撑位"]);
|
|
||||||
const showAuto = autoTypes.has(t);
|
|
||||||
const showFb = fbTypes.has(t);
|
|
||||||
const showBe = showAuto || fibTypes.has(t) || showFb;
|
|
||||||
const showDir = !rsTypes.has(t);
|
|
||||||
const upperEl = document.getElementById("key-upper");
|
|
||||||
const lowerEl = document.getElementById("key-lower");
|
|
||||||
const fbPriceEl = document.getElementById("key-fb-price");
|
|
||||||
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(window.TimeCloseUI) TimeCloseUI.syncKeyTimeCloseVisibility(showBe);
|
|
||||||
if(upperEl){
|
|
||||||
upperEl.style.display = showFb ? "none" : "";
|
|
||||||
upperEl.required = !showFb;
|
|
||||||
if(showFb) upperEl.value = "";
|
|
||||||
}
|
|
||||||
if(lowerEl){
|
|
||||||
lowerEl.style.display = showFb ? "none" : "";
|
|
||||||
lowerEl.required = !showFb;
|
|
||||||
if(showFb) 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") ? "低点(支撑)" : "做空填高点/做多填低点");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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(window.TimeCloseUI){
|
if(window.TimeCloseUI){
|
||||||
TimeCloseUI.bindTimeCloseForm("key-time-close-cb", "key-time-close-hours", "key-time-close-wrap");
|
|
||||||
TimeCloseUI.bindTimeCloseForm("order-time-close-cb", "order-time-close-hours", "order-time-close-wrap");
|
TimeCloseUI.bindTimeCloseForm("order-time-close-cb", "order-time-close-hours", "order-time-close-wrap");
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyForm = document.getElementById("key-form");
|
|
||||||
if(keyForm){
|
|
||||||
keyForm.addEventListener("submit", (e)=>{
|
|
||||||
e.preventDefault();
|
|
||||||
if(window.FormSubmitGuard && 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 === "假突破"){
|
|
||||||
if(window.FormSubmitGuard) FormSubmitGuard.nativeSubmitOnce(keyForm, "提交中…");
|
|
||||||
else keyForm.submit();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if(window.FormSubmitGuard) 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(window.FormSubmitGuard) FormSubmitGuard.unlock(keyForm);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const rankMax = data.rank_max || 30;
|
|
||||||
if(!data.in_top30){
|
|
||||||
alert(`${data.symbol} 当前日成交量排名 ${data.rank}/${data.total},不在前${rankMax},已拦截。`);
|
|
||||||
if(window.FormSubmitGuard) FormSubmitGuard.unlock(keyForm);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if(window.FormSubmitGuard) FormSubmitGuard.nativeSubmitOnce(keyForm, "提交中…");
|
|
||||||
else keyForm.submit();
|
|
||||||
})
|
|
||||||
.catch(()=>{
|
|
||||||
alert("日成交量排名检查失败,请稍后重试");
|
|
||||||
if(window.FormSubmitGuard) FormSubmitGuard.unlock(keyForm);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// 复盘/AI列表:初次进入页面后再异步刷新一次,避免浏览器 bfcache/重定向后仍显示旧缓存
|
// 复盘/AI列表:初次进入页面后再异步刷新一次,避免浏览器 bfcache/重定向后仍显示旧缓存
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if(document.getElementById("journal-list")) loadJournals();
|
if(document.getElementById("journal-list")) loadJournals();
|
||||||
@@ -1661,6 +1581,7 @@ function refreshOrderTpPreview(entryPx){
|
|||||||
const entry = entryPx != null && Number.isFinite(Number(entryPx)) ? Number(entryPx) : sl;
|
const entry = entryPx != null && Number.isFinite(Number(entryPx)) ? Number(entryPx) : sl;
|
||||||
const tp = calcTpFromFixedRr(direction, entry, sl, rr);
|
const tp = calcTpFromFixedRr(direction, entry, sl, rr);
|
||||||
preview.textContent = tp == null ? "预估止盈:—" : ("预估止盈:" + formatPriceForInput(tp));
|
preview.textContent = tp == null ? "预估止盈:—" : ("预估止盈:" + formatPriceForInput(tp));
|
||||||
|
if(window.ManualOrderRrPreview) ManualOrderRrPreview.schedule();
|
||||||
}
|
}
|
||||||
function calcClientRr(direction, entry, sl, tp){
|
function calcClientRr(direction, entry, sl, tp){
|
||||||
const e = Number(entry), s = Number(sl), t = Number(tp);
|
const e = Number(entry), s = Number(sl), t = Number(tp);
|
||||||
@@ -1747,6 +1668,13 @@ function submitTpslEntrust(){
|
|||||||
alert(data.msg || '已提交');
|
alert(data.msg || '已提交');
|
||||||
closeTpslEntrustModal();
|
closeTpslEntrustModal();
|
||||||
if(data.exchange_tpsl) paintExchangeTpslRow(orderId, data.exchange_tpsl);
|
if(data.exchange_tpsl) paintExchangeTpslRow(orderId, data.exchange_tpsl);
|
||||||
|
paintPlanTpslDisplay(orderId, data);
|
||||||
|
paintLatestRiskDisplay(orderId, data);
|
||||||
|
const rrEl = document.getElementById(`order-rr-${orderId}`);
|
||||||
|
if(rrEl){
|
||||||
|
const rr = data.display_rr_ratio != null && data.display_rr_ratio !== "" ? data.display_rr_ratio : data.planned_rr;
|
||||||
|
rrEl.innerText = formatRrRatio(rr);
|
||||||
|
}
|
||||||
refreshPriceSnapshotConditional();
|
refreshPriceSnapshotConditional();
|
||||||
}).catch(()=>alert('委托请求失败'));
|
}).catch(()=>alert('委托请求失败'));
|
||||||
}
|
}
|
||||||
@@ -1814,6 +1742,25 @@ function paintPlanTpslDisplay(orderId, snap){
|
|||||||
else if(tpDisp) card.setAttribute("data-plan-tp", tpDisp);
|
else if(tpDisp) card.setAttribute("data-plan-tp", tpDisp);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function paintLatestRiskDisplay(orderId, snap){
|
||||||
|
const wrap = document.getElementById(`order-latest-risk-wrap-${orderId}`);
|
||||||
|
if(!wrap) return;
|
||||||
|
const v = snap && snap.latest_risk_amount;
|
||||||
|
const n = v != null && v !== "" ? Number(v) : NaN;
|
||||||
|
if(Number.isFinite(n)){
|
||||||
|
wrap.style.display = "inline-flex";
|
||||||
|
wrap.textContent = `最新风险: ${n.toFixed(2)}U`;
|
||||||
|
} else {
|
||||||
|
wrap.style.display = "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function paintContractsDisplay(orderId, snap){
|
||||||
|
const el = document.getElementById(`order-contracts-${orderId}`);
|
||||||
|
if(!el || !snap) return;
|
||||||
|
const v = snap.contracts != null && snap.contracts !== "" ? snap.contracts : snap.order_amount;
|
||||||
|
const n = v != null && v !== "" ? Number(v) : NaN;
|
||||||
|
el.innerText = Number.isFinite(n) ? String(parseFloat(n.toFixed(4))) : "—";
|
||||||
|
}
|
||||||
|
|
||||||
function paintPriceTrend(el, key, value){
|
function paintPriceTrend(el, key, value){
|
||||||
if(!el) return;
|
if(!el) return;
|
||||||
@@ -1897,8 +1844,11 @@ function refreshPriceSnapshot(){
|
|||||||
}
|
}
|
||||||
const rrEl = document.getElementById(`order-rr-${o.id}`);
|
const rrEl = document.getElementById(`order-rr-${o.id}`);
|
||||||
if(rrEl){
|
if(rrEl){
|
||||||
rrEl.innerText = formatRrRatio(o.rr_ratio);
|
const rr = o.display_rr_ratio != null && o.display_rr_ratio !== "" ? o.display_rr_ratio : o.rr_ratio;
|
||||||
|
rrEl.innerText = formatRrRatio(rr);
|
||||||
}
|
}
|
||||||
|
paintLatestRiskDisplay(o.id, o);
|
||||||
|
paintContractsDisplay(o.id, o);
|
||||||
paintBreakevenBadge(o.id, o.sl_breakeven_secured);
|
paintBreakevenBadge(o.id, o.sl_breakeven_secured);
|
||||||
if(o.exchange_tpsl) paintExchangeTpslRow(o.id, o.exchange_tpsl);
|
if(o.exchange_tpsl) paintExchangeTpslRow(o.id, o.exchange_tpsl);
|
||||||
paintPlanTpslDisplay(o.id, o);
|
paintPlanTpslDisplay(o.id, o);
|
||||||
@@ -1933,6 +1883,7 @@ function refreshOrderDefaults(){
|
|||||||
}
|
}
|
||||||
const px = data.last_price || data.price;
|
const px = data.last_price || data.price;
|
||||||
if(px) refreshOrderTpPreview(px);
|
if(px) refreshOrderTpPreview(px);
|
||||||
|
if(window.ManualOrderRrPreview) ManualOrderRrPreview.schedule();
|
||||||
}).catch(()=>{});
|
}).catch(()=>{});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1949,9 +1900,25 @@ function refreshAccountSnapshot(){
|
|||||||
if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) {
|
if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) {
|
||||||
latestAvailableUsdt = Number(data.available_trading_usdt);
|
latestAvailableUsdt = Number(data.available_trading_usdt);
|
||||||
}
|
}
|
||||||
|
if (data.risk_status) {
|
||||||
|
const badge = document.getElementById("account-risk-badge");
|
||||||
|
if (badge) {
|
||||||
|
if (window.AccountRiskBadge) {
|
||||||
|
AccountRiskBadge.applyToElement(badge, data.risk_status);
|
||||||
|
} else {
|
||||||
|
const st = data.risk_status.status || "normal";
|
||||||
|
badge.className = "risk-status-badge risk-status-" + st;
|
||||||
|
badge.innerText = data.risk_status.status_label || "正常";
|
||||||
|
badge.title = data.risk_status.reason || "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let canTradeText = "可开仓";
|
let canTradeText = "可开仓";
|
||||||
if (!data.can_trade) {
|
if (!data.can_trade) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
|
if (data.risk_status && data.risk_status.can_trade === false && data.risk_status.reason) {
|
||||||
|
parts.push(data.risk_status.reason);
|
||||||
|
}
|
||||||
const ac = Number(data.active_count || 0);
|
const ac = Number(data.active_count || 0);
|
||||||
const max = Number(data.max_active_positions || {{ max_active_positions }});
|
const max = Number(data.max_active_positions || {{ max_active_positions }});
|
||||||
if (ac >= max) parts.push(`持仓 ${ac}/${max}`);
|
if (ac >= max) parts.push(`持仓 ${ac}/${max}`);
|
||||||
@@ -2012,12 +1979,16 @@ function toggleSltpMode(){
|
|||||||
slPctEl.required = pct;
|
slPctEl.required = pct;
|
||||||
tpPctEl.required = pct;
|
tpPctEl.required = pct;
|
||||||
refreshOrderTpPreview();
|
refreshOrderTpPreview();
|
||||||
|
if(window.ManualOrderRrPreview) ManualOrderRrPreview.schedule();
|
||||||
}
|
}
|
||||||
if(sltpModeEl){
|
if(sltpModeEl){
|
||||||
sltpModeEl.addEventListener("change", toggleSltpMode);
|
sltpModeEl.addEventListener("change", toggleSltpMode);
|
||||||
loadFixedRrPref();
|
loadFixedRrPref();
|
||||||
toggleSltpMode();
|
toggleSltpMode();
|
||||||
}
|
}
|
||||||
|
if(window.ManualOrderRrPreview){
|
||||||
|
ManualOrderRrPreview.wire({ minRr: MANUAL_MIN_PLANNED_RR });
|
||||||
|
}
|
||||||
["order-sl","order-fixed-rr","order-direction"].forEach(function(id){
|
["order-sl","order-fixed-rr","order-direction"].forEach(function(id){
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if(el) el.addEventListener("input", function(){ refreshOrderTpPreview(); });
|
if(el) el.addEventListener("input", function(){ refreshOrderTpPreview(); });
|
||||||
@@ -2025,6 +1996,7 @@ if(sltpModeEl){
|
|||||||
});
|
});
|
||||||
|
|
||||||
refreshAccountSnapshot();
|
refreshAccountSnapshot();
|
||||||
|
if (window.AccountRiskBadge) AccountRiskBadge.startTicker();
|
||||||
const _journalFormEl = document.getElementById("journal-form");
|
const _journalFormEl = document.getElementById("journal-form");
|
||||||
if(_journalFormEl){
|
if(_journalFormEl){
|
||||||
_journalFormEl.addEventListener("submit", function(ev){
|
_journalFormEl.addEventListener("submit", function(ev){
|
||||||
@@ -2162,10 +2134,41 @@ function refreshPriceSnapshotConditional(){
|
|||||||
paintExchangeTpslRow(o.id, o.exchange_tpsl || {});
|
paintExchangeTpslRow(o.id, o.exchange_tpsl || {});
|
||||||
paintPlanTpslDisplay(o.id, o);
|
paintPlanTpslDisplay(o.id, o);
|
||||||
if(window.TimeCloseUI) TimeCloseUI.paintOrderTimeClose(o);
|
if(window.TimeCloseUI) TimeCloseUI.paintOrderTimeClose(o);
|
||||||
|
const holdEl = document.getElementById(`order-hold-duration-${o.id}`);
|
||||||
|
if(holdEl && o.opened_at_ms != null && o.opened_at_ms !== ""){
|
||||||
|
holdEl.setAttribute("data-order-opened-ms", String(o.opened_at_ms));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
tickOrderHoldDurations();
|
||||||
}
|
}
|
||||||
}).catch(()=>{});
|
}).catch(()=>{});
|
||||||
}
|
}
|
||||||
|
function formatLiveHoldDurationFromMs(openedMs, nowMs){
|
||||||
|
if(openedMs == null || openedMs === "" || !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("");
|
||||||
|
}
|
||||||
|
function tickOrderHoldDurations(){
|
||||||
|
const now = Date.now();
|
||||||
|
document.querySelectorAll(".order-hold-duration[data-order-opened-ms]").forEach(el=>{
|
||||||
|
const ms = Number(el.getAttribute("data-order-opened-ms"));
|
||||||
|
if(!Number.isFinite(ms) || ms <= 0) return;
|
||||||
|
el.textContent = formatLiveHoldDurationFromMs(ms, now);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setInterval(tickOrderHoldDurations, 1000);
|
||||||
|
tickOrderHoldDurations();
|
||||||
setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }});
|
setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -120,14 +120,14 @@
|
|||||||
<div class="flash">{{ messages[0] }}</div>
|
<div class="flash">{{ messages[0] }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
<form method="POST">
|
<form method="POST" autocomplete="off">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>账号</label>
|
<label>账号</label>
|
||||||
<input type="text" name="username" required placeholder="请输入账号">
|
<input type="text" name="username" required placeholder="请输入账号" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>密码</label>
|
<label>密码</label>
|
||||||
<input type="password" name="password" required placeholder="请输入密码">
|
<input type="password" name="password" required placeholder="请输入密码" autocomplete="new-password">
|
||||||
</div>
|
</div>
|
||||||
<button type="submit">登录</button>
|
<button type="submit">登录</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -65,9 +65,11 @@
|
|||||||
| **收敛突破** | 同上(自动开仓类)。 |
|
| **收敛突破** | 同上(自动开仓类)。 |
|
||||||
| **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 |
|
| **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 |
|
||||||
| **关键支撑位** | 同上(仅提醒)。 |
|
| **关键支撑位** | 同上(仅提醒)。 |
|
||||||
|
| **回调触价开仓** | **不挂交易所限价**;标记价回调触达 E 后 **下一轮询市价开仓**(RR 门槛同 `KEY_AUTO_MIN_PLANNED_RR`);有效期 **24h** |
|
||||||
|
| **突破触价开仓** | **不挂交易所限价**;标记价 **穿越 E 立即市价开仓**;先触 SL/TP 侧失效;有效期 **24h** |
|
||||||
|
|
||||||
3. **方向**:做多 / 做空(必选)。
|
3. **方向**:做多 / 做空(触价开仓 / 箱体 / 收敛 / 斐波必选;阻力/支撑不选)。
|
||||||
4. **上沿 / 下沿**:必填;保存时会按交易所 **价格精度** 取整。
|
4. **价位**:箱体/收敛/阻力/支撑填 **上沿 / 下沿**;触价开仓填 **入场 E / 止损 SL / 止盈 TP**。
|
||||||
|
|
||||||
**限制:**
|
**限制:**
|
||||||
活跃持仓数达到 **`MAX_ACTIVE_POSITIONS`**(默认 1)时,**不允许**再添加「**箱体突破** / **收敛突破**」;仍可添加「**关键阻力位 / 支撑位**」。
|
活跃持仓数达到 **`MAX_ACTIVE_POSITIONS`**(默认 1)时,**不允许**再添加「**箱体突破** / **收敛突破**」;仍可添加「**关键阻力位 / 支撑位**」。
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ Binance / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `k
|
|||||||
| **关键阻力位** | **不选**(`direction=watch`) | **否** | 5m 收盘突破上/下沿 → 微信 **3 次** → `key_level_alert_done` |
|
| **关键阻力位** | **不选**(`direction=watch`) | **否** | 5m 收盘突破上/下沿 → 微信 **3 次** → `key_level_alert_done` |
|
||||||
| **关键支撑位** | **不选** | **否** | 同上(与阻力位**相同规则**:填上沿+下沿,程序双向监控) |
|
| **关键支撑位** | **不选** | **否** | 同上(与阻力位**相同规则**:填上沿+下沿,程序双向监控) |
|
||||||
| 斐波回调 0.618 / 0.786 | 必选 | 限价挂单逻辑 | 见斐波说明(**不在下文展开**) |
|
| 斐波回调 0.618 / 0.786 | 必选 | 限价挂单逻辑 | 见斐波说明(**不在下文展开**) |
|
||||||
|
| **回调触价开仓** | **必选** 多/空 | **程序盯价 → 回调触 E 后市价** | 见下文 **§四** |
|
||||||
|
| **突破触价开仓** | **必选** 多/空 | **程序盯价 → 穿越 E 立即市价** | 见下文 **§四** |
|
||||||
|
|
||||||
**添加时(所有类型):** 品种须 **日成交量排名前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30)**;上沿 **>** 下沿。
|
**添加时(箱体/收敛/斐波/触价):** 品种须 **日成交量排名前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30)**;上沿 **>** 下沿(触价开仓填 E/SL/TP,上下沿仅作展示占位)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -110,6 +112,7 @@ Binance / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `k
|
|||||||
|
|
||||||
| `close_reason` | 含义 |
|
| `close_reason` | 含义 |
|
||||||
|----------------|------|
|
|----------------|------|
|
||||||
|
| `box_opposite_break` | 标记价先突破反向边界(多:≤下沿;空:≥上沿) |
|
||||||
| `rr_insufficient` | 门控通过但 RR 不达标或 SL/TP 几何无效 |
|
| `rr_insufficient` | 门控通过但 RR 不达标或 SL/TP 几何无效 |
|
||||||
| `exchange_failed` | RR 达标但实盘/交易所等原因未开仓 |
|
| `exchange_failed` | RR 达标但实盘/交易所等原因未开仓 |
|
||||||
| `auto_opened` | RR 达标且市价开仓成功 |
|
| `auto_opened` | RR 达标且市价开仓成功 |
|
||||||
@@ -117,7 +120,37 @@ Binance / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `k
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 四、环境与参数(`.env` 摘要)
|
## 四、回调 / 突破触价开仓(程序触价,无交易所挂单)
|
||||||
|
|
||||||
|
### 4.1 录入
|
||||||
|
|
||||||
|
- **回调触价开仓**:方向必选多/空;填写 **计划入场价 E**、**止损 SL**、**止盈 TP**(做多须 `SL < E < TP`)。
|
||||||
|
- **突破触价开仓**:同上;添加时当前价须在突破方向一侧(做多:价低于 E;做空:价高于 E)。
|
||||||
|
- 计划 RR 以 **E** 为基准,须 **严格大于** `KEY_AUTO_MIN_PLANNED_RR`(默认 1.5)。
|
||||||
|
- 可选移动保本、时间平仓;**全仓杠杆模式**下可用。
|
||||||
|
|
||||||
|
### 4.2 触发与结案
|
||||||
|
|
||||||
|
| 类型 | 触发条件(标记价) |
|
||||||
|
|------|-------------------|
|
||||||
|
| **回调触价** | 做多 `≤ E`;做空 `≥ E` → 下一轮询市价开仓 |
|
||||||
|
| **突破触价** | 做多**向上穿越** E;做空**向下穿越** E → **立即**市价开仓 |
|
||||||
|
|
||||||
|
- 未成交前标记价先触 **TP 侧** → `trigger_tp_invalidate`。
|
||||||
|
- **突破触价**另:未穿越 E 先触 **SL 侧** → `trigger_sl_invalidate`。
|
||||||
|
- **24h** 未触发 → `trigger_entry_expired`。
|
||||||
|
- 成功 → `trigger_entry_filled`;触发后开仓失败 → `trigger_exchange_failed`。
|
||||||
|
|
||||||
|
### 4.3 计仓与占位
|
||||||
|
|
||||||
|
- **以损定仓**:按 E、SL 反推保证金,触发时重算;**全仓杠杆**:可用×缓冲比例,BTC/ETH 10x、其它 5x。
|
||||||
|
- **占当日开仓意图**(已开 + 待触发),未成交不占持仓;同币仅 1 条触价监控(含回调/突破)。
|
||||||
|
|
||||||
|
共享逻辑:`trigger_entry_key_monitor_lib.py`;轮询:`check_trigger_entry_key_monitors`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、环境与参数(`.env` 摘要)
|
||||||
|
|
||||||
| 变量 | 箱体/收敛 | 阻力/支撑 |
|
| 变量 | 箱体/收敛 | 阻力/支撑 |
|
||||||
|------|-----------|-----------|
|
|------|-----------|-----------|
|
||||||
@@ -130,7 +163,7 @@ Binance / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `k
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 五、相关代码
|
## 六、相关代码
|
||||||
|
|
||||||
| 说明 | 位置 |
|
| 说明 | 位置 |
|
||||||
|------|------|
|
|------|------|
|
||||||
|
|||||||
@@ -141,7 +141,7 @@
|
|||||||
## 升级步骤
|
## 升级步骤
|
||||||
|
|
||||||
1. `git pull` 后对比 `.env.example`,把新增变量合并进本地 `.env`。
|
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` 等)。
|
3. 重启 Gate 实例服务(如 `pm2 restart crypto_gate`);首次启动会自动 `ALTER TABLE` 缺列(斐波、交易所盈亏、`entry_reason` 等)。
|
||||||
4. 浏览器强刷(Ctrl+F5)避免旧版 `index.html` 缓存。
|
4. 浏览器强刷(Ctrl+F5)避免旧版 `index.html` 缓存。
|
||||||
5. 打开任意页确认顶栏出现 **「列表筛选(UTC)」**;`/stats` 可见分品类统计与「北京 8:00 切日」说明。
|
5. 打开任意页确认顶栏出现 **「列表筛选(UTC)」**;`/stats` 可见分品类统计与「北京 8:00 切日」说明。
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ bash scripts/backup_data.sh # 试跑
|
|||||||
|
|
||||||
备份目录:`/root/backups/crypto_monitor_gate/YYYY-MM-DD/`。详见 Binance 项目 `部署文档.md` 第 5.4 节(恢复步骤、可选 `.env` 变量相同)。
|
备份目录:`/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 + 代理)
|
### 5.5 必填项检查(Gate + 代理)
|
||||||
|
|
||||||
|
|||||||
@@ -1,200 +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=dekun
|
|
||||||
# 登录密码(请改成你自己的强密码)
|
|
||||||
APP_PASSWORD=ChangeMe123!
|
|
||||||
# 是否关闭登录校验(局域网可设 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
|
|
||||||
|
|
||||||
# 资金与仓位刷新周期(秒)
|
|
||||||
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
|
|
||||||
@@ -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** 后台一致,并遵守当地法律法规与交易所用户协议。
|
|
||||||
@@ -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 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Daily backup: SQLite DB + static/images → /root/backups/<instance>/<YYYY-MM-DD>/
|
|
||||||
# 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"
|
|
||||||
@@ -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())
|
|
||||||
@@ -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())
|
|
||||||
|
|
||||||
@@ -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
|
|
||||||
@@ -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())
|
|
||||||
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 181 B |
|
Before Width: | Height: | Size: 162 B |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 497 B |
|
Before Width: | Height: | Size: 5.9 KiB |
@@ -1,17 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
|
||||||
<stop offset="0%" stop-color="#22d3ee"/>
|
|
||||||
<stop offset="100%" stop-color="#34d399"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<rect width="512" height="512" rx="108" fill="#0c1019"/>
|
|
||||||
<rect x="36" y="36" width="440" height="440" rx="88" fill="#141b2d"/>
|
|
||||||
<rect x="36" y="36" width="440" height="440" rx="88" fill="none" stroke="url(#g)" stroke-width="12"/>
|
|
||||||
<path d="M120 320 L200 248 L280 272 L392 168" fill="none" stroke="url(#g)" stroke-width="20" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
<circle cx="392" cy="168" r="18" fill="#34d399"/>
|
|
||||||
<rect x="168" y="268" width="28" height="64" rx="6" fill="#f87171"/>
|
|
||||||
<line x1="182" y1="248" x2="182" y2="340" stroke="#f87171" stroke-width="10" stroke-linecap="round"/>
|
|
||||||
<rect x="268" y="220" width="28" height="96" rx="6" fill="#34d399"/>
|
|
||||||
<line x1="282" y1="200" x2="282" y2="340" stroke="#34d399" stroke-width="10" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
ok2
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN" data-theme="dark">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<script src="/static/instance_theme.js?v=4"></script>
|
|
||||||
|
|
||||||
<title>登录 · {{ exchange_display }}</title>
|
|
||||||
<style>
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
background: #0a0a10;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100vh;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
.login-box {
|
|
||||||
background: #12121a;
|
|
||||||
padding: 2.5rem;
|
|
||||||
border-radius: 16px;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 400px;
|
|
||||||
border: 1px solid #242435;
|
|
||||||
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
|
|
||||||
}
|
|
||||||
.login-box h2 {
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
background: linear-gradient(90deg, #4cc2ff, #7b42ff);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
}
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 1.25rem;
|
|
||||||
}
|
|
||||||
.form-group label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: #a9a9ff;
|
|
||||||
}
|
|
||||||
.form-group input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.85rem 1rem;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: 1px solid #2e2e45;
|
|
||||||
background: #1a1a29;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
.form-group input:focus {
|
|
||||||
border-color: #4cc2ff;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.9rem;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: none;
|
|
||||||
background: linear-gradient(90deg, #4285f4, #7b42ff);
|
|
||||||
color: #fff;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: 0.2s;
|
|
||||||
}
|
|
||||||
button:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
.flash {
|
|
||||||
padding: 0.8rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
background: #331e24;
|
|
||||||
color: #ff6666;
|
|
||||||
border-radius: 8px;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
.exchange-line {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: #8892b0;
|
|
||||||
margin: -0.5rem 0 1.25rem;
|
|
||||||
}
|
|
||||||
.exchange-line strong {
|
|
||||||
color: #b8f5d0;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<link rel="stylesheet" href="/static/instance_theme.css?v=4">
|
|
||||||
|
|
||||||
</head>
|
|
||||||
<div class="login-theme-bar">
|
|
||||||
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
|
|
||||||
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
|
|
||||||
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
|
||||||
<path fill="currentColor" d="M12.1 3a9 9 0 1 0 8.9 11 6.5 6.5 0 1 1-8.9-11z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="theme-toggle-btn" data-theme-value="light" aria-pressed="false" title="亮色主题">
|
|
||||||
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
|
||||||
<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<body>
|
|
||||||
<div class="login-box">
|
|
||||||
<h2>交易监控系统登录</h2>
|
|
||||||
<p class="exchange-line">交易所:<strong>{{ exchange_display }}</strong></p>
|
|
||||||
{% with messages = get_flashed_messages() %}
|
|
||||||
{% if messages %}
|
|
||||||
<div class="flash">{{ messages[0] }}</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
<form method="POST">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>账号</label>
|
|
||||||
<input type="text" name="username" required placeholder="请输入账号">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>密码</label>
|
|
||||||
<input type="password" name="password" required placeholder="请输入密码">
|
|
||||||
</div>
|
|
||||||
<button type="submit">登录</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>实盘下单放大 | 100根K线</title>
|
|
||||||
<style>
|
|
||||||
*{margin:0;padding:0;box-sizing:border-box}
|
|
||||||
body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;background:#0b0d14;color:#eaeaea;padding:14px}
|
|
||||||
.container{width:min(98vw,1900px);margin:0 auto}
|
|
||||||
.card{background:#121726;border-radius:10px;padding:12px;border:1px solid #2a3150;margin-bottom:12px}
|
|
||||||
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
|
||||||
.btn{padding:7px 10px;border-radius:8px;text-decoration:none;border:1px solid #304164;background:#151a2a;color:#8fc8ff;cursor:pointer}
|
|
||||||
.btn:hover{background:#1f2740}
|
|
||||||
select,button{padding:8px 10px;border-radius:8px;border:1px solid #2e2e45;background:#1a1a29;color:#fff}
|
|
||||||
.meta{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px;margin-top:10px}
|
|
||||||
.meta-item{background:#141b2f;border:1px solid #27324e;border-radius:8px;padding:8px}
|
|
||||||
.meta-item .k{font-size:.76rem;color:#9fb0d8}
|
|
||||||
.meta-item .v{font-size:1rem;margin-top:4px;word-break:break-all}
|
|
||||||
.status{font-size:.84rem;color:#95a2c2}
|
|
||||||
.status.err{color:#ff8080}
|
|
||||||
#chart-wrap{height:560px;background:#0f1320;border:1px solid #2a3150;border-radius:10px;padding:8px}
|
|
||||||
#chart{width:100%;height:100%}
|
|
||||||
.empty{padding:18px;color:#95a2c2}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="card">
|
|
||||||
<div class="row" style="justify-content:space-between">
|
|
||||||
<div class="row">
|
|
||||||
<a class="btn" href="/">返回首页</a>
|
|
||||||
<strong style="color:#dbe4ff">实盘下单放大(100根K线)</strong>
|
|
||||||
</div>
|
|
||||||
<div class="status">最近刷新:<span id="updated-at">--</span></div>
|
|
||||||
</div>
|
|
||||||
{% if orders %}
|
|
||||||
<div class="row" style="margin-top:10px">
|
|
||||||
<label>订单</label>
|
|
||||||
<select id="order-id">
|
|
||||||
{% for o in orders %}
|
|
||||||
<option value="{{ o.id }}" {% if selected_order and o.id == selected_order.id %}selected{% endif %}>
|
|
||||||
#{{ o.id }} {{ o.symbol }} {{ '做多' if o.direction == 'long' else '做空' }}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
<label>周期</label>
|
|
||||||
<select id="timeframe">
|
|
||||||
{% for tf in ['1m','3m','5m','15m','30m','1h','4h','1d'] %}
|
|
||||||
<option value="{{ tf }}" {% if tf == default_timeframe %}selected{% endif %}>{{ tf }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
<button id="manual-refresh" type="button">刷新</button>
|
|
||||||
<span id="load-status" class="status"></span>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="empty">当前没有激活订单,无法展示放大K线。</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if orders %}
|
|
||||||
<div class="card">
|
|
||||||
<div class="meta">
|
|
||||||
<div class="meta-item"><div class="k">交易对</div><div class="v" id="m-symbol">-</div></div>
|
|
||||||
<div class="meta-item"><div class="k">方向</div><div class="v" id="m-direction">-</div></div>
|
|
||||||
<div class="meta-item"><div class="k">成交价</div><div class="v" id="m-entry">-</div></div>
|
|
||||||
<div class="meta-item"><div class="k">止损</div><div class="v" id="m-sl">-</div></div>
|
|
||||||
<div class="meta-item"><div class="k">止盈</div><div class="v" id="m-tp">-</div></div>
|
|
||||||
<div class="meta-item"><div class="k">盈亏比</div><div class="v" id="m-rr">-</div></div>
|
|
||||||
<div class="meta-item"><div class="k">现价</div><div class="v" id="m-price">-</div></div>
|
|
||||||
<div class="meta-item"><div class="k">浮盈亏</div><div class="v" id="m-pnl">-</div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div id="chart-wrap"><div id="chart"></div></div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if orders %}
|
|
||||||
<script src="https://unpkg.com/lightweight-charts/dist/lightweight-charts.standalone.production.js"></script>
|
|
||||||
<script>
|
|
||||||
const refreshMs = Math.max({{ price_refresh_seconds * 1000 }}, 5000);
|
|
||||||
const orderSelect = document.getElementById("order-id");
|
|
||||||
const tfSelect = document.getElementById("timeframe");
|
|
||||||
const statusEl = document.getElementById("load-status");
|
|
||||||
const updatedAtEl = document.getElementById("updated-at");
|
|
||||||
const chartHost = document.getElementById("chart");
|
|
||||||
const fmt = (v, d=6) => (v === null || typeof v === "undefined" || Number.isNaN(Number(v))) ? "-" : Number(v).toFixed(d);
|
|
||||||
|
|
||||||
let chart = null;
|
|
||||||
let candleSeries = null;
|
|
||||||
let priceLines = [];
|
|
||||||
|
|
||||||
function ensureChart(){
|
|
||||||
if(chart){ return true; }
|
|
||||||
if(!window.LightweightCharts){
|
|
||||||
statusEl.className = "status err";
|
|
||||||
statusEl.innerText = "图表库加载失败";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
chart = LightweightCharts.createChart(chartHost, {
|
|
||||||
layout: { background: { color: "#0f1320" }, textColor: "#d6deff" },
|
|
||||||
grid: { vertLines: { color: "#1e263d" }, horzLines: { color: "#1e263d" } },
|
|
||||||
rightPriceScale: { borderColor: "#2a3150" },
|
|
||||||
timeScale: { borderColor: "#2a3150", timeVisible: true, secondsVisible: false },
|
|
||||||
crosshair: { mode: 0 }
|
|
||||||
});
|
|
||||||
candleSeries = chart.addCandlestickSeries({
|
|
||||||
upColor: "#4cd97f",
|
|
||||||
downColor: "#ff6666",
|
|
||||||
borderVisible: false,
|
|
||||||
wickUpColor: "#4cd97f",
|
|
||||||
wickDownColor: "#ff6666"
|
|
||||||
});
|
|
||||||
window.addEventListener("resize", () => {
|
|
||||||
chart.applyOptions({ width: chartHost.clientWidth, height: chartHost.clientHeight });
|
|
||||||
});
|
|
||||||
chart.applyOptions({ width: chartHost.clientWidth, height: chartHost.clientHeight });
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetPriceLines(){
|
|
||||||
if(!candleSeries){ return; }
|
|
||||||
priceLines.forEach(line => {
|
|
||||||
try { candleSeries.removePriceLine(line); } catch (_) {}
|
|
||||||
});
|
|
||||||
priceLines = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function addLine(price, title, color){
|
|
||||||
if(!candleSeries || typeof price === "undefined" || price === null){ return; }
|
|
||||||
const p = Number(price);
|
|
||||||
if(Number.isNaN(p) || p <= 0){ return; }
|
|
||||||
priceLines.push(candleSeries.createPriceLine({
|
|
||||||
price: p, color, lineWidth: 1, lineStyle: 0, axisLabelVisible: true, title
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function paintOrder(order){
|
|
||||||
document.getElementById("m-symbol").innerText = order.symbol || "-";
|
|
||||||
document.getElementById("m-direction").innerText = (order.direction === "short") ? "做空" : "做多";
|
|
||||||
document.getElementById("m-entry").innerText = order.trigger_price_display || fmt(order.trigger_price, 8);
|
|
||||||
document.getElementById("m-sl").innerText = order.stop_loss_display || fmt(order.stop_loss, 8);
|
|
||||||
document.getElementById("m-tp").innerText = order.take_profit_display || fmt(order.take_profit, 8);
|
|
||||||
document.getElementById("m-rr").innerText = (order.rr_ratio === null || typeof order.rr_ratio === "undefined") ? "-" : `1:${Number(order.rr_ratio).toFixed(2)}`;
|
|
||||||
document.getElementById("m-price").innerText = order.current_price_display || fmt(order.current_price, 8);
|
|
||||||
const pnlEl = document.getElementById("m-pnl");
|
|
||||||
pnlEl.innerText = `${fmt(order.float_pnl, 2)}U (${fmt(order.float_pct, 2)}%)`;
|
|
||||||
pnlEl.style.color = Number(order.float_pnl || 0) > 0 ? "#4cd97f" : (Number(order.float_pnl || 0) < 0 ? "#ff6666" : "#d6deff");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadOrderKline(){
|
|
||||||
if(!ensureChart()){ return; }
|
|
||||||
const orderId = orderSelect.value;
|
|
||||||
const timeframe = tfSelect.value;
|
|
||||||
if(!orderId){ return; }
|
|
||||||
statusEl.className = "status";
|
|
||||||
statusEl.innerText = "加载中...";
|
|
||||||
try{
|
|
||||||
const resp = await fetch(`/api/order_kline?order_id=${encodeURIComponent(orderId)}&timeframe=${encodeURIComponent(timeframe)}`);
|
|
||||||
const data = await resp.json();
|
|
||||||
if(!resp.ok || !data.ok){ throw new Error(data.msg || "请求失败"); }
|
|
||||||
const candles = Array.isArray(data.candles) ? data.candles : [];
|
|
||||||
if(!candles.length){
|
|
||||||
statusEl.className = "status err";
|
|
||||||
statusEl.innerText = "暂无K线数据";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
candleSeries.setData(candles);
|
|
||||||
resetPriceLines();
|
|
||||||
addLine(data.order.trigger_price, "成交价", "#42a5f5");
|
|
||||||
addLine(data.order.stop_loss, "止损", "#ff6666");
|
|
||||||
addLine(data.order.take_profit, "止盈", "#4cd97f");
|
|
||||||
chart.timeScale().fitContent();
|
|
||||||
paintOrder(data.order || {});
|
|
||||||
updatedAtEl.innerText = data.updated_at || "--";
|
|
||||||
statusEl.className = "status";
|
|
||||||
statusEl.innerText = `已加载 ${candles.length} 根K线`;
|
|
||||||
}catch(err){
|
|
||||||
statusEl.className = "status err";
|
|
||||||
statusEl.innerText = err && err.message ? err.message : "加载失败";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById("manual-refresh").addEventListener("click", loadOrderKline);
|
|
||||||
orderSelect.addEventListener("change", loadOrderKline);
|
|
||||||
tfSelect.addEventListener("change", loadOrderKline);
|
|
||||||
loadOrderKline();
|
|
||||||
setInterval(loadOrderKline, refreshMs);
|
|
||||||
</script>
|
|
||||||
{% endif %}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,145 +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 次微信**,然后本条 **结案进历史**。 |
|
|
||||||
| **关键支撑位** | 同上(仅提醒)。 |
|
|
||||||
|
|
||||||
3. **方向**:做多 / 做空(必选)。
|
|
||||||
4. **上沿 / 下沿**:必填;保存时会按交易所 **价格精度** 取整。
|
|
||||||
|
|
||||||
**限制:**
|
|
||||||
活跃持仓数达到 **`MAX_ACTIVE_POSITIONS`**(默认 1)时,**不允许**再添加「**箱体突破** / **收敛突破**」;仍可添加「**关键阻力位 / 支撑位**」。
|
|
||||||
若 **4h EMA55** 与你的方向逆势,页面会 **额外 Flash 提示**,**不阻挡**提交。
|
|
||||||
|
|
||||||
### 4.2 触发后会发生什么(简版)
|
|
||||||
|
|
||||||
- **箱体 / 收敛**:门控通过后计算计划 SL/TP 与 RR;不达标则 **微信说明 + `rr_insufficient` 结案**;达标则尝试 **市价开仓**,成功 **`auto_opened`**,失败 **`exchange_failed`**——均 **不重试同一关键位**。
|
|
||||||
- **阻力 / 支撑**:仅 **单次推送** → **`key_level_alert_only`** 结案。
|
|
||||||
|
|
||||||
详细公式、结案字段、与企业微信文案口径见 **`关键位自动下单说明.md`**。
|
|
||||||
|
|
||||||
### 4.3 列表与历史
|
|
||||||
|
|
||||||
- 当前条目可 **删除**(会按规则记入历史的情形见页面说明)。
|
|
||||||
- **关键位历史**:已结案记录;可配合导出链接(若有)做备份。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 实盘下单(顶栏「实盘下单」→ `/trade`)
|
|
||||||
|
|
||||||
用于 **自己点按钮** 开单:
|
|
||||||
|
|
||||||
- 持仓上限由 **`MAX_ACTIVE_POSITIONS`** 控制(默认 1,与关键位自动单共用)。
|
|
||||||
- **人工开仓**时计划盈亏比不得低于 **`MANUAL_MIN_PLANNED_RR`**(默认 1.4:1),否则页面弹窗且后端拒绝。
|
|
||||||
- 填写币种、方向、杠杆(可选)、止损/止盈(价格或百分比按表单说明)。
|
|
||||||
- 勾选是否启用 **移动保本** 等行为以 `.env`/页面默认值为准。
|
|
||||||
|
|
||||||
平仓通过页面 **平仓**(或等价入口),会从交易所市价处理并更新记录。**删除/误操作可能造成真实盈亏**,请先确认环境与方向。
|
|
||||||
|
|
||||||
开仓成功后持仓卡片上会显示 **「来源」**:手工单一般为 **下单监控**;来自关键位自动单的为 **关键位监控**。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 企业微信会看到什么
|
|
||||||
|
|
||||||
- 关键位:按类型与结案结果推送(RR 不足、下单失败、自动开仓成功、仅阻力支撑提醒等),**每条关键位结案路径原则上一条主推送**(详见 `关键位自动下单说明.md`)。
|
|
||||||
- 手工开仓、平仓、部分异常也会在规则满足时推送(以代码与配置为准)。
|
|
||||||
|
|
||||||
若未配置 **`WECHAT_WEBHOOK`** 或网络失败,可能只是看不到推送,不代表逻辑未执行;要紧操作请以 **交易所端持仓与挂单** 为准核对。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 强烈建议的风险与运维习惯
|
|
||||||
|
|
||||||
1. **先用 `LIVE_TRADING_ENABLED=false`** 验证页面、录入、推送,再开小资金开实盘。
|
|
||||||
2. **API 权限**:仅开所需合约权限;勿泄露密钥;定期轮换。
|
|
||||||
3. **单进程控盘**:同一账户避免本程序与其他机器人 **重复开仓**。
|
|
||||||
4. **自动备份**:服务器上执行 `bash scripts/install_backup_cron.sh`(每天北京时间 0:00 → `/root/backups`,保留 30 天);升级前也可 `bash scripts/backup_data.sh` 手动跑一次。
|
|
||||||
5. **升级代码后**:启动时会跑 **数据库迁移**(如新列 `order_monitors.monitor_type`);首次启动关注一下日志或无报错页面。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 常见问题(简要)
|
|
||||||
|
|
||||||
| 现象 | 可自查 |
|
|
||||||
|------|--------|
|
|
||||||
| 关键位永远不触发 | 5m 门控是否全通过(页面门控摘要)、币种日成交量是否在规则内、`KLINE_TIMEFRAME`。 |
|
|
||||||
| 有信号但不自动开仓 | `LIVE_TRADING_ENABLED`、`KEY_AUTO_MIN_PLANNED_RR`、计划 RR、是否已有持仓、API/余额报错(微信或日志)。 |
|
|
||||||
| 加不了箱体/收敛 | 是否已有活跃持仓;先平仓或改用「阻力/支撑位」仅提醒。 |
|
|
||||||
| 推送收不到 | `WECHAT_WEBHOOK`、企业微信机器人配额与网络。 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Binance 版(`crypto_monitor_binance`)差异速查
|
|
||||||
|
|
||||||
| 项目 | Gate 本仓库 | Binance 版 |
|
|
||||||
|------|-------------|------------|
|
|
||||||
| API 变量 | `GATE_API_KEY`、`GATE_API_SECRET`、`GATE_*` | `BINANCE_API_KEY`、`BINANCE_API_SECRET`、`BINANCE_*` |
|
|
||||||
| 实盘开关 | `LIVE_TRADING_ENABLED`(通用) | 同上 |
|
|
||||||
| 止盈止损挂载路径 | `_gate_place_tp_sl_orders` 与 `GATE_TPSL_*` | `_binance_place_tp_sl_orders`(U 本位条件单) |
|
|
||||||
| 资金显示舍入 | 以本仓库为准 | 与 **`FUNDS_DECIMALS`** 等一致 |
|
|
||||||
| 专门文档 | **`关键位自动下单说明.md`**(各仓库有一份,开头标明交易所) | 同左 |
|
|
||||||
|
|
||||||
操作流程(登录、关键位四类、手工单、单仓)**两份程序一致**:换目录、换 `.env` 即可对照使用。
|
|
||||||
@@ -1,142 +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` | 含义 |
|
|
||||||
|----------------|------|
|
|
||||||
| `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` |
|
|
||||||
@@ -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 已挂上;平仓后交易记录止损(开仓)与开仓类型是否正确。
|
|
||||||
@@ -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='保本止盈'`。
|
|
||||||
@@ -21,9 +21,9 @@ APP_PORT=5004
|
|||||||
APP_DEBUG=false
|
APP_DEBUG=false
|
||||||
|
|
||||||
# 登录账号
|
# 登录账号
|
||||||
APP_USERNAME=dekun
|
APP_USERNAME=admin
|
||||||
# 登录密码(请改成你自己的强密码)
|
# 登录密码(请改成你自己的强密码)
|
||||||
APP_PASSWORD=ChangeMe123!
|
APP_PASSWORD=admin123
|
||||||
# 是否关闭登录校验(局域网可设 true;公网务必 false)
|
# 是否关闭登录校验(局域网可设 true;公网务必 false)
|
||||||
APP_AUTH_DISABLED=true
|
APP_AUTH_DISABLED=true
|
||||||
# --- 多账户交易中控 manual_trading_hub ---
|
# --- 多账户交易中控 manual_trading_hub ---
|
||||||
@@ -167,6 +167,16 @@ DAILY_OPEN_ALERT_THRESHOLD=5
|
|||||||
# 【单日开仓硬上限】本交易日开仓次数>=该值后禁止一切新开仓直至下一交易日(北京时间 TRADING_DAY_RESET_HOUR 切日);0=不启用
|
# 【单日开仓硬上限】本交易日开仓次数>=该值后禁止一切新开仓直至下一交易日(北京时间 TRADING_DAY_RESET_HOUR 切日);0=不启用
|
||||||
DAILY_OPEN_HARD_LIMIT=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
|
||||||
|
|
||||||
KEY_CONFIRM_BREAKOUT_BAR=-2
|
KEY_CONFIRM_BREAKOUT_BAR=-2
|
||||||
KEY_CONFIRM_BAR=-1
|
KEY_CONFIRM_BAR=-1
|
||||||
KEY_VOLUME_MA_BARS=20
|
KEY_VOLUME_MA_BARS=20
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<script src="/static/instance_theme.js?v=6"></script>
|
<script src="/static/instance_theme.js?v=46"></script>
|
||||||
<link rel="stylesheet" href="/static/instance_theme_early.css?v=1">
|
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
|
||||||
|
<link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
|
||||||
|
<script src="/static/account_risk_badge.js?v=4"></script>
|
||||||
|
|
||||||
<meta name="theme-color" content="#0b0d14">
|
<meta name="theme-color" content="#0b0d14">
|
||||||
<meta name="apple-mobile-web-app-title" content="监控">
|
<meta name="apple-mobile-web-app-title" content="监控">
|
||||||
@@ -20,6 +22,7 @@
|
|||||||
.header{display:flex;flex-direction:column;align-items:center;gap:8px;margin-bottom:12px}
|
.header{display:flex;flex-direction:column;align-items:center;gap:8px;margin-bottom:12px}
|
||||||
.header h1{font-size:1.75rem;color:#dbe4ff;text-align:center;line-height:1.25}
|
.header h1{font-size:1.75rem;color:#dbe4ff;text-align:center;line-height:1.25}
|
||||||
.exchange-tag{font-size:.82rem;font-weight:600;color:#b8f5d0;background:#14241e;border:1px solid #2d6a4f;padding:5px 14px;border-radius:999px;letter-spacing:.06em}
|
.exchange-tag{font-size:.82rem;font-weight:600;color:#b8f5d0;background:#14241e;border:1px solid #2d6a4f;padding:5px 14px;border-radius:999px;letter-spacing:.06em}
|
||||||
|
.header-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:center}
|
||||||
.top-nav{display:flex;gap:8px;flex-wrap:wrap;justify-content:center;margin-bottom:12px}
|
.top-nav{display:flex;gap:8px;flex-wrap:wrap;justify-content:center;margin-bottom:12px}
|
||||||
.top-nav a{padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a;color:#8fc8ff;text-decoration:none}
|
.top-nav a{padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a;color:#8fc8ff;text-decoration:none}
|
||||||
.top-nav a.active{background:#2a3f6c;color:#dbe4ff}
|
.top-nav a.active{background:#2a3f6c;color:#dbe4ff}
|
||||||
@@ -34,6 +37,12 @@
|
|||||||
.form-row{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px;align-items:center}
|
.form-row{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px;align-items:center}
|
||||||
.form-row > input:not([type=checkbox]):not([type=radio]),.form-row > select{flex:0 1 auto;width:10rem;max-width:200px;min-width:7rem}
|
.form-row > input:not([type=checkbox]):not([type=radio]),.form-row > select{flex:0 1 auto;width:10rem;max-width:200px;min-width:7rem}
|
||||||
#add-order-form #sltp-mode{min-width:12.5rem;max-width:16rem;width:auto}
|
#add-order-form #sltp-mode{min-width:12.5rem;max-width:16rem;width:auto}
|
||||||
|
.order-plan-preview{display:flex;gap:18px;flex-wrap:wrap;align-items:center;margin:4px 0 10px;padding:10px 12px;background:#151a28;border:1px solid #2a3150;border-radius:8px;font-size:.85rem}
|
||||||
|
.order-preview-risk{color:#ff6b6b}.order-preview-risk strong{color:#ff8f8f;font-weight:600}
|
||||||
|
.order-preview-profit{color:#4cd97f}.order-preview-profit strong{color:#6ee7a0;font-weight:600}
|
||||||
|
.order-preview-rr{color:#cfd3ef}.order-preview-rr strong{font-weight:600;color:#dbe4ff}
|
||||||
|
.order-preview-rr.order-preview-rr-low strong{color:#ff8f8f}
|
||||||
|
.order-preview-rr.order-preview-rr-ok strong{color:#8fc8ff}
|
||||||
.form-row > button,.form-row > label{flex:0 0 auto}
|
.form-row > button,.form-row > label{flex:0 0 auto}
|
||||||
.form-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
|
.form-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
|
||||||
/* 复盘表单:长下拉文案需可收缩,否则会撑破四列网格 */
|
/* 复盘表单:长下拉文案需可收缩,否则会撑破四列网格 */
|
||||||
@@ -234,10 +243,17 @@
|
|||||||
.stats-period-block h3{font-size:1rem;color:#dbe4ff;margin-bottom:4px}
|
.stats-period-block h3{font-size:1rem;color:#dbe4ff;margin-bottom:4px}
|
||||||
.stats-period-block .sub{font-size:.78rem;color:#8892b0;margin-bottom:10px;line-height:1.4}
|
.stats-period-block .sub{font-size:.78rem;color:#8892b0;margin-bottom:10px;line-height:1.4}
|
||||||
</style>
|
</style>
|
||||||
<link rel="stylesheet" href="/static/instance_theme.css?v=14">
|
<link rel="stylesheet" href="/static/instance_theme.css?v=48">
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
<body data-page="{{ page }}">
|
<body
|
||||||
|
data-page="{{ page }}"
|
||||||
|
data-risk-percent="{{ risk_percent }}"
|
||||||
|
data-position-sizing-mode="{{ position_sizing_mode }}"
|
||||||
|
data-btc-leverage="{{ btc_leverage }}"
|
||||||
|
data-alt-leverage="{{ alt_leverage }}"
|
||||||
|
data-full-margin-buffer="{{ full_margin_buffer_ratio }}"
|
||||||
|
>
|
||||||
{% macro period_stats(title, s) %}
|
{% macro period_stats(title, s) %}
|
||||||
<div class="stats-period-block">
|
<div class="stats-period-block">
|
||||||
<h3>{{ title }}</h3>
|
<h3>{{ title }}</h3>
|
||||||
@@ -262,6 +278,7 @@
|
|||||||
<h1>加密货币|交易监控 + AI复盘一体化</h1>
|
<h1>加密货币|交易监控 + AI复盘一体化</h1>
|
||||||
<div class="header-row">
|
<div class="header-row">
|
||||||
<div class="exchange-tag">{{ exchange_display }}</div>
|
<div class="exchange-tag">{{ exchange_display }}</div>
|
||||||
|
<span class="risk-status-badge risk-status-{{ risk_status.status|default('normal') }}" id="account-risk-badge" role="status" title="{{ risk_status.reason|default('', true) }}" data-status-label="{{ risk_status.status_label|default('正常') }}"{% if risk_status.freeze_until_ms %} data-freeze-until-ms="{{ risk_status.freeze_until_ms }}"{% endif %}>{{ risk_status.status_label|default('正常') }}</span>
|
||||||
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
|
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
|
||||||
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
|
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
|
||||||
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
||||||
@@ -365,7 +382,7 @@
|
|||||||
</select>
|
</select>
|
||||||
<button type="submit">手动划转</button>
|
<button type="submit">手动划转</button>
|
||||||
</form>
|
</form>
|
||||||
<form id="add-order-form" action="/add_order" method="post" class="form-row">
|
<form id="add-order-form" action="/add_order" method="post" class="form-row" data-risk-percent="{{ risk_percent }}">
|
||||||
<input id="order-symbol" name="symbol" placeholder="BTC 或 BTC/USDT" required>
|
<input id="order-symbol" name="symbol" placeholder="BTC 或 BTC/USDT" required>
|
||||||
<select id="order-direction" name="direction" required>
|
<select id="order-direction" name="direction" required>
|
||||||
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
|
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
|
||||||
@@ -407,6 +424,7 @@
|
|||||||
<input id="order-tp-pct" name="tp_pct" type="number" min="0.01" step="0.01" placeholder="止盈%" style="display:none">
|
<input id="order-tp-pct" name="tp_pct" type="number" min="0.01" step="0.01" placeholder="止盈%" style="display:none">
|
||||||
<button type="submit">{{ open_position_button_label }}</button>
|
<button type="submit">{{ open_position_button_label }}</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% include 'order_plan_preview_bar.html' %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 style="margin-bottom:8px">实时持仓</h2>
|
<h2 style="margin-bottom:8px">实时持仓</h2>
|
||||||
@@ -440,6 +458,7 @@
|
|||||||
<span class="pos-meta-item">来源: {{ o.monitor_type|default('下单监控', true) }}{% if o.key_signal_type %} · {{ o.key_signal_type }}{% endif %}</span>
|
<span class="pos-meta-item">来源: {{ o.monitor_type|default('下单监控', true) }}{% if o.key_signal_type %} · {{ o.key_signal_type }}{% endif %}</span>
|
||||||
<span class="pos-meta-item">风格: {{ o.trade_style or 'trend' }}</span>
|
<span class="pos-meta-item">风格: {{ o.trade_style or 'trend' }}</span>
|
||||||
<span class="pos-meta-item">风险: {% 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 %}</span>
|
<span class="pos-meta-item">风险: {% 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 %}</span>
|
||||||
|
<span class="pos-meta-item" id="order-latest-risk-wrap-{{ o.id }}" style="display:none">最新风险: —</span>
|
||||||
<span class="pos-meta-item {% if o.breakeven_enabled %}pos-meta-on{% else %}pos-meta-off{% endif %}">
|
<span class="pos-meta-item {% if o.breakeven_enabled %}pos-meta-on{% else %}pos-meta-off{% endif %}">
|
||||||
{% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %}
|
{% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %}
|
||||||
</span>
|
</span>
|
||||||
@@ -462,6 +481,10 @@
|
|||||||
<span class="pos-label">盈亏比</span>
|
<span class="pos-label">盈亏比</span>
|
||||||
<span class="pos-value" id="order-rr-{{ o.id }}">{% if o.rr_ratio is not none %}{{ '%g'|format(o.rr_ratio) }}:1{% else %}-:1{% endif %}</span>
|
<span class="pos-value" id="order-rr-{{ o.id }}">{% if o.rr_ratio is not none %}{{ '%g'|format(o.rr_ratio) }}:1{% else %}-:1{% endif %}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pos-cell">
|
||||||
|
<span class="pos-label">张数</span>
|
||||||
|
<span class="pos-value" id="order-contracts-{{ o.id }}">{% if o.order_amount is not none %}{{ '%g'|format(o.order_amount) }}{% else %}—{% endif %}</span>
|
||||||
|
</div>
|
||||||
<div class="pos-cell">
|
<div class="pos-cell">
|
||||||
<span class="pos-label">标记价</span>
|
<span class="pos-label">标记价</span>
|
||||||
<span class="pos-value" id="order-price-{{ o.id }}">-</span>
|
<span class="pos-value" id="order-price-{{ o.id }}">-</span>
|
||||||
@@ -476,6 +499,8 @@
|
|||||||
<span>计划基数: {{ funds_fmt(o.margin_capital) if o.margin_capital is not none else '-' }}U</span>
|
<span>计划基数: {{ funds_fmt(o.margin_capital) if o.margin_capital is not none else '-' }}U</span>
|
||||||
<span>杠杆: {{ o.leverage or '-' }}x</span>
|
<span>杠杆: {{ o.leverage or '-' }}x</span>
|
||||||
<span>仓位占比: {{ o.position_ratio if o.position_ratio is not none else '-' }}%</span>
|
<span>仓位占比: {{ o.position_ratio if o.position_ratio is not none else '-' }}%</span>
|
||||||
|
<span>开仓时间: {{ (o.opened_at or '-')[:16] }}</span>
|
||||||
|
<span>持仓时长: <span class="order-hold-duration" id="order-hold-duration-{{ o.id }}" data-order-opened-ms="{{ o.opened_at_ms or '' }}">—</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="pos-ex-orders">
|
<div class="pos-ex-orders">
|
||||||
<div class="pos-ex-orders-title">交易所止盈止损</div>
|
<div class="pos-ex-orders-title">交易所止盈止损</div>
|
||||||
@@ -618,8 +643,7 @@
|
|||||||
<select name="type" required>
|
<select name="type" required>
|
||||||
<option value="箱体突破">箱体突破</option>
|
<option value="箱体突破">箱体突破</option>
|
||||||
<option value="收敛突破">收敛突破</option>
|
<option value="收敛突破">收敛突破</option>
|
||||||
<option value="关键阻力位">关键阻力位</option>
|
<option value="关键支撑阻力">关键支撑阻力</option>
|
||||||
<option value="关键支撑位">关键支撑位</option>
|
|
||||||
</select>
|
</select>
|
||||||
<select name="direction" required>
|
<select name="direction" required>
|
||||||
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
|
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
|
||||||
@@ -812,10 +836,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/instance_ui.js?v=1"></script>
|
<script src="/static/instance_ui.js?v=4"></script>
|
||||||
|
<script src="/static/instance_records_mobile.js?v=2"></script>
|
||||||
<script src="/static/time_close_ui.js?v=2"></script>
|
<script src="/static/time_close_ui.js?v=2"></script>
|
||||||
<script src="/static/ai_review_render.js?v=2"></script>
|
<script src="/static/ai_review_render.js?v=2"></script>
|
||||||
<script src="/static/form_submit_guard.js?v=2"></script>
|
<script src="/static/form_submit_guard.js?v=2"></script>
|
||||||
|
<script src="/static/manual_order_rr_preview.js?v=5"></script>
|
||||||
|
<script src="/static/strategy_roll.js?v=6"></script>
|
||||||
<script>
|
<script>
|
||||||
const JOURNAL_ENTRY_REASON_OPTIONS = {{ entry_reason_options | tojson }};
|
const JOURNAL_ENTRY_REASON_OPTIONS = {{ entry_reason_options | tojson }};
|
||||||
const JOURNAL_ENTRY_REASON_OTHER = {{ entry_reason_other_value | tojson }};
|
const JOURNAL_ENTRY_REASON_OTHER = {{ entry_reason_other_value | tojson }};
|
||||||
@@ -896,8 +923,10 @@ function setDetailBodyPlain(text){
|
|||||||
body.innerText = text || "";
|
body.innerText = text || "";
|
||||||
}
|
}
|
||||||
function setDetailBodyMarkdown(text){
|
function setDetailBodyMarkdown(text){
|
||||||
|
if(window.InstanceUI && InstanceUI.clearDetailActions) InstanceUI.clearDetailActions();
|
||||||
const body = document.getElementById("detailBody");
|
const body = document.getElementById("detailBody");
|
||||||
if(!body) return;
|
if(!body) return;
|
||||||
|
body.classList.remove("trade-record-detail-wrap", "journal-detail-meta");
|
||||||
if(window.AiReviewRender && AiReviewRender.setElementMarkdown){
|
if(window.AiReviewRender && AiReviewRender.setElementMarkdown){
|
||||||
body.classList.add("md-review");
|
body.classList.add("md-review");
|
||||||
AiReviewRender.setElementMarkdown(body, text || "");
|
AiReviewRender.setElementMarkdown(body, text || "");
|
||||||
@@ -1099,22 +1128,12 @@ function loadJournals(){
|
|||||||
const qs = listWindowQueryString();
|
const qs = listWindowQueryString();
|
||||||
fetch("/api/journals" + (qs ? "?" + qs : "")).then(r=>r.json()).then(data=>{
|
fetch("/api/journals" + (qs ? "?" + qs : "")).then(r=>r.json()).then(data=>{
|
||||||
Object.keys(journalCache).forEach(k=>delete journalCache[k]);
|
Object.keys(journalCache).forEach(k=>delete journalCache[k]);
|
||||||
let html="";
|
data.forEach(o=>{ journalCache[o.id] = o; });
|
||||||
data.forEach(o=>{
|
|
||||||
journalCache[o.id] = o;
|
|
||||||
const moodTags = (o.mood_issues || []).join(",") || "无";
|
|
||||||
html += `<div class="entry">
|
|
||||||
<div><strong>${o.coin||"-"} ${o.tf||"-"}</strong> | 盈亏:${o.pnl||"-"}U</div>
|
|
||||||
<div>开:${o.open_datetime||"-"} 平:${o.close_datetime||"-"} 持仓:${o.hold_duration||"-"}</div>
|
|
||||||
<div>心态标签:${moodTags}</div>
|
|
||||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:6px">
|
|
||||||
<button type="button" class="btn-del" style="border:none;cursor:pointer;background:#1f3a5a;color:#8fc8ff" onclick="openJournalDetail('${o.id}')">查看详情</button>
|
|
||||||
<button type="button" class="btn-del" onclick="deleteJournal('${o.id}')">删除</button>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
});
|
|
||||||
const box = document.getElementById("journal-list");
|
const box = document.getElementById("journal-list");
|
||||||
if(box){ box.innerHTML = html || "<div class='entry'>暂无数据</div>"; }
|
if(box){
|
||||||
|
const html = InstanceUI.renderJournalListHtml(data);
|
||||||
|
box.innerHTML = html || "<div class='journal-empty-msg'>暂无数据</div>";
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1538,109 +1557,9 @@ if(journalForm){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 rsTypes = new Set(["关键阻力位","关键支撑位"]);
|
|
||||||
const showAuto = autoTypes.has(t);
|
|
||||||
const showFb = fbTypes.has(t);
|
|
||||||
const showBe = showAuto || fibTypes.has(t) || showFb;
|
|
||||||
const showDir = !rsTypes.has(t);
|
|
||||||
const upperEl = document.getElementById("key-upper");
|
|
||||||
const lowerEl = document.getElementById("key-lower");
|
|
||||||
const fbPriceEl = document.getElementById("key-fb-price");
|
|
||||||
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(window.TimeCloseUI) TimeCloseUI.syncKeyTimeCloseVisibility(showBe);
|
|
||||||
if(upperEl){
|
|
||||||
upperEl.style.display = showFb ? "none" : "";
|
|
||||||
upperEl.required = !showFb;
|
|
||||||
if(showFb) upperEl.value = "";
|
|
||||||
}
|
|
||||||
if(lowerEl){
|
|
||||||
lowerEl.style.display = showFb ? "none" : "";
|
|
||||||
lowerEl.required = !showFb;
|
|
||||||
if(showFb) 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") ? "低点(支撑)" : "做空填高点/做多填低点");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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(window.TimeCloseUI){
|
if(window.TimeCloseUI){
|
||||||
TimeCloseUI.bindTimeCloseForm("key-time-close-cb", "key-time-close-hours", "key-time-close-wrap");
|
|
||||||
TimeCloseUI.bindTimeCloseForm("order-time-close-cb", "order-time-close-hours", "order-time-close-wrap");
|
TimeCloseUI.bindTimeCloseForm("order-time-close-cb", "order-time-close-hours", "order-time-close-wrap");
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyForm = document.getElementById("key-form");
|
|
||||||
if(keyForm){
|
|
||||||
keyForm.addEventListener("submit", (e)=>{
|
|
||||||
e.preventDefault();
|
|
||||||
if(window.FormSubmitGuard && 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 === "假突破"){
|
|
||||||
if(window.FormSubmitGuard) FormSubmitGuard.nativeSubmitOnce(keyForm, "提交中…");
|
|
||||||
else keyForm.submit();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if(window.FormSubmitGuard) 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(window.FormSubmitGuard) 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} 当前24h成交额排名 ${data.rank == null ? "—" : data.rank}/${data.total},不在前${rankMax},已拦截。`);
|
|
||||||
if(window.FormSubmitGuard) FormSubmitGuard.unlock(keyForm);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if(window.FormSubmitGuard) FormSubmitGuard.nativeSubmitOnce(keyForm, "提交中…");
|
|
||||||
else keyForm.submit();
|
|
||||||
})
|
|
||||||
.catch(()=>{
|
|
||||||
alert("日成交量排名检查失败,请稍后重试");
|
|
||||||
if(window.FormSubmitGuard) FormSubmitGuard.unlock(keyForm);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// 复盘/AI列表:初次进入页面后再异步刷新一次,避免浏览器 bfcache/重定向后仍显示旧缓存
|
// 复盘/AI列表:初次进入页面后再异步刷新一次,避免浏览器 bfcache/重定向后仍显示旧缓存
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if(document.getElementById("journal-list")) loadJournals();
|
if(document.getElementById("journal-list")) loadJournals();
|
||||||
@@ -1691,6 +1610,7 @@ function refreshOrderTpPreview(entryPx){
|
|||||||
const entry = entryPx != null && Number.isFinite(Number(entryPx)) ? Number(entryPx) : sl;
|
const entry = entryPx != null && Number.isFinite(Number(entryPx)) ? Number(entryPx) : sl;
|
||||||
const tp = calcTpFromFixedRr(direction, entry, sl, rr);
|
const tp = calcTpFromFixedRr(direction, entry, sl, rr);
|
||||||
preview.textContent = tp == null ? "预估止盈:—" : ("预估止盈:" + formatPriceForInput(tp));
|
preview.textContent = tp == null ? "预估止盈:—" : ("预估止盈:" + formatPriceForInput(tp));
|
||||||
|
if(window.ManualOrderRrPreview) ManualOrderRrPreview.schedule();
|
||||||
}
|
}
|
||||||
function calcClientRr(direction, entry, sl, tp){
|
function calcClientRr(direction, entry, sl, tp){
|
||||||
const e = Number(entry), s = Number(sl), t = Number(tp);
|
const e = Number(entry), s = Number(sl), t = Number(tp);
|
||||||
@@ -1777,6 +1697,13 @@ function submitTpslEntrust(){
|
|||||||
alert(data.msg || '已提交');
|
alert(data.msg || '已提交');
|
||||||
closeTpslEntrustModal();
|
closeTpslEntrustModal();
|
||||||
if(data.exchange_tpsl) paintExchangeTpslRow(orderId, data.exchange_tpsl);
|
if(data.exchange_tpsl) paintExchangeTpslRow(orderId, data.exchange_tpsl);
|
||||||
|
paintPlanTpslDisplay(orderId, data);
|
||||||
|
paintLatestRiskDisplay(orderId, data);
|
||||||
|
const rrEl = document.getElementById(`order-rr-${orderId}`);
|
||||||
|
if(rrEl){
|
||||||
|
const rr = data.display_rr_ratio != null && data.display_rr_ratio !== "" ? data.display_rr_ratio : data.planned_rr;
|
||||||
|
rrEl.innerText = formatRrRatio(rr);
|
||||||
|
}
|
||||||
refreshPriceSnapshotConditional();
|
refreshPriceSnapshotConditional();
|
||||||
}).catch(()=>alert('委托请求失败'));
|
}).catch(()=>alert('委托请求失败'));
|
||||||
}
|
}
|
||||||
@@ -1844,6 +1771,25 @@ function paintPlanTpslDisplay(orderId, snap){
|
|||||||
else if(tpDisp) card.setAttribute("data-plan-tp", tpDisp);
|
else if(tpDisp) card.setAttribute("data-plan-tp", tpDisp);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function paintLatestRiskDisplay(orderId, snap){
|
||||||
|
const wrap = document.getElementById(`order-latest-risk-wrap-${orderId}`);
|
||||||
|
if(!wrap) return;
|
||||||
|
const v = snap && snap.latest_risk_amount;
|
||||||
|
const n = v != null && v !== "" ? Number(v) : NaN;
|
||||||
|
if(Number.isFinite(n)){
|
||||||
|
wrap.style.display = "inline-flex";
|
||||||
|
wrap.textContent = `最新风险: ${n.toFixed(2)}U`;
|
||||||
|
} else {
|
||||||
|
wrap.style.display = "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function paintContractsDisplay(orderId, snap){
|
||||||
|
const el = document.getElementById(`order-contracts-${orderId}`);
|
||||||
|
if(!el || !snap) return;
|
||||||
|
const v = snap.contracts != null && snap.contracts !== "" ? snap.contracts : snap.order_amount;
|
||||||
|
const n = v != null && v !== "" ? Number(v) : NaN;
|
||||||
|
el.innerText = Number.isFinite(n) ? String(parseFloat(n.toFixed(4))) : "—";
|
||||||
|
}
|
||||||
|
|
||||||
function paintPriceTrend(el, key, value){
|
function paintPriceTrend(el, key, value){
|
||||||
if(!el) return;
|
if(!el) return;
|
||||||
@@ -1927,8 +1873,11 @@ function refreshPriceSnapshot(){
|
|||||||
}
|
}
|
||||||
const rrEl = document.getElementById(`order-rr-${o.id}`);
|
const rrEl = document.getElementById(`order-rr-${o.id}`);
|
||||||
if(rrEl){
|
if(rrEl){
|
||||||
rrEl.innerText = formatRrRatio(o.rr_ratio);
|
const rr = o.display_rr_ratio != null && o.display_rr_ratio !== "" ? o.display_rr_ratio : o.rr_ratio;
|
||||||
|
rrEl.innerText = formatRrRatio(rr);
|
||||||
}
|
}
|
||||||
|
paintLatestRiskDisplay(o.id, o);
|
||||||
|
paintContractsDisplay(o.id, o);
|
||||||
paintBreakevenBadge(o.id, o.sl_breakeven_secured);
|
paintBreakevenBadge(o.id, o.sl_breakeven_secured);
|
||||||
if(o.exchange_tpsl) paintExchangeTpslRow(o.id, o.exchange_tpsl);
|
if(o.exchange_tpsl) paintExchangeTpslRow(o.id, o.exchange_tpsl);
|
||||||
paintPlanTpslDisplay(o.id, o);
|
paintPlanTpslDisplay(o.id, o);
|
||||||
@@ -1963,6 +1912,7 @@ function refreshOrderDefaults(){
|
|||||||
}
|
}
|
||||||
const px = data.last_price || data.price;
|
const px = data.last_price || data.price;
|
||||||
if(px) refreshOrderTpPreview(px);
|
if(px) refreshOrderTpPreview(px);
|
||||||
|
if(window.ManualOrderRrPreview) ManualOrderRrPreview.schedule();
|
||||||
}).catch(()=>{});
|
}).catch(()=>{});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1979,9 +1929,25 @@ function refreshAccountSnapshot(){
|
|||||||
if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) {
|
if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) {
|
||||||
latestAvailableUsdt = Number(data.available_trading_usdt);
|
latestAvailableUsdt = Number(data.available_trading_usdt);
|
||||||
}
|
}
|
||||||
|
if (data.risk_status) {
|
||||||
|
const badge = document.getElementById("account-risk-badge");
|
||||||
|
if (badge) {
|
||||||
|
if (window.AccountRiskBadge) {
|
||||||
|
AccountRiskBadge.applyToElement(badge, data.risk_status);
|
||||||
|
} else {
|
||||||
|
const st = data.risk_status.status || "normal";
|
||||||
|
badge.className = "risk-status-badge risk-status-" + st;
|
||||||
|
badge.innerText = data.risk_status.status_label || "正常";
|
||||||
|
badge.title = data.risk_status.reason || "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let canTradeText = "可开仓";
|
let canTradeText = "可开仓";
|
||||||
if(!data.can_trade){
|
if(!data.can_trade){
|
||||||
const parts = [];
|
const parts = [];
|
||||||
|
if (data.risk_status && data.risk_status.can_trade === false && data.risk_status.reason) {
|
||||||
|
parts.push(data.risk_status.reason);
|
||||||
|
}
|
||||||
if((data.active_count||0) >= (data.max_active_positions||{{ max_active_positions }})) parts.push(`持仓 ${data.active_count}/${data.max_active_positions}`);
|
if((data.active_count||0) >= (data.max_active_positions||{{ max_active_positions }})) parts.push(`持仓 ${data.active_count}/${data.max_active_positions}`);
|
||||||
const hard = Number(data.daily_open_hard_limit != null ? data.daily_open_hard_limit : {{ daily_open_hard_limit }});
|
const hard = Number(data.daily_open_hard_limit != null ? data.daily_open_hard_limit : {{ daily_open_hard_limit }});
|
||||||
const opens = Number(data.opens_today);
|
const opens = Number(data.opens_today);
|
||||||
@@ -2065,12 +2031,16 @@ function toggleSltpMode(){
|
|||||||
slPctEl.required = pct;
|
slPctEl.required = pct;
|
||||||
tpPctEl.required = pct;
|
tpPctEl.required = pct;
|
||||||
refreshOrderTpPreview();
|
refreshOrderTpPreview();
|
||||||
|
if(window.ManualOrderRrPreview) ManualOrderRrPreview.schedule();
|
||||||
}
|
}
|
||||||
if(sltpModeEl){
|
if(sltpModeEl){
|
||||||
sltpModeEl.addEventListener("change", toggleSltpMode);
|
sltpModeEl.addEventListener("change", toggleSltpMode);
|
||||||
loadFixedRrPref();
|
loadFixedRrPref();
|
||||||
toggleSltpMode();
|
toggleSltpMode();
|
||||||
}
|
}
|
||||||
|
if(window.ManualOrderRrPreview){
|
||||||
|
ManualOrderRrPreview.wire({ minRr: MANUAL_MIN_PLANNED_RR });
|
||||||
|
}
|
||||||
["order-sl","order-fixed-rr","order-direction"].forEach(function(id){
|
["order-sl","order-fixed-rr","order-direction"].forEach(function(id){
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if(el) el.addEventListener("input", function(){ refreshOrderTpPreview(); });
|
if(el) el.addEventListener("input", function(){ refreshOrderTpPreview(); });
|
||||||
@@ -2078,6 +2048,7 @@ if(sltpModeEl){
|
|||||||
});
|
});
|
||||||
|
|
||||||
refreshAccountSnapshot();
|
refreshAccountSnapshot();
|
||||||
|
if (window.AccountRiskBadge) AccountRiskBadge.startTicker();
|
||||||
const _journalFormEl = document.getElementById("journal-form");
|
const _journalFormEl = document.getElementById("journal-form");
|
||||||
if(_journalFormEl){
|
if(_journalFormEl){
|
||||||
_journalFormEl.addEventListener("submit", function(ev){
|
_journalFormEl.addEventListener("submit", function(ev){
|
||||||
@@ -2215,10 +2186,41 @@ function refreshPriceSnapshotConditional(){
|
|||||||
paintExchangeTpslRow(o.id, o.exchange_tpsl || {});
|
paintExchangeTpslRow(o.id, o.exchange_tpsl || {});
|
||||||
paintPlanTpslDisplay(o.id, o);
|
paintPlanTpslDisplay(o.id, o);
|
||||||
if(window.TimeCloseUI) TimeCloseUI.paintOrderTimeClose(o);
|
if(window.TimeCloseUI) TimeCloseUI.paintOrderTimeClose(o);
|
||||||
|
const holdEl = document.getElementById(`order-hold-duration-${o.id}`);
|
||||||
|
if(holdEl && o.opened_at_ms != null && o.opened_at_ms !== ""){
|
||||||
|
holdEl.setAttribute("data-order-opened-ms", String(o.opened_at_ms));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
tickOrderHoldDurations();
|
||||||
}
|
}
|
||||||
}).catch(()=>{});
|
}).catch(()=>{});
|
||||||
}
|
}
|
||||||
|
function formatLiveHoldDurationFromMs(openedMs, nowMs){
|
||||||
|
if(openedMs == null || openedMs === "" || !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("");
|
||||||
|
}
|
||||||
|
function tickOrderHoldDurations(){
|
||||||
|
const now = Date.now();
|
||||||
|
document.querySelectorAll(".order-hold-duration[data-order-opened-ms]").forEach(el=>{
|
||||||
|
const ms = Number(el.getAttribute("data-order-opened-ms"));
|
||||||
|
if(!Number.isFinite(ms) || ms <= 0) return;
|
||||||
|
el.textContent = formatLiveHoldDurationFromMs(ms, now);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setInterval(tickOrderHoldDurations, 1000);
|
||||||
|
tickOrderHoldDurations();
|
||||||
setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }});
|
setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -109,14 +109,14 @@
|
|||||||
<div class="flash">{{ messages[0] }}</div>
|
<div class="flash">{{ messages[0] }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
<form method="POST">
|
<form method="POST" autocomplete="off">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>账号</label>
|
<label>账号</label>
|
||||||
<input type="text" name="username" required placeholder="请输入账号">
|
<input type="text" name="username" required placeholder="请输入账号" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>密码</label>
|
<label>密码</label>
|
||||||
<input type="password" name="password" required placeholder="请输入密码">
|
<input type="password" name="password" required placeholder="请输入密码" autocomplete="new-password">
|
||||||
</div>
|
</div>
|
||||||
<button type="submit">登录</button>
|
<button type="submit">登录</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -65,9 +65,11 @@
|
|||||||
| **收敛突破** | 同上(自动开仓类)。 |
|
| **收敛突破** | 同上(自动开仓类)。 |
|
||||||
| **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 |
|
| **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 |
|
||||||
| **关键支撑位** | 同上(仅提醒)。 |
|
| **关键支撑位** | 同上(仅提醒)。 |
|
||||||
|
| **回调触价开仓** | **不挂交易所限价**;标记价回调触达 E 后 **下一轮询市价开仓**(RR 门槛同 `KEY_AUTO_MIN_PLANNED_RR`);有效期 **24h** |
|
||||||
|
| **突破触价开仓** | **不挂交易所限价**;标记价 **穿越 E 立即市价开仓**;先触 SL/TP 侧失效;有效期 **24h** |
|
||||||
|
|
||||||
3. **方向**:做多 / 做空(必选)。
|
3. **方向**:做多 / 做空(触价开仓 / 箱体 / 收敛 / 斐波必选;阻力/支撑不选)。
|
||||||
4. **上沿 / 下沿**:必填;保存时会按交易所 **价格精度** 取整。
|
4. **价位**:箱体/收敛/阻力/支撑填 **上沿 / 下沿**;触价开仓填 **入场 E / 止损 SL / 止盈 TP**。
|
||||||
|
|
||||||
**限制:**
|
**限制:**
|
||||||
活跃持仓数达到 **`MAX_ACTIVE_POSITIONS`**(默认 1)时,**不允许**再添加「**箱体突破** / **收敛突破**」;仍可添加「**关键阻力位 / 支撑位**」。
|
活跃持仓数达到 **`MAX_ACTIVE_POSITIONS`**(默认 1)时,**不允许**再添加「**箱体突破** / **收敛突破**」;仍可添加「**关键阻力位 / 支撑位**」。
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 关键位监控说明(自动开仓 + 人工盯盘)
|
# 关键位监控说明(自动开仓 + 人工盯盘)
|
||||||
|
|
||||||
**适用:`crypto_monitor_okx`(OKX 永续)**
|
**适用:`crypto_monitor_gate`(Gate U 本位永续)**
|
||||||
箱体/收敛与 Binance、Gate 相同:**门控通过后自动市价开仓**(须 `LIVE_TRADING_ENABLED=true`)。阻力/支撑仍为微信提醒。共享逻辑见 `key_monitor_lib.py`。
|
Binance / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `key_monitor_lib.py`。
|
||||||
|
|
||||||
本文档与 `.env`、`check_key_monitors`、`add_key`、`_key_hard_checks`、`_process_key_rs_level_alert` 一致。
|
本文档与 `.env`、`check_key_monitors`、`add_key`、`_key_hard_checks`、`_process_key_rs_level_alert` 一致。
|
||||||
|
|
||||||
@@ -16,8 +16,10 @@
|
|||||||
| **关键阻力位** | **不选**(`direction=watch`) | **否** | 5m 收盘突破上/下沿 → 微信 **3 次** → `key_level_alert_done` |
|
| **关键阻力位** | **不选**(`direction=watch`) | **否** | 5m 收盘突破上/下沿 → 微信 **3 次** → `key_level_alert_done` |
|
||||||
| **关键支撑位** | **不选** | **否** | 同上(与阻力位**相同规则**:填上沿+下沿,程序双向监控) |
|
| **关键支撑位** | **不选** | **否** | 同上(与阻力位**相同规则**:填上沿+下沿,程序双向监控) |
|
||||||
| 斐波回调 0.618 / 0.786 | 必选 | 限价挂单逻辑 | 见斐波说明(**不在下文展开**) |
|
| 斐波回调 0.618 / 0.786 | 必选 | 限价挂单逻辑 | 见斐波说明(**不在下文展开**) |
|
||||||
|
| **回调触价开仓** | **必选** 多/空 | **程序盯价 → 回调触 E 后市价** | 见下文 **§四** |
|
||||||
|
| **突破触价开仓** | **必选** 多/空 | **程序盯价 → 穿越 E 立即市价** | 见下文 **§四** |
|
||||||
|
|
||||||
**添加时(所有类型):** 品种须 **日成交量排名前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30)**;上沿 **>** 下沿。
|
**添加时(箱体/收敛/斐波/触价):** 品种须 **日成交量排名前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30)**;上沿 **>** 下沿(触价开仓填 E/SL/TP,上下沿仅作展示占位)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -110,6 +112,7 @@
|
|||||||
|
|
||||||
| `close_reason` | 含义 |
|
| `close_reason` | 含义 |
|
||||||
|----------------|------|
|
|----------------|------|
|
||||||
|
| `box_opposite_break` | 标记价先突破反向边界(多:≤下沿;空:≥上沿) |
|
||||||
| `rr_insufficient` | 门控通过但 RR 不达标或 SL/TP 几何无效 |
|
| `rr_insufficient` | 门控通过但 RR 不达标或 SL/TP 几何无效 |
|
||||||
| `exchange_failed` | RR 达标但实盘/交易所等原因未开仓 |
|
| `exchange_failed` | RR 达标但实盘/交易所等原因未开仓 |
|
||||||
| `auto_opened` | RR 达标且市价开仓成功 |
|
| `auto_opened` | RR 达标且市价开仓成功 |
|
||||||
@@ -117,7 +120,37 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 四、环境与参数(`.env` 摘要)
|
## 四、回调 / 突破触价开仓(程序触价,无交易所挂单)
|
||||||
|
|
||||||
|
### 4.1 录入
|
||||||
|
|
||||||
|
- **回调触价开仓**:方向必选多/空;填写 **计划入场价 E**、**止损 SL**、**止盈 TP**(做多须 `SL < E < TP`)。
|
||||||
|
- **突破触价开仓**:同上;添加时当前价须在突破方向一侧(做多:价低于 E;做空:价高于 E)。
|
||||||
|
- 计划 RR 以 **E** 为基准,须 **严格大于** `KEY_AUTO_MIN_PLANNED_RR`(默认 1.5)。
|
||||||
|
- 可选移动保本、时间平仓;**全仓杠杆模式**下可用。
|
||||||
|
|
||||||
|
### 4.2 触发与结案
|
||||||
|
|
||||||
|
| 类型 | 触发条件(标记价) |
|
||||||
|
|------|-------------------|
|
||||||
|
| **回调触价** | 做多 `≤ E`;做空 `≥ E` → 下一轮询市价开仓 |
|
||||||
|
| **突破触价** | 做多**向上穿越** E;做空**向下穿越** E → **立即**市价开仓 |
|
||||||
|
|
||||||
|
- 未成交前标记价先触 **TP 侧** → `trigger_tp_invalidate`。
|
||||||
|
- **突破触价**另:未穿越 E 先触 **SL 侧** → `trigger_sl_invalidate`。
|
||||||
|
- **24h** 未触发 → `trigger_entry_expired`。
|
||||||
|
- 成功 → `trigger_entry_filled`;触发后开仓失败 → `trigger_exchange_failed`。
|
||||||
|
|
||||||
|
### 4.3 计仓与占位
|
||||||
|
|
||||||
|
- **以损定仓**:按 E、SL 反推保证金,触发时重算;**全仓杠杆**:可用×缓冲比例,BTC/ETH 10x、其它 5x。
|
||||||
|
- **占当日开仓意图**(已开 + 待触发),未成交不占持仓;同币仅 1 条触价监控(含回调/突破)。
|
||||||
|
|
||||||
|
共享逻辑:`trigger_entry_key_monitor_lib.py`;轮询:`check_trigger_entry_key_monitors`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、环境与参数(`.env` 摘要)
|
||||||
|
|
||||||
| 变量 | 箱体/收敛 | 阻力/支撑 |
|
| 变量 | 箱体/收敛 | 阻力/支撑 |
|
||||||
|------|-----------|-----------|
|
|------|-----------|-----------|
|
||||||
@@ -130,7 +163,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 五、相关代码
|
## 六、相关代码
|
||||||
|
|
||||||
| 说明 | 位置 |
|
| 说明 | 位置 |
|
||||||
|------|------|
|
|------|------|
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ nano .env
|
|||||||
- **升级前备份**:`cp .env .env.backup.$(date +%Y%m%d)`;恢复:`cp .env.backup.YYYYMMDD .env`。
|
- **升级前备份**:`cp .env .env.backup.$(date +%Y%m%d)`;恢复:`cp .env.backup.YYYYMMDD .env`。
|
||||||
- **换机**:`scp` 复制 `.env`,或新机 `cp .env.example .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 + 代理)
|
### 5.3 必填项检查(OKX + 代理)
|
||||||
|
|
||||||
|
|||||||
@@ -29,12 +29,14 @@ bash deploy/setup_env.sh --install-system-deps
|
|||||||
常用参数:
|
常用参数:
|
||||||
|
|
||||||
```bash
|
```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 --recreate-venv # 重建虚拟环境
|
||||||
bash deploy/setup_env.sh --skip-pm2 # 不尝试安装 pm2
|
bash deploy/setup_env.sh --skip-pm2 # 不尝试安装 pm2
|
||||||
bash deploy/setup_env.sh --skip-env-copy # 不复制 .env.example
|
bash deploy/setup_env.sh --skip-env-copy # 不复制 .env.example
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**整目录重装**(保留 `.env`、清库、去脏 PM2)见 **[reinstall-plan-b.md](./reinstall-plan-b.md)**,执行 `bash deploy/reinstall.sh`。与 `setup_env.sh` 独立,不影响首次一键安装。
|
||||||
|
|
||||||
若在其它环境编辑过脚本后报 `pipefail` 错误,先转 LF:
|
若在其它环境编辑过脚本后报 `pipefail` 错误,先转 LF:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -68,11 +70,13 @@ sed -i 's/\r$//' deploy/setup_env.sh
|
|||||||
pm2 save
|
pm2 save
|
||||||
```
|
```
|
||||||
|
|
||||||
3. 四所 `.env` 同步脚本见 **[docs/env-sync-scripts.md](../docs/env-sync-scripts.md)**。
|
或一条命令:`bash deploy/pm2_start_all.sh`
|
||||||
|
|
||||||
|
3. 三所 `.env` 同步脚本见 **[docs/env-sync-scripts.md](../docs/env-sync-scripts.md)**。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 依赖说明
|
## 依赖说明
|
||||||
|
|
||||||
- 四个监控子项目共用根目录 **[requirements.txt](../requirements.txt)**。
|
- 三个监控子项目共用根目录 **[requirements.txt](../requirements.txt)**。
|
||||||
- 走 SOCKS 须 **PySocks**(已包含在 requirements 中)。
|
- 走 SOCKS 须 **PySocks**(已包含在 requirements 中)。
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 按推荐顺序启动三所 Flask + 中控 hub/三 agent(PM2)。
|
||||||
|
# 用法(仓库根或任意目录):
|
||||||
|
# bash deploy/pm2_start_all.sh
|
||||||
|
#
|
||||||
|
# 与 deploy/setup_env.sh 独立:setup_env 只建 venv;本脚本负责 PM2 启动。
|
||||||
|
set -e
|
||||||
|
set -u
|
||||||
|
if [ -n "${BASH_VERSION:-}" ]; then
|
||||||
|
set -o pipefail
|
||||||
|
fi
|
||||||
|
|
||||||
|
DEPLOY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "${DEPLOY_DIR}/.." && pwd)"
|
||||||
|
|
||||||
|
start_one() {
|
||||||
|
local dir_name="$1"
|
||||||
|
local proj="${REPO_ROOT}/${dir_name}"
|
||||||
|
local eco="${proj}/ecosystem.config.cjs"
|
||||||
|
if [[ ! -f "${eco}" ]]; then
|
||||||
|
echo "skip (no ecosystem): ${dir_name}" >&2
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "==> pm2 start ${dir_name}"
|
||||||
|
(cd "${proj}" && pm2 start ecosystem.config.cjs)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ! command -v pm2 >/dev/null 2>&1; then
|
||||||
|
echo "未找到 pm2,请先安装 Node.js 与 pm2(见 docs/ubuntu-server.md)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
start_one crypto_monitor_binance
|
||||||
|
start_one crypto_monitor_gate
|
||||||
|
start_one crypto_monitor_okx
|
||||||
|
start_one manual_trading_hub
|
||||||
|
|
||||||
|
pm2 save 2>/dev/null || true
|
||||||
|
echo ""
|
||||||
|
echo "PM2 进程:"
|
||||||
|
pm2 list
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# Plan B:整目录重装(生产清库)
|
||||||
|
|
||||||
|
适用于:**保留三所 `.env` 与中控配置,丢弃旧代码、旧 SQLite、脏 PM2 名单**(例如移除 `gate_bot` 后偶发重启)。
|
||||||
|
|
||||||
|
与 **[setup_env.sh](./setup_env.sh)** 的关系:
|
||||||
|
|
||||||
|
| 脚本 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `setup_env.sh` | **首次安装 / 日常**:建 venv、装依赖、从 `.env.example` 复制(**不变**) |
|
||||||
|
| `reinstall.sh` | **整目录重装**:备份 → 移走旧目录 → `git clone` → 调 `setup_env.sh` → 恢复配置 → PM2 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一键执行(推荐)
|
||||||
|
|
||||||
|
在现有服务器安装上以 **root** 执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/crypto_monitor
|
||||||
|
bash deploy/reinstall.sh --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
交互确认(不加 `--yes`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash deploy/reinstall.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
仅预览步骤:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash deploy/reinstall.sh --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 脚本会做什么
|
||||||
|
|
||||||
|
1. 备份到 **`/root/backups/pre-reinstall-YYYYMMDD-HHMMSS/`**
|
||||||
|
- 三所 `crypto_monitor_*/.env`
|
||||||
|
- `manual_trading_hub/.env`
|
||||||
|
- `manual_trading_hub/hub_settings.json`(若有)
|
||||||
|
- 可选:仓库内 `one_shot` 备份目录
|
||||||
|
2. **`pm2 stop all` + `pm2 delete all`**
|
||||||
|
3. **`mv /opt/crypto_monitor /opt/crypto_monitor.old.时间戳`**
|
||||||
|
4. **`git clone`** 到 `/opt/crypto_monitor`(默认 `main`)
|
||||||
|
5. **`bash deploy/setup_env.sh --skip-env-copy --recreate-venv --skip-pm2`**
|
||||||
|
6. 从备份 **恢复 `.env` / `hub_settings.json`**
|
||||||
|
7. **`deploy/sanitize_hub_settings.py`** 去掉 `gate_bot` / 第四账户
|
||||||
|
8. **`deploy/pm2_start_all.sh`** + `pm2 save`
|
||||||
|
9. 为三所重装 **每日 0 点备份 cron**(可用 `--no-backup-cron` 跳过)
|
||||||
|
|
||||||
|
**不会备份/恢复**:`crypto.db`、hub `data/*.db`、`static/images`(符合「全新启动」)。
|
||||||
|
|
||||||
|
**不会动**:宝塔/Nginx 反代、SSH SOCKS 隧道(tmux 内)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export INSTALL_ROOT=/opt/crypto_monitor
|
||||||
|
export GIT_URL=https://git.bz121.com/dekun/crypto_monitor.git
|
||||||
|
export GIT_BRANCH=main
|
||||||
|
export BACKUP_ROOT=/root/backups
|
||||||
|
bash deploy/reinstall.sh --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 list
|
||||||
|
# 应有 7 个: crypto_binance crypto_gate crypto_okx manual-trading-hub manual-agent-*
|
||||||
|
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:5100/
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器:中控 `/monitor` 登录,三所 LINK 绿,监控区为空库。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 回滚
|
||||||
|
|
||||||
|
旧目录默认保留为 `/opt/crypto_monitor.old.时间戳`,配置在 `/root/backups/pre-reinstall-*`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 delete all
|
||||||
|
rm -rf /opt/crypto_monitor
|
||||||
|
mv /opt/crypto_monitor.old.XXXXXXXX /opt/crypto_monitor
|
||||||
|
bash /opt/crypto_monitor/deploy/pm2_start_all.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
确认新环境稳定后再删 `.old.*` 目录。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 辅助脚本
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| [pm2_start_all.sh](./pm2_start_all.sh) | 按顺序 PM2 启动三所 + hub(setup_env 之后手动用) |
|
||||||
|
| [sanitize_hub_settings.py](./sanitize_hub_settings.py) | 清理 `hub_settings.json` 中 gate_bot 条目 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [deploy/README.md](./README.md) — 首次一键安装
|
||||||
|
- [docs/ubuntu-server.md](../docs/ubuntu-server.md) — Python / PM2 版本
|
||||||
|
- [备份与恢复.md](../备份与恢复.md) — 日常 DB 备份 cron
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Plan B:整目录重装 /opt/crypto_monitor(备份 .env → 移走旧目录 → git clone → setup_env → 恢复配置 → PM2)
|
||||||
|
#
|
||||||
|
# 与 deploy/setup_env.sh 分工:
|
||||||
|
# setup_env.sh — 首次 / 日常:建 venv、装依赖、复制 .env.example(一键安装,不变)
|
||||||
|
# reinstall.sh — 生产清库重装:保留密钥与 hub 配置,丢弃旧代码/旧库/脏 PM2
|
||||||
|
#
|
||||||
|
# 用法(在现有安装目录以 root 执行):
|
||||||
|
# cd /opt/crypto_monitor
|
||||||
|
# bash deploy/reinstall.sh # 交互确认
|
||||||
|
# bash deploy/reinstall.sh --yes # 跳过确认
|
||||||
|
# bash deploy/reinstall.sh --dry-run # 仅打印步骤
|
||||||
|
#
|
||||||
|
# 可选环境变量:
|
||||||
|
# INSTALL_ROOT=/opt/crypto_monitor
|
||||||
|
# GIT_URL=https://git.bz121.com/dekun/crypto_monitor.git
|
||||||
|
# GIT_BRANCH=main
|
||||||
|
# BACKUP_ROOT=/root/backups
|
||||||
|
#
|
||||||
|
set -e
|
||||||
|
set -u
|
||||||
|
if [ -n "${BASH_VERSION:-}" ]; then
|
||||||
|
set -o pipefail
|
||||||
|
fi
|
||||||
|
|
||||||
|
DEPLOY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
SCRIPT_SOURCE="${DEPLOY_DIR}/reinstall.sh"
|
||||||
|
REPO_ROOT="$(cd "${DEPLOY_DIR}/.." && pwd)"
|
||||||
|
|
||||||
|
INSTALL_ROOT="${INSTALL_ROOT:-/opt/crypto_monitor}"
|
||||||
|
GIT_URL="${GIT_URL:-https://git.bz121.com/dekun/crypto_monitor.git}"
|
||||||
|
GIT_BRANCH="${GIT_BRANCH:-main}"
|
||||||
|
BACKUP_ROOT="${BACKUP_ROOT:-/root/backups}"
|
||||||
|
TZ_NAME="${REINSTALL_TZ:-Asia/Shanghai}"
|
||||||
|
|
||||||
|
ASSUME_YES=0
|
||||||
|
DRY_RUN=0
|
||||||
|
INSTALL_BACKUP_CRON=1
|
||||||
|
|
||||||
|
CONFIG_PATHS=(
|
||||||
|
"crypto_monitor_binance/.env"
|
||||||
|
"crypto_monitor_okx/.env"
|
||||||
|
"crypto_monitor_gate/.env"
|
||||||
|
"manual_trading_hub/.env"
|
||||||
|
"manual_trading_hub/hub_settings.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
sed -n '2,18p' "$0" | sed 's/^# \?//'
|
||||||
|
exit "${1:-0}"
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--yes|-y) ASSUME_YES=1; shift ;;
|
||||||
|
--dry-run) DRY_RUN=1; shift ;;
|
||||||
|
--no-backup-cron) INSTALL_BACKUP_CRON=0; shift ;;
|
||||||
|
-h|--help) usage 0 ;;
|
||||||
|
*) echo "未知参数: $1" >&2; usage 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
log() { printf '[%s] %s\n' "$(TZ="${TZ_NAME}" date '+%Y-%m-%d %H:%M:%S')" "$*"; }
|
||||||
|
step() { echo ""; log "==> $*"; }
|
||||||
|
|
||||||
|
run() {
|
||||||
|
if [[ "${DRY_RUN}" -eq 1 ]]; then
|
||||||
|
log "[dry-run] $*"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
log "+ $*"
|
||||||
|
"$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
confirm() {
|
||||||
|
if [[ "${ASSUME_YES}" -eq 1 || "${DRY_RUN}" -eq 1 ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
local msg="$1"
|
||||||
|
read -r -p "${msg} [y/N] " ans
|
||||||
|
[[ "${ans}" == [yY] || "${ans}" == [yY][eE][sS] ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_path() {
|
||||||
|
local base="$1"
|
||||||
|
local rel="$2"
|
||||||
|
printf '%s/%s' "${base}" "${rel}"
|
||||||
|
}
|
||||||
|
|
||||||
|
backup_configs() {
|
||||||
|
local src_root="$1"
|
||||||
|
local dest="$2"
|
||||||
|
mkdir -p "${dest}"
|
||||||
|
local rel copied=0
|
||||||
|
for rel in "${CONFIG_PATHS[@]}"; do
|
||||||
|
local src
|
||||||
|
src="$(resolve_path "${src_root}" "${rel}")"
|
||||||
|
if [[ -f "${src}" ]]; then
|
||||||
|
mkdir -p "${dest}/$(dirname "${rel}")"
|
||||||
|
if [[ "${DRY_RUN}" -eq 1 ]]; then
|
||||||
|
log "[dry-run] backup ${src} -> ${dest}/${rel}"
|
||||||
|
else
|
||||||
|
cp -a "${src}" "${dest}/${rel}"
|
||||||
|
log "backup ${rel}"
|
||||||
|
fi
|
||||||
|
copied=$((copied + 1))
|
||||||
|
else
|
||||||
|
log "skip (missing): ${rel}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [[ "${copied}" -eq 0 ]]; then
|
||||||
|
echo "错误: 未备份到任何配置文件,请检查 ${src_root}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -f "${src_root}/scripts/one_shot_backup_config_before_cleanup.py" ]]; then
|
||||||
|
if [[ "${DRY_RUN}" -eq 1 ]]; then
|
||||||
|
log "[dry-run] python3 scripts/one_shot_backup_config_before_cleanup.py (in ${src_root})"
|
||||||
|
else
|
||||||
|
(cd "${src_root}" && python3 scripts/one_shot_backup_config_before_cleanup.py) || true
|
||||||
|
if compgen -G "${src_root}/backups/one-shot-*" >/dev/null; then
|
||||||
|
cp -a "${src_root}"/backups/one-shot-* "${dest}/" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [[ "${DRY_RUN}" -eq 0 ]]; then
|
||||||
|
{
|
||||||
|
echo "created_at=${STAMP}"
|
||||||
|
echo "install_root=${INSTALL_ROOT}"
|
||||||
|
echo "old_dir=${OLD_DIR}"
|
||||||
|
echo "git_url=${GIT_URL}"
|
||||||
|
echo "git_branch=${GIT_BRANCH}"
|
||||||
|
echo "script=${SCRIPT_SOURCE}"
|
||||||
|
} >"${dest}/reinstall.manifest"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_configs() {
|
||||||
|
local backup_dir="$1"
|
||||||
|
local dest_root="$2"
|
||||||
|
local rel
|
||||||
|
for rel in "${CONFIG_PATHS[@]}"; do
|
||||||
|
local src dest
|
||||||
|
src="${backup_dir}/${rel}"
|
||||||
|
dest="$(resolve_path "${dest_root}" "${rel}")"
|
||||||
|
if [[ -f "${src}" ]]; then
|
||||||
|
mkdir -p "$(dirname "${dest}")"
|
||||||
|
if [[ "${DRY_RUN}" -eq 1 ]]; then
|
||||||
|
log "[dry-run] restore ${src} -> ${dest}"
|
||||||
|
else
|
||||||
|
cp -a "${src}" "${dest}"
|
||||||
|
log "restore ${rel}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
local hub_settings
|
||||||
|
hub_settings="$(resolve_path "${dest_root}" "manual_trading_hub/hub_settings.json")"
|
||||||
|
if [[ -f "${hub_settings}" && "${DRY_RUN}" -eq 0 ]]; then
|
||||||
|
python3 "${dest_root}/deploy/sanitize_hub_settings.py" "${hub_settings}" || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
install_instance_backup_cron() {
|
||||||
|
local dest_root="$1"
|
||||||
|
local dir
|
||||||
|
for dir in crypto_monitor_binance crypto_monitor_gate crypto_monitor_okx; do
|
||||||
|
local proj="${dest_root}/${dir}"
|
||||||
|
local inst="${proj}/scripts/install_backup_cron.sh"
|
||||||
|
local data="${proj}/scripts/backup_data.sh"
|
||||||
|
if [[ -f "${inst}" && -f "${data}" ]]; then
|
||||||
|
chmod +x "${inst}" "${data}"
|
||||||
|
run bash "${inst}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_pm2() {
|
||||||
|
log "预期 PM2 进程(7 个): crypto_binance crypto_gate crypto_okx manual-trading-hub manual-agent-*"
|
||||||
|
if [[ "${DRY_RUN}" -eq 1 ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
pm2 list || true
|
||||||
|
if pm2 list 2>/dev/null | grep -qiE 'gate_bot|15203'; then
|
||||||
|
log "警告: PM2 列表仍含 gate_bot 相关进程,请 pm2 delete 后 pm2 save"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- 前置检查 ---
|
||||||
|
|
||||||
|
if [[ "$(id -u)" -ne 0 ]]; then
|
||||||
|
echo "请使用 root 执行(推荐路径 ${INSTALL_ROOT})" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "${REPO_ROOT}/deploy/setup_env.sh" ]]; then
|
||||||
|
echo "当前脚本不在有效仓库内: ${REPO_ROOT}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${REPO_ROOT}" != "${INSTALL_ROOT}" ]]; then
|
||||||
|
log "提示: 当前仓库 ${REPO_ROOT} 与 INSTALL_ROOT=${INSTALL_ROOT} 不一致;将备份当前仓库并克隆到 INSTALL_ROOT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
STAMP="$(TZ="${TZ_NAME}" date +%Y%m%d-%H%M%S)"
|
||||||
|
BACKUP_DIR="${BACKUP_ROOT}/pre-reinstall-${STAMP}"
|
||||||
|
OLD_DIR="${INSTALL_ROOT}.old.${STAMP}"
|
||||||
|
SRC_ROOT="${REPO_ROOT}"
|
||||||
|
|
||||||
|
if [[ -d "${INSTALL_ROOT}" && "${REPO_ROOT}" != "${INSTALL_ROOT}" ]]; then
|
||||||
|
SRC_ROOT="${INSTALL_ROOT}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "计划"
|
||||||
|
echo " 备份目录: ${BACKUP_DIR}"
|
||||||
|
echo " 配置来源: ${SRC_ROOT}"
|
||||||
|
echo " 旧目录移走: ${OLD_DIR}"
|
||||||
|
echo " 新克隆: ${GIT_URL} (${GIT_BRANCH}) -> ${INSTALL_ROOT}"
|
||||||
|
echo " 环境: deploy/setup_env.sh --skip-env-copy --recreate-venv --skip-pm2"
|
||||||
|
echo ""
|
||||||
|
echo " 将停止并 delete 全部 PM2 进程;不备份 crypto.db / hub data / 图片。"
|
||||||
|
|
||||||
|
if ! confirm "确认执行 Plan B 整目录重装?"; then
|
||||||
|
log "已取消"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 1. 备份 ---
|
||||||
|
|
||||||
|
step "备份配置到 ${BACKUP_DIR}"
|
||||||
|
backup_configs "${SRC_ROOT}" "${BACKUP_DIR}"
|
||||||
|
|
||||||
|
# --- 2. 停 PM2 ---
|
||||||
|
|
||||||
|
step "停止并清空 PM2"
|
||||||
|
if command -v pm2 >/dev/null 2>&1; then
|
||||||
|
run pm2 stop all || true
|
||||||
|
run pm2 delete all || true
|
||||||
|
else
|
||||||
|
log "未安装 pm2,跳过"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 3. 移走旧目录 ---
|
||||||
|
|
||||||
|
step "移走旧安装 ${INSTALL_ROOT} -> ${OLD_DIR}"
|
||||||
|
if [[ -d "${INSTALL_ROOT}" ]]; then
|
||||||
|
if [[ "${DRY_RUN}" -eq 1 ]]; then
|
||||||
|
log "[dry-run] mv ${INSTALL_ROOT} ${OLD_DIR}"
|
||||||
|
else
|
||||||
|
mv "${INSTALL_ROOT}" "${OLD_DIR}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "目标目录不存在,跳过 mv"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 4. 克隆 ---
|
||||||
|
|
||||||
|
step "git clone"
|
||||||
|
if [[ "${DRY_RUN}" -eq 1 ]]; then
|
||||||
|
log "[dry-run] git clone -b ${GIT_BRANCH} ${GIT_URL} ${INSTALL_ROOT}"
|
||||||
|
else
|
||||||
|
git clone -b "${GIT_BRANCH}" "${GIT_URL}" "${INSTALL_ROOT}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 5. setup_env(一键安装逻辑,不复制 .env)---
|
||||||
|
|
||||||
|
step "重建 Python 虚拟环境 (setup_env.sh)"
|
||||||
|
if [[ "${DRY_RUN}" -eq 1 ]]; then
|
||||||
|
log "[dry-run] bash ${INSTALL_ROOT}/deploy/setup_env.sh --skip-env-copy --recreate-venv --skip-pm2"
|
||||||
|
else
|
||||||
|
bash "${INSTALL_ROOT}/deploy/setup_env.sh" --skip-env-copy --recreate-venv --skip-pm2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 6. 恢复配置 ---
|
||||||
|
|
||||||
|
step "恢复 .env 与 hub_settings.json"
|
||||||
|
restore_configs "${BACKUP_DIR}" "${INSTALL_ROOT}"
|
||||||
|
|
||||||
|
# --- 7. PM2 启动 ---
|
||||||
|
|
||||||
|
step "PM2 启动全部进程"
|
||||||
|
if command -v pm2 >/dev/null 2>&1; then
|
||||||
|
run bash "${INSTALL_ROOT}/deploy/pm2_start_all.sh"
|
||||||
|
run pm2 save
|
||||||
|
else
|
||||||
|
log "未安装 pm2;请手动: bash ${INSTALL_ROOT}/deploy/pm2_start_all.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 8. 定时备份 cron(可选)---
|
||||||
|
|
||||||
|
if [[ "${INSTALL_BACKUP_CRON}" -eq 1 ]]; then
|
||||||
|
step "安装三所每日备份 cron"
|
||||||
|
install_instance_backup_cron "${INSTALL_ROOT}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 完成 ---
|
||||||
|
|
||||||
|
step "完成"
|
||||||
|
verify_pm2
|
||||||
|
echo ""
|
||||||
|
echo "备份: ${BACKUP_DIR}"
|
||||||
|
echo "旧目录(确认无误后可删): ${OLD_DIR}"
|
||||||
|
echo ""
|
||||||
|
echo "验收建议:"
|
||||||
|
echo " pm2 list"
|
||||||
|
echo " curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:5100/"
|
||||||
|
echo " 浏览器打开中控 /monitor,确认三所 LINK 正常"
|
||||||
|
echo ""
|
||||||
|
echo "回滚(未删旧目录时):"
|
||||||
|
echo " pm2 delete all"
|
||||||
|
echo " rm -rf ${INSTALL_ROOT}"
|
||||||
|
echo " mv ${OLD_DIR} ${INSTALL_ROOT}"
|
||||||
|
echo " cp -a ${BACKUP_DIR}/*/ ${INSTALL_ROOT}/ # 若需恢复配置"
|
||||||
|
echo " bash ${INSTALL_ROOT}/deploy/pm2_start_all.sh"
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""重装后清理 hub_settings.json 中已废弃的 gate_bot / 第四账户条目。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DROP_KEYS = frozenset({"gate_bot", "gate-bot"})
|
||||||
|
DROP_MARKERS = (
|
||||||
|
"gate_bot",
|
||||||
|
"crypto_monitor_gate_bot",
|
||||||
|
"15203",
|
||||||
|
":5002",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _text(*parts: object) -> str:
|
||||||
|
return " ".join(str(p) for p in parts if p is not None).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def should_drop(ex: dict) -> bool:
|
||||||
|
key = str(ex.get("key") or "").strip().lower()
|
||||||
|
if key in DROP_KEYS:
|
||||||
|
return True
|
||||||
|
blob = _text(
|
||||||
|
ex.get("name"),
|
||||||
|
ex.get("flask_url"),
|
||||||
|
ex.get("agent_url"),
|
||||||
|
ex.get("review_url"),
|
||||||
|
)
|
||||||
|
if any(m in blob for m in DROP_MARKERS):
|
||||||
|
return True
|
||||||
|
ex_id = str(ex.get("id") or "").strip()
|
||||||
|
if ex_id == "3" and key not in ("gate", ""):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_settings(data: dict) -> tuple[dict, list[str]]:
|
||||||
|
removed: list[str] = []
|
||||||
|
exchanges = data.get("exchanges")
|
||||||
|
if not isinstance(exchanges, list):
|
||||||
|
return data, removed
|
||||||
|
|
||||||
|
kept: list[dict] = []
|
||||||
|
seen_keys: set[str] = set()
|
||||||
|
for ex in exchanges:
|
||||||
|
if not isinstance(ex, dict):
|
||||||
|
continue
|
||||||
|
key = str(ex.get("key") or "").strip().lower()
|
||||||
|
label = f"id={ex.get('id')} key={key} name={ex.get('name')}"
|
||||||
|
if should_drop(ex):
|
||||||
|
removed.append(label)
|
||||||
|
continue
|
||||||
|
if key and key in seen_keys:
|
||||||
|
removed.append(f"duplicate {label}")
|
||||||
|
continue
|
||||||
|
if key:
|
||||||
|
seen_keys.add(key)
|
||||||
|
kept.append(ex)
|
||||||
|
|
||||||
|
out = dict(data)
|
||||||
|
out["exchanges"] = kept
|
||||||
|
return out, removed
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
args = argv if argv is not None else sys.argv[1:]
|
||||||
|
if len(args) != 1:
|
||||||
|
print("用法: python deploy/sanitize_hub_settings.py <hub_settings.json>", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
path = Path(args[0])
|
||||||
|
if not path.is_file():
|
||||||
|
print(f"文件不存在: {path}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
print(f"JSON 解析失败: {e}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
print("hub_settings.json 根节点必须是 object", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
cleaned, removed = sanitize_settings(data)
|
||||||
|
if removed:
|
||||||
|
path.write_text(json.dumps(cleaned, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
print("已移除条目:")
|
||||||
|
for line in removed:
|
||||||
|
print(f" - {line}")
|
||||||
|
else:
|
||||||
|
print("无需修改(未发现 gate_bot / 第四账户)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
#
|
#
|
||||||
# 用法:
|
# 用法:
|
||||||
# bash deploy/setup_env.sh
|
# 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 --skip-pm2
|
||||||
# bash deploy/setup_env.sh --recreate-venv
|
# bash deploy/setup_env.sh --recreate-venv
|
||||||
# bash deploy/setup_env.sh --install-system-deps # root + apt 时安装 python*-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 binance && setup_monitor crypto_monitor_binance
|
||||||
should_include gate && setup_monitor crypto_monitor_gate
|
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 okx && setup_monitor crypto_monitor_okx
|
||||||
should_include hub && setup_hub
|
should_include hub && setup_hub
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# 账户冷静期 / 日冻结风控
|
||||||
|
|
||||||
|
三所实例(币安 / OKX / Gate / Gate)共用 `account_risk_lib.py`。
|
||||||
|
**仅用户主动平仓**计入风控;交易所止盈/止损、空仓同步、改保本/改委托等**不触发**冷静期。
|
||||||
|
|
||||||
|
## 状态展示
|
||||||
|
|
||||||
|
实例页顶、中控监控卡片账户名旁显示风控徽章:
|
||||||
|
|
||||||
|
| 状态 | 含义 | 倒计时 |
|
||||||
|
|------|------|--------|
|
||||||
|
| 正常 | 可新开仓 | 无 |
|
||||||
|
| 1h冻结 | 冷静期中(通常为复盘后缩短的 1 小时) | 剩余时间,如 `1h冻结 · 52m 08s` |
|
||||||
|
| 4h冻结 | 冷静期中(默认 4 小时) | 剩余时间,如 `4h冻结 · 3h 12m` |
|
||||||
|
| 日冻结 | 当日禁止一切新开仓 | 至下一 **交易日切点**(`TRADING_DAY_RESET_HOUR`) |
|
||||||
|
|
||||||
|
- 倒计时每秒刷新;到期后徽章自动恢复为 **正常**(下次轮询/API 刷新会再次对齐服务端状态)。
|
||||||
|
- 鼠标悬停徽章可见完整说明(含解除时刻,如有)。
|
||||||
|
|
||||||
|
## 什么算「手动平仓」(计入风控)
|
||||||
|
|
||||||
|
以下操作通过 `close_source` 登记为 **用户主动平仓**:
|
||||||
|
|
||||||
|
| 来源标识 | 操作 |
|
||||||
|
|----------|------|
|
||||||
|
| `user_instance` | 实例页删单/手动平仓(`del_order`) |
|
||||||
|
| `user_hub` | 中控「平仓」「全平」「紧急全平」 |
|
||||||
|
| `user_trend_stop` | 趋势计划 **「结束计划」**(手动结束) |
|
||||||
|
|
||||||
|
**不算**手动平仓(不触发风控):
|
||||||
|
|
||||||
|
- 趋势 **「保本移交下单监控」**
|
||||||
|
- 中控/实例修改委托、挂止盈止损、移动保本
|
||||||
|
- 交易所止盈/止损/条件单成交
|
||||||
|
- 后台 `reconcile_external_closes` 空仓同步(即使记账为「外部平仓」)
|
||||||
|
- 监控轮询自动止盈/止损/保本
|
||||||
|
|
||||||
|
## 触发规则
|
||||||
|
|
||||||
|
| 事件 | 行为 |
|
||||||
|
|------|------|
|
||||||
|
| 第 1 次用户主动平仓 | 默认 **4h** 冷静期 |
|
||||||
|
| 第 2 次用户主动平仓(同一交易日) | **日冻结** |
|
||||||
|
| 复盘勾选任意情绪标签 | **日冻结** |
|
||||||
|
| 复盘:离场=手动平仓 且说明非空 | 将当前冷静期降为 **1h**(须处于 4h 档冷静期中) |
|
||||||
|
|
||||||
|
情绪标签:怕踏空、报复开仓、盈利飘了、拿不住单、扛单、重仓违规。
|
||||||
|
|
||||||
|
### 复盘缩短为 1h
|
||||||
|
|
||||||
|
任选一种方式,并填写说明:
|
||||||
|
|
||||||
|
| 方式 | 必填 |
|
||||||
|
|------|------|
|
||||||
|
| **复盘表单**提交 | 离场触发 = **手动平仓**;**离场补充** 非空(不是下方「备注」) |
|
||||||
|
| **核对修改**保存 | 结果 = **手动平仓**;**备注** 非空 |
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 中控全平 / 实例手动平仓后,只要在 4h 窗口内完成上述操作即可降为 1h。
|
||||||
|
- 复盘保存后会同步更新 `last_close_at_ms`,倒计时以 **最后一次手动平仓 + 当前档位数** 为准,不会继续读库内旧 4h 结束时间。
|
||||||
|
- 1h 窗口已结束后,即使库里残留旧 `cooloff_until_ms`,状态也会恢复 **正常**。
|
||||||
|
- 若超过「平仓 + 1h」才复盘,则从 **保存复盘时刻** 起再计 1h(不延长原 4h)。
|
||||||
|
- **止盈 / 保本止盈 / 止损** 等自动平仓不触发风控,也不会刷新冷静期。
|
||||||
|
- 代码更新后需 **重启对应实例** 并硬刷新页面。
|
||||||
|
|
||||||
|
### 倒计时与标签
|
||||||
|
|
||||||
|
- 结束时刻 = `last_close_at_ms + cooloff_hours`(`APP_TIMEZONE` 默认北京时间)
|
||||||
|
- 1h / 4h 标签按实际剩余时长判断,与倒计时一致
|
||||||
|
- 切交易日后,若冷静期已过期,自动清库内残留字段
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
```env
|
||||||
|
RISK_CONTROL_ENABLED=true
|
||||||
|
RISK_COOLING_HOURS_MANUAL=4
|
||||||
|
RISK_COOLING_HOURS_MANUAL_JOURNAL=1
|
||||||
|
RISK_MANUAL_CLOSE_DAILY_LIMIT=2
|
||||||
|
RISK_MOOD_ISSUES_DAILY_FREEZE=true
|
||||||
|
TRADING_DAY_RESET_HOUR=8
|
||||||
|
APP_TIMEZONE=Asia/Shanghai
|
||||||
|
```
|
||||||
|
|
||||||
|
`RISK_COOLING_HOURS_EXTERNAL` 已废弃(外部平仓不再触发风控)。
|
||||||
|
|
||||||
|
## API 与 `risk_status` 字段
|
||||||
|
|
||||||
|
| 接口 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `GET /api/account_snapshot` | 实例页轮询,含 `risk_status` |
|
||||||
|
| `GET /api/account_risk_status` | hub_bridge 专用 |
|
||||||
|
| `GET /api/hub/monitor` | 中控监控板,每账户含 `risk_status` |
|
||||||
|
| `POST /api/hub/account-risk/user-close` | 中控登记用户平仓,`body: { source, count }` |
|
||||||
|
|
||||||
|
`risk_status` 主要字段:
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `status` | `normal` / `freeze_1h` / `freeze_4h` / `freeze_daily` / `freeze_position` |
|
||||||
|
| `status_label` | 中文标签 |
|
||||||
|
| `can_trade` | 是否允许新开仓(仅风控维度) |
|
||||||
|
| `reason` | 悬停提示文案 |
|
||||||
|
| `active_count` / `max_active_positions` | 当前活跃持仓与 `.env` 中 `MAX_ACTIVE_POSITIONS` |
|
||||||
|
| `cooloff_until_ms` | 1h/4h 冷静期结束时间戳(毫秒) |
|
||||||
|
| `freeze_until_ms` | 倒计时结束时间戳(日冻结为下一交易日切点) |
|
||||||
|
| `freeze_remaining_sec` | 服务端计算的剩余秒数(供调试) |
|
||||||
|
|
||||||
|
**仓位上限冻结**:当 **计入上限的** 活跃持仓数(不含趋势回调)≥ 实例 `.env` 的 `MAX_ACTIVE_POSITIONS`(默认 1)且账户无时间类冻结时,徽章显示 **仓位上限冻结**;此时 **新开仓** 被禁止,但 **顺势加仓**(在已有同向监控持仓上加仓)仍可用。仅存在趋势回调持仓时不触发该冻结。时间冻结(1h/4h/日)优先展示。
|
||||||
|
|
||||||
|
`risk_status.can_roll`:仓位上限冻结时为 `true`,表示顺势加仓不受该冻结限制。
|
||||||
|
|
||||||
|
## 前端倒计时
|
||||||
|
|
||||||
|
- 共用脚本:`static/account_risk_badge.js?v=4`
|
||||||
|
- 样式:`static/account_risk_badge.css`
|
||||||
|
- 展示格式:`4h冻结 · 3h 12m`;日冻结为距下一交易日切点剩余时间
|
||||||
|
- 倒计时优先用服务端 `freeze_remaining_sec` 推算结束时刻,避免绝对时间戳与时区/脏数据偏差
|
||||||
|
- 服务端在冷静期**已结束**或锚点无效时**自动清库**,避免重启后误读旧 `account_risk_state` 仍显示冻结
|
||||||
|
- 无效的未来 `last_close_at_ms` **不会**被当作「现在」重启计时
|
||||||
|
- 若当日手动平仓**已复盘**(journal 有说明)且 1h 窗口已过,即使 risk 表被误写也会强制恢复 **正常**
|
||||||
|
- 勿与交易记录列表中的历史平仓时间混淆:风控只看 `account_risk_state` 表内 **最后一次用户主动平仓** 及其复盘结果
|
||||||
|
|
||||||
|
## 相关代码
|
||||||
|
|
||||||
|
- `account_risk_lib.py` — 状态机、`enrich_risk_status_countdown`、`apply_position_limit_risk`、`on_user_initiated_close`
|
||||||
|
- `hub_bridge.py` — `/api/hub/account-risk/user-close`
|
||||||
|
- `manual_trading_hub/hub.py` — 中控平仓成功后调用 user-close
|
||||||
|
- `strategy_trend_register.py` — `stop_trend_pullback` 结束计划时登记风控
|
||||||
|
- `tests/test_account_risk_lib.py`
|
||||||
@@ -1,45 +1,45 @@
|
|||||||
# 每日自动划转(四所统一)
|
# 每日自动划转(三所统一)
|
||||||
|
|
||||||
## 行为
|
## 行为
|
||||||
|
|
||||||
在 `.env` 开启 `AUTO_TRANSFER_ENABLED=true` 后,监控轮询在**北京时间 `AUTO_TRANSFER_BJ_HOUR` 整点所在小时**内(默认 8:00–8:59)执行一次(按 **UTC 自然日** 去重):
|
在 `.env` 开启 `AUTO_TRANSFER_ENABLED=true` 后,监控轮询在**北京时间 `AUTO_TRANSFER_BJ_HOUR` 整点所在小时**内(默认 8:00–8:59)执行一次(按 **UTC 自然日** 去重):
|
||||||
|
|
||||||
| 交易账户 (`AUTO_TRANSFER_TO`,默认 swap) | 动作 |
|
| 交易账户 (`AUTO_TRANSFER_TO`,默认 swap) | 动作 |
|
||||||
|------------------------------------------|------|
|
|------------------------------------------|------|
|
||||||
| 余额 **低于** `AUTO_TRANSFER_AMOUNT` | 从 `AUTO_TRANSFER_FROM`(默认 funding)划入差额 |
|
| 余额 **低于** `AUTO_TRANSFER_AMOUNT` | 从 `AUTO_TRANSFER_FROM`(默认 funding)划入差额 |
|
||||||
| 余额 **高于** `AUTO_TRANSFER_AMOUNT` | 将多余划回 `AUTO_TRANSFER_FROM` |
|
| 余额 **高于** `AUTO_TRANSFER_AMOUNT` | 将多余划回 `AUTO_TRANSFER_FROM` |
|
||||||
| 与目标相差 < 0.01U | 跳过,不写划转 |
|
| 与目标相差 < 0.01U | 跳过,不写划转 |
|
||||||
| 存在 **active** 持仓(`order_monitors`,或 Gate 趋势回调已开仓计划) | **不划转**,写账簿 `skipped`,并**企业微信**说明「持仓中,本次资金无划转」 |
|
| 存在 **active** 持仓(`order_monitors`,或 Gate回调已开仓计划) | **不划转**,写账簿 `skipped`,并**企业微信**说明「持仓中,本次资金无划转」 |
|
||||||
|
|
||||||
## 配置示例(目标 50U)
|
## 配置示例(目标 50U)
|
||||||
|
|
||||||
```env
|
```env
|
||||||
AUTO_TRANSFER_ENABLED=true
|
AUTO_TRANSFER_ENABLED=true
|
||||||
AUTO_TRANSFER_AMOUNT=50
|
AUTO_TRANSFER_AMOUNT=50
|
||||||
AUTO_TRANSFER_FROM=funding
|
AUTO_TRANSFER_FROM=funding
|
||||||
AUTO_TRANSFER_TO=swap
|
AUTO_TRANSFER_TO=swap
|
||||||
AUTO_TRANSFER_BJ_HOUR=8
|
AUTO_TRANSFER_BJ_HOUR=8
|
||||||
```
|
```
|
||||||
|
|
||||||
`AUTO_TRANSFER_AMOUNT` 与 `DAILY_START_CAPITAL`(每日开仓基数)**独立**。
|
`AUTO_TRANSFER_AMOUNT` 与 `DAILY_START_CAPITAL`(每日开仓基数)**独立**。
|
||||||
|
|
||||||
API Key 须具备万向划转权限(与手动划转相同)。
|
API Key 须具备万向划转权限(与手动划转相同)。
|
||||||
|
|
||||||
## 用脚本更新四所 `.env`
|
## 用脚本更新三所 `.env`
|
||||||
|
|
||||||
详见 **[env-sync-scripts.md](./env-sync-scripts.md)**。常用命令:
|
详见 **[env-sync-scripts.md](./env-sync-scripts.md)**。常用命令:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git pull
|
git pull
|
||||||
|
|
||||||
# 仅补全划转相关项
|
# 仅补全划转相关项
|
||||||
python scripts/sync_four_exchange_transfer_env.py
|
python scripts/sync_four_exchange_transfer_env.py
|
||||||
|
|
||||||
# 目标 50U 并开启自动划转
|
# 目标 50U 并开启自动划转
|
||||||
python scripts/sync_four_exchange_transfer_env.py --set-amount 50 --enable-auto-transfer
|
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
|
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
|
pm2 restart crypto-monitor-binance crypto-monitor-okx crypto-monitor-gate
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,81 +1,81 @@
|
|||||||
# 单日开仓次数限制(四所统一)
|
# 单日开仓次数限制(三所统一)
|
||||||
|
|
||||||
各交易实例(Binance / OKX / Gate / Gate_bot)在 `.env` 中独立配置,互不影响。
|
各交易实例(Binance / OKX / Gate)在 `.env` 中独立配置,互不影响。
|
||||||
|
|
||||||
## 交易日口径
|
## 交易日口径
|
||||||
|
|
||||||
- 以 **北京时间** `TRADING_DAY_RESET_HOUR`(默认 **8:00**)切分交易日,与统计、顶栏「交易日」一致。
|
- 以 **北京时间** `TRADING_DAY_RESET_HOUR`(默认 **8:00**)切分交易日,与统计、顶栏「交易日」一致。
|
||||||
- **次日恢复**:过了切日时刻后 `session_date` 变为新日期,计数自动归零,无需清库。
|
- **次日恢复**:过了切日时刻后 `session_date` 变为新日期,计数自动归零,无需清库。
|
||||||
|
|
||||||
## 计数口径
|
## 计数口径
|
||||||
|
|
||||||
每成功新建一条 `order_monitors` 记录计 **1 次**,包括:
|
每成功新建一条 `order_monitors` 记录计 **1 次**,包括:
|
||||||
|
|
||||||
- 人工「实盘下单」
|
- 人工「实盘下单」
|
||||||
- 关键位自动开仓
|
- 关键位自动开仓
|
||||||
- 其他写入 `order_monitors` 的成功开仓
|
- 其他写入 `order_monitors` 的成功开仓
|
||||||
|
|
||||||
平仓后再开仍算新的一单。当日总次数到硬上限后 **当天不再允许新开**(即使已空仓)。
|
平仓后再开仍算新的一单。当日总次数到硬上限后 **当天不再允许新开**(即使已空仓)。
|
||||||
|
|
||||||
## 环境变量
|
## 环境变量
|
||||||
|
|
||||||
在「交易执行 / 人工风控」段配置:
|
在「交易执行 / 人工风控」段配置:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
# 【单日开仓 AI 提醒】本交易日开仓次数达到该值时,企业微信推送 AI 克制提醒(不拦单)
|
# 【单日开仓 AI 提醒】本交易日开仓次数达到该值时,企业微信推送 AI 克制提醒(不拦单)
|
||||||
DAILY_OPEN_ALERT_THRESHOLD=5
|
DAILY_OPEN_ALERT_THRESHOLD=5
|
||||||
|
|
||||||
# 【单日开仓硬上限】本交易日开仓次数 >= 该值后,禁止一切新开仓直至下一交易日;0=不启用
|
# 【单日开仓硬上限】本交易日开仓次数 >= 该值后,禁止一切新开仓直至下一交易日;0=不启用
|
||||||
DAILY_OPEN_HARD_LIMIT=0
|
DAILY_OPEN_HARD_LIMIT=0
|
||||||
```
|
```
|
||||||
|
|
||||||
### 配置示例
|
### 配置示例
|
||||||
|
|
||||||
```env
|
```env
|
||||||
# 保守户:3 次提醒,5 次封死
|
# 保守户:3 次提醒,5 次封死
|
||||||
DAILY_OPEN_ALERT_THRESHOLD=3
|
DAILY_OPEN_ALERT_THRESHOLD=3
|
||||||
DAILY_OPEN_HARD_LIMIT=5
|
DAILY_OPEN_HARD_LIMIT=5
|
||||||
|
|
||||||
# 仅提醒、不封(与旧版行为接近)
|
# 仅提醒、不封(与旧版行为接近)
|
||||||
DAILY_OPEN_ALERT_THRESHOLD=5
|
DAILY_OPEN_ALERT_THRESHOLD=5
|
||||||
DAILY_OPEN_HARD_LIMIT=0
|
DAILY_OPEN_HARD_LIMIT=0
|
||||||
|
|
||||||
# 严格户:到 3 次即封
|
# 严格户:到 3 次即封
|
||||||
DAILY_OPEN_ALERT_THRESHOLD=2
|
DAILY_OPEN_ALERT_THRESHOLD=2
|
||||||
DAILY_OPEN_HARD_LIMIT=3
|
DAILY_OPEN_HARD_LIMIT=3
|
||||||
```
|
```
|
||||||
|
|
||||||
建议 `DAILY_OPEN_ALERT_THRESHOLD <= DAILY_OPEN_HARD_LIMIT`(硬上限为 0 时除外)。
|
建议 `DAILY_OPEN_ALERT_THRESHOLD <= DAILY_OPEN_HARD_LIMIT`(硬上限为 0 时除外)。
|
||||||
|
|
||||||
## 程序行为
|
## 程序行为
|
||||||
|
|
||||||
| 次数 | 行为 |
|
| 次数 | 行为 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 未达提醒阈值 | 正常开仓 |
|
| 未达提醒阈值 | 正常开仓 |
|
||||||
| 达到 `DAILY_OPEN_ALERT_THRESHOLD` | 成功开仓后 AI 企业微信提醒 |
|
| 达到 `DAILY_OPEN_ALERT_THRESHOLD` | 成功开仓后 AI 企业微信提醒 |
|
||||||
| 达到 `DAILY_OPEN_HARD_LIMIT`(>0) | `precheck_risk` 拒绝人工/关键位开仓;顶栏 `can_trade=false` |
|
| 达到 `DAILY_OPEN_HARD_LIMIT`(>0) | `precheck_risk` 拒绝人工/关键位开仓;顶栏 `can_trade=false` |
|
||||||
|
|
||||||
硬限制与以下规则 **同时生效**(取交集):
|
硬限制与以下规则 **同时生效**(取交集):
|
||||||
|
|
||||||
- `TRADING_DAY_RESET_OPEN_GUARD_ENABLED`:切日前禁止新开
|
- `TRADING_DAY_RESET_OPEN_GUARD_ENABLED`:切日前禁止新开
|
||||||
- `MAX_ACTIVE_POSITIONS`:同时持仓上限
|
- `MAX_ACTIVE_POSITIONS`:同时持仓上限
|
||||||
- Gate_bot:`precheck_trend_pullback_start` 同样校验单日硬上限
|
- Gate:`precheck_trend_pullback_start` 同样校验单日硬上限
|
||||||
|
|
||||||
## 页面与接口
|
## 页面与接口
|
||||||
|
|
||||||
- 顶栏 / `api/account_snapshot` 返回 `opens_today`、`daily_open_hard_limit`、`daily_open_alert_threshold`。
|
- 顶栏 / `api/account_snapshot` 返回 `opens_today`、`daily_open_hard_limit`、`daily_open_alert_threshold`。
|
||||||
- 达硬上限时提示:`本交易日开仓 N/M 已达上限,次日 8:00 后恢复`(`M` 为配置的硬上限)。
|
- 达硬上限时提示:`本交易日开仓 N/M 已达上限,次日 8:00 后恢复`(`M` 为配置的硬上限)。
|
||||||
|
|
||||||
## 部署
|
## 部署
|
||||||
|
|
||||||
修改各实例 `.env` 后重启对应 pm2 进程,例如:
|
修改各实例 `.env` 后重启对应 pm2 进程,例如:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pm2 restart crypto_binance crypto_okx crypto_gate crypto_gate_bot
|
pm2 restart crypto_binance crypto_okx crypto_gate
|
||||||
```
|
```
|
||||||
|
|
||||||
## 实现位置
|
## 实现位置
|
||||||
|
|
||||||
- 共享逻辑:`daily_open_limit_lib.py`
|
- 共享逻辑:`daily_open_limit_lib.py`
|
||||||
- 四所 `app.py`:`precheck_risk`、`can_trade`、`api/account_snapshot`、开仓成功后的 AI 提醒文案
|
- 三所 `app.py`:`precheck_risk`、`can_trade`、`api/account_snapshot`、开仓成功后的 AI 提醒文案
|
||||||
- 单元测试:`tests/test_daily_open_limit_lib.py`
|
- 单元测试:`tests/test_daily_open_limit_lib.py`
|
||||||
|
|||||||
@@ -1,116 +1,116 @@
|
|||||||
# 四所 `.env` 同步脚本说明
|
# 三所 `.env` 同步脚本说明
|
||||||
|
|
||||||
在**仓库根目录**执行。仅处理四所实例目录下的 `.env`,**不覆盖** API 密钥与已存在的自定义值;若某目录无 `.env` 会 `SKIP`(需先 `cp .env.example .env`)。
|
在**仓库根目录**执行。仅处理三所实例目录下的 `.env`,**不覆盖** API 密钥与已存在的自定义值;若某目录无 `.env` 会 `SKIP`(需先 `cp .env.example .env`)。
|
||||||
|
|
||||||
| 目录 |
|
| 目录 |
|
||||||
|------|
|
|------|
|
||||||
| `crypto_monitor_binance` |
|
| `crypto_monitor_binance` |
|
||||||
| `crypto_monitor_okx` |
|
| `crypto_monitor_okx` |
|
||||||
| `crypto_monitor_gate` |
|
| `crypto_monitor_gate` |
|
||||||
| `crypto_monitor_gate_bot` |
|
| `crypto_monitor_gate` |
|
||||||
|
|
||||||
修改 `.env` 后须 **`pm2 restart`** 对应实例后生效。
|
修改 `.env` 后须 **`pm2 restart`** 对应实例后生效。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 一键同步(推荐)
|
## 一键同步(推荐)
|
||||||
|
|
||||||
`scripts/sync_four_exchange_env.py`:依次执行**计仓** + **自动划转** 两个子脚本。
|
`scripts/sync_four_exchange_env.py`:依次执行**计仓** + **自动划转** 两个子脚本。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /path/to/crypto_monitor
|
cd /path/to/crypto_monitor
|
||||||
git pull
|
git pull
|
||||||
|
|
||||||
# 仅补全缺失项(已有值保留)
|
# 仅补全缺失项(已有值保留)
|
||||||
python scripts/sync_four_exchange_env.py
|
python scripts/sync_four_exchange_env.py
|
||||||
|
|
||||||
# 预览,不写文件
|
# 预览,不写文件
|
||||||
python scripts/sync_four_exchange_env.py --dry-run
|
python scripts/sync_four_exchange_env.py --dry-run
|
||||||
|
|
||||||
# 划转目标 50U 并开启自动划转(计仓仍只补缺失项)
|
# 划转目标 50U 并开启自动划转(计仓仍只补缺失项)
|
||||||
python scripts/sync_four_exchange_env.py --set-transfer-amount 50 --enable-auto-transfer
|
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
|
python scripts/sync_four_exchange_env.py --set-mode full_margin
|
||||||
```
|
```
|
||||||
|
|
||||||
| 参数 | 说明 |
|
| 参数 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `--dry-run` | 只打印将做的变更,不写 `.env` |
|
| `--dry-run` | 只打印将做的变更,不写 `.env` |
|
||||||
| `--set-mode risk\|full_margin` | 强制四所 `POSITION_SIZING_MODE` |
|
| `--set-mode risk\|full_margin` | 强制三所 `POSITION_SIZING_MODE` |
|
||||||
| `--set-transfer-amount U` | 强制四所 `AUTO_TRANSFER_AMOUNT` |
|
| `--set-transfer-amount U` | 强制三所 `AUTO_TRANSFER_AMOUNT` |
|
||||||
| `--enable-auto-transfer` | 强制四所 `AUTO_TRANSFER_ENABLED=true` |
|
| `--enable-auto-transfer` | 强制三所 `AUTO_TRANSFER_ENABLED=true` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 仅自动划转
|
## 仅自动划转
|
||||||
|
|
||||||
`scripts/sync_four_exchange_transfer_env.py`
|
`scripts/sync_four_exchange_transfer_env.py`
|
||||||
|
|
||||||
行为说明见 [auto-transfer-daily.md](./auto-transfer-daily.md)。
|
行为说明见 [auto-transfer-daily.md](./auto-transfer-daily.md)。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 补全缺失项
|
# 补全缺失项
|
||||||
python scripts/sync_four_exchange_transfer_env.py
|
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 --dry-run
|
||||||
|
|
||||||
# 目标 50U 并开启
|
# 目标 50U 并开启
|
||||||
python scripts/sync_four_exchange_transfer_env.py --set-amount 50 --enable-auto-transfer
|
python scripts/sync_four_exchange_transfer_env.py --set-amount 50 --enable-auto-transfer
|
||||||
```
|
```
|
||||||
|
|
||||||
| 参数 | 说明 |
|
| 参数 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `--dry-run` | 预览 |
|
| `--dry-run` | 预览 |
|
||||||
| `--set-amount U` | 强制 `AUTO_TRANSFER_AMOUNT` |
|
| `--set-amount U` | 强制 `AUTO_TRANSFER_AMOUNT` |
|
||||||
| `--enable-auto-transfer` | 强制 `AUTO_TRANSFER_ENABLED=true` |
|
| `--enable-auto-transfer` | 强制 `AUTO_TRANSFER_ENABLED=true` |
|
||||||
|
|
||||||
**缺项默认**(未使用 `--set-amount` 且文件中无该键时):
|
**缺项默认**(未使用 `--set-amount` 且文件中无该键时):
|
||||||
|
|
||||||
1. 若已有 `AUTO_TRANSFER_AMOUNT` → 保留
|
1. 若已有 `AUTO_TRANSFER_AMOUNT` → 保留
|
||||||
2. 否则若存在 `DAILY_START_CAPITAL` → 沿用其值
|
2. 否则若存在 `DAILY_START_CAPITAL` → 沿用其值
|
||||||
3. 否则 → **50**
|
3. 否则 → **50**
|
||||||
|
|
||||||
补全时会写入(若缺失):`AUTO_TRANSFER_FROM=funding`、`AUTO_TRANSFER_TO=swap`、`TRANSFER_CCY=USDT`、`AUTO_TRANSFER_BJ_HOUR=8`;币安额外补 `BINANCE_FUNDING_INCLUDE_SPOT=false`。
|
补全时会写入(若缺失):`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`
|
`scripts/sync_four_exchange_position_sizing_env.py`
|
||||||
|
|
||||||
行为说明见 [position-sizing-mode.md](./position-sizing-mode.md)。
|
行为说明见 [position-sizing-mode.md](./position-sizing-mode.md)。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 补全缺失项(默认 risk、FULL_MARGIN_BUFFER_RATIO=0.98)
|
# 补全缺失项(默认 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
|
||||||
python scripts/sync_four_exchange_position_sizing_env.py --dry-run
|
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 full_margin
|
||||||
|
|
||||||
# 无仓后切回以损定仓
|
# 无仓后切回以损定仓
|
||||||
python scripts/sync_four_exchange_position_sizing_env.py --set-mode risk
|
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
|
python scripts/sync_four_exchange_position_sizing_env.py --set-buffer 0.98
|
||||||
```
|
```
|
||||||
|
|
||||||
| 参数 | 说明 |
|
| 参数 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `--dry-run` | 预览 |
|
| `--dry-run` | 预览 |
|
||||||
| `--set-mode risk\|full_margin` | 强制 `POSITION_SIZING_MODE`(**须无持仓**后 restart) |
|
| `--set-mode risk\|full_margin` | 强制 `POSITION_SIZING_MODE`(**须无持仓**后 restart) |
|
||||||
| `--set-buffer RATIO` | 强制 `FULL_MARGIN_BUFFER_RATIO` |
|
| `--set-buffer RATIO` | 强制 `FULL_MARGIN_BUFFER_RATIO` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 部署后重启
|
## 部署后重启
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pm2 restart crypto-monitor-binance crypto-monitor-okx crypto-monitor-gate crypto-monitor-gate-bot
|
pm2 restart crypto-monitor-binance crypto-monitor-okx crypto-monitor-gate
|
||||||
```
|
```
|
||||||
|
|
||||||
## 相关文档
|
## 相关文档
|
||||||
|
|
||||||
- [计仓模式](./position-sizing-mode.md)
|
- [计仓模式](./position-sizing-mode.md)
|
||||||
- [每日自动划转](./auto-transfer-daily.md)
|
- [每日自动划转](./auto-transfer-daily.md)
|
||||||
- [部署说明](../deploy/README.md)
|
- [部署说明](../deploy/README.md)
|
||||||
|
|||||||
@@ -1,132 +1,135 @@
|
|||||||
# 内照明心与永久 K 线
|
# 内照明心与永久 K 线
|
||||||
|
|
||||||
## 概述
|
## 概述
|
||||||
|
|
||||||
「内照明心」页(`/archive`)用于 **复盘语录 + 交易记录回顾 + 按需 K 线**。左侧维护每日复盘语录(最多 100 条);右侧按日期区间列出开仓记录,展示区间统计,并可展开 K 线图表对照单笔交易。
|
「内照明心」页(`/archive`)用于 **复盘语录 + 交易记录回顾 + 按需 K 线**。左侧维护每日复盘语录(最多 100 条);右侧按日期区间列出开仓记录,展示区间统计,并可展开 K 线图表对照单笔交易。
|
||||||
|
|
||||||
与行情区 `hub_kline.db`(15 天滚动缓存)**完全独立**:档案库只增不删,从建档起永久保留。
|
与行情区 `hub_kline.db`(15 天滚动缓存)**完全独立**:档案库只增不删,从建档起永久保留。
|
||||||
|
|
||||||
## 页面布局
|
## 页面布局
|
||||||
|
|
||||||
| 区域 | 说明 |
|
| 区域 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| **复盘语录** | 左栏;按日期添加/编辑/删除,一日一条 |
|
| **复盘语录** | 左栏;按日期添加/编辑/删除,一日一条 |
|
||||||
| **日期与筛选** | 顶栏:本日 / 本周 / 本月 / 自选区间;盈利单、亏损单、犯病、交易所、搜索 |
|
| **日期与筛选** | 顶栏:本日 / 本周 / 本月 / 自选区间;盈利单、亏损单、犯病、交易所、搜索 |
|
||||||
| **区间统计** | 统计栏随日期选择自动更新(见下) |
|
| **区间统计** | 统计栏随日期选择自动更新(见下) |
|
||||||
| **K 线图表** | 默认折叠;点「图表」或展开后按需加载 |
|
| **K 线图表** | 默认折叠;点「图表」或展开后按需加载 |
|
||||||
| **交易记录** | 默认展开;犯病行 **红色字体**(无红底);可编辑标签与备注 |
|
| **交易记录** | 默认展开;犯病行 **红色字体**(无红底);可编辑标签与备注 |
|
||||||
|
|
||||||
## 日期区间
|
## 日期区间
|
||||||
|
|
||||||
交易日按北京时间 **8:00** 切日(`TRADING_DAY_RESET_HOUR`)。
|
交易日按北京时间 **8:00** 切日(`TRADING_DAY_RESET_HOUR`)。
|
||||||
|
|
||||||
| 模式 | 范围 |
|
| 模式 | 范围 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| **本日** | 可选单个交易日(默认当前交易日) |
|
| **本日** | 可选单个交易日(默认当前交易日) |
|
||||||
| **本周** | 当周周一至当前交易日 |
|
| **本周** | 当周周一至当前交易日 |
|
||||||
| **本月** | 当月 1 日至当前交易日 |
|
| **本月** | 当月 1 日至当前交易日 |
|
||||||
| **区间** | 自选 `date_from`~`date_to`(含首尾交易日) |
|
| **区间** | 自选 `date_from`~`date_to`(含首尾交易日) |
|
||||||
|
|
||||||
## 区间统计(统计栏)
|
## 区间统计(统计栏)
|
||||||
|
|
||||||
基于所选日期区间内 **全部开仓**(不受盈利/亏损/犯病勾选与搜索影响;交易所筛选仍生效):
|
基于当前 **列表筛选结果**(含盈利/亏损/犯病勾选、合约搜索;交易所下拉仍限定数据源):
|
||||||
|
|
||||||
| 指标 | 说明 |
|
| 指标 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 总开仓次数 | 区间内开仓笔数 |
|
| 总开仓次数 | 区间内开仓笔数 |
|
||||||
| 犯病次数 / 占比 | `behavior_tag = sick` 的笔数及占开仓比例 |
|
| 盈利单 / 亏损单 | 盈亏 > 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 |
|
| 交易来源 | 三所 `trade_records` + 未落库的 `strategy_trade_snapshots`,经 `/api/hub/trades/archive` 拉取 |
|
||||||
| 增量同步 | 默认每 **4 小时** 补新 5m 至当前 |
|
| 犯病标签 | 中控 `trade_overlay.behavior_tag = sick` |
|
||||||
| 展示周期 | Tab:**5m / 15m / 1h / 4h**,默认 **15m** |
|
| K 线真源 | 仅 **5m** 写入 `hub_symbol_archive.db` |
|
||||||
| 视窗模式 | **持仓过程**(锚平仓,默认)/ **进场决策**(锚开仓) |
|
| 建档种子 | 该币 **最早开仓** 向前 **30 天** 5m |
|
||||||
| 时间跳转 | 输入 `YYYY-MM-DD HH:MM` 后点「跳转」 |
|
| 增量同步 | 默认每 **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` — 建档元数据
|
- 默认路径:`manual_trading_hub/data/hub_symbol_archive.db`
|
||||||
- `archive_bars_5m` — 永久 5m K 线
|
- 环境变量:`HUB_ARCHIVE_DB_PATH`
|
||||||
- `archive_trade_cache` — 从实例同步的交易快照
|
- 表:
|
||||||
- `trade_overlay` — 犯病标签与备注(仅中控)
|
- `archive_meta` — 建档元数据
|
||||||
- `archive_review_quotes` — 复盘语录
|
- `archive_bars_5m` — 永久 5m K 线
|
||||||
|
- `archive_trade_cache` — 从实例同步的交易快照
|
||||||
## API(中控 FastAPI)
|
- `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` | 新增语录 |
|
| GET | `/api/archive/meta` | 周期、交易所、同步间隔等 |
|
||||||
| PATCH | `/api/archive/quotes/{id}` | 更新语录 |
|
| GET | `/api/archive/daily-trades` | 区间交易列表与统计(见 query) |
|
||||||
| DELETE | `/api/archive/quotes/{id}` | 删除语录 |
|
| GET | `/api/archive/quotes` | 复盘语录列表 |
|
||||||
| GET | `/api/archive/ohlcv` | K 线视窗(`timeframe` / `mode` / `anchor_ms` / `at`) |
|
| POST | `/api/archive/quotes` | 新增语录 |
|
||||||
| PATCH | `/api/archive/trade/{exchange_key}/{trade_id}` | 更新标签/备注 |
|
| PATCH | `/api/archive/quotes/{id}` | 更新语录 |
|
||||||
| POST | `/api/archive/sync` | 立即同步四所交易 + K 线 |
|
| DELETE | `/api/archive/quotes/{id}` | 删除语录 |
|
||||||
|
| GET | `/api/archive/ohlcv` | K 线视窗(`timeframe` / `mode` / `anchor_ms` / `at`) |
|
||||||
`GET /api/archive/daily-trades` 主要 query:
|
| 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` | 可选,按交易所筛选 |
|
| `period` | `today` / `week` / `month` / `range` |
|
||||||
| `filter_profit` / `filter_loss` / `filter_sick` | 仅过滤表格列表 |
|
| `trading_day` | 本日模式下的交易日 `YYYY-MM-DD` |
|
||||||
| `search` | 合约 / 交易所 / 备注搜索(仅列表) |
|
| `date_from` / `date_to` | 区间模式起止日 |
|
||||||
|
| `exchange_key` | 可选,按交易所筛选 |
|
||||||
返回 `stats` 含 `open_count`、`sick_count`、`sick_pct`、`pnl_total`、`pnl_ex_sick`、`by_exchange`。
|
| `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`) |
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
## 后台任务
|
|------|------|------|
|
||||||
|
| GET | `/api/hub/trades/archive` | 近 N 天已平仓(`days` / `limit`) |
|
||||||
Hub 启动后在 lifespan 中运行 `hub-archive-sync`:
|
|
||||||
|
## 后台任务
|
||||||
1. 对各启用交易所调用 `/api/hub/trades/archive`
|
|
||||||
2. 写入 `archive_trade_cache`
|
Hub 启动后在 lifespan 中运行 `hub-archive-sync`:
|
||||||
3. 未建档币种:拉 30 天 5m 种子
|
|
||||||
4. 已建档币种:增量补 5m
|
1. 对各启用交易所调用 `/api/hub/trades/archive`
|
||||||
|
2. 写入 `archive_trade_cache`
|
||||||
间隔:`HUB_ARCHIVE_SYNC_INTERVAL_SEC`(默认 14400)。
|
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` — 路由与后台同步
|
- `hub_symbol_archive_lib.py` — 库表、区间统计、种子、增量、聚合
|
||||||
- `manual_trading_hub/static/archive.js` — 内照明心前端
|
- `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,高周期聚合 |
|
|--|--------|----------|
|
||||||
| 用途 | 实时看盘 | 复盘语录与交易回顾 |
|
| DB | `hub_kline.db` | `hub_symbol_archive.db` |
|
||||||
|
| 保留 | 15 天滚动删除 | 建档起永久 |
|
||||||
## 相关文档
|
| 周期 | 多周期直存/拉取 | 仅存 5m,高周期聚合 |
|
||||||
|
| 用途 | 实时看盘 | 复盘语录与交易回顾 |
|
||||||
- [中控平仓与交易记录](trend-hub-close-and-trade-records.md)
|
|
||||||
- [中控使用说明](../manual_trading_hub/使用说明.md)
|
## 相关文档
|
||||||
|
|
||||||
|
- [中控平仓与交易记录](trend-hub-close-and-trade-records.md)
|
||||||
|
- [中控使用说明](../manual_trading_hub/使用说明.md)
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# lib/ 共用模块结构
|
||||||
|
|
||||||
|
三所实例与中控共用的 Python 库、模板与静态资源统一放在仓库根目录的 **`lib/`** 下。部署单元(`crypto_monitor_*`、`manual_trading_hub`)仍保持独立目录与 PM2 配置不变。
|
||||||
|
|
||||||
|
**重构前快照 Git 标签**:`pre-lib-modularization`(可用 `git checkout pre-lib-modularization` 查看旧布局)。
|
||||||
|
**移除 gate_bot 前快照 Git 标签**:`pre-remove-gate-bot`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 顶层目录
|
||||||
|
|
||||||
|
```
|
||||||
|
crypto_monitor/
|
||||||
|
├── crypto_monitor_binance/ # 三所:各自 app + .env + PM2
|
||||||
|
├── crypto_monitor_gate/
|
||||||
|
├── crypto_monitor_okx/
|
||||||
|
├── manual_trading_hub/ # 中控 + 子代理 agent
|
||||||
|
│
|
||||||
|
├── lib/ # 共用模块(本说明)
|
||||||
|
│ ├── strategy/
|
||||||
|
│ ├── key_monitor/
|
||||||
|
│ ├── trade/
|
||||||
|
│ ├── hub/
|
||||||
|
│ ├── ai/
|
||||||
|
│ ├── instance/
|
||||||
|
│ ├── exchange/
|
||||||
|
│ ├── common/
|
||||||
|
│ └── paths.py
|
||||||
|
│
|
||||||
|
├── brand/ # 各所共用图标
|
||||||
|
├── docs/
|
||||||
|
├── deploy/
|
||||||
|
├── scripts/
|
||||||
|
├── tests/
|
||||||
|
├── requirements.txt
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## lib/ 子包说明
|
||||||
|
|
||||||
|
| 子包 | 职责 | 主要模块 |
|
||||||
|
|------|------|----------|
|
||||||
|
| **`lib/strategy/`** | 策略交易(顺势加仓、趋势回调、快照与记录) | `strategy_register.py`、`strategy_trend_register.py`、`strategy_db.py`、`strategy_roll_*`、`strategy_trend_*` |
|
||||||
|
| **`lib/strategy/templates/`** | 策略页 Jinja 模板(原 `strategy_templates/`) | `strategy_trading_page.html`、`strategy_roll_panel.html` 等 |
|
||||||
|
| **`lib/key_monitor/`** | 关键位监控、斐波、假突破、止盈止损方案 | `key_monitor_lib.py`、`fib_key_monitor_lib.py`、`key_sl_tp_lib.py` 等 |
|
||||||
|
| **`lib/trade/`** | 下单监控展示、计仓、账户风控、手动 SL/TP | `order_monitor_display_lib.py`、`position_sizing_lib.py`、`account_risk_lib.py` 等 |
|
||||||
|
| **`lib/hub/`** | 中控 API、K 线、归档、计仓器、SSO/Bridge | `hub_bridge.py`、`hub_kline_store.py`、`hub_trades_lib.py` 等 |
|
||||||
|
| **`lib/ai/`** | AI 复盘与文本生成 | `ai_client.py`、`ai_review_lib.py` |
|
||||||
|
| **`lib/instance/`** | 中控 iframe 嵌入、导航、复盘图表 | `instance_embed_lib.py`、`focus_chart_lib.py`、`journal_chart_lib.py` |
|
||||||
|
| **`lib/instance/templates/`** | 嵌入页片段(原 `embed_templates/`) | `embed_page_fragment.html` |
|
||||||
|
| **`lib/exchange/`** | 特定交易所工具 | `gate_transfer_lib.py`、`okx_orders_lib.py` 等 |
|
||||||
|
| **`lib/common/`** | 跨功能小工具 | `form_submit_lib.py`、`wechat_notify_lib.py` 等 |
|
||||||
|
| **`lib/common/static/`** | 三所与中控共用的 JS/CSS(原根目录 `static/`) | `instance_theme.js`、`strategy_roll.js` 等 |
|
||||||
|
|
||||||
|
> **说明**:`hub_*` 命名表示「中控侧能力或行情聚合」,但部分模块(如 `hub_volume_rank_lib`、`hub_market_info_lib`)三所 `app.py` 也会调用,并非中控独占。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 路径辅助函数
|
||||||
|
|
||||||
|
`lib/paths.py` 集中维护资源目录,避免硬编码:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from lib.paths import strategy_templates_dir, embed_templates_dir, common_static_dir
|
||||||
|
|
||||||
|
strategy_templates_dir() # .../lib/strategy/templates
|
||||||
|
embed_templates_dir() # .../lib/instance/templates
|
||||||
|
common_static_dir() # .../lib/common/static
|
||||||
|
```
|
||||||
|
|
||||||
|
可选传入 `repo_root`(字符串或 `Path`),默认使用 `lib/` 的上级目录即仓库根。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Python 导入约定
|
||||||
|
|
||||||
|
各部署目录在启动时将 **仓库根** 加入 `sys.path`(与重构前相同):
|
||||||
|
|
||||||
|
```python
|
||||||
|
_REPO_ROOT = os.path.dirname(BASE_DIR) # 或 Path(__file__).resolve().parent.parent
|
||||||
|
if _REPO_ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, _REPO_ROOT)
|
||||||
|
```
|
||||||
|
|
||||||
|
之后使用 **`lib.<子包>.<模块>`** 形式导入,例如:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from lib.strategy.strategy_db import init_strategy_tables
|
||||||
|
from lib.key_monitor.key_monitor_lib import check_key_monitors
|
||||||
|
from lib.hub.hub_bridge import install_on_app
|
||||||
|
from lib.ai.ai_client import ai_review
|
||||||
|
```
|
||||||
|
|
||||||
|
策略注册仍在各所 `app.py` 末尾:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from lib.strategy.strategy_register import install_strategy_trading
|
||||||
|
from lib.strategy.strategy_trend_register import install_strategy_trend
|
||||||
|
|
||||||
|
install_strategy_trading(app, _REPO_ROOT, app_module=sys.modules[__name__])
|
||||||
|
install_strategy_trend(app, _REPO_ROOT, app_module=sys.modules[__name__])
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 静态资源与 URL
|
||||||
|
|
||||||
|
- 三所页面仍通过 **`/static/...`** 访问共用脚本;`hub_bridge.install_instance_theme_static` 从 `lib/common/static/` 提供部分根级静态路由。
|
||||||
|
- 各所目录下 **`static/`**(图标、上传图片等)仍为实例私有,未迁入 `lib/`。
|
||||||
|
- 中控 `manual_trading_hub/hub.py` 通过 `_REPO_ROOT / "lib" / "common" / "static"` 挂载与三所共用的 badge、复盘 JS 等。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
在仓库根执行(需将根目录置于 Python 路径,或从根目录运行):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/crypto_monitor
|
||||||
|
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)
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# 宏观关键数据 · 风控前置
|
||||||
|
|
||||||
|
中控 **系统设置** 手动录入 FOMC / CPI / 就业数据发布时间,在 **监控区** 发布前后各 1 小时给出风险提示。
|
||||||
|
**不看公布结果、不解读数据**,仅作波动窗口前的行为提醒;**不拦截下单**(与账户冷静期/日冻结独立)。
|
||||||
|
|
||||||
|
## 支持的数据类型
|
||||||
|
|
||||||
|
| 类型 ID | 显示名称 |
|
||||||
|
|---------|----------|
|
||||||
|
| `fomc` | FOMC 联邦基金利率 |
|
||||||
|
| `cpi` | 美国 CPI 通胀 |
|
||||||
|
| `employment` | 就业与劳工数据 |
|
||||||
|
|
||||||
|
每项在设置中 **名称下拉三选一**,**发布时间** 手动输入(北京时间,精确到分钟)。FOMC 只录 **一条**(决议公布时刻即可)。
|
||||||
|
|
||||||
|
## 风险窗口
|
||||||
|
|
||||||
|
- 默认:**发布时间 ±1 小时**
|
||||||
|
- 发布前 **30 分钟内**:文案加强为「即将发布」
|
||||||
|
- 窗口结束后横幅自动消失;设置列表中过期记录逐步不再展示
|
||||||
|
|
||||||
|
环境变量(可选):
|
||||||
|
|
||||||
|
```env
|
||||||
|
HUB_MACRO_WINDOW_BEFORE_SEC=3600
|
||||||
|
HUB_MACRO_WINDOW_AFTER_SEC=3600
|
||||||
|
HUB_MACRO_IMMINENT_BEFORE_SEC=1800
|
||||||
|
HUB_MACRO_LIST_FUTURE_DAYS=60
|
||||||
|
```
|
||||||
|
|
||||||
|
## 监控区提示文案
|
||||||
|
|
||||||
|
读取当前监控板:**任意交易所有持仓 = 有仓**,否则 = 无仓。
|
||||||
|
|
||||||
|
| 场景 | 提示要点 |
|
||||||
|
|------|----------|
|
||||||
|
| 无仓 · 窗口内 | 建议等待,避免新开仓 |
|
||||||
|
| 有仓 · 窗口内 | 注意仓位,勿加仓,检查止损/减仓 |
|
||||||
|
| 即将发布(30 分钟内) | 在上述基础上标注剩余分钟数 |
|
||||||
|
|
||||||
|
## 存储
|
||||||
|
|
||||||
|
- SQLite:`manual_trading_hub/data/hub_macro_calendar.db`
|
||||||
|
- 可覆盖:`HUB_MACRO_CALENDAR_DB_PATH`
|
||||||
|
|
||||||
|
表 `macro_events`:`event_type`, `event_at_ms`, `note`, `created_at_ms`, `updated_at_ms`
|
||||||
|
同类型 + 同一发布时间不可重复录入。
|
||||||
|
|
||||||
|
## API(均需中控登录)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/api/macro-calendar/meta` | 类型列表与窗口说明 |
|
||||||
|
| GET | `/api/macro-calendar/events` | 设置页列表 |
|
||||||
|
| GET | `/api/macro-calendar/active` | 当前处于窗口内的事件(监控横幅) |
|
||||||
|
| POST | `/api/macro-calendar/events` | 新增 |
|
||||||
|
| PATCH | `/api/macro-calendar/events/{id}` | 更新 |
|
||||||
|
| DELETE | `/api/macro-calendar/events/{id}` | 删除 |
|
||||||
|
|
||||||
|
请求体示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_type": "cpi",
|
||||||
|
"event_at": "2026-06-18 20:30",
|
||||||
|
"note": "可选备注"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用习惯
|
||||||
|
|
||||||
|
1. 每月在金十/日历查看 **FOMC、CPI、非农** 公布时间
|
||||||
|
2. 中控 **系统设置 → 宏观关键数据** 录入 1~3 条
|
||||||
|
3. 到点前后监控区顶栏出现 **宏观风控** 横幅;无操作则窗口结束后自动消失
|
||||||
|
|
||||||
|
## 与账户风控的关系
|
||||||
|
|
||||||
|
| 模块 | 时机 | 作用 |
|
||||||
|
|------|------|------|
|
||||||
|
| 宏观日历 | **事前** | 已知高波动窗口,提醒等待或管仓 |
|
||||||
|
| 账户冷静期/日冻结 | **事后** | 用户主动平仓后的惩罚性限制 |
|
||||||
|
|
||||||
|
宏观提醒 **不触发** 冷静期、不计入手动平仓次数。
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# 实盘下单 · 预估盈亏比
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
三所(Binance / OKX / Gate)**实盘下单监控**表单中,在「开仓」按钮前显示 **预估盈亏比**。
|
||||||
|
|
||||||
|
- **价格模式**:填完币种、方向、止损价、止盈价后,调用 `GET /api/order_defaults` 取标记价,按几何距离计算 RR。
|
||||||
|
- **百分比模式**:填完币种、方向、止损%、止盈% 后拉快照校验币种,再显示 RR(`止盈% / 止损%`)。
|
||||||
|
- **固定盈亏比模式**:不显示预估盈亏比(盈亏比由输入框直接指定;仍保留原有「预估止盈」)。
|
||||||
|
|
||||||
|
- **以损定仓**(`POSITION_SIZING_MODE=risk`):预估风险 = 当前交易基数 × `risk%`。
|
||||||
|
- **全仓杠杆**(`full_margin`):预估风险 = 合约可用 × 缓冲比例 × 杠杆(BTC/ETH 与山寨按 `.env` 配置)× 止损距离比例,与开仓时 `calc_risk_amount_from_plan` 一致。
|
||||||
|
|
||||||
|
## 前端实现
|
||||||
|
|
||||||
|
- 共享脚本:`static/manual_order_rr_preview.js`
|
||||||
|
- 各所 `templates/index.html` 引入并在 `MANUAL_MIN_PLANNED_RR` 定义后执行:
|
||||||
|
```js
|
||||||
|
ManualOrderRrPreview.wire({ minRr: MANUAL_MIN_PLANNED_RR });
|
||||||
|
```
|
||||||
|
- 展示元素:`#order-rr-preview`(开仓按钮左侧)
|
||||||
|
- 颜色:≥ 最低要求为绿色,低于为红色,无效/取价失败为红色或灰色
|
||||||
|
|
||||||
|
## 与提交校验
|
||||||
|
|
||||||
|
提交时仍走原有 `calcClientRr` / `calcClientRrFromPct` 与 `rejectManualOrderRr`;预估仅用于下单前参考,不替代服务端风控。
|
||||||
|
|
||||||
|
## 校验记录
|
||||||
|
|
||||||
|
- `node --check static/manual_order_rr_preview.js`
|
||||||
|
- `tests/test_manual_order_rr_preview.py`:RR 公式与三所 `calc_rr_ratio` 口径一致
|
||||||
@@ -1,55 +1,57 @@
|
|||||||
# 计仓模式(四所统一)
|
# 计仓模式(三所统一)
|
||||||
|
|
||||||
## 配置
|
## 配置
|
||||||
|
|
||||||
在各实例 `.env` 中设置(**仅能通过 env 切换,修改后须重启进程**):
|
在各实例 `.env` 中设置(**仅能通过 env 切换,修改后须重启进程**):
|
||||||
|
|
||||||
```env
|
```env
|
||||||
# risk(默认)= 以损定仓
|
# risk(默认)= 以损定仓
|
||||||
# full_margin = 全仓杠杆(合约可用保证金 × 比例)
|
# full_margin = 全仓杠杆(合约可用保证金 × 比例)
|
||||||
POSITION_SIZING_MODE=risk
|
POSITION_SIZING_MODE=risk
|
||||||
FULL_MARGIN_BUFFER_RATIO=0.98
|
FULL_MARGIN_BUFFER_RATIO=0.98
|
||||||
```
|
```
|
||||||
|
|
||||||
切换为全仓杠杆前:**交易所须无持仓**(`MAX_ACTIVE_POSITIONS` 默认 1,全仓模式会强制单仓)。
|
切换为全仓杠杆前:**交易所须无持仓**(`MAX_ACTIVE_POSITIONS` 默认 1,全仓模式会强制单仓)。
|
||||||
|
|
||||||
## 模式说明
|
## 模式说明
|
||||||
|
|
||||||
| 模式 | 保证金计算 | 杠杆 | 允许入口 |
|
| 模式 | 保证金计算 | 杠杆 | 允许入口 |
|
||||||
|------|------------|------|----------|
|
|------|------------|------|----------|
|
||||||
| `risk` | `RISK_PERCENT` × 交易资金,按止损距离反推 | 表单可选 / 同步交易所 | 实盘人工、关键位自动、趋势回调、顺势加仓 |
|
| `risk` | `RISK_PERCENT` × 交易资金,按止损距离反推 | 表单可选 / 同步交易所 | 实盘人工、关键位自动、趋势回调、顺势加仓 |
|
||||||
| `full_margin` | **合约账户可用 USDT × `FULL_MARGIN_BUFFER_RATIO`**(保留 2 位小数) | BTC/ETH **10x**,其它 **5x**(与 `BTC_LEVERAGE`/`ALT_LEVERAGE` 一致) | **仅** 实盘人工下单;阻力/支撑仅提醒 |
|
| `full_margin` | **合约账户可用 USDT × `FULL_MARGIN_BUFFER_RATIO`**(保留 2 位小数) | BTC/ETH **10x**,其它 **5x**(与 `BTC_LEVERAGE`/`ALT_LEVERAGE` 一致) | **实盘人工下单**、**关键位触价开仓**;阻力/支撑仅提醒 |
|
||||||
|
|
||||||
全仓模式下:
|
全仓模式下:
|
||||||
|
|
||||||
- 仍校验 **计划盈亏比**(`MANUAL_MIN_PLANNED_RR`)。
|
- 仍校验 **计划盈亏比**(实盘用 `MANUAL_MIN_PLANNED_RR`;触价开仓用 `KEY_AUTO_MIN_PLANNED_RR`)。
|
||||||
- 下单张数由 `prepare_order_amount` + 交易所 `amount_to_precision` 决定。
|
- 下单张数由 `prepare_order_amount` + 交易所 `amount_to_precision` 决定。
|
||||||
- `order_monitors.initial_stop_loss` 仍记录**开仓时**止损快照;交易记录复盘以该快照为准。
|
- `order_monitors.initial_stop_loss` 仍记录**开仓时**止损快照;交易记录复盘以该快照为准。
|
||||||
- 已存在的 **箱体突破 / 收敛突破 / 斐波** 监控:进程启动时**自动撤销**并企业微信通知。
|
- 已存在的 **箱体突破 / 收敛突破 / 斐波 / 假突破** 监控:进程启动时**自动撤销**并企业微信通知。
|
||||||
|
|
||||||
## 不允许(全仓模式)
|
## 不允许(全仓模式)
|
||||||
|
|
||||||
- 关键位:箱体突破、收敛突破、斐波自动单(添加时拒绝;已存在则启动时撤销)。
|
- 关键位:箱体突破、收敛突破、斐波、假突破(添加时拒绝;已存在则启动时撤销)。
|
||||||
- 趋势回调、顺势加仓(策略入口返回明确错误)。
|
- 趋势回调、顺势加仓(策略入口返回明确错误)。
|
||||||
|
|
||||||
## 用脚本更新四所 `.env`
|
**允许:** 关键位 **回调触价开仓** / **突破触价开仓**(程序盯价、触达/穿越计划入场后市价成交,无交易所挂单;全仓下仅允许一条待触发)。
|
||||||
|
|
||||||
详见 **[env-sync-scripts.md](./env-sync-scripts.md)**。常用命令:
|
## 用脚本更新三所 `.env`
|
||||||
|
|
||||||
```bash
|
详见 **[env-sync-scripts.md](./env-sync-scripts.md)**。常用命令:
|
||||||
git pull
|
|
||||||
|
```bash
|
||||||
# 仅补全计仓相关项(缺省 risk、缓冲 0.98)
|
git pull
|
||||||
python scripts/sync_four_exchange_position_sizing_env.py
|
|
||||||
|
# 仅补全计仓相关项(缺省 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 full_margin
|
||||||
python scripts/sync_four_exchange_position_sizing_env.py --set-mode risk
|
|
||||||
|
# 无仓后切回以损定仓
|
||||||
# 计仓 + 划转一并补全
|
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
|
python scripts/sync_four_exchange_env.py
|
||||||
```
|
|
||||||
|
pm2 restart crypto-monitor-binance crypto-monitor-okx crypto-monitor-gate
|
||||||
|
```
|
||||||
|
|||||||
@@ -1,41 +1,41 @@
|
|||||||
# Chrome 桌面快捷方式图标说明
|
# Chrome 桌面快捷方式图标说明
|
||||||
|
|
||||||
## 图标从哪来?
|
## 图标从哪来?
|
||||||
|
|
||||||
用 Chrome **「创建快捷方式」** 或 **「安装应用」** 时,桌面/开始菜单图标**不是**操作系统自带的,而是浏览器从**你打开的网站**读取的,优先级大致为:
|
用 Chrome **「创建快捷方式」** 或 **「安装应用」** 时,桌面/开始菜单图标**不是**操作系统自带的,而是浏览器从**你打开的网站**读取的,优先级大致为:
|
||||||
|
|
||||||
1. `manifest.webmanifest` 里的 `icons`(192×192、512×512)
|
1. `manifest.webmanifest` 里的 `icons`(192×192、512×512)
|
||||||
2. `link rel="apple-touch-icon"`(约 180×180)
|
2. `link rel="apple-touch-icon"`(约 180×180)
|
||||||
3. `link rel="icon"` / `favicon.ico`
|
3. `link rel="icon"` / `favicon.ico`
|
||||||
4. 若都没有 → 灰色地球或网页标题首字
|
4. 若都没有 → 灰色地球或网页标题首字
|
||||||
|
|
||||||
本仓库已在 **中控** 与 **四所监控页** 配置统一品牌图标(深色圆角底 + 青绿趋势线 + 简化的 K 线),与页面 UI 一致。PNG/ICO 由 **Pillow** 生成,避免损坏的 favicon 出现花屏。
|
本仓库已在 **中控** 与 **三所监控页** 配置统一品牌图标(深色圆角底 + 青绿趋势线 + 简化的 K 线),与页面 UI 一致。PNG/ICO 由 **Pillow** 生成,避免损坏的 favicon 出现花屏。
|
||||||
|
|
||||||
## 文件位置
|
## 文件位置
|
||||||
|
|
||||||
| 位置 | 访问路径 |
|
| 位置 | 访问路径 |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| 源稿 | `brand/icon.svg`、`brand/icons/*.png` |
|
| 源稿 | `brand/icon.svg`、`brand/icons/*.png` |
|
||||||
| 中控 | `manual_trading_hub/static/icons/` → `/assets/icons/...` |
|
| 中控 | `manual_trading_hub/static/icons/` → `/assets/icons/...` |
|
||||||
| 四所 | `crypto_monitor_*/static/icons/` → `/static/icons/...` |
|
| 三所 | `crypto_monitor_*/static/icons/` → `/static/icons/...` |
|
||||||
|
|
||||||
## 重新生成 / 同步
|
## 重新生成 / 同步
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python scripts/generate_brand_icons.py
|
python scripts/generate_brand_icons.py
|
||||||
python scripts/sync_brand_icons.py
|
python scripts/sync_brand_icons.py
|
||||||
git pull # 服务器部署后
|
git pull # 服务器部署后
|
||||||
pm2 restart …
|
pm2 restart …
|
||||||
```
|
```
|
||||||
|
|
||||||
## 快捷方式仍显示旧图标?
|
## 快捷方式仍显示旧图标?
|
||||||
|
|
||||||
Chrome / Windows 会**缓存** favicon:
|
Chrome / Windows 会**缓存** favicon:
|
||||||
|
|
||||||
1. 浏览器打开站点,**Ctrl+F5** 强刷
|
1. 浏览器打开站点,**Ctrl+F5** 强刷
|
||||||
2. 删除旧快捷方式,重新「创建快捷方式」
|
2. 删除旧快捷方式,重新「创建快捷方式」
|
||||||
3. 必要时清除 Chrome 站点数据(该域名)后再创建
|
3. 必要时清除 Chrome 站点数据(该域名)后再创建
|
||||||
|
|
||||||
## 自定义图标
|
## 自定义图标
|
||||||
|
|
||||||
可替换 `brand/icon.svg` 后重新运行上面两条命令;或把设计好的 `icon-192.png`、`icon-512.png` 放入 `brand/icons/` 再 `sync_brand_icons.py`。
|
可替换 `brand/icon.svg` 后重新运行上面两条命令;或把设计好的 `icon-192.png`、`icon-512.png` 放入 `brand/icons/` 再 `sync_brand_icons.py`。
|
||||||
|
|||||||
@@ -1,184 +1,184 @@
|
|||||||
# 趋势回调:中控平仓与交易记录(检阅备忘)
|
# 趋势回调:中控平仓与交易记录(检阅备忘)
|
||||||
|
|
||||||
本文档汇总 **中控手动结束趋势计划**、**交易记录 / 策略记录** 写入规则,以及 **四所展示统一**、**补仓表计价** 相关修复,便于自行检阅与排错。
|
本文档汇总 **中控手动结束趋势计划**、**交易记录 / 策略记录** 写入规则,以及 **三所展示统一**、**补仓表计价** 相关修复,便于自行检阅与排错。
|
||||||
|
|
||||||
适用仓库:`crypto_monitor`(Binance / OKX / Gate / Gate Bot + `manual_trading_hub`)。
|
适用仓库:`crypto_monitor`(Binance / OKX / + `manual_trading_hub`)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 中控手动平仓会不会写交易记录?
|
## 1. 中控手动平仓会不会写交易记录?
|
||||||
|
|
||||||
**会。** 在实例已部署 **`80226ee` 及之后** 代码并 **重启对应 Flask** 的前提下:
|
**会。** 在实例已部署 **`80226ee` 及之后** 代码并 **重启对应 Flask** 的前提下:
|
||||||
|
|
||||||
中控点击 **「结束计划」** → 实例执行市价平仓 + 结束计划 → **同时写入**:
|
中控点击 **「结束计划」** → 实例执行市价平仓 + 结束计划 → **同时写入**:
|
||||||
|
|
||||||
| 目标 | 表 | 页面入口 |
|
| 目标 | 表 | 页面入口 |
|
||||||
|------|-----|----------|
|
|------|-----|----------|
|
||||||
| 策略记录 | `strategy_trade_snapshots` | 顶栏 **策略交易记录** → 左栏「趋势回调记录」 |
|
| 策略记录 | `strategy_trade_snapshots` | 顶栏 **策略交易记录** → 左栏「趋势回调记录」 |
|
||||||
| 交易记录 | `trade_records` | 顶栏 **交易记录与复盘** |
|
| 交易记录 | `trade_records` | 顶栏 **交易记录与复盘** |
|
||||||
|
|
||||||
手动结束的结果字段为 **「手动平仓」**(亏损时也不会被改成「止损」)。
|
手动结束的结果字段为 **「手动平仓」**(亏损时也不会被改成「止损」)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. 调用链(四所统一)
|
## 2. 调用链(三所统一)
|
||||||
|
|
||||||
```
|
```
|
||||||
manual_trading_hub
|
manual_trading_hub
|
||||||
POST /api/trend/{exchange_id}/stop
|
POST /api/trend/{exchange_id}/stop
|
||||||
→ 实例 POST /api/hub/trend/stop/{plan_id}
|
→ 实例 POST /api/hub/trend/stop/{plan_id}
|
||||||
→ stop_trend_pullback(pid)
|
→ stop_trend_pullback(pid)
|
||||||
→ 市价平仓 + 撤单
|
→ 市价平仓 + 撤单
|
||||||
→ _finalize_plan(cfg, conn, row, "手动平仓", exit_price)
|
→ _finalize_plan(cfg, conn, row, "手动平仓", exit_price)
|
||||||
```
|
```
|
||||||
|
|
||||||
共用实现:`strategy_trend_register.py`(四所同一套,Gate Bot 的 `stop_trend_pullback` 也调用 `_finalize_plan`)。
|
共用实现:`strategy_trend_register.py`(三所同一套,各所的 `stop_trend_pullback` 也调用 `_finalize_plan`)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. `_finalize_plan` 写入顺序(修复后)
|
## 3. `_finalize_plan` 写入顺序(修复后)
|
||||||
|
|
||||||
1. 写 **策略快照** `save_trend_plan_snapshot` → `strategy_trade_snapshots`
|
1. 写 **策略快照** `save_trend_plan_snapshot` → `strategy_trade_snapshots`
|
||||||
2. 撤该品种挂单
|
2. 撤该品种挂单
|
||||||
3. 若尚无 `trade_records.trend_plan_id = 计划ID`:
|
3. 若尚无 `trade_records.trend_plan_id = 计划ID`:
|
||||||
- 更新当日 session 资金
|
- 更新当日 session 资金
|
||||||
- **`insert_trade_record`** 写入交易记录
|
- **`insert_trade_record`** 写入交易记录
|
||||||
4. 更新 `trend_pullback_plans.status`(`stopped_manual` / `stopped_sl` / `stopped_tp`)
|
4. 更新 `trend_pullback_plans.status`(`stopped_manual` / `stopped_sl` / `stopped_tp`)
|
||||||
5. **`conn.commit()`** 一次提交
|
5. **`conn.commit()`** 一次提交
|
||||||
|
|
||||||
要点:**先写交易记录,再结束计划**,避免「计划已结束、交易记录未写入」的半成功状态。
|
要点:**先写交易记录,再结束计划**,避免「计划已结束、交易记录未写入」的半成功状态。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 曾出现的 Bug(#4 ONDO 漏记)
|
## 4. 曾出现的 Bug(#4 ONDO 漏记)
|
||||||
|
|
||||||
**现象**:策略记录有(止损 -2.71U),**交易记录没有**。
|
**现象**:策略记录有(止损 -2.71U),**交易记录没有**。
|
||||||
|
|
||||||
**原因**:Gate Bot 的 `insert_trade_record` 曾 **缺少 `entry_reason` 参数**,而 `_finalize_plan` 固定传入 `entry_reason="趋势回调"`,触发:
|
**原因**:各所的 `insert_trade_record` 曾 **缺少 `entry_reason` 参数**,而 `_finalize_plan` 固定传入 `entry_reason="趋势回调"`,触发:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
TypeError: insert_trade_record() got an unexpected keyword argument 'entry_reason'
|
TypeError: insert_trade_record() got an unexpected keyword argument 'entry_reason'
|
||||||
```
|
```
|
||||||
|
|
||||||
策略快照在异常 **之前** 已插入,交易记录插入失败,故只出现在策略记录页。
|
策略快照在异常 **之前** 已插入,交易记录插入失败,故只出现在策略记录页。
|
||||||
|
|
||||||
**修复提交**:`80226ee`
|
**修复提交**:`80226ee`
|
||||||
|
|
||||||
- Gate Bot `insert_trade_record` 增加 `entry_reason`
|
- `insert_trade_record` 增加 `entry_reason`
|
||||||
- `_call_insert_trade_record`:按各所函数 **签名过滤** 参数,避免未知字段导致失败
|
- `_call_insert_trade_record`:按各所函数 **签名过滤** 参数,避免未知字段导致失败
|
||||||
- 调整写入顺序:交易记录 → 计划结束 → commit
|
- 调整写入顺序:交易记录 → 计划结束 → commit
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 历史漏记补录
|
## 5. 历史漏记补录
|
||||||
|
|
||||||
对已结束、策略快照在、交易记录缺的计划(如 #4):
|
对已结束、策略快照在、交易记录缺的计划(如 #4):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/crypto_monitor # 或本机仓库根目录
|
cd /opt/crypto_monitor # 或本机仓库根目录
|
||||||
|
|
||||||
# 先预览
|
# 先预览
|
||||||
python scripts/backfill_trend_trade_records.py \
|
python scripts/backfill_trend_trade_records.py \
|
||||||
--db crypto_monitor_gate_bot/crypto.db --dry-run
|
--db crypto_monitor_gate/crypto.db --dry-run
|
||||||
|
|
||||||
# 确认后写入
|
# 确认后写入
|
||||||
python scripts/backfill_trend_trade_records.py \
|
python scripts/backfill_trend_trade_records.py \
|
||||||
--db crypto_monitor_gate_bot/crypto.db --apply
|
--db crypto_monitor_gate/crypto.db --apply
|
||||||
```
|
```
|
||||||
|
|
||||||
其它所将 `--db` 换成对应 `crypto.db` 路径即可。
|
其它所将 `--db` 换成对应 `crypto.db` 路径即可。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 与「保本移交」的区别
|
## 6. 与「保本移交」的区别
|
||||||
|
|
||||||
| 操作 | 策略记录 | 交易记录 |
|
| 操作 | 策略记录 | 交易记录 |
|
||||||
|------|----------|----------|
|
|------|----------|----------|
|
||||||
| 中控 **结束计划**(手动平仓) | 计划结束时写入 | **同一时刻**写入 |
|
| 中控 **结束计划**(手动平仓) | 计划结束时写入 | **同一时刻**写入 |
|
||||||
| **保本移交** | 移交时写入策略快照 | **不立即写**;持仓移交到 `order_monitors`,**后续平仓** 再写入 `trade_records` |
|
| **保本移交** | 移交时写入策略快照 | **不立即写**;持仓移交到 `order_monitors`,**后续平仓** 再写入 `trade_records` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. 四所展示统一(中控 ↔ 实例)
|
## 7. 三所展示统一(中控 ↔ 实例)
|
||||||
|
|
||||||
### 7.1 数据 enrich 入口
|
### 7.1 数据 enrich 入口
|
||||||
|
|
||||||
| 场景 | 函数 |
|
| 场景 | 函数 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 实例策略页 | `enrich_trend_plan` |
|
| 实例策略页 | `enrich_trend_plan` |
|
||||||
| 中控 `/api/hub/monitor` | `enrich_trend_plan_for_hub` → 同上 |
|
| 中控 `/api/hub/monitor` | `enrich_trend_plan_for_hub` → 同上 |
|
||||||
| 补仓明细表 | `attach_trend_dca_levels` → `enrich_trend_dca_levels_with_tp` |
|
| 补仓明细表 | `attach_trend_dca_levels` → `enrich_trend_dca_levels_with_tp` |
|
||||||
|
|
||||||
Gate Bot 在 `hub_bridge` 安装后调用 `patch_trend_hub_enrich`,与另外三所 `install_strategy_trend` 行为一致。
|
在 `hub_bridge` 安装后调用 `patch_trend_hub_enrich`,与另外三所 `install_strategy_trend` 行为一致。
|
||||||
|
|
||||||
### 7.2 补仓表「触发价 / 加仓后均价」
|
### 7.2 补仓表「触发价 / 加仓后均价」
|
||||||
|
|
||||||
**禁止**为凑均价 **反推虚构成交价**(曾错误出现做多补仓触发价 0.3941 等离谱数值)。
|
**禁止**为凑均价 **反推虚构成交价**(曾错误出现做多补仓触发价 0.3941 等离谱数值)。
|
||||||
|
|
||||||
**`trend_leg_display_price`(四所唯一口径)**:
|
**`trend_leg_display_price`(三所唯一口径)**:
|
||||||
|
|
||||||
| 列 | 规则 |
|
| 列 | 规则 |
|
||||||
|----|------|
|
|----|------|
|
||||||
| **触发价** | `leg_fill_prices_json` 有记录 → 实际成交价;无记录 → **计划网格价** |
|
| **触发价** | `leg_fill_prices_json` 有记录 → 实际成交价;无记录 → **计划网格价** |
|
||||||
| **末档已补仓的加仓后均价** | 与顶部均价一致,取 **交易所持仓 `entry_price`**(`avg_entry_price`) |
|
| **末档已补仓的加仓后均价** | 与顶部均价一致,取 **交易所持仓 `entry_price`**(`avg_entry_price`) |
|
||||||
| **顶部均价** | 优先交易所 live `entry_price`,非计划库内估算值 |
|
| **顶部均价** | 优先交易所 live `entry_price`,非计划库内估算值 |
|
||||||
|
|
||||||
修复提交:`08082eb`(移除反推成交价逻辑)。
|
修复提交:`08082eb`(移除反推成交价逻辑)。
|
||||||
|
|
||||||
### 7.3 中控静态页
|
### 7.3 中控静态页
|
||||||
|
|
||||||
`manual_trading_hub/static/app.js`:趋势浮盈亏计算 **优先** `trendPlan.avg_entry_price`,与计划卡一致。
|
`manual_trading_hub/static/app.js`:趋势浮盈亏计算 **优先** `trendPlan.avg_entry_price`,与计划卡一致。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. 部署与自检
|
## 8. 部署与自检
|
||||||
|
|
||||||
### 8.1 升级
|
### 8.1 升级
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/crypto_monitor
|
cd /opt/crypto_monitor
|
||||||
git pull # 需含 80226ee、08082eb
|
git pull # 需含 80226ee、08082eb
|
||||||
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
|
pm2 save
|
||||||
```
|
```
|
||||||
|
|
||||||
### 8.2 手动平仓后自检
|
### 8.2 手动平仓后自检
|
||||||
|
|
||||||
1. 中控结束一笔测试计划(或极小仓位)
|
1. 中控结束一笔测试计划(或极小仓位)
|
||||||
2. **策略交易记录**:出现对应条目
|
2. **策略交易记录**:出现对应条目
|
||||||
3. **交易记录与复盘**:出现 `类型=趋势回调`、`结果=手动平仓`,且 `trend_plan_id` 与计划 ID 一致
|
3. **交易记录与复盘**:出现 `类型=趋势回调`、`结果=手动平仓`,且 `trend_plan_id` 与计划 ID 一致
|
||||||
4. 若实例 flash / 日志出现「计划已结束但记账可能不完整」,说明 `insert_trade_record` 仍失败,需查 PM2 日志
|
4. 若实例 flash / 日志出现「计划已结束但记账可能不完整」,说明 `insert_trade_record` 仍失败,需查 PM2 日志
|
||||||
|
|
||||||
### 8.3 相关代码文件
|
### 8.3 相关代码文件
|
||||||
|
|
||||||
| 文件 | 作用 |
|
| 文件 | 作用 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `strategy_trend_register.py` | `_finalize_plan`、`_call_insert_trade_record`、`enrich_trend_plan` |
|
| `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_trend_lib.py` | `trend_leg_display_price`、`enrich_trend_dca_levels_with_tp` |
|
||||||
| `strategy_snapshot_lib.py` | 策略快照写入 |
|
| `strategy_snapshot_lib.py` | 策略快照写入 |
|
||||||
| `hub_bridge.py` | `/api/hub/trend/stop/<pid>` |
|
| `hub_bridge.py` | `/api/hub/trend/stop/<pid>` |
|
||||||
| `crypto_monitor_gate_bot/app.py` | `insert_trade_record`(含 `entry_reason`) |
|
| `crypto_monitor_gate/app.py` | `insert_trade_record`(含 `entry_reason`) |
|
||||||
| `scripts/backfill_trend_trade_records.py` | 漏记交易记录补录 |
|
| `scripts/backfill_trend_trade_records.py` | 漏记交易记录补录 |
|
||||||
|
|
||||||
### 8.4 相关提交
|
### 8.4 相关提交
|
||||||
|
|
||||||
| 提交 | 说明 |
|
| 提交 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `6a4ec69` | 中控与四所趋势展示 enrich 统一 |
|
| `6a4ec69` | 中控与三所趋势展示 enrich 统一 |
|
||||||
| `08082eb` | 移除补仓表反推虚构成交价 |
|
| `08082eb` | 移除补仓表反推虚构成交价 |
|
||||||
| `80226ee` | 修复 Gate Bot 中控平仓漏写 `trade_records` |
|
| `80226ee` | 修复 中控平仓漏写 `trade_records` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. 相关文档
|
## 9. 相关文档
|
||||||
|
|
||||||
| 文档 | 内容 |
|
| 文档 | 内容 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| [策略交易说明.md](../策略交易说明.md) | 策略总览、策略交易记录页 |
|
| [策略交易说明.md](../策略交易说明.md) | 策略总览、策略交易记录页 |
|
||||||
| [crypto_monitor_gate_bot/趋势回调策略说明.md](../crypto_monitor_gate_bot/趋势回调策略说明.md) | 趋势回调业务细则 |
|
| [crypto_monitor_gate/趋势回调策略说明.md](../crypto_monitor_gate/趋势回调策略说明.md) | 趋势回调业务细则 |
|
||||||
| [manual_trading_hub/使用说明.md](../manual_trading_hub/使用说明.md) | 中控监控与趋势卡布局 |
|
| [manual_trading_hub/使用说明.md](../manual_trading_hub/使用说明.md) | 中控监控与趋势卡布局 |
|
||||||
| [hub-symbol-archive-kline.md](./hub-symbol-archive-kline.md) | 币种档案、永久 5m K 线、交易 overlay |
|
| [hub-symbol-archive-kline.md](./hub-symbol-archive-kline.md) | 币种档案、永久 5m K 线、交易 overlay |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*最后整理:2026-06-07(与对话中修复项同步)*
|
*最后整理:2026-06-07(与对话中修复项同步)*
|
||||||
|
|||||||
@@ -1,129 +1,129 @@
|
|||||||
# 趋势回调策略(机器人)说明
|
# 趋势回调策略说明
|
||||||
|
|
||||||
本文描述 **「趋势回调」** 自动交易计划的业务规则与实现口径。
|
本文描述 **「趋势回调」** 自动交易计划的业务规则与实现口径。
|
||||||
|
|
||||||
**四所主站**(Binance / Gate / OKX / 本目录 `crypto_monitor_gate_bot`)均在顶栏 **策略交易 → `/strategy`** 左栏提供同一套逻辑(共用 `strategy_trend_register.py`);本目录侧重 **Gate 子账户 / 机器人** 实例,可与主 Gate 账户隔离部署。
|
**三所主站**(Binance / Gate / OKX)均在顶栏 **策略交易 → `/strategy`** 左栏提供同一套逻辑(共用 `strategy_trend_register.py`);各所使用各自 API 与 `crypto.db`。
|
||||||
|
|
||||||
**检阅备忘**(中控平仓、交易记录、补仓展示、漏记补录):[docs/trend-hub-close-and-trade-records.md](../docs/trend-hub-close-and-trade-records.md)
|
**检阅备忘**(中控平仓、交易记录、补仓展示、漏记补录):[trend-hub-close-and-trade-records.md](./trend-hub-close-and-trade-records.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 适用场景
|
## 1. 适用场景
|
||||||
|
|
||||||
- 单独用于跑策略的 **Gate.io USDT 永续** 子账户(建议与主资金隔离);其它交易所实例同理,使用各自 API 与 `crypto.db`。
|
- 各 **USDT 永续** 实例独立部署,使用各自 API 与 `crypto.db`。
|
||||||
- 你已明确:**方向、止损价、补仓区间边界价、止盈价、杠杆**,并接受程序按风险预算拆分 **首仓 50% + 多档补仓 50%**。
|
- 你已明确:**方向、止损价、补仓区间边界价、止盈价、杠杆**,并接受程序按风险预算拆分 **首仓 50% + 多档补仓 50%**。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. 名词与参数
|
## 2. 名词与参数
|
||||||
|
|
||||||
| 名称 | 含义 |
|
| 名称 | 含义 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| **合约 USDT 可用余额** | **生成预览**时通过 API 读取的 **swap 账户 USDT `free`** 快照;**确认执行**时再次读取并与快照比对偏差。 |
|
| **合约 USDT 可用余额** | **生成预览**时通过 API 读取的 **swap 账户 USDT `free`** 快照;**确认执行**时再次读取并与快照比对偏差。 |
|
||||||
| **风险比例** | 默认 **5%**:指「若整笔计划在 **补仓区间远侧边界**(做多=上沿、做空=下沿)这一侧的最坏价格结构下触及止损」,目标亏损上限约为 **可用余额快照 × 风险比例**(实现上用 `calc_risk_fraction` 与 `prepare_order_amount` 反推总张数,受交易所最小张数与精度约束)。 |
|
| **风险比例** | 默认 **5%**:指「若整笔计划在 **补仓区间远侧边界**(做多=上沿、做空=下沿)这一侧的最坏价格结构下触及止损」,目标亏损上限约为 **可用余额快照 × 风险比例**(实现上用 `calc_risk_fraction` 与 `prepare_order_amount` 反推总张数,受交易所最小张数与精度约束)。 |
|
||||||
| **止损价** | 用户填写;开仓后挂 **交易所仓位类止损触发单**(全平)。 |
|
| **止损价** | 用户填写;开仓后挂 **交易所仓位类止损触发单**(全平)。 |
|
||||||
| **补仓区间边界**(库字段 `add_upper`) | 用户填写;**仅在该价位与止损价构成的区间内** 才允许程序触发剩余 50% 的市价补仓。**界面文案**:做多显示「补仓上沿」,做空显示「补仓下沿」。校验:做多 `止损 < 边界价`;做空 `止损 > 边界价`。 |
|
| **补仓区间边界**(库字段 `add_upper`) | 用户填写;**仅在该价位与止损价构成的区间内** 才允许程序触发剩余 50% 的市价补仓。**界面文案**:做多显示「补仓上沿」,做空显示「补仓下沿」。校验:做多 `止损 < 边界价`;做空 `止损 > 边界价`。 |
|
||||||
| **止盈价** | 用户填写的 **固定价格**;**不由交易所条件止盈单触发**,由应用后台 **按标记价/行情价轮询**,达到后 **市价全平**。 |
|
| **止盈价** | 用户填写的 **固定价格**;**不由交易所条件止盈单触发**,由应用后台 **按标记价/行情价轮询**,达到后 **市价全平**。 |
|
||||||
| **杠杆** | 计划内固定写入;用于 `set_leverage` 与名义换算。 |
|
| **杠杆** | 计划内固定写入;用于 `set_leverage` 与名义换算。 |
|
||||||
| **补仓档位数** | 默认 **5** 档(环境变量 `TREND_PULLBACK_DCA_LEGS` 可调);程序在满足最小张数前提下可能 **自动减少档数**。 |
|
| **补仓档位数** | 默认 **5** 档(环境变量 `TREND_PULLBACK_DCA_LEGS` 可调);程序在满足最小张数前提下可能 **自动减少档数**。 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. 执行流程(时间顺序)
|
## 3. 执行流程(时间顺序)
|
||||||
|
|
||||||
### 3.0 列表时间窗(交易记录 / 计划历史)
|
### 3.0 列表时间窗(交易记录 / 计划历史)
|
||||||
|
|
||||||
- **交易记录**、**计划历史**(含预览快照)列表与 **交易记录 CSV 导出** 支持 **UTC** 时间筛选(默认 UTC 当日;可选近 24h、近 7d、自定义起止)。
|
- **交易记录**、**计划历史**(含预览快照)列表与 **交易记录 CSV 导出** 支持 **UTC** 时间筛选(默认 UTC 当日;可选近 24h、近 7d、自定义起止)。
|
||||||
- 查询参数:`win_preset`(`utc_today` / `utc_last24h` / `utc_last7d` / `custom`)、自定义时另传 `from_utc`、`to_utc`。
|
- 查询参数:`win_preset`(`utc_today` / `utc_last24h` / `utc_last7d` / `custom`)、自定义时另传 `from_utc`、`to_utc`。
|
||||||
- **统计分析**页仍按北京时间 `TRADING_DAY_RESET_HOUR` 切日,不受列表窗影响。
|
- **统计分析**页仍按北京时间 `TRADING_DAY_RESET_HOUR` 切日,不受列表窗影响。
|
||||||
|
|
||||||
### 3.1 预览阶段(不下单)
|
### 3.1 预览阶段(不下单)
|
||||||
|
|
||||||
1. **风控**:与「机器人下单监控」**互斥**——存在活跃机器人持仓或运行中趋势计划时,不可生成预览。
|
1. **风控**:与「机器人下单监控」**互斥**——存在活跃机器人持仓或运行中趋势计划时,不可生成预览。
|
||||||
2. **读取可用余额快照** `get_available_trading_usdt()`,失败则拒绝。
|
2. **读取可用余额快照** `get_available_trading_usdt()`,失败则拒绝。
|
||||||
3. **计算**(写入表 `trend_pullback_previews`,并跳转带 `preview_id`):
|
3. **计算**(写入表 `trend_pullback_previews`,并跳转带 `preview_id`):
|
||||||
- 在 **补仓区间边界 ↔ 止损** 区间内生成 `N` 个补仓触发价(做多从上沿向止损、做空从下沿向止损);
|
- 在 **补仓区间边界 ↔ 止损** 区间内生成 `N` 个补仓触发价(做多从上沿向止损、做空从下沿向止损);
|
||||||
- 将 **剩余 50% 计划张数** 拆成 `N` 份写入 `leg_amounts_json`。
|
- 将 **剩余 50% 计划张数** 拆成 `N` 份写入 `leg_amounts_json`。
|
||||||
4. **预览有效期**:默认 **120 秒**(`TREND_PULLBACK_PREVIEW_TTL_SECONDS`),超时须重新点「生成预览」。
|
4. **预览有效期**:默认 **120 秒**(`TREND_PULLBACK_PREVIEW_TTL_SECONDS`),超时须重新点「生成预览」。
|
||||||
|
|
||||||
### 3.2 确认执行(实盘)
|
### 3.2 确认执行(实盘)
|
||||||
|
|
||||||
5. 再次校验:预览未过期;**当前可用余额**与预览快照相对偏差 ≤ `TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT`(默认 **5%**),否则拒绝执行并要求重新预览。
|
5. 再次校验:预览未过期;**当前可用余额**与预览快照相对偏差 ≤ `TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT`(默认 **5%**),否则拒绝执行并要求重新预览。
|
||||||
6. **首仓**:**立即市价** 开立 **总计划张数 × 50%**(不附带交易所止盈单)。
|
6. **首仓**:**立即市价** 开立 **总计划张数 × 50%**(不附带交易所止盈单)。
|
||||||
7. **止损**:撤销旧条件单后,挂 **仅止损** 的仓位触发单;之后每次补仓成交会 **刷新** 止损挂单。
|
7. **止损**:撤销旧条件单后,挂 **仅止损** 的仓位触发单;之后每次补仓成交会 **刷新** 止损挂单。
|
||||||
7b. **保本移交下单监控**(可选):首仓完成且交易所有持仓后,可点击「保本移交下单监控」——将止损移至 **持仓均价 ± 偏移%**(默认 **+0.3%** 多 / **−0.3%** 空),仅当新止损 **优于** 当前止损时生效;**本次趋势计划随即结束**,持仓写入 **下单监控**(备注 **趋势回调计划**),交易所在 **同一时刻挂保本止损 + 计划止盈**;后续无论中控平仓或交易所手动平仓,均经下单监控轮询 **`reconcile_external_closes` / `check_order_monitors`** 写入 **交易记录**(含 `trend_plan_id`、开仓类型「趋势回调」),供人工核对。
|
7b. **保本移交下单监控**(可选):首仓完成且交易所有持仓后,可点击「保本移交下单监控」——将止损移至 **持仓均价 ± 偏移%**(默认 **+0.3%** 多 / **−0.3%** 空),仅当新止损 **优于** 当前止损时生效;**本次趋势计划随即结束**,持仓写入 **下单监控**(备注 **趋势回调计划**),交易所在 **同一时刻挂保本止损 + 计划止盈**;后续无论中控平仓或交易所手动平仓,均经下单监控轮询 **`reconcile_external_closes` / `check_order_monitors`** 写入 **交易记录**(含 `trend_plan_id`、开仓类型「趋势回调」),供人工核对。
|
||||||
8. **补仓**:当价格 **穿越** 下一档触发价(做多为自上向下穿越,做空为自下向上穿越)时,按该档张数 **市价加仓**;直至 `N` 档执行完毕或计划结束。
|
8. **补仓**:当价格 **穿越** 下一档触发价(做多为自上向下穿越,做空为自下向上穿越)时,按该档张数 **市价加仓**;直至 `N` 档执行完毕或计划结束。
|
||||||
9. **止盈监控**:后台线程若发现价格触及止盈,则 **市价全平**。
|
9. **止盈监控**:后台线程若发现价格触及止盈,则 **市价全平**。
|
||||||
10. **止损触发**:若仓位被交易所止损打光,本地检测到 **持仓为 0** 后记账为 **止损** 并结束计划。
|
10. **止损触发**:若仓位被交易所止损打光,本地检测到 **持仓为 0** 后记账为 **止损** 并结束计划。
|
||||||
11. **计划结束**:任一结束路径(止盈 / 止损 / 用户手动结束)均会 **撤单**(条件单 + 普通挂单,尽力而为)。
|
11. **计划结束**:任一结束路径(止盈 / 止损 / 用户手动结束)均会 **撤单**(条件单 + 普通挂单,尽力而为)。
|
||||||
|
|
||||||
### 3.3 取消预览
|
### 3.3 取消预览
|
||||||
|
|
||||||
用户可「取消预览」删除 `trend_pullback_previews` 中对应记录;过期记录会在新预览或页面加载时清理。
|
用户可「取消预览」删除 `trend_pullback_previews` 中对应记录;过期记录会在新预览或页面加载时清理。
|
||||||
|
|
||||||
### 3.4 界面:计划历史与运行中浮动盈亏
|
### 3.4 界面:计划历史与运行中浮动盈亏
|
||||||
|
|
||||||
- **计划历史(页顶卡片)**
|
- **计划历史(页顶卡片)**
|
||||||
- 仅展示 **`trend_pullback_plans` 中已结束的计划**(`status != 'active'`,如止盈结束、止损结束、手动结束)。
|
- 仅展示 **`trend_pullback_plans` 中已结束的计划**(`status != 'active'`,如止盈结束、止损结束、手动结束)。
|
||||||
- **不包含**仅存在于 `trend_pullback_previews`、从未「确认执行」的预览。
|
- **不包含**仅存在于 `trend_pullback_previews`、从未「确认执行」的预览。
|
||||||
- 每行提供 **删除**:删除该计划行,并删除 `trade_records` 中 **`trend_plan_id` 与之相同** 且类型为「趋势回调」的记录(用于与计划一一对应的新数据;历史旧行若无 `trend_plan_id` 则不会随删)。
|
- 每行提供 **删除**:删除该计划行,并删除 `trade_records` 中 **`trend_plan_id` 与之相同** 且类型为「趋势回调」的记录(用于与计划一一对应的新数据;历史旧行若无 `trend_plan_id` 则不会随删)。
|
||||||
- **运行中的计划(交易执行页)**
|
- **运行中的计划(交易执行页)**
|
||||||
- 在计划摘要下方展示 **浮盈亏(交易所)**:来自 Gate 当前持仓接口的 **未实现盈亏**(及标记价,若可得);与本地按均价估算可能略有差异,以交易所为准便于对照。
|
- 在计划摘要下方展示 **浮盈亏(交易所)**:来自 Gate 当前持仓接口的 **未实现盈亏**(及标记价,若可得);与本地按均价估算可能略有差异,以交易所为准便于对照。
|
||||||
- **补仓边界**按方向显示「补仓上沿」或「补仓下沿」(数值仍为 `add_upper` 字段)。
|
- **补仓边界**按方向显示「补仓上沿」或「补仓下沿」(数值仍为 `add_upper` 字段)。
|
||||||
- **手动保本**:表单可改偏移 %(默认见 `TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT`);成功后显示「已保本」时间与原止损(若与当前不同)。
|
- **手动保本**:表单可改偏移 %(默认见 `TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT`);成功后显示「已保本」时间与原止损(若与当前不同)。
|
||||||
|
|
||||||
### 3.5 交易记录与交易所「已实现盈亏」对齐
|
### 3.5 交易记录与交易所「已实现盈亏」对齐
|
||||||
|
|
||||||
- 平仓时仍会写入一条 **`trade_records`**(`monitor_type=趋势回调`),其中的 **`pnl_amount` 等为本地估算**(`calc_pnl`,不含手续费、资金费等完整账单口径)。
|
- 平仓时仍会写入一条 **`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` 的趋势回调记录 **匹配一条平仓记录**,并回填:
|
- 打开 **「交易执行」或「交易记录」** 页面时,若已配置 **`GATE_API_KEY` / `GATE_API_SECRET`**(不要求 `LIVE_TRADING_ENABLED=true`,只读即可),应用会按节流策略(同进程约 **25 秒**内最多一次)调用 Gate **`fetch_positions_history`(平仓历史)**,为尚未写入 `exchange_sync_key` 的趋势回调记录 **匹配一条平仓记录**,并回填:
|
||||||
- **`exchange_realized_pnl`**:交易所口径已实现盈亏(与 App「历史仓位」更接近);
|
- **`exchange_realized_pnl`**:交易所口径已实现盈亏(与 App「历史仓位」更接近);
|
||||||
- **`exchange_opened_at` / `exchange_closed_at`**:换算为应用时区(默认北京)下的开、平时间字符串。
|
- **`exchange_opened_at` / `exchange_closed_at`**:换算为应用时区(默认北京)下的开、平时间字符串。
|
||||||
- **交易记录表**展示列「开仓(展示) / 平仓(展示) / 盈亏U(展示)」:对「趋势回调」行,若已同步则优先显示交易所字段(界面小字 **「所」**);未同步前仍显示本地复盘字段(小字 **「估」**)。
|
- **交易记录表**展示列「开仓(展示) / 平仓(展示) / 盈亏U(展示)」:对「趋势回调」行,若已同步则优先显示交易所字段(界面小字 **「所」**);未同步前仍显示本地复盘字段(小字 **「估」**)。
|
||||||
- 匹配规则概要:同品种、同方向、平仓时间与本地 `closed_at` 接近,并结合 **`trend_plan_id`** 对应计划的 `opened_at` 收窄时间窗;极端情况下若短时间多笔同向同品种,仍存在错配可能,可对照 `exchange_sync_key` 与交易所记录。
|
- 匹配规则概要:同品种、同方向、平仓时间与本地 `closed_at` 接近,并结合 **`trend_plan_id`** 对应计划的 `opened_at` 收窄时间窗;极端情况下若短时间多笔同向同品种,仍存在错配可能,可对照 `exchange_sync_key` 与交易所记录。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 与「机器人下单监控」的差异
|
## 4. 与「机器人下单监控」的差异
|
||||||
|
|
||||||
| 项目 | 机器人下单监控 | 趋势回调 |
|
| 项目 | 机器人下单监控 | 趋势回调 |
|
||||||
|------|------------------|----------|
|
|------|------------------|----------|
|
||||||
| 开仓 | 单次市价 + 条件止盈+止损 | 首仓 50% 市价 + 多档补仓 + **仅止损在交易所** |
|
| 开仓 | 单次市价 + 条件止盈+止损 | 首仓 50% 市价 + 多档补仓 + **仅止损在交易所** |
|
||||||
| 止盈 | 条件单 + 本地监控 | **仅本地监控市价止盈** |
|
| 止盈 | 条件单 + 本地监控 | **仅本地监控市价止盈** |
|
||||||
| 仓位基数 | 以损定仓(表单/会话基数) | **可用余额快照 × 风险比例** 推导 |
|
| 仓位基数 | 以损定仓(表单/会话基数) | **可用余额快照 × 风险比例** 推导 |
|
||||||
| 移动保本 | 支持(按 R 自动上移) | **保本移交**(结束计划→下单监控;交易所 TP+SL;**无**自动 R 保本) |
|
| 移动保本 | 支持(按 R 自动上移) | **保本移交**(结束计划→下单监控;交易所 TP+SL;**无**自动 R 保本) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 风险声明(必读)
|
## 5. 风险声明(必读)
|
||||||
|
|
||||||
- 市价单存在 **滑点**;极端行情下实际亏损可能 **大于** 理论 5%。
|
- 市价单存在 **滑点**;极端行情下实际亏损可能 **大于** 理论 5%。
|
||||||
- 补仓触发依赖应用 **轮询间隔**(`MONITOR_POLL_SECONDS`),非毫秒级高频。
|
- 补仓触发依赖应用 **轮询间隔**(`MONITOR_POLL_SECONDS`),非毫秒级高频。
|
||||||
- 交易所 **最小张数 / 精度** 可能导致计划张数被截断,实际风险略低于或偏离纸面计算。
|
- 交易所 **最小张数 / 精度** 可能导致计划张数被截断,实际风险略低于或偏离纸面计算。
|
||||||
- 请使用 **单独 API Key / 子账户**,并先在 `LIVE_TRADING_ENABLED=false` 环境验证流程(若需沙盒请自行对接测试网,本仓库默认实盘接口)。
|
- 请使用 **单独 API Key / 子账户**,并先在 `LIVE_TRADING_ENABLED=false` 环境验证流程(若需沙盒请自行对接测试网,本仓库默认实盘接口)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 相关环境变量
|
## 6. 相关环境变量
|
||||||
|
|
||||||
| 变量 | 说明 | 默认 |
|
| 变量 | 说明 | 默认 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT` | 手动保本默认偏移(相对持仓均价,%) | `0.3` |
|
| `TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT` | 手动保本默认偏移(相对持仓均价,%) | `0.3` |
|
||||||
| `TREND_PULLBACK_DCA_LEGS` | 剩余 50% 拆档数量上限 | `5` |
|
| `TREND_PULLBACK_DCA_LEGS` | 剩余 50% 拆档数量上限 | `5` |
|
||||||
| `TREND_PULLBACK_PREVIEW_TTL_SECONDS` | 预览有效时间(秒) | `120` |
|
| `TREND_PULLBACK_PREVIEW_TTL_SECONDS` | 预览有效时间(秒) | `120` |
|
||||||
| `TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT` | 确认执行时允许「当前可用 / 预览快照」最大相对偏差(%) | `5` |
|
| `TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT` | 确认执行时允许「当前可用 / 预览快照」最大相对偏差(%) | `5` |
|
||||||
| `MONITOR_POLL_SECONDS` | 监控轮询间隔(秒) | `3` |
|
| `MONITOR_POLL_SECONDS` | 监控轮询间隔(秒) | `3` |
|
||||||
| `LIVE_TRADING_ENABLED` | 是否允许真实下单 | `false` |
|
| `LIVE_TRADING_ENABLED` | 是否允许真实下单 | `false` |
|
||||||
| `FULL_MARGIN_BUFFER_RATIO` | 计划保证金相对可用余额上限比例 | `0.98` |
|
| `FULL_MARGIN_BUFFER_RATIO` | 计划保证金相对可用余额上限比例 | `0.98` |
|
||||||
| `APP_TIMEZONE` | 应用墙钟与「北京日期」同步起点时区(如 `Asia/Shanghai`) | `Asia/Shanghai` |
|
| `APP_TIMEZONE` | 应用墙钟与「北京日期」同步起点时区(如 `Asia/Shanghai`) | `Asia/Shanghai` |
|
||||||
| `EXCHANGE_POSITION_SYNC_FROM_BJ` | 拉取 Gate **平仓历史** 的最早日期(`YYYY-MM-DD`,按 `APP_TIMEZONE` 当日 **00:00** 起算)。**留空**则从近 **90 天** 起拉取 | 空 |
|
| `EXCHANGE_POSITION_SYNC_FROM_BJ` | 拉取 Gate **平仓历史** 的最早日期(`YYYY-MM-DD`,按 `APP_TIMEZONE` 当日 **00:00** 起算)。**留空**则从近 **90 天** 起拉取 | 空 |
|
||||||
| `EXCHANGE_POSITION_HISTORY_LIMIT` | 单次拉取平仓历史条数上限(50–1000) | `200` |
|
| `EXCHANGE_POSITION_HISTORY_LIMIT` | 单次拉取平仓历史条数上限(50–1000) | `200` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. 数据库
|
## 7. 数据库
|
||||||
|
|
||||||
- **`trend_pullback_previews`**:未执行的预览行(含 `expires_at_ms`),执行成功或取消后删除;过期可被清理。
|
- **`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`** 存补仓区间远侧边界价(做多=上沿、做空=下沿)。
|
- **`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)。
|
- **`trade_records`**(`monitor_type=趋势回调`):每次计划结束插入一行;含本地估算盈亏等。新写入行带 **`trend_plan_id`** 指向 `trend_pullback_plans.id`。另含 **`exchange_realized_pnl`、`exchange_opened_at`、`exchange_closed_at`、`exchange_sync_key`**,由页面触发的交易所平仓历史同步填充(见 3.5)。
|
||||||
|
|
||||||
**CSV 导出**:交易记录导出为 **v3**,包含上述交易所对齐字段及 `trend_plan_id`。
|
**CSV 导出**:交易记录导出为 **v3**,包含上述交易所对齐字段及 `trend_plan_id`。
|
||||||
@@ -1,160 +1,169 @@
|
|||||||
# Ubuntu 服务器部署与环境说明
|
# Ubuntu 服务器部署与环境说明
|
||||||
|
|
||||||
本文档为 **生产环境唯一推荐路径**:**Ubuntu**、**root** 用户、代码目录 **`/opt/crypto_monitor`**、进程托管 **PM2**。不使用 Windows 部署、不使用 systemd/screen/nohup 托管应用(SSH 隧道除外)。
|
本文档为 **生产环境唯一推荐路径**:**Ubuntu**、**root** 用户、代码目录 **`/opt/crypto_monitor`**、进程托管 **PM2**。不使用 Windows 部署、不使用 systemd/screen/nohup 托管应用(SSH 隧道除外)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 系统要求
|
## 1. 系统要求
|
||||||
|
|
||||||
| 项 | 要求 |
|
| 项 | 要求 |
|
||||||
|----|------|
|
|----|------|
|
||||||
| 操作系统 | **Ubuntu 22.04 LTS** 或 **24.04 LTS**(64 位) |
|
| 操作系统 | **Ubuntu 22.04 LTS** 或 **24.04 LTS**(64 位) |
|
||||||
| 运行用户 | **root**(下文命令均按 root 编写) |
|
| 运行用户 | **root**(下文命令均按 root 编写) |
|
||||||
| 项目路径 | **`/opt/crypto_monitor`**(整仓克隆到此目录) |
|
| 项目路径 | **`/opt/crypto_monitor`**(整仓克隆到此目录) |
|
||||||
| 进程管理 | **PM2**(全局安装,见 §3) |
|
| 进程管理 | **PM2**(全局安装,见 §3) |
|
||||||
| 网络 | 能 `git clone` 私有仓库;访问交易所不稳定时需 **SSH SOCKS**(见各所《部署文档》) |
|
| 网络 | 能 `git clone` 私有仓库;访问交易所不稳定时需 **SSH SOCKS**(见各所《部署文档》) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Python 环境
|
## 2. Python 环境
|
||||||
|
|
||||||
| 项 | 说明 |
|
| 项 | 说明 |
|
||||||
|----|------|
|
|----|------|
|
||||||
| **版本** | **Python 3.10 或 3.11**(`python3 --version` ≥ 3.10);脚本会拒绝 3.9 及以下 |
|
| **版本** | **Python 3.10 或 3.11**(`python3 --version` ≥ 3.10);脚本会拒绝 3.9 及以下 |
|
||||||
| **虚拟环境** | 每个子项目独立 **`.venv`**(`deploy/setup_env.sh` 自动创建) |
|
| **虚拟环境** | 每个子项目独立 **`.venv`**(`deploy/setup_env.sh` 自动创建) |
|
||||||
| **依赖文件** | 四所监控共用仓库根目录 **`requirements.txt`**;中控用 **`manual_trading_hub/requirements.txt`** |
|
| **依赖文件** | 三所监控共用仓库根目录 **`requirements.txt`**;中控用 **`manual_trading_hub/requirements.txt`** |
|
||||||
| **SOCKS** | 走代理时必须安装 **PySocks**(已写入 requirements) |
|
| **SOCKS** | 走代理时必须安装 **PySocks**(已写入 requirements) |
|
||||||
|
|
||||||
### 2.1 系统包(root)
|
### 2.1 系统包(root)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
apt update
|
apt update
|
||||||
apt install -y python3 python3-pip python3-venv curl git ca-certificates
|
apt install -y python3 python3-pip python3-venv curl git ca-certificates
|
||||||
# 若 python3 为 3.10:
|
# 若 python3 为 3.10:
|
||||||
apt install -y python3.10-venv
|
apt install -y python3.10-venv
|
||||||
# 若为 3.12:
|
# 若为 3.12:
|
||||||
apt install -y python3.12-venv
|
apt install -y python3.12-venv
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.2 一键创建各目录 venv
|
### 2.2 一键创建各目录 venv
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/crypto_monitor
|
cd /opt/crypto_monitor
|
||||||
bash deploy/setup_env.sh --install-system-deps
|
bash deploy/setup_env.sh --install-system-deps
|
||||||
# 或已是 root 且已装 venv 包:
|
# 或已是 root 且已装 venv 包:
|
||||||
bash deploy/setup_env.sh
|
bash deploy/setup_env.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
完成后各目录使用 **`.venv/bin/python`** 运行 `app.py` / `hub.py`;**PM2 的 ecosystem 脚本已指向该解释器**。
|
完成后各目录使用 **`.venv/bin/python`** 运行 `app.py` / `hub.py`;**PM2 的 ecosystem 脚本已指向该解释器**。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Node.js 与 PM2
|
## 3. Node.js 与 PM2
|
||||||
|
|
||||||
| 项 | 说明 |
|
| 项 | 说明 |
|
||||||
|----|------|
|
|----|------|
|
||||||
| **Node.js** | 建议 **18 LTS** 或 **20 LTS**(用于安装 PM2;应用本体为 Python) |
|
| **Node.js** | 建议 **18 LTS** 或 **20 LTS**(用于安装 PM2;应用本体为 Python) |
|
||||||
| **PM2** | 全局安装,托管所有 Flask 与中控/子代理 |
|
| **PM2** | 全局安装,托管所有 Flask 与中控/子代理 |
|
||||||
|
|
||||||
### 3.1 安装 Node + PM2(root)
|
### 3.1 安装 Node + PM2(root)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 方式 A:NodeSource(示例 Node 20)
|
# 方式 A:NodeSource(示例 Node 20)
|
||||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||||
apt install -y nodejs
|
apt install -y nodejs
|
||||||
node -v # v20.x
|
node -v # v20.x
|
||||||
npm -v
|
npm -v
|
||||||
|
|
||||||
npm install -g pm2
|
npm install -g pm2
|
||||||
pm2 -v
|
pm2 -v
|
||||||
pm2 startup # 按提示执行,保证重启后 PM2 自启
|
pm2 startup # 按提示执行,保证重启后 PM2 自启
|
||||||
```
|
```
|
||||||
|
|
||||||
`deploy/setup_env.sh` 在检测到 Node 时也会尝试 `npm install -g pm2`(未装 Node 则跳过并提示手动安装)。
|
`deploy/setup_env.sh` 在检测到 Node 时也会尝试 `npm install -g pm2`(未装 Node 则跳过并提示手动安装)。
|
||||||
|
|
||||||
### 3.2 PM2 启动顺序(推荐)
|
### 3.2 PM2 启动顺序(推荐)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1) 四所 Flask(在各子目录执行,或分别 start)
|
# 1) 三所 Flask(在各子目录执行,或分别 start)
|
||||||
cd /opt/crypto_monitor/crypto_monitor_binance && pm2 start ecosystem.config.cjs
|
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 && 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
|
||||||
cd /opt/crypto_monitor/crypto_monitor_okx && pm2 start ecosystem.config.cjs
|
|
||||||
|
# 2) 中控 + 三子代理(一条配置 4 进程:hub + 3 agent)
|
||||||
# 2) 中控 + 四子代理(一条配置 5 进程)
|
cd /opt/crypto_monitor/manual_trading_hub
|
||||||
cd /opt/crypto_monitor/manual_trading_hub
|
pm2 start ecosystem.config.cjs
|
||||||
pm2 start ecosystem.config.cjs
|
|
||||||
|
pm2 save
|
||||||
pm2 save
|
pm2 list
|
||||||
pm2 list
|
```
|
||||||
```
|
|
||||||
|
升级代码后:
|
||||||
升级代码后:
|
|
||||||
|
```bash
|
||||||
```bash
|
cd /opt/crypto_monitor && git pull
|
||||||
cd /opt/crypto_monitor && git pull
|
# 若 requirements 有变,对各目录 .venv/bin/pip install -r ...
|
||||||
# 若 requirements 有变,对各目录 .venv/bin/pip install -r ...
|
pm2 restart all # 或按进程名 restart
|
||||||
pm2 restart all # 或按进程名 restart
|
```
|
||||||
```
|
|
||||||
|
**不要** 再用 systemd unit、screen、nohup 启动 `app.py` / `hub.py` / `agent.py`,避免与 PM2 抢端口。
|
||||||
**不要** 再用 systemd unit、screen、nohup 启动 `app.py` / `hub.py` / `agent.py`,避免与 PM2 抢端口。
|
|
||||||
|
### 3.3 常见 PM2 进程名
|
||||||
### 3.3 常见 PM2 进程名
|
|
||||||
|
| 目录 | ecosystem 内典型名称 |
|
||||||
| 目录 | ecosystem 内典型名称 |
|
|------|---------------------|
|
||||||
|------|---------------------|
|
| `crypto_monitor_binance` | `crypto_binance` |
|
||||||
| `crypto_monitor_binance` | `crypto-monitor-binance` |
|
| `crypto_monitor_gate` | `crypto_gate` |
|
||||||
| `crypto_monitor_gate` | `crypto-monitor-gate` |
|
| `crypto_monitor_okx` | `crypto_okx` |
|
||||||
| `crypto_monitor_gate_bot` | `crypto-monitor-gate-bot` |
|
| `manual_trading_hub` | `manual-trading-hub`、`manual-agent-*` |
|
||||||
| `crypto_monitor_okx` | `crypto-monitor-okx` |
|
|
||||||
| `manual_trading_hub` | `manual-trading-hub`、`manual-agent-*` |
|
以各目录 **`ecosystem.config.cjs`** 为准。
|
||||||
|
|
||||||
以各目录 **`ecosystem.config.cjs`** 为准。
|
### 3.4 整目录重装(清库 / 去脏 PM2)
|
||||||
|
|
||||||
---
|
保留 `.env`、丢弃旧库与旧 PM2 名单时,见 **[deploy/reinstall-plan-b.md](../deploy/reinstall-plan-b.md)**:
|
||||||
|
|
||||||
## 4. 目录与权限
|
```bash
|
||||||
|
cd /opt/crypto_monitor
|
||||||
```bash
|
bash deploy/reinstall.sh --yes
|
||||||
mkdir -p /opt
|
```
|
||||||
cd /opt
|
|
||||||
git clone https://git.bz121.com/dekun/crypto_monitor.git crypto_monitor
|
首次安装仍只用 `deploy/setup_env.sh`,二者互不影响。
|
||||||
chown -R root:root /opt/crypto_monitor
|
|
||||||
```
|
---
|
||||||
|
|
||||||
- 数据库默认:各所 **`crypto.db`**(SQLite)
|
## 4. 目录与权限
|
||||||
- 备份目录建议:**`/root/backups`**(见 [备份与恢复.md](../备份与恢复.md))
|
|
||||||
- **`.env`**:仅本机编辑,**勿提交 Git**;升级前 `cp .env .env.backup.$(date +%Y%m%d)`
|
```bash
|
||||||
|
mkdir -p /opt
|
||||||
---
|
cd /opt
|
||||||
|
git clone https://git.bz121.com/dekun/crypto_monitor.git crypto_monitor
|
||||||
## 5. SSH 动态转发(SOCKS)
|
chown -R root:root /opt/crypto_monitor
|
||||||
|
```
|
||||||
若交易所 API 需经境外 VPS:
|
|
||||||
|
- 数据库默认:各所 **`crypto.db`**(SQLite)
|
||||||
- 在本机用 **`ssh -N -D 127.0.0.1:1080 别名`** 建立隧道(配置见各所《部署文档》`~/.ssh/config`)
|
- 备份目录建议:**`/root/backups`**(见 [备份与恢复.md](../备份与恢复.md))
|
||||||
- 隧道进程可用 **tmux** 或 **autossh** 保持常驻;**不必** 也不建议把 `ssh` 交给 PM2
|
- **`.env`**:仅本机编辑,**勿提交 Git**;升级前 `cp .env .env.backup.$(date +%Y%m%d)`
|
||||||
- 各所 `.env` 设置对应 `*_SOCKS_PROXY=socks5h://127.0.0.1:1080`
|
|
||||||
|
---
|
||||||
---
|
|
||||||
|
## 5. SSH 动态转发(SOCKS)
|
||||||
## 6. 部署后检查
|
|
||||||
|
若交易所 API 需经境外 VPS:
|
||||||
```bash
|
|
||||||
# 中控验收(需已 start hub)
|
- 在本机用 **`ssh -N -D 127.0.0.1:1080 别名`** 建立隧道(配置见各所《部署文档》`~/.ssh/config`)
|
||||||
bash /opt/crypto_monitor/manual_trading_hub/scripts/verify_hub_deploy.sh
|
- 隧道进程可用 **tmux** 或 **autossh** 保持常驻;**不必** 也不建议把 `ssh` 交给 PM2
|
||||||
|
- 各所 `.env` 设置对应 `*_SOCKS_PROXY=socks5h://127.0.0.1:1080`
|
||||||
pm2 logs manual-trading-hub --lines 50
|
|
||||||
curl -sS http://127.0.0.1:5100/api/monitor/board | head
|
---
|
||||||
```
|
|
||||||
|
## 6. 部署后检查
|
||||||
---
|
|
||||||
|
```bash
|
||||||
## 7. 相关文档
|
# 中控验收(需已 start hub)
|
||||||
|
bash /opt/crypto_monitor/manual_trading_hub/scripts/verify_hub_deploy.sh
|
||||||
| 文档 | 内容 |
|
|
||||||
|------|------|
|
pm2 logs manual-trading-hub --lines 50
|
||||||
| [deploy/README.md](../deploy/README.md) | `setup_env.sh` 参数说明 |
|
curl -sS http://127.0.0.1:5100/api/monitor/board | head
|
||||||
| [备份与恢复.md](../备份与恢复.md) | 数据库与 `.env` 备份 |
|
```
|
||||||
| 各 `crypto_monitor_*/部署文档.md` | 交易所 SOCKS、`.env`、PM2 细节 |
|
|
||||||
| [manual_trading_hub/部署文档.md](../manual_trading_hub/部署文档.md) | 中控 PM2、端口、反代 |
|
---
|
||||||
|
|
||||||
|
## 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、端口、反代 |
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""crypto_monitor shared libraries."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Shared library package."""
|
||||||
@@ -1,180 +1,180 @@
|
|||||||
"""AI 日复盘 / 周复盘:附图收集与 journal 文本格式化(四所共用)。"""
|
"""AI 日复盘 / 周复盘:附图收集与 journal 文本格式化(三所共用)。"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any, Callable, List, Mapping, Optional, Sequence
|
from typing import Any, Callable, List, Mapping, Optional, Sequence
|
||||||
|
|
||||||
from journal_chart_lib import (
|
from lib.instance.journal_chart_lib import (
|
||||||
JOURNAL_CHART_ANCHOR_CLOSE,
|
JOURNAL_CHART_ANCHOR_CLOSE,
|
||||||
JOURNAL_CHART_DEFAULT_LIMIT,
|
JOURNAL_CHART_DEFAULT_LIMIT,
|
||||||
JOURNAL_CHART_DEFAULT_TF1,
|
JOURNAL_CHART_DEFAULT_TF1,
|
||||||
JOURNAL_CHART_DEFAULT_TF2,
|
JOURNAL_CHART_DEFAULT_TF2,
|
||||||
normalize_chart_timeframe,
|
normalize_chart_timeframe,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _journal_nz(v: Any, default: str = "无") -> str:
|
def _journal_nz(v: Any, default: str = "无") -> str:
|
||||||
if v is None:
|
if v is None:
|
||||||
return default
|
return default
|
||||||
s = str(v).strip()
|
s = str(v).strip()
|
||||||
return s if s else default
|
return s if s else default
|
||||||
|
|
||||||
|
|
||||||
def _row_get(row: Any, key: str, default: Any = None) -> Any:
|
def _row_get(row: Any, key: str, default: Any = None) -> Any:
|
||||||
"""兼容 dict 与 sqlite3.Row(Row 无 .get 方法)。"""
|
"""兼容 dict 与 sqlite3.Row(Row 无 .get 方法)。"""
|
||||||
if row is None:
|
if row is None:
|
||||||
return default
|
return default
|
||||||
getter = getattr(row, "get", None)
|
getter = getattr(row, "get", None)
|
||||||
if callable(getter):
|
if callable(getter):
|
||||||
return getter(key, default)
|
return getter(key, default)
|
||||||
try:
|
try:
|
||||||
keys = row.keys() if hasattr(row, "keys") else ()
|
keys = row.keys() if hasattr(row, "keys") else ()
|
||||||
if key in keys:
|
if key in keys:
|
||||||
return row[key]
|
return row[key]
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
return row[key]
|
return row[key]
|
||||||
except (KeyError, TypeError, IndexError):
|
except (KeyError, TypeError, IndexError):
|
||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
def journal_row_lines_for_ai(
|
def journal_row_lines_for_ai(
|
||||||
idx: int,
|
idx: int,
|
||||||
row: Any,
|
row: Any,
|
||||||
*,
|
*,
|
||||||
include_hold_duration: bool = True,
|
include_hold_duration: bool = True,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""把 journal 字段拼成给 AI 的文本;四所日复盘/周复盘共用。"""
|
"""把 journal 字段拼成给 AI 的文本;三所日复盘/周复盘共用。"""
|
||||||
lines = [
|
lines = [
|
||||||
(
|
(
|
||||||
f"{idx}. {_journal_nz(_row_get(row, 'coin'))} {_journal_nz(_row_get(row, 'tf'))} "
|
f"{idx}. {_journal_nz(_row_get(row, 'coin'))} {_journal_nz(_row_get(row, 'tf'))} "
|
||||||
f"| 盈亏:{_journal_nz(_row_get(row, 'pnl'))}U "
|
f"| 盈亏:{_journal_nz(_row_get(row, 'pnl'))}U "
|
||||||
f"| 实际RR:{_journal_nz(_row_get(row, 'real_rr'))} "
|
f"| 实际RR:{_journal_nz(_row_get(row, 'real_rr'))} "
|
||||||
f"| 预期RR:{_journal_nz(_row_get(row, 'expect_rr'))}"
|
f"| 预期RR:{_journal_nz(_row_get(row, 'expect_rr'))}"
|
||||||
),
|
),
|
||||||
f" 开仓逻辑:{_journal_nz(_row_get(row, 'entry_reason'))}",
|
f" 开仓逻辑:{_journal_nz(_row_get(row, 'entry_reason'))}",
|
||||||
f" 平仓/离场(交易员自述):{_journal_nz(_row_get(row, 'exit_reason'))}",
|
f" 平仓/离场(交易员自述):{_journal_nz(_row_get(row, 'exit_reason'))}",
|
||||||
]
|
]
|
||||||
if include_hold_duration:
|
if include_hold_duration:
|
||||||
lines.append(f" 持仓时长:{_journal_nz(_row_get(row, 'hold_duration'))}")
|
lines.append(f" 持仓时长:{_journal_nz(_row_get(row, 'hold_duration'))}")
|
||||||
ee_bits = [
|
ee_bits = [
|
||||||
_journal_nz(_row_get(row, "early_exit")),
|
_journal_nz(_row_get(row, "early_exit")),
|
||||||
_journal_nz(_row_get(row, "early_exit_reason")),
|
_journal_nz(_row_get(row, "early_exit_reason")),
|
||||||
_journal_nz(_row_get(row, "early_exit_trigger")),
|
_journal_nz(_row_get(row, "early_exit_trigger")),
|
||||||
_journal_nz(_row_get(row, "early_exit_note")),
|
_journal_nz(_row_get(row, "early_exit_note")),
|
||||||
]
|
]
|
||||||
if any(x != "无" for x in ee_bits):
|
if any(x != "无" for x in ee_bits):
|
||||||
lines.append(
|
lines.append(
|
||||||
" 提前离场记录:"
|
" 提前离场记录:"
|
||||||
f"{ee_bits[0]} | 原因:{ee_bits[1]} | 触发:{ee_bits[2]} | 备注:{ee_bits[3]}"
|
f"{ee_bits[0]} | 原因:{ee_bits[1]} | 触发:{ee_bits[2]} | 备注:{ee_bits[3]}"
|
||||||
)
|
)
|
||||||
mood_bits = f"心态标签:{_journal_nz(_row_get(row, 'mood_issues'))}"
|
mood_bits = f"心态标签:{_journal_nz(_row_get(row, 'mood_issues'))}"
|
||||||
mood_score = _row_get(row, "mood_score")
|
mood_score = _row_get(row, "mood_score")
|
||||||
if mood_score is not None:
|
if mood_score is not None:
|
||||||
mood_bits += f" | 自评心态分:{mood_score}"
|
mood_bits += f" | 自评心态分:{mood_score}"
|
||||||
lines.append(f" {mood_bits}")
|
lines.append(f" {mood_bits}")
|
||||||
if _journal_nz(_row_get(row, "post_breakeven_stare")) != "无":
|
if _journal_nz(_row_get(row, "post_breakeven_stare")) != "无":
|
||||||
lines.append(f" 保本后盯盘:{_journal_nz(_row_get(row, 'post_breakeven_stare'))}")
|
lines.append(f" 保本后盯盘:{_journal_nz(_row_get(row, 'post_breakeven_stare'))}")
|
||||||
if _journal_nz(_row_get(row, "new_trade_while_occupied")) != "无":
|
if _journal_nz(_row_get(row, "new_trade_while_occupied")) != "无":
|
||||||
lines.append(f" 占用时新开仓:{_journal_nz(_row_get(row, 'new_trade_while_occupied'))}")
|
lines.append(f" 占用时新开仓:{_journal_nz(_row_get(row, 'new_trade_while_occupied'))}")
|
||||||
if _journal_nz(_row_get(row, "note")) != "无":
|
if _journal_nz(_row_get(row, "note")) != "无":
|
||||||
lines.append(f" 备注:{_journal_nz(_row_get(row, 'note'))}")
|
lines.append(f" 备注:{_journal_nz(_row_get(row, 'note'))}")
|
||||||
return "\n".join(lines) + "\n"
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
def collect_images_for_ai_review(
|
def collect_images_for_ai_review(
|
||||||
rows: Sequence,
|
rows: Sequence,
|
||||||
upload_folder: str,
|
upload_folder: str,
|
||||||
*,
|
*,
|
||||||
build_chart_if_missing: Optional[Callable] = None,
|
build_chart_if_missing: Optional[Callable] = None,
|
||||||
) -> List[str]:
|
) -> List[str]:
|
||||||
"""
|
"""
|
||||||
收集传给视觉模型的本地图片路径。
|
收集传给视觉模型的本地图片路径。
|
||||||
- 优先 journal_entries.image 已存附图;
|
- 优先 journal_entries.image 已存附图;
|
||||||
- 若无附图且提供 build_chart_if_missing,则临时生成 K 线图。
|
- 若无附图且提供 build_chart_if_missing,则临时生成 K 线图。
|
||||||
"""
|
"""
|
||||||
paths: List[str] = []
|
paths: List[str] = []
|
||||||
seen = set()
|
seen = set()
|
||||||
upload_folder = os.path.abspath(upload_folder or "")
|
upload_folder = os.path.abspath(upload_folder or "")
|
||||||
for row in rows or []:
|
for row in rows or []:
|
||||||
candidate = None
|
candidate = None
|
||||||
try:
|
try:
|
||||||
keys = row.keys() if hasattr(row, "keys") else []
|
keys = row.keys() if hasattr(row, "keys") else []
|
||||||
except Exception:
|
except Exception:
|
||||||
keys = []
|
keys = []
|
||||||
img = row["image"] if "image" in keys else None
|
img = row["image"] if "image" in keys else None
|
||||||
if img:
|
if img:
|
||||||
candidate = os.path.join(upload_folder, str(img).strip())
|
candidate = os.path.join(upload_folder, str(img).strip())
|
||||||
elif build_chart_if_missing:
|
elif build_chart_if_missing:
|
||||||
try:
|
try:
|
||||||
candidate = build_chart_if_missing(row)
|
candidate = build_chart_if_missing(row)
|
||||||
except Exception:
|
except Exception:
|
||||||
candidate = None
|
candidate = None
|
||||||
if not candidate:
|
if not candidate:
|
||||||
continue
|
continue
|
||||||
candidate = os.path.abspath(candidate)
|
candidate = os.path.abspath(candidate)
|
||||||
if os.path.isfile(candidate) and candidate not in seen:
|
if os.path.isfile(candidate) and candidate not in seen:
|
||||||
seen.add(candidate)
|
seen.add(candidate)
|
||||||
paths.append(candidate)
|
paths.append(candidate)
|
||||||
return paths
|
return paths
|
||||||
|
|
||||||
|
|
||||||
def build_journal_ai_chart_path(
|
def build_journal_ai_chart_path(
|
||||||
row,
|
row,
|
||||||
upload_folder: str,
|
upload_folder: str,
|
||||||
*,
|
*,
|
||||||
order_chart_enabled: bool,
|
order_chart_enabled: bool,
|
||||||
normalize_exchange_symbol_fn: Callable[[str], str],
|
normalize_exchange_symbol_fn: Callable[[str], str],
|
||||||
generate_chart_fn: Callable,
|
generate_chart_fn: Callable,
|
||||||
local_datetime_to_ms_fn: Callable[[str], Optional[int]],
|
local_datetime_to_ms_fn: Callable[[str], Optional[int]],
|
||||||
now_ts_ms_fn: Callable[[], int],
|
now_ts_ms_fn: Callable[[], int],
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""无已存附图时,按复盘记录开平仓时间临时生成 K 线图路径。"""
|
"""无已存附图时,按复盘记录开平仓时间临时生成 K 线图路径。"""
|
||||||
if not order_chart_enabled:
|
if not order_chart_enabled:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
keys = row.keys() if hasattr(row, "keys") else []
|
keys = row.keys() if hasattr(row, "keys") else []
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
coin = (row["coin"] if "coin" in keys else "") or ""
|
coin = (row["coin"] if "coin" in keys else "") or ""
|
||||||
coin = str(coin).strip()
|
coin = str(coin).strip()
|
||||||
if not coin:
|
if not coin:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
symbol = normalize_exchange_symbol_fn(coin)
|
symbol = normalize_exchange_symbol_fn(coin)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
open_dt = row["open_datetime"] if "open_datetime" in keys else ""
|
open_dt = row["open_datetime"] if "open_datetime" in keys else ""
|
||||||
close_dt = row["close_datetime"] if "close_datetime" in keys else ""
|
close_dt = row["close_datetime"] if "close_datetime" in keys else ""
|
||||||
entry_ms = local_datetime_to_ms_fn(open_dt)
|
entry_ms = local_datetime_to_ms_fn(open_dt)
|
||||||
exit_ms = local_datetime_to_ms_fn(close_dt)
|
exit_ms = local_datetime_to_ms_fn(close_dt)
|
||||||
if not entry_ms:
|
if not entry_ms:
|
||||||
return None
|
return None
|
||||||
row_tf = row["tf"] if "tf" in keys else ""
|
row_tf = row["tf"] if "tf" in keys else ""
|
||||||
tf1 = normalize_chart_timeframe(row_tf) or JOURNAL_CHART_DEFAULT_TF1
|
tf1 = normalize_chart_timeframe(row_tf) or JOURNAL_CHART_DEFAULT_TF1
|
||||||
tf2 = JOURNAL_CHART_DEFAULT_TF2 if tf1 != JOURNAL_CHART_DEFAULT_TF2 else "1h"
|
tf2 = JOURNAL_CHART_DEFAULT_TF2 if tf1 != JOURNAL_CHART_DEFAULT_TF2 else "1h"
|
||||||
row_id = str(row["id"] if "id" in keys else "")[:8] or uuid.uuid4().hex[:8]
|
row_id = str(row["id"] if "id" in keys else "")[:8] or uuid.uuid4().hex[:8]
|
||||||
marker = {
|
marker = {
|
||||||
"entry_ts_ms": entry_ms,
|
"entry_ts_ms": entry_ms,
|
||||||
"exit_ts_ms": exit_ms,
|
"exit_ts_ms": exit_ms,
|
||||||
"chart_anchor": JOURNAL_CHART_ANCHOR_CLOSE,
|
"chart_anchor": JOURNAL_CHART_ANCHOR_CLOSE,
|
||||||
"now_ts_ms": int(now_ts_ms_fn()),
|
"now_ts_ms": int(now_ts_ms_fn()),
|
||||||
}
|
}
|
||||||
fname = f"ai_rev_{row_id}_{uuid.uuid4().hex[:6]}.png"
|
fname = f"ai_rev_{row_id}_{uuid.uuid4().hex[:6]}.png"
|
||||||
saved = generate_chart_fn(
|
saved = generate_chart_fn(
|
||||||
symbol,
|
symbol,
|
||||||
f"AI复盘 {coin}",
|
f"AI复盘 {coin}",
|
||||||
timeframes=[tf1, tf2],
|
timeframes=[tf1, tf2],
|
||||||
limit=JOURNAL_CHART_DEFAULT_LIMIT,
|
limit=JOURNAL_CHART_DEFAULT_LIMIT,
|
||||||
out_dir=upload_folder,
|
out_dir=upload_folder,
|
||||||
filename=fname,
|
filename=fname,
|
||||||
marker_payload=marker,
|
marker_payload=marker,
|
||||||
marker_timeframes={tf1, tf2},
|
marker_timeframes={tf1, tf2},
|
||||||
layout="vertical",
|
layout="vertical",
|
||||||
)
|
)
|
||||||
if not saved:
|
if not saved:
|
||||||
return None
|
return None
|
||||||
path = os.path.join(upload_folder, saved)
|
path = os.path.join(upload_folder, saved)
|
||||||
return path if os.path.isfile(path) else None
|
return path if os.path.isfile(path) else None
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Shared library package."""
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/* 账户风控状态徽章 — 三所实例 + 中控共用;兼容 data-theme light/dark */
|
||||||
|
|
||||||
|
:root,
|
||||||
|
html[data-theme="dark"] {
|
||||||
|
--risk-normal-fg: #9cf0c4;
|
||||||
|
--risk-normal-bg: rgba(36, 140, 96, 0.16);
|
||||||
|
--risk-normal-border: rgba(72, 190, 130, 0.42);
|
||||||
|
--risk-normal-glow: rgba(72, 190, 130, 0.35);
|
||||||
|
|
||||||
|
--risk-1h-fg: #ffd27a;
|
||||||
|
--risk-1h-bg: rgba(210, 150, 40, 0.16);
|
||||||
|
--risk-1h-border: rgba(230, 170, 60, 0.45);
|
||||||
|
--risk-1h-glow: rgba(230, 170, 60, 0.32);
|
||||||
|
|
||||||
|
--risk-4h-fg: #ffab8a;
|
||||||
|
--risk-4h-bg: rgba(210, 90, 55, 0.16);
|
||||||
|
--risk-4h-border: rgba(230, 110, 70, 0.48);
|
||||||
|
--risk-4h-glow: rgba(230, 110, 70, 0.34);
|
||||||
|
|
||||||
|
--risk-daily-fg: #ff9ec4;
|
||||||
|
--risk-daily-bg: rgba(190, 55, 100, 0.18);
|
||||||
|
--risk-daily-border: rgba(210, 75, 120, 0.5);
|
||||||
|
--risk-daily-glow: rgba(210, 75, 120, 0.36);
|
||||||
|
|
||||||
|
--risk-position-fg: #8ec8ff;
|
||||||
|
--risk-position-bg: rgba(55, 120, 210, 0.18);
|
||||||
|
--risk-position-border: rgba(75, 145, 230, 0.48);
|
||||||
|
--risk-position-glow: rgba(75, 145, 230, 0.34);
|
||||||
|
|
||||||
|
--risk-badge-shadow: 0 1px 2px rgba(0, 0, 0, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] {
|
||||||
|
--risk-normal-fg: #056b44;
|
||||||
|
--risk-normal-bg: rgba(10, 143, 92, 0.14);
|
||||||
|
--risk-normal-border: rgba(8, 122, 80, 0.38);
|
||||||
|
--risk-normal-glow: rgba(10, 143, 92, 0.22);
|
||||||
|
|
||||||
|
--risk-1h-fg: #8a5a00;
|
||||||
|
--risk-1h-bg: rgba(200, 140, 20, 0.14);
|
||||||
|
--risk-1h-border: rgba(170, 115, 10, 0.38);
|
||||||
|
--risk-1h-glow: rgba(200, 140, 20, 0.2);
|
||||||
|
|
||||||
|
--risk-4h-fg: #a83812;
|
||||||
|
--risk-4h-bg: rgba(210, 85, 35, 0.12);
|
||||||
|
--risk-4h-border: rgba(180, 65, 25, 0.36);
|
||||||
|
--risk-4h-glow: rgba(210, 85, 35, 0.2);
|
||||||
|
|
||||||
|
--risk-daily-fg: #9a1248;
|
||||||
|
--risk-daily-bg: rgba(180, 35, 80, 0.1);
|
||||||
|
--risk-daily-border: rgba(155, 28, 68, 0.34);
|
||||||
|
--risk-daily-glow: rgba(180, 35, 80, 0.18);
|
||||||
|
|
||||||
|
--risk-position-fg: #0b5cab;
|
||||||
|
--risk-position-bg: rgba(20, 100, 190, 0.12);
|
||||||
|
--risk-position-border: rgba(15, 85, 165, 0.36);
|
||||||
|
--risk-position-glow: rgba(20, 100, 190, 0.2);
|
||||||
|
|
||||||
|
--risk-badge-shadow: 0 1px 2px rgba(20, 50, 80, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
line-height: 1.15;
|
||||||
|
padding: 5px 12px 5px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--risk-border, transparent);
|
||||||
|
background: var(--risk-bg, transparent);
|
||||||
|
color: var(--risk-fg, inherit);
|
||||||
|
box-shadow: var(--risk-badge-shadow);
|
||||||
|
white-space: nowrap;
|
||||||
|
vertical-align: middle;
|
||||||
|
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 中控 iframe 内切页:避免徽章过渡动画造成 header 闪动 */
|
||||||
|
html[data-hub-linked="1"] .header-row .risk-status-badge {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-status-badge::before {
|
||||||
|
content: "";
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 0 0 1px color-mix(in srgb, currentColor 30%, transparent),
|
||||||
|
0 0 8px var(--risk-glow, currentColor);
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-status-normal {
|
||||||
|
--risk-fg: var(--risk-normal-fg);
|
||||||
|
--risk-bg: var(--risk-normal-bg);
|
||||||
|
--risk-border: var(--risk-normal-border);
|
||||||
|
--risk-glow: var(--risk-normal-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-status-freeze_1h {
|
||||||
|
--risk-fg: var(--risk-1h-fg);
|
||||||
|
--risk-bg: var(--risk-1h-bg);
|
||||||
|
--risk-border: var(--risk-1h-border);
|
||||||
|
--risk-glow: var(--risk-1h-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-status-freeze_4h {
|
||||||
|
--risk-fg: var(--risk-4h-fg);
|
||||||
|
--risk-bg: var(--risk-4h-bg);
|
||||||
|
--risk-border: var(--risk-4h-border);
|
||||||
|
--risk-glow: var(--risk-4h-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-status-freeze_daily {
|
||||||
|
--risk-fg: var(--risk-daily-fg);
|
||||||
|
--risk-bg: var(--risk-daily-bg);
|
||||||
|
--risk-border: var(--risk-daily-border);
|
||||||
|
--risk-glow: var(--risk-daily-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-status-freeze_position {
|
||||||
|
--risk-fg: var(--risk-position-fg);
|
||||||
|
--risk-bg: var(--risk-position-bg);
|
||||||
|
--risk-border: var(--risk-position-border);
|
||||||
|
--risk-glow: var(--risk-position-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 实例页:与交易所标签并排 */
|
||||||
|
.header-row .risk-status-badge {
|
||||||
|
min-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 中控卡片标题内 */
|
||||||
|
.card-title .risk-status-badge,
|
||||||
|
.hub-tile-name .risk-status-badge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 3px 10px 3px 8px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title .risk-status-badge::before,
|
||||||
|
.hub-tile-name .risk-status-badge::before {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* 账户风控徽章倒计时 — 三所实例 + 中控共用。
|
||||||
|
*/
|
||||||
|
(function (global) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
function formatRemaining(totalSec) {
|
||||||
|
const sec = Math.max(0, Math.floor(Number(totalSec) || 0));
|
||||||
|
if (sec <= 0) return "";
|
||||||
|
const h = Math.floor(sec / 3600);
|
||||||
|
const m = Math.floor((sec % 3600) / 60);
|
||||||
|
const s = sec % 60;
|
||||||
|
if (h > 0) return `${h}h ${String(m).padStart(2, "0")}m`;
|
||||||
|
if (m > 0) return `${m}m ${String(s).padStart(2, "0")}s`;
|
||||||
|
return `${s}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseLabel(riskStatus, el) {
|
||||||
|
if (riskStatus && riskStatus.status_label) return String(riskStatus.status_label);
|
||||||
|
if (el && el.dataset && el.dataset.statusLabel) return String(el.dataset.statusLabel);
|
||||||
|
return "正常";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFreezeUntilMs(riskStatus) {
|
||||||
|
if (!riskStatus) return null;
|
||||||
|
const sec = Number(riskStatus.freeze_remaining_sec);
|
||||||
|
if (Number.isFinite(sec) && sec > 0) {
|
||||||
|
return Date.now() + sec * 1000;
|
||||||
|
}
|
||||||
|
const until = Number(riskStatus.freeze_until_ms);
|
||||||
|
return Number.isFinite(until) && until > 0 ? until : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function badgeText(riskStatus) {
|
||||||
|
const label = baseLabel(riskStatus, null);
|
||||||
|
const until = resolveFreezeUntilMs(riskStatus);
|
||||||
|
if (!until || until <= Date.now()) return label;
|
||||||
|
const cd = formatRemaining((until - Date.now()) / 1000);
|
||||||
|
return cd ? `${label} · ${cd}` : label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNormalBadge(el) {
|
||||||
|
el.className = "risk-status-badge risk-status-normal";
|
||||||
|
el.dataset.statusLabel = "正常";
|
||||||
|
el.textContent = "正常";
|
||||||
|
el.title = "";
|
||||||
|
if (el.dataset) delete el.dataset.freezeUntilMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshElement(el) {
|
||||||
|
if (!el) return;
|
||||||
|
const label = baseLabel(null, el);
|
||||||
|
const until = Number(el.dataset && el.dataset.freezeUntilMs);
|
||||||
|
if (!Number.isFinite(until) || until <= Date.now()) {
|
||||||
|
if (el.dataset && el.dataset.freezeUntilMs) {
|
||||||
|
setNormalBadge(el);
|
||||||
|
} else {
|
||||||
|
el.textContent = label;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cd = formatRemaining((until - Date.now()) / 1000);
|
||||||
|
el.textContent = cd ? `${label} · ${cd}` : label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyToElement(el, riskStatus) {
|
||||||
|
if (!el || !riskStatus) return;
|
||||||
|
const st = riskStatus.status || "normal";
|
||||||
|
el.className = "risk-status-badge risk-status-" + st;
|
||||||
|
el.dataset.statusLabel = baseLabel(riskStatus, el);
|
||||||
|
const until = resolveFreezeUntilMs(riskStatus);
|
||||||
|
if (until) {
|
||||||
|
el.dataset.freezeUntilMs = String(until);
|
||||||
|
} else if (el.dataset) {
|
||||||
|
delete el.dataset.freezeUntilMs;
|
||||||
|
}
|
||||||
|
el.textContent = badgeText(riskStatus);
|
||||||
|
el.title = riskStatus.reason || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBadgeHtml(riskStatus, esc) {
|
||||||
|
if (!riskStatus || typeof riskStatus !== "object") return "";
|
||||||
|
const safe = typeof esc === "function" ? esc : (s) => String(s);
|
||||||
|
const st = riskStatus.status || "normal";
|
||||||
|
const label = safe(riskStatus.status_label || "正常");
|
||||||
|
const title = safe(riskStatus.reason || "");
|
||||||
|
const text = safe(badgeText(riskStatus));
|
||||||
|
const until = resolveFreezeUntilMs(riskStatus);
|
||||||
|
const untilAttr =
|
||||||
|
until != null
|
||||||
|
? ` data-freeze-until-ms="${safe(String(Math.floor(until)))}"`
|
||||||
|
: "";
|
||||||
|
return (
|
||||||
|
`<span class="risk-status-badge risk-status-${safe(st)}" role="status"` +
|
||||||
|
` title="${title}" data-status-label="${label}"${untilAttr}>${text}</span>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
/**
|
||||||
|
* 中控 iframe 壳:顶栏/统计常驻,tab 内容走 /api/embed/page/<tab>。
|
||||||
|
*/
|
||||||
|
(function (global) {
|
||||||
|
const TAB_PATH = {
|
||||||
|
key_monitor: "/key_monitor",
|
||||||
|
trade: "/trade",
|
||||||
|
strategy: "/strategy",
|
||||||
|
strategy_records: "/strategy/records",
|
||||||
|
records: "/records",
|
||||||
|
stats: "/stats",
|
||||||
|
};
|
||||||
|
|
||||||
|
let navToken = 0;
|
||||||
|
let loadingTab = false;
|
||||||
|
|
||||||
|
/** 自带校验后 form.submit() 的表单,勿在捕获阶段再 fetch 一份(会双发 POST) */
|
||||||
|
const CUSTOM_SUBMIT_FORM_IDS = new Set(["add-order-form", "key-form", "roll-form"]);
|
||||||
|
|
||||||
|
function isEmbedShell() {
|
||||||
|
return document.body && document.body.getAttribute("data-embed-shell") === "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTab() {
|
||||||
|
try {
|
||||||
|
const t = new URLSearchParams(location.search).get("tab");
|
||||||
|
if (t) return t;
|
||||||
|
} catch (_) {}
|
||||||
|
return document.body.getAttribute("data-page") || "trade";
|
||||||
|
}
|
||||||
|
|
||||||
|
function listWindowQueryString() {
|
||||||
|
if (typeof global.listWindowQueryString === "function") {
|
||||||
|
return global.listWindowQueryString();
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRootLoading(on) {
|
||||||
|
const root = document.getElementById("embed-page-root");
|
||||||
|
if (root) root.classList.toggle("is-embed-tab-loading", !!on);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNavActive(tab) {
|
||||||
|
document.querySelectorAll(".embed-top-nav [data-embed-tab]").forEach((a) => {
|
||||||
|
a.classList.toggle("active", a.getAttribute("data-embed-tab") === tab);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncUrl(tab, replace) {
|
||||||
|
const q = new URLSearchParams(location.search);
|
||||||
|
q.set("tab", tab);
|
||||||
|
q.set("embed", "1");
|
||||||
|
const qs = q.toString();
|
||||||
|
const url = "/embed?" + qs;
|
||||||
|
if (replace) history.replaceState({ embedTab: tab }, "", url);
|
||||||
|
else history.pushState({ embedTab: tab }, "", url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runPageInit(tab) {
|
||||||
|
document.body.setAttribute("data-page", tab);
|
||||||
|
if (typeof global.attachListWindowToExports === "function") {
|
||||||
|
global.attachListWindowToExports();
|
||||||
|
}
|
||||||
|
if (tab === "trade") {
|
||||||
|
if (typeof global.refreshOrderDefaults === "function") global.refreshOrderDefaults();
|
||||||
|
if (global.ManualOrderRrPreview && typeof global.ManualOrderRrPreview.wire === "function") {
|
||||||
|
global.ManualOrderRrPreview.wire();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tab === "key_monitor" && global.KeyMonitorForm && typeof global.KeyMonitorForm.init === "function") {
|
||||||
|
global.KeyMonitorForm.init();
|
||||||
|
}
|
||||||
|
if (tab === "strategy" && typeof global.initStrategyRollForm === "function") {
|
||||||
|
global.initStrategyRollForm();
|
||||||
|
}
|
||||||
|
if (tab === "records") {
|
||||||
|
if (typeof global.loadJournals === "function") global.loadJournals();
|
||||||
|
if (typeof global.loadReviews === "function") global.loadReviews();
|
||||||
|
if (typeof global.toggleReviewMode === "function") global.toggleReviewMode();
|
||||||
|
}
|
||||||
|
if (tab === "stats") {
|
||||||
|
if (typeof global.initStatsSegmentFromUrl === "function") global.initStatsSegmentFromUrl();
|
||||||
|
}
|
||||||
|
if (typeof global.refreshPriceSnapshotConditional === "function") {
|
||||||
|
global.refreshPriceSnapshotConditional();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectFragment(html) {
|
||||||
|
const root = document.getElementById("embed-page-root");
|
||||||
|
if (!root) return;
|
||||||
|
root.innerHTML = html;
|
||||||
|
root.querySelectorAll("script").forEach((old) => {
|
||||||
|
const s = document.createElement("script");
|
||||||
|
if (old.src) s.src = old.src;
|
||||||
|
else s.textContent = old.textContent;
|
||||||
|
old.replaceWith(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTab(tab, opts) {
|
||||||
|
const options = opts || {};
|
||||||
|
if (!tab || loadingTab) return;
|
||||||
|
const token = ++navToken;
|
||||||
|
loadingTab = true;
|
||||||
|
setRootLoading(true);
|
||||||
|
try {
|
||||||
|
const qs = listWindowQueryString();
|
||||||
|
const url = "/api/embed/page/" + encodeURIComponent(tab) + (qs ? "?" + qs : "");
|
||||||
|
const r = await fetch(url, { credentials: "same-origin" });
|
||||||
|
if (token !== navToken) return;
|
||||||
|
const j = await r.json();
|
||||||
|
if (!j.ok || !j.html) throw new Error(j.msg || "加载失败");
|
||||||
|
injectFragment(j.html);
|
||||||
|
setNavActive(tab);
|
||||||
|
if (!options.skipUrl) syncUrl(tab, !!options.replace);
|
||||||
|
runPageInit(tab);
|
||||||
|
} catch (e) {
|
||||||
|
if (token === navToken) {
|
||||||
|
const flash = document.getElementById("embed-flash");
|
||||||
|
if (flash) {
|
||||||
|
flash.style.display = "";
|
||||||
|
flash.textContent = String(e && e.message ? e.message : e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (token === navToken) {
|
||||||
|
loadingTab = false;
|
||||||
|
setRootLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reloadCurrentTab() {
|
||||||
|
return loadTab(getTab(), { replace: true, skipUrl: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function postFormAndReload(form, label) {
|
||||||
|
if (!form) return Promise.resolve();
|
||||||
|
if (global.FormSubmitGuard) {
|
||||||
|
if (global.FormSubmitGuard.isLocked(form)) {
|
||||||
|
global.FormSubmitGuard.setSubmitLabel(form, label || "提交中…");
|
||||||
|
} else {
|
||||||
|
global.FormSubmitGuard.lock(form, label || "提交中…");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fd = new FormData(form);
|
||||||
|
return fetch(form.action, {
|
||||||
|
method: form.method || "POST",
|
||||||
|
body: fd,
|
||||||
|
credentials: "same-origin",
|
||||||
|
redirect: "manual",
|
||||||
|
})
|
||||||
|
.then(() => reloadCurrentTab())
|
||||||
|
.catch(() => reloadCurrentTab());
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchApplyListWindow() {
|
||||||
|
if (typeof global.applyListWindow !== "function") return;
|
||||||
|
global.applyListWindow = function embedApplyListWindow() {
|
||||||
|
const qs = listWindowQueryString();
|
||||||
|
const tab = getTab();
|
||||||
|
const q = new URLSearchParams(qs);
|
||||||
|
q.set("tab", tab);
|
||||||
|
q.set("embed", "1");
|
||||||
|
window.location.href = "/embed?" + q.toString();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchHardNavigations() {
|
||||||
|
const resubmitPaths =
|
||||||
|
/^\/(del_|delete_|add_|stop_|strategy\/|trend_|roll_|cancel_|place_)/;
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
"click",
|
||||||
|
(ev) => {
|
||||||
|
if (!isEmbedShell()) return;
|
||||||
|
const a = ev.target.closest("a[href]");
|
||||||
|
if (!a || ev.defaultPrevented) return;
|
||||||
|
if (a.closest(".embed-top-nav")) return;
|
||||||
|
if (a.hasAttribute("download") || a.target === "_blank") return;
|
||||||
|
const raw = a.getAttribute("href");
|
||||||
|
if (!raw || raw.startsWith("#") || raw.startsWith("javascript:")) return;
|
||||||
|
let url;
|
||||||
|
try {
|
||||||
|
url = new URL(raw, location.href);
|
||||||
|
} catch (_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.origin !== location.origin) return;
|
||||||
|
if (url.pathname.startsWith("/export/") || url.pathname.startsWith("/order_focus") || url.pathname.startsWith("/key_focus")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!resubmitPaths.test(url.pathname)) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
fetch(url.pathname + url.search, { credentials: "same-origin", redirect: "manual" })
|
||||||
|
.then(() => reloadCurrentTab())
|
||||||
|
.catch(() => reloadCurrentTab());
|
||||||
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
"submit",
|
||||||
|
(ev) => {
|
||||||
|
if (!isEmbedShell()) return;
|
||||||
|
const form = ev.target;
|
||||||
|
if (!(form instanceof HTMLFormElement)) return;
|
||||||
|
if (form.method && form.method.toUpperCase() === "GET") return;
|
||||||
|
if (CUSTOM_SUBMIT_FORM_IDS.has(form.id)) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
const fd = new FormData(form);
|
||||||
|
fetch(form.action, {
|
||||||
|
method: form.method || "POST",
|
||||||
|
body: fd,
|
||||||
|
credentials: "same-origin",
|
||||||
|
redirect: "manual",
|
||||||
|
})
|
||||||
|
.then(() => reloadCurrentTab())
|
||||||
|
.catch(() => reloadCurrentTab());
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindNav() {
|
||||||
|
document.querySelectorAll(".embed-top-nav [data-embed-tab]").forEach((a) => {
|
||||||
|
a.addEventListener("click", (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
const tab = a.getAttribute("data-embed-tab");
|
||||||
|
if (!tab || tab === getTab()) return;
|
||||||
|
void loadTab(tab);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
window.addEventListener("popstate", () => {
|
||||||
|
const tab = getTab();
|
||||||
|
void loadTab(tab, { replace: true, skipUrl: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function boot() {
|
||||||
|
if (!isEmbedShell()) return;
|
||||||
|
patchApplyListWindow();
|
||||||
|
patchHardNavigations();
|
||||||
|
bindNav();
|
||||||
|
runPageInit(getTab());
|
||||||
|
try {
|
||||||
|
window.parent.postMessage({ type: "instance-frame-ready" }, "*");
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
global.InstanceEmbed = {
|
||||||
|
loadTab,
|
||||||
|
reloadCurrentTab,
|
||||||
|
getTab,
|
||||||
|
postFormAndReload,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", boot);
|
||||||
|
} else {
|
||||||
|
boot();
|
||||||
|
}
|
||||||
|
})(typeof window !== "undefined" ? window : globalThis);
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;background:#0b0d14;color:#eaeaea;padding:14px 20px}
|
||||||
|
.container{width:100%;max-width:min(1440px,94vw);margin:0 auto;padding:0 clamp(8px,1.5vw,20px)}
|
||||||
|
.header{display:flex;flex-direction:column;align-items:center;gap:8px;margin-bottom:12px}
|
||||||
|
.header h1{font-size:1.75rem;color:#dbe4ff;text-align:center;line-height:1.25}
|
||||||
|
.exchange-tag{font-size:.82rem;font-weight:600;color:#b8f5d0;background:#14241e;border:1px solid #2d6a4f;padding:5px 14px;border-radius:999px;letter-spacing:.06em}
|
||||||
|
.header-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:center}
|
||||||
|
.top-nav{display:flex;gap:8px;flex-wrap:wrap;justify-content:center;margin-bottom:12px}
|
||||||
|
.top-nav a{padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a;color:#8fc8ff;text-decoration:none}
|
||||||
|
.top-nav a.active{background:#2a3f6c;color:#dbe4ff}
|
||||||
|
.stat-box{display:grid;grid-template-columns:repeat(auto-fit,minmax(148px,1fr));gap:12px;margin-bottom:16px;align-items:stretch}
|
||||||
|
.stat-item{min-width:0;min-height:76px;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:6px;background:#151a2a;padding:12px 10px;border-radius:10px;text-align:center;border:1px solid #2a3152}
|
||||||
|
.stat-item .label{font-size:.8rem;color:#aaa;line-height:1.25;max-width:100%}
|
||||||
|
.stat-item .value{font-size:1.25rem;font-weight:600;color:#fff;line-height:1.3;min-height:1.35em;display:flex;align-items:center;justify-content:center}
|
||||||
|
.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}
|
||||||
|
.card{background:#121726;border-radius:10px;padding:12px;border:1px solid #2a3150}
|
||||||
|
.full{grid-column:1/-1}
|
||||||
|
.card h2{font-size:1rem;margin-bottom:10px;color:#d4d9ff}
|
||||||
|
.form-row{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px;align-items:center}
|
||||||
|
.form-row > input:not([type=checkbox]):not([type=radio]),.form-row > select{flex:0 1 auto;width:10rem;max-width:200px;min-width:7rem}
|
||||||
|
#add-order-form #sltp-mode{min-width:12.5rem;max-width:16rem;width:auto}
|
||||||
|
.order-plan-preview{display:flex;gap:18px;flex-wrap:wrap;align-items:center;margin:4px 0 10px;padding:10px 12px;background:#151a28;border:1px solid #2a3150;border-radius:8px;font-size:.85rem}
|
||||||
|
.order-preview-risk{color:#ff6b6b}
|
||||||
|
.order-preview-risk strong{color:#ff8f8f;font-weight:600}
|
||||||
|
.order-preview-profit{color:#4cd97f}
|
||||||
|
.order-preview-profit strong{color:#6ee7a0;font-weight:600}
|
||||||
|
.order-preview-rr{color:#cfd3ef}
|
||||||
|
.order-preview-rr strong{font-weight:600;color:#dbe4ff}
|
||||||
|
.order-preview-rr.order-preview-rr-low strong{color:#ff8f8f}
|
||||||
|
.order-preview-rr.order-preview-rr-ok strong{color:#8fc8ff}
|
||||||
|
.form-row > button,.form-row > label{flex:0 0 auto}
|
||||||
|
.form-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
|
||||||
|
/* 复盘表单:长下拉文案需可收缩,否则会撑破四列网格 */
|
||||||
|
.journal-card .form-grid{grid-template-columns:repeat(4,minmax(0,1fr))}
|
||||||
|
.journal-card .form-grid > input,
|
||||||
|
.journal-card .form-grid > select{
|
||||||
|
min-width:0;
|
||||||
|
width:100%;
|
||||||
|
max-width:100%;
|
||||||
|
}
|
||||||
|
.journal-card .form-grid select[name="entry_reason"]{
|
||||||
|
grid-column:1/-1;
|
||||||
|
font-size:.8rem;
|
||||||
|
line-height:1.35;
|
||||||
|
}
|
||||||
|
.journal-card .form-grid input[name="entry_reason_custom"]{
|
||||||
|
grid-column:1/-1;
|
||||||
|
font-size:.8rem;
|
||||||
|
}
|
||||||
|
input,select,button,textarea{padding:8px 10px;border-radius:8px;border:1px solid #2e2e45;background:#1a1a29;color:#fff;font-size:.88rem;outline:none}
|
||||||
|
button{background:linear-gradient(90deg,#4285f4,#7b42ff);border:none;cursor:pointer}
|
||||||
|
.list{display:flex;flex-direction:column;gap:8px;margin-top:8px;max-height:240px;overflow:auto}
|
||||||
|
.list-item{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:9px;background:#1a2034;border:1px solid #2a3150;border-radius:8px}
|
||||||
|
.btn-del{padding:5px 9px;background:#2f2134;color:#ff7b7b;border-radius:8px;text-decoration:none;font-size:.8rem}
|
||||||
|
.rule-tip{font-size:.8rem;color:#95a2c2;margin-bottom:8px}
|
||||||
|
table{width:100%;border-collapse:collapse}
|
||||||
|
th,td{padding:8px;text-align:left;border-bottom:1px solid #25253b;font-size:.85rem}
|
||||||
|
th{color:#a9a9ff}
|
||||||
|
.badge{padding:2px 6px;border-radius:6px;font-size:.72rem}
|
||||||
|
.profit{background:#1e332f;color:#4cd97f}
|
||||||
|
.loss{background:#331e24;color:#ff6666}
|
||||||
|
.miss{background:#29241e;color:#eac147}
|
||||||
|
.direction{background:#1e2533;color:#4cc2ff}
|
||||||
|
.direction-long{background:#1e332f;color:#4cd97f}
|
||||||
|
.direction-short{background:#331e24;color:#ff6666}
|
||||||
|
.pnl-profit{color:#4cd97f;font-weight:600}
|
||||||
|
.pnl-loss{color:#ff6666;font-weight:600}
|
||||||
|
.flash{padding:10px;background:#1e2533;color:#4cc2ff;border-radius:10px;margin-bottom:12px;text-align:center;border:1px solid #304164}
|
||||||
|
form.is-form-submitting{opacity:.88;pointer-events:none}
|
||||||
|
form.is-form-submitting button[type=submit],form.is-form-submitting input[type=submit]{cursor:wait}
|
||||||
|
.ai-result{background:#1a1a29;border:1px solid #2e2e45;border-radius:8px;padding:10px;white-space:pre-wrap;max-height:220px;overflow:auto;font-size:.84rem;line-height:1.45;margin-top:8px}
|
||||||
|
.ai-result.ai-result-md,.detail-modal .panel-body.md-review{white-space:normal}
|
||||||
|
.ai-result-md p,.detail-modal .panel-body.md-review p{margin:6px 0;color:#dde2ff}
|
||||||
|
.ai-result-md ul,.ai-result-md ol,.detail-modal .panel-body.md-review ul,.detail-modal .panel-body.md-review ol{margin:6px 0 8px 1.25em;padding:0}
|
||||||
|
.ai-result-md li,.detail-modal .panel-body.md-review li{margin:5px 0;line-height:1.5}
|
||||||
|
.ai-result-md strong,.detail-modal .panel-body.md-review strong{color:#f0f3ff;font-weight:600}
|
||||||
|
.ai-result-md h2,.detail-modal .panel-body.md-review h2{font-size:1.02rem;color:#b8c8ff;margin:14px 0 8px;padding-bottom:4px;border-bottom:1px solid #2e2e45}
|
||||||
|
.ai-result-md h3,.detail-modal .panel-body.md-review h3{font-size:.92rem;color:#c9d4ff;margin:10px 0 6px}
|
||||||
|
.ai-result-md code,.detail-modal .panel-body.md-review code{background:#252538;padding:1px 4px;border-radius:4px;font-size:.82em}
|
||||||
|
.ai-result-md .md-raw-block-title,.detail-modal .panel-body.md-review .md-raw-block-title{margin-top:14px;padding-top:10px;border-top:1px dashed #3a3a55;color:#a8b0d8;font-weight:600}
|
||||||
|
.price-up{color:#4cd97f}
|
||||||
|
.price-down{color:#ff6666}
|
||||||
|
.price-flat{color:#cfd3ef}
|
||||||
|
.panel-list{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||||||
|
.panel-item{background:#141423;border:1px solid #24243b;border-radius:10px;padding:10px;max-height:260px;overflow:auto}
|
||||||
|
.entry{border-bottom:1px solid #2b2b43;padding:8px 0}
|
||||||
|
.entry:last-child{border-bottom:none}
|
||||||
|
.table-del{padding:4px 8px;background:#2f2134;color:#ff7b7b;border:none;border-radius:6px;cursor:pointer;font-size:.78rem}
|
||||||
|
.mood-grid{display:flex;gap:10px;flex-wrap:wrap;font-size:.82rem;color:#d7d7ea}
|
||||||
|
.mood-grid label{display:flex;align-items:center;gap:3px}
|
||||||
|
.screenshot{width:100px;border-radius:6px;cursor:pointer;margin-top:6px}
|
||||||
|
.modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.78);justify-content:center;align-items:center;z-index:1210}
|
||||||
|
.modal img{max-width:90%;max-height:90%;border-radius:8px}
|
||||||
|
.detail-modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.78);justify-content:center;align-items:center;z-index:1200;padding:20px}
|
||||||
|
.detail-modal .panel{width:min(92vw,980px);max-height:88vh;overflow:auto;background:#121726;border:1px solid #2a3150;border-radius:10px;padding:14px}
|
||||||
|
.detail-modal .panel-head{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:10px}
|
||||||
|
.detail-modal .panel-title{font-size:1rem;color:#dbe4ff}
|
||||||
|
.detail-modal .panel-close{padding:6px 10px;background:#2f2134;color:#ffb2b2;border:none;border-radius:8px;cursor:pointer}
|
||||||
|
.detail-modal .panel-body{white-space:pre-wrap;line-height:1.5;font-size:.86rem;color:#e5e9ff}
|
||||||
|
.detail-modal .panel-image{margin-top:10px;max-width:min(100%,680px);border-radius:8px;cursor:pointer;border:1px solid #2a3150}
|
||||||
|
.detail-modal .panel-actions{display:flex;gap:8px;align-items:center;flex-shrink:0}
|
||||||
|
.detail-modal .panel-fs{padding:6px 10px;background:#1f3a5a;color:#8fc8ff;border:none;border-radius:8px;cursor:pointer;font-size:.82rem}
|
||||||
|
.detail-modal.fullscreen{padding:10px}
|
||||||
|
.detail-modal.fullscreen .panel{width:100%;height:100%;max-width:none;max-height:none;display:flex;flex-direction:column;overflow:hidden}
|
||||||
|
.detail-modal.fullscreen .panel-body{flex:1;overflow:auto;min-height:0;font-size:.9rem}
|
||||||
|
.ai-result-wrap{margin-top:8px}
|
||||||
|
.ai-result-toolbar{display:flex;gap:8px;margin-top:6px}
|
||||||
|
.ai-result-toolbar .btn-fs{padding:4px 10px;font-size:.78rem;background:#1f3a5a;color:#8fc8ff;border:none;border-radius:6px;cursor:pointer}
|
||||||
|
.table-wrap{overflow-x:auto}
|
||||||
|
.dual-panel-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;align-items:stretch}
|
||||||
|
.dual-panel-grid .card{height:100%;display:flex;flex-direction:column}
|
||||||
|
.panel-scroll{flex:1;min-height:280px;max-height:420px;overflow:auto}
|
||||||
|
.records-card{grid-column:1/-1}
|
||||||
|
.review-card{grid-column:1/-1}
|
||||||
|
.review-card-head{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap}
|
||||||
|
.review-card-head h2{margin:0}
|
||||||
|
.review-card-fs-btn{padding:6px 12px;background:#1f3a5a;color:#8fc8ff;border:none;border-radius:8px;cursor:pointer;font-size:.82rem;white-space:nowrap}
|
||||||
|
.review-card-fs-btn:hover{filter:brightness(1.08)}
|
||||||
|
body.review-card-fullscreen-open{overflow:hidden}
|
||||||
|
.review-card.is-fullscreen{
|
||||||
|
position:fixed;inset:12px;z-index:1100;margin:0;
|
||||||
|
width:auto !important;max-width:none;height:auto;
|
||||||
|
overflow:auto;display:flex;flex-direction:column;
|
||||||
|
box-shadow:0 12px 48px rgba(0,0,0,.55);
|
||||||
|
}
|
||||||
|
.review-card.is-fullscreen .panel-list{flex:1;min-height:320px}
|
||||||
|
.review-card.is-fullscreen .panel-item{max-height:none;height:auto;min-height:280px}
|
||||||
|
.review-card.is-fullscreen .ai-result{max-height:min(36vh, 320px)}
|
||||||
|
@media (max-width: 1200px){
|
||||||
|
.stat-box{grid-template-columns:repeat(auto-fill,minmax(140px,1fr))}
|
||||||
|
}
|
||||||
|
@media (min-width: 1440px){
|
||||||
|
.panel-scroll,.pos-list{max-height:420px}
|
||||||
|
.records-card .table-wrap{max-height:620px;overflow:auto}
|
||||||
|
}
|
||||||
|
@media (min-width: 2200px){
|
||||||
|
.container{max-width:min(1720px,90vw)}
|
||||||
|
}
|
||||||
|
@media (min-width: 2560px){
|
||||||
|
.container{max-width:min(1860px,88vw)}
|
||||||
|
.dual-panel-grid{gap:18px}
|
||||||
|
}
|
||||||
|
@media (min-width: 3000px){
|
||||||
|
.container{max-width:min(1980px,86vw)}
|
||||||
|
.pos-grid{grid-template-columns:repeat(4,minmax(0,1fr))}
|
||||||
|
}
|
||||||
|
@media (max-width: 1100px){
|
||||||
|
.grid{grid-template-columns:1fr}
|
||||||
|
.dual-panel-grid{grid-template-columns:1fr}
|
||||||
|
.records-card,.review-card{grid-column:auto}
|
||||||
|
.panel-list{grid-template-columns:1fr}
|
||||||
|
}
|
||||||
|
@media (max-width: 960px){
|
||||||
|
body{padding:10px}
|
||||||
|
.form-grid{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||||
|
.stat-box{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||||
|
}
|
||||||
|
.stats-detail{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:10px;margin-top:10px}
|
||||||
|
.stats-detail .stat-item{min-width:0;min-height:0;display:block;text-align:left;padding:10px 12px;align-items:stretch;gap:4px}
|
||||||
|
.stats-detail .stat-item .value{min-height:0;display:block;font-size:1.05rem}
|
||||||
|
.stats-detail .stat-item .label{font-size:.75rem}
|
||||||
|
.stats-detail .stat-item .value{font-size:1.05rem;word-break:break-all}
|
||||||
|
.export-bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:12px;font-size:.85rem}
|
||||||
|
.export-bar a{color:#8fc8ff;text-decoration:none;padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a}
|
||||||
|
.export-bar a:hover{background:#1f2740}
|
||||||
|
.list-window-bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:12px;padding:10px 12px;background:#151a2a;border:1px solid #304164;border-radius:10px;font-size:.82rem}
|
||||||
|
.list-window-bar label{color:#9aa;display:flex;align-items:center;gap:6px}
|
||||||
|
.stats-segment-block{margin-top:20px;padding-top:14px;border-top:1px solid #3a4468}
|
||||||
|
.stats-segment-block h2{font-size:1.05rem;color:#dbe4ff;margin-bottom:8px}
|
||||||
|
.key-history{margin-top:12px;padding-top:10px;border-top:1px solid #2a3150}
|
||||||
|
.key-history h3{font-size:.88rem;color:#b8c4ff;margin-bottom:6px}
|
||||||
|
.key-history .sub{font-size:.72rem;color:#8892b0;margin-bottom:6px}
|
||||||
|
.key-history .list{max-height:200px}
|
||||||
|
.pos-section{margin-top:12px}
|
||||||
|
.pos-section-title{font-size:.82rem;color:#8892b0;margin-bottom:8px;font-weight:500}
|
||||||
|
.pos-list{display:flex;flex-direction:column;gap:10px;max-height:280px;overflow:auto}
|
||||||
|
.dual-panel-grid .pos-list-live{max-height:none;overflow:visible;flex:1 1 auto}
|
||||||
|
.dual-panel-grid .panel-scroll.pos-list-live{max-height:none;overflow:visible}
|
||||||
|
.pos-card{background:#141923;border:1px solid #2a3348;border-radius:10px;padding:12px 14px}
|
||||||
|
.pos-card-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px}
|
||||||
|
.pos-meta{font-size:.74rem;color:#8b95a8;line-height:1.45;margin-bottom:12px;display:flex;flex-wrap:wrap;align-items:center;gap:4px 0}
|
||||||
|
.pos-meta-item{display:inline-flex;align-items:center}
|
||||||
|
.pos-meta-item:not(:last-child)::after{content:'|';margin:0 8px;color:#3d4659}
|
||||||
|
.pos-meta-on{color:#6eb5ff}
|
||||||
|
.pos-meta-off{color:#7d8799}
|
||||||
|
.pos-breakeven-badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:6px;font-size:.72rem;font-weight:600;background:#1a3d2e;color:#4cd97f}
|
||||||
|
.pos-card-symbol{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0}
|
||||||
|
.pos-card-symbol strong{font-size:.95rem;color:#fff;font-weight:600}
|
||||||
|
.pos-side-badge{padding:3px 8px;border-radius:6px;font-size:.72rem;font-weight:500;line-height:1.2}
|
||||||
|
.pos-side-long{background:#253a6e;color:#6eb5ff}
|
||||||
|
.pos-side-short{background:#4a2230;color:#ff8a8a}
|
||||||
|
.pos-head-actions{display:flex;align-items:center;gap:6px;flex-shrink:0}
|
||||||
|
.pos-entrust-btn{padding:6px 12px;background:#2a4a7a;color:#8fc8ff;border:none;border-radius:8px;font-size:.82rem;font-weight:500;cursor:pointer;white-space:nowrap}
|
||||||
|
.pos-entrust-btn:hover{background:#355d96}
|
||||||
|
.pos-close-btn{padding:6px 14px;background:#c45454;color:#fff;border-radius:8px;text-decoration:none;font-size:.82rem;font-weight:500;flex-shrink:0;white-space:nowrap;border:none;cursor:pointer;display:inline-block}
|
||||||
|
.pos-close-btn:hover{background:#d66565;color:#fff}
|
||||||
|
.pos-ex-orders{margin-top:10px;padding-top:10px;border-top:1px dashed #2a3348}
|
||||||
|
.pos-ex-orders-title{font-size:.74rem;color:#7d8799;margin-bottom:6px}
|
||||||
|
.pos-ex-order-row{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:.78rem;color:#c5cce0;margin-top:5px}
|
||||||
|
.pos-ex-order-main{flex:1;min-width:0;line-height:1.35}
|
||||||
|
.pos-ex-cancel-btn{padding:3px 10px;background:#3a3048;color:#d4b8ff;border:none;border-radius:6px;font-size:.74rem;cursor:pointer;flex-shrink:0}
|
||||||
|
.pos-ex-cancel-btn:disabled{opacity:.4;cursor:not-allowed}
|
||||||
|
.tpsl-modal-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:9000;align-items:center;justify-content:center;padding:16px}
|
||||||
|
.tpsl-modal-backdrop.open{display:flex}
|
||||||
|
.tpsl-modal{background:#1a2030;border:1px solid #3a4a66;border-radius:12px;padding:16px 18px;width:min(440px,100%);max-height:90vh;overflow:auto}
|
||||||
|
.tpsl-modal h3{margin:0 0 12px;font-size:1rem;color:#fff}
|
||||||
|
.tpsl-modal .form-row{margin-bottom:10px}
|
||||||
|
.tpsl-modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:14px}
|
||||||
|
.tpsl-modal-actions button{padding:8px 16px;border-radius:8px;border:none;cursor:pointer;font-size:.85rem}
|
||||||
|
.tpsl-modal-submit{background:#2d6a4f;color:#fff}
|
||||||
|
.tpsl-modal-cancel{background:#3a3f52;color:#ddd}
|
||||||
|
.pos-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px 14px;margin-bottom:12px}
|
||||||
|
.pos-cell{display:flex;flex-direction:column;gap:4px;min-width:0}
|
||||||
|
.pos-label{font-size:.72rem;color:#7d8799}
|
||||||
|
.pos-value{font-size:.88rem;color:#e8ecf4;font-weight:500;line-height:1.25}
|
||||||
|
.pos-val-dash{opacity:.75;color:#8b95a8}
|
||||||
|
.pos-value.price-up{color:#4cd97f}
|
||||||
|
.pos-value.price-down{color:#ff6666}
|
||||||
|
.pos-value.price-flat{color:#e8ecf4}
|
||||||
|
.pos-footer{display:flex;flex-wrap:wrap;gap:14px 18px;font-size:.75rem;color:#6d7689}
|
||||||
|
.pos-empty{padding:18px;text-align:center;color:#8892b0;font-size:.85rem;background:#141923;border:1px dashed #2a3348;border-radius:10px}
|
||||||
|
@media (max-width:520px){.pos-grid{grid-template-columns:repeat(2,1fr)}}
|
||||||
|
.stats-card{grid-column:1/-1;margin-top:14px}
|
||||||
|
.stats-card .stats-toggle{background:#1f3a5a;color:#8fc8ff;border:none;border-radius:8px;padding:6px 10px;cursor:pointer}
|
||||||
|
.stats-card.collapsed .stats-content{display:none}
|
||||||
|
.stats-period-block{margin-bottom:18px;padding-bottom:14px;border-bottom:1px solid #2a3150}
|
||||||
|
.stats-period-block:last-child{border-bottom:none;margin-bottom:0;padding-bottom:0}
|
||||||
|
.stats-period-block h3{font-size:1rem;color:#dbe4ff;margin-bottom:4px}
|
||||||
|
.stats-period-block .sub{font-size:.78rem;color:#8892b0;margin-bottom:10px;line-height:1.4}
|
||||||
|
#embed-page-root{transition:opacity .12s ease}
|
||||||
|
#embed-page-root.is-embed-tab-loading{opacity:.55;pointer-events:none}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* 手机端:交易记录 / 复盘记录紧凑列表(币种 · 方向 · 盈亏),点击展开详情。
|
||||||
|
*/
|
||||||
|
(function (global) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var resizeTimer = null;
|
||||||
|
|
||||||
|
function refreshTradeRecords() {
|
||||||
|
var UI = global.InstanceUI;
|
||||||
|
if (!UI) return;
|
||||||
|
var card = document.querySelector(".records-card");
|
||||||
|
if (!card) return;
|
||||||
|
var tableWrap = card.querySelector(".table-wrap");
|
||||||
|
var table = tableWrap && tableWrap.querySelector("table");
|
||||||
|
if (!table) return;
|
||||||
|
|
||||||
|
var listEl = card.querySelector(".mobile-record-list");
|
||||||
|
var mobile = UI.isMobileCompactRecords();
|
||||||
|
|
||||||
|
if (!mobile) {
|
||||||
|
if (listEl) listEl.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!listEl) {
|
||||||
|
listEl = document.createElement("div");
|
||||||
|
listEl.className = "mobile-record-list";
|
||||||
|
tableWrap.parentNode.insertBefore(listEl, tableWrap);
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = table.querySelectorAll('tr[id^="trade-row-"]');
|
||||||
|
listEl.innerHTML = rows.length
|
||||||
|
? Array.prototype.map
|
||||||
|
.call(rows, function (tr) {
|
||||||
|
return UI.renderMobileTradeRow(tr);
|
||||||
|
})
|
||||||
|
.join("")
|
||||||
|
: '<div class="journal-empty-msg">暂无交易记录</div>';
|
||||||
|
|
||||||
|
listEl.querySelectorAll(".mobile-record-row").forEach(function (btn) {
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
var rowId = btn.getAttribute("data-row-id");
|
||||||
|
var tr = rowId && document.getElementById(rowId);
|
||||||
|
if (tr) UI.openTradeRecordDetailModal(tr);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResize() {
|
||||||
|
if (resizeTimer) clearTimeout(resizeTimer);
|
||||||
|
resizeTimer = setTimeout(function () {
|
||||||
|
refreshTradeRecords();
|
||||||
|
if (typeof global.loadJournals === "function" && document.getElementById("journal-list")) {
|
||||||
|
global.loadJournals();
|
||||||
|
}
|
||||||
|
}, 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
refreshTradeRecords();
|
||||||
|
global.addEventListener("resize", onResize);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
global.InstanceRecordsMobile = {
|
||||||
|
refresh: refreshTradeRecords,
|
||||||
|
};
|
||||||
|
})(typeof window !== "undefined" ? window : globalThis);
|
||||||
@@ -1,347 +1,572 @@
|
|||||||
/**
|
/**
|
||||||
* 四所实例主题:默认暗色;单独登录用 instance-theme;中控 iframe/SSO 随 hub-theme 联动。
|
* 三所实例主题:默认暗色;单独登录用 instance-theme;中控 iframe/SSO 随 hub-theme 联动。
|
||||||
*/
|
*/
|
||||||
(function (global) {
|
(function (global) {
|
||||||
const STANDALONE_KEY = "instance-theme";
|
const STANDALONE_KEY = "instance-theme";
|
||||||
const HUB_LINKED_THEME_KEY = "hub-linked-theme";
|
const HUB_LINKED_THEME_KEY = "hub-linked-theme";
|
||||||
const META = { dark: "#0b0d14", light: "#d8e2ec" };
|
const META = { dark: "#0b0d14", light: "#d8e2ec" };
|
||||||
|
|
||||||
function normalize(theme) {
|
function normalize(theme) {
|
||||||
return theme === "light" ? "light" : "dark";
|
return theme === "light" ? "light" : "dark";
|
||||||
}
|
}
|
||||||
|
|
||||||
function isHubLinked() {
|
function isHubLinked() {
|
||||||
try {
|
try {
|
||||||
const p = new URLSearchParams(location.search);
|
const p = new URLSearchParams(location.search);
|
||||||
if (p.get("embed") === "1") return true;
|
if (p.get("embed") === "1") return true;
|
||||||
const ht = p.get("hub_theme");
|
const ht = p.get("hub_theme");
|
||||||
if (ht === "light" || ht === "dark") return true;
|
if (ht === "light" || ht === "dark") return true;
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
try {
|
try {
|
||||||
if (window.self !== window.top) return true;
|
if (window.self !== window.top) return true;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function themeFromUrl() {
|
function themeFromUrl() {
|
||||||
try {
|
try {
|
||||||
const t = new URLSearchParams(location.search).get("hub_theme");
|
const t = new URLSearchParams(location.search).get("hub_theme");
|
||||||
if (t === "light" || t === "dark") return t;
|
if (t === "light" || t === "dark") return t;
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readLinkedThemeStorage() {
|
function readLinkedThemeStorage() {
|
||||||
try {
|
try {
|
||||||
const t = sessionStorage.getItem(HUB_LINKED_THEME_KEY);
|
const t = sessionStorage.getItem(HUB_LINKED_THEME_KEY);
|
||||||
if (t === "light" || t === "dark") return t;
|
if (t === "light" || t === "dark") return t;
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeLinkedThemeStorage(theme) {
|
function writeLinkedThemeStorage(theme) {
|
||||||
if (!isHubLinked()) return;
|
if (!isHubLinked()) return;
|
||||||
try {
|
try {
|
||||||
sessionStorage.setItem(HUB_LINKED_THEME_KEY, normalize(theme));
|
sessionStorage.setItem(HUB_LINKED_THEME_KEY, normalize(theme));
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStandalone() {
|
function getStandalone() {
|
||||||
try {
|
try {
|
||||||
return normalize(localStorage.getItem(STANDALONE_KEY));
|
return normalize(localStorage.getItem(STANDALONE_KEY));
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return "dark";
|
return "dark";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setStandalone(theme) {
|
function setStandalone(theme) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(STANDALONE_KEY, normalize(theme));
|
localStorage.setItem(STANDALONE_KEY, normalize(theme));
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
let _linkedTheme = null;
|
let _linkedTheme = null;
|
||||||
|
let _appliedTheme = null;
|
||||||
function get() {
|
|
||||||
if (isHubLinked()) {
|
function get() {
|
||||||
return themeFromUrl() || _linkedTheme || readLinkedThemeStorage() || "dark";
|
if (isHubLinked()) {
|
||||||
}
|
return themeFromUrl() || _linkedTheme || readLinkedThemeStorage() || "dark";
|
||||||
return getStandalone();
|
}
|
||||||
}
|
return getStandalone();
|
||||||
|
}
|
||||||
/** 模板内联暗色 → 亮色(切换时重写 style 属性) */
|
|
||||||
const INLINE_HEX_LIGHT = {
|
/** 模板内联暗色 → 亮色(切换时重写 style 属性) */
|
||||||
"#cfd3ef": "#1a2838",
|
const INLINE_HEX_LIGHT = {
|
||||||
"#8892b0": "#4a6078",
|
"#cfd3ef": "#1a2838",
|
||||||
"#9aa3c4": "#4a6078",
|
"#8892b0": "#4a6078",
|
||||||
"#8b95a8": "#4a6078",
|
"#9aa3c4": "#4a6078",
|
||||||
"#8b95b8": "#4a6078",
|
"#8b95a8": "#4a6078",
|
||||||
"#6a7598": "#4a6078",
|
"#8b95b8": "#4a6078",
|
||||||
"#7d8799": "#4a6078",
|
"#6a7598": "#4a6078",
|
||||||
"#6d7689": "#4a6078",
|
"#7d8799": "#4a6078",
|
||||||
"#dbe4ff": "#142232",
|
"#6d7689": "#4a6078",
|
||||||
"#f0f2ff": "#142232",
|
"#dbe4ff": "#142232",
|
||||||
"#e8ecf4": "#142232",
|
"#f0f2ff": "#142232",
|
||||||
"#c5cce0": "#4a6078",
|
"#e8ecf4": "#142232",
|
||||||
"#b8c4ff": "#142232",
|
"#c5cce0": "#4a6078",
|
||||||
"#8fc8ff": "#006e9a",
|
"#b8c4ff": "#142232",
|
||||||
"#6ab8ff": "#006e9a",
|
"#8fc8ff": "#006e9a",
|
||||||
"#6eb5ff": "#006e9a",
|
"#6ab8ff": "#006e9a",
|
||||||
"#101522": "#ffffff",
|
"#6eb5ff": "#006e9a",
|
||||||
"#121726": "#ffffff",
|
"#101522": "#ffffff",
|
||||||
"#141423": "#ffffff",
|
"#121726": "#ffffff",
|
||||||
"#24243b": "#b8c8d8",
|
"#141423": "#ffffff",
|
||||||
"#252a45": "#b8c8d8",
|
"#24243b": "#b8c8d8",
|
||||||
"#252538": "#eef3f8",
|
"#252a45": "#b8c8d8",
|
||||||
"#1a1a29": "#f6f9fc",
|
"#252538": "#eef3f8",
|
||||||
"#2e2e45": "#b8c8d8",
|
"#1a1a29": "#f6f9fc",
|
||||||
"#2b2b43": "#d0dae4",
|
"#2e2e45": "#b8c8d8",
|
||||||
"#151a2a": "#eef3f8",
|
"#2b2b43": "#d0dae4",
|
||||||
"#141a2a": "#ffffff",
|
"#151a2a": "#eef3f8",
|
||||||
"#141923": "#ffffff",
|
"#141a2a": "#ffffff",
|
||||||
"#141a2e": "#ffffff",
|
"#141923": "#ffffff",
|
||||||
"#0f1424": "#f6f9fc",
|
"#141a2e": "#ffffff",
|
||||||
"#0f1420": "#f6f9fc",
|
"#0f1424": "#f6f9fc",
|
||||||
"#0f1117": "#d8e2ec",
|
"#0f1420": "#f6f9fc",
|
||||||
"#1a2034": "#eef3f8",
|
"#0f1117": "#d8e2ec",
|
||||||
"#1a2030": "#ffffff",
|
"#1a2034": "#eef3f8",
|
||||||
"#1f3a5a": "#e8eef5",
|
"#1a2030": "#ffffff",
|
||||||
"#2f2f44": "#dde5ec",
|
"#1f3a5a": "#e8eef5",
|
||||||
"#2a3f6c": "rgba(0,110,154,0.14)",
|
"#2f2f44": "#dde5ec",
|
||||||
"#304164": "rgba(0,95,140,0.22)",
|
"#2a3f6c": "rgba(0,110,154,0.14)",
|
||||||
"#2a3150": "#b8c8d8",
|
"#304164": "rgba(0,95,140,0.22)",
|
||||||
"#2a3152": "#b8c8d8",
|
"#2a3150": "#b8c8d8",
|
||||||
"#3a5a8a": "rgba(0,95,140,0.35)",
|
"#2a3152": "#b8c8d8",
|
||||||
"#2a3348": "#b8c8d8",
|
"#3a5a8a": "rgba(0,95,140,0.35)",
|
||||||
"#243050": "rgba(0,75,115,0.16)",
|
"#2a3348": "#b8c8d8",
|
||||||
"#2a3558": "#d0dae4",
|
"#243050": "rgba(0,75,115,0.16)",
|
||||||
"#3a4468": "#c8d4e0",
|
"#2a3558": "#d0dae4",
|
||||||
"#3a4a66": "#b8c8d8",
|
"#3a4468": "#c8d4e0",
|
||||||
"#3a3f52": "#dde5ec",
|
"#3a4a66": "#b8c8d8",
|
||||||
"#3d4659": "#b8c8d8",
|
"#3a3f52": "#dde5ec",
|
||||||
"#1f2740": "#eef3f8",
|
"#3d4659": "#b8c8d8",
|
||||||
"#1f2a44": "rgba(0,110,154,0.1)",
|
"#1f2740": "#eef3f8",
|
||||||
"#1f4a3a": "#e8f5ef",
|
"#1f2a44": "rgba(0,110,154,0.1)",
|
||||||
"#2a4a7a": "#e8eef5",
|
"#1f4a3a": "#e8f5ef",
|
||||||
"#3a3048": "#eef3f8",
|
"#2a4a7a": "#e8eef5",
|
||||||
"#d4b8ff": "#5b4fc7",
|
"#3a3048": "#eef3f8",
|
||||||
"#e6e8ef": "#1a2838",
|
"#d4b8ff": "#5b4fc7",
|
||||||
};
|
"#e6e8ef": "#1a2838",
|
||||||
|
};
|
||||||
function remapInlineStyle(style, theme) {
|
|
||||||
if (!style) return style;
|
function remapInlineStyle(style, theme) {
|
||||||
if (theme !== "light") return style;
|
if (!style) return style;
|
||||||
const hadSecondaryBtnBg = /#1f3a5a/i.test(style);
|
if (theme !== "light") return style;
|
||||||
let out = style;
|
const hadSecondaryBtnBg = /#1f3a5a/i.test(style);
|
||||||
for (const [from, to] of Object.entries(INLINE_HEX_LIGHT)) {
|
let out = style;
|
||||||
out = out.replace(new RegExp(from.replace("#", "\\#"), "gi"), to);
|
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`;
|
if (hadSecondaryBtnBg && !/color\s*:/i.test(style)) {
|
||||||
}
|
out = `${out.replace(/;+\s*$/, "")};color:#006e9a`;
|
||||||
return out;
|
}
|
||||||
}
|
return out;
|
||||||
|
}
|
||||||
function syncInlineStyles(theme, root) {
|
|
||||||
const scope = root || document;
|
function syncInlineStyles(theme, root) {
|
||||||
scope.querySelectorAll("[style]").forEach((el) => {
|
const scope = root || document;
|
||||||
const raw = el.getAttribute("style");
|
scope.querySelectorAll("[style]").forEach((el) => {
|
||||||
if (!raw) return;
|
const raw = el.getAttribute("style");
|
||||||
if (!el.dataset.instStyleBase) {
|
if (!raw) return;
|
||||||
el.dataset.instStyleBase = raw;
|
if (!el.dataset.instStyleBase) {
|
||||||
}
|
el.dataset.instStyleBase = raw;
|
||||||
const base = el.dataset.instStyleBase;
|
}
|
||||||
el.setAttribute("style", theme === "light" ? remapInlineStyle(base, "light") : base);
|
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;
|
function mergeHubQueryIntoHref(href, theme) {
|
||||||
try {
|
if (!href || href.startsWith("#") || href.startsWith("javascript:")) return href;
|
||||||
const u = new URL(href, location.origin);
|
try {
|
||||||
if (u.origin !== location.origin) return href;
|
const u = new URL(href, location.origin);
|
||||||
if (isHubLinked()) {
|
if (u.origin !== location.origin) return href;
|
||||||
u.searchParams.set("embed", "1");
|
if (isHubLinked()) {
|
||||||
if (theme === "light" || theme === "dark") {
|
u.searchParams.set("embed", "1");
|
||||||
u.searchParams.set("hub_theme", theme);
|
if (theme === "light" || theme === "dark") {
|
||||||
}
|
u.searchParams.set("hub_theme", theme);
|
||||||
}
|
}
|
||||||
return u.pathname + u.search + u.hash;
|
}
|
||||||
} catch (_) {
|
return u.pathname + u.search + u.hash;
|
||||||
return href;
|
} catch (_) {
|
||||||
}
|
return href;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
function patchHubNavLinks(theme) {
|
|
||||||
if (!isHubLinked()) return;
|
function patchHubNavLinks(theme) {
|
||||||
const t = normalize(theme || get());
|
if (!isHubLinked()) return;
|
||||||
document
|
const t = normalize(theme || get());
|
||||||
.querySelectorAll(".top-nav a[href], .strategy-subnav a[href]")
|
document
|
||||||
.forEach((a) => {
|
.querySelectorAll(".top-nav a[href], .strategy-subnav a[href]")
|
||||||
const href = a.getAttribute("href");
|
.forEach((a) => {
|
||||||
if (!href) return;
|
const href = a.getAttribute("href");
|
||||||
const next = mergeHubQueryIntoHref(href, t);
|
if (!href) return;
|
||||||
if (next !== href) a.setAttribute("href", next);
|
const next = mergeHubQueryIntoHref(href, t);
|
||||||
});
|
if (next !== href) a.setAttribute("href", next);
|
||||||
}
|
});
|
||||||
|
}
|
||||||
function apply(theme, opts) {
|
|
||||||
const options = opts || {};
|
function apply(theme, opts) {
|
||||||
const linked = isHubLinked();
|
const options = opts || {};
|
||||||
const t = normalize(theme);
|
const linked = isHubLinked();
|
||||||
if (linked) {
|
const t = normalize(theme);
|
||||||
_linkedTheme = t;
|
const root = document.documentElement;
|
||||||
writeLinkedThemeStorage(t);
|
const unchanged =
|
||||||
} else if (!options.skipStore) {
|
!options.force &&
|
||||||
setStandalone(t);
|
_appliedTheme === t &&
|
||||||
}
|
root.getAttribute("data-theme") === t;
|
||||||
const root = document.documentElement;
|
if (unchanged) {
|
||||||
root.setAttribute("data-theme", t);
|
return t;
|
||||||
const meta = document.querySelector('meta[name="theme-color"]');
|
}
|
||||||
if (meta) meta.setAttribute("content", META[t]);
|
_appliedTheme = t;
|
||||||
root.style.colorScheme = t;
|
if (linked) {
|
||||||
if (document.body) {
|
_linkedTheme = t;
|
||||||
syncInlineStyles(t);
|
writeLinkedThemeStorage(t);
|
||||||
patchHubNavLinks(t);
|
root.setAttribute("data-hub-linked", "1");
|
||||||
} else {
|
} else {
|
||||||
document.addEventListener(
|
root.removeAttribute("data-hub-linked");
|
||||||
"DOMContentLoaded",
|
}
|
||||||
function onDom() {
|
if (!linked && !options.skipStore) {
|
||||||
syncInlineStyles(t);
|
setStandalone(t);
|
||||||
patchHubNavLinks(t);
|
}
|
||||||
},
|
root.setAttribute("data-theme", t);
|
||||||
{ once: true }
|
const meta = document.querySelector('meta[name="theme-color"]');
|
||||||
);
|
if (meta) meta.setAttribute("content", META[t]);
|
||||||
}
|
root.style.colorScheme = t;
|
||||||
syncToggleUI();
|
if (document.body) {
|
||||||
document.dispatchEvent(
|
syncInlineStyles(t);
|
||||||
new CustomEvent("instance-theme-change", { detail: { theme: t, hubLinked: linked } })
|
patchHubNavLinks(t);
|
||||||
);
|
} else {
|
||||||
return t;
|
document.addEventListener(
|
||||||
}
|
"DOMContentLoaded",
|
||||||
|
function onDom() {
|
||||||
function syncToggleUI(root) {
|
syncInlineStyles(t);
|
||||||
const scope = root || document;
|
patchHubNavLinks(t);
|
||||||
const linked = isHubLinked();
|
},
|
||||||
const toggle = scope.querySelector(".instance-theme-toggle");
|
{ once: true }
|
||||||
if (toggle) {
|
);
|
||||||
toggle.classList.toggle("is-hub-linked", linked);
|
}
|
||||||
toggle.setAttribute("aria-hidden", linked ? "true" : "false");
|
syncToggleUI();
|
||||||
}
|
document.dispatchEvent(
|
||||||
if (linked) return;
|
new CustomEvent("instance-theme-change", { detail: { theme: t, hubLinked: linked } })
|
||||||
scope.querySelectorAll(".theme-toggle-btn[data-theme-value]").forEach((btn) => {
|
);
|
||||||
const on = btn.getAttribute("data-theme-value") === getStandalone();
|
return t;
|
||||||
btn.classList.toggle("is-active", on);
|
}
|
||||||
btn.setAttribute("aria-pressed", on ? "true" : "false");
|
|
||||||
});
|
function syncToggleUI(root) {
|
||||||
}
|
const scope = root || document;
|
||||||
|
const linked = isHubLinked();
|
||||||
function initToggleUI(root) {
|
const toggle = scope.querySelector(".instance-theme-toggle");
|
||||||
const scope = root || document;
|
if (toggle) {
|
||||||
syncToggleUI(scope);
|
toggle.classList.toggle("is-hub-linked", linked);
|
||||||
scope.querySelectorAll(".theme-toggle-btn[data-theme-value]").forEach((btn) => {
|
toggle.setAttribute("aria-hidden", linked ? "true" : "false");
|
||||||
if (btn.dataset.themeBound === "1") return;
|
}
|
||||||
btn.dataset.themeBound = "1";
|
if (linked) return;
|
||||||
btn.addEventListener("click", () => {
|
scope.querySelectorAll(".theme-toggle-btn[data-theme-value]").forEach((btn) => {
|
||||||
if (isHubLinked()) return;
|
const on = btn.getAttribute("data-theme-value") === getStandalone();
|
||||||
apply(btn.getAttribute("data-theme-value"));
|
btn.classList.toggle("is-active", on);
|
||||||
});
|
btn.setAttribute("aria-pressed", on ? "true" : "false");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function initMobileTopNav() {
|
function initToggleUI(root) {
|
||||||
const mq = window.matchMedia("(max-width: 720px)");
|
const scope = root || document;
|
||||||
|
syncToggleUI(scope);
|
||||||
function scrollActiveTab(nav) {
|
scope.querySelectorAll(".theme-toggle-btn[data-theme-value]").forEach((btn) => {
|
||||||
const active = nav.querySelector("a.active");
|
if (btn.dataset.themeBound === "1") return;
|
||||||
if (!active) return;
|
btn.dataset.themeBound = "1";
|
||||||
requestAnimationFrame(() => {
|
btn.addEventListener("click", () => {
|
||||||
try {
|
if (isHubLinked()) return;
|
||||||
active.scrollIntoView({ inline: "center", block: "nearest", behavior: "instant" });
|
apply(btn.getAttribute("data-theme-value"));
|
||||||
} catch (_) {
|
});
|
||||||
active.scrollIntoView(false);
|
});
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
function initMobileTopNav() {
|
||||||
|
const mq = window.matchMedia("(max-width: 720px)");
|
||||||
function apply() {
|
|
||||||
if (!mq.matches) return;
|
function scrollActiveTab(nav) {
|
||||||
document.querySelectorAll(".top-nav").forEach(scrollActiveTab);
|
const active = nav.querySelector("a.active");
|
||||||
}
|
if (!active) return;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
apply();
|
try {
|
||||||
mq.addEventListener("change", apply);
|
active.scrollIntoView({ inline: "center", block: "nearest", behavior: "instant" });
|
||||||
window.addEventListener("resize", apply);
|
} catch (_) {
|
||||||
window.addEventListener("orientationchange", apply);
|
active.scrollIntoView(false);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
function initFromHubMessage(data) {
|
}
|
||||||
if (!data || data.type !== "hub-theme-sync") return;
|
|
||||||
if (!isHubLinked()) return;
|
function apply() {
|
||||||
apply(data.theme, { skipStore: true });
|
if (!mq.matches) return;
|
||||||
}
|
document.querySelectorAll(".top-nav").forEach(scrollActiveTab);
|
||||||
|
}
|
||||||
function boot() {
|
|
||||||
if (isHubLinked()) {
|
apply();
|
||||||
apply(get(), { skipStore: true });
|
mq.addEventListener("change", apply);
|
||||||
window.addEventListener("message", (ev) => initFromHubMessage(ev.data));
|
window.addEventListener("resize", apply);
|
||||||
try {
|
window.addEventListener("orientationchange", apply);
|
||||||
window.parent.postMessage({ type: "instance-theme-ready" }, "*");
|
}
|
||||||
} catch (_) {}
|
|
||||||
} else {
|
function initFromHubMessage(data) {
|
||||||
apply(getStandalone());
|
if (!data || data.type !== "hub-theme-sync") return;
|
||||||
}
|
if (!isHubLinked()) return;
|
||||||
|
apply(data.theme, { skipStore: true });
|
||||||
function observeDynamicLists() {
|
}
|
||||||
["journal-list", "review-list"].forEach((id) => {
|
|
||||||
const el = document.getElementById(id);
|
/** 交易记录页:核对开关与按钮 disabled 保持同步(iframe 软导航/表单恢复后不触发 change) */
|
||||||
if (!el || el.dataset.instThemeObserved === "1") return;
|
function syncReviewEditButtons() {
|
||||||
el.dataset.instThemeObserved = "1";
|
const toggle = document.getElementById("review-mode-toggle");
|
||||||
new MutationObserver(() => {
|
if (!toggle) return;
|
||||||
syncInlineStyles(get());
|
const on = !!toggle.checked;
|
||||||
patchHubNavLinks(get());
|
document.querySelectorAll(".review-edit-btn").forEach((btn) => {
|
||||||
}).observe(el, {
|
btn.disabled = !on;
|
||||||
childList: true,
|
});
|
||||||
subtree: true,
|
}
|
||||||
});
|
|
||||||
});
|
function initReviewEditModeSync() {
|
||||||
}
|
const toggle = document.getElementById("review-mode-toggle");
|
||||||
|
if (!toggle) return;
|
||||||
const onReady = () => {
|
if (toggle.dataset.instReviewModeBound !== "1") {
|
||||||
initToggleUI();
|
toggle.dataset.instReviewModeBound = "1";
|
||||||
initMobileTopNav();
|
toggle.addEventListener("input", () => {
|
||||||
syncInlineStyles(get());
|
if (typeof global.toggleReviewMode === "function") global.toggleReviewMode();
|
||||||
patchHubNavLinks(get());
|
else syncReviewEditButtons();
|
||||||
observeDynamicLists();
|
});
|
||||||
};
|
}
|
||||||
if (document.readyState === "loading") {
|
const run = () => {
|
||||||
document.addEventListener("DOMContentLoaded", onReady);
|
if (typeof global.toggleReviewMode === "function") global.toggleReviewMode();
|
||||||
} else {
|
else syncReviewEditButtons();
|
||||||
onReady();
|
};
|
||||||
}
|
run();
|
||||||
document.addEventListener("instance-theme-change", (ev) => {
|
requestAnimationFrame(run);
|
||||||
const t = ev.detail && ev.detail.theme;
|
setTimeout(run, 0);
|
||||||
if (t) {
|
if (!global.__instReviewModePageshowBound) {
|
||||||
syncInlineStyles(t);
|
global.__instReviewModePageshowBound = true;
|
||||||
patchHubNavLinks(t);
|
window.addEventListener("pageshow", run);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
|
||||||
|
function notifyParentFrameNavStart() {
|
||||||
boot();
|
if (!isHubLinked()) return;
|
||||||
|
try {
|
||||||
global.InstanceTheme = {
|
window.parent.postMessage({ type: "instance-frame-navigating", theme: get() }, "*");
|
||||||
STANDALONE_KEY,
|
} catch (_) {}
|
||||||
HUB_LINKED_THEME_KEY,
|
}
|
||||||
isHubLinked,
|
|
||||||
get,
|
function notifyParentFrameReady() {
|
||||||
apply,
|
if (!isHubLinked()) return;
|
||||||
initToggleUI,
|
dismissNavOverlay();
|
||||||
syncToggleUI,
|
try {
|
||||||
syncInlineStyles,
|
window.parent.postMessage({ type: "instance-frame-ready", theme: get() }, "*");
|
||||||
patchHubNavLinks,
|
} catch (_) {}
|
||||||
mergeHubQueryIntoHref,
|
}
|
||||||
};
|
|
||||||
})(typeof window !== "undefined" ? window : globalThis);
|
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 =
|
||||||
|
'<style id="inst-nav-guard">html,body{background:' +
|
||||||
|
bg +
|
||||||
|
"!important;color-scheme:" +
|
||||||
|
t +
|
||||||
|
';}</style>';
|
||||||
|
if (out.includes("</head>")) {
|
||||||
|
out = out.replace("</head>", guard + "</head>");
|
||||||
|
} else {
|
||||||
|
out = guard + out;
|
||||||
|
}
|
||||||
|
out = out.replace(/<html([^>]*)>/i, (m, attrs) => {
|
||||||
|
if (/data-theme=/i.test(attrs)) {
|
||||||
|
return m.replace(/data-theme="[^"]*"/i, 'data-theme="' + t + '"');
|
||||||
|
}
|
||||||
|
return "<html" + attrs + ' data-theme="' + t + '">';
|
||||||
|
});
|
||||||
|
const overlay =
|
||||||
|
'<div id="inst-nav-overlay" aria-hidden="true" style="position:fixed;inset:0;z-index:2147483646;background:' +
|
||||||
|
bg +
|
||||||
|
';opacity:1;pointer-events:auto"></div>';
|
||||||
|
if (/<body[^>]*>/i.test(out)) {
|
||||||
|
out = out.replace(/<body([^>]*)>/i, "<body$1>" + 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);
|
||||||
@@ -1,9 +1,24 @@
|
|||||||
/* 紧接 instance_theme.js 之后加载,避免亮色下先闪暗色底 */
|
/* 紧接 instance_theme.js 之后加载,避免亮色下先闪暗色底 */
|
||||||
|
html {
|
||||||
|
background: #0b0d14;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] {
|
||||||
|
background: #d8e2ec;
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
html[data-theme="light"] body {
|
html[data-theme="light"] body {
|
||||||
background: #d8e2ec !important;
|
background: #d8e2ec !important;
|
||||||
color: #1a2838 !important;
|
color: #1a2838 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.review-edit-btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
html[data-theme="light"] .header h1 {
|
html[data-theme="light"] .header h1 {
|
||||||
color: #142232 !important;
|
color: #142232 !important;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
/**
|
||||||
|
* 三所实例共用 UI:复盘详情、盈亏着色等。
|
||||||
|
*/
|
||||||
|
(function (global) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s == null ? "" : s)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.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 ? `<span class="${cls}">${text}</span>` : 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("<br>");
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
? `<span class="badge ${dir.cls}">${escapeHtml(dir.text)}</span>`
|
||||||
|
: `<span class="mrr-muted">-</span>`;
|
||||||
|
const id = escapeHtml(o.id);
|
||||||
|
return `<div class="mobile-record-row-wrap">
|
||||||
|
<button type="button" class="mobile-record-row" onclick="openJournalDetail('${id}')">
|
||||||
|
<span class="mrr-symbol">${escapeHtml(o.coin || "-")} ${escapeHtml(o.tf || "")}</span>
|
||||||
|
<span class="mrr-dir">${dirHtml}</span>
|
||||||
|
<span class="mrr-pnl ${pnlCls}">${escapeHtml(o.pnl == null || o.pnl === "" ? "-" : o.pnl)}U</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="mobile-record-del" title="删除" onclick="deleteJournal('${id}')">×</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
const moodTags = (o.mood_issues || []).join(",") || "无";
|
||||||
|
const id = escapeHtml(o.id);
|
||||||
|
return `<div class="entry">
|
||||||
|
<div><strong>${escapeHtml(o.coin || "-")} ${escapeHtml(o.tf || "-")}</strong> | 盈亏:${escapeHtml(o.pnl == null || o.pnl === "" ? "-" : o.pnl)}U</div>
|
||||||
|
<div>开:${escapeHtml(o.open_datetime || "-")} 平:${escapeHtml(o.close_datetime || "-")} 持仓:${escapeHtml(o.hold_duration || "-")}</div>
|
||||||
|
<div>心态标签:${escapeHtml(moodTags)}</div>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:6px">
|
||||||
|
<button type="button" class="btn-del" style="border:none;cursor:pointer;background:#1f3a5a;color:#8fc8ff" onclick="openJournalDetail('${id}')">查看详情</button>
|
||||||
|
<button type="button" class="btn-del" onclick="deleteJournal('${id}')">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
})
|
||||||
|
.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 `<button type="button" class="mobile-record-row" data-row-id="${escapeHtml(row.rowId)}">
|
||||||
|
<span class="mrr-symbol">${escapeHtml(row.symbol)}</span>
|
||||||
|
<span class="mrr-dir">${row.directionHtml}</span>
|
||||||
|
<span class="mrr-pnl ${pnlCls}">${escapeHtml(row.pnlText || "-")}</span>
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tradeDetailRow(label, valueHtml) {
|
||||||
|
return `<div class="trd-row"><span class="trd-label">${escapeHtml(label)}</span><span class="trd-value">${valueHtml}</span></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTradeRecordDetailHtml(row) {
|
||||||
|
return `<div class="trade-record-detail">${
|
||||||
|
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)
|
||||||
|
}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
`<div class="detail-actions-inner">${row.actionsHtml}</div>`
|
||||||
|
);
|
||||||
|
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);
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* 关键位监控添加表单:类型切换显隐、成交量排名校验(三所实例共用)。
|
||||||
|
*/
|
||||||
|
(function (global) {
|
||||||
|
const RS_TYPES = new Set([
|
||||||
|
"关键支撑阻力",
|
||||||
|
"关键阻力位",
|
||||||
|
"关键支撑位",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function syncKeyMonitorFormFields() {
|
||||||
|
const typeEl = document.querySelector('#key-form [name="type"]');
|
||||||
|
const dirEl = document.getElementById("key-direction");
|
||||||
|
const modeEl = document.getElementById("key-sl-tp-mode");
|
||||||
|
const manualTp = document.getElementById("key-manual-tp");
|
||||||
|
const beWrap = document.getElementById("key-breakeven-wrap");
|
||||||
|
if (!typeEl) return;
|
||||||
|
const t = (typeEl.value || "").trim();
|
||||||
|
const autoTypes = new Set(["箱体突破", "收敛突破"]);
|
||||||
|
const fibTypes = new Set(["斐波回调0.618", "斐波回调0.786"]);
|
||||||
|
const fbTypes = new Set(["假突破"]);
|
||||||
|
const teTypes = new Set(["回调触价开仓", "突破触价开仓", "触价开仓"]);
|
||||||
|
const showAuto = autoTypes.has(t);
|
||||||
|
const showFb = fbTypes.has(t);
|
||||||
|
const showTe = teTypes.has(t);
|
||||||
|
const showBe = showAuto || fibTypes.has(t) || showFb || showTe;
|
||||||
|
const showDir = !RS_TYPES.has(t);
|
||||||
|
const upperEl = document.getElementById("key-upper");
|
||||||
|
const lowerEl = document.getElementById("key-lower");
|
||||||
|
const fbPriceEl = document.getElementById("key-fb-price");
|
||||||
|
const teEntryEl = document.getElementById("key-trigger-entry");
|
||||||
|
const teSlEl = document.getElementById("key-trigger-sl");
|
||||||
|
const teTpEl = document.getElementById("key-trigger-tp");
|
||||||
|
if (dirEl) {
|
||||||
|
dirEl.style.display = showDir ? "" : "none";
|
||||||
|
dirEl.required = showDir;
|
||||||
|
if (!showDir) dirEl.value = "";
|
||||||
|
}
|
||||||
|
if (modeEl) modeEl.style.display = showAuto ? "" : "none";
|
||||||
|
if (manualTp) {
|
||||||
|
const trend = showAuto && modeEl && modeEl.value === "trend_manual";
|
||||||
|
manualTp.style.display = trend ? "" : "none";
|
||||||
|
manualTp.required = !!trend;
|
||||||
|
}
|
||||||
|
if (beWrap) beWrap.style.display = showBe ? "inline-flex" : "none";
|
||||||
|
if (global.TimeCloseUI) global.TimeCloseUI.syncKeyTimeCloseVisibility(showBe);
|
||||||
|
const hideBounds = showFb || showTe;
|
||||||
|
if (upperEl) {
|
||||||
|
upperEl.style.display = hideBounds ? "none" : "";
|
||||||
|
upperEl.required = !hideBounds;
|
||||||
|
if (hideBounds) upperEl.value = "";
|
||||||
|
}
|
||||||
|
if (lowerEl) {
|
||||||
|
lowerEl.style.display = hideBounds ? "none" : "";
|
||||||
|
lowerEl.required = !hideBounds;
|
||||||
|
if (hideBounds) lowerEl.value = "";
|
||||||
|
}
|
||||||
|
if (fbPriceEl) {
|
||||||
|
fbPriceEl.style.display = showFb ? "" : "none";
|
||||||
|
fbPriceEl.required = showFb;
|
||||||
|
if (!showFb) fbPriceEl.value = "";
|
||||||
|
fbPriceEl.placeholder =
|
||||||
|
dirEl && dirEl.value === "short"
|
||||||
|
? "高点(阻力)"
|
||||||
|
: dirEl && dirEl.value === "long"
|
||||||
|
? "低点(支撑)"
|
||||||
|
: "做空填高点/做多填低点";
|
||||||
|
}
|
||||||
|
[teEntryEl, teSlEl, teTpEl].forEach((el) => {
|
||||||
|
if (!el) return;
|
||||||
|
el.style.display = showTe ? "" : "none";
|
||||||
|
el.required = showTe;
|
||||||
|
if (!showTe) el.value = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitKeyForm(keyForm, label) {
|
||||||
|
if (
|
||||||
|
document.body &&
|
||||||
|
document.body.getAttribute("data-embed-shell") === "1" &&
|
||||||
|
global.InstanceEmbed &&
|
||||||
|
typeof global.InstanceEmbed.postFormAndReload === "function"
|
||||||
|
) {
|
||||||
|
global.InstanceEmbed.postFormAndReload(keyForm, label || "提交中…");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (global.FormSubmitGuard) global.FormSubmitGuard.nativeSubmitOnce(keyForm, label || "提交中…");
|
||||||
|
else keyForm.submit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindKeyMonitorForm() {
|
||||||
|
const keyForm = document.getElementById("key-form");
|
||||||
|
const keyTypeSel = document.querySelector('#key-form [name="type"]');
|
||||||
|
const keyModeSel = document.getElementById("key-sl-tp-mode");
|
||||||
|
const keyDirSel = document.getElementById("key-direction");
|
||||||
|
if (keyTypeSel) keyTypeSel.addEventListener("change", syncKeyMonitorFormFields);
|
||||||
|
if (keyModeSel) keyModeSel.addEventListener("change", syncKeyMonitorFormFields);
|
||||||
|
if (keyDirSel) keyDirSel.addEventListener("change", syncKeyMonitorFormFields);
|
||||||
|
syncKeyMonitorFormFields();
|
||||||
|
if (global.TimeCloseUI) {
|
||||||
|
global.TimeCloseUI.bindTimeCloseForm(
|
||||||
|
"key-time-close-cb",
|
||||||
|
"key-time-close-hours",
|
||||||
|
"key-time-close-wrap"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!keyForm || keyForm.dataset.keyFormBound === "1") return;
|
||||||
|
keyForm.dataset.keyFormBound = "1";
|
||||||
|
keyForm.addEventListener("submit", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (global.FormSubmitGuard && global.FormSubmitGuard.isLocked(keyForm)) return;
|
||||||
|
const symbolEl = keyForm.querySelector('[name="symbol"]');
|
||||||
|
const symbol = (symbolEl ? symbolEl.value : "").trim();
|
||||||
|
if (!symbol) {
|
||||||
|
alert("请先输入交易对");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const typeVal = (keyForm.querySelector('[name="type"]') || {}).value || "";
|
||||||
|
if (typeVal === "假突破") {
|
||||||
|
submitKeyForm(keyForm, "提交中…");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (global.FormSubmitGuard) global.FormSubmitGuard.lock(keyForm, "校验排名中…");
|
||||||
|
fetch(`/api/symbol_liquidity_rank?symbol=${encodeURIComponent(symbol)}`)
|
||||||
|
.then((r) => r.json().then((d) => ({ status: r.status, data: d })))
|
||||||
|
.then(({ status, data }) => {
|
||||||
|
if (status >= 400 || !data.ok) {
|
||||||
|
alert((data && data.msg) || "日成交量排名读取失败");
|
||||||
|
if (global.FormSubmitGuard) global.FormSubmitGuard.unlock(keyForm);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rankMax = data.rank_max || 30;
|
||||||
|
const inTop = data.in_top != null ? data.in_top : data.in_top30;
|
||||||
|
if (data.rank == null || !inTop) {
|
||||||
|
alert(
|
||||||
|
`${data.symbol} 当前日成交量排名 ${data.rank == null ? "—" : data.rank}/${data.total},不在前${rankMax},已拦截。`
|
||||||
|
);
|
||||||
|
if (global.FormSubmitGuard) global.FormSubmitGuard.unlock(keyForm);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitKeyForm(keyForm, "提交中…");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
alert("日成交量排名检查失败,请稍后重试");
|
||||||
|
if (global.FormSubmitGuard) global.FormSubmitGuard.unlock(keyForm);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
global.KeyMonitorForm = {
|
||||||
|
syncFields: syncKeyMonitorFormFields,
|
||||||
|
init: bindKeyMonitorForm,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", bindKeyMonitorForm);
|
||||||
|
} else {
|
||||||
|
bindKeyMonitorForm();
|
||||||
|
}
|
||||||
|
})(typeof window !== "undefined" ? window : globalThis);
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
/**
|
||||||
|
* 实盘下单:填完币种与止盈止损后,在表单下方显示预估风险 / 预估盈利 / 预估盈亏比。
|
||||||
|
* 以损定仓:风险 = 当前交易基数 × risk%。
|
||||||
|
* 全仓杠杆:风险 = 可用保证金×缓冲 × 杠杆 × |SL-入场|/入场(与开仓 calc_risk_amount_from_plan 一致)。
|
||||||
|
*/
|
||||||
|
(function (global) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
let debounceMs = 400;
|
||||||
|
let minRr = 1.5;
|
||||||
|
let debounceTimer = null;
|
||||||
|
let fetchSeq = 0;
|
||||||
|
|
||||||
|
function $(id) {
|
||||||
|
return document.getElementById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function num(v) {
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRr(rr) {
|
||||||
|
if (rr === null || typeof rr === "undefined") return "—";
|
||||||
|
const n = Number(rr);
|
||||||
|
if (!Number.isFinite(n)) return "—";
|
||||||
|
const body = Number.isInteger(n) ? String(n) : String(parseFloat(n.toFixed(2)));
|
||||||
|
return body + ":1";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatU(v) {
|
||||||
|
if (v === null || typeof v === "undefined" || !Number.isFinite(Number(v))) return "—";
|
||||||
|
return Number(v).toFixed(2) + "U";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMetric(el, label, valueText) {
|
||||||
|
if (!el) return;
|
||||||
|
el.innerHTML = label + ":<strong>" + valueText + "</strong>";
|
||||||
|
}
|
||||||
|
|
||||||
|
function sizingMode() {
|
||||||
|
return (document.body && document.body.getAttribute("data-position-sizing-mode")) || "risk";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFullMarginMode() {
|
||||||
|
return sizingMode() === "full_margin";
|
||||||
|
}
|
||||||
|
|
||||||
|
function fullMarginBuffer() {
|
||||||
|
const n = Number(document.body && document.body.getAttribute("data-full-margin-buffer"));
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
function leverageForSymbol(sym) {
|
||||||
|
const u = (sym || "").trim().toUpperCase();
|
||||||
|
const btc = Number(document.body && document.body.getAttribute("data-btc-leverage"));
|
||||||
|
const alt = Number(document.body && document.body.getAttribute("data-alt-leverage"));
|
||||||
|
if (u.startsWith("BTC") || u.startsWith("ETH")) {
|
||||||
|
return Number.isFinite(btc) && btc > 0 ? btc : 10;
|
||||||
|
}
|
||||||
|
return Number.isFinite(alt) && alt > 0 ? alt : 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
function riskPercent() {
|
||||||
|
const form = $("add-order-form");
|
||||||
|
const raw =
|
||||||
|
(form && form.getAttribute("data-risk-percent")) ||
|
||||||
|
(document.body && document.body.getAttribute("data-risk-percent")) ||
|
||||||
|
"";
|
||||||
|
const n = Number(raw);
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcRiskFraction(direction, entry, sl) {
|
||||||
|
const e = num(entry);
|
||||||
|
const s = num(sl);
|
||||||
|
if (e === null || s === null || e <= 0 || s <= 0) return null;
|
||||||
|
let risk = 0;
|
||||||
|
if (direction === "short") {
|
||||||
|
risk = s - e;
|
||||||
|
} else {
|
||||||
|
risk = e - s;
|
||||||
|
}
|
||||||
|
if (risk <= 0) return null;
|
||||||
|
return risk / e;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcRr(direction, entry, sl, tp) {
|
||||||
|
const e = num(entry);
|
||||||
|
const s = num(sl);
|
||||||
|
const t = num(tp);
|
||||||
|
if (e === null || s === null || t === null) return null;
|
||||||
|
if (direction === "short") {
|
||||||
|
if (s <= e || t >= e) return null;
|
||||||
|
return (e - t) / (s - e);
|
||||||
|
}
|
||||||
|
if (s >= e || t <= e) return null;
|
||||||
|
return (t - e) / (e - s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcRrFromPct(slPct, tpPct) {
|
||||||
|
const sl = num(slPct);
|
||||||
|
const tp = num(tpPct);
|
||||||
|
if (sl === null || tp === null || sl <= 0 || tp <= 0) return null;
|
||||||
|
return tp / sl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcTpFromFixedRr(direction, entry, sl, rr) {
|
||||||
|
const e = num(entry);
|
||||||
|
const s = num(sl);
|
||||||
|
const r = num(rr);
|
||||||
|
if (e === null || s === null || r === null || r <= 0) return null;
|
||||||
|
if (direction === "short") {
|
||||||
|
if (s <= e) return null;
|
||||||
|
return e - (s - e) * r;
|
||||||
|
}
|
||||||
|
if (s >= e) return null;
|
||||||
|
return e + (e - s) * r;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSlPrice(mode, direction, entry) {
|
||||||
|
if (mode === "pct") {
|
||||||
|
const slPct = num($("order-sl-pct") && $("order-sl-pct").value);
|
||||||
|
if (slPct === null || slPct <= 0) return null;
|
||||||
|
if (direction === "short") return entry * (1 + slPct / 100);
|
||||||
|
return entry * (1 - slPct / 100);
|
||||||
|
}
|
||||||
|
return num($("order-sl") && $("order-sl").value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentMode() {
|
||||||
|
return ($("sltp-mode") && $("sltp-mode").value) || "fixed_rr";
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentDirection() {
|
||||||
|
return ($("order-direction") && $("order-direction").value) || "long";
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentSymbol() {
|
||||||
|
return (($("order-symbol") && $("order-symbol").value) || "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputsComplete(m) {
|
||||||
|
const dir = currentDirection();
|
||||||
|
if (!currentSymbol() || !dir) return false;
|
||||||
|
if (m === "pct") {
|
||||||
|
const sl = num($("order-sl-pct") && $("order-sl-pct").value);
|
||||||
|
const tp = num($("order-tp-pct") && $("order-tp-pct").value);
|
||||||
|
return sl !== null && tp !== null && sl > 0 && tp > 0;
|
||||||
|
}
|
||||||
|
if (m === "fixed_rr") {
|
||||||
|
const sl = num($("order-sl") && $("order-sl").value);
|
||||||
|
const rr = num($("order-fixed-rr") && $("order-fixed-rr").value);
|
||||||
|
return sl !== null && rr !== null && sl > 0 && rr > 0;
|
||||||
|
}
|
||||||
|
const sl = num($("order-sl") && $("order-sl").value);
|
||||||
|
const tp = num($("order-tp") && $("order-tp").value);
|
||||||
|
return sl !== null && tp !== null && sl > 0 && tp > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintEmpty() {
|
||||||
|
setMetric($("order-risk-preview"), "预估风险", "—");
|
||||||
|
setMetric($("order-profit-preview"), "预估盈利", "—");
|
||||||
|
setMetric($("order-rr-preview"), "预估盈亏比", "—");
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintLoading() {
|
||||||
|
setMetric($("order-risk-preview"), "预估风险", "计算中…");
|
||||||
|
setMetric($("order-profit-preview"), "预估盈利", "计算中…");
|
||||||
|
setMetric($("order-rr-preview"), "预估盈亏比", "计算中…");
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintFail(kind) {
|
||||||
|
const msg = kind === "fetch_fail" ? "取价失败" : "无效";
|
||||||
|
setMetric($("order-risk-preview"), "预估风险", msg);
|
||||||
|
setMetric($("order-profit-preview"), "预估盈利", msg);
|
||||||
|
setMetric($("order-rr-preview"), "预估盈亏比", msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintOk(riskU, profitU, rr) {
|
||||||
|
setMetric($("order-risk-preview"), "预估风险", formatU(riskU));
|
||||||
|
setMetric($("order-profit-preview"), "预估盈利", formatU(profitU));
|
||||||
|
const rrEl = $("order-rr-preview");
|
||||||
|
const rrText = formatRr(rr);
|
||||||
|
setMetric(rrEl, "预估盈亏比", rrText);
|
||||||
|
if (rrEl && rr !== null && Number.isFinite(Number(rr))) {
|
||||||
|
rrEl.classList.toggle("order-preview-rr-low", Number(rr) < minRr);
|
||||||
|
rrEl.classList.toggle("order-preview-rr-ok", Number(rr) >= minRr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function plannedRiskFromRiskMode(capital) {
|
||||||
|
const cap = num(capital);
|
||||||
|
if (cap === null || cap <= 0) return null;
|
||||||
|
return Math.round((cap * riskPercent()) / 100 * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function plannedRiskFromFullMargin(availableUsdt, symbol, direction, entry, sl) {
|
||||||
|
const avail = num(availableUsdt);
|
||||||
|
if (avail === null || avail <= 0) return null;
|
||||||
|
const slPx = num(sl);
|
||||||
|
const entryPx = num(entry);
|
||||||
|
if (slPx === null || entryPx === null) return null;
|
||||||
|
const rf = calcRiskFraction(direction, entryPx, slPx);
|
||||||
|
if (rf === null) return null;
|
||||||
|
const margin = Math.round(avail * fullMarginBuffer() * 100) / 100;
|
||||||
|
const lev = leverageForSymbol(symbol);
|
||||||
|
return Math.round(margin * lev * rf * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePreviewRr(m, dir, entry) {
|
||||||
|
if (m === "pct") {
|
||||||
|
return calcRrFromPct(
|
||||||
|
$("order-sl-pct") && $("order-sl-pct").value,
|
||||||
|
$("order-tp-pct") && $("order-tp-pct").value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const sl = num($("order-sl") && $("order-sl").value);
|
||||||
|
if (m === "fixed_rr") {
|
||||||
|
const fixed = num($("order-fixed-rr") && $("order-fixed-rr").value);
|
||||||
|
if (fixed !== null && fixed > 0) return fixed;
|
||||||
|
const tp = calcTpFromFixedRr(dir, entry, sl, fixed);
|
||||||
|
return calcRr(dir, entry, sl, tp);
|
||||||
|
}
|
||||||
|
const tp = num($("order-tp") && $("order-tp").value);
|
||||||
|
return calcRr(dir, entry, sl, tp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshNow() {
|
||||||
|
if (!$("order-plan-preview")) return;
|
||||||
|
const m = currentMode();
|
||||||
|
if (!inputsComplete(m)) {
|
||||||
|
paintEmpty();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sym = currentSymbol();
|
||||||
|
const dir = currentDirection();
|
||||||
|
const seq = ++fetchSeq;
|
||||||
|
paintLoading();
|
||||||
|
|
||||||
|
const defaultsP = fetch(
|
||||||
|
"/api/order_defaults?symbol=" +
|
||||||
|
encodeURIComponent(sym) +
|
||||||
|
"&direction=" +
|
||||||
|
encodeURIComponent(dir)
|
||||||
|
).then(function (r) {
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
|
||||||
|
const capitalP = fetch("/api/account_snapshot").then(function (r) {
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
|
||||||
|
Promise.all([defaultsP, capitalP])
|
||||||
|
.then(function (results) {
|
||||||
|
if (seq !== fetchSeq) return;
|
||||||
|
const data = results[0];
|
||||||
|
const account = results[1] || {};
|
||||||
|
if (!data.ok) {
|
||||||
|
paintFail("fetch_fail");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const entry = num(data.last_price != null ? data.last_price : data.price);
|
||||||
|
if (entry === null) {
|
||||||
|
paintFail("fetch_fail");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rr = resolvePreviewRr(m, dir, entry);
|
||||||
|
if (rr === null) {
|
||||||
|
paintFail("invalid");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let riskU = null;
|
||||||
|
if (isFullMarginMode()) {
|
||||||
|
const slPx = resolveSlPrice(m, dir, entry);
|
||||||
|
const avail =
|
||||||
|
data.available_trading_usdt != null
|
||||||
|
? data.available_trading_usdt
|
||||||
|
: account.available_trading_usdt;
|
||||||
|
riskU = plannedRiskFromFullMargin(avail, sym, dir, entry, slPx);
|
||||||
|
} else {
|
||||||
|
riskU = plannedRiskFromRiskMode(account.current_capital);
|
||||||
|
}
|
||||||
|
if (riskU === null) {
|
||||||
|
paintFail("fetch_fail");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const profitU = Math.round(riskU * rr * 100) / 100;
|
||||||
|
paintOk(riskU, profitU, rr);
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
if (seq !== fetchSeq) return;
|
||||||
|
paintFail("fetch_fail");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedule() {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(refreshNow, debounceMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function wire(opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
if (opts.minRr != null && Number.isFinite(Number(opts.minRr))) {
|
||||||
|
minRr = Number(opts.minRr);
|
||||||
|
}
|
||||||
|
if (opts.debounceMs != null && Number.isFinite(Number(opts.debounceMs))) {
|
||||||
|
debounceMs = Number(opts.debounceMs);
|
||||||
|
}
|
||||||
|
[
|
||||||
|
"order-symbol",
|
||||||
|
"order-direction",
|
||||||
|
"sltp-mode",
|
||||||
|
"order-sl",
|
||||||
|
"order-tp",
|
||||||
|
"order-sl-pct",
|
||||||
|
"order-tp-pct",
|
||||||
|
"order-fixed-rr",
|
||||||
|
"order-leverage",
|
||||||
|
].forEach(function (id) {
|
||||||
|
const el = $(id);
|
||||||
|
if (!el || el._rrPreviewBound) return;
|
||||||
|
el._rrPreviewBound = true;
|
||||||
|
el.addEventListener("input", schedule);
|
||||||
|
el.addEventListener("change", schedule);
|
||||||
|
});
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
global.ManualOrderRrPreview = {
|
||||||
|
wire: wire,
|
||||||
|
schedule: schedule,
|
||||||
|
refresh: refreshNow,
|
||||||
|
calcRr: calcRr,
|
||||||
|
calcRrFromPct: calcRrFromPct,
|
||||||
|
calcRiskFraction: calcRiskFraction,
|
||||||
|
formatRr: formatRr,
|
||||||
|
};
|
||||||
|
})(typeof window !== "undefined" ? window : globalThis);
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
function syncRollFormMode(form, mode) {
|
||||||
|
if (!form) return;
|
||||||
|
const m = mode || "market";
|
||||||
|
form.setAttribute("data-add-mode", m);
|
||||||
|
const showFib = m === "fib_618" || m === "fib_786";
|
||||||
|
const showBreakout = m === "breakout";
|
||||||
|
const fibWrap = form.querySelector(".roll-field-fib");
|
||||||
|
const breakoutWrap = form.querySelector(".roll-field-breakout");
|
||||||
|
const fibUpper = form.querySelector("#roll-fib-upper");
|
||||||
|
const fibLower = form.querySelector("#roll-fib-lower");
|
||||||
|
const breakoutInput = form.querySelector("#roll-breakout");
|
||||||
|
|
||||||
|
function tuneInput(inp, active, required) {
|
||||||
|
if (!inp) return;
|
||||||
|
inp.disabled = !active;
|
||||||
|
inp.required = !!required && active;
|
||||||
|
inp.tabIndex = active ? 0 : -1;
|
||||||
|
if (!active) inp.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fibWrap) fibWrap.setAttribute("aria-hidden", showFib ? "false" : "true");
|
||||||
|
if (breakoutWrap) breakoutWrap.setAttribute("aria-hidden", showBreakout ? "false" : "true");
|
||||||
|
tuneInput(fibUpper, showFib, showFib);
|
||||||
|
tuneInput(fibLower, showFib, showFib);
|
||||||
|
tuneInput(breakoutInput, showBreakout, showBreakout);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.syncRollFormMode = syncRollFormMode;
|
||||||
|
|
||||||
|
function isEmbedShell() {
|
||||||
|
return document.body && document.body.getAttribute("data-embed-shell") === "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitRollForm(form) {
|
||||||
|
if (isEmbedShell() && window.InstanceEmbed && typeof window.InstanceEmbed.postFormAndReload === "function") {
|
||||||
|
window.InstanceEmbed.postFormAndReload(form, "执行中…");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (window.FormSubmitGuard && typeof window.FormSubmitGuard.nativeSubmitOnce === "function") {
|
||||||
|
window.FormSubmitGuard.nativeSubmitOnce(form, "执行中…");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initStrategyRollForm() {
|
||||||
|
const form = document.getElementById("roll-form");
|
||||||
|
if (!form) return;
|
||||||
|
if (form.dataset.rollJsInit === "1") return;
|
||||||
|
form.dataset.rollJsInit = "1";
|
||||||
|
|
||||||
|
const symbolSel = document.getElementById("roll-symbol");
|
||||||
|
const dirInput = document.getElementById("roll-direction");
|
||||||
|
const modeSel = document.getElementById("roll-add-mode");
|
||||||
|
const riskBanner = document.getElementById("roll-risk-banner");
|
||||||
|
const previewBtn = document.getElementById("roll-preview-btn");
|
||||||
|
const submitBtn = document.getElementById("roll-submit-btn");
|
||||||
|
const previewBox = document.getElementById("roll-preview-box");
|
||||||
|
const previewText = document.getElementById("roll-preview-text");
|
||||||
|
const countdownEl = document.getElementById("roll-countdown");
|
||||||
|
const trendLocked = submitBtn && submitBtn.getAttribute("data-trend-locked") === "1";
|
||||||
|
|
||||||
|
let countdownTimer = null;
|
||||||
|
let previewOk = false;
|
||||||
|
let lastPreviewMode = "";
|
||||||
|
let monitorSubmitting = false;
|
||||||
|
|
||||||
|
function isMarketMode() {
|
||||||
|
return (modeSel.value || "market") === "market";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMonitorMode() {
|
||||||
|
const m = modeSel.value || "market";
|
||||||
|
return m === "fib_618" || m === "fib_786" || m === "breakout";
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedOption() {
|
||||||
|
return symbolSel.options[symbolSel.selectedIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncDirectionLock() {
|
||||||
|
const opt = selectedOption();
|
||||||
|
if (!opt || !opt.value) {
|
||||||
|
riskBanner.textContent = "当前风险:请选择持仓币种";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dir = opt.getAttribute("data-direction") || "long";
|
||||||
|
const rp = opt.getAttribute("data-risk-percent") || "—";
|
||||||
|
dirInput.value = dir;
|
||||||
|
riskBanner.textContent =
|
||||||
|
"当前风险:" + rp + "%(来自监控单 #" + (opt.getAttribute("data-monitor-id") || "?") + ")";
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSubmitButton() {
|
||||||
|
if (!submitBtn || trendLocked) return;
|
||||||
|
if (isMonitorMode()) {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.removeAttribute("disabled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blocked = !previewOk || !!countdownTimer;
|
||||||
|
submitBtn.disabled = blocked;
|
||||||
|
if (!blocked) submitBtn.removeAttribute("disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearMessageBox() {
|
||||||
|
if (!previewBox) return;
|
||||||
|
previewBox.style.display = "none";
|
||||||
|
previewBox.classList.remove("is-error", "is-preview");
|
||||||
|
if (previewText) previewText.textContent = "";
|
||||||
|
if (countdownEl) countdownEl.style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function showReject(msg) {
|
||||||
|
if (!previewBox || !previewText) return;
|
||||||
|
previewBox.style.display = "block";
|
||||||
|
previewBox.classList.remove("is-preview");
|
||||||
|
previewBox.classList.add("is-error");
|
||||||
|
previewText.textContent = msg || "无法执行";
|
||||||
|
if (countdownEl) countdownEl.style.display = "none";
|
||||||
|
previewBox.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPreviewResult(p) {
|
||||||
|
if (!previewBox || !previewText) return;
|
||||||
|
previewBox.style.display = "block";
|
||||||
|
previewBox.classList.remove("is-error");
|
||||||
|
previewBox.classList.add("is-preview");
|
||||||
|
previewText.innerHTML =
|
||||||
|
"<strong>" +
|
||||||
|
(p.add_mode_label || "") +
|
||||||
|
"</strong> · 约 <strong>" +
|
||||||
|
(p.add_amount_display != null ? p.add_amount_display : p.add_amount_raw) +
|
||||||
|
"</strong> 张<br>" +
|
||||||
|
"加仓参考价 " +
|
||||||
|
(p.add_price_display != null ? p.add_price_display : p.add_price) +
|
||||||
|
" · 新止损 " +
|
||||||
|
(p.new_sl_display != null ? p.new_sl_display : p.new_stop_loss) +
|
||||||
|
"<br>" +
|
||||||
|
"合并均价 " +
|
||||||
|
p.avg_entry_after +
|
||||||
|
" · 打到止损约 " +
|
||||||
|
p.loss_at_sl_usdt +
|
||||||
|
"U(风险预算 " +
|
||||||
|
(p.risk_budget_usdt != null ? p.risk_budget_usdt : "—") +
|
||||||
|
"U)";
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncFieldVisibility() {
|
||||||
|
syncRollFormMode(form, modeSel.value || "market");
|
||||||
|
resetPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPreview() {
|
||||||
|
previewOk = false;
|
||||||
|
monitorSubmitting = false;
|
||||||
|
clearMessageBox();
|
||||||
|
if (countdownTimer) {
|
||||||
|
clearInterval(countdownTimer);
|
||||||
|
countdownTimer = null;
|
||||||
|
}
|
||||||
|
syncSubmitButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formPayload() {
|
||||||
|
const fd = new FormData(form);
|
||||||
|
const obj = {};
|
||||||
|
fd.forEach(function (v, k) {
|
||||||
|
if (v !== "") obj[k] = v;
|
||||||
|
});
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestPreview() {
|
||||||
|
return fetch("/strategy/roll/preview", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||||
|
body: JSON.stringify(formPayload()),
|
||||||
|
credentials: "same-origin",
|
||||||
|
}).then(function (r) {
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runPreview() {
|
||||||
|
resetPreview();
|
||||||
|
if (!symbolSel.value) {
|
||||||
|
showReject("请先选择持仓币种");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (previewBtn) previewBtn.disabled = true;
|
||||||
|
requestPreview()
|
||||||
|
.then(function (data) {
|
||||||
|
if (previewBtn) previewBtn.disabled = false;
|
||||||
|
if (!data.ok) {
|
||||||
|
showReject(data.msg || "预览失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const p = data.preview || {};
|
||||||
|
lastPreviewMode = p.add_mode || modeSel.value;
|
||||||
|
showPreviewResult(p);
|
||||||
|
previewOk = true;
|
||||||
|
if (lastPreviewMode === "market") {
|
||||||
|
startCountdown(10);
|
||||||
|
} else {
|
||||||
|
syncSubmitButton();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
if (previewBtn) previewBtn.disabled = false;
|
||||||
|
showReject("预览请求失败,请稍后重试");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runMonitorSubmit() {
|
||||||
|
if (monitorSubmitting) return;
|
||||||
|
if (!symbolSel.value) {
|
||||||
|
showReject("请先选择持仓币种");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
monitorSubmitting = true;
|
||||||
|
if (submitBtn) submitBtn.disabled = true;
|
||||||
|
requestPreview()
|
||||||
|
.then(function (data) {
|
||||||
|
monitorSubmitting = false;
|
||||||
|
if (submitBtn && !trendLocked) {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.removeAttribute("disabled");
|
||||||
|
}
|
||||||
|
if (!data.ok) {
|
||||||
|
showReject(data.msg || "无法提交监控");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const p = data.preview || {};
|
||||||
|
const modeLabel = modeSel.options[modeSel.selectedIndex].text;
|
||||||
|
const summary =
|
||||||
|
"约 " +
|
||||||
|
(p.add_amount_display != null ? p.add_amount_display : p.add_amount_raw) +
|
||||||
|
" 张 · 触发参考价 " +
|
||||||
|
(p.add_price_display != null ? p.add_price_display : p.add_price) +
|
||||||
|
" · 新止损 " +
|
||||||
|
(p.new_sl_display != null ? p.new_sl_display : p.new_stop_loss);
|
||||||
|
if (!confirm("确认提交「" + modeLabel + "」?\n" + summary)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitRollForm(form);
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
monitorSubmitting = false;
|
||||||
|
if (submitBtn && !trendLocked) {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.removeAttribute("disabled");
|
||||||
|
}
|
||||||
|
showReject("校验请求失败,请稍后重试");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCountdown(sec) {
|
||||||
|
let left = sec;
|
||||||
|
if (submitBtn) submitBtn.disabled = true;
|
||||||
|
if (countdownEl) {
|
||||||
|
countdownEl.style.display = "block";
|
||||||
|
countdownEl.textContent = "市价加仓:" + left + " 秒后可执行(修改表单将取消预览)";
|
||||||
|
}
|
||||||
|
countdownTimer = setInterval(function () {
|
||||||
|
left -= 1;
|
||||||
|
if (left <= 0) {
|
||||||
|
clearInterval(countdownTimer);
|
||||||
|
countdownTimer = null;
|
||||||
|
if (countdownEl) countdownEl.textContent = "可以执行市价加仓";
|
||||||
|
syncSubmitButton();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (countdownEl) countdownEl.textContent = "市价加仓:" + left + " 秒后可执行";
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
symbolSel.addEventListener("change", function () {
|
||||||
|
syncDirectionLock();
|
||||||
|
resetPreview();
|
||||||
|
});
|
||||||
|
modeSel.addEventListener("change", syncFieldVisibility);
|
||||||
|
form.addEventListener("input", resetPreview);
|
||||||
|
form.addEventListener("change", function (e) {
|
||||||
|
if (e.target !== previewBtn) resetPreview();
|
||||||
|
});
|
||||||
|
if (previewBtn) previewBtn.addEventListener("click", runPreview);
|
||||||
|
form.addEventListener("submit", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (isMonitorMode()) {
|
||||||
|
runMonitorSubmit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!previewOk) {
|
||||||
|
showReject("请先点击「预览」并通过校验");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (submitBtn && submitBtn.disabled) {
|
||||||
|
showReject("请等待 10 秒确认倒计时结束后再执行市价加仓");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const modeLabel = modeSel.options[modeSel.selectedIndex].text;
|
||||||
|
if (!confirm("确认提交「" + modeLabel + "」?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitRollForm(form);
|
||||||
|
});
|
||||||
|
|
||||||
|
syncDirectionLock();
|
||||||
|
syncFieldVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.initStrategyRollForm = initStrategyRollForm;
|
||||||
|
initStrategyRollForm();
|
||||||
|
})();
|
||||||
@@ -0,0 +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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
/**
|
||||||
|
* 交易日历组件:内照明心档案 + 三所统计分析共用。
|
||||||
|
*/
|
||||||
|
(function (global) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"];
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s == null ? "" : s)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.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 =
|
||||||
|
'<div class="trade-cal-weekdays">' +
|
||||||
|
WEEKDAYS.map(function (w) {
|
||||||
|
return '<span class="trade-cal-wd">' + w + "</span>";
|
||||||
|
}).join("") +
|
||||||
|
'</div><div class="trade-cal-grid">';
|
||||||
|
var i;
|
||||||
|
for (i = 0; i < startWd; i++) {
|
||||||
|
html += '<span class="trade-cal-cell trade-cal-pad"></span>';
|
||||||
|
}
|
||||||
|
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 = '<span class="trade-cal-day-num">' + d + "</span>";
|
||||||
|
if (hasTrade) {
|
||||||
|
body +=
|
||||||
|
'<span class="trade-cal-pnl">' +
|
||||||
|
esc(formatCalPnl(pnl)) +
|
||||||
|
"</span>" +
|
||||||
|
'<span class="trade-cal-cnt">' +
|
||||||
|
cnt +
|
||||||
|
"笔</span>";
|
||||||
|
if (sick) body += '<span class="trade-cal-sick-tag">犯病</span>';
|
||||||
|
}
|
||||||
|
html +=
|
||||||
|
'<button type="button" class="' +
|
||||||
|
cls +
|
||||||
|
'" data-day="' +
|
||||||
|
dayStr +
|
||||||
|
'" data-sick="' +
|
||||||
|
(sick ? "1" : "0") +
|
||||||
|
'"' +
|
||||||
|
(hasTrade ? "" : " disabled") +
|
||||||
|
">" +
|
||||||
|
body +
|
||||||
|
"</button>";
|
||||||
|
}
|
||||||
|
html += "</div>";
|
||||||
|
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);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Shared library package."""
|
||||||