diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index 7a609be..2670968 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -6875,6 +6875,13 @@ def render_main_page(page="trade", embed_mode=None): hedge_plan_budget_buffer=float(os.getenv("HEDGE_PLAN_BUDGET_BUFFER") or "0.95"), options_trade_budget=OKX_OPTIONS_TRADE_BUDGET_USDC, options_budget_buffer=float(os.getenv("OKX_OPTIONS_BUDGET_BUFFER") or "0.95"), + options_compound_full_cap_enabled=os.getenv( + "OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", "false" + ).lower() + in ("1", "true", "yes", "on"), + options_compound_full_cap_usdc=float( + os.getenv("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC") or "300" + ), options_default_underly=OKX_OPTIONS_DEFAULT_UNDERLY, options_chain_ask_liq_filter=os.getenv( "OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", "true" diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index de480a3..797e83c 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -281,14 +281,26 @@ const sheetsEl = document.getElementById("opt-sheets-amount"); const ethEl = document.getElementById("opt-eth-amount"); const hint = document.getElementById("opt-budget-full-hint"); + const compoundHint = document.getElementById("opt-compound-full-hint"); const capEl = document.getElementById("opt-budget-full-cap"); + const compoundCapLine = document.getElementById("opt-compound-cap-line"); if (sheetsEl) sheetsEl.style.display = mode === "sheets" ? "" : "none"; if (ethEl) ethEl.style.display = mode === "eth_amount" ? "" : "none"; if (hint) hint.style.display = mode === "budget_full" ? "" : "none"; + if (compoundHint) compoundHint.style.display = mode === "compound_full" ? "" : "none"; if (capEl && root && root.dataset.tradeBudget) { const n = Number(root.dataset.tradeBudget); if (Number.isFinite(n) && n > 0) capEl.textContent = n.toFixed(2); } + if (compoundCapLine && root) { + const on = String(root.dataset.compoundCapEnabled || "") === "1"; + const cap = Number(root.dataset.compoundCapUsdc); + if (on && Number.isFinite(cap) && cap > 0) { + compoundCapLine.textContent = "全仓上限已开启:" + cap.toFixed(2) + "U"; + } else { + compoundCapLine.textContent = "全仓上限关闭(env可开)"; + } + } document.querySelectorAll(".opt-size-mode-chip").forEach(function (chip) { const radio = chip.querySelector('input[name="opt-size-mode"]'); chip.classList.toggle("is-selected", !!(radio && radio.checked)); diff --git a/lib/env/env_ui_manifest.py b/lib/env/env_ui_manifest.py index c220332..739964e 100644 --- a/lib/env/env_ui_manifest.py +++ b/lib/env/env_ui_manifest.py @@ -144,7 +144,17 @@ _OPTIONS_SECTION: dict[str, Any] = { ("OKX_OPTIONS_ENABLED", "启用期权模块", "与永续共用上方 OKX_API_*;不再单独配置期权密钥"), ("OKX_OPTIONS_ACCOUNT_LABEL", "期权账户备注", ""), ("OKX_OPTIONS_TRADE_BUDGET_USDC", "单笔预算(USDC)", ""), - ("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95"), + ("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95;打满/全仓复利共用"), + ( + "OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", + "全仓复利上限开关", + "默认 false=关闭上限,用期权户全部可用;true 时按下方上限封顶", + ), + ( + "OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", + "全仓复利上限(USDC)", + "仅上限开关开启时生效;例如 300", + ), ( "OKX_OPTIONS_MAX_ACTIVE_POSITIONS", "期权持仓上限(笔)", diff --git a/lib/options/options_position_limit_lib.py b/lib/options/options_position_limit_lib.py index 93a2205..4ceac4c 100644 --- a/lib/options/options_position_limit_lib.py +++ b/lib/options/options_position_limit_lib.py @@ -119,3 +119,26 @@ def option_position_limit_block_msg( f"请将 OKX_OPTIONS_MAX_ACTIVE_POSITIONS 设为 0(不限制)或不小于 {active + need},或先平仓" ) return f"期权持仓已达上限({active}/{mx}),请先平仓后再开" + + +def compound_full_single_position_block_msg( + ex: Any, + *, + fetch_positions=None, +) -> Optional[str]: + """全仓复利:账户内已有任意期权持仓则禁止再开(仅允许 1 笔).""" + fetch = fetch_positions + if fetch is None: + from lib.exchange.okx_options_lib import fetch_option_positions + + fetch = fetch_option_positions + try: + rows = fetch(ex) + except Exception: + rows = None + if rows is None: + return "无法获取期权持仓,全仓复利模式暂不可开仓" + active = count_live_option_positions(rows) + if active >= 1: + return f"全仓复利模式仅允许同时持有 1 笔仓位(当前 {active} 笔),请先平仓" + return None diff --git a/lib/options/options_pricing_lib.py b/lib/options/options_pricing_lib.py index 07daf8a..d049e2a 100644 --- a/lib/options/options_pricing_lib.py +++ b/lib/options/options_pricing_lib.py @@ -264,6 +264,25 @@ def resolve_budget_full_usdc(trading_usdc: float, trade_budget_usdc: float) -> f return min(float(trading_usdc), float(trade_budget_usdc)) +def resolve_compound_full_usdc( + trading_usdc: float, + *, + cap_enabled: bool = False, + cap_usdc: float | None = None, +) -> float: + """全仓复利:默认用期权交易户全部可用;上限开关开启时再封顶.""" + bal = max(0.0, float(trading_usdc or 0)) + if not cap_enabled: + return bal + try: + cap = float(cap_usdc) if cap_usdc is not None else 0.0 + except (TypeError, ValueError): + cap = 0.0 + if cap <= 0: + return bal + return min(bal, cap) + + def calc_order_size( *, quote_per_unit: float, diff --git a/lib/options/options_register.py b/lib/options/options_register.py index acf8684..4f175d4 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -103,6 +103,8 @@ def _build_cfg(app_module: Any) -> dict[str, Any]: "render_main_page": app_module.render_main_page, "trade_budget": _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", 10.0), "budget_buffer": _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95), + "compound_full_cap_enabled": _env_bool("OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", False), + "compound_full_cap_usdc": _env_float("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", 300.0), "default_underly": (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper(), "max_dte_days": _env_float("OKX_OPTIONS_MAX_DTE_DAYS", 2.0), "chain_max_dte_days": _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", 14.0), @@ -174,6 +176,34 @@ def _budget_full_usdc(cfg: dict[str, Any], ex: Any) -> tuple[float | None, str]: return resolve_budget_full_usdc(trading, float(cap)), "" +def _compound_full_usdc(cfg: dict[str, Any], ex: Any) -> tuple[float | None, str]: + """全仓复利 = 期权交易户可用(可选上限封顶);再由 calc_order_size × budget_buffer.""" + from lib.exchange.okx_options_lib import fetch_options_trading_usdc + from lib.options.options_pricing_lib import resolve_compound_full_usdc + + raw = fetch_options_trading_usdc(ex) + if raw is None or float(raw) <= 0: + return None, "交易账户 USDC 可用余额不足" + trading = float(raw) + # 额度热更读 env(与模板启动值无关) + cap_on = _env_bool("OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", False) + cap_v = _env_float("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", 300.0) + if cap_on and cap_v <= 0: + return None, "全仓上限无效(OKX_OPTIONS_COMPOUND_FULL_CAP_USDC)" + return ( + resolve_compound_full_usdc( + trading, + cap_enabled=cap_on, + cap_usdc=cap_v, + ), + "", + ) + + +def _is_budget_mode(mode: str) -> bool: + return mode in ("budget_full", "compound_full") + + def _open_premium_paid(cfg: dict[str, Any], inst_id: str) -> float | None: conn = cfg["get_db"]() try: @@ -440,6 +470,14 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: budget_cap = budget from lib.exchange.okx_options_lib import fetch_options_trading_usdc + available_usdc = fetch_options_trading_usdc(ex) + elif mode == "compound_full": + budget, budget_err = _compound_full_usdc(cfg, ex) + if budget is None: + return jsonify({"ok": False, "msg": budget_err}) + budget_cap = budget + from lib.exchange.okx_options_lib import fetch_options_trading_usdc + available_usdc = fetch_options_trading_usdc(ex) eth_amount = None try: @@ -471,6 +509,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: }, "available_usdc": available_usdc, "budget_full_usdc": budget if mode == "budget_full" else None, + "compound_full_usdc": budget if mode == "compound_full" else None, } ) except Exception as e: @@ -507,6 +546,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: }, "available_usdc": available_usdc, "budget_full_usdc": budget if mode == "budget_full" else None, + "compound_full_usdc": budget if mode == "compound_full" else None, } ) except Exception as e: @@ -531,9 +571,39 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: }, "available_usdc": available_usdc, "budget_full_usdc": budget if mode == "budget_full" else None, + "compound_full_usdc": budget if mode == "compound_full" else None, } ) - from lib.options.options_position_limit_lib import option_position_limit_block_msg + from lib.options.options_position_limit_lib import ( + compound_full_single_position_block_msg, + option_position_limit_block_msg, + ) + + if mode == "compound_full": + compound_block = compound_full_single_position_block_msg( + ex, fetch_positions=cfg.get("fetch_option_positions") + ) + if compound_block: + return jsonify( + { + **q, + "ok": True, + "can_open": False, + "msg": compound_block, + "quote_per_unit": ask, + "premium_per_sheet": None, + "sizing": { + "ok": False, + "msg": compound_block, + "sheets": 0, + "eth_amount": 0.0, + "total_premium": 0.0, + }, + "available_usdc": available_usdc, + "budget_full_usdc": None, + "compound_full_usdc": budget, + } + ) pos_limit_msg = option_position_limit_block_msg( ex, @@ -558,17 +628,18 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: }, "available_usdc": available_usdc, "budget_full_usdc": budget if mode == "budget_full" else None, + "compound_full_usdc": budget if mode == "compound_full" else None, } ) sizing = calc_order_size( quote_per_unit=float(ask), ct_mult=float(ct_mult), min_sz=int(min_sz), - budget_usdc=budget if mode == "budget_full" else None, + budget_usdc=budget if _is_budget_mode(mode) else None, budget_buffer=cfg["budget_buffer"], eth_amount=eth_amount if mode == "eth_amount" else None, sheets=sheet_count if mode == "sheets" else None, - budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None, + budget_cap=budget_cap if mode in ("budget_full", "compound_full", "sheets", "eth_amount") else None, ) if sizing.get("ok"): capped, cap_msg = cap_option_buy_sheets_to_ask_depth( @@ -590,7 +661,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: ct_mult=float(ct_mult), min_sz=int(min_sz), sheets=capped, - budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None, + budget_cap=budget_cap if mode in ("budget_full", "compound_full", "sheets", "eth_amount") else None, ) if sizing.get("ok"): sizing["ask_depth_capped"] = True @@ -612,6 +683,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: "sizing": sizing, "available_usdc": available_usdc, "budget_full_usdc": budget if mode == "budget_full" else None, + "compound_full_usdc": budget if mode == "compound_full" else None, } ) @@ -672,7 +744,17 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: "ref_ask": q.get("ref_ask"), } ) - from lib.options.options_position_limit_lib import option_position_limit_block_msg + from lib.options.options_position_limit_lib import ( + compound_full_single_position_block_msg, + option_position_limit_block_msg, + ) + + if mode == "compound_full": + compound_block = compound_full_single_position_block_msg( + ex, fetch_positions=cfg.get("fetch_option_positions") + ) + if compound_block: + return jsonify({"ok": False, "msg": compound_block, "can_open": False}) pos_limit_msg = option_position_limit_block_msg( ex, @@ -702,15 +784,20 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: if budget is None: return jsonify({"ok": False, "msg": budget_err}) budget_cap = budget + elif mode == "compound_full": + budget, budget_err = _compound_full_usdc(cfg, ex) + if budget is None: + return jsonify({"ok": False, "msg": budget_err}) + budget_cap = budget sizing = calc_order_size( quote_per_unit=float(ask), ct_mult=ct_mult, min_sz=min_sz, - budget_usdc=budget if mode == "budget_full" else None, + budget_usdc=budget if _is_budget_mode(mode) else None, budget_buffer=cfg["budget_buffer"], eth_amount=eth_amount, sheets=sheet_count, - budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None, + budget_cap=budget_cap if mode in ("budget_full", "compound_full", "sheets", "eth_amount") else None, ) if not sizing.get("ok"): return jsonify({"ok": False, "msg": sizing.get("msg") or "张数计算失败", "sizing": sizing}) diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html index 363a8d8..f59d771 100644 --- a/lib/options/templates/options_panel.html +++ b/lib/options/templates/options_panel.html @@ -2,6 +2,8 @@ data-default-underly="{{ options_default_underly | default('ETH') }}" data-budget-buffer="{{ options_budget_buffer | default(0.95) }}" data-trade-budget="{{ options_trade_budget | default(10) }}" + data-compound-cap-enabled="{% if options_compound_full_cap_enabled %}1{% else %}0{% endif %}" + data-compound-cap-usdc="{{ '%.2f'|format(options_compound_full_cap_usdc|default(300)|float) }}" data-ask-liq-filter="{% if options_chain_ask_liq_filter is defined %}{{ '1' if options_chain_ask_liq_filter else '0' }}{% else %}1{% endif %}"> {% if not options_enabled %}
crypto_monitor_okx/.env 设置 OKX_OPTIONS_ENABLED=true 及 OKX_API_*(永续与期权共用),然后 pm2 restart crypto_okx --update-env.