你的说明
This commit is contained in:
@@ -27,7 +27,7 @@ cd crypto_monitor
|
|||||||
| `crypto_monitor_binance/` | Binance USDT-M 永续 | [部署文档.md](./crypto_monitor_binance/部署文档.md) · [README.md](./crypto_monitor_binance/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/` | 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) · [策略交易说明.md](./策略交易说明.md) |
|
| `crypto_monitor_gate_bot/` | Gate.io 永续(机器人;含趋势回调等) | [部署文档.md](./crypto_monitor_gate_bot/部署文档.md) · [趋势回调策略说明.md](./crypto_monitor_gate_bot/趋势回调策略说明.md) · [策略交易说明.md](./策略交易说明.md) |
|
||||||
| `crypto_monitor_okx/` | OKX 永续 | [部署文档.md](./crypto_monitor_okx/部署文档.md) · [使用说明.md](./crypto_monitor_okx/使用说明.md) |
|
| `crypto_monitor_okx/` | OKX 永续(功能对齐币安) | [部署文档.md](./crypto_monitor_okx/部署文档.md) · [使用说明.md](./crypto_monitor_okx/使用说明.md) · [README.md](./crypto_monitor_okx/README.md) |
|
||||||
| `manual_trading_hub/` | 多账户中控(监控 + 紧急全平 + 登录;**不在中控网页下单**) | [README.md](./manual_trading_hub/README.md) · [使用说明.md](./manual_trading_hub/使用说明.md) · [部署文档.md](./manual_trading_hub/部署文档.md) · [常见问题.md](./manual_trading_hub/常见问题.md) |
|
| `manual_trading_hub/` | 多账户中控(监控 + 紧急全平 + 登录;**不在中控网页下单**) | [README.md](./manual_trading_hub/README.md) · [使用说明.md](./manual_trading_hub/使用说明.md) · [部署文档.md](./manual_trading_hub/部署文档.md) · [常见问题.md](./manual_trading_hub/常见问题.md) |
|
||||||
| 根目录 `strategy_*.py` | **策略交易**(趋势回调 + 顺势加仓共用逻辑) | [策略交易说明.md](./策略交易说明.md) |
|
| 根目录 `strategy_*.py` | **策略交易**(趋势回调 + 顺势加仓共用逻辑) | [策略交易说明.md](./策略交易说明.md) |
|
||||||
| 根目录 `ai_client.py` | **AI 复盘**(OpenAI 兼容网关 / Ollama 二选一) | [AI复盘与模型配置说明.md](./AI复盘与模型配置说明.md) |
|
| 根目录 `ai_client.py` | **AI 复盘**(OpenAI 兼容网关 / Ollama 二选一) | [AI复盘与模型配置说明.md](./AI复盘与模型配置说明.md) |
|
||||||
|
|||||||
+160
-141
@@ -1,141 +1,160 @@
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 环境配置模板(可提交 Git)。程序运行时只读取同目录下的 .env。
|
# 环境配置模板(可提交 Git)。程序运行时只读取同目录下的 .env。
|
||||||
#
|
#
|
||||||
# 首次部署 / 新机:
|
# 首次部署 / 新机:
|
||||||
# cp .env.example .env
|
# cp .env.example .env
|
||||||
# nano .env # 填入真实密钥、端口、代理等
|
# nano .env # 填入真实密钥、端口、代理等
|
||||||
#
|
#
|
||||||
# 升级代码(git pull)前建议备份(.env 不在 Git 中,pull 不会覆盖):
|
# 升级代码(git pull)前建议备份(.env 不在 Git 中,pull 不会覆盖):
|
||||||
# cp .env .env.backup.$(date +%Y%m%d)
|
# cp .env .env.backup.$(date +%Y%m%d)
|
||||||
#
|
#
|
||||||
# 从备份恢复:
|
# 从备份恢复:
|
||||||
# cp .env.backup.YYYYMMDD .env
|
# cp .env.backup.YYYYMMDD .env
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
APP_ENV=production
|
APP_ENV=production
|
||||||
# 服务监听地址(云服务器通常用 0.0.0.0)
|
# 服务监听地址(云服务器通常用 0.0.0.0)
|
||||||
APP_HOST=0.0.0.0
|
APP_HOST=0.0.0.0
|
||||||
# 服务端口
|
# 服务端口
|
||||||
APP_PORT=5004
|
APP_PORT=5004
|
||||||
# 是否开启调试模式(生产建议 false)
|
# 是否开启调试模式(生产建议 false)
|
||||||
APP_DEBUG=false
|
APP_DEBUG=false
|
||||||
|
|
||||||
# 登录账号
|
# 登录账号
|
||||||
APP_USERNAME=dekun
|
APP_USERNAME=dekun
|
||||||
# 登录密码(请改成你自己的强密码)
|
# 登录密码(请改成你自己的强密码)
|
||||||
APP_PASSWORD=ChangeMe123!
|
APP_PASSWORD=ChangeMe123!
|
||||||
# 是否关闭登录校验(局域网可设 true;公网务必 false)
|
# 是否关闭登录校验(局域网可设 true;公网务必 false)
|
||||||
APP_AUTH_DISABLED=true
|
APP_AUTH_DISABLED=true
|
||||||
# --- 多账户交易中控 manual_trading_hub ---
|
# --- 多账户交易中控 manual_trading_hub ---
|
||||||
# 中控请求本实例 /api/hub/* 时携带请求头 X-Hub-Token,须与中控启动环境变量 HUB_BRIDGE_TOKEN 一致
|
# 中控请求本实例 /api/hub/* 时携带请求头 X-Hub-Token,须与中控启动环境变量 HUB_BRIDGE_TOKEN 一致
|
||||||
# 未设置且 APP_AUTH_DISABLED=false 时,仅网页登录后可访问;本机联调可保持 APP_AUTH_DISABLED=true
|
# 未设置且 APP_AUTH_DISABLED=false 时,仅网页登录后可访问;本机联调可保持 APP_AUTH_DISABLED=true
|
||||||
# HUB_BRIDGE_TOKEN=your-long-random-token
|
# HUB_BRIDGE_TOKEN=your-long-random-token
|
||||||
# Flask 会话密钥(必须替换为长随机字符串)
|
# Flask 会话密钥(必须替换为长随机字符串)
|
||||||
FLASK_SECRET_KEY=CHANGE_TO_LONG_RANDOM_SECRET
|
FLASK_SECRET_KEY=CHANGE_TO_LONG_RANDOM_SECRET
|
||||||
|
|
||||||
# 企业微信机器人 Webhook(用于行情/风控推送)
|
# 企业微信机器人 Webhook(用于行情/风控推送)
|
||||||
WECHAT_WEBHOOK=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=REPLACE_WITH_REAL_KEY
|
WECHAT_WEBHOOK=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=REPLACE_WITH_REAL_KEY
|
||||||
|
|
||||||
# 数据库文件路径(相对路径会自动按项目目录解析)
|
# 数据库文件路径(相对路径会自动按项目目录解析)
|
||||||
DB_PATH=crypto.db
|
DB_PATH=crypto.db
|
||||||
# 交易截图上传目录
|
# 交易截图上传目录
|
||||||
UPLOAD_DIR=static/images
|
UPLOAD_DIR=static/images
|
||||||
|
|
||||||
# 训练总资金(U)
|
# 训练总资金(U)
|
||||||
TOTAL_CAPITAL=100
|
# TOTAL_CAPITAL=100 # 已弃用,资金展示读交易所
|
||||||
# 每天起始基数(U)
|
# 每天起始基数(U)
|
||||||
DAILY_START_CAPITAL=30
|
DAILY_START_CAPITAL=30
|
||||||
# 日内回撤后基数(U)
|
# 日内回撤后基数(U)
|
||||||
DAILY_LOSS_CAPITAL=20
|
DAILY_LOSS_CAPITAL=20
|
||||||
# 日内盈利后基数(U)
|
# 日内盈利后基数(U)
|
||||||
DAILY_PROFIT_CAPITAL=50
|
DAILY_PROFIT_CAPITAL=50
|
||||||
# BTC 默认杠杆倍数
|
# BTC 默认杠杆倍数
|
||||||
BTC_LEVERAGE=10
|
BTC_LEVERAGE=10
|
||||||
# 山寨币默认杠杆倍数
|
# 山寨币默认杠杆倍数
|
||||||
ALT_LEVERAGE=5
|
ALT_LEVERAGE=5
|
||||||
# 交易日重置小时(北京时间)
|
# 交易日重置小时(北京时间)
|
||||||
TRADING_DAY_RESET_HOUR=8
|
TRADING_DAY_RESET_HOUR=8
|
||||||
|
|
||||||
# 是否开启 OKX 实盘下单(false=只做本地流程,true=真实下单)
|
# 是否开启 OKX 实盘下单(false=只做本地流程,true=真实下单)
|
||||||
LIVE_TRADING_ENABLED=true
|
LIVE_TRADING_ENABLED=true
|
||||||
# OKX API Key(实盘)
|
# OKX API Key(实盘)
|
||||||
OKX_API_KEY=REPLACE_WITH_OKX_API_KEY
|
OKX_API_KEY=REPLACE_WITH_OKX_API_KEY
|
||||||
# OKX API Secret(实盘)
|
# OKX API Secret(实盘)
|
||||||
OKX_API_SECRET=REPLACE_WITH_OKX_API_SECRET
|
OKX_API_SECRET=REPLACE_WITH_OKX_API_SECRET
|
||||||
# OKX API Passphrase(实盘)
|
# OKX API Passphrase(实盘)
|
||||||
OKX_API_PASSPHRASE=REPLACE_WITH_OKX_API_PASSPHRASE
|
OKX_API_PASSPHRASE=REPLACE_WITH_OKX_API_PASSPHRASE
|
||||||
# 保证金模式:cross=全仓,isolated=逐仓
|
# 保证金模式:cross=全仓,isolated=逐仓
|
||||||
OKX_TD_MODE=cross
|
OKX_TD_MODE=cross
|
||||||
# 持仓模式:hedge=双向持仓,net=单向净持仓
|
# 持仓模式:hedge=双向持仓,net=单向净持仓
|
||||||
OKX_POS_MODE=hedge
|
OKX_POS_MODE=hedge
|
||||||
# 仓位查询 instType(OKX)
|
# 仓位查询 instType(OKX)
|
||||||
OKX_POSITION_INST_TYPE=SWAP
|
OKX_POSITION_INST_TYPE=SWAP
|
||||||
|
|
||||||
# 关键位监控:5m收线突破过滤参数
|
# 关键位监控:5m收线突破过滤参数
|
||||||
KLINE_TIMEFRAME=5m
|
KLINE_TIMEFRAME=5m
|
||||||
KEY_BREAKOUT_LIMIT_PCT=1.5
|
KEY_BREAKOUT_LIMIT_PCT=1.5
|
||||||
KEY_ALERT_MAX_TIMES=3
|
KEY_ALERT_MAX_TIMES=3
|
||||||
KEY_ALERT_INTERVAL_MINUTES=5
|
KEY_ALERT_INTERVAL_MINUTES=5
|
||||||
|
|
||||||
# 资金与仓位刷新周期(秒)
|
# 资金与仓位刷新周期(秒)
|
||||||
BALANCE_REFRESH_SECONDS=60
|
BALANCE_REFRESH_SECONDS=60
|
||||||
# 后台监控轮询周期(秒)
|
# 后台监控轮询周期(秒)
|
||||||
MONITOR_POLL_SECONDS=3
|
MONITOR_POLL_SECONDS=3
|
||||||
# 使用可用资金时的缓冲比例(如0.98代表用98%)
|
# 使用可用资金时的缓冲比例(如0.98代表用98%)
|
||||||
FULL_MARGIN_BUFFER_RATIO=0.98
|
FULL_MARGIN_BUFFER_RATIO=0.98
|
||||||
|
|
||||||
# 自动划转:将目标账户补足到 AUTO_TRANSFER_AMOUNT
|
# 自动划转:将目标账户补足到 AUTO_TRANSFER_AMOUNT
|
||||||
AUTO_TRANSFER_ENABLED=false
|
AUTO_TRANSFER_ENABLED=false
|
||||||
AUTO_TRANSFER_AMOUNT=30
|
AUTO_TRANSFER_AMOUNT=30
|
||||||
AUTO_TRANSFER_FROM=funding
|
AUTO_TRANSFER_FROM=funding
|
||||||
AUTO_TRANSFER_TO=swap
|
AUTO_TRANSFER_TO=swap
|
||||||
TRANSFER_CCY=USDT
|
TRANSFER_CCY=USDT
|
||||||
# 强制清仓整点(北京时间,默认 0=凌晨00点)
|
# 强制清仓整点(北京时间,默认 0=凌晨00点)
|
||||||
FORCE_CLOSE_BJ_HOUR=0
|
FORCE_CLOSE_BJ_HOUR=0
|
||||||
# 是否启用强制清仓(默认关闭,true 才会在整点执行)
|
# 是否启用强制清仓(默认关闭,true 才会在整点执行)
|
||||||
FORCE_CLOSE_ENABLED=false
|
FORCE_CLOSE_ENABLED=false
|
||||||
|
|
||||||
# 推送与AI超时(秒)
|
# 推送与AI超时(秒)
|
||||||
WECHAT_TIMEOUT_SECONDS=10
|
WECHAT_TIMEOUT_SECONDS=10
|
||||||
AI_TIMEOUT_SECONDS=120
|
AI_TIMEOUT_SECONDS=120
|
||||||
|
|
||||||
# AI 复盘服务地址(本机 Ollama 默认地址)
|
# AI 复盘服务地址(本机 Ollama 默认地址)
|
||||||
AI_PROVIDER=openai
|
AI_PROVIDER=openai
|
||||||
OPENAI_API_BASE=https://op.bz121.com/v1
|
OPENAI_API_BASE=https://op.bz121.com/v1
|
||||||
OPENAI_API_KEY=你的密钥
|
OPENAI_API_KEY=你的密钥
|
||||||
OPENAI_MODEL=gemma4:e4b
|
OPENAI_MODEL=gemma4:e4b
|
||||||
OLLAMA_API=http://127.0.0.1:11434/api/generate
|
OLLAMA_API=http://127.0.0.1:11434/api/generate
|
||||||
AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest
|
AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest
|
||||||
|
|
||||||
# OKX 代理(可选):用于本机网络对 OKX TLS/SNI 不稳定时,通过 SSH 动态转发 SOCKS5 出口
|
# OKX 代理(可选):用于本机网络对 OKX TLS/SNI 不稳定时,通过 SSH 动态转发 SOCKS5 出口
|
||||||
# 1) 先在本机建立隧道(示例):
|
# 1) 先在本机建立隧道(示例):
|
||||||
# ssh -N -D 127.0.0.1:1080 root@你的VPS_IP -o ServerAliveInterval=30 -o ExitOnForwardFailure=yes
|
# ssh -N -D 127.0.0.1:1080 root@你的VPS_IP -o ServerAliveInterval=30 -o ExitOnForwardFailure=yes
|
||||||
# 2) 再启用下面这一行(推荐 socks5h,让远端解析域名):
|
# 2) 再启用下面这一行(推荐 socks5h,让远端解析域名):
|
||||||
# OKX_SOCKS_PROXY=socks5h://127.0.0.1:1080
|
# OKX_SOCKS_PROXY=socks5h://127.0.0.1:1080
|
||||||
#
|
#
|
||||||
# 如你更偏向 HTTP 代理(VPS 上跑 tinyproxy 之类),可用:
|
# 如你更偏向 HTTP 代理(VPS 上跑 tinyproxy 之类),可用:
|
||||||
# OKX_HTTP_PROXY=http://127.0.0.1:3128
|
# OKX_HTTP_PROXY=http://127.0.0.1:3128
|
||||||
# OKX_HTTPS_PROXY=http://127.0.0.1:3128
|
# OKX_HTTPS_PROXY=http://127.0.0.1:3128
|
||||||
|
|
||||||
# 开仓多周期K线图(可选)
|
# 开仓多周期K线图(可选)
|
||||||
# ORDER_CHART_ENABLED=true
|
# ORDER_CHART_ENABLED=true
|
||||||
# ORDER_CHART_TFS=4h,1h,15m,5m
|
# ORDER_CHART_TFS=4h,1h,15m,5m
|
||||||
# ORDER_CHART_LIMIT=100
|
# ORDER_CHART_LIMIT=100
|
||||||
# ORDER_CHART_DIR=static/images/order_charts
|
# ORDER_CHART_DIR=static/images/order_charts
|
||||||
# DAILY_OPEN_ALERT_THRESHOLD=5
|
# DAILY_OPEN_ALERT_THRESHOLD=5
|
||||||
# 关键位:标准方案止损外侧%、趋势单方案止损外侧%(默认 0.5 / 1)
|
# 关键位:标准方案止损外侧%、趋势单方案止损外侧%(默认 0.5 / 1)
|
||||||
# KEY_STOP_OUTSIDE_BREAKOUT_PCT=0.5
|
# KEY_STOP_OUTSIDE_BREAKOUT_PCT=0.5
|
||||||
# KEY_TREND_STOP_OUTSIDE_PCT=1
|
# KEY_TREND_STOP_OUTSIDE_PCT=1
|
||||||
# 以损定仓(按交易账户资金的百分比)
|
# 以损定仓(按交易账户资金的百分比)
|
||||||
# RISK_PERCENT=2
|
# RISK_PERCENT=2
|
||||||
# 移动保本触发(达到多少R触发)与偏移(百分比)
|
# 移动保本触发(达到多少R触发)与偏移(百分比)
|
||||||
# BREAKEVEN_RR_TRIGGER=1.0
|
# BREAKEVEN_RR_TRIGGER=1.0
|
||||||
# 移动保本阶梯(每多少R继续上移一次,默认1R)
|
# 移动保本阶梯(每多少R继续上移一次,默认1R)
|
||||||
# BREAKEVEN_STEP_R=1.0
|
# BREAKEVEN_STEP_R=1.0
|
||||||
# BREAKEVEN_OFFSET_PCT=0.02
|
# BREAKEVEN_OFFSET_PCT=0.02
|
||||||
# 开单风格默认值:trend / swing
|
# 开单风格默认值:trend / swing
|
||||||
# DEFAULT_TRADE_STYLE=trend
|
# DEFAULT_TRADE_STYLE=trend
|
||||||
|
|
||||||
APP_TIMEZONE=Asia/Shanghai
|
APP_TIMEZONE=Asia/Shanghai
|
||||||
AUTO_TRANSFER_BJ_HOUR=8
|
AUTO_TRANSFER_BJ_HOUR=8
|
||||||
# TRADING_DAY_RESET_HOUR 现在表示「北京时间」整点,默认 8 点起算新交易日/可开仓等
|
# TRADING_DAY_RESET_HOUR 现在表示「北京时间」整点,默认 8 点起算新交易日/可开仓等
|
||||||
|
|
||||||
|
TRADING_DAY_RESET_OPEN_GUARD_ENABLED=true
|
||||||
|
|
||||||
|
MAX_ACTIVE_POSITIONS=1
|
||||||
|
MANUAL_MIN_PLANNED_RR=1.4
|
||||||
|
|
||||||
|
KEY_CONFIRM_BREAKOUT_BAR=-2
|
||||||
|
KEY_CONFIRM_BAR=-1
|
||||||
|
KEY_VOLUME_MA_BARS=20
|
||||||
|
KEY_VOLUME_RATIO_MIN=1.3
|
||||||
|
KEY_BREAKOUT_AMP_MIN_PCT=0.03
|
||||||
|
KEY_BREAKOUT_AMP_MAX_PCT=0.5
|
||||||
|
|
||||||
|
EXCHANGE_DISPLAY_NAME=OKX
|
||||||
|
OKX_ACCOUNT_LABEL=
|
||||||
|
|
||||||
|
BACKUP_ROOT=/root/backups
|
||||||
|
BACKUP_RETENTION_DAYS=30
|
||||||
|
BACKUP_INSTANCE=crypto_monitor_okx
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# crypto_monitor_okx
|
||||||
|
|
||||||
|
基于 **Flask** 的加密货币 **下单监控 / 关键位监控 / 交易复盘** 小系统,行情与实盘接口统一走 **OKX(USDT 永续)**,通过 **ccxt** 访问。功能与界面已与 **`crypto_monitor_binance`** 对齐(顶栏分栏、风控参数、交易所 TP/SL 管理等),差异主要在 **`.env` 的 `OKX_*` 变量** 与 OKX API(含 Passphrase)。
|
||||||
|
|
||||||
|
## 功能概要
|
||||||
|
|
||||||
|
- **关键位监控**:`/key_monitor`,5m 门控、企业微信、部分类型自动开仓(见 `关键位自动下单说明.md`)
|
||||||
|
- **实盘下单**:`/trade`,以损定仓、移动保本、页面内撤挂止盈止损
|
||||||
|
- **策略交易**:`/strategy`(趋势回调 + 顺势加仓),见 [策略交易说明.md](../策略交易说明.md)
|
||||||
|
- **AI 复盘**:见 [AI复盘与模型配置说明.md](../AI复盘与模型配置说明.md)
|
||||||
|
- **实盘(可选)**:`LIVE_TRADING_ENABLED=true` 且配置 `OKX_API_KEY` / `OKX_API_SECRET` / `OKX_API_PASSPHRASE`
|
||||||
|
- **止盈止损(OKX)**:市价成交后通过 ccxt 挂 **止损 / 止盈** 条件单(`attachAlgoOrds` 或 reduceOnly 市价单路径,见 `app.py`)
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
- Python 3.10+
|
||||||
|
- 依赖见仓库根 `requirements.txt`;经 **SSH SOCKS** 访问 OKX 时需 **`PySocks`**,并配置 `OKX_SOCKS_PROXY=socks5h://127.0.0.1:1080`
|
||||||
|
|
||||||
|
## 配置说明
|
||||||
|
|
||||||
|
| 变量 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `OKX_API_KEY` / `OKX_API_SECRET` / `OKX_API_PASSPHRASE` | OKX API |
|
||||||
|
| `OKX_TD_MODE` / `OKX_POS_MODE` | 全仓/逐仓、单向/双向 |
|
||||||
|
| `OKX_SOCKS_PROXY` | 本机 SSH 动态转发时常用 |
|
||||||
|
| `MAX_ACTIVE_POSITIONS` / `MANUAL_MIN_PLANNED_RR` | 与币安版一致的风控 |
|
||||||
|
| `EXCHANGE_DISPLAY_NAME` | 页面展示名,默认 `OKX` |
|
||||||
|
|
||||||
|
完整模板见 **`.env.example`**。
|
||||||
|
|
||||||
|
## 本地运行
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd crypto_monitor_okx
|
||||||
|
$env:PYTHONPATH=".."
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
默认端口 **`APP_PORT`**(常为 `5004`,与中控登记一致)。
|
||||||
|
|
||||||
|
## 部署
|
||||||
|
|
||||||
|
详见 **[部署文档.md](./部署文档.md)**、**[使用说明.md](./使用说明.md)**。
|
||||||
|
|
||||||
|
## 自检
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/verify_okx_funding.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 风险与合规
|
||||||
|
|
||||||
|
实盘风险自负;请确认 API 权限、IP 白名单与 OKX 账户设置一致。
|
||||||
+480
-57
@@ -127,6 +127,9 @@ BTC_LEVERAGE = int(os.getenv("BTC_LEVERAGE", "10"))
|
|||||||
ALT_LEVERAGE = int(os.getenv("ALT_LEVERAGE", "5"))
|
ALT_LEVERAGE = int(os.getenv("ALT_LEVERAGE", "5"))
|
||||||
# 交易日滚动与「可开仓」整点:按应用本地时区 wall clock(默认北京时间 UTC+8)
|
# 交易日滚动与「可开仓」整点:按应用本地时区 wall clock(默认北京时间 UTC+8)
|
||||||
TRADING_DAY_RESET_HOUR = int(os.getenv("TRADING_DAY_RESET_HOUR", "8"))
|
TRADING_DAY_RESET_HOUR = int(os.getenv("TRADING_DAY_RESET_HOUR", "8"))
|
||||||
|
TRADING_DAY_RESET_OPEN_GUARD_ENABLED = os.getenv(
|
||||||
|
"TRADING_DAY_RESET_OPEN_GUARD_ENABLED", "true"
|
||||||
|
).lower() in ("1", "true", "yes", "on")
|
||||||
APP_TIMEZONE = os.getenv("APP_TIMEZONE", "Asia/Shanghai")
|
APP_TIMEZONE = os.getenv("APP_TIMEZONE", "Asia/Shanghai")
|
||||||
|
|
||||||
|
|
||||||
@@ -146,6 +149,7 @@ OKX_API_SECRET = os.getenv("OKX_API_SECRET", "")
|
|||||||
OKX_API_PASSPHRASE = os.getenv("OKX_API_PASSPHRASE", "")
|
OKX_API_PASSPHRASE = os.getenv("OKX_API_PASSPHRASE", "")
|
||||||
OKX_TD_MODE = os.getenv("OKX_TD_MODE", "cross")
|
OKX_TD_MODE = os.getenv("OKX_TD_MODE", "cross")
|
||||||
OKX_POS_MODE = os.getenv("OKX_POS_MODE", "hedge")
|
OKX_POS_MODE = os.getenv("OKX_POS_MODE", "hedge")
|
||||||
|
EXCHANGE_DISPLAY_NAME = (os.getenv("EXCHANGE_DISPLAY_NAME") or "OKX").strip() or "OKX"
|
||||||
BALANCE_REFRESH_SECONDS = int(os.getenv("BALANCE_REFRESH_SECONDS", "60"))
|
BALANCE_REFRESH_SECONDS = int(os.getenv("BALANCE_REFRESH_SECONDS", "60"))
|
||||||
PRICE_REFRESH_SECONDS = int(os.getenv("PRICE_REFRESH_SECONDS", "5"))
|
PRICE_REFRESH_SECONDS = int(os.getenv("PRICE_REFRESH_SECONDS", "5"))
|
||||||
KEY_ALERT_MAX_TIMES = int(os.getenv("KEY_ALERT_MAX_TIMES", "3"))
|
KEY_ALERT_MAX_TIMES = int(os.getenv("KEY_ALERT_MAX_TIMES", "3"))
|
||||||
@@ -183,7 +187,16 @@ KEY_MONITOR_ALERT_ONLY_TYPES = frozenset({"关键阻力位", "关键支撑位"})
|
|||||||
KEY_AUTO_MIN_PLANNED_RR = float(os.getenv("KEY_AUTO_MIN_PLANNED_RR", "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"))
|
KEY_STOP_OUTSIDE_BREAKOUT_PCT = float(os.getenv("KEY_STOP_OUTSIDE_BREAKOUT_PCT", "0.5"))
|
||||||
KEY_TREND_STOP_OUTSIDE_PCT = float(os.getenv("KEY_TREND_STOP_OUTSIDE_PCT", "1"))
|
KEY_TREND_STOP_OUTSIDE_PCT = float(os.getenv("KEY_TREND_STOP_OUTSIDE_PCT", "1"))
|
||||||
KEY_DAILY_VOLUME_RANK_MAX = int(os.getenv("KEY_DAILY_VOLUME_RANK_MAX", "30"))
|
KEY_DAILY_VOLUME_RANK_MAX = max(1, int(os.getenv("KEY_DAILY_VOLUME_RANK_MAX", "30")))
|
||||||
|
|
||||||
|
MANUAL_MIN_PLANNED_RR = float(os.getenv("MANUAL_MIN_PLANNED_RR", "1.4"))
|
||||||
|
MAX_ACTIVE_POSITIONS = max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", "1")))
|
||||||
|
KEY_VOLUME_MA_BARS = max(1, int(os.getenv("KEY_VOLUME_MA_BARS", "20")))
|
||||||
|
KEY_VOLUME_RATIO_MIN = float(os.getenv("KEY_VOLUME_RATIO_MIN", "1.3"))
|
||||||
|
KEY_BREAKOUT_AMP_MIN_PCT = float(os.getenv("KEY_BREAKOUT_AMP_MIN_PCT", "0.03"))
|
||||||
|
KEY_BREAKOUT_AMP_MAX_PCT = float(os.getenv("KEY_BREAKOUT_AMP_MAX_PCT", "0.5"))
|
||||||
|
KEY_CONFIRM_BREAKOUT_BAR = int(os.getenv("KEY_CONFIRM_BREAKOUT_BAR", "-2"))
|
||||||
|
KEY_CONFIRM_BAR = int(os.getenv("KEY_CONFIRM_BAR", "-1"))
|
||||||
KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT = os.getenv("KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT", "true").lower() in (
|
KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT = os.getenv("KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT", "true").lower() in (
|
||||||
"1",
|
"1",
|
||||||
"true",
|
"true",
|
||||||
@@ -1753,6 +1766,18 @@ def format_price_for_symbol(symbol, value):
|
|||||||
return text.rstrip("0").rstrip(".") if "." in text else text
|
return text.rstrip("0").rstrip(".") if "." in text else text
|
||||||
|
|
||||||
|
|
||||||
|
FUNDS_DECIMALS = 2
|
||||||
|
|
||||||
|
|
||||||
|
def format_funds_u(value):
|
||||||
|
if value in (None, ""):
|
||||||
|
return "-"
|
||||||
|
try:
|
||||||
|
return f"{float(value):.{FUNDS_DECIMALS}f}"
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
def format_hold_minutes(minutes):
|
def format_hold_minutes(minutes):
|
||||||
if not minutes:
|
if not minutes:
|
||||||
return "0分钟"
|
return "0分钟"
|
||||||
@@ -2194,13 +2219,19 @@ def auto_transfer_once_per_day():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def trading_day_reset_allows_new_open(now):
|
||||||
|
if not TRADING_DAY_RESET_OPEN_GUARD_ENABLED:
|
||||||
|
return True
|
||||||
|
return now.hour >= TRADING_DAY_RESET_HOUR
|
||||||
|
|
||||||
|
|
||||||
def precheck_risk(conn, symbol, direction):
|
def precheck_risk(conn, symbol, direction):
|
||||||
now = app_now()
|
now = app_now()
|
||||||
if now.hour < TRADING_DAY_RESET_HOUR:
|
if not trading_day_reset_allows_new_open(now):
|
||||||
return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓"
|
return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓"
|
||||||
active_count = conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0]
|
active_count = get_active_position_count(conn)
|
||||||
if active_count > 0:
|
if active_count >= MAX_ACTIVE_POSITIONS:
|
||||||
return False, "一次只能持有一个仓位"
|
return False, f"已达最大持仓数({active_count}/{MAX_ACTIVE_POSITIONS})"
|
||||||
if direction not in ("long", "short"):
|
if direction not in ("long", "short"):
|
||||||
return False, "方向必须为 long 或 short"
|
return False, "方向必须为 long 或 short"
|
||||||
if symbol.upper().startswith("BTC") or symbol.upper().startswith("ETH"):
|
if symbol.upper().startswith("BTC") or symbol.upper().startswith("ETH"):
|
||||||
@@ -2393,6 +2424,210 @@ def _okx_place_tp_sl_orders(exchange_symbol, direction, amount, stop_loss, take_
|
|||||||
exchange.create_order(exchange_symbol, "market", close_side, amt, None, params)
|
exchange.create_order(exchange_symbol, "market", close_side, amt, None, params)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def exchange_private_api_configured():
|
||||||
|
return bool(OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE)
|
||||||
|
|
||||||
|
|
||||||
|
def _position_row_effective_contracts(p):
|
||||||
|
info = p.get("info", {}) or {}
|
||||||
|
contracts = p.get("contracts")
|
||||||
|
if contracts is None:
|
||||||
|
raw_pos = info.get("pos")
|
||||||
|
try:
|
||||||
|
contracts = abs(float(raw_pos)) if raw_pos is not None else 0.0
|
||||||
|
except Exception:
|
||||||
|
contracts = 0.0
|
||||||
|
try:
|
||||||
|
return float(contracts)
|
||||||
|
except Exception:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _position_matches_wanted_contract(exchange_symbol, position):
|
||||||
|
if not position:
|
||||||
|
return False
|
||||||
|
sym = position.get("symbol")
|
||||||
|
return sym == exchange_symbol
|
||||||
|
|
||||||
|
|
||||||
|
def _select_live_position_row(rows, exchange_symbol, direction, relax_hedge=False):
|
||||||
|
if not rows:
|
||||||
|
return None
|
||||||
|
candidates = []
|
||||||
|
for p in rows:
|
||||||
|
if not _position_matches_wanted_contract(exchange_symbol, p):
|
||||||
|
continue
|
||||||
|
info = p.get("info", {}) or {}
|
||||||
|
side = (p.get("side") or info.get("posSide") or "").lower()
|
||||||
|
contracts = _position_row_effective_contracts(p)
|
||||||
|
if contracts <= 0:
|
||||||
|
continue
|
||||||
|
if (not relax_hedge) and OKX_POS_MODE == "hedge":
|
||||||
|
if side and side != (direction or "").lower():
|
||||||
|
continue
|
||||||
|
candidates.append((contracts, p))
|
||||||
|
if not candidates and (not relax_hedge) and OKX_POS_MODE == "hedge":
|
||||||
|
return _select_live_position_row(rows, exchange_symbol, direction, relax_hedge=True)
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
candidates.sort(key=lambda x: x[0], reverse=True)
|
||||||
|
return candidates[0][1]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ccxt_position_metrics(position, order_leverage=None):
|
||||||
|
if not position:
|
||||||
|
return None
|
||||||
|
p = position
|
||||||
|
info = p.get("info", {}) or {}
|
||||||
|
initial = _coerce_float(p.get("collateral"), p.get("initialMargin"), p.get("margin"))
|
||||||
|
if initial is None or initial <= 0:
|
||||||
|
initial = _coerce_float(
|
||||||
|
info.get("margin"),
|
||||||
|
info.get("imr"),
|
||||||
|
info.get("initial_margin"),
|
||||||
|
)
|
||||||
|
notional = _coerce_float(p.get("notional"), p.get("notionalValue"))
|
||||||
|
if notional is None or notional <= 0:
|
||||||
|
notional = _coerce_float(info.get("notionalUsd"), info.get("notional"))
|
||||||
|
if notional is not None:
|
||||||
|
notional = abs(notional)
|
||||||
|
if (initial is None or initial <= 0) and notional and notional > 0 and order_leverage:
|
||||||
|
try:
|
||||||
|
lev = float(order_leverage)
|
||||||
|
if lev > 0:
|
||||||
|
approx = notional / lev
|
||||||
|
if approx > 0:
|
||||||
|
initial = approx
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
unrealized = _coerce_float(
|
||||||
|
p.get("unrealizedPnl"),
|
||||||
|
info.get("upl"),
|
||||||
|
info.get("unrealized_pnl"),
|
||||||
|
)
|
||||||
|
mark = _coerce_float(p.get("markPrice"), p.get("mark_price"), info.get("markPx"))
|
||||||
|
out = {}
|
||||||
|
if initial is not None and initial > 0:
|
||||||
|
out["initial_margin"] = round(initial, FUNDS_DECIMALS)
|
||||||
|
if notional is not None and notional > 0:
|
||||||
|
out["notional"] = round(notional, FUNDS_DECIMALS)
|
||||||
|
if unrealized is not None:
|
||||||
|
out["unrealized_pnl"] = round(unrealized, FUNDS_DECIMALS)
|
||||||
|
if mark is not None and mark > 0:
|
||||||
|
out["mark_price"] = round(mark, 8)
|
||||||
|
return out or None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_tpsl_prices_for_manual(direction, live_price, sltp_mode, data):
|
||||||
|
sltp_mode = (sltp_mode or "price").strip().lower()
|
||||||
|
if sltp_mode == "pct":
|
||||||
|
sl_pct = float(data.get("sl_pct") or 0)
|
||||||
|
tp_pct = float(data.get("tp_pct") or 0)
|
||||||
|
if sl_pct <= 0 or tp_pct <= 0:
|
||||||
|
raise ValueError("百分比止盈止损须为正数")
|
||||||
|
sl_ratio = sl_pct / 100.0
|
||||||
|
tp_ratio = tp_pct / 100.0
|
||||||
|
entry = float(live_price)
|
||||||
|
if direction == "short":
|
||||||
|
stop_loss = entry * (1 + sl_ratio)
|
||||||
|
take_profit = entry * (1 - tp_ratio)
|
||||||
|
else:
|
||||||
|
stop_loss = entry * (1 - sl_ratio)
|
||||||
|
take_profit = entry * (1 + tp_ratio)
|
||||||
|
else:
|
||||||
|
stop_loss = float(data.get("sl") or data.get("stop_loss") or 0)
|
||||||
|
take_profit = float(data.get("tp") or data.get("take_profit") or data.get("tgt") or 0)
|
||||||
|
if stop_loss <= 0 or take_profit <= 0:
|
||||||
|
raise ValueError("止盈止损价格须大于 0")
|
||||||
|
return stop_loss, take_profit
|
||||||
|
|
||||||
|
|
||||||
|
def _okx_tpsl_slot_from_order(order, exchange_symbol):
|
||||||
|
info = order.get("info") or {}
|
||||||
|
oid = order.get("id") or info.get("algoId") or info.get("ordId")
|
||||||
|
trig = _coerce_float(
|
||||||
|
info.get("slTriggerPx"),
|
||||||
|
info.get("tpTriggerPx"),
|
||||||
|
order.get("stopLossPrice"),
|
||||||
|
order.get("takeProfitPrice"),
|
||||||
|
)
|
||||||
|
if trig is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"order_id": str(oid) if oid is not None else None,
|
||||||
|
"trigger_price": float(trig),
|
||||||
|
"trigger_display": format_price_for_symbol(
|
||||||
|
exchange_symbol.replace(":USDT", "").replace("/USDT:USDT", ""),
|
||||||
|
trig,
|
||||||
|
),
|
||||||
|
"type": str(order.get("type") or info.get("ordType") or ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_exchange_tpsl_slots(exchange_symbol, direction, plan_sl=None, plan_tp=None):
|
||||||
|
slots = {"sl": None, "tp": None}
|
||||||
|
if not exchange_symbol:
|
||||||
|
return slots
|
||||||
|
ok, _ = ensure_okx_live_ready()
|
||||||
|
if not ok:
|
||||||
|
return slots
|
||||||
|
try:
|
||||||
|
ensure_markets_loaded()
|
||||||
|
ambiguous = []
|
||||||
|
for order in exchange.fetch_open_orders(exchange_symbol) or []:
|
||||||
|
slot = _okx_tpsl_slot_from_order(order, exchange_symbol)
|
||||||
|
if not slot or not slot.get("order_id"):
|
||||||
|
continue
|
||||||
|
trig = slot.get("trigger_price")
|
||||||
|
if plan_sl is not None and plan_tp is not None:
|
||||||
|
try:
|
||||||
|
role = "sl" if abs(trig - float(plan_sl)) <= abs(trig - float(plan_tp)) else "tp"
|
||||||
|
except Exception:
|
||||||
|
role = None
|
||||||
|
elif plan_sl is not None:
|
||||||
|
role = "sl"
|
||||||
|
elif plan_tp is not None:
|
||||||
|
role = "tp"
|
||||||
|
else:
|
||||||
|
ambiguous.append(slot)
|
||||||
|
continue
|
||||||
|
if role in ("sl", "tp") and slots[role] is None:
|
||||||
|
slots[role] = slot
|
||||||
|
for slot in ambiguous:
|
||||||
|
trig = slot.get("trigger_price")
|
||||||
|
if trig is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
plan_sl_f = float(plan_sl) if plan_sl is not None else None
|
||||||
|
plan_tp_f = float(plan_tp) if plan_tp is not None else None
|
||||||
|
except Exception:
|
||||||
|
plan_sl_f = plan_tp_f = None
|
||||||
|
if plan_sl_f is not None and plan_tp_f is not None:
|
||||||
|
role = "sl" if abs(trig - plan_sl_f) <= abs(trig - plan_tp_f) else "tp"
|
||||||
|
elif plan_sl_f is not None:
|
||||||
|
role = "sl"
|
||||||
|
elif plan_tp_f is not None:
|
||||||
|
role = "tp"
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
if slots[role] is None:
|
||||||
|
slots[role] = slot
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return slots
|
||||||
|
|
||||||
|
|
||||||
|
def cancel_okx_tpsl_slot(exchange_symbol, slot):
|
||||||
|
if not slot or not exchange_symbol:
|
||||||
|
return
|
||||||
|
oid = slot.get("order_id")
|
||||||
|
if not oid:
|
||||||
|
return
|
||||||
|
ensure_markets_loaded()
|
||||||
|
exchange.cancel_order(str(oid), exchange_symbol)
|
||||||
|
|
||||||
|
|
||||||
def replace_active_monitor_tpsl_on_exchange(order_row, stop_loss, take_profit):
|
def replace_active_monitor_tpsl_on_exchange(order_row, stop_loss, take_profit):
|
||||||
"""先撤该合约挂单/条件单,再按新价重挂 TP/SL。"""
|
"""先撤该合约挂单/条件单,再按新价重挂 TP/SL。"""
|
||||||
ok, reason = ensure_okx_live_ready()
|
ok, reason = ensure_okx_live_ready()
|
||||||
@@ -4146,8 +4381,8 @@ def render_main_page(page="trade"):
|
|||||||
session_row = ensure_session(conn, trading_day)
|
session_row = ensure_session(conn, trading_day)
|
||||||
local_current_capital = float(session_row["current_capital"])
|
local_current_capital = float(session_row["current_capital"])
|
||||||
funding_capital, trading_capital = get_exchange_capitals()
|
funding_capital, trading_capital = get_exchange_capitals()
|
||||||
total_capital = round(funding_capital, 4) if funding_capital is not None else TOTAL_CAPITAL
|
funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None
|
||||||
current_capital = round(trading_capital, 4) if trading_capital is not None else round(local_current_capital, 4)
|
current_capital = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else round(local_current_capital, FUNDS_DECIMALS)
|
||||||
recommended_capital = get_recommended_capital(current_capital)
|
recommended_capital = get_recommended_capital(current_capital)
|
||||||
key_list = conn.execute("SELECT * FROM key_monitors").fetchall()
|
key_list = conn.execute("SELECT * FROM key_monitors").fetchall()
|
||||||
key_history = conn.execute(
|
key_history = conn.execute(
|
||||||
@@ -4176,11 +4411,13 @@ def render_main_page(page="trade"):
|
|||||||
)
|
)
|
||||||
rate = round(win/total*100,2) if total else 0
|
rate = round(win/total*100,2) if total else 0
|
||||||
active_count = len(order_list)
|
active_count = len(order_list)
|
||||||
can_trade = now.hour >= TRADING_DAY_RESET_HOUR and active_count == 0
|
can_trade = trading_day_reset_allows_new_open(now) and active_count < MAX_ACTIVE_POSITIONS
|
||||||
key_gate_rule_text = (
|
key_gate_rule_text = (
|
||||||
f"周期 {KLINE_TIMEFRAME}|量能/突破/二确门控见箱体与收敛规则|"
|
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}|"
|
f"自动开仓盈亏比 > {KEY_AUTO_MIN_PLANNED_RR}:1|日成交量排名前 {KEY_DAILY_VOLUME_RANK_MAX}|"
|
||||||
f"斐波:添加后立即挂限价 @ E,失效按标记价触达 H/L(未成交撤单)"
|
f"箱体/收敛可选 SL/TP 方案(标准 / 箱体1R·止盈1.5H / 趋势单+自填止盈)|移动保本默认关|"
|
||||||
|
f"斐波:限价 @ E(SL/TP 为 H/L),可选移动保本|趋势止损外侧 {KEY_TREND_STOP_OUTSIDE_PCT}%"
|
||||||
)
|
)
|
||||||
strategy_extra = {}
|
strategy_extra = {}
|
||||||
if page in ("strategy", "strategy_trend", "strategy_roll"):
|
if page in ("strategy", "strategy_trend", "strategy_roll"):
|
||||||
@@ -4206,7 +4443,6 @@ def render_main_page(page="trade"):
|
|||||||
miss_count=miss_count,
|
miss_count=miss_count,
|
||||||
rate=rate,
|
rate=rate,
|
||||||
trading_day=trading_day,
|
trading_day=trading_day,
|
||||||
total_capital=total_capital,
|
|
||||||
daily_start_capital=DAILY_START_CAPITAL,
|
daily_start_capital=DAILY_START_CAPITAL,
|
||||||
current_capital=current_capital,
|
current_capital=current_capital,
|
||||||
recommended_capital=recommended_capital,
|
recommended_capital=recommended_capital,
|
||||||
@@ -4242,7 +4478,13 @@ def render_main_page(page="trade"):
|
|||||||
entry_reason_options=list(ENTRY_REASON_OPTIONS),
|
entry_reason_options=list(ENTRY_REASON_OPTIONS),
|
||||||
entry_reason_other_value=ENTRY_REASON_OTHER,
|
entry_reason_other_value=ENTRY_REASON_OTHER,
|
||||||
key_gate_rule_text=key_gate_rule_text,
|
key_gate_rule_text=key_gate_rule_text,
|
||||||
|
funds_fmt=format_funds_u,
|
||||||
|
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_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR,
|
||||||
|
kline_timeframe=KLINE_TIMEFRAME,
|
||||||
|
funding_usdt=funding_usdt,
|
||||||
**strategy_extra,
|
**strategy_extra,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -4253,6 +4495,12 @@ def index():
|
|||||||
return redirect("/trade")
|
return redirect("/trade")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/key_monitor")
|
||||||
|
@login_required
|
||||||
|
def key_monitor_page():
|
||||||
|
return render_main_page("key_monitor")
|
||||||
|
|
||||||
|
|
||||||
@app.route("/trade")
|
@app.route("/trade")
|
||||||
@login_required
|
@login_required
|
||||||
def trade_page():
|
def trade_page():
|
||||||
@@ -4280,21 +4528,23 @@ def api_account_snapshot():
|
|||||||
session_row = ensure_session(conn, trading_day)
|
session_row = ensure_session(conn, trading_day)
|
||||||
local_current_capital = float(session_row["current_capital"])
|
local_current_capital = float(session_row["current_capital"])
|
||||||
funding_capital, trading_capital = get_exchange_capitals(force=True)
|
funding_capital, trading_capital = get_exchange_capitals(force=True)
|
||||||
total_capital = round(funding_capital, 4) if funding_capital is not None else TOTAL_CAPITAL
|
funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None
|
||||||
current_capital = round(trading_capital, 4) if trading_capital is not None else round(local_current_capital, 4)
|
current_capital = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else round(local_current_capital, FUNDS_DECIMALS)
|
||||||
recommended_capital = get_recommended_capital(current_capital)
|
recommended_capital = get_recommended_capital(current_capital)
|
||||||
active_count = conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0]
|
active_count = get_active_position_count(conn)
|
||||||
conn.close()
|
conn.close()
|
||||||
can_trade = now.hour >= TRADING_DAY_RESET_HOUR and active_count == 0
|
can_trade = trading_day_reset_allows_new_open(now) and active_count < MAX_ACTIVE_POSITIONS
|
||||||
available_trading_usdt = get_available_trading_usdt()
|
available_trading_usdt = get_available_trading_usdt()
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"total_capital": total_capital,
|
"funding_usdt": funding_usdt,
|
||||||
"current_capital": current_capital,
|
"current_capital": current_capital,
|
||||||
"available_trading_usdt": round(available_trading_usdt, 4) if available_trading_usdt is not None else None,
|
"available_trading_usdt": round(available_trading_usdt, FUNDS_DECIMALS) if available_trading_usdt is not None else None,
|
||||||
"recommended_capital": recommended_capital,
|
"recommended_capital": recommended_capital,
|
||||||
"active_count": active_count,
|
"active_count": active_count,
|
||||||
|
"max_active_positions": MAX_ACTIVE_POSITIONS,
|
||||||
"can_trade": can_trade,
|
"can_trade": can_trade,
|
||||||
"trading_day": trading_day
|
"manual_min_planned_rr": MANUAL_MIN_PLANNED_RR,
|
||||||
|
"trading_day": trading_day,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -4306,10 +4556,15 @@ def api_price_snapshot():
|
|||||||
"SELECT id,symbol,monitor_type,direction,upper,lower,fib_entry_price,fib_limit_order_id FROM key_monitors"
|
"SELECT id,symbol,monitor_type,direction,upper,lower,fib_entry_price,fib_limit_order_id FROM key_monitors"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
order_rows = conn.execute(
|
order_rows = conn.execute(
|
||||||
"SELECT id,symbol,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage FROM order_monitors WHERE status='active'"
|
"SELECT id,symbol,exchange_symbol,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage FROM order_monitors WHERE status='active'"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
try:
|
||||||
|
ensure_markets_loaded()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
symbol_set = set()
|
symbol_set = set()
|
||||||
for r in key_rows:
|
for r in key_rows:
|
||||||
symbol_set.add(r["symbol"])
|
symbol_set.add(r["symbol"])
|
||||||
@@ -4322,20 +4577,30 @@ def api_price_snapshot():
|
|||||||
if p is not None:
|
if p is not None:
|
||||||
prices[s] = float(p)
|
prices[s] = float(p)
|
||||||
|
|
||||||
|
all_swap_positions = []
|
||||||
|
if exchange_private_api_configured():
|
||||||
|
try:
|
||||||
|
ensure_markets_loaded()
|
||||||
|
# 显式 USDT 本位;不传 symbols 拉全量,再在本地按合约对齐
|
||||||
|
all_swap_positions = exchange.fetch_positions(None, {"instType": OKX_POSITION_INST_TYPE}) or []
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
all_swap_positions = exchange.fetch_positions() or []
|
||||||
|
except Exception:
|
||||||
|
all_swap_positions = []
|
||||||
|
|
||||||
key_prices = []
|
key_prices = []
|
||||||
for r in key_rows:
|
for r in key_rows:
|
||||||
price = prices.get(r["symbol"])
|
is_fib = is_fib_key_monitor_type(r["monitor_type"])
|
||||||
|
if is_fib:
|
||||||
|
price = get_symbol_mark_price(r["symbol"])
|
||||||
|
else:
|
||||||
|
price = prices.get(r["symbol"])
|
||||||
if price is None:
|
if price is None:
|
||||||
continue
|
continue
|
||||||
upper_diff, upper_pct = calc_price_diff_pct(price, r["upper"])
|
upper_diff, upper_pct = calc_price_diff_pct(price, r["upper"])
|
||||||
lower_diff, lower_pct = calc_price_diff_pct(price, r["lower"])
|
lower_diff, lower_pct = calc_price_diff_pct(price, r["lower"])
|
||||||
is_fib = is_fib_key_monitor_type(r["monitor_type"])
|
|
||||||
gate = None
|
gate = None
|
||||||
if not is_fib:
|
|
||||||
try:
|
|
||||||
gate = _key_hard_checks(r["symbol"], (r["direction"] or "long").lower(), r["upper"], r["lower"], r["monitor_type"])
|
|
||||||
except Exception:
|
|
||||||
gate = None
|
|
||||||
gate_summary = "-"
|
gate_summary = "-"
|
||||||
gate_metrics = ""
|
gate_metrics = ""
|
||||||
fib_gate_ok = True
|
fib_gate_ok = True
|
||||||
@@ -4344,11 +4609,16 @@ def api_price_snapshot():
|
|||||||
inval = fib_invalidate_by_mark(direction, price, r["upper"], r["lower"])
|
inval = fib_invalidate_by_mark(direction, price, r["upper"], r["lower"])
|
||||||
fib_gate_ok = not inval
|
fib_gate_ok = not inval
|
||||||
entry = _sqlite_row_val(r, "fib_entry_price")
|
entry = _sqlite_row_val(r, "fib_entry_price")
|
||||||
entry_txt = format_price_for_symbol(r["symbol"], entry) if entry is not None else "-"
|
entry_txt = format_price_for_symbol(r["symbol"], entry) if entry else "-"
|
||||||
gate_summary = f"斐波 挂E={entry_txt} {'标记价将失效' if inval else '等待成交'}"
|
gate_summary = f"斐波 挂E={entry_txt} {'标记价将失效' if inval else '等待成交'}"
|
||||||
if _sqlite_row_val(r, "fib_limit_order_id"):
|
if _sqlite_row_val(r, "fib_limit_order_id"):
|
||||||
gate_metrics = f"限价单:{_sqlite_row_val(r, 'fib_limit_order_id')}"
|
gate_metrics = f"限价单:{_sqlite_row_val(r, 'fib_limit_order_id')}"
|
||||||
elif gate:
|
else:
|
||||||
|
try:
|
||||||
|
gate = _key_hard_checks(r["symbol"], (r["direction"] or "long").lower(), r["upper"], r["lower"], r["monitor_type"])
|
||||||
|
except Exception:
|
||||||
|
gate = None
|
||||||
|
if gate:
|
||||||
rank_seg = "ERR" if int(gate.get("rank_total") or 0) <= 0 else f"{gate.get('rank')}/{gate.get('rank_total')}"
|
rank_seg = "ERR" if int(gate.get("rank_total") or 0) <= 0 else f"{gate.get('rank')}/{gate.get('rank_total')}"
|
||||||
gate_summary = (
|
gate_summary = (
|
||||||
f"量:{'Y' if gate.get('vol_ok') else 'N'} "
|
f"量:{'Y' if gate.get('vol_ok') else 'N'} "
|
||||||
@@ -4371,10 +4641,16 @@ def api_price_snapshot():
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
gate_metrics = ""
|
gate_metrics = ""
|
||||||
|
px_disp = format_price_for_symbol(r["symbol"], price)
|
||||||
|
try:
|
||||||
|
price_num = float(px_disp) if px_disp != "-" else float(price)
|
||||||
|
except Exception:
|
||||||
|
price_num = float(price)
|
||||||
key_prices.append({
|
key_prices.append({
|
||||||
"id": r["id"],
|
"id": r["id"],
|
||||||
"symbol": r["symbol"],
|
"symbol": r["symbol"],
|
||||||
"price": round(price, 6),
|
"price": price_num,
|
||||||
|
"price_display": px_disp,
|
||||||
"upper_diff": upper_diff,
|
"upper_diff": upper_diff,
|
||||||
"upper_pct": upper_pct,
|
"upper_pct": upper_pct,
|
||||||
"lower_diff": lower_diff,
|
"lower_diff": lower_diff,
|
||||||
@@ -4395,19 +4671,67 @@ def api_price_snapshot():
|
|||||||
pnl = calc_pnl(r["direction"], entry, price, margin, leverage) if entry > 0 else 0
|
pnl = calc_pnl(r["direction"], entry, price, margin, leverage) if entry > 0 else 0
|
||||||
pnl_pct = round((pnl / margin * 100), 4) if margin > 0 else 0
|
pnl_pct = round((pnl / margin * 100), 4) if margin > 0 else 0
|
||||||
rr_ratio = calc_rr_ratio(r["direction"], entry, r["initial_stop_loss"] or r["stop_loss"], r["take_profit"])
|
rr_ratio = calc_rr_ratio(r["direction"], entry, r["initial_stop_loss"] or r["stop_loss"], r["take_profit"])
|
||||||
order_prices.append({
|
ex_sym = resolve_monitor_exchange_symbol(r)
|
||||||
|
prow = _select_live_position_row(all_swap_positions, ex_sym, r["direction"])
|
||||||
|
lev_row = r["leverage"] if "leverage" in r.keys() else None
|
||||||
|
ex_metrics = parse_ccxt_position_metrics(prow, order_leverage=lev_row) if prow else None
|
||||||
|
payload = {
|
||||||
"id": r["id"],
|
"id": r["id"],
|
||||||
"symbol": r["symbol"],
|
"symbol": r["symbol"],
|
||||||
"price": round(price, 6),
|
"float_pnl": round(pnl, 2),
|
||||||
"float_pnl": round(pnl, 6),
|
|
||||||
"float_pct": pnl_pct,
|
"float_pct": pnl_pct,
|
||||||
"rr_ratio": rr_ratio,
|
"rr_ratio": rr_ratio,
|
||||||
})
|
"plan_margin": round(margin, 2) if margin else None,
|
||||||
|
"exchange_initial_margin": None,
|
||||||
|
"exchange_notional": None,
|
||||||
|
"exchange_mark_price": None,
|
||||||
|
"pnl_source": "plan",
|
||||||
|
}
|
||||||
|
if ex_metrics:
|
||||||
|
if ex_metrics.get("initial_margin") is not None:
|
||||||
|
payload["exchange_initial_margin"] = ex_metrics["initial_margin"]
|
||||||
|
if ex_metrics.get("notional") is not None:
|
||||||
|
payload["exchange_notional"] = ex_metrics["notional"]
|
||||||
|
if ex_metrics.get("mark_price") is not None:
|
||||||
|
payload["exchange_mark_price"] = ex_metrics["mark_price"]
|
||||||
|
if ex_metrics.get("unrealized_pnl") is not None:
|
||||||
|
payload["float_pnl"] = round(float(ex_metrics["unrealized_pnl"]), 2)
|
||||||
|
payload["pnl_source"] = "exchange"
|
||||||
|
denom = ex_metrics.get("initial_margin") or margin
|
||||||
|
payload["float_pct"] = (
|
||||||
|
round((payload["float_pnl"] / float(denom)) * 100, 4) if denom and float(denom) > 0 else pnl_pct
|
||||||
|
)
|
||||||
|
px_for_fmt = float(price)
|
||||||
|
if ex_metrics and ex_metrics.get("mark_price") is not None:
|
||||||
|
try:
|
||||||
|
px_for_fmt = float(ex_metrics["mark_price"])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
px_disp = format_price_for_symbol(r["symbol"], px_for_fmt)
|
||||||
|
try:
|
||||||
|
payload["price"] = float(px_disp) if px_disp != "-" else px_for_fmt
|
||||||
|
except Exception:
|
||||||
|
payload["price"] = px_for_fmt
|
||||||
|
payload["price_display"] = px_disp
|
||||||
|
if exchange_private_api_configured():
|
||||||
|
try:
|
||||||
|
payload["exchange_tpsl"] = fetch_exchange_tpsl_slots(
|
||||||
|
ex_sym,
|
||||||
|
r["direction"],
|
||||||
|
plan_sl=r["stop_loss"],
|
||||||
|
plan_tp=r["take_profit"],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
payload["exchange_tpsl"] = {"sl": None, "tp": None}
|
||||||
|
else:
|
||||||
|
payload["exchange_tpsl"] = {"sl": None, "tp": None}
|
||||||
|
order_prices.append(payload)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"updated_at": app_now_str(),
|
"updated_at": app_now_str(),
|
||||||
"key_prices": key_prices,
|
"key_prices": key_prices,
|
||||||
"order_prices": order_prices
|
"order_prices": order_prices,
|
||||||
|
"positions_raw_count": len(all_swap_positions),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -4669,6 +4993,94 @@ def api_key_kline():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/order/<int:order_id>/cancel_tpsl", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_order_cancel_tpsl(order_id):
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
role = (data.get("role") or "").strip().lower()
|
||||||
|
if role not in ("sl", "tp"):
|
||||||
|
return jsonify({"ok": False, "msg": "role 须为 sl 或 tp"}), 400
|
||||||
|
conn = get_db()
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM order_monitors WHERE id=? AND status='active'",
|
||||||
|
(order_id,),
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
return jsonify({"ok": False, "msg": "持仓不存在或已结束"}), 404
|
||||||
|
ok, reason = ensure_okx_live_ready()
|
||||||
|
if not ok:
|
||||||
|
return jsonify({"ok": False, "msg": reason}), 400
|
||||||
|
ex_sym = resolve_monitor_exchange_symbol(row)
|
||||||
|
slots = fetch_exchange_tpsl_slots(ex_sym, row["direction"], plan_sl=row["stop_loss"], plan_tp=row["take_profit"])
|
||||||
|
slot = slots.get(role)
|
||||||
|
if not slot:
|
||||||
|
return jsonify({"ok": False, "msg": f"交易所未找到{'止损' if role == 'sl' else '止盈'}委托"}), 404
|
||||||
|
try:
|
||||||
|
cancel_okx_tpsl_slot(ex_sym, slot)
|
||||||
|
return jsonify({"ok": True, "msg": "已撤单", "exchange_tpsl": fetch_exchange_tpsl_slots(ex_sym, row["direction"], plan_sl=row["stop_loss"], plan_tp=row["take_profit"])})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"ok": False, "msg": friendly_exchange_error(e)}), 400
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/order/<int:order_id>/place_tpsl", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_order_place_tpsl(order_id):
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
conn = get_db()
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM order_monitors WHERE id=? AND status='active'",
|
||||||
|
(order_id,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({"ok": False, "msg": "持仓不存在或已结束"}), 404
|
||||||
|
symbol = row["symbol"]
|
||||||
|
direction = row["direction"]
|
||||||
|
live_price = get_price(symbol)
|
||||||
|
if live_price is None:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({"ok": False, "msg": "获取交易所实时价格失败"}), 400
|
||||||
|
try:
|
||||||
|
sltp_mode = (data.get("sltp_mode") or "price").strip().lower()
|
||||||
|
stop_loss, take_profit = _resolve_tpsl_prices_for_manual(direction, live_price, sltp_mode, data)
|
||||||
|
except Exception as e:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({"ok": False, "msg": str(e)}), 400
|
||||||
|
planned_rr = calc_rr_ratio(direction, live_price, stop_loss, take_profit)
|
||||||
|
if planned_rr is None or planned_rr < MANUAL_MIN_PLANNED_RR:
|
||||||
|
conn.close()
|
||||||
|
rr_txt = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算"
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"msg": f"计划盈亏比 {rr_txt}:1 低于最低要求 {MANUAL_MIN_PLANNED_RR}:1",
|
||||||
|
}
|
||||||
|
), 400
|
||||||
|
try:
|
||||||
|
replace_active_monitor_tpsl_on_exchange(row, stop_loss, take_profit)
|
||||||
|
except Exception as e:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({"ok": False, "msg": friendly_exchange_error(e)}), 400
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE order_monitors SET stop_loss=?, take_profit=? WHERE id=?",
|
||||||
|
(stop_loss, take_profit, order_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
ex_sym = resolve_monitor_exchange_symbol(row)
|
||||||
|
slots = fetch_exchange_tpsl_slots(ex_sym, direction, plan_sl=stop_loss, plan_tp=take_profit)
|
||||||
|
conn.close()
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"msg": "已先撤后挂止盈止损",
|
||||||
|
"stop_loss": stop_loss,
|
||||||
|
"take_profit": take_profit,
|
||||||
|
"planned_rr": planned_rr,
|
||||||
|
"exchange_tpsl": slots,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
@app.route("/add_key", methods=["POST"])
|
@app.route("/add_key", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def add_key():
|
def add_key():
|
||||||
@@ -4676,11 +5088,11 @@ def add_key():
|
|||||||
symbol = normalize_symbol_input(d.get("symbol"))
|
symbol = normalize_symbol_input(d.get("symbol"))
|
||||||
if not symbol:
|
if not symbol:
|
||||||
flash("symbol 不能为空")
|
flash("symbol 不能为空")
|
||||||
return redirect("/")
|
return redirect("/key_monitor")
|
||||||
direction_sel = (d.get("direction") or "").strip().lower()
|
direction_sel = (d.get("direction") or "").strip().lower()
|
||||||
if direction_sel not in ("long", "short"):
|
if direction_sel not in ("long", "short"):
|
||||||
flash("请选择做多或做空")
|
flash("请选择做多或做空")
|
||||||
return redirect("/")
|
return redirect("/key_monitor")
|
||||||
mt = (d.get("type") or "").strip()
|
mt = (d.get("type") or "").strip()
|
||||||
allowed_types = tuple(KEY_MONITOR_AUTO_TYPES) + tuple(KEY_MONITOR_ALERT_ONLY_TYPES) + tuple(FIB_KEY_MONITOR_TYPES)
|
allowed_types = tuple(KEY_MONITOR_AUTO_TYPES) + tuple(KEY_MONITOR_ALERT_ONLY_TYPES) + tuple(FIB_KEY_MONITOR_TYPES)
|
||||||
if mt not in allowed_types:
|
if mt not in allowed_types:
|
||||||
@@ -4695,9 +5107,13 @@ def add_key():
|
|||||||
return redirect("/")
|
return redirect("/")
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
if mt in KEY_MONITOR_AUTO_TYPES:
|
if mt in KEY_MONITOR_AUTO_TYPES:
|
||||||
if get_active_position_count(conn) > 0:
|
occupied = get_active_position_count(conn)
|
||||||
|
if occupied >= MAX_ACTIVE_POSITIONS:
|
||||||
conn.close()
|
conn.close()
|
||||||
flash("当前已有持仓:无法添加「箱体突破 / 收敛突破」(请先平仓或使用阻力/支撑/斐波类型)")
|
flash(
|
||||||
|
f"当前持仓已达上限({occupied}/{MAX_ACTIVE_POSITIONS}):无法添加「箱体突破 / 收敛突破」。"
|
||||||
|
"请先平仓或使用阻力/支撑/斐波类型"
|
||||||
|
)
|
||||||
return redirect("/")
|
return redirect("/")
|
||||||
ex_sym_key = normalize_okx_symbol(symbol)
|
ex_sym_key = normalize_okx_symbol(symbol)
|
||||||
try:
|
try:
|
||||||
@@ -4759,7 +5175,7 @@ def add_key():
|
|||||||
if mt in KEY_MONITOR_AUTO_TYPES:
|
if mt in KEY_MONITOR_AUTO_TYPES:
|
||||||
extra = f"|方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'开' if be_flag else '关'}"
|
extra = f"|方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'开' if be_flag else '关'}"
|
||||||
flash(f"添加成功({symbol} 日成交量排名 {rank}/{total}){extra}")
|
flash(f"添加成功({symbol} 日成交量排名 {rank}/{total}){extra}")
|
||||||
return redirect("/")
|
return redirect("/key_monitor")
|
||||||
|
|
||||||
@app.route("/add_order", methods=["POST"])
|
@app.route("/add_order", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
@@ -4772,10 +5188,10 @@ def add_order():
|
|||||||
if not symbol:
|
if not symbol:
|
||||||
conn.close()
|
conn.close()
|
||||||
flash("symbol 不能为空")
|
flash("symbol 不能为空")
|
||||||
return redirect("/")
|
return redirect("/trade")
|
||||||
ok, reason = precheck_risk(conn, symbol, direction)
|
ok, reason = precheck_risk(conn, symbol, direction)
|
||||||
if not ok:
|
if not ok:
|
||||||
if "一次只能持有一个仓位" in reason:
|
if "已达最大持仓数" in reason or "一次只能持有一个仓位" in reason:
|
||||||
try:
|
try:
|
||||||
tp_raw = parse_positive_float(d.get("tp"))
|
tp_raw = parse_positive_float(d.get("tp"))
|
||||||
sl_raw = parse_positive_float(d.get("sl"))
|
sl_raw = parse_positive_float(d.get("sl"))
|
||||||
@@ -4798,12 +5214,12 @@ def add_order():
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
flash(f"风控拒绝下单:{reason}")
|
flash(f"风控拒绝下单:{reason}")
|
||||||
return redirect("/")
|
return redirect("/trade")
|
||||||
ok_live, reason_live = ensure_okx_live_ready()
|
ok_live, reason_live = ensure_okx_live_ready()
|
||||||
if not ok_live:
|
if not ok_live:
|
||||||
conn.close()
|
conn.close()
|
||||||
flash(f"风控拒绝下单:{reason_live}")
|
flash(f"风控拒绝下单:{reason_live}")
|
||||||
return redirect("/")
|
return redirect("/trade")
|
||||||
exchange_symbol = normalize_okx_symbol(symbol)
|
exchange_symbol = normalize_okx_symbol(symbol)
|
||||||
default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol)
|
default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol)
|
||||||
try:
|
try:
|
||||||
@@ -4812,11 +5228,11 @@ def add_order():
|
|||||||
except Exception:
|
except Exception:
|
||||||
conn.close()
|
conn.close()
|
||||||
flash("杠杆参数格式错误")
|
flash("杠杆参数格式错误")
|
||||||
return redirect("/")
|
return redirect("/trade")
|
||||||
if leverage <= 0:
|
if leverage <= 0:
|
||||||
conn.close()
|
conn.close()
|
||||||
flash("杠杆必须大于0")
|
flash("杠杆必须大于0")
|
||||||
return redirect("/")
|
return redirect("/trade")
|
||||||
|
|
||||||
trading_day = get_trading_day(now)
|
trading_day = get_trading_day(now)
|
||||||
opens_today_before = conn.execute(
|
opens_today_before = conn.execute(
|
||||||
@@ -4834,7 +5250,7 @@ def add_order():
|
|||||||
if live_price is None:
|
if live_price is None:
|
||||||
conn.close()
|
conn.close()
|
||||||
flash("获取交易所实时价格失败,请稍后重试")
|
flash("获取交易所实时价格失败,请稍后重试")
|
||||||
return redirect("/")
|
return redirect("/trade")
|
||||||
sltp_mode = (d.get("sltp_mode") or "price").strip().lower()
|
sltp_mode = (d.get("sltp_mode") or "price").strip().lower()
|
||||||
if sltp_mode not in ("price", "pct"):
|
if sltp_mode not in ("price", "pct"):
|
||||||
sltp_mode = "price"
|
sltp_mode = "price"
|
||||||
@@ -4855,7 +5271,7 @@ def add_order():
|
|||||||
except Exception:
|
except Exception:
|
||||||
conn.close()
|
conn.close()
|
||||||
flash("百分比止盈止损参数错误,请填写正数百分比")
|
flash("百分比止盈止损参数错误,请填写正数百分比")
|
||||||
return redirect("/")
|
return redirect("/trade")
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
stop_loss = float(d["sl"])
|
stop_loss = float(d["sl"])
|
||||||
@@ -4863,16 +5279,22 @@ def add_order():
|
|||||||
except Exception:
|
except Exception:
|
||||||
conn.close()
|
conn.close()
|
||||||
flash("价格参数格式错误")
|
flash("价格参数格式错误")
|
||||||
return redirect("/")
|
return redirect("/trade")
|
||||||
if stop_loss <= 0 or take_profit <= 0:
|
if stop_loss <= 0 or take_profit <= 0:
|
||||||
conn.close()
|
conn.close()
|
||||||
flash("价格参数必须大于0")
|
flash("价格参数必须大于0")
|
||||||
return redirect("/")
|
return redirect("/trade")
|
||||||
|
planned_rr_manual = calc_rr_ratio(direction, live_price, stop_loss, take_profit)
|
||||||
|
if planned_rr_manual is None or planned_rr_manual < MANUAL_MIN_PLANNED_RR:
|
||||||
|
conn.close()
|
||||||
|
rr_txt = f"{planned_rr_manual:.4f}" if planned_rr_manual is not None else "无法计算"
|
||||||
|
flash(f"风控拒绝下单:计划盈亏比 {rr_txt}:1 低于最低要求 {MANUAL_MIN_PLANNED_RR}:1")
|
||||||
|
return redirect("/trade")
|
||||||
risk_fraction = calc_risk_fraction(direction, live_price, stop_loss)
|
risk_fraction = calc_risk_fraction(direction, live_price, stop_loss)
|
||||||
if risk_fraction is None:
|
if risk_fraction is None:
|
||||||
conn.close()
|
conn.close()
|
||||||
flash("止损方向不合法:请检查入场方向与止损价格关系")
|
flash("止损方向不合法:请检查入场方向与止损价格关系")
|
||||||
return redirect("/")
|
return redirect("/trade")
|
||||||
risk_percent = max(0.01, float(RISK_PERCENT))
|
risk_percent = max(0.01, float(RISK_PERCENT))
|
||||||
risk_amount = round(capital_base * risk_percent / 100.0, 4)
|
risk_amount = round(capital_base * risk_percent / 100.0, 4)
|
||||||
notional_value = round(risk_amount / risk_fraction, 4)
|
notional_value = round(risk_amount / risk_fraction, 4)
|
||||||
@@ -4880,13 +5302,13 @@ def add_order():
|
|||||||
if capital_base and margin_capital > capital_base:
|
if capital_base and margin_capital > capital_base:
|
||||||
conn.close()
|
conn.close()
|
||||||
flash("以损定仓后保证金超过当前交易资金,请放宽止损或降低风险比例")
|
flash("以损定仓后保证金超过当前交易资金,请放宽止损或降低风险比例")
|
||||||
return redirect("/")
|
return redirect("/trade")
|
||||||
if available_usdt is not None:
|
if available_usdt is not None:
|
||||||
max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4)
|
max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4)
|
||||||
if margin_capital > max_margin:
|
if margin_capital > max_margin:
|
||||||
conn.close()
|
conn.close()
|
||||||
flash(f"保证金不足:交易账户可用约 {round(available_usdt,4)}U,当前最多建议 {max_margin}U")
|
flash(f"保证金不足:交易账户可用约 {round(available_usdt,4)}U,当前最多建议 {max_margin}U")
|
||||||
return redirect("/")
|
return redirect("/trade")
|
||||||
position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0
|
position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0
|
||||||
try:
|
try:
|
||||||
amount, quote_price = prepare_order_amount(exchange_symbol, margin_capital, leverage, live_price)
|
amount, quote_price = prepare_order_amount(exchange_symbol, margin_capital, leverage, live_price)
|
||||||
@@ -4899,7 +5321,7 @@ def add_order():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
conn.close()
|
conn.close()
|
||||||
flash(friendly_okx_error(e, available_usdt=available_usdt))
|
flash(friendly_okx_error(e, available_usdt=available_usdt))
|
||||||
return redirect("/")
|
return redirect("/trade")
|
||||||
|
|
||||||
make_order_chart = d.get("order_chart", "").lower() in ("1", "true", "on", "yes")
|
make_order_chart = d.get("order_chart", "").lower() in ("1", "true", "on", "yes")
|
||||||
opened_at_bj = app_now_str()
|
opened_at_bj = app_now_str()
|
||||||
@@ -5060,7 +5482,7 @@ def add_order():
|
|||||||
if advice:
|
if advice:
|
||||||
send_wechat_msg(f"【AI提醒】今日开仓次数已达 {opens_today_after}\n{advice[:800]}")
|
send_wechat_msg(f"【AI提醒】今日开仓次数已达 {opens_today_after}\n{advice[:800]}")
|
||||||
flash(f"【AI提醒】今日开仓次数已达 {opens_today_after}:{advice[:300]}")
|
flash(f"【AI提醒】今日开仓次数已达 {opens_today_after}:{advice[:300]}")
|
||||||
return redirect("/")
|
return redirect("/trade")
|
||||||
|
|
||||||
@app.route("/delete_key_monitor/<int:kid>", methods=["POST"])
|
@app.route("/delete_key_monitor/<int:kid>", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
@@ -5920,10 +6342,11 @@ def _hub_meta_bundle():
|
|||||||
"key_gate_rule_text": (
|
"key_gate_rule_text": (
|
||||||
f"周期 {KLINE_TIMEFRAME}|量能/突破/二确门控见箱体与收敛规则|"
|
f"周期 {KLINE_TIMEFRAME}|量能/突破/二确门控见箱体与收敛规则|"
|
||||||
f"自动开仓盈亏比 > {KEY_AUTO_MIN_PLANNED_RR}:1|日成交量排名前 {KEY_DAILY_VOLUME_RANK_MAX}|"
|
f"自动开仓盈亏比 > {KEY_AUTO_MIN_PLANNED_RR}:1|日成交量排名前 {KEY_DAILY_VOLUME_RANK_MAX}|"
|
||||||
f"斐波:添加后立即挂限价 @ E,失效按标记价触达 H/L(未成交撤单)"
|
f"箱体/收敛可选 SL/TP 方案(标准 / 箱体1R·止盈1.5H / 趋势单+自填止盈)|移动保本默认关|"
|
||||||
|
f"斐波:限价 @ E(SL/TP 为 H/L),可选移动保本|趋势止损外侧 {KEY_TREND_STOP_OUTSIDE_PCT}%"
|
||||||
),
|
),
|
||||||
"manual_min_planned_rr": float(os.getenv("MANUAL_MIN_PLANNED_RR", "1.4")),
|
"manual_min_planned_rr": MANUAL_MIN_PLANNED_RR,
|
||||||
"max_active_positions": max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", "1"))),
|
"max_active_positions": MAX_ACTIVE_POSITIONS,
|
||||||
"btc_leverage": BTC_LEVERAGE,
|
"btc_leverage": BTC_LEVERAGE,
|
||||||
"alt_leverage": ALT_LEVERAGE,
|
"alt_leverage": ALT_LEVERAGE,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,34 @@
|
|||||||
/**
|
/**
|
||||||
* PM2 进程定义(Ubuntu / Linux)。
|
* PM2 进程定义(Ubuntu / Linux)。
|
||||||
*
|
*
|
||||||
* 仅托管 Flask 应用。**SSH SOCKS 隧道请在本机用 screen/tmux/systemd 等方式单独常驻**,
|
* 仅托管 Flask 应用。**SSH SOCKS 隧道请在本机用 screen/tmux/systemd 等方式单独常驻**,
|
||||||
* 与 `.env` 里 `GATE_SOCKS_PROXY` 端口一致即可;不必交给 PM2。
|
* 与 `.env` 里 `OKX_SOCKS_PROXY` 端口一致即可;不必交给 PM2。
|
||||||
*
|
*
|
||||||
* 使用前:项目根目录存在 `.venv`,且已安装依赖(走 SOCKS 时需 PySocks)。
|
* 使用前:项目根目录存在 `.venv`,且已安装依赖(走 SOCKS 时需 PySocks)。
|
||||||
*
|
*
|
||||||
* 启动:
|
* 启动:
|
||||||
* pm2 start ecosystem.config.cjs
|
* pm2 start ecosystem.config.cjs
|
||||||
* 保存开机列表:
|
* 保存开机列表:
|
||||||
* pm2 save && pm2 startup
|
* pm2 save && pm2 startup
|
||||||
*/
|
*/
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
|
||||||
const ROOT = __dirname;
|
const ROOT = __dirname;
|
||||||
const REPO_ROOT = path.join(ROOT, "..");
|
const REPO_ROOT = path.join(ROOT, "..");
|
||||||
const PY = path.join(ROOT, ".venv", "bin", "python");
|
const PY = path.join(ROOT, ".venv", "bin", "python");
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
apps: [
|
apps: [
|
||||||
{
|
{
|
||||||
name: "crypto_okx",
|
name: "crypto_okx",
|
||||||
cwd: ROOT,
|
cwd: ROOT,
|
||||||
script: path.join(ROOT, "app.py"),
|
script: path.join(ROOT, "app.py"),
|
||||||
interpreter: PY,
|
interpreter: PY,
|
||||||
instances: 1,
|
instances: 1,
|
||||||
autorestart: true,
|
autorestart: true,
|
||||||
watch: false,
|
watch: false,
|
||||||
max_memory_restart: "800M",
|
max_memory_restart: "800M",
|
||||||
env: { PYTHONPATH: REPO_ROOT },
|
env: { PYTHONPATH: REPO_ROOT },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,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,52 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
python scripts/verify_okx_funding.py
|
||||||
|
|
||||||
|
打印 OKX_API_KEY 前 8 位便于与 Binance 控制台核对(不含 Secret)。用于服务器自检。
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
sys.path.insert(0, BASE)
|
||||||
|
|
||||||
|
|
||||||
|
def load_env(path):
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return
|
||||||
|
for line in open(path, "r", encoding="utf-8", errors="ignore"):
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
k, v = line.split("=", 1)
|
||||||
|
k = k.strip().lstrip("\ufeff")
|
||||||
|
if k.replace("_", "").isalnum():
|
||||||
|
os.environ[k] = v.strip().strip('"').strip("'")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
load_env(os.path.join(BASE, ".env"))
|
||||||
|
k = (os.getenv("OKX_API_KEY") or "").strip()
|
||||||
|
s = (os.getenv("OKX_API_SECRET") or "").strip()
|
||||||
|
if not k or "REPLACE" in k.upper():
|
||||||
|
print("WARN: OKX_API_KEY 为空或仍像占位符,请核对 .env")
|
||||||
|
if not s or "REPLACE" in s.upper():
|
||||||
|
print("WARN: OKX_API_SECRET 为空或仍像占位符,请核对 .env")
|
||||||
|
print("OKX_API_KEY prefix (8 chars):", (k[:8] + "…") if len(k) > 8 else "(short)")
|
||||||
|
|
||||||
|
import app as mod # noqa: E402
|
||||||
|
|
||||||
|
mod.ensure_markets_loaded()
|
||||||
|
fu = mod._fetch_okx_funding_usdt()
|
||||||
|
print(">>> _fetch_okx_funding_usdt() =", fu)
|
||||||
|
try:
|
||||||
|
sw = mod._fetch_okx_swap_usdt_total()
|
||||||
|
print(">>> _fetch_okx_swap_usdt_total() (合约账户) =", sw)
|
||||||
|
sf = mod._fetch_okx_swap_usdt_free()
|
||||||
|
print(">>> _fetch_okx_swap_usdt_free() (合约可用) =", sf)
|
||||||
|
except Exception as e:
|
||||||
|
print(">>> swap balance fetch error:", e)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
File diff suppressed because it is too large
Load Diff
+136
-57
@@ -1,57 +1,136 @@
|
|||||||
# 使用说明
|
# 使用说明
|
||||||
|
|
||||||
**本文件对应仓库:`crypto_monitor_okx`(OKX USDT 永续)。**
|
**本文件对应仓库:`crypto_monitor_okx`(OKX USDT 本位永续)。**
|
||||||
界面与 Binance / Gate 主站版基本一致,差异在 **`.env` 的 `OKX_*` 变量** 与 OKX 合约 API(含 Passphrase)。
|
功能、界面与 **Gate.io USDT 永续版**(目录 `crypto_monitor_gate`)基本一致,差异主要在 **`.env` 里交易所密钥与部分参数名**(`OKX_*` / `GATE_*`),文末有对照。
|
||||||
|
|
||||||
**部署(SSH SOCKS、PM2、备份)** 见同目录 **[部署文档.md](./部署文档.md)**。
|
**部署、代理、PM2 等**请参考本仓库说明或 **`crypto_monitor_gate`** 下的 **`部署文档.md`**(该文以 Gate + SSH SOCKS 为例;OKX 侧将 API 与密钥改为 `OKX_*` 即可类比)。
|
||||||
**策略交易、AI 复盘** 为四所共用根目录逻辑,见 **[策略交易说明.md](../策略交易说明.md)**、**[AI复盘与模型配置说明.md](../AI复盘与模型配置说明.md)**。
|
**关键位自动开仓的规则、RR、结案原因**见本目录 **`关键位自动下单说明.md`**。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 它能做什么
|
## 1. 它能做什么
|
||||||
|
|
||||||
| 模块 | 说明 |
|
面向个人盘面的 **Web 控制台**,主要能力包括:
|
||||||
|------|------|
|
|
||||||
| **关键位监控** | 5m 收线门控、企业微信推送;部分类型可自动市价开仓。 |
|
| 模块 | 说明 |
|
||||||
| **实盘下单监控** | 以损定仓、条件止盈止损、移动保本等。 |
|
|------|------|
|
||||||
| **策略交易** | 顶栏 **`/strategy`**:左 **趋势回调**、右 **顺势加仓**(双栏并列)。 |
|
| **关键位监控** | 录入上/下沿与类型,按 **5m 收线** 做硬条件过滤;符合条件后 **企业微信** 提醒,部分类型可 **自动市价开仓**(见第 4 节与专门文档)。 |
|
||||||
| **交易记录 / 复盘** | 归档、导出;可选 **AI 复盘**(`.env` 中 `AI_PROVIDER` 等)。 |
|
| **实盘下单监控** | 手工填止损/止盈,**以损定仓** 市价开单,挂上条件止盈止损,并在页面跟踪浮盈亏、保本逻辑等。 |
|
||||||
| **统计分析** | 按北京时间切日的统计(与顶栏 UTC 列表窗无关)。 |
|
| **交易记录 / 复盘** | 平仓结果、盈亏、错过的单等归档与导出;可选 **AI 复盘**(见仓库根 [AI复盘与模型配置说明.md](../AI复盘与模型配置说明.md))。 |
|
||||||
|
| **策略交易** | 顶栏 `/strategy`:**趋势回调**(左)与 **顺势加仓**(右)左右并列;细则见 [策略交易说明.md](../策略交易说明.md)。 |
|
||||||
---
|
|
||||||
|
后台按 **`MONITOR_POLL_SECONDS`**(默认几秒)轮询行情与监控逻辑。**切勿**在未理解规则时同时运行两套程序共用一个实盘账户。
|
||||||
## 2. 运行前必须配置(`.env`)
|
|
||||||
|
---
|
||||||
```bash
|
|
||||||
cp -n .env.example .env
|
## 2. 运行前必须配置(`.env`)
|
||||||
nano .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` |
|
| 类别 | 说明 |
|
||||||
| **OKX API** | `OKX_API_KEY`、`OKX_API_SECRET`、`OKX_API_PASSPHRASE` |
|
|------|------|
|
||||||
| **代理** | 本机 SSH SOCKS 时常用 `OKX_SOCKS_PROXY=socks5h://127.0.0.1:1080` |
|
| **登录网页** | `APP_PASSWORD`:打开站点后的登录口令。`FLASK_SECRET_KEY`:Session 密钥,请勿使用默认值。 |
|
||||||
| **实盘开关** | `LIVE_TRADING_ENABLED=false` 仅本地逻辑,不测真下单 |
|
| **企业微信** | `WECHAT_WEBHOOK`:告警与关键位推送机器人的 Webhook。 |
|
||||||
| **AI 复盘** | 默认 `AI_PROVIDER=openai`,`OPENAI_API_BASE=https://op.bz121.com/v1`、`OPENAI_API_KEY`、`OPENAI_MODEL=gemma4:e4b`;或 `ollama` + `OLLAMA_API` / `AI_MODEL` |
|
| **是否真下单** | `LIVE_TRADING_ENABLED=false`:**不会**向交易所发送开仓指令(适合测试流程)。改为 `true` 且密钥正确才会实盘。 |
|
||||||
|
| **交易所 API** | **本仓库:** `OKX_API_KEY`、`OKX_API_SECRET`;永续相关见 `OKX_TD_MODE`、`OKX_POS_MODE`、`OKX_TRIGGER_WORKING_TYPE` 等。**勿**把 `.env` 提交到 Git。 |
|
||||||
---
|
| **关键位 RR / 止损外扩** | `KEY_AUTO_MIN_PLANNED_RR`、`KEY_STOP_OUTSIDE_BREAKOUT_PCT`(详见 `关键位自动下单说明.md`)。 |
|
||||||
|
| **AI 复盘** | 默认 `AI_PROVIDER=openai`,`OPENAI_API_BASE=https://op.bz121.com/v1`、`OPENAI_API_KEY`、`OPENAI_MODEL=gemma4:e4b`;或 `AI_PROVIDER=ollama` + `OLLAMA_API` / `AI_MODEL`。详见 [AI复盘与模型配置说明.md](../AI复盘与模型配置说明.md)。 |
|
||||||
## 3. 启动与顶栏
|
|
||||||
|
网络需要代理时可配置 **`OKX_SOCKS_PROXY` / `OKX_HTTP_PROXY`**(与 Gate 版 `GATE_*_PROXY` 用法类似)。
|
||||||
1. 按 **部署文档** 建 venv、装依赖、配 SOCKS(如需)。
|
|
||||||
2. `python app.py` 或 PM2 `ecosystem.config.cjs`(须 **`PYTHONPATH=..`**)。
|
---
|
||||||
3. 浏览器登录后顶栏:**关键位监控** | **实盘下单** | **策略交易** | **交易记录与复盘** | **统计分析**。
|
|
||||||
|
## 3. 如何启动与登录
|
||||||
旧链接 `/strategy/trend`、`/strategy/roll` 会重定向到 **`/strategy`**。
|
|
||||||
|
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. 相关文档
|
|
||||||
|
登录后顶栏:**关键位监控** | **实盘下单**(默认首页)| **策略交易**(`/strategy`,趋势回调 + 顺势加仓双栏)| **交易记录与复盘** | **统计分析**。
|
||||||
| 文档 | 内容 |
|
|
||||||
|------|------|
|
---
|
||||||
| [部署文档.md](./部署文档.md) | Ubuntu、PM2、`.env`、备份 |
|
|
||||||
| [策略交易说明.md](../策略交易说明.md) | 趋势回调 + 顺势加仓 |
|
## 4. 关键位监控(顶栏「关键位监控」→ `/key_monitor`)
|
||||||
| [AI复盘与模型配置说明.md](../AI复盘与模型配置说明.md) | OpenAI 网关 / Ollama |
|
|
||||||
| [crypto_monitor_binance/使用说明.md](../crypto_monitor_binance/使用说明.md) | 关键位细则(流程与 OKX 版对齐) |
|
### 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. 与币安版(`crypto_monitor_binance`)差异速查
|
||||||
|
|
||||||
|
| 项目 | OKX 本仓库 | 币安版 |
|
||||||
|
|------|------------|--------|
|
||||||
|
| API 变量 | `OKX_API_KEY`、`OKX_API_SECRET`、`OKX_API_PASSPHRASE` | `BINANCE_API_KEY`、`BINANCE_API_SECRET` |
|
||||||
|
| 代理 | `OKX_SOCKS_PROXY` | `BINANCE_SOCKS_PROXY` |
|
||||||
|
| 默认端口 | 常为 `5004` | 常为 `5001` |
|
||||||
|
| TP/SL 实现 | `_okx_place_tp_sl_orders`、页面 `/api/order/.../cancel_tpsl` | `_binance_place_tp_sl_orders` |
|
||||||
|
|
||||||
|
业务流程、顶栏分栏、策略交易、风控参数名已与币安版对齐;仅需更换目录与 `.env`。
|
||||||
|
|||||||
@@ -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` |
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
| id | 名称 | Flask | Agent | 监控能力(设置页勾选) | 默认启用 |
|
| id | 名称 | Flask | Agent | 监控能力(设置页勾选) | 默认启用 |
|
||||||
|----|------|-------|-------|------------------------|----------|
|
|----|------|-------|-------|------------------------|----------|
|
||||||
| 0 | 币安 | :5001 | :15200 | 关键位 | 是 |
|
| 0 | 币安 | :5001 | :15200 | 关键位 | 是 |
|
||||||
| 1 | OKX | :5004 | :15201 | 关键位 | **否**(`HUB_DISABLED_IDS=1`) |
|
| 1 | OKX | :5004 | :15201 | 关键位 + 趋势计划(建议) | **否**(`HUB_DISABLED_IDS=1`,需用时在设置页启用) |
|
||||||
| 2 | Gate 训练 | :5000 | :15202 | 关键位 | 是 |
|
| 2 | Gate 训练 | :5000 | :15202 | 关键位 | 是 |
|
||||||
| 3 | Gate 趋势 | :5002 | :15203 | 趋势计划(默认不勾关键位) | 是 |
|
| 3 | Gate 趋势 | :5002 | :15203 | 趋势计划(默认不勾关键位) | 是 |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,566 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""One-shot: align crypto_monitor_okx with binance/gate patterns (OKX_* prefixes)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
OKX = ROOT / "crypto_monitor_okx"
|
||||||
|
BIN = ROOT / "crypto_monitor_binance"
|
||||||
|
GATE = ROOT / "crypto_monitor_gate"
|
||||||
|
|
||||||
|
|
||||||
|
def patch_app():
|
||||||
|
app_path = OKX / "app.py"
|
||||||
|
text = app_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
if "EXCHANGE_DISPLAY_NAME" not in text.split("OKX_POS_MODE")[0]:
|
||||||
|
text = text.replace(
|
||||||
|
'OKX_POS_MODE = os.getenv("OKX_POS_MODE", "hedge")\n',
|
||||||
|
'OKX_POS_MODE = os.getenv("OKX_POS_MODE", "hedge")\n'
|
||||||
|
'EXCHANGE_DISPLAY_NAME = (os.getenv("EXCHANGE_DISPLAY_NAME") or "OKX").strip() or "OKX"\n',
|
||||||
|
)
|
||||||
|
|
||||||
|
if "TRADING_DAY_RESET_OPEN_GUARD_ENABLED" not in text:
|
||||||
|
text = text.replace(
|
||||||
|
"TRADING_DAY_RESET_HOUR = int(os.getenv(\"TRADING_DAY_RESET_HOUR\", \"8\"))\nAPP_TIMEZONE",
|
||||||
|
'TRADING_DAY_RESET_HOUR = int(os.getenv("TRADING_DAY_RESET_HOUR", "8"))\n'
|
||||||
|
"TRADING_DAY_RESET_OPEN_GUARD_ENABLED = os.getenv(\n"
|
||||||
|
' "TRADING_DAY_RESET_OPEN_GUARD_ENABLED", "true"\n'
|
||||||
|
').lower() in ("1", "true", "yes", "on")\n'
|
||||||
|
"APP_TIMEZONE",
|
||||||
|
)
|
||||||
|
|
||||||
|
extra_env = """
|
||||||
|
MANUAL_MIN_PLANNED_RR = float(os.getenv("MANUAL_MIN_PLANNED_RR", "1.4"))
|
||||||
|
MAX_ACTIVE_POSITIONS = max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", "1")))
|
||||||
|
KEY_VOLUME_MA_BARS = max(1, int(os.getenv("KEY_VOLUME_MA_BARS", "20")))
|
||||||
|
KEY_VOLUME_RATIO_MIN = float(os.getenv("KEY_VOLUME_RATIO_MIN", "1.3"))
|
||||||
|
KEY_BREAKOUT_AMP_MIN_PCT = float(os.getenv("KEY_BREAKOUT_AMP_MIN_PCT", "0.03"))
|
||||||
|
KEY_BREAKOUT_AMP_MAX_PCT = float(os.getenv("KEY_BREAKOUT_AMP_MAX_PCT", "0.5"))
|
||||||
|
KEY_CONFIRM_BREAKOUT_BAR = int(os.getenv("KEY_CONFIRM_BREAKOUT_BAR", "-2"))
|
||||||
|
KEY_CONFIRM_BAR = int(os.getenv("KEY_CONFIRM_BAR", "-1"))
|
||||||
|
"""
|
||||||
|
if "MANUAL_MIN_PLANNED_RR = float" not in text:
|
||||||
|
text = text.replace(
|
||||||
|
"KEY_DAILY_VOLUME_RANK_MAX = int(os.getenv(\"KEY_DAILY_VOLUME_RANK_MAX\", \"30\"))\n",
|
||||||
|
"KEY_DAILY_VOLUME_RANK_MAX = max(1, int(os.getenv(\"KEY_DAILY_VOLUME_RANK_MAX\", \"30\")))\n"
|
||||||
|
+ extra_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
if "def format_funds_u" not in text:
|
||||||
|
text = text.replace(
|
||||||
|
"def format_hold_minutes(minutes):",
|
||||||
|
'''FUNDS_DECIMALS = 2
|
||||||
|
|
||||||
|
|
||||||
|
def format_funds_u(value):
|
||||||
|
if value in (None, ""):
|
||||||
|
return "-"
|
||||||
|
try:
|
||||||
|
return f"{float(value):.{FUNDS_DECIMALS}f}"
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def format_hold_minutes(minutes):''',
|
||||||
|
)
|
||||||
|
|
||||||
|
if "def trading_day_reset_allows_new_open" not in text:
|
||||||
|
text = text.replace(
|
||||||
|
"def precheck_risk(conn, symbol, direction):",
|
||||||
|
'''def trading_day_reset_allows_new_open(now):
|
||||||
|
if not TRADING_DAY_RESET_OPEN_GUARD_ENABLED:
|
||||||
|
return True
|
||||||
|
return now.hour >= TRADING_DAY_RESET_HOUR
|
||||||
|
|
||||||
|
|
||||||
|
def precheck_risk(conn, symbol, direction):''',
|
||||||
|
)
|
||||||
|
|
||||||
|
text = re.sub(
|
||||||
|
r"def precheck_risk\(conn, symbol, direction\):.*?return True, \"\"",
|
||||||
|
'''def precheck_risk(conn, symbol, direction):
|
||||||
|
now = app_now()
|
||||||
|
if not trading_day_reset_allows_new_open(now):
|
||||||
|
return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓"
|
||||||
|
active_count = get_active_position_count(conn)
|
||||||
|
if active_count >= MAX_ACTIVE_POSITIONS:
|
||||||
|
return False, f"已达最大持仓数({active_count}/{MAX_ACTIVE_POSITIONS})"
|
||||||
|
if direction not in ("long", "short"):
|
||||||
|
return False, "方向必须为 long 或 short"
|
||||||
|
if symbol.upper().startswith("BTC") or symbol.upper().startswith("ETH"):
|
||||||
|
expected = BTC_LEVERAGE
|
||||||
|
else:
|
||||||
|
expected = ALT_LEVERAGE
|
||||||
|
if expected <= 0:
|
||||||
|
return False, "杠杆配置异常"
|
||||||
|
return True, ""''',
|
||||||
|
text,
|
||||||
|
count=1,
|
||||||
|
flags=re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
# _key_hard_checks from gate
|
||||||
|
gate_text = (GATE / "app.py").read_text(encoding="utf-8")
|
||||||
|
m = re.search(r"def _key_hard_checks\(symbol.*?return out\n", gate_text, re.DOTALL)
|
||||||
|
if m:
|
||||||
|
kh = m.group(0).replace("normalize_exchange_symbol", "normalize_okx_symbol")
|
||||||
|
text = re.sub(r"def _key_hard_checks\(symbol.*?return out\n", kh, text, count=1, flags=re.DOTALL)
|
||||||
|
|
||||||
|
if "def exchange_private_api_configured" not in text:
|
||||||
|
insert = '''
|
||||||
|
def exchange_private_api_configured():
|
||||||
|
return bool(OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE)
|
||||||
|
|
||||||
|
|
||||||
|
def _position_row_effective_contracts(p):
|
||||||
|
info = p.get("info", {}) or {}
|
||||||
|
contracts = p.get("contracts")
|
||||||
|
if contracts is None:
|
||||||
|
raw_pos = info.get("pos")
|
||||||
|
try:
|
||||||
|
contracts = abs(float(raw_pos)) if raw_pos is not None else 0.0
|
||||||
|
except Exception:
|
||||||
|
contracts = 0.0
|
||||||
|
try:
|
||||||
|
return float(contracts)
|
||||||
|
except Exception:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _position_matches_wanted_contract(exchange_symbol, position):
|
||||||
|
if not position:
|
||||||
|
return False
|
||||||
|
sym = position.get("symbol")
|
||||||
|
return sym == exchange_symbol
|
||||||
|
|
||||||
|
|
||||||
|
def _select_live_position_row(rows, exchange_symbol, direction, relax_hedge=False):
|
||||||
|
if not rows:
|
||||||
|
return None
|
||||||
|
candidates = []
|
||||||
|
for p in rows:
|
||||||
|
if not _position_matches_wanted_contract(exchange_symbol, p):
|
||||||
|
continue
|
||||||
|
info = p.get("info", {}) or {}
|
||||||
|
side = (p.get("side") or info.get("posSide") or "").lower()
|
||||||
|
contracts = _position_row_effective_contracts(p)
|
||||||
|
if contracts <= 0:
|
||||||
|
continue
|
||||||
|
if (not relax_hedge) and OKX_POS_MODE == "hedge":
|
||||||
|
if side and side != (direction or "").lower():
|
||||||
|
continue
|
||||||
|
candidates.append((contracts, p))
|
||||||
|
if not candidates and (not relax_hedge) and OKX_POS_MODE == "hedge":
|
||||||
|
return _select_live_position_row(rows, exchange_symbol, direction, relax_hedge=True)
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
candidates.sort(key=lambda x: x[0], reverse=True)
|
||||||
|
return candidates[0][1]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ccxt_position_metrics(position, order_leverage=None):
|
||||||
|
if not position:
|
||||||
|
return None
|
||||||
|
p = position
|
||||||
|
info = p.get("info", {}) or {}
|
||||||
|
initial = _coerce_float(p.get("collateral"), p.get("initialMargin"), p.get("margin"))
|
||||||
|
if initial is None or initial <= 0:
|
||||||
|
initial = _coerce_float(
|
||||||
|
info.get("margin"),
|
||||||
|
info.get("imr"),
|
||||||
|
info.get("initial_margin"),
|
||||||
|
)
|
||||||
|
notional = _coerce_float(p.get("notional"), p.get("notionalValue"))
|
||||||
|
if notional is None or notional <= 0:
|
||||||
|
notional = _coerce_float(info.get("notionalUsd"), info.get("notional"))
|
||||||
|
if notional is not None:
|
||||||
|
notional = abs(notional)
|
||||||
|
if (initial is None or initial <= 0) and notional and notional > 0 and order_leverage:
|
||||||
|
try:
|
||||||
|
lev = float(order_leverage)
|
||||||
|
if lev > 0:
|
||||||
|
approx = notional / lev
|
||||||
|
if approx > 0:
|
||||||
|
initial = approx
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
unrealized = _coerce_float(
|
||||||
|
p.get("unrealizedPnl"),
|
||||||
|
info.get("upl"),
|
||||||
|
info.get("unrealized_pnl"),
|
||||||
|
)
|
||||||
|
mark = _coerce_float(p.get("markPrice"), p.get("mark_price"), info.get("markPx"))
|
||||||
|
out = {}
|
||||||
|
if initial is not None and initial > 0:
|
||||||
|
out["initial_margin"] = round(initial, FUNDS_DECIMALS)
|
||||||
|
if notional is not None and notional > 0:
|
||||||
|
out["notional"] = round(notional, FUNDS_DECIMALS)
|
||||||
|
if unrealized is not None:
|
||||||
|
out["unrealized_pnl"] = round(unrealized, FUNDS_DECIMALS)
|
||||||
|
if mark is not None and mark > 0:
|
||||||
|
out["mark_price"] = round(mark, 8)
|
||||||
|
return out or None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_tpsl_prices_for_manual(direction, live_price, sltp_mode, data):
|
||||||
|
sltp_mode = (sltp_mode or "price").strip().lower()
|
||||||
|
if sltp_mode == "pct":
|
||||||
|
sl_pct = float(data.get("sl_pct") or 0)
|
||||||
|
tp_pct = float(data.get("tp_pct") or 0)
|
||||||
|
if sl_pct <= 0 or tp_pct <= 0:
|
||||||
|
raise ValueError("百分比止盈止损须为正数")
|
||||||
|
sl_ratio = sl_pct / 100.0
|
||||||
|
tp_ratio = tp_pct / 100.0
|
||||||
|
entry = float(live_price)
|
||||||
|
if direction == "short":
|
||||||
|
stop_loss = entry * (1 + sl_ratio)
|
||||||
|
take_profit = entry * (1 - tp_ratio)
|
||||||
|
else:
|
||||||
|
stop_loss = entry * (1 - sl_ratio)
|
||||||
|
take_profit = entry * (1 + tp_ratio)
|
||||||
|
else:
|
||||||
|
stop_loss = float(data.get("sl") or data.get("stop_loss") or 0)
|
||||||
|
take_profit = float(data.get("tp") or data.get("take_profit") or data.get("tgt") or 0)
|
||||||
|
if stop_loss <= 0 or take_profit <= 0:
|
||||||
|
raise ValueError("止盈止损价格须大于 0")
|
||||||
|
return stop_loss, take_profit
|
||||||
|
|
||||||
|
|
||||||
|
def _okx_tpsl_slot_from_order(order, exchange_symbol):
|
||||||
|
info = order.get("info") or {}
|
||||||
|
oid = order.get("id") or info.get("algoId") or info.get("ordId")
|
||||||
|
trig = _coerce_float(
|
||||||
|
info.get("slTriggerPx"),
|
||||||
|
info.get("tpTriggerPx"),
|
||||||
|
order.get("stopLossPrice"),
|
||||||
|
order.get("takeProfitPrice"),
|
||||||
|
)
|
||||||
|
if trig is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"order_id": str(oid) if oid is not None else None,
|
||||||
|
"trigger_price": float(trig),
|
||||||
|
"trigger_display": format_price_for_symbol(
|
||||||
|
exchange_symbol.replace(":USDT", "").replace("/USDT:USDT", ""),
|
||||||
|
trig,
|
||||||
|
),
|
||||||
|
"type": str(order.get("type") or info.get("ordType") or ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_exchange_tpsl_slots(exchange_symbol, direction, plan_sl=None, plan_tp=None):
|
||||||
|
slots = {"sl": None, "tp": None}
|
||||||
|
if not exchange_symbol:
|
||||||
|
return slots
|
||||||
|
ok, _ = ensure_okx_live_ready()
|
||||||
|
if not ok:
|
||||||
|
return slots
|
||||||
|
try:
|
||||||
|
ensure_markets_loaded()
|
||||||
|
ambiguous = []
|
||||||
|
for order in exchange.fetch_open_orders(exchange_symbol) or []:
|
||||||
|
slot = _okx_tpsl_slot_from_order(order, exchange_symbol)
|
||||||
|
if not slot or not slot.get("order_id"):
|
||||||
|
continue
|
||||||
|
trig = slot.get("trigger_price")
|
||||||
|
if plan_sl is not None and plan_tp is not None:
|
||||||
|
try:
|
||||||
|
role = "sl" if abs(trig - float(plan_sl)) <= abs(trig - float(plan_tp)) else "tp"
|
||||||
|
except Exception:
|
||||||
|
role = None
|
||||||
|
elif plan_sl is not None:
|
||||||
|
role = "sl"
|
||||||
|
elif plan_tp is not None:
|
||||||
|
role = "tp"
|
||||||
|
else:
|
||||||
|
ambiguous.append(slot)
|
||||||
|
continue
|
||||||
|
if role in ("sl", "tp") and slots[role] is None:
|
||||||
|
slots[role] = slot
|
||||||
|
for slot in ambiguous:
|
||||||
|
trig = slot.get("trigger_price")
|
||||||
|
if trig is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
plan_sl_f = float(plan_sl) if plan_sl is not None else None
|
||||||
|
plan_tp_f = float(plan_tp) if plan_tp is not None else None
|
||||||
|
except Exception:
|
||||||
|
plan_sl_f = plan_tp_f = None
|
||||||
|
if plan_sl_f is not None and plan_tp_f is not None:
|
||||||
|
role = "sl" if abs(trig - plan_sl_f) <= abs(trig - plan_tp_f) else "tp"
|
||||||
|
elif plan_sl_f is not None:
|
||||||
|
role = "sl"
|
||||||
|
elif plan_tp_f is not None:
|
||||||
|
role = "tp"
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
if slots[role] is None:
|
||||||
|
slots[role] = slot
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return slots
|
||||||
|
|
||||||
|
|
||||||
|
def cancel_okx_tpsl_slot(exchange_symbol, slot):
|
||||||
|
if not slot or not exchange_symbol:
|
||||||
|
return
|
||||||
|
oid = slot.get("order_id")
|
||||||
|
if not oid:
|
||||||
|
return
|
||||||
|
ensure_markets_loaded()
|
||||||
|
exchange.cancel_order(str(oid), exchange_symbol)
|
||||||
|
|
||||||
|
|
||||||
|
'''
|
||||||
|
text = text.replace(
|
||||||
|
"def replace_active_monitor_tpsl_on_exchange(order_row, stop_loss, take_profit):",
|
||||||
|
insert + "def replace_active_monitor_tpsl_on_exchange(order_row, stop_loss, take_profit):",
|
||||||
|
)
|
||||||
|
|
||||||
|
# render_main_page funding + template vars (gate style)
|
||||||
|
text = text.replace(
|
||||||
|
" funding_capital, trading_capital = get_exchange_capitals()\n"
|
||||||
|
" total_capital = round(funding_capital, 4) if funding_capital is not None else TOTAL_CAPITAL\n"
|
||||||
|
" current_capital = round(trading_capital, 4) if trading_capital is not None else round(local_current_capital, 4)\n",
|
||||||
|
" funding_capital, trading_capital = get_exchange_capitals()\n"
|
||||||
|
" funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None\n"
|
||||||
|
" current_capital = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else round(local_current_capital, FUNDS_DECIMALS)\n",
|
||||||
|
)
|
||||||
|
text = text.replace(
|
||||||
|
" can_trade = now.hour >= TRADING_DAY_RESET_HOUR and active_count == 0\n"
|
||||||
|
" key_gate_rule_text = (\n"
|
||||||
|
' f"周期 {KLINE_TIMEFRAME}|量能/突破/二确门控见箱体与收敛规则|"\n',
|
||||||
|
" can_trade = trading_day_reset_allows_new_open(now) and active_count < MAX_ACTIVE_POSITIONS\n"
|
||||||
|
" key_gate_rule_text = (\n"
|
||||||
|
' f"周期 {KLINE_TIMEFRAME}|确认K:突破棒偏移 {KEY_CONFIRM_BREAKOUT_BAR}、确认棒偏移 {KEY_CONFIRM_BAR}|"\n'
|
||||||
|
' f"量能:突破量 > 前{KEY_VOLUME_MA_BARS}均量×{KEY_VOLUME_RATIO_MIN}|"\n',
|
||||||
|
)
|
||||||
|
text = text.replace(
|
||||||
|
' f"斐波:添加后立即挂限价 @ E,失效按标记价触达 H/L(未成交撤单)"\n',
|
||||||
|
' f"箱体/收敛可选 SL/TP 方案(标准 / 箱体1R·止盈1.5H / 趋势单+自填止盈)|移动保本默认关|"\n'
|
||||||
|
' f"斐波:限价 @ E(SL/TP 为 H/L),可选移动保本|趋势止损外侧 {KEY_TREND_STOP_OUTSIDE_PCT}%"\n',
|
||||||
|
)
|
||||||
|
text = text.replace(" total_capital=total_capital,\n", "")
|
||||||
|
text = text.replace(
|
||||||
|
" key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR,\n **strategy_extra,",
|
||||||
|
" funds_fmt=format_funds_u,\n"
|
||||||
|
" exchange_display=EXCHANGE_DISPLAY_NAME,\n"
|
||||||
|
" max_active_positions=MAX_ACTIVE_POSITIONS,\n"
|
||||||
|
" manual_min_planned_rr=MANUAL_MIN_PLANNED_RR,\n"
|
||||||
|
" key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR,\n"
|
||||||
|
" kline_timeframe=KLINE_TIMEFRAME,\n"
|
||||||
|
" funding_usdt=funding_usdt,\n"
|
||||||
|
" **strategy_extra,",
|
||||||
|
)
|
||||||
|
|
||||||
|
if '@app.route("/key_monitor")' not in text:
|
||||||
|
text = text.replace(
|
||||||
|
'@app.route("/trade")\n@login_required\ndef trade_page():',
|
||||||
|
'@app.route("/key_monitor")\n@login_required\ndef key_monitor_page():\n'
|
||||||
|
' return render_main_page("key_monitor")\n\n\n'
|
||||||
|
'@app.route("/trade")\n@login_required\ndef trade_page():',
|
||||||
|
)
|
||||||
|
|
||||||
|
# account_snapshot
|
||||||
|
text = re.sub(
|
||||||
|
r"@app\.route\(\"/api/account_snapshot\"\).*?return jsonify\(\{[^}]+\}\)",
|
||||||
|
'''@app.route("/api/account_snapshot")
|
||||||
|
@login_required
|
||||||
|
def api_account_snapshot():
|
||||||
|
now = app_now()
|
||||||
|
trading_day = get_trading_day(now)
|
||||||
|
conn = get_db()
|
||||||
|
session_row = ensure_session(conn, trading_day)
|
||||||
|
local_current_capital = float(session_row["current_capital"])
|
||||||
|
funding_capital, trading_capital = get_exchange_capitals(force=True)
|
||||||
|
funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None
|
||||||
|
current_capital = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else round(local_current_capital, FUNDS_DECIMALS)
|
||||||
|
recommended_capital = get_recommended_capital(current_capital)
|
||||||
|
active_count = get_active_position_count(conn)
|
||||||
|
conn.close()
|
||||||
|
can_trade = trading_day_reset_allows_new_open(now) and active_count < MAX_ACTIVE_POSITIONS
|
||||||
|
available_trading_usdt = get_available_trading_usdt()
|
||||||
|
return jsonify({
|
||||||
|
"funding_usdt": funding_usdt,
|
||||||
|
"current_capital": current_capital,
|
||||||
|
"available_trading_usdt": round(available_trading_usdt, FUNDS_DECIMALS) if available_trading_usdt is not None else None,
|
||||||
|
"recommended_capital": recommended_capital,
|
||||||
|
"active_count": active_count,
|
||||||
|
"max_active_positions": MAX_ACTIVE_POSITIONS,
|
||||||
|
"can_trade": can_trade,
|
||||||
|
"manual_min_planned_rr": MANUAL_MIN_PLANNED_RR,
|
||||||
|
"trading_day": trading_day,
|
||||||
|
})''',
|
||||||
|
text,
|
||||||
|
count=1,
|
||||||
|
flags=re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
# api_price_snapshot from gate (OKX positions)
|
||||||
|
gate_ps = re.search(
|
||||||
|
r'@app\.route\("/api/price_snapshot"\).*?return jsonify\(\{[^}]+\}\)',
|
||||||
|
gate_text,
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
if gate_ps:
|
||||||
|
ps = gate_ps.group(0)
|
||||||
|
ps = ps.replace("exchange_private_api_configured()", "exchange_private_api_configured()")
|
||||||
|
ps = ps.replace(
|
||||||
|
'all_swap_positions = exchange.fetch_positions(None, {"settle": "usdt"}) or []',
|
||||||
|
'all_swap_positions = exchange.fetch_positions(None, {"instType": OKX_POSITION_INST_TYPE}) or []',
|
||||||
|
)
|
||||||
|
ps = ps.replace("fetch_exchange_tpsl_slots(", "fetch_exchange_tpsl_slots(")
|
||||||
|
ps = ps.replace("cancel_gate_tpsl_slot", "cancel_okx_tpsl_slot")
|
||||||
|
ps = ps.replace("ensure_exchange_live_ready", "ensure_okx_live_ready")
|
||||||
|
text = re.sub(
|
||||||
|
r'@app\.route\("/api/price_snapshot"\).*?return jsonify\(\{[^}]+\}\)',
|
||||||
|
ps,
|
||||||
|
text,
|
||||||
|
count=1,
|
||||||
|
flags=re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
# cancel/place tpsl routes
|
||||||
|
if 'api_order_cancel_tpsl' not in text:
|
||||||
|
bin_text = (BIN / "app.py").read_text(encoding="utf-8")
|
||||||
|
m = re.search(
|
||||||
|
r'@app\.route\("/api/order/<int:order_id>/cancel_tpsl".*?exchange_tpsl": slots,\s*\}\s*\)',
|
||||||
|
bin_text,
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
if m:
|
||||||
|
block = m.group(0)
|
||||||
|
block = block.replace("ensure_exchange_live_ready", "ensure_okx_live_ready")
|
||||||
|
block = block.replace("cancel_binance_tpsl_slot", "cancel_okx_tpsl_slot")
|
||||||
|
block = block.replace(
|
||||||
|
'fetch_exchange_tpsl_slots(ex_sym, row["direction"])',
|
||||||
|
'fetch_exchange_tpsl_slots(ex_sym, row["direction"], plan_sl=row["stop_loss"], plan_tp=row["take_profit"])',
|
||||||
|
)
|
||||||
|
block = block.replace(
|
||||||
|
'fetch_exchange_tpsl_slots(ex_sym, direction)',
|
||||||
|
'fetch_exchange_tpsl_slots(ex_sym, direction, plan_sl=stop_loss, plan_tp=take_profit)',
|
||||||
|
)
|
||||||
|
text = text.replace(
|
||||||
|
'@app.route("/add_key", methods=["POST"])',
|
||||||
|
block + '\n\n@app.route("/add_key", methods=["POST"])',
|
||||||
|
)
|
||||||
|
|
||||||
|
# add_order RR + redirects
|
||||||
|
if "planned_rr_manual" not in text:
|
||||||
|
text = text.replace(
|
||||||
|
" if stop_loss <= 0 or take_profit <= 0:\n"
|
||||||
|
" conn.close()\n"
|
||||||
|
" flash(\"价格参数必须大于0\")\n"
|
||||||
|
" return redirect(\"/\")\n"
|
||||||
|
" risk_fraction = calc_risk_fraction",
|
||||||
|
" if stop_loss <= 0 or take_profit <= 0:\n"
|
||||||
|
" conn.close()\n"
|
||||||
|
" flash(\"价格参数必须大于0\")\n"
|
||||||
|
" return redirect(\"/trade\")\n"
|
||||||
|
" planned_rr_manual = calc_rr_ratio(direction, live_price, stop_loss, take_profit)\n"
|
||||||
|
" if planned_rr_manual is None or planned_rr_manual < MANUAL_MIN_PLANNED_RR:\n"
|
||||||
|
" conn.close()\n"
|
||||||
|
" rr_txt = f\"{planned_rr_manual:.4f}\" if planned_rr_manual is not None else \"无法计算\"\n"
|
||||||
|
" flash(f\"风控拒绝下单:计划盈亏比 {rr_txt}:1 低于最低要求 {MANUAL_MIN_PLANNED_RR}:1\")\n"
|
||||||
|
" return redirect(\"/trade\")\n"
|
||||||
|
" risk_fraction = calc_risk_fraction",
|
||||||
|
)
|
||||||
|
|
||||||
|
text = text.replace(
|
||||||
|
'if get_active_position_count(conn) > 0:\n'
|
||||||
|
' conn.close()\n'
|
||||||
|
' flash("当前已有持仓:无法添加「箱体突破 / 收敛突破」(请先平仓或使用阻力/支撑/斐波类型)")',
|
||||||
|
'occupied = get_active_position_count(conn)\n'
|
||||||
|
' if occupied >= MAX_ACTIVE_POSITIONS:\n'
|
||||||
|
' conn.close()\n'
|
||||||
|
' flash(\n'
|
||||||
|
' f"当前持仓已达上限({occupied}/{MAX_ACTIVE_POSITIONS}):无法添加「箱体突破 / 收敛突破」。"\n'
|
||||||
|
' "请先平仓或使用阻力/支撑/斐波类型"\n'
|
||||||
|
' )',
|
||||||
|
)
|
||||||
|
|
||||||
|
# add_key → /key_monitor (success paths in add_key only)
|
||||||
|
text = text.replace(
|
||||||
|
'def add_key():\n d = request.form\n symbol = normalize_symbol_input(d.get("symbol"))\n if not symbol:\n flash("symbol 不能为空")\n return redirect("/")',
|
||||||
|
'def add_key():\n d = request.form\n symbol = normalize_symbol_input(d.get("symbol"))\n if not symbol:\n flash("symbol 不能为空")\n return redirect("/key_monitor")',
|
||||||
|
)
|
||||||
|
text = re.sub(
|
||||||
|
r'(def add_key\(\):.*?)(return redirect\("/"\))',
|
||||||
|
lambda m: m.group(1) + 'return redirect("/key_monitor")',
|
||||||
|
text,
|
||||||
|
count=0,
|
||||||
|
flags=re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = text.replace(
|
||||||
|
'if "一次只能持有一个仓位" in reason:',
|
||||||
|
'if "已达最大持仓数" in reason or "一次只能持有一个仓位" in reason:',
|
||||||
|
)
|
||||||
|
|
||||||
|
app_path.write_text(text, encoding="utf-8")
|
||||||
|
print("patched", app_path)
|
||||||
|
|
||||||
|
|
||||||
|
def copy_templates():
|
||||||
|
src = BIN / "templates" / "index.html"
|
||||||
|
dst = OKX / "templates" / "index.html"
|
||||||
|
shutil.copy2(src, dst)
|
||||||
|
print("copied", dst)
|
||||||
|
|
||||||
|
|
||||||
|
def copy_env_example():
|
||||||
|
bin_env = (BIN / ".env.example").read_text(encoding="utf-8")
|
||||||
|
okx_path = OKX / ".env.example"
|
||||||
|
okx = okx_path.read_text(encoding="utf-8")
|
||||||
|
# inject binance-style blocks if missing
|
||||||
|
for marker, block in [
|
||||||
|
(
|
||||||
|
"TRADING_DAY_RESET_OPEN_GUARD",
|
||||||
|
"\nTRADING_DAY_RESET_OPEN_GUARD_ENABLED=true\n",
|
||||||
|
),
|
||||||
|
("MAX_ACTIVE_POSITIONS", "\nMAX_ACTIVE_POSITIONS=1\nMANUAL_MIN_PLANNED_RR=1.4\n"),
|
||||||
|
("KEY_CONFIRM_BREAKOUT_BAR", "\nKEY_CONFIRM_BREAKOUT_BAR=-2\nKEY_CONFIRM_BAR=-1\nKEY_VOLUME_MA_BARS=20\nKEY_VOLUME_RATIO_MIN=1.3\nKEY_BREAKOUT_AMP_MIN_PCT=0.03\nKEY_BREAKOUT_AMP_MAX_PCT=0.5\n"),
|
||||||
|
("EXCHANGE_DISPLAY_NAME", "\nEXCHANGE_DISPLAY_NAME=OKX\nOKX_ACCOUNT_LABEL=\n"),
|
||||||
|
("BACKUP_ROOT", "\nBACKUP_ROOT=/root/backups\nBACKUP_RETENTION_DAYS=30\nBACKUP_INSTANCE=crypto_monitor_okx\n"),
|
||||||
|
]:
|
||||||
|
if marker not in okx:
|
||||||
|
okx += block
|
||||||
|
if "TOTAL_CAPITAL=100" in okx and "# TOTAL_CAPITAL" not in okx:
|
||||||
|
okx = okx.replace("TOTAL_CAPITAL=100", "# TOTAL_CAPITAL=100 # 已弃用,资金展示读交易所")
|
||||||
|
okx_path.write_text(okx, encoding="utf-8")
|
||||||
|
print("updated .env.example")
|
||||||
|
|
||||||
|
|
||||||
|
def copy_scripts_docs():
|
||||||
|
for name in ("backup_data.sh", "install_backup_cron.sh"):
|
||||||
|
s = BIN / "scripts" / name
|
||||||
|
d = OKX / "scripts" / name
|
||||||
|
if s.is_file():
|
||||||
|
d.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
content = s.read_text(encoding="utf-8").replace("crypto_monitor_binance", "crypto_monitor_okx")
|
||||||
|
content = content.replace("BINANCE", "OKX")
|
||||||
|
d.write_text(content, encoding="utf-8")
|
||||||
|
v = BIN / "scripts" / "verify_binance_funding.py"
|
||||||
|
if v.is_file():
|
||||||
|
t = v.read_text(encoding="utf-8")
|
||||||
|
t = t.replace("binance", "okx").replace("BINANCE", "OKX").replace("verify_binance", "verify_okx")
|
||||||
|
(OKX / "scripts" / "verify_okx_funding.py").write_text(t, encoding="utf-8")
|
||||||
|
doc = BIN / "关键位自动下单说明.md"
|
||||||
|
if doc.is_file() and not (OKX / "关键位自动下单说明.md").exists():
|
||||||
|
shutil.copy2(doc, OKX / "关键位自动下单说明.md")
|
||||||
|
eco = OKX / "ecosystem.config.cjs"
|
||||||
|
if eco.is_file():
|
||||||
|
t = eco.read_text(encoding="utf-8").replace("GATE_SOCKS_PROXY", "OKX_SOCKS_PROXY")
|
||||||
|
eco.write_text(t, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
copy_templates()
|
||||||
|
patch_app()
|
||||||
|
copy_env_example()
|
||||||
|
copy_scripts_docs()
|
||||||
|
print("done")
|
||||||
Reference in New Issue
Block a user