Add OKX hedge-plan P0: env group, preview page, and PnL scenario math.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user