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
+11
View File
@@ -2390,4 +2390,15 @@ html[data-theme="light"] .settings-export-link {
font-size: 0.85rem;
margin-bottom: 8px;
}
#opt-order-msg.opt-error,
.opt-error {
color: #ff6b6b;
}
.opt-row-actions {
white-space: nowrap;
}
.opt-row-actions .btn-primary,
.opt-row-actions .btn-secondary {
margin-right: 4px;
}
+93 -30
View File
@@ -22,6 +22,13 @@
return r.json();
}
function scrollToOrderPanel() {
const panel = document.getElementById("opt-order-panel");
if (panel && panel.style.display !== "none") {
panel.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
}
async function refreshBalances() {
const d = await apiJson("/api/options/balances");
if (!d.ok) return;
@@ -81,7 +88,10 @@
"<td><code>" + c.inst_id + "</code></td>" +
"<td>" + fmt(c.ask, 4) + "</td>" +
"<td>" + fmt(c.bid, 4) + "</td>" +
'<td><button type="button" class="btn-secondary opt-pick-btn" data-inst="' + c.inst_id + '">选择</button></td>';
'<td class="opt-row-actions">' +
'<button type="button" class="btn-secondary opt-pick-btn" data-inst="' + c.inst_id + '">选择</button> ' +
'<button type="button" class="btn-primary opt-buy-btn" data-inst="' + c.inst_id + '">买入</button>' +
"</td>";
tbody.appendChild(tr);
});
tbody.querySelectorAll(".opt-pick-btn").forEach(function (btn) {
@@ -89,6 +99,12 @@
selectContract(btn.getAttribute("data-inst"));
});
});
tbody.querySelectorAll(".opt-buy-btn").forEach(function (btn) {
btn.addEventListener("click", async function () {
await selectContract(btn.getAttribute("data-inst"));
await openPosition();
});
});
}
async function selectContract(instId) {
@@ -108,7 +124,19 @@
document.getElementById("opt-order-sheets").textContent = sz.sheets != null ? sz.sheets : "—";
document.getElementById("opt-order-eth").textContent = sz.eth_amount != null ? sz.eth_amount : "—";
document.getElementById("opt-order-premium").textContent = sz.total_premium != null ? fmt(sz.total_premium, 4) + " USDC" : "—";
document.getElementById("opt-order-msg").textContent = sz.ok === false ? (sz.msg || "") : "";
const msgEl = document.getElementById("opt-order-msg");
if (!d.ok) {
msgEl.textContent = d.msg || "报价失败";
msgEl.classList.add("opt-error");
} else if (sz.ok === false) {
msgEl.textContent = sz.msg || "";
msgEl.classList.add("opt-error");
} else {
msgEl.textContent = "";
msgEl.classList.remove("opt-error");
}
scrollToOrderPanel();
return d;
}
async function loadChain() {
@@ -123,25 +151,69 @@
}
async function openPosition() {
if (!state.selectedInst) return;
const mode = document.querySelector('input[name="opt-size-mode"]:checked').value;
const body = {
inst_id: state.selectedInst,
mode: mode,
signal_note: document.getElementById("opt-signal-note").value || "",
};
if (mode === "eth_amount") {
body.eth_amount = parseFloat(document.getElementById("opt-eth-amount").value);
if (!state.selectedInst) {
alert("请先选择合约");
return;
}
const d = await apiJson("/api/options/open", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
document.getElementById("opt-order-msg").textContent = d.ok ? "下单已提交" : (d.msg || "失败");
if (d.ok) {
refreshBalances();
const btn = document.getElementById("opt-open-btn");
btn.disabled = true;
try {
const mode = document.querySelector('input[name="opt-size-mode"]:checked').value;
const body = {
inst_id: state.selectedInst,
mode: mode,
signal_note: document.getElementById("opt-signal-note").value || "",
};
if (mode === "eth_amount") {
body.eth_amount = parseFloat(document.getElementById("opt-eth-amount").value);
}
const d = await apiJson("/api/options/open", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const msgEl = document.getElementById("opt-order-msg");
msgEl.textContent = d.ok ? "下单已提交,可在 OKX 委托中查看" : (d.msg || "失败");
msgEl.classList.toggle("opt-error", !d.ok);
if (d.ok) {
refreshBalances();
refreshPositions();
} else {
alert(d.msg || "下单失败");
}
} finally {
btn.disabled = false;
}
}
async function closePosition(inst, btn) {
const q = await apiJson("/api/options/quote?inst_id=" + encodeURIComponent(inst) + "&mode=budget_full");
if (!q.ok) {
alert(q.msg || "获取买一价失败");
return;
}
const bid = q.bid;
if (bid == null || bid <= 0) {
alert("暂无买一价,请稍后在 OKX App 平仓或等盘口恢复");
return;
}
if (!confirm("限价卖出 @ 买一 " + fmt(bid, 4) + "(每 1 ETH/BTC)?\n合约:" + inst)) return;
if (btn) btn.disabled = true;
try {
const r = await apiJson("/api/options/close", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ inst_id: inst }),
});
if (r.ok) {
alert("平仓单已提交" + (r.bid != null ? " @ " + fmt(r.bid, 4) : ""));
} else {
alert(r.msg || "平仓失败");
}
refreshPositions();
refreshBalances();
} finally {
if (btn) btn.disabled = false;
}
}
@@ -168,17 +240,8 @@
tbody.appendChild(tr);
});
tbody.querySelectorAll(".opt-close-btn").forEach(function (btn) {
btn.addEventListener("click", async function () {
const inst = btn.getAttribute("data-inst");
if (!confirm("确认限价卖出 @ 买一?")) return;
const r = await apiJson("/api/options/close", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ inst_id: inst }),
});
alert(r.ok ? "平仓单已提交" : (r.msg || "失败"));
refreshPositions();
refreshBalances();
btn.addEventListener("click", function () {
closePosition(btn.getAttribute("data-inst"), btn);
});
});
}
+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}
+31 -20
View File
@@ -59,6 +59,8 @@ def install_options_trading(app: Flask, repo_root: str, app_module: Any) -> None
def _build_cfg(app_module: Any) -> dict[str, Any]:
from lib.exchange.okx_options_lib import (
_pos_side_from_position,
_safe_float,
build_option_chain,
estimate_usdt_to_usdc,
execute_convert,
@@ -67,6 +69,7 @@ def _build_cfg(app_module: Any) -> dict[str, Any]:
format_position_row,
options_api_ready,
place_option_limit_order,
place_option_market_order,
quote_option_contract,
transfer_ccy,
)
@@ -91,6 +94,7 @@ def _build_cfg(app_module: Any) -> dict[str, Any]:
"build_option_chain": build_option_chain,
"quote_option_contract": quote_option_contract,
"place_option_limit_order": place_option_limit_order,
"place_option_market_order": place_option_market_order,
"fetch_option_positions": fetch_option_positions,
"fetch_options_balances": fetch_options_balances,
"format_position_row": format_position_row,
@@ -220,6 +224,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
if not sizing.get("ok"):
return jsonify({"ok": False, "msg": sizing.get("msg") or "张数计算失败", "sizing": sizing})
sheets = int(sizing["sheets"])
tick_sz = q.get("tick_sz")
order = cfg["place_option_limit_order"](
ex,
inst_id=inst_id,
@@ -227,6 +232,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
sheets=sheets,
price=float(ask),
td_mode=cfg["td_mode"],
tick_sz=tick_sz,
)
if not order.get("ok"):
return jsonify(order)
@@ -291,38 +297,43 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
pos = next((p for p in raw_positions if str(p.get("instId")) == inst_id), None)
if not pos:
return jsonify({"ok": False, "msg": "未找到持仓"})
avail = float(pos.get("availPos") or pos.get("pos") or 0)
close_sheets = int(sheets) if sheets else int(abs(avail))
avail = _safe_float(pos.get("availPos"))
if avail is None or avail <= 0:
avail = abs(_safe_float(pos.get("pos")) or 0)
close_sheets = int(sheets) if sheets else int(avail)
if close_sheets < 1:
return jsonify({"ok": False, "msg": "可平张数不足"})
td_mode = str(pos.get("mgnMode") or cfg["td_mode"])
pos_side = _pos_side_from_position(pos) or "net"
tick_sz = q.get("tick_sz")
if use_market:
try:
resp = ex.private_post_trade_order(
{
"instId": inst_id,
"tdMode": cfg["td_mode"],
"side": "sell",
"ordType": "market",
"sz": str(close_sheets),
}
)
data_rows = (resp or {}).get("data") or []
if not data_rows or str(data_rows[0].get("sCode")) != "0":
return jsonify({"ok": False, "msg": data_rows[0].get("sMsg") if data_rows else "市价平仓失败"})
order = {"ok": True, "data": data_rows[0]}
except Exception as e:
return jsonify({"ok": False, "msg": str(e)})
order = cfg["place_option_market_order"](
ex,
inst_id=inst_id,
side="sell",
sheets=close_sheets,
td_mode=td_mode,
reduce_only=True,
pos_side=pos_side,
)
if not order.get("ok"):
return jsonify(order)
else:
close_px = float(bid)
order = cfg["place_option_limit_order"](
ex,
inst_id=inst_id,
side="sell",
sheets=close_sheets,
price=float(bid),
td_mode=cfg["td_mode"],
price=close_px,
td_mode=td_mode,
tick_sz=tick_sz,
reduce_only=True,
pos_side=pos_side,
)
if not order.get("ok"):
return jsonify(order)
bid = order.get("px", close_px)
prem_recv = total_premium(float(bid or 0), close_sheets * float(q.get("ct_mult") or 0.01))
conn = cfg["get_db"]()
try:
+2 -2
View File
@@ -5,7 +5,7 @@
{% if not options_enabled %}
<div class="flash" style="margin-bottom:12px">期权 API 未启用:请在 <code>crypto_monitor_okx/.env</code> 设置 <code>OKX_OPTIONS_ENABLED=true</code> 及主账户 <code>OKX_OPTIONS_API_*</code>,然后 <code>pm2 restart crypto_okx --update-env</code></div>
{% endif %}
<p class="muted options-hint">资金账户兑换 USDT→USDC 后,划转到交易账户即可买入。报价单位为每 1 ETH/BTC1 张 = 0.01 ETH/BTC。</p>
<p class="muted options-hint">资金账户兑换 USDT→USDC 后,划转到交易账户即可买入。报价单位为每 1 ETH/BTC1 张 = 0.01 ETH/BTC。表格中点「买入」直接下单;或点「选择」后在下方确认张数再买入。</p>
<div class="options-funds-grid">
<div class="options-funds-col">
@@ -128,4 +128,4 @@
</div>
</div>
</div>
<script src="/static/options_panel.js?v=1"></script>
<script src="/static/options_panel.js?v=2"></script>
+7
View File
@@ -5,6 +5,13 @@ from lib.options.options_pricing_lib import (
sheets_from_eth_amount,
total_premium,
)
from lib.exchange.okx_options_lib import format_option_px, round_option_px
def test_round_option_px():
assert round_option_px(14.9184, "0.2", "sell") == 14.8
assert round_option_px(14.81, "0.2", "buy") == 15.0
assert format_option_px(14.8, "0.2") == "14.8"
def test_premium_per_sheet():