#!/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")