first commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
# Shell 脚本在仓库内统一 LF,避免 Linux 上 bash: pipefail: invalid option name(CRLF)
|
||||||
|
*.sh text eol=lf
|
||||||
|
deploy/** text eol=lf
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
# 本地真实配置(含 API 密钥),勿提交
|
||||||
|
**/.env
|
||||||
|
.env
|
||||||
|
|
||||||
|
# 允许提交模板
|
||||||
|
!**/.env.example
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Python
|
||||||
|
**/__pycache__/
|
||||||
|
**/*.pyc
|
||||||
|
**/.venv/
|
||||||
|
|
||||||
|
# 本地备份(可选,勿提交)
|
||||||
|
**/.env.backup*
|
||||||
|
**/.env.bak
|
||||||
|
**/.env.local
|
||||||
|
|
||||||
|
# 数据库与上传(运行时生成)
|
||||||
|
**/*.sqlite
|
||||||
|
**/crypto.db
|
||||||
|
|
||||||
|
# 整机许可缓存(仓库根 .license/)
|
||||||
|
.license/
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# 复盘交易系统(crypto_monitor)
|
||||||
|
|
||||||
|
本仓库为 **多交易所 USDT 永续** 的下单监控、关键位监控与交易复盘工具集:四个子项目分别对接 **Binance、Gate.io(主号)、Gate.io(机器人/趋势策略)、OKX**,共享相似的 Flask 架构与本地 SQLite 记账思路,可按账户独立部署、独立端口运行。
|
||||||
|
|
||||||
|
**远程仓库(克隆地址)**:[https://git.bz121.com/dekun/crypto_monitor.git](https://git.bz121.com/dekun/crypto_monitor.git)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.bz121.com/dekun/crypto_monitor.git
|
||||||
|
cd crypto_monitor
|
||||||
|
```
|
||||||
|
|
||||||
|
### 一键环境部署
|
||||||
|
|
||||||
|
| 系统 | 命令 |
|
||||||
|
|------|------|
|
||||||
|
| **Windows** | 双击根目录 **`一键部署.bat`**,或 `.\deploy\setup_env.ps1` |
|
||||||
|
| **Linux / macOS** | `bash deploy/setup_env.sh` |
|
||||||
|
|
||||||
|
会为各子项目创建 `.venv`、安装依赖、从 `.env.example` 生成 `.env`(不覆盖已有)。详见 **[deploy/README.md](./deploy/README.md)**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、仓库目录一览
|
||||||
|
|
||||||
|
| 目录 | 交易所 / 角色 | 说明文档 |
|
||||||
|
|------|-----------------|----------|
|
||||||
|
| `crypto_monitor_binance/` | Binance USDT-M 永续 | [部署文档.md](./crypto_monitor_binance/部署文档.md) · [README.md](./crypto_monitor_binance/README.md) |
|
||||||
|
| `crypto_monitor_gate/` | Gate.io 永续(主号) | [部署文档.md](./crypto_monitor_gate/部署文档.md) |
|
||||||
|
| `crypto_monitor_gate_bot/` | Gate.io 永续(机器人;含趋势回调等) | [部署文档.md](./crypto_monitor_gate_bot/部署文档.md) · [趋势回调策略说明.md](./crypto_monitor_gate_bot/趋势回调策略说明.md) |
|
||||||
|
| `crypto_monitor_okx/` | OKX 永续 | [部署文档.md](./crypto_monitor_okx/部署文档.md) |
|
||||||
|
| `manual_trading_hub/` | 多账户中控(监控 + 紧急全平,**不参与开仓**) | [README.md](./manual_trading_hub/README.md) · [部署文档.md](./manual_trading_hub/部署文档.md) · [scripts/后台运行-Ubuntu.md](./manual_trading_hub/scripts/后台运行-Ubuntu.md) |
|
||||||
|
|
||||||
|
前四列为四个 **`crypto_monitor_*`** 交易/监控应用;`manual_trading_hub` 与四者 **进程独立**,无需改四者代码即可并行使用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、四个 `crypto_monitor_*` 子项目:共同点
|
||||||
|
|
||||||
|
- **技术栈**:Python 3.10+、`Flask` Web、`ccxt` 调交易所 API、本地 SQLite(默认 `crypto.db`)等。
|
||||||
|
- **能力类型**(各所细节见各自 README / 部署文档):
|
||||||
|
- **关键位监控**、**下单监控**(含风控与移动保本等逻辑)、**交易复盘**;
|
||||||
|
- **实盘(可选)**:在对应 `.env` 中开启 `LIVE_TRADING_ENABLED=true` 并配置各所 API 后,由程序发起真实委托(请务必理解风险并做好权限与 IP 白名单控制)。
|
||||||
|
- **网络**:若本机直连交易所不稳定,可通过 **SSH 动态转发 SOCKS** 或 HTTP/S 代理;经 SOCKS 时依赖中需包含 **`PySocks`**(各《部署文档》中有说明)。
|
||||||
|
- **进程托管**:Linux 上常用 **PM2** 托管 `app.py`;各目录内一般有 `ecosystem.config.cjs` 或文档中的等价命令。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、四个子项目:差异速查
|
||||||
|
|
||||||
|
| 项目 | 环境变量前缀(示例) | 典型用途区分 |
|
||||||
|
|------|----------------------|--------------|
|
||||||
|
| `crypto_monitor_binance` | `BINANCE_*` | 币安 U 本位永续;止盈止损以 `STOP_MARKET` / `TAKE_PROFIT_MARKET` 等与币安规则对齐 |
|
||||||
|
| `crypto_monitor_gate` | `GATE_*` | Gate 主账户监控与交易页面 |
|
||||||
|
| `crypto_monitor_gate_bot` | `GATE_*` | Gate 侧 **独立子账户 / 机器人**;文档中含 **趋势回调** 等策略说明 |
|
||||||
|
| `crypto_monitor_okx` | `OKX_*` | OKX 永续;需 API Key / Secret / Passphrase |
|
||||||
|
|
||||||
|
各目录根下:
|
||||||
|
|
||||||
|
- **`.env.example`**:配置模板(**可** `git pull` 同步),新机执行 `cp .env.example .env` 后编辑。
|
||||||
|
- **`.env`**:本机真实配置(**勿**提交 Git);`app.py` 只读此文件。`git pull` **不会**覆盖 `.env`;升级前建议 `cp .env .env.backup.$(date +%Y%m%d)`。
|
||||||
|
|
||||||
|
变量名以前缀区分,**不可混用**(例如在 Gate 项目中写 OKX 变量会导致代理与密钥不生效)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、与 `manual_trading_hub` 的关系(可选)
|
||||||
|
|
||||||
|
- **中控** `hub.py`:汇总各子代理状态,提供网页与 HTTP API。
|
||||||
|
- **子代理** `agent.py`:每个进程绑定一个交易所账户,默认监听 **`15200`~`15203`**,与四个 Flask 应用的 **`APP_PORT`**(如 5000、5001)**必须错开**。
|
||||||
|
- 详细端口、`EXCHANGE`、`HUB_AGENTS` 等见 [manual_trading_hub/README.md](./manual_trading_hub/README.md)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、Linux 推荐目录布局(可选)
|
||||||
|
|
||||||
|
为与仓库内《部署文档》示例一致,可将整个克隆结果置于 **`/opt/crypto_monitor/`** 下,例如:
|
||||||
|
|
||||||
|
- `/opt/crypto_monitor/crypto_monitor_binance`
|
||||||
|
- `/opt/crypto_monitor/crypto_monitor_gate`
|
||||||
|
- `/opt/crypto_monitor/crypto_monitor_gate_bot`
|
||||||
|
- `/opt/crypto_monitor/crypto_monitor_okx`
|
||||||
|
- `/opt/crypto_monitor/manual_trading_hub`
|
||||||
|
|
||||||
|
具体 `mkdir`、`venv`、`pm2` 与 **SSH SOCKS** 步骤以各子目录 **《部署文档.md》** 为准。
|
||||||
|
|
||||||
|
### 备份与恢复(服务器必读)
|
||||||
|
|
||||||
|
项目路径 **`/opt/crypto_monitor`**,数据备份 **`/root/backups`**:
|
||||||
|
|
||||||
|
| 类型 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| **数据库 + 复盘图片** | 每天北京时间 0:00 自动备份,保留 30 天 |
|
||||||
|
| **`.env`** | 升级 / 改配置前手动备份与恢复 |
|
||||||
|
|
||||||
|
**一键复制命令(Ubuntu SSH)** 见根目录 **[备份与恢复.md](./备份与恢复.md)**:含安装 cron、手动备份、`.env` 备份/恢复、从备份还原数据库等整段脚本。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、整机许可(月卡 / 季卡 / 年卡)
|
||||||
|
|
||||||
|
- 用户自备电脑部署;**整机**一个设备 ID,四个 `crypto_monitor_*` 共用仓库根目录 **`.license/`** 缓存。
|
||||||
|
- 未授权或已过期:关键位监控、复盘、统计等 **全部不可用**(后台监控线程亦停止)。
|
||||||
|
- 每 **3** 天联网校验一次(`LICENSE_CHECK_INTERVAL_DAYS`);断网宽限默认 **7** 天。
|
||||||
|
- 购买:微信 **`dekun03`**,添加好友时 **备注必须填写设备 ID**(打开任意实例的 **`/license`** 页复制)。
|
||||||
|
- 定价:月卡 ¥199 / 季卡 ¥399 / 年卡 ¥699;续费在剩余天数上叠加(由云端实现)。
|
||||||
|
- 签发与管理 Web 为 **独立云端项目**,不在本仓库;用户端 API 约定见 **[docs/LICENSE_API.md](./docs/LICENSE_API.md)**。
|
||||||
|
- 配置:各子目录 `.env` 或仓库根 `.env` 中的 `LICENSE_API_URL`、`LICENSE_CLIENT_KEY` 等(见各 `.env.example`)。本地开发可设 `LICENSE_DISABLED=true`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、推荐阅读顺序
|
||||||
|
|
||||||
|
1. 克隆本仓库后,执行 **一键环境部署**(上表),或手动在各子目录 `python -m venv .venv` 与 `pip install`。
|
||||||
|
2. 根据实际交易所进入对应 **`crypto_monitor_*`** 目录,编辑 **`.env`**(填入 API 与密码等;部署脚本已可从 `.env.example` 复制)。
|
||||||
|
3. 阅读该目录下的 **《部署文档.md》**(Ubuntu / PM2 / 代理 / 升级说明)。
|
||||||
|
4. 服务器部署完成后,按 **[备份与恢复.md](./备份与恢复.md)** 配置自动备份与 `.env` 备份习惯。
|
||||||
|
5. 需要 **Gate 趋势回调** 规则时,阅读 [crypto_monitor_gate_bot/趋势回调策略说明.md](./crypto_monitor_gate_bot/趋势回调策略说明.md)。
|
||||||
|
6. 需要 **多账户一块看 + 紧急全平** 时,阅读 [manual_trading_hub](./manual_trading_hub/) 下 README 与部署文档。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、安全与合规
|
||||||
|
|
||||||
|
- **切勿**将 `.env`、`.env.backup*`、API Secret、SSH 私钥 `.pem` 等提交到版本库或公开渠道;仅 **`.env.example`** 可提交(占位符,无真实密钥)。
|
||||||
|
|
||||||
|
### 从旧版仓库升级(曾把 `.env` 提交进 Git)
|
||||||
|
|
||||||
|
在 **`git pull` 之前**按 **[备份与恢复.md](./备份与恢复.md)** 备份 `.env` 与数据库;pull 后若本地 `.env` 被误删,用备份恢复;再对照新的 **`.env.example`** 补全可能新增的变量名。
|
||||||
|
- 实盘前务必在 **`LIVE_TRADING_ENABLED=false`** 下验证页面与网络;API 权限与 IP 白名单遵循各交易所要求。
|
||||||
|
- 使用本仓库进行实盘交易的风险由使用者自行承担;请遵守当地法律法规与交易所用户协议。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、仓库信息摘要
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
|----|------|
|
||||||
|
| 远程地址 | `https://git.bz121.com/dekun/crypto_monitor.git` |
|
||||||
|
| 说明 | 复盘交易系统(骆驼比特币私有代码仓库,Gitea) |
|
||||||
|
| 本说明 | 仓库根目录 `README.md`,仅描述结构与文档索引,不包含业务代码变更说明 |
|
||||||
|
|
||||||
|
若各子项目 README 与根说明不一致,以 **子目录内当前代码与《部署文档》** 为准。
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 环境配置模板(可提交 Git)。程序运行时只读取同目录下的 .env。
|
||||||
|
#
|
||||||
|
# 首次部署 / 新机:
|
||||||
|
# cp .env.example .env
|
||||||
|
# nano .env # 填入真实密钥、端口、代理等
|
||||||
|
#
|
||||||
|
# 升级代码(git pull)前建议备份(.env 不在 Git 中,pull 不会覆盖):
|
||||||
|
# cp .env .env.backup.$(date +%Y%m%d)
|
||||||
|
#
|
||||||
|
# 从备份恢复:
|
||||||
|
# cp .env.backup.YYYYMMDD .env
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
APP_ENV=production
|
||||||
|
# 服务监听地址(云服务器通常用 0.0.0.0)
|
||||||
|
APP_HOST=0.0.0.0
|
||||||
|
# 服务端口
|
||||||
|
APP_PORT=5001
|
||||||
|
# 是否开启调试模式(生产建议 false)
|
||||||
|
APP_DEBUG=false
|
||||||
|
|
||||||
|
# 登录账号
|
||||||
|
APP_USERNAME=dekun
|
||||||
|
# 登录密码(请改成你自己的强密码)
|
||||||
|
APP_PASSWORD=ChangeMe123!
|
||||||
|
# 是否关闭登录校验(局域网可设 true;公网务必 false)
|
||||||
|
APP_AUTH_DISABLED=true
|
||||||
|
# Flask 会话密钥(必须替换为长随机字符串)
|
||||||
|
FLASK_SECRET_KEY=CHANGE_TO_LONG_RANDOM_SECRET
|
||||||
|
|
||||||
|
# ---------- 整机许可(也可写在仓库根目录 .env 的 LICENSE_* 变量)----------
|
||||||
|
# 云端授权服务根地址(独立项目部署,见 docs/LICENSE_API.md)
|
||||||
|
LICENSE_API_URL=https://license.example.com
|
||||||
|
LICENSE_CLIENT_KEY=REPLACE_WITH_CLIENT_KEY
|
||||||
|
# 联网校验间隔(天),默认 3
|
||||||
|
LICENSE_CHECK_INTERVAL_DAYS=3
|
||||||
|
# 断网后仍可使用的天数(基于上次校验成功时间),默认 7
|
||||||
|
LICENSE_OFFLINE_GRACE_DAYS=7
|
||||||
|
# 购买联系微信;添加好友时备注请填写设备 ID(见 /license 页)
|
||||||
|
LICENSE_WECHAT_ID=dekun03
|
||||||
|
# LICENSE_WECHAT_REMARK=自定义备注文案
|
||||||
|
# 仅本地开发可设为 true,跳过许可
|
||||||
|
# LICENSE_DISABLED=false
|
||||||
|
|
||||||
|
# 企业微信机器人 Webhook(用于行情/风控推送)
|
||||||
|
WECHAT_WEBHOOK=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=REPLACE_WITH_REAL_KEY
|
||||||
|
|
||||||
|
# 数据库文件路径(相对路径会自动按项目目录解析)
|
||||||
|
DB_PATH=crypto.db
|
||||||
|
# 交易截图上传目录
|
||||||
|
UPLOAD_DIR=static/images
|
||||||
|
|
||||||
|
# 自动备份(scripts/backup_data.sh + cron,可选;默认即可)
|
||||||
|
# BACKUP_ROOT=/root/backups
|
||||||
|
# BACKUP_RETENTION_DAYS=30
|
||||||
|
# BACKUP_INSTANCE=crypto_monitor_binance
|
||||||
|
|
||||||
|
# 已废弃:资金账户仅显示交易所 funding 余额,不再读取此变量
|
||||||
|
# TOTAL_CAPITAL=100
|
||||||
|
# 每天起始基数(U)
|
||||||
|
DAILY_START_CAPITAL=30
|
||||||
|
# 日内回撤后基数(U)
|
||||||
|
DAILY_LOSS_CAPITAL=20
|
||||||
|
# 日内盈利后基数(U)
|
||||||
|
DAILY_PROFIT_CAPITAL=50
|
||||||
|
# BTC 默认杠杆倍数
|
||||||
|
BTC_LEVERAGE=10
|
||||||
|
# 山寨币默认杠杆倍数
|
||||||
|
ALT_LEVERAGE=5
|
||||||
|
# 交易日重置小时(北京时间)
|
||||||
|
TRADING_DAY_RESET_HOUR=8
|
||||||
|
# 整点前禁止新开仓:true=启用(默认),false=关闭(仍可保留 8 点作为交易日划分)
|
||||||
|
TRADING_DAY_RESET_OPEN_GUARD_ENABLED=true
|
||||||
|
|
||||||
|
# 是否开启 Binance 实盘下单(false=只做本地流程,true=真实下单)
|
||||||
|
LIVE_TRADING_ENABLED=true
|
||||||
|
# Binance API Key(需开通合约、万向划转等权限)
|
||||||
|
BINANCE_API_KEY=REPLACE_WITH_BINANCE_API_KEY
|
||||||
|
# Binance API Secret
|
||||||
|
BINANCE_API_SECRET=REPLACE_WITH_BINANCE_API_SECRET
|
||||||
|
# 保证金模式:cross=全仓,isolated=逐仓
|
||||||
|
BINANCE_MARGIN_MODE=cross
|
||||||
|
# 持仓模式:hedge=双向(需账户开启双向持仓,下单带 positionSide);oneway=单向
|
||||||
|
BINANCE_POSITION_MODE=hedge
|
||||||
|
# 条件单触发参考价:CONTRACT_PRICE=最新成交价 MARK_PRICE=标记价(更易触发时用标记价)
|
||||||
|
BINANCE_TRIGGER_WORKING_TYPE=CONTRACT_PRICE
|
||||||
|
# 页面与浏览器标签展示的交易所名称(多环境区分时可改成例如 Binance·测试网)
|
||||||
|
# EXCHANGE_DISPLAY_NAME=Binance
|
||||||
|
# 企业微信推送里展示的账户备注
|
||||||
|
# BINANCE_ACCOUNT_LABEL=binance实盘账户
|
||||||
|
# 平仓盈亏估算:false=按仓位历史口径(已实现盈亏+手续费,不含资金费);true=含资金费
|
||||||
|
# BINANCE_PNL_INCLUDE_FUNDING=false
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 关键位门控(页面「关键位监控」规则条与 _key_hard_checks 共用)
|
||||||
|
# =============================================================================
|
||||||
|
# 【周期】门控 K 线周期,如 5m、15m;仅影响关键位硬条件,不改变顶栏分区
|
||||||
|
KLINE_TIMEFRAME=5m
|
||||||
|
# 【确认K】闭合 K 序列中的棒偏移:突破棒默认 -2(倒数第2根),确认棒默认 -1(倒数第1根)
|
||||||
|
KEY_CONFIRM_BREAKOUT_BAR=-2
|
||||||
|
KEY_CONFIRM_BAR=-1
|
||||||
|
# 【量能】突破棒成交量 > 前 N 根均量 × 倍数(默认 N=20,倍数=1.3 即放大 30%)
|
||||||
|
KEY_VOLUME_MA_BARS=20
|
||||||
|
KEY_VOLUME_RATIO_MIN=1.3
|
||||||
|
# 【突破K实体幅度】占开盘价百分比区间(须同时满足有效突破)
|
||||||
|
KEY_BREAKOUT_AMP_MIN_PCT=0.03
|
||||||
|
KEY_BREAKOUT_AMP_MAX_PCT=0.5
|
||||||
|
# 【日成交量排名】品种须在该排名前 N 名(添加关键位与运行时门控均校验)
|
||||||
|
KEY_DAILY_VOLUME_RANK_MAX=30
|
||||||
|
# 【关键位自动开仓盈亏比】按确认K收盘 E 计算,严格大于该值才市价开仓(如 1.5 表示须 >1.5:1)
|
||||||
|
KEY_AUTO_MIN_PLANNED_RR=1.5
|
||||||
|
# 止损:突破 K 极值向外缓冲的百分比(默认 0.5 即 0.5%)
|
||||||
|
KEY_STOP_OUTSIDE_BREAKOUT_PCT=0.5
|
||||||
|
# 趋势单方案:止损在突破 K 极值外侧的百分比(默认 1 即 1%)
|
||||||
|
KEY_TREND_STOP_OUTSIDE_PCT=1
|
||||||
|
KEY_ALERT_MAX_TIMES=3
|
||||||
|
KEY_ALERT_INTERVAL_MINUTES=5
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 交易执行 / 人工风控(页面「实盘下单」)
|
||||||
|
# =============================================================================
|
||||||
|
# 【最大同时持仓】active 订单数达到该值后禁止人工与关键位自动再加仓(默认 1=单仓)
|
||||||
|
MAX_ACTIVE_POSITIONS=1
|
||||||
|
# 【人工下单最低盈亏比】按当前价与 SL/TP 计算,低于该值前后端均拒绝(默认 1.4,即须 >=1.4:1)
|
||||||
|
MANUAL_MIN_PLANNED_RR=1.4
|
||||||
|
# 【关键位连开计仓】true=已有持仓时关键位自动单仍按「无仓时」资金快照算保证金基数
|
||||||
|
KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT=true
|
||||||
|
|
||||||
|
# 资金与仓位刷新周期(秒)
|
||||||
|
BALANCE_REFRESH_SECONDS=60
|
||||||
|
# 前端价格快照轮询(秒)
|
||||||
|
PRICE_REFRESH_SECONDS=5
|
||||||
|
# 后台监控轮询周期(秒)
|
||||||
|
MONITOR_POLL_SECONDS=3
|
||||||
|
# 使用可用资金时的缓冲比例(如0.98代表用98%)
|
||||||
|
FULL_MARGIN_BUFFER_RATIO=0.98
|
||||||
|
|
||||||
|
# 自动划转:将目标账户补足到 AUTO_TRANSFER_AMOUNT(ccxt:funding↔swap 等)
|
||||||
|
AUTO_TRANSFER_ENABLED=false
|
||||||
|
AUTO_TRANSFER_AMOUNT=30
|
||||||
|
AUTO_TRANSFER_FROM=funding
|
||||||
|
AUTO_TRANSFER_TO=swap
|
||||||
|
TRANSFER_CCY=USDT
|
||||||
|
# 强制清仓整点(北京时间,默认 0=凌晨00点)
|
||||||
|
FORCE_CLOSE_BJ_HOUR=0
|
||||||
|
# 是否启用强制清仓(默认关闭,true 才会在整点执行)
|
||||||
|
FORCE_CLOSE_ENABLED=false
|
||||||
|
|
||||||
|
# 推送与AI超时(秒)
|
||||||
|
WECHAT_TIMEOUT_SECONDS=10
|
||||||
|
AI_TIMEOUT_SECONDS=120
|
||||||
|
|
||||||
|
# AI 复盘服务地址(本机 Ollama 默认地址)
|
||||||
|
OLLAMA_API=http://127.0.0.1:11434/api/generate
|
||||||
|
# AI 模型名称
|
||||||
|
AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest
|
||||||
|
|
||||||
|
# Binance 代理(可选):本机网络不稳定时通过 SSH 动态转发 SOCKS5 出口
|
||||||
|
# 1) 先在本机建立隧道(示例):
|
||||||
|
# ssh -N -D 127.0.0.1:1080 user@vps -o ServerAliveInterval=30 -o ExitOnForwardFailure=yes
|
||||||
|
# 2) 再启用下面这一行(推荐 socks5h,让远端解析域名):
|
||||||
|
# BINANCE_SOCKS_PROXY=socks5h://127.0.0.1:1080
|
||||||
|
#
|
||||||
|
# 如你更偏向 HTTP 代理(VPS 上跑 tinyproxy 之类),可用:
|
||||||
|
# BINANCE_HTTP_PROXY=http://127.0.0.1:3128
|
||||||
|
# BINANCE_HTTPS_PROXY=http://127.0.0.1:3128
|
||||||
|
|
||||||
|
# 开仓多周期K线图(可选)
|
||||||
|
# ORDER_CHART_ENABLED=true
|
||||||
|
# ORDER_CHART_TFS=4h,1h,15m,5m
|
||||||
|
# ORDER_CHART_LIMIT=100
|
||||||
|
# ORDER_CHART_DIR=static/images/order_charts
|
||||||
|
# DAILY_OPEN_ALERT_THRESHOLD=5
|
||||||
|
# 以损定仓(按交易账户资金的百分比)
|
||||||
|
# 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
|
||||||
|
AUTO_TRANSFER_BJ_HOUR=8
|
||||||
|
# TRADING_DAY_RESET_HOUR 现在表示「北京时间」整点,默认 8 点起算新交易日;开仓整点限制见 TRADING_DAY_RESET_OPEN_GUARD_ENABLED
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# crypto_monitor_binance
|
||||||
|
|
||||||
|
基于 **Flask** 的加密货币 **下单监控 / 关键位监控 / 交易复盘** 小系统,行情与实盘接口统一走 **Binance(USDT-M 永续)**,通过 **ccxt** 访问。
|
||||||
|
|
||||||
|
## 功能概要
|
||||||
|
|
||||||
|
- **关键位监控**:价格与硬条件校验、企业微信推送(可选)
|
||||||
|
- **下单监控**:本地风控(含移动保本逻辑)、触达止盈/止损后尝试市价平仓并记账
|
||||||
|
- **实盘(可选)**:`LIVE_TRADING_ENABLED=true` 且配置 `BINANCE_API_KEY` / `BINANCE_API_SECRET` 时,支持合约开仓、平仓、余额读取与内部划转(依赖 API 权限)
|
||||||
|
- **止盈止损(Binance)**:市价成交后挂 **`STOP_MARKET`**(止损)、**`TAKE_PROFIT_MARKET`**(止盈);双向持仓带 `positionSide`;不显式传 `reduceOnly`(避免 API `-1106`)。触发参考价由 `BINANCE_TRIGGER_WORKING_TYPE` 控制(最新价 / 标记价)
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
- Python 3.10+(建议)
|
||||||
|
- 依赖:`flask`、`requests`、`ccxt`、`werkzeug`、`Pillow`(K 线图可选);经 SOCKS 代理时需 **`PySocks`**
|
||||||
|
|
||||||
|
安装示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||||
|
pip install flask requests ccxt werkzeug PySocks Pillow
|
||||||
|
```
|
||||||
|
|
||||||
|
页面上的 **「当日资金(交易账户)」** 与 **「可开仓」可用 U** 仅统计 **Binance U 本位永续合约账户**(`fetch_balance` 的 `swap` / FAPI `assets` 中的 USDT),**不会**再用现货余额顶替。
|
||||||
|
|
||||||
|
## 配置说明(`.env.example` → `.env`)
|
||||||
|
|
||||||
|
- **`.env.example`**:模板(可提交 Git);首次:`cp .env.example .env` 后编辑。
|
||||||
|
- **`.env`**:本机真实配置(勿提交);`app.py` 只读此文件。`git pull` 不覆盖 `.env`;升级前可 `cp .env .env.backup.$(date +%Y%m%d)`。
|
||||||
|
|
||||||
|
与 Binance 相关的常用变量:
|
||||||
|
|
||||||
|
| 变量 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `BINANCE_API_KEY` / `BINANCE_API_SECRET` | 币安 API(需合约等权限) |
|
||||||
|
| `LIVE_TRADING_ENABLED` | `true` 时允许真实下单;`false` 仅本地逻辑 |
|
||||||
|
| `BINANCE_MARGIN_MODE` | `cross` 全仓 / `isolated` 逐仓 |
|
||||||
|
| `BINANCE_POSITION_MODE` | `hedge` 双向(需账户开启双向持仓)/ `oneway` 单向 |
|
||||||
|
| `BINANCE_TRIGGER_WORKING_TYPE` | `CONTRACT_PRICE` 或 `MARK_PRICE`(条件单触发参考) |
|
||||||
|
| `BINANCE_SOCKS_PROXY` / `BINANCE_HTTP_PROXY` | 可选代理(与部署文档一致) |
|
||||||
|
| `EXCHANGE_DISPLAY_NAME` | 页面展示的交易所名称,默认 `Binance` |
|
||||||
|
| `BINANCE_ACCOUNT_LABEL` | 推送文案中的账户备注 |
|
||||||
|
|
||||||
|
其余变量(登录、企业微信、风控参数、数据库路径等)见 **`.env.example` 内注释** 或 `app.py` 顶部默认值。
|
||||||
|
|
||||||
|
## 本地运行
|
||||||
|
|
||||||
|
**Windows(UTF-8 控制台)** 可使用:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\start_utf8.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
或直接:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python .\app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
默认监听端口由 `.env` 的 `APP_PORT` 决定(未设置时多为 `5000`)。
|
||||||
|
|
||||||
|
## 部署(Linux / PM2 / SSH SOCKS)
|
||||||
|
|
||||||
|
详见 **[部署文档.md](./部署文档.md)**(Ubuntu + PM2 + 可选 SOCKS 访问 Binance)。
|
||||||
|
|
||||||
|
## 自检脚本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/verify_binance_funding.py
|
||||||
|
```
|
||||||
|
|
||||||
|
用于核对 Key 前缀(不含 Secret)并尝试读取资金钱包 / 合约钱包 USDT(需网络与 API 权限)。
|
||||||
|
|
||||||
|
## 数据与脚本
|
||||||
|
|
||||||
|
- 默认 SQLite:`crypto.db`(路径由 `DB_PATH` 指定)
|
||||||
|
- `scripts/fix_breakeven_labels.py`:批量修正「止损」但盈亏为正的记录标签(见部署文档附录)
|
||||||
|
|
||||||
|
## 风险与合规
|
||||||
|
|
||||||
|
实盘交易有亏损风险。请自行确认 API 权限、IP 白名单、杠杆与保证金模式与币安账户设置一致,并遵守当地法律法规与 Binance 用户协议。
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* PM2 进程定义(Ubuntu / Linux)。
|
||||||
|
*
|
||||||
|
* 仅托管 Flask 应用。**SSH SOCKS 隧道请在本机用 screen/tmux/systemd 等方式单独常驻**,
|
||||||
|
* 与 `.env` 里 `BINANCE_SOCKS_PROXY` 端口一致即可;不必交给 PM2。
|
||||||
|
*
|
||||||
|
* 使用前:项目根目录存在 `.venv`,且已安装依赖(走 SOCKS 时需 PySocks)。
|
||||||
|
*
|
||||||
|
* 启动:
|
||||||
|
* pm2 start ecosystem.config.cjs
|
||||||
|
* 保存开机列表:
|
||||||
|
* pm2 save && pm2 startup
|
||||||
|
*/
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const ROOT = __dirname;
|
||||||
|
const PY = path.join(ROOT, ".venv", "bin", "python");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: "crypto_binance",
|
||||||
|
cwd: ROOT,
|
||||||
|
script: path.join(ROOT, "app.py"),
|
||||||
|
interpreter: PY,
|
||||||
|
instances: 1,
|
||||||
|
autorestart: true,
|
||||||
|
watch: false,
|
||||||
|
max_memory_restart: "800M",
|
||||||
|
// app.py 从项目根目录 .env 加载(由 .env.example 复制而来,勿提交 Git)
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
{% if page == 'key_monitor' %}
|
||||||
|
<motion class="dual-panel-grid" style="grid-column:1/-1">
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
#!/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"
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
一次性修复历史交易记录标签:
|
||||||
|
将 trade_records 里“止损但实际盈利”的记录改为“保本止盈”。
|
||||||
|
|
||||||
|
默认条件(可通过参数修改):
|
||||||
|
- monitor_type = 下单监控
|
||||||
|
- result = 止损
|
||||||
|
- pnl_amount > 0
|
||||||
|
|
||||||
|
用法示例:
|
||||||
|
1) 仅预览(不落库):
|
||||||
|
python scripts/fix_breakeven_labels.py --db ./crypto.db --dry-run
|
||||||
|
|
||||||
|
2) 执行修复:
|
||||||
|
python scripts/fix_breakeven_labels.py --db ./crypto.db --apply
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Fix historical stop-loss records with positive pnl.")
|
||||||
|
parser.add_argument("--db", required=True, help="Path to sqlite db file, e.g. ./crypto.db")
|
||||||
|
parser.add_argument("--monitor-type", default="下单监控", help="Filter by monitor_type (default: 下单监控)")
|
||||||
|
parser.add_argument("--from-result", default="止损", help="Source result label (default: 止损)")
|
||||||
|
parser.add_argument("--to-result", default="保本止盈", help="Target result label (default: 保本止盈)")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Preview only, no write")
|
||||||
|
parser.add_argument("--apply", action="store_true", help="Execute update")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
db_path = Path(args.db).expanduser().resolve()
|
||||||
|
if not db_path.exists():
|
||||||
|
print(f"[ERR] DB not found: {db_path}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if args.dry_run and args.apply:
|
||||||
|
print("[ERR] --dry-run and --apply are mutually exclusive.")
|
||||||
|
return 1
|
||||||
|
if not args.dry_run and not args.apply:
|
||||||
|
print("[INFO] No mode provided, defaulting to --dry-run.")
|
||||||
|
args.dry_run = True
|
||||||
|
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
where_sql = """
|
||||||
|
monitor_type = ?
|
||||||
|
AND result = ?
|
||||||
|
AND CAST(COALESCE(pnl_amount, 0) AS REAL) > 0
|
||||||
|
"""
|
||||||
|
params = (args.monitor_type, args.from_result)
|
||||||
|
|
||||||
|
cur.execute(f"SELECT COUNT(*) AS c FROM trade_records WHERE {where_sql}", params)
|
||||||
|
will_change = int(cur.fetchone()["c"])
|
||||||
|
print(f"[INFO] Candidate rows: {will_change}")
|
||||||
|
|
||||||
|
if will_change == 0:
|
||||||
|
print("[INFO] Nothing to update.")
|
||||||
|
conn.close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT id, symbol, result, pnl_amount, closed_at
|
||||||
|
FROM trade_records
|
||||||
|
WHERE {where_sql}
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 10
|
||||||
|
""",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
sample = cur.fetchall()
|
||||||
|
print("[INFO] Sample (latest 10):")
|
||||||
|
for r in sample:
|
||||||
|
print(
|
||||||
|
f" id={r['id']} symbol={r['symbol']} result={r['result']} "
|
||||||
|
f"pnl={r['pnl_amount']} closed_at={r['closed_at']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print("[DRY-RUN] No write executed.")
|
||||||
|
conn.close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
f"UPDATE trade_records SET result=? WHERE {where_sql}",
|
||||||
|
(args.to_result, *params),
|
||||||
|
)
|
||||||
|
changed = int(cur.rowcount)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print(f"[DONE] Updated rows: {changed}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install daily backup cron: Beijing 00:00 (CRON_TZ=Asia/Shanghai).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
BACKUP_SCRIPT="$SCRIPT_DIR/backup_data.sh"
|
||||||
|
INSTANCE_NAME="${BACKUP_INSTANCE:-$(basename "$PROJECT_DIR")}"
|
||||||
|
LOG_FILE="${BACKUP_CRON_LOG:-/var/log/crypto-monitor-backup-${INSTANCE_NAME}.log}"
|
||||||
|
if [[ ! -x "$BACKUP_SCRIPT" ]]; then
|
||||||
|
chmod +x "$BACKUP_SCRIPT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
TMP="$(mktemp)"
|
||||||
|
trap 'rm -f "$TMP"' EXIT
|
||||||
|
|
||||||
|
{
|
||||||
|
crontab -l 2>/dev/null | grep -vF "$BACKUP_SCRIPT" || true
|
||||||
|
echo "CRON_TZ=Asia/Shanghai"
|
||||||
|
echo "0 0 * * * $BACKUP_SCRIPT >> $LOG_FILE 2>&1"
|
||||||
|
} >"$TMP"
|
||||||
|
|
||||||
|
# Keep a single CRON_TZ line at top.
|
||||||
|
awk '
|
||||||
|
BEGIN { tz = 0 }
|
||||||
|
/^CRON_TZ=Asia\/Shanghai$/ {
|
||||||
|
if (tz++) next
|
||||||
|
}
|
||||||
|
{ print }
|
||||||
|
' "$TMP" >"${TMP}.2"
|
||||||
|
mv "${TMP}.2" "$TMP"
|
||||||
|
|
||||||
|
crontab "$TMP"
|
||||||
|
echo "Installed cron for $INSTANCE_NAME"
|
||||||
|
echo " Schedule : daily 00:00 Asia/Shanghai"
|
||||||
|
echo " Script : $BACKUP_SCRIPT"
|
||||||
|
echo " Log : $LOG_FILE"
|
||||||
|
crontab -l | grep -F "$BACKUP_SCRIPT" || true
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Patch index.html layout for key_monitor / trade split."""
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
|
||||||
|
TAG = "div"
|
||||||
|
|
||||||
|
PATHS = [
|
||||||
|
Path(__file__).resolve().parent.parent / "templates" / "index.html",
|
||||||
|
Path(r"c:\Users\dekun\Desktop\crypto_monitor\crypto_monitor_gate\templates\index.html"),
|
||||||
|
]
|
||||||
|
|
||||||
|
KEY_START = " {% if page == 'key_monitor' %}"
|
||||||
|
KEY_START_ALT = " {% if page == 'trade' %}"
|
||||||
|
RECORDS_START = " {% if page == 'records' %}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_section(order_loop: str) -> str:
|
||||||
|
t = TAG
|
||||||
|
return f""" {{% if page == 'key_monitor' %}}
|
||||||
|
<{t} class="dual-panel-grid" style="grid-column:1/-1">
|
||||||
|
<{t} class="card">
|
||||||
|
<{t} style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:8px">
|
||||||
|
<h2 style="margin-bottom:0">关键位监控</h2>
|
||||||
|
{{% if focus_key_id %}}
|
||||||
|
<a href="/key_focus?key_id={{{{ focus_key_id }}}}" class="btn-del" style="text-decoration:none;background:#1f3a5a;color:#8fc8ff">放大查看K线(默认200根)</a>
|
||||||
|
{{% else %}}
|
||||||
|
<a href="/key_focus" class="btn-del" style="text-decoration:none;background:#1f3a5a;color:#8fc8ff">输入币种查看K线</a>
|
||||||
|
{{% endif %}}
|
||||||
|
</{t}>
|
||||||
|
<form id="key-form" action="/add_key" method="post" class="form-row">
|
||||||
|
<input name="symbol" placeholder="BTC 或 BTC/USDT" required>
|
||||||
|
<select name="type" required>
|
||||||
|
<option value="箱体突破">箱体突破</option>
|
||||||
|
<option value="收敛突破">收敛突破</option>
|
||||||
|
<option value="关键阻力位">关键阻力位</option>
|
||||||
|
<option value="关键支撑位">关键支撑位</option>
|
||||||
|
</select>
|
||||||
|
<select name="direction" required>
|
||||||
|
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
|
||||||
|
</select>
|
||||||
|
<input name="upper" step="0.0001" placeholder="上沿/阻力" required>
|
||||||
|
<input name="lower" step="0.0001" placeholder="下沿/支撑" required>
|
||||||
|
<button type="submit">添加</button>
|
||||||
|
</form>
|
||||||
|
<{t} class="rule-tip">{{{{ key_gate_rule_text }}}}</{t}>
|
||||||
|
<{t} class="panel-scroll pos-list">
|
||||||
|
{{% for k in key %}}
|
||||||
|
<{t} class="pos-card" id="key-row-{{{{ k.id }}}}">
|
||||||
|
<{t} class="pos-card-head">
|
||||||
|
<{t} class="pos-card-symbol">
|
||||||
|
<strong>{{{{ k.symbol }}}}</strong>
|
||||||
|
<span class="pos-side-badge {{{{ 'pos-side-long' if k.direction == 'long' else 'pos-side-short' }}}}">{{{{ '做多' if k.direction == 'long' else '做空' }}}}</span>
|
||||||
|
<span class="badge direction" style="margin-left:4px">{{{{ k.monitor_type }}}}</span>
|
||||||
|
</{t}>
|
||||||
|
<button type="button" class="pos-close-btn" style="border:none;cursor:pointer" onclick="deleteKeyMonitor({{{{ k.id }}}})">删</button>
|
||||||
|
</{t}>
|
||||||
|
<{t} class="pos-meta">
|
||||||
|
<span class="pos-meta-item">上沿: {{{{ k.upper }}}}</span>
|
||||||
|
<span class="pos-meta-item">下沿: {{{{ k.lower }}}}</span>
|
||||||
|
<span class="pos-meta-item">已提醒: {{{{ k.notification_count or 0 }}}}/{{{{ k.max_notify or 3 }}}}</span>
|
||||||
|
</{t}>
|
||||||
|
<{t} class="pos-grid">
|
||||||
|
<{t} class="pos-cell"><span class="pos-label">现价</span><span class="pos-value" id="key-price-{{{{ k.id }}}}">-</span></{t}>
|
||||||
|
<{t} class="pos-cell"><span class="pos-label">距上沿</span><span class="pos-value" id="key-up-diff-{{{{ k.id }}}}">-</span></{t}>
|
||||||
|
<{t} class="pos-cell"><span class="pos-label">距下沿</span><span class="pos-value" id="key-low-diff-{{{{ k.id }}}}">-</span></{t}>
|
||||||
|
<{t} class="pos-cell"><span class="pos-label">门控</span><span class="pos-value" id="key-gate-{{{{ k.id }}}}" style="color:#9aa">-</span></{t}>
|
||||||
|
</{t}>
|
||||||
|
<{t} class="pos-meta" style="margin-top:8px"><span class="pos-meta-item" id="key-gate-metrics-{{{{ k.id }}}}" style="color:#8fc8ff"></span></{t}>
|
||||||
|
</{t}>
|
||||||
|
{{% else %}}
|
||||||
|
<{t} class="pos-empty">暂无监控中的关键位</{t}>
|
||||||
|
{{% endfor %}}
|
||||||
|
</{t}>
|
||||||
|
</{t}>
|
||||||
|
<{t} class="card">
|
||||||
|
<h2 style="margin-bottom:8px">关键位历史</h2>
|
||||||
|
<{t} class="sub" style="font-size:.72rem;color:#8892b0;margin-bottom:8px">失效或已结案的关键位</{t}>
|
||||||
|
<{t} class="panel-scroll pos-list">
|
||||||
|
{{% for h in key_history %}}
|
||||||
|
<{t} class="pos-card">
|
||||||
|
<{t} class="pos-card-head">
|
||||||
|
<{t} class="pos-card-symbol">
|
||||||
|
<strong>{{{{ h.symbol }}}}</strong>
|
||||||
|
<span class="pos-side-badge {{{{ 'pos-side-long' if h.direction == 'long' else 'pos-side-short' }}}}">{{{{ '做多' if h.direction == 'long' else '做空' }}}}</span>
|
||||||
|
</{t}>
|
||||||
|
<button type="button" class="table-del" onclick="deleteKeyHistory({{{{ h.id }}}})">删除</button>
|
||||||
|
</{t}>
|
||||||
|
<{t} class="pos-meta">
|
||||||
|
<span class="pos-meta-item">{{{{ h.monitor_type }}}}</span>
|
||||||
|
<span class="pos-meta-item">{{{{ h.close_reason }}}}</span>
|
||||||
|
<span class="pos-meta-item">{{{{ (h.closed_at or '-')[:16] }}}}</span>
|
||||||
|
</{t}>
|
||||||
|
<{t} class="pos-meta">
|
||||||
|
<span class="pos-meta-item">上: {{{{ h.upper }}}} 下: {{{{ h.lower }}}}</span>
|
||||||
|
<span class="pos-meta-item">提醒: {{{{ h.notification_count }}}}</span>
|
||||||
|
</{t}>
|
||||||
|
{{% if h.last_alert_message %}}<{t} style="font-size:.75rem;color:#aab;margin-top:6px;white-space:pre-wrap">{{{{ h.last_alert_message[:180] }}}}{{% if h.last_alert_message|length > 180 %}}…{{% endif %}}</{t}>{{% endif %}}
|
||||||
|
</{t}>
|
||||||
|
{{% else %}}
|
||||||
|
<{t} class="pos-empty">暂无历史</{t}>
|
||||||
|
{{% endfor %}}
|
||||||
|
</{t}>
|
||||||
|
</{t}>
|
||||||
|
</{t}>
|
||||||
|
{{% elif page == 'trade' %}}
|
||||||
|
<{t} class="dual-panel-grid" style="grid-column:1/-1">
|
||||||
|
<{t} class="card">
|
||||||
|
<{t} style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:8px">
|
||||||
|
<h2 style="margin-bottom:0">实盘下单监控</h2>
|
||||||
|
{{% if focus_order_id %}}
|
||||||
|
<a href="/order_focus?order_id={{{{ focus_order_id }}}}" class="btn-del" style="text-decoration:none;background:#1f3a5a;color:#8fc8ff">放大查看K线(100根)</a>
|
||||||
|
{{% else %}}
|
||||||
|
<span class="btn-del" style="background:#2f2f44;color:#9aa;cursor:not-allowed">暂无持仓可放大</span>
|
||||||
|
{{% endif %}}
|
||||||
|
</{t}>
|
||||||
|
<{t} class="rule-tip" id="order-rule-tip">
|
||||||
|
规则:最多 {{{{ max_active_positions }}}} 仓;BTC {{{{ btc_leverage }}}}x / 山寨 {{{{ alt_leverage }}}}x;
|
||||||
|
{{% if can_trade %}}可开仓{{% else %}}不可开仓(持仓已满或未到北京时间 {{{{ reset_hour }}}}:00){{% endif %}};
|
||||||
|
人工开仓盈亏比不得低于 {{{{ manual_min_planned_rr }}}}:1
|
||||||
|
</{t}>
|
||||||
|
<{t} class="rule-tip">
|
||||||
|
以损定仓:风险 {{{{ risk_percent }}}}% |移动保本:下单可勾选关闭;开启时 {{{{ breakeven_rr_trigger }}}}R 触发(每 1R 阶梯上移),偏移 {{{{ breakeven_offset_pct }}}}%
|
||||||
|
</{t}>
|
||||||
|
<{t} class="rule-tip">
|
||||||
|
划转:自动划转 {{{{ '开启' if auto_transfer_enabled else '关闭' }}}}(每天<strong>北京时间 {{{{ auto_transfer_bj_hour }}}}:00</strong>起该整点小时内尝试;账簿按 <strong>UTC 自然日</strong>去重;界面时间为北京;将 {{{{ auto_transfer_to }}}} 补足到 {{{{ auto_transfer_amount }}}}U,来自 {{{{ auto_transfer_from }}}})
|
||||||
|
</{t}>
|
||||||
|
<form action="/manual_transfer" method="post" class="form-row">
|
||||||
|
<input name="amount" type="number" min="0.01" step="0.01" placeholder="手动划转金额U" required>
|
||||||
|
<select name="from_account">
|
||||||
|
<option value="funding" {{% if auto_transfer_from == 'funding' %}}selected{{% endif %}}>from: funding</option>
|
||||||
|
<option value="swap" {{% if auto_transfer_from == 'swap' %}}selected{{% endif %}}>from: swap</option>
|
||||||
|
<option value="spot" {{% if auto_transfer_from == 'spot' %}}selected{{% endif %}}>from: spot</option>
|
||||||
|
</select>
|
||||||
|
<select name="to_account">
|
||||||
|
<option value="swap" {{% if auto_transfer_to == 'swap' %}}selected{{% endif %}}>to: swap</option>
|
||||||
|
<option value="funding" {{% if auto_transfer_to == 'funding' %}}selected{{% endif %}}>to: funding</option>
|
||||||
|
<option value="spot" {{% if auto_transfer_to == 'spot' %}}selected{{% endif %}}>to: spot</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit">手动划转</button>
|
||||||
|
</form>
|
||||||
|
<form id="add-order-form" action="/add_order" method="post" class="form-row">
|
||||||
|
<input id="order-symbol" name="symbol" placeholder="BTC 或 BTC/USDT" required>
|
||||||
|
<select id="order-direction" name="direction" required>
|
||||||
|
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
|
||||||
|
</select>
|
||||||
|
<select id="sltp-mode" name="sltp_mode">
|
||||||
|
<option value="price">止盈止损:价格模式</option>
|
||||||
|
<option value="pct">止盈止损:百分比模式</option>
|
||||||
|
</select>
|
||||||
|
<select name="trade_style" required>
|
||||||
|
<option value="trend">趋势单</option>
|
||||||
|
<option value="swing">波段单</option>
|
||||||
|
</select>
|
||||||
|
<input id="order-leverage" name="leverage" type="number" min="1" step="1" placeholder="杠杆(可选)">
|
||||||
|
<label style="display:flex;align-items:center;gap:4px;font-size:.82rem;color:#cfd3ef">
|
||||||
|
<input type="checkbox" name="breakeven_enabled" value="1" checked> 启用移动保本(关闭则仅保留初始止损与交易所挂单)
|
||||||
|
</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:4px;font-size:.82rem;color:#cfd3ef">
|
||||||
|
<input type="checkbox" name="order_chart" value="true"> 开仓后生成多周期K线图(各周期100根,含开平仓标记)
|
||||||
|
</label>
|
||||||
|
<span style="display:flex;align-items:center;padding:0 10px;font-size:.8rem;color:#8fc8ff">成交价自动取交易所实时+成交回报</span>
|
||||||
|
<input id="order-sl" name="sl" step="any" placeholder="止损价格" required>
|
||||||
|
<input id="order-tp" name="tgt" step="any" placeholder="止盈价格" required>
|
||||||
|
<input id="order-sl-pct" name="sl_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">开仓(以损定仓)</button>
|
||||||
|
</form>
|
||||||
|
</{t}>
|
||||||
|
<{t} class="card">
|
||||||
|
<h2 style="margin-bottom:8px">实时持仓</h2>
|
||||||
|
<{t} class="panel-scroll pos-list">
|
||||||
|
{order_loop}
|
||||||
|
</{t}>
|
||||||
|
</{t}>
|
||||||
|
</{t}>
|
||||||
|
{{% endif %}}
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def patch_nav(text: str) -> str:
|
||||||
|
old = '<a href="/trade" class="{% if page == \'trade\' %}active{% endif %}">交易执行</a>'
|
||||||
|
new = (
|
||||||
|
'<a href="/key_monitor" class="{% if page == \'key_monitor\' %}active{% endif %}">关键位监控</a>\n'
|
||||||
|
' <a href="/trade" class="{% if page == \'trade\' %}active{% endif %}">实盘下单</a>'
|
||||||
|
)
|
||||||
|
if "关键位监控" not in text:
|
||||||
|
text = text.replace(old, new)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def patch_js(text: str) -> str:
|
||||||
|
# page id on body
|
||||||
|
if 'id="page-trade"' not in text:
|
||||||
|
text = text.replace("<body>", '<body data-page="{{ page }}">', 1)
|
||||||
|
if "MANUAL_MIN_PLANNED_RR" not in text:
|
||||||
|
insert = """
|
||||||
|
const MANUAL_MIN_PLANNED_RR = {{ manual_min_planned_rr }};
|
||||||
|
function calcClientRr(direction, entry, sl, tp){
|
||||||
|
const e = Number(entry), s = Number(sl), t = Number(tp);
|
||||||
|
if(!Number.isFinite(e) || !Number.isFinite(s) || !Number.isFinite(t)) return null;
|
||||||
|
if(direction === 'short'){
|
||||||
|
if(s <= e || t >= e) return null;
|
||||||
|
return (e - t) / (s - e);
|
||||||
|
}
|
||||||
|
if(s >= e || t <= e) return null;
|
||||||
|
return (t - e) / (e - s);
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
text = text.replace("let latestAvailableUsdt = null;", insert + "\nlet latestAvailableUsdt = null;")
|
||||||
|
if "add-order-form" not in text or "calcClientRr" in text and "addOrderForm" not in text:
|
||||||
|
hook = """
|
||||||
|
const addOrderForm = document.getElementById("add-order-form");
|
||||||
|
if(addOrderForm){
|
||||||
|
addOrderForm.addEventListener("submit", function(ev){
|
||||||
|
const direction = (document.getElementById("order-direction")||{}).value || "long";
|
||||||
|
const mode = (document.getElementById("sltp-mode")||{}).value || "price";
|
||||||
|
let sl, tp, entry;
|
||||||
|
if(mode === "pct"){
|
||||||
|
alert("百分比模式请确认盈亏比后再提交;建议使用价格模式以便校验。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sl = Number((document.getElementById("order-sl")||{}).value);
|
||||||
|
tp = Number((document.getElementById("order-tp")||{}).value);
|
||||||
|
entry = sl;
|
||||||
|
fetch(`/api/order_defaults?symbol=${encodeURIComponent((document.getElementById("order-symbol")||{}).value||"")}&direction=${encodeURIComponent(direction)}`)
|
||||||
|
.then(r=>r.json())
|
||||||
|
.then(data=>{
|
||||||
|
const px = data.last_price || data.price;
|
||||||
|
if(px) entry = Number(px);
|
||||||
|
const rr = calcClientRr(direction, entry, sl, tp);
|
||||||
|
if(rr === null || rr < MANUAL_MIN_PLANNED_RR){
|
||||||
|
alert(`计划盈亏比 ${rr === null ? '无效' : rr.toFixed(2)}:1 低于最低要求 ${MANUAL_MIN_PLANNED_RR}:1,已阻止人工下单。`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addOrderForm.submit();
|
||||||
|
})
|
||||||
|
.catch(()=>{ ev.preventDefault(); alert("无法校验盈亏比,请稍后重试"); });
|
||||||
|
ev.preventDefault();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
text = text.replace("refreshOrderDefaults();", hook + "\nrefreshOrderDefaults();")
|
||||||
|
if "max_active_positions" not in text and "order-rule-tip" in text:
|
||||||
|
text = text.replace(
|
||||||
|
"规则:单仓;",
|
||||||
|
"规则:最多 {{ max_active_positions }} 仓;",
|
||||||
|
)
|
||||||
|
# account snapshot tip
|
||||||
|
old_tip = '`规则:单仓;BTC {{ btc_leverage }}x'
|
||||||
|
if old_tip in text:
|
||||||
|
text = text.replace(
|
||||||
|
old_tip,
|
||||||
|
"`规则:最多 ${data.max_active_positions || {{ max_active_positions }}} 仓;BTC {{ btc_leverage }}x",
|
||||||
|
)
|
||||||
|
text = text.replace(
|
||||||
|
'const canTradeText = data.can_trade ? "可开仓" : "不可开仓(有持仓或未到北京时间 {{ reset_hour }}:00)";',
|
||||||
|
'const canTradeText = data.can_trade ? "可开仓" : `不可开仓(持仓 ${data.active_count||0}/${data.max_active_positions||{{ max_active_positions }}} 或未到北京时间 {{ reset_hour }}:00)`;',
|
||||||
|
)
|
||||||
|
text = text.replace(
|
||||||
|
"if(!data.in_top30){",
|
||||||
|
"const rankMax = data.rank_max || 30;\n if(!data.in_top30){",
|
||||||
|
)
|
||||||
|
text = text.replace(
|
||||||
|
"不在前30,已拦截",
|
||||||
|
"不在前${rankMax},已拦截",
|
||||||
|
)
|
||||||
|
# conditional price refresh
|
||||||
|
if "data-page" in text and "refreshPriceSnapshotConditional" not in text:
|
||||||
|
text = text.replace(
|
||||||
|
"setInterval(refreshPriceSnapshot, {{ price_refresh_seconds * 1000 }});",
|
||||||
|
"""function refreshPriceSnapshotConditional(){
|
||||||
|
const page = document.body.getAttribute("data-page") || "";
|
||||||
|
fetch("/api/price_snapshot").then(r=>r.json()).then(data=>{
|
||||||
|
const updatedEl = document.getElementById("price-last-updated");
|
||||||
|
if(data.updated_at && updatedEl) updatedEl.innerText = data.updated_at;
|
||||||
|
if(page === "key_monitor"){
|
||||||
|
(data.key_prices || []).forEach(k=>{
|
||||||
|
const pEl = document.getElementById(`key-price-${k.id}`);
|
||||||
|
if(pEl){ pEl.innerText = k.price_display || (Number.isFinite(Number(k.price)) ? Number(k.price).toFixed(6) : "-"); paintPriceTrend(pEl, `k-${k.id}`, Number(k.price)); }
|
||||||
|
const upEl = document.getElementById(`key-up-diff-${k.id}`);
|
||||||
|
if(upEl) upEl.innerText = `${formatSigned(k.upper_diff, 4)} (${formatSigned(k.upper_pct, 2)}%)`;
|
||||||
|
const lowEl = document.getElementById(`key-low-diff-${k.id}`);
|
||||||
|
if(lowEl) lowEl.innerText = `${formatSigned(k.lower_diff, 4)} (${formatSigned(k.lower_pct, 2)}%)`;
|
||||||
|
const gateEl = document.getElementById(`key-gate-${k.id}`);
|
||||||
|
if(gateEl){ gateEl.innerText = k.gate_summary || "-"; gateEl.style.color = k.gate_ok ? "#4cd97f" : "#ff8f8f"; }
|
||||||
|
const gateMetricEl = document.getElementById(`key-gate-metrics-${k.id}`);
|
||||||
|
if(gateMetricEl) gateMetricEl.innerText = k.gate_metrics || "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if(page === "trade"){
|
||||||
|
(data.order_prices || []).forEach(o=>{
|
||||||
|
const pEl = document.getElementById(`order-price-${o.id}`);
|
||||||
|
if(pEl){
|
||||||
|
const hasMark = (()=>{ const x = o.exchange_mark_price; if(x===null||x===undefined||x==="")return false; const n=Number(x); return !Number.isNaN(n); })();
|
||||||
|
let disp = "";
|
||||||
|
if(hasMark && o.exchange_mark_price_display) disp = o.exchange_mark_price_display;
|
||||||
|
else if(o.price_display) disp = o.price_display;
|
||||||
|
else { const px = hasMark ? Number(o.exchange_mark_price) : Number(o.price); disp = Number.isFinite(px) ? px.toFixed(6) : "-"; }
|
||||||
|
pEl.innerText = disp;
|
||||||
|
const pxNum = hasMark ? Number(o.exchange_mark_price) : Number(o.price);
|
||||||
|
paintPriceTrend(pEl, `o-${o.id}`, Number.isFinite(pxNum) ? pxNum : px);
|
||||||
|
}
|
||||||
|
const exM = document.getElementById(`order-ex-margin-${o.id}`);
|
||||||
|
if(exM){
|
||||||
|
const mv = o.exchange_initial_margin;
|
||||||
|
const mn = (mv === null || mv === undefined || mv === "") ? NaN : Number(mv);
|
||||||
|
if(!Number.isNaN(mn)) exM.innerText = `${mn.toFixed(2)}U`;
|
||||||
|
else { const prc = (typeof data.positions_raw_count === "number") ? data.positions_raw_count : null; exM.innerText = (prc === 0) ? "无仓数据" : "-"; }
|
||||||
|
}
|
||||||
|
const pnlEl = document.getElementById(`order-pnl-${o.id}`);
|
||||||
|
if(pnlEl){
|
||||||
|
pnlEl.innerText = `${formatSigned(o.float_pnl, 2)}U (${formatSigned(o.float_pct, 2)}%)`;
|
||||||
|
pnlEl.classList.remove("price-up","price-down","price-flat");
|
||||||
|
if(Number(o.float_pnl) > 0) pnlEl.classList.add("price-up");
|
||||||
|
else if(Number(o.float_pnl) < 0) pnlEl.classList.add("price-down");
|
||||||
|
else pnlEl.classList.add("price-flat");
|
||||||
|
}
|
||||||
|
const rrEl = document.getElementById(`order-rr-${o.id}`);
|
||||||
|
if(rrEl) rrEl.innerText = formatRrRatio(o.rr_ratio);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).catch(()=>{});
|
||||||
|
}
|
||||||
|
setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }});""",
|
||||||
|
)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
for path in PATHS:
|
||||||
|
if not path.exists():
|
||||||
|
print("skip", path)
|
||||||
|
continue
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
start = text.find(KEY_START)
|
||||||
|
if start < 0:
|
||||||
|
start = text.find(KEY_START_ALT)
|
||||||
|
end = text.find(RECORDS_START)
|
||||||
|
if start < 0 or end < 0:
|
||||||
|
raise SystemExit(f"markers not found: {path}")
|
||||||
|
old = text[start:end]
|
||||||
|
m = re.search(r"(\{% for o in order %\}.*?\{% endfor %\})", old, re.S)
|
||||||
|
if not m:
|
||||||
|
raise SystemExit(f"order loop not found: {path}")
|
||||||
|
order_loop = m.group(1)
|
||||||
|
section = build_section(order_loop)
|
||||||
|
section = section.replace("{{%", "{%").replace("%}}", "%}").replace("{{{{", "{{").replace("}}}}", "}}")
|
||||||
|
out = text[:start] + section + "\n\n" + text[end:]
|
||||||
|
out = patch_nav(out)
|
||||||
|
out = patch_js(out)
|
||||||
|
path.write_text(out, encoding="utf-8")
|
||||||
|
print("patched", path)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Apply binance app.py risk/layout changes to gate app.py (pattern replace)."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
binance = Path(__file__).resolve().parent.parent / "app.py"
|
||||||
|
gate = Path(r"c:\Users\dekun\Desktop\crypto_monitor\crypto_monitor_gate\app.py")
|
||||||
|
|
||||||
|
b = binance.read_text(encoding="utf-8")
|
||||||
|
g = gate.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# 1) env block
|
||||||
|
old_env = """KEY_BREAKOUT_LIMIT_PCT = float(os.getenv("KEY_BREAKOUT_LIMIT_PCT", "1.5"))
|
||||||
|
KEY_AUTO_MIN_PLANNED_RR = float(os.getenv("KEY_AUTO_MIN_PLANNED_RR", "1.5"))
|
||||||
|
KEY_STOP_OUTSIDE_BREAKOUT_PCT = float(os.getenv("KEY_STOP_OUTSIDE_BREAKOUT_PCT", "0.5"))"""
|
||||||
|
|
||||||
|
new_env = """KEY_AUTO_MIN_PLANNED_RR = float(os.getenv("KEY_AUTO_MIN_PLANNED_RR", "1.5"))
|
||||||
|
KEY_STOP_OUTSIDE_BREAKOUT_PCT = float(os.getenv("KEY_STOP_OUTSIDE_BREAKOUT_PCT", "0.5"))
|
||||||
|
MANUAL_MIN_PLANNED_RR = float(os.getenv("MANUAL_MIN_PLANNED_RR", "1.4"))
|
||||||
|
MAX_ACTIVE_POSITIONS = max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", "1")))
|
||||||
|
KEY_VOLUME_MA_BARS = max(1, int(os.getenv("KEY_VOLUME_MA_BARS", "20")))
|
||||||
|
KEY_VOLUME_RATIO_MIN = float(os.getenv("KEY_VOLUME_RATIO_MIN", "1.3"))
|
||||||
|
KEY_BREAKOUT_AMP_MIN_PCT = float(os.getenv("KEY_BREAKOUT_AMP_MIN_PCT", "0.03"))
|
||||||
|
KEY_BREAKOUT_AMP_MAX_PCT = float(os.getenv("KEY_BREAKOUT_AMP_MAX_PCT", "0.5"))
|
||||||
|
KEY_DAILY_VOLUME_RANK_MAX = max(1, int(os.getenv("KEY_DAILY_VOLUME_RANK_MAX", "30")))
|
||||||
|
KEY_CONFIRM_BREAKOUT_BAR = int(os.getenv("KEY_CONFIRM_BREAKOUT_BAR", "-2"))
|
||||||
|
KEY_CONFIRM_BAR = int(os.getenv("KEY_CONFIRM_BAR", "-1"))
|
||||||
|
KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT = os.getenv("KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT", "true").lower() == "true")"""
|
||||||
|
|
||||||
|
if old_env in g:
|
||||||
|
g = g.replace(old_env, new_env)
|
||||||
|
|
||||||
|
# 2) DB migration snippet
|
||||||
|
snip = """ try:
|
||||||
|
c.execute("ALTER TABLE trading_sessions ADD COLUMN key_sizing_capital_snapshot REAL")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
c.execute("""
|
||||||
|
if snip not in g and 'key_sizing_capital_snapshot' not in g:
|
||||||
|
g = g.replace(
|
||||||
|
' c.execute(\n """CREATE TABLE IF NOT EXISTS key_monitor_history',
|
||||||
|
""" try:
|
||||||
|
c.execute("ALTER TABLE trading_sessions ADD COLUMN key_sizing_capital_snapshot REAL")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
c.execute(
|
||||||
|
\"\"\"CREATE TABLE IF NOT EXISTS key_monitor_history""",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3) precheck block - extract from binance
|
||||||
|
import re
|
||||||
|
m = re.search(
|
||||||
|
r"def get_active_position_count\(conn\):.*?return True, \"\"\n\n\ndef prepare_order_amount",
|
||||||
|
b,
|
||||||
|
re.S,
|
||||||
|
)
|
||||||
|
if m and "get_active_position_count" not in g:
|
||||||
|
g = g.replace(
|
||||||
|
"def precheck_risk(conn, symbol, direction):\n now = app_now()\n if not trading_day_reset_allows_new_open(now):\n return False, f\"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓\"\n active_count = conn.execute(\"SELECT COUNT(*) FROM order_monitors WHERE status='active'\").fetchone()[0]\n if active_count > 0:\n return False, \"一次只能持有一个仓位\"\n if direction not in (\"long\", \"short\"):\n return False, \"方向必须为 long 或 short\"\n if symbol.upper().startswith(\"BTC\") or symbol.upper().startswith(\"ETH\"):\n expected = BTC_LEVERAGE\n else:\n expected = ALT_LEVERAGE\n if expected <= 0:\n return False, \"杠杆配置异常\"\n return True, \"\"\n\n\ndef prepare_order_amount",
|
||||||
|
m.group(0),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4) render_main_page can_trade + template vars + route
|
||||||
|
if "key_monitor_page" not in g:
|
||||||
|
g = g.replace(
|
||||||
|
" can_trade = trading_day_reset_allows_new_open(now) and active_count == 0\n conn.close()\n return render_template(",
|
||||||
|
""" can_trade = trading_day_reset_allows_new_open(now) and active_count < MAX_ACTIVE_POSITIONS
|
||||||
|
key_gate_rule_text = (
|
||||||
|
f"周期 {KLINE_TIMEFRAME}|确认K:突破棒偏移 {KEY_CONFIRM_BREAKOUT_BAR}、确认棒偏移 {KEY_CONFIRM_BAR}|"
|
||||||
|
f"量能:突破量 > 前{KEY_VOLUME_MA_BARS}均量×{KEY_VOLUME_RATIO_MIN}|"
|
||||||
|
f"自动开仓盈亏比 > {KEY_AUTO_MIN_PLANNED_RR}:1|日成交量排名前 {KEY_DAILY_VOLUME_RANK_MAX}"
|
||||||
|
)
|
||||||
|
conn.close()
|
||||||
|
return render_template(""",
|
||||||
|
)
|
||||||
|
g = g.replace(
|
||||||
|
" exchange_display=EXCHANGE_DISPLAY_NAME,\n )\n\n\n@app.route(\"/\")\n@login_required\ndef index():\n return redirect(\"/trade\")\n\n\n@app.route(\"/trade\")",
|
||||||
|
""" exchange_display=EXCHANGE_DISPLAY_NAME,
|
||||||
|
max_active_positions=MAX_ACTIVE_POSITIONS,
|
||||||
|
manual_min_planned_rr=MANUAL_MIN_PLANNED_RR,
|
||||||
|
key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR,
|
||||||
|
key_gate_rule_text=key_gate_rule_text,
|
||||||
|
kline_timeframe=KLINE_TIMEFRAME,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
@login_required
|
||||||
|
def index():
|
||||||
|
return redirect("/trade")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/key_monitor")
|
||||||
|
@login_required
|
||||||
|
def key_monitor_page():
|
||||||
|
return render_main_page("key_monitor")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/trade")""",
|
||||||
|
)
|
||||||
|
|
||||||
|
# api account
|
||||||
|
g = g.replace(
|
||||||
|
" active_count = conn.execute(\"SELECT COUNT(*) FROM order_monitors WHERE status='active'\").fetchone()[0]\n conn.close()\n can_trade = trading_day_reset_allows_new_open(now) and active_count == 0",
|
||||||
|
" active_count = get_active_position_count(conn)\n conn.close()\n can_trade = trading_day_reset_allows_new_open(now) and active_count < MAX_ACTIVE_POSITIONS",
|
||||||
|
)
|
||||||
|
if '"max_active_positions"' not in g:
|
||||||
|
g = g.replace(
|
||||||
|
'"can_trade": can_trade,\n "trading_day": trading_day\n })',
|
||||||
|
'"can_trade": can_trade,\n "max_active_positions": MAX_ACTIVE_POSITIONS,\n "manual_min_planned_rr": MANUAL_MIN_PLANNED_RR,\n "trading_day": trading_day\n })',
|
||||||
|
)
|
||||||
|
|
||||||
|
gate.write_text(g, encoding="utf-8")
|
||||||
|
print("gate app partially synced; manual review _key_hard_checks add_order still needed")
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
python scripts/verify_binance_funding.py
|
||||||
|
|
||||||
|
打印 BINANCE_API_KEY 前 8 位便于与 Binance 控制台核对(不含 Secret)。用于服务器自检。
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
sys.path.insert(0, BASE)
|
||||||
|
|
||||||
|
|
||||||
|
def load_env(path):
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return
|
||||||
|
for line in open(path, "r", encoding="utf-8", errors="ignore"):
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
k, v = line.split("=", 1)
|
||||||
|
k = k.strip().lstrip("\ufeff")
|
||||||
|
if k.replace("_", "").isalnum():
|
||||||
|
os.environ[k] = v.strip().strip('"').strip("'")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
load_env(os.path.join(BASE, ".env"))
|
||||||
|
k = (os.getenv("BINANCE_API_KEY") or "").strip()
|
||||||
|
s = (os.getenv("BINANCE_API_SECRET") or "").strip()
|
||||||
|
if not k or "REPLACE" in k.upper():
|
||||||
|
print("WARN: BINANCE_API_KEY 为空或仍像占位符,请核对 .env")
|
||||||
|
if not s or "REPLACE" in s.upper():
|
||||||
|
print("WARN: BINANCE_API_SECRET 为空或仍像占位符,请核对 .env")
|
||||||
|
print("BINANCE_API_KEY prefix (8 chars):", (k[:8] + "…") if len(k) > 8 else "(short)")
|
||||||
|
|
||||||
|
import app as mod # noqa: E402
|
||||||
|
|
||||||
|
mod.ensure_markets_loaded()
|
||||||
|
fu = mod._fetch_binance_funding_usdt()
|
||||||
|
print(">>> _fetch_binance_funding_usdt() =", fu)
|
||||||
|
try:
|
||||||
|
sw = mod._fetch_binance_swap_usdt_total()
|
||||||
|
print(">>> _fetch_binance_swap_usdt_total() (合约账户) =", sw)
|
||||||
|
sf = mod._fetch_binance_swap_usdt_free()
|
||||||
|
print(">>> _fetch_binance_swap_usdt_free() (合约可用) =", sf)
|
||||||
|
except Exception as e:
|
||||||
|
print(">>> swap balance fetch error:", e)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
ok2
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>{{ exchange_display }} | 关键位放大</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}
|
||||||
|
input,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:580px;background:#0f1320;border:1px solid #2a3150;border-radius:10px;padding:8px}
|
||||||
|
#chart{width:100%;height:100%}
|
||||||
|
.exchange-tag{font-size:.72rem;font-weight:600;color:#b8f5d0;background:#14241e;border:1px solid #2d6a4f;padding:4px 10px;border-radius:999px;margin-left:8px}
|
||||||
|
</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">关键位放大(可输入币种)</strong><span class="exchange-tag">{{ exchange_display }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="status">最近刷新:<span id="updated-at">--</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row" style="margin-top:10px">
|
||||||
|
<label>币种</label>
|
||||||
|
<input id="symbol-input" value="{{ default_symbol }}" placeholder="BTC/USDT">
|
||||||
|
|
||||||
|
<label>关键位</label>
|
||||||
|
<select id="key-id">
|
||||||
|
<option value="">无(仅看K线)</option>
|
||||||
|
{% for k in key_list %}
|
||||||
|
<option value="{{ k.id }}" {% if selected_key and k.id == selected_key.id %}selected{% endif %}>#{{ k.id }} {{ k.symbol }} {{ k.monitor_type }} {{ '做多' if k.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>
|
||||||
|
|
||||||
|
<label>K线数</label>
|
||||||
|
<select id="kline-limit">
|
||||||
|
<option value="100" {% if default_kline_limit == 100 %}selected{% endif %}>100</option>
|
||||||
|
<option value="200" {% if default_kline_limit == 200 %}selected{% endif %}>200</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button id="manual-refresh" type="button">刷新</button>
|
||||||
|
<span id="load-status" class="status"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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-type">-</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-upper">-</div></div>
|
||||||
|
<div class="meta-item"><div class="k">下沿/支撑</div><div class="v" id="m-lower">-</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-updiff">-</div></div>
|
||||||
|
<div class="meta-item"><div class="k">距下沿</div><div class="v" id="m-lowdiff">-</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card"><div id="chart-wrap"><div id="chart"></div></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
|
||||||
|
<script>
|
||||||
|
const refreshMs = Math.max({{ price_refresh_seconds * 1000 }}, 5000);
|
||||||
|
const keySelect = document.getElementById("key-id");
|
||||||
|
const symbolInput = document.getElementById("symbol-input");
|
||||||
|
const tfSelect = document.getElementById("timeframe");
|
||||||
|
const limitSelect = document.getElementById("kline-limit");
|
||||||
|
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);
|
||||||
|
const fmtSigned = (v,d=4)=>{
|
||||||
|
if(v===null||typeof v==="undefined"||Number.isNaN(Number(v))) return "-";
|
||||||
|
const n = Number(v);
|
||||||
|
return `${n>0?"+":""}${n.toFixed(d)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
let chart = null;
|
||||||
|
let candleSeries = null;
|
||||||
|
let priceLines = [];
|
||||||
|
const keyMap = {};
|
||||||
|
{% for k in key_list %}
|
||||||
|
keyMap["{{ k.id }}"] = "{{ k.symbol }}";
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
function ensureChart(){
|
||||||
|
if(chart && candleSeries) return true;
|
||||||
|
if(!window.LightweightCharts){
|
||||||
|
statusEl.className = "status err";
|
||||||
|
statusEl.innerText = "图表库加载失败";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!chart){
|
||||||
|
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}
|
||||||
|
});
|
||||||
|
window.addEventListener("resize",()=>{
|
||||||
|
chart.applyOptions({width:chartHost.clientWidth,height:chartHost.clientHeight});
|
||||||
|
});
|
||||||
|
chart.applyOptions({width:chartHost.clientWidth,height:chartHost.clientHeight});
|
||||||
|
}
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
upColor: "#4cd97f",
|
||||||
|
downColor: "#ff6666",
|
||||||
|
borderVisible: false,
|
||||||
|
wickUpColor: "#4cd97f",
|
||||||
|
wickDownColor: "#ff6666"
|
||||||
|
};
|
||||||
|
if (typeof chart.addCandlestickSeries === "function") {
|
||||||
|
candleSeries = chart.addCandlestickSeries(opts);
|
||||||
|
} else if (typeof chart.addSeries === "function" && window.LightweightCharts && window.LightweightCharts.CandlestickSeries) {
|
||||||
|
candleSeries = chart.addSeries(window.LightweightCharts.CandlestickSeries, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!candleSeries){
|
||||||
|
statusEl.className = "status err";
|
||||||
|
statusEl.innerText = "K线序列初始化失败";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPriceLines(){
|
||||||
|
if(!candleSeries) return;
|
||||||
|
priceLines.forEach(line=>{ try { candleSeries.removePriceLine(line); } catch (_) {} });
|
||||||
|
priceLines = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLine(price, title, color){
|
||||||
|
if(!candleSeries || price===null || typeof price==="undefined") 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 paintMeta(data){
|
||||||
|
const key = data.key_monitor || null;
|
||||||
|
document.getElementById("m-symbol").innerText = data.symbol || "-";
|
||||||
|
document.getElementById("m-price").innerText = data.current_price_display || fmt(data.current_price,8);
|
||||||
|
|
||||||
|
if(!key){
|
||||||
|
document.getElementById("m-type").innerText = "未匹配到关键位";
|
||||||
|
document.getElementById("m-direction").innerText = "-";
|
||||||
|
document.getElementById("m-upper").innerText = "-";
|
||||||
|
document.getElementById("m-lower").innerText = "-";
|
||||||
|
document.getElementById("m-updiff").innerText = "-";
|
||||||
|
document.getElementById("m-lowdiff").innerText = "-";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("m-type").innerText = key.monitor_type || "-";
|
||||||
|
document.getElementById("m-direction").innerText = key.direction === "short" ? "做空" : "做多";
|
||||||
|
document.getElementById("m-upper").innerText = key.upper_display || fmt(key.upper,8);
|
||||||
|
document.getElementById("m-lower").innerText = key.lower_display || fmt(key.lower,8);
|
||||||
|
document.getElementById("m-updiff").innerText = `${fmtSigned(key.upper_diff,4)} (${fmtSigned(key.upper_pct,2)}%)`;
|
||||||
|
document.getElementById("m-lowdiff").innerText = `${fmtSigned(key.lower_diff,4)} (${fmtSigned(key.lower_pct,2)}%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSymbolByKey(){
|
||||||
|
const keyId = keySelect.value;
|
||||||
|
if(keyId && keyMap[keyId]) symbolInput.value = keyMap[keyId];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadKeyKline(){
|
||||||
|
if(!ensureChart()) return;
|
||||||
|
const keyId = keySelect.value;
|
||||||
|
const symbol = (symbolInput.value || "").trim().toUpperCase();
|
||||||
|
const timeframe = tfSelect.value;
|
||||||
|
const limit = limitSelect.value;
|
||||||
|
|
||||||
|
if(!symbol && !keyId){
|
||||||
|
statusEl.className = "status err";
|
||||||
|
statusEl.innerText = "请先输入币种或选择关键位";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusEl.className = "status";
|
||||||
|
statusEl.innerText = "加载中...";
|
||||||
|
|
||||||
|
try{
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if(keyId) qs.set("key_id", keyId);
|
||||||
|
if(symbol) qs.set("symbol", symbol);
|
||||||
|
qs.set("timeframe", timeframe);
|
||||||
|
qs.set("limit", limit);
|
||||||
|
|
||||||
|
const resp = await fetch(`/api/key_kline?${qs.toString()}`);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!candleSeries) throw new Error("Series init failed");
|
||||||
|
candleSeries.setData(candles);
|
||||||
|
resetPriceLines();
|
||||||
|
addLine(data.current_price, "现价", "#42a5f5");
|
||||||
|
if(data.key_monitor){
|
||||||
|
addLine(data.key_monitor.upper, "上沿/阻力", "#ffb84d");
|
||||||
|
addLine(data.key_monitor.lower, "下沿/支撑", "#4cd97f");
|
||||||
|
}
|
||||||
|
chart.timeScale().fitContent();
|
||||||
|
paintMeta(data);
|
||||||
|
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", loadKeyKline);
|
||||||
|
keySelect.addEventListener("change", ()=>{ syncSymbolByKey(); loadKeyKline(); });
|
||||||
|
symbolInput.addEventListener("change", ()=>{
|
||||||
|
if(symbolInput.value.trim()) keySelect.value = "";
|
||||||
|
loadKeyKline();
|
||||||
|
});
|
||||||
|
tfSelect.addEventListener("change", loadKeyKline);
|
||||||
|
limitSelect.addEventListener("change", loadKeyKline);
|
||||||
|
|
||||||
|
syncSymbolByKey();
|
||||||
|
loadKeyKline();
|
||||||
|
setInterval(loadKeyKline, refreshMs);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<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>
|
||||||
|
</head>
|
||||||
|
<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>
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
<!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>
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>{{ exchange_display }} | 实盘下单放大</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}
|
||||||
|
.exchange-tag{font-size:.72rem;font-weight:600;color:#b8f5d0;background:#14241e;border:1px solid #2d6a4f;padding:4px 10px;border-radius:999px;margin-left:8px}
|
||||||
|
</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><span class="exchange-tag">{{ exchange_display }}</span>
|
||||||
|
</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-breakeven">-</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-breakeven").innerText =
|
||||||
|
(order.breakeven_enabled === false || order.breakeven_enabled === 0) ? "关闭" : "开启";
|
||||||
|
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 %}
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
if (typeof ensureChart !== 'function') return;
|
||||||
|
const oldEnsureChart = ensureChart;
|
||||||
|
ensureChart = function(){
|
||||||
|
if (chart && candleSeries) return true;
|
||||||
|
try { const ok = oldEnsureChart(); if (ok && candleSeries) return true; } catch (_) {}
|
||||||
|
if (chart && !candleSeries && typeof chart.addSeries === 'function' && window.LightweightCharts && window.LightweightCharts.CandlestickSeries) {
|
||||||
|
const opts = { upColor:'#4cd97f', downColor:'#ff6666', borderVisible:false, wickUpColor:'#4cd97f', wickDownColor:'#ff6666' };
|
||||||
|
candleSeries = chart.addSeries(window.LightweightCharts.CandlestickSeries, opts);
|
||||||
|
return !!candleSeries;
|
||||||
|
}
|
||||||
|
return !!candleSeries;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# 使用说明
|
||||||
|
|
||||||
|
**本文件对应仓库:`crypto_monitor_binance`(Binance U 本位永续)。**
|
||||||
|
功能、界面与 **Gate.io USDT 永续版**(目录 `crypto_monitor_gate`)基本一致,差异主要在 **`.env` 里交易所密钥与部分参数名**(`BINANCE_*` / `GATE_*`),文末有对照。
|
||||||
|
|
||||||
|
**部署、代理、PM2 等**请参考本仓库说明或 **`crypto_monitor_gate`** 下的 **`部署文档.md`**(该文以 Gate + SSH SOCKS 为例;Binance 侧将 API 与密钥改为 `BINANCE_*` 即可类比)。
|
||||||
|
**关键位自动开仓的规则、RR、结案原因**见本目录 **`关键位自动下单说明.md`**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 它能做什么
|
||||||
|
|
||||||
|
面向个人盘面的 **Web 控制台**,主要能力包括:
|
||||||
|
|
||||||
|
| 模块 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| **关键位监控** | 录入上/下沿与类型,按 **5m 收线** 做硬条件过滤;符合条件后 **企业微信** 提醒,部分类型可 **自动市价开仓**(见第 4 节与专门文档)。 |
|
||||||
|
| **实盘下单监控** | 手工填止损/止盈,**以损定仓** 市价开单,挂上条件止盈止损,并在页面跟踪浮盈亏、保本逻辑等。 |
|
||||||
|
| **交易记录 / 复盘** | 平仓结果、盈亏、错过的单等归档与导出。 |
|
||||||
|
|
||||||
|
后台按 **`MONITOR_POLL_SECONDS`**(默认几秒)轮询行情与监控逻辑。**切勿**在未理解规则时同时运行两套程序共用一个实盘账户。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 运行前必须配置(`.env`)
|
||||||
|
|
||||||
|
首次在本目录执行 **`cp .env.example .env`**,再编辑 `.env`(`.env` 勿提交 Git;`git pull` 不会改你的 `.env`,升级前建议 `cp .env .env.backup.$(date +%Y%m%d)`)。
|
||||||
|
|
||||||
|
至少检查以下项(具体键名以 **`.env.example`** 为准):
|
||||||
|
|
||||||
|
| 类别 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| **登录网页** | `APP_PASSWORD`:打开站点后的登录口令。`FLASK_SECRET_KEY`:Session 密钥,请勿使用默认值。 |
|
||||||
|
| **企业微信** | `WECHAT_WEBHOOK`:告警与关键位推送机器人的 Webhook。 |
|
||||||
|
| **是否真下单** | `LIVE_TRADING_ENABLED=false`:**不会**向交易所发送开仓指令(适合测试流程)。改为 `true` 且密钥正确才会实盘。 |
|
||||||
|
| **交易所 API** | **本仓库:** `BINANCE_API_KEY`、`BINANCE_API_SECRET`;永续相关见 `BINANCE_MARGIN_MODE`、`BINANCE_POSITION_MODE`、`BINANCE_TRIGGER_WORKING_TYPE` 等。**勿**把 `.env` 提交到 Git。 |
|
||||||
|
| **关键位 RR / 止损外扩** | `KEY_AUTO_MIN_PLANNED_RR`、`KEY_STOP_OUTSIDE_BREAKOUT_PCT`(详见 `关键位自动下单说明.md`)。 |
|
||||||
|
|
||||||
|
网络需要代理时可配置 **`BINANCE_SOCKS_PROXY` / `BINANCE_HTTP_PROXY`**(与 Gate 版 `GATE_*_PROXY` 用法类似)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 如何启动与登录
|
||||||
|
|
||||||
|
1. 准备 Python 虚拟环境并安装依赖(如 `flask`、`requests`、`ccxt`、按需 `Pillow`、`PySocks` 等),配置好 `.env`。
|
||||||
|
2. 启动 Flask 应用(可用 **`ecosystem.config.cjs`** 交给 PM2,或本地 `python app.py` / `flask run`,以你当前脚本为准)。
|
||||||
|
3. 浏览器访问站点,打开 **`/login`**,使用 **`.env` 里的 `APP_PASSWORD`** 登录。
|
||||||
|
|
||||||
|
登录后顶栏为四页:**关键位监控** | **实盘下单**(默认首页)| **交易记录与复盘** | **统计分析**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 列表与历史
|
||||||
|
|
||||||
|
当前条目与历史记录的用法与 Gate 版相同;结案后可在历史区查阅 **`close_reason`**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 实盘下单(顶栏「实盘下单」→ `/trade`)
|
||||||
|
|
||||||
|
- 持仓上限由 **`MAX_ACTIVE_POSITIONS`** 控制(默认 1)。
|
||||||
|
- **人工开仓**计划盈亏比不得低于 **`MANUAL_MIN_PLANNED_RR`**(默认 1.4:1)。
|
||||||
|
- 填写币种、方向、杠杆(可选)、止损/止盈(价格或百分比按表单)。
|
||||||
|
- 移动保本等选项按页面与 `.env` 默认。
|
||||||
|
|
||||||
|
开仓成功后卡片 **「来源」**:手工一般为 **下单监控**;关键位自动为 **关键位监控**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 企业微信
|
||||||
|
|
||||||
|
推送逻辑与 Gate 版一致;未配置 **`WECHAT_WEBHOOK`** 时可能没有消息,请以 **交易所端** 核对持仓与挂单。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 强烈建议的风险与运维习惯
|
||||||
|
|
||||||
|
1. **先用 `LIVE_TRADING_ENABLED=false`** 熟悉流程再实盘。
|
||||||
|
2. **API 权限**最小化,密钥勿泄露。
|
||||||
|
3. **同一账户避免多程序重复开仓**。
|
||||||
|
4. **自动备份**:服务器上执行 `bash scripts/install_backup_cron.sh`(每天北京时间 0:00 → `/root/backups`,保留 30 天);升级前也可 `bash scripts/backup_data.sh` 手动跑一次。
|
||||||
|
5. 升级代码后留意 **首轮启动**有无数据库迁移报错。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 常见问题(简要)
|
||||||
|
|
||||||
|
| 现象 | 可自查 |
|
||||||
|
|------|--------|
|
||||||
|
| 关键位永远不触发 | 门控五项、日成交量排名、`KLINE_TIMEFRAME`。 |
|
||||||
|
| 有信号但不自动开仓 | `LIVE_TRADING_ENABLED`、RR 阈值、是否已有持仓、API/保证金错误信息。 |
|
||||||
|
| 加不了箱体/收敛 | 是否已有持仓。 |
|
||||||
|
| 推送收不到 | Webhook、网络。 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Gate 版(`crypto_monitor_gate`)差异速查
|
||||||
|
|
||||||
|
| 项目 | Binance 本仓库 | Gate 版 |
|
||||||
|
|------|----------------|--------|
|
||||||
|
| API 变量 | `BINANCE_API_KEY`、`BINANCE_API_SECRET`、`BINANCE_*` | `GATE_API_KEY`、`GATE_API_SECRET`、`GATE_*` |
|
||||||
|
| 代理示例 | `BINANCE_SOCKS_PROXY` | `GATE_SOCKS_PROXY` |
|
||||||
|
| TP/SL 实现 | `_binance_place_tp_sl_orders` | `_gate_place_tp_sl_orders`、`GATE_TPSL_*` |
|
||||||
|
| 资金舍入口径 | **`FUNDS_DECIMALS`**(与记账一致) | 以 Gate 仓库实现为准 |
|
||||||
|
|
||||||
|
业务流程(登录、四种关键位、手工单、单仓)两份程序对齐;仅需更换目录与 `.env`。
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# 关键位自动下单说明
|
||||||
|
|
||||||
|
**适用仓库:`crypto_monitor_binance`|交易所:Binance U 本位永续**(Gate 版见同名的 `crypto_monitor_gate` 目录。)
|
||||||
|
|
||||||
|
本文档与 `.env`、`app.check_key_monitors`、`app.add_key`、`_market_open_for_key_monitor` 的实现一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 结构与是否自动开仓
|
||||||
|
|
||||||
|
| `key_monitors.monitor_type`(录入类型) | 自动下单 | 触发后处置 |
|
||||||
|
|---------------------------------------|----------|------------|
|
||||||
|
| **箱体突破** | 是(满足全部条件) | **一次性结案**:写 `key_monitor_history` → 从 `key_monitors` **删除** |
|
||||||
|
| **收敛突破** | 是(同上) | 同上 |
|
||||||
|
| **关键阻力位** | 否 | 企业微信 **1 次** → `close_reason=key_level_alert_only` → **失效** |
|
||||||
|
| **关键支撑位** | 否 | 同上 |
|
||||||
|
|
||||||
|
触发条件:**5m 收线硬门控** `_key_hard_checks`(量能、突破幅度、第二根收盘确认、日成交量前 30 等)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 录入限制(`/add_key`)
|
||||||
|
|
||||||
|
- 存在 **`order_monitors.status='active'`** 时:**禁止添加** 「箱体突破」「收敛突破」。
|
||||||
|
- **关键阻力位 / 关键支撑位**:不受上条限制;触发后 **仅单次微信提醒**,然后结案。
|
||||||
|
- **4h EMA55 与所选方向逆势**:**不拦截**;添加成功后 **Flash** 提示。
|
||||||
|
- 上下沿入库前经 **`round_price_to_exchange`** 按合约 **价格精度** 取整。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 环境与参数(`.env`)
|
||||||
|
|
||||||
|
| 变量 | 含义 | 默认 |
|
||||||
|
|------|------|------|
|
||||||
|
| `KEY_AUTO_MIN_PLANNED_RR` | 计划 RR 阈值:**仅当严格大于该值** 才自动开仓(按下方 `E` 计算) | `1.5` |
|
||||||
|
| `KEY_STOP_OUTSIDE_BREAKOUT_PCT` | 止损:突破 K 极值向外 **百分比**(多:`低×(1−p/100)`;空:`高×(1+p/100)`) | `0.5` |
|
||||||
|
|
||||||
|
**其余与本仓库手动实盘一致:** `KLINE_TIMEFRAME`、`RISK_PERCENT`、`LIVE_TRADING_ENABLED`、`BREAKEVEN_*`、`DAILY_OPEN_ALERT_THRESHOLD`,以及 **`BINANCE_*`**(密钥、`BINANCE_MARGIN_MODE`、`BINANCE_POSITION_MODE`、`BINANCE_TRIGGER_WORKING_TYPE` 等)。资金字段舍入端口径与 **`FUNDS_DECIMALS`** 一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 计价与下单口径
|
||||||
|
|
||||||
|
| 用途 | 价格 |
|
||||||
|
|------|------|
|
||||||
|
| 企业微信展示、**与 RR 门槛比较的计划 RR** | 确认 K(第二根闭合 5m)收盘 **`E`** |
|
||||||
|
| **实际开仓** | **市价**(`place_exchange_order`,与 `/add_order` 一致);成交价可能与 `E` **滑点** |
|
||||||
|
| **以损定仓** | `calc_risk_fraction(direction, 当前市价, 止损)` + `RISK_PERCENT`(保证金等 **`FUNDS_DECIMALS`** 舍入,与 `/add_order` 一致) |
|
||||||
|
|
||||||
|
- 开仓成功后:`order_monitors.monitor_type` 为 **关键位监控**;持仓卡片「来源」显示之。手动开仓为 **下单监控**。
|
||||||
|
- 持仓列表中的 **盈亏比**:按 **实际成交价** 相对 SL/TP 重算,可与「按 `E` 算的计划 RR」略有偏差。
|
||||||
|
- **本仓库止盈止损挂单**:开仓后由 **`_binance_place_tp_sl_orders`** 挂载(与手动一致:U 本位条件/Algo 类触发单;具体类型以 ccxt / 交易所为准)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 自动单止盈 / 止损(仅箱体突破、收敛突破)
|
||||||
|
|
||||||
|
添加关键位时在页面选择 **止盈止损方案**(写入 `key_monitors.sl_tp_mode`)。确认 K 收盘 **E**,箱体高 **H = |upper − lower|`**。
|
||||||
|
|
||||||
|
| 方案 | `sl_tp_mode` | 多:SL / TP | 空:SL / TP |
|
||||||
|
|------|--------------|-------------|-------------|
|
||||||
|
| 标准突破(默认) | `standard` | 突破 K 低 × (1−`KEY_STOP_OUTSIDE_BREAKOUT_PCT`%) / **E+H** | 突破 K 高 × (1+外侧%) / **E−H** |
|
||||||
|
| 箱体1R·止盈1.5H | `box_1p5` | **E−H** / **E+1.5×H**(RR≈1.5) | **E+H** / **E−1.5×H** |
|
||||||
|
| 趋势单·自填止盈 | `trend_manual` | 突破 K 低 × (1−`KEY_TREND_STOP_OUTSIDE_PCT`%) / **录入止盈** | 突破 K 高 × (1+外侧%) / **录入止盈** |
|
||||||
|
|
||||||
|
计划 **`RR = calc_rr_ratio(direction, E, SL, TP)`**。若为 `None` 或 **RR ≤ `KEY_AUTO_MIN_PLANNED_RR`** → **不下单**,走 `rr_insufficient` 结案。
|
||||||
|
|
||||||
|
**移动保本:** 添加时可勾选(默认关);开仓写入 `order_monitors.breakeven_enabled` 与勾选一致。详见仓库根目录 `关键位止盈止损与移动保本更新说明.md`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一次性结案(`close_reason`)
|
||||||
|
|
||||||
|
以下任一发生:**按需发微信** → **`key_monitor_history`** → **从 `key_monitors` 删除**;**不会对同一条关键位重复轮询重试开仓**。
|
||||||
|
|
||||||
|
| `close_reason` | 含义 |
|
||||||
|
|----------------|------|
|
||||||
|
| `rr_insufficient` | 门控通过,但计划 RR 未达标或 SL/TP / RR **几何无效** |
|
||||||
|
| `exchange_failed` | 计划 RR 达标,但未开实盘、`LIVE_TRADING_ENABLED=false`、风控、保证金或 **交易所报错** 等导致 **开仓失败** |
|
||||||
|
| `auto_opened` | 计划 RR 达标且 **市价开仓成功**(已写 `order_monitors`,并已挂止盈止损) |
|
||||||
|
| `key_level_alert_only` | 阻力/支撑位 **仅推送**结案 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 与企业微信推送
|
||||||
|
|
||||||
|
每种结案路径 **至多一条**主业务推送(RR 不足 / 下单失败 / 开仓成功 / 阻力支撑仅提醒)。
|
||||||
|
|
||||||
|
旧版「满 `KEY_ALERT_MAX_TIMES` 次再归档」对已触发结案的路径 **不再适用**;表中 `notification_count`、`max_notify` 等字段仍可能存在,以 **导出、兼容** 为主。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关代码位置(通用)
|
||||||
|
|
||||||
|
| 说明 | 符号 |
|
||||||
|
|------|------|
|
||||||
|
| 门控与主循环 | `check_key_monitors` |
|
||||||
|
| 录入、有仓拦截、4h Flash | `add_key` |
|
||||||
|
| 市价开仓 + 写 `order_monitors` | `_market_open_for_key_monitor` |
|
||||||
|
| 计划 RR | `calc_rr_ratio(direction, E, SL, TP)` |
|
||||||
|
| 价格精度 | `round_price_to_exchange` |
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# 界面与风控更新说明(Binance 实例)
|
||||||
|
|
||||||
|
## 顶栏导航(4 项)
|
||||||
|
|
||||||
|
| 顺序 | 名称 | 路由 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 1 | 关键位监控 | `/key_monitor` | 关键位添加、实时门控、历史 |
|
||||||
|
| 2 | 实盘下单 | `/trade` | 人工开仓、划转、实时持仓(**默认首页** `/` → `/trade`) |
|
||||||
|
| 3 | 交易记录与复盘 | `/records` | 交易记录、复盘表单、AI 历史(受顶栏 UTC 时间窗筛选) |
|
||||||
|
| 4 | 统计分析 | `/stats` | 按北京时间交易日切日 + 分品类统计块 |
|
||||||
|
|
||||||
|
## 关键位监控页
|
||||||
|
|
||||||
|
- 标题去掉「5m」;规则条从 `.env` 读取(周期、确认K、量能、自动开仓盈亏比、日成交量排名)。
|
||||||
|
- 左列:活跃关键位,**pos-card** 样式展示现价/距上沿/距下沿/门控。
|
||||||
|
- 右列:关键位历史(失效/结案),与左列等高滚动;**受顶栏 UTC 列表时间窗筛选**(默认 UTC 当日)。
|
||||||
|
- 监控类型新增:**斐波回调0.618**、**斐波回调0.786**(与 Gate 主站同一套规则,计算逻辑见仓库根目录 `fib_key_monitor_lib.py`)。
|
||||||
|
|
||||||
|
### 斐波关键位监控(方案 A:交易所限价)
|
||||||
|
|
||||||
|
| 项 | 说明 |
|
||||||
|
|----|------|
|
||||||
|
| 同币互斥 | 每个币种只能有一条斐波监控(0.618 与 0.786 不可并存) |
|
||||||
|
| 上下沿 | 上沿 **H**、下沿 **L**(须 H > L) |
|
||||||
|
| 挂单价 E | **做多** `E = H − ratio × (H − L)`(自 H 向下回撤);**做空** `E = L + ratio × (H − L)`(自 L 向上反弹) |
|
||||||
|
| 做多 | 限价 @ E,止损 L,止盈 H |
|
||||||
|
| 做空 | 限价 @ E,止损 H,止盈 L |
|
||||||
|
| 添加后 | **立即**在 Binance U 本位挂限价单;卡片显示 **挂E**、限价单 ID |
|
||||||
|
| 失效 | 以**标记价**判断:做多且标记价 ≥ H、做空且标记价 ≤ L,且限价**未成交** → 撤销该限价单并结案 |
|
||||||
|
| 成交后 | 挂交易所 TP/SL(含 Algo 通道条件单)→ 写入 **实盘下单监控**(`monitor_type=关键位监控`,`key_signal_type=斐波回调…`)→ 从关键位列表移除 |
|
||||||
|
| 撤单 | 仅撤本条斐波的订单 ID,**不会**对该合约 `cancel_all_orders` / 全撤 Algo,避免误伤其他委托 |
|
||||||
|
| 盈亏比 | 计划 RR 须 > `KEY_AUTO_MIN_PLANNED_RR`;0.618 理论约 1.6:1,0.786 约 3.7:1 |
|
||||||
|
| 日成交量 | 与箱体/收敛相同,须在前 `KEY_DAILY_VOLUME_RANK_MAX` 名内方可添加 |
|
||||||
|
|
||||||
|
后台轮询:`check_fib_key_monitors()`;箱体/收敛仍走 `check_key_monitors()`。
|
||||||
|
|
||||||
|
手动删除关键位时,未成交斐波会先撤限价再删库。
|
||||||
|
|
||||||
|
### 箱体 / 收敛自动开仓(来源标注)
|
||||||
|
|
||||||
|
- 自动开仓写入 `order_monitors.key_signal_type`:`箱体突破` 或 `收敛突破`。
|
||||||
|
- 持仓与交易记录展示「来源 · 信号类型」。
|
||||||
|
|
||||||
|
## 列表时间窗(UTC,全站顶栏)
|
||||||
|
|
||||||
|
共用模块:仓库根目录 `history_window_lib.py`(Gate / Binance 主站一致)。
|
||||||
|
|
||||||
|
| 项 | 说明 |
|
||||||
|
|----|------|
|
||||||
|
| 默认 | **UTC 当日**(`win_preset=utc_today`,从 UTC 0:00 至当前时刻) |
|
||||||
|
| 可选 | 近 24 小时、近 7 天、自定义起止(UTC,`datetime-local`) |
|
||||||
|
| 作用范围 | 关键位历史、交易记录列表、复盘记录 API、AI 历史 API、导出「交易记录」「关键位历史」 |
|
||||||
|
| 与统计的关系 | **仅影响列表/导出**;**统计分析页仍按北京时间 `TRADING_DAY_RESET_HOUR`(默认 8:00)切交易日** |
|
||||||
|
| 库内时间 | DB 存北京时间字符串;后端用 `utc_window_to_bj_sql_strings()` 换算后再 SQL 比较 |
|
||||||
|
| 切换方式 | 顶栏「列表筛选(UTC)」→ 选预设 → **应用**(保留当前路由,如 `/records?win_preset=…`) |
|
||||||
|
|
||||||
|
查询参数示例:
|
||||||
|
|
||||||
|
- `?win_preset=utc_today`
|
||||||
|
- `?win_preset=utc_last24h` / `utc_last7d`
|
||||||
|
- `?win_preset=custom&from_utc=2026-05-18 00:00:00&to_utc=2026-05-19 12:00:00`
|
||||||
|
|
||||||
|
## 交易记录与复盘
|
||||||
|
|
||||||
|
- 交易记录盈亏以**本地估算**为准(平仓时按成交/计划价计算);盈亏列可标注 **估**。
|
||||||
|
- 与币安 App 不一致时,请在「核对修改」或复盘中 **手工填写** `reviewed_pnl_amount` 覆盖展示(不再提供批量「同步交易所盈亏」)。
|
||||||
|
- **列表默认只显示当前 UTC 时间窗内**的记录(见上节);导出 CSV 同步该时间窗。
|
||||||
|
- 表头 **「止损(开仓)」**:展示开仓快照 `initial_stop_loss`(无则回退 `stop_loss`);核对/复盘仍可用有效止损字段。
|
||||||
|
- 平仓写入 `trade_records` 时:`stop_loss` 与 `initial_stop_loss` 均写入**开仓时止损快照**;`key_signal_type` 保留箱体/收敛/斐波来源(`fib_key_monitor_lib.key_signal_type_for_trade_record`)。
|
||||||
|
- **开仓类型**(`entry_reason`):机器单平仓入库时,若未手填,按 `key_signal_type` 自动映射(见下表);列表/导出「开仓类型」列 = 复盘核对值优先,否则入库值,否则按信号映射。
|
||||||
|
|
||||||
|
| `key_signal_type` | 自动写入的 `entry_reason` |
|
||||||
|
|-------------------|---------------------------|
|
||||||
|
| 箱体突破 | 关键位箱体突破 |
|
||||||
|
| 收敛突破 | 关键位收敛突破 |
|
||||||
|
| 斐波回调0.618 | 关键位斐波0.618 |
|
||||||
|
| 斐波回调0.786 | 关键位斐波0.786 |
|
||||||
|
|
||||||
|
- 复盘表单 **开仓类型** 下拉新增上述四条固定文案(与趋势/波段类并列)。
|
||||||
|
- 复盘 **离场触发** 新增 **「止盈」**;从交易记录「填入复盘」时,若结果为「止盈/保本止盈/移动止盈/止损/手动平仓」会自动选中对应触发项,并按 `key_signal_type` 预填开仓类型。
|
||||||
|
- 勾选「保存时自动生成多周期 K 线图」时:以 **平仓时间** 为锚点,各周期向前约 `ORDER_CHART_LIMIT`(默认 100)根 K 线(`_fetch_ohlcv_ending_at`),不再固定拉「最近 100 根」。
|
||||||
|
- `/api/journals`、`/api/reviews` 支持同一时间窗 query,与列表一致。
|
||||||
|
|
||||||
|
### 导出(交易记录 v3)
|
||||||
|
|
||||||
|
- 文件名:`trade_records_v3_YYYYMMDD.csv`
|
||||||
|
- 相对 v2 增加:`key_signal_type`、`initial_stop_loss`(及开仓快照列)、`planned_rr`、`actual_rr`、`risk_amount`、交易所盈亏与时间字段等;末列「开仓类型」为有效展示文案。
|
||||||
|
- 「关键位历史」导出同样受 UTC 时间窗限制。
|
||||||
|
|
||||||
|
## 实盘下单页
|
||||||
|
|
||||||
|
- 左列:实盘下单监控(表单、划转、规则)。
|
||||||
|
- 右列:实时持仓(独立模块)。
|
||||||
|
- **人工开仓门控**:计划盈亏比 < `MANUAL_MIN_PLANNED_RR`(默认 **1.4**)时前端弹窗 + 后端拒绝。
|
||||||
|
- **移动保本**(勾选启用):监控轮询达到触发 RR 后,止损阶梯上移时**同步交易所**——**先撤**该合约全部 TP/SL(含 Algo 条件单)**再挂**新止损 + 原止盈(`replace_active_monitor_tpsl_on_exchange`)。仅交易所成功后才写库;失败发企业微信告警。未配置实盘 API 时仍只更新本地。
|
||||||
|
|
||||||
|
## 统计分析页(`/stats`)
|
||||||
|
|
||||||
|
| 项 | 说明 |
|
||||||
|
|----|------|
|
||||||
|
| 切日 | **北京时间**;交易日边界 = 每日 `TRADING_DAY_RESET_HOUR:00`(`.env` 默认 **8**) |
|
||||||
|
| 品类下拉 | 页顶 **「统计品类」** 下拉切换(默认「全部交易」):全部交易、下单监控、关键位箱体突破、关键位收敛结构、关键位斐波0.618、关键位斐波0.786;一次只显示所选品类的日/周/月 |
|
||||||
|
| URL | 切换后写入 `stats_segment=`(如 `all`、`manual`、`key_box`、`key_conv`、`key_fib618`、`key_fib786`),刷新 `/stats` 可保持选项 |
|
||||||
|
| 每块指标 | 日 / 周 / 月:开单次数、平仓笔数、胜率、净盈亏、回撤、连续亏损等(与原口径一致) |
|
||||||
|
| 开单次数 | 人工块:`monitor_type=下单监控` 且无 `key_signal_type`;关键位块:按 `order_monitors.key_signal_type` 计数 |
|
||||||
|
| 不受 UTC 窗影响 | 统计始终基于库内全部已平仓记录,按北京交易日归类,**不**随顶栏 UTC 列表窗切换 |
|
||||||
|
|
||||||
|
## 持仓与计仓
|
||||||
|
|
||||||
|
- `MAX_ACTIVE_POSITIONS` 默认 **1**(可在 `.env` 调大)。
|
||||||
|
- 关键位自动开仓:在已有持仓时,若 `KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT=true`,按**首笔开仓前**交易账户资金快照计仓(字段 `trading_sessions.key_sizing_capital_snapshot`)。
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
详见 `.env.example` 中「关键位门控」「交易执行 / 人工风控」注释段。
|
||||||
|
|
||||||
|
## 自动备份(服务器)
|
||||||
|
|
||||||
|
- 脚本:`scripts/backup_data.sh`(`crypto.db` + `static/images`)
|
||||||
|
- 定时:`scripts/install_backup_cron.sh` → 每天 **北京时间 0:00**,目录 **`/root/backups/<实例名>/YYYY-MM-DD/`**,保留 **30** 天
|
||||||
|
- 详见 `部署文档.md` 第 5.3 节
|
||||||
|
|
||||||
|
## 数据库(启动时自动迁移)
|
||||||
|
|
||||||
|
`key_monitors` 斐波字段:`fib_limit_order_id`、`fib_entry_price`、`fib_stop_loss`、`fib_take_profit`、`fib_order_amount`、`fib_margin_capital`、`fib_leverage`。
|
||||||
|
|
||||||
|
`trade_records` / `order_monitors`:`key_signal_type`、`exchange_realized_pnl`、`exchange_opened_at`、`exchange_closed_at`、`exchange_sync_key`、`entry_reason`、`reviewed_entry_reason`、`initial_stop_loss`。
|
||||||
|
|
||||||
|
**历史数据**:本次**不做**旧记录的批量回填(`entry_reason` / `initial_stop_loss` / `key_signal_type` 等);仅**新产生**的平仓与复盘按新逻辑写入。旧行展示可回退已有字段。
|
||||||
|
|
||||||
|
## 涉及文件(便于排查)
|
||||||
|
|
||||||
|
| 路径 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `history_window_lib.py` | UTC 时间窗解析与转北京时间 SQL 字符串 |
|
||||||
|
| `fib_key_monitor_lib.py` | 斐波计算、`KEY_ENTRY_REASON_BY_SIGNAL`、`entry_reason_from_key_signal` |
|
||||||
|
| `crypto_monitor_binance/app.py` | 列表筛选、统计分块、导出 v3、复盘 K 线锚点、入库逻辑 |
|
||||||
|
| `crypto_monitor_binance/templates/index.html` | 顶栏时间窗、统计分块 UI、止损(开仓)列、复盘预填 |
|
||||||
|
|
||||||
|
## 升级步骤
|
||||||
|
|
||||||
|
1. `git pull` 后对比 `.env.example`,把新增变量合并进本地 `.env`。
|
||||||
|
2. 在 VPS 上为 Binance / Gate / Gate Bot **各执行一次** `bash scripts/install_backup_cron.sh`(若尚未安装)。
|
||||||
|
3. 重启 Binance 实例(如 `pm2 restart crypto_binance`);SQLite 会自动 `ALTER` 缺列(斐波、交易所盈亏、`entry_reason` 等)。
|
||||||
|
4. 浏览器强刷(Ctrl+F5)避免旧版 `index.html` 缓存。
|
||||||
|
5. 打开任意页确认顶栏出现 **「列表筛选(UTC)」**;`/stats` 可见分品类统计与「北京 8:00 切日」说明。
|
||||||
|
6. 建议先用测试币验证斐波:限价挂出、标记价失效撤单、成交后 TP/SL 与订单监控是否正常;平仓后检查交易记录止损(开仓)与开仓类型。
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
# `crypto_monitor_binance` 部署指南:SSH SOCKS + Binance + PM2(Ubuntu)
|
||||||
|
|
||||||
|
项目功能、环境变量总览与本地运行说明见 **[README.md](./README.md)**。
|
||||||
|
|
||||||
|
本文面向:**在本机或 VPS 上运行本项目**,但 **直连 Binance API 不稳定、超时或被网络策略拦截** 的场景。思路是:
|
||||||
|
|
||||||
|
- 本机用 `ssh -D` 做动态转发,把 **SOCKS5 出口**放到能稳定访问 Binance 的机器(常见为一台境外 VPS)
|
||||||
|
- 项目在 `.env` 中设置 **`BINANCE_SOCKS_PROXY=socks5h://127.0.0.1:1080`**(或你实际端口),`ccxt` 经 SOCKS 访问交易所
|
||||||
|
- **SSH 隧道**:用 `ssh -D` 在本机常驻即可(screen / tmux / systemd 等),**不必交给 PM2**
|
||||||
|
- 使用 **PM2** 仅托管 **Flask 应用**;仓库根目录 **`ecosystem.config.cjs`** 默认进程名为 **`crypto-monitor-binance`**
|
||||||
|
|
||||||
|
> 安全提醒:不要把 `.env`、私钥 `.pem`、Binance API Key / Secret 提交到 Git;下文只用占位符。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 你需要准备的东西
|
||||||
|
|
||||||
|
- 一台 **Ubuntu**(或同类 Linux)运行项目的机器(下文称「本机」)
|
||||||
|
- 一台可 SSH 登录、且 **能正常访问 Binance API** 的 VPS(示例:`HostName` 填你的服务器 IP,用户如 `root`)
|
||||||
|
- SSH:**私钥登录**(推荐,便于隧道脚本无人值守)
|
||||||
|
- 本机已安装:`python3`、`python3-venv`、`pip`、`curl`、`ssh`、`git`(可选)、`node` + `npm`(安装 PM2)
|
||||||
|
- Binance 账户:已开通 **USDT-M 永续合约**;API Key 勾选 **合约**、**万向划转**(若使用资金↔合约划转)等所需权限,并配置 **IP 白名单**(若启用)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 获取代码与目录
|
||||||
|
|
||||||
|
将包含 `app.py` 的项目放到固定目录,例如:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /opt/crypto_monitor
|
||||||
|
cd /opt/crypto_monitor
|
||||||
|
git clone https://git.bz121.com/dekun/crypto_monitor.git
|
||||||
|
cd crypto_monitor/crypto_monitor_binance
|
||||||
|
```
|
||||||
|
|
||||||
|
下文用 **`/opt/crypto_monitor/crypto_monitor_binance`** 仅为示例,请换成你的实际绝对路径。
|
||||||
|
|
||||||
|
拉取代码后,若目录下尚无 `.env`,先从模板生成(**勿**把填好密钥的 `.env` 提交 Git):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp -n .env.example .env # -n:已存在 .env 时不覆盖
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 配置 SSH 私钥与 `~/.ssh/config`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
chmod 700 ~/.ssh
|
||||||
|
# 私钥示例:~/.ssh/vps1.pem
|
||||||
|
chmod 600 ~/.ssh/vps1.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
编辑 `~/.ssh/config`(示例别名 **`bn-vps`**,与你手工启动 `ssh -D ... bn-vps` 一致即可):
|
||||||
|
|
||||||
|
```sshconfig
|
||||||
|
Host bn-vps
|
||||||
|
HostName 你的_VPS_IP
|
||||||
|
User root
|
||||||
|
IdentityFile ~/.ssh/vps1.pem
|
||||||
|
IdentitiesOnly yes
|
||||||
|
ServerAliveInterval 30
|
||||||
|
ServerAliveCountMax 3
|
||||||
|
ExitOnForwardFailure yes
|
||||||
|
BatchMode yes
|
||||||
|
```
|
||||||
|
|
||||||
|
测试:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh bn-vps true
|
||||||
|
```
|
||||||
|
|
||||||
|
> 若尚未完全改为密钥登录,可暂时注释 `BatchMode yes`,调试完成后再打开。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 手工验证:SSH SOCKS + Binance API
|
||||||
|
|
||||||
|
### 3.1 本地 SOCKS(示例端口 1080)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -N -D 127.0.0.1:1080 bn-vps
|
||||||
|
```
|
||||||
|
|
||||||
|
保持运行,另开终端继续。
|
||||||
|
|
||||||
|
### 3.2 验证经 SOCKS 可访问 Binance(公开接口)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -4 -sS --max-time 15 --proxy socks5h://127.0.0.1:1080 https://api.binance.com/api/v3/time
|
||||||
|
```
|
||||||
|
|
||||||
|
应返回 JSON(含 `serverTime` 字段)。若此处失败,**不要先启动应用**:先修隧道或 VPS 出站。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Python 虚拟环境
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/crypto_monitor/crypto_monitor_binance
|
||||||
|
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
python -m pip install -U pip
|
||||||
|
pip install flask requests ccxt werkzeug PySocks Pillow
|
||||||
|
```
|
||||||
|
|
||||||
|
走 SOCKS 时 **必须** 安装 **`PySocks`**,否则易出现代理相关报错。
|
||||||
|
|
||||||
|
可选:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PYTHONDONTWRITEBYTECODE=1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 配置环境变量(`.env.example` → `.env`)
|
||||||
|
|
||||||
|
| 文件 | 是否进 Git | 说明 |
|
||||||
|
|------|------------|------|
|
||||||
|
| **`.env.example`** | ✅ 是 | 变量模板与注释,可随 `git pull` 更新 |
|
||||||
|
| **`.env`** | ❌ 否 | 本机真实配置;`app.py` **只读此文件** |
|
||||||
|
|
||||||
|
### 5.1 首次配置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/crypto_monitor/crypto_monitor_binance
|
||||||
|
|
||||||
|
cp -n .env.example .env # 已存在 .env 时不覆盖
|
||||||
|
nano .env # 填入 API、登录密码、端口、代理等
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 备份与 `git pull`
|
||||||
|
|
||||||
|
- **`.env` 已被仓库根目录 `.gitignore` 忽略**:`git pull` **不会**覆盖或删除你本地的 `.env`。
|
||||||
|
- 若远端更新了 **`.env.example`**(新增变量名),pull 后请对照模板,**手动把新行补进你的 `.env`**(不会自动合并进 `.env`)。
|
||||||
|
- **建议在每次 `git pull` 或大批量改配置前备份**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env .env.backup.$(date +%Y%m%d)
|
||||||
|
# 恢复示例:cp .env.backup.20260516 .env
|
||||||
|
```
|
||||||
|
|
||||||
|
- **换机 / 迁移**:用 `scp` 复制整份 `.env` 到新机器对应目录;或在新机重新 `cp .env.example .env` 后填写。
|
||||||
|
|
||||||
|
### 5.3 自动备份(数据库 + 复盘图片)
|
||||||
|
|
||||||
|
默认每天 **北京时间 0:00** 备份到 **`/root/backups`**,保留 **30 天** 后自动删除更早的目录。
|
||||||
|
|
||||||
|
备份内容(路径来自 `.env` 的 `DB_PATH`、`UPLOAD_DIR`):
|
||||||
|
|
||||||
|
- `crypto.db`(优先 `sqlite3 .backup` 热备)
|
||||||
|
- `static/images` 打包为 `static_images.tar.gz`
|
||||||
|
|
||||||
|
目录结构示例:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/root/backups/crypto_monitor_binance/2026-05-17/
|
||||||
|
crypto.db
|
||||||
|
static_images.tar.gz
|
||||||
|
manifest.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**一次性安装定时任务**(在对应项目目录执行,Binance / Gate 各执行一次):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/crypto_monitor/crypto_monitor_binance
|
||||||
|
chmod +x scripts/backup_data.sh scripts/install_backup_cron.sh
|
||||||
|
bash scripts/install_backup_cron.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Gate 实例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/crypto_monitor/crypto_monitor_gate
|
||||||
|
bash scripts/install_backup_cron.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Gate Bot 实例(趋势回调等):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/crypto_monitor/crypto_monitor_gate_bot
|
||||||
|
bash scripts/install_backup_cron.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**立即试跑**(不写 cron):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/backup_data.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
日志默认:`/var/log/crypto-monitor-backup-<项目目录名>.log`。可选在 `.env` 中覆盖:`BACKUP_ROOT`、`BACKUP_RETENTION_DAYS`、`BACKUP_INSTANCE`。
|
||||||
|
|
||||||
|
**恢复示例**(先停 PM2,再覆盖文件):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 stop crypto-monitor-binance
|
||||||
|
cp /root/backups/crypto_monitor_binance/2026-05-16/crypto.db ./crypto.db
|
||||||
|
tar -xzf /root/backups/crypto_monitor_binance/2026-05-16/static_images.tar.gz -C .
|
||||||
|
pm2 start ecosystem.config.cjs
|
||||||
|
```
|
||||||
|
|
||||||
|
建议安装:`apt install -y sqlite3`(热备更稳)。
|
||||||
|
|
||||||
|
### 5.4 必填项检查(Binance + 代理)
|
||||||
|
|
||||||
|
与交易所相关的变量使用 **`BINANCE_`** 前缀(与代码一致)。至少确认:
|
||||||
|
|
||||||
|
```env
|
||||||
|
APP_HOST=127.0.0.1
|
||||||
|
APP_PORT=5000
|
||||||
|
|
||||||
|
# 实盘(按需)
|
||||||
|
LIVE_TRADING_ENABLED=false
|
||||||
|
BINANCE_API_KEY=你的_Key
|
||||||
|
BINANCE_API_SECRET=你的_Secret
|
||||||
|
|
||||||
|
# 保证金:cross=全仓 isolated=逐仓(与币安账户/习惯一致)
|
||||||
|
BINANCE_MARGIN_MODE=cross
|
||||||
|
|
||||||
|
# 持仓模式:hedge=双向(需在币安开启双向持仓);oneway=单向
|
||||||
|
BINANCE_POSITION_MODE=hedge
|
||||||
|
|
||||||
|
# 条件单触发参考:CONTRACT_PRICE=最新成交价 MARK_PRICE=标记价
|
||||||
|
BINANCE_TRIGGER_WORKING_TYPE=CONTRACT_PRICE
|
||||||
|
|
||||||
|
# 经本机 SSH 动态转发访问 Binance(端口与隧道一致)
|
||||||
|
BINANCE_SOCKS_PROXY=socks5h://127.0.0.1:1080
|
||||||
|
|
||||||
|
# 若不用 SOCKS,可改用 HTTP 代理(一般二选一)
|
||||||
|
# BINANCE_HTTP_PROXY=http://127.0.0.1:7890
|
||||||
|
# BINANCE_HTTPS_PROXY=http://127.0.0.1:7890
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:**推荐 `socks5h://`**,由 SOCKS 端解析域名,与 `curl --proxy socks5h://...` 行为一致。
|
||||||
|
|
||||||
|
**止盈止损说明(应用逻辑)**:实盘开仓后,程序会在 Binance USDT-M 永续上挂 **`STOP_MARKET`(止损)** 与 **`TAKE_PROFIT_MARKET`(止盈)**;`BINANCE_POSITION_MODE=hedge` 时会自动带 **`positionSide`**,须与币安合约「双向持仓」开关一致。不显式传 **`reduceOnly`**(否则易触发 API **`-1106`**:`Parameter 'reduceOnly' sent when not required`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 自检脚本(可选)
|
||||||
|
|
||||||
|
在已配置 `.env` 且网络可达的前提下:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/crypto_monitor/crypto_monitor_binance
|
||||||
|
source .venv/bin/activate
|
||||||
|
python scripts/verify_binance_funding.py
|
||||||
|
```
|
||||||
|
|
||||||
|
用于粗测资金钱包与合约钱包 USDT 读取(需有效 API 与权限)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 手工启动 Flask(验证)
|
||||||
|
|
||||||
|
1. SOCKS 已监听 `127.0.0.1:1080`(若使用代理)
|
||||||
|
2. 已 `source .venv/bin/activate`
|
||||||
|
3. `.env` 已按需配置 `BINANCE_SOCKS_PROXY` 等
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/crypto_monitor/crypto_monitor_binance
|
||||||
|
source .venv/bin/activate
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器访问:`http://127.0.0.1:5000`(或你在 `.env` 中的端口)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 安装 PM2
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo npm i -g pm2
|
||||||
|
pm2 -v
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. PM2:使用仓库内 `ecosystem.config.cjs`(推荐)
|
||||||
|
|
||||||
|
在项目根目录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/crypto_monitor/crypto_monitor_binance
|
||||||
|
pm2 start ecosystem.config.cjs
|
||||||
|
pm2 status
|
||||||
|
pm2 logs --lines 200
|
||||||
|
```
|
||||||
|
|
||||||
|
默认只启动 **`crypto-monitor-binance`**(`.venv/bin/python app.py`)。
|
||||||
|
|
||||||
|
### 本机已可直连 Binance、不需要隧道时
|
||||||
|
|
||||||
|
`.env` 里应 **去掉或留空** `BINANCE_SOCKS_PROXY`(除非仍要走别的代理),再 `pm2 start ecosystem.config.cjs`。
|
||||||
|
|
||||||
|
### 开机自启
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 save
|
||||||
|
pm2 startup
|
||||||
|
# 按屏幕提示执行一条 sudo 命令
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 等价手工命令(不使用 ecosystem 文件时)
|
||||||
|
|
||||||
|
### 10.1 SSH SOCKS(自行后台常驻,不推荐用 PM2)
|
||||||
|
|
||||||
|
示例(前台;实际可用 `screen`/`tmux`/`-f` 后台化或 systemd):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -N -D 127.0.0.1:1080 bn-vps \
|
||||||
|
-o ServerAliveInterval=30 -o ServerAliveCountMax=3 \
|
||||||
|
-o ExitOnForwardFailure=yes
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 Flask
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/crypto_monitor/crypto_monitor_binance
|
||||||
|
pm2 start /opt/crypto_monitor/crypto_monitor_binance/.venv/bin/python --name crypto-monitor-binance -- \
|
||||||
|
/opt/crypto_monitor/crypto_monitor_binance/app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 交易所「连接不上」排查清单
|
||||||
|
|
||||||
|
1. **`.env` 是否为 Binance 变量**:`BINANCE_SOCKS_PROXY` / `BINANCE_HTTP_PROXY` / `BINANCE_API_KEY` / `BINANCE_API_SECRET` 等前缀需与代码一致。
|
||||||
|
2. **隧道是否在本机端口监听**(若配置了 `BINANCE_SOCKS_PROXY`):
|
||||||
|
```bash
|
||||||
|
ss -lntp | grep 1080 || true
|
||||||
|
```
|
||||||
|
3. **curl 复测 Binance**(与第 3.2 节相同);curl 不通则应用也不会通。
|
||||||
|
4. **PySocks**:`pip show PySocks`,缺失则 `pip install PySocks`。
|
||||||
|
5. **SSH 隧道连不上**:检查私钥权限、`~/.ssh/config`、VPS 出站与端口是否与 `.env` 一致。
|
||||||
|
6. **API 权限与 IP 白名单**:Secret 错误、权限不足、未放行当前出口 IP 时,私有接口会失败。
|
||||||
|
7. **启动顺序**:若走代理,先保证 SOCKS 已监听,再 `pm2 start` 应用(或重启应用)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 推荐启动顺序(习惯)
|
||||||
|
|
||||||
|
1. 若走代理:先启动并确认 SSH SOCKS 已监听,再 `curl --proxy socks5h://127.0.0.1:1080 https://api.binance.com/api/v3/time` 成功
|
||||||
|
2. `pm2 start ecosystem.config.cjs`
|
||||||
|
3. 再确认页面与余额等接口正常
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 免责声明
|
||||||
|
|
||||||
|
交易所有合规与地区政策要求。请确保使用方式符合当地法律法规与交易所条款。本文仅描述网络与工程部署路径。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录:数据库标签修复脚本 `scripts/fix_breakeven_labels.py`
|
||||||
|
|
||||||
|
在 Ubuntu 上:
|
||||||
|
|
||||||
|
1)预览(不写库):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/fix_breakeven_labels.py --db ./crypto.db --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
2)确认后执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/fix_breakeven_labels.py --db ./crypto.db --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
默认修复条件:`monitor_type='下单监控'` 且 `result='止损'` 且 `pnl_amount > 0` → 改为 `result='保本止盈'`。
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 环境配置模板(可提交 Git)。程序运行时只读取同目录下的 .env。
|
||||||
|
#
|
||||||
|
# 首次部署 / 新机:
|
||||||
|
# cp .env.example .env
|
||||||
|
# nano .env # 填入真实密钥、端口、代理等
|
||||||
|
#
|
||||||
|
# 升级代码(git pull)前建议备份(.env 不在 Git 中,pull 不会覆盖):
|
||||||
|
# cp .env .env.backup.$(date +%Y%m%d)
|
||||||
|
#
|
||||||
|
# 从备份恢复:
|
||||||
|
# cp .env.backup.YYYYMMDD .env
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
APP_ENV=production
|
||||||
|
# 服务监听地址(云服务器通常用 0.0.0.0)
|
||||||
|
APP_HOST=0.0.0.0
|
||||||
|
# 服务端口
|
||||||
|
APP_PORT=5000
|
||||||
|
# 是否开启调试模式(生产建议 false)
|
||||||
|
APP_DEBUG=false
|
||||||
|
|
||||||
|
# 登录账号
|
||||||
|
APP_USERNAME=dekun
|
||||||
|
# 登录密码(请改成你自己的强密码)
|
||||||
|
APP_PASSWORD=ChangeMe123!
|
||||||
|
# 是否关闭登录校验(局域网可设 true;公网务必 false)
|
||||||
|
APP_AUTH_DISABLED=true
|
||||||
|
# Flask 会话密钥(必须替换为长随机字符串)
|
||||||
|
FLASK_SECRET_KEY=CHANGE_TO_LONG_RANDOM_SECRET
|
||||||
|
|
||||||
|
# ---------- 整机许可(也可写在仓库根目录 .env 的 LICENSE_* 变量)----------
|
||||||
|
LICENSE_API_URL=https://license.example.com
|
||||||
|
LICENSE_CLIENT_KEY=REPLACE_WITH_CLIENT_KEY
|
||||||
|
LICENSE_CHECK_INTERVAL_DAYS=3
|
||||||
|
LICENSE_OFFLINE_GRACE_DAYS=7
|
||||||
|
LICENSE_WECHAT_ID=dekun03
|
||||||
|
# LICENSE_DISABLED=false
|
||||||
|
|
||||||
|
# 企业微信机器人 Webhook(用于行情/风控推送)
|
||||||
|
WECHAT_WEBHOOK=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=REPLACE_WITH_REAL_KEY
|
||||||
|
|
||||||
|
# 数据库文件路径(相对路径会自动按项目目录解析)
|
||||||
|
DB_PATH=crypto.db
|
||||||
|
# 交易截图上传目录
|
||||||
|
UPLOAD_DIR=static/images
|
||||||
|
|
||||||
|
# 自动备份(scripts/backup_data.sh + cron,可选;默认即可)
|
||||||
|
# BACKUP_ROOT=/root/backups
|
||||||
|
# BACKUP_RETENTION_DAYS=30
|
||||||
|
# BACKUP_INSTANCE=crypto_monitor_gate
|
||||||
|
|
||||||
|
# 已废弃:资金账户仅显示交易所 funding 余额,不再读取此变量
|
||||||
|
# TOTAL_CAPITAL=100
|
||||||
|
# 每天起始基数(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
|
||||||
|
# 页面与浏览器标签展示的交易所名称(多环境区分时可改成例如 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实体幅度】占开盘价百分比区间
|
||||||
|
KEY_BREAKOUT_AMP_MIN_PCT=0.03
|
||||||
|
KEY_BREAKOUT_AMP_MAX_PCT=0.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
|
||||||
|
|
||||||
|
# 资金与仓位刷新周期(秒)
|
||||||
|
BALANCE_REFRESH_SECONDS=60
|
||||||
|
# 前端价格快照轮询(秒)
|
||||||
|
PRICE_REFRESH_SECONDS=5
|
||||||
|
# 后台监控轮询周期(秒)
|
||||||
|
MONITOR_POLL_SECONDS=3
|
||||||
|
# 使用可用资金时的缓冲比例(如0.98代表用98%)
|
||||||
|
FULL_MARGIN_BUFFER_RATIO=0.98
|
||||||
|
|
||||||
|
# 自动划转:将目标账户补足到 AUTO_TRANSFER_AMOUNT
|
||||||
|
AUTO_TRANSFER_ENABLED=false
|
||||||
|
AUTO_TRANSFER_AMOUNT=30
|
||||||
|
AUTO_TRANSFER_FROM=funding
|
||||||
|
AUTO_TRANSFER_TO=swap
|
||||||
|
TRANSFER_CCY=USDT
|
||||||
|
# 强制清仓整点(北京时间,默认 0=凌晨00点)
|
||||||
|
FORCE_CLOSE_BJ_HOUR=0
|
||||||
|
# 是否启用强制清仓(默认关闭,true 才会在整点执行)
|
||||||
|
FORCE_CLOSE_ENABLED=false
|
||||||
|
|
||||||
|
# 推送与AI超时(秒)
|
||||||
|
WECHAT_TIMEOUT_SECONDS=10
|
||||||
|
AI_TIMEOUT_SECONDS=120
|
||||||
|
|
||||||
|
# AI 复盘服务地址(本机 Ollama 默认地址)
|
||||||
|
OLLAMA_API=http://127.0.0.1:11434/api/generate
|
||||||
|
# AI 模型名称
|
||||||
|
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=5
|
||||||
|
# 以损定仓(按交易账户资金的百分比)
|
||||||
|
# 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
|
||||||
|
AUTO_TRANSFER_BJ_HOUR=8
|
||||||
|
# TRADING_DAY_RESET_HOUR 现在表示「北京时间」整点,默认 8 点起算新交易日;开仓整点限制见 TRADING_DAY_RESET_OPEN_GUARD_ENABLED
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# 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
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||||
|
pip install flask requests ccxt werkzeug PySocks Pillow
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置(`.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` 顶部默认值**。
|
||||||
|
|
||||||
|
## 本地运行
|
||||||
|
|
||||||
|
**Windows** 推荐使用 UTF-8 控制台脚本:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\start_utf8.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
或直接:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python .\app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
端口由 **`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** 后台一致,并遵守当地法律法规与交易所用户协议。
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* PM2 进程定义(Ubuntu / Linux)。
|
||||||
|
*
|
||||||
|
* 仅托管 Flask 应用。**SSH SOCKS 隧道请在本机用 screen/tmux/systemd 等方式单独常驻**,
|
||||||
|
* 与 `.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 PY = path.join(ROOT, ".venv", "bin", "python");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: "crypto_gate",
|
||||||
|
cwd: ROOT,
|
||||||
|
script: path.join(ROOT, "app.py"),
|
||||||
|
interpreter: PY,
|
||||||
|
instances: 1,
|
||||||
|
autorestart: true,
|
||||||
|
watch: false,
|
||||||
|
max_memory_restart: "800M",
|
||||||
|
// app.py 从项目根目录 .env 加载(由 .env.example 复制而来,勿提交 Git)
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
#!/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"
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
一次性修复历史交易记录标签:
|
||||||
|
将 trade_records 里“止损但实际盈利”的记录改为“保本止盈”。
|
||||||
|
|
||||||
|
默认条件(可通过参数修改):
|
||||||
|
- monitor_type = 下单监控
|
||||||
|
- result = 止损
|
||||||
|
- pnl_amount > 0
|
||||||
|
|
||||||
|
用法示例:
|
||||||
|
1) 仅预览(不落库):
|
||||||
|
python scripts/fix_breakeven_labels.py --db ./crypto.db --dry-run
|
||||||
|
|
||||||
|
2) 执行修复:
|
||||||
|
python scripts/fix_breakeven_labels.py --db ./crypto.db --apply
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Fix historical stop-loss records with positive pnl.")
|
||||||
|
parser.add_argument("--db", required=True, help="Path to sqlite db file, e.g. ./crypto.db")
|
||||||
|
parser.add_argument("--monitor-type", default="下单监控", help="Filter by monitor_type (default: 下单监控)")
|
||||||
|
parser.add_argument("--from-result", default="止损", help="Source result label (default: 止损)")
|
||||||
|
parser.add_argument("--to-result", default="保本止盈", help="Target result label (default: 保本止盈)")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Preview only, no write")
|
||||||
|
parser.add_argument("--apply", action="store_true", help="Execute update")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
db_path = Path(args.db).expanduser().resolve()
|
||||||
|
if not db_path.exists():
|
||||||
|
print(f"[ERR] DB not found: {db_path}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if args.dry_run and args.apply:
|
||||||
|
print("[ERR] --dry-run and --apply are mutually exclusive.")
|
||||||
|
return 1
|
||||||
|
if not args.dry_run and not args.apply:
|
||||||
|
print("[INFO] No mode provided, defaulting to --dry-run.")
|
||||||
|
args.dry_run = True
|
||||||
|
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
where_sql = """
|
||||||
|
monitor_type = ?
|
||||||
|
AND result = ?
|
||||||
|
AND CAST(COALESCE(pnl_amount, 0) AS REAL) > 0
|
||||||
|
"""
|
||||||
|
params = (args.monitor_type, args.from_result)
|
||||||
|
|
||||||
|
cur.execute(f"SELECT COUNT(*) AS c FROM trade_records WHERE {where_sql}", params)
|
||||||
|
will_change = int(cur.fetchone()["c"])
|
||||||
|
print(f"[INFO] Candidate rows: {will_change}")
|
||||||
|
|
||||||
|
if will_change == 0:
|
||||||
|
print("[INFO] Nothing to update.")
|
||||||
|
conn.close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT id, symbol, result, pnl_amount, closed_at
|
||||||
|
FROM trade_records
|
||||||
|
WHERE {where_sql}
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 10
|
||||||
|
""",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
sample = cur.fetchall()
|
||||||
|
print("[INFO] Sample (latest 10):")
|
||||||
|
for r in sample:
|
||||||
|
print(
|
||||||
|
f" id={r['id']} symbol={r['symbol']} result={r['result']} "
|
||||||
|
f"pnl={r['pnl_amount']} closed_at={r['closed_at']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print("[DRY-RUN] No write executed.")
|
||||||
|
conn.close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
f"UPDATE trade_records SET result=? WHERE {where_sql}",
|
||||||
|
(args.to_result, *params),
|
||||||
|
)
|
||||||
|
changed = int(cur.rowcount)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print(f"[DONE] Updated rows: {changed}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install daily backup cron: Beijing 00:00 (CRON_TZ=Asia/Shanghai).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
BACKUP_SCRIPT="$SCRIPT_DIR/backup_data.sh"
|
||||||
|
INSTANCE_NAME="${BACKUP_INSTANCE:-$(basename "$PROJECT_DIR")}"
|
||||||
|
LOG_FILE="${BACKUP_CRON_LOG:-/var/log/crypto-monitor-backup-${INSTANCE_NAME}.log}"
|
||||||
|
|
||||||
|
if [[ ! -x "$BACKUP_SCRIPT" ]]; then
|
||||||
|
chmod +x "$BACKUP_SCRIPT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
TMP="$(mktemp)"
|
||||||
|
trap 'rm -f "$TMP"' EXIT
|
||||||
|
|
||||||
|
{
|
||||||
|
crontab -l 2>/dev/null | grep -vF "$BACKUP_SCRIPT" || true
|
||||||
|
echo "CRON_TZ=Asia/Shanghai"
|
||||||
|
echo "0 0 * * * $BACKUP_SCRIPT >> $LOG_FILE 2>&1"
|
||||||
|
} >"$TMP"
|
||||||
|
|
||||||
|
awk '
|
||||||
|
BEGIN { tz = 0 }
|
||||||
|
/^CRON_TZ=Asia\/Shanghai$/ {
|
||||||
|
if (tz++) next
|
||||||
|
}
|
||||||
|
{ print }
|
||||||
|
' "$TMP" >"${TMP}.2"
|
||||||
|
mv "${TMP}.2" "$TMP"
|
||||||
|
|
||||||
|
crontab "$TMP"
|
||||||
|
echo "Installed cron for $INSTANCE_NAME"
|
||||||
|
echo " Schedule : daily 00:00 Asia/Shanghai"
|
||||||
|
echo " Script : $BACKUP_SCRIPT"
|
||||||
|
echo " Log : $LOG_FILE"
|
||||||
|
crontab -l | grep -F "$BACKUP_SCRIPT" || true
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""
|
||||||
|
在项目根目录执行(会加载根目录 .env):
|
||||||
|
python scripts/verify_gate_funding.py
|
||||||
|
|
||||||
|
依次探测:[0] swap 余额(与 App「交易账户」同源);[1]–[3] 现货 / 统一账户资金路径。
|
||||||
|
打印 GATE_API_KEY 前 8 位便于与 Gate 控制台核对(不含 Secret)。用于服务器自检。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
if ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, ROOT)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_app():
|
||||||
|
path = os.path.join(ROOT, "app.py")
|
||||||
|
spec = importlib.util.spec_from_file_location("crypto_app", path)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.chdir(ROOT)
|
||||||
|
mod = _load_app()
|
||||||
|
print("LIVE_TRADING_ENABLED =", os.getenv("LIVE_TRADING_ENABLED"))
|
||||||
|
ok, reason = mod.ensure_exchange_live_ready()
|
||||||
|
print("ensure_exchange_live_ready =", ok, repr(reason))
|
||||||
|
if not ok:
|
||||||
|
print("跳过私有接口探测")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
mod.ensure_markets_loaded()
|
||||||
|
|
||||||
|
k = (os.getenv("GATE_API_KEY") or "").strip()
|
||||||
|
s = (os.getenv("GATE_API_SECRET") or "").strip()
|
||||||
|
if not k or "REPLACE" in k.upper():
|
||||||
|
print("WARN: GATE_API_KEY 为空或仍像占位符,请核对 .env")
|
||||||
|
if not s or "REPLACE" in s.upper():
|
||||||
|
print("WARN: GATE_API_SECRET 为空或仍像占位符,请核对 .env")
|
||||||
|
print("GATE_API_KEY prefix (8 chars):", (k[:8] + "…") if len(k) > 8 else "(short)")
|
||||||
|
|
||||||
|
# 0) swap — 与 App「交易账户」余额同源(优先看此项是否与网页一致)
|
||||||
|
try:
|
||||||
|
bal = mod.exchange.fetch_balance({"type": "swap"})
|
||||||
|
v0 = mod._extract_usdt_total(bal)
|
||||||
|
print("[0] fetch_balance(swap) USDT total =", v0)
|
||||||
|
except Exception as e:
|
||||||
|
print("[0] fetch_balance(swap) FAILED:", type(e).__name__, e)
|
||||||
|
|
||||||
|
# 1) fetch_balance spot + marginMode spot
|
||||||
|
try:
|
||||||
|
bal = mod.exchange.fetch_balance({"type": "spot", "marginMode": "spot"})
|
||||||
|
v = mod._extract_usdt_total(bal)
|
||||||
|
print("[1] fetch_balance(spot,marginMode=spot) USDT total =", v)
|
||||||
|
except Exception as e:
|
||||||
|
print("[1] fetch_balance(spot) FAILED:", type(e).__name__, e)
|
||||||
|
|
||||||
|
# 2) raw spot accounts
|
||||||
|
try:
|
||||||
|
resp = mod.exchange.privateSpotGetAccounts({})
|
||||||
|
v2 = mod._parse_gate_spot_accounts_response_usdt(resp)
|
||||||
|
print("[2] privateSpotGetAccounts USDT =", v2)
|
||||||
|
except Exception as e:
|
||||||
|
print("[2] privateSpotGetAccounts FAILED:", type(e).__name__, e)
|
||||||
|
|
||||||
|
# 3) unified accounts raw
|
||||||
|
try:
|
||||||
|
raw = mod.exchange.privateUnifiedGetAccounts({})
|
||||||
|
body = raw
|
||||||
|
if isinstance(body, dict) and isinstance(body.get("result"), dict):
|
||||||
|
body = body["result"]
|
||||||
|
if isinstance(body, dict):
|
||||||
|
keys = sorted(body.keys())
|
||||||
|
print("[3] unified top-level keys (sample):", keys[:25], "..." if len(keys) > 25 else "")
|
||||||
|
v3 = mod._parse_usdt_from_gate_unified_accounts_body(body) if isinstance(body, dict) else None
|
||||||
|
print("[3] parsed unified USDT =", v3)
|
||||||
|
except Exception as e:
|
||||||
|
print("[3] privateUnifiedGetAccounts FAILED:", type(e).__name__, e)
|
||||||
|
|
||||||
|
fu = mod._fetch_gate_funding_usdt()
|
||||||
|
print(">>> _fetch_gate_funding_usdt() =", fu)
|
||||||
|
f, t = mod.get_exchange_capitals(force=True)
|
||||||
|
print(">>> get_exchange_capitals(force=True) funding, trading =", f, t)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
ok2
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>{{ exchange_display }} | 关键位放大</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}
|
||||||
|
input,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:580px;background:#0f1320;border:1px solid #2a3150;border-radius:10px;padding:8px}
|
||||||
|
#chart{width:100%;height:100%}
|
||||||
|
.exchange-tag{font-size:.72rem;font-weight:600;color:#b8f5d0;background:#14241e;border:1px solid #2d6a4f;padding:4px 10px;border-radius:999px;margin-left:8px}
|
||||||
|
</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">关键位放大(可输入币种)</strong><span class="exchange-tag">{{ exchange_display }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="status">最近刷新:<span id="updated-at">--</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row" style="margin-top:10px">
|
||||||
|
<label>币种</label>
|
||||||
|
<input id="symbol-input" value="{{ default_symbol }}" placeholder="BTC/USDT">
|
||||||
|
|
||||||
|
<label>关键位</label>
|
||||||
|
<select id="key-id">
|
||||||
|
<option value="">无(仅看K线)</option>
|
||||||
|
{% for k in key_list %}
|
||||||
|
<option value="{{ k.id }}" {% if selected_key and k.id == selected_key.id %}selected{% endif %}>#{{ k.id }} {{ k.symbol }} {{ k.monitor_type }} {{ '做多' if k.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>
|
||||||
|
|
||||||
|
<label>K线数</label>
|
||||||
|
<select id="kline-limit">
|
||||||
|
<option value="100" {% if default_kline_limit == 100 %}selected{% endif %}>100</option>
|
||||||
|
<option value="200" {% if default_kline_limit == 200 %}selected{% endif %}>200</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button id="manual-refresh" type="button">刷新</button>
|
||||||
|
<span id="load-status" class="status"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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-type">-</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-upper">-</div></div>
|
||||||
|
<div class="meta-item"><div class="k">下沿/支撑</div><div class="v" id="m-lower">-</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-updiff">-</div></div>
|
||||||
|
<div class="meta-item"><div class="k">距下沿</div><div class="v" id="m-lowdiff">-</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card"><div id="chart-wrap"><div id="chart"></div></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
|
||||||
|
<script>
|
||||||
|
const refreshMs = Math.max({{ price_refresh_seconds * 1000 }}, 5000);
|
||||||
|
const keySelect = document.getElementById("key-id");
|
||||||
|
const symbolInput = document.getElementById("symbol-input");
|
||||||
|
const tfSelect = document.getElementById("timeframe");
|
||||||
|
const limitSelect = document.getElementById("kline-limit");
|
||||||
|
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);
|
||||||
|
const fmtSigned = (v,d=4)=>{
|
||||||
|
if(v===null||typeof v==="undefined"||Number.isNaN(Number(v))) return "-";
|
||||||
|
const n = Number(v);
|
||||||
|
return `${n>0?"+":""}${n.toFixed(d)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
let chart = null;
|
||||||
|
let candleSeries = null;
|
||||||
|
let priceLines = [];
|
||||||
|
const keyMap = {};
|
||||||
|
{% for k in key_list %}
|
||||||
|
keyMap["{{ k.id }}"] = "{{ k.symbol }}";
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
function ensureChart(){
|
||||||
|
if(chart && candleSeries) return true;
|
||||||
|
if(!window.LightweightCharts){
|
||||||
|
statusEl.className = "status err";
|
||||||
|
statusEl.innerText = "图表库加载失败";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!chart){
|
||||||
|
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}
|
||||||
|
});
|
||||||
|
window.addEventListener("resize",()=>{
|
||||||
|
chart.applyOptions({width:chartHost.clientWidth,height:chartHost.clientHeight});
|
||||||
|
});
|
||||||
|
chart.applyOptions({width:chartHost.clientWidth,height:chartHost.clientHeight});
|
||||||
|
}
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
upColor: "#4cd97f",
|
||||||
|
downColor: "#ff6666",
|
||||||
|
borderVisible: false,
|
||||||
|
wickUpColor: "#4cd97f",
|
||||||
|
wickDownColor: "#ff6666"
|
||||||
|
};
|
||||||
|
if (typeof chart.addCandlestickSeries === "function") {
|
||||||
|
candleSeries = chart.addCandlestickSeries(opts);
|
||||||
|
} else if (typeof chart.addSeries === "function" && window.LightweightCharts && window.LightweightCharts.CandlestickSeries) {
|
||||||
|
candleSeries = chart.addSeries(window.LightweightCharts.CandlestickSeries, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!candleSeries){
|
||||||
|
statusEl.className = "status err";
|
||||||
|
statusEl.innerText = "K线序列初始化失败";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPriceLines(){
|
||||||
|
if(!candleSeries) return;
|
||||||
|
priceLines.forEach(line=>{ try { candleSeries.removePriceLine(line); } catch (_) {} });
|
||||||
|
priceLines = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLine(price, title, color){
|
||||||
|
if(!candleSeries || price===null || typeof price==="undefined") 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 paintMeta(data){
|
||||||
|
const key = data.key_monitor || null;
|
||||||
|
document.getElementById("m-symbol").innerText = data.symbol || "-";
|
||||||
|
document.getElementById("m-price").innerText = fmt(data.current_price,8);
|
||||||
|
|
||||||
|
if(!key){
|
||||||
|
document.getElementById("m-type").innerText = "未匹配到关键位";
|
||||||
|
document.getElementById("m-direction").innerText = "-";
|
||||||
|
document.getElementById("m-upper").innerText = "-";
|
||||||
|
document.getElementById("m-lower").innerText = "-";
|
||||||
|
document.getElementById("m-updiff").innerText = "-";
|
||||||
|
document.getElementById("m-lowdiff").innerText = "-";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("m-type").innerText = key.monitor_type || "-";
|
||||||
|
document.getElementById("m-direction").innerText = key.direction === "short" ? "做空" : "做多";
|
||||||
|
document.getElementById("m-upper").innerText = fmt(key.upper,8);
|
||||||
|
document.getElementById("m-lower").innerText = fmt(key.lower,8);
|
||||||
|
document.getElementById("m-updiff").innerText = `${fmtSigned(key.upper_diff,4)} (${fmtSigned(key.upper_pct,2)}%)`;
|
||||||
|
document.getElementById("m-lowdiff").innerText = `${fmtSigned(key.lower_diff,4)} (${fmtSigned(key.lower_pct,2)}%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSymbolByKey(){
|
||||||
|
const keyId = keySelect.value;
|
||||||
|
if(keyId && keyMap[keyId]) symbolInput.value = keyMap[keyId];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadKeyKline(){
|
||||||
|
if(!ensureChart()) return;
|
||||||
|
const keyId = keySelect.value;
|
||||||
|
const symbol = (symbolInput.value || "").trim().toUpperCase();
|
||||||
|
const timeframe = tfSelect.value;
|
||||||
|
const limit = limitSelect.value;
|
||||||
|
|
||||||
|
if(!symbol && !keyId){
|
||||||
|
statusEl.className = "status err";
|
||||||
|
statusEl.innerText = "请先输入币种或选择关键位";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusEl.className = "status";
|
||||||
|
statusEl.innerText = "加载中...";
|
||||||
|
|
||||||
|
try{
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if(keyId) qs.set("key_id", keyId);
|
||||||
|
if(symbol) qs.set("symbol", symbol);
|
||||||
|
qs.set("timeframe", timeframe);
|
||||||
|
qs.set("limit", limit);
|
||||||
|
|
||||||
|
const resp = await fetch(`/api/key_kline?${qs.toString()}`);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!candleSeries) throw new Error("Series init failed");
|
||||||
|
candleSeries.setData(candles);
|
||||||
|
resetPriceLines();
|
||||||
|
addLine(data.current_price, "现价", "#42a5f5");
|
||||||
|
if(data.key_monitor){
|
||||||
|
addLine(data.key_monitor.upper, "上沿/阻力", "#ffb84d");
|
||||||
|
addLine(data.key_monitor.lower, "下沿/支撑", "#4cd97f");
|
||||||
|
}
|
||||||
|
chart.timeScale().fitContent();
|
||||||
|
paintMeta(data);
|
||||||
|
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", loadKeyKline);
|
||||||
|
keySelect.addEventListener("change", ()=>{ syncSymbolByKey(); loadKeyKline(); });
|
||||||
|
symbolInput.addEventListener("change", ()=>{
|
||||||
|
if(symbolInput.value.trim()) keySelect.value = "";
|
||||||
|
loadKeyKline();
|
||||||
|
});
|
||||||
|
tfSelect.addEventListener("change", loadKeyKline);
|
||||||
|
limitSelect.addEventListener("change", loadKeyKline);
|
||||||
|
|
||||||
|
syncSymbolByKey();
|
||||||
|
loadKeyKline();
|
||||||
|
setInterval(loadKeyKline, refreshMs);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<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>
|
||||||
|
</head>
|
||||||
|
<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>
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
<!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>
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>{{ exchange_display }} | 实盘下单放大</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}
|
||||||
|
.exchange-tag{font-size:.72rem;font-weight:600;color:#b8f5d0;background:#14241e;border:1px solid #2d6a4f;padding:4px 10px;border-radius:999px;margin-left:8px}
|
||||||
|
</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><span class="exchange-tag">{{ exchange_display }}</span>
|
||||||
|
</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-breakeven">-</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-breakeven").innerText =
|
||||||
|
(order.breakeven_enabled === false || order.breakeven_enabled === 0) ? "关闭" : "开启";
|
||||||
|
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 %}
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
if (typeof ensureChart !== 'function') return;
|
||||||
|
const oldEnsureChart = ensureChart;
|
||||||
|
ensureChart = function(){
|
||||||
|
if (chart && candleSeries) return true;
|
||||||
|
try { const ok = oldEnsureChart(); if (ok && candleSeries) return true; } catch (_) {}
|
||||||
|
if (chart && !candleSeries && typeof chart.addSeries === 'function' && window.LightweightCharts && window.LightweightCharts.CandlestickSeries) {
|
||||||
|
const opts = { upColor:'#4cd97f', downColor:'#ff6666', borderVisible:false, wickUpColor:'#4cd97f', wickDownColor:'#ff6666' };
|
||||||
|
candleSeries = chart.addSeries(window.LightweightCharts.CandlestickSeries, opts);
|
||||||
|
return !!candleSeries;
|
||||||
|
}
|
||||||
|
return !!candleSeries;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
# 使用说明
|
||||||
|
|
||||||
|
**本文件对应仓库:`crypto_monitor_gate`(Gate.io USDT 永续)。**
|
||||||
|
功能、界面与 **Binance U 本位版**(目录 `crypto_monitor_binance`)基本一致,差异主要在 **`.env` 里交易所密钥与部分参数名**(`GATE_*` / `BINANCE_*`),文末有对照。
|
||||||
|
|
||||||
|
**更细的部署(SSH 代理、PM2、依赖安装)** 见同目录 **`部署文档.md`**。
|
||||||
|
**关键位自动开仓的规则、RR、结案原因** 见 **`关键位自动下单说明.md`**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 它能做什么
|
||||||
|
|
||||||
|
面向个人盘面的 **Web 控制台**,主要能力包括:
|
||||||
|
|
||||||
|
| 模块 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| **关键位监控** | 录入上/下沿与类型,按 **5m 收线** 做硬条件过滤;符合条件后 **企业微信** 提醒,部分类型可 **自动市价开仓**(见第 4 节与专门文档)。 |
|
||||||
|
| **实盘下单监控** | 手工填止损/止盈,**以损定仓** 市价开单,挂上条件止盈止损,并在页面跟踪浮盈亏、保本逻辑等。 |
|
||||||
|
| **交易记录 / 复盘** | 平仓结果、盈亏、错过的单等归档与导出。 |
|
||||||
|
|
||||||
|
后台按 **`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`)。 |
|
||||||
|
|
||||||
|
网络不稳定时可为 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`** 登录。
|
||||||
|
|
||||||
|
登录后顶栏为四页:**关键位监控** | **实盘下单**(默认首页)| **交易记录与复盘** | **统计分析**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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` 即可对照使用。
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# 关键位自动下单说明
|
||||||
|
|
||||||
|
**适用仓库:`crypto_monitor_gate`|交易所:Gate.io USDT 永续**(Binance 版见同名的 `crypto_monitor_binance` 目录。)
|
||||||
|
|
||||||
|
本文档与 `.env`、`app.check_key_monitors`、`app.add_key`、`_market_open_for_key_monitor` 的实现一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 结构与是否自动开仓
|
||||||
|
|
||||||
|
| `key_monitors.monitor_type`(录入类型) | 自动下单 | 触发后处置 |
|
||||||
|
|---------------------------------------|----------|------------|
|
||||||
|
| **箱体突破** | 是(满足全部条件) | **一次性结案**:写 `key_monitor_history` → 从 `key_monitors` **删除** |
|
||||||
|
| **收敛突破** | 是(同上) | 同上 |
|
||||||
|
| **关键阻力位** | 否 | 企业微信 **1 次** → `close_reason=key_level_alert_only` → **失效** |
|
||||||
|
| **关键支撑位** | 否 | 同上 |
|
||||||
|
|
||||||
|
触发条件:**5m 收线硬门控** `_key_hard_checks`(量能、突破幅度、第二根收盘确认、日成交量前 30 等)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 录入限制(`/add_key`)
|
||||||
|
|
||||||
|
- 存在 **`order_monitors.status='active'`** 时:**禁止添加** 「箱体突破」「收敛突破」。
|
||||||
|
- **关键阻力位 / 关键支撑位**:不受上条限制;触发后 **仅单次微信提醒**,然后结案。
|
||||||
|
- **4h EMA55 与所选方向逆势**:**不拦截**;添加成功后 **Flash** 提示。
|
||||||
|
- 上下沿入库前经 **`round_price_to_exchange`** 按合约 **价格精度** 取整。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 环境与参数(`.env`)
|
||||||
|
|
||||||
|
| 变量 | 含义 | 默认 |
|
||||||
|
|------|------|------|
|
||||||
|
| `KEY_AUTO_MIN_PLANNED_RR` | 计划 RR 阈值:**仅当严格大于该值** 才自动开仓(按下方 `E` 计算) | `1.5` |
|
||||||
|
| `KEY_STOP_OUTSIDE_BREAKOUT_PCT` | 止损:突破 K 极值向外 **百分比**(多:`低×(1−p/100)`;空:`高×(1+p/100)`) | `0.5` |
|
||||||
|
|
||||||
|
**其余与本仓库手动实盘一致:** `KLINE_TIMEFRAME`、`RISK_PERCENT`、`LIVE_TRADING_ENABLED`、`BREAKEVEN_*`、`DAILY_OPEN_ALERT_THRESHOLD`,以及 **`GATE_*`**(密钥、止盈止损触发、`GATE_TPSL_*` 等)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 计价与下单口径
|
||||||
|
|
||||||
|
| 用途 | 价格 |
|
||||||
|
|------|------|
|
||||||
|
| 企业微信展示、**与 RR 门槛比较的计划 RR** | 确认 K(第二根闭合 5m)收盘 **`E`** |
|
||||||
|
| **实际开仓** | **市价**(`place_exchange_order`,与 `/add_order` 一致);成交价可能与 `E` **滑点** |
|
||||||
|
| **以损定仓** | `calc_risk_fraction(direction, 当前市价, 止损)` + `RISK_PERCENT`(与 `/add_order` 一致) |
|
||||||
|
|
||||||
|
- 开仓成功后:`order_monitors.monitor_type` 为 **关键位监控**;持仓卡片「来源」显示之。手动开仓为 **下单监控**。
|
||||||
|
- 持仓列表中的 **盈亏比**:按 **实际成交价** 相对 SL/TP 重算,可与「按 `E` 算的计划 RR」略有偏差。
|
||||||
|
- **本仓库止盈止损挂单**:开仓后由 **`_gate_place_tp_sl_orders`** 挂载(仓位类 `price_orders` 或备选条件路径,逻辑与手动一致);细节受 `GATE_TPSL_USE_POSITION_ORDER`、`GATE_TPSL_PRICE_TYPE` 等影响。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 自动单止盈 / 止损(仅箱体突破、收敛突破)
|
||||||
|
|
||||||
|
设箱体高度 **`H = |upper − lower|`**(录入上下沿)。
|
||||||
|
|
||||||
|
| 方向 | 止损 SL | 止盈 TP |
|
||||||
|
|------|---------|---------|
|
||||||
|
| 多 | 突破 K **最低价** × (1 − `KEY_STOP_OUTSIDE_BREAKOUT_PCT`/100) | **`E + 1×H`** |
|
||||||
|
| 空 | 突破 K **最高价** × (1 + `KEY_STOP_OUTSIDE_BREAKOUT_PCT`/100) | **`E − 1×H`** |
|
||||||
|
|
||||||
|
计划 **`RR = calc_rr_ratio(direction, E, SL, TP)`**。若为 `None` 或 **RR ≤ `KEY_AUTO_MIN_PLANNED_RR`** → **不下单**,走 `rr_insufficient` 结案。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一次性结案(`close_reason`)
|
||||||
|
|
||||||
|
以下任一发生:**按需发微信** → **`key_monitor_history`** → **从 `key_monitors` 删除**;**不会对同一条关键位重复轮询重试开仓**。
|
||||||
|
|
||||||
|
| `close_reason` | 含义 |
|
||||||
|
|----------------|------|
|
||||||
|
| `rr_insufficient` | 门控通过,但计划 RR 未达标或 SL/TP / RR **几何无效** |
|
||||||
|
| `exchange_failed` | 计划 RR 达标,但未开实盘、`LIVE_TRADING_ENABLED=false`、风控、保证金或 **交易所报错** 等导致 **开仓失败** |
|
||||||
|
| `auto_opened` | 计划 RR 达标且 **市价开仓成功**(已写 `order_monitors`,并已挂止盈止损) |
|
||||||
|
| `key_level_alert_only` | 阻力/支撑位 **仅推送**结案 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 与企业微信推送
|
||||||
|
|
||||||
|
每种结案路径 **至多一条**主业务推送(RR 不足 / 下单失败 / 开仓成功 / 阻力支撑仅提醒)。
|
||||||
|
|
||||||
|
旧版「满 `KEY_ALERT_MAX_TIMES` 次再归档」对已触发结案的路径 **不再适用**;表中 `notification_count`、`max_notify` 等字段仍可能存在,以 **导出、兼容** 为主。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关代码位置(通用)
|
||||||
|
|
||||||
|
| 说明 | 符号 |
|
||||||
|
|------|------|
|
||||||
|
| 门控与主循环 | `check_key_monitors` |
|
||||||
|
| 录入、有仓拦截、4h Flash | `add_key` |
|
||||||
|
| 市价开仓 + 写 `order_monitors` | `_market_open_for_key_monitor` |
|
||||||
|
| 计划 RR | `calc_rr_ratio(direction, E, SL, TP)` |
|
||||||
|
| 价格精度 | `round_price_to_exchange` |
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# 界面与风控更新说明(Gate 实例)
|
||||||
|
|
||||||
|
## 顶栏导航(4 项)
|
||||||
|
|
||||||
|
| 顺序 | 名称 | 路由 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 1 | 关键位监控 | `/key_monitor` | 关键位添加、实时门控、历史 |
|
||||||
|
| 2 | 实盘下单 | `/trade` | 人工开仓、划转、实时持仓(**默认首页** `/` → `/trade`) |
|
||||||
|
| 3 | 交易记录与复盘 | `/records` | 交易记录、复盘表单、AI 历史(受顶栏 UTC 时间窗筛选) |
|
||||||
|
| 4 | 统计分析 | `/stats` | 按北京时间交易日切日 + 分品类统计块 |
|
||||||
|
|
||||||
|
## 关键位监控页
|
||||||
|
|
||||||
|
- 标题去掉「5m」;规则条从 `.env` 读取(周期、确认K、量能、自动开仓盈亏比、日成交量排名)。
|
||||||
|
- 左列:活跃关键位,**pos-card** 样式展示现价/距上沿/距下沿/门控。
|
||||||
|
- 右列:关键位历史(失效/结案),与左列等高滚动;**受顶栏 UTC 列表时间窗筛选**(默认 UTC 当日)。
|
||||||
|
- 监控类型新增:**斐波回调0.618**、**斐波回调0.786**(与 Binance 主站同一套规则,计算逻辑见仓库根目录 `fib_key_monitor_lib.py`)。
|
||||||
|
|
||||||
|
### 斐波关键位监控(方案 A:交易所限价)
|
||||||
|
|
||||||
|
| 项 | 说明 |
|
||||||
|
|----|------|
|
||||||
|
| 同币互斥 | 每个币种只能有一条斐波监控(0.618 与 0.786 不可并存) |
|
||||||
|
| 上下沿 | 上沿 **H**、下沿 **L**(须 H > L) |
|
||||||
|
| 挂单价 E | **做多** `E = H − ratio × (H − L)`(自 H 向下回撤);**做空** `E = L + ratio × (H − L)`(自 L 向上反弹) |
|
||||||
|
| 做多 | 限价 @ E,止损 L,止盈 H |
|
||||||
|
| 做空 | 限价 @ E,止损 H,止盈 L |
|
||||||
|
| 添加后 | **立即**在 Gate 挂限价单;卡片显示 **挂E**、限价单 ID |
|
||||||
|
| 失效 | 以**标记价**判断:做多且标记价 ≥ H、做空且标记价 ≤ L,且限价**未成交** → 撤销该限价单并结案(不写历史开仓) |
|
||||||
|
| 成交后 | 按仓位挂交易所 TP/SL → 写入 **实盘下单监控**(`monitor_type=关键位监控`,`key_signal_type=斐波回调0.618/0.786`)→ 从关键位列表移除 |
|
||||||
|
| 撤单 | 仅撤本条斐波的 `fib_limit_order_id`,**不会** `cancel_all`,避免误伤其他委托 |
|
||||||
|
| 盈亏比 | 计划 RR 须 > `KEY_AUTO_MIN_PLANNED_RR`(与箱体/收敛一致);0.618 理论约 1.6:1,0.786 约 3.7:1 |
|
||||||
|
| 日成交量 | 与箱体/收敛相同,须在前 `KEY_DAILY_VOLUME_RANK_MAX` 名内方可添加 |
|
||||||
|
|
||||||
|
后台轮询:`check_fib_key_monitors()`(标记价失效 / 成交检测);箱体/收敛仍走 `check_key_monitors()`,互不干扰。
|
||||||
|
|
||||||
|
手动删除关键位时,若斐波限价尚未成交,会先撤交易所限价再删库记录。
|
||||||
|
|
||||||
|
### 箱体 / 收敛自动开仓(来源标注)
|
||||||
|
|
||||||
|
- 自动开仓写入 `order_monitors.key_signal_type`:`箱体突破` 或 `收敛突破`。
|
||||||
|
- 持仓卡片、交易记录列表会显示「来源 · 信号类型」。
|
||||||
|
|
||||||
|
## 列表时间窗(UTC,全站顶栏)
|
||||||
|
|
||||||
|
共用模块:仓库根目录 `history_window_lib.py`(Gate / Binance 主站一致)。
|
||||||
|
|
||||||
|
| 项 | 说明 |
|
||||||
|
|----|------|
|
||||||
|
| 默认 | **UTC 当日**(`win_preset=utc_today`,从 UTC 0:00 至当前时刻) |
|
||||||
|
| 可选 | 近 24 小时、近 7 天、自定义起止(UTC,`datetime-local`) |
|
||||||
|
| 作用范围 | 关键位历史、交易记录列表、复盘记录 API、AI 历史 API、导出「交易记录」「关键位历史」 |
|
||||||
|
| 与统计的关系 | **仅影响列表/导出**;**统计分析页仍按北京时间 `TRADING_DAY_RESET_HOUR`(默认 8:00)切交易日** |
|
||||||
|
| 库内时间 | DB 存北京时间字符串;后端用 `utc_window_to_bj_sql_strings()` 换算后再 SQL 比较 |
|
||||||
|
| 切换方式 | 顶栏「列表筛选(UTC)」→ 选预设 → **应用**(保留当前路由,如 `/records?win_preset=…`) |
|
||||||
|
|
||||||
|
查询参数示例:
|
||||||
|
|
||||||
|
- `?win_preset=utc_today`
|
||||||
|
- `?win_preset=utc_last24h` / `utc_last7d`
|
||||||
|
- `?win_preset=custom&from_utc=2026-05-18 00:00:00&to_utc=2026-05-19 12:00:00`
|
||||||
|
|
||||||
|
## 交易记录与复盘
|
||||||
|
|
||||||
|
- 平仓记录可同步交易所已实现盈亏(Gate 仓位历史等);列表盈亏列优先显示交易所数据,标注 **所** / **估**。
|
||||||
|
- 记录页提供 **立即同步**(`POST /api/sync_exchange_pnl`),用于补全或刷新 `exchange_realized_pnl` 等字段。
|
||||||
|
- 未做人工复盘时,展示以交易所盈亏为准(有同步数据时)。
|
||||||
|
- **列表默认只显示当前 UTC 时间窗内**的记录(见上节);导出 CSV 同步该时间窗。
|
||||||
|
- 表头 **「止损(开仓)」**:展示开仓快照 `initial_stop_loss`(无则回退 `stop_loss`);核对/复盘仍可用有效止损字段。
|
||||||
|
- 平仓写入 `trade_records` 时:`stop_loss` 与 `initial_stop_loss` 均写入**开仓时止损快照**;`key_signal_type` 保留箱体/收敛/斐波来源(`fib_key_monitor_lib.key_signal_type_for_trade_record`)。
|
||||||
|
- **开仓类型**(`entry_reason`):机器单平仓入库时,若未手填,按 `key_signal_type` 自动映射(见下表);列表/导出「开仓类型」列 = 复盘核对值优先,否则入库值,否则按信号映射。
|
||||||
|
|
||||||
|
| `key_signal_type` | 自动写入的 `entry_reason` |
|
||||||
|
|-------------------|---------------------------|
|
||||||
|
| 箱体突破 | 关键位箱体突破 |
|
||||||
|
| 收敛突破 | 关键位收敛突破 |
|
||||||
|
| 斐波回调0.618 | 关键位斐波0.618 |
|
||||||
|
| 斐波回调0.786 | 关键位斐波0.786 |
|
||||||
|
|
||||||
|
- 复盘表单 **开仓类型** 下拉新增上述四条固定文案(与趋势/波段类并列)。
|
||||||
|
- 复盘 **离场触发** 新增 **「止盈」**;从交易记录「填入复盘」时,若结果为「止盈/保本止盈/移动止盈/止损/手动平仓」会自动选中对应触发项,并按 `key_signal_type` 预填开仓类型。
|
||||||
|
- 勾选「保存时自动生成多周期 K 线图」时:以 **平仓时间** 为锚点,各周期向前约 `ORDER_CHART_LIMIT`(默认 100)根 K 线(`_fetch_ohlcv_ending_at`),不再固定拉「最近 100 根」。
|
||||||
|
- `/api/journals`、`/api/reviews` 支持同一时间窗 query,与列表一致。
|
||||||
|
|
||||||
|
### 导出(交易记录 v3)
|
||||||
|
|
||||||
|
- 文件名:`trade_records_v3_YYYYMMDD.csv`
|
||||||
|
- 相对 v2 增加:`key_signal_type`、`initial_stop_loss`(及开仓快照列)、`planned_rr`、`actual_rr`、`risk_amount`、交易所盈亏与时间字段等;末列「开仓类型」为有效展示文案。
|
||||||
|
- 「关键位历史」导出同样受 UTC 时间窗限制。
|
||||||
|
|
||||||
|
## 实盘下单页
|
||||||
|
|
||||||
|
- 左列:实盘下单监控(表单、划转、规则)。
|
||||||
|
- 右列:实时持仓(独立模块)。
|
||||||
|
- **人工开仓门控**:计划盈亏比 < `MANUAL_MIN_PLANNED_RR`(默认 **1.4**)时前端弹窗 + 后端拒绝。
|
||||||
|
- **移动保本**(勾选启用):监控轮询达到触发 RR 后,止损阶梯上移时**同步交易所**——调用与页面「挂止盈止损」相同的 **先撤后挂**(`replace_active_monitor_tpsl_on_exchange`:撤该合约全部 TP/SL 条件单 → 按新止损 + 原止盈重挂)。仅交易所成功后才写库;失败发企业微信告警,本地止损不变。未配置实盘 API 时仍只更新本地(与旧行为一致)。
|
||||||
|
|
||||||
|
## 统计分析页(`/stats`)
|
||||||
|
|
||||||
|
| 项 | 说明 |
|
||||||
|
|----|------|
|
||||||
|
| 切日 | **北京时间**;交易日边界 = 每日 `TRADING_DAY_RESET_HOUR:00`(`.env` 默认 **8**) |
|
||||||
|
| 品类下拉 | 页顶 **「统计品类」** 下拉切换(默认「全部交易」):全部交易、下单监控、关键位箱体突破、关键位收敛结构、关键位斐波0.618、关键位斐波0.786;一次只显示所选品类的日/周/月 |
|
||||||
|
| URL | 切换后写入 `stats_segment=`(如 `all`、`manual`、`key_box`、`key_conv`、`key_fib618`、`key_fib786`),刷新 `/stats` 可保持选项 |
|
||||||
|
| 每块指标 | 日 / 周 / 月:开单次数、平仓笔数、胜率、净盈亏、回撤、连续亏损等(与原口径一致) |
|
||||||
|
| 开单次数 | 人工块:`monitor_type=下单监控` 且无 `key_signal_type`;关键位块:按 `order_monitors.key_signal_type` 计数 |
|
||||||
|
| 不受 UTC 窗影响 | 统计始终基于库内全部已平仓记录,按北京交易日归类,**不**随顶栏 UTC 列表窗切换 |
|
||||||
|
|
||||||
|
## 持仓与计仓
|
||||||
|
|
||||||
|
- `MAX_ACTIVE_POSITIONS` 默认 **1**(可在 `.env` 调大)。
|
||||||
|
- 关键位自动开仓:在已有持仓时,若 `KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT=true`,按**首笔开仓前**交易账户资金快照计仓(`trading_sessions.key_sizing_capital_snapshot`)。
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
详见 `.env.example` 中「关键位门控」「交易执行 / 人工风控」注释段。Gate 专用项(`GATE_*`、止盈止损触发等)保持原有段落不变。
|
||||||
|
|
||||||
|
## 自动备份(服务器)
|
||||||
|
|
||||||
|
- 脚本:`scripts/backup_data.sh`(`crypto.db` + `static/images`)
|
||||||
|
- 定时:`scripts/install_backup_cron.sh` → 每天 **北京时间 0:00**,目录 **`/root/backups/<实例名>/YYYY-MM-DD/`**,保留 **30** 天
|
||||||
|
- 详见 `部署文档.md` 第 5.3 节
|
||||||
|
|
||||||
|
## 数据库(启动时自动迁移)
|
||||||
|
|
||||||
|
`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 已挂上;平仓后交易记录止损(开仓)与开仓类型是否正确。
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
# `crypto_monitor_gate` 部署指南:SSH SOCKS + Gate.io + PM2(Ubuntu)
|
||||||
|
|
||||||
|
本文面向:**在本机运行本项目**,但 **直连 Gate.io API 不稳定或被重置** 的场景。思路是:
|
||||||
|
|
||||||
|
- 本机用 `ssh -D` 做动态转发,把 **SOCKS5 出口**放到能正常访问 Gate 的机器(常见为一台境外 VPS)
|
||||||
|
- 项目在 `.env` 中设置 **`GATE_SOCKS_PROXY=socks5h://127.0.0.1:1080`**(或你实际端口),`ccxt` 经 SOCKS 访问交易所
|
||||||
|
- **SSH 隧道**:用 `ssh -D` 在本机常驻即可(screen / tmux / systemd 等),**不必交给 PM2**
|
||||||
|
- 使用 **PM2** 仅托管 **Flask 应用**;仓库根目录 **`ecosystem.config.cjs`** 只定义 `crypto-monitor-gate`
|
||||||
|
|
||||||
|
> 安全提醒:不要把 `.env`、私钥 `.pem`、Gate API Key 提交到 Git;下文只用占位符。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 你需要准备的东西
|
||||||
|
|
||||||
|
- 一台 **Ubuntu**(或同类 Linux)运行项目的机器(下文称「本机」)
|
||||||
|
- 一台可 SSH 登录、且 **能正常访问 Gate.io API** 的 VPS(示例:`HostName` 填你的服务器 IP,用户如 `root`)
|
||||||
|
- SSH:**私钥登录**(推荐,便于隧道脚本无人值守)
|
||||||
|
- 本机已安装:`python3`、`python3-venv`、`pip`、`curl`、`ssh`、`git`(可选)、`node` + `npm`(安装 PM2)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 获取代码与目录
|
||||||
|
|
||||||
|
将包含 `app.py` 的项目放到固定目录,例如:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /opt/crypto_monitor
|
||||||
|
cd /opt/crypto_monitor
|
||||||
|
git clone https://git.bz121.com/dekun/crypto_monitor.git
|
||||||
|
cd crypto_monitor/crypto_monitor_gate
|
||||||
|
```
|
||||||
|
|
||||||
|
下文用 **`/opt/crypto_monitor/crypto_monitor_gate`** 仅为示例,请换成你的实际绝对路径。
|
||||||
|
|
||||||
|
拉取代码后,若目录下尚无 `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp -n .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 配置 SSH 私钥与 `~/.ssh/config`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
chmod 700 ~/.ssh
|
||||||
|
# 私钥示例:~/.ssh/vps1.pem
|
||||||
|
chmod 600 ~/.ssh/vps1.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
编辑 `~/.ssh/config`(示例别名 **`gate-vps`**,与你手工启动 `ssh -D ... gate-vps` 一致即可):
|
||||||
|
|
||||||
|
```sshconfig
|
||||||
|
Host gate-vps
|
||||||
|
HostName 你的_VPS_IP
|
||||||
|
User root
|
||||||
|
IdentityFile ~/.ssh/vps1.pem
|
||||||
|
IdentitiesOnly yes
|
||||||
|
ServerAliveInterval 30
|
||||||
|
ServerAliveCountMax 3
|
||||||
|
ExitOnForwardFailure yes
|
||||||
|
BatchMode yes
|
||||||
|
```
|
||||||
|
|
||||||
|
测试:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh gate-vps true
|
||||||
|
```
|
||||||
|
|
||||||
|
> 若尚未完全改为密钥登录,可暂时注释 `BatchMode yes`,调试完成后再打开。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 手工验证:SSH SOCKS + Gate API
|
||||||
|
|
||||||
|
### 3.1 本地 SOCKS(示例端口 1080)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -N -D 127.0.0.1:1080 gate-vps
|
||||||
|
```
|
||||||
|
|
||||||
|
保持运行,另开终端继续。
|
||||||
|
|
||||||
|
### 3.2 验证经 SOCKS 可访问 Gate
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -4 -sS --max-time 15 --proxy socks5h://127.0.0.1:1080 https://api.gateio.ws/api/v4/spot/time
|
||||||
|
```
|
||||||
|
|
||||||
|
应返回 JSON(含服务器时间字段)。若此处失败,**不要先启动应用**:先修隧道或 VPS 出站。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Python 虚拟环境
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/crypto_monitor/crypto_monitor_gate
|
||||||
|
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
python -m pip install -U pip
|
||||||
|
pip install flask requests ccxt werkzeug PySocks Pillow
|
||||||
|
```
|
||||||
|
|
||||||
|
走 SOCKS 时 **必须** 安装 **`PySocks`**,否则易出现代理相关报错。
|
||||||
|
|
||||||
|
可选:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PYTHONDONTWRITEBYTECODE=1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 配置环境变量(`.env.example` → `.env`)
|
||||||
|
|
||||||
|
| 文件 | 是否进 Git | 说明 |
|
||||||
|
|------|------------|------|
|
||||||
|
| **`.env.example`** | ✅ 是 | 变量模板与注释,可随 `git pull` 更新 |
|
||||||
|
| **`.env`** | ❌ 否 | 本机真实配置;`app.py` **只读此文件** |
|
||||||
|
|
||||||
|
### 5.1 首次配置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/crypto_monitor/crypto_monitor_gate
|
||||||
|
|
||||||
|
cp -n .env.example .env
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 备份与 `git pull`
|
||||||
|
|
||||||
|
- **`.env` 不在 Git 中**:`git pull` **不会**覆盖本地 `.env`。
|
||||||
|
- 远端若更新 **`.env.example`**,pull 后请**手动**把新增变量补进你的 `.env`。
|
||||||
|
- **升级前备份**:`cp .env .env.backup.$(date +%Y%m%d)`;恢复:`cp .env.backup.YYYYMMDD .env`。
|
||||||
|
- **换机**:`scp` 复制 `.env`,或新机 `cp .env.example .env` 后重填。
|
||||||
|
|
||||||
|
### 5.3 自动备份(数据库 + 复盘图片)
|
||||||
|
|
||||||
|
与 Binance 实例相同:每天 **北京时间 0:00** → **`/root/backups`**,保留 **30 天**。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/crypto_monitor/crypto_monitor_gate
|
||||||
|
chmod +x scripts/backup_data.sh scripts/install_backup_cron.sh
|
||||||
|
bash scripts/install_backup_cron.sh
|
||||||
|
bash scripts/backup_data.sh # 试跑
|
||||||
|
```
|
||||||
|
|
||||||
|
备份目录:`/root/backups/crypto_monitor_gate/YYYY-MM-DD/`。详见 Binance 项目 `部署文档.md` 第 5.3 节(恢复步骤、可选 `.env` 变量相同)。
|
||||||
|
|
||||||
|
若还部署了 **`crypto_monitor_gate_bot`**,请在该目录同样执行 `bash scripts/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://...` 行为一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 手工启动 Flask(验证)
|
||||||
|
|
||||||
|
1. SOCKS 已监听 `127.0.0.1:1080`
|
||||||
|
2. 已 `source .venv/bin/activate`
|
||||||
|
3. `.env` 已含 `GATE_SOCKS_PROXY`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/crypto_monitor/crypto_monitor_gate
|
||||||
|
source .venv/bin/activate
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器访问:`http://127.0.0.1:5000`(或你在 `.env` 中的端口)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 安装 PM2
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo npm i -g pm2
|
||||||
|
pm2 -v
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. PM2:使用仓库内 `ecosystem.config.cjs`(推荐)
|
||||||
|
|
||||||
|
在项目根目录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/crypto_monitor/crypto_monitor_gate
|
||||||
|
pm2 start ecosystem.config.cjs
|
||||||
|
pm2 status
|
||||||
|
pm2 logs --lines 200
|
||||||
|
```
|
||||||
|
|
||||||
|
默认只启动 **`crypto-monitor-gate`**(`.venv/bin/python app.py`)。
|
||||||
|
|
||||||
|
### 本机已可直连 Gate、不需要隧道时
|
||||||
|
|
||||||
|
`.env` 里应 **去掉或留空** `GATE_SOCKS_PROXY`(除非仍要走别的代理),再 `pm2 start ecosystem.config.cjs`。
|
||||||
|
|
||||||
|
### 开机自启
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 save
|
||||||
|
pm2 startup
|
||||||
|
# 按屏幕提示执行一条 sudo 命令
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 等价手工命令(不使用 ecosystem 文件时)
|
||||||
|
|
||||||
|
### 9.1 SSH SOCKS(自行后台常驻,不推荐用 PM2)
|
||||||
|
|
||||||
|
示例(前台;实际可用 `screen`/`tmux`/`-f` 后台化或 systemd):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -N -D 127.0.0.1:1080 gate-vps \
|
||||||
|
-o ServerAliveInterval=30 -o ServerAliveCountMax=3 \
|
||||||
|
-o ExitOnForwardFailure=yes
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 Flask
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/crypto_monitor/crypto_monitor_gate
|
||||||
|
pm2 start /opt/crypto_monitor/crypto_monitor_gate/.venv/bin/python --name crypto-monitor-gate -- \
|
||||||
|
/opt/crypto_monitor/crypto_monitor_gate/app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 交易所「连接不上」排查清单
|
||||||
|
|
||||||
|
1. **`.env` 是否为 Gate 变量**:必须是 `GATE_SOCKS_PROXY` / `GATE_API_KEY` / `GATE_API_SECRET`,不是 OKX。
|
||||||
|
2. **隧道是否在本机端口监听**(若配置了 `GATE_SOCKS_PROXY`):
|
||||||
|
```bash
|
||||||
|
ss -lntp | grep 1080 || true
|
||||||
|
```
|
||||||
|
3. **curl 复测 Gate**(与第 3.2 节相同);curl 不通则应用也不会通。
|
||||||
|
4. **PySocks**:`pip show PySocks`,缺失则 `pip install PySocks`。
|
||||||
|
5. **SSH 隧道连不上**:检查私钥权限、`~/.ssh/config`、VPS 出站与端口是否与 `.env` 一致。
|
||||||
|
6. **启动顺序**:先保证 SOCKS 已监听,再 `pm2 start` 应用(或重启应用)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 推荐启动顺序(习惯)
|
||||||
|
|
||||||
|
1. 若走代理:先启动并确认 SSH SOCKS 已监听,再 `curl --proxy socks5h://127.0.0.1:1080 https://api.gateio.ws/api/v4/spot/time` 成功
|
||||||
|
2. `pm2 start ecosystem.config.cjs`
|
||||||
|
3. 再确认页面与余额等接口正常
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 免责声明
|
||||||
|
|
||||||
|
交易所有合规与地区政策要求。请确保使用方式符合当地法律法规与交易所条款。本文仅描述网络与工程部署路径。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录:数据库标签修复脚本 `scripts/fix_breakeven_labels.py`
|
||||||
|
|
||||||
|
在 Ubuntu 上:
|
||||||
|
|
||||||
|
1)预览(不写库):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/fix_breakeven_labels.py --db ./crypto.db --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
2)确认后执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/fix_breakeven_labels.py --db ./crypto.db --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
默认修复条件:`monitor_type='下单监控'` 且 `result='止损'` 且 `pnl_amount > 0` → 改为 `result='保本止盈'`。
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 环境配置模板(可提交 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
|
||||||
|
# Flask 会话密钥(必须替换为长随机字符串)
|
||||||
|
FLASK_SECRET_KEY=CHANGE_TO_LONG_RANDOM_SECRET
|
||||||
|
|
||||||
|
# ---------- 整机许可(也可写在仓库根目录 .env 的 LICENSE_* 变量)----------
|
||||||
|
LICENSE_API_URL=https://license.example.com
|
||||||
|
LICENSE_CLIENT_KEY=REPLACE_WITH_CLIENT_KEY
|
||||||
|
LICENSE_CHECK_INTERVAL_DAYS=3
|
||||||
|
LICENSE_OFFLINE_GRACE_DAYS=7
|
||||||
|
LICENSE_WECHAT_ID=dekun03
|
||||||
|
# LICENSE_DISABLED=false
|
||||||
|
|
||||||
|
# 企业微信机器人 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
|
||||||
|
# 每天起始基数(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
|
||||||
|
# Gate 平仓历史:同步「趋势回调」交易记录与交易所已实现盈亏(北京日期 00:00 起,与 APP_TIMEZONE 一致);留空则从近 90 天拉取
|
||||||
|
# EXCHANGE_POSITION_SYNC_FROM_BJ=2026-05-14
|
||||||
|
# EXCHANGE_POSITION_HISTORY_LIMIT=200
|
||||||
|
|
||||||
|
# 是否开启 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
|
||||||
|
# 页面与浏览器标签展示的交易所名称(多环境区分时可改成例如 Gate·模拟)
|
||||||
|
# EXCHANGE_DISPLAY_NAME=Gate.io
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 交易执行 / 开仓限制(与 crypto_monitor_gate 主站一致)
|
||||||
|
# =============================================================================
|
||||||
|
# 【最大同时持仓】active 下单监控数达到该值后禁止再开仓(默认 1=单仓)
|
||||||
|
MAX_ACTIVE_POSITIONS=1
|
||||||
|
# 整点前禁止新开仓:true=启用(默认),false=关闭(交易日划分仍用 TRADING_DAY_RESET_HOUR)
|
||||||
|
# TRADING_DAY_RESET_OPEN_GUARD_ENABLED=true
|
||||||
|
|
||||||
|
# 关键位监控:5m收线突破过滤参数
|
||||||
|
KLINE_TIMEFRAME=5m
|
||||||
|
KEY_BREAKOUT_LIMIT_PCT=1.5
|
||||||
|
KEY_ALERT_MAX_TIMES=3
|
||||||
|
KEY_ALERT_INTERVAL_MINUTES=5
|
||||||
|
|
||||||
|
# 资金与仓位刷新周期(秒)
|
||||||
|
BALANCE_REFRESH_SECONDS=60
|
||||||
|
# 后台监控轮询周期(秒)
|
||||||
|
MONITOR_POLL_SECONDS=3
|
||||||
|
# 使用可用资金时的缓冲比例(如0.98代表用98%)
|
||||||
|
FULL_MARGIN_BUFFER_RATIO=0.98
|
||||||
|
|
||||||
|
# 自动划转:将目标账户补足到 AUTO_TRANSFER_AMOUNT
|
||||||
|
AUTO_TRANSFER_ENABLED=false
|
||||||
|
AUTO_TRANSFER_AMOUNT=30
|
||||||
|
AUTO_TRANSFER_FROM=funding
|
||||||
|
AUTO_TRANSFER_TO=swap
|
||||||
|
TRANSFER_CCY=USDT
|
||||||
|
# 强制清仓整点(北京时间,默认 0=凌晨00点)
|
||||||
|
FORCE_CLOSE_BJ_HOUR=0
|
||||||
|
# 是否启用强制清仓(默认关闭,true 才会在整点执行)
|
||||||
|
FORCE_CLOSE_ENABLED=false
|
||||||
|
|
||||||
|
# 推送与AI超时(秒)
|
||||||
|
WECHAT_TIMEOUT_SECONDS=10
|
||||||
|
AI_TIMEOUT_SECONDS=120
|
||||||
|
|
||||||
|
# AI 复盘服务地址(本机 Ollama 默认地址)
|
||||||
|
OLLAMA_API=http://127.0.0.1:11434/api/generate
|
||||||
|
# AI 模型名称
|
||||||
|
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=5
|
||||||
|
# 以损定仓(按交易账户资金的百分比)
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# 趋势回调策略(可选,见 趋势回调策略说明.md)
|
||||||
|
# TREND_PULLBACK_DCA_LEGS=5
|
||||||
|
# TREND_PULLBACK_PREVIEW_TTL_SECONDS=120
|
||||||
|
# 趋势回调手动保本:相对持仓均价的默认偏移(%);多=均价×(1+pct/100),空=均价×(1-pct/100)
|
||||||
|
# TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT=0.3
|
||||||
|
# TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT=5
|
||||||
|
|
||||||
|
APP_TIMEZONE=Asia/Shanghai
|
||||||
|
AUTO_TRANSFER_BJ_HOUR=8
|
||||||
|
# TRADING_DAY_RESET_HOUR 现在表示「北京时间」整点,默认 8 点起算新交易日;开仓整点限制见 TRADING_DAY_RESET_OPEN_GUARD_ENABLED
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* PM2 进程定义(Ubuntu / Linux)。
|
||||||
|
*
|
||||||
|
* 仅托管 Flask 应用。**SSH SOCKS 隧道请在本机用 screen/tmux/systemd 等方式单独常驻**,
|
||||||
|
* 与 `.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 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",
|
||||||
|
// app.py 从项目根目录 .env 加载(由 .env.example 复制而来,勿提交 Git)
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
#!/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"
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
一次性修复历史交易记录标签:
|
||||||
|
将 trade_records 里“止损但实际盈利”的记录改为“保本止盈”。
|
||||||
|
|
||||||
|
默认条件(可通过参数修改):
|
||||||
|
- monitor_type = 下单监控
|
||||||
|
- result = 止损
|
||||||
|
- pnl_amount > 0
|
||||||
|
|
||||||
|
用法示例:
|
||||||
|
1) 仅预览(不落库):
|
||||||
|
python scripts/fix_breakeven_labels.py --db ./crypto.db --dry-run
|
||||||
|
|
||||||
|
2) 执行修复:
|
||||||
|
python scripts/fix_breakeven_labels.py --db ./crypto.db --apply
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Fix historical stop-loss records with positive pnl.")
|
||||||
|
parser.add_argument("--db", required=True, help="Path to sqlite db file, e.g. ./crypto.db")
|
||||||
|
parser.add_argument("--monitor-type", default="下单监控", help="Filter by monitor_type (default: 下单监控)")
|
||||||
|
parser.add_argument("--from-result", default="止损", help="Source result label (default: 止损)")
|
||||||
|
parser.add_argument("--to-result", default="保本止盈", help="Target result label (default: 保本止盈)")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Preview only, no write")
|
||||||
|
parser.add_argument("--apply", action="store_true", help="Execute update")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
db_path = Path(args.db).expanduser().resolve()
|
||||||
|
if not db_path.exists():
|
||||||
|
print(f"[ERR] DB not found: {db_path}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if args.dry_run and args.apply:
|
||||||
|
print("[ERR] --dry-run and --apply are mutually exclusive.")
|
||||||
|
return 1
|
||||||
|
if not args.dry_run and not args.apply:
|
||||||
|
print("[INFO] No mode provided, defaulting to --dry-run.")
|
||||||
|
args.dry_run = True
|
||||||
|
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
where_sql = """
|
||||||
|
monitor_type = ?
|
||||||
|
AND result = ?
|
||||||
|
AND CAST(COALESCE(pnl_amount, 0) AS REAL) > 0
|
||||||
|
"""
|
||||||
|
params = (args.monitor_type, args.from_result)
|
||||||
|
|
||||||
|
cur.execute(f"SELECT COUNT(*) AS c FROM trade_records WHERE {where_sql}", params)
|
||||||
|
will_change = int(cur.fetchone()["c"])
|
||||||
|
print(f"[INFO] Candidate rows: {will_change}")
|
||||||
|
|
||||||
|
if will_change == 0:
|
||||||
|
print("[INFO] Nothing to update.")
|
||||||
|
conn.close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT id, symbol, result, pnl_amount, closed_at
|
||||||
|
FROM trade_records
|
||||||
|
WHERE {where_sql}
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 10
|
||||||
|
""",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
sample = cur.fetchall()
|
||||||
|
print("[INFO] Sample (latest 10):")
|
||||||
|
for r in sample:
|
||||||
|
print(
|
||||||
|
f" id={r['id']} symbol={r['symbol']} result={r['result']} "
|
||||||
|
f"pnl={r['pnl_amount']} closed_at={r['closed_at']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print("[DRY-RUN] No write executed.")
|
||||||
|
conn.close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
f"UPDATE trade_records SET result=? WHERE {where_sql}",
|
||||||
|
(args.to_result, *params),
|
||||||
|
)
|
||||||
|
changed = int(cur.rowcount)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print(f"[DONE] Updated rows: {changed}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install daily backup cron: Beijing 00:00 (CRON_TZ=Asia/Shanghai).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
BACKUP_SCRIPT="$SCRIPT_DIR/backup_data.sh"
|
||||||
|
INSTANCE_NAME="${BACKUP_INSTANCE:-$(basename "$PROJECT_DIR")}"
|
||||||
|
LOG_FILE="${BACKUP_CRON_LOG:-/var/log/crypto-monitor-backup-${INSTANCE_NAME}.log}"
|
||||||
|
|
||||||
|
if [[ ! -x "$BACKUP_SCRIPT" ]]; then
|
||||||
|
chmod +x "$BACKUP_SCRIPT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
TMP="$(mktemp)"
|
||||||
|
trap 'rm -f "$TMP"' EXIT
|
||||||
|
|
||||||
|
{
|
||||||
|
crontab -l 2>/dev/null | grep -vF "$BACKUP_SCRIPT" || true
|
||||||
|
echo "CRON_TZ=Asia/Shanghai"
|
||||||
|
echo "0 0 * * * $BACKUP_SCRIPT >> $LOG_FILE 2>&1"
|
||||||
|
} >"$TMP"
|
||||||
|
|
||||||
|
awk '
|
||||||
|
BEGIN { tz = 0 }
|
||||||
|
/^CRON_TZ=Asia\/Shanghai$/ {
|
||||||
|
if (tz++) next
|
||||||
|
}
|
||||||
|
{ print }
|
||||||
|
' "$TMP" >"${TMP}.2"
|
||||||
|
mv "${TMP}.2" "$TMP"
|
||||||
|
|
||||||
|
crontab "$TMP"
|
||||||
|
echo "Installed cron for $INSTANCE_NAME"
|
||||||
|
echo " Schedule : daily 00:00 Asia/Shanghai"
|
||||||
|
echo " Script : $BACKUP_SCRIPT"
|
||||||
|
echo " Log : $LOG_FILE"
|
||||||
|
crontab -l | grep -F "$BACKUP_SCRIPT" || true
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""
|
||||||
|
在项目根目录执行(会加载根目录 .env):
|
||||||
|
python scripts/verify_gate_funding.py
|
||||||
|
|
||||||
|
依次探测:[0] swap 余额(与 App「交易账户」同源);[1]–[3] 现货 / 统一账户资金路径。
|
||||||
|
打印 GATE_API_KEY 前 8 位便于与 Gate 控制台核对(不含 Secret)。用于服务器自检。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
if ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, ROOT)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_app():
|
||||||
|
path = os.path.join(ROOT, "app.py")
|
||||||
|
spec = importlib.util.spec_from_file_location("crypto_app", path)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.chdir(ROOT)
|
||||||
|
mod = _load_app()
|
||||||
|
print("LIVE_TRADING_ENABLED =", os.getenv("LIVE_TRADING_ENABLED"))
|
||||||
|
ok, reason = mod.ensure_exchange_live_ready()
|
||||||
|
print("ensure_exchange_live_ready =", ok, repr(reason))
|
||||||
|
if not ok:
|
||||||
|
print("跳过私有接口探测")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
mod.ensure_markets_loaded()
|
||||||
|
|
||||||
|
k = (os.getenv("GATE_API_KEY") or "").strip()
|
||||||
|
s = (os.getenv("GATE_API_SECRET") or "").strip()
|
||||||
|
if not k or "REPLACE" in k.upper():
|
||||||
|
print("WARN: GATE_API_KEY 为空或仍像占位符,请核对 .env")
|
||||||
|
if not s or "REPLACE" in s.upper():
|
||||||
|
print("WARN: GATE_API_SECRET 为空或仍像占位符,请核对 .env")
|
||||||
|
print("GATE_API_KEY prefix (8 chars):", (k[:8] + "…") if len(k) > 8 else "(short)")
|
||||||
|
|
||||||
|
# 0) swap — 与 App「交易账户」余额同源(优先看此项是否与网页一致)
|
||||||
|
try:
|
||||||
|
bal = mod.exchange.fetch_balance({"type": "swap"})
|
||||||
|
v0 = mod._extract_usdt_total(bal)
|
||||||
|
print("[0] fetch_balance(swap) USDT total =", v0)
|
||||||
|
except Exception as e:
|
||||||
|
print("[0] fetch_balance(swap) FAILED:", type(e).__name__, e)
|
||||||
|
|
||||||
|
# 1) fetch_balance spot + marginMode spot
|
||||||
|
try:
|
||||||
|
bal = mod.exchange.fetch_balance({"type": "spot", "marginMode": "spot"})
|
||||||
|
v = mod._extract_usdt_total(bal)
|
||||||
|
print("[1] fetch_balance(spot,marginMode=spot) USDT total =", v)
|
||||||
|
except Exception as e:
|
||||||
|
print("[1] fetch_balance(spot) FAILED:", type(e).__name__, e)
|
||||||
|
|
||||||
|
# 2) raw spot accounts
|
||||||
|
try:
|
||||||
|
resp = mod.exchange.privateSpotGetAccounts({})
|
||||||
|
v2 = mod._parse_gate_spot_accounts_response_usdt(resp)
|
||||||
|
print("[2] privateSpotGetAccounts USDT =", v2)
|
||||||
|
except Exception as e:
|
||||||
|
print("[2] privateSpotGetAccounts FAILED:", type(e).__name__, e)
|
||||||
|
|
||||||
|
# 3) unified accounts raw
|
||||||
|
try:
|
||||||
|
raw = mod.exchange.privateUnifiedGetAccounts({})
|
||||||
|
body = raw
|
||||||
|
if isinstance(body, dict) and isinstance(body.get("result"), dict):
|
||||||
|
body = body["result"]
|
||||||
|
if isinstance(body, dict):
|
||||||
|
keys = sorted(body.keys())
|
||||||
|
print("[3] unified top-level keys (sample):", keys[:25], "..." if len(keys) > 25 else "")
|
||||||
|
v3 = mod._parse_usdt_from_gate_unified_accounts_body(body) if isinstance(body, dict) else None
|
||||||
|
print("[3] parsed unified USDT =", v3)
|
||||||
|
except Exception as e:
|
||||||
|
print("[3] privateUnifiedGetAccounts FAILED:", type(e).__name__, e)
|
||||||
|
|
||||||
|
fu = mod._fetch_gate_funding_usdt()
|
||||||
|
print(">>> _fetch_gate_funding_usdt() =", fu)
|
||||||
|
f, t = mod.get_exchange_capitals(force=True)
|
||||||
|
print(">>> get_exchange_capitals(force=True) funding, trading =", f, t)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
ok2
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>{{ exchange_display }} | 关键位放大</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}
|
||||||
|
input,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:580px;background:#0f1320;border:1px solid #2a3150;border-radius:10px;padding:8px}
|
||||||
|
#chart{width:100%;height:100%}
|
||||||
|
.exchange-tag{font-size:.72rem;font-weight:600;color:#b8f5d0;background:#14241e;border:1px solid #2d6a4f;padding:4px 10px;border-radius:999px;margin-left:8px}
|
||||||
|
</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">关键位放大(可输入币种)</strong><span class="exchange-tag">{{ exchange_display }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="status">最近刷新:<span id="updated-at">--</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row" style="margin-top:10px">
|
||||||
|
<label>币种</label>
|
||||||
|
<input id="symbol-input" value="{{ default_symbol }}" placeholder="BTC/USDT">
|
||||||
|
|
||||||
|
<label>关键位</label>
|
||||||
|
<select id="key-id">
|
||||||
|
<option value="">无(仅看K线)</option>
|
||||||
|
{% for k in key_list %}
|
||||||
|
<option value="{{ k.id }}" {% if selected_key and k.id == selected_key.id %}selected{% endif %}>#{{ k.id }} {{ k.symbol }} {{ k.monitor_type }} {{ '做多' if k.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>
|
||||||
|
|
||||||
|
<label>K线数</label>
|
||||||
|
<select id="kline-limit">
|
||||||
|
<option value="100" {% if default_kline_limit == 100 %}selected{% endif %}>100</option>
|
||||||
|
<option value="200" {% if default_kline_limit == 200 %}selected{% endif %}>200</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button id="manual-refresh" type="button">刷新</button>
|
||||||
|
<span id="load-status" class="status"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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-type">-</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-upper">-</div></div>
|
||||||
|
<div class="meta-item"><div class="k">下沿/支撑</div><div class="v" id="m-lower">-</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-updiff">-</div></div>
|
||||||
|
<div class="meta-item"><div class="k">距下沿</div><div class="v" id="m-lowdiff">-</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card"><div id="chart-wrap"><div id="chart"></div></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
|
||||||
|
<script>
|
||||||
|
const refreshMs = Math.max({{ price_refresh_seconds * 1000 }}, 5000);
|
||||||
|
const keySelect = document.getElementById("key-id");
|
||||||
|
const symbolInput = document.getElementById("symbol-input");
|
||||||
|
const tfSelect = document.getElementById("timeframe");
|
||||||
|
const limitSelect = document.getElementById("kline-limit");
|
||||||
|
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);
|
||||||
|
const fmtSigned = (v,d=4)=>{
|
||||||
|
if(v===null||typeof v==="undefined"||Number.isNaN(Number(v))) return "-";
|
||||||
|
const n = Number(v);
|
||||||
|
return `${n>0?"+":""}${n.toFixed(d)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
let chart = null;
|
||||||
|
let candleSeries = null;
|
||||||
|
let priceLines = [];
|
||||||
|
const keyMap = {};
|
||||||
|
{% for k in key_list %}
|
||||||
|
keyMap["{{ k.id }}"] = "{{ k.symbol }}";
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
function ensureChart(){
|
||||||
|
if(chart && candleSeries) return true;
|
||||||
|
if(!window.LightweightCharts){
|
||||||
|
statusEl.className = "status err";
|
||||||
|
statusEl.innerText = "图表库加载失败";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!chart){
|
||||||
|
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}
|
||||||
|
});
|
||||||
|
window.addEventListener("resize",()=>{
|
||||||
|
chart.applyOptions({width:chartHost.clientWidth,height:chartHost.clientHeight});
|
||||||
|
});
|
||||||
|
chart.applyOptions({width:chartHost.clientWidth,height:chartHost.clientHeight});
|
||||||
|
}
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
upColor: "#4cd97f",
|
||||||
|
downColor: "#ff6666",
|
||||||
|
borderVisible: false,
|
||||||
|
wickUpColor: "#4cd97f",
|
||||||
|
wickDownColor: "#ff6666"
|
||||||
|
};
|
||||||
|
if (typeof chart.addCandlestickSeries === "function") {
|
||||||
|
candleSeries = chart.addCandlestickSeries(opts);
|
||||||
|
} else if (typeof chart.addSeries === "function" && window.LightweightCharts && window.LightweightCharts.CandlestickSeries) {
|
||||||
|
candleSeries = chart.addSeries(window.LightweightCharts.CandlestickSeries, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!candleSeries){
|
||||||
|
statusEl.className = "status err";
|
||||||
|
statusEl.innerText = "K线序列初始化失败";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPriceLines(){
|
||||||
|
if(!candleSeries) return;
|
||||||
|
priceLines.forEach(line=>{ try { candleSeries.removePriceLine(line); } catch (_) {} });
|
||||||
|
priceLines = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLine(price, title, color){
|
||||||
|
if(!candleSeries || price===null || typeof price==="undefined") 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 paintMeta(data){
|
||||||
|
const key = data.key_monitor || null;
|
||||||
|
document.getElementById("m-symbol").innerText = data.symbol || "-";
|
||||||
|
document.getElementById("m-price").innerText = fmt(data.current_price,8);
|
||||||
|
|
||||||
|
if(!key){
|
||||||
|
document.getElementById("m-type").innerText = "未匹配到关键位";
|
||||||
|
document.getElementById("m-direction").innerText = "-";
|
||||||
|
document.getElementById("m-upper").innerText = "-";
|
||||||
|
document.getElementById("m-lower").innerText = "-";
|
||||||
|
document.getElementById("m-updiff").innerText = "-";
|
||||||
|
document.getElementById("m-lowdiff").innerText = "-";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("m-type").innerText = key.monitor_type || "-";
|
||||||
|
document.getElementById("m-direction").innerText = key.direction === "short" ? "做空" : "做多";
|
||||||
|
document.getElementById("m-upper").innerText = fmt(key.upper,8);
|
||||||
|
document.getElementById("m-lower").innerText = fmt(key.lower,8);
|
||||||
|
document.getElementById("m-updiff").innerText = `${fmtSigned(key.upper_diff,4)} (${fmtSigned(key.upper_pct,2)}%)`;
|
||||||
|
document.getElementById("m-lowdiff").innerText = `${fmtSigned(key.lower_diff,4)} (${fmtSigned(key.lower_pct,2)}%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSymbolByKey(){
|
||||||
|
const keyId = keySelect.value;
|
||||||
|
if(keyId && keyMap[keyId]) symbolInput.value = keyMap[keyId];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadKeyKline(){
|
||||||
|
if(!ensureChart()) return;
|
||||||
|
const keyId = keySelect.value;
|
||||||
|
const symbol = (symbolInput.value || "").trim().toUpperCase();
|
||||||
|
const timeframe = tfSelect.value;
|
||||||
|
const limit = limitSelect.value;
|
||||||
|
|
||||||
|
if(!symbol && !keyId){
|
||||||
|
statusEl.className = "status err";
|
||||||
|
statusEl.innerText = "请先输入币种或选择关键位";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusEl.className = "status";
|
||||||
|
statusEl.innerText = "加载中...";
|
||||||
|
|
||||||
|
try{
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if(keyId) qs.set("key_id", keyId);
|
||||||
|
if(symbol) qs.set("symbol", symbol);
|
||||||
|
qs.set("timeframe", timeframe);
|
||||||
|
qs.set("limit", limit);
|
||||||
|
|
||||||
|
const resp = await fetch(`/api/key_kline?${qs.toString()}`);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!candleSeries) throw new Error("Series init failed");
|
||||||
|
candleSeries.setData(candles);
|
||||||
|
resetPriceLines();
|
||||||
|
addLine(data.current_price, "现价", "#42a5f5");
|
||||||
|
if(data.key_monitor){
|
||||||
|
addLine(data.key_monitor.upper, "上沿/阻力", "#ffb84d");
|
||||||
|
addLine(data.key_monitor.lower, "下沿/支撑", "#4cd97f");
|
||||||
|
}
|
||||||
|
chart.timeScale().fitContent();
|
||||||
|
paintMeta(data);
|
||||||
|
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", loadKeyKline);
|
||||||
|
keySelect.addEventListener("change", ()=>{ syncSymbolByKey(); loadKeyKline(); });
|
||||||
|
symbolInput.addEventListener("change", ()=>{
|
||||||
|
if(symbolInput.value.trim()) keySelect.value = "";
|
||||||
|
loadKeyKline();
|
||||||
|
});
|
||||||
|
tfSelect.addEventListener("change", loadKeyKline);
|
||||||
|
limitSelect.addEventListener("change", loadKeyKline);
|
||||||
|
|
||||||
|
syncSymbolByKey();
|
||||||
|
loadKeyKline();
|
||||||
|
setInterval(loadKeyKline, refreshMs);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<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>
|
||||||
|
</head>
|
||||||
|
<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>
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
<!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 = fmt(order.trigger_price, 8);
|
||||||
|
document.getElementById("m-sl").innerText = fmt(order.stop_loss, 8);
|
||||||
|
document.getElementById("m-tp").innerText = 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 = fmt(order.current_price, 8);
|
||||||
|
const pnlEl = document.getElementById("m-pnl");
|
||||||
|
pnlEl.innerText = `${fmt(order.float_pnl, 4)}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>
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>{{ exchange_display }} | 实盘下单放大</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}
|
||||||
|
.exchange-tag{font-size:.72rem;font-weight:600;color:#b8f5d0;background:#14241e;border:1px solid #2d6a4f;padding:4px 10px;border-radius:999px;margin-left:8px}
|
||||||
|
</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><span class="exchange-tag">{{ exchange_display }}</span>
|
||||||
|
</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-breakeven">-</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 = fmt(order.trigger_price, 8);
|
||||||
|
document.getElementById("m-sl").innerText = fmt(order.stop_loss, 8);
|
||||||
|
document.getElementById("m-tp").innerText = 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-breakeven").innerText =
|
||||||
|
(order.breakeven_enabled === false || order.breakeven_enabled === 0) ? "关闭" : "开启";
|
||||||
|
document.getElementById("m-price").innerText = fmt(order.current_price, 8);
|
||||||
|
const pnlEl = document.getElementById("m-pnl");
|
||||||
|
pnlEl.innerText = `${fmt(order.float_pnl, 4)}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 %}
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
if (typeof ensureChart !== 'function') return;
|
||||||
|
const oldEnsureChart = ensureChart;
|
||||||
|
ensureChart = function(){
|
||||||
|
if (chart && candleSeries) return true;
|
||||||
|
try { const ok = oldEnsureChart(); if (ok && candleSeries) return true; } catch (_) {}
|
||||||
|
if (chart && !candleSeries && typeof chart.addSeries === 'function' && window.LightweightCharts && window.LightweightCharts.CandlestickSeries) {
|
||||||
|
const opts = { upColor:'#4cd97f', downColor:'#ff6666', borderVisible:false, wickUpColor:'#4cd97f', wickDownColor:'#ff6666' };
|
||||||
|
candleSeries = chart.addSeries(window.LightweightCharts.CandlestickSeries, opts);
|
||||||
|
return !!candleSeries;
|
||||||
|
}
|
||||||
|
return !!candleSeries;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# 趋势回调策略(机器人)说明
|
||||||
|
|
||||||
|
本文描述本仓库内 **「趋势回调」** 自动交易计划的业务规则与实现口径,便于单独策略账户使用与审计。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 适用场景
|
||||||
|
|
||||||
|
- 单独用于跑策略的 **Gate.io USDT 永续** 子账户(建议与主资金隔离)。
|
||||||
|
- 你已明确:**方向、止损价、补仓区间边界价、止盈价、杠杆**,并接受程序按风险预算拆分 **首仓 50% + 多档补仓 50%**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 名词与参数
|
||||||
|
|
||||||
|
| 名称 | 含义 |
|
||||||
|
|------|------|
|
||||||
|
| **合约 USDT 可用余额** | **生成预览**时通过 API 读取的 **swap 账户 USDT `free`** 快照;**确认执行**时再次读取并与快照比对偏差。 |
|
||||||
|
| **风险比例** | 默认 **5%**:指「若整笔计划在 **补仓区间远侧边界**(做多=上沿、做空=下沿)这一侧的最坏价格结构下触及止损」,目标亏损上限约为 **可用余额快照 × 风险比例**(实现上用 `calc_risk_fraction` 与 `prepare_order_amount` 反推总张数,受交易所最小张数与精度约束)。 |
|
||||||
|
| **止损价** | 用户填写;开仓后挂 **交易所仓位类止损触发单**(全平)。 |
|
||||||
|
| **补仓区间边界**(库字段 `add_upper`) | 用户填写;**仅在该价位与止损价构成的区间内** 才允许程序触发剩余 50% 的市价补仓。**界面文案**:做多显示「补仓上沿」,做空显示「补仓下沿」。校验:做多 `止损 < 边界价`;做空 `止损 > 边界价`。 |
|
||||||
|
| **止盈价** | 用户填写的 **固定价格**;**不由交易所条件止盈单触发**,由应用后台 **按标记价/行情价轮询**,达到后 **市价全平**。 |
|
||||||
|
| **杠杆** | 计划内固定写入;用于 `set_leverage` 与名义换算。 |
|
||||||
|
| **补仓档位数** | 默认 **5** 档(环境变量 `TREND_PULLBACK_DCA_LEGS` 可调);程序在满足最小张数前提下可能 **自动减少档数**。 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 执行流程(时间顺序)
|
||||||
|
|
||||||
|
### 3.0 列表时间窗(交易记录 / 计划历史)
|
||||||
|
|
||||||
|
- **交易记录**、**计划历史**(含预览快照)列表与 **交易记录 CSV 导出** 支持 **UTC** 时间筛选(默认 UTC 当日;可选近 24h、近 7d、自定义起止)。
|
||||||
|
- 查询参数:`win_preset`(`utc_today` / `utc_last24h` / `utc_last7d` / `custom`)、自定义时另传 `from_utc`、`to_utc`。
|
||||||
|
- **统计分析**页仍按北京时间 `TRADING_DAY_RESET_HOUR` 切日,不受列表窗影响。
|
||||||
|
|
||||||
|
### 3.1 预览阶段(不下单)
|
||||||
|
|
||||||
|
1. **风控**:与「机器人下单监控」**互斥**——存在活跃机器人持仓或运行中趋势计划时,不可生成预览。
|
||||||
|
2. **读取可用余额快照** `get_available_trading_usdt()`,失败则拒绝。
|
||||||
|
3. **计算**(写入表 `trend_pullback_previews`,并跳转带 `preview_id`):
|
||||||
|
- 在 **补仓区间边界 ↔ 止损** 区间内生成 `N` 个补仓触发价(做多从上沿向止损、做空从下沿向止损);
|
||||||
|
- 将 **剩余 50% 计划张数** 拆成 `N` 份写入 `leg_amounts_json`。
|
||||||
|
4. **预览有效期**:默认 **120 秒**(`TREND_PULLBACK_PREVIEW_TTL_SECONDS`),超时须重新点「生成预览」。
|
||||||
|
|
||||||
|
### 3.2 确认执行(实盘)
|
||||||
|
|
||||||
|
5. 再次校验:预览未过期;**当前可用余额**与预览快照相对偏差 ≤ `TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT`(默认 **5%**),否则拒绝执行并要求重新预览。
|
||||||
|
6. **首仓**:**立即市价** 开立 **总计划张数 × 50%**(不附带交易所止盈单)。
|
||||||
|
7. **止损**:撤销旧条件单后,挂 **仅止损** 的仓位触发单;之后每次补仓成交会 **刷新** 止损挂单。
|
||||||
|
7b. **手动保本**(可选):首仓完成且交易所有持仓后,可在运行中计划卡片点击「应用保本止损」——将止损移至 **持仓均价 ± 偏移%**(默认 **+0.3%** 多 / **−0.3%** 空);仅当新止损 **优于** 当前止损时生效,并同步 Gate 仓位止损单。
|
||||||
|
8. **补仓**:当价格 **穿越** 下一档触发价(做多为自上向下穿越,做空为自下向上穿越)时,按该档张数 **市价加仓**;直至 `N` 档执行完毕或计划结束。
|
||||||
|
9. **止盈监控**:后台线程若发现价格触及止盈,则 **市价全平**。
|
||||||
|
10. **止损触发**:若仓位被交易所止损打光,本地检测到 **持仓为 0** 后记账为 **止损** 并结束计划。
|
||||||
|
11. **计划结束**:任一结束路径(止盈 / 止损 / 用户手动结束)均会 **撤单**(条件单 + 普通挂单,尽力而为)。
|
||||||
|
|
||||||
|
### 3.3 取消预览
|
||||||
|
|
||||||
|
用户可「取消预览」删除 `trend_pullback_previews` 中对应记录;过期记录会在新预览或页面加载时清理。
|
||||||
|
|
||||||
|
### 3.4 界面:计划历史与运行中浮动盈亏
|
||||||
|
|
||||||
|
- **计划历史(页顶卡片)**
|
||||||
|
- 仅展示 **`trend_pullback_plans` 中已结束的计划**(`status != 'active'`,如止盈结束、止损结束、手动结束)。
|
||||||
|
- **不包含**仅存在于 `trend_pullback_previews`、从未「确认执行」的预览。
|
||||||
|
- 每行提供 **删除**:删除该计划行,并删除 `trade_records` 中 **`trend_plan_id` 与之相同** 且类型为「趋势回调」的记录(用于与计划一一对应的新数据;历史旧行若无 `trend_plan_id` 则不会随删)。
|
||||||
|
- **运行中的计划(交易执行页)**
|
||||||
|
- 在计划摘要下方展示 **浮盈亏(交易所)**:来自 Gate 当前持仓接口的 **未实现盈亏**(及标记价,若可得);与本地按均价估算可能略有差异,以交易所为准便于对照。
|
||||||
|
- **补仓边界**按方向显示「补仓上沿」或「补仓下沿」(数值仍为 `add_upper` 字段)。
|
||||||
|
- **手动保本**:表单可改偏移 %(默认见 `TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT`);成功后显示「已保本」时间与原止损(若与当前不同)。
|
||||||
|
|
||||||
|
### 3.5 交易记录与交易所「已实现盈亏」对齐
|
||||||
|
|
||||||
|
- 平仓时仍会写入一条 **`trade_records`**(`monitor_type=趋势回调`),其中的 **`pnl_amount` 等为本地估算**(`calc_pnl`,不含手续费、资金费等完整账单口径)。
|
||||||
|
- 打开 **「交易执行」或「交易记录」** 页面时,若已配置 **`GATE_API_KEY` / `GATE_API_SECRET`**(不要求 `LIVE_TRADING_ENABLED=true`,只读即可),应用会按节流策略(同进程约 **25 秒**内最多一次)调用 Gate **`fetch_positions_history`(平仓历史)**,为尚未写入 `exchange_sync_key` 的趋势回调记录 **匹配一条平仓记录**,并回填:
|
||||||
|
- **`exchange_realized_pnl`**:交易所口径已实现盈亏(与 App「历史仓位」更接近);
|
||||||
|
- **`exchange_opened_at` / `exchange_closed_at`**:换算为应用时区(默认北京)下的开、平时间字符串。
|
||||||
|
- **交易记录表**展示列「开仓(展示) / 平仓(展示) / 盈亏U(展示)」:对「趋势回调」行,若已同步则优先显示交易所字段(界面小字 **「所」**);未同步前仍显示本地复盘字段(小字 **「估」**)。
|
||||||
|
- 匹配规则概要:同品种、同方向、平仓时间与本地 `closed_at` 接近,并结合 **`trend_plan_id`** 对应计划的 `opened_at` 收窄时间窗;极端情况下若短时间多笔同向同品种,仍存在错配可能,可对照 `exchange_sync_key` 与交易所记录。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 与「机器人下单监控」的差异
|
||||||
|
|
||||||
|
| 项目 | 机器人下单监控 | 趋势回调 |
|
||||||
|
|------|------------------|----------|
|
||||||
|
| 开仓 | 单次市价 + 条件止盈+止损 | 首仓 50% 市价 + 多档补仓 + **仅止损在交易所** |
|
||||||
|
| 止盈 | 条件单 + 本地监控 | **仅本地监控市价止盈** |
|
||||||
|
| 仓位基数 | 以损定仓(表单/会话基数) | **可用余额快照 × 风险比例** 推导 |
|
||||||
|
| 移动保本 | 支持(按 R 自动上移) | **手动保本**(首仓后有持仓即可点;默认均价 +0.3%,可改偏移;同步 Gate 止损单;**无**自动 R 保本) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 风险声明(必读)
|
||||||
|
|
||||||
|
- 市价单存在 **滑点**;极端行情下实际亏损可能 **大于** 理论 5%。
|
||||||
|
- 补仓触发依赖应用 **轮询间隔**(`MONITOR_POLL_SECONDS`),非毫秒级高频。
|
||||||
|
- 交易所 **最小张数 / 精度** 可能导致计划张数被截断,实际风险略低于或偏离纸面计算。
|
||||||
|
- 请使用 **单独 API Key / 子账户**,并先在 `LIVE_TRADING_ENABLED=false` 环境验证流程(若需沙盒请自行对接测试网,本仓库默认实盘接口)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 相关环境变量
|
||||||
|
|
||||||
|
| 变量 | 说明 | 默认 |
|
||||||
|
|------|------|------|
|
||||||
|
| `TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT` | 手动保本默认偏移(相对持仓均价,%) | `0.3` |
|
||||||
|
| `TREND_PULLBACK_DCA_LEGS` | 剩余 50% 拆档数量上限 | `5` |
|
||||||
|
| `TREND_PULLBACK_PREVIEW_TTL_SECONDS` | 预览有效时间(秒) | `120` |
|
||||||
|
| `TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT` | 确认执行时允许「当前可用 / 预览快照」最大相对偏差(%) | `5` |
|
||||||
|
| `MONITOR_POLL_SECONDS` | 监控轮询间隔(秒) | `3` |
|
||||||
|
| `LIVE_TRADING_ENABLED` | 是否允许真实下单 | `false` |
|
||||||
|
| `FULL_MARGIN_BUFFER_RATIO` | 计划保证金相对可用余额上限比例 | `0.98` |
|
||||||
|
| `APP_TIMEZONE` | 应用墙钟与「北京日期」同步起点时区(如 `Asia/Shanghai`) | `Asia/Shanghai` |
|
||||||
|
| `EXCHANGE_POSITION_SYNC_FROM_BJ` | 拉取 Gate **平仓历史** 的最早日期(`YYYY-MM-DD`,按 `APP_TIMEZONE` 当日 **00:00** 起算)。**留空**则从近 **90 天** 起拉取 | 空 |
|
||||||
|
| `EXCHANGE_POSITION_HISTORY_LIMIT` | 单次拉取平仓历史条数上限(50–1000) | `200` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 数据库
|
||||||
|
|
||||||
|
- **`trend_pullback_previews`**:未执行的预览行(含 `expires_at_ms`),执行成功或取消后删除;过期可被清理。
|
||||||
|
- **`trend_pullback_plans`**:趋势回调计划。执行后写入一行,`status='active'` 表示运行中;止盈 / 止损 / 手动结束后变为 **`stopped_tp` / `stopped_sl` / `stopped_manual`** 等非 `active` 状态,并出现在页顶 **计划历史**。字段含快照可用余额、计划保证金、总张数、首仓张数、补仓 JSON、网格价 JSON、已补仓档数、均价、`opened_at`、`message`(结束说明)等;**`add_upper`** 存补仓区间远侧边界价(做多=上沿、做空=下沿)。
|
||||||
|
- **`trade_records`**(`monitor_type=趋势回调`):每次计划结束插入一行;含本地估算盈亏等。新写入行带 **`trend_plan_id`** 指向 `trend_pullback_plans.id`。另含 **`exchange_realized_pnl`、`exchange_opened_at`、`exchange_closed_at`、`exchange_sync_key`**,由页面触发的交易所平仓历史同步填充(见 3.5)。
|
||||||
|
|
||||||
|
**CSV 导出**:交易记录导出为 **v3**,包含上述交易所对齐字段及 `trend_plan_id`。
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
# `crypto_monitor_gate` 部署指南:SSH SOCKS + Gate.io + PM2(Ubuntu)
|
||||||
|
|
||||||
|
本文面向:**在本机运行本项目**,但 **直连 Gate.io API 不稳定或被重置** 的场景。思路是:
|
||||||
|
|
||||||
|
- 本机用 `ssh -D` 做动态转发,把 **SOCKS5 出口**放到能正常访问 Gate 的机器(常见为一台境外 VPS)
|
||||||
|
- 项目在 `.env` 中设置 **`GATE_SOCKS_PROXY=socks5h://127.0.0.1:1080`**(或你实际端口),`ccxt` 经 SOCKS 访问交易所
|
||||||
|
- **SSH 隧道**:用 `ssh -D` 在本机常驻即可(screen / tmux / systemd 等),**不必交给 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 自动备份(数据库 + 复盘图片)
|
||||||
|
|
||||||
|
每天 **北京时间 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.3 节(恢复步骤、可选 `.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)
|
||||||
|
|
||||||
|
示例(前台;实际可用 `screen`/`tmux`/`-f` 后台化或 systemd):
|
||||||
|
|
||||||
|
```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='保本止盈'`。
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 环境配置模板(可提交 Git)。程序运行时只读取同目录下的 .env。
|
||||||
|
#
|
||||||
|
# 首次部署 / 新机:
|
||||||
|
# cp .env.example .env
|
||||||
|
# nano .env # 填入真实密钥、端口、代理等
|
||||||
|
#
|
||||||
|
# 升级代码(git pull)前建议备份(.env 不在 Git 中,pull 不会覆盖):
|
||||||
|
# cp .env .env.backup.$(date +%Y%m%d)
|
||||||
|
#
|
||||||
|
# 从备份恢复:
|
||||||
|
# cp .env.backup.YYYYMMDD .env
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
APP_ENV=production
|
||||||
|
# 服务监听地址(云服务器通常用 0.0.0.0)
|
||||||
|
APP_HOST=0.0.0.0
|
||||||
|
# 服务端口
|
||||||
|
APP_PORT=5004
|
||||||
|
# 是否开启调试模式(生产建议 false)
|
||||||
|
APP_DEBUG=false
|
||||||
|
|
||||||
|
# 登录账号
|
||||||
|
APP_USERNAME=dekun
|
||||||
|
# 登录密码(请改成你自己的强密码)
|
||||||
|
APP_PASSWORD=ChangeMe123!
|
||||||
|
# 是否关闭登录校验(局域网可设 true;公网务必 false)
|
||||||
|
APP_AUTH_DISABLED=true
|
||||||
|
# Flask 会话密钥(必须替换为长随机字符串)
|
||||||
|
FLASK_SECRET_KEY=CHANGE_TO_LONG_RANDOM_SECRET
|
||||||
|
|
||||||
|
# ---------- 整机许可(也可写在仓库根目录 .env 的 LICENSE_* 变量)----------
|
||||||
|
LICENSE_API_URL=https://license.example.com
|
||||||
|
LICENSE_CLIENT_KEY=REPLACE_WITH_CLIENT_KEY
|
||||||
|
LICENSE_CHECK_INTERVAL_DAYS=3
|
||||||
|
LICENSE_OFFLINE_GRACE_DAYS=7
|
||||||
|
LICENSE_WECHAT_ID=dekun03
|
||||||
|
# LICENSE_DISABLED=false
|
||||||
|
|
||||||
|
# 企业微信机器人 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
|
||||||
|
|
||||||
|
# 训练总资金(U)
|
||||||
|
TOTAL_CAPITAL=100
|
||||||
|
# 每天起始基数(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
|
||||||
|
|
||||||
|
# 是否开启 OKX 实盘下单(false=只做本地流程,true=真实下单)
|
||||||
|
LIVE_TRADING_ENABLED=true
|
||||||
|
# OKX API Key(实盘)
|
||||||
|
OKX_API_KEY=REPLACE_WITH_OKX_API_KEY
|
||||||
|
# OKX API Secret(实盘)
|
||||||
|
OKX_API_SECRET=REPLACE_WITH_OKX_API_SECRET
|
||||||
|
# OKX API Passphrase(实盘)
|
||||||
|
OKX_API_PASSPHRASE=REPLACE_WITH_OKX_API_PASSPHRASE
|
||||||
|
# 保证金模式:cross=全仓,isolated=逐仓
|
||||||
|
OKX_TD_MODE=cross
|
||||||
|
# 持仓模式:hedge=双向持仓,net=单向净持仓
|
||||||
|
OKX_POS_MODE=hedge
|
||||||
|
# 仓位查询 instType(OKX)
|
||||||
|
OKX_POSITION_INST_TYPE=SWAP
|
||||||
|
|
||||||
|
# 关键位监控:5m收线突破过滤参数
|
||||||
|
KLINE_TIMEFRAME=5m
|
||||||
|
KEY_BREAKOUT_LIMIT_PCT=1.5
|
||||||
|
KEY_ALERT_MAX_TIMES=3
|
||||||
|
KEY_ALERT_INTERVAL_MINUTES=5
|
||||||
|
|
||||||
|
# 资金与仓位刷新周期(秒)
|
||||||
|
BALANCE_REFRESH_SECONDS=60
|
||||||
|
# 后台监控轮询周期(秒)
|
||||||
|
MONITOR_POLL_SECONDS=3
|
||||||
|
# 使用可用资金时的缓冲比例(如0.98代表用98%)
|
||||||
|
FULL_MARGIN_BUFFER_RATIO=0.98
|
||||||
|
|
||||||
|
# 自动划转:将目标账户补足到 AUTO_TRANSFER_AMOUNT
|
||||||
|
AUTO_TRANSFER_ENABLED=false
|
||||||
|
AUTO_TRANSFER_AMOUNT=30
|
||||||
|
AUTO_TRANSFER_FROM=funding
|
||||||
|
AUTO_TRANSFER_TO=swap
|
||||||
|
TRANSFER_CCY=USDT
|
||||||
|
# 强制清仓整点(北京时间,默认 0=凌晨00点)
|
||||||
|
FORCE_CLOSE_BJ_HOUR=0
|
||||||
|
# 是否启用强制清仓(默认关闭,true 才会在整点执行)
|
||||||
|
FORCE_CLOSE_ENABLED=false
|
||||||
|
|
||||||
|
# 推送与AI超时(秒)
|
||||||
|
WECHAT_TIMEOUT_SECONDS=10
|
||||||
|
AI_TIMEOUT_SECONDS=120
|
||||||
|
|
||||||
|
# AI 复盘服务地址(本机 Ollama 默认地址)
|
||||||
|
OLLAMA_API=http://127.0.0.1:11434/api/generate
|
||||||
|
# AI 模型名称
|
||||||
|
AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest
|
||||||
|
|
||||||
|
# OKX 代理(可选):用于本机网络对 OKX TLS/SNI 不稳定时,通过 SSH 动态转发 SOCKS5 出口
|
||||||
|
# 1) 先在本机建立隧道(示例):
|
||||||
|
# ssh -N -D 127.0.0.1:1080 root@你的VPS_IP -o ServerAliveInterval=30 -o ExitOnForwardFailure=yes
|
||||||
|
# 2) 再启用下面这一行(推荐 socks5h,让远端解析域名):
|
||||||
|
# OKX_SOCKS_PROXY=socks5h://127.0.0.1:1080
|
||||||
|
#
|
||||||
|
# 如你更偏向 HTTP 代理(VPS 上跑 tinyproxy 之类),可用:
|
||||||
|
# OKX_HTTP_PROXY=http://127.0.0.1:3128
|
||||||
|
# OKX_HTTPS_PROXY=http://127.0.0.1:3128
|
||||||
|
|
||||||
|
# 开仓多周期K线图(可选)
|
||||||
|
# ORDER_CHART_ENABLED=true
|
||||||
|
# ORDER_CHART_TFS=4h,1h,15m,5m
|
||||||
|
# ORDER_CHART_LIMIT=100
|
||||||
|
# ORDER_CHART_DIR=static/images/order_charts
|
||||||
|
# DAILY_OPEN_ALERT_THRESHOLD=5
|
||||||
|
# 关键位:标准方案止损外侧%、趋势单方案止损外侧%(默认 0.5 / 1)
|
||||||
|
# KEY_STOP_OUTSIDE_BREAKOUT_PCT=0.5
|
||||||
|
# KEY_TREND_STOP_OUTSIDE_PCT=1
|
||||||
|
# 以损定仓(按交易账户资金的百分比)
|
||||||
|
# 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
|
||||||
|
AUTO_TRANSFER_BJ_HOUR=8
|
||||||
|
# TRADING_DAY_RESET_HOUR 现在表示「北京时间」整点,默认 8 点起算新交易日/可开仓等
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* PM2 进程定义(Ubuntu / Linux)。
|
||||||
|
*
|
||||||
|
* 仅托管 Flask 应用。**SSH SOCKS 隧道请在本机用 screen/tmux/systemd 等方式单独常驻**,
|
||||||
|
* 与 `.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 PY = path.join(ROOT, ".venv", "bin", "python");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: "crypto_okx",
|
||||||
|
cwd: ROOT,
|
||||||
|
script: path.join(ROOT, "app.py"),
|
||||||
|
interpreter: PY,
|
||||||
|
instances: 1,
|
||||||
|
autorestart: true,
|
||||||
|
watch: false,
|
||||||
|
max_memory_restart: "800M",
|
||||||
|
// app.py 从项目根目录 .env 加载(由 .env.example 复制而来,勿提交 Git)
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
一次性修复历史交易记录标签:
|
||||||
|
将 trade_records 里“止损但实际盈利”的记录改为“保本止盈”。
|
||||||
|
|
||||||
|
默认条件(可通过参数修改):
|
||||||
|
- monitor_type = 下单监控
|
||||||
|
- result = 止损
|
||||||
|
- pnl_amount > 0
|
||||||
|
|
||||||
|
用法示例:
|
||||||
|
1) 仅预览(不落库):
|
||||||
|
python scripts/fix_breakeven_labels.py --db ./crypto.db --dry-run
|
||||||
|
|
||||||
|
2) 执行修复:
|
||||||
|
python scripts/fix_breakeven_labels.py --db ./crypto.db --apply
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Fix historical stop-loss records with positive pnl.")
|
||||||
|
parser.add_argument("--db", required=True, help="Path to sqlite db file, e.g. ./crypto.db")
|
||||||
|
parser.add_argument("--monitor-type", default="下单监控", help="Filter by monitor_type (default: 下单监控)")
|
||||||
|
parser.add_argument("--from-result", default="止损", help="Source result label (default: 止损)")
|
||||||
|
parser.add_argument("--to-result", default="保本止盈", help="Target result label (default: 保本止盈)")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Preview only, no write")
|
||||||
|
parser.add_argument("--apply", action="store_true", help="Execute update")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
db_path = Path(args.db).expanduser().resolve()
|
||||||
|
if not db_path.exists():
|
||||||
|
print(f"[ERR] DB not found: {db_path}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if args.dry_run and args.apply:
|
||||||
|
print("[ERR] --dry-run and --apply are mutually exclusive.")
|
||||||
|
return 1
|
||||||
|
if not args.dry_run and not args.apply:
|
||||||
|
print("[INFO] No mode provided, defaulting to --dry-run.")
|
||||||
|
args.dry_run = True
|
||||||
|
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
where_sql = """
|
||||||
|
monitor_type = ?
|
||||||
|
AND result = ?
|
||||||
|
AND CAST(COALESCE(pnl_amount, 0) AS REAL) > 0
|
||||||
|
"""
|
||||||
|
params = (args.monitor_type, args.from_result)
|
||||||
|
|
||||||
|
cur.execute(f"SELECT COUNT(*) AS c FROM trade_records WHERE {where_sql}", params)
|
||||||
|
will_change = int(cur.fetchone()["c"])
|
||||||
|
print(f"[INFO] Candidate rows: {will_change}")
|
||||||
|
|
||||||
|
if will_change == 0:
|
||||||
|
print("[INFO] Nothing to update.")
|
||||||
|
conn.close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT id, symbol, result, pnl_amount, closed_at
|
||||||
|
FROM trade_records
|
||||||
|
WHERE {where_sql}
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 10
|
||||||
|
""",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
sample = cur.fetchall()
|
||||||
|
print("[INFO] Sample (latest 10):")
|
||||||
|
for r in sample:
|
||||||
|
print(
|
||||||
|
f" id={r['id']} symbol={r['symbol']} result={r['result']} "
|
||||||
|
f"pnl={r['pnl_amount']} closed_at={r['closed_at']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print("[DRY-RUN] No write executed.")
|
||||||
|
conn.close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
f"UPDATE trade_records SET result=? WHERE {where_sql}",
|
||||||
|
(args.to_result, *params),
|
||||||
|
)
|
||||||
|
changed = int(cur.rowcount)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print(f"[DONE] Updated rows: {changed}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
|
|
||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
ok2
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>关键位放大 | 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}
|
||||||
|
input,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:580px;background:#0f1320;border:1px solid #2a3150;border-radius:10px;padding:8px}
|
||||||
|
#chart{width:100%;height:100%}
|
||||||
|
</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">关键位放大(可输入币种)</strong>
|
||||||
|
</div>
|
||||||
|
<div class="status">最近刷新:<span id="updated-at">--</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row" style="margin-top:10px">
|
||||||
|
<label>币种</label>
|
||||||
|
<input id="symbol-input" value="{{ default_symbol }}" placeholder="BTC/USDT">
|
||||||
|
|
||||||
|
<label>关键位</label>
|
||||||
|
<select id="key-id">
|
||||||
|
<option value="">无(仅看K线)</option>
|
||||||
|
{% for k in key_list %}
|
||||||
|
<option value="{{ k.id }}" {% if selected_key and k.id == selected_key.id %}selected{% endif %}>#{{ k.id }} {{ k.symbol }} {{ k.monitor_type }} {{ '做多' if k.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>
|
||||||
|
|
||||||
|
<label>K线数</label>
|
||||||
|
<select id="kline-limit">
|
||||||
|
<option value="100" {% if default_kline_limit == 100 %}selected{% endif %}>100</option>
|
||||||
|
<option value="200" {% if default_kline_limit == 200 %}selected{% endif %}>200</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button id="manual-refresh" type="button">刷新</button>
|
||||||
|
<span id="load-status" class="status"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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-type">-</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-upper">-</div></div>
|
||||||
|
<div class="meta-item"><div class="k">下沿/支撑</div><div class="v" id="m-lower">-</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-updiff">-</div></div>
|
||||||
|
<div class="meta-item"><div class="k">距下沿</div><div class="v" id="m-lowdiff">-</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card"><div id="chart-wrap"><div id="chart"></div></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
|
||||||
|
<script>
|
||||||
|
const refreshMs = Math.max({{ price_refresh_seconds * 1000 }}, 5000);
|
||||||
|
const keySelect = document.getElementById("key-id");
|
||||||
|
const symbolInput = document.getElementById("symbol-input");
|
||||||
|
const tfSelect = document.getElementById("timeframe");
|
||||||
|
const limitSelect = document.getElementById("kline-limit");
|
||||||
|
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);
|
||||||
|
const fmtSigned = (v,d=4)=>{
|
||||||
|
if(v===null||typeof v==="undefined"||Number.isNaN(Number(v))) return "-";
|
||||||
|
const n = Number(v);
|
||||||
|
return `${n>0?"+":""}${n.toFixed(d)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
let chart = null;
|
||||||
|
let candleSeries = null;
|
||||||
|
let priceLines = [];
|
||||||
|
const keyMap = {};
|
||||||
|
{% for k in key_list %}
|
||||||
|
keyMap["{{ k.id }}"] = "{{ k.symbol }}";
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
function ensureChart(){
|
||||||
|
if(chart && candleSeries) return true;
|
||||||
|
if(!window.LightweightCharts){
|
||||||
|
statusEl.className = "status err";
|
||||||
|
statusEl.innerText = "图表库加载失败";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!chart){
|
||||||
|
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}
|
||||||
|
});
|
||||||
|
window.addEventListener("resize",()=>{
|
||||||
|
chart.applyOptions({width:chartHost.clientWidth,height:chartHost.clientHeight});
|
||||||
|
});
|
||||||
|
chart.applyOptions({width:chartHost.clientWidth,height:chartHost.clientHeight});
|
||||||
|
}
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
upColor: "#4cd97f",
|
||||||
|
downColor: "#ff6666",
|
||||||
|
borderVisible: false,
|
||||||
|
wickUpColor: "#4cd97f",
|
||||||
|
wickDownColor: "#ff6666"
|
||||||
|
};
|
||||||
|
if (typeof chart.addCandlestickSeries === "function") {
|
||||||
|
candleSeries = chart.addCandlestickSeries(opts);
|
||||||
|
} else if (typeof chart.addSeries === "function" && window.LightweightCharts && window.LightweightCharts.CandlestickSeries) {
|
||||||
|
candleSeries = chart.addSeries(window.LightweightCharts.CandlestickSeries, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!candleSeries){
|
||||||
|
statusEl.className = "status err";
|
||||||
|
statusEl.innerText = "K线序列初始化失败";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPriceLines(){
|
||||||
|
if(!candleSeries) return;
|
||||||
|
priceLines.forEach(line=>{ try { candleSeries.removePriceLine(line); } catch (_) {} });
|
||||||
|
priceLines = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLine(price, title, color){
|
||||||
|
if(!candleSeries || price===null || typeof price==="undefined") 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 paintMeta(data){
|
||||||
|
const key = data.key_monitor || null;
|
||||||
|
document.getElementById("m-symbol").innerText = data.symbol || "-";
|
||||||
|
document.getElementById("m-price").innerText = fmt(data.current_price,8);
|
||||||
|
|
||||||
|
if(!key){
|
||||||
|
document.getElementById("m-type").innerText = "未匹配到关键位";
|
||||||
|
document.getElementById("m-direction").innerText = "-";
|
||||||
|
document.getElementById("m-upper").innerText = "-";
|
||||||
|
document.getElementById("m-lower").innerText = "-";
|
||||||
|
document.getElementById("m-updiff").innerText = "-";
|
||||||
|
document.getElementById("m-lowdiff").innerText = "-";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("m-type").innerText = key.monitor_type || "-";
|
||||||
|
document.getElementById("m-direction").innerText = key.direction === "short" ? "做空" : "做多";
|
||||||
|
document.getElementById("m-upper").innerText = fmt(key.upper,8);
|
||||||
|
document.getElementById("m-lower").innerText = fmt(key.lower,8);
|
||||||
|
document.getElementById("m-updiff").innerText = `${fmtSigned(key.upper_diff,4)} (${fmtSigned(key.upper_pct,2)}%)`;
|
||||||
|
document.getElementById("m-lowdiff").innerText = `${fmtSigned(key.lower_diff,4)} (${fmtSigned(key.lower_pct,2)}%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSymbolByKey(){
|
||||||
|
const keyId = keySelect.value;
|
||||||
|
if(keyId && keyMap[keyId]) symbolInput.value = keyMap[keyId];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadKeyKline(){
|
||||||
|
if(!ensureChart()) return;
|
||||||
|
const keyId = keySelect.value;
|
||||||
|
const symbol = (symbolInput.value || "").trim().toUpperCase();
|
||||||
|
const timeframe = tfSelect.value;
|
||||||
|
const limit = limitSelect.value;
|
||||||
|
|
||||||
|
if(!symbol && !keyId){
|
||||||
|
statusEl.className = "status err";
|
||||||
|
statusEl.innerText = "请先输入币种或选择关键位";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusEl.className = "status";
|
||||||
|
statusEl.innerText = "加载中...";
|
||||||
|
|
||||||
|
try{
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if(keyId) qs.set("key_id", keyId);
|
||||||
|
if(symbol) qs.set("symbol", symbol);
|
||||||
|
qs.set("timeframe", timeframe);
|
||||||
|
qs.set("limit", limit);
|
||||||
|
|
||||||
|
const resp = await fetch(`/api/key_kline?${qs.toString()}`);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!candleSeries) throw new Error("Series init failed");
|
||||||
|
candleSeries.setData(candles);
|
||||||
|
resetPriceLines();
|
||||||
|
addLine(data.current_price, "现价", "#42a5f5");
|
||||||
|
if(data.key_monitor){
|
||||||
|
addLine(data.key_monitor.upper, "上沿/阻力", "#ffb84d");
|
||||||
|
addLine(data.key_monitor.lower, "下沿/支撑", "#4cd97f");
|
||||||
|
}
|
||||||
|
chart.timeScale().fitContent();
|
||||||
|
paintMeta(data);
|
||||||
|
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", loadKeyKline);
|
||||||
|
keySelect.addEventListener("change", ()=>{ syncSymbolByKey(); loadKeyKline(); });
|
||||||
|
symbolInput.addEventListener("change", ()=>{
|
||||||
|
if(symbolInput.value.trim()) keySelect.value = "";
|
||||||
|
loadKeyKline();
|
||||||
|
});
|
||||||
|
tfSelect.addEventListener("change", loadKeyKline);
|
||||||
|
limitSelect.addEventListener("change", loadKeyKline);
|
||||||
|
|
||||||
|
syncSymbolByKey();
|
||||||
|
loadKeyKline();
|
||||||
|
setInterval(loadKeyKline, refreshMs);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>系统登录</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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-box">
|
||||||
|
<h2>交易监控系统登录</h2>
|
||||||
|
{% 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>
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
<!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 = fmt(order.trigger_price, 8);
|
||||||
|
document.getElementById("m-sl").innerText = fmt(order.stop_loss, 8);
|
||||||
|
document.getElementById("m-tp").innerText = 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 = fmt(order.current_price, 8);
|
||||||
|
const pnlEl = document.getElementById("m-pnl");
|
||||||
|
pnlEl.innerText = `${fmt(order.float_pnl, 4)}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>
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
<!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 = fmt(order.trigger_price, 8);
|
||||||
|
document.getElementById("m-sl").innerText = fmt(order.stop_loss, 8);
|
||||||
|
document.getElementById("m-tp").innerText = 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 = fmt(order.current_price, 8);
|
||||||
|
const pnlEl = document.getElementById("m-pnl");
|
||||||
|
pnlEl.innerText = `${fmt(order.float_pnl, 4)}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 %}
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
if (typeof ensureChart !== 'function') return;
|
||||||
|
const oldEnsureChart = ensureChart;
|
||||||
|
ensureChart = function(){
|
||||||
|
if (chart && candleSeries) return true;
|
||||||
|
try { const ok = oldEnsureChart(); if (ok && candleSeries) return true; } catch (_) {}
|
||||||
|
if (chart && !candleSeries && typeof chart.addSeries === 'function' && window.LightweightCharts && window.LightweightCharts.CandlestickSeries) {
|
||||||
|
const opts = { upColor:'#4cd97f', downColor:'#ff6666', borderVisible:false, wickUpColor:'#4cd97f', wickDownColor:'#ff6666' };
|
||||||
|
candleSeries = chart.addSeries(window.LightweightCharts.CandlestickSeries, opts);
|
||||||
|
return !!candleSeries;
|
||||||
|
}
|
||||||
|
return !!candleSeries;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# 界面与风控更新说明(OKX 实例)
|
||||||
|
|
||||||
|
与 Gate / Binance 主站对齐的列表窗、统计分品类、交易记录展示、复盘与移动保本交易所同步;OKX 仍为 **三页导航**(交易执行 / 记录复盘 / 统计),关键位监控合并在 **交易执行** 页,**无** Gate 独立「关键位监控」页与斐波限价监控。
|
||||||
|
|
||||||
|
## 顶栏导航(3 项)
|
||||||
|
|
||||||
|
| 顺序 | 名称 | 路由 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 1 | 交易执行 | `/trade` | 关键位监控 + 实盘下单(**默认首页** `/` → `/trade`) |
|
||||||
|
| 2 | 交易记录与复盘 | `/records` | 交易记录、复盘表单、AI 历史(受顶栏 UTC 时间窗筛选) |
|
||||||
|
| 3 | 统计分析 | `/stats` | 按北京时间交易日切日 + 分品类统计块 |
|
||||||
|
|
||||||
|
## 列表时间窗(UTC,全站顶栏)
|
||||||
|
|
||||||
|
共用模块:仓库根目录 `history_window_lib.py`(与 Gate / Binance 一致)。
|
||||||
|
|
||||||
|
| 项 | 说明 |
|
||||||
|
|----|------|
|
||||||
|
| 默认 | **UTC 当日**(`win_preset=utc_today`) |
|
||||||
|
| 可选 | 近 24 小时、近 7 天、自定义起止(UTC) |
|
||||||
|
| 作用范围 | 关键位历史、交易记录列表、复盘 API、AI 历史 API、导出「交易记录」「关键位历史」 |
|
||||||
|
| 与统计 | **仅影响列表/导出**;统计页仍按北京时间 `TRADING_DAY_RESET_HOUR`(默认 8:00)切日 |
|
||||||
|
| 切换 | 顶栏「列表筛选(UTC)」→ 应用(保留当前路由 query) |
|
||||||
|
|
||||||
|
## 交易记录与复盘
|
||||||
|
|
||||||
|
- 列表 **止损(开仓)**:展示 `initial_stop_loss` 快照(`display_open_stop_loss`)。
|
||||||
|
- 类型列显示 `monitor_type` 与 `key_signal_type`(若有)。
|
||||||
|
- 平仓入库:`stop_loss` / `initial_stop_loss` 为开仓止损快照;机器单 `entry_reason` 可按 `key_signal_type` 自动映射(箱体突破 / 收敛突破 → 四条固定关键位开仓类型文案)。
|
||||||
|
- 复盘:开仓类型下拉含四条关键位固定文案 +「其他」;离场触发含 **「止盈」**;从交易记录填入时按结果与信号预填。
|
||||||
|
- 复盘 K 线图:以 **平仓时间** 为锚点向前约 `ORDER_CHART_LIMIT`(默认 100)根(`_fetch_ohlcv_ending_at`)。
|
||||||
|
- `/api/journals`、`/api/reviews` 与顶栏 UTC 窗一致。
|
||||||
|
|
||||||
|
### 导出(交易记录 v3)
|
||||||
|
|
||||||
|
- 文件名:`trade_records_v3_YYYYMMDD.csv`
|
||||||
|
- 含 `key_signal_type`、`initial_stop_loss`、计划/实际 RR、`risk_amount` 等;末列「开仓类型」为有效展示文案。
|
||||||
|
- 受 UTC 列表窗限制;关键位历史导出同理。
|
||||||
|
|
||||||
|
## 实盘下单(交易执行页)
|
||||||
|
|
||||||
|
- **移动保本**:表单可勾选「启用移动保本」;触发阶梯上移后 **先撤后挂** 交易所 TP/SL(`replace_active_monitor_tpsl_on_exchange`),仅成功后才写库;企业微信提示含「交易所:已先撤后挂止盈止损」。未配置实盘 API 时仅更新本地止损。
|
||||||
|
- 开仓 TP/SL 仍通过 OKX `attachAlgoOrds`(与原有逻辑一致);重挂使用 ccxt `stopLoss` / `takeProfit` 参数,触发价经 `_okx_algo_trigger_price_str` 格式化。
|
||||||
|
|
||||||
|
## 统计分析页(`/stats`)
|
||||||
|
|
||||||
|
| 项 | 说明 |
|
||||||
|
|----|------|
|
||||||
|
| 切日 | 北京时间;边界 = `TRADING_DAY_RESET_HOUR:00`(默认 8) |
|
||||||
|
| 品类下拉 | 全部交易、下单监控、关键位箱体突破、关键位收敛结构、关键位斐波0.618、关键位斐波0.786 |
|
||||||
|
| URL | `stats_segment=`(`all` / `manual` / `key_box` / `key_conv` / `key_fib618` / `key_fib786`) |
|
||||||
|
| 与 UTC 窗 | 统计 **不** 随顶栏列表窗变化 |
|
||||||
|
|
||||||
|
## 斐波关键位监控(与 Gate / Binance 对齐)
|
||||||
|
|
||||||
|
| 项 | 说明 |
|
||||||
|
|----|------|
|
||||||
|
| 类型 | **斐波回调0.618**、**斐波回调0.786**(交易执行页关键位表单) |
|
||||||
|
| 同币互斥 | 每币仅一条斐波监控 |
|
||||||
|
| 挂单价 E | 做多 `E = H − ratio×(H−L)`;做空 `E = L + ratio×(H−L)`;SL/TP 为 L/H |
|
||||||
|
| 添加后 | 立即在 OKX 挂限价单;卡片显示 **挂E**、限价单 ID |
|
||||||
|
| 失效 | 标记价触达止盈侧且限价未成交 → 仅撤本条限价单(`cancel_fib_limit_order`) |
|
||||||
|
| 成交后 | 挂交易所 TP/SL → 写入 `order_monitors`(`monitor_type=关键位监控`,`key_signal_type=斐波回调…`)→ 从关键位表移除 |
|
||||||
|
| 轮询 | `check_fib_key_monitors()`(与箱体/收敛 `check_key_monitors()` 分离) |
|
||||||
|
| 盈亏比 | 计划 RR 须 > `KEY_AUTO_MIN_PLANNED_RR`(默认 1.5) |
|
||||||
|
| 日成交量 | 排名前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30) |
|
||||||
|
|
||||||
|
计算逻辑见仓库根目录 `fib_key_monitor_lib.py`。
|
||||||
|
|
||||||
|
## 与 Gate 的差异(其余)
|
||||||
|
|
||||||
|
- 无独立「关键位监控」导航页(斐波在 **交易执行** 页添加)。
|
||||||
|
- 无交易所已实现盈亏同步(`/api/sync_exchange_pnl`)。
|
||||||
|
- 箱体/收敛仍为 **提醒** 模式,不自动市价开仓(Gate/Binance 主站为自动开仓)。
|
||||||
|
|
||||||
|
## 配置与部署
|
||||||
|
|
||||||
|
- 详见 `.env.example` 中 OKX(`OKX_*`)与通用风控项。
|
||||||
|
- 代码更新后请 **重启 OKX 监控进程**;旧库行不做批量回填,展示字段有则用之、无则回退。
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
# `crypto_monitor` 本地部署 + SSH SOCKS 转发 + PM2 启动指南(Ubuntu)
|
||||||
|
|
||||||
|
本文面向:**本地 Ubuntu 机器运行项目**,但 **本机直连 OKX 会被 TLS/SNI reset** 的场景。解决思路是:
|
||||||
|
|
||||||
|
- 本机启动 `ssh -D` 动态转发,把 **SOCKS5 出口**放到你可正常访问 OKX 的 VPS 上
|
||||||
|
- 项目通过环境变量 `OKX_SOCKS_PROXY=socks5h://127.0.0.1:1080` 让 `ccxt` 走 SOCKS
|
||||||
|
- 用 `pm2` 托管 **SSH 隧道** 与 **Flask 应用**(你也可以只用 `screen`,但本文按你要求用 PM2)
|
||||||
|
|
||||||
|
> 安全提醒:不要把 `.env`、私钥 `.pem`、OKX API Key 提交到 Git;文档里只用占位符。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 你需要准备的东西
|
||||||
|
|
||||||
|
- 一台 **Ubuntu** 本地机器(下文称“本机”)
|
||||||
|
- 一台可 SSH 登录、且 **能正常访问 OKX** 的 VPS(示例公网 IP:`47.76.87.111`,用户:`root`)
|
||||||
|
- VPS 登录方式:**SSH 私钥**(推荐)或密码(不推荐用于无人值守)
|
||||||
|
- 本机已安装:
|
||||||
|
- `python3`、`python3-venv`、`pip`(或 `python3-pip`)
|
||||||
|
- `git`(可选)
|
||||||
|
- `curl`、`ssh`
|
||||||
|
- `node` + `npm`(用于安装 `pm2`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 从云服务器把项目同步到本地(推荐:打包下载)
|
||||||
|
|
||||||
|
在云服务器项目目录(包含 `app.py` 的目录)执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/crypto_monitor/crypto_monitor_okx
|
||||||
|
|
||||||
|
# 可选:清理 Python 缓存,减少小文件传输
|
||||||
|
find . -type d -name __pycache__ -prune -exec rm -rf {} +
|
||||||
|
find . -type f -name "*.pyc" -delete
|
||||||
|
|
||||||
|
tar -czf crypto_monitor.tgz .
|
||||||
|
```
|
||||||
|
|
||||||
|
下载 `crypto_monitor.tgz` 到本机后解压:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /opt/crypto_monitor/crypto_monitor_okx
|
||||||
|
cd /opt/crypto_monitor
|
||||||
|
tar -xzf crypto_monitor.tgz -C crypto_monitor_okx
|
||||||
|
cd crypto_monitor_okx
|
||||||
|
cp -n .env.example .env # 若尚无 .env
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 配置 SSH 私钥与 `~/.ssh/config`(推荐)
|
||||||
|
|
||||||
|
把私钥放到本机(示例:`~/.ssh/vps1.pem`),并设置权限:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
chmod 700 ~/.ssh
|
||||||
|
mv ~/Downloads/vps1.pem ~/.ssh/vps1.pem
|
||||||
|
chmod 600 ~/.ssh/vps1.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
编辑 `~/.ssh/config`(没有就创建),添加:
|
||||||
|
|
||||||
|
```sshconfig
|
||||||
|
Host okx-vps
|
||||||
|
HostName 47.76.87.111
|
||||||
|
User root
|
||||||
|
IdentityFile ~/.ssh/vps1.pem
|
||||||
|
IdentitiesOnly yes
|
||||||
|
ServerAliveInterval 30
|
||||||
|
ServerAliveCountMax 3
|
||||||
|
ExitOnForwardFailure yes
|
||||||
|
BatchMode yes
|
||||||
|
```
|
||||||
|
|
||||||
|
测试:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh okx-vps true
|
||||||
|
```
|
||||||
|
|
||||||
|
> 如果你还没完全切到密钥登录(还会交互要密码),先把 `BatchMode yes` 注释掉,等密钥登录稳定后再打开。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 先手工验证:SSH SOCKS + OKX API
|
||||||
|
|
||||||
|
### 3.1 开一个本地 SOCKS(1080)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -N -D 127.0.0.1:1080 okx-vps
|
||||||
|
```
|
||||||
|
|
||||||
|
保持该进程运行(另开终端继续下面步骤)。
|
||||||
|
|
||||||
|
### 3.2 验证 OKX 走 SOCKS 可用
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -4 -Iv --max-time 15 --proxy socks5h://127.0.0.1:1080 https://www.okx.com/api/v5/public/time
|
||||||
|
```
|
||||||
|
|
||||||
|
看到 `HTTP/2 200`(或至少 TLS 握手成功且返回 JSON)即 OK。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Python 虚拟环境(venv)
|
||||||
|
|
||||||
|
在本机项目目录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/crypto_monitor/crypto_monitor_okx
|
||||||
|
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
|
||||||
|
python -m pip install -U pip
|
||||||
|
pip install flask requests ccxt werkzeug PySocks Pillow
|
||||||
|
```
|
||||||
|
|
||||||
|
> 说明:本仓库当前没有 `requirements.txt`。如果你希望“完全复刻云服务器依赖”,可以在云服务器项目环境里执行 `pip freeze > requirements.txt` 带回本机再 `pip install -r requirements.txt`(记得删掉明显无关/体积巨大的包)。
|
||||||
|
|
||||||
|
建议减少 `.pyc` 垃圾文件(可选):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PYTHONDONTWRITEBYTECODE=1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 配置环境变量(`.env.example` → `.env`)
|
||||||
|
|
||||||
|
| 文件 | 是否进 Git | 说明 |
|
||||||
|
|------|------------|------|
|
||||||
|
| **`.env.example`** | ✅ 是 | 变量模板与注释,可随 `git pull` 更新 |
|
||||||
|
| **`.env`** | ❌ 否 | 本机真实配置;`app.py` **只读此文件** |
|
||||||
|
|
||||||
|
### 5.1 首次配置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/crypto_monitor/crypto_monitor_okx
|
||||||
|
|
||||||
|
cp -n .env.example .env # 已存在 .env 时不覆盖
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 备份与 `git pull`
|
||||||
|
|
||||||
|
- **`.env` 不在 Git 中**:`git pull` **不会**覆盖本地 `.env`。
|
||||||
|
- 远端若更新 **`.env.example`**,pull 后请**手动**把新增变量补进你的 `.env`。
|
||||||
|
- **升级前备份**:`cp .env .env.backup.$(date +%Y%m%d)`;恢复:`cp .env.backup.YYYYMMDD .env`。
|
||||||
|
- **换机**:`scp` 复制 `.env`,或新机 `cp .env.example .env` 后重填。
|
||||||
|
|
||||||
|
### 5.3 必填项检查(OKX + 代理)
|
||||||
|
|
||||||
|
至少确认/填写这些关键项(示例):
|
||||||
|
|
||||||
|
```env
|
||||||
|
APP_HOST=127.0.0.1
|
||||||
|
APP_PORT=5000
|
||||||
|
|
||||||
|
# OKX(如需实盘)
|
||||||
|
LIVE_TRADING_ENABLED=false
|
||||||
|
OKX_API_KEY=...
|
||||||
|
OKX_API_SECRET=...
|
||||||
|
OKX_API_PASSPHRASE=...
|
||||||
|
|
||||||
|
# OKX 出口:走本机 SSH 动态转发 SOCKS
|
||||||
|
OKX_SOCKS_PROXY=socks5h://127.0.0.1:1080
|
||||||
|
|
||||||
|
# 开仓多周期K线图(可选)
|
||||||
|
# ORDER_CHART_ENABLED=true
|
||||||
|
# ORDER_CHART_TFS=4h,1h,15m,5m
|
||||||
|
# ORDER_CHART_LIMIT=100
|
||||||
|
# ORDER_CHART_DIR=static/images/order_charts
|
||||||
|
# DAILY_OPEN_ALERT_THRESHOLD=5
|
||||||
|
|
||||||
|
# Ollama(如本机跑)
|
||||||
|
OLLAMA_API=http://127.0.0.1:11434/api/generate
|
||||||
|
AI_MODEL=你的模型名
|
||||||
|
```
|
||||||
|
|
||||||
|
> `OKX_SOCKS_PROXY` 使用 `socks5h`:让 SOCKS 侧做域名解析(更贴近你 `curl --proxy socks5h://...` 的成功路径)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 本机手工启动(验证 Flask)
|
||||||
|
|
||||||
|
确保:
|
||||||
|
|
||||||
|
1. SOCKS 隧道已运行(127.0.0.1:1080)
|
||||||
|
2. 虚拟环境已 `activate`
|
||||||
|
3. `.env` 已配置
|
||||||
|
|
||||||
|
启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/crypto_monitor/crypto_monitor_okx
|
||||||
|
source .venv/bin/activate
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器访问:`http://127.0.0.1:5000`(或你在 `.env` 配的端口)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 安装 PM2(Node)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo npm i -g pm2
|
||||||
|
pm2 -v
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 用 PM2 启动 SSH SOCKS 隧道(推荐:密钥免交互)
|
||||||
|
|
||||||
|
### 8.1 启动隧道进程
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 start "ssh" --name okx-socks-tunnel -- \
|
||||||
|
-N -D 127.0.0.1:1080 okx-vps \
|
||||||
|
-o ServerAliveInterval=30 -o ServerAliveCountMax=3 \
|
||||||
|
-o ExitOnForwardFailure=yes -o BatchMode=yes
|
||||||
|
```
|
||||||
|
|
||||||
|
查看日志:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 logs okx-socks-tunnel --lines 200
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 仍然验证 OKX
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -4 -Iv --max-time 15 --proxy socks5h://127.0.0.1:1080 https://www.okx.com/api/v5/public/time
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 开机自启(可选)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 save
|
||||||
|
pm2 startup
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 用 PM2 启动 Flask(`app.py`)
|
||||||
|
|
||||||
|
`pm2` 管理 Python 的常用方式是直接启动解释器:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/crypto_monitor/crypto_monitor_okx
|
||||||
|
|
||||||
|
pm2 start /opt/crypto_monitor/crypto_monitor_okx/.venv/bin/python --name crypto-monitor -- \
|
||||||
|
/opt/crypto_monitor/crypto_monitor_okx/app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
> 若项目目录与上文不一致,请替换为实际绝对路径;或用 `readlink -f app.py` 得到绝对路径。
|
||||||
|
|
||||||
|
查看日志:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 logs crypto-monitor --lines 200
|
||||||
|
```
|
||||||
|
|
||||||
|
保存进程列表:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 save
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 常见问题排查(高频)
|
||||||
|
|
||||||
|
### 10.1 OKX 仍然失败:先看隧道是否在
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ss -lntp | grep 1080 || true
|
||||||
|
pm2 status
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 `pm2` 里的 `ssh` 立刻退出
|
||||||
|
|
||||||
|
常见原因:
|
||||||
|
|
||||||
|
- 私钥权限不对(`chmod 600`)
|
||||||
|
- `~/.ssh/config` 写错 `HostName/User/IdentityFile`
|
||||||
|
- 开了 `BatchMode yes` 但仍需要密码(会失败)
|
||||||
|
|
||||||
|
### 10.3 `ccxt` SOCKS 报错 / 代理不生效
|
||||||
|
|
||||||
|
本机 Python 依赖通常需要:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install PySocks
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.4 `.pyc` 很多导致同步慢
|
||||||
|
|
||||||
|
`.pyc` 是缓存,删除不影响功能:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
find . -type d -name __pycache__ -prune -exec rm -rf {} +
|
||||||
|
find . -type f -name "*.pyc" -delete
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 推荐的启动顺序(固定习惯)
|
||||||
|
|
||||||
|
1. `pm2` 启动 `okx-socks-tunnel`
|
||||||
|
2. `curl --proxy socks5h://127.0.0.1:1080 ...` 验证 OKX
|
||||||
|
3. `pm2` 启动 `crypto-monitor`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 免责声明
|
||||||
|
|
||||||
|
交易所有合规与地区政策要求。请确保你的使用方式符合当地法律法规与交易所条款。本文仅描述网络与工程部署技术路径。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
写好了,脚本路径:
|
||||||
|
|
||||||
|
- `scripts/fix_breakeven_labels.py`
|
||||||
|
|
||||||
|
你在 Ubuntu 上这样用:
|
||||||
|
|
||||||
|
1) 先预览(不写库):
|
||||||
|
```bash
|
||||||
|
python scripts/fix_breakeven_labels.py --db ./crypto.db --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
2) 确认后执行:
|
||||||
|
```bash
|
||||||
|
python scripts/fix_breakeven_labels.py --db ./crypto.db --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
默认修复条件就是你要的:
|
||||||
|
- `monitor_type='下单监控'`
|
||||||
|
- `result='止损'`
|
||||||
|
- `pnl_amount > 0`
|
||||||
|
- 改成 `result='保本止盈'`
|
||||||
|
|
||||||
|
如果你想,我还可以再给你一条“先自动备份 DB 再执行”的一键命令。
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# 环境一键部署
|
||||||
|
|
||||||
|
为仓库内各子项目创建 Python 虚拟环境、安装依赖、初始化 `.env` 与静态目录。
|
||||||
|
|
||||||
|
## Windows(推荐)
|
||||||
|
|
||||||
|
双击仓库根目录 **`一键部署.bat`**,或在 PowerShell 中:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd C:\path\to\crypto_monitor
|
||||||
|
.\deploy\setup_env.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
仅部署部分项目:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\deploy\setup_env.ps1 -Only binance,gate_bot
|
||||||
|
```
|
||||||
|
|
||||||
|
重建虚拟环境:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\deploy\setup_env.ps1 -RecreateVenv
|
||||||
|
```
|
||||||
|
|
||||||
|
跳过 PM2、跳过复制 `.env`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\deploy\setup_env.ps1 -SkipPm2 -SkipEnvCopy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Linux / macOS
|
||||||
|
|
||||||
|
**Ubuntu / Debian 首次部署**(若 `python -m venv` 报 `ensurepip is not available`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apt update
|
||||||
|
apt install -y python3.10-venv python3-pip curl # 版本号与 python3 --version 一致
|
||||||
|
bash deploy/setup_env.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本在 **root** 下会自动尝试 `apt install python*-venv`;非 root 请先装系统包或使用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash deploy/setup_env.sh --install-system-deps
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/crypto_monitor
|
||||||
|
bash deploy/setup_env.sh
|
||||||
|
bash deploy/setup_env.sh --only binance,gate
|
||||||
|
bash deploy/setup_env.sh --recreate-venv
|
||||||
|
```
|
||||||
|
|
||||||
|
若在 Windows 编辑过脚本后在 Linux 报错 `set: pipefail: invalid option name`,先去掉 CRLF 再执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sed -i 's/\r$//' deploy/setup_env.sh
|
||||||
|
# 或: apt install -y dos2unix && dos2unix deploy/setup_env.sh
|
||||||
|
bash deploy/setup_env.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## 脚本会做什么
|
||||||
|
|
||||||
|
| 步骤 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 检查 Python | 需要 **3.10+** |
|
||||||
|
| `crypto_monitor_*` | 各目录 `.venv` + `pip install -r ../requirements.txt` |
|
||||||
|
| `manual_trading_hub` | 独立 `requirements.txt` |
|
||||||
|
| `.env` | 若不存在则从 `.env.example` 复制(**不覆盖**已有) |
|
||||||
|
| 目录 | 创建 `static/images`、`static/images/order_charts` |
|
||||||
|
| PM2 | 若已装 Node.js 且未 `-SkipPm2`,尝试 `npm install -g pm2` |
|
||||||
|
|
||||||
|
## 部署之后
|
||||||
|
|
||||||
|
1. 编辑各子目录 **`.env`**(API、登录密码、SOCKS 代理等)。
|
||||||
|
2. 本地试运行(以 Binance 为例):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd crypto_monitor_binance
|
||||||
|
source .venv/bin/activate # Windows: .\.venv\Scripts\activate
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 服务器长期运行见各目录 **《部署文档.md》**(SSH SOCKS、PM2)。
|
||||||
|
|
||||||
|
## 依赖说明
|
||||||
|
|
||||||
|
- 四个监控子项目共用仓库根目录 **[requirements.txt](../requirements.txt)**。
|
||||||
|
- 走 SOCKS 代理时必须安装 **PySocks**(已包含在 requirements 中)。
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
#Requires -Version 5.1
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
crypto_monitor 一键环境部署(Windows PowerShell)
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
- 为各子项目创建 Python venv 并安装依赖
|
||||||
|
- 从 .env.example 复制 .env(不覆盖已有)
|
||||||
|
- 创建 static/images 等运行时目录
|
||||||
|
- 可选安装 PM2(需已安装 Node.js)
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\deploy\setup_env.ps1
|
||||||
|
.\deploy\setup_env.ps1 -Only binance,gate_bot
|
||||||
|
.\deploy\setup_env.ps1 -SkipPm2
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[string]$Only = "all",
|
||||||
|
[switch]$SkipPm2,
|
||||||
|
[switch]$SkipEnvCopy,
|
||||||
|
[switch]$RecreateVenv
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
|
|
||||||
|
$DeployDir = $PSScriptRoot
|
||||||
|
$RepoRoot = (Resolve-Path (Join-Path $DeployDir "..")).Path
|
||||||
|
$ReqFile = Join-Path $RepoRoot "requirements.txt"
|
||||||
|
$HubReqFile = Join-Path $RepoRoot "manual_trading_hub\requirements.txt"
|
||||||
|
|
||||||
|
$MonitorProjects = @(
|
||||||
|
@{ Key = "binance"; Dir = "crypto_monitor_binance" },
|
||||||
|
@{ Key = "gate"; Dir = "crypto_monitor_gate" },
|
||||||
|
@{ Key = "gate_bot"; Dir = "crypto_monitor_gate_bot" },
|
||||||
|
@{ Key = "okx"; Dir = "crypto_monitor_okx" }
|
||||||
|
)
|
||||||
|
$HubProject = @{ Key = "hub"; Dir = "manual_trading_hub" }
|
||||||
|
|
||||||
|
function Write-Step([string]$Msg) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "==> $Msg" -ForegroundColor Cyan
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-Python310 {
|
||||||
|
$py = Get-Command python -ErrorAction SilentlyContinue
|
||||||
|
if (-not $py) {
|
||||||
|
throw "未找到 python。请安装 Python 3.10+ 并加入 PATH:https://www.python.org/downloads/"
|
||||||
|
}
|
||||||
|
$verText = & python -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')"
|
||||||
|
$parts = $verText.Trim() -split "\."
|
||||||
|
$major = [int]$parts[0]
|
||||||
|
$minor = [int]$parts[1]
|
||||||
|
if ($major -lt 3 -or ($major -eq 3 -and $minor -lt 10)) {
|
||||||
|
throw "需要 Python 3.10+,当前: $verText"
|
||||||
|
}
|
||||||
|
Write-Host "Python: $(python --version 2>&1)" -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
|
||||||
|
function Should-Include([string]$Key, [string[]]$Selected) {
|
||||||
|
if ($Selected -contains "all") { return $true }
|
||||||
|
return $Selected -contains $Key
|
||||||
|
}
|
||||||
|
|
||||||
|
function Setup-MonitorProject([hashtable]$Proj) {
|
||||||
|
$projPath = Join-Path $RepoRoot $Proj.Dir
|
||||||
|
if (-not (Test-Path $projPath)) {
|
||||||
|
Write-Host " 跳过(目录不存在): $($Proj.Dir)" -ForegroundColor Yellow
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Write-Step "$($Proj.Dir)"
|
||||||
|
Push-Location $projPath
|
||||||
|
try {
|
||||||
|
$venvDir = Join-Path $projPath ".venv"
|
||||||
|
$venvPy = Join-Path $venvDir "Scripts\python.exe"
|
||||||
|
$venvPip = Join-Path $venvDir "Scripts\pip.exe"
|
||||||
|
|
||||||
|
if ($RecreateVenv -and (Test-Path $venvDir)) {
|
||||||
|
Write-Host " 删除旧 venv ..."
|
||||||
|
Remove-Item -Recurse -Force $venvDir
|
||||||
|
}
|
||||||
|
if (-not (Test-Path $venvPy)) {
|
||||||
|
Write-Host " 创建 venv ..."
|
||||||
|
& python -m venv .venv
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host " 升级 pip ..."
|
||||||
|
& $venvPy -m pip install -U pip setuptools wheel -q
|
||||||
|
|
||||||
|
Write-Host " 安装依赖 (requirements.txt) ..."
|
||||||
|
& $venvPip install -r $ReqFile -q
|
||||||
|
|
||||||
|
if (-not $SkipEnvCopy) {
|
||||||
|
$envExample = Join-Path $projPath ".env.example"
|
||||||
|
$envFile = Join-Path $projPath ".env"
|
||||||
|
if ((Test-Path $envExample) -and -not (Test-Path $envFile)) {
|
||||||
|
Copy-Item $envExample $envFile
|
||||||
|
Write-Host " 已复制 .env.example -> .env" -ForegroundColor Green
|
||||||
|
} elseif (Test-Path $envFile) {
|
||||||
|
Write-Host " 保留已有 .env" -ForegroundColor DarkGray
|
||||||
|
} else {
|
||||||
|
Write-Host " 无 .env.example,请手动配置 .env" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$staticDirs = @(
|
||||||
|
"static\images",
|
||||||
|
"static\images\order_charts"
|
||||||
|
)
|
||||||
|
foreach ($d in $staticDirs) {
|
||||||
|
$full = Join-Path $projPath $d
|
||||||
|
if (-not (Test-Path $full)) {
|
||||||
|
New-Item -ItemType Directory -Path $full -Force | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Write-Host " 完成: $venvPy" -ForegroundColor Green
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Setup-HubProject() {
|
||||||
|
$projPath = Join-Path $RepoRoot $HubProject.Dir
|
||||||
|
if (-not (Test-Path $projPath)) {
|
||||||
|
Write-Host " 跳过 hub(目录不存在)" -ForegroundColor Yellow
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Write-Step $HubProject.Dir
|
||||||
|
Push-Location $projPath
|
||||||
|
try {
|
||||||
|
$venvDir = Join-Path $projPath ".venv"
|
||||||
|
$venvPy = Join-Path $venvDir "Scripts\python.exe"
|
||||||
|
$venvPip = Join-Path $venvDir "Scripts\pip.exe"
|
||||||
|
|
||||||
|
if ($RecreateVenv -and (Test-Path $venvDir)) {
|
||||||
|
Remove-Item -Recurse -Force $venvDir
|
||||||
|
}
|
||||||
|
if (-not (Test-Path $venvPy)) {
|
||||||
|
& python -m venv .venv
|
||||||
|
}
|
||||||
|
& $venvPy -m pip install -U pip setuptools wheel -q
|
||||||
|
if (Test-Path $HubReqFile) {
|
||||||
|
& $venvPip install -r $HubReqFile -q
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $SkipEnvCopy) {
|
||||||
|
$envExample = Join-Path $projPath ".env.example"
|
||||||
|
$envFile = Join-Path $projPath ".env"
|
||||||
|
if ((Test-Path $envExample) -and -not (Test-Path $envFile)) {
|
||||||
|
Copy-Item $envExample $envFile
|
||||||
|
Write-Host " 已复制 .env.example -> .env" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Write-Host " 完成: $venvPy" -ForegroundColor Green
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Install-Pm2IfNeeded() {
|
||||||
|
if ($SkipPm2) { return }
|
||||||
|
$node = Get-Command node -ErrorAction SilentlyContinue
|
||||||
|
if (-not $node) {
|
||||||
|
Write-Host "未检测到 Node.js,跳过 PM2。安装 Node 后执行: npm install -g pm2" -ForegroundColor Yellow
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Write-Step "PM2(可选进程托管)"
|
||||||
|
$pm2 = Get-Command pm2 -ErrorAction SilentlyContinue
|
||||||
|
if ($pm2) {
|
||||||
|
Write-Host " PM2 已安装: $(pm2 -v)" -ForegroundColor Green
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Write-Host " 正在全局安装 pm2 ..."
|
||||||
|
& npm install -g pm2
|
||||||
|
Write-Host " PM2 安装完成。在各子目录执行: pm2 start ecosystem.config.cjs" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- main ---
|
||||||
|
Write-Host "crypto_monitor 环境部署" -ForegroundColor White
|
||||||
|
Write-Host "仓库根目录: $RepoRoot" -ForegroundColor DarkGray
|
||||||
|
|
||||||
|
if (-not (Test-Path $ReqFile)) {
|
||||||
|
throw "缺少 $ReqFile"
|
||||||
|
}
|
||||||
|
|
||||||
|
Test-Python310
|
||||||
|
|
||||||
|
$selected = ($Only -split "[,;\s]+" | ForEach-Object { $_.Trim().ToLower() } | Where-Object { $_ })
|
||||||
|
if (-not $selected -or $selected.Count -eq 0) { $selected = @("all") }
|
||||||
|
|
||||||
|
foreach ($p in $MonitorProjects) {
|
||||||
|
if (Should-Include $p.Key $selected) {
|
||||||
|
Setup-MonitorProject $p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Should-Include $HubProject.Key $selected) {
|
||||||
|
Setup-HubProject
|
||||||
|
}
|
||||||
|
|
||||||
|
Install-Pm2IfNeeded
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "部署完成。下一步:" -ForegroundColor Green
|
||||||
|
Write-Host " 1. 编辑各子目录 .env(API Key、密码、代理等)"
|
||||||
|
Write-Host " 2. 启动示例(Binance):"
|
||||||
|
Write-Host " cd crypto_monitor_binance"
|
||||||
|
Write-Host " .\.venv\Scripts\activate"
|
||||||
|
Write-Host " python app.py"
|
||||||
|
Write-Host " 3. Linux 服务器可用: bash deploy/setup_env.sh"
|
||||||
|
Write-Host ""
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# crypto_monitor 一键环境部署(Linux / macOS / Git Bash)
|
||||||
|
#
|
||||||
|
# 用法:
|
||||||
|
# bash deploy/setup_env.sh
|
||||||
|
# bash deploy/setup_env.sh --only binance,gate_bot
|
||||||
|
# bash deploy/setup_env.sh --skip-pm2
|
||||||
|
# bash deploy/setup_env.sh --recreate-venv
|
||||||
|
# bash deploy/setup_env.sh --install-system-deps # root + apt 时安装 python*-venv
|
||||||
|
#
|
||||||
|
set -e
|
||||||
|
set -u
|
||||||
|
# 避免 Windows CRLF 导致 set -euo pipefail 一行报错;pipefail 仅 bash 支持
|
||||||
|
if [ -n "${BASH_VERSION:-}" ]; then
|
||||||
|
set -o pipefail
|
||||||
|
fi
|
||||||
|
|
||||||
|
DEPLOY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "${DEPLOY_DIR}/.." && pwd)"
|
||||||
|
REQ_FILE="${REPO_ROOT}/requirements.txt"
|
||||||
|
HUB_REQ="${REPO_ROOT}/manual_trading_hub/requirements.txt"
|
||||||
|
|
||||||
|
ONLY="all"
|
||||||
|
SKIP_PM2=0
|
||||||
|
SKIP_ENV_COPY=0
|
||||||
|
RECREATE_VENV=0
|
||||||
|
INSTALL_APT_DEPS=0
|
||||||
|
PY=""
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
sed -n '2,12p' "$0" | sed 's/^# \?//'
|
||||||
|
exit "${1:-0}"
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--only) ONLY="${2:-all}"; shift 2 ;;
|
||||||
|
--skip-pm2) SKIP_PM2=1; shift ;;
|
||||||
|
--skip-env-copy) SKIP_ENV_COPY=1; shift ;;
|
||||||
|
--recreate-venv) RECREATE_VENV=1; shift ;;
|
||||||
|
--install-system-deps) INSTALL_APT_DEPS=1; shift ;;
|
||||||
|
-h|--help) usage 0 ;;
|
||||||
|
*) echo "未知参数: $1" >&2; usage 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
step() { echo ""; echo "==> $*"; }
|
||||||
|
|
||||||
|
should_include() {
|
||||||
|
local key="$1"
|
||||||
|
if [[ "${ONLY}" == "all" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
local item
|
||||||
|
IFS=',' read -ra PARTS <<< "${ONLY}"
|
||||||
|
for item in "${PARTS[@]}"; do
|
||||||
|
item="$(echo "${item}" | tr '[:upper:]' '[:lower:]' | xargs)"
|
||||||
|
[[ "${item}" == "${key}" ]] && return 0
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
find_python() {
|
||||||
|
if command -v python3 >/dev/null 2>&1; then
|
||||||
|
echo python3
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if command -v python >/dev/null 2>&1; then
|
||||||
|
echo python
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
echo "未找到 python3/python,请先安装 Python 3.10+" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
check_python_version() {
|
||||||
|
local py="$1"
|
||||||
|
local ver
|
||||||
|
ver="$("${py}" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')"
|
||||||
|
local major minor
|
||||||
|
major="${ver%%.*}"
|
||||||
|
minor="${ver#*.}"
|
||||||
|
if [[ "${major}" -lt 3 ]] || [[ "${major}" -eq 3 && "${minor}" -lt 10 ]]; then
|
||||||
|
echo "需要 Python 3.10+,当前: ${ver}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Python: $("${py}" --version 2>&1)"
|
||||||
|
}
|
||||||
|
|
||||||
|
python_minor_version() {
|
||||||
|
local py="$1"
|
||||||
|
"${py}" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")'
|
||||||
|
}
|
||||||
|
|
||||||
|
check_venv_available() {
|
||||||
|
local py="$1"
|
||||||
|
local tmp
|
||||||
|
tmp="$(mktemp -d 2>/dev/null || mktemp -d -t cmvenv)"
|
||||||
|
if "${py}" -m venv "${tmp}" >/dev/null 2>&1 && [[ -x "${tmp}/bin/python" ]]; then
|
||||||
|
rm -rf "${tmp}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
rm -rf "${tmp}" 2>/dev/null || true
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
install_debian_venv_packages() {
|
||||||
|
local py="$1"
|
||||||
|
local ver
|
||||||
|
ver="$(python_minor_version "${py}")"
|
||||||
|
if ! command -v apt-get >/dev/null 2>&1; then
|
||||||
|
echo " 未检测到 apt-get,请手动安装 python${ver}-venv 与 python3-pip" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ "$(id -u)" -ne 0 ]]; then
|
||||||
|
echo " 需要 root 安装系统包,请执行:" >&2
|
||||||
|
echo " sudo apt update && sudo apt install -y python${ver}-venv python3-pip curl" >&2
|
||||||
|
echo " 或: sudo bash deploy/setup_env.sh --install-system-deps" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
step "安装系统依赖 (python${ver}-venv) ..."
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
apt-get update -qq
|
||||||
|
if ! apt-get install -y "python${ver}-venv" python3-pip curl ca-certificates; then
|
||||||
|
apt-get install -y python3-venv python3-pip curl ca-certificates
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_venv_prereqs() {
|
||||||
|
local py="$1"
|
||||||
|
if check_venv_available "${py}"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo " 当前 Python 无法创建 venv(缺少 ensurepip,常见于未安装 python*-venv)" >&2
|
||||||
|
if [[ "${INSTALL_APT_DEPS}" -eq 1 ]] || [[ "$(id -u)" -eq 0 ]]; then
|
||||||
|
install_debian_venv_packages "${py}" || exit 1
|
||||||
|
if check_venv_available "${py}"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
local ver
|
||||||
|
ver="$(python_minor_version "${py}")"
|
||||||
|
echo "请安装后重试:" >&2
|
||||||
|
echo " apt update && apt install -y python${ver}-venv python3-pip" >&2
|
||||||
|
echo " bash deploy/setup_env.sh" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
create_project_venv() {
|
||||||
|
local py="$1"
|
||||||
|
if [[ "${RECREATE_VENV}" -eq 1 && -d .venv ]]; then
|
||||||
|
echo " 删除旧 venv ..."
|
||||||
|
rm -rf .venv
|
||||||
|
fi
|
||||||
|
if [[ -d .venv && ! -x .venv/bin/python ]]; then
|
||||||
|
echo " 清理未完成的 venv ..."
|
||||||
|
rm -rf .venv
|
||||||
|
fi
|
||||||
|
if [[ -x .venv/bin/python ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo " 创建 venv ..."
|
||||||
|
if ! "${py}" -m venv .venv; then
|
||||||
|
rm -rf .venv 2>/dev/null || true
|
||||||
|
echo " venv 创建失败" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_monitor() {
|
||||||
|
local dir_name="$1"
|
||||||
|
local proj="${REPO_ROOT}/${dir_name}"
|
||||||
|
if [[ ! -d "${proj}" ]]; then
|
||||||
|
echo " 跳过(目录不存在): ${dir_name}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
step "${dir_name}"
|
||||||
|
cd "${proj}"
|
||||||
|
create_project_venv "${PY}"
|
||||||
|
echo " 升级 pip ..."
|
||||||
|
.venv/bin/python -m pip install -U pip setuptools wheel -q
|
||||||
|
echo " 安装依赖 ..."
|
||||||
|
.venv/bin/pip install -r "${REQ_FILE}" -q
|
||||||
|
if [[ "${SKIP_ENV_COPY}" -eq 0 ]]; then
|
||||||
|
if [[ -f .env.example && ! -f .env ]]; then
|
||||||
|
cp -n .env.example .env 2>/dev/null || cp .env.example .env
|
||||||
|
echo " 已复制 .env.example -> .env"
|
||||||
|
elif [[ -f .env ]]; then
|
||||||
|
echo " 保留已有 .env"
|
||||||
|
else
|
||||||
|
echo " 无 .env.example,请手动配置 .env"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
mkdir -p static/images/order_charts
|
||||||
|
echo " 完成: ${proj}/.venv/bin/python"
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_hub() {
|
||||||
|
local proj="${REPO_ROOT}/manual_trading_hub"
|
||||||
|
if [[ ! -d "${proj}" ]]; then
|
||||||
|
echo " 跳过 hub(目录不存在)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
step "manual_trading_hub"
|
||||||
|
cd "${proj}"
|
||||||
|
create_project_venv "${PY}"
|
||||||
|
.venv/bin/python -m pip install -U pip setuptools wheel -q
|
||||||
|
if [[ -f "${HUB_REQ}" ]]; then
|
||||||
|
.venv/bin/pip install -r "${HUB_REQ}" -q
|
||||||
|
fi
|
||||||
|
if [[ "${SKIP_ENV_COPY}" -eq 0 && -f .env.example && ! -f .env ]]; then
|
||||||
|
cp -n .env.example .env 2>/dev/null || cp .env.example .env
|
||||||
|
echo " 已复制 .env.example -> .env"
|
||||||
|
fi
|
||||||
|
echo " 完成: ${proj}/.venv/bin/python"
|
||||||
|
}
|
||||||
|
|
||||||
|
install_pm2() {
|
||||||
|
if [[ "${SKIP_PM2}" -eq 1 ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
step "PM2(可选)"
|
||||||
|
if ! command -v node >/dev/null 2>&1; then
|
||||||
|
echo " 未检测到 Node.js,跳过。安装后执行: npm install -g pm2"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if command -v pm2 >/dev/null 2>&1; then
|
||||||
|
echo " PM2 已安装: $(pm2 -v)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
echo " 正在安装 pm2 ..."
|
||||||
|
npm install -g pm2
|
||||||
|
echo " 各子目录: pm2 start ecosystem.config.cjs"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "crypto_monitor 环境部署"
|
||||||
|
echo "仓库根目录: ${REPO_ROOT}"
|
||||||
|
|
||||||
|
[[ -f "${REQ_FILE}" ]] || { echo "缺少 ${REQ_FILE}" >&2; exit 1; }
|
||||||
|
|
||||||
|
PY="$(find_python)"
|
||||||
|
check_python_version "${PY}"
|
||||||
|
ensure_venv_prereqs "${PY}"
|
||||||
|
|
||||||
|
should_include binance && setup_monitor crypto_monitor_binance
|
||||||
|
should_include gate && setup_monitor crypto_monitor_gate
|
||||||
|
should_include gate_bot && setup_monitor crypto_monitor_gate_bot
|
||||||
|
should_include okx && setup_monitor crypto_monitor_okx
|
||||||
|
should_include hub && setup_hub
|
||||||
|
|
||||||
|
install_pm2
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "部署完成。下一步:"
|
||||||
|
echo " 1. 编辑各子目录 .env"
|
||||||
|
echo " 2. 启动示例: cd crypto_monitor_binance && source .venv/bin/activate && python app.py"
|
||||||
|
echo " 3. Windows 可用: powershell -File deploy/setup_env.ps1"
|
||||||
|
echo ""
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# 授权云端 API 约定(独立服务实现)
|
||||||
|
|
||||||
|
用户端仓库仅包含 `license_lib.py`;签发 Web 与数据库部署在**独立项目**。
|
||||||
|
|
||||||
|
## 环境变量(用户端 `.env`)
|
||||||
|
|
||||||
|
| 变量 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `LICENSE_API_URL` | 云端根地址,如 `https://license.example.com` |
|
||||||
|
| `LICENSE_CLIENT_KEY` | 客户端密钥,请求头 `X-Client-Key` |
|
||||||
|
| `LICENSE_CHECK_INTERVAL_DAYS` | 校验间隔,默认 `3` |
|
||||||
|
| `LICENSE_OFFLINE_GRACE_DAYS` | 断网宽限,默认 `7` |
|
||||||
|
| `LICENSE_DISABLED` | `true` 时关闭许可(仅开发) |
|
||||||
|
| `LICENSE_WECHAT_ID` | 默认 `dekun03` |
|
||||||
|
| `LICENSE_WECHAT_REMARK` | 可选;未设则提示用户用设备 ID 作备注 |
|
||||||
|
|
||||||
|
本地缓存:仓库根目录 `.license/license.cache`(勿提交 Git)。
|
||||||
|
|
||||||
|
## POST /v1/redeem
|
||||||
|
|
||||||
|
请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"device_id": "32位十六进制",
|
||||||
|
"code": "一次性激活码"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
成功响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"subscription_id": "sub_xxx",
|
||||||
|
"plan": "monthly",
|
||||||
|
"expires_at": "2026-06-21T23:59:59+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
续费叠加(服务端实现):
|
||||||
|
|
||||||
|
```text
|
||||||
|
base = max(now, current_expires_at)
|
||||||
|
expires_at = base + days(plan) # monthly 30 / quarterly 90 / yearly 365
|
||||||
|
```
|
||||||
|
|
||||||
|
失败:`ok: false`,`message` 说明原因。
|
||||||
|
|
||||||
|
## POST /v1/validate
|
||||||
|
|
||||||
|
请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"device_id": "...",
|
||||||
|
"subscription_id": "sub_xxx"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
成功且有效:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"valid": true,
|
||||||
|
"expires_at": "2026-06-21T23:59:59+08:00",
|
||||||
|
"plan": "monthly"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
无效(换机、过期等):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"valid": false,
|
||||||
|
"reason": "device_revoked"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 套餐定价(商务参考)
|
||||||
|
|
||||||
|
| plan | 售价 | 天数 |
|
||||||
|
|------|------|------|
|
||||||
|
| monthly | 199 | 30 |
|
||||||
|
| quarterly | 399 | 90 |
|
||||||
|
| yearly | 699 | 365 |
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""斐波关键位监控:纯计算与类型判断(Gate / Binance 主站共用)。"""
|
||||||
|
|
||||||
|
FIB_KEY_MONITOR_TYPES = frozenset({"斐波回调0.618", "斐波回调0.786"})
|
||||||
|
|
||||||
|
FIB_RATIO_BY_TYPE = {
|
||||||
|
"斐波回调0.618": 0.618,
|
||||||
|
"斐波回调0.786": 0.786,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_fib_key_monitor_type(monitor_type):
|
||||||
|
return (monitor_type or "").strip() in FIB_KEY_MONITOR_TYPES
|
||||||
|
|
||||||
|
|
||||||
|
def fib_ratio_from_type(monitor_type):
|
||||||
|
return FIB_RATIO_BY_TYPE.get((monitor_type or "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
def calc_fib_plan(direction, upper, lower, ratio):
|
||||||
|
"""
|
||||||
|
上沿 H、下沿 L(H > L)。
|
||||||
|
做多:自 H 向下回撤 ratio,E = H - ratio*(H-L);SL=L,TP=H。
|
||||||
|
做空:自 L 向上反弹 ratio,E = L + ratio*(H-L);SL=H,TP=L。
|
||||||
|
返回 (entry, stop_loss, take_profit) 或 None。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
h = float(upper)
|
||||||
|
l = float(lower)
|
||||||
|
r = float(ratio)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if h <= l or r <= 0 or r >= 1:
|
||||||
|
return None
|
||||||
|
span = h - l
|
||||||
|
direction = (direction or "long").strip().lower()
|
||||||
|
if direction == "short":
|
||||||
|
entry = l + r * span
|
||||||
|
return entry, h, l
|
||||||
|
entry = h - r * span
|
||||||
|
return entry, l, h
|
||||||
|
|
||||||
|
|
||||||
|
def stored_key_signal_type(monitor_type):
|
||||||
|
"""写入 order_monitors / trade_records 的 key_signal_type(箱体/收敛/斐波)。"""
|
||||||
|
mt = (monitor_type or "").strip()
|
||||||
|
if mt in FIB_KEY_MONITOR_TYPES:
|
||||||
|
return mt
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
KEY_ENTRY_REASON_BY_SIGNAL = {
|
||||||
|
"箱体突破": "关键位箱体突破",
|
||||||
|
"收敛突破": "关键位收敛突破",
|
||||||
|
"斐波回调0.618": "关键位斐波0.618",
|
||||||
|
"斐波回调0.786": "关键位斐波0.786",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def entry_reason_from_key_signal(key_signal_type):
|
||||||
|
return KEY_ENTRY_REASON_BY_SIGNAL.get((key_signal_type or "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
def key_signal_type_for_trade_record(key_signal_type, box_auto_types):
|
||||||
|
"""平仓写入 trade_records 时保留箱体/收敛/斐波来源。"""
|
||||||
|
kst = (key_signal_type or "").strip()
|
||||||
|
if kst in FIB_KEY_MONITOR_TYPES:
|
||||||
|
return kst
|
||||||
|
if box_auto_types and kst in box_auto_types:
|
||||||
|
return kst
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def fib_invalidate_by_mark(direction, mark_price, upper, lower):
|
||||||
|
"""先触达止盈侧(标记价)则失效。多:mark>=H;空:mark<=L。"""
|
||||||
|
try:
|
||||||
|
m = float(mark_price)
|
||||||
|
h = float(upper)
|
||||||
|
l = float(lower)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
direction = (direction or "long").strip().lower()
|
||||||
|
if direction == "short":
|
||||||
|
return m <= l
|
||||||
|
return m >= h
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""列表/导出用 UTC 时间窗(Gate / Binance 主站共用)。"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
PRESET_UTC_TODAY = "utc_today"
|
||||||
|
PRESET_UTC_LAST24H = "utc_last24h"
|
||||||
|
PRESET_UTC_LAST7D = "utc_last7d"
|
||||||
|
PRESET_CUSTOM = "custom"
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def utc_today_bounds(now=None):
|
||||||
|
now = now or utc_now()
|
||||||
|
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
return start, now
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_window(query_mapping, default_preset=PRESET_UTC_TODAY):
|
||||||
|
"""
|
||||||
|
从 ?win_preset= & from_utc= & to_utc= 解析窗口。
|
||||||
|
返回 dict: preset, start_utc, end_utc, label, start_ms, end_ms
|
||||||
|
"""
|
||||||
|
preset = (query_mapping.get("win_preset") or default_preset or PRESET_UTC_TODAY).strip().lower()
|
||||||
|
now = utc_now()
|
||||||
|
|
||||||
|
if preset == PRESET_UTC_LAST24H:
|
||||||
|
start = now - timedelta(hours=24)
|
||||||
|
end = now
|
||||||
|
label = "近24小时(UTC)"
|
||||||
|
elif preset == PRESET_UTC_LAST7D:
|
||||||
|
start = now - timedelta(days=7)
|
||||||
|
end = now
|
||||||
|
label = "近7天(UTC)"
|
||||||
|
elif preset == PRESET_CUSTOM:
|
||||||
|
start = _parse_utc_input(query_mapping.get("from_utc")) or utc_today_bounds(now)[0]
|
||||||
|
end = _parse_utc_input(query_mapping.get("to_utc")) or now
|
||||||
|
if end < start:
|
||||||
|
start, end = end, start
|
||||||
|
label = f"{start.strftime('%Y-%m-%d %H:%M')} ~ {end.strftime('%Y-%m-%d %H:%M')} UTC"
|
||||||
|
else:
|
||||||
|
start, end = utc_today_bounds(now)
|
||||||
|
preset = PRESET_UTC_TODAY
|
||||||
|
label = f"UTC当日 {start.strftime('%Y-%m-%d')}"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"preset": preset,
|
||||||
|
"start_utc": start,
|
||||||
|
"end_utc": end,
|
||||||
|
"label": label,
|
||||||
|
"start_ms": int(start.timestamp() * 1000),
|
||||||
|
"end_ms": int(end.timestamp() * 1000),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_utc_input(raw):
|
||||||
|
s = (raw or "").strip().replace("T", " ").replace("Z", "").strip()
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
for fmt, n in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)):
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(s[:n], fmt)
|
||||||
|
return dt.replace(tzinfo=timezone.utc)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def utc_window_to_bj_sql_strings(start_utc, end_utc, app_tz):
|
||||||
|
"""DB 存北京时间字符串时,用于 SQLite 字符串范围比较。"""
|
||||||
|
start_bj = start_utc.astimezone(app_tz).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
end_bj = end_utc.astimezone(app_tz).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
return start_bj, end_bj
|
||||||
|
|
||||||
|
|
||||||
|
SESSION_KEY_LIST_WIN = "list_win_filter"
|
||||||
|
|
||||||
|
|
||||||
|
def query_mapping_from_session(session_store):
|
||||||
|
"""从 Flask session 恢复 win_preset / from_utc / to_utc。"""
|
||||||
|
if not session_store:
|
||||||
|
return {}
|
||||||
|
block = session_store.get(SESSION_KEY_LIST_WIN)
|
||||||
|
if not isinstance(block, dict):
|
||||||
|
return {}
|
||||||
|
preset = (block.get("preset") or "").strip()
|
||||||
|
if not preset:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
"win_preset": preset,
|
||||||
|
"from_utc": (block.get("from_utc") or "").strip(),
|
||||||
|
"to_utc": (block.get("to_utc") or "").strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_list_window(query_mapping, session_store=None, default_preset=PRESET_UTC_TODAY):
|
||||||
|
"""
|
||||||
|
URL 带 win_preset 时解析并写入 session;无参数时用 session 中上次「应用」的预设。
|
||||||
|
"""
|
||||||
|
qm = query_mapping or {}
|
||||||
|
preset_in_q = (qm.get("win_preset") or "").strip()
|
||||||
|
if preset_in_q:
|
||||||
|
win = resolve_window(qm, default_preset=default_preset)
|
||||||
|
if session_store is not None:
|
||||||
|
session_store[SESSION_KEY_LIST_WIN] = {
|
||||||
|
"preset": win["preset"],
|
||||||
|
"from_utc": (qm.get("from_utc") or "").strip(),
|
||||||
|
"to_utc": (qm.get("to_utc") or "").strip(),
|
||||||
|
}
|
||||||
|
return win
|
||||||
|
stored = query_mapping_from_session(session_store)
|
||||||
|
if stored.get("win_preset"):
|
||||||
|
return resolve_window(stored, default_preset=default_preset)
|
||||||
|
return resolve_window(qm, default_preset=default_preset)
|
||||||
|
|
||||||
|
|
||||||
|
def list_window_redirect_query(session_store):
|
||||||
|
"""复盘/表单 POST 后重定向时附带列表筛选 query。"""
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
stored = query_mapping_from_session(session_store)
|
||||||
|
if not stored.get("win_preset"):
|
||||||
|
return ""
|
||||||
|
params = {k: v for k, v in stored.items() if v}
|
||||||
|
return urlencode(params)
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""关键位箱体/收敛:止盈止损方案(Binance / Gate / OKX 共用)。"""
|
||||||
|
|
||||||
|
KEY_SL_TP_MODES = frozenset({"standard", "box_1p5", "trend_manual"})
|
||||||
|
|
||||||
|
KEY_SL_TP_MODE_LABELS = {
|
||||||
|
"standard": "标准突破",
|
||||||
|
"box_1p5": "箱体1R·止盈1.5H",
|
||||||
|
"trend_manual": "趋势单·自填止盈",
|
||||||
|
}
|
||||||
|
|
||||||
|
KEY_MONITOR_AUTO_TYPES_FOR_FORM = frozenset({"箱体突破", "收敛突破"})
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_sl_tp_mode(raw):
|
||||||
|
m = (raw or "standard").strip().lower()
|
||||||
|
if m in ("box_1p5", "box15", "box-1.5", "box_1.5"):
|
||||||
|
return "box_1p5"
|
||||||
|
if m in ("trend_manual", "trend", "manual"):
|
||||||
|
return "trend_manual"
|
||||||
|
if m in KEY_SL_TP_MODES:
|
||||||
|
return m
|
||||||
|
return "standard"
|
||||||
|
|
||||||
|
|
||||||
|
def sl_tp_mode_label(mode):
|
||||||
|
return KEY_SL_TP_MODE_LABELS.get(normalize_sl_tp_mode(mode), normalize_sl_tp_mode(mode))
|
||||||
|
|
||||||
|
|
||||||
|
def sl_tp_mode_from_row(row, default="standard"):
|
||||||
|
try:
|
||||||
|
if hasattr(row, "keys") and "sl_tp_mode" in row.keys():
|
||||||
|
raw = row["sl_tp_mode"]
|
||||||
|
else:
|
||||||
|
raw = row.get("sl_tp_mode") if isinstance(row, dict) else None
|
||||||
|
except Exception:
|
||||||
|
raw = None
|
||||||
|
return normalize_sl_tp_mode(raw if raw not in (None, "") else default)
|
||||||
|
|
||||||
|
|
||||||
|
def breakeven_enabled_from_row(row, default=0):
|
||||||
|
try:
|
||||||
|
if hasattr(row, "keys") and "breakeven_enabled" in row.keys():
|
||||||
|
v = row["breakeven_enabled"]
|
||||||
|
else:
|
||||||
|
v = row.get("breakeven_enabled") if isinstance(row, dict) else None
|
||||||
|
except Exception:
|
||||||
|
v = None
|
||||||
|
if v is None:
|
||||||
|
return int(default) != 0
|
||||||
|
return int(v) != 0
|
||||||
|
|
||||||
|
|
||||||
|
def parse_breakeven_enabled_form(form_value):
|
||||||
|
return 1 if (form_value or "").strip().lower() in ("1", "true", "on", "yes") else 0
|
||||||
|
|
||||||
|
|
||||||
|
def plan_key_sl_tp(
|
||||||
|
mode,
|
||||||
|
direction,
|
||||||
|
upper,
|
||||||
|
lower,
|
||||||
|
checks,
|
||||||
|
*,
|
||||||
|
outside_pct,
|
||||||
|
trend_outside_pct,
|
||||||
|
manual_take_profit=None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
以确认 K 收盘 E 为「当前价」计算计划 SL/TP。
|
||||||
|
返回 (E, sl_raw, tp_raw, box_h) 或 None(几何无效 / 模式3缺止盈)。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
E = float(checks["confirm_close"])
|
||||||
|
H = abs(float(upper) - float(lower))
|
||||||
|
except (TypeError, ValueError, KeyError):
|
||||||
|
return None
|
||||||
|
if H <= 0:
|
||||||
|
return None
|
||||||
|
direction = (direction or "long").strip().lower()
|
||||||
|
mode = normalize_sl_tp_mode(mode)
|
||||||
|
|
||||||
|
if mode == "box_1p5":
|
||||||
|
if direction == "long":
|
||||||
|
sl_raw = E - H
|
||||||
|
tp_raw = E + 1.5 * H
|
||||||
|
else:
|
||||||
|
sl_raw = E + H
|
||||||
|
tp_raw = E - 1.5 * H
|
||||||
|
return E, sl_raw, tp_raw, H
|
||||||
|
|
||||||
|
if mode == "trend_manual":
|
||||||
|
try:
|
||||||
|
br_hi = float(checks["breakout_high"])
|
||||||
|
br_lo = float(checks["breakout_low"])
|
||||||
|
tp_raw = float(manual_take_profit)
|
||||||
|
except (TypeError, ValueError, KeyError):
|
||||||
|
return None
|
||||||
|
m = float(trend_outside_pct) / 100.0
|
||||||
|
if direction == "long":
|
||||||
|
sl_raw = br_lo * (1.0 - m) if br_lo > 0 else 0.0
|
||||||
|
if tp_raw <= E or sl_raw <= 0:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
sl_raw = br_hi * (1.0 + m) if br_hi > 0 else 0.0
|
||||||
|
if tp_raw >= E or sl_raw <= 0:
|
||||||
|
return None
|
||||||
|
return E, sl_raw, tp_raw, H
|
||||||
|
|
||||||
|
# standard:突破 K 极值外侧 + 止盈 E±1×H
|
||||||
|
try:
|
||||||
|
br_hi = float(checks["breakout_high"])
|
||||||
|
br_lo = float(checks["breakout_low"])
|
||||||
|
except (TypeError, ValueError, KeyError):
|
||||||
|
return None
|
||||||
|
om = float(outside_pct) / 100.0
|
||||||
|
if direction == "long":
|
||||||
|
sl_raw = br_lo * (1.0 - om) if br_lo > 0 else 0.0
|
||||||
|
tp_raw = E + H
|
||||||
|
else:
|
||||||
|
sl_raw = br_hi * (1.0 + om) if br_hi > 0 else 0.0
|
||||||
|
tp_raw = E - H
|
||||||
|
return E, sl_raw, tp_raw, H
|
||||||
|
|
||||||
|
|
||||||
|
def sl_tp_plan_summary_text(mode, direction, E, sl_raw, tp_raw, box_h, *, outside_pct, trend_outside_pct):
|
||||||
|
"""微信/页面用一行计划 SL/TP 说明。"""
|
||||||
|
mode = normalize_sl_tp_mode(mode)
|
||||||
|
direction = (direction or "long").strip().lower()
|
||||||
|
if mode == "box_1p5":
|
||||||
|
return (
|
||||||
|
f"方案:{sl_tp_mode_label(mode)}|E={E}|SL=E∓1×H({box_h})|TP=E∓1.5×H"
|
||||||
|
)
|
||||||
|
if mode == "trend_manual":
|
||||||
|
return (
|
||||||
|
f"方案:{sl_tp_mode_label(mode)}|E={E}|SL=突破K极值外{trend_outside_pct}%|TP={tp_raw}(录入)"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"方案:{sl_tp_mode_label(mode)}|E={E}|SL=突破K外{outside_pct}%|TP=E±1×H({box_h})"
|
||||||
|
)
|
||||||
+437
@@ -0,0 +1,437 @@
|
|||||||
|
"""
|
||||||
|
整机许可客户端:设备 ID、激活码兑换、定期云端校验。
|
||||||
|
签发与管理 Web 为独立云端服务,不在本仓库内。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from functools import wraps
|
||||||
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
_REPO_ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
LICENSE_DIR = os.path.join(_REPO_ROOT, ".license")
|
||||||
|
DEVICE_ID_FILE = os.path.join(LICENSE_DIR, "device.id")
|
||||||
|
CACHE_FILE = os.path.join(LICENSE_DIR, "license.cache")
|
||||||
|
_TEMPLATE_DIR = os.path.join(_REPO_ROOT, "license_templates")
|
||||||
|
|
||||||
|
BJ_TZ = timezone(timedelta(hours=8))
|
||||||
|
|
||||||
|
|
||||||
|
def load_shared_env() -> None:
|
||||||
|
"""从仓库根目录 .env 加载 LICENSE_*(子项目 .env 已存在同名变量时不覆盖)。"""
|
||||||
|
path = os.path.join(_REPO_ROOT, ".env")
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
raw_bytes = open(path, "rb").read()
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
text = ""
|
||||||
|
for enc in ("utf-8-sig", "utf-16", "utf-16-le", "utf-16-be"):
|
||||||
|
try:
|
||||||
|
text = raw_bytes.decode(enc)
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if not text:
|
||||||
|
text = raw_bytes.decode("utf-8", errors="ignore")
|
||||||
|
text = text.replace("\x00", "")
|
||||||
|
for line in text.splitlines():
|
||||||
|
raw = line.strip()
|
||||||
|
if not raw or raw.startswith("#") or "=" not in raw:
|
||||||
|
continue
|
||||||
|
key, value = raw.split("=", 1)
|
||||||
|
clean_key = key.strip().lstrip("\ufeff")
|
||||||
|
if not clean_key.startswith("LICENSE_"):
|
||||||
|
continue
|
||||||
|
if clean_key in os.environ and (os.environ.get(clean_key) or "").strip():
|
||||||
|
continue
|
||||||
|
clean_value = value.strip().strip('"').strip("'")
|
||||||
|
os.environ[clean_key] = clean_value
|
||||||
|
|
||||||
|
|
||||||
|
def _env_bool(name: str, default: bool = False) -> bool:
|
||||||
|
return (os.getenv(name) or "").strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
|
||||||
|
def license_disabled() -> bool:
|
||||||
|
return _env_bool("LICENSE_DISABLED", False)
|
||||||
|
|
||||||
|
|
||||||
|
def license_api_url() -> str:
|
||||||
|
return (os.getenv("LICENSE_API_URL") or "").strip().rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def license_client_key() -> str:
|
||||||
|
return (os.getenv("LICENSE_CLIENT_KEY") or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def check_interval_days() -> int:
|
||||||
|
try:
|
||||||
|
return max(1, int(os.getenv("LICENSE_CHECK_INTERVAL_DAYS", "3")))
|
||||||
|
except ValueError:
|
||||||
|
return 3
|
||||||
|
|
||||||
|
|
||||||
|
def offline_grace_days() -> int:
|
||||||
|
try:
|
||||||
|
return max(1, int(os.getenv("LICENSE_OFFLINE_GRACE_DAYS", "7")))
|
||||||
|
except ValueError:
|
||||||
|
return 7
|
||||||
|
|
||||||
|
|
||||||
|
def wechat_id() -> str:
|
||||||
|
return (os.getenv("LICENSE_WECHAT_ID") or "dekun03").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def wechat_remark_hint(device_id: str) -> str:
|
||||||
|
custom = (os.getenv("LICENSE_WECHAT_REMARK") or "").strip()
|
||||||
|
if custom:
|
||||||
|
return custom
|
||||||
|
return device_id
|
||||||
|
|
||||||
|
|
||||||
|
def _machine_fingerprint() -> str:
|
||||||
|
parts = [
|
||||||
|
platform.node() or "",
|
||||||
|
platform.system() or "",
|
||||||
|
platform.release() or "",
|
||||||
|
platform.machine() or "",
|
||||||
|
]
|
||||||
|
if platform.system() == "Windows":
|
||||||
|
try:
|
||||||
|
out = subprocess.check_output(
|
||||||
|
["wmic", "csproduct", "get", "uuid"],
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
timeout=8,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="ignore",
|
||||||
|
)
|
||||||
|
for line in out.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line and line.lower() != "uuid":
|
||||||
|
parts.append(line)
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
for path in ("/etc/machine-id", "/var/lib/dbus/machine-id"):
|
||||||
|
try:
|
||||||
|
if os.path.isfile(path):
|
||||||
|
parts.append(open(path, encoding="utf-8", errors="ignore").read().strip())
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "|".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def get_device_id() -> str:
|
||||||
|
os.makedirs(LICENSE_DIR, exist_ok=True)
|
||||||
|
if os.path.isfile(DEVICE_ID_FILE):
|
||||||
|
with open(DEVICE_ID_FILE, encoding="utf-8") as f:
|
||||||
|
existing = f.read().strip()
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
raw = _machine_fingerprint()
|
||||||
|
device_id = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32]
|
||||||
|
with open(DEVICE_ID_FILE, "w", encoding="utf-8") as f:
|
||||||
|
f.write(device_id)
|
||||||
|
return device_id
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_iso(value: str | None) -> Optional[datetime]:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
s = str(value).strip()
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
if s.endswith("Z"):
|
||||||
|
s = s[:-1] + "+00:00"
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(s)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=BJ_TZ)
|
||||||
|
return dt.astimezone(BJ_TZ)
|
||||||
|
|
||||||
|
|
||||||
|
def _now_bj() -> datetime:
|
||||||
|
return datetime.now(BJ_TZ)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_cache() -> Optional[dict[str, Any]]:
|
||||||
|
if not os.path.isfile(CACHE_FILE):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with open(CACHE_FILE, encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return data if isinstance(data, dict) else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _save_cache(data: dict[str, Any]) -> None:
|
||||||
|
os.makedirs(LICENSE_DIR, exist_ok=True)
|
||||||
|
with open(CACHE_FILE, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def _api_headers() -> dict[str, str]:
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
key = license_client_key()
|
||||||
|
if key:
|
||||||
|
headers["X-Client-Key"] = key
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def _api_configured() -> bool:
|
||||||
|
return bool(license_api_url() and license_client_key())
|
||||||
|
|
||||||
|
|
||||||
|
def redeem_code(code: str) -> tuple[bool, str]:
|
||||||
|
if license_disabled():
|
||||||
|
return True, "许可校验已关闭(开发模式)"
|
||||||
|
if not _api_configured():
|
||||||
|
return False, "未配置 LICENSE_API_URL / LICENSE_CLIENT_KEY"
|
||||||
|
device_id = get_device_id()
|
||||||
|
url = f"{license_api_url()}/v1/redeem"
|
||||||
|
try:
|
||||||
|
r = requests.post(
|
||||||
|
url,
|
||||||
|
json={"device_id": device_id, "code": (code or "").strip()},
|
||||||
|
headers=_api_headers(),
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
body = r.json() if r.content else {}
|
||||||
|
except requests.RequestException as e:
|
||||||
|
return False, f"无法连接授权服务:{e}"
|
||||||
|
except ValueError:
|
||||||
|
return False, "授权服务返回非 JSON"
|
||||||
|
|
||||||
|
if r.status_code >= 400 or not body.get("ok"):
|
||||||
|
return False, (body.get("message") or body.get("detail") or f"兑换失败 HTTP {r.status_code}")
|
||||||
|
|
||||||
|
expires_at = body.get("expires_at") or ""
|
||||||
|
cache = {
|
||||||
|
"device_id": device_id,
|
||||||
|
"subscription_id": body.get("subscription_id") or "",
|
||||||
|
"plan": body.get("plan") or "",
|
||||||
|
"expires_at": expires_at,
|
||||||
|
"last_validated_at": _now_bj().isoformat(),
|
||||||
|
"last_validated_ok_at": _now_bj().isoformat(),
|
||||||
|
}
|
||||||
|
_save_cache(cache)
|
||||||
|
return True, "激活成功"
|
||||||
|
|
||||||
|
|
||||||
|
def validate_remote() -> tuple[bool, str, Optional[dict[str, Any]]]:
|
||||||
|
if license_disabled():
|
||||||
|
return True, "", _load_cache()
|
||||||
|
if not _api_configured():
|
||||||
|
return False, "未配置授权服务", None
|
||||||
|
|
||||||
|
cache = _load_cache()
|
||||||
|
if not cache or not cache.get("subscription_id"):
|
||||||
|
return False, "未激活", None
|
||||||
|
|
||||||
|
device_id = get_device_id()
|
||||||
|
url = f"{license_api_url()}/v1/validate"
|
||||||
|
try:
|
||||||
|
r = requests.post(
|
||||||
|
url,
|
||||||
|
json={
|
||||||
|
"device_id": device_id,
|
||||||
|
"subscription_id": cache.get("subscription_id"),
|
||||||
|
},
|
||||||
|
headers=_api_headers(),
|
||||||
|
timeout=20,
|
||||||
|
)
|
||||||
|
body = r.json() if r.content else {}
|
||||||
|
except requests.RequestException as e:
|
||||||
|
return False, f"校验请求失败:{e}", cache
|
||||||
|
except ValueError:
|
||||||
|
return False, "授权服务返回非 JSON", cache
|
||||||
|
|
||||||
|
now_iso = _now_bj().isoformat()
|
||||||
|
cache["last_validated_at"] = now_iso
|
||||||
|
|
||||||
|
if r.status_code >= 400 or not body.get("ok"):
|
||||||
|
_save_cache(cache)
|
||||||
|
return False, body.get("message") or body.get("detail") or "校验失败", cache
|
||||||
|
|
||||||
|
if not body.get("valid"):
|
||||||
|
reason = body.get("reason") or body.get("message") or "许可无效"
|
||||||
|
_save_cache(cache)
|
||||||
|
return False, reason, cache
|
||||||
|
|
||||||
|
if body.get("expires_at"):
|
||||||
|
cache["expires_at"] = body["expires_at"]
|
||||||
|
if body.get("plan"):
|
||||||
|
cache["plan"] = body["plan"]
|
||||||
|
cache["last_validated_ok_at"] = now_iso
|
||||||
|
_save_cache(cache)
|
||||||
|
return True, "", cache
|
||||||
|
|
||||||
|
|
||||||
|
def _needs_periodic_check(last_validated_at: str | None) -> bool:
|
||||||
|
if not last_validated_at:
|
||||||
|
return True
|
||||||
|
last = _parse_iso(last_validated_at)
|
||||||
|
if not last:
|
||||||
|
return True
|
||||||
|
return _now_bj() - last >= timedelta(days=check_interval_days())
|
||||||
|
|
||||||
|
|
||||||
|
def _within_offline_grace(cache: dict[str, Any]) -> bool:
|
||||||
|
ref = cache.get("last_validated_ok_at") or cache.get("last_validated_at")
|
||||||
|
last_ok = _parse_iso(ref if isinstance(ref, str) else None)
|
||||||
|
if not last_ok:
|
||||||
|
return False
|
||||||
|
return _now_bj() - last_ok <= timedelta(days=offline_grace_days())
|
||||||
|
|
||||||
|
|
||||||
|
def _is_expired(cache: dict[str, Any]) -> bool:
|
||||||
|
exp = _parse_iso(cache.get("expires_at"))
|
||||||
|
if not exp:
|
||||||
|
return True
|
||||||
|
return _now_bj() > exp
|
||||||
|
|
||||||
|
|
||||||
|
def is_licensed(*, force_validate: bool = False) -> bool:
|
||||||
|
if license_disabled():
|
||||||
|
return True
|
||||||
|
|
||||||
|
cache = _load_cache()
|
||||||
|
if not cache:
|
||||||
|
return False
|
||||||
|
if cache.get("device_id") and cache.get("device_id") != get_device_id():
|
||||||
|
return False
|
||||||
|
if _is_expired(cache):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if force_validate or _needs_periodic_check(cache.get("last_validated_at")):
|
||||||
|
ok, _msg, updated = validate_remote()
|
||||||
|
if ok:
|
||||||
|
return True
|
||||||
|
use_cache = updated or cache
|
||||||
|
if _within_offline_grace(use_cache) and not _is_expired(use_cache):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def license_status() -> dict[str, Any]:
|
||||||
|
device_id = get_device_id()
|
||||||
|
cache = _load_cache() or {}
|
||||||
|
exp = _parse_iso(cache.get("expires_at"))
|
||||||
|
licensed = is_licensed()
|
||||||
|
days_left = None
|
||||||
|
if exp:
|
||||||
|
days_left = max(0, (exp - _now_bj()).days)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"device_id": device_id,
|
||||||
|
"licensed": licensed,
|
||||||
|
"disabled": license_disabled(),
|
||||||
|
"api_configured": _api_configured(),
|
||||||
|
"subscription_id": cache.get("subscription_id") or "",
|
||||||
|
"plan": cache.get("plan") or "",
|
||||||
|
"expires_at": cache.get("expires_at") or "",
|
||||||
|
"expires_at_display": exp.strftime("%Y-%m-%d %H:%M") if exp else "",
|
||||||
|
"days_left": days_left,
|
||||||
|
"last_validated_at": cache.get("last_validated_at") or "",
|
||||||
|
"check_interval_days": check_interval_days(),
|
||||||
|
"offline_grace_days": offline_grace_days(),
|
||||||
|
"wechat_id": wechat_id(),
|
||||||
|
"wechat_remark": wechat_remark_hint(device_id),
|
||||||
|
"plans": [
|
||||||
|
{"id": "monthly", "name": "月卡", "price": 199, "days": 30},
|
||||||
|
{"id": "quarterly", "name": "季卡", "price": 399, "days": 90},
|
||||||
|
{"id": "yearly", "name": "年卡", "price": 699, "days": 365},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _render_license_html(ctx: dict[str, Any]) -> str:
|
||||||
|
path = os.path.join(_TEMPLATE_DIR, "license.html")
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
template = f.read()
|
||||||
|
from jinja2 import Template
|
||||||
|
|
||||||
|
return Template(template).render(**ctx)
|
||||||
|
|
||||||
|
|
||||||
|
def init_flask_app(app, exchange_display: str = "交易系统") -> None:
|
||||||
|
"""注册 /license 路由与全局门禁。"""
|
||||||
|
load_shared_env()
|
||||||
|
|
||||||
|
@app.route("/license", methods=["GET", "POST"])
|
||||||
|
def license_page():
|
||||||
|
from flask import flash, get_flashed_messages, redirect, render_template_string, request, url_for
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
code = (request.form.get("activation_code") or "").strip()
|
||||||
|
if not code:
|
||||||
|
flash("请输入激活码")
|
||||||
|
else:
|
||||||
|
ok, msg = redeem_code(code)
|
||||||
|
if ok:
|
||||||
|
flash(msg or "激活成功", "ok")
|
||||||
|
return redirect(url_for("license_page"))
|
||||||
|
flash(msg or "激活失败")
|
||||||
|
return redirect(url_for("license_page"))
|
||||||
|
|
||||||
|
status = license_status()
|
||||||
|
html = _render_license_html(
|
||||||
|
{
|
||||||
|
**status,
|
||||||
|
"exchange_display": exchange_display,
|
||||||
|
"messages": get_flashed_messages(with_categories=True),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return render_template_string(html)
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def _license_gate():
|
||||||
|
from flask import redirect, request, url_for
|
||||||
|
|
||||||
|
if license_disabled():
|
||||||
|
return None
|
||||||
|
endpoint = request.endpoint or ""
|
||||||
|
if endpoint in ("license_page", "login", "logout") or endpoint.startswith("static"):
|
||||||
|
return None
|
||||||
|
if not is_licensed():
|
||||||
|
return redirect(url_for("license_page"))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def init_fastapi_app(app) -> None:
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
|
||||||
|
load_shared_env()
|
||||||
|
|
||||||
|
@app.get("/license", response_class=HTMLResponse)
|
||||||
|
async def license_page(request: Request):
|
||||||
|
return HTMLResponse(_render_license_html({**license_status(), "exchange_display": "中控", "messages": []}))
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def _license_gate(request: Request, call_next):
|
||||||
|
if license_disabled():
|
||||||
|
return await call_next(request)
|
||||||
|
path = request.url.path
|
||||||
|
if path == "/license" or path.startswith("/assets"):
|
||||||
|
return await call_next(request)
|
||||||
|
if not is_licensed():
|
||||||
|
return RedirectResponse(url="/license", status_code=302)
|
||||||
|
return await call_next(request)
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<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;
|
||||||
|
color: #e8e8f0;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
.wrap { max-width: 520px; margin: 0 auto; }
|
||||||
|
h1 {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
background: linear-gradient(90deg, #4cc2ff, #7b42ff);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
.sub { color: #8892b0; font-size: 0.85rem; margin-bottom: 1.5rem; }
|
||||||
|
.card {
|
||||||
|
background: #12121a;
|
||||||
|
border: 1px solid #242435;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1.25rem 1.35rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.card h2 { font-size: 0.95rem; color: #a9a9ff; margin-bottom: 0.75rem; }
|
||||||
|
.device {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
word-break: break-all;
|
||||||
|
background: #1a1a29;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #2e2e45;
|
||||||
|
color: #b8f5d0;
|
||||||
|
}
|
||||||
|
.hint { font-size: 0.8rem; color: #8892b0; margin-top: 0.5rem; line-height: 1.5; }
|
||||||
|
.wechat {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #4cc2ff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.remark-box {
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
background: #331e24;
|
||||||
|
border: 1px solid #5c3040;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #ffb4b4;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.plans { list-style: none; font-size: 0.88rem; color: #c5c5e0; }
|
||||||
|
.plans li { padding: 0.25rem 0; }
|
||||||
|
label { display: block; font-size: 0.88rem; color: #a9a9ff; margin-bottom: 0.4rem; }
|
||||||
|
input[type="text"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #2e2e45;
|
||||||
|
background: #1a1a29;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.85rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(90deg, #4285f4, #7b42ff);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.flash {
|
||||||
|
padding: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background: #331e24;
|
||||||
|
color: #ff8888;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.ok { background: #1a3328; color: #7dffb0; }
|
||||||
|
.status-line { font-size: 0.88rem; margin: 0.35rem 0; }
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.badge-ok { background: #1a4030; color: #7dffb0; }
|
||||||
|
.badge-no { background: #402020; color: #ff8888; }
|
||||||
|
a { color: #4cc2ff; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<h1>复盘交易系统 · 授权</h1>
|
||||||
|
<p class="sub">整机许可 · 每 {{ check_interval_days }} 天联网校验一次</p>
|
||||||
|
|
||||||
|
{% for msg in messages %}
|
||||||
|
<div class="flash{% if msg is iterable and msg|length > 1 and msg[0] == 'ok' %} ok{% endif %}">
|
||||||
|
{% if msg is iterable and msg|length > 1 %}{{ msg[1] }}{% else %}{{ msg }}{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>授权状态</h2>
|
||||||
|
{% if licensed %}
|
||||||
|
<span class="badge badge-ok">已授权</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-no">未授权或已过期</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if expires_at_display %}
|
||||||
|
<p class="status-line">到期时间:{{ expires_at_display }}(BJ)</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if days_left is not none and licensed %}
|
||||||
|
<p class="status-line">剩余约 {{ days_left }} 天</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if plan %}
|
||||||
|
<p class="status-line">套餐:{{ plan }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<p class="status-line" style="margin-top:0.6rem;font-size:0.78rem;color:#6a7088;">
|
||||||
|
校验间隔 {{ check_interval_days }} 天;断网宽限 {{ offline_grace_days }} 天
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>设备 ID(换机需重新联系签发)</h2>
|
||||||
|
<div class="device" id="deviceId">{{ device_id }}</div>
|
||||||
|
<p class="hint">复制上方 ID,联系管理员获取激活码。续费在剩余天数上叠加。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>购买 / 续费</h2>
|
||||||
|
<ul class="plans">
|
||||||
|
{% for p in plans %}
|
||||||
|
<li>{{ p.name }}:¥{{ p.price }} / {{ p.days }} 天</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<p class="hint" style="margin-top:0.75rem;">
|
||||||
|
微信:<span class="wechat">{{ wechat_id }}</span>
|
||||||
|
</p>
|
||||||
|
<div class="remark-box">
|
||||||
|
<strong>添加微信时必须填写备注:</strong><br>
|
||||||
|
请将上方 <strong>设备 ID 完整复制</strong> 到好友验证备注中(或填写:{{ wechat_remark }})
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>激活码兑换</h2>
|
||||||
|
<form method="POST">
|
||||||
|
<label for="activation_code">激活码</label>
|
||||||
|
<input type="text" id="activation_code" name="activation_code" required placeholder="粘贴管理员提供的激活码" autocomplete="off">
|
||||||
|
<button type="submit">激活 / 续费</button>
|
||||||
|
</form>
|
||||||
|
{% if not api_configured and not disabled %}
|
||||||
|
<p class="hint" style="color:#ff8888;margin-top:0.75rem;">未配置 LICENSE_API_URL,请在 .env 中设置授权服务地址。</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
# 手工交易多账户中控(manual_trading_hub)
|
||||||
|
|
||||||
|
本目录提供**极简中控**:只负责多账户**监控**(持仓、盈亏、余额等)与**紧急全平**,不参与开仓、策略或任何自动化下单。策略账户侧的 `crypto_monitor_*` 项目**无需改代码**,与中控并行运行即可。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 功能概览
|
||||||
|
|
||||||
|
| 能力 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 监控 | 汇总各子代理的永续持仓、未实现盈亏、余额(USDT)、持仓模式等 |
|
||||||
|
| 单账户全平 | 对某一子代理发起市价减仓,尽量平掉该所 USDT 永续仓位 |
|
||||||
|
| 全局全平 | 对当前「已开启监控」的子代理依次发起全平 |
|
||||||
|
| 关闭某账户 | 网页上取消「参与监控」,或环境变量 `HUB_DISABLED_IDS`;被关闭的不轮询、不参与全局全平 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 架构说明
|
||||||
|
|
||||||
|
```
|
||||||
|
浏览器 → 中控 hub.py(默认监听 0.0.0.0:5100,私网可访问;本机仍可用 127.0.0.1)
|
||||||
|
↓ HTTP
|
||||||
|
子代理 agent.py × N(默认 127.0.0.1:15200~15203,与 Flask 的 APP_PORT 错开)
|
||||||
|
↓ ccxt
|
||||||
|
各交易所 API
|
||||||
|
```
|
||||||
|
|
||||||
|
- **中控(hub)**:读配置、并行请求各子代理 `/status`;全平时转发 `POST /emergency/close-all`。
|
||||||
|
- **子代理(agent)**:每个进程绑定一个交易所 API Key,只做只读状态 + 紧急平仓;与 `crypto_monitor_*` 里的 Flask **独立进程**,互不影响日常手工/策略操作。
|
||||||
|
|
||||||
|
### 与四个 `crypto_monitor_*` 目录的关系(已对照 `app.py`)
|
||||||
|
|
||||||
|
各策略项目在**本目录**用 **`.env`**(由 **`cp .env.example .env`** 生成,勿提交 Git;`git pull` 不覆盖)加载配置(`load_env_file`),并用其中的 **`APP_HOST` / `APP_PORT`** 启动 **Flask 网页**(`app.py` 里 `HOST`、`PORT`)。这是你在浏览器里打开策略/监控后台用的端口,例如 `APP_PORT=5001`。
|
||||||
|
|
||||||
|
子代理 **不用 `APP_PORT`**,而是用环境变量 **`PORT`**(FastAPI/uvicorn 监听)。若子代理与 Flask 抢同一端口,后启动的会起不来,因此中控默认把子代理配在 **`15200`~`15203`**,与常见 `5000` 段 Flask 配置**错开**。
|
||||||
|
|
||||||
|
| 子代理 `PORT`(建议) | 对应策略目录 | `EXCHANGE` |
|
||||||
|
|----------------------|--------------|------------|
|
||||||
|
| 15200 | `crypto_monitor_binance` | `binance` |
|
||||||
|
| 15201 | `crypto_monitor_okx` | `okx` |
|
||||||
|
| 15202 | `crypto_monitor_gate` | `gate` |
|
||||||
|
| 15203 | `crypto_monitor_gate_bot` | `gate` |
|
||||||
|
|
||||||
|
Flask 仍可继续用 `APP_HOST=0.0.0.0` 方便云服务器外网访问;**子代理**建议 **`HOST=127.0.0.1`**。中控默认 `HUB_HOST=0.0.0.0` 便于局域网打开页面;若只给自己用,设 `HUB_HOST=127.0.0.1` 或 `HUB_TRUST_LAN=0`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 依赖
|
||||||
|
|
||||||
|
- Python 3.10+(与仓库其他项目相近即可)
|
||||||
|
- 见 `requirements.txt`:`fastapi`、`uvicorn`、`httpx`、`ccxt`
|
||||||
|
|
||||||
|
安装:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd manual_trading_hub
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
建议使用独立虚拟环境,避免与全局包冲突。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速启动(本机)
|
||||||
|
|
||||||
|
### 1. 启动子代理(每个账户一个终端)
|
||||||
|
|
||||||
|
在**对应策略目录**下加载该目录已有 `.env`(含 API 密钥与可选代理),再启动 agent,避免密钥分散维护两套。
|
||||||
|
|
||||||
|
**Binance(子代理端口 15200,与 Flask APP_PORT 无关)**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd ..\crypto_monitor_binance
|
||||||
|
$env:EXCHANGE="binance"
|
||||||
|
$env:PORT="15200"
|
||||||
|
$env:HOST="127.0.0.1"
|
||||||
|
python ..\manual_trading_hub\agent.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**OKX(15201)**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd ..\crypto_monitor_okx
|
||||||
|
$env:EXCHANGE="okx"
|
||||||
|
$env:PORT="15201"
|
||||||
|
python ..\manual_trading_hub\agent.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gate / Gate-Bot(15202 / 15203)**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:EXCHANGE="gate"
|
||||||
|
$env:PORT="15202" # Gate-Bot 用 15203
|
||||||
|
python ..\manual_trading_hub\agent.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 启动中控
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd manual_trading_hub
|
||||||
|
$env:HUB_HOST="0.0.0.0"
|
||||||
|
$env:HUB_PORT="5100"
|
||||||
|
# 可选:自定义各账户卡片标题(顺序与默认 HUB_AGENTS 一致:15200→15201→15202→15203)
|
||||||
|
# $env:HUB_AGENT_NAMES="币安主号,OKX,Gate主号,Gate机器人"
|
||||||
|
python hub.py
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器打开:**http://127.0.0.1:5100/** 或 **http://本机局域网IP:5100/**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 子代理(agent)环境变量
|
||||||
|
|
||||||
|
| 变量 | 含义 | 默认 |
|
||||||
|
|------|------|------|
|
||||||
|
| `EXCHANGE` | `binance` / `okx` / `gate` | `binance` |
|
||||||
|
| `HOST` | 监听地址 | `127.0.0.1` |
|
||||||
|
| `PORT` | 子代理监听端口(勿与 Flask 的 `APP_PORT` 相同) | `15200` |
|
||||||
|
| `CONTROL_TOKEN` | 若设置,请求须带头 `X-Control-Token` | 空 |
|
||||||
|
|
||||||
|
**Binance**:`BINANCE_API_KEY`、`BINANCE_API_SECRET`;可选 `BINANCE_POSITION_MODE`(`hedge`/`oneway`)、`BINANCE_MARGIN_MODE`;代理 `BINANCE_SOCKS_PROXY` 或 `BINANCE_HTTP_PROXY` / `HTTPS_PROXY`。`/status` 的 `balance_usdt` 为 **U 本位永续合约**账户 USDT(与 `crypto_monitor_binance` 合约口径一致,非现货钱包)。
|
||||||
|
|
||||||
|
**OKX**:`OKX_API_KEY`、`OKX_API_SECRET`、`OKX_API_PASSPHRASE`;可选 `OKX_TD_MODE`、`OKX_POS_MODE`;代理 `OKX_SOCKS_PROXY` 等。
|
||||||
|
|
||||||
|
**Gate**:`GATE_API_KEY`、`GATE_API_SECRET`;可选 `GATE_TD_MODE`、`GATE_POS_MODE`;代理 `GATE_SOCKS_PROXY` 等。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 中控(hub)环境变量
|
||||||
|
|
||||||
|
| 变量 | 含义 | 默认 |
|
||||||
|
|------|------|------|
|
||||||
|
| `HUB_HOST` | 监听地址 | `0.0.0.0`(局域网可连);`127.0.0.1` 仅本机 |
|
||||||
|
| `HUB_PORT` | 监听端口 | `5100` |
|
||||||
|
| `HUB_AGENTS` | 子代理 base URL,逗号分隔 | `http://127.0.0.1:15200` … `15203` |
|
||||||
|
| `HUB_AGENT_NAMES` | 与 `HUB_AGENTS` **顺序一一对应**的卡片显示名(逗号分隔)。不设则用内置默认名。改后需重启 hub,**所有访问该中控的浏览器**显示一致 | 内置与四目录对应的英文标签 |
|
||||||
|
| `HUB_DISABLED_IDS` | 不参与监控与全局全平的账户 `id`,逗号分隔(如暂不用 OKX 写 `1`) | 空 |
|
||||||
|
| `HUB_TRUST_LAN` | 默认 `true`;设为 `0`/`false`/`off` 则仅允许本机 IP 访问中控 | 开 |
|
||||||
|
| `CONTROL_TOKEN` | 与子代理一致时,中控代发 `X-Control-Token` | 空 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 网页操作说明
|
||||||
|
|
||||||
|
- **立即刷新 / 自动刷新**:拉取最新汇总;自动刷新默认约 3 秒一轮(仅请求已开启监控的账户)。
|
||||||
|
- **账户显示名**:在运行 `hub.py` 的机器上设置环境变量 **`HUB_AGENT_NAMES`**(逗号分隔,顺序与 **`HUB_AGENTS`** 里每个子代理 URL 一致),重启中控后,任意电脑打开同一中控地址都会看到相同名称。名称里若含逗号,需整体用引号包裹或避免使用逗号。
|
||||||
|
- **参与监控**:勾选则参与轮询与「全局一键全平」;取消则本浏览器记住(`localStorage` 键 `manual_trading_hub_excluded`),不再请求该子代理。
|
||||||
|
- **该账户全平**:仅针对该子代理;与是否关闭「参与监控」无关(仍可直接调交易所紧急平仓)。
|
||||||
|
- **全局一键全平**:只对当前「参与监控」且未被 `HUB_DISABLED_IDS` 关闭的账户发起全平;请求体中的 `exclude_ids` 与网页关闭状态一致。
|
||||||
|
|
||||||
|
服务端 `HUB_DISABLED_IDS` 与浏览器关闭**取并集**:任一方关闭即不轮询、不进全局全平。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HTTP API(访问控制与浏览器一致)
|
||||||
|
|
||||||
|
允许来源:**本机**或 **RFC1918 私网 IPv4**(与 `HUB_TRUST_LAN` 开启时一致);`HUB_TRUST_LAN=0` 时仅本机。
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/` | 中控页面 |
|
||||||
|
| GET | `/api/agents` | 当前配置的账户列表(id、name、url) |
|
||||||
|
| GET | `/api/snapshot` | 聚合状态;可选查询参数 `exclude_ids`(逗号分隔 id) |
|
||||||
|
| POST | `/api/close/{agent_id}` | 单账户紧急全平 |
|
||||||
|
| POST | `/api/close-all` | JSON 体可选 `{"exclude_ids":["1"]}`,与 `HUB_DISABLED_IDS` 合并 |
|
||||||
|
|
||||||
|
子代理:
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/health` | 健康检查 |
|
||||||
|
| GET | `/status` | 余额、持仓、盈亏汇总 |
|
||||||
|
| POST | `/emergency/close-all` | 市价尽量平掉 USDT 永续仓位,并尝试撤该合约挂单 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 安全与边界
|
||||||
|
|
||||||
|
- 中控默认 **监听 0.0.0.0** 并放行私网访问,便于局域网使用;**公网非私网 IP** 仍会被中间件拒绝。
|
||||||
|
- 若机器有公网 IP,请用 **防火墙** 限制 `HUB_PORT` 仅内网可进,或改为 `HUB_HOST=127.0.0.1` + `HUB_TRUST_LAN=0` 仅本机。
|
||||||
|
- 子代理建议仍用 **`HOST=127.0.0.1`**,不要对局域网暴露交易所密钥通道。
|
||||||
|
- 中控**不具备**开仓、改策略、划转账能力;全平为**市价减仓**,请谨慎操作。
|
||||||
|
- 子代理与主策略进程共用密钥时,注意权限与 IP 白名单仍按交易所要求配置。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
**1. 某一行一直连不上**
|
||||||
|
检查该端口上的 `agent.py` 是否已启动、防火墙是否放行本机回环、`.env` 密钥是否与交易所一致。
|
||||||
|
|
||||||
|
**2. 暂时不用 OKX**
|
||||||
|
网页取消该行的「参与监控」,或启动中控前设置 `HUB_DISABLED_IDS=1`(默认 OKX 的 id 为 `1`)。
|
||||||
|
|
||||||
|
**3. 策略项目要不要改?**
|
||||||
|
不需要改 `crypto_monitor_*` 代码;只需额外运行 `agent.py` 进程。
|
||||||
|
|
||||||
|
**5. 局域网里别的电脑打不开中控**
|
||||||
|
默认应已可访问:中控监听 `0.0.0.0` 且 `HUB_TRUST_LAN` 默认开启。请检查:防火墙是否放行 `HUB_PORT`;浏览器是否使用 **中控机器的局域网 IP**(不要用另一台电脑上的 `127.0.0.1`)。若你曾设置 `HUB_TRUST_LAN=0` 或 `HUB_HOST=127.0.0.1`,改回默认或删掉环境变量后重启 hub。
|
||||||
|
|
||||||
|
更细的安装顺序、验收清单与可选常驻方式见同目录 **《部署文档.md》**。
|
||||||
@@ -0,0 +1,568 @@
|
|||||||
|
"""
|
||||||
|
子账户极轻代理:仅 GET /status + POST /emergency/close-all,仅监听 127.0.0.1。
|
||||||
|
|
||||||
|
与仓库内四个策略/监控目录一一对应时,典型用法(各目录自己的 .env 里已有密钥;子代理用环境变量 PORT,勿与 Flask 的 APP_PORT 相同):
|
||||||
|
EXCHANGE=binance → crypto_monitor_binance(BINANCE_*)
|
||||||
|
EXCHANGE=okx → crypto_monitor_okx(OKX_*)
|
||||||
|
EXCHANGE=gate → crypto_monitor_gate / crypto_monitor_gate_bot(GATE_*)
|
||||||
|
|
||||||
|
环境变量:
|
||||||
|
EXCHANGE binance(默认)| okx | gate
|
||||||
|
PORT 默认 15200(与 crypto_monitor_* 的 Flask APP_PORT 错开;中控默认聚合 15200–15203)
|
||||||
|
HOST 默认 127.0.0.1
|
||||||
|
CONTROL_TOKEN 可选;请求头 X-Control-Token
|
||||||
|
|
||||||
|
Binance:BINANCE_API_KEY / BINANCE_API_SECRET;余额为 **U 本位永续合约账户** USDT(与 `crypto_monitor_binance` 的合约口径一致,非现货钱包);BINANCE_POSITION_MODE;BINANCE_MARGIN_MODE
|
||||||
|
OKX:OKX_API_KEY / OKX_API_SECRET / OKX_API_PASSPHRASE;OKX_TD_MODE;OKX_POS_MODE
|
||||||
|
Gate:GATE_API_KEY / GATE_API_SECRET;GATE_TD_MODE;GATE_POS_MODE
|
||||||
|
|
||||||
|
代理与主项目一致时可设:BINANCE_SOCKS_PROXY / OKX_SOCKS_PROXY / GATE_SOCKS_PROXY(或 HTTP(S)_PROXY)。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import ccxt
|
||||||
|
from fastapi import FastAPI, Header, HTTPException, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
HOST = os.getenv("HOST", "127.0.0.1")
|
||||||
|
PORT = int(os.getenv("PORT", "15200"))
|
||||||
|
CONTROL_TOKEN = (os.getenv("CONTROL_TOKEN") or "").strip()
|
||||||
|
|
||||||
|
_raw_ex = (os.getenv("EXCHANGE") or "binance").strip().lower()
|
||||||
|
if _raw_ex in ("binance", "bnb", "ba"):
|
||||||
|
EXCHANGE_KIND = "binance"
|
||||||
|
elif _raw_ex in ("okx", "okex"):
|
||||||
|
EXCHANGE_KIND = "okx"
|
||||||
|
elif _raw_ex in ("gate", "gateio"):
|
||||||
|
EXCHANGE_KIND = "gate"
|
||||||
|
else:
|
||||||
|
EXCHANGE_KIND = "binance"
|
||||||
|
|
||||||
|
# —— Binance ——
|
||||||
|
_bin_pos = (os.getenv("BINANCE_POSITION_MODE") or "hedge").strip().lower()
|
||||||
|
BINANCE_POSITION_MODE = "hedge" if _bin_pos in ("hedge", "dual", "double", "hedged") else "oneway"
|
||||||
|
_bin_margin = (os.getenv("BINANCE_MARGIN_MODE") or "cross").strip().lower()
|
||||||
|
BINANCE_DEFAULT_MARGIN_MODE = "cross" if _bin_margin in ("cross", "cross_margin") else "isolated"
|
||||||
|
|
||||||
|
# —— OKX ——
|
||||||
|
OKX_TD_MODE = (os.getenv("OKX_TD_MODE") or "cross").strip()
|
||||||
|
_okx_pos = (os.getenv("OKX_POS_MODE") or "hedge").strip().lower()
|
||||||
|
OKX_POS_MODE = "hedge" if _okx_pos in ("hedge", "long_short_mode", "dual") else "net"
|
||||||
|
|
||||||
|
# —— Gate ——
|
||||||
|
_gate_td = (os.getenv("GATE_TD_MODE") or "cross").strip().lower()
|
||||||
|
GATE_DEFAULT_MARGIN_MODE = "cross" if _gate_td in ("cross", "cross_margin") else "isolated"
|
||||||
|
_gate_pos = (os.getenv("GATE_POS_MODE") or "hedge").strip().lower()
|
||||||
|
GATE_POS_MODE = "hedge" if _gate_pos in ("hedge", "dual", "double") else "single"
|
||||||
|
|
||||||
|
app = FastAPI(title="sub-agent", docs_url=None, redoc_url=None)
|
||||||
|
_ccxt_ex: Any = None
|
||||||
|
_markets_loaded = False
|
||||||
|
|
||||||
|
|
||||||
|
def _socks_proxy_url(prefix: str) -> str:
|
||||||
|
return (os.getenv(f"{prefix}_SOCKS_PROXY") or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _http_https_proxy(prefix: str) -> dict[str, str] | None:
|
||||||
|
http = (os.getenv(f"{prefix}_HTTP_PROXY") or "").strip()
|
||||||
|
https = (os.getenv(f"{prefix}_HTTPS_PROXY") or "").strip()
|
||||||
|
socks = _socks_proxy_url(prefix)
|
||||||
|
if socks:
|
||||||
|
return {"http": socks, "https": socks}
|
||||||
|
if http or https:
|
||||||
|
return {"http": http, "https": https}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_proxies(ex: Any, prefix: str) -> None:
|
||||||
|
p = _http_https_proxy(prefix)
|
||||||
|
if p:
|
||||||
|
ex.proxies = p
|
||||||
|
|
||||||
|
|
||||||
|
def _make_exchange() -> Any:
|
||||||
|
if EXCHANGE_KIND == "binance":
|
||||||
|
key = (os.getenv("BINANCE_API_KEY") or "").strip()
|
||||||
|
secret = (os.getenv("BINANCE_API_SECRET") or "").strip()
|
||||||
|
if not key or not secret:
|
||||||
|
raise RuntimeError("缺少 BINANCE_API_KEY / BINANCE_API_SECRET")
|
||||||
|
ex = ccxt.binance(
|
||||||
|
{
|
||||||
|
"apiKey": key,
|
||||||
|
"secret": secret,
|
||||||
|
"enableRateLimit": True,
|
||||||
|
"options": {
|
||||||
|
"defaultType": "swap",
|
||||||
|
# ccxt 默认 fetch_balance 走现货;与监控项目一致,固定为 U 本位合约钱包
|
||||||
|
"fetchBalance": {"defaultType": "swap"},
|
||||||
|
"defaultMarginMode": BINANCE_DEFAULT_MARGIN_MODE,
|
||||||
|
"adjustForTimeDifference": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_attach_proxies(ex, "BINANCE")
|
||||||
|
return ex
|
||||||
|
|
||||||
|
if EXCHANGE_KIND == "okx":
|
||||||
|
key = (os.getenv("OKX_API_KEY") or "").strip()
|
||||||
|
secret = (os.getenv("OKX_API_SECRET") or "").strip()
|
||||||
|
password = (os.getenv("OKX_API_PASSPHRASE") or "").strip()
|
||||||
|
if not key or not secret or not password:
|
||||||
|
raise RuntimeError("缺少 OKX_API_KEY / OKX_API_SECRET / OKX_API_PASSPHRASE")
|
||||||
|
ex = ccxt.okx(
|
||||||
|
{
|
||||||
|
"apiKey": key,
|
||||||
|
"secret": secret,
|
||||||
|
"password": password,
|
||||||
|
"enableRateLimit": True,
|
||||||
|
"options": {"defaultType": "swap"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_attach_proxies(ex, "OKX")
|
||||||
|
return ex
|
||||||
|
|
||||||
|
# gate
|
||||||
|
key = (os.getenv("GATE_API_KEY") or "").strip()
|
||||||
|
secret = (os.getenv("GATE_API_SECRET") or "").strip()
|
||||||
|
if not key or not secret:
|
||||||
|
raise RuntimeError("缺少 GATE_API_KEY / GATE_API_SECRET")
|
||||||
|
ex = ccxt.gateio(
|
||||||
|
{
|
||||||
|
"apiKey": key,
|
||||||
|
"secret": secret,
|
||||||
|
"enableRateLimit": True,
|
||||||
|
"options": {
|
||||||
|
"defaultType": "swap",
|
||||||
|
"defaultMarginMode": GATE_DEFAULT_MARGIN_MODE,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_attach_proxies(ex, "GATE")
|
||||||
|
return ex
|
||||||
|
|
||||||
|
|
||||||
|
def get_exchange() -> Any:
|
||||||
|
global _ccxt_ex
|
||||||
|
if _ccxt_ex is None:
|
||||||
|
_ccxt_ex = _make_exchange()
|
||||||
|
return _ccxt_ex
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_markets() -> None:
|
||||||
|
global _markets_loaded
|
||||||
|
if not _markets_loaded:
|
||||||
|
get_exchange().load_markets()
|
||||||
|
_markets_loaded = True
|
||||||
|
|
||||||
|
|
||||||
|
def _check_token(x_control_token: str | None) -> None:
|
||||||
|
if not CONTROL_TOKEN:
|
||||||
|
return
|
||||||
|
if (x_control_token or "").strip() != CONTROL_TOKEN:
|
||||||
|
raise HTTPException(status_code=401, detail="invalid token")
|
||||||
|
|
||||||
|
|
||||||
|
def _position_mode_label() -> str:
|
||||||
|
if EXCHANGE_KIND == "binance":
|
||||||
|
return BINANCE_POSITION_MODE
|
||||||
|
if EXCHANGE_KIND == "okx":
|
||||||
|
return OKX_POS_MODE
|
||||||
|
return GATE_POS_MODE
|
||||||
|
|
||||||
|
|
||||||
|
def _close_param_candidates_binance(direction: str) -> list[dict[str, Any]]:
|
||||||
|
ps = "LONG" if direction == "long" else "SHORT"
|
||||||
|
hedge_ro = {"positionSide": ps, "reduceOnly": True}
|
||||||
|
hedge_plain = {"positionSide": ps}
|
||||||
|
oneway_ro = {"reduceOnly": True}
|
||||||
|
oneway_plain: dict[str, Any] = {}
|
||||||
|
if BINANCE_POSITION_MODE == "hedge":
|
||||||
|
return [hedge_ro, hedge_plain, oneway_ro, oneway_plain]
|
||||||
|
return [oneway_ro, oneway_plain, hedge_ro, hedge_plain]
|
||||||
|
|
||||||
|
|
||||||
|
def _close_param_candidates_okx(direction: str) -> list[dict[str, Any]]:
|
||||||
|
base: dict[str, Any] = {"tdMode": OKX_TD_MODE}
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
if OKX_POS_MODE == "hedge":
|
||||||
|
ps = "long" if direction == "long" else "short"
|
||||||
|
out.extend(
|
||||||
|
[
|
||||||
|
{**base, "posSide": ps, "reduceOnly": True},
|
||||||
|
{**base, "posSide": ps},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
out.extend([{**base, "reduceOnly": True}, dict(base)])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _close_param_candidates_gate(_direction: str) -> list[dict[str, Any]]:
|
||||||
|
return [{"reduceOnly": True}, {}]
|
||||||
|
|
||||||
|
|
||||||
|
def _close_param_candidates(direction: str) -> list[dict[str, Any]]:
|
||||||
|
if EXCHANGE_KIND == "binance":
|
||||||
|
return _close_param_candidates_binance(direction)
|
||||||
|
if EXCHANGE_KIND == "okx":
|
||||||
|
return _close_param_candidates_okx(direction)
|
||||||
|
return _close_param_candidates_gate(direction)
|
||||||
|
|
||||||
|
|
||||||
|
def _retryable_close_err(msg: str) -> bool:
|
||||||
|
s = (msg or "").lower()
|
||||||
|
if "-4061" in s:
|
||||||
|
return True
|
||||||
|
if "-1106" in s and "reduceonly" in s:
|
||||||
|
return True
|
||||||
|
if "reduceonly" in s or "reduce only" in s:
|
||||||
|
return True
|
||||||
|
if "position side" in s or "positionside" in s or "pos side" in s:
|
||||||
|
return True
|
||||||
|
if "dual side" in s or "position mode" in s:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _position_contracts(p: dict[str, Any]) -> float:
|
||||||
|
raw = p.get("contracts")
|
||||||
|
if raw is not None:
|
||||||
|
try:
|
||||||
|
return float(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
info = p.get("info") or {}
|
||||||
|
for k in ("positionAmt", "positionamt", "pos", "size"):
|
||||||
|
if k in info:
|
||||||
|
try:
|
||||||
|
v = float(info[k])
|
||||||
|
if v != 0:
|
||||||
|
return v
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _position_side(p: dict[str, Any], contracts: float) -> str:
|
||||||
|
s = (p.get("side") or "").lower()
|
||||||
|
if s in ("long", "short"):
|
||||||
|
return s
|
||||||
|
if contracts > 0:
|
||||||
|
return "long"
|
||||||
|
if contracts < 0:
|
||||||
|
return "short"
|
||||||
|
return "long"
|
||||||
|
|
||||||
|
|
||||||
|
def _cancel_symbol_orders(ex: Any, sym: str) -> None:
|
||||||
|
try:
|
||||||
|
ex.cancel_all_orders(sym, params={})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if EXCHANGE_KIND != "binance":
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
m = ex.market(sym)
|
||||||
|
cid = m.get("id")
|
||||||
|
if cid and hasattr(ex, "fapiPrivateDeleteAlgoOpenOrders"):
|
||||||
|
ex.fapiPrivateDeleteAlgoOpenOrders({"symbol": cid})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _is_local(host: str | None) -> bool:
|
||||||
|
if not host:
|
||||||
|
return False
|
||||||
|
h = host.lower()
|
||||||
|
return h in ("127.0.0.1", "::1", "localhost") or h.startswith("::ffff:127.0.0.1")
|
||||||
|
|
||||||
|
|
||||||
|
def _finite_or_none(x: Any) -> float | None:
|
||||||
|
try:
|
||||||
|
f = float(x)
|
||||||
|
return f if math.isfinite(f) else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_usdt_total(balance: dict[str, Any]) -> float | None:
|
||||||
|
"""从 ccxt balance 结构中尽量取出 USDT 总额(与 crypto_monitor_binance 一致)。"""
|
||||||
|
usdt_info = balance.get("USDT") or {}
|
||||||
|
if not isinstance(usdt_info, dict):
|
||||||
|
usdt_info = {}
|
||||||
|
total_map = balance.get("total") or {}
|
||||||
|
if not isinstance(total_map, dict):
|
||||||
|
total_map = {}
|
||||||
|
free_map = balance.get("free") or {}
|
||||||
|
if not isinstance(free_map, dict):
|
||||||
|
free_map = {}
|
||||||
|
total = usdt_info.get("total")
|
||||||
|
if total is None:
|
||||||
|
total = usdt_info.get("equity")
|
||||||
|
if total is None:
|
||||||
|
total = total_map.get("USDT")
|
||||||
|
if total is None:
|
||||||
|
total = usdt_info.get("free")
|
||||||
|
if total is None:
|
||||||
|
total = free_map.get("USDT")
|
||||||
|
try:
|
||||||
|
return float(total) if total is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _binance_futures_usdt_asset_row(balance: Any) -> dict[str, Any] | None:
|
||||||
|
"""U 本位合约 fetch_balance(type=swap) 的 info.assets 中 USDT 一行(与币安合约后台口径一致)。"""
|
||||||
|
if not isinstance(balance, dict):
|
||||||
|
return None
|
||||||
|
info = balance.get("info")
|
||||||
|
if not isinstance(info, dict):
|
||||||
|
return None
|
||||||
|
assets = info.get("assets")
|
||||||
|
if not isinstance(assets, list):
|
||||||
|
return None
|
||||||
|
for a in assets:
|
||||||
|
if isinstance(a, dict) and str(a.get("asset") or "").upper() == "USDT":
|
||||||
|
return a
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _binance_swap_usdt_total(ex: Any) -> float | None:
|
||||||
|
"""仅 U 本位永续合约账户 USDT(显式 type=swap,不用现货余额)。"""
|
||||||
|
try:
|
||||||
|
bal = ex.fetch_balance({"type": "swap"})
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
row = _binance_futures_usdt_asset_row(bal)
|
||||||
|
if row:
|
||||||
|
for k in ("marginBalance", "walletBalance", "crossWalletBalance", "balance"):
|
||||||
|
x = row.get(k)
|
||||||
|
if x is not None and str(x).strip() != "":
|
||||||
|
try:
|
||||||
|
fv = float(x)
|
||||||
|
if fv >= 0:
|
||||||
|
return fv
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
v = _extract_usdt_total(bal)
|
||||||
|
return float(v) if v is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def local_only(request: Request, call_next):
|
||||||
|
if request.client and not _is_local(request.client.host):
|
||||||
|
return JSONResponse({"detail": "forbidden"}, status_code=403)
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"ok": True, "exchange": EXCHANGE_KIND}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/status")
|
||||||
|
def status(x_control_token: str | None = Header(default=None, alias="X-Control-Token")):
|
||||||
|
try:
|
||||||
|
return _status_inner(x_control_token)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"error": f"status: {e}",
|
||||||
|
"exchange": EXCHANGE_KIND,
|
||||||
|
"balance_usdt": None,
|
||||||
|
"positions": [],
|
||||||
|
"total_unrealized_pnl": None,
|
||||||
|
},
|
||||||
|
status_code=200,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _status_inner(x_control_token: str | None) -> Any:
|
||||||
|
_check_token(x_control_token)
|
||||||
|
try:
|
||||||
|
ex = get_exchange()
|
||||||
|
except RuntimeError as e:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"error": str(e),
|
||||||
|
"exchange": EXCHANGE_KIND,
|
||||||
|
"balance_usdt": None,
|
||||||
|
"positions": [],
|
||||||
|
"total_unrealized_pnl": None,
|
||||||
|
},
|
||||||
|
status_code=200,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
_ensure_markets()
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"error": f"load_markets: {e}",
|
||||||
|
"exchange": EXCHANGE_KIND,
|
||||||
|
"balance_usdt": None,
|
||||||
|
"positions": [],
|
||||||
|
"total_unrealized_pnl": None,
|
||||||
|
},
|
||||||
|
status_code=200,
|
||||||
|
)
|
||||||
|
balance_usdt: float | None = None
|
||||||
|
try:
|
||||||
|
if EXCHANGE_KIND == "binance":
|
||||||
|
balance_usdt = _binance_swap_usdt_total(ex)
|
||||||
|
else:
|
||||||
|
bal = ex.fetch_balance()
|
||||||
|
u = bal.get("USDT") or {}
|
||||||
|
if isinstance(u, dict) and u.get("total") is not None:
|
||||||
|
balance_usdt = _finite_or_none(u["total"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
positions_out: list[dict[str, Any]] = []
|
||||||
|
total_upnl = 0.0
|
||||||
|
try:
|
||||||
|
raw = ex.fetch_positions() or []
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"error": str(e),
|
||||||
|
"exchange": EXCHANGE_KIND,
|
||||||
|
"balance_usdt": balance_usdt,
|
||||||
|
"positions": [],
|
||||||
|
"total_unrealized_pnl": None,
|
||||||
|
},
|
||||||
|
status_code=200,
|
||||||
|
)
|
||||||
|
|
||||||
|
for p in raw:
|
||||||
|
if not isinstance(p, dict):
|
||||||
|
continue
|
||||||
|
c = _position_contracts(p)
|
||||||
|
if abs(c) < 1e-12:
|
||||||
|
continue
|
||||||
|
sym = p.get("symbol") or ""
|
||||||
|
side = _position_side(p, c)
|
||||||
|
upnl = p.get("unrealizedPnl")
|
||||||
|
try:
|
||||||
|
upnl_f = float(upnl) if upnl is not None else 0.0
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
upnl_f = 0.0
|
||||||
|
total_upnl += upnl_f
|
||||||
|
notional = p.get("notional")
|
||||||
|
try:
|
||||||
|
notional_f = float(notional) if notional is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
notional_f = None
|
||||||
|
entry = p.get("entryPrice")
|
||||||
|
try:
|
||||||
|
entry_f = float(entry) if entry is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
entry_f = None
|
||||||
|
positions_out.append(
|
||||||
|
{
|
||||||
|
"symbol": sym,
|
||||||
|
"side": side,
|
||||||
|
"contracts": abs(c),
|
||||||
|
"contracts_signed": c,
|
||||||
|
"notional_usdt": _finite_or_none(notional_f) if notional_f is not None else None,
|
||||||
|
"unrealized_pnl": _finite_or_none(upnl_f),
|
||||||
|
"entry_price": _finite_or_none(entry_f) if entry_f is not None else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
pm = _position_mode_label()
|
||||||
|
except Exception:
|
||||||
|
pm = EXCHANGE_KIND
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"exchange": EXCHANGE_KIND,
|
||||||
|
"balance_usdt": balance_usdt,
|
||||||
|
"positions": positions_out,
|
||||||
|
"total_unrealized_pnl": _finite_or_none(total_upnl),
|
||||||
|
"position_mode": pm,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/emergency/close-all")
|
||||||
|
def emergency_close_all(x_control_token: str | None = Header(default=None, alias="X-Control-Token")):
|
||||||
|
_check_token(x_control_token)
|
||||||
|
try:
|
||||||
|
ex = get_exchange()
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=503, detail=str(e)) from e
|
||||||
|
try:
|
||||||
|
_ensure_markets()
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": False, "error": f"load_markets: {e}", "closed": [], "errors": [str(e)], "exchange": EXCHANGE_KIND},
|
||||||
|
status_code=200,
|
||||||
|
)
|
||||||
|
errors: list[str] = []
|
||||||
|
closed: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = ex.fetch_positions() or []
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=502, detail=f"fetch_positions: {e}") from e
|
||||||
|
|
||||||
|
for p in raw:
|
||||||
|
if not isinstance(p, dict):
|
||||||
|
continue
|
||||||
|
c = _position_contracts(p)
|
||||||
|
if abs(c) < 1e-12:
|
||||||
|
continue
|
||||||
|
sym = p.get("symbol")
|
||||||
|
if not sym:
|
||||||
|
continue
|
||||||
|
side = _position_side(p, c)
|
||||||
|
close_side = "sell" if side == "long" else "buy"
|
||||||
|
direction = "long" if side == "long" else "short"
|
||||||
|
try:
|
||||||
|
amt = float(ex.amount_to_precision(sym, abs(c)))
|
||||||
|
except Exception:
|
||||||
|
amt = abs(c)
|
||||||
|
if amt <= 0:
|
||||||
|
continue
|
||||||
|
order_resp = None
|
||||||
|
last_err: Exception | None = None
|
||||||
|
for params in _close_param_candidates(direction):
|
||||||
|
try:
|
||||||
|
order_resp = ex.create_order(sym, "market", close_side, amt, None, params)
|
||||||
|
last_err = None
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
last_err = e
|
||||||
|
if _retryable_close_err(str(e)):
|
||||||
|
continue
|
||||||
|
errors.append(f"{sym}: {e}")
|
||||||
|
order_resp = None
|
||||||
|
break
|
||||||
|
if order_resp is None and last_err and sym not in "".join(errors):
|
||||||
|
errors.append(f"{sym}: {last_err}")
|
||||||
|
if order_resp is not None:
|
||||||
|
closed.append({"symbol": sym, "side": side, "amount": amt, "order_id": order_resp.get("id")})
|
||||||
|
_cancel_symbol_orders(ex, sym)
|
||||||
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
return {"ok": len(errors) == 0, "closed": closed, "errors": errors, "exchange": EXCHANGE_KIND}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
uvicorn.run(app, host=HOST, port=PORT, log_level="warning", access_log=False)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
"""
|
||||||
|
中控:聚合各子账户 /status,转发紧急全平。
|
||||||
|
|
||||||
|
默认 **HUB_HOST=0.0.0.0** 且 **HUB_TRUST_LAN=开启**,便于局域网内浏览器访问;中间件仍拒绝非公网、非 RFC1918 私网的来源(本机 127.0.0.1 始终允许)。
|
||||||
|
若仅需本机访问,请设置:HUB_HOST=127.0.0.1 或 HUB_TRUST_LAN=0(false/off)。
|
||||||
|
|
||||||
|
与仓库根目录下四个策略/监控项目对应时,中控默认聚合的子代理地址为 127.0.0.1:15200–15203
|
||||||
|
(与各 crypto_monitor_* 里 Flask 的 APP_PORT 错开;Flask 仍用各自 .env 的 APP_HOST/APP_PORT)。
|
||||||
|
|
||||||
|
crypto_monitor_binance → 子代理建议 15200
|
||||||
|
crypto_monitor_okx → 子代理建议 15201
|
||||||
|
crypto_monitor_gate → 子代理建议 15202
|
||||||
|
crypto_monitor_gate_bot→ 子代理建议 15203
|
||||||
|
|
||||||
|
各目录单独启动 agent.py 时设置 PORT=上述端口(环境变量名是 PORT,不是 APP_PORT),与 Flask 并存。
|
||||||
|
|
||||||
|
环境变量:
|
||||||
|
HUB_PORT 默认 5100
|
||||||
|
HUB_HOST 默认 0.0.0.0(局域网可连);改为 127.0.0.1 则仅本机
|
||||||
|
HUB_AGENTS 逗号分隔子代理 URL,留空则默认 15200–15203(避免与 Flask APP_PORT 冲突)
|
||||||
|
HUB_AGENT_NAMES 可选,逗号分隔显示名,与 URL 顺序对应
|
||||||
|
HUB_DISABLED_IDS 可选,逗号分隔不参与监控/全平的账户 id(与 /api/agents 中 id 一致),例:暂不用 OKX 时写 1
|
||||||
|
CONTROL_TOKEN 若子代理启用校验,在此填同一令牌(由中控代发请求头)
|
||||||
|
HUB_TRUST_LAN 默认开启;设为 0/false/off 则仅允许本机 IP 访问(与 HUB_HOST=0.0.0.0 搭配时仍只放行 127.0.0.1)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import Body, FastAPI, HTTPException, Query, Request
|
||||||
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
HUB_HOST = os.getenv("HUB_HOST", "0.0.0.0")
|
||||||
|
HUB_PORT = int(os.getenv("HUB_PORT", "5100"))
|
||||||
|
CONTROL_TOKEN = (os.getenv("CONTROL_TOKEN") or "").strip()
|
||||||
|
_trust_raw = (os.getenv("HUB_TRUST_LAN", "true") or "").strip().lower()
|
||||||
|
HUB_TRUST_LAN = _trust_raw not in ("0", "false", "no", "off")
|
||||||
|
DIR = Path(__file__).resolve().parent
|
||||||
|
_REPO_ROOT = DIR.parent
|
||||||
|
if str(_REPO_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_REPO_ROOT))
|
||||||
|
import license_lib # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _is_local(host: str | None) -> bool:
|
||||||
|
if not host:
|
||||||
|
return False
|
||||||
|
h = host.lower()
|
||||||
|
return h in ("127.0.0.1", "::1", "localhost") or h.startswith("::ffff:127.0.0.1")
|
||||||
|
|
||||||
|
|
||||||
|
def _ipv4_rfc1918_private(host: str) -> bool:
|
||||||
|
h = host.lower()
|
||||||
|
if h.startswith("::ffff:"):
|
||||||
|
h = h[7:]
|
||||||
|
parts = h.split(".")
|
||||||
|
if len(parts) != 4:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
a, b, c, d = (int(x) for x in parts)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
if any(x < 0 or x > 255 for x in (a, b, c, d)):
|
||||||
|
return False
|
||||||
|
if a == 10:
|
||||||
|
return True
|
||||||
|
if a == 172 and 16 <= b <= 31:
|
||||||
|
return True
|
||||||
|
if a == 192 and b == 168:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _client_allowed(host: str | None) -> bool:
|
||||||
|
if _is_local(host):
|
||||||
|
return True
|
||||||
|
if HUB_TRUST_LAN and host and _ipv4_rfc1918_private(host):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_headers() -> dict[str, str]:
|
||||||
|
if not CONTROL_TOKEN:
|
||||||
|
return {}
|
||||||
|
return {"X-Control-Token": CONTROL_TOKEN}
|
||||||
|
|
||||||
|
|
||||||
|
_DEFAULT_FOLDER_LABELS = (
|
||||||
|
"币安山寨账户 · crypto_monitor_binance",
|
||||||
|
"OKX · crypto_monitor_okx",
|
||||||
|
"Gate训练账户 · crypto_monitor_gate",
|
||||||
|
"Gate趋势回调 · crypto_monitor_gate_bot",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ids_from_csv(raw: str | None) -> set[str]:
|
||||||
|
if not raw or not str(raw).strip():
|
||||||
|
return set()
|
||||||
|
return {x.strip() for x in str(raw).split(",") if x.strip()}
|
||||||
|
|
||||||
|
|
||||||
|
def hub_env_excluded_ids() -> set[str]:
|
||||||
|
"""服务端固定关闭的账户(不参与拉取 /status、不参与全局全平)。"""
|
||||||
|
return _ids_from_csv(os.getenv("HUB_DISABLED_IDS"))
|
||||||
|
|
||||||
|
|
||||||
|
def merged_excluded_ids(query_exclude: str | None, body_ids: list[str] | None) -> set[str]:
|
||||||
|
s = hub_env_excluded_ids()
|
||||||
|
s |= _ids_from_csv(query_exclude)
|
||||||
|
if body_ids:
|
||||||
|
s |= {str(x).strip() for x in body_ids if str(x).strip()}
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def parse_agents() -> list[dict[str, str]]:
|
||||||
|
urls_s = (os.getenv("HUB_AGENTS") or "").strip()
|
||||||
|
if urls_s:
|
||||||
|
urls = [u.strip() for u in urls_s.split(",") if u.strip()]
|
||||||
|
else:
|
||||||
|
urls = [f"http://127.0.0.1:{p}" for p in range(15200, 15204)]
|
||||||
|
# 注意:若环境变量 HUB_AGENT_NAMES 非空,会完全优先于 _DEFAULT_FOLDER_LABELS(改代码不生效时请检查是否设了该变量)
|
||||||
|
names_s = (os.getenv("HUB_AGENT_NAMES") or "").strip()
|
||||||
|
names = [n.strip() for n in names_s.split(",") if n.strip()] if names_s else []
|
||||||
|
out = []
|
||||||
|
for i, url in enumerate(urls):
|
||||||
|
if i < len(names):
|
||||||
|
name = names[i]
|
||||||
|
elif i < len(_DEFAULT_FOLDER_LABELS):
|
||||||
|
name = _DEFAULT_FOLDER_LABELS[i]
|
||||||
|
else:
|
||||||
|
name = f"账户{i + 1}"
|
||||||
|
out.append({"id": str(i), "name": name, "url": url.rstrip("/")})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="hub", docs_url=None, redoc_url=None)
|
||||||
|
license_lib.init_fastapi_app(app)
|
||||||
|
STATIC_DIR = DIR / "static"
|
||||||
|
if STATIC_DIR.is_dir():
|
||||||
|
app.mount("/assets", StaticFiles(directory=str(STATIC_DIR)), name="assets")
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def local_only(request: Request, call_next):
|
||||||
|
if request.client and not _client_allowed(request.client.host):
|
||||||
|
return JSONResponse({"detail": "forbidden"}, status_code=403)
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def index_page():
|
||||||
|
index = STATIC_DIR / "index.html"
|
||||||
|
if not index.is_file():
|
||||||
|
return JSONResponse({"detail": "missing static/index.html"}, status_code=500)
|
||||||
|
return FileResponse(index)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/agents")
|
||||||
|
def api_agents():
|
||||||
|
return {"agents": parse_agents()}
|
||||||
|
|
||||||
|
|
||||||
|
class CloseAllBody(BaseModel):
|
||||||
|
exclude_ids: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/snapshot")
|
||||||
|
async def api_snapshot(
|
||||||
|
exclude_ids: str | None = Query(
|
||||||
|
default=None,
|
||||||
|
description="逗号分隔,浏览器侧再关闭的账户 id,与服务端 HUB_DISABLED_IDS 合并",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
excl = merged_excluded_ids(exclude_ids, None)
|
||||||
|
agents = [a for a in parse_agents() if a["id"] not in excl]
|
||||||
|
headers = _agent_headers()
|
||||||
|
|
||||||
|
async def one(client: httpx.AsyncClient, a: dict[str, str]) -> dict:
|
||||||
|
url = f"{a['url']}/status"
|
||||||
|
try:
|
||||||
|
r = await client.get(url, headers=headers, timeout=10.0)
|
||||||
|
body = None
|
||||||
|
if r.content:
|
||||||
|
try:
|
||||||
|
body = r.json()
|
||||||
|
except Exception as je:
|
||||||
|
preview = (r.text or "")[:400].replace("\n", " ")
|
||||||
|
return {
|
||||||
|
"id": a["id"],
|
||||||
|
"name": a["name"],
|
||||||
|
"url": a["url"],
|
||||||
|
"http_ok": False,
|
||||||
|
"status_code": r.status_code,
|
||||||
|
"error": f"子代理返回非 JSON({je})。响应片段: {preview!r}",
|
||||||
|
"payload": None,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"id": a["id"],
|
||||||
|
"name": a["name"],
|
||||||
|
"url": a["url"],
|
||||||
|
"http_ok": r.status_code == 200,
|
||||||
|
"status_code": r.status_code,
|
||||||
|
"payload": body,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"id": a["id"],
|
||||||
|
"name": a["name"],
|
||||||
|
"url": a["url"],
|
||||||
|
"http_ok": False,
|
||||||
|
"status_code": None,
|
||||||
|
"error": str(e),
|
||||||
|
"payload": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
rows = await asyncio.gather(*[one(client, a) for a in agents])
|
||||||
|
env_ex = sorted(hub_env_excluded_ids())
|
||||||
|
return {"rows": list(rows), "env_excluded_ids": env_ex}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/close/{agent_id}")
|
||||||
|
async def api_close_one(agent_id: str):
|
||||||
|
agents = parse_agents()
|
||||||
|
target = next((a for a in agents if a["id"] == agent_id), None)
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(status_code=404, detail="unknown agent")
|
||||||
|
headers = _agent_headers()
|
||||||
|
url = f"{target['url']}/emergency/close-all"
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
r = await client.post(url, headers=headers, timeout=120.0)
|
||||||
|
try:
|
||||||
|
body = r.json()
|
||||||
|
except Exception:
|
||||||
|
body = {"raw": r.text[:2000]}
|
||||||
|
return {"agent": target, "status_code": r.status_code, "payload": body}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=502, detail=str(e)) from e
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/close-all")
|
||||||
|
async def api_close_all(body: CloseAllBody | None = Body(default=None)):
|
||||||
|
excl = merged_excluded_ids(None, body.exclude_ids if body else None)
|
||||||
|
agents = [a for a in parse_agents() if a["id"] not in excl]
|
||||||
|
headers = _agent_headers()
|
||||||
|
|
||||||
|
async def post_close(client: httpx.AsyncClient, a: dict[str, str]) -> dict:
|
||||||
|
url = f"{a['url']}/emergency/close-all"
|
||||||
|
try:
|
||||||
|
r = await client.post(url, headers=headers, timeout=120.0)
|
||||||
|
try:
|
||||||
|
body = r.json()
|
||||||
|
except Exception:
|
||||||
|
body = {"raw": r.text[:2000]}
|
||||||
|
return {"id": a["id"], "name": a["name"], "status_code": r.status_code, "payload": body}
|
||||||
|
except Exception as e:
|
||||||
|
return {"id": a["id"], "name": a["name"], "status_code": None, "error": str(e)}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
results = await asyncio.gather(*[post_close(client, a) for a in agents])
|
||||||
|
return {"results": list(results)}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
uvicorn.run(app, host=HUB_HOST, port=HUB_PORT, log_level="warning", access_log=False)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
fastapi>=0.110,<1
|
||||||
|
uvicorn[standard]>=0.27,<1
|
||||||
|
httpx>=0.27,<1
|
||||||
|
ccxt>=4.2,<5
|
||||||
|
PySocks>=1.7,<2
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=手工交易子代理 Binance(复制为 manual-agent-binance.service 并修改路径)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=YOUR_USER
|
||||||
|
WorkingDirectory=YOUR_REPO/crypto_monitor_binance
|
||||||
|
Environment=PATH=YOUR_REPO/manual_trading_hub/.venv/bin:/usr/bin:/bin
|
||||||
|
Environment=EXCHANGE=binance
|
||||||
|
Environment=PORT=15200
|
||||||
|
Environment=HOST=127.0.0.1
|
||||||
|
EnvironmentFile=-YOUR_REPO/crypto_monitor_binance/.env
|
||||||
|
ExecStart=YOUR_REPO/manual_trading_hub/.venv/bin/python YOUR_REPO/manual_trading_hub/agent.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=手工交易中控 hub(复制到 /etc/systemd/system/manual-hub.service 并修改路径)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=YOUR_USER
|
||||||
|
WorkingDirectory=YOUR_REPO/manual_trading_hub
|
||||||
|
Environment=HUB_HOST=0.0.0.0
|
||||||
|
Environment=HUB_TRUST_LAN=1
|
||||||
|
Environment=HUB_PORT=5100
|
||||||
|
ExecStart=YOUR_REPO/manual_trading_hub/.venv/bin/python YOUR_REPO/manual_trading_hub/hub.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 一键用 screen 后台启动 3 个子代理(不含 OKX):Binance:15200、Gate:15202、Gate-Bot:15203
|
||||||
|
# 用法:在任意目录执行 bash /path/to/manual_trading_hub/scripts/start_agents_3screen.sh
|
||||||
|
# 若 hub 单独在 /opt/manual_trading_hub,四个策略目录在别的路径,请先:
|
||||||
|
# export MANUAL_TRADING_REPO_ROOT=/path/to/含_crypto_monitor_*_的目录
|
||||||
|
# 依赖:各策略目录已 cp .env.example .env 并配置;manual_trading_hub/.venv 已 pip install -r requirements.txt
|
||||||
|
# 日志:manual_trading_hub/logs/<会话名>.log(若 screen 里进程秒退,tail 该文件排查)
|
||||||
|
# 查看:screen -ls 接入:screen -r mt-agent-bn 停:./stop_agents_3screen.sh
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
HUB_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
if [[ -n "${MANUAL_TRADING_REPO_ROOT:-}" ]]; then
|
||||||
|
REPO_ROOT="$(cd "${MANUAL_TRADING_REPO_ROOT}" && pwd)"
|
||||||
|
else
|
||||||
|
REPO_ROOT="$(cd "${HUB_DIR}/.." && pwd)"
|
||||||
|
fi
|
||||||
|
AGENT_PY="${HUB_DIR}/agent.py"
|
||||||
|
VENV_PY="${HUB_DIR}/.venv/bin/python"
|
||||||
|
LOG_DIR="${HUB_DIR}/logs"
|
||||||
|
mkdir -p "${LOG_DIR}"
|
||||||
|
|
||||||
|
echo "REPO_ROOT=${REPO_ROOT} (其下应有 crypto_monitor_binance、crypto_monitor_gate 等目录)"
|
||||||
|
|
||||||
|
if ! command -v screen >/dev/null 2>&1; then
|
||||||
|
echo "未找到 screen,请先安装:sudo apt install screen" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "${VENV_PY}" ]]; then
|
||||||
|
echo "未找到 ${VENV_PY},请先在 manual_trading_hub 下创建 venv 并 pip install -r requirements.txt" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "${AGENT_PY}" ]]; then
|
||||||
|
echo "未找到 agent.py:${AGENT_PY}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
start_one() {
|
||||||
|
local name="$1" subdir="$2" exchange="$3" port="$4"
|
||||||
|
local work="${REPO_ROOT}/${subdir}"
|
||||||
|
local logf="${LOG_DIR}/${name}.log"
|
||||||
|
if [[ ! -d "${work}" ]]; then
|
||||||
|
echo "目录不存在,跳过:${work}" >&2
|
||||||
|
echo " 若项目在别处,请设置 export MANUAL_TRADING_REPO_ROOT=/正确上级目录 后重跑" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if screen -ls 2>/dev/null | grep -qF ".${name}"; then
|
||||||
|
echo "已存在会话 ${name},跳过。要重建请先: screen -S ${name} -X quit" >&2
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
screen -dmS "${name}" bash -c "
|
||||||
|
cd '${work}' || { echo 'cd failed' >>'${logf}'; exit 1; }
|
||||||
|
set -a
|
||||||
|
if [[ -f .env ]]; then
|
||||||
|
. ./.env
|
||||||
|
fi
|
||||||
|
set +a
|
||||||
|
export EXCHANGE='${exchange}' PORT='${port}' HOST=127.0.0.1
|
||||||
|
exec '${VENV_PY}' '${AGENT_PY}' >>'${logf}' 2>&1
|
||||||
|
"
|
||||||
|
sleep 0.5
|
||||||
|
if screen -ls 2>/dev/null | grep -qF ".${name}"; then
|
||||||
|
echo "已启动 screen:${name} (${subdir} EXCHANGE=${exchange} PORT=${port}) 日志:${logf}"
|
||||||
|
else
|
||||||
|
echo "错误:${name} 启动后立刻退出。请执行: tail -80 '${logf}'" >&2
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
start_one "mt-agent-bn" "crypto_monitor_binance" "binance" "15200"
|
||||||
|
start_one "mt-agent-gate" "crypto_monitor_gate" "gate" "15202"
|
||||||
|
start_one "mt-agent-gatebot" "crypto_monitor_gate_bot" "gate" "15203"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "下一步(一键中控 screen):"
|
||||||
|
echo " chmod +x ${SCRIPT_DIR}/start_hub_screen.sh && ${SCRIPT_DIR}/start_hub_screen.sh"
|
||||||
|
echo "或手动:cd ${HUB_DIR} && source .venv/bin/activate && export HUB_AGENTS=... && python hub.py"
|
||||||
|
echo ""
|
||||||
|
echo "查看会话: screen -ls"
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 一键用 screen 后台启动中控 hub.py(默认对接 3 个 agent:不含 OKX)
|
||||||
|
# 用法:先启动 3 个 agent(start_agents_3screen.sh),再执行本脚本
|
||||||
|
# bash /path/to/manual_trading_hub/scripts/start_hub_screen.sh
|
||||||
|
# 可在运行前 export 覆盖:HUB_AGENTS、HUB_AGENT_NAMES、HUB_HOST、HUB_PORT、CONTROL_TOKEN、HUB_TRUST_LAN
|
||||||
|
# 默认 HUB_HOST=0.0.0.0、HUB_TRUST_LAN=1(局域网可访问私网 IP)。仅本机: export HUB_HOST=127.0.0.1 HUB_TRUST_LAN=0
|
||||||
|
# 查看:screen -ls 接入:screen -r mt-hub 停:./stop_hub_screen.sh
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
HUB_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
VENV_PY="${HUB_DIR}/.venv/bin/python"
|
||||||
|
HUB_PY="${HUB_DIR}/hub.py"
|
||||||
|
|
||||||
|
HUB_AGENTS="${HUB_AGENTS:-http://127.0.0.1:15200,http://127.0.0.1:15202,http://127.0.0.1:15203}"
|
||||||
|
# 不设 HUB_AGENT_NAMES 时不在此写死,由 hub.py 里 _DEFAULT_FOLDER_LABELS 生效;要覆盖可: export HUB_AGENT_NAMES="名1,名2,名3" 或写在 manual_trading_hub/.env
|
||||||
|
HUB_AGENT_NAMES="${HUB_AGENT_NAMES:-}"
|
||||||
|
HUB_HOST="${HUB_HOST:-0.0.0.0}"
|
||||||
|
HUB_PORT="${HUB_PORT:-5100}"
|
||||||
|
HUB_TRUST_LAN="${HUB_TRUST_LAN:-1}"
|
||||||
|
|
||||||
|
if ! command -v screen >/dev/null 2>&1; then
|
||||||
|
echo "未找到 screen,请先安装:sudo apt install screen" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "${VENV_PY}" ]]; then
|
||||||
|
echo "未找到 ${VENV_PY},请先在 manual_trading_hub 下创建 venv 并 pip install -r requirements.txt" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "${HUB_PY}" ]]; then
|
||||||
|
echo "未找到 hub.py:${HUB_PY}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if screen -ls 2>/dev/null | grep -qF ".mt-hub"; then
|
||||||
|
echo "已存在会话 mt-hub,跳过。要重建请先: screen -S mt-hub -X quit" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 仅当外层显式设置了 HUB_AGENT_NAMES 时才 export,避免 export 空串覆盖 .env 里已有配置
|
||||||
|
EXTRA_NAMES=""
|
||||||
|
if [[ -n "${HUB_AGENT_NAMES:-}" ]]; then
|
||||||
|
EXTRA_NAMES="export HUB_AGENT_NAMES=$(printf '%q' "${HUB_AGENT_NAMES}")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
screen -dmS mt-hub bash -c "
|
||||||
|
set -e
|
||||||
|
cd '${HUB_DIR}'
|
||||||
|
set -a
|
||||||
|
if [[ -f .env ]]; then
|
||||||
|
. ./.env
|
||||||
|
fi
|
||||||
|
set +a
|
||||||
|
export HUB_AGENTS='${HUB_AGENTS}'
|
||||||
|
${EXTRA_NAMES}
|
||||||
|
export HUB_HOST='${HUB_HOST}'
|
||||||
|
export HUB_PORT='${HUB_PORT}'
|
||||||
|
export HUB_TRUST_LAN='${HUB_TRUST_LAN}'
|
||||||
|
exec '${VENV_PY}' '${HUB_PY}'
|
||||||
|
"
|
||||||
|
|
||||||
|
echo "已启动 screen:mt-hub"
|
||||||
|
echo " HUB_AGENTS=${HUB_AGENTS}"
|
||||||
|
if [[ -n "${HUB_AGENT_NAMES}" ]]; then
|
||||||
|
echo " HUB_AGENT_NAMES=${HUB_AGENT_NAMES}"
|
||||||
|
else
|
||||||
|
echo " HUB_AGENT_NAMES=(未设,使用 hub.py 内 _DEFAULT_FOLDER_LABELS)"
|
||||||
|
fi
|
||||||
|
echo " 监听:${HUB_HOST}:${HUB_PORT} HUB_TRUST_LAN=${HUB_TRUST_LAN}(默认允许私网访问中控)"
|
||||||
|
echo " 本机:http://127.0.0.1:${HUB_PORT}/ 局域网:http://<本机局域网IP>:${HUB_PORT}/"
|
||||||
|
echo " 仅本机请: export HUB_HOST=127.0.0.1 HUB_TRUST_LAN=0 后重启本脚本"
|
||||||
|
echo "接入: screen -r mt-hub"
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 关闭 start_agents_3screen.sh 启动的 3 个 screen 会话
|
||||||
|
set -euo pipefail
|
||||||
|
for s in mt-agent-bn mt-agent-gate mt-agent-gatebot; do
|
||||||
|
if screen -ls 2>/dev/null | grep -qF ".${s}"; then
|
||||||
|
screen -S "${s}" -X quit && echo "已关闭:${s}" || true
|
||||||
|
else
|
||||||
|
echo "未运行:${s}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 关闭 start_hub_screen.sh 启动的中控 screen 会话
|
||||||
|
set -euo pipefail
|
||||||
|
if screen -ls 2>/dev/null | grep -qF ".mt-hub"; then
|
||||||
|
screen -S mt-hub -X quit && echo "已关闭:mt-hub" || true
|
||||||
|
else
|
||||||
|
echo "未运行:mt-hub"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# Ubuntu 后台运行(中控 + 子代理)
|
||||||
|
|
||||||
|
各 `crypto_monitor_*` 策略目录:首次 **`cp .env.example .env`** 并编辑;**`.env` 不进 Git**,`git pull` 不覆盖。升级前建议 `cp .env .env.backup.$(date +%Y%m%d)`。子代理启动前须 **`source` 该目录 `.env`**(见下文)。
|
||||||
|
|
||||||
|
前台跑 `python agent.py` / `python hub.py` 时,关掉终端进程会结束。要**常驻后台**,可用下面三种之一(推荐 **systemd**)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、tmux / screen(最简单,适合先试用)
|
||||||
|
|
||||||
|
仓库已提供 **一键起 3 个 agent(不含 OKX)** 的 screen 脚本(需可执行权限):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x manual_trading_hub/scripts/start_agents_3screen.sh
|
||||||
|
chmod +x manual_trading_hub/scripts/start_hub_screen.sh
|
||||||
|
chmod +x manual_trading_hub/scripts/stop_agents_3screen.sh
|
||||||
|
chmod +x manual_trading_hub/scripts/stop_hub_screen.sh
|
||||||
|
./manual_trading_hub/scripts/start_agents_3screen.sh
|
||||||
|
./manual_trading_hub/scripts/start_hub_screen.sh
|
||||||
|
# 关闭:
|
||||||
|
./manual_trading_hub/scripts/stop_hub_screen.sh
|
||||||
|
./manual_trading_hub/scripts/stop_agents_3screen.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本默认认为:`manual_trading_hub` 的**上一级目录**里并列放着三个 `crypto_monitor_*`。若你把 hub 单独放在 `/opt/crypto_monitor/manual_trading_hub`,而策略项目在例如 `/opt/crypto_monitor/` 下的其他位置,请先执行
|
||||||
|
`export MANUAL_TRADING_REPO_ROOT=/opt/crypto_monitor` 再运行 `start_agents_3screen.sh`。
|
||||||
|
启动后若 `screen -ls` 里没有 `mt-agent-*`,看日志:`tail -80 /opt/crypto_monitor/manual_trading_hub/logs/mt-agent-bn.log`。
|
||||||
|
|
||||||
|
### 局域网内其他电脑访问中控
|
||||||
|
|
||||||
|
中控 **默认** `HUB_HOST=0.0.0.0`、`HUB_TRUST_LAN=开启`,同一局域网内可用 `http://<中控机局域网IP>:5100/` 打开页面(本机仍可用 `http://127.0.0.1:5100/`)。请确保防火墙放行端口,例如:`sudo ufw allow 5100/tcp`。
|
||||||
|
|
||||||
|
若改为 **仅本机** 访问:`HUB_HOST=127.0.0.1` 或 `HUB_TRUST_LAN=0`,重启 hub。
|
||||||
|
|
||||||
|
也可把上述变量写进 `manual_trading_hub/.env`,再用 `start_hub_screen.sh` 启动。
|
||||||
|
|
||||||
|
以下为手工 tmux 示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 新建会话,在里面照常启动 agent 或 hub,然后按键 Ctrl+B 再按 D 脱离
|
||||||
|
tmux new -s hub
|
||||||
|
cd /opt/crypto_monitor/manual_trading_hub && source .venv/bin/activate && python hub.py
|
||||||
|
# Ctrl+B, D
|
||||||
|
|
||||||
|
tmux new -s agent-bn
|
||||||
|
cd /opt/crypto_monitor/crypto_monitor_binance && set -a && source .env && set +a
|
||||||
|
export EXCHANGE=binance PORT=15200 HOST=127.0.0.1
|
||||||
|
source /opt/crypto_monitor/manual_trading_hub/.venv/bin/activate
|
||||||
|
python /opt/crypto_monitor/manual_trading_hub/agent.py
|
||||||
|
# Ctrl+B, D
|
||||||
|
```
|
||||||
|
|
||||||
|
重新连上:`tmux attach -t hub`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、nohup(快速、无守护重启)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/crypto_monitor/manual_trading_hub
|
||||||
|
source .venv/bin/activate
|
||||||
|
nohup python hub.py > /tmp/manual-hub.log 2>&1 &
|
||||||
|
```
|
||||||
|
|
||||||
|
子代理同理(每个账户一条):**先 `source` 该策略目录的 `.env`**(`agent.py` 不会自己读文件),再 `nohup python …/agent.py`。
|
||||||
|
|
||||||
|
停进程:`ps aux | grep hub.py` 或 `grep agent.py`,再 `kill <pid>`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、systemd(推荐:开机自启、崩溃自动拉起)
|
||||||
|
|
||||||
|
1. 把下面两个示例里的 **`YOUR_REPO`** 改成 **`/opt/crypto_monitor`**(或你本机实际仓库根目录),`YOUR_USER` 改成 Linux 用户名。
|
||||||
|
2. 复制到 `/etc/systemd/system/`(需 sudo),文件名例如 `manual-hub.service`、`manual-agent-binance.service`。
|
||||||
|
3. 执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now manual-hub.service
|
||||||
|
sudo systemctl enable --now manual-agent-binance.service
|
||||||
|
# 其余 OKX / Gate 同理再建 3 个 unit 或合并为多条
|
||||||
|
```
|
||||||
|
|
||||||
|
查看状态:`sudo systemctl status manual-hub`
|
||||||
|
日志:`journalctl -u manual-hub -f`
|
||||||
|
|
||||||
|
### 示例:`/etc/systemd/system/manual-hub.service`
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=手工交易中控 hub
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=YOUR_USER
|
||||||
|
WorkingDirectory=YOUR_REPO/manual_trading_hub
|
||||||
|
Environment=HUB_HOST=0.0.0.0
|
||||||
|
Environment=HUB_TRUST_LAN=1
|
||||||
|
Environment=HUB_PORT=5100
|
||||||
|
ExecStart=YOUR_REPO/manual_trading_hub/.venv/bin/python YOUR_REPO/manual_trading_hub/hub.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意:** `agent.py` **不会**像 Flask 那样自动加载目录里的 `.env`,密钥必须由 **systemd 的 `EnvironmentFile=`** 注入,或用下面 `bash -c` 方式 `source .env` 后再启动。
|
||||||
|
|
||||||
|
### 示例:`/etc/systemd/system/manual-agent-binance.service`
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=手工交易子代理 Binance
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=YOUR_USER
|
||||||
|
WorkingDirectory=YOUR_REPO/crypto_monitor_binance
|
||||||
|
Environment=PATH=YOUR_REPO/manual_trading_hub/.venv/bin:/usr/bin:/bin
|
||||||
|
Environment=EXCHANGE=binance
|
||||||
|
Environment=PORT=15200
|
||||||
|
Environment=HOST=127.0.0.1
|
||||||
|
# 把该账户的 .env 注入进程(与 Flask 同一份即可;仅支持 KEY=VALUE 行,勿写 shell 语法)
|
||||||
|
EnvironmentFile=-YOUR_REPO/crypto_monitor_binance/.env
|
||||||
|
ExecStart=YOUR_REPO/manual_trading_hub/.venv/bin/python YOUR_REPO/manual_trading_hub/agent.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
若 `.env` 含 **systemd 无法解析** 的内容(复杂引号、`export` 等),改用:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
ExecStart=/bin/bash -lc 'set -a; source YOUR_REPO/crypto_monitor_binance/.env; set +a; exec YOUR_REPO/manual_trading_hub/.venv/bin/python YOUR_REPO/manual_trading_hub/agent.py'
|
||||||
|
```
|
||||||
|
|
||||||
|
并删掉或注释掉 `EnvironmentFile=` 行,避免重复注入。
|
||||||
|
|
||||||
|
**OKX / Gate / Gate-Bot**:各复制一份 `.service`,改 `Description`、`WorkingDirectory`、以及 `EXCHANGE` / `PORT`(`15201`、`15202`、`15203`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、常见问题(子代理 / screen / 依赖)
|
||||||
|
|
||||||
|
1. **`curl http://127.0.0.1:15202/status`(或其它端口)返回 `ok:false`,错误里提到 pysocks / SOCKS**
|
||||||
|
策略目录 `.env` 里配置了 `GATE_SOCKS_PROXY`(或 `BINANCE_SOCKS_PROXY`、`OKX_SOCKS_PROXY`)时,ccxt 需要 **PySocks**。在 **`/opt/crypto_monitor/manual_trading_hub/.venv`**(或你本机的 `manual_trading_hub/.venv`)中执行:
|
||||||
|
`pip install PySocks` 或 `pip install -r requirements.txt`。
|
||||||
|
|
||||||
|
2. **已经 `pip install PySocks`,错误文案完全不变**
|
||||||
|
子代理是**常驻进程**,首次请求已创建 ccxt;在运行中的进程里**仅安装包不会自动生效**。须**重启**该 agent:例如
|
||||||
|
`screen -S mt-agent-gate -X quit`
|
||||||
|
再执行 `start_agents_3screen.sh`(或你的等价启动方式)。**不要**依赖「会话还在、以为已经更新」的旧进程。
|
||||||
|
|
||||||
|
3. **`start_agents_3screen.sh` 打印「已存在会话、跳过」**
|
||||||
|
脚本检测到 `mt-agent-*` 已在跑会跳过创建。需要先停再启:
|
||||||
|
`./stop_agents_3screen.sh`
|
||||||
|
或对单个会话:`screen -S mt-agent-gate -X quit`,再跑启动脚本。
|
||||||
|
|
||||||
|
4. **确认 15200/15202/15203 上的进程用的是 hub 的 venv**
|
||||||
|
```bash
|
||||||
|
ps aux | grep agent.py
|
||||||
|
tr '\0' ' ' < /proc/<PID>/cmdline; echo
|
||||||
|
```
|
||||||
|
应看到 `…/manual_trading_hub/.venv/bin/python` 与 `…/manual_trading_hub/agent.py`。若用的是系统 `python3`,要么在**同一解释器环境**里装依赖,要么改为用 `start_agents_3screen.sh` 启动(脚本内写死 `VENV_PY`)。
|
||||||
|
|
||||||
|
5. **中控某账户一直红 / 非 JSON**
|
||||||
|
对应该端口的 agent 未启动,或 `HUB_AGENTS` 与 agent 的 `PORT` 不一致。本机先测:
|
||||||
|
`curl -sS http://127.0.0.1:1520x/status | head -c 400`
|
||||||
|
再看 `logs/mt-agent-*.log`。
|
||||||
|
|
||||||
|
6. **子代理端口与 Flask 冲突**
|
||||||
|
agent 使用环境变量 **`PORT`**(脚本里 15200、15202、15203);各策略 `.env` 里的 **`APP_PORT`** 给 Flask。二者**不能**相同。
|
||||||
|
|
||||||
|
7. **systemd 下改依赖或 `.env` 后**
|
||||||
|
与 screen 相同:`pip install` 或改 `EnvironmentFile` 后需 **`systemctl restart <unit>`**,否则仍是旧进程。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、注意
|
||||||
|
|
||||||
|
- 子代理与中控仍建议只监听 **127.0.0.1**;Flask 的 `APP_HOST=0.0.0.0` 与中控无关。
|
||||||
|
- 若策略项目用**自己的** `.venv`,把 `ExecStart` 里的 Python 改成该 venv 的 `python`,但 `agent.py` 路径仍指向 `manual_trading_hub/agent.py`。
|
||||||
|
|
||||||
|
同目录下另有 `example-systemd/*.service.example` 可复制后改路径使用。
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>手工交易中控</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f1216;
|
||||||
|
--panel: #171b22;
|
||||||
|
--text: #e8eaed;
|
||||||
|
--muted: #8b929a;
|
||||||
|
--border: #2a313c;
|
||||||
|
--green: #3fb950;
|
||||||
|
--red: #f85149;
|
||||||
|
--accent: #58a6ff;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: ui-sans-serif, system-ui, "Segoe UI", sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0;
|
||||||
|
padding: 16px clamp(16px, 4vw, 56px);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.page {
|
||||||
|
max-width: 1040px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
h1 { font-size: 1.1rem; font-weight: 600; margin: 0 0 12px; }
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.toolbar span { color: var(--muted); font-size: 12px; }
|
||||||
|
button {
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
button:hover { border-color: var(--accent); }
|
||||||
|
button.danger { border-color: var(--red); color: var(--red); }
|
||||||
|
button.danger:hover { background: #2d1514; }
|
||||||
|
button:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||||
|
.card {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.card-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.card-head strong { font-size: 14px; }
|
||||||
|
.card-head .meta { color: var(--muted); font-size: 12px; word-break: break-all; }
|
||||||
|
.metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.metrics div span { color: var(--muted); display: block; font-size: 11px; }
|
||||||
|
.metrics-row-balance-upnl {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px 28px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.metric-inline {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.metric-inline .metric-lbl { color: var(--muted); font-size: 12px; }
|
||||||
|
.metric-inline .metric-num {
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.metric-inline .metric-num.pnl-pos { color: var(--green); }
|
||||||
|
.metric-inline .metric-num.pnl-neg { color: var(--red); }
|
||||||
|
.pnl-pos { color: var(--green); }
|
||||||
|
.pnl-neg { color: var(--red); }
|
||||||
|
th.hl-pnl,
|
||||||
|
td.hl-pnl {
|
||||||
|
background: rgba(88, 166, 255, 0.08);
|
||||||
|
border-left: 2px solid rgba(88, 166, 255, 0.55);
|
||||||
|
}
|
||||||
|
th.hl-pnl { color: var(--accent); font-weight: 600; }
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
th, td { padding: 8px 10px; text-align: left; border-top: 1px solid var(--border); }
|
||||||
|
th { color: var(--muted); font-weight: 500; }
|
||||||
|
.err { color: var(--red); padding: 12px; font-size: 13px; }
|
||||||
|
.card-disabled { opacity: 0.72; border-style: dashed; }
|
||||||
|
.card-disabled .card-head { border-bottom-style: dashed; }
|
||||||
|
.off-note { padding: 12px 14px; color: var(--muted); font-size: 13px; }
|
||||||
|
.monitor-toggle { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); cursor: pointer; user-select: none; }
|
||||||
|
.monitor-toggle input { cursor: pointer; }
|
||||||
|
.monitor-toggle input:disabled { cursor: not-allowed; }
|
||||||
|
#toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 16px;
|
||||||
|
right: 16px;
|
||||||
|
max-width: min(420px, 90vw);
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
display: none;
|
||||||
|
z-index: 20;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
#toast.show { display: block; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<h1>手工交易 · 多账户中控</h1>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button type="button" id="btn-refresh">立即刷新</button>
|
||||||
|
<label style="color:var(--muted);font-size:12px;display:flex;align-items:center;gap:6px;">
|
||||||
|
<input type="checkbox" id="auto-refresh" checked /> 每 3 秒自动刷新
|
||||||
|
</label>
|
||||||
|
<button type="button" id="btn-close-all" class="danger">全局一键全平</button>
|
||||||
|
<span style="color:var(--muted);font-size:12px;">关闭的账户不轮询、不参与全平(本机记住);账户显示名由中控环境变量 <code style="font-size:11px;">HUB_AGENT_NAMES</code> 配置,所有访问同一中控的电脑一致。</span>
|
||||||
|
<span id="last-updated"></span>
|
||||||
|
</div>
|
||||||
|
<div id="root"></div>
|
||||||
|
</div>
|
||||||
|
<div id="toast"></div>
|
||||||
|
<script>
|
||||||
|
const LS_EXCLUDED = "manual_trading_hub_excluded";
|
||||||
|
const root = document.getElementById("root");
|
||||||
|
const toast = document.getElementById("toast");
|
||||||
|
const lastUpdated = document.getElementById("last-updated");
|
||||||
|
let timer = null;
|
||||||
|
let agentsList = [];
|
||||||
|
let envExcludedSet = new Set();
|
||||||
|
let rowById = new Map();
|
||||||
|
|
||||||
|
function loadExcludedLS() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(LS_EXCLUDED);
|
||||||
|
const arr = raw ? JSON.parse(raw) : [];
|
||||||
|
return new Set((Array.isArray(arr) ? arr : []).map(String));
|
||||||
|
} catch {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveExcludedLS(set) {
|
||||||
|
localStorage.setItem(LS_EXCLUDED, JSON.stringify([...set]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(msg, isErr) {
|
||||||
|
toast.textContent = msg;
|
||||||
|
toast.style.borderColor = isErr ? "var(--red)" : "var(--border)";
|
||||||
|
toast.classList.add("show");
|
||||||
|
clearTimeout(showToast._t);
|
||||||
|
showToast._t = setTimeout(() => toast.classList.remove("show"), 6000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtNum(x, d) {
|
||||||
|
if (x === null || x === undefined || Number.isNaN(Number(x))) return "—";
|
||||||
|
const n = Number(x);
|
||||||
|
return n.toLocaleString(undefined, { maximumFractionDigits: d });
|
||||||
|
}
|
||||||
|
|
||||||
|
function pnlClass(v) {
|
||||||
|
const n = Number(v);
|
||||||
|
if (!Number.isFinite(n) || n === 0) return "";
|
||||||
|
return n > 0 ? "pnl-pos" : "pnl-neg";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderActiveCard(r) {
|
||||||
|
const err = r.error || (r.payload && r.payload.error);
|
||||||
|
const p = r.payload || {};
|
||||||
|
let inner;
|
||||||
|
if (!r.http_ok || err) {
|
||||||
|
inner = `<div class="err">${escapeHtml(String(err || ("HTTP " + (r.status_code ?? "?"))))}</div>`;
|
||||||
|
} else {
|
||||||
|
const pos = Array.isArray(p.positions) ? p.positions : [];
|
||||||
|
const rows = pos.map(
|
||||||
|
(x) =>
|
||||||
|
`<tr>
|
||||||
|
<td>${escapeHtml(x.symbol || "")}</td>
|
||||||
|
<td>${escapeHtml(x.side || "")}</td>
|
||||||
|
<td>${fmtNum(x.contracts, 6)}</td>
|
||||||
|
<td>${fmtNum(x.notional_usdt, 2)}</td>
|
||||||
|
<td class="hl-pnl ${pnlClass(x.unrealized_pnl)}">${fmtNum(x.unrealized_pnl, 4)}</td>
|
||||||
|
<td>${fmtNum(x.entry_price, 6)}</td>
|
||||||
|
</tr>`
|
||||||
|
);
|
||||||
|
const topBalUpnl = `<div class="metrics-row-balance-upnl">
|
||||||
|
<span class="metric-inline"><span class="metric-lbl">余额 USDT</span><span class="metric-num">${fmtNum(p.balance_usdt, 2)}</span></span>
|
||||||
|
<span class="metric-inline"><span class="metric-lbl">未实现盈亏合计</span><span class="metric-num ${pnlClass(p.total_unrealized_pnl)}">${fmtNum(p.total_unrealized_pnl, 4)}</span></span>
|
||||||
|
</div>`;
|
||||||
|
inner = `
|
||||||
|
<div class="metrics">
|
||||||
|
${topBalUpnl}
|
||||||
|
<div><span>交易所</span>${escapeHtml(p.exchange || "—")}</div>
|
||||||
|
<div><span>持仓模式</span>${escapeHtml(p.position_mode || "—")}</div>
|
||||||
|
</div>
|
||||||
|
${
|
||||||
|
pos.length
|
||||||
|
? `<table><thead><tr><th>合约</th><th>方向</th><th>张数</th><th>名义(约)</th><th class="hl-pnl">未实现盈亏</th><th>均价</th></tr></thead><tbody>${rows}</tbody></table>`
|
||||||
|
: `<div style="padding:12px;color:var(--muted)">无持仓</div>`
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="card" data-agent-id="${escapeHtml(r.id)}">
|
||||||
|
<div class="card-head">
|
||||||
|
<div>
|
||||||
|
<strong>${escapeHtml(r.name)}</strong>
|
||||||
|
<div class="meta">${escapeHtml(r.url)}</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
|
||||||
|
<label class="monitor-toggle">
|
||||||
|
<input type="checkbox" class="toggle-monitor" data-agent-id="${escapeHtml(r.id)}" checked />
|
||||||
|
参与监控
|
||||||
|
</label>
|
||||||
|
<button type="button" class="danger btn-close-one" data-agent-id="${escapeHtml(r.id)}">该账户全平</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${inner}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDisabledCard(agent, reason) {
|
||||||
|
const server = reason === "server";
|
||||||
|
const inputAttrs = server
|
||||||
|
? `class="toggle-monitor" data-agent-id="${escapeHtml(agent.id)}" disabled`
|
||||||
|
: `class="toggle-monitor" data-agent-id="${escapeHtml(agent.id)}"`;
|
||||||
|
const note = server
|
||||||
|
? "已在服务端关闭(环境变量 HUB_DISABLED_IDS),不轮询、不参与全局全平。"
|
||||||
|
: "已在本浏览器关闭。勾选「参与监控」可重新纳入轮询与全局全平。";
|
||||||
|
return `
|
||||||
|
<div class="card card-disabled" data-agent-id="${escapeHtml(agent.id)}">
|
||||||
|
<div class="card-head">
|
||||||
|
<div>
|
||||||
|
<strong>${escapeHtml(agent.name)}</strong>
|
||||||
|
<div class="meta">${escapeHtml(agent.url)}</div>
|
||||||
|
</div>
|
||||||
|
<label class="monitor-toggle">
|
||||||
|
<input type="checkbox" ${inputAttrs} />
|
||||||
|
参与监控
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="off-note">${note}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
const lsEx = loadExcludedLS();
|
||||||
|
const csv = [...lsEx].join(",");
|
||||||
|
const qs = csv ? "?exclude_ids=" + encodeURIComponent(csv) : "";
|
||||||
|
const [ar, sr] = await Promise.all([
|
||||||
|
fetch("/api/agents").then((r) => r.json()),
|
||||||
|
fetch("/api/snapshot" + qs).then((r) => r.json()),
|
||||||
|
]);
|
||||||
|
agentsList = ar.agents || [];
|
||||||
|
envExcludedSet = new Set((sr.env_excluded_ids || []).map(String));
|
||||||
|
rowById = new Map((sr.rows || []).map((row) => [String(row.id), row]));
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
for (const agent of agentsList) {
|
||||||
|
const id = String(agent.id);
|
||||||
|
const serverOff = envExcludedSet.has(id);
|
||||||
|
const clientOff = lsEx.has(id);
|
||||||
|
if (serverOff) {
|
||||||
|
parts.push(renderDisabledCard(agent, "server"));
|
||||||
|
} else if (clientOff) {
|
||||||
|
parts.push(renderDisabledCard(agent, "client"));
|
||||||
|
} else {
|
||||||
|
const row = rowById.get(id);
|
||||||
|
if (row) {
|
||||||
|
parts.push(renderActiveCard(row));
|
||||||
|
} else {
|
||||||
|
parts.push(
|
||||||
|
renderActiveCard({
|
||||||
|
id,
|
||||||
|
name: agent.name,
|
||||||
|
url: agent.url,
|
||||||
|
http_ok: false,
|
||||||
|
error: "无快照",
|
||||||
|
payload: null,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
root.innerHTML = parts.join("") || '<div class="err">无账户配置</div>';
|
||||||
|
lastUpdated.textContent = "更新于 " + new Date().toLocaleTimeString();
|
||||||
|
root.querySelectorAll(".btn-close-one").forEach((btn) => {
|
||||||
|
btn.onclick = () => closeOne(btn.getAttribute("data-agent-id"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeOne(id) {
|
||||||
|
if (!confirm("确认对该账户市价全平所有永续持仓?")) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/close/" + encodeURIComponent(id), { method: "POST" });
|
||||||
|
const j = await res.json();
|
||||||
|
showToast(JSON.stringify(j, null, 2), !res.ok);
|
||||||
|
await loadAll();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(String(e), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeAll() {
|
||||||
|
const lsEx = loadExcludedLS();
|
||||||
|
const activeCount = agentsList.filter(
|
||||||
|
(a) => !envExcludedSet.has(String(a.id)) && !lsEx.has(String(a.id))
|
||||||
|
).length;
|
||||||
|
if (!confirm(`对当前 ${activeCount} 个已开启监控的账户执行市价全平?此操作不可撤销。`)) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/close-all", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ exclude_ids: [...lsEx] }),
|
||||||
|
});
|
||||||
|
const j = await res.json();
|
||||||
|
showToast(JSON.stringify(j, null, 2), !res.ok);
|
||||||
|
await loadAll();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(String(e), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
root.addEventListener("change", (ev) => {
|
||||||
|
const t = ev.target;
|
||||||
|
if (!t.classList || !t.classList.contains("toggle-monitor")) return;
|
||||||
|
if (t.disabled) return;
|
||||||
|
const id = t.getAttribute("data-agent-id");
|
||||||
|
if (!id) return;
|
||||||
|
const set = loadExcludedLS();
|
||||||
|
if (t.checked) set.delete(id);
|
||||||
|
else set.add(id);
|
||||||
|
saveExcludedLS(set);
|
||||||
|
loadAll().catch((e) => showToast(String(e), true));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("btn-refresh").onclick = () => loadAll().catch((e) => showToast(String(e), true));
|
||||||
|
document.getElementById("btn-close-all").onclick = closeAll;
|
||||||
|
|
||||||
|
function schedule() {
|
||||||
|
clearInterval(timer);
|
||||||
|
if (document.getElementById("auto-refresh").checked)
|
||||||
|
timer = setInterval(() => loadAll().catch(() => {}), 3000);
|
||||||
|
}
|
||||||
|
document.getElementById("auto-refresh").onchange = schedule;
|
||||||
|
|
||||||
|
loadAll().catch((e) => {
|
||||||
|
root.innerHTML = `<div class="err">${escapeHtml(String(e))}</div>`;
|
||||||
|
});
|
||||||
|
schedule();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
# 手工交易中控 — 部署文档
|
||||||
|
|
||||||
|
本文档描述在本机(以 Windows 为主)将 **manual_trading_hub** 部署为「多账户监控 + 紧急全平」的推荐步骤、验收方法与注意事项。功能说明与配置项详见同目录 **《README.md》**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、部署目标
|
||||||
|
|
||||||
|
- 本机或局域网可访问 **中控页面**(默认监听 `0.0.0.0:5100`,私网 IP 可打开;本机可用 `127.0.0.1`)。
|
||||||
|
- 每个需要纳入监控的交易所账户,有独立的 **子代理** 进程(默认端口 **`15200`~`15203`**,与各 `crypto_monitor_*` 里 Flask 的 **`APP_PORT`** 错开)。
|
||||||
|
- 策略项目 `crypto_monitor_binance` / `crypto_monitor_okx` / `crypto_monitor_gate` / `crypto_monitor_gate_bot` **无需修改代码**,与中控并行运行。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、前置条件
|
||||||
|
|
||||||
|
1. 已安装 **Python 3.10+**,且 `pip` 可用。
|
||||||
|
2. 各策略目录下已配置好交易所 API:在对应 `crypto_monitor_*` 目录执行过 **`cp .env.example .env`** 并填好密钥(与平时运行 Flask 时同一份 **`.env`**,或等价环境变量)。各项目 `app.py` 会读取同目录 `.env` 中的 **`APP_HOST` / `APP_PORT`** 用于 **Flask**;子代理使用环境变量 **`PORT`**,二者不能占用同一端口。策略目录的 **`.env` 不在 Git 中**,`git pull` 不会覆盖;升级前建议在各策略目录 `cp .env .env.backup.$(date +%Y%m%d)`。
|
||||||
|
3. 本机端口 **`15200`~`15203`、5100**(及你各策略 `.env` 里的 `APP_PORT`)无冲突;若被占用,须改子代理 `PORT` 并同步修改 `HUB_AGENTS`。
|
||||||
|
4. **Linux 路径约定(可选)**:若部署在 Ubuntu 等环境,建议将整个代码树放在 **`/opt/crypto_monitor/`** 下(与 `scripts/后台运行-Ubuntu.md` 示例一致),例如策略目录为 `/opt/crypto_monitor/crypto_monitor_binance`、中控为 `/opt/crypto_monitor/manual_trading_hub`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、目录与文件
|
||||||
|
|
||||||
|
```
|
||||||
|
manual_trading_hub/
|
||||||
|
agent.py # 子代理(单账户)
|
||||||
|
hub.py # 中控
|
||||||
|
requirements.txt # Python 依赖
|
||||||
|
static/
|
||||||
|
index.html # 中控前端页面
|
||||||
|
README.md # 说明文档
|
||||||
|
部署文档.md # 本文档
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、安装依赖
|
||||||
|
|
||||||
|
在 `manual_trading_hub` 目录执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd manual_trading_hub
|
||||||
|
python -m venv .venv
|
||||||
|
.\.venv\Scripts\Activate.ps1
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux / macOS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd manual_trading_hub
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
后续启动 `hub.py` / `agent.py` 时,请使用**同一虚拟环境**中的 `python`,保证已安装 `fastapi`、`uvicorn`、`httpx`、`ccxt`;若各策略 `.env` 中配置了 **SOCKS 代理**(`GATE_SOCKS_PROXY` / `BINANCE_SOCKS_PROXY` / `OKX_SOCKS_PROXY` 等),还需 **`PySocks`**(已写入 `requirements.txt`,新环境 `pip install -r requirements.txt` 即可)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、部署步骤(推荐顺序)
|
||||||
|
|
||||||
|
### 步骤 1:确认策略进程(可选)
|
||||||
|
|
||||||
|
若你平时已运行各目录下的 Flask(`app.py`),可保持运行;中控与子代理**不依赖** Flask 是否启动。
|
||||||
|
|
||||||
|
### 步骤 2:为每个账户启动子代理
|
||||||
|
|
||||||
|
**每个账户一个终端窗口**,在对应策略项目目录进入后加载环境变量,再启动 agent(路径按你仓库实际位置调整)。
|
||||||
|
|
||||||
|
示例:**Binance 账户 → 子代理端口 15200**(Flask 的 `APP_PORT` 可为 5000、5001 等,由该目录 `.env` 决定,二者不同即可)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd D:\你的路径\交易复盘系统\crypto_monitor_binance
|
||||||
|
.\.venv\Scripts\Activate.ps1 # 若策略项目使用独立 venv,可在此激活
|
||||||
|
# 确保已加载 .env(若你用 dotenv-cli 等工具,在此执行)
|
||||||
|
$env:EXCHANGE="binance"
|
||||||
|
$env:PORT="15200"
|
||||||
|
$env:HOST="127.0.0.1"
|
||||||
|
python ..\manual_trading_hub\agent.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**OKX → 子代理 `PORT=15201`**、`EXCHANGE=okx`;**Gate → 15202**、**Gate-Bot → 15203**,`EXCHANGE=gate`。
|
||||||
|
|
||||||
|
**可选安全**:各 agent 与中控制台设置相同随机串:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:CONTROL_TOKEN="你的长随机串"
|
||||||
|
```
|
||||||
|
|
||||||
|
子代理与中控均需设置同一 `CONTROL_TOKEN`。
|
||||||
|
|
||||||
|
### 步骤 3:启动中控
|
||||||
|
|
||||||
|
新开终端:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd D:\你的路径\交易复盘系统\manual_trading_hub
|
||||||
|
.\.venv\Scripts\Activate.ps1
|
||||||
|
$env:HUB_HOST="0.0.0.0"
|
||||||
|
$env:HUB_PORT="5100"
|
||||||
|
# 若暂不使用 OKX,可在服务端固定关闭 id=1:
|
||||||
|
# $env:HUB_DISABLED_IDS="1"
|
||||||
|
python hub.py
|
||||||
|
```
|
||||||
|
|
||||||
|
控制台无报错即表示监听成功(日志级别为 `warning`,默认不刷屏)。
|
||||||
|
|
||||||
|
### 步骤 4:浏览器验收
|
||||||
|
|
||||||
|
1. 打开 **http://127.0.0.1:5100/** 或 **http://本机局域网IP:5100/** 。
|
||||||
|
2. 应看到与 `HUB_AGENTS` 配置一致的账户卡片;已启动子代理的行应能显示余额或持仓(无持仓则显示「无持仓」)。
|
||||||
|
3. 点击 **立即刷新**,数据应更新。
|
||||||
|
4. 取消某一行的 **「参与监控」**,该行应变灰且不再刷新数据;再勾选应恢复。
|
||||||
|
|
||||||
|
**不建议**在生产环境对实盘轻易点击「全平」做测试;若必须测试,请使用测试网或小资金账户。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、接口验收(可选)
|
||||||
|
|
||||||
|
在已启动 hub 的本机 PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Invoke-RestMethod "http://127.0.0.1:5100/api/agents"
|
||||||
|
Invoke-RestMethod "http://127.0.0.1:5100/api/snapshot"
|
||||||
|
```
|
||||||
|
|
||||||
|
子代理单测(需 agent 已启动):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Invoke-RestMethod "http://127.0.0.1:15200/health"
|
||||||
|
Invoke-RestMethod "http://127.0.0.1:15200/status"
|
||||||
|
```
|
||||||
|
|
||||||
|
若启用了 `CONTROL_TOKEN`,需加请求头(PowerShell 示例):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$h = @{ "X-Control-Token" = "你的长随机串" }
|
||||||
|
Invoke-RestMethod "http://127.0.0.1:15200/status" -Headers $h
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、自定义端口与账户数量
|
||||||
|
|
||||||
|
仅部署 3 个账户时,可只启动 3 个 agent,并设置:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:HUB_AGENTS="http://127.0.0.1:15200,http://127.0.0.1:15202,http://127.0.0.1:15203"
|
||||||
|
$env:HUB_AGENT_NAMES="Binance,Gate,Gate-Bot"
|
||||||
|
```
|
||||||
|
|
||||||
|
`HUB_AGENT_NAMES` 与 URL **数量、顺序**一一对应。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、常驻运行(可选)
|
||||||
|
|
||||||
|
**不必一直开着终端。** Ubuntu 下可用 **tmux 脱离**、**nohup &**、或 **systemd**(推荐:崩溃自启、可开机启动)。
|
||||||
|
|
||||||
|
详细命令、**常见问题(screen / PySocks / 重启)**与 **systemd 单元示例**见:
|
||||||
|
|
||||||
|
- `manual_trading_hub/scripts/后台运行-Ubuntu.md`
|
||||||
|
- `manual_trading_hub/scripts/example-systemd/*.service.example`(改路径后复制到 `/etc/systemd/system/`)
|
||||||
|
|
||||||
|
Windows 可将 `hub.py` 与各 `agent.py` 写入「启动」文件夹,或使用 **NSSM** / **任务计划程序** 在登录后启动;注意工作目录与环境变量要在任务里写全。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、升级与回滚
|
||||||
|
|
||||||
|
- **升级**:在仓库根目录 `git pull` 后,于 `manual_trading_hub` 重新 `pip install -r requirements.txt`(若依赖有变)。**各策略目录 `.env` 不会被 pull 覆盖**;若 `.env.example` 有新增变量,请对照模板**手动补进**你的 `.env`。
|
||||||
|
- **升级前备份(推荐)**:在每个用到的 `crypto_monitor_*` 目录执行 `cp .env .env.backup.$(date +%Y%m%d)`。
|
||||||
|
- **回滚**:恢复上一版本代码与依赖;配置在各策略 `.env` 与浏览器 localStorage,无中控数据库。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、故障排查
|
||||||
|
|
||||||
|
### 10.1 现象速查表
|
||||||
|
|
||||||
|
| 现象 | 可能原因 | 处理 |
|
||||||
|
|------|----------|------|
|
||||||
|
| 页面打不开 | 中控未启动或端口错 | 检查 `HUB_PORT`、防火墙、是否用 127.0.0.1 访问 |
|
||||||
|
| 某账户一直报错 | 子代理未启动或端口不一致 | 核对 `HUB_AGENTS` 与该 agent 的 `PORT` |
|
||||||
|
| 中控 JSON 报错 / `Expecting value` | 子代理未启动、返回非 JSON、或端口错 | 本机 `curl http://127.0.0.1:1520x/status`;确认 agent 已起且端口与 `HUB_AGENTS` 一致 |
|
||||||
|
| 401 / 连不上子代理 | `CONTROL_TOKEN` 不一致 | 中控与子代理设为同一令牌,或全部去掉令牌 |
|
||||||
|
| 有密钥仍报缺密钥 | 启动 agent 时未加载策略目录的 `.env` | 在对应目录启动,或手动 export 全部密钥变量 |
|
||||||
|
| `/status` 里 `ok:false`,文案含 **pysocks** / **SOCKS** | 使用 SOCKS 代理但未装 **PySocks** | 在 **`manual_trading_hub/.venv`** 执行 `pip install PySocks` 或 `pip install -r requirements.txt` |
|
||||||
|
| **已安装 PySocks**,`/status` 仍报同样 pysocks 文案 | 子代理进程未重启 | 子代理是常驻进程,**仅 pip 不会替换已运行进程**;退出对应 screen / systemd 单元后重新拉起(Ubuntu 见 `scripts/后台运行-Ubuntu.md` §四) |
|
||||||
|
| 跑 `start_agents_3screen.sh` 无新会话 | screen 会话已存在被脚本跳过 | 先 `stop_agents_3screen.sh`,或 `screen -S mt-agent-xxx -X quit` 后再启动 |
|
||||||
|
| 子代理行为异常、依赖已装仍报错 | 实际用的不是 hub 的 venv | `ps aux` 查看命令行,应为 **`/opt/crypto_monitor/manual_trading_hub/.venv/bin/python`** `…/manual_trading_hub/agent.py`(路径按你机器实际根目录调整);否则在**当前使用的解释器**对应环境中装依赖,或改用官方脚本启动 |
|
||||||
|
| 子代理端口与 Flask 抢端口 | `PORT` 与策略目录 `.env` 的 `APP_PORT` 相同 | 子代理用 **15200~15203**(或自改),Flask 继续用 `APP_PORT`,二者勿重复 |
|
||||||
|
| 全平失败 | 持仓模式、精度、交易所维护 | 看返回 JSON 中 `errors` 字段;对照交易所 App |
|
||||||
|
|
||||||
|
### 10.2 依赖或代码更新后
|
||||||
|
|
||||||
|
- **`pip install -r requirements.txt` 或单独 `pip install` 之后**:须**重启**受影响的 **hub**、**各子代理** 进程,变更才会生效。
|
||||||
|
- **拉取新版本代码后**:同样重启进程;若曾遇 `'function' object has no attribute load_markets'` 等旧版 agent 异常,升级后重启子代理即可。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、安全清单(部署前自检)
|
||||||
|
|
||||||
|
- [ ] 若机器暴露在公网,已用防火墙限制 `HUB_PORT` 或已改为 `HUB_HOST=127.0.0.1` / `HUB_TRUST_LAN=0` 仅本机。
|
||||||
|
- [ ] API Key 权限最小化;生产环境建议启用 IP 白名单。
|
||||||
|
- [ ] `CONTROL_TOKEN` 已设为足够长的随机串(若启用)。
|
||||||
|
- [ ] 已告知实际操作人员:**全局全平**不可撤销。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十二、与说明文档的关系
|
||||||
|
|
||||||
|
- **《README.md》**:产品能力、架构、环境变量表、API 简表、常见问题。
|
||||||
|
- **《部署文档.md》**:按步骤安装、启动、验收与运维注意(本文)。
|
||||||
|
|
||||||
|
两处如有端口/变量不一致,以**当前代码**与 **README 中的表格**为准。
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# crypto_monitor 四个 Flask 子项目共用依赖(Binance / Gate / Gate_bot / OKX)
|
||||||
|
# 安装:在各子目录 venv 内执行 pip install -r ../requirements.txt
|
||||||
|
flask>=3.0,<4
|
||||||
|
requests>=2.31,<3
|
||||||
|
ccxt>=4.2,<5
|
||||||
|
werkzeug>=3.0,<4
|
||||||
|
PySocks>=1.7,<2
|
||||||
|
Pillow>=10.0,<12
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
@echo off
|
||||||
|
chcp 65001 >nul
|
||||||
|
setlocal
|
||||||
|
cd /d "%~dp0"
|
||||||
|
echo crypto_monitor 一键环境部署 ...
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0deploy\setup_env.ps1" %*
|
||||||
|
set ERR=%ERRORLEVEL%
|
||||||
|
if not "%ERR%"=="0" (
|
||||||
|
echo.
|
||||||
|
echo 部署失败,退出码 %ERR%
|
||||||
|
pause
|
||||||
|
exit /b %ERR%
|
||||||
|
)
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
|
exit /b 0
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# 关键位止盈止损方案 & 移动保本开关 — 更新说明
|
||||||
|
|
||||||
|
**版本日期:** 2026-05-21
|
||||||
|
**影响实例:** `crypto_monitor_binance`(币安)、`crypto_monitor_gate`(Gate)、`crypto_monitor_okx`(OKX)
|
||||||
|
**共用库:** 仓库根目录 `key_sl_tp_lib.py`(计算逻辑)、`fib_key_monitor_lib.py`(斐波 SL/TP 不变)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 变更摘要
|
||||||
|
|
||||||
|
| 项 | 变更前 | 变更后 |
|
||||||
|
|----|--------|--------|
|
||||||
|
| 箱体/收敛 SL/TP | 仅一种(标准突破) | 添加时可三选一 |
|
||||||
|
| 移动保本(关键位) | 自动单写死开启 | 添加时可勾选,**默认关闭** |
|
||||||
|
| 斐波 SL/TP | H/L 限价方案 | **不变**;仅增加移动保本开关 |
|
||||||
|
| 历史 `key_monitors` | 无新字段 | 缺字段视为 **标准方案 + 保本关** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 数据库(`key_monitors` 新增列)
|
||||||
|
|
||||||
|
启动时自动 `ALTER TABLE`(已有库兼容):
|
||||||
|
|
||||||
|
| 字段 | 类型 | 默认 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `sl_tp_mode` | TEXT | `standard` | `standard` / `box_1p5` / `trend_manual` |
|
||||||
|
| `manual_take_profit` | REAL | NULL | 仅 `trend_manual` 使用 |
|
||||||
|
| `breakeven_enabled` | INTEGER | `0` | 0=关,1=开 |
|
||||||
|
|
||||||
|
旧记录无上述列时,读取逻辑按 **`standard` + 保本关** 处理。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 三种止盈止损方案(仅箱体突破 / 收敛突破)
|
||||||
|
|
||||||
|
计划价 **E** = 确认 K(倒数第 1 根已闭合 5m)收盘价;**H** = \|上沿 − 下沿\|。
|
||||||
|
自动开仓(币安/Gate)前仍须:**计划 RR(按 E)> `KEY_AUTO_MIN_PLANNED_RR`**(默认 1.5)。
|
||||||
|
|
||||||
|
### 3.1 标准突破 `standard`(原逻辑)
|
||||||
|
|
||||||
|
| 方向 | 止损 SL | 止盈 TP |
|
||||||
|
|------|---------|---------|
|
||||||
|
| 多 | 突破 K 最低价 × (1 − `KEY_STOP_OUTSIDE_BREAKOUT_PCT`/100) | E + 1×H |
|
||||||
|
| 空 | 突破 K 最高价 × (1 + 外侧%) | E − 1×H |
|
||||||
|
|
||||||
|
默认外侧:**0.5%**(`KEY_STOP_OUTSIDE_BREAKOUT_PCT`)。
|
||||||
|
|
||||||
|
### 3.2 箱体 1R / 止盈 1.5H `box_1p5`
|
||||||
|
|
||||||
|
以 **E 为当前价**,风险距离 = 1×H,止盈距离 = 1.5×H,**计划 RR 固定约 1.5:1**。
|
||||||
|
|
||||||
|
| 方向 | 止损 SL | 止盈 TP |
|
||||||
|
|------|---------|---------|
|
||||||
|
| 多 | E − H | E + 1.5×H |
|
||||||
|
| 空 | E + H | E − 1.5×H |
|
||||||
|
|
||||||
|
### 3.3 趋势单 + 自填止盈 `trend_manual`
|
||||||
|
|
||||||
|
| 方向 | 止损 SL | 止盈 TP |
|
||||||
|
|------|---------|---------|
|
||||||
|
| 多 | 突破 K 最低价 × (1 − `KEY_TREND_STOP_OUTSIDE_PCT`/100) | 添加时录入的 `manual_take_profit` |
|
||||||
|
| 空 | 突破 K 最高价 × (1 + 外侧%) | 同上 |
|
||||||
|
|
||||||
|
- 环境变量 **`KEY_TREND_STOP_OUTSIDE_PCT`**,默认 **1**(即 1%)。
|
||||||
|
- 添加时校验:做多止盈 > 上沿;做空止盈 < 下沿。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 斐波回调 0.618 / 0.786
|
||||||
|
|
||||||
|
- **SL/TP**:仍为 `calc_fib_plan`(多:SL=L、TP=H;空:SL=H、TP=L),**无**三方案下拉。
|
||||||
|
- **移动保本**:添加时可勾选;成交写入 `order_monitors` 时带入该勾选状态(默认关)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 移动保本
|
||||||
|
|
||||||
|
| 场景 | 行为 |
|
||||||
|
|------|------|
|
||||||
|
| 关键位添加 | 复选框「移动保本」,**默认不勾选** |
|
||||||
|
| 箱体/收敛自动开仓成功 | `order_monitors.breakeven_enabled` = 添加时的选择 |
|
||||||
|
| 斐波限价成交后 | 同上 |
|
||||||
|
| 人工「实盘下单」 | **不变**:仍为表单勾选,默认仍可按原页面逻辑 |
|
||||||
|
|
||||||
|
触发参数仍用全局 `.env`:`BREAKEVEN_RR_TRIGGER`、`BREAKEVEN_STEP_R`、`BREAKEVEN_OFFSET_PCT`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 前端(关键位添加表单)
|
||||||
|
|
||||||
|
在「上沿 / 下沿」后增加:
|
||||||
|
|
||||||
|
1. **止盈止损方案**(仅类型为箱体突破、收敛突破时显示)
|
||||||
|
2. **趋势单止盈价**(仅选「趋势单·自填止盈」时显示且必填)
|
||||||
|
3. **移动保本**(箱体/收敛/斐波显示;默认不勾)
|
||||||
|
|
||||||
|
活跃列表卡片展示:**方案**、**保本:开/关**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 环境变量
|
||||||
|
|
||||||
|
```env
|
||||||
|
# 标准方案:突破 K 极值外侧 %
|
||||||
|
KEY_STOP_OUTSIDE_BREAKOUT_PCT=0.5
|
||||||
|
|
||||||
|
# 趋势单方案:突破 K 极值外侧 %
|
||||||
|
KEY_TREND_STOP_OUTSIDE_PCT=1
|
||||||
|
```
|
||||||
|
|
||||||
|
已写入各实例 `.env.example`(Binance / Gate / OKX)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 交易所差异
|
||||||
|
|
||||||
|
| 实例 | 箱体/收敛触发后 |
|
||||||
|
|------|----------------|
|
||||||
|
| **Binance / Gate** | 门控通过 → 按方案算 SL/TP → 市价开仓 → 挂交易所 TP/SL → 写入下单监控 |
|
||||||
|
| **OKX** | 门控通过 → **企业微信提醒**(推送中含录入方案的计划 SL/TP/RR),**不自动市价开仓** |
|
||||||
|
|
||||||
|
OKX 用户按推送中的计划价自行下单;斐波仍为限价 + 成交后挂 TP/SL(与原先一致)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 涉及文件清单
|
||||||
|
|
||||||
|
| 路径 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `key_sl_tp_lib.py` | **新建**,三方案计算与文案 |
|
||||||
|
| `crypto_monitor_binance/app.py` | 门控触发、开仓、斐波、add_key |
|
||||||
|
| `crypto_monitor_binance/templates/index.html` | 表单 + JS + 列表展示 |
|
||||||
|
| `crypto_monitor_binance/.env.example` | `KEY_TREND_STOP_OUTSIDE_PCT` |
|
||||||
|
| `crypto_monitor_gate/app.py` | 同 Binance |
|
||||||
|
| `crypto_monitor_gate/templates/index.html` | 同 Binance |
|
||||||
|
| `crypto_monitor_gate/.env.example` | 同上 |
|
||||||
|
| `crypto_monitor_okx/app.py` | add_key、提醒文案、斐波保本 |
|
||||||
|
| `crypto_monitor_okx/templates/index.html` | 表单 + JS |
|
||||||
|
| `crypto_monitor_okx/.env.example` | 注释项 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 部署与验证建议
|
||||||
|
|
||||||
|
1. `git pull` 后重启三个实例的 Flask 进程(会自动迁移 `key_monitors` 列)。
|
||||||
|
2. 在 `.env` 中按需设置 `KEY_TREND_STOP_OUTSIDE_PCT`(不配则用默认 1)。
|
||||||
|
3. **验证 Binance/Gate**
|
||||||
|
- 添加箱体突破,选「箱体1R·止盈1.5H」,不勾保本 → 触发后微信应显示方案名、保本关、SL/TP 符合 E±H / E±1.5H。
|
||||||
|
- 添加趋势单,填止盈,勾保本 → 成交后持仓卡片「移动保本:开」。
|
||||||
|
4. **验证 OKX**:门控通过时微信应含「录入方案」与计划 SL/TP,并注明提醒模式不自动开仓。
|
||||||
|
5. 旧关键位条目:列表应显示「方案:标准突破」「保本:关」(除非库中已有新字段值)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 代码入口(便于二次开发)
|
||||||
|
|
||||||
|
| 功能 | 符号 |
|
||||||
|
|------|------|
|
||||||
|
| 计划 SL/TP | `plan_key_sl_tp()` in `key_sl_tp_lib.py` |
|
||||||
|
| 按监控行计算 | `_key_plan_sl_tp_for_row()` in各 `app.py` |
|
||||||
|
| 添加关键位 | `add_key()` |
|
||||||
|
| 箱体/收敛轮询 | `check_key_monitors()`(OKX 仅提醒) |
|
||||||
|
| 斐波添加 | `_add_fib_key_monitor(..., breakeven_enabled=)` |
|
||||||
|
| 自动开仓写监控 | `_market_open_for_key_monitor(..., breakeven_enabled=)` |
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
# 备份与恢复(Ubuntu 服务器)
|
||||||
|
|
||||||
|
本文档面向 **VPS / Ubuntu**,项目统一放在 **`/opt/crypto_monitor`**,数据备份统一放在 **`/root/backups`**。
|
||||||
|
|
||||||
|
| 类型 | 内容 | 存放位置 | 频率 |
|
||||||
|
|------|------|----------|------|
|
||||||
|
| **数据库 + 复盘图片** | `crypto.db`、`static/images` | `/root/backups/<实例名>/YYYY-MM-DD/` | 每天北京时间 **0:00**(cron) |
|
||||||
|
| **`.env` 配置** | API、密码、风控参数等 | 项目目录 `.env.backup.日期`;可选集中拷到 `/root/backups/env/` | **升级 / 改配置前**手动执行 |
|
||||||
|
|
||||||
|
> `.env` **不会**被自动备份脚本包含(含密钥,请单独备份)。
|
||||||
|
> 三个常用实例:`crypto_monitor_binance`、`crypto_monitor_gate`、`crypto_monitor_gate_bot`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、首次安装:三个实例自动备份 + 试跑
|
||||||
|
|
||||||
|
整段复制到 SSH 终端执行(需 **root** 或对该目录有写权限):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apt install -y sqlite3 2>/dev/null || true
|
||||||
|
|
||||||
|
for dir in crypto_monitor_binance crypto_monitor_gate crypto_monitor_gate_bot; do
|
||||||
|
cd "/opt/crypto_monitor/${dir}" || exit 1
|
||||||
|
chmod +x scripts/backup_data.sh scripts/install_backup_cron.sh
|
||||||
|
bash scripts/install_backup_cron.sh
|
||||||
|
bash scripts/backup_data.sh
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "=== crontab ==="
|
||||||
|
crontab -l
|
||||||
|
echo "=== backup dirs ==="
|
||||||
|
ls -la /root/backups/*/
|
||||||
|
```
|
||||||
|
|
||||||
|
成功后应有:
|
||||||
|
|
||||||
|
- `crontab -l` 含一行 `CRON_TZ=Asia/Shanghai` + 三条 `0 0 * * * .../backup_data.sh`
|
||||||
|
- `/root/backups/crypto_monitor_binance/2026-05-17/`(日期为当天)等目录,内含 `crypto.db`、`static_images.tar.gz`、`manifest.txt`
|
||||||
|
|
||||||
|
日志路径:
|
||||||
|
|
||||||
|
- `/var/log/crypto-monitor-backup-crypto_monitor_binance.log`
|
||||||
|
- `/var/log/crypto-monitor-backup-crypto_monitor_gate.log`
|
||||||
|
- `/var/log/crypto-monitor-backup-crypto_monitor_gate_bot.log`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、仅安装某一个实例的自动备份
|
||||||
|
|
||||||
|
把 `INSTANCE` 改成目录名后整段执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
INSTANCE=crypto_monitor_binance
|
||||||
|
cd "/opt/crypto_monitor/${INSTANCE}"
|
||||||
|
chmod +x scripts/backup_data.sh scripts/install_backup_cron.sh
|
||||||
|
bash scripts/install_backup_cron.sh
|
||||||
|
bash scripts/backup_data.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`INSTANCE` 可选:`crypto_monitor_binance` | `crypto_monitor_gate` | `crypto_monitor_gate_bot`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、手动立即备份(数据库 + 图片,三个实例)
|
||||||
|
|
||||||
|
不等到 0 点,立刻各备份一次:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for dir in crypto_monitor_binance crypto_monitor_gate crypto_monitor_gate_bot; do
|
||||||
|
echo ">>> ${dir}"
|
||||||
|
bash "/opt/crypto_monitor/${dir}/scripts/backup_data.sh"
|
||||||
|
done
|
||||||
|
ls -la /root/backups/*/*/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、检查定时任务与备份是否正常
|
||||||
|
|
||||||
|
```bash
|
||||||
|
crontab -l
|
||||||
|
ls -la /root/backups/*/
|
||||||
|
du -sh /root/backups/*/
|
||||||
|
tail -n 20 /var/log/crypto-monitor-backup-crypto_monitor_binance.log
|
||||||
|
tail -n 20 /var/log/crypto-monitor-backup-crypto_monitor_gate.log
|
||||||
|
tail -n 20 /var/log/crypto-monitor-backup-crypto_monitor_gate_bot.log
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、`.env` 备份(升级 / git pull / 改密钥前)
|
||||||
|
|
||||||
|
### 5.1 三个实例一次性备份到各自项目目录
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATE=$(TZ=Asia/Shanghai date +%Y%m%d)
|
||||||
|
for dir in crypto_monitor_binance crypto_monitor_gate crypto_monitor_gate_bot; do
|
||||||
|
src="/opt/crypto_monitor/${dir}/.env"
|
||||||
|
dst="/opt/crypto_monitor/${dir}/.env.backup.${DATE}"
|
||||||
|
if [ -f "$src" ]; then
|
||||||
|
cp -a "$src" "$dst"
|
||||||
|
echo "ok: $dst"
|
||||||
|
else
|
||||||
|
echo "skip (no .env): $src"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 同时集中备份到 `/root/backups/env/`(推荐)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATE=$(TZ=Asia/Shanghai date +%Y%m%d)
|
||||||
|
mkdir -p /root/backups/env
|
||||||
|
for dir in crypto_monitor_binance crypto_monitor_gate crypto_monitor_gate_bot; do
|
||||||
|
src="/opt/crypto_monitor/${dir}/.env"
|
||||||
|
if [ -f "$src" ]; then
|
||||||
|
cp -a "$src" "/root/backups/env/${dir}.env.${DATE}"
|
||||||
|
echo "ok: /root/backups/env/${dir}.env.${DATE}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
ls -la /root/backups/env/
|
||||||
|
```
|
||||||
|
|
||||||
|
> `/root/backups/env/` 含密钥,勿上传网盘、勿提交 Git。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、`.env` 恢复
|
||||||
|
|
||||||
|
### 6.1 从项目目录内的备份恢复
|
||||||
|
|
||||||
|
把 `INSTANCE` 和 `DATE` 改成实际值(`DATE` 为备份当天的 `YYYYMMDD`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
INSTANCE=crypto_monitor_binance
|
||||||
|
DATE=20260517
|
||||||
|
cd "/opt/crypto_monitor/${INSTANCE}"
|
||||||
|
cp -a ".env.backup.${DATE}" .env
|
||||||
|
echo "restored .env from .env.backup.${DATE}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 从 `/root/backups/env/` 恢复
|
||||||
|
|
||||||
|
```bash
|
||||||
|
INSTANCE=crypto_monitor_binance
|
||||||
|
DATE=20260517
|
||||||
|
cp -a "/root/backups/env/${INSTANCE}.env.${DATE}" "/opt/crypto_monitor/${INSTANCE}/.env"
|
||||||
|
echo "restored from /root/backups/env/${INSTANCE}.env.${DATE}"
|
||||||
|
```
|
||||||
|
|
||||||
|
恢复后重启对应 PM2 进程,例如:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 restart crypto-monitor-binance
|
||||||
|
pm2 restart crypto-monitor-gate
|
||||||
|
pm2 restart crypto-monitor-gate-bot
|
||||||
|
```
|
||||||
|
|
||||||
|
(进程名以你 `pm2 list` 为准。)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、数据库 + 复盘图片恢复
|
||||||
|
|
||||||
|
从自动备份目录恢复。先停服务再覆盖,避免 SQLite 写入冲突。
|
||||||
|
|
||||||
|
把 `INSTANCE`、`DATE`(文件夹名 `YYYY-MM-DD`)改成实际值:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
INSTANCE=crypto_monitor_binance
|
||||||
|
DATE=2026-05-17
|
||||||
|
BK="/root/backups/${INSTANCE}/${DATE}"
|
||||||
|
PROJ="/opt/crypto_monitor/${INSTANCE}"
|
||||||
|
|
||||||
|
test -f "${BK}/crypto.db" || { echo "backup not found: ${BK}"; exit 1; }
|
||||||
|
|
||||||
|
pm2 stop crypto-monitor-binance 2>/dev/null || true
|
||||||
|
|
||||||
|
cp -a "${PROJ}/crypto.db" "${PROJ}/crypto.db.before_restore.$(date +%Y%m%d%H%M)" 2>/dev/null || true
|
||||||
|
cp -a "${BK}/crypto.db" "${PROJ}/crypto.db"
|
||||||
|
|
||||||
|
if [ -f "${BK}/static_images.tar.gz" ]; then
|
||||||
|
tar -xzf "${BK}/static_images.tar.gz" -C "${PROJ}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
pm2 start crypto-monitor-binance 2>/dev/null || true
|
||||||
|
echo "restored ${INSTANCE} from ${BK}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Gate / Gate Bot 将 `INSTANCE`、`pm2` 名称改为对应实例即可。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、升级代码推荐顺序(含备份)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATE=$(TZ=Asia/Shanghai date +%Y%m%d)
|
||||||
|
mkdir -p /root/backups/env
|
||||||
|
|
||||||
|
for dir in crypto_monitor_binance crypto_monitor_gate crypto_monitor_gate_bot; do
|
||||||
|
PROJ="/opt/crypto_monitor/${dir}"
|
||||||
|
[ -f "${PROJ}/.env" ] && cp -a "${PROJ}/.env" "/root/backups/env/${dir}.env.${DATE}"
|
||||||
|
bash "${PROJ}/scripts/backup_data.sh" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
|
||||||
|
cd /opt/crypto_monitor
|
||||||
|
git pull
|
||||||
|
|
||||||
|
for dir in crypto_monitor_binance crypto_monitor_gate crypto_monitor_gate_bot; do
|
||||||
|
echo ">>> merge .env.example if needed: ${dir}"
|
||||||
|
diff -u "${dir}/.env.example" "${dir}/.env" | head -30 || true
|
||||||
|
done
|
||||||
|
|
||||||
|
pm2 restart all
|
||||||
|
```
|
||||||
|
|
||||||
|
`git pull` 后对照各目录 **`.env.example`**,把**新增变量名**手动补进 `.env`(不会自动合并)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、备份目录结构说明
|
||||||
|
|
||||||
|
```text
|
||||||
|
/root/backups/
|
||||||
|
env/ # .env 集中备份(手动)
|
||||||
|
crypto_monitor_binance.env.20260517
|
||||||
|
crypto_monitor_gate.env.20260517
|
||||||
|
crypto_monitor_gate_bot.env.20260517
|
||||||
|
crypto_monitor_binance/
|
||||||
|
2026-05-17/
|
||||||
|
crypto.db
|
||||||
|
static_images.tar.gz
|
||||||
|
manifest.txt
|
||||||
|
crypto_monitor_gate/
|
||||||
|
2026-05-17/
|
||||||
|
...
|
||||||
|
crypto_monitor_gate_bot/
|
||||||
|
2026-05-17/
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
- **保留策略**:自动备份目录按日期文件夹保留 **30 天**,超期在下次 `backup_data.sh` 运行时删除。
|
||||||
|
- **可选 `.env` 变量**(写在各实例 `.env` 中):`BACKUP_ROOT`、`BACKUP_RETENTION_DAYS`、`BACKUP_INSTANCE`(见各目录 `.env.example` 注释)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、卸载自动备份定时任务
|
||||||
|
|
||||||
|
仅删除三个实例的 backup 行(保留其它 cron):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for dir in crypto_monitor_binance crypto_monitor_gate crypto_monitor_gate_bot; do
|
||||||
|
SCRIPT="/opt/crypto_monitor/${dir}/scripts/backup_data.sh"
|
||||||
|
crontab -l 2>/dev/null | grep -vF "$SCRIPT" | crontab -
|
||||||
|
done
|
||||||
|
crontab -l
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、相关文档
|
||||||
|
|
||||||
|
| 文档 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| [README.md](./README.md) | 仓库总览 |
|
||||||
|
| [crypto_monitor_binance/部署文档.md](./crypto_monitor_binance/部署文档.md) | Binance 部署与备份细节 |
|
||||||
|
| [crypto_monitor_gate/部署文档.md](./crypto_monitor_gate/部署文档.md) | Gate 部署 |
|
||||||
|
| [crypto_monitor_gate_bot/部署文档.md](./crypto_monitor_gate_bot/部署文档.md) | Gate Bot 部署 |
|
||||||
Reference in New Issue
Block a user