fix: OKX options buy/close orders with tick alignment and reduceOnly
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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/BTC;1 张 = 0.01 ETH/BTC。</p>
|
||||
<p class="muted options-hint">资金账户兑换 USDT→USDC 后,划转到交易账户即可买入。报价单位为每 1 ETH/BTC;1 张 = 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>
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user