diff --git a/crypto_monitor_okx/.env.example b/crypto_monitor_okx/.env.example index 52c5b9d..899c942 100644 --- a/crypto_monitor_okx/.env.example +++ b/crypto_monitor_okx/.env.example @@ -113,6 +113,7 @@ OKX_OPTIONS_TRADE_BUDGET_USDC=10 OKX_OPTIONS_BUDGET_BUFFER=0.95 OKX_OPTIONS_DEFAULT_UNDERLY=ETH OKX_OPTIONS_MAX_DTE_DAYS=2 +OKX_OPTIONS_CHAIN_MAX_DTE_DAYS=14 OKX_OPTIONS_ITM_MAX_DIST_USD=30 OKX_OPTIONS_PROFIT_ALERT_RATIO=1.0 OKX_OPTIONS_POLL_SECONDS=15 diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css index 9859d93..b9af670 100644 --- a/lib/common/static/instance_theme.css +++ b/lib/common/static/instance_theme.css @@ -2401,4 +2401,30 @@ html[data-theme="light"] .settings-export-link { .opt-row-actions .btn-secondary { margin-right: 4px; } +.opt-moneyness { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 0.78rem; + font-weight: 600; +} +.opt-moneyness-itm { + color: #7ee787; + background: rgba(46, 160, 67, 0.15); +} +.opt-moneyness-otm { + color: #a8b3cf; + background: rgba(136, 146, 176, 0.12); +} +.opt-moneyness-atm { + color: #ffd166; + background: rgba(255, 209, 102, 0.12); +} +.options-order-mode-row { + flex-wrap: wrap; + gap: 8px; +} +.options-order-mode-row input[type="number"] { + width: 88px; +} diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index ea43222..31d76ee 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -29,6 +29,38 @@ } } + function currentSizeMode() { + const el = document.querySelector('input[name="opt-size-mode"]:checked'); + return el ? el.value : "sheets"; + } + + function updateSizeInputs() { + const mode = currentSizeMode(); + const sheetsEl = document.getElementById("opt-sheets-amount"); + const ethEl = document.getElementById("opt-eth-amount"); + if (sheetsEl) sheetsEl.style.display = mode === "sheets" ? "" : "none"; + if (ethEl) ethEl.style.display = mode === "eth_amount" ? "" : "none"; + } + + function quoteUrl(instId) { + const mode = currentSizeMode(); + let url = "/api/options/quote?inst_id=" + encodeURIComponent(instId) + "&mode=" + mode; + if (mode === "eth_amount") { + const eth = document.getElementById("opt-eth-amount").value; + if (eth) url += "ð_amount=" + encodeURIComponent(eth); + } else if (mode === "sheets") { + const sheets = document.getElementById("opt-sheets-amount").value; + if (sheets) url += "&sheets=" + encodeURIComponent(sheets); + } + return url; + } + + function moneynessBadge(c) { + const m = (c && c.moneyness) || ""; + const label = (c && c.moneyness_label) || "—"; + return '' + label + ""; + } + async function refreshBalances() { const d = await apiJson("/api/options/balances"); if (!d.ok) return; @@ -42,7 +74,11 @@ function expLabel(ms) { try { - return new Date(Number(ms)).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }); + const dt = new Date(Number(ms)); + const now = Date.now(); + const dte = Math.max(0, Math.ceil((Number(ms) - now) / 86400000)); + const base = dt.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }); + return base + " · " + dte + "D"; } catch (e) { return String(ms); } @@ -58,8 +94,14 @@ o.textContent = expLabel(e.exp_time) + " (" + e.contracts.length + ")"; sel.appendChild(o); }); + const idx = state.chain && state.chain.index_px; + const dte = state.chain && state.chain.chain_max_dte_days; + if (dte != null) { + const el = document.getElementById("opt-chain-dte"); + if (el) el.textContent = String(Math.round(dte)); + } document.getElementById("opt-index-line").textContent = - "指数 " + state.underlying + " ≈ " + fmt(state.chain && state.chain.index_px, 2); + "指数 " + state.underlying + " ≈ " + fmt(idx, 2) + " · 实值=价内 · 虚值=价外"; } function renderStrikes() { @@ -67,7 +109,7 @@ const expMs = document.getElementById("opt-exp-select").value; tbody.innerHTML = ""; if (!expMs || !state.chain) { - tbody.innerHTML = '请选择到期日'; + tbody.innerHTML = '请选择到期日'; return; } const exp = (state.chain.expiries || []).find(function (e) { @@ -78,13 +120,15 @@ return c.opt_type === state.optType; }); if (!list.length) { - tbody.innerHTML = '无符合的实值合约'; + tbody.innerHTML = '该到期日暂无报价'; return; } list.forEach(function (c) { const tr = document.createElement("tr"); + if (c.moneyness) tr.classList.add("opt-row-" + c.moneyness); tr.innerHTML = "" + c.strike + "" + + "" + moneynessBadge(c) + "" + "" + c.inst_id + "" + "" + fmt(c.ask, 4) + "" + "" + fmt(c.bid, 4) + "" + @@ -109,13 +153,7 @@ async function selectContract(instId) { state.selectedInst = instId; - const mode = document.querySelector('input[name="opt-size-mode"]:checked').value; - const ethInput = document.getElementById("opt-eth-amount"); - let url = "/api/options/quote?inst_id=" + encodeURIComponent(instId) + "&mode=" + mode; - if (mode === "eth_amount" && ethInput.value) { - url += "ð_amount=" + encodeURIComponent(ethInput.value); - } - const d = await apiJson(url); + const d = await apiJson(quoteUrl(instId)); const panel = document.getElementById("opt-order-panel"); panel.style.display = ""; document.getElementById("opt-order-inst").textContent = instId; @@ -158,7 +196,7 @@ const btn = document.getElementById("opt-open-btn"); btn.disabled = true; try { - const mode = document.querySelector('input[name="opt-size-mode"]:checked').value; + const mode = currentSizeMode(); const body = { inst_id: state.selectedInst, mode: mode, @@ -166,6 +204,8 @@ }; if (mode === "eth_amount") { body.eth_amount = parseFloat(document.getElementById("opt-eth-amount").value); + } else if (mode === "sheets") { + body.sheets = parseInt(document.getElementById("opt-sheets-amount").value, 10); } const d = await apiJson("/api/options/open", { method: "POST", @@ -187,7 +227,7 @@ } async function closePosition(inst, btn) { - const q = await apiJson("/api/options/quote?inst_id=" + encodeURIComponent(inst) + "&mode=budget_full"); + const q = await apiJson("/api/options/quote?inst_id=" + encodeURIComponent(inst) + "&mode=sheets&sheets=1"); if (!q.ok) { alert(q.msg || "获取买一价失败"); return; @@ -272,8 +312,18 @@ document.querySelectorAll('input[name="opt-size-mode"]').forEach(function (r) { r.addEventListener("change", function () { - document.getElementById("opt-eth-amount").style.display = - r.value === "eth_amount" && r.checked ? "" : "none"; + updateSizeInputs(); + if (state.selectedInst) selectContract(state.selectedInst); + }); + }); + + ["opt-sheets-amount", "opt-eth-amount"].forEach(function (id) { + const el = document.getElementById(id); + if (!el) return; + el.addEventListener("change", function () { + if (state.selectedInst) selectContract(state.selectedInst); + }); + el.addEventListener("input", function () { if (state.selectedInst) selectContract(state.selectedInst); }); }); @@ -331,6 +381,7 @@ refreshBalances(); }); + updateSizeInputs(); refreshBalances(); loadChain(); refreshPositions(); diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py index 43d75f3..0b986d0 100644 --- a/lib/exchange/okx_options_lib.py +++ b/lib/exchange/okx_options_lib.py @@ -7,7 +7,7 @@ from typing import Any, Callable import ccxt -from lib.options.options_pricing_lib import is_shallow_itm +from lib.options.options_pricing_lib import is_shallow_itm, option_moneyness, option_moneyness_label def create_options_exchange( @@ -251,6 +251,7 @@ def build_option_chain( bid = _safe_float(t.get("bidPx")) if ask is None and bid is None: continue + mny = option_moneyness(opt_type=opt_type, strike=strike, index_px=idx) exp_key = str(exp_ms) expiries.setdefault(exp_key, []).append( { @@ -260,6 +261,8 @@ def build_option_chain( "exp_time": exp_ms, "ask": ask, "bid": bid, + "moneyness": mny, + "moneyness_label": option_moneyness_label(mny), "ct_mult": _safe_float(meta.get("ctMult")) or 0.01, "tick_sz": meta.get("tickSz"), "min_sz": int(_safe_float(meta.get("minSz")) or 1), diff --git a/lib/options/options_pricing_lib.py b/lib/options/options_pricing_lib.py index 882fdfe..6d1421b 100644 --- a/lib/options/options_pricing_lib.py +++ b/lib/options/options_pricing_lib.py @@ -50,16 +50,19 @@ def calc_order_size( budget_usdc: float | None = None, budget_buffer: float = 0.95, eth_amount: float | None = None, + sheets: int | None = None, budget_cap: float | None = None, ) -> dict[str, Any]: """ 返回 sheets, eth_amount, total_premium。 - mode: budget_full 或 eth_amount。 + mode: budget_full / eth_amount / sheets。 """ if quote_per_unit <= 0: return {"ok": False, "msg": "卖一价无效", "sheets": 0, "eth_amount": 0.0, "total_premium": 0.0} - if eth_amount is not None and eth_amount > 0: + if sheets is not None and int(sheets) > 0: + sheets = int(sheets) + elif eth_amount is not None and eth_amount > 0: sheets = sheets_from_eth_amount(eth_amount, ct_mult) elif budget_usdc is not None and budget_usdc > 0: eff = float(budget_usdc) * float(budget_buffer) @@ -68,7 +71,7 @@ def calc_order_size( return {"ok": False, "msg": "无法计算单张权利金", "sheets": 0, "eth_amount": 0.0, "total_premium": 0.0} sheets = int(math.floor(eff / per_sheet)) else: - return {"ok": False, "msg": "请指定预算或 ETH 数量", "sheets": 0, "eth_amount": 0.0, "total_premium": 0.0} + return {"ok": False, "msg": "请指定预算、币数量或张数", "sheets": 0, "eth_amount": 0.0, "total_premium": 0.0} if sheets < min_sz: per = premium_per_sheet(quote_per_unit, ct_mult) @@ -110,3 +113,22 @@ def is_shallow_itm( return False return (strike - index_px) <= max_dist_usd return False + + +def option_moneyness(*, opt_type: str, strike: float, index_px: float) -> str: + """返回 itm / otm / atm。""" + o = (opt_type or "").upper() + if strike is None or index_px is None or index_px <= 0: + return "unknown" + atm_band = max(index_px * 0.002, 2.0) + if abs(strike - index_px) <= atm_band: + return "atm" + if o == "C": + return "itm" if strike < index_px else "otm" + if o == "P": + return "itm" if strike > index_px else "otm" + return "unknown" + + +def option_moneyness_label(moneyness: str) -> str: + return {"itm": "实值", "otm": "虚值", "atm": "平值"}.get((moneyness or "").lower(), "") diff --git a/lib/options/options_register.py b/lib/options/options_register.py index ae703cf..81a969a 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -84,6 +84,7 @@ def _build_cfg(app_module: Any) -> dict[str, Any]: "budget_buffer": _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95), "default_underly": (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper(), "max_dte_days": _env_float("OKX_OPTIONS_MAX_DTE_DAYS", 2.0), + "chain_max_dte_days": _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", 14.0), "itm_max_dist": _env_float("OKX_OPTIONS_ITM_MAX_DIST_USD", 30.0), "td_mode": (os.getenv("OKX_OPTIONS_TD_MODE") or "cross").strip(), "allow_market_close": _env_bool("OKX_OPTIONS_ALLOW_MARKET_CLOSE", False), @@ -136,11 +137,11 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: chain = cfg["build_option_chain"]( ex, u, - max_dte_days=cfg["max_dte_days"], - itm_only=True, + max_dte_days=cfg["chain_max_dte_days"], + itm_only=False, itm_max_dist_usd=cfg["itm_max_dist"], ) - return jsonify({"ok": True, **chain}) + return jsonify({"ok": True, **chain, "chain_max_dte_days": cfg["chain_max_dte_days"]}) @app.route("/api/options/quote") @lr @@ -160,20 +161,27 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: mode = (request.args.get("mode") or "budget_full").strip() budget = cfg["trade_budget"] eth_amount = None + sheet_count = None try: if request.args.get("eth_amount"): eth_amount = float(request.args.get("eth_amount")) except (TypeError, ValueError): pass + try: + if request.args.get("sheets"): + sheet_count = int(request.args.get("sheets")) + except (TypeError, ValueError): + pass if ask is None or ask <= 0: return jsonify({**q, "ok": False, "msg": "暂无卖一价"}) sizing = calc_order_size( quote_per_unit=float(ask), ct_mult=float(ct_mult), min_sz=int(min_sz), - budget_usdc=budget if mode != "eth_amount" else None, + budget_usdc=budget if mode == "budget_full" else None, budget_buffer=cfg["budget_buffer"], eth_amount=eth_amount if mode == "eth_amount" else None, + sheets=sheet_count if mode == "sheets" else None, budget_cap=cfg["trade_budget"], ) return jsonify( @@ -206,18 +214,25 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: ct_mult = float(q.get("ct_mult") or 0.01) min_sz = int(q.get("min_sz") or 1) eth_amount = None + sheet_count = None if mode == "eth_amount": try: eth_amount = float(data.get("eth_amount")) except (TypeError, ValueError): return jsonify({"ok": False, "msg": "ETH 数量无效"}) + elif mode == "sheets": + try: + sheet_count = int(data.get("sheets")) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "张数无效"}) sizing = calc_order_size( quote_per_unit=float(ask), ct_mult=ct_mult, min_sz=min_sz, - budget_usdc=cfg["trade_budget"] if mode != "eth_amount" else None, + budget_usdc=cfg["trade_budget"] if mode == "budget_full" else None, budget_buffer=cfg["budget_buffer"], eth_amount=eth_amount, + sheets=sheet_count, budget_cap=cfg["trade_budget"], ) if not sizing.get("ok"): diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html index a9ec1b3..fccf3e5 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。期权链展示近 14 日到期合约,标注实值/虚值;下单可指定张数。

@@ -72,6 +72,7 @@ 行权价 + 类型 合约 卖一 买一 @@ -79,7 +80,7 @@ - 请选择到期日 + 请选择到期日
@@ -94,8 +95,10 @@
ETH/BTC 数量
预估权利金
-
- +
+ + + @@ -128,4 +131,4 @@
- + diff --git a/tests/test_options_pricing.py b/tests/test_options_pricing.py index 8ee4fa1..4cf1f0b 100644 --- a/tests/test_options_pricing.py +++ b/tests/test_options_pricing.py @@ -45,6 +45,28 @@ def test_calc_order_size_budget(): assert r["total_premium"] <= 10 +def test_calc_order_size_sheets(): + r = calc_order_size( + quote_per_unit=15.6, + ct_mult=0.01, + min_sz=1, + sheets=3, + budget_cap=10, + ) + assert r["ok"] is True + assert r["sheets"] == 3 + assert abs(r["total_premium"] - 0.468) < 1e-9 + + +def test_option_moneyness(): + from lib.options.options_pricing_lib import option_moneyness, option_moneyness_label + + assert option_moneyness(opt_type="C", strike=1700, index_px=1800) == "itm" + assert option_moneyness(opt_type="C", strike=1900, index_px=1800) == "otm" + assert option_moneyness_label("itm") == "实值" + assert option_moneyness_label("otm") == "虚值" + + def test_calc_order_size_too_small(): r = calc_order_size( quote_per_unit=2000.0,