"""OKX 对冲计划:P0 测算页与 API 注册.""" from __future__ import annotations import os import time 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, gate_status, option_premium_total, ) from lib.trade.position_sizing_lib import 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: from lib.hedge_plan.hedge_po_mode_lib import clamp_hedge_po_mode_on_startup, public_mode_payload attach_hedge_plan_templates(app, repo_root) mode_summary = clamp_hedge_po_mode_on_startup() cfg = _build_cfg(app_module) cfg["po_mode"] = mode_summary.get("mode") cfg["po_mode_payload"] = public_mode_payload(mode_summary.get("mode")) 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, fetch_option_positions, 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), "get_live_position_exchange_metrics": getattr( app_module, "get_live_position_exchange_metrics", None ), "fetch_option_positions": fetch_option_positions, "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", True), "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(), "po_mode": (os.getenv("HEDGE_PO_MODE") or "").strip().lower(), "trade_budget_usdc": float( os.getenv("OKX_OPTIONS_COIN_BUDGET_USDT") or 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 sim_mode = False try: from lib.sim.mode_lib import is_sim_mode get_db = cfg.get("get_db") if callable(get_db): sim_mode = bool(is_sim_mode(get_db)) except Exception: sim_mode = False 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, sim_mode=sim_mode, ) 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, ) from lib.hedge_plan.hedge_po_mode_lib import ( apply_mode_to_start_body, get_hedge_po_mode, option_underlying, perp_underlying, ) body = apply_mode_to_start_body(body) 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) ) po_mode = str(body.get("po_mode") or get_hedge_po_mode()) plan_row = { "plan_type": "perp_options", "status": "partial" if is_partial else "active", "underlying": str(body.get("underlying") or option_underlying(po_mode)).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, "po_mode": po_mode, "perp_underlying": str(body.get("perp_underlying") or perp_underlying(po_mode)).upper(), } if opt_primary: # 实例模式强制组合盈利金额出场 exit_mode = "profit" 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 ""), "exit_mode": exit_mode, "profit_target_usdt": float(body.get("profit_target_usdt") or 0), } ) 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 from lib.hedge_plan.hedge_po_mode_lib import ( apply_mode_to_start_body, get_hedge_po_mode, option_underlying, perp_underlying, ) body = apply_mode_to_start_body(body) 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() po_mode = str(body.get("po_mode") or get_hedge_po_mode()) plan_id = insert_plan( conn, { "plan_type": "perp_options", "status": "watching", "underlying": str(body.get("underlying") or option_underlying(po_mode)).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), "exit_mode": "profit", "profit_target_usdt": float(body.get("profit_target_usdt") or 0), "po_mode": po_mode, "perp_underlying": str(body.get("perp_underlying") or perp_underlying(po_mode)).upper(), }, ) 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(): from lib.hedge_plan.hedge_po_mode_lib import ( get_hedge_po_mode, option_underlying, perp_underlying, public_mode_payload, ) mode = get_hedge_po_mode() mode_payload = public_mode_payload(mode) # 永续行情按实例模式的永续标的;跨币忽略前端乱传的 base base = perp_underlying(mode) req_base = (request.args.get("base") or "").strip().upper() if req_base in ("BTC", "ETH") and req_base == base: pass elif req_base and req_base != base: # 兼容旧前端:仍返回正确永续,并提示 pass direction = (request.args.get("direction") or "long").strip().lower() if direction not in ("long", "short"): direction = "long" # 实例模式强制以期权为主 option_primary = True 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") 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 = ( f"实例模式 {mode_payload['po_mode_label']}:期权 {option_underlying(mode)}" f" + 永续 {base};一子账户互不干涉" ) out = { "ok": True, "base": base, "option_underlying": option_underlying(mode), "perp_underlying": base, "direction": direction, "option_primary": True, "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, **mode_payload, } return jsonify(out) @app.route("/api/hedge-plan/po-mode") @lr def api_hedge_po_mode(): from lib.hedge_plan.hedge_po_mode_lib import public_mode_payload return jsonify({"ok": True, **public_mode_payload()}) @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 from lib.hedge_plan.hedge_po_mode_lib import get_hedge_po_mode, option_underlying # 期权链锁定实例模式的期权标的(跨币=BTC) u = option_underlying(get_hedge_po_mode()) req_u = (request.args.get("underlying") or "").strip().upper() if req_u in ("BTC", "ETH") and req_u != u: return jsonify( { "ok": False, "msg": f"本实例模式期权标的为 {u},不可拉 {req_u} 链", "option_underlying": u, } ), 400 # 热更新:链展示天数每次读 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": "期权腿使用币本位(交易账户 USDT 买币开期权)", "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) # CoinOX: 对冲期权腿固定币本位,不再拦截 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 # 补齐永续杠杆:页面未填时用 env(BTC_LEVERAGE / ALT_LEVERAGE=ETH) if plan_type == "perp_options" and not body.get("leverage"): from lib.hedge_plan.hedge_plan_option_primary_lib import is_option_primary base = str(body.get("underlying") or "ETH").upper() if base == "BTC": body["leverage"] = int(cfg.get("btc_leverage") or 10) elif base == "ETH": body["leverage"] = int(cfg.get("alt_leverage") or cfg.get("btc_leverage") or 10) elif is_option_primary(body): body["leverage"] = 100 else: body["leverage"] = int(cfg.get("alt_leverage") or 5) # 以期权为主:默认盯盘;手动下单(market_order)或 watch_entry=0 则现场开仓 if plan_type == "perp_options": from lib.hedge_plan.hedge_plan_option_primary_lib import is_option_primary market = body.get("market_order") in (True, 1, "1", "true", "yes", "on") watch = body.get("watch_entry") watch_off = watch in (False, 0, "0", "false", "no", "off") watch_on = (not market) and (not watch_off) and ( 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): """仅结束计划记录(不平仓).有持仓请用 /close.""" 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//close", methods=["POST"]) @lr def api_hedge_close_plan(plan_id: int): """人工平仓:期权→永续→卖币,再结束计划.""" from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables from lib.hedge_plan.hedge_plan_orders_lib import execute_manual_close_plan conn = cfg["get_db"]() try: init_hedge_plan_tables(conn) out = execute_manual_close_plan(cfg, conn, plan_id) if not out.get("ok"): # 半平成功时也要落库(期权已平/永续失败标记) try: conn.commit() except Exception: pass return jsonify(out), 400 conn.commit() finally: conn.close() return jsonify(out) @app.route("/api/hedge-plan//exit", methods=["POST"]) @lr def api_hedge_update_exit(plan_id: int): """持仓中改出场:可切换盈利金额/目标位并改数字.""" from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables from lib.hedge_plan.hedge_plan_exit_lib import update_plan_exit body = request.get_json(silent=True) or {} conn = cfg["get_db"]() try: init_hedge_plan_tables(conn) out = update_plan_exit(cfg, conn, plan_id, body) 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/live-positions") @lr def api_hedge_live_positions(): """当前持仓:期权+永续实时卡,组合净利已扣开平双边手续费.""" from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables from lib.hedge_plan.hedge_plan_live_positions_lib import build_live_hedge_cards conn = cfg["get_db"]() try: init_hedge_plan_tables(conn) cards = build_live_hedge_cards(cfg, conn) conn.commit() finally: conn.close() return jsonify({"ok": True, "cards": cards}) @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/transfer", methods=["POST"]) @lr def api_hedge_transfer(): """账户内划转(资金↔交易);USDT/ETH/BTC.本仓不依赖单独期权 UI.""" if not _hedge_enabled(): return jsonify({"ok": False, "msg": "对冲模块未启用"}), 400 ex = cfg.get("exchange_options") or cfg.get("exchange") if ex is None: return jsonify({"ok": False, "msg": "交易所未配置"}), 400 data = request.get_json(silent=True) or {} ccy = str(data.get("ccy") or "USDT").strip().upper() if ccy not in ("USDT", "ETH", "BTC"): return jsonify({"ok": False, "msg": "币种仅支持 USDT/ETH/BTC"}), 400 from_acct = str(data.get("from") or "funding").strip().lower() to_acct = str(data.get("to") or "trading").strip().lower() if from_acct not in ("funding", "trading") or to_acct not in ("funding", "trading"): return jsonify({"ok": False, "msg": "账户仅支持 funding/trading"}), 400 if from_acct == to_acct: return jsonify({"ok": False, "msg": "划出与划入账户不能相同"}), 400 try: amount = float(data.get("amount")) except (TypeError, ValueError): return jsonify({"ok": False, "msg": "数量无效"}), 400 if amount <= 0: return jsonify({"ok": False, "msg": "数量须大于 0"}), 400 from lib.exchange.okx_options_lib import transfer_ccy result = transfer_ccy(ex, ccy, amount, from_acct, to_acct) if result.get("ok"): try: from lib.exchange.okx_options_lib import invalidate_options_balance_cache invalidate_options_balance_cache() except Exception: pass return jsonify(result) @app.route("/api/hedge-plan/sell-coin-status") @lr def api_hedge_sell_coin_status(): """是否显示「手动卖回币」:交易户有币且 ≥ 现货最小卖出量(粉尘隐藏).""" if not _hedge_enabled(): return jsonify({"ok": False, "sellable": False, "msg": "对冲模块未启用"}) ex = cfg.get("exchange_options") or cfg.get("exchange") if ex is None: return jsonify({"ok": False, "sellable": False, "msg": "交易所未配置"}) underlying = str( request.args.get("underlying") or cfg.get("default_underly") or "ETH" ).strip().upper() if underlying not in ("ETH", "BTC"): return jsonify({"ok": False, "sellable": False, "msg": "标的仅支持 ETH/BTC"}) from lib.options.options_spot_bridge_lib import ( fetch_trading_coin_available, is_sellable_spot_coin, spot_min_sell_sz, ) avail = fetch_trading_coin_available(ex, underlying) try: avail_f = float(avail) if avail is not None else 0.0 except (TypeError, ValueError): avail_f = 0.0 min_sz = float(spot_min_sell_sz(ex, underlying)) sellable = is_sellable_spot_coin(ex, underlying, avail_f) return jsonify( { "ok": True, "underlying": underlying, "available": avail_f, "min_sz": min_sz, "sellable": bool(sellable), } ) @app.route("/api/hedge-plan/sell-coin", methods=["POST"]) @lr def api_hedge_sell_coin(): """手动卖回币:市价卖出交易账户全部可用标的币换 USDT(粉尘拒绝).""" if not _hedge_enabled(): return jsonify({"ok": False, "msg": "对冲模块未启用"}), 400 ex = cfg.get("exchange_options") or cfg.get("exchange") if ex is None: return jsonify({"ok": False, "msg": "交易所未配置"}), 400 data = request.get_json(silent=True) or {} underlying = str(data.get("underlying") or cfg.get("default_underly") or "ETH").strip().upper() if underlying not in ("ETH", "BTC"): return jsonify({"ok": False, "msg": "标的仅支持 ETH/BTC"}), 400 from lib.options.options_spot_bridge_lib import ( fetch_trading_coin_available, is_sellable_spot_coin, spot_min_sell_sz, ) avail = fetch_trading_coin_available(ex, underlying) try: avail_f = float(avail) if avail is not None else 0.0 except (TypeError, ValueError): avail_f = 0.0 if not is_sellable_spot_coin(ex, underlying, avail_f): min_sz = spot_min_sell_sz(ex, underlying) return jsonify( { "ok": False, "msg": f"无足够 {underlying} 可卖(可用 {avail_f:g} · 最小 {min_sz:g})", "available": avail_f, "min_sz": min_sz, } ), 400 conn = cfg["get_db"]() try: from lib.options.options_db import init_options_tables from lib.options.options_spot_bridge_lib import sell_residual_after_option_flat init_options_tables(conn) out = sell_residual_after_option_flat( conn, ex, underlying=underlying, inst_id=(str(data.get("inst_id") or "").strip() or None), ) if out.get("ok"): conn.commit() try: from lib.exchange.okx_options_lib import invalidate_options_balance_cache invalidate_options_balance_cache() except Exception: pass return jsonify(out) finally: conn.close() @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]: """对冲页行情:只走公共 HTTP/curl,绝不触发 ccxt load_markets(弱网会卡死整页).""" ex = cfg.get("exchange") 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 = index_px = None last_err: Exception | None = None try: from lib.exchange.okx_public_http_lib import fetch_index_px, fetch_swap_ticker row = fetch_swap_ticker(base) last = _sf(row.get("last")) mark = _sf(row.get("markPx")) or last bid = _sf(row.get("bidPx")) ask = _sf(row.get("askPx")) index_px = fetch_index_px(base) except Exception as e: last_err = e if ex is not None: try: inst = f"{base}-USDT-SWAP" rows = (ex.public_get_market_ticker({"instId": inst}) or {}).get("data") or [] row = rows[0] if rows else {} last = _sf(row.get("last")) mark = _sf(row.get("markPx")) or last bid = _sf(row.get("bidPx")) ask = _sf(row.get("askPx")) except Exception as e2: last_err = e2 if last is None and mark is None: return {}, f"拉永续行情失败: {last_err or '无报价'}" if index_px is None: index_px = mark or last # 面值/可用/精度:展示用默认值,避免 get_contract_size→load_markets 阻塞 cs = 0.1 if base == "ETH" else 0.01 available = None amount_precision = 2 if base == "ETH" else 4 sizing = None suggest_contracts = None entry = float(mark or last or 0) return { "exchange_symbol": sym, "mark": mark, "last": last, "bid": bid, "ask": ask, "index_px": index_px, "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). trading_usdt=展示权益(含永续全仓占用);trading_usdt_avail=可开仓余额. """ out: dict[str, Any] = { "label": cfg.get("options_account_label") or "期权账户", "trading_usdc": None, "funding_usdc": None, "trading_usdt": None, "funding_usdt": None, "trading_usdt_avail": None, "perp_margin_usdt": None, "margin_note": "永续全仓 · 期权逐仓", } ex = cfg.get("exchange_options") if ex is None: return out try: from lib.exchange.okx_options_lib import options_header_balance_pack pack = options_header_balance_pack(ex, force=False) or {} out.update( { "trading_usdc": pack.get("trading_usdc"), "funding_usdc": pack.get("funding_usdc"), "trading_usdt": pack.get("trading_usdt"), "funding_usdt": pack.get("funding_usdt"), "trading_usdt_avail": pack.get("trading_usdt_avail", pack.get("trading_usdt")), "perp_margin_usdt": pack.get("perp_margin_usdt"), "funding_eth": pack.get("funding_eth"), "trading_eth": pack.get("trading_eth"), "funding_btc": pack.get("funding_btc"), "trading_btc": pack.get("trading_btc"), } ) except Exception: hdr = cfg.get("options_header_balances") if callable(hdr): 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, "trading_usdt_avail": trading_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