fix: OKX options buy/close orders with tick alignment and reduceOnly

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-07 08:52:18 +08:00
parent ae0c44e20d
commit 05586242f0
6 changed files with 269 additions and 65 deletions
+125 -13
View File
@@ -1,6 +1,7 @@
"""OKX USDⓈ 期权 API 封装(主账户 exchange_options 专用)。"""
from __future__ import annotations
import math
import time
from typing import Any, Callable
@@ -38,6 +39,57 @@ def _safe_float(v: Any) -> float | None:
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 _extract_ccy_balance(balance: dict[str, Any], ccy: str) -> float | None:
ccy = (ccy or "").upper()
if not isinstance(balance, dict):
@@ -200,15 +252,29 @@ def quote_option_contract(ex: ccxt.okx, inst_id: str) -> dict[str, Any]:
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 {}
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": _safe_float(t.get("askPx")),
"bid": _safe_float(t.get("bidPx")),
"mark": _safe_float(t.get("markPx")),
"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),
@@ -227,23 +293,69 @@ def place_option_limit_order(
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(
{
"instId": inst_id,
"tdMode": td_mode,
"side": side_l,
"ordType": "limit",
"px": str(price),
"sz": str(int(sheets)),
}
)
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}