diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css
index fa7a119..9859d93 100644
--- a/lib/common/static/instance_theme.css
+++ b/lib/common/static/instance_theme.css
@@ -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;
+}
diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js
index e3b74c9..ea43222 100644
--- a/lib/common/static/options_panel.js
+++ b/lib/common/static/options_panel.js
@@ -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 @@
"
" + c.inst_id + " | " +
"" + fmt(c.ask, 4) + " | " +
"" + fmt(c.bid, 4) + " | " +
- ' | ';
+ '' +
+ ' ' +
+ '' +
+ " | ";
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);
});
});
}
diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py
index 346c8f8..391413b 100644
--- a/lib/exchange/okx_options_lib.py
+++ b/lib/exchange/okx_options_lib.py
@@ -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}
diff --git a/lib/options/options_register.py b/lib/options/options_register.py
index 60ceca7..9ced75f 100644
--- a/lib/options/options_register.py
+++ b/lib/options/options_register.py
@@ -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:
diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html
index 3ed3dc6..a9ec1b3 100644
--- a/lib/options/templates/options_panel.html
+++ b/lib/options/templates/options_panel.html
@@ -5,7 +5,7 @@
{% if not options_enabled %}
期权 API 未启用:请在 crypto_monitor_okx/.env 设置 OKX_OPTIONS_ENABLED=true 及主账户 OKX_OPTIONS_API_*,然后 pm2 restart crypto_okx --update-env。
{% endif %}
- 资金账户兑换 USDT→USDC 后,划转到交易账户即可买入。报价单位为每 1 ETH/BTC;1 张 = 0.01 ETH/BTC。
+ 资金账户兑换 USDT→USDC 后,划转到交易账户即可买入。报价单位为每 1 ETH/BTC;1 张 = 0.01 ETH/BTC。表格中点「买入」直接下单;或点「选择」后在下方确认张数再买入。
-
+
diff --git a/tests/test_options_pricing.py b/tests/test_options_pricing.py
index 5f3cab9..d110956 100644
--- a/tests/test_options_pricing.py
+++ b/tests/test_options_pricing.py
@@ -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():