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。表格中点「买入」直接下单;或点「选择」后在下方确认张数再买入。

@@ -128,4 +128,4 @@
- + 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():