Files
crypto_monitor/lib/exchange/okx_options_lib.py
T
2026-07-07 10:09:51 +08:00

616 lines
20 KiB
Python

"""OKX USDⓈ 期权 API 封装(主账户 exchange_options 专用)。"""
from __future__ import annotations
import math
import time
from typing import Any, Callable
import ccxt
from lib.options.options_pricing_lib import is_shallow_itm, option_moneyness, option_moneyness_label
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) -> dict[str, Any]:
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"]
return {
"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"),
}
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 = "cross",
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}
msg = data[0].get("sMsg") if data else str(resp)
return {"ok": False, "msg": msg or "下单失败", "raw": resp, "px": px}
except Exception as e:
return {"ok": False, "msg": str(e), "px": px}
def place_option_market_order(
ex: ccxt.okx,
*,
inst_id: str,
side: str,
sheets: int,
td_mode: str = "cross",
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}
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 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) -> float | None:
bal = fetch_options_balances(ex)
v = bal.get("trading_usdc")
if v is None:
return None
return round(float(v), 2)
def fetch_options_funding_usdc(ex: ccxt.okx) -> float | None:
bal = fetch_options_balances(ex)
v = bal.get("funding_usdc")
if v is None:
return None
return round(float(v), 2)
def fetch_options_funding_usdt(ex: ccxt.okx) -> float | None:
bal = fetch_options_balances(ex)
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, ""