"""OKX 对冲计划:P0 测算页与 API 注册.""" from __future__ import annotations import os from typing import Any, Optional from flask import Flask, jsonify, request from jinja2 import ChoiceLoader, FileSystemLoader from lib.hedge_plan.hedge_plan_calc_lib import ( build_options_options_preview, build_perp_options_preview, floor_contracts_to_precision, gate_status, option_premium_total, suggest_contracts_from_notional, ) from lib.hub.hub_calculator_market_lib import amount_decimals_from_exchange from lib.trade.position_sizing_lib import ( compute_full_margin_sizing, load_position_sizing_mode, ) def _env_bool(key: str, default: bool = False) -> bool: raw = (os.getenv(key) or "").strip().lower() if not raw: return default return raw in ("1", "true", "yes", "on") def attach_hedge_plan_templates(app: Flask, repo_root: str) -> None: tpl_dir = os.path.join(repo_root, "lib", "hedge_plan", "templates") if not os.path.isdir(tpl_dir): return existing = app.jinja_loader loaders = [FileSystemLoader(tpl_dir)] if existing is not None: if isinstance(existing, ChoiceLoader): loaders = list(existing.loaders) + loaders else: loaders.insert(0, existing) app.jinja_loader = ChoiceLoader(loaders) def install_hedge_plan(app: Flask, repo_root: str, app_module: Any) -> None: attach_hedge_plan_templates(app, repo_root) cfg = _build_cfg(app_module) app.extensions["hedge_plan_cfg"] = cfg register_hedge_plan_routes(app, cfg) _maybe_start_monitor(cfg) def _build_cfg(app_module: Any) -> dict[str, Any]: from lib.exchange.okx_options_lib import ( build_option_chain, fetch_index_price, options_header_balances, place_option_limit_order, quote_option_contract, td_mode_for_option_buy, ) def _amount_to_precision(sym: str, amt: float) -> float: ex = getattr(app_module, "exchange", None) if ex is None: return float(amt) return float(ex.amount_to_precision(sym, amt)) return { "get_db": app_module.get_db, "login_required": app_module.login_required, "render_main_page": app_module.render_main_page, "exchange": getattr(app_module, "exchange", None), "exchange_options": getattr(app_module, "exchange_options", None), "get_available_trading_usdt": getattr(app_module, "get_available_trading_usdt", None), "get_contract_size": getattr(app_module, "get_contract_size", None), "normalize_exchange_symbol": getattr(app_module, "normalize_exchange_symbol", None), "ensure_markets_loaded": getattr(app_module, "ensure_markets_loaded", None), "ensure_okx_live_ready": getattr(app_module, "ensure_okx_live_ready", None), "place_exchange_order": getattr(app_module, "place_exchange_order", None), "close_exchange_order": getattr(app_module, "close_exchange_order", None), "get_live_position_contracts": getattr(app_module, "get_live_position_contracts", None), "amount_to_precision": _amount_to_precision, "build_option_chain": build_option_chain, "options_header_balances": options_header_balances, "quote_option_contract": quote_option_contract, "place_option_limit_order": place_option_limit_order, "td_mode_for_option_buy": td_mode_for_option_buy, "fetch_index_price": fetch_index_price, "options_td_mode": (os.getenv("OKX_OPTIONS_TD_MODE") or "isolated").strip(), "btc_leverage": int(getattr(app_module, "BTC_LEVERAGE", 10) or 10), "alt_leverage": int(getattr(app_module, "ALT_LEVERAGE", 5) or 5), "full_margin_buffer": float(getattr(app_module, "FULL_MARGIN_BUFFER_RATIO", 0.98) or 0.98), "funds_decimals": int(getattr(app_module, "FUNDS_DECIMALS", 2) or 2), "options_enabled": _env_bool("OKX_OPTIONS_ENABLED", False), "default_underly": (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper(), "chain_max_dte": float(os.getenv("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS") or os.getenv("OKX_OPTIONS_MAX_DTE_DAYS") or "14"), "perp_account_label": (os.getenv("OKX_ACCOUNT_LABEL") or "合约账户").strip(), "options_account_label": (os.getenv("OKX_OPTIONS_ACCOUNT_LABEL") or "期权账户").strip(), "trade_budget_usdc": float(os.getenv("OKX_OPTIONS_TRADE_BUDGET_USDC") or "10"), # 对冲专用缓冲;与期权页 OKX_OPTIONS_BUDGET_BUFFER 独立 "budget_buffer": float(os.getenv("HEDGE_PLAN_BUDGET_BUFFER") or "0.95"), "oo_bias_split_by": _oo_bias_split_by(), "oo_bias_ratio": _oo_bias_ratio(), "live_trading": _env_bool("LIVE_TRADING_ENABLED", False), "send_wechat": getattr(app_module, "send_wechat_msg", None), } def _hedge_enabled() -> bool: from lib.hedge_plan.okx_trade_mode_lib import hedge_module_enabled return hedge_module_enabled() def _show_perp_options() -> bool: from lib.hedge_plan.okx_trade_mode_lib import show_perp_options return show_perp_options() def _show_options_options() -> bool: from lib.hedge_plan.okx_trade_mode_lib import show_options_options return show_options_options() def _oo_close_mode_enabled() -> bool: return _env_bool("HEDGE_PLAN_OO_CLOSE_MODE_ENABLED", True) def _oo_bias_split_by() -> str: from lib.hedge_plan.hedge_plan_calc_lib import _normalize_oo_bias_split_by return _normalize_oo_bias_split_by(os.getenv("HEDGE_PLAN_OO_BIAS_SPLIT_BY") or "budget") def _oo_bias_ratio() -> float: from lib.hedge_plan.hedge_plan_calc_lib import _clamp_oo_bias_ratio return _clamp_oo_bias_ratio(os.getenv("HEDGE_PLAN_OO_BIAS_RATIO") or "0.7") def _normalize_oo_close_mode(raw: Any) -> str: """方案C关闭时强制 hold_expiry;开启时默认 close_all.""" if not _oo_close_mode_enabled(): return "hold_expiry" v = str(raw or "close_all").strip().lower() if v in ("hold_expiry", "hold_to_expiry", "expiry", "到期平"): return "hold_expiry" return "close_all" def _live_order() -> bool: return _env_bool("HEDGE_PLAN_LIVE_ORDER", False) def _max_active() -> int: try: return max(1, int(os.getenv("MAX_ACTIVE_HEDGE_PLANS") or "1")) except ValueError: return 1 def _gates_dict(cfg: dict[str, Any], plan_type: str) -> dict[str, Any]: active = 0 has_standalone = False mutual = True try: from lib.hedge_plan.hedge_options_exclusive_lib import ( has_standalone_option_position, mutual_exclusive_enabled, ) from lib.hedge_plan.hedge_plan_db import count_active_plans, init_hedge_plan_tables mutual = mutual_exclusive_enabled() conn = cfg["get_db"]() try: init_hedge_plan_tables(conn) active = count_active_plans(conn) if mutual: try: from lib.exchange.okx_options_lib import fetch_option_positions ex = cfg.get("exchange_options") or cfg.get("exchange") raw = fetch_option_positions(ex) if ex is not None else [] has_standalone = has_standalone_option_position(conn, raw or []) except Exception: has_standalone = True # fail-closed conn.commit() finally: conn.close() except Exception: # fail-closed:探测失败视为不可开仓 active = 10**9 has_standalone = True return gate_status( hedge_enabled=_hedge_enabled(), sizing_mode=load_position_sizing_mode(), plan_type=plan_type, options_enabled=bool(cfg.get("options_enabled")), live_order=_live_order(), live_trading=bool(cfg.get("live_trading")) or _env_bool("LIVE_TRADING_ENABLED", False), active_count=active, max_active=_max_active(), show_perp_options=_show_perp_options(), show_options_options=_show_options_options(), mutual_exclusive=mutual, has_standalone_option=has_standalone, ) def _gates_public(cfg: dict[str, Any], plan_type: str) -> dict[str, Any]: g = _gates_dict(cfg, plan_type) g["oo_close_mode_enabled"] = _oo_close_mode_enabled() g["oo_close_mode_default"] = "close_all" if _oo_close_mode_enabled() else "hold_expiry" g["oo_bias_split_by"] = _oo_bias_split_by() g["oo_bias_ratio"] = _oo_bias_ratio() g["budget_buffer"] = float(cfg.get("budget_buffer") or 0.95) return g def _maybe_start_monitor(cfg: dict[str, Any]) -> None: # 始终启动监控线程:单独期权模式下仍需收口遗留 active/partial 计划 with _hedge_start_lock(): if cfg.get("hedge_monitor_thread") is not None: return try: secs = float(os.getenv("HEDGE_PLAN_MONITOR_POLL_SECONDS") or "15") except ValueError: secs = 15.0 secs = max(5.0, secs) def _loop() -> None: import time from lib.hedge_plan.hedge_plan_monitor_lib import tick_active_plans while True: try: tick_active_plans(cfg) except Exception: pass time.sleep(secs) import threading t = threading.Thread(target=_loop, name="hedge-plan-monitor", daemon=True) t.start() cfg["hedge_monitor_thread"] = t _start_lock = None def _hedge_start_lock(): global _start_lock if _start_lock is None: import threading _start_lock = threading.Lock() return _start_lock def _start_body_json(body: dict[str, Any], missing_leg: Optional[str] = None) -> str: import json try: return json.dumps( {"start_body": body, "missing_leg": missing_leg}, ensure_ascii=False, )[:8000] except Exception: return "" def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any]) -> int: from lib.hedge_plan.hedge_plan_db import ( get_plan, get_plan_legs, init_hedge_plan_tables, insert_leg, insert_plan, ) from lib.hedge_plan.hedge_plan_notify_lib import notify_plan_start conn = cfg["get_db"]() try: init_hedge_plan_tables(conn) is_partial = bool(result.get("partial")) missing = str(result.get("missing_leg") or "") if is_partial else "" opt = result.get("option") or {} perp = result.get("perp") or {} if is_partial: opt_ok = missing != "option_hedge" and bool(result.get("option")) perp_ok = missing != "perp" and bool(result.get("perp")) else: opt_ok = True perp_ok = True premium = float((opt or {}).get("premium") or 0) if opt_ok else 0.0 from lib.hedge_plan.hedge_plan_option_primary_lib import ( is_option_primary, perp_direction_for_view, ) opt_primary = is_option_primary(body) view = str(body.get("direction") or "long") perp_dir = ( str((perp or {}).get("direction") or "") or (perp_direction_for_view(view) if opt_primary else view) ) plan_row = { "plan_type": "perp_options", "status": "partial" if is_partial else "active", "underlying": str(body.get("underlying") or "ETH").upper(), "direction": view, "entry_mark": float(body.get("entry") or 0), "tp": float(body.get("tp") or 0) if not opt_primary else 0, "sl": float(body.get("sl") or 0) if not opt_primary else 0, "sizing_mode_at_open": load_position_sizing_mode(), "perp_size": float((perp or {}).get("contracts") or body.get("contracts") or 0), "margin": body.get("margin"), "leverage": float(body.get("leverage") or (100 if opt_primary else 10)), "premium_total": premium, "preview_json": _start_body_json(body, missing or None), "close_reason": "partial_fail" if is_partial else None, "opened_at": result.get("opened_at"), "note": (result.get("msg") or "")[:500] if is_partial else None, "option_primary": 1 if opt_primary else 0, "perp_direction": perp_dir, } if opt_primary: plan_row.update( { "option_target_points": float(body.get("option_target_points") or 0), "perp_target_points": float(body.get("perp_target_points") or 0), "option_perp_ratio": float(body.get("option_perp_ratio") or 0), "premium_budget": float(body.get("premium_budget") or 0), "strike_interval": float(body.get("strike_interval") or 15), "min_option_hours": float(body.get("min_option_hours") or 36), "option_moneyness": str(body.get("moneyness") or body.get("option_moneyness") or ""), } ) plan_id = insert_plan(conn, plan_row) insert_leg( conn, { "plan_id": plan_id, "leg_role": "perp", "symbol": str(body.get("exchange_symbol") or ""), "side": perp_dir, "size": float((perp or {}).get("contracts") or body.get("contracts") or 0), "avg_open": float(body.get("entry") or 0) if perp_ok else None, "status": "open" if perp_ok else "pending", "exchange_ord_id": str((perp or {}).get("exchange_ord_id") or ""), "opened_at": result.get("opened_at") if perp_ok else None, }, ) insert_leg( conn, { "plan_id": plan_id, "leg_role": "option_hedge", "inst_id": str((opt or {}).get("inst_id") or body.get("opt_inst_id") or ""), "opt_type": str((opt or {}).get("opt_type") or body.get("opt_type") or ""), "strike": (opt or {}).get("strike") or body.get("strike"), "side": "buy", "size": float((opt or {}).get("sheets") or body.get("sheets") or 1), "avg_open": float((opt or {}).get("ask") or body.get("ask") or 0) if opt_ok else None, "premium": premium if opt_ok else 0, "ct_mult": float(body.get("ct_mult") or (opt or {}).get("ct_mult") or 0.01), "status": "open" if opt_ok else "pending", "exchange_ord_id": str((opt or {}).get("exchange_ord_id") or ""), "opened_at": result.get("opened_at") if opt_ok else None, }, ) conn.commit() if not is_partial: plan = get_plan(conn, plan_id) legs = get_plan_legs(conn, plan_id) if plan: notify_plan_start(cfg, conn, plan, legs) conn.commit() return plan_id finally: conn.close() def _persist_po_watching(cfg: dict[str, Any], body: dict[str, Any]) -> int: """以期权为主:只落库盯盘计划,不下单.""" from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables, insert_plan from lib.hedge_plan.hedge_plan_option_primary_lib import perp_direction_for_view conn = cfg["get_db"]() try: init_hedge_plan_tables(conn) view = str(body.get("direction") or "long") money = str(body.get("moneyness") or body.get("option_moneyness") or "otm").strip().lower() plan_id = insert_plan( conn, { "plan_type": "perp_options", "status": "watching", "underlying": str(body.get("underlying") or "ETH").upper(), "direction": view, "entry_mark": float(body.get("index_px") or body.get("entry") or 0) or None, "tp": 0, "sl": 0, "sizing_mode_at_open": None, "perp_size": None, "margin": None, "leverage": float(body.get("leverage") or 100), "premium_total": 0, "preview_json": _start_body_json(body), "close_reason": None, "opened_at": None, "note": "盯盘中:等待杠杆/间隔达标后自动开仓", "option_primary": 1, "perp_direction": perp_direction_for_view(view), "option_target_points": float(body.get("option_target_points") or 0), "perp_target_points": float(body.get("perp_target_points") or 0), "option_perp_ratio": float(body.get("option_perp_ratio") or 0), "premium_budget": float(body.get("premium_budget") or 0), "strike_interval": float(body.get("strike_interval") or 15), "min_option_hours": float(body.get("min_option_hours") or 36), "option_moneyness": money, "option_leverage": float(body.get("option_leverage") or 0), }, ) conn.commit() return plan_id finally: conn.close() def _activate_watching_po( cfg: dict[str, Any], conn: Any, plan_id: int, result: dict[str, Any], body: dict[str, Any], ) -> None: """盯盘命中后:写入腿并把 watching → active/partial.""" from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, insert_leg, update_plan from lib.hedge_plan.hedge_plan_notify_lib import notify_plan_start from lib.hedge_plan.hedge_plan_option_primary_lib import perp_direction_for_view is_partial = bool(result.get("partial")) missing = str(result.get("missing_leg") or "") if is_partial else "" opt = result.get("option") or {} perp = result.get("perp") or {} if is_partial: opt_ok = missing != "option_hedge" and bool(result.get("option")) perp_ok = missing != "perp" and bool(result.get("perp")) else: opt_ok = True perp_ok = True premium = float((opt or {}).get("premium") or 0) if opt_ok else 0.0 view = str(body.get("direction") or "long") perp_dir = ( str((perp or {}).get("direction") or "") or perp_direction_for_view(view) ) update_plan( conn, int(plan_id), status="partial" if is_partial else "active", entry_mark=float(body.get("entry") or body.get("index_px") or 0) or None, perp_size=float((perp or {}).get("contracts") or body.get("contracts") or 0), leverage=float(body.get("leverage") or 100), premium_total=premium, preview_json=_start_body_json(body, missing or None), close_reason="partial_fail" if is_partial else None, opened_at=result.get("opened_at"), note=(result.get("msg") or "")[:500] if is_partial else "盯盘达标已开仓", perp_direction=perp_dir, ) insert_leg( conn, { "plan_id": int(plan_id), "leg_role": "perp", "symbol": str(body.get("exchange_symbol") or ""), "side": perp_dir, "size": float((perp or {}).get("contracts") or body.get("contracts") or 0), "avg_open": float(body.get("entry") or 0) if perp_ok else None, "status": "open" if perp_ok else "pending", "exchange_ord_id": str((perp or {}).get("exchange_ord_id") or ""), "opened_at": result.get("opened_at") if perp_ok else None, }, ) insert_leg( conn, { "plan_id": int(plan_id), "leg_role": "option_hedge", "inst_id": str((opt or {}).get("inst_id") or body.get("opt_inst_id") or ""), "opt_type": str((opt or {}).get("opt_type") or body.get("opt_type") or ""), "strike": (opt or {}).get("strike") or body.get("strike"), "side": "buy", "size": float((opt or {}).get("sheets") or body.get("sheets") or 1), "avg_open": float((opt or {}).get("ask") or body.get("ask") or 0) if opt_ok else None, "premium": premium if opt_ok else 0, "ct_mult": float(body.get("ct_mult") or (opt or {}).get("ct_mult") or 0.01), "status": "open" if opt_ok else "pending", "exchange_ord_id": str((opt or {}).get("exchange_ord_id") or ""), "opened_at": result.get("opened_at") if opt_ok else None, }, ) if not is_partial: plan = get_plan(conn, int(plan_id)) legs = get_plan_legs(conn, int(plan_id)) if plan: notify_plan_start(cfg, conn, plan, legs) def _persist_oo(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any]) -> int: from lib.hedge_plan.hedge_plan_db import ( get_plan, get_plan_legs, init_hedge_plan_tables, insert_leg, insert_plan, ) from lib.hedge_plan.hedge_plan_notify_lib import notify_plan_start conn = cfg["get_db"]() try: init_hedge_plan_tables(conn) is_partial = bool(result.get("partial")) missing = str(result.get("missing_leg") or "") if is_partial else "" a = result.get("leg_a") or {} b = result.get("leg_b") or {} a_ok = True if not is_partial else bool(result.get("leg_a")) b_ok = True if not is_partial else (missing != "option_b" and bool(result.get("leg_b"))) premium = (float(a.get("premium") or 0) if a_ok else 0.0) + ( float(b.get("premium") or 0) if b_ok else 0.0 ) rr_raw = body.get("profit_rr") try: profit_rr = float(rr_raw) if rr_raw not in (None, "") else 2.0 except (TypeError, ValueError): profit_rr = 2.0 if profit_rr <= 0: profit_rr = 2.0 # 旧字段兼容:不再要求上/下破;有传则原样落库 def _opt_float(key: str, *alts: str) -> float | None: for k in (key, *alts): v = body.get(k) if v not in (None, ""): try: return float(v) except (TypeError, ValueError): continue return None up_f = _opt_float("target_price_up", "target_price") down_f = _opt_float("target_price_down", "target_price") plan_id = insert_plan( conn, { "plan_type": "options_options", "status": "partial" if is_partial else "active", "underlying": str(body.get("underlying") or "ETH").upper(), "target_price": up_f, "target_price_up": up_f, "target_price_down": down_f, "profit_rr": profit_rr, "sizing_mode_at_open": load_position_sizing_mode(), "premium_total": premium, "oo_close_mode": _normalize_oo_close_mode(body.get("oo_close_mode")), "preview_json": _start_body_json(body, missing or None), "close_reason": "partial_fail" if is_partial else None, "opened_at": result.get("opened_at"), "note": (result.get("msg") or "")[:500] if is_partial else None, }, ) for role, res, src, ok in ( ("option_a", a, body.get("leg_a") or {}, a_ok), ("option_b", b, body.get("leg_b") or {}, b_ok), ): insert_leg( conn, { "plan_id": plan_id, "leg_role": role, "inst_id": str((res or {}).get("inst_id") or src.get("inst_id") or ""), "opt_type": str((res or {}).get("opt_type") or src.get("opt_type") or ""), "strike": (res or {}).get("strike") or src.get("strike"), "side": "buy", "size": float((res or {}).get("sheets") or src.get("sheets") or 1), "avg_open": float((res or {}).get("ask") or 0) if ok else None, "premium": float((res or {}).get("premium") or 0) if ok else 0, "status": "open" if ok else "pending", "exchange_ord_id": str((res or {}).get("exchange_ord_id") or ""), "opened_at": result.get("opened_at") if ok else None, }, ) conn.commit() if not is_partial: plan = get_plan(conn, plan_id) legs = get_plan_legs(conn, plan_id) if plan: notify_plan_start(cfg, conn, plan, legs) conn.commit() return plan_id finally: conn.close() def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None: lr = cfg["login_required"] @app.route("/hedge-plan") @lr def page_hedge_plan(): from lib.instance.instance_embed_lib import redirect_to_embed_shell_if_enabled redir = redirect_to_embed_shell_if_enabled("hedge_plan") if redir is not None: return redir return cfg["render_main_page"]("hedge_plan") @app.route("/api/hedge-plan/gates") @lr def api_hedge_gates(): plan_type = (request.args.get("plan_type") or "perp_options").strip() return jsonify({"ok": True, **_gates_public(cfg, plan_type)}) @app.route("/api/hedge-plan/market") @lr def api_hedge_market(): base = (request.args.get("base") or cfg.get("default_underly") or "ETH").strip().upper() if base not in ("BTC", "ETH"): return jsonify({"ok": False, "msg": "对冲计划仅支持 BTC/ETH"}), 400 direction = (request.args.get("direction") or "long").strip().lower() if direction not in ("long", "short"): direction = "long" option_primary = (request.args.get("option_primary") or "").strip().lower() in ( "1", "true", "yes", "on", ) data, err = _fetch_perp_market(cfg, base) if err: return jsonify({"ok": False, "msg": err}), 400 sizing_mode = load_position_sizing_mode() gates = _gates_dict(cfg, "perp_options") if option_primary: from lib.hedge_plan.hedge_plan_option_primary_lib import ( opt_type_for_view, perp_direction_for_view, ) suggested = opt_type_for_view(direction) perp_dir = perp_direction_for_view(direction) acct_note = "以期权为主:看法腿买期权,永续反向对冲" else: suggested = "P" if direction == "long" else "C" perp_dir = direction acct_note = "永续腿使用合约(交易)账户可用 USDT" out = { "ok": True, "base": base, "direction": direction, "option_primary": option_primary, "suggested_opt_type": suggested, "perp_direction": perp_dir, **data, "gates": gates, "sizing_mode": sizing_mode, "account_kind": "perp", "account_label": cfg.get("perp_account_label") or "合约账户", "account_note": acct_note, } return jsonify(out) @app.route("/api/hedge-plan/options-chain") @lr def api_hedge_options_chain(): if not cfg.get("options_enabled"): return jsonify({"ok": False, "msg": "期权模块未启用"}), 400 ex = cfg.get("exchange_options") if ex is None: return jsonify({"ok": False, "msg": "期权交易所未初始化"}), 400 u = (request.args.get("underlying") or cfg.get("default_underly") or "ETH").upper() # 热更新:链展示天数每次读 env chain_max_dte = float( os.getenv("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS") or os.getenv("OKX_OPTIONS_MAX_DTE_DAYS") or cfg.get("chain_max_dte") or 14 ) try: chain = cfg["build_option_chain"]( ex, u, max_dte_days=chain_max_dte, itm_only=False, itm_max_dist_usd=float(os.getenv("OKX_OPTIONS_ITM_MAX_DIST_USD") or "30"), ) except Exception as e: return jsonify({"ok": False, "msg": f"拉取期权链失败: {e}"}), 500 # 可选:永期以期权为主时按最低剩余小时/行权间隔过滤(仅当请求显式带 option_primary) # 默认拉链不再带此过滤,避免期期看不到明天到期 option_primary = (request.args.get("option_primary") or "").strip().lower() in ( "1", "true", "yes", "on", ) min_hours = None strike_interval = None try: if request.args.get("min_hours") not in (None, ""): min_hours = float(request.args.get("min_hours")) except (TypeError, ValueError): min_hours = 36.0 if option_primary else None try: if request.args.get("strike_interval") not in (None, ""): strike_interval = float(request.args.get("strike_interval")) except (TypeError, ValueError): strike_interval = 15.0 if option_primary else None if option_primary and min_hours is None: min_hours = 36.0 if option_primary and strike_interval is None: strike_interval = 15.0 if min_hours is not None or strike_interval is not None: from lib.hedge_plan.hedge_plan_option_primary_lib import hours_to_expiry_from_ms idx = None try: idx = float(chain.get("index_px") or 0) or None except (TypeError, ValueError): idx = None filtered = [] for exp in chain.get("expiries") or []: h = hours_to_expiry_from_ms(exp.get("exp_time")) if min_hours is not None and h is not None and h < min_hours: continue contracts = [] for c in exp.get("contracts") or []: row = dict(c) row["hours_to_expiry"] = h if strike_interval is not None and idx and idx > 0: try: k = float(row.get("strike") or 0) except (TypeError, ValueError): k = 0.0 if k > 0 and abs(k - idx) > strike_interval + 1e-9: continue contracts.append(row) if contracts: filtered.append({**exp, "contracts": contracts, "hours_to_expiry": h}) chain = {**chain, "expiries": filtered} opt_acct = _options_account_snapshot(cfg) return jsonify( { "ok": True, **chain, "underlying": u, "chain_max_dte_days": chain_max_dte, "account_kind": "options", "account_label": cfg.get("options_account_label") or "期权账户", "account_note": "期权腿使用期权账户(交易 USDC)", "options_account": opt_acct, "trade_budget_usdc": cfg.get("trade_budget_usdc"), "budget_buffer": cfg.get("budget_buffer"), "option_primary": option_primary, "min_hours": min_hours, "strike_interval": strike_interval, } ) @app.route("/api/hedge-plan/preview", methods=["POST"]) @lr def api_hedge_preview(): body = request.get_json(silent=True) or {} plan_type = (body.get("plan_type") or "perp_options").strip().lower() gates = _gates_dict(cfg, plan_type) if not gates.get("can_preview"): return jsonify({"ok": False, "msg": "; ".join(gates.get("reasons") or ["不可测算"]), "gates": gates}), 400 try: if plan_type == "options_options": data = _preview_oo(body) else: data = _preview_po(body) except ValueError as e: return jsonify({"ok": False, "msg": str(e)}), 400 except Exception as e: return jsonify({"ok": False, "msg": f"测算失败: {e}"}), 500 return jsonify({"ok": True, "gates": gates, **data}) @app.route("/api/hedge-plan/validate-path", methods=["POST"]) @lr def api_hedge_validate_path(): """只校验下单路径(强制 dry_run),不真实成交.""" from lib.hedge_plan.hedge_plan_orders_lib import ( execute_options_options_start, execute_perp_options_start, validate_start_body, ) body = request.get_json(silent=True) or {} plan_type = (body.get("plan_type") or "perp_options").strip().lower() err = validate_start_body(plan_type, body) if err: return jsonify({"ok": False, "msg": err}), 400 if plan_type == "options_options": out = execute_options_options_start(cfg, body, dry_run=True) else: out = execute_perp_options_start(cfg, body, dry_run=True) return jsonify(out), (200 if out.get("ok") else 400) @app.route("/api/hedge-plan/start", methods=["POST"]) @lr def api_hedge_start(): from lib.hedge_plan.hedge_plan_orders_lib import ( execute_options_options_start, execute_perp_options_start, validate_start_body, ) body = request.get_json(silent=True) or {} plan_type = (body.get("plan_type") or "perp_options").strip().lower() dry_run = bool(body.get("dry_run")) or _env_bool("HEDGE_PLAN_DRY_RUN", False) try: from lib.options.options_margin_mode_lib import is_coin_margin_mode if is_coin_margin_mode() and not dry_run: return jsonify( { "ok": False, "msg": "当前单笔期权为币本位模式,对冲计划仅支持 USDC 期权;请将 OKX_OPTIONS_MARGIN_MODE=usdc 并重启后再开对冲", } ), 400 except Exception as e: return jsonify( {"ok": False, "msg": f"期权本位校验失败,已拒绝开对冲: {e}"} ), 400 with _hedge_start_lock(): gates = _gates_dict(cfg, plan_type) if not dry_run and not gates.get("can_start"): return jsonify( {"ok": False, "msg": "; ".join(gates.get("reasons") or ["不可开仓"]), "gates": gates} ), 400 err = validate_start_body(plan_type, body) if err: return jsonify({"ok": False, "msg": err, "gates": gates}), 400 # 补齐永续杠杆(以期权为主默认 100;保险模式 BTC/ETH 用 btc_leverage) if plan_type == "perp_options" and not body.get("leverage"): from lib.hedge_plan.hedge_plan_option_primary_lib import is_option_primary if is_option_primary(body): body["leverage"] = 100 else: base = str(body.get("underlying") or "ETH").upper() if base in ("BTC", "ETH"): body["leverage"] = int(cfg.get("btc_leverage") or 10) else: body["leverage"] = int(cfg.get("alt_leverage") or 5) # 以期权为主:策略启动=盯盘,不现场开仓 if plan_type == "perp_options": from lib.hedge_plan.hedge_plan_option_primary_lib import is_option_primary watch = body.get("watch_entry") watch_on = watch in (None, "", True, 1, "1", "true", "yes", "on") if is_option_primary(body) and watch_on: if dry_run: return jsonify( { "ok": True, "dry_run": True, "watching": True, "msg": "dry_run:将创建盯盘计划(不落库)", "gates": gates, } ) plan_id = _persist_po_watching(cfg, body) return jsonify( { "ok": True, "watching": True, "plan_id": plan_id, "msg": "已启动盯盘,杠杆/间隔达标后自动开仓", "gates": gates, } ) if plan_type == "options_options": out = execute_options_options_start( cfg, body, dry_run=dry_run, persist=(None if dry_run else (lambda r, b: _persist_oo(cfg, r, b))), ) else: out = execute_perp_options_start( cfg, body, dry_run=dry_run, persist=(None if dry_run else (lambda r, b: _persist_po(cfg, r, b))), ) out["gates"] = gates return jsonify(out), (200 if out.get("ok") else 400) @app.route("/api/hedge-plan//end", methods=["POST"]) @lr def api_hedge_end_plan(plan_id: int): """人工结束进行中计划:不自动平仓;未成交腿改为 cancelled.""" from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables from lib.hedge_plan.hedge_plan_orders_lib import execute_manual_end_plan conn = cfg["get_db"]() try: init_hedge_plan_tables(conn) out = execute_manual_end_plan(cfg, conn, plan_id) if not out.get("ok"): return jsonify(out), 400 conn.commit() finally: conn.close() return jsonify(out) @app.route("/api/hedge-plan//complete-leg", methods=["POST"]) @lr def api_hedge_complete_leg(plan_id: int): """半腿待补:手动补开缺失腿,成功后升为 active.""" import json from lib.hedge_plan.hedge_plan_db import ( get_plan, get_plan_legs, init_hedge_plan_tables, update_leg, update_plan, ) from lib.hedge_plan.hedge_plan_notify_lib import notify_plan_start from lib.hedge_plan.hedge_plan_orders_lib import execute_complete_missing_leg body = request.get_json(silent=True) or {} dry_run = bool(body.get("dry_run")) or _env_bool("HEDGE_PLAN_DRY_RUN", False) if not dry_run and not _hedge_enabled(): return jsonify({"ok": False, "msg": "当前交易模式为单独期权,不可补开对冲腿"}), 400 conn = cfg["get_db"]() try: init_hedge_plan_tables(conn) plan = get_plan(conn, plan_id) if not plan: return jsonify({"ok": False, "msg": "计划不存在"}), 404 pt = str(plan.get("plan_type") or "") if pt == "perp_options" and not _show_perp_options(): return jsonify({"ok": False, "msg": "当前模式非永期对冲,不可补开"}), 400 if pt == "options_options" and not _show_options_options(): return jsonify({"ok": False, "msg": "当前模式非期期对冲,不可补开"}), 400 if str(plan.get("status") or "") != "partial": return jsonify({"ok": False, "msg": "仅半腿待补(partial)计划可补开"}), 400 legs = get_plan_legs(conn, plan_id) start_body: dict[str, Any] = {} try: meta = json.loads(plan.get("preview_json") or "{}") if isinstance(meta, dict): start_body = dict(meta.get("start_body") or {}) except Exception: start_body = {} if not start_body: return jsonify({"ok": False, "msg": "缺少开仓参数,无法补开"}), 400 # 允许请求体覆盖少量字段 for k in ("contracts", "leverage", "sheets", "tp", "sl"): if body.get(k) not in (None, ""): start_body[k] = body.get(k) out = execute_complete_missing_leg( cfg, plan, legs, start_body, dry_run=dry_run ) if not out.get("ok"): return jsonify(out), 400 if dry_run: return jsonify(out) fill = out.get("fill") or {} leg_id = out.get("leg_id") role = str(out.get("leg_role") or "") opened_at = out.get("opened_at") if leg_id: if role == "perp": update_leg( conn, int(leg_id), status="open", size=float(fill.get("contracts") or start_body.get("contracts") or 0), avg_open=float(start_body.get("entry") or plan.get("entry_mark") or 0), exchange_ord_id=str(fill.get("exchange_ord_id") or ""), opened_at=opened_at, ) update_plan( conn, plan_id, status="active", close_reason=None, note=None, perp_size=float(fill.get("contracts") or start_body.get("contracts") or 0), ) else: prem = float(fill.get("premium") or 0) update_leg( conn, int(leg_id), status="open", size=float(fill.get("sheets") or start_body.get("sheets") or 1), avg_open=float(fill.get("ask") or 0), premium=prem, exchange_ord_id=str(fill.get("exchange_ord_id") or ""), opened_at=opened_at, inst_id=str(fill.get("inst_id") or ""), ) old_prem = float(plan.get("premium_total") or 0) update_plan( conn, plan_id, status="active", close_reason=None, note=None, premium_total=old_prem + prem, ) conn.commit() plan2 = get_plan(conn, plan_id) legs2 = get_plan_legs(conn, plan_id) if plan2: notify_plan_start(cfg, conn, plan2, legs2) conn.commit() out["plan_id"] = plan_id out["status"] = "active" out["plan"] = plan2 out["legs"] = legs2 return jsonify(out) finally: conn.close() @app.route("/api/hedge-plan/list") @lr def api_hedge_list(): from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables, list_plans status = (request.args.get("status") or "").strip() or None plan_type = (request.args.get("plan_type") or "").strip() or None underlying = (request.args.get("underlying") or "").strip() or None conn = cfg["get_db"]() try: init_hedge_plan_tables(conn) rows = list_plans( conn, status=status, plan_type=plan_type, underlying=underlying, limit=80 ) conn.commit() finally: conn.close() return jsonify({"ok": True, "plans": rows}) @app.route("/api/hedge-plan/history") @lr def api_hedge_history(): from lib.hedge_plan.hedge_plan_db import ( attach_legs_to_plans, init_hedge_plan_tables, list_plans, ) conn = cfg["get_db"]() try: init_hedge_plan_tables(conn) rows = list_plans(conn, status="closed", limit=100) failed = list_plans(conn, status="failed", limit=50) cancelled = list_plans(conn, status="cancelled", limit=50) merged = attach_legs_to_plans(conn, rows + failed + cancelled) conn.commit() finally: conn.close() return jsonify({"ok": True, "plans": merged}) @app.route("/api/hedge-plan/active") @lr def api_hedge_active(): from lib.hedge_plan.hedge_plan_db import ( attach_legs_to_plans, init_hedge_plan_tables, list_plans, ) from lib.hedge_plan.hedge_plan_orders_lib import reconcile_unfilled_option_legs conn = cfg["get_db"]() try: init_hedge_plan_tables(conn) rows = [] for status in ("watching", "opening", "active", "partial"): rows.extend(list_plans(conn, status=status, limit=80)) rows.sort(key=lambda row: int(row.get("id") or 0), reverse=True) for row in rows: if str(row.get("status") or "") == "watching": continue try: reconcile_unfilled_option_legs(cfg, conn, int(row["id"])) except Exception: pass # 校正后可能 status 变化,重新拉一遍 rows = [] for status in ("watching", "opening", "active", "partial"): rows.extend(list_plans(conn, status=status, limit=80)) rows.sort(key=lambda row: int(row.get("id") or 0), reverse=True) plans = attach_legs_to_plans(conn, rows) conn.commit() finally: conn.close() return jsonify({"ok": True, "plans": plans}) @app.route("/api/hedge-plan/stats") @lr def api_hedge_stats(): from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables, stats_summary conn = cfg["get_db"]() try: init_hedge_plan_tables(conn) s = stats_summary(conn) conn.commit() finally: conn.close() return jsonify({"ok": True, **s}) @app.route("/api/hedge-plan/") @lr def api_hedge_detail(plan_id: int): from lib.hedge_plan.hedge_plan_db import ( get_plan, get_plan_legs, init_hedge_plan_tables, legs_contract_summary, ) from lib.hedge_plan.hedge_plan_orders_lib import reconcile_unfilled_option_legs conn = cfg["get_db"]() try: init_hedge_plan_tables(conn) plan = get_plan(conn, plan_id) if not plan: return jsonify({"ok": False, "msg": "计划不存在"}), 404 # 打开细节时校正:无成交却标 open → cancelled if str(plan.get("status") or "") in ("opening", "active", "partial"): reconcile_unfilled_option_legs(cfg, conn, plan_id) plan = get_plan(conn, plan_id) or plan legs = get_plan_legs(conn, plan_id) conn.commit() finally: conn.close() return jsonify( { "ok": True, "plan": plan, "legs": legs, "contracts_summary": legs_contract_summary(legs), } ) @app.route("/api/hedge-plan/", methods=["DELETE"]) @lr def api_hedge_delete(plan_id: int): from lib.hedge_plan.hedge_plan_db import delete_plan, init_hedge_plan_tables conn = cfg["get_db"]() try: init_hedge_plan_tables(conn) out = delete_plan(conn, plan_id) if not out.get("ok"): return jsonify(out), 400 conn.commit() finally: conn.close() return jsonify(out) @app.route("/api/hedge-plan/monitor-tick", methods=["POST"]) @lr def api_hedge_monitor_tick(): from lib.hedge_plan.hedge_plan_monitor_lib import tick_active_plans return jsonify(tick_active_plans(cfg)) def _preview_po(body: dict[str, Any]) -> dict[str, Any]: from lib.hedge_plan.hedge_plan_moneyness_lib import validate_po_option_moneyness from lib.hedge_plan.hedge_plan_option_primary_lib import ( build_option_primary_preview, is_option_primary, size_from_premium, validate_option_primary_start, ) if is_option_primary(body): err = validate_option_primary_start(body) if err: raise ValueError(err) sized = size_from_premium( premium_budget=float(body.get("premium_budget") or 0), ask=float(body.get("ask") or 0), ct_mult=float(body.get("ct_mult") or 0.01), ratio=float(body.get("option_perp_ratio") or 2), contract_size=float(body.get("contract_size") or 0.01), ) if not sized.get("ok"): raise ValueError(sized.get("msg") or "定仓失败") body = dict(body) body["sheets"] = sized["sheets"] body["contracts"] = sized["contracts"] body["eth_qty"] = sized["eth_qty"] if not body.get("entry"): body["entry"] = body.get("index_px") or 0 out = build_option_primary_preview(body) out["sizing"] = sized return out direction = str(body.get("direction") or "long").lower() entry = float(body["entry"]) tp = float(body["tp"]) sl = float(body["sl"]) contracts = float(body["contracts"]) contract_size = float(body.get("contract_size") or 0.01) opt_type = str(body.get("opt_type") or ("P" if direction == "long" else "C")) strike = float(body["strike"]) sheets = float(body.get("sheets") or 1) ct_mult = float(body.get("ct_mult") or 0.01) ask = body.get("ask") premium = body.get("premium_paid") if premium is None: if ask is None: raise ValueError("缺少权利金或卖一价") premium = option_premium_total(ask=float(ask), sheets=sheets, ct_mult=ct_mult) index_px = body.get("index_px") idx_for_money = float(index_px) if index_px is not None else entry money_err = validate_po_option_moneyness( opt_type=opt_type, strike=strike, index_px=idx_for_money, ask=ask, hours_to_expiry=body.get("hours_to_expiry"), ) if money_err: raise ValueError(money_err) return build_perp_options_preview( direction=direction, entry=entry, tp=tp, sl=sl, contracts=contracts, contract_size=contract_size, opt_type=opt_type, strike=strike, sheets=sheets, ct_mult=ct_mult, premium_paid=float(premium), index_px=float(index_px) if index_px is not None else None, ) def _preview_oo(body: dict[str, Any]) -> dict[str, Any]: from lib.hedge_plan.hedge_plan_moneyness_lib import validate_oo_legs_moneyness rr_raw = body.get("profit_rr") profit_rr = None if rr_raw not in (None, ""): profit_rr = float(rr_raw) if profit_rr <= 0: raise ValueError("盈亏比须大于0") up = body.get("target_price_up") down = body.get("target_price_down") legacy = body.get("target_price") if up in (None, "") and legacy not in (None, ""): up = legacy if down in (None, "") and legacy not in (None, ""): down = legacy if profit_rr is None and (up in (None, "") or down in (None, "")): raise ValueError("请填写盈亏比") up_f = float(up) if up not in (None, "") else None down_f = float(down) if down not in (None, "") else None if profit_rr is None and up_f is not None and down_f is not None and up_f <= down_f: raise ValueError("上破目标价必须大于下破目标价") index_px = body.get("index_px") if index_px in (None, ""): if up_f is not None and down_f is not None: index_px = (up_f + down_f) / 2 else: raise ValueError("缺少指数价格") index_px = float(index_px) leg_a = body.get("leg_a") or {} leg_b = body.get("leg_b") or {} for name, leg in (("leg_a", leg_a), ("leg_b", leg_b)): if not leg.get("strike"): raise ValueError(f"缺少 {name} 行权价") if leg.get("premium_paid") is None and leg.get("ask") is not None: leg["premium_paid"] = option_premium_total( ask=float(leg["ask"]), sheets=float(leg.get("sheets") or 1), ct_mult=float(leg.get("ct_mult") or 0.01), ) if leg.get("premium_paid") is None: raise ValueError(f"缺少 {name} 权利金") money_err = validate_oo_legs_moneyness(leg_a, leg_b, index_px=index_px) if money_err: raise ValueError(money_err) return build_options_options_preview( profit_rr=profit_rr, target_price_up=up_f, target_price_down=down_f, index_px=index_px, leg_a=leg_a, leg_b=leg_b, ) def _fetch_perp_market(cfg: dict[str, Any], base: str) -> tuple[dict[str, Any], str | None]: ex = cfg.get("exchange") if ex is None: return {}, "永续交易所未初始化" ensure = cfg.get("ensure_markets_loaded") if callable(ensure): try: ensure() except Exception as e: return {}, f"加载市场失败: {e}" norm = cfg.get("normalize_exchange_symbol") sym = f"{base}/USDT:USDT" if callable(norm): try: sym = norm(f"{base}/USDT") except Exception: sym = f"{base}/USDT:USDT" mark = bid = ask = last = None try: t = ex.fetch_ticker(sym) last = _sf(t.get("last")) mark = _sf(t.get("info", {}).get("markPx")) if isinstance(t.get("info"), dict) else None if mark is None: mark = _sf(t.get("mark")) or last bid = _sf(t.get("bid")) ask = _sf(t.get("ask")) except Exception as e: return {}, f"拉永续行情失败: {e}" cs = 0.01 get_cs = cfg.get("get_contract_size") if callable(get_cs): try: cs = float(get_cs(sym) or 0.01) except Exception: cs = 0.01 available = None get_av = cfg.get("get_available_trading_usdt") if callable(get_av): try: available = get_av() except Exception: available = None entry = float(mark or last or 0) sizing = None suggest_contracts = None amount_precision = 4 try: amount_precision = int(amount_decimals_from_exchange(ex, sym)) except Exception: amount_precision = 4 if available is not None and entry > 0: sizing, _serr = compute_full_margin_sizing( symbol=sym, available_usdt=float(available), capital_base=float(available), buffer_ratio=float(cfg.get("full_margin_buffer") or 0.98), btc_leverage=int(cfg.get("btc_leverage") or 10), alt_leverage=int(cfg.get("alt_leverage") or 5), funds_decimals=int(cfg.get("funds_decimals") or 2), ) if sizing: raw_contracts = suggest_contracts_from_notional( notional=float(sizing["notional_value"]), entry=entry, contract_size=cs, ) # 优先走交易所 amount_to_precision;失败则按精度位数向下取整 suggest_contracts = None try: precise = float(ex.amount_to_precision(sym, raw_contracts)) if precise > raw_contracts + 1e-12: precise = floor_contracts_to_precision(raw_contracts, amount_precision) suggest_contracts = precise except Exception: suggest_contracts = floor_contracts_to_precision(raw_contracts, amount_precision) return { "exchange_symbol": sym, "mark": mark, "last": last, "bid": bid, "ask": ask, "contract_size": cs, "available_usdt": available, "full_margin_sizing": sizing, "suggest_contracts": suggest_contracts, "amount_precision": amount_precision, "unit_quote": "USDT", "unit_contracts": "合约张", "unit_note": "价格单位 USDT;张数=交易所永续合约张(与下单精度一致);名义≈张数×面值×价格", "entry_ref": entry or None, }, None def _options_account_snapshot(cfg: dict[str, Any]) -> dict[str, Any]: """期权账户资金快照(与期权页同源: exchange_options).""" out: dict[str, Any] = { "label": cfg.get("options_account_label") or "期权账户", "trading_usdc": None, "funding_usdc": None, "trading_usdt": None, "funding_usdt": None, } ex = cfg.get("exchange_options") hdr = cfg.get("options_header_balances") if ex is None or not callable(hdr): return out try: trading_usdc, funding_usdc, funding_usdt, trading_usdt = hdr(ex, force=False) out.update( { "trading_usdc": trading_usdc, "funding_usdc": funding_usdc, "trading_usdt": trading_usdt, "funding_usdt": funding_usdt, } ) except Exception: pass return out def _sf(v: Any) -> float | None: if v is None or v == "": return None try: return float(v) except (TypeError, ValueError): return None