"""OKX USDⓈ 期权 API 封装(主账户 exchange_options 专用)。""" from __future__ import annotations import json import math import re import time from typing import Any, Callable import ccxt from lib.options.options_pricing_lib import is_shallow_itm, option_moneyness, option_moneyness_label _OKX_OPTION_ERR_ZH: dict[str, str] = { "51018": "期权账户不能持有净空头头寸", "51019": "期权买入须使用逐仓模式(全仓模式下不能持有多头净头寸)", } _OPTIONS_BALANCE_CACHE: dict[str, Any] = {"updated_at": 0.0, "data": None} def _okx_trade_error_message(exc: BaseException | None = None, resp: Any = None) -> str: row: dict[str, Any] | None = None if isinstance(resp, dict): data = resp.get("data") or [] if data and isinstance(data[0], dict): row = data[0] if row is None and exc is not None: text = str(exc) match = re.search(r"\{.*\}", text, re.DOTALL) if match: try: payload = json.loads(match.group(0)) data = payload.get("data") or [] if data and isinstance(data[0], dict): row = data[0] except json.JSONDecodeError: pass if row: code = str(row.get("sCode") or "") zh = _OKX_OPTION_ERR_ZH.get(code) if zh: return zh msg = str(row.get("sMsg") or "").strip() if msg: return msg if exc is not None: text = str(exc).strip() if text.lower().startswith("okx "): text = text[4:].strip() return text or "下单失败" return "下单失败" def td_mode_for_option_buy(configured: str | None = None) -> str: """OKX 买入期权(多头)必须使用逐仓。""" mode = (configured or "isolated").strip().lower() return "isolated" if mode == "cross" else mode or "isolated" def create_options_exchange( api_key: str, api_secret: str, passphrase: str, proxies: dict[str, str] | None = None, ) -> ccxt.okx: ex = ccxt.okx( { "apiKey": api_key, "secret": api_secret, "password": passphrase, "enableRateLimit": True, "options": {"defaultType": "option"}, } ) if proxies: ex.proxies = proxies return ex 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 round_option_px(px: float, tick_sz: Any, side: str) -> float: """按 OKX tickSz 对齐:买入向上取整,卖出向下取整。""" tick = _safe_float(tick_sz) if tick is None or tick <= 0 or px <= 0: return px steps = px / tick side_l = (side or "").lower() if side_l == "buy": return math.ceil(steps - 1e-12) * tick return math.floor(steps + 1e-12) * tick def format_option_px(px: float, tick_sz: Any) -> str: tick = _safe_float(tick_sz) if tick is None or tick <= 0: return str(px) decimals = max(0, -int(round(math.log10(tick)))) if tick < 1 else 0 if tick >= 1: decimals = len(str(tick).split(".")[-1]) if "." in str(tick) else 0 return f"{px:.{decimals}f}".rstrip("0").rstrip(".") or "0" def _fetch_book_bid_ask(ex: ccxt.okx, inst_id: str) -> tuple[float | None, float | None]: try: rows = ex.public_get_market_books({"instId": inst_id, "sz": "1"}).get("data") or [] if not rows: return None, None row = rows[0] asks = row.get("asks") or [] bids = row.get("bids") or [] ask = _safe_float(asks[0][0]) if asks else None bid = _safe_float(bids[0][0]) if bids else None return bid, ask except Exception: return None, None def _pos_side_from_position(pos: dict[str, Any] | None) -> str | None: if not pos: return None ps = str(pos.get("posSide") or "").strip().lower() if ps in ("long", "short", "net"): return ps sheets = _safe_float(pos.get("pos")) or 0.0 if sheets > 0: return "long" if sheets < 0: return "short" return "net" def inst_family_from_inst_id(inst_id: str) -> str | None: """从 instId 解析 instFamily,如 ETH-USD_UM-260707-1790-C → ETH-USD_UM。""" parts = (inst_id or "").strip().split("-") if len(parts) < 4: return None return "-".join(parts[:-3]) def fetch_option_instrument_meta(ex: ccxt.okx, inst_id: str) -> dict[str, Any] | None: family = inst_family_from_inst_id(inst_id) if not family: return None try: rows = ex.public_get_public_instruments( {"instType": "OPTION", "instFamily": family, "instId": inst_id} ).get("data") or [] if rows and isinstance(rows[0], dict): return rows[0] rows = ex.public_get_public_instruments( {"instType": "OPTION", "instFamily": family} ).get("data") or [] for r in rows: if isinstance(r, dict) and str(r.get("instId")) == inst_id: return r except Exception: return None return None def _extract_ccy_balance(balance: dict[str, Any], ccy: str) -> float | None: ccy = (ccy or "").upper() if not isinstance(balance, dict): return None info = balance.get(ccy) if isinstance(info, dict): for k in ("free", "total", "eq"): v = _safe_float(info.get(k)) if v is not None: return v total_map = balance.get("total") or {} if isinstance(total_map, dict): v = _safe_float(total_map.get(ccy)) if v is not None: return v free_map = balance.get("free") or {} if isinstance(free_map, dict): v = _safe_float(free_map.get(ccy)) if v is not None: return v return None def fetch_account_balances_by_type(ex: ccxt.okx, account_type: str) -> dict[str, float | None]: out: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None} try: bal = ex.fetch_balance(params={"type": account_type}) for c in out: out[c] = _extract_ccy_balance(bal, c) except Exception: pass return out def fetch_options_balances(ex: ccxt.okx, *, force: bool = False) -> dict[str, Any]: import os ttl = float(os.getenv("OKX_OPTIONS_BALANCE_REFRESH_SEC", "30")) now = time.time() cached = _OPTIONS_BALANCE_CACHE.get("data") if not force and cached is not None and now - float(_OPTIONS_BALANCE_CACHE.get("updated_at") or 0) < ttl: return dict(cached) funding = fetch_account_balances_by_type(ex, "funding") trading = fetch_account_balances_by_type(ex, "trading") # 统一账户部分 USDC 可能在 swap 类型 if trading.get("USDC") is None: swap_bal = fetch_account_balances_by_type(ex, "swap") if swap_bal.get("USDC") is not None: trading["USDC"] = swap_bal["USDC"] result = { "funding_usdt": funding.get("USDT"), "funding_usdc": funding.get("USDC"), "funding_usdg": funding.get("USDG"), "trading_usdt": trading.get("USDT"), "trading_usdc": trading.get("USDC"), "trading_usdg": trading.get("USDG"), } _OPTIONS_BALANCE_CACHE["updated_at"] = now _OPTIONS_BALANCE_CACHE["data"] = result return result def options_header_balances( ex: ccxt.okx, *, force: bool = False, ) -> tuple[float | None, float | None, float | None]: """顶栏三格:交易 USDC、资金 USDC、资金 USDT(单次拉取 + 缓存)。""" bal = fetch_options_balances(ex, force=force) def _round(v: Any) -> float | None: if v is None: return None try: return round(float(v), 2) except (TypeError, ValueError): return None return ( _round(bal.get("trading_usdc")), _round(bal.get("funding_usdc")), _round(bal.get("funding_usdt")), ) def fetch_index_price(ex: ccxt.okx, uly: str) -> float | None: inst = f"{uly}" if "-" in uly else f"{uly}-USD" try: rows = ex.public_get_market_index_tickers({"instId": inst}).get("data") or [] if rows: return _safe_float(rows[0].get("idxPx")) except Exception: pass return None def fetch_option_instruments( ex: ccxt.okx, inst_family: str, ) -> list[dict[str, Any]]: try: rows = ex.public_get_public_instruments( {"instType": "OPTION", "instFamily": inst_family} ).get("data") or [] return [r for r in rows if isinstance(r, dict) and r.get("state") == "live"] except Exception: return [] def fetch_option_tickers(ex: ccxt.okx, inst_family: str) -> dict[str, dict[str, Any]]: out: dict[str, dict[str, Any]] = {} try: rows = ex.public_get_market_tickers( {"instType": "OPTION", "instFamily": inst_family} ).get("data") or [] for r in rows: if isinstance(r, dict) and r.get("instId"): out[str(r["instId"])] = r except Exception: pass return out def build_option_chain( ex: ccxt.okx, underlying: str, *, max_dte_days: float = 2.0, itm_only: bool = True, itm_max_dist_usd: float = 30.0, index_px: float | None = None, ) -> dict[str, Any]: u = (underlying or "ETH").upper() 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 max_ms = now_ms + max_dte_days * 86400 * 1000 instruments = fetch_option_instruments(ex, family) tickers = fetch_option_tickers(ex, family) expiries: dict[str, list[dict[str, Any]]] = {} for meta in instruments: try: exp_ms = int(meta.get("expTime") or 0) except (TypeError, ValueError): continue if exp_ms <= now_ms or exp_ms > max_ms: continue opt_type = str(meta.get("optType") or "") strike = _safe_float(meta.get("stk")) if strike is None or idx is None: continue if itm_only and not is_shallow_itm( opt_type=opt_type, strike=strike, index_px=idx, max_dist_usd=itm_max_dist_usd, ): continue inst_id = str(meta.get("instId") or "") t = tickers.get(inst_id) or {} ask = _safe_float(t.get("askPx")) bid = _safe_float(t.get("bidPx")) if ask is None and bid is None: continue mny = option_moneyness(opt_type=opt_type, strike=strike, index_px=idx) exp_key = str(exp_ms) expiries.setdefault(exp_key, []).append( { "inst_id": inst_id, "strike": strike, "opt_type": opt_type, "exp_time": exp_ms, "ask": ask, "bid": bid, "moneyness": mny, "moneyness_label": option_moneyness_label(mny), "ct_mult": _safe_float(meta.get("ctMult")) or 0.01, "tick_sz": meta.get("tickSz"), "min_sz": int(_safe_float(meta.get("minSz")) or 1), } ) exp_list = [] for exp_ms_str, contracts in sorted(expiries.items(), key=lambda x: int(x[0])): contracts.sort(key=lambda c: (c["opt_type"], c["strike"])) exp_list.append({"exp_time": int(exp_ms_str), "contracts": contracts}) return {"underlying": u, "index_px": idx, "inst_family": family, "expiries": exp_list} def quote_option_contract(ex: ccxt.okx, inst_id: str) -> dict[str, Any]: inst_id = (inst_id or "").strip() if not inst_id: return {"ok": False, "msg": "缺少 inst_id"} try: meta = fetch_option_instrument_meta(ex, inst_id) if not meta: return {"ok": False, "msg": "合约不存在"} t_rows = ex.public_get_market_ticker({"instId": inst_id}).get("data") or [] t = t_rows[0] if t_rows else {} ask = _safe_float(t.get("askPx")) bid = _safe_float(t.get("bidPx")) if ask is None or bid is None: book_bid, book_ask = _fetch_book_bid_ask(ex, inst_id) if ask is None: ask = book_ask if bid is None: bid = book_bid mark = _safe_float(t.get("markPx")) tick_sz = meta.get("tickSz") if ask is None and mark is not None: ask = round_option_px(mark, tick_sz, "buy") if bid is None and mark is not None: bid = round_option_px(mark, tick_sz, "sell") uly = str(meta.get("uly") or "") idx = fetch_index_price(ex, uly) return { "ok": True, "inst_id": inst_id, "meta": meta, "ask": ask, "bid": bid, "mark": mark, "index_px": idx, "ct_mult": _safe_float(meta.get("ctMult")) or 0.01, "min_sz": int(_safe_float(meta.get("minSz")) or 1), "tick_sz": tick_sz, "strike": _safe_float(meta.get("stk")), "opt_type": meta.get("optType"), "exp_time": meta.get("expTime"), } except Exception as e: return {"ok": False, "msg": str(e)} def place_option_limit_order( ex: ccxt.okx, *, inst_id: str, side: str, sheets: int, price: float, td_mode: str = "isolated", tick_sz: Any = None, reduce_only: bool = False, pos_side: str | None = None, ) -> dict[str, Any]: side_l = (side or "").lower() if side_l not in ("buy", "sell"): return {"ok": False, "msg": "side 必须为 buy 或 sell"} if sheets < 1: return {"ok": False, "msg": "张数至少为 1"} px = round_option_px(float(price), tick_sz, side_l) if px <= 0: return {"ok": False, "msg": "价格无效"} body: dict[str, Any] = { "instId": inst_id, "tdMode": td_mode, "side": side_l, "ordType": "limit", "px": format_option_px(px, tick_sz), "sz": str(int(sheets)), } if pos_side: body["posSide"] = pos_side if reduce_only: body["reduceOnly"] = True try: 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, "data": data[0], "raw": resp, "px": px} return {"ok": False, "msg": _okx_trade_error_message(resp=resp), "raw": resp, "px": px} except Exception as e: return {"ok": False, "msg": _okx_trade_error_message(e), "px": px} def place_option_market_order( ex: ccxt.okx, *, inst_id: str, side: str, sheets: int, td_mode: str = "isolated", reduce_only: bool = False, pos_side: str | None = None, ) -> dict[str, Any]: side_l = (side or "").lower() if side_l not in ("buy", "sell"): return {"ok": False, "msg": "side 必须为 buy 或 sell"} if sheets < 1: return {"ok": False, "msg": "张数至少为 1"} body: dict[str, Any] = { "instId": inst_id, "tdMode": td_mode, "side": side_l, "ordType": "market", "sz": str(int(sheets)), } if pos_side: body["posSide"] = pos_side if reduce_only: body["reduceOnly"] = True try: 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, "data": data[0], "raw": resp} return {"ok": False, "msg": _okx_trade_error_message(resp=resp), "raw": resp} except Exception as e: return {"ok": False, "msg": _okx_trade_error_message(e)} def fetch_option_positions(ex: ccxt.okx) -> list[dict[str, Any]]: try: rows = ex.private_get_account_positions({"instType": "OPTION"}).get("data") or [] out = [] for r in rows: if not isinstance(r, dict): continue pos = _safe_float(r.get("pos")) if pos is None or abs(pos) < 1e-12: continue out.append(r) return out except Exception: return [] def estimate_usdt_to_usdc(ex: ccxt.okx, usdt_amount: float) -> dict[str, Any]: if usdt_amount <= 0: return {"ok": False, "msg": "兑换数量须大于 0"} try: resp = ex.private_post_asset_convert_estimate_quote( { "baseCcy": "USDC", "quoteCcy": "USDT", "side": "buy", "rfqSz": str(usdt_amount), "rfqSzCcy": "USDT", } ) data = (resp or {}).get("data") or [] if not data: return {"ok": False, "msg": "询价失败", "raw": resp} row = data[0] return { "ok": True, "quote_id": row.get("quoteId"), "base_ccy": row.get("baseCcy"), "quote_ccy": row.get("quoteCcy"), "cnvt_px": _safe_float(row.get("cnvtPx")), "base_sz": _safe_float(row.get("baseSz")), "quote_sz": _safe_float(row.get("quoteSz")), "rfq_sz": usdt_amount, "raw": row, } except Exception as e: return {"ok": False, "msg": str(e)} def execute_convert(ex: ccxt.okx, quote_id: str) -> dict[str, Any]: if not quote_id: return {"ok": False, "msg": "缺少 quoteId"} try: resp = ex.private_post_asset_convert_trade({"quoteId": str(quote_id)}) data = (resp or {}).get("data") or [] if data and str(data[0].get("sCode", "0")) == "0": return {"ok": True, "data": data[0], "raw": resp} msg = data[0].get("sMsg") if data else str(resp) return {"ok": False, "msg": msg or "兑换失败", "raw": resp} except Exception as e: return {"ok": False, "msg": str(e)} def transfer_ccy( ex: ccxt.okx, ccy: str, amount: float, from_account: str, to_account: str, ) -> dict[str, Any]: if amount <= 0: return {"ok": False, "msg": "划转金额须大于 0"} try: resp = ex.transfer(str(ccy).upper(), float(amount), from_account, to_account) return {"ok": True, "data": resp} except Exception as e: return {"ok": False, "msg": str(e)} _OKX_ACCT_CODE = {"funding": "6", "trading": "18", "spot": "18"} def fetch_options_trading_usdc(ex: ccxt.okx, *, force: bool = False) -> float | None: bal = fetch_options_balances(ex, force=force) v = bal.get("trading_usdc") if v is None: return None return round(float(v), 2) def fetch_options_funding_usdc(ex: ccxt.okx, *, force: bool = False) -> float | None: bal = fetch_options_balances(ex, force=force) v = bal.get("funding_usdc") if v is None: return None return round(float(v), 2) def fetch_options_funding_usdt(ex: ccxt.okx, *, force: bool = False) -> float | None: bal = fetch_options_balances(ex, force=force) v = bal.get("funding_usdt") if v is None: return None return round(float(v), 2) def spot_market_swap_usdt_usdc( ex: ccxt.okx, *, direction: str, amount: float, ) -> dict[str, Any]: """现货市价兑换 USDC-USDT。direction: usdt_to_usdc | usdc_to_usdt。""" if amount <= 0: return {"ok": False, "msg": "数量须大于 0"} d = (direction or "").lower() inst_id = "USDC-USDT" try: if d == "usdt_to_usdc": body = { "instId": inst_id, "tdMode": "cash", "side": "buy", "ordType": "market", "sz": str(amount), "tgtCcy": "quote", } elif d == "usdc_to_usdt": body = { "instId": inst_id, "tdMode": "cash", "side": "sell", "ordType": "market", "sz": str(amount), "tgtCcy": "base", } else: return {"ok": False, "msg": "direction 须为 usdt_to_usdc 或 usdc_to_usdt"} 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, "data": data[0], "raw": resp} msg = data[0].get("sMsg") if data else str(resp) return {"ok": False, "msg": msg or "现货兑换失败", "raw": resp} except Exception as e: return {"ok": False, "msg": str(e)} def transfer_main_sub_account( ex: ccxt.okx, *, ccy: str, amount: float, sub_acct: str, main_to_sub: bool, from_account: str = "funding", to_account: str = "funding", ) -> dict[str, Any]: """主账户与子账户之间划转(须主账户 API)。""" if amount <= 0: return {"ok": False, "msg": "划转金额须大于 0"} sub = (sub_acct or "").strip() if not sub: return {"ok": False, "msg": "未配置子账户名称 OKX_SUB_ACCOUNT_NAME"} from_code = _OKX_ACCT_CODE.get((from_account or "funding").lower(), "6") to_code = _OKX_ACCT_CODE.get((to_account or "funding").lower(), "6") try: resp = ex.private_post_asset_transfer( { "type": "1" if main_to_sub else "2", "ccy": str(ccy).upper(), "amt": str(amount), "from": from_code, "to": to_code, "subAcct": sub, } ) data = (resp or {}).get("data") or [] if data and str(data[0].get("sCode", "0")) == "0": return {"ok": True, "data": data[0], "raw": resp} msg = data[0].get("sMsg") if data else str(resp) return {"ok": False, "msg": msg or "主/子账户划转失败", "raw": resp} except Exception as e: return {"ok": False, "msg": str(e)} def format_position_row(pos: dict[str, Any], ct_mult: float = 0.01) -> dict[str, Any]: sheets = _safe_float(pos.get("pos")) or 0.0 avg = _safe_float(pos.get("avgPx")) mark = _safe_float(pos.get("markPx")) upl = _safe_float(pos.get("upl")) upl_ratio = _safe_float(pos.get("uplRatio")) return { "inst_id": pos.get("instId"), "pos": sheets, "eth_amount": round(abs(sheets) * ct_mult, 8), "avg_px": avg, "mark_px": mark, "upl": upl, "upl_ratio_pct": round(upl_ratio * 100, 2) if upl_ratio is not None else None, "exp_time": pos.get("expTime"), "opt_type": pos.get("optType"), "strike": _safe_float(pos.get("stk")), "avail_pos": _safe_float(pos.get("availPos")), "raw": pos, } def options_api_ready(ex: ccxt.okx | None) -> tuple[bool, str]: if ex is None: return False, "期权 API 未配置" if not ex.apiKey or not ex.secret or not ex.password: return False, "期权 API Key 不完整" return True, ""