diff --git a/README.md b/README.md index 8c602f5..442de29 100644 --- a/README.md +++ b/README.md @@ -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_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_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) | | 根目录 `strategy_*.py` | **策略交易**(趋势回调 + 顺势加仓共用逻辑) | [策略交易说明.md](./策略交易说明.md) | | 根目录 `ai_client.py` | **AI 复盘**(OpenAI 兼容网关 / Ollama 二选一) | [AI复盘与模型配置说明.md](./AI复盘与模型配置说明.md) | diff --git a/crypto_monitor_okx/.env.example b/crypto_monitor_okx/.env.example index 9527787..44a386b 100644 --- a/crypto_monitor_okx/.env.example +++ b/crypto_monitor_okx/.env.example @@ -1,141 +1,160 @@ -# ============================================================================= -# 环境配置模板(可提交 Git)。程序运行时只读取同目录下的 .env。 -# -# 首次部署 / 新机: -# cp .env.example .env -# nano .env # 填入真实密钥、端口、代理等 -# -# 升级代码(git pull)前建议备份(.env 不在 Git 中,pull 不会覆盖): -# cp .env .env.backup.$(date +%Y%m%d) -# -# 从备份恢复: -# cp .env.backup.YYYYMMDD .env -# ============================================================================= - -APP_ENV=production -# 服务监听地址(云服务器通常用 0.0.0.0) -APP_HOST=0.0.0.0 -# 服务端口 -APP_PORT=5004 -# 是否开启调试模式(生产建议 false) -APP_DEBUG=false - -# 登录账号 -APP_USERNAME=dekun -# 登录密码(请改成你自己的强密码) -APP_PASSWORD=ChangeMe123! -# 是否关闭登录校验(局域网可设 true;公网务必 false) -APP_AUTH_DISABLED=true -# --- 多账户交易中控 manual_trading_hub --- -# 中控请求本实例 /api/hub/* 时携带请求头 X-Hub-Token,须与中控启动环境变量 HUB_BRIDGE_TOKEN 一致 -# 未设置且 APP_AUTH_DISABLED=false 时,仅网页登录后可访问;本机联调可保持 APP_AUTH_DISABLED=true -# HUB_BRIDGE_TOKEN=your-long-random-token -# Flask 会话密钥(必须替换为长随机字符串) -FLASK_SECRET_KEY=CHANGE_TO_LONG_RANDOM_SECRET - -# 企业微信机器人 Webhook(用于行情/风控推送) -WECHAT_WEBHOOK=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=REPLACE_WITH_REAL_KEY - -# 数据库文件路径(相对路径会自动按项目目录解析) -DB_PATH=crypto.db -# 交易截图上传目录 -UPLOAD_DIR=static/images - -# 训练总资金(U) -TOTAL_CAPITAL=100 -# 每天起始基数(U) -DAILY_START_CAPITAL=30 -# 日内回撤后基数(U) -DAILY_LOSS_CAPITAL=20 -# 日内盈利后基数(U) -DAILY_PROFIT_CAPITAL=50 -# BTC 默认杠杆倍数 -BTC_LEVERAGE=10 -# 山寨币默认杠杆倍数 -ALT_LEVERAGE=5 -# 交易日重置小时(北京时间) -TRADING_DAY_RESET_HOUR=8 - -# 是否开启 OKX 实盘下单(false=只做本地流程,true=真实下单) -LIVE_TRADING_ENABLED=true -# OKX API Key(实盘) -OKX_API_KEY=REPLACE_WITH_OKX_API_KEY -# OKX API Secret(实盘) -OKX_API_SECRET=REPLACE_WITH_OKX_API_SECRET -# OKX API Passphrase(实盘) -OKX_API_PASSPHRASE=REPLACE_WITH_OKX_API_PASSPHRASE -# 保证金模式:cross=全仓,isolated=逐仓 -OKX_TD_MODE=cross -# 持仓模式:hedge=双向持仓,net=单向净持仓 -OKX_POS_MODE=hedge -# 仓位查询 instType(OKX) -OKX_POSITION_INST_TYPE=SWAP - -# 关键位监控:5m收线突破过滤参数 -KLINE_TIMEFRAME=5m -KEY_BREAKOUT_LIMIT_PCT=1.5 -KEY_ALERT_MAX_TIMES=3 -KEY_ALERT_INTERVAL_MINUTES=5 - -# 资金与仓位刷新周期(秒) -BALANCE_REFRESH_SECONDS=60 -# 后台监控轮询周期(秒) -MONITOR_POLL_SECONDS=3 -# 使用可用资金时的缓冲比例(如0.98代表用98%) -FULL_MARGIN_BUFFER_RATIO=0.98 - -# 自动划转:将目标账户补足到 AUTO_TRANSFER_AMOUNT -AUTO_TRANSFER_ENABLED=false -AUTO_TRANSFER_AMOUNT=30 -AUTO_TRANSFER_FROM=funding -AUTO_TRANSFER_TO=swap -TRANSFER_CCY=USDT -# 强制清仓整点(北京时间,默认 0=凌晨00点) -FORCE_CLOSE_BJ_HOUR=0 -# 是否启用强制清仓(默认关闭,true 才会在整点执行) -FORCE_CLOSE_ENABLED=false - -# 推送与AI超时(秒) -WECHAT_TIMEOUT_SECONDS=10 -AI_TIMEOUT_SECONDS=120 - -# AI 复盘服务地址(本机 Ollama 默认地址) -AI_PROVIDER=openai -OPENAI_API_BASE=https://op.bz121.com/v1 -OPENAI_API_KEY=你的密钥 -OPENAI_MODEL=gemma4:e4b -OLLAMA_API=http://127.0.0.1:11434/api/generate -AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest - -# OKX 代理(可选):用于本机网络对 OKX TLS/SNI 不稳定时,通过 SSH 动态转发 SOCKS5 出口 -# 1) 先在本机建立隧道(示例): -# ssh -N -D 127.0.0.1:1080 root@你的VPS_IP -o ServerAliveInterval=30 -o ExitOnForwardFailure=yes -# 2) 再启用下面这一行(推荐 socks5h,让远端解析域名): -# OKX_SOCKS_PROXY=socks5h://127.0.0.1:1080 -# -# 如你更偏向 HTTP 代理(VPS 上跑 tinyproxy 之类),可用: -# OKX_HTTP_PROXY=http://127.0.0.1:3128 -# OKX_HTTPS_PROXY=http://127.0.0.1:3128 - -# 开仓多周期K线图(可选) -# ORDER_CHART_ENABLED=true -# ORDER_CHART_TFS=4h,1h,15m,5m -# ORDER_CHART_LIMIT=100 -# ORDER_CHART_DIR=static/images/order_charts -# DAILY_OPEN_ALERT_THRESHOLD=5 -# 关键位:标准方案止损外侧%、趋势单方案止损外侧%(默认 0.5 / 1) -# KEY_STOP_OUTSIDE_BREAKOUT_PCT=0.5 -# KEY_TREND_STOP_OUTSIDE_PCT=1 -# 以损定仓(按交易账户资金的百分比) -# RISK_PERCENT=2 -# 移动保本触发(达到多少R触发)与偏移(百分比) -# BREAKEVEN_RR_TRIGGER=1.0 -# 移动保本阶梯(每多少R继续上移一次,默认1R) -# BREAKEVEN_STEP_R=1.0 -# BREAKEVEN_OFFSET_PCT=0.02 -# 开单风格默认值:trend / swing -# DEFAULT_TRADE_STYLE=trend - -APP_TIMEZONE=Asia/Shanghai -AUTO_TRANSFER_BJ_HOUR=8 -# TRADING_DAY_RESET_HOUR 现在表示「北京时间」整点,默认 8 点起算新交易日/可开仓等 +# ============================================================================= +# 环境配置模板(可提交 Git)。程序运行时只读取同目录下的 .env。 +# +# 首次部署 / 新机: +# cp .env.example .env +# nano .env # 填入真实密钥、端口、代理等 +# +# 升级代码(git pull)前建议备份(.env 不在 Git 中,pull 不会覆盖): +# cp .env .env.backup.$(date +%Y%m%d) +# +# 从备份恢复: +# cp .env.backup.YYYYMMDD .env +# ============================================================================= + +APP_ENV=production +# 服务监听地址(云服务器通常用 0.0.0.0) +APP_HOST=0.0.0.0 +# 服务端口 +APP_PORT=5004 +# 是否开启调试模式(生产建议 false) +APP_DEBUG=false + +# 登录账号 +APP_USERNAME=dekun +# 登录密码(请改成你自己的强密码) +APP_PASSWORD=ChangeMe123! +# 是否关闭登录校验(局域网可设 true;公网务必 false) +APP_AUTH_DISABLED=true +# --- 多账户交易中控 manual_trading_hub --- +# 中控请求本实例 /api/hub/* 时携带请求头 X-Hub-Token,须与中控启动环境变量 HUB_BRIDGE_TOKEN 一致 +# 未设置且 APP_AUTH_DISABLED=false 时,仅网页登录后可访问;本机联调可保持 APP_AUTH_DISABLED=true +# HUB_BRIDGE_TOKEN=your-long-random-token +# Flask 会话密钥(必须替换为长随机字符串) +FLASK_SECRET_KEY=CHANGE_TO_LONG_RANDOM_SECRET + +# 企业微信机器人 Webhook(用于行情/风控推送) +WECHAT_WEBHOOK=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=REPLACE_WITH_REAL_KEY + +# 数据库文件路径(相对路径会自动按项目目录解析) +DB_PATH=crypto.db +# 交易截图上传目录 +UPLOAD_DIR=static/images + +# 训练总资金(U) +# TOTAL_CAPITAL=100 # 已弃用,资金展示读交易所 +# 每天起始基数(U) +DAILY_START_CAPITAL=30 +# 日内回撤后基数(U) +DAILY_LOSS_CAPITAL=20 +# 日内盈利后基数(U) +DAILY_PROFIT_CAPITAL=50 +# BTC 默认杠杆倍数 +BTC_LEVERAGE=10 +# 山寨币默认杠杆倍数 +ALT_LEVERAGE=5 +# 交易日重置小时(北京时间) +TRADING_DAY_RESET_HOUR=8 + +# 是否开启 OKX 实盘下单(false=只做本地流程,true=真实下单) +LIVE_TRADING_ENABLED=true +# OKX API Key(实盘) +OKX_API_KEY=REPLACE_WITH_OKX_API_KEY +# OKX API Secret(实盘) +OKX_API_SECRET=REPLACE_WITH_OKX_API_SECRET +# OKX API Passphrase(实盘) +OKX_API_PASSPHRASE=REPLACE_WITH_OKX_API_PASSPHRASE +# 保证金模式:cross=全仓,isolated=逐仓 +OKX_TD_MODE=cross +# 持仓模式:hedge=双向持仓,net=单向净持仓 +OKX_POS_MODE=hedge +# 仓位查询 instType(OKX) +OKX_POSITION_INST_TYPE=SWAP + +# 关键位监控:5m收线突破过滤参数 +KLINE_TIMEFRAME=5m +KEY_BREAKOUT_LIMIT_PCT=1.5 +KEY_ALERT_MAX_TIMES=3 +KEY_ALERT_INTERVAL_MINUTES=5 + +# 资金与仓位刷新周期(秒) +BALANCE_REFRESH_SECONDS=60 +# 后台监控轮询周期(秒) +MONITOR_POLL_SECONDS=3 +# 使用可用资金时的缓冲比例(如0.98代表用98%) +FULL_MARGIN_BUFFER_RATIO=0.98 + +# 自动划转:将目标账户补足到 AUTO_TRANSFER_AMOUNT +AUTO_TRANSFER_ENABLED=false +AUTO_TRANSFER_AMOUNT=30 +AUTO_TRANSFER_FROM=funding +AUTO_TRANSFER_TO=swap +TRANSFER_CCY=USDT +# 强制清仓整点(北京时间,默认 0=凌晨00点) +FORCE_CLOSE_BJ_HOUR=0 +# 是否启用强制清仓(默认关闭,true 才会在整点执行) +FORCE_CLOSE_ENABLED=false + +# 推送与AI超时(秒) +WECHAT_TIMEOUT_SECONDS=10 +AI_TIMEOUT_SECONDS=120 + +# AI 复盘服务地址(本机 Ollama 默认地址) +AI_PROVIDER=openai +OPENAI_API_BASE=https://op.bz121.com/v1 +OPENAI_API_KEY=你的密钥 +OPENAI_MODEL=gemma4:e4b +OLLAMA_API=http://127.0.0.1:11434/api/generate +AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest + +# OKX 代理(可选):用于本机网络对 OKX TLS/SNI 不稳定时,通过 SSH 动态转发 SOCKS5 出口 +# 1) 先在本机建立隧道(示例): +# ssh -N -D 127.0.0.1:1080 root@你的VPS_IP -o ServerAliveInterval=30 -o ExitOnForwardFailure=yes +# 2) 再启用下面这一行(推荐 socks5h,让远端解析域名): +# OKX_SOCKS_PROXY=socks5h://127.0.0.1:1080 +# +# 如你更偏向 HTTP 代理(VPS 上跑 tinyproxy 之类),可用: +# OKX_HTTP_PROXY=http://127.0.0.1:3128 +# OKX_HTTPS_PROXY=http://127.0.0.1:3128 + +# 开仓多周期K线图(可选) +# ORDER_CHART_ENABLED=true +# ORDER_CHART_TFS=4h,1h,15m,5m +# ORDER_CHART_LIMIT=100 +# ORDER_CHART_DIR=static/images/order_charts +# DAILY_OPEN_ALERT_THRESHOLD=5 +# 关键位:标准方案止损外侧%、趋势单方案止损外侧%(默认 0.5 / 1) +# KEY_STOP_OUTSIDE_BREAKOUT_PCT=0.5 +# KEY_TREND_STOP_OUTSIDE_PCT=1 +# 以损定仓(按交易账户资金的百分比) +# RISK_PERCENT=2 +# 移动保本触发(达到多少R触发)与偏移(百分比) +# BREAKEVEN_RR_TRIGGER=1.0 +# 移动保本阶梯(每多少R继续上移一次,默认1R) +# BREAKEVEN_STEP_R=1.0 +# BREAKEVEN_OFFSET_PCT=0.02 +# 开单风格默认值:trend / swing +# DEFAULT_TRADE_STYLE=trend + +APP_TIMEZONE=Asia/Shanghai +AUTO_TRANSFER_BJ_HOUR=8 +# TRADING_DAY_RESET_HOUR 现在表示「北京时间」整点,默认 8 点起算新交易日/可开仓等 + +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 diff --git a/crypto_monitor_okx/README.md b/crypto_monitor_okx/README.md new file mode 100644 index 0000000..5c38374 --- /dev/null +++ b/crypto_monitor_okx/README.md @@ -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 账户设置一致。 diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index 1be28da..0afd199 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -127,6 +127,9 @@ BTC_LEVERAGE = int(os.getenv("BTC_LEVERAGE", "10")) ALT_LEVERAGE = int(os.getenv("ALT_LEVERAGE", "5")) # 交易日滚动与「可开仓」整点:按应用本地时区 wall clock(默认北京时间 UTC+8) TRADING_DAY_RESET_HOUR = int(os.getenv("TRADING_DAY_RESET_HOUR", "8")) +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") @@ -146,6 +149,7 @@ OKX_API_SECRET = os.getenv("OKX_API_SECRET", "") OKX_API_PASSPHRASE = os.getenv("OKX_API_PASSPHRASE", "") OKX_TD_MODE = os.getenv("OKX_TD_MODE", "cross") 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")) PRICE_REFRESH_SECONDS = int(os.getenv("PRICE_REFRESH_SECONDS", "5")) 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_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_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 ( "1", "true", @@ -1753,6 +1766,18 @@ def format_price_for_symbol(symbol, value): 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): if not minutes: 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): 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 前不允许持仓" - active_count = conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0] - if active_count > 0: - return False, "一次只能持有一个仓位" + 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"): @@ -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) + +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): """先撤该合约挂单/条件单,再按新价重挂 TP/SL。""" ok, reason = ensure_okx_live_ready() @@ -4146,8 +4381,8 @@ def render_main_page(page="trade"): session_row = ensure_session(conn, trading_day) local_current_capital = float(session_row["current_capital"]) funding_capital, trading_capital = get_exchange_capitals() - total_capital = round(funding_capital, 4) if funding_capital is not None else TOTAL_CAPITAL - current_capital = round(trading_capital, 4) if trading_capital is not None else round(local_current_capital, 4) + 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) key_list = conn.execute("SELECT * FROM key_monitors").fetchall() key_history = conn.execute( @@ -4176,11 +4411,13 @@ def render_main_page(page="trade"): ) rate = round(win/total*100,2) if total else 0 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 = ( - 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"斐波:添加后立即挂限价 @ E,失效按标记价触达 H/L(未成交撤单)" + f"箱体/收敛可选 SL/TP 方案(标准 / 箱体1R·止盈1.5H / 趋势单+自填止盈)|移动保本默认关|" + f"斐波:限价 @ E(SL/TP 为 H/L),可选移动保本|趋势止损外侧 {KEY_TREND_STOP_OUTSIDE_PCT}%" ) strategy_extra = {} if page in ("strategy", "strategy_trend", "strategy_roll"): @@ -4206,7 +4443,6 @@ def render_main_page(page="trade"): miss_count=miss_count, rate=rate, trading_day=trading_day, - total_capital=total_capital, daily_start_capital=DAILY_START_CAPITAL, current_capital=current_capital, recommended_capital=recommended_capital, @@ -4242,7 +4478,13 @@ def render_main_page(page="trade"): entry_reason_options=list(ENTRY_REASON_OPTIONS), entry_reason_other_value=ENTRY_REASON_OTHER, 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, + kline_timeframe=KLINE_TIMEFRAME, + funding_usdt=funding_usdt, **strategy_extra, ) @@ -4253,6 +4495,12 @@ def index(): return redirect("/trade") +@app.route("/key_monitor") +@login_required +def key_monitor_page(): + return render_main_page("key_monitor") + + @app.route("/trade") @login_required def trade_page(): @@ -4280,21 +4528,23 @@ def api_account_snapshot(): session_row = ensure_session(conn, trading_day) local_current_capital = float(session_row["current_capital"]) funding_capital, trading_capital = get_exchange_capitals(force=True) - total_capital = round(funding_capital, 4) if funding_capital is not None else TOTAL_CAPITAL - current_capital = round(trading_capital, 4) if trading_capital is not None else round(local_current_capital, 4) + 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 = conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0] + active_count = get_active_position_count(conn) 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() return jsonify({ - "total_capital": total_capital, + "funding_usdt": funding_usdt, "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, "active_count": active_count, + "max_active_positions": MAX_ACTIVE_POSITIONS, "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" ).fetchall() 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() conn.close() + try: + ensure_markets_loaded() + except Exception: + pass + symbol_set = set() for r in key_rows: symbol_set.add(r["symbol"]) @@ -4322,20 +4577,30 @@ def api_price_snapshot(): if p is not None: prices[s] = float(p) + all_swap_positions = [] + if exchange_private_api_configured(): + try: + ensure_markets_loaded() + # 显式 USDT 本位;不传 symbols 拉全量,再在本地按合约对齐 + all_swap_positions = exchange.fetch_positions(None, {"instType": OKX_POSITION_INST_TYPE}) or [] + except Exception: + try: + all_swap_positions = exchange.fetch_positions() or [] + except Exception: + all_swap_positions = [] + key_prices = [] 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: continue upper_diff, upper_pct = calc_price_diff_pct(price, r["upper"]) lower_diff, lower_pct = calc_price_diff_pct(price, r["lower"]) - is_fib = is_fib_key_monitor_type(r["monitor_type"]) 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_metrics = "" fib_gate_ok = True @@ -4344,11 +4609,16 @@ def api_price_snapshot(): inval = fib_invalidate_by_mark(direction, price, r["upper"], r["lower"]) fib_gate_ok = not inval entry = _sqlite_row_val(r, "fib_entry_price") - entry_txt = format_price_for_symbol(r["symbol"], entry) if entry 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 '等待成交'}" if _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')}" gate_summary = ( f"量:{'Y' if gate.get('vol_ok') else 'N'} " @@ -4371,10 +4641,16 @@ def api_price_snapshot(): ) except Exception: gate_metrics = "" + px_disp = format_price_for_symbol(r["symbol"], price) + try: + price_num = float(px_disp) if px_disp != "-" else float(price) + except Exception: + price_num = float(price) key_prices.append({ "id": r["id"], "symbol": r["symbol"], - "price": round(price, 6), + "price": price_num, + "price_display": px_disp, "upper_diff": upper_diff, "upper_pct": upper_pct, "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_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"]) - 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"], "symbol": r["symbol"], - "price": round(price, 6), - "float_pnl": round(pnl, 6), + "float_pnl": round(pnl, 2), "float_pct": pnl_pct, "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({ "updated_at": app_now_str(), "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//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//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"]) @login_required def add_key(): @@ -4676,11 +5088,11 @@ def add_key(): symbol = normalize_symbol_input(d.get("symbol")) if not symbol: flash("symbol 不能为空") - return redirect("/") + return redirect("/key_monitor") direction_sel = (d.get("direction") or "").strip().lower() if direction_sel not in ("long", "short"): flash("请选择做多或做空") - return redirect("/") + return redirect("/key_monitor") mt = (d.get("type") or "").strip() allowed_types = tuple(KEY_MONITOR_AUTO_TYPES) + tuple(KEY_MONITOR_ALERT_ONLY_TYPES) + tuple(FIB_KEY_MONITOR_TYPES) if mt not in allowed_types: @@ -4695,9 +5107,13 @@ def add_key(): return redirect("/") conn = get_db() 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() - flash("当前已有持仓:无法添加「箱体突破 / 收敛突破」(请先平仓或使用阻力/支撑/斐波类型)") + flash( + f"当前持仓已达上限({occupied}/{MAX_ACTIVE_POSITIONS}):无法添加「箱体突破 / 收敛突破」。" + "请先平仓或使用阻力/支撑/斐波类型" + ) return redirect("/") ex_sym_key = normalize_okx_symbol(symbol) try: @@ -4759,7 +5175,7 @@ def add_key(): if mt in KEY_MONITOR_AUTO_TYPES: extra = f"|方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'开' if be_flag else '关'}" flash(f"添加成功({symbol} 日成交量排名 {rank}/{total}){extra}") - return redirect("/") + return redirect("/key_monitor") @app.route("/add_order", methods=["POST"]) @login_required @@ -4772,10 +5188,10 @@ def add_order(): if not symbol: conn.close() flash("symbol 不能为空") - return redirect("/") + return redirect("/trade") ok, reason = precheck_risk(conn, symbol, direction) if not ok: - if "一次只能持有一个仓位" in reason: + if "已达最大持仓数" in reason or "一次只能持有一个仓位" in reason: try: tp_raw = parse_positive_float(d.get("tp")) sl_raw = parse_positive_float(d.get("sl")) @@ -4798,12 +5214,12 @@ def add_order(): conn.commit() conn.close() flash(f"风控拒绝下单:{reason}") - return redirect("/") + return redirect("/trade") ok_live, reason_live = ensure_okx_live_ready() if not ok_live: conn.close() flash(f"风控拒绝下单:{reason_live}") - return redirect("/") + return redirect("/trade") exchange_symbol = normalize_okx_symbol(symbol) default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) try: @@ -4812,11 +5228,11 @@ def add_order(): except Exception: conn.close() flash("杠杆参数格式错误") - return redirect("/") + return redirect("/trade") if leverage <= 0: conn.close() flash("杠杆必须大于0") - return redirect("/") + return redirect("/trade") trading_day = get_trading_day(now) opens_today_before = conn.execute( @@ -4834,7 +5250,7 @@ def add_order(): if live_price is None: conn.close() flash("获取交易所实时价格失败,请稍后重试") - return redirect("/") + return redirect("/trade") sltp_mode = (d.get("sltp_mode") or "price").strip().lower() if sltp_mode not in ("price", "pct"): sltp_mode = "price" @@ -4855,7 +5271,7 @@ def add_order(): except Exception: conn.close() flash("百分比止盈止损参数错误,请填写正数百分比") - return redirect("/") + return redirect("/trade") else: try: stop_loss = float(d["sl"]) @@ -4863,16 +5279,22 @@ def add_order(): except Exception: conn.close() flash("价格参数格式错误") - return redirect("/") + return redirect("/trade") if stop_loss <= 0 or take_profit <= 0: conn.close() 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) if risk_fraction is None: conn.close() flash("止损方向不合法:请检查入场方向与止损价格关系") - return redirect("/") + return redirect("/trade") risk_percent = max(0.01, float(RISK_PERCENT)) risk_amount = round(capital_base * risk_percent / 100.0, 4) notional_value = round(risk_amount / risk_fraction, 4) @@ -4880,13 +5302,13 @@ def add_order(): if capital_base and margin_capital > capital_base: conn.close() flash("以损定仓后保证金超过当前交易资金,请放宽止损或降低风险比例") - return redirect("/") + return redirect("/trade") if available_usdt is not None: max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4) if margin_capital > max_margin: conn.close() 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 try: amount, quote_price = prepare_order_amount(exchange_symbol, margin_capital, leverage, live_price) @@ -4899,7 +5321,7 @@ def add_order(): except Exception as e: conn.close() 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") opened_at_bj = app_now_str() @@ -5060,7 +5482,7 @@ def add_order(): if advice: send_wechat_msg(f"【AI提醒】今日开仓次数已达 {opens_today_after}\n{advice[:800]}") flash(f"【AI提醒】今日开仓次数已达 {opens_today_after}:{advice[:300]}") - return redirect("/") + return redirect("/trade") @app.route("/delete_key_monitor/", methods=["POST"]) @login_required @@ -5920,10 +6342,11 @@ def _hub_meta_bundle(): "key_gate_rule_text": ( f"周期 {KLINE_TIMEFRAME}|量能/突破/二确门控见箱体与收敛规则|" 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")), - "max_active_positions": max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", "1"))), + "manual_min_planned_rr": MANUAL_MIN_PLANNED_RR, + "max_active_positions": MAX_ACTIVE_POSITIONS, "btc_leverage": BTC_LEVERAGE, "alt_leverage": ALT_LEVERAGE, } diff --git a/crypto_monitor_okx/ecosystem.config.cjs b/crypto_monitor_okx/ecosystem.config.cjs index 7183c40..3377ddf 100644 --- a/crypto_monitor_okx/ecosystem.config.cjs +++ b/crypto_monitor_okx/ecosystem.config.cjs @@ -1,34 +1,34 @@ -/** - * PM2 进程定义(Ubuntu / Linux)。 - * - * 仅托管 Flask 应用。**SSH SOCKS 隧道请在本机用 screen/tmux/systemd 等方式单独常驻**, - * 与 `.env` 里 `GATE_SOCKS_PROXY` 端口一致即可;不必交给 PM2。 - * - * 使用前:项目根目录存在 `.venv`,且已安装依赖(走 SOCKS 时需 PySocks)。 - * - * 启动: - * pm2 start ecosystem.config.cjs - * 保存开机列表: - * pm2 save && pm2 startup - */ -const path = require("path"); - -const ROOT = __dirname; -const REPO_ROOT = path.join(ROOT, ".."); -const PY = path.join(ROOT, ".venv", "bin", "python"); - -module.exports = { - apps: [ - { - name: "crypto_okx", - cwd: ROOT, - script: path.join(ROOT, "app.py"), - interpreter: PY, - instances: 1, - autorestart: true, - watch: false, - max_memory_restart: "800M", - env: { PYTHONPATH: REPO_ROOT }, - }, - ], -}; +/** + * PM2 进程定义(Ubuntu / Linux)。 + * + * 仅托管 Flask 应用。**SSH SOCKS 隧道请在本机用 screen/tmux/systemd 等方式单独常驻**, + * 与 `.env` 里 `OKX_SOCKS_PROXY` 端口一致即可;不必交给 PM2。 + * + * 使用前:项目根目录存在 `.venv`,且已安装依赖(走 SOCKS 时需 PySocks)。 + * + * 启动: + * pm2 start ecosystem.config.cjs + * 保存开机列表: + * pm2 save && pm2 startup + */ +const path = require("path"); + +const ROOT = __dirname; +const REPO_ROOT = path.join(ROOT, ".."); +const PY = path.join(ROOT, ".venv", "bin", "python"); + +module.exports = { + apps: [ + { + name: "crypto_okx", + cwd: ROOT, + script: path.join(ROOT, "app.py"), + interpreter: PY, + instances: 1, + autorestart: true, + watch: false, + max_memory_restart: "800M", + env: { PYTHONPATH: REPO_ROOT }, + }, + ], +}; diff --git a/crypto_monitor_okx/scripts/backup_data.sh b/crypto_monitor_okx/scripts/backup_data.sh new file mode 100644 index 0000000..9a25287 --- /dev/null +++ b/crypto_monitor_okx/scripts/backup_data.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Daily backup: SQLite DB + static/images → /root/backups/// +# Prune backup folders older than RETENTION_DAYS (default 30). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$PROJECT_DIR" + +BACKUP_ROOT="${BACKUP_ROOT:-/root/backups}" +RETENTION_DAYS="${RETENTION_DAYS:-30}" +INSTANCE_NAME="${BACKUP_INSTANCE:-$(basename "$PROJECT_DIR")}" +TZ_NAME="${BACKUP_TZ:-Asia/Shanghai}" + +log() { + printf '[%s] %s\n' "$(TZ="$TZ_NAME" date '+%Y-%m-%d %H:%M:%S %Z')" "$*" +} + +read_env_var() { + local key="$1" + local default="$2" + local line + if [[ ! -f .env ]]; then + printf '%s' "$default" + return + fi + line="$(grep -E "^${key}=" .env 2>/dev/null | tail -1 || true)" + if [[ -z "$line" ]]; then + printf '%s' "$default" + return + fi + printf '%s' "${line#*=}" | tr -d '\r' +} + +resolve_project_path() { + local p="$1" + if [[ "$p" == /* ]]; then + printf '%s' "$p" + else + printf '%s' "$PROJECT_DIR/$p" + fi +} + +prune_old_backups() { + local base="$BACKUP_ROOT/$INSTANCE_NAME" + [[ -d "$base" ]] || return 0 + local cutoff + cutoff="$(TZ="$TZ_NAME" date -d "-${RETENTION_DAYS} days" +%Y-%m-%d 2>/dev/null || true)" + if [[ -z "$cutoff" ]]; then + find "$base" -mindepth 1 -maxdepth 1 -type d -mtime +"$RETENTION_DAYS" -print0 | + xargs -r -0 rm -rf + return 0 + fi + local dir name + for dir in "$base"/*/; do + [[ -d "$dir" ]] || continue + name="$(basename "$dir")" + [[ "$name" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || continue + if [[ "$name" < "$cutoff" ]]; then + log "prune: remove $dir (older than ${RETENTION_DAYS} days)" + rm -rf "$dir" + fi + done +} + +DB_REL="$(read_env_var DB_PATH crypto.db)" +UPLOAD_REL="$(read_env_var UPLOAD_DIR static/images)" +BACKUP_ROOT="$(read_env_var BACKUP_ROOT "$BACKUP_ROOT")" +RETENTION_DAYS="$(read_env_var BACKUP_RETENTION_DAYS "$RETENTION_DAYS")" +INSTANCE_NAME="$(read_env_var BACKUP_INSTANCE "$INSTANCE_NAME")" + +DB_PATH="$(resolve_project_path "$DB_REL")" +UPLOAD_DIR="$(resolve_project_path "$UPLOAD_REL")" +DATE_TAG="$(TZ="$TZ_NAME" date +%Y-%m-%d)" +DEST="$BACKUP_ROOT/$INSTANCE_NAME/$DATE_TAG" + +if [[ ! -f "$DB_PATH" ]]; then + log "error: database not found: $DB_PATH" + exit 1 +fi + +mkdir -p "$DEST" +log "start backup instance=$INSTANCE_NAME dest=$DEST" + +if command -v sqlite3 >/dev/null 2>&1; then + sqlite3 "$DB_PATH" ".backup '$DEST/crypto.db'" + log "db: sqlite3 backup -> $DEST/crypto.db" +else + cp -a "$DB_PATH" "$DEST/crypto.db" + log "db: cp -> $DEST/crypto.db (sqlite3 not installed)" +fi + +if [[ -d "$UPLOAD_DIR" ]]; then + tar -czf "$DEST/static_images.tar.gz" -C "$(dirname "$UPLOAD_DIR")" "$(basename "$UPLOAD_DIR")" + log "images: $UPLOAD_DIR -> $DEST/static_images.tar.gz" +else + log "warn: upload dir missing, skip images: $UPLOAD_DIR" +fi + +{ + echo "instance=$INSTANCE_NAME" + echo "project_dir=$PROJECT_DIR" + echo "backup_date=$DATE_TAG" + echo "db_path=$DB_PATH" + echo "upload_dir=$UPLOAD_DIR" +} >"$DEST/manifest.txt" + +prune_old_backups +log "done" diff --git a/crypto_monitor_okx/scripts/install_backup_cron.sh b/crypto_monitor_okx/scripts/install_backup_cron.sh new file mode 100644 index 0000000..96053f4 --- /dev/null +++ b/crypto_monitor_okx/scripts/install_backup_cron.sh @@ -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 diff --git a/crypto_monitor_okx/scripts/verify_okx_funding.py b/crypto_monitor_okx/scripts/verify_okx_funding.py new file mode 100644 index 0000000..6037ff8 --- /dev/null +++ b/crypto_monitor_okx/scripts/verify_okx_funding.py @@ -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() diff --git a/crypto_monitor_okx/templates/index.html b/crypto_monitor_okx/templates/index.html index 0557b40..9ffe01c 100644 --- a/crypto_monitor_okx/templates/index.html +++ b/crypto_monitor_okx/templates/index.html @@ -2,13 +2,14 @@ - 加密货币 | 交易监控复盘系统 + {{ exchange_display }} · 加密货币 | 交易监控复盘系统 - + {% macro period_stats(title, s) %}

