diff --git a/crypto_monitor_okx/.env.example b/crypto_monitor_okx/.env.example index c71bbae..7ddd848 100644 --- a/crypto_monitor_okx/.env.example +++ b/crypto_monitor_okx/.env.example @@ -113,8 +113,15 @@ OKX_OPTIONS_ENABLED=false # OKX_OPTIONS_API_SECRET= # OKX_OPTIONS_API_PASSPHRASE= OKX_OPTIONS_ACCOUNT_LABEL=账户·期权 +# 单笔期权本位: usdc(默认,权利金USDC) | coin(币本位+USDT买币桥;对冲仍仅USDC) +OKX_OPTIONS_MARGIN_MODE=usdc OKX_OPTIONS_TRADE_BUDGET_USDC=10 OKX_OPTIONS_BUDGET_BUFFER=0.95 +# 币本位:按交易户USDT×缓冲复利;上限开关默认关(靠人工转走) +OKX_OPTIONS_COIN_COMPOUND=true +OKX_OPTIONS_COIN_BUDGET_USDT=10 +OKX_OPTIONS_COIN_MAX_USDT_ENABLED=false +OKX_OPTIONS_COIN_MAX_USDT=50 # 全仓复利:开启时隐藏单笔预算且不可用打满;关闭后恢复单笔预算 OKX_OPTIONS_COMPOUND_FULL_ENABLED=true OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED=false diff --git a/docs/OKX单笔期权-币本位与USDT桥-开发方案.md b/docs/OKX单笔期权-币本位与USDT桥-开发方案.md index b5871d7..1001987 100644 --- a/docs/OKX单笔期权-币本位与USDT桥-开发方案.md +++ b/docs/OKX单笔期权-币本位与USDT桥-开发方案.md @@ -1,9 +1,9 @@ # OKX 单笔期权 · 币本位模式(USDT 桥 + 复利)— 开发方案 -> 状态:**方案待实现**(按本文落地;改需求先改本文). +> 状态:**已实现首版**(按本文落地;改需求先改本文). > 范围:**`crypto_monitor_okx` 单笔期权开平** + **中控对 OKX 期权只读字段**(能识别币本位);对冲计划(永期/期期)**不接币本位**. > **硬约束:本次不改 Gate**(不改 `crypto_monitor_gate/`、不改 Gate 专用模板/静态/测试;共享 `lib` 若动刀不得改变 Gate 启动与交易行为). -> 相关:[期权方案.md](./期权方案.md) · [期权用法.md](./期权用法.md) · [期权开平仓与监控说明.md](./期权开平仓与监控说明.md) · [position-sizing-mode.md](./position-sizing-mode.md) +> 相关:[期权方案.md](./期权方案.md) · [期权用法.md](./期权用法.md) · [期权开平仓与监控说明.md](./期权开平仓与监控说明.md) · [position-sizing-mode.md](./position-sizing-mode.md) · [更新文档.md](./更新文档.md) --- diff --git a/docs/更新文档.md b/docs/更新文档.md index 3707b12..e97b2dd 100644 --- a/docs/更新文档.md +++ b/docs/更新文档.md @@ -4,6 +4,36 @@ --- +## 2026-08-20 · OKX 单笔期权币本位 + USDT 桥 + 复利 + +### 修改原因 + +币本位期权流动性往往好于 USDC;操作者仍用 USDT 思考本金。需 env 切换本位、自动 USDT↔币桥、交易户 USDT×0.95 复利;对冲仍仅 USDC;中控只读识别本位;不改 Gate。 + +### 修改的地方 + +| 文件 | 改动摘要 | +|------|----------| +| `lib/options/options_margin_mode_lib.py` | 本位/合约族/USDT 预算/按币算张数 | +| `lib/options/options_spot_bridge_lib.py` | 买币/卖回/桥状态表/回滚 | +| `lib/options/options_coin_open_lib.py` | 买满→开满编排;平后卖回 | +| `options_register` / `okx_options_lib` / close_exec | 链族切换、开平接入、retry-sell | +| `options_hub_lib` + 中控 `app.js` / AI context | 只读字段识别币本位 | +| `hedge_plan_register` | 币本位禁止开对冲 | +| `env_*` / `.env.example` | 新 env;MARGIN_MODE 需重启;有仓拒切 | +| `docs/OKX单笔期权-币本位与USDT桥-开发方案.md` | 方案(已有) | + +### 交付之后的验收 + +1. `OKX_OPTIONS_MARGIN_MODE=usdc` 行为与现网一致. +2. `=coin` 时链为 `ETH-USD`(非 `_UM`);开仓走买币再开期权;失败回滚卖币. +3. 平仓清空后卖回本桥币量;失败可 `POST /api/options/spot-bridge/retry-sell`. +4. 预算默认交易户 USDT×0.95;上限开关默认关. +5. 中控期权卡显示本位标签;无下单. +6. Gate 无改动. + +--- + ## 2026-07-19 · 期权复盘详情改为对话框 + 截图显示修复 ### 修改原因 diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index 99ed92b..ef6fae8 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -1266,7 +1266,16 @@ const openBtn = document.getElementById("opt-open-btn"); if (openBtn) { openBtn.disabled = !canOpen || sz.ok === false; - openBtn.textContent = canOpen ? "限价买入 @ 卖一" : "暂无卖一深度,无法开仓"; + const coinMode = (state.chain && state.chain.margin_mode === "coin") || (state.chain && state.chain.options_margin_mode === "coin"); + const bud = state.chain && state.chain.coin_budget && state.chain.coin_budget.budget_usdt; + if (!canOpen) { + openBtn.textContent = "暂无卖一深度,无法开仓"; + } else if (coinMode) { + openBtn.textContent = + bud != null ? "买币并开仓(预算 ≈ " + Number(bud).toFixed(2) + " USDT)" : "买币并开仓 @ 卖一"; + } else { + openBtn.textContent = "限价买入 @ 卖一"; + } } const msgEl = document.getElementById("opt-order-msg"); if (!d.ok) { diff --git a/lib/env/env_schema.py b/lib/env/env_schema.py index fbe7058..9d4451d 100644 --- a/lib/env/env_schema.py +++ b/lib/env/env_schema.py @@ -100,6 +100,10 @@ HOT_RELOAD_EXACT = frozenset({ "OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", "OKX_OPTIONS_TRADE_BUDGET_USDC", "OKX_OPTIONS_BUDGET_BUFFER", + "OKX_OPTIONS_COIN_COMPOUND", + "OKX_OPTIONS_COIN_BUDGET_USDT", + "OKX_OPTIONS_COIN_MAX_USDT_ENABLED", + "OKX_OPTIONS_COIN_MAX_USDT", "OKX_TRADE_MODE", "MAX_ACTIVE_HEDGE_PLANS", "HEDGE_PLAN_LIVE_ORDER", diff --git a/lib/env/env_ui_manifest.py b/lib/env/env_ui_manifest.py index 8418ff9..afba271 100644 --- a/lib/env/env_ui_manifest.py +++ b/lib/env/env_ui_manifest.py @@ -143,16 +143,41 @@ _OPTIONS_SECTION: dict[str, Any] = { "fields": [ ("OKX_OPTIONS_ENABLED", "启用期权模块", "与永续共用上方 OKX_API_*;不再单独配置期权密钥"), ("OKX_OPTIONS_ACCOUNT_LABEL", "期权账户备注", ""), + ( + "OKX_OPTIONS_MARGIN_MODE", + "单笔期权本位", + "usdc=USDⓈ权利金(现状);coin=币本位+USDT买币桥。有持仓/半成品桥时勿切换;改后需重启", + ), ( "OKX_OPTIONS_TRADE_BUDGET_USDC", "单笔预算(USDC)", - "仅全仓复利关闭时显示/生效;用于「按可用余额打满」及张数/币数上限", + "仅 USDC 模式且全仓复利关闭时显示/生效;用于「按可用余额打满」及张数/币数上限", + ), + ("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95;USDC 打满/全仓复利与币本位复利共用"), + ( + "OKX_OPTIONS_COIN_COMPOUND", + "币本位按交易户USDT复利", + "默认 true;预算=交易账户USDT×缓冲;关闭则用下方固定 USDT 预算×缓冲", + ), + ( + "OKX_OPTIONS_COIN_BUDGET_USDT", + "币本位固定预算(USDT)", + "仅币本位且复利关闭时生效", + ), + ( + "OKX_OPTIONS_COIN_MAX_USDT_ENABLED", + "币本位单笔上限开关", + "默认 false=靠人工转走控规模;true 时预算不超过下方 N U", + ), + ( + "OKX_OPTIONS_COIN_MAX_USDT", + "币本位单笔上限(USDT)", + "仅上限开关开启时生效", ), - ("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95;打满/全仓复利共用"), ( "OKX_OPTIONS_COMPOUND_FULL_ENABLED", "全仓复利开关", - "默认 true;开启时隐藏单笔预算且不可用打满预算,下单以全仓复利为主;关闭则恢复单笔预算并隐藏全仓复利", + "默认 true;仅 USDC 模式。开启时隐藏单笔预算且不可用打满预算,下单以全仓复利为主;关闭则恢复单笔预算并隐藏全仓复利", ), ( "OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py index 094b298..8ad7ff7 100644 --- a/lib/exchange/okx_options_lib.py +++ b/lib/exchange/okx_options_lib.py @@ -734,9 +734,19 @@ def build_option_chain( itm_only: bool = True, itm_max_dist_usd: float = 30.0, index_px: float | None = None, + margin_mode: str | None = None, + inst_family: str | None = None, ) -> dict[str, Any]: u = (underlying or "ETH").upper() - family = f"{u}-USD_UM" + if inst_family: + family = str(inst_family).strip() + else: + try: + from lib.options.options_margin_mode_lib import inst_family_for_underlying + + family = inst_family_for_underlying(u, margin_mode=margin_mode) + except Exception: + family = f"{u}-USD_UM" uly = f"{u}-USD" idx = index_px if index_px is not None else fetch_index_price(ex, uly) now_ms = time.time() * 1000 @@ -838,6 +848,8 @@ def build_option_chain( "underlying": u, "index_px": idx, "inst_family": family, + "margin_mode": "usdc" if "_UM" in family.upper() else "coin", + "premium_ccy": "USDC" if "_UM" in family.upper() else u, "expiries": exp_list, "instruments_count": len(instruments), } diff --git a/lib/hedge_plan/hedge_plan_register.py b/lib/hedge_plan/hedge_plan_register.py index 4557a98..03327a9 100644 --- a/lib/hedge_plan/hedge_plan_register.py +++ b/lib/hedge_plan/hedge_plan_register.py @@ -826,6 +826,20 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None: 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"): diff --git a/lib/instance/instance_settings_register.py b/lib/instance/instance_settings_register.py index d928fcc..0d0484c 100644 --- a/lib/instance/instance_settings_register.py +++ b/lib/instance/instance_settings_register.py @@ -106,6 +106,24 @@ def register_instance_settings_routes( clean = coerce_hedge_partial_close_with_manual(clean, env_path=env_path) if not clean: return jsonify({"ok": True, "changed_keys": [], "restart_required": False}) + if "OKX_OPTIONS_MARGIN_MODE" in clean: + try: + from lib.options.options_margin_mode_lib import normalize_options_margin_mode + from lib.options.options_spot_bridge_lib import mode_switch_block_msg + + lines = read_env_lines(env_path) + old_mode = normalize_options_margin_mode(env_get(lines, "OKX_OPTIONS_MARGIN_MODE") or "usdc") + new_mode = normalize_options_margin_mode(clean.get("OKX_OPTIONS_MARGIN_MODE")) + if old_mode != new_mode: + conn_m = get_db() + try: + block = mode_switch_block_msg(conn_m, None) + if block: + return jsonify({"ok": False, "msg": block}), 400 + finally: + conn_m.close() + except Exception as e: + return jsonify({"ok": False, "msg": f"本位切换校验失败: {e}"}), 400 changed = apply_env_updates(env_path, clean) groups = parse_env_example_schema(example_path) reload_info = apply_env_reload(env_path, get_db, changed, groups) diff --git a/lib/options/options_close_exec_lib.py b/lib/options/options_close_exec_lib.py index 7fbde32..6939bd3 100644 --- a/lib/options/options_close_exec_lib.py +++ b/lib/options/options_close_exec_lib.py @@ -351,7 +351,7 @@ def close_option_by_bid1( # 自动平已挂过单:同仓续批只验流动性 mark_close_gate_passed(inst_id) - return { + out = { "ok": True, "mode": "bid1", "orders": [{"order": order, "px": px, "sheets": level_sheets}], @@ -369,6 +369,18 @@ def close_option_by_bid1( + ("" if fully_closed else f",剩余 {remaining_pos} 张待下次平仓") ), } + if fully_closed: + try: + from lib.options.options_coin_open_lib import maybe_sell_spot_after_close + + spot_sell = maybe_sell_spot_after_close(cfg, ex, inst_id=inst_id, close_result=out) + if spot_sell is not None: + out["spot_sell"] = spot_sell + if spot_sell.get("bridge_status") == "pending_sell_spot": + out["msg"] = str(out.get("msg") or "") + ";卖回 USDT 失败,请重试卖回" + except Exception as e: + out["spot_sell"] = {"ok": False, "msg": str(e)} + return out # 兼容旧名 diff --git a/lib/options/options_coin_open_lib.py b/lib/options/options_coin_open_lib.py new file mode 100644 index 0000000..cb707ce --- /dev/null +++ b/lib/options/options_coin_open_lib.py @@ -0,0 +1,398 @@ +"""币本位单笔期权:买满 USDT→币 → 开满期权 → 平后卖回.""" +from __future__ import annotations + +import os +import time +from typing import Any + +from lib.exchange.okx_options_lib import ( + cap_option_buy_sheets_to_ask_depth, + option_buy_liquidity_ok, + td_mode_for_option_buy, + wait_option_order_full_fill, +) +from lib.options.options_margin_mode_lib import ( + calc_sheets_from_coin_balance, + compute_coin_budget_usdt, + is_coin_margin_mode, + margin_mode_from_inst_id, + normalize_options_margin_mode, + premium_ccy_for_mode, +) +from lib.options.options_spot_bridge_lib import ( + BRIDGE_BOUGHT, + BRIDGE_HOLDING, + bridge_blocks_new_open_msg, + fetch_trading_coin_available, + insert_bridge, + rollback_bought_coin_to_usdt, + sell_residual_after_option_flat, + spot_market_buy_coin_with_usdt, + update_bridge, +) + + +def coin_budget_preview(cfg: dict[str, Any], ex: Any) -> dict[str, Any]: + from lib.exchange.okx_options_lib import fetch_options_balances + + bal = cfg.get("fetch_options_balances")(ex, force=True) if callable(cfg.get("fetch_options_balances")) else fetch_options_balances(ex, force=True) + trading = bal.get("trading_usdt_avail") + if trading is None: + trading = bal.get("trading_usdt") + try: + trading_f = float(trading or 0) + except (TypeError, ValueError): + trading_f = 0.0 + buf = float(cfg.get("budget_buffer") or 0.95) + return compute_coin_budget_usdt(trading_f, buffer=buf) + + +def open_coin_option_buy_full( + cfg: dict[str, Any], + ex: Any, + *, + inst_id: str, + signal_note: str = "", + target_index: float | None = None, + profit_exit_enabled: bool = False, + profit_exit_mult: float = 1.0, +) -> dict[str, Any]: + """先买满 USDT 预算对应的币,再按卖一尽量开满.""" + from lib.options.options_db import init_options_tables + from lib.options.options_position_limit_lib import ( + compound_full_single_position_block_msg, + option_position_limit_block_msg, + ) + + if not is_coin_margin_mode(): + return {"ok": False, "msg": "当前非币本位模式"} + if margin_mode_from_inst_id(inst_id) != "coin": + return {"ok": False, "msg": "合约不是币本位期权(请确认未选中 USD_UM 合约)"} + + # 解析标的 + parts = inst_id.split("-") + underlying = (parts[0] if parts else "ETH").upper() + + conn = cfg["get_db"]() + try: + init_options_tables(conn) + block = bridge_blocks_new_open_msg(conn) + if block: + return {"ok": False, "msg": block, "can_open": False} + + compound_block = compound_full_single_position_block_msg( + ex, fetch_positions=cfg.get("fetch_option_positions") + ) + if compound_block: + return {"ok": False, "msg": compound_block, "can_open": False} + pos_limit_msg = option_position_limit_block_msg( + ex, + opening_inst_id=inst_id, + fetch_positions=cfg.get("fetch_option_positions"), + ) + if pos_limit_msg: + return {"ok": False, "msg": pos_limit_msg, "can_open": False} + + budget_info = coin_budget_preview(cfg, ex) + if not budget_info.get("ok"): + return {"ok": False, "msg": budget_info.get("msg") or "USDT 预算无效", "budget": budget_info} + budget_usdt = float(budget_info["budget_usdt"]) + + q = cfg["quote_option_contract"](ex, inst_id) + if not q.get("ok"): + return q + ask = q.get("ask") + ask_sz = q.get("ask_sz") + can_open, block_msg = option_buy_liquidity_ok(ask, ask_sz) + if not can_open: + return { + "ok": False, + "msg": block_msg or "暂无卖一深度,无法买入", + "can_open": False, + } + + # 1) 买币(用买入前后可用差作为本轮币量,避免叠加原有现货) + coin_before = fetch_trading_coin_available(ex, underlying) or 0.0 + buy = spot_market_buy_coin_with_usdt(ex, underlying=underlying, usdt_amount=budget_usdt) + if not buy.get("ok"): + return {"ok": False, "msg": f"现货买入 {underlying} 失败: {buy.get('msg')}", "budget": budget_info} + bridge_id = insert_bridge( + conn, + underlying=underlying, + status=BRIDGE_BOUGHT, + budget_usdt=budget_usdt, + buy_ord_id=str(buy.get("ord_id") or ""), + inst_id=inst_id, + message="已买币,待开期权", + ) + # 等余额落账 + time.sleep(0.6) + coin_after = fetch_trading_coin_available(ex, underlying) + if coin_after is None: + rb = rollback_bought_coin_to_usdt( + conn, ex, bridge_id=bridge_id, underlying=underlying, reason="买币后读不到可用余额" + ) + return { + "ok": False, + "msg": "买币后读不到可用余额,已尝试卖回 USDT", + "rollback": rb, + "budget": budget_info, + } + coin_bought = max(0.0, float(coin_after) - float(coin_before or 0)) + if coin_bought <= 0: + # 落账延迟时退化为用当前可用,但仍写入上限提示 + coin_bought = float(coin_after) + if coin_bought <= 0: + rb = rollback_bought_coin_to_usdt( + conn, ex, bridge_id=bridge_id, underlying=underlying, reason="买入量无效" + ) + return {"ok": False, "msg": "买币后可用增量无效", "rollback": rb, "budget": budget_info} + update_bridge(conn, bridge_id, coin_bought=float(coin_bought)) + + ct_mult = float(q.get("ct_mult") or 0.01) + min_sz = int(q.get("min_sz") or 1) + sizing = calc_sheets_from_coin_balance( + quote_per_unit=float(ask), + ct_mult=ct_mult, + min_sz=min_sz, + coin_available=float(coin_bought), + ) + if not sizing.get("ok"): + rb = rollback_bought_coin_to_usdt( + conn, + ex, + bridge_id=bridge_id, + underlying=underlying, + reason=sizing.get("msg") or "张数不足", + coin_amount=float(coin_bought), + ) + return {"ok": False, "msg": sizing.get("msg"), "sizing": sizing, "rollback": rb, "budget": budget_info} + + sheets = int(sizing["sheets"]) + capped, cap_msg = cap_option_buy_sheets_to_ask_depth(sheets, ask_sz, min_sz=min_sz) + if capped is None: + rb = rollback_bought_coin_to_usdt( + conn, ex, bridge_id=bridge_id, underlying=underlying, reason=cap_msg or "深度不足" + ) + return {"ok": False, "msg": cap_msg or "卖一深度不足", "rollback": rb} + if capped < sheets: + sheets = int(capped) + sizing = { + "ok": True, + "sheets": sheets, + "eth_amount": round(sheets * ct_mult, 8), + "coin_premium": round(sheets * float(ask) * ct_mult, 8), + "ask_depth_capped": True, + } + + tick_sz = q.get("tick_sz") + order = cfg["place_option_limit_order"]( + ex, + inst_id=inst_id, + side="buy", + sheets=sheets, + price=float(ask), + td_mode=td_mode_for_option_buy(cfg.get("td_mode")), + tick_sz=tick_sz, + ord_type="ioc", + ) + if not order.get("ok"): + rb = rollback_bought_coin_to_usdt( + conn, ex, bridge_id=bridge_id, underlying=underlying, reason=order.get("msg") or "下单失败" + ) + return {"ok": False, "msg": order.get("msg") or "期权下单失败", "order": order, "rollback": rb} + + ord_id = str((order.get("data") or {}).get("ordId") or "").strip() + if not ord_id: + rb = rollback_bought_coin_to_usdt( + conn, ex, bridge_id=bridge_id, underlying=underlying, reason="无订单号" + ) + return {"ok": False, "msg": "下单成功但未返回订单号", "rollback": rb} + + try: + fill_timeout = max(2.0, float(os.getenv("OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC") or "12")) + except (TypeError, ValueError): + fill_timeout = 12.0 + fill = wait_option_order_full_fill( + ex, + inst_id=inst_id, + ord_id=ord_id, + need_sheets=int(sheets), + timeout_sec=fill_timeout, + cancel_on_timeout=True, + ) + if not fill.get("ok"): + filled_n = int(fill.get("filled_sheets") or 0) + if filled_n <= 0: + rb = rollback_bought_coin_to_usdt( + conn, + ex, + bridge_id=bridge_id, + underlying=underlying, + reason=fill.get("msg") or "未成交", + ) + return {"ok": False, "msg": fill.get("msg") or "未完全成交", "fill": fill, "rollback": rb} + sheets = filled_n + + eth_amount = round(float(sheets) * ct_mult, 8) + premium_paid = round(float(ask) * eth_amount, 8) + premium_ccy = premium_ccy_for_mode("coin", underlying) + + update_bridge( + conn, + bridge_id, + status=BRIDGE_HOLDING, + inst_id=inst_id, + message="期权持仓中", + ) + + trade_id = _insert_coin_trade( + conn, + inst_id=inst_id, + underlying=underlying, + opt_type=str(q.get("opt_type") or ""), + strike=q.get("strike"), + exp_time=q.get("exp_time"), + sheets=int(sheets), + eth_amount=eth_amount, + open_quote=float(ask), + premium_paid=premium_paid, + signal_note=signal_note, + exchange_ord_id=ord_id, + bridge_id=bridge_id, + budget_usdt=budget_usdt, + premium_ccy=premium_ccy, + profit_exit_enabled=profit_exit_enabled, + profit_exit_mult=profit_exit_mult, + ) + + # 目标位 / 翻倍离场 — 复用现有逻辑若存在 + try: + if target_index is not None: + from lib.options.options_target_lib import upsert_target_monitor + + upsert_target_monitor( + conn, + inst_id=inst_id, + underlying=underlying, + opt_type=str(q.get("opt_type") or ""), + target_index=float(target_index), + trade_id=trade_id, + sheets=int(sheets), + ) + except Exception: + pass + try: + from lib.options.options_notify_lib import notify_options_open + + notify_options_open( + cfg, + conn, + trade_id=trade_id, + inst_id=inst_id, + underlying=underlying, + opt_type=str(q.get("opt_type") or ""), + sheets=int(sheets), + premium_paid=premium_paid, + open_quote=float(ask), + target_index=target_index, + signal_note=signal_note, + ) + except Exception: + pass + + return { + "ok": True, + "msg": f"币本位开仓成功 {sheets} 张", + "margin_mode": "coin", + "budget": budget_info, + "sizing": sizing, + "sheets": sheets, + "eth_amount": eth_amount, + "premium_paid": premium_paid, + "premium_ccy": premium_ccy, + "bridge_id": bridge_id, + "trade_id": trade_id, + "order": order, + "fill": fill, + } + finally: + try: + conn.close() + except Exception: + pass + + +def _insert_coin_trade(conn: Any, **kwargs: Any) -> int: + pe = 1 if kwargs.get("profit_exit_enabled") else 0 + pe_mult = float(kwargs.get("profit_exit_mult") or 1.0) + pe_state = "active" if pe else "idle" + cur = conn.execute( + """ + INSERT INTO options_trades( + inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount, + open_quote, premium_paid, status, signal_note, exchange_ord_id, + margin_mode, premium_ccy, bridge_id, budget_usdt, + profit_exit_enabled, profit_exit_mult, profit_exit_state + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, 'coin', ?, ?, ?, ?, ?, ?) + """, + ( + kwargs["inst_id"], + kwargs["underlying"], + kwargs["opt_type"], + kwargs.get("strike"), + str(kwargs.get("exp_time") or ""), + kwargs["sheets"], + kwargs["eth_amount"], + kwargs.get("open_quote"), + kwargs.get("premium_paid"), + kwargs.get("signal_note") or "", + kwargs.get("exchange_ord_id"), + kwargs.get("premium_ccy") or "ETH", + kwargs.get("bridge_id"), + kwargs.get("budget_usdt"), + pe, + pe_mult, + pe_state, + ), + ) + conn.commit() + return int(cur.lastrowid) + + +def maybe_sell_spot_after_close( + cfg: dict[str, Any], + ex: Any, + *, + inst_id: str, + close_result: dict[str, Any] | None = None, +) -> dict[str, Any] | None: + """期权平仓后若该合约为币本位且已空仓,卖回本桥残留币.""" + if margin_mode_from_inst_id(inst_id) != "coin": + return None + # 仍有仓则不卖 + try: + rows = cfg["fetch_option_positions"](ex) or [] + for p in rows: + if str(p.get("instId") or p.get("inst_id") or "") != inst_id: + continue + try: + if abs(float(p.get("pos") or 0)) > 1e-12: + return {"ok": True, "skipped": True, "msg": "仍有持仓,暂不卖币"} + except (TypeError, ValueError): + pass + except Exception: + pass + parts = inst_id.split("-") + underlying = (parts[0] if parts else "ETH").upper() + conn = cfg["get_db"]() + try: + from lib.options.options_db import init_options_tables + + init_options_tables(conn) + return sell_residual_after_option_flat(conn, ex, underlying=underlying, inst_id=inst_id) + finally: + try: + conn.close() + except Exception: + pass diff --git a/lib/options/options_db.py b/lib/options/options_db.py index f4e5f22..377ed08 100644 --- a/lib/options/options_db.py +++ b/lib/options/options_db.py @@ -101,11 +101,21 @@ def init_options_tables(conn: sqlite3.Connection) -> None: "ALTER TABLE options_trades ADD COLUMN profit_exit_enabled INTEGER DEFAULT 0", "ALTER TABLE options_trades ADD COLUMN profit_exit_mult REAL DEFAULT 1.0", "ALTER TABLE options_trades ADD COLUMN profit_exit_state TEXT DEFAULT 'idle'", + "ALTER TABLE options_trades ADD COLUMN margin_mode TEXT DEFAULT 'usdc'", + "ALTER TABLE options_trades ADD COLUMN premium_ccy TEXT DEFAULT 'USDC'", + "ALTER TABLE options_trades ADD COLUMN bridge_id INTEGER", + "ALTER TABLE options_trades ADD COLUMN budget_usdt REAL", ): try: conn.execute(ddl) except Exception: pass + try: + from lib.options.options_spot_bridge_lib import ensure_bridge_table + + ensure_bridge_table(conn) + except Exception: + pass init_options_review_tables(conn) diff --git a/lib/options/options_hub_lib.py b/lib/options/options_hub_lib.py index 76b4406..973739b 100644 --- a/lib/options/options_hub_lib.py +++ b/lib/options/options_hub_lib.py @@ -96,6 +96,47 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]: has_upl = True upl_total += float(pnl) bal = cfg["fetch_options_balances"](ex) + from lib.options.options_margin_mode_lib import ( + is_coin_margin_mode, + normalize_options_margin_mode, + premium_ccy_for_mode, + ) + + margin_mode = normalize_options_margin_mode() + for p in positions: + mid = str(p.get("inst_id") or "") + from lib.options.options_margin_mode_lib import margin_mode_from_inst_id + + row_mode = margin_mode_from_inst_id(mid) if mid else margin_mode + p["margin_mode"] = row_mode + p["premium_ccy"] = p.get("premium_ccy") or premium_ccy_for_mode( + row_mode, str(p.get("underlying") or mid.split("-")[0] if mid else "ETH") + ) + p["margin_mode_label"] = "币本位" if row_mode == "coin" else "USDC" + + coin_budget = None + bridge_status = None + open_bridges = [] + if is_coin_margin_mode(): + try: + from lib.options.options_coin_open_lib import coin_budget_preview + + coin_budget = coin_budget_preview(cfg, ex) + except Exception: + coin_budget = None + try: + conn_b = cfg["get_db"]() + try: + from lib.options.options_spot_bridge_lib import list_open_bridges + + open_bridges = list_open_bridges(conn_b) + if open_bridges: + bridge_status = str(open_bridges[0].get("status") or "") + finally: + conn_b.close() + except Exception: + open_bridges = [] + return { "ok": True, "enabled": True, @@ -113,6 +154,11 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]: "trade_budget": cfg.get("trade_budget"), "account_label": cfg.get("account_label") or "OKX期权", "max_active_positions": options_max_active_positions(), + "options_margin_mode": margin_mode, + "options_margin_mode_label": "币本位" if margin_mode == "coin" else "USDC", + "coin_budget": coin_budget, + "bridge_status": bridge_status, + "open_bridges": open_bridges, } except Exception as e: return {"ok": False, "enabled": True, "msg": str(e)} diff --git a/lib/options/options_margin_mode_lib.py b/lib/options/options_margin_mode_lib.py new file mode 100644 index 0000000..292b9ff --- /dev/null +++ b/lib/options/options_margin_mode_lib.py @@ -0,0 +1,168 @@ +"""OKX 单笔期权本位模式与币本位 USDT 预算.""" +from __future__ import annotations + +import os +from typing import Any + + +MODE_USDC = "usdc" +MODE_COIN = "coin" + + +def _env_bool(name: str, default: bool = False) -> bool: + v = (os.getenv(name) or "").strip().lower() + if not v: + return default + return v in ("1", "true", "yes", "on", "y") + + +def _env_float(name: str, default: float) -> float: + try: + return float(os.getenv(name) or default) + except (TypeError, ValueError): + return float(default) + + +def normalize_options_margin_mode(raw: Any = None) -> str: + """返回 usdc | coin.""" + if raw is None: + raw = os.getenv("OKX_OPTIONS_MARGIN_MODE") + v = str(raw or MODE_USDC).strip().lower() + if v in ("coin", "coin_margin", "crypto", "crypto_margin", "币本位"): + return MODE_COIN + return MODE_USDC + + +def is_coin_margin_mode(raw: Any = None) -> bool: + return normalize_options_margin_mode(raw) == MODE_COIN + + +def inst_family_for_underlying(underlying: str, *, margin_mode: str | None = None) -> str: + u = (underlying or "ETH").strip().upper() or "ETH" + mode = normalize_options_margin_mode(margin_mode) + if mode == MODE_COIN: + return f"{u}-USD" + return f"{u}-USD_UM" + + +def margin_mode_from_inst_id(inst_id: str) -> str: + inst = (inst_id or "").strip().upper() + if not inst: + return normalize_options_margin_mode() + if "_UM" in inst: + return MODE_USDC + # ETH-USD-260701-2500-C / BTC-USD-... + if "-USD-" in inst and "_UM" not in inst: + return MODE_COIN + return normalize_options_margin_mode() + + +def premium_ccy_for_mode(margin_mode: str, underlying: str = "ETH") -> str: + if normalize_options_margin_mode(margin_mode) == MODE_COIN: + return (underlying or "ETH").strip().upper() or "ETH" + return "USDC" + + +def spot_quote_inst_id(underlying: str) -> str: + """现货市价买卖: ETH-USDT / BTC-USDT.""" + u = (underlying or "ETH").strip().upper() or "ETH" + return f"{u}-USDT" + + +def compute_coin_budget_usdt( + trading_usdt: float, + *, + compound: bool | None = None, + buffer: float | None = None, + fixed_budget_usdt: float | None = None, + max_enabled: bool | None = None, + max_usdt: float | None = None, +) -> dict[str, Any]: + """ + 币本位单笔 USDT 预算. + 复利开: trading_usdt × buffer; 复利关: fixed × buffer. + 上限开: min(..., max_usdt). + """ + bal = max(0.0, float(trading_usdt or 0)) + use_compound = _env_bool("OKX_OPTIONS_COIN_COMPOUND", True) if compound is None else bool(compound) + buf = float(buffer) if buffer is not None else _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95) + if buf <= 0: + buf = 0.95 + fixed = ( + float(fixed_budget_usdt) + if fixed_budget_usdt is not None + else _env_float("OKX_OPTIONS_COIN_BUDGET_USDT", 10.0) + ) + if use_compound: + raw = bal * buf + source = "compound" + else: + raw = max(0.0, fixed) * buf + source = "fixed" + capped = False + max_on = ( + _env_bool("OKX_OPTIONS_COIN_MAX_USDT_ENABLED", False) + if max_enabled is None + else bool(max_enabled) + ) + max_n = ( + float(max_usdt) + if max_usdt is not None + else _env_float("OKX_OPTIONS_COIN_MAX_USDT", 50.0) + ) + budget = raw + if max_on and max_n > 0 and budget > max_n: + budget = max_n + capped = True + return { + "ok": budget > 0, + "budget_usdt": round(budget, 8), + "raw_usdt": round(raw, 8), + "trading_usdt": round(bal, 8), + "buffer": buf, + "compound": use_compound, + "source": source, + "max_enabled": max_on, + "max_usdt": max_n if max_on else None, + "capped_by_max": capped, + "msg": "" if budget > 0 else "交易账户 USDT 不足,无法计算币本位预算", + } + + +def calc_sheets_from_coin_balance( + *, + quote_per_unit: float, + ct_mult: float, + min_sz: int, + coin_available: float, +) -> dict[str, Any]: + """用可用标的币尽量开满(权利金以币计).""" + import math + + ask = float(quote_per_unit or 0) + mult = float(ct_mult or 0.01) + need = max(1, int(min_sz or 1)) + coin = max(0.0, float(coin_available or 0)) + if ask <= 0 or mult <= 0: + return {"ok": False, "msg": "卖一价无效", "sheets": 0, "coin_premium": 0.0} + per_sheet = ask * mult + if per_sheet <= 0: + return {"ok": False, "msg": "无法计算单张权利金(币)", "sheets": 0, "coin_premium": 0.0} + sheets = int(math.floor((coin / per_sheet) + 1e-12)) + if sheets < need: + return { + "ok": False, + "msg": f"可用币不足,无法买入 {need} 张(单张约 {per_sheet:.8g} 币,可用 {coin:g})", + "sheets": sheets, + "coin_premium": round(sheets * per_sheet, 8), + "per_sheet_coin": per_sheet, + } + prem = sheets * per_sheet + return { + "ok": True, + "msg": "", + "sheets": sheets, + "coin_premium": round(prem, 8), + "per_sheet_coin": per_sheet, + "eth_amount": round(sheets * mult, 8), + } diff --git a/lib/options/options_register.py b/lib/options/options_register.py index 2d6d20f..cd928a8 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -106,6 +106,7 @@ def _build_cfg(app_module: Any) -> dict[str, Any]: "compound_full_enabled": _env_bool("OKX_OPTIONS_COMPOUND_FULL_ENABLED", True), "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), + "margin_mode": (os.getenv("OKX_OPTIONS_MARGIN_MODE") or "usdc").strip().lower(), "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), @@ -421,16 +422,27 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: return jsonify({"ok": False, "msg": err}) force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes") bal = cfg["fetch_options_balances"](ex, force=force, scope="main") - return jsonify( - { - "ok": True, - **bal, - "trade_budget": _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", float(cfg.get("trade_budget") or 10)), - "compound_full_enabled": _compound_full_enabled(), - "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), - } - ) + from lib.options.options_margin_mode_lib import is_coin_margin_mode, normalize_options_margin_mode + + margin_mode = normalize_options_margin_mode() + payload = { + "ok": True, + **bal, + "trade_budget": _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", float(cfg.get("trade_budget") or 10)), + "compound_full_enabled": _compound_full_enabled(), + "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), + "options_margin_mode": margin_mode, + "options_margin_mode_label": "币本位" if margin_mode == "coin" else "USDC", + } + if is_coin_margin_mode(): + try: + from lib.options.options_coin_open_lib import coin_budget_preview + + payload["coin_budget"] = coin_budget_preview(cfg, ex) + except Exception as e: + payload["coin_budget"] = {"ok": False, "msg": str(e)} + return jsonify(payload) @app.route("/api/options/chain") @lr @@ -442,12 +454,16 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: # 热更新:链展示天数每次读 env,保存后刷新链即可 chain_max_dte = _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", float(cfg.get("chain_max_dte_days") or 14)) try: + from lib.options.options_margin_mode_lib import normalize_options_margin_mode + + margin_mode = normalize_options_margin_mode() chain = cfg["build_option_chain"]( ex, u, max_dte_days=chain_max_dte, itm_only=False, itm_max_dist_usd=cfg["itm_max_dist"], + margin_mode=margin_mode, ) except Exception as e: return jsonify({"ok": False, "msg": f"加载期权链失败: {e}"}) @@ -456,6 +472,15 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: # 热更新:每次读 env,保存配置后刷新链即可生效 ask_liq_filter = _env_bool("OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", True) budget_buffer = _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95) + coin_budget = None + try: + from lib.options.options_margin_mode_lib import is_coin_margin_mode + from lib.options.options_coin_open_lib import coin_budget_preview + + if is_coin_margin_mode(): + coin_budget = coin_budget_preview(cfg, ex) + except Exception: + coin_budget = None if not expiries: return jsonify( { @@ -466,6 +491,8 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: "ask_liq_filter_enabled": ask_liq_filter, "budget_buffer": budget_buffer, "trade_budget": cfg["trade_budget"], + "options_margin_mode": chain.get("margin_mode") or margin_mode, + "coin_budget": coin_budget, } ) return jsonify( @@ -476,6 +503,8 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: "ask_liq_filter_enabled": ask_liq_filter, "budget_buffer": budget_buffer, "trade_budget": cfg["trade_budget"], + "options_margin_mode": chain.get("margin_mode") or margin_mode, + "coin_budget": coin_budget, } ) @@ -810,6 +839,28 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: profit_exit_mult = normalize_profit_exit_mult(data.get("profit_exit_mult"), default=1.0) if not inst_id: return jsonify({"ok": False, "msg": "缺少 inst_id"}) + try: + from lib.options.options_margin_mode_lib import is_coin_margin_mode + from lib.options.options_coin_open_lib import open_coin_option_buy_full + + if is_coin_margin_mode(): + result = open_coin_option_buy_full( + cfg, + ex, + inst_id=inst_id, + signal_note=signal_note, + target_index=target_index, + profit_exit_enabled=profit_exit_enabled, + profit_exit_mult=profit_exit_mult, + ) + if result.get("ok"): + from lib.exchange.okx_options_lib import invalidate_option_positions_cache + + invalidate_option_positions_cache() + _mark_balances_stale(cfg) + return jsonify(result) + except Exception as e: + return jsonify({"ok": False, "msg": f"币本位开仓失败: {e}"}) q = cfg["quote_option_contract"](ex, inst_id) if not q.get("ok"): return jsonify(q) @@ -1434,9 +1485,46 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: conn2.close() except Exception: pass + try: + from lib.options.options_coin_open_lib import maybe_sell_spot_after_close + + spot_sell = maybe_sell_spot_after_close(cfg, ex, inst_id=inst_id, close_result=result) + if spot_sell is not None: + result = dict(result) + result["spot_sell"] = spot_sell + if spot_sell.get("bridge_status") == "pending_sell_spot": + result["msg"] = ( + str(result.get("msg") or "平仓成功") + + ";但卖回 USDT 失败,请点「重试卖回」" + ) + except Exception as e: + result = dict(result) + result["spot_sell"] = {"ok": False, "msg": str(e)} _mark_balances_stale(cfg) return jsonify(result) + @app.route("/api/options/spot-bridge/retry-sell", methods=["POST"]) + @lr + def api_options_spot_bridge_retry_sell(): + """币本位:重试把残留标的币市价卖回 USDT.""" + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + data = request.get_json(silent=True) or {} + underlying = (data.get("underlying") or cfg.get("default_underly") or "ETH").strip().upper() + inst_id = (data.get("inst_id") or "").strip() or None + conn = cfg["get_db"]() + try: + init_options_tables(conn) + from lib.options.options_spot_bridge_lib import sell_residual_after_option_flat + + out = sell_residual_after_option_flat(conn, ex, underlying=underlying, inst_id=inst_id) + if out.get("ok"): + _mark_balances_stale(cfg) + return jsonify(out) + finally: + conn.close() + @app.route("/api/options/convert/quote", methods=["POST"]) @lr def api_options_convert_quote(): diff --git a/lib/options/options_spot_bridge_lib.py b/lib/options/options_spot_bridge_lib.py new file mode 100644 index 0000000..886947b --- /dev/null +++ b/lib/options/options_spot_bridge_lib.py @@ -0,0 +1,415 @@ +"""币本位期权:USDT↔标的币现货桥与本地状态.""" +from __future__ import annotations + +import sqlite3 +import time +from typing import Any + +from lib.options.options_margin_mode_lib import spot_quote_inst_id + + +BRIDGE_BOUGHT = "bought_pending_open" +BRIDGE_HOLDING = "holding" +BRIDGE_PENDING_SELL = "pending_sell_spot" +BRIDGE_CLOSED = "closed" + + +def ensure_bridge_table(conn: sqlite3.Connection) -> None: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS options_spot_bridge ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + underlying TEXT NOT NULL, + status TEXT NOT NULL, + budget_usdt REAL, + buy_ord_id TEXT, + coin_bought REAL, + sell_ord_id TEXT, + coin_sold REAL, + usdt_recovered REAL, + inst_id TEXT, + message TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + closed_at TIMESTAMP + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_options_spot_bridge_status + ON options_spot_bridge(status) + """ + ) + + +def list_open_bridges(conn: sqlite3.Connection) -> list[dict[str, Any]]: + ensure_bridge_table(conn) + cur = conn.execute( + """ + SELECT id, underlying, status, budget_usdt, buy_ord_id, coin_bought, + sell_ord_id, coin_sold, usdt_recovered, inst_id, message, + created_at, updated_at, closed_at + FROM options_spot_bridge + WHERE status IN (?, ?, ?) + ORDER BY id DESC + """, + (BRIDGE_BOUGHT, BRIDGE_HOLDING, BRIDGE_PENDING_SELL), + ) + cols = [d[0] for d in cur.description] + return [dict(zip(cols, row)) for row in cur.fetchall()] + + +def has_unfinished_bridge(conn: sqlite3.Connection) -> bool: + return bool(list_open_bridges(conn)) + + +def insert_bridge( + conn: sqlite3.Connection, + *, + underlying: str, + status: str, + budget_usdt: float | None = None, + buy_ord_id: str | None = None, + coin_bought: float | None = None, + inst_id: str | None = None, + message: str | None = None, +) -> int: + ensure_bridge_table(conn) + cur = conn.execute( + """ + INSERT INTO options_spot_bridge( + underlying, status, budget_usdt, buy_ord_id, coin_bought, inst_id, message, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + """, + ( + (underlying or "ETH").upper(), + status, + budget_usdt, + buy_ord_id, + coin_bought, + inst_id, + message, + ), + ) + conn.commit() + return int(cur.lastrowid) + + +def update_bridge( + conn: sqlite3.Connection, + bridge_id: int, + *, + status: str | None = None, + buy_ord_id: str | None = None, + coin_bought: float | None = None, + sell_ord_id: str | None = None, + coin_sold: float | None = None, + usdt_recovered: float | None = None, + inst_id: str | None = None, + message: str | None = None, + close: bool = False, +) -> None: + ensure_bridge_table(conn) + fields: list[str] = ["updated_at=CURRENT_TIMESTAMP"] + vals: list[Any] = [] + if status is not None: + fields.append("status=?") + vals.append(status) + if buy_ord_id is not None: + fields.append("buy_ord_id=?") + vals.append(buy_ord_id) + if coin_bought is not None: + fields.append("coin_bought=?") + vals.append(coin_bought) + if sell_ord_id is not None: + fields.append("sell_ord_id=?") + vals.append(sell_ord_id) + if coin_sold is not None: + fields.append("coin_sold=?") + vals.append(coin_sold) + if usdt_recovered is not None: + fields.append("usdt_recovered=?") + vals.append(usdt_recovered) + if inst_id is not None: + fields.append("inst_id=?") + vals.append(inst_id) + if message is not None: + fields.append("message=?") + vals.append(message) + if close or status == BRIDGE_CLOSED: + fields.append("closed_at=CURRENT_TIMESTAMP") + vals.append(int(bridge_id)) + conn.execute( + f"UPDATE options_spot_bridge SET {', '.join(fields)} WHERE id=?", + vals, + ) + conn.commit() + + +def _safe_float(v: Any) -> float | None: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def fetch_trading_coin_available(ex: Any, ccy: str) -> float | None: + """交易账户标的币可用.""" + from lib.exchange.okx_options_lib import _extract_ccy_free, _safe_float as _sf + + ccy_u = (ccy or "").upper() + if not ccy_u: + return None + try: + bal = ex.fetch_balance(params={"type": "trading"}) + free = _extract_ccy_free(bal, ccy_u) + if free is not None: + return float(free) + # 部分账户结构只有 total + from lib.exchange.okx_options_lib import _extract_ccy_balance + + tot = _extract_ccy_balance(bal, ccy_u) + return float(tot) if tot is not None else None + except Exception: + return None + + +def spot_market_buy_coin_with_usdt( + ex: Any, + *, + underlying: str, + usdt_amount: float, +) -> dict[str, Any]: + """交易账户:用 USDT 市价买入标的币.""" + if usdt_amount <= 0: + return {"ok": False, "msg": "USDT 数量须大于 0"} + inst_id = spot_quote_inst_id(underlying) + try: + body = { + "instId": inst_id, + "tdMode": "cash", + "side": "buy", + "ordType": "market", + "sz": str(usdt_amount), + "tgtCcy": "quote_ccy", + } + resp = ex.private_post_trade_order(body) + data = (resp or {}).get("data") or [] + if data and str(data[0].get("sCode")) == "0": + return { + "ok": True, + "inst_id": inst_id, + "ord_id": str(data[0].get("ordId") or ""), + "data": data[0], + "raw": resp, + } + from lib.exchange.okx_options_lib import _okx_trade_error_message + + return {"ok": False, "msg": _okx_trade_error_message(resp=resp), "raw": resp} + except Exception as e: + from lib.exchange.okx_options_lib import _okx_trade_error_message + + return {"ok": False, "msg": _okx_trade_error_message(e)} + + +def spot_market_sell_coin_to_usdt( + ex: Any, + *, + underlying: str, + coin_amount: float | None = None, +) -> dict[str, Any]: + """交易账户:市价卖出标的币换 USDT.coin_amount 空则尽量卖光可用.""" + ccy = (underlying or "ETH").upper() + amt = coin_amount + if amt is None or float(amt) <= 0: + avail = fetch_trading_coin_available(ex, ccy) + if avail is None or float(avail) <= 0: + return {"ok": False, "msg": f"交易账户无可用 {ccy}"} + amt = float(avail) + if float(amt) <= 0: + return {"ok": False, "msg": f"{ccy} 数量须大于 0"} + # 留一点粉尘避免精度拒单 + sell_sz = float(amt) + if sell_sz > 1e-8: + sell_sz = max(0.0, sell_sz * 0.999) + inst_id = spot_quote_inst_id(ccy) + try: + # 现货卖出数量精度:截到 8 位 + sz = f"{sell_sz:.8f}".rstrip("0").rstrip(".") + if not sz or float(sz) <= 0: + return {"ok": False, "msg": f"{ccy} 可卖数量过小"} + body = { + "instId": inst_id, + "tdMode": "cash", + "side": "sell", + "ordType": "market", + "sz": sz, + "tgtCcy": "base_ccy", + } + resp = ex.private_post_trade_order(body) + data = (resp or {}).get("data") or [] + if data and str(data[0].get("sCode")) == "0": + return { + "ok": True, + "inst_id": inst_id, + "ord_id": str(data[0].get("ordId") or ""), + "coin_sold": float(sz), + "data": data[0], + "raw": resp, + } + from lib.exchange.okx_options_lib import _okx_trade_error_message + + return {"ok": False, "msg": _okx_trade_error_message(resp=resp), "raw": resp} + except Exception as e: + from lib.exchange.okx_options_lib import _okx_trade_error_message + + return {"ok": False, "msg": _okx_trade_error_message(e)} + + +def rollback_bought_coin_to_usdt( + conn: sqlite3.Connection, + ex: Any, + *, + bridge_id: int, + underlying: str, + reason: str = "", + coin_amount: float | None = None, +) -> dict[str, Any]: + """买币后开期权失败:卖回 USDT 并关闭桥.优先卖 bridge 记录的买入量.""" + amt = coin_amount + if amt is None or float(amt) <= 0: + ensure_bridge_table(conn) + row = conn.execute( + "SELECT coin_bought FROM options_spot_bridge WHERE id=?", + (int(bridge_id),), + ).fetchone() + if row: + try: + amt = float(row[0] if not isinstance(row, dict) else row.get("coin_bought") or 0) + except (TypeError, ValueError, KeyError, IndexError): + amt = None + sell = spot_market_sell_coin_to_usdt(ex, underlying=underlying, coin_amount=amt) + if not sell.get("ok"): + update_bridge( + conn, + bridge_id, + status=BRIDGE_PENDING_SELL, + message=(reason or "") + " | 回滚卖币失败: " + str(sell.get("msg") or ""), + ) + return {"ok": False, "msg": sell.get("msg") or "回滚卖币失败", "bridge_status": BRIDGE_PENDING_SELL} + update_bridge( + conn, + bridge_id, + status=BRIDGE_CLOSED, + sell_ord_id=str(sell.get("ord_id") or ""), + coin_sold=_safe_float(sell.get("coin_sold")), + message=reason or "开仓失败已卖回 USDT", + close=True, + ) + return {"ok": True, "sell": sell, "bridge_status": BRIDGE_CLOSED} + + +def sell_residual_after_option_flat( + conn: sqlite3.Connection, + ex: Any, + *, + underlying: str, + inst_id: str | None = None, +) -> dict[str, Any]: + """期权已平:卖掉本桥残留标的币;优先关闭 matching holding/pending 桥.""" + ensure_bridge_table(conn) + bridges = list_open_bridges(conn) + target = None + for b in bridges: + if str(b.get("status")) in (BRIDGE_HOLDING, BRIDGE_PENDING_SELL, BRIDGE_BOUGHT): + if not underlying or str(b.get("underlying") or "").upper() == underlying.upper(): + target = b + break + coin_amt = None + if target is not None: + try: + coin_amt = float(target.get("coin_bought") or 0) or None + except (TypeError, ValueError): + coin_amt = None + sell = spot_market_sell_coin_to_usdt(ex, underlying=underlying, coin_amount=coin_amt) + if target is None: + if not sell.get("ok"): + msg = str(sell.get("msg") or "") + if "无可用" in msg or "过小" in msg: + return {"ok": True, "msg": "无残留币需卖回", "skipped": True} + return {"ok": False, "msg": msg, "bridge_status": BRIDGE_PENDING_SELL} + return {"ok": True, "sell": sell, "bridge_status": None} + bid = int(target["id"]) + if not sell.get("ok"): + update_bridge( + conn, + bid, + status=BRIDGE_PENDING_SELL, + inst_id=inst_id, + message=str(sell.get("msg") or "卖回 USDT 失败"), + ) + return { + "ok": False, + "msg": sell.get("msg") or "卖回 USDT 失败", + "bridge_id": bid, + "bridge_status": BRIDGE_PENDING_SELL, + } + update_bridge( + conn, + bid, + status=BRIDGE_CLOSED, + sell_ord_id=str(sell.get("ord_id") or ""), + coin_sold=_safe_float(sell.get("coin_sold")), + inst_id=inst_id, + message="期权已平,币已卖回 USDT", + close=True, + ) + return {"ok": True, "sell": sell, "bridge_id": bid, "bridge_status": BRIDGE_CLOSED} + + +def bridge_blocks_new_open_msg(conn: sqlite3.Connection) -> str | None: + bridges = list_open_bridges(conn) + if not bridges: + return None + st = str(bridges[0].get("status") or "") + if st == BRIDGE_PENDING_SELL: + return "存在待卖回 USDT 的币本位桥残留,请先到期权页重试卖回后再开仓" + if st == BRIDGE_BOUGHT: + return "存在已买币未完成开仓的桥流程,请等待回滚或联系处理后重试" + if st == BRIDGE_HOLDING: + return "币本位桥仍在持仓中(一次仅一笔),请先平仓并卖回 USDT" + return "存在未完成的币本位资金桥,暂不可开仓" + + +def mode_switch_block_msg(conn: sqlite3.Connection, ex: Any | None = None) -> str | None: + """有单笔期权仓或未完成桥时禁止切换本位.""" + if has_unfinished_bridge(conn): + return "存在未完成的币本位资金桥,禁止切换期权本位模式" + if ex is not None: + try: + from lib.exchange.okx_options_lib import fetch_option_positions + + rows = fetch_option_positions(ex) or [] + for p in rows: + try: + pos = float(p.get("pos") or 0) + except (TypeError, ValueError): + pos = 0.0 + if abs(pos) > 1e-12: + return "存在未平期权持仓,禁止切换期权本位模式" + except Exception: + pass + # 本地 open 交易记录 + try: + row = conn.execute( + "SELECT COUNT(*) FROM options_trades WHERE status='open'" + ).fetchone() + n = int(row[0] if not isinstance(row, dict) else row.get("COUNT(*)") or list(row.values())[0]) + if n > 0: + return "本地仍有未平期权记录,禁止切换期权本位模式" + except Exception: + pass + return None diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html index 8ff17f9..38b6304 100644 --- a/lib/options/templates/options_panel.html +++ b/lib/options/templates/options_panel.html @@ -28,6 +28,7 @@
OKX_OPTIONS_MARGIN_MODE=coin):按交易户 USDT×缓冲买满 ETH/BTC 再开满期权;平仓后自动卖回 USDT;对冲仍仅 USDC。有仓勿切换本位。${esc(shortOptionsInst(p.inst_id))}${esc(shortOptionsInst(p.inst_id))}${
+ p.margin_mode_label || p.margin_mode === "coin" ? ` ${esc(p.margin_mode_label || "币本位")}` : ""
+ }