"""OKX 对冲计划:P0 测算页与 API 注册.""" from __future__ import annotations import os from typing import Any 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, suggest_contracts_from_notional, ) 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) def _build_cfg(app_module: Any) -> dict[str, Any]: from lib.exchange.okx_options_lib import build_option_chain 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), "build_option_chain": build_option_chain, "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"), } def _hedge_enabled() -> bool: return _env_bool("HEDGE_PLAN_ENABLED", False) 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, **gate_status( hedge_enabled=_hedge_enabled(), sizing_mode=load_position_sizing_mode(), plan_type=plan_type, options_enabled=bool(cfg.get("options_enabled")), ), } ) @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" data, err = _fetch_perp_market(cfg, base) if err: return jsonify({"ok": False, "msg": err}), 400 sizing_mode = load_position_sizing_mode() gates = gate_status( hedge_enabled=_hedge_enabled(), sizing_mode=sizing_mode, plan_type="perp_options", options_enabled=bool(cfg.get("options_enabled")), ) out = { "ok": True, "base": base, "direction": direction, "suggested_opt_type": "P" if direction == "long" else "C", **data, "gates": gates, "sizing_mode": sizing_mode, } 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() try: chain = cfg["build_option_chain"]( ex, u, max_dte_days=float(cfg.get("chain_max_dte") or 14), 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 return jsonify({"ok": True, **chain, "chain_max_dte_days": cfg.get("chain_max_dte")}) @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 = gate_status( hedge_enabled=_hedge_enabled(), sizing_mode=load_position_sizing_mode(), plan_type=plan_type, options_enabled=bool(cfg.get("options_enabled")), ) 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}) def _preview_po(body: dict[str, Any]) -> dict[str, Any]: 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") 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]: target = float(body["target_price"]) index_px = float(body.get("index_px") or target) 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} 权利金") return build_options_options_preview( target_price=target, 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 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: suggest_contracts = suggest_contracts_from_notional( notional=float(sizing["notional_value"]), entry=entry, contract_size=cs, ) return { "exchange_symbol": sym, "mark": mark, "last": last, "bid": bid, "ask": ask, "contract_size": cs, "available_usdt": available, "full_margin_sizing": sizing, "suggest_contracts": round(suggest_contracts, 6) if suggest_contracts is not None else None, "entry_ref": entry or None, }, None def _sf(v: Any) -> float | None: if v is None or v == "": return None try: return float(v) except (TypeError, ValueError): return None