{{ title }}

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

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

+
+

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

+
{{ exchange_display }}
+
+
交易所
{{ exchange_display }}
总交易
{{ total }}
错过次数
{{ miss_count }}
胜率
{{ rate }}%
-
训练总资金(资金账户)
{{ total_capital }}U
+
资金账户(USDT)
{% if funding_usdt is not none %}{{ funds_fmt(funding_usdt) }}U{% else %}—{% endif %}
交易日
{{ trading_day }}
-
当日资金(交易账户)
{{ current_capital }}U
+
当日资金(交易账户)
{{ funds_fmt(current_capital) }}U
实时价格更新时间:--(北京时间 UTC+8)
- {% if page == 'trade' %} -
+ {% if page == 'key_monitor' %} +
+
-

关键位监控(5m)

+

关键位监控

{% if focus_key_id %} 放大查看K线(默认200根) {% else %} @@ -232,47 +298,73 @@
{{ key_gate_rule_text }}
-
+
{% for k in key %} -
-
{{ k.symbol }} | {{ k.monitor_type }} | {{ '做多' if k.direction == 'long' else '做空' }}
-
- 上:{{ k.upper }} 下:{{ k.lower }} - {% if k.fib_entry_price %}| 挂E:{{ k.fib_entry_price }}{% endif %} - | 已提醒:{{ k.notification_count or 0 }}/{{ k.max_notify or 3 }} - {% if k.monitor_type in ['箱体突破','收敛突破'] %}| 方案:{{ '标准' if (k.sl_tp_mode or 'standard') == 'standard' else ('1.5H' if k.sl_tp_mode == 'box_1p5' else '趋势') }}{% endif %} - | 保本:{{ '开' if k.breakeven_enabled else '关' }} - | 现价:- - | 距上沿:- - | 距下沿:- - | 门控:- - +
+
+
+ {{ k.symbol }} + {{ '做多' if k.direction == 'long' else '做空' }} + {{ k.monitor_type }} +
+
- +
+ 上沿: {{ k.upper }} + 下沿: {{ k.lower }} + {% if k.fib_entry_price %}挂E: {{ k.fib_entry_price }}{% endif %} + 已提醒: {{ k.notification_count or 0 }}/{{ k.max_notify or 3 }} + {% if k.monitor_type in ['箱体突破','收敛突破'] %} + 方案: {{ '标准突破' if (k.sl_tp_mode or 'standard') == 'standard' else ('箱体1R·止盈1.5H' if k.sl_tp_mode == 'box_1p5' else '趋势单') }} + {% endif %} + 保本: {{ '开' if k.breakeven_enabled else '关' }} +
+
+
现价-
+
距上沿-
+
距下沿-
+
门控-
+
+
+ {% else %} +
暂无监控中的关键位
{% endfor %}
-
-

