feat: add OKX options module with dual API, USDT/USDC convert, and docs
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,361 @@
|
||||
"""OKX USDⓈ 期权 API 封装(主账户 exchange_options 专用)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, Callable
|
||||
|
||||
import ccxt
|
||||
|
||||
from lib.options.options_pricing_lib import is_shallow_itm
|
||||
|
||||
|
||||
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 _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
|
||||
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,
|
||||
"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]:
|
||||
meta_rows = ex.public_get_public_instruments(
|
||||
{"instType": "OPTION", "instId": inst_id}
|
||||
).get("data") or []
|
||||
if not meta_rows:
|
||||
return {"ok": False, "msg": "合约不存在"}
|
||||
meta = meta_rows[0]
|
||||
t_rows = ex.public_get_market_ticker({"instId": inst_id}).get("data") or []
|
||||
t = t_rows[0] if t_rows else {}
|
||||
uly = str(meta.get("uly") or "")
|
||||
idx = fetch_index_price(ex, uly)
|
||||
return {
|
||||
"ok": True,
|
||||
"inst_id": inst_id,
|
||||
"meta": meta,
|
||||
"ask": _safe_float(t.get("askPx")),
|
||||
"bid": _safe_float(t.get("bidPx")),
|
||||
"mark": _safe_float(t.get("markPx")),
|
||||
"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": meta.get("tickSz"),
|
||||
"strike": _safe_float(meta.get("stk")),
|
||||
"opt_type": meta.get("optType"),
|
||||
"exp_time": meta.get("expTime"),
|
||||
}
|
||||
|
||||
|
||||
def place_option_limit_order(
|
||||
ex: ccxt.okx,
|
||||
*,
|
||||
inst_id: str,
|
||||
side: str,
|
||||
sheets: int,
|
||||
price: float,
|
||||
td_mode: str = "cross",
|
||||
) -> 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"}
|
||||
try:
|
||||
resp = ex.private_post_trade_order(
|
||||
{
|
||||
"instId": inst_id,
|
||||
"tdMode": td_mode,
|
||||
"side": side_l,
|
||||
"ordType": "limit",
|
||||
"px": str(price),
|
||||
"sz": str(int(sheets)),
|
||||
}
|
||||
)
|
||||
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)}
|
||||
|
||||
|
||||
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, ""
|
||||
Reference in New Issue
Block a user