From 090b9c6215891562d7f194ec99c8d8a2caef7aca Mon Sep 17 00:00:00 2001 From: dekun Date: Tue, 7 Jul 2026 09:36:00 +0800 Subject: [PATCH] feat: restructure OKX options UI with header funds, settings card, and dual-column layout Co-authored-by: Cursor --- crypto_monitor_okx/.env.example | 1 + crypto_monitor_okx/app.py | 31 ++- lib/common/static/instance_theme.css | 58 +++++ lib/common/static/options_panel.js | 182 +++++++-------- lib/common/static/options_settings.js | 73 ++++++ lib/exchange/okx_options_lib.py | 89 ++++++++ lib/instance/instance_embed_context_lib.py | 11 +- lib/instance/instance_settings_lib.py | 16 +- .../templates/embed_boot_scripts.html | 5 + lib/instance/templates/index.html | 7 +- .../templates/instance_header_panel.html | 6 + lib/instance/templates/settings_panel.html | 7 + lib/options/options_register.py | 88 ++++++++ lib/options/templates/options_panel.html | 209 +++++++----------- .../templates/options_settings_panel.html | 61 +++++ 15 files changed, 619 insertions(+), 225 deletions(-) create mode 100644 lib/common/static/options_settings.js create mode 100644 lib/options/templates/options_settings_panel.html diff --git a/crypto_monitor_okx/.env.example b/crypto_monitor_okx/.env.example index 899c942..82a90a2 100644 --- a/crypto_monitor_okx/.env.example +++ b/crypto_monitor_okx/.env.example @@ -114,6 +114,7 @@ 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_SUB_ACCOUNT_NAME= OKX_OPTIONS_ITM_MAX_DIST_USD=30 OKX_OPTIONS_PROFIT_ALERT_RATIO=1.0 OKX_OPTIONS_POLL_SECONDS=15 diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index 8222161..5388233 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -343,6 +343,7 @@ OKX_OPTIONS_API_SECRET = os.getenv("OKX_OPTIONS_API_SECRET", "") OKX_OPTIONS_API_PASSPHRASE = os.getenv("OKX_OPTIONS_API_PASSPHRASE", "") OKX_OPTIONS_TRADE_BUDGET_USDC = float(os.getenv("OKX_OPTIONS_TRADE_BUDGET_USDC", "10")) OKX_OPTIONS_DEFAULT_UNDERLY = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() +OKX_SUB_ACCOUNT_NAME = (os.getenv("OKX_SUB_ACCOUNT_NAME") or "").strip() OKX_TD_MODE = os.getenv("OKX_TD_MODE", "cross") OKX_POS_MODE = os.getenv("OKX_POS_MODE", "hedge") EXCHANGE_DISPLAY_NAME = (os.getenv("EXCHANGE_DISPLAY_NAME") or "OKX").strip() or "OKX" @@ -6505,6 +6506,14 @@ def render_main_page(page="trade", embed_mode=None): funding_capital, trading_capital = None, None funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None current_capital = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else round(local_current_capital, FUNDS_DECIMALS) + options_trading_usdc = None + if OKX_OPTIONS_ENABLED and exchange_options.apiKey: + try: + from lib.exchange.okx_options_lib import fetch_options_trading_usdc + + options_trading_usdc = fetch_options_trading_usdc(exchange_options) + except Exception: + options_trading_usdc = None recommended_capital = get_recommended_capital(current_capital) key_list = ( conn.execute("SELECT * FROM key_monitors").fetchall() if plan.key_list else [] @@ -6622,7 +6631,8 @@ def render_main_page(page="trade", embed_mode=None): total=total, rate=rate, profit_loss_ratio=profit_loss_ratio, - total_funds=total_funds_usdt(funding_usdt, current_capital), + total_funds=total_funds_usdt(funding_usdt, current_capital, options_trading_usdc), + options_trading_usdc=options_trading_usdc, trading_day=trading_day, daily_start_capital=DAILY_START_CAPITAL, current_capital=current_capital, @@ -6782,6 +6792,14 @@ def api_account_snapshot(): funding_capital, trading_capital = get_exchange_capitals(force=True) funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None current_capital = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else round(local_current_capital, FUNDS_DECIMALS) + options_trading_usdc = None + if OKX_OPTIONS_ENABLED and exchange_options.apiKey: + try: + from lib.exchange.okx_options_lib import fetch_options_trading_usdc + + options_trading_usdc = fetch_options_trading_usdc(exchange_options) + except Exception: + options_trading_usdc = None recommended_capital = get_recommended_capital(current_capital) from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors @@ -6824,7 +6842,8 @@ def api_account_snapshot(): return jsonify({ "funding_usdt": funding_usdt, "current_capital": current_capital, - "total_funds": total_funds_usdt(funding_usdt, current_capital), + "options_trading_usdc": options_trading_usdc, + "total_funds": total_funds_usdt(funding_usdt, current_capital, options_trading_usdc), "available_trading_usdt": round(available_trading_usdt, FUNDS_DECIMALS) if available_trading_usdt is not None else None, "unrealized_pnl": unrealized_pnl, "recommended_capital": recommended_capital, @@ -8835,6 +8854,7 @@ _AI_REVIEW_RENDER_JS = os.path.join(_REPO_STATIC_DIR, "ai_review_render.js") _FORM_SUBMIT_GUARD_JS = os.path.join(_REPO_STATIC_DIR, "form_submit_guard.js") _MANUAL_ORDER_RR_PREVIEW_JS = os.path.join(_REPO_STATIC_DIR, "manual_order_rr_preview.js") _OPTIONS_PANEL_JS = os.path.join(_REPO_STATIC_DIR, "options_panel.js") +_OPTIONS_SETTINGS_JS = os.path.join(_REPO_STATIC_DIR, "options_settings.js") @app.route("/static/ai_review_render.js") @@ -8865,6 +8885,13 @@ def static_options_panel_js(): return send_file(_OPTIONS_PANEL_JS, mimetype="application/javascript; charset=utf-8") +@app.route("/static/options_settings.js") +def static_options_settings_js(): + if not os.path.isfile(_OPTIONS_SETTINGS_JS): + return Response("not found", status=404, mimetype="text/plain; charset=utf-8") + return send_file(_OPTIONS_SETTINGS_JS, mimetype="application/javascript; charset=utf-8") + + @app.route("/export/review_md/") @login_required def export_review_md(rid): diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css index 2f1bd6a..608a771 100644 --- a/lib/common/static/instance_theme.css +++ b/lib/common/static/instance_theme.css @@ -2458,4 +2458,62 @@ html[data-theme="light"] .settings-export-link { .options-order-mode-row input[type="number"] { width: 88px; } +.options-dual-grid { + display: grid; + grid-template-columns: 1.15fr 0.85fr; + gap: 16px; + align-items: start; +} +@media (max-width: 1100px) { + .options-dual-grid { + grid-template-columns: 1fr; + } +} +.options-order-card h2, +.options-pos-card-wrap h2 { + margin: 0 0 10px; +} +.options-pos-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; +} +.options-pos-head h2 { + margin: 0; +} +.options-pos-tabs { + display: flex; + gap: 8px; + margin-bottom: 10px; +} +.opt-pos-tab.active { + border-color: #5b8cff; + color: #cfe0ff; + background: rgba(74, 124, 255, 0.28); +} +.options-pos-pane { + min-height: 200px; +} +.opt-pos-card { + margin-bottom: 10px; +} +.options-settings-block { + margin-bottom: 14px; +} +.options-settings-row { + flex-wrap: wrap; + gap: 8px; +} +.options-settings-hint { + font-size: 0.82rem; + margin: 0 0 8px; +} +.pos-pnl-profit { + color: #7ee787; +} +.pos-pnl-loss { + color: #ff8b8b; +} diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index 0b04a1b..67bcf6f 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -9,7 +9,7 @@ optType: "C", chain: null, selectedInst: null, - convertQuoteId: null, + posTab: "live", }; function fmt(v, d) { @@ -55,20 +55,17 @@ if (!panel || !instId) return; const row = document.querySelector('#opt-strike-tbody tr.opt-strike-row[data-inst="' + CSS.escape(instId) + '"]'); if (!row) return; - document.querySelectorAll(".opt-strike-row").forEach(function (r) { r.classList.toggle("opt-row-selected", r === row); }); - document.querySelectorAll(".opt-pick-btn").forEach(function (b) { - const on = b.getAttribute("data-inst") === instId; - b.classList.toggle("active", on); - if (!b.dataset.origText) b.dataset.origText = b.textContent; - if (on) b.textContent = "已选"; + document.querySelectorAll(".opt-pick-btn").forEach(function (btn) { + const on = btn.getAttribute("data-inst") === instId; + btn.classList.toggle("active", on); + if (!btn.dataset.origText) btn.dataset.origText = btn.textContent; + if (on) btn.textContent = "已选"; }); - const oldInline = document.querySelector(".opt-order-inline-row"); if (oldInline) oldInline.remove(); - const tr = document.createElement("tr"); tr.className = "opt-order-inline-row"; const td = document.createElement("td"); @@ -113,15 +110,8 @@ return '' + label + ""; } - async function refreshBalances() { - const d = await apiJson("/api/options/balances"); - if (!d.ok) return; - document.getElementById("opt-funding-usdt").textContent = fmt(d.funding_usdt) + " U"; - document.getElementById("opt-funding-usdc").textContent = fmt(d.funding_usdc) + " U"; - document.getElementById("opt-trading-usdt").textContent = fmt(d.trading_usdt) + " U"; - document.getElementById("opt-trading-usdc").textContent = fmt(d.trading_usdc) + " U"; - document.getElementById("opt-trading-usdg").textContent = fmt(d.trading_usdg) + " U"; - document.getElementById("opt-trade-budget").textContent = fmt(d.trade_budget) + " USDC"; + function optTypeLabel(t) { + return (t || "").toUpperCase() === "P" ? "看跌 Put" : "看涨 Call"; } function expLabel(ms) { @@ -299,8 +289,8 @@ msgEl.textContent = d.ok ? "下单已提交,可在 OKX 委托中查看" : (d.msg || "失败"); msgEl.classList.toggle("opt-error", !d.ok); if (d.ok) { - refreshBalances(); refreshPositions(); + if (typeof refreshAccountSnapshot === "function") refreshAccountSnapshot(); } else { alert(d.msg || "下单失败"); } @@ -309,6 +299,32 @@ } } + function renderPositionCard(p) { + const upl = p.upl; + const uplCls = upl > 0 ? "pos-pnl-profit" : upl < 0 ? "pos-pnl-loss" : ""; + const sideCls = (p.opt_type || "").toUpperCase() === "P" ? "pos-side-short" : "pos-side-long"; + return ( + '
' + + '
' + + '
' + (p.inst_id || "") + '' + + '' + optTypeLabel(p.opt_type) + "
" + + '
' + + '' + + "
" + + '
' + + '行权价: ' + fmt(p.strike, 0) + "" + + '张数: ' + fmt(p.pos, 0) + " · 币量 " + fmt(p.eth_amount, 4) + "" + + "
" + + '
' + + '
开仓均价' + fmt(p.avg_px, 4) + "
" + + '
标记价' + fmt(p.mark_px, 4) + "
" + + '
浮盈亏' + fmt(p.upl, 4) + "
" + + '
收益率' + + (p.upl_ratio_pct != null ? p.upl_ratio_pct + "%" : "—") + "
" + + "
" + ); + } + async function closePosition(inst, btn) { const q = await apiJson("/api/options/quote?inst_id=" + encodeURIComponent(inst) + "&mode=sheets&sheets=1"); if (!q.ok) { @@ -334,7 +350,8 @@ alert(r.msg || "平仓失败"); } refreshPositions(); - refreshBalances(); + refreshHistory(); + if (typeof refreshAccountSnapshot === "function") refreshAccountSnapshot(); } finally { if (btn) btn.disabled = false; } @@ -342,33 +359,62 @@ async function refreshPositions() { const d = await apiJson("/api/options/positions"); - const tbody = document.getElementById("opt-positions-tbody"); - tbody.innerHTML = ""; + const wrap = document.getElementById("opt-pos-cards"); + const empty = document.getElementById("opt-pos-empty"); const list = (d.ok && d.positions) || []; + wrap.innerHTML = ""; if (!list.length) { - tbody.innerHTML = '暂无持仓'; + empty.style.display = ""; return; } + empty.style.display = "none"; list.forEach(function (p) { - const tr = document.createElement("tr"); - tr.innerHTML = - "" + (p.inst_id || "") + "" + - "" + fmt(p.pos, 0) + "" + - "" + fmt(p.eth_amount, 4) + "" + - "" + fmt(p.avg_px, 4) + "" + - "" + fmt(p.mark_px, 4) + "" + - "" + fmt(p.upl, 4) + "" + - "" + (p.upl_ratio_pct != null ? p.upl_ratio_pct + "%" : "—") + "" + - ''; - tbody.appendChild(tr); + const div = document.createElement("div"); + div.innerHTML = renderPositionCard(p); + wrap.appendChild(div.firstChild); }); - tbody.querySelectorAll(".opt-close-btn").forEach(function (btn) { + wrap.querySelectorAll(".opt-close-btn").forEach(function (btn) { btn.addEventListener("click", function () { closePosition(btn.getAttribute("data-inst"), btn); }); }); } + async function refreshHistory() { + const d = await apiJson("/api/options/history"); + const tbody = document.getElementById("opt-history-tbody"); + tbody.innerHTML = ""; + const list = (d.ok && d.history) || []; + if (!list.length) { + tbody.innerHTML = '暂无历史记录'; + return; + } + list.forEach(function (h) { + const tr = document.createElement("tr"); + const prem = h.status === "closed" ? h.premium_received : h.premium_paid; + const pnl = h.realized_pnl; + const pnlTxt = pnl != null ? fmt(pnl, 4) : "—"; + tr.innerHTML = + "" + (h.inst_id || "") + "" + + "" + fmt(h.sheets, 0) + "" + + "" + fmt(prem, 4) + "" + + "" + (h.status === "closed" ? "已平" : "持仓中") + "" + + "" + pnlTxt + "" + + "" + (h.closed_at || h.created_at || "—") + ""; + tbody.appendChild(tr); + }); + } + + function switchPosTab(tab) { + state.posTab = tab; + document.querySelectorAll(".opt-pos-tab").forEach(function (b) { + b.classList.toggle("active", b.getAttribute("data-tab") === tab); + }); + document.getElementById("opt-pos-live").hidden = tab !== "live"; + document.getElementById("opt-pos-history").hidden = tab !== "history"; + if (tab === "history") refreshHistory(); + } + document.querySelectorAll(".opt-uly-btn").forEach(function (btn) { btn.addEventListener("click", function () { document.querySelectorAll(".opt-uly-btn").forEach(function (b) { b.classList.remove("active"); }); @@ -387,10 +433,18 @@ }); }); + document.querySelectorAll(".opt-pos-tab").forEach(function (btn) { + btn.addEventListener("click", function () { + switchPosTab(btn.getAttribute("data-tab")); + }); + }); + document.getElementById("opt-exp-select").addEventListener("change", renderStrikes); document.getElementById("opt-load-chain").addEventListener("click", loadChain); - document.getElementById("opt-refresh-balances").addEventListener("click", refreshBalances); - document.getElementById("opt-refresh-positions").addEventListener("click", refreshPositions); + document.getElementById("opt-refresh-positions").addEventListener("click", function () { + refreshPositions(); + if (state.posTab === "history") refreshHistory(); + }); document.getElementById("opt-open-btn").addEventListener("click", openPosition); document.querySelectorAll('input[name="opt-size-mode"]').forEach(function (r) { @@ -408,61 +462,7 @@ }); }); - document.getElementById("opt-convert-quote-btn").addEventListener("click", async function () { - const amount = parseFloat(document.getElementById("opt-convert-amount").value); - if (!amount || amount <= 0) { - alert("请输入 USDT 数量"); - return; - } - const d = await apiJson("/api/options/convert/quote", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ amount: amount }), - }); - const prev = document.getElementById("opt-convert-preview"); - if (!d.ok) { - prev.textContent = d.msg || "询价失败"; - state.convertQuoteId = null; - document.getElementById("opt-convert-exec-btn").disabled = true; - return; - } - state.convertQuoteId = d.quote_id; - prev.textContent = - "预估获得 " + fmt(d.base_sz, 6) + " USDC,汇率 " + fmt(d.cnvt_px, 6); - document.getElementById("opt-convert-exec-btn").disabled = false; - }); - - document.getElementById("opt-convert-exec-btn").addEventListener("click", async function () { - if (!state.convertQuoteId) return; - const amount = parseFloat(document.getElementById("opt-convert-amount").value); - const d = await apiJson("/api/options/convert/execute", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ quote_id: state.convertQuoteId, rfq_sz: amount }), - }); - document.getElementById("opt-convert-preview").textContent = d.ok ? "兑换成功" : (d.msg || "失败"); - state.convertQuoteId = null; - document.getElementById("opt-convert-exec-btn").disabled = true; - refreshBalances(); - }); - - document.getElementById("opt-transfer-btn").addEventListener("click", async function () { - const d = await apiJson("/api/options/transfer", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - ccy: document.getElementById("opt-transfer-ccy").value, - from: document.getElementById("opt-transfer-from").value, - to: document.getElementById("opt-transfer-to").value, - amount: parseFloat(document.getElementById("opt-transfer-amount").value), - }), - }); - document.getElementById("opt-transfer-msg").textContent = d.ok ? "划转成功" : (d.msg || "失败"); - refreshBalances(); - }); - updateSizeInputs(); - refreshBalances(); loadChain(); refreshPositions(); })(); diff --git a/lib/common/static/options_settings.js b/lib/common/static/options_settings.js new file mode 100644 index 0000000..2b3938e --- /dev/null +++ b/lib/common/static/options_settings.js @@ -0,0 +1,73 @@ +(function () { + "use strict"; + + const root = document.getElementById("options-settings-root"); + if (!root) return; + + async function apiJson(url, opts) { + const r = await fetch(url, Object.assign({ credentials: "same-origin" }, opts || {})); + return r.json(); + } + + function setMsg(id, text, isErr) { + const el = document.getElementById(id); + if (!el) return; + el.textContent = text || ""; + el.classList.toggle("opt-error", !!isErr); + } + + document.getElementById("opt-set-swap-btn").addEventListener("click", async function () { + const amount = parseFloat(document.getElementById("opt-set-swap-amount").value); + if (!amount || amount <= 0) { + setMsg("opt-set-swap-msg", "请输入有效数量", true); + return; + } + const d = await apiJson("/api/options/spot/swap", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + direction: document.getElementById("opt-set-swap-dir").value, + amount: amount, + }), + }); + setMsg("opt-set-swap-msg", d.ok ? "兑换单已提交" : (d.msg || "失败"), !d.ok); + }); + + document.getElementById("opt-set-int-btn").addEventListener("click", async function () { + const amount = parseFloat(document.getElementById("opt-set-int-amount").value); + if (!amount || amount <= 0) { + setMsg("opt-set-int-msg", "请输入有效数量", true); + return; + } + const d = await apiJson("/api/options/transfer", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ccy: document.getElementById("opt-set-int-ccy").value, + from: document.getElementById("opt-set-int-from").value, + to: document.getElementById("opt-set-int-to").value, + amount: amount, + }), + }); + setMsg("opt-set-int-msg", d.ok ? "划转成功" : (d.msg || "失败"), !d.ok); + }); + + document.getElementById("opt-set-cross-btn").addEventListener("click", async function () { + const amount = parseFloat(document.getElementById("opt-set-cross-amount").value); + if (!amount || amount <= 0) { + setMsg("opt-set-cross-msg", "请输入有效数量", true); + return; + } + const d = await apiJson("/api/options/cross-transfer", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ccy: document.getElementById("opt-set-cross-ccy").value, + amount: amount, + account: document.getElementById("opt-set-cross-acct").value, + direction: document.getElementById("opt-set-cross-dir").value, + }), + }); + setMsg("opt-set-cross-msg", d.ok ? "划转成功" : (d.msg || "失败"), !d.ok); + }); +})(); diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py index 0b986d0..010210a 100644 --- a/lib/exchange/okx_options_lib.py +++ b/lib/exchange/okx_options_lib.py @@ -478,6 +478,95 @@ def transfer_ccy( return {"ok": False, "msg": str(e)} +_OKX_ACCT_CODE = {"funding": "6", "trading": "18", "spot": "18"} + + +def fetch_options_trading_usdc(ex: ccxt.okx) -> float | None: + bal = fetch_options_balances(ex) + v = bal.get("trading_usdc") + if v is None: + return None + return round(float(v), 2) + + +def spot_market_swap_usdt_usdc( + ex: ccxt.okx, + *, + direction: str, + amount: float, +) -> dict[str, Any]: + """现货市价兑换 USDC-USDT。direction: usdt_to_usdc | usdc_to_usdt。""" + if amount <= 0: + return {"ok": False, "msg": "数量须大于 0"} + d = (direction or "").lower() + inst_id = "USDC-USDT" + try: + if d == "usdt_to_usdc": + body = { + "instId": inst_id, + "tdMode": "cash", + "side": "buy", + "ordType": "market", + "sz": str(amount), + "tgtCcy": "quote", + } + elif d == "usdc_to_usdt": + body = { + "instId": inst_id, + "tdMode": "cash", + "side": "sell", + "ordType": "market", + "sz": str(amount), + "tgtCcy": "base", + } + else: + return {"ok": False, "msg": "direction 须为 usdt_to_usdc 或 usdc_to_usdt"} + 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} + msg = data[0].get("sMsg") if data else str(resp) + return {"ok": False, "msg": msg or "现货兑换失败", "raw": resp} + except Exception as e: + return {"ok": False, "msg": str(e)} + + +def transfer_main_sub_account( + ex: ccxt.okx, + *, + ccy: str, + amount: float, + sub_acct: str, + main_to_sub: bool, + account: str = "funding", +) -> dict[str, Any]: + """主账户与子账户之间划转(须主账户 API)。""" + if amount <= 0: + return {"ok": False, "msg": "划转金额须大于 0"} + sub = (sub_acct or "").strip() + if not sub: + return {"ok": False, "msg": "未配置子账户名称 OKX_SUB_ACCOUNT_NAME"} + acct_code = _OKX_ACCT_CODE.get((account or "funding").lower(), "6") + try: + resp = ex.private_post_asset_transfer( + { + "type": "1" if main_to_sub else "2", + "ccy": str(ccy).upper(), + "amt": str(amount), + "from": acct_code, + "to": acct_code, + "subAcct": sub, + } + ) + data = (resp or {}).get("data") or [] + if data and str(data[0].get("sCode", "0")) == "0": + return {"ok": True, "data": data[0], "raw": resp} + msg = data[0].get("sMsg") if data else str(resp) + return {"ok": False, "msg": msg or "主/子账户划转失败", "raw": resp} + except Exception as e: + return {"ok": False, "msg": str(e)} + + def format_position_row(pos: dict[str, Any], ct_mult: float = 0.01) -> dict[str, Any]: sheets = _safe_float(pos.get("pos")) or 0.0 avg = _safe_float(pos.get("avgPx")) diff --git a/lib/instance/instance_embed_context_lib.py b/lib/instance/instance_embed_context_lib.py index 9efe145..e13ef18 100644 --- a/lib/instance/instance_embed_context_lib.py +++ b/lib/instance/instance_embed_context_lib.py @@ -85,11 +85,18 @@ def profit_loss_ratio_from_trades(trades: list[dict[str, Any]] | None) -> float return profit_loss_ratio_from_averages(avg_win, avg_loss) -def total_funds_usdt(funding_usdt: float | None, trading_usdt: float | None) -> float | None: +def total_funds_usdt( + funding_usdt: float | None, + trading_usdt: float | None, + options_trading_usdc: float | None = None, +) -> float | None: if funding_usdt is None: return None try: - return round(float(funding_usdt) + float(trading_usdt or 0), 2) + total = float(funding_usdt) + float(trading_usdt or 0) + if options_trading_usdc is not None: + total += float(options_trading_usdc) + return round(total, 2) except (TypeError, ValueError): return None diff --git a/lib/instance/instance_settings_lib.py b/lib/instance/instance_settings_lib.py index f8d2c55..7d94826 100644 --- a/lib/instance/instance_settings_lib.py +++ b/lib/instance/instance_settings_lib.py @@ -139,18 +139,21 @@ def build_instance_settings_view( opt_key = (os.getenv("OKX_OPTIONS_API_KEY") or "").strip() sections.append( { - "title": "期权账户(主账户)", + "title": "期权设置", "rows": [ _row("期权模块", "已启用"), _row( "期权 API", f"已配置(…{opt_key[-4:]})" if len(opt_key) >= 4 else "未配置", ), - _row("单笔权利金上限", f"{_env_float('OKX_OPTIONS_TRADE_BUDGET_USDC', 10):g} USDC"), _row( - "资金说明", - "资金账户 USDT 兑换 USDC 后划转到交易账户", - "期权页操作;与永续子账户资金分开", + "子账户", + (os.getenv("OKX_SUB_ACCOUNT_NAME") or "").strip() or "未配置 OKX_SUB_ACCOUNT_NAME", + "主/子账户划转用", + ), + _row( + "说明", + "币种兑换与账户划转到右侧「期权设置」卡片操作", ), ], } @@ -169,6 +172,9 @@ def build_instance_settings_view( "sections": sections, "data_export_version": int(data_export_version), "show_transfer": (exchange_key or "").strip().lower() in ("gate", "binance", "okx"), + "options_settings_enabled": (exchange_key or "").strip().lower() == "okx" + and _env_bool("OKX_OPTIONS_ENABLED", False), + "options_sub_account": (os.getenv("OKX_SUB_ACCOUNT_NAME") or "").strip(), "auto_transfer_enabled": auto_transfer_on, "auto_transfer_bj_hour": _env_int("AUTO_TRANSFER_BJ_HOUR", 8), "auto_transfer_amount": _env_float("AUTO_TRANSFER_AMOUNT", 30), diff --git a/lib/instance/templates/embed_boot_scripts.html b/lib/instance/templates/embed_boot_scripts.html index 34e38d7..ee66531 100644 --- a/lib/instance/templates/embed_boot_scripts.html +++ b/lib/instance/templates/embed_boot_scripts.html @@ -1061,6 +1061,11 @@ function refreshAccountSnapshot(){ const el = document.getElementById("current-capital"); if(el) el.innerText = `${Number(data.current_capital).toFixed(2)}U`; } + if (typeof data.options_trading_usdc !== "undefined") { + const el = document.getElementById("options-trading-usdc"); + if (el) el.innerText = (data.options_trading_usdc === null || data.options_trading_usdc === undefined) + ? "—" : `${Number(data.options_trading_usdc).toFixed(2)} USDC`; + } if (typeof data.unrealized_pnl !== "undefined") { paintRealtimePnl(data.unrealized_pnl); } diff --git a/lib/instance/templates/index.html b/lib/instance/templates/index.html index e1b3086..8af0157 100644 --- a/lib/instance/templates/index.html +++ b/lib/instance/templates/index.html @@ -68,7 +68,7 @@ {% with msg=get_flashed_messages() %}{% if msg %}
{{ msg[0] }}
{% endif %}{% endwith %} - {% if page != 'settings' and page != 'options' %} + {% if page != 'settings' %} {% include 'instance_header_panel.html' %} {% endif %} {% if page != 'settings' and page != 'options' %} @@ -1612,6 +1612,11 @@ function refreshAccountSnapshot(){ const el = document.getElementById("current-capital"); if(el) el.innerText = `${Number(data.current_capital).toFixed(2)}U`; } + if (typeof data.options_trading_usdc !== "undefined") { + const el = document.getElementById("options-trading-usdc"); + if (el) el.innerText = (data.options_trading_usdc === null || data.options_trading_usdc === undefined) + ? "—" : `${Number(data.options_trading_usdc).toFixed(2)} USDC`; + } if (typeof data.unrealized_pnl !== "undefined") { paintRealtimePnl(data.unrealized_pnl); } diff --git a/lib/instance/templates/instance_header_panel.html b/lib/instance/templates/instance_header_panel.html index 6092740..7fdab80 100644 --- a/lib/instance/templates/instance_header_panel.html +++ b/lib/instance/templates/instance_header_panel.html @@ -74,6 +74,12 @@
交易账户
{{ funds_fmt(current_capital) }}U
+ {% if options_enabled %} +
+
期权交易账户
+
{% if options_trading_usdc is not none %}{{ funds_fmt(options_trading_usdc) }} USDC{% else %}—{% endif %}
+
+ {% endif %}
实时盈亏
diff --git a/lib/instance/templates/settings_panel.html b/lib/instance/templates/settings_panel.html index a54ea22..6721f1d 100644 --- a/lib/instance/templates/settings_panel.html +++ b/lib/instance/templates/settings_panel.html @@ -39,6 +39,13 @@
+ {% if instance_settings.options_settings_enabled %} +
+

期权设置

+

主账户期权资金:现货市价兑换 USDT/USDC;主账户与子账户(永续)之间划转。

+ {% include 'options_settings_panel.html' %} +
+ {% endif %} {% if instance_settings.show_transfer %}

资金划转

diff --git a/lib/options/options_register.py b/lib/options/options_register.py index 81a969a..2dc8d8a 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -70,11 +70,14 @@ def _build_cfg(app_module: Any) -> dict[str, Any]: place_option_limit_order, place_option_market_order, quote_option_contract, + spot_market_swap_usdt_usdc, transfer_ccy, + transfer_main_sub_account, ) return { "enabled": _env_bool("OKX_OPTIONS_ENABLED", False), + "sub_account_name": (os.getenv("OKX_SUB_ACCOUNT_NAME") or "").strip(), "get_db": app_module.get_db, "login_required": app_module.login_required, "exchange_options": getattr(app_module, "exchange_options", None), @@ -101,6 +104,8 @@ def _build_cfg(app_module: Any) -> dict[str, Any]: "estimate_usdt_to_usdc": estimate_usdt_to_usdc, "execute_convert": execute_convert, "transfer_ccy": transfer_ccy, + "spot_market_swap_usdt_usdc": spot_market_swap_usdt_usdc, + "transfer_main_sub_account": transfer_main_sub_account, "options_api_ready": options_api_ready, } @@ -452,6 +457,89 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: conn.close() return jsonify(result) + @app.route("/api/options/spot/swap", methods=["POST"]) + @lr + def api_options_spot_swap(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + data = request.get_json(silent=True) or {} + direction = (data.get("direction") or "usdt_to_usdc").strip() + try: + amount = float(data.get("amount")) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "数量无效"}) + return jsonify(cfg["spot_market_swap_usdt_usdc"](ex, direction=direction, amount=amount)) + + @app.route("/api/options/cross-transfer", methods=["POST"]) + @lr + def api_options_cross_transfer(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + data = request.get_json(silent=True) or {} + ccy = (data.get("ccy") or "USDT").upper() + account = (data.get("account") or "funding").strip() + direction = (data.get("direction") or "sub_to_main").strip() + try: + amount = float(data.get("amount")) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "数量无效"}) + main_to_sub = direction == "main_to_sub" + result = cfg["transfer_main_sub_account"]( + ex, + ccy=ccy, + amount=amount, + sub_acct=cfg.get("sub_account_name") or "", + main_to_sub=main_to_sub, + account=account, + ) + if result.get("ok"): + conn = cfg["get_db"]() + try: + init_options_tables(conn) + conn.execute( + """ + INSERT INTO options_transfer_log (ccy, amount, from_account, to_account, status, message) + VALUES (?, ?, ?, ?, 'ok', ?) + """, + ( + ccy, + amount, + "main" if main_to_sub else "sub", + "sub" if main_to_sub else "main", + f"cross:{account}", + ), + ) + conn.commit() + finally: + conn.close() + return jsonify(result) + + @app.route("/api/options/history") + @lr + def api_options_history(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + conn = cfg["get_db"]() + try: + init_options_tables(conn) + rows = conn.execute( + """ + SELECT id, inst_id, underlying, opt_type, strike, sheets, eth_amount, + open_quote, premium_paid, close_quote, premium_received, + realized_pnl, status, signal_note, created_at, closed_at + FROM options_trades + ORDER BY id DESC + LIMIT 200 + """ + ).fetchall() + items = [dict(r) for r in rows] + finally: + conn.close() + return jsonify({"ok": True, "history": items}) + def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None: if app.extensions.get("options_monitor_started"): diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html index d429ea1..6a2d06b 100644 --- a/lib/options/templates/options_panel.html +++ b/lib/options/templates/options_panel.html @@ -1,135 +1,96 @@ -
-

期权(USDⓈ 本位 · 仅买方)

{% 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。期权链展示近 14 日到期合约,标注实值/虚值;下单可指定张数。

-
-
-

资金账户

-
USDT
-
USDC
-
-
-

交易账户

-
USDT
-
USDC
-
USDG
-
-
-
单笔权利金上限
- -
-
- -
-

币种兑换(资金账户 USDT → USDC)

-
- - - -
-
-
- -
-

账户划转

-
- - - - - - -
-
-
- -
-
- - - - - - -
-
-
- - - - - - - - - - - - - - -
行权价类型合约卖一买一操作
请选择到期日
-
-