关键位历史(满次提醒或手动删除)

-
满 {{ key_alert_max_times }} 次企业微信提醒后自动移入此处;手动删除也会归档。受顶栏 UTC 列表时间窗筛选。
-
- {% for h in key_history %} -
-
- {{ h.symbol }} | {{ h.monitor_type }} | {{ '做多' if h.direction == 'long' else '做空' }} | {{ h.close_reason }} - +
+
+

关键位历史

+
失效或已结案的关键位
+
+ {% for h in key_history %} +
+
+
+ {{ h.symbol }} + {{ '做多' if h.direction == 'long' else '做空' }}
-
上:{{ h.upper }} 下:{{ h.lower }} | 提醒次数:{{ h.notification_count }} | {{ (h.closed_at or '-')[:16] }}
- {% if h.last_alert_message %}
{{ h.last_alert_message[:200] }}{% if h.last_alert_message|length > 200 %}…{% endif %}
{% endif %} +
- {% else %} -
暂无历史
- {% endfor %} +
+ {{ h.monitor_type }} + {{ h.close_reason }} + {{ (h.closed_at or '-')[:16] }} +
+
+ 上: {{ h.upper }} 下: {{ h.lower }} + 提醒: {{ h.notification_count }} +
+ {% if h.last_alert_message %}
{{ h.last_alert_message[:180] }}{% if h.last_alert_message|length > 180 %}…{% endif %}
{% endif %}
+ {% else %} +
暂无历史
+ {% endfor %}
- -
+
+ {% elif page == 'trade' %} +
+

实盘下单监控

{% if focus_order_id %} @@ -282,12 +374,12 @@ {% endif %}
- 规则:单仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x; - {% if can_trade %}可开仓{% else %}不可开仓(有持仓或未到北京时间 {{ reset_hour }}:00){% endif %}; - 按风险比例自动计算仓位 + 规则:最多 {{ max_active_positions }} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x; + {% if can_trade %}可开仓{% else %}不可开仓(持仓已满或未到北京时间 {{ reset_hour }}:00){% endif %}; + 人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1
- 以损定仓:风险 {{ risk_percent }}% |移动保本:{{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}% + 以损定仓:风险 {{ risk_percent }}% |移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}%
划转:自动划转 {{ '开启' if auto_transfer_enabled else '关闭' }}(每天北京时间 {{ auto_transfer_bj_hour }}:00起该整点小时内尝试;账簿按 UTC 自然日去重;界面时间为北京;将 {{ auto_transfer_to }} 补足到 {{ auto_transfer_amount }}U,来自 {{ auto_transfer_from }}) @@ -306,7 +398,7 @@ -
+ 成交价自动取交易所实时+成交回报 @@ -333,29 +425,126 @@
-
+
+
+

实时持仓

+
{% for o in order %} -
-
{{ o.symbol }} | {{ '做多' if o.direction == 'long' else '做空' }}
-
- 风格:{{ o.trade_style or 'trend' }} | 风险:{{ o.risk_percent or '-' }}%≈{{ o.risk_amount or '-' }}U - | {% if o.breakeven_enabled %}移动保本:开{% else %}移动保本:关{% endif %} {{ o.breakeven_rr_trigger or '-' }}R→{{ o.breakeven_price or '-' }} -
- 成交:{{ o.trigger_price }} 止损:{{ o.stop_loss }} 止盈:{{ o.take_profit }} - | 盈亏比:{% if o.rr_ratio is not none %}1:{{ '%.2f'|format(o.rr_ratio) }}{% else %}-{% endif %} - | 现价:- - | 浮盈亏:- - | 保证金:{{ o.margin_capital }}U | 杠杆:{{ o.leverage }}x | 仓位占比:{{ o.position_ratio }}% +
+
+
+ {{ o.exchange_symbol or o.symbol }} + {{ '做多' if o.direction == 'long' else '做空' }} +
+
+ + 平仓 +
+
+
+ 来源: {{ o.monitor_type|default('下单监控', true) }}{% if o.key_signal_type %} · {{ o.key_signal_type }}{% endif %} + 风格: {{ o.trade_style or 'trend' }} + 风险: {{ o.risk_percent or '-' }}%≈{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U + + {% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %} + +
+
+
+ 成交价 + {{ price_fmt(o.symbol, o.trigger_price) }} +
+
+ 止损 + {% if o.stop_loss %} + {{ price_fmt(o.symbol, o.stop_loss) }} + {% else %} + + {% endif %} +
+
+ 止盈 + {% if o.take_profit %} + {{ price_fmt(o.symbol, o.take_profit) }} + {% else %} + + {% endif %} +
+
+ 盈亏比 + {% if o.rr_ratio is not none %}{{ '%g'|format(o.rr_ratio) }}:1{% else %}-:1{% endif %} +
+
+ 标记价 + - +
+
+ 浮盈亏 + - +
+
+ +
+
交易所止盈止损
+
+ 止损:加载中… + +
+
+ 止盈:加载中… + +
- 平仓
+ {% else %} +
暂无持仓
{% endfor %}
+ +
+
+

挂止盈止损

+

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

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

交易记录 & 错过机会

@@ -374,18 +563,18 @@ {{ r.symbol }} {{ r.monitor_type }}{% if r.key_signal_type %} · {{ r.key_signal_type }}{% endif %} {{ '做多' if r.direction == 'long' else '做空' }} - {{ r.trigger_price }} + {{ price_fmt(r.symbol, r.trigger_price) }} {% set stop_show = r.display_open_stop_loss or r.initial_stop_loss or r.stop_loss %} {% set tp_show = r.effective_take_profit or r.take_profit %} {{ price_fmt(r.symbol, stop_show) }} {{ price_fmt(r.symbol, tp_show) }} - {{ r.margin_capital or '-' }} + {% if r.margin_capital is not none and r.margin_capital != '' %}{{ funds_fmt(r.margin_capital) }}{% else %}-{% endif %} {{ r.leverage or '-' }} {{ r.effective_hold_minutes or 0 }} {{ (r.effective_opened_at or '-')[:16] }} {{ (r.effective_closed_at or r.created_at or '-')[:16] }} {% set pnl_val = (r.effective_pnl_amount or 0)|float %} - {{ r.effective_pnl_amount or 0 }} + {{ funds_fmt(r.effective_pnl_amount or 0) }}{% if r.display_pnl_source == 'exchange' %}{% elif r.display_pnl_source != 'reviewed' %}{% endif %} {% set effective_result = r.effective_result %} {% if effective_result in ["止盈","保本止盈","移动止盈"] %}{{ effective_result }} @@ -471,7 +660,7 @@ - {% for er in entry_reason_options %} @@ -541,6 +730,8 @@
+
+
{% endif %}
@@ -596,9 +787,24 @@ \ No newline at end of file diff --git a/crypto_monitor_okx/使用说明.md b/crypto_monitor_okx/使用说明.md index eae3de7..6ee5671 100644 --- a/crypto_monitor_okx/使用说明.md +++ b/crypto_monitor_okx/使用说明.md @@ -1,57 +1,136 @@ -# 使用说明 - -**本文件对应仓库:`crypto_monitor_okx`(OKX USDT 永续)。** -界面与 Binance / Gate 主站版基本一致,差异在 **`.env` 的 `OKX_*` 变量** 与 OKX 合约 API(含 Passphrase)。 - -**部署(SSH SOCKS、PM2、备份)** 见同目录 **[部署文档.md](./部署文档.md)**。 -**策略交易、AI 复盘** 为四所共用根目录逻辑,见 **[策略交易说明.md](../策略交易说明.md)**、**[AI复盘与模型配置说明.md](../AI复盘与模型配置说明.md)**。 - ---- - -## 1. 它能做什么 - -| 模块 | 说明 | -|------|------| -| **关键位监控** | 5m 收线门控、企业微信推送;部分类型可自动市价开仓。 | -| **实盘下单监控** | 以损定仓、条件止盈止损、移动保本等。 | -| **策略交易** | 顶栏 **`/strategy`**:左 **趋势回调**、右 **顺势加仓**(双栏并列)。 | -| **交易记录 / 复盘** | 归档、导出;可选 **AI 复盘**(`.env` 中 `AI_PROVIDER` 等)。 | -| **统计分析** | 按北京时间切日的统计(与顶栏 UTC 列表窗无关)。 | - ---- - -## 2. 运行前必须配置(`.env`) - -```bash -cp -n .env.example .env -nano .env -``` - -| 类别 | 说明 | -|------|------| -| **登录** | `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` | -| **实盘开关** | `LIVE_TRADING_ENABLED=false` 仅本地逻辑,不测真下单 | -| **AI 复盘** | 默认 `AI_PROVIDER=openai`,`OPENAI_API_BASE=https://op.bz121.com/v1`、`OPENAI_API_KEY`、`OPENAI_MODEL=gemma4:e4b`;或 `ollama` + `OLLAMA_API` / `AI_MODEL` | - ---- - -## 3. 启动与顶栏 - -1. 按 **部署文档** 建 venv、装依赖、配 SOCKS(如需)。 -2. `python app.py` 或 PM2 `ecosystem.config.cjs`(须 **`PYTHONPATH=..`**)。 -3. 浏览器登录后顶栏:**关键位监控** | **实盘下单** | **策略交易** | **交易记录与复盘** | **统计分析**。 - -旧链接 `/strategy/trend`、`/strategy/roll` 会重定向到 **`/strategy`**。 - ---- - -## 4. 相关文档 - -| 文档 | 内容 | -|------|------| -| [部署文档.md](./部署文档.md) | Ubuntu、PM2、`.env`、备份 | -| [策略交易说明.md](../策略交易说明.md) | 趋势回调 + 顺势加仓 | -| [AI复盘与模型配置说明.md](../AI复盘与模型配置说明.md) | OpenAI 网关 / Ollama | -| [crypto_monitor_binance/使用说明.md](../crypto_monitor_binance/使用说明.md) | 关键位细则(流程与 OKX 版对齐) | +# 使用说明 + +**本文件对应仓库:`crypto_monitor_okx`(OKX USDT 本位永续)。** +功能、界面与 **Gate.io USDT 永续版**(目录 `crypto_monitor_gate`)基本一致,差异主要在 **`.env` 里交易所密钥与部分参数名**(`OKX_*` / `GATE_*`),文末有对照。 + +**部署、代理、PM2 等**请参考本仓库说明或 **`crypto_monitor_gate`** 下的 **`部署文档.md`**(该文以 Gate + SSH SOCKS 为例;OKX 侧将 API 与密钥改为 `OKX_*` 即可类比)。 +**关键位自动开仓的规则、RR、结案原因**见本目录 **`关键位自动下单说明.md`**。 + +--- + +## 1. 它能做什么 + +面向个人盘面的 **Web 控制台**,主要能力包括: + +| 模块 | 说明 | +|------|------| +| **关键位监控** | 录入上/下沿与类型,按 **5m 收线** 做硬条件过滤;符合条件后 **企业微信** 提醒,部分类型可 **自动市价开仓**(见第 4 节与专门文档)。 | +| **实盘下单监控** | 手工填止损/止盈,**以损定仓** 市价开单,挂上条件止盈止损,并在页面跟踪浮盈亏、保本逻辑等。 | +| **交易记录 / 复盘** | 平仓结果、盈亏、错过的单等归档与导出;可选 **AI 复盘**(见仓库根 [AI复盘与模型配置说明.md](../AI复盘与模型配置说明.md))。 | +| **策略交易** | 顶栏 `/strategy`:**趋势回调**(左)与 **顺势加仓**(右)左右并列;细则见 [策略交易说明.md](../策略交易说明.md)。 | + +后台按 **`MONITOR_POLL_SECONDS`**(默认几秒)轮询行情与监控逻辑。**切勿**在未理解规则时同时运行两套程序共用一个实盘账户。 + +--- + +## 2. 运行前必须配置(`.env`) + +首次在本目录执行 **`cp .env.example .env`**,再编辑 `.env`(`.env` 勿提交 Git;`git pull` 不会改你的 `.env`,升级前建议 `cp .env .env.backup.$(date +%Y%m%d)`)。 + +至少检查以下项(具体键名以 **`.env.example`** 为准): + +| 类别 | 说明 | +|------|------| +| **登录网页** | `APP_PASSWORD`:打开站点后的登录口令。`FLASK_SECRET_KEY`:Session 密钥,请勿使用默认值。 | +| **企业微信** | `WECHAT_WEBHOOK`:告警与关键位推送机器人的 Webhook。 | +| **是否真下单** | `LIVE_TRADING_ENABLED=false`:**不会**向交易所发送开仓指令(适合测试流程)。改为 `true` 且密钥正确才会实盘。 | +| **交易所 API** | **本仓库:** `OKX_API_KEY`、`OKX_API_SECRET`;永续相关见 `OKX_TD_MODE`、`OKX_POS_MODE`、`OKX_TRIGGER_WORKING_TYPE` 等。**勿**把 `.env` 提交到 Git。 | +| **关键位 RR / 止损外扩** | `KEY_AUTO_MIN_PLANNED_RR`、`KEY_STOP_OUTSIDE_BREAKOUT_PCT`(详见 `关键位自动下单说明.md`)。 | +| **AI 复盘** | 默认 `AI_PROVIDER=openai`,`OPENAI_API_BASE=https://op.bz121.com/v1`、`OPENAI_API_KEY`、`OPENAI_MODEL=gemma4:e4b`;或 `AI_PROVIDER=ollama` + `OLLAMA_API` / `AI_MODEL`。详见 [AI复盘与模型配置说明.md](../AI复盘与模型配置说明.md)。 | + +网络需要代理时可配置 **`OKX_SOCKS_PROXY` / `OKX_HTTP_PROXY`**(与 Gate 版 `GATE_*_PROXY` 用法类似)。 + +--- + +## 3. 如何启动与登录 + +1. 准备 Python 虚拟环境并安装依赖(如 `flask`、`requests`、`ccxt`、按需 `Pillow`、`PySocks` 等),配置好 `.env`。 +2. 启动 Flask 应用(可用 **`ecosystem.config.cjs`** 交给 PM2,或本地 `python app.py` / `flask run`,以你当前脚本为准)。 +3. 浏览器访问站点,打开 **`/login`**,使用 **`.env` 里的 `APP_PASSWORD`** 登录。 + +登录后顶栏:**关键位监控** | **实盘下单**(默认首页)| **策略交易**(`/strategy`,趋势回调 + 顺势加仓双栏)| **交易记录与复盘** | **统计分析**。 + +--- + +## 4. 关键位监控(顶栏「关键位监控」→ `/key_monitor`) + +### 4.1 添加一条关键位 + +1. **币种**:如 `BTC` 或 `BTC/USDT`(会规范成内部符号)。 +2. **类型**(必选其一): + + | 类型 | 行为摘要 | + |------|----------| + | **箱体突破** | 通过门控且计划 RR 达标 → **自动市价开仓**(需 `LIVE_TRADING_ENABLED=true` 且无其他持仓占位)。结案后本条从列表消失并记入历史。 | + | **收敛突破** | 同上(自动开仓类)。 | + | **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 | + | **关键支撑位** | 同上(仅提醒)。 | + +3. **方向**:做多 / 做空(必选)。 +4. **上沿 / 下沿**:必填;保存时会按交易所 **价格精度** 取整。 + +**限制:** +活跃持仓数达到 **`MAX_ACTIVE_POSITIONS`**(默认 1)时,**不允许**再添加「**箱体突破** / **收敛突破**」;仍可添加「**关键阻力位 / 支撑位**」。 +若 **4h EMA55** 与你的方向逆势,页面会 **额外 Flash 提示**,**不阻挡**提交。 + +### 4.2 触发后会发生什么(简版) + +- **箱体 / 收敛**:门控通过后算计划 SL/TP 与 RR;不达标 → 微信说明 + **`rr_insufficient`** 结案;达标 → **市价开仓**,成功 **`auto_opened`** / 失败 **`exchange_failed`**,均不重试同一关键位。 +- **阻力 / 支撑**:仅 **单次推送** → **`key_level_alert_only`** 结案。 + +详细公式与字段见 **`关键位自动下单说明.md`**。 + +### 4.3 列表与历史 + +当前条目与历史记录的用法与 Gate 版相同;结案后可在历史区查阅 **`close_reason`**。 + +--- + +## 5. 实盘下单(顶栏「实盘下单」→ `/trade`) + +- 持仓上限由 **`MAX_ACTIVE_POSITIONS`** 控制(默认 1)。 +- **人工开仓**计划盈亏比不得低于 **`MANUAL_MIN_PLANNED_RR`**(默认 1.4:1)。 +- 填写币种、方向、杠杆(可选)、止损/止盈(价格或百分比按表单)。 +- 移动保本等选项按页面与 `.env` 默认。 + +开仓成功后卡片 **「来源」**:手工一般为 **下单监控**;关键位自动为 **关键位监控**。 + +--- + +## 6. 企业微信 + +推送逻辑与 Gate 版一致;未配置 **`WECHAT_WEBHOOK`** 时可能没有消息,请以 **交易所端** 核对持仓与挂单。 + +--- + +## 7. 强烈建议的风险与运维习惯 + +1. **先用 `LIVE_TRADING_ENABLED=false`** 熟悉流程再实盘。 +2. **API 权限**最小化,密钥勿泄露。 +3. **同一账户避免多程序重复开仓**。 +4. **自动备份**:服务器上执行 `bash scripts/install_backup_cron.sh`(每天北京时间 0:00 → `/root/backups`,保留 30 天);升级前也可 `bash scripts/backup_data.sh` 手动跑一次。 +5. 升级代码后留意 **首轮启动**有无数据库迁移报错。 + +--- + +## 8. 常见问题(简要) + +| 现象 | 可自查 | +|------|--------| +| 关键位永远不触发 | 门控五项、日成交量排名、`KLINE_TIMEFRAME`。 | +| 有信号但不自动开仓 | `LIVE_TRADING_ENABLED`、RR 阈值、是否已有持仓、API/保证金错误信息。 | +| 加不了箱体/收敛 | 是否已有持仓。 | +| 推送收不到 | Webhook、网络。 | + +--- + +## 9. 与币安版(`crypto_monitor_binance`)差异速查 + +| 项目 | OKX 本仓库 | 币安版 | +|------|------------|--------| +| API 变量 | `OKX_API_KEY`、`OKX_API_SECRET`、`OKX_API_PASSPHRASE` | `BINANCE_API_KEY`、`BINANCE_API_SECRET` | +| 代理 | `OKX_SOCKS_PROXY` | `BINANCE_SOCKS_PROXY` | +| 默认端口 | 常为 `5004` | 常为 `5001` | +| TP/SL 实现 | `_okx_place_tp_sl_orders`、页面 `/api/order/.../cancel_tpsl` | `_binance_place_tp_sl_orders` | + +业务流程、顶栏分栏、策略交易、风控参数名已与币安版对齐;仅需更换目录与 `.env`。 diff --git a/crypto_monitor_okx/关键位自动下单说明.md b/crypto_monitor_okx/关键位自动下单说明.md new file mode 100644 index 0000000..230d87e --- /dev/null +++ b/crypto_monitor_okx/关键位自动下单说明.md @@ -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` | diff --git a/manual_trading_hub/使用说明.md b/manual_trading_hub/使用说明.md index c885043..60b4537 100644 --- a/manual_trading_hub/使用说明.md +++ b/manual_trading_hub/使用说明.md @@ -27,7 +27,7 @@ | id | 名称 | Flask | Agent | 监控能力(设置页勾选) | 默认启用 | |----|------|-------|-------|------------------------|----------| | 0 | 币安 | :5001 | :15200 | 关键位 | 是 | -| 1 | OKX | :5004 | :15201 | 关键位 | **否**(`HUB_DISABLED_IDS=1`) | +| 1 | OKX | :5004 | :15201 | 关键位 + 趋势计划(建议) | **否**(`HUB_DISABLED_IDS=1`,需用时在设置页启用) | | 2 | Gate 训练 | :5000 | :15202 | 关键位 | 是 | | 3 | Gate 趋势 | :5002 | :15203 | 趋势计划(默认不勾关键位) | 是 | diff --git a/scripts/align_okx_to_binance.py b/scripts/align_okx_to_binance.py new file mode 100644 index 0000000..110dd8b --- /dev/null +++ b/scripts/align_okx_to_binance.py @@ -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//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")